Neural Networks and Backpropagation —— 从线性分类器到多层全连接网络、激活函数、计算图、链式法则与梯度流
按照课程传统,老师先快速回顾了前面几讲建立起来的整体框架:我们定义了 \((x, y)\) 数据对与打分函数(scoring function),目前用的是线性打分函数;在此基础上定义了损失函数(loss function)和正则化(regularization),并用"损失景观(loss landscape)是一个大山谷"的比喻引出了梯度下降(gradient descent)——沿损失对 \(W\) 的梯度的反方向、按步长一步步走到谷底。
接着老师回应了同学们反复出现的一个疑问:为什么我们一直只用 Softmax 损失?答案是:它并非唯一的损失函数。Softmax 是深度学习尤其是分类任务中最常用的之一,但还有许多其他选择。老师特别补充介绍了合页损失(hinge loss,早期常被称为 SVM loss):与 Softmax 不同,它不把分数转成概率。它鼓励正确类别的分数 \(s_{y_i}\) 比其他所有类别的分数 \(s_j\) 至少高出一个边界(margin,式中那个"1");条件满足时损失为 0,条件被违反时损失随差距按比例增长。其形式为:
这从另一个角度"惩罚那些把无关类别打分打得过高"的情形。详细例子在第 2 讲的阅读材料中,这里只做高层理解。
优化方面老师重申三点:(1)数值梯度(numerical gradient)与解析梯度(analytical gradient)各有优劣,实践中我们推导解析梯度,当推导或实现没把握时用数值梯度做梯度检验(gradient check);(2)在整套数据上算损失和梯度太贵,所以用 mini-batch(常取 32/64/128/256)做随机梯度下降(SGD);(3)学习率及其衰减调度很重要——传统优化器常从较大学习率开始再逐步衰减,而 Adam 及其变体把这种衰减内建进了优化器,通常不再需要手动调整。Momentum、RMSProp、Adam 的细节见第 3 讲。
到目前为止我们的模型是 \(f = Wx\),其中 \(x\) 是输入,\(D\) 为输入维度(特征数),\(C\) 为类别数(输出节点/神经元数)。这其实就是最简单的神经网络——只有一层的网络。要变成两层神经网络,只需再引入一组权重 \(W_2\),作用在第一层的输出上:
老师特别提醒两点。第一,注意维度:\(W_1\) 把 \(D\) 维输入映射到 \(H\) 维,这里的 \(H\) 是隐藏层(hidden layer)神经元个数,是一个我们自由选择的新维度;\(W_2\) 再把 \(H\) 维映射回 \(C\) 维输出,各层维度必须首尾相接。第二,公式中间的 \(\max(0, \cdot)\) 至关重要——它在两次线性变换之间制造了非线性(non-linearity)。另外,为简洁起见公式里省略了偏置(bias),实际框架中每层都带偏置项。
为什么必须有非线性?老师回顾了之前讲过的例子:有些数据集(如同心圆环上的两类点)无法用一条直线分开;但若先做一个非线性变换(例如把 \(x, y\) 映射到极坐标 \(r, \theta\)),在新空间里就线性可分了。这正说明我们需要某种非线性变换把原始空间"掰弯"到可分的新空间。
这类只由权重、输入和层堆叠而成(核心运算就是矩阵乘法)的网络,通常称为全连接网络(fully connected network)或多层感知机(Multi-Layer Perceptron,MLP)。可以继续堆更多层、更大的网络,同样要注意中间各隐藏层维度互相匹配。
老师在课堂上专门提问:如果把 max 去掉会怎样?此时 \(f = W_2 (W_1 x)\)。同学们正确地回答了:\(W_2 W_1\) 可以合并成另一个矩阵 \(W_3\),整个网络就塌缩成一个线性函数——堆再多层也毫无意义。
神经网络的核心力量来自"线性变换 + 非线性激活"的交替组合。没有激活函数,任意深度的网络都等价于一个单层线性分类器,也就解不了非线性问题。
这个夹在层间的非线性函数,在神经网络术语里叫激活函数(activation function),它在建模中扮演枢纽性的角色。前文的 \(\max(0, \cdot)\) 就是其中一种:ReLU(Rectified Linear Unit,修正线性单元),一个非常流行的默认选择。
老师从高层次给出了隐藏层意义的解读。第 2 讲讲过,线性分类器的每一行权重要学一个覆盖整个物体的"模板(template)";而现在中间有了例如 100 个隐藏神经元,网络就有了创造 100 个模板的能力——这些模板不必对应完整物体,而可以是物体的一部分。例如 CIFAR-10 里鸟、猫、鹿、狗、蛙、马都有眼睛,那么 100 个模板中很可能有一个专门负责"眼睛"这类可在多个类别间共享的局部部件。这些中间模板再组合出最终的 10 个类别输出。(更深入的验证要等到后面讲网络可视化时再展开。)
用 Python 写一个两层网络不到 20 行。\(N\) 是样本数,\(D_{\mathrm{in}}\)、\(H\)、\(D_{\mathrm{out}}\) 分别是输入维度、隐藏神经元数和输出维度:
import numpy as np
N, D_in, H, D_out = 64, 1000, 100, 10 # 维度设定
x = np.random.randn(N, D_in) # 输入
y = np.random.randn(N, D_out) # 目标
w1 = np.random.randn(D_in, H) # 随机初始化权重
w2 = np.random.randn(H, D_out)
# 前向传播(forward pass):逐层应用 W,得到预测与损失
h = np.maximum(0, x @ w1) # 隐藏层 + ReLU
y_pred = h @ w2 # 输出层
loss = np.mean((y_pred - y) ** 2) # 损失值
# 反向传播(backward pass):解析梯度 -> 梯度下降更新 w1, w2
# 这正是本讲下半场的主题
前向传播就是把 \(W\) 逐层作用到输入上,得到预测值并算出损失;之后需要优化——计算解析梯度并用梯度下降更新 \(W_1\)、\(W_2\),向最优值迈一步。其中"计算解析梯度"这一步是整个流程中最重要、也尚未展开的部分,本讲剩余篇幅几乎全部用于让它在各种场景下正确且可扩展地工作。
老师随后系统介绍了常用激活函数及各自的问题,这也是学生提问最密集的段落之一。
以 sigmoid 为激活函数的三层网络前向传播非常直白:对每一层,先算 \(W\) 乘以上一层输入、加偏置,再套激活函数:
问:面对一个新问题,怎么知道该用哪个激活函数?答:很大程度上是经验性的(empirical)。通常做法是:先从 ReLU 这类标准默认开始,或者直接采用该类架构(CNN、Transformer 等)社区里已被反复验证的惯用选择。如果你在设计全新网络,激活函数就像其他超参数一样,是你要做的选择之一。
问:这些激活函数的共同本质是什么?答:最核心的共同点是制造非线性——激活函数绝不能用线性的。之所以有这么多变体,是因为还牵扯到其他因素:梯度消失问题、可微性(differentiability)(网络要靠求导训练,函数必须可微)、以及零中心、平滑性——一个零中心且平滑的函数往往能让网络收敛得更快。
问:整个网络都用同一种激活函数吗?答:通常各隐藏层用同一种;但如前所述,最后的输出层有时会单独用 sigmoid 或 tanh(例如需要二值化输出的场景)。
训练这样的网络时,隐藏神经元数量直接决定模型容量:神经元越多,能学出的决策边界越复杂、分离能力越强。老师把它与第 2 讲的 KNN 类比:隐藏神经元很多时的行为,颇像 \(K=1\) 的最近邻——容量给得太多就会过拟合(overfitting),无法泛化到未见数据。
由此引出一条重要的经验法则:
不要把网络规模当作正则化手段来调。实践中我们不去精细搜索"最佳网络大小"这个超参数;相反,倾向于把网络建得比需要的稍大一些,然后用正则化(regularization)及其超参数(如 \(\lambda\))去控制过拟合。真正要调的是正则化强度,而不是网络尺寸本身。
老师对相关追问做了详细解释:
神经网络确有生物学启示(biological inspiration),但老师一开始就声明:这些类比非常宽松,"如果有神经科学家在场,请别把我说的当成真相"。生物神经元有细胞体(cell body),树突(dendrite)把来自其他神经元的脉冲/信号汇聚到细胞体,轴突(axon)再把信号传给其他神经元。这和我们的人工神经元很像:每个神经元接收上一层的全部激活(脉冲),在"细胞体"处做一个函数运算,产生自己的激活并传给下一层——这也解释了为什么需要激活函数来增强或抑制信号。
但差异同样巨大:生物神经元远比我们的简化模型复杂。我们构建的网络之所以组织成规整的模式(层、矩阵),主要是为了计算效率;虽有人研究任意连接的复杂网络结构,但其效果大体与规整网络相当。老师最后再次郑重提醒:对"大脑类比"要保持警惕,谨慎解读。
把前面所有东西拼起来:打分函数把输入经若干 \(W\) 变换成分数;损失函数(softmax、hinge 或其他)作用于分数;加上正则项得到总损失 \(L = \text{数据损失} + R(W)\)。要优化,就必须求 \(\partial L/\partial W_1\)、\(\partial L/\partial W_2\)。手推这些导数面临三大困难:(1)函数复杂时矩阵推导繁琐易错;(2)损失函数稍一改动,纸上推的一切全部重来;(3)对复杂损失甚至不可行。于是有了更好的办法——计算图与反向传播。
计算图(computational graph)把神经网络中的所有运算组织成一张从输入出发、逐步计算、最终以损失为输出的流程图。以线性分类器为例:\(x\) 与 \(W\) 经一个乘法节点得到分数 \(s\);损失函数(softmax 或 hinge)作用于 \(s\) 得到数据损失;正则函数 \(R(W)\) 作用于 \(W\) 得到正则项;两者经加法节点得到最终损失 \(L\)。任何复杂函数都能用同一框架表达——即便是神经图灵机(Neural Turing Machine)这种含大量时序展开的复杂结构也能画出计算图;手推它的导数不可行,但反向传播可以系统地解决。
从最简单的函数开始:\(f(x, y, z) = (x + y) \cdot z\)。计算图只有两个门:加法门和乘法门。取具体数值 \(x = -2\),\(y = 5\),\(z = -4\),先做前向传播:记中间变量 \(q = x + y = 3\),再算 \(f = q \cdot z = 3 \times (-4) = -12\)。
每个门的局部梯度(local gradient)很容易由定义写出:
然后做反向传播:从图的末端出发,递归地把梯度往回传。起点永远是 \(\partial f/\partial f = 1\)(损失对自身的导数恒为 1)。
计算图(上行为前向值,下行为梯度):
x = -2 y = 5 z = -4
\ / |
[ + 门 ] |
q = 3 \ /
[ * 门 ]
f = -12
反向传播(从末端开始):
df/df = 1
df/dz = q = 3 (乘法门:对 z 的局部梯度是 q)
df/dq = z = -4 (乘法门:对 q 的局部梯度是 z)
df/dy = df/dq * dq/dy = (-4) * 1 = -4 (链式法则)
df/dx = df/dq * dq/dx = (-4) * 1 = -4 (链式法则)
这里出现两个关键术语:上游梯度(upstream gradient)是从网络末端传到当前节点的梯度;局部梯度(local gradient)是该节点输出对自身输入的梯度。每个节点只需做一件事:把上游梯度乘以局部梯度,得到下游梯度(downstream gradient),传给前面的节点——而下游梯度又成为前一节点的上游梯度。如此逐级回传,无需写出整个网络的复合函数表达式。如果网络有 100 层,我们不可能坐在纸上为 100 层写一个巨大的复合函数再求导;反向传播让我们一步步得到优化每个权重所需的梯度。
为什么这套机制重要?它把"求任意复杂函数的梯度"这件事彻底模块化了:每个节点只关心自己输入输出的局部导数,完全不需要了解网络其余部分长什么样。只要每个节点知道自己的局部梯度、并拿到上游传来的梯度,就能算出下游梯度并继续传递——这正是所有多层神经网络与众多优化过程最底层的机制。
第二个例子稍复杂:\(f(w, x) = 1 / (1 + e^{-(w_0 x_0 + w_1 x_1 + w_2)})\),即线性组合上套一个 sigmoid。把它拆成一串基本门:两个乘法门、一个加法门、加常数 \(w_2\)、取负、exp、加 1、取倒数。需要用到的微积分常识:\(e^x\) 的导数是 \(e^x\);乘以常数 \(c\) 的导数是 \(c\);\(1/x\) 的导数是 \(-1/x^2\);加常数的导数是 1。
取 \(w_0 = 2\), \(x_0 = -1\), \(w_1 = -3\), \(x_1 = -2\), \(w_2 = -3\)。先做前向传播:
前向传播:
w0*x0 = 2 * (-1) = -2
w1*x1 = -3 * (-2) = 6
相加 = 4
+ w2 (= -3) = 1
取负 = -1
exp(-1) = 0.37
+ 1 = 1.37
1 / 1.37 = 0.73 (即 sigmoid 输出)
反向传播(从 df/df = 1 开始,逐门回传):
1/x 门:局部梯度 = -1/x^2 = -1/(1.37)^2 = -0.53
下游梯度 = 1 * (-0.53) = -0.53
+1 门 :局部梯度 = 1,下游梯度 = -0.53
exp 门:局部梯度 = e^x = e^(-1) = 0.37
下游梯度 = -0.53 * 0.37 = -0.20
取负门:局部梯度 = -1,下游梯度 = 0.20
+w2 门:局部梯度 = 1,w2 的梯度 = 0.20
加法门 :局部梯度均为 1,两路都传 = 0.20
乘法门1(w0*x0):对 w0 局部梯度 = x0 = -1 → dw0 = 0.20 * (-1) = -0.20
对 x0 局部梯度 = w0 = 2 → dx0 = 0.20 * 2 = 0.40
乘法门2(w1*x1):对 w1 局部梯度 = x1 = -2 → dw1 = 0.20 * (-2) = -0.40
对 x1 局部梯度 = w1 = -3 → dx1 = 0.20 * (-3) = -0.60
这些梯度告诉我们:要让 \(f\) 增大,应增大 \(w_0\) 和 \(w_1\)(梯度为负,梯度下降沿负梯度走)、减小 \(x_0\) 等——它们正是"\(W\) 应该变动多少才能向网络最优点迈一步"的定量答案。
进一步地,不必把 sigmoid 拆成五个小门,可以把整段打包成一个 sigmoid 门。sigmoid 有个非常漂亮的性质:它的导数可以用自己表示——
验证:输入为 1 时 \(\sigma(1) \approx 0.73\),局部梯度 \(= (1 - 0.73) \times 0.73 \approx 0.2\),与上面逐门手算得到的 0.20 完全一致。这说明计算图的粒度可粗可细:只要写得出一个函数的局部梯度,就可以把它整体当作一个门使用。
老师总结了几种常见门的梯度行为,值得背下来:
| 门类型 | 局部梯度 | 梯度行为 |
|---|---|---|
| 加法门(add gate) | \(\partial(x+y)/\partial x = \partial(x+y)/\partial y = 1\) | 梯度分发器(distributor):上游梯度原封不动分给每个输入 |
| 乘法门(multiply gate) | \(\partial(xy)/\partial x = y\),\(\partial(xy)/\partial y = x\) | 交换器(swap):对每个输入的梯度就是另一个输入的值 |
| 复制门(copy gate) | 一个值被复制到多条边 | 各分支梯度相加后回传 |
| max 门 | 对最大值输入为 1,其余为 0 | 梯度路由器(router):梯度只流向值最大的那条输入(与 ReLU 的行为高度相关) |
有了这些模式,实现神经网络变得简单:前向传播逐门算出所有中间值;反向传播从 \(\partial L/\partial L = 1\) 出发逐门回传梯度。更妙的是可以把每个函数模块化,为它分别实现 forward 和 backward 两个 API。例如一个乘法门模块,在前向时保存输入值(backward 要用到),反向时按"交换"规则算梯度:
class MultiplyGate:
def forward(self, x, y):
self.x = x # 缓存输入,反向传播要用
self.y = y
return x * y
def backward(self, d_upstream):
dx = d_upstream * self.y # 交换:对 x 的梯度是 y
dy = d_upstream * self.x # 交换:对 y 的梯度是 x
return dx, dy
拓展:自动微分与 PyTorch autograd。老师指出,现在的 PyTorch 算子正是这种"forward/backward 成对实现"的样子:比如 Sigmoid 层的 forward 就是算 sigmoid 值,backward 里实现的恰是 \((1 - \sigma)\sigma\) 这个我们刚手推过的公式(实际计算位于底层 C++/C 代码中)。这正是自动微分(automatic differentiation,AD)的思想——框架按计算图自动把每个算子的 backward 串起来,用户只需写前向表达式。现代框架(PyTorch 的 autograd、JAX、TensorFlow)都采用反向模式自动微分(reverse-mode AD),即本讲讲的反向传播:一次反向扫过计算图,就能同时得到损失对所有参数的梯度,代价约与一次前向传播相当——这对手推导数不可行的大规模模型至关重要。
前面的例子全是标量,但同样的机制可以推广到向量与矩阵。设 \(x\) 有 \(n\) 个元素、\(y\) 有 \(m\) 个元素,则导数不再是数,而是 \(m \times n\) 的雅可比矩阵(Jacobian matrix):第 \((i, j)\) 个元素表示"\(x_j\) 变动一点点时,\(y_i\) 变动多少"。三种情形:
反向传播的流程不变:损失 \(L\) 永远是标量;上游梯度 \(dL/dz\) 是与 \(z\) 同尺寸的向量;局部梯度 \(\partial z/\partial x\)、\(\partial z/\partial y\) 是雅可比矩阵(尺寸 = 输出尺寸 \(\times\) 输入尺寸);下游梯度 = 上游梯度与局部梯度的矩阵乘积,得到与 \(x\) 同尺寸的向量。一个重要规律:
变量关于损失的梯度,永远与该变量本身尺寸相同。梯度与变量一一对应、形状一致,这是检查实现是否出错的最常用手段。
以 \(f(x) = \max(0, x)\)(ReLU)为例。这是逐元素(element-wise)操作:输出的第 \(i\) 个元素只依赖输入的第 \(i\) 个元素,因此雅可比矩阵是对角矩阵,对角元为 1(该元素为正、通过)或 0(被替换成 0)。把它与上游梯度相乘就得到下游梯度。由于是逐元素运算,实际实现中根本不存储这个巨大而稀疏的矩阵,而是直接用规则化计算:梯度照抄到正元素位置、置零到非正元素位置。
同一套逻辑可继续扩展到矩阵乃至高维张量:梯度和变量同形,上游、局部、下游的关系完全一致。但局部雅可比可能大得离谱。老师给了一个具体数字:若一个节点的输入是 \(64 \times 4096\) 的矩阵(mini-batch 为 64、特征维 4096),其雅可比矩阵将包含 \(4096 \times 4096 \times 64 \times 64\) 量级的元素,超过 256 GB——仅仅一个矩阵乘法节点。
解决办法是回到语义分析:问"当 \(x\) 的某个元素 \(x_{n,d}\) 变动时,\(y\) 的哪些部分受影响、受多大影响?"对 \(y = xW\) 而言,\(x_{n,d}\) 只影响 \(y\) 的第 \(n\) 行;具体影响量是多少?回忆乘法门是交换器——标量情形下对 \(x\) 的梯度就是另一个乘数 \(w\)。矩阵情形是同一个"交换",只需在巨大的矩阵里定位到正确的行列:\(x_{n,d}\) 对 \(y_{n,m}\) 的影响是 \(W_{d,m}\)。据此可把整套雅可比运算重新整理成极简的矩阵公式:
对比记忆:对 \(x\) 求梯度时把整个 \(W\) 转置过来乘;对 \(W\) 求梯度时把整个 \(x\) 转置过来乘——还是那个"交换"模式,只是作用在矩阵级别。有了这些紧凑公式,再大再复杂的算子也能高效地实现其 backward 版本,反向传播也因此能扩展到任意深的网络。老师总结收尾:本讲讲了全连接神经网络,走完了反向传播所需的全部步骤——前向传播与反向传播;下一讲进入卷积神经网络(CNN)。