1. 为什么几乎所有团队最后都选了PPO
如果你最近开始接触强化学习,我敢说你大概率躲不开PPO这个名词。不管是翻OpenAI的早期博客,还是去GitHub上找机械臂、游戏AI、时序决策的项目源码,PPO几乎成了一个默认选项。很多人的入门路线图也直接写着:会用DQN,会写简单的策略梯度,然后就该学PPO了。
我第一次正经跑RL项目用的是DQN,当时在Atari小游戏上折腾了整整两周。同一个算法,同一个环境,换个随机种子结果就天差地别。后来换到连续控制任务,又试了DDPG,那更是一场灾难,动不动就发散,而且你还说不清楚到底是哪一步犯了错。直到把PPO跑通,我才第一次感觉到"这个算法是可控的"。不是说它万能,但它给了一种非常踏实的体验:训练曲线可能不那么惊艳,但很少出现完全失控的飞飞乐。
1.1 把"稳定"放在第一位的算法
PPO全称是Proximal Policy Optimization,中文一般叫近端策略优化。它要解决的核心问题其实很简单:策略梯度方法每次更新的步子不能迈太大,否则优化目标这块地就踩塌了。传统策略梯度会用学习率来控制步长,但学习率是个全局参数,碰到地形复杂的地方根本不够用。
这里就要说为什么PPO变成大流行默认了。真实项目里,我们最怕的不是收敛慢,而是发散、抖、复现不出来。PPO的设计目标恰恰是"让每次更新都老老实实待在一个信任区域附近",它用一阶优化器(比如Adam)就能做到接近TRPO的效果,实现成本低,调参难度低,还能上百万级别并行采样。这个组合太符合工程团队的需求了。
我个人的体感是:它的训练曲线通常不漂亮,像蜗牛爬,但胜在稳定。你可以把大部分精力放在环境设计、奖励塑形、网络结构上,而不是天天盯着loss去猜哪里出了问题。对一个打工人来说,这比什么都重要。
1.2 PPO、TRPO、SAC、DQN,到底选谁
入门强化学习,常被这些缩写绕晕。我用一个表格把它们放一起对照,基本就能看清楚各自的位置:
| 算法 | 类型 | 适用动作空间 | 样本效率 | 稳定性 | 上手难度 |
|---|---|---|---|---|---|
| DQN | 价值学习 | 离散 | 高 | 中 | 低 |
| DDPG | 策略+价值 | 连续 | 高 | 低 | 中 |
| TRPO | 策略梯度 | 连续/离散 | 低 | 高 | 高 |
| PPO | 策略梯度 | 连续/离散 | 低 | 高 | 低 |
| SAC | 策略+价值 | 连续 | 高 | 中 | 中 |
这里的"样本效率"指的是用同样数量的环境交互,算法能学到多少。PPO是on-policy算法,数据用完就扔,所以样本效率天然不如off-policy的SAC或DQN。但on-policy换来的是更好的稳定性和更简单的复现。如果你的环境交互一次极其昂贵,比如物理仿真特别慢、真机上做实验很危险,那应该优先考虑SAC这类off-policy方法;如果你要快速验证一个想法,或者任务本身就很容易交互,那PPO永远是省心的第一选择。
我在实际项目中遇到过很多次这样的对话:需求方问我"能不能用强化学习做这个控制",我第一个反应永远不是讨论算法细节,而是先确认交互成本和环境能否大规模并行。如果答案是可以,我就用PPO起步,跑通之后再看瓶颈是在样本效率还是模型表达力,再决定要不要替换成SAC或者加model-based的模块。这条路线几乎从来没有错过。
2. 核心目标函数:clip那句式子在算什么
网上讲PPO的教程很多,但不少都是从公式开始讲,读者看完只觉得"每个字母我都认识,合起来不知道在干嘛"。我觉得理解PPO必须先建立直觉,再回去看公式。
PPO目标函数最难啃的就是那一句带min的式子,我记得我第一次看到也是头大。这里我把每一步都拆开,用大白话+公式的方式过一遍。
2.1 一句话理解PPO在干什么
策略梯度的本质是:让好动作出现概率变大,让坏动作概率变小。但问题在于,一次采样是有噪声的。某个动作可能只是运气好拿到了高奖励,如果你让它的概率猛增,下次采样性能就会断崖式下跌。所以PPO规定了一条规矩:每次更新,策略输出概率的变化幅度必须被限制在一个小范围内。
这个限制不是用学习率软磨,而是直接在目标函数里做了"钳制"。核心公式长这样:
L^CLIP(θ) = E[ min( r(θ) * A, clip(r(θ), 1-ε, 1+ε) * A ) ]
其中r(θ)是新旧策略的概率比:r(θ) = π_θ(a|s) / π_old(a|s),A是优势函数(advantage),ε是裁剪范围,通常取0.2。
这个式子干的事情是:如果某个动作的优势是正的(比平均水平好),我们最多只能把它出现的概率提高20%;如果优势是负的(比平均水平差),最多只能把概率降低20%。超过这个范围,更新梯度就会变成0,策略就不会继续朝极端方向移动了。
2.2 为什么是概率比,而不是直接用新旧策略差
这个问题我在带新人的时候被问过无数次。直观想想,限制更新幅度,为什么不能用π_θ - π_old的差值?原因是策略网络的输出是概率分布,不同状态下动作概率的绝对数值差异可解释性很差。比如一个状态下某个动作原本概率是0.5,另一个状态下原本概率是0.01,同样增加0.1,前者的相对变化很小,后者则直接翻了11倍。
用概率比的好处是做了归一化:无论原始概率多大,我们都能用一个统一的阈值(比如0.8到1.2)来约束相对变化。这本质上和TRPO里限制新旧策略KL散度是一个思路,只是实现方式粗暴了一些:TRPO是硬性约束,PPO是软性钳制。
这里还要注意一个细节:概率比是基于同一批采样数据算出来的。PPO会把这批数据反复利用好几个epoch来更新策略,所以随着更新进行,新旧策略的差距会越拉越大,概率比也会越来越偏离1。clip在这里就是兜底机制,防止你用着旧数据一股脑把策略推到深渊里。
2.3 clip的取舍:好消息不能全信,坏消息也不能太悲观
clip式子里的min有两个分支,很多人会忽略它们的区别。我直接说结论:当优势A为正时,如果概率比超过了1+ε,梯度会被截断;当优势A为负时,如果概率比低于1-ε,梯度会被截断。换句话说,无论天上掉馅饼还是脚下踩坑,策略在一次更新里都不会被推得太远。
但这里有个有意思的不对称性:当A为正、更新方向又明显偏向提升这个动作概率时,哪怕它是个好动作,我们也不允许一步吃成胖子;当A为负、某个坏动作因为随机噪声背了锅,我们也不允许一步就把它彻底打入冷宫。经验上,PPO在正更新和负更新上的稳定效果都不错,但如果你发现某个环境里坏动作惩罚特别猛烈,可以考虑用dual-clip PPO这种变体,它在优势非常负、概率比又严重偏离时做二次钳制,能进一步防止单批坏数据把策略带崩。
网上还有实现会在clip之外额外加一个KL惩罚项,或者是先把策略更新一小步再计算KL。这些都是对PPO核心思想的加固,本质都是同一件事:用约束换稳定。理解了这个,你再去看任何PPO改进算法,基本都能一眼抓住它在改哪里。
3. 从零到能跑的PPO:PyTorch实现关键点
理论说再多,代码跑不通都没用。这部分我拿一个连续动作控制任务做例子,把PPO训练主循环的每个关键点都过一遍。也是很多初学者最容易写错的地方。
3.1 Actor-Critic网络结构怎么搭
PPO用的是Actor-Critic结构:Actor输出动作的概率分布,Critic估计状态价值。对于连续控制,Actor的常见做法是输出一个均值向量,再加上一个可学习的log标准差,构成一个高斯分布。
import torch import torch.nn as nn from torch.distributions import Normal class ActorCritic(nn.Module): def __init__(self, obs_dim, action_dim, hidden_dim=256): super().__init__() self.policy_mean = nn.Sequential( nn.Linear(obs_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, action_dim), ) self.log_std = nn.Parameter(torch.zeros(action_dim)) self.value = nn.Sequential( nn.Linear(obs_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1), ) def forward(self, obs): mean = self.policy_mean(obs) std = self.log_std.exp() return Normal(mean, std) def evaluate(self, obs, action): dist = self.forward(obs) logp = dist.log_prob(action).sum(-1) value = self.value(obs).squeeze(-1) return logp, value一个重要提醒:不要用ReLU做隐藏层,尤其是Critic。ReLU的死亡神经元问题在RL里特别严重,因为RL的数据分布是动态变化的,一个神经元可能在前期被激活得很好,后期就彻底死了。我习惯统一用Tanh,效果稳定。
3.2 采样与GAE计算:优势估计的完整链路
PPO更新依赖优势函数,而优势函数最常用的估计方式是GAE(Generalized Advantage Estimation)。GAE的本质是一个带指数衰减的多步TD误差叠加,它在偏差和方差之间做折中。先看采样循环:
for step in range(rollout_steps): with torch.no_grad(): dist = actor_critic(obs) action = dist.sample() logp = dist.log_prob(action).sum(-1) value = actor_critic.value(obs).squeeze(-1) next_obs, reward, done, _ = env.step(action.cpu().numpy()) # 这里要把 obs, action, logp, value, reward, done 都存进bufferGAE计算要用逆序遍历,把每个时间步的advantage累积起来。核心是维护一个gae = 0的变量:
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = torch.zeros_like(rewards) gae = 0 next_value = 0 for t in reversed(range(len(rewards))): if t == len(rewards) - 1: next_value = 0 # 序列结束 else: next_value = values[t + 1] * (1 - dones[t + 1]) delta = rewards[t] + gamma * next_value - values[t] gae = delta + gamma * lam * gae * (1 - dones[t]) advantages[t] = gae returns = advantages + values return advantages, returns这里有两个容易踩的坑。第一,dones要正确传入,否则序列边界处理会出错;第二,GAE算出来的是advantage,更新Critic的标签是returns = advantages + values,不是直接把advantage当回归目标。我见过不止一次有人把这两个弄混,训练出来的value function一塌糊涂。
3.3 更新主循环:minibatch、epoch、advantage归一化
采样完一批数据后,PPO不是只更新一次,而是要把这堆数据打乱后切成小批量,重复更新K个epoch。这正是它sample效率不如SAC的原因,但也正是它稳定性好的关键。你需要小心的是,更新的同时旧策略也在变,所以概率比的分母必须锁在采样时的旧策略上,否则整个目标函数的含义就变了。
for _ in range(update_epochs): indices = torch.randperm(rollout_steps) for start in range(0, rollout_steps, batch_size): mb = indices[start:start + batch_size] logp_new, value_new = actor_critic.evaluate(obs[mb], action[mb]) ratio = (logp_new - logp_old[mb]).exp() surr1 = ratio * advantages[mb] surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages[mb] policy_loss = -torch.min(surr1, surr2).mean() value_loss = F.mse_loss(value_new, returns[mb]) loss = policy_loss + 0.5 * value_loss - entropy_coef * entropy.mean() optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(actor_critic.parameters(), 0.5) optimizer.step()advantage在送入更新前,强烈建议做一个标准化:advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)。这是因为GAE的绝对值跟reward尺度强相关,不归一化的话,每个任务的超参数都对不上。
3.4 连续动作空间细节:tanh压缩与log prob修正
上面代码假设动作范围不受限,但如果你的环境动作区间是[-1, 1]这类有边界的,通常会在采样时加一个tanh压缩。这一步看着简单,但概率计算必须修正。手写起来很容易错,我会直接用PyTorch的分布变换工具:
from torch.distributions import TanhTransform, TransformedDistribution base_dist = Normal(mean, std) dist = TransformedDistribution(base_dist, TanhTransform()) def log_prob(self, action): # action是实际动作,先把它转回tanh前的值 pre_tanh = torch.atanh(action) return base_dist.log_prob(pre_tanh) - torch.log(1 - action.pow(2) + 1e-6).sum(-1)如果不用这个修正,策略梯度的方向就是错的,训练大概率会在前几百步就乱掉。这是连续控制PPO实现里最隐蔽的一个bug,我第一次踩的时候查了两天才发现是log prob少加了一个雅可比修正项。
4. 超参数调优手册:默认值与背后的逻辑
很多人跑PPO失败,不是环境设计问题,而是超参数设置不合理。这里我把一套经过大量验证的默认值列出来,再解释每个值为什么是这个量级,什么时候需要改。
4.1 一套可以直接当起点的默认值
| 超参数 | 常用默认值 | 作用 |
|---|---|---|
| clip_eps | 0.2 | 控制每次更新的步长上限 |
| actor学习率 | 3e-4 | Actor网络更新速度 |
| critic学习率 | 1e-3 或 3e-4 | Critic网络更新速度 |
| gamma | 0.99 | 奖励折扣,长时任务用0.999 |
| GAE lambda | 0.95 | 优势估计的偏差-方差折中 |
| rollout_steps | 2048 | 每轮采样的步数 |
| minibatch大小 | 64 | 每个小批次的样本数 |
| update_epochs | 3 | 每批数据被使用次数 |
| entropy_coef | 0.0 | 探索熵正则强度 |
| advantage归一化 | True | 数据中心化和缩放 |
这套值在不少经典环境上都能跑出不错的效果。注意actor和critic学习率通常是解耦的,如果你发现critic学得太快、value loss变小但策略没进步,把critic学习率降下去会有效。
4.2 这些默认值为什么长这样
先说clip_eps=0.2。这个值约等于TRPO里常见的KL散度上限0.01对应的策略偏移量,是一个经验上"安全又足够大"的范围。太小(比如0.05)会让策略更新像蚂蚁爬,训练很慢;太大(比如0.5)又会让clip形同虚设,退化成普通策略梯度。
学习率3e-4这个数字也很有意思。在监督学习里,3e-4算低的了,但RL里一般不要调大。原因是RL的梯度信号噪声极大,而且数据分布本身就在跟着策略变化,Adam的自适应学习率在某些情况下会把噪声放大。我自己曾经为了加快学习把学习率调到1e-3,结果训练曲线像心电图,完全没法看。
rollout_steps=2048背后的逻辑是让单批数据包含足够多的轨迹片段,降低样本之间的自相关性。如果你的环境单个episode特别长,比如几百步,那么2048里可能只包含几个片段,梯度方差会很大,这时应该加大rollout_steps。
4.3 什么时候一定要改这些默认值
我总结了几类常见的调整场景:
- 稀疏奖励、长时间跨度任务:gamma从0.99改到0.999,否则早期状态几乎拿不到未来奖励的梯度信号。
- 动作维度高:网络输出的高斯分布探索能力弱,可以加一点entropy_coef(比如0.01)防止过早确定性坍缩。
- clip比例过高或过低:训练日志里加一个
ratio_clip_fraction,如果超过0.3,说明策略动得太猛,降低学习率或clip值;如果常年接近0.0,说明策略几乎没有更新,可以提高学习率或者增加update_epochs。 - 单轮更新loss波动大:把value_loss的系数从0.5降到0.2或0.1,Critic的错误信号会少一些。
调参这件事没有银弹,但PPO已经是所有算法里最宽容的了。先把默认值跑通,再按日志指标一点点动,基本上不会太惨。
5. 训练失败的常见原因与排查思路
遇到训练失败,别急着怀疑代码,大部分时候问题出在一些软性环节。我把自己复现失败和帮别人排查的案例浓缩成几类。
5.1 吃了on-policy的亏:数据被用到发臭
PPO是on-policy算法,用旧策略采的数据去更新当前策略,本质上是借importance sampling在算期望。但随着更新次数增加,旧数据对应的策略和当前策略越偏越远,重要性权重也随之失真。这就是为什么我强烈建议在日志里记录两个指标:平均概率比和clip比例。
如果clip比例长期很高,说明你的更新太激进,数据已经严重陈旧。此时降低学习率或者减小update_epochs都比修改clip值有效。反过来,如果clip比例常年为0,说明更新太慢,可以尝试把update_epochs加到10,很多环境反而会学得更快。
5.2 reward尺度失控
reward的绝对大小对PPO的影响比多数人想象的大。如果reward动辄上千,advantage的值也会非常巨大,再经过softmax或exp操作,数值很容易溢出;如果reward全是0或1这样的稀疏值,GAE的信号又会微弱到没有。
解决办法就是advantage归一化,这个上文提过。但归一化只能解决训练稳定问题,不能解决奖励稀疏问题。如果环境里绝大多数采样结果都是零reward,策略几乎学不到东西,这时候该考虑的是设计更稠密的奖励函数,而不是继续调PPO超参数。
5.3 熵坍缩与"假收敛"
一个特别容易误判的现象是:策略熵在一两百帧内迅速掉到接近0,训练曲线的reward看着也在稳定上升,你以为模型已经学会了。实际上它很可能学成了一个"复读机",不管状态是什么都输出同一个动作。这在机器人控制里尤其致命。
排查方法很简单:把策略熵打印出来。正常训练的熵应该平缓下降,如果骤降,说明探索已经死亡。处理方式无非是调高entropy_coef,或者降低学习率让它慢点收敛。如果任务需要非常精确的控制,熵坍缩后还可以主动在动作上叠加一个小幅高斯噪声,给策略留条后路。
5.4 复现性问题:种子和环境版本的锅
很多同学问过我"为什么我按教程跑出来的结果跟别人不一样"。除了随机种子外,一个容易被忽略的坑是环境库版本。同一个环境,mujoco-py或gymnasium升级了patch版本,动力学参数可能就变了,训练曲线自然对不上。
我现在做实验会固定一套依赖版本,并且把环境版本号写进实验日志。如果某天代码没动、结果变了,先去看环境版本是不是变了。这比怀疑算法本身要靠谱得多。
6. 从跑通到落地:PPO在真实任务中的经验
跑通CartPole和HalfCheetah只是起点。真正让PPO发挥价值的是把这些能力用到实机上,比如机械臂控制、巡检机器人、游戏角色决策。这里我分享一些落地阶段的经验。
6.1 机械臂控制:仿真到实机的差距
机械臂强化学习是现在特别火的方向,也是被问得最多的。仿真里训练好的PPO策略直接搬到真机上,十有八九会翻车。原因是仿真模型和真实动力学存在偏差,哪怕是摩擦力、阻尼这些微小的差别,在累积效应下都会把轨迹误差放大。
我的做法是分三步走。第一步,在仿真里做domain randomization,每次reset随机化物理参数(质量、摩擦系数、控制延迟等),让策略见过各种"手感";第二步,在策略输出层加一个低通滤波器或者动作平滑正则,限制相邻动作之间的突变幅度;第三,实机上先跑开环轨迹验证安全性,再逐步切到闭环策略,同时保留一个紧急停止的接口。
这中间还有一个现实问题:PPO在训练时需要大量采样,如果真机采样太慢,就只能在仿真里把策略训到足够鲁棒,然后做一次sim-to-real迁移。如果条件允许,也可以在真机上微调,但sample效率低,我个人还是倾向把仿真的工作做足。
6.2 奖励塑形:给PPO一个"不会学歪"的目标
奖励函数设计本质上是工程问题。稀疏奖励(只在任务完成时给+1)理论上可行,但实际中收敛太慢。常见做法是做阶段性奖励,比如接近目标点给一个小正奖励,完成操作给一个大正奖励。
但奖励塑形要小心"奖励黑客"现象。比如你让机械臂学习搬运,如果只是根据末端位置与目标距离给负奖励,策略可能学会先把机械臂缩到某个位置,让距离计算钻空子。我的防跑偏思路是:用potential-based shaping,把塑形奖励设计成某个势函数的差分,理论上不改变最优策略,实际操作中也明显减少学歪的概率。
另外一点:如果发现训练时reward不断上涨但任务并没有真正完成,先去看看环境中reward的统计分布,很多问题是奖励定义本身造成的,跟PPO没关系。
6.3 PPO之外的扩展:dual-clip、离线强化学习、model-based
PPO并非只此一家。如果你在大规模分布式场景用PPO,会出现一个问题:并行采样带来的优势估计噪声会因为clip失效而累积。dual-clip PPO就是在clip之外,对负优势再做一层限制,它专门抑制gradient variance过大的问题。如果任务需要离线数据集,比如只能从日志数据里学,IQL这类离线强化学习会更合适,因为它刻意不估计策略之外的动作价值,规避了分布外误差。而如果仿真采样太贵,model-based RL通过学一个环境动力学模型来扩充虚拟采样,可以和PPO结合成一种"先学模型、再用PPO在模型里采样"的框架。
说到底,PPO是一种工程上极其好用的基础算法,但不是终点。掌握了它以后,你再去看这些变体会发现,它们都在改同一个东西:RL的稳定性和样本效率的权衡。
7. 写在最后:几条让训练更省心的经验
最后分享几个我自己的习惯,未必是标准答案,但确实帮我少熬了很多夜。
第一,训练日志里一定要有那三个指标:explained variance、ratio clip fraction、entropy。explained variance反映的是value function对回报的拟合程度,如果一直很低,策略学习会非常缓慢。我见过很多人只看reward曲线,完全不知道模型内部已经坏了。
第二,不要在第一个环境版本上花太多时间调参。环境本身如果有bug,PPO再稳定也救不回来。我的习惯是先跑一个已知能出结果的经典环境验证代码正确性,再切换到自己的任务。
第三,实验记录里必须写清随机种子、环境版本、策略网络结构和超参数。这听着像废话,但真的能救命。我有一次根据旧日志复现实验,发现奖励曲线对不上,查了半天是换了台机器、某个依赖包被自动升级成新版本了。
第四,大任务开局先跑小规模实验。如果你想在一个复杂环境上做训练,先缩小观测维度、缩短episode长度、减少并行环境数量,快速确认梯度没有消失、训练流程没有死循环,再放大规模跑。这样做不是省时间,是防心态崩坏。
PPO这套东西,学会了并不难,难的是把"能跑"变成"好用"。希望这些踩坑经验能帮你少走几步弯路。