本期我自己动手跑通的部分:拿到公开的 b11 篮球策略、导出 ONNX、做 PyTorch/ONNX 逐步一致性、
生成自由球上的平衡视频、复现两种评测协议,并跑了一轮 5 次 PPO 更新的续训 smoke。
所有数字都来自本机实跑,末尾单列"这次没证明什么"。
环境:Ubuntu 22.04 云容器 / RTX 4080 SUPER 32G / Python 3.12.3 / 50 Hz 控制
代码:Vottivott/microduck-playground@aa5bd7909873a807fb9f4045f491519c443142e7(= HF 清单里的training.commit)
模型:公开 checkpointb11_6999,sha25657a322eff092cb71e7cba831791232f0cc4fd45ede0441cad6d87813b0033e41
一、先把"接口"搞清楚,再谈算法
很多人一上来就看 PPO 超参,但这个任务最难也最值钱的是接口——它是一份契约。
| 观测(actor,61 维) | 维度 | 训练时的 term |
|---|---|---|
| 身体角速度(陀螺仪) | 3 | base_ang_vel |
| 重力在身体坐标系下的投影 | 3 | projected_gravity |
| 14 个关节位置 | 14 | joint_pos |
| 14 个关节速度 | 14 | joint_vel |
| 上一时刻14 维动作 | 14 | actions |
| 前向 / 横向 / 偏航速度指令 | 3 | command(twist) |
| head_command 零填充 | 4 | basketball_head_pad |
| body_command 槽(actor 填 0) | 6 | basketball_body_pad |
动作是 14 维关节位置残差:下发目标 = 默认站姿 + 1.0 × 动作,再裁剪到关节行程,50 Hz 下发;
部署端没有动作低通滤波(平滑只体现在训练的action_rate_l2奖励里)。
三个容易忽略的细节:
51:61是占位槽。它原本服务 head/body 姿态任务,篮球任务里 actor 固定填 0——留着不删,是为了让 61 维契约和别的任务完全对齐,从而复用整条部署链路(ONNX 导出、BPU 推理、50 Hz 循环都不用改)。- 动作是相对默认站姿的归一化残差,不是绝对关节角。所以同一个 actor 换个默认站姿就能站到别的东西上。
- 导出 ONNX 时,动作上限裁剪和关节行程裁剪都被烘焙进图里(
bake_action_clip/bake_action_bounds),部署端拿到的是同一份带裁剪的策略。
关节顺序(ONNX 元数据,和训练一致):left_hip_yaw, left_hip_roll, left_hip_pitch, left_knee, left_ankle, neck_pitch, head_pitch, head_yaw, head_roll, right_hip_yaw, right_hip_roll, right_hip_pitch, right_knee, right_ankle
二、为什么这个策略必须"带记忆"
actor 的输入里没有球——球在哪、滚多快,它看不见。这叫 blind actor。
但单帧状态不足以判断下一步:球往哪边滚、脚底在球面哪个位置接触,藏在"最近几十个控制步身体姿态怎么变"里。
所以网络结构是:
61D 本体感觉 → LSTM(256) → ELU MLP(512 → 256 → 128) → 14 维动作训练时critic 是 privileged 的:它输入 76 维,多出来的正是球相对位置/速度(占body_command那个 6 维槽,
位置 ×3.0、速度 ×1.0)、脚部接触高度/时间/力,以及 base 线速度。
导出 ONNX 只保留 actor,所以真机不需要摄像头,也不需要球检测器。
我一开始把 “blind” 理解成"没记忆",这是错的。blind 说的是不看球;LSTM 的
h/c就是记忆。
ONNX 契约(我们自己导出的图,opset 18,16 个节点,2.50 MB,sha2568b1c8149…):
inputs : obs[1,61], h_in[1,1,256], c_in[1,1,256] outputs: actions[1,14], h_out[1,1,256], c_out[1,1,256]h_out/c_out必须传给下一帧;只在启动、策略切换、摔倒恢复、episode reset 时清零。
普通的速度指令变化不能清记忆,否则下一次踩到球面时策略会失去对球滚动趋势的估计。
三、奖励设计的关键:别把目标写成惩罚
训练日志里打印出的 13 项奖励,是理解这个任务行为的钥匙:
| 奖励项 | 权重 | 作用 |
|---|---|---|
track_linear_velocity | +1.0 | 速度跟踪(“在球上走”) |
track_angular_velocity | +0.5 | 偏航跟踪 |
pose | +1.0 | 保持站姿 |
body_ang_vel | −0.05 | 抑制身体乱转 |
angular_momentum | −0.02 | 抑制角动量堆积 |
dof_pos_limits | −1.0 | 关节别顶限位 |
action_rate_l2 | −0.2 | 动作变化惩罚 |
self_collisions | −1.0 | 自碰撞 |
upright | +1.0 | 身体直立 |
centered | +2.0 | 根部留在球心上方(权重最高) |
feet_on_ball | +1.0 | 双脚在球面合理接触高度 |
height | +1.0 | 站在球顶,别蹲下也别陷进去 |
ball_speed | −0.05 | 球速正则,小权重 |
最反直觉的一条:球速只做很小的负项(−0.05),"滚动"交给速度跟踪去驱动。
如果反过来把球速当主要负项,策略最容易学到的行为是"僵在原地不动"——它确实不滚了,但也就不走了。
课程与随机化也值得记:训练用BallHoldAction从1 → 0.5 → 0.25 → 0.1 → 0.03 → 0逐步松开球
(1 相当于焊死,0 是完全自由),play/eval 默认 0;interval 事件push_robot每 1.5–3.0 s 推一次机器人
——这正是 b11 相对 b10 的唯一改动,却把 60 秒生存率从 92.19% 提到 97.85%。
四、实操:把环境搭干净比跑模型难
uv sync --locked在这台机器上只有 ~1 MB/s,而且卡在全平台索引解析上久久不动。最后用的办法是绕开实时解析:
# 1) 从提交的 uv.lock 离线导出精确版本清单(1119 行,带 sha256)uvexport--frozen--no-dev --no-emit-project--formatrequirements-txt-opins.txt# 2) 按清单直连国内镜像下载轮子(140 个 / 4.2 GB / 45 秒 / 逐个校验 sha256)# 关键:--frozen 会按 lock 里的 files.pythonhosted.org 地址下载(~0.4 MB/s),# 必须改成直连镜像,否则会以为"uv 很慢",其实是源不对# 3) 离线安装,再 editable 装项目uv pipinstall--no-index --find-links<wheels>-rpins.txt uv pipinstall--no-deps-e.两个坑值得单独记:
- 大文件限速:镜像对单连接的大文件(torch 858 MB)会压到 ~0.25 MB/s,但它支持 Range 请求——
分 8 块并行下载再按序拼接、最后校验 sha256,几分钟就完了。 - 代理是把双刃剑:学术代理能过 GitHub / HuggingFace,但会让国内镜像从 14 MB/s 掉到 0.2 MB/s。
所以"下载镜像用直连、拉 GitHub 用代理"要分开设置(git 可单独git config --global http.proxy ...)。
还有一条更重要的教训:别把"我这边能跑"当成"可复现"。我一度在 main HEAD 上跑出结果,
后来发现发布清单里写的是training.commit,于是重新钉到aa5bd79重跑全链路,
并确认该提交的uv.lock与专题代码包的uv.lock字节一致(sha256affaa39d…),才敢说环境与发布口径一致。
五、结果:复核到了官方量级
HF 仓库里只有checkpoint.pt,没有policy.onnx,所以 ONNX 是我们自己导出的——这反而让链路更完整。
| 检查项 | 结果 |
|---|---|
| ONNX 导出 | opset 18、16 节点、obs[1,61]+h/c → actions[1,14]+h/c、2.50 MB、sha2568b1c8149… |
| PyTorch ↔ ONNX 逐步一致性(40 步,第 20 步含一次 reset) | 最大绝对误差1.430511474609375e-06 |
官方validation.json记录的同项检查 | 1.430511474609375e-06(完全一致) |
| 本地 ONNX Runtime(CPU)全 0 观测 | 通过,输出形状正确且全部有限 |
| 256 环境 × 30 s(seed 101,command_scale 1) | 10 s100%· 20 s99.61%· 30 s98.83% |
| 1024 环境 × 60 s × 3 seed(官方压力协议) | 2975/3072 = 96.84%(官方同协议 97.01%,差 5 个环境) |
| 续训 smoke(64 环境 × 5 迭代) | 迭代 6999→7004,无 NaN,吞吐 1224–1743 env-step/s |
| 官方仓库测试套件 | 332 passed, 2 skipped |
视频两段,同一 actor、同一个 ONNX,只改速度指令:
(0, 0, 0):250 步 = 5.0 s,0 次摔倒,单步推理 1.19 ms(控制预算 20 ms,余量充足);- 前向
0.15 m/s:500 步 = 10.0 s,0 次摔倒,单步推理 1.15 ms——这段才真正体现"一边抬脚换重心、一边把球滚出去"。
续训那一轮还能看到"从恢复态重新站稳"的过程:Mean episode length从 16.00 步(0.32 s)一路涨到 68.33 步(1.37 s),
平均奖励 1.58 → 6.52。
六、顺手搞清的一件事:控制步 / 训练迭代 / episode
三个尺度混起来最容易读错,实际换算是:
| 概念 | 尺度 | 本次取值 |
|---|---|---|
| 物理步 | 2.5 ms | 0.005 s(MuJoCo-Warp) |
| 控制步 | 20 ms | 0.02 s = 4 个物理步,一次观测 → 一次 14 维动作 |
| episode | 10 s | 500 控制步 |
| 训练迭代 | 0.48 s 仿真 | 64 环境 × 24 控制步 =1536 个转移 |
所以教程里那句提醒要按字面理解:"10 个 iteration"是 10 轮采样+优化,不是每个动作只训练 10 步——
按本次配置是 64 × 24 × 10 = 15360 条转移。
七、一条从数据里看出来的观察
34 次摔倒里有33 次触发条件是low_height(根部相对球心太低),不是倾角、也不是偏移。再看数值:
| 状态 | 根部高于球心 | 球速 | 倾角 | 动作变化 RMS |
|---|---|---|---|---|
| 正常(30 s 复核) | 0.2345 m | 0.115 m/s | ~0.04 rad | 0.233 |
| 失败瞬间 | 0.16 m 左右 | 0.46–0.49 m/s | 0.20–0.26 rad | — |
也就是说它不是"被推倒",更像"球被带快了 → 自己陷下去 → 失去球顶支撑"。
这条观察直接给出一个可验证的改动:把height权重(当前 1.0)加大,或把ball_speed正则(当前 −0.05)调强一点,
看low_height型失败是否下降、同时速度跟踪误差是否变差。这是我下一步最想做的对照实验。
八、常用的两条命令
# 导出 + 逐步一致性(含一次 reset 检查记忆清零)MICRODUCK_BB_BLIND=1MICRODUCK_BB_HISTORY=1uv run python scripts/export.py Mjlab-Basketball-MicroDuck\--checkpoint-file artifacts/basketball/checkpoint.pt --onnx-file outputs/basketball_b11.onnx uv run python scripts/verify_basketball_onnx_parity.py artifacts/basketball/checkpoint.pt outputs/basketball_b11.onnx# 续训 smoke / 官方三 seed 评测WANDB_MODE=disabled uv run python scripts/finetune_basketball.py artifacts/basketball/checkpoint.pt\--run-name smoke --num-envs64--iterations5--learning-rate 2e-5 --push-interval-s1.53--save-interval5uv run python scripts/eval_basketball_long.py artifacts/basketball/checkpoint.pt eval.json\--seed101--seconds60--num-envs1024--command-scale2--blind九、这次没证明什么(避免误读)
- 这是公开 checkpoint 的本地复核,不是我们从随机初始化训练出来的策略。
98.83%、96.84%都是复核数字,不能当自研成果。 - 全部来自 MuJoCo-Warp 仿真。官方明确说明真机时序与真实行为未验证,这些数字不能写成真实机器人成功率。
- smoke 不等于变好。5 次 PPO 更新、7680 个转移,只能证明采样/更新/保存/导出/一致性链路可用。
十、下一步想做的
- 奖励权重对照实验:加大
height权重 / 调强ball_speed正则,在同一 60 秒协议下比较改动前后的生存率与速度跟踪误差。 - 记忆的边际贡献:按
h/c清零频率或输入历史长度做消融(教程里 b4/b5 近似 0、b7 44.6%、b6 84.4% 已经给了线索,我还没亲手复现这条链)。 - 把速度跟踪做细:前向误差 0.108–0.154 m/s 相对指令幅度并不小,值得单独出一组曲线。