In robot learning, the dominant dataset format is the LeRobot format. You might also see 📄RLDS: an Ecosystem to Generate, Share and Use Datasets in Reinforcement Learning, but it is largely considered legacy at this point.
Last month, I participated in the Workshop on Post-Training for Robotics Foundation Models, which was the first time I fine-tuned the OpenPI model. During this process, I discovered the script compute_norm_stats.py.
Initially, I thought normalization was just routine data processing. It wasn’t until I hit a frustrating bug that I realized how crucial it is.
ErrorA teaser: what is the root cause of this bug🤔? The policy seems to sample the exact same action chunks and completely ignores the observation (where the button is located).
LeRobot Dataset
Essentially, the output of computing normalization statistics in OpenPI is equivalent to the meta/stats.json in the LeRobot v3 Dataset. It’s that simple!
RemarkFor simplicity, we will use the LeRobot port of the DROID(Franka Research 3 + Robotiq 2F-85 gripper) as our example data. For the original DROID dataset, see
gs://openpi-assets/checkpoints/pi05_droid_jointpos/assets/droid/norm_stats.json
RemarkYou can find the full LeRobot port data at
lerobot/droid_1.0.1.
According to LeRobot, the meta/stats.json includes the global feature statistics (mean/std/min/max) used for normalization. The following is just a portion of the stats.json, but it is enough to explain the core concept.
Both the state space(observation space) and action space are both -vector:
- joint 1 position
- joint 2 position
- …
- joint 7 position
- gripper position
{
"observation.state": {
"min": [
-2.672288417816162,
-1.6589547395706177,
-2.6781487464904785,
-2.9409868717193604,
-2.6705946922302246,
0.23991677165031433,
-3.01689076423645,
0.0
],
"max": [
2.677424192428589,
1.5840554237365723,
2.6957037448883057,
-0.29779934883117676,
2.6624162197113037,
4.309162616729736,
2.755643367767334,
1.0
],
"mean": [
0.01428347627459065,
0.23987777195473095,
-0.014661363646208965,
-2.027954954645529,
-0.035476306435143545,
2.3233678805030076,
0.08326671319745274,
0.36085284714413735
],
"std": [
0.3244343844169754,
0.5158281965633522,
0.2901322476548548,
0.5021871776809874,
0.5331921797732538,
0.46856794632856424,
0.7446577730524244,
0.40315882970033534
],
"count": [
27630375
],
"q01": [
-0.2971545831353961,
-0.6024684970210599,
-0.3246890370604207,
-2.5776213986184056,
-0.52954145297974,
1.7039841960281517,
-0.5634534462399059,
0.002269706155241995
],
"q10": [
-0.2071942693776079,
-0.3068905435857967,
-0.21927851864465314,
-2.452772240957223,
-0.39501687629084803,
1.905812968606974,
-0.40315326357583076,
0.010995347302075378
],
"q50": [
0.010898195970654135,
0.30906078867268894,
-0.013634248145127616,
-2.0263343166214582,
-0.03297508389338494,
2.34620050390607,
0.08219286794729123,
0.3156374992693502
],
"q90": [
0.24029386451182386,
0.6464682600380799,
0.18802776173926217,
-1.614161172246043,
0.31768183246098786,
2.692380787048461,
0.5679128927968153,
0.7567200354092934
],
"q99": [
0.34023668609343527,
0.7309937017421492,
0.28773896879256056,
-1.501648218214918,
0.44214316073356147,
2.7994098251162134,
0.7265853834273763,
0.7906762786984639
]
},
"action": {
"min": [
-2.781099557876587,
-1.6589616537094116,
-2.771268129348755,
-2.9508564472198486,
-2.7826988697052,
0.16821147501468658,
-3.01689076423645,
0.0
],
"max": [
2.753600597381592,
1.6668277978897095,
2.7745442390441895,
-0.1839631199836731,
2.781451463699341,
4.402013778686523,
2.90183162689209,
1.0
],
"mean": [
0.013463458013074163,
0.24601321813945412,
-0.014288112193182743,
-2.0200592647205013,
-0.0353399614908174,
2.32466114760956,
0.08414546941051428,
0.40204036931057757
],
"std": [
0.32789199211814846,
0.5112242010705491,
0.29162841875567036,
0.5062175169396173,
0.5390442358042883,
0.47233520385832545,
0.7513580141274556,
0.4383071888747673
],
"count": [
27630375
],
"q01": [
-0.3057221105237345,
-0.6010594144871354,
-0.3293859171349254,
-2.580815474026496,
-0.5470604481925744,
1.6954206192591532,
-0.5794598728051213,
0.00322552291371711
],
"q10": [
-0.20913248614877078,
-0.27556167366243195,
-0.21814577080501105,
-2.4391765056753867,
-0.3993205468420163,
1.9132415490232342,
-0.40268805973095767,
0.012199343819725749
],
"q50": [
0.010042982056836115,
0.3065354500450097,
-0.01305926287987116,
-2.0218571352222696,
-0.03270612776477943,
2.3444952566340898,
0.08267060217998833,
0.3381859423988968
],
"q90": [
0.24059428747780476,
0.6499532877396464,
0.18731938770018622,
-1.6049653921899445,
0.32193393894959416,
2.6958706296928363,
0.569767585628472,
0.8518120642995886
],
"q99": [
0.3468789948857942,
0.7466127878263257,
0.29294297642969547,
-1.480600698538236,
0.4594471669167336,
2.8145718222830456,
0.7434564212326804,
0.8782617940128535
]
}
}Intuitively💡, both the state space and action space share the exact same structural skeleton:
"observation.state": {
"min": [
-2.672288417816162,
-1.6589547395706177,
-2.6781487464904785,
-2.9409868717193604,
-2.6705946922302246,
0.23991677165031433,
-3.01689076423645,
0.0
],
"max": [ 8 values ],
"mean": [ 8 values ],
"std": [ 8 values ],
"count": [ 8 values ],
"q01": [ 8 values ],
"q10": [ 8 values ],
"q50": [ 8 values ],
"q90": [ 8 values ],
"q99": [ 8 values ]
}Note that these statistics are calculated across the entire dataset, not just a single training batch. For example, the value -2.672288417816162 is the minimum value recorded for the 1st joint of the Franka robot across all the data. We can confirm this value is reasonable by checking the hardware control limits from Understanding Franka Robot Control Parameters:
| Name | Joint 1 | Joint 2 | Joint 3 | Joint 4 | Joint 5 | Joint 6 | Joint 7 | Unit |
|---|---|---|---|---|---|---|---|---|
| 2.9007 | 1.8361 | 2.9007 | -0.1169 | 2.8763 | 4.6216 | 3.0508 | ||
| -2.9007 | -1.8361 | -2.9007 | -3.0770 | -2.8763 | 0.4398 | -3.0508 |
Therefore, the dataset minimum safety stays within the hardware limit (since -2.672 >= -2.9007), which makes complete physical sense.
Why do we need normalization?
Like any other machine learning problem, robot learning requires data normalization. But before we jump into the math, let’s reflect on why.
As seen in the concrete example above, the range of each dimension varies widly.
- the first joint ranges from
[-2.9, 2.9] - the last joint ranges from
[-3, 3]. - gripper only ranges from
[0, 1].
In a nutshell, these dimensions operate on entirely different scales. Normalizing the data puts everything on a same domain, which helps the network converge much faster during training👍️.
When do we use Normalization?
It is applied in 2 specific spots:
- training: transform raw data() into a normalized, uniform space()
- inference: transform the network’s normalized predictions() back to realistic kinematics values() so the robot can actually execute them
RemarkThis dual requirement is exactly why
OpenPIsaves thenorm_stats.jsondirectly inside the model checkpoint.
Statistics Metrics
Inside the norm_stats.json or the stats.json, several metrics are used:
I will primarily focus on quantile normalization and z-score scaling, as these are the default methods used in OpenPI:
- : uses z-score scaling
- : uses quantile normalization
Z-Score Scaling
The z-score scaling is defined like so
where
- is the raw value
- is the mean
- is the standard deviation
- is a tiny constant( in OpenPI) to prevent division by zero
- is the normalized z-score
RemarkIn pure statistics, the equation is just .
The reason why we add is that in the real world, we might encounter an edge case where . Dividing by 0 crashes the system, so keeps things safe.
Let’s take the DROID dataset as an example. For the action[0](1st joint angle), we have
- (mean):
0.013 - (std):
0.327
Suppose the incoming action value is action[0]=0.2, during training, it transforms into:
The neural network then tries to predict this normalized value during training. Once the model is fully trained, we use the same statistics to convert the predicted value back into a real world action during inference:
Quantile Normalization
The quantile normalization relies on 2 metrics:
- : the value below which 1% of the data lies
- : the value below which 99% of the data lies
This technique ensures that the central 98% of your data maps cleanly into the [-1, 1] range. Rare outliers aren’t removed, they simply map to values just below -1 or above +1.
In OpenPI, quantile normalization during training looks like this:
During inference, we invert the math to get the physical action back:
My Debugging Story
Now, let me unveil the pitfall I ran into. Here was the setup: I generated 1k episodes of LeRobot dataset demonstrating a robot navigating to a blue button in simulation. Then, I trained a policy purely on that simulation data.
When testing it, the result is intermittent. The successful rollouts made me think “I’ve solved zero-shot sim2real transfer!” The failures, however, left me frustrated, wondering what on earth was going wrong😮💨.
Two sample failure rollouts:
| Rollout 1 | Rollout 2 |
|---|---|
Here’s where it gets interesting: when the policy failed, the robot executed the exact same trajectory regardless of where the button was actually placed on the table. The actions sampled from the policy were completely ignoring the visual observation🤯.
After digging into the debug logs, I found the root cause: normalization. The policy was failing because the actual gripper observation in the real world had fluctuated from exactly 0.0 to about 0.0005.
WarningYes, this is the real world. We are not in a perfect simulation anymore. There are tiny “epsilons” everywhere in hardware. The
double franka::GripperState::width {}returned by the robot is sometimes nonzero!
That difference sounds tiny, but my trained policy normalizes observation.state.gripper with:
Looking back at my real world gripper reading of 0.0005, that input was roughly out-of-distribution! My policy was reacting strongly to a massive statistical shift.
Once I manually clamped the gripper position as 0, the robot could successfully navigate conditioned to the position of the blue button on the table.
Therefore, the policy hadn’t failed to learn. The deployment state just had a tiny sensor calibration offset that exploded in magnitude after normalization. It was a true “aha!” moment. Does it explain why we need domain randomization?!🤔