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节 深度学习环境安装:CUDA、PyTorch 与依赖配置教程」内容所介绍的步骤,根据项目中所提供的requirements.txt(包含有48个依赖包)文件完成Python环境的安装。
10.15.2 生成结果筛选#
在正式使用预训练模型进行内容生成之前,我们先来介绍如何根据模型预测的logits值来筛选得到对应的预测结果,这与我们「第9.9节 神经机器翻译 NMT:Seq2Seq 在翻译任务中的应用」介绍的内容有些许差异,可以看做是升级版考虑得更加细致。当然,这也是大语言模型中一种比较通用的做法。各位读者也可以暂时跳过这部分内容的相关原理介绍,直接阅读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根据采样策略得到预测值。
现在,假设模型已经预测得到了当前时刻的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_remove将logits中满足条件的值忽略,设置为负无穷大。从这里可以看出,通过调整阈值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_k和top_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越平滑,生成的结果也更加具有丰富性,而越小则生成内容更具有确定性。这里可以看出,temperature和top_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.json、vocab.txt和pytorch_model.bin,其中前两个分别是模型对应的超参数和词表,最后一个为预训练模型的权重参数。进一步,我们在工程的根目录下新建一个名为model的文件夹,然后将这个3个文件放入到该文件夹中。
进一步,我们可以通过如下代码完成模型的推理过程:
1 def main():
2 parser = argparse.ArgumentParser()
3 parser.add_argument("--model_config", default="model/config.json")
4 parser.add_argument("--tokenizer_path", default="model/vocab.txt")
5 parser.add_argument("--model_path", default="model/pytorch_model.bin")
6 tokenizer = BertTokenizer(vocab_file=args.tokenizer_path)
7 model_config = GPT2Config.from_json_file(args.model_config)
8 model = GPT2LMHeadModel(config=model_config)
9 state_dict = torch.load(args.model_path, map_location="cpu")
10 if 'state_dict' in state_dict:
11 state_dict = {k[6:]: v for k, v in state_dict["state_dict"].items()}
12 model.load_state_dict(state_dict)
13 for i in range(nsamples):
14 raw_text = args.prefix
15 encoded = tokenizer(raw_text)["input_ids"][:-1]
16 out = sample_sequence(model, encoded, length=length, n_ctx=n_ctx,
17 tokenizer=tokenizer, temperature=temperature, top_k=topk,
18 top_p=topp, repitition_penalty=repetition_penalty, device=device)
19 print(tokenizer.decode(out))
20
21 if __name__ == "__main__":
22 main()在上述代码中,第2~5行是设定这3个参数的默认值,即上面我们所下载的预训练模型。第6行是实例化一个BERT切分器。第7~8行分别是实例化一个GPT-2配置类和GPT-2网络模型,均直接使用自Transformers库中的模块。第9~12行是载入预训练模型,并用其重新对实例化的网络模型初始化参数,其中第10~11行是考虑有的模型不止保存了state_dict,可能还有优化器的参数等,此时就是一个嵌套的字典。第13行开始是循环根据同一个提示文本生成多个结果,其中第14~15行是将文本序列转换为索引序列。例如序列“先帝创业未半而中道崩殂”转换后的结果为[101, 1044, 2370, 1158, 689, 3313, 1288, 5445, 704, 6887, 2309, 22156],即第1个索引为[CLS]。第16~18行则是根据输入返回得到生成后的内容对应的索引。第19行则是将索引解码为对应的文字内容。
最后,通过如下命令便可以完成内容生成:
1 python generate.py当然,也可以在运行时指定对应参数的值而不使用默认值,例如:
1 python generate.py --length=100 --n_ctx=512 --nsamples=2 --topk=5到此对于预训练模型的使用方法就介绍完了,项目中所提供的其它预训练模型也可以通过类似的方式进行使用,相关细节信息也可以参考项目主页的介绍文档。
10.15.4 模型训练#
在介绍完预训练模型的使用方法后我们再来看如何使用自定义语料来从头训练一个基于GPT-2的预训练模型并完成后续推理任务。这里该项目使用的是开源框架PyTorch Lightning来完成的整个模型的训练、验证和保存过程。
Lightning 框架,也称为PyTorch Lightning,类似于之前介绍的Transformers框架,它本质上也是一个基于 PyTorch 的轻量级深度学习API库,旨在简化深度学习模型的训练过程并提高代码的可读性和可维护性 [3] [4]。Lightning 框架最初由 PyTorch 的一位研究员William Falcon创建,并于2019年在GitHub上首次发布。最初的版本是作为一个用于简化PyTorch训练循环的工具,提供了许多预定义的训练组件和功能。
Lightning 框架封装了深度学习模型的训练循环,包括前向传播、损失计算、反向传播等过程,使得用户无需重复编写这些训练代码,只需通过简单的配置即可进行模型训练。Lightning 同时框架也提供了自动化的训练和优化功能,包括自动批处理(Automatic batching)、自动调整学习率(Automatic learning rate tuning)等,帮助用户更轻松地优化模型的性能。由于基于PyTorch构建,Lightning框架可以充分利用PyTorch的高性能计算能力,同时又提供了更简洁和易用的接口,使得用户可以更高效地开发和训练深度学习模型。
1. 数据集定义
在这里我们使用到的依旧是「第7.6节 CharRNN教程:基于字符级序列的生成模型」内容中所介绍的古诗词数据集。首先,我们需要读取所有古诗文本,每一首诗看成是一个段落,然后将它们放到一个列表中,具体实现代码如下所示:
1 def load_raw_data(file_dir="./data/peotry_tang"):
2 def read_json_data(path):
3 samples, labels = [], []
4 with open(path, encoding='utf-8') as f:
5 data = json.loads(f.read())
6 for item in data:
7 content = item['paragraphs']
8 content = "".join(content)
9 samples.append(content)
10 return samples
11
12 all_samples = []
13 for i in range(58):
14 file_path = os.path.join(file_dir, f'poet.tang.{i * 1000}.json')
15 samples = read_json_data(file_path)
16 all_samples += samples
17 return all_samples在上述代码中,第1~10行是读取原始每一个json文件,并提取其中的正文内容并返回一个列表。第12~17行是循环遍历读取每一个文件,并存放至一个列表中。
最后构造完成的训练样本类似如下所示:
1 ['云想衣裳花想容,春风拂槛露华浓。若非群玉山头见,会向瑶台月下逢。',
2 '葡萄美酒夜光杯,欲饮琵琶马上催。醉卧沙场君莫笑,古来征战几人回?']2. 数据集构建
在完成原始数据的预处理以后,我们需要定义一个类来完成数据集的生成,示例代码如下所示:
1 class DS(Dataset):
2 def __init__(self, lines, vocab_path="vocab.txt", max_length=1024):
3 self.data = lines
4 self.tok = BertTokenizer(vocab_file=vocab_path)
5 self.max_length = max_length
6
7 def __len__(self):
8 return len(self.data)
9
10 def __getitem__(self, index):
11 line = self.data[index]
12 line = self.tok(line, max_length=self.max_length,
13 truncation=True, padding="max_length", return_tensors="pt")
14 return line在上述代码中,第2行lines传入的便是上面原始数据处理完成后返回的列表;vocab_path表示词表路径;max_length表示样本的最大程度,即所有样本按该长度进行截断或填充。第4行是实例化一个词元切分对象。第7~8行用于定义一个方法来获取数据集的样本数量。第10~14行用于定义每个原始文本的处理流程,即先根据index取一个样本,然后进行词元切分并按最大程度进行处理,最后返回PyTorch张量。
3. 模型定义
进一步,借助Transformers框架中的GPT2LMHeadModel模块来构造整个GPT-2模型,并同时完成数据集迭代器的构建,相关核心代码如下所示:
1 class Net(pl.LightningModule):
2 def __init__(self,
3 config_path="config/model_config.json",
4 data_path="data/train.txt",
5 valid_examples=100,
6 vocab_path="vocab/vocab.txt",
7 max_length=1024):
8 super(Net, self).__init__()
9 self.config = GPT2Config.from_json_file(config_path)
10 self.model = GPT2LMHeadModel(config=self.config)
11 self.data = load_raw_data()
12 self.dataset_train = DS(self.data[:-valid_examples], vocab_path, max_length)
13 self.dataset_valid = DS(self.data[-valid_examples:], vocab_path, max_length)
14
15 def forward(self, input_ids, attention_mask):
16 r = self.model(input_ids=input_ids, attention_mask=attention_mask,
17 labels=input_ids, return_dict=True)
18 return r["loss"]在上述代码中,第3~7行是指定相关的模型超参数。第9~10行分别实例化一个配置类和GPT-2模型对象。第11~13行的载入所有原始数据,并构建相应的训练集和测试集。第15~18行分别是计算GPT-2的前向传播过程,并取对应的损失函数。
接着,我们重载pl.LightningModule类中的一些方法来完成模型训练过程中需要调用到的模块,具体示例代码如下所示:
1 def train_dataloader(self):
2 return DataLoader(self.dataset_train, batch_size=self.batch_size)
3
4 def configure_optimizers(self):
5 optimizer = AdamW(self.parameters(), lr=self.lr, weight_decay=0.001)
6 scheduler = get_linear_schedule_with_warmup(optimizer,
7 self.warm_up_steps, self.t_total)
8 scheduler = {"scheduler": scheduler, "interval": "step", "frequency": 1}
9 return [optimizer], [scheduler]
10
11 def training_step(self, batch, batch_nb):
12 loss = self.forward(batch["input_ids"], batch["attention_mask"])
13 self.log("train_loss", loss, on_step=True, on_epoch=True, logger=True)
14 return loss
15
16 def validation_epoch_end(self, outputs):
17 avg_loss = torch.stack(outputs).mean()
18 self.log("val_loss", avg_loss, on_epoch=True, logger=True)
19 return {"val_loss": avg_loss}在上述代码中,第1~2行根据训练集返回得到训练集对应的迭代器,并且按照类似写法还可以得到验证集对应的迭代器。第4~9行是定义优化器和学习率调度器。第11~14行是定义模型的训练步骤,即计算得到的损失值,其中第12行两个输入的形状均为[batch_size, max_length],第13行则是输出日志信息后续可通过Tensorboard进行可视化。同理,我们也可以按照类似的写法得到模型验证的计算步骤。第16~19行是按照设定的频率计算模型在验证集上的损失值。
在完成上述步骤以后便可以通过如下方式来训练整个模型:
1 if __name__ == "__main__s":
2 parser = argparse.ArgumentParser()
3 parser.add_argument("--config_path", default="model/config.json")
4 parser.add_argument("--vocab_path", default="model/vocab.txt")
5 args = parser.parse_args()
6 ...
7 checkpoint_callback = ModelCheckpoint(dirpath=output_path, verbose=True,
8 period=1, save_top_k=1, onitor="val_loss", mode="min")
9 learning_rate_callback = LearningRateMonitor()
10 trainer = pl.Trainer(default_root_dir=output_path, gradient_clip_val=1,
11 max_epochs=epochs, gpus=args.device, distributed_backend="dp",
12 val_check_interval=eval_interval,
13 callbacks=[learning_rate_callback, checkpoint_callback])
14 net = Net(batch_size, epochs, t_total=t_total,
15 config_path=config_path, data_path=data_path,
16 valid_examples=val_examples, vocab_path=vocab_path,
17 max_length=max_length, warm_up_steps=warmup_steps, lr=lr)
18 trainer.fit(net)在上述代码中,第3~5行是设置模型的相关默认参数并解析。第7~8行是实例化一个模型检测对象,即指定模型的保存路径,且每间隔1轮迭代时以验证集上损失值按照最小标准的原则保存前1个最好的模型权重。第10~18行是分别实例化一个模型训练器,然后再实例化整个网络模型,并开始进行训练。
上述代码运行时将会得到类似如下输出结果:
1 Epoch 0: 10/14293 [00:12 < 2:31:09], 1.25s/it, loss=9.05, v_num=9, train_loss_step=9.090
2 Epoch 0: 11/14293 [00:13 < 2:30:12], 1.25s/it, loss=8.98, v_num=9, train_loss_step=8.550
3 Epoch 0: 12/14293 [00:14 < 2:29:23], 1.24s/it, loss=8.81, v_num=9, train_loss_step=8.350
4 Epoch 0: 13/14293 [00:16 < 2:18:42], 1.23s/it, loss=8.72, v_num=9, train_loss_step=7.840当模型开始训练时项目根目录将会生成一个名为model的文件,且每一轮迭代结束后的模型文件将会被保存到该路径下,名称类似epoch=4-step=11675.ckpt的形式。同时,在该目录下还会生成一个名为lightning_logs的文件,里面记录了模型训练时的日志信息可以通过Tensorboard来可视化学习率、损失等值的变化过程。
最后,在运行generate.py模块时我们只需要将预训练模型的路径指定为此时模型保存的权重即可进行推理使用,示例如下:
1 python generate.py --length=24 --n_ctx=256 --nsamples=3
2 --model_path='model/epoch=4-step=11675.ckpt'
3 --prefix='金风玉露一相逢'运行结束后将会生成类似如下结果:
1 金风玉露一相逢,白馬黃河十四重。天上青冥人未到,洞中應合鶴空蹝。
2 金风玉露一相逢,只為紅兒不自容。若使君王為底事,何須直到海頭峰。
3 金风玉露一相逢,紅杏花開兩處濃。誰道君王應有意,莫教明主與君封。10.15.5 小结#
在本节内容中,我们首先简单介绍了基于GPT-2网络结构的开源预训练模型以及整个环境的安装;然后详细介绍了在生成模型中的模型生成文本序列时结果筛选的原理以及实现过程,并同时介绍了如何直接使用这一开源预训练模型进行推理;最后详细介绍了如何使用该项目来从头训练一个语言模型,以及利用训练得到的权重参数完成模型的推理过程。
引用#
[1] https://github.com/Morizeyao/GPT2-Chinese
[2] https://github.com/moon-hotel/GPT2-Chinese
[3] https://en.wikipedia.org/wiki/PyTorch_Lightning
[4] https://lightning.ai/docs/app/stable/