第 1 讲 · 课程概述、RNN 语言模型与自动微分 · Course Overview, RNN-LMs & Automatic Differentiation

Generative AI · Lecture 01。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

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

本篇目录

对应 Maps to:CMU 10-423 / 10-623 / 10-723 Generative AI(Spring 2026;Aran Nayebi、Matt Gormley),Lecture 1 · Mon 12-Jan · RNN LMs / Autodiff。 阅读材料:Goodfellow, Bengio & Courville, Deep Learning 第 10.1–10.5 节(循环网络);Bottou & Gallinari (1991), A Framework for the Cooperation of Learning Algorithms;Paszke et al. (2019), PyTorch: An Imperative Style, High-Performance Deep Learning Library。 依据说明:本讲以原课第 1 讲公开的 slides(lecture1-overview.pdf)为主线:什么是生成式 AI、它能做什么、规模化、“生成式 AI 就是概率建模”、关于学习方式的讨论、课程安排、基于模块的自动微分、n-gram 背景、RNN 语言模型。slides 引用的论文数字都尽量核对过;§0.6 的农场玩具语言和全部实验是本讲义自己设计的,不代表课堂上讲了什么。 配套代码 Code:code/lecture01/(6 个脚本 + 5 个模块;01 只用标准库,其余用 numpy 和 PyTorch CPU 版;每个脚本 1–12 秒) 预计阅读时间:4–5 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 说清楚生成式 AI 和机器学习、深度学习的关系,举出它在感知、推理、规划、交流、创造、学习等方面的例子;理解”学习”除了调参数,还可以通过推断完成(上下文学习)。
  2. 解释为什么说生成式 AI 就是概率建模:生成模型要回答”怎样表示一个分布、怎样学习它、怎样从中采样”三个问题。
  3. 写出序列的链式法则,并区分”恒等式”和”模型”:n-gram 的马尔可夫假设是一个近似。会用计数做最大似然估计、会算困惑度、知道为什么要平滑,能用实验说明 n-gram 为什么注定处理不了长程依赖。
  4. 写出 RNN 语言模型的公式,说明它怎样用一个定长向量总结任意长的历史;会训练它、从中采样,并解释梯度穿越时间时为什么会消失或爆炸。
  5. 理解自动微分:计算图、反向模式的两种写法、为什么深度学习用反向模式;能从”过程式”写法过渡到”基于模块”的写法,再到”面向对象 + tape”的写法,并知道 PyTorch 的 loss.backward() 背后做的就是这件事。
  6. 会用有限差分检查梯度,并知道为什么逐模块检查比整网检查更容易定位 bug。

0.2 先修与本讲范围 Prerequisites & scope

原课的先修是一门机器学习或深度学习入门课,并且明确说不要求会深度学习和 PyTorch。本讲把需要的背景都就地补上。

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
概率的基本概念:条件概率、独立、期望更多概率工具:KL 散度、ELBO、重参数化(前置讲 P2;第 6–9 讲)
导数、偏导数和链式法则;矩阵乘法浮点格式与显存核算的细节(前置讲 P1;第 17 讲)
机器学习的基本流程:训练集 / 验证集 / 测试集、损失函数、梯度下降小批量、填充、分词、解码策略(第 3 讲)
Python 基础(不熟悉的写法代码注释里都有解释)LSTM、注意力、Transformer(第 2 讲)

0.3 本讲地图 Map

§1  什么是生成式 AI:AI ⊃ ML ⊃ DL ⊃ GenAI;能做什么;规模化;为什么要学内部机制
§2  生成式 AI 就是概率建模:生成 vs 判别;三个问题;链式法则              ← 代码 01 第 1 部分
§3  n-gram 语言模型:数数即最大似然;采样;困惑度;平滑;长程依赖的失败    ← 代码 01
§4  RNN 语言模型:定长向量总结历史;训练;采样;与 n-gram 对比;梯度穿越时间 ← 代码 05、06
§5  自动微分:计算图;反向模式;过程式 → 模块化 → tape;梯度检查;PyTorch    ← 代码 02、03、04
§6  "另一种学习":这门课希望你怎样学      §7  课程地图
§8  小结   §9  常见误区   §10 练习   §11 延伸阅读   §12 下一讲预告

建议的阅读顺序:先读 §1–§3 并运行 01;再读 §5 并运行 02–04(理解梯度从哪来);最后读 §4 并运行 05、06。讲义按”先讲模型、再讲怎样训练”排列,代码按”先造工具、再用工具”编号,两种顺序都可以。

0.4 记号 Notation(全课统一)

记号意思
V\mathcal{V},∣V∣\lvert\mathcal{V}\rvert词表(vocabulary)及其大小
wtw_t,w1:Tw_{1:T}第 tt 个词;长度为 TT 的词序列 (w1,…,wT)(w_1, \dots, w_T)
w<tw_{<t}wtw_t 之前的所有词 (w1,…,wt−1)(w_1, \dots, w_{t-1}),叫”历史”或”上下文”
p(⋅)p(\cdot),pθ(⋅)p_\theta(\cdot)概率分布;下标 θ\theta 表示由参数 θ\theta 决定的模型分布
pdatap_{\text{data}}真实的数据分布(我们看不见,只能看到从中抽出的样本)
ht∈RHh_t \in \mathbb{R}^HRNN 在第 tt 步的隐藏状态(hidden state)
logitssoftmax 之前的未归一化分数
softmax(b)i=ebi/∑jebj\mathrm{softmax}(b)_i = e^{b_i} / \sum_j e^{b_j}把一组实数变成概率分布
JJ标量损失(slides 的记号)
gx=∂J/∂xg_x = \partial J / \partial x损失对变量 xx 的梯度,形状与 xx 相同(slides 的记号)
⊙\odot,÷\div逐元素乘、逐元素除
nat以 ee 为底的对数单位(以 2 为底时叫 bit)

0.5 关于这门课 · About 10-423/623/723

是什么。 CMU 机器学习系的生成式 AI 课。课程主页列出的往届从 Spring 2024 开始,之后每学期都开(Fall 2024、Spring 2025、Fall 2025、Spring 2026);Matt Gormley 每届都是主讲之一,合讲老师每届不同(Spring 2026 是 Aran Nayebi)。三个课号上课内容完全一样:10-423 是本科版,10-623 是硕士版(多一份论文研读作业 HW623:读一篇近期论文、分析并录一段讲解视频),10-723 是 Spring 2026 新增的博士版课号(再多一次 Quiz723)。

讲什么。 课程描述里有一句话很能概括它:“学生会理解、开发并应用最先进的算法,让机器生成逼真而有创造性的内容”。具体说:学习的基本机制;怎样构建生成模型和大型基础模型(处理视觉和语言的 Transformer、扩散模型);怎样训练(预训练、微调)和高效适配(adapter、上下文学习);怎样扩展到海量数据(多 GPU、分布式优化);怎样在日常中使用(生成代码、有生成模型参与的编程);以及它们会怎样出错(偏见、幻觉、对抗攻击、数据污染)和怎样应对。

怎么考核(10-423):5 次作业 30%、6 次课堂小测 10%、2 次编程测试 10%、1 次考试 20%、3 人小组项目 25%、课堂参与 5%。作业有一个独特的”Slot A / Slot B”制度,§6 会专门讨论它背后的想法。

原课作业 vs 本讲义代码。 原课作业要求从零实现核心算法(例如 HW1 实现带 GQA 和 RoPE 的 Transformer LM、HW2 实现扩散模型),同时调用现成的库和模型(例如 HW3 给 GPT-2 加 LoRA 做指令微调)。本讲义的代码只做前一半:所有东西都从零写、在 CPU 上的小玩具数据上运行;用到预训练大模型的地方,换成”先自己预训练一个小模型,再对它做同样的事”。

0.6 本讲的玩具语料 · The farm language

为了把”n-gram 和 RNN 到底差在哪里”讲清楚,我们需要一份语料同时满足三个条件:(1) 有大量局部规律(n-gram 能学会的东西);(2) 有长程依赖(只看最近几个词一定会错的东西);(3) 我们确切知道它的真实分布,好把模型和”理论上最好的模型”作比较。真实英语做不到第 (3) 点,所以代码 toy_corpus.py 造了一门小语言。每句话的结构是:

the <主语> [<介词> the <干扰名词>] × k  <动词>  <宾语>  <时间/地点> .

the bat makes noise at night .
the cows near the dog behind the hens eat corn in the field .
  • 主语是 8 种动物之一,单数或复数各占一半。
  • 中间插入 kk 个介词短语(near / behind / beside the …),里面的名词叫干扰名词(attractor),单复数随机、和主语无关。训练集里 k=0,1,2,3k = 0, 1, 2, 3 的比例是 45%、30%、15%、10%。
  • 动词必须和主语一致(the bat … makes;the bats … make),而不是和离它最近的名词一致。这是英语的主谓一致(subject-verb agreement)。
  • 每种动物有自己的习性:蝙蝠多在夜里出现、多吃飞虫;奶牛多吃玉米、草和干草……所以动词、宾语和时间短语都依赖于很远之前的主语。

主语到动词的距离是 3k+13k+1 个词。生成每句话时,代码把每一次随机选择的概率乘起来,得到这句话在真实分布下的精确概率,于是能算出真实困惑度(测试集上是 2.491)——任何模型在期望意义下都不可能比它更低。词表一共 56 个(含 <pad>、<s>、</s> 三个特殊记号),训练句平均 10.6 个词。

这门语言的设计受到 Linzen et al. (2016) 的启发:他们用真实英语里的主谓一致来测试 LSTM 能否学到句法结构。人造语言的好处是”知道正确答案”,代价是它比真实语言简单得多——后面凡是结论可能依赖这种简单性的地方,讲义都会指出来。


1. 什么是生成式 AI · What is Generative AI?

1.1 一组嵌套的圆

slides 开头画了四个嵌套的圆:人工智能 ⊃ 机器学习 ⊃ 深度学习 ⊃ 生成式 AI。

  • 人工智能(AI)的目标是造出”智能的机器”。这个大目标通常拆成一串子目标:感知、推理、控制 / 运动 / 操作、规划、交流、创造、学习。
  • 机器学习(ML)是实现 AI 的一种方式:不直接写规则,而是让机器从数据里学。
  • 深度学习(DL)是机器学习的一个分支:用很多层的神经网络来学。
  • 生成式 AI(GenAI)是深度学习里专门”生成内容”的那一部分:生成文字、图像、音频、视频、代码……

一个自然的问题是:生成式 AI 只是”生成内容”,它和 AI 的其他子目标有什么关系?slides 的回答是:它正在渗透进所有子目标。

AI 的子目标生成式 AI 带来的变化(slides 举的例子)本课哪里展开
交流 Communication交流 = 理解 + 生成人类语言;大语言模型(LLM)两者都很擅长——尽管它们通常只是被训练成”给定前面的词,生成下一个词”第 1–4 讲
学习 Learning传统机器学习的”学习”是参数估计;上下文学习(in-context learning)表明,把训练样例放进上下文、在测试时直接推断,也能”学习”第 9、11 讲
推理 ReasoningLLM 出乎意料地擅长某些推理任务,例如思维链(chain-of-thought)提示——它本身就是上下文学习的一个例子第 11、20 讲
规划 PlanningLLM 已被用于具身智能体的规划(如 LLM-Planner);规划也是智能编程助手的关键一步第 23 讲
创造 Creativity文生图(slides 提到 Midjourney 的 Discord 服务器有 1800 万成员);文生音乐(MusicGen 可以同时以文字和一段音频为条件)第 12–14、24 讲
感知 Perception多模态基础模型能回答关于图像(以及图中文字)的问题;扩散模型甚至可以当作零样本分类器用第 13 讲
控制 ControlDayDreamer 为强化学习学一个”世界模型”(经验的生成模型),四足机器人不靠仿真、在真实世界里一小时内学会走路第 26 讲

表里最值得停下来想一想的是”学习”那一行。这门课的第一个学习目标就是”区分不同的学习机制,比如调参数和上下文学习”。传统上,模型”学会”一个任务意味着它的参数被梯度下降改变了;而上下文学习里,参数一个都没变,你只是在输入里放了几个例子,模型的输出行为就变了。可以把它理解成:模型在预训练时”学会了怎样学习”,于是测试时的一次前向传播(推断)就完成了一次小小的学习。这件事为什么会发生,至今仍是研究热点,第 11 讲会专门讨论。

1.2 生成式 AI 能做什么

slides 列了六类例子,每一类本课都有对应的讲次:

能力slides 的例子背后的模型本课
文本生成GPT-4 用莎士比亚戏剧的对白形式证明”素数有无穷多个”(Bubeck et al., 2023)Transformer 语言模型第 2–4 讲
图像编辑补全缺失像素(inpainting)、给黑白照片上色、把照片向外延伸(uncropping)(Saharia et al., 2022)条件扩散模型第 7、8、14 讲
文生图给一段文字描述,采样一张符合描述的图(SDXL)潜空间扩散模型第 12、14 讲
音乐生成MusicGen:在”音频帧的离散码本单元”上训练的 Transformer 解码器,交错安排多个码本之间的依赖,可以同时以文字和音频为条件Transformer + 神经音频编解码器第 24 讲
代码生成GPT-4 写代码Transformer 语言模型第 23 讲
视频生成潜空间扩散 + 在每层空间卷积 / 注意力之间插入时间维度的卷积 / 注意力(Align your Latents);或者让噪声在时间上相关(Preserve Your Own Correlation)视频扩散模型第 25 讲

视频那一行 slides 特别标出了一个”关键问题”:怎样生成多张彼此相关的帧? 这是一个很好的例子,说明生成式 AI 的大多数难题都可以翻译成”怎样表示变量之间的依赖关系”——这正是本讲 §2 的主题。

1.3 规模化 Scaling up

今天的生成式 AI 和十年前最大的区别是规模。slides 用五个角度说明”规模”意味着什么,每个角度都对应本课后面的一个单元:

  1. 训练数据。 The Pile 是一个开源的语言模型训练集,由 22 个较小的数据集组成,偏重高质量文本,总共 825 GiB。(slides 换算成约 1.2 万亿 token;更常见的估计是用 GPT-NeoX 分词器约 3000 亿 token,例如 Pythia 在 Pile 上训练 3000 亿 token 大约是 1 个 epoch。token 数取决于分词器,这类换算只能看数量级。)数据怎样影响规模定律见第 15 讲。
  2. 对齐。 InstructGPT 用基于人类反馈的强化学习(RLHF)微调预训练的 GPT。论文报告:在人类评估中,13 亿参数的 InstructGPT 的输出比 1750 亿参数的 GPT-3 更受偏好,尽管参数少了 100 倍。规模不是唯一的杠杆,怎样训练同样重要(第 11、12 讲)。
  3. 内存。 一个参数用 32 位浮点数存要 4 字节,用 16 位要 2 字节。所以 1750 亿参数的 GPT-3 光是权重就要 652 GiB(FP32)或 326 GiB(FP16),而当时最大的单块 GPU(A100)只有 80 GB 显存。半精度不仅省内存,还更快——slides 引用 PyTorch 文档说,A100 上 float16 矩阵乘法的峰值性能是 float32 的 16 倍。代码 04 第 6 部分复现了这张表。
  4. 分布式训练。 模型放不进一块 GPU,就得切开:按层切(每块 GPU 负责几层)、按矩阵的行 / 列切(Transformer 的大部分计算是矩阵乘法),或者两者结合(第 17 讲)。
  5. 训练成本。 slides 引用了 Chinchilla 论文(Hoffmann et al., 2022)的图:给定算力预算,模型应该多大、数据应该多少,有一个可以预测的最优配比(大约每个参数 20 个 token;第 15 讲)。

1.4 两条时间线

slides 画了语言模型和图像生成的两条时间线,把它们并排看很有启发:

年份语言模型图像生成 / 视觉
1998–2012n-gram 语言模型(2000 年前后的主流);RNN 语言模型(2010)LeNet(1998);ImageNet(2009);AlexNet(2012)
2013–2016VAE(2013);GAN(2014);扩散模型的雏形、ResNet(2015)
2017–2019Transformer(2017);ELMo、BERT、GPT(2018);GPT-2、RoBERTa(2019)Transformer 进入视觉
2020–2021GPT-3(2020)DDPM(2020);ViT、CLIP、DALL·E(2021)
2022InstructGPT、PaLM、BLOOM、ChatGPTDALL·E 2、Imagen、Midjourney、Stable Diffusion
2023–2024Llama、GPT-4、Falcon、Mistral、Gemini、Claude;Mamba;Llama 3、GPT-4oSDXL、SDXL Turbo、DALL·E 3;Stable Diffusion 3、Sora

两个观察:

  • 两条线在 2017 年之后汇合了。 Transformer 先在语言上成功,然后进入视觉(ViT)、多模态(CLIP)和扩散模型(DiT,第 14 讲)。这是这门课把文本和图像放在一起讲的原因:底层的积木越来越相同。
  • 语言模型那条线的起点是 n-gram 和 RNN。 今天的大语言模型和 2000 年的 n-gram 模型在”用链式法则把句子的概率拆成一串下一个词的概率”这一点上完全相同。理解 n-gram 和 RNN 各自的局限,就理解了为什么需要 Transformer——这是本讲后半部分和第 2 讲的主线。

1.5 为什么要学内部机制

slides 用一个类比回答”既然有现成的工具,为什么还要学生成式 AI 的内部原理”:福特 T 型车、交通科学、智能手机的普及,以及交通安全的统计数据。slides 上只有图片,没有文字解说;我的理解是:一项技术普及之后,它带来的问题(交通拥堵、交通事故、开车看手机……)需要懂得它怎样运作的人来研究和解决。只会开车的人造不出更安全的车,也设计不出更好的道路。

放到生成式 AI 上就是:会调用 API 的人越来越多,但能回答”为什么模型会产生幻觉""为什么这个微调方法省显存""这个评测分数可信吗”的人,必须理解内部机制。这也是这门课的作业要求从零实现 Transformer 和扩散模型的原因。

1.6 课堂练习:机会与谨慎

slides 里有一个课堂讨论:(1) 找一个生成式 AI 有巨大机会造福社会的场景;(2) 找一个我们都应该放慢脚步、谨慎使用生成式 AI 的场景。这两个问题没有标准答案,但值得你在读完整门课之后再回答一次。我的一组例子:

  • 机会:给资源匮乏地区的学生提供一对一的辅导;帮助科研人员读文献、写分析代码;为视障人士描述图像。
  • 谨慎:医疗诊断和法律判决这类高风险决定(模型会自信地出错,第 22 讲);大规模生成以假乱真的图像和音频(深度伪造);把模型输出直接当作事实来源。

2. 生成式 AI 就是概率建模 · GenAI is probabilistic modeling

2.1 生成 vs 判别

slides 的这一节标题是”GenAI is probabilistic modeling”,下面只有一个公式:

p(xt+1∣x1,…,xt)p(x_{t+1} \mid x_1, \dots, x_t)

这个公式是整门课的起点。先区分两类模型:

  • 判别模型(discriminative model)学 p(y∣x)p(y \mid x):给一张图 xx,它是猫还是狗 yy?它只关心”在给定输入时,输出是什么”,不关心输入本身长什么样。
  • 生成模型(generative model)学 p(x)p(x) 或条件分布 p(x∣c)p(x \mid c):所有可能的图像 xx 里,哪些更可能出现?给定一句描述 cc,什么样的图像符合它?

区别在于被建模的对象:判别模型建模一个低维的标签,生成模型建模高维的数据本身(一张 512×512 的彩色图有约 79 万个数,一段 1000 词的文章有 1000 个离散变量)。高维数据的分布极其复杂——变量之间有大量依赖关系——所以生成建模要难得多。

2.2 为什么要”概率”

为什么不直接训练一个”输入描述、输出图像”的确定性函数?因为生成的目标本来就不是唯一的:同一句”一只在月光下的猫”可以对应无数张合理的图。一个确定性函数只能给出一个答案(通常是所有合理答案的某种”平均”,于是一片模糊——第 9 讲讲 VAE 时会看到这种模糊)。概率模型则给出一个分布,它能做三件事:

  1. 采样(sampling):从分布里抽一个样本——生成一张图、写一段话。每次抽到的都不同,但都”像真的”。
  2. 打分(scoring / likelihood):给一个样本算概率——这句话通顺吗?这段代码像人写的吗?语音识别和机器翻译里常用语言模型给候选句子打分。
  3. 条件化(conditioning):固定一部分变量,对其余变量建模——给定前半句写后半句,给定描述生成图像,给定缺失区域周围的像素补全图像。

2.3 三个问题:表示、学习、采样

任何一个生成模型都要回答三个问题。这门课里出现的模型,就是对这三个问题的不同回答:

模型怎样表示分布怎样学习怎样采样本课
n-gram链式法则 + 只看前 n−1n-1 个词;一张计数表数数(= 最大似然)逐词掷骰子第 1 讲
RNN / Transformer LM链式法则 + 神经网络算每个条件分布最大似然(交叉熵 + 梯度下降)逐词掷骰子第 1–4 讲
GAN一个把随机噪声变成样本的生成器(不写出 p(x)p(x))和判别器的对抗博弈一次前向第 6 讲
VAE隐变量 zz + 解码器 p(x∣z)p(x \mid z)最大化证据下界(ELBO)先抽 zz 再解码第 9 讲
扩散模型一串逐步去噪的条件分布最大化 ELBO(简化为预测噪声)从纯噪声出发去噪几十到上千步第 7、8 讲

一个贯穿全课的线索:能不能算出似然 p(x)p(x)。n-gram 和自回归语言模型可以精确算出任意句子的概率,所以能直接做最大似然;GAN、VAE、扩散模型的 p(x)p(x) 要么写不出来、要么算不动,于是要换一个训练目标。slides 后面几讲反复出现的标题”MLE? Nope!”说的就是这件事。

2.4 序列的链式法则

怎样给一个长度为 TT 的序列定义概率分布?slides 用例句”The bat made noise at night”说明。概率论里的链式法则(chain rule)说:

p(w1,w2,…,wT)=p(w1) p(w2∣w1) p(w3∣w1,w2)⋯p(wT∣w1,…,wT−1)=∏t=1Tp(wt∣w<t)p(w_1, w_2, \dots, w_T) = p(w_1)\, p(w_2 \mid w_1)\, p(w_3 \mid w_1, w_2) \cdots p(w_T \mid w_1, \dots, w_{T-1}) = \prod_{t=1}^{T} p(w_t \mid w_{<t}) p(The bat made noise at night)=  p(The)⋅p(bat∣The)⋅p(made∣The bat)⋅p(noise∣The bat made)⋯p(night∣The bat made noise at)\begin{aligned} p(\text{The bat made noise at night}) = \;& p(\text{The}) \cdot p(\text{bat} \mid \text{The}) \cdot p(\text{made} \mid \text{The bat}) \\ &\cdot p(\text{noise} \mid \text{The bat made}) \cdots p(\text{night} \mid \text{The bat made noise at}) \end{aligned}

slides 特别强调:这叫”法则”,因为它对任何概率分布都成立。它不是假设、不是近似,只是把条件概率的定义 p(a,b)=p(a) p(b∣a)p(a, b) = p(a)\,p(b \mid a) 反复使用。

代码验证(01 第 1 部分):把训练集当成一个”经验分布”——一句话的概率 = 它出现的次数 / 句子总数。对这个经验分布,用”完整历史”的条件概率连乘(每个条件概率 = 以某个前缀开头的句子数 / 以更短前缀开头的句子数),结果和直接数整句逐位相同:

句子直接数整句链式法则(完整历史)trigram 近似低估倍数
the bat makes noise at night .7.400e-037.400e-032.123e-033.5
the owls eat mice at night .6.250e-036.250e-032.778e-032.2
the owls behind the hen see the bats at night .5.000e-055.000e-051.453e-07344

一旦假设”每个词只依赖前面两个词”(trigram),等式就变成了近似:高频短句被低估 2–3.5 倍,带介词短语的长句被低估 344 倍。原因下一节会看到:trigram 把”the bat""noise at”这些片段在所有句子里的用法混在一起统计,看不到”这句话的主语是谁”。

因为链式法则永远成立,所以生成一个序列可以归结为反复预测”下一个元素”:只要能对任意历史 w<tw_{<t} 给出下一个词的分布 p(wt∣w<t)p(w_t \mid w_{<t}),就定义了整个序列的分布。这类模型叫自回归模型(autoregressive model)。今天所有的 GPT 式大语言模型都是自回归模型。

2.5 “建模所有交互”的代价

链式法则把问题变成了:怎样表示 p(wt∣w1,…,wt−1)p(w_t \mid w_1, \dots, w_{t-1})?最直接的办法是给每一种可能的历史存一张分布表。但历史有多少种?词表有 5 万个词时,长度为 10 的历史就有 5000010≈104750000^{10} \approx 10^{47} 种——宇宙里的原子都不够存这张表,更不可能有足够的数据把它填满。

slides 的提问是:“如果我想对每一种可能的交互建模——或者至少是当前变量和它之前所有变量的交互——怎么办?“有两条出路:

  1. 砍掉历史:只看最近 n−1n-1 个词。这就是 n-gram 模型(§3)。表的大小变成 ∣V∣n\lvert\mathcal{V}\rvert^{n},仍然随 nn 指数增长,但 nn 小的时候可以接受。
  2. 压缩历史:用一个函数 fθf_\theta 把整个历史压缩成一个定长向量,再基于这个向量预测下一个词。这就是 RNN 语言模型(§4)。参数量固定,和历史长度无关。

第 2 讲的 Transformer 是第三条路:不压缩,也不砍掉,而是让每个位置在需要时直接回头看历史里的任何一个位置。


3. n-gram 语言模型 · Background: n-gram LMs

3.1 马尔可夫假设

n-gram 模型的核心假设是:下一个词只依赖于最近的 n−1n-1 个词:

p(wt∣w1,…,wt−1)≈p(wt∣wt−n+1,…,wt−1)p(w_t \mid w_1, \dots, w_{t-1}) \approx p(w_t \mid w_{t-n+1}, \dots, w_{t-1})
  • n=1n = 1(unigram):p(wt)p(w_t),完全不看上下文;
  • n=2n = 2(bigram):p(wt∣wt−1)p(w_t \mid w_{t-1});
  • n=3n = 3(trigram):p(wt∣wt−2,wt−1)p(w_t \mid w_{t-2}, w_{t-1})。

slides 的注解是:“这叫一个模型,因为我们对’要看多少个前面的词’做了假设”。对比 §2.4 的”链式法则永远成立”——法则 vs 模型,这是理解所有语言模型的第一个分界线。

为了让句子的第一个词也有完整的上下文,实现时会在句首补 n−1n-1 个开始记号 <s>;为了让模型知道句子在哪里结束,句尾补一个结束记号 </s>(§4.4 解释为什么结束记号必不可少)。

3.2 学习:数数就是最大似然

n-gram 的参数就是那张条件概率表。怎样从数据里学出来?slides 的回答是:“从数据里——数 n-gram 出现的次数就行”:

p^(w∣u,v)=count(u,v,w)count(u,v)\hat{p}(w \mid u, v) = \frac{\mathrm{count}(u, v, w)}{\mathrm{count}(u, v)}

slides 的例子:语料里 “cows eat” 后面跟了 corn 4 次、grass 3 次、hay 2 次、if 1 次、which 1 次,一共 11 次,所以 p^(corn∣cows eat)=4/11\hat{p}(\text{corn} \mid \text{cows eat}) = 4/11。

为什么”数数”恰好是最大似然估计(MLE)? 固定一个上下文 cc,它后面的词服从一个类别分布 θw=p(w∣c)\theta_w = p(w \mid c),约束是 ∑wθw=1\sum_w \theta_w = 1。训练数据里这个上下文后面出现了 NwN_w 次词 ww,对数似然是 ∑wNwlog⁡θw\sum_w N_w \log \theta_w。用拉格朗日乘子法最大化:

∂∂θw[∑w′Nw′log⁡θw′−λ(∑w′θw′−1)]=Nwθw−λ=0  ⇒  θw=Nwλ=Nw∑w′Nw′\frac{\partial}{\partial \theta_w}\Big[\sum_{w'} N_{w'} \log \theta_{w'} - \lambda\Big(\sum_{w'} \theta_{w'} - 1\Big)\Big] = \frac{N_w}{\theta_w} - \lambda = 0 \;\Rightarrow\; \theta_w = \frac{N_w}{\lambda} = \frac{N_w}{\sum_{w'} N_{w'}}

最后一步用了约束 ∑wθw=1\sum_w \theta_w = 1 得到 λ=∑w′Nw′\lambda = \sum_{w'} N_{w'}。所以最大似然解就是相对频率。每个上下文各自独立地这样估计,就得到了整张表。(这个推导第 3 讲还会再出现一次:它说明了”计数”和”梯度下降训练神经网络”在目标上是同一件事。)

代码验证(01 第 2 部分):在我们的 2 万句训练语料里数 “cows eat ___”:

下一个词计数比p^\hat{p}其中主语真是 cows 的次数
corn148/4330.342132
grass101/4330.23388
hay78/4330.18063
seeds44/4330.10231
mice22/4330.0510
fish、bones、flies、eggs、fruit16、9、7、5、3 /4330.037–0.0070

表的前四行和 slides 的例子是同一回事。但最后一列揭示了一个问题:433 次 “cows eat” 里有 119 次的 cows 并不是主语,而是干扰名词——比如 “the bats near the cows eat flies”。这些句子把蝙蝠、猫头鹰、狗的食物也算进了 “cows eat” 的分布。trigram 只看到 “cows eat” 两个词,根本无法知道是谁在吃。

3.3 采样:掷一颗加权骰子

slides 把从语言模型里采样描述成四步:

  1. 把每个条件分布想象成一颗(有 5 万个面的)加权骰子;
  2. 取出当前上下文对应的那颗骰子,比如 p(wt∣wt−2,wt−1)p(w_t \mid w_{t-2}, w_{t-1});
  3. 掷骰子,朝上的那个词就是 wtw_t;
  4. 重复,直到掷出 </s>。

01 第 3 部分从不同阶数的 n-gram 里各抽了 2000 句话,用一个语法检查器判断它们是否合法:

n结构合法率合法句中主谓一致率和某个训练句完全相同
10.000—0.000
20.1810.6190.025
30.4750.6210.097
41.0000.7200.306
51.0000.8070.405

几个样本能让你直观感受这些数字:

n=1:  the beside beside . the the hear eat every eats in morning in chases the noise ...
n=2:  the hens behind the hen eats corn in the morning .        ← 动词跟着最近的 hen 用了单数
n=3:  the bats at night .                                         ← 'bats at' 在 'chase the bats at night' 里见过
      the bat behind the dogs see the owls eat mice at night .   ← 主谓不一致,而且一句话里出现了两个动词
n=5:  the hens behind the bat make nests at night .              ← 合法,也一致
  • unigram 是词的乱序堆砌。
  • n 增大,局部越来越通顺:4-gram 以上,“介词 the 名词""动词 宾语”这些局部结构就不会再出错。但主谓一致率始终上不去——句子一长,动词就跟着最近的名词走。
  • n 越大越像背课文:5-gram 有 40% 的样本和某个训练句一模一样。slides 里莎士比亚作品的 5-gram 样本也是这个现象:读起来像莎士比亚,因为很多片段就是从原文里原样拼出来的。

(“照抄率”要谨慎解读:§4.7 会看到,直接从真实分布抽样也有 55% 的句子在训练集里出现过——这门语言的短句组合是有限的。)

3.4 评估:交叉熵与困惑度

怎样定量地比较两个语言模型?最常用的指标是困惑度(perplexity)。在一份测试集上,设所有句子的预测次数(词数加上每句一个 </s>)一共是 NN:

PPL=exp⁡(−1N∑t=1Nlog⁡p(wt∣w<t))\mathrm{PPL} = \exp\Big(-\frac{1}{N} \sum_{t=1}^{N} \log p(w_t \mid w_{<t})\Big)

括号里是平均每个词的负对数似然,也就是模型分布和数据之间的交叉熵(单位 nat)。困惑度就是它的指数。

直觉:有效分支数。 如果模型每一步都在 KK 个词里均匀地犹豫,每个词的概率都是 1/K1/K,那么 PPL=exp⁡(log⁡K)=K\mathrm{PPL} = \exp(\log K) = K。所以困惑度可以理解为”模型平均在几个词之间拿不定主意”。它越小越好;最好的情况是 1(每一步都百分之百确定,而且都对)。

三个要点:

  1. 必须在没见过的数据上算。 在训练集上,一个能把所有训练句背下来的模型困惑度可以很低,但它没有学到”语言”。
  2. 它有理论下界。 真实分布 pdatap_{\text{data}} 自己的困惑度(即数据的熵的指数)是任何模型在期望意义下都达不到更低的值。真实语料里我们不知道这个下界;在玩具语言里我们知道:测试集上是 2.491。
  3. 只有词表相同的模型才能直接比较。 换一个分词方式,“每个词”的含义就变了(第 3 讲讲分词时会再说)。

3.5 零概率与平滑

最大似然估计有一个致命问题:训练集里没见过的 n-gram,概率是 0。只要测试集里有一个这样的 n-gram,整句的概率就是 0,困惑度就是无穷大。01 第 4 部分:

n测试集里没见过的 n-gramMLE(不平滑)加 1 平滑加 0.01 平滑插值(验证集调 λ)
1025.42125.42125.42125.421
203.7993.8413.8003.830
31(0.00%)inf3.5103.2933.293
443(0.19%)inf3.3822.9122.903
5398(1.72%)inf4.2523.0542.866
61111(4.81%)inf5.7343.3972.856

trigram 在测试集的约 23,000 个 trigram 里只有 1 个没见过,困惑度就已经是无穷大了。解决办法叫平滑(smoothing)——从见过的事件那里”匀”一点概率给没见过的事件:

  • 加 k 平滑(add-k / Laplace):每个计数都加 kk,p^(w∣c)=count(c,w)+kcount(c)+k∣V∣\hat{p}(w \mid c) = \frac{\mathrm{count}(c, w) + k}{\mathrm{count}(c) + k\lvert\mathcal{V}\rvert}。简单,但 k=1k=1 时太粗暴:把大量概率分给了根本不可能的词,nn 越大越糟(6-gram 时 5.734,比 trigram 还差)。
  • 插值(interpolation):把高阶和低阶模型按比例混合,p=λ p高阶+(1−λ) p低阶p = \lambda\, p_{\text{高阶}} + (1-\lambda)\, p_{\text{低阶}};上下文没见过时完全退回低阶(回退,back-off)。高阶模型懂上下文但稀疏,低阶模型粗糙但从不出零,混合起来最稳。λ\lambda 要在验证集上调,绝不能碰测试集。
  • 实践中最好的是 Kneser–Ney 平滑(1995):它除了插值,还考虑”一个词出现在多少种不同的上下文里”。Chen & Goodman (1998) 对各种平滑方法做了系统比较。本讲不实现它。

插值模型在 n=6n=6 时最好(2.856),但离真实下界 2.491 还有明显差距。下一节看这个差距从哪里来。

3.6 n-gram 的根本缺陷:看不到窗口之外

主谓一致测试(01 第 5 部分,也是 Linzen et al. 2016 的做法):取动词之前的所有词作为前缀,比较模型给同一个动词的两种形式的概率——p(makes∣前缀)p(\text{makes} \mid \text{前缀}) 和 p(make∣前缀)p(\text{make} \mid \text{前缀})。正确形式的概率更大就算对。只比较同一个动词的两种形式,所以结果和”模型想用哪个动词”无关,只测它是否记得主语的单复数。

最难的情况是所有干扰名词都和主语单复数相反(“the bat near the cows behind the dogs ___”):

模型k=0k=1k=2k=3k=4k=5k=6
2-gram(看 1 个词)1.0000.0430.0310.0450.0600.0510.052
3-gram(看 2 个词)1.0000.0430.0310.0450.0600.0510.052
4-gram(看 3 个词)1.0000.3780.4380.3750.4120.4120.388
5-gram(看 4 个词)1.0000.8150.2000.2230.2050.1800.205
6-gram(看 5 个词)1.0000.8200.1950.2230.2030.1770.198

(训练集只有 k≤3k \le 3;干扰名词单复数随机时,所有 n-gram 在 k≥2k \ge 2 时都在 0.5 附近。)这张表值得逐行读:

  1. bigram 和 trigram 不是在瞎猜,而是被系统性地误导。 它们的上下文是”the cows”,而在训练数据里”the cows”后面最常跟的是复数动词(那些 cows 作主语的句子)。所以它们跟着最近的名词走:最近的名词和主语一致时准确率 0.956,相反时 0.052(k=2k=2)。这就是语言学里的干扰名词效应(attraction effect)——有趣的是,人类在快速说话时也会犯这种错误。
  2. 4-gram 好得多(约 0.4),但原因很微妙。 它看到了名词前面的介词(“near the cows”),从数据里学到了”介词后面的名词不决定动词”,于是接近抛硬币,而不是被误导。
  3. 5-gram、6-gram 在 k=1 时”看得到”主语(主语到动词的距离是 3k+1=43k+1 = 4),准确率 0.82;但 k=2k=2 时主语出了窗口,又回到 0.2。没有任何有限的 nn 能处理任意长的距离。
  4. 就算看得到,5-gram 在 k=1 时也只有 0.84。 它看到的 4 个词(如 “bat near the cows”)在训练集里有 39.6% 的时候出现在更长句子的中间——“the dogs behind the bat near the cows chase …”——那时 bat 本身也只是干扰名词。窗口里没有句首记号,模型就分不清窗口里的第一个名词是不是主语。它真正需要的是结构(“这个名词处在什么位置”),而不只是更长的窗口。

3.7 我们需要什么样的模型

把 n-gram 的问题汇总一下:

问题表现我们想要
数据稀疏没见过的 n-gram 概率为 0;nn 一大,测试集里没见过的 n-gram 越来越多(6-gram 已有 4.8%)能在相似的上下文之间共享信息:“the bat makes” 和 “the owl makes” 应该互相帮助
参数爆炸表的大小随 nn 指数增长;6-gram 的表在我们这门小语言上已有 3.1 万个非零条目参数量和上下文长度无关
窗口有限看不到 n−1n-1 个词之外;看得到也分不清结构能记住任意长的历史,并且知道该记住什么

第一个需求的答案是词向量(embedding):每个词用一个稠密向量表示,相似的词向量相近,于是模型可以把关于 bat 学到的东西迁移到 owl 上(Bengio et al., 2003 的神经概率语言模型最早系统地提出了这一点)。后两个需求的答案就是 RNN。


4. RNN 语言模型 · Recurrent Neural Network LMs

4.1 关键想法:把历史压缩成一个定长向量

slides 对 RNN-LM 的概括只有两句话:

  1. 把之前所有的词转换成一个定长向量:ht=fθ(wt−1,…,w1)h_t = f_\theta(w_{t-1}, \dots, w_1);
  2. 定义一个以这个向量为条件的分布 p(wt∣ht)p(w_t \mid h_t)。

于是整句的概率是

p(w1,…,wT)=∏t=1Tp(wt∣ht),ht=fθ(w<t)p(w_1, \dots, w_T) = \prod_{t=1}^{T} p(w_t \mid h_t), \qquad h_t = f_\theta(w_{<t})

和 §2.4 的链式法则对比:RNN 没有做马尔可夫假设,hth_t 原则上依赖于全部历史。它做的是另一种近似——把历史”压缩”进一个 HH 维的向量。压缩必然有损失,但它是学出来的有损压缩:网络自己决定该记住什么(主语的单复数、主语是哪种动物),该忘掉什么(介词是 near 还是 behind)。

怎样用一个固定大小的函数处理任意长的历史?答案是递推:hth_t 由上一步的 ht−1h_{t-1} 和新读入的词 wt−1w_{t-1} 算出来,每一步用的是同一个函数、同一组参数。

4.2 Elman RNN 的定义

slides 引用的是 Graves et al. (2013) 里的标准写法。给定输入序列 x=(x1,…,xT)x = (x_1, \dots, x_T),RNN 计算隐藏向量序列 h=(h1,…,hT)h = (h_1, \dots, h_T) 和输出序列 y=(y1,…,yT)y = (y_1, \dots, y_T):

ht=H(Wxhxt+Whhht−1+bh),yt=Whyht+byh_t = \mathcal{H}(W_{xh} x_t + W_{hh} h_{t-1} + b_h), \qquad y_t = W_{hy} h_t + b_y

其中 H\mathcal{H} 是逐元素的非线性函数(slides 说”通常是 sigmoid”;实践中更常用 tanh,我们的代码用 tanh)。这种最简单的循环网络叫 Elman RNN(Elman, 1990)。各部分的形状(设输入维度 EE、隐藏维度 HH、输出维度 KK):

量形状作用
xtx_tEE第 tt 步的输入(对语言模型是一个词向量)
WxhW_{xh}H×EH \times E把新输入映射到隐藏空间
WhhW_{hh}H×HH \times H把”过去的总结”传到下一步——循环就在这里
bhb_hHH偏置
hth_tHH到第 tt 步为止的”记忆”
WhyW_{hy},byb_yK×HK \times H,KK从记忆读出输出

参数共享。 注意 Wxh,Whh,bh,Why,byW_{xh}, W_{hh}, b_h, W_{hy}, b_y 没有下标 tt:每个时间步用的是同一组参数。这有三个后果:

  1. 参数量和序列长度无关——处理 10 个词和 1000 个词的句子用的是同一个模型。
  2. 在第 3 个位置学到的规律(“复数主语后面跟复数动词”)自动适用于第 30 个位置。这是一种归纳偏置(inductive bias):我们假设”语言的规律不随位置变化”。
  3. 训练时,同一个参数的梯度要把所有时间步的贡献加起来(§5.7 会在代码里看到)。

为什么用 tanh? 它把 hth_t 限制在 (−1,1)(-1, 1),防止递推中数值无限增大;它在 0 附近接近线性、导数为 1,比 sigmoid(导数最大只有 0.25)更不容易让梯度在多步传递后消失(§4.8)。

4.3 从隐藏状态到下一个词的分布

对语言模型,还需要两样东西:

  • 输入怎样表示一个词? 用一张嵌入表 E∈R∣V∣×dE \in \mathbb{R}^{\lvert\mathcal{V}\rvert \times d}:第 ii 个词的向量就是第 ii 行。查表等价于”one-hot 向量乘矩阵”,所以它就是一个没有偏置的线性层,只是实现时直接取行更快。嵌入表是和其他参数一起学出来的,训练结束后意思相近的词向量也相近——这正是 §3.7 要的”相似上下文共享信息”。
  • 怎样从 hth_t 得到一个分布? 输出层给每个词打一个分(logits),再用 softmax 归一化:
p(wt=i∣ht)=softmax(Whyht+by)i=exp⁡((Whyht+by)i)∑j=1∣V∣exp⁡((Whyht+by)j)p(w_t = i \mid h_t) = \mathrm{softmax}(W_{hy} h_t + b_y)_i = \frac{\exp\big((W_{hy} h_t + b_y)_i\big)}{\sum_{j=1}^{\lvert\mathcal{V}\rvert} \exp\big((W_{hy} h_t + b_y)_j\big)}

slides 在这里问了一个问题:“怎样从 hth_t 创建分布 p(wt∣ht)p(w_t \mid h_t)?“答案就是这个 softmax:它保证所有词的概率都为正、加起来等于 1,而且是可微的,可以用梯度下降训练。

我们的模型(rnn_lm.py):∣V∣=56\lvert\mathcal{V}\rvert = 56、d=32d = 32、H=64H = 64,一共 11,640 个参数(嵌入 1,792 + 循环层 6,208 + 输出层 3,640)。作为对照,6-gram 的计数表里有 31,136 个非零条目(还不算低阶的表)。

4.4 START 与 END

slides 画的 RNN-LM 图里,序列以 START 开始、以 END 结束:

输入:   START   The    bat    made   noise   at     night
          h1     h2     h3     h4     h5      h6     h7
预测:    The    bat    made   noise  at      night  END
  • START(代码里是 <s>):预测第一个词时还没有任何历史,需要一个”什么都没读过”的输入来产生 h1h_1。
  • END(</s>):句子长度不固定,模型必须学会什么时候停。没有 END 时,模型只能给”长度为 TT 的序列”定义分布,无法比较不同长度的句子;有了 END,p(The bat made noise at night END)p(\text{The bat made noise at night END}) 才是一个在所有有限长度序列上的合法分布(所有句子的概率加起来等于 1)。采样时也要靠它停下来。

4.5 训练:最大似然、教师强制与 BPTT

训练目标和 n-gram 一样是最大似然:让训练句子的概率尽可能大。等价地,最小化平均负对数似然,也就是交叉熵损失:

J(θ)=−1N∑句子∑tlog⁡pθ(wt∣w<t)J(\theta) = -\frac{1}{N} \sum_{\text{句子}} \sum_{t} \log p_\theta(w_t \mid w_{<t})

和 n-gram 不同的是,这里没有”数数”这种闭式解,只能用梯度下降。训练 RNN-LM 有三个要点:

  1. 教师强制(teacher forcing):训练时,每一步的输入都是真实的上一个词,而不是模型自己预测的词。这样一句话的所有位置可以一次前向就算出损失(不必等模型逐词生成)。代价是训练和生成时的输入分布不一样(训练时永远看到正确的历史,生成时看到的是自己生成的、可能有错的历史),这叫暴露偏差(exposure bias)。
  2. 反向传播穿越时间(BPTT, backpropagation through time):把 RNN 按时间展开成一个很深的前馈网络(每个时间步一层,各层共享参数),在这张展开图上做普通的反向传播。§5.7 会用我们自己写的自动微分引擎验证:BPTT 不是一个新算法,只是在展开图上做反向模式自动微分。
  3. 梯度裁剪(gradient clipping):梯度的总范数超过某个阈值(我们用 1.0)就等比例缩小。原因见 §4.8。

批量训练时,不等长的句子要补齐成一样长(填充记号 <pad>),损失要跳过填充的位置——这些工程细节第 3 讲细讲,代码 rnn_lm.py 的 make_batches 已经实现了。

4.6 采样

slides 强调:“我们用来从 n-gram 采样的方法,同样适用于 RNN 语言模型”:从 START 开始,每一步用当前的 hth_t 算出下一个词的分布、掷骰子、把抽中的词作为下一步的输入,直到抽到 END。唯一的区别是骰子由 hth_t 决定,而不是由最近 n−1n-1 个词查表决定;而且 hth_t 在步与步之间传递,不需要每次把整个前缀重新喂一遍。

slides 展示了 Karpathy (2015) 用字符级 RNN 在莎士比亚作品上训练后生成的文本,和莎士比亚的真实文本并排放在一起,问”哪个才是真的莎士比亚?“——字符级 RNN 生成的文字格式、用词、甚至角色名的写法都像模像样,只是读下去意思不太连贯。这个例子在 2015 年让很多人第一次意识到神经语言模型能学到多少结构。

温度(temperature):把 logits 除以 τ\tau 再做 softmax。τ<1\tau < 1 让分布更尖(更保守、更重复),τ>1\tau > 1 让分布更平(更多样、更容易出错)。第 3 讲会系统讨论解码策略。

4.7 实验:RNN-LM vs n-gram

05_rnn_lm.py 在 2 万句训练语料上训练 10 个 epoch(单线程 CPU 每个 epoch 约 0.7 秒),然后和 n-gram 正面比较。

训练过程。 每个 epoch 结束时,在”干扰名词全部和主语相反”的最难句子上测一致性:

epoch训练损失验证集困惑度一致性 k=1k=3k=6(训练中没见过)
11.20282.84471.0000.9400.880
21.00562.67871.0001.0001.000
40.94932.57181.0001.0001.000
100.93172.55101.0001.0001.000

仅仅 1 个 epoch 后,验证集困惑度就到了 2.845,已经和最好的 6-gram(测试集 2.856)差不多;而且近的依赖先学会、远的依赖后学会(第 1 个 epoch 结束时 k=1 已全对,k=6 还只有 0.88)。

困惑度。

模型测试集困惑度
unigram(加 1 平滑)25.4209
bigram(插值)3.8303
trigram(插值)3.2932
6-gram(插值)2.8557
RNN-LM2.5396
真实分布(理论下界)2.4913

换算成”每个词比真实分布多付出的交叉熵”:6-gram 是 0.137 nat,RNN 是 0.019 nat——差距缩小到七分之一。

差距从哪里来? 把每个预测位置按语法角色分组,分别看平均负对数似然(nat/词,越小越好):

位置预测次数trigramRNNtrigram − RNN
主语名词20002.7742.791−0.017
介词短语52471.6081.572+0.036
动词20002.8811.842+1.040
宾语28661.3500.815+0.536
时间/地点50070.7870.339+0.447

(“the”、句号、</s> 三个位置是确定的,两者都是 0。)RNN 的优势全部集中在依赖远处主语的位置:主语决定动词的单复数和种类、决定吃什么、决定什么时候出现。在”主语名词”这个位置,没有任何历史可以利用(8 种动物 × 单复数均匀分布),最好也只能做到 ln⁡16=2.773\ln 16 = 2.773,两者都接近这个值。

主谓一致。

模型k=0k=1k=2k=3k=4k=5k=6
3-gram1.0000.0430.0310.0450.0600.0510.052
6-gram1.0000.8200.1950.2230.2030.1770.198
RNN-LM1.0001.0001.0001.0001.0001.0001.000

(干扰名词全部和主语相反。)RNN 在所有距离上都做对了,包括训练中从没见过的 k=4、5、6。这说明它学到的不是”第几个词是主语”,而是”跳过介词短语、记住主语单复数”这样的规则。

但要诚实地说:这个结果比真实情况”好看”。 我们的介词短语格式非常规整(永远是”介词 the 名词”),要记住的只有 1 个比特(单复数)。Linzen et al. (2016) 在真实英语上发现,LSTM 整体准确率很高,但错误率会随干扰名词个数明显上升。而且这种”外推”依赖于训练数据:练习 7 会看到,如果训练集里只有 k≤1k \le 1 的句子,RNN 在 k=2k=2 时就降到 0.83、k=6k=6 时 0.37;如果训练集里根本没有介词短语,它和 trigram 一样跟着最近的名词走。第 2 讲会构造需要记住更多信息、距离更远的任务,那时简单 RNN 的”遗忘”就会暴露出来。

采样。

来源温度结构合法率合法句中主谓一致率与训练句完全相同不同句子占比
真实分布(生成器本身)—1.0001.0000.5530.781
3-gram(最大似然)1.00.4680.6450.1160.835
5-gram(最大似然)1.01.0000.8070.3890.824
RNN-LM0.51.0001.0000.6060.696
RNN-LM1.00.9940.9990.5670.768
RNN-LM1.50.9360.9850.5200.798
  • 温度 1.0 时 RNN 的样本几乎全部合法且一致,各项统计都和真实分布非常接近。
  • “照抄率”必须和真实分布对照着看:真实分布本身就有 55% 的样本在训练集里出现过。RNN(0.567)和它接近;5-gram 只有 0.389,不是因为它更有创造力,而是因为它拼出了很多真实分布里不可能出现的句子(主谓不一致)。
  • 温度 0.5 更保守(不同句子占比降到 0.696);温度 1.5 更多样,但合法率掉到 0.936。这是质量与多样性的权衡。

4.8 梯度穿越时间:消失与爆炸

RNN 在 k=6 的句子里判断动词形式时,需要的信息来自 18 步之前的主语。训练时,误差信号必须从动词位置一路传回读到主语的那个时刻,才能”告诉”当时的 hth_t“你应该把单复数记住”。这个信号在途中会变成什么样?

由 ht=tanh⁡(Wxhxt+Whhht−1+bh)h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h),

∂ht∂ht−1=diag(1−ht2) Whh,∂hT∂hT−d=∏s=T−d+1Tdiag(1−hs2) Whh\frac{\partial h_t}{\partial h_{t-1}} = \mathrm{diag}(1 - h_t^2)\, W_{hh}, \qquad \frac{\partial h_T}{\partial h_{T-d}} = \prod_{s=T-d+1}^{T} \mathrm{diag}(1 - h_s^2)\, W_{hh}

传回 dd 步要连乘 dd 个矩阵。粗略地说,如果每个因子的”放大倍数”小于 1,连乘后指数级变小(梯度消失,vanishing gradient);大于 1 且 tanh 没有饱和,则指数级变大(梯度爆炸,exploding gradient)。Bengio, Simard & Frasconi (1994) 和 Hochreiter (1991) 最早分析了这个问题;Pascanu, Mikolov & Bengio (2013) 给出了基于谱半径的条件,并提出了梯度裁剪。

06_gradient_through_time.py 实际测了 ∥∂log⁡p(正确动词)/∂hT−d∥\lVert \partial \log p(\text{正确动词}) / \partial h_{T-d} \rVert(相对于 d=0d=0):

距离 d训练后随机初始化
011
32.4e-019.0e-02
97.9e-028.2e-04
18(主语名词)2.6e-037.5e-07
  • 随机初始化时,信号传回主语位置只剩百万分之一:每往回一步平均缩小到 0.46 倍。刚开始训练时,模型几乎收不到”主语很重要”的信号——这解释了为什么远的依赖学得晚。
  • 训练之后,每步平均缩小到 0.72 倍,传回 18 步还剩 2.6e-03,是随机初始化时的 3500 倍。训练把衰减放慢到足以让”单复数”这一个比特跨过 18 步。但这仍然是一个指数衰减;依赖再远、要记的东西再多,这种平衡就很难维持。

把随机初始化的 WhhW_{hh} 乘以不同倍数(默认初始化下谱半径 ρ=0.608\rho = 0.608),看 d=18d=18 处的相对梯度:

W_hh 倍数谱半径tanh RNN(平均 1−h²)线性 RNN(去掉 tanh)
0.50.304.5e-12(0.774)2.0e-10
1.00.617.5e-07(0.749)5.2e-05
2.01.222.4e-02(0.639)1.5e+01
4.02.431.5e+01(0.399)4.9e+06
  • 线性 RNN 的梯度大致按 ρd\rho^d 变化:ρ=2.43\rho = 2.43 时 18 步后放大近五百万倍(2.4318≈9×1062.43^{18} \approx 9 \times 10^6,同一个数量级)。
  • tanh RNN 在 ρ=2.43\rho = 2.43 时也在增长(15 倍),但比线性 RNN 小五个数量级:WhhW_{hh} 一大,隐藏状态就被推进 tanh 的饱和区,平均 1−h21-h^2 从 0.749 降到 0.399,把增长压住了绝大部分。
  • 消失和爆炸是同一个连乘结构的两面。 对爆炸,梯度裁剪是简单有效的保险;对消失,要改结构——第 2 讲的 LSTM 用一个加法式更新的记忆单元 ct=ft⊙ct−1+it⊙c~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t 让梯度可以几乎不衰减地流过很多步。

4.9 RNN 家族

slides 引用的 Graves et al. (2013) 还介绍了几种变体,第 2 讲会展开:

  • LSTM(Hochreiter & Schmidhuber, 1997):用输入门、遗忘门、输出门控制一个记忆单元 ctc_t 的读写,专门为了解决长程依赖。slides 列出的公式:it=σ(… )i_t = \sigma(\dots),ft=σ(… )f_t = \sigma(\dots),ct=ftct−1+ittanh⁡(… )c_t = f_t c_{t-1} + i_t \tanh(\dots),ot=σ(… )o_t = \sigma(\dots),ht=ottanh⁡(ct)h_t = o_t \tanh(c_t)。
  • 双向 RNN:一个 RNN 从左往右读,另一个从右往左读,每个位置的输出同时看到过去和未来。语音识别里整段话一次性转写,用未来的上下文没有问题。但它不能直接当生成式语言模型用:生成第 tt 个词时,未来的词还不存在(练习 8)。
  • 深层 RNN:把多个 RNN 叠起来,下一层的输出序列是上一层的输入序列,逐层建立更高层次的表示。

5. 自动微分 · Automatic Differentiation

§4.5 说”用梯度下降训练”。但 RNN-LM 有上万个参数(GPT-3 有 1750 亿个),损失对每个参数的偏导数是怎样算出来的?这一节回答这个问题。它是这门课里所有模型共用的引擎。

5.1 为什么需要梯度;三种求导方式

训练 = 最小化损失 J(θ)J(\theta)。梯度下降每一步做 θ←θ−η ∇θJ\theta \leftarrow \theta - \eta\, \nabla_\theta J,所以每一步都要算出 JJ 对每一个参数的偏导数。有三种办法:

方法做法问题
符号微分像手算一样,把 JJ 的表达式求导,得到导数的表达式表达式会膨胀(同一个子表达式在导数里反复出现);碰到 for 循环和 if 就难办
数值微分(有限差分)∂J/∂θi≈[J(θ+εei)−J(θ−εei)]/2ε\partial J / \partial \theta_i \approx [J(\theta + \varepsilon e_i) - J(\theta - \varepsilon e_i)] / 2\varepsilon每个参数要算两次 JJ:1750 亿个参数就是 3500 亿次前向;而且有截断误差和舍入误差
自动微分把计算拆成一串基本运算,对每个基本运算用已知的导数公式,再用链式法则组合无(精确到浮点误差,代价和算一次 JJ 同一量级)

有限差分虽然不能用来训练,却是检查梯度是否写对的标准工具(§5.6):它慢,但几乎不可能写错。

5.2 计算图

自动微分的第一步,是把计算 y=f(x)y = f(x) 的过程写成一个有向无环图(DAG),叫计算图:每个变量是一个节点,”uiu_i 由 v1,…,vNv_1, \dots, v_N 算出”就画从 vjv_j 指向 uiu_i 的边。slides 的写法:

前向计算

  1. 写出计算 y=f(x)y = f(x) 的算法,它定义了一张有向无环图;
  2. 按拓扑序访问每个节点(保证一个节点的所有输入都先算好):对输入为 v1,…,vNv_1, \dots, v_N 的变量 uiu_i,计算 ui=gi(v1,…,vN)u_i = g_i(v_1, \dots, v_N),并把结果存在节点上。

5.3 反向模式:两种写法

反向计算从输出往回走。slides 给了两种等价的写法。

Version A(“拉”): 初始化 dy/dy=1dy/dy = 1;按逆拓扑序访问每个节点 vjv_j。设 u1,…,uMu_1, \dots, u_M 是所有以 vjv_j 为输入的节点,此时 dy/duidy/du_i 都已知,于是

dydvj=∑i=1Mdyduiduidvj\frac{dy}{dv_j} = \sum_{i=1}^{M} \frac{dy}{du_i} \frac{du_i}{dv_j}

Version B(“推”): 把所有 dy/dudy/du 初始化为 0,dy/dy=1dy/dy = 1;按逆拓扑序访问每个节点 ui=gi(v1,…,vN)u_i = g_i(v_1, \dots, v_N),此时 dy/duidy/du_i 已经完整,对它的每个输入累加:

dydvj+=dyduiduidvj\frac{dy}{dv_j} \mathrel{+}= \frac{dy}{du_i} \frac{du_i}{dv_j}

两种写法算的是同一个和:A 是”站在 vjv_j 处,把所有下游节点的贡献拉过来”,B 是”站在 uiu_i 处,把自己的贡献推给每个上游节点”。B 更容易实现:每个节点只需要知道自己的输入,不需要知道谁用了自己。PyTorch 和我们的 tape 引擎都用 B。

+=+= 是关键:一个节点可能被多个下游节点使用,它的梯度是所有用途的贡献之和。RNN 里同一个 WhhW_{hh} 在每个时间步都被用了一次,所以它的梯度是 TT 个时间步的贡献之和(§5.7 用代码验证)。这也是”每一步训练前要清零梯度”的根源(§5.8)。

slides 在两种写法里都标注了一句:“算法的选择保证了 dui/dvjdu_i/dv_j 容易计算”——每个基本运算(加、乘、tanh、矩阵乘法……)的局部导数都有现成的公式。自动微分的全部智慧就是:复杂函数的导数 = 很多个简单局部导数按链式法则组合,而组合的顺序由计算图决定。

5.4 为什么是反向模式

链式法则也可以从输入往输出方向用,叫前向模式(forward mode):给每个变量附带”它对某一个输入 xix_i 的导数”,随着前向计算一起传播。前向模式一次只能得到对一个输入的导数。所以:

  • 函数 f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m;
  • 前向模式要跑 nn 遍(每遍得到雅可比矩阵的一列);
  • 反向模式要跑 mm 遍(每遍得到雅可比矩阵的一行),每遍的代价是一次前向 + 一次反向,和算一次 ff 同一量级。

神经网络训练恰好是 nn 极大(几十亿个参数)、m=1m = 1(标量损失) 的情形,所以反向模式完胜。反向模式自动微分在神经网络里的名字就是反向传播(backpropagation)。

02 第 4 部分对 f(x)=∑isin⁡(xi) xi+1f(x) = \sum_i \sin(x_i)\, x_{i+1}(f:Rn→Rf: \mathbb{R}^n \to \mathbb{R})实测了基本运算次数:

输入维度 n前向模式(对偶数,n 遍)反向模式(1 遍前向 + 1 遍反向)倍数
10300803.8×
10030,00080037.5×
10003,000,0008,000375×

前向模式的代价随 n2n^2 增长(nn 遍 × 每遍约 3n3n 次运算),反向模式随 nn 线性增长(前向 3n3n 次 + 反向时每条边一次乘加 5n5n 次)。两种模式算出的梯度完全相同。

5.5 过程式实现:slides 的那个网络

slides 用一个小网络贯穿自动微分这一节:一层 sigmoid 隐藏层 + softmax 输出 + 交叉熵损失(没有偏置项;需要偏置时可以在 xx 末尾补一个常数 1)。

前向(Algorithm 1):

a=αx,z=σ(a),b=βz,y^=softmax(b),J=−y⊤log⁡y^a = \alpha x, \quad z = \sigma(a), \quad b = \beta z, \quad \hat{y} = \mathrm{softmax}(b), \quad J = -y^\top \log \hat{y}

反向(Algorithm 2),每一行都是”下游梯度 × 局部导数”:

步骤公式来源
交叉熵gy^=−y÷y^g_{\hat{y}} = -y \div \hat{y}∂(−∑kyklog⁡y^k)/∂y^k=−yk/y^k\partial(-\sum_k y_k \log \hat{y}_k) / \partial \hat{y}_k = -y_k / \hat{y}_k
softmaxgb=(diag(y^)−y^y^⊤) gy^g_b = (\mathrm{diag}(\hat{y}) - \hat{y}\hat{y}^\top)\, g_{\hat{y}}softmax 的雅可比矩阵:∂y^i/∂bj=y^i(δij−y^j)\partial \hat{y}_i / \partial b_j = \hat{y}_i(\delta_{ij} - \hat{y}_j)
线性层 2gβ=gbz⊤g_\beta = g_b z^\top,gz=β⊤gbg_z = \beta^\top g_bbi=∑jβijzjb_i = \sum_j \beta_{ij} z_j,所以 ∂J/∂βij=gb,i zj\partial J / \partial \beta_{ij} = g_{b,i}\, z_j
sigmoidga=gz⊙z⊙(1−z)g_a = g_z \odot z \odot (1 - z)σ′(a)=σ(a)(1−σ(a))\sigma'(a) = \sigma(a)(1 - \sigma(a))
线性层 1gα=gax⊤g_\alpha = g_a x^\top同线性层 2

一个值得记住的化简:把 softmax 和交叉熵合起来求导,gb=y^−yg_b = \hat{y} - y——“预测分布减真实分布”,简洁而且数值稳定(不需要除以可能很小的 y^\hat{y})。所以实际框架都把两者合成一个模块(PyTorch 的 F.cross_entropy 直接吃 logits)。练习 4 让你推导它。

02 第 1 部分用有限差分检查这 6 行:gαg_\alpha、gβg_\beta 的相对误差分别是 4.5e-10 和 1.4e-10(float64 下有限差分自身就有这么大的误差),说明公式是对的。

过程式写法的三个缺点(slides):

  1. 难以复用:换一个网络(多一层、sigmoid 换成 tanh)就要重写整串公式;
  2. 难以单独优化某一步;
  3. 难以定位错误:有限差分检查报错时,你只知道”这 17 行代码里某处错了”。

5.6 基于模块的自动微分

关键想法(slides):把网络的计算组件化成层;每一层把计算图里的一组实数节点合并成一个向量值节点,叫一个模块(module)。每个模块只需要会两件事:

  1. 前向:给定输入 a∈RAa \in \mathbb{R}^A,用某个可微函数算出输出 b=f(a)∈RBb = f(a) \in \mathbb{R}^B;
  2. 反向:给定”损失对输出的梯度” gb=∇bJ∈RBg_b = \nabla_b J \in \mathbb{R}^B,算出”损失对输入的梯度” ga=∇aJ∈RAg_a = \nabla_a J \in \mathbb{R}^A,用的正是链式法则 ∂J∂ai=∑j=1B∂J∂bj∂bj∂ai\frac{\partial J}{\partial a_i} = \sum_{j=1}^{B} \frac{\partial J}{\partial b_j} \frac{\partial b_j}{\partial a_i}。

slides 列了四个模块:Linear(b=ωab = \omega a;反向 gω=gba⊤g_\omega = g_b a^\top,ga=ω⊤gbg_a = \omega^\top g_b)、Sigmoid、Softmax、CrossEntropy。代码 autodiff.py 实现了它们(外加 Tanh),网络的前向就是依次调用各模块的 forward,反向就是倒序调用各模块的 backward。02 第 2 部分验证:模块化写法和过程式写法的结果严格相同(差为 0,因为执行的是同样的浮点运算)。

模块化的三个好处(slides):易于复用和改造;封装好的层更容易单独优化(例如用 C++ 或 CUDA 实现);更容易找到 bug,因为可以对每一层单独做有限差分检查。

最后一点值得用实验体会(02 第 3 部分)。我们故意写一个有 bug 的 sigmoid 反向:忘了乘 (1−b)(1 - b)。

检查方式结果
逐模块检查:Linear、Sigmoid、Tanh、Softmax、CrossEntropy相对误差都在 1e-10 量级
逐模块检查:有 bug 的 Sigmoid0.41
整网检查(用有 bug 的 sigmoid 组装网络)gαg_\alpha 误差 0.25,gβg_\beta 误差 1.4e-10

整网检查只能告诉你”gαg_\alpha 错了、gβg_\beta 没错”——错误在 β\beta 之前的某处;逐模块检查直接指出是 Sigmoid。单模块检查的技巧:随便取一个随机的上游梯度 goutg_{\text{out}},令 L=∑gout⊙forward(输入)L = \sum g_{\text{out}} \odot \mathrm{forward}(\text{输入}),那么 ∂L/∂输入\partial L / \partial \text{输入} 恰好就是 backward 应该返回的东西,可以直接和有限差分比较(check_module)。

有限差分的两个细节:用中心差分 [f(x+ε)−f(x−ε)]/2ε[f(x+\varepsilon) - f(x-\varepsilon)]/2\varepsilon 而不是单侧差分,截断误差从 O(ε)O(\varepsilon) 降到 O(ε2)O(\varepsilon^2);必须用 float64,float32 下 ε=10−6\varepsilon = 10^{-6} 的差分几乎全是舍入噪声(练习 5)。

5.7 面向对象 + tape:边运行边建图

模块化写法还有一个不便:反向时”倒序调用哪些模块”仍然要手写(nn_backward_modular 里那 5 行)。slides 的 OOP 版本把这一步也自动化了:

  • 每个模块是一个对象;
  • 让程序的控制流决定计算图的构建:每调用一次模块的 apply_fwd,就把它压进一个全局的栈(tape,磁带);
  • 反向时不再需要 NNBackward:从磁带顶部一个个弹出模块、调用它的 apply_bwd,每个模块把输入梯度累加到它的输入模块上(in_modules[i].out_gradient += in_gradients[i])。

为什么弹栈的顺序是对的?因为前向时任何节点都在它的输入之后压栈,所以栈的顺序是一个拓扑序;倒着弹出就是逆拓扑序——处理一个节点时,所有用到它输出的节点都已经处理完、它的梯度已经累加完整。这正是 §5.3 的 Version B。

03_tape_autodiff.py 验证了三件事:

  1. tape 版的梯度和手推公式逐位相同(差为 0)。前向时磁带上录了 5 个节点;反向之后磁带被弹空——要再求一次梯度就得重新跑一遍前向。PyTorch 默认也这样(第二次 backward 会报错,除非 retain_graph=True)。
  2. 它足以训练网络。 只用这个不到 200 行的引擎 + 逐样本 SGD,在三臂螺旋数据上训练 100 个 epoch:损失从 1.03 降到 0.005,训练准确率从 0.587 升到 1.000;没有隐藏层的线性分类器只有 0.627(螺旋缠绕在一起,必须靠非线性把空间”弯”过来)。
  3. BPTT 就是展开图上的反向模式。 把 Elman RNN-LM 在一句 15 步的话上展开(rnn_lm_tape:一个 for 循环,每一步调用查表、两个线性层、两次加法、tanh、输出层、softmax 交叉熵),磁带上一共录了 136 个节点。用同样的权重在 PyTorch(float64)里算同样的损失,6 个参数的梯度最大差 6.9e-15——我们没写任何 BPTT 专用代码,for 循环 + 磁带就自动得到了正确的 BPTT。而且 WhhW_{hh} 的梯度等于 15 个时间步各自贡献 gpre,t ht−1⊤g_{\text{pre},t}\, h_{t-1}^\top 之和(差 1.8e-15)。

这种”边运行边建图”的方式叫 define-by-run(也叫 eager / imperative 模式),是 PyTorch 的标志性设计。与之相对的是先声明一张静态图、再喂数据运行的 define-and-run(TensorFlow 1.x 的方式)。define-by-run 的好处是计算图可以随数据变化——RNN 处理不同长度的句子时,展开的步数不同,图自然就不同;代价是每次都要重新建图,框架必须让这件事足够快。

5.8 PyTorch

slides 最后用 PyTorch 重写了同一个网络,并回答了两个问题。04_pytorch_basics.py 逐一演示:

PyTorch 的 autograd 就是 tape。 requires_grad=True 的张量参与的每个运算,都会在结果上记一个 grad_fn(图里的节点)。对 if x.sum() > 0: y = (x*x).sum() else: y = sin(x).sum()*3,两次调用走了不同分支,记下了两张不同的图(SumBackward0 ← MulBackward0 ← AccumulateGrad vs MulBackward0 ← SumBackward0 ← SinBackward0 ← AccumulateGrad)。AccumulateGrad 是图的叶子,它把梯度累加进 .grad。同一个网络的 PyTorch 梯度和我们手推的梯度差 3e-17。

问题 1:“为什么不直接调用 linear.forward()?” slides 的回答:这只是语法糖。Python 的特殊方法 __call__ 让对象可以像函数一样被调用,linear(x) 等价于 linear.__call__(x),而 PyTorch 把每个 Module 的 __call__ 定义成”差不多就是调用 forward”。“差不多”在于:__call__ 还会执行注册在模块上的钩子(hook)。04 第 3 部分:调用 model(x) 时 forward hook 执行了 1 次,直接调用 model.forward(x) 时没有执行。很多工具(显存统计、可视化、第 10 讲的 LoRA 注入)都依赖钩子,所以永远写 model(x)。

问题 2:“为什么不把参数传进 Module?” slides 的回答:这样代码更干净。参数存在模块内部,并被”标记”为应该参与梯度计算的参数,优化器一行 SGD(model.parameters()) 就能拿到它们。04 第 3 部分演示了一个常见 bug:在 __init__ 里写 self.bad = torch.zeros(3, requires_grad=True),它不会被注册为参数(必须用 nn.Parameter 或子模块),于是不会被优化器更新、也不会被 state_dict 保存。

梯度会累加。 对 ℓ=3w\ell = 3w 连续调用三次 backward(),w.grad 依次是 3、6、9。这是有意的设计(共享参数、梯度累积都需要累加),代价是每一步训练前必须 optimizer.zero_grad()。于是有了所有 PyTorch 训练代码共同的五步循环:

optimizer.zero_grad()            # 1. 清零梯度
logits = model(X)                # 2. 前向(autograd 边算边录)
loss = F.cross_entropy(logits, y)  # 3. 损失
loss.backward()                  # 4. 反向:沿着录下的图倒着走,填好每个参数的 .grad
optimizer.step()                 # 5. 用梯度更新参数

同一份螺旋数据,用 Adam 全批量训练 800 步,训练准确率 1.000。

PyTorch 论文的设计哲学。 Paszke et al. (2019) 把 PyTorch 的设计原则概括为:符合 Python 的习惯(Pythonic);研究者优先;务实的性能;“够用就好”(worse is better)——宁可让内部实现简单,也要让新功能能快速加进来。它的核心是一个用 C++ 写的张量库和 autograd 引擎,Python 只负责”指挥”:03 的纯 Python 引擎训练 100 个 epoch 要 0.8 秒,同样的事 PyTorch 一次前向处理一整批、底层用优化过的 C++/CUDA 运算,快几个数量级。

5.9 历史小注

  • 前向模式的思想可以追溯到 Wengert (1964);反向模式由 Linnainmaa (1970) 系统提出。
  • Rumelhart, Hinton & Williams (1986) 让反向传播在神经网络界广为人知。
  • Bottou & Gallinari (1991)(本讲阅读材料)提出了一个”让学习算法协作”的框架:把学习系统拆成模块,每个模块提供前向计算和反向的梯度计算,通过链式法则组合起来训练。这正是 slides 里”基于模块的自动微分”的思想源头之一,也是后来 Lush、Torch(Lua)、PyTorch 的 nn.Module 一脉相承的设计。
  • PyTorch(2016 年底发布,论文是 Paszke et al., 2019)让 define-by-run 成为主流;今天训练几乎所有生成式模型用的都是 PyTorch 或 JAX。

6. “另一种学习”:这门课希望你怎样学 · The other kind of learning

slides 里有一节标题叫”THE OTHER KIND OF LEARNING”——这里的”学习”不是机器的学习,而是你的学习。它讨论了一个今天所有学生都要面对的问题:有了 LLM 和编程智能体,还需要自己做题、自己写代码吗?

slides 提出两个主张,并逐一回应了反对意见:

  • 主张 A:这门课最重要的学习,发生在你自己挣扎着解决有挑战性的作业题(和写一些代码)的过程中。 反对意见包括”将来不需要自己写代码了""雇主想要会用 AI 助手的人""将来的专家只需要知道该解决什么问题”。slides 的回应大意是:资深工程师的日常本来就不是”自己写所有代码”,但面试时你仍要边写代码边讲清思路;而精心设计的作业正是培养”判断力和品味”的途径。slides 还坦率承认:对于新手能做的题,LLM 当然也能做——正因为如此,做题的价值在于你自己的成长,而不是答案本身。
  • 主张 B:用编程助手解决一个”还没人解决过”的问题(不同于作业这种早已被解决的问题)其实很难,因为你必须能读懂大量生成的代码、在看似正确且注释良好的代码里发现 bug;能准确说清问题是什么、想怎样解决;懂得软件开发的最佳实践(设计模式、单元测试、代码规范、可复现性、版本控制)。对于”会用 AI 助手很简单”的说法,slides 的类比是:对一个懂得个位数加法的幼儿园小朋友来说,学会用计算器确实很简单。

基于这些想法,原课的每份作业有两个截止时间:

  • Slot A(只允许人类独立完成):个人完成,有限的讨论,不允许任何 AI 辅助。助教答疑只在这个阶段开放。
  • 反馈:助教批改,告诉你哪些题错了。
  • Slot B(允许 AI 辅助和充分合作):收到反馈三天后截止,只重新批改 Slot A 里做错的题。

每道题的得分取两次中的较高者,Slot A 总分超过 50% 还有额外加分:

s=0.95×∑q∈HWmax⁡(sA,q, sB,q)+0.05×1(sA>0.50)s = 0.95 \times \sum_{q \in \text{HW}} \max(s_{A,q},\, s_{B,q}) + 0.05 \times \mathbb{1}(s_A > 0.50)

这个设计的激励很清楚:(1) 先自己独立做一遍,真正学到东西;(2) 通过反馈纠正误解;(3) 再在一个独立的阶段里练习高效地使用 AI。

对自学者的建议。 读这份讲义时,你也可以给自己设一个”Slot A”:先自己推导练习题、自己预测代码的输出,再看参考答案和运行结果;先自己读懂代码、改一个参数预测会发生什么,再去问 AI。讲义里的代码注释很详细,正是为了让你能读懂每一行,而不是只运行它。


7. 课程地图 · The course map

7.1 讲次安排

单元讲次主题
文本生成模型1–4RNN-LM 与自动微分 → Transformer → 训练与解码 → 预训练、微调与现代 Transformer(RoPE、GQA)
图像生成模型5–9CNN / BERT / ViT → GAN + 概率图模型 → 扩散模型(上、下)→ VAE
应用与适配10–12PEFT(LoRA)→ ICL、提示、指令微调、RLHF → DPO、文生图、潜空间扩散
多模态13–15视觉-语言模型 → 交叉注意力、DiT、Prompt-to-Prompt → Q-Former、规模定律
扩大规模16–18MoE → 分布式训练 → FlashAttention、高效解码
进阶主题19–26长上下文 → 推理模型 → SSM → 会出什么错 → 代码与智能体 → 音频 → 视频 → 世界模型 + 对齐科学

详细大纲见 SYLLABUS.md。为没有系统背景的读者规划了 4 篇前置讲:P1 数字、张量与内存;P2 生成模型的概率工具箱;P3 GPU 与性能模型;P4 多卡与通信。

7.2 本讲的概念在后面哪里展开

本讲的概念后面在哪里
链式法则、自回归所有语言模型(第 2–4 讲);自回归图像生成(第 12 讲);音频 token 的语言模型(第 24 讲)
最大似然、交叉熵、困惑度第 3 讲(训练 LLM);第 7–9 讲(似然算不动时怎么办)
平滑、插值第 3 讲(解码时的分布截断);第 14 讲(无分类器引导也是一种”混合两个分布”)
词向量第 2 讲(Transformer 的输入);第 13 讲(图像 token 和文本 token 放进同一个空间)
长程依赖、梯度消失第 2 讲(LSTM、注意力);第 19 讲(长上下文);第 21 讲(SSM 重新拥抱递推)
温度采样第 3 讲(解码策略);第 20 讲(采样多条推理链)
自动微分、nn.Module、训练循环每一讲的代码
内存核算前置讲 P1;第 10 讲(PEFT 为什么省显存);第 17 讲(分布式训练)
参数学习 vs 上下文学习第 9、11 讲

8. 小结 · Summary

  1. 生成式 AI 就是概率建模:用模型表示一个高维分布 p(x)p(x) 或 p(x∣c)p(x \mid c),从数据里学它,从中采样。每个生成模型都要回答”怎样表示、怎样学习、怎样采样”。
  2. 链式法则是恒等式,把序列的分布拆成一串”下一个元素”的条件分布;n-gram 的马尔可夫假设是模型。在我们的语料上,trigram 把高频短句低估 2–3.5 倍、把一个长句低估 344 倍。
  3. n-gram 的学习就是数数(= 最大似然);采样是逐词掷加权骰子;评估用困惑度(有效分支数)。它有三个根本问题:零概率(需要平滑;trigram 在约 23,000 个测试 trigram 里只有 1 个没见过,困惑度就是无穷大)、表随 nn 指数膨胀、看不到窗口之外。
  4. 主谓一致实验把第三个问题看得最清楚:trigram 跟着最近的名词走(干扰名词与主语相反时准确率约 0.05);5-gram 在主语进入窗口时也只有 0.84,因为它分不清窗口里的名词是不是主语。
  5. RNN-LM 把全部历史压缩成一个定长向量,参数在时间步之间共享、数量和上下文长度无关。11,640 个参数的 RNN 把测试困惑度从 6-gram 的 2.856 降到 2.540(下界 2.491),优势全部集中在依赖远处主语的位置;主谓一致在 k=0..6k = 0..6 全对——但这依赖于我们语言的规整性和训练数据里有足够的嵌套例子。
  6. 梯度穿越时间是连乘雅可比矩阵:随机初始化时传回 18 步只剩 7.5e-07(消失);谱半径大于 1 的线性 RNN 会爆炸。梯度裁剪治爆炸,LSTM 治消失(第 2 讲)。
  7. 自动微分 = 计算图 + 局部导数 + 链式法则。神经网络是”输入极多、输出一个”的函数,所以用反向模式(反向传播):n=1000n = 1000 时前向模式的运算量是反向模式的 375 倍。
  8. 从过程式到模块化再到 tape:模块只需实现 forward / backward,可以逐个做有限差分检查(一下子定位植入的 bug);tape 让控制流自动决定计算图(define-by-run),BPTT 就是在展开图上做反向模式(和 PyTorch 差 6.9e-15)。
  9. PyTorch 的 autograd 就是工业级的 tape:永远写 model(x)(会执行钩子),参数用 nn.Parameter 注册,梯度会累加所以每步要 zero_grad();五步训练循环是之后所有代码的骨架。
  10. 规模首先是内存问题:GPT-3 的 FP16 权重 326 GiB,一块 80 GB 的 GPU 放不下——这引出了半精度、模型并行和分布式训练。

9. 常见误区 · Common pitfalls

  1. “链式法则是一种近似。” 不是。链式法则对任何分布都精确成立;近似来自对条件分布的建模假设(只看 n−1n-1 个词、压缩成定长向量)。
  2. “困惑度越低越好,所以在训练集上算困惑度就行。” 训练集困惑度奖励死记硬背;必须在没见过的数据上算,而且只有词表(分词方式)相同的模型才能直接比较。
  3. “n 越大的 n-gram 越好。” 不平滑时 n≥3n \ge 3 就是无穷大;加 1 平滑时 6-gram 比 trigram 还差。高阶模型需要好的平滑和插值,而且仍然有窗口上限。
  4. “n-gram 在长程依赖上失败只是因为窗口不够长。” 窗口够长时它也只有 0.84:它还分不清窗口里的名词扮演什么角色。长程依赖需要的是能表示结构的记忆,不只是更长的窗口。
  5. “RNN 能记住任意长的历史。” 原则上可以,实践中梯度会随距离指数衰减,记忆是脆弱的、需要学出来的;它在我们语料上的完美表现依赖于语言的规整性和训练数据(练习 7)。
  6. “BPTT 是专门为 RNN 发明的算法。” BPTT 就是在按时间展开的计算图上做普通的反向模式自动微分(03 用 PyTorch 逐位验证了这一点)。
  7. “反向传播比有限差分更’近似’。” 正好相反:自动微分精确到浮点误差;有限差分有截断误差和舍入误差,只适合用来检查梯度。
  8. “model.forward(x) 和 model(x) 完全一样。” 前者跳过钩子。很多工具依赖钩子,所以永远写 model(x)。
  9. “loss.backward() 会覆盖上一次的梯度。” 它是累加的;忘了 zero_grad() 会让梯度越来越大。
  10. “照抄训练句的比例高,说明模型在死记硬背。” 要和真实分布本身的重复率对照:在我们的语料上,真实分布有 55% 的样本在训练集里出现过,比 5-gram 还高。

10. 练习 · Exercises

练习 1(手算 n-gram)。 训练语料只有三句话(每句前补一个 <s>,后补一个 </s>):

the cow eats corn
the cow eats hay
the bat eats flies

(a) 写出 bigram 的最大似然估计 p^(cow∣the)\hat{p}(\text{cow} \mid \text{the})、p^(corn∣eats)\hat{p}(\text{corn} \mid \text{eats})、p^(</s>∣corn)\hat{p}(\text{</s>} \mid \text{corn})。 (b) 算句子 “the bat eats corn” 的 bigram 概率。它在训练集里出现过吗? (c) 算句子 “the cow eats fish” 的概率。用加 1 平滑(预测词表为 the、cow、bat、eats、corn、hay、flies、</s>,共 8 个)重新算 p^(fish∣eats)\hat{p}(\text{fish} \mid \text{eats}) 会遇到什么问题?

参考答案

(a) “the” 出现 3 次,后面 2 次是 cow:p^(cow∣the)=2/3\hat{p}(\text{cow} \mid \text{the}) = 2/3。“eats” 出现 3 次,后面 1 次是 corn:1/31/3。“corn” 后面总是 </s>:11。

(b) p=p^(the∣<s>)⋅p^(bat∣the)⋅p^(eats∣bat)⋅p^(corn∣eats)⋅p^(</s>∣corn)=1⋅13⋅1⋅13⋅1=1/9p = \hat{p}(\text{the} \mid \text{<s>}) \cdot \hat{p}(\text{bat} \mid \text{the}) \cdot \hat{p}(\text{eats} \mid \text{bat}) \cdot \hat{p}(\text{corn} \mid \text{eats}) \cdot \hat{p}(\text{</s>} \mid \text{corn}) = 1 \cdot \tfrac{1}{3} \cdot 1 \cdot \tfrac{1}{3} \cdot 1 = 1/9。它没在训练集里出现过,但 bigram 给了它不小的概率——这就是 n-gram 的”泛化”:把见过的片段重新拼接。(顺便:这句话”蝙蝠吃玉米”在语义上不太对,bigram 看不出来,因为”吃什么”取决于两个词之前的主语。)

(c) fish 根本不在词表里,最大似然下 p^(fish∣eats)=0\hat{p}(\text{fish} \mid \text{eats}) = 0,整句概率为 0。加 1 平滑只能把概率分给预测词表里的词:如果 fish 不在词表里,平滑也无能为力——这是”未登录词”(out-of-vocabulary)问题。实践中的做法是引入 <unk> 记号,或者用子词分词让任何字符串都能被表示(第 3 讲的 BPE)。把 fish 加进词表(9 个词)后,p^(fish∣eats)=(0+1)/(3+9)=1/12\hat{p}(\text{fish} \mid \text{eats}) = (0+1)/(3+9) = 1/12。

练习 2(困惑度的直觉)。 (a) 证明:如果模型对每个位置都给出 KK 个词上的均匀分布,并且真实的下一个词总在这 KK 个词里,那么困惑度是 KK。(b) 05 里 unigram 的困惑度是 25.42,而预测词表有 54 个词(56 个减去 <pad> 和 <s>)。为什么 unigram 的困惑度小于 54?(c) 困惑度从 2.856(6-gram)降到 2.540(RNN),换算成”每个词少付出的交叉熵”是多少 bit?

参考答案

(a) 每个词的概率都是 1/K1/K,平均负对数似然 =log⁡K= \log K,困惑度 =elog⁡K=K= e^{\log K} = K。

(b) 均匀分布的困惑度才是 54。unigram 按词频分配概率:the 出现得极多(每句至少 1–2 次),句号和 </s> 每句各一次,而很多词(某些宾语)很少。概率分配越不均匀,困惑度越低。困惑度 = 有效分支数,unigram 平均”只在 25 个词之间犹豫”。

(c) log⁡22.856−log⁡22.540=1.514−1.345=0.169\log_2 2.856 - \log_2 2.540 = 1.514 - 1.345 = 0.169 bit/词。(用 nat 是 ln⁡2.856−ln⁡2.540=0.117\ln 2.856 - \ln 2.540 = 0.117。)每个词少 0.17 bit 看起来很小,但测试句平均要做 11.6 次预测(10.6 个词加一个 </s>),一句就是约 1.96 bit,相当于把整句的概率平均提高了约 21.96≈3.92^{1.96} \approx 3.9 倍。

练习 3(参数量)。 (a) 验证我们的 RNN-LM 有 11,640 个参数(∣V∣=56\lvert\mathcal{V}\rvert = 56、E=32E = 32、H=64H = 64;nn.Linear 默认带偏置,W_hh 不带偏置)。(b) 如果词表有 50,000 个词、E=H=1024E = H = 1024,参数量是多少?哪一部分占大头?(c) 同样的词表下,一个”完整”的 trigram 表最多有多少个条目?

参考答案

(a) 嵌入 56×32=179256 \times 32 = 1792;WxhW_{xh} 与 bhb_h:64×32+64=211264 \times 32 + 64 = 2112;WhhW_{hh}:64×64=409664 \times 64 = 4096;循环层合计 6208;输出层 64×56+56=364064 \times 56 + 56 = 3640。总计 1792+6208+3640=116401792 + 6208 + 3640 = 11640。

(b) 嵌入 50000×1024≈51.250000 \times 1024 \approx 51.2M;循环层 1024×1024+1024+1024×1024≈2.11024 \times 1024 + 1024 + 1024 \times 1024 \approx 2.1M;输出层 1024×50000+50000≈51.251024 \times 50000 + 50000 \approx 51.25M。合计约 104.6M,嵌入和输出层占了 98%。所以大词表模型常把输出层和嵌入层绑定(共享同一个矩阵,weight tying),能省掉近一半参数。

(c) 500003=1.25×101450000^3 = 1.25 \times 10^{14} 个条目(每种两个词的上下文 × 每个下一个词)。实际中绝大多数为 0,只存非零的,但即使只存见过的,万亿词级别的语料也会产生数以十亿计的条目。

练习 4(softmax + 交叉熵的梯度)。 设 y^=softmax(b)\hat{y} = \mathrm{softmax}(b),J=−∑kyklog⁡y^kJ = -\sum_k y_k \log \hat{y}_k,yy 是 one-hot。(a) 证明 softmax 的雅可比矩阵是 ∂y^i/∂bj=y^i(δij−y^j)\partial \hat{y}_i / \partial b_j = \hat{y}_i(\delta_{ij} - \hat{y}_j)。(b) 证明 gb=y^−yg_b = \hat{y} - y。(c) 为什么实际代码里要把这两步合并?

参考答案

(a) y^i=ebi/Z\hat{y}_i = e^{b_i} / Z,Z=∑kebkZ = \sum_k e^{b_k}。i=ji = j 时 ∂y^i/∂bi=(ebiZ−ebiebi)/Z2=y^i−y^i2\partial \hat{y}_i / \partial b_i = (e^{b_i} Z - e^{b_i} e^{b_i}) / Z^2 = \hat{y}_i - \hat{y}_i^2;i≠ji \ne j 时 ∂y^i/∂bj=−ebiebj/Z2=−y^iy^j\partial \hat{y}_i / \partial b_j = -e^{b_i} e^{b_j} / Z^2 = -\hat{y}_i \hat{y}_j。合起来就是 y^i(δij−y^j)\hat{y}_i(\delta_{ij} - \hat{y}_j),矩阵形式 diag(y^)−y^y^⊤\mathrm{diag}(\hat{y}) - \hat{y}\hat{y}^\top。

(b) gbj=∑i∂J∂y^i∂y^i∂bj=∑i(−yiy^i)y^i(δij−y^j)=−yj+y^j∑iyi=y^j−yjg_{b_j} = \sum_i \frac{\partial J}{\partial \hat{y}_i} \frac{\partial \hat{y}_i}{\partial b_j} = \sum_i \big(-\frac{y_i}{\hat{y}_i}\big) \hat{y}_i (\delta_{ij} - \hat{y}_j) = -y_j + \hat{y}_j \sum_i y_i = \hat{y}_j - y_j(用了 ∑iyi=1\sum_i y_i = 1)。更简单的推法:J=−bc+log⁡ZJ = -b_c + \log Z(cc 是正确类别),直接对 bjb_j 求导得 −δjc+y^j-\delta_{jc} + \hat{y}_j。

(c) 分开算时要先算 −y÷y^-y \div \hat{y}:正确类别的概率 y^c\hat{y}_c 很小时会得到很大的数,再乘上同样很小的雅可比元素,数值误差大,y^c\hat{y}_c 下溢成 0 时直接除以 0。合并后的 y^−y\hat{y} - y 没有除法,并且 log⁡Z\log Z 可以用 log-sum-exp 技巧稳定地算(SoftmaxCrossEntropy)。

练习 5(有限差分的步长)。 对 f(x)=sin⁡xf(x) = \sin x 在 x=1x = 1 处,用单侧差分 [f(x+ε)−f(x)]/ε[f(x+\varepsilon) - f(x)]/\varepsilon 和中心差分 [f(x+ε)−f(x−ε)]/2ε[f(x+\varepsilon) - f(x-\varepsilon)]/2\varepsilon 估计导数,ε\varepsilon 取 10−1,10−3,…,10−1310^{-1}, 10^{-3}, \dots, 10^{-13},和真值 cos⁡1\cos 1 比较。分别在 float64 和 float32 下做。你会看到什么规律?

参考答案

误差由两部分组成:截断误差(泰勒展开被丢掉的项)随 ε\varepsilon 减小而减小——单侧差分是 O(ε)O(\varepsilon),中心差分是 O(ε2)O(\varepsilon^2);舍入误差约为 ϵmachine/ε\epsilon_{\text{machine}} / \varepsilon,随 ε\varepsilon 减小而增大。两者之和在某个中间的 ε\varepsilon 最小。本机实测(和 cos⁡1\cos 1 的绝对误差):

εfloat64 单侧float64 中心float32 单侧float32 中心
1e-14.3e-029.0e-044.3e-029.0e-04
1e-34.2e-049.0e-084.0e-041.4e-05
1e-54.2e-061.1e-113.9e-038.8e-04
1e-74.2e-081.9e-105.6e-025.6e-02
1e-95.3e-083.0e-095.4e-015.4e-01
1e-137.3e-041.8e-045.4e-015.4e-01

float64(ϵmachine≈2×10−16\epsilon_{\text{machine}} \approx 2 \times 10^{-16})下,单侧差分最好约 4e-8,中心差分在 ε=10−5\varepsilon = 10^{-5} 附近最好,约 1e-11。float32(ϵmachine≈10−7\epsilon_{\text{machine}} \approx 10^{-7})下,最好也只有 1e-5 左右;ε≤10−9\varepsilon \le 10^{-9} 时 1+ε1 + \varepsilon 在 float32 里就等于 1,差分恒为 0,误差就是导数本身(0.54)。这就是 numerical_gradient 默认用 float64 + 中心差分 + ε=10−6\varepsilon = 10^{-6} 的原因,也是梯度检查的相对误差在 1e-10 左右而不是 0 的原因。验证代码:

import numpy as np
for dt in (np.float64, np.float32):
    for p in range(1, 14, 2):
        e, x = dt(10.0 ** -p), dt(1.0)
        fwd = (np.sin(x + e) - np.sin(x)) / e
        cen = (np.sin(x + e) - np.sin(x - e)) / (dt(2) * e)
        print(dt.__name__, p, abs(float(fwd) - np.cos(1.0)), abs(float(cen) - np.cos(1.0)))

练习 6(前向模式 vs 反向模式)。 (a) 函数 f:Rn→Rmf: \mathbb{R}^n \to \mathbb{R}^m 的雅可比矩阵是 m×nm \times n。用前向模式和反向模式分别要跑多少遍才能得到完整的雅可比矩阵?(b) 举一个前向模式更合适的例子。(c) 反向模式在内存上有什么代价?

参考答案

(a) 前向模式每遍得到一列(对一个输入的导数),要 nn 遍;反向模式每遍得到一行(一个输出对所有输入的导数),要 mm 遍。

(b) 输入少、输出多的函数,例如一个物理仿真器 f:R3→R106f: \mathbb{R}^3 \to \mathbb{R}^{10^6}(3 个参数控制 100 万个网格点的结果),想知道每个网格点对这 3 个参数的敏感度:前向模式 3 遍,反向模式要 100 万遍。另一个例子是”雅可比-向量积”(JVP):只需要 JvJ v 而不是整个 JJ 时,前向模式一遍就够。

(c) 反向模式必须把前向时的中间结果(激活值)全部存下来,等反向时使用(slides 的 NNForward 返回”intermediate quantities o”就是这个意思)。前向模式边算边丢,不需要存。这就是”训练比推理费显存得多”的原因之一;对超长序列或超深网络,人们用激活检查点(只存一部分,反向时重算其余的)来折中(第 17 讲)。

练习 7(RNN 的外推依赖什么?)。 修改训练数据的干扰名词个数分布,重新训练 RNN-LM(10 个 epoch),在”干扰名词全部和主语相反”的句子上测一致性。三种训练分布:(a) 只有 k=0k = 0;(b) k∈{0,1}k \in \{0, 1\} 各一半;(c) k∈{0,1,2}k \in \{0, 1, 2\} 按 0.5 / 0.3 / 0.2。先预测结果,再运行。提示:

from toy_corpus import make_splits, make_agreement_set, Vocab
from rnn_lm import train_rnn_lm, agreement_scores
base = make_splits(seed=0); vocab = Vocab.from_examples(base["train"])
splits = make_splits(seed=0, k_weights=(0.5, 0.5))          # 换成 (1.0,) 或 (0.5, 0.3, 0.2)
model, _ = train_rnn_lm(splits, vocab, {"epochs": 10})
for k in range(7):
    ex = make_agreement_set(seed=7, k=k, n=400, incongruent=True)
    print(k, sum(agreement_scores(model, vocab, ex)) / 400)
参考答案

本机实际运行的结果(k = 0..6):

训练分布k=0k=1k=2k=3k=4k=5k=6
(a) 只有 k=01.0000.0620.0350.0180.0550.0250.035
(b) k ≤ 11.0001.0000.8300.6130.5220.4270.372
(c) k ≤ 21.0001.0001.0001.0001.0001.0001.000

(a) 训练中从没出现过介词短语(near、behind、beside 的词向量还是随机初始化的值),RNN 的行为和 trigram 一模一样:跟着最近的名词走。在它学过的所有句子里,“紧挨在动词前面的名词就是主语”永远成立,它没有理由学出别的规则。

(b) 见过一层介词短语,就学会了 k=1k = 1,但没有学会”可以有任意多层”:kk 越大越差,k=6k = 6 时低于抛硬币。

(c) 只要训练数据里出现过”介词短语接介词短语”,RNN 就外推到了 k=6k = 6。

教训:神经网络的”泛化”强烈依赖训练数据覆盖了哪些模式。在 (c) 里它看起来”学会了规则”,但 (b) 说明这种规则是从数据里归纳出来的,不是先天就有的。这也是为什么第 2 讲要换更难的任务来比较不同架构。

练习 8(双向 RNN 能当语言模型吗?)。 双向 RNN 在每个位置同时看到过去和未来,听起来比单向 RNN 更强。(a) 用它来做”下一个词预测”的训练会发生什么?(b) 双向结构在什么任务上合适?

参考答案

(a) 如果在位置 tt 用双向的隐藏状态预测 wt+1w_{t+1},反向那个 RNN 已经读过 wt+1w_{t+1} 了——模型可以直接”偷看答案”,训练损失会迅速降到接近 0,但它学到的不是语言模型。更根本的是,生成时未来的词还不存在,反向 RNN 无从计算。所以生成式语言模型必须是因果的(causal):位置 tt 只能依赖 w≤tw_{\le t}。第 2 讲 Transformer 的”因果掩码”就是为了保证这一点。

(b) 输入整个给定、只需要理解而不需要生成的任务:序列标注(每个词的词性、命名实体)、整段语音的转写、句子分类。第 5 讲的 BERT 是 Transformer 版的”双向”:它用”掩码语言模型”(遮住一些词让模型填)代替”下一个词预测”,从而合法地同时使用左右上下文。

练习 9(共享参数的梯度)。 (a) 一个 RNN 在长度为 TT 的序列上展开,WhhW_{hh} 被用了几次?写出 ∂J/∂Whh\partial J / \partial W_{hh} 用各时间步表示的公式。(b) 如果 tape 引擎里 Op.apply_bwd 把 node.out_gradient = node.out_gradient + g 写成了 node.out_gradient = g(覆盖而不是累加),03 第 3 部分里哪些参数的梯度会出错?嵌入矩阵 EE 的梯度会变成什么样?先预测,再把 autodiff.Op.apply_bwd 换成覆盖版本跑一遍验证。

参考答案

(a) TT 次。∂J∂Whh=∑t=1Tgpre,t ht−1⊤\frac{\partial J}{\partial W_{hh}} = \sum_{t=1}^{T} g_{\text{pre},t}\, h_{t-1}^\top,其中 gpre,t=ght⊙(1−ht2)g_{\text{pre},t} = g_{h_t} \odot (1 - h_t^2) 是损失对第 tt 步 tanh 输入的梯度,而 ghtg_{h_t} 本身又要累加两个来源:第 tt 步输出层的损失,以及第 t+1t+1 步通过 WhhW_{hh} 传回来的梯度。

(b) 全部 6 个参数都会错(本机实测,和正确梯度的相对误差在 0.56 到 1.0 之间),没有一个”碰巧”正确:每个参数在每个时间步都被用到,覆盖之后只剩逆拓扑序里最后被处理的那一次(第 1 个时间步)的贡献;而且每个隐藏状态 hth_t 有两个下游(第 tt 步的输出层、第 t+1t+1 步的循环),它的梯度也只剩一个来源,错误会一路传下去。最戏剧性的是 EE:查表模块每次返回一个整张表大小、只有一行非零的梯度矩阵,覆盖之后只剩第 1 个时间步那次查表(<s> 那一行,而且数值也是错的),其余 11 个词的行全部变成 0——包括 the(出现 4 次)和只出现一次的 fox。这说明了 slides Version B 里 +=+= 的重要性:只要有共享(参数共享、一个节点被多处使用),覆盖就是错的。

练习 10(内存核算,为前置讲 P1 和第 17 讲热身)。 用 Adam 优化器、混合精度训练时,每个参数大约要存:BF16 的权重(2 字节)、BF16 的梯度(2 字节)、FP32 的主权重(4 字节)、FP32 的一阶矩和二阶矩(各 4 字节)。(a) 每个参数一共多少字节?(b) 训练一个 70 亿参数的模型(不算激活值),至少要多少 GiB?(c) 一块 80 GB 的 GPU 放得下吗?

参考答案

(a) 2+2+4+4+4=162 + 2 + 4 + 4 + 4 = 16 字节/参数——这就是 04 第 6 部分”约 16 字节/参数”的来源。

(b) 7×109×16/230≈1047 \times 10^9 \times 16 / 2^{30} \approx 104 GiB。

(c) 放不下,而且还没算激活值(序列越长、批越大,激活值越多)。所以即使是”只有” 70 亿参数的模型,全量训练也需要把优化器状态切分到多块 GPU 上(ZeRO,第 17 讲),或者只训练一小部分参数(LoRA,第 10 讲)。


11. 延伸阅读 · Further reading

本讲阅读材料

  • Goodfellow, Bengio & Courville, Deep Learning(2016),第 10.1–10.5 节:展开计算图、RNN 与 BPTT、双向 RNN、编码器-解码器、深层 RNN。免费在线:deeplearningbook.org。
  • Bottou & Gallinari (1991), A Framework for the Cooperation of Learning Algorithms, NIPS:模块化学习系统的早期框架。
  • Paszke et al. (2019), PyTorch: An Imperative Style, High-Performance Deep Learning Library, NeurIPS:PyTorch 的设计原则与实现。

本讲提到的其他工作

  • Shannon (1948), A Mathematical Theory of Communication:用 n-gram 近似生成英文的最早例子之一。
  • Chen & Goodman (1998), An Empirical Study of Smoothing Techniques for Language Modeling:平滑方法的系统比较(含 Kneser–Ney)。
  • Bengio et al. (2003), A Neural Probabilistic Language Model, JMLR:词向量 + 神经网络语言模型。
  • Elman (1990), Finding Structure in Time:简单循环网络。
  • Mikolov et al. (2010), Recurrent Neural Network Based Language Model, Interspeech(第 3 讲的阅读材料)。
  • Graves, Mohamed & Hinton (2013), Speech Recognition with Deep Recurrent Neural Networks:slides 里 RNN / LSTM / 双向 / 深层 RNN 公式的出处。
  • Karpathy (2015), The Unreasonable Effectiveness of Recurrent Neural Networks(博客):字符级 RNN 的莎士比亚样本。
  • Linzen, Dupoux & Goldberg (2016), Assessing the Ability of LSTMs to Learn Syntax-Sensitive Dependencies, TACL:主谓一致与干扰名词。
  • Bengio, Simard & Frasconi (1994), Learning Long-Term Dependencies with Gradient Descent is Difficult;Pascanu, Mikolov & Bengio (2013), On the Difficulty of Training Recurrent Neural Networks:梯度消失与爆炸、梯度裁剪。
  • Baydin et al. (2018), Automatic Differentiation in Machine Learning: a Survey, JMLR:自动微分的全面综述。

研究生版(10-623/723)可以多读的

  • Ouyang et al. (2022), Training Language Models to Follow Instructions with Human Feedback(InstructGPT):1.3B 胜过 175B 的那篇。
  • Hoffmann et al. (2022), Training Compute-Optimal Large Language Models(Chinchilla)。
  • Gao et al. (2020), The Pile: An 800GB Dataset of Diverse Text for Language Modeling。

12. 下一讲预告 · Next lecture

第 2 讲:Transformer 语言模型。 本讲的 RNN 用一个定长向量总结全部历史,信息必须一步一步地”传递”下去,梯度也必须一步一步地传回来。第 2 讲先看 RNN 为什么会遗忘、LSTM 怎样用门控的记忆单元缓解它;然后提出一个激进的替代方案:不要压缩历史,让每个位置直接”注意”它需要的任何一个过去的位置。这就是注意力机制和 Transformer。我们会构造一个更难、距离完全可控的长程依赖任务(“记住开头”),比较 RNN、LSTM 和 Transformer;再回到本讲的农场语言,把干扰名词加到 8 个,看看动词位置的注意力是不是真的落在了主语上。

← 返回课程目录