Instructions to use AlexWortega/tinyvla with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LeRobot
How to use AlexWortega/tinyvla with LeRobot:
- Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- README.md +148 -0
- checkpoints/stage2_final/config.json +88 -0
- checkpoints/stage2_final/model.safetensors +3 -0
- checkpoints/stage3_final/config.json +90 -0
- checkpoints/stage3_final/model.safetensors +3 -0
- config.json +90 -0
- configs/ablation_nolatent.yaml +25 -0
- configs/ablation_nospatial.yaml +25 -0
- configs/libero_ft.yaml +31 -0
- configs/libero_ft2.yaml +33 -0
- configs/libero_ft3.yaml +33 -0
- configs/libero_ft4.yaml +33 -0
- configs/stage2_mixture.yaml +37 -0
- configs/stage3_distill.yaml +40 -0
- model.safetensors +3 -0
- scripts/diag_obs_gap.py +131 -0
- scripts/download_subsets.py +77 -0
- scripts/eval_libero.py +193 -0
- scripts/eval_offline.py +114 -0
- scripts/label_spatial.py +148 -0
- scripts/prepare_community_v3.py +163 -0
- scripts/replay_libero.py +85 -0
- scripts/train.py +255 -0
- scripts/train_stage1_smoke.sh +20 -0
- src/tinyvla.egg-info/PKG-INFO +15 -0
- src/tinyvla.egg-info/SOURCES.txt +7 -0
- src/tinyvla.egg-info/dependency_links.txt +1 -0
- src/tinyvla.egg-info/requires.txt +11 -0
- src/tinyvla.egg-info/top_level.txt +1 -0
- src/tinyvla/__init__.py +9 -0
- src/tinyvla/__pycache__/__init__.cpython-312.pyc +0 -0
- src/tinyvla/__pycache__/configuration_tinyvla.cpython-312.pyc +0 -0
- src/tinyvla/__pycache__/modeling_tinyvla.cpython-312.pyc +0 -0
- src/tinyvla/__pycache__/processor_tinyvla.cpython-312.pyc +0 -0
- src/tinyvla/configuration_tinyvla.py +122 -0
- src/tinyvla/data/__init__.py +0 -0
- src/tinyvla/data/__pycache__/__init__.cpython-312.pyc +0 -0
- src/tinyvla/data/__pycache__/mixture.cpython-312.pyc +0 -0
- src/tinyvla/data/__pycache__/spatial_labels.cpython-312.pyc +0 -0
- src/tinyvla/data/mixture.py +178 -0
- src/tinyvla/data/spatial_labels.py +44 -0
- src/tinyvla/modeling_tinyvla.py +213 -0
- src/tinyvla/modules/__pycache__/embodiment.cpython-312.pyc +0 -0
- src/tinyvla/modules/__pycache__/expert.cpython-312.pyc +0 -0
- src/tinyvla/modules/__pycache__/semantic.cpython-312.pyc +0 -0
- src/tinyvla/modules/__pycache__/vision.cpython-312.pyc +0 -0
- src/tinyvla/modules/embodiment.py +35 -0
- src/tinyvla/modules/expert.py +128 -0
- 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)
|