学习Bert

目录

介绍

[1. 早期 NLP 中的迁移学习](#1. 早期 NLP 中的迁移学习)

[2.BERT 的动机:基于"微调"的新范式](#2.BERT 的动机:基于“微调”的新范式)

Bert:

[1. 只有编码器的 Transformer](#1. 只有编码器的 Transformer)

[2. 两个经典版本(中杯与大杯)](#2. 两个经典版本(中杯与大杯))

[3. 在大规模数据上训练](#3. 在大规模数据上训练)

改动:

[1. 文本被设计为"句子对"](#1. 文本被设计为“句子对”)

[2. 三合一的"嵌入(Embedding)"机制](#2. 三合一的“嵌入(Embedding)”机制)

[3.任务1 :带掩码的语言模型](#3.任务1 :带掩码的语言模型)

4.任务2:下一个句子预测

总结:(建议看bert论文)

代码:

Bert输入格式代码(句子对和片段嵌入)

BERT编码器:

测试:为了演示BERTEncoder的前向推断

任务:

[带掩码的语言模型(Masked Language Model,简称 MLM,也就是"完形填空")的代码实现](#带掩码的语言模型(Masked Language Model,简称 MLM,也就是“完形填空”)的代码实现)

下一句预测(NSP)

整合代码:

例子:

小结


介绍

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)

简单来说,这就是一个"完形填空"游戏,但为了让模型学得更好,研究人员加入了一些"小心机"。我们可以分两步来理解为什么要这么做:

  1. 为什么要做"完形填空"?(前置背景)

以前的语言模型通常是单向的(比如给定"我喜欢吃",让模型猜下一个词是"苹果")(encoder的self-attention层整个句子都能看,而decoder的self-attention层有mask,看不到后面的)。 但正如前面提到的,BERT 是一个完全双向的模型。如果你还让它"猜下一个词",它早就通过后面的词偷看到答案了(就好像考试时答案已经写在了题目旁边)。 因此,BERT 改用了"完形填空"的方法:随机挖掉句子中 15% 的词,然后让模型通过上下文把这些挖掉的词猜出来。

  1. 为什么要做 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列表:

'\', 'this', 'movie', 'is', 'great', '\', 'i', 'like', 'it', '\'

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个输入序列,其中每个词元是词表的索引。使用输入tokensBERTEncoder的前向推断返回编码结果,其中每个词元由向量表示,其长度由超参数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)。

  1. 准备输入数据(给机器备料)

在调用这个模型之前,我们会先准备好四个包裹(张量):

  • 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)

  1. 开动机器(走进 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 个分数(是不是真实的下一句)。

  1. 最终返回(流水线吐出成品)

函数最后执行 return encoded_X, mlm_Y_hat, nsp_Y_hat

你作为程序员,在外部接到的最终结果就是这三个张量:

  1. encoded_X : (2, 8, 768) ------ 底层加工好的优质材料(如果下游要做其他任务,就用它)。

  2. mlm_Y_hat : (2, 3, 10000) ------ 完形填空的预测答卷。

  3. nsp_Y_hat : (2, 2) ------ 下一句逻辑判断的预测答卷。

小结

* word2vec和GloVe等词嵌入模型与上下文无关。它们将相同的预训练向量赋给同一个词,而不考虑词的上下文(如果有的话)。它们很难处理好自然语言中的一词多义或复杂语义。

* 对于上下文敏感的词表示,如ELMo和GPT,词的表示依赖于它们的上下文。

* ELMo对上下文进行双向编码,但使用特定于任务的架构(然而,为每个自然语言处理任务设计一个特定的体系架构实际上并不容易);而GPT是任务无关的,但是从左到右编码上下文。

* BERT结合了这两个方面的优点:它对上下文进行双向编码,并且需要对大量自然语言处理任务进行最小的架构更改。

* BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和。

* 预训练包括两个任务:掩蔽语言模型和下一句预测。前者能够编码双向上下文来表示单词,而后者则显式地建模文本对之间的逻辑关系。

相关推荐
渣渣灰飞1 小时前
MySQL 系统学习 第六阶段:Redis + 缓存 + 高并发设计 第十章:Redis 综合企业项目实战
学习·mysql·缓存
xian_wwq2 小时前
【学习笔记】多模态部署:VLM、语音、视频理解(29/35)
笔记·学习·音视频
冷小鱼2 小时前
AI Agent的核心算法:自主学习与反思(Self-Reflection / Critique)
人工智能·学习·算法·自主学习·self-reflection·critique·反思
寒月小酒13 小时前
第三章 索引构建(2-all -in-rag学习)
学习
天国梦13 小时前
中学生居家英语听力训练深度解析:从核心痛点到AI赋能的全链路方案
人工智能·学习
All The Way North-17 小时前
【完形填空实战】BERT中文MLM微调:数据构造→训练→评估,有完整可运行代码
pytorch·bert·预训练模型·transformers·模型微调·mlm·完形填空
胡渠洋17 小时前
postman学习
学习·测试工具·postman
Amazing_Cacao19 小时前
CFCA精品可可产区风土解析(美洲):无情打破风味堆叠假象,建立时间轴上的层次动态阅读系统
学习
六点_dn19 小时前
Linux学习笔记-shell运算符
linux·笔记·学习