第 28 章 网球击球控制与全身协调
本章定位:Ch26 建立了网球场的物理底座(坐标系、球体模型、弹道空气动力学),Ch27 建立了感知与轨迹预测管线(EKF、GRU 残差预测、延迟补偿、
PredictorOutput接口)。本章进入综合项目的最终层——击球控制。击球控制不是"让机械臂碰到球",而是一个高时效的接触控制问题:机器人必须在有限时间内判断球是否可打、移动底盘到合适位置、调整球拍姿态、在正确时刻以正确速度和法向击中球、控制出球方向——同时保持自身稳定。这比 cube lifting 更难(目标在高速运动),比 velocity tracking 更难(reward 极稀疏),比普通 reach task 更难(需要"带速度和姿态约束的定时接触"而非简单到点)。前置依赖:Ch26(court-local 坐标 / 八维发球命令 / BallAerodynamics / 接触物理 / 机器人+球拍建模路线)、Ch27(PredictorOutput 接口 / time_to_impact / uncertainty / EKF + GRU predictor)、Ch06(Reward 设计)、Ch08(Domain Randomization)、Ch09(Teacher-Student 特权学习)、Ch25(训练诊断与调参)、mjlab action 架构(DifferentialIKActionCfg)
关键参考:✅ KungfuBot(NeurIPS'25,arXiv:2506.12851,
github.com/TeleHuman/PBHC,自适应 curriculum + asymmetric AC)· ✅ WoCoCo(CoRL'24,arXiv:2406.06005,github.com/LeCAR-Lab/wococo,sequential contacts 分解 + Butterworth 滤波)· ✅ LATENT(arXiv:2603.12686,人形网球,latent action space + wrist correction)· ✅ HITTER(arXiv:2508.21043,人形乒乓球,hierarchical planner + RL controller)· 📄 ETH 羽毛球(Science Robotics, 2025)· 📄 Phybot 羽毛球(arXiv:2511.11218,三阶段 curriculum)· 📄 PACE(arXiv:2509.21690,dual predictor)
前置自测
📋 答不出 ≥ 3 题 → 先回 Ch26-Ch27 复习
- [Ch26] 当前
Mjlab-Tennis-Launcher的actions字典内容是什么?为什么不能直接在这个 task 上训练击球? - [Ch26] Ch26 规划的机器人+球拍引入阶段有几个 Stage?为什么不能直接跳到最后一个 Stage?
- [Ch27]
PredictorOutput的四个核心字段是什么?它们分别给控制器什么信息? - [Ch27]
time_to_impact为什么比predicted_position更接近控制需求? - [RL] 什么是 reward shaping?为什么纯稀疏 reward 很难让 PPO 学到东西?
本章目标
学完本章后,你应该能够:
- 设计 击球 MDP 的完整五元组(state / action / transition / reward / termination),包含分层状态空间和 task-space action
- 实现 五类 staged reward(approach / timing / contact / outcome / regularization)的完整代码,解释乘法结构的设计理由
- 设计 从静态球到高速发球的 8 阶段 curriculum,包含成功率门控和安全回退
- 配置 Domain Randomization 的五类 gap 覆盖策略,并通过消融实验验证每类 DR 的贡献
- 实现 Lagrangian PPO 安全约束的训练循环代码
- 分析 网球 Sim2Real 的五类特殊挑战及其工程对策
28.1 击球控制的本质:带速度和姿态约束的定时接触 ⭐⭐
这一节解决什么问题:建立对击球控制问题复杂度的正确认知,避免"把球拍移到球的位置"的天真假设。
动机:五个让天真方案失败的原因
一个天真的控制方案是:从 Ch27 predictor 得到未来球的位置,让机械臂用 IK 把球拍中心移到那个点,如果接触就给 reward。这个方案会失败,原因有五个。
原因一:IK 到点不等于击球。 球拍必须在正确时间到达(太早等在那里球还没来,太晚球已经飞过去了),还要有正确的速度(静止的球拍碰到高速球只会被弹开或产生微弱反弹),面法向也必须正确(偏了出球方向不可控)。这就好比棒球击球——站在好球带里不挥棒也算"到位了",但球不会自己飞向外场。
原因二:只控制手臂可能不可达。 高速横向来球可能落在 arm workspace 之外。如果球预计到达机器人右侧 1.5 m 处,固定底座的机械臂根本够不到。移动底盘需要提前开始——但底盘移动会改变 arm base frame,又会改变 IK 目标。base-arm 协调是耦合问题,不能拆成两个独立模块。
原因三:接触瞬间极短。 真实网球拍-球接触约 3-5 ms。Ch26 确认了这一点——LATENT 用 2000 Hz 仿真才能准确解算。reward 如果只看 episode 结束时的落点,策略很难知道是哪一步做对了——credit assignment 问题被极端放大。
原因四:击中但失稳也不是成功。 极端动作挥拍在仿真中可能打到球,但真实机器人会因惯性和反作用力损坏或摔倒。WoCoCo(CoRL'24)在部署时使用 4 Hz Butterworth 低通滤波器(Ch25 详述)正是为了防止策略产生硬件不安全的高频动作。
原因五:仿真接触太理想。 仿真中球拍稍微碰到球就产生可控出球,真实的线床变形、球体压缩、摩擦变化更复杂。LATENT 的消融实验直接证明了这一点——去掉 dynamics randomization 后真机成功率从 91% 骤降到 14-29%。
本质洞察:击球控制的核心不是末端到点, 而是"带速度和姿态约束的定时接触"。 只把球拍中心追到球心,会把最关键的时间和动量问题删掉。 控制器需要同时解决四个子问题:到哪(position)、什么时候(timing)、 多快(velocity)、什么角度(orientation)。
如果不做 staged reward 会怎样
反事实推理:只给 outcome reward(球落在目标区域内才给分)。大多数时间 reward 为 0——球拍追不上球、追上了没打到、打到了没过网。PPO 在这种极稀疏 reward 下几乎学不到任何东西。这就像让从未摸过球拍的人参加 Wimbledon,只告诉他"赢了才有奖金"但不教怎么挥拍——他连球都碰不到,更谈不上改进技术。staged reward 相当于沿途放一系列越来越亮的灯——至少能沿着光走。
当前事实边界
回顾 Ch26:当前 actions: dict = {} 为空,无 robot/racket/击球策略。本章是击球 MDP 和训练路线的完整设计——基于仓库已有的 manipulation(staged reward pattern)、velocity(base control + DR + curriculum pattern)、differential IK(task-space arm action)三类可复用 pattern,以及 Ch27 定义的 PredictorOutput 接口。
前沿系统的击球控制架构对比
| 系统 | 控制架构 | 动作空间 | Reward 类型 | 关键创新 |
|---|---|---|---|---|
| LATENT | 分层:high-level policy + low-level tracker | latent action (~16D) + wrist correction | 击球成功 + 落点精度 + 自然度 | latent action barrier |
| HITTER | 分层:model-based planner + RL controller | 全关节 PD targets (29D) | tracking + balance + contact | 解析弹道规划 |
| WoCoCo | 端到端:sequential contact stages | 全关节 PD targets | contact-stage-decomposed reward | Butterworth 部署滤波 |
| KungfuBot | 端到端:motion tracking + adaptive curriculum | 全关节 PD targets | tracking error(无 AMP) | bi-level adaptive tolerance |
| Phybot | 三阶段 curriculum | 全关节 PD targets | footwork → swing → task | 无 motion prior |
| ETH 羽毛球 | 端到端:unified RL | 全关节 PD targets + 步态相位 | perception-aware reward | 机载视觉集成 |
Ch26→Ch27→Ch28 的完整数据流
本章是三章综合项目的最后一环。在进入技术细节之前,回顾从 Ch26 到 Ch28 的完整数据流——理解每个模块的输入、输出和接口:
Ch26 物理底座 Ch27 感知管线 Ch28 控制策略
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ tennis_court.xml │──坐标──→│ BallObsHistory │ │ TennisStrikeEnv │
│ BallEntity │──状态──→│ BallEKFWithDrag │──状态──→│ ObservationsCfg │
│ LaunchCommand │──参数──→│ GRUResidual │──预测──→│ ActionsCfg │
│ BallAerodynamics │──物理──→│ DelayCompensation│──补偿──→│ RewardsCfg │
│ ContactPhysics │──接触──→│ │ │ CurriculumMgr │
│ │ │ PredictorOutput │──接口──→│ LagrangianPPO │
└──────────────────┘ └──────────────────┘ └──────────────────┘
↓
joint_targets
↓
DeploySafety
(Butterworth +
Action Rescaler)
↓
真机执行
每个箭头代表一个明确的接口——坐标系约定(Ch26)、PredictorOutput 数据结构(Ch27)、StrikeEnvCfg(Ch28)。如果任何一个接口的语义不一致(比如 Ch27 的 impact_pos 用的坐标系和 Ch28 的 reward 计算用的坐标系不同),整个系统就会失效。
这就是为什么 Ch26 花了大量篇幅在坐标约定上——它是所有后续模块的"坐标语言"。
⚠️ 常见陷阱
⚠️ 思维陷阱:直接在 Phase 1 launcher 上训练击球
- actions: dict = {} 意味着 PPO 无输出维度,训练无意义
- 正确做法:新增包含 robot/racket/action 的 strike task(参见 Ch26 的阶段化引入路线)
⚠️ 概念误区:IK 到点就是击球 - 位置对了但速度为零,接触冲量不足;面法向偏了,出球方向不可控 - 正确做法:同时约束位置、速度和姿态
⚠️ 编程陷阱:wrist pose 当 racket face pose
- 球拍有长度和面法向偏移(Ch26 的 euler="0 -15 0"),wrist 到位不代表 racket face 到位
- 正确做法:在 MJCF 中定义 racket_face_center site,IK target 指向该 site
练习
- [分析题] 解释为什么 cube lifting 的 staged reward 可以迁移到网球击球,但参数不能直接复用。从目标物体速度(0 vs 30 m/s)、接触持续时间(持续 vs 3 ms)、reward 稀疏性三个角度分析。
- [设计题] 对比 LATENT(latent action)和 HITTER(全关节 PD targets)的动作空间设计。哪种更适合教学项目?为什么?
建立了复杂度认知后,下一步定义 MDP 五元组——这是把"打网球"转化为 RL 可优化问题的数学语言。
28.2 击球 MDP 设计:状态、动作与转移 ⭐⭐⭐
这一节解决什么问题:定义击球任务的完整 MDP 五元组,为后续 reward 和 curriculum 设计提供框架。
状态空间分层设计
击球任务的状态天然分成三层,对应不同的信息访问权限:
| 层 | 包含的状态 | 谁能看到 | 维度(近似) |
|---|---|---|---|
| 环境真值 | ball pos/vel/ang_vel, contact state, launch cmd, court geometry | 仿真内部 | ~30 |
| Teacher Actor | ground truth ball + future trajectory + perfect robot state | teacher policy | ~60 |
| Student Actor | estimated impact pos/vel, time_to_impact, uncertainty, proprioception, prev action | 可部署 policy | ~50 |
为什么要分层? Teacher 能看到未来轨迹和精确接触状态,Student 只能看到 Ch27 predictor 输出和自身传感器。如果一开始就限制 teacher 信息,上限性能更低,蒸馏目标更弱。分层让我们先确定"信息完全时能打多好",再逐步降级到"可部署信息下能打多好"。
Student Actor 的 Observation 设计
class TennisStrikeObsCfg:
"""击球 task 的 student actor observation。"""
class ActorCfg:
# === 球相关(来自 Ch27 PredictorOutput)===
ball_pos_estimate = ObsTermCfg(
func=lambda env: env.perception.predicted_state_now[:, :3],
enable_corruption=True,
) # [3] EKF 估计的当前球位置
ball_vel_estimate = ObsTermCfg(
func=lambda env: env.perception.predicted_state_now[:, 3:6],
) # [3] EKF 估计的当前球速度
impact_pos = ObsTermCfg(
func=lambda env: env.perception.impact_pos_c,
) # [3] 预测击球点
time_to_impact = ObsTermCfg(
func=lambda env: env.perception.time_to_impact.unsqueeze(-1),
) # [1] 距击球时间
prediction_uncertainty = ObsTermCfg(
func=lambda env: env.perception.uncertainty.unsqueeze(-1),
) # [1] 预测不确定性
prediction_validity = ObsTermCfg(
func=lambda env: env.perception.validity.float().unsqueeze(-1),
) # [1] 预测是否有效
# === 机器人本体感知 ===
joint_pos = ObsTermCfg(func=mdp.joint_pos_rel) # [n_j]
joint_vel = ObsTermCfg(func=mdp.joint_vel_rel) # [n_j]
base_lin_vel = ObsTermCfg(func=mdp.base_lin_vel) # [3]
base_ang_vel = ObsTermCfg(func=mdp.base_ang_vel) # [3]
projected_gravity = ObsTermCfg(func=mdp.projected_gravity) # [3]
# === 球拍状态 ===
racket_pos = ObsTermCfg(func=racket_face_position) # [3]
racket_normal = ObsTermCfg(func=racket_face_normal) # [3]
racket_vel = ObsTermCfg(func=racket_face_velocity) # [3]
# === 动作历史 ===
last_action = ObsTermCfg(func=mdp.last_action) # [action_dim]
class CriticCfg(ActorCfg):
"""Critic 额外看到 privileged 信息。"""
ball_pos_perfect = ObsTermCfg(
func=ball_state_privileged, enable_corruption=False,
) # [9] ground truth ball state
ball_future_trajectory = ObsTermCfg(
func=ball_future_positions_20step,
) # [60] 未来 20 步真实位置
动作空间设计
| 候选方案 | 维度 | 优点 | 缺点 | 推荐阶段 |
|---|---|---|---|---|
| base_vel + racket_delta_pos | 6D | 简单起步 | 无姿态控制 | Stage 1-2 |
| base_vel + racket_delta_pose_6d | 9D | 有姿态控制 | IK 可能不可达 | Stage 2-4 |
| 全关节 joint_position_targets | \(n_j\)D | 最大灵活性 | 策略要自学运动学 | Stage 4+ |
| latent action(LATENT 方案) | ~16D | 自然运动 | 需要 motion data | 高级 |
本课程推荐 base velocity + differential IK(9D)起步。
class TennisStrikeActionsCfg:
"""击球 task 的动作空间配置。"""
# 方案 A(推荐起步):base velocity + task-space IK
base_velocity = ActionTermCfg(
class_type=BaseVelocityActionCfg,
asset_name="robot",
scale=(1.0, 0.5, 1.0), # (vx, vy, wz) 缩放
) # 3D: 底盘速度
racket_ik = ActionTermCfg(
class_type=DifferentialIKActionCfg,
asset_name="robot",
joint_names=[".*_shoulder_.*", ".*_elbow_.*", ".*_wrist_.*"],
body_name="racket_face_center",
controller=DifferentialIKControllerCfg(
command_type="pose", # 位置 + 旋转
use_relative_mode=True, # delta pose
ik_method="dls", # damped least squares
ik_params={"lambda_val": 0.05},
),
) # 6D: 球拍 task-space 增量
# 总 action_dim = 3 + 6 = 9
本质洞察:移动底盘不是"让机器人更灵活"的附加功能, 而是把击球点从 arm workspace 外拉回可达域的控制变量。 忽略 base,会把许多可打来球错误地变成不可达来球。
类比理解:足球守门员如果只能站在原地伸手,大部分射门都接不到。移动脚步(底盘)是扩大防守范围的关键。这个类比的边界在于:守门员的反应时间约 0.2-0.5 秒,而网球机器人的反应时间可能只有 0.3-0.8 秒(球从远端飞到近端),两者数量级接近。
DifferentialIK 的关键参数
DifferentialIKActionCfg 把 task-space 的 delta pose 转化为 joint-space 的关节角增量,核心是 damped least squares (DLS) 伪逆:
其中 \(J\) 是 Jacobian 矩阵(\(6 \times n_j\)),\(\Delta \mathbf{x}\) 是 task-space 中的 pose 增量(3D 位置 + 3D 旋转),\(\lambda\) 是阻尼系数。
为什么不直接用 \(J^{-1}\)(伪逆)? 当机器人接近 workspace 边界或奇异构型(Jacobian 秩下降)时,\(J\) 的小奇异值导致伪逆 \(J^+\) 放大——等价地,上式中 \(JJ^T\) 的小特征值接近零,使逆矩阵剧增——策略输出一个微小的 task-space 动作,关节却跳变几十度。DLS 通过在 \(JJ^T\) 上添加 \(\lambda^2 I\) 来正则化——代价是精度稍有下降,但换来了数值稳定性。
| 参数 | 含义 | 击球任务建议值 | 理由 |
|---|---|---|---|
use_relative_mode |
action 是 delta 还是 absolute | True |
delta 更适合 RL(action space 以零为中心) |
delta_pos_scale |
位置 delta 的缩放 | 0.05-0.1 m/step | 50 Hz 控制 → 0.05 m/step → 最大速度 2.5 m/s |
delta_ori_scale |
旋转 delta 的缩放 | 0.1-0.2 rad/step | 不宜过大防止球拍翻转 |
lambda_val (damping) |
DLS 阻尼 | 0.05-0.1 | 太小 → 奇异附近发散;太大 → 响应迟钝 |
max_dq |
单步最大关节角变化 | 按关节速度限制 | 防止关节瞬间跳变 |
DifferentialIK 在 mjlab 中的完整使用流程
# Step 1: 在 env_cfg 中配置 IK action
class TennisStrikeActionsCfg:
racket_ik = ActionTermCfg(
class_type=DifferentialIKActionCfg,
asset_name="robot",
joint_names=["right_shoulder_pitch", "right_shoulder_roll",
"right_shoulder_yaw", "right_elbow", "right_wrist_yaw"],
body_name="racket_face_center", # IK 目标:球拍面中心
controller=DifferentialIKControllerCfg(
command_type="pose",
use_relative_mode=True,
ik_method="dls",
ik_params={"lambda_val": 0.05},
),
)
# Step 2: 策略输出 6D action → IK 转化为关节目标
# 策略: obs → [base_vx, base_vy, base_wz, dx, dy, dz, drx, dry, drz]
# DifferentialIK: [dx, dy, dz, drx, dry, drz] → Δq for arm joints
# Step 3: PD 控制器追踪关节目标
# current_joint_pos + Δq → PD target
# torque = Kp * (target - current) - Kd * joint_vel
action transform 的完整链路:
策略输出 action [9D]
├── action[:3] = base_velocity [vx, vy, wz]
│ → 直接发送给底盘速度控制器
│
└── action[3:9] = racket_delta_pose [dx, dy, dz, drx, dry, drz]
→ delta_pos_scale * action[3:6] = Δx_pos [m]
→ delta_ori_scale * action[6:9] = Δx_ori [rad]
→ DLS IK: Δq = J^T(JJ^T + λ²I)^{-1} [Δx_pos; Δx_ori]
→ clamp(Δq, -max_dq, +max_dq)
→ current_joint_pos + Δq = joint_target
→ PD controller: τ = Kp(q_target - q) - Kd * q̇
→ 施加到物理仿真
为什么推荐 Task-Space IK 而非 Joint-Space Action
反事实推理:如果不用 DifferentialIK 而直接用全关节 PD targets(\(n_j\) 维 action)——策略需要自己学习"怎么移动关节才能让球拍到达目标位置"。这是一个从 joint space 到 task space 的逆运动学映射——人类工程师用几十行代码就能精确解的问题,让神经网络用几亿个样本去学是极其低效的。
HITTER 和 Phybot 使用全关节 PD targets 的原因是它们的机器人有完整的人形身体——IK 只覆盖手臂,但腿部和躯干也参与运动。对这种情况,全关节 action + 动作先验(motion data)是一种合理选择。但对教学项目(特别是固定底座或简单移动底盘的配置),Task-Space IK 大幅降低了学习难度。
本质洞察:DifferentialIK 把运动学知识("怎么移动关节才能让末端到达目标") 编码进了 action transform。策略只需要学习"球拍应该去哪"—— 这是一个 3D/6D 的决策问题,比 \(n_j\)-D 的关节控制问题简单得多。
Isaac Lab 中的 IK Action 对照
Isaac Lab 提供了类似的 DifferentialIK action,但 API 路径不同:
# Isaac Lab 的 DifferentialIK 配置
from isaaclab.envs.mdp.actions import DifferentialInverseKinematicsActionCfg
class IsaacLabTennisActionsCfg:
racket_ik = DifferentialInverseKinematicsActionCfg(
asset_name="robot",
joint_names=["panda_joint.*"],
body_name="panda_hand",
controller=DifferentialIKControllerCfg(
command_type="pose",
use_relative_mode=True,
ik_method="dls",
ik_params={"lambda_val": 0.05},
),
)
两个框架的 IK 解算器在数学上完全等价——都是 DLS 伪逆。差异在于 Jacobian 的获取方式:mjlab 通过 MuJoCo 的 mj_jac 函数获取,Isaac Lab 通过 PhysX 的 articulation API 获取。
反事实推理:如果不用 DifferentialIK 而直接用全关节 PD targets(\(n_j\) 维 action)——策略需要自己学习"怎么移动关节才能让球拍到达目标位置"。这是一个从 joint space 到 task space 的逆运动学映射——人类工程师用几十行代码就能解的问题,让神经网络用几亿个样本去学是极其低效的。DifferentialIK 把这个已知的映射编码进 action transform,让策略只需要学习"球拍应该去哪"而非"关节应该怎么动"。
⚠️ 常见陷阱
⚠️ 思维陷阱:先练手臂再加底盘 - arm-only 策略只能打正面来球。加入底盘后策略需重新学 base-arm 协调 - 正确做法:从第一版就把 base command 放入 action space(收窄初始活动范围即可)
⚠️ 编程陷阱:DifferentialIK 的 damping 设太低 - 接近 workspace 边界或奇异构型时 IK 解发散(关节角跳变) - 正确做法:damping 从 0.1 开始逐步降低,观察 IK 残差
⚠️ 概念误区:高维 action space 一定更好 - joint-space action(\(n_j\) 维)让策略自己学运动学,样本效率极低 - 正确做法:用 task-space IK 把运动学知识编码进 action transform
练习
- [设计题] 设计 9D action
[base_vx, base_vy, base_wz, racket_dx, racket_dy, racket_dz, racket_drx, racket_dry, racket_drz],说明每维的物理含义和建议 action scale。如果控制频率是 50 Hz,每维的 action scale 应该设为多少才能达到合理的最大速度/角速度? - [手算题] 固定底座机械臂的 arm workspace 半径约 0.6-0.8 m。如果球可能到达机器人左右 2 m 范围,底盘需要至少移动多远?假设底盘最大速度 2 m/s,需要多少时间?
MDP 的状态和动作定义好后,接下来是 RL 训练中最关键的部分——reward 设计。
28.3 Reward 分解:从稀疏到可学 ⭐⭐⭐
这一节解决什么问题:设计让 PPO 能有效学习击球行为的 reward 函数。关键是把"打赢一分"拆成持续可学的子目标。
五类 reward 详解
| 类别 | 名称 | 时机 | 数学形式(示意) | 作用 | 权重建议 |
|---|---|---|---|---|---|
| 接近 | approach |
contact 前 | \(\exp(-\|\mathbf{p}_r - \mathbf{p}_i\|^2 / \sigma^2)\) | 引导球拍进入击球窗口 | 1.0 |
| 对时 | timing |
contact 前 | $\exp(- | t_{\text{tti}} - t_{\text{ready}} | / \tau)$ |
| 击中 | contact |
contact 时 | 合法球拍-球接触的二值信号 | 确认物理接触发生 | 5.0 |
| 出球 | outcome |
contact 后 | 过网 \(\cdot\) 落入目标区 | 最终任务目标 | 10.0 |
| 正则 | reg |
全程 | action rate + joint limit + base stability | 安全和平滑 | -0.1 ~ -0.5 |
每类 reward 的工程实现
Approach Reward:引导球拍接近预测击球点
def compute_approach_reward(env, sigma=0.3):
"""球拍面中心到预测击球点的距离 reward。
使用指数衰减:远处有信号,近处梯度最大。
"""
racket_pos = env.get_site_position("racket_face_center") # [N, 3]
impact_pos = env.perception.impact_pos_c # [N, 3]
validity = env.perception.validity # [N] bool
distance = (racket_pos - impact_pos).norm(dim=-1) # [N]
# 指数衰减
reward = torch.exp(-distance**2 / sigma**2) # [N]
# 只对有效预测给 reward(无效时 reward = 0)
reward = reward * validity.float()
return reward
为什么用指数衰减而非线性? \(\exp(-d^2/\sigma^2)\) 在近处 reward 最高,且在距离大时梯度也不为零(远处仍有学习信号);其对距离的导数大小为 \((2d/\sigma^2)\exp(-d^2/\sigma^2)\),在 \(d=0\) 处为 0、在 \(d=\sigma/\sqrt{2}\) 附近最大,因此引导信号在中等距离最强、近处趋于平缓。线性衰减 \(\max(0, 1 - d/d_{\max})\) 在 \(d > d_{\max}\) 时梯度为零——策略远处没有方向信号。\(\sigma\) 应该和 arm workspace 尺度匹配(0.3-0.5 m),让球拍在整个工作空间内都有非零信号。
Timing Reward:在正确时间接近击球点
def compute_timing_reward(env, tau=0.05, t_ready=0.1):
"""时间协调 reward:球拍应在 time_to_impact ≈ t_ready 时到达击球点附近。
过早到位等在那里→球还没来→击中概率低
过晚到位→球已经飞过→完全打空
"""
tti = env.perception.time_to_impact # [N] 剩余时间
distance = (env.get_site_position("racket_face_center")
- env.perception.impact_pos_c).norm(dim=-1) # [N]
# 定义"准备度":距离小,且 tti 接近理想提前量 t_ready(提前 t_ready 到位开始挥拍)。
# 用 |tti - t_ready| 而非 -tti,避免 tti<0(球已飞过)时 exp 爆炸;并 clamp 掉负 tti。
tti_clamped = tti.clamp_min(0.0)
readiness = torch.exp(-distance / 0.2) * torch.exp(-torch.abs(tti_clamped - t_ready) / tau)
return readiness * env.perception.validity.float()
Contact Reward:检测到合法的球拍-球接触
def compute_contact_reward(env):
"""合法球拍-球接触的二值 reward。
"合法"的定义:
1. 球拍面的 geom 碰到球的 geom(不是球拍框或握柄)
2. 球在空中(不是落地后的球)
3. 球在可打范围内(不是已经飞过机器人的球)
"""
# 检查 MuJoCo contact 数组中是否有 racket_face ↔ ball 接触
contact_detected = env.contact_sensor.detect_contact(
geom_a="racket_face",
geom_b="ball_geom",
) # [N] bool
# 额外条件
ball_airborne = env.perception.predicted_state_now[:, 2] > 0.1 # 球在空中
ball_in_range = env.perception.validity # 球在可打范围
legal_contact = contact_detected & ball_airborne & ball_in_range
# 记录事件(用于后续 outcome reward)
env.event_buffer.record_contact(legal_contact, env.sim_time)
return legal_contact.float()
Outcome Reward:出球过网并落入目标区域
def compute_outcome_reward(env):
"""出球结果 reward:过网 + 落入目标区域。
这是最终任务目标,但触发条件苛刻(需要先击中球)。
"""
if not env.event_buffer.has_contact.any():
return torch.zeros(env.num_envs, device=env.device)
# 检查接触后球是否过了网
ball_crossed_net = env.event_buffer.ball_crossed_net_after_contact # [N]
# 检查落点是否在目标区域:回球任务的目标是对方(far)半场(x>0),
# 而非机器人所在的近端 x<0(后者是 Ch26 Phase 1 发球验证的落点区,语义不同)
landing_pos = env.event_buffer.first_landing_after_contact # [N, 3]
in_target = (
(landing_pos[:, 0] > 0.0) & (landing_pos[:, 0] < 6.40) &
(landing_pos[:, 1] > -4.115) & (landing_pos[:, 1] < 4.115) &
(landing_pos[:, 2] < 0.1)
)
# 过网但没落入目标区域:部分 reward
# 过网且落入目标区域:满分
reward = ball_crossed_net.float() * 0.5 + in_target.float() * 0.5
return reward
Regularization Reward:安全和平滑约束
def compute_regularization(env, w_action_rate=0.01, w_joint_limit=0.1,
w_base_stability=0.05, w_energy=0.001):
"""正则化惩罚:确保动作安全、平滑、高效。"""
# 动作平滑度(Ch25 action_rate penalty)
action_rate = (env.actions - env.previous_actions).norm(dim=-1)
action_rate_penalty = w_action_rate * action_rate
# 关节限位惩罚
joint_pos = env.scene["robot"].data.joint_pos
joint_limits = env.scene["robot"].data.joint_pos_limits
margin = 0.05 # 距限位 5% 以内开始惩罚
lower_violation = torch.clamp(joint_limits[:, :, 0] + margin - joint_pos, min=0).sum(dim=-1)
upper_violation = torch.clamp(joint_pos - joint_limits[:, :, 1] + margin, min=0).sum(dim=-1)
joint_limit_penalty = w_joint_limit * (lower_violation + upper_violation)
# 底盘稳定性(防止倾倒)
projected_gravity = env.scene["robot"].data.projected_gravity_b
base_tilt = (projected_gravity[:, :2]).norm(dim=-1) # 水平分量越大越倾斜
base_stability_penalty = w_base_stability * base_tilt
# 能量消耗
joint_torques = env.scene["robot"].data.applied_torque
energy = (joint_torques.abs() * env.scene["robot"].data.joint_vel.abs()).sum(dim=-1)
energy_penalty = w_energy * energy
return action_rate_penalty + joint_limit_penalty + base_stability_penalty + energy_penalty
Staged Reward 的乘法结构
def compute_total_reward(env):
"""五类 reward 组合:乘法结构确保层级优先。"""
approach = compute_approach_reward(env)
timing = compute_timing_reward(env)
contact = compute_contact_reward(env)
outcome = compute_outcome_reward(env)
reg = compute_regularization(env)
# 乘法结构:approach 为 0 时后面再好也不贡献
total = (
1.0 * approach
* (1.0 + 0.5 * timing)
* (1.0 + 5.0 * contact)
* (1.0 + 10.0 * outcome)
- reg
)
return total
乘法结构的设计理由:这确保了层级优先。如果 approach = 0(球拍离击球点很远),无论 timing 多好、是否碰到球,总 reward 都是零——强制策略先解决接近问题。只有接近了(approach > 0),timing 的好坏才开始有意义。只有接近且时间对了,contact 才有可能发生。只有 contact 发生后,outcome 才能评估。
这与 mjlab manipulation 中 reaching * (1 + bringing) 的模式完全一致——cube lifting 中策略必须先够到 cube(reaching),才有机会搬起来(bringing)。
WandB 分项 Reward 监控
分项记录每个 reward term 是诊断训练问题的关键。如果只看 total reward,你无法判断"reward 上升是因为策略真的学会了击球,还是因为学会了刷 approach shaping"。
# 在 env step 中记录分项 reward
def step(self, actions):
# ... 物理仿真和 reward 计算 ...
# 分项记录到 WandB
self.extras["log"]["Reward/approach"] = approach.mean().item()
self.extras["log"]["Reward/timing"] = timing.mean().item()
self.extras["log"]["Reward/contact"] = contact.mean().item()
self.extras["log"]["Reward/outcome"] = outcome.mean().item()
self.extras["log"]["Reward/regularization"] = reg.mean().item()
self.extras["log"]["Reward/total"] = total.mean().item()
# 关键指标(不是 reward 但是真正的成功度量)
self.extras["log"]["Metric/contact_rate"] = contact.float().mean().item()
self.extras["log"]["Metric/over_net_rate"] = self.event_buffer.ball_crossed_net_after_contact.float().mean().item()
self.extras["log"]["Metric/target_landing_rate"] = self.event_buffer.landing_in_target.float().mean().item()
WandB 面板推荐布局(击球任务专用):
| 行 | 左列 | 中列 | 右列 |
|---|---|---|---|
| 1 | Total Reward | Contact Rate (最重要) | Over-Net Rate |
| 2 | Approach Reward | Timing Reward | Contact Reward |
| 3 | Outcome Reward | Regularization | Episode Length |
| 4 | KL Divergence | Entropy | Curriculum Stage |
Contact Rate 是击球任务的北极星指标——它直接衡量"策略碰到球了吗"。total reward 可能因为 approach shaping 持续上升但 contact rate 不动(说明策略学会了"靠近但不击球"的局部最优)。这种情况下需要增大 contact reward 权重或减小 approach σ。
Reward 调优的系统流程
当训练不收敛时,按以下流程诊断和调优:
Total reward 升但 contact rate = 0?
├── 是 → 策略在刷 approach shaping
│ 修复:减小 approach σ 或增大 contact weight
│
└── 否 → Contact rate 升但 over_net rate = 0?
├── 是 → 碰到球但力量/角度不对
│ 修复:加 orientation reward 或 racket velocity reward
│
└── 否 → Over_net rate 升但 landing rate = 0?
├── 是 → 球过网但落点不准
│ 修复:加 landing accuracy reward
│
└── 否 → ✅ 训练正常!逐步推进 curriculum
PACE 的 Physics Predictor 用于 Reward 构造
回顾 Ch27 的 PACE 双通道设计——physics predictor 在训练时提供精确的未来球位置用于构造 approach reward。这比用 learned predictor 构造 reward 更好,因为 physics predictor 从第一天起就准确。
def compute_approach_reward_with_physics_predictor(env, sigma=0.3):
"""使用 physics predictor(精确)而非 learned predictor 构造 approach reward。
这是 PACE(arXiv:2509.21690)的核心设计之一。
训练时:reward 用 physics predictor(精确) → reward 稳定
运行时:policy obs 用 learned predictor(可部署) → 策略学会使用不精确的预测
"""
racket_pos = env.get_site_position("racket_face_center")
# Physics predictor:用 ground truth ball state + 物理模型
physics_impact_pos = env.physics_predictor.compute_impact_point(
env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins,
env.scene["ball"].data.root_lin_vel_w[:, :3],
)
distance = (racket_pos - physics_impact_pos).norm(dim=-1)
return torch.exp(-distance**2 / sigma**2)
为什么不用 learned predictor 构造 reward? 因为训练初期 learned predictor 还不准确,用不准确的预测构造 reward → reward 噪声大 → PPO 梯度方差大 → 训练不稳定。Physics predictor 从第一天起就相当准确(物理定律不需要"学习"),提供了稳定的 reward 信号。
与 cube lifting 不同,网球接触是瞬时事件(3-5 ms)。如果不记录接触事件,几个 env step 后就无法知道"是否曾经接触过"。
class StrikeEventBuffer:
"""记录击球事件的 buffer。"""
def __init__(self, num_envs, device):
self.has_contact = torch.zeros(num_envs, dtype=torch.bool, device=device)
self.contact_time = torch.zeros(num_envs, device=device)
self.outgoing_velocity = torch.zeros(num_envs, 3, device=device)
self.ball_crossed_net_after_contact = torch.zeros(num_envs, dtype=torch.bool, device=device)
self.first_landing_after_contact = torch.zeros(num_envs, 3, device=device)
self.landing_recorded = torch.zeros(num_envs, dtype=torch.bool, device=device)
def record_contact(self, contact_mask, sim_time):
"""记录接触事件。"""
new_contacts = contact_mask & ~self.has_contact # 只记录第一次接触
self.has_contact = self.has_contact | contact_mask
self.contact_time[new_contacts] = sim_time
def update_post_contact(self, ball_pos_local, ball_vel):
"""接触后追踪球的状态。"""
# 只对已接触的环境追踪
active = self.has_contact & ~self.landing_recorded
# 检查过网:机器人在近端(x<0)击球,成功回球应把球打回对方半场(x>0)
crossed = active & (ball_pos_local[:, 0] > 0) # 球越网到了对方(far)半场
self.ball_crossed_net_after_contact = self.ball_crossed_net_after_contact | crossed
# 检查落地
landed = active & (ball_pos_local[:, 2] < 0.05) & (ball_vel[:, 2] < 0)
self.first_landing_after_contact[landed] = ball_pos_local[landed]
self.landing_recorded = self.landing_recorded | landed
def reset(self, env_ids):
"""episode reset。"""
self.has_contact[env_ids] = False
self.contact_time[env_ids] = 0.0
self.outgoing_velocity[env_ids] = 0.0
self.ball_crossed_net_after_contact[env_ids] = False
self.first_landing_after_contact[env_ids] = 0.0
self.landing_recorded[env_ids] = False
⚠️ 常见陷阱
⚠️ 思维陷阱:reward 越高越好 - approach reward 权重过大时,策略学到"一直把球拍举在预测点"刷分但不挥拍 - 正确做法:分项记录每个 reward term,关注 contact rate 和 target landing rate 作为真正指标
⚠️ 编程陷阱:contact reward 在 episode 结束后才计算 - 接触发生的 step 没有立即获得 reward → credit assignment 更困难 - 正确做法:每步检查是否发生合法接触,立即给 reward
⚠️ 概念误区:approach 的 \(\sigma\) 越小越好 - \(\sigma\) 太小时远处 reward 趋零,策略没有方向信号 - 正确做法:\(\sigma\) 匹配 workspace 尺度(0.3-0.5 m)
⚠️ 编程陷阱:outcome reward 需要接触后的 episode 继续运行 - 如果在 contact 时刻立即 terminate → 无法评估出球是否过网、落点在哪 - 正确做法:contact 后继续运行到球落地或出界
练习
- [设计题] 写完整的 staged reward 函数代码,包含 5 类 reward 的权重和物理解释。
- [分析题] approach reward 稳定上升但 contact rate 不变,说明什么问题?怎么调?提示:策略可能学到"把球拍举在预测点不动"。
- [跨章综合题] 结合 Ch26 标准 deuce service box 坐标和 Ch27 predictor 接口,设计 outcome reward:如何判断出球过网并落入 deuce service box?写出坐标不等式。
有了 reward 函数,接下来设计 curriculum——如何从简单到困难逐步增加训练难度。
28.4 分阶段 Curriculum:从静态球到高速发球 ⭐⭐⭐
这一节解决什么问题:设计从简单到困难的训练课程,确保策略在每个阶段都有足够的学习信号。
术语说明:本节的"Curriculum 阶段 0-7"与 Ch26 第 26.8 节的"环境引入 Stage 0-6"是不同维度的概念。Ch26 的 Stage 描述的是"何时引入机器人和球拍"(环境组装顺序);本节的 Curriculum 阶段描述的是"如何逐步增加训练难度"(发球参数和噪声递进)。两者可以正交组合——例如在 Ch26 的 Stage 3(固定底座机械臂引入后)才开始 Ch28 的 Curriculum 阶段 0。
八阶段 curriculum
| 阶段 | 条件 | 新增难度 | 通过标准 | 对应 Ch26 LaunchCommand |
|---|---|---|---|---|
| 0 | 静态球,固定位置 | 无 | racket 到达球位置 (approach > 0.5) | speed=0, 固定 pos |
| 1 | 低速直线球 12 m/s,无 spin | 运动目标 | contact rate > 50% | speed=12, elev=0, spin=0 |
| 2 | 低速抛物线,固定 elev/y | 垂直维度 | contact rate > 40% | speed=12, elev=-5 |
| 3 | 增加横向偏移 | base-arm 协调 | contact rate > 30% | y_offset ∈ [-2, 2] |
| 4 | 增加速度 12-25 m/s | 时间窗口缩短 | contact rate > 25% | speed ∈ [12, 25] |
| 5 | 增加 elev/azimuth 随机 | 轨迹多样性 | legal contact > 20% | 全范围 elev/azim |
| 6 | 增加 topspin/sidespin | 出球复杂化 | over-net rate > 15% | topspin ∈ [-100, 300] |
| 7 | 加入 perception latency | 信息退化 | 性能下降 < 30% | + 0-3 帧延迟 |
Curriculum Manager 实现
class TennisCurriculumManager:
"""网球击球任务的 curriculum 管理器。"""
def __init__(self, env_cfg, num_envs, device):
self.current_stage = 0
self.stages = self._define_stages()
self.success_history = []
self.history_window = 200 # 最近 200 个 episode 的成功率
def _define_stages(self):
return [
# Stage 0: 静态球
{
"speed_range": (0.0, 0.0),
"elevation_range": (0.0, 0.0),
"azimuth_range": (0.0, 0.0),
"y_offset_range": (0.0, 0.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.5, # approach > 0.5
"metric": "approach_rate",
},
# Stage 1: 低速直线球
{
"speed_range": (10.0, 14.0),
"elevation_range": (-2.0, 2.0),
"azimuth_range": (0.0, 0.0),
"y_offset_range": (0.0, 0.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.5,
"metric": "contact_rate",
},
# Stage 2: 抛物线
{
"speed_range": (10.0, 14.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (0.0, 0.0),
"y_offset_range": (0.0, 0.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.4,
"metric": "contact_rate",
},
# Stage 3: 横向偏移
{
"speed_range": (10.0, 14.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (-5.0, 5.0),
"y_offset_range": (-2.0, 2.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.3,
"metric": "contact_rate",
},
# Stage 4: 增加速度
{
"speed_range": (12.0, 25.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (-5.0, 5.0),
"y_offset_range": (-2.0, 2.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.25,
"metric": "contact_rate",
},
# Stage 5: 全范围角度
{
"speed_range": (15.0, 35.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (-10.0, 10.0),
"y_offset_range": (-3.0, 3.0),
"topspin_range": (0.0, 0.0),
"sidespin_range": (0.0, 0.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.2,
"metric": "legal_contact_rate",
},
# Stage 6: 加旋转
{
"speed_range": (20.0, 45.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (-10.0, 10.0),
"y_offset_range": (-3.0, 3.0),
"topspin_range": (-100.0, 300.0),
"sidespin_range": (-100.0, 100.0),
"obs_delay_range": (0, 0),
"pass_threshold": 0.15,
"metric": "over_net_rate",
},
# Stage 7: 加延迟
{
"speed_range": (20.0, 45.0),
"elevation_range": (-8.0, 2.0),
"azimuth_range": (-10.0, 10.0),
"y_offset_range": (-3.0, 3.0),
"topspin_range": (-100.0, 300.0),
"sidespin_range": (-100.0, 100.0),
"obs_delay_range": (0, 3),
"pass_threshold": 0.12,
"metric": "over_net_rate",
},
]
def update(self, metrics):
"""根据指标决定是否推进或回退。"""
stage = self.stages[self.current_stage]
metric_value = metrics.get(stage["metric"], 0.0)
self.success_history.append(metric_value)
# 取最近 window 的平均
if len(self.success_history) > self.history_window:
self.success_history = self.success_history[-self.history_window:]
avg = sum(self.success_history) / len(self.success_history)
# 推进条件
if avg >= stage["pass_threshold"] and self.current_stage < len(self.stages) - 1:
self.current_stage += 1
self.success_history = [] # 重置历史
print(f"🎯 Curriculum advanced to Stage {self.current_stage}")
# 回退条件(可选):如果推进后成绩持续很差
if (len(self.success_history) > 50 and
avg < stage["pass_threshold"] * 0.3 and
self.current_stage > 0):
self.current_stage -= 1
self.success_history = []
print(f"⚠️ Curriculum retreated to Stage {self.current_stage}")
def get_current_params(self):
"""返回当前阶段的发球参数范围。"""
return self.stages[self.current_stage]
KungfuBot 的自适应 Curriculum 机制
KungfuBot(NeurIPS'25)提出了一种更精细的 curriculum——bi-level adaptive tolerance。不是按阶段切换参数范围,而是动态调整 tracking 精度的容忍度:
# KungfuBot 的 bi-level adaptive tolerance(概念重构)
class AdaptiveTolerance:
"""根据当前 tracking error 动态调整容忍度。"""
def __init__(self, initial_tolerance=0.5, min_tolerance=0.05, decay=0.999):
self.tolerance = initial_tolerance
self.min_tolerance = min_tolerance
self.decay = decay
def update(self, tracking_error):
"""
如果 tracking error < 当前 tolerance,缩小 tolerance(增加难度)
如果 tracking error > 当前 tolerance,保持 tolerance(给策略时间学习)
"""
if tracking_error < self.tolerance * 0.8:
self.tolerance = max(self.min_tolerance, self.tolerance * self.decay)
def compute_reward(self, tracking_error):
"""容忍度内的 error 给满分,超出的线性惩罚。"""
normalized = tracking_error / self.tolerance
return torch.clamp(1.0 - normalized, min=0.0)
这种自适应方法的优势是不需要手动设定阶段数和切换条件——tolerance 会自动根据策略能力调整。但它只适用于 tracking 类任务;网球击球的"成功"是二值的(碰到球或没碰到),不能直接用 tracking error。
Phybot 的三阶段解耦
Phybot(arXiv:2511.11218)使用了一种概念上更清晰的三阶段设计——每个阶段训练不同的能力:
Stage 1: Footwork(步法)
- 只训练下肢
- Reward: 移动到指定位置 + 保持平衡 + 能量最小化
- 无球参与,纯 locomotion 任务
Stage 2: Swing(挥拍)
- 只训练上肢(机器人位置固定)
- Reward: 球拍面法向对准 + 球拍速度匹配 + 击中球
- 球从固定位置投出
Stage 3: Task(全任务组合)
- 上下肢协调
- 加载 Stage 1/2 pretrained weights 作为初始化
- 完整随机 launcher + reward
与八阶段 curriculum 的区别:八阶段 curriculum 在同一个 task 中逐步增加难度(环境参数变化),而 Phybot 在不同 task 之间切换(能力解耦)。两者可以组合使用——例如 Stage 1 内部也可以有从慢到快的 curriculum。
⚠️ 常见陷阱
⚠️ 思维陷阱:curriculum 越多阶段越好 - 太多阶段 = 太多超参数(每个阶段的门控条件、参数范围) - 8 阶段已覆盖静态→全随机的完整过渡
⚠️ 编程陷阱:curriculum 推进不可逆 - 如果暂时达标但质量不稳,进入更难阶段后持续失败 - 正确做法:允许回退(上面代码已实现)
⚠️ 概念误区:只按训练步数推进 - 1000 iteration 后推进到下一阶段——但如果 contact rate 还是 0%,推进毫无意义 - 正确做法:按成功率门控,不按步数
练习
- [设计题] 为阶段 0(静态球)设计具体的环境参数:球的位置、高度、机器人初始位置。球应该放在 arm workspace 内(距机器人 0.3-0.5 m),高度约 1.0-1.5 m。
- [设计题] 设计阶段 3→4 的过渡条件和安全回退条件。什么指标达标后推进?什么指标低于什么阈值时回退?
Curriculum 定义了训练难度的递进。下一步是在训练中注入感知噪声——让策略学会在不完美观测下工作。
28.5 Perception Noise Model:与 Ch27 的衔接 ⭐⭐
这一节解决什么问题:将 Ch27 的感知管线与 Ch28 的控制训练连接起来,定义训练时的噪声注入策略。
动机:训练时必须见过噪声
如果策略只在 ground truth 球状态下训练,部署时面对 EKF 估计的有噪声状态——即使噪声只有 2 cm,策略的行为也可能完全不同。回顾 Ch27 的 LATENT 消融实验:去掉 observation noise 后反手成功率从 78% → 0%。这不是理论推测——这是 2026 年最先进的人形网球系统在真机上的定量消融结果。
反事实推理:如果在完全无噪声的 ground truth 下训练到 95% contact rate,然后部署到有 2 cm 噪声的 EKF 输出上——预测球位置偏了 2 cm,策略可能完全无法调整(因为从未在训练中见过这种偏差)。但如果训练时加入了 0-5 cm 的随机噪声,策略会学到"对噪声鲁棒的挥拍策略"——即使预测不完美也能碰到球。
噪声注入的三种策略
| 策略 | 实现 | 适用场景 | 复杂度 | 与 Ch27 的关系 |
|---|---|---|---|---|
| 固定高斯噪声 | ball_pos += N(0, σ²) |
快速起步 | ⭐ | Ch27 方案 B 的简化 |
| DR 噪声范围 | σ ~ U(σ_min, σ_max) |
LATENT 方案 | ⭐⭐ | Ch27 的 LATENT DR 配置 |
| Perception-aware 噪声 | σ = f(distance, robot_speed) |
ETH 方案 | ⭐⭐⭐ | Ch27 的 ETH 噪声模型 |
教学项目推荐从固定高斯噪声开始,验证后升级到 DR 噪声范围。Perception-aware 噪声需要标定真实相机的误差模型,适合高级项目。
在 mjlab 中配置噪声注入
方法 1:通过 ObsTermCfg 的 noise 参数(最简单)
class NoisyBallObsCfg:
"""直接在 ObsTermCfg 中配置噪声。"""
ball_pos = ObsTermCfg(
func=ball_position_court_local,
noise=GaussianNoiseCfg(mean=0.0, std=0.02), # 2 cm 高斯噪声
enable_corruption=True, # 只在训练时启用,评估时可关闭
)
ball_vel = ObsTermCfg(
func=ball_velocity_world,
noise=GaussianNoiseCfg(mean=0.0, std=0.5), # 0.5 m/s 速度噪声
enable_corruption=True,
)
方法 2:通过自定义 obs function(更灵活,支持 DR 和 perception-aware)
def ball_position_with_dr_noise(env) -> torch.Tensor:
"""带 DR 噪声的球位置观测。
噪声标准差在每个 episode 开始时从 DR 分布中采样,
同一 episode 内噪声标准差固定但每步噪声值不同。
"""
gt_pos = env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins
# DR 噪声标准差:每个环境在 episode 开始时采样一次
noise_std = env.dr_params["ball_obs_noise_std"] # [N] 从 EventManager 采样
noise = torch.randn_like(gt_pos) * noise_std.unsqueeze(-1)
return gt_pos + noise
def ball_position_with_perception_aware_noise(env) -> torch.Tensor:
"""ETH 风格的 perception-aware 噪声。
噪声随球距离和机器人运动速度动态变化。
"""
gt_pos = env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins
robot_pos = env.scene["robot"].data.root_pos_w[:, :3] - env.scene.env_origins
robot_vel = env.scene["robot"].data.root_lin_vel_w[:, :3]
# 距离依赖噪声
distance = (gt_pos - robot_pos).norm(dim=-1, keepdim=True)
base_noise = 0.01 + 0.04 * (distance / 10.0) # 1cm @ 0m, 5cm @ 10m
# 运动模糊噪声
robot_speed = robot_vel.norm(dim=-1, keepdim=True)
motion_factor = 1.0 + 0.3 * robot_speed
# 球速依赖噪声(高速球在图像中更模糊)
ball_speed = env.scene["ball"].data.root_lin_vel_w[:, :3].norm(dim=-1, keepdim=True)
speed_factor = 1.0 + 0.1 * ball_speed / 30.0
total_noise_std = base_noise * motion_factor * speed_factor
noise = torch.randn_like(gt_pos) * total_noise_std
return gt_pos + noise
方法 3:通过 Ch27 的 TennisPerceptionPipeline(最真实)
# 直接使用 Ch27 的感知管线作为 obs 来源
def ball_state_from_perception_pipeline(env) -> torch.Tensor:
"""从 Ch27 的 EKF + GRU predictor 获取球状态估计。
这是最接近真实部署的方案——
策略看到的就是 EKF 滤波 + GRU 预测的输出,不是 ground truth。
"""
pred = env.perception_pipeline.step(
raw_observation=env.scene["ball"].data.root_pos_w[:, :3] - env.scene.env_origins,
sim_time=env.sim_time,
launch_cmd=env.commands["launch"].launch_params,
)
return torch.cat([
pred.predicted_state_now, # [N, 6] 当前状态估计
pred.impact_pos_c, # [N, 3] 预测击球点
pred.time_to_impact.unsqueeze(-1), # [N, 1]
pred.uncertainty.unsqueeze(-1), # [N, 1]
pred.validity.float().unsqueeze(-1), # [N, 1]
], dim=-1) # [N, 12]
延迟注入的配置
回顾 Ch27 的 DelayedObservationBuffer——在训练中随机化延迟步数:
class PerceptionNoiseManager:
"""管理感知噪声的注入,与 Curriculum 联动。"""
def __init__(self, num_envs, device):
self.delay_buffer = DelayedObservationBuffer(
num_envs=num_envs, obs_dim=3, max_delay_steps=5, device=device
)
self.noise_std = torch.zeros(num_envs, device=device)
self.delay_steps = torch.zeros(num_envs, dtype=torch.int, device=device)
def configure_for_stage(self, curriculum_stage):
"""根据 Curriculum 阶段配置噪声参数。"""
noise_configs = {
0: {"noise_std": 0.0, "max_delay": 0}, # 无噪声
1: {"noise_std": 0.0, "max_delay": 0}, # 无噪声
2: {"noise_std": 0.0, "max_delay": 0}, # 无噪声
3: {"noise_std": 0.005, "max_delay": 0}, # 极小噪声(5mm)
4: {"noise_std": 0.01, "max_delay": 0}, # 小噪声(1cm)
5: {"noise_std": 0.02, "max_delay": 0}, # 标准噪声(2cm)
6: {"noise_std": 0.03, "max_delay": 1}, # 噪声 + 1帧延迟
7: {"noise_std": 0.03, "max_delay": 3}, # 噪声 + 0-3帧延迟
}
cfg = noise_configs.get(curriculum_stage, noise_configs[7])
self.noise_std[:] = cfg["noise_std"]
self.max_delay = cfg["max_delay"]
def apply(self, gt_ball_pos, sim_time):
"""对 ground truth 球位置施加噪声和延迟。"""
# 1. 添加位置噪声
noise = torch.randn_like(gt_ball_pos) * self.noise_std.unsqueeze(-1)
noisy_pos = gt_ball_pos + noise
# 2. 推入延迟 buffer
self.delay_buffer.push(noisy_pos, sim_time)
# 3. 获取延迟后的观测
if self.max_delay > 0:
delay = torch.randint(0, self.max_delay + 1,
(gt_ball_pos.shape[0],), device=gt_ball_pos.device)
delayed_pos = self.delay_buffer.get_delayed(delay)
else:
delayed_pos = noisy_pos
return delayed_pos
def reset(self, env_ids):
self.delay_buffer.reset(env_ids)
噪声与 Reward 的关系
关键工程决策:噪声应该加在 observation(策略输入) 上,而不是 reward 计算 上。Reward 应该始终用 ground truth 或 physics predictor 计算——确保 reward 信号稳定。策略从 noisy observation 做决策,但 reward 告诉它"真实世界中你做得多好"。
# ✅ 正确做法
obs["ball_pos"] = perception_noise_manager.apply(gt_ball_pos, sim_time) # 噪声 obs
reward = compute_approach_reward_with_physics_predictor(env) # 精确 reward
# ❌ 错误做法
obs["ball_pos"] = gt_ball_pos # 无噪声 obs(部署时会有噪声)
reward = compute_approach_reward(env) # 基于 noisy predictor 的 reward(不稳定)
这正是 PACE(arXiv:2509.21690)的核心设计——learned predictor 给 policy obs(可部署),physics predictor 给 reward(精确稳定)。
⚠️ 常见陷阱
⚠️ 概念误区:噪声注入应该从训练一开始就加 - 噪声让任务更难,策略还没学会在无噪声下击球时加噪声只会更学不会 - 正确做法:Curriculum Stage 0-4 用 ground truth(或极低噪声),Stage 5-7 逐步加噪声
⚠️ 编程陷阱:噪声同时加在 obs 和 reward 上 - 后果:reward 不稳定 → PPO 梯度方差大 → 训练发散 - 正确做法:obs 有噪声,reward 用 ground truth
⚠️ 编程陷阱:噪声 buffer 在 episode reset 时未清空
- 新 episode 开始时 delay buffer 中残留上个 episode 的观测
- 正确做法:在 env.reset() 中调用 noise_manager.reset(env_ids)
⚠️ 思维陷阱:认为噪声标准差越大训练越鲁棒 - 过大的噪声让任务变得不可能完成——策略学到"什么都不做" - 正确做法:噪声范围应覆盖真实部署的噪声水平 + 少量余量
练习
- [设计题] 为每个 Curriculum Stage(0-7)设计噪声配置:位置噪声标准差、速度噪声标准差、最大延迟帧数。解释每阶段的设计理由。
- [编程题] 实现
PerceptionNoiseManager,在训练循环中集成。验证:Stage 0 时 obs 和 ground truth 完全一致,Stage 7 时 obs 有明显偏差。 - [分析题] 如果 LATENT 的消融实验显示"去掉 obs noise 后反手成功率从 78% → 0%",说明反手比正手对噪声更敏感。从运动学角度解释为什么——反手动作的哪些特征让它对时机误差更敏感?
28.6 Domain Randomization 与 Sim2Real 风险 ⭐⭐⭐
这一节解决什么问题:识别仿真与真实之间的 gap,通过 DR 在仿真阶段提前暴露风险。
五类 Sim2Real Gap
| 类别 | 仿真 vs 真实差异 | DR 策略 | DR 范围建议 |
|---|---|---|---|
| 感知 gap | RGB 太干净 | 随机光照/纹理/噪声 | Ch27 噪声模型 |
| 动力学 gap | drag/Magnus 未标定 | 随机 \(C_d\), \(C_L\), mass | ±20-30% |
| 接触 gap | 线床/球体变形难建模 | 随机 friction/restitution/solref | ±30% |
| 执行 gap | 关节延迟/饱和/backlash | 随机 actuator delay/strength | delay 5-15 ms |
| 系统 gap | 网络延迟/标定误差 | 随机 observation delay | 0-3 帧 |
DR 在 mjlab EventManager 中的配置
class TennisDREventCfg:
"""网球击球任务的 Domain Randomization 事件配置。"""
# 球质量随机化
ball_mass_dr = EventTermCfg(
func=mdp.randomize_rigid_body_mass,
mode="reset", # 每个 episode 重新采样
params={
"asset_cfg": SceneEntityCfg("ball"),
"mass_distribution_params": (0.045, 0.070), # ITF: 56-59.4g ±20%
},
)
# 球场摩擦随机化
court_friction_dr = EventTermCfg(
func=mdp.randomize_rigid_body_material,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("court"),
"static_friction_range": (0.4, 0.8),
"dynamic_friction_range": (0.3, 0.7),
"restitution_range": (0.6, 0.9),
},
)
# 球拍-球摩擦随机化
racket_friction_dr = EventTermCfg(
func=mdp.randomize_rigid_body_material,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("racket"),
"static_friction_range": (0.2, 0.6),
},
)
# 执行器强度随机化
actuator_strength_dr = EventTermCfg(
func=mdp.randomize_actuator_gains,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("robot"),
"stiffness_distribution_params": (0.8, 1.2), # ±20%
"damping_distribution_params": (0.8, 1.2),
},
)
# 空气动力学参数随机化
aero_dr = EventTermCfg(
func=randomize_ball_aerodynamics,
mode="reset",
params={
"drag_coefficient_range": (0.4, 0.7),
"lift_coefficient_range": (0.5, 1.5),
},
)
逐项引入原则
不要一次加满。每加入一项 DR,记录成功率变化:
# DR 消融实验脚本
dr_configs = [
{"name": "baseline", "dr_items": []},
{"name": "+ball_mass", "dr_items": ["ball_mass_dr"]},
{"name": "+court_friction", "dr_items": ["ball_mass_dr", "court_friction_dr"]},
{"name": "+racket_friction", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr"]},
{"name": "+actuator", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr", "actuator_strength_dr"]},
{"name": "+aero", "dr_items": ["ball_mass_dr", "court_friction_dr", "racket_friction_dr", "actuator_strength_dr", "aero_dr"]},
]
for config in dr_configs:
# 训练并记录最终 contact_rate 和 over_net_rate
result = train_with_dr(config["dr_items"], max_iterations=5000)
print(f"{config['name']}: contact={result['contact_rate']:.1%}, "
f"over_net={result['over_net_rate']:.1%}")
预期结果:每加一项 DR,成功率会下降 5-15%——但最终在真机上的 zero-shot 性能更好。如果某项 DR 导致成功率断崖式下降(> 50%),说明 DR 范围太宽或 reward 对该参数过敏。
⚠️ 常见陷阱
⚠️ 思维陷阱:DR 是万能保险 - 如果接触模型结构就是错的(没建模线床变形),DR 只在错误模型的参数空间加噪声 - 正确做法:DR 覆盖参数不确定性,不替代正确物理建模
⚠️ 概念误区:DR 应该训练一开始就加 - DR 让任务更难学,策略还没学会标准环境中击球时加 DR 只会更学不会 - 正确做法:先在固定环境中训练到合理 contact rate(Curriculum Stage 0-4),再逐步引入 DR
练习
- [设计题] 为 ball mass 设计 DR 范围。ITF 规定 56.0-59.4 g,你的 DR 范围应该比 ITF 范围更宽还是更窄?为什么?提示:DR 需要覆盖标定误差和真实世界的变异。
- [实验题] 运行上述 DR 消融实验,绘制"每加一项 DR → 成功率变化"的柱状图。哪项 DR 影响最大?
Isaac Lab 中的 DR 对照
Isaac Lab 的 DR 配置方式与 mjlab 类似但使用不同的 API:
# Isaac Lab 中的 DR 配置
from isaaclab.envs.mdp import events as mdp_events
class IsaacLabDRCfg:
randomize_ball_mass = EventTermCfg(
func=mdp_events.randomize_rigid_body_mass,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("ball"),
"mass_distribution_params": (0.045, 0.070),
"operation": "abs", # Isaac Lab 特有:绝对值替换
},
)
randomize_friction = EventTermCfg(
func=mdp_events.randomize_rigid_body_material,
mode="reset",
params={
"asset_cfg": SceneEntityCfg("court", body_names=".*"),
"static_friction_range": (0.4, 0.8),
"dynamic_friction_range": (0.3, 0.7),
"restitution_range": (0.6, 0.9),
},
)
跨框架 DR 的一致性验证:由于 MuJoCo 和 PhysX 的接触模型不同(Ch26),同样的 friction 值在两个引擎中的行为可能不同。建议在两个框架中分别做 COR 标定(Ch26 方法),确保 DR 范围在两个框架中都能产生合理的球弹跳行为。
DR 与 Curriculum 的联动
DR 不应该在 Curriculum 所有阶段都启用——应该随 Curriculum 推进逐步引入:
class CurriculumAwareDR:
"""DR 强度随 Curriculum 阶段自适应调整。"""
def __init__(self):
# 每个 DR 项在哪个 Curriculum Stage 引入
self.dr_schedule = {
"ball_mass_dr": {"start_stage": 4, "scale_start": 0.3, "scale_end": 1.0},
"court_friction_dr": {"start_stage": 4, "scale_start": 0.3, "scale_end": 1.0},
"racket_friction_dr": {"start_stage": 5, "scale_start": 0.3, "scale_end": 1.0},
"actuator_strength_dr": {"start_stage": 5, "scale_start": 0.5, "scale_end": 1.0},
"aero_dr": {"start_stage": 6, "scale_start": 0.5, "scale_end": 1.0},
}
def get_dr_scale(self, dr_name, curriculum_stage):
"""返回指定 DR 项在当前 Curriculum Stage 的缩放比例。"""
schedule = self.dr_schedule[dr_name]
if curriculum_stage < schedule["start_stage"]:
return 0.0 # DR 未启用
# 线性插值
progress = min(1.0, (curriculum_stage - schedule["start_stage"]) / 3.0)
scale = schedule["scale_start"] + progress * (schedule["scale_end"] - schedule["scale_start"])
return scale
def apply_to_event_cfg(self, event_cfg, curriculum_stage):
"""根据 Curriculum Stage 调整 DR 范围。"""
for name, term in event_cfg.__dict__.items():
if name in self.dr_schedule:
scale = self.get_dr_scale(name, curriculum_stage)
if scale == 0.0:
term.enabled = False
else:
# 缩小 DR 范围(中心值不变,宽度按 scale 缩放)
for key in term.params:
if key.endswith("_range"):
low, high = term.params[key]
center = (low + high) / 2
half_width = (high - low) / 2 * scale
term.params[key] = (center - half_width, center + half_width)
term.enabled = True
这种联动设计确保了:Curriculum Stage 0-3(策略还在学基本击球)时没有 DR 干扰;Stage 4-5 时逐步引入 DR(范围较窄);Stage 6-7 时使用完整 DR 范围。这与 LATENT 的训练策略一致——先在简单条件下建立基本能力,再用 DR 增强鲁棒性。
28.7 Constrained RL 安全约束 ⭐⭐⭐
这一节解决什么问题:如何在 RL 训练中融入硬件安全约束,防止策略学到损坏机器人的动作。
动机:为什么 reward penalty 不够
用 reward penalty(r -= w * violation)来惩罚不安全动作有一个根本问题:如果不安全动作偶尔带来高 reward(比如极端挥拍碰到了球),策略可能学到"冒险挥拍"——因为 reward 的期望值仍然是正的。在仿真中这没问题,但在真机上一次极端动作就可能损坏电机。
安全约束需要的是硬限制——不是"做了会扣分"而是"根本不允许做"。
Lagrangian PPO 实现
回顾 Ch25 中的 Constrained PPO——将安全约束优化转化为 Lagrangian 对偶问题:
class LagrangianPPOForTennis:
"""网球击球任务的 Lagrangian PPO。"""
def __init__(self, cost_configs):
"""
cost_configs: 安全约束列表
每项包含 name, cost_function, limit
"""
self.costs = cost_configs
self.lambdas = {cfg["name"]: 0.0 for cfg in cost_configs}
self.lambda_lr = 0.005 # 乘子学习率(远小于策略 lr)
def compute_augmented_reward(self, reward, env):
"""计算 Lagrangian 增强 reward。"""
augmented = reward.clone()
for cfg in self.costs:
cost = cfg["cost_function"](env) # [N]
lam = self.lambdas[cfg["name"]]
augmented -= lam * cost
return augmented
def update_lambdas(self, avg_costs):
"""每个 rollout 后更新 Lagrange 乘子。"""
for cfg in self.costs:
violation = avg_costs[cfg["name"]] - cfg["limit"]
self.lambdas[cfg["name"]] = max(
0.0,
min(10.0, self.lambdas[cfg["name"]] + self.lambda_lr * violation)
)
# 安全约束定义
tennis_safety_constraints = [
{
"name": "joint_velocity_limit",
"cost_function": lambda env: (
env.scene["robot"].data.joint_vel.abs() >
env.scene["robot"].data.joint_vel_limits * 0.9
).float().mean(dim=-1),
"limit": 0.05, # 不超过 5% 的时间步违规
},
{
"name": "base_tilt_limit",
"cost_function": lambda env: (
env.scene["robot"].data.projected_gravity_b[:, :2].norm(dim=-1) > 0.5
).float(),
"limit": 0.02, # 不超过 2% 的时间步倾斜过大
},
{
"name": "racket_ground_contact",
"cost_function": lambda env: env.contact_sensor.detect_contact(
geom_a="racket_face", geom_b="court_surface"
).float(),
"limit": 0.0, # 球拍不能碰地
},
]
Termination 与安全设计
| 类别 | 条件 | 优先级 | time_out | 含义 |
|---|---|---|---|---|
| 安全 | robot fall (body height < 0.3 m) | 最高 | False | 机器人倒地 → 立即终止 |
| 安全 | joint limit severe (> 95% of limit) | 最高 | False | 关节接近极限 → 可能损坏 |
| 安全 | self collision (检测到) | 最高 | False | 自碰撞 → 硬件风险 |
| 安全 | racket hit ground | 高 | False | 球拍碰地 → 球拍损坏 |
| 安全 | excessive contact force (> 500 N) | 高 | False | 接触力过大 → 关节损坏 |
| 任务 | ball missed (ball stopped, no contact) | 中 | False | 球停了但没碰到 → 失败 |
| 任务 | ball oob after contact | 中 | False | 出界 → 评估完成 |
| 任务 | ball landed after contact | 中 | False | 击球任务完成 → MDP terminal(terminated) |
| 仿真 | timeout (5 秒) | 低 | True | 人为时间截断 → truncation,value bootstrap |
关键设计:要分清 Isaac Lab 的两类终止语义——time_out=True 表示人为时间截断(truncation),进入 truncated/timeouts 通道,RSL-RL 据此对最终状态做 value bootstrapping(\(V(s_T) \neq 0\),因为真实 MDP 本会继续);time_out=False 表示真正的 MDP 终止(terminated),\(V(s_T)=0\)。ball landed after contact 是单次击球任务的真实终止(球已落地、本回合结束、无后续回报),因此应设为 time_out=False,只有 5 秒上限这种人为截断才用 time_out=True。
那"策略会不会为了避免 episode 结束而学到不击球(Ch25 模式九自杀策略)"?正确的解决办法是奖励设计——成功击球落入目标区给显著正的 outcome reward,策略自然被引导去击球,而不是靠把成功终止错标成 timeout 来回避。把成功落地标成
time_out=True反而会混淆终止统计、is_terminated类奖励和价值目标。
Termination 的完整配置代码
class TennisStrikeTerminationsCfg:
"""击球任务的完整 termination 配置。"""
# === 安全 termination(立即终止,不做 value bootstrapping)===
robot_fall = TerminationTermCfg(
func=mdp.root_height_below_threshold,
params={"minimum_height": 0.3},
time_out=False,
)
joint_limit_violation = TerminationTermCfg(
func=mdp.joint_pos_out_of_limit,
params={"margin": 0.05},
time_out=False,
)
self_collision = TerminationTermCfg(
func=mdp.illegal_contact,
params={
"threshold": 1.0,
"sensor_cfg": SceneEntityCfg("contact_sensor", body_names=".*"),
},
time_out=False,
)
racket_ground_contact = TerminationTermCfg(
func=detect_racket_ground_contact,
time_out=False,
)
# === 任务 termination ===
ball_missed = TerminationTermCfg(
func=ball_stopped_no_contact,
params={"speed_threshold": 0.5, "z_threshold": 0.05},
time_out=False,
)
ball_oob = TerminationTermCfg(
func=ball_out_of_bounds,
params={"x_limit": 15.0, "y_limit": 8.0, "z_limit": -0.5},
time_out=False,
)
# === 任务成功结束(MDP terminal,terminated,不做 timeout bootstrapping)===
# 注意:击球任务完成(球落地)是真正的 MDP 终止,应走 terminated(time_out=False);
# 只有"人为时间截断"才用 time_out=True(进入 truncated/timeouts 通道,RSL-RL 据此 bootstrap value)。
ball_landed_after_contact = TerminationTermCfg(
func=ball_landed_post_contact,
time_out=False,
)
# === 时间截断(truncation,value bootstrapping)===
timeout = TerminationTermCfg(
func=mdp.time_out,
params={"max_episode_length_s": 5.0},
time_out=True,
)
CBF 安全过滤器(推理层安全保证)
Lagrangian PPO 提供训练层的统计安全保证。但单个 episode 仍可能违反约束。部署时需要推理层的逐步安全保证——CBF(Control Barrier Function)安全过滤器。
数学基础(Ames et al. 2014-2019):定义安全集合 \(\mathcal{C} = \{x : h(x) \geq 0\}\),其中 \(h\) 是连续可微的 barrier function。\(h(x) > 0\) 表示安全,\(h(x) = 0\) 是边界,\(h(x) < 0\) 是危险区域。对仿射控制系统 \(\dot{x} = f(x) + g(x)u\),CBF 安全条件为:
其中 \(L_f h\) 和 \(L_g h\) 是 Lie 导数,\(\gamma > 0\) 控制保守程度。直觉:\(h(x)\) 很大时右侧 \(-\gamma h\) 是大负数,约束很弱——策略自由行动。\(h(x)\) 接近零时约束变紧——策略被迫调整。就像汽车自适应巡航:车距远时自由加减速,车距近时自动减速。
网球击球中的典型 barrier function:
| 约束 | \(h(x)\) | 安全含义 |
|---|---|---|
| 关节位置 | \((q_{\max} - q)(q - q_{\min})\) | 关节在允许范围内 |
| 机器人最小高度 | \(z_{\text{root}} - z_{\min}\) | 不跌倒 |
| 接触力 | \(f_{\max} - \|f_{\text{contact}}\|\) | 接触力不超限 |
| 球拍高度 | \(z_{\text{racket}} - z_{\text{ground}}\) | 球拍不碰地 |
CBF 的核心思想:在策略输出后、执行前,检查动作是否可能导致状态越过安全边界。如果可能越界,用 QP 找到最近的安全动作。以下是教学简化版本(直接约束关节速度):
def cbf_joint_limit_filter(
action: torch.Tensor, # [N, action_dim] 策略输出
joint_pos: torch.Tensor, # [N, n_j] 当前关节位置
joint_vel: torch.Tensor, # [N, n_j] 当前关节速度
joint_limits: torch.Tensor, # [n_j, 2] (min, max)
dt: float = 0.02,
gamma: float = 2.0,
) -> torch.Tensor:
"""CBF 安全过滤器:防止关节越限。
对每个关节 j 定义两个 barrier function:
h_lower(q) = q_j - q_min_j (距下限余量)
h_upper(q) = q_max_j - q_j (距上限余量)
CBF 条件(简化为速度约束):
q̇_j >= -gamma * (q_j - q_min_j) (不能太快向下限移动)
q̇_j <= +gamma * (q_max_j - q_j) (不能太快向上限移动)
"""
q_min = joint_limits[:, 0] # [n_j]
q_max = joint_limits[:, 1] # [n_j]
# 预估下一步关节位置(简化:假设 action 直接影响关节速度)
# 实际中需要通过 IK Jacobian 映射
estimated_vel = joint_vel + action[:, 3:] * dt # 简化
# CBF 速度约束
vel_lower = -gamma * (joint_pos - q_min)
vel_upper = gamma * (q_max - joint_pos)
# Clamp 到安全范围
safe_vel = torch.clamp(estimated_vel, min=vel_lower, max=vel_upper)
# 逆映射回 action(简化)
safe_action = action.clone()
safe_action[:, 3:] = (safe_vel - joint_vel) / dt
return safe_action
⚠️ 重要工程声明:上述 CBF 是教学简化版本——它直接约束关节速度而非通过 IK Jacobian 映射 task-space action 到 joint-space 约束。完整的 CBF-QP 需要知道动作到关节速度的映射(\(J^{-1}\)),计算量更大但更精确。真机部署时不应直接使用此简化版本——应使用基于 Jacobian 的完整 CBF-QP,或至少在此基础上叠加硬件层的 PD 限幅保护。对教学目的,简化版本足够演示 CBF 的核心思想——"在安全边界处最小修正策略"。
本质洞察:三层安全架构(训练层 Lagrangian + 推理层 CBF + 硬件层 PD 限幅) 就像汽车的三重保护:安全意识培训(训练)→ 自动紧急制动(CBF)→ 安全带(硬件限幅)。 每层都可能失效,但三层同时失效的概率极低。
策略的"放弃"能力
策略应该有能力决定"这个球不打了"——当 Ch27 的 PredictorOutput.validity = False 时(球不可达),机器人应该回到 ready pose 而非强行挥拍。真实系统中不可达球比危险挥拍更可接受。
def compute_give_up_reward(env):
"""当球不可达时,回到 ready pose 给 reward。"""
should_give_up = ~env.perception.validity
if should_give_up.any():
current_joint_pos = env.scene["robot"].data.joint_pos
ready_joint_pos = env.default_joint_pos
distance_to_ready = (current_joint_pos - ready_joint_pos).norm(dim=-1)
give_up_reward = torch.exp(-distance_to_ready / 0.5)
give_up_reward = give_up_reward * should_give_up.float()
else:
give_up_reward = torch.zeros(env.num_envs, device=env.device)
return give_up_reward
Lambda 训练轨迹诊断
Lagrange 乘子 \(\lambda\) 的训练轨迹是诊断安全约束是否合理的关键信号:
# WandB 中监控 lambda 轨迹
# Lambda/joint_velocity_limit = 0.5 → 约束活跃但不过度
# Lambda/joint_velocity_limit 持续上升 → 约束太紧,策略无法在约束内完成任务
# Lambda/joint_velocity_limit = 0 → 约束太松,可以收紧
# Lambda/joint_velocity_limit 轻微、收敛范围内振荡 → 约束活跃;
# 但持续大幅振荡通常需检查 lambda_lr 过大、cost 归一化、约束上限或 curriculum 难度(参见 PID-Lagrangian)
⚠️ 常见陷阱
⚠️ 编程陷阱:termination 吞掉 outcome 评估窗口 - 球拍碰到球就 terminate → outcome reward 无法计算 - 正确做法:contact 后继续运行到球落地或出界
⚠️ 思维陷阱:策略学到"总是放弃" - 如果 miss penalty 太弱或 approach reward 不足,策略发现"不动"比"挥拍但失败"更安全 - 修复:确保接近球的行为有持续正 reward;give_up reward 的权重不能大于 approach reward
⚠️ 编程陷阱:Lagrange 乘子 lambda_lr 设太大 - 乘子振荡导致策略在"满足约束"和"完成任务"之间反复切换 - 正确做法:lambda_lr = policy_lr / 10 ~ policy_lr / 100
⚠️ 概念误区:CBF 可以替代 reward shaping - CBF 保证安全但不优化性能。策略仍需 reward 学习好的行为 - 两者互补:reward 负责"学会做什么",CBF 负责"不允许做什么"
练习
- [设计题] 为网球击球任务设计完整的 termination 列表(至少 8 项),区分安全/任务/仿真三类,标注 time_out 设置和优先级。
- [编程题] 实现
LagrangianPPOForTennis,在标准 PPO 训练循环中加入安全约束。在 WandB 中监控 lambda 轨迹——lambda 持续上升意味着什么?需要怎么调整? - [推导题] 对一维系统 \(\dot{q} = u\),推导关节位置限制 \(h(q) = (q_{max} - q)(q - q_{min})\) 的 CBF 条件 \(\dot{h} \geq -\gamma h\)。这个条件如何转化为对 \(u\) 的约束?
28.8 实时性与频率分离 ⭐⭐⭐
这一节解决什么问题:击球控制的时间约束分析和频率设计。这不是"性能优化"——而是"任务能否完成"的前提。
动机:频率决定了策略能否"看到"击球窗口
回顾 Ch26:网球拍-球接触持续约 3-5 ms。如果控制步长为 20 ms(50 Hz),一次接触只有 1 个控制步在"接触区间"内——策略在这个步之前还不知道球到了,这个步之后球已经飞走了。策略必须提前开始挥拍,而不是"看到球到了再反应"。
定量分析:球速 30 m/s,控制频率 50 Hz。
| 参数 | 数值 | 含义 |
|---|---|---|
| 控制步长 | 20 ms | 策略每 20 ms 做一次决策 |
| 球单步移动 | 0.60 m | 每个控制步球飞 60 cm |
| 球拍有效直径 | ~0.30 m | 球拍面可击球的宽度 |
| 击球窗口 | < 1 步 | 球从"可打"到"飞过"不到一个控制步! |
| 反应时间需求 | ~0.3-0.5 s | 球从远端飞到近端 |
| 可用决策步数 | 15-25 步 | 反应窗口内的控制步数 |
关键认知:策略不是在"击球那一刻"做决策——而是在球飞来的0.3-0.5 秒内规划整个击球动作。这就像棒球击球手——不是看到球到好球带才挥棒,而是在投手投球后 0.2 秒内就决定要不要挥棒、怎么挥。
频率需求分析
| 模块 | 频率 | 决定因素 | 前沿系统参考 |
|---|---|---|---|
| Predictor(Ch27) | 50-100 Hz | 相机帧率、EKF 推理时间 | HITTER: OptiTrack 360 Hz |
| Policy(Ch28) | 50-100 Hz | env step、球速约束 | LATENT: 50 Hz, HITTER: 50 Hz |
| IK/Controller | 500-1000 Hz | 关节动力学时间常数 | LATENT: PD @ 2000 Hz |
| Physics sim | 1000-2000 Hz | 接触数值稳定性 | LATENT: 2000 Hz, HITTER: 2000 Hz |
前沿系统的频率配置对比
| 系统 | 物理频率 | 控制频率 | 感知频率 | decimation | 理由 |
|---|---|---|---|---|---|
| LATENT | 2000 Hz | 50 Hz | 50 Hz | 40 | 球拍-球接触需要高频仿真 |
| HITTER | 2000 Hz | 50 Hz | 360 Hz | 40 | 同上 + 高频动捕 |
| ETH 羽毛球 | 1000 Hz | 50 Hz | 30 Hz | 20 | 羽毛球速较慢 |
| Phybot | 1000 Hz | 50 Hz | 100 Hz | 20 | 同上 |
| 当前 mjlab Tennis | 500 Hz | 100 Hz | N/A | 5 | Phase 1 无接触 |
Phase 1 → Strike Task 的频率升级:当前 timestep=0.002(500 Hz)对纯弹道飞行足够,但对球拍-球接触不足。Ch28 的 strike task 应升级到 timestep=0.001(1000 Hz)或 timestep=0.0005(2000 Hz),对应 decimation=20 或 decimation=40(保持 50 Hz 控制频率)。
# Strike task 的仿真配置(从 Phase 1 升级)
class TennisStrikeSimCfg:
# Phase 1(弹道验证)
# timestep = 0.002 # 500 Hz
# decimation = 5 # 100 Hz 控制
# Strike task(需要准确的接触解算)
timestep = 0.0005 # 2000 Hz(LATENT 配置)
decimation = 40 # 50 Hz 控制(2000/40 = 50)
# 性能影响
# 仿真步数翻 4 倍(500→2000 Hz)
# 但 env step 不变(50 Hz 控制 → 每个 policy step 40 个仿真步)
# 4096 环境 × 40 仿真步/控制步 ≈ 每个 rollout step 16 万仿真步
# 在单 GPU 上约需 50-100 ms(可接受)
频率分离架构的完整设计
高速接触需要频率分离:高层策略低频更新击球计划,低层 controller 高频追踪轨迹。这与腿足控制中 MPC(10-100 Hz)+ WBC(500-1000 Hz)的分层架构完全一致。
class TennisFrequencySeparation:
"""网球击球的频率分离架构。"""
def __init__(self, policy, ik_solver, predictor):
self.policy = policy # 50 Hz 高层策略
self.ik_solver = ik_solver # 内置在 action transform 中
self.predictor = predictor # 50 Hz 感知
self.pd_controller = PDController() # 2000 Hz PD 追踪
self.current_action = None
self.high_level_counter = 0
self.decimation = 40 # 2000/50 = 40
def step_physics(self, robot_state, sim_step):
"""每个仿真步(2000 Hz)调用一次。"""
# 每 40 个仿真步更新一次高层策略
if sim_step % self.decimation == 0:
obs = self._build_observation(robot_state)
pred = self.predictor.step(obs)
self.current_action = self.policy.act(obs)
# 每个仿真步都用 PD 控制器追踪
if self.current_action is not None:
# IK 把 task-space action 转为 joint targets
joint_targets = self.ik_solver.solve(self.current_action, robot_state)
# PD 控制
torques = self.pd_controller.compute(
target=joint_targets,
current_pos=robot_state.joint_pos,
current_vel=robot_state.joint_vel,
)
return torques
else:
return torch.zeros_like(robot_state.joint_pos) # 零力矩
延迟预算表
从球发出到策略输出动作到机器人执行,每个环节的延迟:
| 环节 | 仿真中 | 真机(动捕) | 真机(视觉) |
|---|---|---|---|
| 感知采集 | 0 ms | 3 ms | 16-33 ms |
| 状态估计(EKF) | 0 ms | < 1 ms | < 1 ms |
| 轨迹预测(GRU) | 0 ms | 1-2 ms | 1-2 ms |
| 策略推理 | 0 ms | 2-5 ms | 2-5 ms |
| IK 计算 | 内置 | 1-2 ms | 1-2 ms |
| 通信延迟 | 0 ms | 1-3 ms | 1-3 ms |
| 执行器响应 | 0 ms | 5-20 ms | 5-20 ms |
| 总延迟 | 0 ms | ~15-35 ms | ~30-80 ms |
球速 30 m/s + 总延迟 50 ms → 延迟位置误差 1.5 m——超过球拍有效范围!这就是为什么 Ch27 的延迟补偿和 Ch28 的 perception noise model 如此重要——延迟不补偿,策略永远挥晚。
反事实推理:如果 policy 频率只有 10 Hz(env step = 0.100 s),球速 35 m/s 时一个 step 球移动 3.5 m——这比半个球场还长。策略在整个飞行过程中只有 3-5 次决策机会,根本无法精确控制挥拍时机。这就像用 3 帧动画表达一个复杂的击球动作——帧数不够信息就丢失了。
⚠️ 常见陷阱
⚠️ 思维陷阱:训练时不考虑延迟 - 策略在无延迟仿真中学到"球到面前再挥拍",部署时总挥晚 - 正确做法:Curriculum Stage 7 注入 observation delay
⚠️ 编程陷阱:提高仿真频率时忘记调整 decimation
- timestep=0.0005 + decimation=5 → 控制频率 400 Hz → policy 被调用太频繁,rollout 步数不够
- 正确做法:timestep=0.0005 + decimation=40 → 50 Hz 控制
⚠️ 概念误区:更高仿真频率总是更好 - 2000 Hz → 4000 Hz 增加一倍计算量但接触精度改善有限 - 正确做法:2000 Hz 对网球已经足够(LATENT 验证过),不需要更高
练习
- [估算题] policy 50 Hz + 球速 35 m/s,一个 step 球移动多少?和球拍半径 0.15 m 比较。如果球从远端(x=11m)飞到近端(x=-8m),飞行约 19m。在 50 Hz 控制下策略有多少个决策步?
- [设计题] 设计从 Phase 1(500 Hz / 100 Hz)到 Strike Task(2000 Hz / 50 Hz)的仿真配置升级计划。列出需要修改的所有参数和预期的性能影响。
- [估算题] 如果真机总延迟 50 ms,球速 30 m/s,Ch27 的延迟补偿将球状态前推 50 ms。补偿后的残余误差主要来自什么?提示:前推 50 ms 使用的是近似的物理模型,不是精确模型。
28.9 网球 Sim2Real 特殊挑战 ⭐⭐⭐
这一节解决什么问题:总结网球 Sim2Real 的特有挑战和工程对策。
五类特殊挑战
| 挑战 | 网球特有的原因 | 对策 | Ch 参考 |
|---|---|---|---|
| 接触模型 gap | 线床变形、球体压缩、接触面积变化 | DR friction/restitution + 增大 contact 碰撞体厚度 | Ch26 |
| 空气动力学 gap | 真实 drag/Magnus 与简化模型有偏差 | DR \(C_d\)/\(C_L\) + GRU 残差(Ch27) | Ch26, Ch27 |
| 感知延迟 | 总延迟 20-100 ms vs 球飞行 0.5-1 s | 延迟补偿(Ch27)+ DR 延迟 | Ch27 |
| 执行器饱和 | 高速挥拍时电机力矩饱和 | DR actuator strength + action clamp + CBF | Ch28 |
| 接触时机精度 | 3-5 ms 接触窗口 vs ~20 ms 控制步长 | 2000 Hz 仿真 + Butterworth 滤波 | Ch26, Ch25 |
LATENT 的完整 Sim2Real 工程路线
LATENT 的 Sim2Real 路线是目前最完整的网球部署方案。以下是其工程细节(根据论文和代码库重构):
Phase 1:仿真训练(MuJoCo JAX, 2000 Hz, 8 GPU)
# LATENT 的训练配置(重构)
class LATENTTrainingConfig:
# 仿真
sim_frequency: int = 2000 # Hz
control_frequency: int = 50 # Hz
num_envs: int = 4096
num_gpus: int = 8
# 动力学 DR(必需——去掉后 91% → 14-29%)
ball_mass_range: tuple = (0.045, 0.070)
ball_restitution_range: tuple = (0.60, 0.85)
ball_drag_range: tuple = (0.40, 0.70)
ball_friction_range: tuple = (0.3, 0.8)
# 观测噪声 DR(必需——去掉后反手 78% → 0%)
ball_pos_noise_std: float = 0.02
ball_vel_noise_std: float = 0.5
ball_obs_delay_range: tuple = (0, 3) # 帧
# 机器人 DR
joint_friction_range: tuple = (0.5, 1.5)
motor_strength_range: tuple = (0.8, 1.2)
action_delay_range: tuple = (0, 2) # 帧
Phase 2:Sim2Sim 验证
在另一个仿真器中回放策略,验证关节顺序和动作缩放的一致性。LATENT 使用 MuJoCo JAX 训练、MuJoCo C 回放——两者的 joint ordering 可能不同。
def sim2sim_verification(policy_onnx, env_mjcf):
"""Sim2Sim 验证流程。"""
# 1. 加载策略
import onnxruntime as ort
session = ort.InferenceSession(policy_onnx)
# 2. 在 MuJoCo C 中运行
model = mujoco.MjModel.from_xml_path(env_mjcf)
data = mujoco.MjData(model)
for step in range(1000):
# 构造观测
obs = build_observation(data)
# 策略推理
action = session.run(None, {"obs": obs.numpy()})[0]
# 检查 joint ordering
verify_joint_mapping(action, model)
# 执行
data.ctrl[:] = action[0]
mujoco.mj_step(model, data)
print("✅ Sim2Sim verification passed")
Phase 3:真机低速测试
# 真机部署脚本框架
class RealWorldDeployment:
def __init__(self, policy_path, robot_interface):
self.policy = load_onnx_policy(policy_path)
self.robot = robot_interface
self.filter = ButterworthFilter(order=2, cutoff=4.0, fs=50.0, num_joints=29)
self.beta = 0.3 # 初始 action rescaler(保守)
def run_episode(self, ball_launcher):
"""运行一个 episode。"""
# 发球
ball_launcher.launch(speed=15.0) # 先用低速
for step in range(500):
# 获取观测
robot_state = self.robot.get_state()
ball_state = self.perception.get_ball_state()
obs = build_observation(robot_state, ball_state)
# 策略推理
raw_action = self.policy.infer(obs)
# 安全处理
scaled_action = raw_action * self.beta
filtered_action = self.filter.filter(scaled_action)
# 安全检查
if self.is_unsafe(filtered_action, robot_state):
self.robot.emergency_stop()
break
# 执行
self.robot.send_action(filtered_action)
# 检查 episode 结束
if ball_state.stopped or ball_state.oob:
break
def is_unsafe(self, action, state):
"""运行时安全检查。"""
# 关节速度过大
if (state.joint_vel.abs() > state.joint_vel_limits * 0.95).any():
return True
# 机器人倾斜过大
if state.body_tilt > 0.7: # ~40 度
return True
# 球拍太低(可能碰地)
if state.racket_height < 0.1:
return True
return False
def gradually_increase_difficulty(self):
"""逐步增加难度。"""
# Step 1: 低速 + 低 β
self.beta = 0.3
results_low = self.run_n_episodes(20, ball_speed=15.0)
if results_low["contact_rate"] < 0.5:
print("⚠️ Low-speed contact rate too low, check deployment")
return
# Step 2: 低速 + 高 β
self.beta = 0.8
results_mid = self.run_n_episodes(20, ball_speed=15.0)
# Step 3: 中速 + 高 β
results_fast = self.run_n_episodes(20, ball_speed=25.0)
# Step 4: 全速
self.beta = 1.0
results_full = self.run_n_episodes(50, ball_speed=35.0)
print(f"Deployment results:")
print(f" Low speed: contact={results_low['contact_rate']:.1%}")
print(f" Mid speed: contact={results_mid['contact_rate']:.1%}")
print(f" High speed: contact={results_fast['contact_rate']:.1%}")
print(f" Full speed: contact={results_full['contact_rate']:.1%}")
WoCoCo 的部署安全措施
WoCoCo(CoRL'24)在部署时使用了两道安全措施(Ch25 已详述),这里给出在网球击球中的具体配置:
class WoCoCoDeploySafety:
"""WoCoCo 风格的部署安全包装。"""
def __init__(self):
# 1. Butterworth 低通滤波器
# 截止频率 4 Hz @ 50 Hz 控制 → 滤除 > 4 Hz 的高频噪声
self.filter = ButterworthFilter(
order=2, cutoff=4.0, fs=50.0, num_joints=29
)
# 2. Action rescaler
# 部署初期 β=0.3(动作幅度减半),逐步提高到 1.0
self.beta = 0.3
# 3. 速度限制
self.max_joint_vel = 5.0 # rad/s(保守)
# 4. 平滑过渡
self.prev_action = None
self.max_action_rate = 2.0 # rad/step
def apply(self, raw_action):
"""完整的安全处理链。"""
# Step 1: 缩放
action = raw_action * self.beta
# Step 2: 速率限制
if self.prev_action is not None:
delta = action - self.prev_action
delta = torch.clamp(delta, -self.max_action_rate, self.max_action_rate)
action = self.prev_action + delta
# Step 3: 滤波
action = self.filter.filter(action)
# Step 4: 速度限制
action = torch.clamp(action, -self.max_joint_vel, self.max_joint_vel)
self.prev_action = action.clone()
return action
从 LATENT 消融实验中学到的工程教训
LATENT 的消融实验提供了关于 Sim2Real 的三个关键工程教训:
教训 1:Dynamics DR 是不可省略的。 去掉后成功率从 91% → 14-29%。这说明仿真中的物理参数和真实世界之间存在显著的 gap——即使你认为自己标定得很好。DR 的本质不是让仿真更"真实",而是让策略对参数不确定性鲁棒。
教训 2:Observation noise 对某些技能是生死攸关的。 去掉后反手成功率从 78% → 0%。反手比正手更敏感的原因是:反手动作的机械约束更多(手臂活动范围更小),需要更精确的时机控制。精确时机依赖于准确的延迟补偿——如果训练时没见过延迟/噪声,部署时延迟就会导致系统性的时机偏差。
教训 3:两者必须同时存在。 只有 dynamics DR 没有 observation noise → 50% 正手但 0% 反手。只有 observation noise 没有 dynamics DR → 效果更差。两种 DR 覆盖不同的 gap——dynamics DR 覆盖物理参数的不确定性,observation noise 覆盖感知管线的不确定性。
# LATENT 真机消融实验的定量结果(按论文 Table 5,单位 %)
# 论文 Table 5 的 w/o DR 行按 Forehand/Backhand/Forecourt/Backcourt 为 16.67/25.00/28.57/14.29,
# 此处仅取 Forehand/Backhand(早期版本曾误把 Forecourt/Backcourt 的 28.57/14.29 写成 forehand/backhand)。
latent_ablation = {
"full_system": {"forehand": 90.90, "backhand": 77.78},
"no_dynamics_dr": {"forehand": 16.67, "backhand": 25.00},
"no_obs_noise": {"forehand": 50.00, "backhand": 0.00},
}
⚠️ 常见陷阱
⚠️ 思维陷阱:仿真中 contact rate 95% = 真机 contact rate 95% - Sim2Real gap 通常让成绩下降 20-50% - LATENT 正手:仿真 SR 96.52%(判据:落到目标 2.5 m 内);真机 SR 90.90%(判据放宽为落入对方球场边界)——两者指标定义不同,不能直接当作同一 metric 的 sim-to-real 降幅 - 正确做法:仿真中至少达到 80%+ contact rate 才考虑上真机
⚠️ 编程陷阱:部署时不加 Butterworth 滤波器 - 策略可能输出超过电机带宽的高频信号 → 电机过热、齿轮打牙 - 正确做法:始终在部署端加低通滤波器,截止频率 = 控制频率的 8-10%
⚠️ 思维陷阱:只做一次 Sim2Real 就结束 - 第一次部署几乎一定有问题(joint ordering、action scale、延迟不匹配等) - 正确做法:Sim2Real 是迭代过程——分析真机失败 case → 调整 DR → 重新训练 → 再部署
⚠️ 概念误区:DR 范围越宽越好 - 过宽的 DR 让训练任务过难,策略可能学到过于保守的行为("什么都不做最安全") - 正确做法:DR 范围应覆盖真实世界的不确定性 + 适量余量(通常 ±20-30%)
练习
- [分析题] 对比 LATENT 和 HITTER 的 Sim2Real 路线。哪个更依赖外部设备(动捕)?哪个更接近自主部署?如果目标是"不需要 OptiTrack 的自主网球机器人",需要解决什么额外挑战?
- [设计题] 设计一个完整的 Sim2Real 检查清单(至少 12 项),参考 Ch25 的部署前检查清单。每项标注"仿真侧检查"还是"真机侧检查"。
- [估算题] 如果真机的总感知延迟是 50 ms,球速 30 m/s,延迟补偿后的残余误差约为多少?这个误差对 contact rate 的影响有多大?提示:比较误差和球拍半径(15 cm)。
28.10 前沿系统总结与综合项目收尾 ⭐⭐⭐
这一节解决什么问题:总结 Part VII 三章(Ch26-28)的完整综合项目,回顾前沿系统的共性模式。
Part VII 三章的完整链路
Ch26(物理底座) Ch27(感知管线) Ch28(控制策略)
↓ ↓ ↓
球场坐标系 Label 设计 击球 MDP
球体物理模型 EKF 状态估计 Staged Reward
发球命令 GRU 残差预测 8 阶段 Curriculum
弹道空气动力学 延迟补偿 DR + 安全约束
接触验证 PredictorOutput Sim2Real
机器人+球拍规划 Teacher-Student 部署安全
↓ ↓ ↓
└────────────────→ 数据协议 ←────────────────┘
三章通过明确的接口连接:Ch26 向 Ch27 传递坐标系和物理参数;Ch27 向 Ch28 传递 PredictorOutput;Ch28 向真机传递安全包装后的关节动作。
综合项目的最终产出
| 产出 | 描述 | 章节 |
|---|---|---|
tennis_court.xml |
标准网球场 MJCF | Ch26 |
BallEKFWithDrag |
含阻力的 GPU EKF | Ch27 |
PhysicsResidualPredictor |
物理+GRU 预测器 | Ch27 |
TennisPerceptionPipeline |
完整感知管线 | Ch27 |
TennisStrikeEnvCfg |
击球 task 配置 | Ch28 |
TennisCurriculumManager |
8 阶段 curriculum | Ch28 |
LagrangianPPOForTennis |
安全约束训练 | Ch28 |
DeploySafetyWrapper |
部署安全包装 | Ch28 |
从本项目学到的通用模式
网球项目不是"一个特殊场景"——它是机器人感知控制的压力测试。以下模式可以迁移到任何高速交互任务:
| 模式 | 网球中的体现 | 可迁移到 |
|---|---|---|
| 环境即数据协议 | 坐标系、接触参数、termination 语义 | 任何多模块系统 |
| 分层感知 | ground truth → EKF → predictor | 任何有噪声传感器的系统 |
| 物理+残差预测 | drag baseline + GRU residual | 任何有物理模型的预测任务 |
| Staged reward | approach × timing × contact × outcome | 任何稀疏 reward 任务 |
| Curriculum | 静态→低速→全速→有噪声 | 任何从简到难的训练 |
| DR 逐项引入 | 先固定→逐项加噪→全噪声 | 任何需要 Sim2Real 的系统 |
| Privileged critic | critic 看 GT、actor 看 sensor | 任何 teacher-student 任务 |
| 部署安全包装 | Butterworth + action rescaler | 任何真机部署 |
完整训练管线的集成
把前面所有组件组装成一个可运行的训练管线——这是本章最重要的工程产出。
# === 完整的 Tennis Strike 环境配置 ===
class TennisStrikeEnvCfg(ManagerBasedRlEnvCfg):
"""网球击球任务的完整环境配置。"""
# === Scene 配置 ===
scene = TennisStrikeSceneCfg(
terrain=TerrainImporterCfg(terrain_type="plane"),
court=EntityCfg(spawn=MjcfFileCfg(mjcf_path="tennis_court.xml")),
ball=EntityCfg(spawn=BallSpecCfg()),
robot=ArticulationCfg(
spawn=MjcfFileCfg(mjcf_path="unitree_g1_with_racket.xml"),
init_state=ArticulationCfg.InitialStateCfg(
pos=(-8.0, 0.0, 0.0),
joint_pos={".*": 0.0},
),
),
num_envs=4096,
env_spacing=30.0,
)
# === Observation 配置 ===
observations = TennisStrikeObsCfg()
# === Action 配置 ===
actions = TennisStrikeActionsCfg()
# === Reward 配置 ===
rewards = {
"approach": RewardTermCfg(
func=compute_approach_reward, weight=1.0,
params={"sigma": 0.3},
),
"timing": RewardTermCfg(
func=compute_timing_reward, weight=0.5,
params={"tau": 0.05},
),
"contact": RewardTermCfg(
func=compute_contact_reward, weight=5.0,
),
"outcome": RewardTermCfg(
func=compute_outcome_reward, weight=10.0,
),
"regularization": RewardTermCfg(
func=compute_regularization, weight=-0.1,
),
"give_up": RewardTermCfg(
func=compute_give_up_reward, weight=0.2,
),
}
# === Termination 配置 ===
terminations = TennisTerminationsCfg()
# === Commands 配置 ===
commands = {
"launch": LaunchCommandCfg(
speed_range=(20.0, 45.0),
elevation_range=(-8.0, 2.0),
# ... 其他参数由 Curriculum 动态设置
),
}
# === Events 配置(含 DR)===
events = TennisDREventCfg()
# === Simulation 配置 ===
# 与本章 strike task 推荐(LATENT 2000 Hz)保持一致;如算力受限可降到 1000 Hz/decimation=20
sim = SimCfg(
timestep=0.0005, # 2000 Hz(球拍-球接触需要高频仿真,LATENT 配置)
render_interval=4,
gravity=(0.0, 0.0, -9.81),
)
decimation = 40 # 控制频率 = 2000/40 = 50 Hz
episode_length_s = 5.0
训练循环的完整代码
def train_tennis_strike(cfg, max_iterations=10000):
"""网球击球任务的完整训练流程。"""
# 1. 创建环境
env = ManagerBasedRlEnv(cfg)
# 2. 创建感知管线(Ch27)
perception = TennisPerceptionPipeline(
num_envs=cfg.scene.num_envs,
device=env.device,
config=PerceptionConfig(),
)
# 3. 创建 Curriculum
curriculum = TennisCurriculumManager(
env_cfg=cfg,
num_envs=cfg.scene.num_envs,
device=env.device,
)
# 4. 创建 Lagrangian PPO
lagrangian = LagrangianPPOForTennis(tennis_safety_constraints)
# 5. 创建 PPO agent(RSL-RL)
agent = PPOAgent(
actor_critic_cfg=ActorCriticCfg(
actor_hidden_dims=[256, 256, 128],
critic_hidden_dims=[256, 256, 128],
),
algorithm_cfg=PPOCfg(
learning_rate=3e-4,
num_learning_epochs=5,
num_mini_batches=4,
clip_param=0.2,
entropy_coef=0.005,
desired_kl=0.01,
gamma=0.99,
lam=0.95,
),
)
# 6. 训练循环
for iteration in range(max_iterations):
# Curriculum 更新发球参数
launch_params = curriculum.get_current_params()
env.update_command_ranges(launch_params)
# 收集 rollout
rollout_data = collect_rollout(env, agent, perception, lagrangian)
# 更新策略
train_info = agent.update(rollout_data)
# 更新 Lagrange 乘子
avg_costs = compute_average_costs(rollout_data)
lagrangian.update_lambdas(avg_costs)
# 更新 Curriculum
metrics = compute_metrics(rollout_data)
curriculum.update(metrics)
# 记录到 WandB
if iteration % 10 == 0:
log_to_wandb(iteration, train_info, metrics, curriculum, lagrangian)
# 保存 checkpoint
if iteration % 500 == 0:
agent.save(f"checkpoints/tennis_strike_iter{iteration}.pt")
return agent
def collect_rollout(env, agent, perception, lagrangian, num_steps=24):
"""收集一个 rollout。"""
obs_buffer = []
action_buffer = []
reward_buffer = []
done_buffer = []
for step in range(num_steps):
# 获取观测
obs = env.get_observations()
# 更新感知管线
raw_ball_obs = obs["policy"]["ball_pos_estimate"]
pred = perception.step(
raw_ball_obs, env.sim_time,
env.commands["launch"].launch_params,
)
# 策略推理
with torch.no_grad():
action = agent.act(obs["policy"])
# 环境步进
next_obs, reward, done, info = env.step(action)
# Lagrangian 增强 reward
augmented_reward = lagrangian.compute_augmented_reward(reward, env)
# 存储
obs_buffer.append(obs)
action_buffer.append(action)
reward_buffer.append(augmented_reward)
done_buffer.append(done)
# 处理 reset
if done.any():
reset_ids = done.nonzero(as_tuple=True)[0]
perception.reset(reset_ids,
env.scene["ball"].data.root_pos_w[reset_ids, :3]
- env.scene.env_origins[reset_ids])
return RolloutData(obs_buffer, action_buffer, reward_buffer, done_buffer)
实验管理与对比
训练网球击球策略通常需要多次实验迭代。推荐使用 WandB group 来管理实验:
# 基线实验
uv run train Mjlab-Tennis-Strike \
--num-envs 4096 --max-iterations 5000 \
--wandb-name baseline_v1 --wandb-group tennis_strike
# 消融:去掉 timing reward
uv run train Mjlab-Tennis-Strike \
--rewards.timing.weight 0.0 \
--wandb-name ablation_no_timing --wandb-group tennis_strike
# 消融:去掉 DR
uv run train Mjlab-Tennis-Strike \
--events.ball_mass_dr.enabled False \
--events.court_friction_dr.enabled False \
--wandb-name ablation_no_dr --wandb-group tennis_strike
# 消融:全关节 action(不用 IK)
uv run train Mjlab-Tennis-Strike \
--actions.use_joint_space True \
--wandb-name ablation_joint_action --wandb-group tennis_strike
部署前的完整检查清单
综合 Ch25-28 的内容,从训练完成到真机运行的检查清单:
| 步骤 | 检查内容 | 工具 | 通过标准 |
|---|---|---|---|
| 1 | 仿真 contact rate | WandB | > 80% |
| 2 | 仿真 over_net rate | WandB | > 60% |
| 3 | 动作平滑性 | Ch25 FFT 分析 | 90% 能量 < 电机带宽 |
| 4 | 关节安全性 | Ch25 check_joint_safety() |
峰值 < 90% 限位 |
| 5 | Lipschitz 常数 | Ch25 check_lipschitz_sensitivity() |
< 10 |
| 6 | ONNX 导出一致性 | PyTorch vs ONNX | max diff < 1e-5 |
| 7 | Normalization 冻结 | ONNX graph 检查 | running_mean/var 包含 |
| 8 | Joint ordering | deploy.yaml vs 真机 | 完全匹配 |
| 9 | Sim-to-sim(跨引擎) | mjlab vs Isaac Lab | 行为一致 |
| 10 | Butterworth 滤波 | 频谱分析 | 截止频率合理 |
| 11 | Action rescaler β=0.3 | 真机低速 | 无异常振动 |
| 12 | Action rescaler β=0.8 | 真机中速 | 行为匹配仿真 |
| 13 | 全速测试 β=1.0 | 真机全速 | contact rate 下降 < 30% |
本质洞察:球类运动 RL 的核心难点不在于算法(PPO 足够), 而在于工程设计。本章的 staged reward、curriculum、DR、安全约束 都是工程设计——它们不改变 PPO 的数学,但决定了 PPO 能否学到有用的策略。 算法是引擎,工程设计是方向盘和油门——没有方向盘的引擎只会原地打转。
⚠️ 常见陷阱
⚠️ 思维陷阱:认为算法创新比工程设计更重要 - LATENT 用的就是 PPO,但通过 latent action + adaptive DR 达到了 91% 真机成功率 - 正确认知:在机器人 RL 中,reward/curriculum/DR/安全的边际收益通常大于算法创新
⚠️ 编程陷阱:训练时用 ground truth reward,评估时用 learned predictor reward - 训练和评估的 reward 语义不同 → 训练曲线和评估性能脱节 - 正确做法:reward 始终用 physics predictor(精确),policy obs 用 learned predictor
⚠️ 思维陷阱:Curriculum 全部通过 = 可以上真机 - Curriculum 通过只说明策略在仿真中可以击球,不保证 Sim2Real - 正确做法:必须经过部署检查清单的全部 13 步
练习
- [综合题] 回顾 Ch26-28 的完整链路,画出数据流图:从
LaunchCommand到最终的joint_targets,标注每个模块的输入、输出、频率和关键参数。 - [设计题] 如果要把本项目从网球迁移到乒乓球,哪些模块需要修改?哪些可以直接复用?球场尺寸(2.74m vs 23.77m)、球速范围(5-15 m/s vs 20-45 m/s)、机器人形态各有什么变化?
- [思考题] LATENT 使用 5 小时业余选手数据 + RL,达到了 91% 真机正手成功率。如果没有 motion data(像 Phybot 那样纯 RL),你预计需要多少额外的训练时间才能达到相同性能?motion prior 的核心价值是什么?
本章常见误解汇总
| 误解 | 正确理解 |
|---|---|
| "IK 到点就是击球" | 需要同时满足位置、速度、姿态、时机四个约束 |
| "纯稀疏 reward 也能学" | 3-5 ms 接触窗口 + 30 m/s 球速 → PPO 几乎无法探索到成功 |
| "先练手臂再加底盘" | 底盘是把不可达球拉回可达域的关键控制变量 |
| "Curriculum 越多阶段越好" | 8 阶段已覆盖完整过渡,更多阶段 = 更多超参数 |
| "DR 应该训练一开始就加" | 先在固定环境学会击球,再加 DR 增强鲁棒性 |
| "reward penalty 可以替代安全约束" | Lagrangian PPO 提供更可靠的约束满足 |
| "仿真 95% = 真机 95%" | Sim2Real gap 通常导致 20-50% 性能下降 |
| "算法创新比工程设计重要" | PPO 足够,reward/curriculum/DR/安全是真正的杠杆 |
本章建立的心智模型
击球控制 = 定时接触控制
↓
MDP 设计
├── State: ball_estimate + robot_proprio + predictor_output
├── Action: base_vel(3D) + racket_delta_pose(6D) = 9D
└── Reward: approach × (1+timing) × (1+contact) × (1+outcome) - reg
↓
Curriculum
Stage 0(静态) → 1(低速) → ... → 6(全随机) → 7(+延迟)
↓
DR + 安全约束
├── 五类 DR 逐项引入
├── Lagrangian PPO 安全约束
└── Termination 安全优先
↓
Sim2Real
├── Sim2Sim 验证
├── 低速真机测试
├── Butterworth + Action Rescaler
└── 逐步提速
本章小结
知识点总表
| 编号 | 知识点 | 核心要点 | 对应节 | 难度 |
|---|---|---|---|---|
| 1 | 击球本质 | 带速度+姿态约束的定时接触 | 28.1 | ⭐⭐ |
| 2 | MDP 分层状态 | env真值 / teacher / student 三层 | 28.2 | ⭐⭐⭐ |
| 3 | Task-space IK action | base_vel(3D) + racket_delta_pose(6D) | 28.2 | ⭐⭐⭐ |
| 4 | 五类 staged reward | approach × timing × contact × outcome - reg | 28.3 | ⭐⭐⭐ |
| 5 | 乘法 reward 结构 | 层级优先,approach=0 时总 reward=0 | 28.3 | ⭐⭐⭐ |
| 6 | Event Buffer | 瞬时接触事件的记录和追踪 | 28.3 | ⭐⭐ |
| 7 | 八阶段 curriculum | 静态→低速→全速→延迟,成功率门控 | 28.4 | ⭐⭐⭐ |
| 8 | KungfuBot 自适应 tolerance | bi-level 动态调整精度容忍度 | 28.4 | ⭐⭐⭐ |
| 9 | Perception noise 注入 | 固定噪声 / DR噪声 / perception-aware | 28.5 | ⭐⭐ |
| 10 | 五类 Sim2Real gap | 感知/动力学/接触/执行/系统 | 28.6 | ⭐⭐⭐ |
| 11 | DR 逐项引入 | 每加一项记录成功率变化 | 28.6 | ⭐⭐⭐ |
| 12 | Lagrangian PPO | 安全约束的对偶优化 | 28.7 | ⭐⭐⭐ |
| 13 | Termination 安全优先 | 安全终止 > 任务终止 > 超时 | 28.7 | ⭐⭐ |
| 14 | 频率分离 | policy 50Hz + IK 500Hz + sim 2000Hz | 28.8 | ⭐⭐ |
| 15 | LATENT Sim2Real 路线 | DR+noise → sim2sim → 低速真机 → 全速 | 28.9 | ⭐⭐⭐ |
| 16 | 部署安全包装 | Butterworth + action rescaler β | 28.9 | ⭐⭐ |
| 17 | 工程 > 算法 | PPO 足够,reward/curriculum/DR 是杠杆 | 28.10 | ⭐⭐⭐ |
累积项目:本章新增模块
项目进度更新:
| 阶段 | 能力 | 新增于 |
|---|---|---|
| 环境构建 | EntityCfg → SceneCfg → Registry | Ch04, Ch15 |
| Obs/Action 设计 | 五条原则 + 双框架配置 | Ch05 |
| Reward/Curriculum | 四类奖励 + 渐进 curriculum | Ch06 |
| 训练管线 | PPO + 多后端 + 训练诊断 | Ch07, Ch25 |
| Domain Randomization | EventManager + 分阶段 DR | Ch08 |
| Teacher-Student | 特权学习 + 蒸馏 | Ch09 |
| 球类环境底座 | 球场坐标 + 球物理 + 弹道模型 | Ch26 |
| 感知管线 | EKF + GRU + 延迟补偿 + PredictorOutput | Ch27 |
| 击球控制 | MDP + Staged Reward + Curriculum + DR + Safety + Deploy | Ch28 |
本章代码产出清单
| 模块 | 类/函数名 | 功能 | 节 |
|---|---|---|---|
| Observation | TennisStrikeObsCfg |
分层观测(Actor/Critic) | 28.2 |
| Action | TennisStrikeActionsCfg |
base_vel + DifferentialIK | 28.2 |
| Approach Reward | compute_approach_reward() |
指数距离衰减 | 28.3 |
| Timing Reward | compute_timing_reward() |
时间-距离协调 | 28.3 |
| Contact Reward | compute_contact_reward() |
合法接触检测 | 28.3 |
| Outcome Reward | compute_outcome_reward() |
过网+落点 | 28.3 |
| Regularization | compute_regularization() |
安全平滑惩罚 | 28.3 |
| Event Buffer | StrikeEventBuffer |
瞬时接触事件记录 | 28.3 |
| Curriculum | TennisCurriculumManager |
8阶段+门控+回退 | 28.4 |
| Noise Manager | PerceptionNoiseManager |
噪声+延迟注入 | 28.5 |
| DR Config | TennisDREventCfg |
五类 DR 配置 | 28.6 |
| DR-Curriculum | CurriculumAwareDR |
DR 随 Curriculum 联动 | 28.6 |
| Safety | LagrangianPPOForTennis |
安全约束训练 | 28.7 |
| CBF Filter | cbf_joint_limit_filter() |
推理层安全过滤 | 28.7 |
| Termination | TennisStrikeTerminationsCfg |
完整终止配置 | 28.7 |
| Give-up | compute_give_up_reward() |
不可达球放弃 | 28.7 |
| Freq Separation | TennisFrequencySeparation |
高低层频率分离 | 28.8 |
| Deploy Safety | WoCoCoDeploySafety |
Butterworth + Rescaler | 28.9 |
| Deployment | RealWorldDeployment |
真机部署脚本 | 28.9 |
| Env Config | TennisStrikeEnvCfg |
完整环境配置 | 28.10 |
| Train Loop | train_tennis_strike() |
完整训练流程 | 28.10 |
本章实验清单
| 实验 | 目的 | 预期结果 |
|---|---|---|
| Approach-only 训练 | 验证 approach reward 有效 | 球拍持续接近预测击球点 |
| Staged reward vs sparse | 对比乘法 staged 和纯 outcome | staged 显著更好 |
| Curriculum 推进记录 | 记录每个 Stage 的达标时间 | Stage 0-3 快,Stage 4-7 慢 |
| DR 消融 | 逐项加入 DR 记录成绩变化 | 每项下降 5-15% |
| Lambda 轨迹 | 监控安全约束乘子 | 稳定或轻微振荡 |
| CBF 过滤率 | 统计 CBF 修正动作的比例 | 正常训练 < 5% |
| 频率消融 | 500 Hz vs 1000 Hz vs 2000 Hz sim | 2000 Hz 接触最稳定 |
| Noise 消融 | 有噪声 vs 无噪声训练 | 有噪声 Sim2Real 更好 |
延伸阅读
| 资料 | 地址 | 难度 | 与本章的关系 |
|---|---|---|---|
| LATENT 代码库 | github.com/GalaxyGeneralRobotics/LATENT |
⭐⭐⭐ | 最完整人形网球系统 |
| KungfuBot 代码库 | github.com/TeleHuman/PBHC |
⭐⭐⭐ | 自适应 curriculum + asymmetric AC |
| WoCoCo 代码库 | github.com/LeCAR-Lab/wococo |
⭐⭐⭐ | sequential contacts + Butterworth |
| HITTER 论文 | arXiv:2508.21043 | ⭐⭐⭐ | hierarchical planner + RL controller |
| PACE 论文 | arXiv:2509.21690 | ⭐⭐⭐ | dual predictor + dense reward |
| Phybot 论文 | arXiv:2511.11218 | ⭐⭐⭐ | 三阶段 curriculum,无 motion prior |
| ETH 羽毛球 | Science Robotics, 2025 | ⭐⭐⭐ | perception-aware + 机载视觉 |
| Tobin et al. 2017 | arXiv:1703.06907 | ⭐⭐ | Domain Randomization 开山之作 |
| Achiam et al. 2017 CPO | Proceedings of ICML | ⭐⭐⭐ | Constrained Policy Optimization |
| Lee et al. 2020 | Science Robotics | ⭐⭐⭐ | Teacher-Student + curriculum 经典 |
🔧 故障排查手册
| 症状 | 可能原因 | 排查步骤 | 相关节 |
|---|---|---|---|
| 球拍追不上球 | curriculum 太难 / base 没移动 | 1. 降速度 2. 检查 base action 3. 检查 approach σ | 28.3-28.4 |
| 碰到球但不过网 | racket 速度不足 / 面法向偏 | 1. 记录接触时 racket vel 2. 加 orientation reward 3. 检查 IK damping | 28.3 |
| contact rate 升但机器人摔倒 | safety penalty 弱 | 1. 增大 joint limit penalty 2. 加严 safety termination 3. 用 Lagrangian PPO | 28.7 |
| reward 升但 contact 不变 | 刷 approach shaping | 1. 分项记录 reward 2. 增加 contact 权重 3. 减小 approach σ | 28.3 |
| 固定速度成功随机失败 | 策略记住固定时刻挥拍 | 1. 检查 actor obs 含 time_to_impact 2. held-out eval 3. 加宽 curriculum | 28.4 |
| Lagrange λ 持续上升 | 约束太紧或任务太难 | 1. 放宽 cost limit 2. 检查 curriculum 难度 3. 降低 λ_lr | 28.7 |
| DR 后成绩断崖下降 | DR 范围太宽 | 1. 缩小范围 2. 逐项排查 3. 检查 reward 对该参数的敏感性 | 28.6 |
| 真机比仿真差 50%+ | DR 不够 / 接触模型 gap | 1. 分析失败 case 2. 增大 DR 范围 3. 检查 Butterworth 滤波 | 28.9 |
给下一章的桥
本章完成了网球综合项目的最后一个模块——击球控制策略。至此,Part VII(Ch26-28)建立了一个完整的端到端系统:从球场物理建模、到感知与轨迹预测、再到全身协调击球控制。
Part VII 的三章产出总结:
| 章节 | 核心产出 | 代码行数 | 关键类/函数 |
|---|---|---|---|
| Ch26 | 物理环境底座 | ~2465 行 | tennis_court.xml, BallAerodynamics, LaunchCommand |
| Ch27 | 感知预测管线 | ~2508 行 | BallEKFWithDrag, PhysicsResidualPredictor, TennisPerceptionPipeline |
| Ch28 | 击球控制策略 | ~2500 行 | TennisStrikeEnvCfg, TennisCurriculumManager, LagrangianPPOForTennis |
这个综合项目不是"一个网球游戏"——它是机器人感知控制的工程方法论教学载体。本章和前两章中介绍的每个设计模式(环境即数据协议、分层感知、物理+残差预测、staged reward、curriculum、DR、安全约束、部署安全包装)都可以迁移到其他高速交互任务——乒乓球、羽毛球、足球、甚至工业装配中的高速对位。
整本教材的 28 章到此完成。从 Ch01 的仿真生态全景,到 Ch14 的人形 locomotion,到 Ch20 的全身操作,再到本章的网球击球——贯穿始终的核心理念是:算法回顾(20%)→ 工程实现(80%)。机器人 RL 的挑战不在于发明新算法,而在于把已有算法正确地、鲁棒地、安全地应用到复杂的真实世界中。
本质洞察:28 章的旅程证明了一个工程事实—— PPO 已经足够好,瓶颈在于环境设计、reward 工程、感知管线、 训练诊断、部署安全这些"算法之外"的工程能力。 掌握这些能力,你就拥有了让机器人在真实世界中做有用事情的基础。