
Dataset statistics define the map between robot units and the model input space. Change the action representation without regenerating them and the loss still falls while the arm does nothing.
A vision-language-action policy never sees your robot's joint angles. It sees numbers that have been shifted and scaled into roughly [-1, 1], trained on those, and predicts in that space. The shift and the scale come from a small metadata file: the dataset statistics. Get that file wrong and nothing crashes. The loss curve looks fine, the checkpoint saves, and the arm does something dull and confidently wrong.
This page is about that file. What is in it, where each trainer keeps it, why it has to be regenerated when you change how actions are represented, and the specific errors that follow when you do not. The numbers below are computed from the cube_to_bowl_5 demo dataset that ships inside NVIDIA's Isaac-GR00T repository, so you can reproduce every one of them. Versions checked on 2026-08-23: lerobot 0.6.2 on main, Isaac-GR00T main (gr00t 0.1.0), openpi main.
What you need to know
- •Dataset statistics are per-dimension mean, std, min, max and quantiles over your action and state columns. They define the affine map between robot units and the model's input space.
- •Absolute actions and relative actions are different numbers in the same column. On the GR00T demo dataset, shoulder_lift has a standard deviation of 57.8 degrees absolute and 3.6 degrees relative. The stats file does not record which one it holds.
- •LeRobot writes either one into the same meta/stats.json action entry. Recomputing with --operation.relative_action true replaces that entry with relative numbers, and nothing in the file records which representation it now holds.
- •GR00T keeps them apart in meta/stats.json and meta/relative_stats.json, and fingerprints them. But the fingerprint for stats.json hashes only dtype and shape, so adding episodes does not invalidate the cache.
- •Relative offsets scored with absolute statistics land in under a quarter of the usable range, or clip to a rail outright when the joint's absolute range excludes zero. Absolute targets scored with relative statistics saturate at the rails, because the relative ranges are several times too narrow. Neither raises an exception.
- •Regenerating is cheap: minutes of CPU, no GPU. An A100-class fine-tune on AY-Robots costs 4 to 12 USD. Check the file first.
What is actually in a stats file
A LeRobot dataset stores raw sensor and motor values. Joint angles in degrees, gripper aperture in whatever unit your calibration produced, image tensors in 0 to 255. A neural network trained directly on that mix would spend its capacity learning that elbow_flex lives around 50 and wrist_roll around -1.6. So every trainer normalises first, and the parameters of that normalisation are computed once over the whole dataset and written to a metadata file.
In LeRobot this file is meta/stats.json. It holds one entry per feature key, and each entry holds a fixed set of per-dimension arrays. LeRobot's default quantile list is [0.01, 0.10, 0.50, 0.90, 0.99], so a fully populated entry carries mean, std, min, max, count and the five quantiles. GR00T computes a narrower set: mean, std, min, max, q01, q99.
| Statistic | Computed over | Used by which normalisation mode | Fails when |
|---|---|---|---|
| mean, std | every frame in every episode | MEAN_STD (ACT, SmolVLA) | a dimension barely moves, so std approaches zero and the normalised values explode |
| min, max | every frame | MIN_MAX, and GR00T when use_percentiles is false | one bad episode with a servo at the hard stop stretches the range for the whole dataset |
| q01, q99 | 1st and 99th percentile | QUANTILES (Pi0.5), GR00T fine-tuning default | the dataset is too small for the tail to be meaningful |
| q10, q90 | 10th and 90th percentile | QUANTILE10 in LeRobot | rarely used, missing from most older datasets |
| count | number of frames aggregated | internal, for merging per-episode stats | not a failure mode, but it is how per-episode stats are weighted |
{
"action": {
"mean": [ 2.01, -58.36, 52.07, 84.16, -1.61, 9.50],
"std": [ 10.54, 57.81, 64.36, 10.12, 2.42, 15.96],
"min": [-29.98, -100.0, -97.84, 25.30, -11.13, 0.41],
"max": [ 12.48, 59.79, 99.61, 100.00, 3.92, 63.69],
"q01": [-28.77, -99.39, -91.99, 46.93, -11.08, 0.41],
"q99": [ 11.08, 56.50, 97.64, 100.00, 2.77, 63.11]
}
}Two things are worth noticing in those numbers. The fourth dimension, wrist_flex, has a q01 of 46.93 against a min of 25.30: the joint spends 99 percent of its frames above roughly 47 degrees and drops far below that in the remaining 1 percent. And min for shoulder_lift is exactly -100.0 while q01 is -99.39, which is the signature of a joint that touched a software limit in at least one episode. That gap between min and q01 is why GR00T defaults to percentiles rather than raw extremes.
Where each trainer keeps its statistics
Three ecosystems, three conventions. If you train more than one policy family on the same data, you will end up with all three on disk.
| Stack | File | Written by | Contains |
|---|---|---|---|
| LeRobot (v3.0 datasets) | meta/stats.json | dataset recording, or lerobot-edit-dataset with recompute_stats | one entry per feature key, including observation.state and action |
| Isaac-GR00T | meta/stats.json | gr00t/data/stats.py, and automatically at the start of a fine-tune | absolute stats for every float column in info.json, plus a reserved __fingerprints__ key inside the same file |
| Isaac-GR00T | meta/relative_stats.json | the same script, only for action keys marked RELATIVE | per-horizon-step stats, shape (chunk length, dims), plus its own fingerprints |
| openpi | assets/ | scripts/compute_norm_stats.py | mean, std, q01, q99 for the keys state and actions |
| Any trained checkpoint | statistics.json (GR00T) or normalizer buffers in the state dict (LeRobot) | save_pretrained | the stats the model was actually trained with, frozen |
This is the part people miss. GR00T's save_pretrained writes a statistics.json next to the model weights, and reads it back in from_pretrained. LeRobot serialises the normaliser's tensors into the saved processor state dict. So a checkpoint is not just weights, it is weights plus the exact affine map it was trained under. Serving a checkpoint against a freshly regenerated stats file is a real way to break a working policy, and the fix is to leave the checkpoint's own statistics alone.
Normalisation modes, and which policy uses which
LeRobot's NormalizationMode enum has five members: MIN_MAX, MEAN_STD, IDENTITY, QUANTILES and QUANTILE10. Each policy config declares a mapping from feature type to mode, and those defaults differ in ways that matter for which statistics your dataset needs to carry.
| Policy | VISUAL | STATE | ACTION | Statistics the dataset must carry |
|---|---|---|---|---|
| ACT | MEAN_STD | MEAN_STD | MEAN_STD | mean and std |
| SmolVLA | IDENTITY | MEAN_STD | MEAN_STD | mean and std |
| Pi0.5 | IDENTITY | QUANTILES | QUANTILES | q01 and q99 |
| GR00T N1.7 | handled inside the model processor | min/max or q01/q99 | min/max or q01/q99 | q01 and q99 by default, plus relative_stats.json |
| GR00T N1.5 | handled inside the model processor | min/max or q01/q99 | min/max or q01/q99 | same as N1.7 |
The practical consequence: a dataset recorded before quantile statistics existed will train ACT and SmolVLA without complaint and refuse Pi0.5 outright, with the message QUANTILES normalization mode requires q01 and q99 stats, please update the dataset with the correct stats using the `augment_dataset_quantile_stats.py` script. That is one of the few places in this whole area where you get a clear error instead of a bad policy.
normalised = 2 * (x - min) / (max - min) - 1
raw = (normalised + 1) / 2 * (max - min) + min
# GR00T then applies, with clip_outliers=True by default:
normalised = clip(normalised, -1.0, 1.0)
Absolute, relative and delta are different numbers in the same column
An action chunk can express the same motion in three ways. LeRobot's own documentation follows the terminology from the Universal Manipulation Interface paper: absolute, relative and delta. Absolute is the default everywhere. Relative subtracts the current state once, so every step in the chunk is an offset from the same reference. Delta subtracts the previous action, so errors accumulate along the chunk, which is why UMI argues against it.
| Representation | Definition | Typical magnitude on an SO-100 | Who uses it |
|---|---|---|---|
| Absolute | action[t] is the target joint position | tens of degrees, offset far from zero | LeRobot default, ACT, SmolVLA out of the box |
| Relative | action[t] - state[0], one reference per chunk | single-digit standard deviation at the head of the chunk, centred near zero | openpi and the pi family, GR00T for non-gripper joints |
| Delta | action[t] - action[t-1] | fractions of a degree | discouraged; error accumulates over the chunk |
The GR00T example config for the SO-100 makes the mixture explicit and it is worth reading closely, because it is the exact shape that trips people up: the arm joints are relative, the gripper is absolute, in the same action vector.
"action": ModalityConfig(
delta_indices=list(range(0, 16)), # predict 16 future steps
modality_keys=["single_arm", "gripper"],
action_configs=[
# single_arm: RELATIVE = delta from current state
ActionConfig(rep=ActionRepresentation.RELATIVE,
type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT),
# gripper: ABSOLUTE = target position
ActionConfig(rep=ActionRepresentation.ABSOLUTE,
type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT),
],
),The failure: same file, different meaning
Here is the whole problem in one sentence. A stats file records what the numbers were, not what the numbers meant. Nothing in meta/stats.json says whether the action column it summarises was absolute or relative. So when you flip the representation and reuse the old file, the normaliser applies a perfectly valid affine map derived from the wrong distribution.
The table below is computed from the shipped GR00T demo dataset. Column two is the absolute action range from meta/stats.json. Column three is the relative range for the first step of the chunk from meta/relative_stats.json. Column four is what happens when relative action values are normalised with the absolute statistics, which is exactly what you get if you switch on relative actions and skip the regeneration step.
| Joint | Absolute q01 to q99 (deg) | Relative q01 to q99, first step (deg) | Relative values scored with absolute stats | Share of [-1, 1] actually used |
|---|---|---|---|---|
| shoulder_pan | -28.8 to 11.1 | -4.2 to 4.7 | 0.23 to 0.68 | 22 percent |
| shoulder_lift | -99.4 to 56.5 | -14.4 to 11.7 | 0.09 to 0.42 | 17 percent |
| elbow_flex | -92.0 to 97.6 | -14.2 to 16.1 | -0.18 to 0.14 | 16 percent |
| wrist_flex | 46.9 to 100.0 | -7.3 to 11.3 | -3.04 to -2.34 | 0 percent, both ends clip to -1 |
| wrist_roll | -11.1 to 2.8 | -1.4 to 1.4 | 0.40 to 0.80 | 20 percent |
Read the wrist_flex row again. In the absolute dataset the q01 to q99 band for that joint is 47 to 100 degrees. Relative offsets around zero are therefore far below the recorded minimum, the normalised value comes out at -3.04 to -2.34, and GR00T's clip_outliers=True default squashes every single one to exactly -1. The training target for that joint is a constant. The model dutifully learns to output a constant, the loss for that dimension drops to near zero, and the wrist never moves. That is the textbook shape of loss falls but the policy does nothing.
LeRobot's recompute_stats operation, when called with relative_action=true, computes chunk-based relative statistics and then assigns them straight onto the action key: new_stats[ACTION] = relative_action_stats. The resulting meta/stats.json looks like any other stats file and nothing in it records the change. Two things follow. First, watch where the output goes: lerobot-edit-dataset writes to <repo_id>_recomputed_stats unless you pass --new_repo_id, and it refuses to modify the input dataset unless you also pass --operation.overwrite true. Train the original repo id after a recompute and you have simply trained the old statistics again. Second, if you train the recomputed dataset without --policy.use_relative_actions=true, absolute joint targets get normalised against relative ranges several times too narrow, everything saturates at the rails, and you get a bang-bang policy. Keep one dataset directory per representation.
The reverse direction is quieter but not harmless. Even in the rows above where nothing clips, the real signal occupies 16 to 22 percent of the representable range. Since the same wrong map is applied in both directions at inference, min-max normalisation does round-trip cleanly, so the policy is not immediately broken. What breaks is learning: the differences that matter are compressed into a narrow band, while the flow-matching or diffusion head is being asked to resolve them against noise defined over the full range.
joint absolute std relative std (step 0) ratio
shoulder_pan 10.54 1.13 9.3x
shoulder_lift 57.81 3.61 16.0x
elbow_flex 64.36 5.19 12.4x
wrist_flex 10.12 3.00 3.4x
wrist_roll 2.42 0.40 6.0xRegenerating statistics: the actual commands
All three stacks give you a way to do this from a terminal, and none of them needs a GPU. Regeneration is a CPU pass over the parquet files. Do it before you rent anything, not after; see what a run costs on the pricing page for why the ordering matters.
- 1LeRobot: recompute absolute statistics
The plain form. Use this after you add, delete or trim episodes, because LeRobot has no content fingerprint and will happily keep serving the old file. Note where the result lands: without --new_repo_id this writes a new dataset called your_user/your_dataset_recomputed_stats, and editing the input dataset in place additionally requires --operation.overwrite true.
bashlerobot-edit-dataset \ --repo_id your_user/your_dataset \ --operation.type recompute_stats - 2LeRobot: add quantiles to an older dataset
Required before Pi0.5 will train, because its normalisation mapping is QUANTILES for both state and action. Add --overwrite to replace quantiles that are already present, --skip-images to leave video features alone.
bashpython src/lerobot/scripts/augment_dataset_quantile_stats.py \ --repo-id your_user/your_dataset \ --skip-images - 3LeRobot: relative action statistics for the pi family
chunk_size should match your policy chunk size. relative_exclude_joints keeps listed dimensions in absolute space; it defaults to ['gripper'] and you almost always want that, because a binary open/close signal gains nothing from being expressed as an offset. The same output rule applies here: the recomputed dataset is written to your_user/your_dataset_recomputed_stats unless you say otherwise, so point the training run at that repo id and not at the original.
bashlerobot-edit-dataset \ --repo_id your_user/your_dataset \ --operation.type recompute_stats \ --operation.relative_action true \ --operation.chunk_size 50 \ --operation.relative_exclude_joints "['gripper']" - 4LeRobot: train with the matching flag
This flag and the previous command are a pair. Setting one without the other is the failure this whole article is about. use_relative_actions defaults to false.
bashlerobot-train \ --dataset.repo_id=your_user/your_dataset \ --policy.type=pi05 \ --policy.use_relative_actions=true \ --policy.relative_exclude_joints='["gripper"]' - 5Isaac-GR00T: generate both stats files
A tyro CLI. It writes meta/stats.json and, for every action key marked RELATIVE in your modality config, meta/relative_stats.json. The --modality-config-path argument is required for NEW_EMBODIMENT, which is what a custom SO-100 setup uses.
bashpython gr00t/data/stats.py \ --dataset-path ./my_so100_dataset \ --embodiment-tag NEW_EMBODIMENT \ --modality-config-path examples/SO100/so100_config.py - 6openpi: compute norm stats for a config
openpi computes statistics after running the data transforms, so if your config includes DeltaActions the resulting norm_stats.json is already in the relative space. This is the cleanest of the three designs, because the representation cannot drift away from the statistics.
bashuv run scripts/compute_norm_stats.py --config-name pi05_libero
Isaac-GR00T's loader still expects a LeRobot v2 dataset. A v3.0 dataset has to be converted down first with scripts/lerobot_conversion/convert_v3_to_v2.py, and the GR00T-specific meta/modality.json has to be copied into meta/ afterwards. If your dataset was rejected before you ever got to the statistics, start at the v3 rejection page.
What GR00T does for you, and what it still does not
GR00T is the only one of the three that tries to protect you. Its dataset factory calls generate_stats and generate_rel_stats on rank zero at the start of every fine-tune, so you rarely have to run the script by hand. And it fingerprints the cached entries so that a configuration change invalidates them.
| Change you make | Invalidates stats.json? | Invalidates relative_stats.json? | Consequence if not caught |
|---|---|---|---|
| Add or remove an action dimension | yes, shape is in the hash | no, the relative fingerprint covers the modality config only | stats.json is recomputed, relative_stats.json stays stale until deleted |
| Change dtype of a column | yes, dtype is in the hash | no, dtype is not in the relative fingerprint | stats.json is recomputed, relative_stats.json stays stale until deleted |
| Change delta_indices, the chunk length | no | yes | relative stats recomputed for the new horizon |
| Flip a key from ABSOLUTE to RELATIVE | no | yes, rep is in the hash | a relative entry appears for that key |
| Record 50 more episodes with a wider joint range | no | no | silently trains on statistics from the old subset |
| Recalibrate the arm, shifting every joint offset | no | no | silently trains on statistics from before the recalibration |
GR00T's cache key for meta/stats.json is a sha256 over exactly three things: the feature name, its dtype and its shape, all read from info.json. Nothing about the data itself is hashed. Doubling your dataset, re-recording every episode, or recalibrating the arm leaves all three unchanged, so the cached entry is judged fresh and reused. Delete meta/stats.json and meta/relative_stats.json by hand whenever the contents of the dataset change, not just its schema.
There is a second GR00T trap worth naming, because it produces no error at all. When the fine-tuning pipeline merges your dataset's statistics into the processor, it calls set_statistics with override=False by default. If the base checkpoint already carries statistics for the embodiment tag you selected, your dataset's merged statistics are discarded and only a log warning records it. Using NEW_EMBODIMENT for a custom SO-100 avoids this, because the base model has no statistics under that tag.
ValueError: Relative action statistics required for embodiment 'new_embodiment'
but 'relative_action' not found in statistics
ValueError: Relative action statistics required for key 'single_arm'
in embodiment 'new_embodiment' but not found
WARNING Statistics for embodiment 'oxe_droid_relative_eef_relative_joint'
already present; new stats DISCARDED (override=False). If the new
data differs from the existing distribution this will cause silent
normalization mismatch
Two ways to get this right
You control the dataset directory, so you control the statistics. The cost is that you have to be disciplined about it, every time, on every machine that touches the data.
- Clone the trainer you intend to use and pin the version. The flag names in this article are from lerobot 0.6.2 and Isaac-GR00T main as of 2026-08-23, and older tags differ.
- Record the dataset, then run the recompute for your target representation before anything else.
- Keep the absolute and the relative dataset as two repo ids rather than recomputing over one, and pass --operation.overwrite true only when you really mean to lose the absolute stats.
- Print the action mean and std and sanity-check them against the joint ranges you expect from your arm.
- Train, then read the first hundred steps of the loss. A loss that starts near zero on some dimensions is a clipping symptom, not a good sign.
- Keep the checkpoint's own statistics.json with the weights. Do not regenerate statistics for a checkpoint you already trained.
# write the relative-stats copy to its own repo id
lerobot-edit-dataset --repo_id me/task \
--new_repo_id me/task_rel \
--operation.type recompute_stats \
--operation.relative_action true \
--operation.chunk_size 50
# train the dataset whose stats you just recomputed
lerobot-train --dataset.repo_id=me/task_rel --policy.type=pi05 \
--policy.use_relative_actions=trueThis path is free, fully reproducible, and it is what you want if you are modifying the trainer or comparing representations as an experiment. It is also the path where a forgotten step costs you a rented GPU and a day.
The platform runs the trainers for you. You pick a model and a dataset in a form, the backend rents a GPU on a spot market sized to the required VRAM, runs the trainer, and writes checkpoints to object storage. Datasets can come from the public directory, from a Hugging Face repo id, or from your own machine via the desktop client.
What that buys you here is that the dataset preparation and the trainer invocation are wired together rather than typed twice. What it does not buy you is a normalisation-mode switch: the training form exposes batch size, learning rate, max steps, gradient accumulation and a small set of per-model knobs, and nothing else. If you need to compare absolute against relative statistics as an experiment, the do-it-yourself path is the honest answer.
| Policy | Dataset format | GPU tier | Minimum episodes | Typical cost per run |
|---|---|---|---|---|
| GR00T N1.7 | LeRobot v2.0 or v2.1 | A100 80 GB or H100 80 GB | 50 | 4 to 12 USD |
| Pi0.5 | LeRobot v3.0 | A100 80 GB or H100 80 GB | 50 | 4 to 12 USD |
| SmolVLA | LeRobot v3.0 | RTX 4090 or any 24 GB card | 30 | 1 to 3 USD |
| ACT | LeRobot v3.0 | RTX 4090 or any 24 GB card | 50 | 1 to 3 USD |
The format column is the one to read first. GR00T needs v2.0 or v2.1 and a v3.0 dataset crashes its loader, which is a conversion problem that shows up before the statistics problem does. Full walkthroughs live in the training docs and in train your first policy.
Symptoms and what they actually mean
Normalisation bugs do not announce themselves. They arrive disguised as model problems, which is why people spend a day tuning learning rates first. This table maps what you see to what to check. Isaac-GR00T's own fine-tuning guide lists the same class of symptom, attributing an exploding or NaN loss to action and state normalisation rather than to a model bug.
| What you see | Most likely cause | First thing to check |
|---|---|---|
| Loss drops fast then flattens near zero, policy barely moves | actions clipped to a rail by a too-narrow range | print the fraction of normalised targets at exactly -1 or +1 |
| One joint is frozen, the others work | that dimension's range does not contain its values in the new representation | compare per-dimension min/max against actual data for that joint |
| Loss is NaN from the first step | a std or a q01 to q99 span close to zero somewhere | look for a dimension whose range is a rounding error, usually a sensor that barely moved |
| Training looks fine, the arm overshoots on the real robot | checkpoint statistics and serving statistics disagree | diff the checkpoint's statistics.json against the dataset's stats.json |
| Policy works on the training episodes, fails on anything new | not a stats problem, a data problem | read the generalisation notes instead, linked below the table |
| Diverging loss with huge normalised values | a rarely used dimension with a tiny q99 minus q01 | openpi documents this exact case and suggests adjusting the stats by hand |
The fifth row is the one worth being careful about, because it is the case where statistics are not to blame. Generalisation failure looks superficially similar and has nothing to do with normalisation; the page on policies that only work in one setup covers that separately. The full list of failure modes and their fixes is at /fix.
A five-line check before you rent a GPU
You do not need any of the training stacks installed to look at a stats file. It is JSON. The check below takes seconds and catches most of what this article describes.
import json, numpy as np
s = json.load(open("meta/stats.json"))["action"]
mean, std = np.array(s["mean"]), np.array(s["std"])
lo, hi = np.array(s["q01"]), np.array(s["q99"])
print("mean :", np.round(mean, 2))
print("std :", np.round(std, 2))
print("span :", np.round(hi - lo, 2))
print("degenerate dims:", np.where(hi - lo < 1e-6)[0])
print("looks relative:", bool(np.all(np.abs(mean) < 5) and np.all(std < 20)))- Targets are centred near zero, so a fixed [-1, 1] output range is used efficiently across the whole workspace.
- The policy learns motion rather than absolute position, which transfers better when the arm is repositioned or recalibrated.
- Every step in the chunk references the same state, so there is no error accumulation across the chunk, unlike delta encoding.
- It composes cleanly with real-time chunking, because the reference state is captured once per inference call.
- It is a supported path rather than a fork: LeRobot ships the processor steps for the pi family, and GR00T's example SO-100 config already uses relative for the arm joints.
- It doubles your statistics bookkeeping: two files or two representations of the same column, and no marker in the file saying which you have.
- Relative statistics depend on the chunk length, so changing the horizon means recomputing them.
- A gripper is usually better left absolute, so your action vector is now mixed and the exclude list has to be right.
- It makes the policy depend on an accurate current state at inference; a stale or noisy state reading now biases the whole chunk.
- ACT and SmolVLA in LeRobot do not expose the relative flags that the pi family does, so the option is not available for every policy.
Where none of this helps
Correct statistics are necessary and nowhere near sufficient. If your demonstrations are inconsistent, if the camera moved between recording sessions, or if you have 20 episodes when the policy needs 50, a perfect stats file changes nothing. Normalisation bugs are worth ruling out early precisely because they are cheap to rule out, not because they are the most common cause of a bad policy.
It is also worth being clear about what the platform does and does not do for you here. AY-Robots runs the trainers and rents the GPUs; it does not expose a normalisation-mode selector, and it cannot tell you whether your action column is absolute or relative any more than the file itself can. And on the serving side there is a hard physical limit that no amount of preprocessing fixes: the control loop is 20 to 485 ms per action step depending on the model, so inference latency over the public internet turns a working policy into a hesitant one. Remote inference is viable for slow pick-and-place and not for fast reactive motion.

Something already broken?
The failure-mode pages cover the specific symptoms: a loss that falls while the policy does nothing, a dataset rejected for being v3.0, a policy that freezes mid-motion. Each one names the check to run first.
Open the fix indexRelated reading
If you are earlier in the pipeline, collecting high-quality VLA training data covers what to record in the first place, and the full SO-100 guide covers build, teleoperation and training end to end. For the model side, the VLA overview explains what the action head is doing with those normalised numbers. If you are still choosing, GR00T N1.7 against Pi0.5 compares them directly, and the arena has 85 models with 332 benchmark results, each value linked to its source.
Do I have to regenerate statistics after adding episodes to a dataset?▾
Yes, and no tool will do it for you automatically based on content. LeRobot has no content fingerprint at all. GR00T fingerprints its cache, but the hash covers only the feature name, dtype and shape from info.json, so more episodes with the same schema look identical to it and the stale cache is reused. Delete meta/stats.json and meta/relative_stats.json by hand, or rerun the recompute, whenever the data changes.
How do I tell whether a stats file holds absolute or relative action statistics?▾
There is no field that says so, which is the root of the problem. The heuristic that works: relative statistics have a mean close to zero and a small standard deviation, while absolute joint statistics have means offset by tens of degrees. On the GR00T demo dataset, the absolute mean for wrist_flex is 84.16 degrees and the relative mean is 1.54. If your action mean is near zero and your std is single-digit degrees, you are holding relative statistics.
Why does Pi0.5 refuse to train on my dataset when ACT trains fine?▾
Pi0.5's normalization mapping is QUANTILES for both state and action, so it needs q01 and q99. ACT and SmolVLA use MEAN_STD and only need mean and std. Datasets created before quantile statistics were added to LeRobot carry the second set and not the first. Run augment_dataset_quantile_stats.py with your repo id and the error goes away.
Should I regenerate statistics for a checkpoint I already trained?▾
No. The statistics a model was trained with are part of the model. GR00T writes them to statistics.json next to the weights, LeRobot serialises them into the saved processor state dict. Serving a trained checkpoint against a freshly computed stats file applies a different affine map at inference than at training, which is a good way to break a policy that was working.
Does GR00T compute the statistics itself, or do I have to run the script?▾
The dataset factory calls generate_stats and generate_rel_stats on rank zero at the start of a fine-tune, so on a clean dataset you usually do not need to run gr00t/data/stats.py by hand. Running it yourself is still worth doing, because it surfaces configuration errors before a GPU is rented, and because it is the only way to regenerate a cache that the schema-only fingerprint considers fresh.
Is relative always better than absolute for an SO-100?▾
Not universally, and the shipped configs reflect that. Isaac-GR00T's SO-100 example uses relative for the five arm joints and absolute for the gripper, and LeRobot's relative_exclude_joints defaults to ['gripper'] for the same reason: a binary open and close signal gains nothing from being expressed as an offset. The arm joints benefit; the gripper does not.
Sources
- Isaac-GR00T gr00t/data/stats.py: stats generation, the __fingerprints__ cache and relative action statistics
- Isaac-GR00T StateActionProcessor: normalisation modes, clip_outliers, and the relative statistics errors
- Isaac-GR00T examples/SO100/so100_config.py: relative arm joints, absolute gripper, 16-step horizon
- Isaac-GR00T data preparation guide: GR00T LeRobot v2 schema and modality.json
- Isaac-GR00T fine-tuning on a custom embodiment, including the symptom-to-cause table
- Isaac-GR00T FinetuneConfig: use_percentiles defaults to true, and there is no seed field
- LeRobot documentation: absolute, relative and delta action representations
- LeRobot normalize_processor.py: MIN_MAX, MEAN_STD, QUANTILES, QUANTILE10 and their error messages
- LeRobot compute_stats.py: RunningQuantileStats and compute_relative_action_stats
- LeRobot dataset_tools.py: recompute_stats, and where relative stats overwrite the action entry
- LeRobot augment_dataset_quantile_stats.py: adding q01 to q99 to an older dataset
- openpi compute_norm_stats.py: statistics computed after the data transforms
- openpi README: the missing norm stats error and the diverging-loss note on small q01/q99 spans
- Universal Manipulation Interface: In-The-Wild Robot Teaching Without In-The-Wild Robots (Chi et al., 2024)
- pi0.5: a Vision-Language-Action Model with Open-World Generalization (2025)
Sources
- Isaac-GR00T gr00t/data/stats.py: stats generation, the __fingerprints__ cache and relative action statistics
- Isaac-GR00T StateActionProcessor: normalisation modes, clip_outliers, and the relative statistics errors
- Isaac-GR00T examples/SO100/so100_config.py: relative arm joints, absolute gripper, 16-step horizon
- Isaac-GR00T data preparation guide: GR00T LeRobot v2 schema and modality.json
- Isaac-GR00T fine-tuning on a custom embodiment, including the symptom-to-cause table
- Isaac-GR00T FinetuneConfig: use_percentiles defaults to true, and there is no seed field
- LeRobot documentation: absolute, relative and delta action representations
- LeRobot normalize_processor.py: MIN_MAX, MEAN_STD, QUANTILES, QUANTILE10 and their error messages
- LeRobot compute_stats.py: RunningQuantileStats and compute_relative_action_stats
- LeRobot dataset_tools.py: recompute_stats, and where relative stats overwrite the action entry
- LeRobot augment_dataset_quantile_stats.py: adding q01 to q99 to an older dataset
- openpi compute_norm_stats.py: statistics computed after the data transforms
- openpi README: the missing norm stats error and the diverging-loss note on small q01/q99 spans
- Universal Manipulation Interface: In-The-Wild Robot Teaching Without In-The-Wild Robots (Chi et al., 2024)
- pi0.5: a Vision-Language-Action Model with Open-World Generalization (2025)
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started