更新于 2026年7月25日

经过前面几节内容的介绍,我们已经清楚了BERT模型的基本原理、如何从零实现BERT、如何基于BERT预训练模型来完成文本分类任务、文本蕴含任务、问答选择任务(SWAG)以及问题回答任务(SQuAD),算是完成了BERT模型前面两部分内容的介绍。在接下来的这一节内容中,我们将开始就BERT模型的第三部分内容,即如何利用MLM和NSP这两个预训练任务来训练BERT模型进行介绍。

11.1 引言#

通常来说,我们既可以通过MLM和NSP这两个任务来从头训练一个BERT模型,当然也可以在开源预训练模型的基础上再次通过MLM和NSP任务来在特定语料中对模型进行追加训练,以使得模型参数更加符合这一场景。并且一般来说更加倾向于第二种做法。

在第1.1节BERT模型的基本原理中我们已经就MLM和NSP这两个任务的原理做了详细的介绍,所以这里就不再赘述。一句话概括,如图11-1所示MLM就是随机掩盖掉部分Token让模型来预测,而NSP则是同时输入模型两句话让模型判断后一句话是否真的为前一句话的下一句话,最终通过这两个任务来训练BERT中的权重参数。

图 11-1. MLM和NSP任务网络结构图
图 11-1. MLM和NSP任务网络结构图

11.2 数据预处理#

在正式介绍数据预处理之前,我们还是依照老规矩先通过一张图来大致了解一下整个处理流程,以便做到心中有数不会迷路。

图 11-2. MLM和NSP任务数据集构造流程图
图 11-2. MLM和NSP任务数据集构造流程图

如图11-2所示便是整个NSP和MLM任务数据集的构建流程。第①②步是根据原始语料来构造NSP任务所需要的输入和标签;第③步则是随机MASK掉部分Token来构造MLM任务的输入,并同时进行padding处理;第④步则是根据第③步处理后的结果来构造MLM任务的标签值,其中[P]表示Padding的含义,这样做的目的是为了忽略那些不需要进行预测的Token在计算损失时的损失值。在大致清楚了整个数据集的构建流程后,我们下面就可以一步一步地来完成数据集的构建了。

同时,为了能够使得整个数据预处理代码具有通用性,同时支持构造不同场景语料下的训练数据集,因此我们需要为每一类不同的数据源定义一个格式化函数来完成标准化的输入。这样即使是换了不同的语料只需要重写一个针对该数据集的格式化函数即可,其余部分的代码都不需要进行改动。

11.2.1 英文数据格式化#

这里首先以英文维基百科数据wiki2 [23]为例来介绍如何得到格式化后的标准数据。如下所示便是 wiki2 中的原始文本数据:

1 The development of [UNK] powder , based on [UNK] or [UNK] , by the French inventor Paul [UNK] in 1884 was a further step allowing smaller charges of propellant with longer barrels . The guns of the pre @-@ [UNK] battleships of the 1890s tended to be smaller in calibre compared to the ships of the 1880s , most often 12 in ( 305 mm ) , but progressively grew in length of barrel , making use of improved [UNK] to gain greater muzzle velocity .
2   
3    = = = [UNK] of armament = = =
4     
5  The nature of the projectiles also changed during the ironclad period . Initially , the best armor @-@ piercing [UNK] was a solid cast @-@ iron shot . Later , shot of [UNK] iron , a harder iron alloy , gave better armor @-@ piercing qualities . Eventually the armor @-@ piercing shell was developed .

在上述示例数据中,每一行都表示一个段落,其由一句话或多句话组成。如图2-6所示,我们需要在目录 utils 下新建 create_pretraining_data.py 模块,然后定义一个函数来对其进行预处理:

 1 def read_wiki2(filepath=None,seps='.'):
 2     with open(filepath, 'r') as f:
 3         lines = f.readlines() 
 4     paragraphs = []
 5     for line in tqdm(lines, ncols=80, desc=" ## 正在读取原始数据"):
 6         if len(line.split(' . ')) < 2:
 7             continue
 8         line = line.strip()
 9         paragraphs.append([line[0]])
10         for w in line[1:]:
11             if paragraphs[-1][-1][-1] in seps:
12                 paragraphs[-1].append(w)
13             else:
14                 paragraphs[-1][-1] += w
15     random.shuffle(paragraphs)  # 将所有段落打乱
16     return paragraphs

在上述代码中,第1行seps用于指定句子与句子之间的分隔符。第2~3行用于一次读取所有原始数据,每一行为一个段落。第5~14行用于遍历每一个段落,并进行相应的处理。第6~7行用于过滤掉段落中只有一个句子的情况,因为后续我们要构造NSP任务所需的数据集所以只有一句话的段落需要去掉。第8行用于去掉整个段落两端的空格或换行符。第9~14行开始遍历段落中的每一句话并进行分割,同时保留了分隔符在句子中。第15行则是将所有的段落给打乱,注意不是句子。

最终,经过 read_wiki2 函数处理后,我们便能得到一个标准的2维列表,格式形如:

1 [ [sentence a1, sentence a2, ...], [sentence b1, sentence b2,...],...,[] ]

例如上述语料处理后的结果为:

1 [['the development of [unk] powder , based on [unk] or [unk] , by the french inventor paul [unk] in 1884 was a further step allowing smaller charges of propellant with longer barrels', 'the guns of the pre @-@ [unk] battleships of the 1890s tended to be smaller in calibre compared to the ships of the 1880s , most often 12 in ( 305 mm ) , but progressively grew in length of barrel , making use of improved [unk] to gain greater muzzle velocity .'], ['the nature of the projectiles also changed during the ironclad period', 'initially , the best armor @-@ piercing [unk] shot .'],[],[]...[]]

这种格式就是后续代码处理所接受的标准格式,如果需要引入自己的数据那么务必需要处理成这样的格式。

11.2.2 中文数据格式化#

在介绍完英文数据集的格式化过程后我们再来看一个中文原始数据的格式化过程。如下所示便是我们后续所需要用到的中文宋词数据集:

1 红酥手黄縢酒满城春色宫墙柳东风恶欢情薄一怀愁绪几年离索错错错 春如旧人空瘦泪痕红鲛绡透桃花落闲池阁山盟虽在锦书难托莫莫莫
2 十年生死两茫茫不思量自难忘千里孤坟无处话凄凉纵使相逢应不识尘满面鬓如霜夜来幽梦忽还乡小轩窗正梳妆相顾无言惟有泪千行料得年年断肠处明月夜短松冈

在上述示例中,每一行表示一首词,句与句之间通过句号进行分割。下面我们同样需要定义一个函数来对其进行预处理并返回指定的标准格式:

 1 def read_songci(filepath=None, seps='。'):
 2     with open(filepath, 'r', encoding='utf-8') as f:
 3         lines = f.readlines() 
 4     paragraphs = []
 5     for line in tqdm(lines, ncols=80, desc=" ## 正在读取原始数据"):
 6         if "□" in line or "……" in line or len(line.split('。')) < 2:
 7             continue
 8         paragraphs.append([line[0]])
 9         line = line.strip()  # 去掉换行符和两边的空格
10         for w in line[1:]:
11             if paragraphs[-1][-1][-1] in seps:
12                 paragraphs[-1].append(w)
13             else:
14                 paragraphs[-1][-1] += w
15     random.shuffle(paragraphs)  # 将所有段落打乱
16     return paragraphs

在上述代码中,第1行seps用于指定句子与句子之间的分隔符。第2~3行用于一次读取所有原始数据,每一行为一首词(段落)。第5~14行用于遍历每一个段落,并进行相应的处理。第6~7行用于过滤掉字符乱码以及段落中只有一个句子的情况。第8~14行开始遍历段落中的每一句话并进行分割,同时保留了分隔符在句子中。第15行则是将所有的段落给打乱,注意不是句子。

例如上述语料处理后的结果为:

1 [['五花心里看抛球', '香腮红嫩柳烟稠'], ['若论风流,无过圆社,拐蹬蹑搭齐全', '门庭富贵,曾到御帘前', '灌口二郎为首,赵皇上、下脚流传', '人都道、齐云一社, 三锦独争先', '花前', '并月下,全身绣带,偷侧双肩', '更高而不远,一搭打秋千', '球落处、圆光拐,双佩剑、侧蹑相连', '高人处,翻身佶料,天下总呼圆'],[],[]....[]]

可以看到, 预处理完成后的结果同上面wiki2数据预处理完后的格式一样。

11.2.3 构造NSP任务数据#

在正式构造NSP任务数据之前,我们需要在 create_pretraining_data.py 先定义一个类并定义相关的类成员变量以方便在其它成员方法中使用,代码如下:

 1 class LoadBertPretrainingDataset(object):
 2     def __init__(self,
 3                  vocab_path='./vocab.txt',
 4                  tokenizer=None,
 5                  batch_size=32,
 6                  max_sen_len=None,
 7                  max_position_embeddings=512,
 8                  pad_index=0,
 9                  is_sample_shuffle=True,
10                  random_state=2021,
11                  data_name='wiki2',
12                  masked_rate=0.15,
13                  masked_token_rate=0.8,
14                  masked_token_unchanged_rate=0.5,
15			seps='。'):
16         self.tokenizer = tokenizer
17         self.vocab = build_vocab(vocab_path)
18         self.PAD_IDX = pad_index
19         self.SEP_IDX = self.vocab['[SEP]']
20         self.CLS_IDX = self.vocab['[CLS]']
21         self.MASK_IDS = self.vocab['[MASK]']
22         self.batch_size = batch_size
23         self.max_sen_len = max_sen_len
24         self.max_position_embeddings = max_position_embeddings
25         self.pad_index = pad_index
26         self.is_sample_shuffle = is_sample_shuffle
27         self.data_name = data_name
28         self.masked_rate = masked_rate
29         self.masked_token_rate = masked_token_rate
30         self.masked_token_unchanged_rate = masked_token_unchanged_rate
31         self.random_state = random_state
32         self.seps = seps
33        random.seed(random_state)

由于后续会有一系列的随机操作,所以上面代码第33行加入了随机状态用于固定随机结果。

紧接着,需要定义一个成员函数来封装格式化原始数据集的函数,实现代码如下:

 1     def get_format_data(self, filepath,):
 2         if self.data_name == 'wiki2':
 3             return read_wiki2(filepath, self.seps)
 4         elif self.data_name == 'songci':
 5             return read_songci(filepath, self.seps)
 6         elif self.data_name == 'custom':
 7             return read_custom(filepath)
 8         else: 
 9             raise ValueError(f"数据 {self.data_name} 不存在对应的格式化函数,"
10                              f"请参考read_wiki(filepath)实现对应格式化函数!")

从上述代码可以看出,该函数的作用就是给出了一个标准化的格式化函数调用方式,可以根据指定的数据集名称返回相应的格式化函数。但是需要注意的是,格式化函数返回的格式需要同 read_wiki2() 函数返回的样式保持一致。

进一步,我们便可以来定义构造NSP任务数据的处理函数,用来根据给定的连续两句话和对应的段落返回NSP任务中的句子对和标签,具体代码如下:

 1     @staticmethod
 2     def get_next_sentence_sample(sentence, next_sentence, paragraphs):
 3         if random.random() < 0.5:  # 产生[0,1)之间的一个随机数
 4             is_next = True
 5         else:
 6             new_next_sentence = next_sentence
 7             while next_sentence == new_next_sentence:
 8                 new_next_sentence = random.choice(random.choice(paragraphs))
 9             next_sentence = new_next_sentence
10             is_next = False
11         return sentence, next_sentence, is_next

在上述代码中,第3行用于根据均匀分布产生 之间的一个随机数作为概率值。第6~9行则是先从所有段落中随机出一个段落,再从随机出的一个段落中随机出一句话,以此来随机选择下一句话;其中第6~8行是防止随机选择的下一个句子仍旧与之前的相同(尽管概率非常小)。第11行则是返回构造好的一条NSP任务样本。最后,由于该方法只是功能性的函数没有引用到类中的其它成员,所以通过第1行代码将其申明为了静态方法。

到此,对于NSP任务样本的构造就介绍完了,后续我们只需要调用 get_next_sentence_sample() 函数即可。

11.2.4 构造MLM任务数据#

为了方便后续构造MLM任务中的数据样本,我们这里需要先定义一个辅助函数,其作用是根据给定的 token_ids、候选 mask 位置以及需要 mask 的数量来返回被 mask 后的 token_ids 和标签 label 信息,代码如下:

 1    def replace_masked_tokens(self, token_ids, candidate_pred_positions, num_mlm_preds):
 2         pred_positions = []
 3         mlm_input_tokens_id = [token_id for token_id in token_ids]
 4         for mlm_pred_position in candidate_pred_positions:
 5             if len(pred_positions) >= num_mlm_preds:
 6                 break  
 7             masked_token_id = None
 8             if random.random() < self.masked_token_rate:  
 9                 masked_token_id = self.MASK_IDS
10             else:
11                 if random.random() < self.masked_token_unchanged_rate:
12                     masked_token_id = token_ids[mlm_pred_position]
13                 else: 
14                     masked_token_id=random.randint(0,len(self.vocab.stoi)-1)
15             mlm_input_tokens_id[mlm_pred_position] = masked_token_id
16             pred_positions.append(mlm_pred_position)  
17         mlm_label = [self.PAD_IDX if idx not in pred_positions
18                      else token_ids[idx] for idx in range(len(token_ids))]
19         return mlm_input_tokens_id, mlm_label

在上述代码中,第1行里token_ids表示经过 get_next_sentence_sample() 函数处理后的上下句,且已经转换为ids后的结果,candidate_pred_positions 表示所有可能被maks掉的候选位置,num_mlm_preds 表示根据 的比例计算出来的需要被mask掉的位置数量。第4~6行为依次遍历每一个候选Token的索引,如果已满足需要被mask的数量则跳出循环。第8~9行则表示将其中 的Token替换为[MASK](注意,这里其实就是 里面的 )。第10~14行则是分别保持 的Token不变以及将另外 替换为随机Token。第15~16则是对Token进行替换,以及记录下哪些位置上的Token进行了替换。第17~18行则是根据已记录的Token替换信息得到对应的标签信息,其做法便是如果该位置没出现在 pred_positions 中则表示该位置不是需要被预测的对象,因此在进行损失计算时需要忽略掉这些位置(即为PAD_IDX)。而如果其出现在 mask 的位置,则其标签为原始 token_ids 对应的id,即正确标签。

例如以下输入:

1 token_ids = [101, 1031, 4895, 2243, 1033, 10029, 2000, 2624, 1031,....]
2 candidate_pred_positions = [2,8,5,9,7,3...]
3 num_mlm_preds = 5

经过函数 replace_masked_tokens() 处理后的结果则类似为:

1 mlm_input_tokens_id = [101,1031,103,2243,1033, 10029, 2000, 103,  1031, ...]
2 mlm_label = [ 0,   0,   4895,  0,    0,    0,    0,   2624,  0,...]

在这之后,我们便可以定义一个函数来构造MLM任务所需要用到的训练数据,代码如下:

 1     def get_masked_sample(self, token_ids):
 2         candidate_pred_positions = []  # 候选预测位置的索引
 3         for i, ids in enumerate(token_ids):
 4             # 在遮蔽语言模型任务中不会预测特殊词元,所以如果该位置是特殊词元
 5             # 那么该位置就不会成为候选mask位置
 6             if ids in [self.CLS_IDX, self.SEP_IDX]:
 7                 continue
 8             candidate_pred_positions.append(i)
 9             # 保存候选位置的索引, 例如可能是 [ 2,3,4,5, ....]
10         random.shuffle(candidate_pred_positions) # 将候选位置打乱,更利于随机
11         # 被掩盖位置的数量,BERT模型中默认将15%的Token进行mask
12         num_mlm_preds = max(1, round(len(token_ids) * self.masked_rate))
13         logging.debug(f" ## Mask数量为: {num_mlm_preds}")
14         mlm_input_tokens_id, mlm_label = self.replace_masked_tokens(
15             token_ids, candidate_pred_positions, num_mlm_preds)
16         return mlm_input_tokens_id, mlm_label

在上述代码中,第1行 token_ids 便是传入的模型输入序列的Token ID(一个样本)。第3~8行是用来记录所有可能进行掩盖的Token的索引,并同时排除掉特殊Token。第10行是将所有候选位置打乱,更利于后续随机抽取。第12行则是用来计算需要进行掩盖的Token的数量,例如原始论文中是 。第14~15行便是上面介绍到的 replace_masked_tokens() 功能函数。第16行则是返回最终MLM任务和NSP任务的输入 mlm_input_tokens_id 和MLM任务的标签 mlm_label

11.2.5 构造整体任务数据#

在分别介绍完MLM和NSP两个任务各自的样本构造方法后,下面我们再通过一个方法将两者组合起来便得到了最终整个样本数据的构建,实现代码如下所示:

 1     @cache
 2     def data_process(self, filepath, postfix='cache'):
 3         paragraphs = self.get_format_data(filepath)
 4         # 返回的是一个二维列表,每个列表可以看做是一个段落(其中每个元素为一句话)
 5         data = []
 6         max_len = 0
 7         # max_len用来记录整个数据集中最长序列的长度,后续可将其作为padding长度的标准
 8         desc = f" ## 正在构造NSP和MLM样本({filepath.split('.')[1]})"
 9         for paragraph in tqdm(paragraphs, ncols=80, desc=desc): 
10             for i in range(len(paragraph) - 1):  # 遍历一个段落中的每一句话
11                 sentence, next_sentence, is_next 
12                     = self.get_next_sentence_sample(paragraph[i], 
13                     paragraph[i + 1], paragraphs)  # 构造NSP样本
14                 logging.debug(f" ## 当前句文本:{sentence}")
15                 logging.debug(f" ## 下一句文本:{next_sentence}")
16                 logging.debug(f" ## 下一句标签:{is_next}")
17                 if len(next_sentence) < 2 : 
18                     logging.warning(f"句子{sentence}的下一句为空,请检查!")
19                     continue
20                 token_a_ids = [self.vocab[token] for token in 
21                                             self.tokenizer(sentence)]
22                 token_b_ids = [self.vocab[token] for token in 
23                                             self.tokenizer(next_sentence)]
24                 token_ids = [self.CLS_IDX] + token_a_ids + 
25                                            [self.SEP_IDX] + token_b_ids
26                 if len(token_ids) > self.max_position_embeddings - 1:
27                     token_ids = token_ids[:self.max_position_embeddings - 1]
28                 token_ids += [self.SEP_IDX]
29                 seg1 = [0] * (len(token_a_ids) + 2) 
30                 seg2 = [1] * (len(token_ids) - len(seg1))
31                 segs = torch.tensor(seg1 + seg2, dtype=torch.long)
32                 nsp_lable = torch.tensor(int(is_next), dtype=torch.long)
33                 mlm_input_tokens_id, mlm_label = 
34                                     self.get_masked_sample(token_ids)
35                 token_ids=torch.tensor(mlm_input_tokens_id,dtype=torch.long)
36                 mlm_label = torch.tensor(mlm_label, dtype=torch.long)
37                 max_len = max(max_len, token_ids.size(0))
38                 logging.debug(f"## Mask之后token ids:{token_ids.tolist()}")
39                 logging.debug(f"## Mask之后词元结果:{[self.vocab.itos[t] 
40                                             for t in token_ids.tolist()]}")
41                 logging.debug(f"## Mask之后label ids:{mlm_label.tolist()}")
42                 logging.debug(f"## 当前样本构造结束================== \n\n")
43                 data.append([token_ids, segs, nsp_lable, mlm_label])
44         all_data = {'data': data, 'max_len': max_len}
45         return all_data

在上述代码中,第1行中的@cache修饰器用于保存或直接载入已预处理完成后的结果,具体原理可以参见文章[27]。第6行中的max_len用来记录整个数据集中最长序列的长度,在后续可将其作为padding长度的标准。从第9~10行开始,便是依次遍历每个段落以及段落中的每个句子来构造MLM和NSP任务样本。第11~13行用于构建NSP任务数据样本;第17~19行用于过滤掉NSP任务中下一句过段或为空等数据预处理中没有考虑到的情况;第20~28行则是将得到的Token序列转换为token_ids,其中26-27行用于判断序列长度,对于超出部分进行截取。

紧接着,第29~32行则是分别构造Segment Embedding输入和NSP任务的真实标签;第33~36行是分别构造MLM任务的输入和标签。第43行则是将每个构造完成的样本保存到data这个列表中;第43~45行是返回最终生成的结果。

例如在处理宋词语料时,上述代码便会输出如下类似结果:

 1 - DEBUG:  ## 当前句文本:风住尘香花已尽,日晚倦梳头
 2 - DEBUG:  ## 下一句文本:锦书欲寄鸿难托
 3 - DEBUG:  ## 下一句标签:False
 4 - DEBUG:  ## Mask之前词元结果:['[CLS]', '风', '住', '尘', '香', '花', '已', '尽', ',', '日', '晚', '倦', '梳', '头','[SEP]', '锦', '书', '欲','寄', '鸿','难', '托','[SEP]']
 5 - DEBUG:  ## Mask之前token ids:[101, 7599, 857, 2212, 7676, 5709, 2347, 2226, 8024, 3189, 3241, 958, 3463, 1928, 102, 7239, 741, 3617, 2164, 7896, 7410, 2805, 102]
 6 - DEBUG:  ## segment ids:[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,  1, 1, 1, 1, 1, 1],序列长度为 23
 7 - DEBUG:  ## Mask数量为: 3
 8 - DEBUG:  ## Mask之后token ids:[101, 7599, 857, 2212, 103, 5709, 2347, 103,  8024, 3189, 3241, 103, 3463, 1928, 102, 7239, 741, 3617, 2164, 7896, 7410, 2805, 102]
 9 - DEBUG:  ## Mask之后词元结果:['[CLS]','风', '住', '尘','[MASK]','花','已','[MASK]',  ',', '日','晚','[MASK]', '梳','头','[SEP]', '锦','书', '欲','寄','鸿','难','托', '[SEP]']
10 - DEBUG:  ## Mask之后label ids:[0, 0, 0, 0, 7676, 0, 0, 2226, 0, 0, 0, 958, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
11 - DEBUG:  ## 当前样本构造结束==================

如果在构造数据集时不想输出上述结果,只需要将日志等级设置为 log_level=logging.INFO 即可。

11.2.6 构造训练数据集#

在整个数据预处理结束后,便可以构造得到最终模型训练时所需要的 DataLoader 了。如下代码便是训练集、验证集和测试集3部分 DataLoader 的构建过程:

 1     def load_train_val_test_data(self,
 2                 train_file_path=None,val_file_path=None,
 3                 test_file_path=None,only_test=False):
 4         postfix = f"_ml{self.max_sen_len}_rs{self.random_state}"
 5                   f"_mr{str(self.masked_rate)[2:]}" 
 6                   f"_mtr{str(self.masked_token_rate)[2:]}"
 7                   f"_mtur{str(self.masked_token_unchanged_rate)[2:]}"
 8         test_data = self.data_process(filepath=test_file_path,
 9                                       postfix='test' + postfix)['data']
10         test_iter = DataLoader(test_data, batch_size=self.batch_size,
11                            shuffle=False, collate_fn=self.generate_batch)
12         if only_test:
13             logging.info(f"# 返回测试集,包含样本{len(test_iter.dataset)}个")
14             return test_iter
15         data = self.data_process(filepath=train_file_path, postfix='train' + postfix)
16         train_data, max_len = data['data'], data['max_len']
17         if self.max_sen_len == 'same':
18             self.max_sen_len = max_len
19         train_iter = DataLoader(train_data, batch_size=self.batch_size,
20                                 shuffle=self.is_sample_shuffle,
21                                 collate_fn=self.generate_batch)
22         val_data = self.data_process(filepath=val_file_path, 
23                                            postfix='val' + postfix)['data']
24         val_iter = DataLoader(val_data, batch_size=self.batch_size,
25                               shuffle=False,collate_fn=self.generate_batch)
26         logging.info(f"# 返回训练集样本({len(train_iter.dataset)})个、"
27                      f"开发集样本({len(val_iter.dataset)})个"
28                      f"测试集样本({len(test_iter.dataset)})个.")
29         return train_iter, test_iter, val_iter

在上述代码中,第4~7行是根据传入的相关参数来构建一个数据预处理结果的缓存名称,因为不同的参数会处理得到不同的结果,最终缓存后的数据预处理结果名称将会类似如下所示:

1 songci_test_mlNone_rs2021_mr15_mtr8_mtur5.pt

这样在每次载入数据集时如果已经有相应的预处理缓存则直接载入即可。

第8~14行便是用来构造测试集所对应的DataLoader。第15~21行则用于构建训练集所对应的 DataLoader ,其中如果 self.max_sen_lensame ,那么在对样本进行padding时会以整个数据中最长样本的长度为标准进行padding,该参数默认情况下为None,即以每个batch中最长的样本为标准进行padding,更多相关内容可以参见第4.2.4节第4步中的介绍;第22~25行则是构造验证集所对应的DataLoader。

到此,对于整个BERT模型预训练的数据集就算是构建完成了。

11.2.7 使用示例#

在整个数据集的DataLoader构建完毕后,便可以通过如下方式来进行使用:

 1 class ModelConfig:
 2     def __init__(self):
 3         self.project_dir = os.path.dirname(os.path.dirname(
 4                                                 os.path.abspath(__file__)))
 5         # ========== wiki2 数据集相关配置
 6         # self.dataset_dir=os.path.join(self.project_dir,'data','WikiText')
 7         # self.pretrained_model_dir = os.path.join(self.project_dir, 
 8         #                                      "bert_base_uncased_english")
 9         # self.train_file_path = os.path.join(self.dataset_dir, 
10         #                                              'wiki.train.tokens')
11         # self.val_file_path = os.path.join(self.dataset_dir, 
12         #                                              'wiki.valid.tokens')
13         # self.test_file_path = os.path.join(self.dataset_dir,
14         #                                               'wiki.test.tokens')
15         # self.data_name = 'wiki2' 
16         # self.seps = '.'
         
17         # ========== songci 数据集相关配置
18         self.dataset_dir = os.path.join(self.project_dir, 'data', 'SongCi')
19         self.pretrained_model_dir = os.path.join(self.project_dir,
20                                                        "bert_base_chinese")
21         self.train_file_path = os.path.join(self.dataset_dir, 
22                                                         'songci.train.txt')
23         self.val_file_path = os.path.join(self.dataset_dir,
24                                                         'songci.valid.txt')
25         self.test_file_path = os.path.join(self.dataset_dir, 
26                                                          'songci.test.txt')
27         self.data_name = 'songci'        
28         self.seps = '。'
29         self.vocab_path=os.path.join(self.pretrained_model_dir, 'vocab.txt')
30         self.model_save_dir = os.path.join(self.project_dir, 'cache')
31         self.logs_save_dir = os.path.join(self.project_dir, 'logs')
32         self.is_sample_shuffle = True
33         self.batch_size = 16
34         self.max_sen_len = None
35         self.max_position_embeddings = 512
36         self.pad_index = 0
37         self.is_sample_shuffle = True
38         self.random_state = 2021
39         self.masked_rate = 0.15
40         self.masked_token_rate = 0.8
41         self.masked_token_unchanged_rate = 0.5
42         ......

在上述代码中,第5~16行为 wiki2 数据集的相关路径,而17-28行则是 songci 数据集的相关路径,可以根据需要直接进行切换。第29~42行则是其它数据预处理的相关数据。

最后,我们便可通过如下方式来实例化类 LoadBertPretrainingDataset 并输出相应的结果:

 1 if __name__ == '__main__':
 2     config = ModelConfig()
 3     data_loader = LoadBertPretrainingDataset(
 4         vocab_path=config.vocab_path,
 5         tokenizer=BertTokenizer.from_pretrained(
 6             config.pretrained_model_dir).tokenize,
 7         batch_size=config.batch_size,
 8         max_sen_len=config.max_sen_len,
 9         max_position_embeddings=config.max_position_embeddings,
10         pad_index=config.pad_index,
11         is_sample_shuffle=config.is_sample_shuffle,
12         random_state=config.random_state,
13         data_name=config.data_name,
14         masked_rate=config.masked_rate,
15         masked_token_rate=config.masked_token_rate,
16         masked_token_unchanged_rate=config.masked_token_unchanged_rate,
17         seps=config.seps)
18     test_iter = data_loader.load_train_val_test_data(
19         test_file_path=config.test_file_path,only_test=True)
20     for b_token_ids, b_segs, b_mask, b_mlm_label, b_nsp_label in test_iter:
21         print(b_token_ids.shape)  # [src_len,batch_size]
22         print(b_segs.shape)  # [src_len,batch_size]
23         print(b_mask.shape)  # [batch_size,src_len]
24         print(b_mlm_label.shape)  # [src_len,batch_size]
25         print(b_nsp_label.shape)  # [batch_size]
26         break

输出结果如下:

1 - INFO: 缓存文件 ~/BertWithPretrained/data/SongCi/songci_test_
2             mlNone_rs2021_mr15_mtr8_mtur5.pt 存在直接载入缓存文件
3 - INFO: ## 成功返回测试集,一共包含样本6249个
4 torch.Size([42, 16]) 
5 torch.Size([42, 16])
6 torch.Size([16, 42])
7 torch.Size([42, 16])
8 torch.Size([16])

到此,对BERT模型中NSP和MLM这两个预训练任务数据集的构造过程我们就介绍完了。接下来,让我来看如何实现这两个任务对应的前向传播过程。

11.3 预训练任务实现#

根据第1节内容可知,BERT预训练过程包括两个任务:NSP和MLM。为了使得大家能够对这两部分的代码实现有着更加清晰的认识与理解,我们将先分别来实现这两个任务,最后再将两者结合到一起来实现BERT的预训练任务。

11.3.1 NSP任务实现#

由于NSP任务实现起来较为简单,所以这里就直接贴出代码:

 1 class BertForNextSentencePrediction(nn.Module):
 2     def __init__(self, config, bert_pretrained_model_dir=None):
 3         super(BertForNextSentencePrediction, self).__init__()
 4         if bert_pretrained_model_dir is not None:
 5             self.bert = BertModel.from_pretrained(config, 
 6                                             bert_pretrained_model_dir)
 7         else:
 8             self.bert = BertModel(config)
 9         self.classifier = nn.Linear(config.hidden_size, 2)
10 
11     def forward(self,
12                 input_ids,  # [src_len, batch_size]
13                 attention_mask=None,  # [batch_size, src_len] 
14                 token_type_ids=None,  # [src_len, batch_size] 
15                 position_ids=None,
16                 next_sentence_labels=None):  # [batch_size,]
17         pooled_output, _ = self.bert(
18             input_ids=input_ids,attention_mask=attention_mask,
19             token_type_ids=token_type_ids,position_ids=position_ids)
20         # pooled_output: [batch_size, hidden_size]
21         seq_relationship_score = self.classifier(pooled_output)
22         # seq_relationship_score: [batch_size, 2]
23         if next_sentence_labels is not None:
24             loss_fct = nn.CrossEntropyLoss()
25             loss = loss_fct(seq_relationship_score.view(-1, 2), 
26                                         next_sentence_labels.view(-1))
27             return loss
28         else:
29             return seq_relationship_score

上述代码便是整个NSP任务的实现,可以看到其本质上就是一个文本分类任务,仅仅只用取BERT模型最后一层输出的[CLS]做一个分类任务即可,这里就不再赘述了。

11.3.2 MLM任务实现#

相比较于NSP,对于实现MLM任务来说则稍微复杂了一点点。它需要将BERT模型整个最后一层的输出进行一次变换和标准化,然后再做Token级的分类任务来预测被掩盖部分对应的Token值,这个网络结构如图11-3所示。

图 11-3. MLM任务网络结构图
图 11-3. MLM任务网络结构图

如图11-3所示便是构造MLM任务的流程示意。首先取BERT模型最后一层的输出,形状为[src_len,batch_size,hidden_size];然后再经过一次(非)线性变换和标准化,形状同样为[src_len,batch_size,hidden_size];最后再经过一个分类层对每个Token进行分类处理便得到了最后的预测结果,形状为[src_len,batch_size,vocab_size]。

此时我们便可以定义类 BertForLMTransformHead 来完成上述3个步骤:

 1 class BertForLMTransformHead(nn.Module):
 2     def __init__(self, config, bert_model_embedding_weights=None):
 3         super(BertForLMTransformHead, self).__init__()
 4         self.dense = nn.Linear(config.hidden_size, config.hidden_size)
 5         if isinstance(config.hidden_act, str):
 6             self.transform_act_fn = get_activation(config.hidden_act)
 7         else:
 8             self.transform_act_fn = config.hidden_act
 9         self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=1e-12)
10         self.decoder = nn.Linear(config.hidden_size, config.vocab_size)
11         if bert_model_embedding_weights is not None:
12             self.decoder.weight = nn.Parameter(bert_model_embedding_weights)
13         # [hidden_size, vocab_size]
14         self.decoder.bias = nn.Parameter(torch.zeros(config.vocab_size))
15     def forward(self, hidden_states):
16         hidden_states = self.dense(hidden_states)  
17         hidden_states = self.transform_act_fn(hidden_states) 
18         hidden_states = self.LayerNorm(hidden_states)  
19         hidden_states = self.decoder(hidden_states)
20         return hidden_states # [src_len, batch_size, vocab_size]

在上述代码中,第4~8行用来定义相应的(非)线性变换。第9~10行则是用来定义对应的标准化和最后的分类层。第11~12则是用来判断最后分类层中的权重参数是否复用BERT模型Token Embedding中的权重参数,因为MLM任务最后的预测类别就等于Token Embedding中的各个词,所以最后分类层中的权重参数可以复用Token Embedding中的权重参数[26]。第15行开始则是对应的前向传播过程。第16~18行处理后的结果形状均为[src_len, batch_size, hidden_size]。

同时,还有一个细节的地方在于,在Token Embedding中词表的形状为[vocab_size,hidden_size],而最后一个分类层权重参数的形状为[hidden_size,vocab_size],因此按道理说这里的权重 bert_model_embedding_weights 应该要转置后才能复用。不过在第12行代码中我们却直接将其赋值给了最后一层的权重参数,这是为什么呢?原来在PyTorch的 nn.Linear() 中,权重参数默认的初始化方式就是

1 self.weight = Parameter(torch.Tensor(out_features, in_features))

即,将两个维度进行了交换,所以刚好在复用时就不用做其它任何操作了。

紧接着,我们便可以通过如下代码来实现MLM任务:

 1 class BertForMaskedLM(nn.Module):
 2     def __init__(self, config, bert_pretrained_model_dir=None):
 3         super(BertForMaskedLM, self).__init__()
 4         if bert_pretrained_model_dir is not None:
 5             self.bert = BertModel.from_pretrained(config, 
 6                                         bert_pretrained_model_dir)
 7         else:
 8             self.bert = BertModel(config)
 9         weights = None
10         if config.use_embedding_weight:
11             weights = self.bert.bert_embeddings.
12                                    word_embeddings.embedding.weight
13         
14         self.classifier = BertForLMTransformHead(config, weights)
15         self.config = config
16     def forward(self,input_ids, attention_mask=None,token_type_ids=None, 
17                 position_ids=None, masked_lm_labels=None):
18         _, all_encoder_outputs = self.bert(
19             input_ids=input_ids,attention_mask=attention_mask,
20             token_type_ids=token_type_ids,position_ids=position_ids)
21         sequence_output = all_encoder_outputs[-1]  # 取Bert最后一层的输出
22         prediction_scores = self.classifier(sequence_output)
23         if masked_lm_labels is not None:
24             loss_fct = nn.CrossEntropyLoss(ignore_index=0)
25             masked_lm_loss = loss_fct(prediction_scores.reshape(-1,
26                                              self.config.vocab_size),
27                                       masked_lm_labels.reshape(-1))
28             return masked_lm_loss
29         else:
30             return prediction_scores  # [src_len, batch_size, vocab_size]

在上述代码中,第4~8行用于返回得到原始的BERT模型。第9~12行则是取Token Embedding中的权重参数。第14行则是返回得到MLM任务实例化后的类对象。第18~21行是返回得到BERT模型的所有层输出,并只取最后一层,此时的形状为[src_len, batch_size, hidden_size]。第22行则是完成最后MLM中分类任务的输出,形状为[src_len, batch_size, vocab_size]。第23~30行则是根据标签是否为空来返回不同的输出结果。

到此,对于MLM任务的实现就介绍完了。

11.3.3 前向传播#

经过上面两节内容的介绍,此时再来整体实现NSP和MLM任务那就变得十分容易了。整体实现代码如下所示:

 1 class BertForPretrainingModel(nn.Module):
 2     def __init__(self, config, bert_pretrained_model_dir=None):
 3         super(BertForPretrainingModel, self).__init__()
 4         if bert_pretrained_model_dir is not None:
 5             self.bert = BertModel.from_pretrained(config, 
 6                                            bert_pretrained_model_dir)
 7         else:  # 如果没有指定预训练模型路径,则随机初始化整个网络权重
 8             self.bert = BertModel(config)
 9         weights = None
10         if 'use_embedding_weight' in config.__dict__ and
11                                           config.use_embedding_weight:
12             weights = self.bert.bert_embeddings.
13                                       word_embeddings.embedding.weight
14  
15         self.mlm_prediction = BertForLMTransformHead(config, weights)
16         self.nsp_prediction = nn.Linear(config.hidden_size, 2)
17         self.config = config
18 
19     def forward(self, input_ids,  # [src_len, batch_size]
20                 attention_mask=None,  # [batch_size, src_len]
21                 token_type_ids=None,  # [src_len, batch_size]
22                 position_ids=None,
23                 masked_lm_labels=None,  # [src_len,batch_size]
24                 next_sentence_labels=None):  # [batch_size]
25         pooled_output, all_encoder_outputs = self.bert(
26             input_ids=input_ids,attention_mask=attention_mask,
27             token_type_ids=token_type_ids,position_ids=position_ids)
28         sequence_output = all_encoder_outputs[-1]  # 取Bert最后一层的输出
29         mlm_prediction_logits = self.mlm_prediction(sequence_output)
30         nsp_pred_logits = self.nsp_prediction(pooled_output)
31         if masked_lm_labels is not None and next_sentence_labels is not None
32             loss_fct_mlm = nn.CrossEntropyLoss(ignore_index=0)
33             loss_fct_nsp = nn.CrossEntropyLoss()
34             mlm_loss = loss_fct_mlm(mlm_prediction_logits.reshape(
35                 -1,self.config.vocab_size),masked_lm_labels.reshape(-1))
36             nsp_loss = loss_fct_nsp(nsp_pred_logits.reshape(-1, 2),
37                                 next_sentence_labels.reshape(-1))
38             total_loss = mlm_loss + nsp_loss
39             return total_loss, mlm_prediction_logits, nsp_pred_logits
40         else: # [src_len, batch_size, vocab_size], [batch_size, 2]
41             return mlm_prediction_logits, nsp_pred_logits

在上述代码中,第15~16行分别是返回得到实例化后的MLM和NSP任务模型。第25~27行则是返回BERT模型的所有输出。第27~30行则是分别取BERT模型输出的不同部分来分别进行后续的MLM和NSP任务,此时 sequence_output 的形状为[src_len, batch_size, hidden_size],mlm_prediction_logits 的形状为[src_len, batch_size, vocab_size],nsp_pred_logits的形状为[batch_size, 2]。第31~41行则是根据是否有标签输入来返回不同的输出结果,同时需要注意的是第37行返回的是NSP+MLM两个任务的损失和作为整体模型的损失值;第39~41行是根据条件返回模型不同的结果。

到此对于NSP和MLM任务模型的实现部分就介绍完了。不过在这里同样要提醒大家的是,在逐行阅读代码的时候最好是将各个变量的维度一起带进去,弄清楚每一步计算后各个变量维度的变化,这样才能更好的理解整个模型。

11.4 模型训练与微调#

在实现完整个NSP和MLM部分的代码后便可以开始进行模型的训练。同时,经过训练完成之后的模型参数又可以继续在下游任务中进行微调。

11.4.1 模型训练#

对于整个模型训练部分的代码其实和在前面几个微调任务中介绍的差不多,只是为了能更加清楚地知道训练模的训练过程型在这里加入了一些通过Tensorboard可视化的代码。由于这部分代码较长我们就分两部分来进行介绍。第一部分代码如下:

 1 def train(config):
 2     model = BertForPretrainingModel(config,config.pretrained_model_dir)
 3     last_epoch = -1
 4     if os.path.exists(config.model_save_path):
 5         checkpoint = torch.load(config.model_save_path)
 6         last_epoch = checkpoint['last_epoch']
 7         loaded_paras = checkpoint['model_state_dict']
 8         model.load_state_dict(loaded_paras)
 9         logging.info("## 成功载入已有模型,进行追加训练......")
10     model.train()
11     tokenize = BertTokenizer.from_pretrained(config.pretrained_model_dir)
12     data_loader=LoadBertPretrainingDataset(vocab_path=config.vocab_path,...)
13     train_iter, test_iter, val_iter = \
14         data_loader.load_train_val_test_data(config.test_file_path, ...)
15     no_decay = ["bias", "LayerNorm.weight"]
16     optimizer_grouped_parameters = [
17         {
18             "params": [p for n, p in model.named_parameters() 
19                             if not any(nd in n for nd in no_decay)],
20             "weight_decay": config.weight_decay,
21             "initial_lr": config.learning_rate
22         },
23         {
24             "params": [p for n, p in model.named_parameters() 
25                             if any(nd in n for nd in no_decay)],
26             "weight_decay": 0.0,
27             "initial_lr": config.learning_rate
28         },
29     ]      
30     optimizer = AdamW([{"params": model.parameters(),
31                         "initial_lr": config.learning_rate}])
32     scheduler = get_polynomial_decay_schedule_with_warmup(optimizer,
33       config.num_warmup_steps,config.num_train_steps,last_epoch=last_epoch)

在上述代码中,第2行是实例化模型对象;第4~9行是查看本地是否存在相关模型(指对训练过程中保存的模型进行追加训练),这里值得一说的是之所以也要保存 last_epoch 这个参数是为了同时能够恢复学习率、以及 Tensorboard 中相关可视化变量在上一次模型保存是时的状态;第11~14行是载入训练模型时所需要用到的数据集;第15~29行是筛选模型中哪些参数需要进行权重衰减(也就是${{L}_{2}}$正则化),哪些参数不需要进行权重衰减,同时根据筛选条件可知所有 biasLayerNorm 相关的参数都不需要进行正则化处理;第30~31行是定义优化器,并且是通过 initial_lr 来指定的学习率的,因为后续需要用到动态学习率调整的策略;第32~33行是指定动态学习率的调整策略。

进一步,实现模型的迭代正反向传播过程,代码如下:

 1     for epoch in range(config.epochs):
 2         losses = 0
 3         for idx, (b_token_ids, b_segs, b_mask, b_mlm_label, b_nsp_label) 
 4                                                 in enumerate(train_iter):
 5           #......
 6             loss, mlm_logits, nsp_logits = model(input_ids=b_token_ids,
 7                     attention_mask=b_mask,token_type_ids=b_segs,
 8                     masked_lm_labels=b_mlm_label,
 9                     next_sentence_labels=b_nsp_label)
10             optimizer.zero_grad()
11             loss.backward()
12             optimizer.step()
13             scheduler.step()
14             losses += loss.item()
15             mlm_acc, _, _, nsp_acc, _, _ = accuracy(mlm_logits, nsp_logits, 
16                               b_mlm_label,b_nsp_label, data_loader.PAD_IDX)
17             if idx % 20 == 0:
18                 config.writer.add_scalar('Training/Loss', loss.item(), 
19                                         scheduler.last_epoch)
20                 config.writer.add_scalar('Training/Learning Rate', 
21                                          scheduler.get_last_lr()[0], 
22                                          scheduler.last_epoch)
23                 config.writer.add_scalars(main_tag='Training/Accuracy',
24                                           tag_scalar_dict={'NSP': nsp_acc,
25                                                            'MLM': mlm_acc},
26                                           global_step=scheduler.last_epoch)
27         train_loss = losses / len(train_iter)
28         if (epoch + 1) % config.model_val_per_epoch == 0:
29             mlm_acc, nsp_acc = evaluate(config, val_iter, model, data_loader.PAD_IDX)
30             config.writer.add_scalars(main_tag='Testing/Accuracy',
31                                       tag_scalar_dict={'NSP': nsp_acc,'MLM': mlm_acc},
32                                       global_step=scheduler.last_epoch)
33             if mlm_acc > max_acc:
34                 max_acc = mlm_acc
35                 state_dict = model.state_dict()
36             torch.save({'last_epoch': scheduler.last_epoch,
37                         'model_state_dict': state_dict},
38                          config.model_save_path)

在上述代码中,第6~9行是模型的前向传播过程;第10~13行是反向传播及相关参数的更新过程;第14~16行分别是损失的累计和两个预训练任务准确率的计算;第18~26行是对训练过程中模型的损失、学习率和准确率进行可视化;第29~32行是计算模型在测试集上的准确率,并同时进行可视化处理;第33~38行则是保存在测试集上MLM任务取得最大准确率时所对应的模型。

到此,对于模型训练部分的内容就介绍完了。同时,模型在训练过程中将会有类似如下所示的输出:

1 - INFO: Epoch: [1/120], Batch[ 0/7836], Train loss : 1.897, Train mlm acc: 0.0, nsp acc: 0.537
2 - INFO:  Epoch: [1/120], Batch[20/7836], Train loss : 1.786, Train mlm acc: 0.0, nsp acc: 0.562
3 - INFO: Epoch: [1/120], Batch[40/7836], Train loss : 1.812, Train mlm acc: 0.0, nsp acc: 0.489

最终,模型的在宋词数据集上损失值和准确率的变化如图11-4所示。

图 11-4. 模型损失和准确率变化图
图 11-4. 模型损失和准确率变化图

如图11-4所示,左边为NSP和MLM两个预训练任务整体损失的变化情况;左边绿色曲线和灰色曲线分别为NSP和MLM这两个任务在训练集上的准确率的变化情况。不过虽然模型看似在训练集上有着不错的训练效果,但是在测试集上的结果却显得不那么尽如人意,如图11-5所示。

图 11-5. 模型测试集准确率变化图
图 11-5. 模型测试集准确率变化图

如图11-5所示便是模型在测试集上的表现情况,其中绿色曲线和灰色曲线分别表示NSP和MLM这两个任务在测试集上的准确率变化情况。从图中可以发现,模型大约在6万次迭代后准确率就没有发生明显地变换了。在调整过多次参数组合后依旧没有得到一个好的效果,我们猜测这可能是由于使用的宋词数据集比较特别,不像普通的白话文那样测试集和训练集的句式比较类似;以及原始数据集每句话的分割方式导致句子过短等。同时NSP和MLM任务的最后两层参数也随机初始化的并没有使用开源的模型参数。当然,也不排除代码中存在未知Bug的情况。不过后续我们也会继续尝试调整模型,并将相应的结果推送到代码仓库中。

11.4.2 模型推理#

在模型训练部分的内容介绍完毕后,下面我们再来看模型推理部分的实现。对于推理部分的实现总体思路为:①将测试样本构造为模型所接受的输入格式;②通过模型前向传播得到预测结果输出;③对模型输出结果进行格式化处理得到最终的预测结果。

首先,对于模型预测部分的实现代码如下:

 1 def inference(config, sentences=None, masked=False, language='en'):
 2     tokenize = BertTokenizer.from_pretrained(config.pretrained_model_dir)
 3     data_loader = LoadBertPretrainingDataset(vocab_path=config.vocab_path,
 4                 tokenizer=tokenize.tokenize,pad_index=config.pad_index,
 5                 random_state=config.random_state,masked_rate=0.15)# 15% Mask
 6     token_ids, pred_idx, mask = data_loader.make_inference_samples(
 7                                sentences,masked=masked,language=language)
 8     model = BertForPretrainingModel(config,config.pretrained_model_dir)
 9     if os.path.exists(config.model_save_path):
10         checkpoint = torch.load(config.model_save_path)
11         loaded_paras = checkpoint['model_state_dict']
12         model.load_state_dict(loaded_paras)
13         logging.info("## 成功载入已有模型进行推理......")
14     else:
15         raise ValueError(f"模型 {config.model_save_path} 不存在!")
16     model.eval()
17     with torch.no_grad():
18         mlm_logits, _ = model(input_ids=token_ids,attention_mask=mask)
19     pretty_print(token_ids, mlm_logits, pred_idx,
20                  data_loader.vocab.itos, sentences, language)

在上述代码中,第3~5行为初始化类 LoadBertPretrainingDataset ,同时需要说明的是由于是预测场景,所以构造样本时 masked_rate 可以是任意值,不用局限15%。第6~7行则是将传入的测试样本转换为模型所接受的形式,其中masked参数是用来指定输入的测试样本有没进行 mask 操作,如果没有则自动按 masked_rate 的比例进行mask操作;language参数是指定测试样本的语种类型。第9~13行则是载入本地保存好的模型来初始化完了。第17~18行是得到模型前向传播的输出结果。第19~20行是根据模型的前向传播输出结果来格式化得到最终的输出形式。

最终,可以通过如下方式来完成模型的推理过程,代码如下:

1 if __name__ == '__main__':
2     config = ModelConfig()
3     train(config)    
4     sentences = ["十年生死两茫茫。不思量。自难忘。千里孤坟,无处话凄凉。",
5                    "红酥手。黄藤酒。满园春色宫墙柳。"]
6     inference(config, sentences, masked=False, language='zh')

上述代码运行结束后将会看到类似如下所示的结果:

1 - INFO: ## 成功载入已有模型进行推理……
2 - INFO:  ### 原始:我住长江头,君住长江尾。
3 - INFO:   ## 掩盖:我住长江头,[MASK]住长[MASK]尾。
4 - INFO:   ## 预测:我住长江头,君住长河尾。  
5 - INFO: ====================
6 - INFO:  ### 原始:日日思君不见君,共饮长江水。
7 - INFO:   ## 掩盖:日日思君不[MASK]君,共[MASK]长江水。
8 - INFO:   ## 预测:日日思君不见君,共饮长江水。
9 #   ......

需要注意的是,由于目前模型在测试集上的结果并不理想,所以上述展示的是模型在训练集上的推理结果。

11.4.3 模型微调#

在介绍完整个预训练模型实现过程后,最后一步就是如何将训练得到的模型继续运用在下游任务中。当然,实现这一目的也非常简单,只需要将保存好的模型重新命名为pytorch_model.bin,然后替换掉之前的文件即可。这样就可以想前面介绍的几个下游任务一样对模型进行微调了。

总结#

在本篇文章中,我们首先详细地介绍了BERT模型的基本原理以及其所提出的动机,并且明白了BERT模型本质上就是Transformer模型的Encoder部分;接着,一步一步详细地介绍了如何从零来实现BERT模型,包括导入Input Embedding的实现、BertModel的实现以及如何完成预训练模型的加载和模型的迁移等内容;然后介绍了基于BERT预训练模型的四大微调任务场景,包括单文本分类如任务、文本对分类任务、问题选择任务和问题回答任务,同时也详细讲解了在对模型进行训练时的相关技巧,如动态学习了调整和通过Tensorboard进行模型变量可视化等;最后,我们细致的介绍了BERT模型中NSP和MLM这两个任务的实现过程,以及构建一个通用的数据预处理模块,大家只需要根据自己的需要定义一个数据格式化函数便可以快捷地在自己的数据集上完成整个模型的训练工作。当然,我们在后期也会根据实际情况继续补充一些基于BERT预训练模型的其它微调场景,如关系抽取、命名体识别等任务。

引用#

[1] BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.

[2] Vaswani A, Shazeer N, Parmar N, et al. Attention is all you need

[3] http://jalammar.github.io/illustrated-transformer/

[4] This post is all you need

[5] The Illustrated BERT, ELMo, and co. (How NLP Cracked Transfer Learning) https://jalammar.github.io/illustrated-bert/

[6] This Post Is All You Need(上卷)——层层剥开Transformer

[7] https://github.com/google-research/bert/blob/master/modeling.py#L558

[8] BERT https://huggingface.co/transformers/model_doc/bert.html#bertmodel

[9] https://github.com/codertimo/BERT-pytorch

[10] bert_base_chinese下载地址:https://huggingface.co/bert-base-chinese/tree/main

[11] SAVING AND LOADING MODELS https://pytorch.org/tutorials/beginner/saving_loading_models.html

[12] https://github.com/mlwithme/DeepLearningWithMe

[13] https://github.com/aceimnorstuvwxz/toutiao-text-classfication-dataset

[14] 训练模型时如何便捷保存训练日志

[15] https://cims.nyu.edu/~sbowman/multinli/

[16] https://rowanzellers.com/swag/

[17] https://github.com/rowanz/swagaf/tree/master/data

[18] https://huggingface.co/docs/transformers/main_classes/optimizer_schedules

[19] https://rajpurkar.github.io/SQuAD-explorer/

[20] Python中的默认字典与命名体元组你会用吗?

[21] https://pytorch.org/docs/master/tensorboard.html#torch-utils-tensorboard

[23] https://s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-v1.zip

[24] https://docs.python.org/3.6/library/random.html?highlight=random

[25] 动手学深度学习,李沐

[26] https://github.com/google-research/bert/

[27] 如何用@修饰器来缓存数据与处理结果?

[28] https://github.com/zjy-ucas/ChineseNER

[29] 多分类任务下的召回率与F值

[30] ttps://github.com/huggingface/transformers/tree/main/examples/pytorch/token-classification

您当前阅读的系列内容有完整版高清 PDF ,点击右侧了解

近200页高清 PDF + 80页配套PPT、高清无水印网络结构图,打印学习更方便!

查看详情
阅读 --

第7节 自定义学习率动态调整

本文是 BERT 精读系列第7讲,作为 BERT 第8讲 SQuAD 问答任务的过渡篇,系统讲清 Transformer 论文中提出的 Noam 动态学习率调整策略:从 warmup 公式 d_model^{-0.5} · …

第8节 基于BERT预训练模型的SQuAD问答任务

本文是 BERT 精读系列第8讲,详细讲解如何基于 huggingface bert-base-uncased 在 SQuAD 斯坦福问答数据集上微调阅读理解模型。覆盖问题回答与文本蕴含的核心差异:把 (问题, 描述) 拼接后让 BERT …