10.15 基于GPT-2的中文预训练模型#

在前面几节内容中我们陆续介绍了GPT-1到GPT-3的原理和动机,从网络结构上来看三者并没有本质上的差异,都是以Transformer中解码器为基础构建而来。在本节内容中,我们将以一个开源的中文GPT-2中文预训练模型为例,来详细介绍GPT-2模型的训练和推理过程,并训练完成自己的GPT-2模型。

10.15.1 项目介绍#

1. 项目介绍

在本节内容中我们使用到的是一个用GPT-2为网络结构的开源中文预训练语言模型GPT2-Chinese [1]。基于不同的训练语料,该项目中也包含了不同的预训练模型。例如使用130MB的名家散文、情感散文和散文诗歌所训练得到的散文模型;使用180MB约80万首古诗词训练得到的诗词模型;使用40MB约70万条对联训练得到的对联模型;通过CLUECorpusSmall语料训练得到的通用中文模型;以及本节内容将会示例的使用1.8GB约300万篇文言文训练得到的文言文模型。

如下所示便是根据项目中文言文模型以“先帝创业未半而中道崩殂”为开始所生成的示例内容:

[CLS] 先帝创业未半而中道崩殂, 故其子孙不能保守社稷,遂为奸臣所误。此事之大,人皆知之。然不知当日之事,果系何人?若果系奸臣所误,亦当据实具奏,以为国家除害。不然,此后奸人必复有所借口,亦不可不防之于早也。至于奸人之所借口,亦不可不防。今既不可得见,而奸臣所指之事,又不可不察。盖奸臣之为人,有如是之大者,而其所托名,则又有如是之重者,故必须详审,乃可得其要领,使其人不敢为奸臣所误,而其子孙不至费绝也。

从上述内容的意思可以看出,GPT-2以为“先帝中道崩殂”是糟奸人所害,所以后续通篇内容都在讨论这件事情。但是总体来说生成的内容似模似样,足以以假乱真。

对于上面所提及的各个预训练模型我们都可以在该项目的主页获得相应下载地址,然后直接进行使用。不过遗憾的是大部分预训练模型所使用到的语料并没有公开,因此在后续内容中我们将使用其它公开语料来训练我们自己的GPT-2预训练模型。

2. 环境安装

在GitHub主页上该项目一共有两个分支,我们需要选择master主分支进行使用。同时,建议各位读者使用本书所维护的克隆版本[2],差别在于我们对其中的各行代码进行了详细注释和说明,并且提供了更加完整的环境依赖列表。

在完成项目的克隆以后,我们可以看到有若干个文件夹,但是我们这里基本上都不会用到,因为都是一些数据或配置存放目录可以自定义指定。这里主要会使用到两个模块:定义数据预处理、网络结构和训练过程的train.py模块;定义模型推理和筛选过程的generate.py模块。首先,我们根据2.2.3节内容所介绍的步骤,根据项目中所提供的requirements.txt(包含有48个依赖包)文件完成Python环境的安装。

10.15.2 生成结果筛选#

在正式使用预训练模型进行内容生成之前,我们先来介绍如何根据模型预测的logits值来筛选得到对应的预测结果,这与我们9.9节介绍的内容有些许差异,可以看做是升级版考虑得更加细致。当然,这也是大语言模型中一种比较通用的做法。各位读者也可以暂时跳过这部分内容的相关原理介绍,直接阅读10.15.3节内容使用对应的预训练模型。

如图10-44所示便是生成结果筛选的原理示意图。筛选结果筛选结果的整体思路为:①根据预测得到的logits取前Top_k为候选结果,并将剩余部分置为负无穷大。②首先将候选logits进行排序并得排序后的结果sorted_logits和其对应的索引序号sorted_indeces;然后对排序后的结果进行归一化并计算累积概率,再将大于Top_p的位置标记为T(表示后续需要忽略),同时为了避免Top_p设置过小导致所有结果都被忽略所以需要将sorted_indx_to_remove中的结果整体向后移动一位并将第1个位置直接置为F(表示一定会有一个筛选结果剩下);最后,根据sorted_indx_to_remove和sorted_indeces可知需要将logits中过滤掉的索引序号为3、0、4和5,这样便得到了经过两次筛序后的logits。③将得到的logits根据采样策略得到预测值。

图 10-44 结果筛选原理图

现在,假设模型已经预测得到了当前时刻的logits输出结果,且形状为[vocab_size,],则图10-44过程可通过如下代码进行实现:

 1 def top_k_top_p_filtering(logits, top_k=0, top_p=0.0, filter_value=-float("Inf")):
 2     top_k = min(top_k, logits.size(-1)) 
 3     if top_k > 0:
 4         indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
 5         logits[indices_to_remove] = filter_value
 6     if top_p > 0.0: 
 7         sorted_logits, sorted_indices = torch.sort(logits, descending=True)
 8         cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
 9         sorted_indices_to_remove = cumulative_probs > top_p
10         sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
11         sorted_indices_to_remove[..., 0] = 0 
12         indices_to_remove = sorted_indices[sorted_indices_to_remove] 
13         logits[indices_to_remove] = filter_value
14     return logits

在上述代码中,第2行是检查top_k取值是否超过了序列长度,是则直接取序列长度。第3~5行便是图10-44中第①步处理后的结果。第6~13行则是图10-44中第②步中的处理过程,其中第7行表示对原始logits进行排序并得到排序后的结果以及在原始logits中的索引。第8~9行是对排序后的sorted_logits进行归一化处理并计算累积概率,同时得到大于top_p的位置标记。第10~11行是为了避免当top_p设置过小导致所有结果都被忽略所考虑的情况。第12~13行是根据indices_to_removelogits中满足条件的值忽略,设置为负无穷大。从这里可以看出,通过调整阈值top_p可以在不同的生成效果之间找到平衡,较小的阈值将导致模型生成更加集中和确定性的文本,而较大的阈值将产生更加多样和随机的文本。

进一步,对于一条完整的文本生成过程可以通过如下代码完成:

 1 def sample_sequence(model, context, length, n_ctx, tokenizer, temperature=1.0,
 2                     top_k=30, top_p=0.0, repitition_penalty=1.0, device="cpu"):
 3     context = torch.tensor(context, dtype=torch.long, device=device)
 4     generated = context.unsqueeze(0)  
 5     with torch.no_grad():
 6         for _ in trange(length, ncols=80):
 7             inputs = {"input_ids": generated[0][-n_ctx:].unsqueeze(0)} 
 8             outputs = model(**inputs)
 9             next_token_logits = outputs[0][0, -1, :] 
10             for id in set(generated):
11                 next_token_logits[id] /= repitition_penalty 
12             next_token_logits = next_token_logits / temperature  
13             next_token_logits[tokenizer.convert_tokens_to_ids("[UNK]")] = -float("Inf") 
14             filtered_logits = top_k_top_p_filtering(next_token_logits, top_k, top_p)
15             next_token = torch.multinomial(F.softmax(filtered_logits, dim=-1), 1)
16             generated = torch.cat((generated, next_token.unsqueeze(0)), dim=1)
17     return generated.tolist()[0] 

在上述代码中,第1行model表示GPT-2模型的实例化对象;context表示输入模型的提示部分,此时已经转换成了索引序号;n_ctx表示上下文长度,即生成当前时刻结果时允许模型考虑的历史序列的长度;temperature表示生成文本的温度,源于玻尔兹曼分布中,用于控制生成结果的随机性。第2行top_ktop_p则是上面介绍的top_k_top_p_filtering()函数中的两个参数;repitition_penalty表示对重复结果的惩罚系数。第3~4行是将提示文本转换为张量并扩充维度,此时generated的维度为[1, seq_len]

第6行开始则是循环生成序列中每一个字。第7行是从已生成的序列generated中取后n_ctx个Token作为解码当前时刻的输入。第8~9行是利用模型进行解码,此处outputs的输出结果为一个元组,第0个元素为GPT-2最后一层经过分类层后的结果,即outputs[0]的形状为[batch_size, seq_len, vocab_size],则next_token_logits的形状为[vocab_size,]。第10~11行的作用是尽可能使得已经出现在generated中的结果在当前时刻解码时不再出现,即避免产生重复内容。例如generated = tensor([27,68,77,89]),则 next_token_logits[id] /= repitition_penalty将使得next_token_logits中27、68、77和89这4个位置上的值变小,进而使得后续预测结果再次为这4个值的情况减小。第12行是对next_token_logits值进行缩放,temperature越大则next_token_logits越平滑,生成的结果也更加具有丰富性,而越小则生成内容更具有确定性。这里可以看出,temperaturetop_p这两个参数是各自从不同的角度来控制生成内容的多样性。

第13行是直接将[UNK]对应索引位置的logits值置为无穷大,避免生成结果中出现[UNK]的情况。第14行则是使用top_k_top_p_filtering()函数来对预测结果进行过滤,返回结果形状为[vocab_size,]。第15行是根据筛选后的logits值来采样得到当前时刻的预测结果,形状为[1,]。第16行是将当前时刻的预测值与历史生成结果拼接到一起,此时generated的形状为[1,len]。第17行则是返回整个样本预测完成的结果,为一个一维列表。

10.15.3 模型推理#

在进行模型推理之前我们需要根据项目主页提供的地址下载相应的预训练模型,下面以其中的文言文模型为例进行介绍。在完成该模型下载以后我们将会看到3个文件,分别是config.jsonvocab.txtpytorch_model.bin,其中前两个分别是模型对应的超参数和词表,最后一个为预训练模型的权重参数。进一步,我们在工程的根目录下新建一个名为model的文件夹,然后将这个3个文件放入到该文件夹中。

51