feat(vla): add ACT policy for socket peg

This commit is contained in:
Logic
2026-07-31 10:11:04 +08:00
parent acbd7c605a
commit ff7f4a1b03
8 changed files with 926 additions and 5 deletions
@@ -0,0 +1,49 @@
# ACT Socket Peg Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a local ACT policy/model to RoboIMI and launch training on the socket peg dataset with three 224×224 camera views.
**Architecture:** Implement a self-contained ACT agent and head that reuse the existing ResNet multiview backbone, dataset, training loop, normalization, and checkpointing. The ACT model uses a posterior transformer encoder for latent z and a transformer decoder with learned action queries for action chunks.
**Tech Stack:** Python, PyTorch, Hydra/OmegaConf, unittest, HDF5 dataset via existing `SimpleRobotDataset`.
---
## File Structure
- Create `roboimi/vla/models/heads/act.py`: local ACT model/head implementation, no imports from external ACT repository.
- Create `roboimi/vla/agent_act.py`: VLA-compatible ACT agent wrapper with normalization, condition building, loss, and inference queues.
- Create `roboimi/vla/conf/agent/act_resnet.yaml`: Hydra agent config for three-camera ACT with 224×224 images.
- Create `tests/test_act_agent.py`: model/agent unit tests.
- Modify no external ACT code and do not add vendored ACT files.
## Tasks
### Task 1: Add ACT model/head tests
- [ ] Write tests in `tests/test_act_agent.py` that define a lightweight fake vision backbone emitting deterministic camera tokens.
- [ ] Test `ACTAgent.compute_loss()` returns a scalar tensor and backpropagates through the head.
- [ ] Test masked L1 ignores padded timesteps by comparing all-padded vs partially valid batches for finite loss behavior.
- [ ] Test `ACTAgent.predict_action()` returns `(B,pred_horizon,action_dim)`.
- [ ] Run `python -m unittest tests.test_act_agent -v` and confirm tests fail because `roboimi.vla.agent_act` does not exist.
### Task 2: Implement local ACT head and ACT agent
- [ ] Create `roboimi/vla/models/heads/act.py` with `ACTPolicyHead`, sinusoidal table helper, KL helper, and transformer layers using `batch_first=True` PyTorch modules.
- [ ] Create `roboimi/vla/agent_act.py` with `ACTAgent` implementing existing training/inference API.
- [ ] Reuse `NormalizationModule` and camera ordering checks from `VLAAgent` behavior.
- [ ] Run `python -m unittest tests.test_act_agent -v` and fix until green.
### Task 3: Add Hydra config and wiring tests
- [ ] Add `roboimi/vla/conf/agent/act_resnet.yaml` using existing `resnet_diffusion` backbone with `output_tokens_per_camera=true` and `camera_names=${data.camera_names}`.
- [ ] Extend `tests/test_act_agent.py` with a Hydra compose/instantiate test using reduced backbone/head sizes and `data.camera_names='[l_vis,r_vis,front]'`.
- [ ] Run `python -m unittest tests.test_act_agent -v` and `python -m unittest tests.test_resnet_transformer_agent_wiring -v`.
### Task 4: Verify socket peg data path and training smoke test
- [ ] Run a dataset sample check against `/data/roboimi_datasets/sim_air_insert_socket_peg` with `camera_names=[l_vis,r_vis,front]` and `image_resize_shape=[224,224]`.
- [ ] Run a short CPU or GPU training smoke test with `agent=act_resnet`, `train.max_steps=2`, `train.num_workers=0`, pretrained backbone disabled, and reduced head sizes if needed.
- [ ] Record exact command and output snippet.
### Task 5: Launch real ACT socket peg training
- [ ] Create a run directory under `runs/` with timestamped name.
- [ ] Start training using `/home/droid/.conda/envs/roboimi/bin/python roboimi/demos/vla_scripts/train_vla.py agent=act_resnet data.dataset_dir=/data/roboimi_datasets/sim_air_insert_socket_peg data.camera_names='[l_vis,r_vis,front]' data.image_resize_shape='[224,224]'` plus selected training hyperparameters.
- [ ] Redirect output to `train.log` and store PID in `train.pid`.
- [ ] Tail log to verify dataset loads, agent initializes, and first loss is produced.
@@ -0,0 +1,78 @@
# ACT Socket Peg Policy Design
## Goal
Add a local ACT-style policy/model to the existing VLA training stack and start training it on `/data/roboimi_datasets/sim_air_insert_socket_peg` using three 224×224 camera views.
## Constraints
- Base work is on branch `feat-act-socket-peg`, created from current `main` (`acbd7c605a8d203a774f2f47cff8094c05d9325e`).
- Do not vendor or import ACT repository environment, dataset, training, or utility code.
- Reimplement only the model/policy logic needed for this repo: CVAE action encoder, learned action queries, transformer conditioning, KL + L1 loss, and inference from prior.
- Keep existing dataset/training loop style and checkpoint format.
## Data
The socket peg dataset is HDF5 under `/data/roboimi_datasets/sim_air_insert_socket_peg`. Episodes contain:
- `action`: `(600, 16)`, `float32`
- `observations/qpos`: `(600, 16)`, `float32`
- `observations/images/l_vis`, `r_vis`, `front`: `(600, 256, 256, 3)`, `uint8`
- attrs include `camera_names=[l_vis,r_vis,front]`, `image_height=256`, `image_width=256`, `sim=True`.
Training config must use `data.camera_names='[l_vis,r_vis,front]'` and `data.image_resize_shape=[224,224]`. Existing dataset code already resizes HWC uint8 frames to `(C,224,224)` float tensors in `[0,1]`.
## Architecture
Create `roboimi/vla/agent_act.py` with `ACTAgent`, an `nn.Module` that follows the existing agent API:
- `compute_loss(batch)` accepts `images`, `qpos`, `action`, `action_is_pad`.
- `predict_action(images, proprioception)` returns denormalized `(B,pred_horizon,action_dim)` chunks.
- `predict_action_chunk`, `select_action`, and queue handling mirror the existing inference contract.
- `get_normalization_stats()` returns the current normalization module stats.
Create `roboimi/vla/models/heads/act.py` containing focused, local ACT model classes:
- Sinusoidal positional table helper.
- Transformer encoder for posterior `z` from `[CLS, qpos, action sequence]` with padding mask.
- Transformer decoder/action-query module conditioned on visual tokens, current qpos, and latent token.
- `ACTPolicyHead` returning action predictions and latent `(mu, logvar)`.
To avoid copying ACT's DETR image backbone code, reuse this repo's `ResNetDiffusionBackbone`. Configure it with `output_tokens_per_camera=true`, so each observation step emits one token per camera. The ACT agent builds memory tokens by concatenating camera visual tokens, a qpos token, and a latent token.
## Loss
During training:
1. Normalize qpos/action with existing `NormalizationModule`.
2. Keep only `num_queries == pred_horizon` actions.
3. Encode posterior `z` from normalized current qpos and normalized target action sequence.
4. Predict action chunk from image/qpos/latent tokens.
5. Compute masked L1 over non-padded action timesteps.
6. Add `kl_weight * KL(N(mu,sigma), N(0,I))`.
Inference uses zero latent prior and denormalizes predicted actions.
## Config
Add `roboimi/vla/conf/agent/act_resnet.yaml`:
- `_target_: roboimi.vla.agent_act.ACTAgent`
- `action_dim=16`, `obs_dim=16`
- `pred_horizon=16`, `obs_horizon=1` by default, `num_action_steps=8`
- `camera_names: ${data.camera_names}`, `num_cams: 3`
- ResNet backbone with `input_shape=[3,224,224]`, `output_tokens_per_camera=true`, three cameras.
- ACT head hyperparameters small enough for a training smoke test and usable as defaults: `hidden_dim=256`, `nheads=8`, `enc_layers=4`, `dec_layers=6`, `dim_feedforward=2048`, `latent_dim=32`, `dropout=0.1`, `kl_weight=10.0`.
Training command should override dataset path and camera names:
```bash
/home/droid/.conda/envs/roboimi/bin/python roboimi/demos/vla_scripts/train_vla.py \
agent=act_resnet \
data.dataset_dir=/data/roboimi_datasets/sim_air_insert_socket_peg \
data.camera_names='[l_vis,r_vis,front]' \
data.image_resize_shape='[224,224]' \
train.device=cuda \
train.num_workers=8 \
train.batch_size=32 \
train.max_steps=100000 \
train.use_swanlab=true \
train.swanlab_project=roboimi-vla \
train.swanlab_run_name=act-socket-peg-224-$(date +%Y%m%d-%H%M%S)
```
## Tests
Add unit coverage without requiring ACT repo code:
- Hydra config can instantiate `agent=act_resnet` with stubbed torchvision/diffusers-like dependencies where needed.
- ACT loss returns a scalar, masks padded actions, and produces gradients.
- ACT prediction returns `(B,pred_horizon,action_dim)` and honors configured camera ordering.
- Dataset config/sample for socket peg cameras returns three resized `(obs_horizon,3,224,224)` tensors.
+14 -5
View File
@@ -1497,7 +1497,12 @@ def _build_parallel_worker_payloads(
num_episodes=int(eval_cfg.num_episodes), num_episodes=int(eval_cfg.num_episodes),
num_workers=requested_workers, num_workers=requested_workers,
) )
box_poses = _plan_episode_box_poses(int(eval_cfg.num_episodes)) task_name = str(eval_cfg.get('task_name', ''))
box_poses = (
_plan_episode_box_poses(int(eval_cfg.num_episodes))
if 'sim_transfer' in task_name
else None
)
resolved_cfg = OmegaConf.to_container(cfg, resolve=True) resolved_cfg = OmegaConf.to_container(cfg, resolve=True)
payloads = [] payloads = []
workers_dir = None workers_dir = None
@@ -1521,10 +1526,14 @@ def _build_parallel_worker_payloads(
'worker_index': int(worker_index), 'worker_index': int(worker_index),
'artifact_dir': str(worker_artifact_dir) if worker_artifact_dir is not None else None, 'artifact_dir': str(worker_artifact_dir) if worker_artifact_dir is not None else None,
'episode_plans': [ 'episode_plans': [
{ (
'episode_index': int(episode_index), {
'box_pos': np.asarray(box_poses[episode_index], dtype=np.float32).tolist(), 'episode_index': int(episode_index),
} 'box_pos': np.asarray(box_poses[episode_index], dtype=np.float32).tolist(),
}
if box_poses is not None
else {'episode_index': int(episode_index)}
)
for episode_index in episode_indices for episode_index in episode_indices
], ],
}) })
+214
View File
@@ -0,0 +1,214 @@
"""ACT agent wrapper compatible with RoboIMI VLA training/eval scripts."""
from __future__ import annotations
from collections import deque
from typing import Dict, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from roboimi.vla.models.normalization import NormalizationModule
class ACTAgent(nn.Module):
def __init__(
self,
vision_backbone,
head,
action_dim: int,
obs_dim: int,
pred_horizon: int = 16,
obs_horizon: int = 1,
num_cams: int = 3,
camera_names: Optional[Tuple[str, ...]] = None,
dataset_stats=None,
normalization_type: str = "min_max",
num_action_steps: int = 8,
**_: object,
) -> None:
super().__init__()
self.action_dim = int(action_dim)
self.obs_dim = int(obs_dim)
self.pred_horizon = int(pred_horizon)
self.obs_horizon = int(obs_horizon)
self.num_cams = int(num_cams)
self.num_action_steps = int(num_action_steps)
self.vision_encoder = vision_backbone
self.normalization = NormalizationModule(
stats=dataset_stats,
normalization_type=normalization_type,
)
agent_camera_names = tuple(camera_names) if camera_names is not None else None
backbone_camera_names = getattr(self.vision_encoder, "camera_names", None)
backbone_camera_names = tuple(backbone_camera_names) if backbone_camera_names is not None else None
backbone_num_cameras = getattr(self.vision_encoder, "num_cameras", None)
if backbone_num_cameras is not None and int(backbone_num_cameras) != self.num_cams:
raise ValueError(
f"agent.num_cams({self.num_cams}) 与 vision_backbone.num_cameras({backbone_num_cameras}) 不一致"
)
if agent_camera_names is not None and backbone_camera_names is not None and agent_camera_names != backbone_camera_names:
raise ValueError(
f"agent.camera_names({list(agent_camera_names)}) 与 vision_backbone.camera_names({list(backbone_camera_names)}) 不一致"
)
self.camera_names = agent_camera_names if agent_camera_names is not None else backbone_camera_names
if self.camera_names is not None and len(self.camera_names) != self.num_cams:
raise ValueError(f"camera_names 长度({len(self.camera_names)})与 num_cams({self.num_cams})不一致")
if self.camera_names is not None:
self.vision_encoder.camera_names = self.camera_names
self.tokens_per_step = int(getattr(self.vision_encoder, "tokens_per_step", 1))
base_vision_dim = int(getattr(self.vision_encoder, "output_dim"))
self.vision_dim = base_vision_dim if self.tokens_per_step > 1 else base_vision_dim * self.num_cams
if isinstance(head, nn.Module):
self.policy_head = head
else:
self.policy_head = head(
action_dim=self.action_dim,
obs_dim=self.obs_dim,
vision_dim=self.vision_dim,
num_cams=self.num_cams,
pred_horizon=self.pred_horizon,
obs_horizon=self.obs_horizon,
)
# Alias so train_vla.py optimizer grouping can find head groups if added later.
self.noise_pred_net = self.policy_head
self.reset()
def _get_model_device(self) -> torch.device:
return next(self.parameters()).device
def _move_to_device(self, data, device: torch.device):
if torch.is_tensor(data):
return data.to(device)
if isinstance(data, dict):
return {k: self._move_to_device(v, device) for k, v in data.items()}
if isinstance(data, list):
return [self._move_to_device(v, device) for v in data]
if isinstance(data, tuple):
return tuple(self._move_to_device(v, device) for v in data)
return data
def _order_images(self, images: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
if self.camera_names is None:
camera_names = tuple(sorted(images.keys()))
if len(camera_names) != self.num_cams:
raise ValueError(f"图像条件相机数量({len(camera_names)})与 num_cams({self.num_cams})不一致")
return {cam_name: images[cam_name] for cam_name in camera_names}
missing = [cam_name for cam_name in self.camera_names if cam_name not in images]
if missing:
raise ValueError(f"图像条件缺少必需相机。missing={missing}, expected={list(self.camera_names)}")
return {cam_name: images[cam_name] for cam_name in self.camera_names}
def _build_visual_tokens(self, images: Dict[str, torch.Tensor]) -> torch.Tensor:
ordered_images = self._order_images(images)
visual = self.vision_encoder(ordered_images)
if visual.ndim == 3:
if visual.shape[1] < 1:
raise RuntimeError("视觉特征时间维为空")
return visual[:, -1:, :]
if visual.ndim == 4:
if visual.shape[1] < 1:
raise RuntimeError("视觉特征时间维为空")
return visual[:, -1, :, :]
raise RuntimeError(f"不支持的视觉特征形状: {tuple(visual.shape)}")
@staticmethod
def _current_qpos(states: torch.Tensor) -> torch.Tensor:
if states.ndim == 2:
return states
if states.ndim == 3:
return states[:, -1]
raise ValueError(f"qpos must have shape (B,D) or (B,T,D), got {tuple(states.shape)}")
def compute_loss(self, batch) -> torch.Tensor:
actions = batch["action"]
states = batch["qpos"]
images = batch["images"]
action_is_pad = batch.get("action_is_pad", None)
states = self.normalization.normalize_qpos(states)
actions = self.normalization.normalize_action(actions)
qpos = self._current_qpos(states)
actions = actions[:, : self.pred_horizon]
if action_is_pad is not None:
action_is_pad = action_is_pad[:, : self.pred_horizon].to(torch.bool)
visual_tokens = self._build_visual_tokens(images)
pred_actions, latent_info = self.policy_head(qpos, visual_tokens, actions, action_is_pad)
l1 = F.l1_loss(pred_actions, actions, reduction="none")
if action_is_pad is not None:
mask = (~action_is_pad).unsqueeze(-1).to(l1.dtype)
valid_count = (mask.sum() * l1.shape[-1]).clamp_min(1.0)
l1_loss = (l1 * mask).sum() / valid_count
else:
l1_loss = l1.mean()
kl = latent_info.get("kl")
if kl is None:
kl = torch.zeros(1, device=l1_loss.device, dtype=l1_loss.dtype)
kl_weight = float(latent_info.get("kl_weight", getattr(self.policy_head, "kl_weight", 0.0)))
return l1_loss + kl_weight * kl.squeeze()
@torch.no_grad()
def predict_action(self, images, proprioception):
proprioception = self.normalization.normalize_qpos(proprioception)
qpos = self._current_qpos(proprioception)
visual_tokens = self._build_visual_tokens(images)
actions, _ = self.policy_head(qpos, visual_tokens)
return self.normalization.denormalize_action(actions)
def reset(self):
self._queues = {
"qpos": deque(maxlen=self.obs_horizon),
"images": deque(maxlen=self.obs_horizon),
"action": deque(maxlen=max(1, self.pred_horizon - self.obs_horizon + 1)),
}
def _populate_queues(self, observation: Dict[str, torch.Tensor]) -> None:
if "qpos" in observation:
self._queues["qpos"].append(observation["qpos"].clone())
if "images" in observation:
ordered_images = self._order_images(observation["images"])
self._queues["images"].append({k: v.clone() for k, v in ordered_images.items()})
def _prepare_observation_batch(self) -> Dict[str, torch.Tensor]:
qpos_list = list(self._queues["qpos"])
if not qpos_list:
raise ValueError("观测队列为空,请先调用 _populate_queues 添加观测")
while len(qpos_list) < self.obs_horizon:
qpos_list.append(qpos_list[-1])
batch_qpos = torch.stack(qpos_list, dim=0).unsqueeze(0)
images_list = list(self._queues["images"])
if not images_list:
raise ValueError("图像队列为空,请先调用 _populate_queues 添加观测")
while len(images_list) < self.obs_horizon:
images_list.append(images_list[-1])
camera_names = self.camera_names if self.camera_names is not None else tuple(sorted(images_list[0].keys()))
batch_images = {
cam_name: torch.stack([img[cam_name] for img in images_list], dim=0).unsqueeze(0)
for cam_name in camera_names
}
return {"qpos": batch_qpos, "images": batch_images}
@torch.no_grad()
def select_action(self, observation: Dict[str, torch.Tensor]) -> torch.Tensor:
device = self._get_model_device()
observation = self._move_to_device(observation, device)
self._populate_queues(observation)
if len(self._queues["action"]) == 0:
batch = self._prepare_observation_batch()
actions = self.predict_action_chunk(batch)
start = self.obs_horizon - 1
end = start + self.num_action_steps
executable_actions = actions[:, start:end]
for i in range(executable_actions.shape[1]):
self._queues["action"].append(executable_actions[:, i].squeeze(0))
return self._queues["action"].popleft()
@torch.no_grad()
def predict_action_chunk(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
return self.predict_action(batch["images"], batch["qpos"])
def get_normalization_stats(self):
return self.normalization.get_stats()
+36
View File
@@ -0,0 +1,36 @@
# @package agent
defaults:
- /backbone@vision_backbone: resnet_diffusion
- _self_
_target_: roboimi.vla.agent_act.ACTAgent
action_dim: 16
obs_dim: 16
normalization_type: "min_max"
pred_horizon: 16
obs_horizon: 1
num_action_steps: 8
camera_names: ${data.camera_names}
num_cams: 3
vision_backbone:
num_cameras: ${agent.num_cams}
camera_names: ${agent.camera_names}
input_shape: [3, 224, 224]
output_tokens_per_camera: true
head:
_target_: roboimi.vla.models.heads.act.ACTPolicyHead
_partial_: true
hidden_dim: 256
nheads: 8
enc_layers: 4
dec_layers: 6
dim_feedforward: 2048
latent_dim: 32
dropout: 0.1
kl_weight: 10.0
activation: "gelu"
+257
View File
@@ -0,0 +1,257 @@
"""Local ACT-style CVAE policy head.
This module intentionally reimplements only the ACT model logic needed by the
RoboIMI VLA training stack. It does not import or vendor the external ACT repo.
"""
from __future__ import annotations
import math
from typing import Optional, Tuple
import torch
import torch.nn as nn
def kl_divergence(mu: torch.Tensor, logvar: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""KL divergence between diagonal posterior N(mu, exp(logvar)) and N(0, I)."""
if mu.ndim > 2:
mu = mu.view(mu.size(0), -1)
if logvar.ndim > 2:
logvar = logvar.view(logvar.size(0), -1)
klds = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp())
total_kld = klds.sum(1).mean(0, keepdim=True)
dimension_wise_kld = klds.mean(0)
mean_kld = klds.mean(1).mean(0, keepdim=True)
return total_kld, dimension_wise_kld, mean_kld
def _build_sinusoidal_table(length: int, dim: int) -> torch.Tensor:
if length <= 0:
raise ValueError(f"length must be positive, got {length}")
if dim <= 0:
raise ValueError(f"dim must be positive, got {dim}")
position = torch.arange(length, dtype=torch.float32).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, dim, 2, dtype=torch.float32) * (-math.log(10000.0) / max(dim, 1))
)
table = torch.zeros(length, dim, dtype=torch.float32)
table[:, 0::2] = torch.sin(position * div_term)
if dim > 1:
table[:, 1::2] = torch.cos(position * div_term[: table[:, 1::2].shape[1]])
return table.unsqueeze(0)
class ACTPolicyHead(nn.Module):
"""ACT CVAE head using native PyTorch transformer blocks.
Args follow the existing Hydra style and are intentionally configurable so
this implementation is not tied to ACT's original 14-DoF ALOHA setup.
"""
def __init__(
self,
action_dim: int,
obs_dim: int,
vision_dim: int,
num_cams: int,
pred_horizon: int,
obs_horizon: int = 1,
hidden_dim: int = 256,
nheads: int = 8,
enc_layers: int = 4,
dec_layers: int = 6,
dim_feedforward: int = 2048,
latent_dim: int = 32,
dropout: float = 0.1,
kl_weight: float = 10.0,
activation: str = "gelu",
**_: object,
) -> None:
super().__init__()
self.action_dim = int(action_dim)
self.obs_dim = int(obs_dim)
self.vision_dim = int(vision_dim)
self.num_cams = int(num_cams)
self.pred_horizon = int(pred_horizon)
self.obs_horizon = int(obs_horizon)
self.hidden_dim = int(hidden_dim)
self.nheads = int(nheads)
self.latent_dim = int(latent_dim)
self.kl_weight = float(kl_weight)
if self.pred_horizon <= 0:
raise ValueError(f"pred_horizon must be positive, got {self.pred_horizon}")
if self.hidden_dim % self.nheads != 0:
raise ValueError(
f"hidden_dim({self.hidden_dim}) must be divisible by nheads({self.nheads})"
)
self.cls_embed = nn.Parameter(torch.zeros(1, 1, self.hidden_dim))
self.encoder_qpos_proj = nn.Linear(self.obs_dim, self.hidden_dim)
self.encoder_action_proj = nn.Linear(self.action_dim, self.hidden_dim)
encoder_layer = nn.TransformerEncoderLayer(
d_model=self.hidden_dim,
nhead=self.nheads,
dim_feedforward=int(dim_feedforward),
dropout=float(dropout),
activation=activation,
batch_first=True,
norm_first=False,
)
self.posterior_encoder = nn.TransformerEncoder(encoder_layer, num_layers=int(enc_layers))
self.latent_proj = nn.Linear(self.hidden_dim, self.latent_dim * 2)
self.latent_out_proj = nn.Linear(self.latent_dim, self.hidden_dim)
self.decoder_qpos_proj = nn.Linear(self.obs_dim, self.hidden_dim)
self.visual_proj = nn.Linear(self.vision_dim, self.hidden_dim)
self.memory_type_embed = nn.Embedding(3, self.hidden_dim) # latent, qpos, visual
self.query_embed = nn.Embedding(self.pred_horizon, self.hidden_dim)
decoder_layer = nn.TransformerDecoderLayer(
d_model=self.hidden_dim,
nhead=self.nheads,
dim_feedforward=int(dim_feedforward),
dropout=float(dropout),
activation=activation,
batch_first=True,
norm_first=False,
)
self.decoder = nn.TransformerDecoder(decoder_layer, num_layers=int(dec_layers))
self.action_head = nn.Linear(self.hidden_dim, self.action_dim)
self.register_buffer(
"posterior_pos_table",
_build_sinusoidal_table(self.pred_horizon + 2, self.hidden_dim),
persistent=False,
)
self.register_buffer(
"memory_pos_table",
_build_sinusoidal_table(1024, self.hidden_dim),
persistent=False,
)
self._reset_parameters()
def _reset_parameters(self) -> None:
nn.init.normal_(self.cls_embed, mean=0.0, std=0.02)
for module in self.modules():
if isinstance(module, nn.Linear):
nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
def _validate_inputs(
self,
qpos: torch.Tensor,
visual_tokens: torch.Tensor,
actions: Optional[torch.Tensor],
action_is_pad: Optional[torch.Tensor],
) -> None:
if qpos.ndim != 2 or qpos.shape[-1] != self.obs_dim:
raise ValueError(f"qpos must have shape (B,{self.obs_dim}), got {tuple(qpos.shape)}")
if visual_tokens.ndim != 3 or visual_tokens.shape[-1] != self.vision_dim:
raise ValueError(
f"visual_tokens must have shape (B,N,{self.vision_dim}), got {tuple(visual_tokens.shape)}"
)
if visual_tokens.shape[0] != qpos.shape[0]:
raise ValueError("qpos and visual_tokens batch dimensions must match")
if actions is not None:
expected = (qpos.shape[0], self.pred_horizon, self.action_dim)
if tuple(actions.shape) != expected:
raise ValueError(f"actions must have shape {expected}, got {tuple(actions.shape)}")
if action_is_pad is not None and tuple(action_is_pad.shape) != expected[:2]:
raise ValueError(
f"action_is_pad must have shape {expected[:2]}, got {tuple(action_is_pad.shape)}"
)
def _posterior(
self,
qpos: torch.Tensor,
actions: Optional[torch.Tensor],
action_is_pad: Optional[torch.Tensor],
) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
batch_size = qpos.shape[0]
if actions is None:
latent = torch.zeros(batch_size, self.latent_dim, device=qpos.device, dtype=qpos.dtype)
return latent, None, None, None
cls = self.cls_embed.to(dtype=qpos.dtype).expand(batch_size, -1, -1)
qpos_token = self.encoder_qpos_proj(qpos).unsqueeze(1)
action_tokens = self.encoder_action_proj(actions)
tokens = torch.cat([cls, qpos_token, action_tokens], dim=1)
pos = self.posterior_pos_table[:, : tokens.shape[1]].to(device=tokens.device, dtype=tokens.dtype)
tokens = tokens + pos
padding_mask = None
if action_is_pad is not None:
prefix = torch.zeros(
batch_size,
2,
dtype=torch.bool,
device=action_is_pad.device,
)
padding_mask = torch.cat([prefix, action_is_pad.to(torch.bool)], dim=1)
encoded = self.posterior_encoder(tokens, src_key_padding_mask=padding_mask)
latent_info = self.latent_proj(encoded[:, 0])
mu, logvar = torch.chunk(latent_info, 2, dim=-1)
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
latent = mu + eps * std
kl, _, _ = kl_divergence(mu, logvar)
return latent, mu, logvar, kl
def _memory(self, qpos: torch.Tensor, visual_tokens: torch.Tensor, latent: torch.Tensor) -> torch.Tensor:
batch_size = qpos.shape[0]
latent_token = self.latent_out_proj(latent).unsqueeze(1)
qpos_token = self.decoder_qpos_proj(qpos).unsqueeze(1)
visual = self.visual_proj(visual_tokens)
memory = torch.cat([latent_token, qpos_token, visual], dim=1)
if memory.shape[1] > self.memory_pos_table.shape[1]:
pos = _build_sinusoidal_table(memory.shape[1], self.hidden_dim).to(
device=memory.device,
dtype=memory.dtype,
)
else:
pos = self.memory_pos_table[:, : memory.shape[1]].to(
device=memory.device,
dtype=memory.dtype,
)
memory = memory + pos
type_ids = torch.cat(
[
torch.zeros(1, dtype=torch.long, device=memory.device),
torch.ones(1, dtype=torch.long, device=memory.device),
torch.full((memory.shape[1] - 2,), 2, dtype=torch.long, device=memory.device),
]
)
memory = memory + self.memory_type_embed(type_ids).to(dtype=memory.dtype).unsqueeze(0)
if memory.shape[0] != batch_size:
raise RuntimeError("internal memory batch shape mismatch")
return memory
def forward(
self,
qpos: torch.Tensor,
visual_tokens: torch.Tensor,
actions: Optional[torch.Tensor] = None,
action_is_pad: Optional[torch.Tensor] = None,
):
self._validate_inputs(qpos, visual_tokens, actions, action_is_pad)
latent, mu, logvar, kl = self._posterior(qpos, actions, action_is_pad)
memory = self._memory(qpos, visual_tokens, latent)
query = self.query_embed.weight.to(dtype=memory.dtype).unsqueeze(0).expand(qpos.shape[0], -1, -1)
target = torch.zeros_like(query)
decoded = self.decoder(target + query, memory)
pred_actions = self.action_head(decoded)
if kl is None:
kl = torch.zeros(1, device=qpos.device, dtype=qpos.dtype)
latent_info = {
"mu": mu,
"logvar": logvar,
"kl": kl,
"kl_weight": self.kl_weight,
}
return pred_actions, latent_info
+249
View File
@@ -0,0 +1,249 @@
import contextlib
import sys
import types
import unittest
from pathlib import Path
import torch
from hydra import compose, initialize_config_dir
from hydra.core.global_hydra import GlobalHydra
from hydra.utils import instantiate
from omegaconf import OmegaConf
_REPO_ROOT = Path(__file__).resolve().parents[1]
_CONFIG_DIR = str((_REPO_ROOT / 'roboimi/vla/conf').resolve())
_MISSING = object()
class FakeVisionBackbone(torch.nn.Module):
def __init__(self, output_dim=4, camera_names=('l_vis', 'r_vis', 'front')):
super().__init__()
self.output_dim = output_dim
self.num_cameras = len(camera_names)
self.tokens_per_step = self.num_cameras
self.camera_names = tuple(camera_names)
self.scale = torch.nn.Parameter(torch.tensor(1.0))
def forward(self, images):
features = []
for cam_name in self.camera_names:
image = images[cam_name]
marker = image.mean(dim=(2, 3, 4), keepdim=False).unsqueeze(-1)
features.append(marker.repeat(1, 1, self.output_dim) * self.scale)
return torch.stack(features, dim=2)
class _IdentityCrop:
def __init__(self, size):
self.size = size
def __call__(self, x):
return x
class _FakeResNet(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = torch.nn.Conv2d(3, 8, kernel_size=3, padding=1)
self.relu1 = torch.nn.ReLU()
self.conv2 = torch.nn.Conv2d(8, 16, kernel_size=3, padding=1, stride=2)
self.relu2 = torch.nn.ReLU()
self.avgpool = torch.nn.AdaptiveAvgPool2d((1, 1))
self.fc = torch.nn.Linear(16, 16)
def forward(self, x):
x = self.relu1(self.conv1(x))
x = self.relu2(self.conv2(x))
x = self.avgpool(x)
return self.fc(torch.flatten(x, start_dim=1))
@contextlib.contextmanager
def _stub_torchvision():
previous = {}
def inject(name, module):
if name not in previous:
previous[name] = sys.modules.get(name, _MISSING)
sys.modules[name] = module
torchvision_module = types.ModuleType('torchvision')
models_module = types.ModuleType('torchvision.models')
transforms_module = types.ModuleType('torchvision.transforms')
models_module.resnet18 = lambda weights=None: _FakeResNet()
transforms_module.CenterCrop = _IdentityCrop
transforms_module.RandomCrop = _IdentityCrop
torchvision_module.models = models_module
torchvision_module.transforms = transforms_module
try:
inject('torchvision', torchvision_module)
inject('torchvision.models', models_module)
inject('torchvision.transforms', transforms_module)
yield
finally:
for name, old in reversed(list(previous.items())):
if old is _MISSING:
sys.modules.pop(name, None)
else:
sys.modules[name] = old
def _compose_cfg(overrides=None):
if not OmegaConf.has_resolver('len'):
OmegaConf.register_new_resolver('len', lambda x: len(x))
GlobalHydra.instance().clear()
with initialize_config_dir(version_base=None, config_dir=_CONFIG_DIR):
return compose(config_name='config', overrides=list(overrides or []))
def _make_batch(batch_size=2, obs_horizon=2, pred_horizon=4, action_dim=3, obs_dim=5):
camera_names = ('l_vis', 'r_vis', 'front')
images = {
cam_name: torch.full(
(batch_size, obs_horizon, 3, 8, 8),
float(cam_idx + 1),
)
for cam_idx, cam_name in enumerate(camera_names)
}
return {
'images': images,
'qpos': torch.randn(batch_size, obs_horizon, obs_dim),
'action': torch.randn(batch_size, pred_horizon, action_dim),
'action_is_pad': torch.zeros(batch_size, pred_horizon, dtype=torch.bool),
}
class ACTAgentTest(unittest.TestCase):
def test_compute_loss_returns_scalar_and_backpropagates(self):
from roboimi.vla.agent_act import ACTAgent
from roboimi.vla.models.heads.act import ACTPolicyHead
agent = ACTAgent(
vision_backbone=FakeVisionBackbone(output_dim=4),
head=ACTPolicyHead(
action_dim=3,
obs_dim=5,
vision_dim=4,
num_cams=3,
pred_horizon=4,
obs_horizon=2,
hidden_dim=32,
nheads=4,
enc_layers=1,
dec_layers=1,
dim_feedforward=64,
latent_dim=8,
kl_weight=0.1,
),
action_dim=3,
obs_dim=5,
pred_horizon=4,
obs_horizon=2,
num_cams=3,
camera_names=('l_vis', 'r_vis', 'front'),
)
loss = agent.compute_loss(_make_batch())
self.assertEqual(loss.ndim, 0)
self.assertTrue(torch.isfinite(loss))
loss.backward()
grads = [p.grad for p in agent.parameters() if p.requires_grad]
self.assertTrue(any(grad is not None and torch.isfinite(grad).all() for grad in grads))
def test_compute_loss_handles_all_padded_actions_without_nan(self):
from roboimi.vla.agent_act import ACTAgent
from roboimi.vla.models.heads.act import ACTPolicyHead
agent = ACTAgent(
vision_backbone=FakeVisionBackbone(output_dim=4),
head=ACTPolicyHead(
action_dim=3,
obs_dim=5,
vision_dim=4,
num_cams=3,
pred_horizon=4,
obs_horizon=2,
hidden_dim=32,
nheads=4,
enc_layers=1,
dec_layers=1,
dim_feedforward=64,
latent_dim=8,
kl_weight=0.1,
),
action_dim=3,
obs_dim=5,
pred_horizon=4,
obs_horizon=2,
num_cams=3,
camera_names=('l_vis', 'r_vis', 'front'),
)
batch = _make_batch()
batch['action_is_pad'][:] = True
loss = agent.compute_loss(batch)
self.assertEqual(loss.ndim, 0)
self.assertTrue(torch.isfinite(loss))
def test_predict_action_returns_denormalized_chunk_shape(self):
from roboimi.vla.agent_act import ACTAgent
from roboimi.vla.models.heads.act import ACTPolicyHead
agent = ACTAgent(
vision_backbone=FakeVisionBackbone(output_dim=4),
head=ACTPolicyHead(
action_dim=3,
obs_dim=5,
vision_dim=4,
num_cams=3,
pred_horizon=4,
obs_horizon=2,
hidden_dim=32,
nheads=4,
enc_layers=1,
dec_layers=1,
dim_feedforward=64,
latent_dim=8,
),
action_dim=3,
obs_dim=5,
pred_horizon=4,
obs_horizon=2,
num_cams=3,
camera_names=('l_vis', 'r_vis', 'front'),
)
batch = _make_batch()
actions = agent.predict_action(batch['images'], batch['qpos'])
self.assertEqual(tuple(actions.shape), (2, 4, 3))
self.assertTrue(torch.isfinite(actions).all())
def test_hydra_instantiates_act_resnet_for_socket_peg_camera_order(self):
cfg = _compose_cfg(
overrides=[
'agent=act_resnet',
'data.camera_names=[l_vis,r_vis,front]',
'agent.vision_backbone.pretrained_backbone_weights=null',
'agent.vision_backbone.input_shape=[3,16,16]',
'agent.pred_horizon=4',
'agent.obs_horizon=1',
'agent.num_action_steps=2',
'agent.head.hidden_dim=32',
'agent.head.nheads=4',
'agent.head.enc_layers=1',
'agent.head.dec_layers=1',
'agent.head.dim_feedforward=64',
'agent.head.latent_dim=8',
]
)
self.assertEqual(list(cfg.data.camera_names), ['l_vis', 'r_vis', 'front'])
self.assertEqual(cfg.agent._target_, 'roboimi.vla.agent_act.ACTAgent')
with _stub_torchvision():
agent = instantiate(cfg.agent)
self.assertEqual(agent.camera_names, ('l_vis', 'r_vis', 'front'))
self.assertEqual(agent.pred_horizon, 4)
self.assertEqual(agent.vision_encoder.tokens_per_step, 3)
if __name__ == '__main__':
unittest.main()
+29
View File
@@ -445,6 +445,35 @@ class EvalVLAExecutionTest(unittest.TestCase):
self.assertEqual(summary["episode_rewards"], [1.0, 2.0, 3.0, 4.0, 5.0]) self.assertEqual(summary["episode_rewards"], [1.0, 2.0, 3.0, 4.0, 5.0])
self.assertEqual(summary["num_episodes"], 5) self.assertEqual(summary["num_episodes"], 5)
def test_build_parallel_worker_payloads_keeps_socket_peg_sampling_lazy(self):
cfg = _make_parallel_cfg(
num_episodes=3,
num_workers=2,
task_name="sim_air_insert_socket_peg",
)
artifact_paths = {"output_dir": None}
with mock.patch.object(
eval_vla,
"sample_transfer_pose",
side_effect=AssertionError("socket-peg parallel eval should not pre-sample transfer poses"),
):
worker_payloads, _ = eval_vla._build_parallel_worker_payloads(cfg, artifact_paths)
episode_plans = [
plan
for payload in worker_payloads
for plan in payload["episode_plans"]
]
self.assertEqual(
episode_plans,
[
{"episode_index": 0},
{"episode_index": 1},
{"episode_index": 2},
],
)
def test_run_eval_parallel_allows_trajectory_images_and_keeps_worker_artifact_paths(self): def test_run_eval_parallel_allows_trajectory_images_and_keeps_worker_artifact_paths(self):
cfg = _make_parallel_cfg( cfg = _make_parallel_cfg(
num_episodes=2, num_episodes=2,