第 8 课结尾留了个钩子:这只注意力还缺一块刹车。现在把问题摆上桌——训练时偷看未来,会出什么事?第 8 课的注意力里,每个词看的是全句:猫看得见鱼,"吃"也看得见鱼——包括自己右边的词。但别忘了 GPT 的本职工作(第 1 课):预测下一个词。一个要看未来的任务,配一只看得见未来的注意力——听上去很配,其实是一场事故。
一、为啥必须遮住未来:答案印在考卷上
先看 GPT 是怎么训练的(第 1 课的伏笔在这里收口)。训练素材就是普通文本,训练任务是把每个位置都变成一道题:给定左边的词,预测下一个词。妙处在于并行——一句话 $L$ 个词,一次前向就是 $L$ 道题同时作答(这个"整句进、逐位出题"的训练法有个名字,叫教师强制,teacher forcing):「猫吃鱼」一次就是 3 道题——看"猫"猜下一词、看"猫吃"猜下一词、看"猫吃鱼"猜下一词。
现在把第 8 课那只"全句可见"的注意力放进考场,事故就来了:做第 2 道题(看"猫吃"猜下一词)时,"吃"的注意力看得见鱼——而鱼的答案就印在输入里。模型不需要学会任何语言规律,只要学会抄:把未来词的内容原样搬进自己的表示,下一词预测的"考试成绩"就会非常好看。这叫数据泄漏(data leakage)——考卷上印着答案。
更阴的是,训练分数完全暴露不出来:训练时永远有未来可抄,损失一路走低,看起来学得极好;等真正上场生成时——写完一个词才有下一个词,未来根本不存在——模型当场露馅。平时做题带答案册,上考场傻眼。
把定义立住:因果掩码(causal mask)
解决方案一句话:打分之后、softmax 之前,把分数表的"右上三角"(未来)全部压成 −∞,softmax 之后那些格子的权重恒为 0——每个词只能看自己和左边的词。这张"左下可见、右上封死"的表就叫因果掩码,也叫下三角掩码 / look-ahead mask / 自回归掩码。你其实已经见过它两次:附录全景图第 ⑦ 步那三个打 × 的格子,和 py 块里 S[np.triu(...)] = -1e9 那一行——本课就把这个"预习版黑盒"拆成正式零件。
二、手算:同一句「猫吃鱼」,掩码前后
数字全部沿用第 8 课——q/k 一字不改,所以分数表一模一样(缩放后):猫行 [0.5, 0.5, 1],吃行 [0, 2, 1],鱼行 [0.5, 0.5, 0]。只有 v 换个新装:这一课给 v 的两个维度起上语义名——维 0 叫「猫味」、维 1 叫「鱼味」(教学技巧:维度有了名字,算出来的向量就能直接"读出故事"):
| 词 | v(内容,可读维度) | 读法 |
| 猫 | [1, 0] | 纯猫味 |
| 吃 | [0.1, 0.1] | 动作,两种味都淡 |
| 鱼 | [0, 1] | 纯鱼味 |
掩码长什么样?行 = 提问者,列 = 被看者;对角线及其左下 = 过去和自己,可见;右上 = 未来,封死:
因果掩码:下三角(含对角线)可见,上三角封死。第 t 行只看得见前 t 个词——恰好就是"生成第 t+1 个词时模型真实见过的东西"。
现在把掩码后的 softmax 算出来(第 8 课的手法,只是每行的分母里少了几项):
手算:掩码后的三行配比
猫行 [0.5, −∞, −∞] → e 指数 [1.65, 0, 0],总和 1.65 → [1, 0, 0]——句首词 100% 只能看自己。
吃行 [0, 2, −∞] → [1, 7.39, 0],总和 8.39 → [0.12, 0.88, 0]——猫 12%、自己 88%,鱼出局。
鱼行 [0.5, 0.5, 0](最后一行没有未来可掩)→ [0.38, 0.38, 0.23],与第 8 课相同。
对照第 8 课没掩码的配比,变化最剧烈的是吃行:
| 吃行的配比 | → 猫 | → 吃(自己) | → 鱼(未来) |
| 第 8 课 · 未掩码 | 9% | 67% | 24% |
| 本课 · 已掩码 | 12% | 88% | 0% |
注意一个细节:掩码不只是"删掉 24%",剩下的配比还会重新分摊(9%→12%、67%→88%)——因为 softmax 的分母里少了鱼那份 e 指数。下面把这场事故的"案发现场"读出来——用可读维度算吃的上下文向量:
手算:泄漏是看得见的——「吃」的上下文向量,掩码前后
未掩码:0.09×[1, 0] + 0.67×[0.1, 0.1] + 0.24×[0, 1] = [0.16, 0.31]——鱼味 0.31 居然比猫味 0.16 还重!「吃」的向量里混进了满满的未来:这就是数据泄漏的现场证据,不是抽象概念,是向量里真实写着的内容。
已掩码:0.12×[1, 0] + 0.88×[0.1, 0.1] + 0×[0, 1] = [0.21, 0.09]——猫味为主,鱼味只剩吃自己那点淡淡的底味。「吃」只能凭过去理解自己,这才是它生成时真实拥有的信息。
三个观察,每个都值得停一秒:①句首只能看自己(猫行 100%)——附录动画里那行扎眼的 [100%, 0, 0],现在你知道为什么了;②越往后看得越多(1 格 → 2 格 → 3 格),信息像涨潮一样往右涌;③每行可见格数 = 行号 + 1——训练时每道题的"可见范围",和生成时真实的可见范围,严格一致。练的就是考的,考的就是练的。
三、−∞ 的技巧,与"先掩码后 softmax"的顺序
公式 + 逐符号讲解(掩码的写法——每个符号什么意思、数字从哪来)
$$\tilde{s}_{ij} = \begin{cases} s_{ij}, & j \le i \\ -\infty, & j > i \end{cases} \qquad \text{再对每行做 softmax(第 8 课老朋友)}$$
$i$(行号):提问者的位置——第 $i$ 个词(从 0 数);$j$(列号):被看者的位置。$j \le i$ 读作"j 不晚于 i" = 过去或自己 → 分数照留;$j > i$ = 严格在未来 → 压成 $-\infty$。$s_{ij}$ = 第 8 课缩放后的分数(本课吃行 $s_{1,2} = 1$,即"吃给鱼"那格);波浪号 $\tilde{s}$ = 掩码后的分数,送进 softmax。
下标防混淆:这里的 i、j 是位置编号,与第 8 课 softmax 框里的候选编号同角色;和 $d_k$ 的 k 无关。论文里也常写成加法形式 $S + M$:M 是"0 / −∞"两张表的其中一个,加到分数表上效果相同,叫加性掩码。
两个实现细节,每个都值得抠:
两个必抠的细节
① 为什么压 −∞,不是置 0?手算一遍就明白:分数 [2, 0],想把第一格(未来)封掉。若只置 0:softmax([0, 0]) = [50%, 50%]——它还能分走一半注意力,还在偷看!压成 −∞:$e^{-\infty} = 0$,softmax([−∞, 0]) = [0%, 100%]——彻底出局。置 0 只是"把答案藏起来",−∞ 才是"把答案销毁"。
② 为什么必须"先掩码、后 softmax"?softmax 的本职是归一化——先定谁有资格参赛,再分蛋糕。反过来的话:先 softmax,未来词已经分到了份额,再把它的权重置 0,行和就不等于 1 了(蛋糕分完了才踢人,账对不上)。所以顺序是铁的:打分 → 掩码 → softmax → 加权 V。
工程上还有一个小花招:浮点数里没有真的 −∞,代码习惯用一个"足够大"的负数,比如 $-10^9$——softmax 分子分母同减最大值后,$e^{-10^9}$ 早就下溢成 0,效果与 −∞ 无异。附录 py 块里那行 S[np.triu(np.ones((L, L), dtype=bool), 1)] = -1e9,现在你可以逐字符讲给旁人听了(np.triu(..., 1) = 取严格上三角的布尔表,恰好就是"未来"那片格子)。
四、掩码实验室:亲眼看未来被切掉
下面这台实验室把上面的手算全部变成可操作的:点词选提问者,看它的分数行和配比;一键在「已掩码 / 未掩码」之间切换,亲眼看未来格从红框"偷看"变成 ×、配比怎么重分摊、上下文向量怎么改写;点配比条能调出这一行 softmax 的完整算式,与手算互证:
交互 · 因果掩码实验室:点词看行,开关掩码看未来被切掉
建议按这个顺序玩:选「吃」→ 切到"未掩码",看鱼味怎么涨过猫味(泄漏现场)→ 切回"已掩码",看它归零、其余配比重分摊 → 再选「猫」,体会句首词的 100%。分数、配比全部与课文手算一致;v 的可读维度(猫味/鱼味)是本课示意,真实模型里的维度语义由训练自然形成,不会这么干净。
五、落点:训练与生成,被同一张掩码统一了
机制讲完,回到那个值钱的问题:费这么大劲,到底买到什么?三样东西:
- 训练可以并行了。整句一次前向,L 道题同时作答,掩码保证没人抄答案——还记得第 7 课 RNN 的死穴一吗("第 t 步必须等第 t−1 步,GPU 上万个核心干等")?因果掩码 + 教师强制,正是那个死穴的解药:训练时 GPU 满负荷,L 道题一张卷一次改完。
- 生成时不需要额外做任何事。生成是逐词喂入:写完一个词才有下一个词,输入里本来就没有未来。掩码在训练时保证"练的就是这种题型"——每个位置只见过过去,生成时它面对的正是同样的局面。训练与生成,被同一张下三角表统一了。
- 附录动画的最后一块拼图。全景图 5 圈自回归,每圈输入长一格、每圈只信最后一行——它可信的根基就是本课这张表:第 t 行只看得见前 t 个词,恰好是第 t 轮生成时模型真实拥有的东西。
六、dropout:训练期的"随机闭眼"
掩码之外,GPT 的注意力还常配第二块刹车:dropout(Vizuara 1.14)。老规矩三问:
为啥需要?网络容量一大,最爱的作弊方式是过拟合——把训练集的答案死记硬背下来,几个单元结成"小团体"互相兜底(合谋作弊),换句新话就原形毕露。它是什么?训练时每一步随机把一部分权重(这里是 softmax 之后的注意力配比)直接置 0,剩下的放大 $\tfrac{1}{1-p}$ 补偿,推理时全部恢复。小团体刚结成就被拆散——每一步训练面对的都是"随机缺几块"的网络,逼它把知识分散存储,谁都不能被独依赖。代价:单步前向变笨、收敛慢一点;换来的是相当于同时训练了指数多个子网络的集成,泛化明显变好。用多大?按丢的比例 $p$ 记,注意力配比上常用 0.1(GPT-2 就是 0.1)。
手算:dropout 迷你版(p = 0.5)
某行配比 [0.4, 0.3, 0.2, 0.1],本步随机丢掉第 2 格:→ [0.4, 0.3, 0.2, 0.1],存活三格放大 ×1/(1−0.5) = ×2 → [0.8, 0, 0.4, 0.2]。放大系数 $\tfrac{1}{1-p}$ 的用意:每格被留下的概率是 1−p,放大 (1−p)⁻¹ 后期望值恰好回到原配比——训练时"平均而言"还是原来那张配比表,只是每步都在随机地缺零件。哪些格被丢,每一步都重新摇号。
与掩码对照一遍,别混:掩码封的是未来,训练、生成都在;dropout 封的是随机格子,只在训练期开。一个管"不许看哪",一个管"不许总靠谁"。
七、代码:把刹车装进第 8 课的类
# ============================================================
# 【演示目标】把"偷看未来"量化:随机分数下,每个位置平均把多少
# 注意力分给了"未来的词"(自己右边的词)——掩码前 vs 掩码后。
# 【思路】
# 1. 不训练,用随机 q/k 模拟"还没学会任何模式"的注意力:
# 随机分数 softmax 后接近均匀 → 每个位置大致按可见词数平分
# 2. L=8 个词,30 组随机 q/k 取平均(消运气),统计每个位置
# 流向未来的权重和(位置 t 的未来 = 第 t+1..L-1 个词)
# 3. 对照:加因果掩码后,未来权重应恒等于 0
# 读法: 未掩码时句首(位置 0)约 85% 的注意力在看未来(均匀理论 87.5%)——
# 八个词里它只"信任"自己一个,其余全是答案;掩码后一列 0.0%,干净。
# 均匀理论值 (L-1-t)/L 一并列出,对账用。
# ============================================================
import numpy as np
rng = np.random.RandomState(7)
L, d = 8, 16
def softmax(x, axis=-1): # 第 8 课老朋友:减最大值防溢出
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
future_u, future_m = [], []
for _ in range(30): # 30 组随机 q/k 取平均
Q, K = rng.randn(L, d) * 0.8, rng.randn(L, d) * 0.8
S = Q @ K.T / np.sqrt(d) # 打分 + 缩放(第 8 课)
A_free = softmax(S) # 未掩码:全句可见(事故现场)
S_masked = S - 1e9 * np.triu(np.ones((L, L)), 1) # 加性掩码:上三角压 -1e9
A_masked = softmax(S_masked) # 先掩码,后 softmax(顺序铁律)
future_u.append([A_free[t, t+1:].sum() for t in range(L)])
future_m.append([A_masked[t, t+1:].sum() for t in range(L)])
fu = np.mean(future_u, axis=0)
fm = np.mean(future_m, axis=0)
print("位置 t | 流向未来的注意力(未掩码) | 均匀理论 (L-1-t)/L | 掩码后")
for t in range(L):
print(" %d | %5.1f%% | %5.1f%% | %.1f%%"
% (t, fu[t]*100, (L-1-t)/L*100, fm[t]*100))
# 读法:第一行 ≈ 87% —— 位置 0 的注意力几乎全在看"还没轮到它看的词"。
# 掩码列全 0:不是少看了一点,是一格都不许看。训练若不掩码,
# 模型最优策略就是把未来抄进来——损失漂亮,能力为零。
# ============================================================
# 【演示目标】① 把因果掩码与 dropout 装进第 8 课的注意力,得到
# GPT 同款骨架的 CausalSelfAttention;② 亲眼看 dropout 在配比表
# 上做什么、训练态和推理态差在哪。
# 【思路】
# A. 类里新增两处:先掩码(S 的上三角压 -1e9)再 softmax;
# 可选 dropout(仅训练态:随机置零 × 放大 1/(1-p))
# B. 体检:固定种子,X(4 词 × 8 维) → 打印配比表 A——
# 下三角有值、上三角全 0,就是课文那张 3×3 的放大版
# C. dropout 体验:同一张 A,p=0.5 训练态 vs 推理态对比
# 读法: A 的上三角全是 0.00 = 未来出局;行和仍为 1 = 只有可见词分摊。
# dropout 后部分格子变 0、存活格放大 ×2;推理态原样全开——
# "同一个模型,两种用法":训练故意缺零件,上场满编制。
# ============================================================
import numpy as np
rng = np.random.RandomState(3)
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
def dropout(A, p, rng):
keep = (rng.rand(*A.shape) > p) # 每格独立:1-p 概率存活
return A * keep / (1 - p) # 存活格放大 1/(1-p),期望不变
class CausalSelfAttention:
"""第 8 课 SelfAttention + 因果掩码 + dropout —— GPT 每层的同款骨架"""
def __init__(self, d_in, d_k):
self.d_k = d_k
self.Wq = rng.randn(d_in, d_k) * 0.5 # 三副眼镜(第 7 课)
self.Wk = rng.randn(d_in, d_k) * 0.5
self.Wv = rng.randn(d_in, d_k) * 0.5
def forward(self, X, training=False, p_drop=0.5):
L = X.shape[0]
Q, K, V = X @ self.Wq, X @ self.Wk, X @ self.Wv # 投影
S = Q @ K.T / np.sqrt(self.d_k) # ① 打分 ② 缩放
S[np.triu(np.ones((L, L), dtype=bool), 1)] = -1e9 # ③ 掩码:先于 softmax!
A = softmax(S) # ④ softmax:未来权重 = 0
if training: # dropout 只在训练态开
A = dropout(A, p_drop, rng)
return A @ V, A # ⑤ 加权 V → 上下文
X = rng.randn(4, 8) # 4 个词,每个 8 维嵌入
attn = CausalSelfAttention(8, 8)
np.set_printoptions(precision=2, suppress=True)
Z, A = attn.forward(X, training=False) # 推理态:dropout 关闭
print("推理态 配比表 A(4×4,行=提问者,列=被看者):")
print(A)
print("每行和:", A.sum(axis=1), " ← 仍全为 1:只有可见词分摊 100%")
print("上三角全 0 —— 课文那张 3×3 掩码表的放大版\n")
Zt, At = attn.forward(X, training=True, p_drop=0.5)
print("训练态 同一张配比表过 dropout(p=0.5):")
print(At)
print("部分格子被清 0,存活格放大 ×2 —— 每步训练摇号换位置,")
print("逼网络把知识分散存,不许几个单元合谋。输出 Z 形状:", Z.shape, "=", Zt.shape)
# 读法:对照两张表——掩码封"未来"(位置固定,训练生成都在),
# dropout 封"随机格"(位置每步变,只在训练)。两块刹车,各管各的。
本课胜利
① 不掩码 = 答案印在考卷上:训练抄未来、损失虚低,生成当场露馅(数据泄漏);② 因果掩码 = 分数表右上三角压 −∞,softmax 后未来权重恒 0——你手算了掩码前后吃行的配比(24%→0)和上下文向量([0.16, 0.31]→[0.21, 0.09],泄漏在向量里看得见);③ 两个铁律:压 −∞ 不是置 0(e⁰=1 还能偷看)、先掩码后 softmax(先定资格再分蛋糕);④ 每行可见格数 = 行号+1,训练的可见范围 = 生成的可见范围——并行训练与逐词生成被同一张下三角表统一;⑤ dropout = 训练期随机闭眼(置零 × 放大 1/(1−p),期望不变),与掩码各管各的。你刚刚亲手算过的这张下三角表,就是 GPT 每一层都在用的那张——附录里打 × 的三个格子,现在归你管了。下一课:一套 Q/K/V 只打得出一种分数——指代、语法、情感,一个头照顾得过来吗?
检索练习
本课主读材料
我是你的老师,别客气
"BERT 为什么可以双向、不要掩码""生成时也要开 dropout 吗""−1e9 换成 −1e6 行不行"——这类问题都在正道上,贴回来问。下一课把注意力切成多头,一句话里的多种关系各有各的眼。