4. 多头注意力与残差:完整的一层
这是什么:多头注意力(Multi-Head Attention) = 把向量切成几份、各算各的注意力再拼回来;残差连接(Residual) 与 LayerNorm(层归一化) = 让几十层网络算得动、又不会"越算越崩"的两个保命搭档。
为什么用:第 3 章只有"一个聚光灯",每个词只能用一套目光看全句,容易顾此失彼——多头就是几个聚光灯各盯一面(语法一盏、指代一盏、情感一盏)。而几十层往上叠时,靠残差和归一化才立得住。
如何用:本章分别手搓"切头"的维度账和"残差 + LayerNorm"的收尾两步,看清模型的"一层"由哪几块拼成。
第一步:切头——维度对半分
所谓多头,就是把 d_model 维向量平均切成 h 份,每份进一个独立的注意力。范围不受任何影响,只是"同一层能并行换几套眼镜":
import numpy as np
rng = np.random.default_rng(7)
T, d_model, h = 2, 6, 3 # 2 个词、每个词 6 维、3 个注意力头
d_head = d_model // h # 切分维度账:每头拿走 2 维
X = rng.normal(size=(T, d_model))
heads = []
for _ in range(h):
W = rng.normal(size=(d_model, d_head)) # 各头有各头的权重
heads.append(X @ W)
stacked = np.stack(heads, axis=0) # 把 3 个头摆成一摞
print(stacked.shape) # (3, 2, 2):3 个头 × 2 词 × 2 维
# 真实现:拼回 (2, 6) 再乘一个 d_model×d_model 的投影矩阵
你会看到:
(3, 2, 2)
| 代码 | 作用 |
|---|---|
d_head = d_model // h |
切分公式:6 维 ÷ 3 头 = 每头 2 维,切完总维度不变 |
for _ in range(h) |
各头独立循环算注意力,真实实现里可以并行一块跑 |
np.stack(heads, axis=0) |
把各头的输出摞成 (头数, 词数, 每头维度),真实实现再拼回 (2, 6) |
(3, 2, 2) |
维度账对上了:3 头 × 2 词 × 每头 2 维 = 12 个数 = 原来 2×6 = 12 个数 |
第二步:残差——把输入原样加回来
网络叠到几十层,梯度传不回去是老大难。残差连接的办法直白:每层的输出不但给新算的结果,还把输入原样加回来(输出 = 层计算 + 输入),信息永远有"原路直达"的通道:
W2 = rng.normal(size=(d_model, d_model))
hidden = X @ W2 # 假设这就是这层注意力的输出
residual = hidden + X # 残差:新结果 + 原输入
这一行 hidden + X 独立看没什么玄乎,妙在几十层照此叠加,每一层都能"抄近道",梯度与信息都走得动。
第三步:LayerNorm——每行拉回标准身材
残差这类加法叠加后,数值容易越滚越大。层归一化(LayerNorm)的法子:对每个词向量独立做"减均值、除标准差",把它拉回均值 0、标准差 1 的标准身材:
mean = residual.mean(axis=1, keepdims=True)
std = residual.std(axis=1, keepdims=True)
normed = (residual - mean) / std
print(np.round(normed.std(axis=1), 2)) # 归一化后每行标准差都是 1;均值也归到 0 附近
你会看到:
[1. 1.]
记忆口诀:六维切三头,各看各的世界;输出加输入,残差开近道;减均值除标准差,LayerNorm 保身材。
算完"多头注意力 →(加残差)→ LayerNorm",这才走完 Transformer 的一层。真实的 GPT 有几十层这样的一层,并且注意力层中间还夹着前馈网络(FFN);第 7 章的迷你版我们只留最关键的两步:位置编码加注意力。
动手改
- 把
h改成 6,让d_head变成 1,看stacked.shape怎么变;再想想"6 个头、每头 1 维"和"1 个头、6 维"哪个更合理 - 在
normed后面打印np.round(normed.mean(axis=1), 6),验证每行均值确实在 0 附近 - 把
residual = hidden + X里的X换成hidden自己,观察标准差输出怎么变化
下一章:位置编码,解决"谁在前谁在后" 🐸