Microduck 强化学习源码拆解:一只 800g 的机器鸭怎么学会走路
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 个并行环境里自举出一套步态。

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.py | BAM + 摩擦域随机化 + 齿隙编码器反馈 | ★★ |
train_cli.py / train_hook.py | train 入口与 --hf-jobs 拦截 | ★ |
一、算法:PPO 的快照
算法部分用 rsl_rl 的 PPO,配置在 MicroduckRlCfg 里。全部超参如下:
| 超参 | 值 | 说明 |
|---|---|---|
clip_param | 0.2 | PPO 的招牌——限制每轮策略更新幅度 |
num_learning_epochs | 5 | 同一批数据反复利用 5 遍 |
num_mini_batches | 4 | 每遍切成 4 个 mini-batch |
learning_rate | 1e-3,schedule="adaptive" | 按实测 KL 动态调,目标 desired_kl=0.01 |
gamma / lam | 0.99 / 0.95 | 折扣因子 / GAE 优势估计的 λ |
entropy_coef | 0.01 | 熵正则,维持探索 |
max_grad_norm | 1.0 | 梯度裁剪 |
value_loss_coef | 1.0,use_clipped_value_loss=True | 价值损失同样做裁剪 |
num_steps_per_env | 24 | 每环境每次迭代采样 24 步 |
max_iterations | 50 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.0,std_type="scalar",即所有动作维共享一个标准差),采样得到 14 维动作。
区别在观测:
| Actor | Critic | |
|---|---|---|
| 观测 | 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 安装误差随机化只作用于 Actor。
base_ang_vel和projected_gravity会被一个随机轴、最大 6° 的旋转扰动——模拟真实 IMU 的安装偏差;Critic 拿到的仍是真值。 - 编码器零位偏置只给 Actor。
joint_pos观测项有个biased=True开关,Actor 读到的是"带 ±0.015 rad 零点误差"的关节角(真编码器的样子),Critic 读真值。
三、61 维观测契约
所有策略——走路、起身、踢球、前滚翻——共用同一套 61 维观测布局。这是整个项目的架构基石。

| 索引 | 字段 | 维度 | 内容 |
|---|---|---|---|
[0:3] | base_ang_vel | 3 | IMU 角速度(roll/pitch/yaw) |
[3:6] | projected_gravity | 3 | 重力在机体坐标系的投影 ≈ 姿态 |
[6:20] | joint_pos_rel | 14 | 14 个关节位置(相对默认姿态) |
[20:34] | joint_vel_rel | 14 | 14 个关节速度 |
[34:48] | last_action | 14 | 上一帧动作 |
[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.03 | 0.2 |
projected_gravity | ±0.01 | 0.15 |
joint_pos | ±0.001 | 0.05 |
joint_vel | ±0.25 | 2.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_tracking | 0.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_clearance 和 foot_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_curriculum、com_range_curriculum 里都单独写了警告注释——说明作者踩过。
速度任务里实际启用了这几条曲线:
| 曲线 | 起 → 止 | 触发时机 | 目的 |
|---|---|---|---|
| 动作平滑权重 | −0.1 → −1.0 | iter 0 → 1500(6 档) | 步态未成形时先松,成形后再收紧 |
| 站立环境占比 | 2% → 25% | iter 0 → 2000(6 档) | 先学会走,再学会站定 |
| 躯干质心随机化 | ±3 → ±15 mm | iter 0 → 1500(4 档) | 上限卡在脚掌支撑面内 |
| 头部指令范围 | 5% → 100% | iter 0 → 2000(5 档) | 逐档放大,按关节可达范围缩放 |
| 低头偏置惩罚 | 0 → 3.0 | iter 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_frictionloss 在 edit_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.py 的 cfg.rewards[...] |
| 观测内容 | 同文件的 cfg.observations["actor"/"critic"].terms |
| 域随机化开关 | 文件顶部的 ENABLE_* 布尔量 |
| 课程曲线节点 | 同文件 cfg.curriculum[...] 的 *_stages |
| 网络结构 / PPO 超参 | 文件末尾的 MicroduckRlCfg |
| 舵机模型参数 | robot/microduck_constants.py 的 _BAM_ACTUATOR_KWARGS |
另外注意:导出的 ONNX 一定要用 scripts/export.py 生成的,因为导出器会把观测归一化器烘焙进计算图里——手工转换的 checkpoint 会让策略在运行时读到未归一化的观测。
结语:这个仓库最值得学的三件事
-
奖励设计是"反直觉"的。打滑惩罚要刻意调弱(否则学不会转身)、姿态惩罚要刻意调强(否则前倾摔倒)、而"提高精度"这种看起来永远正确的操作,可能直接让策略罢工。判据只有一条:这个惩罚可不可逃避?不可逃避的惩罚,加多少都是在教机器人躺着不动。
-
课程学习的关键是知道何时"不加难"。五条曲线都是先松后紧,头部精度项甚至先关掉 600 轮。而速度指令范围明确不加宽——因为课程跑得比机器人能力快,只会训出崩坏的策略。Sim2real 的随机化同理:±0.5 的推力比最大步速还大,训出来的不是鲁棒,是应激。
-
保真度花在刀刃上。算力有限时,不要均匀地提升所有仿真细节——这个项目把预算全押在执行器建模上(电压控制律、反电动势、摩擦、齿隙),因为这是"小舵机驱动小机器人"场景下 sim2real 差距的主要来源。其余部分(视觉、复杂地形)反而克制甚至不做。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)