简单来说,ChatGPT 是自然语言处理(NLP)和强化学习(RL)的一次成功结合,考虑到读者可能只熟悉其中一个方向或者两个方向都不太熟悉,本文会将 ChatGPT 涉及到的所有知识点尽可能通俗易懂的方式展现出来,有基础的同学可以选择性跳过一些内容。
GPT 的进化史
本节的主要目的是介绍自然语言处理中语言模型的一些基础知识,理解语言模型到底在做什么。
GPT
所谓的 GPT(Generative Pre-trained Transformer),其实是 Generative Pre Training of a language model(语言模型)。那什么是语言模型呢?可以简单地把语言模型理解为“给定一些字或者词,预测下一个字或者词的模型”,这里的字或者词在 NLP 领域通常也被称为 token,即给定已有 token,预测下一个 token 的模型,这里举个例子,我们在搜索引擎里进行搜索时,自动会往后联想就是种语言模型的体现。
那么训练语言模型有什么优势呢?答案就是它不需要人工标注数据!
比如以“today is a good day”为例,它可以被拆解为:
训练数据标签todayistoday isatoday is agoodtoday is a goodday
接下来让我们来数学化地描述一下,给定一个句子,比如 ,语言模型其实就是想最大化:
其中 是考虑的窗口长度,条件概率 通过一个参数为 的神经网络来描述。
GPT 的神经网络采用了一个多层的 Transformer decoder,输入经过 embedding 层(token embedding 叠加 position embedding),然后过多层解码器,最后通过一个 position-wise 的前向网络得到输出的分布:
有了模型结构,有了目标函数,已经可以预训练一个大容量的语言模型了,这也就是 GPT 的第一阶段,在 GPT 的训练流程里还有第二个阶段,利用一些有标签数据进行微调。假设输入为 ,标签为 ,可以将输入喂入模型,模型的输出再叠加一个线性层作为最终的输出:
目标函数也就是:
然而作者在微调时还发现,同时考虑语言模型的自回归目标函数效果更好,也就是:
在微调阶段,可以优化的参数只有顶部的线性层已经用作分隔符的 token embedding。下图展示的就是 GPT 做微调时对文本的一些常见做法,其实就是拼接和加分割符之类的操作。
GPT2
GPT1 需要对特定任务再进行精调(依赖有标签数据进行监督学习),而 GPT2 则是考虑在预训练时考虑各种不同的任务,也就更加通用化。因此,GPT2 的模型从原本 GPT1 的:
改为 task conditioning 的形式:
也就是把任务也作为模型的输入,具体的做法是引入一些表示任务的 token,举几个例子。
自回归任务input:Today is aoutput:good
翻译任务input:Today is a [翻译为中文]output:今天是一个
问答任务input:我是小明 [问题] 我是谁 [答案]output:小明上面例子中 [翻译为中文]、[问题] 、[答案] 这些就是用于告诉模型执行什么任务的 token。
通过这样的方式,各种任务都能塞进预训练里进行了,想学的越多,模型的容量自然也需要更大,GPT2 的参数量达到了 1.5 Billions(GPT1 仅 117 Millions)。
GPT3
GPT3 可以理解为 GPT2 的升级版,使用了 45TB 的训练数据,拥有 175B 的参数量,真正诠释了什么叫暴力出奇迹。
GPT3 主要提出了两个概念:
情景(in-context)学习:就是对模型进行引导,教会它应当输出什么内容,比如翻译任务可以采用输入:请把以下英文翻译为中文:Today is a good day。这样模型就能够基于这一场景做出回答了,其实跟 GPT2 中不同任务的 token 有异曲同工之妙,只是表达更加完善、更加丰富了。
Zero-shot, one-shot and few-shot:GPT3 打出的口号就是“告别微调的 GPT3”,它可以通过不使用一条样例的 Zero-shot、仅使用一条样例的 One-shot 和使用少量样例的 Few-shot 来完成推理任务。下面是对比微调模型和 GPT3 三种不同的样本推理形式图。
ChatGPT
ChatGPT 使用了类似 InstructGPT 的方式来训练模型,该方法也叫做 Learning from Human Feedback。主要分为三个步骤:
用有监督数据精调 GPT-3.5;
对于模型输出的候选结果(因为采样会导致同一输入有不同输出)进行打分,从而训练得到一个奖励模型;
使用这个奖励模型,用 PPO 算法来进一步对模型进行训练。
如何训练一个 GPT
接下来我们来动手实践一下如何训练一个 GPT 模型出来,这里以从头训练一个代码补全的 GPT 模型为例。
代码补全有什么用呢,比如我们给模型一个提示:
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# build a BERT classifier
然后模型就能够输出:
tokenizer = AutoTokenizer.from_pretrained(‘bert-base-uncased’)
model = AutoModelForSequenceClassification.from_pretrained(‘bert-base-uncased’)
帮我们定义了一个基于 Bert 的分类器出来。
为了训练这样一个模型,首先我们需要准备用于训练的数据,常见的代码补全的数据为 codeparrot。
https://huggingface.co/codeparrot/codeparrot
这里随便打印一条数据(截断了,不然太长了)出来看看,可以看到其实跟我们正常写的代码是一样的。
然而模型是不能直接接收这样的“文本”信息的,所以训练 NLP 模型前通常需要对其进行“分词”,转化为由一串数字表示,可以创建一个分词器:
tokenizer = AutoTokenizer.from_pretrained(“./code-search-net-tokenizer”)
对上面的代码进行分词转化,就可以得到如下的一串 id:
[3, 41082, 17023, 26, 11334, 13, 24, 41082, 173, 2745, 756, 173, 2745, 4397, 173, 2745, 1893, 173, 2745, 3857, 442, 2604, 173, 973, 7880, 978, 3399, 173, 973, 10888, 978, 4582, 173, 173, 973, 309, 65, 552, 978, 6336, 4391, 173, 295, 6472, 8, …
上面的例子展示了对单条样本进行分词的结果;通常我们会把分词函数定义好(中间会涉及到比如需不需要截断、最大长度多少等细节配置这里就不详细展开了),然后直接对整个数据集进行 map 就可以对整个数据集进行分词了。