前言
近期,除了研究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、填充标记 IDself.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防止取倒数之后分母为0self.eps = epsself.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是末尾乘的可训练参数,即gireturn 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 mathdef 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_posk2 = torch.stack([-k[..., 1::2], k[..., ::2]], dim=-1)k2 = k2.reshape(k.shape)# 更新kw, *对应位置相乘k = k * cos_pos + k2 * sin_posreturn 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:# 对权重进行dropoutatt_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_scoresif __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] = xkself.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 Fclass FeedForward(nn.Module):def __init__(self,dim: int,hidden_dim: int,multiple_of: int,):super().__init__()# 初始化隐藏层的维度为输入维度的2/3hidden_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 Optionalclass 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 # 当前层的IDself.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&Normh = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask)# 上一步的输出h作为输入,经过前馈神经网络Feed forward之后,做Add&Normout = 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 Optionalclass Transformer(nn.Module):def __init__(self, params: ModelArgs):super().__init__()# 初始化参数self.params = paramsself.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 = Noneif 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)# 逐层计算Transformerfor layer in self.layers:h = layer(h, start_pos, freqs_cis, mask)h = self.norm(h)output = self.output(h[:, -1, :]) # 只计算最后一个位置的logitsreturn 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 = modelself.tokenizer = tokenizerdef 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_sizeprev_pos = 0# 逐个生成标记for cur_pos in range(start_pos, total_len):# 通过模型进行前向传递以获取logitslogits = 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_tokenprev_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 decodeddef 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# 将被过滤的标记的概率设置为0probs_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":continueidx += num_prompt_instructions + 1# 根据索引和"Instruction", "Input", "Output"关键字进行切分splitted_data = re.split(f"{idx}\.\s+(Instruction|Input|Output):", inst)if len(splitted_data) != 7: # 如果切分结果不等于7,则继续下一轮循环continueelse:# 提取指令、输入、输出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",以追加模式打开,然后把文件对象赋值给foutwith 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_instructionsprompt_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条指令编码成模型可以接收的输入格式,然后保存到变量promptprompt = 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"}
# 执行输出过程# 使用文件操作打开一个输出文件,然后把文件对象赋值给foutwith open(output_path, "w") as fout:# 迭代输入的数据行,步长为request_batch_sizefor 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_sizefor 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:# 如果不都在,那么需要构建请求的promptsprompts = []for task in batch:# 根据任务的类型,使用不同的模板构建promptif 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, Sequenceimport torch import transformers from torch.utils.data import Dataset from transformers import Trainerimport 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的实例变量,类型为intmodel_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_dicttrainer._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 tensorspadding="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"] # 从组合后的分词结果中提取输入IDlabels = 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"] # 保存输入IDself.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进行填充,使它们具有相同的长度,填充值为分词器的填充标记IDinput_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) # 返回一个字典,包含训练数据集、评估数据集和数据整理器。在这里,评估数据集为Nonereturn 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_tokenif "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 = weightself.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_sizenn.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(中文称小羊驼,GitHub地址:FastChat)
在数据规模上,Vicuna从ShareGPT.com 的公共 API 收集了大约 70K 用户共享对话,且为了确保数据质量,原作者们将 HTML 转换回 markdown 并过滤掉一些不合适或低质量的样本。此外,将冗长的对话分成更小的部分,以适应模型的最大上下文长度,并做了以下改进
- 内存优化:为了使 Vicuna 能够理解长上下文,将最大上下文长度从羊驼Alpaca中的 512 扩展到 2048,这大大增加了 GPU 内存需求,对此通过利用梯度检查点和闪存注意力来解决内存压力 (We tackle the memory pressure by utilizing gradient checkpointing and flash attention)
- 多轮对话:调整训练损失以考虑多轮对话,并仅根据聊天机器人的输出计算微调损失
- 通过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的预训练是一天之内通过8个具有 80GB 显存的 A100 GPU 进行训练的,预训练好之后单纯部署的话,Vicuna-13B 需要大约 28GB 的GPU 显存,Vicuna-7B 大约需要14GB GPU显存
- 且Vicuna使用了和Alpaca差不多的超参数
Hyperparameter 全局批量大小
Batch Size
学习率
Learning rate
Epochs Max length Weight decay Vicuna-13B 128 2e-5 3 2048 0
最终通过直接使用GPT4评估之后(基于 GPT-4 的评估框架来自动评估聊天机器人的性能),效果还不错
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才能拼成一个完整的汉字,进而导致信息密度降低
其对应的扩充词表的脚本代码为 (代码地址在:merge_tokenizers.py,为方便大家更好的理解,我给每一行的代码 都加上了注释)
# # 导入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 = piecenew_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 姜子牙系列模型Ziya-LLaMA-13B-v1
2.5.1 基本信息:软件依赖/模型分类
姜子牙通用大模型V1是基于LLaMa的130亿参数的大规模预训练模型 (论文地址:Zero-Shot Learners for Natural Language Understanding via a Unified Multiple Choice Perspective),具备翻译,编程,文本分类,信息抽取,摘要,文案生成,常识问答和数学计算等能力。目前姜子牙通用大模型已完成大规模预训练、多任务有监督微调和人类反馈学习三阶段的训练过程「large-scale continual pre-training (PT), multi-task supervised fine-tuning (SFT), and human feedback learning (RM, PPO)」
软件依赖
pip install torch==1.12.1 tokenizers==0.13.3 git+https://github.com/huggingface/transformers
模型分类 Model Taxonomy
需求 Demand | 任务 Task | 系列 Series | 模型 Model | 参数 Parameter | 额外 Extra |
---|---|---|---|---|---|
通用 General | AGI模型 | 姜子牙 Ziya | LLaMA | 13B | English&Chinese |
2.5.2 模型的预训练与微调:预训练、SFT、HFFT
继续预训练 Continual pretraining
- 数据方面
原始数据包含英文和中文,其中英文数据来自openwebtext、Books、Wikipedia和Code,中文数据来自清洗后的悟道数据集、自建的中文数据集。在对原始数据进行去重、模型打分、数据分桶、规则过滤、敏感主题过滤和数据评估后,最终得到125B tokens的有效数据「After deduplication, model scoring, data bucketing, rule filtering, sensitive topic filtering, and data evaluation, we finally obtained 125 billion tokens of valid data」 - 分词方面
为了解决LLaMA原生分词对中文编解码效率低下的问题,我们在LLaMA词表的基础上增加了7k+个常见中文字,通过和LLaMA原生的词表去重,最终得到一个39410大小的词表,并通过复用Transformers里LlamaTokenizer来实现了这一效果「We achieved this by reusing the LlamaTokenizer in Transformers」 - 训练过程
在增量训练过程中,我们使用了160张40GB的A100,采用2.6M tokens的训练集样本数量和FP 16的混合精度,吞吐量达到118 TFLOP per GPU per second。因此我们能够在8天的时间里在原生的LLaMA-13B模型基础上,增量训练110B tokens的数据
训练期间,虽然遇到了机器宕机、底层框架bug、loss spike等各种问题,但我们通过快速调整,保证了增量训练的稳定性。我们也放出训练过程的loss曲线,让大家了解可能出现的问题
多任务有监督微调 Supervised finetuning
在多任务有监督微调阶段,采用了课程学习(curiculum learning)和增量训练( incremental training)的策略,用大模型辅助划分已有的数据难度,然后通过“Easy To Hard”的方式,分多个阶段进行SFT训练
SFT训练数据包含多个高质量的数据集,均经过人工筛选和校验:
- Self-Instruct构造的数据(约2M):BELLE、Alpaca、Alpaca-GPT4等多个数据集
- 内部收集Code数据(300K):包含leetcode、多种Code任务形式
- 内部收集推理/逻辑相关数据(500K):推理、申论、数学应用题、数值计算等
- 中英平行语料(2M):中英互译语料、COT类型翻译语料、古文翻译语料等
- 多轮对话语料(500K):Self-Instruct生成、任务型多轮对话、Role-Playing型多轮对话等
人类反馈学习 Human-Feedback training
为了进一步提升模型的综合表现,使其能够充分理解人类意图、减少“幻觉”和不安全的输出,基于指令微调后的模型,进行了人类反馈训练(Human-Feedback Training,HFT)。在训练中,我们采用了以人类反馈强化学习(RM、PPO)为主,结合多种其他手段联合训练的方法用来弥补PPO方法的短板、加速训练,具体包括
- 人类反馈微调(Human-Feedback Fine-tuning,HFFT)
- 后见链微调(Chain-of-Hindsight Fine-tuning,COHFT)
- AI反馈(AI Feedback)
- 基于规则的奖励系统(Rule-based Reward System,RBRS)等
我们在内部自研的框架上实现了HFT的训练流程,该框架可以利用最少8张40G的A100显卡完成Ziya-LLaMA-13B-v1的全参数训练。在PPO训练中,我们没有限制生成样本的长度,以确保长文本任务的奖励准确性。每次训练的总经验池尺寸超过100k样本,确保了训练的充分性
2.5.3 效果评估、使用说明、微调示例
使用 Usage
考虑到LLaMA权重的许可限制,我们无法直接发布完整的模型权重。因此,我们使用了开源的FastChat工具,并进一步优化它以计算Ziya-LLaMA-13B-v1权重和原始LLaMA权重之间的差异( we utilized the open-source FastChat tool and further optimized it to calculate the differences between Ziya-LLaMA-13B-v1 weights and the original LLaMA weights)。用户可以按照以下步骤获得Ziya-LLaMA-13B-v1的完整权重:
- Step 1:获取LLaMA权重并转成Hugging Face Transformers模型格式,可参考转换脚本(若已经有huggingface权重则跳过)
python src/transformers/models/llama/convert_llama_weights_to_hf.py \--input_dir /path/to/downloaded/llama/weights --model_size 13B --output_dir /output/path
- Step 2:下载Ziya-LLaMA-13B-v1的delta权重以及step 1中转换好的原始LLaMA权重,使用如下脚本转换:https://github.com/IDEA-CCNL/Fengshenbang-LM/blob/main/fengshen/utils/apply_delta.py
python3 -m apply_delta --base ~/model_weights/llama-13b --target ~/model_weights/Ziya-LLaMA-13B --delta ~/model_weights/Ziya-LLaMA-13B-v1
- Step 3: 加载step 2得到的模型推理
from transformers import AutoTokenizer from transformers import LlamaForCausalLM import torchdevice = torch.device("cuda") ckpt = '基于delta参数合并后的完整模型权重'query="帮我写一份去西安的旅游计划" model = LlamaForCausalLM.from_pretrained(ckpt, torch_dtype=torch.float16, device_map="auto") tokenizer = AutoTokenizer.from_pretrained(ckpt, use_fast=False) inputs = '<human>:' + query.strip() + '\n<bot>:'input_ids = tokenizer(inputs, return_tensors="pt").input_ids.to(device) generate_ids = model.generate(input_ids,max_new_tokens=1024, do_sample = True, top_p = 0.85, temperature = 1.0, repetition_penalty=1., eos_token_id=2, bos_token_id=1, pad_token_id=0) output = tokenizer.batch_decode(generate_ids)[0] print(output)
微调示例 Finetune Example
Refer to ziya_finetune
推理量化示例 Inference & Quantization Example
Refer to ziya_inference
2.6 基于LLaMA微调的各模型对比:Alpaca/Vicuna/BELLE/Chinese-LLaMA/Ziya-LLaMA-13B
项目 | 一句话描述 |
Stanford Alpaca | 结合英文语料通过Self Instruct方式微调LLaMA 7B |
Vicuna-13B | 通过ShareGPT.com的7万条对话数据微调LLaMA |
BELLE | 结合中文语料通过Self Instruct方式微调BLOOMZ-7B或LLaMA |
Chinese-LLaMA/Chinese-Alpaca | 通过中文数据预训练/指令微调LLaMA |
姜子牙系列模型Ziya-LLaMA-13B-v1 | 基于LLaMA-13B的中英文模型 |
ChatLLaMA(英文版) | LLaMA的RLHF版 |
ColossalChat | 通过self-instruct技术指令微调LLaMA且加上RLHF |
第三部分 更强的LLaMA 2开源,可直接商用
3.1 LLaMA2简介:相比LLaMA1代——1.4倍token,2倍上下文
LLAMA 2 (项目地址、论文地址、论文解读之一),是 LLAMA 1 的更新版本
- 模型结构
采用了 Llama 1 的大部分预训练设置和模型架构,比如使用标准Transformer 架构,使用 RMSNorm 应用预归一化、使用 SwiGLU 激活函数和旋转位置嵌入RoPE - 训练数据
使用一种新的混合的公开可用数据进行训练,训练数据规模是2T个token,相比1代的1.4T多出了40% - 上下文长度
上下文长度达到了4096,相比1代的2048直接翻了一倍 - 模型种类
目前 LLAMA 2 的系列模型有 7B、13B、70B 三种(34B的后续发布)
值得特别注意的是,其中的70B模型采用了分组查询注意力(grouped-query attention,GQA)「Transformer原始论文中用的多头注意力(MHA)、ChatGLM2-6B则用的多查询注意力(MQA)」
同时 Meta 还发布了 LLaMA 2-CHAT,其是基于 LLAMA 2 针对对话场景微调的版本,同样 7B、13B 和 70B 参数三个版本,具体的训练方法与ChatGPT类似
- 先是监督微调LLaMA2得到SFT版本 (接受了成千上万个人类标注数据的训练,本质是问题-答案对 )
- 然后使用人类反馈强化学习(RLHF)进行迭代优化
先训练一个奖励模型
然后在奖励模型/优势函数的指引下,通过拒绝抽样(rejection sampling)和近端策略优化(PPO)的方法迭代模型的生成策略
LLAMA 2 的性能表现更加接近 GPT-3.5,Meta 也承认距离 GPT-4 和 PaLM 2 等领先非开源模型还有差距
Meta 在技术报告中详细列出了 LLAMA 2 的性能、测评数据,以及分享了重要的训练方法,具体详见:
https://scontent.fhkg3-1.fna.fbcdn.net/v/t39.2365-6/10000000_6495670187160042_4742060979571156424_n.pdf?_nc_cat=104&ccb=1-7&_nc_sid=3c67a6&_nc_ohc=GK8Rh1tm_4IAX9ONr_Z&_nc_ht=scontent.fhkg3-1.fna&oh=00_AfAb7LWIi-6JH1zhdMt5uaMggiCzex1HqIKLtxu4qvtiAA&oe=64BBD830
3.2 LLaMA2之分组查询注意力——Grouped-Query Attention
自回归解码的标准做法是缓存序列中先前标记的键 (K) 和值 (V) 对,从而加快注意力计算速度
然而,随着上下文窗口或批量大小的增加,多头注意力 (MHA)模型中与 KV 缓存大小相关的内存成本显着增长
对于较大的模型,KV 缓存大小成为瓶颈,键和值投影可以在多个头之间共享,而不会大幅降低性能,可以使用
- 具有单个 KV 投影的原始多查询格式(MQA)
ChatGLM2-6B即用的这个,详见此文《中文模型的奋起直追:MOSS、baichuan-7B/13B和ChatGLM2-6B/13B的原理、部署与微调》的3.1节
不过,多查询注意(Multi-query attention, MQA)只使用一个键值头,虽大大加快了解码器推断的速度,但MQA可能导致质量下降,而且仅仅为了更快的推理而训练一个单独的模型可能是不可取的 - 或具有多个 KV 投影的分组查询注意力变体(GQA),速度快 质量高
23年,还是Google的研究者们提出了一种新的方法,即分组查询注意(GQA,论文地址为:GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints),这是一种多查询注意的泛化,它通过折中(多于一个且少于查询头的数量,比如4个)键值头的数量,使得经过强化训练的GQA以与MQA相当的速度达到接近多头注意力的质量
经实验论证,GQA 变体在大多数评估任务上的表现与 MHA 基线相当,并且平均优于 MQA 变体
3.3 Llama 2-Chat中的RLHF:依然是三阶段训练方式
3.3.1 监督微调(SFT)
他们首先重点收集了几千个高质量 SFT 数据示例 (胜过百万低质量的数据,As a result, we focused first on collecting several thousand examples of high-quality SFT data. By setting aside millions of examples from third-party datasets and using fewer buthigher-quality examples from our own vendor-based annotation efforts, our results notably improved),在微调过程中,每个样本都包括一个prompt和一个response(说白了,就是问题-答案对,和instructGPT/ChatGPT本身的监督微调是一个本质)
且为确保模型序列长度得到正确填充,Meta 将训练集中的所有prompt和response连接起来。他们使用一个特殊的 token 来分隔prompt和response片段,利用自回归目标,将来自用户提示的 token 损失归零,因此只对答案 token 进行反向传播,最后对模型进行了 2 次微调
3.3.2 训练一个奖励函数
为了兼顾和平衡模型的帮助性和安全性,LLaMA 2团队训练了两个独立的奖励模型,一个针对帮助性(称为帮助性RM)进行了优化,另一个针对安全性(安全性RM)进行了优化
并通过预训练的聊天模型初始化奖励模型,因为它确保了两个模型都能从预训练中获得的知识中受益 (虽然论文中的原文是:We initialize our reward models from pretrained chat model checkpoints, as it ensures that both modelsbenefit from knowledge acquired in pretraining,以及The model architecture and hyper-parameters are identical to thoseof the pretrained language models, except that the classification head for next-token prediction is replacedwith a regression head for outputting a scalar reward,但为何没有类似ChatGPT那样,通过微调过的SFT初始化RM模型,该点存疑)
为了使模型行为与人类偏好相一致,Meta 收集了代表了人类偏好经验采样的数据,通过针对同一个prompt模型给出的两个不同的response,人类标注者选择他们更喜欢的模型输出。这种人类偏好被用于训练奖励模型
其中,是标注者选择的首选response,是被拒绝的对应response
且为了让模型可以更好的体会到不同response质量之间的差异,作者团队将偏好评级被分为4层评级,且考虑到根据这些评级信息使得奖励模型对有更多差异的生成,有着不同分数且这些分数上彼此之间的差距尽可能拉开是有用的「Given that our preference ratings is decomposed as a scale of four points (e.g.,significantly better), it can be useful to leverage this information to explicitlyteach the reward model to assign more discrepant scores to the generations that have more differences」,为此,我们在损失中进一步添加一个边际成分
其中边际是偏好评级的离散函数,他们发现这个边际成分可以提高帮助性奖励模型的准确性,特别是在两个response更好区分的的样本上「where the margin m(r) is a discrete function of the preference rating. We found this margin component can improve Helpfulness reward model accuracy especially on sampleswhere two responses are more separable」
具体而言,为了衡量不同response好坏的程度,划分为4个等级(比如很好、好、较好、一般好或不确定),那这4个等级是需要有一定的间隔的,彼此之间不能模棱两可(模棱两可就容易把模型搞糊涂),而这个间隔大小是个超参数,可以人为设定,比如为小点的间隔1/3或大点的间隔1
下表 6 报告了 Meta 长期以来收集到的奖励建模数据的统计结果,并将其与多个开源偏好数据集进行了对比。他们收集了超过 100 万个基于人类应用指定准则的二元比较的大型数据集,也就是元奖赏建模数据
请注意,prompt和response中的标记数因文本领域而异。摘要和在线论坛数据的prompt通常较长,而对话式的prompt通常较短。与现有的开源数据集相比,本文的偏好数据具有更多的对话回合,平均长度也更长
奖励模型将模型响应及其相应的提示(包括前一轮的上下文)作为输入,并输出一个标量分数来表示模型生成的质量(例如有用性和安全性),利用这种作为奖励的响应得分,Meta 在 RLHF 期间优化了 Llama 2-Chat,以更好地与人类偏好保持一致,并提高有用性和安全性
在每一批用于奖励建模的人类偏好注释中,Meta 都拿出 1000 个样本作为测试集来评估模型,并将相应测试集的所有prompt的集合分别称为「元有用性」和「元安全性」
3.3.3 具体的策略迭代
此处使用两种主要算法对 RLHF 进行了微调:近端策略优化 (PPO)、Rejection 采样微调
//待更..