Visuomotor Policy Learning via Action Diffusion · RSS 2023 / IJRR 2024
Diffusion Policy 把"机器人看图选动作"这件事,建模成一个去噪过程:从纯随机噪声出发,以当前观测为条件,迭代地把噪声"擦"成一段平滑的未来动作序列。
| 方法 | 怎么做 | 痛点 |
|---|---|---|
| 显式策略 (回归) | 网络直接输出动作 a = f(o) | 多模态崩溃:左绕/右绕两条路被平均成"撞墙" |
| 隐式策略 (IBC) | 学能量函数 E(o,a),argmin 取动作 | 训练不稳定:需负采样估计配分函数 Z |
| BET | 动作离散化 + Transformer | 无法 commit:多模态间摇摆 |
| Diffusion Policy | 学分数函数,迭代去噪生成 | 天然多模态 + 训练稳定 + 高维友好 |
假设演示数据里,人遇到障碍物有时往左绕、有时往右绕。这是多模态动作分布(同一观测对应多个合理动作)。
扩散模型有两个方向:
给干净动作序列 A⁰ 逐步加高斯噪声,K 步后变成纯噪声 Aᴷ ~ N(0, I)。任意一步可一次性算出:
ᾱₖ 由噪声调度(noise schedule)决定,k 越大噪声越多。
从噪声 Aᴷ 出发,训练一个网络 ε_θ 预测"这一步加了多少噪声",然后减掉它:
迭代 K 次,噪声被逐步擦除,得到干净动作 A⁰。
训练时不需要跑完整的去噪链,只需:
关键设计:视觉观测 Oₜ 是去噪网络的条件输入,而不是要生成的东西。
对比"联合分布 p(Aₜ, Oₜ)"的做法(如原始 inpainting 方案),这样设计有两大好处:
| 训练 | 推理 | |
|---|---|---|
| 去噪步数 | K = 100 步 | DDIM 10~16 步 |
| 调度 | iDDPM 的 Square Cosine Schedule | |
| 延迟 | — | ~0.1s (RTX 3080) |
训练用 DDPM 100 步保证质量;推理换成 DDIM(确定性采样)只需 10~16 步,满足 10Hz 实时控制。
=== 训练阶段 ===
2× RGB [B,To,3,240,320]
│
▼ ResNet-18 ×2 (每个相机独立)
│ GroupNorm + SpatialSoftmax 替代 BN + 全局池化
▼
obs_feat [B, To×128] ──────────────┐
│ (作为 global_cond)
GT actions A⁰ [B,Tp,Da] │
│ 采样 k~U(1,100), ε~N(0,I) │
│ Aᵏ = √ᾱ·A⁰ + √(1-ᾱ)·ε │
▼ │
┌──────────────────────────────┐ │
│ Conditional UNet1D (ε_θ) │◄───┘ FiLM 注入
│ 输入: Aᵏ [B,Tp,Da] │◄─── diffusion step k (正弦嵌入)
│ Conv1D→GroupNorm→FiLM→Mish │
│ 下采样[256,512,1024]+上采样 │
└──────────┬───────────────────┘
▼
ε_pred [B,Tp,Da]
│
Loss = MSE(ε, ε_pred)
=== 推理阶段 (Receding Horizon) ===
obs [B,To,...] ──→ ResNet ──→ obs_feat (只算一次)
│
Aᴷ ~ N(0,I) [B,Tp,Da] │
│ DDIM 去噪 10~16 步 │
▼ Aᵏ⁻¹ = scheduler.step(...) ◄┘
...迭代
▼
A⁰ [B,Tp=16,Da]
│
├─ 执行前 Ta=8 步 ──→ 机器人
└─ 丢弃后 8 步
↻ 每 8 步重新规划
| 张量 | 维度 | 含义 |
|---|---|---|
| RGB 输入 | [B, To, 3, 240, 320] | To=2 步观测, 2 路相机 |
| 视觉特征(每路) | [B, To, 64] | ResNet-18+SpatialSoftmax |
| 观测条件 | [B, To×128] | 多路拼接, 作 global_cond |
| 噪声动作序列 | [B, Tp, Da] | Tp=16, Da=2~14 |
| 扩散步嵌入 | [B, 256] | 标量 k → 正弦向量 |
| ε_pred | [B, Tp, Da] | 预测噪声 |
| 执行子序列 | [B, Ta, Da] | Ta=8, 实际下发 |
| CNN (ConditionalUnet1D) | Transformer | |
|---|---|---|
| 基础 | 1D 时间卷积 (Janner 2022) | minGPT decoder |
| 条件注入 | FiLM (scale/bias 调制) | 交叉注意力 |
| 优点 | 开箱即用, 超参稳定 | 高频动作、长horizon更好 |
| 缺点 | 偏低频, 过度平滑 | 超参敏感(dropout/wd) |
| 适用 | 大部分任务的默认选择 | ToolHang(高精度)、Kitchen |
此外:每路相机用独立编码器,每个时间步独立编码后拼接。ResNet-18 从头训练效果最好(也可用 CLIP ViT-B/16 微调,50 epoch 达 98%)。
| 功能 | 文件 |
|---|---|
| 策略(lowdim) | policy/diffusion_unet_lowdim_policy.py |
| 策略(图像,主力) | policy/diffusion_unet_hybrid_image_policy.py |
| 去噪网络 UNet1D | model/diffusion/conditional_unet1d.py |
| 视觉编码器 | model/vision/multi_image_obs_encoder.py |
| Transformer 变体 | model/diffusion/transformer_for_diffusion.py |
| 训练循环 | workspace/train_diffusion_unet_hybrid_workspace.py |
| 噪声调度 | diffusers 的 DDPMScheduler |
policy/diffusion_unet_lowdim_policy.py:169-238
def compute_loss(self, batch):
# ═══ 1. 归一化 ═══
nbatch = self.normalizer.normalize(batch)
obs = nbatch['obs'] # [B, T, Do]
action = nbatch['action'] # [B, T, Da]
# ═══ 2. 准备条件 (三种方式) ═══
local_cond = None; global_cond = None
trajectory = action
if self.obs_as_global_cond:
# 主力方式: 观测拍平成 global 条件
global_cond = obs[:,:self.n_obs_steps,:].reshape(obs.shape[0], -1)
# [B, To*Do] —— 只取前 To=2 步观测!
else:
# inpainting 方式: 动作和观测拼一起
trajectory = torch.cat([action, obs], dim=-1)
# ═══ 3. 采样噪声 + 随机时间步 ═══
noise = torch.randn(trajectory.shape, device=trajectory.device)
timesteps = torch.randint(0, 100, (bsz,)).long() # k ~ U(0,100)
# ═══ 4. 前向加噪 (一步到位) ═══
noisy_trajectory = self.noise_scheduler.add_noise(
trajectory, noise, timesteps)
# = √ᾱ·trajectory + √(1-ᾱ)·noise
# ═══ 5. 预测噪声 ═══
pred = self.model(noisy_trajectory, timesteps,
local_cond=local_cond, global_cond=global_cond)
# ═══ 6. MSE Loss (预测 epsilon) ═══
target = noise # prediction_type='epsilon'
loss = F.mse_loss(pred, target, reduction='none')
loss = loss * loss_mask # 只在动作维度算 loss
return loss.mean()
global_cond 只取前 n_obs_steps=2 步观测,这呼应了消融实验里 To=2 最优的结论。policy/diffusion_unet_lowdim_policy.py:60-96
def conditional_sample(self, condition_data, condition_mask,
local_cond=None, global_cond=None, **kwargs):
# ═══ 1. 从纯噪声起步 ═══
trajectory = torch.randn(size=condition_data.shape, ...) # Aᴷ ~ N(0,I)
# ═══ 2. 设置去噪步数 (推理只 10~16 步) ═══
scheduler.set_timesteps(self.num_inference_steps)
# ═══ 3. 迭代去噪 ═══
for t in scheduler.timesteps: # 从 K 递减到 0
# 3a. 强制已知部分 (inpainting 时用)
trajectory[condition_mask] = condition_data[condition_mask]
# 3b. 预测噪声
model_output = model(trajectory, t,
local_cond=local_cond, global_cond=global_cond)
# 3c. 去噪一步: Aᵏ → Aᵏ⁻¹
trajectory = scheduler.step(
model_output, t, trajectory).prev_sample
trajectory[condition_mask] = condition_data[condition_mask]
return trajectory
然后在 predict_action 中切片取出要执行的 Ta 步:
start = To # To=2
end = start + self.n_action_steps # +Ta=8
action = action_pred[:, start:end] # 只执行中间 8 步
model/diffusion/conditional_unet1d.py:175-235
def forward(self, sample, timestep, local_cond=None, global_cond=None):
# sample: [B,T,Da] 噪声动作; timestep: [B] 扩散步
sample = rearrange(sample, 'b h t -> b t h') # 转成 conv1d 格式
# ═══ 1. 编码扩散步 k → 正弦嵌入 ═══
global_feature = self.diffusion_step_encoder(timesteps) # [B,256]
# SinusoidalPosEmb → Linear → Mish → Linear
# ═══ 2. 拼接观测条件 ═══
if global_cond is not None:
global_feature = torch.cat([global_feature, global_cond], axis=-1)
# [B, 256 + To*obs_dim] ← 时间步 + 观测 合成总条件
# ═══ 3. U-Net 下采样 (encoder) ═══
x = sample; h = []
for resnet, resnet2, downsample in self.down_modules:
x = resnet(x, global_feature) # FiLM 注入条件!
x = resnet2(x, global_feature)
h.append(x) # 存 skip connection
x = downsample(x)
# ═══ 4. 中间层 ═══
for mid in self.mid_modules:
x = mid(x, global_feature)
# ═══ 5. 上采样 (decoder) + skip ═══
for resnet, resnet2, upsample in self.up_modules:
x = torch.cat((x, h.pop()), dim=1) # U-Net skip
x = resnet(x, global_feature)
x = resnet2(x, global_feature)
x = upsample(x)
x = self.final_conv(x)
return rearrange(x, 'b t h -> b h t') # [B,T,Da]
model/diffusion/conditional_unet1d.py:48-66 (ConditionalResidualBlock1D.forward)
def forward(self, x, cond):
out = self.blocks[0](x) # Conv1d → GroupNorm → Mish
embed = self.cond_encoder(cond) # cond → scale & bias
if self.cond_predict_scale:
embed = embed.reshape(B, 2, out_channels, 1)
scale = embed[:,0,...]
bias = embed[:,1,...]
out = scale * out + bias # ← FiLM: 逐通道线性调制
else:
out = out + embed # 简化版: 只加 bias
out = self.blocks[1](out)
return out + self.residual_conv(x) # 残差连接
scale 和 bias,逐通道地调制特征。这是把条件信息注入 CNN 的高效方式——不改变网络结构,只调制激活值。预测 16 步 ──→ 只执行前 8 步 ──→ 丢弃后 8 步 ──→ 重新观测预测
Tp=16 Ta=8 ↻ 循环
| 参数 | 值 | 作用 |
|---|---|---|
| To 观测 horizon | 2 | 看几步历史观测 |
| Tp 预测 horizon | 16 | 一次预测多少步动作 |
| Ta 执行 horizon | 8 | 实际执行多少步后重规划 |
三大好处:
把视觉观测当条件而非生成对象:
两种去噪网络(见架构 tab)。CNN 用 FiLM、开箱即用;Transformer 用交叉注意力、适合高频动作。
论文发现 位置控制 (position control) 一致优于速度控制:
有趣的是:基线方法(LSTM-GMM 等)反而在速度控制下更好——这是 DP 的独特协同效应。
15 个任务 / 4 个 benchmark(Robomimic, Push-T, Block Push, Franka Kitchen),平均提升 46.9% 超越所有基线(LSTM-GMM, IBC, BET),在所有任务和观测模态(state/image)均一致领先。
| 任务 | 成功率 | 看点 |
|---|---|---|
| Push-T | 95% | vs LSTM-GMM 20%, IBC 0% |
| Mug Flip | 90% | 3D旋转+多模态抓取 |
| Sauce Pour | 79% | 处理空闲动作+精细调整 |
| Sauce Spread | 100% | 周期动作+自终止 |
| Shirt Folding | 75% | 双臂9步长horizon |
| 维度 | Diffusion Policy | LSTM-GMM | IBC | BET |
|---|---|---|---|---|
| 多模态 | 天然支持+commit | 偏一侧 | 偏一侧 | 无法commit |
| 训练稳定 | 平滑(无Z) | 中 | 不稳定(需负采样) | 中 |
| 动作生成 | 迭代去噪10-16步 | 单步 | 优化argmin | 分类+offset |
| 推理速度 | ~0.1s | 快 | 慢 | 快 |
数据来源:Diffusion Policy 论文(Chi et al.)实验附录 + 仓库 dp/README。DP 是轻量策略网络(CNN-based 或 Transformer-based,~百 M 参数级),资源门槛在本站所有工作里属最低。
| 项 | 配置 |
|---|---|
| 推理延迟 | 约 0.1s @ NVIDIA 3080(得益于 receding-horizon + 少步去噪) |
| 训练 GPU | 论文未统一列型号;仓库说明 单张 Nvidia GPU 即可复现仿真结果;多卡可选(ray 集群,CUDA_VISIBLE_DEVICES=0,1,2 + ray start --num-gpus=3) |
| Batch size | state-based 实验 batch 256 |
| 训练时长 | epoch 数很多:state-based 训到 4500 epoch、image-based 3000 epoch(每 50 epoch 存 ckpt+评测);部分 benchmark 用固定 12h 训练后取最后 ckpt 对比 |
选完会即时判分并给解析。最后有总分。