GPT系列:GPT-2模型结构简述和实践

关键词:GPTTransformer

内容摘要

  • GPT的背景来源和发展简述
  • GPT的自回归工作方式
  • 图解GPT-2的网络结构
  • GPT的解码采样策略
  • minGPT源码分析和文本生成实战

GPT的背景来源和发展简述

GPT(Generative Pre-Trained Transformer,生成式预训练Transformer模型),它是基于Transformer的Decoder解码器在海量文本上训练得到的预训练模型。GPT采用自回归的工作方式,能够查看句子的一部分并且预测下一个单词,不断重复这个过程来生成连贯且适当上下文文本。
GPT的诞生早于Bert,相比于Bert针对的任务通常是文本分类、实体识别、完型填空, GPT这种完全自行生成类似人类语言文本的任务更具挑战性,是强人工智能的体现,ChatGPT就是在GPT技术的基础之上开发的交互式对话版本。


GPT的自回归工作方式

给定一个前句,将它作为问题或者引导词输入给GPT,GPT基于这个句子预测一个单词,同时预测出的词会加入到原始句子的结尾合并成一个新的句子作为形成下一次的输入,这种工作方式被成为自回归性,如下图所示

GPT的最回归工作方式

在训练阶段GPT采用对文本进行shifted-right右移一位的方式构造出输入和输出,在预测推理阶段采用自回归的方式通过持续地更新上下文窗口中的输入来源源不断的预测下去。


图解GPT-2的网络结构

GPT系列自OpenAI在2018年发布的第一代GPT-1开始,以及后的GPT-2,GPT-3,他们的区别主要在于预训练数据和模型参数的规模在不断扩大,以及训练策略优化等等,而模型网络结构并没有大的改变,都是以堆叠Transformer的解码器Decoder作为基座。

模型 发布时间 层数 头数 词向量长度 参数量 预训练数据量
GPT-1 2018年6月 12 12 768 1.17亿 5GB
GPT-2 2019年2月 48 25 1600 15亿 40GB
GPT-3 2020年5月 96 96 12888 1750亿 45TB

本篇采用GPT-2作为GPT系列网路结构的说明。

GPT-1,GPT-2网络结构

上图所示左侧为GPT-1结构,右侧为GPT-2结构,堆叠了12层Transformer的Decoder解码器,GPT-2略微修改了Layer Norm层归一化的位置,输入层对上下文窗口长度大小(block size)的文本做建模,使用token embedding+位置编码,输出层获取解码向量信息,最后一个block对应的向量为下一个词的信息表征,可以对该向量做分类任务完成序列的生成。
GPT-2本质上沿用了Transformer的解码器Self Attention机制,在堆叠的每一层,当前时刻信息的表征,只能使用当前词和当前词之前的信息,不能像Bert那样既能看到左侧的词又能看到右侧的词,因为GPT是文本生成任务,而Bert是给定了全文的基础上进行语意理解,两者区别如下图所示

GPT和Bert的区别

GPT的解码采样策略

GPT的堆叠Decoder结构输出的最后一个block的embedding,再结合Linear线性层和Softmax层映射到全部词表,即可获得下一个单词的概率分布,GPT的解码过程就是基于概率分布来确定下一个单词是谁,显然得分高的更应该被作为预测结果。GPT常用的解码策略有贪婪采样和多项式采样

  • 贪婪采样:在每一步选择概率最高的单词作为结果。这种方法简单高效,但是可能会导致生成的文本过于单调和重复
  • 多项式采样:在每一步根据概率分布作为权重,随机选择单词。这种方法可以增加生成的多样性,但是可能会导致生成的文本不连贯和无意义,出现比较离谱的生成结果

用PyTorch模拟一个基于多项式采样的例子如下

>>> import torch
>>> weights = torch.Tensor([0, 1, 2, 3, 4, 5]) 
>>> cnt = {}
>>> for i in range(10000):
>>>     res = torch.multinomial(weights, 1).item()
>>>     cnt[res] = cnt.get(aaa, 0) + 1
>>> cnt
{5: 3302, 2: 1307, 3: 1994, 1: 707, 4: 2690}

采用torch.multinomial模拟10000次采样,记录下最终采样的结果,结论是被采样到的概率基本等于该单词的权重占全部权重的比例。
通常GPT采用多项式采样结合Temperature温度系数,Top-K,Top-P来一起完成联合采样。

Temperature温度系数

温度系数是一个大于0的值,温度系数会对概率得分分布做统一的缩放,具体做法是在softmax之前对原始得分除以温度系数,公式如下

温度系数公式

当温度系数大于1时,高得分单词和低得分单词的差距被缩小,导致多项式采样的倾向更加均匀化,结果是生成的内容多样性更高。当温度系数小于1时,高得分单词和低得分单词的差距被放大,导致多项式采样的更加倾向于选择得分高的单词,生成的内容确定性更高。默认温度系数等于1,此时等同于使用原始的概率分布不做任何缩放。。
使用PyTorch模拟温度系数分别为2和0.5,结果如下

>>> torch.softmax(torch.tensor([1.0, 2.0, 3.0, 4.0]), -1)
tensor([0.0321, 0.0871, 0.2369, 0.6439])

>>> # 温度系数为2
>>> torch.softmax(torch.tensor([1.0, 2.0, 3.0, 4.0]) / 2, -1)
tensor([0.1015, 0.1674, 0.2760, 0.4551])

>>> # 温度系数为0.5
>>> torch.softmax(torch.tensor([1.0, 2.0, 3.0, 4.0]) / 0.5, -1)
tensor([0.0021, 0.0158, 0.1171, 0.8650])

显然温度系数越高为2时抽样概率越平均,温度系数越低为0.5时抽样概率越极端,对高分的顶部词更加强化。

Top-K

Top-K指的是将词表中所有单词根据概率分布从高到低排序,选取前K个作为采样候选,剩下的其他单词永远不会被采样到,这样做是可以将过于离谱的单词直接排除在外,一定程度上提高了多项式采样策略的准确性。同样的k越大,生成的多样性越高,k 越小,生成的质量一般越高。

Top-P

Top-P和Top-K类似,选取头部单词概率累加到阈值P截止,剩余的长尾单词全部丢弃,例如P=95%,单词A的概率为0.85,单词B的概率为0.11,头部的两个词概率相加已经达到95%,因此该步的采样候选只有单词A和单词B,其他词不会被采样到。对位概率分布长尾的情况,Top-P相比于Top-K更加可以避免抽样到一些概率过低的不相关的词。


minGPT源码分析和文本生成实战

minGPT项目是基于PyTorch实现的GPT-2,它包含GPT-2的训练和推理,本篇以minGPT的chargpt例子作源码分析。chargpt是训练用户自定义的语料,训练完成后基于用户给到的文本可以实现自动续先,本例采用电视剧《狂飙》的部分章节作为语料灌给GPT-2进行训练。

模型参数概览

定位到chargpt.py,首先作者定义了数据和模型相关的参数配置,部分关键配置信息如下

data:
    block_size: 128
model:
    model_type: gpt-mini
    n_layer: 6
    n_head: 6
    n_embd: 192
    vocab_size: 2121
    block_size: 128
    embd_pdrop: 0.1
    resid_pdrop: 0.1
    attn_pdrop: 0.1
trainer:
    num_workers: 4
    max_iters: None
    batch_size: 64
    learning_rate: 0.0005
    betas: (0.9, 0.95)
    weight_decay: 0.1
    grad_norm_clip: 1.0
  • block_size:上下文窗口长度,也是输入的最大文本长度为128
  • model_type:gpt模型类型,这里采用gpt-mini,一个小规模参数的gpt,它包含6层Decoder,6个自注意力头,词向量的embedding维度为192
  • num_workers:DataLoader加载数据的子进程数量,可以加快训练速度,对模型结果没有影响
训练数据构造

需要用户自己指定一个input.txt,其内容为合并了所有文本而成的一行字符串

text = open('input.txt', 'r').read()
train_dataset = CharDataset(config.data, text)

CharDataset基于PyTorch的Dataset构造,len和getitem实现如下

    def __len__(self):
        return len(self.data) - self.config.block_size

    def __getitem__(self, idx):
        # grab a chunk of (block_size + 1) characters from the data
        chunk = self.data[idx:idx + self.config.block_size + 1]
        # encode every character to an integer
        dix = [self.stoi[s] for s in chunk]
        # return as tensors
        x = torch.tensor(dix[:-1], dtype=torch.long)
        y = torch.tensor(dix[1:], dtype=torch.long)
        return x, y

采用上下文窗口在原文本上从前滑动到最后,生成文本长度减去上下文窗口大小的实例作为len,getitem部分实现训练的输入x和目标y,每次选取129个连续的字符作为一个块,然后以前128个作为x,后128个作为y,实现shifted right。

gpt模型构建

作者将配置参数传递给GPT类实例化一个GPT网络

model = GPT(config.model)

在GPT类中实现了网路的各个元素,包括token embedding,位置编码,Decoder,Decoder结束后的层归一化,以及最后的概率分布线性映射层

self.transformer = nn.ModuleDict(dict(
            # TODO 输入的embedding [10, 48]
            wte=nn.Embedding(config.vocab_size, config.n_embd),
            # TODO 位置编码,block_size是上下文窗口 [6, 48]
            wpe=nn.Embedding(config.block_size, config.n_embd),
            drop=nn.Dropout(config.embd_pdrop),  # 0.1
            # TODO 隐藏层,6层transformer
            h=nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
            ln_f=nn.LayerNorm(config.n_embd),
        ))
# TODO 线性层输出概率分布
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)

其中定义h为一个包含6层的堆叠Decoder,Block实现了其中每一个Decoder单元,内部包括点积注意力层,层归一化,前馈传播层和残差连接,实现如下

class Block(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln_1 = nn.LayerNorm(config.n_embd)
        # TODO 自回归模型中的自注意力层
        self.attn = CausalSelfAttention(config)
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp = nn.ModuleDict(dict(
            c_fc=nn.Linear(config.n_embd, 4 * config.n_embd),
            c_proj=nn.Linear(4 * config.n_embd, config.n_embd),
            act=NewGELU(),
            dropout=nn.Dropout(config.resid_pdrop),
        ))
        m = self.mlp
        # TODO feed forward层
        self.mlpf = lambda x: m.dropout(m.c_proj(m.act(m.c_fc(x))))  # MLP forward

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlpf(self.ln_2(x))
        return x

forward中顺序有所不同,GPT-2中将层归一化提前到了注意力操作之前。
核心的点积注意力在CausalSelfAttention中实现,该类自己维护了下三角矩阵用于对后面的词进行mask。

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        ...
        self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
                             .view(1, 1, config.block_size, config.block_size))
    def forward(self, x):
        B, T, C = x.size()
        ...
        att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf'))

作者先定义了一个基于block_size全局最大128×128的下三角矩阵bias,而实际在使用时,根据输入文本的实际长度T从左上角开始取对应T×T形状,无论T多长bias始终是下三角矩阵,接着将注意力矩阵上三角部分全部置为-inf,从而使得所有后词信息被隔离。这个下三角掩码不论输入句子长度多长,并且在每一层堆叠的Decoder上都会生效。
在网络搭建好后,作者构建输入层,它是由token embedding和位置编码相加而成

b, t = idx.size()
pos = torch.arange(0, t, dtype=torch.long, device=device).unsqueeze(0)
tok_emb = self.transformer.wte(idx)  # token embeddings
pos_emb = self.transformer.wpe(pos)  # position embeddings
x = self.transformer.drop(tok_emb + pos_emb)

紧接着输入到由6层Decode组成的隐层h,GPT-2在Decoder结束之后又加了层归一化ln_f,最终到线性映射层lm_head输出预测到词表的得分分布

for block in self.transformer.h:
    x = block(x)
x = self.transformer.ln_f(x)
logits = self.lm_head(x)
损失函数

损失采用每个预测位置的多分类交叉熵,一个批次下将所有样本合并拉直,最终所有位置的交叉熵取平均值作为最终损失

loss = None
if targets is not None:
    # TODO logits => [batch_size * seq_len, 10], target => [batch_size * seq_len, ]
    # TODO 该批次下 每条样本,每个位置下预测的多分类交叉熵损失 的平均值
    loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
模型推理

在模型推理阶段使用generate类方法,该方法定义了最大推理步长,温度系数等参数。作者给了个例子,输入一句话,对其进行自回归预测下一个单词,其中最大预测100步,温度系数为1.0,采用多项式采样,限制Top-K为10

with torch.no_grad():
    context = "高启强被捕之后"
    # TODO [seq_len, ] => [batch_size, seq_len]
    x = torch.tensor([train_dataset.stoi[s] for s in context], dtype=torch.long)[None, ...].to(trainer.device)
    y = model.generate(x, 100, temperature=1.0, do_sample=True, top_k=10)[0]
    completion = ''.join([train_dataset.itos[int(i)] for i in y])

跟进generate方法查看源码实现

    def generate(self, idx, max_new_tokens, temperature=1.0, do_sample=False, top_k=None):
        # TODO idx => [batch_size, 4]
        for _ in range(max_new_tokens):
            # TODO idx_cond不定长,只要低于最大上下文窗口即可, 如果文本超过block上下文窗口(输入序列最大长度),向前截取,取最后和block等长的
            idx_cond = idx if idx.size(1) <= self.block_size else idx[:, -self.block_size:]
            # TODO [batch_size, seq_len, 10]
            logits, _ = self(idx_cond)
            # TODO [batch_size, 10] 拿到最后一个block位置的作为下一个单词的概率得分分布
            # TODO temperature 温度系数, 默认为1,不对分数做任何缩放
            # TODO 温度系数越大,得分差距缩短,多样性更大,温度系数越小,得分差距加大,多样性更低
            logits = logits[:, -1, :] / temperature
            # TODO 只选取得分最大的topk个
            if top_k is not None:
                # TODO 输出最大值和最大值的索引
                # TODO v => [batch_size, topk]
                v, _ = torch.topk(logits, top_k)
                # TODO v[:, [-1]] => [batch_size, 1], topk的最小值, 将没有进入topk的全部改为-inf
                logits[logits < v[:, [-1]]] = -float('Inf')
            # TODO [batch_size, 10] 经过温度系数和topk压缩之后使用softmax转化为概率
            probs = F.softmax(logits, dim=-1)
            if do_sample:
                # TODO 根据概率作为权重采样其中一个,概率越大被采样到的概率越大
                # TODO 温度系数的大小会直接影响这个地方的采样权重,温度系数决定了在topk范围内的采样多样性
                # TODO topK会把没有进入topk的softmax概率转化为0,使得永远不能被采样到, topk决定了哪些会被采样到
                # TODO idx_next => [batch_size, 1]
                idx_next = torch.multinomial(probs, num_samples=1)
            else:
                # TODO 贪婪搜索 [batch_size, 1]
                _, idx_next = torch.topk(probs, k=1, dim=-1)
            # TODO 将预测的新单词拼接到原始输入上作为新的输入
            idx = torch.cat((idx, idx_next), dim=1)

        return idx

其中idx为自回归维护的输入,每次预测完下一个单词拼接到结尾覆盖更新idx

idx = torch.cat((idx, idx_next), dim=1)

在每次推理过程中只使用上下文窗口中最后一个block的输出作为下个单词的推理依据

logits = logits[:, -1, :] / temperature

在采样策略顺序上,先进行温度系数缩放,再进行top_k筛选,最后通过softmax压缩为概率,最终采用torch.multinomial进行多项式采样,top_k的实现是将所有非top_k的位置得分置为-inf,使得softmax压缩为0,只要存在其他非0权重,多项式采样永远不会将0采样出来,通过这种方法将非top_k在解码中剔除。

模型训练

基于《狂飙》语料灌入定义好的gpt-mini,每迭代500词打印一次模型推理的结果,我们给到例句“高启强被捕之后”,查看随着损失的收敛,模型预测接下来50个词的结果

迭代次数 损失 生成结果
0 7.696 高启强被捕之后操”徐”,姓”,越,尬””越上”越徐噎曲噎噎的猫上建验建,姓”越拼越津噎,”建,,的,,,上”上”越
1000 0.671 高启强被捕之后,高启强看着安静地洗得很好的摊子。音在顺道我们送礼物就去躲到了解决。门诊大夫都准备,你再帮忙!”“不
2000 0.288 高启强被捕之后的遗照片的下医院常车牌,刑警队员礼貌似乎跑断。耳机里的现场馆情不伦次,虽然快地想立即决定:将自己在将
3000 0.200 高启强被捕之后,安欣也十分爽快地答应帮忙——只要高家兄弟拿出三万元。正在高启强一筹莫展的时候,唐小龙介绍了一个门道
7500 0.107 高启强被捕之后,高启强终于弄明白了是怎么回事。原来唐家兄弟自从上次见到安欣和李响之后,便四处打听安欣的身份,确定了
8500 0.095 高启强被捕之后随即打开怀里的包,点出一本厚的法医学专著摆在桌上,得意地指了指,“看看。”安欣一怔:“你怎么看这个?

随着损失的收敛,生成结果逐渐变得连贯,尤其最后一句从语法和语义上基本挑不出什么问题,也预测出了训练集中的部分原文。此处只是一个例子在给定文本上从头训练一个简单的小规模gpt网络,如果期望更好的效果需要基于更大规模数据的预训练gpt模型。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容