ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PPO强化学习实战:用近端策略优化训练小车征服MountainCar连续控制

PPO强化学习实战:用近端策略优化训练小车征服MountainCar连续控制 第一次看到那辆橙色小车在MountainCarContinuous-v0里心惊胆战地倒车、加速、再倒车最后终于借着惯性冲上右侧峰顶的时候我很确定PPO这个算法我算是入门了。各位这个环境没什么花哨的3D渲染状态就两个数字——位置和速度动作也只有一个连续量——发动机油门。可就是这么一个看起来像物理课实验的小玩具把“策略模型训练不稳定”“奖励稀疏难收敛”“超参敏感一调就崩”这些强化学习项目里真正让人头疼的问题全都能逼你碰一遍。这篇博文就围绕MountainCarContinuous-v0完整讲清楚我是怎么用PPO把小车送上山顶的从算法思路、网络设计、完整可运行的代码到训练中反复踩到的坑和调参记录一次性都放出来。适合刚学完强化学习基础、想找一个连续动作环境练手的人也适合已经跑过CartPole但想看看PPO在稍复杂任务里怎么落地的人。代码我放在第3节可以直接复制跑通。1. 为什么我会用MountainCarContinuous-v0来练PPO1.1 环境规则与难点拆解MountainCarContinuous-v0是OpenAI Gym里MountainCar的“连续动作版”。任务目标一句话让一辆动力不足的小车从谷底出发通过不断来回摆动蓄积势能最终爬到右侧山顶的旗帜位置。状态空间是2维小车的位置position和速度velocity。位置范围大约在[-1.2, 0.6]速度范围在[-0.07, 0.07]不同版本略微有差异。动作空间是1维油门力度范围是[-1.0, 1.0]正值表示向右发力负值表示向左发力0表示滑行。难点在于这辆车不是普通的小车。发动机推力有限直接匀速往右爬是永远爬不上去的必须利用来回摆动的动能积累。这其实是一个很典型的重力场下的欠驱动系统跟生活中“荡秋千要来回蹬腿才能越荡越高”是一个道理。环境默认每局最多999步如果长时间爬不上去这一局就会被TimeLimit强制截断。环境本身的奖励设计非常稀疏到达山顶位置position 0.45时给一个比较大的正奖励通常是100其他每一步都是0。这意味着如果完全依赖原始奖励算法在前期几乎得不到任何有效反馈纯粹是在一片灰茫茫的奖励信号里瞎摸。所以做这个项目时大部分人的第一件事就是做reward shaping——给“还在路上”的过程一点点负奖励逼它别再慢悠悠地晃。1.2 相比CartPole和Pendulum它有什么不一样很多人学强化学习入门用的是CartPole-v1那个环境的动作是离散的左推或者右推。PPO也能处理离散动作但PPO真正的优势在于连续动作控制。如果只跑CartPole你其实并没有充分体会到Actor-Critic和策略分布建模在连续空间里的那种感觉。Pendulum-v1虽然也是连续动作但它每一步都有明确的奖励反馈模型很容易通过“把杆子立起来”这个方向收敛。MountainCarContinuous-v0更折磨人奖励稀疏状态量级很小位置和速度都不是整数级别的大数而且任务必须依赖“非直觉的来回摆动策略”才能完成。换句话说这个环境特别能检验策略是否在真正的“探索”道路上而不只是朝着当前梯度方向闷头冲。PPO在这里的收敛曲线往往不是一路平滑下降而是会在某个瞬间突然“悟了”一样跳上去这种体验非常有意思。2. PPO算法与项目设计思路2.1 PPO的核心clipped surrogate objectivePPO全称Proximal Policy Optimization中文常翻译成近端策略优化。它属于Policy Gradient家族的升级版。普通的策略梯度方法比如REINFORCE有一个很致命的缺点更新步长不好控制。步长小了学得慢步长大了策略一下子偏离太多后面的采样数据全部作废训练直接崩掉。PPO解决这个问题靠的是一个非常巧妙的“止损机制”——clipped surrogate objective。简单说它会在每轮更新时计算新旧策略的比值如果这个比值超出[1-epsilon, 1epsilon]这个区间就把它的优势贡献卡住不再继续放大。用做饭来类比的话普通策略梯度就像你从网上抄了一份菜谱第一次做就把盐改成三勺结果整锅菜废了。PPO则规定你做实验时调料改动最多在20%以内超出部分先不采纳等确认这次改动真的有效再继续加码。正因为这个限制PPO可以在一个rollout里多次复用数据大大提高了采样效率。数学上PPO的actor损失是这样[ L^{CLIP}(\theta) \mathbb{E}_t \left[ \min(r_t(\theta) A_t, ; \operatorname{clip}(r_t(\theta), 1-\epsilon, 1\epsilon) A_t) \right] ]其中 (r_t(\theta)) 是新策略与旧策略在采样动作上的概率比值(A_t) 是优势函数估计。每当比值超出限制区间时损失就不再随比值增大或减小从而防止一步更新过猛。这个公式看起来有点绕但实践里需要理解的就一句话它在保护“旧数据仍然可信”的安全边界。2.2 网络结构Actor-Critic怎么设计PPO用的是Actor-Critic结构这个项目里我设计得很简单Actor网络输入状态输出连续动作的均值再加上一个可学习的log_std两者共同构成一个高斯分布。采样时从分布中抽一个动作再用tanh激活函数把动作限制在[-1, 1]范围内。Critic网络输入状态输出一个标量用来估计状态价值V(s)在做GAE的时候作为基准线。两个网络共享同一个输入但各自独立的隐藏层我用了两层128维的MLP激活函数用Tanh。这里的隐藏层宽度并没有玄学128在大部分经典连续控制任务里都够用。状态量纲差异不大不需要额外做归一化。一个容易忽略的细节是参数初始化。我在每个线性层上都用了正交初始化orthogonal initializationscale设成sqrt(2)。这个做法是从baselines代码库里传下来的经验实践下来确实能让早期训练更稳。如果你发现自己的PPO模型前几千步loss疯狂乱跳可以检查一下有没有做初始化。2.3 GAE优势估计与训练循环PPO训练时不是每走一步就更新一次的那样方差太大。通常会收集一段长度的样本比如2048步然后统一做一次更新。在这2048步里我们需要为每一个状态-动作对计算优势函数A_t。优势函数的意思是这个动作比“平均水平的动作”好多少。我们用一个折扣累积的TD误差来估计它。为了平衡偏差和方差我使用了Generalized Advantage Estimation也就是GAE核心公式是[ A_t \sum_{l0}^{T-t-1} (\gamma \lambda)^l \delta_{tl} ]其中 (\delta_t r_t \gamma V(s_{t1}) - V(s_t))。gamma配置为0.99lambda配置为0.95。lambda越大优势估计越偏向全局回报方差更大但偏差更小lambda越小越依赖当前的TD误差偏差大但方差小。在MountainCar这种需要长时间规划才能找到正确策略的任务里lambda取0.95比较合适。训练循环的整体结构是这样的用当前策略与环境交互收集一批经验样本计算每个样本的优势值和回报将优势值做标准化减均值除以标准差用这批数据连续更新K次比如10次每次更新前重新计算新策略下的概率比值清空经验池重新采样。这个模式在强化学习圈子里叫on-policy的“采样-更新”循环。PPO虽然在更新时会反复利用数据但它要求这些数据必须来自最近的策略不能像DQN那样把很老的样本放进回放池里这一点要和off-policy的方法区分清楚。3. 完整代码实现3.1 环境部署与依赖安装代码基于目前主流的gymnasium库老版本叫gym。建议在Python 3.9以上的环境里操作。安装命令如下pip install gymnasium torch numpy matplotlib如果只是要跑这个小车环境torch用CPU版本就完全够了因为网络非常小计算量不大。不需要买显卡也不需要用GPU跑。3.2 PPO核心类下面这段代码是PPO Agent的核心定义。Actor和Critic放在同一个类里方便在forward的时候同时拿到策略分布和值函数。import numpy as np import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F from torch.distributions import Normal import gymnasium as gym class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.actor nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, action_dim), nn.Tanh() ) self.log_std nn.Parameter(torch.zeros(action_dim)) self.critic nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1) ) for layer in self.modules(): if isinstance(layer, nn.Linear): nn.init.orthogonal_(layer.weight, np.sqrt(2)) nn.init.constant_(layer.bias, 0) def get_dist(self, state): mean self.actor(state) std torch.exp(self.log_std).expand_as(mean) return Normal(mean, std) def get_value(self, state): return self.critic(state)Actor网络最终带了一层Tanh激活确保输出的动作均值落在[-1, 1]区间正好匹配MountainCarContinuous-v0的动作空间边界。如果你换别的环境动作范围可能不同到时需要按环境实际范围做缩放。log_std是一个可训练参数初始为0也就是初始标准差为1。在训练早期探索范围较广有利于发现不同策略路径。如果你的模型开始收敛后动作还在剧烈抖动可以适度降低训练后期log_std的值或者给它加一个下限约束。3.3 GAE优势函数计算这一部分的重要性非常高GAE实现如果写错训练效果会变得奇差无比而且不容易排查。我自己就曾经在done标志的处理上出过错导致每个episode边界都“跨回合泄露”了价值信息训练完全跑不动。def compute_gae(rewards, dones, values, next_value, gamma0.99, lam0.95): advantages np.zeros_like(rewards, dtypenp.float32) gae 0.0 for t in reversed(range(len(rewards))): if t len(rewards) - 1: delta rewards[t] gamma * next_value * (1 - dones[t]) - values[t] else: delta rewards[t] gamma * values[t 1] * (1 - dones[t]) - values[t] gae delta gamma * lam * (1 - dones[t]) * gae advantages[t] gae returns advantages values return advantages, returns注意代码里每个delta计算时都乘了(1 - done)。这意味着在一个回合结束后我们不会拿下一个回合的价值去bootstrap当前回合的最后一步因为这两个回合之间根本没有连续关系。如果你把这一项漏掉GAE就相当于把两个不同回合的轨迹强行接在一起价值估计会严重偏高策略会被误导。3.4 训练主循环主循环的采样和更新逻辑如下。我保留了环境原生的大奖励在非终止步骤额外减去0.1作为时间惩罚。def main(): env gym.make(MountainCarContinuous-v0, max_episode_steps300) state_dim env.observation_space.shape[0] action_dim env.action_space.shape[0] agent ActorCritic(state_dim, action_dim, hidden_dim128) optimizer optim.Adam(agent.parameters(), lr3e-4) gamma 0.99 lam 0.95 clip_eps 0.2 update_epochs 10 rollout_steps 2048 max_timesteps 100000 log_returns [] state, _ env.reset() ep_reward 0.0 episode 0 global_step 0 while global_step max_timesteps: buffer_states [] buffer_actions [] buffer_log_probs [] buffer_values [] buffer_rewards [] buffer_dones [] for _ in range(rollout_steps): state_t torch.FloatTensor(state).unsqueeze(0) dist agent.get_dist(state_t) value agent.get_value(state_t).item() action dist.sample() log_prob dist.log_prob(action).sum(dim-1).item() action_np torch.clamp(action, -1, 1).detach().numpy().flatten() next_state, reward, done, truncated, _ env.step(action_np) shaped_reward reward if not (done or truncated): shaped_reward reward - 0.1 buffer_states.append(state) buffer_actions.append(action_np) buffer_log_probs.append(log_prob) buffer_values.append(value) buffer_rewards.append(shaped_reward) buffer_dones.append(1.0 if (done or truncated) else 0.0) state next_state ep_reward shaped_reward global_step 1 if done or truncated: episode 1 log_returns.append(ep_reward) ep_reward 0.0 state, _ env.reset() with torch.no_grad(): next_value agent.get_value(torch.FloatTensor(state).unsqueeze(0)).item() rewards np.array(buffer_rewards) dones np.array(buffer_dones) values np.array(buffer_values) advantages, returns compute_gae(rewards, dones, values, next_value, gamma, lam) adv_mean advantages.mean() adv_std advantages.std() 1e-8 advantages (advantages - adv_mean) / adv_std states_t torch.FloatTensor(np.array(buffer_states)) actions_t torch.FloatTensor(np.array(buffer_actions)) old_log_probs_t torch.FloatTensor(buffer_log_probs) advantages_t torch.FloatTensor(advantages) returns_t torch.FloatTensor(returns) for _ in range(update_epochs): dist agent.get_dist(states_t) log_probs_new dist.log_prob(actions_t).sum(dim-1) entropy dist.entropy().sum(dim-1).mean() ratios torch.exp(log_probs_new - old_log_probs_t) surr1 ratios * advantages_t surr2 torch.clamp(ratios, 1 - clip_eps, 1 clip_eps) * advantages_t actor_loss -torch.min(surr1, surr2).mean() critic_pred agent.get_value(states_t).squeeze(-1) critic_loss F.mse_loss(critic_pred, returns_t) loss actor_loss 0.5 * critic_loss - 0.01 * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(agent.parameters(), 0.5) optimizer.step() if len(log_returns) 0 and episode % 10 0: avg_return np.mean(log_returns[-10:]) best_return max(log_returns) print(fEpisode: {episode} | Steps: {global_step} | fAvgReturn(last10): {avg_return:.2f} | BestReturn: {best_return:.2f}) env.close() if __name__ __main__: main()我在训练时把环境的max_episode_steps设成了300。这样每一局最多300步如果连续走出负几百的return说明策略还完全没有掌握摆动技巧。如果你不设置这个参数gymnasium默认会给999步对早期状态来说刷一局要等很久训练效率会低不少。将训练过程中每个episode的return打印出来。正常后期每次到达山顶只需要几十步return会接近100符合“越早到达奖励越高”的期望。3.5 关键超参数选择思路超参数我直接给出能稳定跑出来的组合并解释一下它们为什么合适超参数取值选择理由gamma0.99长期回报权重较大适合需要先蓄势后退、再冲刺的任务lam0.95GAE偏差方差折中经典配置clip_eps0.2PPO论文和多数baseline的默认值update_epochs10单批数据复用10次多则容易过拟合当前采样rollout_steps2048保证一次更新有足够样本量降低方差lr3e-4Adam在连续控制任务里常用初始学习率开头比较稳妥关于学习率我见过很多人一上来就试1e-3结果训练半天完全不动。可以从3e-4开始跑确认收敛之后再试着调低到1e-4看看有没有提升。强化学习模型对学习率的敏感程度超过监督学习不要觉得0.0001和0.001只是“一个数量级的差异”在PPO上这两种配置很可能是“能学”和“崩掉”的分界线。3.6 Reward Shaping的一个小技巧我在环境原始奖励的基础上加了每步-0.1的惩罚。这个细节很多人觉得不起眼实际上对这个项目的成败非常关键。MountainCarContinuous-v0如果完全不做时间惩罚模型只会在160步左右缓慢到达山顶而且早期完全不知道该干什么因为所有非终止状态的奖励全都是0策略梯度在数值上几乎推不动。加了-0.1之后模型每多拖一步就多扣一点点分相当于告诉它“别在原地踏步”。我试过把这个惩罚系数改成-0.5结果模型学到的策略过度激进经常猛轰油门但丢失了节奏反而很久爬不上去。惩罚系数应该小一些让它只是起到一种“早期指南针”的作用而不是完全重新定义任务目标。4. 训练效果、收敛过程与调参记录4.1 训练曲线到底长什么样我第一次跑通时的曲线可以分成三个阶段看第一个阶段前几百步或者前几万步平均每个episode的return在-150到-300之间波动看起来非常惨。这个阶段小车完全不会摆动就在谷底附近左右来回蹭经常被TimeLimit截断。此时actor策略基本等于随机策略critic的价值估计也不准。第二个阶段可能突然出现一个return转正的episode。这往往发生在模型无意中探索到一个“来回摆动蓄能”的轨迹一次性冲上了山顶拿到了100奖励。这个时候critic会立刻把这个成功经验学进去价值估计开始变准后续优势函数计算也更加可靠。所以你会看到曲线不是平滑上升而是突然跳一下然后继续维持一段低迷再跳一下。第三个阶段大约在两三万步之后策略慢慢稳定几乎每个episode都能在100步以内到达山顶平均return稳定在70到95之间。到这一步可以说任务算是真正学会了。如果你看到自己的曲线一直保持在-300附近没有任何跳起来的迹象多半不是运气问题而是某个细节有bug比如GAE实现错误、奖励没有正确传入、或者初始化方式有问题。4.2 从失败到成功的三次调试记录第一次失败我把lr设成了1e-3更新epochs设为30结果训练到10万步都完全没起色。原因很典型学习率太大加上epochs太多单批数据被反复过拟合策略在几次更新里来回震荡根本没法积累稳定的方向。第二次失败问题出在reward shaping上。我当时图省事给每一步奖励都减了0.5结果模型学会了“用最小油门平缓爬坡”的保守策略永远冲不上山顶。后来在实验里一个系数一个系数地试发现-0.1到-0.2是最平衡的范围。惩罚太重会把“勇敢尝试”的探索行为也一并惩罚掉。第三次失败是最隐蔽的我把gymnasium返回的truncated当成了普通done的一部分结果每局到300步被TimeLimit截断时代码也当成到达山顶处理给了100的奖励模型疯狂学习一个根本不存在的“原地等待300步能得100分”策略。这是个逻辑bug但只要检查一下终止条件的具体含义就能避免。4.3 不同超参数的表现对比我用同一段代码做了一组小对比实验只改动单变量每组跑了20万步超参数改动观察结果clip_eps0.1训练更稳但偏慢可能需要更多步数才能跳出早期平台clip_eps0.3初期探索更快但偶发性能回退update_epochs5每轮更新力度小收敛略慢但更稳定update_epochs20单批数据上过拟合明显曲线震荡加剧lr1e-3训练发散或长时间停滞不推荐hidden_dim256容量增大但小环境收益有限训练速度变慢对MountainCarContinuous这种简单任务来说128维hidden已经非常够用了。加大网络并不会让任务自动变简单反而可能引入了更多需要调参的自由度得不偿失。5. 常见问题排查与避坑指南5.1 gymnasium与gym版本兼容问题如果你用的是老版gymenv.reset()返回的是单个numpy数组如果用新版gymnasiumenv.reset()返回的是(obs, info)元组env.step()返回的是5个值比老版多一个truncated。不按版本适配直接套老代码轻则报错重则出现我上面提到的“把truncated当done导致假成功”的逻辑错误。我建议指定安装classic control相关组件pip install gymnasium[classic-control]这样MountainCarContinuous-v0会自动注册不需要额外配置。5.2 训练不收敛的常见现象如果你发现loss变成NaN先检查两件事一是log_std有没有被更新成极端负数导致exp爆炸可以给它加个范围限制二是advantage标准化的分母有没有加一个极小值保护避免std为0的时候除0。代码里我加了1e-8就是防止这种情况。如果loss很正常但策略学不会检查一下奖励信号是不是传到了buffer里。我写过一次reward和done错位的bug事后打log才发现所有奖励都被往前移了一步训练结果自然完全错误。这种问题光看loss看不出毛病最有效的办法是定期打印当前episode的平均return、步数等统计量和期望值对比。5.3 小车原地抖动或只爬一半小车原地抖动通常代表策略标准差太小探索不足。可以在训练早期适当提高学习率、增大熵系数或者提高初始log_std。再一个原因是critic价值估计不准导致优势函数噪声太大策略被各种随机梯度推来推去。小车能爬到接近山顶但又滑下来说明它已经学到了“来回摆动”的思路但油门的时机和力度还不对。可以试试把最大episode steps从300减少到200逼迫模型在更短的时间内找到更高效的轨迹。也可以检查reward shaping是不是惩罚过重导致它在最需要放手一搏的时刻反而选择了保守。5.4 把这个项目扩展到其他环境的建议跑通MountainCarContinuous-v0之后我强烈建议你接着试LunarLanderContinuous-v2或者BipedalWalker-v3。这两个环境的状态维度更高、动作维度更多但代码框架几乎不用改只需要调整网络隐藏层大小、reward shaping的方式以及可能要开始做Observation Norm等预处理。有一个经验是通用的遇到新环境先别急着怀疑PPO实现先写一个随机策略的baseline统计随机策略的return分布再看训练后的策略明显优于baseline。如果连随机策略都不如大概率是reward设计或者状态预处理有问题而不是算法本身不行。最后说一点我的个人心得从CartPole到MountainCarContinuous-v0是我自己强化学习实践里跨过的一道坎。这个东西最熬人的地方在于前几万步你完全看不到成功的希望曲线惨得跟随机一样但只要你确认代码没有bug、超参数不离谱就必须相信PPO在慢慢收集信息。我建议所有新手在这个环境里都把“训练过程可视化”做好每隔一段时间打印平均return不要只看loss曲线。等有一天你的小车突然冲上山顶那种感觉会让你对强化学习整个领域都更有耐心。
返回列表