Transformer 不是凭空出现的。在它之前,处理「序列」(一句话、一段语音、一串时间戳)的主力是 RNN(Recurrent Neural Network,循环神经网络)。今天我们走一遍 2014–2015 这条进化线:LSTM → GRU → Seq2Seq → Attention 起源。理解这条线,你会发现 Transformer 的 attention 不是魔法,而是对 RNN 一个具体瓶颈的精确回应——而那个瓶颈,用你熟悉的「有损压缩」一句话就能讲清。
普通 RNN 像一个只有一个变量的滚动聚合(running aggregate):每读一个词就把它揉进同一个状态里,旧信息被反复覆盖。LSTM 在旁边加了一条独立的「传送带」(cell state)——类似数据库的 WAL(write-ahead log,预写日志):信息默认原样往前传,只有在「门」明确批准时才读、写、擦除。这条传送带让久远的信息能近乎无衰减地走很远。
先说 RNN 的病根:梯度消失(vanishing gradient)。RNN 每往前一步,信息都要乘一个权重矩阵;训练时误差反向传播要把这些矩阵连乘几十上百次。连乘小于 1 的数 → 指数级趋零,远处的梯度消失(大于 1 则爆炸)。结果:RNN 学不到「The clouds are in the ___」这种隔了很多词的长依赖——就像信号经过太多跳(hop)后衰减殆尽。
LSTM(Hochreiter & Schmidhuber, 1997)的解法是把「记忆」和「计算」拆开。cell state 这条传送带上的更新是加法而非反复相乘——加法路径上梯度不会指数衰减,这是 LSTM 能记长的数学核心。三个门控制传送带:
每个门都是 σ(W·[h, x] + b)——sigmoid 把结果压到 0~1,可以直观理解成「开关的开合程度」:0 = 完全关闭,1 = 完全放行。门不是人写死的规则,而是学出来的。
import torch, torch.nn as nn # PyTorch 内置 LSTM,门控逻辑都封装好了 lstm = nn.LSTM(input_size=16, hidden_size=32, batch_first=True) x = torch.randn(4, 10, 16) # (batch=4, 序列长10, 每步特征16) out, (h_n, c_n) = lstm(x) # c_n 就是那条「传送带」cell state print(out.shape) # (4, 10, 32) 每个时间步都有输出 print(h_n.shape) # (1, 4, 32) 最后一步的隐状态——常拿来当整段的「摘要」 print(c_n.shape) # (1, 4, 32) 最后一步的 cell state
GRU 是 LSTM 的精简版。如果说 LSTM 是「三副本强一致」的存储,GRU 就是「两副本」——少了一条独立的 cell state,把「记忆」和「输出」合并成同一个隐状态,门也从 3 个砍到 2 个。换来的是更少参数、更快训练,多数任务上效果和 LSTM 打平。典型的工程权衡:用一点理论上的表达力,换实打实的速度。
LSTM 三门 + 独立 cell state,参数多、计算重。GRU(Cho et al., 2014)问:能不能更省?它的设计:
关键直觉在更新门的插值(interpolation)结构:h_t = (1−z)·h_{t-1} + z·h̃_t。读作「新状态 = (1−z) 份旧的 + z 份新的」。当 z→0,h 几乎原样直传——这和 LSTM 传送带是同一个对抗梯度消失的招式:留一条接近恒等(identity)的直传通道。
import torch, torch.nn as nn gru = nn.GRU(16, 32, batch_first=True) lstm = nn.LSTM(16, 32, batch_first=True) # 直接对比参数量:GRU 用 3 组门权重,LSTM 用 4 组 n_gru = sum(p.numel() for p in gru.parameters()) n_lstm = sum(p.numel() for p in lstm.parameters()) print(n_gru, n_lstm) # 4800 vs 6400 —— GRU 约少 1/4 参数 out, h_n = gru(torch.randn(4, 10, 16)) # 注意:GRU 只返回 h,没有 c
Seq2Seq 把「输入序列 → 输出序列」拆成两段:编码器(encoder)把整句话读完,压成一个固定长度的向量;解码器(decoder)拿这个向量逐词生成输出。像一个 RPC 调用:客户端(encoder)把整个请求序列化成一个定长 payload,发给服务端(decoder)反序列化出结果。问题马上就来了——把任意长的句子塞进一个固定大小的向量,必然有损。
痛点:翻译、摘要、对话这类任务,输入和输出长度都不固定且不对齐(中文 5 个词可能译成英文 8 个词)。普通分类网络做不到「变长进、变长出」。Seq2Seq(Sutskever, Vinyals & Le, 2014)的解法优雅:
这是「理解」和「生成」第一次被干净地分成两个模块——今天所有 encoder-decoder 架构(包括原版 Transformer)都继承自这个骨架。原论文还有个反直觉的工程技巧:把输入句子倒着喂能提分,因为它让源句开头和译文开头在时间上更近,缓解了长距离传递的衰减。
import torch, torch.nn as nn class Seq2Seq(nn.Module): def __init__(self, vocab, dim=64): super().__init__() self.emb = nn.Embedding(vocab, dim) self.encoder = nn.LSTM(dim, dim, batch_first=True) self.decoder = nn.LSTM(dim, dim, batch_first=True) self.out = nn.Linear(dim, vocab) def forward(self, src, tgt): _, state = self.encoder(self.emb(src)) # 编码:只取最终状态当「摘要」 dec, _ = self.decoder(self.emb(tgt), state) # 用摘要初始化解码器 return self.out(dec) # 每步预测下一个词
Attention 把 Seq2Seq 的「只传一份摘要」改成「保留全部原始记录 + 一个按需检索的索引」。decoder 每生成一个词,就对 encoder 的所有隐状态做一次加权检索——像数据库的 JOIN,但用相关性打分代替精确匹配,也像 RAG 每步从原文动态召回最相关的片段。它彻底干掉了定长瓶颈,也是后来 Transformer「Attention is All You Need」的直系祖先。
痛点就是上一张卡的瓶颈:decoder 只拿到一个定长摘要,长句细节全丢。Bahdanau, Cho & Bengio(2014)的解法是软对齐(soft alignment)——decoder 生成第 i 个词时,不再只用那一个向量,而是临时算一个专属于这一步的上下文向量:
逐符号拆解(这是今天唯一需要嚼的公式):
核心直觉:翻译每个词时,模型自己学会该回头看原文的哪几个词。译「cats」时权重集中在「猫」,译「love」时集中在「爱」。这套 score→softmax→加权求和的三步,正是今天 Transformer self-attention 的原型——区别只在 Transformer 把它从 encoder-decoder 之间,推广到了序列对自己。
import torch, torch.nn.functional as F # 加性注意力(Bahdanau)的核心三步,去掉工程封装看本质 def attention(s_prev, enc_h): # s_prev:(B,d) decoder上一步; enc_h:(B,T,d) 全部源词 score = (enc_h * s_prev.unsqueeze(1)).sum(-1) # ① 打分 (B,T) alpha = F.softmax(score, dim=-1) # ② 归一成权重,和为1 ctx = (alpha.unsqueeze(-1) * enc_h).sum(1) # ③ 加权求和 (B,d) return ctx, alpha # alpha 还能可视化「模型在看哪个词」 ctx, a = attention(torch.randn(2,8), torch.randn(2,5,8)) print(ctx.shape, a.shape) # (2,8) (2,5) —— 每步一个定制上下文 + 一行注意力分布