Microduck 强化学习源码拆解:一只 800g 的机器鸭怎么学会走路

请添加图片描述

逐行读 pollen-robotics/microduck_rl 的训练配置、奖励函数、课程学习和域随机化,把 PPO 训练栈拆开看。


写在前面:这只鸭子用了什么

Microduck 是 Hugging Face × Pollen Robotics 在 2026 年 8 月开源的小型双足机器人:25cm 高、约 800g、15 个舵机。它的强化学习训练代码全部公开在 microduck_rl 仓库里。

技术栈一句话说完:

mjlab(MuJoCo Warp 的 GPU 并行后端)+ rsl_rl 的 PPO + BAM 舵机电机模型,在 4096 个并行环境里自举出一套步态。

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?请添加图片描述
origin_url=blog_assets%2Frl1_stack.png&pos_id=img-gmLax6mT-1789114306091)

它和大多数"开源机器人 + RL"项目的区别在于执行器保真度:不图省事用理想 PD 控制,而是把 Dynamixel XL330 这枚小舵机建到了电压控制律和摩擦模型的层面。原因写在 README 里,很直白:

At this scale — tiny servos driving a ~800 g biped — actuator fidelity is most of the sim2real gap.

先给一份代码地图,后面每一节都对应到具体文件:

文件职责本文关注度
tasks/microduck_velocity_env_cfg.py走路任务的主配置:奖励、观测、课程、域随机化开关、PPO 超参★★★
tasks/mdp.py自定义奖励/观测/课程函数实现(约 7200 行)★★★
tasks/symmetry.py左右镜像对称增强 / 镜像损失★★
robot/microduck_constants.py机器人配置、HOME 姿态、BAM 执行器参数★★
actuator/friction_dr_bam.pyBAM + 摩擦域随机化 + 齿隙编码器反馈★★
train_cli.py / train_hook.pytrain 入口与 --hf-jobs 拦截

一、算法:PPO 的快照

算法部分用 rsl_rl 的 PPO,配置在 MicroduckRlCfg 里。全部超参如下:

超参说明
clip_param0.2PPO 的招牌——限制每轮策略更新幅度
num_learning_epochs5同一批数据反复利用 5 遍
num_mini_batches4每遍切成 4 个 mini-batch
learning_rate1e-3,schedule="adaptive"按实测 KL 动态调,目标 desired_kl=0.01
gamma / lam0.99 / 0.95折扣因子 / GAE 优势估计的 λ
entropy_coef0.01熵正则,维持探索
max_grad_norm1.0梯度裁剪
value_loss_coef1.0,use_clipped_value_loss=True价值损失同样做裁剪
num_steps_per_env24每环境每次迭代采样 24 步
max_iterations50 000上限;实际 1~2 小时就出可用步态

关于 schedule="adaptive":这是 rsl_rl 的一个实用设计——它不按固定曲线衰减学习率,而是盯着 KL 散度自动调。KL 太大说明步子迈太猛,降学习率;太小说明学得慢,升学习率。对这种奖励尺度需要反复调整的任务,比手工设衰减曲线省心得多。

采样规模算一下:24 步 × 4096 环境 ≈ 每轮迭代 10 万步经验。官方标称 4096 环境在 4090 上约 1~2 小时收敛出一套能走的步态。


二、网络:非对称 Actor-Critic

这是这个项目里设计得最讲究的一块。

请添加图片描述

Actor 和 Critic 都是 512-256-128 的 MLP + ELU 激活,都开 obs_normalization=True。Actor 输出高斯分布的均值和标准差(init_std=1.0std_type="scalar",即所有动作维共享一个标准差),采样得到 14 维动作。

区别在观测:

ActorCritic
观测61 维(真机传感器给得出的量)61 维 + 基座线速度
额外特权base_lin_vel、脚部接触力/高度/腾空时间
是否部署✅ 导出成 ONNX 上机器人❌ 训练完丢弃

代码里的体现就三行:从 actor 观测里删掉 base_lin_vel,只往 critic 加回去。

# 观测裁剪
del cfg.observations["actor"].terms["base_lin_vel"]      # Actor 看不到线速度
del cfg.observations["actor"].terms["height_scan"]
del cfg.observations["critic"].terms["height_scan"]

# 特权信息只给 Critic
cfg.observations["critic"].terms["base_lin_vel"] = ObservationTermCfg(
    func=mdp.base_lin_vel, scale=1.0,
)

为什么这么做:机器人身上没有测量自身平移速度的传感器。如果训练时把线速度喂给 Actor,策略就会依赖一个部署时根本不存在的观测量——训练指标漂亮,上真机就崩。把这类信息交给 Critic 独占,价值估计更准(Critic 只是训练时的"老师"),而 Actor 学到的仍然是可以部署的映射。

同样的思路还体现在两处细节上:

  • IMU 安装误差随机化只作用于 Actorbase_ang_velprojected_gravity 会被一个随机轴、最大 6° 的旋转扰动——模拟真实 IMU 的安装偏差;Critic 拿到的仍是真值。
  • 编码器零位偏置只给 Actorjoint_pos 观测项有个 biased=True 开关,Actor 读到的是"带 ±0.015 rad 零点误差"的关节角(真编码器的样子),Critic 读真值。

三、61 维观测契约

所有策略——走路、起身、踢球、前滚翻——共用同一套 61 维观测布局。这是整个项目的架构基石。
请添加图片描述

索引字段维度内容
[0:3]base_ang_vel3IMU 角速度(roll/pitch/yaw)
[3:6]projected_gravity3重力在机体坐标系的投影 ≈ 姿态
[6:20]joint_pos_rel1414 个关节位置(相对默认姿态)
[20:34]joint_vel_rel1414 个关节速度
[34:48]last_action14上一帧动作
[48:51]twist 指令3前进 / 横移 / 转向
[51:55]head 指令4颈/头 4 自由度目标增量
[55:61]body 指令6躯干位姿增量 [x,y,z,roll,pitch,yaw]

关节顺序也是固定的:

0-4   左腿: hip_yaw, hip_roll, hip_pitch, knee, ankle
5-8   颈头: neck_pitch, head_pitch, head_yaw, head_roll
9-13  右腿: hip_yaw, hip_roll, hip_pitch, knee, ankle

为什么"固定布局"值得单独讲:18 个任务是各自独立训练的——一个策略只学走路,另一个只学前滚翻。但它们共用观测槽位,所以部署时可以在任意时刻热切换策略,机器人侧不需要改任何代码。用不到的指令维度补零而不是删掉,就是为了形状永远对齐。仓库里 scripts/infer_policy.py 演示的正是这件事:一条命令同时挂载走路/站立/坐站/前滚翻四个 ONNX,运行时按键切换。

观测里的三个"真实感"处理

这部分是 sim2real 的功夫所在,全在 microduck_velocity_env_cfg.py 的观测配置段:

1. 延迟建模。 真实舵机的编码器读数是滞后的,IMU 也不是瞬时。代码里显式配了延迟:

# IMU 通道:0~1 个控制周期延迟(原来配到 3,即最坏 60ms;2026-07 复核后收到 ±20ms)
cfg.observations["actor"].terms["base_ang_vel"].delay_max_lag = 1
cfg.observations["actor"].terms[gravity_term_name].delay_max_lag = 1

# 关节速度:固定延迟 1 个控制周期
# 原因:Dynamixel 固件用滑动平均算 present_velocity,策略读到的值就是"旧"的
cfg.observations["actor"].terms["joint_vel"].delay_min_lag = 1
cfg.observations["actor"].terms["joint_vel"].delay_max_lag = 1

注释里解释得很清楚:Dynamixel 固件通过一个移动平均窗口计算 present_velocity,所以策略实际读到的速度值大约滞后一个控制周期。如果不在仿真里建这个延迟,策略会学会依赖瞬时速度反馈,上真机就失效。

2. 观测噪声。 全部用均匀噪声,而且数值经过多轮收紧:

观测项噪声范围曾经的值
base_ang_vel±0.030.2
projected_gravity±0.010.15
joint_pos±0.0010.05
joint_vel±0.252.0

3. Critic 的 NaN 防护。 Critic 有三个观测项来自传感器(脚部接触力、脚部高度、脚部腾空时间)。这些数据 MuJoCo 有可能返回非有限值,而 nan_state 终止检查只看关节和根状态——inspector 不到它们。rsl_rl 的 check_nan 一旦发现 NaN 会让整个训练挂掉(代码注释里点名了这次事故:2026-08-21 Velocity2-Rough-Backlash crash)。于是给这三个项套了 NaN-safe 包装:

for _term, _safe in (
    ("foot_contact_forces", microduck_mdp.foot_contact_forces_safe),
    ("foot_height", microduck_mdp.foot_height_safe),
    ("foot_air_time", microduck_mdp.foot_air_time_safe),
):
    if _term in cfg.observations["critic"].terms:
        cfg.observations["critic"].terms[_term].func = _safe

因为是 critic-only,这一层清洗对策略毫无代价。


四、动作空间:14 维位置控制

动作就是 14 个关节的目标角度,位置控制,scale = 1.0(不做缩放)。

joint_pos_action = cfg.actions["joint_pos"]
joint_pos_action.scale = 1.0

一个容易忽略的点:机器人有 15 个舵机,但策略只输出 14 维动作。领部/嘴部连杆在 MJCF 模型里是被动关节,统一命名为 passive_*——它们不进动作空间,也不进关节观测。所有需要"受控关节"的地方都用同一个正则把它们排除掉:

joint_names=(r"^(?!passive_).*",)

passive_* 命名约定是有意为之的:齿隙铰链、轮滑轮子也都用这个前缀,凡是不受控的关节一律以此命名,选择器统一写成"非 passive"。


五、奖励函数:一张完整的权重表

走路任务(Velocity)的奖励是所有任务里最完整的。先看全景:

请添加图片描述

奖励项权重作用
air_time 步态腾空+3.0鼓励抬脚离地、正常摆步(窗口 0.125~0.300 s)
track_linear_velocity+2.0主目标:走多快听指令
track_angular_velocity+2.0主目标:转多快听指令
head_pose_tracking+2.0头部姿态跟踪(4 关节逐关节高斯)
upright 躯干直立+2.0刻意加强(详见下文)
pose 腿部姿态+1.0站立收紧、行走放宽
dof_pos_limits−1.0关节别顶到限位
self_collisions−1.0腿别砸到机身
foot_clearance 抬脚高度−2.0目标 2cm,防止拖脚
foot_swing_height 摆动高度−0.25目标 2cm
foot_slip 打滑−0.1刻意很弱
action_rate_l2 动作平滑−0.1 → −1.0课程式收紧
body_ang_vel−0.05抑制躯干乱转
angular_momentum−0.02抑制角动量堆积
body_pose_tracking0.0保留槽位,权重为零
head_pose_bias 低头偏置0 → 3.0后启用的 DC 项

奖励设计里最有价值的不是这张表,而是表背后的三个故事——每一条都写在代码注释里。

故事一:为什么打滑惩罚只有 −0.1

# foot_slip deliberately weak (-0.1, not -1.0): -1.0 was too restrictive
# for this robot's pivot-heavy turning.
cfg.rewards["foot_slip"].weight = -0.1

按常理,打滑是走路的敌人,惩罚应该给足。但这个机器人转身时依赖大量枢转动作——脚掌拧着地面转。打滑惩罚给到 −1.0 会把这些"必要的打滑"一并罚掉,策略学到的是不敢转身的步态。所以最终只给 −0.1。

反例同样有价值:foot_clearancefoot_swing_height 的处罚很重(−2.0 / −0.25),因为拖脚是完全没必要的坏习惯。该罚的罚狠,该放的放到最松——这条在 RL 奖励设计里比任何公式都重要。

故事二:为什么把"躯干直立"加重了一倍

# upright: deliberately strong (2.0 / std²=0.05, was 1.0 / std²=0.1).
# 2026-07 pitch-vs-speed eval: the policy walks with a +2-4° steady forward
# lean ... ~2/3 of push-induced falls at speed are FORWARD.
cfg.rewards["upright"].weight = 2.0
cfg.rewards["upright"].params["std"] = math.sqrt(0.05)

作者做了一次"前倾角 vs 速度"的评测,发现策略会以 +2~4° 的稳定前倾走路(90 分位到 6~8°),而推倒事故里有三分之二发生在正前方。用原来 1.0 的权重、std²=0.1 的容差算一下:4° 前倾每步只花掉约 0.05 的奖励——等于不要钱。

于是把权重翻倍、容差收紧一半(std² 0.1 → 0.05):同样 4° 前倾变成每步约 0.19 的成本,这个梯度足够让策略在稳态行走时把躯干端平,同时又允许加速、抗扰时的瞬时倾斜。这是典型的"用奖励梯度给姿态定精度"的手法。

故事三:让鸭子"罢工"的那次实验 —— head_pose_bias 的由来

这是全仓库最精彩的一段注释,值得原文引用:

# Head droop fix (2026-08-20). The head walks pitched ~15° down (measured:
# run ww1g2198 head_pose_tracking 1.544/2.0 → 14.6° mean joint error).
# DO NOT fix this by tightening head_pose_tracking's std: run 5yay13u4 tried
# fine_std=0.1 and the policy stopped walking entirely by iter 300 (air_time
# 1.01 → 0.02, peak foot height 15 mm → 2 mm, entropy collapsed 10.9 → 1.9).

情况是这样的:鸭子走路时头一直低着约 15°。很自然的想法是把头部跟踪奖励的容差收紧(fine_std=0.1),逼它把头抬起来。

结果——鸭子不走了。到第 300 轮迭代,腾空奖励从 1.01 掉到 0.02,抬脚高度从 15mm 掉到 2mm,熵从 10.9 崩到 1.9。原因算一下就明白:

一个 280g 的头(占机器人总质量 38%)走路时必然晃动。瞬时收紧的容差等于给"走路"这件事加了一道 0.77/步 的永久税——而腾空奖励总共才 1.01/步,税占 76%。更要命的是这笔税无法逃避,因为头必须摆。于是"站着不动"反而是得分最高的策略。策略没有犯错,它只是正确地找到了最优解。

修正办法很聪明:区分可逃避的直流偏置不可避免的振荡。低头是重力造成的稳态下垂,策略其实可以主动把脖子指令往上偏一点来抵消——这是可逃避的。而走路时的晃动是不可逃避的。所以只惩罚前者的时间平均值:

alpha = min(1.0, float(env.step_dt) / max(tau_s, 1e-6))
env._head_bias_ema = (1.0 - alpha) * env._head_bias_ema + alpha * err
out = -env._head_bias_ema.abs().mean(dim=-1)     # L1 惩罚 EMA 的绝对值

1 秒时间常数的 EMA 把振荡平均掉,只留下直流分量;惩罚用 L1 而不是高斯,因为 L1 在大误差处梯度恒定,不会像收紧的高斯那样"梯度死掉":

# L1 (not Gaussian) on purpose: the gradient stays constant at large bias,
# where a tight Gaussian would be flat and dead.

而且这个项的权重是课程式后启用的——前 600 轮迭代权重为 0,之后才从 1.0 升到 3.0。理由同样直白:

# Held at 0 early because a posture-precision term is a distraction before
# a gait exists.

步态还不存在的时候,去纠正姿态是纯粹的干扰。 先让它学会走,再让它走好看。


六、课程学习:五条自动加难曲线

请添加图片描述

课程学习的实现方式很朴素但很有效:同一个参数按训练进度分档推进mdp.py 里提供了几个通用的分档函数。

reward_weight——这是最基础的机制,遍历所有阶段、把"已经到达的阶段"的权重写到活着的 term 配置上:

def reward_weight(env, env_ids, reward_name, weight_stages):
    del env_ids
    term_cfg = env.reward_manager.get_term_cfg(reward_name)
    for stage in weight_stages:
        if env.common_step_counter > stage["step"]:
            term_cfg.weight = stage["weight"]
    return torch.tensor([term_cfg.weight])

注意一个坑:必须改 live 的 manager 配置,不能改 env.cfg。因为 manager 初始化时对 cfg 做了 deepcopy,改原始 cfg 是个静默的空操作。这条在 push_curriculumcom_range_curriculum 里都单独写了警告注释——说明作者踩过。

速度任务里实际启用了这几条曲线:

曲线起 → 止触发时机目的
动作平滑权重−0.1 → −1.0iter 0 → 1500(6 档)步态未成形时先松,成形后再收紧
站立环境占比2% → 25%iter 0 → 2000(6 档)先学会走,再学会站定
躯干质心随机化±3 → ±15 mmiter 0 → 1500(4 档)上限卡在脚掌支撑面内
头部指令范围5% → 100%iter 0 → 2000(5 档)逐档放大,按关节可达范围缩放
低头偏置惩罚0 → 3.0iter 600 → 1500(4 档)前 600 轮为 0,避免干扰学步
头部指令幅值±0.05 → ±1.10 rad同上neck_pitch 为例

一个"不做"的决定:指令范围不加宽

大部分 locomotion 项目会做速度指令范围课程——一开始只要求慢慢走,逐渐加大到快跑。Microduck 明确取消了这条

# Modest, FIXED command ranges (no widening curriculum): a ramp to
# lin ±0.4 / ang ±2.0 outpaced the robot's capability and tracked a
# post-iter-1000 reward/episode-length decline. ang ±1.0 is the big
# change — it makes turning learnable.
command.ranges.lin_vel_x = (-0.4, 0.4)
command.ranges.lin_vel_y = (-0.3, 0.3)
command.ranges.ang_vel_z = (-1.0, 1.0)      # 原来的 ±2.0 学不会

指令范围固定为 lin ±0.4 / ang ±1.0。原因:原来把角速度加宽到 ±2.0,超出了这个小机器人的实际能力,导致 1000 轮之后奖励和回合长度同步下滑——课程跑得比机器人的能力快了。收到 ±1.0 之后转向才真正学得会。

还有一个有意思的采样技巧:转身桶

TURN_IN_PLACE_FRACTION = 0.15
command.rel_turn_in_place_envs = TURN_IN_PLACE_FRACTION

独立均匀采样线速度和角速度时,“原地转圈”(lin=0|ang| 较大)的组合大约只占 2% 的数据——等于没训过。所以显式划出 15% 的环境专门做原地转身。


七、域随机化

这是 sim2real 的正面战场。开关集中在每个 env cfg 文件顶部,用 ENABLE_* 布尔量控制。

请添加图片描述

类别随机化项范围 / 备注
几何 / 质量躯干质心偏移±3 → ±15 mm(课程),上限受支撑面约束
头部组件质心偏移±3 → ±10 mm(课程),逐个头部刚体
质量 + 惯量±5%,用 pseudo_inertia 物理一致缩放
执行器电池电压6.5 ~ 8.2 V(每环境启动时采样)
负载压降压降系数 0 ~ 0.2,电压下限 6.0 V
关节摩擦预算×0.9 ~ 1.1(BAM 原生路径)
反射转子惯量±10%(armature)
指令延迟3 ~ 6 个仿真步
传感器IMU 安装误差随机轴,最大 6°
编码器零位偏置±0.015 rad(≈ ±0.86°),每环境恒定
关节速度延迟1 个控制周期
观测噪声见上一节表格
外部扰动随机推力每 3~6 s 施加 ±0.3 m/s 速度踢
脚底摩擦0.7 ~ 1.3
粗糙地形台阶 ≤1.5 cm、随机格子 ≤1 cm、坡度 1.7°~5.7°

同样地,调参的历史留在注释里,而且每一个数字都能讲出理由。

教训一:质心偏移的上限是被脚掌卡住的

# Capped at ±15 mm (2026-07 audit): the previous ramp to ±30 mm
# exceeded the foot support polygon (heel is only 20 mm behind
# the ankle) — the randomized CoM could sit entirely outside
# support, forcing a wide/fast hyper-reactive gait and making
# BACKWARD balance untrainable.

原来课程一路加到 ±30mm,结果发现脚后跟离踝关节只有 20mm——随机出来的质心可以整个跑到支撑多边形外面。后果不是"更难",而是"错":策略被逼成一种又宽又快的过度反应步态,向后平衡永远学不会。作者还给了回归时间线佐证:0.015 → 0.02 → 0.03 每加一次,策略就变差一档。最终封顶 ±15mm。

教训二:推力从 ±0.5 降到 ±0.3

VELOCITY_PUSH_RANGE = (-0.3, 0.3)  # Velocity change range in m/s. Was ±0.5 — an
# ADDITIVE kick larger than max walk speed (0.4) every 3-6 s trains a permanently
# nervous fall-recovery gait (2026-07 audit). ±0.3 keeps push robustness while
# letting a calmer gait be optimal.

原来每 3~6 秒施加 ±0.5 m/s 的增量踢——这比最大行走速度(0.4 m/s)还大!结果是训出一种"神经质"的、时刻准备摔倒恢复的步态。降到 ±0.3 后既保住了抗扰能力,又让更平静的步态成为最优解

这句话值得记住:随机化不是越大越好。超出合理范围,你训练出的不是鲁棒性,而是应激反应。

一个技术细节:非累积

域随机化有个经典陷阱——如果每回合直接往当前值上加偏移,误差会逐回合累积,跑几十轮之后参数就飘到天边了。作者为此专门写了一段说明:

# In mjlab 1.3.0 the stock dr.* ops with operation="add"/"scale" read from the
# compile-time default field each reset (Operation.uses_defaults=True), so they
# are NON-accumulating natively — this upstream behavior replaces microduck's old
# custom restore-then-add functions that worked around the accumulation footgun.

mjlab 1.3.0 的 dr.* 操作每次重采样都从编译期默认值出发,天然非累积,所以自己写的那套"先还原再加偏移"的祖传代码可以删了。


八、执行器:BAM M6,为什么不用理想 PD

这是全项目我最想推的一节。
请添加图片描述

典型的 RL 机器人项目里,执行器就是一个理想 PD 控制器:τ = kp·(q_target − q) − kd·(dq/dt),给多少力矩就有多少力矩,瞬时响应、没有上限。对大型机器人(比如宇树那类)这没问题,因为电机足够强,PD 假设基本成立。

但 Microduck 是 800g 的双足靠 15 个小舵机驱动——这枚 Dynamixel XL330 每一档都在它的物理极限附近工作。于是项目用了 BAM 的 M6 模型,把执行器建到了电压控制律级别:

_BAM_ACTUATOR_KWARGS = dict(
    motor_name="xl330",
    model="m6",
    target_names_expr=(r"^(?!passive_).*",),
    kp_fw=200.0,              # 保留固件的刚度(对比:microban 用 125)
    vin_range=(6.5, 8.2),     # 每环境采样的电池电压
    vin_drop_gain_range=(0.0, 0.2),  # 负载相关压降 V_drop = gain * Σ|τ|
    vin_min=6.0,              # 压降后的电压下限
    delay_min_lag=3,
    delay_max_lag=6,
)

也就是说,仿真里输入的是电压而不是力矩,中间环节全建了:

  • 反电动势——转速越高,可用力矩越低;
  • 库仑 + Stribeck + 负载相关摩擦——不是常数,是模型;
  • 负载下的电压压降——电池不是理想电源,用劲大了电压就掉,vin_min=6.0 是硬下限。

摩擦随机化要绕个弯

BAM 自己算摩擦,所以 MuJoCo 的 dof_frictionlossedit_spec 里被清零了——标准的 dr.dof_frictionloss 在这里是个空操作。项目为此写了一个薄子类:

class FrictionDRBamActuator(BamActuator):
    """BamActuator + per-env friction_scale on the BAM friction budget."""

    def _compute_friction_budget(self, motor_torque, external_torque, stribeck_coeff):
        base = super()._compute_friction_budget(motor_torque, external_torque, stribeck_coeff)
        fs = getattr(self, "friction_scale", None)
        return base if fs is None else base * fs      # 每环境缩放

只缩放速度无关的摩擦预算(库仑 + Stribeck + 负载相关)——因为那一项才是"静摩擦/齿轮箱"这个 sim2real 主要不确定性的载体。

齿隙:编码器装在间隙的输出侧

每个主任务都有一个 -Backlash 孪生版本,训练模型里给 14 个舵机各串一个 ±1° 的被动铰链(总计 2° 行程)。关键在于位置:

class BacklashEncoderBamActuator(FrictionDRBamActuator):
    """FrictionDRBamActuator whose firmware PD reads the encoder THROUGH backlash."""

真实编码器装在齿轮间隙的输出侧,所以固件 PD 和关节位置观测都要"读穿"间隙:qpos[舵机] + qpos[间隙]。这一点如果不建,后果很微妙——头部可以靠着间隙下垂却不受任何惩罚,而策略如果主动去补偿它,反而因为在舵机侧产生了"误差"而被罚。观测和动作维度都不变,所以 ONNX 导出和运行时完全不需要改。


九、任务家族:18 个策略共享一个大脑

仓库注册的任务(uv run list-envs 可列出实时注册表):

任务地形说明
Mjlab-Velocity-{Flat,Rough}-MicroDuck平/崎岖主任务:速度指令 + 头部姿态指令
Mjlab-VelStand-{Flat,Rough}-MicroDuck平/崎岖走路 + 摔倒恢复,一个策略
Mjlab-StandUp-{Flat,Rough}-MicroDuck平/崎岖从趴着/仰着/坐着起身,再保持站立
Mjlab-SitStand-{Flat,Rough}-MicroDuck平/崎岖坐下 ↔ 站起,一个策略
Mjlab-GroundPick-{Flat,Rough}-MicroDuck平/崎岖蹲下用喙触地,再站起来
Mjlab-BallKick-Flat-MicroDuck踢 70mm/15g 的球(Actor 看不见球
Mjlab-Roulade-Flat-MicroDuck前滚翻过顶,落回脚上
Mjlab-Velocity-Flat-MicroDuck-Rollers轮滑速度跟踪(脚下是被动轮)
Mjlab-Velocity-Swizzle-MicroDuck对称 swizzle 滑行
Mjlab-RollerCrouch-Flat-MicroDuck滑行中蹲低
Mjlab-RollerSlope-Flat-MicroDuck轮滑下坡
Mjlab-RollerStandUp-Flat-MicroDuck从地面站到轮子上
Mjlab-Spin-Flat-MicroDuck轮滑原地高速旋转

一共 18 个基础任务 id(13 个任务家族),每个主任务还有一个 -Backlash 孪生变体(在任务名里插入 -Backlash,如 Mjlab-Velocity-Flat-Backlash-MicroDuck)。它们全部共享那套 61 维观测契约——这是整个"18 个策略共用一只鸭子"的架构前提。


十、如果你想自己动手

训练命令(在 microduck_rl 目录下):

# 主任务:走路(4096 环境,4090 上约 1~2 小时出可用步态)
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096

# 冒烟测试:验证环境能跑通
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 64 --agent.max_iterations 5

没有 GPU 怎么办:任意训练命令后面加 --hf-jobs,会自动提交到 Hugging Face Jobs(默认 L4 显卡)上跑,本地零 GPU 需求。

几个常用的改动入口:

想改什么改哪里
奖励权重microduck_velocity_env_cfg.pycfg.rewards[...]
观测内容同文件的 cfg.observations["actor"/"critic"].terms
域随机化开关文件顶部的 ENABLE_* 布尔量
课程曲线节点同文件 cfg.curriculum[...]*_stages
网络结构 / PPO 超参文件末尾的 MicroduckRlCfg
舵机模型参数robot/microduck_constants.py_BAM_ACTUATOR_KWARGS

另外注意:导出的 ONNX 一定要用 scripts/export.py 生成的,因为导出器会把观测归一化器烘焙进计算图里——手工转换的 checkpoint 会让策略在运行时读到未归一化的观测。


结语:这个仓库最值得学的三件事

  1. 奖励设计是"反直觉"的。打滑惩罚要刻意调弱(否则学不会转身)、姿态惩罚要刻意调强(否则前倾摔倒)、而"提高精度"这种看起来永远正确的操作,可能直接让策略罢工。判据只有一条:这个惩罚可不可逃避?不可逃避的惩罚,加多少都是在教机器人躺着不动。

  2. 课程学习的关键是知道何时"不加难"。五条曲线都是先松后紧,头部精度项甚至先关掉 600 轮。而速度指令范围明确不加宽——因为课程跑得比机器人能力快,只会训出崩坏的策略。Sim2real 的随机化同理:±0.5 的推力比最大步速还大,训出来的不是鲁棒,是应激。

  3. 保真度花在刀刃上。算力有限时,不要均匀地提升所有仿真细节——这个项目把预算全押在执行器建模上(电压控制律、反电动势、摩擦、齿隙),因为这是"小舵机驱动小机器人"场景下 sim2real 差距的主要来源。其余部分(视觉、复杂地形)反而克制甚至不做。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐