Appearance
一文彻底搞懂卷积神经网络
目录
- Part 1: CNN - 为什么我们需要它。
- Part 2: 卷积层 - 如何像侦探一样提取局部特征。
- Part 3: 池化层 - 如何总结和压缩信息,获得位置不变性。
- Part 4: 全连接层 - 如何像决策者一样进行最终分类。
- Part 5: 激活函数(ReLU) - 如何引入非线性,让网络学会复杂模式。
- Part 6: Dropout - 如何防止模型“死记硬背”,提高泛化能力。
- Part 7: Softmax - 如何将最终得分转换为易于理解的概率。
- Part 8: 优化器(Adam/AdamW) - 如何驱动整个网络进行学习和优化。
第一部分:CNN 简介 (Introduction to CNNs)
【WHAT】什么是卷积神经网络?
首先,我们先用生活中的类比来建立一个直观的认识。你可以把CNN想象成我们人类大脑的视觉皮层。当我们看东西时,我们的大脑并不是一下子就认出“这是一只猫”,而是有专门的神经元(细胞)各司其职:一些神经元对光线的边缘特别敏感,另一些对特定的颜色或纹理有反应,还有一些对更复杂的形状(比如圆形或尖角)有反应。最后,大脑将这些零散的信息组合起来,才最终做出判断。
CNN正是模仿了这种生物机制。它不是一个单一的巨大网络,而是一个由多个专门处理不同视觉信息的“专家层”组成的系统,让计算机也能像我们一样“看”懂图像。
【WHY】为什么处理图像要用CNN?
你可能会问,我们已经有了标准的神经网络(也叫全连接神经网络,Fully Connected Neural Network),为什么还要发明一个专门处理图像的CNN呢?这主要是为了解决标准神经网络在处理图像时的两个致命弱点:
- 参数量过大(维度灾难):想象一张很小的彩色图片,尺寸是100x100像素。它包含的输入数据就是 100 (宽) × 100 (高) × 3 (红绿蓝三通道) = 30,000个像素值。如果使用标准神经网络,输入层的每一个神经元都要与这30,000个像素点相连。假设隐藏层有1000个神经元,那么仅第一层就会产生
30,000 * 1000 = 3000万
个参数!这会让模型变得极其庞大,难以训练。 - 空间结构信息丢失:标准神经网络在处理数据前,需要将输入的二维图像“压平”(Flatten)成一个一维的长向量。这个操作会完全破坏图像中像素之间的空间关系。比如,一个像素点和它旁边的像素点是什么关系?它们共同组成了什么形状?这些对于理解图像至关重要的信息都丢失了。而“眼睛在鼻子的上方”这类空间信息,恰恰是识别物体的关键。
CNN通过其特殊的设计,完美地解决了这两个问题。它能够高效地处理高维图像数据,并且能很好地保留和利用图像的空间结构信息。
【HOW】CNN 是如何工作的?
一个典型的CNN就像一个处理图像的流水线”。下面是它的基本工作流程:
- 输入层 (Input Layer):输入一张原始图像,比如一张小猫的照片。
- 卷积层 (Convolutional Layer):这是CNN的核心。它会用许多个特征探测器(我们称之为滤镜或卷积核)去扫描图像,提取出底层的局部特征,例如边缘、角落、颜色块等。
- 池化层 (Pooling Layer):对卷积层提取出的特征图进行压缩或降采样,目的是减少数据量,提高计算效率,并让模型对物体在图像中的位置不那么敏感。
- 全连接层 (Fully Connected Layer):在经过多次卷积和池化之后,模型已经提取出了足够丰富和抽象的特征。全连接层就像一个决策大脑,它将这些高级特征整合起来,进行分析和推理。
- 输出层 (Output Layer):最终给出一个或多个预测结果。例如,在图像分类任务中,它会输出这张图片是“猫”、“狗”或“鸟”的概率。
这个流程通常是 输入 -> [卷积 -> 池化] -> [卷积 -> 池化] -> ... -> [展平] -> 全连接 -> 输出
。我们会一层一层地学习它们。
好的,我们继续前进!现在,让我们深入到CNN的心脏——卷积层。
第二部分:卷积层 (The Convolutional Layer)
【WHAT】什么是滤镜/卷积核 (Filters/Kernels)?
我们接着用生活中的类比来理解卷积层。想象你是一位侦探,正在检查一个复杂的犯罪现场(也就是我们的图片)。你有一套特制的魔法眼镜:
- 一副眼镜专门用来高亮显示指纹(这就像一个“边缘”滤镜)。
- 另一副眼镜专门用来高亮显示脚印(这就像一个“角落”或“形状”滤镜)。
- 还有一副可以高亮显示特定的颜色(这就像一个“颜色”滤镜)。
在CNN中,滤镜(Filter) 或 卷积核(Kernel) 就扮演着这些“魔法眼镜”的角色。它是一个很小的、包含一组权重数字的矩阵(比如 3x3 或 5x5 大小)。CNN的任务就是通过训练,自动学习出成千上万个这样的滤镜,每一个滤镜都负责去图像中寻找一种特定的、有用的微小特征(feature)。
【WHY】为什么卷积是CNN的核心?
卷积操作之所以如此关键,因为它优雅地解决了我们之前提到的两大问题(参数量过大和空间结构信息丢失),并带来了三个核心优势:
- 局部特征检测 (Feature Detection):卷积操作通过在整个图像上滑动滤镜,可以系统性地检测出局部特征。无论一只猫的眼睛出现在图片的左上角还是右下角,负责检测“眼睛”这个特征的滤镜都能找到它。
- 参数共享 (Parameter Sharing):这是CNN最天才的设计!对于一个用来检测“竖直边缘”的3x3滤镜,它在扫描图片左上角时使用的权重,和扫描图片右下角时是完全一样的。这意味着我们只需要学习这一个3x3=9个参数,而不是为图片上的每个位置都学习一套新参数。这极大地减少了模型的参数量,让训练变得高效且可行。
- 保留空间层级 (Spatial Hierarchy):卷积操作是在局部区域进行的,它完整地保留了像素间的空间关系。第一层卷积可能只学习到简单的边缘和颜色块;第二层卷积则可以在第一层的基础上,将这些简单的特征组合成更复杂的特征,比如“眼睛”或“鼻子”;更深层的卷积则能组合出“猫脸”这样更高级、更抽象的概念。这种层层递进的特征提取方式,完全模拟了人类的视觉认知过程。
【HOW】卷积是如何计算的?
数学直觉 (Mathematical Intuition)
我们来看一个具体的例子。假设我们有一张5x5的黑白图片(用0和1表示像素值)和一个3x3的滤镜。
1. 输入图像 (Input Image - 5x5)
1 1 1 0 0
0 1 1 1 0
0 0 1 1 1
0 0 1 1 0
0 1 1 0 0
2. 滤镜/卷积核 (Filter/Kernel - 3x3)
假设这个滤镜是用来检测“左上-右下”方向的斜线。
1 0 1
0 1 0
1 0 1
3. 卷积操作
我们将滤镜覆盖在图像的左上角,将对应位置的数字相乘,然后把所有结果加起来。
(1*1) + (1*0) + (1*1) + (0*0) + (1*1) + (1*0) + (0*1) + (0*0) + (1*1) = 1+0+1+0+1+0+0+0+1 = 4
- 这个
4
就是我们输出的特征图 (Feature Map) 的第一个值。
4. 滑动 (Stride)
接着,我们将滤镜向右移动一个单位(这个移动的步长叫做 Stride,这里 stride=1
),重复上述计算。
(1*1) + (1*0) + (0*1) + (1*0) + (1*1) + (1*0) + (0*1) + (1*0) + (1*1) = 1+0+0+0+1+0+0+0+1 = 3
- 这个
3
就是特征图的第二个值。
5. 填充 (Padding)
你会发现,当滤镜移动到图像边缘时,它会“掉出去”。为了解决这个问题,也为了控制输出特征图的尺寸,我们可以在原始图像的周围填充一圈0(这个操作叫 Padding)。如果 padding=1
,我们的5x5图像会变成7x7,这样3x3的滤镜就能覆盖到原始图像的每一个像素了。
通过不断地滑动滤镜,直到覆盖过所有位置,我们就得到了一个完整的特征图。这个特征图上的值越高,说明该区域与滤镜所要检测的特征越匹配。
PyTorch 代码示例:
在PyTorch中,实现这个过程非常简单。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备输入数据
# PyTorch的输入需要是4维张量: (batch_size, in_channels, height, width)
# batch_size=1: 一次处理一张图片
# in_channels=1: 黑白图片(彩色为3)
# height=5, width=5: 图片尺寸
input_image = torch.tensor([
[1, 1, 1, 0, 0],
[0, 1, 1, 1, 0],
[0, 0, 1, 1, 1],
[0, 0, 1, 1, 0],
[0, 1, 1, 0, 0]
], dtype=torch.float32, device=device).view(1, 1, 5, 5)
# 2. 定义一个卷积层
# in_channels=1: 输入通道数,和上面对应
# out_channels=1: 输出通道数,代表我们想用1个滤镜
# kernel_size=3: 滤镜尺寸为3x3
# stride=1: 步长为1
# padding=0: 这里为了和上面的手动计算对应,我们先不加填充
conv_layer = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, stride=1, padding=0)
conv_layer = conv_layer.to(device)
# 我们可以手动设置卷积核的权重,以匹配我们上面的例子
custom_kernel = torch.tensor([
[1, 0, 1],
[0, 1, 0],
[1, 0, 1]
], dtype=torch.float32, device=device).view(1, 1, 3, 3)
# 将我们定义的权重赋值给卷积层
conv_layer.weight.data = custom_kernel
# 卷积层通常还有一个偏置项(bias),这里我们先忽略它,设为0
conv_layer.bias.data.fill_(0)
# 3. 执行卷积操作
output_feature_map = conv_layer(input_image)
print("--------------------------------")
print("输入图像的尺寸:")
rich.pretty.pprint(input_image.shape)
print("滤镜(卷积核)的权重:")
rich.pretty.pprint(conv_layer.weight.data.tolist())
print("--------------------------------")
print("输出特征图的尺寸:")
rich.pretty.pprint(output_feature_map.shape)
print("计算得到的输出特征图:")
rich.pretty.pprint(output_feature_map.data.tolist())
print("--------------------------------")
# 你可以看到输出的第一个值约等于4,第二个值约等于3,和我们手动计算的结果一致!
# 输出尺寸是3x3,因为 (5 - 3 + 2*0)/1 + 1 = 3
问题1:关于参数共享 (Parameter Sharing)
让我们回到刚才的侦探类比。
【WHAT】什么是参数共享?
假设你有一个专门寻找“圆形”特征的放大镜(也就是我们的滤镜)。参数共享的意思是,你始终使用同一个放大镜去检查整个犯罪现场(整张图片)。你不会因为要检查桌子左边就换一个放大镜,检查右边又换另一个。这个放大镜的“配方”(即滤镜中的权重数字)是固定的。
在CNN里,一个滤镜(比如那个3x3的矩阵)就是一组参数。这个滤镜会从图像的左上角开始,一步步地滑过(convolve)整个图像。在滑动的每一步,它都在做同样的事情:计算它覆盖区域的加权和。关键在于,无论它滑到哪里,这个滤镜自身的权重值是不变的。这就是“共享”。
【WHY】为什么要参数共享?
这主要有两个原因:
- 大幅减少参数数量:这是最直接的好处。我们再用一下那个100x100的图片例子。如果不共享参数,假设我们想在图片的每一个3x3区域都检测特征,那我们就需要为图像上几乎每一个像素点都配备一个独立的3x3滤镜,参数量会是天文数字。但通过参数共享,我们只需要学习一个3x3的滤镜(9个参数),就可以扫描完整张图片。即使我们用100个不同的滤镜来学习100种特征,也只需要
100 * (3*3) = 900
个参数,这和之前动辄几千万的参数量相比,是天壤之别。 - 让模型具有位置不变性 (Location Invariance):一个物体的特征,比如鸟的喙,不应该因为出现在图片的左上角或右下角而有所不同。既然“鸟喙”这个特征本身是一样的,那么检测它的工具(滤镜)也应该是一样的。参数共享确保了我们的模型可以在图像的任何位置识别出同一个特征,这使得模型更加健壮(robust)。
【HOW】它在代码里是如何体现的?
在PyTorch代码 nn.Conv2d(...)
中,当你定义一个卷积层时,参数共享是默认内建的机制。你定义了一个 kernel_size=3
的卷积层,PyTorch就创建了一个3x3的权重矩阵。在内部计算时,它会自动用这同一个矩阵去扫描整个输入图像。你不需要做任何额外的操作来“开启”参数共享,它就是卷积层的基本工作方式。
问题2:关于填充 (Padding)
【WHAT】什么是填充?
填充是指在输入图像的四周边缘,像加一个相框一样,填充一些额外的像素,通常填充的值是0。
【WHY】为什么要填充?
填充主要解决两个问题:
- 保持图像边界信息:如果没有填充,你看,我们的3x3滤镜在5x5的图像上滑动时,中心点永远无法到达图像的边缘像素。例如,左上角的那个像素
[0,0]
只被滤镜覆盖过一次,而中心的像素被覆盖了九次。这意味着边缘的像素信息被利用得更少,可能会导致一些重要信息的丢失。通过填充,我们可以让滤镜的中心也能对准原图的每一个像素,公平地处理边界信息。 - 控制输出特征图的尺寸:每次卷积操作后,图像的尺寸通常会变小。比如刚才5x5的图像经过3x3的卷积后,变成了3x3。如果我们连续进行很多次卷积,图像尺寸会迅速缩小,可能很快就变成1x1了,这样就无法再继续提取特征了。填充可以帮助我们维持或控制卷积后特征图的大小。
【HOW】为什么 padding=1
会让 5x5 图像变成 7x7?
padding=1
的意思是,在图像的上、下、左、右四个方向,各增加 1 层像素。
我们来看一个5x5的图像矩阵:
(5x5 Image)
A B C D E
F G H I J
K L M N O
P Q R S T
U V W X Y
当 padding=1
时,我们给它加上一个宽度为1的“0”边框:
(Padded 7x7 Image)
0 0 0 0 0 0 0 <-- 上边加了1行
0 A B C D E 0 <-- 左、右各加了1列
0 F G H I J 0
0 K L M N O 0
0 P Q R S T 0
0 U V W X Y 0
0 0 0 0 0 0 0 <-- 下边加了1行
你看,原来的5行,上下各加1行,变成了 1 + 5 + 1 = 7
行。原来的5列,左右各加1列,变成了 1 + 5 + 1 = 7
列。所以,一个5x5的图像,经过 padding=1
处理后,就变成了一个7x7的图像。
有一个常用的Padding设置叫做 "Same" Padding。它的目标是让卷积后的输出尺寸和输入尺寸保持一致。当 stride=1
时,可以通过设置 padding = (kernel_size - 1) / 2
来实现。例如,如果 kernel_size=3
,设置 padding=1
,输出尺寸就和输入一样。如果 kernel_size=5
,设置 padding=2
,也能达到同样的效果。这在设计深层网络时非常有用。
第三部分:池化层 (The Pooling Layer)
【WHAT】什么是池化?
我们继续用生活中的类比来理解池化层。想象你正在快速浏览一幅巨大的画作,比如《清明上河图》。你不会去记下每一个人的每一个细节,而是会把画分成几个区域,然后对每个区域进行一个概括性的总结。比如,你会记下:“左上角这块区域最显眼的是一座桥”,“中间这块最突出的是一艘大船”,“右边这块最热闹的是一个集市”。
池化(Pooling),特别是最常见的最大池化(Max Pooling),做的就是类似的事情。它将卷积层输出的特征图(Feature Map)分割成若干个小区域,然后从每个小区域中,只挑出那个最重要、最显著的特征值(也就是最大的那个数值),来代表整个区域。它是一种非常有力的摘要或降维工具。
【WHY】为什么我们需要池化?
在卷积层已经提取了特征之后,为什么还要加一个池化层呢?它主要有三个目的:
- 降低维度,减少计算量 (Dimensionality Reduction):卷积层产生的特征图可能仍然很大。池化层通过显著减小特征图的尺寸(通常是长和宽都减半),可以大幅度减少后续网络层的参数数量和计算负担。这让我们的模型运行得更快,也更节省内存。
- 增加感受野 (Receptive Field):这个概念稍微抽象一点。经过池化后,一个来自更深层网络的神经元,它所看到的原始图像区域会变得更大。比如,一个3x3的卷积核,经过一次2x2的池化后,它在下一层实际上等效于看到了原始图像上一个更大范围的区域。这有助于网络学习到更全局、更宏观的特征。
- 提供位置不变性 (Translation Invariance):这是池化一个非常重要的特性。因为池化只关心某个区域内是否存在某个特征(取最大值),而不关心这个特征在该区域内的具体位置。比如,在一个2x2的区域里,无论那个最强的“边缘”特征出现在左上角还是右下角,最大池化的输出都是一样的。这使得模型对于物体在图像中的轻微平移、旋转或缩放不那么敏感,从而提高了模型的鲁棒性 (robustness)。
【HOW】池化是如何计算的?
数学直觉 (Mathematical Intuition):
我们以最常用的最大池化(Max Pooling) 为例。假设我们有一个4x4的特征图,我们想用一个2x2的池化窗口(Pooling Window)和一个步长(Stride)为2来进行池化。
1. 输入特征图 (Input Feature Map - 4x4):
1 8 2 4
3 5 7 1
9 6 0 2
1 3 4 8
2. 池化操作 (Pooling Operation):
- 我们将2x2的窗口放在特征图的左上角。这个区域的数值是
[1, 8, 3, 5]
。其中最大值是 8。 - 我们将窗口向右移动2个单位(
stride=2
)。现在覆盖的区域是[2, 4, 7, 1]
。其中最大值是 7。 - 我们将窗口移回最左边,并向下移动2个单位。现在覆盖的区域是
[9, 6, 1, 3]
。其中最大值是 9。 - 最后,我们将窗口向右移动2个单位。现在覆盖的区域是
[0, 2, 4, 8]
。其中最大值是 8。
3. 输出特征图 (Output Feature Map - 2x2): 通过上面的操作,我们得到一个被“压缩”了的2x2特征图:
8 7
9 8
你看,原来的4x4特征图,经过2x2、步长为2的池化后,尺寸变成了2x2。除了最大池化,还有平均池化 (Average Pooling),它计算的是区域内的平均值,而不是最大值。不过在现代的CNN图像分类任务中,最大池化用得更普遍。
PyTorch 代码示例:
在PyTorch中,实现池化层同样非常直接。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备输入数据
# 假设这是从卷积层输出的一个4x4特征图
# (batch_size, channels, height, width)
feature_map = torch.tensor([
[1, 8, 2, 4],
[3, 5, 7, 1],
[9, 6, 0, 2],
[1, 3, 4, 8]
], dtype=torch.float32, device=device).view(1, 1, 4, 4)
# 2. 定义一个最大池化层
# 池化窗口大小为2x2,步长为2
pool_layer = nn.MaxPool2d(kernel_size=2, stride=2)
pool_layer = pool_layer.to(device)
# 5. 执行池化操作
output_map = pool_layer(feature_map)
print("--------------------------------")
print("输入特征图的尺寸:")
rich.pretty.pprint(feature_map.shape)
print("输入特征图:")
rich.pretty.pprint(feature_map.data.tolist())
print("--------------------------------")
print("池化后的输出特征图的尺寸:")
rich.pretty.pprint(output_map.shape)
rich.pretty.pprint(output_map.data.tolist())
print("--------------------------------")
# 输出结果和我们手动计算的完全一致!
再探感受野 (Receptive Field)
【WHAT】什么是感受野?
生活中的类比: 想象一下你正在用一堆乐高积木拼一个房子。
- 第一层(基层员工):你让一群人(第一层卷积核)各自负责把两个小积木块拼在一起。张三拼了一个红蓝组合,李四拼了一个黄绿组合。此时,张三的“感受野”就是他手里的那两个积木块。他只知道这两个积木块的信息。
- 第二层(小组长):现在,你让小组长(第二层卷积核)来工作。王五的任务是把张三的“红蓝组合”和李四的“黄绿组合”拼在一起,形成一个“红蓝黄绿”的四块积木片。对于王五来说,他直接接触的是两个“组合”,但他的决策实际上是基于那四个最原始的积木块。所以,王五这个小组长的“感受野”就是那四个原始积木块。他的视野比基层员工更广。
- 第三层(项目经理):最后,项目经理(第三层卷积核)把王五的“四块积木片”和另一个小组长赵六的“四块积木片”组合起来,可能就拼成了一面墙。这个项目经理的“感受野”就扩大到了八个原始积木块。
在CNN中,感受野就是指输出特征图上的一个像素点,对应回输入图像上有多大的区域。换句话说,就是输出层的一个神经元“能看到”原始图像的多大范围。
【WHY】为什么感受野很重要?
感受野的大小决定了我们的网络能看到多宏观的模式。
- 小的感受野:在网络的浅层,感受野很小(比如3x3或5x5)。这很适合学习一些局部、细节的特征,比如边缘、颜色、纹理。就像基层员工只能看到两个积木块。
- 大的感受野:随着网络层数的加深,感受野会逐层增大。这使得深层网络能够将浅层学到的简单特征组合起来,形成更复杂、更抽象、更具全局性的概念。比如,网络深层的一个神经元,可能因为它巨大的感受野覆盖了原始图像中的眼睛、鼻子和嘴巴区域,从而能被激活来识别“人脸”。就像项目经理能看到一面墙,而不是零散的积木。
没有逐层增大的感受野,CNN就永远只能看到“零件”,而无法识别出“整体”。
【HOW】池化层如何帮助增大感受野?
池化层是增大感受野最有效的方式。我们用一个直观的图例来说明:
假设我们有一个输入图像,和一个3x3的卷积核(Conv1
)。
Conv1
输出的特征图(Feature Map 1)上一个像素点,它的感受野是输入图像上的一个 3x3 区域。
现在,我们对 Feature Map 1 进行一次 2x2 的最大池化(MaxPool
),步长为2。
- 池化后的特征图(Pooled Map)尺寸减半。Pooled Map 上的一个像素点,因为它是由 Feature Map 1 上的一个 2x2 区域取最大值得到的,所以它继承了这整个 2x2 区域的信息。
- 而这 2x2 区域中的每一个点,又分别对应着原始图像中的 3x3 区域。综合来看,Pooled Map 上的这一个像素点,它的信息来自于原始图像上一个更大的区域。
最后,我们再用一个3x3的卷积核(Conv2
)去处理这个 Pooled Map。
Conv2
输出的特征图(Feature Map 2)上的一个像素点,它直接看到的是 Pooled Map 上的 3x3 区域。- 但是!这个 3x3 区域的每一个点,都代表着原始图像上的一大片区域。当我们把这些追溯回去,会发现 Feature Map 2 上的这一个像素点,它最终的感受野已经覆盖了原始图像上一个 7x7 的区域!
总结一下这个过程:Conv1(3x3)
-> 感受野 3x3MaxPool(2x2)
-> 池化本身不改变感受野,但它为下一层提供了“视野跳板”。 Conv2(3x3)
-> 感受野急剧增大到 7x7
如果没有中间的池化层,我们连续用两个3x3的卷积,感受野只会从3x3增加到5x5。而加入了池化层后,感受野迅速从3x3跃升到了7x7。这就是池化层在帮助网络“看得更远”、“看得更广”方面的巨大作用。
第四部分:全连接层 (The Fully Connected Layer)
【WHAT】什么是全连接层?
接着用生活中的类比: 再次回到我们的大脑。视觉皮层(卷积和池化层)已经完成了所有的初步工作:识别了边缘、形状、纹理、颜色,甚至组合出了像“毛茸茸的耳朵”、“圆圆的眼睛”、“粉色的鼻子”这样的高级概念。
现在,这些高度处理过的信息被送到了大脑的“推理决策中心”。这个中心会接收所有这些视觉线索,然后进行综合分析和推理,最终给出一个结论:“根据我收到的所有证据 —— 有胡须、有毛皮、有尖耳朵 —— 我99%确定这是一只猫!”
在CNN中,全连接层(Fully Connected Layer, FC Layer) 就扮演着这个“推理决策中心”的角色。它位于网络的末端,负责将前面所有卷积和池化层提取出的高级特征进行整合,并最终映射到我们想要的输出结果上(比如,每个类别的得分)。
它的名字“全连接”意味着,这一层的每一个神经元都与前一层的所有神经元相连接。这和我们最开始提到的标准神经网络是完全一样的结构。
【WHY】为什么在网络末端需要全连接层?
在经过层层卷积和池化之后,我们得到了一堆高度抽象的特征图(Feature Maps)。这些特征图仍然保留着一定的空间结构(比如,特征“眼睛”可能在特征图的左上角,特征“嘴巴”可能在中间)。
但是,对于最终的分类任务而言,我们不再关心这些特征的具体位置,而更关心这些特征是否存在。例如,要判断一张图是不是猫,我们只需要知道它“有猫眼”、“有猫耳”、“有猫须”这些特征就够了,至于这些特征在图片的哪个角落,已经不那么重要了。
因此,全连接层的作用就是:
- 整合全局信息:通过将所有特征“压平”并连接到一起,全连接层可以综合考虑所有提取出的高级特征,进行最终的分类判断。它不再局限于局部信息,而是着眼于全局。
- 进行高级推理和分类:它是将“特征”映射到“类别”的关键一步。卷积/池化层负责回答“这张图里有什么?”,而全连接层则负责回答“所以,这张图是什么?”。
【HOW】全连接层是如何工作的?
数学直觉 (Mathematical Intuition):
整个过程分为两步:
1. 展平 (Flatten): 在进入全连接层之前,我们必须进行一个“展平”操作。假设经过最后一轮池化后,我们得到了一个 5x5
大小,并且有 10
个通道(即10个特征图)的输出。它的维度是 (10, 5, 5)
。
展平操作就是把这个三维的张量“拉直”,变成一个一维的长向量。 向量长度 = 10 * 5 * 5 = 250
所以,我们会得到一个包含250个元素的长向量。这个向量就代表了从图像中提取出的所有高级特征的集合。
2. 全连接计算: 这个长度为250的向量,现在作为标准神经网络的输入。
- 输入层:有250个神经元。
- 隐藏层:可以有一层或多层,假设我们有一个包含128个神经元的隐藏层。那么,输入层的250个神经元中的每一个,都会连接到隐藏层的128个神经元中的每一个。这会产生
250 * 128
个权重。 - 输出层:假设我们要做一个10分类任务(比如识别数字0-9)。那么输出层就会有10个神经元,每个神经元对应一个类别。隐藏层的128个神经元会全连接到这10个输出神经元上,产生
128 * 10
个权重。
最终,这10个输出神经元会给出10个分数,分别代表输入图像属于每个类别的“置信度”。
PyTorch 代码示例:
在PyTorch中,我们通过 torch.nn.Flatten
和 torch.nn.Linear
来实现这个过程。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备一个模拟的、从池化层出来的输出
# (batch_size, channels, height, width)
# 假设我们批处理2张图片,每张图片有10个5x5的特征图
pooled_output = torch.randn(2, 10, 5, 5, device=device)
print("池化层输出的尺寸:")
rich.pretty.pprint(pooled_output.shape)
# 2. 定义展平层和全连接层
# 展平层,它会自动计算并拉直除batch_size外的所有维度
flatten_layer = nn.Flatten()
flatten_layer = flatten_layer.to(device)
# 全连接层 (在PyTorch中称为 'Linear' 层)
# in_features: 输入特征数,必须和展平后的向量长度一致 (10 * 5 * 5 = 250)
# out_features: 输出特征数,即下一层神经元的数量,我们设为128 (隐藏层)
fc1 = nn.Linear(in_features=10 * 5 * 5, out_features=128, device=device)
# 假设我们还有一个输出层,用于10分类任务
# 输入来自上一个隐藏层(128),输出为10个类别
output_layer = nn.Linear(in_features=128, out_features=10, device=device)
# 3. 模拟数据流
# 第一步:展平
flattened_output = flatten_layer(pooled_output)
print("展平后的尺寸:")
rich.pretty.pprint(flattened_output.shape) # 输出应为 (2, 250)
# 第二步:通过第一个全连接层
hidden_output = fc1(flattened_output)
print("第一个全连接层输出的尺寸:")
rich.pretty.pprint(hidden_output.shape) # 输出应为 (2, 128)
# 第三步:通过输出层
final_scores = output_layer(hidden_output)
print("最终输出分数的尺寸:")
rich.pretty.pprint(final_scores.shape) # 输出应为 (2, 10)
print("\n其中第一张图片的最终得分:")
rich.pretty.pprint(final_scores[0].data.tolist()) # 打印第一张图片的得分
第五部分:激活函数 (Activation Function)
我们已经搭建好了CNN的“骨架”(卷积、池化、全连接层)。但要让这个骨架“活”起来,能够学习和表达复杂的模式,我们还需要一些至关重要的东西,其中之一就是激活函数。
我们以目前最流行、最常用的ReLU为例来讲解。
【WHAT】什么是激活函数?
生活中的类比: 想象一个神经元(无论是生物的还是人工的)接收到了来自上游的各种信号。它需要一个激活开关来决定自己是否应该被“点亮”(激活),并将信号继续传递下去。如果信号太弱(低于某个阈值),这个开关就保持关闭,神经元就保持沉默;如果信号足够强,开关就打开,神经元被激活。
激活函数 就是这个“激活开关”的数学表达。它是一个非线性的函数,被应用在每个神经元的输出上,以决定这个神经元的最终输出值。
【WHY】为什么需要激活函数?
这是神经网络理论中一个极其关键的问题。如果没有激活函数,我们的网络会发生什么?
一个卷积层或一个全连接层,其核心计算本质上都是线性运算(就是矩阵乘法和加法)。如果你把很多个线性运算叠加在一起,比如 线性层1 -> 线性层2 -> 线性层3
,其最终结果无论多么复杂,本质上仍然等价于一个单一的线性运算。
这意味着,一个没有激活函数的“深度”网络,其表达能力和一个简单的“浅层”线性模型(比如线性回归)是完全一样的。它永远无法学习到现实世界中普遍存在的非线性关系。例如,房价和面积的关系可能不是一条直线,图像中“猫”的特征组合也绝不是线性的。
所以,激活函数的根本目的就是:向网络中引入非线性因素 (Introduce Non-linearity)。
只有加入了非线性的激活函数,我们的神经网络才能真正地“深”得有意义,才能有能力去拟合和学习各种复杂的、弯曲的、真实世界中的数据模式。
【HOW】ReLU 是如何工作的?
ReLU (Rectified Linear Unit,修正线性单元) 是目前最受欢迎的激活函数,因为它非常简单且高效。
数学直觉 (Mathematical Intuition):
ReLU的数学表达式简单到令人惊讶:
ReLU(x) = max(0, x)
它的规则是:
- 如果输入的数值
x
大于0,那么输出就是x
本身。 - 如果输入的数值
x
小于或等于0,那么输出就是 0。
举个例子: 假设一个神经元的输出经过计算后得到一个向量:[2.5, -1.8, 0.0, 5.0, -0.1]
经过ReLU激活函数处理后,输出会变成: [max(0, 2.5), max(0, -1.8), max(0, 0.0), max(0, 5.0), max(0, -0.1)]
= [2.5, 0, 0, 5.0, 0]
它就像一个过滤器,保留了所有正向的信号,而将所有负向的信号直接“掐断”归零。这种简单的非线性特性,在实践中被证明效果非常好,并且计算速度很快。
PyTorch 代码示例:
在PyTorch中,激活函数通常作为独立的层来使用,放在卷积层或线性层之后。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备一个模拟的、来自线性层或卷积层的输出
# (通常包含正数、负数和零)
linear_layer_output = torch.tensor([-3.0, -1.5, 0.0, 2.0, 4.5], device=device)
# 2. 定义一个ReLU激活函数层
relu_activation = nn.ReLU()
relu_activation = relu_activation.to(device)
# 3. 将数据通过激活函数
activated_output = relu_activation(linear_layer_output)
print("激活前的输出:")
rich.pretty.pprint(linear_layer_output.data.tolist())
print("经过ReLU激活后的输出:")
rich.pretty.pprint(activated_output.data.tolist())
# 在一个完整的模型中,它通常这样使用:
# model = nn.Sequential(
# nn.Linear(in_features=10, out_features=20, device=device), # 线性层
# nn.ReLU().to(device), # 激活层
# nn.Linear(in_features=20, out_features=5, device=device), # 下一个线性层
# nn.ReLU().to(device) # 下一个激活层
# )
激活函数是赋予神经网络学习复杂模式能力的关键。虽然还有Sigmoid、Tanh等其他激活函数,但ReLU及其变体(如Leaky ReLU)是目前构建CNN时的首选。
激活函数补充知识点
- Sigmoid 函数
【WHAT】是什么? Sigmoid函数可能是最早被广泛使用的激活函数之一。它的数学公式是 σ(x) = 1 / (1 + e^(-x))
。
特点:
- 它能将任何输入的实数值“挤压”到 (0, 1) 这个区间内。
- 输出值可以被解释为“概率”,比如一个神经元被激活的概率。
图形: 它看起来像一个平滑的 "S" 形曲线。
【WHY】为什么用它?/ 它的问题是什么?
优点:
- 输出像概率: 由于输出在0和1之间,非常适合用在二分类问题的输出层,可以直接表示样本属于正类的概率。
- 平滑性: 函数处处可导,梯度平滑,有利于优化。
缺点(现在很少在隐藏层使用它的原因):
- 梯度消失 (Vanishing Gradient): 这是它最致命的弱点。从图形上可以看到,当输入
x
非常大(比如大于5)或非常小(比如小于-5)时,函数的曲线变得非常平坦,它的导数(梯度)趋近于0。在反向传播时,梯度需要逐层相乘,如果很多层的梯度都接近于0,那么传递到浅层网络的梯度就会变得微乎其微,导致浅层网络的参数几乎无法更新。这就是“梯度消失”问题。 - 输出非零中心 (Not Zero-centered): Sigmoid的输出恒大于0。这会导致后续层的输入都是正数,在反向传播时,对权重的梯度更新方向会受到限制(要么都为正,要么都为负),这会降低收敛速度。
- 计算成本高: 包含指数运算
e^x
,相对于ReLU的简单比较操作,计算上更耗时。
- 梯度消失 (Vanishing Gradient): 这是它最致命的弱点。从图形上可以看到,当输入
【适用场景】
- 主要用于二分类问题的输出层:例如,判断一张图片“是猫”还是“不是猫”,输出一个0到1之间的概率值。
- 在循环神经网络(RNN)的某些门控单元(如LSTM)中仍有使用。
- 基本不再用于CNN或深度前馈网络的隐藏层。
- Tanh 函数 (双曲正切函数)
【WHAT】是什么? Tanh函数的数学公式是 tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x))
。你可以把它看作是放大并平移过的Sigmoid函数。
特点:
- 它将任何输入的实数值“挤压”到 (-1, 1) 这个区间内。
- 它的输出是零中心 (Zero-centered) 的。
图形: 它也像一个 "S" 形曲线,但中心点在(0,0)。
【WHY】为什么用它?/ 它的问题是什么?
- 优点:
- 零中心输出: 相比Sigmoid,Tanh的输出是零中心的。这意味着下一层的输入有正有负,收敛速度通常会比Sigmoid快。
- 缺点:
- 梯度消失问题仍然存在: 和Sigmoid一样,当输入过大或过小时,Tanh的曲线也会变得平坦,梯度同样会趋近于0,梯度消失的问题没有得到解决。
- 计算成本高: 同样包含指数运算。
【适用场景】
- 在RNN和LSTM中比较常见。
- 在一些早期的CNN或需要零中心输出的特定场景中,它比Sigmoid在隐藏层的表现更好。但在ReLU出现后,它的使用频率也大幅下降了。
- Leaky ReLU (带泄露的修正线性单元)
【WHAT】是什么? Leaky ReLU 是对标准ReLU的一个改进,旨在解决ReLU的一个潜在问题。它的数学公式是: LeakyReLU(x) = max(α*x, x)
其中 α
是一个很小的正常数,比如 0.01。
规则:
- 如果输入的数值
x
大于0,输出就是x
本身(和ReLU一样)。 - 如果输入的数值
x
小于或等于0,输出不再是0,而是α*x
(一个很小的负值)。
【WHY】为什么用它?/ 它的问题是什么?
优点:
- 解决了“Dying ReLU”问题: 标准ReLU有一个问题,如果一个神经元的输入在训练过程中恒为负,那么它的输出将永远是0,梯度也永远是0,这个神经元就再也无法被激活和更新了,我们称之为“死亡ReLU”(Dying ReLU)。Leaky ReLU通过在负半区给予一个微小的、非零的梯度(
α
),保证了即使输入为负,神经元也能得到更新,不会“死亡”。 - 具备ReLU的所有优点: 计算速度快,收敛速度快。
- 解决了“Dying ReLU”问题: 标准ReLU有一个问题,如果一个神经元的输入在训练过程中恒为负,那么它的输出将永远是0,梯度也永远是0,这个神经元就再也无法被激活和更新了,我们称之为“死亡ReLU”(Dying ReLU)。Leaky ReLU通过在负半区给予一个微小的、非零的梯度(
缺点:
- 引入一个超参数
α
:α
的选择需要实验,虽然通常0.01的效果就不错。还有一个变体叫PReLU(Parametric ReLU),它直接把α
作为一个可学习的参数交给网络自己去训练。 - 在实践中,它带来的性能提升并不总是非常显著,有时和标准ReLU效果相当。
- 引入一个超参数
【适用场景】
- 当你怀疑你的网络中存在大量的“死亡ReLU”神经元时,可以尝试用Leaky ReLU替换标准的ReLU。
- 在生成对抗网络(GAN)中非常常用。
- 在很多现代CNN架构中,它是一个比ReLU更稳健的选择,可以作为默认的激活函数来尝试。
总结对比
特性 | Sigmoid | Tanh | ReLU | Leaky ReLU |
---|---|---|---|---|
输出范围 | (0, 1) | (-1, 1) | [0, +∞) | (-∞, +∞) |
零中心 | 否 | 是 | 否 (但影响较小) | 近似是 |
梯度消失 | 严重 | 严重 | 负半区消失 (可能死亡) | 基本解决 |
计算速度 | 慢 (指数运算) | 慢 (指数运算) | 非常快 | 非常快 |
主要用途 | 二分类输出层 | RNN, 早期网络的隐藏层 | CNN隐藏层的默认选择 | GAN, ReLU的稳健替代品 |
建议:
- 默认从ReLU开始,它简单、快速、效果好。
- 如果你的网络性能不佳或训练不稳定,换成Leaky ReLU通常是一个不错的尝试。
- Tanh可以作为备选,但一般不如ReLU家族。
- 绝对避免在隐藏层使用Sigmoid,只在需要输出(0,1)概率的二分类输出层使用它。
第六部分:Dropout 层
我们已经学习了卷积神经网络的核心组件和激活机制。现在,我们来学习一个非常重要的“正则化”技巧,它能让我们的模型变得更聪明、更不容易犯“书呆子”的错误。这个技巧就是 Dropout。
【WHAT】什么是Dropout?
生活中的类比: 想象一个非常重要的项目团队,团队里有很多专家。为了防止团队成员之间产生过度依赖(比如,张三总是完全依赖李四提供的数据,自己不动脑子),项目经理想出了一个绝佳的团队协作方法:
在每一次模拟演练(训练迭代)中,经理会随机地让一部分成员“去喝茶”,让他们暂时不参与这次任务。这样一来,剩下的成员就必须学会独立思考,并与其他还在场的成员协同工作,而不能指望某个特定的“大腿”。经过多次这样的随机演练,整个团队的整体协作能力和每个成员的独立解决问题的能力都得到了提升。最终在正式项目(测试)中,所有成员都会回来一起工作,此时的团队会变得异常强大和稳健。
在神经网络中,Dropout 就扮演着这位项目经理的角色。在训练过程的每一次前向传播中,它会以一个预设的概率 p
,随机地将一部分神经元的输出临时“丢弃”(设置为0),让它们不参与这一次的计算。
【WHY】为什么需要使用Dropout?
Dropout的主要目的是为了防止过拟合 (Overfitting)。
什么是过拟合? 过拟合就像一个“死记硬背”的学生。他在练习题上(训练数据)能考100分,因为他把所有题目的答案都背下来了。但一到正式考试(测试数据),遇到稍微有点变化的题目,他就完全不会做了,成绩一落千丈。
在神经网络中,过拟合表现为模型在训练集上表现极好,但在从未见过的新数据(验证集或测试集)上表现很差。这通常是因为网络过于复杂,神经元之间形成了脆弱而复杂的协同适应 (co-adaptation)。某些神经元可能会过度依赖另一些特定神经元的输出,而不是自己去学习有用的特征。
Dropout通过以下方式解决这个问题:
- 打破神经元间的协同适应:由于任何一个神经元都有可能在下一次迭代中被“丢弃”,所以网络不能过度依赖任何一个或一小撮神经元。它迫使每个神经元都要学会提取更加独立、更加鲁棒的特征。
- 模型集成 (Model Ensemble) 的效果:从另一个角度看,每一次应用Dropout,我们都相当于在训练一个不同结构的、更“瘦”的子网络。整个训练过程就像是在成千上万个不同的子网络上进行训练。在最后测试时,我们恢复所有神经元(相当于将所有这些子网络“平均”起来),从而得到一个更强大、泛化能力更强的“集成模型”。
【HOW】Dropout 是如何工作的?
数学直觉 (Mathematical Intuition):
过程非常简单:
- 设置一个丢弃概率
p
:比如p=0.5
,代表每个神经元有50%的概率被丢弃。 - 训练阶段 (Training):
- 对于某一层网络的输出,为每个神经元生成一个随机数(0到1之间)。
- 如果随机数小于
p
,则该神经元的输出置为0(“丢弃”)。 - 如果随机数大于等于
p
,则该神经元的输出被保留。为了补偿那些被丢弃的神经元所损失的能量,通常会将保留下来的神经元的输出值放大1 / (1 - p)
倍。这样做是为了保证该层输出的总期望值在训练和测试时保持一致。(PyTorch的nn.Dropout
会自动帮你做这一步)。
- 测试阶段 (Inference/Evaluation):
- 在测试模型时,Dropout层不起任何作用。所有的神经元都保持激活状态(概率为100%)。我们使用完整的、训练好的网络进行预测。
PyTorch 代码示例:
在PyTorch中,nn.Dropout
通常被放在全连接层之后,有时也会用在卷积层之后。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备一个模拟的、来自全连接层的输出
# (batch_size, num_features)
fc_output = torch.randn(1, 10, device=device) # 假设有10个神经元的输出
print("原始输出:")
rich.pretty.pprint(fc_output.data.tolist())
# 2. 定义一个Dropout层
# p=0.5 代表每个神经元有50%的概率被丢弃
dropout_layer = nn.Dropout(p=0.5)
dropout_layer = dropout_layer.to(device)
# 3. 在训练模式下应用Dropout
# 必须调用 .train() 来确保Dropout层是激活的
dropout_layer.train()
dropout_output_train = dropout_layer(fc_output)
print("\n--- 训练模式 ---")
print("Dropout后的输出 (大约一半的元素会变成0):")
rich.pretty.pprint(dropout_output_train.data.tolist())
# 注意:未被置零的元素的值被放大了 1/(1-0.5) = 2倍
# 4. 在评估/测试模式下应用Dropout
# 必须调用 .eval() 来关闭Dropout层
dropout_layer.eval()
dropout_output_eval = dropout_layer(fc_output)
print("\n--- 评估模式 ---")
print("Dropout后的输出 (所有元素都保留,与原始输出一致):")
rich.pretty.pprint(dropout_output_eval.data.tolist())
# # 在一个完整的模型中,它是这样使用的:
# model = nn.Sequential(
# nn.Linear(250, 128, device=device),
# nn.ReLU().to(device),
# nn.Dropout(p=0.5).to(device), # 通常放在激活函数之后
# nn.Linear(128, 10, device=device)
# )
# 在训练循环中,你会调用 model.train()
# 在验证/测试循环中,你会调用 model.eval()
Dropout是一个简单但极其有效的正则化工具,也是现代深度学习模型中几乎必备的组件之一。
第七部分:Softmax 层
非常好!我们已经来到了构建分类模型的最后一步。网络经过全连接层后,会给出一组原始的、未经处理的得分(我们称之为 logits)。这些得分可能包含任意的正数、负数或零,它们的大小可以反映模型对某个类别的“信心”,但它们并不直观。
为了让这些分数变得更有意义,我们需要 Softmax 层。
【WHAT】什么是Softmax?
生活中的类比: 想象一场才艺比赛,有三位选手:A、B、C。评委们给出的原始分数分别是:
- 选手A:2.0分
- 选手B:1.0分
- 选手C:0.1分
这些分数告诉我们A表现最好,但它们不是概率。我们想把这些分数转换成一个获胜概率的分布,使得:
- 每个选手的“获胜概率”都在0和1之间。
- 所有选手的“获胜概率”加起来正好等于1(或100%)。
Softmax 函数做的就是这件“将分数转换为概率”的工作。经过Softmax转换后,上面的分数可能会变成:
- 选手A:约 0.7 (70% 的获胜概率)
- 选手B:约 0.2 (20% 的获胜概率)
- 选手C:约 0.1 (10% 的获胜概率)
现在这个结果就非常直观了,它清晰地表示了模型预测输入样本属于每个类别的概率。
【WHY】为什么需要使用Softmax?
在多分类问题中,我们最终的目标是得到一个概率分布,而不是一堆无约束的分数。Softmax层之所以必不可少,主要有两个原因:
- 输出归一化,易于解释:Softmax将网络的原始输出(logits)转换成一个标准的概率分布。这使得模型的输出结果非常容易理解。当我们看到输出是
[0.1, 0.8, 0.1]
时,我们可以直接说:“模型预测这张图片有80%的概率是第二个类别。” - 配合损失函数进行训练:这个概率分布是训练过程中的关键。在多分类任务中,我们通常使用交叉熵损失函数 (Cross-Entropy Loss)。这个损失函数需要一个概率分布作为输入,来衡量模型的预测概率与真实的标签(比如,真实标签是
[0, 1, 0]
,代表它确实是第二个类别)之间的差距。Softmax正好提供了这个必需的输入。
简单来说,Softmax是连接模型原始输出和最终分类决策(以及损失计算)的桥梁。
【HOW】Softmax 是如何工作的?
数学直觉 (Mathematical Intuition):
Softmax函数的计算分为两步:
取指数 (Exponentiation):对所有原始分数
z_i
,计算e^(z_i)
。这一步有两个作用:- 它将所有值都映射到正数域,因为
e
的任何次方都是正的。 - 它会放大输入之间的差异。原来相差不大的分数,取指数后差距会变得更明显。
- 它将所有值都映射到正数域,因为
归一化 (Normalization):将每个取完指数后的值,除以所有这些值的总和。
数学公式:Softmax(z_i) = e^(z_i) / Σ(e^(z_j))
(其中 j
代表所有类别)
我们用刚才的例子来手动计算一遍:
原始分数 (logits):
z = [2.0, 1.0, 0.1]
第一步:取指数
e^2.0 ≈ 7.389
e^1.0 ≈ 2.718
e^0.1 ≈ 1.105
计算总和
Sum = 7.389 + 2.718 + 1.105 = 11.212
第二步:归一化
P(A) = 7.389 / 11.212 ≈ 0.659
(约66%)P(B) = 2.718 / 11.212 ≈ 0.242
(约24%)P(C) = 1.105 / 11.212 ≈ 0.099
(约10%)
最终的概率向量就是 [0.66, 0.24, 0.10]
(由于四舍五入,加起来可能不完全是1)。
PyTorch 代码示例:
在PyTorch中,nn.Softmax
可以直接应用在模型的输出上。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 1. 准备一个模拟的、来自最后一个全连接层的原始输出 (logits)
# (batch_size, num_classes)
# 假设我们批处理1张图片,进行3分类任务
logits = torch.tensor([[2.0, 1.0, 0.1]], device=device)
# 2. 定义一个Softmax层
# dim=1 表示我们希望在“类别”这个维度上进行Softmax操作。
# 对于 (batch_size, num_classes) 这样的2D张量,dim=1是正确的选择。
softmax_layer = nn.Softmax(dim=1)
softmax_layer = softmax_layer.to(device)
# 3. 执行Softmax操作
probabilities = softmax_layer(logits)
print("原始 Logits:")
rich.pretty.pprint(logits.data.tolist())
print("转换后的概率:")
rich.pretty.pprint(probabilities.data.tolist())
print("所有概率的和:")
rich.pretty.pprint(torch.sum(probabilities).item()) # 这个和应该等于1.0
# **重要提示**:
# 在PyTorch中,如果你使用 `nn.CrossEntropyLoss` 作为损失函数,
# 你**不需要**在模型的最后一层手动添加Softmax。
# 因为 `nn.CrossEntropyLoss` 内部已经隐式地包含了Softmax的计算。
# 所以,你的模型应该直接输出原始的logits。
#
# 正确的做法:
# model_output = YourModel(input_data) # 输出的是logits
# loss_fn = nn.CrossEntropyLoss()
# loss = loss_fn(model_output, true_labels) # loss函数内部处理softmax
#
# 错误的做法(会导致重复计算和数值不稳定):
# probabilities = nn.Softmax(dim=1)(YourModel(input_data))
# loss_fn = nn.CrossEntropyLoss()
# loss = loss_fn(probabilities, true_labels)
到这里,我们已经学会了如何将模型的原始输出变成有意义的概率了。我们离完成一个完整的CNN只差最后一步:选择一个好的“教练”(优化器)来指导整个网络的学习过程。
额外补充的交叉熵知识点
如果说模型是“学生”,数据是“课本”,那么损失函数(Loss Function) 就是“评分标准”。它告诉模型,你这次的回答(预测)距离正确答案有多远。模型训练的整个过程,就是想尽办法去最小化这个损失函数所计算出的“错误分”。
我们来详细探讨几种最常用的损失函数。
1. 交叉熵损失 (Cross-Entropy Loss)
这是分类问题中最常用、最重要的损失函数。它衡量的是模型预测的概率分布与真实的标签分布之间的“距离”或“差异”。
二元交叉熵损失 (Binary Cross-Entropy Loss, BCELoss)
【WHAT】是什么? 专门用于二分类问题(比如,是猫/不是猫,邮件是垃圾/不是垃圾)。
【WHY】为什么用它? 假设真实标签 y
是0或1,模型预测的概率 p
是一个0到1之间的数。
- 如果真实标签
y=1
(是猫),我们希望模型预测的概率p
越接近1越好。损失函数是-log(p)
。当p
趋近1时,-log(p)
趋近0(损失小);当p
趋近0时,-log(p)
趋近无穷大(损失极大)。 - 如果真实标签
y=0
(不是猫),我们希望模型预测的概率p
越接近0越好(即1-p
越接近1越好)。损失函数是-log(1-p)
。当p
趋近0时,-log(1-p)
趋近0;当p
趋近1时,-log(1-p)
趋近无穷大。
数学公式:Loss = -[y * log(p) + (1 - y) * log(1 - p)]
这个公式巧妙地将上面两种情况合二为一。
PyTorch 代码示例:
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 模型输出必须先经过Sigmoid,得到(0,1)之间的概率
sigmoid = nn.Sigmoid()
sigmoid = sigmoid.to(device)
loss_fn = nn.BCELoss()
loss_fn = loss_fn.to(device)
# 场景1: 真实标签是1
true_label = torch.tensor([1.0], device=device)
# 预测很好,p接近1
good_prediction = sigmoid(torch.tensor([3.0], device=device)) # p ≈ 0.95
# 预测很差,p接近0
bad_prediction = sigmoid(torch.tensor([-3.0], device=device)) # p ≈ 0.05
print(f"好预测的损失: {loss_fn(good_prediction, true_label):.4f}") # 损失很小
print(f"差预测的损失: {loss_fn(bad_prediction, true_label):.4f}") # 损失很大
# PyTorch提供了一个更方便、数值更稳定的版本:BCEWithLogitsLoss
# 它将Sigmoid和BCELoss合并了,输入应该是原始的logits
loss_fn_with_logits = nn.BCEWithLogitsLoss()
loss_fn_with_logits = loss_fn_with_logits.to(device)
raw_logits_good = torch.tensor([3.0], device=device)
raw_logits_bad = torch.tensor([-3.0], device=device)
print(f"用BCEWithLogitsLoss的好预测损失: {loss_fn_with_logits(raw_logits_good, true_label):.4f}")
print(f"用BCEWithLogitsLoss的差预测损失: {loss_fn_with_logits(raw_logits_bad, true_label):.4f}")
分类交叉熵损失 (Categorical Cross-Entropy Loss)
【WHAT】是什么? 用于多分类问题(比如,识别数字0-9,判断图片是猫、狗还是鸟)。
【WHY】为什么用它? 它直接比较模型输出的概率分布(经过Softmax后)和真实标签的独热编码 (One-Hot Encoding) 之间的差异。
- 真实标签:对于一张“猫”的图片,在[猫, 狗, 鸟]三分类中,它的真实标签是
[1, 0, 0]
。 - 模型预测:模型可能预测
[0.7, 0.2, 0.1]
。
交叉熵损失只关心正确类别所对应的那个概率。在这个例子中,它只看“猫”的预测概率0.7。损失就是 -log(0.7)
。如果模型对“猫”的预测概率越高(越接近1),log
值就越接近0,损失就越小。
数学公式:Loss = -Σ(y_i * log(p_i))
由于y
是one-hot编码,只有一个 y_i
是1,其他都是0,所以公式等价于 -log(p_c)
,其中 c
是正确的类别。
PyTorch 代码示例:
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 如上一节所说,nn.CrossEntropyLoss包含了Softmax
loss_fn = nn.CrossEntropyLoss()
loss_fn = loss_fn.to(device)
# 模型的原始输出 (logits),假设批处理2张图,3分类
logits = torch.tensor([
[2.0, 1.0, 0.1], # 第一张图的预测,模型认为类别0最可能
[-1.0, 1.0, 3.0] # 第二张图的预测,模型认为类别2最可能
], device=device)
# 真实标签(不是one-hot,而是类别的索引)
# 第一张图是类别0,第二张图是类别2
true_labels = torch.tensor([0, 2], device=device)
loss = loss_fn(logits, true_labels)
print(f"交叉熵损失: {loss.item():.4f}")
# 我们手动验证一下
import torch.nn.functional as F
probs = F.softmax(logits, dim=1)
print("预测概率:\n", probs.data.tolist())
# 第一张图的损失 = -log(0.6652) ≈ 0.4076
# 第二张图的损失 = -log(0.8360) ≈ 0.1791
# 总损失 = (0.4076 + 0.1791) / 2 ≈ 0.2934 (和PyTorch计算结果基本一致)
2. 均方误差损失 (Mean Squared Error, MSELoss)
【WHAT】是什么? 主要用于回归问题,即预测一个连续值(而不是类别)。例如,预测房价、预测股票价格、预测图片中一个物体的坐标等。
【WHY】为什么用它? 它计算的是预测值和真实值之间差值的平方的平均值。
数学公式:Loss = (1/N) * Σ(y_true - y_pred)²
- 平方的作用:
- 使得差值为正数,避免正负抵消。
- 对较大的误差给予更重的惩罚。比如,误差是2,平方后是4;误差是10,平方后是100。这会迫使模型优先去修正那些错得离谱的预测。
PyTorch 代码示例:
python
import rich
import rich.pretty
import torch
import torch.nn as nn
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
loss_fn = nn.MSELoss()
loss_fn = loss_fn.to(device)
# 预测房价(单位:万元)
predictions = torch.tensor([120.5, 290.0, 510.8], device=device)
true_values = torch.tensor([110.0, 305.0, 500.0], device=device)
loss = loss_fn(predictions, true_values)
print(f"均方误差损失: {loss.item():.4f}")
# 手动计算:
# ((120.5 - 110.0)^2 + (290.0 - 305.0)^2 + (510.8 - 500.0)^2) / 3
# = (10.5^2 + (-15.0)^2 + 10.8^2) / 3
# = (110.25 + 225 + 116.64) / 3 = 451.89 / 3 ≈ 150.63
总结:如何选择损失函数?
- 二分类问题:
nn.BCEWithLogitsLoss
(推荐)或nn.Sigmoid
+nn.BCELoss
。 - 多分类问题:
nn.CrossEntropyLoss
(模型输出logits)。 - 回归问题(预测连续值):
nn.MSELoss
或nn.L1Loss
(MAE, 平均绝对误差)。
选择正确的损失函数是定义一个机器学习任务的起点。它直接定义了“好”与“坏”的标准,所有后续的优化都是围绕着这个标准来进行的。
第八部分:优化器 (Optimizer)
我们来到了整个学习旅程的最后一部分,也是让模型真正“学习”起来的动力引擎 —— 优化器 (Optimizer)。
【WHAT】什么是优化器?
生活中的类比: 想象你正站在一座大山的半山腰,你的目标是尽快走到山谷的最低点(也就是损失函数的最小值)。但是,你被浓雾笼罩,只能看到脚下附近的一小块区域。
你要如何决策下一步往哪里走呢?
- 最朴素的想法(梯度下降 Gradient Descent):看看脚下哪个方向最陡峭(计算梯度),然后朝着那个方向迈一小步。
- 更聪明的想法(动量 Momentum):如果你一直在持续下坡,说明这个方向可能很不错,你可以加大步伐,增加一点“惯性”,这样可以更快地冲下坡。
- 更自适应的想法(自适应学习率 Adaptive Learning Rate):如果你发现某个方向非常陡峭,你可能会小心翼翼地迈小步,防止“一脚踩空”冲过头;如果在某个方向很平缓,你可能会大胆地迈大步,以求快速通过。
优化器,就是你在浓雾中决定如何迈出下一步的策略。它根据损失函数计算出的梯度(哪个方向是“下坡”),来更新网络中的每一个参数(权重和偏置),目标是让损失函数的值越来越小。
【WHY】为什么选择 Adam 或 AdamW?
早期的优化器如SGD(随机梯度下降)就像那个最朴素的登山者,它在所有方向上都使用相同的“步长”(学习率)。这种方法虽然有效,但有时会很慢,或者在复杂的“地形”(损失曲面)中容易被困住。
Adam (Adaptive Moment Estimation) 是目前最流行、最常用的优化器之一。它非常强大,因为它结合了两种先进的登山策略:
- 动量 (Momentum):它引入了“惯性”的概念。它不仅仅看当前这一步的梯度,还会参考之前几步的平均方向。这有助于它冲过平缓区域,并抑制在“山沟”里来回震荡,从而加速收敛。
- 自适应学习率 (Adaptive Learning Rate):它为网络中的每一个参数都维护一个独立的、自适应的学习率。对于那些梯度变化剧烈的参数,它会使用较小的步长;对于梯度平稳的参数,它会使用较大的步长。这使得它在处理稀疏数据和复杂的网络时表现得非常出色。
AdamW (Adam with Weight Decay) 是对Adam的一个重要改进。
- 在传统的Adam中,一种叫做“权重衰减(Weight Decay)”的正则化技术(用于防止过拟合)与梯度更新耦合在了一起,这在某些情况下会导致效果不佳。
- AdamW将权重衰减从梯度更新中解耦出来,使其成为一个独立的过程。在实践中,人们发现AdamW通常能提供比标准Adam更好、更稳定的泛化性能。
总结来说,选择Adam或AdamW是因为它们:
- 收敛速度快。
- 对超参数(如学习率)的选择不那么敏感。
- 在大多数情况下都能取得非常好的效果,是深度学习任务的“默认安全选项”。
- AdamW通常被认为比Adam更优越,尤其是在需要强正则化的任务中。
【HOW】优化器是如何工作的?
数学直觉 (Mathematical Intuition):
我们不需要深入推导复杂的公式,但理解其核心思想很重要:
Adam的核心:
m
(一阶动量):像一个“指数移动平均”,记录了梯度的平均方向(惯性)。v
(二阶动量):也像一个“指数移动平均”,记录了梯度大小的平均波动。- 更新规则:参数的更新量大致正比于
m / sqrt(v)
。这意味着:- 如果梯度方向一致(
m
大),更新就快。 - 如果梯度波动剧烈(
v
大),分母变大,更新步长就变小,起到稳定作用。
- 如果梯度方向一致(
AdamW与Adam的区别:
- 在Adam的更新规则里,权重衰减会影响
m
和v
的计算。 - 在AdamW里,参数更新分为两步:
- 先按照Adam的规则计算出一个更新量。
- 然后,在更新参数时,直接让参数自身乘以一个小于1的衰减因子
(1 - λ * lr)
,再减去Adam计算出的更新量。这个λ
就是weight_decay
。
- 在Adam的更新规则里,权重衰减会影响
PyTorch 代码示例:
在PyTorch中,使用优化器是一个标准流程:
- 定义你的模型。
- 将模型的参数
model.parameters()
传递给优化器。 - 在训练循环中,执行三个关键步骤。
python
import rich
import rich.pretty
import torch
import torch.nn as nn
import torch.optim as optim
# Check that MPS is available
if not torch.backends.mps.is_available():
if not torch.backends.mps.is_built():
print("MPS not available because the current PyTorch install was not "
"built with MPS enabled.")
else:
print("MPS not available because the current MacOS version is not 12.3+ "
"and/or you do not have an MPS-enabled device on this machine.")
device = torch.device("cpu")
else:
device = torch.device("mps")
# 假设我们有一个简单的模型
model = nn.Sequential(
nn.Linear(10, 20, device=device),
nn.ReLU().to(device),
nn.Linear(20, 2, device=device)
)
# 1. 定义优化器
# 使用 AdamW,它是现代实践中的首选
# lr=1e-3 (0.001) 是一个常用的初始学习率
# weight_decay=1e-2 (0.01) 是一个常用的权重衰减值
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)
# 你也可以选择Adam
# optimizer_adam = optim.Adam(model.parameters(), lr=1e-3)
# --- 模拟一个训练步骤 ---
# 准备数据
input_data = torch.randn(64, 10, device=device) # 64个样本
labels = torch.randint(0, 2, (64,), device=device)
loss_fn = nn.CrossEntropyLoss()
loss_fn = loss_fn.to(device)
# **训练循环中的三步曲**
# 第一步:清零旧的梯度
# 每次计算新梯度前,必须清除上一次的梯度,否则梯度会累积
optimizer.zero_grad()
# 第二步:前向传播,计算损失
outputs = model(input_data)
loss = loss_fn(outputs, labels)
# 第三步:反向传播,计算梯度
loss.backward()
# 第四步(由优化器完成):根据梯度更新参数
optimizer.step()
print("模型训练了一步!")
print("损失值:", loss.item())
课程总结
恭喜你!我们已经完成了CNN核心概念的全部学习!
最后我们用两张流程图来总结一下CNN的训练与推理过程。
现在你已经掌握了构建一个完整的、现代的图像分类器所需要的所有理论积木。下一步,就是将这些积木拼在一起,用真实的数据集(比如CIFAR-10或MNIST)来训练一个你自己的模型了。