目录
一、写在前面
二、代码精读
2.1 数据预处理
2.2 准备数据集
2.3 训练与推理
在正式阅读之前,笔者认为有必要先对文章作一些基本的说明,以供各位看官选择是否需要继续阅读。毕竟在信息爆炸的今天,读者的attention是如此宝贵,这可是Transformer教给我们的人生哲理!(bushi)
这篇博客的内容为作者本人参照油管大佬的视频实现的一个mini版语言模型。采用的并非注意力机制,效果也不比注意力机制好,但是个人感觉有很多适合小白的知识点,因此还是决定分享。后面可能会单独出一期注意力机制的实现案例。
下面先就作者本人的情况作以说明:本人是LLM萌新,有一点python和pytorch基础但也真的只有一点,所以在平时看代码的时候难免会被一个个小的知识点卡壳,于是本着打补丁的精神,逢山开路遇水架桥,哪里不会就gpt哪里,在自学的泥潭里摸爬滚打(哭)。
屏幕面前的你如果和我情况相似,那我强烈建议你看这篇博客,我将以一个完完全全的新手视角(毕竟作者本人水平也就这样...)带你看每一行代码,相信无论是一些细小的python知识点还是训练一个mini语言模型的流程,你都会有新的收获!
tips:
这篇博客代码部分的整体编排方式如下:代码块+结果+知识点逐行解读
每一个代码块的结果都会附在相应的代码之后,以供读者对结果有直观的了解和感受
为了不影响代码的完整性和读者的阅读体验,知识点的逐行解读将放在代码块之后,且知识点均以无序列表的形式书写(就是前面那个小黑点)
有条件的读者可以跟笔者的节奏自己动手实现,效果更佳
笔者使用的工具为Jupyter notebook
话不多说,开干!
!python -m wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
首先下载训练模型所用的数据集,这是一个1.06MB的txt文件,内容为莎士比亚作品节选,安全无毒,放心下载,部分内容如图所示:
下载完成之后,会在当前目录列表出现一个'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里英雄的伴生皮肤一样,绑定在一起同时出现),然后用每一组ch
和i
作为一个元素加入字典stoi
中。
懂了这行代码之后,下面的itos
也是一样的道理,知识更换了键值对的顺序,由ch到i的映射转变为i到ch的映射,从而实现双向翻译。
encode = lambda s: [stoi[c] for c in s]
然后是这行代码。在python中呢,有两种定义函数的形式,一种是我们常见的def
,另一种是这里的lambda
表达式。二者的联系和区别呢,笔者认为下面这张图片解释的很清楚了。
在此基础上来看这行代码是不是就很清晰明了了。
encode作为一个lambda匿名函数,它接收参数s
,返回一个列表(因为外面是中括号),其中的元素是s
中每个字符c
在字典stoi
中对应的数字
decode = lambda l: ''.join([itos[i] for i in l])
decode与encode相同,它接收参数l
,然后以空字符串为分隔符,将数字列表l
中的每个数字i
在字典itos
中对应的字符连接在一起组成一个字符串
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]在新的维度上堆叠起来从而成为一个整体,不然就是两个分离的个体了。
# 准备好数据集之后,接下来就可以构建神经网络了,这里我们选择构建的是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
虽然还不够好,但已经能明显感觉到比之前的乱码进步很多了(至少有断句和空格了)。