AlexWortega commited on
Commit
afe36fe
·
verified ·
1 Parent(s): 5d64f2b

Upload tinyvla2/scripts/eval_physical_ai.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. tinyvla2/scripts/eval_physical_ai.py +493 -0
tinyvla2/scripts/eval_physical_ai.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Evaluate the physical_ai_ft (GR00T) checkpoint — the run that was never measured.
3
+
4
+ Everything here exists because this checkpoint can produce a plausible-looking
5
+ number for the wrong reason. Three specific traps, each with a gate or control:
6
+
7
+ 1. The published weights carry torch.compile's `expert._orig_mod.` prefix, and
8
+ LeRobot loads with strict=False while FlowMatchingExpert zero-inits action_out.
9
+ Loaded naively the model returns its own initial Gaussian noise. Gate: strict
10
+ load of the rekeyed copy; Control B re-runs the broken path as a labelled row.
11
+
12
+ 2. Action semantics are per-DIM, not per-family: r1_pro's `action[3:6]` IS
13
+ `state[6:9]`, gr1 mixes 12 absolute dims with deltas, and 17 of gr1's 44 dims
14
+ are constant. So the trivial baseline is searched per dim, in raw units, not
15
+ assumed. Getting this wrong on r1_pro (hold-still 0.895 vs predict-mean 3.79)
16
+ would manufacture a 4x win out of nothing.
17
+
18
+ 3. The realized training mixture is ~97% r1_pro, because the stream samples a
19
+ dataset per EPISODE and r1_pro episodes are ~37x longer. Per-family numbers are
20
+ printed next to that table or they will be misread.
21
+
22
+ Usage:
23
+ python scripts/eval_physical_ai.py [--preflight] [--datasets-per-family 8]
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import math
31
+ from collections import defaultdict
32
+ from pathlib import Path
33
+
34
+ import numpy as np
35
+ import torch
36
+ import yaml
37
+
38
+ CKPT = "/home/alexw/tinyvla/outputs/physical_ai_ft_fixed"
39
+ RAW_CKPT = "/home/alexw/tinyvla_data/b200/outputs/physical_ai_ft/final"
40
+ SPEC = "/home/alexw/tinyvla/configs/physical_ai_stream.yaml"
41
+ FAMILY = {10: "gr1", 11: "bi_panda_grip", 12: "bi_panda_hand",
42
+ 13: "single_panda", 14: "r1_pro", 15: "g1"}
43
+ DEAD_STD = 1e-4
44
+ EVAL_SEED = 20260830 # deliberately not the training seed (42)
45
+
46
+
47
+ # ----------------------------------------------------------------- calibration
48
+
49
+ def classify_dims(state_raw, action_raw):
50
+ """Per action dim: ABS (tracks a state dim), DELTA, or DEAD (constant).
51
+
52
+ Searched in RAW units and over lags 0..2, because the z-score hides exact
53
+ identities and commanded targets lead the measured state.
54
+ """
55
+ T, A = action_raw.shape
56
+ S = state_raw.shape[1]
57
+ std_a = action_raw.std(0)
58
+ kind = np.full(A, "delta", dtype=object)
59
+ amap = np.full(A, -1, dtype=int)
60
+ lag = np.zeros(A, dtype=int)
61
+
62
+ dead = std_a < DEAD_STD
63
+ kind[dead] = "dead"
64
+
65
+ for j in range(A):
66
+ if dead[j]:
67
+ continue
68
+ best = (np.inf, -1, 0)
69
+ for lg in (0, 1, 2):
70
+ a = action_raw[: T - lg, j]
71
+ sl = state_raw[lg:, :]
72
+ rms = np.sqrt(((sl - a[:, None]) ** 2).mean(0))
73
+ i = int(np.argmin(rms))
74
+ if rms[i] < best[0]:
75
+ best = (float(rms[i]), i, lg)
76
+ rms, i, lg = best
77
+ if i < 0:
78
+ continue
79
+ a = action_raw[: T - lg, j]
80
+ s = state_raw[lg:, i]
81
+ if s.std() < 1e-9 or a.std() < 1e-9:
82
+ continue
83
+ corr = float(np.corrcoef(a, s)[0, 1])
84
+ if corr > 0.95 and rms < 0.5 * std_a[j]:
85
+ kind[j], amap[j], lag[j] = "abs", i, lg
86
+ return kind, amap, lag, std_a
87
+
88
+
89
+ def displacement(A, kind, amap, ref):
90
+ """(chunk, adim) actions -> displacement, per the dim's semantics.
91
+
92
+ ABS : residual against the hold-still reference (trivial baseline = hold still)
93
+ DELTA : integrated (trivial baseline = zero motion)
94
+ In both cases the trivial baseline maps to d == 0, which is what makes the
95
+ ratio comparable across families.
96
+ """
97
+ d = np.array(A, dtype=np.float64, copy=True)
98
+ is_abs = kind == "abs"
99
+ is_del = kind == "delta"
100
+ d[:, is_abs] = A[:, is_abs] - ref[is_abs][None, :]
101
+ d[:, is_del] = np.cumsum(A[:, is_del], axis=0)
102
+ return d
103
+
104
+
105
+ def chunk_err(pred, gt, kind, amap, ref, sel):
106
+ """Mean-over-steps L2 of the displacement error, restricted to dims `sel`."""
107
+ dp = displacement(pred, kind, amap, ref)[:, sel]
108
+ dg = displacement(gt, kind, amap, ref)[:, sel]
109
+ return float(np.linalg.norm(dp - dg, axis=1).mean()), float(np.linalg.norm(dg, axis=1).mean())
110
+
111
+
112
+ # --------------------------------------------------------------------- helpers
113
+
114
+ def make_batch(items, tok, cfg, device="cuda", state_override=None, image_override=None,
115
+ task_override=None, emb_override=None, padding="max_length"):
116
+ tasks = task_override if task_override is not None else [it["task"] for it in items]
117
+ t = tok(tasks, padding=padding, truncation=True, max_length=48, return_tensors="pt")
118
+ cam0 = torch.stack([it["observation.images.cam0"] for it in items])
119
+ cam1 = torch.stack([it["observation.images.cam1"] for it in items])
120
+ if image_override is not None:
121
+ cam0, cam1 = image_override
122
+ st = torch.stack([it["observation.state"] for it in items])
123
+ if state_override is not None:
124
+ st = state_override
125
+ emb = torch.stack([it["embodiment_id"] for it in items])
126
+ if emb_override is not None:
127
+ emb = emb_override
128
+ return {
129
+ "observation.images.cam0": cam0.to(device),
130
+ "observation.images.cam1": cam1.to(device),
131
+ "observation.state": st.to(device),
132
+ "observation.language.tokens": t["input_ids"].to(device),
133
+ "observation.language.attention_mask": t["attention_mask"].bool().to(device),
134
+ "embodiment_id": emb.to(device),
135
+ }
136
+
137
+
138
+ def predict_mean(pol, batch, n_seeds: int):
139
+ """Average the flow ODE over n_seeds draws of the initial noise.
140
+
141
+ predict_action_chunk starts from torch.randn, and control C measured seed
142
+ sensitivity S ~ 1.2 on this checkpoint: two draws disagree with each other
143
+ about as much as either disagrees with ground truth. The expert itself is fine
144
+ (flow loss L/L0 = 0.12) — the 10-step Euler integration simply does not
145
+ contract the initial noise. Since the metric is L2, the conditional MEAN is the
146
+ optimal point estimate, so averaging is the right estimator, not a fudge.
147
+ """
148
+ acc = None
149
+ for k in range(n_seeds):
150
+ torch.manual_seed(1000 + k)
151
+ with torch.autocast("cuda", torch.bfloat16):
152
+ p = pol.predict_action_chunk(batch).float()
153
+ acc = p if acc is None else acc + p
154
+ return (acc / n_seeds).cpu().numpy()
155
+
156
+
157
+ def bootstrap_ci(per_ep, n=2000, seed=0):
158
+ """CI over EPISODES — frames inside an episode are not independent."""
159
+ if len(per_ep) < 2:
160
+ return (float("nan"), float("nan"))
161
+ rng = np.random.default_rng(seed)
162
+ a = np.asarray(per_ep, dtype=float)
163
+ means = [a[rng.integers(0, len(a), len(a))].mean() for _ in range(n)]
164
+ return (float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5)))
165
+
166
+
167
+ def realized_mixture(specs, metas):
168
+ """Sample share actually seen, given the stream draws a DATASET PER EPISODE."""
169
+ rows = []
170
+ for si, s in enumerate(specs):
171
+ m = metas.get(si)
172
+ if m is None:
173
+ continue
174
+ rows.append((s["embodiment_id"], s["weight"], m["samples_per_ep"], m["n_eps"]))
175
+ tot_w = sum(r[1] for r in rows) or 1.0
176
+ fam = defaultdict(lambda: [0.0, 0.0, 0])
177
+ for emb, w, spe, n_eps in rows:
178
+ share = (w / tot_w) * spe
179
+ fam[emb][0] += share
180
+ fam[emb][1] += w / tot_w
181
+ fam[emb][2] += n_eps
182
+ norm = sum(v[0] for v in fam.values()) or 1.0
183
+ return {k: (v[0] / norm, v[1], v[2]) for k, v in fam.items()}
184
+
185
+
186
+ # ------------------------------------------------------------------------ main
187
+
188
+ @torch.no_grad()
189
+ def main():
190
+ ap = argparse.ArgumentParser()
191
+ ap.add_argument("--preflight", action="store_true", help="gates and tables only")
192
+ ap.add_argument("--datasets-per-family", type=int, default=8)
193
+ ap.add_argument("--episodes", type=int, default=3)
194
+ ap.add_argument("--starts", type=int, default=12)
195
+ ap.add_argument("--seeds", type=int, default=4,
196
+ help="average the flow ODE over N noise draws (control C measured S~1.2)")
197
+ ap.add_argument("--out", default="/home/alexw/tinyvla/outputs/physical_ai_eval.json")
198
+ args = ap.parse_args()
199
+
200
+ from safetensors.torch import load_file
201
+ from transformers import AutoTokenizer
202
+
203
+ from tinyvla.data.hub_stream import EvalEpisodeStream
204
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
205
+
206
+ specs_all = yaml.safe_load(open(SPEC))["datasets"]
207
+
208
+ # ---------------------------------------------------------------- GATE 1
209
+ pol = TinyVLAPolicy.from_pretrained(CKPT)
210
+ sd = load_file(f"{CKPT}/model.safetensors")
211
+ pol.load_state_dict(sd, strict=True)
212
+ aow = pol.expert.action_out.weight.abs().mean().item()
213
+ assert aow > 1e-3, "expert.action_out at zero init — expert never trained"
214
+ cfg = pol.config
215
+ pol = pol.cuda().eval()
216
+ print(f"GATE rekey {len(sd)}/{len(pol.state_dict())} strict=True OK | "
217
+ f"action_out |w|={aow:.4f} | action_dim={pol.action_dim} "
218
+ f"max_action={cfg.max_action_dim} max_state={cfg.max_state_dim}")
219
+ tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
220
+
221
+ # ------------------------------------------------- dataset selection
222
+ rng = np.random.default_rng(EVAL_SEED)
223
+ by_fam = defaultdict(list)
224
+ for s in specs_all:
225
+ by_fam[s["embodiment_id"]].append(s)
226
+ chosen = []
227
+ for emb in sorted(by_fam):
228
+ pool = by_fam[emb]
229
+ k = min(args.datasets_per_family if emb != 14 else 12, len(pool))
230
+ idx = rng.choice(len(pool), size=k, replace=False)
231
+ chosen += [pool[i] for i in sorted(idx)]
232
+ print(f"selected {len(chosen)} datasets across {len(by_fam)} families "
233
+ f"(seed {EVAL_SEED}, not the training seed)")
234
+
235
+ stream = EvalEpisodeStream(chosen, chunk=cfg.chunk_size, image_size=cfg.image_size,
236
+ max_state_dim=cfg.max_state_dim,
237
+ max_action_dim=cfg.max_action_dim, shuffle_buffer=1)
238
+
239
+ # ------------------------------------------------- GATE 2/3 + calibration
240
+ metas, calib = {}, {}
241
+ for si, s in enumerate(chosen):
242
+ try:
243
+ m = stream._load_meta(si)
244
+ eps = m["episodes"]
245
+ metas[si] = {"n_eps": len(eps),
246
+ "samples_per_ep": float(np.mean([e[1] for e in eps[:200]])) / m["stride"],
247
+ "stride": m["stride"], "adim": m["action_dim"],
248
+ "stats_src": m.get("stats_src", "?")}
249
+ sraw, araw, _ = stream.episode_raw(si)
250
+ kind, amap, lag, std_a = classify_dims(sraw, araw)
251
+ calib[si] = dict(kind=kind, amap=amap, lag=lag, std_a=std_a)
252
+ print(f" [{si:>3}] {s['prefix'][:44] or s['repo_id'][-30:]:46} "
253
+ f"emb={s['embodiment_id']} adim={m['action_dim']:>3} "
254
+ f"abs={int((kind=='abs').sum()):>3} del={int((kind=='delta').sum()):>3} "
255
+ f"dead={int((kind=='dead').sum()):>3}", flush=True)
256
+ except Exception as e:
257
+ print(f" [{si:>3}] {s.get('prefix','')[:44]:46} CALIB FAIL {type(e).__name__}: {str(e)[:60]}",
258
+ flush=True)
259
+
260
+ mix = realized_mixture(chosen, metas)
261
+ print("\nREALIZED MIXTURE (stream draws a dataset PER EPISODE, so share = weight x episode length)")
262
+ for emb in sorted(mix):
263
+ share, wshare, n_eps = mix[emb]
264
+ print(f" {FAMILY[emb]:14} realized {share*100:5.1f}% intended(weight) {wshare*100:5.1f}% "
265
+ f"episodes {n_eps}")
266
+ draws_8gpu = 8 * 256 * 60000 / max(1e-9, np.mean([m["samples_per_ep"] for m in metas.values()]))
267
+ print("HOLD-OUT P(episode never drawn), 8-GPU / 1-GPU scenario (no log disambiguates):")
268
+ for emb in sorted(mix):
269
+ share, _, n_eps = mix[emb]
270
+ lam8 = draws_8gpu * share / max(1, n_eps)
271
+ print(f" {FAMILY[emb]:14} P(unseen) {math.exp(-lam8)*100:5.1f}% / {math.exp(-lam8/8)*100:5.1f}%")
272
+
273
+ if args.preflight:
274
+ return
275
+
276
+ # ------------------------------------------------------------ main pass
277
+ print("\n=== main pass ===", flush=True)
278
+ results = defaultdict(lambda: defaultdict(list))
279
+ per_ep_json = []
280
+ subsample = [] # kept for the control block
281
+
282
+ for si, s in enumerate(chosen):
283
+ if si not in calib:
284
+ continue
285
+ c = calib[si]
286
+ emb = s["embodiment_id"]
287
+ m = stream._load_meta(si)
288
+ eps = m["episodes"]
289
+ n_ep = 1 if emb == 14 else args.episodes
290
+ n_st = 24 if emb == 14 else args.starts
291
+ pick = rng.choice(len(eps), size=min(n_ep, len(eps)), replace=False)
292
+ for e_i in pick:
293
+ ep_idx, length, task = eps[e_i]
294
+ n_rows = length // m["stride"]
295
+ hi = n_rows - cfg.chunk_size - 1
296
+ if hi <= 0:
297
+ continue
298
+ if emb == 14: # prefix-decode: starts confined to the episode head (labelled bias)
299
+ hi = min(hi, 600)
300
+ starts = np.unique(np.linspace(0, hi, n_st).astype(int))
301
+ try:
302
+ items = stream.episode_samples(si, ep_idx, task, starts)
303
+ except Exception as ex:
304
+ print(f" ep fail {s.get('prefix','')[:36]} {type(ex).__name__}: {str(ex)[:60]}", flush=True)
305
+ continue
306
+ if not items:
307
+ continue
308
+
309
+ preds = []
310
+ for k in range(0, len(items), 8):
311
+ b = make_batch(items[k:k + 8], tok, cfg)
312
+ preds.append(predict_mean(pol, b, args.seeds))
313
+ pred = np.concatenate(preds, 0)
314
+
315
+ ad = m["action_dim"]
316
+ sm, ss = m["stats"]["observation.state"]
317
+ am, as_ = m["stats"]["action"]
318
+ kind, amap = c["kind"], c["amap"]
319
+ for subset in ("abs", "delta"):
320
+ sel = (kind == subset)
321
+ if not sel.any():
322
+ continue
323
+ e_l, f0_l, f1_l, f2_l = [], [], [], []
324
+ for t_i, it in enumerate(items):
325
+ A = it["action"][:, :ad].numpy().astype(np.float64)
326
+ P = pred[t_i][:, :ad].astype(np.float64)
327
+ s_norm = it["observation.state"].numpy()
328
+ s_raw = s_norm[:len(sm)] * np.maximum(ss, 1e-6) + sm
329
+ ref = np.zeros(ad)
330
+ for j in range(ad):
331
+ if kind[j] == "abs" and amap[j] >= 0 and amap[j] < len(s_raw):
332
+ ref[j] = (s_raw[amap[j]] - am[j]) / max(as_[j], 1e-6)
333
+ err, floor_zero = chunk_err(P, A, kind, amap, ref, sel)
334
+ b0, _ = chunk_err(np.zeros_like(A), A, kind, amap, ref, sel)
335
+ b1, _ = chunk_err(np.tile(ref, (len(A), 1)), A, kind, amap, ref, sel)
336
+ b2, _ = chunk_err(np.tile(A[0], (len(A), 1)), A, kind, amap, ref, sel)
337
+ e_l.append(err); f0_l.append(b0); f1_l.append(b1); f2_l.append(b2)
338
+ key = (emb, subset)
339
+ results[key]["err"].append(np.mean(e_l))
340
+ results[key]["b0"].append(np.mean(f0_l))
341
+ results[key]["b1"].append(np.mean(f1_l))
342
+ results[key]["b2"].append(np.mean(f2_l))
343
+ results[key]["ndim"].append(int(sel.sum()))
344
+ per_ep_json.append(dict(emb=int(emb), subset=subset, dataset=s.get("prefix", s["repo_id"]),
345
+ episode=int(ep_idx), err=float(np.mean(e_l)),
346
+ b0=float(np.mean(f0_l)), b1=float(np.mean(f1_l)),
347
+ b2=float(np.mean(f2_l)), ndim=int(sel.sum())))
348
+ if len(subsample) < 24:
349
+ subsample.append((si, items[:4], c, m))
350
+ print(f" {FAMILY[emb]:14} {s.get('prefix','')[:34]:36} ep{ep_idx:<6} "
351
+ f"n={len(items)}", flush=True)
352
+
353
+ # ------------------------------------------------------------- report
354
+ print("\n=== RESULTS (displacement error / trivial floor; <1 beats the floor) ===")
355
+ print(f"{'family':16}{'subset':7}{'nep':>4}{'dims':>5}{'err':>8}{'B0':>8}{'B1':>8}{'B2':>8}"
356
+ f"{'ratio':>8} 95% CI (bootstrap over episodes)")
357
+ summary = {}
358
+ for (emb, subset), v in sorted(results.items()):
359
+ err = np.mean(v["err"]); b0 = np.mean(v["b0"]); b1 = np.mean(v["b1"]); b2 = np.mean(v["b2"])
360
+ floor = min(b0, b1)
361
+ ratios = [e / max(min(x, y), 1e-9) for e, x, y in zip(v["err"], v["b0"], v["b1"])]
362
+ lo, hi = bootstrap_ci(ratios)
363
+ tag = " [IN-TRAINING]" if emb == 14 else ""
364
+ print(f"{FAMILY[emb]:16}{subset:7}{len(v['err']):>4}{int(np.mean(v['ndim'])):>5}"
365
+ f"{err:>8.3f}{b0:>8.3f}{b1:>8.3f}{b2:>8.3f}{err/floor:>8.2f} "
366
+ f"[{lo:.2f}, {hi:.2f}]{tag}")
367
+ summary[f"{FAMILY[emb]}/{subset}"] = dict(err=err, b0=b0, b1=b1, b2=b2,
368
+ ratio=err / floor, ci=[lo, hi],
369
+ n_ep=len(v["err"]))
370
+ if abs(b2 - floor) / max(floor, 1e-9) < 0.1:
371
+ print(f"{'':16}^^ oracle repeat-A0 ({b2:.3f}) is within 10% of the floor "
372
+ f"({floor:.3f}) — this subset carries almost no predictable signal")
373
+
374
+ # ------------------------------------------------------------ controls
375
+ print("\n=== CONTROLS ===", flush=True)
376
+ ctrl = {}
377
+
378
+ # C: ODE seed sensitivity
379
+ si, items, c, m = subsample[0]
380
+ b = make_batch(items, tok, cfg)
381
+ torch.manual_seed(1)
382
+ with torch.autocast("cuda", torch.bfloat16):
383
+ p1 = pol.predict_action_chunk(b).float().cpu().numpy()
384
+ torch.manual_seed(2)
385
+ with torch.autocast("cuda", torch.bfloat16):
386
+ p2 = pol.predict_action_chunk(b).float().cpu().numpy()
387
+ gt = torch.stack([it["action"] for it in items]).numpy()[:, :, :p1.shape[-1]]
388
+ S = float(np.abs(p1 - p2).mean() / max(np.abs(p1 - gt).mean(), 1e-9))
389
+ a1 = predict_mean(pol, b, args.seeds)
390
+ torch.manual_seed(77)
391
+ a2 = predict_mean(pol, b, args.seeds)
392
+ S_avg = float(np.abs(a1 - a2).mean() / max(np.abs(a1 - gt).mean(), 1e-9))
393
+ ctrl["ode_seed_sensitivity"] = S
394
+ ctrl["ode_seed_sensitivity_averaged"] = S_avg
395
+ print(f" C ODE seed sensitivity: single draw S = {S:.3f}, "
396
+ f"{args.seeds}-seed average S = {S_avg:.3f} (headline uses the average)")
397
+
398
+ # E: flow-matching training loss vs the trivial predictor
399
+ e_rows = {}
400
+ for si, items, c, m in subsample[:12]:
401
+ emb = chosen[si]["embodiment_id"]
402
+ b = make_batch(items, tok, cfg)
403
+ b["action"] = torch.stack([it["action"] for it in items]).cuda()
404
+ b["action_dim_mask"] = torch.stack([it["action_dim_mask"] for it in items]).cuda()
405
+ b["action_is_pad"] = torch.stack([it["action_is_pad"] for it in items]).cuda()
406
+ torch.manual_seed(0)
407
+ with torch.autocast("cuda", torch.bfloat16):
408
+ loss, _ = pol.forward(b)
409
+ A = b["action"][:, :, : m["action_dim"]]
410
+ l0 = float((torch.randn_like(A) - A).pow(2).mean())
411
+ e_rows.setdefault(emb, []).append(float(loss) / l0)
412
+ ctrl["flow_loss_ratio"] = {FAMILY[k]: float(np.mean(v)) for k, v in e_rows.items()}
413
+ print(" E flow loss L/L0 :", {k: round(v, 3) for k, v in ctrl["flow_loss_ratio"].items()},
414
+ " (<1 = expert learned something, independent of the ODE and the metric)")
415
+
416
+ # D3/D4: in-distribution donor swap — is vision used at all, or is this a proprio regressor?
417
+ def ratio_for(items, c, m, state_override=None, image_override=None, emb_override=None):
418
+ b = make_batch(items, tok, cfg, state_override=state_override,
419
+ image_override=image_override, emb_override=emb_override)
420
+ pr = predict_mean(pol, b, args.seeds)
421
+ ad = m["action_dim"]
422
+ sm, ss = m["stats"]["observation.state"]
423
+ am, as_ = m["stats"]["action"]
424
+ kind, amap = c["kind"], c["amap"]
425
+ sel = (kind != "dead")
426
+ num, den = [], []
427
+ for t_i, it in enumerate(items):
428
+ A = it["action"][:, :ad].numpy().astype(np.float64)
429
+ P = pr[t_i][:, :ad].astype(np.float64)
430
+ s_raw = it["observation.state"].numpy()[:len(sm)] * np.maximum(ss, 1e-6) + sm
431
+ ref = np.zeros(ad)
432
+ for j in range(ad):
433
+ if kind[j] == "abs" and 0 <= amap[j] < len(s_raw):
434
+ ref[j] = (s_raw[amap[j]] - am[j]) / max(as_[j], 1e-6)
435
+ e, f = chunk_err(P, A, kind, amap, ref, sel)
436
+ b0, _ = chunk_err(np.zeros_like(A), A, kind, amap, ref, sel)
437
+ b1, _ = chunk_err(np.tile(ref, (len(A), 1)), A, kind, amap, ref, sel)
438
+ num.append(e); den.append(min(b0, b1))
439
+ return float(np.mean(num) / max(np.mean(den), 1e-9))
440
+
441
+ d_rows = defaultdict(dict)
442
+ for pos, (si, items, c, m) in enumerate(subsample[:8]):
443
+ emb = chosen[si]["embodiment_id"]
444
+ donor = subsample[(pos + 1) % len(subsample)][1]
445
+ n = min(len(items), len(donor))
446
+ it_, dn_ = items[:n], donor[:n]
447
+ base = ratio_for(it_, c, m)
448
+ img = (torch.stack([d["observation.images.cam0"] for d in dn_]),
449
+ torch.stack([d["observation.images.cam1"] for d in dn_]))
450
+ d3 = ratio_for(it_, c, m, image_override=img)
451
+ d4 = ratio_for(it_, c, m, state_override=torch.stack([d["observation.state"] for d in dn_]))
452
+ d6 = ratio_for(it_, c, m, emb_override=torch.full((n,), 11 if emb != 11 else 10).long())
453
+ d_rows[emb] = dict(base=base, d3_img=d3, d4_state=d4, d6_emb=d6)
454
+ ctrl["ablation"] = {FAMILY[k]: {kk: round(vv, 3) for kk, vv in v.items()} for k, v in d_rows.items()}
455
+ print(" D donor-swap ablation (ratio; higher = input mattered):")
456
+ for k, v in ctrl["ablation"].items():
457
+ print(f" {k:14} base {v['base']:.3f} | images {v['d3_img']:.3f} "
458
+ f"| state {v['d4_state']:.3f} | wrong emb id {v['d6_emb']:.3f}")
459
+
460
+ # B: the broken (un-rekeyed) path, as shipped
461
+ broken = TinyVLAPolicy.from_pretrained(RAW_CKPT).cuda().eval()
462
+ si, items, c, m = subsample[0]
463
+ b = make_batch(items, tok, cfg)
464
+ with torch.autocast("cuda", torch.bfloat16):
465
+ pb = broken.predict_action_chunk(b).float().cpu().numpy()
466
+ ad = m["action_dim"]
467
+ A = torch.stack([it["action"] for it in items]).numpy()[:, :, :ad]
468
+ ctrl["broken_err"] = float(np.linalg.norm(pb[:, :, :ad] - A, axis=2).mean())
469
+ with torch.autocast("cuda", torch.bfloat16):
470
+ pg = pol.predict_action_chunk(b).float().cpu().numpy()
471
+ ctrl["fixed_err"] = float(np.linalg.norm(pg[:, :, :ad] - A, axis=2).mean())
472
+ print(f" B un-rekeyed (as published) err {ctrl['broken_err']:.3f} vs rekeyed "
473
+ f"{ctrl['fixed_err']:.3f} (equal => the rename did not take effect)")
474
+ del broken
475
+ torch.cuda.empty_cache()
476
+
477
+ # F: tokenizer padding fidelity (training used max_length=48)
478
+ si, items, c, m = subsample[0]
479
+ r_max = ratio_for(items, c, m)
480
+ b_long = make_batch(items, tok, cfg, padding=True)
481
+ with torch.autocast("cuda", torch.bfloat16):
482
+ _ = pol.predict_action_chunk(b_long)
483
+ ctrl["padding_note"] = "headline uses padding=max_length(48), as train_fast.Collate did"
484
+ print(f" F padding: headline uses max_length=48 (training convention); ratio {r_max:.3f}")
485
+
486
+ Path(args.out).write_text(json.dumps(
487
+ {"summary": summary, "controls": ctrl, "per_episode": per_ep_json,
488
+ "mixture": {FAMILY[k]: v[0] for k, v in mix.items()}}, indent=2))
489
+ print(f"\nwrote {args.out}")
490
+
491
+
492
+ if __name__ == "__main__":
493
+ main()