本文通过一个最简单的手写数字识别神经网络的训练过程,带你一步步看清前向传播、反向传播、梯度下降、学习率 这些常被提及但让人犯糊涂的术语是什么,具体是怎么做的,搞懂神经网络到底是怎么学会识别数字的!
我们用一个最简单的神经网络(MLP,全称 Multi-Layer Perceptron)来识别手写数字。
比如下面这些图片:
每张图片都是分辨率为 28×28 的灰度图,图中是一个手写体的数字。我们希望神经网络看一眼这些图,就能说出其中的数字。或者说判断出图片中数字最可能是几。
在后面的处理中,我们会把每个图片,用一个28×28=784维的向量表示。向量的每个值,表示这个图片对应像素的灰度值。比如一个图片对应的向量可能是:(0,36,255,...,0)
我们使用 PyTorch 定义一个非常简单的模型,只包含一个隐藏层:
class MLP(nn.Module):
def \_\_init\_\_(self):
super(MLP, self).\_\_init\_\_()
self.fc1 = nn.Linear(28*28, 512) # 输入层 → 隐藏层,输入784,输出512
self.fc2 = nn.Linear(512, 10) # 隐藏层 → 输出层,输入512,输出10,对应10个类别
def forward(self, x):
x = x.view(-1, 28*28) # 展平图像
x = torch.relu(self.fc1(x)) # ReLU激活
x = self.fc2(x) # 输出10类logits
return x
其中 class MLP(nn.Module) 表示 MLP 继承自 nn.Module 类,而 nn.Module 类封装了神经网络模型的所有公共操作。一个 nn.Module 对象,就表示一个神经网络模型实例。
初始化函数 __init__中,放置模型每一层的定义。类似 self.fc1 = nn.Linear(28*28, 512) 这样就定义了一个线性层(也称为全连接层或稠密层)。nn.Linear 封装了线性层的所有计算。fc1 是给这层取个名字,方便在其他地方调用。
nn.Linear 是 PyTorch 中表示线性层的类。这个类对应的计算过程,就是一个简单的线性变换:
其中
是输入数据向量,
是权重矩阵,
是偏置向量。
nn.Linear(28*28, 512) 表示输入特征的数量是 28×28=784 维,也就是展平后的图像像素数量。第二个参数 512 表示输出特征的数量,也可以理解为神经元的数量,每个神经元,学习一个特征。所以这里定义了模型的2个层,都是线性层:
- • self.fc1 = nn.Linear(28*28, 512) 表示 fc1这个线性层,接受784维的输入向量,输出一个512维的向量
- • self.fc2 = nn.Linear(512, 10) 表示 fc2 这个线性层,接受512维的输入向量,输出一个10维的向量
forward 函数定义了模型的具体计算过程。也就是通常所说的前向计算。其中 x 表示输入数据。
- • x = x.view(-1, 28*28) 将输入的二维数据拉平为一维
- • x = torch.relu(self.fc1(x)) 将拉平的一维数据传入第一层fc1进行计算,结果应用ReLU激活函数
- • x = self.fc2(x) 将经过ReLU编号值,输入模型第二次fc2进行计算,计算结果作为模型输出
下图表示了这段代码定义的模型结构:
本质上是什么呢,就是我有个一数据集,数据集每个样本有784个数据。现在我要定义一个神经网络,神经网络的核心是一个包含512个神经元的隐藏层。把数据集每个样本的每个数据都看做一个信号。每个神经元都接收每个样本的784个信号进行计算。计算的方式为
即每个信号乘以一个权重后相加,再加上一个偏置值b,进行激活函数变换后,作为该神经元的计算结果。不同的神经元,用不同的权重来计算:
...
我们把这层神经元的所有参数和偏置合并起来,作为参数矩阵
和偏置向量
; 我们期望通过调整
和
,让这1层的512个神经元,能学习到手写体的不同的512个特征。然后将这512个特征,再转交给下一层(输出层)10个神经元处理。期望这10个神经元,能通过这512个特征,判断出其是某一个数字的概率:
...
我们把这层神经元的所有参数和偏置合并起来,作为参数矩阵
和偏置向量
;。我们期望通过调整
,
,及偏置
,
让这个神经网络能识别每个样本对应的数字。
所以整个神经网络的计算过程为:
其中
是ReLU激活函数。这个函数的作用是,就是把所有负值变成0,正值不变。不要小看这个处理,有了这个处理,就为模型引入了非线性因素。如果没有经过这样的非线性激活函数处理,那么计算过程就成了纯线性的叠加:
也就是不管你加多少层,最后都会等效坍缩为一层,模型也就失去了抽象复杂特征的能力。这就像遗传中缺了变异,物种就不会进化出丰富的形态和能力了
那么参数
,
,及偏置
,
的初始值是什么? 又是如何更新的呢?我们先来看下初始值
当定义好模型,执行以下代码实例化模型时:
model = MLP()
PyTorch 会自动为你的每一层(例如 nn.Linear)生成一组初始权重和 初始偏置。
这些初始值 不是全为 0,也不是全为 1 ,而是从某种概率分布中 随机采样出来的数值 !
PyTorch 也支持你用 torch.nn.init 模块手动设定权重初始值,这里就不展开了。
我们可以通过以下代码,打印下初始权重看看:
model = MLP()
print(f"fc1.weight:")
print(model.fc1.weight.data)
print("\nfc1.bias:")
print(model.fc1.bias.data)
print("\nfc3.weight:")
print(model.fc3.weight.data)
print("\nfc3.bias:")
print(model.fc3.bias.data)
在 Jupyter 中可以看到类似如下输出:
----- 初始权重和偏置参数:
fc1.weight.shape:torch.Size([512, 784])
fc1.weight:
tensor([[-0.0114, -0.0108, -0.0142, ..., -0.0291, 0.0284, -0.0165],
[ 0.0025, 0.0220, -0.0124, ..., -0.0053, 0.0252, 0.0342],
[ 0.0184, 0.0034, 0.0044, ..., 0.0037, -0.0164, -0.0269],
...,
[ 0.0055, 0.0348, -0.0229, ..., 0.0182, 0.0155, -0.0074],
[-0.0117, -0.0185, -0.0128, ..., -0.0299, -0.0345, 0.0234],
[-0.0321, 0.0172, 0.0070, ..., -0.0080, -0.0198, -0.0178]])
fc1.bias:
tensor([-2.4902e-03, -1.4291e-02, 2.9421e-02, 3.4671e-03, -1.6535e-02,
。。。
-1.6776e-02, -1.7924e-02])
fc3.weight:
tensor([[ 0.0079, 0.0337, -0.0289, ..., -0.0052, 0.0380, 0.0055],
[ 0.0127, -0.0006, 0.0209, ..., 0.0379, 0.0191, 0.0254],
[ 0.0247, 0.0291, -0.0160, ..., -0.0087, 0.0204, -0.0298],
...,
[ 0.0068, -0.0374, -0.0327, ..., 0.0371, 0.0080, 0.0135],
[ 0.0042, -0.0291, -0.0007, ..., -0.0246, -0.0369, -0.0115],
[ 0.0229, -0.0348, 0.0250, ..., 0.0060, 0.0274, -0.0048]])
fc3.bias:
tensor([-0.0227, 0.0110, -0.0008, -0.0030, 0.0097, 0.0047, 0.0030, 0.0433,
0.0112, -0.0292])
这就是初始权重。这个模型的参数数量:
- • 第一层:权重参数:784 × 512 = 401,408,偏置参数:512,共 401,408 + 512 = 401920
- • 第二层:权重参数:512 × 10 = 5120,偏置参数:10,共 5120 + 10 = 5130
总共有 401920+5130 = 407050 个可训练参数。模型从这些初始权重开始,在后面的训练过程中,通过一个个样本来学习其内在规律,并逐步更新这些权重。
模型具体是怎么学习的呢? 对于每个样本,学习过程(训练迭代)包含前向传播、损失计算、反向传播、梯度下降、权重更新这几个环节
前向传播的计算过程,就是前面 PyTorch 模型定义中,forword 函数执行的过程。第一次前向传播计算,从初始权重开始。每次 “前向传播”也可以认为是基于模型当前的权重参数,对输入样本做一次预测。我们用一个简化的数据,一步步手算下,假设:
- • 输入图像
(为了简单,我们只用2个输入维度来讲解)
- • 第一层权重矩阵
,偏置
- • 第二层(输出层)权重
,我们暂时只取前两个神经元的输出做说明。
第一步:输入 → 隐藏层
经过 ReLU 激活:
第二步:隐藏层 → 输出层
设输出层的参数为:
那么:
这个输出 [-0.1, 1.18] 就是 logits ,可以用 softmax 函数转为概率。
这个预测的结果,如果是推理场景,就作为结果返回了。如果是训练场景,就会计算和标注值的偏差,根据这个偏差程度,来计算权重如何更新。这个偏差,通常叫做损失(loss)
假设真实标签是数字 "1"(标注为1),我们希望模型对“1”打分高。
我们常用 CrossEntropyLoss 作为损失函数,在 PyTorch 中有对应的实现 nn.CrossEntropyLoss() ,它的计算等价于:
其中
是模型输出的 logits ,
是真实标签。对我们前面的 logits 样例使用交叉熵损失函数:
那么损失0.26 这个值越小,表示模型越接近正确答案。
反向传播的本质就是:计算每个参数对损失的“责任”(梯度),然后反省调整!
如何根据损失确定每个参数的责任呢?这里要用到链式求导法则(Chain Rule)。
在我们的例子中:
这是交叉熵损失 + softmax。那我们会先计算:
再乘上:
得到:
继续传回去:
最终:
反向传播也就是梯度在链式法则下层层往回传递。回到我们前面的简化数据的例子,通过 链式法则 计算损失对每个参数的导数。
比如:
-
• 输出层梯度:
-
• 再计算:
这表示第一个输出神经元的权重应略增,第二个应略减。
神经网络的目标是最小化一个“误差”或者“损失”,比如预测错数字的程度。梯度 :表示“你当前的参数往哪个方向调整,误差会上升最快”。那么我们就:往反方向走 ,让误差最小。这就是梯度下降。
梯度指明了权重更新的方向。在这个方向上具体怎么更新,涉及到一个更新的步子大小的问题。这个步子大小由学习率控制。
权重更新的数学式:对每个参数
,我们通过下式来更新它:
其中:
- •
是学习率(learning rate);
- •
是损失函数对参数 w 的导数;
- 表示我们每次“走一点点”,让误差越来越小。
举个例子:
假设模型对数字3预测错了,计算出的损失对某个权重的梯度是 +0.2,学习率是 0.1:
这个参数就被“拉回来”一点,模型变得更“准确”一点 。
权重更新后,一个完整的训练迭代就完成了。下一个(下一批)样本会在新的权重下,继续同样的迭代,直到所有的训练数据执行完,则训练完成。如果过程中 loss 值已经达到最小,也可以提前结束训练,避免过拟合。最后产生的权重,就是训练出来的结果模型。
MLP就是这样一步步学会如何分类的:
前向传播 :像人一样“推理”出一个结论
损失函数 :衡量结论离正确答案有多远
反向传播 :计算每个参数对错误的“责任”
梯度下降 :根据责任方向(梯度方向)小步修正这些参数
循环这一过程 ,直到模型变得“聪明”
在神经网络中,我们通过前向传播 计算模型预测结果,通过损失函数 评估预测的好坏。接着,利用链式法则 ,一步步反推每个参数对误差的“责任”(即导数或梯度),再通过梯度下降 方法,把每个参数朝着减小误差的方向微调一小步。这个“预测 → 评估 → 反省 → 改正”的循环,就是神经网络训练的核心。
是不是破除了“神秘”感后,比你想象得更“简单”了?
具体代码可以参见上一篇《 深度学习模型训练的一般过程》。上一篇用了两个隐藏层。发现删掉一个后,最终loss略有下降,但差别不大:
测试集: 平均损失: 0.0001, 准确率: 9743/10000 (97.43%)