让我们用 PyTorch 从头开始构建我们自己的 GPT 模型

今天,我们将离开Vision Transformer系列,并讨论构建生成预训练 Transformer (GPT) 的基本变体。

更准确地说,我们将构建一个自回归(二元语法)模型,即每次根据所有先前的标记生成一个标记。自回归模型通常会根据账户中的先前标记按顺序生成标记(字符或单词)。例如,在句子“我喜欢吃”中, 的后续标记可能是 等。

统计/经典自回归模型指定输出变量线性依赖于其自身的先前值和随机项(不完全可预测的项);

这个不完全可预测或随机的项可以非常松散地[1]与我们在“我喜欢吃”的例子中预测的下一个标记相关,我们通过让模型随机选择下一个标记进行预测(即等),来帮助模型在选择时不那么确定,我们将在本文后面了解这一点。

由于我们正在实现一个非常基础的自回归模型,所以我们将从头开始所有事情,使用一个数据集来生成威廉·莎士比亚风格的文本。这将是我迄今为止最长的文章,所以请深呼吸,随时可以休息一下。让我们开始吧!

内容

  1. 加载数据——创建数据批量加载器和数据分割。
  2. BigramLanguageModel — 语言模型编码
  3. 训练——训练模型并生成文本。

加载数据

# 导入 torch 特定模块
import torch 
import torch.nn as nn 
from torch.nn import functional as F 

# 我们首先下载莎士比亚 txt 文件(存储为名称 input.txt)
!wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/ input .txt */ 

# 读取 txt 文件(编码解码)
 text = open('input.txt','r',).read() 
vocab = sorted(list(set(text)))
encode = lambda s:[vocab.index(c) for c in s] 
decode = lambda l:[vocab[c] for c in l]

我们没有使用外部标记器,而是实现了自定义 lambda 函数来对我们的数据执行字符级标记化。

ids = encode( "我喜欢吃" ) 
txt = decoder( f "ids: {ids} " ) 
print ( f"txt: {txt} " ) print ( f"" .join(txt)) #输出:- ids : [ 21 , 1 , 50 , 47 , 49 , 43 , 1 , 58 , 53 , 1 , 43 , 39 , 58 ] txt: [ 'I' , ' ' , 'l' , 'i' , 'k ' , ' e' , ' ' , 't' , 'o' , ' ' , 'e' , 'a' , 't' ]我喜欢吃





按照 90/10 分割数据

x = int( 0.9 *len( text )) # text 是一个包含我们整个数据的大字符串 text = torch.tensor(encode( text ), dtype=torch. long ) train, val = text [:x], text [x:]

请记住,当我们在字符级别进行标记时,我们也将在字符级别生成,这里谨慎的方法是在语料库中创建批量随机句子,以输入到我们的模型中进行训练。

batch_size = 32  #batch_size - 我们将并行处理多少个独立序列?
 block_size = 8  #block_size = 预测的最大上下文长度

device = 'cuda'  if torch.cuda.is_available() else  'cpu' 

def  get_batch ( split ): 
    # 生成一小批输入 x 和目标 y
    数据 data = train if split == 'train'  else val   
    ix = torch.randint(len(data) - block_size, (batch_size,)) 
    x = torch.stack([data[ i: i+block_size] for i in ix]) 
    y = torch.stack([data[i+ 1 :i+block_size+ 1 ] for i in ix]) 
    return x.to(device), y.to(device) 

xb, yb = get_batch( 'train' )

批次按以下方式创建...

我们希望从语料库中获取块大小(8)的随机句子,因此我们生成批量大小(32)的索引(ix),并且对于每个索引,我们取接下来的 8 个标记 id 并为批量(ix)中的每个索引堆叠,但是我们的目标(y)生成的索引比 x(i+1, i + block_size + 1)多一个,因为我们需要预测序列中的下一个标记。

例子:-

ix = [33] 
for i in ix: 
    print (train[i:i+18]) 
    print (train[i+3:i+18+3]) # 我选择 +3 而不是 +1 只是为了举例
for i in ix: 
    print ( "" . join (decode(train[i:i+18])).replace( "\n" , "" )) 
    print ( "" . join (decode(train[i+3:i+18+3])).replace( "\n" , "" )) 

# 输出:-
 tensor([39, 52, 63, 1, 44, 59, 56, 58, 46, 43, 56, 6, 1, 46, 43, 39, 56, 1]) 
tensor([ 1, 44, 59, 56, 58, 46, 43, 56, 6, 1, 46, 43, 39, 56, 1, 51, 43, 1])
再往前走,听
我说,听我说

双元语言模型

二元语法是=2的一种,表示文本中两个连续词汇单位(例如单词或字符)的序列。

  • “我喜欢吃”的1-gram(Unigram) :["i", "like", "to", "eat"]
  • “我喜欢吃”的2-gram(Bigram) :["i like", "like to", "to eat"]
  • “我喜欢吃”的三元组(Trigram) :["i like to", "like to eat"]

由于我们正在执行自回归任务,因此我们需要以二元语法格式加载数据,就像我们在上面的代码块中所做的那样。

现在让我们进入本文的核心——多头注意力机制。由于我已经在 Vision Transformer 系列的十几篇文章中实现过这部分内容,所以我会尽量不浪费大家的时间,直接讲解这个概念。(天哪……我本来可以很容易地把这篇博客分成两部分,不过管他呢,反正我们就这样吧。)

让我们用 PyTorch 从头开始构建我们自己的 GPT 模型_第1张图片

我们看到,GPT 借鉴了《Attention is all you need》论文中提出的 Transformer 架构。然而,它的不同之处在于,它只堆叠了解码器部分中的多头注意力机制。

二元语言模型。

class  BigramLanguageModel (nn.Module): 

    def  __init__ ( self, vocab_size ): 
        super ().__init__() 
        # 每个 token 直接从查找表中读取下一个 token 的 logits
         self.token_embedding_table = nn.Embedding(vocab_size, embed_size) 
        self.possitional_embedding = nn.Embedding(block_size, embed_size) 
        self.linear = nn.Linear(embed_size, vocab_size) 
        self.block = nn.Sequential(*[Block(embed_size, num_head) for _ in  range (num_layers)]) 
        self.layer_norm = nn.LayerNorm(embed_size) 

    def  forward ( self, idx, target= None ): 
        B, T = idx.shape 
        # idx 和 target 都是 (B,T) 整数张量
        logits = self.token_embedding_table(idx) # (B,T,C)
         ps = self.possitional_embedding(torch.arange(T, device=device)) 
        x = logits + ps     #(B, T, C)
         logits = self.block(x)      #(B, T, c)
        logits = self.linear(self.layer_norm(logits))# 这假设 embed_size 和 Vocab_size 之间进行映射 

        B、T、C = logits.shape 
        logits = logits.view(B*T, C) 
        target = target.view(B*T) 
        loss = F.cross_entropy(logits, target)

        返回logits、loss

这里的输入 idx 是我们之前生成的形状为 (B, T) 的批次,其中 T 是块大小或令牌长度。

正向传递首先为每个形状为 (B, T, C) 的 token 生成嵌入。如上图所示,我们需要在 token 嵌入中添加位置嵌入。我们为输入 (idx) 创建嵌入,以便能够在固定的嵌入维度中表示 token 所包含的信息,但这并不能提供任何关于 token 位置(即位置)的信息,因此我们必须额外添加位置嵌入,以确保模型能够获取 token 位置的上下文信息。

nn.EmbeddingPyTorch 中有一个层,用于将离散的分类值(例如单词索引)映射到连续的稠密向量。该层以整数索引作为输入,每个索引代表一个唯一的分类项(例如,单词、token 或其他分类数据)。在内部,nn.Embedding它维护一个形状为 的嵌入矩阵num_embeddings, embedding_dim,以便为每个 token 创建一个稠密的表示。由于我们想要一个简化版的 GPT,因此我们直接使用 nn.Embeddings 来生成位置嵌入,而不是采用其他标准方法。

从这里开始就很简单了...我们有我们的块(一堆解码器模块),最后,我们生成与输入具有相同形状的新注意力矩阵,但其中每个标记都包含有关其之前的所有标记的一些信息。

最后,我们应用层范数(Layer Norm,这是稳定训练的常用方法),然后将其传递到线性层,将嵌入 C 映射到词汇维度。词汇维度就是 input.txt 中所有唯一字符的数量。定义预测准确性的常用方法之一是将模块(注意力模块)的输出与目标索引进行比较。

输出对数函数可以简单地理解为词汇量 V 上的概率分布,用于预测序列中的下一个词(目标词)。因此,我们使用交叉熵损失来生成一个损失函数,以确定我们的输出与目标词序列的接近程度。

我们已经介绍了 Bigram 的实现。现在来看看 Block 如何使用多头注意力机制 (MHA) 来创建注意力指标。

多头注意力机制

使用多头注意力的原因是我们可以直接将输入(B、T、embedding_size)传递给注意力块,但更快的方法是,不是直接生成 Q、K、V 并计算维度 embedding_size 的注意力权重,而是创建注意力模块的各个部分,分别计算注意力权重,然后在最后将它们连接起来。

类 Head(nn.Module):
    def  __init__(self,head_size):
        super()。__init__()
        self.head_size = head_size 
        self.key = nn.Linear(embed_size,head_size,bias= False)
        self.query = nn.Linear(embed_size,head_size,bias= False)
        self.value = nn.Linear(embed_size,head_size,bias= False)

    def  forward(self,x):
        B,T,C = x.shape k = 
        self.key(x)
        q = self.query(x)
        v = self.value(x) wei =
         [email protected](2,1)/self.head_size** 0.5

让我们用 PyTorch 从头开始构建我们自己的 GPT 模型_第2张图片

因此,按照上述逻辑,我们创建了一个单独的注意力头。现在让我们开始理解它。

添加位置嵌入后,我们得到一个维度为 (批量大小、token 长度、嵌入维度) 的输入。这里,输入中的每个 token 都由一个 embed_dim (64) 表示。但是,没有一个 token 包含其之前所有 token 的信息。

为了创建富含此类信息的嵌入,我们使用注意力机制,通过生成键、查询和值向量。

该类中的注意力机制Head旨在帮助模型在生成输出时关注输入序列的不同部分,这在语言建模等任务中特别有用。

keyquery和投影的概念value源于查询与序列中每个标记的上下文相关的信息。每个标记都用一个向量 ( x) 表示,通过将其线性变换为单独的keyqueryvalue向量,我们可以计算出序列中哪些标记应该相互关注。

当(查询) 和(键)q点缀时,结果 ( ) 告诉我们每个标记与其他标记之间的“相关性”或“注意力”得分。得分越高,表示一个标记在该上下文中对另一个标记越相关或“重要”。缩放因子可以防止这些得分过大,否则会导致 softmax 分布过于尖锐,难以优化。kwei1 / sqrt(head_size)

类 Head(nn.Module):
    def  __init__(self,head_size):
        super()。__init__()
        self.head_size = head_size 
        self.key = nn.Linear(embed_size,head_size,bias= False)
        self.query = nn.Linear(embed_size,head_size,bias= False)
        self.value = nn.Linear(embed_size,head_size,bias= False)
        self.register_buffer('tril',torch.tril(torch.ones(block_size,block_size)))
        self.dropout = nn.Dropout(dropout)

    def  forward(self,x):
        B,T,C = x.shape 
        k = 
        self.key(x) q = self.query(x)v = self.value( x
         )
        wei = [email protected](2,1)/self.head_size** 0.5         wei = wei.masked_fill(self.tril[:T, :T] == 0 , float ( '-inf' ))         wei = F.softmax(wei, dim= 2 )     # (B , block_size, block_size)         wei = self.dropout(wei)         out = wei@v return out



因果掩码tril的作用是确保每个 token 只能“看到”自身和之前的 token。这对于文本生成等自回归任务至关重要,因为模型在预测下一个 token 时不应考虑未来的 token。将不相关的位置设置为负无穷,masked_fill会使它们在 softmax 之后变为零,因此它们不会参与最终的注意力计算。这可以防止模型在我们想要预测未来 token 时,通过考虑未来的 token 来作弊。最后,我们对权重和价值指标进行点积,并返回输出。

您可以检查此示例输出以更好地了解发生的转换:

q = torch.randint( 10 , ( 1 , 3 , 3 )) 
v = torch.randint( 10 , ( 1 , 3 , 3 )) 
print ( "查询:\n" ,q) 
print ( "值:\n" ,v) 
wei = [email protected]( 2 , 1 )/ 3 ** 0.5 
print ( "权重:\n" , wei) 
tril = torch.tril(torch.ones( 3 , 3 )) 
print ( "三角指标:\n" ,tril) 
wei = wei.masked_fill(tril == 0 , float ( '-inf' )) 
print ( "掩蔽权重\n" , wei) 
print ( "Softmax ( e^-inf = 0 )\n" , F.softmax(wei, dim= 2 )) 

#输出:-
查询:
张量([[[ 2 , 8 , 8 ],
         [ 4 , 2 , 4 ],
         [ 1 , 2 , 9 ]]])
值:
张量([[[ 9 , 5 , 7 ],
         [ 3 , 1 , 4 ],
         [ 6 , 2 , 9 ]]])
权重:
张量([[[ 65.8179 , 26.5581 , 57.7350 ],
         [ 42.7239 , 17.3205 , 36.9504 ],
         [ 47.3427 , 23.6714 , 52.5389 ]]])
三角度量:
张量([[ 1. , 0. , 0. ],
        [ 1. , 1. , 0. ], 
        [ 1. , 1. , 1. ]])
掩蔽权重
张量([[[ 65.8179 , -inf, -inf], 
         [ 42.7239 , 17.3205 , -inf], 
         [ 47.3427 , 23.6714 ,52.5389 ]]]) 
Softmax(e ^ - inf = 0)
张量([[[ 1.0000e+00 , 0.0000e+00 , 0.0000e+00 ], 
         [ 1.0000e+00 , 9.2777e-12 , 0.0000e+00 ], 
         [ 5.5073e-03 , 2.8880e-13 , 9.9449e-01 ]]])

由于我们使用多头注意力,因此我们将这样实现它:

类 MultiHeadAttention (nn.Module): 
    def  __init__ ( self , head_size, num_head ): 
        super ().__init__() 
        self .sa_head = nn.ModuleList([Head(head_size) for _ in range(num_head)]) 
        self .dropout = nn.Dropout(dropout) 
        self .proj = nn.Linear(embed_size, embed_size) 

    def  forward ( self , x ): 
        x = torch.cat([head(x) for head in  self .sa_head], dim= - 1 ) 
        x = self .dropout( self .proj(x))
        返回x

在这里,我们将输入 x (B, T, E) 传递给不同的注意力头,每个注意力头返回大小为 (B, T, head_size) 的最终向量,其中头部大小 = E (64) / num heads (4) = 16,正如我们在范围 (num_head(4)) 的 for 循环中所做的那样,我们将其连接回其原始大小 (B, T, 4*16)。

在嵌入维度更大的规模上,多头注意力被认为更快、更高效。

连接后,我们将最终输出传递到线性投影层,这样做的目的是使最终向量中的嵌入能够进一步交流它们在注意力权重计算过程中彼此学到的知识。之后,它被传递到dropout层并返回。

综合起来,标准解码器块的实现如上图 1 所示。

让我们用 PyTorch 从头开始构建我们自己的 GPT 模型_第3张图片

类 FeedForward(nn.Module):
    def  __init__(self,embed_size):
        super()。__init__()
    
        self.ff = nn.Sequential(
              nn.Linear(embed_size,4 *embed_size),
              nn.ReLU(),
              nn.Linear(4 *embed_size,embed_size),
              nn.Dropout(dropout)
        )

    def  forward(self,x):
        返回self.ff(x)
    
类 Block(nn.Module):
    def  __init__(self,embed_size,num_head):
        super()。__init__()
        head_size = embed_size // num_head 
        self.multihead = MultiHeadAttention(head_size,num_head)
        self.ff = FeedForward(embed_size)
        self.ll1 = nn.LayerNorm(embed_size)
        self.ll2 = nn.LayerNorm(embed_size)

    def  forward(self,x):
        x = x + self.multihead(self.ll1(x)) 
        x = x + self.ff(self.ll2(x))
        返回x

头部尺寸的计算如前所述,输入只是通过一个层规范,然后是我们的多头注意力网络,然后是另一个层规范,最后通过前馈网络。

回到二元模型

class  BigramLanguageModel (nn.Module): 

    def  __init__ ( self, vocab_size ): 
        super ().__init__() 
        # 每个 token 直接从查找表中读取下一个 token 的 logits
         self.token_embedding_table = nn.Embedding(vocab_size, embed_size) 
        self.possitional_embedding = nn.Embedding(block_size, embed_size) 
        self.linear = nn.Linear(embed_size, vocab_size) 
        self.block = nn.Sequential(*[Block(embed_size, num_head) for _ in  range (num_layers)]) 
        self.layer_norm = nn.LayerNorm(embed_size) 

    def  forward ( self, idx, target= None ): 
        B, T = idx.shape 
        # idx 和 target 都是 (B,T) 整数张量
        logits = self.token_embedding_table(idx) # (B,T,C)
         ps = self.possitional_embedding(torch.arange(T, device=device)) 
        x = logits + ps     #(B, T, C)
         logits = self.block(x)      #(B, T, c)
         logits = self.linear(self.layer_norm(logits)) # 假设在 head_size 和 Vocab_size 之间进行映射
        if targets is  None : 
            loss = None 
        else : 
            B, T, C = logits.shape 
            logits = logits.view(B*T, C) 
            targets = targets.view(B*T) 
            loss = F.cross_entropy(logits, targets) 

        return logits, loss 

    def  generate ( self, idx, max_new_tokens ): 
        # idx 是当前上下文中的 (B, T) 索引数组
        for _ in  range (max_new_tokens): 
            # 将 idx 裁剪为最后的 block_size 个标记
            crop_idx= idx[:, -block_size:].to(device) 
            # 获取预测
            logits, loss = self(crop_idx) 
            # 仅关注最后的时间步
            logits = logits[:, - 1 , :] # 从 (B, T, C) 变为 (B, C) 
            # 应用 softmax 获取概率
            probs = F.softmax(logits, dim=- 1 ) # (B, C) 
            # 我们从滤波分布中抽取一个索引
            idx_next = torch.multinomial(probs, num_samples= 1 ).to(device) 
            # 将抽样索引附加到正在运行的序列
            idx = torch.cat((idx, idx_next), dim= 1 )# (B, T+1)
        返回idx

这里我们看到通过层数的范围来调用Sequential层下的Block。

正在生成令牌...

首先,将单维 idx 张量(我们标记的索引)以及我们想要生成的新标记的最大数量传递给generate函数。由于我们的模型是为块大小 8 构建的,我们一次只能传递 8 个标记,因此我们裁剪了 idx 中的最后 8 个标记(如果 idx 长度小于块大小,则选择所有标记)。

我们将 crop idx 传递给我们的 BigramLanguageModel,因为我们生成的 logits 具有目标标记的可能分布,我们只对最后一个标记感兴趣,因为目标的最后一个标记(y)是序列(x)中的下一个(在批量加载器部分中解释)。

我们现在得到的 logits 向量形状为 (B, C),其中 C 是词汇大小,表示最后一个词条索引在整个词汇表中的概率分布。现在我们只需对其应用 softmax 函数,即可将该向量转换为概率向量(即元素和 = 1)。

现在,还记得我们在文章一开始讨论的不完全可预测或随机项,以及我们如何让模型随机选择序列中的下一个标记吗?为此,我们使用torch.multinomial,这是一种统计策略,用于从给定的概率分布中抽取样本。在这里,它根据指定的概率随机抽取索引。

然后我们最终得到预测的下一个 idx,将其与前一个 idx 连接起来,然后继续 for 循环,根据前一个索引不断生成下一个索引,直到达到最大标记。

训练

幸运的是,训练部分非常简单。

m = BigramLanguageModel( 65 ).to(device) 

optimizer = torch.optim.AdamW(m.parameters(), lr= 1e-3 ) 

# 训练模型,因为我不会不战而降
batch_size = 32 
for epoch in  range ( 5000 ): 

    # 打印训练和验证损失
    if epoch% 1000 == 0 : 
        m. eval () 
        Loss= 0.0
         Val_Loss = 0.0 
        for k in  range ( 200 ): 
            x, y = get_batch( True ) 
            
            val_ , val_loss = m(x, y) 
            x1, y1 = get_batch( False ) 

            _, train_loss = m(x1, y1)             
            Loss += train_loss.item() 
            Val_Loss += val_loss.item() 
        av​​g_loss = Val_Loss/(k+ 1 ) 

        avg_train_loss = Loss/(k+ 1 ) 
        m.train() 
        
        print ( "Epoch: {} \n 验证损失为:{} 损失为:{}" . format (epoch, avg_loss, avg_train_loss)) 
    # 前向
    data, target = get_batch( False ) 
    logits, loss = m(data, target) 
    # 后向
    优化器.zero_grad(set_to_none = True)
    loss.backward()
    优化器.step()

在这里,我们将训练进行 5000 个时期,在 4 GB VRAM Nvidia RTX 3050 中,这大约需要 2 分钟。

我们从正向传递开始,从get_batch() 获取批次并将其传递给 BigramLanguageModel。设置optimizer.zero_grad(),执行loss.backward(),并执行optimizer.step(),我们使用了 AdamW 优化器,它足以满足我们的需求。

现在正是您等待的时刻……

我们为以下句子创建一个张量:

ids = torch.tensor(encode( "我喜欢吃食物" ), dtype=torch.long).unsqueeze( 0 )

ids 的形状为 (1, 18)(批量大小,标记)。从 18 个标记(代表词汇表中的索引)开始,生成另外 2000 个字符。为了便于理解,我们的词汇表是 input.txt 中所有唯一字符的集合,我们之前在数据加载部分中实现了这个集合,即vocab = sorted(list(set(text)))

打印(“”。加入(解码(m.generate(torch.zeros([ 1 , 1 ],dtype=torch.long),max_new_tokens= 2000)[ 0 ].tolist())))
输出:-

我喜欢吃东西,没有 BANIO:
在这里,我和
已婚的女人们握手恳求,因为你们在这沼泽之间,
就像坟墓一样,是他的太阳。

路森修:
去吧,多么双重的情妇啊。

尾巴,别忘了,豌豆帽你;——是主人,你指挥着为slawake跳跃和离开这沙发;
彼特鲁乔?
可怜的这只公鸡从未如此做老路,我评论和curh
并blate确保poccient你miad曾经a参加,
Unory speitied在哪里buzzarr'd formorns,
Pitedame,
海滩,和我firit。

安藤:
哦,美德鹦鹉,那是至少,不是因为吮吸可以mighreature好;你的,
我会在counteent之后toence,
Signior到paptista?
你还敢跟我玩吗?

比安卡:
哪里有女人会让他偷偷摸摸地溜走。

普洛斯彼罗:
我,既然已经成功了,
这一切都不是真的,因为岛上的一切都很好;我穿着这身行头;
那就结婚吧。

特拉尼奥:
是的,伙计。

格鲁乔:
我肯定能​​证明我出身。

普洛斯彼特鲁乔:
这东西是我的;它就是我的。

让我们用 PyTorch 从头开始构建我们自己的 GPT 模型_第4张图片

我知道……这说不通。但我们必须意识到,语言模型并非仅仅基于莎士比亚作品的数据集进行训练。这需要强大的 GPU 算力、更先进的分词技术以及海量的数据集。

由于我们在一个非常小的模型上进行训练,我们的表现还不错,输出仍然有意义,并且已经学会使用实际的英语单词而不是生成随机的胡言乱语。

您可以尝试针对不同的数据集和 Token Size、Batch Size、Number of Layers 等试验该模型。

谢谢你!

本博客的主要目标是向您详细解释如何从头开始构建语言模型并在数据集上对其进行训练......好吧,现在您知道了!!!

在此,我由衷感谢你给予我学习的机会并阅读本文,希望你喜欢这篇文章。完整的代码可以在我的 让我们用 PyTorch 从头开始构建我们自己的 GPT 模型_第5张图片

你可能感兴趣的:(PyTorch,GPT,大模型,机器学习,深度学习,计算机视觉,人工智能)