前言
近期,除了研究ChatGPT背后的各种技术细节 不断看论文(至少100篇,100篇目录见此:ChatGPT相关技术必读论文100篇),还开始研究一系列开源模型(包括各自对应的模型架构、训练方法、训练数据、本地私有化部署、硬件配置要求、微调等细节)
本文一开始是作为此文《ChatGPT技术原理解析:从RL之PPO算法、RLHF到GPT4、instructGPT》的第4部分,但随着研究深入 为避免该文篇幅又过长,将把『第4部分 开源项目』抽取出来 独立成本文,然后不断续写本文直至成了一个系列
毕竟我上半年的目标之一,便是把ChatGPT涉及的所有一切关键技术细节,以及相关的开源项目都研究的透透的,故过程中会不断产出一篇篇新文章、新课程(比如七月类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),并于次月3.8日被迫开源
LLaMA只使用公开的数据(总计1.4T即1.4万亿的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的模型架构:改造Transformer——RMSNorm/SwiGLU/RoPE
1.2.1 项目环境依赖:torch、fairscale、fire、sentencepiece
此项目给出的环境依赖有4个:
- torch
- fairscale,fairscale是用来做GPU分布的,一般是当使用DDP仍然遇到超显存的问题时使用fairscale
- fire,fire是一个命令行工具,用或者不用他都可以
- sentencepiece,sentencepiece是用于tokenizer的工具包
「 SentencePiece 实现了subword单元(例如,字节对编码(BPE)和 unigram语言模型),并可以直接从原始句子训练字词模型(subword model),这是对SentencePiece的解读:大模型词表扩充必备工具SentencePiece 」
首先,引入一些库和定义一个 Tokenizer 类
然后再定义encode、decode函数# 引入 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
先计算出来
xW的结果,即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
关于旋转位置编码的理解,请参看此文《一文通透位置编码:从标准位置编码、旋转位置编码RoPE到ALiBi、LLaMA 2 Long(含NTK-aware简介)》,足够详尽且细致
接下来,我们来看下LLaMA里是怎么实现这个旋转位置编码的,具体而言,LLaMA 的model.py文件里面实现了旋转位置编码(为方便大家理解,我给相关代码 加了下注释)
首先,逐一实现这三个函数
- precompute_freqs_cis
# 预计算频率和复数的函数 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 # 返回复数
- reshape_for_broadcast
# 重塑的函数 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) # 重塑复数的形状并返回
- apply_rotary_emb
# 应用旋转嵌入的函数 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.3 Transform架构的实现:Attention计算、SA、FFN
LLaMA和GPT一样,都是基于Transformer这个架构,通常,我们在构建transformer时,是按Block构建的,每个transformer Block包含SA和FFN两部分,然后再通过堆叠block的形式,构建起整个transformer网络,LLaMA也是这样做的
1.3.1 Attention计算的总过程
回顾一下Attention计算的总体过程是(至于更多细节,详见此文:Transformer通俗笔记:从Word2Vec、Seq2Seq逐步理解到GPT、BERT):
- 输入
,分别经过三个Linear得到
- 在
和
中加入旋转位置编码
- 缓存
和
- 计算
其中有一个细节就是缓存机制,它设计的目的是在generate时减少token的重复计算。简单解释一下,就是在计算第n个token特征的时候,需要用到第个token,即每次生成时,需要知道前面所有的过往信息,如果每次都从头算的话,那就会造成极大的浪费,所以就没算一个位置的信息,就把它缓存下来
接下来,我们来看下代码实现,首先是SA(self-attention)部分:
- 先初始化,涉及到注意力计算中的三个query、key、value向量的产生
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()
- 后forward,其中的核心即是
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)
1.3.2 前馈网络FFN:基于SwiGLU
然后是前馈网络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采用了三个全连接层以实现FFN SwiGLU,即
1.3.3 Transformer Block:将SA和FFN这两部分拼在一起
然后将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()
1.3.4 Transformer的生成过程——预测下一个token
接着看下生成过程,如下:
- 对prompts进行tokenize,得到token ids;
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) # 使用分词器对提示进行编码为token prompt_tokens = [self.tokenizer.encode(x, bos=True, eos=False) for x in prompts]
- 计算当前batch的最大长度total_len,用来创建输入的token tensor,最大长度不能超过前文所述缓存的大小;
# 查找提示token的最小和最大大小 min_prompt_size = min([len(t) for t in prompt_tokens]) max_prompt_size = max([len(t) for t in prompt_tokens]) # 计算要生成的token的总长度 total_len = min(params.max_seq_len, max_gen_len + max_prompt_size)
- 从当前batch中,最短的一个prompt的位置,作为生成的开始位置,开始生成;
# 创建一个张量来存储生成的token,填充为填充token tokens = torch.full((bsz, total_len), self.tokenizer.pad_id).cuda().long() # 将提示token复制到token张量中 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
- 输入的token tensor传入transformer模型,计算logits,得到形状为(batch_size, hidden_size)的logits(transformer最后一层的输出);
# 逐个生成token for cur_pos in range(start_pos, total_len): # 通过模型进行前向传递以获取logits logits = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
- softmax+top_p采样,得到当前预测的token,并更新当前位置,准备预测下一个token;
if temperature > 0: # 对logits应用温度并计算概率 probs = torch.softmax(logits / temperature, dim=-1) # 使用top-p采样抽样下一个token next_token = sample_top_p(probs, top_p) else: # 选择概率最高的标记 next_token = torch.argmax(logits, dim=-1) next_token = next_token.reshape(-1) # 只有在已经生成了prompt的情况下才替换token next_token = torch.where( input_text_mask[:, cur_pos], tokens[:, cur_pos], next_token ) tokens[:, cur_pos] = next_token prev_pos = cur_pos
- 解码得到生成的文本
# 将生成的token解码为文本 decoded = [] for i, t in enumerate(tokens.tolist()): # 将token截断到最大生成长度 t = t[: len(prompt_tokens[i]) + max_gen_len] # 将token截断到如果存在结束标记 try: t = t[: t.index(self.tokenizer.eos_id)] except ValueError: pass # 将标记解码为文本 decoded.append(self.tokenizer.decode(t)) return decoded
至于上述其中的sample_top_p,如下实现
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.4 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 Stanford Alpaca简介:论文、代码、数据
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生成的指令的答案
2.1.2 什么是self-instruct方式:提示GPT3/GPT3.5/GPT4的API收集数据
而这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步直到种子池有足够多的数据
2.1.3 生成微调LLaMA的52K数据的完整代码
斯坦福的Alpaca在实际生成52K数据时,在上节self-instruct方式的基础上,还考虑到了多重过滤机制,防止生成过于相似、过长或含有特定关键词的指令,以此保证生成的指令集的质量和多样性,且在每一轮生成指令后,都会保存当前的结果,方便随时跟踪进度,此外,还采用了多进程处理,提高了效率
故最终完整生成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,^_^
2.1.4 微软研究者提示GPT4的API生成指令数据
值得一提的是,后来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.5 手把手实战: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
另,考虑到七月类ChatGPT微调实战课上有一学员对这两个数据集为何各自的输出一个是output,一个是completion,故特再解释下
区别在于:在第4步之前是output,经过第4步处理后是completion。而第4步就是一些过滤以及一些限制条件的处理,比如为了数据的多样性,新生成的指令只有与种子池中的指令的 ROUGE-L 小于0.7时才会保留下来;还会排除一些无法被语言模型处理的指令,比如涉及图像、图片、图形的指令等等
核心代码为# 使用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:微调代码的逐行分析
可能有读者疑问,那微调的代码长啥样呢?实际上,微调步骤大同小异,具体而言,一般直接用的Hugging Face的transformer标准库中的微调代码 (We fine-tune our models using standard Hugging Face training code)
- 首先安装pip install -r requirements.txt
- 下面的命令在具有 4 个 A100 80G GPU 且处于 FSDP 模式的机器上使用52K的数据集对 LLaMA-7B 进行微调
full_shard
替换<your_random_port>为你自己的端口
<your_path_to_hf_converted_llama_ckpt_and_tokenizer>转换后的检查点和分词器的路径
以及<your_output_dir>你想要存储输出的位置torchrun --nproc_per_node=4 --master_port=<your_random_port> train.py \ --model_name_or_path <your_path_to_hf_converted_llama_ckpt_and_tokenizer> \ --data_path ./alpaca_data.json \ --bf16 True \ --output_dir <your_output_dir> \ --num_train_epochs 3 \ --per_device_train_batch_size 4 \ --per_device_eval_batch_size 4 \ --gradient_accumulation_steps 8 \ --evaluation_strategy "no" \ --save_strategy "steps" \ --save_steps 2000 \ --save_total_limit 1 \ --learning_rate 2e-5 \ --weight_decay 0. \ --warmup_ratio 0.03 \ --lr_scheduler_type "cosine" \ --logging_steps 1 \ --fsdp "full_shard auto_wrap" \ --fsdp_transformer_layer_cls_to_wrap 'LlamaDecoderLayer' \ --tf32 True
- 当然,以上脚本也适用于对 OPT 微调,这是微调 OPT-6.7B 的示例
torchrun --nproc_per_node=4 --master_port=<your_random_port> train.py \ --model_name_or_path "facebook/opt-6.7b" \ --data_path ./alpaca_data.json \ --bf16 True \ --output_dir <your_output_dir> \ --num_train_epochs 3 \ --per_device_train_batch_size 4 \ --per_device_eval_batch_size 4 \ --gradient_accumulation_steps 8 \ --evaluation_strategy "no" \ --save_strategy "steps" \ --save_steps 2000 \ --save_total_limit 1 \ --learning_rate 2e-5 \ --weight_decay 0. \ --warmup_ratio 0.03 \ --lr_scheduler_type "cosine" \ --logging_steps 1 \ --fsdp "full_shard auto_wrap" \ --fsdp_transformer_layer_cls_to_wrap 'OPTDecoderLayer' \ --tf32 True
好,现在问题来了,上面脚本中的微调代码即train.py到底长啥样呢?
据Stanford Alpaca于Mar 17, 2023发布的231行的训练代码stanford_alpaca/train.py at aa65c492bb788e144712daab42bc5d11c2761591 · tatsu-lab/stanford_alpaca · GitHub (注:后代码于Apr 16, 2023有更新,代码略微缩小至222行代码,详见:https://github.com/tatsu-lab/stanford_alpaca/blob/main/train.py),可得微调的步骤如下
- 导入所需的库:包括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代码库中的微调代码: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()这行代码根据计算出的梯度来更新模型参数
且慢,暂停解释下为何会有梯度累积这个操作呢?原因在于batch size越大,局部数据求得的梯度方向越接近全局的梯度优化方向,那怎么增大batch size呢?一方面可以增加硬件资源,二方面可以通过梯度累积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层的两条分支结果进行加和”,如下图所示『图源:https://huggingface.co/docs/peft/conceptual_guides/lora,相当于在训练期间,较小的权重矩阵(下图中的A和B)是分开的,但一旦训练完成,权重可以合并到一个新权重矩阵中 』
在代码中体现为
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,为方便大家更好的理解,我给每一行的代码 都加上了注释)
import os # 导入os模块,用于操作系统相关操作
# 设置环境变量,使得Protocol Buffers使用Python实现
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"]="python"
from transformers import LlamaTokenizer # 导入LlamaTokenizer类
# 导入Protocol Buffers格式的sentencepiece模型
from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model
import sentencepiece as spm # 导入sentencepiece模块
import argparse # 导入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 = args.llama_tokenizer_dir # 获取llama_tokenizer_dir参数值
chinese_sp_model_file = args.chinese_sp_model_file # 获取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))
print(llama_tokenizer.all_special_tokens) # 输出LlamaTokenizer的所有特殊词汇
print(llama_tokenizer.all_special_ids) # 输出LlamaTokenizer的所有特殊词汇ID
print(llama_tokenizer.special_tokens_map) # 输出LlamaTokenizer的特殊词汇映射
'''
将中文词汇添加到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无损扩词的思路,可做参考”
考虑到再后来24年6月份,我司七月的「大模型线上营」中又有学员问到了这个问题,阿荀则详细的回答了下
至于如果自己扩建词汇表后,新的词汇表里面还是会存在没有意义的字符文字,有什么办法可以优化吗?这个还是要先看原因的,可能有几个方面
- 可能是根本的语料问题,新词是根据语料统计得到的,反过来说是语料中的字符频率信息决定新词的发掘,可能要看语料本身有没有问题
- 可能是具体实现问题,实施的时候vocabsize一类的阈值参数设置太大,导致连带一些确实没什么意义的长尾词都纳入进新词中了
- 也可能是对“无意义”的定义问题,如果你用的是bbpe,也比较容易得到一些看上去不太合乎语言学层面的新词,但实际并没有什么影响
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 torch device = 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版 |
通过self-instruct技术指令微调LLaMA且加上RLHF |
第三部分 更强的LLaMA 2开源,可直接商用
3.1 LLaMA 2简介:相比LLaMA1代——1.4倍token,2倍上下文
3.1.1 LLaMA 2:32K词表、2T训练数据、4K上下文长度、模型种类
23年7月份,Meta发布LLAMA 2 (项目地址、论文地址、论文解读之一),是 LLAMA 1 的更新版本
- 模型结构
采用了 Llama 1 的大部分预训练设置和模型架构,比如使用标准Transformer 架构,使用 RMSNorm 应用预归一化、使用 SwiGLU 激活函数和旋转位置嵌入RoPE - 32K词表大小
继续沿用Llama1 所用的通过SentencePiece实现的BPE,且整个词表的大小依然为 32K
We use the same tokenizer as Llama 1; it employs a bytepair encoding (BPE) algorithm(Sennrich et al., 2016) using the implementation from SentencePiece (Kudo and Richardson, 2018). As with Llama 1, we split all numbers into individual digits and use bytes to decompose unknown UTF-8 characters. The total vocabulary size is 32k tokens. - 2T训练数据
使用一种新的混合的公开可用数据进行训练,训练数据规模是2T个token(即2万亿个token),相比1代的1.4T多出了40% - 4K上下文长度
上下文长度达到了4096,相比1代的2048直接翻了一倍 - 模型种类:7B、13B、70B(用了GQA)
目前 LLAMA 2 的系列模型有 7B、13B、70B 三种(34B的后续发布)
值得特别注意的是,其中的70B模型采用了分组查询注意力(grouped-query attention,简称GQA,关于什么是GQA,详见此文 ),至于其中的7B和13B则没用GQA「Transformer原始论文中用的多头注意力(MHA)、ChatGLM2-6B则用的多查询注意力(Multi-query attention,简称MQA)」
3.1.2 LLaMA 2-Chat:三个版本——7B 13B 70B
同时 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 的性能、测评数据,以及分享了重要的训练方法,具体详见原论文
3.1.3 LLaMA 2 70B之分组查询注意力——Grouped-Query Attention
自回归解码的标准做法是缓存序列中先前标记的键 (K) 和值 (V) 对,从而加快注意力计算速度
然而,随着上下文窗口或批量大小的增加,多头注意力 (MHA)模型中与 KV 缓存大小相关的内存成本显着增长
对于较大的模型,KV 缓存大小成为瓶颈,键和值投影可以在多个头之间共享,而不会大幅降低性能,可以使用
- 具有单个 KV 投影的原始多查询格式(MQA)
ChatGLM2-6B即用的这个,详见此文《ChatGLM两代的部署/微调/实现:从基座GLM、ChatGLM的LoRA/P-Tuning微调、6B源码解读到ChatGLM2的微调与实现》的3.1.2节
不过,多查询注意(Multi-query attention,简称MQA)只使用一个键值头,虽大大加快了解码器推断的速度,但MQA可能导致质量下降,而且仅仅为了更快的推理而训练一个单独的模型可能是不可取的 - 或具有多个 KV 投影的分组查询注意力(grouped-query attention,简称GQA),速度快 质量高
23年,还是Google的研究者们提出了一种新的方法,即分组查询注意(GQA,论文地址为:GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints),这是一种多查询注意的泛化,它通过折中(多于一个且少于查询头的数量,比如4个)键值头的数量,使得经过强化训练的GQA以与MQA相当的速度达到接近多头注意力的质量
经实验论证,GQA 变体在大多数评估任务上的表现与 MHA 基线相当,并且平均优于 MQA 变体
更多请参见《一文通透各种注意力:从多头注意力MHA到分组查询注意力GQA、多查询注意力MQA》
3.2 Llama 2-Chat中的RLHF:依然是三阶段训练方式
3.2.1 监督微调(SFT)
在SFT的数据上
- 他们先是重点收集了几千个高质量 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)
- 之后发现几万次的SFT标注就足以获得高质量的结果,最终总共收集了27540条用于SFT的标注数据 (We found that SFT annotations in the order of tens ofthousands was enough to achieve a high-quality result. We stopped annotating SFT after collecting a total of 27,540 annotations.)
在微调过程中
- 每个样本都包括一个prompt和一个response(说白了,就是问题-答案对,和instructGPT/ChatGPT本身的监督微调是一个本质),且为确保模型序列长度得到正确填充,Meta 将训练集中的所有prompt和response连接起来。他们使用一个特殊的 token 来分隔prompt和response片段,利用自回归目标,将来自用户提示的 token 损失归零,因此只对答案 token 进行反向传播,最后对模型进行了 2 次微调
- 微调过程中的参数则如此设置:we use a cosine learning rate schedule with an initiallearning rate of 2 ×10−5 , a weight decay of 0.1, a batch size of 64, and a sequence length of 4096 token
3.2.2 训练两个奖励模型:一个偏实用 一个偏安全
下表 6 报告了 Meta 长期以来收集到的奖励建模数据的统计结果,并将其与多个开源偏好数据集进行了对比。他们收集了超过 100 万个基于人类应用指定准则的二元比较的大型数据集,也就是奖励建模数据
关于奖励数据
- prompt和response中的标记数因文本领域而异,比如摘要和在线论坛数据的prompt通常较长,而对话式的prompt通常较短。与现有的开源数据集相比,本文的偏好数据具有更多的对话回合,平均长度也更长
- 奖励模型将模型响应及其相应的提示(包括前一轮的上下文)作为输入,并输出一个标量分数来表示模型生成的质量(例如有用性和安全性),利用这种作为奖励的响应得分,Meta 在 RLHF 期间优化了 Llama 2-Chat,以更好地与人类偏好保持一致,并提高有用性和安全性
在每一批用于奖励建模的人类偏好标注中,Meta 都拿出 1000 个样本作为测试集来评估模型,并将相应测试集的所有prompt的集合分别称为实用性和安全性 (很多新闻稿会翻译成元实用、元安全,其实没必要加个“元”字,你理解为是Meta内部定义的“实用”与“安全”两个概念即可)
故为了兼顾和平衡模型的实用性和安全性,LLaMA 2团队训练了两个独立的奖励模型
- 一个针对实用性(称为实用性RM)进行了优化,在内部所有偏实用的奖励数据集上进行训练,并结合从内部偏安全的奖励数据集和开源安全性数据集中统一采样的同等部分剩余数据
theHelpfulness reward model is eventually trained on all Meta Helpfulness data, combined with an equalparts of the remaining data uniformly sampled from Meta Safety and from the open-source datasets - 另一个针对安全性(安全性RM)进行了优化,在内部所有偏安全的奖励数据和人类无害数据上进行训练,并以90/10的比例混合内部偏实用的奖励数据和开源实用性数据
TheMeta Safety reward model is trained on all Meta Safety and Anthropic Harmless data, mixed with MetaHelpfulness and open-source helpfulness data in a 90/10 proportion.
We found that the setting with 10% helpfulness data is especially beneficial for the accuracy on samples where both the chosen and rejectedresponses were deemed safe
并通过预训练的LLaMA 2初始化奖励模型(意味着奖励模型的架构与参数与预训练模型一致,只是用于下一个token预测的分类头被替换为用于输出标量奖励的回归头),因为它确保了两个模型都能从预训练中获得的知识中受益
「虽然论文中的原文是: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,如下图所示
3.2.3 具体的策略迭代:PPO与拒绝采样
此处使用两种主要算法对 RLHF 进行了微调:
- 近端策略优化(PPO)
PPO在之前的《ChatGPT技术原理解析》或《强化学习极简入门》等文章中讲的很详细了,可以参见 - 拒绝采样(Rejection Sampling)
即在模型生成多个回复后,选择最佳的回复作为模型的输出,过程中,如果生成的回复不符合预期,就会被拒绝,直到找到最佳回复
从而帮助提高模型的生成质量,使其更符合人类的期望
At each iterative stage, we sample K answers for each prompt from the most recent model. We score eachsample given the best reward model accessible at the time of the experiment, and then select the best answerfor a given prompt