一、动机
1、在NLP中的迁移学习中,使用预训练好的模型抽取词、句子的特征,不更新预训练好的模型,而是在需要构建新的网络来抓取新任务需要的信息,也就是最后面加上一个MLP做分类;
2、由于基于微调的NLP模型,也想要一个类似的东西,使预训练的模型抽取了足够多的信息,新任务只需要一个简单的输出层。
二、BERT架构
1、是只有编码器的transform,在大规模数据上训练大于3亿,其中有两个版本:
2、对输入的修改
(1)每个样本是一个句子对
(2)加入了额外的片段嵌入:分隔词元“<sep>”、特殊类别词元“<cls>”
(3)位置编码可以学习
3、训练任务
(1)带掩码的语言模型
在这个预训练任务中,将随机选择15%的词元作为预测的掩蔽词元,用一个特殊的“<mask>”替换输入序列中的词元。
人造特殊词元“<mask>”不会出现在微调中。为了避免预训练和微调之间的这种不匹配,如果为预测而屏蔽词元(例如,在“this movie is great”中选择掩蔽和预测“great”),则在输入中将其替换为:
(2)二元分类任务——下一句预测。
在为预训练生成句子对时,有一半的时间它们确实是标签为“真”的连续句子;在另一半的时间里,第二个句子是从语料库中随机抽取的,标记为“假”。
三、总结
1、word2vec和GloVe等词嵌入模型与上下文无关。它们将相同的预训练向量赋给同一个词,而不考虑词的上下文(如果有的话)。它们很难处理好自然语言中的一词多义或复杂语义。
2、对于上下文敏感的词表示,如ELMo和GPT,词的表示依赖于它们的上下文。
3、ELMo对上下文进行双向编码,但使用特定于任务的架构(然而,为每个自然语言处理任务设计一个特定的体系架构实际上并不容易);而GPT是任务无关的,但是从左到右编码上下文。
4、BERT结合了这两个方面的优点:它对上下文进行双向编码,并且需要对大量自然语言处理任务进行最小的架构更改。
5、BERT输入序列的嵌入是词元嵌入、片段嵌入和位置嵌入的和。
6、预训练包括两个任务:掩蔽语言模型和下一句预测。前者能够编码双向上下文来表示单词,而后者则显式地建模文本对之间的逻辑关系。
7、BERT针对微调设计
四、BERT代码
1、获取输入序列的词元及其片段索引
#@save def get_tokens_and_segments(tokens_a, tokens_b=None): """获取输入序列的词元及其片段索引""" tokens = ['<cls>'] + tokens_a + ['<sep>'] # 0和1分别标记片段A和B 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
2、BERT编码器
#@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) self.token_embedding = nn.Embedding(vocab_size, num_hiddens) #用于区分输入序列中的不同段(segments),在BERT中,输入序列通常是由两个句子拼接而成,并且需要区分它们。每个token都会被分配一个段ID(通常是0或1),表示它属于哪个句子。 self.segment_embedding = nn.Embedding(2, num_hiddens) 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)) # 在BERT中,位置嵌入是可学习的,因此我们创建一个足够长的位置嵌入参数 #每个位置都需要一个唯一的编码,因此有 max_len 个位置编码;num_hiddens 表示每个位置编码向量的维度。这个维度与输入嵌入向量的维度相同,以便位置编码可以直接添加到输入嵌入向量中。 self.pos_embedding = nn.Parameter(torch.randn(1, max_len, num_hiddens)) def forward(self, tokens, segments, valid_lens): # 在以下代码段中,X的形状保持不变:(批量大小,最大序列长度,num_hiddens) #包含结合token和段信息的嵌入向量,为输入到接下来的Transformer编码层中的进一步处理。 X = self.token_embedding(tokens) + self.segment_embedding(segments) X = X + self.pos_embedding.data[:, :X.shape[1], :] for blk in self.blks: X = blk(X, valid_lens) return X
3、BERT的掩蔽语言模型任务
#@save class MaskLM(nn.Module): """BERT的掩蔽语言模型任务""" def __init__(self, vocab_size, num_hiddens, num_inputs=768, **kwargs): super(MaskLM, self).__init__(**kwargs) #MLP将输入的特征转换为词汇表大小的输出。 self.mlp = nn.Sequential(nn.Linear(num_inputs, num_hiddens), nn.ReLU(), nn.LayerNorm(num_hiddens), nn.Linear(num_hiddens, 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) # 假设batch_size=2,num_pred_positions=3 # 那么batch_idx是np.array([0,0,0,1,1,1]) #将批次索引重复 num_pred_positions 次。 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
4、 下一句预测
#@save class NextSentencePred(nn.Module): """BERT的下一句预测任务""" def __init__(self, num_inputs, **kwargs): super(NextSentencePred, self).__init__(**kwargs) #是或者不是 self.output = nn.Linear(num_inputs, 2) def forward(self, X): # X的形状:(batchsize,num_hiddens) return self.output(X)
5、整合
#@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__() 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) self.hidden = nn.Sequential(nn.Linear(hid_in_features, num_hiddens), nn.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): 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 # 用于下一句预测的多层感知机分类器的隐藏层,0是“<cls>”标记的索引 nsp_Y_hat = self.nsp(self.hidden(encoded_X[:, 0, :])) return encoded_X, mlm_Y_hat, nsp_Y_hat
五、预训练BERT的数据集
以理想的格式生成数据集,以便于两个预训练任务:遮蔽语言模型和下一句预测
1、为预训练任务定义辅助函数
(1)下载数据集
#@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
(2)生成下一句预测任务的数据
#@save def _get_next_sentence(sentence, next_sentence, paragraphs): if random.random() < 0.5: is_next = True else: # paragraphs是三重列表的嵌套 next_sentence = random.choice(random.choice(paragraphs)) is_next = False return sentence, next_sentence, is_next
#@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
(3)生成遮蔽语言模型任务的数据
#@save #candidate_pred_positions:候选预测位置列表;num_mlm_preds:需要进行掩蔽或替换的词元数量;vocab:词汇表对象,提供词元到索引的映射。 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])) #返回更新后的词元列表 mlm_input_tokens 和记录掩蔽位置及标签的列表 pred_positions_and_labels。 return mlm_input_tokens, pred_positions_and_labels
(4)将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 和对应的原始标签 mlm_pred_labels。 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]
2、将文本转换为预训练数据集
#@save def _pad_bert_inputs(examples, max_len, vocab): max_num_mlm_preds = round(max_len * 0.15) 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: #填充到 max_len 长度,用 <pad> 词元进行填充 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)) # valid_lens不包括'<pad>'的计数 valid_lens.append(torch.tensor(len(token_ids), dtype=torch.float32)) all_pred_positions.append(torch.tensor(pred_positions + [0] * ( max_num_mlm_preds - len(pred_positions)), dtype=torch.long)) # 填充MLM权重,预测位置对应权重为1.0,填充位置对应权重为0.0 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)) #将 mlm_pred_label_ids 填充到 max_num_mlm_preds 长度,用0进行填充。 all_mlm_labels.append(torch.tensor(mlm_pred_label_ids + [0] * ( max_num_mlm_preds - len(mlm_pred_label_ids)), dtype=torch.long)) 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)
3、处理维基文本数据集
#@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)
4、加载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
六、预训练BERT
1、定义了一个小的BERT,使用了2层、128个隐藏单元和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) devices = d2l.try_all_gpus() loss = nn.CrossEntropyLoss()
2、计算遮蔽语言模型和下一句子预测任务的损失
#@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
3、在WikiText-2(train_iter
)数据集上预训练BERT(net
)
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)}')
4、预训练BERT之后,我们可以用它来表示单个文本、文本对或其中的任何词元。
def get_bert_encoding(net, tokens_a, tokens_b=None): tokens, segments = d2l.get_tokens_and_segments(tokens_a, tokens_b) token_ids = torch.tensor(vocab[tokens], device=devices[0]).unsqueeze(0) segments = torch.tensor(segments, device=devices[0]).unsqueeze(0) valid_len = torch.tensor(len(tokens), device=devices[0]).unsqueeze(0) encoded_X, _, _ = net(token_ids, segments, valid_len) return encoded_X
tokens_a = ['a', 'crane', 'is', 'flying'] encoded_text = get_bert_encoding(net, tokens_a) # 词元:'<cls>','a','crane','is','flying','<sep>' #因为零是“<cls>”词元,encoded_text[:, 0, :]是整个输入语句的BERT表示。 #来自预训练BERT的整个句子对的编码结果 encoded_text_cls = encoded_text[:, 0, :] encoded_text_crane = encoded_text[:, 2, :] #打印出了该词元的BERT表示的前三个元素 encoded_text.shape, encoded_text_cls.shape, encoded_text_crane[0][:3]
输出(torch.Size([1, 6, 128]),------['cls','a', 'crane', 'is', 'flying','seq']
torch.Size([1, 128]),------['cls']
tensor([-0.5007, -1.0034, 0.8718], device='cuda:0', grad_fn=<SliceBackward0>))