注意力掩码里的 True:同一个矩阵,两个 API 含义相反
一个小型语言模型的训练损失下降得出奇地快,验证集上也很好,可是逐词生成时输出的全是乱码。原因是训练时每个位置都看到了后面的词:模型学会的是「抄答案」,而生成时答案还不存在。
因果语言模型的前提是:位置 t 的输出只能依赖位置 0..t 的输入。保证这一点的就是注意力掩码。掩码写错时程序不会报错,损失还会很好看,所以这类错误往往要到生成阶段才暴露。更麻烦的是,PyTorch 里两个常用的注意力接口对布尔掩码的约定恰好相反。
本文在 PyTorch 2.14.1(CPU)上用一个简单的方法检验掩码:扰动位置 t 之后的输入,看位置 0..t 的输出是否变化。变了,就说明有未来信息泄漏。实验见文末。
scaled_dot_product_attention 得到因果注意力;原样传给 nn.MultiheadAttention,每个位置只看得到未来。扰动位置 2 之后的输入,位置 0..2 的输出随之变化,就是泄漏。一、两个 API,两种约定
| 接口 | 布尔掩码中 True 的含义 | 因果掩码应该传 |
|---|---|---|
F.scaled_dot_product_attention(attn_mask=...) | 参与注意力 | 下三角(含对角线)为 True |
nn.MultiheadAttention(attn_mask=...) | 不允许注意(遮住) | 严格上三角为 True |
nn.MultiheadAttention(key_padding_mask=...) | 这个位置是填充,忽略 | 填充位置为 True |
scaled_dot_product_attention 传下三角 True(True=参与):与 is_causal=True 一致:True
nn.MultiheadAttention 传严格上三角 True(True=遮住):位置 0 对位置 1..4 的权重和 0.0;位置 2 之后的输入会影响位置 0..2:不会
nn.MultiheadAttention 误传下三角 True:位置 0 对自己的权重 0.0,只看未来;位置 2 之后的输入会影响位置 0..2:会把为 scaled_dot_product_attention 写的下三角矩阵原样传给 nn.MultiheadAttention,掩码就完全反了:每个位置看不到自己和过去,只能看到未来。这正是开头那种「训练很好、生成乱码」的模式。
浮点掩码没有这个歧义,它按加法作用在注意力分数上:遮住的位置是 -inf,其余是 0。nn.Transformer.generate_square_subsequent_mask 生成的就是这种。
nn.MultiheadAttention 传浮点掩码(遮住处 -inf,其余 0,按加法作用):与布尔上三角结果一致:True
generate_square_subsequent_mask 生成浮点掩码,位置 0 行:['0', '-inf', '-inf', '-inf', '-inf']还有一个容易误会的参数:
nn.MultiheadAttention 只传 is_causal=True、不传 attn_mask:RuntimeError(is_causal 只是提示,掩码仍要自己给)在 scaled_dot_product_attention 里,is_causal=True 会直接应用因果掩码;在 nn.MultiheadAttention 和 nn.Transformer* 模块里,is_causal 只是告诉实现「你传的掩码是因果的,可以走优化路径」,掩码本身仍然要传。
二、全被遮住的行:结果取决于计算路径
如果某一行的所有位置都被遮住,softmax 的分母是零。
scaled_dot_product_attention 传严格上三角 True:与 is_causal=True 一致:False;最后一行全被遮住,这一行输出 [0.0, 0.0, 0.0, 0.0]
最后一行全被遮住:need_weights=True 时这一行输出全是 NaN:True;need_weights=False 时含 NaN:False(走了另一条计算路径,全遮住的行得到 0)同一个 nn.MultiheadAttention 模块、同一个掩码,只是 need_weights 不同,全遮住的行就一个是 NaN、一个是 0。NaN 会在后续层里传播,让整个批次的损失变成 NaN;0 则悄无声息。两种结果都不该依赖:出现全遮住的行,说明掩码的构造或者数据(例如一条完全是填充的样本)有问题,应该在数据阶段就排除。
三、填充掩码
批量训练时,短序列要补齐到同样的长度。填充位置不是真实的输入,不应该被注意到。
不传 key_padding_mask:真实词的输出与不填充时一致:False;随填充向量变化:True
传 key_padding_mask(True=填充):真实词的输出与不填充时一致:True;随填充向量变化:False不传填充掩码时,同一句话补齐的内容不同,真实词的输出就不同:模型的结果取决于这句话和谁分在了同一批。传了之后,输出与不填充时完全一致。
因果掩码和填充掩码管的是两件事,通常要同时传:前者限制「能看哪些位置」,后者排除「哪些位置不是真的」。填充位置在损失里也要排除,那是标签的事,见微调样本的标签对齐。
四、编码器—解码器结构里,memory 也要遮
nn.TransformerDecoder 有两次注意力:对目标序列的自注意力(用 tgt_mask),和对编码器输出 memory 的交叉注意力(用 memory_mask)。在翻译这类任务里,memory 是源语言句子,解码器看到它的全部是合理的。但如果把同一段输入既当 tgt 又当 memory,事情就变了:
TransformerDecoder(tgt=x, memory=x),只给 tgt_mask:位置 2 之后的输入会影响位置 0..2:会(交叉注意力看得到整段 memory)
同时给 memory_mask:不会
只用自注意力的「仅解码器」结构(TransformerEncoder + 因果掩码):不会
TransformerEncoder 不给因果掩码:会自注意力遮住了未来,交叉注意力又把整段未来送了进来。GPT 这类「仅解码器」模型实际上只有带因果掩码的自注意力,没有交叉注意力;在 PyTorch 里搭这种结构,用 nn.TransformerEncoder 加因果掩码更直接(模块名字里的 Encoder 与 Decoder 指的是结构,不决定是否因果)。
五、把扰动检验写成测试
掩码错误不会报错,所以值得为它写一个单元测试:
def leaks(fn, x, t):
"""扰动 t 之后的位置,看位置 0..t 的输出是否变化。"""
y1 = fn(x)
x2 = x.clone()
x2[:, t + 1:] += torch.randn_like(x2[:, t + 1:])
y2 = fn(x2)
return bool((y1[:, : t + 1] - y2[:, : t + 1]).abs().max() > 1e-6)
assert not leaks(model_forward, x, t=2)它不关心掩码是怎么构造的、用的哪个 API,只检查「未来是否影响过去」这个性质本身。模型在 eval() 模式下运行(关闭 dropout),结果是确定的。换注意力实现、升级框架或者重构模型之后,这个测试都能直接复用。
六、检查清单
- 用布尔掩码时先查清楚这个 API 的 True 是「参与」还是「遮住」;不确定就用浮点掩码。
nn.MultiheadAttention的is_causal只是提示,掩码仍要传。- 因果掩码和填充掩码通常同时需要;不要让任何一行全被遮住。
- 自己的输入当作 memory 时,交叉注意力也要遮住未来;仅解码器结构只用带因果掩码的自注意力。
- 为模型写一个扰动检验,作为单元测试。
配套实验
- codesphere-labs/ai/attention-mask-semantics:Python 3.14.8、PyTorch 2.14.1 CPU:两个 API 的布尔约定、浮点掩码、
is_causal提示、全遮住的行、填充掩码、解码器以输入为 memory 的泄漏(验证记录)
参考资料