赞
踩
近期,除了研究ChatGPT背后的各种技术细节 不断看论文(至少100篇,100篇目录见此:ChatGPT相关技术必读论文100篇),还开始研究一系列开源模型(包括各自对应的模型架构、训练方法、训练数据、本地私有化部署、硬件配置要求、微调等细节)
本文一开始是作为此文《ChatGPT技术原理解析:从RL之PPO算法、RLHF到GPT4、instructGPT》的第4部分,但随着研究深入 为避免该文篇幅又过长,将把『第4部分 开源项目』抽取出来 独立成本文,然后不断续写本文直至成了一个系列
毕竟我上半年的目标之一,便是把ChatGPT涉及的所有一切关键技术细节,以及相关的开源项目都研究的透透的,故过程中会不断产出一篇篇新文章、新课程(比如七月类ChatGPT微调实战课)出来
一直致力于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,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
且试图证明小模型在足够多的的数据上训练后,也能达到甚至超过大模型的效果
此项目给出的环境依赖有4个:
- # 引入 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)
为了提高训练的稳定性,对每个transformer子层的输入进行归一化,而不是对输出进行归一化,且使用由Zhang和Sennrich(2019)提出的RMSNorm(Root Mean Square Layer Normalization)
RMS Norm是一般LayerNorm的一种变体,可以在梯度下降时令损失更加平滑,与layerNorm相比,RMS Norm的主要区别在于去掉了减去均值的部分(re-centering),只保留方差部分(re-scaling)
为一目了然,我们看下它们各自的归一化的表达式
- 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
至于RMS Norm为什么有用,需要求梯度进行分析,感兴趣的同学可以阅读RMS Norm的论文为了更好的理解SwiGLU,首先你得先了解什么是ReLU和GLU
而LLaMA采用Shazeer(2020)提出的SwiGLU替换了原有的ReLU,SwiGLU的作用机制是根据输入数据的特性,通过学习到的参数自动调整信息流动的路径,具体是采用SwiGLU的Feedforward Neural Network (简称FNN,这是一种使用可学习的门控机制的前馈神经网络)
其在论文中以如下公式进行表述:
解释下这个公式
至于Swish激活函数可表示为
表示sigmoid函数,但其输入被缩放了 倍,是一个可以学习的参数,比如下图,不同,Swish激活函数的形状则各异
对应论文见: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 的不同,动态地调整信息流动
关于旋转位置编码的理解,请参看此文《一文通透位置编码:从标准位置编码、旋转位置编码RoPE到ALiBi、LLaMA 2 Long》,足够详尽且细致
接下来,我们来看下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)
LLaMA和GPT一样,都是基于Transformer这个架构,通常,我们在构建transformer时,是按Block构建的,每个transformer Block包含SA和FFN两部分,然后再通过堆叠block的形式,构建起整个transformer网络,LLaMA也是这样做的
回顾一下Attention计算的总体过程是:
其中有一个细节就是缓存机制,它设计的目的是在generate时减少token的重复计算。简单解释一下,就是在计算第n个token特征的时候,需要用到第个token,即每次生成时,需要知道前面所有的过往信息,如果每次都从头算的话,那就会造成极大的浪费,所以就没算一个位置的信息,就把它缓存下来
接下来,我们来看下代码实现,首先是SA(self-attention)部分:
- class Attention(nn.Module):
- def __init__(self, args: ModelArgs):
- super().__init__()
-
- # 设置本地注意力头的数量
- self.n_local_heads = args.n_heads // fs_init.get_model_parallel_world_size()
- # 每个注意力头的维度
- self.head_dim = args.dim // args.n_heads
-
- # Query投影层
- self.wq = ColumnParallelLinear(
- args.dim,
- args.n_heads * self.head_dim,
- bias=False,
- gather_output=False,
- init_method=lambda x: x,
- )
- # Key投影层
- self.wk = ColumnParallelLinear(
- args.dim,
- args.n_heads * self.head_dim,
- bias=False,
- gather_output=False,
- init_method=lambda x: x,
- )
- # Value投影层
- self.wv = ColumnParallelLinear(
- args.dim,
- args.n_heads * self.head_dim,
- bias=False,
- gather_output=False,
- init_method=lambda x: x,
- )
- # 输出投影层
- self.wo = RowParallelLinear(
- args.n_heads * self.head_dim,
- args.dim,
- bias=False,
- input_is_parallel=True,
- init_method=lambda x: x,
- )
-
- # 使用零初始化键缓存
- self.cache_k = torch.zeros(
- (args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
- ).cuda()
- # 使用零初始化值缓存
- self.cache_v = torch.zeros(
- (args.max_batch_size, args.max_seq_len, self.n_local_heads, self.head_dim)
- ).cuda()
-
- def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
- bsz, seqlen, _ = x.shape
- # 进行Query投影
- xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)
-
- # 将形状调整为[bsz, seqlen, n_local_heads, head_dim]
- xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)
- xk = xk.view(bsz, seqlen, self.n_local_heads, self.head_dim)
- xv = xv.view(bsz, seqlen, self.n_local_heads, self.head_dim)
-
- # 对Query和Key应用旋转嵌入
- xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)
-
- # 将缓存键和值转换为xq的设备类型
- self.cache_k = self.cache_k.to(xq)
- self.cache_v = self.cache_v.to(xq)
-
- # 更新缓存键和值
- self.cache_k[:bsz, start_pos : start_pos + seqlen] = xk
- self.cache_v[:bsz, start_pos : start_pos + seqlen] = xv
-
- # 获取键和值
- keys = self.cache_k[:bsz, : start_pos + seqlen]
- values = self.cache_v[:bsz, : start_pos + seqlen]
-
- # 转置xq、键和值的维度
- xq = xq.transpose(1, 2)
- keys = keys.transpose(1, 2)
- values = values.transpose(1, 2)
-
- # 计算注意力分数
- scores = torch.matmul(xq, keys.transpose(2, 3)) / math.sqrt(self.head_dim)
- if mask is not None:
- scores = scores + mask # (bs, n_local_heads, slen, cache_len + slen)
- scores = F.softmax(scores.float(), dim=-1).type_as(xq)
-
- # 使用注意力分数加权求和得到输出
- output = torch.matmul(scores, values) # (bs, n_local_heads, slen, head_dim)
- output = output.transpose(
- 1, 2
- ).contiguous().view(bsz, seqlen, -1)
-
- # 应用输出投影
- return self.wo(output)
然后是前馈网络FFN部分,需要注意的点就是采用的激活函数,以及激活函数的位置
- import torch.nn as nn
- import torch.nn.functional as F
-
- class FeedForward(nn.Module):
- def __init__(
- self,
- dim: int,
- hidden_dim: int,
- multiple_of: int,
- ):
- super().__init__()
-
- # 初始化隐藏层的维度为输入维度的2/3
- hidden_dim = int(2 * hidden_dim / 3)
- # 调整隐藏层维度为multiple_of的倍数
- hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)
-
- # 第一个线性层
- self.w1 = ColumnParallelLinear(
- dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
- )
-
- # 第二个线性层
- self.w2 = RowParallelLinear(
- hidden_dim, dim, bias=False, input_is_parallel=True, init_method=lambda x: x
- )
-
- # 第三个线性层
- self.w3 = ColumnParallelLinear(
- dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x
- )
-
- def forward(self, x):
- # 前向传播函数
- return self.w2(F.silu(self.w1(x)) * self.w3(x))
这里与常见模型中的FFN做一下简单的对比
然后将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()
接着看下生成过程,如下:
代码如下
- class LLaMA:
- def __init__(self, model: Transformer, tokenizer: Tokenizer):
- self.model = model
- self.tokenizer = tokenizer
-
- def generate(
- self,
- prompts: List[str],
- max_gen_len: int,
- temperature: float = 0.8,
- top_p: float = 0.95,
- ) -> List[str]:
- # 获取批处理大小
- bsz = len(prompts)
- # 获取模型参数
- params = self.model.params
- # 检查批处理大小是否在允许的最大批处理大小范围内
- assert bsz <= params.max_batch_size, (bsz, params.max_batch_size)
-
- # 使用分词器对提示进行编码为标记
- prompt_tokens = [self.tokenizer.encode(x, bos=True, eos=False) for x in prompts]
-
- # 查找提示标记的最小和最大大小
- min_prompt_size = min([len(t) for t in prompt_tokens])
- max_prompt_size = max([len(t) for t in prompt_tokens])
-
- # 计算要生成的标记的总长度
- total_len = min(params.max_seq_len, max_gen_len + max_prompt_size)
-
- # 创建一个张量来存储生成的标记,填充为填充标记
- tokens = torch.full((bsz, total_len), self.tokenizer.pad_id).cuda().long()
- # 将提示标记复制到标记张量中
- for k, t in enumerate(prompt_tokens):
- tokens[k, : len(t)] = torch.tensor(t).long()
- # 创建一个掩码以识别输入文本
- input_text_mask = tokens != self.tokenizer.pad_id
- # 设置生成的起始位置
- start_pos = min_prompt_size
- prev_pos = 0
- # 逐个生成标记
- for cur_pos in range(start_pos, total_len):
- # 通过模型进行前向传递以获取logits
- logits = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
- if temperature > 0:
- # 对logits应用温度并计算概率
- probs = torch.softmax(logits / temperature, dim=-1)
- # 使用top-p采样抽样下一个标记
- next_token = sample_top_p(probs, top_p)
- else:
- # 选择概率最高的标记
- next_token = torch.argmax(logits, dim=-1)
- next_token = next_token.reshape(-1)
- # 只有在已经生成了提示的情况下才替换标记
- next_token = torch.where(
- input_text_mask[:, cur_pos], tokens[:, cur_pos], next_token
- )
- tokens[:, cur_pos] = next_token
- prev_pos = cur_pos
-
- # 将生成的标记解码为文本
- decoded = []
- for i, t in enumerate(tokens.tolist()):
- # 将标记截断到最大生成长度
- t = t[: len(prompt_tokens[i]) + max_gen_len]
- # 将标记截断到如果存在结束标记
- try:
- t = t[: t.index(self.tokenizer.eos_id)]
- except ValueError:
- pass
- # 将标记解码为文本
- decoded.append(self.tokenizer.decode(t))
- return decoded
-
- def sample_top_p(probs, p):
- # 按降序对概率进行排序
- probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
- # 计算概率的累积和
- probs_sum = torch.cumsum(probs_sort, dim=-1)
- # 创建一个掩码以过滤累积概率超过p的标记
- mask = probs_sum - probs_sort > p
- # 将被过滤的标记的概率设置为0
- probs_sort[mask] = 0.0
- # 归一化概率
- probs_sort.div_(probs_sort.sum(dim=-1, keepdim=True))
- # 使用修改后的概率进行抽样下一个标记
- next_token = torch.multinomial(probs_sort, num_samples=1)
- # 收集抽样标记的原始索引
- next_token = torch.gather(probs_idx, -1, next_token)
- return next_token
在Optimizer设计上
在模型的加速优化方面
LLaMA发布不久后,一些研究者基于它做了不少工作
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的意义所在
这52K数据所对应的alpaca_data.json文件是一个字典列表,每个字典包含以下字段:
而这52K数据是怎么来的呢?实际上,是通过Self-Instruct『Self-Instruct是来自华盛顿大学Yizhong Wang等人于22年12月通过这篇论文《SELF-INSTRUCT: Aligning Language Model with Self Generated Instructions》提出的,这是其论文地址、代码地址』提示GPT3的API拿到的
具体而言,论文中提出
斯坦福的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,^_^
值得一提的是,后来23年4月有微软的研究者提示GPT4的API进行指令微调「论文地址:INSTRUCTION TUNING WITH GPT-4、GitHub地址:instruction-Tuning-with-GPT-4、项目地址:使用GPT4进行指令调优」,从而生成以下数据
之前已说过,Self-Instruct是来自华盛顿大学Yizhong Wang等人于22年12月通过这篇论文《SELF-INSTRUCT: Aligning Language Model with Self Generated Instructions》提出的,这是其论文地址、代码地址
为进一步理解self-instruct这个方式的原理与实现细节,我司杜老师把这个self-instruct的方式生成语料的过程实践了下(这是该教程地址),具体而言,先理清如下4个步骤
- # 1. Generate instructions from the seed tasks
- ./scripts/generate_instructions.sh
- # 2. Identify whether the instruction represents a classification task or not
- ./scripts/is_clf_or_not.sh
- # 3. Generate instances for each instruction
- ./scripts/generate_instances.sh
- # 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
- {
- "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
- }
本次只是实验,故将scripts/generate_instructions.sh中的50000改为100(这样产生的费用也较少)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"
大概需要4分半的时间,生成100条数据,它们会写入data/ceishi/machine_generated_instructions.jsonl中,最终生成了122条,这些数据是通过LLM生成的与种子任务关联度比较弱的一些任务描述(一些相似度高的就删除了)- 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")
实际生成指令时,分两步:- parser.add_argument(
- "--num_prompt_instructions",
- type=int,
- default=8,
- help="The number of instructions to use in the 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 数据(如:是分类数据,不是分类数据),构建方式在函数encode_prompt中 - # 构建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
python self_instruct/identify_clf_or_not.py --batch_dir data/ceshi --engine "davinci" --request_batch_size 5 --api_key "自己的openai API"
会写入data/ceishi/is_clf_or_not_davinci_template_1.jsonl中 (如上说的122条)- {"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")
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"
如果遇到以下报错:- # 使用文件操作打开一个输出文件,以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))
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
运行后会生成两个数据文件,均在data/ceshi/finetuning_data目录下:- # 使用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")
可能有读者疑问,那微调的代码长啥样呢?实际上,微调步骤大同小异,具体而言,一般直接用的Hugging Face的transformer标准库中的微调代码 (We fine-tune our models using standard Hugging Face training code)
- 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
- 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),可得微调的步骤如下
- 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)."},
- )
- 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,输入包括一个字典(用于定义特殊词汇),一个分词器和一个预训练模型
- 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
- # 函数定义,接受一个字符串序列和一个预训练的分词器,返回一个字典
- 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,
- )
- # 函数定义,接受源字符串、目标字符串和一个预训练的分词器,返回一个字典
- 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)
- # 定义一个用于监督学习微调的数据集类
- 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])
- # 定义一个用于监督学习微调的数据整理类
- @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),
- )
- # 函数定义,接受一个预训练的分词器和数据参数,返回一个字典
- 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)
- parser = transformers.HfArgumentParser((ModelArguments, DataArguments, TrainingArguments))
- model_args, data_args, training_args = parser.parse_args_into_dataclasses()
b. 加载预训练模型:使用transformers.AutoModelForCausalLM.from_pretrained 从预训练的模型检查点加载一个用于因果语言建模的模型 - model = transformers.AutoModelForCausalLM.from_pretrained(
- model_args.model_name_or_path,
- cache_dir=training_args.cache_dir,
- )
c. 加载分词器:使用transformers.AutoTokenizer.from_pretrained 从预训练的模型检查点加载分词器 - # 从预训练模型创建一个自动化的分词器,其中包含了模型的名称或路径,缓存目录,模型的最大长度,填充的位置以及是否使用快速分词器
- 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,
- )
d. 为分词器添加特殊字符:根据需要,将特殊字符添加到分词器中 - # 如果模型名包含"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,
- }
- )
e. 创建数据集和整理器:使用make_supervised_data_module 函数为监督学习任务创建数据集和整理器 data_module = make_supervised_data_module(tokenizer=tokenizer, data_args=data_args)
f. 实例化Trainer类:实例化transformers.Trainer 类,并传入模型、分词器、训练参数以及数据集。Trainer类负责管理训练过程 trainer = Trainer(model=model, tokenizer=tokenizer, args=training_args, **data_module)
g. 训练模型:调用Trainer类的train()
方法对模型进行微调,相当于链路就是:transformers库 Trainer类 train函数 trainer.train()
h. 保存模型状态:在训练完成后,调用Trainer.save_state() 方法保存模型的状态 trainer.save_state()
i. 将训练器的模型安全地保存到磁盘:使用safe_save_model_for_hf_trainer 函数将训练器中的模型安全地保存到磁盘 trainer.save_model(output_dir=training_args.output_dir)
- 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,一模一样,没有任何不同
但,可能很快便有同学疑问,怎么没有预想中的损失计算、梯度下降、参数更新呢,实际上这三步的具体实现都封装在了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.loss
loss = 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进行分布式训练
在神经网络模型中,模型参数通常以矩阵的形式表示。对于一个预训练好的模型,其参数矩阵已经包含了很多有用的信息。为了使模型适应特定任务,我们需要对这些参数进行微调
LoRA的核心思想是用一种低秩的方式来调整这些参数矩阵。在数学上,低秩意味着一个矩阵可以用两个较小的矩阵相乘来近似,通过论文《LORA: LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS》可知(这是解读之一)
总之,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)支持以下流行的方法
- 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
故而,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 # 指示模型在训练时使用输入文本
很快,通过下文你会发现
一下子出来这么新的模型 似乎有点懵,没事,请看下文及下一篇文章娓娓道来..
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 并过滤掉一些不合适或低质量的样本。此外,将冗长的对话分成更小的部分,以适应模型的最大上下文长度,并做了以下改进
有两点值得一提的是
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 |
Stanford Alpaca的种子任务都是英语,收集的数据也都是英文,因此训练出来的模型未对中文优化。为了提升对话模型在中文上的效果,70 亿参数的中文对话大模型 BELLE『Bloom-Enhanced Large Language model Engine』来了(这是项目地址)。
在数据方面,结合以下两方面的数据:
模型训练上,有
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
该项目主要包含以下三部分内容:
Chinese LLaMA(也称中文LLaMA,有7B和13B两个版本,项目地址),相当于在原版LLaMA的基础上扩充了中文词表并使用了中文数据进行二次预训练,进一步提升了中文基础语义理解能力,同时,在中文LLaMA的基础上,且用中文指令数据进行指令精调得Chinese-Alpaca(也称中文Alpaca,同样也有7B和13B两个版本)
具体而言,主要做了以下三方面的工作
在通用中文语料上训练了基于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以及进行测试等步骤,具体如下
此外,七月在线ChatGPT原理解析课一学员在群内问道:“如何扩充词表、训练embedding,然后再与llama的合并,想在自己的数据上试试”
“吹牛班的春天”答道:“我知道的方法就是直接改embedding结构:初始化参数concat到以前embedding层上,以前的权embedding权重就保留,多出来的部分就后面更新,下图是以前BERT无损扩词的思路,可做参考”
在预训练阶段,使用约20G左右的通用中文语料(与中文BERT-wwm、MacBERT、LERT、PERT中使用的语料一致)在原版LLaMA权重的基础上进一步进行预训练。该过程又分为两个阶段:
第一阶段:冻结transformer参数,仅训练embedding,在尽量不干扰原模型的情况下适配新增的中文词向量
第二阶段:使用LoRA技术,为模型添加LoRA权重(adapter),训练embedding的同时也更新LoRA参数
指令精调阶段的任务形式基本与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模型,效果相对更好一些(也更慢)
姜子牙通用大模型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
需求 Demand | 任务 Task | 系列 Series | 模型 Model | 参数 Parameter | 额外 Extra |
---|---|---|---|---|---|
通用 General | AGI模型 | 姜子牙 Ziya | LLaMA | 13B | English&Chinese |
在多任务有监督微调阶段,采用了课程学习(curiculum learning)和增量训练( incremental training)的策略,用大模型辅助划分已有的数据难度,然后通过“Easy To Hard”的方式,分多个阶段进行SFT训练
SFT训练数据包含多个高质量的数据集,均经过人工筛选和校验:
为了进一步提升模型的综合表现,使其能够充分理解人类意图、减少“幻觉”和不安全的输出,基于指令微调后的模型,进行了人类反馈训练(Human-Feedback Training,HFT)。在训练中,我们采用了以人类反馈强化学习(RM、PPO)为主,结合多种其他手段联合训练的方法用来弥补PPO方法的短板、加速训练,具体包括
我们在内部自研的框架上实现了HFT的训练流程,该框架可以利用最少8张40G的A100显卡完成Ziya-LLaMA-13B-v1的全参数训练。在PPO训练中,我们没有限制生成样本的长度,以确保长文本任务的奖励准确性。每次训练的总经验池尺寸超过100k样本,确保了训练的充分性
考虑到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的完整权重:
- 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
python3 -m apply_delta --base ~/model_weights/llama-13b --target ~/model_weights/Ziya-LLaMA-13B --delta ~/model_weights/Ziya-LLaMA-13B-v1
- 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)
Refer to ziya_finetune
Refer to ziya_inference
项目 | 一句话描述 |
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 |
23年7月份,Meta发布LLAMA 2 (项目地址、论文地址、论文解读之一),是 LLAMA 1 的更新版本
同时 Meta 还发布了 LLaMA 2-CHAT,其是基于 LLAMA 2 针对对话场景微调的版本,同样 7B、13B 和 70B 参数三个版本,具体的训练方法与ChatGPT类似
LLAMA 2 的性能表现更加接近 GPT-3.5,Meta 也承认距离 GPT-4 和 PaLM 2 等领先非开源模型还有差距
Meta 在技术报告中详细列出了 LLAMA 2 的性能、测评数据,以及分享了重要的训练方法,具体详见原论文
自回归解码的标准做法是缓存序列中先前标记的键 (K) 和值 (V) 对,从而加快注意力计算速度
然而,随着上下文窗口或批量大小的增加,多头注意力 (MHA)模型中与 KV 缓存大小相关的内存成本显着增长
对于较大的模型,KV 缓存大小成为瓶颈,键和值投影可以在多个头之间共享,而不会大幅降低性能,可以使用
经实验论证,GQA 变体在大多数评估任务上的表现与 MHA 基线相当,并且平均优于 MQA 变体
更多请参见《一文通透各种注意力:从多头注意力MHA到分组查询注意力GQA、多查询注意力MQA》
在SFT的数据上
在微调过程中
下表 6 报告了 Meta 长期以来收集到的奖励建模数据的统计结果,并将其与多个开源偏好数据集进行了对比。他们收集了超过 100 万个基于人类应用指定准则的二元比较的大型数据集,也就是奖励建模数据
关于奖励数据
故为了兼顾和平衡模型的实用性和安全性,LLaMA 2团队训练了两个独立的奖励模型
并通过预训练的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,如下图所示
此处使用两种主要算法对 RLHF 进行了微调:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。