12_masked_LM

5223 字
26 分钟
12_masked_LM
import torch
from torch import nn
from transformers.models.bert import BertModel, BertTokenizer, BertForMaskedLM

1. model load and data processing#

model_type = 'bert-base-uncased'
tokenizer = BertTokenizer.from_pretrained(model_type)
bert = BertModel.from_pretrained(model_type)
mlm = BertForMaskedLM.from_pretrained(model_type, output_hidden_states=True)
bert
mlm

输出为:

# BertModel 的结构
BertModel(
(embeddings): BertEmbeddings(
(word_embeddings): Embedding(30522, 768, padding_idx=0)
(position_embeddings): Embedding(512, 768)
(token_type_embeddings): Embedding(2, 768)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(encoder): BertEncoder(
(layer): ModuleList(
(0-11): 12 x BertLayer(
(attention): BertAttention(
(self): BertSelfAttention(
(query): Linear(in_features=768, out_features=768, bias=True)
(key): Linear(in_features=768, out_features=768, bias=True)
(value): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(output): BertSelfOutput(
(dense): Linear(in_features=768, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
)
(intermediate): BertIntermediate(
(dense): Linear(in_features=768, out_features=3072, bias=True)
(intermediate_act_fn): GELUActivation()
)
(output): BertOutput(
(dense): Linear(in_features=3072, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
)
(pooler): BertPooler(
(dense): Linear(in_features=768, out_features=768, bias=True)
(activation): Tanh()
)
)
# BertForMaskedLM 的结构
BertForMaskedLM(
(bert): BertModel(
(embeddings): BertEmbeddings(
(word_embeddings): Embedding(30522, 768, padding_idx=0)
(position_embeddings): Embedding(512, 768)
(token_type_embeddings): Embedding(2, 768)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(encoder): BertEncoder(
(layer): ModuleList(
(0-11): 12 x BertLayer(
(attention): BertAttention(
(self): BertSelfAttention(
(query): Linear(in_features=768, out_features=768, bias=True)
(key): Linear(in_features=768, out_features=768, bias=True)
(value): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
(output): BertSelfOutput(
(dense): Linear(in_features=768, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
)
(intermediate): BertIntermediate(
(dense): Linear(in_features=768, out_features=3072, bias=True)
(intermediate_act_fn): GELUActivation()
)
(output): BertOutput(
(dense): Linear(in_features=3072, out_features=768, bias=True)
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
)
)
)
)
)
(cls): BertOnlyMLMHead(
(predictions): BertLMPredictionHead(
(transform): BertPredictionHeadTransform(
(dense): Linear(in_features=768, out_features=768, bias=True)
(transform_act_fn): GELUActivation()
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
)
(decoder): Linear(in_features=768, out_features=30522, bias=True)
)
)
)

我们从输出中可以看出,实际上 BertModelBertForMaskedLM 只是在最后结尾的部分结构有所不同:

flowchart TB subgraph BertModel["BertModel"] A1["Embeddings"] --> A2["Encoder (12层)"] A2 --> A3["Pooler"] end subgraph BertForMaskedLM["BertForMaskedLM"] B1["bert: BertModel"] B2["cls: BertOnlyMLMHead"] B1 --> B2 end
组件BertModelBertForMaskedLM
embeddings✅ 有✅ 有(在 .bert 里)
encoder (12层)✅ 有✅ 有(在 .bert 里)
pooler✅ 有✅ 有(在 .bert 里)
cls (MLM Head)❌ 没有独有的

这是 BertForMaskedLMBertModel 多出来的唯一模块:

(cls): BertOnlyMLMHead(
(predictions): BertLMPredictionHead(
(transform): BertPredictionHeadTransform(
(dense): Linear(768 → 768)
(transform_act_fn): GELU
(LayerNorm)
)
(decoder): Linear(768 → 30522) ← 关键!
)
)

它做了两件事:

  1. transform:把 encoder 输出的 768 维隐藏向量,通过一个全连接 + GELU 激活 + LayerNorm 做一次非线性变换
  2. decoder:把 768 维映射到 30522 维(= BERT 词表大小),每个位置对应一个词的预测分数

1.1 为什么会有这些区别?#

1.1.1 分工不同#

  • BertModel 是”通用编码器”——输入文本 → 输出每个 token 的上下文向量。至于拿到向量后做什么,它不管。
  • BertForMaskedLM 是”专用任务模型”——在 BertModel 的基础上,加了一个预测头,专门做完形填空:输入 "The [MASK] is blue" → 预测 [MASK]"sky"

1.1.2 设计模式:骨干网络 + 任务头#

这是 HuggingFace Transformers 的统一设计模式:

BertForMaskedLM = BertModel (骨干) + MLM Head (任务头)
BertForSequenceClassification = BertModel (骨干) + 分类头
BertForQuestionAnswering = BertModel (骨干) + QA头

同一个 BertModel 被不同任务模型复用,只需换不同的”头”即可。

1.1.3 为什么 decoder 是 768 → 30522?#

BERT 的词表大小是 30522,所以 MLM 做的是一个 30522 分类问题——对每个被 mask 的位置,从 30522 个词中选出最可能的那个。

1.1.4 Pooler 虽然在,但 MLM 不用它#

BertForMaskedLM 内部的 BertModel 仍然包含 pooler,这是因为 HuggingFace 直接复用了整个 BertModel 类。但 MLM 任务实际上只用 encoder 最后一层的隐藏状态,pooler 在这里是”冗余但无害”的。


一句话总结:

BertModel 只负责”理解”,BertForMaskedLM 在理解的基础上多了一步”预测被遮住的词”。

text = ("After Abraham Lincoln won the November 1860 presidential "
"election on an anti-slavery platform, an initial seven "
"slave states declared their secession from the country "
"to form the Confederacy. War broke out in April 1861 "
"when secessionist forces attacked Fort Sumter in South "
"Carolina, just over a month after Lincoln's "
"inauguration.")
text
inputs = tokenizer(text, return_tensors='pt')
inputs['input_ids'].shape # torch.Size([1, 62])
inputs

输出为:

{'input_ids': tensor([[ 101, 2044, 8181, 5367, 2180, 1996, 2281, 7313, 4883, 2602,
2006, 2019, 3424, 1011, 8864, 4132, 1010, 2019, 3988, 2698,
6658, 2163, 4161, 2037, 22965, 2013, 1996, 2406, 2000, 2433,
1996, 18179, 1012, 2162, 3631, 2041, 1999, 2258, 6863, 2043,
22965, 2923, 2749, 4457, 3481, 7680, 3334, 1999, 2148, 3792,
1010, 2074, 2058, 1037, 3204, 2044, 5367, 1005, 1055, 17331,
1012, 102]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])}

这里 inputs['input_ids'] 的形状为 (1, 64),它代表的是 (batch_size, sequence_length). 注意这里的 62 个 token 包含了开头自动加的 [CLS] 和结尾的 [SEP].

' '.join(tokenizer.convert_ids_to_tokens(inputs['input_ids'][0]))

输出为:

"[CLS] after abraham lincoln won the november 1860 presidential election on an anti - slavery platform , an initial seven slave states declared their secession from the country to form the confederacy . war broke out in april 1861 when secession ##ist forces attacked fort sum ##ter in south carolina , just over a month after lincoln ' s inauguration . [SEP]"

2. masking#

inputs['labels'] = inputs['input_ids'].detach().clone()

这行代码是我们在 MLM(Masked Language Model)任务中准备标签数据的一步:

  • .detach()切断计算图链接,防止梯度传播。
    • input_ids 本身是模型输入的整数张量,按理说是”数据”不需要梯度
    • .detach() 是 PyTorch 的安全习惯,明确告诉框架:“这个张量只是作为标签使用,不需要反向传播”
  • .clone()创建一个独立的副本
    • 如果不 .clone(),新变量和原变量会共享同一块内存。之后我们会在 input_ids 上随机把某些 token 换成 [MASK](变成模型的输入),但 labels 需要保留原始未 mask 的值作为正确答案,所以必须复制一份。
  • inputs['labels']:这就是 MLM 的”标准答案”。

整体流程如下图:

flowchart LR A["原始句子"] --> B["input_ids=[CLS] after ... [SEP]"] A --> C["labels=[CLS] after ... [SEP] (备份)"] B --> D["把部分 token 替换为 [MASK]"] D --> E["模型预测 masked 位置"] E --> F["与 labels 对比算损失"] C --> F

我们总体的步骤将是:

  1. 先把原始 input_ids 备份一份到 labels
  2. 然后把 input_ids 中的部分 token 替换成 [MASK]
  3. 模型预测 [MASK] 位置应该是什么词
  4. 拿预测结果和 labels 里的原始词对比,计算损失

所以这行代码的核心作用就是:在把句子弄乱之前,先保存一份”正确答案”。

inputs['labels']

输出为:

tensor([[ 101, 2044, 8181, 5367, 2180, 1996, 2281, 7313, 4883, 2602,
2006, 2019, 3424, 1011, 8864, 4132, 1010, 2019, 3988, 2698,
6658, 2163, 4161, 2037, 22965, 2013, 1996, 2406, 2000, 2433,
1996, 18179, 1012, 2162, 3631, 2041, 1999, 2258, 6863, 2043,
22965, 2923, 2749, 4457, 3481, 7680, 3334, 1999, 2148, 3792,
1010, 2074, 2058, 1037, 3204, 2044, 5367, 1005, 1055, 17331,
1012, 102]])
mask = torch.rand(inputs['input_ids'].shape) < 0.15
mask

输出为:

tensor([[False, False, True, False, False, True, True, False, False, False,
False, False, False, False, False, False, False, False, False, False,
True, False, False, True, False, False, False, False, False, False,
False, False, False, False, True, False, False, False, False, False,
False, True, False, False, False, True, False, False, False, False,
False, False, False, False, False, False, False, False, False, False,
False, False]])
  • torch.rand(inputs['input_ids'].shape):生成一个形状和 input_ids 相同的随机数矩阵,每个值在 [0, 1) 之间均匀分布。

因为 input_ids 的形状是 (1, 62),所以生成的就是 1 行 × 62 列 的随机小数,例如:

tensor([[0.73, 0.02, 0.91, 0.14, ..., 0.88]])
  • < 0.15阈值判断——将每个随机数与 0.15 比较,小于 0.15 的位置为 True,否则为 False

结果就是一个布尔矩阵(Boolean mask)

tensor([[ True, False, False, ..., False]])
  • True ≈ 这个位置要被遮住(概率 ≈ 15%)
  • False ≈ 这个位置保持不变(概率 ≈ 85%)

为什么是 15%?

这是 BERT 原始论文中的设定——随机选择 15% 的 token 进行掩码

从 notebook 的输出可以验证,总共 62 个 token,被选中的数量约为 62 × 15% ≈ 9.3,实际输出正好是 14 个(随机波动正常):


完整步骤回顾:

flowchart LR A["input_ids: 原始句子"] --> B["mask: 随机选 15% 位置"] B --> C["被选中的位置 → [MASK]"] C --> D["模型预测这些位置"] D --> E["与 labels 对比算损失"]

这行代码就是上图中的第二步——决定把哪些词藏起来让模型猜

sum(mask[0]) # tensor(8)
mask_arr = (torch.rand(inputs['input_ids'].shape) < 0.15) \
* (inputs['input_ids'] != 101) \
* (inputs['input_ids'] != 102)
mask_arr

输出为:

tensor([[False, False, False, False, False, False, False, False, False, False,
False, False, True, False, False, False, False, False, False, True,
False, False, False, False, False, True, False, False, False, False,
False, False, False, False, False, False, False, False, False, False,
True, False, False, False, False, False, False, False, True, False,
True, False, False, True, False, False, True, False, True, False,
False, False]])

这段代码相比上一个 mask 加了两个额外条件:

mask_arr = (torch.rand(inputs['input_ids'].shape) < 0.15) # ① 随机选 15%
* (inputs['input_ids'] != 101) # ② 排除 [CLS]
* (inputs['input_ids'] != 102) # ③ 排除 [SEP]
  • torch.rand(...) < 0.15:随机选 15%,和之前一样,随机选择 15% 的 token 位置标记为 True
  • inputs['input_ids'] != 101:排除 [CLS],即每个句子的第一个 token
    • 这行代码生成一个布尔张量:[CLS] 的位置为 False,其他位置为 True
  • inputs['input_ids'] != 102 — 排除 [SEP]. 同理,102[SEP](句子的最后一个 token),这个位置也设为 False
  • *(乘法):逻辑与(AND)。三个布尔张量逐元素相乘(True = 1False = 0),相当于做逻辑与运算——只有三个条件都为 True 的位置,结果才是 True

2.1 为什么要排除 [CLS] 和 [SEP]?#

这是 BERT 论文中的要求——特殊 token 不应该被 mask

  • [CLS] 是分类标记,不包含有意义的词义
  • [SEP] 是分隔符,也不包含有意义的词义
  • mask 它们没有意义,模型也学不到什么
sum(mask_arr[0]) # tensor(9)
selection = torch.flatten(mask_arr[0].nonzero()).tolist()
selection

输出为:

[12, 19, 25, 40, 48, 50, 53, 56, 58]

这里我们就是把布尔掩码中 True 的位置提取出来,变成一个 easy-to-use 的 Python 列表。

  • mask_arr[0]:取出 mask_arr 的第一行(因为只有 1 句话,batch size = 1)。

之前 mask_arr 的形状是 (1, 62),所以 mask_arr[0] 变成一维的 (62,)

tensor([False, False, ..., True, ..., False])
  • .nonzero():找到所有值为 True索引位置

输出是一个 2D 张量,每个 True 对应一行:

tensor([[12],
[19],
[25],
...
[58]])
  • torch.flatten(...):把上面的 2D 张量展平成一维:
tensor([[12, 19, 25, 40, 48, 50, 53, 56, 58]])
  • .tolist():把 PyTorch 张量转成普通的 Python 列表
[12, 19, 25, 40, 48, 50, 53, 56, 58]

我们可以对比一下数据流转:

步骤形状内容
mask_arr(1, 62)[[False, False, True, ...]]
mask_arr[0](62,)[False, False, True, ...]
.nonzero()(12, 1)[[12], [19], ...]
flatten(12,)[12, 19, 25, ...]
.tolist()Python list[12, 19, 25, 40, 48, 50, 53, 56, 58]

为什么要转成列表?

后续代码需要逐个处理这些被选中的位置(把对应的 token 换成 [MASK])。Python 列表比 PyTorch 张量更容易做循环遍历:

for i in selection:
# 把第 i 个位置的 token 替换成 [MASK]

最终输出 [12, 19, 25, 40, 48, 50, 53, 56, 58] 就是这 9 个 token 的位置索引,后续我们要对它们进行 masking 操作。

tokenizer.special_tokens_map

输出为:

{'unk_token': '[UNK]',
'sep_token': '[SEP]',
'pad_token': '[PAD]',
'cls_token': '[CLS]',
'mask_token': '[MASK]'}
tokenizer.vocab['[MASK]'] # 103
inputs['input_ids'][0, selection] = 103
inputs

输出为:

{'input_ids': tensor([[ 101, 2044, 8181, 5367, 2180, 1996, 2281, 7313, 4883, 2602,
2006, 2019, 103, 1011, 8864, 4132, 1010, 2019, 3988, 103,
6658, 2163, 4161, 2037, 22965, 103, 1996, 2406, 2000, 2433,
1996, 18179, 1012, 2162, 3631, 2041, 1999, 2258, 6863, 2043,
103, 2923, 2749, 4457, 3481, 7680, 3334, 1999, 103, 3792,
103, 2074, 2058, 103, 3204, 2044, 103, 1005, 103, 17331,
1012, 102]]), 'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]), 'labels': tensor([[ 101, 2044, 8181, 5367, 2180, 1996, 2281, 7313, 4883, 2602,
2006, 2019, 3424, 1011, 8864, 4132, 1010, 2019, 3988, 2698,
6658, 2163, 4161, 2037, 22965, 2013, 1996, 2406, 2000, 2433,
1996, 18179, 1012, 2162, 3631, 2041, 1999, 2258, 6863, 2043,
22965, 2923, 2749, 4457, 3481, 7680, 3334, 1999, 2148, 3792,
1010, 2074, 2058, 1037, 3204, 2044, 5367, 1005, 1055, 17331,
1012, 102]])}

这里就是我们 MLM 任务数据准备中 最关键的一步:我们真正把被选中的 token 替换为 [MASK].

代码解析:

  • inputs['input_ids'] :形状为 (1, 62) 的 token 矩阵;
  • [0, selection]高级索引(fancy indexing):
    • 0 表示取第 0 句话,在这里,我们的 batch 中也只有一句;
    • selection 就是我们之前得到的列表 [12, 19, 25, 40, 48, 50, 53, 56, 58],即所有要遮挡的位置。
  • =103 :我们把这 9 个位置的值全部替换为 103,即 [MASK] 的 id.

例如替换前:

位置: ... 18 19 20 21 ...
值: ... 3988 2698 6658 2163 ...
(anti) (slavery) (platform) (,)

替换后:

位置: ... 18 19 20 21 ...
值: ... 103 2698 103 2163 ...
([MASK]) (slavery) ([MASK]) (,)

别忘了,我们的 labels 还是备份好的 原始值。所以,我们的下一步就是:模型看到 [MASK],需要预测出 antiplatform,再和 labels 对比计算 loss.

' '.join(tokenizer.convert_ids_to_tokens(inputs['input_ids'][0]))

输出为:

"[CLS] after abraham lincoln won the november 1860 presidential election on an [MASK] - slavery platform , an initial [MASK] slave states declared their secession [MASK] the country to form the confederacy . war broke out in april 1861 when [MASK] ##ist forces attacked fort sum ##ter in [MASK] carolina [MASK] just over [MASK] month after [MASK] ' [MASK] inauguration . [SEP]"

我们可以很清晰地从上面的输出中看到,这 9 个位置都被 MASK 掉了。

' '.join(tokenizer.convert_ids_to_tokens(inputs['labels'][0]))

输出为:

"[CLS] after abraham lincoln won the november 1860 presidential election on an anti - slavery platform , an initial seven slave states declared their secession from the country to form the confederacy . war broke out in april 1861 when secession ##ist forces attacked fort sum ##ter in south carolina , just over a month after lincoln ' s inauguration . [SEP]"

可以看到,我们保存好的 labels 是完整的,这是我们的 ground truth.

3. forward and calculate loss#

mlm.eval()
with torch.no_grad():
outputs = mlm(**inputs)
outputs.keys() # odict_keys(['loss', 'logits', 'hidden_states'])

如果你还记得我们在 11 节中看到的 outputs.keys() 的话,你会发现 11 节中的输出是 odict_keys(['last_hidden_state', 'pooler_output']),和这里的 odict_keys(['loss', 'logits', 'hidden_states']) 不一样。

这主要是因为模型不同。在 11 节中,outputs.keys() 来自 BertModel,而这里我们使用的是 BertForMaskedLM. 它的输出结果中:

  • loss :预测结果与 labels 之间的损失值(标量);
  • logits :每个 token 位置对词表中 30522 个子的预测分数 (1, 62, 30522);
  • hidden_states :各层的隐藏状态,因为我们加载时传入了 output_hidden_states=True 的参数。

3.1 loss 是怎么来的#

首先,前向传播时,BertModel 先输出 last_hidden_statepooler_output,然后 MLM Head 接过 last_hidden_state 做预测,losslogits 都是在这个阶段算出来的。

具体到 loss,它的出现是因为我们在 inputs 里传入了 labels

inputs['labels'] = inputs['input_ids'].detach().clone()

BertForMaskedLM 的前向代码中有一个内置的损失函数,当检测到 labels 存在时,会自动计算 CrossEntropyLoss(logits, labels),把结果放在 outputs['loss'] 中。

而 BertModel 没有这个逻辑——它甚至不知道什么是 MLM 任务,所以永远不会有 losslogits.

outputs.logits

输出为:

tensor([[[ -7.2043, -7.1276, -7.1729, ..., -6.3316, -6.3432, -4.3540],
[-12.1506, -12.0077, -12.1967, ..., -11.4132, -10.8127, -8.9152],
[ -6.2586, -6.4102, -5.8441, ..., -6.1903, -6.3061, -5.1038],
...,
[ -3.5310, -3.7281, -3.4160, ..., -2.7291, -2.5730, -6.5398],
[-14.4257, -14.3249, -14.3869, ..., -11.1762, -11.3302, -10.0136],
[-11.3686, -11.7058, -11.4880, ..., -11.0921, -9.7356, -8.3027]]])
outputs.loss # tensor(0.6281)
type(outputs.hidden_states) # tuple
len(outputs.hidden_states) # 13
outputs['hidden_states'][-1]

输出为:

tensor([[[-4.3005e-01, 3.4016e-02, -3.8321e-01, ..., -3.4883e-01,
-1.0174e-01, 4.8761e-01],
[-6.9344e-01, -3.2490e-04, 2.5159e-01, ..., -4.7717e-01,
1.6140e-01, 6.2177e-01],
[-6.0726e-01, 1.0112e+00, -6.3583e-01, ..., -6.0174e-01,
-3.2421e-01, 4.3809e-01],
...,
[-4.3882e-01, 2.9114e-01, -8.9229e-01, ..., -3.3631e-01,
-8.5137e-01, 9.9881e-01],
[ 5.0888e-01, 1.2994e-02, -3.8768e-01, ..., 3.2210e-02,
-6.3586e-01, 1.6127e-02],
[-3.6704e-03, 1.7164e-01, -7.1432e-01, ..., -4.6810e-01,
-8.0063e-01, 2.6163e-02]]])

关于 outputs.hidden_states 的长度和类型,我们在 07 节中做了详尽的探讨,这里的 13 包含了第 0 层 embedding 层和 12 个 transformer 层(attention layers).

4. from scratch#

mlm.cls(outputs['hidden_states'][-1])

输出为:

tensor([[[ -7.2043, -7.1276, -7.1729, ..., -6.3316, -6.3432, -4.3540],
[-12.1506, -12.0077, -12.1967, ..., -11.4132, -10.8127, -8.9152],
[ -6.2586, -6.4102, -5.8441, ..., -6.1903, -6.3061, -5.1038],
...,
[ -3.5310, -3.7281, -3.4160, ..., -2.7291, -2.5730, -6.5398],
[-14.4257, -14.3249, -14.3869, ..., -11.1762, -11.3302, -10.0136],
[-11.3686, -11.7058, -11.4880, ..., -11.0921, -9.7356, -8.3027]]],
grad_fn=<ViewBackward0>)

可以看到,这里的输出与之前的 outputs.logits 是完全一致的。这是因为 outputs.logits 本身就是由 mlm.cls(outputs['hidden_states'][-1]) 计算出来的。当我们执行 mlm(**inputs) 时,内部的数据流是这样的:

inputs
mlm.bert(inputs) ← 12层 Transformer
├── outputs['last_hidden_state'] = 最后一层输出 (1, 62, 768)
└── outputs['hidden_states'] = 全部 13 层输出
hidden_states[-1] ← 还是最后一层
mlm.cls(...) ← BertOnlyMLMHead
┌─────┴──────┐
│ │
transform decoder
(Linear+GELU (Linear
+LayerNorm) 768→30522)
│ │
└──────┬──────┘
outputs['logits'] (1, 62, 30522)
CrossEntropyLoss(logits, labels)
outputs['loss']

需要补充的一点是,这里的 cls 指的就是 分类器。在 HuggingFace 中,各类任务模型的”任务头”统一命名为 cls,因为本质上它们都是在做分类(classification):

模型任务头做什么分类
BertForMaskedLMcls对 30522 个词分类 → 预测被 mask 的词
BertForSequenceClassificationcls对情感/类别分类 → 判断句子是正面/负面
BertForNextSentencePredictioncls二分类 → 判断两句话是否连续

而在 MLM 任务中,cls = MLM Head.

我们可以从模型结构看到 mlm.cls 的类型:

mlm.cls

输出为:

BertOnlyMLMHead(
(predictions): BertLMPredictionHead(
(transform): BertPredictionHeadTransform(
(dense): Linear(in_features=768, out_features=768, bias=True)
(transform_act_fn): GELUActivation()
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True, bias=True)
)
(decoder): Linear(in_features=768, out_features=30522, bias=True) <- 真正的分类层
)
)

它做的就是对一个 mask 位置的上下文向量,从 30522 个候选词中 分类出 最可能是哪一个词。

last_hidden_state = outputs['hidden_states'][-1] # 最后一个隐藏层的输出
mlm.eval()
with torch.no_grad():
transformed = mlm.cls.predictions.transform(last_hidden_state)
print(transformed.shape) # torch.Size([1, 62, 768])
logits = mlm.cls.predictions.decoder(transformed)
print(logits.shape) # torch.Size([1, 62, 30522])
logits

输出为:

tensor([[[ -7.2043, -7.1276, -7.1729, ..., -6.3316, -6.3432, -4.3540],
[-12.1506, -12.0077, -12.1967, ..., -11.4132, -10.8127, -8.9152],
[ -6.2586, -6.4102, -5.8441, ..., -6.1903, -6.3061, -5.1038],
...,
[ -3.5310, -3.7281, -3.4160, ..., -2.7291, -2.5730, -6.5398],
[-14.4257, -14.3249, -14.3869, ..., -11.1762, -11.3302, -10.0136],
[-11.3686, -11.7058, -11.4880, ..., -11.0921, -9.7356, -8.3027]]])

这里,我们就是把 hidden_states[-1](最后一层的输出,768 维)转换成最终的 logits:

  1. 第一步是 transform,它对应我们前面 mlm.cls 结构中的:
(transform): BertPredictionHeadTransform(
(dense): Linear(768 → 768) ← 全连接
(transform_act_fn): GELUActivation() ← GELU 激活
(LayerNorm): LayerNorm((768,)) ← LayerNorm
)

做的事情:对每个 token 的 768 维向量做一次非线性变换(Linear → GELU → LayerNorm),输出仍是 (1, 62, 768). 你可以理解为,在输出最终的分类结果前,我们先对特征做一道“精加工”。

  1. 第二步是 decoder,它对应模型结构中的:
(decoder): Linear(in_features=768, out_features=30522, bias=True)

做的事情:把 768 维映射到 30522 维,输出 (1, 62, 30522).

整个的数据流如下:

last_hidden_state (1, 62, 768)
▼ transform (Linear + GELU + LayerNorm)
transformed (1, 62, 768) ← 维度不变,特征被精加工
▼ decoder (Linear 768 → 30522)
logits (1, 62, 30522) ← 每个 token 对 30522 个词的预测分数

而两者合起来就是完整的 mlm.cls().

5. loss and translate#

cross_entropy = nn.CrossEntropyLoss()
logits.shape # torch.Size([1, 62, 30522])
inputs['labels'].shape # torch.Size([1, 62])
inputs['labels'][0].view(-1).shape # torch.Size([62])

这里我们主要关注最后一行,它输出的是 labels 展平后的一维形状:

  • inputs['labels'] :形状是 (1, 62),这是之前备份的原始 input_ids,所以形状就是 (batch_size, seq_len)
  • [0] :取第 0 句话,也就是去掉 batch 维度,形状变为 (62,);
  • .view(-1) :这是 Pytorch 中“自动推断该维度大小”的展平操作。由于原来的 [0] 已经是 (62,) 一维了,所以 view(-1) 并没有改变形状。

因此,最终的形状就是 62 个 token ID,一个简单的一维序列。

torch.argmax(logits[0], dim=-1) # torch.Size([1, 62])

输出为:

tensor([ 1012, 2044, 8181, 5367, 2180, 1996, 2281, 7313, 4883, 2602,
2006, 2019, 3424, 1011, 8864, 4132, 1010, 2019, 3988, 2698,
6658, 2163, 4161, 2037, 22965, 2013, 1996, 2406, 2000, 2433,
1996, 18179, 1012, 2162, 3631, 2041, 1999, 2258, 6863, 2043,
22965, 8055, 2749, 4457, 3481, 7680, 3334, 1999, 2148, 3792,
1010, 2074, 2058, 1037, 3204, 2044, 5367, 1005, 1055, 17331,
1012, 1055])

这里我们就是 把模型的预测分数转换成具体的预测词 ID

  • logits :形状 (1, 62, 30522),表示 logits 中每个 token 的 30522 个分数,代表这个词是词表中每个词的可能性;
  • logits[0] :去掉 batch 维度,形状变为 (62, 30522);
  • argmax(logits[0], dim=-1)取最后一个维度的最大值索引
    • dim=-1 表示沿着最后一个维度(即 30522 词表维度),找分数最高的那个位置;
    • 对每个 token,从 30522 个候选词中挑出分数最高的那个词的 ID;
    • 输出形状为 (62,),即 62 个位置各自的预测词 ID.
logits[0] (62, 30522)
┌──────────────────────────────────────┐
│ 位置0: [-6.97, -6.90, ..., -4.24] │ → argmax → ID=1996
│ 位置1: [-12.18, -12.09, ..., -9.73] │ → argmax → ID=2044
│ 位置2: [-6.56, -6.69, ..., -5.23] │ → argmax → ID=1055
│ ... │
│ 位置61: [-12.45, -12.79, ..., -8.94] │ → argmax → ID=1055
└──────────────────────────────────────┘
tensor([1996, 2044, 1055, ..., 1055])
(62,)
' '.join(tokenizer.convert_ids_to_tokens(inputs['input_ids'][0]))

输出为:

"[CLS] after abraham lincoln won the november 1860 presidential election on an [MASK] - slavery platform , an initial [MASK] slave states declared their secession [MASK] the country to form the confederacy . war broke out in april 1861 when [MASK] ##ist forces attacked fort sum ##ter in [MASK] carolina [MASK] just over [MASK] month after [MASK] ' [MASK] inauguration . [SEP]"

这是我们之前看过的输出,也就是我们要进行“完形填空”的对象。

' '.join(tokenizer.convert_ids_to_tokens(torch.argmax(logits[0], dim=1)))

输出为:

". after abraham lincoln won the november 1860 presidential election on an anti - slavery platform , an initial seven slave states declared their secession from the country to form the confederacy . war broke out in april 1861 when secession confederate forces attacked fort sum ##ter in south carolina , just over a month after lincoln ' s inauguration . s"

这里就是我们模型对整个句子的预测结果,相当于“做完完形填空后的答卷”。

我们可以看到,大部分 [MASK] 的位置都填对了,只有个别位置,比如 ##ist 前预测了 confederate 而不是 secession.

不过我们要特别注意的是,预测结果的开头和结尾:

位置预测期望原因
位置 0.(句号)[CLS]模型不知道 [CLS] 是特殊 token,它只是在 30522 个词中选了分数最高的
位置 61s[SEP]同理

这不是模型”猜错了”,而是 convert_ids_to_tokens 会把任何 ID 都转成对应的词——包括非 mask 位置。模型对 [CLS] 位置预测了 .,因为 [CLS] 本身不在词表中,模型只能输出词表中存在的 token。

' '.join(tokenizer.convert_ids_to_tokens(inputs['labels'][0]))

输出为:

"[CLS] after abraham lincoln won the november 1860 presidential election on an anti - slavery platform , an initial seven slave states declared their secession from the country to form the confederacy . war broke out in april 1861 when secession ##ist forces attacked fort sum ##ter in south carolina , just over a month after lincoln ' s inauguration . [SEP]"

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

12_masked_LM
https://github.com/chunhuizhang/bilibili_vlogs/
作者
HAC
发布于
2026-07-06
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
HAC
观之非易,行且克难
Greetings
欢迎来到我的博客!这里主要分享我的学习笔记与兴趣爱好。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
32
分类
5
标签
13
总字数
79,889
运行时长
0
最后活动
0 天前

文章目录