![]() |
跑龙套的小狗 · 人民网:江汉大学依斯巴尼亚西班牙语学校西班牙 ...· 17 小时前 · |
![]() |
博学的豌豆 · C/C++ for Visual ...· 1 周前 · |
![]() |
痴情的大象 · 尋找泉州世遺:老君岩造像“天下第一”· 1 周前 · |
![]() |
强悍的创口贴 · 百木心枪-赵云- 王者荣耀爆料站- ...· 7 月前 · |
![]() |
精明的书包 · 湖南网络工程职业学院招生网· 1 年前 · |
近期,除了研究ChatGPT背后的各种技术细节 不断看论文(至少100篇,100篇目录见此:ChatGPT相关技术必读论文100篇),还开始研究一系列开源模型(包括各自对应的模型架构、训练方法、训练数据、本地私有化部署、硬件配置要求、微调等细节)
本文一开始是作为此文《ChatGPT技术原理解析:从RL之PPO算法、RLHF到GPT4、instructGPT》的第4部分,但随着研究深入 为避免该文篇幅又过长,将把『第4部分 开源项目』抽取出来 独立成本文,然后不断续写本文直至成了一个系列
毕竟我上半年的目标之一,便是把ChatGPT涉及的所有一切关键技术细节,以及相关的开源项目都研究的透透的,故过程中会不断产出一篇篇新文章出来
虽说GPT3在2020年就出来了,但OpenAI并未开源,所以直到一年半后以后才有国内外各个团队比如DeepMind等陆续复现出来,这些大厂的复现代码我们自然无法窥知一二,毕竟人家也未开源出来
再到后来基于GPT3的InstructGPT、基于GPT3.5ChatGPT初版(GPT3.5的参数规模也尚无准确定论)、GPT4均未开源,OpenAI不再open,好在Meta等公司或研究者开源出了一系列类ChatGPT项目,本部分针对其中部分做下简要推荐(根据发布顺序排序)
2.15,很多朋友在GitHub上发现了一个基于Colossal-AI低成本实现类ChatGPT迷你版训练过程的开源项目(基于OPT + RLHF + PPO),虽 是类似GPT3的开源项目OPT与RLHF的结合 ,但可以增进我们对ChatGPT的理解,该项目有几个不错的特点
from chatgpt.nn import GPTActor, GPTCritic, RewardModel
from chatgpt.trainer import PPOTrainer
from chatgpt.trainer.strategies import ColossalAIStrategy
strategy = ColossalAIStrategy(stage=3, placement_policy='cuda')
with strategy.model_init_context():
actor = GPTActor().cuda()
critic = GPTCritic().cuda()
initial_model = deepcopy(actor).cuda()
reward_model = RewardModel(deepcopy(critic.model)).cuda()
trainer = PPOTrainer(strategy, actor, critic, reward_model, initial_model, ...)
trainer.fit(prompts)
此外,据钟博士在我所维护的『Machine Learning读书会群』里所说,Colossal-AI的并行效率确实不错,是新加坡的一个初创团队推出的,但目前尚没有团队采用Colossal-AI框架来做主训练框架训练175b级别的超大模型,可以再了解下Meta家训练OPT用的Metaseq
通过《ChatGPT技术原理解析》一文,我们已经知道了ChatGPT的三阶段训练过程,其中,阶段三的本质其实就是通过PPO的方式去微调LM
GitHub上有个TRL(Transformer Reinforcement Learning,基于『Hugging Face开发的Transformer库』),便是通过PPO的方式去微调LM,需要的数据便是三元组「query, response, reward」,具体如下图所示
示例代码如下
# imports
import torch
from transformers import AutoTokenizer
from trl import PPOTrainer, PPOConfig, AutoModelForCausalLMWithValueHead, create_reference_model
from trl.core import respond_to_batch
# get models
model = AutoModelForCausalLMWithValueHead.from_pretrained('gpt2')
model_ref = create_reference_model(model)
tokenizer = AutoTokenizer.from_pretrained('gpt2')
# initialize trainer
ppo_config = PPOConfig(
batch_size=1,
# encode a query
query_txt = "This morning I went to the "
query_tensor = tokenizer.encode(query_txt, return_tensors="pt")
# get model response
response_tensor = respond_to_batch(model_ref, query_tensor)
# create a ppo trainer
ppo_trainer = PPOTrainer(ppo_config, model, model_ref, tokenizer)
# define a reward for response
# (this could be any reward such as human feedback or output from another model)
reward = [torch.tensor(1.0)]
# train model for one step with ppo
train_stats = ppo_trainer.step([query_tensor[0]], [response_tensor[0]], reward)
一直致力于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(这是解读之一),有多个参数规模的版本(7B 13B 33B 65B)
LLaMA只使用公开的数据(总计1.4T即1,400GB的token,其中CommonCrawl的数据占比67%,C4数据占比15%,Github Wikipedia Books这三项数据均各自占比4.5%,ArXiv占比2.5%,StackExchange占比2%),论文中提到
When training a 65B-parameter model, our code processes around 380 tokens/sec/GPU on 2048 A100 GPU with 80GB of RAM.
This means that training over our dataset containing 1.4T tokens takes approximately 21 days
且试图证明小模型在足够多的的数据上训练后,也能达到甚至超过大模型的效果
此项目给出的环境依赖有4个:
from sentencepiece import SentencePieceProcessor
from logging import getLogger
from typing import List
import os
logger = getLogger()
class Tokenizer:
def __init__(self, model_path: str):
# reload tokenizer
assert os.path.isfile(model_path), model_path
self.sp_model = SentencePieceProcessor(model_file=model_path)
logger.info(f"Reloaded SentencePiece model from {model_path}")
# BOS / EOS token IDs
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()
def encode(self, s: str, bos: bool, eos: bool) -> List[int]:
assert type(s) is str
t = self.sp_model.encode(s)
if bos:
t = [self.bos_id] + t
if eos:
t = t + [self.eos_id]
return t
def decode(self, t: List[int]) -> str:
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)
为一目了然,我们看下它们各自的归一化的表达式
至于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
用Shazeer(2020)提出的SwiGLU替代ReLU,在维度上使用的维度是2/3*4d,而不是PaLM中的4d
LLaMA采用SwiGLU替换了原有的ReLU,具体是采用SwiGLU的FNN,在论文中以如下公式进行表述:
其中
对应论文见:Ramachandran et al., 2017
代码实现上:可以通过调用torch内置方法F.silu()实现,会在下文的FFN部分介绍
在位置编码上,删除了绝对位置嵌入,而在网络的每一层增加了苏剑林等人(2021)提出的旋转位置嵌入(RoPE),其思想是采用绝对位置编码的形式,实现相对位置编码
原理理解了,接下来可以代码实现旋转位置编码
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
// 首先torch.arange创建了一个tensor,[ 0 , 2 , 4 , . . . , 60 , 62 ] [0, 2, 4, ..., 60, 62][0,2,4,...,60,62]
// 然后统一除以64,把它变成分数,然后整体作为基础角度的指数,它的shape是(32)
freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
// t比较容易理解,也就是绝对位置信息,它的shape是(1024)
t = torch.arange(end, device=freqs.device)
// torch.outer是把一个向量的转置乘以另一个向量:torch.outer(a, b) = a^T * b
// 于是根据torch.outer运算,我们得到了一个shape为(1024, 32)的tensor。其意义也就是将每一个绝对位置,分配到对应的角度,相乘
// 直观理解一下,就是每一个绝对位置上,都有32个角度
// 为什么是这样的呢,回顾计算的公式,对于旋转矩阵,每两个元素为一组,它们乘以的角度是同一个θ,所以这个(1024, 32)
// 在后续的过程中,就可以reshape成(512, 64),并且在64的那个维度上,每两个是相同的
freqs = torch.outer(t, freqs).float()
// torch.polar(abs, angle)利用一个绝对数值和一个角度值,从而在极坐标下构造一个复数张量
// 即abs∗cos(angle)+abs∗sin(angle)j
// torch.polar(torch.tensor([1], dtype=torch.float64), torch.tensor([np.pi / 2], dtype=torch.float64))
// # tensor([6.1232e-17+1.j], dtype=torch.complex128)
// freqs_cis其实就是需要计算出来的mθ,也就是跟绝对位置相关的旋转的角度,在极坐标下对应的复数tensor
// 这一步就是在生成我们需要的位置信息
// 直观理解一下,像是在复平面内,以原点为中心,转了1024组,每一组64个的单位向量,它的shape是(1024, 64)
freqs_cis = torch.polar(torch.ones_like(freqs), freqs) # complex64
return freqs_cis
// 第二个函数reshape_for_broadcast,是把freqs_cis变成和输入的tensor相同的形状
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])
// 这个方法的作用是为了把freqs_cis变成和输入的tensor相同的形状
// 需要注意的是,这里的freqs_cis并不是precompute_freqs_cis生成的形状为(1024, 64)的那个tensor
// 而是根据输入的绝对位置,在(1024, 64)的tensor中,截取了长度为当前seq_len的一部分
// 代码在Transformer类的forward方法中freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]
// 也就是说,假如当前输入的序列长度是512,那么截取出来的这个新的freqs_cis,形状就是(512, 64)
// reshape之后,形状就变成了(1, 512, 1, 32),也就是在每一个位置上,都对应有32个角度
// 根据上面torch.polar的介绍,当我们固定绝对值(也就是向量的模长)时,角度就可以在笛卡尔坐标系下唯一确定一个复数
// 这样一来也就是32个复数,即64个特征维度,所以就可以对应的将它融合到每个attention head的64个特征中去了
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方法,这个方法其实就是把位置信息添加到原有的编码结果上,在multi-head attention阶段调用
def apply_rotary_emb(
xq: torch.Tensor,
xk: torch.Tensor,
freqs_cis: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:
// torch.view_as_complex是把一个tensor转为复数形式
// 比如torch.view_as_complex(torch.Tensor([[1, 2], [3, 4], [5, 6]]))
// # tensor([1.+2.j, 3.+4.j, 5.+6.j])
// 假设输入x_q的尺寸就是(2, 512, 12, 64)
// 那么这一句操作的reshape,就是把它变成(2, 512, 12, -1, 2),也就是(2, 512, 12, 32, 2)。x_k同理,略
// 紧接着把它变成复数形式,也就是变成了(2, 512, 12, 32)的形状。
xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))
xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))
// 然后进入到上面的第二个函数reshape_for_broadcast
freqs_cis = reshape_for_broadcast(freqs_cis, xq_)
// torch.view_as_real是把复数tensor变回实数
// torch.view_as_real(torch.view_as_complex(torch.Tensor([[1, 2], [3, 4], [5, 6]])))
// # tensor([[1., 2.],
// # [3., 4.],
// # [5., 6.]])
// reshape之后,就是将位置信息融入query和key中
// 这一步将二者相乘得到的复数tensor,重新转换为实数形式,得到的shape为(2, 512, 12, 32, 2)
// 然后再flatten成(2, 512, 12, 64),这样一来,就变回了和最开始x_q相同的形状,也就完成了将位置信息融入到x_q的这一操作,x_k同理
xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)
xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)
return xq_out.type_as(xq), xk_out.type_as(xk)
引用此文的介绍,再着重解释下precompute_freqs_cis的作用
而precompute_freqs_cis就是提前将这些旋转角度对应的tensor给创建出来,并可以重复利用。因为确定了序列的最大长度,所以这个tensor是固定死的。根据后续的数据流我们可以发现,在调用该函数时,传入的两个参数分别是attention_head的维度,以及最大长度的两倍,具象地,也就是64和1024
LLaMA和GPT一样,都是基于
Transformer
这个架构,通常,我们在构建transformer时,是按Block构建的,每个transformer Block包含SA和FFN两部分,然后再通过堆叠block的形式,构建起整个transformer网络,LLaMA也是这样做的
回顾一下Attention计算的总体过程是:
其中有一个细节就是缓存机制,它设计的目的是在generate时减少token的重复计算。简单解释一下,就是在计算第n
个token特征的时候,需要用到第
个token,即每次生成时,需要知道前面所有的过往信息,如果每次都从头算的话,那就会造成极大的浪费,所以就没算一个位置的信息,就把它缓存下来
接下来,我们来看下代码实现,首先是SA部分:
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
self.wq = ColumnParallelLinear(
args.dim,
args.n_heads * self.head_dim,
bias=False,
gather_output=False,
init_method=lambda x: x,
self.wk = ColumnParallelLinear(
args.dim,
args.n_heads * self.head_dim,
bias=False,
gather_output=False,
init_method=lambda x: x,
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
xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)
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)
xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)
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.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(
).contiguous().view(bsz, seqlen, -1)
return self.wo(output)
然后是FFN部分,需要注意的点就是采用的激活函数,以及激活函数的位置
class FeedForward(nn.Module):
def __init__(
self,
dim: int,
hidden_dim: int,
multiple_of: int,
super().__init__()
hidden_dim = int(2 * hidden_dim / 3)
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
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
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]):
h = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask)
out = h + self.feed_forward.forward(self.ffn_norm(h))
return out
最后利用torch的module list将transformer block进行堆叠,拼上最前头的embedding部分,就是一个完整的transformer decoder结构了
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
self.tok_embeddings = ParallelEmbedding(
params.vocab_size, params.dim, init_method=lambda x: x
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,先做token embedding,然后添加位置信息
h = self.tok_embeddings(tokens)
self.freqs_cis = self.freqs_cis.to(h.device)
freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]
// 对于decoder模型,为了防止标签泄漏,需要mask,所以做了一个上三角的mask矩阵
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, :]) # only compute last 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 = self.model.forward(tokens[:, prev_pos:cur_pos], prev_pos)
if temperature > 0:
probs = torch.softmax(logits / temperature, dim=-1)
next_token = sample_top_p(probs, top_p)
else:
next_token = torch.argmax(logits, dim=-1)
next_token = next_token.reshape(-1)
# only replace token if prompt has already been generated
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()):
# cut to max gen len
t = t[: len(prompt_tokens[i]) + max_gen_len]
# cut to eos tok if any
t = t[: t.index(self.tokenizer.eos_id)]
except ValueError:
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)
mask = probs_sum - probs_sort > p
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月中旬,斯坦福发布Alpaca(中文名:羊驼):号称只花100美元,人人都可微调Meta家70亿参数的LLaMA大模型(即LLaMA 7B),具体做法是通过52k指令数据,然后在8个80GB A100上训练3个小时,使得 Alpaca版的LLaMA 7B 在单纯对话上的性能比肩GPT-3.5(text-davinci-003),这便是指令调优LLaMA的意义所在
可能有读者有疑问,即52k数据都长啥样呢?这52K数据存在Alpaca项目的alpaca_data.json文件中,这个JSON文件是一个字典列表,每个字典包含以下字段:
而斯坦福团队微调LLaMA 7B所用的52K指令数据,便是通过Self-Instruct『Self-Instruct 是来自华盛顿大学Yizhong Wang等22年12月通过这篇论文《SELF-INSTRUCT: Aligning Language Model with Self Generated Instructions》提出的 』提示GPT3的API拿到的
具体而言,论文中提出
def openai_completion(
prompts: Union[str, Sequence[str], Sequence[dict[str, str]], dict[str, str]],
decoding_args: OpenAIDecodingArguments,
model_name="text-davinci-003",
sleep_time=2,
batch_size=1,
max_instances=sys.maxsize,
max_batches=sys.maxsize,
return_text=False,
**decoding_kwargs,
) -> Union[Union[StrOrOpenAIObject], Sequence[StrOrOpenAIObject], Sequence[Sequence[StrOrOpenAIObject]],]:
prompts:输入提示,可以是单个字符串、字符串列表、字典或字典列表
decoding_args:解码参数,用于指定如何生成文本
model_name:要使用的模型名称,默认为"text-davinci-003"
sleep_time:在达到速率限制时,程序暂停的时间(以秒为单位)
batch_size:在单个请求中发送的prompts的数量
max_instances:要解码的prompts的最大数量
max_batches:要解码的批次的最大数量(此参数将在未来被弃用)
return_text:如果为True,则返回文本而不是包含诸如logprob等信息的完整completion对象
decoding_kwargs:其他解码参数,例如best_of和logit_bias
# 函数首先检查是否有单个prompt,如果是,则将其转换为列表
# 然后,根据最大实例数量截取prompts。接着,将prompts分成批次以便进行批处理
is_single_prompt = isinstance(prompts, (str, dict))
if is_single_prompt:
prompts = [prompts]
if max_batches < sys.maxsize:
logging.warning(
"`max_batches` will be deprecated in the future, please use `max_instances` instead."
"Setting `max_instances` to `max_batches * batch_size` for now."
max_instances = max_batches * batch_size
prompts = prompts[:max_instances]
num_prompts = len(prompts)
prompt_batches = [
prompts[batch_id * batch_size : (batch_id + 1) * batch_size]
for batch_id in range(int(math.ceil(num_prompts / batch_size)))
函数遍历这些批次,并尝试与OpenAI API进行交互。当遇到OpenAIError时,会根据错误类型采取不同的措施
如果是因为速率限制,程序将暂停一段时间再重试。如果提示过长,程序将减小目标长度再重试。
completions = []
for batch_id, prompt_batch in tqdm.tqdm(
enumerate(prompt_batches),
desc="prompt_batches",
total=len(prompt_batches),
batch_decoding_args = copy.deepcopy(decoding_args) # cloning the decoding_args
while True:
shared_kwargs = dict(
model=model_name,
**batch_decoding_args.__dict__,
**decoding_kwargs,
completion_batch = openai.Completion.create(prompt=prompt_batch, **shared_kwargs)
choices = completion_batch.choices
for choice in choices:
choice["total_tokens"] = completion_batch.usage.total_tokens
completions.extend(choices)
break
except openai.error.OpenAIError as e:
logging.warning(f"OpenAIError: {e}.")
if "Please reduce your prompt" in str(e):
batch_decoding_args.max_tokens = int(batch_decoding_args.max_tokens * 0.8)
logging.warning(f"Reducing target length to {batch_decoding_args.max_tokens}, Retrying...")
else:
logging.warning("Hit request rate limit; retrying...")
time.sleep(sleep_time) # Annoying rate limit on requests.
# 最后,函数根据return_text、decoding_args.n以及是否为单个prompt的情况返回不同类型的结果
if return_text:
completions = [completion.text for completion in completions]
if decoding_args.n > 1:
# make completions a nested list, where each entry is a consecutive decoding_args.n of original entries.
completions = [completions[i : i + decoding_args.n] for i in range(0, len(completions), decoding_args.n)]
if is_single_prompt:
# Return non-tuple if only 1 input and 1 generation.
(completions,) = completions
return completions
而斯坦福的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进行指令调优」,从而生成以下数据
可能有读者疑问,那微调的代码长啥样呢?实际上,微调步骤大同小异,据代码:tatsu-lab/stanford_alpaca · GitHub,可得微调的步骤如下
torch
,
transformers
等。
safe_save_model_for_hf_trainer
:安全地保存训练器中的模型;
smart_tokenizer_and_embedding_resize
:调整分词器和词嵌入大小;
_tokenize_fn
:将字符串序列进行分词;
preprocess
:预处理数据,对源数据和目标数据进行分词。
SupervisedDataset
类,用于加载数据、格式化输入、进行分词等操作。
DataCollatorForSupervisedDataset
类,用于将数据集的实例整理为批次。
make_supervised_data_module
函数,用于创建监督学习任务的数据集和整理器。
train
函数,用于执行以下操作:
transformers.HfArgumentParser
解析命令行参数,将它们分为模型参数、数据参数和训练参数
transformers.AutoModelForCausalLM.from_pretrained
从预训练的模型检查点加载一个用于因果语言建模的模型
transformers.AutoTokenizer.from_pretrained
从预训练的模型检查点加载分词器
make_supervised_data_module
函数为监督学习任务创建数据集和整理器
Trainer
类:实例化
transformers.Trainer
类
,并传入模型、分词器、训练参数以及数据集。
Trainer
类负责管理训练过程
Trainer类的train()
方法对模型进行微调,相当于链路就是:transformers库
Trainer.save_state()
方法保存模型的状态
safe_save_model_for_hf_trainer
函数将训练器中的模型安全地保存到磁盘
train
函数以开始训练过程
可能,很快便有同学疑问,怎么没有预想中的损失计算、梯度下降、参数更新呢,实际上这三步的具体实现都封装在了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)进行比较,以计算损失
loss = outputs.loss
outputs.loss是模型预测输出和真实输出(标签)之间的损失。这个损失值将用于计算梯度并更新模型参数
计算梯度:loss.backward()这行代码计算模型参数关于损失的梯度
loss.backward()
梯度累积:当gradient_accumulation_steps大于1时,梯度会被累积,而不是立即更新模型参数
if (step + 1) % self.args.gradient_accumulation_steps == 0:
更新模型参数:optimizer.step()这行代码根据计算出的梯度来更新模型参数
self.optimizer.step()
学习率调整: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来学习特定任务的知识,从而提高模型在该任务上的性能。
而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库支持以下流行的方法
而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 (中文称小羊驼,代码地址: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评估之后,效果还不错
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》(翻译之一,解读之一)
至于HuggingFace是著名开源工具Transformers的开发公司,很多推理工具都会支持Transformers中的模型截至23年3月中旬,超过100B参数量且能够支持中文的开源大模型只有BLOOM和GLM-130B
该项目主要包含以下三部分内容:
Chinese LLaMA (也称中文LLaMA,有7B和13B两个版本,项目地址),相当于在原版LLaMA的基础上扩充了中文词表并使用了中文数据进行二次预训练,进一步提升了中文基础语义理解能力,同时,在中文LLaMA的基础上,且用中文指令数据进行指令精调得 Chinese-Alpaca( 也称中文Alpaca,同样也有7B和13B两个版本)
具体而言,主要做了以下三方面的工作
# # 导入os模块,用于操作系统相关操作
import os
# 设置环境变量,使得Protocol Buffers使用Python实现
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"]="python"
# 导入LlamaTokenizer类
from transformers import LlamaTokenizer
# 导入Protocol Buffers格式的sentencepiece模型
from sentencepiece import sentencepiece_model_pb2 as sp_pb2_model
# 导入sentencepiece模块
import sentencepiece as spm
# 导入argparse模块,用于处理命令行参数
import argparse
# 创建一个命令行参数解析器实例
parser = argparse.ArgumentParser()
# 添加llama_tokenizer_dir参数,必需
parser.add_argument('--llama_tokenizer_dir', default=None, type=str, required=True)
# 添加chinese_sp_model_file参数,可选
parser.add_argument('--chinese_sp_model_file', default='./chinese_sp.model', type=str)
# 解析命令行参数
args = parser.parse_args()
# 获取llama_tokenizer_dir参数值
llama_tokenizer_dir = args.llama_tokenizer_dir
# 获取chinese_sp_model_file参数值
chinese_sp_model_file = args.chinese_sp_model_file
# load, 加载预训练LlamaTokenizer实例
llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir)
# 创建SentencePieceProcessor实例
chinese_sp_model = spm.SentencePieceProcessor()
# 加载中文sentencepiece模型
chinese_sp_model.Load(chinese_sp_model_file)
# 将LlamaTokenizer和中文sentencepiece模型转换为Protocol Buffers格式
llama_spm = sp_pb2_model.ModelProto()
llama_spm.ParseFromString(llama_tokenizer.sp_model.serialized_model_proto())
chinese_spm = sp_pb2_model.ModelProto()
chinese_spm.ParseFromString(chinese_sp_model.serialized_model_proto())
# print number of tokens
# 输出LlamaTokenizer和中文sentencepiece模型的词汇数量
print(len(llama_tokenizer),len(chinese_sp_model))
# 输出LlamaTokenizer的所有特殊词汇
print(llama_tokenizer.all_special_tokens)
# 输出LlamaTokenizer的所有特殊词汇ID
print(llama_tokenizer.all_special_ids)
# 输出LlamaTokenizer的特殊词汇映射
print(llama_tokenizer.special_tokens_map)
# 将中文词汇添加到LLaMA tokenizer中
# 提取LLaMA tokenizer中的词汇
llama_spm_tokens_set=set(p.piece for p in llama_spm.pieces)
print(len(llama_spm_tokens_set))
print(f"Before:{len(llama_spm_tokens_set)}")
for p in chinese_spm.pieces:
piece = p.piece
# 如果中文词汇不存在于LLaMA tokenizer中
if piece not in llama_spm_tokens_set:
new_p = sp_pb2_model.ModelProto().SentencePiece()
new_p.piece = piece
new_p.score = 0
# 将中文词汇添加到LLaMA tokenizer中
llama_spm.pieces.append(new_p)
print(f"New model pieces: {len(llama_spm.pieces)}")
# Save, 设置输出目录,用于保存合并后的sentencepiece模型
output_sp_dir = 'merged_tokenizer_sp'
# 设置输出目录,用于保存合并后的Chinese-LLaMA tokenizer
output_hf_dir = 'merged_tokenizer_hf'
# 创建输出目录(如果不存在)
os.makedirs(output_sp_dir, exist_ok=True)
# 打开合并后的sentencepiece模型文件,准备写入
with open(output_sp_dir + '/chinese_llama.model', 'wb') as f:
# 将合并后的sentencepiece模型序列化为字符串并写入文件
f.write(llama_spm.SerializeToString())
# 从合并后的sentencepiece模型文件中创建LlamaTokenizer实例
tokenizer = LlamaTokenizer(vocab_file=output_sp_dir + '/chinese_llama.model')
# 保存合并后的Chinese-LLaMA tokenizer到指定目录
tokenizer.save_pretrained(output_hf_dir)
# 输出保存信息
print(f"Chinese-LLaMA tokenizer has been saved to {output_hf_dir}")
# Test
# 重新加载原始的LLaMA tokenizer
llama_tokenizer = LlamaTokenizer.from_pretrained(llama_tokenizer_dir)
# 加载合并后的Chinese-LLaMA tokenizer
chinese_llama_tokenizer = LlamaTokenizer.from_pretrained(output_hf_dir)
# 输出合并后的tokenizer的所有特殊词汇
print(tokenizer.all_special_tokens)
# 输出合并后的tokenizer的所有特殊词汇ID
print(tokenizer.all_special_ids)
# 输出合并后的tokenizer的特殊词汇映射
print(tokenizer.special_tokens_map)
# 定义测试文本
text = '''白日依山尽,黄河入海流。欲穷千里目,更上一层楼。
The primary use of LLaMA is research on large language models, including'''
# 输出测试文本
print("Test text:n", text)
print
# 使用原始的LLaMA tokenizer对文本进行分词
print(f"Tokenized by LLaMA tokenizer:{llama_tokenizer.tokenize(text)}")
# 使用合并后的Chinese-LLaMA tokenizer对文本进行分词
print(f"Tokenized by Chinese-LLaMA tokenizer:{chinese_llama_tokenizer.tokenize(text)}")
这段代码的主要目的是将一个中文的sentencepiece模型与一个已经预训练好的LLaMA tokenizer进行合并,以便在处理中文文本时,LLaMA tokenizer能更好地进行分词。整个过程包括了加载模型、合并模型、保存新的tokenizer以及进行测试等步骤。
首先,通过argparse模块获取命令行参数,包括原始的LLaMA tokenizer的路径和中文sentencepiece模型的路径。
接着,加载这两个模型,并将它们转换为Protocol Buffers格式,方便进行操作。
然后,从中文sentencepiece模型中提取词汇,并将这些词汇添加到LLaMA tokenizer中。在这个过程中,需要检查每个中文词汇是否已经存在于LLaMA tokenizer中,以避免重复添加。
将合并后的模型保存到指定的目录。首先保存为sentencepiece模型文件,然后创建一个新的LlamaTokenizer实例,并将其保存为Hugging Face格式的tokenizer。
最后,对原始的LLaMA tokenizer和合并后的Chinese-LLaMA tokenizer进行测试,以验证合并是否成功。测试包括输出特殊词汇、特殊词汇ID、特殊词汇映射等信息,以及使用这两个tokenizer对给定文本进行分词。从测试结果可以看出,合并后的Chinese-LLaMA tokenizer能够更好地处理中文文本。
f"{instruction}+n+{input}"
的形式进行拼接
数据 | 量级 | 来源 | 说明 |
---|---|---|---|
中英翻译数据 | 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模型,效果相对更好一些(也更慢)
项目 | 一句话描述 |
Stanford Alpaca |
结合英文语料通过Self Instruct方式微调LLaMA 7B |
Vicuna-13B |
通过ShareGPT.com的7万条对话数据微调LLaMA |
BELLE |
结合中文语料通过Self Instruct方式微调BLOOMZ-7B或LLaMA |
Chinese-LLaMA/Chinese-Alpaca |
通过中文数据预训练/指令微调LLaMA |
ChatLLaMA(英文版) |
LLaMA的RLHF版 |
ColossalChat |
通过self-instruct技术指令微调LLaMA且加上RLHF |
更多请查看下一篇:从ChatGLM-6b到ChatDoctor
![]() |
跑龙套的小狗 · 人民网:江汉大学依斯巴尼亚西班牙语学校西班牙语中心揭牌启用 17 小时前 |
![]() |
痴情的大象 · 尋找泉州世遺:老君岩造像“天下第一” 1 周前 |
![]() |
精明的书包 · 湖南网络工程职业学院招生网 1 年前 |