Skip to content

39 · Domain randomization

Part9 · Sim to real
Run typeCPU · Mac or Colab CPU
Prerequisites33, 38
Learn firstSim-to-Real Transfer · Bias-Variance Tradeoff
Time~60 min
Codeexperiments/39-domain-randomization/experiment.py
Status✅ done

Act on this lesson

Tick each step when you have done it. Saved in this browser only.

If you cannot know the real parameters exactly, train on a range that contains them; make the range too narrow and the policy is brittle, too wide and it is timid.

System identification (lesson 38) narrows the gap between simulator and robot but never closes it: some parameters stay uncertain, and some change from day to day (payload, wear, temperature, a different floor). A controller or policy optimized on one nominal simulator exploits exactly the numbers it was given. Unitree’s open training code for its legged robots therefore draws a new ground friction coefficient for every simulated robot, randomize_friction = True, friction_range = [0.5, 1.25], and pushes the robots at random every 15 s (legged_robot_config.py); the Go2 configuration inherits these settings unchanged (go2_config.py). How wide should such a range be, and what does width cost?

Before you read · 2 questions. Guess first; the lesson tells you why.

1. A driver trains only on one simulator with exactly known grip and reaction delay. What happens in a real car that differs a little?

  • Nothing; small differences average out.
  • The driver misjudges corners, because the style exploits the exact numbers of that one simulator.
  • The driver becomes too cautious.

Why: Tuned on the nominal plant only, the controller is best there (0.165°) and worst on the real plants (5/32): it chose Kp = 117 with little damping, relying on a 5 ms delay.

2. If randomizing helps, why not make the training range as wide as possible?

  • A very wide range makes the policy cautious, worse on the plant you know and eventually on the real one too.
  • Wide ranges make simulation unstable.
  • Wide ranges need a GPU.

Why: Break it: at w = 2 the controller is soft (Kp 19.59), 2.201° nominal and 13/32 on the real plants, worse than the narrowest range (24/32 at w = 0.1).

Train a driver on one simulator only and they learn its exact quirks: where to brake, how much grip there is, how quickly the car reacts. Put them in a real car that differs a little and they misjudge every corner. Train them instead on many slightly different simulators, each with its own grip, weight and reaction delay, and they learn a driving style that works across the whole family. If the real car belongs to that family, it is, in Tobin et al.’s words, “just another variation”. This is domain randomization (DR).

The price: a driver who must handle everything from ice to dry asphalt drives more cautiously than one who only knows dry asphalt. A range that is too narrow gives a brittle policy; one that is too wide gives a timid one. It is the same trade-off as regularization in machine learning: a little generalizes better, too much underfits.

flowchart TD
    W["choose width w"] --> S["draw a simulator:<br/>mass, damping,<br/>friction, delay"]
    S --> R["run an episode,<br/>score the tracking error"]
    R --> O["optimizer (CEM)<br/>updates the controller"]
    O --> S
    O --> P["tuned controller"]
    P --> T["test on held-out<br/>'real' plants"]

The only change from ordinary training is where the score comes from: an average over randomly drawn simulators instead of one fixed simulator. In this lesson the optimizer is the cross-entropy method (CEM), a simple derivative-free search: sample candidate controllers, keep the best ones, move the sampling distribution toward them, repeat.

def expected_return(gains, w, n=10):
plants = [sample_plant(w) for _ in range(n)] # n random simulators of width w
return mean(J(gains, p) for p in plants) # J: return of one episode (here -RMS tracking error)
mu, sd = initial_mean, initial_spread # search distribution over (Kp, Kd, kg)
for it in range(12): # CEM iterations
cands = [normal(mu, sd) for _ in range(24)] # candidate controllers
elite = best(cands, key=lambda g: expected_return(g, w))
mu, sd = mean(elite), std(elite) # move the search toward the best ones
WhatFormula
Nominal trainingπ0∗=arg⁡max⁡π J(π;ξ0)\pi^*_0 = \arg\max_\pi\ J(\pi;\xi_0)
Domain randomizationπw∗=arg⁡max⁡π Eξ∼pw[J(π;ξ)]\pi^*_w = \arg\max_\pi\ \mathbb{E}_{\xi \sim p_w}\big[J(\pi;\xi)\big]
Width in this lessonscales ∈[2−w,2w]\in [2^{-w}, 2^{w}] (log-uniform), delay ∈[5,5+40w]\in [5, 5+40w] ms

π\pi is the policy (here the gains KpK_p, KdK_d, kgk_g), ξ\xi a vector of simulator parameters (mass, damping, friction, delay), ξ0\xi_0 its nominal value, J(π;ξ)J(\pi;\xi) the return of the policy on the simulator with parameters ξ\xi, and pwp_w the distribution of simulators with width ww.

Math details: why averaging over simulators helps, and when it cannot

Coverage. DR optimizes the average return over pwp_w. If the real robot’s ξreal\xi_{\text{real}} lies inside the support of pwp_w (the set of simulators that can be drawn), the policy has been scored on simulators like it; if it lies outside, nothing in training checked that case.

Average vs worst case. As ww grows, the average is increasingly dominated by hard simulators (long delays, heavy links), so the optimum moves toward a cautious, worst-case controller. In the limit this approaches a minimax problem, max⁡πmin⁡ξJ(π;ξ)\max_\pi \min_\xi J(\pi;\xi), which gives up nominal performance for guarantees.

The bias-variance analogy. Training on one ξ0\xi_0 is like fitting one data set exactly: low error there, high error on new data. A wider pwp_w acts like regularization: higher error on the known plant (bias), lower sensitivity to the unknown one (variance) (see AIEFS: Bias-Variance Tradeoff).

Limits of a fixed controller. A controller with fixed gains must find one compromise for every ξ\xi. When the real variation is larger than what any single compromise can absorb, no width helps (Observe 4).

Prior work. Tobin et al. (arXiv 1703.06907) randomized rendering to train an object detector on simulated images only. Tan et al. (arXiv 1804.10332) randomized physical parameters, added perturbations and identified an actuator model and latency to move quadruped gaits from simulation to a real robot.

Go deeper: Sim-to-Real Transfer, Bias-Variance Tradeoff (AIEFS); de Boer et al., “A Tutorial on the Cross-Entropy Method” (2005).

WidthNominal simReal robot
0 (nominal only)bestfails when ξreal≠ξ0\xi_{\text{real}} \ne \xi_0
just covers ξreal\xi_{\text{real}}slightly worseworks
far widerworseworse again: the policy is tuned for the worst case

For an arm, randomize the parameters of lesson 38 (masses, damping, friction, armature, actuator gain) and the delay of lesson 37. Center the ranges on identified values and size them from the identification’s uncertainty.

You will now write the part of domain randomization that is not physics: the sampler, a coverage check, the averaged cost and the optimizer. The plant and its batched MuJoCo rollout stay the Experiment’s (Batch.rollout(gains, plants) runs one episode per row and returns its RMS tracking error). The finished functions are in physai/sim2real.py; uv run python experiments/39-domain-randomization/build.py runs the steps (output in build_output.txt).

A log-uniform scale is 2u2^u with uu uniform in [−w,w][-w, w]: halving and doubling are equally likely, and the median is 1.

def dr_sample(rng, n, w, delay0=0.005, delay_per_w=0.040):
"""n random plants of width w: mass, damping and friction scales log-uniform in [2^-w, 2^w], delay uniform in
[delay0, delay0 + delay_per_w * w] seconds. w = 0 returns n nominal plants and draws nothing from rng."""
if w == 0:
return dict(mass=np.ones(n), damping=np.ones(n), friction=np.ones(n), delay=np.full(n, delay0))
s = lambda: 2.0 ** rng.uniform(-w, w, n) # noqa: E731
return dict(mass=s(), damping=s(), friction=s(), delay=rng.uniform(delay0, delay0 + delay_per_w * w, n))
== Step 1. The sampler at w = 0.5, 100,000 draws ==
mass min 0.7071 max 1.4142 (2^-0.5 = 0.7071, 2^0.5 = 1.4142), median 0.9993, share above 1: 0.499
damping min 0.7071 max 1.4142 (2^-0.5 = 0.7071, 2^0.5 = 1.4142), median 0.9983, share above 1: 0.497
friction min 0.7071 max 1.4142 (2^-0.5 = 0.7071, 2^0.5 = 1.4142), median 0.9994, share above 1: 0.499
delay min 5.00 ms, max 25.00 ms (formula 5-25 ms)
same draws as the Experiment's sampler (seed 7): True

The draws hit both formula bounds, half of them lie above 1, and with the same seed they equal the Experiment’s sampler draw for draw, which Step 4 relies on.

Before training anything, ask which real plants a width can draw at all:

def inside_support(plants, w, delay0=0.005, delay_per_w=0.040):
"""For every plant and parameter: can the width-w training distribution draw this value?"""
lo, hi, eps = 2.0 ** -w, 2.0 ** w, 1e-12
out = {k: (plants[k] >= lo - eps) & (plants[k] <= hi + eps) for k in ("mass", "damping", "friction")}
out["delay"] = (plants["delay"] >= delay0 - eps) & (plants["delay"] <= delay0 + delay_per_w * w + eps)
return out
== Step 2. Coverage: real plants inside the training support (of 32) ==
w mass damping friction delay all four
0.00 0 0 0 0 0
0.10 0 6 2 0 0
0.25 7 13 4 0 0
0.50 32 28 6 25 5
0.75 32 32 11 32 11
1.00 32 32 20 32 20
1.50 32 32 32 32 32
2.00 32 32 32 32 32
largest real friction scale 2.48, largest real delay 28.8 ms

At ww = 0.5 only 5 of the 32 real plants are fully inside the training distribution; it takes ww = 1.5 to cover all of them, because of friction (up to ×2.48). Yet the Experiment’s best widths are 0.5–1.0 (27–28/32) and ww = 1.5 reaches only 23/32. Coverage is necessary for a guarantee, not sufficient for good transfer: the price of width is paid on every plant.

Domain randomization replaces “cost on one simulator” by “mean cost over sampled simulators”. Scoring every candidate on the same sampled plants (common random numbers) keeps the comparison between candidates fair:

def expected_cost(rollout, gains, plants):
"""Mean cost of every controller (row of gains) over the same sampled plants (common random numbers).
rollout(gain_rows, plant_rows) runs one episode per row and returns its cost."""
n, k = len(gains), len(plants["delay"])
tiled = {key: np.tile(v, n) for key, v in plants.items()} # plants 0..k-1 for controller 0, then 1, ...
return rollout(np.repeat(gains, k, axis=0), tiled).reshape(n, k).mean(axis=1)
== Step 3. The averaged cost: the Experiment's tuned controllers, re-scored ==
tuned at Kp Kd kg nominal deg real mean deg real < 2 deg
w = 0.00 117.36 0.914 0.679 0.165 3.525 5/32
w = 0.50 55.43 3.244 0.563 0.475 1.307 28/32

Re-scored with the gains printed in the Experiment’s table, both rows come back: 0.165° nominal and 5/32 for the nominal-only controller, 0.475° and 28/32 for ww = 0.5. The real mean of the first row, 3.525° against the table’s 3.526°, differs because the table’s gains are rounded to two decimals.

CEM keeps a Gaussian over the parameters, samples candidates, keeps the cheapest quarter, and refits the Gaussian to them. The small sd_extra stops the search from collapsing onto one point before it has converged.

def cem(cost, mu, sd, lo, hi, rng, iters=12, pop=24, n_elite=6, sd_extra=0.02):
"""Cross-entropy method: draw pop candidates from N(mu, sd) clipped to [lo, hi], keep the n_elite cheapest,
refit mu and sd to them (plus sd_extra, so the search never collapses), repeat. cost(x, rng) -> one per row."""
for _ in range(iters):
x = np.clip(mu + sd * rng.standard_normal((pop, len(mu))), lo, hi)
elite = x[np.argsort(cost(x, rng))[:n_elite]]
mu, sd = elite.mean(axis=0), elite.std(axis=0) + sd_extra
return mu
== Step 4. CEM: a toy quadratic, then domain randomization at w = 0.5 ==
toy: minimum at [ 1. -2. 0.5], CEM finds [0.980, -2.008, 0.503]
w = 0.5: Kp 55.43, Kd 3.244, kg 0.563; nominal 0.475 deg, real mean 1.307 deg, 28/32 below 2 deg

On the toy quadratic CEM lands within 0.02 of the minimum. On the plant it reproduces the Experiment’s ww = 0.5 row exactly (Kp 55.43, Kd 3.244, kg 0.563, 28/32), because the sampler consumes the random generator in the same order. The optimizer never sees a real plant; only the width of dr_sample connects it to them.

physai.sim2real now has dr_sample, inside_support, expected_cost and cem. The capstone (lesson 44) reasons about the same coverage question for its camera calibration. tests/test_39_domain_randomization.py checks the distributions against SciPy: uv run pytest tests/test_39_domain_randomization.py.

There is no “randomize” call in MuJoCo: the knobs are model fields, writable between episodes without recompiling (m.body_mass, m.dof_damping, m.dof_frictionloss, m.geom_friction, …; delay with lesson 37’s delay attribute). The Experiment puts many copies of the joint into one model, so one mj_step advances a whole batch, and writes one entry per copy before each rollout. SciPy names the distribution:

from scipy import stats
scales = stats.loguniform(2 ** -w, 2 ** w).rvs(size=n, random_state=rng) # same law as dr_sample's 2**u
== Use it. SciPy's log-uniform distribution; MuJoCo model fields as the knobs ==
Kolmogorov-Smirnov, our mass scales vs scipy.stats.loguniform(2^-0.5, 2^0.5): D = 0.0026, p = 0.50
randomized fields in the batched model: body_mass (32,), dof_damping (32,), dof_frictionloss (32,) (one entry per copy, written before each rollout)
ToolWhere the range livesWhat is drawn
physaidr_sample(rng, n, w)mass, damping, friction scales and delay, every episode
unitree_rl_gymrandomize_friction, friction_range = [0.5, 1.25], added_mass_range (config)ground friction once per environment, from 64 buckets (legged_robot.py)
MuJoComodel fields (body_mass, dof_damping, …)whatever you write before mj_step

Traps:

  • Scale vs absolute. “Mass ×2” and “mass + 1” are different distributions: unitree_rl_gym’s added_mass_range adds a number to the base body’s mass (props[0].mass +=).
  • Per episode vs per environment. Drawing once per environment (as the friction buckets above) gives each robot one fixed floor for the whole run; drawing per episode, as here, changes it every episode.
  • Inertia goes with mass. Scaling body_mass without body_inertia makes a physically inconsistent link; the Experiment scales both.

Check after building · 2 questions.

1. Build it, Step 2 shows that at w = 0.5 only 5 of the 32 real plants lie fully inside the training distribution, yet the w = 0.5 controller succeeds on 28/32. What does that say?

  • The coverage check has a bug.
  • Full coverage is required for any success.
  • Coverage is needed for a guarantee, not for good transfer; at w = 1.5 all 32 are covered, but success drops to 23/32.

Why: Most of the gain comes from not trusting the exact delay; the price of width is paid on every plant.

2. Why does expected_cost score every candidate on the same sampled plants instead of drawing new plants per candidate?

  • To save random numbers.
  • So that the difference between two candidates comes from the candidates, not from one getting easier plants (common random numbers).
  • Because MuJoCo can simulate only one plant per model.

Why: With the same order of random draws, Build it, Step 4 reproduces the Experiment’s w = 0.5 row exactly (Kp 55.43, Kd 3.244, kg 0.563, 28/32).

Setup. One arm joint with the STS3215 joint parameters of the Menagerie SO-101 (armature 0.028, damping 0.6, frictionloss 0.052, torque limit 2.94 N·m) carrying a 0.5 kg link with its center of mass at 15 cm (gravity torque up to 0.736 N·m). Torque control through a measurement delay. The “policy” is a three-parameter controller: PD tracking plus a gravity term kgcos⁡qrefk_g\cos q_{\text{ref}},

τ=clip⁡(Kp(qref−q~)+Kd(q˙ref−q~˙)+kgcos⁡qref, ±2.94),\tau = \operatorname{clip}\big(K_p(q_{\text{ref}} - \tilde q) + K_d(\dot q_{\text{ref}} - \dot{\tilde q}) + k_g\cos q_{\text{ref}},\ \pm 2.94\big),

where q~\tilde q is the delayed measurement. It is tuned with CEM (Build it, Step 4: 12 iterations × 24 candidates × 10 sampled plants each) to minimize RMS tracking error of a 3 s reference, 0.5sin⁡(2π 0.4t)+0.25sin⁡(2π 1.1t+1)0.5\sin(2\pi\,0.4t) + 0.25\sin(2\pi\,1.1t+1) rad, at a 2 ms step. With width ww, every training episode draws mass, damping and friction-loss scales log-uniformly in [2−w,2w][2^{-w}, 2^{w}] and the delay uniformly in [5,5+40w][5, 5+40w] ms (nominal: scales 1, delay 5 ms).

Test. The nominal plant, and 32 held-out “real” plants never seen in training (invented for the test): mass ×1.10–1.39, damping ×0.80–1.56, friction ×1.02–2.48, delay 15.5–28.8 ms. Success: RMS error below 2°. Many copies of the joint live in one MuJoCo model so that one mj_step advances a whole batch.

Full script: experiments/39-domain-randomization/experiment.py. MuJoCo 3.17.0, NumPy 2.5.4, Python 3.14, Apple Silicon Mac (Apple M5), deterministic (fixed seeds), well under a minute of CPU. Predict: which width is best on the real plants, and what happens to nominal error as ww grows?

wwdelay range [ms]scale rangeKpK_pKdK_dkgk_gnominal RMS [°]real mean [°]real worst [°]real success
051.00117.360.9140.6790.1653.5266.0255/32
0.15–90.93–1.0799.984.4840.3070.2271.6073.26024/32
0.255–150.84–1.1974.774.2760.5230.2521.4903.36823/32
0.55–250.71–1.4155.433.2440.5630.4751.3073.29328/32
0.755–350.59–1.6845.192.6720.4610.7361.2283.11827/32
1.05–450.50–2.0030.411.9690.5911.2241.2333.41927/32

Left: number of the 32 held-out real plants with RMS error below 2 degrees versus randomization width w, rising from 5 at w = 0 to 28 at w = 0.5 and falling to 13 at w = 2; widths whose training delays cover all real delays are shaded. Right: RMS tracking error on the nominal plant rises steadily with width, while the mean error on the real plants is lowest around w = 0.75 to 1 and rises again beyond.

Observe.

  1. Tuned on the nominal plant only, the controller is the best on that plant (0.165°) and the worst on the real ones (5/32). It chose KpK_p = 117 with little damping, a loop that relies on knowing the delay is 5 ms; with 15.5–28.8 ms its error averages 3.526°.
  2. Even a narrow range (ww = 0.1, delay up to 9 ms) lifts real success to 24/32: most of the gain comes from not trusting the exact delay. The best results come from ranges whose delay reaches into the real 15.5–28.8 ms (ww = 0.5 up to 25 ms; 0.75 and 1 all of it): 27–28/32, mean 1.228–1.307°, although only 5–20 of the 32 plants lie fully inside those ranges (Build it, Step 2). Each row is one CEM run with one seed; treat differences of a few plants as noise.
  3. Nominal error grows steadily with width (0.165 → 1.224°) as KpK_p falls (117 → 30): robustness is paid for in tracking on the plant you know.
  4. No width solves all 32: the worst real plant stays above 3° in every row. Some real plants have friction ×2.48, outside even the ww = 1 range (up to ×2.00), and a fixed-gain controller has limits.

Make the range much wider than the real variation:

wwdelay range [ms]scale rangeKpK_pnominal RMS [°]real mean [°]real success
1.55–650.35–2.8338.011.1901.78823/32
2.05–850.25–4.0019.592.2012.34513/32

At ww = 2 the training distribution includes 85 ms of delay and links four times lighter or heavier. The controller that survives all of that is soft (KpK_p = 19.59): 2.201° on the nominal plant (13 times the nominal-only controller) and 13/32 on the real plants, worse than the narrowest range. The optimizer spends its effort on plants that do not exist. Width is a design parameter, set by identification (lesson 38) and measured latency (lesson 37), not “as wide as possible”.

  • Unitree’s defaults in unitree_rl_gym: ground friction per robot from [0.5, 1.25] (drawn once per environment from 64 buckets, legged_robot.py), randomize_base_mass = False with added_mass_range = [-1., 1.] available, push_robots = True every 15 s with max_push_vel_xy = 1. (legged_robot_config.py). Those are training-simulation settings; they say nothing about how well a given policy transfers, which we did not measure.
  • SO-101. Policies trained by imitation on real demonstrations (Part 7, lesson 40) see real dynamics already; DR matters for policies trained in simulation (Part 8). For the arm, randomize around the parameters you identified, with ranges at least as wide as their spread across repeated identification runs, plus the latency you measured. Not yet measured on the real arm.
  • Check the range on hardware. Run the trained policy on the robot and on held-out simulated plants; if the real result is worse than the worst simulated one, the real robot is outside your range or a mechanism is missing (lesson 38).

Quick check · 2 questions. Click an answer.

1. The controller tuned on the nominal plant only has the best nominal error (0.165°) and the worst transfer (5/32). Why?

  • CEM did not converge on the nominal plant.
  • The nominal plant has no friction, so the controller never learned to push.
  • It chose Kp = 117 with little damping, a loop that relies on the delay being 5 ms; the real plants have 15.5–28.8 ms.

Why: Width table, row w=0w = 0: Kp 117.36, Kd 0.914, real mean 3.526°. Randomized rows choose Kd between 1.916 and 4.484 for phase margin (lesson 37).

2. In every row the worst real plant stays above 3°. Why can no width fix that?

  • Some real plants have friction ×2.48, outside even the w=1w = 1 range (up to ×2.00), and a fixed-gain controller has limits.
  • The real plants are measured with a biased sensor.
  • CEM was run with too few iterations for the wide rows.

Why: Observe 4. Even at w = 1.5, where all real plants are inside the range (Build it, Step 2), the worst plant is at 3.791°.

  1. Easy. With ww = 0.5, what are the delay range and the scale range? Do they contain all 32 real plants?
  2. Medium. Why does the nominal-only controller choose low KdK_d (0.914) while all randomized ones choose KdK_d between 1.916 and 4.484? Use lesson 37.
  3. Hard. Randomize only the delay (scales fixed at 1) with ww = 0.75, and compare real success with the full ww = 0.75 row. Predict first.
  1. Delay 5–25 ms, scales 0.71–1.41. No: real delays go up to 28.8 ms and friction up to ×2.48.
  2. At 5 ms of delay the loop’s phase margin is large, so the optimizer can trade damping for faster tracking. With uncertain, larger delays, KdK_d restores phase margin at the crossover (PM=arctan⁡(Kdωc/Kp)\mathrm{PM} = \arctan(K_d\omega_c/K_p)), and the optimizer keeps it.
  3. 27/32, the same as the full ww = 0.75 row, with a slightly lower mean error (1.174° vs 1.228°) and a higher nominal error (1.339° vs 0.736°, KpK_p = 27.87). For these real plants the delay is the parameter that matters; the scale ranges add little. Last row of output.txt.
  • Optimizing on the nominal simulator gives the best nominal result (0.165°) and the worst transfer (5/32).
  • A range that covers most of the real variation transfers best (27–28/32); even a narrow one helps (24/32).
  • Too wide costs both: nominal 2.201° and real 13/32 at ww = 2. Size the range from identification and measured latency.

Next: lesson 40 · Deploying on the real SO-101.

Sources: