资讯动态

REFACTOR-VLA:用无监督学习构建类型化运动程序库

发布时间:2026/9/5 5:16:22 来源:尧图企业网站定制
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_{t1}, ..., a_{tk-1}]模型先把action_chunk压缩成向量z再由z重建出action_chunk。如果重建误差很低说明z保留了动作片段中最重要的运动信息。再刻意让z只能从有限个离散代码中取值模型就会被迫把相近动作映射到同一代码。这种“先压缩再重建”的训练方式不依赖人类标注因此可以应用到大规模未整理轨迹上。不同任务里的“直线移动”即使方向不同只要模型发现它们可以用同一个代码表示并成功重建就会被归到同一原语。2.3 向量量化与类型化直接绑定VQ-VAE 里引入了一个代码表也叫 codebookcodebook [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 python3.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 数据组织结构可以准备一个清单文件每条记录表示一个 episodeepisodes: - 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。下面定义一个切分动作片段的 Datasetimport numpy as np import torch from torch.utils.data import Dataset class ActionChunkDataset(Dataset): def __init__(self, episode_paths, chunk_len16, stride8): 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], dtypetorch.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_dim7, chunk_len16, latent_dim32, codebook_size32, beta0.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(dim1) # [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(dim1) 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(dim1)也就是把一条动作窗口压缩成一个整体特征。这样每个原语只有一个代码避免了“一个窗口内每个时刻一个原语”的碎片化问题。第二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, epochs50, batch_size128, lr1e-3): loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) optimizer optim.Adam(model.parameters(), lrlr) 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( fepoch{epoch}, ftotal_loss{float(output[total_loss]):.4f}, fcode_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], dtypetorch.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_dim256): 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_len8 到 32原语更碎片组合灵活但决策负担大原语更完整但细节被压缩选择不灵活stride2 到 16数据重叠多训练慢数据采样少可能漏掉关键片段codebook_size16 到 128原语类型少容易坍缩原语类型多可能过拟合且使用率低latent_dim16 到 128压缩强信息损失大表达强但离散化后可能仍保留无关细节beta0.1 到 1.0编码器与代码表约束弱重建被牺牲代码表过强会导致重建质量差batch_size64 到 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 输出训练集平均动作重建误差也不是特别大

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价