生成模型实战 | GPT-2(Generative Pretrained Transformer 2)详解与实现
生成模型实战 | GPT-2 详解与实现
- 0. 前言
- 1. GPT-2 架构和因果自注意力机制
- 1.1 GPT-2 架构
- 1.2 词嵌入和位置编码
- 1.3 因果自注意力
- 2. 从零开始构建 GPT-2XL
- 2.1 BPE 分词
- 2.2 GELU 激活函数
- 2.3 因果自注意力机制
- 2.4 构建 GPT-2XL 模型
- 3. 加载预训练权重生成文本
- 3.1 加载预训练权重
- 3.2 定义 generate() 函数生成文本
- 3.3 使用 GPT-2XL 进行文本生成
- 小结
0. 前言
GPT-2
(Generative Pretrained Transformer 2
) 是由 OpenAI
开发的大语言模型 (Large Language Model
, LLM
)。它标志着自然语言处理 (Large Language Model
, NLP
) 领域的一个重要里程碑,并为更复杂的模型的发展奠定了基础。GPT-2
是对 GPT-1
的改进,旨在根据给定的提示生成连贯且具有上下文相关性的文本,展示了在多个风格和主题中模仿人类生成文本的卓越能力。
GPT-2
基于 Transformer
架构。然而,与原始 Transformer 不同,GPT-2
是一个仅包含解码器的 Transformer
,这意味着该模型没有编码器部分。在将英语短语翻译成法语时,编码器捕捉英语短语的含义,并将其传递给解码器生成翻译。然而,在文本生成任务中,模型不需要编码器来理解不同的语言,而是基于句子中先前的词元生成文本,采用仅解码器架构。像其他 Transformer
模型一样,GPT-2
使用自注意力机制并行处理输入数据,显著提高了训练 LLM
的效率和效果。GPT-2
是在大型文本语料库上预训练的,主要任务是根据前面的单词预测句子中的下一个单词,使得模型能够学习到各种语言模式、语法结构以及知识。
在本节中,我们将学习如何从零开始构建 GPT-2XL
,这是 GPT-2
的最大参数量版本。之后,从 Hugging Face
提取预训练的权重,并将其加载到自定义的 GPT-2
模型中。使用自定义 GPT-2
模型输入提示 (prompt
) 来生成文本。此外,我们可以通过使用温度 (temperature
) 参数和 top-K
采样来控制生成文本的创意性。
1. GPT-2 架构和因果自注意力机制
GPT-2
采用的是完全基于解码器的 Transformer
架构(根据句子中之前的一个词生成文本,而不需要编码器来理解不同的语言),这与英法翻译器中的解码器组件类似。与双语模型不同,GPT-2
没有编码器,因此它的输出生成过程中不包括编码器衍生的输入,该模型完全依赖于序列中之前的词元来生成输出。在本节中,我们将讨论 GPT-2
的架构,并深入探讨其核心机制——因果自注意力机制。
1.1 GPT-2 架构
GPT-2
有四个不同的版本:小型 (S
)、中型 (M
)、大型 (L
) 和超大型 (XL
),每个版本的能力有所不同。我们主要关注最强大的版本——GPT-2XL
。最小的 GPT-2
模型有约 1.24
亿个参数,而 XL
版本则有约 15
亿个参数,是 GPT-2
系列中最强大的版本,拥有最多的参数。GPT-2XL
能够理解复杂的上下文,生成连贯且细致的文本。
GPT-2
由多个相同的解码器块组成,其中 XL
版本有 48
个解码器块,而其他三个版本分别有 12
、24
和 36
个解码器块。每个解码器块由两个不同的子层组成,第一个子层是因果自注意力层,第二个子层是一个逐位置全连接前馈网络。每个子层都包含层归一化和残差连接,以稳定训练过程。GPT-2
的架构如下图所示。
GPT-2
首先通过词嵌入和位置编码将一个词元序列的索引传递到输入嵌入,输入嵌入依次通过N个解码器块。之后,输出经过层归一化和一个线性层。GPT-2
的输出数量等于词汇表中独特词元的数量(所有 GPT-2
版本的词汇量均为 50,257
个词元)。该模型旨在基于序列中所有先前的词元预测下一个词元。
为了训练 GPT-2
,OpenAI
使用 WebText
数据集,该数据集是从互联网自动收集的。数据集包含了各种类型的文本,旨在涵盖广泛的人类语言和话题,该数据集包含大约 40GB
的文本。训练数据被分解为固定长度的序列(所有 GPT-2
版本的序列长度均为 1,024
个词元)并用作输入。在训练过程中,序列向右移动一个词元并用作模型的输出。由于模型使用因果自注意力机制,在此过程中序列中的未来词元被掩码隐藏,这实际上是训练模型基于序列中所有先前的词元来预测下一个词元。
1.2 词嵌入和位置编码
GPT-2
使用字节对编码 (Byte Pair Encoding
, BPE
) 的子词分词方法,将文本分解为单个词元(在大多数情况下是单词或标点符号,但对于不常见的单词可能分解为音节)。然后,这些词元被映射为介于 0
和 50,256
之间的索引,因为词汇表的大小为 50,257
。GPT-2
通过词嵌入将训练数据中的文本转换为向量表示,以捕捉其含义。
例如,短语 “this is a prompt
” 首先通过 BPE
分词方法转换成四个词元,['this', ' is', ' a', ' prompt']
。然后,每个词元都被表示为一个大小为 50,257
的独热编码变量。GPT-2
模型通过词嵌入层将它们压缩为长度较小的浮点值向量,例如 GPT-2XL
中的长度为 1,600
(其他三个版本的 GPT-2
的长度分别为 768
、1,024
和 1,280
)。通过词嵌入,短语 “this is a prompt
” 表示为一个大小为 4 × 1,600
的矩阵,而不是原始的 4 × 50,257
。词嵌入显著减少了模型的参数数量,并提高了训练效率。下图左侧展示了词嵌入的工作原理。
GPT-2
与其他 Transformer
模型一样,以并行方式处理输入数据,这使其无法识别输入的顺序。为了解决这个问题,我们需要向输入嵌入添加位置编码。GPT-2
采用了一种独特的位置编码方法,与原始 Transformer 的位置编码方法不同,GPT-2
的位置编码技术与词嵌入的方法相似。由于模型能够处理最多 1,024
个词元的输入序列,序列中的每个位置最初由一个与输入大小相同的独热向量表示。例如,在序列 “this is a prompt
” 中,第一个词元由一个独热向量表示,其中除第一个元素为 1
外,其余元素均为 0
,第二个词元也表示为类似向量,其中只有第二个元素为 1
,其余元素为 0
,以此类推。因此,短语 “this is a prompt
” 的位置表示是一个 4 × 1,024
的矩阵,如上图右上部分所示。
为了生成位置编码,序列的位置表示通过一个大小为 1,024 × 1,600
的线性神经网络进行处理,网络中的权重会随机初始化,并在训练过程不断优化。因此,序列中每个词元的位置信息将生成一个 1,600
维的向量,与词嵌入向量的维度相匹配。序列的输入嵌入是其词嵌入和位置编码的总和,如上图中下半部分所示。
1.3 因果自注意力
因果自注意力 (Causal self-attention
) 是 GPT-2
模型中的一个关键机制,使模型能够基于先前生成的词元序列生成文本。类似于英法翻译模型中,每个解码器层的第一个子层中的掩码自注意力,尽管实现方式有所不同。
需要注意的是,这里的“因果”概念指的是模型的能力,即确保对于给定词元的预测只能受到序列中位于它之前的词元的影响,遵循文本生成的因果(时间向前)方向,这对于生成连贯且上下文相关的文本输出至关重要。
自注意力允许输入序列中的每个词元关注同一序列中的所有词元。在 GPT-2
等 Transformer
模型中,自注意力使得模型在处理特定词元时能够权衡其他词元的重要性,从而捕捉句子中单词之间的上下文和关系。
为了确保因果性,GPT-2
的自注意力机制进行了修改,使得每个词元只能关注它自身以及序列中它之前的词元。具体而言,通过在注意力计算中对未来的词元(即在当前词元之后的词元)进行掩码屏蔽来实现的,从而确保模型在预测序列中的下一个词元时,无法“看到”或受到未来词元的影响。
例如,在短语 “this is a prompt
” 中,当模型使用单词 “this
” 来预测单词 “is
” 时,会在第一个时间步掩码屏蔽后面三个单词。为了实现这一点,在计算注意力分数时,未来词元对应的位置设置为负无穷大。经过 softmax
激活后,未来词元会分配为零权重,从而有效地将它们从注意力计算中移除。
接下来,让我们通过一个具体的例子来说明因果自注意力在代码中的工作原理。短语 “this is a prompt
” 的输入嵌入是经过词嵌入和位置编码后的 4 × 1,600
矩阵,然后将该输入嵌入通过 GPT-2
中的 N
个解码器层。在每个解码器层中,首先通过因果自注意力子层。
(1) 在因果自注意力子层中,输入嵌入通过三个神经网络,分别生成查询 Q
、键 K
和值 V
:
import torch
import torch.nn as nn# 创建输入嵌入 x
x=torch.randn((1,4,1600))
# 创建神经网络
c_attn=nn.Linear(1600,1600*3)
B,T,C=x.size()
# 将输入嵌入传递给神经网络,以创建 Q、K 和 V
q,k,v=c_attn(x).split(1600,dim=2)
# 打印 Q、K 和 V 的大小
print(f"the shape of Q vector is {q.size()}")
print(f"the shape of K vector is {k.size()}")
print(f"the shape of V vector is {v.size()}")
首先创建一个大小为 4 × 1,600
的矩阵,大小与 “this is a prompt
” 的输入嵌入相同。然后将输入嵌入通过三个神经网络,每个网络大小为 1,600 × 1,600
,以获取查询 Q
、键 K
和值 V
,输出结果如下所示:
(2) 将单个注意力头拆分成 25
个并行的注意力头。每个注意力头关注输入的不同部分或方面,使得模型能够捕捉更广泛的信息,并形成对输入数据的更详细和更具上下文的理解。因此,将得到有 25
组 Q
、K
和 V
:
# 将 Q、K 和 V 分成 25 个注意力头
hs=C//25
k = k.view(B, T, 25, hs).transpose(1, 2)
q = q.view(B, T, 25, hs).transpose(1, 2)
v = v.view(B, T, 25, hs).transpose(1, 2)
# 打印多头 Q、K 和 V 的大小
print(f"the shape of Q vector is {q.size()}")
print(f"the shape of K vector is {k.size()}")
print(f"the shape of V vector is {v.size()}")
输出结果如下所示,Q
、K
和 V
的形状变为 25 × 4 × 64
,这意味着我们有 25
个注意力头,每个注意力头有一组查询、键和值,它们的形状都是 4 × 64
:
(3) 计算每个注意力头中的缩放注意力分数:
import math
scaled_att = (q @ k.transpose(-2, -1)) *\(1.0 / math.sqrt(k.size(-1)))
print(scaled_att[0,0])
缩放注意力分数是每个注意力头中 Q
和 K
的点积,然后经过 K
维度的平方根进行缩放,K
的维度是 1,600/25 = 64
。缩放后的注意力分数在每个注意力头中形成一个 4 × 4
的矩阵,打印出第一个注意力头中的结果,结果如下所示:
(4) 对缩放注意力分数应用掩码,以隐藏序列中的未来词元:
# 创建一个掩码
mask=torch.tril(torch.ones(4,4))
print(mask)
# 通过将未来词元的值改为 -∞,将掩码应用于缩放后的注意力得分
masked_scaled_att=scaled_att.masked_fill(mask == 0, float('-inf'))
print(masked_scaled_att[0,0])
输出结果如下所示,掩码是一个 4 × 4
的矩阵:
掩码的下半部分(主对角线以下的值)为 1
,上半部分(主对角线以上的值)为 0
。当这个掩码应用到缩放后的注意力分数时,矩阵上半部分的值会变为 –∞–∞–∞。这样,当我们对缩放后的注意力分数应用 softmax
函数时,注意力权重矩阵的上半部分将填充为 0
:
import torch.nn.functional as F
att = F.softmax(masked_scaled_att, dim=-1)
print(att[0,0])
打印出第一个注意力头中的注意力权重,结果如下:
第一行表示在第一个时间步,词元 “this
” 仅关注自己,而不关注任何未来的词元。同样地,第二行中的词元 “this is
”会相互关注,但不会关注未来的词元 “a prompt
”。
(5) 接下来,计算每个注意力头中的注意力向量,它是注意力权重与值向量的点积。然后,将 25
个注意力头中的注意力向量会合并成一个单一的注意力向量:
y=att@v
y = y.transpose(1, 2).contiguous().view(B, T, C)
print(y.shape)
输出结果如下所示:
torch.Size([1, 4, 1600])
因果自注意力机制后的最终输出是一个 4 × 1,600
的矩阵,大小与因果自注意力子层的输入大小相同。解码器层的设计使得输入和输出具有相同的维度,这使得我们能够将多个解码器层堆叠在一起,以增强模型的表示能力,并在训练过程中实现层次化特征提取。
2. 从零开始构建 GPT-2XL
在本节中,首先学习如何使用 GPT-2
中的子词分词方法,即字节对编码 (Byte Pair Encoding
, BPE
) 分词器,将文本拆分为单个词元。然后了解 GPT-2
中前馈网络所使用的 GELU
激活函数。之后,实现因果自注意力机制,并将其与前馈网络结合,形成一个解码器块。最后,堆叠 48 个解码器块,构建 GPT-2XL
模型。
2.1 BPE 分词
GPT-2
使用了称为字节对编码 (Byte Pair Encoding
, BPE
) 的子词分词方法,因其在训练大语言模型中的应用而广为人知,BPE
的主要目标是将一段文本编码成一系列词元,以平衡词汇表大小和分词后文本的长度。
BPE
通过迭代合并数据集中最频繁的连续字符对来生成新的词元,直到达到所需的词汇表大小或无法进一步合并为止。这种方法在字符级和词级分词之间取得了平衡,能够在不显著增加序列长度的情况下减少词汇表大小,这对自然语言处理模型的性能至关重要。
(1) 在 bpe.py
文件中实现 BPE
分词器,限于篇幅,可以直接从 GitHub 下载。
(2) 使用模块 bpe.py
将文本转换为词元并转化为相应索引:
from bpe import get_encoderexample="This is the original text."
# 实例化 get_encoder() 类
bpe_encoder=get_encoder()
# 分词并打印词元
response=bpe_encoder.encode_and_show_work(example)
print(response["tokens"])
输出结果如下所示:
['This', ' is', ' the', ' original', ' text', '.']
需要注意的是,BPE
分词器不会将大写字母转换为小写字母。这会产生更有意义的标记化,但也使得独特词元的数量大幅增加。
(3) 使用模块 bpe.py
将词元映射为索引:
print(response['bpe_idx'])
输出结果如下所示,包含了与示例文本 “This is the original text.
” 中的六个词元相对应的六个索引:
[1212, 318, 262, 2656, 2420, 13]
可以基于这些索引恢复文本:
from bpe import BPETokenizer
# 实例化 BPETokenizer() 类
tokenizer = BPETokenizer()
# 使用分词器根据索引恢复文本
out=tokenizer.decode(torch.LongTensor(response['bpe_idx']))
print(out)
输出结果如下所示:
This is the original text.
2.2 GELU 激活函数
GELU
(Gaussian error linear unit
) 激活函数在 GPT-2
中的每个解码器块的前馈子层中使用。GELU 提供了线性与非线性激活特性的混合,已被证明可以增强深度学习任务(尤其是自然语言处理任务)中的模型性能。
GELU
提供了一种非线性的平滑曲线,与 ReLU
等其他激活函数相比,能够进行更加细致的调整。这种平滑性有助于更有效地优化神经网络,因为它为反向传播提供了更加连续的梯度。为了将 GELU
与常用的激活函数 ReLU
进行对比,首先定义 GELU()
类:
class GELU(nn.Module):def forward(self, x):return 0.5*x*(1.0+torch.tanh(math.sqrt(2.0/math.pi)*\(x + 0.044715 * torch.pow(x, 3.0))))
ReLU
函数并非处处可微,因为它存在一个拐点。相比之下,GELU
激活函数处处可微,并且提供了更好的学习过程。接下来,绘制 GELU
激活函数的图像,并将其与 ReLU
进行比较:
import matplotlib.pyplot as plt
import numpy as npgenu=GELU()
def relu(x):y=torch.zeros(len(x))for i in range(len(x)):if x[i]>0:y[i]=x[i]return y
xs = torch.linspace(-6,6,300)
ys=relu(xs)
gs=genu(xs)
fig, ax = plt.subplots(figsize=(6,4),dpi=300)
plt.xlim(-3,3)
plt.ylim(-0.5,3.5)
plt.plot(xs, ys, color = 'blue', label="ReLU")
plt.plot(xs, gs, "--", color = 'red', label="GELU")
plt.legend(fontsize=15)
plt.xlabel("values of x")
plt.ylabel("values of $ReLU(x)$ and $GELU(x)$")
plt.title("The ReLU and GELU Activation Functions")
plt.show()
此外,GELU
函数的形式使其能够更有效地建模输入数据的分布。它结合了线性和高斯分布建模的特性,这对于处理自然语言处理任务中复杂多变的数据尤其有利。这一特性有助于捕捉语言数据中的微妙模式,从而提高模型对文本的理解和生成能力。
2.3 因果自注意力机制
因果自注意力机制是 GPT-2
模型的核心元素。接下来,我们将使用 PyTorch
从零开始实现因果自注意力机制。
(1) 首先,定义构建 GPT-2XL
模型的超参数:
# 定义 Config() 类
class Config():def __init__(self):# 将模型超参数作为属性放入类中self.n_layer = 48self.n_head = 25self.n_embd = 1600self.vocab_size = 50257self.block_size = 1024 self.embd_pdrop = 0.1 self.resid_pdrop = 0.1 self.attn_pdrop = 0.1 # 实例化 Config() 类
config=Config()
其中,n_layer
属性表示我们构建的 GPT-2XL
模型将包含 48 个解码器层;n_head
属性表示在计算因果自注意力时将 Q
、K
和 V
分成 25
个并行头;n_embd
属性表示嵌入维度为 1600
:每个 token
将由一个 1600
维的向量表示;vocab_size
属性表示词汇表中有 50,257
个独特词元;block_size
属性表示输入序列最多包含 1,024
个词元;dropout
设置为 0.1。
(2) 定义 CausalSelfAttention()
类实现因果自注意力机制:
class CausalSelfAttention(nn.Module):def __init__(self, config):super().__init__()self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)self.c_proj = nn.Linear(config.n_embd, config.n_embd)self.attn_dropout = nn.Dropout(config.attn_pdrop)self.resid_dropout = nn.Dropout(config.resid_pdrop)# 创建一个掩码并将其注册为缓冲区self.register_buffer("bias", torch.tril(torch.ones(\config.block_size, config.block_size)).view(1, 1, config.block_size, config.block_size))self.n_head = config.n_headself.n_embd = config.n_embddef forward(self, x):B, T, C = x.size()# 通过神经网络传递输入嵌入,以获得 Q、K 和 Vq, k ,v = self.c_attn(x).split(self.n_embd, dim=2)# 将 Q、K 和 V 分成多个注意力头hs = C // self.n_headk = k.view(B, T, self.n_head, hs).transpose(1, 2) q = q.view(B, T, self.n_head, hs).transpose(1, 2) v = v.view(B, T, self.n_head, hs).transpose(1, 2) att = (q @ k.transpose(-2, -1)) *\(1.0 / math.sqrt(k.size(-1)))att = att.masked_fill(self.bias[:,:,:T,:T] == 0, \float('-inf'))# 计算每个头中的掩码注意力权重att = F.softmax(att, dim=-1)att = self.attn_dropout(att)y = att @ v# 将所有头中的注意力向量连接成一个单一的注意力向量y = y.transpose(1, 2).contiguous().view(B, T, C)y = self.resid_dropout(self.c_proj(y))return y
在 PyTorch
中,register_buffer
是一种将张量注册为缓冲区的方法。缓冲区中的变量不被视为模型的可学习参数,因此在反向传播过程中不会被更新。在以上代码中,我们创建了一个掩码并将其注册为缓冲区。这对于后续提取和加载模型权重有重要意义:在从 GPT-2XL
获取权重时,我们会省略掩码。
2.4 构建 GPT-2XL 模型
(1) 接下来,向因果自注意力子层中添加一个前馈网络,以形成一个解码器块:
class Block(nn.Module):def __init__(self, config):super().__init__()self.ln_1 = nn.LayerNorm(config.n_embd)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 = GELU(),dropout = nn.Dropout(config.resid_pdrop),))m = self.mlpself.mlpf=lambda x:m.dropout(m.c_proj(m.act(m.c_fc(x)))) def forward(self, x):# 块中的第一个子层是因果自注意力子层,带有层归一化和残差连接x = x + self.attn(self.ln_1(x))# 块中的第二个子层是前馈神经网络,具有 GELU 激活函数、层归一化和残差连接x = x + self.mlpf(self.ln_2(x))return x
(2) 堆叠 48
个解码器层,形成 GPT-2XL
模型的主体:
class GPT2XL(nn.Module):def __init__(self, config):super().__init__()self.block_size = config.block_sizeself.transformer = nn.ModuleDict(dict(wte = nn.Embedding(config.vocab_size, config.n_embd),wpe = nn.Embedding(config.block_size, config.n_embd),drop = nn.Dropout(config.embd_pdrop),h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),ln_f = nn.LayerNorm(config.n_embd),))self.lm_head = nn.Linear(config.n_embd,config.vocab_size, bias=False) def forward(self, idx, targets=None):b, t = idx.size()pos = torch.arange(0,t,dtype=torch.long).unsqueeze(0)tok_emb = self.transformer.wte(idx) pos_emb = self.transformer.wpe(pos)# 计算输入嵌入为词嵌入和位置编码的和x = self.transformer.drop(tok_emb + pos_emb)# 将输入嵌入通过 48 个解码器块for block in self.transformer.h:x = block(x)# 再次应用层归一化x = self.transformer.ln_f(x)# 添加线性层,使输出的数量等于唯一词元的数量logits = self.lm_head(x)loss = Noneif targets is not None:loss=F.cross_entropy(logits.view(-1,logits.size(-1)),targets.view(-1), ignore_index=-1)return logits, loss
(3) 接下来,通过实例化 GPT2XL() 类来创建 GPT-2XL 模型:
model=GPT2XL(config)
num=sum(p.numel() for p in model.transformer.parameters())
print("number of parameters: %.2fM" % (num/1e6,))
统计模型主体中的参数数量,输出结果如下:
number of parameters: 1557.61M
从上述结果可以看到,GPT-2XL
模型有超过 15
亿个参数。需要注意的是,这不包括模型末尾线性输出层的参数。根据下游任务的不同,我们可以为模型附加不同的输出层。在文本生成任务中,我们附加了一个线性输出层,以确保输出的数量等于词汇表中独特词元的数量。
需要注意的是,在 GPT-2
、ChatGPT
或 BERT
等大语言模型中,输出头 (output head
) 指的是模型的最终层,负责根据处理后的输入生成实际的输出,输出可以根据下游任务的不同而有所不同。在文本生成任务中,输出头通常是一个线性层,将最终的隐层状态转换为词汇表中每个词元的 logits
。这些 logits
通过 softmax
函数生成词汇表上的概率分布,用来预测序列中的下一个词元。对于分类任务,输出头通常由一个线性层和一个 softmax
函数组成。线性层将模型的最终隐层状态转换为每个类别的 logits
,softmax
函数则将这些 logits
转换为每个类别的概率。输出头的具体架构可能会根据模型和任务的不同而有所变化,但其主要功能是将处理过的输入映射到所需的输出格式(例如,类别概率、词元概率等)。
(4) 打印 GPT-2XL
模型的结构:
print(model)
输出结果如下:
3. 加载预训练权重生成文本
考虑到 GPT-2XL
模型的参数数量庞大,本节将使用 OpenAI
发布的 GPT-2
模型的预训练权重,加载预训练权重生成文本。
3.1 加载预训练权重
本节中,我们将使用 Hugging Face
的 transformers
库提取 GPT-2XL
中的预训练权重。
(1) 首先,使用 pip
命令安装 transformers
库:
$ pip install transformers
(2) 接下来,从 transformers
库中导入 GPT2
模型并提取 GPT-2XL
的预训练权重:
from transformers import GPT2LMHeadModel
# 加载预训练模型权重
model_hf = GPT2LMHeadModel.from_pretrained('gpt2-xl')
# 提取模型权重
sd_hf = model_hf.state_dict()
# 打印出原始 OpenAI GPT-2XL 模型的结构
print(model_hf)
输出结果如下所示:
将这个模型结构与上一小节构建的模型进行比较,可以看到它们完全相同,只是线性层被 Conv1d
层所替代。在前馈网络中,我们将输入中的值视为独立元素而非序列。因此,我们通常称其为 1D
卷积网络。OpenAI
的模型在我们使用线性层的地方使用 Conv1d
模块。因此,在从 Hugging Face
提取模型权重并将其放入我们自己的模型时,我们需要转置某些权重矩阵。
(3) 为了理解这一点,查看 OpenAI GPT-2XL
模型中第一个解码器块的前馈网络第一层中的权重,打印其形状:
print(model_hf.transformer.h[0].mlp.c_fc.weight.shape)
输出结果如下所示,可以看到,Conv1d
层中的权重矩阵是一个大小为 (1,600, 6,400)
的张量。
torch.Size([1600, 6400])
查看上一小节中我们构建的模型中相同权重矩阵的形状:
print(model.transformer.h[0].mlp.c_fc.weight.shape)
输出结果如下所示:
torch.Size([6400, 1600])
可以看到,我们模型中的线性层的权重矩阵是一个大小为 (6,400, 1,600)
的张量,这是 OpenAI GPT-2XL
中权重矩阵的转置矩阵。因此,在将 OpenAI GPT-2XL
模型中的权重矩阵放置到我们的模型中之前,我们需要转置所有 Conv1d
层中的权重矩阵。接下来,将 OpenAI GPT-2XL
模型中的参数命名为键:
keys = [k for k in sd_hf if not k.endswith('attn.masked_bias')]
需要注意的是,在以上代码中,我们排除了以 attn.masked_bias
结尾的参数。OpenAI GPT-2
使用它们来实现未来词元的掩码。由于我们在 CausalSelfAttention()
类中已经创建了自定义掩码,并将其作为缓冲区注册到 PyTorch
中,因此我们不需要加载 OpenAI
中以 attn.masked_bias
结尾的参数。
(4) 将自定义 GPT-2XL
模型中的参数命名为 sd
:
sd=model.state_dict()
(5) 接下来,提取 OpenAI GPT-2XL
中的预训练权重并将其放入自定义模型中:
# 找出 OpenAI 使用 Conv1d 模块而不是线性模块的层
transposed = ['attn.c_attn.weight', 'attn.c_proj.weight','mlp.c_fc.weight', 'mlp.c_proj.weight']
for k in keys:if any(k.endswith(w) for w in transposed):# 对于这些层,在将权重放入模型之前转置权重矩阵with torch.no_grad():sd[k].copy_(sd_hf[k].t())else:# 否则,直接从 OpenAI 复制权重并将其放入模型中with torch.no_grad():sd[k].copy_(sd_hf[k])
3.2 定义 generate() 函数生成文本
借助 OpenAI GPT-2XL
模型的预训练权重,我们将使用自定义的 GPT2
模型生成文本。在生成文本时,我们将向模型提供一系列与提示中的词元相对应的索引。模型预测与下一个词元对应的索引,并将预测添加到序列的末尾以形成新序列。然后,模型使用新序列再次进行预测。重复以上过程,直到模型生成了指定数量的新词元或对话结束(由特殊词元 <|endoftext|>
表示)。
GPT
模型使用来自各种来源的文本进行训练,GPT
中使用特殊词元 <|endoftext|>
来区分来自不同来源的文本。在文本生成阶段,遇到此特殊词元时必须停止对话。如果不这样做,可能会触发一个不相关的新话题,导致后续生成的文本与当前对话无关。
定义 sample()
函数,为当前序列添加一定数量的新索引。它以索引序列作为输入,输入到 GPT-2XL
模型中。它一次预测一个索引,并将新索引添加到当前序列的末尾。重复以上过程,直到达到指定的时间步数 max_new_tokens
,或者当预测的下一个词元为 <|endoftext|>
。
(1) sample()
函数使用 GPT-2XL
向一个正在进行的序列中添加新的索引,包含两个参数:temperature
和 top_k
,用来调节生成输出的新颖性,函数返回一个新的索引序列:
model.eval()
def sample(idx, max_new_tokens, temperature=1.0, top_k=None):# 生成指定数量的新索引for _ in range(max_new_tokens):if idx.size(1) <= config.block_size:idx_cond = idx else:idx_cond = idx[:, -config.block_size:]# 使用 GTP-2XL 预测下一索引logits, _ = model(idx_cond)logits = logits[:, -1, :] / temperature# 如果使用 top-K 采样,将低于前 K 个选择的 logits 设置为 –∞if top_k is not None:v, _ = torch.topk(logits, top_k)logits[logits < v[:, [-1]]] = -float('Inf')probs = F.softmax(logits, dim=-1)idx_next = torch.multinomial(probs, num_samples=1)# 如果下一个标记是 <|endoftext|>,则停止预测if idx_next.item()==tokenizer.encoder.encoder['<|endoftext|>']:break# 将新预测添加到序列中idx = torch.cat((idx, idx_next), dim=1)return idx
(2) 定义 generate()
函数,用于根据提示生成文本。首先将提示中的文本转换为索引序列,然后将该序列深入到 sample()
函数中,以生成一个新的索引序列,最后,generate()
函数将新的索引序列转换回文本:
def generate(prompt, max_new_tokens, temperature=1.0,top_k=None):if prompt == '':x=torch.tensor([[tokenizer.encoder.encoder['<|endoftext|>']]],dtype=torch.long)else:x = tokenizer(prompt)y = sample(x, max_new_tokens, temperature, top_k)out = tokenizer.decode(y.squeeze())print(out)
3.3 使用 GPT-2XL 进行文本生成
(1) generate()
函数支持无条件的文本生成,这意味着当提示为空时,模型将随机生成文本。这在创意写作中非常有用:生成的文本可以作为灵感或者创作的起点:
prompt=""
generate(prompt, max_new_tokens=100, temperature=1.0,top_k=None)
输出结果如下所示,输出连贯且语法正确,但可能在事实准确性上有所欠缺:
(2) 为了评估 GPT-2XL
是否能基于前面的词元生成连贯的文本,我们将使用提示 “I went to the kitchen and
” 并生成 10
个新词元。重复此过程五次,以判断生成的文本是否与上下文信息相关:
prompt="I went to the kitchen and"
for i in range(5):torch.manual_seed(i)generate(prompt, max_new_tokens=10, temperature=1.0,top_k=None)
输出结果如下所示,结果表明 GPT-2XL
能够根据给定的上下文生成相关的文本:
(3) 接下来,使用 “Lexington is the second largest city in the state of Kentucky
” 作为提示,并使用 generate()
函数生成最多 100
个新词元:
prompt="Lexington is the second largest city in the state of Kentucky"
generate(prompt, max_new_tokens=100, temperature=1.0,top_k=None)
输出结果如下所示,可以看到,这段文字的表达已经很连贯,尽管生成的内容可能不完全符合事实:
GPT-2XL
模型的基本训练目标是根据句子中前面的词元来预测下一个词元。从以上输出可以看出,模型达到了这一目标:生成的文本语法正确,看起来也合乎逻辑,展现了记住文本开头部分内容并生成与上下文相关的后续词汇的能力。
(4) 接下来,将探讨温度 (temperature
) 和 top-K
采样如何影响 GPT-2XL
生成的文本。将 temperature
设置为 0.9
,top_k
设置为 50
,其他参数保持不变,查看生成的文本:
prompt="Lexington is the second largest city in the state of Kentucky"
generate(prompt, max_new_tokens=100, temperature=0.9,top_k=50)
输出结果如下所示:
生成的文本似乎比之前更连贯。然而,内容并不完全准确,它编造了许多虚构内容。
小结
GPT-2
是由OpenAI
开发的大语言模型大语言模型 (Large Language Model
,LLM
),它代表了自然语言处理 (Large Language Model
,NLP
) 领域的重要突破,并为更为复杂的模型发展奠定了基础GPT-2
是一个仅由解码器构成的Transformer
模型,模型中没有编码器部分。与其他Transformer
模型类似,GPT-2
使用自注意力机制 (self-attention
) 来并行处理输入数据,从而显著提高了训练LLM
的效率和效果GPT-2
在位置编码 (positional encoding
) 方面采用了不同于经典 Transformer 中使用的方法,GPT-2
采用的位置信息编码方法与词嵌入 (word embeddings
) 相似GPT-2
的前馈子层 (feed-forward sublayers
) 使用了GELU
激活函数。GELU
结合了线性与非线性激活的特点,能够提高深度学习任务中的模型性能,尤其是在LLM
训练中- 我们从零开始构建一个
GPT-2
模型,并加载由OpenAI
发布的预训练权重,用于生成连贯的文本