VLAwithVariousSpeed / README_ablation.md
Alan0928's picture
Upload folder using huggingface_hub
08ff31f verified
|
Raw
History Blame Contribute Delete
17.4 kB
# Variable-Speed Ablation Sweeps
Workflow for running ablation studies that compare different target-speed
sets, different ways of integrating the speed signal into the model, and
different soft-prompt capacities, against a shared LIBERO evaluation harness.
For the underlying single-pipeline workflow (build one dataset, train one
model), see `VARIOUS_SPEED_README.md`.
## 1. Research questions
The default `ABLATIONS` table in `scripts/run_ablations.py` answers three
research questions:
1. **Step size** β€” holding the speed range fixed at `[0.5, 2.0]`, does denser
speed coverage improve generalization?
2. **Range** β€” holding step size roughly fixed, how much does the speed range
matter? Where does the policy break?
3. **Integration strategy** β€” given a fixed training speed set, does it
matter *how* the speed signal reaches the model: as text in the
instruction prompt, as a continuous feature that modulates the action
expert (adaRMSNorm), or as a learned soft-prompt inserted between the
image and the language tokens?
Plus a hyperparameter sweep on the soft-prompt capacity:
4. **Soft-prompt P** β€” how many learnable tokens per speed anchor are
actually needed?
### Speed-set sweep (text conditioning across all entries)
| name | speeds | role |
|----------------|---------------------------------------------------|-------------------------------|
| `g1_baseline` | `[1.0]` | no augmentation, baseline |
| `g2_coarse` | `[0.5, 1.0, 1.5, 2.0]` | wide range, coarse step (0.5) |
| `g3a_step025` | `[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]` | wide range, fine step (0.25) |
| `g4_narrow` | `[0.75, 1.0, 1.25, 1.5]` | narrow range probe |
| `g5_extreme` | `[0.25, 0.5, 1.0, 2.0, 4.0]` | extreme range probe |
### Speed-integration sweep (fixed speeds `[0.75, 1.0, 1.25, 1.5]`)
| name | speed_integration | model surgery |
|-----------------------|-------------------|----------------------------------------|
| `speedint_text` | `text` | none (instruction prompt only) |
| `speedint_modulation` | `modulation` | requires `Pi0Config.speed_modulation=True` -> MLP head + adaRMSNorm in action expert |
| `softprompt_p8` | `soft_prompt` | K=4 Γ— P=8 learnable tokens (also serves as the `P=8` arm of the P-sweep below) |
### Soft-prompt P-length sweep (same speeds, varies tokens per anchor)
| name | P |
|-------------------|----|
| `softprompt_p1` | 1 |
| `softprompt_p4` | 4 |
| `softprompt_p8` | 8 |
| `softprompt_p16` | 16 |
| `softprompt_p32` | 32 |
`g4_narrow`, `speedint_text`, `speedint_modulation`, and all five
`softprompt_p*` entries share the same speed set
`(0.75, 1.0, 1.25, 1.5)` -- the runner builds that dataset only once.
### Run-time dedup
- **Build dedup**: ablations with identical `speeds` reuse one built dataset
directory.
- **Norm-stats dedup**: ablations whose effective `asset_id` collides reuse
one `norm_stats.json`. The five `softprompt_p*` entries declare
`shared_norm_key="softprompt_shared"` because norm stats depend only on
the dataset, not on the soft-prompt parameter `P`.
- For the default 12-ablation table, this means **5 dataset builds, 8 norm
stats computes, and 12 training runs**.
### Note on data-volume confound
Each ablation produces `len(speeds) Γ— source_episodes` training samples, so
larger speed sets train on more total data. When comparing groups, prefer
plotting metrics against **samples seen** (or epochs), not raw step count,
to disentangle "more granular speeds" from "more data".
## 2. Calibrate eps once (data-driven cleaning thresholds)
Before the first build, profile the source dataset to pick `--clean-*-eps`:
```bash
uv run python scripts/profile_action_norms.py \
--src "$SRC" \
--output "$SRC/action_norm_profile.json"
```
If you want near-zero cleaning, use the printed P1 / P5 percentiles as
`--clean-eps` overrides. The default is `0.0` (no cleaning), since LIBERO
demos never fall below `1e-4` in practice. Set `--clean-eps 1e-4` (or the
percentile value) if you bring in a noisier dataset.
## 3. Two ways to drive a sweep
### (a) Data-prep only (separate machine)
```bash
export SRC=/path/to/libero_data
export ROOT=/path/to/ablation_outputs
uv run python scripts/build_ablation_datasets.py \
--src "$SRC" --out-root "$ROOT" \
--num-workers 16 \
--clean-eps 0.0 # 0 = no cleaning (LIBERO default); set 1e-4 for noisier data
```
This shells out to `scripts/build_libero_speed_dataset_mp.py` once per
unique speed set. Each dataset lands at
`$ROOT/libero_speed_<speed_token>_<run_tag>/`.
### (b) End-to-end (build + norm-stats + train)
```bash
uv run python scripts/run_ablations.py \
--src "$SRC" --out-root "$ROOT" \
--train-config pi05_libero_various_speed_all \
--base-asset-id libero_various_speed_all_pi05 \
--exp-prefix pi05_ablation \
--num-train-steps 30000 \
--build-num-workers 16 --train-num-workers 8
```
Useful flags: `--only NAME[,NAME]` to run a subset, `--skip-build /
--skip-norm-stats / --skip-train` to bypass stages, `--dry-run` to print
commands without running them. **Always do a `--dry-run` once before the
first real run** to sanity-check the generated CLI strings (especially
`--model.soft-prompt-speeds 0.75 1 1.25 1.5` which must be space-separated).
## 4. How configs are scoped per ablation
A single `TrainConfig` name (e.g., `pi05_libero_various_speed_all`) is
reused across all ablations. Per-ablation differences are applied at the CLI
via tyro's `overridable_config_cli`:
| Stage | Override |
|----------------------|------------------------------------------------------|
| `build` | `--speeds <ab.speeds>` and per-(speeds) `--dst` |
| `compute_norm_stats` | `--repo-id <dataset_dir>` `--asset-id <effective_asset_id>` |
| `train_pytorch` | `--data.repo-id`, `--data.assets.asset-id`, `--data.speed-integration`, `--exp-name`, `--eval-speed-set`, plus per-ablation `extra_train_args` |
`effective_asset_id = "<base>_<ablation.name>"` unless `Ablation.shared_norm_key`
is set, in which case it is `"<base>_<shared_norm_key>"`. Norm stats are
written to
`assets/<train_config_name>/<effective_asset_id>/norm_stats.json`.
`--eval-speed-set` makes the wandb per-speed loss breakdown follow the
ablation's own speeds. It only fires when `use_flow_control=True`
(i.e. the modulation path); for `text` and `soft_prompt` paths it's
harmless dead-code that nonetheless stays in sync.
## 5. Diagnostic outputs after each build
Three artifacts under `<dataset_dir>/meta/`:
- `cleaning_summary.json` β€” frame counts and ratios that were near-zero and
got zeroed by `clean_near_zero_actions`. Cross-check against the
percentiles in `action_norm_profile.json` from Β§2.
- `replay_summary.json` β€” per-target-speed mean/median/max of integrated
translation/rotation L2 error vs. source, path-length ratios, padded
ratio, and gripper-switch delta. Also printed to stdout at end of build.
- `speed_metrics.jsonl` β€” one row per `(source_episode, target_speed)` with
the same raw fields as above; useful for plotting.
If `transl_L2 / rot_L2 medians` are not at floating-point noise level
(~1e-7), or if `gripper_delta sum != 0`, something is wrong with that
build.
## 6. LIBERO evaluation across 8 GPUs
For each trained model, evaluate at multiple speeds β€” including
out-of-distribution values β€” to measure interpolation vs. extrapolation:
```text
training: g3a_step025 ([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0])
evaluation: [0.4, 0.5, 0.6, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
```
For each speed, run all four LIBERO suites on the 8-GPU server. Use
`scripts/eval_libero_8gpu.sh`, which partitions the work to roughly equal
wall-clock per GPU:
| GPU | suite | task_ids | episodes |
|-----|-------------------|----------|----------|
| 0 | `libero_spatial` | all | 500 |
| 1 | `libero_goal` | all | 500 |
| 2 | `libero_object` | all | 500 |
| 3 | `libero_10` | `0,1` | 100 |
| 4 | `libero_10` | `2,3` | 100 |
| 5 | `libero_10` | `4,5` | 100 |
| 6 | `libero_10` | `6,7` | 100 |
| 7 | `libero_10` | `8,9` | 100 |
`libero_10` (libero_long) has the longest rollouts (`max_steps=520`), so
splitting it 5 ways balances total wall-clock against the three short
suites at `max_steps ≀ 300`.
### Step 1: launch one policy server per GPU
```bash
for g in 0 1 2 3 4 5 6 7; do
CUDA_VISIBLE_DEVICES=$g uv run python scripts/serve_policy.py \
policy:checkpoint --policy.config=pi05_libero_various_speed_all \
--policy.dir=checkpoints/<run>/<step> --port=$((8000 + g)) &
done
```
### Step 2: launch the 8-way eval driver
```bash
SPEED=1.25 ./scripts/eval_libero_8gpu.sh
```
This dispatches `scripts/eval_libero_speed.py` 8 times in parallel (one per
GPU/server), each writing per-episode results to
`results/libero_eval_<speed>x_<ts>/<label>_<speed>x.json` and a video
directory under the same root.
### What gets recorded per episode
`scripts/eval_libero_speed.py` records:
- `success` (bool, from `env.step` returning `done=True` before max_steps)
- `steps` (int, **policy steps actually executed**, excluding the
`num_steps_wait` warmup frames)
- `task_id`, `episode_idx`, `task_description`, `suite`, `speed`
Successful rollouts terminate as soon as the env returns `done`; failures
run to `max_steps` for the suite.
### What gets printed per rank and globally
Each rank prints a summary line like:
```
[rank=0] libero_spatial speed=1.25x success=412/500 (82.4%) mean_steps_success=87.3 median=82.0 mean_steps_failure=220.0 mean_steps_all=110.6
```
After all 8 ranks finish, the driver auto-aggregates per suite (merging
the 5 `libero_10` shards back into one row) and globally:
```
--- per-suite rollup ---
libero_spatial success=412/500 (82.4%) mean_steps_success=87.3 mean_steps_all=110.6
libero_goal success=...
libero_object success=...
libero_10 success=... (merged across ranks 3-7)
GLOBAL (speed=1.25): success=1342/1500 (89.5%) mean_steps_success=128.5 mean_steps_all=156.8
```
Use the `mean_steps_success` vs. `mean_steps_all` gap as a fast read-out:
when a policy fails, it tends to walk to the time limit, so
`mean_steps_all` rises sharply while `mean_steps_success` stays roughly
constant.
### How `speed` reaches the policy
The eval client adds both `"speed": float(speed)` and
`"speed_label": "1p25x"` (etc.) to the element dict it sends to the
websocket server. Whichever integration strategy was trained, the
corresponding pipeline consumes the appropriate key:
- `text` β†’ data-side `SpeedConditionedPrompt` reads `speed_label` and
rewrites the instruction prompt
- `modulation` β†’ model-side reads `observation.speed` (raw scalar) and
feeds it through an MLP that fuses with the timestep embedding to drive
adaRMSNorm in the action expert
- `soft_prompt` β†’ model-side reads `observation.speed` for nearest-anchor
lookup over the K learnable token groups
`modulation` and `soft_prompt` share the same `observation.speed` field
end-to-end; there is no separate `flow_control` channel anymore.
For OOD speeds at eval time, `text` benefits from token-level extrapolation,
`modulation` is naturally continuous, but `soft_prompt` falls back to the
nearest training anchor (e.g., `speed=2.0` with anchors `(0.75, 1.0, 1.25,
1.5)` clamps to `1.5`). This is by design and is the main reason to compare
the three strategies on the same OOD test set.
## 7. Implementation notes
The speed-integration paths (modulation + soft-prompt) were implemented /
refactored across these files (branch `0502_mp_process`):
- `src/openpi/models/model.py` β€” `Observation.speed` is the single
end-to-end channel for raw target speed. The legacy `flow_control` field
(and the `LogSpeedControl` data transform that produced it) was removed.
- `src/openpi/models/pi0_config.py` β€” added `speed_modulation: bool`
(replaces the old `flow_control_dim: int`), `soft_prompt_speeds`
(tuple of K anchor speeds), and `soft_prompt_p` (tokens per anchor).
- `src/openpi/models_pytorch/pi0_pytorch.py`:
- `__init__` registers two optional sub-modules:
- `speed_mod_mlp_in/out` + `speed_condition_mlp_in/out` when
`speed_modulation=True`. Reads raw `observation.speed`
(shape `(B, 1)`), pushes through MLP, fuses with timestep embedding,
sends to action expert as `adarms_cond`.
- `soft_prompt_tokens: nn.Parameter` of shape
`(K, P, paligemma_width)` with `N(0, 0.02)` init, plus a
non-persistent buffer `soft_prompt_anchors: tensor(K,)`, when
`soft_prompt_p > 0`.
- `embed_prefix` does an argmin nearest-anchor lookup, then inserts
`(B, P, hidden)` soft-prompt tokens between vision and language with
full attention.
- `embed_suffix` reads `speed` directly (raw scalar) for the
modulation path; the MLP head is wide enough to learn any monotonic
transform internally so log-scaling is unnecessary.
- `_preprocess_observation`, `forward`, `sample_actions`, and
`denoise_step` plumb `speed` through.
- `src/openpi/models_pytorch/preprocessing_pytorch.py` β€” passes `speed`
through `SimpleProcessedObservation`. (Previously it silently dropped
`flow_control`, which is why the old modulation path appeared
half-baked.)
- `src/openpi/policies/libero_policy.py` β€” `LiberoInputs` passes
`speed` through to the observation dict.
- `src/openpi/models/pi0.py` β€” JAX `Pi0` mirrors the PyTorch rename
(`speed_modulation`, `speed_mod_mlp_*`, `speed_condition_mlp_*`,
reads `obs.speed`).
- `src/openpi/training/config.py` β€” `LeRobotVariousSpeedLiberoDataConfig`
has a single high-level `speed_integration: Literal["text",
"modulation", "soft_prompt"]` switch (no more `auto`,
`use_flow_control`, or `use_speed_prompt` plumbing). The
`pi05_libero_various_speed_all_flow_prompt/noprompt` configs were
replaced by `pi05_libero_various_speed_all_modulation`.
- `tests/test_soft_prompt_smoke.py` β€” light tests for config validation
and the argmin nearest-neighbor logic. The full forward-pass test is
gated to manual GPU runs because PaliGemma weights are heavy.
### Token budget
For PI0.5, `max_token_len=200` counts only the language tokens (image and
soft-prompt tokens come from a separate budget). With `P=8`, the soft prompt
adds 8 prefix tokens; `P=32` is still well under typical attention budgets.
For sanity, `pi0_config.py:max_token_len` only constrains the tokenized
prompt, not the visual or soft-prompt embeddings.
### Inference at OOD speeds (soft-prompt only)
`embed_prefix` uses an argmin lookup, so a speed not in
`soft_prompt_speeds` falls back to the nearest training anchor. If you want
linear interpolation between two adjacent anchors instead, modify the
lookup in `pi0_pytorch.py:embed_prefix`. (`text` and `modulation` paths do
not have this limitation.)
## 8. Adding a new ablation
1. Append `Ablation(name, speeds, ...)` to the `ABLATIONS` tuple in
`scripts/run_ablations.py`. Use a short unique name -- it is embedded in
dataset directory, asset_id, and exp_name.
2. (Optional) If the new group uses very different speeds than what
`profile_action_norms.py` was last run for, re-run it.
3. `build_ablation_datasets.py --only <new_name>` to produce the dataset
(or skip if it shares speeds with an existing one).
4. `run_ablations.py --only <new_name> --skip-build` to compute norm-stats
and train.
5. After training, `eval_libero_8gpu.sh` to evaluate at the speeds you care
about.
## 9. Files added by this workflow
```text
# New
scripts/profile_action_norms.py # action-norm profiler (eps calibration)
scripts/build_libero_speed_dataset_mp.py # multi-process speed dataset builder
scripts/build_ablation_datasets.py # data-prep stage of ablation sweep
scripts/run_ablations.py # end-to-end ablation runner
scripts/eval_libero_speed.py # LIBERO eval client w/ speed + step tracking
scripts/eval_libero_8gpu.sh # 8-GPU eval driver
tests/test_soft_prompt_smoke.py # smoke tests for soft_prompt logic
# Updated
scripts/build_libero_speed_dataset.py # writes cleaning_summary.json + replay_summary.json
scripts/compute_norm_stats.py # accepts --repo-id / --asset-id overrides
scripts/train_pytorch.py # eval_speed_set config-driven; wandb image logging removed
src/openpi/models/model.py # Observation.speed field
src/openpi/models/pi0_config.py # soft_prompt_speeds / soft_prompt_p fields
src/openpi/models_pytorch/pi0_pytorch.py # soft_prompt model surgery
src/openpi/models_pytorch/preprocessing_pytorch.py # bug fix: flow_control + speed pass-through
src/openpi/policies/libero_policy.py # speed pass-through
src/openpi/training/config.py # eval_speed_set, speed_integration fields
src/various_speed/core.py # cleaned_any/both metrics + ratios
```