前言
之前的内容中,将入门+跟进chatGPT-SOTA并形成自己的认知体系的内容做了整理:zhuanlan.zhihu.com/p/627641026
。但由于篇幅所限,内容又涵盖了技术和非技术的部分,对于很多技术的介绍不够。本文就与大家一起梳理训练一个ChatGPT的流程和示例代码,做中学。基于此,通过对各个模块的优化改善,就可以得到自己的模型。尽管当前开源实现已经茫茫多,但如果想要做出创新或者优化,甚至理解模型的一些表现,也需要对基础的底层实现有所了解。
本文一共分为五个小节,第一节核心是以GPT为例梳理实现一个基座模型LLM;第二节则是介绍增量学习与微调,同时介绍了SFT和alignment(对齐)两个在chatGPT中发挥重要作用的概念,到此为止就可以拿到一个表现不错的chatllm了;第三节介绍奖励模型和PPO,这部分让人类的参与释放了出来,使得模型自优化“对齐”变得自动化;第四节针对一些微调的关键点进行分析,并对训练使用的数据相关要点做了一个整合。第五节是一个小结。
PS:多卡的实验我没有做,因为穷(求包养 谢谢~
第一节:实现一个LLM
实现一个LLM,首先需要实现一个LM,基于之前对语言模型的介绍zhuanlan.zhihu.com/p/608047052…** Language Models,当前我们以 GPT 为代表,本质学习的是给定前序队列,然后预估下一个单词是什么的问题。为了深入了解GPT是怎么进化到ChatGPT的,我们会从GPT1开始逐步升级直到GPT3。
基于之前分析过复现需要关注的维度,我们从数据和模型两个维度切入来描述复现过程
PS:GPT刚出来的论文名叫Generative Pre-Training,这是GPT的全名,当前的盛况让我想到了熊猫和猫熊。
模型 | 介绍 |
---|---|
GPT4 | 预计参数量1-1.7w亿,支持文本和图像,输出文本(但是可以支持编程绘图),在各项任务上表现更好 |
GPT3.5(instructGPT和chatGPT) | 1750亿参数,文字输入输出;规范了Alignment这个概念,规范了训练流程:SFT、RLHF(RW+PPO);基于上文,我们看到这里集合了WebGPT和CodeX的优点。 |
GPT3 | 1750亿参数,文字输入输出。提出in-context learning(0/few-shot) |
GPT2 | 15亿参数,文字输入输出。弱化版GPT3,也是大家摸索GPT3的重要参考 |
GPT1 | 1.17亿参数,文字输入输出,无监督预训练,task oriented finetuning->下游任务上需要finetune,没有足够泛化性,同时finetune需要数据 |
这个项目中,我们基于nanoGPT进行模型实现
GPT的模型结构设计
这个小节单独对模型细节做一次介绍,代码实现在后续单独讲解。
GPT1明确-学习目标
论文中明确了学习目标,就是学习训练一个语言模型。给定一个数据集U=u1,,,unU={u_1,,,u_n},最大化以下log-likelihood:
L1(U)=∑ilogP(ui∣ui−k,…,ui−1;θ)L_1(U)=sum_{i}logP(u_i|u_{i-k},…,u_{i-1};theta)
k表示上下文窗口设定, θtheta表示神经网络的参数,L(U)表示联合概率,注意对数函数相加,底数和对数的变化。
我们都知道GPT使用了Transformer的解码器部分来作为语言模型,该模型在输入上应用多头自注意操作,然后通过位置感知的前馈层生成目标token的输出分布。在整个模型架构中,可以表示为以下三个公式,同时我们在模型架构图中表示出了对应的额输入和输出:
h0=UWe+Wph_0=UW_e+W_p :输入层,将位置向量和单词向量输入
hl=transformer_block(ht−1) ∀ i ∈ [1,n]h_l=transformer_block(h_{t-1}) forall i in [1,n]:将输入层的输出输入到transformer中,经过多层重复计算
p(u)=softmax(hnWeT)p(u)=softmax(h_nW_e^T):将最后一个transformer的输出输入到softmax层中,针对不同的单词输出最终的概率分布
其中u是上下文的token的向量,n是层数,W_e是word embeeding matrix,W_p是position embedding matrix。
暂时无法在飞书文档外展示此内容
GPT1奠定基础-模型结构设计
要点归结如下,GPT-2会基于此进行一定修改:
- 主要基于原始的Transformer工作使用了一个12层的deocoder-only Transformer模型,其中包括masked self-attention heads(768d和12个heads)
- 对于position-wise feed forward networks,使用了3072d inner states
- 使用了Adam优化器,最大学习速率为2.5e-4。学习率在前2000次更新期间线性增加,并基于cosine-schedule降至0
- 模型采用残差、嵌入和注意力的dropout,正则化率为0.1。同时还采用了L2正则化的修改版本,其中所有非偏置或增益权重的w=0.01
- 激活函数使用了GELU
- 使用了基于学习的位置嵌入,而非transformer中的正弦版本
- 我们使用ftfy库清理BooksCorpus中的原始文本,对一些标点和空格进行标准化,并使用spaCy分词器。
- 由于Layernorm在整个模型中被广泛使用,所以简单的权重初始化N(0, 0.02)就足够了。
- 数据输入:我们使用了一个BPE词汇表,其中包括40,000个merges
- 训练设置:使用batchsize为64,seq长度为512的随机采样数据,训练了100个epochs
GPT2-放大数据和模型参数
We would like to move towards more general systems which can perform many tasks – eventually without the need to manually create and label a training dataset for each one.
牢记这一点,才能更好的利用LLM
GPT-2 vs GPT-1的模型修改
-
从一开始的表中我们看到GPT-2相比GPT-1有显著的模型参数上升。
-
由于此时已经希望GPT-2是一个通才,可以zero-shot解决下游任务,所以构建了新的训练数据,清洗得到约40G
-
GPT-2基于GPT-1做了一些修改:
- 将Layer Normalization移动到了每一个sub-block的前面(我甚至怀疑过,但后来发现官方开源代码确实是这样的 hhh
- 在最后的self-attention block之后增加了一个额外的layer normalization
- 考虑到模型深度造成的残差积累,对初始化做了修改。在初始化时,通过一个因子 1/√N 对残差层的权重进行缩放,其中 N 是残差层的数量。
- 将词汇表扩展到50,257个词汇。
- 上下文长度从512增加到了1024
- 训练batchsize增加到了512
GPT-3
论文中号称使用与GPT-2相同的模型和架构,包括其中描述的修改初始化、预归一化和可逆标记化。
在transformer的层中,我们使用了Sparse Transformer,transformer中是用了alternating dense & locally banded sparse attention patterns。
表2.1显示了8个模型的大小和架构。其中,nparams是可训练参数的总数,nlayers是总层数,dmodel是每个bottleneck layer中单元的数量(我们始终使feedforward layer的大小是bottleneck layer的4倍,d_ff = 4 * d_model),dhead是每个注意头的维数。所有模型都使用n_ctx = 2048个标记的上下文窗口。
我们将模型沿着深度和宽度维度分配到 GPU 上,以最小化节点之间的数据传输。 每个模型的精确架构参数是基于计算效率和在GPU布局中的负载平衡而选择的。先前的工作表明,在合理的范围内,validation loss对这些参数并不敏感。
至于本文很重要的工作,比如in-context learning,则与模型训练没有关系。
GPT-4****
Multimodal model:image & text->model->text。~~~~chatgpt~~~~的实现暂时不需要~~~~GPT-4~~~~。
代码实现
至此,我们就了解了一个Pre-train LLM的一些基本模型细节。GPT1和2在前几年都有很多很多开源实现,很有利于我们了解学习。
由于当前没有足够的机器和数据,所以可以基于此跑一个小数据的小模型,这就让我想起当初实验Bert的时候了:zhuanlan.zhihu.com/p/113326366…
NOTE:当前由于 torch 和huggingface的存在,导致我们在实际编码上变简单了很多,很多细节自己抄一遍会更有感觉。
基于上面我们提到的技术路线,可以很明确的将整个过程进行实现。在一个模型训练定义的时候,我们一般会将其分为几个模块:
model.py:模型定义
train.py:模型训练调用代码
inference.py:模型推理调用代码
conf.py:保存公共配置信息,譬如全局随机种子等
data:文件夹下包括数据预处理代码和一些常用demo数据,由于不同数据源格式不同,会有多个数据处理文件的可能。训练全量数据一般也可以保存在这里,只是不会放在github上。(PS:对于常用数据,可以保存在公共目录下,方便不同项目使用)
bash:文件夹下保存多种常用bash文件,可以直接运行
其他可选:基于模型复杂度等,有可能会拆分不同的代码文件来保存模型用到的公共代码
下面我们解释一下BPE和transformer_block
BPE
这部分简化实现复制自:www.cnblogs.com/wwj99/p/125… platform.openai.com/tokenizer
举例来说明,我们要对下面的字符进行编码。
aaabdaaabac
字节对 aa
出现的次数最多,所以我们将它替换成一个没在字符串中被用过的字符 Z
,
ZabdZabac
Z=aa
然后我们重复这个过程,用 Y
替换 ab
,
ZYdZYac
Y=ab
Z=aa
继续,用 X
替换 ZY
,
XdXac
X=ZY
Y=ab
Z=aa
这个过程重复进行,直到没有字节对出现超过一次。当需要解码时,就将上述替换过程反向进行。
bpe的优势,是可以将vocab size 变多,但是每次输入就可以变短,这也是模型可以处理越来越长token的一种方式。坏处在于理解起来需要一个转化成本。同时这种操作可以避免OOV的问题。
实际使用中,可以用tiktoken来实现。
tiktok…tiktok…tiktok->tiktoken hhh
Transformer_block
关于self-attention和transformer的介绍也是比较古老了,这里我复制一下自己之前给Bert的介绍,做一些修改
数据输入:GPT用了两种输入进行相加输入到模型中:词向量参数,位置向量参数。并且位置向量的参数是可学习的。
一个transformerblock中包括了masked-multi-head-self-attention和feedforward,以及穿插在他们之间的normalization或者dropout等。下面我们介绍一下multiheadattention和feedforward。至于mask,在这个decoder-only的结构中,我们主要是用其来盖住当前timestep中后面的词语,不让解码器看到future words,避免用答案预估答案。
Multi-heads self-attention与feedforward
左边的图是一个self attention。即一个head,右边是多个head。
从结构中可以看到,Q,K,V就是我们输入的三个(句子词向量),从之前的词向量分析可知,输出向量大小从len -> len x hidden_size,即len x 768。
如果是self-attention,Q=K=V,如果是普通的attention,Q !=(K=V)。
上次说过了Q和K就是两个要比较的东西。在NLP中的attention中K和V一般是一样的【或许可以修改V得到一种新的attention,应该有了类似的研究了吧】
但是,不管用的是self-attention还是普通的attention,参数计算并不影响。因为在输入单头head时,对QKV的向量均进行了不同的线性变换,引入了三个参数,W1,W2,W3。其维度均为:768 x 64。为什么是64呢,
768/12 . 选择了12个头。所谓的12个头就是有12个scaled dot-product attention. 所以每个头的输入都是64维的。 得出:W1,W2,W3的维度都是768 x 64。为什么W123是一样的维度,因为他们的输入是一样的,本身就是同一个东西在做self-attention。 所以参数计算结果为:
一个head:768 * 768/12 * 3 输入768,本身维度的输入是768,输出是64,有三个
有12个head。所以有768 * 768/12 * 3 * 12
有一个线性变化的W,768 * 768
所以在multi-heads的参数量为768 * 768/12 * 3 * 12 + 768 * 768=1769472+589824=2359296
对应的每个W都会有一个相应维度的bias 12个 64 以及一个 768.合起来就是两个768
FeedForwrd:
FFN(x)=max(0,xW1+b1)W2+b2FFN(x)=max(0, xW_1+b_1)W_2+b_2
看到参数有W1,W2。其中用到了两个参数W1和W2,Bert沿用了惯用的全连接层大小设置,即4 * dmodle(768,上一层的输出,x就是768 * 1),为3072,因此,W1,W2大小都是为768 * 3072,2个为 2 * 768 * 3072=4718592。
有两个bisa,那么大小为3072*2
解释一下,W1负责把768变成3072, W2再变回来。BTW这个max就是relu。
参考实现如下:
class CausalSelfAttention(nn.Module):
def __init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0
# key, query, value projections for all heads, but in a batch
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
# output projection
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
# regularization
self.attn_dropout = nn.Dropout(config.dropout)
self.resid_dropout = nn.Dropout(config.dropout)
self.n_head = config.n_head
self.n_embd = config.n_embd
self.dropout = config.dropout
# flash attention make GPU go brrrrr but support is only in PyTorch >= 2.0
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
if not self.flash:
print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
# causal mask to ensure that attention is only applied to the left in the input sequence
self.register_buffer("bias", torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size))
def forward(self, x):
B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)
# calculate query, key, values for all heads in batch and move head forward to be the batch dim
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
# causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
if self.flash:
# efficient attention using Flash Attention CUDA kernels
y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True)
else:
# manual implementation of attention
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
att = att.masked_fill(self.bias[:,:,:T,:T] == 0, float('-inf'))
att = F.softmax(att, dim=-1)
att = self.attn_dropout(att)
y = att @ v # (B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side
# output projection
y = self.resid_dropout(self.c_proj(y))
return y
class Trans_Block(nn.Module):
def __init__(self, config):
super().__init__()
self.ln_1 = LayerNorm(config.n_embd, bias=config.bias)
self.attn = CausalSelfAttention(config)
self.ln_2 = LayerNorm(config.n_embd, bias=config.bias)
self.mlp = MLP(config)
def forward(self, x):
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x
第二节:增量学习与微调
增量学习
如果我们从头开始训练,明显成本过高,所以我们可以退而求其次,进行continue leanring。增量学习,有时候也叫做继续学习,是基于一个模型的进一步学习。在LLM场景下,其目的往往是进一步增强LLM的基础能力。学习方式也与预训练基本一致,加载好之前训练的模型,然后继续学习。在训练技巧上,有时候会有一些训练设置上的变化,要根据实际情况而定。
与Finetuning相比,CL在定义上更加倾向于轻目的倾向性,强调优化模型的通用语言能力
注意,在数据输入和模型设置上必须与原始模型一样。这部分的训练代码配置和微调基本一致,可以参见微调部分
微调finetune(代码需要调整)
所谓微调,与增量学习一样,即基于给定模型进行进一步学习。与增量学习上的唯一区别在于,其往往会有更强烈的领域/任务目的性。
微调的流程是从一个已经训练好的模型加载,然后开始训练,在训练设置时,一般以较小的学习率,这是为了避免对整体的大模型进行波动干扰,影响到原始的通用能力。同时训练所使用的数据往往会与自己的目的有关,譬如某个垂直领域,或者某种形式的对齐(例如iGPT或者cGPT)
下面是m1笔记本的训练代码示例:
m1
训练
python train.py config/train_shakespeare_char.py --device=cpu --compile=False --eval_iters=20 --log_interval=1 --block_size=64 --batch_size=12 --n_layer=4 --n_head=4 --n_embd=128 --max_iters=100 --lr_decay_iters=2000 --dropout=0.0
推理
python sample.py --out_dir=out-shakespeare-char --device=cpu
Supervised fine-tuning
GPT1在实际应用到下游任务的时候,需要进行fine-tuning来优化效果,了解即可,上个时代的产物。在后面了解对齐任务的时候,这部分了解有帮助。
在第一部分的语言模型训练好之后,接下来进行监督学习。给定一个有标签的数据集C,X是输入token,y是label。
输入X经过预训练模型得到最终的transformer block的激活函数hlmh_l^m,之后将其喂入一个线性输出层(参数为W_y)中来预估label y
P(y∣x1,…,xm)=softmax(hlmWy)P(y|x^1,…,x^m)=softmax(h_l^mW_y)
基于此,最大化以下目标函数
L2(C)=∑x,ylogP(y∣x1,…,xm)L_2(C)=sum_{x,y}logP(y|x^1,…,x^m)
这里也提到了,将语言模型作为一个额外的目标进行优化可以提升监督学习模型的泛化性,加速收敛。
L3(C)=L2(C)+λ∗L1(C)L_3(C)=L_2(C)+lambda*L_1(C)
alignment(对齐)-SFT
基于上面的介绍,我们了解了如何进行模型的finetuning。同时OpenAI有明确的提出,对齐是让模型了解到人类的习惯,思想,说话方式和价值观。这里我们要让模型来学习人类针对一些prompt的回复,同样通过SFT。
那么训练数据应该如何构造?先看一下在预训练的时候数据应该如何构造
def get_batch(split):
"""
GPT生成~
这段代码定义了一个函数get_batch(split),用于生成输入数据(x)和目标数据(y)的小批量样本。
首先,根据split参数选择使用训练集还是验证集的数据。如果split是'train',则使用训练集数据,否则使用验证集数据。
然后,通过随机生成一个长度为(batch_size,)的整数张量ix,其取值范围是从data数据中减去block_size的长度。
接下来,通过将索引ix应用于data,使用列表推导式生成输入数据x和目标数据y。具体而言,对于每个索引i,从data中截取长度为block_size的子序列作为输入x,从索引i+1到索引i+block_size+1的子序列作为目标y。
最后,将生成的x和y转移到设备(通常是GPU)上,并返回这对数据作为函数的输出。
这个函数的目的是为了生成模型训练过程中的小批量样本,其中batch_size表示每个批次的样本数量,block_size表示输入和目标序列的长度。
"""
# generate a small batch of data of inputs x and targets y
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i:i+block_size] for i in ix])
y = torch.stack([data[i+1:i+block_size+1] for i in ix])
x, y = x.to(device), y.to(device)
return x, y
ok这段代码也很简单,基于此,我们思考一下alignment的数据应该如何构造,下面是找的一段开源的prompt指令训练数据:
{"instruction": "在以下文本中提取所有的日期。", "input": "6月21日是夏至,这是一年中白天最长的一天。", "output": "6月21日"}
{"instruction": "", "input": "请生成一个新闻标题,描述一场正在发生的大型自然灾害。nn", "output": ""强烈飓风肆虐,数百万人疏散!""}
对于一个生成模型来说,他的输入应该是指令和input,输出就是output,由于基础的生成能力我们其实并不需要在这个过程中训练,所以我们就会有明确的训练目标:即基于给定的指令+input生成output。基于此,训练数据的生成代码可以是以output的结尾idx为标记开始构造预估目标。
def get_instruction_batch(split):
# generate a small batch of data of inputs x and targets y
data = train_data if split == 'train' else val_data
ix = torch.randint(low=data.find("output") + 11, len(data) - block_size, (batch_size,))
x = torch.stack([data[i:i+block_size] for i in ix])
y = torch.stack([data[i+1:i+block_size+1] for i in ix])
x, y = x.to(device), y.to(device)
return x, y
但是我们想一想,似乎在使用chatgpt的时候,它会自动补全我们的一些没有写完或者没有写对的指令,所以这里的训练是可以直接保留一开始getbatch的数据构造代码的。
【绕了一下~】
至此,可以说就可以得到一个像模像样的chatGPT了。
第三节:RLHF:RM与PPO
至此,就已经完成了所有的预训练+SFT的工作,也就是说我们已经得到了一个初步可用的模型,也可以说就可以得到一个像模像样的chatGPT了。如果是基于开源模型微调的话,这个模型的效果或许已经和很多开源的模型效果相当,因为当前开源的工作也没有明确表示他们做过后续的RM和PPO的工作。
NOTE:为什么我们这里要用RL来学习用户反馈而不是继续用SFT呢?因为SFT的继续学习,鉴于目标函数,其依然是在学习一个概率,不同的数据是在改变概率分布。但我们这里的目标是进一步学习alignment。如果继续堆积SFT,无非就是让模型输出的习惯和内容更加符合“人话”,即语言模型本身的性能会越来越好。
而RM+PPO想要做的是让模型学习在N句都是人话的选项中,排序出哪些更好。相比SFT照葫芦画瓢,此时其中隐含的是一种价值观、事实性以及labeler偏好的判断,会让模型更不容易胡说八道。
SFT:侧重学习给定prompt下的语言表达形式
RLHF:侧重学习给定prompt下的语言偏好
在基于给定模型finetune的过程中,如果不断强调SFT的学习,可能会让RLHF性能下降;此时合理的情况应该是SFT和RLHF一起作用来实现对模型的迭代优化,甚至仅RLHF。
流程
简单介绍一下这部分的工作流程,下图中的step2和3:
-
收集对比数据,训练一个奖励(Reward)模型
- 采样获取propmt和几个语言模型输出的结果,构成pair
- 标注者对这些输出结果进行从好到坏的排序
- 使用标注数据来训练奖励模型
-
基于RW模型,使用RL对语言模型进行优化
- 从训练数据中采样一个prompt
- 语言模型针对这个prompt输出结果
- 奖励模型为这个输出的结果计算一个分数
- 语言模型利用这个计算出来的分数进行模型优化
从上面的流程中,我们可以看到:基于此再进行后续的工作,RW+PPO,则是将对人类的依赖释放,利用RW+PPO不断进行模型优化。下面我们分别介绍奖励模型、PPO算法以及RLHF的流程与建模。
Reward模型训练
为了实现RM+PPO,首先我们需要训练一个Reward模型,训练数据的格式:(prompt, winning_response, losing_response)。
-
Prompt(提示): 表示输入的问题或上下文。
-
Winning response(获胜回答): 表示模型认为是正确或优秀的回答。
-
Losing response(失败回答): 表示模型认为是错误或较差的回答。
-
数据规模:10万到100万个example
- InstructGPT:50,000个prompt。每个prompt有4到9个response,形成了6到36个(winning_response, losing_response)pair。这意味着在(prompt, winning_response, losing_response)格式中有30万到180万个训练示例。
- Constitutional AI,可能是Claude(Anthropic)的backbone:318,000个comparison pair(其中13.5万个由人类生成,18.3万个由人工智能生成)。Anthropic还有一个旧版本的数据开源(hh-rlhf:huggingface.co/datasets/An… pair。
训练设置:
rθ:被训练的奖励模型,由参数θ进行参数化。训练过程的目标是找到使损失最小化的θ。
-
训练数据的格式如下:
- x:提示(prompt)
- yw:获胜回答(winning response)
- yl:失败回答(losing response)
-
对于每个训练样本(x,yw,yl):
- sw = rθ(x, yw):获胜回答的奖励模型得分
- sl = rθ(x, yl):失败回答的奖励模型得分
- 损失值的计算公式为:−log(σ(sw−sl))
-
目标是找到参数θ,以最小化所有训练样本的期望损失,即−Exlog(σ(sw−sl))。
这里我们的奖励模型可以基于之前训练好的SFT进行,即参考GPT-1时代介绍过的finetune工作,来实现对训练样本的打分。
PPO算法
PPO是OpenAI推出的RL算法,其提出的目的是为了解决Policy Gradient中低效与更新不稳定的问题,具体特点如下:
- Mini-batch training:由on-policy修改成为off-policy,可以提升对受限数据集的使用效率
- Regularization KL:PPO利用了KL作为约束来避免对小型数据集的过拟合
- Clip Objective:使用了clip来避免不稳定的变化,也减少了过拟合的风险
PPO在chatGPT中作用的节点如下
关于上图中左边绿色的部分,严格意义上应该是一个指令微调后的模型,如果我们直接用RL进行模型调整的话,一方面不一定能够很好的针对给定的prompt生成想要的格式,特别是一个general的LLM要应对对话这种形式,另一方面效率这样的调整效率也不够高。SFT与RL实际要做的事情的侧重还是不同的,上面我们已经分析过了。
Meta与CMU也放出了相应的研究,即使没有经过RLHF的训练,仅仅通过详细的SFT也能够拿到很好的效果。论文见:arxiv.org/pdf/2305.11…
关于PPO详细介绍可以看论文,也推荐这个视频进行了解:www.bilibili.com/video/BV1sg…
RLHF
这里回顾一下SFT的流程:从prompt数据集中采样prompt,然后由标注人员进行标注,最终使用prompt和标注结果构成的数据来finetune语言模型。
综上,整个流程中标注人员参与的环节有SFT和RW模型训练的环节。也就是在线上实际应用模型的时候,SFT和RW模型训练这两个环节是可以将用户反馈的信息引入并进行模型优化的。
训练流程
这里,我们进一步训练SFT模型,生成的输出回答将最大化奖励模型的得分。OpenAI使用Proximal Policy Optimization (PPO)进行这部分的训练。在此过程中,随机选择一批prompt,将每个prompt输入到LLM中,得到一个回答,并由RM给出得分,基于给定的得分进行模型参数更新。
从这个阶段得到的模型不应该与SFT阶段得到的模型相差太远,这一点在下面的公式中以两个模型的KL散度作为约束条件表现。其原因是对于任何给定的prompt,有许多可能的回答,其中绝大部分奖励模型以前从未见过。对于那些未知的(prompt,answer)对中的许多情况,奖励模型可能会错误地给出极高或极低的得分。如果没有这个约束条件,我们可能会偏向那些得分极高的回答,尽管它们可能不是好的回答。
强化学习建模
- 动作空间(Action space):LLM使用的词汇表中的token。执行动作意味着完成一次要生成的token的选择。
- 观察空间(Observation space):所有可能的prompt的分布。
- Policy:给定一个observation(prompt)下的所有可能采取的action的概率分布。LLM就是这个Policy,因为它会决策一个token有多大的可能性被选择。
- 奖励函数(Reward function):奖励模型,就刚才我们讲过的reward模型训练
- 训练数据:随机选择的prompt。
- 数据规模:10,000 – 100,000个prompt(InstructGPT:40,000个prompt。)
数学表示
-
RM: 奖励模型
-
LLM^SFT: SFT的结果,有监督微调模型。
- 给定prompt x,它输出一系列回答的分布。
- 在InstructGPT论文中,LLM^SFT被表示为πSFT,这是因为在强化学习的建模中经常这样表示
-
LLM^RL_ϕ: 使用强化学习训练的模型,由参数ϕ进行参数化。
- 目标是找到使得根据RM得分最大化的ϕ。
- 给定prompt x,它输出一系列回答的分布。
- 在InstructGPT论文中,LLMRLϕ被表示为πRLϕ。原因同上
-
x: prompt
-
D_RL: 明确用于RL模型的prompt分布。
-
D_pretrain: 预训练模型的训练数据分布。
对于每个训练步骤,从D_RL中抽取一个x_RL批次,从D_pretrain中抽取一个x_pretrain批次。每个样本的目标函数取决于样本来自哪个分布。
x_RL = prompt_batch
x_pretrain = batch of seqs
详细训练步骤如下:
- 对于每个x_RL(即prompt),使用LLM_RL生成回复y。objective方程如下,公式第二项是KL散度,目的是为了不让RL训练后的模型与SFT差异过大。
objective1(xRL,y;ϕ)=RM(xRL,y)−βlog(LLMϕRL(y∣x)/LLMSFT(y∣x))objective1(x_{RL},y;ϕ)=RM(x_{RL},y)−βlog(LLM^{RL}_ϕ(y|x)/LLM^{SFT}(y|x))
- 对于每个x_pretrain,目标函数的计算如下。从直观上讲,这个目标是确保RL模型在文本完成任务上表现不会比预训练模型更差。
objective2(xpretrain;ϕ)=γlog(LLMϕRL(xpretrain)objective2(x_{pretrain};ϕ)=γlog(LLM^{RL}_ϕ(x_pretrain)
在这里,x_pretrain表示预训练数据集中的样本(例如,预定义的对话数据)。目标函数objective2的计算涉及使用LLMRLϕ模型对xpretrain进行采样,并计算其对数概率。
通过最大化这个目标函数,我们希望确保RL模型在文本完成任务上的表现不会比预训练模型更差。这有助于保持模型的基本能力,并防止在优化过程中产生负面效果。通过控制目标函数中的γ参数,可以调整这个任务对优化过程的相对重要性。
- 最终的目标是以上两个公式之和。在RL设置中,最大化objective作为我们的学习目标
objective(ϕ)=Ex DRLEy LLMϕRL(x)[RM(xRL,y)−βlog(LLMϕRL(y∣x)/LLMSFT(y∣x))]+γEx Dpretrainlog(LLMϕRL(xpretrain)objective(ϕ)=E_{x~D_{RL}}E_{y~LLM_ϕ^{RL}(x)}[RM(x_{RL},y)−βlog(LLM^{RL}_ϕ(y|x)/LLM^{SFT}(y|x))]+γE_{x~D_{pretrain}}log(LLM^{RL}_ϕ(x_pretrain)
Tips:已知当前基于RLHF的思路和流程,但实际效果不一定是最优的,这个情况在OpenAI的WebGPT中也同样有所讨论
第四节:微调的关键要点
在我们的实际开发和应用过程中,大多数人的工作都是基于开源模型的微调优化;即使是当前一些开源出来的模型,也有很多模型基底是基于开源进行的,毕竟大把的时间和金钱,还是能省则省。这一节,我们讨论一些流行的微调方法以及并对各个模块的优化点进行梳理,并将数据相关工作也做了简单讨论。
开源基底模型
当前业界开源模型主要由外国研究者贡献,国内当前开源较为有代表性的有复旦的MOSS,清华的chatGLM以及近期火热的RWKV等工作。
在前序内容中我们介绍了GPT系列工作的架构,这也是当前大多数模型的架构。由于之前的文章已经提供过了一些基础的模型介绍,www.zhihu.com/question/59…
在之前介绍的基础上,新加两个工作:
- ColossalChat:首个开源了整体的RLHF-pipeline的工作。github.com/hpcaitech/C…
- Linly:身边小伙伴的工作,整体开源的信息相对清晰。github.com/CVI-SZU/Lin…
微调手段
本节图片主要来自于chatGLM的微调教程PPT。
全参数微调
所谓全参数微调,就是对模型的所有参数都进行了调整,也是上文中提到的SFT的实现方式。由于在实际中存在客观困难(模型大,数据多,参数多)等,所以我们有一些优化的微调手段。
混合精度微调: 混合精度是指训练时在模型中同时使用 16 位和 32 位浮点类型,从而加快运行速度,减少内存使用的一种训练方法。通过让模型的某些部分保持使用 32 位类型以保持数值稳定性,可以缩短模型的单步用时,而在评估指标(如准确率)方面仍可以获得同等的训练效果。现代加速器使用 16 位 dtype 执行运算的速度更快,因为它们有执行 16 位计算的专用硬件,并且从内存中读取 16 位 dtype 的速度也更快。实际工作中要注意的点:
- 实际工作中,经常在内存中用fp16做储存和乘法从而加速计算,用fp32做累加避免下溢出误差
- 为了避免梯度过小,可以手动在避免梯度爆炸的基础上,在梯度上进行放大,这样当实际发生fp16和fp32的计算的时候,由于我们放大了值,就不容易出现下溢出。
- 小结:省 显存 ,加快训练速度,但是会丢失精度
多卡训练
Data Parallel:多张卡使用相同的模型,一张卡跑一部分batch,各自bp计算gradient,然后整体求均值,然后各自进行优化然后进行参数更新,这样的好处是速度比较快,通信很少。缺点是浪费了memory,因为每张卡都要load同样的模型,都要计算一次gradient。同时如果模型太大,单卡太小也放不进去。
Model Parallel:将一个模型拆分到不同的卡上,使用同一个batch的数据,在实际训练的时候将中间结果在卡与卡之间移动,分别训练模型的不同部分。优点是克服了上面说的缺点,但由于这种频繁的通讯,也导致了整体的计算效率的问题。
ZeRO: ZeRO(The Zero Redundancy Optimizer)是一种用于大规模分布式深度学习的新型内存优化技术。ZeRO可以在当前的GPU集群上训练具有1000亿个参数的深度学习模型,其吞吐量是当前最佳系统的三到五倍。它有三个优化步骤,分别对应Optimizer State Partitioning, Add Gradient Partitioning以及Add Parameter Partitioning。简单来说就是将每个步骤都拆分到不同的卡上,相比baseline(data parallel), GPU可以节约很多。
P-tuning
P-tuning v1 主要结构是利用了一个prompt encoder(例如BiLSTM+MLP),将prompt 通过encode得到向量,然后再与input embedding进行拼接,具体拼接位置不一定是前缀,也可以是中间位置。其初衷是为了增加模型的理解能力。
P-tuning v2 则是一个改进,不仅会添加prompt的embedding调整,还会对应在每一层中都加上prompt对应的权重参数,然后对这部分进行调优(实际实现中有一种很好理解的方式,就是要额外拼接一部分参数,但这种生效方式很不优雅,可以参见chatGLM中的代码实现huggingface.co/THUDM/chatg…
以及苏剑林在P-tuning讨论过的实现方式zhuanlan.zhihu.com/p/364141928…
P-tuning v2这种方式,可能会有灾难性遗忘以及过拟合的问题,因为其往往用了比较少的数据(这也是其优点)实现高性价比的finetune。
LoRA
LoRA(Low-Rank Adaptation)是一种用于对大型模型进行低成本微调的方法。基本原理是冻结预训练好的模型权重参数,在冻结原模型参数的情况下,通过往模型中加入额外的网络层,并只训练这些新增的网络层参数。由于这些新增参数数量较少,这样不仅 finetune 的成本显著下降,还能获得和全模型微调类似的效果。
当我们训练大型的语言模型时,它通常有非常多的参数,这使得训练过程非常耗时和昂贵。LoRA方法的核心思想是,这些大型模型其实是过度参数化的,其中的参数变化可以被视为一个低秩矩阵。因此,我们可以将这个参数矩阵分解成两个较小的矩阵的乘积。在微调过程中,我们不需要调整整个大型模型的参数,只需要调整低秩矩阵的参数。这样做可以显著减少微调所需的参数数量,从而降低训练成本。
具体来说,我们假设大型模型的参数矩阵A可以分解为两个较小矩阵U和V的乘积:A = UV。我们将U和V看作是微调的参数,而A是固定的。实际作用中,前向传播时A和UV都会计算作用,将最终的结果相加,然后进行损失计算。接下来进行反向微调,此时仅仅调整UV即可。通过调整U和V的数值,我们实际上是在微调整个模型。一旦我们调整好了矩阵U和V的数值,可以将U和V的乘积作为一个低秩矩阵的近似,记作UV^T。然后,我们将UV^T与原始参数矩阵A进行加法操作,即 A_new = A + UV^T。最终用A_new替换掉A,就得到了新的调整后的模型
下图中的W0就是A,delteW就是UV。
这种低秩矩阵的分解方法可以有效地减少微调所需的参数量,因为U和V的大小较小。这使得微调过程更加高效和经济。同时,由于大部分参数是固定的,我们可以更快地完成微调过程。
chatGLM推出了微调的教程,参见:www.bilibili.com/video/BV1fd…
优化点
除了微调以外,还有很多地方可以优化,简单列举如下
训练优化:当前的训练成本依然很高,如何能够高效省钱的完成训练,除了训练中的一些tricks以外,软硬件结合等相关的操作或许也会对未来的微调有所影响。
模型优化:模型结构上我们依然存在优化点,比如当前的transformer是最优的的么,参见RWKV:github.com/BlinkDL/RWK…
推理优化:当前已经有很多人在研究如何在消费级显卡上进行推理,也有人研究进行端上的部署,这块如何在确保模型效果的基础上,实现低成本部署则是会很大程度上影响到模型最终的应用和落地,以及未来应用场景的拓展。
tokenizer优化:在不同的语言中,不同的tokenizer或许会有不同的效果;同时 tokenizer的存在是否是足够有价值的,也是当前被诟病的一点(twitter.com/karpathy/st…
生成速度优化:从左往右的生成具有速度上的问题,可以参见arxiv.org/pdf/2205.07… 字节跳动DA-transformer在生成上做到了加速。
上下文长度:Vcc: Scaling Transformers to 128K Tokens or More by Prioritizing Important Tokens将上下文长度做了延伸 arxiv.org/abs/2305.04…
数据
训练数据非常重要,可以说是决定了模型性能的天花板,这一点已经被所有人公认了。但是当前有个关键点是数据、模型和效果三者之间的关系暂时还无法做出具体建模。有研究指出当前GPT的模型其实还没有被完全训练到,当前使用的训练数据还不足够。而数据(规模,质量)要如何与模型结构以及参数搭配才能得到最好的效果(还有具体的ROI上的问题),这块的研究暂时还没有结论,也是一个非常有前(需要钱)景的研究方向。
关于数据这块,相信也是各个机构都在认真思考和研究的方向,当前关于数据的很多问题其实也没有明确的答案,也处在一个讨论的阶段:
-
预训练阶段:建立语言模型的基础通用能力,也是最消耗算力和数据的部分。
- 要如何对数据进行清洗,比如数据去重、数据normalization等操作。
- 数据质量:如何衡量数据的质量,比如红楼梦等四大名著和当代一篇长文网络小说,古诗词和现代口水歌之间的优劣。
- 数据规模,在算力有限的情况下,数据规模应该如何选择
-
SFT+RLHF:
- 数据标注机制如何设计才能提升数据质量
- 有没有一些trick的思路来获取数据
- 增量数据或者finetune的数据要如何与trained模型结合
-
数据自动合成与寻找:针对一个模型,随着训练的进行,可以学习的数据会越来越少;而模型训练者需要明确了解模型的能力,然后帮助其寻找到对它有用的数据。这个过程理论上是可以自动化进行的。
-
评估数据:如何设计评估数据,如何获取评估数据,评估维度等,由于这个方向除了数据还会涉及到对模型评估的维度或者具体的评估标准的设计问题,所以不单是一个简单的数据问题。这里可以见当前
-
部分已知开源数据:严格意义上,我们需要将数据分为预训练数据集和对齐数据集两部分。详细的开源数据有很多,限于精力这里就不穷举了,很容易可以找到很多。
-
预训练数据:这部分数据相对较多,但由于算力的问题,合理进行数据过滤和选择依然是关键问题。常见的开源数据来源有维基百科、Common Crawl等;而对于推理能力等方面则需要一些开源的代码/科学推理等相关的数据使用。
-
对齐数据:这里我们更想要的是指令数据集,例如huggingface.co/datasets/An…
-
中文开源项目:
-
Linly:github.com/CVI-SZU/Lin…
-
Moss 数据:github.com/OpenLMLab/M…
-
第五节:小结(整体的review)
这个内容可以分为三大块,第一节与第二节属于大语言模型的基础知识,具备这些逻辑上就可以自己训练得到一个LLM;第三节则是对RLHF进行了拆解,这是chatGPT避免“胡说八道”重要的一个步骤,但也是当前不太好评估效果的一个维度;第四节则是给出了微调的要点,也是当前要开展模型相关工作时候必须了解的内容。
本文没有详细介绍的包括:
- 数据收集与校验机制:机制上如何获取数据、标注数据质量如何从机制上优化、数据清洗trick,数据收集和处理pipeline
- RLHF实现细节:Reward模型训练与基于PPO进行LLM优化的实现细节。
- 工程优化相关细节。
关于文中提到的第一第二小节的代码见:github.com/DukeEnglish…
后续TODO:
- 增加基于中文数据的模型训练和微调
- 增加RLHF的详细实现
- 以某个开源中文模型为例进行上述流程测试
附录
原始gpt-1代码:github.com/openai/fine… (手动实现了transformer,但是依赖了旧版tensorflow)
原始gpt-2代码:github.com/openai/gpt-…
MAC环境配置可以参考:zhuanlan.zhihu.com/p/548685817
当前我们这个行业也存在一个问题,大家代码和论文有时候不知道是故意还是不小心,会有一些版本不重合(就像各位的注释、文档和代码的不一致一样,嘿嘿 手动狗头)这导致的是在学习的时候,搞不明白代码为啥这样写,为啥跟论文写的不太一样,而且不一样的地方又很少……。
参考
本文重点参考内容如下,感谢大佬们的分享:
www.jiqizhixin.com/articles/20…
yaofu.notion.site/C-Eval-6b79…