首页 / 资讯中心 / 文章详情

从策略梯度到PPO:用PaddlePaddle与PARL实现稳定强化学习

从策略梯度到PPO:用PaddlePaddle与PARL实现稳定强化学习 ★ FEATURED ARTICLE
强化学习这两年几乎成了算法岗的必备技能但如果只追新不看底你会发现真正被广泛验证的往往是老方法。策略梯度PG是强化学习里最基础也最直接的一类方法PPO则是它在工程上的集大成者。这篇内容我打算用PaddlePaddle和PARL把从PG到PPO的原理、代码、训练调试完整走一遍帮助你不仅会用PPO更知道它为什么长这样。适合已经了解强化学习基本概念、想亲手跑通算法并且理解背后调参逻辑的朋友我会尽量把公式用大白话讲清楚代码也会给到可以直接改着跑的程度。1. 先把底子铺好策略梯度PG到底在优化什么1.1 强化学习里的三个关键角色与一条奖励链先统一一下术语。强化学习里有一个智能体Agent它观察环境给出的状态State做出动作Action环境返回下一个状态和奖励Reward然后这个循环一直进行下去。整个过程会形成一条轨迹s0, a0, r1, s1, a1, r2, s2, ...智能体要做的事情就是找到一个策略 π(a|s)让这条轨迹上的累积奖励期望尽量大。注意是“累积”不是单步。好比教小孩做作业今天认真写完可能不会马上有反馈但一周后成绩提升带来的奖励才是真正的回报所以必须把将来的奖励也考虑进来。于是就有了折扣回报 G_t r_t γ r_{t1} γ² r_{t2} ...。γ 在0到1之间它表明“未来的奖励要折算到今天来看”γ 越接近1策略就越有耐心愿意为了远期收益牺牲眼前利益γ 太小智能体就会变得特别短视。这里引出第一个容易混淆的点策略梯度的目标函数不是单步奖励而是整个轨迹的期望折扣回报。策略参数 θ 的每一次更新其实都是在调整“状态→动作”这个映射的概率分布。你要让高回报轨迹里出现的动作被选中概率变大让低回报轨迹里的动作概率变小这就是策略梯度最朴素的想法。1.2 策略梯度的目标函数与梯度推导设策略为 πθ一条轨迹 τ 的概率可以写成P(τ|θ) ρ(s0) · ∏ P(s_{t1}|s_t, a_t) · πθ(a_t|s_t)这里有个麻烦轨迹概率同时依赖于环境动态 P(s_{t1}|s_t, a_t) 和策略 πθ。我们无法直接对“环境概率”求导因为环境不受我们控制。但是可以用一个数学技巧把梯度转换成只依赖策略的形式。目标函数 J(θ) E_{τ~πθ}[G(τ)]对它求梯度∇θ J(θ) E_{τ~πθ} [ G(τ) · ∇θ log P(τ|θ) ]其中 P(τ|θ) 里含有环境转移概率的部分在求 log 导后是零所以只剩下策略部分∇θ J(θ) E_{τ~πθ} [ G(τ) · Σ_t ∇θ log πθ(a_t|s_t) ]这个式子看起来很数学但解释起来特别直白每条轨迹的“回报 G(τ)”相当于一个权重梯度会让这条轨迹上所有动作概率朝着“增大对数概率”的方向移动。高回报轨迹上的动作被推上去低回报轨迹上的动作被压下来。道理和“涨工资的人更愿意留在公司被扣钱的人想走”差不多。实际实现中我们一般把 G(τ) 替换成动作优势函数 A(s,a)也就是“这个动作比当前状态的平均表现好多少”。如果只用轨迹总回报就叫 REINFORCE它是策略梯度最原始的样子。REINFORCE 的实现非常简单采样一批轨迹算每个轨迹的总回报然后拿 ∇log πθ(a_t|s_t) 乘以回报再取梯度下降。但你实际跑一下就会发现REINFORCE 极其不稳定。原因是单条轨迹的回报方差太大一次运气好就是高分一次运气差就是低分策略参数被这些随机波动来回拉扯于是有了接下来要讲的改进方向。2. 从PG到PPO中间踩过的坑与两次关键设计2.1 为什么PG训练不稳定采样、方差与“步子迈太大”策略梯度最大的痛点是 on-policy 属性用来更新的数据必须来自当前策略数据用完就得扔掉。这意味着每条轨迹只被利用一次样本效率低到令人发指。为了降低方差你可能要并行跑几十条轨迹取平均但就算这样轨迹之间的方差还是很大。另外PG 没有告诉你怎么设置学习率。学习率太大一次更新就可能把策略推向一个完全不同的分布下一次采样的数据质量急剧下降然后梯度跟着错整个训练直接崩掉。学习率太小策略优化慢得像蜗牛。这个“步子大小”问题在强化学习里比监督学习严重得多因为数据分布会随着策略变化而变化一步错就可能步步错。你可以想象一个盲人走独木桥每走一步之前都想调整方向但如果这一步挪得太多直接掉下去连重新调整的机会都没有。策略梯度缺的就是一种“能够大胆往前迈但又不会一步迈过头”的机制。于是研究者开始思考能不能让新旧策略保持足够接近同时又能重复利用旧数据2.2 重要性采样与信任区域TRPO为什么又稳又难想重复利用旧数据最自然的方法是重要性采样。假设数据是从旧策略 π_old 采样出来的要估计新策略 πθ 下的期望可以乘一个比值修正E_{a~πθ}[f(a)] E_{a~π_old}[ f(a) · πθ(a|s) / π_old(a|s) ]这个比值 r(θ) πθ(a|s) / π_old(a|s) 就是 PPO 里反复出现的 ratio。把它代入策略梯度目标函数得到L(θ) E_{a~π_old} [ r(θ) · A(s,a) ]理论上是没问题的但直接优化这个目标会再次踩到“步子太大”的坑只要策略更新一点点比值就会偏离1很远优化目标被带偏。TRPOTrust Region Policy Optimization的解决方案是把它写成一个带约束的优化问题maximize E[ r(θ) · A(s,a) ] subject to KL(π_old || πθ) ≤ δ这里的约束就是我们常说的“信任区域”意思是你允许策略在 KL 散度不超过 δ 的范围内自由变化把一次更新限制在一个可信的邻域内。理论上很漂亮实际却难用你需要用共轭梯度法解一个二次近似还要做线性搜索一大堆工程细节让普通研究者望而却步。TRPO 论文效果很好但代码实现和维护成本太高这给 PPO 留出了出场空间。2.3 PPO的两次出手KL惩罚版与Clip版PPO 的出发点就是能不能用更简单的数学形式达到类似 TRPO 的约束效果。原论文给了两种方案。第一种是 KL 惩罚版直接在目标函数里减一项 β·KL(π_old || πθ)把硬约束变成软惩罚。策略跨得远一点惩罚就大一点。这个方案需要动态调整 β工程上比较繁琐。第二种就是大家熟知的 Clip 版本。它不去算 KL而是把比值 r(θ) 直接限制在一个区间里。目标函数长这样L_clip(θ) E [ min(r(θ) · A, clip(r(θ), 1−ε, 1ε) · A) ]这里的 ε 通常取0.2。你可以这样理解ratio 指望通过更新变大变小但 clip 给它的变化幅度上了一个保险。对于优势为正的高回报动作我们希望 ratio 增大来提升动作概率但一旦 ratio 超过 1εclip 后的值就不再增长损失不会再下降相当于“不许过度兴奋”。对于优势为负的低回报动作我们希望 ratio 变小但如果 ratio 掉到 1−ε 以下同样被截住避免因为单次噪声就把动作概率压到消失。min 的作用就是在两个候选值里选更保守的那个让策略始终待在一个安全范围内。这个设计的精妙之处在于不需要复杂的约束求解只在损失函数上做了一层保护更新就能稳定下来。所以 PPO 这两年开始几乎成了强化学习里的默认 baseline倒不是说它每次都是最强的而是它足够稳、足够好复现。3. 用PaddlePaddlePARL动手实现PPO3.1 PARL和PaddlePaddle能帮你省掉哪些事PARL 是飞桨生态里的强化学习框架它把算法经常复用的部分拆成了三层Model 只管定义网络结构Algorithm 负责计算 loss 和更新参数Agent 负责和环境交互、收集数据。这样分层最大的好处是你换任务时只需要改 Model换算法时只需要换 AlgorithmAgent 通常不用大动。安装很简单正常情况两条命令pip install paddlepaddle pip install parl如果你有 GPU 机器把第一条换成对应 CUDA 版本的 paddlepaddle-gpu 即可。我建议第一次跑代码不要急着上复杂环境先用 CPU 跑小环境完全够用。3.2 策略网络与价值网络的实现先看一个最简单的离散动作版本。假设输入是观测向量输出是每个离散动作的 logits 和一个标量价值。PARL 里的 Model 大致这样写import parl import paddle import paddle.nn as nn import paddle.nn.functional as F class PolicyValueModel(parl.Model): def __init__(self, obs_dim, act_dim): super(PolicyValueModel, self).__init__() self.fc1 nn.Linear(obs_dim, 128) self.fc2 nn.Linear(128, 128) self.fc_pi nn.Linear(128, act_dim) self.fc_v nn.Linear(128, 1) def policy(self, obs): x F.relu(self.fc1(obs)) x F.relu(self.fc2(x)) logits self.fc_pi(x) return logits def value(self, obs): x F.relu(self.fc1(obs)) x F.relu(self.fc2(x)) v self.fc_v(x) return v注意这里价值输出是一个数值不是和动作数一样多的向量。价值网络估计的是状态自身的期望回报 V(s)它和动作无关。很多人第一次写容易把价值头也输出成 act_dim 维这是错的。策略头输出的是动作维度的 logits价值头输出的是1维标量。如果做连续动作控制策略头通常输出动作均值再加上一个可学习的 log_std然后用高斯分布采样动作。模型结构没变变的只是策略头的输出解释方式。3.3 核心训练循环compute_loss到底在算什么真正体现 PPO 和 PG 差异的地方在 loss 计算。我先把最原始的策略梯度 loss 写出来对照一下def compute_pg_loss(log_prob, reward): # reward 是整个轨迹的折扣回报或者事后算出的优势 loss -paddle.mean(log_prob * reward) return lossPG 只要一句“高回报动作的对数概率乘权重取负均值”就行。PPO 则复杂得多需要先算即时优势再做 GAE 平滑最后套 clip。GAE 的全称是 Generalized Advantage Estimation中文通常叫广义优势估计。优势 A(s,a) 表示“在这个状态下执行这个动作比执行平均水平的动作好多少”。严格定义是 A Q − VQ 是动作价值V 是状态价值。GAE 提供的是一个可调的折中方案用 λ 来控制偏差和方差的平衡。GAE 的计算是从轨迹尾部往前递推。先算每个时间步的 TD 误差δ_t r_t γ · V(s_{t1}) − V(s_t)然后累加A_t δ_t γ·λ · A_{t1}当 λ0 时A_t 就是一步 TD 误差偏差大方差小当 λ1 时相当于蒙特卡洛累计优势方差大偏差小。PPO 通常在 λ0.95 附近。在 PARL 的 Algorithm 里我一般写成这样def compute_loss(self, obs, action, reward, next_obs, terminal, old_log_prob): # 当前策略的 log 概率 logits self.model.policy(obs) log_probs_all F.log_softmax(logits, axis-1) log_probs log_probs_all.gather(indexaction, axis-1) # 新旧策略概率比值 ratio paddle.exp(log_probs - old_log_prob) # 当前价值与下一状态价值 value self.model.value(obs) next_value self.model.value(next_obs) # TD 误差 td_error reward (1.0 - terminal) * self.gamma * next_value - value # 从后往前递推 GAE advantage paddle.zeros_like(td_error) gae paddle.zeros([obs.shape[0]]) for i in reversed(range(obs.shape[0])): gae td_error[i] (1.0 - terminal[i]) * self.gamma * self.lambda_ * gae advantage[i] gae # PPO clip 目标 surr1 ratio * advantage clipped_ratio paddle.clip(ratio, 1.0 - self.clip_param, 1.0 self.clip_param) surr2 clipped_ratio * advantage policy_loss -paddle.mean(paddle.minimum(surr1, surr2)) # 价值损失 target_value reward (1.0 - terminal) * self.gamma * next_value value_loss paddle.mean(F.mse_loss(value, target_value)) # 熵正则鼓励探索 entropy -paddle.mean(F.softmax(logits, axis-1) * log_probs_all) loss policy_loss self.value_coef * value_loss - self.entropy_coef * entropy return loss代码里的 old_log_prob 是采集数据时用当时的策略计算并保存下来的。这意味着经验池里不只要存 obs、action、reward、next_obs、terminal还要存一份旧策略下的对数概率。这是 PPO 能重复利用旧数据的关键。插入一个容易犯的错误很多人把 old_log_prob 存成了当前策略重新算出来的值这样 ratio 恒等于1clip 完全没有意义整个 PPO 退化成了带噪声的普通策略梯度训练自然会出问题。每次更新前必须从数据里取的是“采样时记录下来的概率”不是“现在的概率”。3.4 训练主循环与超参模板训练主循环是标准的 采样 → 算优势 → 更新 → 再采样。用 PARL 的 Agent 封装后伪代码大致是agent PPOAgent(model) for episode in range(max_episodes): # 采样一条轨迹记录 obs, action, reward, next_obs, terminal, old_log_prob trajectory collect_one_episode(agent, env) # 用采样时的旧模型算 old_log_prob实际上 sample 时已经算好 # 更新一次 agent.learn(trajectory)在正式工程里一般不会一个 episode 就更新一次而是攒一批样本比如几千条再做若干次 mini-batch 更新。这样能减少更新次数带来的波动。PARL 官方实现里还会做 advantage 标准化把计算出的 advantage 减去均值除以标准差。这一步非常重要它能让不同量级的奖励环境共用一套学习率后面我在第4节会细说。这里给一套经过验证的起始超参适用大多数中小规模控制任务参数建议值说明clip_param0.2PPO论文默认先不要动gamma0.99折扣因子lambda0.95GAE的偏差方差折中value_coef1.0价值损失权重entropy_coef0.01熵正则权重太小容易过早收敛learning_rate3e-4AdamPPO常用区间 1e-4 到 3e-4采样批量2048 或 4096每次更新前采集的样本数mini-batch64 或 256更新时切的小批量更新轮数3 到 10每个采样批量对应的梯度更新次数判断是否收敛不能只看单次奖励曲线要多跑几次随机种子取中位数。因为强化学习本身方差大一条曲线说明不了问题。如果你看到奖励在上升且波动范围逐步收窄通常就说明策略在变好。4. 训练实录从PG原地踏步到PPO稳步收敛我踩过的坑4.1 典型问题速查表我在实际跑代码的过程中把最常遇到的问题整理成了下面这张表基本覆盖了新手从 PG 转 PPO 时遇到的绝大多数情况。现象可能原因处理方式训练很久奖励纹丝不动学习率太小或奖励本身太稀疏先调大学习率到1e-3试试再检查奖励设计奖励曲线先升后崩更新太激进clip太小或学习率太大降低学习率减少更新轮数检查ratio分布loss 曲线和 reward 曲线对不上记录的是每个minibatch的loss波动大改为记录每个采样批次的平均lossvalue loss 越来越大奖励量级太大没有归一化对奖励做scale或对advantage做标准化连续动作环境下动作始终在边界探索噪声太少或熵系数过低提高熵系数检查动作是否被tanh截断ratio 分布集中在1附近old_log_prob存错或完全没更新检查经验池数据和日志不同随机种子效果差距巨大强化学习方差大属于正常现象多跑5个种子用中位曲线评估4.2 三个让我印象最深的坑第一个坑clip 参数形同虚设。我一开始觉得 clip 越小越稳把它一路调到0.05结果策略根本不学习。后来观察 ratio 的分布才发现由于学习率设置得太小ratio 几乎全部集中在1附近clip 根本没有触发整个目标函数退化为普通策略梯度。正确的做法是先保证学习率足够让策略动起来再看 ratio 分布如果 ratio 超过 1.2 的样本比例很高才说明更新太猛需要下调 clip 或学习率。0.2 是个非常好的起点不是越低越好。第二个坑GAE 的 lambda 调错方向。我在某个连续控制任务上把 lambda 从 0.95 改到 0.99想着更高应该更准确结果奖励曲线开始剧烈震荡。原因是用蒙特卡洛式优势会把轨迹的随机性完整保留下来方差大幅上升。lambda 接近1并不是“更好”只是更接近真实期望但更不稳定。如果你的环境本身噪声很大lambda 反而要下调到 0.9 附近用一点偏差换大幅的方差降低。第三个坑不归一化优势。在奖励尺度比较小的环境里advantage 值可能只有0.01量级value_loss 看起来很小但策略梯度也会跟着非常微弱更新缓慢。在奖励尺度大的环境里advantage 值几百上千value_loss 爆炸策略更新又过于激进。我后来养成的习惯是算完 GAE 后一定要对 advantage 做标准化也就是减去均值后除以标准差。这个操作特别便宜但对稳定性的提升非常明显PARL 官方示例里也几乎都带了这一步。4.3 一套适合新手起步的调试顺序从零开始调一个 PPO 任务我建议按这个顺序来别一上来就乱调超参。第一步先让代码能跑通。用官方示例里的经典环境什么都不改确认曲线符合预期。这能排除绝大部分框架和 API 使用问题。第二步换到自己的环境后先别管收敛效果跑 20 个 episode 看一下 obs 的数值范围、奖励的数值范围。如果奖励量级超过 100先做 reward scaling也就是乘一个小系数或者直接对 advantage 做标准化。这一步解决的是数值稳定性问题。第三步把注意力放在曲线形状上。如果曲线整体上升但波动大优先降低学习率如果曲线完全不动先确认熵值是不是已经掉到0附近如果曲线先升后崩优先减少更新轮数再看 ratio 分布。我自己总结的经验是大多数训练失败都可以在“学习率”、“更新轮数”、“熵惩罚”这三项里找到原因。第四步当曲线已经能稳定上升时再考虑调 clip_param 和 lambda 这种偏理论向的参数它们对最终性能的影响通常不如前三项那么直接。5. 还需要知道的事从PPO继续往哪走5.1 PPO 仍然需要面对的短板PPO 虽强但并不是银弹。它是 on-policy 算法数据用完就扔所以样本效率明显低于 off-policy 方法。在真实机器人或者实际业务场景里采样一次数据可能要付出很大代价PPO 动辄几百万帧样本才能收敛这就很尴尬了。另外它对超参数还是有敏感性的只是相对策略梯度而言宽容很多。如果任务本身动作空间特别复杂比如高维连续控制PPO 在和 SAC、TD3 这类 off-policy 算法对比时经常吃亏。很多时候我们选择 PPO 不是因为它在某个具体任务上一定最强而是因为它稳定、可复现、出了问题好排查这些特性在工程里往往比极致性能更重要。5.2 进阶方向并行采样、奖励塑形与熵正则调整如果你已经把单环境的 PPO 跑通下一步可以做三件事。第一件是并行采样。PPO 作为 on-policy 算法想要降低方差最直接的方法就是多开几个环境同时采集数据然后再一起做更新。PARL 对并行采样有不错的支持可以减少训练时间。第二件是奖励塑形。同样的 PPO 实现换个奖励函数可能从完全学不会变成快速收敛。我一般会看环境给的奖励是不是太稀疏如果是就补充一些过程奖励比如“离目标更近一点给一点正奖励”但要注意别让智能体钻了奖励函数的空子。第三件是熵正则的动态调整。固定 entropy_coef 在复杂任务上容易出问题训练后期策略几乎确定下来熵会掉到很低这时候应该把熵系数降下来防止策略一直无法聚焦到最优动作附近。你可以在日志里同时画出 entropy 曲线当 entropy 快速下降到接近0时就要小心了。5.3 我个人在实际操作中的体会如果让我只留一条调参建议那就是训练时不要只盯着 reward 曲线把 ratio 分布、entropy、advantage 的均值方差全部打出来一起看。一次训练失败光看奖励曲线你很难判断是“策略没学好”还是“数据分布出了问题”但 ratio 分布能立刻告诉你 PPO 的保险机制有没有在工作entropy 能告诉你探索是不是已经枯竭了。我在几次调参过程中最受用的一件事就是把 advantage 标准化后loss 曲线和 reward 曲线都平稳了很多。在那之前我花了好几天调学习率效果都很差做了标准化之后同样的超参居然直接跑出了漂亮的曲线。强化学习调参很多时候不是“哪个参数最重要”而是“哪个隐藏问题没解决”。多做日志、多保存模型、多跑几个随机种子这些习惯比任何花哨的技巧都更能帮你走完从 PG 到 PPO 这条路。
阅读完成 · 觉得有帮助?
咨询建站