序列建模:RNN / LSTM 到注意力
核心问题:输入是「一串」有顺序的东西(文本、语音、时间序列),怎么建模?
这一篇的落点在 注意力机制 —— 它是 Transformer 的种子,也是理解 L4 大模型的前提。
为什么序列不能用普通全连接
| 问题 | 说明 |
|---|---|
| 长度不固定 | 「我爱你」和「今天天气不错我很开心」长度不同 |
| 顺序有意义 | 「狗咬人」≠「人咬狗」 |
| 长距离依赖 | 「虽然下雨了,但是我还是去了」中间隔着好几个词 |
一、RNN:一步步读
h₁ = f(x₁, h₀)
h₂ = f(x₂, h₁)
...
hₜ = f(xₜ, hₜ₋₁)隐状态 h 是「读到目前为止的记忆」。
python
rnn = nn.RNN(input_size=128, hidden_size=256, batch_first=True)
x = torch.randn(8, 20, 128) # (batch, seq_len, dim)
out, h_n = rnn(x)
print(out.shape, h_n.shape) # (8,20,256) (1,8,256)致命问题:梯度要沿时间反传 20 步,长序列上梯度会消失或爆炸,学不到长距离依赖。
二、LSTM / GRU:加门控
LSTM 用三个门控制信息的存/取/丢:
| 门 | 作用 | 直觉 |
|---|---|---|
| 遗忘门 | 决定丢掉多少旧记忆 | 「这个话题结束了,清空」 |
| 输入门 | 决定写入多少新信息 | 「记住这个新事实」 |
| 输出门 | 决定输出多少当前记忆 | 「现在该说出多少」 |
python
lstm = nn.LSTM(input_size=128, hidden_size=256,
num_layers=2, batch_first=True,
bidirectional=True, dropout=0.1)
x = torch.randn(8, 20, 128)
out, (h_n, c_n) = lstm(x)
print(out.shape) # (8, 20, 512) 双向所以 256×2文本分类的标准做法(BiLSTM + 池化)
python
class BiLSTMClassifier(nn.Module):
def __init__(self, vocab, embed_dim=128, hidden=256, n_cls=2):
super().__init__()
self.emb = nn.Embedding(vocab, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(embed_dim, hidden, batch_first=True,
bidirectional=True, num_layers=2, dropout=0.2)
self.head = nn.Sequential(nn.Dropout(0.3), nn.Linear(hidden * 2, n_cls))
def forward(self, ids, mask=None):
x = self.emb(ids) # (B, T, E)
out, _ = self.lstm(x) # (B, T, 2H)
if mask is not None:
out = out * mask.unsqueeze(-1) # 屏蔽 padding
pooled = out.max(dim=1).values # 最大池化聚合
return self.head(pooled)padding 必须屏蔽
一个 batch 里句子长短不一,短句会补 0。不屏蔽的话,max/mean 池化会被补位污染,指标莫名偏低。
RNN 的极限
| 局限 | 后果 |
|---|---|
| 串行计算 | 无法并行,长序列训练极慢 |
| 长距离依赖仍会衰减 | 即使 LSTM 也撑不过几百步 |
| 固定长度的隐状态 | 所有信息压进一个向量,成为瓶颈 |
这三点直接催生了注意力机制。
三、注意力:从「压缩」到「按需查找」
一句话理解
注意力 = 用「查询」去和一堆「键」比对,按相关度加权取「值」。
查字典的比喻:你要查一个字(Query),在字典里比对哪些词条(Key)匹配,然后把对应解释(Value)按匹配度加权拿回来。
缩放点积注意力
$$\text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$$
python
import torch, torch.nn.functional as F
def scaled_dot_product_attention(Q, K, V, mask=None):
d_k = Q.size(-1)
scores = Q @ K.transpose(-2, -1) / (d_k ** 0.5) # (B, Tq, Tk)
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
attn = F.softmax(scores, dim=-1)
return attn @ V, attn三项各自的作用:
| 项 | 作用 |
|---|---|
| $QK^\top$ | 算每个 query 与每个 key 的相似度 |
| $/\sqrt{d_k}$ | 缩放!否则维度大时点积过大,softmax 饱和成 one-hot,梯度消失 |
| $\text{softmax}$ | 变成权重和为 1 的分布 |
| $\times V$ | 按权重加权求和,得到「聚合后的信息」 |
为什么必须除以 √d
d=64 时,随机向量点积的方差约为 64,标准差 8。softmax 输入跨度过大会输出接近 one-hot → 梯度几乎为 0 → 参数学不动。
三种注意力
| 类型 | Q 来自 | K/V 来自 | 用途 |
|---|---|---|---|
| 自注意力 Self-Attention | 输入本身 | 输入本身 | Transformer 编码 |
| 交叉注意力 Cross-Attn | 解码器 | 编码器输出 | 翻译、多模态对齐 |
| 因果自注意力 | 输入本身 | 输入本身(+因果掩码) | 自回归生成(GPT) |
因果掩码(下三角)保证生成第 t 个 token 时看不到未来:
python
T = 4
causal_mask = torch.tril(torch.ones(T, T)).bool()
# tensor([[1,0,0,0],
# [1,1,0,0],
# [1,1,1,0],
# [1,1,1,1]])四、多头注意力:并行看不同的关系
单个注意力只能学到一种「关注模式」。多头让它同时学多种:
python
class MultiHeadAttention(nn.Module):
def __init__(self, d_model=512, n_heads=8):
super().__init__()
assert d_model % n_heads == 0
self.h, self.d_k = n_heads, d_model // n_heads
self.wq, self.wk, self.wv = (nn.Linear(d_model, d_model) for _ in range(3))
self.wo = nn.Linear(d_model, d_model)
def forward(self, q, k, v, mask=None):
B, T, _ = q.shape
# (B,T,D) → (B,h,T,d_k)
q, k, v = (x.view(B, T, self.h, self.d_k).transpose(1, 2)
for x in (self.wq(q), self.wk(k), self.wv(v)))
out, _ = scaled_dot_product_attention(q, k, v, mask) # (B,h,T,d_k)
out = out.transpose(1, 2).reshape(B, T, self.h * self.d_k)
return self.wo(out)不同头学到的分工(可视化观察到的经验):
- 有的头看相邻词(局部语法)
- 有的头盯着主谓关系
- 有的头盯着标点或句首
五、注意力带来的两个关键能力
| 能力 | 说明 |
|---|---|
| 可并行 | 任意两个位置的距离都是 1 步,全部位置同时计算(训练速度质变) |
| 直接长依赖 | 第 1 个词能直接看第 1000 个词,无需逐步传递 |
代价:计算量 $O(T^2 d)$,序列越长越贵。这就是后来 FlashAttention、稀疏注意力、线性注意力要解决的问题。
六、学习路径建议
| 步骤 | 做什么 |
|---|---|
| 1 | 用 PyTorch 跑通 BiLSTM 文本分类(建立序列任务直觉) |
| 2 | 手写 scaled_dot_product_attention,用 3×3 矩阵手动验证 |
| 3 | 可视化注意力权重(画热力图看它关注什么) |
| 4 | 进入 Transformer 组装完整模型 |
| 5 | 除非必要,不要在生产用 RNN —— 直接用预训练 Transformer |