对应 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
学完这一讲,你应该能够:
- 说清楚生成式 AI 和机器学习、深度学习的关系,举出它在感知、推理、规划、交流、创造、学习等方面的例子;理解”学习”除了调参数,还可以通过推断完成(上下文学习)。
- 解释为什么说生成式 AI 就是概率建模:生成模型要回答”怎样表示一个分布、怎样学习它、怎样从中采样”三个问题。
- 写出序列的链式法则,并区分”恒等式”和”模型”:n-gram 的马尔可夫假设是一个近似。会用计数做最大似然估计、会算困惑度、知道为什么要平滑,能用实验说明 n-gram 为什么注定处理不了长程依赖。
- 写出 RNN 语言模型的公式,说明它怎样用一个定长向量总结任意长的历史;会训练它、从中采样,并解释梯度穿越时间时为什么会消失或爆炸。
- 理解自动微分:计算图、反向模式的两种写法、为什么深度学习用反向模式;能从”过程式”写法过渡到”基于模块”的写法,再到”面向对象 + tape”的写法,并知道 PyTorch 的
loss.backward()背后做的就是这件事。 - 会用有限差分检查梯度,并知道为什么逐模块检查比整网检查更容易定位 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(全课统一)
| 记号 | 意思 |
|---|---|
| , | 词表(vocabulary)及其大小 |
| , | 第 个词;长度为 的词序列 |
| 之前的所有词 ,叫”历史”或”上下文” | |
| , | 概率分布;下标 表示由参数 决定的模型分布 |
| 真实的数据分布(我们看不见,只能看到从中抽出的样本) | |
| RNN 在第 步的隐藏状态(hidden state) | |
| logits | softmax 之前的未归一化分数 |
| 把一组实数变成概率分布 | |
| 标量损失(slides 的记号) | |
| 损失对变量 的梯度,形状与 相同(slides 的记号) | |
| , | 逐元素乘、逐元素除 |
| nat | 以 为底的对数单位(以 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 种动物之一,单数或复数各占一半。
- 中间插入 个介词短语(near / behind / beside the …),里面的名词叫干扰名词(attractor),单复数随机、和主语无关。训练集里 的比例是 45%、30%、15%、10%。
- 动词必须和主语一致(the bat … makes;the bats … make),而不是和离它最近的名词一致。这是英语的主谓一致(subject-verb agreement)。
- 每种动物有自己的习性:蝙蝠多在夜里出现、多吃飞虫;奶牛多吃玉米、草和干草……所以动词、宾语和时间短语都依赖于很远之前的主语。
主语到动词的距离是 个词。生成每句话时,代码把每一次随机选择的概率乘起来,得到这句话在真实分布下的精确概率,于是能算出真实困惑度(测试集上是 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 讲 |
| 推理 Reasoning | LLM 出乎意料地擅长某些推理任务,例如思维链(chain-of-thought)提示——它本身就是上下文学习的一个例子 | 第 11、20 讲 |
| 规划 Planning | LLM 已被用于具身智能体的规划(如 LLM-Planner);规划也是智能编程助手的关键一步 | 第 23 讲 |
| 创造 Creativity | 文生图(slides 提到 Midjourney 的 Discord 服务器有 1800 万成员);文生音乐(MusicGen 可以同时以文字和一段音频为条件) | 第 12–14、24 讲 |
| 感知 Perception | 多模态基础模型能回答关于图像(以及图中文字)的问题;扩散模型甚至可以当作零样本分类器用 | 第 13 讲 |
| 控制 Control | DayDreamer 为强化学习学一个”世界模型”(经验的生成模型),四足机器人不靠仿真、在真实世界里一小时内学会走路 | 第 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 用五个角度说明”规模”意味着什么,每个角度都对应本课后面的一个单元:
- 训练数据。 The Pile 是一个开源的语言模型训练集,由 22 个较小的数据集组成,偏重高质量文本,总共 825 GiB。(slides 换算成约 1.2 万亿 token;更常见的估计是用 GPT-NeoX 分词器约 3000 亿 token,例如 Pythia 在 Pile 上训练 3000 亿 token 大约是 1 个 epoch。token 数取决于分词器,这类换算只能看数量级。)数据怎样影响规模定律见第 15 讲。
- 对齐。 InstructGPT 用基于人类反馈的强化学习(RLHF)微调预训练的 GPT。论文报告:在人类评估中,13 亿参数的 InstructGPT 的输出比 1750 亿参数的 GPT-3 更受偏好,尽管参数少了 100 倍。规模不是唯一的杠杆,怎样训练同样重要(第 11、12 讲)。
- 内存。 一个参数用 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 部分复现了这张表。 - 分布式训练。 模型放不进一块 GPU,就得切开:按层切(每块 GPU 负责几层)、按矩阵的行 / 列切(Transformer 的大部分计算是矩阵乘法),或者两者结合(第 17 讲)。
- 训练成本。 slides 引用了 Chinchilla 论文(Hoffmann et al., 2022)的图:给定算力预算,模型应该多大、数据应该多少,有一个可以预测的最优配比(大约每个参数 20 个 token;第 15 讲)。
1.4 两条时间线
slides 画了语言模型和图像生成的两条时间线,把它们并排看很有启发:
| 年份 | 语言模型 | 图像生成 / 视觉 |
|---|---|---|
| 1998–2012 | n-gram 语言模型(2000 年前后的主流);RNN 语言模型(2010) | LeNet(1998);ImageNet(2009);AlexNet(2012) |
| 2013–2016 | VAE(2013);GAN(2014);扩散模型的雏形、ResNet(2015) | |
| 2017–2019 | Transformer(2017);ELMo、BERT、GPT(2018);GPT-2、RoBERTa(2019) | Transformer 进入视觉 |
| 2020–2021 | GPT-3(2020) | DDPM(2020);ViT、CLIP、DALL·E(2021) |
| 2022 | InstructGPT、PaLM、BLOOM、ChatGPT | DALL·E 2、Imagen、Midjourney、Stable Diffusion |
| 2023–2024 | Llama、GPT-4、Falcon、Mistral、Gemini、Claude;Mamba;Llama 3、GPT-4o | SDXL、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”,下面只有一个公式:
这个公式是整门课的起点。先区分两类模型:
- 判别模型(discriminative model)学 :给一张图 ,它是猫还是狗 ?它只关心”在给定输入时,输出是什么”,不关心输入本身长什么样。
- 生成模型(generative model)学 或条件分布 :所有可能的图像 里,哪些更可能出现?给定一句描述 ,什么样的图像符合它?
区别在于被建模的对象:判别模型建模一个低维的标签,生成模型建模高维的数据本身(一张 512×512 的彩色图有约 79 万个数,一段 1000 词的文章有 1000 个离散变量)。高维数据的分布极其复杂——变量之间有大量依赖关系——所以生成建模要难得多。
2.2 为什么要”概率”
为什么不直接训练一个”输入描述、输出图像”的确定性函数?因为生成的目标本来就不是唯一的:同一句”一只在月光下的猫”可以对应无数张合理的图。一个确定性函数只能给出一个答案(通常是所有合理答案的某种”平均”,于是一片模糊——第 9 讲讲 VAE 时会看到这种模糊)。概率模型则给出一个分布,它能做三件事:
- 采样(sampling):从分布里抽一个样本——生成一张图、写一段话。每次抽到的都不同,但都”像真的”。
- 打分(scoring / likelihood):给一个样本算概率——这句话通顺吗?这段代码像人写的吗?语音识别和机器翻译里常用语言模型给候选句子打分。
- 条件化(conditioning):固定一部分变量,对其余变量建模——给定前半句写后半句,给定描述生成图像,给定缺失区域周围的像素补全图像。
2.3 三个问题:表示、学习、采样
任何一个生成模型都要回答三个问题。这门课里出现的模型,就是对这三个问题的不同回答:
| 模型 | 怎样表示分布 | 怎样学习 | 怎样采样 | 本课 |
|---|---|---|---|---|
| n-gram | 链式法则 + 只看前 个词;一张计数表 | 数数(= 最大似然) | 逐词掷骰子 | 第 1 讲 |
| RNN / Transformer LM | 链式法则 + 神经网络算每个条件分布 | 最大似然(交叉熵 + 梯度下降) | 逐词掷骰子 | 第 1–4 讲 |
| GAN | 一个把随机噪声变成样本的生成器(不写出 ) | 和判别器的对抗博弈 | 一次前向 | 第 6 讲 |
| VAE | 隐变量 + 解码器 | 最大化证据下界(ELBO) | 先抽 再解码 | 第 9 讲 |
| 扩散模型 | 一串逐步去噪的条件分布 | 最大化 ELBO(简化为预测噪声) | 从纯噪声出发去噪几十到上千步 | 第 7、8 讲 |
一个贯穿全课的线索:能不能算出似然 。n-gram 和自回归语言模型可以精确算出任意句子的概率,所以能直接做最大似然;GAN、VAE、扩散模型的 要么写不出来、要么算不动,于是要换一个训练目标。slides 后面几讲反复出现的标题”MLE? Nope!”说的就是这件事。
2.4 序列的链式法则
怎样给一个长度为 的序列定义概率分布?slides 用例句”The bat made noise at night”说明。概率论里的链式法则(chain rule)说:
slides 特别强调:这叫”法则”,因为它对任何概率分布都成立。它不是假设、不是近似,只是把条件概率的定义 反复使用。
代码验证(01 第 1 部分):把训练集当成一个”经验分布”——一句话的概率 = 它出现的次数 / 句子总数。对这个经验分布,用”完整历史”的条件概率连乘(每个条件概率 = 以某个前缀开头的句子数 / 以更短前缀开头的句子数),结果和直接数整句逐位相同:
| 句子 | 直接数整句 | 链式法则(完整历史) | trigram 近似 | 低估倍数 |
|---|---|---|---|---|
| the bat makes noise at night . | 7.400e-03 | 7.400e-03 | 2.123e-03 | 3.5 |
| the owls eat mice at night . | 6.250e-03 | 6.250e-03 | 2.778e-03 | 2.2 |
| the owls behind the hen see the bats at night . | 5.000e-05 | 5.000e-05 | 1.453e-07 | 344 |
一旦假设”每个词只依赖前面两个词”(trigram),等式就变成了近似:高频短句被低估 2–3.5 倍,带介词短语的长句被低估 344 倍。原因下一节会看到:trigram 把”the bat""noise at”这些片段在所有句子里的用法混在一起统计,看不到”这句话的主语是谁”。
因为链式法则永远成立,所以生成一个序列可以归结为反复预测”下一个元素”:只要能对任意历史 给出下一个词的分布 ,就定义了整个序列的分布。这类模型叫自回归模型(autoregressive model)。今天所有的 GPT 式大语言模型都是自回归模型。
2.5 “建模所有交互”的代价
链式法则把问题变成了:怎样表示 ?最直接的办法是给每一种可能的历史存一张分布表。但历史有多少种?词表有 5 万个词时,长度为 10 的历史就有 种——宇宙里的原子都不够存这张表,更不可能有足够的数据把它填满。
slides 的提问是:“如果我想对每一种可能的交互建模——或者至少是当前变量和它之前所有变量的交互——怎么办?“有两条出路:
- 砍掉历史:只看最近 个词。这就是 n-gram 模型(§3)。表的大小变成 ,仍然随 指数增长,但 小的时候可以接受。
- 压缩历史:用一个函数 把整个历史压缩成一个定长向量,再基于这个向量预测下一个词。这就是 RNN 语言模型(§4)。参数量固定,和历史长度无关。
第 2 讲的 Transformer 是第三条路:不压缩,也不砍掉,而是让每个位置在需要时直接回头看历史里的任何一个位置。
3. n-gram 语言模型 · Background: n-gram LMs
3.1 马尔可夫假设
n-gram 模型的核心假设是:下一个词只依赖于最近的 个词:
- (unigram):,完全不看上下文;
- (bigram):;
- (trigram):。
slides 的注解是:“这叫一个模型,因为我们对’要看多少个前面的词’做了假设”。对比 §2.4 的”链式法则永远成立”——法则 vs 模型,这是理解所有语言模型的第一个分界线。
为了让句子的第一个词也有完整的上下文,实现时会在句首补 个开始记号 <s>;为了让模型知道句子在哪里结束,句尾补一个结束记号 </s>(§4.4 解释为什么结束记号必不可少)。
3.2 学习:数数就是最大似然
n-gram 的参数就是那张条件概率表。怎样从数据里学出来?slides 的回答是:“从数据里——数 n-gram 出现的次数就行”:
slides 的例子:语料里 “cows eat” 后面跟了 corn 4 次、grass 3 次、hay 2 次、if 1 次、which 1 次,一共 11 次,所以 。
为什么”数数”恰好是最大似然估计(MLE)? 固定一个上下文 ,它后面的词服从一个类别分布 ,约束是 。训练数据里这个上下文后面出现了 次词 ,对数似然是 。用拉格朗日乘子法最大化:
最后一步用了约束 得到 。所以最大似然解就是相对频率。每个上下文各自独立地这样估计,就得到了整张表。(这个推导第 3 讲还会再出现一次:它说明了”计数”和”梯度下降训练神经网络”在目标上是同一件事。)
代码验证(01 第 2 部分):在我们的 2 万句训练语料里数 “cows eat ___”:
| 下一个词 | 计数比 | 其中主语真是 cows 的次数 | |
|---|---|---|---|
| corn | 148/433 | 0.342 | 132 |
| grass | 101/433 | 0.233 | 88 |
| hay | 78/433 | 0.180 | 63 |
| seeds | 44/433 | 0.102 | 31 |
| mice | 22/433 | 0.051 | 0 |
| fish、bones、flies、eggs、fruit | 16、9、7、5、3 /433 | 0.037–0.007 | 0 |
表的前四行和 slides 的例子是同一回事。但最后一列揭示了一个问题:433 次 “cows eat” 里有 119 次的 cows 并不是主语,而是干扰名词——比如 “the bats near the cows eat flies”。这些句子把蝙蝠、猫头鹰、狗的食物也算进了 “cows eat” 的分布。trigram 只看到 “cows eat” 两个词,根本无法知道是谁在吃。
3.3 采样:掷一颗加权骰子
slides 把从语言模型里采样描述成四步:
- 把每个条件分布想象成一颗(有 5 万个面的)加权骰子;
- 取出当前上下文对应的那颗骰子,比如 ;
- 掷骰子,朝上的那个词就是 ;
- 重复,直到掷出
</s>。
01 第 3 部分从不同阶数的 n-gram 里各抽了 2000 句话,用一个语法检查器判断它们是否合法:
| n | 结构合法率 | 合法句中主谓一致率 | 和某个训练句完全相同 |
|---|---|---|---|
| 1 | 0.000 | — | 0.000 |
| 2 | 0.181 | 0.619 | 0.025 |
| 3 | 0.475 | 0.621 | 0.097 |
| 4 | 1.000 | 0.720 | 0.306 |
| 5 | 1.000 | 0.807 | 0.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>)一共是 :
括号里是平均每个词的负对数似然,也就是模型分布和数据之间的交叉熵(单位 nat)。困惑度就是它的指数。
直觉:有效分支数。 如果模型每一步都在 个词里均匀地犹豫,每个词的概率都是 ,那么 。所以困惑度可以理解为”模型平均在几个词之间拿不定主意”。它越小越好;最好的情况是 1(每一步都百分之百确定,而且都对)。
三个要点:
- 必须在没见过的数据上算。 在训练集上,一个能把所有训练句背下来的模型困惑度可以很低,但它没有学到”语言”。
- 它有理论下界。 真实分布 自己的困惑度(即数据的熵的指数)是任何模型在期望意义下都达不到更低的值。真实语料里我们不知道这个下界;在玩具语言里我们知道:测试集上是 2.491。
- 只有词表相同的模型才能直接比较。 换一个分词方式,“每个词”的含义就变了(第 3 讲讲分词时会再说)。
3.5 零概率与平滑
最大似然估计有一个致命问题:训练集里没见过的 n-gram,概率是 0。只要测试集里有一个这样的 n-gram,整句的概率就是 0,困惑度就是无穷大。01 第 4 部分:
| n | 测试集里没见过的 n-gram | MLE(不平滑) | 加 1 平滑 | 加 0.01 平滑 | 插值(验证集调 λ) |
|---|---|---|---|---|---|
| 1 | 0 | 25.421 | 25.421 | 25.421 | 25.421 |
| 2 | 0 | 3.799 | 3.841 | 3.800 | 3.830 |
| 3 | 1(0.00%) | inf | 3.510 | 3.293 | 3.293 |
| 4 | 43(0.19%) | inf | 3.382 | 2.912 | 2.903 |
| 5 | 398(1.72%) | inf | 4.252 | 3.054 | 2.866 |
| 6 | 1111(4.81%) | inf | 5.734 | 3.397 | 2.856 |
trigram 在测试集的约 23,000 个 trigram 里只有 1 个没见过,困惑度就已经是无穷大了。解决办法叫平滑(smoothing)——从见过的事件那里”匀”一点概率给没见过的事件:
- 加 k 平滑(add-k / Laplace):每个计数都加 ,。简单,但 时太粗暴:把大量概率分给了根本不可能的词, 越大越糟(6-gram 时 5.734,比 trigram 还差)。
- 插值(interpolation):把高阶和低阶模型按比例混合,;上下文没见过时完全退回低阶(回退,back-off)。高阶模型懂上下文但稀疏,低阶模型粗糙但从不出零,混合起来最稳。 要在验证集上调,绝不能碰测试集。
- 实践中最好的是 Kneser–Ney 平滑(1995):它除了插值,还考虑”一个词出现在多少种不同的上下文里”。Chen & Goodman (1998) 对各种平滑方法做了系统比较。本讲不实现它。
插值模型在 时最好(2.856),但离真实下界 2.491 还有明显差距。下一节看这个差距从哪里来。
3.6 n-gram 的根本缺陷:看不到窗口之外
主谓一致测试(01 第 5 部分,也是 Linzen et al. 2016 的做法):取动词之前的所有词作为前缀,比较模型给同一个动词的两种形式的概率—— 和 。正确形式的概率更大就算对。只比较同一个动词的两种形式,所以结果和”模型想用哪个动词”无关,只测它是否记得主语的单复数。
最难的情况是所有干扰名词都和主语单复数相反(“the bat near the cows behind the dogs ___”):
| 模型 | k=0 | k=1 | k=2 | k=3 | k=4 | k=5 | k=6 |
|---|---|---|---|---|---|---|---|
| 2-gram(看 1 个词) | 1.000 | 0.043 | 0.031 | 0.045 | 0.060 | 0.051 | 0.052 |
| 3-gram(看 2 个词) | 1.000 | 0.043 | 0.031 | 0.045 | 0.060 | 0.051 | 0.052 |
| 4-gram(看 3 个词) | 1.000 | 0.378 | 0.438 | 0.375 | 0.412 | 0.412 | 0.388 |
| 5-gram(看 4 个词) | 1.000 | 0.815 | 0.200 | 0.223 | 0.205 | 0.180 | 0.205 |
| 6-gram(看 5 个词) | 1.000 | 0.820 | 0.195 | 0.223 | 0.203 | 0.177 | 0.198 |
(训练集只有 ;干扰名词单复数随机时,所有 n-gram 在 时都在 0.5 附近。)这张表值得逐行读:
- bigram 和 trigram 不是在瞎猜,而是被系统性地误导。 它们的上下文是”the cows”,而在训练数据里”the cows”后面最常跟的是复数动词(那些 cows 作主语的句子)。所以它们跟着最近的名词走:最近的名词和主语一致时准确率 0.956,相反时 0.052()。这就是语言学里的干扰名词效应(attraction effect)——有趣的是,人类在快速说话时也会犯这种错误。
- 4-gram 好得多(约 0.4),但原因很微妙。 它看到了名词前面的介词(“near the cows”),从数据里学到了”介词后面的名词不决定动词”,于是接近抛硬币,而不是被误导。
- 5-gram、6-gram 在 k=1 时”看得到”主语(主语到动词的距离是 ),准确率 0.82;但 时主语出了窗口,又回到 0.2。没有任何有限的 能处理任意长的距离。
- 就算看得到,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; 一大,测试集里没见过的 n-gram 越来越多(6-gram 已有 4.8%) | 能在相似的上下文之间共享信息:“the bat makes” 和 “the owl makes” 应该互相帮助 |
| 参数爆炸 | 表的大小随 指数增长;6-gram 的表在我们这门小语言上已有 3.1 万个非零条目 | 参数量和上下文长度无关 |
| 窗口有限 | 看不到 个词之外;看得到也分不清结构 | 能记住任意长的历史,并且知道该记住什么 |
第一个需求的答案是词向量(embedding):每个词用一个稠密向量表示,相似的词向量相近,于是模型可以把关于 bat 学到的东西迁移到 owl 上(Bengio et al., 2003 的神经概率语言模型最早系统地提出了这一点)。后两个需求的答案就是 RNN。
4. RNN 语言模型 · Recurrent Neural Network LMs
4.1 关键想法:把历史压缩成一个定长向量
slides 对 RNN-LM 的概括只有两句话:
- 把之前所有的词转换成一个定长向量:;
- 定义一个以这个向量为条件的分布 。
于是整句的概率是
和 §2.4 的链式法则对比:RNN 没有做马尔可夫假设, 原则上依赖于全部历史。它做的是另一种近似——把历史”压缩”进一个 维的向量。压缩必然有损失,但它是学出来的有损压缩:网络自己决定该记住什么(主语的单复数、主语是哪种动物),该忘掉什么(介词是 near 还是 behind)。
怎样用一个固定大小的函数处理任意长的历史?答案是递推: 由上一步的 和新读入的词 算出来,每一步用的是同一个函数、同一组参数。
4.2 Elman RNN 的定义
slides 引用的是 Graves et al. (2013) 里的标准写法。给定输入序列 ,RNN 计算隐藏向量序列 和输出序列 :
其中 是逐元素的非线性函数(slides 说”通常是 sigmoid”;实践中更常用 tanh,我们的代码用 tanh)。这种最简单的循环网络叫 Elman RNN(Elman, 1990)。各部分的形状(设输入维度 、隐藏维度 、输出维度 ):
| 量 | 形状 | 作用 |
|---|---|---|
| 第 步的输入(对语言模型是一个词向量) | ||
| 把新输入映射到隐藏空间 | ||
| 把”过去的总结”传到下一步——循环就在这里 | ||
| 偏置 | ||
| 到第 步为止的”记忆” | ||
| , | , | 从记忆读出输出 |
参数共享。 注意 没有下标 :每个时间步用的是同一组参数。这有三个后果:
- 参数量和序列长度无关——处理 10 个词和 1000 个词的句子用的是同一个模型。
- 在第 3 个位置学到的规律(“复数主语后面跟复数动词”)自动适用于第 30 个位置。这是一种归纳偏置(inductive bias):我们假设”语言的规律不随位置变化”。
- 训练时,同一个参数的梯度要把所有时间步的贡献加起来(§5.7 会在代码里看到)。
为什么用 tanh? 它把 限制在 ,防止递推中数值无限增大;它在 0 附近接近线性、导数为 1,比 sigmoid(导数最大只有 0.25)更不容易让梯度在多步传递后消失(§4.8)。
4.3 从隐藏状态到下一个词的分布
对语言模型,还需要两样东西:
- 输入怎样表示一个词? 用一张嵌入表 :第 个词的向量就是第 行。查表等价于”one-hot 向量乘矩阵”,所以它就是一个没有偏置的线性层,只是实现时直接取行更快。嵌入表是和其他参数一起学出来的,训练结束后意思相近的词向量也相近——这正是 §3.7 要的”相似上下文共享信息”。
- 怎样从 得到一个分布? 输出层给每个词打一个分(logits),再用 softmax 归一化:
slides 在这里问了一个问题:“怎样从 创建分布 ?“答案就是这个 softmax:它保证所有词的概率都为正、加起来等于 1,而且是可微的,可以用梯度下降训练。
我们的模型(rnn_lm.py):、、,一共 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>):预测第一个词时还没有任何历史,需要一个”什么都没读过”的输入来产生 。 - END(
</s>):句子长度不固定,模型必须学会什么时候停。没有 END 时,模型只能给”长度为 的序列”定义分布,无法比较不同长度的句子;有了 END, 才是一个在所有有限长度序列上的合法分布(所有句子的概率加起来等于 1)。采样时也要靠它停下来。
4.5 训练:最大似然、教师强制与 BPTT
训练目标和 n-gram 一样是最大似然:让训练句子的概率尽可能大。等价地,最小化平均负对数似然,也就是交叉熵损失:
和 n-gram 不同的是,这里没有”数数”这种闭式解,只能用梯度下降。训练 RNN-LM 有三个要点:
- 教师强制(teacher forcing):训练时,每一步的输入都是真实的上一个词,而不是模型自己预测的词。这样一句话的所有位置可以一次前向就算出损失(不必等模型逐词生成)。代价是训练和生成时的输入分布不一样(训练时永远看到正确的历史,生成时看到的是自己生成的、可能有错的历史),这叫暴露偏差(exposure bias)。
- 反向传播穿越时间(BPTT, backpropagation through time):把 RNN 按时间展开成一个很深的前馈网络(每个时间步一层,各层共享参数),在这张展开图上做普通的反向传播。§5.7 会用我们自己写的自动微分引擎验证:BPTT 不是一个新算法,只是在展开图上做反向模式自动微分。
- 梯度裁剪(gradient clipping):梯度的总范数超过某个阈值(我们用 1.0)就等比例缩小。原因见 §4.8。
批量训练时,不等长的句子要补齐成一样长(填充记号 <pad>),损失要跳过填充的位置——这些工程细节第 3 讲细讲,代码 rnn_lm.py 的 make_batches 已经实现了。
4.6 采样
slides 强调:“我们用来从 n-gram 采样的方法,同样适用于 RNN 语言模型”:从 START 开始,每一步用当前的 算出下一个词的分布、掷骰子、把抽中的词作为下一步的输入,直到抽到 END。唯一的区别是骰子由 决定,而不是由最近 个词查表决定;而且 在步与步之间传递,不需要每次把整个前缀重新喂一遍。
slides 展示了 Karpathy (2015) 用字符级 RNN 在莎士比亚作品上训练后生成的文本,和莎士比亚的真实文本并排放在一起,问”哪个才是真的莎士比亚?“——字符级 RNN 生成的文字格式、用词、甚至角色名的写法都像模像样,只是读下去意思不太连贯。这个例子在 2015 年让很多人第一次意识到神经语言模型能学到多少结构。
温度(temperature):把 logits 除以 再做 softmax。 让分布更尖(更保守、更重复), 让分布更平(更多样、更容易出错)。第 3 讲会系统讨论解码策略。
4.7 实验:RNN-LM vs n-gram
05_rnn_lm.py 在 2 万句训练语料上训练 10 个 epoch(单线程 CPU 每个 epoch 约 0.7 秒),然后和 n-gram 正面比较。
训练过程。 每个 epoch 结束时,在”干扰名词全部和主语相反”的最难句子上测一致性:
| epoch | 训练损失 | 验证集困惑度 | 一致性 k=1 | k=3 | k=6(训练中没见过) |
|---|---|---|---|---|---|
| 1 | 1.2028 | 2.8447 | 1.000 | 0.940 | 0.880 |
| 2 | 1.0056 | 2.6787 | 1.000 | 1.000 | 1.000 |
| 4 | 0.9493 | 2.5718 | 1.000 | 1.000 | 1.000 |
| 10 | 0.9317 | 2.5510 | 1.000 | 1.000 | 1.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-LM | 2.5396 |
| 真实分布(理论下界) | 2.4913 |
换算成”每个词比真实分布多付出的交叉熵”:6-gram 是 0.137 nat,RNN 是 0.019 nat——差距缩小到七分之一。
差距从哪里来? 把每个预测位置按语法角色分组,分别看平均负对数似然(nat/词,越小越好):
| 位置 | 预测次数 | trigram | RNN | trigram − RNN |
|---|---|---|---|---|
| 主语名词 | 2000 | 2.774 | 2.791 | −0.017 |
| 介词短语 | 5247 | 1.608 | 1.572 | +0.036 |
| 动词 | 2000 | 2.881 | 1.842 | +1.040 |
| 宾语 | 2866 | 1.350 | 0.815 | +0.536 |
| 时间/地点 | 5007 | 0.787 | 0.339 | +0.447 |
(“the”、句号、</s> 三个位置是确定的,两者都是 0。)RNN 的优势全部集中在依赖远处主语的位置:主语决定动词的单复数和种类、决定吃什么、决定什么时候出现。在”主语名词”这个位置,没有任何历史可以利用(8 种动物 × 单复数均匀分布),最好也只能做到 ,两者都接近这个值。
主谓一致。
| 模型 | k=0 | k=1 | k=2 | k=3 | k=4 | k=5 | k=6 |
|---|---|---|---|---|---|---|---|
| 3-gram | 1.000 | 0.043 | 0.031 | 0.045 | 0.060 | 0.051 | 0.052 |
| 6-gram | 1.000 | 0.820 | 0.195 | 0.223 | 0.203 | 0.177 | 0.198 |
| RNN-LM | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
(干扰名词全部和主语相反。)RNN 在所有距离上都做对了,包括训练中从没见过的 k=4、5、6。这说明它学到的不是”第几个词是主语”,而是”跳过介词短语、记住主语单复数”这样的规则。
但要诚实地说:这个结果比真实情况”好看”。 我们的介词短语格式非常规整(永远是”介词 the 名词”),要记住的只有 1 个比特(单复数)。Linzen et al. (2016) 在真实英语上发现,LSTM 整体准确率很高,但错误率会随干扰名词个数明显上升。而且这种”外推”依赖于训练数据:练习 7 会看到,如果训练集里只有 的句子,RNN 在 时就降到 0.83、 时 0.37;如果训练集里根本没有介词短语,它和 trigram 一样跟着最近的名词走。第 2 讲会构造需要记住更多信息、距离更远的任务,那时简单 RNN 的”遗忘”就会暴露出来。
采样。
| 来源 | 温度 | 结构合法率 | 合法句中主谓一致率 | 与训练句完全相同 | 不同句子占比 |
|---|---|---|---|---|---|
| 真实分布(生成器本身) | — | 1.000 | 1.000 | 0.553 | 0.781 |
| 3-gram(最大似然) | 1.0 | 0.468 | 0.645 | 0.116 | 0.835 |
| 5-gram(最大似然) | 1.0 | 1.000 | 0.807 | 0.389 | 0.824 |
| RNN-LM | 0.5 | 1.000 | 1.000 | 0.606 | 0.696 |
| RNN-LM | 1.0 | 0.994 | 0.999 | 0.567 | 0.768 |
| RNN-LM | 1.5 | 0.936 | 0.985 | 0.520 | 0.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 步之前的主语。训练时,误差信号必须从动词位置一路传回读到主语的那个时刻,才能”告诉”当时的 “你应该把单复数记住”。这个信号在途中会变成什么样?
由 ,
传回 步要连乘 个矩阵。粗略地说,如果每个因子的”放大倍数”小于 1,连乘后指数级变小(梯度消失,vanishing gradient);大于 1 且 tanh 没有饱和,则指数级变大(梯度爆炸,exploding gradient)。Bengio, Simard & Frasconi (1994) 和 Hochreiter (1991) 最早分析了这个问题;Pascanu, Mikolov & Bengio (2013) 给出了基于谱半径的条件,并提出了梯度裁剪。
06_gradient_through_time.py 实际测了 (相对于 ):
| 距离 d | 训练后 | 随机初始化 |
|---|---|---|
| 0 | 1 | 1 |
| 3 | 2.4e-01 | 9.0e-02 |
| 9 | 7.9e-02 | 8.2e-04 |
| 18(主语名词) | 2.6e-03 | 7.5e-07 |
- 随机初始化时,信号传回主语位置只剩百万分之一:每往回一步平均缩小到 0.46 倍。刚开始训练时,模型几乎收不到”主语很重要”的信号——这解释了为什么远的依赖学得晚。
- 训练之后,每步平均缩小到 0.72 倍,传回 18 步还剩 2.6e-03,是随机初始化时的 3500 倍。训练把衰减放慢到足以让”单复数”这一个比特跨过 18 步。但这仍然是一个指数衰减;依赖再远、要记的东西再多,这种平衡就很难维持。
把随机初始化的 乘以不同倍数(默认初始化下谱半径 ),看 处的相对梯度:
| W_hh 倍数 | 谱半径 | tanh RNN(平均 1−h²) | 线性 RNN(去掉 tanh) |
|---|---|---|---|
| 0.5 | 0.30 | 4.5e-12(0.774) | 2.0e-10 |
| 1.0 | 0.61 | 7.5e-07(0.749) | 5.2e-05 |
| 2.0 | 1.22 | 2.4e-02(0.639) | 1.5e+01 |
| 4.0 | 2.43 | 1.5e+01(0.399) | 4.9e+06 |
- 线性 RNN 的梯度大致按 变化: 时 18 步后放大近五百万倍(,同一个数量级)。
- tanh RNN 在 时也在增长(15 倍),但比线性 RNN 小五个数量级: 一大,隐藏状态就被推进 tanh 的饱和区,平均 从 0.749 降到 0.399,把增长压住了绝大部分。
- 消失和爆炸是同一个连乘结构的两面。 对爆炸,梯度裁剪是简单有效的保险;对消失,要改结构——第 2 讲的 LSTM 用一个加法式更新的记忆单元 让梯度可以几乎不衰减地流过很多步。
4.9 RNN 家族
slides 引用的 Graves et al. (2013) 还介绍了几种变体,第 2 讲会展开:
- LSTM(Hochreiter & Schmidhuber, 1997):用输入门、遗忘门、输出门控制一个记忆单元 的读写,专门为了解决长程依赖。slides 列出的公式:,,,,。
- 双向 RNN:一个 RNN 从左往右读,另一个从右往左读,每个位置的输出同时看到过去和未来。语音识别里整段话一次性转写,用未来的上下文没有问题。但它不能直接当生成式语言模型用:生成第 个词时,未来的词还不存在(练习 8)。
- 深层 RNN:把多个 RNN 叠起来,下一层的输出序列是上一层的输入序列,逐层建立更高层次的表示。
5. 自动微分 · Automatic Differentiation
§4.5 说”用梯度下降训练”。但 RNN-LM 有上万个参数(GPT-3 有 1750 亿个),损失对每个参数的偏导数是怎样算出来的?这一节回答这个问题。它是这门课里所有模型共用的引擎。
5.1 为什么需要梯度;三种求导方式
训练 = 最小化损失 。梯度下降每一步做 ,所以每一步都要算出 对每一个参数的偏导数。有三种办法:
| 方法 | 做法 | 问题 |
|---|---|---|
| 符号微分 | 像手算一样,把 的表达式求导,得到导数的表达式 | 表达式会膨胀(同一个子表达式在导数里反复出现);碰到 for 循环和 if 就难办 |
| 数值微分(有限差分) | 每个参数要算两次 :1750 亿个参数就是 3500 亿次前向;而且有截断误差和舍入误差 | |
| 自动微分 | 把计算拆成一串基本运算,对每个基本运算用已知的导数公式,再用链式法则组合 | 无(精确到浮点误差,代价和算一次 同一量级) |
有限差分虽然不能用来训练,却是检查梯度是否写对的标准工具(§5.6):它慢,但几乎不可能写错。
5.2 计算图
自动微分的第一步,是把计算 的过程写成一个有向无环图(DAG),叫计算图:每个变量是一个节点,” 由 算出”就画从 指向 的边。slides 的写法:
前向计算
- 写出计算 的算法,它定义了一张有向无环图;
- 按拓扑序访问每个节点(保证一个节点的所有输入都先算好):对输入为 的变量 ,计算 ,并把结果存在节点上。
5.3 反向模式:两种写法
反向计算从输出往回走。slides 给了两种等价的写法。
Version A(“拉”): 初始化 ;按逆拓扑序访问每个节点 。设 是所有以 为输入的节点,此时 都已知,于是
Version B(“推”): 把所有 初始化为 0,;按逆拓扑序访问每个节点 ,此时 已经完整,对它的每个输入累加:
两种写法算的是同一个和:A 是”站在 处,把所有下游节点的贡献拉过来”,B 是”站在 处,把自己的贡献推给每个上游节点”。B 更容易实现:每个节点只需要知道自己的输入,不需要知道谁用了自己。PyTorch 和我们的 tape 引擎都用 B。
是关键:一个节点可能被多个下游节点使用,它的梯度是所有用途的贡献之和。RNN 里同一个 在每个时间步都被用了一次,所以它的梯度是 个时间步的贡献之和(§5.7 用代码验证)。这也是”每一步训练前要清零梯度”的根源(§5.8)。
slides 在两种写法里都标注了一句:“算法的选择保证了 容易计算”——每个基本运算(加、乘、tanh、矩阵乘法……)的局部导数都有现成的公式。自动微分的全部智慧就是:复杂函数的导数 = 很多个简单局部导数按链式法则组合,而组合的顺序由计算图决定。
5.4 为什么是反向模式
链式法则也可以从输入往输出方向用,叫前向模式(forward mode):给每个变量附带”它对某一个输入 的导数”,随着前向计算一起传播。前向模式一次只能得到对一个输入的导数。所以:
- 函数 ;
- 前向模式要跑 遍(每遍得到雅可比矩阵的一列);
- 反向模式要跑 遍(每遍得到雅可比矩阵的一行),每遍的代价是一次前向 + 一次反向,和算一次 同一量级。
神经网络训练恰好是 极大(几十亿个参数)、(标量损失) 的情形,所以反向模式完胜。反向模式自动微分在神经网络里的名字就是反向传播(backpropagation)。
02 第 4 部分对 ()实测了基本运算次数:
| 输入维度 n | 前向模式(对偶数,n 遍) | 反向模式(1 遍前向 + 1 遍反向) | 倍数 |
|---|---|---|---|
| 10 | 300 | 80 | 3.8× |
| 100 | 30,000 | 800 | 37.5× |
| 1000 | 3,000,000 | 8,000 | 375× |
前向模式的代价随 增长( 遍 × 每遍约 次运算),反向模式随 线性增长(前向 次 + 反向时每条边一次乘加 次)。两种模式算出的梯度完全相同。
5.5 过程式实现:slides 的那个网络
slides 用一个小网络贯穿自动微分这一节:一层 sigmoid 隐藏层 + softmax 输出 + 交叉熵损失(没有偏置项;需要偏置时可以在 末尾补一个常数 1)。
前向(Algorithm 1):
反向(Algorithm 2),每一行都是”下游梯度 × 局部导数”:
| 步骤 | 公式 | 来源 |
|---|---|---|
| 交叉熵 | ||
| softmax | softmax 的雅可比矩阵: | |
| 线性层 2 | , | ,所以 |
| sigmoid | ||
| 线性层 1 | 同线性层 2 |
一个值得记住的化简:把 softmax 和交叉熵合起来求导,——“预测分布减真实分布”,简洁而且数值稳定(不需要除以可能很小的 )。所以实际框架都把两者合成一个模块(PyTorch 的 F.cross_entropy 直接吃 logits)。练习 4 让你推导它。
02 第 1 部分用有限差分检查这 6 行:、 的相对误差分别是 4.5e-10 和 1.4e-10(float64 下有限差分自身就有这么大的误差),说明公式是对的。
过程式写法的三个缺点(slides):
- 难以复用:换一个网络(多一层、sigmoid 换成 tanh)就要重写整串公式;
- 难以单独优化某一步;
- 难以定位错误:有限差分检查报错时,你只知道”这 17 行代码里某处错了”。
5.6 基于模块的自动微分
关键想法(slides):把网络的计算组件化成层;每一层把计算图里的一组实数节点合并成一个向量值节点,叫一个模块(module)。每个模块只需要会两件事:
- 前向:给定输入 ,用某个可微函数算出输出 ;
- 反向:给定”损失对输出的梯度” ,算出”损失对输入的梯度” ,用的正是链式法则 。
slides 列了四个模块:Linear(;反向 ,)、Sigmoid、Softmax、CrossEntropy。代码 autodiff.py 实现了它们(外加 Tanh),网络的前向就是依次调用各模块的 forward,反向就是倒序调用各模块的 backward。02 第 2 部分验证:模块化写法和过程式写法的结果严格相同(差为 0,因为执行的是同样的浮点运算)。
模块化的三个好处(slides):易于复用和改造;封装好的层更容易单独优化(例如用 C++ 或 CUDA 实现);更容易找到 bug,因为可以对每一层单独做有限差分检查。
最后一点值得用实验体会(02 第 3 部分)。我们故意写一个有 bug 的 sigmoid 反向:忘了乘 。
| 检查方式 | 结果 |
|---|---|
| 逐模块检查:Linear、Sigmoid、Tanh、Softmax、CrossEntropy | 相对误差都在 1e-10 量级 |
| 逐模块检查:有 bug 的 Sigmoid | 0.41 |
| 整网检查(用有 bug 的 sigmoid 组装网络) | 误差 0.25, 误差 1.4e-10 |
整网检查只能告诉你” 错了、 没错”——错误在 之前的某处;逐模块检查直接指出是 Sigmoid。单模块检查的技巧:随便取一个随机的上游梯度 ,令 ,那么 恰好就是 backward 应该返回的东西,可以直接和有限差分比较(check_module)。
有限差分的两个细节:用中心差分 而不是单侧差分,截断误差从 降到 ;必须用 float64,float32 下 的差分几乎全是舍入噪声(练习 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 验证了三件事:
- tape 版的梯度和手推公式逐位相同(差为 0)。前向时磁带上录了 5 个节点;反向之后磁带被弹空——要再求一次梯度就得重新跑一遍前向。PyTorch 默认也这样(第二次
backward会报错,除非retain_graph=True)。 - 它足以训练网络。 只用这个不到 200 行的引擎 + 逐样本 SGD,在三臂螺旋数据上训练 100 个 epoch:损失从 1.03 降到 0.005,训练准确率从 0.587 升到 1.000;没有隐藏层的线性分类器只有 0.627(螺旋缠绕在一起,必须靠非线性把空间”弯”过来)。
- BPTT 就是展开图上的反向模式。 把 Elman RNN-LM 在一句 15 步的话上展开(
rnn_lm_tape:一个 for 循环,每一步调用查表、两个线性层、两次加法、tanh、输出层、softmax 交叉熵),磁带上一共录了 136 个节点。用同样的权重在 PyTorch(float64)里算同样的损失,6 个参数的梯度最大差 6.9e-15——我们没写任何 BPTT 专用代码,for 循环 + 磁带就自动得到了正确的 BPTT。而且 的梯度等于 15 个时间步各自贡献 之和(差 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 保存。
梯度会累加。 对 连续调用三次 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% 还有额外加分:
这个设计的激励很清楚:(1) 先自己独立做一遍,真正学到东西;(2) 通过反馈纠正误解;(3) 再在一个独立的阶段里练习高效地使用 AI。
对自学者的建议。 读这份讲义时,你也可以给自己设一个”Slot A”:先自己推导练习题、自己预测代码的输出,再看参考答案和运行结果;先自己读懂代码、改一个参数预测会发生什么,再去问 AI。讲义里的代码注释很详细,正是为了让你能读懂每一行,而不是只运行它。
7. 课程地图 · The course map
7.1 讲次安排
| 单元 | 讲次 | 主题 |
|---|---|---|
| 文本生成模型 | 1–4 | RNN-LM 与自动微分 → Transformer → 训练与解码 → 预训练、微调与现代 Transformer(RoPE、GQA) |
| 图像生成模型 | 5–9 | CNN / BERT / ViT → GAN + 概率图模型 → 扩散模型(上、下)→ VAE |
| 应用与适配 | 10–12 | PEFT(LoRA)→ ICL、提示、指令微调、RLHF → DPO、文生图、潜空间扩散 |
| 多模态 | 13–15 | 视觉-语言模型 → 交叉注意力、DiT、Prompt-to-Prompt → Q-Former、规模定律 |
| 扩大规模 | 16–18 | MoE → 分布式训练 → 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
- 生成式 AI 就是概率建模:用模型表示一个高维分布 或 ,从数据里学它,从中采样。每个生成模型都要回答”怎样表示、怎样学习、怎样采样”。
- 链式法则是恒等式,把序列的分布拆成一串”下一个元素”的条件分布;n-gram 的马尔可夫假设是模型。在我们的语料上,trigram 把高频短句低估 2–3.5 倍、把一个长句低估 344 倍。
- n-gram 的学习就是数数(= 最大似然);采样是逐词掷加权骰子;评估用困惑度(有效分支数)。它有三个根本问题:零概率(需要平滑;trigram 在约 23,000 个测试 trigram 里只有 1 个没见过,困惑度就是无穷大)、表随 指数膨胀、看不到窗口之外。
- 主谓一致实验把第三个问题看得最清楚:trigram 跟着最近的名词走(干扰名词与主语相反时准确率约 0.05);5-gram 在主语进入窗口时也只有 0.84,因为它分不清窗口里的名词是不是主语。
- RNN-LM 把全部历史压缩成一个定长向量,参数在时间步之间共享、数量和上下文长度无关。11,640 个参数的 RNN 把测试困惑度从 6-gram 的 2.856 降到 2.540(下界 2.491),优势全部集中在依赖远处主语的位置;主谓一致在 全对——但这依赖于我们语言的规整性和训练数据里有足够的嵌套例子。
- 梯度穿越时间是连乘雅可比矩阵:随机初始化时传回 18 步只剩 7.5e-07(消失);谱半径大于 1 的线性 RNN 会爆炸。梯度裁剪治爆炸,LSTM 治消失(第 2 讲)。
- 自动微分 = 计算图 + 局部导数 + 链式法则。神经网络是”输入极多、输出一个”的函数,所以用反向模式(反向传播): 时前向模式的运算量是反向模式的 375 倍。
- 从过程式到模块化再到 tape:模块只需实现 forward / backward,可以逐个做有限差分检查(一下子定位植入的 bug);tape 让控制流自动决定计算图(define-by-run),BPTT 就是在展开图上做反向模式(和 PyTorch 差 6.9e-15)。
- PyTorch 的 autograd 就是工业级的 tape:永远写
model(x)(会执行钩子),参数用nn.Parameter注册,梯度会累加所以每步要zero_grad();五步训练循环是之后所有代码的骨架。 - 规模首先是内存问题:GPT-3 的 FP16 权重 326 GiB,一块 80 GB 的 GPU 放不下——这引出了半精度、模型并行和分布式训练。
9. 常见误区 · Common pitfalls
- “链式法则是一种近似。” 不是。链式法则对任何分布都精确成立;近似来自对条件分布的建模假设(只看 个词、压缩成定长向量)。
- “困惑度越低越好,所以在训练集上算困惑度就行。” 训练集困惑度奖励死记硬背;必须在没见过的数据上算,而且只有词表(分词方式)相同的模型才能直接比较。
- “n 越大的 n-gram 越好。” 不平滑时 就是无穷大;加 1 平滑时 6-gram 比 trigram 还差。高阶模型需要好的平滑和插值,而且仍然有窗口上限。
- “n-gram 在长程依赖上失败只是因为窗口不够长。” 窗口够长时它也只有 0.84:它还分不清窗口里的名词扮演什么角色。长程依赖需要的是能表示结构的记忆,不只是更长的窗口。
- “RNN 能记住任意长的历史。” 原则上可以,实践中梯度会随距离指数衰减,记忆是脆弱的、需要学出来的;它在我们语料上的完美表现依赖于语言的规整性和训练数据(练习 7)。
- “BPTT 是专门为 RNN 发明的算法。” BPTT 就是在按时间展开的计算图上做普通的反向模式自动微分(
03用 PyTorch 逐位验证了这一点)。 - “反向传播比有限差分更’近似’。” 正好相反:自动微分精确到浮点误差;有限差分有截断误差和舍入误差,只适合用来检查梯度。
- “
model.forward(x)和model(x)完全一样。” 前者跳过钩子。很多工具依赖钩子,所以永远写model(x)。 - “
loss.backward()会覆盖上一次的梯度。” 它是累加的;忘了zero_grad()会让梯度越来越大。 - “照抄训练句的比例高,说明模型在死记硬背。” 要和真实分布本身的重复率对照:在我们的语料上,真实分布有 55% 的样本在训练集里出现过,比 5-gram 还高。
10. 练习 · Exercises
练习 1(手算 n-gram)。 训练语料只有三句话(每句前补一个 <s>,后补一个 </s>):
the cow eats corn
the cow eats hay
the bat eats flies
(a) 写出 bigram 的最大似然估计 、、。
(b) 算句子 “the bat eats corn” 的 bigram 概率。它在训练集里出现过吗?
(c) 算句子 “the cow eats fish” 的概率。用加 1 平滑(预测词表为 the、cow、bat、eats、corn、hay、flies、</s>,共 8 个)重新算 会遇到什么问题?
参考答案
(a) “the” 出现 3 次,后面 2 次是 cow:。“eats” 出现 3 次,后面 1 次是 corn:。“corn” 后面总是 </s>:。
(b) 。它没在训练集里出现过,但 bigram 给了它不小的概率——这就是 n-gram 的”泛化”:把见过的片段重新拼接。(顺便:这句话”蝙蝠吃玉米”在语义上不太对,bigram 看不出来,因为”吃什么”取决于两个词之前的主语。)
(c) fish 根本不在词表里,最大似然下 ,整句概率为 0。加 1 平滑只能把概率分给预测词表里的词:如果 fish 不在词表里,平滑也无能为力——这是”未登录词”(out-of-vocabulary)问题。实践中的做法是引入 <unk> 记号,或者用子词分词让任何字符串都能被表示(第 3 讲的 BPE)。把 fish 加进词表(9 个词)后,。
练习 2(困惑度的直觉)。 (a) 证明:如果模型对每个位置都给出 个词上的均匀分布,并且真实的下一个词总在这 个词里,那么困惑度是 。(b) 05 里 unigram 的困惑度是 25.42,而预测词表有 54 个词(56 个减去 <pad> 和 <s>)。为什么 unigram 的困惑度小于 54?(c) 困惑度从 2.856(6-gram)降到 2.540(RNN),换算成”每个词少付出的交叉熵”是多少 bit?
参考答案
(a) 每个词的概率都是 ,平均负对数似然 ,困惑度 。
(b) 均匀分布的困惑度才是 54。unigram 按词频分配概率:the 出现得极多(每句至少 1–2 次),句号和 </s> 每句各一次,而很多词(某些宾语)很少。概率分配越不均匀,困惑度越低。困惑度 = 有效分支数,unigram 平均”只在 25 个词之间犹豫”。
(c) bit/词。(用 nat 是 。)每个词少 0.17 bit 看起来很小,但测试句平均要做 11.6 次预测(10.6 个词加一个 </s>),一句就是约 1.96 bit,相当于把整句的概率平均提高了约 倍。
练习 3(参数量)。 (a) 验证我们的 RNN-LM 有 11,640 个参数(、、;nn.Linear 默认带偏置,W_hh 不带偏置)。(b) 如果词表有 50,000 个词、,参数量是多少?哪一部分占大头?(c) 同样的词表下,一个”完整”的 trigram 表最多有多少个条目?
参考答案
(a) 嵌入 ; 与 :;:;循环层合计 6208;输出层 。总计 。
(b) 嵌入 M;循环层 M;输出层 M。合计约 104.6M,嵌入和输出层占了 98%。所以大词表模型常把输出层和嵌入层绑定(共享同一个矩阵,weight tying),能省掉近一半参数。
(c) 个条目(每种两个词的上下文 × 每个下一个词)。实际中绝大多数为 0,只存非零的,但即使只存见过的,万亿词级别的语料也会产生数以十亿计的条目。
练习 4(softmax + 交叉熵的梯度)。 设 ,, 是 one-hot。(a) 证明 softmax 的雅可比矩阵是 。(b) 证明 。(c) 为什么实际代码里要把这两步合并?
参考答案
(a) ,。 时 ; 时 。合起来就是 ,矩阵形式 。
(b) (用了 )。更简单的推法:( 是正确类别),直接对 求导得 。
(c) 分开算时要先算 :正确类别的概率 很小时会得到很大的数,再乘上同样很小的雅可比元素,数值误差大, 下溢成 0 时直接除以 0。合并后的 没有除法,并且 可以用 log-sum-exp 技巧稳定地算(SoftmaxCrossEntropy)。
练习 5(有限差分的步长)。 对 在 处,用单侧差分 和中心差分 估计导数, 取 ,和真值 比较。分别在 float64 和 float32 下做。你会看到什么规律?
参考答案
误差由两部分组成:截断误差(泰勒展开被丢掉的项)随 减小而减小——单侧差分是 ,中心差分是 ;舍入误差约为 ,随 减小而增大。两者之和在某个中间的 最小。本机实测(和 的绝对误差):
| ε | float64 单侧 | float64 中心 | float32 单侧 | float32 中心 |
|---|---|---|---|---|
| 1e-1 | 4.3e-02 | 9.0e-04 | 4.3e-02 | 9.0e-04 |
| 1e-3 | 4.2e-04 | 9.0e-08 | 4.0e-04 | 1.4e-05 |
| 1e-5 | 4.2e-06 | 1.1e-11 | 3.9e-03 | 8.8e-04 |
| 1e-7 | 4.2e-08 | 1.9e-10 | 5.6e-02 | 5.6e-02 |
| 1e-9 | 5.3e-08 | 3.0e-09 | 5.4e-01 | 5.4e-01 |
| 1e-13 | 7.3e-04 | 1.8e-04 | 5.4e-01 | 5.4e-01 |
float64()下,单侧差分最好约 4e-8,中心差分在 附近最好,约 1e-11。float32()下,最好也只有 1e-5 左右; 时 在 float32 里就等于 1,差分恒为 0,误差就是导数本身(0.54)。这就是 numerical_gradient 默认用 float64 + 中心差分 + 的原因,也是梯度检查的相对误差在 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) 函数 的雅可比矩阵是 。用前向模式和反向模式分别要跑多少遍才能得到完整的雅可比矩阵?(b) 举一个前向模式更合适的例子。(c) 反向模式在内存上有什么代价?
参考答案
(a) 前向模式每遍得到一列(对一个输入的导数),要 遍;反向模式每遍得到一行(一个输出对所有输入的导数),要 遍。
(b) 输入少、输出多的函数,例如一个物理仿真器 (3 个参数控制 100 万个网格点的结果),想知道每个网格点对这 3 个参数的敏感度:前向模式 3 遍,反向模式要 100 万遍。另一个例子是”雅可比-向量积”(JVP):只需要 而不是整个 时,前向模式一遍就够。
(c) 反向模式必须把前向时的中间结果(激活值)全部存下来,等反向时使用(slides 的 NNForward 返回”intermediate quantities o”就是这个意思)。前向模式边算边丢,不需要存。这就是”训练比推理费显存得多”的原因之一;对超长序列或超深网络,人们用激活检查点(只存一部分,反向时重算其余的)来折中(第 17 讲)。
练习 7(RNN 的外推依赖什么?)。 修改训练数据的干扰名词个数分布,重新训练 RNN-LM(10 个 epoch),在”干扰名词全部和主语相反”的句子上测一致性。三种训练分布:(a) 只有 ;(b) 各一半;(c) 按 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=0 | k=1 | k=2 | k=3 | k=4 | k=5 | k=6 |
|---|---|---|---|---|---|---|---|
| (a) 只有 k=0 | 1.000 | 0.062 | 0.035 | 0.018 | 0.055 | 0.025 | 0.035 |
| (b) k ≤ 1 | 1.000 | 1.000 | 0.830 | 0.613 | 0.522 | 0.427 | 0.372 |
| (c) k ≤ 2 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
(a) 训练中从没出现过介词短语(near、behind、beside 的词向量还是随机初始化的值),RNN 的行为和 trigram 一模一样:跟着最近的名词走。在它学过的所有句子里,“紧挨在动词前面的名词就是主语”永远成立,它没有理由学出别的规则。
(b) 见过一层介词短语,就学会了 ,但没有学会”可以有任意多层”: 越大越差, 时低于抛硬币。
(c) 只要训练数据里出现过”介词短语接介词短语”,RNN 就外推到了 。
教训:神经网络的”泛化”强烈依赖训练数据覆盖了哪些模式。在 (c) 里它看起来”学会了规则”,但 (b) 说明这种规则是从数据里归纳出来的,不是先天就有的。这也是为什么第 2 讲要换更难的任务来比较不同架构。
练习 8(双向 RNN 能当语言模型吗?)。 双向 RNN 在每个位置同时看到过去和未来,听起来比单向 RNN 更强。(a) 用它来做”下一个词预测”的训练会发生什么?(b) 双向结构在什么任务上合适?
参考答案
(a) 如果在位置 用双向的隐藏状态预测 ,反向那个 RNN 已经读过 了——模型可以直接”偷看答案”,训练损失会迅速降到接近 0,但它学到的不是语言模型。更根本的是,生成时未来的词还不存在,反向 RNN 无从计算。所以生成式语言模型必须是因果的(causal):位置 只能依赖 。第 2 讲 Transformer 的”因果掩码”就是为了保证这一点。
(b) 输入整个给定、只需要理解而不需要生成的任务:序列标注(每个词的词性、命名实体)、整段语音的转写、句子分类。第 5 讲的 BERT 是 Transformer 版的”双向”:它用”掩码语言模型”(遮住一些词让模型填)代替”下一个词预测”,从而合法地同时使用左右上下文。
练习 9(共享参数的梯度)。 (a) 一个 RNN 在长度为 的序列上展开, 被用了几次?写出 用各时间步表示的公式。(b) 如果 tape 引擎里 Op.apply_bwd 把 node.out_gradient = node.out_gradient + g 写成了 node.out_gradient = g(覆盖而不是累加),03 第 3 部分里哪些参数的梯度会出错?嵌入矩阵 的梯度会变成什么样?先预测,再把 autodiff.Op.apply_bwd 换成覆盖版本跑一遍验证。
参考答案
(a) 次。,其中 是损失对第 步 tanh 输入的梯度,而 本身又要累加两个来源:第 步输出层的损失,以及第 步通过 传回来的梯度。
(b) 全部 6 个参数都会错(本机实测,和正确梯度的相对误差在 0.56 到 1.0 之间),没有一个”碰巧”正确:每个参数在每个时间步都被用到,覆盖之后只剩逆拓扑序里最后被处理的那一次(第 1 个时间步)的贡献;而且每个隐藏状态 有两个下游(第 步的输出层、第 步的循环),它的梯度也只剩一个来源,错误会一路传下去。最戏剧性的是 :查表模块每次返回一个整张表大小、只有一行非零的梯度矩阵,覆盖之后只剩第 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) 字节/参数——这就是 04 第 6 部分”约 16 字节/参数”的来源。
(b) 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 个,看看动词位置的注意力是不是真的落在了主语上。