强化学习实操:microduck环境搭建与PPO训练部署全流程
强化学习这个词听起来唬人但真正上手跑通第一个环境的人都知道最折磨人的往往不是算法推导而是环境装不上、模型导不出、仿真跑不起来这些脏活。我这次拿microduck做强化学习实操从零把环境搭到策略收敛中间踩的坑比看论文多得多。microduck本身是个轻量级的四足机器人仿真模型配合MuJoCo物理引擎做训练整个链路涉及PyTorch建模、ONNX导出、MuJoCo仿真配置这几块。这篇内容适合两类人一是刚入门强化学习、想找个能跑通的最小闭环练手的二是已经在做仿真训练、但卡在环境配置或模型部署环节的。我会把每一步为什么这么做、参数怎么定、哪里容易翻车都讲清楚代码和配置可以直接抄。1. 为什么选microduck而不是直接上Ant或Humanoid1.1 microduck的定位与适用边界microduck在强化学习圈子里不算主流大部分人入门用的是OpenAI Gym里的Ant、HalfCheetah或者Humanoid。但那些环境有个共同问题状态空间和动作空间太大训练一个能看的策略动辄几百万步普通机器跑起来电费都心疼。microduck的优势在于它的关节数量少、观测维度低一个四足模型大概12个自由度左右观测向量控制在40维以内动作空间也就12维连续值。这意味着你在单张消费级显卡上几个小时就能看到策略从随机抖动变成有意义的步态。我选它还有一个原因microduck的URDF模型结构清晰没有那些花里胡哨的传感器和执行器嵌套。你打开XML文件一看就知道每个关节对应哪个body改起来方便。很多复杂环境里你想调一个关节的阻尼参数得在几百行XML里翻半天microduck没这个问题。但要注意microduck不适合做高精度控制研究。它的碰撞模型比较粗糙足端和地面的接触检测用的是简化几何体如果你要做足式机器人的精确力控或者复杂地形适应还是得换更精细的模型。它的定位就是强化学习算法验证平台不是机器人控制研究平台。1.2 和MuJoCo的搭配逻辑MuJoCo作为物理引擎核心优势是接触求解的稳定性和速度。它用的是软约束接触模型相比Bullet或者PhysX在足式机器人这种频繁接触的场景下数值稳定性好很多。我实测过同样的microduck模型在MuJoCo里跑100万步训练中间基本不会出现仿真爆炸换到PyBullet偶尔会出现关节速度突变导致训练崩溃。MuJoCo的另一个好处是它的mj_step函数是确定性的同样的初始状态和动作序列跑出来的轨迹完全一致。这对强化学习很重要因为你需要可复现的实验结果。有些引擎带随机噪声调参的时候你分不清是算法改进了还是运气好。安装MuJoCo这块Windows 11上现在比之前方便多了。官方从2.3版本开始提供了pip安装包直接pip install mujoco就行不需要再手动配环境变量。但有个坑如果你的Python是3.11以上得确认mujoco版本对应老版本可能没有预编译的wheel。我建议用Python 3.10兼容性最稳。1.3 训练框架的选择为什么用PyTorch而不是TensorFlow强化学习社区现在基本倒向PyTorch了原因很实际动态图调试方便。你在写PPO或者SAC的时候经常需要打印中间张量的值来排查问题PyTorch的eager模式直接print就行TensorFlow 1.x那种静态图得开session跑效率差太多。而且microduck的模型定义很简单就是一个几层的MLP输入观测输出动作均值和对数标准差。PyTorch定义这种网络几行代码搞定没必要上复杂的框架。我下面给的代码都是基于PyTorch的版本建议2.0以上因为要用到torch.compile加速推理虽然对这个小模型提升有限但训练循环里累积起来也能省不少时间。2. 环境搭建从零到跑通第一个step2.1 MuJoCo安装的版本陷阱Windows 11上装MuJoCo最容易出问题的是版本匹配。我列一下我实测通过的组合组件版本备注Python3.10.113.11以上部分wheel缺失mujoco3.1.3pip直接安装gymnasium0.29.1注意不是旧版gymtorch2.1.2cu121有GPU用GPU版numpy1.24.32.0以上有兼容问题安装命令很简单pip install mujoco3.1.3 gymnasium0.29.1 torch2.1.2 numpy1.24.3但这里有个隐藏坑gymnasium和旧版gym的API不兼容。很多网上找的强化学习代码用的是gym.make(Ant-v2)这种旧接口你直接跑会报错。gymnasium里改成了gymnasium.make(Ant-v4)而且step()返回的是5个值obs, reward, terminated, truncated, info不是旧的4个值。这个改动影响很大你的训练循环得跟着改。另一个常见问题是MuJoCo的渲染。如果你需要可视化训练过程得装mujoco的渲染依赖。Windows上建议用pip install mujoco自带的渲染不需要额外装OpenGL。但如果报GLFW相关错误可以试pip install glfw。我一般训练的时候不开渲染太拖速度等策略训练好了再开渲染看效果。2.2 microduck模型文件的获取与检查microduck的XML文件网上有几个版本我建议用GitHub上维护比较活跃的那个仓库。下载下来后先别急着跑训练用MuJoCo自带的viewer检查一下模型能不能正常加载import mujoco import mujoco.viewer model mujoco.MjModel.from_xml_path(microduck.xml) data mujoco.MjData(model) mujoco.viewer.launch(model, data)如果能看到一个四足机器人站在地面上说明模型文件没问题。如果报错常见原因有两个一是XML里引用的mesh文件路径不对二是MuJoCo版本和XML的schema版本不匹配。前者改路径后者在XML头部把mujoco modelmicroduck改成mujoco modelmicroduck version3.1试试。检查模型的时候重点看几个东西关节数量、执行器数量、以及option标签里的时间步长。microduck的时间步长一般是0.002秒也就是500Hz。这个值影响训练稳定性太大容易仿真爆炸太小训练慢。如果你发现训练过程中关节角度突变可以试着把时间步长降到0.001。2.3 自定义Gym环境的封装gymnasium的环境封装有个固定套路我直接给一个microduck的模板import gymnasium as gym import numpy as np import mujoco class MicroduckEnv(gym.Env): def __init__(self): self.model mujoco.MjModel.from_xml_path(microduck.xml) self.data mujoco.MjData(self.model) self.action_space gym.spaces.Box( low-1.0, high1.0, shape(self.model.nu,), dtypenp.float32 ) obs_dim self.model.nq self.model.nv self.observation_space gym.spaces.Box( low-np.inf, highnp.inf, shape(obs_dim,), dtypenp.float32 ) def reset(self, seedNone): super().reset(seedseed) mujoco.mj_resetData(self.model, self.data) # 加一点随机初始状态增加探索多样性 self.data.qpos[:] np.random.uniform(-0.05, 0.05, self.model.nq) mujoco.mj_forward(self.model, self.data) return self._get_obs(), {} def step(self, action): self.data.ctrl[:] action mujoco.mj_step(self.model, self.data) obs self._get_obs() reward self._compute_reward() terminated self._check_done() truncated False return obs, reward, terminated, truncated, {} def _get_obs(self): return np.concatenate([self.data.qpos, self.data.qvel]).astype(np.float32) def _compute_reward(self): # 前进速度奖励 姿态惩罚 forward_vel self.data.qvel[0] height self.data.qpos[2] height_penalty abs(height - 0.3) * 2.0 return forward_vel - height_penalty def _check_done(self): height self.data.qpos[2] return height 0.1 or height 0.6这个环境封装里_compute_reward是最需要调的部分。我一开始只用了前进速度做奖励结果训练出来的策略是机器人直接往前扑倒因为扑倒的瞬间速度最大。后来加了高度惩罚强制它保持站立姿态才慢慢学会走路。这个经验很关键奖励函数设计不好算法再强也白搭。3. 算法实现PPO在microduck上的调参实录3.1 为什么选PPO而不是SAC或TD3PPO、SAC、TD3都是连续控制的主流算法但在microduck这个场景下我推荐PPO。原因有三个第一PPO对超参数没那么敏感你学习率设3e-4还是1e-3结果不会差到天上去第二PPO的on-policy特性让它训练过程更稳定不容易出现Q值高估导致的策略崩溃第三PPO的实现相对简单你不需要维护经验回放池代码量少一半。SAC虽然样本效率高但它对温度参数的调节很敏感alpha设不好策略会过早收敛到局部最优。TD3的双Q网络和延迟更新虽然能缓解高估但调参复杂度更高。对于microduck这种状态空间不大的任务PPO的样本效率劣势不明显但稳定性优势很突出。3.2 网络结构别搞太复杂microduck的观测维度大概40维动作12维这种规模的问题两层256的MLP足够了。我见过有人上ResNet或者Transformer纯属杀鸡用牛刀训练慢不说还容易过拟合。import torch import torch.nn as nn class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.shared nn.Sequential( nn.Linear(obs_dim, 256), nn.Tanh(), nn.Linear(256, 256), nn.Tanh(), ) self.actor_mean nn.Linear(256, act_dim) self.actor_logstd nn.Parameter(torch.zeros(act_dim)) self.critic nn.Linear(256, 1) def forward(self, obs): features self.shared(obs) mean self.actor_mean(features) std self.actor_logstd.exp() value self.critic(features) return mean, std, value激活函数用Tanh不用ReLU这是强化学习里的惯例。ReLU在负半轴梯度为零容易导致策略网络输出饱和。Tanh虽然也有饱和问题但它的输出范围是[-1,1]和动作空间匹配初始化的时候不需要额外缩放。actor_logstd初始化成0也就是std1。这个值在训练初期提供足够的探索噪声随着训练进行网络会自己学出合适的std。我试过手动设std0.1结果探索不足策略卡在局部最优出不来。3.3 PPO的核心参数与调参逻辑PPO有几个关键参数我直接给一组在microduck上实测能收敛的配置参数值说明learning_rate3e-4Adam优化器gamma0.99折扣因子lambda0.95GAE参数clip_epsilon0.2策略裁剪范围epochs10每批数据训练轮数batch_size64小批量大小rollout_steps2048每次采集步数entropy_coef0.01熵奖励系数value_coef0.5价值损失系数gamma0.99意味着智能体关注未来100步左右的回报。microduck走一步大概0.002秒100步就是0.2秒这个时间尺度足够它学会一个完整的步态周期。如果你发现策略太短视可以调到0.995如果训练不稳定降到0.98试试。clip_epsilon0.2是PPO的默认值一般不用改。这个值控制新旧策略的差异上限太小学习慢太大策略更新过猛容易崩。我试过0.3训练前期确实快但后期策略震荡明显。entropy_coef0.01是鼓励探索的。microduck的动作空间是连续的如果熵系数太低策略会很快收敛到确定性动作失去探索能力。但也不能太高0.05以上会导致策略一直随机抖动学不会稳定步态。3.4 训练循环中的关键细节PPO的训练循环有个容易忽略的点优势函数的归一化。GAE算出来的优势值量级可能差很多如果不做归一化价值网络的损失会忽大忽小训练不稳定。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages [] gae 0 next_value 0 for t in reversed(range(len(rewards))): if dones[t]: next_value 0 gae 0 delta rewards[t] gamma * next_value - values[t] gae delta gamma * lam * gae advantages.insert(0, gae) next_value values[t] returns [adv val for adv, val in zip(advantages, values)] advantages torch.tensor(advantages) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) return advantages, torch.tensor(returns)这个归一化步骤我一开始漏了结果训练曲线跟心电图似的上下乱跳。加上之后loss曲线平滑多了。另一个细节是学习率衰减。PPO在训练后期需要更小的学习率来精细调整策略。我用的是线性衰减从3e-4降到1e-5训练步数设100万步。如果你发现策略在后期还在震荡可以试试余弦退火。4. 从训练到部署ONNX导出与推理验证4.1 为什么要把策略网络导出成ONNX训练的时候用PyTorch没问题但部署的时候你不可能带着整个PyTorch环境跑。ONNX的好处是跨平台你可以在C、Python、甚至嵌入式设备上加载同一个模型文件。microduck这种小模型导出成ONNX之后大概几百KB推理延迟在1毫秒以内。导出的时候有个坑策略网络输出的是动作分布的均值和标准差但部署的时候你只需要均值确定性策略。所以导出前要把网络改一下去掉std输出class PolicyOnly(nn.Module): def __init__(self, actor_critic): super().__init__() self.shared actor_critic.shared self.actor_mean actor_critic.actor_mean def forward(self, obs): features self.shared(obs) return torch.tanh(self.actor_mean(features))注意最后的torch.tanh这是把动作限制在[-1,1]范围内。训练的时候这个tanh是在采样函数里做的导出的时候得显式加进网络。4.2 ONNX导出的参数设置import torch.onnx policy PolicyOnly(actor_critic) policy.eval() dummy_input torch.randn(1, obs_dim) torch.onnx.export( policy, dummy_input, microduck_policy.onnx, input_names[observation], output_names[action], dynamic_axes{ observation: {0: batch_size}, action: {0: batch_size} }, opset_version17 )opset_version17是必须的低版本不支持dynamic_axes里的batch维度。dynamic_axes的作用是让模型支持变长batch部署的时候你可以一次推理多个观测。导出之后一定要验证别导完就不管了import onnxruntime as ort import numpy as np sess ort.InferenceSession(microduck_policy.onnx) test_obs np.random.randn(1, obs_dim).astype(np.float32) onnx_action sess.run(None, {observation: test_obs})[0] with torch.no_grad(): torch_action policy(torch.tensor(test_obs)).numpy() print(最大误差:, np.abs(onnx_action - torch_action).max())误差应该在1e-5以下如果大了检查是不是漏了某个操作或者opset版本不对。4.3 ONNX量化INT8到底值不值得ONNX Runtime支持INT8量化能把模型大小压缩到原来的四分之一推理速度提升2-3倍。但microduck这个模型本来就小量化带来的收益有限反而可能损失精度。我实测过INT8量化后策略在仿真里的表现明显变差走路变得踉踉跄跄。原因是量化误差在连续控制任务里会被放大动作的小偏差累积起来导致步态不稳定。所以我的建议是除非你要部署到算力极其有限的设备上否则别做INT8量化。FP16量化倒是可以试试精度损失小速度也有提升。如果非要做INT8用ONNX Runtime的量化工具from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( microduck_policy.onnx, microduck_policy_int8.onnx, weight_typeQuantType.QInt8 )注意这是动态量化只量化权重不量化激活值精度损失相对小一些。静态量化需要校准数据集对强化学习策略来说校准数据的分布很难覆盖所有可能的状态容易出问题。5. 训练不收敛时的排查链路5.1 先看仿真是不是炸了训练不收敛第一件事不是调算法参数而是检查仿真数据。在训练循环里加一段日志每隔1000步打印一次关节角度和速度的最大值if step % 1000 0: print(fqpos max: {np.abs(data.qpos).max():.3f}, fqvel max: {np.abs(data.qvel).max():.3f})如果qvel超过100基本可以确定仿真炸了。原因通常是动作输出太大或者时间步长设置不当。解决办法是在动作输出后加clipaction np.clip(action, -1.0, 1.0)别小看这个clip我有一次忘了加训练了三天才发现仿真早就炸了白跑。5.2 奖励曲线的三种异常形态奖励曲线能告诉你很多信息我总结三种常见异常第一种是奖励一直不涨平得像条直线。这通常是探索不足策略陷入了局部最优。解决办法是增大entropy_coef或者给动作加高斯噪声。第二种是奖励先涨后崩。这是策略崩溃的典型表现通常是学习率太大或者clip_epsilon太大。把学习率降一个数量级试试。第三种是奖励震荡剧烈上下跳动。这往往是优势函数没归一化或者batch_size太小导致梯度估计方差大。增大batch_size到128或256。5.3 策略可视化别只看数字数字指标有时候会骗人。我遇到过一次奖励曲线一直在涨但打开渲染一看机器人是在用肚子滑行根本不是走路。原因是我的奖励函数只奖励前进速度没惩罚身体接触地面。所以训练过程中一定要定期开渲染看。我一般每10万步开一次渲染跑个几百步看看策略的实际表现。MuJoCo的viewer支持实时交互你可以用鼠标拖拽视角从侧面看步态是否自然。如果发现策略在作弊比如用奇怪姿势获取奖励就得回去改奖励函数。强化学习里有个说法叫奖励黑客就是策略找到了你没想到的方式来最大化奖励但并不是你真正想要的行为。6. 一些让训练更顺的实操技巧6.1 观测归一化用RunningMeanStdmicroduck的观测里位置和速度的量级差很多。位置大概在0.1到1之间速度可能到10以上。如果不做归一化网络训练时梯度会被大量级特征主导小量级特征学不到东西。用RunningMeanStd做在线归一化class RunningMeanStd: def __init__(self, shape): self.mean np.zeros(shape) self.var np.ones(shape) self.count 1e-4 def update(self, x): batch_mean x.mean(axis0) batch_var x.var(axis0) batch_count x.shape[0] delta batch_mean - self.mean total_count self.count batch_count self.mean delta * batch_count / total_count m_a self.var * self.count m_b batch_var * batch_count M2 m_a m_b delta**2 * self.count * batch_count / total_count self.var M2 / total_count self.count total_count def normalize(self, x): return (x - self.mean) / np.sqrt(self.var 1e-8)这个类会动态更新均值和方差训练初期统计不准没关系跑几千步之后就稳定了。归一化后的观测值范围大概在[-3,3]之间网络训练稳定很多。6.2 并行环境加速采样PPO是on-policy算法需要大量采样。单环境采样太慢可以用向量化环境并行跑多个实例。gymnasium提供了SyncVectorEnv和AsyncVectorEnv前者同步执行后者用多进程。from gymnasium.vector import SyncVectorEnv def make_env(): return MicroduckEnv() envs SyncVectorEnv([make_env for _ in range(8)])8个并行环境采样速度大概提升6倍有通信开销。注意并行环境里每个环境的随机种子要不一样否则采到的数据高度相关训练效果差。6.3 检查点保存与恢复训练过程中一定要定期保存检查点不然跑到一半断电或者程序崩了几天的训练白费。我一般每10万步保存一次torch.save({ model_state_dict: actor_critic.state_dict(), optimizer_state_dict: optimizer.state_dict(), step: step, running_mean_std: (rms.mean, rms.var, rms.count) }, fcheckpoint_{step}.pt)恢复的时候把running_mean_std也加载回来不然归一化统计从头开始策略表现会突然变差。6.4 关于microduck夹爪任务的扩展有人问过microduck能不能做夹爪抓取任务。可以但需要改模型文件在足端或者身体前方加一个夹爪关节。MuJoCo里夹爪的接触检测比较麻烦因为夹爪和物体的接触点会变化需要设置合适的接触参数。我的建议是如果你要做抓取别用microduck换Franka或者UR5这些专门为操作任务设计的模型。microduck的优势在移动不在操作。强行改会造成模型复杂度上升训练难度大增得不偿失。7. 从仿真到现实的差距与应对7.1 域随机化让策略更鲁棒仿真里训练好的策略直接搬到真机上往往表现很差因为仿真和现实的物理参数有差异。域随机化是常用的应对手段在训练时随机化一些物理参数比如质量、摩擦系数、关节阻尼。def randomize_domain(model): # 随机化身体质量±10% for i in range(model.nbody): model.body_mass[i] * np.random.uniform(0.9, 1.1) # 随机化关节阻尼±20% for i in range(model.nv): model.dof_damping[i] * np.random.uniform(0.8, 1.2)每次reset的时候调用一次这样策略见到的物理参数分布更广泛化能力更强。但随机化范围不能太大否则策略学不到有效行为。我一般从±5%开始逐步增加到±20%。7.2 观测噪声注入真机上的传感器有噪声仿真里没有。为了缩小差距训练时给观测加高斯噪声obs self._get_obs() obs np.random.normal(0, 0.01, obs.shape)噪声标准差根据传感器精度定IMU一般0.01左右关节编码器0.001。加噪声之后策略会对观测扰动更鲁棒。7.3 动作延迟模拟真机上从发出控制指令到执行器响应有延迟仿真里是即时的。可以在环境里加一个动作缓冲区延迟几帧再执行self.action_buffer.append(action) if len(self.action_buffer) self.delay_steps: actual_action self.action_buffer.pop(0) else: actual_action np.zeros_like(action)延迟步数根据实际系统定一般2-5步。这个改动会让训练变慢但迁移到真机时效果更好。8. 关于microduck训练的一些个人体会microduck这个平台我用了大概半年最大的感受是它适合验证算法但不适合刷榜。如果你要发论文比性能还是得用标准环境。但如果你是想理解强化学习的每个环节从环境封装到算法实现到部署导出microduck是个很好的练手对象。训练过程中最耗时的不是写代码而是调奖励函数。我前后改了十几版奖励才让机器人走出比较自然的步态。这个过程没有捷径就是多看渲染、多分析数据、多试。ONNX导出这块我建议训练完就导别等到要部署了才弄。导出过程中可能会发现网络里有不支持的操作早点发现早点改。另外导出的模型一定要用ONNX Runtime验证一遍确保推理结果和PyTorch一致。最后说一个容易忽略的点随机种子。强化学习对随机种子很敏感同一个算法换个种子结果可能差很多。我一般至少跑3个种子取平均性能。如果三个种子的结果方差很大说明算法不稳定得回去检查超参数。