上一篇讲架构时,解码器里有一个子层叫“掩码多头自注意力”,我只用一句话带过。这句简单的描述藏着整个训练方式的关键,今天要把它想清楚。
那个看似矛盾的场面
训练语言模型时,我们把“今天 天气 不错”整句同时送进网络,一次前向计算就得到三个位置的向量,各自负责一个预测:位置 0 预测“天气”,位置 1 预测“不错”,位置 2 预测结束符。一句话一次算完三个训练样本,这就是 Transformer 训练效率的来源。
问题是:位置 0 做注意力时,“天气”和“不错”就在输入里,它凭什么看不见?
如果真让它看见,模型很容易学会“后面是什么就照着报什么”,训练损失迅速接近零;但真到生成时,第一个词的位置后面什么也没有,模型立刻失效。这和拿训练集当测试集是同一种自欺。
所以我们要的是一对看似矛盾的要求:整句并行读入,每个位置却只能看见自己和前面的词。 掩码(mask)做的就是这件事。
把分数改成负无穷,而不是事后把权重清零
自注意力的最后一步是:每个位置对所有位置打出分数,softmax 把分数变成一组加起来等于 1 的权重,再用权重加权求和 Value 向量。
softmax 可以理解成“按分数分配一个固定预算”。要屏蔽某个位置,就把它的分数改成 :因为 ,它分到的权重正好是 0;而 softmax 只对剩下的项做归一化,于是剩下的位置自动把预算重新分完,不需要再补一步归一化。
具体看位置 1 对三个位置打出的分数 。不掩码时,、、,和为 30.20,权重是 ,未来的“不错”拿走了 66% 的注意力。掩码后分数变成 ,和为 10.11,权重是 。那 0.665 并没有消失,而是按 1:2 被前两个位置分掉了。
反过来做不行:先算完整 softmax,再把未来位置的权重清零,得到 ,和只有 0.335,输出的量级就错了。若再除以 0.335 校正,数学上确实等价,但要额外遍历一次、做一次除法。所以标准做法是在 softmax 之前加上那张表。
这张表就是下三角
“位置 只能看 ”写成矩阵就是下三角(下面用 1 表示允许),对角线要保留,因为预测下一个词时,当前这个位置本身也是输入:
11 0 0 0
21 1 0 0
31 1 1 0
41 1 1 1对应的代码也短:
1allowed = torch.tril(torch.ones(T, T, dtype=torch.bool)) # 下三角,含对角线
2scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) # (T, T)
3scores = scores.masked_fill(~allowed, torch.finfo(scores.dtype).min)
4weights = scores.softmax(dim=-1) # 每行和为 1实现里通常不写真的 ,而用该浮点类型能表示的最小有限值(如 finfo(dtype).min),这样在整行都被屏蔽的情况下,softmax 内部也不会算出 NaN。
因果性怎么在几十层之间保住
有人会追问:第一层只让位置 1 看到 0 和 1,堆到 96 层以后,信息会不会从别处绕回来?
不会。位置 1 在第一层的输出只用到位置 0、1 的 Value;接下来的残差相加和层归一化是逐元素的,前馈网络是逐位置独立处理的,都不会混进别的词的信息。第二层再做因果注意力时,输入里仍然只含前两个位置的信息。一层层推下去,第 96 层位置 1 的表示依然只依赖前两个输入。
“整句并行”和“不偷看”能同时成立,理由就是这两条:唯一的跨位置运算只有注意力,而它被掩码限制成只看过去;其余计算全都逐位置进行。
第二种掩码:padding
真实训练要把长短不一的句子凑成一个 batch,短的补 padding 到最长那句的长度。padding 是填充物,真实词若从它身上取信息,同一句话的输出就会随“这一批最长的那句有多长”而变。
两种掩码看着像一回事,作用的维度不同:
- 因果掩码每一行都不一样,是否允许取决于行号和列号谁大,是 的表,与 batch 无关。
- padding 掩码只看列:这一列是不是填充物,对所有 query 都一样,形状是 ,广播成 。
合并规则是“两个都允许才允许”,也就是逻辑与;写成加法时恰好也是 、任何数加 仍是 ,所以一张加性掩码表装得下两条规则。
一个容易想错的边界:右 padding 时,每个真实词都在 padding 左边,也就是 padding 处在“未来”,因果掩码本来就不让看,所以真实位置的输出不受影响;漏写 padding 掩码的问题往往只在批量评测、或改成左 padding 时才暴露。另外,padding 行“不计分”是另一件事,要靠 loss mask(如交叉熵的 ignore_index)单独处理:注意力掩码决定谁看谁,loss mask 决定哪些位置算分。
掩码的全部内容,就是往分数上加了一张表。但没有这一行加法,“整句并行训练”就等于让模型抄答案,decoder-only 架构根本训不出来。下一篇继续:最后一层的向量怎么变成词表上的概率,模型又怎么一个词一个词地生成。