ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

在MuJoCo中实现PPO:从环境安装到调参的完整指南

2026/9/12 14:17:13 拓冰建站 浏览量
在MuJoCo中实现PPO:从环境安装到调参的完整指南 简介面向希望在Mujoco物理仿真环境中实践强化学习的开发者这份资源提供了基于PyTorch的PPO算法实现覆盖Ant-v2、Humanoid-v2、Hopper-v2、HalfCheetah-v2等常见连续控制任务。压缩包共13个文件大小仅598KB包含4个Python脚本主程序、模型定义、参数配置与PPO核心逻辑、4张训练效果示意图、3份运行日志、1份README说明及1个Hopper-v2相关数据文件代码结构清晰便于按模块阅读和复用。已有1807人浏览学习适合正在入门或调试PPO算法的学生与研究人员。通过阅读README可快速掌握各环境下的启动方式结合日志和图像可直观对比不同超参数如clip系数、beta值对训练曲线的影响是一份轻量且完整的PPO算法学习与实验参考。1. 在 MuJoCo 里跑 PPOAnt-v2 到 Humanoid-v2 的差距比想象中大很多人把 PPO 代码从仓库里 clone 下来在 HalfCheetah-v2 上跑出漂亮曲线然后原封不动换到 Ant-v2 或 Humanoid-v2却发现 reward 曲线不动甚至 loss 直接变成 NaN。反直觉的结论是问题很少出在 PPO 本身而是出在 MuJoCo 物理引擎的仿真特性和 v2 环境的 reward 结构上——提前终止、接触力观测、健康奖励这几个细节决定了同一套超参数在四个环境里截然不同的表现。这篇文章按「环境安装 → 算法设计 → 完整实现 → 评估调参」的顺序把在 mujoco 环境下实现 PPO 的通用做法讲清楚并把 Ant-v2、Hopper-v2、HalfCheetah-v2、Humanoid-v2 各自要改的参数单独列出。第一次写连续动作策略的人能照抄写过 PPO 但换环境就调不动的人可以拿它对照检查。2. MuJoCo 环境安装与 v2 注册Ubuntu 22.04、Windows 11 和版本匹配先说一个最容易踩的坑Ant-v2、Hopper-v2、HalfCheetah-v2、Humanoid-v2 并不是新版 mujoco python 包自带的。它们属于 gym 的经典 MujocoEnv 环境族背后是 OpenAI 时代的 mujoco-py 绑定库DeepMind 后来发布的mujoco包对应的是 gymnasium 里的 v3/v4 环境。装之前先决定走哪条路线要的是指定环境名 v2就走 mujoco-py 老路线只是想把 PPO 在 MuJoCo 物理引擎上跑起来新版mujoco gymnasium 完全够用算法代码一字不用改只需把 done 拆成 done 和 truncated。MuJoCo 物理引擎本身从 2.x 到 3.x 改过接触求解器和渲染接口gym 0.26 之后又换了step的返回结构这两层版本叠在一起就是「安装 mujoco 常见问题」里大半报错的来源。所以我先给出一条我在 Ubuntu 22.04 上反复验证过的安装路径。2.1 Ubuntu 22.04 上的 mujoco 安装和部署经典 v2 路线的四个步骤# 1. 系统依赖mujoco-py 编译时需要 OSMesa 和 OpenGL 头文件 sudo apt update sudo apt install -y libosmesa6-dev libgl1-mesa-dev libglfw3 libglfw3-dev patchelf # 2. 下载 mujoco210 的 Linux 二进制并解压到 ~/.mujoco/mujoco210 # 目录里需要有 bin/、include/、model/ 三个子目录解压后不要移动 # 3. 安装绑定库和 gym注意 v2 环境要配 gym 0.21.x pip install gym0.21.0 mujoco-py2.1.2.14 # 4. 运行时环境变量建议写进 ~/.bashrc export MUJOCO_PY_MUJOCO_PATH$HOME/.mujoco/mujoco210 export LD_LIBRARY_PATH$LD_LIBRARY_PATH:$HOME/.mujoco/mujoco210/bin四步的职责分别是第一步解决编译期依赖mujoco-py 是 Cython 写的它要链接 OSMesa 来做离屏渲染缺了libosmesa6-dev会在 import 阶段报GL相关错误第二步的 mujoco210 是 DeepMind 发布的最后一个 2.x 二进制包mujoco-py 的 2.1.x 长期用它做后端版本不能换成 3.x否则 API 对不上第三步里gym0.21.0的注册表里才有带-v2后缀的环境新版 gym 已经把 mujoco 环境整组移除第四步的MUJOCO_PY_MUJOCO_PATH是 mujoco-py 找物理库的位置不导出会在运行时抛mujoco_py.mujoco_py.MjLoadError。Windows 11 上我分两种情况处理。明确要 v2 环境的话建议直接在 Windows 11 里开 WSL2装 Ubuntu 22.04 后按上面命令走最省时间只是要跑 PPO可以原生pip install mujoco gymnasium然后gymnasium.make(Ant-v4)物理引擎是同一个只是环境名从 v2 换成了 v4。2.2 冒烟测试确认环境注册与物理步进真的能跑装完别急着写算法先用一段脚本把四个环境的注册、观测维度和随机策略步进都验一遍import gym for env_name in [Ant-v2, Humanoid-v2, Hopper-v2, HalfCheetah-v2]: env gym.make(env_name) obs env.reset() total_reward 0.0 for _ in range(100): action env.action_space.sample() obs, reward, done, info env.step(action) total_reward reward if done: break print(f{env_name}: obs{obs.shape}, act{env.action_space.shape}, f100步随机return{total_reward:.2f}, done{done}) env.close()这段脚本的价值在于把「环境问题」和「算法问题」在第一时间切开。obs的 shape 要和标准值对齐Ant-v2 是 (111,)Humanoid-v2 是 (376,)Hopper-v2 是 (11,)HalfCheetah-v2 是 (17,)对不上说明你拿到的不是经典 v2 环境而是别的版本。done在 100 步内就变 True 是正常的Ant 和 Humanoid 随机策略很容易摔倒。随机策略 100 步 return 为负也是正常的接触惩罚和控制代价会让前期回报很难看不要据此怀疑环境装坏了。2.3 四个 v2 环境的观测、动作与奖励差异一张表看明白gym 0.21 注册表里这四个环境的默认配置差异很大我把关键项列出来后面所有调参都围绕这张表展开环境观测维度动作维度健康奖励提前终止条件对 PPO 影响最大的点HalfCheetah-v2176无无仅 1000 步超时奖励无上界控制代价权重 0.1Hopper-v2113每步 1跌倒或躯干角度越界z 不在 0.7~1.2、角度超 ±0.2奖励被健康项垫底前期不会太负Ant-v21118每步 1z 不在 0.2~1.0观测含 80 维接触力量纲很大Humanoid-v237617每步 5躯干 z 不在 1.0~2.0 等终止频繁17 维力矩输出易发散这里有几个细节值得展开。第一HalfCheetah 是四个环境里唯一没有终止条件的它只在 1000 步被 TimeLimit 截断所以 GAE 里所有 done 都按「超时」处理即可其余三个环境都有健康终止必须区分「摔倒」和「超时」。第二Ant 和 Humanoid 的观测里包含接触力向量量级和关节角的量级差出几个数量级如果不做观测归一化PPO 的价值网络前几千步基本在学噪声。第三v2 和后续 v3/v4 的差异主要在 reward 组成v3 起对接触惩罚做过调整如果你在网上找到的基线代码用的是 v3/v4直接套到 v2 上奖励曲线会不一样这是版本问题不是算法问题。2.4 mujoco 安装常见问题动态库、渲染后端与注册失败排查我自己在不同机器上踩过的问题按现象和修法列成一张速查表报错或现象原因处理方式import 时ImportError: libGL.so.1缺 OpenGL 动态库sudo apt install libgl1-mesa-dev libgl1mujoco-py 编译报找不到glfw.h缺 GLFW 开发头文件sudo apt install libglfw3-dev运行时MjLoadError: could not open mujoco210MUJOCO_PY_MUJOCO_PATH没指向 mujoco210 根目录检查路径必须直接包含bin/mujoco210和include/gym.error.NamespaceNotFound: Ant-v2gym 版本过新v2 注册表被移除降到gym0.21.0并确认import mujoco_py不报错训练时渲染黑屏或报 GL 初始化失败无头服务器的渲染后端不对mujoco-py 用MUJOCO_GLosmesa新版 mujoco 用MUJOCO_GLegl最后补一句如果import mujoco_py本身成功但gym.make仍然找不到 v2 环境最常见的原因是 mujoco-py 的 import 在 gym 注册时失败被静默跳过了。先在 Python 里单独执行import mujoco_py把这一层查干净再动 gym。3. PPO 的连续动作三件套高斯策略、GAE 和 clip 边界环境装好后我们来把 PPO 在 MuJoCo 上能跑起来依赖的三个机制讲透高斯策略给出可微的 log 概率GAE 用 TD 误差递推压低方差clip 目标限制单步更新幅度。这三个设计任何一个写错reward 曲线都不会好看。这一章讲原理第四章给完整代码。3.1 高斯策略均值头加独立 log_stdppo 连续动作代码讲解的标准结构离散动作的 PPO 用 categorical 分布在动作空间上直接取概率连续动作环境里动作是一个向量策略必须是一个分布最常用的是各维度独立的高斯分布即π(a|s) N(μ(s), σ²)。μ 由网络输出σ 有两种接法一种是从网络再分出一个头输出状态相关的方差另一种是做一个独立的log_std参数对所有状态共享。我在 MuJoCo 任务上几乎总是用第二种。原因有三个参数少状态无关的 σ 不会因为某条异常状态把方差顶到爆炸探索强度全局可控想调探索只需改一个向量训练初期更稳状态相关的方差头在小样本下容易对刚见过的状态过拟合造成动作越界。这也是网上 ppo 连续动作代码讲解里最常见的写法self.mean_head nn.Linear(hidden, act_dim) self.log_std nn.Parameter(torch.full((act_dim,), -0.5)) def dist(self, obs): hidden self.trunk(obs) mean self.mean_head(hidden) std self.log_std.exp().expand_as(mean) return torch.distributions.Normal(mean, std)log_std初始化为 -0.5 对应 σ≈0.6初始探索既不会太保守也不会太猛。注意正态分布采样理论上无界而 MuJoCo 环境的action_space已经约束在 [-1, 1]所以env.step之前必须np.clip(action, low, high)否则仿真求解器会拿到越界力矩。这一步是后面 NaN 的头号来源务必养成习惯。计算 log 概率时用log_prob(action).sum(-1)因为各动作维是独立的联合概率的对数是各维之和。3.2 GAE 与 v2 环境的终止语义摔倒和超时必须分开处理GAEGeneralized Advantage Estimation的核心公式是δ_t r_t γ·V(s_{t1}) − V(s_t)A_t Σ_{k≥0} (γλ)^k · δ_{tk}直观理解δ 是单步 TD 误差把未来 K 步的 TD 误差按(γλ)^k衰减叠加λ 在偏差和方差之间做权衡。λ0 退化成一步 TD方差小但偏差大λ1 等价于蒙特卡洛 return偏差小但方差大。MuJoCo 任务里 γ0.99、λ0.95 是几乎不动摇的默认值。真正的坑在 done 的语义。gym 0.21 里这组环境的doneTrue分两种情况摔倒导致的健康终止和达到 1000 步的 TimeLimit 截断。如果都把 done 当成真实终止最后一步的 value 不做 bootstrap价值网络会系统性低估后续回报reward 曲线尾部掉头如果都当成截断去 bootstrap摔倒后那个并不存在的未来会被 value 网络学进去损失函数噪声显著变大。正确做法是从info里把截断标志取出来单独判断def compute_gae(rewards, falls, values, last_value, gamma0.99, lam0.95): falls[i]1 表示第 i 步是真实终止摔倒超时不算 fall。 advantages torch.zeros_like(values) gae 0.0 for t in reversed(range(len(rewards))): if t len(rewards) - 1: next_value last_value else: next_value values[t 1] delta rewards[t] gamma * next_value * (1 - falls[t]) - values[t] gae delta gamma * lam * (1 - falls[t]) * gae advantages[t] gae returns advantages values return advantages, returns采样时用truncated info.get(TimeLimit.truncated, False)然后fall done and not truncated再把 fall 存进缓冲区。代码里(1 - falls[t])的作用是真实终止时把 next_value 和后续 GAE 全部置零也就是不 bootstrap超时则正常往后传。GAE 要从 buffer 尾部往前算因为每一步的 advantage 依赖未来的 δ方向反了结果全错。用 gymnasium 的话done和truncated是step的两个独立返回值逻辑更直白但算法代码里那一处(1 - falls)依然要保留。3.3 clip 目标与 dual-clip ppo当 ratio 撞上边界PPO 的策略损失是L E[min(ratio·A, clip(ratio, 1−ε, 1ε)·A)]其中ratio π_new(a|s) / π_old(a|s)ε 默认 0.2。min 的作用是当优势为正、新策略把某动作概率抬高到 ratio 超过 1.2 时梯度不再继续放大这一步防止单次更新把策略推过头优势为负时同理限制惩罚幅度。clip 的作用本质上是给每次参数更新上一道保险这也是 PPO 相比 TRPO 更实用的原因——不要显式求 KL只约束 surrogate。在这基础上还有一个变体叫 dual-clip ppo它专门处理负优势侧的极端样本。标准 clip 在优势为负时只限制 ratio 的上界 1ε但如果某条 transition 的 reward 方差极大ratio 在另一侧也可能异常负优势配上极小 ratio 会让 surrogate 出现一个假的高值。dual-clip 的做法是给负优势侧再加一个更宽的下界 c 参与裁剪l1 ratio * adv l2 torch.clamp(ratio, 1.0 - clip, 1.0 clip) * adv if dual_clip: pg_loss -(torch.where(adv 0, torch.min(l1, l2), torch.max(l1, l2))).mean() else: pg_loss -torch.min(l1, l2).mean()优势为正取 min、为负取 max这样两个方向的梯度都被限制在裁剪区间内。我一般只在 reward 方差大的环境比如 HalfCheetah-v2单步 reward 幅度受速度影响很大里把 dual_clip 打开代价是收敛略慢但曲线更稳Ant-v2 和 Hopper-v2 用不用差别不大。另外训练时把ratio.mean()打出来如果它持续明显偏离 1说明 clip 边界被频繁触发学习率应该减半而不是去调 ε。3.4 网络规模与初始化为什么两层 256 足够这四个环境里观测维度最大的是 Humanoid-v2 的 376 维但也完全不需要 RNN 或 Transformer两层 tanh MLP 就够了。我用的是hidden256、两层nn.Linear nn.Tanh的共享主干策略头输出均值价值头输出标量。真正影响训练的是初始化策略均值头的权重用方差 0.01 级别的小初始化让初始动作靠近 0价值头同样小初始化避免初始 value 估计过大把 GAE 的第一轮 δ 撑爆。观测归一化对 Ant-v2 和 Humanoid-v2 是必须的对 Hopper-v2 和 HalfCheetah-v2 可做可不做。原因是后两者的观测主要是关节角和角速度量纲一致前两者的观测里混着接触力数值比关节量大两个数量级。实现时维护一个 running mean/std对 obs 做(obs − mean) / (std 1e-6)并 clamp 到 ±10注意统计量只能用训练数据更新eval 时用训练末期冻结的统计量。这套「MuJoCo 物理引擎 高斯策略 归一化」的组合不止适用于这四个标准环境四足、双足乃至扫地机器人的运动规划策略迁移走的都是同一条 pipeline。4. 一个能跑的 PPO 实现采样循环、GAE 与 dual-clip 更新这一章给出完整可抄的实现片段。结构上分四块网络、缓冲区与采样、GAE 与更新循环、参数表。把代码按顺序拼进一个文件Ant-v2 和 Hopper-v2 可以直接训练Humanoid-v2 记得加上观测归一化和更小的学习率。4.1 数据流总览一次 update 在做什么一个 update 分两个阶段。采样阶段用当前策略跑 N 条 transitionN num_envs × steps_per_env把 obs、action、logp、value、reward、fall 存进缓冲区学习阶段先用 GAE 算整段 advantage再打散成 minibatch 做 k_epoch 轮梯度更新。MuJoCo 的单次 step 开销在毫秒级单个环境采 2048 步要十几秒所以采样阶段我一般开 4~8 个并行环境这既是速度问题也是方差问题——多条独立轨迹平均后的 advantage 比单条轨迹平滑得多。做并行环境用SubprocVecEnv或 gym 的AsyncVectorEnvWindows 上 fork 容易出问题就用SyncVectorEnv把并行数调小。4.2 网络定义共享主干加两个头import torch import torch.nn as nn from torch.distributions import Normal class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim, hidden256): super().__init__() self.trunk nn.Sequential( nn.Linear(obs_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), ) self.mean_head nn.Linear(hidden, act_dim) self.log_std nn.Parameter(torch.full((act_dim,), -0.5)) self.value_head nn.Linear(hidden, 1) # 小初始化初始动作靠近 0避免物理仿真被大力矩打散 nn.init.orthogonal_(self.mean_head.weight, gain0.01) nn.init.orthogonal_(self.value_head.weight, gain1.0) def dist(self, obs): h self.trunk(obs) mean self.mean_head(h) std self.log_std.exp().expand_as(mean) return Normal(mean, std) def step(self, obs): d self.dist(obs) action d.sample() logp d.log_prob(action).sum(-1) value self.value_head(self.trunk(obs)).squeeze(-1) return action, logp, value def evaluate(self, obs, action): d self.dist(obs) logp d.log_prob(action).sum(-1) value self.value_head(self.trunk(obs)).squeeze(-1) return logp, value, d.entropy().sum(-1)log_std是nn.Parameter不随输入变化所以evaluate阶段重算 logp 时用的是更新后的 log_std。step用于采样evaluate用于更新阶段两个方法必须分开因为采样阶段不能对网络求梯度、也不能重新采样动作。熵项在evaluate里直接由分布算出连续动作任务我一般把熵系数设 0靠log_std自身调节探索强度。4.3 采样循环缓冲区与截断标志的记录class RolloutBuffer: def __init__(self): self.obs, self.actions, self.logps [], [], [] self.rewards, self.falls, self.values [], [], [] def store(self, obs, action, logp, reward, fall, value): self.obs.append(obs); self.actions.append(action) self.logps.append(logp); self.rewards.append(reward) self.falls.append(float(fall)); self.values.append(value) def get(self): return (torch.tensor(self.obs, dtypetorch.float32), torch.tensor(self.actions, dtypetorch.float32), torch.tensor(self.logps, dtypetorch.float32), torch.tensor(self.rewards, dtypetorch.float32), torch.tensor(self.falls, dtypetorch.float32), torch.tensor(self.values, dtypetorch.float32)) buffer RolloutBuffer() obs env.reset() for _ in range(steps_per_env): with torch.no_grad(): action, logp, value ac.step(torch.as_tensor(obs, dtypetorch.float32)) action_clip np.clip(action.numpy(), env.action_space.low, env.action_space.high) obs_next, reward, done, info env.step(action_clip) truncated info.get(TimeLimit.truncated, False) fall done and not truncated buffer.store(obs, action.numpy(), logp.item(), reward, fall, value.item()) obs obs_next if not done else env.reset()这里有几处必须按这个写法。第一action_clip在env.step前存进 buffer 的也是裁剪后的动作因为更新阶段要重算这个动作在新策略下的 logp如果存的是未裁剪动作logp 和实际执行的策略对不上第二fall用done and not truncated计算采样阶段就把终止语义分好类GAE 阶段就不用再猜第三done之后要立刻env.reset()续上采样否则缓冲区里会混入跨 episode 的 transitionGAE 计算会错位。4.4 GAE 与更新循环advantage 归一化、value clip 和梯度裁剪def compute_gae(rewards, falls, values, last_value, gamma0.99, lam0.95): advantages torch.zeros_like(values) gae 0.0 for t in reversed(range(len(rewards))): if t len(rewards) - 1: next_value last_value else: next_value values[t 1] delta rewards[t] gamma * next_value * (1 - falls[t]) - values[t] gae delta gamma * lam * (1 - falls[t]) * gae advantages[t] gae return advantages, advantages values obs, actions, old_logps, rewards, falls, old_values buffer.get() advantages, returns compute_gae(rewards, falls, old_values, last_value) for _ in range(k_epochs): indices torch.randperm(len(obs)) for mb in indices.split(minibatch_size): old_logp_mb old_logps[mb] adv_mb advantages[mb] adv_mb (adv_mb - adv_mb.mean()) / (adv_mb.std() 1e-8) logp_new, value_new, entropy ac.evaluate(obs[mb], actions[mb]) ratio (logp_new - old_logp_mb).exp() l1 ratio * adv_mb l2 torch.clamp(ratio, 1.0 - clip, 1.0 clip) * adv_mb if use_dual_clip: pg_loss -(torch.where( adv_mb 0, torch.min(l1, l2), torch.max(l1, l2))).mean() else: pg_loss -torch.min(l1, l2).mean() value_clip old_values[mb] (value_new - old_values[mb]).clamp(-0.2, 0.2) value_loss torch.max( (value_new - returns[mb]).pow(2), (value_clip - returns[mb]).pow(2)).mean() loss pg_loss 0.5 * value_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(ac.parameters(), max_grad_norm0.5) optimizer.step()这段代码里有三个细节值得说明。第一advantage 的归一化在 minibatch 内部做均值减掉、标准差除掉这等价于给策略损失加了一个自适应学习率比手动调lr更有效标准差的1e-8是为了防止 buffer 里全是相同 reward 时除零。第二value loss 用 clip 后的目标取 max和策略的 clip 同理不允许价值网络单次更新跑太远这个设计能明显抑制 reward 曲线的震荡。第三clip_grad_norm_设 0.5MuJoCo 任务的梯度经常有尖峰不裁剪的话一次 update 就能把 log_std 打到 NaN。4.5 四个 v2 环境的参数表从 Hopper 起步把 Humanoid 留到最后环境lrbuffer/updatek_epochsminibatchobs 归一化建议总步数Hopper-v23e-4204810256不需要3e5~5e5HalfCheetah-v23e-4409610256可选1e6Ant-v23e-4409610256建议2e6Humanoid-v21e-4~3e-4409610256必须5e6~1e7公共参数γ0.99、λ0.95、clip0.2、Adam 的 eps 取 1e-5。选 Hopper-v2 当第一个实验对象是有意的它维度低、终止条件明确、两三百万步内就能看到明显上升任何实现上的 bug 都会在曲线形态上暴露出来。HalfCheetah-v2 没有提前终止reward 数值大但不稳定适合验证 dual-clip 和 reward 归一化的效果。Ant-v2 开始接触力进入观测这时必须确认归一化代码没有污染训练统计量。最后才是 Humanoid-v2它需要 5e6 起步的步数预算单机 CPU 训练经常要跑到过夜建议先在小步数上确认 loss 不 NaN、explained variance 在上升再放长跑。5. Humanoid-v2 的 PPO 验证与调参eval、NaN 排查和最后一个技巧训练脚本能跑通只算完成一半剩下的一半是让结果可信。这一章讲我验证一个 PPO 实现是否真的学会了的三个手段。5.1 用确定性策略评估而不是盯训练 reward 曲线训练阶段的 reward 是带探索采样出来的单条曲线方差很大Humanoid-v2 上相邻两次 update 的 return 能差出 50%直接盯训练曲线很容易误判。我每 25 次 update 跑一次确定性评估采样时取均值动作而不是重采样跑 10 个 episode 记录 mean±stddef evaluate(ac, env_name, episodes10): env gym.make(env_name) returns [] for _ in range(episodes): obs env.reset() total 0.0 while True: with torch.no_grad(): d ac.dist(torch.as_tensor(obs, dtypetorch.float32)) action d.mean # 确定性动作 obs, reward, done, _ env.step(action.numpy()) total reward if done: break returns.append(total) env.close() return float(np.mean(returns)), float(np.std(returns))d.mean代替d.sample()就是确定性评估的全部区别。eval return 的方差主要来自随机初始状态10 个 episode 的 mean±std 足够画出可信的带状图。Humanoid-v2 的 eval return 从 800 爬到 3000 这个过程是平滑的如果 eval 曲线长时间不动而训练 reward 在涨说明策略停留在局部最优附近问题出在探索而不是拟合。5.2 NaN 与动作越界的排查顺序训练中途 loss 变 NaN我按下面的顺序查能覆盖九成情况。第一步查物理仿真打印env.unwrapped.sim.data.qpos如果 qpos 本身就是 NaN说明不是 PPO 的问题而是给 MuJoCo 物理引擎的力矩越界导致接触求解发散先检查是否每个 step 都做了np.clip再把初始log_std调小到 -1.0。第二步查log_std如果它发散到 1 以上std 就会指数膨胀动作接近随机噪声此时给log_std加clamp(-20, 2)并确认 advantage 归一化没写错。第三步查归一化的 running 统计量obs 里一旦混入 infmean/std 会被污染后面所有 batch 的输入都不正常解决办法是 obs 进 buffer 前做一次torch.isfinite检查。第四步查 value loss如果它震荡幅度超过 pg_loss 一个数量级把 lr 减半再把梯度裁剪降到 0.5。5.3 最后一个技巧把熵、ratio 均值和 explained variance 写进日志reward 曲线只能告诉你「好不好」告诉不了你「哪一侧出了问题」。我在每个 update 里额外记三个字段with torch.no_grad(): entropy ac.dist(obs).entropy().mean().item() explained_var 1 - (returns - old_values).var() / (returns.var() 1e-8) mean_ratio (logp_new - old_logps).exp().mean().item()读法如下explained_var接近 1 说明价值网络对 return 的预测能力很强接近 0 或为负说明 value 还没跟上这个时候别急着调策略侧参数先加大 value loss 权重或多训几个 epochentropy掉到 0.3 以下且 eval return 不再增长说明策略过早确定性化把初始log_std抬高或加一个小的熵系数mean_ratio持续偏离 1 说明 clip 边界频繁被触发策略单步更新过猛lr 减半比调 clip 有效。把这三个字段和 eval return 放进同一张 CSV训练结束时用双 y 轴画出来eval_return一旦跌破前几个百分位先看explained_var和mean_ratio再动参数这比盯着 reward 曲线猜要快得多。本文还有配套的精品资源点击获取