美文网首页NLP学习
GPT系列:有监督微调GPT-2预训练模型,自动续写电视剧本

GPT系列:有监督微调GPT-2预训练模型,自动续写电视剧本

作者: xiaogp | 来源:发表于2023-12-11 21:17 被阅读0次

关键词:GPT预训练模型

前言

在前文GPT系列:GPT-2模型结构简述和实践中介绍了GPT-2的网络结构和minGPT项目的源码实现,并且以电视剧《狂飙》的其中一小段剧本作为输入,从头开始训练了一个小型的gpt-mini。本节介绍GPT-2中文预训练模型的使用,以及基于《狂飙》剧本对GPT-2进行有监督微调。


内容摘要

  • GPT-2预训练模型快速开始
  • GPT2LMHeadModel模型实现简述
  • 微调任务的输入和目标说明
  • GPT-2微调代码实践

GPT-2预训练模型快速开始

本篇使用的预训练模型为腾讯团队推出的gpt2-chinese-cluecorpussmall,它是基于CLUECorpusSmall数据训练,包含14GB的中文文本,由互联网上的新闻,问答,百科,评论等文本数据组成。
结合HuggingFace的GPT2LMHeadModel模型类,用Python可以很方便地的对该预训练模型进行调用。

from transformers import BertTokenizer, GPT2LMHeadModel
# 分词器
tokenizer = BertTokenizer.from_pretrained("./gpt2-chinese-cluecorpussmall")
# gpt-2预训练模型
model = GPT2LMHeadModel.from_pretrained("./gpt2-chinese-cluecorpussmall")
# 文本生成pipeline
text_generator = TextGenerationPipeline(model, tokenizer)

通过管道定义文本生成所需的模型和分词器,给到prompt即可实现文本自动生成。

>>> text = '明天降温了'
>>> res = text_generator(text, max_length=30, do_sample=False)
>>> print(res)
[{'generated_text': '明天降温了 , 但 是 , 我 们 还 是 要 注 意 防 寒 保 暖 , 不 要 让 寒 冷 刺 激 到 你'}]

其中max_length代表算上prompt的文本之后最大生成30个词,do_sample为False代表使用Greedy Search进行文本生成,每一步选择最大概率得分的词。
可以添加更多文本生成参数,包括top_k,温度系数,采用多项式分布采样,输出多个候选文本,比如

>>> res = text_generator(text, max_length=30, do_sample=True, top_k=5, temperature=0.8, num_return_sequences=3)
>>> for i in res:
        print(i)
{'generated_text': '明天降温了 , 但 是 我 们 还 是 要 把 握 好 时 机 , 不 要 错 过 了 。 我 们 的 投 资'}
{'generated_text': '明天降温了 , 你 就 会 明 白 , 我 们 是 多 么 的 幸 运 。 3 : 一 个 男 人 对 一'}
{'generated_text': '明天降温了 , 我 们 可 以 穿 上 一 件 秋 衣 。 王 女 士 说 。 记 者 了 解 到 ,'}

通过采样生成的3条文本增加了多样性,但是在连贯性和语义表达的清晰程度上都不如Greedy Search不采样的结果。


GPT2LMHeadModel模型实现简述

GPT2LMHeadModel类实现了GPT-2的训练和推理,以及调用generate方法实现文本生成。


GPT2LMHeadModel前向传播结构

GPT2LMHeadModel类包含两个子模块,分别是负责实现堆叠Decoder的GPT2Model,以及负责映射到单词得分的线性层Linear

class GPT2LMHeadModel(GPT2PreTrainedModel):
    def __init__(self, config):
        super().__init__(config)
        self.transformer = GPT2Model(config)
        self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
        ...

GPT2LMHeadModel的主要输入为input_ids,past_key_values,labels,分别代表输入文本,模型维护的Q,V上下文信息,目标预测文本

    def forward(
        self,
        input_ids: Optional[torch.LongTensor] = None,
        past_key_values: Optional[Tuple[Tuple[torch.Tensor]]] = None,
        labels: Optional[torch.LongTensor] = None,
        ...
    ) -> Union[Tuple, CausalLMOutputWithCrossAttentions]:
        r"""
        labels (`torch.LongTensor` of shape `(batch_size, sequence_length)`, *optional*):
            Labels for language modeling. Note that the labels **are shifted** inside the model, i.e. you can set
            `labels = input_ids` Indices are selected in `[-100, 0, ..., config.vocab_size]` All labels set to `-100`
            are ignored (masked), the loss is only computed for labels in `[0, ..., config.vocab_size]`
        """
  • input_ids:输入GPT的文本token id,对于gpt2-chinese-cluecorpussmall最大长度不超过1024,在训练阶段输入全部文本,在预测阶段只需要获取下一个单词的embedding信息,因此只需要输入当前最后一个单词的token id,结合past_key_values即可完成子回归推理
  • past_key_values:维护各层各个注意力头在各个步长下的Q,V上下文信息。在推理阶段的自注意力模块,K只要输入一个词的token id即可,但是Q和V是截止到当前词之前所有词的信息,如果在模型的逐位推理过程中不记录past_key_values,则每次都需要将全部文本整体输入才能得到Q和V,导致推理效率低下。在模型首次推理的时候past_key_values设置为None。
  • labels:训练阶段的预测目标token id。若设置labels则模型为训练阶段,会计算loss。需要注意的是shifted right移位的操作已经在该模型内部实现,不需要将文本手动shifted right传入labels,换句话说直接设置labels和input_ids相同即可。另外的可以对labels某些位置设置为-100,代表该位置的预测结果不会计入loss计算。
推理阶段input_ids(Q),past_key_values(K,V)工作示意图

在前向传播阶段GPT2LMHeadModel先进行Decoder层的运算拿到last_hidden_state,再传入线性层拿到在词表中每个词的概率得分lm_logits

        transformer_outputs = self.transformer(
            input_ids,
            past_key_values=past_key_values,
            ...
        )
        # TODO BaseModelOutputWithPastAndCrossAttentions[0] => last_hidden_state
        hidden_states = transformer_outputs[0]
        ...
        lm_logits = self.lm_head(hidden_states)

输入的概率得分和labels进行比较计算交叉熵,作者通过切片将shifted right在模型内部实现,无需在模型外面额外处理

        loss = None
        if labels is not None:
            # TODO shifted right
            shift_logits = lm_logits[..., :-1, :].contiguous()
            shift_labels = labels[..., 1:].contiguous()
            # Flatten the tokens
            loss_fct = CrossEntropyLoss()
            loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))

最终返回CausalLMOutputWithCrossAttentions类,如果是训练阶段从中可以拿到loss,如果是推理阶段可以拿到下一个词的预测得分分布,以及past_key_values上下文向量信息用于下一次预测的输入。

        return CausalLMOutputWithCrossAttentions(
            loss=loss,
            logits=lm_logits,
            past_key_values=transformer_outputs.past_key_values,
            hidden_states=transformer_outputs.hidden_states,
            attentions=transformer_outputs.attentions,
            cross_attentions=transformer_outputs.cross_attentions,
        )

past_key_values模型推理实现

有两种方法实现模型推理,分别是

  • 传入全部文本,past_key_values为None,取输出的一组token中最后一个token embedding
  • 传入当前词,以及截止到当前的past_key_values,取输出的唯一token embedding

以“明天降温了”作为prompt,采用贪婪搜索的方式生成20个单词

>>> tokenizer = BertTokenizer.from_pretrained("gpt2-chinese-cluecorpussmall")
>>> text = '明天降温了'

第一种形式生成如下,past_key_values直接为None

>>> text_list = list(text)
>>> for i in range(20):
        token = torch.tensor([tokenizer.convert_tokens_to_ids(text_list)])
        next_one_emb = model(token, past_key_values=None)
        next_one = torch.argmax(next_one_emb.logits[..., -1, :], dim=-1, keepdim=True)
        token = next_one
        text_list.append(tokenizer.convert_ids_to_tokens(next_one)[0])
>>> print(" ".join(text_list))
>>> 明 天 降 温 了 , 但 是 , 我 们 还 是 要 注 意 防 寒 保 暖 , 不 要 让 寒

第二种形式生成如下,需要不断更新past_key_values

>>> past_key_values = None
>>> res = []
>>> token = torch.tensor([tokenizer.convert_tokens_to_ids(list(text))])
>>> for i in range(20):
        next_one_emb = model(token, past_key_values=past_key_values)
        past_key_values = next_one_emb.past_key_values
        next_one = torch.argmax(next_one_emb.logits[..., -1, :], dim=-1, keepdim=True)
        token = next_one
        res.append(tokenizer.convert_ids_to_tokens(next_one)[0])
>>> print(text + " ".join(res2))
>>> 明天降温了, 但 是 , 我 们 还 是 要 注 意 防 寒 保 暖 , 不 要 让 寒

两种方法生成的结果完全一样,显然从性能角度考虑第二种方式更优。


generate文本生成实现

在快速开始环节介绍了TextGenerationPipeline这种简单快捷的方式来生成文本,通过模型自身的generate方法是另一种更通用的方式

>>> res = model.generate(input_ids=torch.LongTensor([tokenizer.convert_tokens_to_ids(list("明天降温了"))]),
                      max_length=25,  # 生成序列的最大长度
                      do_sample=False,  # 是否开启采样,默认是 False,即贪婪找最大条件概率的词
                      top_k=20,  # top-k-filtering 算法保留多少个 最高概率的词 作为候选,默认50
                      repetition_penalty=1.0,  # 重复词惩罚
                      temperature=1.0)  # 温度系数

>>> generated_texts = tokenizer.batch_decode(res, skip_special_tokens=True)
>>> print(generated_texts)
>>> ['明 天 降 温 了 , 但 是 , 我 们 还 是 要 注 意 防 寒 保 暖 , 不 要 让 寒']

微调任务的输入和目标说明

通过引入领域独有数据,在预训练GPT-2上继续训练,预测下一个词作为任务目标,完成对GPT-2的微调,使得生成的内容更加适配该领域的知识, 本节领域数据继续使用电视剧《狂飙》的部分电视剧本。

狂飙电视剧

GPT-2微调代码实践

微调的输入为上下文窗口最大128的文本的token id,由于GPT2LMHeadModel内部已经实现了shifted-right,因此预测目标和输入等同,数据处理过程如下

import torch.cuda
from torch.utils.data import Dataset, DataLoader
from transformers import GPT2LMHeadModel, BertTokenizer, AdamW, get_linear_schedule_with_warmup

MODEL = GPT2LMHeadModel.from_pretrained("./gpt2-chinese-cluecorpussmall")
TOKENIZER = BertTokenizer.from_pretrained("./gpt2-chinese-cluecorpussmall")

class Data(Dataset):
    def __init__(self, block_size):
        self.text = open("./data/text.txt", encoding="utf8").read()
        self.block_size = block_size

    def __len__(self):
        return len(self.text) - self.block_size + 1

    def __getitem__(self, item):
        block = self.text[item: item + self.block_size]
        return block

def collate_fn(batch_block):
    input_ids = []
    for i in batch_block:
        token = TOKENIZER.convert_tokens_to_ids(list(i))
        input_ids.append(token)

    return torch.LongTensor(input_ids).to(DEVICE)

data = Data(128)
data_loader = DataLoader(data, batch_size=48, collate_fn=collate_fn, shuffle=True, drop_last=False)

接下来直接调用GPT2LMHeadModel拿到loss,同时每训练50步让模型自动基于给定的prompt“高启强被捕之后”进行剧本续写,目的是查看随着微调损失的收敛,文本的生成是否更加贴合剧本信息

epochs = 20
optimizer = AdamW(MODEL.parameters(), lr=2e-4)
scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=50, num_training_steps=epochs * len(data_loader))
criterion = torch.nn.CrossEntropyLoss()
text = "高启强被捕之后"
print("--------------不做微调")
generate(text)

for epoch in range(epochs):
    for step, input_ids in enumerate(data_loader):
        MODEL.to(DEVICE).train()
        optimizer.zero_grad()
        forward = MODEL(input_ids=input_ids, labels=input_ids)
        loss = forward.loss
        loss.backward()
        optimizer.step()
        scheduler.step()
        if (step + 1) % 50 == 0:
            print("--------------step: {}, loss: {}".format(step + 1, loss.item()))
            generate(text)

文本生产采用多项式采样,设置一定的top-k以及温度系数,最大推理50个文本长度

def generate(text):
    encode = torch.LongTensor([TOKENIZER.convert_tokens_to_ids(list(text))]).to(DEVICE)
    output = MODEL.generate(input_ids=encode, max_length=50, do_sample=True, top_k=5, repetition_penalty=1.0,
                            temperature=0.5)
    res = TOKENIZER.batch_decode(output, skip_special_tokens=True)
    print("".join(res[0].split(" ")))

微调模型的训练日志如下

迭代次数 loss 生成内容
0 - 高启强被捕之后,他们的一些人被捕后,他们的一些人被捕后被释放。这些人被送到了一个叫做的地方。他们
100 0.949 高启强被捕之后,不断在反省中寻找着自己的线索。直至今天,他仍然坚持着自己的立场,绝不允许任何人将他交
200 0.249 高启强被捕之后,高启强心里咯噔一下,表面仍不动声色,冲着营业员做个手势,示意盯一下,自己捂着手机出了
300 0.164 高启强被捕之后,高启强被铐在审讯室的椅子上,跷着二郎腿。对面电视里正在放春晚的小品,笑声一阵响似一阵
400 0.139 高启强被捕之后,高启兰哇的一声哭了出来:我是他妹妹,警察大哥,我哥绝对是好人!安欣看着兄妹二

前两条结果为模型在微调初始阶段生成的内容,带有比较重的公开数据的味道,语句勉强通顺但是语义表达不清晰,第三条结果有明显改善,语句通顺且表达出了完整的意义,从第四条结果开始已经很贴切《狂飙》的剧本情节,第四条很类似剧本中曾经出现的原文,而第五条结果也成功生成出了《狂飙》中的人物高启兰,并且成功地表达出了两者的兄妹关系,完全区分不出是人写的还是GPT-2自动生成的,也映证了通过领域数据微调GPT-2预训练模型的有效性。

相关文章

网友评论

    本文标题:GPT系列:有监督微调GPT-2预训练模型,自动续写电视剧本

    本文链接:https://www.haomeiwen.com/subject/srsqgdtx.html