AlexWortega commited on
Commit
f7a2fb2
·
verified ·
1 Parent(s): ee0a1bf

Upload tinyvla2/scripts/train_groot_morph.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. tinyvla2/scripts/train_groot_morph.py +153 -0
tinyvla2/scripts/train_groot_morph.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Convert the id-conditioned GR00T checkpoint to morphology conditioning.
3
+
4
+ Why this run exists: the physical_ai_ft checkpoint is `conditioning: id`, and a
5
+ learned id is a lookup table — it has no row for a robot that was never trained on,
6
+ and none can be synthesised. Measured on held-out LeKiwi: every trained id scores
7
+ 1.18-1.33 on the absolute dims (worse than holding still) and a PURE RANDOM identity
8
+ vector does just as well. Simply switching the config to `morph` and grafting
9
+ tv2_C_scaled's morph encoder does not help either (1.30/1.11) — the expert spent 60k
10
+ steps learning to read an id token and has never seen morph tokens.
11
+
12
+ So the expert has to be taught to read the descriptor. Everything else is already
13
+ trained, so this only has to re-route one conditioning slot: 16 physical numbers ->
14
+ MLP -> 2 tokens, in place of the 1 id token.
15
+
16
+ Data is streamed from the hub exactly as the original run did (no disk), with the
17
+ six GR00T family descriptors from configs/groot_morphology.yaml.
18
+
19
+ The payoff is the project's central claim, measured rather than argued: if a
20
+ descriptor-conditioned model transfers to LeKiwi where the id-conditioned one cannot,
21
+ that is the difference between a code you can look up and a code you can compute.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import torch
31
+ import yaml
32
+
33
+
34
+ def main():
35
+ ap = argparse.ArgumentParser()
36
+ ap.add_argument("--resume", default="/home/alexw/tinyvla/outputs/physical_ai_ft_morph")
37
+ ap.add_argument("--out", default="/home/alexw/tinyvla/outputs/groot_morph")
38
+ ap.add_argument("--spec", default="/home/alexw/tinyvla/configs/physical_ai_stream.yaml")
39
+ ap.add_argument("--descriptors", default="/home/alexw/tinyvla/configs/groot_morphology.yaml")
40
+ ap.add_argument("--steps", type=int, default=6000)
41
+ ap.add_argument("--batch-size", type=int, default=32)
42
+ ap.add_argument("--num-workers", type=int, default=6)
43
+ ap.add_argument("--lr", type=float, default=1.0e-4)
44
+ ap.add_argument("--backbone-lr-mult", type=float, default=0.1)
45
+ ap.add_argument("--warmup", type=int, default=200)
46
+ ap.add_argument("--save-freq", type=int, default=1000)
47
+ ap.add_argument("--log-freq", type=int, default=50)
48
+ args = ap.parse_args()
49
+
50
+ from safetensors.torch import load_file
51
+ from transformers import AutoTokenizer
52
+
53
+ from tinyvla.data.hub_stream import HubEpisodeStream
54
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
55
+ from tinyvla.modules.embodiment import MORPH_FIELDS
56
+
57
+ out = Path(args.out)
58
+ out.mkdir(parents=True, exist_ok=True)
59
+
60
+ pol = TinyVLAPolicy.from_pretrained(args.resume)
61
+ pol.load_state_dict(load_file(f"{args.resume}/model.safetensors"), strict=True)
62
+ cfg = pol.config
63
+ assert cfg.conditioning == "morph", f"expected morph conditioning, got {cfg.conditioning}"
64
+ pol = pol.cuda().train()
65
+ tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
66
+ print(f"resumed {args.resume} | conditioning={cfg.conditioning} "
67
+ f"max_state={cfg.max_state_dim} max_action={cfg.max_action_dim}", flush=True)
68
+
69
+ # same field scaling the rest of the project uses (see MORPH_FIELDS in embodiment.py)
70
+ sc = {"arm_dof": 0.1, "reach_m": 2, "gripper_width_m": 10, "num_cameras": 1 / 3,
71
+ "control_hz": 1 / 30, "joint_lo_mean": 1 / 3.1416, "joint_hi_mean": 1 / 3.1416,
72
+ "workspace_x": 2, "workspace_y": 2, "workspace_z": 2, "payload_kg": 0.2}
73
+ raw = yaml.safe_load(Path(args.descriptors).read_text())
74
+ morph = {k: torch.tensor([v.get(f, 0) * sc.get(f, 1) for f in MORPH_FIELDS], dtype=torch.float32)
75
+ for k, v in raw.items()}
76
+ print("descriptors:", list(morph))
77
+
78
+ specs = yaml.safe_load(Path(args.spec).read_text())["datasets"]
79
+ missing = {s.get("morph_key") for s in specs} - set(morph)
80
+ assert not missing, f"specs reference descriptors that do not exist: {missing}"
81
+
82
+ # Fix the sampler bug the original run shipped with: HubEpisodeStream draws a
83
+ # DATASET PER EPISODE, so a weight of sqrt(frames) is silently multiplied by
84
+ # episode length. r1_pro episodes are ~37x longer, which turned an intended 49%
85
+ # share into a realized 96.5% — and here it also starves the loader, because
86
+ # every draw pulls a ~190 MB two-camera episode and the first batch never
87
+ # arrives. Dividing by relative episode length restores the intended mixture.
88
+ EP_LEN = {"r1_pro": 37.0, "gr1": 1.0, "panda_single": 1.0,
89
+ "panda_bi_gripper": 1.0, "panda_bi_hand": 1.0, "g1": 0.6}
90
+ for sp in specs:
91
+ sp["weight"] = float(sp["weight"]) / EP_LEN.get(sp.get("morph_key"), 1.0)
92
+ from collections import defaultdict as _dd
93
+ _agg = _dd(float)
94
+ for sp in specs:
95
+ _agg[sp["morph_key"]] += sp["weight"]
96
+ _tot = sum(_agg.values())
97
+ print("reweighted family shares:", {k: round(v / _tot, 3) for k, v in sorted(_agg.items())})
98
+
99
+ stream = HubEpisodeStream(specs, chunk=cfg.chunk_size, image_size=cfg.image_size,
100
+ max_state_dim=cfg.max_state_dim, max_action_dim=cfg.max_action_dim,
101
+ shuffle_buffer=64, seed=1234, morph_descriptors=morph)
102
+ loader = torch.utils.data.DataLoader(
103
+ stream, batch_size=args.batch_size, num_workers=args.num_workers,
104
+ pin_memory=True, prefetch_factor=4, persistent_workers=True,
105
+ multiprocessing_context="spawn", # fork deadlocks the hub HTTP pool (B200.md)
106
+ )
107
+
108
+ backbone, head = [], []
109
+ for n, p in pol.named_parameters():
110
+ if not p.requires_grad:
111
+ continue
112
+ (backbone if n.startswith("semantic.") else head).append(p)
113
+ opt = torch.optim.AdamW([
114
+ {"params": head, "lr": args.lr},
115
+ {"params": backbone, "lr": args.lr * args.backbone_lr_mult},
116
+ ], weight_decay=1e-4)
117
+ sched = torch.optim.lr_scheduler.LambdaLR(
118
+ opt, lambda s: min(1.0, (s + 1) / args.warmup))
119
+ scaler_dtype = torch.bfloat16
120
+ print(f"head {sum(p.numel() for p in head)/1e6:.1f}M | "
121
+ f"backbone {sum(p.numel() for p in backbone)/1e6:.1f}M at {args.backbone_lr_mult}x", flush=True)
122
+
123
+ step, t0 = 0, time.time()
124
+ for batch in loader:
125
+ t = tok(list(batch["task"]), padding="max_length", truncation=True,
126
+ max_length=48, return_tensors="pt")
127
+ b = {k: v.cuda(non_blocking=True) for k, v in batch.items()
128
+ if isinstance(v, torch.Tensor)}
129
+ b["observation.language.tokens"] = t["input_ids"].cuda()
130
+ b["observation.language.attention_mask"] = t["attention_mask"].bool().cuda()
131
+ with torch.autocast("cuda", scaler_dtype):
132
+ loss, info = pol.forward(b)
133
+ loss.backward()
134
+ torch.nn.utils.clip_grad_norm_([p for g in opt.param_groups for p in g["params"]], 10.0)
135
+ opt.step()
136
+ sched.step()
137
+ opt.zero_grad(set_to_none=True)
138
+ step += 1
139
+ if step % args.log_freq == 0:
140
+ print(f"step {step}/{args.steps} loss {info['loss']:.4f} "
141
+ f"{step/(time.time()-t0):.2f} it/s", flush=True)
142
+ if step % args.save_freq == 0 or step == args.steps:
143
+ d = out / (f"step_{step}" if step < args.steps else "final")
144
+ d.mkdir(parents=True, exist_ok=True)
145
+ pol.save_pretrained(str(d))
146
+ print(f"saved {d}", flush=True)
147
+ if step >= args.steps:
148
+ break
149
+ print("DONE", flush=True)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()