看了iDP3的论文之后,大多数人最想看的就是Learning模块的代码到底怎么把“3D点云”和“动作预测”串起来的。网上搜代码实现,从OCR文字识别到FIFO的Verilog实现都一堆,但iDP3这种3D模仿学习的训练与推理闭环,确实需要静下心来拆。我花了两周时间把iDP3的Learning模块从数据集构建到3D动作预测完整跑通,这篇博文就把这条链路上的代码逻辑、关键设计和踩坑记录一次性讲清楚。
先说结论:iDP3的Learning模块并不只是一个train.py,它是由数据加载、模型构建、训练器、推理器四个部分组成的整套闭环。只有把每一步的shape变化、条件注入方式和动作还原逻辑都理解透,才算真正吃透了这套代码。接下来我按实际阅读代码的顺序,分五个部分展开。
1. Learning模块整体设计与数据流
1.1 Learning模块在iDP3里的定位
iDP3全称是implicit Diffusion Policy with 3D point cloud,它的核心管线可以概括成一句话:用PointNet++把点云观测编码成条件特征,再交给条件扩散模型来生成机器人动作。整个流程里,Learning模块承担的是“数据到模型再到动作”的组装工作。它不关心你的轨迹是从仿真环境导出的,还是真机遥操作采集的,只要数据落成hdf5文件,它就能完成训练和推理。
和DP3相比,iDP3的Learning模块有两个关键变化:一是动作预测目标从绝对动作改成了delta action,也就是相邻帧动作的差值;二是在前向传播时强制做了时间因果对齐,预测第k步动作时不允许模型偷看未来的点云观测。这两个改动直接影响了数据集的构造方式和训练时的loss计算,后面我会展开讲代码。
1.2 数据流全景:从HDF5到Loss再到动作
先给一张总览表,把训练和推理两个阶段的数据形态变化列出来,这样后面看代码不会迷路。
| 阶段 | 输入 | 处理过程 | 输出 |
|---|---|---|---|
| 训练 | hdf5中的点云序列和动作序列 | 窗口采样、点云预处理、normalizer归一化、加噪 | 噪声预测值,与真实噪声计算MSE loss |
| 推理 | 当前时刻的点云观测 | 点云编码、条件注入、逐步去噪 | delta action序列,累积还原为真实动作 |
训练时,数据流是:读hdf5 -> 随机取一段轨迹 -> 按obs_horizon和action_horizon截取窗口 -> 点云下采样到固定点数 -> 计算delta action -> 全局normalizer归一化 -> 随机采样扩散时间步t -> 给动作加高斯噪声 -> 模型预测噪声 -> 计算loss。
推理时,数据流是:读取最新一帧点云 -> 点云编码成条件特征 -> 初始化纯噪声动作 -> 在scheduler的timesteps上逐步去噪 -> 得到delta action序列 -> 累积成绝对动作 -> 取前action_step步执行。
这里最容易绕晕的是时间维对齐。训练时一条轨迹里随机切一段,obs窗口和action窗口在时间上是连续的;推理时只有“当前”这一刻的观测,动作序列则是未来若干步的预测值。iDP3用delta action和因果mask把这两者的语义统一了起来。
1.3 为什么这块代码不好读
我自己读iDP3代码时卡了好几次,主要原因有三个。第一是工程依赖多,pointnet2的CUDA扩展、diffusers库、hdf5存储都耦合在一起,光环境就得折腾一阵。第二是时间维度的对齐非常容易绕晕,obs_horizon、action_horizon、pred_horizon三个参数一旦没搞清,看代码就是一团浆糊。第三是obs_dict的键结构很隐性,代码里到处是obs_dict["point_cloud"]、obs_dict["agent_pos"]这种访问,但键名是在dataset里定义的,不翻到数据加载部分根本对不上。
所以这篇文章我索性把整个Learning模块拆成“数据集构建-模型前向-训练推理”三段,每段都给出核心代码和shape说明,争取让读完的朋友能直接对着自己的项目改。
2. 数据集构建:从原始轨迹到可训练样本
2.1 HDF5数据格式与字段设计
iDP3的原始数据通常存成hdf5,原因很简单:单文件、支持按需读取、能放得下动辄几千帧的点云序列。一个标准的demo文件内部结构大致是:
import h5py with h5py.File("demo.hdf5", "r") as f: print(f.keys()) # 常见的键:observations, actions # observations下面一般还有 point_clouds、state 等子键 point_clouds = f["observations/point_clouds"][:] # (T, N, 3) 或 (T, M, N, 3) states = f["observations/state"][:] # (T, D_state) actions = f["actions"][:] # (T, Da)这里的T是轨迹帧数,N是每帧点数,M是多视角相机数量。如果你的数据来自多相机,iDP3一般会先把多个视角的点云拼接起来,再统一降采样,后面模型看到的是一个完整场景点云,而不是逐视角分开处理。
存储格式上有两个建议。第一,点云尽量用float16存,一张2048点的点云帧在float32下就是24KB,一条上万帧的轨迹能差出几百MB,float16配合hdf5的压缩可以省很多空间。第二,加载后一定要转成float32再进模型,PyTorch的绝大多数算子不支持float16点云直接输入PointNet++。
2.2 窗口采样与delta action构造
训练时不可能把一整条轨迹都塞进模型,所以核心操作是随机窗口采样。iDP3的做法和DP3类似,从轨迹里随机选一个起始点,然后连续截取obs_horizon帧观测和action_horizon帧动作。
import numpy as np def sample_window(point_clouds, actions, obs_horizon, action_horizon): T = point_clouds.shape[0] start = np.random.randint(0, T - obs_horizon - action_horizon + 1) obs_pc = point_clouds[start : start + obs_horizon] # (obs_horizon, N, 3) act_seq = actions[start : start + action_horizon] # (action_horizon, Da) # 计算delta action:相邻时刻的动作差值 delta_act = np.diff(act_seq, axis=0, prepend=act_seq[:1]) # (action_horizon, Da) return obs_pc, act_seq, delta_act这里有个细节值得注意:np.diff的prepend=act_seq[:1]保证了delta序列和原动作序列长度一致,第一个delta是0。这个边界处理很关键,如果漏掉prepend,序列长度会少一帧,后面对齐直接崩。
那为什么iDP3要用delta action而不是直接预测绝对动作?我在实际实验里的体会是:机器人动作的绝对值往往分布在一个较大的范围里,而且不同轨迹之间的绝对位置差异很大;但相邻帧的动作变化量就稳定得多,更接近一个零均值的小范围分布。扩散模型在这种目标上收敛更快,生成的轨迹也更平滑。推理时只需要把delta累积回去就能还原真实动作,代价非常小。
2.3 点云预处理、normalizer与数据增强
点云不能直接扔进网络,首先要统一点数。iDP3一般用FPS或者随机采样把每帧点云固定到N个点,我实测2048是性价比最高的选择,1024会丢细节,4096则明显拖慢训练。
def downsample_points(points, num_points): # points: (M, 3) if len(points) >= num_points: idx = np.random.choice(len(points), num_points, replace=False) else: idx = np.random.choice(len(points), num_points, replace=True) return points[idx]然后是坐标系归一化。如果你在真机上采集数据,点云坐标一般来自相机外参标定,不同轨迹之间可能存在整体偏移。常见的做法是把点云转到末端执行器坐标系,或者减掉一个workspace中心点,让网络学到的策略不依赖于绝对坐标。
normalizer在DP系列里是标配,iDP3也一样。它维护一个全局的均值和方差估计,在训练过程中不断更新,推理时用训练结束时的统计量来做归一化。
class RunningNormalizer: def __init__(self, dim): self.mean = np.zeros(dim) self.var = np.ones(dim) self.count = 0 def update(self, data): batch = data.shape[0] batch_mean = data.mean(axis=0) batch_var = data.var(axis=0) # 增量合并均值和方差 new_count = self.count + batch self.mean = (self.count * self.mean + batch * batch_mean) / new_count self.var = (self.count * self.var + batch * batch_var) / new_count self.count = new_count def normalize(self, data): return (data - self.mean) / np.sqrt(self.var + 1e-5)这里有个我踩过的坑:normalizer的统计量必须在整个训练集上大致过一遍再开始正式训练,或者至少在训练初期同步更新。如果你只在当前batch上做归一化,模型会不断看到分布漂移的输入,训练很难稳定下来。
数据增强方面,iDP3常用的就两招:点云随机dropout和加少量高斯噪声。这俩对真机数据的泛化帮助很明显,因为真机点云天然带噪声和遮挡,训练时模拟一点进去,推理时就不至于被传感器噪声带偏。
3. 模型构建与前向传播:条件扩散模型的代码架构
3.1 模型三件套:PointNet++、FiLM、ConditionalUnet1D
iDP3的模型主体由三个组件拼成:点云编码器、条件投影层、条件扩散主干。点云编码器用的是PointNet++,它的作用是把一帧(N, 3)的点云编码成一个全局特征向量。条件投影层把多帧观测特征拼起来,映射到扩散模型能接受的条件维度。扩散主干用的是diffusers库里的ConditionalUnet1D,它接收带噪动作序列和全局条件,通过FiLM方式把条件注入到每一层。
模型初始化的核心代码大致长这样:
import torch.nn as nn from diffusers.models import ConditionalUnet1D from diffusers.schedulers.scheduling_ddpm import DDPMScheduler class iDP3Model(nn.Module): def __init__(self, obs_horizon, action_horizon, action_dim, point_feature_dim=128, num_points=2048): super().__init__() self.obs_horizon = obs_horizon self.action_horizon = action_horizon self.action_dim = action_dim # 点云编码器:把每帧 (N, 3) 点云编码成 (D,) 特征 self.point_encoder = PointNet2Encoder( in_channels=3, out_channels=point_feature_dim, num_points=num_points, ) # 条件投影:obs_horizon 帧特征拼接后映射到 unet 条件维度 cond_dim = 256 self.cond_proj = nn.Sequential( nn.Linear(obs_horizon * point_feature_dim, cond_dim), nn.GELU(), nn.Linear(cond_dim, cond_dim), ) # 扩散模型主干 self.model = ConditionalUnet1D( input_dim=action_dim, global_cond_dim=cond_dim, down_block_types=("DownBlock1DNoTimestep", "DownBlock1DNoTimestep", "DownBlock1DNoTimestep"), up_block_types=("UpBlock1DNoTimestep", "UpBlock1DNoTimestep", "UpBlock1DNoTimestep"), block_out_channels=(64, 128, 256), ) # 噪声调度器 self.noise_scheduler = DDPMScheduler( num_train_timesteps=100, beta_schedule="squaredcos_cap_v2", clip_sample=True, prediction_type="epsilon", )这里最值得说的就是ConditionalUnet1D的condition注入方式。它内部用的是FiLM,也就是把global_cond通过一个线性层映射成每个block的scale和shift,然后作用在特征上。这种设计比直接把条件拼在输入里要高效,因为每一层都能感知到条件信息,而不仅仅是第一层。
3.2 前向传播细节:动作序列如何和点云条件对齐
前向传播是理解整个模块的关键。我们一步一步看shape变化。
def forward(self, point_clouds, action, timesteps): # point_clouds: (B, obs_horizon, N, 3) # action: (B, action_horizon, action_dim) B = point_clouds.shape[0] # 1. 每一帧点云独立编码 # 合并batch和obs_horizon维度,逐帧编码 pc = point_clouds.reshape(B * self.obs_horizon, -1, 3) # (B*obs_horizon, N, 3) point_feats = self.point_encoder(pc) # (B*obs_horizon, D) point_feats = point_feats.reshape(B, self.obs_horizon * point_feats.shape[-1]) # 2. 条件投影 global_cond = self.cond_proj(point_feats) # (B, cond_dim) # 3. 给动作加噪 noise = torch.randn_like(action) noisy_action = self.noise_scheduler.add_noise(action, noise, timesteps) # 4. 扩散模型前向 # noisy_action: (B, action_horizon, action_dim) -> unet 内部转成 (B, action_dim, action_horizon) noise_pred = self.model(noisy_action, timesteps, global_cond=global_cond) return noise_pred, noise点云编码这一步有个显存优化技巧:不要直接让PointNet++接收(B, obs_horizon, N, 3)的输入,因为很多PointNet++实现不支持五维张量。把B和obs_horizon合并成一个大batch编码,效率更高,显存占用也更可控。
ConditionalUnet1D内部处理的是序列数据,它把动作序列看作一个“时间维为action_horizon、通道维为action_dim”的信号,用一维卷积来建模序列内部的依赖关系。所以加噪和预测都是针对整个动作序列进行的,而不是单步动作。
3.3 iDP3和DP3前向的区别:因果mask与时间对齐
iDP3在论文里强调了一个DP3没处理好的问题:训练时随机取了obs窗口和action窗口,但如果不加约束,模型在预测第k步动作时可能隐式地用到了未来帧的点云信息。这就像考试时提前看到了答案,平时练得再好,真上场就露馅。
iDP3的做法是在前向时对观测特征做因果mask。代码上可以这样理解:假设obs_horizon=2,action_horizon=8,那么预测前4步动作时只能用第1帧点云的特征,预测后4步才能同时用第1帧和第2帧的特征。实现时可以构造一个mask矩阵,乘在融合后的特征上,也可以在数据加载时直接把obs窗口和action窗口对齐成“前k步动作对应前k帧观测”的格式。
我用一个简单的类比解释这个设计:开车时踩油门,你只能根据当前和过去的路况来决定脚上的动作,不能“提前看到”几秒后的路况再决定现在该踩多深。iDP3的因果mask就是在强制模型遵守这个自然规律。实际效果是,推理时的动作预测更加稳定,不会因为点云的微小抖动产生剧烈跳变。
4. 3D动作预测的训练与推理闭环
4.1 训练循环:噪声采样、Loss计算与梯度更新
训练核心是compute_loss这块逻辑。diffusion policy的训练目标不是直接回归动作,而是让模型学会预测“加进去的噪声”。这看起来有点绕,但正是扩散模型能生成多峰动作分布的原因。
def compute_loss(self, batch): point_clouds = batch["point_clouds"] # (B, obs_horizon, N, 3) delta_action = batch["delta_action"] # (B, action_horizon, Da) B = delta_action.shape[0] # 随机采样扩散时间步 timesteps = torch.randint( 0, self.noise_scheduler.config.num_train_timesteps, (B,), device=delta_action.device ).long() # 模型前向得到噪声预测 noise_pred, noise = self.model(point_clouds, delta_action, timesteps) # MSE loss loss = nn.functional.mse_loss(noise_pred, noise) return loss这里的核心逻辑很简单:随机选一个时间步t,把纯噪声逐步叠加到真实动作上,让模型根据带噪动作和点云条件去预测噪声。训练稳定后,模型就学会了“看到多噪的动作序列和观测,就知道该怎么把它恢复成干净动作”。
训练时还有一个很容易忽略的点:add_noise的timesteps参数必须是long类型的tensor,不能是int。很多复现bug就出在这里,diffusers内部会根据timesteps去查询噪声调度表,传错类型会直接报错或者算出错误结果。
超参数方面,我用的配置是:
| 超参数 | 数值 | 说明 |
|---|---|---|
| num_train_timesteps | 100 | 扩散步数,100是速度和质量的平衡点 |
| beta_schedule | squaredcos_cap_v2 | 余弦噪声调度,训练更稳定 |
| prediction_type | epsilon | 预测噪声,最常用的训练目标 |
| obs_horizon | 2 | 输入多少帧点云观测 |
| action_horizon | 8 | 一次预测多少步动作 |
| learning_rate | 1e-3 | 配合cosine decay |
| batch_size | 8 | 取决于点云数量和显存大小 |
4.2 推理循环:从纯噪声到动作序列的逐步去噪
推理和训练正好是相反的过程。训练时是“加噪-预测噪声”,推理时是“从纯噪声出发-逐步去噪”。代码如下:
def predict_action(self, point_clouds): # 点云编码,得到条件 pc = point_clouds.unsqueeze(0) # (1, obs_horizon, N, 3) point_feats = self.point_encoder(pc.reshape(-1, pc.shape[-2], pc.shape[-1])) global_cond = self.cond_proj(point_feats.reshape(1, -1)) # 初始化纯噪声动作序列 noisy_action = torch.randn( (1, self.action_horizon, self.action_dim), device=self.device ) # 设置推理步数 self.noise_scheduler.set_timesteps(self.num_inference_steps) # 逐步去噪 for t in self.noise_scheduler.timesteps: t_tensor = t.unsqueeze(0).long() noise_pred = self.model(noisy_action, t_tensor, global_cond=global_cond) noisy_action = self.noise_scheduler.step( noise_pred, t, noisy_action ).prev_sample # 去噪结果:delta action序列 (1, action_horizon, Da) delta_action = noisy_action.squeeze(0) # 累积还原成真实动作 action = torch.cumsum(delta_action, dim=0) return action注意最后一步,torch.cumsum把delta action累积成绝对动作。这一步是整个推理里最容易出错的地方。我见过不少人在复现时漏掉这个累积操作,导致机器人动作忽大忽小完全不可用。原因就是训练时用的是delta目标,推理时却直接输出了delta而没有还原。
推理时的set_timesteps也很关键。如果训练时用了100步,推理时可以只用10步或20步,DDPM的调度器会均匀抽取这些时间步。实测下来,灵巧手任务10步去噪质量已经够用,20步更稳,100步纯属浪费计算。
4.3 多步滚动与性能优化
机器人控制是实时的,不能每次都生成一整段动作再全部执行。标准做法是:一次预测action_horizon步,但只执行前action_step步,然后重新采集点云,再次预测。这个“预测-执行-再预测”的滚动方式在DP系列里是标配。
action = self.predict_action(current_obs) exec_action = action[:action_step] # 只执行前面的动作性能优化方面,我实测下来有三个点收益最大。第一,推理时用torch.no_grad()和torch.cuda.amp.autocast(),能省不少显存和计算。第二,点云编码可以缓存,如果你的机器人本体基本不动、只有末端在动,历史帧的点云特征可以复用,不用每帧都重算。第三,把点云下采样点数从2048降到1024,推理延迟能降低将近一半,代价是策略精度略有下降,具体取舍看你的任务。
5. 实操踩坑与调优心得
5.1 我遇到的五个典型报错与排查
把我在跑iDP3时遇到的高频问题整理成一张速查表,方便大家直接对照排查。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| hdf5读取时KeyError | 键名和代码里不一致 | 先打印hdf5所有keys,逐层核对observations下面的子键名 |
| PointNet2编译失败 | CUDA版本与PyTorch不匹配 | 优先用预编译wheel,或按README用Docker镜像 |
| loss下降正常但推理动作为0 | delta action没有累积还原 | 检查推理最后是否做了cumsum |
| 点云输入维度报错 | 数据存的是float16或带额外通道 | 转float32,检查最后一维是3还是4 |
| 训练效果好但真机效果差 | 点云坐标系/单位不一致 | 确认训练和推理用同一套坐标系归一化参数 |
最后一个问题是最隐蔽的。如果你从仿真迁移到真机,点云尺度、相机视角、末端坐标原点都可能变了,但normalizer还是训练时的统计量,等于输入分布整个偏移了。这时候策略一定会崩。
5.2 调参经验:哪些超参数最值得动
调参这件事,我的经验是可动参数越少越好。iDP3里最值得动的四个参数,按优先级排:
| 参数 | 建议范围 | 我的实测结论 |
|---|---|---|
| num_train_timesteps | 50-200 | 100起步;步数太少动作粗糙,太多训练变慢且收益递减 |
| num_inference_steps | 10-50 | 10步能跑,20步稳,追求延迟就压到10 |
| obs_horizon | 1-4 | 灵巧手任务用2,带记忆的环境用3-4 |
| 点云点数 | 1024-4096 | 2048性价比最高,输入太稀疏会丢几何细节 |
学习率这块我建议固定1e-3配合cosine decay,不要一上来就调。它很少是效果差的根因,数据质量和观测对齐才是。
还有一个容易忽视的点:动作维度。如果你的机器人是双手+灵巧手,action_dim可能会到20以上,这时候action_horizon建议调大一点,因为高维动作空间的生成需要更长的序列来保持平滑。我自己在双手任务上把action_horizon从8调到16,效果明显改善。
5.3 最后分享一点个人体会
跑完整个iDP3的Learning模块,我最大的感受是:这类3D模仿学习的难点不在数学公式,而在工程细节的串通。数据集的窗口怎么切、delta怎么算、训练加噪和推理去噪怎么对齐、normalizer统计量怎么更新,任何一环出错,结果都会很离谱。
如果你是第一次接触这套代码,我的建议是先在已有的demo数据上把训练和推理完整跑通,再逐步替换成自己的数据。不要一上来就想着从零手写整个pipeline,先把diffusers的scheduler源码读一遍,把add_noise和step这两个函数的输入输出形状吃透,就已经超过了大部分复现者。
iDP3这套代码后续的扩展空间也很大,比如把PointNet++换成更强的3D backbone、把DDPM换成flow matching、或者把单帧点云改成多视角融合,Learning模块的骨架基本不用动。把这套数据流和训练推理逻辑吃透,后面做任何3D模仿学习项目,都会顺手很多。