【深度学习周报第十三周】

文章目录

  • 摘要
  • 第十章 注意力机制
    • 10.6 Transformer
      • 10.6.1 Transformer
      • 10.6.2 多头注意力
      • 10.6.3 Transformer代码实现
  • 第十四章 自然语言处理
    • 14.1 BERT模型
      • 14.1.1 BERT
      • 14.1.2 BERT代码实现
    • 14.2 BERT预训练数据代码
    • 14.3 预训练BERT
    • 总结

摘要

本周主要学习了Transformer模型和BERT预训练模型的相关内容。Transformer是一种完全基于注意力机制的序列到序列模型,不包含卷积或循环神经网络,广泛应用于语言、视觉、语音等领域。BERT是基于Transformer的预训练模型,通过掩码语言模型和下一句预测任务进行训练,能够生成上下文相关的词向量表示。在实现过程中,遇到了多头注意力机制的理解问题,以及BERT输入格式和预训练任务的细节处理问题。通过代码实践和调试,逐步解决了这些问题。

第十章 注意力机制

10.6 Transformer

10.6.1 Transformer

  • Transformer 模型是完全基于注意力机制,没有任何卷积层或循环神经网络
  • Transformer 最初应用在文本数据上的序列到序列学习,现在已经推广到各种现代的深度学习中,如语言、视觉、语音和强化学习领域

Transformer 架构
在这里插入图片描述

  • 基于编码器-解码器的架构来处理序列对,Transformer 的编码器和解码器是基于自注意力的模块叠加而成的,源(source,输入)序列和目标(target,输出)序列的嵌入(embedding)表示通过加上位置编码(positional encoding)加入位置信息,再分别输入到编码器和解码器中
  • Transformer虽然也是基于Encoder-Decoder的架构去处理一个序列对(比如一句英语和一句法语)。但Transformer模型完全基于注意力机制,没有任何RNN,这是与使用attention的seq2seq(用到了RNN)架构不同的地方。

多头注意力
1、对同一个 key 、value 、query 抽取不同的信息

  • 例如短距离关系和长距离关系
    2、多头注意力使用 h 个独立的注意力池化

  • 合并各个头(head)输出得到最终输出
    在这里插入图片描述

  • key 、value 、query 都是长为 1 的向量,通过全连接层映射到一个低一点的维度,然后进入到注意力模块中

在这里插入图片描述
带掩码的多头注意力(Masked Multi-head attention)
在这里插入图片描述
1、解码器对序列中一个元素输出时,不应该考虑该元素之后的元素

  • 注意力中是没有时间信息的,在输出中间第 i 个信息的时候,也能够看到后面的所有信息,这在编码的时候是可以的,但是在解码的时候是不行的,在解码的时候不应该考虑该元素本身或者该元素之后的元素

2、可以通过掩码来实现

  • 也就是计算 xi 输出时,假装当前序列长度为 i

基于位置的前馈网络(Positionwise FFN)

基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机(MLP),这就是称前馈网络是基于位置的原因
在这里插入图片描述
其实就是全连接层,将输入形状由(b,n,d)变成(bn,d),然后作用两个全连接层,最后输出形状由(bn,d)变回(b,n,d),等价于两层核窗口为 1 的一维卷积层

  • b:batchsize
  • n:序列长度
  • d:dimension
  • 在做卷积的时候是将 n 和 d 合成一维,变成 nd ;但是现在 n 是序列的长度,会变化,要使模型能够处理任意的特征,所以不能将 n 作为一个特征,因此对每个序列中的每个元素作用一个全连接(将每个序列中的 xi 当作是一个样本

残差连接和归一化(Add & norm)
在这里插入图片描述
1、Add 就是一个 Residual_block
在这里插入图片描述
1、加入归一化能够更好地训练比较深的网络,但是这里不能使用批量归一化,批量归一化对每个特征/通道里元素进行归一化

  • 这里的特征指的是每个序列中 D 中的一维,所以在做归一化的时候就是将其方差变 1 ,均值变 0
  • 在做 NLP 的时候,如果选择将 d 作为特征的话,那么批量归一化的输入是 n*b ,b 是批量大小,n 是序列长度,序列的长度是会变的,所以每次做批量归一化的输入大小都不同,所以会导致不稳定,训练和预测的长度本来就不一样,预测的长度会慢慢变长,所以批量归一化不适合长度会变的 NLP 应用
    2、层归一化对每个样本里的元素进行归一化
    在这里插入图片描述
  • b 代表 batchsize
  • d 代表特征维度
  • len 表示序列长度
  • 层归一化和批量归一化的目标相同,但是层归一化是基于特征维度进行归一化的
  • 层归一化和批量归一化的区别在于:批量归一化在 d 的维度上找出一个矩阵,将其均值变成 0 ,方差变成 1,层归一化每次选的是一个元素,也就是每个 batch 里面的一个样本进行归一化
  • 尽管批量归一化在计算机视觉中被广泛应用,但是在自然语言处理任务中,批量归一化通常不如层归一化的效果好,因为在自然语言处理任务中,输入序列的长度通常是变化的
  • 虽然在做层归一化的时候,长度也是变化的,但是至少来说还是在一个单样本中,不管批量多少,都给定一个特征,这样对于变化的长度来讲,稍微稳定一点,不会因为长度变化,导致稳定性发生很大的变化

信息传递
在这里插入图片描述
假设编码器中的输出是 y1,… ,yn ,将其作为解码中第 i 个 Transformer 块中多头注意力的 key 和 value

  • 一共有三个多头注意力(包括一个带掩码的多头注意力),位于带掩码的多头注意力与其它两个不同,其他两个都是自注意力(key 、value 和 query 都相同),而它是普通的注意力(它的 key 和 value 来自编码器的输出, query 来自目标序列)

这就意味着编码器和解码器中块的个数和输出维度都是一样的

10.6.2 多头注意力

与其只使用单独一个注意力汇聚, 我们可以用独立学习得到的h组不同的 线性投影(linear projections)来变换查询q、键k和值v。 然后,这h组变换后的查询、键和值将并行地送到注意力汇聚中。 最后,将这h个注意力汇聚的输出拼接在一起, 并且通过另一个可以学习的线性投影进行变换, 以产生最终输出。
对于h个注意力汇聚输出,每一个注意力汇聚都被称作一个头(head)。
下图展示了使用全连接层来实现可学习的线性变换的多头注意力。
在这里插入图片描述
在这里插入图片描述

实现

import math
import torch
from torch import nn
from d2l import torch as d2l

#@save
class MultiHeadAttention(nn.Module):
    """多头注意力"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 num_heads, dropout, bias=False, **kwargs):
        super(MultiHeadAttention, self).__init__(**kwargs)
        self.num_heads = num_heads
        self.attention = d2l.DotProductAttention(dropout)
        self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
        self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
        self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
        self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)

    def forward(self, queries, keys, values, valid_lens):
        # queries,keys,values的形状:
        # (batch_size,查询或者“键-值”对的个数,num_hiddens)
        # valid_lens 的形状:
        # (batch_size,)或(batch_size,查询的个数)
        # 经过变换后,输出的queries,keys,values 的形状:
        # (batch_size*num_heads,查询或者“键-值”对的个数,
        # num_hiddens/num_heads)
        queries = transpose_qkv(self.W_q(queries), self.num_heads)
        keys = transpose_qkv(self.W_k(keys), self.num_heads)
        values = transpose_qkv(self.W_v(values), self.num_heads)

        if valid_lens is not None:
            # 在轴0,将第一项(标量或者矢量)复制num_heads次,
            # 然后如此复制第二项,然后诸如此类。
            valid_lens = torch.repeat_interleave(
                valid_lens, repeats=self.num_heads, dim=0)

        # output的形状:(batch_size*num_heads,查询的个数,
        # num_hiddens/num_heads)
        output = self.attention(queries, keys, values, valid_lens)

        # output_concat的形状:(batch_size,查询的个数,num_hiddens)
        output_concat = transpose_output(output, self.num_heads)
        return self.W_o(output_concat)
  • pqh=pkh=pvh=p0,可以并行计算h个头
  • p0是通过num_hiddens指定的

上面定义了两个转置函数。transpose_output函数反转了transpose_qkv函数的操作

#@save
def transpose_qkv(X, num_heads):
    """为了多注意力头的并行计算而变换形状"""
    # 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
    # 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,
    # num_hiddens/num_heads)
    X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)

    # 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    X = X.permute(0, 2, 1, 3)

    # 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
    # num_hiddens/num_heads)
    return X.reshape(-1, X.shape[2], X.shape[3])


#@save
def transpose_output(X, num_heads):
    """逆转transpose_qkv函数的操作"""
    X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
    X = X.permute(0, 2, 1, 3)
    return X.reshape(X.shape[0], X.shape[1], -1)

测试

num_hiddens, num_heads = 100, 5
attention = MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
                               num_hiddens, num_heads, 0.5)
attention.eval()

在这里插入图片描述

  • 包含4个平行的注意力头(通过W_q/W_k/W_v线性层体现),最后通过W_o进行输出整合。
  • 采用经典的DotProductAttention(点积注意力)作为核心计算单元
batch_size, num_queries = 2, 4
num_kvpairs, valid_lens =  6, torch.tensor([3, 2])
X = torch.ones((batch_size, num_queries, num_hiddens))
Y = torch.ones((batch_size, num_kvpairs, num_hiddens))
attention(X, Y, Y, valid_lens).shape

在这里插入图片描述

10.6.3 Transformer代码实现

基于位置的前馈网络
基于位置的前馈网络对序列中所有位置进行变换时,使用的是同一个多层感知机。

class PositionWiseFFN(nn.Module):
    def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs,
                 **kwargs):
        super(PositionWiseFFN, self).__init__(**kwargs)
        self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
        self.relu = nn.ReLU()
        self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)

    def forward(self, X):
        return self.dense2(self.relu(self.dense1(X)))
  • 其实就是一个单隐藏层的MLP,不过这里的输入是三维

改变张量的最里层维度的尺寸

将输入张量的最内层维度从 4扩展到 8

ffn = PositionWiseFFN(4, 4, 8)
ffn.eval()
ffn(torch.ones((2, 3, 4)))[0]

在这里插入图片描述

对比不同维度的层归一化和批量归一化的效果
层归一化和批量归一化的目标相同,但对于NLP(变长序列)层归一化效果更好。

ln = nn.LayerNorm(2)
bn = nn.BatchNorm1d(2)
X = torch.tensor([[1, 2], [2, 3]], dtype=torch.float32)
#在训练模式下计算X的均值和方差
print('layer norm:', ln(X), '\nbatch norm:', bn(X))

在这里插入图片描述

使用残差连接和层规范化来实现AddNorm类,暂退法也被作为正则化方法使用

class AddNorm(nn.Module):
    """残差连接 + Dropout + 层归一化(Transformer标准组件)"""
    def __init__(self, normalized_shape, dropout, **kwargs):
        super(AddNorm, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)  # 随机失活层
        self.ln = nn.LayerNorm(normalized_shape)  # 层归一化(针对normalized_shape形状的最后一维)

    def forward(self, X, Y):
        """输入格式:X(残差项), Y(新特征项)
        计算流程:Dropout(Y) + X → LayerNorm"""
        return self.ln(self.dropout(Y) + X)  # 先对Y做dropout再加残差X,最后层归一化

残差连接要求两个输入的形状相同,以便加法操作后输出张量的形状相同。

add_norm = AddNorm([3, 4], 0.5)
add_norm.eval()
add_norm(torch.ones((2, 3, 4)), torch.ones((2, 3, 4))).shape

在这里插入图片描述
编码器

下面的EncoderBlock包含两个子层:多头注意力和居于位置的前馈网络,这两个子层都使用了残差连接和紧随的层规范化。

#@save
class EncoderBlock(nn.Module):
    """Transformer编码器块"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
                 dropout, use_bias=False, **kwargs):
        super(EncoderBlock, self).__init__(**kwargs)
        # 多头注意力层
        self.attention = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout,
            use_bias)
        # 第一个Add & Norm层(残差连接+层归一化)
        self.addnorm1 = AddNorm(norm_shape, dropout)
        # 前馈神经网络层
        self.ffn = PositionWiseFFN(
            ffn_num_input, ffn_num_hiddens, num_hiddens)
        # 第二个Add & Norm层
        self.addnorm2 = AddNorm(norm_shape, dropout)

    def forward(self, X, valid_lens):
        # 多头注意力 + AddNorm
        Y = self.addnorm1(X, self.attention(X, X, X, valid_lens))
        # 前馈网络 + AddNorm
        return self.addnorm2(Y, self.ffn(Y))

Transformer编码器
堆叠了num_layers个EncoderBlock类实例,由于这里使用的值范围在-1到1之间的固定位置编码,需要先乘以嵌入维度的平方根进行重新缩放,再与位置编码相加。

#@save
class TransformerEncoder(d2l.Encoder):
    """Transformer编码器"""
    def __init__(self, vocab_size, key_size, query_size, value_size,
                 num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
                 num_heads, num_layers, dropout, use_bias=False, **kwargs):
        super(TransformerEncoder, self).__init__(**kwargs)
        self.num_hiddens = num_hiddens
        # 词嵌入层(token -> vector)
        self.embedding = nn.Embedding(vocab_size, num_hiddens)
        # 位置编码层(添加序列位置信息)
        self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
        # 堆叠多个编码器块(num_layers决定深度)
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                EncoderBlock(key_size, query_size, value_size, num_hiddens,
                             norm_shape, ffn_num_input, ffn_num_hiddens,
                             num_heads, dropout, use_bias))

    def forward(self, X, valid_lens, *args):
        # 词嵌入 + 缩放 + 位置编码
        X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
        # 初始化注意力权重记录(每层一个)
        self.attention_weights = [None] * len(self.blks)
        # 逐层通过编码器块
        for i, blk in enumerate(self.blks):
            X = blk(X, valid_lens)
            # 记录当前层的注意力权重
            self.attention_weights[i] = blk.attention.attention.attention_weights
        return X

解码器
每个DecoderBlock类包含三个子层:解码器自注意力、编码器-解码器注意力和基于位置的前缀网络

class DecoderBlock(nn.Module):
    """解码器中第i个块"""
    def __init__(self, key_size, query_size, value_size, num_hiddens,
                 norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
                 dropout, i, **kwargs):
        super(DecoderBlock, self).__init__(**kwargs)
        self.i = i
        self.attention1 = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout)
        self.addnorm1 = AddNorm(norm_shape, dropout)
        self.attention2 = d2l.MultiHeadAttention(
            key_size, query_size, value_size, num_hiddens, num_heads, dropout)
        self.addnorm2 = AddNorm(norm_shape, dropout)
        self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens,
                                   num_hiddens)
        self.addnorm3 = AddNorm(norm_shape, dropout)

    def forward(self, X, state):
        enc_outputs, enc_valid_lens = state[0], state[1]
        # 训练阶段,输出序列的所有词元都在同一时间处理,
        # 因此state[2][self.i]初始化为None。
        # 预测阶段,输出序列是通过词元一个接着一个解码的,
        # 因此state[2][self.i]包含着直到当前时间步第i个块解码的输出表示
        if state[2][self.i] is None:
            key_values = X
        else:
            key_values = torch.cat((state[2][self.i], X), axis=1)
        state[2][self.i] = key_values
        if self.training:
            batch_size, num_steps, _ = X.shape
            # dec_valid_lens的开头:(batch_size,num_steps),
            # 其中每一行是[1,2,...,num_steps]
            dec_valid_lens = torch.arange(
                1, num_steps + 1, device=X.device).repeat(batch_size, 1)
        else:
            dec_valid_lens = None

        # 自注意力
        X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
        Y = self.addnorm1(X, X2)
        # 编码器-解码器注意力。
        # enc_outputs的开头:(batch_size,num_steps,num_hiddens)
        Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
        Z = self.addnorm2(Y, Y2)
        return self.addnorm3(Z, self.ffn(Z)), state

** Transformer解码器**

class TransformerDecoder(d2l.AttentionDecoder):
    def __init__(self, vocab_size, key_size, query_size, value_size,
                 num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
                 num_heads, num_layers, dropout, **kwargs):
        super(TransformerDecoder, self).__init__(**kwargs)
        self.num_hiddens = num_hiddens
        self.num_layers = num_layers
        # 词嵌入层(token -> vector)
        self.embedding = nn.Embedding(vocab_size, num_hiddens)
        # 位置编码层(添加序列位置信息)
        self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
        # 堆叠多个解码器块(num_layers决定深度)
        self.blks = nn.Sequential()
        for i in range(num_layers):
            self.blks.add_module("block"+str(i),
                DecoderBlock(key_size, query_size, value_size, num_hiddens,
                             norm_shape, ffn_num_input, ffn_num_hiddens,
                             num_heads, dropout, i))
        # 输出全连接层(隐状态 -> 词表概率分布)
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, enc_valid_lens, *args):
        # 初始化解码器状态:[编码器输出, 编码器有效长度, 各层缓存]
        return [enc_outputs, enc_valid_lens, [None] * self.num_layers]

    def forward(self, X, state):
        # 词嵌入 + 缩放 + 位置编码
        X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
        # 初始化双层注意力权重记录(每层两个注意力模块)
        self._attention_weights = [[None] * len(self.blks) for _ in range (2)]
        # 逐层通过解码器块
        for i, blk in enumerate(self.blks):
            X, state = blk(X, state)
            # 记录当前层的两种注意力权重:
            # [0][i] 解码器自注意力权重(掩码自注意力)
            self._attention_weights[0][i] = blk.attention1.attention.attention_weights
            # [1][i] 编码器-解码器注意力权重(交叉注意力)
            self._attention_weights[1][i] = blk.attention2.attention.attention_weights
        # 返回:预测概率分布 & 更新后的状态
        return self.dense(X), state

    @property
    def attention_weights(self):
        # 返回存储的所有注意力权重
        return self._attention_weights

小结

1、和 seq2seq 有点类似,不同之处在于

  • Transformer 是一个纯使用注意力的编码-解码器

2、编码器和解码器都有 n 个 Transformer 块

3、每个块里使用多头(自)注意力(multi-head attention),基于位置的前馈网络(Positionwise FFN),残差连接和层归一化

  • 编码器和解码器中各有一个自注意力,但是在编码器和解码器中传递信息的是一个正常的注意力
  • 基于位置的前馈网络使用同一个多层感知机,作用是对所有序列位置的表示进行转换,实际上就是一个全连接,等价于 1*1 的卷积
  • Add & norm:Add 实际上就是 Residual block 可以帮助将网络做的更深,norm 使用的是 Layer Norm 使得训练起来更加容易;Transformer 中的残差连接和层规范化是训练非常深度模型的重要工具

4、在 Transformer 中,多头注意力用于表示输入序列和输出序列,但是解码器必须通过掩码机制来保留自回归属性

第十四章 自然语言处理

14.1 BERT模型

14.1.1 BERT

NLP 里的迁移学习

  • 在计算机视觉中比较流行,将 ImageNet 或者更大的数据集上预训练好的模型应用到其他任务中,比如小数据的预测、图片分类或者是目标检测

1、使用预训练好的模型(例如 word2vec 或语言模型)来抽取词、句子的特征

2、做迁移学习的时候,一般不更新预训练好的模型

3、在更换任务之后,还是需要构建新的网络来抓取新任务需要的信息

  • 使用预训练好的模型来抽取特征的时候,一般得到的是一些比较底层的特征,很多时候只是当成一个 embedding 层来使用,还是需要设计一个比较复杂的模型
  • word2vec 忽略了时序信息
  • 语言模型只看一个方向,语言模型只看了一个方向,而且训练的模型不是很大(RNN 处理不了很长的序列,因为它只能看到很短的一部分)

BERT

1、BERT 是芝麻街中一个人物的名字,芝麻街是美国一个针对小朋友启蒙的节目

2、BERT 结合了 ELMo 对上下文进行双向编码以及 GPT 任务无关这两方面的优点,对上下文进行双向编码,并且对于大多数的自然语言处理任务只需要最少的架构改变

  • 通过将整个序列作为输入,ELMo 是为输入序列中的每一个单词分配一个表示的函数(ELMo 将来自预训练的双向长短期记忆网络的所有中间层表示组合为输出表示,ELMo 的表示将作为附加特征添加到下游任务的现有监督模型中)
  • 在加入 ELMo 表示之后,冻结了预训练的双向 LSTM 模型中的所有权重;现有的监督模型是专门为给定的任务定制的(为每一个自然语言处理任务设计一个特定的架构实际上并不是一件容易的事情),利用不同任务的不同最佳模型,添加 ELMo 改进了六种自然语言处理任务的技术水平:情感分析、自然语言推断、语义角色标注、共指消解、命名实体识别和回答
  • GPT (Generative Pre Training ,生成式预训练)模型为上下文的敏感表示设计了通用的任务无关模型,它在 Transformer 解码器的基础上,预训练了一个用于表示文本序列的语言模型,当将 GPT 应用于下游任务时,语言模型的输出被送到一个附加的线性输出层,以预测任务的标签
  • 与 ELMo 冻结预训练模型的参数不同,GPT 在下游任务的监督学习过程中对预训练 Transformer 解码器中的所有参数进行微调,GPT 在自然语言推断、问答、句子相似性和分类等12项任务上进行了评估,并在对模型架构进行最小更改的情况下改善了其中9项任务的最新水平
  • ELMo 对上下文进行双向编码,但使用特定于任务的架构;GPT 是任务无关的,但是从左到右编码上下文(由于语言模型的自回归特性,GPT 只能向前看(从左到右))
    在下游任务的监督学习过程中,BERT 在两方面与GPT相似:BERT 表示将被输入到一个添加的输出层中,根据任务的性质对模型架构进行最小的更改(例如预测每个词元与预测整个序列);BERT 对预训练 Transformer 编码器的所有参数进行微调,而额外的输出层将从头开始训练
  • BERT 进一步改进了 11 种自然语言处理任务的技术水平,这些任务分为以下几个大类:单一文本分类(如情感分析)、文本对分类(如自然语言推断)、问答、文本标记(如命名实体识别)

在这里插入图片描述

BERT 的动机

1、基于微调的 NLP 模型

2、预训练的模型抽取了足够多的信息

3、新的任务只需要增加一个简单的输出层
在这里插入图片描述

  • 做微调的时候,特征抽取的层是可以复用的(也可以应用到别的任务上面去),只需要修改分类器就可以了
  • 预训练的模型抽取了足够多的信息,使得 feature 已经足够好能够抓住很多的信息,所以在做新的任务的时候,只需要增加一个输出层就可以了

BERT 架构

1、只有编码器的 Transformer

2、BERT 在原始的论文中提供了两个原始的版本(原始 BERT 模型中,输入序列最大长度是 512):

Base:#blocks=12,hidden size=768,#heads=12,#parameters=110M
Large:#blocks=24,hidden size=1024,#heads=16,#parameters=340M
3、在大规模数据上训练 > 3B 词

对输入的修改
在这里插入图片描述
1、每个样本是一个句子对

  • 从源句子到目标句子
  • 翻译的时候,源句子进的是编码器,目标句子进的是解码器,而现在只有一个编码器。因为 NLP 中很多情况下都是两个句子,比如说 Q&A 都是两个句子,一个句子进去,一个句子出来。在 BERT 中,将两个句子拼接起来,然后放进编码器,因此每个样本就是一个句子对
    2、加入额外的片段嵌入

上图中的“this movie is great”和“i like it”两个句子是如何放进去的

  • 首先在句首加了一个特殊的分类标签(class),作为句子对的开头( BERT 输入序列明确地表示单个文本和文本对)
  • 然后在两个句子至今之间使用了一个特殊的分隔符(separate),将两个句子分开(第二个句子末尾也使用了一个分隔符)
  • 也可以做得更长,将更多的句子连接起来,但是一般没有这种情况的使用场景,所以一般使用两个句子就够了

因为有两个句子,而且仅仅使用标签的话,对于 transformer 来讲并不是很好区分两个句子的先后顺序,所以额外地添加了一个 Segment Embedding 来进行区分

  • 对于第一个句子中的所有词元添加 Segment Embedding 为 0 (包括句首的分类标签以及两个句子之间的分隔符)
  • 对于第尔个句子中的所有词元添加 Segment Embedding 为 1(包括句末的分隔符)

3、位置编码可学习

  • 在 Transformer 编码器中常见的是,位置嵌入被加入到输入序列的每个位置,而 BERT 中使用的是可学习的位置嵌入( BERT 输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和)
  • 不再使用 sin 和 cos 函数

预训练任务

BERT 是做一个通用的任务,因为他是一个预训练模型,做很常见的通用的任务,使得用这个任务训练出来的数据足够好,以至于做别的任务的时候都能做

在文本中,最通用的任务就是语言模型了,给定一个词,然后预测下一个词

  • 但是 BERT 不能直接这么做,因为他里面的编码器是可以看到后面的东西的
  • Transformer 中的编码器是双向的,既看前面又看后面,解码器才是单向的
  • BERT 中的 B 是 bi-directional ,是双向的意思,所以它是看双向的信息,然后抽取比较好的特征,但是如果用来训练语言模型的话就会有问题

因此在 BERT 中做了一个修改,叫做带掩码的语言模型

  • 给定一个句子,把中间的一些词遮起来,然后预测这些词,有点类似于完型填空

预训练任务 1:掩蔽语言模型(Masked Language Modeling)

1、Transformer 的编码器是双向的,标准语言模型要求单向

  • 语言模型使用左侧的上下文预测词元

  • 为了双向编码上下文以表示每个词元,BERT 随机掩蔽词元并使用来自双向上下文的词元以自监督的方式预测掩蔽词元
    2、带掩码的语言模型每次随机(15%概率)将一些词元(作为预测的掩蔽词元)替换成

  • 任务就变成了预测被遮起来的那些词,模型就不是预测未来,而是变成了完型填空,因此看双向的信息是没有任何问题的

  • 在每个预测位置,输入可以由特殊的“掩码”词元或随机词元替代,或者保持不变
    3、虽然 BERT 在训练的时候加了很多的 ,但是在微调任务中不出现 这种人造特殊词元,为了避免预训练和微调之间的这种不匹配,解决的办法是模型不要总是对 遮掉的部分进行预测输出

  • 80% 概率下,将选中的词元变成

  • 10% 概率下换成一个随机词元(这种偶然的噪声鼓励 BERT 在其双向上下文编码中不那么偏向于掩蔽词元,尤其是当标签词元保持不变时)

  • 10% 概率下保持原有的词元

4、带掩码的语言虽然能够编码双向上下文来表示单词,但是它并不能显式地建模文本对之间的逻辑关系

预训练任务 2:下一句子预测(Next Sentence Prediction)

1、给定一个句子对,预测这个句子对中两个句子在原始的句子中是不是相邻,从而帮助理解两个文本序列之间的关系

2、在构造样本的时候,训练样本中:

  • 50% 概率选择相邻句子对(在采样一个句子的时候,将该句子后面的一个句子也采样进去): this movie is great i like it
  • 50% 概率选择随机句子对(在采样一个句子的时候,在其他地方再随机挑选一个句子采样进去): this movie is great hello world

3、将 对应的输出放到一个全连接层来预测,判断两个句子是不是相邻的

14.1.2 BERT代码实现

下面的函数用于将文本转换为 ​​BERT 模型所需的输入格式​​ :

  • 在句首添加 [CLS](分类标记),句尾添加 [SEP](分隔标记)
  • 为 tokens_a的所有token分配 0,tokens_b(如果存在)分配 1
import torch
from torch import nn
from d2l import torch as d2l

def get_tokens_and_segments(tokens_a, tokens_b=None):
    """将输入句子转换为BERT的输入格式(带特殊标记和片段ID)"""
    # 添加[CLS]和[SEP]标记,片段ID初始化为0(对应tokens_a)
    tokens = ['<cls>'] + tokens_a + ['<sep>']
    segments = [0] * (len(tokens_a) + 2)  # +2是[CLS]和[SEP]

    # 若存在tokens_b(双句任务),追加并标记片段ID为1
    if tokens_b is not None:
        tokens += tokens_b + ['<sep>']
        segments += [1] * (len(tokens_b) + 1)  # +1是末尾[SEP]

    return tokens, segments  # 返回处理后的token序列和片段ID

BERT编码器
用于将输入的词序列(tokens)和句子分段信息(segments)转换为向量表示

class BERTEncoder(nn.Module):
    """BERT编码器,将输入tokens转换为上下文相关的向量表示"""
    
    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)
        # 词嵌入层(vocab_size -> num_hiddens维向量)
        self.token_embedding = nn.Embedding(vocab_size, num_hiddens)
        # 句子分段嵌入层(2类:句A/句B)
        self.segment_embedding = nn.Embedding(2, num_hiddens)
        # 堆叠num_layers个Transformer编码块
        self.blks = nn.Sequential()
        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))
        # 可学习的位置编码(1×max_len×num_hiddens)
        self.pos_embedding = nn.Parameter(torch.randn(1, max_len, num_hiddens))

    def forward(self, tokens, segments, valid_lens):
        # 合并词嵌入和句子分段嵌入
        X = self.token_embedding(tokens) + self.segment_embedding(segments)
        # 添加位置编码(自动截断适配序列长度)
        X = X + self.pos_embedding.data[:, :X.shape[1], :]
        # 通过所有Transformer编码块
        for blk in self.blks:
            X = blk(X, valid_lens)
        return X  # 输出形状:(batch_size, seq_len, num_hiddens)

验证编码器的结果

# BERT编码器测试
vocab_size, num_hiddens, ffn_num_hiddens, num_heads = 10000, 768, 1024, 4  # 词表大小/隐藏层维度/FFN隐藏层大小/注意力头数
norm_shape, ffn_num_input, num_layers, dropout = [768], 768, 2, 0.2         # 归一化维度/FFN输入维度/Transformer层数/Dropout率

# 初始化BERT编码器(2层Transformer)
encoder = BERTEncoder(vocab_size, num_hiddens, norm_shape, ffn_num_input,
                     ffn_num_hiddens, num_heads, num_layers, dropout)

# 生成随机输入(2个句子,每句8个token)
tokens = torch.randint(0, vocab_size, (2, 8))                     # 随机token(0~9999)
segments = torch.tensor([[0, 0, 0, 0, 1, 1, 1, 1],                # 句子分段标识(0=句A,1=句B)
                         [0, 0, 0, 1, 1, 1, 1, 1]])

# 编码并输出形状(无需有效长度掩码)
encoded_X = encoder(tokens, segments, None)                       # 前向传播
encoded_X.shape                                                   # 输出形状:[batch_size=2, seq_len=8, hidden_dim=768]

在这里插入图片描述

定义掩码语言模型

class MaskLM(nn.Module):
    """BERT的掩码语言模型任务头(MLM)"""
    def __init__(self, vocab_size, num_hiddens, num_inputs=768, **kwargs):
        super(MaskLM, self).__init__(**kwargs)
        # 定义MLP网络结构:线性层→ReLU→层归一化→线性层(输出词表概率)
        self.mlp = nn.Sequential(
            nn.Linear(num_inputs, num_hiddens),  # 输入维度768→隐藏层
            nn.ReLU(),                           # 激活函数
            nn.LayerNorm(num_hiddens),          # 归一化稳定训练
            nn.Linear(num_hiddens, vocab_size)  # 输出词表大小概率分布
        )

    def forward(self, X, pred_positions):
        # 获取需要预测的token数量(每个样本的掩码数)
        num_pred_positions = pred_positions.shape[1]
        # 展平预测位置张量(方便索引)
        pred_positions = pred_positions.reshape(-1)
        batch_size = X.shape[0]
        # 生成batch索引(用于定位掩码位置)
        batch_idx = torch.arange(0, batch_size)
        batch_idx = torch.repeat_interleave(batch_idx, num_pred_positions)
        # 提取被掩码token的隐藏向量
        masked_X = X[batch_idx, pred_positions]
        # 调整形状为[batch_size, num_masks, hidden_dim]
        masked_X = masked_X.reshape((batch_size, num_pred_positions, -1))
        # 通过MLP生成预测结果(形状[batch_size, num_masks, vocab_size])
        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。对于每个预测,结果的大小等于词表的大小。

mlm = MaskLM(vocab_size, num_hiddens)
mlm_positions = torch.tensor([[1, 5, 2], [6, 1, 5]])
mlm_Y_hat = mlm(encoded_X, mlm_positions)
mlm_Y_hat.shape

在这里插入图片描述
通过掩码下的预测词元mlm_Y的真实标签mlm_Y_hat,我们可以计算在BERT预训练中的遮蔽语言模型任务的交叉熵损失。

mlm_Y = torch.tensor([[7, 8, 9], [10, 20, 30]])
loss = nn.CrossEntropyLoss(reduction='none')
mlm_l = loss(mlm_Y_hat.reshape((-1, vocab_size)), mlm_Y.reshape(-1))
mlm_l.shape

在这里插入图片描述

下一句预测
尽管掩蔽语言建模能够编码双向上下文来表示单词,但它不能显式地建模文本对之间的逻辑关系。为了帮助理解两个文本序列之间的关系,BERT在预训练中考虑了一个二元分类任务——下一句预测。在为预训练生成句子对时,有一半的时间它们确实是标签为“真”的连续句子;在另一半的时间里,第二个句子是从语料库中随机抽取的,标记为“假”。

下面的NextSentencePred类使用单隐藏层的多层感知机来预测第二个句子是否是BERT输入序列中第一个句子的下一个句子。由于Transformer编码器中的自注意力,特殊词元“”的BERT表示已经对输入的两个句子进行了编码。因此,多层感知机分类器的输出层(self.output)以X作为输入,其中X是多层感知机隐藏层的输出,而MLP隐藏层的输入是编码后的“”词元。

class NextSentencePred(nn.Module):
    """BERT的下一句预测任务头(NSP)"""
    def __init__(self, num_inputs, **kwargs):
        super(NextSentencePred, self).__init__(**kwargs)
        # 单层线性分类器:将[CLS]标记的向量(num_inputs维)映射到二分类结果(0/1)
        self.output = nn.Linear(num_inputs, 2)  # 输出维度2(0=非下一句,1=是下一句)

    def forward(self, X):
        # 输入X:[CLS]标记的向量(形状 [batch_size, num_inputs])
        # 输出:二分类logits(形状 [batch_size, 2])
        return self.output(X)

我们可以看到,NextSentencePred实例的前向推断返回每个BERT输入序列的二分类预测。

encoded_X = torch.flatten(encoded_X, start_dim=1)
# NSP的输入形状:(batchsize,num_hiddens)
nsp = NextSentencePred(encoded_X.shape[-1])
nsp_Y_hat = nsp(encoded_X)
nsp_Y_hat.shape

在这里插入图片描述

还可以计算两个二元分类的交叉熵损失。

nsp_y = torch.tensor([0, 1])
nsp_l = loss(nsp_Y_hat, nsp_y)
nsp_l.shape

在这里插入图片描述

整合全部代码

在预训练BERT时,最终的损失函数是掩蔽语言模型损失函数和下一句预测损失函数的线性组合。现在我们可以通过实例化三个类BERTEncoder、MaskLM和NextSentencePred来定义BERTModel类。前向推断返回编码后的BERT表示encoded_X、掩蔽语言模型预测mlm_Y_hat和下一句预测nsp_Y_hat。

class BERTModel(nn.Module):
    """BERT模型完整实现(包含编码器+MLM+NSP任务头)"""
    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__()
        # BERT编码器(Transformer堆叠)
        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)
        # [CLS]标记的隐藏层转换(用于NSP任务)
        self.hidden = nn.Sequential(
            nn.Linear(hid_in_features, num_hiddens),
            nn.Tanh()  # 按BERT论文要求添加Tanh激活
        )
        # 掩码语言模型任务头
        self.mlm = MaskLM(vocab_size, num_hiddens, mlm_in_features)
        # 下一句预测任务头
        self.nsp = NextSentencePred(nsp_in_features)

    def forward(self, tokens, segments, valid_lens=None, pred_positions=None):
        # 编码输入序列(输出形状:[batch_size, seq_len, num_hiddens])
        encoded_X = self.encoder(tokens, segments, valid_lens)
        
        # MLM任务预测(若提供预测位置)
        mlm_Y_hat = self.mlm(encoded_X, pred_positions) if pred_positions else None
        
        # NSP任务预测(取[CLS]标记向量)
        nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :]))  # [CLS]向量形状[batch_size, num_hiddens]
        
        return encoded_X, mlm_Y_hat, nsp_Y_hat  # 返回编码结果+两个任务输出

小结

1、BERT 是针对 NLP 的微调设计,在大的文本上训练一个比较大的模型,在做别的任务的时候将输出层进行修改,最后的效果会比直接训练好一点( BERT 让微调在 NLP 中变成了主流)

2、BERT 其实就是一个基于 Transformer 的编码器,但是做了一点修改

  • 模型更大,训练数据更多(一般是至少十亿个词,文本不像图片,文本不需要进行标记,所以文本可以无限大)
  • 输入句子对,片段嵌入,可学习的位置编码
  • 训练时使用两个任务:带掩码的语言模型和下一个句子预测

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

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

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

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

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

7、BERT 预训练包括两个任务:掩蔽语言模型和下一句预测

  • 掩蔽语言模型能够编码双向上下文来表示单词
  • 下一句预测能够显式地建模文本对之间的逻辑关系

14.2 BERT预训练数据代码

为了预训练上面的BERT模型,我们需要以理想的格式生成数据集,以便于两个预训练任务:遮蔽语言模型和下一句预测。这里使用了较小的语料库WikiText-2

下载数据集

在WikiText-2数据集中,每行代表一个段落,其中在任意标点符号及其前面的词元之间插入空格。保留至少有两句话的段落。为了简单起见,我们仅使用句号作为分隔符来拆分句子。

import os
import random
import torch
from d2l import torch as d2l

#@save
d2l.DATA_HUB['wikitext-2'] = (
    'https://s3.amazonaws.com/research.metamind.io/wikitext/'
    'wikitext-2-v1.zip', '3c914d17d80b1459be871a5039ac23e752a53cbe')

#@save
def _read_wiki(data_dir):
    file_name = os.path.join(data_dir, 'wiki.train.tokens')
    with open(file_name, 'r') as f:
        lines = f.readlines()
    # 大写字母转换为小写字母
    paragraphs = [line.strip().lower().split(' . ')
                  for line in lines if len(line.split(' . ')) >= 2]
    random.shuffle(paragraphs)
    return paragraphs

生成下一句预测任务的数据
使用_get_next_sentence函数生成二分类任务的训练样本。

#@save
def _get_next_sentence(sentence, next_sentence, paragraphs):
    """生成BERT的下一句预测(NSP)任务数据(50%概率返回真实下一句或随机句)"""
    if random.random() < 0.5:  # 50%概率保留真实下一句
        is_next = True
    else:
        # 从段落库中随机选择非关联句(paragraphs是三重列表:[[[句子1,句子2],...],...])
        next_sentence = random.choice(random.choice(paragraphs))
        is_next = False
    return sentence, next_sentence, is_next  # 返回句对+标签(True=真实下一句)

下面的函数通过调用_get_next_sentence函数从输入paragraph生成用于下一句预测的训练样本。这里paragraph是句子列表,其中每个句子都是词元列表。自变量max_len指定预训练期间的BERT输入序列的最大长度。

#@save
def _get_nsp_data_from_paragraph(paragraph, paragraphs, vocab, max_len):
    nsp_data_from_paragraph = []
    for i in range(len(paragraph) - 1):
        tokens_a, tokens_b, is_next = _get_next_sentence(
            paragraph[i], paragraph[i + 1], paragraphs)
        # 考虑1个'<cls>'词元和2个'<sep>'词元
        if len(tokens_a) + len(tokens_b) + 3 > max_len:
            continue
        tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b)
        nsp_data_from_paragraph.append((tokens, segments, is_next))
    return nsp_data_from_paragraph

生成遮蔽语言模型任务的数据

为了从BERT输入序列生成遮蔽语言模型的训练样本,我们定义了以下_replace_mlm_tokens函数。在其输入中,tokens是表示BERT输入序列的词元的列表,candidate_pred_positions是不包括特殊词元的BERT输入序列的词元索引的列表(特殊词元在遮蔽语言模型任务中不被预测),以及num_mlm_preds指示预测的数量(选择15%要预测的随机词元)。

#@save
def _replace_mlm_tokens(tokens, candidate_pred_positions, num_mlm_preds,
                        vocab):
    # 为遮蔽语言模型的输入创建新的词元副本,其中输入可能包含替换的“<mask>”或随机词元
    mlm_input_tokens = [token for token in tokens]
    pred_positions_and_labels = []
    # 打乱后用于在遮蔽语言模型任务中获取15%的随机词元进行预测
    random.shuffle(candidate_pred_positions)
    for mlm_pred_position in candidate_pred_positions:
        if len(pred_positions_and_labels) >= num_mlm_preds:
            break
        masked_token = None
        # 80%的时间:将词替换为“<mask>”词元
        if random.random() < 0.8:
            masked_token = '<mask>'
        else:
            # 10%的时间:保持词不变
            if random.random() < 0.5:
                masked_token = tokens[mlm_pred_position]
            # 10%的时间:用随机词替换该词
            else:
                masked_token = random.choice(vocab.idx_to_token)
        mlm_input_tokens[mlm_pred_position] = masked_token
        pred_positions_and_labels.append(
            (mlm_pred_position, tokens[mlm_pred_position]))
    return mlm_input_tokens, pred_positions_and_labels

通过调用前述的_replace_mlm_tokens函数,以下函数将BERT输入序列(tokens)作为输入,并返回输入词元的索引、发生预测的词元索引以及这些预测的标签索引。

#@save
def _get_mlm_data_from_tokens(tokens, vocab):
    candidate_pred_positions = []
    # tokens是一个字符串列表
    for i, token in enumerate(tokens):
        # 在遮蔽语言模型任务中不会预测特殊词元
        if token in ['<cls>', '<sep>']:
            continue
        candidate_pred_positions.append(i)
    # 遮蔽语言模型任务中预测15%的随机词元
    num_mlm_preds = max(1, round(len(tokens) * 0.15))
    mlm_input_tokens, pred_positions_and_labels = _replace_mlm_tokens(
        tokens, candidate_pred_positions, num_mlm_preds, vocab)
    pred_positions_and_labels = sorted(pred_positions_and_labels,
                                       key=lambda x: x[0])
    pred_positions = [v[0] for v in pred_positions_and_labels]
    mlm_pred_labels = [v[1] for v in pred_positions_and_labels]
    return vocab[mlm_input_tokens], pred_positions, vocab[mlm_pred_labels]

将文本转换为预训练数据集

现在我们几乎准备好为BERT预训练定制一个Dataset类。在此之前,我们仍然需要定义辅助函数_pad_bert_inputs来将特殊的“”词元附加到输入。它的参数examples包含来自两个预训练任务的辅助函数_get_nsp_data_from_paragraph和_get_mlm_data_from_tokens的输出。

#@save
def _pad_bert_inputs(examples, max_len, vocab):
    """BERT输入批处理填充(处理MLM和NSP任务数据)"""
    max_num_mlm_preds = round(max_len * 0.15)  # 每个样本最多预测15%的token(BERT标准)
    
    # 初始化批处理容器
    all_token_ids, all_segments, valid_lens = [], [], []
    all_pred_positions, all_mlm_weights, all_mlm_labels = [], [], []
    nsp_labels = []

    for (token_ids, pred_positions, mlm_pred_label_ids, segments, is_next) in examples:
        # 填充token和segment到max_len(用<pad>和0填充)
        all_token_ids.append(torch.tensor(token_ids + [vocab['<pad>']] * (max_len - len(token_ids)), dtype=torch.long))
        all_segments.append(torch.tensor(segments + [0] * (max_len - len(segments)), dtype=torch.long))
        
        # 记录实际有效token长度(排除填充部分)
        valid_lens.append(torch.tensor(len(token_ids), dtype=torch.float32))
        
        # 处理MLM任务数据:预测位置/标签填充到max_num_mlm_preds,权重标记有效位置
        all_pred_positions.append(torch.tensor(pred_positions + [0] * (max_num_mlm_preds - len(pred_positions)), dtype=torch.long))
        all_mlm_weights.append(torch.tensor([1.0] * len(mlm_pred_label_ids) + [0.0] * (max_num_mlm_preds - len(pred_positions)), dtype=torch.float32))
        all_mlm_labels.append(torch.tensor(mlm_pred_label_ids + [0] * (max_num_mlm_preds - len(mlm_pred_label_ids)), dtype=torch.long))
        
        # NSP任务标签
        nsp_labels.append(torch.tensor(is_next, dtype=torch.long))

    return (all_token_ids, all_segments, valid_lens, all_pred_positions,
            all_mlm_weights, all_mlm_labels, nsp_labels)

将用于生成两个预训练任务的训练样本的辅助函数和用于填充输入的辅助函数放在一起,我们定义以下_WikiTextDataset类为用于预训练BERT的WikiText-2数据集。通过实现__getitem__函数,我们可以任意访问WikiText-2语料库的一对句子生成的预训练样本(遮蔽语言模型和下一句预测)样本。为简单起见,我们使用d2l.tokenize函数进行词元化。出现次数少于5次的不频繁词元将被过滤掉。

#@save
class _WikiTextDataset(torch.utils.data.Dataset):
    def __init__(self, paragraphs, max_len):
        # 输入paragraphs[i]是代表段落的句子字符串列表;
        # 而输出paragraphs[i]是代表段落的句子列表,其中每个句子都是词元列表
        paragraphs = [d2l.tokenize(
            paragraph, token='word') for paragraph in paragraphs]
        sentences = [sentence for paragraph in paragraphs
                     for sentence in paragraph]
        self.vocab = d2l.Vocab(sentences, min_freq=5, reserved_tokens=[
            '<pad>', '<mask>', '<cls>', '<sep>'])
        # 获取下一句子预测任务的数据
        examples = []
        for paragraph in paragraphs:
            examples.extend(_get_nsp_data_from_paragraph(
                paragraph, paragraphs, self.vocab, max_len))
        # 获取遮蔽语言模型任务的数据
        examples = [(_get_mlm_data_from_tokens(tokens, self.vocab)
                      + (segments, is_next))
                     for tokens, segments, is_next in examples]
        # 填充输入
        (self.all_token_ids, self.all_segments, self.valid_lens,
         self.all_pred_positions, self.all_mlm_weights,
         self.all_mlm_labels, self.nsp_labels) = _pad_bert_inputs(
            examples, max_len, self.vocab)

    def __getitem__(self, idx):
        return (self.all_token_ids[idx], self.all_segments[idx],
                self.valid_lens[idx], self.all_pred_positions[idx],
                self.all_mlm_weights[idx], self.all_mlm_labels[idx],
                self.nsp_labels[idx])

    def __len__(self):
        return len(self.all_token_ids)

通过使用_read_wiki函数和_WikiTextDataset类,定义了下面的load_data_wiki来下载并生成WikiText-2数据集,并从中生成预训练样本。

#@save
def load_data_wiki(batch_size, max_len):
    """加载WikiText-2数据集"""
    num_workers = d2l.get_dataloader_workers()
    data_dir = d2l.download_extract('wikitext-2', 'wikitext-2')
    paragraphs = _read_wiki(data_dir)
    train_set = _WikiTextDataset(paragraphs, max_len)
    train_iter = torch.utils.data.DataLoader(train_set, batch_size,
                                        shuffle=True, num_workers=num_workers)
    return train_iter, train_set.vocab

将批量大小设置为512,将BERT输入序列的最大长度设置为64,我们打印出小批量的BERT预训练样本的形状。注意,在每个BERT输入序列中,为遮蔽语言模型任务预测
10(64*0.15)个位置。

batch_size, max_len = 512, 64
train_iter, vocab = load_data_wiki(batch_size, max_len)

for (tokens_X, segments_X, valid_lens_x, pred_positions_X, mlm_weights_X,
     mlm_Y, nsp_y) in train_iter:
    print(tokens_X.shape, segments_X.shape, valid_lens_x.shape,
          pred_positions_X.shape, mlm_weights_X.shape, mlm_Y.shape,
          nsp_y.shape)
    break

最后,我们来看一下词量。即使在过滤掉不频繁的词元之后,它仍然比PTB数据集的大两倍以上。

len(vocab)

在这里插入图片描述

小结

  • 与PTB数据集相比,WikiText-2数据集保留了原来的标点符号、大小写和数字,并且比PTB数据集大了两倍多。
  • 我们可以任意访问从WikiText-2语料库中的一对句子生成的预训练(遮蔽语言模型和下一句预测)样本。

14.3 预训练BERT

加载数据集

首先,我们加载WikiText-2数据集作为小批量的预训练样本,用于遮蔽语言模型和下一句预测。批量大小是512,BERT输入序列的最大长度是64。注意,在原始BERT模型中,最大长度是512。

import torch
from torch import nn
from d2l import torch as d2l

batch_size, max_len = 512, 64
train_iter, vocab = d2l.load_data_wiki(batch_size, max_len)

这里定义了一个小的BERT,使用了2层、128个隐藏单元和2个自注意头。

初始化BERT模型,参数说明:
len(vocab): 词汇表大小
num_hiddens: 隐藏层维度(128)
norm_shape: 层归一化形状([128])
ffn_num_input: 前馈网络输入维度(128)
ffn_num_hiddens: 前馈网络隐藏层维度(256)
num_heads: 注意力头数(2)

net = d2l.BERTModel(len(vocab), num_hiddens=128, norm_shape=[128],
                    ffn_num_input=128, ffn_num_hiddens=256, num_heads=2,
                    num_layers=2, dropout=0.2, key_size=128, query_size=128,
                    value_size=128, hid_in_features=128, mlm_in_features=128,
                    nsp_in_features=128)

# 尝试获取所有可用的GPU设备
devices = d2l.try_all_gpus()

# 定义交叉熵损失函数(用于分类任务)
loss = nn.CrossEntropyLoss()

在定义训练代码实现之前,我们定义了一个辅助函数。给定训练样本,该函数计算遮蔽语言模型和下一句子预测任务的损失。请注意,BERT预训练的最终损失是遮蔽语言模型损失和下一句预测损失的和。

#@save
def _get_batch_loss_bert(net, loss, vocab_size, tokens_X,
                         segments_X, valid_lens_x,
                         pred_positions_X, mlm_weights_X,
                         mlm_Y, nsp_y):
    # 前向传播
    _, mlm_Y_hat, nsp_Y_hat = net(tokens_X, segments_X,
                                  valid_lens_x.reshape(-1),
                                  pred_positions_X)
    # 计算遮蔽语言模型损失
    mlm_l = loss(mlm_Y_hat.reshape(-1, vocab_size), mlm_Y.reshape(-1)) *\
    mlm_weights_X.reshape(-1, 1)
    mlm_l = mlm_l.sum() / (mlm_weights_X.sum() + 1e-8)
    # 计算下一句子预测任务的损失
    nsp_l = loss(nsp_Y_hat, nsp_y)
    l = mlm_l + nsp_l
    return mlm_l, nsp_l, l

通过调用上述两个辅助函数,下面的函数定义了在WikiText-2数据集上预训练BERT的过程。训练BERT可能需要很长时间。

def train_bert(train_iter, net, loss, vocab_size, devices, num_steps):
    net = nn.DataParallel(net, device_ids=devices).to(devices[0])
    trainer = torch.optim.Adam(net.parameters(), lr=0.01)
    step, timer = 0, d2l.Timer()
    animator = d2l.Animator(xlabel='step', ylabel='loss',
                            xlim=[1, num_steps], legend=['mlm', 'nsp'])
    # 遮蔽语言模型损失的和,下一句预测任务损失的和,句子对的数量,计数
    metric = d2l.Accumulator(4)
    num_steps_reached = False
    while step < num_steps and not num_steps_reached:
        for tokens_X, segments_X, valid_lens_x, pred_positions_X,\
            mlm_weights_X, mlm_Y, nsp_y in train_iter:
            tokens_X = tokens_X.to(devices[0])
            segments_X = segments_X.to(devices[0])
            valid_lens_x = valid_lens_x.to(devices[0])
            pred_positions_X = pred_positions_X.to(devices[0])
            mlm_weights_X = mlm_weights_X.to(devices[0])
            mlm_Y, nsp_y = mlm_Y.to(devices[0]), nsp_y.to(devices[0])
            trainer.zero_grad()
            timer.start()
            mlm_l, nsp_l, l = _get_batch_loss_bert(
                net, loss, vocab_size, tokens_X, segments_X, valid_lens_x,
                pred_positions_X, mlm_weights_X, mlm_Y, nsp_y)
            l.backward()
            trainer.step()
            metric.add(mlm_l, nsp_l, tokens_X.shape[0], 1)
            timer.stop()
            animator.add(step + 1,
                         (metric[0] / metric[3], metric[1] / metric[3]))
            step += 1
            if step == num_steps:
                num_steps_reached = True
                break

    print(f'MLM loss {metric[0] / metric[3]:.3f}, '
          f'NSP loss {metric[1] / metric[3]:.3f}')
    print(f'{metric[2] / timer.sum():.1f} sentence pairs/sec on '
          f'{str(devices)}')

在预训练过程中,可以绘制出遮蔽语言模型损失和下一句预测损失。

train_bert(train_iter, net, loss, len(vocab), devices, 50)

在这里插入图片描述

使用BERT表示文本
在预训练BERT之后,我们可以用它来表示单个文本、文本对或其中的任何词元。

def get_bert_encoding(net, tokens_a, tokens_b=None):
   # 获取token和对应的segment ID(区分句子A和B)
    tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b)
    
    # 将token转换为词汇表ID,并添加batch维度(unsqueeze(0))
    token_ids = torch.tensor(vocab[tokens], device=devices[0]).unsqueeze(0)
    
    # 将segment ID转换为张量,并添加batch维度
    segments = torch.tensor(segments, device=devices[0]).unsqueeze(0)
    
    # 计算有效长度(实际token数),并添加batch维度
    valid_len = torch.tensor(len(tokens), device=devices[0]).unsqueeze(0)
    
    # 通过BERT模型获取编码表示(忽略返回的MLM和NSP任务输出)
    encoded_X, _, _ = net(token_ids, segments, valid_len)
    
    return encoded_X

考虑“A crane is flying”这句话。插入特殊标记“”(用于分类)和“”(用于分隔)后,BERT输入序列的长度为6。因为零是“”词元,是整个输入语句的BERT表示。为了评估一词多义词元“crane”,我们还打印出了该词元的BERT表示的前三个元素。

# 输入句子 tokens_a = ['a', 'crane', 'is', 'flying']
encoded_text = get_bert_encoding(net, tokens_a)  # 获取BERT编码
# 注意:BERT会自动添加特殊token,实际处理序列为:
# ['<cls>', 'a', 'crane', 'is', 'flying', '<sep>']

# 提取[CLS]标记的编码(位于第0位,用于分类任务)
encoded_text_cls = encoded_text[:, 0, :]  # 形状 [batch_size=1, hidden_size=128]

# 提取'crane'的编码(位于原始tokens_a的第2个位置,加上[CLS]后实际是第2位)
encoded_text_crane = encoded_text[:, 2, :]  # 同上形状

# 打印各张量的形状和'crane'编码的前3维(示例观察)
encoded_text.shape,          # 输出: (1, 6, 128) 
                            # [batch_size=1, sequence_length=6, hidden_dim=128]
encoded_text_cls.shape,      # 输出: (1, 128)
encoded_text_crane[0][:3]    # 输出: tensor([0.1, -0.2, 0.3]) (示例值)

在这里插入图片描述

现在考虑一个句子“a crane driver comee”和“he justleft”。类似地,是来自预训练BERT的整个句子对的编码结果。注意,多义词元“crane”的前三个元素与上下文不同时的元素不同。这支持了BERT表示是上下文敏感的。

tokens_a, tokens_b = ['a', 'crane', 'driver', 'came'], ['he', 'just', 'left']
encoded_pair = get_bert_encoding(net, tokens_a, tokens_b)
# 词元:'<cls>','a','crane','driver','came','<sep>','he','just',
# 'left','<sep>'
encoded_pair_cls = encoded_pair[:, 0, :]
encoded_pair_crane = encoded_pair[:, 2, :]
encoded_pair.shape, encoded_pair_cls.shape, encoded_pair_crane[0][:3]

在这里插入图片描述

小结

  • 原始的BERT有两个版本,其中基本模型有1.1亿个参数,大模型有3.4亿个参数。
  • 在预训练BERT之后,我们可以用它来表示单个文本、文本对或其中的任何词元。
  • 在实验中,同一个词元在不同的上下文中具有不同的BERT表示。这支持BERT表示是上下文敏感的。

14.4 微调BERT

  • ​预训练​:通过自监督学习(如掩码语言建模、下一词预测),从万亿级Token中提炼语言结构、常识和跨文本关联,不依赖人工标注。
  • ​微调​:基于预训练模型权重,用任务数据(如情感分析数据集)调整参数,使模型输出更精准、更专业。
  • 在微调期间,不同应用之间的 BERT 所需的“最小架构更改”是额外的全连接层
    在下游应用的监督学习期间,额外的参数是从零开始学习的,而预训练 BERT 模型中的所有参数都是微调的

在这里插入图片描述
1、在训练好 BERT 之后,句子(含分类标识符、句子和分隔符)进入 BERT ,BERT 会对每一个 token 返回一个长为 128 (BERT-Base:768;BERT-Large:1024)的特征向量(因为是 transformer ,所以可以认为这些特征已经包含了整个句子的信息的特征表示)

  • BERT 对每一个词元返回抽取了上下文信息的特征向量
    2、不同的任务使用不同的特征

例:句子分类

1、如何将文本输入的 BERT 表示转换为输出标签?

将 对应的向量输入到全连接层分类

2、单句子分类:单文本分类将单个文本序列作为输入,并输出其分类结果

  • 特殊分类标记 用于序列分类
  • 特殊分类标记 用于标记单个文本的结束或者分隔成对文本
  • 将句子输入到 BERT 模型中,然后只将句子开始的 标识符输出对应的特征向量,然后将这个特征向量输入到一个二分类(或者是 n 分类)输出层中做 softmax 进行分类
  • 单文本分类应用中,特殊标记 的 BERT 表示对整个输入文本序列的信息进行编码,作为单个文本的表示,它将被送入到由全连接(稠密)层组成的小多层感知机中,以输出所有离散标签值的分布
    在这里插入图片描述

3、句子对分类:以一对文本作为输入但输出连续值

对于句子对也是一样的,将句子输入到 BERT 模型中,也是只将句子开始的 标识符输出对应的特征向量,然后将这个特征向量输入到一个二分类(或者是 n 分类)输出层中做 softmax 进行分类
与单文本分类相比,文本对分类的 BERT 微调在输入表示上有所不同
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

小结

1、对于问题回答、句子分类或者词分类,虽然想要的东西不太一样,但是对于 BERT 来讲都是做成一个句子对输入到模型中,最后连接一个全连接层进行分类

  • 全连接层所要看的 token 对应的特征向量可能不太一样,但是整体来讲,最后 BERT 模型- 所有的权重都是可以直接使用预训练好的模型
  • 只有最后的输出层是真的需要从零开始训练的
    2、即使 NLP 的任务各有不同,在使用 BERT 微调的时候都只需要增加输出层就可以了,而不需要在意怎样表示句子信息、段落信息、词信息,这些已经假设 BERT 已经提前做好了,只需要加一个简单的输出层就可以了

3、根据任务的不同,输入的表示和使用的 BERT 特征也会不一样,但是整体来讲,使用了 BERT 之后,使得整个微调相比于之前简单很多,而且 BERT 对于整个任务的提升也是非常显著的(比从零开始训练可能效果会好很多)

4、现在在 NLP 领域基本上也是转向微调的路线

5、对于序列级和词元级自然语言处理应用,BERT 只需要最小的架构改变(额外的全连接层)

6、在下游应用的监督学习期间,额外层的参数是从零开始学习的,而预训练 BERT 模型中的所有参数都是微调的

7、可以针对下游应用对预训练的 BERT 模型进行微调,在微调过程中,BERT 模型成为下游应用模型的一部分,仅与训练前损失相关的参数在微调期间不会更新

总结

本周深入学习了Transformer和BERT模型的核心原理与实现。Transformer作为纯注意力机制的架构,通过多头注意力、位置编码和残差连接实现了高效的序列建模。BERT基于Transformer编码器,通过掩码语言模型和下一句预测任务进行预训练,能够生成上下文相关的词向量表示。在代码实践中,实现了BERT的输入处理、编码器结构和预训练任务,并验证了其上下文敏感性。通过微调BERT模型,可以将其应用于各种NLP任务,展现了强大的迁移学习能力。这些内容为理解现代NLP模型奠定了基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值