Xinheng Ding

Model Architectures › Transformer · Note 1

Attention Masks in the Transformer

Oct 18, 2026

While reading The Annotated Transformer, I kept tripping over its two masks. The source mask has shape (batch, 1, 1, m). The decoder mask has shape (batch, 1, n, n). Padding appears on both sides of attention, yet only some of it is masked, and it shows up once more in the loss. This note follows a single batch through all three places and explains every shape with one principle.

Where the mask acts

Inside multi-head attention, the scores form a tensor of shape (batch, h, n, m). Entry scores[b, h, i, j] is how strongly query position i attends to key position j, for sentence b and head h. The mask is applied right before the softmax:

python
def attention(query, key, value, mask=None, dropout=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    p_attn = scores.softmax(dim=-1)
    ...
    return torch.matmul(p_attn, value), p_attn

Wherever the mask is 0, the score becomes −10⁹ and its softmax weight becomes effectively 0. The softmax runs over the key axis (dim=-1), so the keys that remain visible in each row still sum to 1. The order matters. Zeroing weights after the softmax would leave rows that no longer sum to 1. The value −10⁹ is used instead of −∞ because a fully masked row would turn −∞ into 0/0 = NaN.

The principle

A mask answers one question: may query i look at key j?

Write out an axis only if the answer depends on it. Leave every other axis at size 1 and let broadcasting copy it.

PyTorch aligns shapes from the right and stretches any axis of size 1. A mask therefore stores only the axes along which its value actually changes. For any mask, ask which of b, h, i, j the answer depends on. The shape follows directly.

Masks hide columns, not rows

A column j is a key/value pair, a source of information. A row i is a query, a reader. Padding masks only ever hide columns. Which columns to hide is decided by the padding of the sequence that supplies K and V.

k0 k1 k2 k3 k4 q0 q1 q2 q3 q4 keys / values: sources of information queries (readers) 5 7 9 pad pad visible masked
Figure 1: Padding is hidden as a source. In encoder self-attention, the same padded sentence supplies both the rows and the columns. Only the padded columns are masked. The padded rows still compute an output, but no one reads it.

The source mask: (batch, 1, 1, m)

Whether key j may be seen depends only on whether source token j is padding:

text
mask[b, h, i, j] = (src[b, j] != pad)        # depends on b and j only

The batch axis is full, because each sentence pads differently. The head axis and the query axis are 1, because every head and every query obeys the same rule. The key axis is full. The mask is built once per batch in Batch, and multi-head attention adds the head axis:

python
class Batch:
    def __init__(self, src, tgt=None, pad=2):  # 2 = <blank>
        self.src = src
        self.src_mask = (src != pad).unsqueeze(-2)  # (batch, 1, m)
        ...

class MultiHeadedAttention(nn.Module):
    def forward(self, query, key, value, mask=None):
        if mask is not None:
            # Same mask applied to all h heads.
            mask = mask.unsqueeze(1)  # (batch, 1, 1, m)
        ...
k0 k1 k2 k3 k4 stored: (·, 1, 1, m) broadcast k0 k1 k2 k3 k4 q0 q1 q2 q3 q4 used: (·, h, n, m) copied n times
Figure 2: One row is enough. The source mask stores a single row per sentence. Broadcasting copies it across all h heads and all n queries when it meets the score tensor.

The same src_mask is used twice: in the encoder's self-attention, and in the decoder's cross-attention, discussed below.

The decoder mask: (batch, 1, n, n)

The decoder's self-attention hides two things: padding, and the future.

text
mask[b, h, i, j] = (tgt[b, j] != pad)  AND  (j <= i)
                    depends on b, j          depends on i, j

The causal rule j ≤ i involves the query position. Position 0 sees one token and position 3 sees four, so the rows now differ and the query axis has to be written out. Both axes have length n, because queries and keys both come from the target sentence. The code builds each factor with only its own axes and lets & broadcast them together:

python
def subsequent_mask(size):
    "Mask out subsequent positions."
    attn_shape = (1, size, size)
    subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8)
    return subsequent_mask == 0  # (1, n, n), lower triangle

class Batch:
    @staticmethod
    def make_std_mask(tgt, pad):
        "Create a mask to hide padding and future words."
        tgt_mask = (tgt != pad).unsqueeze(-2)  # (batch, 1, n)
        tgt_mask = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)
        return tgt_mask  # (batch, n, n)
<s> 14 15 </s> <pad> padding · depends on (b, j) ∧ causal · depends on (i, j) = decoder mask · (b, i, j) <s> <s> <s> 14 14 14 15 15 15 </s> </s> </s> <pad> <pad> <pad>
Figure 3: The decoder mask is padding ∧ causal. The padding factor is constant down each column. The causal factor is the same for every sentence. Only their conjunction depends on b, i and j together. The example is the second target sentence from the demo at the end.

The extra n in the shape comes entirely from the causal rule, not from padding. A padding-only target mask, as in a bidirectional encoder like BERT, would be (batch, 1, 1, n): the same form as the source mask.

Cross-attention reuses the source mask

In the decoder's second sub-layer, queries come from the target and keys/values come from the encoder memory. The keys are source tokens, so the mask is src_mask:

python
class DecoderLayer(nn.Module):
    def forward(self, x, memory, src_mask, tgt_mask):
        m = memory
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))  # target ↔ target
        x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))  # target → source
        return self.sublayer[2](x, self.feed_forward)

The score tensor is now (batch, h, n_tgt, m_src), which is not square. The mask still broadcasts, because its query axis is 1. There are two kinds of padding here:

0.10 0.22 0.11 0.57 0.54 0.05 0.10 0.32 0.06 0.12 0.61 0.20 0.21 0.09 0.28 0.41 0.26 0.33 0.32 0.09 6 8 3 4 pad <s> 14 15 </s> <pad> source keys (encoder memory) target queries ← a <pad> query: not masked, its output is dropped by the loss ← every row sums to 1
Figure 4: Real cross-attention weights (sentence 1, head 0, random Q/K) from the demo below. The padded source column receives exactly 0. The padded target row is a valid distribution over the real source tokens.

Padding meets the loss

The decoder is trained with teacher forcing. Its input tgt is the target without its last token, and the labels tgt_y are the target shifted left by one. Positions whose label is padding must contribute nothing. Batch counts the real labels:

python
class Batch:
    def __init__(self, src, tgt=None, pad=2):
        ...
        self.tgt = tgt[:, :-1]  # decoder input
        self.tgt_y = tgt[:, 1:]  # labels, shifted by one
        self.tgt_mask = self.make_std_mask(self.tgt, pad)
        self.ntokens = (self.tgt_y != pad).data.sum()  # real labels only

The criterion builds a smoothed target distribution, then removes padding twice. The <blank> class never receives probability mass. Every position whose label is <blank> gets an all-zero target row. With KLDivLoss(reduction="sum"), an all-zero row contributes exactly 0 loss and 0 gradient:

python
class LabelSmoothing(nn.Module):
    def forward(self, x, target):
        true_dist = x.data.clone()
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
        true_dist[:, self.padding_idx] = 0  # never predict <blank>
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)  # drop <blank> positions
        return self.criterion(x, true_dist.clone().detach())  # KLDivLoss(reduction="sum")

class SimpleLossCompute:
    def __call__(self, x, y, norm):
        x = self.generator(x)
        sloss = self.criterion(x.contiguous().view(-1, x.size(-1)),
                               y.contiguous().view(-1)) / norm
        return sloss.data * norm, sloss

# in run_epoch
out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)
loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)
11 12 13 </s> <pad> sentence 0 14 15 </s> <pad> <pad> sentence 1 tgt_y (what each position must predict) counted in loss and ntokens (= 7) target distribution zeroed: contributes 0 loss, 0 gradient
Figure 5: The loss only sees real labels. The summed loss is divided by ntokens, the number of non-padding labels. The result is a per-token average that does not depend on how much padding the batch carried.

This completes the picture. Padding is masked where it would act as a source: key columns, in both self-attention and cross-attention. It is dropped where it would act as a reader: in the loss.

When is a causal mask needed?

A causal mask prevents exactly one thing: peeking at tokens that have not been generated yet. That can only happen when queries and keys lie on the same sequence that is being produced step by step. In the standard encoder–decoder, that is only the decoder's self-attention.

Summary

MaskUsed inDepends onShape
Source maskEncoder self-attn, decoder cross-attnis key j padding · (b, j)(batch, 1, 1, m)
Decoder maskDecoder self-attnkey j not padding and j ≤ i · (b, i, j)(batch, 1, n, n)
Loss maskLabelSmoothingis label padding · (b, i)rows of true_dist zeroed

A mask stores only the axes its answer depends on. The causal rule is the only rule that depends on the query position, which is why the decoder mask is the only one with a full query axis.

Appendix: try it yourself

A self-contained script that reproduces every shape and number in this note. The helpers mirror the_annotated_transformer.py. I ran it with PyTorch 1.11, the version pinned by the repository.

mask_demo.py
python
"""Self-contained check of the mask shapes and loss masking used in the note.

The helper definitions mirror the_annotated_transformer.py.
"""
import math

import torch
import torch.nn as nn

torch.manual_seed(0)
torch.set_printoptions(precision=2, sci_mode=False, linewidth=120)
PAD, BOS, EOS = 2, 0, 1


def subsequent_mask(size):
    attn_shape = (1, size, size)
    return torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8) == 0


class Batch:
    def __init__(self, src, tgt, pad=PAD):
        self.src = src
        self.src_mask = (src != pad).unsqueeze(-2)
        self.tgt = tgt[:, :-1]
        self.tgt_y = tgt[:, 1:]
        tgt_mask = (self.tgt != pad).unsqueeze(-2)
        self.tgt_mask = tgt_mask & subsequent_mask(self.tgt.size(-1)).type_as(tgt_mask)
        self.ntokens = (self.tgt_y != pad).data.sum()


def attention(query, key, value, mask=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    p_attn = scores.softmax(dim=-1)
    return torch.matmul(p_attn, value), p_attn


class LabelSmoothing(nn.Module):
    def __init__(self, size, padding_idx, smoothing=0.0):
        super().__init__()
        self.criterion = nn.KLDivLoss(reduction="sum")
        self.padding_idx, self.size = padding_idx, size
        self.confidence, self.smoothing = 1.0 - smoothing, smoothing

    def forward(self, x, target):
        true_dist = x.data.clone()
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
        true_dist[:, self.padding_idx] = 0
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)
        self.true_dist = true_dist
        return self.criterion(x, true_dist.clone().detach())


# Sentence 0: 3 real source tokens + 2 pads; sentence 1: 4 real + 1 pad.
src = torch.tensor([[5, 7, 9, PAD, PAD],
                    [6, 8, 3, 4, PAD]])
# Targets: <s> w w w </s> <pad>   and   <s> w w </s> <pad> <pad>
tgt = torch.tensor([[BOS, 11, 12, 13, EOS, PAD],
                    [BOS, 14, 15, EOS, PAD, PAD]])
b = Batch(src, tgt)

print("== shapes")
print("src_mask           ", tuple(b.src_mask.shape))
print("tgt_mask           ", tuple(b.tgt_mask.shape))
print("src_mask.unsqueeze ", tuple(b.src_mask.unsqueeze(1).shape))
print("tgt_mask.unsqueeze ", tuple(b.tgt_mask.unsqueeze(1).shape))
print("ntokens            ", int(b.ntokens))

print("\n== decoder mask, sentence 1 (tgt = <s> 14 15 </s> <pad>)")
print(b.tgt_mask[1].int())

h, d_k = 8, 64
n, m = b.tgt.size(1), b.src.size(1)
q = torch.randn(2, h, n, d_k)  # from the decoder (target side)
kv = torch.randn(2, h, m, d_k)  # from the encoder memory (source side)

print("\n== cross-attention weights, sentence 0, head 0  (rows = target queries, cols = source keys)")
_, p_cross = attention(q, kv, kv, mask=b.src_mask.unsqueeze(1))
print(p_cross[0, 0])
print("row sums:", p_cross[0, 0].sum(-1))

print("\n== cross-attention weights, sentence 1, head 0  (last row is a <pad> query)")
print(p_cross[1, 0])

print("\n== loss: LabelSmoothing ignores <pad> targets")
V = 16
crit = LabelSmoothing(size=V, padding_idx=PAD, smoothing=0.1)
logits = torch.randn(2, n, V).log_softmax(-1)
y = b.tgt_y
print("tgt_y:\n", y)
loss = crit(logits.view(-1, V), y.reshape(-1))
rows = crit.true_dist.sum(-1).view(2, n)
print("true_dist row sums (0 = ignored):\n", rows)
print("loss / ntokens = %.4f" % (loss / b.ntokens).item())
output
== shapes
src_mask            (2, 1, 5)
tgt_mask            (2, 5, 5)
src_mask.unsqueeze  (2, 1, 1, 5)
tgt_mask.unsqueeze  (2, 1, 5, 5)
ntokens             7

== decoder mask, sentence 1 (tgt = <s> 14 15 </s> <pad>)
tensor([[1, 0, 0, 0, 0],
        [1, 1, 0, 0, 0],
        [1, 1, 1, 0, 0],
        [1, 1, 1, 1, 0],
        [1, 1, 1, 1, 0]], dtype=torch.int32)

== cross-attention weights, sentence 0, head 0  (rows = target queries, cols = source keys)
tensor([[0.67, 0.11, 0.22, 0.00, 0.00],
        [0.08, 0.41, 0.51, 0.00, 0.00],
        [0.72, 0.06, 0.23, 0.00, 0.00],
        [0.20, 0.74, 0.06, 0.00, 0.00],
        [0.50, 0.26, 0.23, 0.00, 0.00]])
row sums: tensor([1.00, 1.00, 1.00, 1.00, 1.00])

== cross-attention weights, sentence 1, head 0  (last row is a <pad> query)
tensor([[0.10, 0.22, 0.11, 0.57, 0.00],
        [0.54, 0.05, 0.10, 0.32, 0.00],
        [0.06, 0.12, 0.61, 0.20, 0.00],
        [0.21, 0.09, 0.28, 0.41, 0.00],
        [0.26, 0.33, 0.32, 0.09, 0.00]])

== loss: LabelSmoothing ignores <pad> targets
tgt_y:
 tensor([[11, 12, 13,  1,  2],
        [14, 15,  1,  2,  2]])
true_dist row sums (0 = ignored):
 tensor([[1.00, 1.00, 1.00, 1.00, 0.00],
        [1.00, 1.00, 1.00, 0.00, 0.00]])
loss / ntokens = 2.5445

Code excerpts are from harvardnlp/annotated-transformer, lightly trimmed. Shape comments are mine.

模型架构 › Transformer · 第 1 篇

Transformer 中的 Attention Mask

2026 年 10 月 18 日

读 The Annotated Transformer 的时候,我反复卡在两个 mask 上: 源句 mask 的形状是 (batch, 1, 1, m),解码器 mask 的形状是 (batch, 1, n, n)。 attention 的两侧都有 padding,但只遮掉了其中一部分,而且 padding 在 loss 里还会再出现一次。 这篇笔记用同一个 batch 依次走过这三个地方,并用一条原则解释所有形状。

mask 作用在哪里

在多头注意力内部,打分是一个形状为 (batch, h, n, m) 的张量。scores[b, h, i, j] 表示第 b 个句子、第 h 个头里,query 位置 i 对 key 位置 j 的打分。mask 在 softmax 之前生效:

python
def attention(query, key, value, mask=None, dropout=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    p_attn = scores.softmax(dim=-1)
    ...
    return torch.matmul(p_attn, value), p_attn

mask 为 0 的位置,打分变成 −10⁹,softmax 之后权重约等于 0。softmax 沿 key 维(dim=-1)计算, 所以每一行剩下的可见 key,权重之和仍然是 1。顺序不能反:如果先 softmax 再把权重清零,每行的和就不再是 1。 用 −10⁹ 而不用 −∞,是因为一整行都被遮住时,−∞ 会算出 0/0 = NaN。

核心原则

mask 只回答一个问题:query i 能不能看 key j?

答案随哪一维变化,哪一维才需要写满;其余维度写 1,交给广播去复制。

PyTorch 从右往左对齐形状,并自动扩展大小为 1 的维度。所以 mask 只需要存储它的值真正会变化的那几维。 看任何一个 mask,只要问:答案取决于 b、h、i、j 中的哪几个?形状也就随之确定了。

mask 遮的是列,不是行

第 j 列是 key/value,是信息的来源;第 i 行是 query,是信息的读取方。padding mask 永远只遮列, 遮哪些列,取决于提供 K 和 V 的那个句子里哪些位置是 padding。

k0 k1 k2 k3 k4 q0 q1 q2 q3 q4 keys / values: sources of information queries (readers) 5 7 9 pad pad visible masked
图 1:padding 作为来源时被遮住。在编码器的 self-attention 里,同一个带 padding 的句子同时提供行和列。 只有 padding 的列被遮住;padding 的行照样计算输出,只是没有人读取它。

源句 mask:(batch, 1, 1, m)

key j 能不能被看,只取决于源句第 j 个词是不是 padding:

text
mask[b, h, i, j] = (src[b, j] != pad)        # 只依赖 b 和 j

batch 维写满,因为每个句子的 padding 位置不同;头的维度和 query 维是 1,因为所有头、所有 query 遵守同一条规则; key 维写满。这个 mask 在 Batch 里为每个 batch 构造一次,多头注意力再加上头的维度:

python
class Batch:
    def __init__(self, src, tgt=None, pad=2):  # 2 = <blank>
        self.src = src
        self.src_mask = (src != pad).unsqueeze(-2)  # (batch, 1, m)
        ...

class MultiHeadedAttention(nn.Module):
    def forward(self, query, key, value, mask=None):
        if mask is not None:
            # Same mask applied to all h heads.
            mask = mask.unsqueeze(1)  # (batch, 1, 1, m)
        ...
k0 k1 k2 k3 k4 stored: (·, 1, 1, m) broadcast k0 k1 k2 k3 k4 q0 q1 q2 q3 q4 used: (·, h, n, m) copied n times
图 2:存一行就够了。源句 mask 每个句子只存一行;和打分张量相遇时,广播会把它复制到全部 h 个头和全部 n 个 query 上。

同一个 src_mask 会用两次:一次在编码器的 self-attention 里,另一次在解码器的 cross-attention 里(见下文)。

解码器 mask:(batch, 1, n, n)

解码器的 self-attention 要遮两种东西:padding,以及未来的位置。

text
mask[b, h, i, j] = (tgt[b, j] != pad)  AND  (j <= i)
                    只依赖 b, j               依赖 i, j

因果规则 j ≤ i 用到了 query 位置:位置 0 只能看 1 个词,位置 3 能看 4 个。每一行不再相同,所以 query 维必须写满。 行和列的长度都是 n,因为 query 和 key 都来自目标句。代码让两个部分各自只写满自己依赖的维度,再用 & 广播合并:

python
def subsequent_mask(size):
    "Mask out subsequent positions."
    attn_shape = (1, size, size)
    subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8)
    return subsequent_mask == 0  # (1, n, n),下三角

class Batch:
    @staticmethod
    def make_std_mask(tgt, pad):
        "Create a mask to hide padding and future words."
        tgt_mask = (tgt != pad).unsqueeze(-2)  # (batch, 1, n)
        tgt_mask = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)
        return tgt_mask  # (batch, n, n)
<s> 14 15 </s> <pad> padding · depends on (b, j) ∧ causal · depends on (i, j) = decoder mask · (b, i, j) <s> <s> <s> 14 14 14 15 15 15 </s> </s> </s> <pad> <pad> <pad>
图 3:解码器 mask = padding ∧ 因果。padding 部分沿每一列都相同;因果部分对每个句子都相同; 只有两者相与的结果才同时依赖 b、i、j。例子是文末 demo 中的第二个目标句。

形状里多出来的那个 n 完全来自因果规则,与 padding 无关。如果目标句只需要遮 padding(例如 BERT 这样的双向编码器), mask 就是 (batch, 1, 1, n),和源句 mask 的形式一样。

cross-attention 复用源句 mask

解码器的第二个子层里,query 来自目标句,key/value 来自编码器的 memory。key 是源句的词,所以用的是 src_mask:

python
class DecoderLayer(nn.Module):
    def forward(self, x, memory, src_mask, tgt_mask):
        m = memory
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))  # 目标句 ↔ 目标句
        x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))  # 目标句 → 源句
        return self.sublayer[2](x, self.feed_forward)

此时打分张量是 (batch, h, n_tgt, m_src),不是方阵;但 mask 的 query 维是 1,照样可以广播。这里有两种 padding:

0.10 0.22 0.11 0.57 0.54 0.05 0.10 0.32 0.06 0.12 0.61 0.20 0.21 0.09 0.28 0.41 0.26 0.33 0.32 0.09 6 8 3 4 pad <s> 14 15 </s> <pad> source keys (encoder memory) target queries ← a <pad> query: not masked, its output is dropped by the loss ← every row sums to 1
图 4:真实的 cross-attention 权重(第 1 个句子、第 0 个头、随机的 Q/K),来自文末的 demo。 源句 padding 那一列的权重严格为 0;目标句 padding 那一行仍然是真实源句词上的一个正常分布。

padding 在 loss 里

解码器用 teacher forcing 训练:输入 tgt 是去掉最后一个词的目标句,标签 tgt_y 是左移一位的目标句。 标签是 padding 的位置不能贡献任何东西,Batch 会统计真实标签的数量:

python
class Batch:
    def __init__(self, src, tgt=None, pad=2):
        ...
        self.tgt = tgt[:, :-1]  # 解码器输入
        self.tgt_y = tgt[:, 1:]  # 标签,左移一位
        self.tgt_mask = self.make_std_mask(self.tgt, pad)
        self.ntokens = (self.tgt_y != pad).data.sum()  # 只数真实标签

损失函数先构造平滑后的目标分布,再两次去掉 padding:<blank> 这个类别永远不分到概率; 标签为 <blank> 的每个位置,整行目标分布都被清零。配合 KLDivLoss(reduction="sum"), 全零的一行恰好贡献 0 loss 和 0 梯度:

python
class LabelSmoothing(nn.Module):
    def forward(self, x, target):
        true_dist = x.data.clone()
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
        true_dist[:, self.padding_idx] = 0  # 永不预测 <blank>
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)  # 丢掉 <blank> 位置
        return self.criterion(x, true_dist.clone().detach())  # KLDivLoss(reduction="sum")

class SimpleLossCompute:
    def __call__(self, x, y, norm):
        x = self.generator(x)
        sloss = self.criterion(x.contiguous().view(-1, x.size(-1)),
                               y.contiguous().view(-1)) / norm
        return sloss.data * norm, sloss

# run_epoch 中
out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)
loss, loss_node = loss_compute(out, batch.tgt_y, batch.ntokens)
11 12 13 </s> <pad> sentence 0 14 15 </s> <pad> <pad> sentence 1 tgt_y (what each position must predict) counted in loss and ntokens (= 7) target distribution zeroed: contributes 0 loss, 0 gradient
图 5:loss 只看真实标签。求和得到的 loss 除以 ntokens,即非 padding 标签的个数, 得到每个 token 的平均 loss,与这个 batch 补了多少 padding 无关。

到这里就完整了:padding 在充当来源的地方被遮住(self-attention 和 cross-attention 中的 key 列), 在充当读取方的地方被丢弃(loss)。

什么时候需要因果 mask

因果 mask 只防一件事:偷看还没有生成出来的词。只有当 query 和 key 位于同一条正在逐步生成的序列上时,这件事才可能发生。 在标准的 encoder–decoder 里,这只出现在解码器的 self-attention 中。

总结

mask用在依赖形状
源句 mask编码器 self-attn、解码器 cross-attnkey j 是否为 padding · (b, j)(batch, 1, 1, m)
解码器 mask解码器 self-attnkey j 不是 padding 且 j ≤ i · (b, i, j)(batch, 1, n, n)
loss 中的 maskLabelSmoothing标签是否为 padding · (b, i)true_dist 的行被清零

mask 只存储答案所依赖的维度。因果规则是唯一依赖 query 位置的规则,所以只有解码器 mask 的 query 维是写满的。

附录:自己跑一遍

下面是一个独立的脚本,可以复现本文中的所有形状和数字。其中的辅助函数与 the_annotated_transformer.py 一致; 我用仓库固定的 PyTorch 1.11 跑过。

mask_demo.py
python
"""Self-contained check of the mask shapes and loss masking used in the note.

The helper definitions mirror the_annotated_transformer.py.
"""
import math

import torch
import torch.nn as nn

torch.manual_seed(0)
torch.set_printoptions(precision=2, sci_mode=False, linewidth=120)
PAD, BOS, EOS = 2, 0, 1


def subsequent_mask(size):
    attn_shape = (1, size, size)
    return torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8) == 0


class Batch:
    def __init__(self, src, tgt, pad=PAD):
        self.src = src
        self.src_mask = (src != pad).unsqueeze(-2)
        self.tgt = tgt[:, :-1]
        self.tgt_y = tgt[:, 1:]
        tgt_mask = (self.tgt != pad).unsqueeze(-2)
        self.tgt_mask = tgt_mask & subsequent_mask(self.tgt.size(-1)).type_as(tgt_mask)
        self.ntokens = (self.tgt_y != pad).data.sum()


def attention(query, key, value, mask=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    p_attn = scores.softmax(dim=-1)
    return torch.matmul(p_attn, value), p_attn


class LabelSmoothing(nn.Module):
    def __init__(self, size, padding_idx, smoothing=0.0):
        super().__init__()
        self.criterion = nn.KLDivLoss(reduction="sum")
        self.padding_idx, self.size = padding_idx, size
        self.confidence, self.smoothing = 1.0 - smoothing, smoothing

    def forward(self, x, target):
        true_dist = x.data.clone()
        true_dist.fill_(self.smoothing / (self.size - 2))
        true_dist.scatter_(1, target.data.unsqueeze(1), self.confidence)
        true_dist[:, self.padding_idx] = 0
        mask = torch.nonzero(target.data == self.padding_idx)
        if mask.dim() > 0:
            true_dist.index_fill_(0, mask.squeeze(), 0.0)
        self.true_dist = true_dist
        return self.criterion(x, true_dist.clone().detach())


# Sentence 0: 3 real source tokens + 2 pads; sentence 1: 4 real + 1 pad.
src = torch.tensor([[5, 7, 9, PAD, PAD],
                    [6, 8, 3, 4, PAD]])
# Targets: <s> w w w </s> <pad>   and   <s> w w </s> <pad> <pad>
tgt = torch.tensor([[BOS, 11, 12, 13, EOS, PAD],
                    [BOS, 14, 15, EOS, PAD, PAD]])
b = Batch(src, tgt)

print("== shapes")
print("src_mask           ", tuple(b.src_mask.shape))
print("tgt_mask           ", tuple(b.tgt_mask.shape))
print("src_mask.unsqueeze ", tuple(b.src_mask.unsqueeze(1).shape))
print("tgt_mask.unsqueeze ", tuple(b.tgt_mask.unsqueeze(1).shape))
print("ntokens            ", int(b.ntokens))

print("\n== decoder mask, sentence 1 (tgt = <s> 14 15 </s> <pad>)")
print(b.tgt_mask[1].int())

h, d_k = 8, 64
n, m = b.tgt.size(1), b.src.size(1)
q = torch.randn(2, h, n, d_k)  # from the decoder (target side)
kv = torch.randn(2, h, m, d_k)  # from the encoder memory (source side)

print("\n== cross-attention weights, sentence 0, head 0  (rows = target queries, cols = source keys)")
_, p_cross = attention(q, kv, kv, mask=b.src_mask.unsqueeze(1))
print(p_cross[0, 0])
print("row sums:", p_cross[0, 0].sum(-1))

print("\n== cross-attention weights, sentence 1, head 0  (last row is a <pad> query)")
print(p_cross[1, 0])

print("\n== loss: LabelSmoothing ignores <pad> targets")
V = 16
crit = LabelSmoothing(size=V, padding_idx=PAD, smoothing=0.1)
logits = torch.randn(2, n, V).log_softmax(-1)
y = b.tgt_y
print("tgt_y:\n", y)
loss = crit(logits.view(-1, V), y.reshape(-1))
rows = crit.true_dist.sum(-1).view(2, n)
print("true_dist row sums (0 = ignored):\n", rows)
print("loss / ntokens = %.4f" % (loss / b.ntokens).item())
output
== shapes
src_mask            (2, 1, 5)
tgt_mask            (2, 5, 5)
src_mask.unsqueeze  (2, 1, 1, 5)
tgt_mask.unsqueeze  (2, 1, 5, 5)
ntokens             7

== decoder mask, sentence 1 (tgt = <s> 14 15 </s> <pad>)
tensor([[1, 0, 0, 0, 0],
        [1, 1, 0, 0, 0],
        [1, 1, 1, 0, 0],
        [1, 1, 1, 1, 0],
        [1, 1, 1, 1, 0]], dtype=torch.int32)

== cross-attention weights, sentence 0, head 0  (rows = target queries, cols = source keys)
tensor([[0.67, 0.11, 0.22, 0.00, 0.00],
        [0.08, 0.41, 0.51, 0.00, 0.00],
        [0.72, 0.06, 0.23, 0.00, 0.00],
        [0.20, 0.74, 0.06, 0.00, 0.00],
        [0.50, 0.26, 0.23, 0.00, 0.00]])
row sums: tensor([1.00, 1.00, 1.00, 1.00, 1.00])

== cross-attention weights, sentence 1, head 0  (last row is a <pad> query)
tensor([[0.10, 0.22, 0.11, 0.57, 0.00],
        [0.54, 0.05, 0.10, 0.32, 0.00],
        [0.06, 0.12, 0.61, 0.20, 0.00],
        [0.21, 0.09, 0.28, 0.41, 0.00],
        [0.26, 0.33, 0.32, 0.09, 0.00]])

== loss: LabelSmoothing ignores <pad> targets
tgt_y:
 tensor([[11, 12, 13,  1,  2],
        [14, 15,  1,  2,  2]])
true_dist row sums (0 = ignored):
 tensor([[1.00, 1.00, 1.00, 1.00, 0.00],
        [1.00, 1.00, 1.00, 0.00, 0.00]])
loss / ntokens = 2.5445

代码摘自 harvardnlp/annotated-transformer,略有删减;形状注释是我加的。