强化学习入门之RHLF-PPO 通俗易懂版本

一. 强化学习概述

1.概念

在这里插入图片描述

1.在强化学习这门技术或者学科构建的过程中,涉及到两个实体概念,智能体(Agent)和环境(Environment)

2.两个实体的交互涉及到如图所示的三个概念

  1. 状态空间S,S即为State,指的是环境中所有可能状态的集合
  2. 动作空间A,A即为Action,指的是智能体所有可能动作的集合
  3. 奖励R,R即为Reward,环境对智能体在状态 St 下采取动作 At 并导致状态转移到 St+1 的结果所给出的数值反馈。

3.实体交互过程如图所示

在 t 时刻,环境状态为 St
智能体基于状态 St 选择动作 At
环境执行 At 后转移至 St+1,并返回奖励 Rt+1

2. 价值函数

在上面过程中只讲到了奖励值 Rt ,表示的是环境进入 St 下给的即时奖励 (但是注意一点的是,这里的奖励是针对上一个时刻的动作给出的奖励)。 但是在实际过程中应该也要考虑当下的动作对于未来的状态和动作,进而影响到未来的整体收益。所以,针对这种思想,一种更好的设计方式是 t 时刻的状态s的总收益=当下s状态的收益和从状态s出发以后能带来的未来收益。写成表达式是
Vt = Rt + γ Vt+1 ,其中γ是折扣因子,要考虑我们在多大程度上将未来收益纳入当下收益。
智能体的最终目标就是找到一个策略,这个策略根据当前观测到的环境状态和奖励反馈,来选择最佳的动作。

二. 自然语言处理中的强化学习

这节主要解释在NLP任务中如何理解智能体,环境,动作和状态等关键词,在RLHF(基于人类反馈的强化学习)中我们希望给模型一个prompt,让模型能生成符合人类喜好的response, 而在gpt模型的推理过程中,每个时刻t只产生一个token,即token是一个一个蹦出来的。因此这个gpt模型推理的过程可以类比到强化学习,就是在 t 时刻,模型根据上下文产生一个token,这个token就是对应着强化学习中的动作,记为 At ,,因此在NLP中 强化学习任务中的动作空间就是对应着词表,在t时刻模型产出token At 对应着即时收益为 Rt,总收益为 Vt ,这个收益可以理解为人类喜好的满意度,输出一个token以后,模型的状态由St变为St+1,智能体就是语言模型本身,而环境就是对应着它所产生的语料。

三. RLHF在NLP下的实际运行

在这里插入图片描述

1.四个重要角色

上面第二部分提到智能体就是语言模型本身,也就是生成token的模型,但是收益 Rt 和总收益 Vt 是另外的模型,其实在RLHF-PPO阶段,主要有四个重要的模型。

1. Actor Model:演员模型 也是我们要训练的主要模型

训练的最终目的就是让Actor模型产生符合人类喜好的response,所以我们的策略是先喂给Actor一条prompt,让它生成对应的response。然后将prompt和response都送到我们的奖励-loss中计算最后的loss,用于更新actor模型,这个模型一般就是用SFT阶段产出的SFT模型来对它做初始化。

2. Critic Model:评论家模型 作用是预估总收益 Vt

① 和上面的Actor Model模型一样,需要在训练的过程中做参数更新,在实际实践中, Critic Model 的设计和初始化方法有很多种,例如和actor共享部分参数,或者从RW 阶段的Reward Model初始化出来,下面的过程中,我们和deepspeed-chat(一个RLHF开源的项目)的实现保持一致,从RW阶段的Reward Model 初始化而来。
② RW 阶段(Reward Weighting / Rewarded Warmup / Reward Modeling 之后的阶段)通常指的是:在完成 Reward Model(RM)训练之后,用奖励信号对 Actor 进行“带约束的优化”的阶段,是通往 PPO/RLHF 的过渡阶段。
③为什么要多出这样的一个评论家模型呢?在我们上文中讨论总收益的时候,其实是算上了后续的收益,但是在训练的时候,我们的真实情况是无法获得总收益的,我们只能训练一个模型去预测它。也就是说在RLHF中,我们不仅要训练模型生成符合人类喜好内容的能力,同时也要提升模型对人类喜好量化判断的能力(Critic)。从整体上看,Reward/Critic模型 与Actor模型的架构是很相似的,同时,它在最后一层增加了一个ValueHead层,该层是一个简单的线性层,用于将原始输出结果映射成单一的 Vt 值,表示对t时刻以及未来的收益预估。

3. Reward Model:奖励模型 作用是计算即时收益 Rt

Reward Model用于计算生成token At 的即时收益,它就是RW阶段所训练的奖励模型,在RLHF过程中,它的参数是冻结的。它是代表着人类对已有的prompt和response的固定偏好,所以不能动,在RLHF中如果参数变化的话,就代表着这个评估标准是一直在变化的。

4. Reference Model:参考模型 作用是在RLHF阶段给语言模型增加一些“约束”,防止语言模型训练过程中出现问题。

① 和Actor模型一样,也是用的SFT阶段的模型来初始化,在训练的过程中参数是完全冻结的,那么它是通过什么样的方式防止语言模型在训练过程中偏离方向的呢?换句话说训练出来的actor模型既能符合人类喜好的目的,也尽量和原始模型不要偏离太大,也就是说两个模型的输出分布尽量相似,利用KL散度。
② 具体过程是这样的,对应Actor模型输出的response中的每一个token都有一个log_prob ,把总体结果记为log_probs,而我们把Actor模型同样的prompt和response喂给Ref模型,产生的token也会有一个ref_log_prob,把总体结果记为ref_log_probs,那么两者的输出分布相似度就可以用ref_log_probs-log_probs来衡量,当两者比较接近的时候,Ref 也认可 Actor 的输出分布,说明Actor 没有明显偏离 Ref。

2.一个通俗易懂的例子

目标设定:现在你不是一个会很说话的人,也就是说你是有一点低情商的,现在要训练你成为一个高情商的人,这里面的评价者是你的领导,那么在这个场景中 critic模型就是一个正在根据你的回答来不断调整到领导偏好的长期效果模拟器,而以前的你就是参考模型。如果对于上面的例子有什么不懂的,我觉得通过这个例子都可以明白过来,比如评价者模型的参数和参考模型的参数是不能动的。

四. RLHF中的loss计算

因为主要是有两个模型在变化,那么对应的也就是只有两个模型会有loss ,分别对应着Actor loss和Critic loss。

1. Actor Loss

(1)初始设计

①针对这个Actor模型,我们训练要解决的问题不是“模型说的对不对”,而是模型跟随人类的语言想法,容易说出哪些token,少说哪些token,所以本质上是在调整Actor模型输出的概率,也就是调整Actor接收到当前的上文 St ,产生token At 的概率,P( At | St )。
②为了引导Actor模型输出的token事后证明是好还是不好,我们需要引入一个变量来告诉模型 这一步的结果从最终的结果来看是不是值得,就是Critic 给出的价值估计 Vt
③那么接下来就是如果这一步带来了更好的结果,就应该放大这种概率,相反,如果带来了坏的结果就应该压低概率。那么
很显然 actor_loss= −Vt log P( At | St )
④这个公式很直观的解释就是 当 Vt >0 的时候,意味着Critic模型对Actor模型当前采取的行动给出了正向反馈,因此就需要在训练迭代中提高 P( At | St ),这样就可以达到减小loss的作用,当 Vt <0 的时候,意味着对Actor当前的行动给出了负向反馈,因为要降低P( At | St ),这样就能达到减小lss的作用,可以很好的解释为什么有负号。

(2)引入优势(Advantage)

①针对上面的问题是 对于好结果本身而言,并不一定就说明你的这一步就走的好,就比如说在上面的例子中,最后如果提高你的情商到领导想要的效果,但是并不代表你说的每一句话都是对的。初始用 Vt 训练 Actor 的问题在于:它把“最终结果好”错误地等价成了“中间每一步都值得被奖励”。但现实中,很多行为只是“没有拖后腿”,而不是“真正促成了好结果“。引入优势的目的就是为了纠正这种平均主义。
②为此,引入基线(baseline)
Vt 此时不再是奖励,而是在这种情况下,本来大概就会有的结果,也就是说作为一种基线,那么接下来就是只奖励超出基线的部分, 在这里再重申一下,如果Critic对At 的总收益预测为Vt,但是实际执行At后的总收益为 Rt+γ*Vt+1

那么 我们就可以定义优势为 AdVt= Rt+ γ*Vt+1 -Vt,将 AdVt替换掉Vt,那么 actor_loss = -AdVt log P( At | St )。

(3)重新设计 Rt

在上面的理解中Rt应该表示每个Actor产出token At带来的即时收益,在实际工程deepspeed-chat中的RLHF实践中,对Rt做了另外一种设计:
在这里插入图片描述
首先是前面的常量,kl_ctl可以理解为一个控制比例的缩放因子,因为后面的log对数其实是第三部分介绍的ref_log_probs - log_probs也就是Actor和Ref模型之间的KL散度,所以前面kl_ctl 其实就是KL control的意思,deepspeed-chat中默认设置为0.1, 然后就是T表示是最后一个token输出。
对于上面的公式的理解 可以说是在t≠T时,我们更加关心的是Actor是否有在Ref的约束下生产token At, 而当t=T 时,我们不仅关心Actor是否遵守了Ref的约束,也关心真正的即时收益Rt。为什么只有最后一刻的Rt被纳入考量了呢?那是因为在每一个任务中,只有当Actor最终给出完整的回答的时候,才会给出奖励,在输出中间是不需要考虑的。
当然,Rt的设计也不是只有这一种,在deepseed-chat中的代码注释中也有提到过,可以尝试把最后一个时刻的Rt替换成所有token的即时奖励的平均值,如果在这个角度考虑的话,我们同样也可以在在每一个位置的奖励衡量上引入Rt
代码实践如下:

def compute_rewards(self, prompts, log_probs, ref_log_probs, reward_score, action_mask):
         """
         reward_function:计算最终的reward分数
         复习一下几个相关参数默认值: self.kl_ctl=0.1,   self.clip_reward_value = 5 
         self.clip_reward_value 这个参数的作用是给reward设置一个上限和下限,防止极端reward把PPO的训练直接冲爆。
         reward > 5 → 当作 5,reward < -5 → 当作 -5,中间不变。
         对于batch中的某个prompt来说,它最终的reward分数为:
         (1)先计算actor和ref的logit相似度,self.kl_ctl * (ref_log_probs - log_probs)
            这个值越大,说明ref_model对actor生成的结果的认可度越高(即表明rlhf没有训歪),
            没有训歪的情况下我们也应该给模型一些奖励,这个奖励就是self.kl_ctl * (ref_log_probs - log_probs)2)由于我们只取最后一个token对应位置的分数作为reward_score,因此我们只需要:
               self.kl_ctl * (ref_log_probs - log_probs)的最后一位 + reward_score
         (3)同时我们对reward_score也做了大小限制,最大不超过self.clip_reward_value(超过统一给成self.clip_reward_value),
             最小不低于-self.clip_reward_value(低于统一给成-self.clip_reward_value)
          (4) 最后返回的rewards大小为:(batch_size, 各条数据的长度),对batch中的每条数据来说:
             - response的最后一位:self.kl_ctl * (ref_log_probs - log_probs)的最后一位 + reward_score
             - response的其余位置:self.kl_ctl * (ref_log_probs - log_probs)
             
         """
         kl_divergence_estimate = -self.kl_ctl * (log_probs - ref_log_probs)
         rewards = kl_divergence_estimate
         start = prompts.shape[1] - 1
         """
         解释一下: start = prompts.shape[1] - 1,在 LLM 中:第一个 response token 的 log_prob,是在 “最后一个 prompt token 的位置” 预测出来的。response 的第一个“动作位置”,
        对应的是 prompt 最后一个 token 的 index。   prompts 的 shape 通常是:(batch_size, prompt_length)。因为我们对prompt做过     padding处理,因此batch中每个prompt长度一致,也就意味着每个response开始的位置一致)。(所以这里start是不加s的,只是一个int)。

         """
        ends = start + action_mask[:, start:].sum(1) + 1
         """
         解释一下: ends = start + action_mask[:, start:].sum(1) + 1, 
         action_mask[:, start:].sum(1) action_mask 通常是一个 0/1 mask:1:这是一个有效的 response token ,0:padding / 无效位置, start表示只看 response 区间(从 start 之后)
         因为:prompt 部分不参与 PPO 学习,只对 response token 计算 reward / loss,sum(1)这是在算:每个样本的 response 实际长度因为:每一行的 1 的个数就是该样本 response token 的数量,+ 1:让 end 成为 “右开区间”
         """
         reward_clip =torch.clamp(rewadr_score,-self.clip_reward_value,self.clip_reward_value)
         """
         对reward_clip做裁剪,reward model 或人工奖励出现“极端值”,把 PPO 的梯度直接放大到失控。
         """
         batch_size =log_probs.shape[0]
         for j in range(batch_size):
            rewards[j,start:ends[j][-1] += reward_clip[j]
         return rewards
         """
         对第j个样本,start:ends[j] :该样本的response token区间,这段代码的意思是取出第j个样本模型真正生成的那一段token的reward
         [-1]是取出response区间的最后一个token 
         += reward_clip[j]表示的是把句子级的reward加到response最后一个token上边。
         意思就是对batch里面的每一个样本,只在它生成答案的最后一个token上加上经过裁剪的最终任务奖励,其余token只承担KL偏离的惩罚。
         """
         
(4)重新设计优势

在前文当中,我们已经将Actor的学习信号从状态价值Vt 改为优势函数(Advantage),从而避免将最终的成功错误地归因在中间的每一个动作上面,在最简单的形式下,优势函数可以用一步时序差分(TD error)来近似:
AdVt= Rt+ γ*Vt+1 -Vt
该形式直观的刻画了当前动作是否使未来价值高于原本价值。然而,仅仅只是使用一步TD error 仍然存在一个明显问题:估计具有很大的方差,并且对单步奖励和价值预测误差高度敏感,会导致在实际训练中只看一步的话,信号往往很抖,太不稳定。
在语言模型或者序列决策中,一个token的好坏,往往并不体现在它当下带来的reward上面,而体现在它是否为后续的token创造了更好的条件,如果后面的几个token都表现很好,那么回头看,当前的这个token很可能是一个好的铺垫,但是一步的advantage 只看当前时刻,并不会自动把后面做的好这件事情反馈到现在这一步上面,到这里的话,一个简单直接的想法是:当前时刻的 Advantage,不仅由当前一步的 TD 误差决定,还可以吸收一部分“下一步已经算好的 Advantage”。

AdVt = Rt + γVt+1 - Vt + γ * λ AdVt+1(GAE 广义优势估计)
对这个公式有如下解释:
Rt + γ
Vt+1 - Vt 表示的是当下的这一步,立即看起来好不好,第二项 表示的是如果后面一步表现很好,那当前这一步也可以分到一部分的功劳,其中λ 用来控制:我们到底愿意把“未来做得好”这件事,往前传递多少。
从工程实践的角度来看的话,这种递推形式的Advantage本质上是一种时间维度上的平滑,能让策略更新既不过分依赖单一步骤,也不必等待完整序列结束。
那么这个代表未来优势的AdVt+1如何计算呢,在最后的一个时刻,它的未来收益( VT+1 )和未来优势( AdVT+1 )都是0,也就是 AdVT = Rt - Vt,有了 AdVT,我们就可以从后往前利用动态规划的方法,把所有时刻的优势都依次计算出来。

代码实践如下:

    def get_advantages_and_returns (self,values,rewards,start):
    """
    没有引入GAE前的t时刻的优势值:
        detal_t = r_t + gamma * V_t+1 - V_t
        其中:
            - r_t表示t时刻的即时收益
            - V_t+1表示未来时刻的预期收益
            - r_t + gamma * V_t+1可理解成t时刻的实际预期收益
            - V_t可理解成t时刻的预估预期收益(是模型,例如critic model自己估算出来的)
   引入GAE后的t时刻的优势值:
   A_t = delta_t + gamma * lambda * A_t+1
   粗暴理解为在t时刻时,不仅考虑当下优势,还为了考虑未来的优势
   为了知道A_t,我们得知道A_t+1,所以在本算法中采取了从后往前做动态规划求解的方法,假设T时刻是最后一个时刻,则有A_T+1=0,所以有
   A_T=delta_T;   知道了A_T,就可以依次往前倒退,把A_t-1, A_t-2之类都算出来了。
   引入GAE后t时刻的实际预期收益
   returns_t = A_t+ V_t 
             = delta_t +gamma * lambda * A_t+1+V_t
             =r_t +gamma * V_t+1 -V_t +gamma* lambda *A_t+1 +V_t
             =r_t +gamma * (V_t+1 +lambda *A_t+1)
   注意,这里不管是advantages还是returns,都只算response的部分
    """
    lastgaelam =0 //表示 最后一个token 未来的advantage =0
    advantage_reversed=[]
    length = rewards.size()[-1] //取rewards这个张量最后一个维度的大小。因为rewards.shape == (batch_size, sequence_length),取的是sequence_length
    # 注意这里用了reversed,是采取从后往前倒推计算的方式,只计算response区间内的
    for t in reversed(range(start,length)):
          nextvalues =values[:,t+1] if t < length-1 else 0.0  //取的是下一个时间步的value 如果不是最后一个,用Vt+1 ,否则为0,对应的是公式中的 R<sub>t</sub>+ γ*V<sub>t+1</sub>
          delta =rewards[:,t] + self.gamma *nextvalues - values[:,t] //对应的是 delta = Rt​+γVt+1​−Vt​,当前这一步,和原本预期相比,多出来多少
          lastgaelam =delta + self.gamma * self.lam * lastgaelam //At​= δt​+ γλAt+1​
          advantages_reversed.append(lastgaelam)// 现在存的是反着的,A_T,A_T-1 ...
   advantages = torch.stack(advantages_reversed[::-1],dim=1) //反过来
   returns =advantages +values[:,start]  //目标价值 = 当前预期 + 额外修正
   return advantages.detach(), returns //advantages:用来更新 Actor,returns:用来更新 Critic
  
    
(5)PPO-epoch: 引入新约束

1.梳理一下RLHF-PPO过程
① 准备一个batch的prompts
② 把这个batch的prompts喂给Actor模型,生成对应的responses
③ 把prompt+responses喂给Critic/Reward/Reference模型,让它生成用于计算actor/critic loss的数据,按照强化学习的术语,我们称这些数据为经验。
④实际计算actor/critic loss,用于更新两个模型

在梳理完四个基本步骤的时候,我们发现一个现实,但是又绕不开的一个问题:经验数据的获取成本极高,在传统的强化学习中,采集一批交互数据往往已经是整个训练流程中最耗时的部分,但是在RLHF的场景下,这一问题又被进一步放大——一次完整的经验采集,通常需要依次调用Actor回答问题,Reference Model给出对数概率,RM给出打分以及CriticModel给出估计价值函数,为了得到一个batch的训练数据,往往需要多个模型协同完成多次推理,整体开销十分昂贵。
那么既然这批经验来之不易,能否在不重新采样的情况下,对同一批数据进行多次优化,换句话说,我们是否可以用同一个batch的经验,重复计算loss,并且对模型进行多轮更新,从而更充分地榨取这些数据的价值,这正是PPO中ppo-epoch引入的直接动机。
在上文中说,引入ppo-epoch的动机,从本质上来说是为了更加充分地利用昂贵地经验数据,也就是说,我们希望同一个batch的经验,能够被用来更新模型多次,而不是只用一次就扔掉。很自然地,就可以想到Actor训练地两种方式:
①一个是旧的Actor,真实的与环境进行交互,产生了这批经验
②另一个是当前正在更新的Actor,它在ppo-epoch中反复利用这批数据来更新自己。
需要注意的是,这种不算是真正意义上的与外界环境的交互,而是模拟交互,因为新的交互必然是新的环境。
在ppo-epoch的过程中,我们不再要求当前的Actor取探索新的行为,而是希望它可以在学习得更好的同时,不要偏离当初生成这些数据的那个Actor太远,只要它的行为分布仍然与旧的Actor足够相似,那么用旧数据反复训练,就是合理且稳定的。
也就是说当前Actor在利用旧的batch进行更新的时候,不仅要朝着优势更大的方向走,而且要收到额外的新的约束,就是它在这些样本上面的行为概率,不能和旧的Actor相差太多。这样一来,PPO-epoch中的每一次更新,本质上就变成了在旧的Actor行为分布的附近,做一次受控的小更新。
其实为了实现这个控制,也就是说我们希望两个分布不要差的太远,很自然的用到了KL散度,只不过在实际的工程中,这种约束并不是直接以KL散度的形式出现,而是通过新旧策略概率比值来表达的。直观的说,如果当前Actor在某个token上给出的概率,与旧Actor给出的概率相近,那么这个比值就接近1,如果差的太远,这个比值就会明显偏大或者偏小,这正好为我们提供了一个可操作的信号,来判断当前更新是否偏离的太远。
为了谨慎起见,如果这个约束本身不够强,当前的Actor确实走得太远了怎么办呢?就直接剪掉!我们可以人为的规定一个允许变化的范围,比如在(0.8,1.2)一旦新旧策略的比值超过这个区间,我们就强制把它截断在边界上。这样一来,在超过约束范围的情况下,Actor的更新幅度不再继续增大,等价于告诉模型,这一方向的优化个更新到此为止,不再继续优化。

//初始化RLHF的四个模型
actor,critic,reward,ref=initialize_models()
//训练
//对于每一个batch的数据
for i in steps:
   //收取每一个epoch最开始的经验值
   exps = generate_experience(prompts,actor,critic,reward,ref)
   //一个batch的经验值被用于计算ppo-epochs次loss,更新ppo_epochs次模型更新
   //这也就意味着,当你计算一次loss,你用的是更新后的模型
   for j in ppo-epochs:
     actor_loss =cal_actor_loss(exps,actor)
     critic_loss = cal_critic_loss(exps,critic)
     
     actor.backward(actor_loss)
     actor.step()
     
     critc.backward(critic_loss)
        critic.step()

2. Critic Loss

1. 在一个batch的经验中,除了要更新Actor,这些经验同样会被用来更新Critic。和Actor不同的是,Critic的目标比较单一:尽量准确地预测到这一步开始,未来能拿到多少总的回报,因此我们不再像Actor那样一步步推导loss,而是直接从它“最终想要干什么”出发来理解。

在上面的讨论中,我们已经有一个直觉上的区分就是:Critic给出的值更多的是当前总的收益的预测,它本质上是一种“预估收益”,而Reward和未来折现回到给出的值,更接近真实情况,可以理解为“实际收益”。

2. 但是在PPO中,我们还需要额外一个现实问题就是:一个batch数据会被反复用在多个ppo-epoch里面,而Critic在这个过程中是不断更新的,如果不加限制,Critic可能会在同一批数据上来回震荡,甚至预测值过大,从而影响整体稳定性。

因此,这里的设计思路和Actor是一致的:用真正吃到的batch的那个Critic作为锚点,约束后续更新的Critic不要偏离太远。具体的做法也是很简单的,我们用旧的Critic在当时计算出来的value作为参考,给它设定一个允许的变动区间,只要新的critic的预测超过了这个范围,就直接截断。

values_clipped = torch.clamp(
  values,
  old_values-self.cliprange_value,
  old_values+self.cliprange_value

接下来,loss的定义就非常的清晰,我们分别计算两种情况下的误差,不加约束的时候,新的Critic和实际收益之间的误差以及加上截断约束之后,新的Critic和实际收益之间的误差,然后取两者之中更保守的那个作为最终的loss:

vf_loss1 = (values - returns)**2
vf_loss2 = (values_clipped - returns)**2
vf_loss = 0.5 * torch.sum(torch.max(vf_loss1, vf_loss2) * mask) / mask.sum()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值