VLA normalization stats, from LeRobot to serving
The policy that moves your robot arm never sees your joint angles. It sees numbers that were shifted and scaled into a small band, and it predicts in that band. The map between your units and that band is a set of numbers. They are called norm stats, short for normalization statistics. The map is computed once from your dataset and stored in a small file beside the model. When the map is wrong, nothing crashes. The arm just moves wrong, and at times it moves in ways that are not safe. This guide covers the numbers, the file, the mode each model family wants, the failure signs, how to check and fix your own, and what a served policy owes you across an API.
The subject hurts, and the hurt hides: people hit these failures with a working robot in front of them. Search for "lerobot normalization" and you get a source file, a compatibility page, and a bug report, not an explanation. This guide covers the whole path, from the dataset file to the served endpoint, and every load-bearing claim carries its source.
What the model actually sees
A robot dataset stores raw values. Joint positions in the units your arm reports. Gripper openings on their own scale. Camera pixels from 0 to 255. A network trains badly on numbers like that: the model would spend its capacity on scale instead of behavior. So the values get mapped into a small band first, most often -1 to 1. The model then works in that band for its whole life.
Norm stats are the numbers of that map. Three forms exist, and you will meet all three. Each is a short formula, applied per joint:
mean/std : normalized = (x - mean) / std
min/max : normalized = 2 * (x - min) / (max - min) - 1
quantile : normalized = 2 * (x - q01) / (q99 - q01) - 1
The first form centers each column at zero. It then divides by the spread. The second stretches the observed range onto -1 to 1. The third does the same, but it trims the ends. It uses the 1st and 99th percentile, q01 and q99, not the raw extremes. That trim is the point. One episode with a servo pressed to its hard stop would stretch a min/max band for the whole dataset. Everything else would get squeezed into a sliver. The percentile band shrugs that episode off. Two more modes exist. One, identity, passes values through untouched. The other uses the 10th and 90th percentile. These five are the full set in LeRobot's code, and each error message names the mode it wanted (normalize processor source). Same joint, same reading, two maps: at mean 20 and spread 10 a reading of 30 maps to 1.0, while at q01 of -80 and q99 of 80 it maps to 0.375.
The direction that matters at serving time is the reverse. The model outputs numbers in the band. Your arm needs units. The same stats run backwards. Multiply back the spread, add back the center. The reverse step is exact. It only works if it uses the same numbers training used. That one sentence is the whole subject of this guide.
Why nothing crashes: a wrong map is still a valid map. Scaled by the wrong stats, the model happily predicts in the band, and those numbers get unscaled by the wrong stats. Your arm goes somewhere it should never go. The loss can even fall while training. Every failure below is a variation on this theme.
The file, and what each policy wants from it
In LeRobot, the numbers live in meta/stats.json inside the dataset. There is one entry per feature. One for the state, one for the actions. Each entry holds one array per stat, one number per joint: min, max, mean, spread, a frame count, and five quantiles on current datasets. The default quantile list is 1, 10, 50, 90, and 99 percent, tracked with a running histogram as episodes are written (dataset format docs, compute stats source).
Older datasets stop at min, max, mean, and spread. That gap is what the loud crash in the next section is about.
Each policy family declares which stats it wants, per input type. The defaults differ, and the defaults decide what your dataset must carry. This table is checked against each policy's own config code, on October 5, 2026:
| Policy | State and action mode | Dataset must carry | If missing |
|---|---|---|---|
| act | mean/std | mean, std | clear error |
| smolvla | mean/std | mean, std | clear error |
| pi05, pi05-droid, pi05-libero | quantile, q01 and q99 | quantiles | the quantile ValueError |
| molmoact2-so100-101 | quantile, q01 and q99 | quantiles, or the shipped stats artifact | rough substitutes rescale the actions wrong |
| openvla-oft | quantile bounds on its LIBERO and Bridge setups, plain bounds on ALOHA | its own per-dataset stats, picked by an unnorm_key | wrong-scale actions |
| x-vla-0.9b | identity in its LeRobot config, the body handled by the port | the port's own input handling | silent, port-specific |
Two rows need one more sentence each. OpenVLA-OFT sets its scheme per robot platform in its own repo. Quantile bounds at the 1st and 99th percentile for its LIBERO and Bridge setups. Plain min/max bounds for ALOHA. At inference you pass an unnorm_key. That key names the dataset whose stats should undo the output (constants file). The base OpenVLA keeps the same idea. It loads a dataset_statistics.json file that ships beside the fine-tuned weights (deployment code). X-VLA is the odd row. It handles body differences with learnable prompts, not with the stats file. Its ports carry their own input handling (X-VLA docs).
Images are left out of the table on purpose. Most families here pass them through untouched. ACT scales images too, with its dataset's own stats, and its default handles that without you. The stats you must match by hand are the state and action ones. The one old trap on the image side, an assumption that images arrive in the 0 to 1 range, is closed in the tracker.
The failure gallery: loud, silent, and physical
Failures come in three registers. Only the first is honest enough to stop you.
The loud failure. You start training a quantile family model on an older dataset. The first batch dies:
ValueError: QUANTILES normalization mode requires q01 and q99 stats
That string is verbatim from the official pi05 docs. The same page carries the two fixes. Recompute the stats, or switch that run to mean/std mode (pi05 docs). The error is a gift. Nothing was quietly ruined.
The silent failures. These are worse, and they have a multi-year history in the tracker:
- Stats silently skipped at inference. Issue #4415, open since August 2026. LeRobot had moved normalization out of the model weights and into separate processing steps. Checkpoints trained on several datasets, the published SmolVLA base among them, kept their stats under a nested key. The inference path looked them up under the plain key. It missed. It passed the values through untouched. The policy ran on raw numbers, with no error at all (issue #4415).
- Silent fall to no normalization. Issue #821, from March 2025. The same class in an older code layout. A mode setting fell back to identity. Training ran on raw actions (issue #821).
- Cracks on the config surface. A YAML crash that names the normalization mapping, from October 2026. A migration script that emits an empty config file, from September 2026. Both are open. Both sit on the same moving surface (issue #4840, issue #4649).
The root is documented. A migration moved normalization out of the weights, into external processing steps. Checkpoints from before the move embed their stats inside the weights. Newer ones expect them outside (backward compatibility page).
The physical failures. These are the community's own words, and they are why this subject needs a guide. In an August 2026 build thread on r/robotics, a builder described a fine-tuned SmolVLA whose base checkpoint had its "statistics messed up". The outputs came out as "actions all around 0". The attempts to fix the scaling were described in the thread's own words: the arm would "try to kill itself". That is the failure mode in one line. Wrong stats do not give you an error. They give you motion. Field reports add the quieter shapes. A loss that falls while the arm barely moves. One frozen joint while the rest work. And an engineer who reproduced a published pi0.5 fine-tune at one percent success, while doubting the stats in the same breath (openpi issue #711, October 2025).
A decoder, built from those records:
| What you see | Check first |
|---|---|
| Training dies on the first batch with the quantile error | old dataset, no q01 or q99: recompute, or switch the run to mean/std |
| Actions all around 0, or the arm barely moves | stats skipped or mismatched: confirm the stats actually loaded |
| One joint frozen, the rest work | that joint's recorded range does not fit its live values |
| Loss goes NaN from the first step | a span of nearly zero somewhere, a joint that barely moved |
| Trains well, overshoots on the real arm | the numbers used to unscale at serving differ from training |
| Checkpoint moved between frameworks, arm misbehaves | the two frameworks keep stats differently: re-derive, do not reuse |
Embodiment swaps change the stats
Norm stats are not generic. They describe one robot body: how many joints, where the cameras sit, what the gripper is. Change the body, and every number in the file describes the wrong animal.
The clearest example ships with MolmoAct2. Its maker posts a norm_stats.json next to the weights. The header says "norm_mode": "q01_q99". It holds seven named sections, one per robot family it was tuned for. The SO-100 and SO-101 section carries more than stats. The action horizon of 30 steps. The control mode, "absolute joint pose". A setup sentence fed to the model as text. And full per-joint stat arrays for the actions, min, max, mean, and more, in that arm's own degree scale (the artifact itself, read October 5, 2026). A trimmed excerpt:
"so100_so101_molmoact2": {
"action_horizon": 30,
"control_mode": "absolute joint pose",
"setup_type": "single so100/so101 robotic arm in molmoact2",
"action_stats": {
"min": [-122.6, -270.0, -269.2, -125.8, -269.9, -31.6],
"max": [ 179.2, 219.6, 195.4, 178.9, 269.8, 119.4],
"mean": [ 3.3, 125.6, 120.0, 56.0, -11.6, 11.2]
}
}
The LeRobot port of that model picks a section by name, a norm tag. The tag brings the stats, the prompt text, the image key order, and the horizon along with it. The docs are blunt about the tempting shortcut. Do not feed rough quantiles from a benchmark dataset in place of the released file. The values differ, and the gap shows up as worse actions (MolmoAct2 docs). The same page carries a second law for this arm family. The SO-100 and SO-101 joint frame, the sign flips and angle offsets that map the motors to the training data, is already baked into the converted checkpoint, and a fix for exactly this landed in June 2026 (change #3879). The arm-specific how-to, cameras included, lives in the MolmoAct2 guide.
When you fine-tune on your own robot, one decision is yours alone. Reuse the maker's stats, or recompute over your own data. The pi model maker puts the choice in writing. If your robot and task sit close to a named family, reusing that family's stats can work better, because the actions look more familiar to the model. Their advice is to try both, and keep what scores higher (openpi norm stats docs). Their published sets cover Franka, UR5e, ARX, and ALOHA-class arms, plus DROID and mobile setups. Your arm's units must match the family you borrow from, or the borrowed map is silently wrong.
One more trap, from the official compatibility page. It looks like a stats problem, but the units come first. Some arms report degrees. Some report a normalized internal scale. Those are different bodies, even with the same motors. The page maps a joint range of -180 to 180 degrees onto a normalized -100 to 100, with the gripper on 0 to 100. Feed the wrong convention, and every number shifts. That looks exactly like wrong stats, until you check the units.
Norm stats at serving time
All of the above happens on your machine. The file sits beside the checkpoint, and the same code reads it on both sides. Serving adds a second boundary. The model runs somewhere else, and it scaled its output with the stats it was trained with. You cannot read that host's disk. So the answer itself must tell you what was applied. The numbers that shaped the actions travel with the actions.
This is where self-hosting and a hosted route truly differ, so be precise about who owns what. Self-hosting, you own five things. The stats file your training wrote. The one your serving path reads. The proof they are the same file. The reverse step on the output. The input scaling on the state. Every silent failure in the gallery above is a failure of one of those five.
A hosted action route moves three of them onto the platform. The model side applies the checkpoint's own stats. The response carries a normalization block stating what was applied, passed through exactly as the model made it. You read the block, confirm the body matches your robot, and unscale accordingly, or you trust the numbers because you picked the model row for your arm. What stays yours is worth saying plainly. Your state must arrive in the units your body's contract expects. Your cameras must feed the views the model trained on. Your emergency stop is yours.
The honest state comes first. The action lane is not on the public list. Nothing here is serving. The contract is fixed and published, though, so you can build against the shape today. The request is a multipart form. It carries the model name, an instruction, the robot state as a JSON array of numbers, and one or more camera images. The answer has three fields. The action chunk. The normalization block. A usage object for the bill. Field by field, that is the run-action reference and the action route page. The deep per-model stats tables live in the route's companion reference, robot embodiment stats.
The published contract types this block as an opaque object, passed through exactly as the model produced it, so the keys are the checkpoint's own. A q01_q99 checkpoint's block looks like this in practice:
{
"actions": [
[0.11, -0.79, 1.24, 0.41, -0.19, 0.91, 0.55]
],
"normalization": {
"q01": [ "one number per action dimension" ],
"q99": [ "one number per action dimension" ]
}
}
One served policy, one stated map, every response. If your arm ever moves wrong through a route like this, read the normalization block first. It tells you whether the numbers that shaped those actions were the numbers for your body. The VLA lane page names the model families this route is built around, and each model has its own page. For the cost side, serving this way against renting your own GPU, see the break-even guide. Where the latency budget goes, edge or cloud, is the Jetson versus cloud guide.
Check your stats in five minutes
The file is JSON. You need no GPU, no training stack, and no hour.
First, open it. A healthy entry has one value per joint in every array. Means that sit inside their min and max. No span of nearly zero. A joint whose q99 minus q01 rounds to zero never moved, and it blows up the scaled values near it.
Second, run the community doctor. A tool called lerobot-doctor checks LeRobot datasets directly. Its statistics pass looks for NaN values, zero-spread dimensions, and drift between the stats file and the data. A second pass checks whether a dataset is ready for training, and flags zero-spread joints before a run (GitHub, checked October 5, 2026). It runs on local paths and on Hub datasets, needs no training library installed, and ships as a tab inside the official dataset visualizer.
Third, if the stats are missing or stale, use the official recipes. Verbatim from the pi05 page:
lerobot-edit-dataset \
--repo_id your_dataset \
--new_repo_id your_dataset \
--operation.type recompute_stats \
--operation.overwrite true
Two placement traps live in that command, and the docs warn about both. Without a matching new_repo_id and the overwrite flag, the tool writes a new dataset with a suffix. It does not touch yours. A later training run then reads the old stats in silence. And the result lands under $HF_LEROBOT_HOME/your_dataset. That is not the cache your training command reads by default. Point the run at that directory, or push the dataset to the Hub first. To add quantiles to a dataset that predates them, the histogram script does it. Skip video decoding if you only need state and action columns. Its estimates differ slightly from merged per-episode values. That is by design, and it changes the training targets (pi05 docs).
Last, know when stats are not the problem. Too few episodes. Cameras that moved between recording and testing. Demos that disagree with each other. Correct stats fix none of that. The official guidance for the data side is blunt and numeric. At least 50 episodes. Ten per location. Cameras fixed. Consistent grasping (real robot training guide). This site's guide on the SO-101 loop covers the record and train legs in depth (the cloud-brain guide). How to measure whether the fix worked is the evaluation guide's question.
Common questions
What are norm stats in LeRobot? The numbers in a dataset's meta/stats.json. Per joint: min, max, mean, spread, and, on current datasets, quantiles at 1, 10, 50, 90, and 99 percent. Policies use them to map your robot's units into the small band the model trained on, and to map predictions back out. They travel with the checkpoint. A model and its stats are one object split across two files.
Why does my VLA output actions all around 0? Three usual causes, in order of likelihood. Stats were skipped at inference, a known issue class with a multi-year history. The stats that unscale the output belong to a different body than yours. Or the model learned to do nothing, because its targets were clipped flat. Print the share of output values sitting at exactly the band edges. Confirm the stats loaded. Compare the checkpoint's stats against the dataset's.
What does "ValueError: QUANTILES normalization mode requires q01 and q99 stats" mean? Your dataset predates quantile stats. It carries only min, max, mean, and spread. The policy you launched, a pi05-family or MolmoAct2-class model, wants the 1st and 99th percentile. Recompute the stats with the edit-dataset command, or run that training in mean/std mode instead.
Why did normalization silently fall to no normalization? The lookup for the stats can miss while the data keeps flowing. In the tracked cases, stats stored under a nested key were read under a plain key. The miss returned the values untouched. The policy ran on raw numbers. The fix is to read the checkpoint and its stats as a matched pair, through one path. To detect it, check that the loaded stats show up in the processing config before a run.
Do I need to recompute dataset stats after changing the action representation? Yes. A stats file records what the numbers were, not what they meant. Switching between absolute joint targets and offsets from the current state changes every value in the action column. The old file then describes a spread your data no longer has. Nothing in the file records the switch. Regenerate, and keep one dataset per representation.
Does a served policy need my norm stats? If you fine-tuned your own policy, yes. Its stats are part of it, and any host must apply them and state them. If you call a hosted model row for your body, the platform side owns the stats and states them in every response's normalization block. What you own is sending state in the units that body expects. On this platform's route, that block is contract, not docs.
Which models need quantile stats? In the current LeRobot defaults: the pi05 family and MolmoAct2, for state and actions, plus OpenVLA-OFT's LIBERO and Bridge setups in that repo's own scheme. ACT and SmolVLA want mean and spread. The modes table above carries the full comparison with sources.
The short version
The model never sees your units. It sees a band, and norm stats are the map. The map lives in meta/stats.json, and in the artifacts shipped with released checkpoints. It travels with the model. Every policy family picks its own formula. Wrong maps do not crash. They train quietly, serve quietly, and move arms wrongly, from dead outputs to lunges. Check the file before you blame the model. Run the doctor. Recompute with the placement traps in mind. Recompute again whenever the representation or the robot changes. When a policy is served across an API, the stats it applied must come back with the actions, stated every time. This platform's action route writes that into its contract. Its lane is not on the public list. Rates for the rows the platform lists render on the pricing page. Read the route reference, and sign up so a key is ready. A later guide on this site explains vision language action models as a class. It links back here for the stats half of the story.
Last verified: 2026-10-05