15.8. 来自Transformer的双向编码器表示(BERT)¶ 在 SageMaker Studio Lab 中打开 Notebook
我们已经介绍了多种用于自然语言理解的词嵌入模型。经过预训练后,我们可以得到一个预定义词表中每个词的向量。事实上,这些词嵌入模型都是与上下文无关的。让我们从阐述这个性质开始。
15.8.1. 从与上下文无关到与上下文相关¶
回顾 第 15.4 节 和 第 15.7 节 中的实验。例如,word2vec和GloVe都为同一个词分配了相同的预训练向量,而不管词的上下文(如果有的话)是什么。形式上,任意词元 \(x\) 的与上下文无关的表示是函数 \(f(x)\),它只将 \(x\) 作为其输入。鉴于自然语言中存在大量的一词多义和复杂的语义,与上下文无关的表示具有明显的局限性。例如,在“a crane is flying”(起重机在飞)和“a crane driver came”(一位起重机司机来了)的上下文中,“crane”一词有完全不同的含义;因此,同一个词可以根据不同的上下文被赋予不同的表示。
这激发了与上下文相关的词表示法的发展,其中词的表示依赖于它们的上下文。因此,词元 \(x\) 的与上下文相关的表示是函数 \(f(x, c(x))\),它依赖于 \(x\) 和它的上下文 \(c(x)\)。流行的与上下文相关的表示包括TagLM(语言模型增强的序列标记器)(Peters et al., 2017)、CoVe(上下文向量)(McCann et al., 2017) 和 ELMo(来自语言模型的嵌入)(Peters et al., 2018)。
例如,通过将整个序列作为输入,ELMo是一个函数,它从输入序列中为每个词分配一个表示。具体来说,ELMo将来自预训练的双向LSTM的所有中间层表示组合为输出表示。然后,ELMo表示将被添加到下游任务的现有监督模型中,作为附加特征,例如通过将ELMo表示与现有模型中的词元(例如,GloVe)的原始表示连接起来。一方面,在添加ELMo表示后,预训练的双向LSTM模型中的所有权重都被冻结。另一方面,现有的监督模型是为给定的任务专门定制的。利用当时不同任务的不同最佳模型,添加ELMo改进了六个自然语言处理任务的最新技术水平:情感分析、自然语言推断、语义角色标注、共指消解、命名实体识别和问答。
15.8.2. 从特定于任务到与任务无关¶
尽管ELMo显著改进了各种自然语言处理任务的解决方案,但每个解决方案仍然依赖于一个*特定于任务*的架构。然而,为每个自然语言处理任务都设计一个特定的架构在实践中是不切实际的。GPT(生成式预训练)模型代表了设计一个通用的*与任务无关*的上下文敏感表示模型的努力 (Radford et al., 2018)。GPT建立在Transformer解码器之上,预训练了一个语言模型,该模型将用于表示文本序列。当将GPT应用于下游任务时,语言模型的输出将被送入一个附加的线性输出层,以预测任务的标签。与ELMo冻结预训练模型的参数形成鲜明对比的是,GPT在下游任务的监督学习过程中微调预训练Transformer解码器中的*所有*参数。GPT在自然语言推断、问答、句子相似性和分类的十二个任务上进行了评估,并在其中九个任务上以最小的模型架构改动改进了最新技术水平。
然而,由于语言模型的自回归特性,GPT只向前看(从左到右)。在上下文“i went to the bank to deposit cash”(我去了银行存钱)和“i went to the bank to sit down”(我去了河岸坐下)中,由于“bank”对左侧的上下文敏感,GPT会为“bank”返回相同的表示,尽管它有不同的含义。
15.8.3. BERT:两全其美¶
正如我们所见,ELMo双向编码上下文但使用特定于任务的架构;而GPT与任务无关但从左到右编码上下文。BERT(来自Transformer的双向编码器表示)结合了两者的优点,它双向编码上下文,并且只需要对广泛的自然语言处理任务进行最小的架构更改 (Devlin et al., 2018)。使用预训练的Transformer编码器,BERT能够根据其双向上下文表示任何词元。在下游任务的监督学习期间,BERT在两个方面与GPT相似。首先,BERT表示将被送入一个附加的输出层,根据任务的性质(例如,为每个词元预测 vs. 为整个序列预测)对模型架构进行最小的更改。其次,预训练的Transformer编码器的所有参数都进行微调,而额外的输出层将从头开始训练。图 15.8.1 描绘了ELMo、GPT和BERT之间的差异。
图 15.8.1 ELMo、GPT和BERT的比较。¶
BERT进一步改进了11个自然语言处理任务的最新技术水平,这些任务大致可分为(i) 单文本分类(如情感分析),(ii) 文本对分类(如自然语言推断),(iii) 问答,(iv) 文本标注(如命名实体识别)。所有这些模型都于2018年提出,从上下文敏感的ELMo到与任务无关的GPT和BERT,这些概念上简单但经验上强大的自然语言深度表示预训练方法,已经彻底改变了各种自然语言处理任务的解决方案。
在本章的其余部分,我们将深入探讨BERT的预训练。在 第 16 节 中解释自然语言处理应用时,我们将说明如何为下游应用微调BERT。
import torch
from torch import nn
from d2l import torch as d2l
from mxnet import gluon, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l
npx.set_np()
15.8.4. 输入表示¶
在自然语言处理中,一些任务(如情感分析)以单个文本为输入,而在另一些任务中(如自然语言推断),输入是一对文本序列。BERT输入序列明确地表示单个文本和文本对。在前一种情况下,BERT输入序列是特殊分类词元“<cls>”、一个文本序列的词元和特殊分隔词元“<sep>”的串联。在后一种情况下,BERT输入序列是“<cls>”、第一个文本序列的词元、“<sep>”、第二个文本序列的词元和“<sep>”的串联。我们将始终区分“BERT输入序列”和其他类型的“序列”。例如,一个*BERT输入序列*可能包含一个*文本序列*或两个*文本序列*。
为了区分文本对,学习到的片段嵌入 \(\mathbf{e}_A\) 和 \(\mathbf{e}_B\) 分别被添加到第一个序列和第二个序列的词元嵌入中。对于单个文本输入,只使用 \(\mathbf{e}_A\)。
以下 `get_tokens_and_segments` 以一个句子或两个句子作为输入,然后返回BERT输入序列的词元及其对应的片段ID。
#@save
def get_tokens_and_segments(tokens_a, tokens_b=None):
"""Get tokens of the BERT input sequence and their segment IDs."""
tokens = ['<cls>'] + tokens_a + ['<sep>']
# 0 and 1 are marking segment A and B, respectively
segments = [0] * (len(tokens_a) + 2)
if tokens_b is not None:
tokens += tokens_b + ['<sep>']
segments += [1] * (len(tokens_b) + 1)
return tokens, segments
#@save
def get_tokens_and_segments(tokens_a, tokens_b=None):
"""Get tokens of the BERT input sequence and their segment IDs."""
tokens = ['<cls>'] + tokens_a + ['<sep>']
# 0 and 1 are marking segment A and B, respectively
segments = [0] * (len(tokens_a) + 2)
if tokens_b is not None:
tokens += tokens_b + ['<sep>']
segments += [1] * (len(tokens_b) + 1)
return tokens, segments
BERT选择Transformer编码器作为其双向架构。在Transformer编码器中,位置嵌入在BERT输入序列的每个位置上都会被添加。然而,与原始Transformer编码器不同,BERT使用*可学习*的位置嵌入。总而言之,图 15.8.2 显示BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的总和。
图 15.8.2 BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的总和。¶
以下 `BERTEncoder` 类类似于 第 11.7 节 中实现的 `TransformerEncoder` 类。与 `TransformerEncoder` 不同,`BERTEncoder` 使用片段嵌入和可学习的位置嵌入。
#@save
class BERTEncoder(nn.Module):
"""BERT encoder."""
def __init__(self, vocab_size, num_hiddens, ffn_num_hiddens, num_heads,
num_blks, dropout, max_len=1000, **kwargs):
super(BERTEncoder, self).__init__(**kwargs)
self.token_embedding = nn.Embedding(vocab_size, num_hiddens)
self.segment_embedding = nn.Embedding(2, num_hiddens)
self.blks = nn.Sequential()
for i in range(num_blks):
self.blks.add_module(f"{i}", d2l.TransformerEncoderBlock(
num_hiddens, ffn_num_hiddens, num_heads, dropout, True))
# In BERT, positional embeddings are learnable, thus we create a
# parameter of positional embeddings that are long enough
self.pos_embedding = nn.Parameter(torch.randn(1, max_len,
num_hiddens))
def forward(self, tokens, segments, valid_lens):
# Shape of `X` remains unchanged in the following code snippet:
# (batch size, max sequence length, `num_hiddens`)
X = self.token_embedding(tokens) + self.segment_embedding(segments)
X = X + self.pos_embedding[:, :X.shape[1], :]
for blk in self.blks:
X = blk(X, valid_lens)
return X
#@save
class BERTEncoder(nn.Block):
"""BERT encoder."""
def __init__(self, vocab_size, num_hiddens, ffn_num_hiddens, num_heads,
num_blks, dropout, max_len=1000, **kwargs):
super(BERTEncoder, self).__init__(**kwargs)
self.token_embedding = nn.Embedding(vocab_size, num_hiddens)
self.segment_embedding = nn.Embedding(2, num_hiddens)
self.blks = nn.Sequential()
for _ in range(num_blks):
self.blks.add(d2l.TransformerEncoderBlock(
num_hiddens, ffn_num_hiddens, num_heads, dropout, True))
# In BERT, positional embeddings are learnable, thus we create a
# parameter of positional embeddings that are long enough
self.pos_embedding = self.params.get('pos_embedding',
shape=(1, max_len, num_hiddens))
def forward(self, tokens, segments, valid_lens):
# Shape of `X` remains unchanged in the following code snippet:
# (batch size, max sequence length, `num_hiddens`)
X = self.token_embedding(tokens) + self.segment_embedding(segments)
X = X + self.pos_embedding.data(ctx=X.ctx)[:, :X.shape[1], :]
for blk in self.blks:
X = blk(X, valid_lens)
return X
假设词表大小为10000。为了演示 `BERTEncoder` 的前向推断,我们创建一个它的实例并初始化其参数。
vocab_size, num_hiddens, ffn_num_hiddens, num_heads = 10000, 768, 1024, 4
ffn_num_input, num_blks, dropout = 768, 2, 0.2
encoder = BERTEncoder(vocab_size, num_hiddens, ffn_num_hiddens, num_heads,
num_blks, dropout)
vocab_size, num_hiddens, ffn_num_hiddens, num_heads = 10000, 768, 1024, 4
num_blks, dropout = 2, 0.2
encoder = BERTEncoder(vocab_size, num_hiddens, ffn_num_hiddens, num_heads,
num_blks, dropout)
encoder.initialize()
[22:07:48] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
我们将 `tokens` 定义为2个长度为8的BERT输入序列,其中每个词元是词表中的一个索引。`BERTEncoder` 对输入 `tokens` 的前向推断返回编码结果,其中每个词元由一个向量表示,其长度由超参数 `num_hiddens` 预定义。这个超参数通常被称为Transformer编码器的*隐藏大小*(隐藏单元数)。
tokens = torch.randint(0, vocab_size, (2, 8))
segments = torch.tensor([[0, 0, 0, 0, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1]])
encoded_X = encoder(tokens, segments, None)
encoded_X.shape
torch.Size([2, 8, 768])
tokens = np.random.randint(0, vocab_size, (2, 8))
segments = np.array([[0, 0, 0, 0, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1]])
encoded_X = encoder(tokens, segments, None)
encoded_X.shape
(2, 8, 768)
15.8.5. 预训练任务¶
`BERTEncoder` 的前向推断给出了输入文本的每个词元以及插入的特殊词元“<cls>”和“<seq>”的BERT表示。接下来,我们将使用这些表示来计算用于预训练BERT的损失函数。预训练由以下两个任务组成:掩码语言建模和下一句预测。
15.8.5.1. 掩码语言建模¶
如 第 9.3 节 所述,语言模型使用其左侧的上下文来预测一个词元。为了双向编码上下文以表示每个词元,BERT随机地掩盖词元,并使用来自双向上下文的词元以自监督的方式预测被掩盖的词元。这个任务被称为*掩码语言模型*。
在这个预训练任务中,将随机选择15%的词元作为被掩盖的词元进行预测。为了在不通过使用标签作弊的情况下预测被掩盖的词元,一个直接的方法是在BERT输入序列中总是用一个特殊的“<mask>”词元替换它。然而,人为的特殊词元“<mask>”永远不会出现在微调中。为了避免预训练和微调之间的这种不匹配,如果一个词元被掩盖用于预测(例如,在“this movie is great”中,“great”被选择为被掩盖和预测的词),在输入中它将被替换为:
80%的时间是一个特殊的“<mask>”词元(例如,“this movie is great”变成“this movie is <mask>”);
10%的时间是一个随机的词元(例如,“this movie is great”变成“this movie is drink”);
10%的时间是未改变的标签词元(例如,“this movie is great”变成“this movie is great”)。
注意,在15%的时间里,有10%的时间会插入一个随机词元。这种偶尔的噪声鼓励BERT在其双向上下文编码中对被掩盖的词元(特别是当标签词元保持不变时)减少偏见。
我们实现以下 `MaskLM` 类来预测BERT预训练的掩码语言模型任务中的掩码词元。预测使用一个单隐藏层的MLP(`self.mlp`)。在前向推断中,它接受两个输入:`BERTEncoder` 的编码结果和用于预测的词元位置。输出是这些位置的预测结果。
#@save
class MaskLM(nn.Module):
"""The masked language model task of BERT."""
def __init__(self, vocab_size, num_hiddens, **kwargs):
super(MaskLM, self).__init__(**kwargs)
self.mlp = nn.Sequential(nn.LazyLinear(num_hiddens),
nn.ReLU(),
nn.LayerNorm(num_hiddens),
nn.LazyLinear(vocab_size))
def forward(self, X, pred_positions):
num_pred_positions = pred_positions.shape[1]
pred_positions = pred_positions.reshape(-1)
batch_size = X.shape[0]
batch_idx = torch.arange(0, batch_size)
# Suppose that `batch_size` = 2, `num_pred_positions` = 3, then
# `batch_idx` is `torch.tensor([0, 0, 0, 1, 1, 1])`
batch_idx = torch.repeat_interleave(batch_idx, num_pred_positions)
masked_X = X[batch_idx, pred_positions]
masked_X = masked_X.reshape((batch_size, num_pred_positions, -1))
mlm_Y_hat = self.mlp(masked_X)
return mlm_Y_hat
#@save
class MaskLM(nn.Block):
"""The masked language model task of BERT."""
def __init__(self, vocab_size, num_hiddens, **kwargs):
super(MaskLM, self).__init__(**kwargs)
self.mlp = nn.Sequential()
self.mlp.add(
nn.Dense(num_hiddens, flatten=False, activation='relu'))
self.mlp.add(nn.LayerNorm())
self.mlp.add(nn.Dense(vocab_size, flatten=False))
def forward(self, X, pred_positions):
num_pred_positions = pred_positions.shape[1]
pred_positions = pred_positions.reshape(-1)
batch_size = X.shape[0]
batch_idx = np.arange(0, batch_size)
# Suppose that `batch_size` = 2, `num_pred_positions` = 3, then
# `batch_idx` is `np.array([0, 0, 0, 1, 1, 1])`
batch_idx = np.repeat(batch_idx, num_pred_positions)
masked_X = X[batch_idx, pred_positions]
masked_X = masked_X.reshape((batch_size, num_pred_positions, -1))
mlm_Y_hat = self.mlp(masked_X)
return mlm_Y_hat
为了演示 `MaskLM` 的前向推断,我们创建它的实例 `mlm` 并初始化它。回想一下,`BERTEncoder` 前向推断得到的 `encoded_X` 代表了2个BERT输入序列。我们将 `mlm_positions` 定义为在 `encoded_X` 的任一BERT输入序列中要预测的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
torch.Size([2, 3, 10000])
mlm = MaskLM(vocab_size, num_hiddens)
mlm.initialize()
mlm_positions = np.array([[1, 5, 2], [6, 1, 5]])
mlm_Y_hat = mlm(encoded_X, mlm_positions)
mlm_Y_hat.shape
(2, 3, 10000)
有了被掩码的预测词元 `mlm_Y_hat` 的真实标签 `mlm_Y`,我们就可以计算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
torch.Size([6])
mlm_Y = np.array([[7, 8, 9], [10, 20, 30]])
loss = gluon.loss.SoftmaxCrossEntropyLoss()
mlm_l = loss(mlm_Y_hat.reshape((-1, vocab_size)), mlm_Y.reshape(-1))
mlm_l.shape
(6,)
15.8.5.2. 下一句预测¶
尽管掩码语言建模能够编码双向上下文来表示单词,但它没有显式地建模文本对之间的逻辑关系。为了帮助理解两个文本序列之间的关系,BERT在其预训练中考虑了一个二元分类任务,即*下一句预测*。在为预训练生成句子对时,一半的时间它们确实是连续的句子,标签为“真”;而另一半时间,第二个句子是从语料库中随机抽取的,标签为“假”。
以下 `NextSentencePred` 类使用一个单隐藏层的MLP来预测BERT输入序列中的第二个句子是否是第一个句子的下一句。由于Transformer编码器中的自注意力机制,特殊词元“<cls>”的BERT表示编码了来自输入的两个句子。因此,MLP分类器的输出层(`self.output`)以 `X` 作为输入,其中 `X` 是MLP隐藏层的输出,其输入是编码后的“<cls>”词元。
#@save
class NextSentencePred(nn.Module):
"""The next sentence prediction task of BERT."""
def __init__(self, **kwargs):
super(NextSentencePred, self).__init__(**kwargs)
self.output = nn.LazyLinear(2)
def forward(self, X):
# `X` shape: (batch size, `num_hiddens`)
return self.output(X)
#@save
class NextSentencePred(nn.Block):
"""The next sentence prediction task of BERT."""
def __init__(self, **kwargs):
super(NextSentencePred, self).__init__(**kwargs)
self.output = nn.Dense(2)
def forward(self, X):
# `X` shape: (batch size, `num_hiddens`)
return self.output(X)
我们可以看到,`NextSentencePred` 实例的前向推断为每个BERT输入序列返回二元预测。
# PyTorch by default will not flatten the tensor as seen in mxnet where, if
# flatten=True, all but the first axis of input data are collapsed together
encoded_X = torch.flatten(encoded_X, start_dim=1)
# input_shape for NSP: (batch size, `num_hiddens`)
nsp = NextSentencePred()
nsp_Y_hat = nsp(encoded_X)
nsp_Y_hat.shape
torch.Size([2, 2])
nsp = NextSentencePred()
nsp.initialize()
nsp_Y_hat = nsp(encoded_X)
nsp_Y_hat.shape
(2, 2)
这2个二元分类的交叉熵损失也可以被计算出来。
nsp_y = torch.tensor([0, 1])
nsp_l = loss(nsp_Y_hat, nsp_y)
nsp_l.shape
torch.Size([2])
nsp_y = np.array([0, 1])
nsp_l = loss(nsp_Y_hat, nsp_y)
nsp_l.shape
(2,)
值得注意的是,上述两个预训练任务中的所有标签都可以从预训练语料库中轻易获得,无需人工标注。原始的BERT是在BookCorpus (Zhu et al., 2015) 和英文维基百科的串联上进行预训练的。这两个文本语料库非常庞大:它们分别有8亿个单词和25亿个单词。
15.8.6. 整合¶
在预训练BERT时,最终的损失函数是掩码语言建模和下一句预测的损失函数的线性组合。现在我们可以通过实例化 `BERTEncoder`、`MaskLM` 和 `NextSentencePred` 这三个类来定义 `BERTModel` 类。前向推断返回编码后的BERT表示 `encoded_X`、掩码语言建模的预测 `mlm_Y_hat` 和下一句预测 `nsp_Y_hat`。
#@save
class BERTModel(nn.Module):
"""The BERT model."""
def __init__(self, vocab_size, num_hiddens, ffn_num_hiddens,
num_heads, num_blks, dropout, max_len=1000):
super(BERTModel, self).__init__()
self.encoder = BERTEncoder(vocab_size, num_hiddens, ffn_num_hiddens,
num_heads, num_blks, dropout,
max_len=max_len)
self.hidden = nn.Sequential(nn.LazyLinear(num_hiddens),
nn.Tanh())
self.mlm = MaskLM(vocab_size, num_hiddens)
self.nsp = NextSentencePred()
def forward(self, tokens, segments, valid_lens=None, pred_positions=None):
encoded_X = self.encoder(tokens, segments, valid_lens)
if pred_positions is not None:
mlm_Y_hat = self.mlm(encoded_X, pred_positions)
else:
mlm_Y_hat = None
# The hidden layer of the MLP classifier for next sentence prediction.
# 0 is the index of the '<cls>' token
nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :]))
return encoded_X, mlm_Y_hat, nsp_Y_hat
#@save
class BERTModel(nn.Block):
"""The BERT model."""
def __init__(self, vocab_size, num_hiddens, ffn_num_hiddens, num_heads,
num_blks, dropout, max_len=1000):
super(BERTModel, self).__init__()
self.encoder = BERTEncoder(vocab_size, num_hiddens, ffn_num_hiddens,
num_heads, num_blks, dropout, max_len)
self.hidden = nn.Dense(num_hiddens, activation='tanh')
self.mlm = MaskLM(vocab_size, num_hiddens)
self.nsp = NextSentencePred()
def forward(self, tokens, segments, valid_lens=None, pred_positions=None):
encoded_X = self.encoder(tokens, segments, valid_lens)
if pred_positions is not None:
mlm_Y_hat = self.mlm(encoded_X, pred_positions)
else:
mlm_Y_hat = None
# The hidden layer of the MLP classifier for next sentence prediction.
# 0 is the index of the '<cls>' token
nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :]))
return encoded_X, mlm_Y_hat, nsp_Y_hat
15.8.7. 小结¶
诸如word2vec和GloVe之类的词嵌入模型是与上下文无关的。它们为同一个词分配相同的预训练向量,而不管该词的上下文(如果有的话)。它们很难很好地处理自然语言中的一词多义或复杂语义。
对于像ELMo和GPT这样与上下文相关的词表示,词的表示依赖于它们的上下文。
ELMo双向编码上下文,但使用特定于任务的架构(然而,为每个自然语言处理任务都设计一个特定的架构在实践中是不切实际的);而GPT与任务无关,但从左到右编码上下文。
BERT结合了两者的优点:它双向编码上下文,并且对广泛的自然语言处理任务只需要最小的架构更改。
BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的总和。
预训练BERT由两个任务组成:掩码语言建模和下一句预测。前者能够编码双向上下文来表示单词,而后者则显式地建模文本对之间的逻辑关系。
15.8.8. 练习¶
在所有其他条件相同的情况下,掩码语言模型会比从左到右的语言模型需要更多还是更少的预训练步骤来收敛?为什么?
在BERT的原始实现中,`BERTEncoder` 中的逐位置前馈网络(通过 `d2l.TransformerEncoderBlock`)和 `MaskLM` 中的全连接层都使用高斯误差线性单元(GELU)(Hendrycks and Gimpel, 2016) 作为激活函数。研究GELU和ReLU之间的区别。