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:
- Step size β holding the speed range fixed at
[0.5, 2.0], does denser speed coverage improve generalization? - Range β holding step size roughly fixed, how much does the speed range matter? Where does the policy break?
- 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:
- 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
speedsreuse one built dataset directory. - Norm-stats dedup: ablations whose effective
asset_idcollides reuse onenorm_stats.json. The fivesoftprompt_p*entries declareshared_norm_key="softprompt_shared"because norm stats depend only on the dataset, not on the soft-prompt parameterP. - 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:
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)
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)
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 byclean_near_zero_actions. Cross-check against the percentiles inaction_norm_profile.jsonfrom Β§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:
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
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
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, fromenv.stepreturningdone=Truebefore max_steps)steps(int, policy steps actually executed, excluding thenum_steps_waitwarmup 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-sideSpeedConditionedPromptreadsspeed_labeland rewrites the instruction promptmodulationβ model-side readsobservation.speed(raw scalar) and feeds it through an MLP that fuses with the timestep embedding to drive adaRMSNorm in the action expertsoft_promptβ model-side readsobservation.speedfor 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.speedis the single end-to-end channel for raw target speed. The legacyflow_controlfield (and theLogSpeedControldata transform that produced it) was removed.src/openpi/models/pi0_config.pyβ addedspeed_modulation: bool(replaces the oldflow_control_dim: int),soft_prompt_speeds(tuple of K anchor speeds), andsoft_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/outwhenspeed_modulation=True. Reads rawobservation.speed(shape(B, 1)), pushes through MLP, fuses with timestep embedding, sends to action expert asadarms_cond.soft_prompt_tokens: nn.Parameterof shape(K, P, paligemma_width)withN(0, 0.02)init, plus a non-persistent buffersoft_prompt_anchors: tensor(K,), whensoft_prompt_p > 0.
embed_prefixdoes an argmin nearest-anchor lookup, then inserts(B, P, hidden)soft-prompt tokens between vision and language with full attention.embed_suffixreadsspeeddirectly (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, anddenoise_stepplumbspeedthrough.
src/openpi/models_pytorch/preprocessing_pytorch.pyβ passesspeedthroughSimpleProcessedObservation. (Previously it silently droppedflow_control, which is why the old modulation path appeared half-baked.)src/openpi/policies/libero_policy.pyβLiberoInputspassesspeedthrough to the observation dict.src/openpi/models/pi0.pyβ JAXPi0mirrors the PyTorch rename (speed_modulation,speed_mod_mlp_*,speed_condition_mlp_*, readsobs.speed).src/openpi/training/config.pyβLeRobotVariousSpeedLiberoDataConfighas a single high-levelspeed_integration: Literal["text", "modulation", "soft_prompt"]switch (no moreauto,use_flow_control, oruse_speed_promptplumbing). Thepi05_libero_various_speed_all_flow_prompt/nopromptconfigs were replaced bypi05_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
- Append
Ablation(name, speeds, ...)to theABLATIONStuple inscripts/run_ablations.py. Use a short unique name -- it is embedded in dataset directory, asset_id, and exp_name. - (Optional) If the new group uses very different speeds than what
profile_action_norms.pywas last run for, re-run it. build_ablation_datasets.py --only <new_name>to produce the dataset (or skip if it shares speeds with an existing one).run_ablations.py --only <new_name> --skip-buildto compute norm-stats and train.- After training,
eval_libero_8gpu.shto evaluate at the speeds you care about.
9. Files added by this workflow
# 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