AlexWortega commited on
Commit
1601a2f
·
verified ·
1 Parent(s): d82ea16

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. README.md +148 -0
  3. checkpoints/stage2_final/config.json +88 -0
  4. checkpoints/stage2_final/model.safetensors +3 -0
  5. checkpoints/stage3_final/config.json +90 -0
  6. checkpoints/stage3_final/model.safetensors +3 -0
  7. config.json +90 -0
  8. configs/ablation_nolatent.yaml +25 -0
  9. configs/ablation_nospatial.yaml +25 -0
  10. configs/libero_ft.yaml +31 -0
  11. configs/libero_ft2.yaml +33 -0
  12. configs/libero_ft3.yaml +33 -0
  13. configs/libero_ft4.yaml +33 -0
  14. configs/stage2_mixture.yaml +37 -0
  15. configs/stage3_distill.yaml +40 -0
  16. model.safetensors +3 -0
  17. scripts/diag_obs_gap.py +131 -0
  18. scripts/download_subsets.py +77 -0
  19. scripts/eval_libero.py +193 -0
  20. scripts/eval_offline.py +114 -0
  21. scripts/label_spatial.py +148 -0
  22. scripts/prepare_community_v3.py +163 -0
  23. scripts/replay_libero.py +85 -0
  24. scripts/train.py +255 -0
  25. scripts/train_stage1_smoke.sh +20 -0
  26. src/tinyvla.egg-info/PKG-INFO +15 -0
  27. src/tinyvla.egg-info/SOURCES.txt +7 -0
  28. src/tinyvla.egg-info/dependency_links.txt +1 -0
  29. src/tinyvla.egg-info/requires.txt +11 -0
  30. src/tinyvla.egg-info/top_level.txt +1 -0
  31. src/tinyvla/__init__.py +9 -0
  32. src/tinyvla/__pycache__/__init__.cpython-312.pyc +0 -0
  33. src/tinyvla/__pycache__/configuration_tinyvla.cpython-312.pyc +0 -0
  34. src/tinyvla/__pycache__/modeling_tinyvla.cpython-312.pyc +0 -0
  35. src/tinyvla/__pycache__/processor_tinyvla.cpython-312.pyc +0 -0
  36. src/tinyvla/configuration_tinyvla.py +122 -0
  37. src/tinyvla/data/__init__.py +0 -0
  38. src/tinyvla/data/__pycache__/__init__.cpython-312.pyc +0 -0
  39. src/tinyvla/data/__pycache__/mixture.cpython-312.pyc +0 -0
  40. src/tinyvla/data/__pycache__/spatial_labels.cpython-312.pyc +0 -0
  41. src/tinyvla/data/mixture.py +178 -0
  42. src/tinyvla/data/spatial_labels.py +44 -0
  43. src/tinyvla/modeling_tinyvla.py +213 -0
  44. src/tinyvla/modules/__pycache__/embodiment.cpython-312.pyc +0 -0
  45. src/tinyvla/modules/__pycache__/expert.cpython-312.pyc +0 -0
  46. src/tinyvla/modules/__pycache__/semantic.cpython-312.pyc +0 -0
  47. src/tinyvla/modules/__pycache__/vision.cpython-312.pyc +0 -0
  48. src/tinyvla/modules/embodiment.py +35 -0
  49. src/tinyvla/modules/expert.py +128 -0
  50. src/tinyvla/modules/semantic.py +157 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ videos/ep00_task0_succ.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ videos/ep01_task1_succ.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ videos/ep02_task2_succ.mp4 filter=lfs diff=lfs merge=lfs -text
39
+ videos/ep03_task3_fail.mp4 filter=lfs diff=lfs merge=lfs -text
40
+ videos/ep04_task4_succ.mp4 filter=lfs diff=lfs merge=lfs -text
41
+ videos/ep05_task5_fail.mp4 filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: Qwen/Qwen3.5-0.8B
4
+ tags:
5
+ - robotics
6
+ - vla
7
+ - lerobot
8
+ - libero
9
+ - dual-rate
10
+ pipeline_tag: robotics
11
+ ---
12
+
13
+ # TinyVLA: a dual-rate VLA that beats OpenVLA-7B on LIBERO-Spatial at 0.64B params
14
+
15
+ **Headline result: 86% success on LIBERO-Spatial (n=100) in dual-rate mode (semantic latent
16
+ refreshed at 1 Hz), 79% full-rate — vs OpenVLA-7B's 84.7% — with 140M parameters on the
17
+ online control path.** Built and trained end-to-end in ~5 days on a single RTX A6000.
18
+
19
+ ## 1. Architecture
20
+
21
+ ```
22
+ SLOW / SMART path (0.5–2 Hz, cached)
23
+ instruction ─────────────────────┐
24
+ camera → Qwen3.5-0.8B vision tower → Qwen3.5 language stack (first 12 of 24 layers)
25
+ │ │
26
+ │ 8 readout tokens → linear → semantic latent (8×512)
27
+ │ │ cached between refreshes
28
+ ──────────────────┼───────────────────────┼──────────────────────────
29
+ │ FAST / MOTOR path (10–20 Hz, ~140M online)
30
+ └── 64 spatial tokens per camera ─┐
31
+ proprio (padded 16d) ───────────────────────────────┤
32
+ embodiment-id embedding ────────────────────────────┤
33
+
34
+ flow-matching action expert (38M, 12 blocks,
35
+ interleaved cross-/self-attention, AdaLN-free)
36
+
37
+ 50-step action chunk (padded 8d + validity mask)
38
+ ```
39
+
40
+ - **Single shared encoder**: the Qwen3.5-0.8B native ViT (100.6M) feeds both paths.
41
+ - **Slow path**: image + instruction + 8 trailing pad-token "readout" slots through the
42
+ truncated language stack (L/2 layer-skip a la SmolVLA); their hidden states → latent 8×512.
43
+ - **Fast path**: raw vision-tower spatial tokens (bypassing the LM) + proprio + embodiment
44
+ token, cross-attended by the flow expert. 10 Euler steps at inference.
45
+ - **Flow matching**: linear-OT path, t ~ Beta(1.5, 1), velocity target, masked MSE.
46
+ - Totals: 643M stored / 39.5M–575M trainable depending on stage / ~140M online per tick.
47
+ - Implemented as an **out-of-tree LeRobot policy plugin**
48
+ (`--policy.type=tinyvla --policy.discover_packages_path=tinyvla`, lerobot 0.6.1).
49
+
50
+ ## 2. Training recipe
51
+
52
+ | Stage | Data | Steps | Trainable | Result |
53
+ |---|---|---|---|---|
54
+ | 1. Overfit smoke | svla_so100_stacking (56 eps) | 5k | expert+proj (40M) | loss 1.885→0.099 |
55
+ | 2. Mixture pretrain | 40 SO100/101 community datasets (~10.7K eps, weight 0.65, per-dataset proportional) + Bridge V2 10K eps (0.35) | 40k, bs 64×2, lr 1e-4 | 39.5M (backbone frozen) | held-out RMSE@t0 0.30σ; **stale-latent 2s == fresh** |
56
+ | 2b. Staleness aug | same | from step 20k | — | latent from frame t−k, k~U(0,2s), p=0.5 |
57
+ | 3. Spatial distillation | + 132K teacher labels (Qwen3.5-4B, center-point → 32×32 grid), CE ×0.15 on cam0 tokens | 20k, lr 5e-5 | same + head | head top-1 20.8%, top-5 44.8% (random 0.1%) |
58
+ | 4. LIBERO FT (ft4) | HuggingFaceVLA/libero (1693 eps, all suites) | 40k, bs 48, lr 1e-4 / backbone 0.1× | **575M** (LM layers + vision unfrozen; vocab embeddings frozen) | loss 0.066 |
59
+
60
+ Ablation FTs: ft1 (all frozen) → 0% closed-loop; ft2 (vision unfrozen) → 60%;
61
+ ft3 (= ft2 from distilled ckpt) → 61%; **ft4 (LM+vision unfrozen) → 79/86%**.
62
+ Unfreezing the LM was the single largest gain; distillation was neutral on LIBERO
63
+ (likely overwritten by vision unfreezing during FT — bake it into pretraining next time).
64
+
65
+ Cross-embodiment handling: canonical schema (cam0=scene/cam1=wrist via regex mapping,
66
+ per-dataset mean/std normalization, action pad-to-8 + validity mask, state pad-to-16,
67
+ learned embodiment-id token). Weighted mixture sampler with per-source retry on corrupt
68
+ frames (IndexError/KeyError/decode RuntimeError).
69
+
70
+ ## 3. Results
71
+
72
+ ### LIBERO-Spatial (100 episodes, 10 per task, init states 0–9)
73
+
74
+ | Mode | Success |
75
+ |---|---|
76
+ | Full-rate (latent every chunk) | 79/100 = **79%** |
77
+ | **Dual-rate (latent @ 1 Hz)** | 86/100 = **86%** |
78
+
79
+ Dual-rate ≥ full-rate consistently across all evals (also 65% vs 58% at ft3, n=50) —
80
+ a stable cached latent appears to act as temporal regularization.
81
+
82
+ ### Context (published numbers, their own eval protocols)
83
+
84
+ | Model | Params | LIBERO-Spatial |
85
+ |---|---|---|
86
+ | π0 | 3.3B | 98% |
87
+ | SmolVLA-2.25B | 2.25B | 93% |
88
+ | XS-VLA | 0.26B | ~93 (avg 90.3) |
89
+ | **TinyVLA (this)** | **0.64B (140M online)** | **86% dual-rate / 79% full** |
90
+ | OpenVLA | 7B | 84.7% |
91
+
92
+ Honesty notes: our n=100 (±8pp) vs their 500; single suite; eval covers init states 0–9
93
+ per task (demos of same tasks/init states are in training data ��� this is the standard
94
+ LIBERO protocol used by all listed models). Multi-suite evals (object/goal/long) and an
95
+ identically-protocoled SmolVLA baseline run are in progress.
96
+
97
+ ### Dual-rate validation (the architectural claim)
98
+
99
+ - Offline: held-out chunk MSE with latent from a frame 2s earlier == fresh (0.772 vs 0.772).
100
+ - Closed-loop: 86% @ 1 Hz refresh vs 79% full-rate.
101
+ - Deployment implication (Raspberry Pi 5): the 0.5B slow path can run at 0.5–1 Hz
102
+ (llama.cpp GGUF Q4 territory), only the 140M fast path needs real-time rates.
103
+
104
+ ## 4. Pitfalls we hit (read before reproducing)
105
+
106
+ 1. **robosuite/LIBERO renders are 180°-rotated** vs recorded datasets. A vertical flip
107
+ alone leaves a horizontal MIRROR — the policy reaches to the wrong side and scores 0%.
108
+ Use `img[::-1, ::-1]`. (lerobot's `LiberoEnv.render()` flips for visualization but
109
+ `_format_raw_obs` feeds policies raw rotated frames — check your eval path.)
110
+ 2. **eef orientation antipodal ambiguity**: env quat → rotvec gives −π-ish x-component,
111
+ datasets store +π-ish. Canonicalize (flip antipodal representation when rotvec_x < 0)
112
+ or your normalized state input is off by ~17σ.
113
+ 3. **HuggingFaceVLA/libero meta says fps=10 but frames are native 20 Hz** (~161
114
+ frames/episode ≈ 8s demos). Do NOT action-repeat at eval.
115
+ 4. **GT-replay harness is the decisive convention test**: replay dataset actions in the env
116
+ (first episode per task ↔ init_state 0). 4/5 success = conventions right; policy-independent.
117
+ 5. HuggingFaceVLA community_dataset v1/v3 are aggregates of v2.1 sub-datasets → convert with
118
+ lerobot's `convert_dataset_v21_to_v30.py`; 4/44 sources have meta≠parquet row counts
119
+ (broken at source) — validate `len(reader.hf_dataset) == meta.total_frames`.
120
+ 6. Qwen3.5 custom forwards need `mm_token_type_ids` for M-RoPE; patchify must match the
121
+ official processor bit-exact (verify!). Linear-attention needs fla-core + causal-conv1d
122
+ or you get a 5× slower torch fallback.
123
+ 7. Flow-matching loss can drop while conditioning is ignored — A/B with shuffled
124
+ observations to verify the model actually uses them.
125
+
126
+ ## 5. Repository layout
127
+
128
+ ```
129
+ src/tinyvla/ # LeRobot plugin: configuration/modeling/processor + modules
130
+ scripts/train.py # accelerate loop: weighted mixture, staleness aug, distill loss, resume
131
+ scripts/eval_libero.py # closed-loop LIBERO eval (all convention fixes, dual-rate, video)
132
+ scripts/replay_libero.py, diag_obs_gap.py # convention diagnostics
133
+ scripts/prepare_community_v3.py, label_spatial.py, eval_offline.py
134
+ configs/ # stage2/stage3/libero_ft* yaml
135
+ checkpoints/ # stage2_final, stage3_final, libero_ft4_final (main)
136
+ videos/ # sample eval episodes
137
+ ```
138
+
139
+ ## 6. Quick start
140
+
141
+ ```python
142
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
143
+ policy = TinyVLAPolicy.from_pretrained("AlexWortega/tinyvla") # ft4 checkpoint at root
144
+ # closed-loop LIBERO: python scripts/eval_libero.py --checkpoint <ckpt> \
145
+ # --suite libero_spatial --episodes 100 --n-action-steps 10 --refresh-s 1.0
146
+ ```
147
+
148
+ Trained 2026-08-14 → 2026-08-19 on 1× RTX A6000 (48GB), lerobot 0.6.1, torch 2.10, transformers 5.5.4.
checkpoints/stage2_final/config.json ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "tinyvla",
3
+ "n_obs_steps": 1,
4
+ "input_features": {
5
+ "observation.images.cam0": {
6
+ "type": "VISUAL",
7
+ "shape": [
8
+ 3,
9
+ 256,
10
+ 256
11
+ ]
12
+ },
13
+ "observation.images.cam1": {
14
+ "type": "VISUAL",
15
+ "shape": [
16
+ 3,
17
+ 256,
18
+ 256
19
+ ]
20
+ },
21
+ "observation.state": {
22
+ "type": "STATE",
23
+ "shape": [
24
+ 16
25
+ ]
26
+ }
27
+ },
28
+ "output_features": {
29
+ "action": {
30
+ "type": "ACTION",
31
+ "shape": [
32
+ 8
33
+ ]
34
+ }
35
+ },
36
+ "device": "cuda",
37
+ "use_amp": false,
38
+ "use_peft": false,
39
+ "push_to_hub": true,
40
+ "repo_id": null,
41
+ "private": null,
42
+ "tags": null,
43
+ "license": null,
44
+ "pretrained_path": null,
45
+ "pretrained_revision": null,
46
+ "chunk_size": 50,
47
+ "n_action_steps": 50,
48
+ "normalization_mapping": {
49
+ "VISUAL": "IDENTITY",
50
+ "STATE": "MEAN_STD",
51
+ "ACTION": "MEAN_STD"
52
+ },
53
+ "max_state_dim": 16,
54
+ "max_action_dim": 8,
55
+ "num_embodiments": 8,
56
+ "max_cameras": 3,
57
+ "image_size": 256,
58
+ "freeze_vision_encoder": true,
59
+ "lm_model_name": "Qwen/Qwen3.5-0.8B",
60
+ "lm_num_layers": 12,
61
+ "num_readout_tokens": 8,
62
+ "freeze_lm": true,
63
+ "tokenizer_max_length": 48,
64
+ "pad_language_to": "longest",
65
+ "expert_dim": 512,
66
+ "expert_layers": 12,
67
+ "expert_heads": 8,
68
+ "use_semantic_latent": true,
69
+ "use_spatial_tokens": true,
70
+ "num_denoise_steps": 10,
71
+ "flow_beta_alpha": 1.5,
72
+ "flow_beta_beta": 1.0,
73
+ "min_period": 0.004,
74
+ "max_period": 4.0,
75
+ "staleness_prob": 0.0,
76
+ "staleness_max_s": 2.0,
77
+ "optimizer_lr": 0.0001,
78
+ "optimizer_betas": [
79
+ 0.9,
80
+ 0.95
81
+ ],
82
+ "optimizer_eps": 1e-08,
83
+ "optimizer_weight_decay": 1e-10,
84
+ "optimizer_grad_clip_norm": 10.0,
85
+ "scheduler_warmup_steps": 1000,
86
+ "scheduler_decay_steps": 40000,
87
+ "scheduler_decay_lr": 2.5e-06
88
+ }
checkpoints/stage2_final/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1df919883d5f012a4b7b218f9d38ac165202dfaf6b3cd9515ab083458f2b0494
3
+ size 2573613872
checkpoints/stage3_final/config.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "tinyvla",
3
+ "n_obs_steps": 1,
4
+ "input_features": {
5
+ "observation.images.cam0": {
6
+ "type": "VISUAL",
7
+ "shape": [
8
+ 3,
9
+ 256,
10
+ 256
11
+ ]
12
+ },
13
+ "observation.images.cam1": {
14
+ "type": "VISUAL",
15
+ "shape": [
16
+ 3,
17
+ 256,
18
+ 256
19
+ ]
20
+ },
21
+ "observation.state": {
22
+ "type": "STATE",
23
+ "shape": [
24
+ 16
25
+ ]
26
+ }
27
+ },
28
+ "output_features": {
29
+ "action": {
30
+ "type": "ACTION",
31
+ "shape": [
32
+ 8
33
+ ]
34
+ }
35
+ },
36
+ "device": "cuda",
37
+ "use_amp": false,
38
+ "use_peft": false,
39
+ "push_to_hub": true,
40
+ "repo_id": null,
41
+ "private": null,
42
+ "tags": null,
43
+ "license": null,
44
+ "pretrained_path": null,
45
+ "pretrained_revision": null,
46
+ "chunk_size": 50,
47
+ "n_action_steps": 50,
48
+ "normalization_mapping": {
49
+ "VISUAL": "IDENTITY",
50
+ "STATE": "MEAN_STD",
51
+ "ACTION": "MEAN_STD"
52
+ },
53
+ "max_state_dim": 16,
54
+ "max_action_dim": 8,
55
+ "num_embodiments": 8,
56
+ "max_cameras": 3,
57
+ "image_size": 256,
58
+ "freeze_vision_encoder": true,
59
+ "lm_model_name": "Qwen/Qwen3.5-0.8B",
60
+ "lm_num_layers": 12,
61
+ "num_readout_tokens": 8,
62
+ "freeze_lm": true,
63
+ "tokenizer_max_length": 48,
64
+ "pad_language_to": "longest",
65
+ "expert_dim": 512,
66
+ "expert_layers": 12,
67
+ "expert_heads": 8,
68
+ "use_semantic_latent": true,
69
+ "use_spatial_tokens": true,
70
+ "spatial_vocab": 1024,
71
+ "spatial_loss_weight": 0.15,
72
+ "num_denoise_steps": 10,
73
+ "flow_beta_alpha": 1.5,
74
+ "flow_beta_beta": 1.0,
75
+ "min_period": 0.004,
76
+ "max_period": 4.0,
77
+ "staleness_prob": 0.0,
78
+ "staleness_max_s": 2.0,
79
+ "optimizer_lr": 0.0001,
80
+ "optimizer_betas": [
81
+ 0.9,
82
+ 0.95
83
+ ],
84
+ "optimizer_eps": 1e-08,
85
+ "optimizer_weight_decay": 1e-10,
86
+ "optimizer_grad_clip_norm": 10.0,
87
+ "scheduler_warmup_steps": 1000,
88
+ "scheduler_decay_steps": 40000,
89
+ "scheduler_decay_lr": 2.5e-06
90
+ }
checkpoints/stage3_final/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:631b94a8ea3d279477ce0f79efc852d9c21bb41e483197aa0f77b19ba1f3b8e5
3
+ size 2575715312
config.json ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "tinyvla",
3
+ "n_obs_steps": 1,
4
+ "input_features": {
5
+ "observation.images.cam0": {
6
+ "type": "VISUAL",
7
+ "shape": [
8
+ 3,
9
+ 256,
10
+ 256
11
+ ]
12
+ },
13
+ "observation.images.cam1": {
14
+ "type": "VISUAL",
15
+ "shape": [
16
+ 3,
17
+ 256,
18
+ 256
19
+ ]
20
+ },
21
+ "observation.state": {
22
+ "type": "STATE",
23
+ "shape": [
24
+ 16
25
+ ]
26
+ }
27
+ },
28
+ "output_features": {
29
+ "action": {
30
+ "type": "ACTION",
31
+ "shape": [
32
+ 8
33
+ ]
34
+ }
35
+ },
36
+ "device": "cuda",
37
+ "use_amp": false,
38
+ "use_peft": false,
39
+ "push_to_hub": true,
40
+ "repo_id": null,
41
+ "private": null,
42
+ "tags": null,
43
+ "license": null,
44
+ "pretrained_path": null,
45
+ "pretrained_revision": null,
46
+ "chunk_size": 50,
47
+ "n_action_steps": 50,
48
+ "normalization_mapping": {
49
+ "VISUAL": "IDENTITY",
50
+ "STATE": "MEAN_STD",
51
+ "ACTION": "MEAN_STD"
52
+ },
53
+ "max_state_dim": 16,
54
+ "max_action_dim": 8,
55
+ "num_embodiments": 8,
56
+ "max_cameras": 3,
57
+ "image_size": 256,
58
+ "freeze_vision_encoder": false,
59
+ "lm_model_name": "Qwen/Qwen3.5-0.8B",
60
+ "lm_num_layers": 12,
61
+ "num_readout_tokens": 8,
62
+ "freeze_lm": false,
63
+ "tokenizer_max_length": 48,
64
+ "pad_language_to": "longest",
65
+ "expert_dim": 512,
66
+ "expert_layers": 12,
67
+ "expert_heads": 8,
68
+ "use_semantic_latent": true,
69
+ "use_spatial_tokens": true,
70
+ "spatial_vocab": 1024,
71
+ "spatial_loss_weight": 0.0,
72
+ "num_denoise_steps": 10,
73
+ "flow_beta_alpha": 1.5,
74
+ "flow_beta_beta": 1.0,
75
+ "min_period": 0.004,
76
+ "max_period": 4.0,
77
+ "staleness_prob": 0.0,
78
+ "staleness_max_s": 2.0,
79
+ "optimizer_lr": 0.0001,
80
+ "optimizer_betas": [
81
+ 0.9,
82
+ 0.95
83
+ ],
84
+ "optimizer_eps": 1e-08,
85
+ "optimizer_weight_decay": 1e-10,
86
+ "optimizer_grad_clip_norm": 10.0,
87
+ "scheduler_warmup_steps": 1000,
88
+ "scheduler_decay_steps": 40000,
89
+ "scheduler_decay_lr": 2.5e-06
90
+ }
configs/ablation_nolatent.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage-2 gate baseline: same mixture, 5k steps, nolatent ablation
2
+ output_dir: /home/alexw/tinyvla/outputs/ablation_nolatent
3
+ wandb: null
4
+ datasets:
5
+ - root_glob: /home/alexw/tinyvla_data/so101_v3/*
6
+ weight: 0.65
7
+ - repo_id: nvidia/BridgeData2_LeRobot_v3
8
+ root: /home/alexw/tinyvla_data/bridge_v3_10k
9
+ revision: main
10
+ episodes: 10000
11
+ weight: 0.35
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ use_semantic_latent: false
17
+ batch_size: 64
18
+ grad_accum: 2
19
+ num_workers: 12
20
+ lr: 1.0e-4
21
+ warmup_steps: 500
22
+ steps: 5000
23
+ log_freq: 50
24
+ save_freq: 2500
25
+ mixed_precision: bf16
configs/ablation_nospatial.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage-2 gate baseline: same mixture, 5k steps, nospatial ablation
2
+ output_dir: /home/alexw/tinyvla/outputs/ablation_nospatial
3
+ wandb: null
4
+ datasets:
5
+ - root_glob: /home/alexw/tinyvla_data/so101_v3/*
6
+ weight: 0.65
7
+ - repo_id: nvidia/BridgeData2_LeRobot_v3
8
+ root: /home/alexw/tinyvla_data/bridge_v3_10k
9
+ revision: main
10
+ episodes: 10000
11
+ weight: 0.35
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ use_spatial_tokens: false
17
+ batch_size: 64
18
+ grad_accum: 2
19
+ num_workers: 12
20
+ lr: 1.0e-4
21
+ warmup_steps: 500
22
+ steps: 5000
23
+ log_freq: 50
24
+ save_freq: 2500
25
+ mixed_precision: bf16
configs/libero_ft.yaml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LIBERO fine-tune from the Stage-2 pretrained checkpoint (XS-VLA/SmolVLA protocol).
2
+ # LIBERO = new embodiment (id 2): Franka 7-DoF EE @10fps, two 256^2 cameras.
3
+
4
+ output_dir: /home/alexw/tinyvla/outputs/libero_ft
5
+ wandb: null
6
+
7
+ datasets:
8
+ - repo_id: HuggingFaceVLA/libero
9
+ weight: 1.0
10
+ embodiment_id: 2
11
+
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ freeze_lm: true
17
+ freeze_vision_encoder: true
18
+
19
+ resume_from: /home/alexw/tinyvla/outputs/stage2_mixture/final
20
+ resume_step: 0
21
+
22
+ batch_size: 64
23
+ grad_accum: 1
24
+ num_workers: 12
25
+ lr: 5.0e-5
26
+ warmup_steps: 500
27
+ steps: 15000
28
+ grad_clip: 10.0
29
+ log_freq: 50
30
+ save_freq: 2500
31
+ mixed_precision: bf16
configs/libero_ft2.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LIBERO fine-tune v2: unfreeze the vision tower (sim renders are OOD for the
2
+ # real-world-pretrained encoder), longer schedule.
3
+
4
+ output_dir: /home/alexw/tinyvla/outputs/libero_ft2
5
+ wandb: null
6
+
7
+ datasets:
8
+ - repo_id: HuggingFaceVLA/libero
9
+ weight: 1.0
10
+ embodiment_id: 2
11
+
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ freeze_lm: true
17
+ freeze_vision_encoder: false
18
+
19
+ backbone_lr_mult: 0.1
20
+
21
+ resume_from: /home/alexw/tinyvla/outputs/stage2_mixture/final
22
+ resume_step: 0
23
+
24
+ batch_size: 48
25
+ grad_accum: 1
26
+ num_workers: 12
27
+ lr: 1.0e-4
28
+ warmup_steps: 1000
29
+ steps: 30000
30
+ grad_clip: 10.0
31
+ log_freq: 50
32
+ save_freq: 5000
33
+ mixed_precision: bf16
configs/libero_ft3.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LIBERO fine-tune v2: unfreeze the vision tower (sim renders are OOD for the
2
+ # real-world-pretrained encoder), longer schedule.
3
+
4
+ output_dir: /home/alexw/tinyvla/outputs/libero_ft3
5
+ wandb: null
6
+
7
+ datasets:
8
+ - repo_id: HuggingFaceVLA/libero
9
+ weight: 1.0
10
+ embodiment_id: 2
11
+
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ freeze_lm: true
17
+ freeze_vision_encoder: false
18
+
19
+ backbone_lr_mult: 0.1
20
+
21
+ resume_from: /home/alexw/tinyvla/outputs/stage3_distill/final
22
+ resume_step: 0
23
+
24
+ batch_size: 48
25
+ grad_accum: 1
26
+ num_workers: 12
27
+ lr: 1.0e-4
28
+ warmup_steps: 1000
29
+ steps: 30000
30
+ grad_clip: 10.0
31
+ log_freq: 50
32
+ save_freq: 5000
33
+ mixed_precision: bf16
configs/libero_ft4.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LIBERO fine-tune v4: unfreeze BOTH vision tower and language stack
2
+ # (12 truncated layers; vocab embeddings stay frozen) at 0.1x lr, longer schedule.
3
+
4
+ output_dir: /home/alexw/tinyvla/outputs/libero_ft4
5
+ wandb: null
6
+
7
+ datasets:
8
+ - repo_id: HuggingFaceVLA/libero
9
+ weight: 1.0
10
+ embodiment_id: 2
11
+
12
+ policy:
13
+ chunk_size: 50
14
+ n_action_steps: 50
15
+ image_size: 256
16
+ freeze_lm: false
17
+ freeze_vision_encoder: false
18
+
19
+ backbone_lr_mult: 0.1
20
+
21
+ resume_from: /home/alexw/tinyvla/outputs/stage3_distill/final
22
+ resume_step: 0
23
+
24
+ batch_size: 48
25
+ grad_accum: 1
26
+ num_workers: 12
27
+ lr: 1.0e-4
28
+ warmup_steps: 1000
29
+ steps: 40000
30
+ grad_clip: 10.0
31
+ log_freq: 50
32
+ save_freq: 5000
33
+ mixed_precision: bf16
configs/stage2_mixture.yaml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 2: small-scale mixture pretraining on one A6000.
2
+ # SO-100/101 community slice (converted v3.0) 0.65 + Bridge V2 0.35.
3
+ # Fractal dropped for MVP (v2.0, unconvertible in lerobot 0.6.1).
4
+
5
+ output_dir: /home/alexw/tinyvla/outputs/stage2_mixture
6
+ wandb: null # set a project name to enable
7
+
8
+ datasets:
9
+ - root_glob: /home/alexw/tinyvla_data/so101_v3/*
10
+ weight: 0.65
11
+ - repo_id: nvidia/BridgeData2_LeRobot_v3
12
+ root: /home/alexw/tinyvla_data/bridge_v3_10k
13
+ revision: main
14
+ episodes: 10000
15
+ weight: 0.35
16
+
17
+ policy:
18
+ chunk_size: 50
19
+ n_action_steps: 50
20
+ image_size: 256
21
+ freeze_lm: true
22
+ freeze_vision_encoder: true
23
+
24
+ batch_size: 64
25
+ grad_accum: 2
26
+ num_workers: 14
27
+ resume_from: /home/alexw/tinyvla/outputs/stage2_mixture/step_2000
28
+ resume_step: 2000
29
+ lr: 1.0e-4
30
+ warmup_steps: 1000
31
+ steps: 40000
32
+ grad_clip: 10.0
33
+ log_freq: 50
34
+ save_freq: 2000
35
+ mixed_precision: bf16
36
+ staleness_start_step: 20000
37
+ staleness_prob: 0.5
configs/stage3_distill.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 3: continue SO101+Bridge pretraining with the spatial-distillation
2
+ # aux CE loss (teacher labels from Qwen3.5-4B) and staleness aug from the start.
3
+
4
+ output_dir: /home/alexw/tinyvla/outputs/stage3_distill
5
+ wandb: null
6
+
7
+ spatial_labels_dir: /home/alexw/tinyvla_data/spatial_labels
8
+
9
+ datasets:
10
+ - root_glob: /home/alexw/tinyvla_data/so101_v3/*
11
+ weight: 0.65
12
+ - repo_id: nvidia/BridgeData2_LeRobot_v3
13
+ root: /home/alexw/tinyvla_data/bridge_v3_10k
14
+ revision: main
15
+ episodes: 10000
16
+ weight: 0.35
17
+
18
+ policy:
19
+ chunk_size: 50
20
+ n_action_steps: 50
21
+ image_size: 256
22
+ freeze_lm: true
23
+ freeze_vision_encoder: true
24
+ spatial_loss_weight: 0.15
25
+
26
+ resume_from: /home/alexw/tinyvla/outputs/stage2_mixture/final
27
+ resume_step: 0
28
+
29
+ batch_size: 64
30
+ grad_accum: 2
31
+ num_workers: 14
32
+ lr: 5.0e-5
33
+ warmup_steps: 500
34
+ steps: 20000
35
+ grad_clip: 10.0
36
+ log_freq: 50
37
+ save_freq: 2500
38
+ mixed_precision: bf16
39
+ staleness_start_step: 0
40
+ staleness_prob: 0.5
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:00712c0573cffd4af565a82d2b1e94f74be7d4de5ca9518228522cb311b47dc7
3
+ size 2573613872
scripts/diag_obs_gap.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Diagnose the env-obs adapter: compare policy predictions from env-rendered
3
+ observations vs dataset observations at the SAME init state.
4
+
5
+ If pred(dataset obs) is close to GT but pred(env obs) differs, the observation
6
+ adapter (image orientation/cameras/state) is the remaining gap.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ import numpy as np
14
+ import torch
15
+ from scipy.spatial.transform import Rotation
16
+
17
+
18
+ @torch.no_grad()
19
+ def main():
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--checkpoint", default="outputs/libero_ft2/step_15000")
22
+ parser.add_argument("--suite", default="libero_spatial")
23
+ parser.add_argument("--tasks", type=int, default=3)
24
+ args = parser.parse_args()
25
+
26
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset, LeRobotDatasetMetadata
27
+ from lerobot.envs.factory import make_env, make_env_config
28
+ from transformers import AutoTokenizer
29
+ from tinyvla.data.mixture import CanonicalSource
30
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
31
+
32
+ policy = TinyVLAPolicy.from_pretrained(args.checkpoint).cuda().eval()
33
+ cfg = policy.config
34
+ tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
35
+
36
+ meta = LeRobotDatasetMetadata("HuggingFaceVLA/libero")
37
+ ds = LeRobotDataset(
38
+ "HuggingFaceVLA/libero",
39
+ delta_timestamps={"action": [t / meta.fps for t in range(cfg.chunk_size)]},
40
+ video_backend="torchcodec",
41
+ )
42
+ src = CanonicalSource(ds, 2, cfg.image_size, cfg.max_state_dim, cfg.max_action_dim)
43
+ s_stats = meta.stats["observation.state"]
44
+ s_mean = torch.as_tensor(s_stats["mean"]).flatten().float()
45
+ s_std = torch.as_tensor(s_stats["std"]).flatten().float().clamp(min=1e-6)
46
+
47
+ env_cfg = make_env_config("libero", task=args.suite)
48
+ task_envs = make_env(env_cfg, n_envs=1)[args.suite]
49
+ env_by_task = {}
50
+ for tid, env in task_envs.items():
51
+ desc = env.get_attr("task_description")[0]
52
+ env_by_task[desc.strip().lower()] = (tid, env)
53
+
54
+ eps_meta = ds.meta.episodes
55
+ first_ep_by_task = {}
56
+ for ep in range(ds.num_episodes):
57
+ start = int(eps_meta["dataset_from_index"][ep])
58
+ task = ds[start]["task"].strip().lower()
59
+ if task in env_by_task and task not in first_ep_by_task:
60
+ first_ep_by_task[task] = ep
61
+
62
+ def tok_batch(task_text):
63
+ t = tok([task_text], padding=True, truncation=True,
64
+ max_length=cfg.tokenizer_max_length, return_tensors="pt")
65
+ return t["input_ids"].cuda(), t["attention_mask"].bool().cuda()
66
+
67
+ def env_to_batch(obs, task_text):
68
+ imgs = {}
69
+ for slot, key in (("cam0", "image"), ("cam1", "image2")):
70
+ x = torch.as_tensor(np.asarray(obs["pixels"][key]))[0].flip(0).flip(1)
71
+ x = x.permute(2, 0, 1).float() / 255.0
72
+ x = torch.nn.functional.interpolate(x[None], size=(cfg.image_size, cfg.image_size),
73
+ mode="bilinear", align_corners=False)[0]
74
+ imgs[slot] = x
75
+ rs = obs["robot_state"]
76
+ pos = np.asarray(rs["eef"]["pos"]).flatten()
77
+ quat = np.asarray(rs["eef"]["quat"]).flatten()
78
+ rotvec = Rotation.from_quat(quat).as_rotvec()
79
+ if rotvec[0] < 0:
80
+ th = np.linalg.norm(rotvec)
81
+ rotvec = rotvec * (th - 2 * np.pi) / th
82
+ grip = np.asarray(rs["gripper"]["qpos"]).flatten()
83
+ state = torch.tensor(np.concatenate([pos, rotvec, grip]), dtype=torch.float32)
84
+ state = (state - s_mean) / s_std
85
+ state = torch.nn.functional.pad(state, (0, cfg.max_state_dim - state.shape[-1]))
86
+ ids, mask = tok_batch(task_text)
87
+ return {
88
+ "observation.images.cam0": imgs["cam0"][None].cuda(),
89
+ "observation.images.cam1": imgs["cam1"][None].cuda(),
90
+ "observation.state": state[None].cuda(),
91
+ "observation.language.tokens": ids,
92
+ "observation.language.attention_mask": mask,
93
+ "embodiment_id": torch.tensor([2], device="cuda"),
94
+ }
95
+
96
+ def ds_to_batch(item):
97
+ ids, mask = tok_batch(item.pop("task"))
98
+ b = {k: v[None].cuda() for k, v in item.items() if torch.is_tensor(v)}
99
+ b["observation.language.tokens"] = ids
100
+ b["observation.language.attention_mask"] = mask
101
+ return b
102
+
103
+ for task, ep in list(first_ep_by_task.items())[: args.tasks]:
104
+ tid, env = env_by_task[task]
105
+ obs, _ = env.reset(seed=0)
106
+ start = int(eps_meta["dataset_from_index"][ep])
107
+ item = src[start]
108
+ gt = item["action"].clone()[None].cuda()
109
+
110
+ env_b = env_to_batch(obs, task)
111
+ ds_b = ds_to_batch(dict(item))
112
+
113
+ torch.manual_seed(0)
114
+ pred_env = policy.predict_action_chunk(env_b)
115
+ torch.manual_seed(0)
116
+ pred_ds = policy.predict_action_chunk(ds_b)
117
+
118
+ m = item["action_dim_mask"]
119
+ d_env_gt = ((pred_env[0, :, m].cpu() - gt[0, :, m].cpu()) ** 2).mean().sqrt().item()
120
+ d_ds_gt = ((pred_ds[0, :, m].cpu() - gt[0, :, m].cpu()) ** 2).mean().sqrt().item()
121
+ d_env_ds = ((pred_env[0, :, m] - pred_ds[0, :, m]) ** 2).mean().sqrt().item()
122
+
123
+ # also compare the raw inputs
124
+ img_gap = (env_b["observation.images.cam0"] - ds_b["observation.images.cam0"]).abs().mean().item()
125
+ st_gap = (env_b["observation.state"] - ds_b["observation.state"]).abs().max().item()
126
+ print(f"task {tid}: RMSE(pred_env, GT)={d_env_gt:.3f} | RMSE(pred_ds, GT)={d_ds_gt:.3f} "
127
+ f"| RMSE(pred_env, pred_ds)={d_env_ds:.3f} | img L1 gap={img_gap:.4f} | state max gap={st_gap:.2f}")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
scripts/download_subsets.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Download Stage-2 dataset subsets under a hard disk budget.
3
+
4
+ Usage:
5
+ python scripts/download_subsets.py --root ~/tinyvla_data [--dry-run]
6
+
7
+ Downloads metadata first, measures actual on-disk size of a small episode
8
+ sample, then extrapolates before committing to the full subset download.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ DISK_BUDGET_GB = 400
18
+
19
+ # (repo_id, num_episodes or None for all)
20
+ SUBSETS = [
21
+ ("HuggingFaceVLA/community_dataset_v1", None),
22
+ ("nvidia/BridgeData2_LeRobot_v3", 10_000),
23
+ ("IPEC-COMMUNITY/fractal20220817_data_lerobot", 8_000),
24
+ ]
25
+
26
+
27
+ def free_gb(path: Path) -> float:
28
+ return shutil.disk_usage(path).free / 1e9
29
+
30
+
31
+ def used_gb(path: Path) -> float:
32
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1e9
33
+
34
+
35
+ def main():
36
+ parser = argparse.ArgumentParser()
37
+ parser.add_argument("--root", type=Path, default=Path.home() / "tinyvla_data")
38
+ parser.add_argument("--dry-run", action="store_true")
39
+ parser.add_argument("--only", type=str, default=None, help="substring filter on repo_id")
40
+ args = parser.parse_args()
41
+
42
+ args.root.mkdir(parents=True, exist_ok=True)
43
+
44
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset, LeRobotDatasetMetadata
45
+
46
+ for repo_id, n_eps in SUBSETS:
47
+ if args.only and args.only not in repo_id:
48
+ continue
49
+ print(f"\n=== {repo_id} (episodes: {n_eps or 'all'}) ===")
50
+ meta = LeRobotDatasetMetadata(repo_id)
51
+ total_eps = meta.total_episodes
52
+ print(f"total episodes: {total_eps}, fps: {meta.fps}")
53
+
54
+ episodes = list(range(min(n_eps, total_eps))) if n_eps else None
55
+
56
+ # probe with 1% to estimate size
57
+ probe_n = max(10, (len(episodes) if episodes else total_eps) // 100)
58
+ probe_dir = args.root / "_probe" / repo_id.replace("/", "__")
59
+ if not args.dry_run and not probe_dir.exists():
60
+ LeRobotDataset(repo_id, root=probe_dir, episodes=list(range(probe_n)))
61
+ probe_gb = used_gb(probe_dir)
62
+ est_gb = probe_gb / probe_n * (len(episodes) if episodes else total_eps)
63
+ print(f"probe: {probe_n} eps = {probe_gb:.2f}GB -> estimated full subset {est_gb:.0f}GB")
64
+ if est_gb > free_gb(args.root) - 100 or used_gb(args.root) + est_gb > DISK_BUDGET_GB:
65
+ print(f"SKIP {repo_id}: would exceed budget ({DISK_BUDGET_GB}GB) or disk")
66
+ continue
67
+
68
+ if args.dry_run:
69
+ continue
70
+
71
+ target = args.root / repo_id.replace("/", "__")
72
+ LeRobotDataset(repo_id, root=target, episodes=episodes)
73
+ print(f"downloaded {repo_id}: {used_gb(target):.1f}GB, total used {used_gb(args.root):.1f}GB")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
scripts/eval_libero.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Closed-loop LIBERO evaluation for canonical-schema TinyVLA checkpoints.
3
+
4
+ Adapts env observations to the canonical schema the policy was trained on
5
+ (cam0/cam1, padded normalized state, embodiment_id) and unnormalizes the
6
+ predicted actions with the LIBERO dataset stats.
7
+
8
+ Usage:
9
+ python scripts/eval_libero.py --checkpoint outputs/libero_ft/final \
10
+ --suite libero_spatial --episodes 20 [--refresh-s 1.0]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from pathlib import Path
17
+
18
+ import torch
19
+
20
+
21
+ def make_normalizer(repo_id="HuggingFaceVLA/libero"):
22
+ from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata
23
+
24
+ meta = LeRobotDatasetMetadata(repo_id)
25
+ stats = meta.stats
26
+
27
+ def norm(key, x):
28
+ s = stats[key]
29
+ mean = torch.as_tensor(s["mean"], dtype=torch.float32, device=x.device)
30
+ std = torch.as_tensor(s["std"], dtype=torch.float32, device=x.device).clamp(min=1e-6)
31
+ return (x - mean) / std
32
+
33
+ def unnorm_action(x):
34
+ s = stats["action"]
35
+ mean = torch.as_tensor(s["mean"], dtype=torch.float32, device=x.device)
36
+ std = torch.as_tensor(s["std"], dtype=torch.float32, device=x.device)
37
+ return x * std + mean
38
+
39
+ return norm, unnorm_action, meta
40
+
41
+
42
+ @torch.no_grad()
43
+ def main():
44
+ parser = argparse.ArgumentParser()
45
+ parser.add_argument("--checkpoint", type=Path, required=True)
46
+ parser.add_argument("--suite", default="libero_spatial",
47
+ choices=["libero_spatial", "libero_object", "libero_goal", "libero_10", "libero_90"])
48
+ parser.add_argument("--episodes", type=int, default=20)
49
+ parser.add_argument("--embodiment-id", type=int, default=2)
50
+ parser.add_argument("--refresh-s", type=float, default=None,
51
+ help="if set, refresh the semantic latent only every N seconds (dual-rate mode)")
52
+ parser.add_argument("--max-steps", type=int, default=520)
53
+ parser.add_argument("--n-action-steps", type=int, default=None,
54
+ help="execute only first N actions of each chunk before re-planning")
55
+ parser.add_argument("--action-repeat", type=int, default=1,
56
+ help="env steps per predicted action (dataset 10fps vs env 20Hz -> 2)")
57
+ parser.add_argument("--save-video-dir", type=Path, default=None,
58
+ help="save per-episode mp4s of the agentview camera here")
59
+ args = parser.parse_args()
60
+
61
+ import numpy as np
62
+ from lerobot.envs.factory import make_env, make_env_config
63
+ from transformers import AutoTokenizer
64
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
65
+
66
+ policy = TinyVLAPolicy.from_pretrained(args.checkpoint).cuda().eval()
67
+ cfg = policy.config
68
+ if args.n_action_steps:
69
+ cfg.n_action_steps = args.n_action_steps
70
+ tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
71
+ norm, unnorm_action, meta = make_normalizer()
72
+ fps = meta.fps
73
+
74
+ env_cfg = make_env_config("libero", task=args.suite)
75
+ envs_dict = make_env(env_cfg, n_envs=1)
76
+ task_envs = envs_dict[args.suite] # {task_id: vec_env}
77
+
78
+ from scipy.spatial.transform import Rotation
79
+
80
+ def to_canonical(obs, task_text, latent=None):
81
+ imgs = {}
82
+ for slot, key in (("cam0", "image"), ("cam1", "image2")):
83
+ x = torch.as_tensor(np.asarray(obs["pixels"][key]))
84
+ if x.dim() == 4: # (1, H, W, C)
85
+ x = x[0]
86
+ # robosuite renders 180-degree rotated relative to the recorded dataset
87
+ x = x.flip(0).flip(1)
88
+ x = x.permute(2, 0, 1).float() / 255.0
89
+ if x.shape[-2:] != (cfg.image_size, cfg.image_size):
90
+ x = torch.nn.functional.interpolate(
91
+ x[None], size=(cfg.image_size, cfg.image_size), mode="bilinear", align_corners=False
92
+ )[0]
93
+ imgs[slot] = x
94
+
95
+ # dataset convention (OpenVLA-style): [eef pos(3), eef axis-angle(3), gripper qpos(2)]
96
+ rs = obs["robot_state"]
97
+ pos = np.asarray(rs["eef"]["pos"]).flatten()
98
+ quat = np.asarray(rs["eef"]["quat"]).flatten() # robosuite: (x, y, z, w)
99
+ rotvec = Rotation.from_quat(quat).as_rotvec()
100
+ # canonicalize antipodal representation to match dataset convention
101
+ # (dataset uses rotvec with positive x-component, ~+pi for downward gripper)
102
+ if rotvec[0] < 0:
103
+ theta = np.linalg.norm(rotvec)
104
+ if theta > 1e-6:
105
+ rotvec = rotvec * (theta - 2 * np.pi) / theta
106
+ grip = np.asarray(rs["gripper"]["qpos"]).flatten()
107
+ state = torch.tensor(np.concatenate([pos, rotvec, grip]), dtype=torch.float32)
108
+ state = norm("observation.state", state)
109
+ state = torch.nn.functional.pad(state, (0, cfg.max_state_dim - state.shape[-1]))
110
+
111
+ t = tok([task_text], padding=True, truncation=True,
112
+ max_length=cfg.tokenizer_max_length, return_tensors="pt")
113
+ batch = {
114
+ "observation.images.cam0": imgs["cam0"][None].cuda(),
115
+ "observation.images.cam1": imgs["cam1"][None].cuda(),
116
+ "observation.state": state[None].cuda(),
117
+ "observation.language.tokens": t["input_ids"].cuda(),
118
+ "observation.language.attention_mask": t["attention_mask"].bool().cuda(),
119
+ "embodiment_id": torch.tensor([args.embodiment_id], device="cuda"),
120
+ }
121
+ if latent is not None:
122
+ batch["semantic_latent"] = latent
123
+ return batch
124
+
125
+ action_dim = meta.features["action"]["shape"][0]
126
+ total, succ = 0, 0
127
+ per_task = {}
128
+
129
+ task_ids = sorted(task_envs.keys())
130
+ ep_plan = [(tid, i) for i in range((args.episodes + len(task_ids) - 1) // len(task_ids)) for tid in task_ids]
131
+ ep_plan = ep_plan[: args.episodes]
132
+
133
+ if args.save_video_dir:
134
+ args.save_video_dir.mkdir(parents=True, exist_ok=True)
135
+
136
+ for ep, (tid, rep) in enumerate(ep_plan):
137
+ env = task_envs[tid]
138
+ obs, info = env.reset(seed=1000 + rep)
139
+ frames = [] if args.save_video_dir else None
140
+ try:
141
+ task_text = env.get_attr("task_description")[0]
142
+ except Exception:
143
+ task_text = getattr(getattr(env, "envs", [None])[0], "task_description", "")
144
+ policy.reset()
145
+ latent = None
146
+ last_refresh = -1e9
147
+ done = False
148
+ step_i = 0
149
+ ep_succ = False
150
+ while not done and step_i < args.max_steps:
151
+ t_now = step_i / fps
152
+ batch = to_canonical(obs, task_text)
153
+ if args.refresh_s is not None:
154
+ if t_now - last_refresh >= args.refresh_s:
155
+ latent = policy._semantic_latent(batch)
156
+ last_refresh = t_now
157
+ batch["semantic_latent"] = latent
158
+ act_norm = policy.select_action(batch) # (1, max_action_dim) normalized padded
159
+ act = unnorm_action(act_norm[0, :action_dim].cpu()).clamp(-1, 1)
160
+ for _ in range(args.action_repeat):
161
+ try:
162
+ obs, reward, terminated, truncated, info = env.step(act.numpy()[None])
163
+ except ValueError: # stepped into env's internal horizon
164
+ done = True
165
+ break
166
+ done = bool(terminated[0] or truncated[0])
167
+ if info.get("is_success") is not None:
168
+ ep_succ = ep_succ or bool(np.asarray(info["is_success"]).flatten()[0])
169
+ if frames is not None:
170
+ frames.append(np.asarray(obs["pixels"]["image"])[0][::-1, ::-1])
171
+ step_i += 1
172
+ if done or step_i >= args.max_steps:
173
+ done = done or step_i >= args.max_steps
174
+ break
175
+ total += 1
176
+ succ += int(ep_succ)
177
+ per_task.setdefault(task_text[:50], []).append(int(ep_succ))
178
+ print(f"ep {ep}: {'SUCCESS' if ep_succ else 'fail'} ({step_i} steps) | {task_text[:60]}")
179
+ if frames:
180
+ import imageio.v2 as imageio
181
+
182
+ tag = "succ" if ep_succ else "fail"
183
+ path = args.save_video_dir / f"ep{ep:02d}_task{tid}_{tag}.mp4"
184
+ imageio.mimwrite(path, frames, fps=20, quality=7)
185
+
186
+ print(f"\n=== {args.suite} | {args.checkpoint} | refresh={args.refresh_s} ===")
187
+ print(f"success rate: {succ}/{total} = {succ/total:.1%}")
188
+ for t, v in per_task.items():
189
+ print(f" {sum(v)}/{len(v)} {t}")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
scripts/eval_offline.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Offline eval for canonical-schema checkpoints (stage2+).
3
+
4
+ Reports action-chunk MSE (normalized space) on held-out episodes, the
5
+ per-timestep error curve, and stale-latent degradation.
6
+
7
+ Usage:
8
+ python scripts/eval_offline.py \
9
+ --checkpoint outputs/stage2_mixture/final \
10
+ --repo-id VoicAndrei__so100_kitchen \
11
+ --root ~/tinyvla_data/so101_v3/VoicAndrei__so100_kitchen \
12
+ --episodes 8 --stale-s 0 1 2 [--embodiment-id 0] [--no-latent]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ from pathlib import Path
19
+
20
+ import torch
21
+
22
+
23
+ @torch.no_grad()
24
+ def main():
25
+ parser = argparse.ArgumentParser()
26
+ parser.add_argument("--checkpoint", type=Path, required=True)
27
+ parser.add_argument("--repo-id", required=True)
28
+ parser.add_argument("--root", default=None)
29
+ parser.add_argument("--episodes", type=int, default=8)
30
+ parser.add_argument("--stride", type=int, default=30)
31
+ parser.add_argument("--stale-s", type=float, nargs="*", default=[0.0, 1.0, 2.0])
32
+ parser.add_argument("--embodiment-id", type=int, default=0)
33
+ parser.add_argument("--split", choices=["first", "last"], default="last")
34
+ args = parser.parse_args()
35
+
36
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset, LeRobotDatasetMetadata
37
+ from transformers import AutoTokenizer
38
+ from tinyvla.data.mixture import CanonicalSource
39
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
40
+
41
+ policy = TinyVLAPolicy.from_pretrained(args.checkpoint).cuda().eval()
42
+ cfg = policy.config
43
+ chunk = cfg.chunk_size
44
+ tok = AutoTokenizer.from_pretrained(cfg.lm_model_name)
45
+
46
+ meta = LeRobotDatasetMetadata(args.repo_id, root=args.root)
47
+ ds = LeRobotDataset(
48
+ args.repo_id,
49
+ root=args.root,
50
+ delta_timestamps={"action": [t / meta.fps for t in range(chunk)]},
51
+ video_backend="torchcodec",
52
+ )
53
+ src = CanonicalSource(
54
+ ds, args.embodiment_id, cfg.image_size, cfg.max_state_dim, cfg.max_action_dim
55
+ )
56
+
57
+ if args.split == "first":
58
+ eps = list(range(args.episodes))
59
+ else:
60
+ eps = list(range(ds.num_episodes - args.episodes, ds.num_episodes))
61
+
62
+ def to_batch(item):
63
+ t = tok([item.pop("task")], padding=True, truncation=True,
64
+ max_length=cfg.tokenizer_max_length, return_tensors="pt")
65
+ b = {k: v[None].cuda() if torch.is_tensor(v) else v for k, v in item.items()}
66
+ b["observation.language.tokens"] = t["input_ids"].cuda()
67
+ b["observation.language.attention_mask"] = t["attention_mask"].bool().cuda()
68
+ return b
69
+
70
+ results = {s: [] for s in args.stale_s}
71
+ per_t = torch.zeros(chunk)
72
+ n_chunks = 0
73
+
74
+ for ep in eps:
75
+ start = int(ds.meta.episodes["dataset_from_index"][ep])
76
+ end = int(ds.meta.episodes["dataset_to_index"][ep])
77
+ for idx in range(start, end - 1, args.stride):
78
+ if idx >= len(src):
79
+ break
80
+ item = src[idx - 0]
81
+ gt = item["action"].clone() # (chunk, A) normalized
82
+ mask = item["action_dim_mask"].clone()
83
+ pad = item.get("action_is_pad")
84
+ batch = to_batch(dict(item))
85
+ for stale_s in args.stale_s:
86
+ b = dict(batch)
87
+ if stale_s > 0:
88
+ stale_idx = max(start, idx - int(stale_s * ds.fps))
89
+ stale_item = src[stale_idx]
90
+ sb = to_batch(dict(stale_item))
91
+ b["semantic_latent"] = policy._semantic_latent(sb)
92
+ pred = policy.predict_action_chunk(b)[0].cpu() # (chunk, A) normalized
93
+ err = (pred[:, mask] - gt[:, mask]) ** 2
94
+ if pad is not None:
95
+ err = err[~pad]
96
+ mse = err.mean().item()
97
+ results[stale_s].append(mse)
98
+ if stale_s == 0:
99
+ e = ((pred - gt) ** 2)[:, mask].mean(dim=-1)
100
+ if pad is not None:
101
+ e = e * (~pad).float()
102
+ per_t += e
103
+ n_chunks += 1
104
+
105
+ print(f"\n=== {args.repo_id} | {len(eps)} {args.split} episodes | {n_chunks} chunks | ckpt {args.checkpoint} ===")
106
+ for s, vals in results.items():
107
+ print(f"stale {s:.0f}s: normalized chunk MSE {sum(vals)/len(vals):.4f}")
108
+ curve = (per_t / max(n_chunks, 1)).sqrt()
109
+ print("per-timestep normalized RMSE (t=0,10,25,49):",
110
+ [round(curve[i].item(), 3) for i in (0, 10, 25, 49)])
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
scripts/label_spatial.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Stage 3: spatial distillation labels from a Qwen3-VL teacher (XS-VLA style).
3
+
4
+ For every Nth frame of each episode, ask the teacher for the bounding box of
5
+ the task-relevant object; quantize the box center onto a GRID x GRID map and
6
+ store the cell index. The student later learns a linear classifier over its
7
+ fast-path spatial tokens with CE x 0.15 on labeled frames.
8
+
9
+ Output: parquet with columns (dataset, episode_index, frame_index, cell,
10
+ cx, cy, confidence_ok) at ~/tinyvla_data/spatial_labels/<dataset>.parquet
11
+
12
+ Usage:
13
+ python scripts/label_spatial.py --teacher Qwen/Qwen3-VL-4B-Instruct \
14
+ --frame-stride 10 --episode-frac 0.4 [--datasets-limit 2] [--pilot 20]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+
24
+ import torch
25
+
26
+ GRID = 32
27
+ OUT_DIR = Path.home() / "tinyvla_data" / "spatial_labels"
28
+
29
+ POINT_RE = re.compile(r"\[?\s*(\d+)\s*,\s*(\d+)\s*\]?")
30
+
31
+ PROMPT = (
32
+ "Task: {task}\n"
33
+ "Look at the image. Where is the single object the robot must interact with next "
34
+ "to accomplish this task? Answer with ONLY its center point as [x, y] in 0-1000 "
35
+ "normalized coordinates. If unsure, output [0,0]."
36
+ )
37
+
38
+
39
+ def main():
40
+ parser = argparse.ArgumentParser()
41
+ parser.add_argument("--teacher", default="Qwen/Qwen3.5-4B")
42
+ parser.add_argument("--data-root", type=Path, default=Path.home() / "tinyvla_data/so101_v3")
43
+ parser.add_argument("--frame-stride", type=int, default=10)
44
+ parser.add_argument("--episode-frac", type=float, default=0.4)
45
+ parser.add_argument("--datasets-limit", type=int, default=None)
46
+ parser.add_argument("--pilot", type=int, default=None, help="label only N frames total, print results")
47
+ parser.add_argument("--batch-size", type=int, default=16)
48
+ args = parser.parse_args()
49
+
50
+ import pyarrow as pa
51
+ import pyarrow.parquet as pq
52
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
53
+ from transformers import AutoModelForImageTextToText, AutoProcessor
54
+
55
+ model = AutoModelForImageTextToText.from_pretrained(
56
+ args.teacher, dtype=torch.bfloat16, device_map="cuda"
57
+ )
58
+ proc = AutoProcessor.from_pretrained(args.teacher)
59
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
60
+
61
+ roots = sorted(args.data_root.iterdir())
62
+ if args.datasets_limit:
63
+ roots = roots[: args.datasets_limit]
64
+
65
+ total_done = 0
66
+ for root in roots:
67
+ if not (root / "meta/info.json").exists():
68
+ continue
69
+ out_path = OUT_DIR / f"{root.name}.parquet"
70
+ if out_path.exists() and not args.pilot:
71
+ continue
72
+ ds = LeRobotDataset(root.name, root=root, video_backend="torchcodec")
73
+ image_key = sorted(k for k in ds.meta.features if k.startswith("observation.images"))[0]
74
+ n_eps = max(1, int(ds.num_episodes * args.episode_frac))
75
+
76
+ rows = []
77
+ pending = [] # (ep, fi, image_pil, task)
78
+
79
+ def flush():
80
+ nonlocal total_done
81
+ if not pending:
82
+ return
83
+ msgs = [
84
+ [{"role": "user", "content": [
85
+ {"type": "image", "image": img},
86
+ {"type": "text", "text": PROMPT.format(task=task)},
87
+ ]}]
88
+ for _, _, img, task in pending
89
+ ]
90
+ texts = [
91
+ proc.apply_chat_template(
92
+ m, tokenize=False, add_generation_prompt=True, enable_thinking=False
93
+ )
94
+ for m in msgs
95
+ ]
96
+ images = [[p[2]] for p in pending]
97
+ inputs = proc(text=texts, images=images, return_tensors="pt", padding=True).to("cuda")
98
+ with torch.no_grad():
99
+ out = model.generate(**inputs, max_new_tokens=16, do_sample=False)
100
+ answers = proc.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)
101
+ for (ep, fi, _, _), ans in zip(pending, answers):
102
+ m = POINT_RE.search(ans)
103
+ ok = False
104
+ cell, cx, cy = -1, -1.0, -1.0
105
+ if m:
106
+ px, py = (int(g) for g in m.groups())
107
+ if 0 < px <= 1000 and 0 < py <= 1000:
108
+ cx, cy = px / 1000.0, py / 1000.0
109
+ gx, gy = min(GRID - 1, int(cx * GRID)), min(GRID - 1, int(cy * GRID))
110
+ cell = gy * GRID + gx
111
+ ok = True
112
+ rows.append({"dataset": root.name, "episode_index": ep, "frame_index": fi,
113
+ "cell": cell, "cx": cx, "cy": cy, "confidence_ok": ok})
114
+ if args.pilot:
115
+ print(f"ep{ep} f{fi}: '{ans.strip()[:60]}' -> cell {cell} ({cx:.2f},{cy:.2f})")
116
+ total_done += len(pending)
117
+ pending.clear()
118
+
119
+ from torchvision.transforms.functional import to_pil_image
120
+ import torch.nn.functional as F
121
+
122
+ for ep in range(n_eps):
123
+ start = int(ds.meta.episodes["dataset_from_index"][ep])
124
+ end = int(ds.meta.episodes["dataset_to_index"][ep])
125
+ for idx in range(start, end, args.frame_stride):
126
+ item = ds[idx]
127
+ # label on the same 256^2 view the student sees; ~8x fewer
128
+ # teacher vision tokens than full res
129
+ small = F.interpolate(
130
+ item[image_key][None].clamp(0, 1), size=(256, 256),
131
+ mode="bilinear", align_corners=False,
132
+ )[0]
133
+ img = to_pil_image(small)
134
+ pending.append((ep, idx - start, img, item.get("task") or ""))
135
+ if len(pending) >= args.batch_size:
136
+ flush()
137
+ if args.pilot and total_done + len(pending) >= args.pilot:
138
+ flush()
139
+ print(f"pilot done: {total_done} frames")
140
+ return
141
+ flush()
142
+ pq.write_table(pa.Table.from_pylist(rows), out_path)
143
+ ok_rate = sum(r["confidence_ok"] for r in rows) / max(len(rows), 1)
144
+ print(f"{root.name}: {len(rows)} labels -> {out_path} (ok {ok_rate:.1%})")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
scripts/prepare_community_v3.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Prepare the SO-100/101 slice of HuggingFaceVLA/community_dataset_v3.
3
+
4
+ Steps:
5
+ catalog — fetch all sub-dataset info.json, write catalog.json
6
+ select — filter SO100/101 single-arm, rank by episodes, write selection.json
7
+ convert — download each selected subdir + convert v2.1 -> v3.0 locally
8
+
9
+ Usage:
10
+ python scripts/prepare_community_v3.py catalog
11
+ python scripts/prepare_community_v3.py select --target-episodes 12000
12
+ python scripts/prepare_community_v3.py convert [--limit N]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import shutil
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from pathlib import Path
22
+
23
+ AGG_REPO = "HuggingFaceVLA/community_dataset_v3"
24
+ DISK_BUDGET_GB = 400
25
+ DATA_ROOT = Path.home() / "tinyvla_data"
26
+ CATALOG = DATA_ROOT / "community_v3_catalog.json"
27
+ SELECTION = DATA_ROOT / "community_v3_selection.json"
28
+ CONVERTED_DIR = DATA_ROOT / "so101_v3"
29
+ SO_ROBOTS = {"so100", "so101", "so100_follower", "so101_follower"}
30
+
31
+
32
+ def cmd_catalog(args):
33
+ from huggingface_hub import HfApi, hf_hub_download
34
+
35
+ api = HfApi()
36
+ files = api.list_repo_files(AGG_REPO, repo_type="dataset")
37
+ subs = sorted(f.rsplit("/meta/info.json", 1)[0] for f in files if f.endswith("meta/info.json"))
38
+ print(f"{len(subs)} sub-datasets")
39
+
40
+ def fetch(sub):
41
+ try:
42
+ p = hf_hub_download(AGG_REPO, f"{sub}/meta/info.json", repo_type="dataset")
43
+ info = json.load(open(p))
44
+ return sub, {
45
+ "robot_type": info.get("robot_type"),
46
+ "episodes": info.get("total_episodes"),
47
+ "frames": info.get("total_frames"),
48
+ "fps": info.get("fps"),
49
+ "version": info.get("codebase_version"),
50
+ "action_shape": info.get("features", {}).get("action", {}).get("shape"),
51
+ "cameras": [k for k in info.get("features", {}) if k.startswith("observation.images")],
52
+ }
53
+ except Exception as e:
54
+ return sub, {"error": str(e)[:100]}
55
+
56
+ catalog = {}
57
+ with ThreadPoolExecutor(16) as ex:
58
+ futs = [ex.submit(fetch, s) for s in subs]
59
+ for i, f in enumerate(as_completed(futs)):
60
+ sub, meta = f.result()
61
+ catalog[sub] = meta
62
+ if (i + 1) % 100 == 0:
63
+ print(f"{i+1}/{len(subs)}")
64
+
65
+ DATA_ROOT.mkdir(parents=True, exist_ok=True)
66
+ CATALOG.write_text(json.dumps(catalog, indent=1))
67
+ print(f"wrote {CATALOG}")
68
+
69
+
70
+ def cmd_select(args):
71
+ catalog = json.loads(CATALOG.read_text())
72
+ rows = [
73
+ (sub, m)
74
+ for sub, m in catalog.items()
75
+ if m.get("robot_type") in SO_ROBOTS
76
+ and m.get("action_shape") == [6]
77
+ and m.get("fps") == 30
78
+ and m.get("episodes")
79
+ and m.get("cameras")
80
+ ]
81
+ # prefer larger datasets: fewer conversions per episode
82
+ rows.sort(key=lambda r: -r[1]["episodes"])
83
+ picked, total = [], 0
84
+ for sub, m in rows:
85
+ if total >= args.target_episodes:
86
+ break
87
+ picked.append({"sub": sub, **m})
88
+ total += m["episodes"]
89
+ print(f"{len(rows)} eligible; picked {len(picked)} datasets, {total} episodes")
90
+ SELECTION.write_text(json.dumps(picked, indent=1))
91
+ print(f"wrote {SELECTION}")
92
+
93
+
94
+ def free_gb(path: Path) -> float:
95
+ return shutil.disk_usage(path).free / 1e9
96
+
97
+
98
+ def used_gb_cached(path: Path) -> float:
99
+ import subprocess
100
+
101
+ out = subprocess.run(["du", "-s", "--block-size=1G", str(path)], capture_output=True, text=True)
102
+ return float(out.stdout.split()[0]) if out.returncode == 0 else 0.0
103
+
104
+
105
+ def cmd_convert(args):
106
+ from huggingface_hub import snapshot_download
107
+ from lerobot.scripts.convert_dataset_v21_to_v30 import convert_dataset
108
+
109
+ picked = json.loads(SELECTION.read_text())
110
+ if args.limit:
111
+ picked = picked[: args.limit]
112
+ CONVERTED_DIR.mkdir(parents=True, exist_ok=True)
113
+ raw_dir = DATA_ROOT / "_raw_v21"
114
+ done, failed = 0, []
115
+ for item in picked:
116
+ sub = item["sub"]
117
+ name = sub.replace("/", "__")
118
+ out = CONVERTED_DIR / name
119
+ if (out / "meta" / "info.json").exists():
120
+ done += 1
121
+ continue
122
+ local = raw_dir / name
123
+ try:
124
+ snapshot_download(
125
+ AGG_REPO,
126
+ repo_type="dataset",
127
+ allow_patterns=[f"{sub}/*"],
128
+ local_dir=raw_dir / "_dl",
129
+ )
130
+ src = raw_dir / "_dl" / sub
131
+ if local.exists():
132
+ shutil.rmtree(local)
133
+ shutil.move(str(src), str(local))
134
+ convert_dataset(repo_id=name, root=local, push_to_hub=False, force_conversion=True)
135
+ # converter writes v3.0 in place at root
136
+ shutil.move(str(local), str(out))
137
+ # drop the v2.1 originals the converter stashes as <root>_old
138
+ old = local.parent / (local.name + "_old")
139
+ if old.exists():
140
+ shutil.rmtree(old)
141
+ done += 1
142
+ print(f"[{done}/{len(picked)}] {sub}: converted -> {out} (free {free_gb(DATA_ROOT):.0f}GB)")
143
+ if free_gb(DATA_ROOT) < 150 or used_gb_cached(DATA_ROOT) > DISK_BUDGET_GB:
144
+ print("STOP: disk budget reached")
145
+ break
146
+ except Exception as e:
147
+ failed.append(sub)
148
+ print(f"FAIL {sub}: {type(e).__name__}: {str(e)[:200]}")
149
+ print(f"done={done} failed={len(failed)}")
150
+ if failed:
151
+ (DATA_ROOT / "convert_failures.json").write_text(json.dumps(failed))
152
+
153
+
154
+ if __name__ == "__main__":
155
+ parser = argparse.ArgumentParser()
156
+ sub = parser.add_subparsers(dest="cmd", required=True)
157
+ sub.add_parser("catalog")
158
+ p_sel = sub.add_parser("select")
159
+ p_sel.add_argument("--target-episodes", type=int, default=12_000)
160
+ p_conv = sub.add_parser("convert")
161
+ p_conv.add_argument("--limit", type=int, default=None)
162
+ args = parser.parse_args()
163
+ {"catalog": cmd_catalog, "select": cmd_select, "convert": cmd_convert}[args.cmd](args)
scripts/replay_libero.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Replay ground-truth dataset actions in the LIBERO env.
3
+
4
+ Diagnoses action-convention correctness independently of the policy: if GT
5
+ actions from a matching task episode achieve success (at least sometimes),
6
+ the env adapter/action semantics are right and remaining failures are model
7
+ quality; if GT never succeeds, the action mapping is wrong.
8
+
9
+ Usage:
10
+ python scripts/replay_libero.py --suite libero_spatial --episodes 5 --action-repeat 2
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+
17
+ import numpy as np
18
+ import torch
19
+
20
+
21
+ def main():
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--suite", default="libero_spatial")
24
+ parser.add_argument("--episodes", type=int, default=5)
25
+ parser.add_argument("--action-repeat", type=int, default=2)
26
+ args = parser.parse_args()
27
+
28
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
29
+ from lerobot.envs.factory import make_env, make_env_config
30
+
31
+ ds = LeRobotDataset("HuggingFaceVLA/libero", video_backend="torchcodec")
32
+ env_cfg = make_env_config("libero", task=args.suite)
33
+ task_envs = make_env(env_cfg, n_envs=1)[args.suite]
34
+
35
+ # map task_description -> env
36
+ env_by_task = {}
37
+ for tid, env in task_envs.items():
38
+ try:
39
+ desc = env.get_attr("task_description")[0]
40
+ except Exception:
41
+ desc = getattr(env.envs[0], "task_description", "")
42
+ env_by_task[desc.strip().lower()] = (tid, env)
43
+
44
+ # first dataset episode per task <-> env's first reset (init_state_id=0),
45
+ # assuming dataset episode order preserves LIBERO demo order within a task
46
+ eps_meta = ds.meta.episodes
47
+ first_ep_by_task = {}
48
+ for ep in range(ds.num_episodes):
49
+ start = int(eps_meta["dataset_from_index"][ep])
50
+ task = ds[start]["task"].strip().lower()
51
+ if task in env_by_task and task not in first_ep_by_task:
52
+ first_ep_by_task[task] = ep
53
+
54
+ replayed, succ = 0, 0
55
+ for task, ep in list(first_ep_by_task.items())[: args.episodes]:
56
+ start = int(eps_meta["dataset_from_index"][ep])
57
+ end = int(eps_meta["dataset_to_index"][ep])
58
+ tid, env = env_by_task[task]
59
+ obs, info = env.reset(seed=0) # fresh env: init_state_id starts at 0
60
+ ep_succ = False
61
+ for idx in range(start, end):
62
+ act = ds[idx]["action"].numpy()
63
+ done = False
64
+ for _ in range(args.action_repeat):
65
+ try:
66
+ obs, reward, term, trunc, info = env.step(act[None])
67
+ except ValueError:
68
+ done = True
69
+ break
70
+ if info.get("is_success") is not None:
71
+ ep_succ = ep_succ or bool(np.asarray(info["is_success"]).flatten()[0])
72
+ if term[0] or trunc[0]:
73
+ done = True
74
+ break
75
+ if done or ep_succ:
76
+ break
77
+ replayed += 1
78
+ succ += int(ep_succ)
79
+ print(f"ep {ep} (task {tid}): {'SUCCESS' if ep_succ else 'fail'} | {task[:60]}")
80
+
81
+ print(f"\nGT replay success: {succ}/{replayed}")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
scripts/train.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Stage-2+ training loop: weighted multi-dataset mixture + accelerate.
3
+
4
+ Thin replacement for lerobot-train adding:
5
+ - weighted sampling across LeRobotDatasets (per-dataset embodiment ids)
6
+ - staleness augmentation (Stage 2b)
7
+ - spatial-distillation aux loss (Stage 3)
8
+
9
+ Usage:
10
+ accelerate launch scripts/train.py --config configs/stage2_mixture.yaml
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import math
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import torch
21
+ import yaml
22
+
23
+
24
+ def make_policy(cfg: dict):
25
+ """Canonical-schema policy: two fixed camera slots, padded state/action."""
26
+ from lerobot.configs import FeatureType, PolicyFeature
27
+ from tinyvla.configuration_tinyvla import TinyVLAConfig
28
+ from tinyvla.modeling_tinyvla import TinyVLAPolicy
29
+
30
+ pcfg = TinyVLAConfig(**cfg.get("policy", {}))
31
+ s = pcfg.image_size
32
+ pcfg.input_features = {
33
+ "observation.images.cam0": PolicyFeature(type=FeatureType.VISUAL, shape=(3, s, s)),
34
+ "observation.images.cam1": PolicyFeature(type=FeatureType.VISUAL, shape=(3, s, s)),
35
+ "observation.state": PolicyFeature(type=FeatureType.STATE, shape=(pcfg.max_state_dim,)),
36
+ }
37
+ pcfg.output_features = {
38
+ "action": PolicyFeature(type=FeatureType.ACTION, shape=(pcfg.max_action_dim,)),
39
+ }
40
+ pcfg.validate_features()
41
+ return TinyVLAPolicy(pcfg), pcfg
42
+
43
+
44
+ def main():
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--config", type=Path, required=True)
47
+ args = parser.parse_args()
48
+ cfg = yaml.safe_load(args.config.read_text())
49
+
50
+ from accelerate import Accelerator
51
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
52
+
53
+ accelerator = Accelerator(mixed_precision=cfg.get("mixed_precision", "bf16"))
54
+
55
+ # ---- datasets ------------------------------------------------------
56
+ # spec forms:
57
+ # {repo_id, weight, root?, episodes?, revision?} — one dataset
58
+ # {root_glob, weight, embodiment_group?} — local converted dirs,
59
+ # weight is split across matches proportionally to episode count
60
+ datasets, weights, names, embodiment_ids = [], [], [], []
61
+ chunk = cfg["policy"]["chunk_size"]
62
+
63
+ def add(src, w, name, emb_id):
64
+ datasets.append(src)
65
+ weights.append(w)
66
+ names.append(name)
67
+ embodiment_ids.append(emb_id)
68
+ accelerator.print(
69
+ f"dataset[{len(datasets)-1}] {name}: eps={src.ds.num_episodes} w={w:.4f} emb={emb_id}"
70
+ )
71
+
72
+ policy, pcfg = make_policy(cfg)
73
+
74
+ from tinyvla.data.mixture import CanonicalSource, WeightedMixtureDataset
75
+
76
+ from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata
77
+
78
+ def make_ds(repo_id, root=None, episodes=None, revision=None):
79
+ # delta_timestamps must be set at construction (it feeds DatasetReader),
80
+ # and needs fps — read metadata first
81
+ meta = LeRobotDatasetMetadata(repo_id, root=root, revision=revision)
82
+ return LeRobotDataset(
83
+ repo_id,
84
+ root=root,
85
+ episodes=episodes,
86
+ revision=revision,
87
+ delta_timestamps={"action": [t / meta.fps for t in range(chunk)]},
88
+ video_backend="torchcodec",
89
+ )
90
+
91
+ labels_dir = cfg.get("spatial_labels_dir")
92
+
93
+ def wrap(ds, emb_id):
94
+ store = None
95
+ if labels_dir:
96
+ from tinyvla.data.spatial_labels import SpatialLabelStore
97
+
98
+ store = SpatialLabelStore(labels_dir, ds.repo_id.split("/")[-1])
99
+ if len(store) == 0:
100
+ store = None
101
+ return CanonicalSource(
102
+ ds,
103
+ embodiment_id=emb_id,
104
+ image_size=pcfg.image_size,
105
+ max_state_dim=pcfg.max_state_dim,
106
+ max_action_dim=pcfg.max_action_dim,
107
+ staleness_max_s=pcfg.staleness_max_s,
108
+ # staleness_prob switched on at cfg["staleness_start_step"]
109
+ spatial_labels=store,
110
+ )
111
+
112
+ next_emb = 0
113
+ for spec in cfg["datasets"]:
114
+ if "root_glob" in spec:
115
+ roots = sorted(Path(p) for p in __import__("glob").glob(spec["root_glob"]))
116
+ subs = [
117
+ make_ds(r.name, root=r)
118
+ for r in roots
119
+ if (r / "meta" / "info.json").exists()
120
+ ]
121
+ total_eps = sum(d.num_episodes for d in subs) or 1
122
+ for d in subs:
123
+ add(wrap(d, next_emb), spec["weight"] * d.num_episodes / total_eps, d.root.name, next_emb)
124
+ else:
125
+ emb = spec.get("embodiment_id", next_emb)
126
+ ds = make_ds(
127
+ spec["repo_id"],
128
+ root=spec.get("root"),
129
+ episodes=list(range(spec["episodes"])) if spec.get("episodes") else None,
130
+ revision=spec.get("revision"),
131
+ )
132
+ add(wrap(ds, emb), spec["weight"], spec["repo_id"], emb)
133
+ next_emb += 1
134
+
135
+ mixture = WeightedMixtureDataset(datasets, weights)
136
+ loader = torch.utils.data.DataLoader(
137
+ mixture,
138
+ batch_size=cfg["batch_size"],
139
+ num_workers=cfg.get("num_workers", 8),
140
+ pin_memory=True,
141
+ persistent_workers=True,
142
+ drop_last=True,
143
+ )
144
+
145
+ # tokenizer for task strings (per-source normalization already done in CanonicalSource)
146
+ from transformers import AutoTokenizer
147
+
148
+ tokenizer = AutoTokenizer.from_pretrained(pcfg.lm_model_name)
149
+
150
+ backbone_params = [
151
+ p for n, p in policy.named_parameters() if p.requires_grad and "semantic.vlm" in n
152
+ ]
153
+ head_params = [
154
+ p for n, p in policy.named_parameters() if p.requires_grad and "semantic.vlm" not in n
155
+ ]
156
+ groups = [{"params": head_params, "lr": cfg["lr"]}]
157
+ if backbone_params:
158
+ groups.append({"params": backbone_params, "lr": cfg["lr"] * cfg.get("backbone_lr_mult", 0.1)})
159
+ accelerator.print(f"backbone group: {sum(p.numel() for p in backbone_params)/1e6:.1f}M params at {cfg.get('backbone_lr_mult', 0.1)}x lr")
160
+ opt = torch.optim.AdamW(groups, betas=(0.9, 0.95), weight_decay=1e-10)
161
+ steps = cfg["steps"]
162
+ warmup = cfg.get("warmup_steps", 1000)
163
+
164
+ def lr_lambda(s):
165
+ if s < warmup:
166
+ return s / warmup
167
+ p = (s - warmup) / max(1, steps - warmup)
168
+ return 0.025 + 0.975 * 0.5 * (1 + math.cos(math.pi * p))
169
+
170
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)
171
+
172
+ policy, opt, loader, sched = accelerator.prepare(policy, opt, loader, sched)
173
+
174
+ out_dir = Path(cfg["output_dir"])
175
+ out_dir.mkdir(parents=True, exist_ok=True)
176
+ if cfg.get("wandb") and accelerator.is_main_process:
177
+ import wandb
178
+
179
+ wandb.init(project=cfg["wandb"], config=cfg)
180
+
181
+ step, t0 = 0, time.time()
182
+ if cfg.get("resume_from"):
183
+ from safetensors.torch import load_file
184
+
185
+ sd = load_file(Path(cfg["resume_from"]) / "model.safetensors")
186
+ missing, unexpected = accelerator.unwrap_model(policy).load_state_dict(sd, strict=False)
187
+ step = int(cfg.get("resume_step", 0))
188
+ for _ in range(step):
189
+ sched.step() # fast-forward LR schedule
190
+ accelerator.print(f"resumed from {cfg['resume_from']} at step {step} "
191
+ f"(missing {len(missing)}, unexpected {len(unexpected)})")
192
+ grad_accum = cfg.get("grad_accum", 1)
193
+ staleness_start = cfg.get("staleness_start_step")
194
+ staleness_on = False
195
+ data_iter = iter(loader)
196
+ while step < steps:
197
+ if staleness_start is not None and not staleness_on and step >= staleness_start:
198
+ # persistent workers hold dataset copies — rebuild the loader
199
+ for src in datasets:
200
+ src.staleness_prob = cfg.get("staleness_prob", 0.5)
201
+ del data_iter
202
+ loader = torch.utils.data.DataLoader(
203
+ mixture,
204
+ batch_size=cfg["batch_size"],
205
+ num_workers=cfg.get("num_workers", 8),
206
+ pin_memory=True,
207
+ persistent_workers=True,
208
+ drop_last=True,
209
+ )
210
+ data_iter = iter(loader)
211
+ staleness_on = True
212
+ accelerator.print(f"staleness augmentation ON at step {step}")
213
+ opt.zero_grad()
214
+ for _ in range(grad_accum):
215
+ try:
216
+ batch = next(data_iter)
217
+ except StopIteration:
218
+ data_iter = iter(loader)
219
+ batch = next(data_iter)
220
+ tok = tokenizer(
221
+ list(batch.pop("task")),
222
+ padding=True,
223
+ truncation=True,
224
+ max_length=pcfg.tokenizer_max_length,
225
+ return_tensors="pt",
226
+ )
227
+ batch["observation.language.tokens"] = tok["input_ids"]
228
+ batch["observation.language.attention_mask"] = tok["attention_mask"].bool()
229
+ batch = {
230
+ k: v.to(accelerator.device, non_blocking=True) if torch.is_tensor(v) else v
231
+ for k, v in batch.items()
232
+ }
233
+ loss, info = policy(batch)
234
+ accelerator.backward(loss / grad_accum)
235
+ accelerator.clip_grad_norm_(policy.parameters(), cfg.get("grad_clip", 10.0))
236
+ opt.step()
237
+ sched.step()
238
+ step += 1
239
+
240
+ if step % cfg.get("log_freq", 50) == 0:
241
+ it_s = cfg.get("log_freq", 50) / (time.time() - t0)
242
+ t0 = time.time()
243
+ accelerator.print(f"step {step}/{steps} loss {info['loss']:.4f} {it_s:.2f} it/s")
244
+ if cfg.get("wandb") and accelerator.is_main_process:
245
+ wandb.log({"loss": info["loss"], "lr": sched.get_last_lr()[0]}, step=step)
246
+
247
+ if step % cfg.get("save_freq", 2000) == 0 and accelerator.is_main_process:
248
+ accelerator.unwrap_model(policy).save_pretrained(out_dir / f"step_{step}")
249
+
250
+ if accelerator.is_main_process:
251
+ accelerator.unwrap_model(policy).save_pretrained(out_dir / "final")
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()
scripts/train_stage1_smoke.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Stage 1: overfit smoke on svla_so100_stacking via lerobot-train.
3
+ set -euo pipefail
4
+
5
+ source ~/tinyvla_venv/bin/activate
6
+
7
+ lerobot-train \
8
+ --policy.type=tinyvla \
9
+ --policy.discover_packages_path=tinyvla \
10
+ --policy.device=cuda \
11
+ --policy.push_to_hub=false \
12
+ --dataset.repo_id=lerobot/svla_so100_stacking \
13
+ --dataset.video_backend=torchcodec \
14
+ --batch_size=32 \
15
+ --num_workers=8 \
16
+ --steps=5000 \
17
+ --log_freq=50 \
18
+ --save_freq=2500 \
19
+ --output_dir=outputs/stage1_smoke \
20
+ "$@"
src/tinyvla.egg-info/PKG-INFO ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: tinyvla
3
+ Version: 0.1.0
4
+ Summary: Dual-rate VLA: Qwen3-0.6B slow semantic path + ~90M fast visuomotor path for SO-100/SO-101
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: lerobot>=0.4
7
+ Requires-Dist: timm>=1.0.15
8
+ Requires-Dist: torch>=2.6
9
+ Requires-Dist: torchvision
10
+ Requires-Dist: transformers>=4.51
11
+ Requires-Dist: accelerate>=1.0
12
+ Requires-Dist: wandb
13
+ Requires-Dist: pyarrow
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
src/tinyvla.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ pyproject.toml
2
+ src/tinyvla.egg-info/PKG-INFO
3
+ src/tinyvla.egg-info/SOURCES.txt
4
+ src/tinyvla.egg-info/dependency_links.txt
5
+ src/tinyvla.egg-info/requires.txt
6
+ src/tinyvla.egg-info/top_level.txt
7
+ src/tinyvla/modules/vision.py
src/tinyvla.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/tinyvla.egg-info/requires.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ lerobot>=0.4
2
+ timm>=1.0.15
3
+ torch>=2.6
4
+ torchvision
5
+ transformers>=4.51
6
+ accelerate>=1.0
7
+ wandb
8
+ pyarrow
9
+
10
+ [dev]
11
+ pytest
src/tinyvla.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ tinyvla
src/tinyvla/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """TinyVLA: dual-rate VLA (Qwen3-0.6B slow path + ~90M fast visuomotor path).
2
+
3
+ Importing this package registers the "tinyvla" policy with LeRobot
4
+ (use --policy.type=tinyvla --policy.discover_packages_path=tinyvla).
5
+ """
6
+
7
+ from .configuration_tinyvla import TinyVLAConfig
8
+
9
+ __all__ = ["TinyVLAConfig"]
src/tinyvla/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (464 Bytes). View file
 
src/tinyvla/__pycache__/configuration_tinyvla.cpython-312.pyc ADDED
Binary file (6.03 kB). View file
 
src/tinyvla/__pycache__/modeling_tinyvla.cpython-312.pyc ADDED
Binary file (12.7 kB). View file
 
src/tinyvla/__pycache__/processor_tinyvla.cpython-312.pyc ADDED
Binary file (1.69 kB). View file
 
src/tinyvla/configuration_tinyvla.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyVLA policy config (LeRobot plugin, --policy.type=tinyvla)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from lerobot.configs import FeatureType, NormalizationMode, PreTrainedConfig
8
+ from lerobot.optim import AdamWConfig
9
+ from lerobot.optim.schedulers import CosineDecayWithWarmupSchedulerConfig
10
+
11
+
12
+ @PreTrainedConfig.register_subclass("tinyvla")
13
+ @dataclass
14
+ class TinyVLAConfig(PreTrainedConfig):
15
+ # Input / output structure
16
+ n_obs_steps: int = 1
17
+ chunk_size: int = 50
18
+ n_action_steps: int = 50
19
+
20
+ normalization_mapping: dict[str, NormalizationMode] = field(
21
+ default_factory=lambda: {
22
+ "VISUAL": NormalizationMode.IDENTITY,
23
+ "STATE": NormalizationMode.MEAN_STD,
24
+ "ACTION": NormalizationMode.MEAN_STD,
25
+ }
26
+ )
27
+
28
+ # Cross-embodiment padding
29
+ max_state_dim: int = 16
30
+ max_action_dim: int = 8
31
+ num_embodiments: int = 8
32
+ max_cameras: int = 3
33
+
34
+ # Vision (shared Qwen3.5 vision tower for both paths)
35
+ image_size: int = 256
36
+ freeze_vision_encoder: bool = True # Stage 1; unfreeze at 0.1x lr in Stage 2
37
+
38
+ # Slow path (Qwen3.5 VLM with native vision tower)
39
+ lm_model_name: str = "Qwen/Qwen3.5-0.8B"
40
+ lm_num_layers: int = 12 # L/2 layer skip (24-layer language stack)
41
+ num_readout_tokens: int = 8
42
+ freeze_lm: bool = True
43
+ tokenizer_max_length: int = 48
44
+ pad_language_to: str = "longest"
45
+
46
+ # Fast path (action expert)
47
+ expert_dim: int = 512
48
+ expert_layers: int = 12
49
+ expert_heads: int = 8
50
+
51
+ # Ablation switches (Stage-2 gate baselines)
52
+ use_semantic_latent: bool = True
53
+ use_spatial_tokens: bool = True
54
+
55
+ # Spatial distillation aux task (Stage 3, XS-VLA style)
56
+ spatial_vocab: int = 1024 # 32x32 grid cells
57
+ spatial_loss_weight: float = 0.0 # 0 disables the aux head/loss
58
+
59
+ # Flow matching
60
+ num_denoise_steps: int = 10
61
+ flow_beta_alpha: float = 1.5
62
+ flow_beta_beta: float = 1.0
63
+ min_period: float = 4e-3
64
+ max_period: float = 4.0
65
+
66
+ # Staleness augmentation (Stage 2b): probability of conditioning on a latent
67
+ # computed from an earlier frame. Consumed by the training loop, not here.
68
+ staleness_prob: float = 0.0
69
+ staleness_max_s: float = 2.0
70
+
71
+ # Training presets
72
+ optimizer_lr: float = 1e-4
73
+ optimizer_betas: tuple[float, float] = (0.9, 0.95)
74
+ optimizer_eps: float = 1e-8
75
+ optimizer_weight_decay: float = 1e-10
76
+ optimizer_grad_clip_norm: float = 10.0
77
+ scheduler_warmup_steps: int = 1_000
78
+ scheduler_decay_steps: int = 40_000
79
+ scheduler_decay_lr: float = 2.5e-6
80
+
81
+ def __post_init__(self):
82
+ super().__post_init__()
83
+ if self.n_action_steps > self.chunk_size:
84
+ raise ValueError(
85
+ f"n_action_steps ({self.n_action_steps}) must be <= chunk_size ({self.chunk_size})"
86
+ )
87
+
88
+ def validate_features(self) -> None:
89
+ image_keys = [k for k, v in self.input_features.items() if v.type is FeatureType.VISUAL]
90
+ if not image_keys:
91
+ raise ValueError("TinyVLA requires at least one image feature")
92
+ if len(image_keys) > self.max_cameras:
93
+ raise ValueError(f"got {len(image_keys)} cameras, max_cameras={self.max_cameras}")
94
+
95
+ def get_optimizer_preset(self) -> AdamWConfig:
96
+ return AdamWConfig(
97
+ lr=self.optimizer_lr,
98
+ betas=self.optimizer_betas,
99
+ eps=self.optimizer_eps,
100
+ weight_decay=self.optimizer_weight_decay,
101
+ grad_clip_norm=self.optimizer_grad_clip_norm,
102
+ )
103
+
104
+ def get_scheduler_preset(self):
105
+ return CosineDecayWithWarmupSchedulerConfig(
106
+ peak_lr=self.optimizer_lr,
107
+ decay_lr=self.scheduler_decay_lr,
108
+ num_warmup_steps=self.scheduler_warmup_steps,
109
+ num_decay_steps=self.scheduler_decay_steps,
110
+ )
111
+
112
+ @property
113
+ def observation_delta_indices(self) -> list:
114
+ return [0]
115
+
116
+ @property
117
+ def action_delta_indices(self) -> list:
118
+ return list(range(self.chunk_size))
119
+
120
+ @property
121
+ def reward_delta_indices(self) -> None:
122
+ return None
src/tinyvla/data/__init__.py ADDED
File without changes
src/tinyvla/data/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (145 Bytes). View file
 
src/tinyvla/data/__pycache__/mixture.cpython-312.pyc ADDED
Binary file (11.8 kB). View file
 
src/tinyvla/data/__pycache__/spatial_labels.cpython-312.pyc ADDED
Binary file (2.45 kB). View file
 
src/tinyvla/data/mixture.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weighted mixture over heterogeneous LeRobotDatasets.
2
+
3
+ CanonicalSource maps every dataset to one schema:
4
+ observation.images.cam0 (3, S, S) float [0,1] primary/scene camera
5
+ observation.images.cam1 (3, S, S) float [0,1] wrist camera or zeros
6
+ camera_mask (2,) bool
7
+ observation.state (max_state_dim,) normalized, zero-padded
8
+ action (chunk, max_action_dim) normalized, zero-padded
9
+ action_dim_mask (max_action_dim,) bool — real action dims
10
+ action_is_pad (chunk,) bool — episode-end padding
11
+ embodiment_id () long
12
+ task str
13
+
14
+ Normalization is per-source (each dataset's own mean/std), so the lerobot
15
+ pipeline normalize step must NOT be applied on top.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from torch.utils.data import Dataset
25
+
26
+ PRIMARY_RE = re.compile(r"top|front|base|main|scene|image_0$|images\.image$|image2$")
27
+ WRIST_RE = re.compile(r"wrist|gripper|hand|image_1$")
28
+
29
+
30
+ def _norm(x: torch.Tensor, stats: dict | None) -> torch.Tensor:
31
+ if not stats:
32
+ return x
33
+ mean = torch.as_tensor(stats["mean"], dtype=x.dtype)
34
+ std = torch.as_tensor(stats["std"], dtype=x.dtype).clamp(min=1e-6)
35
+ return (x - mean) / std
36
+
37
+
38
+ def _pad(x: torch.Tensor, dim: int) -> torch.Tensor:
39
+ if x.shape[-1] >= dim:
40
+ return x[..., :dim]
41
+ return F.pad(x, (0, dim - x.shape[-1]))
42
+
43
+
44
+ class CanonicalSource(Dataset):
45
+ def __init__(
46
+ self,
47
+ ds, # LeRobotDataset with delta_timestamps set for action chunk
48
+ embodiment_id: int,
49
+ image_size: int = 256,
50
+ max_state_dim: int = 16,
51
+ max_action_dim: int = 8,
52
+ staleness_prob: float = 0.0,
53
+ staleness_max_s: float = 2.0,
54
+ spatial_labels=None, # SpatialLabelStore | None
55
+ ):
56
+ self.ds = ds
57
+ self.embodiment_id = embodiment_id
58
+ self.image_size = image_size
59
+ self.max_state_dim = max_state_dim
60
+ self.max_action_dim = max_action_dim
61
+ self.staleness_prob = staleness_prob
62
+ self.staleness_max_s = staleness_max_s
63
+ self.spatial_labels = spatial_labels
64
+
65
+ image_keys = sorted(k for k in ds.meta.features if k.startswith("observation.images"))
66
+ primary = next((k for k in image_keys if PRIMARY_RE.search(k)), image_keys[0])
67
+ wrist = next((k for k in image_keys if k != primary and WRIST_RE.search(k)), None)
68
+ if wrist is None and len(image_keys) > 1:
69
+ wrist = next(k for k in image_keys if k != primary)
70
+ self.cam_keys = (primary, wrist)
71
+
72
+ self.stats = ds.meta.stats
73
+ self.action_dim = ds.meta.features["action"]["shape"][0]
74
+ self.state_dim = ds.meta.features.get("observation.state", {}).get("shape", (0,))[0]
75
+
76
+ # some converted repos have fewer parquet rows than metadata claims —
77
+ # clamp so sampling never goes out of bounds
78
+ try:
79
+ actual_rows = len(ds.reader.hf_dataset)
80
+ except AttributeError:
81
+ actual_rows = len(ds)
82
+ if actual_rows != len(ds):
83
+ import logging
84
+
85
+ logging.warning(
86
+ f"{ds.repo_id}: meta frames {len(ds)} != parquet rows {actual_rows}; clamping"
87
+ )
88
+ self._len = min(len(ds), actual_rows)
89
+
90
+ def __len__(self):
91
+ return self._len
92
+
93
+ def _img(self, x: torch.Tensor) -> torch.Tensor:
94
+ if x.shape[-2:] != (self.image_size, self.image_size):
95
+ x = F.interpolate(
96
+ x[None], size=(self.image_size, self.image_size), mode="bilinear", align_corners=False
97
+ )[0]
98
+ return x
99
+
100
+ def __getitem__(self, idx):
101
+ item = self.ds[idx]
102
+ out = {}
103
+ primary, wrist = self.cam_keys
104
+ out["observation.images.cam0"] = self._img(item[primary])
105
+ if wrist is not None:
106
+ out["observation.images.cam1"] = self._img(item[wrist])
107
+ cam_mask = torch.tensor([True, True])
108
+ else:
109
+ out["observation.images.cam1"] = torch.zeros_like(out["observation.images.cam0"])
110
+ cam_mask = torch.tensor([True, False])
111
+ out["camera_mask"] = cam_mask
112
+
113
+ if self.state_dim:
114
+ state = _norm(item["observation.state"], self.stats.get("observation.state"))
115
+ else:
116
+ state = torch.zeros(1)
117
+ out["observation.state"] = _pad(state, self.max_state_dim)
118
+
119
+ action = _norm(item["action"], self.stats.get("action"))
120
+ out["action"] = _pad(action, self.max_action_dim)
121
+ mask = torch.zeros(self.max_action_dim, dtype=torch.bool)
122
+ mask[: self.action_dim] = True
123
+ out["action_dim_mask"] = mask
124
+ if "action_is_pad" in item:
125
+ out["action_is_pad"] = item["action_is_pad"]
126
+
127
+ out["embodiment_id"] = torch.tensor(self.embodiment_id, dtype=torch.long)
128
+ out["task"] = item.get("task") or ""
129
+
130
+ cell = -1
131
+ if self.spatial_labels is not None:
132
+ ep = int(item["episode_index"])
133
+ ep_start = int(self.ds.meta.episodes["dataset_from_index"][ep])
134
+ cell = self.spatial_labels.lookup(ep, idx - ep_start)
135
+ out["spatial_cell"] = torch.tensor(cell, dtype=torch.long)
136
+
137
+ # staleness augmentation: with prob p, the semantic latent is computed
138
+ # from a frame up to staleness_max_s earlier in the same episode.
139
+ # latent_image is always emitted when enabled so batch keys are uniform.
140
+ if self.staleness_prob > 0:
141
+ out["latent_image"] = out["observation.images.cam0"]
142
+ if torch.rand(1).item() < self.staleness_prob:
143
+ ep = int(item["episode_index"])
144
+ ep_start = int(self.ds.meta.episodes["dataset_from_index"][ep])
145
+ k = int(torch.randint(0, int(self.staleness_max_s * self.ds.fps) + 1, (1,)).item())
146
+ stale_idx = max(ep_start, idx - k)
147
+ if stale_idx != idx:
148
+ stale = self.ds[stale_idx]
149
+ out["latent_image"] = self._img(stale[self.cam_keys[0]])
150
+ return out
151
+
152
+
153
+ class WeightedMixtureDataset(Dataset):
154
+ """Samples source i with probability weights[i], then a uniform frame."""
155
+
156
+ def __init__(self, sources: list[CanonicalSource], weights: list[float], seed: int = 42):
157
+ assert len(sources) == len(weights)
158
+ total = sum(weights)
159
+ self.sources = sources
160
+ self.weights = torch.tensor([w / total for w in weights], dtype=torch.double)
161
+ self.seed = seed
162
+ self._len = sum(len(s) for s in sources)
163
+
164
+ def __len__(self):
165
+ return self._len
166
+
167
+ def __getitem__(self, index):
168
+ g = torch.Generator().manual_seed(self.seed + index)
169
+ for attempt in range(3):
170
+ src_idx = int(torch.multinomial(self.weights, 1, generator=g).item())
171
+ src = self.sources[src_idx]
172
+ frame_idx = int(torch.randint(len(src), (1,), generator=g).item())
173
+ try:
174
+ return src[frame_idx]
175
+ except (IndexError, KeyError, RuntimeError): # corrupt frame/video — resample
176
+ if attempt == 2:
177
+ raise
178
+ raise RuntimeError("unreachable")
src/tinyvla/data/spatial_labels.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spatial-distillation label store.
2
+
3
+ Labels come from scripts/label_spatial.py parquets: one row per labeled frame
4
+ (dataset, episode_index, frame_index RELATIVE to episode start, cell in
5
+ [0, GRID*GRID) or -1). Frames were labeled every `stride`; lookup rounds a
6
+ query frame to the nearest labeled one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ GRID = 32
14
+ STRIDE = 10
15
+
16
+
17
+ class SpatialLabelStore:
18
+ def __init__(self, labels_dir: str | Path, dataset_name: str):
19
+ import pyarrow.parquet as pq
20
+
21
+ self.table = {}
22
+ path = Path(labels_dir) / f"{dataset_name}.parquet"
23
+ if path.exists():
24
+ t = pq.read_table(path)
25
+ for ep, fi, cell, ok in zip(
26
+ t["episode_index"].to_pylist(),
27
+ t["frame_index"].to_pylist(),
28
+ t["cell"].to_pylist(),
29
+ t["confidence_ok"].to_pylist(),
30
+ ):
31
+ if ok and cell >= 0:
32
+ self.table[(int(ep), int(fi))] = int(cell)
33
+
34
+ def __len__(self):
35
+ return len(self.table)
36
+
37
+ def lookup(self, episode_index: int, rel_frame: int) -> int:
38
+ """Nearest labeled frame's cell, or -1."""
39
+ base = round(rel_frame / STRIDE) * STRIDE
40
+ for fi in (base, base - STRIDE, base + STRIDE):
41
+ cell = self.table.get((episode_index, fi))
42
+ if cell is not None:
43
+ return cell
44
+ return -1
src/tinyvla/modeling_tinyvla.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyVLA policy: dual-rate VLA on a single shared Qwen3.5 vision encoder.
2
+
3
+ Slow path: image -> Qwen3.5 vision tower -> language stack (first 12 layers) +
4
+ instruction -> 8-token semantic latent (cached at deployment, refreshed at
5
+ 0.5-2 Hz).
6
+
7
+ Fast path: Qwen3.5 vision tower spatial tokens (64/camera, no language stack)
8
+ + proprio + embodiment token + cached semantic latent -> ~40M flow-matching
9
+ action expert -> 50-step action chunk.
10
+
11
+ During training both paths run jointly (single-rate); staleness augmentation
12
+ is added by the Stage-2 training loop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import deque
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch.nn.functional as F
22
+
23
+ from lerobot.configs import FeatureType
24
+ from lerobot.policies.pretrained import PreTrainedPolicy
25
+ from lerobot.utils.constants import (
26
+ ACTION,
27
+ OBS_LANGUAGE_ATTENTION_MASK,
28
+ OBS_LANGUAGE_TOKENS,
29
+ OBS_STATE,
30
+ )
31
+
32
+ from .configuration_tinyvla import TinyVLAConfig
33
+ from .modules.embodiment import EmbodimentEmbedding, pad_vector
34
+ from .modules.expert import FlowMatchingExpert
35
+ from .modules.semantic import SemanticPath
36
+
37
+
38
+ class TinyVLAPolicy(PreTrainedPolicy):
39
+ config_class = TinyVLAConfig
40
+ name = "tinyvla"
41
+
42
+ def __init__(self, config: TinyVLAConfig, dataset_stats=None, dataset_meta=None, **kwargs):
43
+ super().__init__(config)
44
+ self.config = config
45
+
46
+ self.image_keys = sorted(
47
+ k for k, v in config.input_features.items() if v.type is FeatureType.VISUAL
48
+ )
49
+ state_ft = config.input_features.get(OBS_STATE)
50
+ self.state_dim = state_ft.shape[0] if state_ft is not None else 0
51
+ self.action_dim = config.output_features[ACTION].shape[0]
52
+
53
+ d = config.expert_dim
54
+
55
+ self.semantic = SemanticPath(
56
+ model_name=config.lm_model_name,
57
+ num_layers=config.lm_num_layers,
58
+ num_readout=config.num_readout_tokens,
59
+ out_dim=d,
60
+ image_size=config.image_size,
61
+ freeze_lm=config.freeze_lm,
62
+ freeze_vision=config.freeze_vision_encoder,
63
+ )
64
+
65
+ # fast-path conditioning projections
66
+ self.spatial_proj = nn.Linear(self.semantic.visual_hidden_size, d)
67
+ self.camera_emb = nn.Embedding(config.max_cameras, d)
68
+ self.state_proj = nn.Linear(config.max_state_dim, d)
69
+ self.embodiment_emb = EmbodimentEmbedding(config.num_embodiments, d)
70
+
71
+ self.spatial_head = (
72
+ nn.Linear(d, config.spatial_vocab) if config.spatial_loss_weight > 0 else None
73
+ )
74
+
75
+ self.expert = FlowMatchingExpert(
76
+ action_dim=config.max_action_dim,
77
+ chunk_size=config.chunk_size,
78
+ d_model=d,
79
+ n_layers=config.expert_layers,
80
+ n_heads=config.expert_heads,
81
+ min_period=config.min_period,
82
+ max_period=config.max_period,
83
+ )
84
+
85
+ self._action_queue: deque = deque()
86
+
87
+ def reset(self):
88
+ self._action_queue.clear()
89
+
90
+ def get_optim_params(self):
91
+ return [p for p in self.parameters() if p.requires_grad]
92
+
93
+ # ---------------------------------------------------------------- features
94
+
95
+ @staticmethod
96
+ def _last_obs(x: torch.Tensor, ndim: int) -> torch.Tensor:
97
+ """Drop the n_obs_steps dim if present: (B, S, ...) -> (B, ...)."""
98
+ return x[:, -1] if x.dim() == ndim + 1 else x
99
+
100
+ def _encode_spatial(self, batch: dict) -> torch.Tensor:
101
+ """Per-camera spatial tokens for the fast path: (B, num_cams*64, d)."""
102
+ cond = []
103
+ for i, key in enumerate(self.image_keys):
104
+ tokens = self.semantic.encode_image(self._last_obs(batch[key], 4)) # (B, 64, C)
105
+ proj = self.spatial_proj(tokens) + self.camera_emb.weight[i][None, None, :]
106
+ if i == 0:
107
+ self._cam0_proj = proj # consumed by the spatial-distillation head
108
+ cond.append(proj)
109
+ return torch.cat(cond, dim=1)
110
+
111
+ def _semantic_latent(self, batch: dict) -> torch.Tensor:
112
+ """Slow-path latent from the first (main) camera + instruction: (B, K, d)."""
113
+ return self.semantic(
114
+ self._last_obs(batch[self.image_keys[0]], 4),
115
+ batch[OBS_LANGUAGE_TOKENS],
116
+ batch[OBS_LANGUAGE_ATTENTION_MASK],
117
+ )
118
+
119
+ def _conditioning(self, batch: dict) -> torch.Tensor:
120
+ b = batch[self.image_keys[0]].shape[0]
121
+ device = batch[self.image_keys[0]].device
122
+
123
+ parts = []
124
+ if self.config.use_semantic_latent:
125
+ latent = batch.get("semantic_latent")
126
+ if latent is None:
127
+ if "latent_image" in batch: # staleness augmentation: latent from an older frame
128
+ latent = self.semantic(
129
+ batch["latent_image"],
130
+ batch[OBS_LANGUAGE_TOKENS],
131
+ batch[OBS_LANGUAGE_ATTENTION_MASK],
132
+ )
133
+ else:
134
+ latent = self._semantic_latent(batch)
135
+ parts.append(latent)
136
+ if self.config.use_spatial_tokens:
137
+ parts.append(self._encode_spatial(batch))
138
+ if self.state_dim:
139
+ state = pad_vector(self._last_obs(batch[OBS_STATE], 2), self.config.max_state_dim)
140
+ parts.append(self.state_proj(state)[:, None, :])
141
+ emb_id = batch.get("embodiment_id")
142
+ if emb_id is None:
143
+ emb_id = torch.zeros(b, dtype=torch.long, device=device)
144
+ parts.append(self.embodiment_emb(emb_id))
145
+ return torch.cat(parts, dim=1)
146
+
147
+ # ---------------------------------------------------------------- training
148
+
149
+ def forward(self, batch: dict) -> tuple[torch.Tensor, dict]:
150
+ cond = self._conditioning(batch)
151
+
152
+ actions = pad_vector(batch[ACTION], self.config.max_action_dim) # (B, T, A)
153
+ b, t, a = actions.shape
154
+ device = actions.device
155
+
156
+ beta = torch.distributions.Beta(self.config.flow_beta_alpha, self.config.flow_beta_beta)
157
+ time = beta.sample((b,)).to(device) * 0.999 + 0.001
158
+ noise = torch.randn_like(actions)
159
+ x_t = time[:, None, None] * noise + (1 - time[:, None, None]) * actions
160
+ target = noise - actions
161
+
162
+ pred = self.expert(x_t, time, cond)
163
+
164
+ loss_mask = torch.ones(b, t, a, device=device)
165
+ if "action_dim_mask" in batch: # per-sample real dims (mixture training)
166
+ loss_mask = loss_mask * batch["action_dim_mask"][:, None, :].float()
167
+ else:
168
+ loss_mask[:, :, self.action_dim :] = 0 # padded action dims
169
+ if "action_is_pad" in batch: # episode-end padding from delta_timestamps
170
+ loss_mask = loss_mask * (~batch["action_is_pad"])[:, :, None].float()
171
+
172
+ loss = (F.mse_loss(pred, target, reduction="none") * loss_mask).sum() / loss_mask.sum()
173
+ info = {"flow_loss": loss.item()}
174
+
175
+ if (
176
+ self.spatial_head is not None
177
+ and "spatial_cell" in batch
178
+ and self.config.use_spatial_tokens
179
+ ):
180
+ cells = batch["spatial_cell"]
181
+ labeled = cells >= 0
182
+ if labeled.any():
183
+ logits = self.spatial_head(self._cam0_proj.mean(dim=1)) # (B, vocab)
184
+ ce = F.cross_entropy(logits[labeled], cells[labeled])
185
+ loss = loss + self.config.spatial_loss_weight * ce
186
+ info["spatial_ce"] = ce.item()
187
+
188
+ info["loss"] = loss.item()
189
+ return loss, info
190
+
191
+ # --------------------------------------------------------------- inference
192
+
193
+ @torch.no_grad()
194
+ def predict_action_chunk(self, batch: dict) -> torch.Tensor:
195
+ cond = self._conditioning(batch)
196
+ b = cond.shape[0]
197
+ device = cond.device
198
+
199
+ x = torch.randn(b, self.config.chunk_size, self.config.max_action_dim, device=device)
200
+ n = self.config.num_denoise_steps
201
+ dt = 1.0 / n
202
+ for i in range(n):
203
+ t = torch.full((b,), 1.0 - i * dt, device=device)
204
+ v = self.expert(x, t, cond)
205
+ x = x - dt * v
206
+ return x[:, :, : self.action_dim]
207
+
208
+ @torch.no_grad()
209
+ def select_action(self, batch: dict) -> torch.Tensor:
210
+ if len(self._action_queue) == 0:
211
+ chunk = self.predict_action_chunk(batch) # (B, T, A)
212
+ self._action_queue.extend(chunk.transpose(0, 1)[: self.config.n_action_steps])
213
+ return self._action_queue.popleft()
src/tinyvla/modules/__pycache__/embodiment.cpython-312.pyc ADDED
Binary file (2.41 kB). View file
 
src/tinyvla/modules/__pycache__/expert.cpython-312.pyc ADDED
Binary file (7.84 kB). View file
 
src/tinyvla/modules/__pycache__/semantic.cpython-312.pyc ADDED
Binary file (9.57 kB). View file
 
src/tinyvla/modules/__pycache__/vision.cpython-312.pyc ADDED
Binary file (5.34 kB). View file
 
src/tinyvla/modules/embodiment.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-embodiment harmonization: pad-to-max vectors + embodiment embedding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+
9
+ def pad_vector(x: torch.Tensor, target_dim: int) -> torch.Tensor:
10
+ """Zero-pad the last dim of (..., D) to (..., target_dim)."""
11
+ if x.shape[-1] == target_dim:
12
+ return x
13
+ if x.shape[-1] > target_dim:
14
+ raise ValueError(f"dim {x.shape[-1]} exceeds max {target_dim}")
15
+ pad = x.new_zeros(*x.shape[:-1], target_dim - x.shape[-1])
16
+ return torch.cat([x, pad], dim=-1)
17
+
18
+
19
+ def dim_validity_mask(orig_dim: int, target_dim: int, ref: torch.Tensor) -> torch.Tensor:
20
+ """(target_dim,) bool mask, True for real dims, False for padding."""
21
+ mask = torch.zeros(target_dim, dtype=torch.bool, device=ref.device)
22
+ mask[:orig_dim] = True
23
+ return mask
24
+
25
+
26
+ class EmbodimentEmbedding(nn.Module):
27
+ """One learned token per source dataset / robot embodiment."""
28
+
29
+ def __init__(self, num_embodiments: int, dim: int):
30
+ super().__init__()
31
+ self.emb = nn.Embedding(num_embodiments, dim)
32
+
33
+ def forward(self, embodiment_id: torch.Tensor) -> torch.Tensor:
34
+ """(B,) int -> (B, 1, dim)."""
35
+ return self.emb(embodiment_id)[:, None, :]
src/tinyvla/modules/expert.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow-matching action expert.
2
+
3
+ A small transformer over noisy action tokens with interleaved
4
+ cross-attention (to the conditioning set) and self-attention blocks,
5
+ following the SmolVLA expert pattern. Predicts the flow velocity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+
16
+ def sincos_time_embedding(t: torch.Tensor, dim: int, min_period: float = 4e-3, max_period: float = 4.0) -> torch.Tensor:
17
+ """t: (B,) in [0, 1] -> (B, dim) sine-cosine embedding."""
18
+ half = dim // 2
19
+ freqs = torch.exp(
20
+ torch.linspace(math.log(min_period), math.log(max_period), half, device=t.device)
21
+ )
22
+ angles = 2 * math.pi * t[:, None] / freqs[None, :]
23
+ return torch.cat([angles.sin(), angles.cos()], dim=-1)
24
+
25
+
26
+ class _MLP(nn.Module):
27
+ def __init__(self, dim: int, ratio: int = 4):
28
+ super().__init__()
29
+ self.net = nn.Sequential(
30
+ nn.Linear(dim, dim * ratio),
31
+ nn.GELU(),
32
+ nn.Linear(dim * ratio, dim),
33
+ )
34
+
35
+ def forward(self, x):
36
+ return self.net(x)
37
+
38
+
39
+ class SelfAttnBlock(nn.Module):
40
+ def __init__(self, dim: int, n_heads: int):
41
+ super().__init__()
42
+ self.norm1 = nn.LayerNorm(dim)
43
+ self.attn = nn.MultiheadAttention(dim, n_heads, batch_first=True)
44
+ self.norm2 = nn.LayerNorm(dim)
45
+ self.mlp = _MLP(dim)
46
+
47
+ def forward(self, x, cond, cond_mask):
48
+ h = self.norm1(x)
49
+ h, _ = self.attn(h, h, h, need_weights=False)
50
+ x = x + h
51
+ x = x + self.mlp(self.norm2(x))
52
+ return x
53
+
54
+
55
+ class CrossAttnBlock(nn.Module):
56
+ def __init__(self, dim: int, n_heads: int):
57
+ super().__init__()
58
+ self.norm1 = nn.LayerNorm(dim)
59
+ self.norm_cond = nn.LayerNorm(dim)
60
+ self.attn = nn.MultiheadAttention(dim, n_heads, batch_first=True)
61
+ self.norm2 = nn.LayerNorm(dim)
62
+ self.mlp = _MLP(dim)
63
+
64
+ def forward(self, x, cond, cond_mask):
65
+ h = self.norm1(x)
66
+ c = self.norm_cond(cond)
67
+ # key_padding_mask: True = ignore
68
+ kpm = ~cond_mask if cond_mask is not None else None
69
+ h, _ = self.attn(h, c, c, key_padding_mask=kpm, need_weights=False)
70
+ x = x + h
71
+ x = x + self.mlp(self.norm2(x))
72
+ return x
73
+
74
+
75
+ class FlowMatchingExpert(nn.Module):
76
+ def __init__(
77
+ self,
78
+ action_dim: int = 8,
79
+ chunk_size: int = 50,
80
+ d_model: int = 512,
81
+ n_layers: int = 12,
82
+ n_heads: int = 8,
83
+ min_period: float = 4e-3,
84
+ max_period: float = 4.0,
85
+ ):
86
+ super().__init__()
87
+ self.action_dim = action_dim
88
+ self.chunk_size = chunk_size
89
+ self.d_model = d_model
90
+ self.min_period = min_period
91
+ self.max_period = max_period
92
+
93
+ self.action_in = nn.Linear(action_dim, d_model)
94
+ self.pos_emb = nn.Parameter(torch.zeros(1, chunk_size, d_model))
95
+ self.time_mlp = nn.Sequential(
96
+ nn.Linear(d_model, d_model),
97
+ nn.GELU(),
98
+ nn.Linear(d_model, d_model),
99
+ )
100
+ # even blocks cross-attend to the conditioning set, odd blocks self-attend
101
+ self.blocks = nn.ModuleList(
102
+ [
103
+ (CrossAttnBlock if i % 2 == 0 else SelfAttnBlock)(d_model, n_heads)
104
+ for i in range(n_layers)
105
+ ]
106
+ )
107
+ self.norm_out = nn.LayerNorm(d_model)
108
+ self.action_out = nn.Linear(d_model, action_dim)
109
+
110
+ nn.init.trunc_normal_(self.pos_emb, std=0.02)
111
+ nn.init.zeros_(self.action_out.weight)
112
+ nn.init.zeros_(self.action_out.bias)
113
+
114
+ def forward(
115
+ self,
116
+ noisy_actions: torch.Tensor, # (B, T, A)
117
+ time: torch.Tensor, # (B,) in [0, 1]
118
+ cond: torch.Tensor, # (B, N, d_model)
119
+ cond_mask: torch.Tensor | None = None, # (B, N) True = valid
120
+ ) -> torch.Tensor:
121
+ x = self.action_in(noisy_actions) + self.pos_emb[:, : noisy_actions.shape[1]]
122
+ t_emb = self.time_mlp(
123
+ sincos_time_embedding(time, self.d_model, self.min_period, self.max_period)
124
+ )
125
+ x = x + t_emb[:, None, :]
126
+ for block in self.blocks:
127
+ x = block(x, cond, cond_mask)
128
+ return self.action_out(self.norm_out(x))
src/tinyvla/modules/semantic.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Slow semantic path: Qwen3.5-0.8B VLM over [image | instruction | readout slots].
2
+
3
+ Qwen3.5-0.8B has a native vision tower (12-layer ViT, patch 16, spatial merge 2:
4
+ a 256x256 image becomes 64 visual tokens), so the slow path consumes the raw
5
+ image directly — no hand-rolled projector. The language stack is truncated to
6
+ the first `num_layers` blocks (L/2 layer-skip, SmolVLA-style). The semantic
7
+ latent is the hidden state of K trailing readout slots (pad tokens after the
8
+ instruction; causal attention lets them aggregate the full prefix), projected
9
+ to the expert dimension. At deployment this runs at 0.5-2 Hz and the latent is
10
+ cached between refreshes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+
19
+
20
+ class SemanticPath(nn.Module):
21
+ def __init__(
22
+ self,
23
+ model_name: str = "Qwen/Qwen3.5-0.8B",
24
+ num_layers: int = 12,
25
+ num_readout: int = 8,
26
+ out_dim: int = 512,
27
+ image_size: int = 256,
28
+ freeze_lm: bool = True,
29
+ freeze_vision: bool = True,
30
+ ):
31
+ super().__init__()
32
+ from transformers import AutoModelForImageTextToText, AutoProcessor
33
+
34
+ vlm = AutoModelForImageTextToText.from_pretrained(model_name, dtype=torch.float32)
35
+ model = vlm.model # Qwen3_5Model: visual tower + language stack
36
+ # L/2 layer-skip: keep the first num_layers language blocks; lm_head unused
37
+ model.language_model.layers = model.language_model.layers[:num_layers]
38
+ model.language_model.config.num_hidden_layers = num_layers
39
+ self.vlm = model
40
+ cfg = vlm.config
41
+ self.hidden_size = cfg.text_config.hidden_size
42
+ self.image_token_id = cfg.image_token_id
43
+ self.vision_start_id = cfg.vision_start_token_id
44
+ self.vision_end_id = cfg.vision_end_token_id
45
+
46
+ proc = AutoProcessor.from_pretrained(model_name)
47
+ self.pad_token_id = proc.tokenizer.pad_token_id or proc.tokenizer.eos_token_id
48
+ ip = proc.image_processor
49
+ self.patch_size = ip.patch_size # 16
50
+ self.merge_size = ip.merge_size # 2
51
+ self.temporal_patch_size = ip.temporal_patch_size # 2
52
+ self.register_buffer(
53
+ "pixel_mean", torch.tensor(ip.image_mean).view(1, 3, 1, 1), persistent=False
54
+ )
55
+ self.register_buffer(
56
+ "pixel_std", torch.tensor(ip.image_std).view(1, 3, 1, 1), persistent=False
57
+ )
58
+ self.image_size = image_size
59
+ grid = image_size // self.patch_size
60
+ self.num_image_tokens = (grid // self.merge_size) ** 2 # 64 @ 256px
61
+
62
+ self.out_proj = nn.Linear(self.hidden_size, out_dim)
63
+ self.num_readout = num_readout
64
+
65
+ if freeze_lm:
66
+ for p in self.vlm.language_model.parameters():
67
+ p.requires_grad = False
68
+ # vocab embeddings stay frozen either way (254M tied params, negligible gain)
69
+ self.vlm.get_input_embeddings().weight.requires_grad = False
70
+ if freeze_vision:
71
+ for p in self.vlm.visual.parameters():
72
+ p.requires_grad = False
73
+
74
+ @property
75
+ def visual_hidden_size(self) -> int:
76
+ return self.vlm.visual.config.out_hidden_size
77
+
78
+ def encode_image(self, images: torch.Tensor) -> torch.Tensor:
79
+ """Qwen vision tower spatial tokens: (B, 3, H, W) -> (B, 64, out_hidden).
80
+
81
+ Shared encoder for both paths; the fast path cross-attends to these
82
+ directly, bypassing the language stack.
83
+ """
84
+ b = images.shape[0]
85
+ pixel_values, grid_thw = self._patchify(images)
86
+ out = self.vlm.get_image_features(pixel_values.to(self.pixel_mean.dtype), grid_thw)
87
+ embeds = torch.cat(out.pooler_output, dim=0) # per-image tuple -> (B*64, C)
88
+ return embeds.reshape(b, self.num_image_tokens, -1)
89
+
90
+ def _patchify(self, images: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
91
+ """(B, 3, H, W) in [0,1] -> Qwen-VL flattened patches + grid_thw (B, 3).
92
+
93
+ Mirrors the Qwen VL image processor: resize to image_size, normalize,
94
+ repeat temporally, unfold into merge-ordered patches.
95
+ """
96
+ b = images.shape[0]
97
+ if images.shape[-2:] != (self.image_size, self.image_size):
98
+ images = F.interpolate(
99
+ images, size=(self.image_size, self.image_size), mode="bilinear", align_corners=False
100
+ )
101
+ images = (images - self.pixel_mean) / self.pixel_std
102
+
103
+ p, m, tp = self.patch_size, self.merge_size, self.temporal_patch_size
104
+ g = self.image_size // p
105
+ # (B, tp, C, H, W)
106
+ x = images.unsqueeze(1).expand(-1, tp, -1, -1, -1)
107
+ # split H,W into (g/m, m, p) blocks, order patches so that the m*m
108
+ # merge-group is contiguous: (gh/m, gw/m, m, m) patch order
109
+ x = x.reshape(b, tp, 3, g // m, m, p, g // m, m, p)
110
+ x = x.permute(0, 3, 6, 4, 7, 2, 1, 5, 8) # b, gh/m, gw/m, m, m, C, tp, p, p
111
+ x = x.reshape(b * g * g, 3 * tp * p * p)
112
+ grid_thw = torch.tensor([[1, g, g]], device=images.device).expand(b, -1)
113
+ return x, grid_thw
114
+
115
+ def forward(
116
+ self,
117
+ images: torch.Tensor, # (B, 3, H, W) in [0, 1]
118
+ lang_tokens: torch.Tensor, # (B, L)
119
+ lang_mask: torch.Tensor, # (B, L) bool, True = valid
120
+ ) -> torch.Tensor:
121
+ """Returns semantic latent (B, num_readout, out_dim)."""
122
+ b = images.shape[0]
123
+ device = images.device
124
+
125
+ pixel_values, grid_thw = self._patchify(images)
126
+ n_img = self.num_image_tokens
127
+
128
+ # <vision_start> <image>*n <vision_end> instruction <pad>*K
129
+ ids = torch.cat(
130
+ [
131
+ torch.full((b, 1), self.vision_start_id, dtype=torch.long, device=device),
132
+ torch.full((b, n_img), self.image_token_id, dtype=torch.long, device=device),
133
+ torch.full((b, 1), self.vision_end_id, dtype=torch.long, device=device),
134
+ lang_tokens,
135
+ torch.full((b, self.num_readout), self.pad_token_id, dtype=torch.long, device=device),
136
+ ],
137
+ dim=1,
138
+ )
139
+ attn = torch.cat(
140
+ [
141
+ torch.ones(b, n_img + 2, dtype=torch.bool, device=device),
142
+ lang_mask.bool(),
143
+ torch.ones(b, self.num_readout, dtype=torch.bool, device=device),
144
+ ],
145
+ dim=1,
146
+ )
147
+
148
+ out = self.vlm(
149
+ input_ids=ids,
150
+ pixel_values=pixel_values.to(self.pixel_mean.dtype),
151
+ image_grid_thw=grid_thw,
152
+ attention_mask=attn,
153
+ mm_token_type_ids=(ids == self.image_token_id).int(), # text 0, image 1
154
+ use_cache=False,
155
+ )
156
+ readout_h = out.last_hidden_state[:, -self.num_readout :]
157
+ return self.out_proj(readout_h)