图片的尺寸是固定的,一句话却可长可短,而且顺序决定意思:“狗咬人”和”人咬狗”用的是同样三个字。处理序列的模型要回答两个问题:词怎么变成数,前文怎么影响后文。这一篇从最笨的”数一数”开始,一路走到 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 奈特。它的困惑度是多少,怎么理解?
困惑度是平均 NLL 的指数,exp(ln 4) = 4;一个在 4 个选项上均匀分布的模型恰好就是这个交叉熵。
交叉熵本身不是困惑度,差一个指数;没有平方关系;困惑度只由平均 NLL 决定,词表大小只给出它的上界(均匀猜时)。
词向量:用上下文定义一个词
最直接的表示是 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" 最终得到相似的向量,根本原因是?
中心词向量的唯一训练信号是"预测周围的词",两个词周围的词分布一样,最优向量自然接近。这就是分布式假设。
训练没有人工标签;one-hot 下任何两个不同词都正交;模型只看到词编号,看不到拼写。
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 的梯度消失通常比同样深度的前馈网络更难靠调初始化解决?
前馈网络每层权重不同,各层的缩放可能相互抵消;RNN 展开后是同一矩阵的幂,谱半径略小于 1 就指数衰减、略大于 1 就指数爆炸,很难恰好卡在 1 上。
RNN 有 tanh 激活;参数在时间上共享,不随长度增长;它用 BPTT,也就是展开后的普通反向传播。
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) = 5376PyTorch 的 nn.LSTM 默认输入形状是 (T, B, input_size),时间维在前;out 是每一步的 ,h_n、c_n 是最后一步的两个状态。参数量里的 4 来自三个门加一个候选值,每个都有一个输入矩阵和一个隐状态矩阵;2H 是因为 PyTorch 给输入和隐状态各配了一份偏置。GRU 把门合并成两个、去掉单独的 ,参数少四分之一,效果通常相近。
LSTM 缓解了梯度消失,但没有解决 RNN 的另一个根本限制:它必须一步一步算。 依赖 ,长度 1000 的序列就要串行 1000 步,GPU 的并行能力用不上。这个限制后面会变得很关键。
关于 LSTM,哪些说法正确?
c_t = f * c_prev + i * c_tilde,对 c_prev 的导数就是 f,没有矩阵连乘和 tanh 挤压;f 接近 1 就是"记住"。
遗忘门小于 1 时信息仍会逐步衰减,实践中有效依赖长度有限,只是比普通 RNN 长得多。每一步仍依赖上一步的状态,无法跨时间并行。
seq2seq:把一句话压进一个向量
翻译的输入和输出是两个长度不同的序列。2014 年的 seq2seq 用两个 RNN(通常是 LSTM)解决:编码器读完源句子,把最后的隐状态 交给解码器当初始状态;解码器是一个以此为条件的语言模型,一个词一个词地生成目标句,直到输出结束符。训练时解码器每一步喂的是真实的上一个词(teacher forcing),推理时喂自己上一步的输出。
推理时每一步选概率最大的词(贪心)不一定得到整体概率最大的句子,因为早期一个看似最优的选择可能把后面逼进死胡同。束搜索(beam search)每一步保留概率最高的 个候选前缀( 通常 4 到 10),扩展后再挑前 个,是贪心和穷举之间的折中。
这个架构能用,但有一个结构性瓶颈:无论源句子是 5 个词还是 50 个词,编码器都只能通过一个固定长度的向量 把信息传给解码器。句子越长,这个向量越装不下,而且离 越远的词(句首)在 LSTM 里也衰减得越多。当年的实验确实显示,seq2seq 的翻译质量随句子变长明显下降。一个小技巧是把源句子倒过来输入,让句首离解码器开头更近,效果真的变好了,这本身就说明瓶颈在”距离”上。
seq2seq 在长句子上翻译质量下降,最主要的结构性原因是?
源句子的全部信息都只能经过最后一个隐状态这个固定大小的瓶颈,长句子信息装不下,句首信息还要经过很多步衰减。
词表大小与句长无关;teacher forcing 影响训练和推理的不一致,不是长句特有的问题;束搜索只能在模型给出的分布里搜索,修不了信息本身的丢失。
注意力:让解码器回头看
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 式注意力,哪些说法正确?
权重依赖解码器当前状态,所以每步不同;上下文是隐状态的加权和,维度等于隐状态维度。
对齐是端到端从翻译损失里学出来的,不需要对齐标注。编码器和解码器仍是 RNN,必须逐步计算;彻底并行要等到去掉循环的 self-attention。
记住这三件事
- 语言模型预测下一个 token,用测试集平均 NLL 的指数,即困惑度评价;计数模型的问题是组合爆炸和零概率,出路是让相似的词共享表示。
- 词向量从”用上下文预测词”中学出相似性;RNN 用时间上共享的权重读任意长序列,但同一矩阵的连乘导致梯度消失,LSTM 用加法更新的单元状态缓解它。
- seq2seq 的瓶颈是固定长度的向量,注意力让解码器每步按相关性回看所有编码器状态;把注意力用到序列自身并去掉循环,就是 Transformer。
延伸阅读
- Karpathy: makemore 系列(bigram 到 MLP 语言模型)
- CS224n 课程讲义:word vectors、RNN、seq2seq 与 attention
- Colah: Understanding LSTM Networks
- Mikolov et al., Distributed Representations of Words and Phrases
- Sutskever et al., Sequence to Sequence Learning with Neural Networks
- Bahdanau et al., Neural Machine Translation by Jointly Learning to Align and Translate