语言模型与循环神经网络完全指南

面向 NLP 研究生的深度教材 · 改编自 Stanford CS224N Winter 2026 Lecture 4 (Diyi Yang)
覆盖:语言建模任务 · n-gram 与平滑 · Bengio 神经 LM · RNN 数学定义 · BPTT 推导 · Teacher Forcing · Perplexity · Pascanu 梯度消失/爆炸定理 · 梯度裁剪 · LSTM 与残差铺垫 · Seq2Seq 神经机器翻译 · 条件语言模型
版本 1.0 · 2026 春 · 配套姊妹篇 NLP 历史 · Transformer · Pretraining

引论:语言建模——通往现代 NLP 的钥匙

"如果你能把"预测下一个词"这件事做到极致,你就解决了几乎所有 NLP 问题。" — 这是 ChatGPT 之后越来越多人意识到的真相。

在 2018 年 OpenAI 那篇 GPT-1 论文以前,"语言模型 (Language Model, LM)" 这个词在 NLP 工程师心中常常只是统计机器翻译里那个用来给候选译文打分的小工具。 但短短七年后,GPT-4 / Claude / Gemini 都是语言模型——更确切地说,是自回归语言模型 (autoregressive LM)。 所有所谓的"会写代码、能解题、能对话"的能力,本质都浓缩在一个数学对象里:

语言模型的本质
$$ P_\theta\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)}, \boldsymbol{x}^{(t-1)}, \ldots, \boldsymbol{x}^{(1)}\right) $$

本课(CS224N Lecture 4)正是把这个对象第一次拿到台面上详细讲解。它不仅是后续 attention、Transformer、预训练、RLHF 的共同地基, 也是理解现代 LLM 行为(hallucination、长上下文、推理)必须的入门。

本教材的承诺
我们会沿着 1948 → 2000 → 2010 → 2014 → 2017 的历史时间线,从 Shannon 的信息论起步,到 n-gram、Bengio 神经 LM、RNN、LSTM、Seq2Seq, 每一步都讲清三件事:(1) 动机——上一步在哪里失败;(2) 数学——新方法的精确定义;(3) 代码——PyTorch 实现与训练细节。 读完本教材,你应该能从零实现一个 RNN-LM,并理解它为什么必然进化为 Transformer

本课在 CS224N 中的位置

前置课程
Lecture 1-3:词向量、神经网络基础、依存解析与命名实体识别
本课主线
Lecture 4:语言建模 + RNN + BPTT + 梯度病理 + Seq2Seq
后续课程
Lecture 5:Attention 与 Transformer ← 本课的直接动机
作业映射
Assignment 2:RNN 语言模型实现;Assignment 4:Seq2Seq + Attention NMT

本教材的逻辑骨架

flowchart TD A["第一部分
语言建模任务定义"] --> B["第二部分
n-gram LM
(统计方法)"] B -->|稀疏性 + 无泛化| C["第三部分
定窗口神经 LM
(Bengio 2003)"] C -->|窗口过小| D["第四部分
RNN
(变长输入)"] D --> E["第五部分
训练 RNN-LM
(BPTT)"] E --> F["第六部分
生成 & Perplexity"] F --> G["第七部分
梯度消失/爆炸
(RNN 的根本病)"] G -->|LSTM 是创可贴| H["第八部分
Seq2Seq NMT
(瓶颈问题)"] H -->|引出 Attention| I["下一课
Transformer"] style A fill:#e8f1f4 style B fill:#fff7e6 style C fill:#fdf6e9 style D fill:#eafaf0 style E fill:#eafaf0 style F fill:#eafaf0 style G fill:#fbecec style H fill:#eaf3fb style I fill:#f4f1e8

注意几条"演化驱动"的虚线:每一步都不是凭空冒出来的新模型,而是上一步的具体失败模式所逼出来的。这种"沿着失败前进"的学习方式,比单独背诵每个模型更牢固。

第一部分:语言建模任务

1.1 形式化定义:从填空到联合概率

最直白的定义:语言建模 = 预测下一个词的概率分布。 给定一段已知文本,例如 "the students opened their ___",模型需要为词表中每一个可能的词输出一个概率: "books" 0.21、"laptops" 0.18、"minds" 0.07、"exams" 0.06、"refrigerator" 0.00003、…,所有概率之和等于 1。

the students opened their ___ LM books (0.21) laptops (0.18) minds (0.07) exams (0.06) refrigerator (0.00003) …词表中其余 |V|−5 个词
图 1.1:语言模型输出的是整个词表上的概率分布,而不是单个预测词。这一点决定了它和"分类器"在数学上的相似性。

更形式地,给定词序列 $\boldsymbol{x}^{(1)}, \boldsymbol{x}^{(2)}, \ldots, \boldsymbol{x}^{(t)}$,语言模型计算:

语言模型的核心条件分布
$$ P\!\left(\boldsymbol{x}^{(t+1)} \,\big|\, \boldsymbol{x}^{(t)}, \boldsymbol{x}^{(t-1)}, \ldots, \boldsymbol{x}^{(1)}\right), \qquad \boldsymbol{x}^{(t+1)} \in V = \{w_1, w_2, \ldots, w_{|V|}\} $$

这里 $V$ 是词表 (vocabulary),$|V|$ 通常在 $10^4$(小型)到 $10^5$(GPT-2 是 50,257)甚至 $10^6$(多语言模型)之间。 注意几个细节:

1.2 链式法则与自回归分解

有了"下一个词"的条件概率,我们就能用概率乘法链式法则 (chain rule) 计算整段文本的概率:

联合概率的自回归分解
$$ \begin{aligned} P(\boldsymbol{x}^{(1)}, \ldots, \boldsymbol{x}^{(T)}) &= P(\boldsymbol{x}^{(1)}) \cdot P(\boldsymbol{x}^{(2)} \mid \boldsymbol{x}^{(1)}) \cdot \ldots \cdot P(\boldsymbol{x}^{(T)} \mid \boldsymbol{x}^{(T-1)}, \ldots, \boldsymbol{x}^{(1)}) \\[4pt] &= \prod_{t=1}^{T} P\!\left(\boldsymbol{x}^{(t)} \mid \boldsymbol{x}^{(t-1)}, \ldots, \boldsymbol{x}^{(1)}\right) \end{aligned} $$

这个分解之所以叫"自回归 (autoregressive)",是因为每一步预测都依赖前面所有时刻已知的输出,像把一个长问题拆成 $T$ 个串行的小问题。 这也意味着推断(生成文本)天然是顺序的,无法像 BERT 那样并行——这正是现代 LLM 推断慢的根本原因,也是 KV-cache、speculative decoding 等优化技术的出发点。

关键观察:LM 既是"判别器"也是"生成器"
  • 作为生成器:训练好 $P(\boldsymbol{x}^{(t+1)} \mid \cdot)$ 后,逐步采样就能生成新文本。
  • 作为判别器/打分器:给定任意一段文本,用上式计算其概率,可以判断它"像不像人话"。这就是为什么 LM 一直是机器翻译、语音识别的核心组件(给候选输出打分)。
这种对称性(一个模型同时具备评分能力和生成能力)是 LM 在 NLP 中地位特殊的根本原因。

1.3 为什么 LM 是 NLP 的"基准任务"

Diyi Yang 在课程里说:"Language Modeling is the most important concept in this class. It leads to most of modern NLP." 这句话不是夸张,而是基于两个事实:

(1) LM 是 NLP 任务的底层组件

下游任务为什么需要 LM
输入法预测/手机自动补全直接输出 $P(\boldsymbol{x}^{(t+1)}\mid \text{已输入})$,挑前 3 个候选
语音识别声学模型给出多个候选转录,LM 打分挑出"最像人话"的
手写识别 / OCR同上:多个候选 → LM 重排
拼写/语法纠错"He are tall" 与 "He is tall" 谁的概率高?
统计机器翻译 (SMT)翻译模型 + LM 联合打分(IBM 模型时代的经典分解)
文档摘要 / 对话生成生成式输出本身就是从 LM 采样
作者识别每个候选作者训练一个 LM,看测试文本在哪个 LM 下概率最高

(2) LM 是 NLP 进步的基准 (benchmark)

LM 的损失(cross-entropy / perplexity)本身就是衡量模型有多懂语言的客观指标,不需要任务特定的标注。 这意味着任何研究团队都能用同样的 corpus(如 WikiText-103、The Pile)对比自己的方法。整个 2010s 的神经 LM 论文都在比较这一个数字。

"Everything is LM" — 2018 之后的范式
GPT 系列论文揭示:把模型做大、把数据做多、把单一目标(next-token prediction)训练到极致,几乎所有下游任务可以通过 prompting 自然涌现。 ChatGPT 本质上仍然只是 $\arg\max P(\text{下一 token} \mid \text{对话历史})$ 的一个 RLHF 微调版本。

1.4 强 LM 可以做什么:从语法到推理

为了感受"足够强的 LM"能做什么,看几个填空题——每一题都隐含着不同层级的语言学/世界知识:

填空所需知识层级
Stanford University is located in ___, California.百科事实 Trivia
I put ___ fork down on the table.语法 Syntax (need "the"/"my")
The woman walked across the street, checking for traffic over ___ shoulder.指代消解 Coreference (her)
I went to the ocean to see the fish, turtles, seals, and ___.词汇语义/主题 Lexical semantics
Overall, the value I got from the two hours was the sum of popcorn and drink. The movie was ___.情感推理 Sentiment
Iroh went into the kitchen. Standing next to Iroh, Zuko pondered his destiny. Zuko left the ___.常识推理 Reasoning (kitchen)
I was thinking about the sequence 1, 1, 2, 3, 5, 8, 13, 21, ___数学 Arithmetic (Fibonacci)

注意一个事实:所有这些任务,从语法填空到斐波那契数列,都可以被表述成"预测下一个词"。 所以一个理想的 LM——只要它真的能把所有 next-token 概率算准——就必须隐含语法、语义、世界知识、常识推理、甚至数学能力。 这就是 Sutskever 的著名论断:"Just predicting the next token is enough." 当然,"enough" 的代价是巨大的数据和算力。

第二部分:n-gram 语言模型

在 2003 年 Bengio 提出神经语言模型以前,从 1980s 到 2000s 主导 NLP 的 LM 范式叫做 n-gram language model—— 完全基于计数和统计,没有任何"学习"过程(没有梯度,没有参数优化)。理解它对于理解神经 LM 的动机至关重要: 神经 LM 的每一处改进,几乎都是针对 n-gram 的具体缺陷。

2.1 Markov 假设与最大似然估计

n-gram LM 的核心是一个简化假设:下一个词只依赖于前面 $n-1$ 个词,而非全部历史。这就是经典的 $(n-1)$ 阶 Markov 假设

Markov 假设
$$ P\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(1)}\right) \;\approx\; P\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right) $$

让我们先约定术语:

名称定义例子:"the students opened their"
unigram1 个连续词"the", "students", "opened", "their"
bigram2 个连续词"the students", "students opened", "opened their"
trigram3 个连续词"the students opened", "students opened their"
4-gram4 个连续词"the students opened their"

2.2 完整推导:从条件概率到计数比

应用条件概率定义 $P(A\mid B) = P(A,B)/P(B)$:

n-gram 条件概率分解
$$ P\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right) = \frac{P\!\left(\boldsymbol{x}^{(t+1)}, \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right)}{P\!\left(\boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right)} $$

分子是 n-gram 的概率,分母是 $(n-1)$-gram 的概率。问题转化为:如何估计这两个联合概率?

最直接的答案:最大似然估计 (Maximum Likelihood Estimation, MLE)——在一个大语料库里数频次!

n-gram 统计估计
$$ P\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right) \approx \frac{\mathrm{count}\!\left(\boldsymbol{x}^{(t+1)}, \boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right)}{\mathrm{count}\!\left(\boldsymbol{x}^{(t)}, \ldots, \boldsymbol{x}^{(t-n+2)}\right)} $$

例子:4-gram LM 估算 P(? | "students opened their")

假设我们的语料是 "as the proctor started the clock, the students opened their ___"。在 4-gram 假设下, 模型只看 "students opened their",前面的 "as the proctor started the clock," 全部丢弃——这是 Markov 假设的代价。

as the proctor started the clock, ↑ 4-gram 丢弃这段上下文 (discard) students opened their ___ ↑ 仅条件依赖这 3 个词 语料统计: count("students opened their") = 1000 count("students opened their books") = 400 → P(books|·) = 0.40 count("students opened their exams") = 100 → P(exams|·) = 0.10
图 2.1:4-gram MLE 计算。注意被丢弃的 "proctor" 其实是预测 "exams" vs "books" 的关键线索——这是 Markov 假设的固有缺陷。
直觉警告:是否应该丢弃"proctor"?
"proctor (监考人)" 出现意味着这是一个考试场景,应该让 "exams" 的概率高于 "books"。 但 4-gram LM 只看最后 3 个词,无法捕捉这个信号。这正是"上下文长度不够"问题——RNN 的设计目标之一就是修复它。

2.3 稀疏性问题:平滑与回退

n-gram LM 有两个致命的稀疏性问题:

稀疏性问题 1:分子为零

如果某个 4-gram "students opened their petunias" 在语料中从未出现,则 count = 0,模型直接判定 $P(\text{petunias} \mid \cdot) = 0$。 但这显然不对——"petunias (矮牵牛花)" 罕见但不是不可能。

解决方案:加性平滑 (additive / Laplace smoothing)。给每个词的计数都加一个小常数 $\delta$(典型 $\delta=1$ 或 $\delta=0.01$):

Laplace 平滑
$$ P_{\mathrm{Laplace}}(w \mid \mathrm{context}) = \frac{\mathrm{count}(\mathrm{context}, w) + \delta}{\mathrm{count}(\mathrm{context}) + \delta \cdot |V|} $$

分母里加 $\delta\cdot|V|$ 保证概率仍然归一化(因为我们对 $|V|$ 个词都做了加 $\delta$)。这种朴素方法在小词表上能用,但把概率质量从高频词偷给低频词的力度太粗暴。 现代 n-gram 系统采用更精细的方案:

稀疏性问题 2:分母也为零

如果连 "students opened their" 这个 trigram 在语料中都没出现,那分母也是 0,整个表达式没有意义。

解决方案:回退 (backoff)。当 $n$-gram 不可用时,退到 $(n-1)$-gram。比如 4-gram "students opened their _" 不够用,就退到 trigram "opened their _"; 还不够,再退到 bigram "their _"。系统化的实现叫 Stupid Backoff (Brants 2007) 或者上面提到的 KN 的迭代版本。

为什么 n 不能无限增大
n 越大,稀疏性越严重。词表 $|V|=10^4$ 时,理论上 5-gram 的可能组合有 $10^{20}$ 个,但任何语料库都只覆盖其中一小部分。 所以传统 n-gram LM 几乎从不超过 $n=5$。这是统计计数方法的本质天花板。

2.4 存储问题:n 越大越糟

除了稀疏性,n-gram LM 还需要把语料中出现过的所有 n-gram 计数全部存起来。 对于 trigram + 1.7M 词的 Reuters 语料,模型大小约几百 MB;但如果跨到 5-gram、上万亿词的 Web 语料(Google 2006 的 Web 5-gram 数据集解压后 ~24 GB),存储就成为工程瓶颈。 更大的问题是:语料越大,模型越大;模型大小线性甚至超线性增长——这与神经 LM "数据再多模型大小不变" 形成鲜明对比。

2.5 Python 实战:用 trigram LM 生成文本

让我们用代码感受 n-gram LM 的能力极限。下面是一个 50 行的 trigram LM,训练在 Reuters 商业新闻语料上:

from collections import defaultdict, Counter
import random
import nltk
from nltk.corpus import reuters

nltk.download('reuters', quiet=True)
nltk.download('punkt', quiet=True)

# 1. 收集所有 trigram 计数
tri_counts = defaultdict(Counter)   # context (w1,w2) -> {w3: count}
for sent in reuters.sents():
    tokens = ["<s>", "<s>"] + [w.lower() for w in sent] + ["</s>"]
    for w1, w2, w3 in zip(tokens, tokens[1:], tokens[2:]):
        tri_counts[(w1, w2)][w3] += 1

# 2. 从 trigram 计数得到条件概率(无平滑版)
def next_word_dist(context):
    counts = tri_counts[context]
    total = sum(counts.values())
    return {w: c/total for w, c in counts.items()}

# 3. 自回归采样生成
def generate(max_len=40, seed=("<s>", "<s>")):
    out, context = [], seed
    for _ in range(max_len):
        dist = next_word_dist(context)
        if not dist: break
        words, probs = zip(*dist.items())
        nxt = random.choices(words, weights=probs)[0]
        if nxt == "</s>": break
        out.append(nxt)
        context = (context[1], nxt)
    return " ".join(out)

print(generate(seed=("today", "the")))
# 可能输出:
# today the price of gold per ton, while production of shoe
# lasts and shoe industry, the bank intervened just after it
# considered and rejected an imf demand to rebuild depleted
# european stocks, sept 30 end primary 76 cts a share.
观察输出
  • 语法上几乎是对的:每个相邻 3 词组合都是真实语料里见过的,所以局部读起来通顺。
  • 但不连贯:从 "today the price of gold" 到 "primary 76 cts a share" 完全跳跃,因为 trigram 只记得最近 2 个词。
  • 展示了 Markov 假设的根本局限:连贯性需要 long-range coherence,但 n-gram 在结构上做不到。

2.6 n-gram 的根本局限

总结 n-gram LM 的四宗罪:

罪 1:稀疏性
大多数合法 n-gram 在语料中频次 = 0;smoothing/backoff 只能缓解
罪 2:上下文短
典型 $n \le 5$,无法捕捉超过 4 个词的依赖("proctor … exams" 完全丢失)
罪 3:存储爆炸
模型大小随语料线性增长;超大语料部署困难
罪 4:无泛化
"the cat sat on the mat" 训练后,对 "a feline rested on the rug" 没有任何泛化

第 4 条尤其重要。n-gram LM 把每个词当作原子符号,不知道 "cat" 和 "feline" 在意义上相似。这一缺陷只能由分布式表示 (distributed representations)——也就是词向量——来根本解决。 这正是 Bengio 2003 神经 LM 的核心贡献,下一节详谈。

第三部分:神经语言模型的诞生

3.1 定窗口神经 LM 架构

回想 Lecture 3 讲的 window-based neural classifier(用于命名实体识别): 取一个固定大小的词窗口(比如 5 个词),把每个词的 embedding 拼起来,过一个隐藏层,最后输出类别。

Bengio 等人 (2000, 2003) 的洞见非常简单:把这个分类器的输出层从"实体类别"换成"词表上的概率分布",就得到了第一个神经语言模型

the students opened their x⁽¹⁾, x⁽²⁾, x⁽³⁾, x⁽⁴⁾ (one-hot vectors) e⁽¹⁾ e⁽²⁾ e⁽³⁾ e⁽⁴⁾ e = [e⁽¹⁾; e⁽²⁾; e⁽³⁾; e⁽⁴⁾] (concat) h = f(W·e + b₁) ŷ = softmax(U·h + b₂) ∈ ℝ^|V| E (embedding lookup) W ∈ ℝ^(d_h × 4·d_e) U ∈ ℝ^(|V| × d_h)
图 3.1:Bengio 风格的定窗口神经 LM。固定 4 个词 → 各自 embedding → 拼接 → 隐藏层 → softmax 输出 |V| 维概率。
定窗口神经 LM 三层公式
$$ \begin{aligned} \boldsymbol{e}^{(i)} &= \boldsymbol{E}\boldsymbol{x}^{(i)} \quad &&\text{(embedding lookup, } \boldsymbol{E}\in\mathbb{R}^{d_e\times |V|}) \\ \boldsymbol{e} &= [\boldsymbol{e}^{(1)}; \boldsymbol{e}^{(2)}; \boldsymbol{e}^{(3)}; \boldsymbol{e}^{(4)}] \quad &&\text{(concat, dim} = 4 d_e) \\ \boldsymbol{h} &= f(\boldsymbol{W}\boldsymbol{e} + \boldsymbol{b}_1) \quad &&\text{(} \boldsymbol{W}\in\mathbb{R}^{d_h\times 4d_e}\text{, } f = \tanh) \\ \hat{\boldsymbol{y}} &= \mathrm{softmax}(\boldsymbol{U}\boldsymbol{h} + \boldsymbol{b}_2) \quad &&\text{(} \boldsymbol{U}\in\mathbb{R}^{|V|\times d_h}\text{)} \end{aligned} $$

3.2 Bengio 2003:分布式表示破解稀疏性

Bengio et al. (JMLR 2003) 在论文 "A Neural Probabilistic Language Model" 里指出了三个突破:

  1. 无稀疏性:模型不再依赖具体的 n-gram 计数;只要 embedding 矩阵 $\boldsymbol{E}$ 训得好,任意上下文都能输出概率分布。
  2. 不需要存所有 n-gram:模型参数量 = $|V|\cdot d_e + 4d_e\cdot d_h + d_h\cdot |V|$,是固定的,不随语料增长。
  3. 词义泛化:相似词学到相似 embedding。看见 "the cat is on the ___" 后预测 "mat",自动对 "the feline is on the ___" 也能给 "rug" 高概率(即使 "feline rug" 这个 4-gram 从未见过)。这是分布式表示的本质优势。
历史小注:为何 2003 论文沉睡了 10 年?
Bengio 2003 思想完全正确,但当时算力不够(GPU 还没流行,Bengio 用 40 个 CPU core 训练了 3 周才得到结果)。 直到 2010 年 Mikolov 用 RNN-LM 在 perplexity 上击败 KN 平滑的 5-gram,神经 LM 才真正主流化。然后 2013 Word2Vec 的工程化让 embedding 进入每个工程师的工具箱。

3.3 残留病灶:窗口与非对称性

定窗口神经 LM 解决了 n-gram 的稀疏性,但没有解决上下文长度问题,还引入了新的问题:

病灶 1:窗口固定
仍然只能看 $n-1$ 个词。把窗口从 4 增加到 50?参数矩阵 $\boldsymbol{W}$ 也要从 $d_h \times 4d_e$ 增到 $d_h \times 50 d_e$,参数爆炸
病灶 2:参数浪费
$\boldsymbol{x}^{(1)}$ 和 $\boldsymbol{x}^{(2)}$ 走的是 $\boldsymbol{W}$ 中完全不同的列。换句话说,模型对"the 在窗口位置 1"和"the 在窗口位置 2"分别学了两个表示,没有位置不变性
病灶 3:窗口永远不够大
真实语言中的长距离依赖(指代消解、主谓一致跨几十词)需要任意长上下文。定窗口在结构上做不到。
我们需要的架构
(1) 能处理任意长输入;(2) 对所有位置的输入对称处理;(3) 参数量与上下文长度无关。
这三个要求把我们直接推向了下一节的主角:Recurrent Neural Network (RNN)

第四部分:循环神经网络 (RNN)

4.1 核心思想:同一组权重的无限循环

RNN 的核心 idea 一句话:在每个时刻应用同一个权重矩阵 $\boldsymbol{W}$,把前一时刻的"记忆"和当前输入合成新的"记忆"

这就解决了上一节的三个病灶:

h⁽⁰⁾ h⁽¹⁾ h⁽²⁾ h⁽³⁾ h⁽⁴⁾ W_h W_h W_h W_h W_h x⁽¹⁾ x⁽²⁾ x⁽³⁾ x⁽⁴⁾ ŷ⁽¹⁾ ŷ⁽²⁾ ŷ⁽³⁾ ŷ⁽⁴⁾ 输出 (optional) 隐藏状态 输入序列 (任意长)
图 4.1:RNN 的展开 (unrolled) 视图。同一个 $\boldsymbol{W}_h$ 在每个时间步重复应用——这是 RNN 区别于前馈网络的唯一也是最重要的特征。

4.2 RNN 语言模型数学定义

RNN-LM 的前向传播由 4 个公式定义:

RNN 语言模型完整定义
$$ \begin{aligned} \text{(1) embedding} \quad & \boldsymbol{e}^{(t)} = \boldsymbol{E}\boldsymbol{x}^{(t)} \\[2pt] \text{(2) 隐藏状态} \quad & \boldsymbol{h}^{(t)} = \sigma\!\left(\boldsymbol{W}_h\boldsymbol{h}^{(t-1)} + \boldsymbol{W}_e\boldsymbol{e}^{(t)} + \boldsymbol{b}_1\right) \\[2pt] \text{(3) 输出 logits} \quad & \boldsymbol{z}^{(t)} = \boldsymbol{U}\boldsymbol{h}^{(t)} + \boldsymbol{b}_2 \\[2pt] \text{(4) 输出分布} \quad & \hat{\boldsymbol{y}}^{(t)} = \mathrm{softmax}(\boldsymbol{z}^{(t)}) \in \mathbb{R}^{|V|} \end{aligned} $$

其中 $\sigma$ 通常是 $\tanh$(取值 $[-1,1]$,零中心)。初始隐藏状态 $\boldsymbol{h}^{(0)}$ 可以设为零向量或者一个可学习参数。

$\boldsymbol{E} \in \mathbb{R}^{d_e\times|V|}$
词嵌入矩阵,$d_e$ 典型 100~300
$\boldsymbol{W}_e \in \mathbb{R}^{d_h\times d_e}$
把 embedding 投到隐藏空间
$\boldsymbol{W}_h \in \mathbb{R}^{d_h\times d_h}$
关键:循环矩阵,时序传递记忆
$\boldsymbol{U} \in \mathbb{R}^{|V|\times d_h}$
把隐藏向量投到词表大小
$d_h$
隐藏维度,典型 256~1024

注意:参数集合 $\{\boldsymbol{E}, \boldsymbol{W}_e, \boldsymbol{W}_h, \boldsymbol{U}, \boldsymbol{b}_1, \boldsymbol{b}_2\}$ 不随序列长度 $T$ 增长——这是 RNN 相对于定窗口 LM 的关键胜利。

4.3 RNN 的五大优点与两大缺点

优点 ✓说明
处理任意长输入循环结构,$T$ 多大都不变模型大小
原理上可看到所有历史$\boldsymbol{h}^{(t)}$ 是 $\boldsymbol{x}^{(1)}, \ldots, \boldsymbol{x}^{(t)}$ 的函数
模型大小恒定仅与 $d_h, d_e, |V|$ 有关,与上下文长度无关
输入处理对称所有时间步用同一个 $\boldsymbol{W}_h$,无位置偏倚
可端到端训练SGD + BPTT,全程可微
缺点 ✗说明
计算慢(顺序依赖)$\boldsymbol{h}^{(t)}$ 依赖 $\boldsymbol{h}^{(t-1)}$,无法跨时间并行——GPU 利用率低
难以记住远距离信息梯度消失/爆炸,原理上能看到却实际学不到——下一章重点
这两个缺点正是 Transformer 的诞生动机
"慢" → Transformer 用 self-attention 实现了时间并行
"远距离学不到" → Transformer 的 attention 提供了O(1) 路径到任意位置。
这正是 Lecture 5 的核心。

4.4 PyTorch 实现:从零写一个 RNN-LM

理解了数学,写代码就是直译。下面是一个最简版本(不用 nn.RNN 黑盒):

import torch
import torch.nn as nn

class VanillaRNNLM(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256):
        super().__init__()
        self.embed   = nn.Embedding(vocab_size, embed_dim)
        self.W_e     = nn.Linear(embed_dim,  hidden_dim, bias=False)
        self.W_h     = nn.Linear(hidden_dim, hidden_dim, bias=True)  # bias 合并在此
        self.U       = nn.Linear(hidden_dim, vocab_size, bias=True)
        self.tanh    = nn.Tanh()
        self.hidden_dim = hidden_dim

    def forward(self, x, h0=None):
        """
        x: (B, T) long tensor, token ids
        h0: (B, H) initial hidden state, or None
        returns: logits (B, T, V)
        """
        B, T = x.shape
        h = h0 if h0 is not None else x.new_zeros(B, self.hidden_dim, dtype=torch.float)
        e = self.embed(x)                       # (B, T, d_e)
        logits = []
        for t in range(T):                       # ← 顺序循环,无法并行
            h = self.tanh(self.W_e(e[:, t]) + self.W_h(h))   # (B, H)
            logits.append(self.U(h))            # (B, V)
        return torch.stack(logits, dim=1)       # (B, T, V)
为什么生产中很少手写 for loop
PyTorch 提供 nn.RNN / nn.LSTM / nn.GRU,底层用 cuDNN 实现的融合 CUDA kernel,比 Python for 循环快 10–50 倍。 手写仅用于教学和需要奇异变体的研究场景。即便如此,仍然是时间串行——这是 RNN 的本质瓶颈。

第五部分:训练 RNN 语言模型

5.1 交叉熵损失:每一步都是分类

训练 RNN-LM 的目标:让模型预测出的下一个词分布 $\hat{\boldsymbol{y}}^{(t)}$ 尽可能接近真实下一个词的 one-hot 分布 $\boldsymbol{y}^{(t)}$。 形式化即最小化交叉熵

单步交叉熵损失
$$ J^{(t)}(\theta) = \mathrm{CE}\!\left(\boldsymbol{y}^{(t)}, \hat{\boldsymbol{y}}^{(t)}\right) = -\sum_{w\in V} y_w^{(t)} \log \hat{y}_w^{(t)} = -\log \hat{y}^{(t)}_{\boldsymbol{x}^{(t+1)}} $$

由于 $\boldsymbol{y}^{(t)}$ 是 one-hot(只在真实下一词位置为 1),求和坍缩为负对数似然 (NLL):模型给"正确下一词" 分配的概率越高,损失越小。

全序列损失是对所有时刻求平均:

序列层面损失
$$ J(\theta) = \frac{1}{T}\sum_{t=1}^{T} J^{(t)}(\theta) = -\frac{1}{T}\sum_{t=1}^{T} \log \hat{y}^{(t)}_{\boldsymbol{x}^{(t+1)}} $$
the students opened their exams Corpus → x⁽¹⁾ x⁽²⁾ x⁽³⁾ x⁽⁴⁾ target⁽⁴⁾ h⁽¹⁾ h⁽²⁾ h⁽³⁾ h⁽⁴⁾ h⁽⁰⁾ ŷ⁽¹⁾ ŷ⁽²⁾ ŷ⁽³⁾ ŷ⁽⁴⁾ J⁽¹⁾(θ) J⁽²⁾(θ) J⁽³⁾(θ) J⁽⁴⁾(θ) = −log ŷ⁽¹⁾[students] 所有 J⁽ᵗ⁾ 平均 → J(θ)
图 5.1:RNN-LM 训练流程。每个时刻 t 都计算"预测下一词 vs 真实下一词"的交叉熵 $J^{(t)}$,最后求平均。

5.2 Teacher Forcing:训练与推断不一致

注意上图的一个关键细节:训练时第 $t$ 步输入的是真实的 $\boldsymbol{x}^{(t)}$(即语料中真实出现的词),而不是模型上一步预测的 $\hat{\boldsymbol{x}}^{(t)}$。 这种做法叫做 Teacher Forcing——老师在每一步把"正确答案"塞回模型,强迫它在已知正确历史上学习。

优点
训练稳定、收敛快;每一步的损失彼此独立可并行计算(虽然前向仍然顺序)。
缺点 (exposure bias)
推断时模型必须自己消费自己的输出,但训练时它只见过"完美历史"——分布偏移导致一旦犯错就滚雪球
缓解 exposure bias 的研究
  • Scheduled Sampling (Bengio 2015): 训练中以一定概率把 ground truth 换成模型自己的预测,缓慢过渡。
  • Sequence-level training (MIXER, Ranzato 2016): 用强化学习直接优化 BLEU 等序列级目标。
  • DAgger 风格的逐步暴露 (Ross 2011)。
现代 LLM 大多仍然用 Teacher Forcing 训练,靠规模来抑制 exposure bias 的负面影响。RLHF 阶段才会真正消费模型自己的 rollouts。

5.3 SGD 与批量化:句子为单位的小批量

理论上 $J(\theta) = \frac{1}{T}\sum_t J^{(t)}$ 是对整个语料 $T$ 求平均,但语料动辄数十亿 token,一次性算完损失和梯度内存爆炸。 实际做法:

  1. 把语料切成句子(或固定长度的段,比如 512 tokens)。
  2. 组成 mini-batch(B 个句子并行)。
  3. 对每个 batch 计算 $J(\theta)$,反向传播算梯度,SGD 更新。
  4. 重复 epoch。

PyTorch 代码骨架:

model = VanillaRNNLM(vocab_size).cuda()
opt   = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(NUM_EPOCHS):
    for x_batch in loader:                     # x_batch: (B, T)
        # 用前 T-1 个 token 预测后 T-1 个 token(teacher forcing)
        inp, tgt = x_batch[:, :-1], x_batch[:, 1:]
        logits = model(inp.cuda())             # (B, T-1, V)
        loss = loss_fn(logits.reshape(-1, vocab_size),
                       tgt.cuda().reshape(-1))
        opt.zero_grad()
        loss.backward()                        # ← BPTT 自动展开
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)  # 见第七部分
        opt.step()

5.4 多变量链式法则速成

为了讲清 BPTT,先回顾多变量微积分。给定 $f(x, y)$,且 $x = x(t), y = y(t)$ 都是 $t$ 的函数,则复合函数的导数:

多变量链式法则
$$ \frac{d}{dt}f\!\big(x(t), y(t)\big) = \frac{\partial f}{\partial x}\frac{dx}{dt} + \frac{\partial f}{\partial y}\frac{dy}{dt} $$

直觉:$t$ 通过 $x$ 影响 $f$ 的一份,加上通过 $y$ 影响 $f$ 的另一份。

f(x(t), y(t)) x(t) y(t) t 两条路径,梯度求和
图 5.2:链式法则的菱形结构。一个变量 $t$ 影响多个中间变量,再共同影响最终输出——所有路径上的梯度相加

对计算图,这条规则的推广是:"梯度在出度分支处相加 (Gradients sum at outward branches)"。 即:如果某个变量被下游多个节点使用,反向传播时来自各下游的梯度要全部加起来。

5.5 反向传播穿越时间 (BPTT) 完整推导

现在回到 RNN。我们关心一个看似奇怪的问题:损失 $J^{(t)}(\theta)$ 对重复使用的权重 $\boldsymbol{W}_h$ 的梯度是什么?

关键洞察是:把 $\boldsymbol{W}_h$ 看作在每一步 $i=1,\ldots,t$ 都"复制了一份" $\boldsymbol{W}_h\big|_{(i)}$, 然后对每一份的梯度求和,最后再利用 $\frac{\partial \boldsymbol{W}_h|_{(i)}}{\partial \boldsymbol{W}_h} = \boldsymbol{I}$ (即一份份其实就是同一个)。

BPTT 核心公式
$$ \boxed{\;\;\frac{\partial J^{(t)}}{\partial \boldsymbol{W}_h} = \sum_{i=1}^{t} \left.\frac{\partial J^{(t)}}{\partial \boldsymbol{W}_h}\right|_{(i)}\;\;} $$ 文字描述:对重复使用的权重求梯度,等于在每个使用时刻分别求梯度,然后全部求和
h⁽⁰⁾ h⁽ᵗ⁻³⁾ h⁽ᵗ⁻²⁾ h⁽ᵗ⁻¹⁾ h⁽ᵗ⁾ J⁽ᵗ⁾(θ) W_h W_h W_h W_h ∂J⁽ᵗ⁾/∂W_h = Σᵢ₌₁ᵗ ∂J⁽ᵗ⁾/∂W_h |₍ᵢ₎ i = t, t-1, …, 1:从时间末端往回累加梯度
图 5.3:BPTT 沿时间反向累积梯度。每经过一个时刻,就把当前梯度对 $\boldsymbol{W}_h$ 的贡献加进总和。

详细推导:单步 BPTT 怎么算

为了简化,记 $\boldsymbol{z}^{(t)} = \boldsymbol{W}_h \boldsymbol{h}^{(t-1)} + \boldsymbol{W}_e \boldsymbol{e}^{(t)}$,所以 $\boldsymbol{h}^{(t)} = \tanh(\boldsymbol{z}^{(t)})$。

1损失对最终隐状态的梯度: $$ \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(t)}} = \boldsymbol{U}^\top (\hat{\boldsymbol{y}}^{(t)} - \boldsymbol{y}^{(t)}) $$ (标准 softmax + cross-entropy 的简化形式。)
2沿时间反传到 $\boldsymbol{h}^{(i)}$:用链式法则递归 $$ \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(i)}} = \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(i+1)}} \cdot \frac{\partial \boldsymbol{h}^{(i+1)}}{\partial \boldsymbol{h}^{(i)}} $$ 其中 $$ \frac{\partial \boldsymbol{h}^{(i+1)}}{\partial \boldsymbol{h}^{(i)}} = \mathrm{diag}\!\big(\tanh'(\boldsymbol{z}^{(i+1)})\big) \cdot \boldsymbol{W}_h $$ 注意 $\tanh'(z) = 1 - \tanh^2(z) \le 1$,这是梯度消失的种子(第七部分详述)。
3每个时刻对 $\boldsymbol{W}_h$ 的贡献: $$ \left.\frac{\partial J^{(t)}}{\partial \boldsymbol{W}_h}\right|_{(i)} = \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(i)}} \cdot \mathrm{diag}\!\big(\tanh'(\boldsymbol{z}^{(i)})\big) \cdot \big(\boldsymbol{h}^{(i-1)}\big)^\top $$
4全部累加: $$ \frac{\partial J^{(t)}}{\partial \boldsymbol{W}_h} = \sum_{i=1}^{t} \left.\frac{\partial J^{(t)}}{\partial \boldsymbol{W}_h}\right|_{(i)} $$
PyTorch 用户的福利
上面这些推导,PyTorch 的 autogradloss.backward() 一行就自动完成。 但理解推导是必要的——因为梯度消失/爆炸正是从步骤 2 的连乘里诞生的,光会调 API 你无法诊断这些问题。

5.6 截断 BPTT:实践中的妥协

完整 BPTT 要把整个序列的计算图保存在显存里,对于长序列(如一本小说的几千 tokens)会爆显存。 工程实践用 Truncated BPTT (TBPTT):每隔 $k$ 步切断反向梯度。

典型 $k = 20\sim50$。前向继续维护 $\boldsymbol{h}^{(t)}$,但反向只在最近 $k$ 步内传播。

# Truncated BPTT 模式
hidden = None
for chunk in long_sequence_chunks(text, chunk_size=35):  # 每 35 tokens 一段
    logits, hidden = model(chunk, hidden)
    loss = loss_fn(logits, chunk_targets)
    loss.backward()
    opt.step()
    hidden = hidden.detach()      # ← 关键:切断对前面 chunk 的反传依赖
    opt.zero_grad()
截断的副作用
模型再也无法学到跨越 $k$ 步的依赖关系。如果文档的关键信息距离当前位置 $> k$,模型就丢了。 这也是 RNN 时代"长文本理解"差的另一个原因——除了梯度消失,截断 BPTT 本身就是工程上的硬限制。

第六部分:用 RNN-LM 生成文本与评估

6.1 自回归采样:Roll-out 生成

训练完 RNN-LM 后,怎么用它写新文本?流程叫 autoregressive rollout

  1. 给定起始 token(通常是 <s> 句首符号),喂入模型,得到下一个词的概率分布 $\hat{\boldsymbol{y}}^{(1)}$。
  2. 从分布中采样一个词 $\hat{\boldsymbol{x}}^{(1)}$。
  3. 把 $\hat{\boldsymbol{x}}^{(1)}$ 作为下一步的输入,得到 $\hat{\boldsymbol{y}}^{(2)}$。
  4. 采样 $\hat{\boldsymbol{x}}^{(2)}$,继续……
  5. 遇到 </s> 或达到最大长度时停止。
<s> my favorite season is spring my favorite season is spring </s> ↑ sample (top-k / top-p / temperature) - - - 采样得到的输出作为下一步输入
图 6.1:RNN-LM 自回归生成。注意红色虚线:采样得到的输出在下一步成为输入——这就是 exposure bias 的根源。

6.2 解码策略:贪心、采样、温度、Top-k、Top-p

"采样"这一步有大学问。给定一个 $|V|$ 维分布 $\hat{\boldsymbol{y}}^{(t)}$,怎么选下一个词?这就是 解码策略 (decoding strategy)

策略公式特点
贪心 (greedy)$\arg\max_w \hat{y}^{(t)}_w$最确定,但容易重复/无趣
纯随机采样$w \sim \hat{\boldsymbol{y}}^{(t)}$多样性最大,但容易跑偏(罕见词坍塌)
温度采样$\hat{y}_w \propto \exp(z_w / \tau)$$\tau<1$ 更尖锐(保守),$\tau>1$ 更平坦(创意),$\tau\to 0$ = greedy
Top-k只在 top-$k$ 词上重新归一化采样截断长尾,避免奇怪词;$k\in[10,100]$ 常见
Top-p (nucleus)选累积概率达到 $p$ 的最小词集,重归一化采样Holtzman 2019 提出,目前 LLM 默认;$p\in[0.9,0.95]$ 常见
Beam Search同时保持 $k$ 条候选,每步扩展 → 取 top-$k$用于翻译/摘要等需要"找最大概率序列"的任务
为什么不直接用 $\arg\max$
LM 的概率分布有个反直觉特点:整体最高概率的句子,往往是"the the the the…"这类退化输出。 Holtzman 等人 (2019) 在 "The Curious Case of Neural Text Degeneration" 中证明了这一点,并提出 nucleus sampling 作为更好的默认选择。

6.3 评估指标:困惑度 (Perplexity)

怎么客观比较两个 LM 谁更好?标准答案是 perplexity (PPL, 困惑度)——一个 LM 对测试集的预测有多"惊讶"。

Perplexity 定义
$$ \mathrm{perplexity} = \prod_{t=1}^{T} \left(\frac{1}{P_{\mathrm{LM}}\!\left(\boldsymbol{x}^{(t+1)} \mid \boldsymbol{x}^{(t)},\ldots,\boldsymbol{x}^{(1)}\right)}\right)^{1/T} $$

直觉解读:

所以 perplexity 越低越好。直觉上,perplexity = $k$ 意味着"模型在每一步都好像在 $k$ 个等概率词中犹豫"。 $k=1$ 是完美预测(一定知道下一个词是什么),$k=|V|$ 是完全随机猜。

6.4 Perplexity 的信息论解释

Perplexity 跟 cross-entropy loss 在数学上是同一件事的两个皮肤

Perplexity = exp(cross-entropy)
$$ \mathrm{perplexity} = \prod_{t=1}^{T} \left(\frac{1}{\hat{y}^{(t)}_{\boldsymbol{x}^{(t+1)}}}\right)^{1/T} = \exp\!\left(\frac{1}{T}\sum_{t=1}^{T} -\log \hat{y}^{(t)}_{\boldsymbol{x}^{(t+1)}}\right) = \exp\!\big(J(\theta)\big) $$

也就是:perplexity 就是 cross-entropy 损失的指数。因为 $\exp$ 是单调递增函数,最小化 cross-entropy 等价于最小化 perplexity。

模型 / 时代WikiText-103 Test PPL备注
5-gram KN smoothing (经典统计)~140n-gram 的极限
LSTM (Merity 2018)~40+ tied embeddings + dropout
Transformer-XL (Dai 2019)~18RNN 全面退场
GPT-2 1.5B (zero-shot)~17大规模预训练范式
GPT-3 175B~10规模定律 + RLHF 前夜
Perplexity 之外的评估
PPL 衡量"对训练分布的拟合度",但下游能力(推理、代码、对话)越来越无法被 PPL 完整反映。 现代 LLM 评估还看:HumanEval (代码), MMLU (知识), GSM8K (数学), MT-Bench (对话偏好), Arena (人类盲评)…… 但 PPL 仍然是预训练阶段最重要的内部指标,因为它不需要标注数据

第七部分:梯度消失与梯度爆炸

现在我们抵达本课最重要、也最数学化的一节。RNN 在原理上能记住任意远的历史(隐状态能传递无穷长),但实际训练中极难学到长程依赖。 原因正是 Pascanu et al. (2013) 论文 "On the difficulty of training recurrent neural networks" 揭示的两个数学病理: 梯度消失 (vanishing gradient)梯度爆炸 (exploding gradient)

7.1 直觉:连乘的几何后果

我们之前推导过 BPTT 的核心:

$$ \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(i)}} = \frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(t)}} \cdot \prod_{j=i+1}^{t} \frac{\partial \boldsymbol{h}^{(j)}}{\partial \boldsymbol{h}^{(j-1)}} $$

注意中间那个连乘符号 $\prod$。每多一个时间步,就要多乘一个雅可比矩阵 $\partial \boldsymbol{h}^{(j)} / \partial \boldsymbol{h}^{(j-1)}$。 连乘 $T$ 个矩阵的结果,会按矩阵谱半径 (spectral radius) 的 $T$ 次方变化:

若谱半径 < 1
结果 $\to 0$ 指数衰减 → 梯度消失
若谱半径 > 1
结果 $\to \infty$ 指数爆炸 → 梯度爆炸
若谱半径 = 1
稳定,但极其罕见且脆弱
h⁽¹⁾ h⁽²⁾ h⁽³⁾ h⁽⁴⁾ J⁽⁴⁾(θ) W W W 几乎没有梯度 强梯度 梯度信号在反传过程中指数衰减 → 早期权重收不到有效学习信号
图 7.1:梯度消失的直观图示。距离损失越远,反传梯度越弱——RNN 学不到"很久以前的事"。

7.2 数学推导:Pascanu 2013 不等式

我们来精确化"梯度消失"。考虑无激活函数的简化 RNN:$\boldsymbol{h}^{(t)} = \boldsymbol{W}_h \boldsymbol{h}^{(t-1)}$(去掉 $\sigma$)。

那么 $\boldsymbol{h}^{(t)} = \boldsymbol{W}_h^{\,t-i}\,\boldsymbol{h}^{(i)}$,所以

线性 RNN 的梯度结构
$$ \frac{\partial \boldsymbol{h}^{(t)}}{\partial \boldsymbol{h}^{(i)}} = \boldsymbol{W}_h^{\,t-i} $$

对 $\boldsymbol{W}_h$ 做特征分解 $\boldsymbol{W}_h = \boldsymbol{Q}\boldsymbol{\Lambda}\boldsymbol{Q}^{-1}$(假设可对角化),则 $\boldsymbol{W}_h^{\,t-i} = \boldsymbol{Q}\boldsymbol{\Lambda}^{\,t-i}\boldsymbol{Q}^{-1}$。 对角矩阵 $\boldsymbol{\Lambda}^{\,t-i}$ 的元素是 $\lambda_k^{\,t-i}$。如果某些 $|\lambda_k| < 1$,对应的分量按 $\lambda_k^{\,t-i}$ 指数衰减;如果 $|\lambda_k| > 1$,则指数爆炸。

对于带 $\tanh$ 的真实 RNN,Pascanu 等人证明了如下充分条件

Pascanu 2013 梯度消失条件
若 $\|\boldsymbol{W}_h\| \cdot \max_t \|\mathrm{diag}(\sigma'(\boldsymbol{z}^{(t)}))\| < 1$,则 $$ \left\|\frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(i)}}\right\| \le \big(\|\boldsymbol{W}_h\| \cdot \gamma_\sigma\big)^{t-i} \cdot \left\|\frac{\partial J^{(t)}}{\partial \boldsymbol{h}^{(t)}}\right\| \;\xrightarrow{t-i \to \infty}\; 0 $$ 其中 $\gamma_\sigma = \max |\sigma'|$,对 $\tanh$ 而言 $\gamma_\sigma = 1$。
为什么 ReLU 在 RNN 里也不行
$\tanh'$ 最多 1,sigmoid' 最多 0.25——确实容易让梯度衰减。但换成 ReLU 也不能完全解决,因为 ReLU 在负区间是 0,会导致梯度死亡 (dying gradients)。 ReLU 在前馈深度网络中之所以好用,是因为有 batch norm 和残差连接配合。RNN 由于权重共享 + 时序连乘,结构上对这类技巧不友好。

7.3 为什么是病理:长程依赖丢失

为什么梯度消失是致命的?看下面这个真实例子:

When she tried to print her tickets, she found that the printer was out of toner. She went to the stationery store to buy more toner. It was very overpriced. After installing the toner into the printer, she finally printed her ___

正确答案是 "tickets"。它在第 7 个 token 处出现,目标位置在第 40+ 个 token——距离 33+ 步。 要学会这种填空,模型必须把第 7 步的 "tickets" 信息保留到第 40 步。但因为梯度消失,反传时第 40 步的 loss 几乎完全不会更新第 7 步周围的权重。 模型学不到这种长程关联,所以测试时也无法预测。

区分两个常被混淆的概念
  1. "模型表达能力":理论上 RNN 是图灵完备的,可以表示任意算法 (Siegelmann 1995)。
  2. "梯度学习能力":但梯度下降能学到这些算法吗?梯度消失说:很多长程模式原理可表达,但梯度学不到
RNN 的失败属于第二类——不是 RNN 不够强,而是优化算法在 RNN 的损失曲面上太弱。

7.4 梯度爆炸的危险与梯度裁剪

如果谱半径 > 1,梯度会按 $\rho^T$ 指数爆炸。SGD 更新规则 $\theta^{\text{new}} = \theta^{\text{old}} - \alpha\nabla_\theta J$ 中, 一个 $10^{10}$ 量级的梯度乘上学习率 $\alpha=0.01$,就会让参数瞬间跳到 $10^8$ 量级——直接 NaN,训练崩溃。

"You think you've found a hill to climb, but suddenly you're in Iowa." — Diyi Yang 课堂金句
解决方案:梯度裁剪 (Gradient Clipping)
算法(Pascanu 2013,已成为 RNN 训练标配):
grad_norm = total_norm_of_gradients(grads)
if grad_norm > threshold:
    for g in grads:
        g *= threshold / grad_norm   # 等比例缩小
# 然后 SGD 更新
直觉:方向不变(仍指向下降)只是步子小一点,避免一脚踩进灾难区域。PyTorch 一行实现: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
为什么"裁剪治标不治本"
裁剪只解决爆炸(数值上限),不解决消失。爆炸是工程问题,消失才是 RNN 表达能力的根本病——所以业界先做了 LSTM,再做了 Transformer,本质上都在解决"信息长程传输"问题。

7.5 治本之路:LSTM 与残差直觉预告

对付梯度消失的两条治本路径:

路径 1:加入"记忆单元" (memory cell)

vanilla RNN 的隐藏状态 $\boldsymbol{h}^{(t)}$ 每一步都被重写: $\boldsymbol{h}^{(t)} = \sigma(\boldsymbol{W}_h\boldsymbol{h}^{(t-1)} + \cdots)$,前一时刻的信息每次都要过激活函数,自然衰减。

LSTM (Long Short-Term Memory, Hochreiter & Schmidhuber 1997) 的关键 idea: 引入一条独立的 cell state $\boldsymbol{c}^{(t)}$,它的更新是加法而非完全重写:

LSTM 的核心方程(预告)
$$ \boldsymbol{c}^{(t)} = \boldsymbol{f}^{(t)} \odot \boldsymbol{c}^{(t-1)} + \boldsymbol{i}^{(t)} \odot \tilde{\boldsymbol{c}}^{(t)} $$ 其中 $\boldsymbol{f}^{(t)}$ 是遗忘门 (forget gate),$\boldsymbol{i}^{(t)}$ 是输入门 (input gate),$\odot$ 是逐元素乘。

当 $\boldsymbol{f}^{(t)} \approx 1$ 时,$\boldsymbol{c}^{(t)} \approx \boldsymbol{c}^{(t-1)}$——信息可以原封不动跨越任意步。 反向传播时 $\partial \boldsymbol{c}^{(t)} / \partial \boldsymbol{c}^{(t-1)} \approx \boldsymbol{1}$,不连乘衰减。这就是 LSTM 缓解梯度消失的几何原理。

路径 2:直接让梯度"抄近路"

更激进的方案:让梯度经过那一长串雅可比矩阵的连乘。这就是 残差连接 (residual connection)attention 的核心: 建立 $O(1)$ 长度的"梯度高速公路",让损失信号可以直接传到任意远的位置。

flowchart LR A[RNN 梯度消失] --> B{解决思路} B -->|加门控记忆| C[LSTM 1997 / GRU 2014] B -->|加跳过连接| D[ResNet 2015] B -->|加 O(1) 路径| E[Attention 2014] C --> F[NMT 2015-2017] D --> F E --> F F --> G[Transformer 2017] G --> H[BERT/GPT/LLM 时代] style A fill:#fbecec style G fill:#e8f1f4 style H fill:#eafaf0

第八部分:机器翻译与 Seq2Seq

本课最后一个章节,我们把 RNN-LM 推广到一个具体应用:机器翻译 (Machine Translation, MT)。 这个推广不仅是工程练习,更是 attention 机制和 Transformer 诞生的直接历史路径

8.1 MT 任务定义与历史演进

定义:给定源语言句子 $\boldsymbol{x} = x_1, x_2, \ldots, x_n$(如英文 "I like deep learning"), 输出目标语言句子 $\boldsymbol{y} = y_1, y_2, \ldots, y_m$(如中文"我喜欢深度学习")。 注意:$n$ 和 $m$ 通常不相等。

时代方法特点
1950sRule-based (规则翻译)专家手写翻译规则。冷战军用。完全无泛化。
1990s–2010sSMT (Statistical MT, IBM 模型)词对齐 + 短语翻译 + n-gram LM + 重排序,几百位工程师维护
2014–2016NMT 萌芽 (Seq2Seq)一个神经网络端到端学翻译
2016Google 切到 GNMTNMT 全面取代 SMT
2017+Transformer 时代WMT 等竞赛上 BLEU 持续刷新

8.2 Sutskever 2014:seq2seq 的诞生

Sutskever, Vinyals, Le 在 NeurIPS 2014 论文 "Sequence to Sequence Learning with Neural Networks" 提出 seq2seq。 核心想法极其优雅:用两个 RNN 串联——一个把源句子"压缩"成向量,另一个把这个向量"解压"成目标句子。

Encoder RNN
读源语言 $\boldsymbol{x}$,输出最后一个隐藏状态 $\boldsymbol{c}$ ——这是源句的语义向量
Decoder RNN
以 $\boldsymbol{c}$ 为初始隐状态,自回归生成目标语言 $\boldsymbol{y}$

8.3 编码器-解码器架构

●●●● ●●●● ●●●● ●●●● c (bottleneck) ●●●● ●●●● ●●●● ●●●● ●●●● ●●●● il m' a entarté <s> he hit me with a pie he hit me with a pie <END> Encoder RNN Decoder RNN Source: 法语 "il m' a entarté" Target: 英语 "he hit me with a pie"
图 8.1:Seq2Seq 编码器-解码器架构。源句被 Encoder 压成一个向量 $\boldsymbol{c}$(橙色高亮 = bottleneck),Decoder 以此为初始 hidden 自回归生成目标句。

8.4 条件语言模型视角

关键观察:Decoder 就是一个 RNN-LM,只不过它的 hidden state 一开始被源句"初始化"了。所以 seq2seq 是一个条件语言模型 (Conditional LM)

NMT 的概率分解
$$ P(\boldsymbol{y} \mid \boldsymbol{x}) = P(y_1 \mid \boldsymbol{x}) \cdot P(y_2 \mid y_1, \boldsymbol{x}) \cdot \ldots \cdot P(y_T \mid y_1, \ldots, y_{T-1}, \boldsymbol{x}) = \prod_{t=1}^{T} P(y_t \mid y_{<t}, \boldsymbol{x}) $$

对比 vanilla LM 的 $P(\boldsymbol{y})$,NMT 的 $P(\boldsymbol{y}\mid \boldsymbol{x})$ 多了一份源句条件——其余数学结构完全一样。 这种"万物皆条件 LM"的视角,是后来 instruction-following、prompt engineering、in-context learning 等概念的基础。

8.5 训练目标与端到端反传

训练 NMT 需要平行语料 (parallel corpus)——同一句话的源语言和目标语言对照(如 WMT、OPUS 数据集)。 对每个 $(\boldsymbol{x}, \boldsymbol{y})$ 对,用 Teacher Forcing 喂入 Decoder,计算每个时刻的交叉熵,求平均:

NMT 训练目标
$$ J = \frac{1}{T}\sum_{t=1}^{T} J_t = -\frac{1}{T}\sum_{t=1}^{T}\log P(y_t \mid y_{<t}, \boldsymbol{x}) $$
ŷ₁ ŷ₂ ŷ₃ ŷ₄ ŷ₅ ŷ₆ ŷ₇ J₁ J₂ J₃ J₄ J₅ J₆ J₇ J = (1/T) Σ Jₜ ← 端到端反向传播 梯度一路传到 Encoder 第 1 步! il m' a entarté <s> he hit me with a pie Source (corpus) Target (corpus, teacher forcing)
图 8.2:Seq2Seq 端到端训练。所有 $J_t$ 之和的梯度从 Decoder 一路反传到 Encoder 第 1 步——Encoder 和 Decoder 联合优化,这是 Seq2Seq 相对 SMT 流水线的根本优势。

8.6 多层 Encoder-Decoder:深度堆叠

Sutskever 2014 原版用 4 层 LSTM;Luong 2015 进一步堆叠,发现深度对翻译质量重要。多层架构里,第 $i$ 层 RNN 的隐藏状态作为第 $i+1$ 层的输入:

flowchart TB subgraph "Encoder (多层 LSTM)" E1[Layer 1] --> E2[Layer 2] E2 --> E3[Layer 3] end subgraph "Decoder (多层 LSTM)" D1[Layer 1] --> D2[Layer 2] D2 --> D3[Layer 3] end E3 -->|c| D1 X[Source tokens] --> E1 D3 --> Y[Target tokens]

深层 RNN 能够捕捉层次化的语言结构(底层:词形态;中层:短语;高层:语义),但深度受梯度消失影响,2-4 层通常是 RNN 的上限。 Transformer 把这个上限推到 6, 12, 96, 200+ 层。

8.7 瓶颈问题:引出 attention

看图 8.1 那个橙色高亮的 $\boldsymbol{c}$ 向量——它要承担一项不可能的任务把整个源句子的语义压缩到一个 $d_h$ 维向量里(典型 $d_h=512$)。

瓶颈问题 (Bottleneck Problem)
对短句(5-10 词),$\boldsymbol{c}$ 还撑得住;对长句(30+ 词),翻译质量急剧下降。 Cho 等人 2014 论文实验图直接显示:句子越长,BLEU 越差,下降几乎是线性的。
压缩一切! Source 任意长 Target 任意长 ⚠ 瓶颈:单个 d_h 维向量 c 必须承载全部源信息
图 8.3:Seq2Seq 的"沙漏"瓶颈——所有源信息都要挤过中间那一个橙色向量。这激发了下一课主角:Attention

Attention 的根本想法(Bahdanau 2014, Luong 2015):解码每一步不止看 $\boldsymbol{c}$,直接回头看 Encoder 的所有隐藏状态,加权求和挑出当前最相关的源词。 这就把"一根独木桥"变成了"一座立交桥",瓶颈消失。具体数学和实现,请见下一课。

本课总结:从 next-word prediction 到现代 NLP
  1. 语言建模是 NLP 的核心抽象:所有"理解 + 生成"任务都可以归约为 $P(\boldsymbol{x}^{(t+1)} \mid \cdot)$。
  2. n-gram 是计数版本;定窗口神经 LM 用 embedding 破解稀疏性;RNN 用循环结构破解窗口固定。
  3. RNN-LM 训练 = teacher forcing + cross-entropy + BPTT + SGD + gradient clipping。
  4. Perplexity = exp(cross-entropy),越低越好,是 LM 进步的核心指标。
  5. RNN 的根本病:梯度消失 → 长程依赖学不到;梯度爆炸 → 裁剪治标。
  6. Seq2Seq 把 RNN-LM 推广到条件 LM,统一了机器翻译、摘要、对话、代码生成等所有"输入 → 输出"任务。
  7. Seq2Seq 的瓶颈直接导致了 attention 的发明,attention 又导致了 Transformer,Transformer 又导致了 LLM。
所以下一课的 Transformer,不是从天上掉下来,而是在你本课学过的每一个失败模式的逼问下,自然生长出来的解决方案。

附录:研究生思考题、扩展阅读、PyTorch 完整训练脚本

A.1 思考题(建议在草稿纸上推导)

  1. 困惑度的下界:证明对均匀随机的 LM(每个词概率都是 $1/|V|$),perplexity = $|V|$。这是 PPL 的"无信息" baseline。
  2. BPTT 的内存复杂度:完整 BPTT 训练序列长度 $T$、隐藏维度 $d_h$、batch size $B$,需要多少显存(按 float32 计算)?为什么截断 BPTT 能解决这个问题?
  3. 权重共享 vs 不共享:如果 RNN 在每个时间步用不同的 $\boldsymbol{W}_h^{(t)}$,会发生什么?参数量是多少?还能处理变长输入吗?
  4. 梯度消失定量计算:假设 $\|\boldsymbol{W}_h\| = 0.9$,$\tanh' = 0.5$,序列长 $T=100$,初始梯度 $\|\nabla\| = 1$,反传到第 1 步剩多少?换 $\|\boldsymbol{W}_h\| = 1.1$ 再算一次。
  5. Teacher Forcing 的 exposure bias:设计一个最小实验展示 exposure bias 的影响(提示:训练用 teacher forcing,测试用 rollout,比较两种 perplexity)。
  6. NMT 中的搜索:为什么 NMT 测试时用 beam search 而不是 greedy?写出 beam size = $k$ 的复杂度。
  7. 多语言 NMT:如何把一个英→法的 NMT 模型推广到 100 种语言互译?提示:搜索 "Massively Multilingual NMT"。

A.2 PyTorch 完整训练脚本(WikiText-2)

"""
最小可运行的 RNN-LM 训练脚本
依赖: torch, torchtext, datasets
"""
import math
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from datasets import load_dataset

# ---------- 1. 数据准备 ----------
ds = load_dataset("wikitext", "wikitext-2-raw-v1")
# 简化处理:用空格分词 + 构建词表
from collections import Counter
def tokenize(s): return s.split()
counter = Counter()
for ex in ds["train"]: counter.update(tokenize(ex["text"]))
vocab = ["<pad>", "<unk>", "<bos>", "<eos>"] + \
        [w for w,c in counter.most_common(20000)]
stoi = {w:i for i,w in enumerate(vocab)}
def encode(s):
    return [stoi.get(w, 1) for w in tokenize(s)]

# 拼接所有训练文本,切成长度 35 的块(典型 TBPTT 设置)
BPTT = 35
def make_chunks(split):
    ids = []
    for ex in ds[split]:
        ids += encode(ex["text"])
    n_chunks = len(ids) // BPTT
    ids = ids[:n_chunks * BPTT]
    return torch.tensor(ids).view(-1, BPTT)

train_data = make_chunks("train")
val_data   = make_chunks("validation")

# ---------- 2. 模型 ----------
class RNNLM(nn.Module):
    def __init__(self, vocab_size, emb=200, hidden=200, n_layers=2, dropout=0.2):
        super().__init__()
        self.embed   = nn.Embedding(vocab_size, emb)
        self.drop    = nn.Dropout(dropout)
        self.rnn     = nn.LSTM(emb, hidden, n_layers, dropout=dropout, batch_first=True)
        self.fc      = nn.Linear(hidden, vocab_size)
    def forward(self, x, h=None):
        e = self.drop(self.embed(x))
        out, h = self.rnn(e, h)
        return self.fc(self.drop(out)), h

device = "cuda" if torch.cuda.is_available() else "cpu"
model = RNNLM(len(vocab)).to(device)
opt   = torch.optim.Adam(model.parameters(), lr=1e-3)
crit  = nn.CrossEntropyLoss()

def evaluate(data):
    model.eval()
    total_loss, n = 0., 0
    with torch.no_grad():
        for i in range(0, len(data)-1, 64):       # batch=64 (粗略)
            batch = data[i:i+64].to(device)
            x, y = batch[:, :-1], batch[:, 1:]
            logits, _ = model(x)
            loss = crit(logits.reshape(-1, len(vocab)), y.reshape(-1))
            total_loss += loss.item() * y.numel()
            n += y.numel()
    return total_loss / n

# ---------- 3. 训练循环 ----------
for epoch in range(10):
    model.train()
    hidden = None
    for i in range(0, len(train_data)-1, 64):
        batch = train_data[i:i+64].to(device)
        x, y = batch[:, :-1], batch[:, 1:]
        logits, hidden = model(x, hidden)
        loss = crit(logits.reshape(-1, len(vocab)), y.reshape(-1))
        opt.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
        opt.step()
        # 关键:detach hidden 以截断 BPTT
        hidden = tuple(h.detach() for h in hidden)
    val_loss = evaluate(val_data)
    print(f"Epoch {epoch}: val_loss={val_loss:.3f}, val_ppl={math.exp(val_loss):.1f}")

# ---------- 4. 文本生成 ----------
@torch.no_grad()
def generate(prompt, max_len=50, temp=1.0):
    model.eval()
    ids = torch.tensor([encode(prompt)], device=device)
    h = None
    out = list(ids[0].cpu().numpy())
    for _ in range(max_len):
        logits, h = model(ids[:, -1:], h)
        probs = torch.softmax(logits[0, -1] / temp, dim=-1)
        nxt = torch.multinomial(probs, 1).item()
        out.append(nxt)
        ids = torch.tensor([[nxt]], device=device)
    return " ".join(vocab[i] for i in out)

print(generate("the meaning of life is", temp=0.8))

A.3 推荐扩展阅读