前言
近期,除了研究ChatGPT背后的各种技术细节 不断看论文(至少100篇,100篇目录见此:ChatGPT相关技术必读论文100篇),还开始研究一系列开源模型(包括各自对应的模型架构、训练方法、训练数据、本地私有化部署、硬件配置要求、微调等细节)
本文一开始是作为此文《ChatGPT技术原理解析:从RL之PPO算法、RLHF到GPT4、instructGPT》的第4部分,但随着研究深入 为避免该文篇幅又过长,将把『第4部分 开源项目』抽取出来 独立成本文,然后不断续写本文直至成了一个系列
毕竟我上半年的目标之一,便是把ChatGPT涉及的所有一切关键技术细节,以及相关的开源项目都研究的透透的,故过程中会不断产出一篇篇新文章出来
第一部分 LLaMA的代码级解读:RMSNorm/SwiGLU/RoPE/Transformer
1.1 Meta发布LLaMA((7B 13B 33B 65B):参数少但多数任务的效果好于GPT3
一直致力于LLM模型研究的国外TOP 3大厂除了OpenAI、Google,便是Meta(原来的Facebook)
Meta曾第一个发布了基于LLM的聊天机器人——BlenderBot 3,但输出不够安全,很快下线;再后来,Meta发布一个专门为科学研究设计的模型Galactica,但用户期望过高,发布三天后又下线
23年2.24日,Meta通过论文《LLaMA: Open and Efficient Foundation Language Models》发布了自家的大型语言模型LLaMA(这是LLaMA的GitHub代码地址,这是解读之一),有多个参数规模的版本(7B 13B 33B 65B)
LLaMA只使用公开的数据(总计1.4T即1,400GB的token,其中CommonCrawl的数据占比67%,C4数据占比15%,Github、Wikipedia、Books这三项数据均都各自占比4.5%,ArXiv占比2.5%,StackExchange占比2%),论文中提到
When training a 65B-parameter model, our code processes around 380 tokens/sec/GPU on 2048 A100 GPU with 80GB of RAM.
This means that training over our dataset containing 1.4T tokens takes approximately 21 days
且试图证明小模型在足够多的的数据上训练后,也能达到甚至超过大模型的效果
- 比如13B参数的版本在多项基准上测试的效果好于2020年的参数规模达175B的GPT-3
- 而对于65B参数的LLaMA,则可与DeepMind的Chinchilla(70B参数)和谷歌的PaLM(540B参数)旗鼓相当
- 且Meta还尝试使用了论文「Scaling Instruction-Finetuned Language Models」中介绍的指令微调方法,由此产生的模型LLaMA-I,在MMLU(Massive Multitask Language Understanding,大型多任务语言理解)上要优于Google的指令微调模型Flan-PaLM-cont(62B)
1.2 代码级解读:LLaMA的模型架构——RMSNorm/SwiGLU/RoPE/Transformer
1.2.1 项目环境依赖:torch、fairscale、fire、sentencepiece
此项目给出的环境依赖有4个:
- torch
- fairscale,fairscale是用来做GPU分布的,一般是当使用DDP仍然遇到超显存的问题时使用fairscale
- fire,fire是一个命令行工具,用或者不用他都可以
- sentencepiece,sentencepiece是用于tokenizer的工具包
# 引入 sentencepiece 库的 SentencePieceProcessor 模块,用于进行分词操作 from sentencepiece import SentencePieceProcessor # 引入 logging 库的 getLogger 模块,用于生成日志 from logging import getLogger # 引入 typing 库的 List 模块,用于注释函数参数或返回值的类型 from typing import List # 引入 os 库,提供了大量与操作系统进行交互的接口 import os # 创建一个日志记录器 logger = getLogger() # 定义一个 Tokenizer 类 class Tokenizer: # 初始化函数,参数为 SentencePiece 模型的路径 def __init__(self, model_path: str): # 判断指定的模型文件是否存在 assert os.path.isfile(model_path), model_path # 加载 SentencePiece 模型 self.sp_model = SentencePieceProcessor(model_file=model_path) # 记录日志,提示模型加载成功 logger.info(f"Reloaded SentencePiece model from {model_path}") # 获取模型的词汇量、开始标记 ID、结束标记 ID、填充标记 ID self.n_words: int = self.sp_model.vocab_size() self.bos_id: int = self.sp_model.bos_id() self.eos_id: int = self.sp_model.eos_id() self.pad_id: int = self.sp_model.pad_id() # 记录日志,显示获取的信息 logger.info( f"#words: {self.n_words} - BOS ID: {self.bos_id} - EOS ID: {self.eos_id}" ) # 确保模型的词汇量与词片段大小一致 assert self.sp_model.vocab_size() == self.sp_model.get_piece_size() # 编码函数,将输入的字符串编码为 token id 列表 def encode(self, s: str, bos: bool, eos: bool) -> List[int]: # 检查输入的是否是字符串 assert type(s) is str # 使用 SentencePiece 模型将字符串编码为 token id 列表 t = self.sp_model.encode(s) # 如果需要在开头添加开始标记,就将开始标记 id 添加到列表的开头 if bos: t = [self.bos_id] + t # 如果需要在结尾添加结束标记,就将结束标记 id 添加到列表的结尾 if eos: t = t + [self.eos_id] # 返回 token id 列表 return t # 解码函数,将 token id 列表解码为字符串 def decode(self, t: List[int]) -> str: # 使用 SentencePiece 模型将 token id 列表解码为字符串 return self.sp_model.decode(t)
1.2.2 RMSNorm:对每个Transformer子层的输入进行归一化
为了提高训练的稳定性,对每个transformer子层的输入进行归一化,而不是对输出进行归一化,且使用由Zhang和Sennrich(2019)提出的RMSNorm(Root Mean Square Layer Normalization)
RMS Norm是一般LayerNorm的一种变体,可以在梯度下降时令损失更加平滑,与layerNorm相比,RMS Norm的主要区别在于去掉了减去均值的部分(re-centering),只保留方差部分(re-scaling)
为一目了然,我们看下它们各自的归一化的表达式
- LayerNorm
在给定一个输入特征向量后,先计算 x 的均值 μ 和标准差 σ 然后进行归一化操作:
其中的是可学习的缩放参数,来调整每个特征在归一化后的尺度或权重,最终作用是恢复归一化操作可能损失的信息,如数据的比例和分布等
而是偏移因子,可以对归一化并放缩后的数据进行偏移,使模型可以学习到一个最优的数值范围,比如在ReLU激活函数中,我们可能希望值在0以上 - RMS Norm
首先,计算输入特征向量 a 的平方根均值 (其中,n是向量a的元素数量) 然后,对输入特征向量 a 进行归一化 此外,可选地,RMSNorm 还可以引入可学习的放缩参数 和偏移参数 :
其代码实现为
至于RMS Norm为什么有用,需要求梯度进行分析,感兴趣的同学可以阅读RMS Norm的论文class RMSNorm(torch.nn.Module): def __init__(self, dim: int, eps: float = 1e-6): super().__init__() // eps防止取倒数之后分母为0 self.eps = eps self.weight = nn.Parameter(torch.ones(dim)) // x是输入 def _norm(self, x): // torch.rsqrt是开平方并取倒数 // x.pow(2)是平方 / mean(-1)是在最后一个维度(即hidden特征维度)上取平均 return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) def forward(self, x): output = self._norm(x.float()).type_as(x) // weight是末尾乘的可训练参数,即gi return output * self.weight
1.2.3 SwiGLU替代ReLU
为了更好的理解SwiGLU,首先你得先了解什么是ReLU和GLU
- ReLU的函数表达式为,这意味着对于所有负的输入值,ReLU函数的输出都是0,对于所有正的输入值,ReLU函数的输出等于输入值本身
- GLU 的基本思想是引入一种称为“门”机制,该机制可以动态地控制信息的流动
这个公式意味着,对于每个输入 x,都会有一个相应的门值,这个门值由 sigmoid 函数产生,其范围在 0 到 1 之间(在正数区域接近于1,负数区域接近于0),这个门值用于调节相应的输入值
如果 接近 1,那么“门”就几乎完全开启,输入 x 的信息能够自由流动,于是 GLU 的输出接近于 x
如果 接近 0,意味着“门”几乎完全关闭,即输入 x 的大部分或全部信息被阻止通过,于是 GLU 的输出接近 0
而LLaMA采用Shazeer(2020)提出的SwiGLU替换了原有的ReLU,SwiGLU的作用机制是根据输入数据的特性,通过学习到的参数自动调整信息流动的路径,具体是采用SwiGLU的Feedforward Neural Network (简称FNN,这是一种使用可学习的门控机制的前馈神经网络)
其在论文中以如下公式进行表述:
解释下这个公式
- 该公式先是通过Swish非线性激活函数处理 “输入和权重矩阵的乘积”
- 上面步骤1得到的结果和 “输入与权重矩阵的乘积” 进行逐元素的乘法
这个操作相当于在 Swish 激活的输出和第二个线性变换的输出之间引入了一个类似于GLU的“门”,这个门的值是由原始输入 通过线性变换 计算得到的,因此,它可以动态地控制 Swish 激活的输出 - 最后乘以权重矩阵
至于Swish激活函数可表示为
表示sigmoid函数,但其输入被缩放了 倍,是一个可以学习的参数,比如下图,不同,Swish激活函数的形状则各异
- 当 趋近于 0 时,Swish 函数趋近于线性函数 y = x
- 当 趋近于无穷大时,Swish 函数趋近于 ReLU 函数
对应论文见:Ramachandran et al., 2017
代码实现上:可以通过调用torch内置方法F.silu()实现,会在下文的FFN部分介绍
为增进大家对SwiGLU的理解,我还是举个简单的例子来说明这个过程
假设我们的输入 x 是一个二维向量
[2,3]
,权重矩阵 W 和 V 都是 2x2 矩阵,且我们简化问题,令 β =1
x[2,3]乘以权重矩阵 W
得到新的向量z,假设z是[5, 4]
- 对 xW的结果 z =
[5, 4]
应用 Swish 激活函数,即Swish_1(z) = z ⊙ σ(z) =[5σ(5), 4σ(4)]
- 然后,我们计算
xV
以得到“门”控制值
计算xV
得到新的向量 y,假设 y =[1,0]
- 接着,我们将
Swish_1(xW)
和xV
做元素级别的乘法,也就是实施"门控":(Swish_1(xW) ⊙ xV)
=[5σ(5)*1, 4σ(4)*0]
=[5σ(5), 0]
在这个例子中,我们可以看到
xV
的输出[1,0]
在元素级别上控制了Swish_1(xW)
的输出
- 第一个维度的门值为 1,因此
Swish_1(xW)
的第一个维度的输出能够“通过”,得到进入门控之前的结果5σ(5)
- 第二个维度的门值为 0,因此
Swish_1(xW)
的第二个维度的输出被“阻止”了,结果为 0这就是“门”的动态控制作用:它根据
xV
的输出调整Swish_1(xW)
的输出,通过这种方式,模型可以根据输入 x 的不同,动态地调整信息流动
1.2.4 位置编码:如何彻底理解旋转位置嵌入(RoPE)
在位置编码上,删除了绝对位置嵌入,而在网络的每一层增加了苏剑林等人(2021)提出的旋转位置嵌入(RoPE),其思想是采用绝对位置编码的形式 实现相对位置编码,且RoPE主要借助了复数的思想
先复习下复数的一些关键概念
- 我们一般用表示复数,实数a叫做复数的实部,实数b叫做复数的虚部
- 复数的辐角是指复数在复平面上对应的向量和正向实数轴所成的有向角
- 的共轭复数定义为:,也可记作,复数与其共轭的乘积等于它的模的平方,即,这是一个实数
1.2.4.1 旋转位置编码的原理
为了引入复数,首先假设了在加入位置信息之前,原有的编码向量是二维行向量和,其中和是绝对位置,现在需要构造一个变换,将和引入到和中,即寻找变换:
也就是说,我们分别为、设计操作、,使得经过该操作后,、就带有了位置、的绝对位置信息
考虑到Attention的核心计算是内积:
故我们希望的内积的结果带有相对位置信息,即寻求的这个变换,应该具有特性:
「怎么理解?很简单,当m和n表示了绝对位置之后,m与n在句子中的距离即位置差m-n,就可以表示为相对位置了,且对于复数,内积通常定义为一个复数与另一个复数的共轭的乘积」
- 为合理的求出该恒等式的一个尽可能简单的解,可以设定一些初始条件,比如、,然后可以先考虑二维情形,然后借助复数来求解
在复数中有,表示取实部的操作(复数 和“ 复数 的共轭即 ”之积仍是一个复数),总之,我们需要寻找一种变换,使得 - 简单起见,我们假设存在复数,使得,然后我们用复数的指数形式,设
- 那么代入方程后就得到两个方程
方程1:
方程2:Θf(q,m)−Θf(k,n) = Θg(q,k,m−n)
对于方程1,代入得到(接着,再把和都设为0)
最后一个等号源于初始条件和,所以现在我们可以很简单地设,,即它不依赖于
至于方程2,同样代入得到
Θf(q,m)−Θf(k,m) = Θg(q,k,0) = Θf(q,0)−Θf(k,0) = Θ(q)−Θ(k)
这里的、是、本身的幅角,而最后一个等号同样源于初始条件
根据上式Θf(q,m)−Θf(k,m) = Θ(q)−Θ(k),可得Θf(q,m)−Θ(q)=Θf(k,m)−Θ(k),所以Θf(q,m)−Θ(q)的结果是一个只与m相关、跟q无关的函数,记为φ(m),即Θf(q,m)=Θ(q)+φ(m) - 接着令n=m−1代入Θf(q,m)−Θf(k,n) = Θg(q,k,m−n),可以得到 Θf(q,m)−Θf(k,m-1) = Θg(q,k,1)
然后将 Θf(q,m) 和 Θf(k,m-1) 的等式代入Θf(q,m)=Θ(q)+φ(m),我们可以得到 Θ(q) + φ(m) - (Θ(k) + φ(m-1)) = Θg(q,k,1),整理一下就得到
即{φ(m)}是等差数列,设右端为θ,那么就解得φ(m)=mθ
综上,我们得到二维情况下用复数表示的RoPE: - 所以说,寻求的变换就是,也就是给乘以,相应地,乘以
做了这样一个变换之后,根据复数的特性,有: 也就是,如果把二维向量看做复数,那么它们的内积,等于一个复数乘以另一个复数的共轭,得到的结果再取实部,代入上面的变换,也就有: 这样一来,内积的结果就只依赖于,也就是相对位置了
换言之,经过这样一番操作,通过给Embedding添加绝对位置信息,可以使得两个token的编码,经过内积变换(self-attn)之后,得到结果是受它们位置的差值,即相对位置影响的
于是,对于任意的位置为的二维向量,把它看做复数,乘以,而根据欧拉公式,有:
从而上述的相乘变换也就变成了(过程中注意:):
把上述式子写成矩阵形式:
而这个变换的几何意义,就是在二维坐标系下,对向量进行了旋转,因而这种位置编码方法,被称为旋转位置编码
根据刚才的结论,结合内积的线性叠加性,可以将结论推广到高维的情形。可以理解为,每两个维度一组,进行了上述的“旋转”操作,然后再拼接在一起:
由于矩阵的稀疏性,会造成计算上的浪费,所以在计算时采用逐位相乘再相加的方式进行:
其中为矩阵逐位相乘操作
1.2.4.2 旋转位置编码的coding实现(分非LLaMA版和LLaMA版两种)
原理理解了,接下来可以代码实现旋转位置编码,考虑到LLaMA本身的实现不是特别好理解,所以我们先通过一份非LLaMA实现的版本,最后再看下LLaMA实现的版本
对于,非LLaMA版的实现,其核心就是实现下面这三个函数 (再次强调,本份关于RoPE的非LLaMA版的实现 与上面和之后的代码并非一体的,仅为方便理解RoPE的实现)
1.2.4.2.1 sinusoidal_position_embedding的编码实现
sinusoidal_position_embedding:这个函数用来生成正弦形状的位置编码。这种编码用来在序列中的令牌中添加关于相对或绝对位置的信息
def sinusoidal_position_embedding(batch_size, nums_head, max_len, output_dim, device):
# (max_len, 1)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(-1)
# (output_dim//2)
# 即公式里的i, i的范围是 [0,d/2]
ids = torch.arange(0, output_dim // 2, dtype=torch.float)
theta = torch.pow(10000, -2 * ids / output_dim)
# (max_len, output_dim//2)
# 即公式里的:pos / (10000^(2i/d))
embeddings = position * theta
# (max_len, output_dim//2, 2)
embeddings = torch.stack([torch.sin(embeddings), torch.cos(embeddings)], dim=-1)
# (bs, head, max_len, output_dim//2, 2)
# 在bs维度重复,其他维度都是1不重复
embeddings = embeddings.repeat((batch_size, nums_head, *([1] * len(embeddings.shape))))
# (bs, head, max_len, output_dim)
# reshape后就是:偶数sin, 奇数cos了
embeddings = torch.reshape(embeddings, (batch_size, nums_head, max_len, output_dim))
embeddings = embeddings.to(device)
return embeddings
一般的文章可能解释道这个程度基本就over了,但为了让初学者一目了然计,我还是再通过一个完整的示例,来一步步说明上述各个步骤都是怎么逐一结算的,整个过程和之前此文里介绍过的transformer的位置编码本质上是一回事..
为方便和transformer的位置编码做对比,故这里也假定output_dim = 512
- 首先,我们有 ids 张量,当 output_dim 为 512 时,则
,
然后我们有一个基数为10000的指数运算,使用了公式 torch.pow(10000, -2 * ids / output_dim)
,
,
,
,
,
...
,
,
ids = [0,0, 1,1, 2,2, ..., 254,254, 255,255] - 执行 embeddings = position * theta 这行代码,它会将 position 的每个元素与 theta 的相应元素相乘,前三个元素为
- 接下来我们将对 embeddings 的每个元素应用 torch.sin 和 torch.cos 函数
对于 torch.sin(embeddings),我们将取 embeddings 中的每个元素的正弦值:
对于 torch.cos(embeddings),我们将取 embeddings 中的每个元素的余弦值:
最后,torch.stack([torch.sin(embeddings), torch.cos(embeddings)], dim=-1) 将这两个新的张量沿着一个新的维度堆叠起来,得到的 embeddings如下 - 最终,得到如下结果
[ [ [ [sin(\frac{0}{10000^{\frac{0}{512}}}), cos(\frac{0}{10000^{\frac{0}{512}}}), sin(\frac{0}{10000^{\frac{2}{512}}}), cos(\frac{0}{10000^{\frac{2}{512}}}), ..., cos(\frac{0}{10000^{\frac{510}{512}}})], [sin(\frac{1}{10000^{\frac{0}{512}}}), cos(\frac{1}{10000^{\frac{0}{512}}}), sin(\frac{1}{10000^{\frac{2}{512}}}), cos(\frac{1}{10000^{\frac{2}{512}}}), ..., cos(\frac{1}{10000^{\frac{510}{512}}})], [sin(\frac{2}{10000^{\frac{0}{512}}}), cos(\frac{2}{10000^{\frac{0}{512}}}), sin(\frac{2}{10000^{\frac{2}{512}}}), cos(\frac{2}{10000^{\frac{2}{512}}}), ..., cos(\frac{2}{10000^{\frac{510}{512}}})] ] ] ]
1.4.2.1.2 RoPE的编码实现
RoPE:这个函数将相对位置编码(RoPE)应用到注意力机制中的查询和键上。这样,模型就可以根据相对位置关注不同的位置
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
def RoPE(q, k):
# q,k: (bs, head, max_len, output_dim)
batch_size = q.shape[0]
nums_head = q.shape[1]
max_len = q.shape[2]
output_dim = q.shape[-1]
# (bs, head, max_len, output_dim)
pos_emb = sinusoidal_position_embedding(batch_size, nums_head, max_len, output_dim, q.device)
# cos_pos,sin_pos: (bs, head, max_len, output_dim)
# 看rope公式可知,相邻cos,sin之间是相同的,所以复制一遍。如(1,2,3)变成(1,1,2,2,3,3)
cos_pos = pos_emb[..., 1::2].repeat_interleave(2, dim=-1) # 将奇数列信息抽取出来也就是cos 拿出来并复制
sin_pos = pos_emb[..., ::2].repeat_interleave(2, dim=-1) # 将偶数列信息抽取出来也就是sin 拿出来并复制
# q,k: (bs, head, max_len, output_dim)
q2 = torch.stack([-q[..., 1::2], q[..., ::2]], dim=-1)
q2 = q2.reshape(q.shape) # reshape后就是正负交替了
# 更新qw, *对应位置相乘
q = q * cos_pos + q2 * sin_pos
k2 = torch.stack([-k[..., 1::2], k[..., ::2]], dim=-1)
k2 = k2.reshape(k.shape)
# 更新kw, *对应位置相乘
k = k * cos_pos + k2 * sin_pos
return q, k
老规矩,为一目了然起见,还是一步一步通过一个示例来加深理解
- sinusoidal_position_embedding函数生成位置嵌入。在output_dim=512的情况下,每个位置的嵌入会有512个维度,但为了简单起见,我们只考虑前8个维度,前4个维度为sin编码,后4个维度为cos编码。所以,我们可能得到类似以下的位置嵌入
# 注意,这只是一个简化的例子,真实的位置嵌入的值会有所不同。 pos_emb = torch.tensor([[[[0.0000, 0.8415, 0.9093, 0.1411, 1.0000, 0.5403, -0.4161, -0.9900], [0.8415, 0.5403, 0.1411, -0.7568, 0.5403, -0.8415, -0.9900, -0.6536], [0.9093, -0.4161, -0.8415, -0.9589, -0.4161, -0.9093, -0.6536, 0.2836]]]])
- 然后,我们提取出所有的sin位置编码和cos位置编码,并在最后一个维度上每个位置编码进行复制
sin_pos = pos_emb[..., ::2].repeat_interleave(2, dim=-1) # 提取出所有sin编码,并在最后一个维度上复制 cos_pos = pos_emb[..., 1::2].repeat_interleave(2, dim=-1) # 提取出所有cos编码,并在最后一个维度上复制
- 更新query向量
我们首先构建一个新的q2向量,这个向量是由原来向量的负的cos部分和sin部分交替拼接而成的
我们用cos_pos对q进行元素级乘法,用sin_pos对q2进行元素级乘法,并将两者相加得到新的query向量
公式表示如下q2 = torch.stack([-q[..., 1::2], q[..., ::2]], dim=-1).flatten(start_dim=-2) # q2: tensor([[[[-0.2, 0.1, -0.4, 0.3, -0.6, 0.5, -0.8, 0.7], # [-1.0, 0.9, -1.2, 1.1, -1.4, 1.3, -1.6, 1.5], # [-1.8, 1.7, -2.0, 1.9, -2.2, 2.1, -2.4, 2.3]]]]) q = q * cos_pos + q2 * sin_pos
- 更新key向量
对于key向量,我们的处理方法与query向量类似k2 = torch.stack([-k[..., 1::2], k[..., ::2]], dim=-1).flatten(start_dim=-2) # k2: tensor([[[[-0.15, 0.05, -0.35, 0.25, -0.55, 0.45, -0.75, 0.65
1.4.2.1.3 attention的编码实现
attention:这是注意力机制的主要功能
- 首先,如果use_RoPE被设置为True,它会应用RoPE,通过取查询和键的点积(并进行缩放)
- 然后,进行softmax操作来计算注意力分数,以得到概率,输出是值的加权和,权重是计算出的概率
- 最后,旋转后的q和k计算点积注意力后,自然就具备了相对位置信息
def attention(q, k, v, mask=None, dropout=None, use_RoPE=True):
# q.shape: (bs, head, seq_len, dk)
# k.shape: (bs, head, seq_len, dk)
# v.shape: (bs, head, seq_len, dk)
if use_RoPE:
# 使用RoPE进行位置编码
q, k = RoPE(q, k)
d_k = k.size()[-1]
# 计算注意力权重
# (bs, head, seq_len, seq_len)
att_logits = torch.matmul(q, k.transpose(-2, -1))
att_logits /= math.sqrt(d_k)
if mask is not None:
# 对权重进行mask,将为0的部分设为负无穷大
att_scores = att_logits.masked_fill(mask == 0, -1e-9)
# 对权重进行softmax归一化
# (bs, head, seq_len, seq_len)
att_scores = F.softmax(att_logits, dim=-1)
if dropout is not None:
# 对权重进行dropout
att_scores = dropout(att_scores)
# 注意力权重与值的加权求和
# (bs, head, seq_len, seq_len) * (bs, head, seq_len, dk) = (bs, head, seq_len, dk)
return torch.matmul(att_scores, v), att_scores
if __name__ == '__main__':
# (bs, head, seq_len, dk)
q = torch.randn((8, 12, 10, 32))
k = torch.randn((8, 12, 10, 32))
v = torch.randn((8, 12, 10, 32))
# 进行注意力计算
res, att_scores = attention(q, k, v, mask=None, dropout=None, use_RoPE=True)
# 输出结果的形状
# (bs, head, seq_len, dk), (bs, head, seq_len, seq_len)
print(res.shape, att_scores.shape)
接下来,我们再来看下LLaMA里是怎么实现这个旋转位置编码的,具体而言,LLaMA 的model.py文件里面实现了旋转位置编码(为方便大家理解,我给相关代码 加了下注释)
首先,逐一实现这三个函数
precompute_freqs_cis
reshape_for_broadcast
apply_rotary_emb
# 预计算频率和复数的函数
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)) # 计算频率
t = torch.arange(end, device=freqs.device) # 根据结束位置生成序列
freqs = torch.outer(t, freqs).float() # 计算外积得到新的频率
freqs_cis = torch.polar(torch.ones_like(freqs), freqs) # 计算复数
return freqs_cis # 返回复数
# 重塑的函数
def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):
ndim = x.ndim # 获取输入张量的维度
assert 0 <= 1 < ndim # 检查维度的合理性
assert freqs_cis.shape == (x.shape[1], x.shape[-1]) # 检查复数的形状
shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)] # 计算新的形状
return freqs_cis.view(*shape) # 重塑复数的形状并返回
# 应用旋转嵌入的函数
def apply_rotary_emb(
xq: torch.Tensor,
xk: torch.Tensor,
freqs_cis: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2)) # 将xq视为复数
xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2)) # 将xk视为复数
freqs_cis = reshape_for_broadcast(freqs_cis, xq_) # 重塑复数的形状
xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3) # 计算xq的输出
xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3) # 计算xk的输出
return xq_out.type_as(xq), xk_out.type_as(xk) # 返回xq和xk的输出
之后,在注意力机制的前向传播函数中调用上面实现的第三个函数 apply_rotary_emb,赋上位置信息 (详见下文1.2.5节)
# 对Query和Key应用旋转嵌入
xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)
1.2.5 Transform架构的实现:Attention计算、SA、FFN
LLaMA和GPT一样,都是基于Transformer这个架构,通常,我们在构建transformer时,是按Block构建的,每个transformer Block包含SA和FFN两部分,然后再通过堆叠block的形式,构建起整个transformer网络,LLaMA也是这样做的
回顾一下Attention计算的总体过程是:
- 输入,分别经过三个Linear得到
- 在 和中加入旋转位置编码
- 缓存 和
- 计算
其中有一个细节就是缓存机制,它设计的目的是在generate时减少token的重复计算。简单解释一下,就是在计算第n个token特征的时候,需要用到第个token,即每次生成时,需要知道前面所有的过往信息,如果每次都从头算的话,那就会造成极大的浪费,所以就没算一个位置的信息,就把它缓存下来
接下来,我们来看下代码实现,首先是SA(self-attention)部分:
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
# 设置本地注意力头的数量
self.n_local_heads = args.n_heads // fs_init.get_model_parallel_world_size()
# 每个注意力头的维度
self.head_dim = args.dim // args.n_heads
# Query投影层
self.wq = ColumnParallelLinear(
args.dim,
args.n_heads * self.head_dim,
bias=False,
gather_output=False,
init_method=lambda x: x,
)
# Key投影层
self.wk = ColumnParallelLinear(
args.dim,
args.n_heads * self.head_dim,
bias=False,
gather_output=False,
init_method=lambda x: x,
)
# Value投影层
self.wv = ColumnParallelLinear(
args.dim,
args.n_heads * self.head_dim,
bias=False,
gather_output=False,
init_method=lambda x: x,
)
# 输出投影层
self.wo = RowParallelLinear(
args.n_heads * self.head_dim,
args.dim,
bias=False,
input_is_parallel=True,
init_method=lambda x: x,
)
# 使用零初始化键缓存
self.cache_k = torch.zeros(
(args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
).cuda()
# 使用零初始化值缓存
self.cache_v = torch.zeros(
(args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
).cuda()
def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
bsz, seqlen, _ = x.shape
# 进行Query投影
xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)
# 将形状调整为[bsz, seqlen, n_local_heads, head_dim]
xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seqlen, self.n_local_heads, self.head_dim)
xv = xv.view(bsz, seqlen, self.n_local_heads, self.head_dim)
# 对Query和Key应用旋转嵌入
xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)
# 将缓存键和值转换为xq的设备类型
self.cache_k = self.cache_k.to(xq)
self.cache_v = self.cache_v.to(xq)
# 更新缓存键和值
self.cache_k[:bsz, start_pos : start_pos + seqlen] = xk
self.cache_v[:bsz, start_pos : start_pos + seqlen] = xv
# 获取键和值
keys = self.cache_k[:bsz, : start_pos + seqlen]
values = self.cache_v[:bsz, : start_pos + seqlen]
# 转置xq、键和值的维度
xq = xq.transpose(1, 2)
keys = keys.transpose(1, 2)
values = values.transpose(1, 2)
# 计算注意力分数
scores = torch.matmul(xq, keys.transpose(2, 3)) / math.sqrt(self.head_dim)
if mask is not None:
scores = scores + mask # (bs, n_local_heads, slen, cache_len + slen)
scores = F.softmax(scores.float(), dim=-1).type_as(xq)
# 使用注意力分数加权求和得到输出
output = torch.matmul(scores, values) # (bs, n_local_heads, slen, head_dim)
output = output.transpose(
1, 2
).contiguous().view(bsz, seqlen, -1)
# 应用输出投影
return self.wo(output)
然后是前馈网络FFN部分,需要注意的点就是采用的激活函数,以及激活函数的位置
import torch.nn as nn
import torch.nn.functional as F
class FeedForward(nn.Module):
def __init__(
self,
dim: int,
hidden_dim: int,
multiple_of: int,
):
super().__init__()
# 初始化隐藏层的维度为输入维度的2/3
hidden_dim = int(2 * hidden_dim / 3)
# 调整隐藏层维度为multiple_of的倍数
hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
# 第一个线性层
self.w1 = ColumnParallelLinear(
dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
)
# 第二个线性层
self.w2 = RowParallelLinear(
hidden_dim, dim, bias=False, input_is_parallel=True, init_method=lambda x: x
)
# 第三个线性层
self.w3 = ColumnParallelLinear(
dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
)
def forward(self, x):
# 前向传播函数
return self.w2(F.silu(self.w1(x)) * self.w3(x))
这里与常见模型中的FFN做一下简单的对比
- BART中的FFN,用的是fc->act->fc,用了两层全连接
- GPT中的FFN,用的是conv1D->act->conv1D,也是只用了两层
- 而LLaMA中的FFN采用了三个全连接层以实现FFNSwiGLU,即
然后将SA和FFN这两部分拼在一起就是一个transformer block
import torch
import torch.nn as nn
from typing import Optional
class TransformerBlock(nn.Module):
def __init__(self, layer_id: int, args: ModelArgs):
super().__init__()
# 初始化参数
self.n_heads = args.n_heads # 注意力头的数量
self.dim = args.dim # 模型维度
self.head_dim = args.dim // args.n_heads # 每个注意力头的维度
self.attention = Attention(args) # 注意力机制模块
self.feed_forward = FeedForward(
dim=args.dim, hidden_dim=4 * args.dim, multiple_of=args.multiple_of
) # 前馈神经网络模块
self.layer_id = layer_id # 当前层的ID
self.attention_norm = RMSNorm(args.dim, eps=args.norm_eps) # 注意力模块的归一化
self.ffn_norm = RMSNorm(args.dim, eps=args.norm_eps) # 前馈神经网络模块的归一化
def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
# 输入x经过self-attention之后,做Add&Norm
h = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask)
# 上一步的输出h作为输入,经过前馈神经网络Feed forward之后,做Add&Norm
out = h + self.feed_forward.forward(self.ffn_norm(h))
return out
最后利用torch的module list将transformer block进行堆叠,拼上最前头的embedding部分,就是一个完整的transformer decoder结构了
import torch
import torch.nn as nn
from typing import Optional
class Transformer(nn.Module):
def __init__(self, params: ModelArgs):
super().__init__()
# 初始化参数
self.params = params
self.vocab_size = params.vocab_size # 词汇表大小
self.n_layers = params.n_layers # Transformer模型的层数
# 词嵌入层
self.tok_embeddings = ParallelEmbedding(
params.vocab_size, params.dim, init_method=lambda x: x
)
# Transformer的各个层
self.layers = torch.nn.ModuleList()
for layer_id in range(params.n_layers):
self.layers.append(TransformerBlock(layer_id, params))
# 归一化层
self.norm = RMSNorm(params.dim, eps=params.norm_eps)
# 输出层
self.output = ColumnParallelLinear(
params.dim, params.vocab_size, bias=False, init_method=lambda x: x
)
# 预计算的频率矩阵
self.freqs_cis = precompute_freqs_cis(
self.params.dim // self.params.n_heads, self.params.max_seq_len * 2
)
@torch.inference_mode()
def forward(self, tokens: torch.Tensor, start_pos: int):
_bsz, seqlen = tokens.shape
# Token嵌入和位置编码
h = self.tok_embeddings(tokens)
self.freqs_cis = self.freqs_cis.to(h.device)
freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]
# 生成上三角的mask矩阵(为decoder模型防止标签泄漏)
mask = None
if seqlen > 1:
mask = torch.full((1, 1, seqlen, seqlen), float("-inf"), device=tokens.device)
mask = torch.triu(mask, diagonal=start_pos + 1).type_as(h)
# 逐层计算Transformer
for layer in self.layers:
h = layer(h, start_pos, freqs_cis, mask)
h = self.norm(h)
output = self.output(h[:, -1, :]) # 只计算最后一个位置的logits
return output.float()
接着看下生成过程,如下:
- 对prompts进行tokenize,得到token ids;
- 计算当前batch的最大长度total_len,用来创建输入的token tensor,最大长度不能超过前文所述缓存的大小;
- 从当前batch中,最短的一个prompt的位置,作为生成的开始位置,开始生成;
- 输入的token tensor传入transformer模型,计算logits,得到形状为(batch_size, hidden_size)的logits(transformer最后一层的输出);
- softmax+top_p采样,得到当前预测的token,并更新当前位置,准备预测下一个token;
- 解码得到生成的文本
代码如下
class LLaMA:
def __init__(self, model: Transformer, tokenizer: Tokenizer):
self.model = model
self.tokenizer = tokenizer
def generate(
self,
prompts: List[str],
max_gen_len: int,
temperature: float = 0.8,
top_p: float = 0.95,
) -> List[str]:
# 获取批处理大小
bsz = len(prompts)
# 获取模型参数
params = self.model.params
# 检查批处理大小是否在允许的最大批处理大小范围内
assert bsz <= params.max_batch_size, (bsz, params.max_batch_size)
# 使用分词器对提示进行编码为标记
prompt_tokens = [self.tokenizer.encode(x, bos=True, eos=False) for x in prompts]
# 查找提示标记的最小和最大大小
min_prompt_size = min([len(t) for t in prompt_tokens])
max_prompt_size = max([len(t) for t in prompt_tokens])
# 计算要生成的标记的总长度
total_len = min(params.max_seq_len, max_gen_len + max_prompt_size)
# 创建一个张量来存储生成的标记,填充为填充标记
tokens = torch.full((bsz, total_len), self.tokenizer.pad_id).cuda().long()
# 将提示标记复制到标记张量中
for k, t in enumerate(prompt_tokens):
tokens[k, : len(t)] = torch.tensor(t).long()
# 创建一个掩码以识别输入文本
input_text_mask = tokens != self.tokenizer.pad_id
# 设置生成的起始位置
start_pos = min_prompt_size
prev_pos = 0
# 逐个生成标记
for cur_pos in range(start_pos, total_len):
# 通过模型进行前向传递以获取logits
logits = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
if temperature > 0:
# 对logits应用温度并计算概率
probs = torch.softmax(logits / temperature, dim=-1)
# 使用top-p采样抽样下一个标记
next_token = sample_top_p(probs, top_p)
else:
# 选择概率最高的标记
next_token = torch.argmax(logits, dim=-1)
next_token = next_token.reshape(-1)
# 只有在已经生成了提示的情况下才替换标记
next_token = torch.where(
input_text_mask[:, cur_pos], tokens[:, cur_pos], next_token
)
tokens[:, cur_pos] = next_token
prev_pos = cur_pos
# 将生成的标记解码为文本
decoded = []
for i, t in enumerate(tokens.tolist()):
# 将标记截断到最大生成长度
t = t[: len(prompt_tokens[i]) + max_gen_len]
# 将标记截断到如果存在结束标记
try:
t = t[: t.index(self.tokenizer.eos_id)]
except ValueError:
pass
# 将标记解码为文本
decoded.append(self.tokenizer.decode(t))
return decoded
def sample_top_p(probs, p):
# 按降序对概率进行排序
probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
# 计算概率的累积和
probs_sum = torch.cumsum(probs_sort, dim=-1)
# 创建一个掩码以过滤累积概率超过p的标记
mask = probs_sum - probs_sort > p
# 将被过滤的标记的概率设置为0
probs_sort[mask] = 0.0
# 归一化概率
probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
# 使用修改后的概率进行抽样下一个标记
next_token = torch.multinomial(probs_sort, num_samples=1)
# 收集抽样标记的原始索引
next_token = torch.gather(probs_idx, -1, next_token)
return next_token
1.3 LLaMA的Optimizer设计、模型加速优化与微型版本
在Optimizer设计上
- 该模型使用AdamW优化器(Loshchilov和Hutter,2017)进行训练,超参数设置为β1=0.9,β2=0.95
此外,使用余弦学习率方式,使最终学习率等于最大学习率的10%,以及使用0.1的权重衰减和1.0的梯度剪裁,和2000个warm up策略,使得可以根据模型的大小改变学习率和批次大小
在模型的加速优化方面
- 首先,使用一个高效的因果多头注意力方式的实现,灵感来自Rabe和Staats(2021)以及Dao等人(2022),这个实现可在xformers库中找到,可以有效减少内存的使用和计算
具体原理为通过不存储注意力权重和不计算由于语言建模任务的因果性质而被掩盖的键/查询分数来实现的 - 其次,为了进一步提高训练效率,减少了在check point的后向传递中重新计算的激活量,在实现上,通过手动实现trasnformer层的后向函数来进行操作
为了充分受益于这种优化,还通过如Korthikanti等人(2022)中采用的方法,进行使用模型和序列并行来减少模型的内存使用 - 最后,该工作还尽可能地重叠激活的计算和GPU之间在网络上的通信
最终的优化性能效果为:当训练一个65B参数的模型时,代码在2048A100的GPU上处理大约380个token/秒/GPU,并耗费80GB的内存,这意味着对包含1.4Ttoken的数据集进行训练大约花费了21天
LLaMA发布不久后,一些研究者基于它做了不少工作
- 一开始最小参数7B的模型也需要近30GB的GPU才能运行,但通过比特和字节库进行浮点优化,能够让模型在单个NVIDIA RTX 3060(显存一般12G)上运行
- 之后,GitHub 上的一名研究人员甚至能够在Ryzen 7900X CPU上运行LLM的7B 版本,每秒能推断出几个单词
- 再之后,有研究者推出了llama.cpp,无需 GPU,就能运行 LLaMA
llama.cpp 项目实现了在MacBook上运行 LLaMA,还有开发者成功的在 4GB RAM 的树莓派上运行了 LLaMA 7B
第二部分 各种微调LLaMA:Alpaca(self-instruct)、Vicuna(shareGPT)、BELLE(self-instruct)
2.1 Stanford Alpaca:结合英文语料通过Self Instruct方式微调LLaMA 7B
2.1.1 什么是self-instruct方式:提示GPT3/GPT3.5/GPT4的API收集数据
3月中旬,斯坦福的Rohan Taori等人发布Alpaca(中文名:羊驼):号称只花100美元,人人都可微调Meta家70亿参数的LLaMA大模型(即LLaMA 7B),具体做法是通过52k指令数据,然后在8个80GB A100上训练3个小时,使得Alpaca版的LLaMA 7B在单纯对话上的性能比肩GPT-3.5(text-davinci-003),这便是指令调优LLaMA的意义所在
- 论文《Alpaca: A Strong Open-Source Instruction-Following Model》
- GitHub地址:https://github.com/tatsu-lab/stanford_alpaca
- 数据地址(斯坦福团队微调LLaMA 7B所用的52K英文指令数据):https://raw.githubusercontent.com/tatsu-lab/stanford_alpaca/main/alpaca_data.json
有意思的是,后来不断有人把这52K的英文指令数据翻译了下,比如:
单纯翻译的斯坦福52K中文指令数据
斯坦福52K中文指令数据(语句上做了中文表达风格的意译)
这52K数据所对应的alpaca_data.json文件是一个字典列表,每个字典包含以下字段:
- instruction: str,描述了模型应该执行的任务,52K 条指令中的每一条都是唯一的
- input: str,要么是上下文,要么直接输入(optional context or input for the task),例如,当指令是“总结以下文章”时,输入就是文章,大约 40% 的示例有输入
- output: str,由GPT3.5对应的API即 text-davinci-003生成的指令的答案
而这52K数据是怎么来的呢?实际上,是通过Self-Instruct『Self-Instruct是来自华盛顿大学Yizhong Wang等人于22年12月通过这篇论文《SELF-INSTRUCT: Aligning Language Model with Self Generated Instructions》提出的,这是其论文地址、代码地址』提示GPT3的API拿到的
具体而言,论文中提出
- 人工设计175个任务,每个任务都有对应的{指令 输入 输出/实例}或{指令 输出/实例},将这175个任务数据作为种子集
比如这是斯坦福Alpaca的175个种子数据:stanford_alpaca/seed_tasks.jsonl at main · tatsu-lab/stanford_alpaca · GitHub
{"id": "seed_task_0", "name": "breakfast_suggestion",
"instruction": "Is there anything I can eat for a breakfast that doesn't include eggs, yet includes protein, and has roughly 700-1000 calories?",
"instances": [{"input": "", "output": "Yes, you can have 1 oatmeal banana protein shake and 4 strips of bacon. The oatmeal banana protein shake may contain 1/2 cup oatmeal, 60 grams whey protein powder, 1/2 medium banana, 1tbsp flaxseed oil and 1/2 cup watter, totalling about 550 calories. The 4 strips of bacon contains about 200 calories."}],
"is_classification": false}
{"id": "seed_task_1", "name": "antonym_relation",
"instruction": "What is the relation between the given pairs?",
"instances": [{"input": "Night : Day :: Right : Left", "output": "The relation between the given pairs is that they are opposites."}],
"is_classification": false} - 然后提示模型比如GPT3对应的API即 text-davinci-001 (原论文中没用text-davinci-003,because their newer engines are trained with the latest user data and are likely to already see the SUPERNI evaluation set,但实际应用时比如斯坦福Alpaca指定的GPT3.5的API即 text-davinci-003生成指令,包括很快你将看到,23年4月还有微软的研究者指定GPT4的API生成指令),使用种子集作为上下文示例来生成更多新的指令
- 对该模型生成的指令判断是否分类任务
- 使用模型生成实例
- 对上述模型生成的数据{指令 输入 输出/实例}过滤掉低质量或相似度高的
- 将经过过滤和后处理的数据添加到种子池中
一直重复上述2-6步直到种子池有足够多的数据
而斯坦福的Alpaca在实际生成52K数据时,还考虑到了多重过滤机制,防止生成过于相似、过长或含有特定关键词的指令,以此保证生成的指令集的质量和多样性,且在每一轮生成指令后,都会保存当前的结果,方便随时跟踪进度,此外,还采用了多进程处理,提高了效率
故最终完整生成52K数据的完整代码如下(来源于:https://github.com/tatsu-lab/stanford_alpaca/blob/main/generate_instruction.py,且为方便理解,给每一行代码都逐行加上了中文注释)
"""
batch_selfinstruct_generate.py
运行:
python -m generate_instruction generate_instruction_following_data \
--output_dir ./ \
--num_instructions_to_generate 10 \
--model_name="text-davinci-003" \
"""
import time # 引入时间模块
import json # 引入json模块
import os # 引入os模块
import random # 引入随机数模块
import re # 引入正则表达式模块
import string # 引入字符串模块
from functools import partial # 引入偏函数模块
from multiprocessing import Pool # 引入多进程模块
import numpy as np # 引入Numpy库
import tqdm # 引入tqdm库,用于进度条显示
from rouge_score import rouge_scorer # 引入rouge评分器,用于文本相似度计算
import utils # 引入自定义的工具模块
import fire # 引入fire库,用于命令行参数解析
# 定义一个将多个提示指令编码成单一字符串的函数
def encode_prompt(prompt_instructions):
prompt = open("./prompt.txt").read() + "\n" # 打开并读取提示文本文件
# 遍历提示指令,将其格式化并附加到提示字符串中
for idx, task_dict in enumerate(prompt_instructions):
(instruction, input, output) = task_dict["instruction"], task_dict["input"], task_dict["output"]
instruction = re.sub(r"\s+", " ", instruction).strip().rstrip(":") # 对指令进行清洗
input = "<noinput>" if input.lower() == "" else input # 若无输入则标注为"<noinput>"
# 格式化并添加指令、输入和输出到提示中
prompt += f"###\n"
prompt += f"{idx + 1}. Instruction: {instruction}\n"
prompt += f"{idx + 1}. Input:\n{input}\n"
prompt += f"{idx + 1}. Output:\n{output}\n"
prompt += f"###\n"
prompt += f"{idx + 2}. Instruction:" # 添加下一个指令的前缀
return prompt # 返回提示字符串
# 定义一个对GPT-3响应进行后处理的函数,抽取生成的新指令
def post_process_gpt3_response(num_prompt_instructions, response):
if response is None: # 如果响应为空,则返回空列表
return []
raw_instructions = f"{num_prompt_instructions+1}. Instruction:" + response["text"] # 获取原始的指令文本
raw_instructions = re.split("###", raw_instructions) # 根据"###"切分原始指令
instructions = [] # 初始化指令列表
# 对每个切分出的原始指令进行处理
for idx, inst in enumerate(raw_instructions):
# 如果解码由于长度停止,最后一个示例可能被截断,因此我们丢弃它
if idx == len(raw_instructions) - 1 and response["finish_reason"] == "length":
continue
idx += num_prompt_instructions + 1
# 根据索引和"Instruction", "Input", "Output"关键字进行切分
splitted_data = re.split(f"{idx}\.\s+(Instruction|Input|Output):", inst)
if len(splitted_data) != 7: # 如果切分结果不等于7,则继续下一轮循环
continue
else:
# 提取指令、输入、输出
inst = splitted_data[2].strip()
input = splitted_data[4].strip()
input = "" if input.lower() == "<noinput>" else input # 对输入进行处理,如果是"<noinput>",则替换为空字符串
output = splitted_data[6].strip()
# 过滤掉太短或太长的指令
if len(inst.split()) <= 3 or len(inst.split()) > 150:
continue
# 根据不适合语言模型的关键词进行过滤
blacklist = [
"image",
"images",
"graph",
"graphs",
"picture",
"pictures",
"file",
"files",
"map",
"maps",
"draw",
"plot",
"go to",
"video",
"audio",
"music",
"flowchart",
"diagram",
]
# 如果指令中存在黑名单中的词,则忽略该指令
if any(find_word_in_string(word, inst) for word in blacklist):
continue
# 模型倾向于为一些现有指令添加"编写程序",这会导致很多这样的指令。
# 这里过滤掉这类指令
if inst.startswith("Write a program"):
continue
# 过滤那些以标点符号开始的指令
if inst[0] in string.punctuation:
continue
# 过滤那些以非英语字符开始的指令
if not inst[0].isascii():
continue
# 将处理后的指令添加到指令列表中
instructions.append({"instruction": inst, "input": input, "output": output})
return instructions # 返回指令列表
# 定义一个在字符串中查找单词的函数
def find_word_in_string(w, s):
return re.compile(r"\b({0})\b".format(w), flags=re.IGNORECASE).search(s)
# 定义一个生成指令的函数
def generate_instruction_following_data(
output_dir="./",
seed_tasks_path="./seed_tasks.jsonl",
num_instructions_to_generate=100,
model_name="text-davinci-003",
num_prompt_instructions=3,
request_batch_size=5,
temperature=1.0,
top_p=1.0,
num_cpus=16,
):
seed_tasks = [json.loads(l) for l in open(seed_tasks_path, "r")] # 读取并解析种子任务
# 从种子任务中提取指令、输入和输出
seed_instruction_data = [
{"instruction": t["instruction"], "input": t["instances"][0]["input"], "output": t["instances"][0]["output"]}
for t in seed_tasks
]
print(f"Loaded {len(seed_instruction_data)} human-written seed instructions") # 打印加载的人工编写的种子指令的数量
os.makedirs(output_dir, exist_ok=True) # 创建输出目录
request_idx = 0
# 加载LM生成的指令
machine_instruction_data = []
if os.path.exists(os.path.join(output_dir, "regen.json")):
machine_instruction_data = utils.jload(os.path.join(output_dir, "regen.json"))
print(f"Loaded {len(machine_instruction_data)} machine-generated instructions") # 打印加载的机器生成的指令的数量
# 初始化Rouge得分计算器
scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=False)
# 进度条,总数为要生成的指令数量
progress_bar = tqdm.tqdm(total=num_instructions_to_generate)
if machine_instruction_data:
progress_bar.update(len(machine_instruction_data)) # 如果已有机器生成的指令,则更新进度条
# 首先,我们对所有的种子指令和生成的机器指令进行标记
all_instructions = [d["instruction"] for d in seed_instruction_data] + [
d["instruction"] for d in machine_instruction_data
]
all_instruction_tokens = [scorer._tokenizer.tokenize(inst) for inst in all_instructions]
# 当机器指令数据的数量小于需要生成的指令数量时,持续生成
while len(machine_instruction_data) < num_instructions_to_generate:
request_idx += 1 # 请求索引增加
batch_inputs = []
for _ in range(request_batch_size):
# 只从种子任务中采样
prompt_instructions = random.sample(seed_instruction_data, num_prompt_instructions)
# 将多个提示指令编码成一个字符串
prompt = encode_prompt(prompt_instructions)
batch_inputs.append(prompt) # 将编码的指令添加到批输入列表中
decoding_args = utils.OpenAIDecodingArguments(
temperature=temperature,
n=1,
max_tokens=3072, # 硬编码以最大化长度。请求将自动调整
top_p=top_p,
stop=["\n20", "20.", "20."], # 当出现这些字符串时,生成停止
)
# 记录请求开始的时间
request_start = time.time()
# 调用OpenAI API进行批量生成
results = utils.openai_completion(
prompts=batch_inputs,
model_name=model_name,
batch_size=request_batch_size,
decoding_args=decoding_args,
logit_bias={"50256": -100}, # 阻止特定token被生成
)
request_duration = time.time() - request_start # 计算请求的时间
# 开始后处理生成的结果
process_start = time.time()
instruction_data = []
for result in results:
# 对每个结果进行后处理,并获取新的指令
new_instructions = post_process_gpt3_response(num_prompt_instructions, result)
instruction_data.extend(new_instructions)
process_duration = time.time() - process_start # 计算后处理的时间
# 更新进度条
progress_bar.update(len(instruction_data))
print(
f"\nRequest {request_idx} took {request_duration:.2f} seconds, post-processing took {process_duration:.2f} seconds"
)
# 对每一条新指令进行处理
for data in instruction_data:
inst = data["instruction"]
# 使用Rouge得分器对指令进行标记
inst_tokens = scorer._tokenizer.tokenize(inst)
# 计算新指令与已有指令的最大RougeL得分
max_rougeL = max(
[scorer.score(inst_tokens, old_inst_tokens)["rougeL"].fmeasure for old_inst_tokens in all_instruction_tokens]
)
# 如果RougeL得分大于0.5,则认为该指令与已有指令过于相似,不予采纳
if max_rougeL > 0.5:
continue
# 将新指令添加到已有指令列表和已有指令标记列表中
all_instructions.append(inst)
all_instruction_tokens.append(inst_tokens)
# 将新指令添加到机器生成的指令数据中
machine_instruction_data.append(data)
# 将机器生成的指令数据保存到文件中
utils.jdump(machine_instruction_data, os.path.join(output_dir, "regen.json"))
progress_bar.close() # 关闭进度条
print(f"Generated {len(machine_instruction_data)} instructions") # 打印生成的指令数量
# 随机化并截取生成的指令数据
random.shuffle(machine_instruction_data)
machine_instruction_data = machine_instruction_data[:num_instructions_to_generate]
# 将指令数据转化为任务格式
machine_tasks = []
for data in machine_instruction_data:
task = {
"id": utils.random_id(),
"input": data["input"],
"output": data["output"],
"rating": np.random.uniform(1, 5), # 给指令一个随机的评分,代表指令的质量
"instruction": data["instruction"],
}
machine_tasks.append(task)
# 保存机器生成的任务到文件中
utils.jdump(machine_tasks, os.path.join(output_dir, "regen_tasks.json"))
# 使用fire库解析命令行参数,并调用函数
if __name__ == "__main__":
fire.Fire(generate_instruction_following_data)
所以Alpaca,就是花了不到500美元使用OpenAI API生成了5.2万个这样的示例微调LLaMA搞出来的,个人觉得可以取名为 instructLLaMA-7B,^_^
值得一提的是,后来23年4月有微软的研究者提示GPT4的API进行指令微调「论文地址:INSTRUCTION TUNING WITH GPT-4、GitHub地址:instruction-Tuning-with-GPT-4、项目地址:使用GPT4进行指令调优」,从而生成以下数据
- English Instruction-Following Data,generated by GPT-4 using Alpaca prompts
这部分数据在项目文件 alpaca_gpt4_data.json 里,contains 52K instruction-following data generated by GPT-4 with prompts in Alpaca. This JSON file has the same format as Alpaca data, except the output is generated by GPT-4:
instruction: str, describes the task the model should perform. Each of the 52K instructions is unique.
input: str, optional context or input for the task.
output: str, the answer to the instruction as generated by GPT-4. - Chinese Instruction-Following Data,即上面英文数据的中文翻译,存储在项目文件alpaca_gpt4_data_zh.json 里
- Comparison Data ranked by GPT-4,好训练一个奖励模型
存储在 comparision_data.json 文件里,ranked responses from three models, including GPT-4, GPT-3.5 and OPT-IML by asking GPT-4 to rate the quality.
user_input: str, prompts used for quering LLMs.
completion_a: str, a model completion which is ranked higher than completion_b.
completion_b: str, a different model completion which has a lower quality score. - Answers on Unnatural Instructions Data,该数据用于大规模量化 GPT-4 与我们的指令调整模型(即LLaMA by instruction tuning with GPT4)之间的差距,而缩小与GPT4的差距便是本次指令调优的目标
2.1.2 手把手实战:Self-Instruct: Aligning LM with Self Generated Instructions
之前已说过,Self-Instruct是来自华盛顿大学Yizhong Wang等人于22年12月通过这篇论文《SELF-INSTRUCT: Aligning Language Model with Self Generated Instructions》提出的,这是其论文地址、代码地址
为进一步理解self-instruct这个方式的原理与实现细节,我司杜老师把这个self-instruct的方式生成语料的过程实践了下(这是该教程地址),具体而言,先理清如下4个步骤
- Step1:通过模型生成新的指令
根据人工设计的175个任务,每个任务都有对应的(指令,输入,输出)或(指令,输出);使用模型生成新的指令;
执行的代码文件为# 1. Generate instructions from the seed tasks ./scripts/generate_instructions.sh
- Step2:对模型生成的指令进行判断(指令是否是一个分类任务)
而判断指令是否属于分类任务的操作如下:在种子池中随机挑选12条分类指令和19条非分类指令,然后加上新生成的指令
执行的代码文件为# 2. Identify whether the instruction represents a classification task or not ./scripts/is_clf_or_not.sh
- Step3:根据Step2的判断结果,给出不同的输出
如果是分类任务,就通过模型输出 Class_label 和 Input(Output-first,即先输出分类的标签,再输出Input内容)
如果不是分类任务,就通过模型输出 Input 和 Output(Input-first,即先输出Input,再输出Output)
执行的代码文件为# 3. Generate instances for each instruction ./scripts/generate_instances.sh
- Step4:过滤及后处理
对上述模型生成的数据进行过滤和后处理,将经过过滤和后处理的数据添加到种子池中
且为了数据的多样性,新生成的指令只有与种子池中的指令的 ROUGE-L 小于0.7时才会添加进入种子池;
排除一些无法被语言模型处理的指令,比如涉及图像、图片、图形的指令;
在给指令生成实例时,会过滤掉输入相同但是输出不同的实例
执行的代码文件为# 4. Filtering, processing, and reformatting ./scripts/prepare_for_finetuning.sh
对于以上4个步骤进行不断循环,直到种子池有足够多的数据(通常会设定一个具体的参数,比如:52000),生成过程停止
接下来,我们逐一写代码实现
正式编码之前的一些准备工作
1、首先将代码下载到本地,下面两种方式均可
- 使用 Download 下载zip文件
- git clone https://github.com/yizhongw/self-instruct.git
// 因在windows上操作的,所以无法执行bash命令,故直接用python命令运行
2、进入conda环境(用的pytorch这个环境) ,安装相关的包
cd self-instruct-main
pip install -r requirements.txt
- Step1 通过模型生成新的指令
先看下原始人工标注的175种子数据的样式,共包含4个部分,id,name,instruction,is_classification
本次只是实验,故将scripts/generate_instructions.sh中的50000改为100(这样产生的费用也较少){ "id": "seed_task_0", "name": "breakfast_suggestion", "instruction": "Is there anything I can eat for a breakfast that doesn't include eggs, yet includes protein, and has roughly 700-1000 calories?", "instances": [{"input": "", "output": "Yes, you can have 1 oatmeal banana protein shake and 4 strips of bacon. The oatmeal banana protein shake may contain 1/2 cup oatmeal, 60 grams whey protein powder, 1/2 medium banana, 1tbsp flaxseed oil and 1/2 cup watter, totalling about 550 calories. The 4 strips of bacon contains about 200 calories."}], "is_classification": false }
运行命令如下:
大概需要4分半的时间,生成100条数据,它们会写入data/ceishi/machine_generated_instructions.jsonl中,最终生成了122条,这些数据是通过LLM生成的与种子任务关联度比较弱的一些任务描述(一些相似度高的就删除了)python self_instruct/bootstrap_instructions.py --batch_dir data/ceshi --num_instructions_to_generate 100 --seed_tasks_path data/seed_tasks.jsonl --engine "davinci" --api_key "自己的openai API"
从下面的代码中可以看出,最后写入文件时,一共包含了以下5个部分:instruction、most_similar、avg_similarity_score、metadata、request_idx
实际生成指令时,分两步:fout.write(json.dumps({ "instruction": inst, "most_similar": most_similar_instructions, "avg_similarity_score": float(np.mean(rouge_scores)), "metadata": metadata, "request_idx": request_idx }) + "\n")
第一步 先从种子池中随机抽取6个人工编写的指令,再随机抽取2个模型生成的指令,总共8个指令,为何是8?其实可以自定义,比如默认为8:https://github.com/yizhongw/self-instruct/blob/0b26ccaa415992100fa32df62d41b994cf928e23/self_instruct/bootstrap_instructions.py#L106(最开始的时候,是没有模型生成的指令,因此是会直接从种子池中随机抽取8条人工编写的指令)
第二步 按照指定模版格式组织之后,输入给模型,让模型输出一个新的指令parser.add_argument( "--num_prompt_instructions", type=int, default=8, help="The number of instructions to use in the prompt." )
最终,生成数据的核心代码如下:
其中,对不同类型的数据需要构建不同的 prompt 数据(如:是分类数据,不是分类数据),构建方式在函数encode_prompt中# load the LM-generated instructions,使用生成模型得到新的100条 instruction 提示 machine_instructions = [] # 开始生成100条instruction提示数据 # 使用文件操作打开一个文件,该文件位于指定的批处理目录中 # 文件名为"machine_generated_instructions.jsonl",以追加模式打开,然后把文件对象赋值给fout with open(os.path.join(args.batch_dir, "machine_generated_instructions.jsonl"), "a") as fout: # 进入循环,当生成模型产生的指令数量未达到用户指定的数量时,继续产生新的指令 while len(machine_instructions) < args.num_instructions_to_generate: # 初始化一个列表,用于保存批处理的输入数据 batch_inputs = [] # args.request_batch_size为5 # 循环指定的批处理大小的次数,每次循环都会产生一条新的指令 for _ in range(args.request_batch_size): # 调用函数从生成模型中抽样生成指令,这里选择的指令数量为2,然后将生成的指令保存到变量prompt_instructions prompt_instructions = sample_machine_instructions( machine_instructions, similarities=None, n=2) ''' sample human instructions from the pool 从默认的175条中选再选6条seed_instructions,加上上面使用LLM最初生成的2条prompt_instructions,相当于一共选了8条 (最开始的时候,machine_instructions为空,因此会直接从175条中直接选8条) ''' prompt_instructions += random.sample(seed_instructions, args.num_prompt_instructions - len(prompt_instructions)) # 对这8条指令进行随机排序 random.shuffle(prompt_instructions) # 将这8条指令编码成模型可以接收的输入格式,然后保存到变量prompt prompt = encode_prompt(prompt_instructions, classification=args.use_clf_seed_tasks_only) # 将编码后的输入添加到批处理的输入数据列表中 batch_inputs.append(prompt) # 调用函数使用GPT-3引擎对批处理的输入数据进行处理,处理的参数包括最大的输出词汇数量、输出的随机性、输出结果的顶部概率等 results = make_gpt3_requests( engine=args.engine, prompts=batch_inputs, max_tokens=1024, temperature=0.7, top_p=0.5, frequency_penalty=0, presence_penalty=2, stop_sequences=["\n\n", "\n16", "16.", "16 ."], logprobs=1, n=1, best_of=1, api_key=args.api_key, organization=args.organization, )
# 构建prompt数据,针对是否分类分别构建不同的prompt数据 # 定义一个函数,该函数用于将多个提示指令编码成一个字符串 # 该函数接受两个参数,第一个参数是提示指令列表,第二个参数表示是否是分类任务,是=>输出优先,否=>输入优先,对应的 prompt_instructions/prompt_instances 不一样 def encode_prompt(prompt_instructions, classification=False): """Encode multiple prompt instructions into a single string.""" # 如果当前任务是分类任务,那么设置提示信息为一个固定的字符串 if classification: # 这个提示信息是引导用户生成一系列的分类任务,如果可能的话,要求用户明确指定可能的输出标签 prompt = "Referring to a series of classification tasks, generate 8 more new tasks. Try to specify the possible output labels when possible.\n" # 如果当前任务不是分类任务,那么设置提示信息为另一个固定的字符串 else: # 这个提示信息是引导用户生成一系列的任务 prompt = "Referring to these eight tasks, generate 8 more new tasks:\n" # 循环处理每一条提示指令 for idx, instruction in enumerate(prompt_instructions): # 使用正则表达式将指令中的多余空格替换为单个空格,并去掉前后的空格以及末尾的冒号 instruction = re.sub(r"\s+", " ", instruction).strip().rstrip(":") # 将处理后的指令添加到提示信息中,注意指令前面需要添加序号 prompt += f"{idx+1}. {instruction}\n" # 在所有指令之后添加一个空白的序号,这个序号是接下来用户需要填写的新任务的序号 prompt += f"{len(prompt_instructions) + 1}." # 返回编码后的提示信息 return prompt
- Step2 对模型生成的指令进行判断
判断是否是分类任务
会写入data/ceishi/is_clf_or_not_davinci_template_1.jsonl中 (如上说的122条)python self_instruct/identify_clf_or_not.py --batch_dir data/ceshi --engine "davinci" --request_batch_size 5 --api_key "自己的openai API"
内容包括:
核心代码如下:{"instruction": "Find the largest number in this list.", "is_classification": " Yes"} {"instruction": "What is the first name of your favorite actor?", "is_classification": " No"} {"instruction": "Give me the number of distinct elements in this set.", "is_classification": " Yes"} {"instruction": "Give me the top 5 countries that are exporting tea.", "is_classification": " Yes"}
# 执行输出过程 # 使用文件操作打开一个输出文件,然后把文件对象赋值给fout with open(output_path, "w") as fout: # 迭代输入的数据行,步长为request_batch_size for batch_idx in range(0, len(lines), args.request_batch_size): # 对每个批次,将批次中的数据行转换为JSON对象 batch = [json.loads(line) for line in lines[batch_idx: batch_idx + args.request_batch_size]] # 检查批次中的所有指令是否都在已存在的请求中 if all(d["instruction"] in existing_requests for d in batch): # 如果都在,则直接从已存在的请求中获取数据,并写入到输出文件中 for d in batch: data = existing_requests[d["instruction"]] data = OrderedDict( (k, data[k]) for k in \ ["instruction", "is_classification"] ) fout.write(json.dumps(data, ensure_ascii=False) + "\n") else: # 如果不都在,那么需要使用GPT-3引擎生成数据 # 首先构造一个提示,这个提示包含前缀和指令 # prefix = compose_prompt_prefix(human_written_tasks, batch[0]["instruction"], 8, 2) prefix = templates[args.template] prompts = [prefix + " " + d["instruction"].strip() + "\n" + "Is it classification?" for d in batch] # 调用函数使用GPT-3引擎对批处理的输入数据进行处理 # 处理的参数包括最大的输出词汇数量、输出的随机性、输出结果的顶部概率等 results = make_gpt3_requests( engine=args.engine, prompts=prompts, max_tokens=3, temperature=0, top_p=0, frequency_penalty=0, presence_penalty=0, stop_sequences=["\n", "Task"], logprobs=1, n=1, best_of=1, api_key=args.api_key, organization=args.organization) # 将结果写入到输出文件中 for i in range(len(batch)): data = batch[i] # 如果结果存在,则将结果中的"is_classification"字段保存到数据中 if results[i]["response"] is not None: data["is_classification"] = results[i]["response"]["choices"][0]["text"] else: # 如果结果不存在,则将"is_classification"字段设置为空 data["is_classification"] = "" # 构造一个字典,包含指令和"is_classification"字段 data = { "instruction": data["instruction"], "is_classification": data["is_classification"] } # 对字典进行排序,然后将字典转换为JSON字符串,并写入到输出文件中 data = OrderedDict( (k, data[k]) for k in \ ["instruction", "is_classification"] ) fout.write(json.dumps(data, ensure_ascii=False) + "\n")
- Step3:根据Step2的判断结果,给出不同的输出
如果遇到以下报错:python self_instruct/generate_instances.py --batch_dir data/ceshi --input_file machine_generated_instructions.jsonl --output_file machine_generated_instances.jsonl --max_instances_to_gen 5 --engine "davinci" --request_batch_size 5 --api_key "自己的openai API"
UnicodeDecodeError: ‘gbk’ codec can’t decode byte 0x9d in position 6169: illegal multibyte sequence
解决方法:
在open函数中添加encoding='utf-8’即可
运行后会将结果写入 data/ceishi/machine_generated_instances.jsonl中。每条数据包含5部分:“instruction”, “raw_instances”, “instance_metadata”, “instruction_metadata”, “most_similar”, “avg_similarity_score”
核心代码如下『这段核心代码与上面step2最后的核心代码的区别在于:上段代码的重点是确定任务是否为分类任务,这段代码的重点是根据任务类型(分类或生成)生成任务实例 』:# 使用文件操作打开一个输出文件,以utf-8的编码格式,然后把文件对象赋值给fout with open(output_path, "w", encoding='utf-8') as fout: # 迭代任务数据,步长为request_batch_size for batch_idx in range(0, len(tasks), args.request_batch_size): # 获取当前批次的任务 batch = tasks[batch_idx: batch_idx + args.request_batch_size] # 检查批次中的所有指令是否都在已存在的请求中 if all(d["instruction"] in existing_requests for d in batch): # 如果都在,则直接从已存在的请求中获取数据,并写入到输出文件中 for d in batch: data = existing_requests[d["instruction"]] # 只选择关键字段创建有序字典 data = OrderedDict( (k, data[k]) for k in \ ["instruction", "raw_instances", "instance_metadata", "instruction_metadata", "most_similar", "avg_similarity_score"] ) # 写入数据到输出文件 fout.write(json.dumps(data, ensure_ascii=False) + "\n") else: # 如果不都在,那么需要构建请求的prompts prompts = [] for task in batch: # 根据任务的类型,使用不同的模板构建prompt if task_clf_types[task["instruction"]]: prompt = output_first_template_for_clf + " " + task["instruction"].strip() + "\n" prompts.append(prompt) else: prompt = input_first_template_for_gen + " " + task["instruction"].strip() + "\n" prompts.append(prompt) # 使用GPT-3引擎发送请求 results = make_gpt3_requests( engine=args.engine, prompts=prompts, # 根据任务类型调整最大token数 max_tokens=300 if any(task_clf_types[task["instruction"]] for task in batch) else 350, temperature=0, top_p=0, frequency_penalty=0, presence_penalty=1.5, stop_sequences=[f"Example {args.max_instances_to_generate + 1}", "Task:"], logprobs=1, n=1, best_of=1, api_key=args.api_key, organization=args.organization) # 将结果写入到输出文件中 for i in range(len(batch)): data = batch[i] # 保存请求的元数据 data["instance_metadata"] = results[i] # 如果结果存在,则保存生成的实例 if results[i]["response"] is not None: data["raw_instances"] = results[i]["response"]["choices"][0]["text"] else: # 如果结果不存在,则设置为空 data["raw_instances"] = "" # 构建有序字典 data = OrderedDict( (k, data[k]) for k in \ ["instruction", "raw_instances", "instance_metadata", "instruction_metadata", "most_similar", "avg_similarity_score"] ) # 写入数据到输出文件 fout.write(json.dumps(data, ensure_ascii=False) + "\n") # 更新进度条 progress_bar.update(len(batch))
- Step4:过滤及后处理
运行后会生成两个数据文件,均在data/ceshi/finetuning_data目录下:python self_instruct/prepare_for_finetuning.py --instance_files data/ceshi/machine_generated_instances.jsonl --classification_type_files data/ceshi/is_clf_or_not_davinci_template_1.jsonl --output_dir data/ceshi/finetuning_data --include_seed_tasks --seed_tasks_path data/seed_tasks.jsonl
all_generated_instances.jsonl 和 gpt3_finetuning_data_336.jsonl
其中,all_generated_instances.jsonl中包含的是 instruction,input,output
gpt3_finetuning_data_336.jsonl中包含的是prompt,completion
核心代码为# 使用tqdm模块,这是一个快速,可扩展的Python进度条,遍历生成的任务 for task in tqdm.tqdm(generated_tasks): # 从任务中提取出指令 instruction = task["instruction"] # 根据指令判断任务是否为分类任务,并存储结果 task["is_classification"] = task_clf_types[instruction] # 根据任务类型,解析并获取对应的实例 if task["is_classification"]: task_instances = parse_instances_for_classification_task(task["raw_instances"], instruction, task["instance_metadata"]) else: task_instances = parse_instances_for_generation_task(task["raw_instances"], instruction, task["instance_metadata"]) # 每个任务最多取5个实例,如果实例数少于5,则取全部 task_instances = random.sample(task_instances, min(len(task_instances), 5)) # 如果任务没有实例,则跳过当前循环 if not task_instances: continue # 将实例添加到训练实例列表中 training_instances += task_instances # 初始化GPT-3实例列表 gpt3_instances = [] # 遍历训练实例 for instance in training_instances: # 获取输入 inst_input = instance[1] # 对输入进行预处理,可能会去除冒号前的部分,或替换连续的两个新行符为一个新行符 if random.random() < 0.5: colon_words = re.findall(r"(\w+):", inst_input) if len(set(colon_words)) == 1: inst_input = inst_input.split(":", 1)[1].strip() else: inst_input = inst_input.strip() inst_input = inst_input.replace("\n\n", "\n") # 对实例进行编码,并添加到GPT-3实例列表 gpt3_instances.append(encode_instance(instance[0], inst_input, instance[2])) # 初始化过滤实例列表和实例集合,用于移除重复实例 filtered_instances = [] prompt_completion_set = set() # 遍历GPT-3实例 for instance in gpt3_instances: # 创建实例对 instance_pair = (instance["prompt"], instance["completion"]) # 如果实例对不在集合中,添加到集合和过滤实例列表中 if instance_pair not in prompt_completion_set: prompt_completion_set.add((instance["prompt"], instance["completion"])) filtered_instances.append(instance) # 使用过滤后的实例替换原来的GPT-3实例 gpt3_instances = filtered_instances # 打乱GPT-3实例顺序 random.shuffle(gpt3_instances) # 打开文件,准备将GPT-3实例写入文件 with open(os.path.join(args.output_dir, f"gpt3_finetuning_data_{len(gpt3_instances)}.jsonl"), "w") as fout: # 遍历GPT-3实例 for instance in gpt3_instances: # 将实例转化为json格式并写入文件 fout.write(json.dumps({ "prompt": instance["prompt"], "completion": instance["completion"], }) + "\n")
2.2 Stanford Alpaca的微调拆解——见证LLM微调的一般模式
2.2.1 stanford_alpaca/train.py代码的逐行分析
可能有读者疑问,那微调的代码长啥样呢?实际上,微调步骤大同小异,据代码stanford_alpaca/train.py at aa65c492bb788e144712daab42bc5d11c2761591 · tatsu-lab/stanford_alpaca · GitHub,可得微调的步骤如下
- 导入所需的库:包括torch,transformers等
import copy import logging from dataclasses import dataclass, field from typing import Optional, Dict, Sequence import torch import transformers from torch.utils.data import Dataset from transformers import Trainer import utils
- 定义一些全局变量,如特殊字符、提示模板等
- 定义用于处理模型、数据和训练参数的数据类
# 这是Python中的装饰器,用于指示该类是一个数据类。数据类是一个专门用于存储数据的类 # 它为我们自动实现了一些基础方法,如__init__,__repr__,__eq__等 @dataclass # 定义一个名为ModelArguments的数据类 class ModelArguments: # 定义一个名为model_name_or_path的实例变量,类型为Optional[str],默认值为"facebook/opt-125m" model_name_or_path: Optional[str] = field(default="facebook/opt-125m") @dataclass # 定义一个名为DataArguments的数据类 class DataArguments: # 定义一个名为data_path的实例变量,类型为str,默认值为None,额外的metadata提供了该变量的帮助信息 data_path: str = field(default=None, metadata={"help": "Path to the training data."}) @dataclass # 定义一个名为TrainingArguments的数据类,这个类继承了transformers库的TrainingArguments类 class TrainingArguments(transformers.TrainingArguments): # 定义一个名为cache_dir的实例变量,类型为Optional[str],默认值为None cache_dir: Optional[str] = field(default=None) # 定义一个名为optim的实例变量,类型为str,默认值为"adamw_torch" optim: str = field(default="adamw_torch") # 定义一个名为model_max_length的实例变量,类型为int model_max_length: int = field( default=512, metadata={"help": "Maximum sequence length. Sequences will be right padded (and possibly truncated)."}, )
- 定义辅助函数,如:
- safe_save_model_for_hf_trainer :安全地保存训练器中的模型
def safe_save_model_for_hf_trainer(trainer: transformers.Trainer, output_dir: str): """Collects the state dict and dump to disk.""" state_dict = trainer.model.state_dict() if trainer.args.should_save: cpu_state_dict = {key: value.cpu() for key, value in state_dict.items()} del state_dict trainer._save(output_dir, state_dict=cpu_state_dict) # noqa
- smart_tokenizer_and_embedding_resize :调整分词器和词嵌入大小
# 定义一个函数,函数名为 smart_tokenizer_and_embedding_resize,输入包括一个字典(用于定义特殊词汇),一个分词器和一个预训练模型 def smart_tokenizer_and_embedding_resize( special_tokens_dict: Dict, tokenizer: transformers.PreTrainedTokenizer, model: transformers.PreTrainedModel, ): """Resize tokenizer and embedding. Note: This is the unoptimized version that may make your embedding size not be divisible by 64. """ # 向分词器添加特殊词汇,返回新添加的词汇数量 num_new_tokens = tokenizer.add_special_tokens(special_tokens_dict) # 将模型的嵌入层大小调整为与新的词汇表大小一致 model.resize_token_embeddings(len(tokenizer)) # 如果添加了新的词汇 if num_new_tokens > 0: # 获取模型输入嵌入的权重数据 input_embeddings = model.get_input_embeddings().weight.data # 获取模型输出嵌入的权重数据 output_embeddings = model.get_output_embeddings().weight.data # 计算输入嵌入中旧词汇的平均向量 input_embeddings_avg = input_embeddings[:-num_new_tokens].mean(dim=0, keepdim=True) # 计算输出嵌入中旧词汇的平均向量 output_embeddings_avg = output_embeddings[:-num_new_tokens].mean(dim=0, keepdim=True) # 将新添加的词汇的输入嵌入向量设置为旧词汇的平均输入嵌入向量 input_embeddings[-num_new_tokens:] = input_embeddings_avg # 将新添加的词汇的输出嵌入向量设置为旧词汇的平均输出嵌入向量 output_embeddings[-num_new_tokens:] = output_embeddings_avg
- _tokenize_fn :将字符串序列进行分词
# 函数定义,接受一个字符串序列和一个预训练的分词器,返回一个字典 def _tokenize_fn(strings: Sequence[str], tokenizer: transformers.PreTrainedTokenizer) -> Dict: """Tokenize a list of strings.""" tokenized_list = [ tokenizer( text, # 对每个字符串进行分词处理 return_tensors="pt", # 返回PyTorch tensors padding="longest", # padding策略为 "longest",即填充到最长序列的长度 max_length=tokenizer.model_max_length, # 最大长度为分词器的最大长度 truncation=True, # 如果序列超过最大长度,则进行截断 ) for text in strings # 遍历输入的每个字符串 ] # 从分词结果中提取输入的ids和标签 input_ids = labels = [tokenized.input_ids[0] for tokenized in tokenized_list] # 计算输入ids和标签的长度(不包括padding) input_ids_lens = labels_lens = [ tokenized.input_ids.ne(tokenizer.pad_token_id).sum().item() for tokenized in tokenized_list ] # 返回一个字典,包含输入的ids、标签、输入的长度和标签的长度 return dict( input_ids=input_ids, labels=labels, input_ids_lens=input_ids_lens, labels_lens=labels_lens, )
- preprocess :预处理数据,对源数据和目标数据进行分词
# 函数定义,接受源字符串、目标字符串和一个预训练的分词器,返回一个字典 def preprocess( sources: Sequence[str], targets: Sequence[str], tokenizer: transformers.PreTrainedTokenizer, ) -> Dict: # 将源字符串和目标字符串组合在一起 examples = [s + t for s, t in zip(sources, targets)] # 对组合后的字符串和源字符串分别进行分词处理 examples_tokenized, sources_tokenized = [_tokenize_fn(strings, tokenizer) for strings in (examples, sources)] input_ids = examples_tokenized["input_ids"] # 从组合后的分词结果中提取输入ID labels = copy.deepcopy(input_ids) # 复制一份输入ID作为标签 for label, source_len in zip(labels, sources_tokenized["input_ids_lens"]): # 对于标签,将源字符串部分的ID设置为忽略索引(IGNORE_INDEX) label[:source_len] = IGNORE_INDEX # 返回一个字典,包含输入ID和标签 return dict(input_ids=input_ids, labels=labels)
- safe_save_model_for_hf_trainer :安全地保存训练器中的模型
- 定义SupervisedDataset 类(用于监督微调的数据集),用于加载数据、格式化输入、进行分词等操作
# 定义一个用于监督学习微调的数据集类 class SupervisedDataset(Dataset): def __init__(self, data_path: str, tokenizer: transformers.PreTrainedTokenizer): super(SupervisedDataset, self).__init__() # 初始化父类 logging.warning("Loading data...") # 记录开始加载数据的日志 list_data_dict = utils.jload(data_path) # 加载数据 logging.warning("Formatting inputs...") # 记录开始格式化输入的日志 # 从字典中获取输入提示和无输入提示 prompt_input, prompt_no_input = PROMPT_DICT["prompt_input"], PROMPT_DICT["prompt_no_input"] sources = [ prompt_input.format_map(example) if example.get("input", "") != "" else prompt_no_input.format_map(example) # 遍历每个例子,如果有输入则使用输入提示,否则使用无输入提示 for example in list_data_dict ] # 构造目标,每个目标是输出加上结束标记 targets = [f"{example['output']}{tokenizer.eos_token}" for example in list_data_dict] # 记录开始分词输入的日志 logging.warning("Tokenizing inputs... This may take some time...") data_dict = preprocess(sources, targets, tokenizer) # 预处理源和目标 self.input_ids = data_dict["input_ids"] # 保存输入ID self.labels = data_dict["labels"] # 保存标签 # 返回数据集的大小 def __len__(self): return len(self.input_ids) # 返回第i个样本,包含输入ID和标签 def __getitem__(self, i) -> Dict[str, torch.Tensor]: return dict(input_ids=self.input_ids[i], labels=self.labels[i])
- 定义DataCollatorForSupervisedDataset 类,用于将数据集的实例整理为批次
# 定义一个用于监督学习微调的数据整理类 @dataclass class DataCollatorForSupervisedDataset(object): # 预训练的分词器 tokenizer: transformers.PreTrainedTokenizer # 从实例中提取输入ID和标签 def __call__(self, instances: Sequence[Dict]) -> Dict[str, torch.Tensor]: input_ids, labels = tuple([instance[key] for instance in instances] for key in ("input_ids", "labels")) # 对输入ID进行填充,使它们具有相同的长度,填充值为分词器的填充标记ID input_ids = torch.nn.utils.rnn.pad_sequence( input_ids, batch_first=True, padding_value=self.tokenizer.pad_token_id ) # 对标签进行填充,使它们具有相同的长度,填充值为忽略索引(IGNORE_INDEX) labels = torch.nn.utils.rnn.pad_sequence(labels, batch_first=True, padding_value=IGNORE_INDEX) # 返回一个字典,包含输入ID、标签和注意力掩码。注意力掩码用于指示哪些元素应该被模型关注(在这里是非填充的元素) return dict( input_ids=input_ids, labels=labels, attention_mask=input_ids.ne(self.tokenizer.pad_token_id), )
- 定义make_supervised_data_module 函数,用于创建监督学习任务的数据集和整理器
# 函数定义,接受一个预训练的分词器和数据参数,返回一个字典 def make_supervised_data_module(tokenizer: transformers.PreTrainedTokenizer, data_args) -> Dict: """Make dataset and collator for supervised fine-tuning.""" # 创建一个监督学习的微调数据集 train_dataset = SupervisedDataset(tokenizer=tokenizer, data_path=data_args.data_path) # 创建一个数据整理器 data_collator = DataCollatorForSupervisedDataset(tokenizer=tokenizer) # 返回一个字典,包含训练数据集、评估数据集和数据整理器。在这里,评估数据集为None return dict(train_dataset=train_dataset, eval_dataset=None, data_collator=data_collator)
- 定义train函数def train() :,用于执行以下操作:
a. 解析命令行参数:使用transformers.HfArgumentParser 解析命令行参数,将它们分为模型参数、数据参数和训练参数
b. 加载预训练模型:使用transformers.AutoModelForCausalLM.from_pretrained 从预训练的模型检查点加载一个用于因果语言建模的模型parser = transformers.HfArgumentParser((ModelArguments, DataArguments, TrainingArguments)) model_args, data_args, training_args = parser.parse_args_into_dataclasses()
c. 加载分词器:使用transformers.AutoTokenizer.from_pretrained 从预训练的模型检查点加载分词器model = transformers.AutoModelForCausalLM.from_pretrained( model_args.model_name_or_path, cache_dir=training_args.cache_dir, )
d. 为分词器添加特殊字符:根据需要,将特殊字符添加到分词器中# 从预训练模型创建一个自动化的分词器,其中包含了模型的名称或路径,缓存目录,模型的最大长度,填充的位置以及是否使用快速分词器 tokenizer = transformers.AutoTokenizer.from_pretrained( model_args.model_name_or_path, cache_dir=training_args.cache_dir, model_max_length=training_args.model_max_length, padding_side="right", use_fast=False, ) # 如果分词器没有pad token,那么添加一个,并重新设置模型的嵌入大小 if tokenizer.pad_token is None: smart_tokenizer_and_embedding_resize( special_tokens_dict=dict(pad_token=DEFAULT_PAD_TOKEN), tokenizer=tokenizer, model=model, )
e. 创建数据集和整理器:使用make_supervised_data_module 函数为监督学习任务创建数据集和整理器# 如果模型名包含"llama",则为分词器添加特殊的token,包括eos_token,bos_token以及unk_token if "llama" in model_args.model_name_or_path: tokenizer.add_special_tokens( { "eos_token": DEFAULT_EOS_TOKEN, "bos_token": DEFAULT_BOS_TOKEN, "unk_token": DEFAULT_UNK_TOKEN, } )
f. 实例化Trainer类:实例化transformers.Trainer 类,并传入模型、分词器、训练参数以及数据集。Trainer类负责管理训练过程data_module = make_supervised_data_module(tokenizer=tokenizer, data_args=data_args)
g. 训练模型:调用Trainertrainer = Trainer(model=model, tokenizer=tokenizer, args=training_args, **data_module)
类的train()
方法对模型进行微调,相当于链路就是:transformers库 Trainer类 train函数
h. 保存模型状态:在训练完成后,调用Trainer.save_state() 方法保存模型的状态trainer.train()
i. 将训练器的模型安全地保存到磁盘:使用safe_save_model_for_hf_trainer 函数将训练器中的模型安全地保存到磁盘trainer.save_state()
trainer.save_model(output_dir=training_args.output_dir)
- 如果这个脚本是主程序,则调用train 函数以开始训练过程
if __name__ == "__main__": train()
这份训练/微调代码很经典,包括像此文《从GLM、ChatGLM-6B、MOSS、baichuan-7B到垂类医疗/金融/法律模型、可商用模型》第七部分“各种医疗类ChatGPT:或中英文数据微调LLaMA、或中文数据微调ChatGLM”里的chatdoctor (基于医疗语料微调LLaMA),便用到了这份代码
这是chatdoctor代码库中直接引用alpaca的微调代码:https://github.com/Kent0n-Li/ChatDoctor/blob/main/train.py#L192,一模一样,没有任何不同
2.2.2 Hugging face社区实现的鼎鼎大名的transformers库
但,可能很快便有同学疑问,怎么没有预想中的损失计算、梯度下降、参数更新呢,实际上这三步的具体实现都封装在了Hugging face社区实现的鼎鼎大名的transformers库的Trainer类中:transformers/trainer.py at main · huggingface/transformers · GitHub
这个 transformers/trainer.py 文件的主要部分如下
导入:文件首先导入了一些必要的Python库,如os、sys、logging以及其他一些库。它还导入了Hugging Face库中的一些相关模块,如datasets、transformers等
TrainerState:这个类用于保存训练器的状态,包括当前的epoch、迭代步数、最佳指标值等
TrainOutput:这个类用于返回训练过程的结果,包括训练损失、训练步数等
TrainerControl:这个类提供了一种用于控制训练循环的机制,例如,当用户想要在某个特定的迭代步数时停止训练
Trainer:这是文件中的主要类,用于训练和评估Transformers模型,它包含许多方法,如train、evaluate、predict等
更具体的,Trainer类包括如下关键方法:
__init__:初始化方法,用于创建训练器对象。它接收模型、训练参数、数据集等作为输入,并设置相关属性
def __init__( self, model: PreTrainedModel, args: TrainingArguments, train_dataset: Optional[Dataset] = None, eval_dataset: Optional[Dataset] = None, tokenizer: Optional[PreTrainedTokenizerBase] = None, data_collator: Optional[DataCollator] = None, train_iterator: Optional[DataLoader] = None, eval_iterator: Optional[DataLoader] = None, ... ):
train:这个方法负责整个训练过程,它包括遍历数据集、计算损失、计算梯度、更新模型参数以及日志记录等
遍历数据集:train方法通过使用dataloader来遍历训练数据集
for step, inputs in enumerate(epoch_iterator):
- 计算损失:损失计算在training_step方法中,接收输入数据并产生预测输出,然后,这个预测输出会与真实输出(标签)进行比较,以计算损失
outputs = model(**inputs)
上述代码行使用model(已经加载了预训练模型)和inputs(包含输入数据的字典)计算模型的预测输出。这个outputs变量包含模型预测的结果
接下来,我们从outputs中获取预测结果,并与真实标签(即labels)进行比较,以计算损失outputs.lossloss = outputs.loss
是模型预测输出和真实输出(标签)之间的损失。这个损失值将用于计算梯度并更新模型参数
计算梯度:loss.backward()这行代码计算模型参数关于损失的梯度
loss.backward()
梯度累积:且当gradient_accumulation_steps大于1时,梯度会被累积,而不是立即更新模型参数
if (step + 1) % self.args.gradient_accumulation_steps == 0:
更新模型参数:optimizer.step()这行代码根据计算出的梯度来更新模型参数
self.optimizer.step()
举个例子,
假定我们有1000个样本的数据集,我们可以将其分成10个小批次,每个小批次包含100个样本
梯度累积:在每个小批次的训练中,我们会计算出模型参数的梯度,然后将这些梯度累加起来(可以设定一个参数gradient_accumulation_steps,以指定我们想要累积多少个小批次的梯度,比如5),而不是立即用它们来更新模型参数
参数更新:当我们处理完gradient_accumulation_steps个小批次后,我们就使用累积的梯度来更新模型的参数
梯度清零:在每次参数更新后,我们都会将累积的梯度清零,以便于开始下一个梯度累积和参数更新的周期,最终处理完剩下的5个批次
值得一提的是,通常情况下,我们会进行多个epoch的训练(每次进行新的epoch时,数据打乱),每个epoch后都会对模型的性能进行评估,并根据评估结果来调整学习率等超参数学习率调整:lr_scheduler.step()根据预定义的学习率调度策略更新学习率
self.lr_scheduler.step()
日志记录:log方法用于记录训练过程中的一些关键指标,例如损失、学习率等
evaluate
:这个方法用于评估模型在验证数据集上的性能,返回评估结果def evaluate( self, eval_dataset: Optional[Dataset] = None, ignore_keys: Optional[List[str]] = None ) -> Dict[str, float]:
predict
:这个方法用于在给定的数据集上进行预测,返回预测结果def predict( self, test_dataset: Dataset, ignore_keys: Optional[List[str]] = None ) -> PredictionOutput:
save_model
:这个方法用于将训练好的模型保存到指定的目录def save_model(self, output_dir: Optional[str] = None):
ShardedDDPOption:这是一个可选的类,用于支持使用混合精度和ZeRO进行分布式训练
2.2.3 Alpaca-LoRA:通过PEFT库在消费级GPU上微调「基于LLaMA的Alpaca」
在神经网络模型中,模型参数通常以矩阵的形式表示。对于一个预训练好的模型,其参数矩阵已经包含了很多有用的信息。为了使模型适应特定任务,我们需要对这些参数进行微调
LoRA的核心思想是用一种低秩的方式来调整这些参数矩阵。在数学上,低秩意味着一个矩阵可以用两个较小的矩阵相乘来近似,通过论文《LORA: LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS》可知(这是解读之一)
- 选择目标层:首先,在预训练神经网络模型中选择要应用LoRA的目标层。这些层通常是与特定任务相关的,如自注意力机制中的查询Q和键K矩阵
- 初始化映射矩阵和逆映射矩阵:为目标层创建两个较小的矩阵A和B
A是映射矩阵(一般用随机高斯分布初始化,当然实际代码实现时,比如微软的deepspeed chat在用到LoRA时,一开始通过0矩阵占位,然后调用搭配ReLU激活函数的kaiming均匀分布初始化,虽与LoRA原始定义所用的正态分布初始化不同,但此两种初始化方式都可以工作,更多介绍见下面deepspeed chat的代码 ),维度上是降维
B是逆映射矩阵(用0矩阵初始化),维度上是升维
其中,矩阵的大小由LoRA的秩(rank)和alpha值确定 - 参数变换:将目标层的原始参数矩阵W通过映射矩阵A和逆映射矩阵B进行变换。计算公式为:,这里W'是变换后的参数矩阵
- 微调模型:使用新的参数矩阵替换目标层的原始参数矩阵,然后在特定任务的训练数据上对模型进行微调
- 梯度更新:在微调过程中,计算损失函数关于映射矩阵A和逆映射矩阵B的梯度,并使用优化算法(如Adam、SGD等)对A和B进行更新
注意,在更新过程中,原始参数矩阵W保持不变,说白了,训练的时候固定原始PLM的参数,只训练降维矩阵A与升维矩阵B - 重复更新:在训练的每个批次中,重复步骤3-5,直到达到预定的训练轮次(epoch)或满足收敛条件
总之,LoRA的详细步骤包括选择目标层、初始化映射矩阵和逆映射矩阵、进行参数变换和模型微调。在微调过程中,模型会通过更新映射矩阵U和逆映射矩阵V来学习特定任务的知识,从而提高模型在该任务上的性能
继续说一下,这个LoRA的应用还是挺广的,比如后续微软推出的DeepSpeed-Chat便用了这个方法
DeepSpeed-Chat的实现中,当设置LoRA的低秩维度lora_dim(如lora_dim=128)时,即认为启用了LoRA训练,则将原始模型中名称含有“deoder.layers.”且为线性层修改为LoRA层,具体操作为:
- 将原始结构的weight参数冻结;
- 新引入了2个线性层lora_right_weight和lora_left_weight (分别对应上图中的降维矩阵A、升维矩阵B ),可实现先降维至lora_dim再升维回原维度;
- LoRA层主要实现了两分支通路,一条分支为已被冻结weight参数的原始结构、另一条分支为新引入的降维再升维线性层组
# applications/DeepSpeed-Chat/training/step1_supervised_finetuning/main.py # 判断是否启用LoRA模式 if args.lora_dim > 0: """ 如果启用,则对名称中含有“decoder.layers.”且为线性层的结构部分引入LoRA旁路(实现先降维后升维的2个线性层), 这类结构基本都是attention、信息交互用的inner线性层, 这类结构的Weight参数将被冻结,转而优化LoRA旁路的参数。 """ args.lora_module_name = "decoder.layers." model = convert_linear_layer_to_lora(model, args.lora_module_name, args.lora_dim) # applications/DeepSpeed-Chat/training/utils/module/lora.py def convert_linear_layer_to_lora(model, part_module_name, lora_dim=0, lora_scaling=1, lora_droppout=0): """ 将名称中带有"decoder.layers."的线性层转换为lora层 """ """取出模型中参数名含有decoder.layers.的线性层""" repalce_name = [] for name, module in model.named_modules(): if isinstance(module, nn.Linear) and part_module_name in name: repalce_name.append(name) for name in repalce_name: """recursive_getattr实现了从model中根据属性名取出对应原始结构""" module = recursive_getattr(model, name) """纳入原始结构的参数,实例化lora层""" tmp = LinearLayer_LoRA( module.weight, lora_dim, lora_scaling, lora_droppout, module.bias).to(module.weight.device).to(module.weight.dtype) """recursive_getattr实现了将model对应属性的结构换成lora层实例""" recursive_setattr(model, name, tmp) return model # applications/DeepSpeed-Chat/training/utils/module/lora.py class LinearLayer_LoRA(nn.Module): """具体的lora层""" def __init__(...): ... """此处的weight和bias即为原始结构中的参数""" self.weight = weight self.bias = bias ··· """冻结weight部分的参数""" self.weight.requires_grad = False ··· self.lora_right_weight = nn.Parameter(torch.zeros(columns, lora_dim)) self.lora_left_weight = nn.Parameter(torch.zeros(lora_dim, rows)) ... """初始化LoRA线性层的参数""" self.reset_parameters() # 调用reset_parameters(self)做初始化 def reset_parameters(self): # 降维矩阵与LoRA原始定义所用的(0,\sigma^2)正态分布初始化不同,而是使用的kaiming均匀分布初始化 # kaiming服从均匀分布U(-\sqrt{1/in_feature}, +\sqrt{1/in_feature}) # f_i是矩阵的输入维度,就是nn.Linear(in_features, out_features)中的in_features # 对应上面代码中的columns,而这个columns相当于基座模型的hidden_size nn.init.kaiming_uniform_(self.lora_right_weight, a=math.sqrt(5)) # 升维矩阵使用全0初始化 nn.init.zeros_(self.lora_left_weight) def forward(self, input): """LoRA的正向传播""" ··· else: # F.linear(input, self.weight, self.bias)是使用给定的权重self.weight和偏差self.bias对输入数据input进行线性变换 # 这个操作等价于input @ self.weight.t() + self.bias,其中@表示矩阵乘法,.t()表示矩阵转置 return F.linear(input, self.weight, self.bias) # 1,self.lora_dropout(input)对输入进行了随机的dropout操作,这是一种正则化手段 # 2,对结果进行两次线性变换,一次是@ self.lora_right_weight,然后是@ self.lora_left_weight # 3,乘法部分* self.lora_scaling是对加号后面部分的结果进行缩放 + (self.lora_dropout(input) @ self.lora_right_weight @ self.lora_left_weight) * self.lora_scaling
再额外分析下 这段代码的最后部分
# applications/DeepSpeed-Chat/training/utils/module/lora.py class LinearLayer_LoRA(nn.Module): """具体的lora层""" ··· def forward(self, input): """LoRA的正向传播""" ··· else: return F.linear( input, self.weight, self.bias) + (self.lora_dropout(input) @ self.lora_right_weight @ self.lora_left_weight) * self.lora_scaling
常规部分的正向传播由transformers所定义,而LoRA部分的正向传播则由LinearLayer_LoRA(nn.Module)的forward()所定义,即“LoRA层的两条分支结果进行加和”
在代码中体现为
F.linear(input, self.weight, self.bias) + (self.lora_dropout(input) @ self.lora_right_weight @ self.lora_left_weight) * self.lora_scaling
加号左侧为原结构支路,加号右侧为新增支路,self.lora_right_weight 和self.lora_left_weight 分别为两个新引入线性层的参数
而Huggingface公司推出的PEFT(Parameter-Efficient Fine-Tuning)库也封装了LoRA这个方法,PEFT库可以使预训练语言模型高效适应各种下游任务,而无需微调模型的所有参数,即仅微调少量(额外)模型参数,从而大大降低了计算和存储成本
Model | Full Finetuning | PEFT-LoRA PyTorch | PEFT-LoRA DeepSpeed with CPU Offloading |
---|---|---|---|
bigscience/T0_3B (3B params) | 47.14GB GPU / 2.96GB CPU | 14.4GB GPU / 2.96GB CPU | 9.8GB GPU / 17.8GB CPU |
bigscience/mt0-xxl (12B params) | OOM GPU | 56GB GPU / 3GB CPU | 22GB GPU / 52GB CPU |
bigscience/bloomz-7b1 (7B params) | OOM GPU | 32GB GPU / 3.8GB CPU | 18.1GB GPU / 35GB CPU |
且PEFT库 (peft/src/peft/peft_model.py at main · huggingface/peft · GitHub)支持以下流行的方法
- LoRA,PEFT对LoRA的实现封装见:peft/src/peft/tuners/lora.py at main · huggingface/peft · GitHub,比如对权重的合并代码 (和上面DSC对LoRA权重合并的实现,在本质上是一致的)
def merge(self): # 检查当前激活的适配器是否在lora_A的键中,如果不在则终止函数 if self.active_adapter not in self.lora_A.keys(): return if self.merged: warnings.warn("Already merged. Nothing to do.") return # 如果激活适配器的r值大于0,表示有可以合并的权重 if self.r[self.active_adapter] > 0: # 在当前的权重上加上计算得到的新权重 self.weight.data += ( # 转置运算 transpose( # 通过矩阵乘法计算新的权重 self.lora_B[self.active_adapter].weight @ self.lora_A[self.active_adapter].weight, # 这是转置运算的维度参数 self.fan_in_fan_out, ) # 然后将计算得到的权重乘以对应的缩放因子 * self.scaling[self.active_adapter] ) self.merged = True
- Prefix Tuning: Prefix-Tuning: Optimizing Continuous Prompts for Generation, P-Tuning v2: Prompt Tuning Can Be Comparable to Fine-tuning Universally Across Scales and Tasks
- P-Tuning: GPT Understands, Too
- Prompt Tuning: The Power of Scale for Parameter-Efficient Prompt Tuning
故而,Alpaca-LoRA即可以通过PEFT库实现的LoRA方法在消费级GPU微调「基于LLaMA的Alpaca」,比如项目中的这个文件finetune.py 包含了PEFT在LLaMA上的直接应用,以及一些与prompt construction和tokenization相关的代码,以下是用法示例:
python finetune.py \
--base_model 'decapoda-research/llama-7b-hf' \
--data_path 'yahma/alpaca-cleaned' \
--output_dir './lora-alpaca'
我们还可以调整我们的超参数(为方便大家理解,我给每个参数都加了注释说明):
python finetune.py \ # 运行微调脚本
--base_model 'decapoda-research/llama-7b-hf' \ # 选择预训练的基础模型
--data_path 'yahma/alpaca-cleaned' \ # 用于微调的数据集路径
--output_dir './lora-alpaca' \ # 微调后模型的输出目录
--batch_size 128 \ # 设置每个批次的样本数量
--micro_batch_size 4 \ # 设置每个小批次的样本数量
--num_epochs 3 \ # 设置训练的轮次(epoch)
--learning_rate 1e-4 \ # 设置学习速率
--cutoff_len 512 \ # 设置截断长度
--val_set_size 2000 \ # 设置验证集的大小
--lora_r 8 \ # 设置LoRA方法中的秩
--lora_alpha 16 \ # 设置LoRA方法中的alpha值
--lora_dropout 0.05 \ # 设置LoRA方法中的dropout率
--lora_target_modules '[q_proj,v_proj]' \ # 设置使用LoRA进行微调的模型模块
--train_on_inputs # 指示模型在训练时使用输入文本
2.3 Alpaca所用的self-instruct的影响力:解决一大批模型的数据扩展问题
很快,通过下文你会发现
- self-instruct启发出很多「羊驼类模型」
羊驼率先带动的self-instruct,启发后续很多人/团队也用这个方式去采集『提示ChatGPT API』的数据,比如BELLE、ChatLLaMA、ColossalChat - 很多「羊驼类模型」的数据被用于微调新一批模型
然后还有一批模型各种叠加组合比如『Alpaca/BELLE』,又用于微调一批批模型
比如ChatDoctor 有用到Alpaca的数据进行微调,再比如有人拿BELLE数据tuning去调chatglm
一下子出来这么新的模型 似乎有点懵,没事,请看下文及下一篇文章娓娓道来..
2.3.1 UC Berkeley的Vicuna/FastChat:通过ShareGPT.com的7万条对话数据微调LLaMA
23年3.31日,受 Meta LLaMA 和 Stanford Alpaca 项目的启发,加州大学伯克利分校(UC Berkeley)等大学的研究者根据从 ShareGPT.com (ShareGPT是一个用户可以分享他们的 ChatGPT 对话的网站)收集的用户共享对话微调 LLaMA 推出了Vicuna-13B(中文称小羊驼,代码地址:FastChat)。
在数据规模上,Vicuna从ShareGPT.com 的公共 API 收集了大约 70K 用户共享对话,且为了确保数据质量,原作者们将 HTML 转换回 markdown 并过滤掉一些不合适或低质量的样本。此外,将冗长的对话分成更小的部分,以适应模型的最大上下文长度,并做了以下改进
- 内存优化:为了使 Vicuna 能够理解长上下文,将最大上下文长度从羊驼中的 512 扩展到 2048,这大大增加了 GPU 内存需求,对此通过利用梯度检查点和闪存注意力来解决内存压力
- 多轮对话:调整训练损失以考虑多轮对话,并仅根据聊天机器人的输出计算微调损失。
- 通过Spot Instance 降低成本:40 倍大的数据集和 4 倍的训练序列长度对训练费用提出了相当大的挑战。原作者们使用SkyPilot managed spot 来降低成本『SkyPilot是加州大学伯克利分校构建的一个框架,用于在各种云上轻松且经济高效地运行 ML 工作负载』,方法是利用更便宜的spot instances以及auto-recovery for preemptions and auto zone switch
该解决方案将 7B 模型的训练成本从 500 美元削减至 140 美元左右,将 13B 模型的训练成本从 1000 美元左右削减至 300 美元
有两点值得一提的是
- 对于个人开发者而言,Vicuna-13B 只需要大约 28GB 的GPU 显存,Vicuna-7B 大约需要14GB GPU显存,但对于机构而言,一般通过8个具有 80GB 显存的 A100 GPU 进行训练
- 且Vicuna使用了和Alpaca差不多的超参数
Hyperparameter 全局批量大小
Batch Size
学习率
Learning rate
Epochs Max length Weight decay Vicuna-13B 128 2e-5 3 2048 0
最终通过直接使用GPT4评估之后,效果还不错
Model Name | LLaMA(骆驼) | Alpaca(羊驼) | Vicuna(小羊驼) | Bard/ChatGPT |
Dataset | Publicly available datasets (1.4T token) |
Self-instruct from davinci-003 API (52K samples) |
User-shared conversations (70K samples) |
N/A |
Training code | N/A | Available | Available | N/A |
Evaluation metrics | Academic benchmark | Author evaluation | GPT-4 assessment | Mixed |
Training cost (7B) |
82K GPU-hours | $500 (data) + $100 (training) | $140 (training) | N/A |
Training cost (13B) |
135K GPU-hours | N/A | $300 (training) | N/A |
2.3.2 链家BELLE:结合中文语料通过Self Instruct方式微调BLOOMZ-7B或LLaMA
Stanford Alpaca的种子任务都是英语,收集的数据也都是英文,因此训练出来的模型未对中文优化。为了提升对话模型在中文上的效果,70 亿参数的中文对话大模型 BELLE『Bloom-Enhanced Large Language model Engine』来了(这是项目地址)。
在数据方面,结合以下两方面的数据:
- Alpaca 的 5.2 万条英文数据
- 通过Alpaca的数据收集代码生成的约 100 万条中文数据『也仅使用由 GPT3.5 即模型text-davinci-003 生产的数据,不包含任何其他数据,如果想使用ChatGPT的API比如gpt-3.5-turbo模型,可通过参数控制』
模型训练上,有
- 基于BLOOMZ-7B1-mt优化后的模型:BELLE-7B-0.2M,BELLE-7B-0.6M,BELLE-7B-1M,BELLE-7B-2M
- 基于huggingface的LLaMA实例实现调优的模型:BELLE-LLAMA-7B-2M,BELLE-LLAMA-13B-2M
BLOOM是由HuggingFace于2022年3月中旬推出的大模型,规模最大版本的参数量达到176B(GPT-3是175B),基于从 Megatron-LM GPT-2修改而来的仅解码器 transformer 模型架构
对应的论文为《BLOOM: A 176B-Parameter Open-Access Multilingual Language Model》(翻译之一,解读之一)
此外,这里有篇不错的文章(重点讲了下Megatron-DeepSpeed):千亿参数开源大模型 BLOOM 背后的技术
至于HuggingFace是著名开源工具Transformers的开发公司,很多推理工具都会支持Transformers中的模型
截至23年3月中旬,超过100B参数量且能够支持中文的开源大模型只有BLOOM和GLM-130B
该项目主要包含以下三部分内容:
- 175 个中文种子任务,斯坦福Alpaca一样,每个任务都包含对应的指令/任务、prompt、输出
zh_seed_tasks.jsonl:样例如下
{ "id": "seed_task_20", "name": "horror_movie_opening",
"instruction": "你需要为一部恐怖电影写一个创意的开场场景。",
"instances": [{"input": "","output":" 太阳已经落山,留下了一个黑暗的小镇。微风吹拂空荡的街道,让每一个冒险走出门外的人感到一阵寒意。唯一的声音是被风吹动的树叶发出的轻微沙沙声。突然,一声令人毛骨悚然的尖叫声划破了寂静,随后是玻璃破碎的声音。一所房子亮起了灯光,可以看到一个人影朝镇中心奔跑。当> 那个人影越来越靠近时,清楚地看到那是一个年轻女子,她浑身血迹斑斑。"}],
"is_classification": false } - prompt_cn.txt: 生成所使用的提示语
0.5M 生成的数据 - 生成数据及其代码
沿用 Alpaca 的方式:
pip install -r requirements.txt
export OPENAI_API_KEY=YOUR_API_KEY
python generate_instruction.py generate_instruction_following_data
默认使用 Completion API,模型 text-davinci-003。如果想使用 Chat API 并使用 gpt-3.5-turbo 模型,可通过参数控制:
python generate_instruction.py generate_instruction_following_data \
--api=chat --model_name=gpt-3.5-turbo
输出文件在 Belle.train.json,可以人工筛选后再使用 - 基于 BLOOMZ-7B1-mt 模型和 Belle.train.json 训练模型
2.4 Chinese-LLaMA/Chinese-Alpaca:通过中文数据预训练/指令微调
Chinese LLaMA(也称中文LLaMA,有7B和13B两个版本,项目地址),相当于在原版LLaMA的基础上扩充了中文词表并使用了中文数据进行二次预训练,进一步提升了中文基础语义理解能力,同时,在中文LLaMA的基础上,且用中文指令数据进行指令精调得Chinese-Alpaca(也称中文Alpaca,同样也有7B和13B两个版本)
具体而言,主要做了以下三方面的工作
2.4.1 词表扩充中文数据
在通用中文语料上训练了基于sentencepiece的20K中文词表并与原版LLaMA模型的32K词表进行合并
排除重复的token后,得到的最终中文LLaMA词表大小为49953
需要注意的是,在fine-tune阶段Alpaca比LLaMA多一个pad token,所以中文Alpaca的词表大小为49954
这么做的主要原因是原版LLaMA模型的词表大小是32K,其主要针对英语进行训练,对多语种支持不是特别理想(可以对比一下多语言经典模型XLM-R的词表大小为250K)。通过初步统计发现,LLaMA词表中仅包含很少的中文字符,所以在切词时会把中文切地更碎,需要多个byte token才能拼成一个完整的汉字,进而导致信息密度降低
其对应的扩充词表的脚本代码为(为方便大家更好的理解,我给每一行的代码 都加上了注释)
# # 导入os模块,用于操作系统相关操作
import os
# 设置环境变量,使得Protocol Buffers使用Python实现
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"]="python"
# 导入LlamaTokenizer类
from transformers import LlamaTokenizer
# 导入Protocol Buffers格式的sentencepiece模型
from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model
# 导入sentencepiece模块
import sentencepiece as spm
# 导入argparse模块,用于处理命令行参数
import argparse
# 创建一个命令行参数解析器实例
parser = argparse.ArgumentParser()
# 添加llama_tokenizer_dir参数,必需
parser.add_argument('--llama_tokenizer_dir', default=None, type=str, required=True)
# 添加chinese_sp_model_file参数,可选
parser.add_argument('--chinese_sp_model_file', default='./chinese_sp.model', type=str)
# 解析命令行参数
args = parser.parse_args()
# 获取llama_tokenizer_dir参数值
llama_tokenizer_dir = args.llama_tokenizer_dir
# 获取chinese_sp_model_file参数值
chinese_sp_model_file = args.chinese_sp_model_file
# load, 加载预训练LlamaTokenizer实例
llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir)
# 创建SentencePieceProcessor实例
chinese_sp_model = spm.SentencePieceProcessor()
# 加载中文sentencepiece模型
chinese_sp_model.Load(chinese_sp_model_file)
# 将LlamaTokenizer和中文sentencepiece模型转换为Protocol Buffers格式
llama_spm = sp_pb2_model.ModelProto()
llama_spm.ParseFromString(llama_tokenizer.sp_model.serialized_model_proto())
chinese_spm = sp_pb2_model.ModelProto()
chinese_spm.ParseFromString(chinese_sp_model.serialized_model_proto())
# print number of tokens
# 输出LlamaTokenizer和中文sentencepiece模型的词汇数量
print(len(llama_tokenizer),len(chinese_sp_model))
# 输出LlamaTokenizer的所有特殊词汇
print(llama_tokenizer.all_special_tokens)
# 输出LlamaTokenizer的所有特殊词汇ID
print(llama_tokenizer.all_special_ids)
# 输出LlamaTokenizer的特殊词汇映射
print(llama_tokenizer.special_tokens_map)
# 将中文词汇添加到LLaMA tokenizer中
# 提取LLaMA tokenizer中的词汇
llama_spm_tokens_set=set(p.piece for p in llama_spm.pieces)
print(len(llama_spm_tokens_set))
print(f"Before:{len(llama_spm_tokens_set)}")
for p in chinese_spm.pieces:
piece = p.piece
# 如果中文词汇不存在于LLaMA tokenizer中
if piece not in llama_spm_tokens_set:
new_p = sp_pb2_model.ModelProto().SentencePiece()
new_p.piece = piece
new_p.score = 0
# 将中文词汇添加到LLaMA tokenizer中
llama_spm.pieces.append(new_p)
print(f"New model pieces: {len(llama_spm.pieces)}")
# Save, 设置输出目录,用于保存合并后的sentencepiece模型
output_sp_dir = 'merged_tokenizer_sp'
# 设置输出目录,用于保存合并后的Chinese-LLaMA tokenizer
output_hf_dir = 'merged_tokenizer_hf'
# 创建输出目录(如果不存在)
os.makedirs(output_sp_dir, exist_ok=True)
# 打开合并后的sentencepiece模型文件,准备写入
with open(output_sp_dir + '/chinese_llama.model', 'wb') as f:
# 将合并后的sentencepiece模型序列化为字符串并写入文件
f.write(llama_spm.SerializeToString())
# 从合并后的sentencepiece模型文件中创建LlamaTokenizer实例
tokenizer = LlamaTokenizer(vocab_file=output_sp_dir + '/chinese_llama.model')
# 保存合并后的Chinese-LLaMA tokenizer到指定目录
tokenizer.save_pretrained(output_hf_dir)
# 输出保存信息
print(f"Chinese-LLaMA tokenizer has been saved to {output_hf_dir}")
# Test
# 重新加载原始的LLaMA tokenizer
llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir)
# 加载合并后的Chinese-LLaMA tokenizer
chinese_llama_tokenizer = LlamaTokenizer.from_pretrained(output_hf_dir)
# 输出合并后的tokenizer的所有特殊词汇
print(tokenizer.all_special_tokens)
# 输出合并后的tokenizer的所有特殊词汇ID
print(tokenizer.all_special_ids)
# 输出合并后的tokenizer的特殊词汇映射
print(tokenizer.special_tokens_map)
# 定义测试文本
text = '''白日依山尽,黄河入海流。欲穷千里目,更上一层楼。
The primary use of LLaMA is research on large language models, including'''
# 输出测试文本
print("Test text:\n", text)
print
# 使用原始的LLaMA tokenizer对文本进行分词
print(f"Tokenized by LLaMA tokenizer:{llama_tokenizer.tokenize(text)}")
# 使用合并后的Chinese-LLaMA tokenizer对文本进行分词
print(f"Tokenized by Chinese-LLaMA tokenizer:{chinese_llama_tokenizer.tokenize(text)}")
这段代码的主要目的是将一个中文的sentencepiece模型与一个已经预训练好的LLaMA tokenizer进行合并,以便在处理中文文本时,LLaMA tokenizer能更好地进行分词
整个过程包括了加载模型、合并模型、保存新的tokenizer以及进行测试等步骤,具体如下
- 首先,通过argparse模块获取命令行参数,包括原始的LLaMA tokenizer的路径和中文sentencepiece模型的路径
- 接着,加载这两个模型,并将它们转换为Protocol Buffers格式,方便进行操作
- 然后,从中文sentencepiece模型中提取词汇,并将这些词汇添加到LLaMA tokenizer中
在这个过程中,需要检查每个中文词汇是否已经存在于LLaMA tokenizer中,以避免重复添加 - 将合并后的模型保存到指定的目录
即首先保存为sentencepiece模型文件,然后创建一个新的LlamaTokenizer实例,并将其保存为Hugging Face格式的tokenizer - 最后,对原始的LLaMA tokenizer和合并后的Chinese-LLaMA tokenizer进行测试,以验证合并是否成功
测试包括输出特殊词汇、特殊词汇ID、特殊词汇映射等信息,以及使用这两个tokenizer对给定文本进行分词
从测试结果可以看出,合并后的Chinese-LLaMA tokenizer能够更好地处理中文文本
此外,七月在线ChatGPT原理解析课一学员在群内问道:“如何扩充词表,训练embedding,然后再与llama的合并,想在自己的数据上试试”
“吹牛班的春天”答道:“我知道的方法就是直接改embedding结构:初始化参数concat到以前embedding层上,以前的权embedding权重就保留,多出来的部分就后面更新,下图是以前BERT无损扩词的思路,可做参考”
2.4.2 加入中文数据的预训练
在预训练阶段,使用约20G左右的通用中文语料(与中文BERT-wwm、MacBERT、LERT、PERT中使用的语料一致)在原版LLaMA权重的基础上进一步进行预训练。该过程又分为两个阶段:
第一阶段:冻结transformer参数,仅训练embedding,在尽量不干扰原模型的情况下适配新增的中文词向量
第二阶段:使用LoRA技术,为模型添加LoRA权重(adapter),训练embedding的同时也更新LoRA参数
2.4.3 指令精调
指令精调阶段的任务形式基本与Stanford Alpaca相同,训练方案同样采用了LoRA进行高效精调,并进一步增加了可训练参数数量
在prompt设计上,精调以及预测时采用的都是原版Stanford Alpaca不带input的模版。对于包含input字段的数据,采用f"{instruction}+\n+{input}"
的形式进行拼接
且指令精调阶段使用了以下数据,其中7B模型约2M数据、13B模型约3M数据。基本构成如下:
数据 | 量级 | 来源 | 说明 |
---|---|---|---|
中英翻译数据 | 500K | 外部链接 | 在原数据集的基础上进行了采样+规则筛选 |
pCLUE数据 | 300K | 外部链接 | 在原数据集的基础上进行了采样+规则筛选 |
Alpaca数据(英) | 50K | 外部链接 | 斯坦福原版Alpaca训练数据 |
Alpaca数据(中) | 50K | 本地链接 | 本项目使用ChatGPT接口将英文版翻译为中文(筛掉一部分) |
Self-instruction数据 | 1~2M | (暂无) | 本项目使用ChatGPT接口进行爬取,提供了一个动态生成不同领域和指令类型的prompt爬取脚本script/crawl_prompt.py。 python script/crawl_prompt.py output-file |
当然,针对一些任务上效果不好!原作者也给出了几个可能的原因,
1)本身LLaMA对中文支持不是很好,大多数相关衍生工作是直接在原版上进行pretrain/finetune的,而我们采取了更大胆的策略——增加中文词表,可能进一步加剧中文训练不充分的问题,但从长远看是否有利于后续进一步预训练就得靠时间检验了;
2)指令数据的质量有待进一步提升;
3)训练时间、超参等方面还有很大调整空间;
4)没有RLHF;
5)4-bit量化后效果可能会下降,因此可以尝试加载FP16模型,效果相对更好一些(也更慢)
2.5 基于LLaMA微调的各模型对比:Alpaca/Vicuna/BELLE/Chinese-LLaMA
项目 | 一句话描述 |
Stanford Alpaca |
结合英文语料通过Self Instruct方式微调LLaMA 7B |
Vicuna-13B |
通过ShareGPT.com的7万条对话数据微调LLaMA |
BELLE | 结合中文语料通过Self Instruct方式微调BLOOMZ-7B或LLaMA |
Chinese-LLaMA/Chinese-Alpaca |
通过中文数据预训练/指令微调LLaMA |
ChatLLaMA(英文版) | LLaMA的RLHF版 |
通过self-instruct技术指令微调LLaMA且加上RLHF |
更多请查看下一篇:从GLM、ChatGLM-6B、MOSS、baichuan-7B到垂类医疗/金融/法律模型、可商用模型