目录
[1. 早期 NLP 中的迁移学习](#1. 早期 NLP 中的迁移学习)
[2.BERT 的动机:基于"微调"的新范式](#2.BERT 的动机:基于“微调”的新范式)
[1. 只有编码器的 Transformer](#1. 只有编码器的 Transformer)
[2. 两个经典版本(中杯与大杯)](#2. 两个经典版本(中杯与大杯))
[3. 在大规模数据上训练](#3. 在大规模数据上训练)
[1. 文本被设计为"句子对"](#1. 文本被设计为“句子对”)
[2. 三合一的"嵌入(Embedding)"机制](#2. 三合一的“嵌入(Embedding)”机制)
[3.任务1 :带掩码的语言模型](#3.任务1 :带掩码的语言模型)
[带掩码的语言模型(Masked Language Model,简称 MLM,也就是"完形填空")的代码实现](#带掩码的语言模型(Masked Language Model,简称 MLM,也就是“完形填空”)的代码实现)
介绍

1. 早期 NLP 中的迁移学习
在 BERT 出现之前,NLP 领域的迁移学习主要是将预训练模型作为静态的特征提取器:
-
提取特征但不更新 :人们通常使用像 word2vec 这样的预训练模型或早期的语言模型,仅仅用来把单词或句子转换成向量(提取特征)。在训练下游新任务时,不更新这些预训练好的模型参数。
-
需要构建复杂的新网络:因为预训练模型提取的特征不够丰富,为了完成具体的新任务(比如情感分析、问答),研究人员必须在预训练模型之上,重新设计并构建复杂的神经网络来抓取所需的信息。
-
早期模型的局限性:早期的特征提取方法存在缺陷,例如 Word2vec 会忽略句子中词语的先后时序信息,而当时的语言模型通常只能单向(比如从左到右)理解上下文,不够全面。

2.BERT 的动机:基于"微调"的新范式
解释了 BERT 想要解决上述痛点,从而引入了与计算机视觉(CV)类似的新范式:
-
强大的特征抽取能力:BERT 的设计理念是,通过更大规模、更好的结构(双向Transformer)进行预训练,让模型自身就能抽取足够丰富、深度的上下文信息。
-
极简的下游任务适配 :因为预训练模型已经把最难的"理解语言"的工作做完了,所以在面对新的具体任务时,不再需要构建复杂的新网络,只需要在模型最顶端增加一个极其简单的输出层(Output layer)即可。
-
向计算机视觉(CV)看齐:这种"基于微调的NLP模型"终于做到了和 CV 领域(如图像分类)一样。无论是在 NLP 还是 CV 中,模型的主体部分(Layer 1 到 Layer L-1)都作为通用的特征抽取器,而在最顶层加上一个简单的分类器(Output layer)就能直接输出结果。
Bert:
1. 只有编码器的 Transformer
-
背景:最原始的 Transformer 模型包含两部分:编码器(Encoder)负责"理解"输入的文字,解码器(Decoder)负责"生成"输出的文字。
-
BERT的特点 :BERT 的核心目标是把"阅读理解"做到极致,所以它直接砍掉了解码器,只保留了堆叠在一起的编码器。这使得它能够非常专注且双向地去理解句子前后的上下文关系。
2. 两个经典版本(中杯与大杯)
当年 Google 发布 BERT 时,提供了两个不同体量大小的模型:
-
Base(基础版):
-
#blocks = 12:堆叠了 12 层编码器模块(也就是网络深度)。 -
hidden size = 768:每个词被表示成一个 768 维的向量(也就是网络的宽度)。 -
#heads = 12:注意力机制有 12 个"头"。 -
#parameters = 110M:模型总共有大约 1.1亿 个参数。这个版本体量适中,方便普通研究者在单张显卡上微调使用。
-
-
Large(顶配版):
-
#blocks = 24:层数加倍,达到了 24 层。 -
hidden size = 1024:向量维度变得更宽。 -
#parameter = 340M:总共有大约 3.4亿 个参数。这个版本性能极强,在当时的各项 NLP 测试中都刷榜了,但对电脑算力的要求非常高。
-
3. 在大规模数据上训练
- > 3B 词:这里的 3B 指的是 3 Billion(30亿)。意思是 BERT 之所以一经发布就那么聪明,是因为它在"出厂(预训练)"前,阅读了极其海量的文本数据(超过 30 亿个单词,主要是英文维基百科和海量的免费图书集)。它正是在这么庞大的文字库里,自己"硬读"学会了人类语言的语法和内在规律。
改动:

cls(classification分类)是一个标记,表明这个序列是训练用的,是Sep是分隔符,代表句子之间的分割,训练的过程就是让模型判断分隔符前后的句子是否能够语义上连接
1. 文本被设计为"句子对"
-
每个样本是一个句子对:很多 NLP 任务(比如问答系统、判断两句话是否相似)需要同时看两个句子。因此,BERT 的输入通常不是单个句子,而是把两个句子拼在一起作为一次输入。
-
特殊占位符:图中底部展示了输入的单词。注意两个特殊的符号:
-
<cls>(通常写为[CLS]):永远放在整个序列的最开头,它的最终输出特征会被用来代表整个句子的含义,常用于分类任务。 -
<sep>(通常写为[SEP]):用来作为分隔符,告诉模型"这里是第一句话的结束"以及"这是整个输入的结束"。
-
2. 三合一的"嵌入(Embedding)"机制
这部分是图片最下方的图解。BERT 最终输入给神经网络的特征,是由三个独立的向量(Embedding)按元素相加得到的:
-
Token Embeddings(词嵌入/Token 嵌入):
最底下一层。它代表的是这个词本身的含义 。例如,图中的
this,movie,is,great都有自己对应的基础词向量。 -
Segment Embeddings(片段嵌入/句段嵌入):
中间一层。因为输入了两个句子,模型需要知道哪个词属于哪句话。
-
第一句话(this movie is great)里的所有词,都会被加上一个相同的向量 E_A。
-
第二句话(i like it)里的所有词,都会被加上另一个相同的向量 E_B。
这就相当于给两句话贴上了不同的标签。
-
-
Position Embeddings(位置嵌入):
最上面一层。因为 Transformer 模型本身是个"瞎子",它同时看所有词,不知道词的先后顺序。所以需要给每个位置(第0个词、第1个词......)分配一个向量(E_0, E_1, E_2...),告诉模型这个词在句子中的绝对位置。
- 位置编码可学习 :幻灯片里特别提到,与原始 Transformer 使用固定数学公式计算位置不同,BERT 的位置信息是让模型自己通过数据训练学习出来的。
总结:
这张图想表达的核心意思是:在 BERT 中,任何一个输入词最终变成机器能理解的数字向量时,都完美融合了"我是什么词 (Token)"、 "我属于哪句话 (Segment)"以及"排在第几个位置 (Position)"这三种信息。
3.任务1 :带掩码的语言模型

BERT 训练过程中最核心、也是最精妙的一个设计:"带掩码的语言模型"(Masked Language Model,简称 MLM)。
简单来说,这就是一个"完形填空"游戏,但为了让模型学得更好,研究人员加入了一些"小心机"。我们可以分两步来理解为什么要这么做:
- 为什么要做"完形填空"?(前置背景)
以前的语言模型通常是单向的(比如给定"我喜欢吃",让模型猜下一个词是"苹果")(encoder的self-attention层整个句子都能看,而decoder的self-attention层有mask,看不到后面的)。 但正如前面提到的,BERT 是一个完全双向的模型。如果你还让它"猜下一个词",它早就通过后面的词偷看到答案了(就好像考试时答案已经写在了题目旁边)。 因此,BERT 改用了"完形填空"的方法:随机挖掉句子中 15% 的词,然后让模型通过上下文把这些挖掉的词猜出来。
- 为什么要做 80 / 10 / 10 的复杂替换?(核心原因)
这正是幻灯片下半部分要解决的"核心矛盾":预训练(平时练习)和微调(真正考试)的环境不一致。
假设在平时练习时,我们把那 15% 的词全部替换成 <MASK> 符号。模型很快就会学聪明:"哦,只要我看到 <MASK> 这个特殊符号,我再去发力猜词就行了,其他正常的词我随便看看就好。" 但是,当模型被拿去给用户真正使用时,用户输入的正常句子里是绝对没有 <MASK> 这个符号的! 如果模型只习惯于看着 <MASK> 符号干活,到了真实场景它就会"罢工"或者表现很差。为了打破这种对 <MASK> 符号的依赖,迫使模型在没有特殊符号提示的情况下,依然认真对待每一个词,研究人员对那选中的 15% 的词做了如下设定:
-
80% 的概率:真的换成
<MASK>-
例子:
我的 狗 很 [毛茸茸]变成我的 狗 很 <MASK>。 -
目的: 这是常规的完形填空,让模型主要依靠这个机制来学习上下文关联。
-
-
10% 的概率:换成一个完全不相干的"随机错词"
-
例子:
我的 狗 很 [毛茸茸]变成我的 狗 很 苹果。 -
目的: 这是在给模型下套 。即使没有
<MASK>符号,模型读到"苹果"时也必须能察觉到不对劲,并结合上下文算出这里原本应该是"毛茸茸"。这逼迫模型时刻保持警惕,去验证句子里每一个正常词汇的合理性。
-
-
10% 的概率:保持原词不变(明明选中了,但不改)
-
例子:
我的 狗 很 [毛茸茸]依然是我的 狗 很 毛茸茸。 -
目的: 告诉模型不要"疑神疑鬼"。如果每次选中的位置哪怕不是
<MASK>也是错词(比如上面的苹果),模型就会养成"只要让我预测,我就一定要把当前的词改掉"的坏习惯。保持 10% 的不变,是让模型学会自信地确认:"这个词放在这里就是最完美的,不需要修改。"
-
总结一下: 这样设计的根本原因,就是为了消除模型对特殊标记的依赖,逼迫它在面对完全正常的、没有被标记的人类句子时,也能深入挖掘每一个词前后的上下文联系。
因为微调任务没有mask,所以预训练中有太多mask会导致微调效果不好,训练时需模拟真实数据分布(为了让预训练的模型可以很好的适用于通用任务而不仅仅局限于"完形填空",这里就用概率来控制出现mask(做完形填空)的成分):
-
预训练阶段(做题阶段): 为了做完形填空,模型被喂了大量带有
<MASK>标记的句子。如果<MASK>出现得太多,或者每次要预测的地方都必定是<MASK>,模型就会产生"偷懒"的依赖。它会觉得:"只有看到<MASK>这个特殊标记时,我才需要去努力理解上下文并进行预测;至于其他正常的词,我随便扫一眼就行了。" -
微调阶段(实战阶段): 当模型被拿去处理真实的用户任务(比如情感分析、阅读理解)时,正常人类输入的句子里是绝对不会 带有
<MASK>这个特殊符号的。 -
产生的后果: 如果预训练时模型对
<MASK>依赖过重,到了实战阶段发现再也找不到<MASK>这个"提示符"了,它就会变得不知所措,导致平时练就的特征提取能力大打折扣,最终表现就是截图里说的"微调效果不好"。
4.任务2:下一个句子预测

主要内容解释如下:
-
任务定义:预测给定的一个句子对中,两个句子是否在原文本中相邻。
-
训练样本构建:
-
50% 概率(Adjacent Sentence Pair) :选择两个原本相邻的句子。示例:
this movie is great紧接着i like it。 -
50% 概率(Random Sentence Pair) :选择一个句子和另一个完全随机且不相关的句子。示例:
this movie is great紧接着hello world。
-
-
预测方法:将序列开头特殊 ` token(它代表整个序列的信息)的输出向量输入到一个全连接层,进行最终的二进制分类预测。
总结:(建议看bert论文)

预训练是为了学习到更深层的语义信息,在用到新任务上优化更快效果更好
微调就是预训练一个特征提取性能十分优秀的模型,当应用到下游任务的时候,只需要增加或者更改最后一层输出就行。
代码:
在自然语言处理中,有些任务(如情感分析)以单个文本作为输入,而有些任务(如自然语言推断)以一对文本序列作为输入。
BERT输入序列明确地表示单个文本和文本对。
当输入为单个文本时,BERT输入序列是特殊类别词元"<cls>"、文本序列的标记、以及特殊分隔词元"<sep>"的连结。
当输入为文本对时,BERT输入序列是"<cls>"、第一个文本序列的标记、"<sep>"、第二个文本序列标记、以及"<sep>"的连结。我们将始终如一地将术语"BERT输入序列"与其他类型的"序列"区分开来。例如,一个BERT输入序列 可以包括一个文本序列 或两个文本序列。

下面的get_tokens_and_segments将一个句子或两个句子作为输入,然后返回BERT输入序列的标记及其相应的片段索引。
Bert输入格式代码(句子对和片段嵌入)
python
#@save # 这是一个特殊标记,通常用于将代码块保存到外部模块中(例如 d2l 教程的用法)
def get_tokens_and_segments(tokens_a, tokens_b=None):
"""获取输入序列的词元及其片段索引
接收两个输入:tokens_a(第一句话的单词列表,必填)和 tokens_b(第二句话的单词列表,默认是空的)。
这说明它既支持单句子输入,也支持双句子(句子对)输入。
"""
# 【1. 处理句子 A】
# 在句子 A 的开头强制加上分类符 '<cls>',结尾加上分隔符 '<sep>'
tokens = ['<cls>'] + tokens_a + ['<sep>']
# 0 和 1 分别标记片段 A 和 B
# 给句子 A 的所有词打上标签 '0'。
# 长度加 2 是因为要把刚刚加进去的 '<cls>' 和 '<sep>' 这两个词也算进第一句话的阵营里。
segments = [0] * (len(tokens_a) + 2)
# 【2. 处理句子 B(如果存在的话)】
if tokens_b is not None:
# 把句子 B 拼接到当前 tokens 的后面,并在最末尾再加一个分隔符 '<sep>' 代表整段话结束
tokens += tokens_b + ['<sep>']
# 给句子 B 的所有词打上标签 '1',追加到 segments 列表中。
# 长度加 1 是因为开头不需要 <cls> 了,只需要把句尾新加的那个 '<sep>' 算进第二句话的阵营里。
segments += [1] * (len(tokens_b) + 1)
# 返回最终组装好的"词元列表"和对应的"0/1 标签列表"
return tokens, segments
eg:
tokens列表:
'\
segments列表:
0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 , 1
segments :生成片段索引 (也就是我们之前说的 Segment Embeddings 的雏形)。它生成了一个全是 0 的列表,意思是"这些词全都是属于第一句话的"。为什么长度要加 2?因为要把刚才新加的 <cls> 和 <sep> 也算进去。


BERT选择Transformer编码器作为其双向架构。在Transformer编码器中常见是,位置嵌入被加入到输入序列的每个位置。然而,与原始的Transformer编码器不同,BERT使用可学习的 位置嵌入。总之, :numref:fig_bert-input表明BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和。
BERT编码器:
python
#@save
class BERTEncoder(nn.Module):
"""BERT编码器"""
def __init__(self, vocab_size, num_hiddens, norm_shape, ffn_num_input,
ffn_num_hiddens, num_heads, num_layers, dropout,
max_len=1000, key_size=768, query_size=768, value_size=768,
**kwargs):
super(BERTEncoder, self).__init__(**kwargs)
# ==========================================
# 第一部分:定义三大 Embedding (特征映射字典)
# ==========================================
# 1. 词嵌入 (Token Embedding)
# 作用:把词库里的每一个词(共 vocab_size 个)映射成一个 num_hiddens 维的向量。
# 举例:把单词索引 105 变成长度为 768 的浮点数数组。
self.token_embedding = nn.Embedding(vocab_size, num_hiddens)
# 2. 片段嵌入 (Segment Embedding)
# 作用:只有 0 和 1 两种输入,对应第一句话和第二句话,同样映射成 num_hiddens 维向量。
self.segment_embedding = nn.Embedding(2, num_hiddens)
# 3. 位置嵌入 (Position Embedding)
# 作用:给句子里的每个位置(第0个词、第1个词...)分配一个专属向量。
# 注意:这里用了 nn.Parameter。它不像传统 Transformer 用固定的数学公式算,
# 而是先随机初始化 (torch.randn),然后让模型在训练中自己去更新、学习出最合适的位置特征。
# 它的形状是 (1, 最大长度 max_len, 隐藏层维度 num_hiddens)
self.pos_embedding = nn.Parameter(torch.randn(1, max_len, num_hiddens))
# ==========================================
# 第二部分:搭建 Transformer 积木
# ==========================================
# nn.Sequential() 是一个容器,相当于一个流水线。
self.blks = nn.Sequential()
# 循环 num_layers 次(比如 BERT-Base 是 12 次),像叠汉堡一样把编码器层叠起来
for i in range(num_layers):
self.blks.add_module(f"{i}", d2l.EncoderBlock(
key_size, query_size, value_size, num_hiddens, norm_shape,
ffn_num_input, ffn_num_hiddens, num_heads, dropout, True))
def forward(self, tokens, segments, valid_lens):
# ==========================================
# 第三部分:前向传播(数据真实流动的过程)
# ==========================================
# 假设当前输入的 tokens 形状为:(批量大小 batch_size, 序列长度 seq_length)
# 【三合一嵌入操作】
# 1. token_embedding(tokens) 输出形状:(batch_size, seq_length, num_hiddens)
# 2. segment_embedding(segments) 输出形状:(batch_size, seq_length, num_hiddens)
# 因为两者形状完全一样,所以可以直接用 '+' 把对应的数值加起来(广播机制)
X = self.token_embedding(tokens) + self.segment_embedding(segments)
# 3. 加上位置嵌入。
# X.shape[1] 就是当前这批句子的真实长度。
# pos_embedding.data[:, :X.shape[1], :] 的意思是:虽然我准备了 1000 个位置向量,
# 但如果当前句子长度只有 50,我就只切出前 50 个位置向量来跟你相加。
X = X + self.pos_embedding.data[:, :X.shape[1], :]
# 【穿过 Transformer 编码器层】
# 此时的 X 包含了 词意 + 句子归属 + 位置 三重信息。
# 让 X 依次穿过我们刚才叠好的 12 层编码器流水线。
for blk in self.blks:
# blk 内部包含了 自注意力机制(Self-Attention) 和 前馈神经网络(FFN)
# valid_lens (有效长度) 是告诉模型:句子末尾那些用来凑数的 <pad> 填充符不要管,只把注意力放在真词上。
X = blk(X, valid_lens)
# 最终输出的 X 形状依然是:(batch_size, seq_length, num_hiddens)
# 但此时的每一个 768 维向量,都已经高度融合了整个句子的双向上下文语境!
return X
2. 片段嵌入 (Segment Embedding)
self.segment_embedding = nn.Embedding(2, num_hiddens) 解释:


测试:为了演示BERTEncoder的前向推断
假设词表大小为10000,为了演示BERTEncoder的前向推断,让我们创建一个实例并初始化它的参数。将tokens定义为长度为8的2个输入序列,其中每个词元是词表的索引。使用输入tokens的BERTEncoder的前向推断返回编码结果,其中每个词元由向量表示,其长度由超参数num_hiddens定义。此超参数通常称为Transformer编码器的隐藏大小(隐藏单元数)。
python
# ==========================================
# 1. 设定 BERT 模型的超参数 (图纸尺寸)
# ==========================================
# vocab_size: 词表大小为 10000(模型认识一万个不同的词)
# num_hiddens: 隐藏层维度为 768(每个词最终变成 768 维的向量)
# ffn_num_hiddens: 编码器内部前馈神经网络的隐藏层维度设为 1024
# num_heads: 多头注意力机制,切分成 4 个"头"来观察句子
vocab_size, num_hiddens, ffn_num_hiddens, num_heads = 10000, 768, 1024, 4
# norm_shape: 层归一化处理的维度长度,对应 num_hiddens
# ffn_num_input: 前馈神经网络的输入大小,也是 768
# num_layers: 我们这里为了测试,只叠了 2 层编码器块 (标准 Base 版是 12 层)
# dropout: 随机丢弃 20% 的神经元,防止死记硬背 (过拟合)
norm_shape, ffn_num_input, num_layers, dropout = [768], 768, 2, 0.2
# ==========================================
# 2. 实例化模型 (根据图纸把机器造出来)
# ==========================================
encoder = BERTEncoder(vocab_size, num_hiddens, norm_shape, ffn_num_input,
ffn_num_hiddens, num_heads, num_layers, dropout)
# ==========================================
# 3. 制造假数据进行模拟输入 (喂给机器的原料)
# ==========================================
# tokens: 随机生成输入词元。
# 用法:在 0 到 vocab_size(10000) 之间随机挑整数。
# 形状:(2, 8) ------ 代表 2 个样本(批量大小为 2),每个样本长度为 8 个词。
tokens = torch.randint(0, vocab_size, (2, 8))
# segments: 手动指定的片段索引(号码牌)。
# 第一行 [0,0,0,0,1,1,1,1] 代表第一个样本:前 4 个词是句子 A,后 4 个词是句子 B。
# 第二行 [0,0,0,1,1,1,1,1] 代表第二个样本:前 3 个词是句子 A,后 5 个词是句子 B。
# 形状:(2, 8) ------ 必须和 tokens 的形状一模一样。
segments = torch.tensor([[0, 0, 0, 0, 1, 1, 1, 1],
[0, 0, 0, 1, 1, 1, 1, 1]])
# ==========================================
# 4. 前向传播 (开动机器!)
# ==========================================
# 把 tokens 和 segments 喂给模型。
# 第三个参数 valid_lens 传了 None,表示我们这里没有填充符,所有词都是有效的,不需要掩码。
encoded_X = encoder(tokens, segments, None)
# ==========================================
# 5. 检查最终输出结果的形状
# ==========================================
print(encoded_X.shape)
# 期待的输出结果应该是: torch.Size([2, 8, 768])
任务:
带掩码的语言模型(Masked Language Model,简称 MLM,也就是"完形填空")的代码实现
python
import torch
from torch import nn
#@save
class MaskLM(nn.Module):
"""BERT的掩蔽语言模型任务 (完形填空任务)"""
def __init__(self, vocab_size, num_hiddens, num_inputs=768, **kwargs):
super(MaskLM, self).__init__(**kwargs)
# 定义一个简单的多层感知机 (MLP) 作为输出分类器
# 作用:把从编码器那里拿到的 768 维特征,最终转换成 vocab_size (10000) 维的概率预测
self.mlp = nn.Sequential(
nn.Linear(num_inputs, num_hiddens), # 第一层:768 -> 768
nn.ReLU(), # 激活函数:增加非线性
nn.LayerNorm(num_hiddens), # 层归一化:稳定训练
nn.Linear(num_hiddens, vocab_size) # 第二层:768 -> 10000 (给词典里的每一个词打分)
)
def forward(self, X, pred_positions):
# 假设输入:
# X 形状: (2, 8, 768) -> 也就是刚才 BERTEncoder 吐出来的最终特征
# pred_positions 形状: (2, 3) -> 意思是:2个句子,每个句子挑了 3 个位置(词)被"挖空"了,要求预测
# 1. 拿到每个句子需要预测的数量 (这里是 3)
num_pred_positions = pred_positions.shape[1]
# 2. 把二维的预测位置压扁成一维
# pred_positions 从 [[1, 5, 2], [6, 1, 5]] 变成 [1, 5, 2, 6, 1, 5]
pred_positions = pred_positions.reshape(-1)
batch_size = X.shape[0] # 获取批次大小,这里是 2
# 3. 制造批次索引 (高级索引技巧!)********************
batch_idx = torch.arange(0, batch_size) # 变成 [0, 1]
# 把 [0, 1] 里面的每个元素重复 3 次 (num_pred_positions)
# 变成: [0, 0, 0, 1, 1, 1]
batch_idx = torch.repeat_interleave(batch_idx, num_pred_positions)
# 4. 提取需要预测的特征向量 (最核心的一步)
# 这一步相当于同时查两个表:
# 提取 X[0, 1], X[0, 5], X[0, 2], X[1, 6], X[1, 1], X[1, 5] 这 6 个词的 768 维向量
# masked_X 此时形状是 (6, 768)
masked_X = X[batch_idx, pred_positions]
# 5. 把提取出来的 6 个词,重新变回 3D 张量的形状
# 形状变成: (2, 3, 768) -> 代表 2 句话,每句 3 个需要预测的词,每个词是 768 维特征
masked_X = masked_X.reshape((batch_size, num_pred_positions, -1))
# 6. 把这几个词的特征扔进 MLP 分类器进行预测
# 输出形状: (2, 3, 10000)
mlm_Y_hat = self.mlp(masked_X)
return mlm_Y_hat
"""
为了演示MaskLM的前向推断,我们创建了其实例mlm并对其进行了初始化。
回想一下,来自BERTEncoder的正向推断encoded_X表示2个BERT输入序列。我们将mlm_positions定义为在encoded_X的任一输入序列中预测的3个指示。
mlm的前向推断返回encoded_X的所有掩蔽位置mlm_positions处的预测结果mlm_Y_hat。
对于每个预测,结果的大小等于词表的大小。
"""
# ==========================================
# 测试运行区
# ==========================================
# 1. 实例化 MLM 模型 (假设词表大小 10000,特征维度 768)
mlm = MaskLM(vocab_size=10000, num_hiddens=768)
# 2. 伪造被挖空的位置 (2段话,每段话挖掉了3个词)
mlm_positions = torch.tensor([[1, 5, 2], [6, 1, 5]])
# 3. 前向传播:传入上一节算好的 encoded_X (假装它是 (2, 8, 768)) 和需要预测的位置
# 结果 mlm_Y_hat 的形状应该是 (2, 3, 10000)
mlm_Y_hat = mlm(encoded_X, mlm_positions)
print("预测输出形状:", mlm_Y_hat.shape)
"""
通过掩码下的预测词元mlm_Y的真实标签mlm_Y_hat,我们可以计算在BERT预训练中的遮蔽语言模型任务的交叉熵损失
"""
# ==========================================
# 损失计算区 (算算模型猜得对不对)
# ==========================================
# 1. 伪造真实的正确答案 (标签)
# 对应 mlm_positions 的位置,假设正确的词在词典里的真实索引是下面这些:
mlm_Y = torch.tensor([[7, 8, 9], [10, 20, 30]])
# 2. 定义交叉熵损失函数
# reduction='none' 表示不要把所有损失加起来求平均,我们要原汁原味地保留每一个词的损失
loss = nn.CrossEntropyLoss(reduction='none')
# 3. 计算损失
# PyTorch 的 CrossEntropyLoss 要求预测值(输入)是二维的 (N, C),目标值是单维的 (N,)
# 所以我们把 (2, 3, 10000) 压扁成 (6, 10000)
# 把目标值 (2, 3) 压扁成 (6,)
mlm_l = loss(mlm_Y_hat.reshape((-1, 10000)), mlm_Y.reshape(-1))
# 4. 打印最终损失的形状
print("损失形状:", mlm_l.shape)
# 输出应该是 torch.Size([6]),代表这 6 个被挖掉的词,每一个都有了自己的"误差分数"

下一句预测(NSP)
尽管掩蔽语言建模能够编码双向上下文来表示单词,但它不能显式地建模文本对之间的逻辑关系。
为了帮助理解两个文本序列之间的关系,BERT在预训练中考虑了一个二元分类任务------下一句预测。
在为预训练生成句子对时,有一半的时间它们确实是标签为"真"的连续句子;在另一半的时间里,第二个句子是从语料库中随机抽取的,标记为"假"。
下面的NextSentencePred类使用单隐藏层的多层感知机来预测第二个句子是否是BERT输入序列中第一个句子的下一个句子。
由于Transformer编码器中的自注意力,特殊词元"<cls>"的BERT表示已经对输入的两个句子进行了编码。因此,多层感知机分类器的输出层(self.output)以X作为输入,其中X是多层感知机隐藏层的输出,而MLP隐藏层的输入是编码后的"<cls>"词元。(见下面解释)

python
import torch
from torch import nn
#@save
class NextSentencePred(nn.Module):
"""BERT的下一句预测任务 (NSP: Next Sentence Prediction)"""
def __init__(self, num_inputs, **kwargs):
super(NextSentencePred, self).__init__(**kwargs)
# 定义一个全连接层(线性层)作为二分类器
# 为什么输出维度是 2?因为这是一个最简单的"判断题"(二分类):
# 类别 0:这两句话在原文中是相邻的 (IsNext)
# 类别 1:这两句话是随机拼凑在一起的 (NotNext)
self.output = nn.Linear(num_inputs, 2)
def forward(self, X):
# 接收输入特征 X
# X 的预期形状:(batch_size, num_inputs)
# 直接穿过线性层,输出模型对这两个类别的打分
return self.output(X)
# ==========================================
# 测试运行区
# ==========================================
# (这里我们伪造一个上游 BERT 编码器的输出结果,方便代码独立运行)
# 形状:(2句话, 每句话8个词, 768维特征)
encoded_X = torch.randn(2, 8, 768)
loss = nn.CrossEntropyLoss(reduction='none') # 沿用之前的损失函数
# 1. 展平特征矩阵
# start_dim=1 表示保留第 0 维 (batch_size=2) 不动,把后面的所有维度压扁。
# 于是 (2, 8, 768) 被压扁成了 (2, 8 * 768) = (2, 6144) 维度的超级长条向量。
# (注:标准的 BERT 论文里,NSP 任务通常只提取 '<cls>' 这个特殊标记的特征来做预测,
# 但 d2l 教程为了简化演示,这里采用了直接将整个句子所有词的特征全部展平的做法。)
encoded_X = torch.flatten(encoded_X, start_dim=1)
# NSP的输入形状:(batchsize,num_hiddens) 此时为 (2, 6144)
# 2. 实例化 NSP 模型
# encoded_X.shape[-1] 获取了展平后的总特征数(6144),传给 num_inputs
nsp = NextSentencePred(encoded_X.shape[-1])
# 3. 前向推断
# 将 (2, 6144) 的矩阵送入线性层,出来的结果是 (2, 2)
nsp_Y_hat = nsp(encoded_X)
print("NSP预测输出形状:", nsp_Y_hat.shape) # 输出预期: torch.Size([2, 2])
# ==========================================
# 损失计算区 (对答案)
# ==========================================
# 1. 伪造真实的正确答案 (标签)
# [0, 1] 的意思是:
# 告诉机器,第 1 个样本是真的"下一句"(类别0)
# 第 2 个样本是假的"随机拼接"(类别1)
nsp_y = torch.tensor([0, 1])
# 2. 计算损失
# 用模型的打分 nsp_Y_hat (2, 2) 去对比真实答案 nsp_y (2,)
nsp_l = loss(nsp_Y_hat, nsp_y)
# 3. 打印损失形状
print("NSP损失形状:", nsp_l.shape)
# 输出预期: torch.Size([2]),代表这 2 个句子对各自产生的预测误差值
整合代码:
python
import torch
from torch import nn
#@save
class BERTModel(nn.Module):
"""BERT 完整模型(合体版本)"""
def __init__(self, vocab_size, num_hiddens, norm_shape, ffn_num_input,
ffn_num_hiddens, num_heads, num_layers, dropout,
max_len=1000, key_size=768, query_size=768, value_size=768,
hid_in_features=768, mlm_in_features=768,
nsp_in_features=768):
super(BERTModel, self).__init__()
# ==========================================
# 1. 组装零件 A:BERT 双向编码器核心
# ==========================================
# 负责把输入的 tokens 和 segments 提取成高维双向语境特征 (2, 8, 768)
self.encoder = BERTEncoder(
vocab_size, num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, max_len=max_len, key_size=key_size,
query_size=query_size, value_size=value_size
)
# ==========================================
# 2. 组装辅助零件 B:NSP 的特征过渡隐藏层 (Pooler)
# ==========================================
# 在做"下一句预测"前,习惯上先用一个线性层把 <cls> 的特征整理一下,并用 Tanh 激活函数压一压。
# 输入维度: hid_in_features (768) -> 输出维度: num_hiddens (768)
self.hidden = nn.Sequential(
nn.Linear(hid_in_features, num_hiddens),
nn.Tanh()
)
# ==========================================
# 3. 组装零件 C 与 D:两大预训练任务头 (Heads)
# ==========================================
# mlm: 负责盖住 15% 词元做完形填空的预测头
self.mlm = MaskLM(vocab_size, num_hiddens, mlm_in_features)
# nsp: 负责二分类判断是不是下一句的预测头
# 为什么这里的输入维度和刚刚定义的 nsp_in_features 一样?
# 因为在正式的 BERT 论文中,NSP 只取 <cls> 标志的 768 维向量做分类(不展平整句啦!)
self.nsp = NextSentencePred(nsp_in_features)
def forward(self, tokens, segments, valid_lens=None, pred_positions=None):
# ==========================================
# 4. 真实数据流动线 (开动机器)
# ==========================================
# 步骤 1:先把数据送入编码器,获得融合了全局上下文的特征矩阵
# encoded_X 形状: (batch_size, seq_len, 768)
encoded_X = self.encoder(tokens, segments, valid_lens)
# 步骤 2:判断是否要执行"完形填空 (MLM)"
# 在微调下游任务时可能不需要做完形填空,所以做了 if-else 判断
if pred_positions is not None:
# 提取出被挖空位置的特征,并进行 10000 维分类预测
# mlm_Y_hat 形状: (batch_size, 被挖空的词数, 10000)
mlm_Y_hat = self.mlm(encoded_X, pred_positions)
else:
mlm_Y_hat = None
# 步骤 3:进行"下一句预测 (NSP)"
# 💡 这里非常关键:
# `encoded_X[:, 0, :]` 是什么意思?
# 它的意思是:从形状为 (batch_size, seq_len, 768) 的特征矩阵中,只切出第 1 个位置(索引 0)。
# 而索引 0 恰恰就是我们手动塞在最前头的特殊标志位 `<cls>`!
# 在 BERT 中,`<cls>` 的 768 维向量代表了"整句话的宏观大意"。
# 我们把这个 768 维向量送进 self.hidden,再送入 self.nsp,预测是不是下一句(二分类)
# nsp_Y_hat 形状: (batch_size, 2)
nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :]))
# 最终返回:[编码特征矩阵, 完形填空预测分数, 下一句分类打分]
return encoded_X, mlm_Y_hat, nsp_Y_hat
"""
回到代码中:
代码里的 encoded_X 形状是 (batch_size, seq_len, 768),它就是会议结束后的全员状态。
encoded_X[:, 0, :]:这个切片操作的意思就是,"把每个批次里,站在第 0 个位置的秘书叫出来"。
提取出来的形状变成了 (batch_size, 768)。***********
然后,我们把这个融合了全局信息的秘书向量,塞进 self.hidden 和 self.nsp(多层感知机)里,让机器根据秘书的汇报,判断这两句话是不是连在一起的。
总结一下:
<cls> 之所以行,是因为在 BERT 的层层注意力计算中,它被迫"看"遍了句子中的每一个词。在最后一层输出时,它已经不再是一个单纯的标志符,而是整句话的语义浓缩精华(Sentence Representation)。这也是 BERT 原论文中最天才的设计之一!
类似一种pooling,用这个cls的emebdding作为整个句子的表示**************
"""
例子:
假设我们要一次性处理 2 对句子 (batch_size = 2),每对句子的总长度固定为 8 个词 (seq_len = 8)。
- 准备输入数据(给机器备料)
在调用这个模型之前,我们会先准备好四个包裹(张量):
-
tokens(词的索引):假设长这样:
[[105, 33, ..., 9], [105, 66, ..., 12]]注意:第 0 个位置的
105就是我们强行塞进去的<cls>的编号!形状是
(2, 8)。 -
segments(句子A/B的号码牌):假设长这样:
[[0, 0, 0, 0, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1]]形状也是
(2, 8)。 -
valid_lens(有效长度):假设我们没有填充废词,全是有效词,这里直接传
None。 -
pred_positions(告诉机器要在哪做完形填空):假设长这样:
[[1, 5, 2], [6, 1, 5]](每句话挖掉 3 个词让机器猜)。形状是
(2, 3)。
- 开动机器(走进
forward函数的内部)
当你执行 model(tokens, segments, None, pred_positions) 时,模型内部开始流水线作业:
👉 步骤 1:主引擎提炼特征 (self.encoder)
-
输入:
tokens(2, 8) 和segments(2, 8) 被送进了庞大的 Transformer 编码器。 -
发生的事情: 词嵌入、片段嵌入、位置相加、经过 12 层注意力机制互相交流...
-
输出 (
encoded_X): 变成了一个形状为(2, 8, 768)的三维矩阵。这代表 2 段话,每段 8 个词,每个词都变成了一个蕴含丰富语境的 768 维超级特征向量。
👉 步骤 2:分配任务 A - 完形填空 (self.mlm)
-
代码看到了
pred_positions不是空的,于是走入if分支。 -
发生的事情:
self.mlm拿着刚刚算好的(2, 8, 768)矩阵,精准地把那 6 个被挖空的词的特征单独抠出来,然后去查那本 10000 页的字典,给每一个词打分。 -
输出 (
mlm_Y_hat): 变成了一个形状为(2, 3, 10000)的矩阵。这代表 2 段话,每段 3 个空,每个空给出了 10000 个字典单词的概率得分。
👉 步骤 3:分配任务 B - 下一句预测 (self.nsp)
-
发生的事情: 代码执行了神来之笔的
encoded_X[:, 0, :]。它从
(2, 8, 768)的大矩阵里,一把抓出了第 0 个词(也就是<cls>)的特征。此时被抓出来的特征形状缩小成了(2, 768)。然后,它把这个
(2, 768)塞进self.hidden压一压,再塞进self.nsp二分类器进行打分。 -
输出 (
nsp_Y_hat): 变成了一个形状为(2, 2)的矩阵。这代表 2 段话,每段话给出了 2 个分数(是不是真实的下一句)。
- 最终返回(流水线吐出成品)
函数最后执行 return encoded_X, mlm_Y_hat, nsp_Y_hat。
你作为程序员,在外部接到的最终结果就是这三个张量:
-
encoded_X:(2, 8, 768)------ 底层加工好的优质材料(如果下游要做其他任务,就用它)。 -
mlm_Y_hat:(2, 3, 10000)------ 完形填空的预测答卷。 -
nsp_Y_hat:(2, 2)------ 下一句逻辑判断的预测答卷。




小结
* word2vec和GloVe等词嵌入模型与上下文无关。它们将相同的预训练向量赋给同一个词,而不考虑词的上下文(如果有的话)。它们很难处理好自然语言中的一词多义或复杂语义。
* 对于上下文敏感的词表示,如ELMo和GPT,词的表示依赖于它们的上下文。
* ELMo对上下文进行双向编码,但使用特定于任务的架构(然而,为每个自然语言处理任务设计一个特定的体系架构实际上并不容易);而GPT是任务无关的,但是从左到右编码上下文。
* BERT结合了这两个方面的优点:它对上下文进行双向编码,并且需要对大量自然语言处理任务进行最小的架构更改。
* BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和。
* 预训练包括两个任务:掩蔽语言模型和下一句预测。前者能够编码双向上下文来表示单词,而后者则显式地建模文本对之间的逻辑关系。