图片的尺寸是固定的,一句话却可长可短,而且顺序决定意思:“狗咬人”和”人咬狗”用的是同样三个字。处理序列的模型要回答两个问题:词怎么变成数,前文怎么影响后文。这一篇从最笨的”数一数”开始,一路走到 RNN、seq2seq,最后停在 2014 年那个改变一切的想法:注意力。

前置:从线性回归到神经网络(softmax、交叉熵、反向传播)

语言模型:给下一个字打分

语言模型给出下一个 token 的概率分布 。按链式法则,整句话的概率是每一步条件概率的乘积。能算这个,就能生成文本(一个个往后采样),也能给句子打分(哪句更像人话)。GPT 本质上就是一个非常大的语言模型。

最朴素的做法:只看前一个字符,数一数训练文本里”字符 a 后面跟着字符 b”出现了几次,归一化成概率。这叫 bigram 模型。

import numpy as np
text = ("the cat sat on the mat. the dog sat on the log. the cat saw the dog. "
        "a dog and a cat sat on a mat. the log was on the mat. ") * 20
chars = sorted(set(text)); V = len(chars); ix = {c: i for i, c in enumerate(chars)}
data = np.array([ix[c] for c in text]); split = int(0.9 * len(data))
tr, te = data[:split], data[split:]
def ppl(P, seq):
    nll = -np.log(P[seq[:-1], seq[1:]]).mean()
    return nll, np.exp(nll)
print("vocab", V, " uniform ppl", V)
for k in (0, 1):
    C = np.zeros((V, V)) + k
    np.add.at(C, (tr[:-1], tr[1:]), 1)
    with np.errstate(divide='ignore', invalid='ignore'):
        P = C / C.sum(1, keepdims=True)
        nll, p = ppl(P, te)
    print(f"bigram add-{k}: test NLL {nll:.3f}  ppl {p:.2f}")
rng = np.random.default_rng(0); c = ix['t']; out = 't'
for _ in range(40):
    c = rng.choice(V, p=P[c]); out += chars[c]
print(repr(out))
s = np.array([ix[c] for c in "the cow sat."])
C0 = np.zeros((V, V)); np.add.at(C0, (tr[:-1], tr[1:]), 1)
with np.errstate(divide='ignore', invalid='ignore'):
    print("'the cow sat.' add-0 NLL", ppl(C0 / C0.sum(1, keepdims=True), s)[0], " add-1 NLL", round(ppl(P, s)[0], 3))
vocab 15  uniform ppl 15
bigram add-0: test NLL 0.970  ppl 2.64
bigram add-1: test NLL 1.049  ppl 2.85
'the athe ond t t the a sat.wwathe sasathe'
'the cow sat.' add-0 NLL inf  add-1 NLL 1.684

评价语言模型用测试集上每个 token 的平均负对数似然(NLL),也就是 从线性回归到神经网络 里的交叉熵。它的指数叫困惑度(perplexity):

困惑度有个直观解释:模型平均在多少个选项里”犹豫”。完全随机猜,15 个字符的困惑度就是 15;bigram 降到 2.64,相当于每一步在两三个字符里挑。困惑度只能在同一个词表、同一种切分方式下比较,换了 分词 方式,数字就不可比了。

纯计数有个致命问题:没见过的组合概率是 0。训练文本里 o 后面从没出现过 w,于是 “the cow sat.” 的 NLL 是无穷大。最简单的补救是加一平滑,每个计数先加 1,代价是在正常文本上略差(2.64 变 2.85)。更根本的问题是上下文太短:采样出来的 the athe ond 每两个相邻字符都合理,连起来却不成词。想看前 个字符,计数表就要 格,词级别 有几万, 就已经存不下,而且绝大多数格子是 0。

计数表的本质缺陷在于每个组合是独立的一格,“猫坐在”和”狗坐在”之间没有任何共享。如果模型知道猫和狗是相似的东西,看过一个就能推断另一个,那就不需要见过每一种组合。这需要给词一个能表达”相似”的表示。

停一下,检查理解单选

一个语言模型在测试集上的平均交叉熵是 ln 4 奈特。它的困惑度是多少,怎么理解?

词向量:用上下文定义一个词

最直接的表示是 one-hot:词表 3 万个词,每个词是一个 3 万维、只有一位是 1 的向量。任意两个词的点积都是 0,“猫”和”狗”的距离与”猫”和”民主”一样,没有相似性可言。

语言学里有个老观点:一个词的意思由它周围的词决定。“猫”和”狗”都常出现在”喂""宠物""可爱”附近,所以它们的意思相近。word2vec(2013)把这个想法变成一个训练任务:给每个词一个稠密向量(比如 300 维),让中心词的向量能预测它窗口里的上下文词。这叫 skip-gram:

是中心词向量, 是上下文词向量。这就是一个 softmax 分类,用交叉熵训练。在一个小语料上跑:

import torch, torch.nn.functional as F
torch.manual_seed(0)
sents = ["the king rules the land", "the queen rules the land", "a king wears a crown",
         "a queen wears a crown", "the dog chases the ball", "the puppy chases the ball",
         "a dog eats food", "a puppy eats food"] * 50
words = sorted({w for s in sents for w in s.split()}); ix = {w: i for i, w in enumerate(words)}
pairs = []
for s in sents:
    t = [ix[w] for w in s.split()]
    for i, c in enumerate(t):
        for j in range(max(0, i - 2), min(len(t), i + 3)):
            if j != i: pairs.append((c, t[j]))
pairs = torch.tensor(pairs); V, D = len(words), 8
Win = torch.randn(V, D, requires_grad=True); Wout = torch.randn(D, V, requires_grad=True)
opt = torch.optim.Adam([Win, Wout], lr=0.05)
for step in range(301):
    logits = Win[pairs[:, 0]] @ Wout  # 中心词向量去预测上下文词
    loss = F.cross_entropy(logits, pairs[:, 1])
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 100 == 0: print(f"step {step} loss {loss.item():.3f}")
E = F.normalize(Win.detach(), dim=1)
for w in ("king", "dog", "crown"):
    sims = E @ E[ix[w]]; top = sims.argsort(descending=True)[1:3]
    print(w, "->", [(words[i], round(sims[i].item(), 2)) for i in top])
step 0 loss 5.543
step 100 loss 1.454
step 200 loss 1.450
step 300 loss 1.449
king -> [('queen', 0.82), ('land', 0.73)]
dog -> [('puppy', 0.89), ('ball', 0.64)]
crown -> [('queen', 0.58), ('wears', 0.47)]

没有人告诉模型 king 和 queen 相似,但它们出现在完全相同的上下文里(都跟 rules、wears 一起出现),预测任务迫使它们得到相近的向量,余弦相似度 0.82。dog 和 puppy 同理。损失停在 1.45 左右不再下降,是因为同一个中心词的上下文本来就有多种可能,这部分不确定性学不掉。

Win[pairs[:, 0]] 这个索引操作就是嵌入层:一个 的矩阵,按词编号取一行。它在数学上等价于 one-hot 向量乘以这个矩阵,只是省掉了乘一堆 0。PyTorch 里的 nn.Embedding 就是它。

真实的 word2vec 词表有几十万,softmax 分母要对所有词求和,太贵。它用负采样近似:把多分类换成二分类,只让真实上下文词的得分高、随机抽的几个”负样本”词得分低,每步只算 个点积( 通常 5 到 20)。训好的词向量有著名的类比性质, 最接近 ,说明某些语义关系对应向量空间里近似固定的方向。

word2vec 的局限是一个词只有一个向量。“bank” 在”河岸”和”银行”里意思完全不同,却共享同一个表示。要根据上下文给出不同的表示,需要一个能读整句话的模型。

停一下,检查理解单选

在 skip-gram 训练里,"king" 和 "queen" 最终得到相似的向量,根本原因是?

RNN:带着记忆读一句话

我们想要一个模型:能读任意长度的序列,每读一个词就更新一下”到目前为止的理解”,参数量还不随长度增长。循环神经网络(RNN)就是这样一个循环:

是隐状态,是对前 个词的压缩摘要。每一步用的是同一组 ,这和卷积在空间上共享权重是同一个思想,只是换成了在时间上共享。用它当语言模型, 预测第 个词,理论上它能利用全部前文,不再受 -gram 的窗口限制。

训练时把循环按时间展开成一个很深的前馈网络,再做普通的反向传播,这叫 BPTT(随时间反向传播)。问题马上出现:训练深度网络与 CNN 里讲过,深网络的梯度要连乘很多个雅可比矩阵。RNN 的展开深度等于序列长度,而且每一步乘的是同一个 ,问题更严重。看最终隐状态对每个时刻输入的梯度:

import torch
torch.manual_seed(0)
H, T = 32, 50
for name, scale in [("W std 0.5/sqrt(H)", 0.5), ("W std 1.5/sqrt(H)", 1.5)]:
    Wh = torch.randn(H, H) * scale / H ** 0.5
    Wx = torch.randn(H, 8) / 8 ** 0.5
    xs = torch.randn(T, 8, requires_grad=True)
    h = torch.zeros(H)
    for t in range(T):
        h = torch.tanh(Wh @ h + Wx @ xs[t])
    h.sum().backward()
    g = xs.grad.norm(dim=1)
    print(f"{name}: |dL/dx_t| t=49 {g[49]:.1e}  t=40 {g[40]:.1e}  t=25 {g[25]:.1e}  t=0 {g[0]:.1e}")
W std 0.5/sqrt(H): |dL/dx_t| t=49 3.6e+00  t=40 2.9e-04  t=25 1.2e-11  t=0 0.0e+00
W std 1.5/sqrt(H): |dL/dx_t| t=49 2.8e+00  t=40 5.4e-01  t=25 4.1e-02  t=0 7.1e-03

小一些时,往回 9 步梯度就缩小一万倍,25 步缩小到 ,50 步之前的输入在 fp32 里梯度直接是 0。这意味着模型根本学不到第 1 个词对第 50 个词的影响。 大一些时衰减慢了,但 的谱半径一旦明显大于 1,又会转为梯度爆炸(tanh 的饱和在这里起了一点刹车作用)。梯度爆炸好办,梯度裁剪(范数超过阈值就按比例缩小)就能挡住;梯度消失没法靠裁剪解决,需要改结构。

停一下,检查理解单选

为什么 RNN 的梯度消失通常比同样深度的前馈网络更难靠调初始化解决?

LSTM:一条不被反复相乘的记忆通道

LSTM(1997)的思路和后来的残差连接非常像:既然梯度在 这条路上反复被乘,就另开一条主要靠加法更新的通道。它引入一个单元状态 和三个门(每个门是一个 sigmoid,输出 0 到 1 之间的”开度”):

遗忘门 决定旧记忆留多少,输入门 决定新信息写多少,输出门 决定对外暴露多少。关键在 的更新:它是逐元素乘一个门再加新内容,没有矩阵乘法、没有 tanh 挤压。,只要遗忘门接近 1,梯度就能沿 几乎无损地传很多步。网络自己学什么时候该记住、什么时候该忘掉。实践中常把遗忘门的偏置初始化为正数(如 1),让它一开始倾向于”记住”。

lstm = torch.nn.LSTM(8, H)
xs = torch.randn(T, 1, 8, requires_grad=True)
out, (hn, cn) = lstm(xs)
print("LSTM out", tuple(out.shape), "h_n", tuple(hn.shape), "c_n", tuple(cn.shape))
print("LSTM params:", sum(p.numel() for p in lstm.parameters()), "= 4*(H*(8+H)+2H) =", 4 * (H * (8 + H) + 2 * H))
LSTM out (50, 1, 32) h_n (1, 1, 32) c_n (1, 1, 32)
LSTM params: 5376 = 4*(H*(8+H)+2H) = 5376

PyTorch 的 nn.LSTM 默认输入形状是 (T, B, input_size),时间维在前;out 是每一步的 ,h_n、c_n 是最后一步的两个状态。参数量里的 4 来自三个门加一个候选值,每个都有一个输入矩阵和一个隐状态矩阵;2H 是因为 PyTorch 给输入和隐状态各配了一份偏置。GRU 把门合并成两个、去掉单独的 ,参数少四分之一,效果通常相近。

LSTM 缓解了梯度消失,但没有解决 RNN 的另一个根本限制:它必须一步一步算。 依赖 ,长度 1000 的序列就要串行 1000 步,GPU 的并行能力用不上。这个限制后面会变得很关键。

停一下,检查理解多选

关于 LSTM,哪些说法正确?

seq2seq:把一句话压进一个向量

翻译的输入和输出是两个长度不同的序列。2014 年的 seq2seq 用两个 RNN(通常是 LSTM)解决:编码器读完源句子,把最后的隐状态 交给解码器当初始状态;解码器是一个以此为条件的语言模型,一个词一个词地生成目标句,直到输出结束符。训练时解码器每一步喂的是真实的上一个词(teacher forcing),推理时喂自己上一步的输出。

推理时每一步选概率最大的词(贪心)不一定得到整体概率最大的句子,因为早期一个看似最优的选择可能把后面逼进死胡同。束搜索(beam search)每一步保留概率最高的 个候选前缀( 通常 4 到 10),扩展后再挑前 个,是贪心和穷举之间的折中。

这个架构能用,但有一个结构性瓶颈:无论源句子是 5 个词还是 50 个词,编码器都只能通过一个固定长度的向量 把信息传给解码器。句子越长,这个向量越装不下,而且离 越远的词(句首)在 LSTM 里也衰减得越多。当年的实验确实显示,seq2seq 的翻译质量随句子变长明显下降。一个小技巧是把源句子倒过来输入,让句首离解码器开头更近,效果真的变好了,这本身就说明瓶颈在”距离”上。

停一下,检查理解单选

seq2seq 在长句子上翻译质量下降,最主要的结构性原因是?

注意力:让解码器回头看

2014 年 Bahdanau 等人的修正很自然:既然一个向量装不下,就别只给一个。编码器每个位置的隐状态 都留着。解码器在生成每个词之前,用自己当前的状态 给每个 打一个分,softmax 成权重,加权求和得到这一步专属的上下文向量:

上下文向量和解码器状态一起决定输出词。翻译到”猫”时,权重集中在源句的 “cat” 上;翻译到下一个词时,注意力移到别处。每一步看的地方不同,信息瓶颈就消失了。把翻译过程中的权重画成矩阵,能看到它大体沿对角线分布,遇到语序不同的地方(如法语形容词后置)会出现交叉,模型自己学会了对齐。

打分函数有两种主流选择,手写对比:

import torch
torch.manual_seed(0)
T, H = 6, 4
enc = torch.randn(T, H)          # 编码器每个位置的隐状态 h_1..h_T
s = enc[3] * 2 + 0.1 * torch.randn(H)  # 解码器当前状态
W1, W2, v = torch.randn(H, H), torch.randn(H, H), torch.randn(H)
# Bahdanau 加性打分:e_t = v^T tanh(W1 h_t + W2 s)
e_add = torch.tanh(enc @ W1.T + s @ W2.T) @ v
# Luong 点积打分:e_t = h_t · s
e_dot = enc @ s
for name, e in [("additive", e_add), ("dot", e_dot)]:
    a = torch.softmax(e, 0)
    ctx = a @ enc
    print(f"{name:8s} weights {[round(x, 3) for x in a.tolist()]}  sum {a.sum():.1f}  ctx shape {tuple(ctx.shape)}")
additive weights [0.112, 0.265, 0.109, 0.253, 0.111, 0.151]  sum 1.0  ctx shape (4,)
dot      weights [0.003, 0.292, 0.026, 0.231, 0.41, 0.038]  sum 1.0  ctx shape (4,)

这里的 是随机的,所以权重分布本身没有含义,训练后它们会学成有意义的对齐。要看的是结构:无论哪种打分,权重都是一个和为 1 的分布,上下文向量的形状和单个隐状态相同,与源句长度无关。加性注意力(Bahdanau)用一个小 MLP 打分;点积注意力(Luong 2015)直接用点积,一次矩阵乘就能对所有位置打分,在 GPU 上快得多。点积随向量维度增大方差变大,softmax 会变得很尖、梯度变小,所以后来要除以 。

换一个角度看这个操作:解码器状态 是一个查询(query),每个编码器位置提供一个用来匹配的键(key)和一个被取走的值(value),这里键和值都是 。注意力就是一次”软”的字典查找:不是精确命中一个键,而是按相似度对所有值加权平均。而且它完全可导,查哪里是学出来的。

到这里,注意力还只是 RNN 的一个外挂:编码器和解码器本身仍然是串行的 RNN。下一个问题自然就来了:既然注意力能让任意两个位置直接交换信息,距离不再是问题,那还需要 RNN 一步步传递吗?如果让序列里每个位置都对同一序列的所有位置做注意力(self-attention),整句话可以一次并行算完,梯度路径长度也从 变成 。2017 年的 Transformer 正是去掉了循环、只留下注意力,它的结构见 Transformer 架构。

停一下,检查理解多选

关于 Bahdanau 式注意力,哪些说法正确?

记住这三件事

  1. 语言模型预测下一个 token,用测试集平均 NLL 的指数,即困惑度评价;计数模型的问题是组合爆炸和零概率,出路是让相似的词共享表示。
  2. 词向量从”用上下文预测词”中学出相似性;RNN 用时间上共享的权重读任意长序列,但同一矩阵的连乘导致梯度消失,LSTM 用加法更新的单元状态缓解它。
  3. seq2seq 的瓶颈是固定长度的向量,注意力让解码器每步按相关性回看所有编码器状态;把注意力用到序列自身并去掉循环,就是 Transformer。

延伸阅读