先看Gpt-2 的 骨架代码。
代码抄自openAI大神Andrej Karpathy最新发布的四小时gpt-2 复现视频。
B站连接如下:https://www.bilibili.com/video/BV12s421u7sZ?t=1653.2
非常推荐学习Andrej Karpathy大神的系列教学视频。
# GPT_2 骨架
class GPT(nn.Module):
def __init__(self, config):
super().__init__()
self.config = config
# 一个模块,可以使用键索引到子模块(像字典一样)
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(config.vocab_size, config.n_embd), # 标记嵌入的权重
wpe = nn.Embedding(config.block_size, config.n_embd), # 位置嵌入的权重
h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]), # 包含多个 Block 的模型列表
ln_f = nn.LayerNorm(config.n_embd), # 输出层归一化, gpt-2 需要一个额外的最终层规范
))
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) # 语言模型头的线性层,投射到词汇表大小(分类)
def forward(self, idx):
# idx 是输入的 token 索引,形状为 (B, T)
B, T = idx.size() # 获取 batch size【输入的样本数量】 和序列长度 【序列长度(T)指的是输入的 token 序列的长度。】
assert T <= self.config.block_size, f"Cannot forward sequence of length {T}, block size is only {self.config.block_size}"
# forward the token and position embeddings 【嵌入层】
pos = torch.arange(0, T, dtype=torch.long, device=idx.device) # 将位置索引转换为 long 类型【shape(T)】
pos_emb = self.transformer.wpe(pos) # 将位置索引 映射到 位置嵌入【shape(T, n_embd) 】
tok_emb = self.transformer.wte(idx) # 将 token 索引 映射到 token 嵌入 【shape(B, T, n_embd) 】
x = tok_emb + pos_emb # 将 token 嵌入 和 位置嵌入 相加【shape(B, T, n_embd) 】
# forward the blocks of the transformer
for block in self.transformer.h: # 遍历每个block
x = block(x) # 传递输入到block,得到输出
# 应用最终的层规范 【forward the final layernorm and classifier】
x = self.transformer.ln_f(x) # 应用最终的层规范
logits = self.lm_head(x) # 应用语言模型头,得到 logits
return logits
骨架中其他模块的代码在我整理好后会第一时间发布出来,需要的小伙伴可以关注一下。
现在进行详细的举例解释这个骨架代码执行过程:
详细说明一个批量大小为2的完整forward过程。假设我们有两个句子:
- “Hello, world!”
- “GPT is great.”
我们先将这些句子转换为token索引。假设词汇表如下:
["Hello", ",", "world", "!", "GPT", "is", "great", ".", "<EOS>"]
[ 0, 1, 2, 3, 4, 5, 6, 7, 8]
假设我们使用特殊的结束token "<EOS>"
,对应索引为8。
现在我们将两句话转换为token索引:
- “Hello, world!” ->
[0, 1, 2, 3, 8]
- “GPT is great.” ->
[4, 5, 6, 7, 8]
所以我们的批量输入是:
idx = torch.tensor([
[0, 1, 2, 3, 8], # 第一句话
[4, 5, 6, 7, 8] # 第二句话
])
输入 idx
的形状是 (2, 5)
,其中 2
是批量大小,5
是序列长度。
【注意】:在处理变长序列时,通常需要对输入进行填充(padding)操作,以确保每个批次中所有序列的长度相同。这样做的目的是为了使数据可以被批量处理,同时保持张量的形状一致。
填充操作(Padding) 假设我们有两个句子,第三个句子比较短:
- “Hello, world!” -> [0, 1, 2, 3, 8]
- “GPT is great.” -> [4, 5, 6, 7, 8]
- “Hi!” -> [9, 3, 8]
为了将这些句子放入同一个批次中,我们需要找到最长的序列,然后对其他序列进行填充,使其长度与最长序列相同。这里最长的序列长度是5(“Hello, world!” 和 “GPT is great.” 的长度)。我们可以使用一个特殊的填充token,例如 “”,其索引为10。填充后的序列如下:“Hello, world!” -> [0, 1, 2, 3, 8]
“GPT is great.” -> [4, 5, 6, 7, 8]
“Hi!” -> [9, 3, 8, 10, 10]填充后的输入张量 idx 将是:
python idx = torch.tensor([
[0, 1, 2, 3, 8], # 第一句话
[4, 5, 6, 7, 8], # 第二句话
[9, 3, 8, 10, 10] # 第三句话 ])输入 idx 的形状是 (3, 5),其中 3 是批量大小,5 是填充后的序列长度。
【【【【【处理填充的序列】】】】】
在实际的模型实现中,填充token通常不会对模型的实际输出产生影响。可以通过以下方式处理填充的序列:
- 掩码(Masking):掩码用于在计算注意力时忽略填充的部分。掩码张量的形状与输入序列相同,对填充位置赋值0,对有效token位置赋值1。
- 忽略填充token的损失计算:在训练过程中,计算损失时需要忽略填充token的贡献。可以通过掩码来实现这一点。
完整的forward过程
1. 使用nn.Embedding将token索引idx转换为嵌入向量
tok_emb = self.transformer.wte(idx)
假设嵌入维度 n_embd
为4,词汇表大小 vocab_size
为9。输入 idx
是形状 (2, 5)
的张量:
idx = [
[0, 1, 2, 3, 8],
[4, 5, 6, 7, 8]
]
nn.Embedding
将每个token索引转换为一个嵌入向量。假设嵌入矩阵 wte
看起来像这样:
wte = [
[0.1, 0.2, 0.3, 0.4], # "Hello"
[0.1, 0.1, 0.1, 0.1], # ","
[0.2, 0.2, 0.2, 0.2], # "world"
[0.3, 0.3, 0.3, 0.3], # "!"
[0.4, 0.4, 0.4, 0.4], # "GPT"
[0.5, 0.5, 0.5, 0.5], # "is"
[0.6, 0.6, 0.6, 0.6], # "great"
[0.7, 0.7, 0.7, 0.7], # "."
[0.8, 0.8, 0.8, 0.8] # "<EOS>"
]
嵌入后,tok_emb
的形状为 (2, 5, 4)
:
tok_emb = [
[
[0.1, 0.2, 0.3, 0.4], # "Hello"
[0.1, 0.1, 0.1, 0.1], # ","
[0.2, 0.2, 0.2, 0.2], # "world"
[0.3, 0.3, 0.3, 0.3], # "!"
[0.8, 0.8, 0.8, 0.8] # "<EOS>"
],
[
[0.4, 0.4, 0.4, 0.4], # "GPT"
[0.5, 0.5, 0.5, 0.5], # "is"
[0.6, 0.6, 0.6, 0.6], # "great"
[0.7, 0.7, 0.7, 0.7], # "."
[0.8, 0.8, 0.8, 0.8] # "<EOS>"
]
]
2. 使用nn.Embedding将位置索引转换为位置嵌入向量
在Transformer模型中,
pos
通常是根据输入序列的长度来创建的,而不是根据词汇表的大小。pos
是一个表示序列中每个位置的索引列表,它从0开始,依次递增,直到序列的最后一个位置。对于给定的输入序列,`pos` 的长度等于序列中token的数量,而不是词汇表的大小。
这里的
pos
是批次中每个序列的共享位置索引,用于将位置信息添加到每个token的嵌入中。每个位置索引将被广播到批次中的所有序列,以便与相应的token嵌入相加。
总结来说,
pos
是根据输入序列的长度来创建的,它表示序列中的位置信息,而不是词汇表中的token。词汇表大小通常用于创建token嵌入矩阵,而序列长度用于创建位置嵌入矩阵。
pos = torch.arange(0, T, dtype=torch.long, device=idx.device)
生成位置索引 pos
:
pos = [0, 1, 2, 3, 4]
然后,将位置索引转换为嵌入向量:
pos_emb = self.transformer.wpe(pos)
假设位置嵌入矩阵 wpe
看起来像这样:
wpe = [
[0.0, 0.1, 0.1, 0.1],
[0.2, 0.2, 0.2, 0.2],
[0.3, 0.3, 0.3, 0.3],
[0.4, 0.4, 0.4, 0.4],
[0.5, 0.5, 0.5, 0.5]
]
嵌入后,pos_emb
的形状为 (5, 4)
:
pos_emb = [
[0.0, 0.1, 0.1, 0.1],
[0.2, 0.2, 0.2, 0.2],
[0.3, 0.3, 0.3, 0.3],
[0.4, 0.4, 0.4, 0.4],
[0.5, 0.5, 0.5, 0.5]
]
3. 将token嵌入和位置嵌入相加,得到最终的嵌入表示
利用广播机制(Broadcasting)将token嵌入和位置嵌入相加,得到最终的嵌入表示。
广播机制是指当两个张量在进行数学运算时,如果它们的形状不完全相同,PyTorch会【自动“扩展”较小的张量】,使得它们的形状匹配。这种机制使得编写代码更加简洁和高效,而无需显式地调整张量的形状。
具体示例
假设
tok_emb
是输入序列的token嵌入,形状为(batch_size, seq_length, embedding_dim)
,而pos_emb
是位置嵌入,形状为(seq_length, embedding_dim)
。假设以下形状:
tok_emb
形状为(3, 5, 4)
,其中3
是批次大小,5
是序列长度,4
是嵌入维度。pos_emb
形状为(5, 4)
,即每个位置的嵌入向量。在广播机制下,PyTorch会自动扩展
pos_emb
以匹配tok_emb
的形状。具体来说,pos_emb
会扩展为
(1, 5, 4)
,然后在第一个维度上重复batch_size
次,使得它的形状变为(3, 5, 4)
。
x = tok_emb + pos_emb
这里需要广播位置嵌入,使其与token嵌入具有相同的形状 (2, 5, 4)
。相加后的 x
:
x = [
[
[0.1, 0.3, 0.4, 0.5], # token "Hello" + pos 0
[0.3, 0.3, 0.3, 0.3], # token "," + pos 1
[0.5, 0.5, 0.5, 0.5], # token "world" + pos 2
[0.7, 0.7, 0.7, 0.7], # token "!" + pos 3
[1.3, 1.3, 1.3, 1.3] # token "<EOS>" + pos 4
],
[
[0.4, 0.5, 0.5, 0.5], # token "GPT" + pos 0
[0.7, 0.7, 0.7, 0.7], # token "is" + pos 1
[0.9, 0.9, 0.9, 0.9], # token "great" + pos 2
[1.1, 1.1, 1.1, 1.1], # token "." + pos 3
[1.3, 1.3, 1.3, 1.3] # token "<EOS>" + pos 4
]
]
4. 通过transformer的多个块(Block的实例)传递这个嵌入表示
假设我们有2个transformer block,每个block进行自注意力机制和前馈网络等操作:
for block in self.transformer.h:
x = block(x)
假设经过所有block后,x
变成:
x = [
[
[0.15, 0.35, 0.45, 0.55],
[0.35, 0.35, 0.35, 0.35],
[0.55, 0.55, 0.55, 0.55],
[0.75, 0.75, 0.75, 0.75],
[1.35, 1.35, 1.35, 1.35]
],
[
[0.45, 0.55, 0.55, 0.55],
[0.75, 0.75, 0.75, 0.75],
[0.95, 0.95, 0.95, 0.95],
[1.15, 1.15, 1.15, 1.15],
[1.35, 1.35, 1.35, 1.35]
]
]
5. 应用最终的层归一化
x = self.transformer.ln_f(x)
层归一化通常不会改变张量的形状,只会对每个嵌入向量进行归一化处理。假设归一化后的 x
:
x = [
[
[0.14, 0.34, 0.44, 0.54],
[0.34, 0.34, 0.34, 0.34],
[0.54, 0.54, 0.54, 0.54],
[0.74, 0.74, 0.74, 0.74],
[1.34, 1.34, 1.34, 1.34]
],
[
[0.44, 0.54, 0.54, 0.54],
[0.74, 0.74, 0.74, 0.74],
[0.94, 0.94, 0.94, 0.94],
[1.14, 1.14, 1.14, 1.14],
[1.34, 1.34, 1.34, 1.34]
]
]
6. 通过线性层(lm_head)将嵌入表示转换为对词汇表中每个token的预测分数(logits)
logits = self.lm_head(x)
假设 vocab_size
为9,lm_head
是一个线性层,将每个嵌入向量转换为词汇表大小(9)的logits。最终的 logits
形状为 (2, 5, 9)
:
logits = [
[
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1],
[0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2],
[0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3],
[0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4]
],
[
[0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5],
[0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
[0.8, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7],
[0.9, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
]
]
每个位置的logits向量对应于词汇表中每个token的预测分数。通过softmax可以将这些logits转换为概率分布,从而选择最可能的下一个token。
7. 假设我们要预测第一个序列的下一个token,具体步骤如下:
- 选择序列:我们选择第一个序列。
- 选择位置:我们选择第一个序列的最后一个位置(索引为4)。
- 提取logits:提取最后一个位置的logits向量。
从给定的 logits
张量中,我们可以提取第一个序列最后一个位置的logits向量:
# 第一个序列的最后一个位置的logits向量
logits_vector = [0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4]
- 应用softmax:对这个logits向量应用softmax函数,将其转换为概率分布。softmax函数的公式为:
[ \text{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j} e^{x_j}} ]
应用softmax后,我们将得到一个概率分布。让我们计算这些概率:
logits_vector = np.array([0.5, 0.6, 0.7, 0.8, 0.9, 0.1, 0.2, 0.3, 0.4])
# 应用softmax
exp_logits = np.exp(logits_vector)
probabilities = exp_logits / np.sum(exp_logits)
# 打印概率分布
print(probabilities)
假设计算结果为:
probabilities = [0.0928, 0.1025, 0.1134, 0.1258, 0.1397, 0.0617, 0.0684, 0.0758, 0.0840]
- 选择token:根据概率分布选择最可能的下一个token,通常选择概率最大的token。我们在概率分布中查找最大值:
predicted_token = np.argmax(probabilities)
predicted_token
的值为 4
,因为 0.1397
是最大的概率。这意味着在给定的词汇表中,第一个序列的下一个最可能的token是索引为 4
的token。
- 批次大小 (2): 表示我们有两个输入序列。
- 序列长度 (5): 每个序列包含5个token。
- 词汇表大小 (9): 词汇表中有9个不同的token。
- 预测过程: 经过softmax转换后,确定概率最高的token,即索引为
4
的token。
通过以上步骤,我们可以确定给定序列的下一个最可能的token。
总结
整个forward过程从输入句子的token索引开始,经过嵌入、位置编码、transformer块的处理、层归一化,最终经过线性层生成预测的logits。这些logits可以用于进一步的推理,如生成下一个token或计算损失进行模型训练。
为什么要用位置嵌入? 位置嵌入(Position Embedding)在Transformer模型中起着关键作用,因为Transformer没有内置的顺序处理能力。传统的循环神经网络(RNN)和卷积神经网络(CNN)通过递归或局部连接自然地捕获序列中的顺序信息,但Transformer依赖于并行计算和自注意力机制,因此需要显式地注入位置信息。
位置嵌入的作用
自注意力机制(Self-Attention)可以同时关注输入序列中的所有位置,而不考虑这些位置的顺序。换句话说,Transformers中的自注意力机制在处理输入时是位置无关的。这种特性虽然带来了并行计算和更长依赖关系捕获的优势,但也带来了顺序信息丢失的问题。
为了在这种位置无关的机制中引入序列顺序信息,我们需要位置嵌入。位置嵌入为每个token添加了位置信息,使得模型能够区分相同token在不同位置的作用。具体来说:
- 捕获顺序信息:位置嵌入可以让模型知道每个token在序列中的相对或绝对位置,从而捕获序列中的顺序关系。
- 增强模型表达能力:通过将位置嵌入与token嵌入相加,模型可以学习到不仅仅是token之间的关系,还包括这些token在序列中的位置关系。
- 提高模型泛化能力:由于位置嵌入考虑了序列中的位置信息,因此模型可以更好地泛化到新的序列上。
位置编码的原理
位置编码是一种将序列中的位置信息编码为向量的方法。在Transformer中,位置编码被添加到输入序列的每个token中,以提供关于该token在序列中的位置的信息。
位置编码的实现
位置编码的实现通常使用三角函数或其他函数来生成位置编码向量。这些函数可以根据序列的长度和位置信息生成不同的位置编码值。
在Transformer中,位置编码通常被添加到输入序列的每个token中,以提供关于该token在序列中的位置的信息。具体来说,位置编码可以被添加到输入序列的嵌入向量中,或者作为额外的输入特征。