Lecture 4: Neural Networks and Backpropagation
反向传播与神经网络
引言:为什么我们需要反向传播?
在上一篇文章中,我们建立了一个完美的逻辑闭环:定义模型 \(\rightarrow\) 计算 Loss \(\rightarrow\) 算出梯度(求导) \(\rightarrow\) 梯度下降更新权重 \(W\)。
当我们只使用简单的单层线性分类器(\(f = Wx\))时,我们完全可以拿出一张草稿纸,利用微积分知识,手写推导出完美的解析梯度公式。
但是,当模型变成拥有 100 层、包含数千万个参数、充满各种非线性激活函数的深度神经网络时,手推微积分公式会让人直接崩溃。
我们需要一种算法,能够让计算机自动、模块化、高效地求出极其复杂的复合函数的梯度。这就是本节课的终极核武器:计算图(Computational Graph)与反向传播(Backpropagation)。
1. 前向传播与反向传播 (Forward & Backward Propagation)
面对极其复杂的庞大数学公式,深度学习采用了 “计算图 (Computational Graph)” 的概念进行“分而治之”。整个网络的运行被严格划分为两个阶段:
- 前向传播 (Forward Pass):
- 动作:数据从输入端进入,顺着计算图的箭头方向,一步步执行基础运算(加、乘、Max等),最终计算出损失值 (Loss)。
- 关键任务:在计算的同时,必须把每一个节点的局部中间变量暂存在内存中。因为后面的反向传播需要用到它们!
- 反向传播 (Backward Pass):
- 动作:从最终的 Loss 端开始,逆着箭头方向,一步步把梯度(导数)传回给网络的起点(权重 \(W\) 和输入 \(X\))。
- 目的:告诉网络中的每一个参数:“你对最终的误差负有多大的责任(梯度),你应该朝哪个方向微调”。
2. 求导链式法则应用 (Chain Rule Application)
反向传播听起来高深,但落实到计算图的每一个运算节点(门 Gate)上,它仅仅是在机械地执行微积分中的链式法则。
对于任意一个节点,它的核心运转公式只有一句话:
下游梯度 = 上游梯度 \(\times\) 本地局部梯度
(Downstream Gradient = Upstream Gradient \(\times\) Local Gradient)

以上图函数 \(f(x,y,z)=(x+y)z\) 为例,上述公式可以分为2部分,\(q = x + y\) 和 \(f = qz\)。它们都很简单可以直接写出梯度表达式:
- \(f\) 是 \(q\) 和 \(z\) 的乘积,所以 \(\displaystyle \frac{\partial f}{\partial q} = z = -4,\ \frac{\partial f}{\partial z} = q = 3\)
- \(q\) 是 \(x\) 和 \(y\) 相加,所以 \(\displaystyle \frac{\partial q}{\partial x} = 1,\ \frac{\partial q}{\partial y} = 1\)
我们只关心 \(f\) 对于 \(x,y,z\) 的梯度。由链式法则告诉我们可以用「乘法」将这些梯度表达式链接起来,比如
- 同理,\(\displaystyle \frac{\partial f}{\partial y} = -4\),还有一点是 \(\displaystyle \frac{\partial f}{\partial f} = 1\)
前向传播从输入计算到输出(绿色),反向传播从尾部开始,根据链式法,同时利用前向传播计算出的信息递归地向前计算梯度(显示为红色),一直到网络的输入端。可以认为,梯度是从计算链路中回流。

如图所示,在计算线路回流的过程中,可以把局部看作一个门单元,计算时,该单元是独立的,无需知道其他单元计算情况,只需知道真个网络的最终输出值在该单元输出值上的梯度$\displaystyle \frac{\partial L}{\partial z} $,再根据链式法则,整个网络的输出对该门单元的每个输入值的梯度,要用回传梯度乘以它的输出对输入的局部梯度,得到 $\displaystyle \frac{\partial L}{\partial x} $ 和 $\displaystyle \frac{\partial L}{\partial y} $。这两个值又可以作为前面门单元的回传梯度。
了解这个法则后,我们会发现四种经典算子(门)有着极其鲜明的物理性格:

- 加法门 (Add Gate) —— “梯度分发器”:不论输入值多大,它会把接收到的上游梯度,等值、原封不动地分发给所有的输入分支。
- 乘法门 (Multiply Gate) —— “梯度交换器”:它会将上游梯度乘以另一个输入分支的值。(这也是为什么输入数据太大,会导致“梯度爆炸”的原因)。
- 复制门(Copy Gate)——“梯度合并器”:将梯度相加。
- Max门 (Max Gate) —— “梯度路由器”:在前向传播时谁的值大,反向传播时,上游梯度就全部走那条路,较小分支的梯度直接被“杀死”为 0。
进阶实战:如何写出优雅且高效的反向传播代码?
在理解了反向传播的理论后,当你真正面对一个极其复杂的数学公式想要写代码时,很容易无从下手。我们通过实操案例,传授了深度学习框架底层的四大核心工程技巧。

技巧一:门单元的“合并”与化简 (The Sigmoid Trick)
在计算图中,我们需要把公式拆解成基本运算单元。但是,拆得越细越好吗?并非如此。
以非常经典的 Sigmoid 函数 \(\sigma(x) = \frac{1}{1 + e^{-x}}\) 为例:
- 笨办法:如果在计算图中把它拆成最原生的原子操作,你需要经过
*-1\(\rightarrow\)exp\(\rightarrow\)+1\(\rightarrow\)1/x整整四个节点。反向传播时,你需要一步步算四次链式法则,代码繁琐且极易出错(如截图图1所示)。 - 高阶技巧(合体):在微积分中,只要你能直接推导出一个复合函数的解析导数,你就可以把它们打包成一个“超级门 (Super Gate)”。
- Sigmoid 函数有一个极其优美的求导公式:\(\frac{d\sigma(x)}{dx} = (1 - \sigma(x))\sigma(x)\)
- 有了这个公式,我们完全可以把那四个节点合并成一个单一的
Sigmoid Gate。 - 代码实操:在前向传播算出
f = sigmoid(x)后;反向传播只需一行代码dx = (1 - f) * f * ddot即可瞬间完成计算!(工作量骤降,运行效率极大提升)。
技巧二:复杂公式拆解 (分段计算缓存)
面对一个极其恶心的长串数学公式(例子:\(f(x,y) = \frac{x + \sigma(y)}{\sigma(x) + (x+y)^2}\)),千万不要试图一行代码把导数写完!
工程界最好的做法是:分段计算,大量使用中间变量。
- 前向传播 (Forward):把这个庞大的公式,拆解成十几行极短的代码(如
sigy,num,xpy,den等)。算出最终结果。关键点:这些中间变量全部要暂存在内存里! - 反向传播 (Backward):逆序遍历刚才的十几行代码。因为每一个中间变量的局部运算都极其简单(比如只是一个加法或平方),套用“上游梯度 \(\times\) 局部梯度”,逐行写出对应的
dnum,dden等变量即可。
这种写法虽然代码行数变多了,但逻辑极其清晰,几乎不可能出 Bug,且非常方便调试。
技巧三: 梯度累加法则 (+=)
在截图中那段长长的反向传播代码里,有一个极其不起眼、但每年都有无数新手掉进去的坑。
如果仔细看代码,你会发现对 dx 和 dy 的更新,使用的是 += (累加),而不是 = (直接赋值)!
- 为什么? 根据微积分的多元链式法则:如果一个变量(比如 \(x\))在前向传播时像河流一样产生了分支,被用在了多个不同的地方(比如既用在了分子,又用在了分母),那么在反向传播时,各个分支传回来的梯度,必须全部累加(加和)起来,才是 \(x\) 真正的最终梯度!
- 惨痛教训:如果你在代码里写成了
dx = ...,那就相当于后面的分支梯度直接覆盖(抹杀)了前面分支的梯度,你的模型将永远无法收敛。 - 牢记真理:前向传播有分支,反向传播必累加!
现代深度学习框架的底层 API 设计 (面向对象)
目前的 PyTorch 和 TensorFlow 是怎么实现庞大的反向传播的?
框架并不是写死了一长串数学公式,而是把每一个运算操作(如乘法、加法、ReLU)都封装成了一个独立的“类 (Class)”。比如一个乘法单元:
class MultiplyGate(object):
def forward(self, x, y):
z = x*y
self.x = x
self.y = y
return z
def backward(self, dz):
dx = self.y * dz
dy = self.x * dz
return [dx, dy]
每一个类只负责做两件事(强制包含两个函数):
def forward(self, x, y):- 执行前向运算
z = x * y。 - 核心动作:把输入参数缓存到自身体内(
self.x = x,self.y = y)。 - 返回结果
z。
- 执行前向运算
def backward(self, dz):- 接收来自上游的梯度
dz。 - 提取刚才缓存的
self.x和self.y,计算自己的局部梯度。 - 返回分发给下游的梯度
dx = self.y * dz和dy = self.x * dz。
- 接收来自上游的梯度
整个深度学习框架的计算图,本质上就是把成千上万个这样遵守标准 API 的小对象串联起来。前向跑一遍 forward,反向顺藤摸瓜跑一遍 backward,千层神经网络的梯度计算就此优雅地完成了!
class ComputationalGraph(object):
# ...
def forward(self, inputs):
# 把inputs传递给输入门单元
# 前向传播计算图
# 遍历所有从后向前按顺序排列的门单元
for gate in self.graph.nodes_topologically_sorted():
gate.forward() # 每个门单元都有一个前向传播函数
return loss # 最终输出损失
def backward(self):
# 反向遍历门单元
for gate in reversed(self.graph.nodes_topologically_sorted()):
gate.backward() # 反向传播函数应用链式法则
return inputs_gradients # 输出梯度
return inputs_gradients # 输出梯度
3. 标量与向量化形式计算 (Scalar vs. Vectorized)
前面讲的加减乘除是标量(单个数字)。但在真实的神经网络中,流动的数据全是高维的向量和矩阵(张量)。这就涉及到复杂的雅可比矩阵(Jacobian Matrix)求导。
为了在工程中不被复杂的矩阵微积分绕晕,CS231n 提供了一条最强防呆 Debug 法则:
🚨 黄金法则:任何一个变量(无论它是标量、向量还是矩阵),它的“梯度矩阵”的形状(Shape)必定与该变量本身的形状一模一样!
(即:X.shape == dX.shape,W.shape == dW.shape)
在手写推导全连接层公式(如 \(f = WX\))的反向传播时,如果维度对不上,我们只需要利用矩阵转置 (Transpose) 来强行凑出匹配的形状即可。下图给出一个例子:

4. 神经网络结构 (Neural Network Architectures)
告别了单层线性分类器,我们正式步入神经网络。
- 全连接层 (Fully Connected Layer / FC):最基础的网络结构。前一层的每一个神经元,都与后一层的每一个神经元相连,底层运算依然是矩阵乘法 \(WX+b\)。

- 层数命名规范:在深度学习界,当我们说“一个 2 层神经网络(2-layer Neural Network)”时,我们不计算输入层,只计算隐藏层和输出层。所以 2 层网络 = 1个隐藏层 + 1个输出层。
神经网络到底比线性分类器强在哪里?
在之前的课程中,我们将线性分类器 \(f = Wx\) 理解为“模板匹配”。
回忆一下那个经典的 “双头马” 悲剧(就是截图中下方的那排模糊图片):
由于线性分类器每个类别 只能学习唯一的一个模板(比如 10 个类别就只有 10 个模板),为了讨好所有马的照片,它被迫把“头朝左的马”和“头朝右的马”强行揉在了一起,变成了一个诡异的双头怪物。面对现实世界复杂的多样性,线性分类器彻底不够用了。

这时候,2 层神经网络(2-layer Neural Network) 闪亮登场。它的数学公式仅仅多了一点东西:
但这套简单的公式背后,蕴含着极其深刻的物理内涵。我们可以通过截图中的维度图,拆解它的三大核心魔法:
从“死记硬背”到“学习百搭的零件 (\(W_1\))”
- 看中间那个叫做 \(h\)(Hidden layer 隐藏层)的方块,它的维度是 100。
- 内涵解析:现在的网络不再试图直接去学 10 个类别的最终长相了。第一层矩阵 \(W_1\) 的任务,是去学习 100 个中间特征模板(Sub-templates)。
- 这 100 个模板可能学到的是非常具体的细节:比如模板1是“一个朝左的马头”,模板2是“一个朝右的马头”,模板3是“红色的车身”,模板4是“圆形的轮胎”,模板5是“蓝色的背景”。
聪明的组合与“特征共享 (\(W_2\))”
- 第二层矩阵 \(W_2\) 是一个“聪明的裁判”。它不再看原始像素了,而是看着那 100 个中间特征,进行排列组合打分。
- 解决“双头马”:\(W_2\) 现在可以写出极其灵活的逻辑:“如果在 \(h\) 中,‘朝左的马头’亮了,或者‘朝右的马头’亮了,那最终‘马’的得分就加 10 分!”。马终于不需要再长两个头了。
- 特征共享 (Share templates):这是幻灯片底部最核心的一句话。\(W_1\) 学到的“圆形轮胎”模板,不仅可以给 \(W_2\) 里的“汽车(car)”加分,也可以同时给“卡车(truck)”加分。这大大提高了网络对知识的利用率(泛化能力)。
非线性激活函数的灵魂 (\(\max(0, ...)\))
- 公式中间包裹的 \(\max(0, ...)\) 就是著名的 ReLU 激活函数。
- 内涵解析:如果不加这个函数,公式就会变成 \(f = W_2(W_1x) = (W_2W_1)x\)。因为两个矩阵相乘依然是一个矩阵(假设叫 \(W_{new}\)),这就等于白忙一场,网络瞬间退化回了最原始的、只能学 10 个模板的单层线性分类器!
- 正是 ReLU 这种“非线性”的转折,打破了线性叠加的死局,赋予了多层网络真正拟合复杂逻辑(比如“或”逻辑、“异或”逻辑)的能力。
💡 总结:
线性分类器像是一个只会“死记硬背全身照”的笨学生;而神经网络像是一个“懂拆解”的聪明学生,它通过 \(W_1\) 学习各种百搭的局部零件(特征),再通过非线性激活函数和 \(W_2\) 将这些零件灵活组合,从而完美解决了现实世界万物多变的难题!
5. 激活函数 (Activation Functions)
激活函数是神经网络的灵魂!
如果没有激活函数,即使你堆叠了 100 层全连接层(\(W_3(W_2(W_1X))\)),根据矩阵乘法结合律,它们最终也会塌缩成一个等价的单层矩阵运算(\(W_{new}X\)),依然只能画出一条直线的决策边界。
引入非线性激活函数(如 Sigmoid, ReLU 等),打破了这种线性叠加。
它赋予了神经网络扭曲空间、拟合现实世界中各种极其复杂的非线性边界(如“异或”、“环形分布”)的强大能力。其中,ReLU (\(f(x) = \max(0, x)\)) 是目前最常用、计算最快的激活函数。常用激活函数如下图:

一般而言,多层神经网络的每一层都用同一个激活函数,但是最后靠近输出层很多时候采用 \(Sigmoid\) 函数。
两层神经网络参考代码实现如下,中间层使用 sigmoid 函数:
import numpy as np
from numpy.random import randn
N, D_in, H, D_out = 64, 1000, 100, 10
# x 是64x1000的矩阵,y是64x10的矩阵
x, y = randn(N, D_in), randn(N, D_out)
# w1是1000x100的矩阵,w2是100x10的矩阵
w1, w2 = randn(D_in, H), randn(H, D_out)
# 迭代10000次,损失达到0.0001级
for t in range(10000):
h = 1 / (1 + np.exp(-x.dot(w1))) # 激活函数使用sigmoid函数,中间层
y_pred = h.dot(w2)
loss = np.square(y_pred - y).sum() # 损失使用 L2 范数
print(str(t)+': '+str(loss))
# 反向传播
grad_y_pred = 2.0 * (y_pred - y)
grad_w2 = h.T.dot(grad_y_pred)
grad_h = grad_y_pred.dot(w2.T)
# grad_xw1 = grad_h*h*(1-h)
grad_w1 = x.T.dot(grad_h*h*(1-h))
# 学习率是0.0001
w1 -= 1e-4 * grad_w1
w2 -= 1e-4 * grad_w2
6. 设置层的数量和尺寸
当我们设计神经网络时,经常面临一个选择:隐藏层的神经元到底该设置多少个?这涉及到一个核心概念——网络容量 (Network Capacity)。
神经元越多,表达能力越强 \(\rightarrow\) 引发“过拟合”
观察下方对比图(分类红绿圆点):

- 3 个神经元(小网络):决策边界比较平滑僵硬,它只能大刀阔斧地把数据切成两大块。对于混在绿色区域里的少数红色点,它当作“噪声”忽略了。这在实际应用中往往具有较好的泛化能力 (Generalization)。
- 20 个神经元(大网络):决策边界极其扭曲复杂。它为了把每一个红点和绿点都 100% 分类正确,不惜画出各种七零八落的“孤岛”。这种为了迎合训练数据中的“噪声”而牺牲整体规律的现象,就是声名狼藉的 过拟合 (Overfitting)。
为了防过拟合,应该用小网络吗?
既然 20 个神经元会导致严重的过拟合,而 3 个神经元表现得更克制,那我们是不是应该尽量使用小网络?
错!大错特错!这也是新手最容易踩的坑。
CS231n 给出了极其深刻的底层优化原理解释:
- 小网络的致命缺陷(极难训练):小网络的“损失函数地形 (Loss Surface)”非常崎岖。它拥有的局部极小值很少,而且这些极小值往往都非常糟糕(Loss 很高)。更要命的是,小网络极其依赖初始化的运气。运气好,收敛到一个还行的地方;运气不好,直接卡死在一个烂泥坑里,每次训练结果差异巨大。
- 大网络的降维打击(极易优化):大网络虽然有无数个局部极小值,但在极其高维的空间中,数学家发现大网络的绝大多数局部极小值都非常“优秀”(Loss 很低且彼此差不多)。这意味着梯度下降算法在大网络中犹如如鱼得水,不论你怎么随机初始化,它最终都能稳稳地收敛到一个很好的解。
终极解法:大网络 + 强正则化 (Regularization)
既然小网络练不动,大网络容易过拟合,我们该怎么办?
答案是:使用大网络,然后用“正则化”来限制它的野心!

观察上图:这三个模型全部都是 20 个神经元的大网络,唯一的区别是我们加入了 L2 正则化惩罚(强度为 \(\lambda\))。
- 当 \(\lambda = 0.001\) 时,惩罚太弱,网络依然呈现出过拟合的扭曲状态。
- 当 \(\lambda = 0.1\) 时,奇迹发生了!虽然网络拥有 20 个神经元的庞大容量,但在强正则化的威逼下,它的决策边界变得极其平滑和自然,完美避开了过拟合,同时又保留了大网络极易被梯度下降优化的优势。
网络的数量和尺寸一般受限于硬件,故应为测试前确定,而不作为超参数
7. 理解神经网络 (Understanding Neural Networks)
剥开复杂的数学外衣,我们该如何直观地理解神经网络?课程给出了两个视角:
- 视角一:特征的层级组合 (Hierarchical Templates)
还记得线性分类器那个可怜的“双头马”吗?神经网络解决了这个问题。
网络的第一层不再去死记硬背完整的马,而是学习百搭的局部特征(如马耳朵、车轮胎、斜线条);后面的网络层则像搭积木一样,将这些局部特征组合起来,形成复杂的概念。这极大地提升了模型的表达能力以及对数据的泛化能力。
拥有至少一个隐层的神经网络是一个通用的近似器,神经网络可以近似任何连续函数。
- 视角二:关于生物学的“祛魅”
虽然神经网络常被画得像人脑神经元(树突接收信号,轴突输出信号),但教授明确指出:这仅仅是一个粗糙的灵感比喻。 真实的生物神经系统极其复杂。现代深度学习的成功,本质上是应用数学、微积分优化算力和海量数据的胜利,不要将其过度神化为“仿生学”。
对于全连接神经网络而言,在实践中3层的神经网络会比2层的表现好,然而继续加深(做到4,5,6层)很少有太大帮助。卷积神经网络的情况却不同,在卷积神经网络中,对于一个良好的识别系统来说,深度是一个非常重要的因素(比如当今效果好的CNN都有几十上百层)。对于该现象的一种解释观点是:因为图像拥有层次化结构(比如脸是由眼睛等组成,眼睛又是由边缘组成),所以多层处理对于这种数据就有直观意义。
总结
反向传播彻不是什么高不可攀的黑魔法,它仅仅是一套极其精密、严谨执行链式法则的流水线系统。
在下一篇中,我们将告别纯粹的数学推导,正式进入 CS231n 最激动人心的篇章 —— 卷积神经网络 (CNN)。我们将看看它是如何颠覆传统全连接层,彻底统治计算机视觉领域的!

浙公网安备 33010602011771号