对应: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 学完本讲,你应该能够
- 说清楚分类问题的设置,以及为什么 softmax 回归这样的线性分类器表达能力有限。
- 描述命名实体识别(NER)任务和它的难点,解释窗口分类器的结构。
- 解释神经元、层、非线性激活函数的作用,比较 sigmoid、tanh、ReLU。
- 用雅可比矩阵和链式法则推导一层神经网络对 、、 的梯度,理解”形状约定”。
- 解释计算图和反向传播:上游梯度 × 局部梯度 = 下游梯度;为什么梯度要累加;为什么”反向”比”前向”高效。
- 读懂并写出一个最基本的 PyTorch 训练循环。
- 解释参数初始化、学习率、优化器(动量、Adam)、正则化(L2、dropout)各自解决什么问题。
- 判断什么时候应该微调预训练的词向量、什么时候应该冻结它。
0.2 先修知识
| 知识点 | 用在哪里 | 最低要求 |
|---|---|---|
| 第 1、2 讲 | 全讲 | softmax、交叉熵、“期望 − 观测”的梯度、梯度检验、SGD |
| 矩阵乘法 | 第 4 节起 | 会算矩阵乘向量;知道 |
| 求导与链式法则 | 第 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 中大量任务可以看作分类:
| 任务 | 输入 | 输出 | ||
|---|---|---|---|---|
| 情感分析 | 一条评论 | 正面 / 负面 | ||
| 垃圾邮件识别 | 一封邮件 | 垃圾 / 正常 | ||
| 命名实体识别 | 一个词(及其上下文) | 人名 / 地名 / 机构名 / 其他 | ||
| 依存句法分析(第 4 讲) | 分析器的当前状态 | 下一步该做哪个动作 | ||
| 语言模型(第 5 讲) | 前面的若干个词 | 下一个词($ | V | $ 个类别) |
我们有一个训练集 , 是输入(比如由词向量组成的向量), 是 个类别之一。
1.2 softmax 分类器(回顾)
最简单的分类器是 softmax 回归(也叫多分类逻辑回归):
的第 行 可以理解为类别 的”模板”,和输入的点积越大,越可能是这一类。训练时最小化交叉熵损失 ,梯度是我们的老朋友 。
在传统机器学习中, 是固定的特征,只训练 。深度学习 NLP 的不同之处在于:我们同时学习 和输入表示 (比如词向量),甚至在中间加很多层来学习更好的表示。
1.3 线性分类器的局限
softmax 分类器的决策边界是直线(高维中是超平面):类别 和 的分界处满足 ,这是一个线性方程。
很多问题不能用直线分开。最经典的例子是 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 的几个难点:
- 同一个词,不同的类别:第一句的 Paris 是人名(Paris Hilton),第二句是地名;Hilton 在第一句是人名,在第二句是酒店名的一部分。还有 Washington(人名 / 地名)、Jordan(人名 / 国家)、Apple(公司 / 水果)……
- 新实体层出不穷:新的人名、公司名、产品名每天都在出现,不可能都在训练数据里见过。
- 边界难定:First National Bank 是一个实体还是”First National” + “Bank”?
- 类别本身模糊:Future School 是一所学校的名字,还是”未来的学校”?
难点 1 和 2 的共同出路是:看上下文。“born in ___“后面多半是地名,“shares of ___“后面多半是公司名,“mr. ___“后面多半是人名。
2.3 窗口分类
最直接的利用上下文的办法是窗口分类(window classification):要给一个词分类,就把它和左右各 个词一起看。
怎么把一个窗口变成分类器的输入?
- 一个想法是把窗口内的词向量求平均。但平均会丢掉位置信息:“in Paris”和”Paris in”平均起来一样,而我们需要知道哪个词在中心、哪个词在左边。
- 更好的做法是把窗口内的词向量拼接(concatenate)成一个长向量。例如 、词向量 维时:
然后把 送进一个分类器。Collobert 和 Weston(2008)、Collobert 等人(2011,Natural Language Processing (Almost) from Scratch)正是用这种”词向量 + 窗口 + 神经网络”的方法,在 NER、词性标注等任务上达到了当时的最好水平,几乎不需要人工设计特征。这篇论文是 NLP 走向深度学习的里程碑之一。
我们的模型(代码 04_pytorch_window_ner.py):
2.4 怎么评估:精确率、召回率、F1
NER 中绝大多数词都是 O(我们的训练集里 82% 是 O)。如果用准确率评估,一个把所有词都猜成 O 的模型也有 82% 的准确率,毫无意义。所以对每个实体类别 分别计算:
- 精确率(precision):模型说是 的词里,有多少真的是 ;
- 召回率(recall):真的是 的词里,模型找出来了多少;
- F1 :两者的调和平均。任何一个很低,F1 就很低。
再对各个实体类别的 F1 取平均(宏平均)。真实的 NER 评估(如 CoNLL-2003)用实体级的 F1:一个两个词的人名,两个词都标对才算对。我们的代码为了简单用词级别。
2.5 实验:上下文到底有多重要?
动手:代码 04 用
ner_data.py生成了一个玩具 NER 数据集(全部小写,不能靠大写字母判断):4000 个训练句子、28,184 个词。关键设计是:每类实体名单的约 1/3 只出现在测试集里——“未见实体”测试集中的 emma、kenya、tesla 等 23 个实体词,模型在训练时从没见过,它们在模型眼里都是同一个<unk>。演示 3:、词向量 30 维、隐藏层 100 个单元,训练 8 轮(在编写讲义的电脑上约 2 秒)。已见实体测试集宏平均 F1 = 1.000;未见实体测试集宏平均 F1 = 0.992;只看 905 个未见实体词,准确率 97.9%——模型完全靠上下文认出了它们。
演示 4 比较了不同的窗口大小,以及一个叫词丢弃(word dropout)的技巧(3 个随机种子取平均):
设置 已见实体 F1 未见实体 F1 未见实体词准确率 ,不丢弃 0.920 0.070 15.2% ,词丢弃 0.2 0.914 0.000 0.0% ,不丢弃 1.000 0.796 70.5% ,词丢弃 0.2 1.000 0.914 84.0% ,不丢弃 1.000 0.941 89.9% ,词丢弃 0.2 1.000 0.995 98.6%
几点观察:
- (只看词本身)对未见实体完全无能为力:所有没见过的词都是同一个
<unk>,模型没有任何信息可用。 - 窗口越大,上下文越充分,未见实体的表现越好。
- 词丢弃:训练时把窗口里的每个词以 20% 的概率替换成
<unk>。不这样做的话,训练集里实体的位置上从来没出现过<unk>,模型遇到<unk>时倾向于猜 O;加了词丢弃,模型提前”练习”了”中心词不认识就看上下文”。这是一种数据增强,也是一种正则化(第 8.4 节)。 - 但 时词丢弃反而更糟:没有上下文可看,被换成
<unk>的词大多是 O,模型只能学到”<unk>就是 O”。词丢弃只有在模型有别的信息可依靠时才有用。
动手:代码 04 演示 5 专门看 4 个歧义词(jordan、washington、apple、amazon):在已见实体测试集的 166 次出现中, 的准确率只有 41.0%(同一个词只能给出同一个答案), 是 100%。
3. 神经网络:神经元、层、非线性
3.1 一个神经元 = 一个逻辑回归单元
一个神经元接收输入向量 ,计算加权和再加偏置,最后通过一个激活函数 :
如果 是 sigmoid,一个神经元就是一个二分类逻辑回归: 是权重, 是偏置,输出是 0 到 1 之间的”激活程度”。(“神经元”这个名字来自它和生物神经元的粗略类比:接收信号、加权求和、超过阈值就”激发”。但今天的人工神经网络和大脑的关系已经很远了。)
3.2 一层 = 很多个逻辑回归同时运行
把很多个神经元并排放在一起,用同一个输入,就得到一层:
的每一行是一个神经元的权重, 逐元素作用。一层就像同时运行 个逻辑回归。
然后可以把这一层的输出 作为下一层的输入,一层层堆叠起来,最后接一个 softmax 分类器。这就是前馈神经网络(feed-forward network),也叫多层感知机(MLP)。
关键的一点:我们不需要事先决定中间那些逻辑回归要预测什么。它们的”任务”完全由最终的损失函数通过反向传播来决定——网络会自己学出对最终任务有用的中间特征。这就是”表示学习”(representation learning)。
3.3 为什么需要非线性?
如果没有激活函数(或者 是恒等函数),两层网络就是
还是一个线性函数!再多的线性层叠起来也等价于一个线性层,表达能力没有任何提升。
非线性激活函数打破了这一点。有一个著名的理论结果叫通用近似定理(universal approximation theorem):只要隐藏单元足够多,一个只有一个隐藏层的神经网络就能以任意精度近似任何连续函数。不过这只说明”存在”这样的网络,不说明能用梯度下降找到它,也不说明需要多少个单元;实践中,更深的网络往往能用少得多的参数表示复杂函数。
动手:代码 03 演示 3 用一个 100 个 ReLU 隐藏单元的两层网络训练螺旋数据,训练准确率达到 99.0%,终端里画出的决策边界是弯曲的螺旋形。直觉上,隐藏层把每个点映射到一个 100 维的新空间,在新空间里三类点变得可以用超平面分开;映射回原来的二维平面,边界就成了曲线。代码 02 演示 5 也用一个 2 → 4 → 4 → 1 的小网络解决了 XOR。
3.4 常见的激活函数
| 激活函数 | 公式 | 导数 | 特点 |
|---|---|---|---|
| sigmoid | ,最大 0.25 | 输出 (0, 1);导数小,深层网络中梯度会越传越小;输出不以 0 为中心 | |
| tanh | ,最大 1 | 输出 (−1, 1),以 0 为中心;两端仍会饱和 | |
| ReLU | 时为 1,否则为 0 | 计算极快;正半轴梯度恒为 1,传得顺畅;负半轴梯度为 0,可能有”死神经元” | |
| Leaky ReLU | 1 或 0.01 | 负半轴留一点梯度,缓解死神经元 | |
| GELU / Swish | / | 平滑 | 平滑版的 ReLU,Transformer 中常用(第 7 讲) |
动手:代码 03 演示 4 用同样的网络结构(100 个隐藏单元、学习率 1.0、训练 3000 步)比较了四种激活函数:
激活函数 第 300 步损失 最终损失 训练准确率 无(linear) 0.760 0.741 54.0% sigmoid 0.792 0.530 91.0% tanh 0.349 0.271 96.0% ReLU 0.259 0.242 99.0% 没有激活函数时和线性分类器一样差(54.0%,和演示 1 相同)。sigmoid 学得最慢,ReLU 最快。
ReLU 成为默认选择的主要原因:梯度在正半轴不会衰减(第 6 讲会看到”梯度消失”在深层网络和 RNN 中有多致命),而且计算只需要一次比较。
4. 矩阵微积分:一次推导一整层的梯度
4.1 为什么需要它
第 1、2 讲我们逐个推导了向量的梯度。神经网络里到处是 和逐元素的激活函数,如果逐个元素地推导 ,会写出一堆下标,既繁琐又容易错。矩阵微积分让我们”成块”地推导:只要掌握几条规则,多元微积分用起来就和单变量微积分差不多。
4.2 梯度与雅可比矩阵
- 标量对向量:,梯度是 个偏导数组成的向量 。
- 向量对向量:( 个输出、 个输入),所有偏导数排成一个 的矩阵,叫雅可比矩阵(Jacobian):
第 行是”第 个输出对所有输入的导数”,第 列是”所有输出对第 个输入的导数”。
4.3 链式法则 = 雅可比矩阵相乘
单变量时,、,有 。向量时完全一样,只是乘法变成了矩阵乘法:
下面几条规则覆盖了本课程中绝大部分的推导(代码 01_matrix_calculus.py 演示 1 逐一做了数值验证,误差都在 量级):
| 函数 | 雅可比矩阵 | 说明 |
|---|---|---|
| 线性层对输入;对 的雅可比矩阵是单位矩阵 | ||
| (逐元素) | 第 个输出只依赖第 个输入,所以只有对角线非零 | |
| 标量对向量, 的行向量 | ||
| 每一列之和为 0(概率之和恒为 1) |
一个实现上的要点:逐元素函数的雅可比矩阵是对角阵,实现时永远不要真的把它造出来。“上游梯度 × diag()” 就等于 “上游梯度 ∘ ”( 是逐元素乘法)。
动手:代码 01 演示 2 比较了两种写法: 时,造出 的对角阵再相乘要 128 MB 内存、十几毫秒;逐元素乘法不到 0.1 毫秒,结果完全相同(具体时间取决于你的电脑)。
4.4 softmax + 交叉熵的梯度
用链式法则可以严格地推出第 1 讲的结论。设 ,:
只挑出右边矩阵的第 行: 的第 行是 , 的第 行是 。所以
两个”复杂”的雅可比矩阵相乘,得到一个极其简单的结果。这也是为什么所有深度学习框架都把 softmax 和交叉熵合并成一个函数(PyTorch 的 F.cross_entropy):梯度简单,数值上也更稳定(代码 01 演示 3)。
4.5 一层神经网络的梯度
现在推导窗口分类器中最核心的部分。为了简单,把输出层换成一个打分 (第 2 讲的 Manning 课件就是这样讲的,换成 softmax 只是把最后一步的梯度换成 ):
其中 ,,。
对 求导(链式法则,三个雅可比矩阵相乘):
记 ,它叫局部误差信号(error signal):损失对这一层”加权和” 的梯度。
对 求导:前两个因子和上面一样,所以
是一个”向量对矩阵”的导数,严格写出来是一个三维的张量。我们换个角度,看单个元素:,所以 ,而 只影响 。于是
直觉: 连接输入 和隐藏单元 ,它的梯度 = “传到单元 的误差 ” × “这条边的输入 ”。
对 求导:
注意 被重复使用了三次:、、 都从它出发。算一次、到处用——这正是第 5 节反向传播高效的根源。
动手:代码 01 演示 4 对 、、、 逐一做了数值检验,最大误差都在 量级。
4.6 形状约定
严格按雅可比矩阵的定义,标量 对 的矩阵 求导,得到的应该是一个 的长行向量。但做梯度下降 时,我们希望梯度和 形状相同。所以按惯例采用形状约定(shape convention):梯度的形状和参数的形状一致,就像上面把它排成 的矩阵 。
两种写法各有用处:推导链式法则时用雅可比形式最清楚;写代码、做更新时用形状约定。实用建议:
- 推导完成后,检查每个量的形状。比如 必须是 ,而 是 维、 是 维,能拼出 的只有 ——形状本身就能帮你找出转置放在哪里。
- 对批量数据( 是 ,每行一个样本),梯度要对所有样本求和(或平均):,其中 是 的误差矩阵。矩阵乘法自动完成了求和(代码 03 的
loss_and_grads)。
4.7 梯度传回词向量:要不要更新它?
在窗口分类器中, 是窗口内词向量的拼接,所以 按 维一段切开,就是每个词向量的梯度(代码 01 演示 5)。拼接在反向传播时就是切开;如果一个词在窗口里出现两次,两段梯度要加起来。
那要不要用这些梯度去更新词向量本身(微调,fine-tuning)?Manning 在课上举过一个例子:假设我们用预训练的词向量做情感分类,训练集里有 TV 和 telly,但没有 television。预训练时这三个词的向量靠在一起。
- 如果冻结词向量,分类器学到的边界自然地把 television 也划到了 TV、telly 那一边;
- 如果微调词向量,只有在训练集中出现过的 TV、telly 会移动位置,television 留在原地,可能被新的边界”抛下”。
动手:代码 04 演示 6 在我们的 NER 数据上重现了这个现象。先在一份 10,000 句的无标注语料上训练 SGNS 词向量(用的是第 2 讲的代码)——无标注语料里出现了所有实体,包括那些在标注数据中从没出现过的名字。然后比较三种做法(“未见实体词准确率”):
设置 随机初始化(未见实体都是 <unk>)0.0% 97.9% 预训练词向量,冻结 100.0% 100.0% 预训练词向量,微调 69.6% 100.0% 时差别最明显:模型只能看词本身。预训练的词向量里,emma 和 alice、bob 靠在一起(因为它们在无标注语料里出现在相似的上下文中),冻结时分类器自然地认出了 emma 是人名;微调后见过的名字移动了位置,没见过的被抛下,准确率掉到 69.6%。
经验法则:
- 标注数据很少时,冻结预训练的词向量(或用很小的学习率微调);
- 标注数据很多、覆盖了词表的大部分时,微调通常更好,因为词向量可以针对具体任务调整(比如情感任务中 good 和 bad 的上下文很像,预训练向量很接近,微调可以把它们分开)。
这个”预训练 + 冻结 / 微调”的权衡,到了第 9–11 讲的大语言模型时代会以更大的规模再次出现。
5. 反向传播:计算图上的链式法则
5.1 计算图
第 4 节手推了一层网络的梯度。真实的网络有几十层、各种不同的运算,不可能每次都手推。反向传播(backpropagation)把链式法则系统化,让计算机自动完成这件事。
第一步是把计算表示成一张计算图(computation graph):节点是中间结果,边表示”谁由谁算出来”。比如 :
x ──┐
├─[×]── Wx ──┐
W ──┘ ├─[+]── z ──[f]── h ──┐
b ───────────────┘ ├─[·]── s
u ─────────────────────────────────────┘
前向传播:从输入出发,沿着边计算每个节点的值。
反向传播:从输出出发,沿着边反方向计算损失对每个节点的梯度。每个节点只需要知道两件事:
- 上游梯度(upstream gradient):损失对这个节点输出的梯度,从后面传过来;
- 局部梯度(local gradient):这个节点的输出对它输入的导数,只取决于这个节点自己做了什么运算;
- 下游梯度(downstream gradient):损失对这个节点输入的梯度,继续往前传。
每个节点只做”局部”的计算,完全不需要知道整个网络长什么样。这让反向传播可以模块化地实现:每种运算(加法、乘法、tanh……)只要写好自己的前向和局部梯度,就能任意组合。
5.2 一个手算的例子
动手:代码
02_micrograd.py用不到 200 行纯 Python 实现了一个标量自动微分引擎(思路来自 Andrej Karpathy 的 micrograd)。演示 1 计算 ,:
前向: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 也是如此: 时梯度为 0。
5.4 一个变量被用了多次:梯度要累加
如果一个变量在计算图中有多条”出边”(被多个运算用到),根据多元链式法则,损失对它的梯度是沿每条路径的梯度之和:
动手:代码 02 演示 3:,。 有三条出边,正确的 。如果在实现中把
grad += ...错写成grad = ...,后算的路径会覆盖先算的,得到 3。
这就是为什么反向传播的实现里梯度永远是累加的,也是为什么 PyTorch 每一步训练前都要调用 optimizer.zero_grad() 清零。第 1 讲的 np.add.at、第 2 讲负例重复时的梯度累加、本讲 4.7 节”一个词在窗口里出现两次”,都是同一件事。
5.5 按什么顺序?拓扑排序
反向传播时,一个节点的上游梯度必须是最终值(所有用到它的下游节点都已经把梯度传回来了),才能继续往前传。所以要按拓扑顺序的逆序访问节点:先把节点排成”每个节点都在它的输入之后”的顺序,然后从后往前处理。代码 02 中 Value.backward() 用深度优先搜索做拓扑排序,一共十来行。
复杂度:每个节点在前向时计算一次,在反向时也只处理一次,所以反向传播的计算量和前向传播是同一个数量级。如果不这样做,而是对每个参数分别用链式法则展开整个表达式(“符号求导”),大量的中间结果(比如 4.5 节的 )会被重复计算无数次。
5.6 为什么是”反向”?
求导有两种顺序:
- 前向模式:从一个输入出发,往前推出它对所有中间结果和输出的导数。 个输入要做 遍。
- 反向模式:从一个输出出发,往回推出它对所有中间结果和输入的导数。 个输出要做 遍。
神经网络的情形是:参数(输入)有几百万到几千亿个,损失(输出)只有 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 手写了一个两层网络的批量反向传播:
(这里每行是一个样本,所以写成 而不是 ,转置的位置也相应变化。用形状检查一下: 是 , 是 ,乘积是 ,和 一样。)
动手:代码 03 演示 2 对 ReLU、tanh、sigmoid 三种激活函数的两层网络做了梯度检验,所有参数梯度的最大相对误差都在 量级。
5.8 自动微分与深度学习框架
计算梯度有三种办法:
- 手推公式(第 4 节):理解原理必需,但网络一复杂就不现实;
- 数值差分(梯度检验):简单可靠,但每个参数要算两次前向,太慢,只能用来检查;
- 自动微分(automatic differentiation,autodiff):框架在前向时记录计算图,反向时自动应用链式法则。精确、高效、通用。
PyTorch 的 autograd 就是第 3 种。它是动态图:每次前向运行时现场构建计算图,所以可以在模型里随意使用 Python 的 if、for 等控制流。
既然有了自动微分,为什么还要学反向传播? Karpathy 有一篇文章就叫《Yes you should understand backprop》。原因是自动微分是一个会漏的抽象:梯度消失(sigmoid 饱和、深层网络、RNN)、梯度爆炸、ReLU 神经元”死掉”、数值溢出……这些问题出现时,如果不理解反向传播,你只会看到”损失不下降”,却不知道为什么、该怎么修。第 6 讲的 LSTM、第 7 讲的残差连接,都是为了让梯度更好地反向传播而设计的。
6. 梯度检验
第 1 讲已经介绍过梯度检验,这里再强调几点实践经验:
- 用中心差分 ,(误差 );
- 比较相对误差而不是绝对误差; 基本正确, 几乎肯定有错;
- 在小规模数据和网络上检查(数值梯度对每个参数都要算两次前向);
- 检查时关掉随机性(dropout、随机采样),否则两次前向结果不同;
- ReLU、max 在 0 点不可导,扰动恰好跨过 0 时会出现误差,换一组随机输入通常就好了。
用了 PyTorch 之后,你自己写的大多数模型不再需要手动做梯度检验。但如果你要实现一个新的运算(自定义 backward),梯度检验仍然是必不可少的。
7. PyTorch 入门
从这一讲开始,我们用 PyTorch 搭建和训练模型。它提供三样东西:
- 张量(tensor):和 numpy 数组几乎一样的多维数组,但可以放在 GPU 上计算;
- 自动求导(autograd):对设置了
requires_grad=True的张量,前向计算时自动记录计算图,调用.backward()就能算出梯度; - 现成的模块:
nn.Linear、nn.Embedding、各种激活函数、损失函数、优化器。
动手:代码 04 演示 1 逐一演示了这些东西:
f = (x ** 2).sum(); f.backward()之后,x.grad正好是 ;F.cross_entropy对 logits 的梯度正好是 ;nn.Embedding(5, 3)(ids)和one_hot(ids) @ weight完全相同——嵌入层就是”one-hot × 矩阵”,只是实现时直接按下标取行;nn.Linear(3, 2)的权重形状是 (输出维度在前),计算的是 ;- 用 SGD 最小化 ,从 出发走 30 步,得到 。
训练循环的四个步骤,几乎所有 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)。全零初始化时,隐藏层输出全是 ,传回第一层的梯度恰好为 0,训练 200 步后第一层权重仍然全是 0,准确率 33.3%(随机水平);全部初始化为 0.5 时,4 个隐藏单元的权重始终完全相同,准确率只有 49.0%。
随机数的尺度也很重要。 考虑一个线性单元 ,假设 、 都独立、均值为 0,那么
如果希望每一层输出的方差和输入的方差差不多(信号既不放大也不缩小),就要让 。这就是 Xavier 初始化(Glorot & Bengio,2010;原文为了同时照顾反向传播,取 )。对 ReLU,它会把一半的值砍成 0,方差减半,所以要乘 2 补回来:,叫 He 初始化(He 等,2015)。
动手:代码 05 演示 1(b)(c) 构造了一个 10 层、每层 256 个单元的网络,看各层激活值的标准差(第 1 / 4 / 7 / 10 层):
激活函数 初始化 第 1 层 第 4 层 第 7 层 第 10 层 tanh 0.1561 0.0006 0.0000 0.0000 tanh 0.9749 0.9741 0.9740 0.9743 tanh Xavier 0.6274 0.3592 0.2740 0.2307 ReLU Xavier 0.5847 0.1761 0.0630 0.0233 ReLU He 0.8268 0.7044 0.7127 0.7463 初始值太小,信号到深层几乎变成 0(梯度也随之消失);太大,tanh 全部饱和(第 10 层 87% 的值绝对值超过 0.99),局部梯度 ,同样学不动。Xavier 让 tanh 网络的信号在 10 层后仍处于合理范围(它仍在缓慢变小,因为 tanh 会把值往 0 压,推导中”线性”的假设只是近似);ReLU 网络配 He 初始化则基本保持不变。
8.2 学习率
学习率是最重要的超参数。
动手:代码 05 演示 3 用 SGD 训练同一个网络 300 步:
学习率 第 10 步 第 100 步 第 300 步 0.001 1.069 1.051 1.016 0.1 0.940 0.703 0.608 1 0.749 0.378 0.165 50 15.081 15.120 13.694
太小,几乎不动;太大,损失震荡甚至发散。实践中常用的办法是:先试一组按 10 倍间隔的学习率,找到”刚好不发散”的那个,再稍微调小一点;训练过程中逐渐衰减学习率(第 1 讲用的线性衰减,或者每隔一段时间减半、余弦衰减等)。现代大模型训练还会在开头加一段预热(warmup):学习率从 0 逐渐升到最大值,再开始衰减。
8.3 优化器
普通 SGD 的问题是:损失函数的”地形”往往像一条狭长的山谷,梯度方向主要指向谷壁而不是谷底的出口,于是在两壁之间来回震荡,前进很慢。几种改进:
- 动量(momentum):,。把过去的梯度累积起来,像一个滚下山的球:方向一致的分量越走越快,来回震荡的分量互相抵消。 通常取 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.1 0.758 0.703 0.651 0.524 69.0% SGD + 动量 0.9,lr = 0.1 0.579 0.399 0.227 0.111 97.0% Adam,lr = 0.01 0.400 0.195 0.080 0.032 99.3% AdamW,lr = 0.01 0.401 0.197 0.081 0.033 99.3%
8.4 过拟合与正则化
模型参数很多、训练数据很少时,模型会把训练数据(包括其中的噪声)“背”下来:训练误差很低,在新数据上却表现很差。这叫过拟合。缓解过拟合的方法统称为正则化:
- L2 正则 / 权重衰减:在损失中加上 ,惩罚过大的权重,让函数更平滑。梯度多了一项 ,相当于每一步都把权重缩小一点。
- Dropout(Srivastava 等,2014):训练时,每个神经元以概率 被临时”关掉”(输出置 0),留下的输出除以 以保持期望不变;测试时不做任何处理。直觉上,网络不能依赖任何单个神经元,必须学到冗余、鲁棒的特征;也可以看作是在训练大量共享参数的子网络,测试时取它们的平均。
- 早停(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.1 98.5% 81.1% 权重衰减 1.0 94.1% 87.1% 权重衰减 3.0 85.6% 95.0% dropout 0.5 92.2% 84.7% dropout 0.5 + 权重衰减 1.0 85.9% 94.1% 不加正则时,模型连 13 个错误标签也背了下来(训练准确率 98.9%),测试准确率只有 79.9%。正则化之后,训练准确率下降(不再硬记错标签),测试准确率上升——用一点训练误差换更好的泛化,这正是正则化的目的。(训练准确率低于测试准确率,是因为训练标签有错而测试标签是干净的。)权重衰减要足够强才有明显作用:AdamW 每步把权重乘以 ,lr = 0.01 时 wd = 0.1 的效果很弱。
8.5 其他实用经验
- 向量化:永远用矩阵运算代替 Python 循环(第 4.3 节的对角阵、代码 03 的批量反向传播)。差距常常是几十到几百倍;在 GPU 上更大。
- 先在小数据上过拟合:调试新模型时,先确认它能把几十个样本的训练损失降到接近 0。如果连这都做不到,说明代码有 bug 或模型容量不够,没必要上大数据。第 8 讲会系统地讨论调试方法。
- 多跑几个随机种子:第 2 讲已经看到,单次运行的差异可能很大一部分是噪声。
9. 本讲小结
- 分类问题是 NLP 的基本形式。softmax 回归只能画直线;深度学习 NLP 同时学习分类器和输入表示。
- 命名实体识别:给每个词打 PER / LOC / ORG / O 标签,难在歧义和新实体,出路是看上下文。窗口分类器把中心词和左右 个词的词向量拼接后送进神经网络;词丢弃让模型学会处理没见过的词。用精确率、召回率、F1 评估。
- 神经网络 = 一层层的”并行逻辑回归”,中间层学到什么由最终损失决定。非线性激活函数是表达能力的来源;ReLU 是默认选择。
- 矩阵微积分:链式法则 = 雅可比矩阵相乘;逐元素函数的雅可比矩阵是对角阵,实现时用逐元素乘法。一层网络 :,,,。梯度和参数同形状。
- 反向传播:在计算图上按拓扑逆序,“下游梯度 = 上游梯度 × 局部梯度”;多条出边的梯度要累加;反向模式一遍就得到所有参数的梯度,代价与前向相当。自动微分是会漏的抽象,理解它才能调试。
- PyTorch 训练循环:
zero_grad→ 前向 →backward→step。 - 实用技巧:随机初始化打破对称,Xavier / He 初始化保持信号尺度;学习率最重要;Adam / AdamW 是默认优化器;L2、dropout、早停、数据增强缓解过拟合;测试前
model.eval()。 - 预训练词向量:标注数据少时冻结更稳妥,多时微调更好。
10. 与作业的关系
作业 2(Winter 2026:Neural Network Foundations, Tensor Derivatives, Dependency Parsing)的前半部分对应本讲:
- 张量求导 / 神经网络基础(书面):推导 softmax、交叉熵、各种激活函数的导数和雅可比矩阵,推导一层或两层网络对各个参数的梯度,注意形状约定(本讲第 4 节、练习 2–5)。历年版本中还常有关于 Adam(动量和自适应学习率分别起什么作用)和 dropout(缩放因子怎么取、为什么测试时不用 dropout)的问题(第 8.3、8.4 节、练习 7、8)。
- 后半部分是神经依存句法分析器的实现,正是下一讲的内容。它用 PyTorch 实现一个和本讲窗口分类器非常相似的前馈网络:输入是若干个词和词性的嵌入向量的拼接,输出是”下一步做哪个动作”。
作业的具体题目以当年官方发布的版本为准。
11. 练习
练习 1(非线性):证明两个线性层 等价于一个线性层。如果把激活函数换成 呢?
参考答案
,令 、 即可。 仍然是线性函数, 同样可以合并成一个线性层。只有真正的非线性函数才能增加表达能力。
练习 2(雅可比矩阵)★:(a) 写出 (逐元素 sigmoid)的雅可比矩阵。(b) 写出 对 的雅可比矩阵,并验证它的形状。(c) 为什么实现时不需要真的构造 (a) 中的矩阵?
参考答案
(a) , 的对角阵。 (b) ,形状 : 个输出、 个输入,符合”输出数 × 输入数”。 (c) 上游梯度(行向量)乘以对角阵,等于上游梯度逐元素乘以对角线上的值。构造对角阵要 的内存和计算,逐元素乘法只要 。
练习 3(softmax 的雅可比矩阵)★★:推导 ,其中 ,分 和 两种情况,并写成矩阵形式。
参考答案
,。 :商的求导法则,。 :分子不含 ,。 合起来:。可以用代码 01 演示 1(c) 验证。
练习 4(一层网络的梯度)★★:对 ,,,写出 、、,并说明每个量的形状(,)。
参考答案
ReLU 的导数是 (逐元素)。记 。 ( 维);();( 维)。 注意: 的单元 ,它的那一行 在这个样本上完全收不到梯度。如果某个单元对所有样本都有 ,它就永远不会被更新——这就是”死 ReLU”。
练习 5(批量梯度)★:输入是一批数据 (每行一个样本),(),已知 。求 和 。
参考答案
(,和 一样); 按行求和( 维)。可以这样检查:对单个样本 ,梯度是外积 ,对所有样本求和就是 。
练习 6(反向传播手算):,。画出计算图,用”上游 × 局部”手算 、、。
参考答案
,,。
,。
加法门分发:, 经由 得到 2。max 门路由(): 经由 得到 3,。
有两条出边,梯度累加:。
可以用代码 02 的 Value 验证:x, y, z = Value(1), Value(2), Value(0); f = (x + y) * y.maximum(z); f.backward()。
练习 7(Adam)★:Adam 的更新可以简化为 ,,。(a) 起什么作用?为什么能减小震荡?(b) 除以 起什么作用?哪些参数的有效学习率会变大?
参考答案
(a) 是梯度的指数移动平均(动量)。来回震荡的梯度分量正负交替,平均后相互抵消;方向一致的分量会累积。所以更新方向更稳定。 (b) 是梯度平方的移动平均,除以 让每个参数的步长大致按”梯度的典型大小”归一化。梯度一直很小的参数(比如罕见词的嵌入、损失地形平坦方向上的参数), 小,有效学习率变大;梯度一直很大的参数,有效学习率变小。
练习 8(dropout)★:训练时以概率 把每个隐藏单元置 0。(a) 如果不对剩下的单元做缩放,测试时(不 dropout)每个单元输出的期望和训练时有什么不同?(b) 训练时应该把剩下的值乘以多少?(c) 为什么测试时不使用 dropout?
参考答案
(a) 训练时 ,测试时是 ,下一层看到的输入整体变大了 倍,分布不一致。 (b) 乘以 ,使 (“反向 dropout”,PyTorch 就是这样实现的)。 (c) dropout 是训练时的正则化手段,测试时我们要的是确定的、最好的预测;不 dropout 相当于对所有子网络取(近似的)平均。如果测试时也随机丢弃,同一个输入每次的预测都不一样。
练习 9(初始化):一个线性层有 个输入,输入各维独立、均值 0、方差 1。若权重从 初始化,输出的方差是多少?要让输出方差等于 1, 应该取多少?如果后面接 ReLU 呢?
参考答案
。要等于 1,(Xavier)。接 ReLU 时一半的值被置 0,要补回来,,(He)。
练习 10(动手):
(a) 在代码 03 中把隐藏单元从 100 改成 5、20,螺旋数据的训练准确率如何变化?画出的边界有什么不同?
(b) 在代码 04 中把窗口分类器的 tanh 换成 relu,或把隐藏层去掉(直接从拼接的词向量到输出),未见实体的准确率如何变化?
(c) 在代码 04 演示 6 中,把微调时的学习率调小 10 倍(给词向量单独设一个学习率,可以用 PyTorch 优化器的参数组), 时的结果介于冻结和微调之间吗?
(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。