mini版语言模型,逐行精讲

目录

一、写在前面

二、代码精读

2.1 数据预处理

2.2 准备数据集

2.3 训练与推理


一、写在前面

在正式阅读之前,笔者认为有必要先对文章作一些基本的说明,以供各位看官选择是否需要继续阅读。毕竟在信息爆炸的今天,读者的attention是如此宝贵,这可是Transformer教给我们的人生哲理!(bushi)

这篇博客的内容为作者本人参照油管大佬的视频实现的一个mini版语言模型。采用的并非注意力机制,效果也不比注意力机制好,但是个人感觉有很多适合小白的知识点,因此还是决定分享。后面可能会单独出一期注意力机制的实现案例。

下面先就作者本人的情况作以说明:本人是LLM萌新,有一点python和pytorch基础但也真的只有一点,所以在平时看代码的时候难免会被一个个小的知识点卡壳,于是本着打补丁的精神,逢山开路遇水架桥,哪里不会就gpt哪里,在自学的泥潭里摸爬滚打(哭)。

屏幕面前的你如果和我情况相似,那我强烈建议你看这篇博客,我将以一个完完全全的新手视角(毕竟作者本人水平也就这样...)带你看每一行代码,相信无论是一些细小的python知识点还是训练一个mini语言模型的流程,你都会有新的收获!

tips:

  • 这篇博客代码部分的整体编排方式如下:代码块+结果+知识点逐行解读

  • 每一个代码块的结果都会附在相应的代码之后,以供读者对结果有直观的了解和感受

  • 为了不影响代码的完整性和读者的阅读体验,知识点的逐行解读将放在代码块之后,且知识点均以无序列表的形式书写(就是前面那个小黑点)

  • 有条件的读者可以跟笔者的节奏自己动手实现,效果更佳

  • 笔者使用的工具为Jupyter notebook

话不多说,开干!

二、代码精读

2.1 数据预处理

!python -m wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

首先下载训练模型所用的数据集,这是一个1.06MBtxt文件,内容为莎士比亚作品节选,安全无毒,放心下载,部分内容如图所示:

mini版语言模型,逐行精讲_第1张图片

下载完成之后,会在当前目录列表出现一个'input.txt'文件,这将会是我们的数据集。

with open('input.txt','r',encoding='utf-8') as f:
     text = f.read()

这一行是python中常见的读取文件的方式,这里即读取input.txt文件,可以作为模板来记忆。

print('length:',len(text))    # length: 1115394

可以看到,文件总长度为1115394

print(text[:100])    # First Citizen:
                      # Before we proceed any further, hear me speak.
 ​
                      # All:
                      # Speak, speak.
 ​
                      # First Citizen:
                      # You

打印文本中的前100个字符。

  • text[:100] :表示text中的前100个字符,等价于text[0:100],左闭右开,即取下标从0到99的字符

切记左闭右开,后面还会反复强调!

 chars = sorted(list(set(text)))
 vocab_size = len(chars)         
 print(''.join(chars))        
 print(vocab_size)
 ​
 #  !$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
 #  65
  • chars = sorted(list(set(text))):

    • set(): 将字符串转化为一个字符集合,同时去除重复字符,从而得到文本中出现的所有不同字符

    • list(): 将得到的字符串转化为列表,以便进行排序

    • sorter(): 对字符列表进行排序,确保字符按照字母顺序排序

  • print(''.join(chars)):

    • separator.join(iterable):python中string的一个方法

      • separator指定了元素之间的分隔符,它将会出现在连接后的新字符串的每两个元素之间,默认为空字符串

      • iterable为需要连接的元素序列,可以是列表、元组、集合或其他可迭代对象

 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])
 ​
 print(encode("hii there"))
 print(decode(encode("hii there")))
 ​
 # [46, 47, 47, 1, 58, 46, 43, 56, 43]
 # hii there

屏幕面前的你第一次看到这个代码块是不是两眼一黑,不要慌,让我们拆解开来看。

  • stoi = { ch:i for i,ch in enumerate(chars)}

这行代码怎么看?

笔者在自学的这段时间里深刻认识了Python中元组、列表、字典的嵌套用法,这种嵌套十分灵活,导致一开始看的时候实在是摸不着头脑,经过一段时间的适应,笔者现在习惯从括号的形式来判断一个变量是什么类型了(毕竟python里面很多时候不写明数据类型)。

列表是中括号,字典是大括号。

stoi最后以大括号收尾,因此它是一个字典。字典里的内容是什么呢?看看它里面的,来一起看看它里面的内容:

ch:i for i,ch in enumerate(chars)这串代码的意思是:每次从字符串chars中取出一个字符记为ch,并按照顺序从0开始给取出的每个ch分配一个号码并记为i(就像lol里英雄的伴生皮肤一样,绑定在一起同时出现),然后用每一组chi作为一个元素加入字典stoi中。

懂了这行代码之后,下面的itos也是一样的道理,知识更换了键值对的顺序,由ch到i的映射转变为i到ch的映射,从而实现双向翻译。

  • encode = lambda s: [stoi[c] for c in s]

然后是这行代码。在python中呢,有两种定义函数的形式,一种是我们常见的def,另一种是这里的lambda表达式。二者的联系和区别呢,笔者认为下面这张图片解释的很清楚了。

mini版语言模型,逐行精讲_第2张图片

在此基础上来看这行代码是不是就很清晰明了了。

encode作为一个lambda匿名函数,它接收参数s,返回一个列表(因为外面是中括号),其中的元素是s中每个字符c在字典stoi中对应的数字

  • decode = lambda l: ''.join([itos[i] for i in l])

decode与encode相同,它接收参数l,然后以空字符串为分隔符,将数字列表l中的每个数字i在字典itos中对应的字符连接在一起组成一个字符串

2.2 准备数据集

 import torch
 data = torch.tensor(encode(text),dtype=torch.long)  # 把编码之后的文本转化为tentor向量,数据类型为long型
 print(data.shape,data.dtype)
 print(data[:100])                                   # 左闭右开,元素下标为0到99
 ​
 ​
 # torch.Size([1115394]) torch.int64
 # tensor([18, 47, 56, 57, 58,  1, 15, 47, 58, 47, 64, 43, 52, 10,  0, 14, 43, 44,
         53, 56, 43,  1, 61, 43,  1, 54, 56, 53, 41, 43, 43, 42,  1, 39, 52, 63,
          1, 44, 59, 56, 58, 46, 43, 56,  6,  1, 46, 43, 39, 56,  1, 51, 43,  1,
         57, 54, 43, 39, 49,  8,  0,  0, 13, 50, 50, 10,  0, 31, 54, 43, 39, 49,
          6,  1, 57, 54, 43, 39, 49,  8,  0,  0, 18, 47, 56, 57, 58,  1, 15, 47,
         58, 47, 64, 43, 52, 10,  0, 37, 53, 59])
  • data = torch.tensor(encode(text),dtype=torch.long)

这里我们调用前面的encode方法给text中的每个字符按顺序编号,然后将得到的数字列表转化为tensor向量,这是因为在pytorch中,数据只有转化为tensor向量后才能用于训练处理,dtype=torch.long指定存储的数据类型为long型

#将data分割为两部分数据集,训练集和验证集,其中训练集为前90%的数据
 ​
 n=int(0.9*len(data))
 train_data = data[:n]
 val_data = data[n:]
 block_size = 8
 print(train_data[:block_size + 1])
 ​
 # tensor([18, 47, 56, 57, 58,  1, 15, 47, 58])

block_size即选取的序列长度,train_data[:block_size + 1]即从train_data中选取下标从0到block_size(左闭右开)的元素,那么这里应该是一共取了9个元素。

细心的你可能就发现了,不是说block_size = 8吗,那为什么一定要多取一个元素呢?

这是因为,我们训练的时候做的是predict next token的任务,也就是:

拿第一个词预测第二个词;再拿前两个词预测第三个词;再拿前三个词预测第四个词......等到最后一轮呢,我们需要用前八个词去预测第九个。

因此必须要引入block_size+1个元素,这才算是完整的一轮训练。

 x = train_data[:block_size]
 y = train_data[1:block_size+1]
 # 时间步t是从0开始的
 for t in range(block_size):
     # 下面两行注意,x[:t+1]是左闭右开区间,最开始的当t=0时,context只包含了x[0],即x的第1个元素,也就是train_data中的第1个元素
     # 对应的,当t=0时,target只包含了y[0],而根据y的构造方式,y[0]恰恰是train_data中的第2个元素,刚好跟x错开一个
     context = x[:t+1]
     target = y[t]
     print(f"when input is {context} the target is {target}")
     
 ​
 # when input is tensor([18]) the target is 47
 # when input is tensor([18, 47]) the target is 56
 # when input is tensor([18, 47, 56]) the target is 57
 # when input is tensor([18, 47, 56, 57]) the target is 58
 # when input is tensor([18, 47, 56, 57, 58]) the target is 1
 # when input is tensor([18, 47, 56, 57, 58,  1]) the target is 15
 # when input is tensor([18, 47, 56, 57, 58,  1, 15]) the target is 47
 # when input is tensor([18, 47, 56, 57, 58,  1, 15, 47]) the target is 58

看到这个代码块,你应该大概清楚了我们这个mini的语言模型是如何运作的,采用自回归的训练方式,但这个自回归只是在一个小的block里,案例里是8,所以它的效果相比注意力机制并不会好

# 事实上在进行训练的时候,为了充分利用gpu,我们通常会选取像上个代码块里那样的很多个小段来同时独立训练
 torch.manual_seed(1337)   # 设置随机种子,在种子相同的情况下,每次运行程序都会得到相同的随机数序列,这使得实验具有了可重复性
 batch_size = 4            # 每一组有4个独立的句子段序列参与训练
 block_size = 8            # 每一个序列的最大长度是8
 ​
 def get_batch(split):
     data = train_data if split == 'train' else val_data        # 根据split的值选择数据来源
    
     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,y
 ​
 # 得到一个完整的训练集
 xb,yb = get_batch("train")
 print('inputs:')
 print(xb.shape)
 print(xb)
 print('targets:')
 print(yb.shape)
 print(yb)
 ​
 print('-------------')
 ​
 for b in range(batch_size):
     for t in range(block_size):
         context = xb[b,:t+1]           # 张量切片操作,取第b行的前t+1个元素(下标从0到t)
         target = yb[b,t]               # 张量切片操作,取第b行的第t个元素
         print(f"when input is {context.tolist()} the target is:{target}")

(结果比较长,放在一个新的代码块里,知识点在结果代码块后面,建议截图后对照上面的代码观看)

inputs:
 torch.Size([4, 8])
 tensor([[24, 43, 58,  5, 57,  1, 46, 43],
         [44, 53, 56,  1, 58, 46, 39, 58],
         [52, 58,  1, 58, 46, 39, 58,  1],
         [25, 17, 27, 10,  0, 21,  1, 54]])
 targets:
 torch.Size([4, 8])
 tensor([[43, 58,  5, 57,  1, 46, 43, 39],
         [53, 56,  1, 58, 46, 39, 58,  1],
         [58,  1, 58, 46, 39, 58,  1, 46],
         [17, 27, 10,  0, 21,  1, 54, 39]])
 -------------
 when input is [24] the target is:43
 when input is [24, 43] the target is:58
 when input is [24, 43, 58] the target is:5
 when input is [24, 43, 58, 5] the target is:57
 when input is [24, 43, 58, 5, 57] the target is:1
 when input is [24, 43, 58, 5, 57, 1] the target is:46
 when input is [24, 43, 58, 5, 57, 1, 46] the target is:43
 when input is [24, 43, 58, 5, 57, 1, 46, 43] the target is:39
 when input is [44] the target is:53
 when input is [44, 53] the target is:56
 when input is [44, 53, 56] the target is:1
 when input is [44, 53, 56, 1] the target is:58
 when input is [44, 53, 56, 1, 58] the target is:46
 when input is [44, 53, 56, 1, 58, 46] the target is:39
 when input is [44, 53, 56, 1, 58, 46, 39] the target is:58
 when input is [44, 53, 56, 1, 58, 46, 39, 58] the target is:1
 when input is [52] the target is:58
 when input is [52, 58] the target is:1
 when input is [52, 58, 1] the target is:58
 when input is [52, 58, 1, 58] the target is:46
 when input is [52, 58, 1, 58, 46] the target is:39
 when input is [52, 58, 1, 58, 46, 39] the target is:58
 when input is [52, 58, 1, 58, 46, 39, 58] the target is:1
 when input is [52, 58, 1, 58, 46, 39, 58, 1] the target is:46
 when input is [25] the target is:17
 when input is [25, 17] the target is:27
 when input is [25, 17, 27] the target is:10
 when input is [25, 17, 27, 10] the target is:0
 when input is [25, 17, 27, 10, 0] the target is:21
 when input is [25, 17, 27, 10, 0, 21] the target is:1
 when input is [25, 17, 27, 10, 0, 21, 1] the target is:54
 when input is [25, 17, 27, 10, 0, 21, 1, 54] the target is:39
  • ix =torch.randint(len(data) - block_size,(batch_size,))

    • torch.randint(low=0,high,size):

      • low:生成的随机数的下界,可选,默认为0

      • high:生成的随机数的上界,必选;同样遵从左闭右开,即[low,high)

      • size:生成的张量的形状

    • len(data) - block_size:表示可以选择的起始索引的最大值,这样确保选择的序列不会超过数据集边界

    • (batch_size,):这种写法是元组写法,表示生成的向量是一维的且包含batch_size=4个元素

    在python中,为了将元组数值作以区分,(batch_size,)表示元组,batch_size表示数值

  • x = torch.stack([data[i:i+block_size] for i in ix])

    根据随机生成的起始索引来构造输入输出数据集。

    下面假定取到的ix为2和15(其实应该是4个值,这里取两个作为说明),则: x = [[2,3,4,5,6,7,8,9],

    [15,16,17,18,19,20,21,22]] (切记左闭右开) y = [[3,4,5,6,7,8,9,10],

    [16,17,18,19,20,21,22,23]]

    • torch.stack(tensors,dim=0,out=None):

      • tensors:要堆叠的张量序列,可以是一个张量序列或者元组

      • dim:指定要沿着哪个维度堆叠,默认为0

      • out:可选参数,如果提供了此参数,则结果将存储在该张量中

那么这里呢,以x为例,就是我们把[2,3,4,5,6,7,8,9]和[15,16,17,18,19,20,21,22]在新的维度上堆叠起来从而成为一个整体,不然就是两个分离的个体了。

2.3 训练与推理

# 准备好数据集之后,接下来就可以构建神经网络了,这里我们选择构建的是NLP中最简单的Bigram模型
 import torch
 import torch.nn as nn
 from torch.nn import functional as F
 ​
 torch.manual_seed(1337)          # 设置和之前相同的随机种子,保证实验的可重复性
 ​
 class BigramLanguageModel(nn.Module):
     def __init__(self,vocab_size):                   # __init__方法,定义模型的构造,接收参数vocab_size
         super().__init__()                           # 调用父类方法初始化
         # super(BigramLanguageModel).__init__()      # 这种写法亦可,通常用于继承自多个父类的写法
         self.token_embedding_table = nn.Embedding(vocab_size,vocab_size)
     
     # 用于训练
     def forward(self,idx,targets=None):              # 定义模型的前向传播方法,参数idx和targets分别表示输入序列和目标序列
         logits = self.token_embedding_table(idx)     # logits形状为[4,8,65]
                                                      # 怎么理解这个[4,8,65]呢?
                                                      # 首先我们知道,输入xb是[4,8],表示有4个句子段,每个句子段里有8个词
                                                      # 那么经过实例化模型m的forward方法作用,我们找到了每个词对应的词嵌入向量,而且这个向量的维度为65维
                                                      # 于是相当于在每个句子段的每个词下面再开辟了一个65维的空间,因此就是[4,8,65]
                                                      # 这个词嵌入向量的实际意义是对下一个词的预测,模型的优化目标就是使得这个向量对下一个词的预测更准确
         if targets is None:
             loss = None
         else:
             # 改变logits的形状以使用交叉熵计算loss
             batch_size,seq_len,embedding_dim = logits.shape
             logits = logits.view(batch_size*seq_len,embedding_dim)
             targets = targets.view(batch_size*seq_len)
             loss = F.cross_entropy(logits,targets)       # 用交叉熵方法(多分类问题常用)计算loss,即预测结果与label之间的差值。
         return logits,loss
 ​
     # 用于推理,下面细讲
     def generate(self,idx,max_new_tokens):
         for _ in range(max_new_tokens):
             logits, loss = self(idx)                           # 获取对下一个词的预测,这里的logits为[4,8,65]
             logits = logits[:,-1,:]
             probs = F.softmax(logits,dim=-1)
             idx_next = torch.multinomial(probs,num_samples=1)
             idx = torch.cat((idx,idx_next),dim=1)              # 把每一步得到的词加入序列中以用来预测下一个词
         return idx
  • self.token_embedding_table = nn.Embedding(vocab_size, vocab_size):

    • 知识点:nn.Embedding(num_embeddings, embedding_dim),Pytorch中用于实现词嵌入功能的类。

      • num_embeddings:表示词汇表大小,即共有多少的词;

      • embedding_dim:词嵌入向量的维度,表示每个词被用一个含embedding_dim个元素的向量表示(有embedding_dim个特征)

那么在这里呢,就是说我们为这vocab_size,即65个词,每个词用一个vocab_size维的向量表示。

上面这段代码里,个人感觉最难理解的就是generate()这个方法了,不慌,一行一行来看:

  • for _ in range(max_new_tokens):

    这里规定了最多生成max_new_tokens个新元素,每一个元素取什么呢,就要看for循环里面的内容了:

    • logits, loss = self(idx)

      调用forward()方法得到logits和loss,这里由于没有传入targets因此loss=None,故只得到一个形状为[4,8,65]的logits(注意:targets为None时是不走forward()里面的else的,因此不会进行reshape)

    • logits = logits[:,-1,:]

      取出每个样本的最后一个时间步的预测结果。通俗地来讲,logits的形状为[4,8,65],就是说我们现在有4个句子对吧,每个句子最多有8个词,注意是最多,只有在最后一时间步才会是8个词。

      这里即取每个时间步下最后一个词的embedding,亦即对下一个词的预测

      这里还不能称为概率,因为每一行的和不为1,所以有了下面的softmax。

    • probs = F.softmax(logits,dim=-1)

      在最后一个维度上利用softmax方法把数字变为概率,softmax的具体实现有兴趣的读者可以参阅其他资料深入了解。

      于是现在的到了每个词后,下一个词对应是词汇表中65个词中哪一个的各自的概率。

    • idx_next = torch.multinomial(probs,num_samples=1)

      根据得到的概率进行抽样,从而得到下一个词。

      值得注意的是,这里只是根据得到的概率采样,而不是直接选取概率最高的词作为下一个词

现在让我们在这种原始状态下做个推理:

 m = BigramLanguageModel(vocab_size)
 logits,loss = m(xb,yb)
 print(logits.shape)
 print(loss)
 ​
 print(decode(m.generate(idx = torch.zeros((1,1),dtype=torch.long),max_new_tokens=100)[0].tolist()))
 ​
 ​
 # torch.Size([32, 65])
 # tensor(4.8786, grad_fn=)
 ​
 # Sr?qP-QWktXoL&jLDJgOLVz'RIoDqHdhsV&vLLxatjscMpwLERSPyao.qfzs$Ys$zF-w,;eEkzxjgCKFChs!iWW.ObzDnxA Ms$3

可以看到,现在的loss为4.8786,预测出来的序列呢,是完完全全的乱码,接下来我们通过训练让它有所改进。

optimizer = torch.optim.AdamW(m.parameters(),lr=1e-3)  # 创建优化器,它会计算参数的梯度并更新参数
 ​
 batch_size = 32                             # 定了一个稍大一点的batch_size,更符合实际场景
 for steps in range(10000):  
     xb,yb = get_batch('train')              # 获取训练数据集
 ​
     logits, loss = m(xb,yb)
     optimizer.zero_grad(set_to_none=True)   # 清空累积的梯度,以免之前计算的梯度对后续训练产生影响
     loss.backward()                         # 反向传播求梯度 
     optimizer.step()                        # 更新参数
 ​
     if (steps+1) %1000 == 0:
         print(loss.item())
         
         
 # 3.721843719482422
 # 3.1285109519958496
 # 2.8297815322875977
 # 2.5059468746185303
 # 2.6809186935424805
 # 2.505293130874634
 # 2.517559051513672
 # 2.469970941543579
 # 2.404806137084961
 # 2.5727508068084717

可以看到,经过10000轮的训练,loss也在逐步下降,从最初的4.8786到2.5727,现在再来看看预测结果如何。

print(decode(m.generate(idx = torch.zeros((1,1),dtype=torch.long),max_new_tokens=100)[0].tolist()))
 ​
 # GBEXme me JOFLEL:
 # 's&'e s, sonda t warowant ak.
 # QKETRS:
 # HMELAs heys, to aly f t, mmelol meal:
 # INCIA:
 # An yeak, LEmato tce be e hend te y yorewha t t s hs: t wedsme intsheshuine,
 # 'sowathepon pomsthive My I sethethor, l f ghato, wn n,'Slerar s thor llo:
 # Rind, tt's ke ar&xI toyrr tyowailoubet my,
 # Tovit 

虽然还不够好,但已经能明显感觉到比之前的乱码进步很多了(至少有断句和空格了)。

你可能感兴趣的:(gpt,自然语言处理,python,pytorch)