REFACTOR-VLA 这个名称在机器人学习社区里被反复提及。它由 REFACTOR 和 VLA 组成:VLA 指 Vision-Language-Action,把图像、语言指令和机器人当前状态融合成动作;REFACTOR 则强调对模型内部的连续动作空间做一次重构。这个方向对应的问题很具体:机器人策略不能总是一条图像到动作的端到端映射,因为动作空间的随意变化会让训练和迁移都变得脆弱。一种更稳的思路是先整理出一组“语义清晰、可复用”的动作单元,再让高级策略在这些单元之间选择。标题里的“类型化运动程序库”就是这个动作单元集合的工程化表达。
下面从概念、损失设计、最小实现、参数调节到故障排查逐步展开。代码不会完全等同于 REFACTOR-VLA 的官方实现,因为公开细节还没有完整到可以逐行复现,但核心链路是一致的:用无监督学习把轨迹切分成可复用的原语,再通过图像和语言选择原语并执行。
1. 先拆命名:VLA、运动程序库与无监督学习为什么能放在一起
1.1 端到端 VLA 直接输出轨迹的代价
常见的 VLA 输入是多模态信息:当前相机图像、自然语言指令、机械臂关节角度和末端位姿。输出通常是下一段动作,常见动作表达有 6 维或 7 维的末端速度、7 维关节速度或者更密集的扭矩指令。
这种端到端表达并不是不能学,而是“不容易学得稳”。机器人动作空间是连续且高频的,同一个“把杯子放在盘子里”的任务,因为杯子起始位置不同、机械臂型号不同、控制器频率不同,动作轨迹在数值上会差异巨大。模型如果只在底层轨迹上做回归,很容易学到每个训练集的平均动作,而不是任务本身的结构。
因此很多研究者开始把动作空间结构化。先让模型看到一组“动作是什么意思”,而不是直接猜测下一个关节速度。这样语言指令与动作之间的关系就更像“调用函数”,而不是“生成几万个浮点数”。
1.2 运动原语和低级轨迹不是一回事
运动原语可以通俗理解为动作片段,英文常用 motion primitive。一个机械臂任务的完整轨迹可以拆成下面这些片段:
- 从当前点运动到物体附近;
- 调整末端姿态准备抓取;
- 闭合夹爪;
- 垂直抬起;
- 移动到目标位置;
- 张开夹爪。
这些片段中,“移动到目标位置”可能在很多任务里反复出现,只是起点、终点和速度不同。若能把它抽象成一条原语,那么模型就不需要为每个新任务重新学习“怎么移动”这件事。
低级轨迹是具体数值,例如:
joint_velocities: [0.02, -0.10, 0.35, 0.00, 0.00, 0.00, 0.00] end_effector_pose: [0.42, 0.10, 0.18, 0.0, 0.0, 1.0, 0.0]运动原语则是把这些数值进一步抽象后的单元。对机器人学习来说,原语不能只是给动作片段起名字,它还需要能还原出可执行的低层动作;否则下游策略把原语选出来后,机器人仍然无法动起来。
1.3 “类型化”带来的约束和可解释性
“类型化”这个词借用了编程语言中的类型概念。一个类型化运动程序库会规定每类原语的输入、输出和约束条件。例如:
| 原语类型 | 典型参数 | 适用条件 | 输出 |
|---|---|---|---|
| 移动到点 | 目标坐标、速度 | 当前无夹持物体 | 一段末端轨迹 |
| 平移物体 | 目标坐标、保持高度 | 夹爪正在夹持物体 | 一段平移轨迹 |
| 旋转物体 | 目标欧拉角、旋转中心 | 夹爪正在夹持物体 | 一段旋转轨迹 |
| 夹爪开合 | 开合宽度、速度 | 末端接近目标 | 一段夹爪控制指令 |
类型化之后,VLA 的任务发生了改变。它不再需要生成连续的末端轨迹,而是需要先判断“当前状态属于哪类前提”,再选择“哪个原语最合适”。
这种约束也会提高可解释性。策略输出一个原语 ID 时,调试者可以知道机器人下一步会做“移动”还是“旋转”。相比直接解释一个 7 维向量,原语 ID 更容易排查。很多半结构化策略在真实机械臂上失败的案例,最后都归结到一个问题:低级动作虽然连续,但缺少中间语义层,一旦状态离训练分布稍远,动作就会不可控地漂移。
1.4 无监督是标签不足时的必然选择
如果每个轨迹片段都要人工标注“这是移动原语”“这是抓取原语”,数据成本会非常高。机器人数据集通常来自多个任务、多个机械臂,一只机械臂一天可能采集几十万条动作数据,人工不可能逐个细粒度标注。
无监督学习在这里的定位是:通过压缩、重建或者对比学习,从数据内部发现可重复的动作结构,而不依赖人工标签。典型工具是向量量化自编码器(VQ-VAE),它会迫使轨迹片段落进有限数量的离散代码,每个代码在训练结束后往往就对应一类运动原语。
所以 REFACTOR-VLA 这类思路的技术主线可以概括成下面这条链路:
原始轨迹数据 -> 无监督分段与编码 -> 离散运动原语库 -> VLA 选择原语 -> 解码出可执行动作2. 无监督提取类型化运动原语的核心机制
2.1 输入什么、学什么、输出什么
在构建简化模型前,先明确数据流。假设一条机械臂经验轨迹保存为高频率状态与动作序列:
Episode: state_t: [关节角度, 末端坐标, 夹爪宽度, 时间戳] action_t: [关节速度或末端速度和夹爪速度]无监督运动原语提取的输入是这些轨迹,输出是一组离散代码。训练结束后,每个代码配上解码器,就能生成一段连续动作。这个代码与“原语类型”对应,代码参数的入口则由解码器承担。
这里的关键在于模型怎么决定“哪些动作应该共用一个原语”。如果模型完全自由输出,它会倾向于记住每一条轨迹。为了促使它泛化,需要加一个信息瓶颈:先让轨迹压缩成一个很小的向量,再用这个向量重建原始动作。瓶颈越小,模型越只能保留不同片段之间最通用的运动结构。
2.2 用“重建”当监督信号
无监督学习不是没有损失函数,而是不需要人工标签。对运动原语学习来说,最直接的监督信号是重建误差。
一条动作片段可以表示为:
action_chunk = [a_t, a_{t+1}, ..., a_{t+k-1}]模型先把action_chunk压缩成向量z,再由z重建出action_chunk。如果重建误差很低,说明z保留了动作片段中最重要的运动信息。再刻意让z只能从有限个离散代码中取值,模型就会被迫把相近动作映射到同一代码。
这种“先压缩再重建”的训练方式不依赖人类标注,因此可以应用到大规模未整理轨迹上。不同任务里的“直线移动”即使方向不同,只要模型发现它们可以用同一个代码表示并成功重建,就会被归到同一原语。
2.3 向量量化与类型化直接绑定
VQ-VAE 里引入了一个代码表,也叫 codebook:
codebook = [code_0, code_1, ..., code_{K-1}]写入代码表前,编码器输出的连续向量需要找到最近的代码,并把代码 ID 当作模型的中间输出。整个过程可以用下面这段简化伪代码表示:
z = encoder(action_chunk) code_id = argmin_i || z - codebook[i] ||^2 z_q = codebook[code_id] reconstructed_actions = decoder(z_q)训练完成后,每个code_id就可以看成“原语类型”。下游 VLA 不直接回归动作,而是回归 code_id 或 code_id 的概率分布。这么设计后,运动程序库天然是离散的,便于做约束检查:机械臂不能边抓取边旋转时,就可以限制某些原语不能连续出现。
2.4 一个原则:原语不需要覆盖整个轨迹
运动原语最好建立在局部动作窗口上,而不是整条任务轨迹上。完整任务的时间尺度很长,如果强制用一个离散代码表示整条轨迹,模型会丢失局部细节,也很难让语言指令精确控制一个长时间任务。
推荐的做法是提前配置一个 chunk_len。例如 chunk_len = 16,表示每 16 步动作组合成一个原语。训练时用滑窗切数据:
for t in range(0, len(actions) - chunk_len + 1, stride): windows.append(actions[t:t + chunk_len])这样每个原语只覆盖短时间动作,模型可以组合多个原语来完成复杂任务,而不是把全部动作都压进一个向量里。
3. 写一个可学习的简化样例:运动原语 VQ-VAE 与 VLA 适配
3.1 环境准备
在真正训练前,建议先建一个虚拟环境。下面的版本组合适用于教学实现,实际项目要以官方支持的版本为准:
conda create -n refactor_vla python=3.10 conda activate refactor_vla pip install torch einops numpy pyyaml如果数据包含图像序列,还需要安装图像处理相关依赖:
pip install opencv-python pillow最小实现阶段不依赖机器人仿真器,只需要准备一份动作序列数据集即可。先把动作片段用 VQ-VAE 训练成原语库,再在第二阶段把原语 ID 与图像、语言特征关联起来。
依赖版本不建议盲目安装最新版。有过项目经验的人都知道,PyTorch 版本、CUDA 驱动和 numpy 版本经常会互相影响。这里不锁定具体版本号,因为不同训练卡支持的 CUDA 版本不同;但建议在环境里先做一次版本检查和随机种子测试:
import torch print(torch.__version__) print(torch.cuda.is_available())3.2 数据组织结构
可以准备一个清单文件,每条记录表示一个 episode:
episodes: - path: data/episode_001.npz task: pick_place - path: data/episode_002.npz task: drawer_open每个 npz 文件里至少包含两个数组:
observations: [T, obs_dim] actions: [T, act_dim]第一阶段的 VQ-VAE 学习只需要 actions,不需要人工判断动作属于哪类原语。第二阶段再用带语言标签和图像特征的数据训练 VLA adapter。
下面定义一个切分动作片段的 Dataset:
import numpy as np import torch from torch.utils.data import Dataset class ActionChunkDataset(Dataset): def __init__(self, episode_paths, chunk_len=16, stride=8): self.windows = [] for path in episode_paths: data = np.load(path) actions = data["actions"] # shape: [T, act_dim] if len(actions) < chunk_len: continue for t in range(0, len(actions) - chunk_len + 1, stride): self.windows.append(actions[t:t + chunk_len]) def __len__(self): return len(self.windows) def __getitem__(self, idx): return torch.as_tensor(self.windows[idx], dtype=torch.float32)这里的chunk_len是动作原语覆盖的步数,stride控制窗口之间的重叠程度。步数太小,原语会非常琐碎;步数太大,原语会包含过多动作类型。一般先尝试 8 到 32。
3.3 简化版运动原语 VQ-VAE
下面的模型不解析图像,只从动作序列中学习原语代码。它包含三个部分:
- encoder:把长度为 T 的动作序列压缩成连续向量;
- codebook:离散代码表;
- decoder:从离散代码重建完整动作序列。
实现如下:
import torch import torch.nn as nn import torch.nn.functional as F class MotionPrimitiveVQVAE(nn.Module): def __init__(self, act_dim=7, chunk_len=16, latent_dim=32, codebook_size=32, beta=0.25): super().__init__() self.chunk_len = chunk_len self.act_dim = act_dim self.codebook_size = codebook_size self.beta = beta self.encoder = nn.Sequential( nn.Linear(act_dim, 128), nn.ReLU(), nn.Linear(128, latent_dim), ) self.codebook = nn.Embedding(codebook_size, latent_dim) self.decoder = nn.Sequential( nn.Linear(latent_dim, 256), nn.ReLU(), nn.Linear(256, chunk_len * act_dim), ) def encode_to_code(self, action_chunk): # action_chunk: [B, T, act_dim] h = self.encoder(action_chunk) # [B, T, latent_dim] h = h.mean(dim=1) # [B, latent_dim] code_ids = self.quantize(h) return code_ids def quantize(self, h): # h: [B, latent_dim] dist = torch.cdist(h, self.codebook.weight) # [B, codebook_size] code_ids = dist.argmin(dim=-1) # [B] z_q = self.codebook(code_ids) # [B, latent_dim] return code_ids, z_q def forward(self, action_chunk): # action_chunk: [B, T, act_dim] h = self.encoder(action_chunk) h = h.mean(dim=1) code_ids, z_q = self.quantize(h) code_loss = F.mse_loss(z_q.detach(), h) commit_loss = F.mse_loss(z_q, h.detach()) # straight-through estimator z_q_ste = h + (z_q - h).detach() reconstructed_flat = self.decoder(z_q_ste) reconstructed = reconstructed_flat.view( -1, self.chunk_len, self.act_dim ) recon_loss = F.mse_loss(reconstructed, action_chunk) total_loss = recon_loss + code_loss + self.beta * commit_loss return { "reconstructed": reconstructed, "code_ids": code_ids, "recon_loss": recon_loss, "code_loss": code_loss, "commit_loss": commit_loss, "total_loss": total_loss, }这里需要解释几个设计点。
第一,encoder 对每个时间步输出一个向量,但最终mean(dim=1),也就是把一条动作窗口压缩成一个整体特征。这样每个原语只有一个代码,避免了“一个窗口内每个时刻一个原语”的碎片化问题。
第二,codebook 用Embedding实现。训练code_loss时让代码表向编码器输出靠近,训练commit_loss时让编码器输出向代码表靠近。二者方向不同,所以不能只写一个损失。
第三,straight-through estimator 让梯度可以从重建损失传到 encoder。否则argmin不可导,损失无法更新编码器。
3.4 训练原语库的流程
训练循环并不复杂,但要注意保存代码表使用情况:
import torch.optim as optim from torch.utils.data import DataLoader def train_vq_model(model, dataset, epochs=50, batch_size=128, lr=1e-3): loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) optimizer = optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): for batch in loader: optimizer.zero_grad() output = model(batch) output["total_loss"].backward() optimizer.step() code_ids = [] for batch in loader: out = model(batch) code_ids.extend(out["code_ids"].tolist()) unique_ratio = len(set(code_ids)) / model.codebook_size print( f"epoch={epoch}, " f"total_loss={float(output['total_loss']):.4f}, " f"code_usage={unique_ratio:.2f}" )日志里的code_usage是一个关键检查点。若代码表大小为 64,但只有 3 个代码被反复使用,说明模型发生了“代码坍缩”,原语库并没有形成有效区分。此时不要急着继续训练,要定位是数据高度相似、chunk 太短,还是 codebook 初始化不佳。
训练结束后,可以用下面的方式查看一段动作属于哪个原语:
import numpy as np test_data = np.load("data/episode_005.npz") actions = torch.as_tensor(test_data["actions"][:16], dtype=torch.float32).unsqueeze(0) model.eval() with torch.no_grad(): code_id = model.encode_to_code(actions) print("primitive code:", code_id.item())这一步可以让我们直观理解原语 ID 代表哪段运动。
3.5 接入下游 VLA 选择原语
原语库训练完成后,VLA 不再需要输出一长串动作,它只需要输出一个离散代码。简化做法如下:
class SimpleVLACodePolicy(nn.Module): def __init__(self, image_feat_dim, text_feat_dim, state_dim, codebook_size, hidden_dim=256): super().__init__() self.img_proj = nn.Linear(image_feat_dim, hidden_dim) self.text_proj = nn.Linear(text_feat_dim, hidden_dim) self.state_proj = nn.Linear(state_dim, hidden_dim) self.classifier = nn.Sequential( nn.Linear(hidden_dim * 3, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, codebook_size), ) def forward(self, image_feat, text_feat, robot_state): h_img = self.img_proj(image_feat) h_text = self.text_proj(text_feat) h_state = self.state_proj(robot_state) fused = torch.cat([h_img, h_text, h_state], dim=-1) logits = self.classifier(fused) return logits第二阶段训练时,把每个动作窗口的 code_id 当成标签:
logits = policy(image_feat, text_feat, robot_state) loss = F.cross_entropy(logits, code_id_label)推理阶段,先由 VLA 得到 code_id,再取出 codebook 向量并通过原语解码器生成动作:
code_id = logits.argmax(dim=-1) z_q = model.codebook(code_id) decoded_action = model.decoder(z_q).view(1, chunk_len, act_dim)这样就把完整动作拆成了高层决策与底层执行两层。高层只负责选择原语,底层只负责把原语翻译成实际动作。
4. 关键参数、训练顺序和效果验证
4.1 核心超参数速查表
参数调节如果只看模型复杂度会走偏。下表列出与运动原语直接相关的参数及影响:
| 参数 | 推荐范围 | 调小影响 | 调大影响 |
|---|---|---|---|
| chunk_len | 8 到 32 | 原语更碎片,组合灵活但决策负担大 | 原语更完整,但细节被压缩,选择不灵活 |
| stride | 2 到 16 | 数据重叠多,训练慢 | 数据采样少,可能漏掉关键片段 |
| codebook_size | 16 到 128 | 原语类型少,容易坍缩 | 原语类型多,可能过拟合且使用率低 |
| latent_dim | 16 到 128 | 压缩强,信息损失大 | 表达强,但离散化后可能仍保留无关细节 |
| beta | 0.1 到 1.0 | 编码器与代码表约束弱 | 重建被牺牲,代码表过强会导致重建质量差 |
| batch_size | 64 到 512 | 收敛慢,噪声大 | 显存占用高,对代码表更新更稳定 |
这里要特别注意:codebook_size并不一定越大越好。很多实际项目里,把 codebook 从 16 调到 128 后,code_usage 反而降低,大量代码从未被使用。这是典型问题,不是“代码不够多”可以解决的。
4.2 两阶段训练优于一开始联合训练
不建议从零开始同时训练原语库和语言动作策略。原语库在没有稳定语言输入时,更容易学习纯运动结构;若一开始就把语言跨模态损失加进来,模型可能为了拟合语言而扭曲动作细节。
推荐流程分成两阶段:
- 阶段一:只使用动作轨迹训练 MotionPrimitiveVQVAE,固定 chunk_len 和 codebook_size,直到重建误差稳定。
- 阶段二:冻结原语解码器,只训练 VLA CodePolicy,把图像和语言映射到已有 code_id。
- 可选阶段三:解冻部分原语解码器做联合微调,但只在小学习率下进行,避免原语库被语言任务过度改写。
这种顺序能减少问题定位成本。第一阶段表现不好,问题大多在数据切分、编码器结构或代码库容量;第二阶段表现不好,问题更多在图像特征、文本特征或跨模态对齐。
4.3 原语数量的选择策略
codebook_size 的选择可以组合使用以下两个指标:
- code_usage:训练集里被使用过的代码数占代码总量的比例;
- reconstruction error:单位是动作量纲,只观察相对趋势。
若 code_usage 长期低于 50%,先把 codebook_size 调小,不要先增加模型宽度。若 codebook_size 已经很小但重建误差仍高,则说明 chunk_len 太长或 latent_dim 太小。
实际操作中,可以先做一次小规模扫描:
codebook_size in [16, 32, 64, 128] chunk_len in [8, 16, 32]每组训练 20 个 epoch,比较重建误差与 code_usage。这种方式不需要大量算力,也能快速定位量级。
5. 从现象定位问题:验证、日志和三个高频深坑
5.1 用成功率之外的一串指标验证原语
运动原语模型不能只看最终任务成功率。原语库本身的质量需要从多个角度观察:
| 指标 | 计算方法 | 含义 |
|---|---|---|
| 重建误差 | MSE(pred_action, real_action) | 原语是否能还原真实动作 |
| code_usage | 被使用代码数 / codebook_size | 原语库是否被充分使用 |
| code 稳定性 | 同段轨迹多次加噪后 code_id 是否一致 | 原语判断是否受噪声影响 |
| 连续切换频率 | 相邻动作片段 code_id 变化次数 | 原语序列是否过度抖动 |
| 下游任务成功率 | 仿真或真机执行完成率 | 原语库加 VLA 是否真正解决了任务 |
这里强调 code 稳定性。若输入动作加一点点噪声后,code_id 在多个代码之间乱跳,说明聚类边界不稳定,原语类型并不可靠。这时候可以在 real 动作中加入标准差很小的噪声,重新提取 code_id,统计切换率。
5.2 日志应该埋哪些点
训练循环不能只打印总损失。推荐在每个 epoch 记录以下字段:
epoch total_loss recon_loss code_loss commit_loss code_usage reconstruction_error average_primitive_switch_rate若最终下游任务失败,检查日志时可以先看下列路径:
recon_loss是否持续下降;code_usage是否健康;primitive_switch_rate是否过高;- 第二阶段 cross_entropy loss 是否有明显下降;
- 成功率是否随训练轮次上升。
只要其中一步异常,问题就不会只出现在最终策略上。
5.3 典型故障及排查链路
下面三个问题在运动原语库训练中最常见。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| code_usage 极低,大量代码从未被使用 | codebook_size 过大,或 encoder 输出频繁落到少数几个代码 | 打印每个 code_id 在训练集上的直方图 | 调小 codebook_size,先保证使用率超过 80%,再考虑扩充 |
| 重建误差低,但下游任务成功率不高 | 原语库保留了数据集统计规律,但缺少与语言任务对齐的语义 | 抽几个 code_id 观察动作可视化,检查同一 code 是否对应多类动作 | 增加第二阶段的跨模态对齐,不要让原语库永久冻结 |
| 同一指令在仿真中动作抖动 | code_id 频繁切换,原语缺少时序平滑 | 打印 code_id 序列,统计相邻切换次数 | 推理时对 code logits 增加温度限制,或引入时序约束禁止原语过频繁回退 |
5.4 一个很典型的错误:只验证代码能不能训练
很多初学者把“训练 loss 下降”当作模型可用。对机器人运动原语来说,loss 下降不代表原语被正确类型化。例如,一个模型可能把所有动作都压缩进同一个 code_id,然后 decoder 输出训练集平均动作,重建误差也不是特别大,