zero2robot · Phase 2 · Reinforcementch2.7-dr · dr.py

Chapter 2.7

Sim-to-Real Intuition Lab IIRandomize to Generalize

By the end you can

  1. Explain domain randomization as the answer to ch2.6's reality gap — you cannot fix the world, so randomize the SIM dynamics (mass/friction/gravity) each episode so the policy learns a behavior robust across the range
  2. Implement DR from scratch by scaling the compiled MjModel's physical params in place, ON TOP of the quadruped's pinned contact solver (never the solver iterations/cone), preserving per-seed bitwise CPU determinism
  3. Train a NARROW (nominal-only) and a RANDOMIZED policy that differ in exactly one knob (--dr_width), then EVALUATE BOTH across a swept test-dynamics gap with ch1.6 honesty (mean return +- std AND survival rate over held-out episodes)
  4. MEASURE the result and read it honestly — DR CAN extend robustness across the gap (seed 1 holds to 1.4x mass where narrow falls at 1.2x) but at a free-tier budget does so UNRELIABLY; the mean survival edge {-0.02,+0.22,-0.09} sits inside the seed band (the ch1.6 "single numbers lie" trap in an RL costume)
  5. Name WHEN DR pays off reliably — a dynamics-BRITTLE behavior (a gait, contact-rich manipulation) trained with ENOUGH compute — and recognize its limit: DR widens the reachable range, it never lifts the actuator ceiling (the deepest gap survives at no band width)

See it work

live · P2
view
the honest headline: the bands overlap
survival across the gapmean ± seed band
0%25%50%75%100%servos saturate · both fallnominal0.8×1.0×1.2×1.4×1.6×test mass scale — the gap →survival
the insurance premiumoff-nominal mean

No premium is even paid at nominal here — both stand 100% and return is flat (~203–207). The hoped-for payout off-nominal is +4% ± 13%: inside the seed band.

Mean over seeds 0 to 2, with the seed band. Both policies survive every episode at nominal mass, and both collapse past 1.2 times mass where the servos saturate. Across the gap the randomized policy's off-nominal survival edge is +4% plus or minus 13% — it sits inside the seed band. The two error bands overlap at every shifted mass, so at this budget domain randomization does not cleanly beat the narrow policy.

DR is the promise you TEST, not a clean win. At this free-tier budget the off-nominal edge sits within the seed band (+4% ± 13%; per-seed −2%, +22%, −9%), and past ~1.2× mass the ±12 Nm servos saturate so both policies fall — DR widens the reachable range, it never lifts the actuator ceiling. The Scale Lab spends the compute (and/or a walking gait) to make randomization converge. Real measured numbers from dr.py (seeds 0–2, cpu); the mean ± band view reads with JS off.

Domain randomization — narrow vs randomized across the gap, liveTwo policies under the same runtime-scaled dynamics. Drag the mass slider to break them: across most of the range they behave alike, and past ~1.2× mass both fall — the within-band honesty of what randomization can and cannot buy.narrow policynominal dynamics onlyrandomized policytrained across the band
within-band: DR buys a RANGE, not a stronger robot · poster reads with JS off
Open in Colabsoon

Free-tier notebook — the button goes live when the course repository is published.

You can't fix the world, so stop training in only one

Chapter 2.6 handed you a measuring instrument and a verdict. You took a policy raised in a flawless simulator, injected the perturbations that dominate the reality gap, and watched it degrade. The lesson landed as a question: a policy trained in one clean world has no defense it was never asked to learn — so what do you do about it?

You cannot fix the real world to match your simulator. The robot's motors will always be a little weaker than the model, its floor a little more or less grippy, its payload never exactly the nominal mass. So do the opposite. Stop making the simulator one fixed world. Randomize its dynamics every episode — sample a different mass, a different friction, a different gravity each time the policy resets — and the policy is forced to learn a behavior that works across the whole range instead of memorizing the one point it was handed. Meet the reality gap in sim, where falling over is free.

That is domain randomization, and it is the workhorse behind the famous sim-to-real results — Tobin's randomized rendering for object detection, OpenAI's in-hand cube that trained entirely in a randomized sim and transferred to a real Shadow Hand. This chapter builds it from scratch on the quadruped, trains two policies that differ in exactly one thing, and then does the part that separates engineering from wishful thinking: it measures whether it worked.

Two policies, one difference

We train two PPO policies on the quadruped — the same from-scratch PPO you wrote in chapter 2.1, unchanged. The only thing that differs between them is the world they train in:

  • NARROW trains on the nominal dynamics, every episode. The mass, friction, and gravity are always the book values.
  • RANDOMIZED resamples the torso/leg masses, the foot friction, and gravity from a band around nominal at the start of every episode.

Domain randomization, in code, is nothing more than scaling the simulator's physical parameters in place — on top of the contact solver the quadruped env pins. We touch mass, foot friction, and gravity; we never touch the solver iterations or the contact-cone settings the env README fixes for determinism, so the whole thing stays bitwise-reproducible per seed.

dr.py#randomizesha256:415eaeb455…
# Domain randomization = scaling the env's PHYSICAL parameters, in place, on top# of the pinned contact solver. We touch only mass/inertia (heavier or lighter# robot), foot friction (a slick vs grippy floor), and gravity (a heavier or# lighter world) — never the solver iterations/cone the quadruped README pins, so# the bitwise-CPU determinism guarantee still holds per fixed seed. Half-widths# below are the nominal band at --dr_width 1.0; dr_width scales them (0 => narrow).MASS_HW, FRIC_HW, GRAV_HW = 0.4, 0.5, 0.25   # mass +-40%, friction +-50%, gravity +-25%SCALE_FLOOR = 0.2                            # keep every scale physical (no zero-mass / frictionless / zero-g)  def capture_nominal(env: QuadrupedEnv) -> dict:    """Snapshot the env's as-built physical params so every episode scales from    the SAME nominal baseline (scaling in place would otherwise compound)."""    return {"mass": env.model.body_mass.copy(), "inertia": env.model.body_inertia.copy(),            "friction": env.model.geom_friction.copy(), "gravity": float(env.model.opt.gravity[2])}  def sample_scales(rng: np.random.Generator, width: float) -> dict:    """Draw one (mass, friction, gravity) scale triple for an episode, uniform in    the band around 1.0. width 0 collapses the band to a point => all scales 1.0    => the NARROW policy trains on nominal dynamics every episode."""    def draw(half_width: float) -> float:        return max(SCALE_FLOOR, float(rng.uniform(1.0 - width * half_width, 1.0 + width * half_width)))    return {"mass": draw(MASS_HW), "friction": draw(FRIC_HW), "gravity": draw(GRAV_HW)}  def apply_scales(env: QuadrupedEnv, nominal: dict, scales: dict) -> None:    """Write scaled params back into the compiled MjModel (body 0 is the world —    leave its zero mass alone; friction column 0 is the tangential coefficient)."""    env.model.body_mass[1:] = nominal["mass"][1:] * scales["mass"]    env.model.body_inertia[1:] = nominal["inertia"][1:] * scales["mass"]    env.model.geom_friction[:, 0] = nominal["friction"][:, 0] * scales["friction"]    env.model.opt.gravity[2] = nominal["gravity"] * scales["gravity"]

A single knob, --dr_width, scales the band. --dr_width 0 collapses it to a point — that is the narrow policy, so both policies run through the identical code path and differ only in one number. Re-seeding both trainings from the same --seed means they start from the identical network initialization and see the same reset noise; the only variable in the entire experiment is whether the dynamics move. That is a controlled experiment, not a demo.

dr.py#pposha256:69a10376be…
def train_policy(width: float, label: str) -> Agent:    """Train ONE PPO policy end to end and return it. Called twice — once with    width 0 (NARROW) and once with args.dr_width (RANDOMIZED). Re-seeds torch and    the env/DR streams from args.seed at entry, so the two policies start from the    IDENTICAL init and see the same reset noise; the ONLY difference between them    is whether the dynamics get randomized. That is the controlled experiment."""    torch.manual_seed(args.seed)    envs = [QuadrupedEnv() for _ in range(args.num_envs)]    nominal = [capture_nominal(e) for e in envs]    dr_rng = np.random.Generator(np.random.PCG64(args.seed + 1234))  # the DR draw stream, seeded    episode_count = np.zeros(args.num_envs, dtype=np.int64)     def env_reset(i: int) -> np.ndarray:        # each env/episode gets its own reset seed, then fresh randomized dynamics        obs = envs[i].reset(seed=args.seed + i * 1000 + int(episode_count[i]))        apply_scales(envs[i], nominal[i], sample_scales(dr_rng, width))        episode_count[i] += 1        return obs     agent = Agent(OBS_DIM, ACT_DIM, args.hidden_dim).to(device)    optimizer = torch.optim.Adam(agent.parameters(), lr=args.lr, eps=1e-5)    obs_buf = torch.zeros((args.num_steps, args.num_envs, OBS_DIM), device=device)    act_buf = torch.zeros((args.num_steps, args.num_envs, ACT_DIM), device=device)    logp_buf = torch.zeros((args.num_steps, args.num_envs), device=device)    rew_buf = torch.zeros((args.num_steps, args.num_envs), device=device)    val_buf = torch.zeros((args.num_steps, args.num_envs), device=device)    term_buf = torch.zeros((args.num_steps, args.num_envs), device=device)   # fell (terminal)    done_buf = torch.zeros((args.num_steps, args.num_envs), device=device)   # fell OR time-limit    boot_buf = torch.zeros((args.num_steps, args.num_envs), device=device)   # V(real next state) at episode ends     next_obs = np.stack([env_reset(i) for i in range(args.num_envs)])    ep_return = np.zeros(args.num_envs, dtype=np.float64)    recent: list[float] = []    for iteration in range(1, num_iterations + 1):        optimizer.param_groups[0]["lr"] = args.lr * (1.0 - (iteration - 1.0) / num_iterations)  # anneal        for step in range(args.num_steps):            obs_t = torch.as_tensor(next_obs, dtype=torch.float32, device=device)            obs_buf[step] = obs_t            with torch.no_grad():                action, logp, _, value = agent.get_action_and_value(obs_t)            act_buf[step], logp_buf[step], val_buf[step] = action, logp, value            action_np = action.cpu().numpy()            next_obs = np.empty_like(next_obs)            for i in range(args.num_envs):                obs_i, reward, done, info = envs[i].step(action_np[i])                rew_buf[step, i] = reward                term_buf[step, i] = float(info["terminated"])  # a FALL is terminal; a time-limit is not                done_buf[step, i] = float(done)                ep_return[i] += reward                if done:                    with torch.no_grad():  # value of the state we're leaving — bootstrap on truncation, masked on a fall                        boot_buf[step, i] = agent.get_value(                            torch.as_tensor(obs_i, dtype=torch.float32, device=device).unsqueeze(0)).item()                    recent.append(ep_return[i])                    ep_return[i] = 0.0                    obs_i = env_reset(i)  # autoreset with fresh randomized dynamics                next_obs[i] = obs_i         with torch.no_grad():            next_value = agent.get_value(torch.as_tensor(next_obs, dtype=torch.float32, device=device))        advantages = torch.zeros_like(rew_buf)        last_gae = torch.zeros(args.num_envs, device=device)        for t in reversed(range(args.num_steps)):  # GAE; 1-terminated masks the bootstrap so a FALL earns no future value            next_v = next_value if t == args.num_steps - 1 else val_buf[t + 1]            next_v = torch.where(done_buf[t].bool(), boot_buf[t], next_v)            delta = rew_buf[t] + args.gamma * next_v * (1.0 - term_buf[t]) - val_buf[t]            last_gae = delta + args.gamma * args.gae_lambda * (1.0 - done_buf[t]) * last_gae            advantages[t] = last_gae        returns = advantages + val_buf         b_obs, b_act = obs_buf.reshape(-1, OBS_DIM), act_buf.reshape(-1, ACT_DIM)        b_logp, b_adv = logp_buf.reshape(-1), advantages.reshape(-1)        b_ret = returns.reshape(-1)        for _ in range(args.update_epochs):            for start in range(0, batch_size, minibatch_size):                mb = torch.randperm(batch_size, device=device)[start:start + minibatch_size]                _, new_logp, entropy, new_val = agent.get_action_and_value(b_obs[mb], b_act[mb])                ratio = (new_logp - b_logp[mb]).exp()                adv = (b_adv[mb] - b_adv[mb].mean()) / (b_adv[mb].std() + 1e-8)  # normalize advantages                pg_loss = torch.max(-adv * ratio,                                    -adv * torch.clamp(ratio, 1 - args.clip_coef, 1 + args.clip_coef)).mean()                v_loss = 0.5 * ((new_val.flatten() - b_ret[mb]) ** 2).mean()                loss = pg_loss + args.vf_coef * v_loss                optimizer.zero_grad()                loss.backward()                nn.utils.clip_grad_norm_(agent.parameters(), args.max_grad_norm)                optimizer.step()        mean_return = float(np.mean(recent[-50:])) if recent else float("nan")        if args.rerun:            rr.set_time("global_step", sequence=iteration * batch_size)            rr.log(f"train/{label}/episodic_return", rr.Scalars([mean_return]))        if iteration % 10 == 0 or iteration == num_iterations:            print(f"  [{label}] iter {iteration:3d}/{num_iterations}  mean_return {mean_return:6.1f}")    return agent

Evaluate across the gap — with error bars

Training return is not the point; generalization is. So we sweep a test-dynamics axis — by default the robot's mass — from light to crushingly heavy, and evaluate BOTH policies at every point on held-out seeds. For each point we report the mean return, its spread, and the survival rate: the fraction of episodes the robot stayed up for the full ten seconds. Survival is the honest binomial the reality gap moves first (chapter 1.6's discipline: a rate is a band, not a number).

dr.py#evalsha256:47d19d8438…
def rollout(agent: Agent, env: QuadrupedEnv, seed: int) -> tuple[float, bool]:    """One deterministic (policy MEAN, no sampling) episode; return (total_reward,    survived). survived = reached the time limit without falling — the honest    binary signal a brittle policy misses (return folds in walking too, but staying    up is what the reality gap threatens first)."""    obs, done, total = env.reset(seed=seed), False, 0.0    info = {"truncated": False}    while not done:        with torch.no_grad():            action = agent.actor_mean(torch.as_tensor(obs, dtype=torch.float32, device=device).unsqueeze(0))        obs, reward, done, info = env.step(action[0].cpu().numpy())        total += reward    return total, bool(info["truncated"])  # truncated == survived the full horizon (never fell)  def evaluate_at(agent: Agent, knob: str, scale: float) -> dict:    """Return mean+-std return AND survival rate over held-out episodes at ONE    fixed test-dynamics point. The std / rate are the ch1.6 honesty: a single point    estimate hides the spread, and survival is the binomial the reality gap moves."""    env = QuadrupedEnv()    nominal = capture_nominal(env)    scales = {"mass": 1.0, "friction": 1.0, "gravity": 1.0}    scales[knob] = scale  # move ONE axis; hold the others nominal so the curve is attributable    returns, survived = [], []    for ep in range(args.eval_episodes):        # Pin the test dynamics on the MODEL (mj_resetData in rollout resets the        # DATA — qpos/qvel — but never these model params, so the scale persists).        apply_scales(env, nominal, scales)        ret, ok = rollout(agent, env, EVAL_SEED0 + args.seed + ep)        returns.append(ret)        survived.append(ok)    return {"scale": scale, "mean": float(np.mean(returns)), "std": float(np.std(returns)),            "survival": float(np.mean(survived))}  def sweep(agent: Agent) -> list[dict]:    """Evaluate one policy across the whole test-dynamics grid (the 'gap')."""    return [evaluate_at(agent, args.sweep_knob, s) for s in sweep_grid]

The sweep deliberately runs past the randomization band, into the gap where neither policy trained. That is the "break-the-policy" playground the map promises: crank the test dynamics until something falls, and see which policy falls first.

Run it

python curriculum/phase2_reinforcement/ch2.7_dr/dr.py --seed 0
wall-clock · rendered from wallclock.csvone source · every tier
cpu-laptopwall-clock on cpu-laptop: not yet measuredpending
mpswall-clock on mps: not yet measuredpending
t4expected wall-clock on t4: ~5.63 min (measured)measured
4090wall-clock on 4090: not yet measuredpending

This trains both policies from scratch and runs the full sweep across the mass gap. Here is the generalization curve on the default --seed 0 (mean return ± std | survival rate, 16 held-out episodes):

  mass_scale        0.8           1.0           1.2           1.4           1.6
  narrow       206±1|1.00   206±0|1.00   112±82|0.44    32±1|0.00    27±1|0.00
  randomized   203±0|1.00   203±0|1.00   100±79|0.38    29±1|0.00    24±0|0.00

On this seed the two curves lie almost on top of each other — DR bought nothing. But hold that thought: seeds 1 and 2 tell a different story, and the difference between them is the point. Now read it honestly, because the honest reading is the whole chapter.

The result the map didn't promise

Here is what the measurement actually says, and it is subtler than "randomization wins."

Near nominal, both policies stand, and randomization buys nothing the narrow policy didn't already have. Standing near the book dynamics is a stable equilibrium — the PD servos hold a crouch, and a small change in mass is a small push the same feedback absorbs. The narrow policy generalizes across the easy part of the range for free; there is nothing there for domain randomization to fix, and both policies survive every episode at 0.8× and 1.0× mass.

Deeper in the gap, domain randomization sometimes pays off spectacularly — and sometimes not at all. On seed 1, the randomized policy holds a full-survival, ~200-return stance all the way out to 1.4× mass, a load under which the narrow policy has already collapsed (0.38 survival). That is the textbook result: the narrow policy overfit the one stance that works at nominal, and the randomized policy, forced to cope with heavier and lighter bodies in training, learned a sturdier one that carries into the gap. It even shows the "1.2× ceiling" was never physical — it was the narrow policy's ceiling, and DR pushed past it. But run seed 0, or seed 2, and the edge evaporates: the randomized policy converges to an ordinary nominal stand and falls right alongside the narrow one. Across three seeds the survival edge across the gap is −0.02, +0.22, −0.09.

That spread IS the finding. The average benefit lives inside the seed band — exactly the trap chapter 1.6 taught you to catch. If you had trained one seed, seen +0.22, and written "domain randomization extends the robust range by 40%," you would have published noise. The honest statement is: at this compute budget, DR can produce a dramatically more robust policy, but it does so unreliably — because randomizing the dynamics makes the training problem strictly harder, and 400k steps is not always enough to solve the harder problem. Randomization is not free insurance; it is a harder problem that needs more compute to pay off dependably. (Cut the step budget below this and it is worse still: the randomized policy sometimes fails to learn even the nominal stand.)

So when does domain randomization help — reliably?

This is the judgment the chapter exists to build. Domain randomization pays off, and pays off dependably, when two conditions both hold:

  • the narrow policy is genuinely brittle to the axis you randomize — the optimal behavior is dynamics-specific. A walking gait tuned to one friction slips on another; an in-hand manipulation tuned to one mass distribution drops a different one. There, the narrow policy overfits sim-specific dynamics and DR is the fix, not a coin flip. A passively-stable stand is the weakest case: most of its range needs no adaptation at all.
  • you spend enough compute to actually solve the harder randomized problem. The famous transfers — OpenAI's in-hand cube, sim-to-real locomotion — randomize hard and train long. Under-train the randomized policy and you get the seed 0 / seed 2 story: the extra difficulty, none of the payoff.

Notice how cleanly the negative half agrees with chapter 2.6: there, the worst perturbation for these simple policies was latency, not model mismatch — they already shrugged off mass and gravity. Domain randomization is the cure for model mismatch. Where model mismatch was never the disease, the cure does little. Measure the gap, and measure it with error bars, before you trust the method.

The randomization band is a hyperparameter (exercise 2)

If a wider band always meant more robustness, you would just crank --dr_width to the sky. The second exercise makes you test that folklore: sweep the band from 0 (no DR) to double-wide and watch what it buys and where it stops. A wider band does buy more robustness through the middle of the gap — on average, though noisily — and it does so without denting nominal return. But the deepest gap point stays pinned on the floor at every width: by 1.6× mass the ±12 Nm servos simply cannot hold, and no amount of sampling near an impossible load makes it possible. Randomization widens the range you can reach; it never lifts the ceiling of what the motors can do. "Randomize harder" is a real dial with a real limit, not a free path to transfer.

Determinism and honesty

Everything is seeded — torch, every env reset, and the domain-randomization draw stream — so a fixed --seed reproduces byte-for-byte on CPU (CI checks the smoke twice over), and randomizing on top of the pinned contact solver preserves that guarantee. But the conclusion wobbles seed to seed, exactly as chapter 1.6 warned: on one seed the randomized policy looks a touch more reliable, on another a touch worse. That is the tell. When the narrow-vs-randomized difference lives inside the seed band, the honest report is that you have not demonstrated a benefit — and this chapter reports that, rather than cherry-picking the seed where DR happened to win.

Read the real thing

Three papers built the intuition this chapter distills, and reading them against what you just measured is the point. Tobin et al. (2017) introduced domain randomization by randomizing a simulator's rendering — textures, lighting, camera pose — so an object detector trained purely in sim transferred to real images. Peng et al. (2018) moved the idea from pixels to physics, randomizing the dynamics — masses, friction, latency — exactly the axes dr.py scales, and transferred a manipulation policy to a real arm. OpenAI's Dactyl (2018/2019) is the headline case: an in-hand cube-reorientation policy trained entirely in a randomized sim and transferred to a physical Shadow Hand.

Read them for the contrast with what happened here. Every one of those results randomizes a behavior that is genuinely dynamics-brittle — contact-rich manipulation, a dexterous regrasp — where the optimal action really does change with the physics, so the narrow policy has no choice but to overfit and DR is the fix. And every one trains long, spending the compute to actually solve the harder randomized problem. Those are the two conditions a passively stable quadruped stand at a free-tier budget does not meet — which is precisely why your measurement came back inside the seed band. The method is real; the substrate here is honest about its limits.

Practice

practice · candidate exercisesdrafted by the exercise generator, pending human promotion. Answers reveal only after you predict — honor system.

  1. predict-then-run

    Exercise 1

    SUGGESTED exercise candidate (humans promote) — predict-then-run, ch2.7.

    Objective tested: the chapter's central, honest claim — domain randomization CAN extend robustness across the reality gap, but at a free-tier budget it does so UNRELIABLY, and whether it helped is a question you answer with error bars, not one lucky seed. This is the ch1.6 "single numbers lie" lesson wearing an RL costume (ch2.1 spike, H1/H2): you predict a STRUCTURAL fact and check it holds across seeds.

    THE QUESTION. dr.py trains a NARROW policy (nominal dynamics only) and a RANDOMIZED policy (mass/friction/gravity resampled each episode) and sweeps both across a mass-scale gap. Across that gap, does the randomized policy reliably HOLD its survival where the narrow one FALLS?

    PREDICT before you run: (a) yes — randomized reliably survives deeper into the gap than narrow, on every seed; (b) it depends on the seed — sometimes DR extends the range dramatically, sometimes not at all, so the average edge is within the seed band; (c) no — randomized is reliably WORSE, having paid a premium for nothing. Write your choice and one sentence of mechanism in PREDICTION.

    Then run this file. It trains both policies on seeds 0, 1, 2 and reads their survival at nominal (mass 1.0) and at the DEEPEST gap point (heaviest mass). Two facts hold on every seed — both policies stand at nominal, and both collapse in the DEEPEST gap. What happens BETWEEN those brackets (does randomized hold at 1.2x or 1.4x mass?) swings hard with the seed: that swing, not any single run, is the answer to whether DR "worked" here.

    Estimated learner time: ~15 min (three short two-policy trains + sweeps).

    Run it locally:

    pytest curriculum/phase2_reinforcement/ch2.7_dr/exercises/suggested/checks.py -k ex1
  2. hyperparameter-investigation

    Exercise 2

    SUGGESTED exercise candidate (humans promote) — hyperparameter investigation, ch2.7.

    Objective tested: the MECHANISM behind domain randomization — the width of the randomization band, and where it stops helping. The folklore is "randomize more, generalize more." This exercise makes you test that against a seed-robust sweep (ch2.1 spike, H1): form a directional hypothesis about band width, then read it against the numbers.

    THE KNOB. --dr_width scales the randomization band. 0 = no randomization (the narrow policy). 1 = the chapter's nominal band (mass +-40%, friction +-50%, gravity +-25%). 2 = double-wide, reaching into dynamics so heavy that the deepest episodes are near-unlearnable on this hardware.

    PREDICT before you run: as --dr_width grows from 0 to 2, the randomized policy's survival at the DEEPEST gap point (heaviest mass) will (a) rise steadily — more randomization, more robustness even there; (b) stay pinned near zero — that point is a physical ceiling no amount of sampling can cross; (c) rise then fall. And what happens to NOMINAL return as the band widens? Write your choice and a one-sentence reason in PREDICTION.

    Then run this file. It trains the randomized policy at each width on seeds 0, 1 and reports, per width, the nominal return and the deepest-gap survival. Read the MEANS: a real hyperparameter effect has to show in the average, not one seed. Watch nominal hold steady while the deepest-gap survival refuses to leave the floor — "randomize harder" widens the range you can reach, but it cannot buy a load the motors cannot lift.

    Estimated learner time: ~20 min (six short two-policy trains).

    Run it locally:

    pytest curriculum/phase2_reinforcement/ch2.7_dr/exercises/suggested/checks.py -k ex2

Colophon · provenance

The code on this page is not pasted — each panel is included by region straight fromdr.py, and these fingerprints are its sha256, the same ones check_prose_code_drift re-checks on every PR. Edit a shown region without re-rendering and CI turns red.

#setup
eab797a583729ab77f57df6371ecae8ddd9c7f9919ca29f6501590377622559b
#randomize
415eaeb4553eb93355f226eec8fb85ac3aa4df26aacf457f68dbef3a21e0617a
#model
d5d77b08f1cd4b98c6fe31931644f38951668c799673bafe189e0c3f9b18e66b
#ppo
69a10376befc13205b10f65403ebfe706dc6465bdf24db9f399d566c6344542a
#eval
47d19d84383d5a558fdd7f235fa37914ecc6201adf9fa7bfdd26730dbc8deb69
#report
5cf5f188f05472cc27178ea3d7393129bcd27e9d2431fa39fd76849d7dd1cb9e