Instructions to use AlexWortega/tinyvla with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LeRobot
How to use AlexWortega/tinyvla with LeRobot:
- Notebooks
- Google Colab
- Kaggle
| #!/usr/bin/env python | |
| """Convert the id-conditioned GR00T checkpoint to morphology conditioning. | |
| Why this run exists: the physical_ai_ft checkpoint is `conditioning: id`, and a | |
| learned id is a lookup table — it has no row for a robot that was never trained on, | |
| and none can be synthesised. Measured on held-out LeKiwi: every trained id scores | |
| 1.18-1.33 on the absolute dims (worse than holding still) and a PURE RANDOM identity | |
| vector does just as well. Simply switching the config to `morph` and grafting | |
| tv2_C_scaled's morph encoder does not help either (1.30/1.11) — the expert spent 60k | |
| steps learning to read an id token and has never seen morph tokens. | |
| So the expert has to be taught to read the descriptor. Everything else is already | |
| trained, so this only has to re-route one conditioning slot: 16 physical numbers -> | |
| MLP -> 2 tokens, in place of the 1 id token. | |
| Data is streamed from the hub exactly as the original run did (no disk), with the | |
| six GR00T family descriptors from configs/groot_morphology.yaml. | |
| The payoff is the project's central claim, measured rather than argued: if a | |
| descriptor-conditioned model transfers to LeKiwi where the id-conditioned one cannot, | |
| that is the difference between a code you can look up and a code you can compute. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import time | |
| from pathlib import Path | |
| import torch | |
| import yaml | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--resume", default="/home/alexw/tinyvla/outputs/physical_ai_ft_morph") | |
| ap.add_argument("--out", default="/home/alexw/tinyvla/outputs/groot_morph") | |
| ap.add_argument("--spec", default="/home/alexw/tinyvla/configs/physical_ai_stream.yaml") | |
| ap.add_argument("--descriptors", default="/home/alexw/tinyvla/configs/groot_morphology.yaml") | |
| ap.add_argument("--steps", type=int, default=6000) | |
| ap.add_argument("--batch-size", type=int, default=32) | |
| ap.add_argument("--num-workers", type=int, default=6) | |
| ap.add_argument("--lr", type=float, default=1.0e-4) | |
| ap.add_argument("--backbone-lr-mult", type=float, default=0.1) | |
| ap.add_argument("--warmup", type=int, default=200) | |
| ap.add_argument("--save-freq", type=int, default=1000) | |
| ap.add_argument("--log-freq", type=int, default=50) | |
| args = ap.parse_args() | |
| from safetensors.torch import load_file | |
| from transformers import AutoTokenizer | |
| from tinyvla.data.hub_stream import HubEpisodeStream | |
| from tinyvla.modeling_tinyvla import TinyVLAPolicy | |
| from tinyvla.modules.embodiment import MORPH_FIELDS | |
| out = Path(args.out) | |
| out.mkdir(parents=True, exist_ok=True) | |
| pol = TinyVLAPolicy.from_pretrained(args.resume) | |
| pol.load_state_dict(load_file(f"{args.resume}/model.safetensors"), strict=True) | |
| cfg = pol.config | |
| assert cfg.conditioning == "morph", f"expected morph conditioning, got {cfg.conditioning}" | |
| pol = pol.cuda().train() | |
| tok = AutoTokenizer.from_pretrained(cfg.lm_model_name) | |
| print(f"resumed {args.resume} | conditioning={cfg.conditioning} " | |
| f"max_state={cfg.max_state_dim} max_action={cfg.max_action_dim}", flush=True) | |
| # same field scaling the rest of the project uses (see MORPH_FIELDS in embodiment.py) | |
| sc = {"arm_dof": 0.1, "reach_m": 2, "gripper_width_m": 10, "num_cameras": 1 / 3, | |
| "control_hz": 1 / 30, "joint_lo_mean": 1 / 3.1416, "joint_hi_mean": 1 / 3.1416, | |
| "workspace_x": 2, "workspace_y": 2, "workspace_z": 2, "payload_kg": 0.2} | |
| raw = yaml.safe_load(Path(args.descriptors).read_text()) | |
| morph = {k: torch.tensor([v.get(f, 0) * sc.get(f, 1) for f in MORPH_FIELDS], dtype=torch.float32) | |
| for k, v in raw.items()} | |
| print("descriptors:", list(morph)) | |
| specs = yaml.safe_load(Path(args.spec).read_text())["datasets"] | |
| missing = {s.get("morph_key") for s in specs} - set(morph) | |
| assert not missing, f"specs reference descriptors that do not exist: {missing}" | |
| # Fix the sampler bug the original run shipped with: HubEpisodeStream draws a | |
| # DATASET PER EPISODE, so a weight of sqrt(frames) is silently multiplied by | |
| # episode length. r1_pro episodes are ~37x longer, which turned an intended 49% | |
| # share into a realized 96.5% — and here it also starves the loader, because | |
| # every draw pulls a ~190 MB two-camera episode and the first batch never | |
| # arrives. Dividing by relative episode length restores the intended mixture. | |
| EP_LEN = {"r1_pro": 37.0, "gr1": 1.0, "panda_single": 1.0, | |
| "panda_bi_gripper": 1.0, "panda_bi_hand": 1.0, "g1": 0.6} | |
| for sp in specs: | |
| sp["weight"] = float(sp["weight"]) / EP_LEN.get(sp.get("morph_key"), 1.0) | |
| from collections import defaultdict as _dd | |
| _agg = _dd(float) | |
| for sp in specs: | |
| _agg[sp["morph_key"]] += sp["weight"] | |
| _tot = sum(_agg.values()) | |
| print("reweighted family shares:", {k: round(v / _tot, 3) for k, v in sorted(_agg.items())}) | |
| stream = HubEpisodeStream(specs, chunk=cfg.chunk_size, image_size=cfg.image_size, | |
| max_state_dim=cfg.max_state_dim, max_action_dim=cfg.max_action_dim, | |
| shuffle_buffer=64, seed=1234, morph_descriptors=morph) | |
| loader = torch.utils.data.DataLoader( | |
| stream, batch_size=args.batch_size, num_workers=args.num_workers, | |
| pin_memory=True, prefetch_factor=4, persistent_workers=True, | |
| multiprocessing_context="spawn", # fork deadlocks the hub HTTP pool (B200.md) | |
| ) | |
| backbone, head = [], [] | |
| for n, p in pol.named_parameters(): | |
| if not p.requires_grad: | |
| continue | |
| (backbone if n.startswith("semantic.") else head).append(p) | |
| opt = torch.optim.AdamW([ | |
| {"params": head, "lr": args.lr}, | |
| {"params": backbone, "lr": args.lr * args.backbone_lr_mult}, | |
| ], weight_decay=1e-4) | |
| sched = torch.optim.lr_scheduler.LambdaLR( | |
| opt, lambda s: min(1.0, (s + 1) / args.warmup)) | |
| scaler_dtype = torch.bfloat16 | |
| print(f"head {sum(p.numel() for p in head)/1e6:.1f}M | " | |
| f"backbone {sum(p.numel() for p in backbone)/1e6:.1f}M at {args.backbone_lr_mult}x", flush=True) | |
| step, t0 = 0, time.time() | |
| for batch in loader: | |
| t = tok(list(batch["task"]), padding="max_length", truncation=True, | |
| max_length=48, return_tensors="pt") | |
| b = {k: v.cuda(non_blocking=True) for k, v in batch.items() | |
| if isinstance(v, torch.Tensor)} | |
| b["observation.language.tokens"] = t["input_ids"].cuda() | |
| b["observation.language.attention_mask"] = t["attention_mask"].bool().cuda() | |
| with torch.autocast("cuda", scaler_dtype): | |
| loss, info = pol.forward(b) | |
| loss.backward() | |
| torch.nn.utils.clip_grad_norm_([p for g in opt.param_groups for p in g["params"]], 10.0) | |
| opt.step() | |
| sched.step() | |
| opt.zero_grad(set_to_none=True) | |
| step += 1 | |
| if step % args.log_freq == 0: | |
| print(f"step {step}/{args.steps} loss {info['loss']:.4f} " | |
| f"{step/(time.time()-t0):.2f} it/s", flush=True) | |
| if step % args.save_freq == 0 or step == args.steps: | |
| d = out / (f"step_{step}" if step < args.steps else "final") | |
| d.mkdir(parents=True, exist_ok=True) | |
| pol.save_pretrained(str(d)) | |
| print(f"saved {d}", flush=True) | |
| if step >= args.steps: | |
| break | |
| print("DONE", flush=True) | |
| if __name__ == "__main__": | |
| main() | |