ARTICLE DETAIL

资讯详情

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

MountainCarContinuous-v0实战:从零手写PPO搞定连续动作空间

MountainCarContinuous-v0实战:从零手写PPO搞定连续动作空间 说实话MountainCarContinuous-v0这个环境第一次把我搞得很懵。当时我刚跑通CartPole的DQN版本信心满满地想把那套经验直接搬过来结果小车在谷底来回怼了半天别说山顶连半山腰的边都没摸到。后来才知道这个环境的难点压根不在“环境复杂度”而在连续动作空间——每个时间步你要输出的不是一个“向左/向右”的离散指令而是一个连续数值也就是施加在车上的力的大小和方向。这一下就淘汰了DQN那一整套离散动作体系逼着你必须上策略梯度类算法。这篇文章就用一次完整的PPO实战来拆解这件事。我会从环境本身的状态、动作、奖励设计讲起然后讲为什么选PPO、不选DDPG和SAC再一步步带着实现网络、GAE优势估计、训练循环最后附上完整可运行的代码和我在调参时踩过的坑。如果你是刚接触强化学习、想找一个比CartPole稍微“反直觉”一点的练手项目这篇应该能帮你省下不少瞎折腾的时间。1. MountainCarContinuous-v0环境剖析1.1 状态、动作与终止条件先把环境的基础信息捋一遍。MountainCarContinuous-v0的状态空间是二维的小车的位置position和速度velocity。位置范围大约在 [-1.2, 0.6] 之间速度范围则在 [-0.07, 0.07] 左右。动作空间是一维连续值范围是 [-1.0, 1.0]代表施加在车上的水平推力正数向右推负数向左推。任务终止条件有两条一是小车位置达到 0.45 以上也就是成功爬上右边山顶这算“达成目标”二是步数超过1000步仍没登顶环境判定本轮结束。后者在新版gymnasium里用truncated标志表示和真正完成任务触发的terminated含义不同这一点在写训练循环时要特别留意后面第5章我会专门讲。这里有个新手容易误解的地方看状态空间只有2维动作空间只有1维会下意识觉得“这环境也太简单了”直接给个向右的大推力不就行了但物理引擎告诉你没那么便宜——小车发动机功率不够无法一次性克服重力冲上陡坡你必须在谷底左右来回摇摆利用反复的势能和动能转换一点点累积爬升量。这其实很像小时候玩秋千想荡高不是一直朝某个方向蹬腿就行而是要在特定的时机反复发力。1.2 为什么这个环境“看着简单练着抓狂”这就要说到这个环境最核心的“反直觉”属性了奖励函数给出的是稀疏且带惩罚的信号。每一步的即时奖励是-0.1 * action^2也就是说只要你施加推力就会产生一个小的负奖励。只有当小车到达山顶位置时才会额外加上100的奖励。这个奖励设计带来了两个问题。第一1000步的上限意味着小车必须尽快登顶否则每步的小惩罚会不断累积回合总奖励会越来越难看第二由于爬山过程不会给中间奖励智能体前期几乎得不到任何正反馈信号它只能靠随机探索偶尔碰上一次登顶才能开始学到“原来往这个方向蓄力是有用的”。在我实际测试中PPO在这种环境里前一两百个episode基本都在“无效探索”奖励曲线像是死水一潭如果你没提前做好心理预期很容易误判成代码写错了。另外提一下环境命名的学问。带Continuous后缀的版本是连续动作版早期还有个离散动作版叫MountainCar-v0动作只有三个选项向左、向右、不施力。两个版本训练难度差异非常大离散版用DQN就能搞定连续版则必须用能输出连续动作分布的强化学习算法比如PPO、DDPG、SAC。所以看到MountainCarContinuous-v0时第一步就应该确认这是连续动作任务别再想着DQN那套方案了。2. 为什么选PPO而不是DQN或DDPG2.1 连续动作空间下的策略表示DQN之所以不能直接用在连续动作空间原因在于它的核心机制通过Q函数选出令价值最大的动作。离散任务里动作集合是有限的比如“向左、向右、不动”三个选项可以一次算出每个动作的Q值再取最大值。可连续动作空间里可选择的动作是无穷多个你不可能把 [-1.0, 1.0] 区间内所有动作都枚举一遍也没法直接对Q函数做最大值优化这个最优化问题本身就很难求解。策略梯度方法绕开了这个麻烦。它不先算价值再选动作而是直接学习一个“策略函数”这个策略会输出一个动作分布我们从分布里采样得到具体动作。对于给定向右还是向左这类连续决策我们常把这个分布设置成高斯分布即策略输出一个均值和一个标准差动作就从以均值为中心的正态分布中采样。PPO正是基于这种策略梯度的思路。2.2 PPO是怎么控制更新步子的PPO的全称是Proximal Policy Optimization直译叫“近端策略优化”。它解决的核心问题非常简单策略梯度更新时步子迈多大合适步子太小训练慢得让人失去耐心步子太大策略突然大变可能直接崩塌性能断崖式下跌。这就像你站在悬崖边缘想往前探一步看风景但又怕一脚踩空——PPO的办法是给自己拴一根“保险绳”每次更新时强行限制新旧策略之间的差异不许一次性走太远。这根保险绳就是目标函数里的clip机制ratio exp(new_log_prob - old_log_prob) surr1 ratio * advantage surr2 clamp(ratio, 1 - eps, 1 eps) * advantage loss -min(surr1, surr2)其中eps通常取0.2含义是如果新旧策略的比值超出 [0.8, 1.2] 区间就把它的收益强行截断不参与梯度更新。这个机制实现简单不需要像TRPO那样算复杂的二阶近似效果却足够稳定这也是PPO能成为工业界默认选择的重要原因。2.3 与主流连续控制算法的实战对比连续控制领域并不是只有PPO一家DDPG、TD3、SAC也很常见。但作为入门实战我会优先推荐PPO理由有这几个。DDPG和TD3都依赖经验回放和目标网络还涉及动作噪声设计、软更新系数等一堆超参数任何一个环节没配合好训练都会出幺蛾子。SAC虽然鲁棒性好但内部多了一个自动调节温度系数的机制损失函数里也叠加了好几项理解成本高不少。PPO不需要经验回放用的是On-Policy方式结构天然简单超参数就那么几个大部分时候用默认值就能跑出不错的结果。你可能会问PPO是On-Policy算法每次更新完就要扔掉旧经验重新采集数据效率看起来很低啊没错论样本效率PPO确实比不过SAC但MountainCarContinuous-v0这个环境状态维度低、交互成本极低跑几千步也就眨眼功夫样本效率的劣势完全体现不出来。反而它的稳定性和易调性在这种小任务上价值最大一个下午就能验证出“我的PPO实现到底对不对”。3. 从零手写PPO网络设计与GAE3.1 Actor-Critic网络该怎么搭PPO标准实现走的是Actor-Critic架构。Actor相当于“动作生成器”输入状态输出动作分布的参数Critic相当于“裁判”输入状态输出对该状态未来总回报的估计值用于后续计算优势函数。针对MountainCarContinuous-v0状态维度只有2动作维度只有1网络结构不必花哨。我用的是两层MLP隐藏层256个神经元激活函数ReLU。Actor分支最终输出的是高斯分布的均值经tanh压到 [-1, 1] 区间再配合一个可学习的log_std参数来表征标准差动作就从Normal(mean, std)里采样。这里藏着两个实战细节。第一为什么输出层要套tanh因为环境动作范围限定在 [-1, 1]如果你输出的均值是2.5采样出来的动作大概率越界强行clip会导致动作分布畸变训练时会积累严重的偏差。第二log_std为什么要作为可学习参数而不是固定值因为标准差直接决定了探索程度固定值很难满足训练不同阶段的需求。训练初期希望std大一些探索充分后期希望std小一些动作收敛到稳定策略。把它变成可学习参数让梯度自己决定探索节奏会比手动调一个固定标准差聪明得多。3.2 GAE优势估计的推导与实现策略梯度需要知道“这个动作到底比平均水平好多少”这个差距就是优势函数。如果直接拿“从当前步到回合结束的总回报”当作优势方差会非常大训练不稳定。如果用单步TD误差方差小了但偏差又变大。GAE全称Generalized Advantage Estimation就是在这两者之间做权衡它靠一个lambda参数来控制偏差和方差的折中。GAE的核心计算并不复杂核心逻辑是倒退遍历每个时间步用当前TD误差叠加一个按lambda折扣的累计项delta_t reward_t gamma * value_{t1} - value_t gae_t delta_t gamma * lambda * gae_{t1} * (1 - done_t)lambda取0时退化回单步TD误差方差低但偏差大lambda取1时几乎等同于蒙特卡洛总回报偏差小但方差爆炸。我一般取0.95这是很多任务里表现均衡的默认值。实际编码时有一个很隐蔽的坑CRITIC网络估计的是“期望回报”但回合结束时如果next_state是最终状态它的价值就是0不应该再加gamma * v_next。所以在GAE代码里我们需要对终结状态做特殊处理我在下面代码中直接用(1 - done)置零保证终结时的优势不会被未来项污染。3.3 训练循环的骨架与伪代码PPO训练循环大体可以拆成三个阶段。第一交互采样阶段。让当前策略和环境互动按时间步收集(state, action, log_prob, reward, done, value)六元组存进一个缓冲区。这些经验只属于当前策略旧经验会在本轮更新后被丢弃这是On-Policy算法的典型特征。第二优势计算阶段。所有经验收集完毕后调用GAE函数算每个时间步的优势值和回报值。在更新前我还会对优势值做标准化(adv - adv.mean()) / (adv.std() 1e-8)。经验里绝大多数时间步是“无功无过”的中间状态如果不标准化有效梯度会被噪声淹没。第三优化阶段。把缓冲区里的数据按照小批量迭代更新多个epoch通常设为10。每次更新时计算新旧策略的ratio套上clip上限再加价值函数损失和熵正则项。熵正则的作用是鼓励探索防止策略过早收敛成确定性动作陷入局部最优。4. 完整代码与运行说明4.1 完整代码下面这段代码是我整合了多次实战之后整理出来的一版干净实现依赖gymnasium和pytorch贴到文件里直接能跑。注释写得比较详细方便你对照着前面的原理看。import gymnasium as gym import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import numpy as np from torch.distributions import Normal # 超参数 HIDDEN_SIZE 256 LEARNING_RATE 3e-4 GAMMA 0.99 GAE_LAMBDA 0.95 CLIP_EPS 0.2 ENTROPY_COEF 0.01 VALUE_COEF 0.5 MAX_GRAD_NORM 0.5 UPDATE_EPOCHS 10 BATCH_SIZE 128 BUFFER_SIZE 2048 MAX_STEPS 1000 TOTAL_STEPS 200_000 class ActorCritic(nn.Module): def __init__(self, state_dim2, action_dim1): super().__init__() self.fc1 nn.Linear(state_dim, HIDDEN_SIZE) self.fc2 nn.Linear(HIDDEN_SIZE, HIDDEN_SIZE) self.mu_head nn.Linear(HIDDEN_SIZE, action_dim) self.log_std nn.Parameter(torch.zeros(action_dim)) self.v_head nn.Linear(HIDDEN_SIZE, 1) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return x def get_dist(self, x): mu torch.tanh(self.mu_head(x)) log_std self.log_std.clamp(-20, 2) std log_std.exp() return Normal(mu, std) def get_value(self, state): return self.v_head(self.forward(state)).squeeze(-1) def sample_action(self, state): state torch.FloatTensor(state).unsqueeze(0) dist self.get_dist(self.forward(state)) action dist.sample() log_prob dist.log_prob(action).sum(-1) return action.item(), log_prob.item() def evaluate(self, state, action): feature self.forward(state) dist self.get_dist(feature) log_prob dist.log_prob(action).sum(-1) entropy dist.entropy().sum(-1) value self.v_head(feature).squeeze(-1) return log_prob, entropy, value def compute_gae(rewards, dones, values, next_value): advantages [] gae 0 values.append(next_value) for t in reversed(range(len(rewards))): delta rewards[t] GAMMA * values[t 1] * (1 - dones[t]) - values[t] gae delta GAMMA * GAE_LAMBDA * (1 - dones[t]) * gae advantages.insert(0, gae) returns [adv val for adv, val in zip(advantages, values[:-1])] return advantages, returns def update(model, optimizer, buffer): states, actions, old_log_probs, returns, advantages buffer states torch.FloatTensor(np.array(states)) actions torch.FloatTensor(np.array(actions)) old_log_probs torch.FloatTensor(old_log_probs) returns torch.FloatTensor(returns) advantages torch.FloatTensor(advantages) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) dataset_size len(states) for _ in range(UPDATE_EPOCHS): indices np.random.permutation(dataset_size) for start in range(0, dataset_size, BATCH_SIZE): batch_idx indices[start:start BATCH_SIZE] batch_states states[batch_idx] batch_actions actions[batch_idx] batch_old_log_probs old_log_probs[batch_idx] batch_returns returns[batch_idx] batch_advantages advantages[batch_idx] log_probs, entropy, values model.evaluate(batch_states, batch_actions) ratio (log_probs - batch_old_log_probs).exp() surr1 ratio * batch_advantages surr2 torch.clamp(ratio, 1 - CLIP_EPS, 1 CLIP_EPS) * batch_advantages policy_loss -torch.min(surr1, surr2).mean() value_loss F.mse_loss(values, batch_returns) entropy_loss -entropy.mean() loss policy_loss VALUE_COEF * value_loss ENTROPY_COEF * entropy_loss optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM) optimizer.step() def main(): env gym.make(MountainCarContinuous-v0) model ActorCritic() optimizer optim.Adam(model.parameters(), lrLEARNING_RATE) states, actions, log_probs, rewards, dones, values [], [], [], [], [], [] episode_rewards [] state, _ env.reset() episode_rew 0 total_episodes 0 for step in range(TOTAL_STEPS): action, log_prob model.sample_action(state) next_state, reward, terminated, truncated, _ env.step([action]) done terminated or truncated value model.get_value(torch.FloatTensor(state).unsqueeze(0)).item() states.append(state) actions.append(action) log_probs.append(log_prob) rewards.append(reward) dones.append(float(done)) values.append(value) episode_rew reward state next_state if done: total_episodes 1 episode_rewards.append(episode_rew) state, _ env.reset() episode_rew 0 if len(states) BUFFER_SIZE: last_value model.get_value(torch.FloatTensor(state).unsqueeze(0)).item() advantages, returns compute_gae(rewards, dones, values, last_value) update(model, optimizer, (states, actions, log_probs, returns, advantages)) states, actions, log_probs, rewards, dones, values [], [], [], [], [], [] if total_episodes 0 and len(episode_rewards) % 20 0: avg_rew np.mean(episode_rewards[-20:]) print(fsteps:{step}, episodes:{total_episodes}, avg_reward_20:{avg_rew:.2f}) env.close() if __name__ __main__: main()4.2 跑一次要等多久怎么看效果这段代码不需要GPU纯CPU就能跑。我的笔记本上大约几分钟能跑完20万步。如果你机器性能一般可以把TOTAL_STEPS缩到10万通常也足够看到爬坡成功了只是稳定性稍差一点。训练过程中最直观的判断方式是看打印出来的avg_reward_20。注意MountainCarContinuous-v0的回合回报本身是负数因为每一步都有推力惩罚成功登顶才会加100。一个没训练好的策略20回合平均回报可能在 -200 甚至更低能登顶后回报会跳到 80 到 120 附近这个跳变非常明显。我第一次看到这个数字从 -180 突然蹦到 95 时差点从椅子上站起来那种“模型真的理解了这个物理环境”的瞬间正是强化学习最让人上瘾的地方。想更直观看小车运动过程可以在env.step([action])后加env.render()但注意渲染会大大拖慢训练速度建议只在模型训练完后加载权重单独跑一次演示。我习惯训练结束前把最优模型保存下来后面专门做可视化验证这样既不打断训练节奏又能满足“亲眼看到小车登顶”的需求。5. 实战踩坑与调参快查表5.1 小车纹丝不动奖励曲线平得像死水如果你跑了几万步发现平均回报没有任何上升迹象先别急着怀疑代码绝大多数情况是下面三个原因。第一训练步数不够。这个环境的奖励极其稀疏PPO前期基本是在黑暗中摸索我实测中前100个episode看不到明显提升是常态有人甚至要300个episode之后才能稳定登顶。如果你只跑了1万步真的看不出什么东西耐住性子跑完10万步再说。第二熵系数太大。熵正则虽然能鼓励探索但如果ENTROPY_COEF设成0.1以上策略会一直保持高随机性好不容易学到的方向信息也会被噪声稀释动作永远是大范围乱跳。我建议从0.01起步如果发现后期策略收敛过于死板再降到0.001。第三优势标准化缺失。如果某个回合提前成功登顶整个回合的优势值会异常偏大导致这次“撞运气”的经验在更新时权重过高策略被带偏。我在update里对优势做了标准化这一步千万别省它相当于给所有经验“拉到一个量级”避免单次偶然成功扭曲整体梯度方向。5.2 策略训练后期反复震荡另一种典型问题是明明已经能登顶了平均奖励在80左右徘徊但时不时掉回负数像是策略得了“癫痫”。这通常是两个原因叠加造成的。第一GAE的lambda和gamma组合不合理。如果gamma取得太小比如0.9智能体就会过于“短视”只在意眼前几步的推力惩罚看不到“暂时多花几步迂回蓄力”的长期价值策略会飘忽不定。MountainCar这种需要长距离规划的任务gamma取0.99是底线0.995也可以试试不过gamma太大会增加优势估计方差需要同步调整训练步数。第二更新步数过多。UPDATE_EPOCHS如果设成20甚至30同样的经验被反复重放很容易让策略过拟合到这批采样上等下一批经验到来时又剧烈改变形成周期性震荡。我推荐保持10珊一点的话8也行。记住PPO的“近端更新”是一种平衡艺术重放次数太多就失去了clip保护的意义。5.3 超参数速查表这里整理了一份我调试过程中总结的参数参考表适合MountainCarContinuous-v0这类低维连续控制任务。如果你换到更复杂的连续控制环境可以参考同一套逻辑做起点而不是死记硬背数值。参数推荐值作用调参方向GAMMA0.99折扣因子决定智能体“短视”程度任务时间跨度大就调向0.995GAE_LAMBDA0.95优势估计的偏差方差折中训练不稳时调向0.9CLIP_EPS0.2策略更新的最大步长限制更新震荡时调向0.1ENTROPY_COEF0.01探索强度探索不足就调大收敛不稳就调小VALUE_COEF0.5价值函数损失权重默认即可LEARNING_RATE3e-4全局学习步长不收敛时调小训练慢时调大UPDATE_EPOCHS10每批经验重放次数过拟合就调小BUFFER_SIZE2048每轮更新采集的经验量经验方差大就调大MAX_GRAD_NORM0.5梯度裁剪阈值梯度爆炸时调小还有一个我特别想提醒的“环境版本坑”。早期书籍和教程里用的是老版gymAPI是env.reset()返回单个stateenv.step()返回4个值而新版gymnasium里reset()返回(state, info)step()要解包5个值。如果你按照老代码跑新环境第一行就会报错。代码里我已经按新版API写好了但你自己改造时要注意这两个API的差异卡住半天想不通“为什么我的tuple解包不了”是新手期最常见的挫败感来源之一。最后再分享一个让训练可视化更清晰的小技巧。MountainCarContinuous-v0单轮只有1000步如果想看“智能体的能力上限”不要只看训练时打印的平均回报可以每训练完一定步数就单独跑一次无探索的评估回合——也就是把std强制设成0只取均值动作作为最终策略。这样能滤掉探索噪声看到策略的真实水平。我在很多次实验里发现训练曲线明明还处于波动中但评估策略已经能稳定登顶了两者之间有相当大的时间差学会区分“探索中的表现”和“真实策略表现”会少很多焦虑。这个项目作为PPO入门实战真的很合适状态维度低、收敛节奏肉眼可见、失败原因也容易定位。我自己当初通过这个小车环境彻底搞懂了clip、GAE、优势标准化这几个概念之后再看其它连续控制环境比如Ant、Hopper、HalfCheetah心里就有底了无非是状态维度变高、网络需要加深、超参数要重新微调而已。算法骨架完全一致那种“一通百通”的感觉就是在这样一个个小环境里堆出来的。
返回列表