第 3 讲:神经网络基础与反向传播

Natural Language Processing with Deep Learning · Lecture 03。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应:Stanford CS224N Winter 2026 Lecture 3(Backpropagation and Neural Network Basics);Winter 2025 Lecture 3;Manning 主讲的 Winter 2021 / 2023 版第 3 讲(窗口分类与神经网络、矩阵微积分)和第 4 讲前半(反向传播),录像在 YouTube 上。 配套代码:code/lecture03/(04 号文件的演示 6 会导入第 2 讲的代码) 预计阅读时间:4–5 小时(第一遍通读 2 小时,第二遍推公式、对照代码 2–3 小时)


0. 本讲导航

0.1 学完本讲,你应该能够

  1. 说清楚分类问题的设置,以及为什么 softmax 回归这样的线性分类器表达能力有限。
  2. 描述命名实体识别(NER)任务和它的难点,解释窗口分类器的结构。
  3. 解释神经元、层、非线性激活函数的作用,比较 sigmoid、tanh、ReLU。
  4. 用雅可比矩阵和链式法则推导一层神经网络对 WW、bb、xx 的梯度,理解”形状约定”。
  5. 解释计算图和反向传播:上游梯度 × 局部梯度 = 下游梯度;为什么梯度要累加;为什么”反向”比”前向”高效。
  6. 读懂并写出一个最基本的 PyTorch 训练循环。
  7. 解释参数初始化、学习率、优化器(动量、Adam)、正则化(L2、dropout)各自解决什么问题。
  8. 判断什么时候应该微调预训练的词向量、什么时候应该冻结它。

0.2 先修知识

知识点用在哪里最低要求
第 1、2 讲全讲softmax、交叉熵、“期望 − 观测”的梯度、梯度检验、SGD
矩阵乘法第 4 节起会算矩阵乘向量;知道 (AB)⊤=B⊤A⊤(AB)^\top = B^\top A^\top
求导与链式法则第 4、5 节第 1 讲第 10.1 节的四条规则
PyTorch第 7 节不需要,本讲从零介绍

0.3 本讲地图

第1节  从词向量到分类器:线性分类器的局限            ← 代码 03 演示 1
第2节  命名实体识别与窗口分类                       ← 代码 04
第3节  神经网络:神经元、层、非线性                  ← 代码 03
第4节  矩阵微积分:一次推导一整层的梯度              ← 代码 01(本讲重点之一)
第5节  反向传播:计算图上的链式法则                  ← 代码 02、03(本讲重点之二)
第6节  梯度检验
第7节  PyTorch 入门                                 ← 代码 04 演示 1
第8节  训练神经网络的实用技巧                        ← 代码 05
第9–13节 小结、与作业的关系、练习、延伸阅读、下一讲预告

前两讲解决了”词怎么表示”;这一讲解决”有了词向量,怎么搭一个模型去完成任务,并把它训练好”。本讲的数学(矩阵微积分、反向传播)是后面所有模型——RNN、Transformer、大语言模型——的共同基础。


1. 从词向量到分类器

1.1 分类问题的设置

NLP 中大量任务可以看作分类:

任务输入 xx输出 yy
情感分析一条评论正面 / 负面
垃圾邮件识别一封邮件垃圾 / 正常
命名实体识别一个词(及其上下文)人名 / 地名 / 机构名 / 其他
依存句法分析(第 4 讲)分析器的当前状态下一步该做哪个动作
语言模型(第 5 讲)前面的若干个词下一个词($V$ 个类别)

我们有一个训练集 {(xi,yi)}i=1N\{(x_i, y_i)\}_{i=1}^N,xix_i 是输入(比如由词向量组成的向量),yiy_i 是 CC 个类别之一。

1.2 softmax 分类器(回顾)

最简单的分类器是 softmax 回归(也叫多分类逻辑回归):

p(y∣x)=softmax(Wx+b)y=exp⁡(Wy⋅x+by)∑c=1Cexp⁡(Wc⋅x+bc)p(y \mid x) = \text{softmax}(W x + b)_y = \frac{\exp(W_y \cdot x + b_y)}{\sum_{c=1}^{C} \exp(W_c \cdot x + b_c)}

WW 的第 cc 行 WcW_c 可以理解为类别 cc 的”模板”,和输入的点积越大,越可能是这一类。训练时最小化交叉熵损失 J=−log⁡p(yi∣xi)J = -\log p(y_i \mid x_i),梯度是我们的老朋友 y^−y\hat{y} - y。

在传统机器学习中,xx 是固定的特征,只训练 WW。深度学习 NLP 的不同之处在于:我们同时学习 WW 和输入表示 xx(比如词向量),甚至在中间加很多层来学习更好的表示。

1.3 线性分类器的局限

softmax 分类器的决策边界是直线(高维中是超平面):类别 cc 和 c′c' 的分界处满足 Wc⋅x+bc=Wc′⋅x+bc′W_c \cdot x + b_c = W_{c'} \cdot x + b_{c'},这是一个线性方程。

很多问题不能用直线分开。最经典的例子是 XOR(异或):(0,0) 和 (1,1) 属于一类,(0,1) 和 (1,0) 属于另一类,四个点放在正方形的四个角上,没有任何一条直线能把对角的两组分开。

动手:代码 03_numpy_mlp.py 演示 1 用了更难的”螺旋”数据集:三类点各自绕成一条螺旋线,彼此缠绕。softmax 回归训练 1000 步后,训练准确率只有 54.0%,终端里画出的决策边界是三块被直线切开的区域,每块里都混着大量别的类别的点。

要画出弯曲的边界,就需要神经网络(第 3 节)。在那之前,先看一个具体的 NLP 任务。


2. 命名实体识别与窗口分类

2.1 任务

命名实体识别(Named Entity Recognition,NER)是找出文本中的人名、地名、机构名等”命名实体”,并分类:

Last  night  ,  Paris  Hilton  wowed  in  a  sequin  gown  .
O     O      O  PER    PER     O      O   O  O       O     O

Samuel  Quinn  was  arrested  in  the  Hilton  Hotel  in  Paris  in  April  1989  .
PER     PER    O    O         O   O    LOC     LOC    O   LOC    O   DATE   DATE  O

(这是 CS224N 课件里的例子。)NER 是信息抽取、问答、构建知识库的基础步骤:要回答”谁在哪里做了什么”,先得知道哪些词是”谁”和”哪里”。

2.2 为什么难

上面两句话已经展示了 NER 的几个难点:

  1. 同一个词,不同的类别:第一句的 Paris 是人名(Paris Hilton),第二句是地名;Hilton 在第一句是人名,在第二句是酒店名的一部分。还有 Washington(人名 / 地名)、Jordan(人名 / 国家)、Apple(公司 / 水果)……
  2. 新实体层出不穷:新的人名、公司名、产品名每天都在出现,不可能都在训练数据里见过。
  3. 边界难定:First National Bank 是一个实体还是”First National” + “Bank”?
  4. 类别本身模糊:Future School 是一所学校的名字,还是”未来的学校”?

难点 1 和 2 的共同出路是:看上下文。“born in ___“后面多半是地名,“shares of ___“后面多半是公司名,“mr. ___“后面多半是人名。

2.3 窗口分类

最直接的利用上下文的办法是窗口分类(window classification):要给一个词分类,就把它和左右各 mm 个词一起看。

怎么把一个窗口变成分类器的输入?

  • 一个想法是把窗口内的词向量求平均。但平均会丢掉位置信息:“in Paris”和”Paris in”平均起来一样,而我们需要知道哪个词在中心、哪个词在左边。
  • 更好的做法是把窗口内的词向量拼接(concatenate)成一个长向量。例如 m=2m = 2、词向量 dd 维时:
xwindow=[ xmuseums, xin, xParis, xare, xamazing ]∈R5dx_{\text{window}} = \big[\, x_\text{museums},\ x_\text{in},\ x_\text{Paris},\ x_\text{are},\ x_\text{amazing} \,\big] \in \mathbb{R}^{5d}

然后把 xwindowx_{\text{window}} 送进一个分类器。Collobert 和 Weston(2008)、Collobert 等人(2011,Natural Language Processing (Almost) from Scratch)正是用这种”词向量 + 窗口 + 神经网络”的方法,在 NER、词性标注等任务上达到了当时的最好水平,几乎不需要人工设计特征。这篇论文是 NLP 走向深度学习的里程碑之一。

我们的模型(代码 04_pytorch_window_ner.py):

x=[窗口内的词向量拼接]  →  h=tanh⁡(Wx+b)  →  logits=Uh+c  →  softmaxx = [\text{窗口内的词向量拼接}] \;\to\; h = \tanh(W x + b) \;\to\; \text{logits} = U h + c \;\to\; \text{softmax}

2.4 怎么评估:精确率、召回率、F1

NER 中绝大多数词都是 O(我们的训练集里 82% 是 O)。如果用准确率评估,一个把所有词都猜成 O 的模型也有 82% 的准确率,毫无意义。所以对每个实体类别 cc 分别计算:

  • 精确率(precision)P=TPTP+FPP = \frac{TP}{TP + FP}:模型说是 cc 的词里,有多少真的是 cc;
  • 召回率(recall)R=TPTP+FNR = \frac{TP}{TP + FN}:真的是 cc 的词里,模型找出来了多少;
  • F1 =2PRP+R= \frac{2PR}{P + R}:两者的调和平均。任何一个很低,F1 就很低。

再对各个实体类别的 F1 取平均(宏平均)。真实的 NER 评估(如 CoNLL-2003)用实体级的 F1:一个两个词的人名,两个词都标对才算对。我们的代码为了简单用词级别。

2.5 实验:上下文到底有多重要?

动手:代码 04 用 ner_data.py 生成了一个玩具 NER 数据集(全部小写,不能靠大写字母判断):4000 个训练句子、28,184 个词。关键设计是:每类实体名单的约 1/3 只出现在测试集里——“未见实体”测试集中的 emma、kenya、tesla 等 23 个实体词,模型在训练时从没见过,它们在模型眼里都是同一个 <unk>。

演示 3:m=2m = 2、词向量 30 维、隐藏层 100 个单元,训练 8 轮(在编写讲义的电脑上约 2 秒)。已见实体测试集宏平均 F1 = 1.000;未见实体测试集宏平均 F1 = 0.992;只看 905 个未见实体词,准确率 97.9%——模型完全靠上下文认出了它们。

演示 4 比较了不同的窗口大小,以及一个叫词丢弃(word dropout)的技巧(3 个随机种子取平均):

设置已见实体 F1未见实体 F1未见实体词准确率
m=0m = 0,不丢弃0.9200.07015.2%
m=0m = 0,词丢弃 0.20.9140.0000.0%
m=1m = 1,不丢弃1.0000.79670.5%
m=1m = 1,词丢弃 0.21.0000.91484.0%
m=2m = 2,不丢弃1.0000.94189.9%
m=2m = 2,词丢弃 0.21.0000.99598.6%

几点观察:

  1. m=0m = 0(只看词本身)对未见实体完全无能为力:所有没见过的词都是同一个 <unk>,模型没有任何信息可用。
  2. 窗口越大,上下文越充分,未见实体的表现越好。
  3. 词丢弃:训练时把窗口里的每个词以 20% 的概率替换成 <unk>。不这样做的话,训练集里实体的位置上从来没出现过 <unk>,模型遇到 <unk> 时倾向于猜 O;加了词丢弃,模型提前”练习”了”中心词不认识就看上下文”。这是一种数据增强,也是一种正则化(第 8.4 节)。
  4. 但 m=0m = 0 时词丢弃反而更糟:没有上下文可看,被换成 <unk> 的词大多是 O,模型只能学到”<unk> 就是 O”。词丢弃只有在模型有别的信息可依靠时才有用。

动手:代码 04 演示 5 专门看 4 个歧义词(jordan、washington、apple、amazon):在已见实体测试集的 166 次出现中,m=0m = 0 的准确率只有 41.0%(同一个词只能给出同一个答案),m=2m = 2 是 100%。


3. 神经网络:神经元、层、非线性

3.1 一个神经元 = 一个逻辑回归单元

一个神经元接收输入向量 xx,计算加权和再加偏置,最后通过一个激活函数 ff:

h=f(w⊤x+b)h = f(w^\top x + b)

如果 ff 是 sigmoid,一个神经元就是一个二分类逻辑回归:ww 是权重,bb 是偏置,输出是 0 到 1 之间的”激活程度”。(“神经元”这个名字来自它和生物神经元的粗略类比:接收信号、加权求和、超过阈值就”激发”。但今天的人工神经网络和大脑的关系已经很远了。)

3.2 一层 = 很多个逻辑回归同时运行

把很多个神经元并排放在一起,用同一个输入,就得到一层:

h=f(Wx+b),W∈Rnout×nin, b∈Rnouth = f(W x + b), \qquad W \in \mathbb{R}^{n_\text{out} \times n_\text{in}},\ b \in \mathbb{R}^{n_\text{out}}

WW 的每一行是一个神经元的权重,ff 逐元素作用。一层就像同时运行 noutn_\text{out} 个逻辑回归。

然后可以把这一层的输出 hh 作为下一层的输入,一层层堆叠起来,最后接一个 softmax 分类器。这就是前馈神经网络(feed-forward network),也叫多层感知机(MLP)。

关键的一点:我们不需要事先决定中间那些逻辑回归要预测什么。它们的”任务”完全由最终的损失函数通过反向传播来决定——网络会自己学出对最终任务有用的中间特征。这就是”表示学习”(representation learning)。

3.3 为什么需要非线性?

如果没有激活函数(或者 ff 是恒等函数),两层网络就是

W2(W1x+b1)+b2=(W2W1) x+(W2b1+b2)=W′x+b′W_2 (W_1 x + b_1) + b_2 = (W_2 W_1)\, x + (W_2 b_1 + b_2) = W' x + b'

还是一个线性函数!再多的线性层叠起来也等价于一个线性层,表达能力没有任何提升。

非线性激活函数打破了这一点。有一个著名的理论结果叫通用近似定理(universal approximation theorem):只要隐藏单元足够多,一个只有一个隐藏层的神经网络就能以任意精度近似任何连续函数。不过这只说明”存在”这样的网络,不说明能用梯度下降找到它,也不说明需要多少个单元;实践中,更深的网络往往能用少得多的参数表示复杂函数。

动手:代码 03 演示 3 用一个 100 个 ReLU 隐藏单元的两层网络训练螺旋数据,训练准确率达到 99.0%,终端里画出的决策边界是弯曲的螺旋形。直觉上,隐藏层把每个点映射到一个 100 维的新空间,在新空间里三类点变得可以用超平面分开;映射回原来的二维平面,边界就成了曲线。代码 02 演示 5 也用一个 2 → 4 → 4 → 1 的小网络解决了 XOR。

3.4 常见的激活函数

激活函数公式导数特点
sigmoidσ(z)=11+e−z\sigma(z) = \frac{1}{1 + e^{-z}}σ(1−σ)\sigma(1 - \sigma),最大 0.25输出 (0, 1);导数小,深层网络中梯度会越传越小;输出不以 0 为中心
tanhtanh⁡(z)=ez−e−zez+e−z=2σ(2z)−1\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}} = 2\sigma(2z) - 11−tanh⁡21 - \tanh^2,最大 1输出 (−1, 1),以 0 为中心;两端仍会饱和
ReLUmax⁡(0,z)\max(0, z)z>0z > 0 时为 1,否则为 0计算极快;正半轴梯度恒为 1,传得顺畅;负半轴梯度为 0,可能有”死神经元”
Leaky ReLUmax⁡(0.01z,z)\max(0.01 z, z)1 或 0.01负半轴留一点梯度,缓解死神经元
GELU / Swishz⋅Φ(z)z \cdot \Phi(z) / z⋅σ(z)z \cdot \sigma(z)平滑平滑版的 ReLU,Transformer 中常用(第 7 讲)

动手:代码 03 演示 4 用同样的网络结构(100 个隐藏单元、学习率 1.0、训练 3000 步)比较了四种激活函数:

激活函数第 300 步损失最终损失训练准确率
无(linear)0.7600.74154.0%
sigmoid0.7920.53091.0%
tanh0.3490.27196.0%
ReLU0.2590.24299.0%

没有激活函数时和线性分类器一样差(54.0%,和演示 1 相同)。sigmoid 学得最慢,ReLU 最快。

ReLU 成为默认选择的主要原因:梯度在正半轴不会衰减(第 6 讲会看到”梯度消失”在深层网络和 RNN 中有多致命),而且计算只需要一次比较。


4. 矩阵微积分:一次推导一整层的梯度

4.1 为什么需要它

第 1、2 讲我们逐个推导了向量的梯度。神经网络里到处是 Wx+bW x + b 和逐元素的激活函数,如果逐个元素地推导 ∂J/∂Wij\partial J / \partial W_{ij},会写出一堆下标,既繁琐又容易错。矩阵微积分让我们”成块”地推导:只要掌握几条规则,多元微积分用起来就和单变量微积分差不多。

4.2 梯度与雅可比矩阵

  • 标量对向量:f:Rn→Rf: \mathbb{R}^n \to \mathbb{R},梯度是 nn 个偏导数组成的向量 ∂f∂x=[∂f∂x1,…,∂f∂xn]\frac{\partial f}{\partial x} = \left[\frac{\partial f}{\partial x_1}, \dots, \frac{\partial f}{\partial x_n}\right]。
  • 向量对向量:f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m(mm 个输出、nn 个输入),所有偏导数排成一个 m×nm \times n 的矩阵,叫雅可比矩阵(Jacobian):
(∂f∂x)ij=∂fi∂xj\left(\frac{\partial f}{\partial x}\right)_{ij} = \frac{\partial f_i}{\partial x_j}

第 ii 行是”第 ii 个输出对所有输入的导数”,第 jj 列是”所有输出对第 jj 个输入的导数”。

4.3 链式法则 = 雅可比矩阵相乘

单变量时,z=3yz = 3y、y=x2y = x^2,有 dzdx=dzdydydx=3⋅2x\frac{dz}{dx} = \frac{dz}{dy} \frac{dy}{dx} = 3 \cdot 2x。向量时完全一样,只是乘法变成了矩阵乘法:

h=f(z), z=Wx+b⇒∂h∂x=∂h∂z∂z∂xh = f(z),\ z = W x + b \quad\Rightarrow\quad \frac{\partial h}{\partial x} = \frac{\partial h}{\partial z} \frac{\partial z}{\partial x}

下面几条规则覆盖了本课程中绝大部分的推导(代码 01_matrix_calculus.py 演示 1 逐一做了数值验证,误差都在 10−1010^{-10} 量级):

函数雅可比矩阵说明
z=Wx+bz = W x + b∂z∂x=W\frac{\partial z}{\partial x} = W线性层对输入;对 bb 的雅可比矩阵是单位矩阵 II
h=f(z)h = f(z)(逐元素)∂h∂z=diag(f′(z))\frac{\partial h}{\partial z} = \text{diag}\big(f'(z)\big)第 ii 个输出只依赖第 ii 个输入,所以只有对角线非零
s=u⊤hs = u^\top h∂s∂h=u⊤\frac{\partial s}{\partial h} = u^\top标量对向量,1×m1 \times m 的行向量
p=softmax(z)p = \text{softmax}(z)∂p∂z=diag(p)−p p⊤\frac{\partial p}{\partial z} = \text{diag}(p) - p\, p^\top每一列之和为 0(概率之和恒为 1)

一个实现上的要点:逐元素函数的雅可比矩阵是对角阵,实现时永远不要真的把它造出来。“上游梯度 × diag(f′(z)f'(z))” 就等于 “上游梯度 ∘ f′(z)f'(z)”(∘\circ 是逐元素乘法)。

动手:代码 01 演示 2 比较了两种写法:n=4000n = 4000 时,造出 4000×40004000 \times 4000 的对角阵再相乘要 128 MB 内存、十几毫秒;逐元素乘法不到 0.1 毫秒,结果完全相同(具体时间取决于你的电脑)。

4.4 softmax + 交叉熵的梯度

用链式法则可以严格地推出第 1 讲的结论。设 J=−log⁡pyJ = -\log p_y,p=softmax(z)p = \text{softmax}(z):

∂J∂z=∂J∂p∂p∂z=(−1pyey⊤)(diag(p)−p p⊤)\frac{\partial J}{\partial z} = \frac{\partial J}{\partial p} \frac{\partial p}{\partial z} = \left(-\frac{1}{p_y} e_y^\top\right) \left(\text{diag}(p) - p\, p^\top\right)

ey⊤e_y^\top 只挑出右边矩阵的第 yy 行:diag(p)\text{diag}(p) 的第 yy 行是 pyey⊤p_y e_y^\top,p p⊤p\, p^\top 的第 yy 行是 pyp⊤p_y p^\top。所以

∂J∂z=−1py(pyey⊤−pyp⊤)=p⊤−ey⊤=(y^−y)⊤\frac{\partial J}{\partial z} = -\frac{1}{p_y}\left(p_y e_y^\top - p_y p^\top\right) = p^\top - e_y^\top = (\hat{y} - y)^\top

两个”复杂”的雅可比矩阵相乘,得到一个极其简单的结果。这也是为什么所有深度学习框架都把 softmax 和交叉熵合并成一个函数(PyTorch 的 F.cross_entropy):梯度简单,数值上也更稳定(代码 01 演示 3)。

4.5 一层神经网络的梯度

现在推导窗口分类器中最核心的部分。为了简单,把输出层换成一个打分 ss(第 2 讲的 Manning 课件就是这样讲的,换成 softmax 只是把最后一步的梯度换成 y^−y\hat{y} - y):

s=u⊤h,h=f(z),z=Wx+bs = u^\top h, \qquad h = f(z), \qquad z = W x + b

其中 x∈Rnx \in \mathbb{R}^n,W∈Rm×nW \in \mathbb{R}^{m \times n},b,u,z,h∈Rmb, u, z, h \in \mathbb{R}^m。

对 bb 求导(链式法则,三个雅可比矩阵相乘):

∂s∂b=∂s∂h∂h∂z∂z∂b=u⊤ diag(f′(z)) I=(u∘f′(z))⊤\frac{\partial s}{\partial b} = \frac{\partial s}{\partial h} \frac{\partial h}{\partial z} \frac{\partial z}{\partial b} = u^\top\, \text{diag}\big(f'(z)\big)\, I = \big(u \circ f'(z)\big)^\top

记 δ=u∘f′(z)\delta = u \circ f'(z),它叫局部误差信号(error signal):损失对这一层”加权和” zz 的梯度。

对 WW 求导:前两个因子和上面一样,所以

∂s∂W=δ⊤∂z∂W\frac{\partial s}{\partial W} = \delta^\top \frac{\partial z}{\partial W}

∂z∂W\frac{\partial z}{\partial W} 是一个”向量对矩阵”的导数,严格写出来是一个三维的张量。我们换个角度,看单个元素:zi=∑jWijxj+biz_i = \sum_j W_{ij} x_j + b_i,所以 ∂zi∂Wij=xj\frac{\partial z_i}{\partial W_{ij}} = x_j,而 WijW_{ij} 只影响 ziz_i。于是

∂s∂Wij=δi xj⟹∂s∂W=δ x⊤(外积,形状 m×n)\frac{\partial s}{\partial W_{ij}} = \delta_i\, x_j \qquad\Longrightarrow\qquad \frac{\partial s}{\partial W} = \delta\, x^\top \quad (\text{外积,形状 } m \times n)

直觉:WijW_{ij} 连接输入 xjx_j 和隐藏单元 ii,它的梯度 = “传到单元 ii 的误差 δi\delta_i” × “这条边的输入 xjx_j”。

对 xx 求导:

∂s∂x=δ⊤W(写成列向量就是 W⊤δ)\frac{\partial s}{\partial x} = \delta^\top W \qquad (\text{写成列向量就是 } W^\top \delta)

注意 δ\delta 被重复使用了三次:∂s/∂b\partial s / \partial b、∂s/∂W\partial s / \partial W、∂s/∂x\partial s / \partial x 都从它出发。算一次、到处用——这正是第 5 节反向传播高效的根源。

动手:代码 01 演示 4 对 ∂s/∂u=h\partial s / \partial u = h、∂s/∂b=δ\partial s / \partial b = \delta、∂s/∂x=W⊤δ\partial s / \partial x = W^\top \delta、∂s/∂W=δx⊤\partial s / \partial W = \delta x^\top 逐一做了数值检验,最大误差都在 10−1010^{-10} 量级。

4.6 形状约定

严格按雅可比矩阵的定义,标量 ss 对 m×nm \times n 的矩阵 WW 求导,得到的应该是一个 1×mn1 \times mn 的长行向量。但做梯度下降 W←W−α∂s∂WW \leftarrow W - \alpha \frac{\partial s}{\partial W} 时,我们希望梯度和 WW 形状相同。所以按惯例采用形状约定(shape convention):梯度的形状和参数的形状一致,就像上面把它排成 m×nm \times n 的矩阵 δx⊤\delta x^\top。

两种写法各有用处:推导链式法则时用雅可比形式最清楚;写代码、做更新时用形状约定。实用建议:

  1. 推导完成后,检查每个量的形状。比如 ∂s/∂W\partial s / \partial W 必须是 m×nm \times n,而 δ\delta 是 mm 维、xx 是 nn 维,能拼出 m×nm \times n 的只有 δx⊤\delta x^\top——形状本身就能帮你找出转置放在哪里。
  2. 对批量数据(XX 是 N×nN \times n,每行一个样本),梯度要对所有样本求和(或平均):∂J∂W=Δ⊤X\frac{\partial J}{\partial W} = \Delta^\top X,其中 Δ\Delta 是 N×mN \times m 的误差矩阵。矩阵乘法自动完成了求和(代码 03 的 loss_and_grads)。

4.7 梯度传回词向量:要不要更新它?

在窗口分类器中,xx 是窗口内词向量的拼接,所以 ∂s/∂x=W⊤δ\partial s / \partial x = W^\top \delta 按 dd 维一段切开,就是每个词向量的梯度(代码 01 演示 5)。拼接在反向传播时就是切开;如果一个词在窗口里出现两次,两段梯度要加起来。

那要不要用这些梯度去更新词向量本身(微调,fine-tuning)?Manning 在课上举过一个例子:假设我们用预训练的词向量做情感分类,训练集里有 TV 和 telly,但没有 television。预训练时这三个词的向量靠在一起。

  • 如果冻结词向量,分类器学到的边界自然地把 television 也划到了 TV、telly 那一边;
  • 如果微调词向量,只有在训练集中出现过的 TV、telly 会移动位置,television 留在原地,可能被新的边界”抛下”。

动手:代码 04 演示 6 在我们的 NER 数据上重现了这个现象。先在一份 10,000 句的无标注语料上训练 SGNS 词向量(用的是第 2 讲的代码)——无标注语料里出现了所有实体,包括那些在标注数据中从没出现过的名字。然后比较三种做法(“未见实体词准确率”):

设置m=0m = 0m=2m = 2
随机初始化(未见实体都是 <unk>)0.0%97.9%
预训练词向量,冻结100.0%100.0%
预训练词向量,微调69.6%100.0%

m=0m = 0 时差别最明显:模型只能看词本身。预训练的词向量里,emma 和 alice、bob 靠在一起(因为它们在无标注语料里出现在相似的上下文中),冻结时分类器自然地认出了 emma 是人名;微调后见过的名字移动了位置,没见过的被抛下,准确率掉到 69.6%。

经验法则:

  • 标注数据很少时,冻结预训练的词向量(或用很小的学习率微调);
  • 标注数据很多、覆盖了词表的大部分时,微调通常更好,因为词向量可以针对具体任务调整(比如情感任务中 good 和 bad 的上下文很像,预训练向量很接近,微调可以把它们分开)。

这个”预训练 + 冻结 / 微调”的权衡,到了第 9–11 讲的大语言模型时代会以更大的规模再次出现。


5. 反向传播:计算图上的链式法则

5.1 计算图

第 4 节手推了一层网络的梯度。真实的网络有几十层、各种不同的运算,不可能每次都手推。反向传播(backpropagation)把链式法则系统化,让计算机自动完成这件事。

第一步是把计算表示成一张计算图(computation graph):节点是中间结果,边表示”谁由谁算出来”。比如 s=u⊤f(Wx+b)s = u^\top f(W x + b):

  x ──┐
      ├─[×]── Wx ──┐
  W ──┘            ├─[+]── z ──[f]── h ──┐
  b ───────────────┘                     ├─[·]── s
  u ─────────────────────────────────────┘

前向传播:从输入出发,沿着边计算每个节点的值。

反向传播:从输出出发,沿着边反方向计算损失对每个节点的梯度。每个节点只需要知道两件事:

 下游梯度=上游梯度×局部梯度 \boxed{\ \text{下游梯度} = \text{上游梯度} \times \text{局部梯度}\ }
  • 上游梯度(upstream gradient):损失对这个节点输出的梯度,从后面传过来;
  • 局部梯度(local gradient):这个节点的输出对它输入的导数,只取决于这个节点自己做了什么运算;
  • 下游梯度(downstream gradient):损失对这个节点输入的梯度,继续往前传。

每个节点只做”局部”的计算,完全不需要知道整个网络长什么样。这让反向传播可以模块化地实现:每种运算(加法、乘法、tanh……)只要写好自己的前向和局部梯度,就能任意组合。

5.2 一个手算的例子

动手:代码 02_micrograd.py 用不到 200 行纯 Python 实现了一个标量自动微分引擎(思路来自 Andrej Karpathy 的 micrograd)。演示 1 计算 f=(a⋅b+c)⋅df = (a \cdot b + c) \cdot d,a=2,b=−3,c=10,d=−2a = 2, b = -3, c = 10, d = -2:

前向:e = a·b = -6,    s = e + c = 4,    f = s·d = -8
反向:∂f/∂f = 1
      ∂f/∂d = s = 4            (乘法:局部梯度是另一个输入)
      ∂f/∂s = d = -2
      ∂f/∂c = ∂f/∂s × 1 = -2   (加法:局部梯度是 1)
      ∂f/∂e = ∂f/∂s × 1 = -2
      ∂f/∂a = ∂f/∂e × b = 6
      ∂f/∂b = ∂f/∂e × a = -4

和数值梯度完全一致。

5.3 三种”门”的规律

观察常见运算的局部梯度,会发现三个很有用的规律(代码 02 演示 2):

  • 加法门:把上游梯度原样分发给所有输入(局部梯度都是 1)。
  • 乘法门:把上游梯度乘以另一个输入的值,像是”交换”了一下。所以输入数值的大小会影响梯度的大小——这也是为什么输入数据通常要做归一化。
  • max 门:只把梯度路由给较大的那个输入,较小的那个梯度为 0。ReLU =max⁡(0,z)= \max(0, z) 也是如此:z<0z < 0 时梯度为 0。

5.4 一个变量被用了多次:梯度要累加

如果一个变量在计算图中有多条”出边”(被多个运算用到),根据多元链式法则,损失对它的梯度是沿每条路径的梯度之和:

∂f∂a=∑每条从 a 出发的路径(沿该路径的梯度)\frac{\partial f}{\partial a} = \sum_{\text{每条从 } a \text{ 出发的路径}} (\text{沿该路径的梯度})

动手:代码 02 演示 3:f=a⋅a+af = a \cdot a + a,a=3a = 3。aa 有三条出边,正确的 ∂f/∂a=2a+1=7\partial f / \partial a = 2a + 1 = 7。如果在实现中把 grad += ... 错写成 grad = ...,后算的路径会覆盖先算的,得到 3。

这就是为什么反向传播的实现里梯度永远是累加的,也是为什么 PyTorch 每一步训练前都要调用 optimizer.zero_grad() 清零。第 1 讲的 np.add.at、第 2 讲负例重复时的梯度累加、本讲 4.7 节”一个词在窗口里出现两次”,都是同一件事。

5.5 按什么顺序?拓扑排序

反向传播时,一个节点的上游梯度必须是最终值(所有用到它的下游节点都已经把梯度传回来了),才能继续往前传。所以要按拓扑顺序的逆序访问节点:先把节点排成”每个节点都在它的输入之后”的顺序,然后从后往前处理。代码 02 中 Value.backward() 用深度优先搜索做拓扑排序,一共十来行。

复杂度:每个节点在前向时计算一次,在反向时也只处理一次,所以反向传播的计算量和前向传播是同一个数量级。如果不这样做,而是对每个参数分别用链式法则展开整个表达式(“符号求导”),大量的中间结果(比如 4.5 节的 δ\delta)会被重复计算无数次。

5.6 为什么是”反向”?

求导有两种顺序:

  • 前向模式:从一个输入出发,往前推出它对所有中间结果和输出的导数。nn 个输入要做 nn 遍。
  • 反向模式:从一个输出出发,往回推出它对所有中间结果和输入的导数。mm 个输出要做 mm 遍。

神经网络的情形是:参数(输入)有几百万到几千亿个,损失(输出)只有 1 个。所以反向模式只需要 1 遍,就得到损失对所有参数的梯度。

动手:代码 02 演示 6:一个 10 → 16 → 16 → 1 的小网络,计算图有 1372 个节点、465 个参数;一次反向传播访问每个节点恰好一次,就得到了全部 465 个参数的梯度。演示 5 用这个自制引擎训练了一个小网络解决 XOR:损失从 6.10 降到 0.005,四个点的预测分别接近 −1、1、1、−1。

5.7 向量化的反向传播

代码 02 处理的是标量,每个数都是一个节点,非常慢。真实框架中的节点是张量,局部梯度是第 4 节的雅可比矩阵(实现时用等价的高效运算代替)。代码 03 用 numpy 手写了一个两层网络的批量反向传播:

前向:Z=XW1+b1,H=f(Z),logits=HW2+b2反向:Δout=softmax(logits)−YN,∂J∂W2=H⊤Δout,∂J∂H=ΔoutW2⊤反向:ΔZ=∂J∂H∘f′(Z),∂J∂W1=X⊤ΔZ\begin{aligned} &\text{前向:} Z = X W_1 + b_1,\quad H = f(Z),\quad \text{logits} = H W_2 + b_2 \\ &\text{反向:} \Delta_\text{out} = \frac{\text{softmax}(\text{logits}) - Y}{N},\quad \frac{\partial J}{\partial W_2} = H^\top \Delta_\text{out},\quad \frac{\partial J}{\partial H} = \Delta_\text{out} W_2^\top \\ &\phantom{\text{反向:}} \Delta_Z = \frac{\partial J}{\partial H} \circ f'(Z),\quad \frac{\partial J}{\partial W_1} = X^\top \Delta_Z \end{aligned}

(这里每行是一个样本,所以写成 XWX W 而不是 WxW x,转置的位置也相应变化。用形状检查一下:H⊤H^\top 是 nhidden×Nn_\text{hidden} \times N,Δout\Delta_\text{out} 是 N×CN \times C,乘积是 nhidden×Cn_\text{hidden} \times C,和 W2W_2 一样。)

动手:代码 03 演示 2 对 ReLU、tanh、sigmoid 三种激活函数的两层网络做了梯度检验,所有参数梯度的最大相对误差都在 10−1010^{-10} 量级。

5.8 自动微分与深度学习框架

计算梯度有三种办法:

  1. 手推公式(第 4 节):理解原理必需,但网络一复杂就不现实;
  2. 数值差分(梯度检验):简单可靠,但每个参数要算两次前向,太慢,只能用来检查;
  3. 自动微分(automatic differentiation,autodiff):框架在前向时记录计算图,反向时自动应用链式法则。精确、高效、通用。

PyTorch 的 autograd 就是第 3 种。它是动态图:每次前向运行时现场构建计算图,所以可以在模型里随意使用 Python 的 if、for 等控制流。

既然有了自动微分,为什么还要学反向传播? Karpathy 有一篇文章就叫《Yes you should understand backprop》。原因是自动微分是一个会漏的抽象:梯度消失(sigmoid 饱和、深层网络、RNN)、梯度爆炸、ReLU 神经元”死掉”、数值溢出……这些问题出现时,如果不理解反向传播,你只会看到”损失不下降”,却不知道为什么、该怎么修。第 6 讲的 LSTM、第 7 讲的残差连接,都是为了让梯度更好地反向传播而设计的。


6. 梯度检验

第 1 讲已经介绍过梯度检验,这里再强调几点实践经验:

  • 用中心差分 f(θ+ϵ)−f(θ−ϵ)2ϵ\frac{f(\theta + \epsilon) - f(\theta - \epsilon)}{2\epsilon},ϵ≈10−5\epsilon \approx 10^{-5}(误差 O(ϵ2)O(\epsilon^2));
  • 比较相对误差而不是绝对误差;<10−7< 10^{-7} 基本正确,>10−4> 10^{-4} 几乎肯定有错;
  • 在小规模数据和网络上检查(数值梯度对每个参数都要算两次前向);
  • 检查时关掉随机性(dropout、随机采样),否则两次前向结果不同;
  • ReLU、max 在 0 点不可导,扰动恰好跨过 0 时会出现误差,换一组随机输入通常就好了。

用了 PyTorch 之后,你自己写的大多数模型不再需要手动做梯度检验。但如果你要实现一个新的运算(自定义 backward),梯度检验仍然是必不可少的。


7. PyTorch 入门

从这一讲开始,我们用 PyTorch 搭建和训练模型。它提供三样东西:

  1. 张量(tensor):和 numpy 数组几乎一样的多维数组,但可以放在 GPU 上计算;
  2. 自动求导(autograd):对设置了 requires_grad=True 的张量,前向计算时自动记录计算图,调用 .backward() 就能算出梯度;
  3. 现成的模块:nn.Linear、nn.Embedding、各种激活函数、损失函数、优化器。

动手:代码 04 演示 1 逐一演示了这些东西:

  • f = (x ** 2).sum(); f.backward() 之后,x.grad 正好是 2x2x;
  • F.cross_entropy 对 logits 的梯度正好是 y^−y\hat{y} - y;
  • nn.Embedding(5, 3)(ids) 和 one_hot(ids) @ weight 完全相同——嵌入层就是”one-hot × 矩阵”,只是实现时直接按下标取行;
  • nn.Linear(3, 2) 的权重形状是 (2,3)(2, 3)(输出维度在前),计算的是 xW⊤+bx W^\top + b;
  • 用 SGD 最小化 (w−2)2(w - 2)^2,从 w=5w = 5 出发走 30 步,得到 w≈2.004w \approx 2.004。

训练循环的四个步骤,几乎所有 PyTorch 代码都长这样:

for xb, yb in 数据批次:
    optimizer.zero_grad()              # 1. 清零梯度(梯度是累加的,第 5.4 节)
    loss = F.cross_entropy(model(xb), yb)   # 2. 前向:一边算一边记录计算图
    loss.backward()                    # 3. 反向传播:算出每个参数的 .grad
    optimizer.step()                   # 4. 用梯度更新参数

定义模型:继承 nn.Module,在 __init__ 里定义子模块,在 forward 里写前向计算。代码 04 的 WindowClassifier 只有十几行:

class WindowClassifier(nn.Module):
    def __init__(self, vocab_size, m, embed_dim=30, hidden=100, n_classes=4):
        super().__init__()
        self.emb = nn.Embedding(vocab_size, embed_dim)
        self.hidden = nn.Linear((2 * m + 1) * embed_dim, hidden)
        self.out = nn.Linear(hidden, n_classes)

    def forward(self, X):                       # X: (B, 2m+1) 的词编号
        x = self.emb(X).view(X.shape[0], -1)    # 查词向量并拼接
        return self.out(torch.tanh(self.hidden(x)))

还有两个常用的细节:评估时用 @torch.no_grad()(不记录计算图,省内存、提速),以及 model.eval() / model.train() 切换模式(会影响 dropout 等层的行为,第 8.4 节)。


8. 训练神经网络的实用技巧

能写出前向和反向传播,离”把网络训练好”还有一段距离。这一节介绍最重要的几条经验,每一条都有代码 05_training_tricks.py 的实验支持。

8.1 参数初始化

不能全设成 0 或同一个值。 如果一层里所有神经元的参数相同,它们收到相同的输入、算出相同的输出、得到相同的梯度、做相同的更新——永远一模一样。网络再宽,实际上也只有”一个”神经元。必须用随机数打破这种对称性。

动手:代码 05 演示 1(a)。全零初始化时,隐藏层输出全是 tanh⁡(0)=0\tanh(0) = 0,传回第一层的梯度恰好为 0,训练 200 步后第一层权重仍然全是 0,准确率 33.3%(随机水平);全部初始化为 0.5 时,4 个隐藏单元的权重始终完全相同,准确率只有 49.0%。

随机数的尺度也很重要。 考虑一个线性单元 z=∑j=1ninwjxjz = \sum_{j=1}^{n_\text{in}} w_j x_j,假设 wjw_j、xjx_j 都独立、均值为 0,那么

Var(z)=nin⋅Var(w)⋅Var(x)\text{Var}(z) = n_\text{in} \cdot \text{Var}(w) \cdot \text{Var}(x)

如果希望每一层输出的方差和输入的方差差不多(信号既不放大也不缩小),就要让 Var(w)=1/nin\text{Var}(w) = 1 / n_\text{in}。这就是 Xavier 初始化(Glorot & Bengio,2010;原文为了同时照顾反向传播,取 Var(w)=2/(nin+nout)\text{Var}(w) = 2 / (n_\text{in} + n_\text{out}))。对 ReLU,它会把一半的值砍成 0,方差减半,所以要乘 2 补回来:Var(w)=2/nin\text{Var}(w) = 2 / n_\text{in},叫 He 初始化(He 等,2015)。

动手:代码 05 演示 1(b)(c) 构造了一个 10 层、每层 256 个单元的网络,看各层激活值的标准差(第 1 / 4 / 7 / 10 层):

激活函数初始化第 1 层第 4 层第 7 层第 10 层
tanhN(0,0.012)N(0, 0.01^2)0.15610.00060.00000.0000
tanhN(0,1)N(0, 1)0.97490.97410.97400.9743
tanhXavier N(0,1/nin)N(0, 1/n_\text{in})0.62740.35920.27400.2307
ReLUXavier0.58470.17610.06300.0233
ReLUHe N(0,2/nin)N(0, 2/n_\text{in})0.82680.70440.71270.7463

初始值太小,信号到深层几乎变成 0(梯度也随之消失);太大,tanh 全部饱和(第 10 层 87% 的值绝对值超过 0.99),局部梯度 1−tanh⁡2≈01 - \tanh^2 \approx 0,同样学不动。Xavier 让 tanh 网络的信号在 10 层后仍处于合理范围(它仍在缓慢变小,因为 tanh 会把值往 0 压,推导中”线性”的假设只是近似);ReLU 网络配 He 初始化则基本保持不变。

8.2 学习率

学习率是最重要的超参数。

动手:代码 05 演示 3 用 SGD 训练同一个网络 300 步:

学习率第 10 步第 100 步第 300 步
0.0011.0691.0511.016
0.10.9400.7030.608
10.7490.3780.165
5015.08115.12013.694

太小,几乎不动;太大,损失震荡甚至发散。实践中常用的办法是:先试一组按 10 倍间隔的学习率,找到”刚好不发散”的那个,再稍微调小一点;训练过程中逐渐衰减学习率(第 1 讲用的线性衰减,或者每隔一段时间减半、余弦衰减等)。现代大模型训练还会在开头加一段预热(warmup):学习率从 0 逐渐升到最大值,再开始衰减。

8.3 优化器

普通 SGD 的问题是:损失函数的”地形”往往像一条狭长的山谷,梯度方向主要指向谷壁而不是谷底的出口,于是在两壁之间来回震荡,前进很慢。几种改进:

  • 动量(momentum):v←βv+∇Jv \leftarrow \beta v + \nabla J,θ←θ−αv\theta \leftarrow \theta - \alpha v。把过去的梯度累积起来,像一个滚下山的球:方向一致的分量越走越快,来回震荡的分量互相抵消。β\beta 通常取 0.9。
  • AdaGrad(第 2 讲 GloVe 用过):每个参数的学习率除以它历史梯度平方和的平方根。经常有大梯度的参数步子变小,梯度一直很小的参数步子相对变大。缺点是累积量只增不减,学习率会越来越小。
  • RMSprop:把 AdaGrad 的”累加”换成”指数移动平均”,学习率不会一直变小。
  • Adam(Kingma & Ba,2015):动量 + RMSprop,再加上偏差修正。对学习率不那么敏感,是训练 NLP 模型最常用的优化器。
  • AdamW(Loshchilov & Hutter,2019):把权重衰减从梯度中分离出来单独做,是今天训练 Transformer 的标准选择。

动手:代码 05 演示 2 在螺旋数据上比较了四种优化器(全批量 500 步,表中是损失):

优化器第 50 步第 100 步第 200 步第 500 步准确率
SGD,lr = 0.10.7580.7030.6510.52469.0%
SGD + 动量 0.9,lr = 0.10.5790.3990.2270.11197.0%
Adam,lr = 0.010.4000.1950.0800.03299.3%
AdamW,lr = 0.010.4010.1970.0810.03399.3%

8.4 过拟合与正则化

模型参数很多、训练数据很少时,模型会把训练数据(包括其中的噪声)“背”下来:训练误差很低,在新数据上却表现很差。这叫过拟合。缓解过拟合的方法统称为正则化:

  • L2 正则 / 权重衰减:在损失中加上 λ2∑θ2\frac{\lambda}{2} \sum \theta^2,惩罚过大的权重,让函数更平滑。梯度多了一项 λθ\lambda \theta,相当于每一步都把权重缩小一点。
  • Dropout(Srivastava 等,2014):训练时,每个神经元以概率 pp 被临时”关掉”(输出置 0),留下的输出除以 1−p1 - p 以保持期望不变;测试时不做任何处理。直觉上,网络不能依赖任何单个神经元,必须学到冗余、鲁棒的特征;也可以看作是在训练大量共享参数的子网络,测试时取它们的平均。
  • 早停(early stopping):在验证集上的效果不再变好时就停止训练。
  • 数据增强:人为制造更多训练数据,比如第 2.5 节的词丢弃。

动手:代码 05 演示 4 先展示了 nn.Dropout(0.5) 的行为:训练模式下,全 1 向量变成 [0, 0, 2, 0, 0, 0, 2, 2](一半置 0,其余乘 2);评估模式下保持全 1。所以测试前一定要调用 model.eval()。

然后构造了一个容易过拟合的设置:90 个训练点,其中 13 个标签是错的;测试集 900 个点,标签干净;模型 2 → 200 → 200 → 3(约 4 万个参数),训练 2000 步(3 个随机种子取平均):

设置训练准确率测试准确率
不加正则98.9%79.9%
权重衰减 0.198.5%81.1%
权重衰减 1.094.1%87.1%
权重衰减 3.085.6%95.0%
dropout 0.592.2%84.7%
dropout 0.5 + 权重衰减 1.085.9%94.1%

不加正则时,模型连 13 个错误标签也背了下来(训练准确率 98.9%),测试准确率只有 79.9%。正则化之后,训练准确率下降(不再硬记错标签),测试准确率上升——用一点训练误差换更好的泛化,这正是正则化的目的。(训练准确率低于测试准确率,是因为训练标签有错而测试标签是干净的。)权重衰减要足够强才有明显作用:AdamW 每步把权重乘以 1−lr⋅wd1 - \text{lr} \cdot \text{wd},lr = 0.01 时 wd = 0.1 的效果很弱。

8.5 其他实用经验

  • 向量化:永远用矩阵运算代替 Python 循环(第 4.3 节的对角阵、代码 03 的批量反向传播)。差距常常是几十到几百倍;在 GPU 上更大。
  • 先在小数据上过拟合:调试新模型时,先确认它能把几十个样本的训练损失降到接近 0。如果连这都做不到,说明代码有 bug 或模型容量不够,没必要上大数据。第 8 讲会系统地讨论调试方法。
  • 多跑几个随机种子:第 2 讲已经看到,单次运行的差异可能很大一部分是噪声。

9. 本讲小结

  1. 分类问题是 NLP 的基本形式。softmax 回归只能画直线;深度学习 NLP 同时学习分类器和输入表示。
  2. 命名实体识别:给每个词打 PER / LOC / ORG / O 标签,难在歧义和新实体,出路是看上下文。窗口分类器把中心词和左右 mm 个词的词向量拼接后送进神经网络;词丢弃让模型学会处理没见过的词。用精确率、召回率、F1 评估。
  3. 神经网络 = 一层层的”并行逻辑回归”,中间层学到什么由最终损失决定。非线性激活函数是表达能力的来源;ReLU 是默认选择。
  4. 矩阵微积分:链式法则 = 雅可比矩阵相乘;逐元素函数的雅可比矩阵是对角阵,实现时用逐元素乘法。一层网络 s=u⊤f(Wx+b)s = u^\top f(W x + b):δ=u∘f′(z)\delta = u \circ f'(z),∂s/∂b=δ\partial s / \partial b = \delta,∂s/∂W=δx⊤\partial s / \partial W = \delta x^\top,∂s/∂x=W⊤δ\partial s / \partial x = W^\top \delta。梯度和参数同形状。
  5. 反向传播:在计算图上按拓扑逆序,“下游梯度 = 上游梯度 × 局部梯度”;多条出边的梯度要累加;反向模式一遍就得到所有参数的梯度,代价与前向相当。自动微分是会漏的抽象,理解它才能调试。
  6. PyTorch 训练循环:zero_grad → 前向 → backward → step。
  7. 实用技巧:随机初始化打破对称,Xavier / He 初始化保持信号尺度;学习率最重要;Adam / AdamW 是默认优化器;L2、dropout、早停、数据增强缓解过拟合;测试前 model.eval()。
  8. 预训练词向量:标注数据少时冻结更稳妥,多时微调更好。

10. 与作业的关系

作业 2(Winter 2026:Neural Network Foundations, Tensor Derivatives, Dependency Parsing)的前半部分对应本讲:

  1. 张量求导 / 神经网络基础(书面):推导 softmax、交叉熵、各种激活函数的导数和雅可比矩阵,推导一层或两层网络对各个参数的梯度,注意形状约定(本讲第 4 节、练习 2–5)。历年版本中还常有关于 Adam(动量和自适应学习率分别起什么作用)和 dropout(缩放因子怎么取、为什么测试时不用 dropout)的问题(第 8.3、8.4 节、练习 7、8)。
  2. 后半部分是神经依存句法分析器的实现,正是下一讲的内容。它用 PyTorch 实现一个和本讲窗口分类器非常相似的前馈网络:输入是若干个词和词性的嵌入向量的拼接,输出是”下一步做哪个动作”。

作业的具体题目以当年官方发布的版本为准。


11. 练习

练习 1(非线性):证明两个线性层 W2(W1x+b1)+b2W_2(W_1 x + b_1) + b_2 等价于一个线性层。如果把激活函数换成 f(z)=2zf(z) = 2z 呢?

参考答案

W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)W_2(W_1 x + b_1) + b_2 = (W_2 W_1) x + (W_2 b_1 + b_2),令 W′=W2W1W' = W_2 W_1、b′=W2b1+b2b' = W_2 b_1 + b_2 即可。f(z)=2zf(z) = 2z 仍然是线性函数,W2⋅2(W1x+b1)+b2W_2 \cdot 2(W_1 x + b_1) + b_2 同样可以合并成一个线性层。只有真正的非线性函数才能增加表达能力。

练习 2(雅可比矩阵)★:(a) 写出 h=σ(z)h = \sigma(z)(逐元素 sigmoid)的雅可比矩阵。(b) 写出 z=Wxz = W x 对 xx 的雅可比矩阵,并验证它的形状。(c) 为什么实现时不需要真的构造 (a) 中的矩阵?

参考答案

(a) diag(σ(z)∘(1−σ(z)))\text{diag}\big(\sigma(z) \circ (1 - \sigma(z))\big),m×mm \times m 的对角阵。 (b) ∂z∂x=W\frac{\partial z}{\partial x} = W,形状 m×nm \times n:mm 个输出、nn 个输入,符合”输出数 × 输入数”。 (c) 上游梯度(行向量)乘以对角阵,等于上游梯度逐元素乘以对角线上的值。构造对角阵要 O(m2)O(m^2) 的内存和计算,逐元素乘法只要 O(m)O(m)。

练习 3(softmax 的雅可比矩阵)★★:推导 ∂pi∂zj\frac{\partial p_i}{\partial z_j},其中 p=softmax(z)p = \text{softmax}(z),分 i=ji = j 和 i≠ji \ne j 两种情况,并写成矩阵形式。

参考答案

pi=ezi/Sp_i = e^{z_i} / S,S=∑kezkS = \sum_k e^{z_k}。 i=ji = j:商的求导法则,∂pi∂zi=eziS−ezieziS2=pi−pi2=pi(1−pi)\frac{\partial p_i}{\partial z_i} = \frac{e^{z_i} S - e^{z_i} e^{z_i}}{S^2} = p_i - p_i^2 = p_i(1 - p_i)。 i≠ji \ne j:分子不含 zjz_j,∂pi∂zj=−eziezjS2=−pipj\frac{\partial p_i}{\partial z_j} = \frac{-e^{z_i} e^{z_j}}{S^2} = -p_i p_j。 合起来:∂p∂z=diag(p)−p p⊤\frac{\partial p}{\partial z} = \text{diag}(p) - p\, p^\top。可以用代码 01 演示 1(c) 验证。

练习 4(一层网络的梯度)★★:对 s=u⊤hs = u^\top h,h=ReLU(z)h = \text{ReLU}(z),z=Wx+bz = W x + b,写出 ∂s/∂W\partial s / \partial W、∂s/∂b\partial s / \partial b、∂s/∂x\partial s / \partial x,并说明每个量的形状(x∈Rnx \in \mathbb{R}^n,W∈Rm×nW \in \mathbb{R}^{m \times n})。

参考答案

ReLU 的导数是 1[z>0]\mathbb{1}[z > 0](逐元素)。记 δ=u∘1[z>0]∈Rm\delta = u \circ \mathbb{1}[z > 0] \in \mathbb{R}^m。 ∂s/∂b=δ\partial s / \partial b = \delta(mm 维);∂s/∂W=δx⊤\partial s / \partial W = \delta x^\top(m×nm \times n);∂s/∂x=W⊤δ\partial s / \partial x = W^\top \delta(nn 维)。 注意:zi≤0z_i \le 0 的单元 δi=0\delta_i = 0,它的那一行 WW 在这个样本上完全收不到梯度。如果某个单元对所有样本都有 zi≤0z_i \le 0,它就永远不会被更新——这就是”死 ReLU”。

练习 5(批量梯度)★:输入是一批数据 X∈RN×nX \in \mathbb{R}^{N \times n}(每行一个样本),Z=XW+bZ = X W + b(W∈Rn×mW \in \mathbb{R}^{n \times m}),已知 Δ=∂J/∂Z∈RN×m\Delta = \partial J / \partial Z \in \mathbb{R}^{N \times m}。求 ∂J/∂W\partial J / \partial W 和 ∂J/∂b\partial J / \partial b。

参考答案

∂J/∂W=X⊤Δ\partial J / \partial W = X^\top \Delta(n×mn \times m,和 WW 一样);∂J/∂b=Δ\partial J / \partial b = \Delta 按行求和(mm 维)。可以这样检查:对单个样本 ii,梯度是外积 xiδi⊤x_i \delta_i^\top,对所有样本求和就是 ∑ixiδi⊤=X⊤Δ\sum_i x_i \delta_i^\top = X^\top \Delta。

练习 6(反向传播手算):f=(x+y)⋅max⁡(y,z)f = (x + y) \cdot \max(y, z),x=1,y=2,z=0x = 1, y = 2, z = 0。画出计算图,用”上游 × 局部”手算 ∂f/∂x\partial f / \partial x、∂f/∂y\partial f / \partial y、∂f/∂z\partial f / \partial z。

参考答案

a=x+y=3a = x + y = 3,b=max⁡(y,z)=2b = \max(y, z) = 2,f=ab=6f = a b = 6。 ∂f/∂a=b=2\partial f / \partial a = b = 2,∂f/∂b=a=3\partial f / \partial b = a = 3。 加法门分发:∂f/∂x=2\partial f / \partial x = 2,yy 经由 aa 得到 2。max 门路由(y>zy > z):yy 经由 bb 得到 3,∂f/∂z=0\partial f / \partial z = 0。 yy 有两条出边,梯度累加:∂f/∂y=2+3=5\partial f / \partial y = 2 + 3 = 5。 可以用代码 02 的 Value 验证:x, y, z = Value(1), Value(2), Value(0); f = (x + y) * y.maximum(z); f.backward()。

练习 7(Adam)★:Adam 的更新可以简化为 m←β1m+(1−β1)gm \leftarrow \beta_1 m + (1 - \beta_1) g,v←β2v+(1−β2)g2v \leftarrow \beta_2 v + (1 - \beta_2) g^2,θ←θ−α m/(v+ϵ)\theta \leftarrow \theta - \alpha\, m / (\sqrt{v} + \epsilon)。(a) mm 起什么作用?为什么能减小震荡?(b) 除以 v\sqrt{v} 起什么作用?哪些参数的有效学习率会变大?

参考答案

(a) mm 是梯度的指数移动平均(动量)。来回震荡的梯度分量正负交替,平均后相互抵消;方向一致的分量会累积。所以更新方向更稳定。 (b) vv 是梯度平方的移动平均,除以 v\sqrt{v} 让每个参数的步长大致按”梯度的典型大小”归一化。梯度一直很小的参数(比如罕见词的嵌入、损失地形平坦方向上的参数),v\sqrt{v} 小,有效学习率变大;梯度一直很大的参数,有效学习率变小。

练习 8(dropout)★:训练时以概率 pp 把每个隐藏单元置 0。(a) 如果不对剩下的单元做缩放,测试时(不 dropout)每个单元输出的期望和训练时有什么不同?(b) 训练时应该把剩下的值乘以多少?(c) 为什么测试时不使用 dropout?

参考答案

(a) 训练时 E[h~i]=(1−p)hiE[\tilde{h}_i] = (1 - p) h_i,测试时是 hih_i,下一层看到的输入整体变大了 1/(1−p)1/(1-p) 倍,分布不一致。 (b) 乘以 γ=1/(1−p)\gamma = 1 / (1 - p),使 E[γh~i]=hiE[\gamma \tilde{h}_i] = h_i(“反向 dropout”,PyTorch 就是这样实现的)。 (c) dropout 是训练时的正则化手段,测试时我们要的是确定的、最好的预测;不 dropout 相当于对所有子网络取(近似的)平均。如果测试时也随机丢弃,同一个输入每次的预测都不一样。

练习 9(初始化):一个线性层有 nin=400n_\text{in} = 400 个输入,输入各维独立、均值 0、方差 1。若权重从 N(0,σ2)N(0, \sigma^2) 初始化,输出的方差是多少?要让输出方差等于 1,σ\sigma 应该取多少?如果后面接 ReLU 呢?

参考答案

Var(z)=400σ2\text{Var}(z) = 400 \sigma^2。要等于 1,σ=1/20=0.05\sigma = 1/20 = 0.05(Xavier)。接 ReLU 时一半的值被置 0,要补回来,σ2=2/400\sigma^2 = 2/400,σ≈0.0707\sigma \approx 0.0707(He)。

练习 10(动手):

(a) 在代码 03 中把隐藏单元从 100 改成 5、20,螺旋数据的训练准确率如何变化?画出的边界有什么不同? (b) 在代码 04 中把窗口分类器的 tanh 换成 relu,或把隐藏层去掉(直接从拼接的词向量到输出),未见实体的准确率如何变化? (c) 在代码 04 演示 6 中,把微调时的学习率调小 10 倍(给词向量单独设一个学习率,可以用 PyTorch 优化器的参数组),m=0m = 0 时的结果介于冻结和微调之间吗? (d) 在代码 05 演示 4 中,把训练点数从 90 增加到 600,过拟合还明显吗?

练习 11(思考):在代码 04 的数据集中,大写字母被去掉了。在真实的英文 NER 中,大写是非常强的特征(专有名词首字母大写)。如果保留大小写,窗口分类器需要做什么改动?会带来什么新问题(提示:句首的词也大写;词表会变大)?

参考答案

最简单的办法是让词表区分大小写(“Paris”和”paris”是不同的词),但这会让词表变大、每个词形的样本变少;而且句首单词也大写,会和专有名词混淆。常见的做法是把词统一转小写,再额外加一个”大小写特征”(全小写 / 首字母大写 / 全大写 / 含数字等)的嵌入,和词向量拼接在一起作为输入。后来的模型(如基于字符的 CNN/RNN、子词模型)则可以直接从字符层面学到大小写的模式。


12. 延伸阅读

  • CS224N 课程笔记 Part III(Neural Networks, Backpropagation);CS224N 补充材料《Computing Neural Network Gradients》(Kevin Clark):矩阵微积分的详细推导,非常适合配合第 4 节阅读。
  • CS231n 课程笔记:Optimization、Backpropagation(Intuitions)、Neural Networks Part 1–3:反向传播的直观解释和训练技巧,图文并茂。
  • Andrej Karpathy,Yes you should understand backprop(博客文章);The spelled-out intro to neural networks and backpropagation: building micrograd(视频):代码 02 的灵感来源。
  • Collobert et al.,Natural Language Processing (Almost) from Scratch(2011):窗口分类器与”几乎不用特征工程”的 NLP。
  • Glorot & Bengio,Understanding the difficulty of training deep feedforward neural networks(2010):Xavier 初始化。
  • Kingma & Ba,Adam: A Method for Stochastic Optimization(2015);Srivastava et al.,Dropout(2014)。
  • Goodfellow, Bengio & Courville,Deep Learning(网上免费)第 6–8 章。

13. 下一讲预告

窗口分类器只看一个词周围的几个词。可是理解一个句子,需要知道词和词之间的结构:谁是主语、谁是宾语、哪个介词短语修饰哪个词。“我用望远镜看到一个人”和”我看到一个拿着望远镜的人”,区别就在结构上。下一讲(依存句法分析)会讲:

  • 句子的结构是什么?成分句法和依存句法有什么不同?
  • 为什么结构歧义的数量会随句子长度指数增长?
  • 基于转移的依存分析:用一个栈、一个缓冲区和三种动作,一步步分析出依存树;
  • 神经依存分析器(Chen & Manning,2014):用本讲的前馈网络来决定每一步做哪个动作——这正是作业 2 的编程部分。

下一讲之前,建议你:跑通 code/lecture03/ 下的 5 个脚本;不看讲义推一遍第 4.5 节的梯度(练习 4);用代码 02 的 Value 验证练习 6。

← 返回课程目录