写在前面:作者也是初学者,出错难免,另外本文尽可能讲清楚每一步矩阵形状的变化,不太涉及框架底层,可能较为基础,勿喷喵,欢迎捉虫或讨论
第0步 准备数据集
假设数据集:
The quick brown fox jumps over the lazy dog.
Artificial intelligence is transforming how we interact with technology.
Mount Fuji is the highest mountain in Japan, standing at 3,776 meters.
She smiled and said, 'Tomorrow will be a better day.'
读取数据集:
with open('input.txt', 'r', encoding='utf-8') as f:
text = f.read()
# here are all the unique characters that occur in this text
chars = sorted(list(set(text)))
vocab_size = len(chars)
插一条:超参数设置: batch_size = 4,block_size=8
这里batch_size(后序缩写为bas)指的是每次并行计算多少条数据,每条数据之间不产生任何干涉。
block_size(后序缩写为bls)指的是每条数据有多少个token。
我们随机从数据集中选取4条长度为8的连续字符串,作为接下来讲解流程的数据样本。
此时,数据样本的矩阵形状为4,8。内容是32个字符。
第一步,数据集的tokenize:
在这里,我们采取最简单的tokenizer:每个英语字母一个token。
tokenizer实现:
#建立字符和整数之间的关系
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
#执行编码和解码操作
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])
这一步结束后,我们的数据形状仍然为4,8。但这里每行的8个字符已经变成了8个数字-也就是token。
虽然在我们的模型中一个token对应一个字母,但为了严谨,在接下来的讲解中,我们将统一称呼为token。
接下来,我们将开始构建模型结构。
第二步,Embedding和位置编码
class GPTLanguageModel(nn.Module):
def __init__(self):
super().__init__()
self.token_embedding_table = nn.Embedding(vocab_size, n_embd)#embedding,这一步是为了将每一个token从一个数字转化成一条向量,让模型能更好地学习每个token之间的关系。vocab_size的意思是我们数据集中有多少个不同的字符。
self.position_embedding_table = nn.Embedding(block_size, n_embd)## 位置编码:为每个token的位置单独设置相同长度的一条向量,与 token 嵌入相加,向模型注入“第几个 token”这一顺序信息,弥补 Transformer 本身对序列顺序无感知的缺陷。
我们这里设置第三个超参数,n_embd=8。
现在我们就将一个token转化成了一条向量,我们的示例数据矩阵也就变成了4,8,8。
然后我们处理一下数据,将x作为后序注意力机制中的输入:
tok_emb = self.token_embedding_table(idx)
pos_emb = self.position_embedding_table(torch.arange(T, device=device))
x = tok_emb + pos_emb
这部分代码后序在组装GPT时在forward中实现,这里先暂时理解x作为注意力的输入,已经经过了token embedding和position embedding
第三步,也是最重要的一步,实现单头注意力机制(即超参数head_size=8(后序简写为hs),这在现代大语言模型中不太常见,但这里为简化设置如此),细分步骤比较多,仔细看注释。
class Head(nn.Module):
""" one head of self-attention """
def __init__(self, head_size):
super().__init__()
#首先计算QKV,这里QKV的权重矩阵由pytorch自动建立
#为什么是Linear,是因为QKV矩阵的计算本质上是对数据矩阵的矩阵变换
#矩阵乘法本质上是一种线性变换,具体可以参看3B1B的相关视频
self.key = nn.Linear(n_embd, head_size, bias=False)#权重矩阵形状[8,8](n_embd,hs),结果矩阵形状[4,8,8](bas,bls,hs)
self.query = nn.Linear(n_embd, head_size, bias=False)#同上
self.value = nn.Linear(n_embd, head_size, bias=False)#同上
#现代的大语言模型主流均采用decoder only架构,因此我们这里需要实现一个下三角矩阵进行掩码。其结果是将注意力分数矩阵的上三角部分(不含对角线)全部置为负无穷大,让模型对每个token处理时不受到其后序token的影响。
#“tril 是 [block_size, block_size] 的常量下三角矩阵,在推理时直接切片即可,无需重复构造。
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
#Dropout防止过拟合
self.dropout = nn.Dropout(dropout)
def forward(self, x):
#这里的T指的是Input的实际长度,也就是实际上的token长度,或者说第一步我们放进去的字符串长度,在训练阶段和我们现在的示例中,他是等于block_size的,但在推理的时候,他是小于等于block_size的
B, T, C = x.shape # C指Channel,这里等于n_embd
k = self.key(x) # (Bas, T, hs)
q = self.query(x) # (Bas, T, hs)
# 计算注意力分数(相似度)
wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5 # (Bas, T, hs) @ (Bas, hs, T) -> (Bas, T, T)(第一个T为query对应的实际长度,第二个为key对应的实际长度)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) # 屏蔽未来信息 (B, T, T)
wei = F.softmax(wei, dim=-1) # 归一化为注意力权重 (B, T, T)
wei = self.dropout(wei) # dropout
# 用注意力权重对 value 做加权求和
v = self.value(x) # (B, T, hs)
out = wei @ v # (B, T, T) @ (B, T, hs) -> (B, T, hs)
return out
下集预告: Multi-head attention,残差连接,FFN,以及最终体GPT的组装。