287 lines
10 KiB
Python
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()
|