Files
2026-07-13 13:05:14 +08:00

287 lines
10 KiB
Python

"""Train a LeRobot ACT policy using the Rerun dataloader.
Demonstrates how to stream robot trajectory data from Rerun's catalog
into an imitation learning policy (Action Chunking Transformers).
The Rerun dataloader's Field.window feature fetches future action chunks in a single query per batch.
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from typing import cast
import torch
import torch.nn.functional as F
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
from lerobot.policies.act.configuration_act import ACTConfig
from lerobot.policies.act.modeling_act import ACTPolicy
from torch.utils.data import DataLoader
from rerun._tracing import tracing_scope, with_tracing
from rerun.catalog import CatalogClient
from rerun.experimental.dataloader import (
DataSource,
Field,
NumericDecoder,
RerunIterableDataset,
RerunMapDataset,
VideoFrameDecoder,
)
CHECKPOINT_DIR = Path(__file__).resolve().parent / "act_checkpoint"
IMAGE_H = 32
IMAGE_W = 128
CAMERAS = ("laptop", "phone", "side")
IMAGE_KEYS = tuple(f"observation.images.{cam}" for cam in CAMERAS)
CHUNK_SIZE = 50
EPOCHS = 5
BATCH_SIZE = 8
LR = 1e-5
NUM_WORKERS = 4
FETCH_SIZE = 256
class CollateFn:
"""Picklable collate callable for PyTorch DataLoader multiprocessing."""
def __init__(self, chunk_size: int, state_dim: int) -> None:
self.chunk_size = chunk_size
self.state_dim = state_dim
@with_tracing("CollateFn")
def __call__(self, samples: list[dict[str, torch.Tensor | None]]) -> dict[str, torch.Tensor]:
# `VideoFrameDecoder` returns `None` when a target precedes the first keyframe; filter those out.
complete: list[dict[str, torch.Tensor]] = [
cast("dict[str, torch.Tensor]", s) for s in samples if all(s[f"image_{cam}"] is not None for cam in CAMERAS)
]
batch_size = len(complete)
states = torch.stack([s["state"] for s in complete]).float()
# Future action chunks: reshape windowed flat tensors
actions = torch.stack([s["action"].reshape(self.chunk_size, self.state_dim) for s in complete]).float()
batch: dict[str, torch.Tensor] = {
"observation.state": states,
"action": actions,
"action_is_pad": torch.zeros(batch_size, self.chunk_size, dtype=torch.bool),
}
# Per-camera images: (3, H, W) uint8 -> float in [0, 1], resized to (IMAGE_H, IMAGE_W)
for cam, key in zip(CAMERAS, IMAGE_KEYS):
imgs = torch.stack([s[f"image_{cam}"] for s in complete]).float() / 255.0
batch[key] = F.interpolate(imgs, size=(IMAGE_H, IMAGE_W), mode="bilinear", align_corners=False)
return batch
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--catalog-url",
default="rerun+http://127.0.0.1:51234",
help="Rerun catalog URL",
)
parser.add_argument(
"--dataset",
default="rerun_so101-pick-and-place",
help="Dataset name in the catalog",
)
parser.add_argument("--num-segments", type=int, default=3, help="Number of segments to use (0 for all)")
parser.add_argument("--epochs", type=int, default=EPOCHS, help="Number of training epochs")
parser.add_argument("--batch-size", type=int, default=BATCH_SIZE, help="Training batch size")
parser.add_argument("--num-workers", type=int, default=NUM_WORKERS, help="DataLoader worker processes")
parser.add_argument(
"--fetch-size",
type=int,
default=FETCH_SIZE,
help="Samples fetched per server query for the iterable dataset",
)
parser.add_argument("--lr", type=float, default=LR, help="Learning rate")
parser.add_argument(
"--dataset-style",
choices=("iterable", "map"),
default="iterable",
help="Which Rerun dataset class to use: 'iterable' (RerunIterableDataset, internal shuffling) "
"or 'map' (RerunMapDataset, random access via DataLoader samplers).",
)
parser.add_argument(
"--checkpoint-dir",
type=Path,
default=CHECKPOINT_DIR,
help="Directory to save the trained policy checkpoint",
)
return parser.parse_args()
@with_tracing("main")
def main() -> None:
args = parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
client = CatalogClient(args.catalog_url)
dataset_entry = client.get_dataset(args.dataset)
all_segments = dataset_entry.segment_ids()
segments = all_segments if args.num_segments == 0 else all_segments[: args.num_segments]
print(f"Using {len(segments)} segments")
source = DataSource(dataset_entry, segments=segments)
fields = {
"state": Field("/observation.state:Scalars:scalars", decode=NumericDecoder()),
"action": Field(
"/action:Scalars:scalars",
decode=NumericDecoder(),
window=(1, CHUNK_SIZE),
),
"image_laptop": Field(
"/observation.images.laptop:VideoStream:sample",
decode=VideoFrameDecoder(codec="av1", keyframe_interval=2),
),
"image_phone": Field(
"/observation.images.phone:VideoStream:sample",
decode=VideoFrameDecoder(codec="av1", keyframe_interval=2),
),
"image_side": Field(
"/observation.images.side:VideoStream:sample",
decode=VideoFrameDecoder(codec="av1", keyframe_interval=2),
),
}
ds: RerunIterableDataset | RerunMapDataset
if args.dataset_style == "map":
ds = RerunMapDataset(source=source, index="frame_index", fields=fields)
else:
ds = RerunIterableDataset(source=source, index="frame_index", fields=fields, fetch_size=args.fetch_size)
print(f"Using {args.dataset_style} dataset with {len(ds)} samples (after window trimming)")
# IterableDataset doesn't support indexing, so probe shape via iteration.
state_tensor = next(iter(ds))["state"]
assert state_tensor is not None # NumericDecoder never returns None
state_dim = state_tensor.shape[0]
action_dim = state_dim
print(f"Dimensions: {state_dim=}, {action_dim=}")
config = ACTConfig(
chunk_size=CHUNK_SIZE,
n_action_steps=CHUNK_SIZE,
use_vae=True,
kl_weight=10.0,
dim_model=256,
n_heads=8,
dim_feedforward=1024,
n_encoder_layers=4,
n_decoder_layers=1,
latent_dim=32,
n_vae_encoder_layers=4,
dropout=0.1,
vision_backbone="resnet18",
pretrained_backbone_weights=None,
normalization_mapping={
"STATE": NormalizationMode.MEAN_STD,
"VISUAL": NormalizationMode.MEAN_STD,
"ACTION": NormalizationMode.MEAN_STD,
},
input_features={
"observation.state": PolicyFeature(type=FeatureType.STATE, shape=(state_dim,)),
**{key: PolicyFeature(type=FeatureType.VISUAL, shape=(3, IMAGE_H, IMAGE_W)) for key in IMAGE_KEYS},
},
output_features={
"action": PolicyFeature(type=FeatureType.ACTION, shape=(action_dim,)),
},
)
policy = ACTPolicy(config)
policy.train()
policy.to(device)
print(f"ACT policy created ({sum(p.numel() for p in policy.parameters()):,} parameters, device={device})")
optimizer = torch.optim.AdamW(
policy.get_optim_params(),
lr=args.lr,
weight_decay=1e-4,
)
collate_fn = CollateFn(CHUNK_SIZE, state_dim)
# For the map-style dataset, shuffling is driven by the DataLoader's default RandomSampler.
# Swap in `sampler=DistributedSampler(ds)` (and call `sampler.set_epoch(epoch)` each epoch)
# for multi-node training, or plug in any other PyTorch sampler.
loader = DataLoader(
ds,
batch_size=args.batch_size,
shuffle=isinstance(ds, RerunMapDataset),
num_workers=args.num_workers,
collate_fn=collate_fn,
persistent_workers=True,
prefetch_factor=8,
)
num_batches = len(loader)
print(f"\nTraining for {args.epochs} epochs, {num_batches} batches/epoch, batch_size={args.batch_size}\n")
for epoch in range(args.epochs):
with tracing_scope(f"epoch {epoch}"):
if isinstance(ds, RerunIterableDataset):
ds.set_epoch(epoch)
total_loss = 0.0
total_l1 = 0.0
total_kld = 0.0
n = 0
t_last_print = time.perf_counter()
data_sum = 0.0
model_sum = 0.0
t_data_start = time.perf_counter()
for batch in loader:
data_time = time.perf_counter() - t_data_start
t_model_start = time.perf_counter()
batch = {k: v.to(device) for k, v in batch.items()}
loss, loss_dict = policy.forward(batch)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model_time = time.perf_counter() - t_model_start
total_loss += loss.item()
total_l1 += loss_dict["l1_loss"]
total_kld += loss_dict.get("kld_loss", 0.0)
data_sum += data_time
model_sum += model_time
n += 1
if n % 10 == 0 or n == 1:
now = time.perf_counter()
since_last = now - t_last_print
t_last_print = now
print(
f" epoch {epoch + 1}/{args.epochs} batch {n}/{num_batches}"
f" loss={loss.item():.4f}"
f" data={data_sum:.1f}s model={model_sum:.1f}s"
f" since_last={since_last:.1f}s",
flush=True,
)
data_sum = 0.0
model_sum = 0.0
t_data_start = time.perf_counter()
avg_loss = total_loss / max(n, 1)
avg_l1 = total_l1 / max(n, 1)
avg_kld = total_kld / max(n, 1)
print(f"Epoch {epoch + 1}/{args.epochs} loss={avg_loss:.4f} l1={avg_l1:.4f} kld={avg_kld:.4f}")
with tracing_scope("save_pretrained"):
policy.save_pretrained(str(args.checkpoint_dir))
print(f"\nSaved checkpoint to {args.checkpoint_dir}")
if __name__ == "__main__":
main()