<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://m-rr-j.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://m-rr-j.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-07-07T09:19:40+00:00</updated><id>https://m-rr-j.github.io/feed.xml</id><title type="html">blank</title><subtitle>Notes on reinforcement learning, self-supervised representation learning, and transformers — built from scratch, one experiment at a time. </subtitle><entry><title type="html">The last mile was a compiler flag: getting Procgen to build on Apple Silicon</title><link href="https://m-rr-j.github.io/blog/2026/procgen-last-mile/" rel="alternate" type="text/html" title="The last mile was a compiler flag: getting Procgen to build on Apple Silicon"/><published>2026-07-07T09:00:00+00:00</published><updated>2026-07-07T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/procgen-last-mile</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/procgen-last-mile/"><![CDATA[<p>The benchmark everyone recommends wouldn’t install on my laptop. This is the short story of why I switched the RL work from Super Mario Bros to <a href="https://github.com/openai/procgen">Procgen</a>, what stood in the way, and the <em>last mile</em> it took to get past it — the part where you either fix the tool for real and give the fix back, or quietly work around it and move on.</p> <p>I fixed it. And to be upfront about how I work now: I did it <strong>pairing with an AI coding agent</strong> (Claude Code) — I drove the diagnosis and the decisions, it accelerated the grind. That collaboration <em>is</em> part of the story.</p> <hr/> <h2 id="why-leave-mario-at-all">Why leave Mario at all?</h2> <p>Short version: Mario 1-1 is a <strong>single hand-built level</strong>, so you can’t separate <em>learning</em> from <em>memorizing</em>, and it runs on an emulator that caps how many environments you can parallelize. <a href="https://arxiv.org/abs/1912.01588">Procgen</a> fixes both — procedurally-generated levels with a real train/test split, batched natively in one process. I unpack all of that in the previous post, <a href="/blog/2026/mario-vs-procgen/">From Mario to Procgen</a>. This post is about the wall I hit <em>getting there</em>.</p> <h2 id="the-wall">The wall</h2> <p>There’s no prebuilt Procgen wheel for Apple Silicon, so <code class="language-plaintext highlighter-rouge">pip</code> tries to build from source — and on an M1 that build simply fails. Three separate root causes, none of them obvious from the error spew:</p> <ol> <li><strong>An x86-only compiler flag.</strong> Procgen’s <code class="language-plaintext highlighter-rouge">CMakeLists.txt</code> compiles packaged builds with <code class="language-plaintext highlighter-rouge">-march=ivybridge</code> — an <em>Intel</em> microarchitecture. arm64 Clang rejects it outright. (The fix: on <code class="language-plaintext highlighter-rouge">arm64</code>/<code class="language-plaintext highlighter-rouge">aarch64</code>, use the base <code class="language-plaintext highlighter-rouge">armv8-a</code> ISA instead.)</li> <li><strong>Qt looked for at the wrong address.</strong> The build probes one hardcoded path for Qt — <code class="language-plaintext highlighter-rouge">/usr/local/opt/qt5</code>, the <em>Intel</em> Homebrew location. Apple Silicon Homebrew installs under <code class="language-plaintext highlighter-rouge">/opt/homebrew</code>, so CMake never finds it. (The fix: also probe <code class="language-plaintext highlighter-rouge">/opt/homebrew/opt/qt@5/lib/cmake</code>.)</li> <li><strong>Warnings that became errors.</strong> Modern AppleClang (21) promotes two “set-but-unused variable” warnings in <code class="language-plaintext highlighter-rouge">starpilot.cpp</code> and <code class="language-plaintext highlighter-rouge">chaser.cpp</code> to hard errors under <code class="language-plaintext highlighter-rouge">-Werror</code>. (The fix: mark the two locals <code class="language-plaintext highlighter-rouge">[[maybe_unused]]</code> — zero change to any game logic.)</li> </ol> <p>None of these is deep. All three are the kind of paper cut that makes a person give up: buy a cloud box, switch benchmarks, or paste a hack into their local copy and never speak of it again.</p> <h2 id="the-last-mile">The last mile</h2> <p>Here’s the part I actually care about. A local hack would have unblocked <em>me</em> in ten minutes. Instead:</p> <ul> <li>I traced each failure to its <strong>root cause</strong> rather than silencing symptoms.</li> <li>The fix is <strong>four files, +15/−5 lines, behavior-preserving</strong> — the smallest change that restores the source build without touching a single game’s determinism.</li> <li>I <strong>upstreamed it</strong> as a pull request to <code class="language-plaintext highlighter-rouge">openai/procgen</code> — and it turned out to close <a href="https://github.com/openai/procgen/issues/69">issue #69, “Apple Silicon Support,”</a> which had been open since <strong>2021</strong> with a string of people hitting the same wall. So the fix doesn’t just help me; it helps everyone who tries Procgen on a Mac next.</li> <li>Then I <strong>proved it holds under load</strong> — not a 50-step smoke test, but a full <strong>100M-step</strong> PPO run (IMPALA-CNN, 64 environments batched natively, training on Metal/MPS) plus an off-policy world-model experiment on top. Stable start to finish.</li> </ul> <blockquote> <p><strong>PR:</strong> <a href="https://github.com/openai/procgen/pull/107">openai/procgen#107</a> — <em>Fix building from source on Apple Silicon (arm64) and recent Clang.</em> My first open-source contribution.</p> </blockquote> <p>That’s the difference between “I tried Procgen and it didn’t work” and “Procgen works on Apple Silicon now, for everyone, because I fixed it.” The distance between those two sentences is the last mile.</p> <h2 id="the-payoff">The payoff</h2> <p>With the build working, the question that justified the whole detour became answerable. Trained on 200 Miner levels and evaluated on the unseen full distribution:</p> <table> <thead> <tr> <th> </th> <th>mean return</th> <th>solve rate</th> </tr> </thead> <tbody> <tr> <td><strong>Train</strong> (200 seen levels)</td> <td>10.63</td> <td>99%</td> </tr> <tr> <td><strong>Test</strong> (unseen levels)</td> <td>7.78</td> <td>91%</td> </tr> </tbody> </table> <p>The agent genuinely learned to play — 91% solved on levels it had <em>never seen</em> — but there’s a real <strong>27% generalization gap</strong> it still leaves on the table. That gap is the entire point: a single, honest number that Mario could never have shown me, and the starting line for the representation and transfer experiments this project is actually about.</p> <p>The compiler flag was never the interesting part. Refusing to route around it was.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="procgen"/><category term="generalization"/><category term="open-source"/><category term="apple-silicon"/><summary type="html"><![CDATA[Why I moved the RL work from Mario to Procgen — and why the first step was fixing a four-year-old build bug in the benchmark itself, then upstreaming the patch.]]></summary></entry><entry><title type="html">From Mario to Procgen: preprocessing, the emulator tax, and generalization</title><link href="https://m-rr-j.github.io/blog/2026/mario-vs-procgen/" rel="alternate" type="text/html" title="From Mario to Procgen: preprocessing, the emulator tax, and generalization"/><published>2026-07-06T09:00:00+00:00</published><updated>2026-07-06T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/mario-vs-procgen</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/mario-vs-procgen/"><![CDATA[<p>After the curiosity posts, I moved the reinforcement-learning work off <strong>Super Mario Bros</strong> and onto <strong><a href="https://openai.com/index/procgen-benchmark/">Procgen</a></strong>. This tutorial is the <em>why</em>, from first principles — because the <strong>environment</strong> you pick decides both <em>what you can measure</em> and <em>how fast you can measure it</em>, often more than the algorithm does. Two goals drove the switch:</p> <ol> <li><strong>Stop being CPU-bound</strong> — a genuine waste in deep RL, and</li> <li><strong>Be able to test generalization at all.</strong></li> </ol> <p>If you remember one sentence: <strong>Mario is one hand-built level behind an emulator; Procgen is a factory of levels running natively in one process — and that difference is preprocessing you don’t write, environments you can actually parallelize, and a generalization test you couldn’t run before.</strong></p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/envs/what_the_agent_sees-480.webp 480w,/assets/img/blog/envs/what_the_agent_sees-800.webp 800w,/assets/img/blog/envs/what_the_agent_sees-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/envs/what_the_agent_sees.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p><em>What each agent actually sees. Mario’s rich 240×256 frame gets reduced to an 84×84 grayscale stack before the network touches it; Procgen’s 64×64 frame is fed in as-is.</em></p> <p>Every new term is defined the first time it appears.</p> <hr/> <h2 id="part-0--what-an-environment-gives-you">Part 0 — What an “environment” gives you</h2> <p>An RL <strong>environment</strong> is the world the agent acts in. Each step it takes an <strong>action</strong> and returns an <strong>observation</strong> (here: an image of the screen), a <strong>reward</strong>, and whether the episode ended. The agent never sees the game’s internal state — only pixels. So the <em>shape and cost of those pixels</em>, and <em>how many copies of the world you can run at once</em>, are first-order design facts. That’s what changes between Mario and Procgen.</p> <hr/> <h2 id="part-1--preprocessing-the-frames-you-must-fix-vs-the-frames-you-dont">Part 1 — Preprocessing: the frames you must fix vs. the frames you don’t</h2> <p><strong>Mario hands you a raw NES frame: <code class="language-plaintext highlighter-rouge">(240, 256, 3)</code> RGB</strong>, at the console’s frame rate. You can’t feed that to a CNN as-is — three problems, three fixes, the standard Atari/DQN pipeline:</p> <ul> <li><strong>Too big</strong> → <strong>resize</strong> to <code class="language-plaintext highlighter-rouge">84×84</code> (less compute per frame).</li> <li><strong>Color is mostly decoration</strong> → <strong>grayscale</strong> (drop 3 channels to 1).</li> <li><strong>A single frame isn’t Markov.</strong> From one still image you can’t tell if Mario is <em>rising or falling</em> — velocity is invisible. → <strong>stack the last 4 frames</strong> so the network can infer motion. And <strong>skip frames</strong>: repeat each chosen action for 4 game frames (cheaper, and one decision then maps to one meaningful change).</li> </ul> <blockquote> <p><strong>Markov:</strong> a state is Markov if it contains everything needed to predict the future — no memory of the past required. One Mario frame isn’t (no velocity); four stacked frames approximately are.</p> </blockquote> <p>So before the agent learns anything, you write and tune <strong>four wrappers</strong> (resize, grayscale, stack, skip) that turn <code class="language-plaintext highlighter-rouge">(240,256,3)</code> into <code class="language-plaintext highlighter-rouge">(4, 84, 84)</code>.</p> <p><strong>Procgen hands you <code class="language-plaintext highlighter-rouge">(64, 64, 3)</code> RGB — and you feed it straight to the CNN.</strong> No resize (already small), no grayscale (color is <em>semantic</em> here: keys, walls, and enemies are color-coded — graying it out destroys information), no stacking and no frame-skip (the games are designed to be ~fully observable from a single frame). The preprocessing you skip isn’t laziness — it’s the <em>standard</em>, so your results stay comparable to the published Procgen numbers.</p> <table> <thead> <tr> <th> </th> <th><strong>Mario</strong></th> <th><strong>Procgen</strong></th> </tr> </thead> <tbody> <tr> <td>Raw observation</td> <td><code class="language-plaintext highlighter-rouge">(240, 256, 3)</code> RGB</td> <td><code class="language-plaintext highlighter-rouge">(64, 64, 3)</code> RGB</td> </tr> <tr> <td>Resize</td> <td>needed → <code class="language-plaintext highlighter-rouge">84×84</code></td> <td>none (already small)</td> </tr> <tr> <td>Grayscale</td> <td>yes (color irrelevant)</td> <td><strong>no</strong> (color is semantic)</td> </tr> <tr> <td>Frame stacking</td> <td>4 (to see velocity)</td> <td><strong>none</strong> (near-Markov single frame)</td> </tr> <tr> <td>Frame-skip</td> <td>4</td> <td>none</td> </tr> <tr> <td>What the net sees</td> <td><code class="language-plaintext highlighter-rouge">(4, 84, 84)</code></td> <td><code class="language-plaintext highlighter-rouge">(3, 64, 64)</code></td> </tr> </tbody> </table> <hr/> <h2 id="part-2--the-emulator-tax-why-i-could-run-8-mario-envs-but-64-procgen-envs">Part 2 — The emulator tax: why I could run 8 Mario envs but 64 Procgen envs</h2> <p>RL is data-hungry, so you run <strong>many copies of the environment in parallel</strong> (a <strong>vectorized environment</strong>) to feed the network a big batch each step. <em>How</em> you parallelize depends entirely on what the environment <em>is</em>.</p> <p><strong>Mario runs on an NES emulator.</strong> Under the hood, <code class="language-plaintext highlighter-rouge">gym-super-mario-bros</code> drives <code class="language-plaintext highlighter-rouge">nes-py</code>, a program that <strong>simulates the console</strong> — a CPU-bound chunk of work, one instance per environment. To run many in parallel, “parallel” has to mean <strong>N separate operating-system processes</strong> (a <em>subprocess</em> vectorized env): each environment is its own process, and on every step you <strong>pickle</strong> the observation (a whole image) and push it through an OS <strong>pipe</strong> back to the learner.</p> <blockquote> <p><strong>IPC (inter-process communication):</strong> moving data between separate processes. Pickling an image and sending it over a pipe every step is pure overhead — it does no learning, it just <em>moves bytes</em>.</p> </blockquote> <p>That overhead is brutal: on my M1, roughly <strong>two-thirds of the CPU was spent in the kernel</strong> shuffling observations between processes, not stepping games or training. It also caps how many envs you can afford. <strong>I ran 8.</strong></p> <p><strong>Procgen is a native C++ environment that batches internally.</strong> <code class="language-plaintext highlighter-rouge">ProcgenGym3Env(num=64)</code> steps <strong>64 levels inside one process</strong>, writing all 64 observations straight into a single array — no per-env process, no pickling, no pipes. One call in, a batch out. I jumped straight to <strong>64 environments</strong> feeding one batched GPU forward pass, and throughput <strong>roughly tripled</strong>.</p> <p>Why care? Deep RL alternates two phases: <strong>step the environment (CPU)</strong> and <strong>run the network (GPU)</strong>. If env-stepping is slow, your expensive GPU <strong>sits idle waiting for data</strong> — you’re <em>CPU-bound</em>, paying for a GPU that twiddles its thumbs. The whole point of modern RL infra is to keep the GPU fed; an emulator-one-process-per-env starves it, a native batched env feeds it. <strong>Changing the environment did more for my throughput than any code optimization could.</strong></p> <hr/> <h2 id="part-3--train-and-test-the-question-mario-cant-answer">Part 3 — Train and test: the question Mario can’t answer</h2> <p>Here’s the scientific reason, and it’s the big one.</p> <p><strong>Mario 1-1 is a single, fixed, hand-authored level.</strong> You train on it and evaluate on it — the <em>same</em> level. So when the agent finally clears it, you genuinely <strong>cannot tell</strong> whether it <em>learned to play</em> or <em>memorized this exact layout</em>. There is no held-out level to check against. (Mario has a “random-stages” mode, but it’s a small hand-made pool, not a real protocol.)</p> <p><strong>Procgen procedurally generates its levels from a seed.</strong> So you can train on a fixed set — say <strong>200 levels</strong> — and then <strong>test on levels the agent has never seen</strong> (the rest of an effectively infinite distribution).</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/envs/procgen_levels_grid-480.webp 480w,/assets/img/blog/envs/procgen_levels_grid-800.webp 800w,/assets/img/blog/envs/procgen_levels_grid-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/envs/procgen_levels_grid.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p><em>Nine CoinRun levels from nine different seeds — same rules, different layouts. Train on some, test on the rest, and the score difference tells you how much the agent actually learned versus memorized.</em></p> <blockquote> <p><strong>generalization gap:</strong> <em>train score − test score</em>. How much of the performance is transferable skill versus memorization of the specific training levels.</p> </blockquote> <p>That gap is a single, honest number, and Mario simply can’t produce it. Measuring generalization is the <em>entire reason</em> Procgen exists (<a href="https://arxiv.org/abs/1912.01588">Cobbe et al., 2020</a>). It’s what turns “my agent got good at the game” into “my agent learned something that <em>transfers</em>” — which is the question the rest of this project is actually about.</p> <hr/> <h2 id="so-two-goals-one-environment-change">So: two goals, one environment change</h2> <ul> <li><strong>Not CPU-bound</strong> — native in-process batching took me from 8 → 64 environments and kept the GPU fed instead of idling behind an emulator.</li> <li><strong>Test generalization</strong> — a train/test split over procedurally-generated levels gives a real generalization gap, which a single fixed level never could.</li> </ul> <p>There was one catch: none of this mattered until Procgen actually <em>built</em> on my Apple-Silicon laptop — which, out of the box, it didn’t. Getting past that was its own small adventure in refusing to route around a broken build. That’s the next post: <a href="/blog/2026/procgen-last-mile/">the last mile of fixing Procgen on Apple Silicon</a>.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="procgen"/><category term="mario"/><category term="environments"/><category term="generalization"/><summary type="html"><![CDATA[Three concrete reasons I moved the RL work from Super Mario Bros to Procgen — the preprocessing you don't have to write, the environments you can finally parallelize, and the generalization test Mario can't give you.]]></summary></entry><entry><title type="html">kNN episodic novelty: the count that can’t saturate</title><link href="https://m-rr-j.github.io/blog/2026/knn/" rel="alternate" type="text/html" title="kNN episodic novelty: the count that can’t saturate"/><published>2026-07-04T15:00:00+00:00</published><updated>2026-07-04T15:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/knn</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/knn/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/M-RR-J/rl-ablations/tree/main/rl_ablations/models/knn"><code class="language-plaintext highlighter-rouge">rl_ablations/models/knn</code></a>, built on the <a href="https://github.com/M-RR-J/rl-factory">rl-factory</a> engine.</p> </blockquote> <p>This is the sequel to <code class="language-plaintext highlighter-rouge">rnd_on_features_and_byol.md</code>. There we tried <strong>rndf</strong> — RND novelty measured on the policy’s learned feature φ instead of raw pixels — and it <strong>collapsed</strong>: the predictor MLP fit the frozen random target across the compact 512-d φ-manifold almost instantly (<code class="language-plaintext highlighter-rouge">distill_loss</code> 36 → 0.007 within 200k steps), so prediction error → 0 <em>everywhere</em> and the novelty signal died by step ~20k.</p> <p>The diagnosis pointed at a fix the literature already uses (NGU / Agent57): <strong>stop predicting, start counting.</strong> This tutorial explains the <code class="language-plaintext highlighter-rouge">knn</code> model (<code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/</code>) — why a memory/count signal <em>structurally cannot</em> saturate the way distillation did, how the NGU novelty kernel works, and what to watch for. Every new term is defined the first time it appears.</p> <p>One-sentence version: <strong>novelty = how empty the neighbourhood around this state is, in a memory of where you’ve been <em>this episode</em> — and since each episode starts with an empty memory, novelty is always available, no matter how long training has run.</strong></p> <p>Reference: Badia et al., <em>Never Give Up: Learning Directed Exploration Strategies</em>, ICLR 2020 (<code class="language-plaintext highlighter-rouge">papers/</code> — the episodic-novelty half of NGU).</p> <hr/> <h2 id="part-0--why-distillation-saturated-in-one-picture">Part 0 — Why distillation saturated, in one picture</h2> <p>RND-style novelty is <em>prediction error</em>: a predictor chases a target, and error = “I haven’t learned this yet.” That works <strong>only while the predictor can’t catch up</strong>. On raw pixels (84×84×4, a huge space, CNN target) it never fully catches up, so novelty persists. On a <strong>512-d learned feature</strong>, the predictor fits the <em>whole manifold</em> in a few thousand steps → error ≈ 0 everywhere → <strong>no novelty left</strong>. A learned predictor is a moving target that eventually stops moving.</p> <p>The escape: use a signal whose “freshness” doesn’t depend on a predictor that can converge.</p> <hr/> <h2 id="part-1--counting-instead-of-predicting">Part 1 — Counting instead of predicting</h2> <p>Imagine you could count how many times you’d visited each state. A perfect <strong>visit count</strong> <code class="language-plaintext highlighter-rouge">N(s)</code> gives novelty <code class="language-plaintext highlighter-rouge">1/√N(s)</code> — high for rarely-seen states, low for common ones — and it <strong>never saturates</strong>: a state you’ve never visited has <code class="language-plaintext highlighter-rouge">N(s)=0</code> forever, however long you train. Count-based exploration is the gold standard; the problem is you can’t literally count continuous, high-dimensional states (you never see the exact same pixel frame twice).</p> <p>kNN episodic novelty is an <strong>approximate, soft count</strong>: instead of “how many times have I been at <em>exactly</em> <code class="language-plaintext highlighter-rouge">s</code>”, ask “<strong>how crowded is the neighbourhood around φ(s) in my memory of recent states?</strong>” Many close neighbours ≈ high visit count (familiar); few or far neighbours ≈ low count (novel).</p> <blockquote> <p><strong>memory</strong> here is a running set <code class="language-plaintext highlighter-rouge">M</code> of the feature vectors φ(s) the agent has visited. Novelty reads <code class="language-plaintext highlighter-rouge">M</code>; it doesn’t train anything. In our code it’s a per-env ring buffer, <code class="language-plaintext highlighter-rouge">EpisodicMemory</code> (<code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/memory.py</code>).</p> </blockquote> <hr/> <h2 id="part-2--the-episodic-part-is-what-makes-it-not-saturate">Part 2 — The “episodic” part is what makes it not saturate</h2> <p><strong>The memory is reset at every episode boundary.</strong> So <code class="language-plaintext highlighter-rouge">M</code> only ever holds states from the <em>current</em> episode, and novelty measures “have I been near here <strong>this episode</strong>”, not ever.</p> <p>This is the crux. A brand-new episode starts with <code class="language-plaintext highlighter-rouge">M</code> <strong>empty</strong> → the first states are maximally novel → the agent is paid to reach fresh ground <em>every episode</em>, forever. There is no predictor to converge, no target to match, nothing that decays with training age. Contrast the two failure clocks:</p> <table> <thead> <tr> <th> </th> <th>rndf (distillation)</th> <th>knn (episodic count)</th> </tr> </thead> <tbody> <tr> <td>Novelty source</td> <td>predictor-vs-target error</td> <td>neighbourhood sparsity in episodic memory</td> </tr> <tr> <td>Dies when…</td> <td>predictor fits the manifold (~20k steps, permanent)</td> <td>never — memory resets each episode</td> </tr> <tr> <td>Trains a network?</td> <td>yes (predictor)</td> <td><strong>no</strong></td> </tr> </tbody> </table> <p>In the code this is the <code class="language-plaintext highlighter-rouge">dones</code> handling in <code class="language-plaintext highlighter-rouge">EpisodicKNNModule.intrinsic_reward</code>: on a done step the env’s memory is <code class="language-plaintext highlighter-rouge">reset()</code>, so the next episode scores against an empty buffer.</p> <hr/> <h2 id="part-3--the-ngu-novelty-kernel-the-actual-formula">Part 3 — The NGU novelty kernel (the actual formula)</h2> <p>For an arrived state’s feature <code class="language-plaintext highlighter-rouge">φ</code>, novelty against memory <code class="language-plaintext highlighter-rouge">M</code>:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. find the k nearest neighbours of φ in M           (k = 10)         → squared distances d²_1..d²_k
2. normalise each by a running mean of kNN distances  d̄²             → d²_i / d̄²
3. kernel per neighbour:   K_i = ε / (d²_i/d̄² + ε)   (ε = 1e-3)      → ~1 if very close, ~0 if far
4. similarity:             s   = √(Σ_i K_i)
5. reward:                 r   = 1 / (s + c)          (c = 1e-3), capped at reward_max
</code></pre></div></div> <p>Read it as: <strong>Σ K_i is a soft neighbour count.</strong> Crowded neighbourhood → large Σ K → large <code class="language-plaintext highlighter-rouge">s</code> → small reward (familiar). Empty/far neighbourhood → Σ K → 0 → <code class="language-plaintext highlighter-rouge">s</code> → 0 → large reward (novel). The running <code class="language-plaintext highlighter-rouge">d̄²</code> (an EMA of the kNN distances) makes the kernel <strong>scale-free</strong>, so it works whatever the raw magnitude of φ — that normaliser, not per-dimension whitening, is how NGU handles φ’s scale. The <code class="language-plaintext highlighter-rouge">c</code> and the cap keep a near-empty memory from producing an unbounded spike. Code: <code class="language-plaintext highlighter-rouge">EpisodicKNNModule._novelty</code>.</p> <p>As with the other curiosity modules, the raw reward is then <strong>η-scaled and EMA-normalised</strong> to ~unit scale (<code class="language-plaintext highlighter-rouge">RewardNormalizer</code>) before it’s folded into the reward ahead of GAE — so <code class="language-plaintext highlighter-rouge">knn</code> drops into the exact same PPO pipeline as <code class="language-plaintext highlighter-rouge">rnd</code>/<code class="language-plaintext highlighter-rouge">rndf</code>, only the bonus differs.</p> <hr/> <h2 id="part-4--whats-different-in-the-plumbing-and-one-honest-caveat">Part 4 — What’s different in the plumbing (and one honest caveat)</h2> <p>Two things set <code class="language-plaintext highlighter-rouge">knn</code> apart from <code class="language-plaintext highlighter-rouge">rnd</code>/<code class="language-plaintext highlighter-rouge">rndf</code> mechanically:</p> <ul> <li><strong>No trainable curiosity network.</strong> Novelty is a distance query, so there’s no optimizer, no <code class="language-plaintext highlighter-rouge">distill_loss</code>. <code class="language-plaintext highlighter-rouge">learn()</code> does no gradient step; it reports <code class="language-plaintext highlighter-rouge">mem_size</code> (mean memory occupancy) as a health signal instead. The policy encoder is still only <em>read</em> (φ is detached) — PPO owns it.</li> <li><strong>Stateful and order-dependent.</strong> <code class="language-plaintext highlighter-rouge">intrinsic_reward</code> must be called <strong>once per rollout, in temporal order</strong>, because it accumulates each visited φ into its env’s memory and resets at dones. The memory persists <em>across</em> rollouts (an episode spans many 128-step rollouts). That’s why the module reshapes the flattened <code class="language-plaintext highlighter-rouge">(T·N,)</code> transitions back to <code class="language-plaintext highlighter-rouge">(T, N)</code> and loops per-env, per-step — unlike the stateless, shuffled batching <code class="language-plaintext highlighter-rouge">rnd</code>/<code class="language-plaintext highlighter-rouge">rndf</code> can use.</li> </ul> <blockquote> <p><strong>The caveat to watch for:</strong> <em>pure</em> episodic novelty can reward <strong>resetting the episode</strong>. Dying clears the memory, and a fresh memory means fresh novelty — so an agent can learn that dying, then re-exploring the same early states, is “novel” and pays. NGU guards against this with a <strong>lifelong</strong> novelty modulator (an RND-on-pixels term that stays low in globally-familiar regions), which we’ve deliberately left out to isolate the episodic mechanism. If the pilot shows the agent farming deaths (short episodes, <code class="language-plaintext highlighter-rouge">mem_size</code> never growing, <code class="language-plaintext highlighter-rouge">ep_max_x</code> stuck low), that’s this failure — and the fix is to add the lifelong modulator, i.e. full NGU.</p> </blockquote> <hr/> <h2 id="part-5--what-we-expect-and-how-well-read-it">Part 5 — What we expect, and how we’ll read it</h2> <p>Matched pure-curiosity A/B, third arm alongside <code class="language-plaintext highlighter-rouge">rnd_pc</code> (pixels) and <code class="language-plaintext highlighter-rouge">rndf</code> (learned-feature distillation): same knobs (extrinsic 0.0, entropy 0.02, η 0.5, seed 0, 1M steps, 8 actors), novelty mechanism the only difference.</p> <ul> <li><strong>The hypothesis:</strong> <code class="language-plaintext highlighter-rouge">intrinsic_reward</code> <strong>stays alive for the whole run</strong> (no crater to ~0), because a count can’t saturate — directly fixing the rndf failure.</li> <li><strong><code class="language-plaintext highlighter-rouge">mem_size</code></strong> should grow within episodes and drop at resets (memory is doing its job).</li> <li><strong>Success looks like</strong> healthy entropy and <code class="language-plaintext highlighter-rouge">ep_max_x</code> that climbs as curiosity drives coverage.</li> <li><strong>The two failure modes to distinguish:</strong> (a) death-farming (Part 4 caveat) → needs the lifelong modulator; (b) distances-uninformative-in-φ → novelty noisy but not dead.</li> </ul> <h2 id="code-map">Code map</h2> <table> <thead> <tr> <th>Piece</th> <th>File</th> </tr> </thead> <tbody> <tr> <td>Reward + memory maintenance</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/module.py</code></td> </tr> <tr> <td>The episodic ring buffer</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/memory.py</code></td> </tr> <tr> <td>Learner (PPO + kNN, no curiosity training)</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/learner.py</code></td> </tr> <tr> <td>Hyperparameters (kernel, k, capacity)</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/knn/config.py</code></td> </tr> <tr> <td>Pilot config</td> <td><code class="language-plaintext highlighter-rouge">configs/knn_pilot.yaml</code></td> </tr> </tbody> </table> <p>Prior tutorials in this arc: <code class="language-plaintext highlighter-rouge">curiosity_icm_on_ppo.md</code> → <code class="language-plaintext highlighter-rouge">icm_vs_rnd.md</code> → <code class="language-plaintext highlighter-rouge">rnd_on_features_and_byol.md</code> → <strong>this</strong>. The empirical thread (ICM saturates → RND fixes it → rndf collapses → knn) is in the <code class="language-plaintext highlighter-rouge">curiosity-exploration-findings</code> project note.</p> <h2 id="results-on-mario-1-1">Results on Mario 1-1</h2> <p>Here are all four novelty mechanisms side by side in pure-curiosity mode — RND (distill on pixels), rndf (distill on learned φ), knn (count on the critic’s φ), and knn_idf (count on an inverse-dynamics φ):</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/curves/curiosity_4way-480.webp 480w,/assets/img/blog/curves/curiosity_4way-800.webp 800w,/assets/img/blog/curves/curiosity_4way-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/curves/curiosity_4way.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p>The two distillation signals behave as predicted — rndf’s intrinsic reward flatlines near zero (its target got easy), and even RND-on-pixels drifts. The <strong>count-based kNN signals stay alive</strong> the whole run, because each episode starts with an empty memory: novelty is always available, no matter how long training has run. That’s the structural win — a novelty signal that can’t die.</p> <h2 id="where-the-series-pauses">Where the series pauses</h2> <p>That closes the arc I set out to walk — <strong>DQN → PPO → ICM → RND → kNN</strong> — each model earning its place by fixing a limitation of the last, all sharing the one <a href="/blog/2026/rl-factory/">rl-factory</a> spine. The natural next step, learning the representation φ <em>on purpose</em> rather than borrowing the critic’s, is where this turns into open research — a story for another day.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="curiosity"/><category term="exploration"/><category term="ngu"/><category term="reinforcement-learning"/><summary type="html"><![CDATA[A count-based novelty signal that structurally cannot die the way distillation did — because every episode starts from an empty memory.]]></summary></entry><entry><title type="html">RND: a simpler curiosity, and why simpler won</title><link href="https://m-rr-j.github.io/blog/2026/rnd/" rel="alternate" type="text/html" title="RND: a simpler curiosity, and why simpler won"/><published>2026-07-04T09:00:00+00:00</published><updated>2026-07-04T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/rnd</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/rnd/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/M-RR-J/rl-ablations/tree/main/rl_ablations/models/rnd"><code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd</code></a>, built on the <a href="https://github.com/M-RR-J/rl-factory">rl-factory</a> engine.</p> </blockquote> <p>You’ve read the ICM tutorial (<code class="language-plaintext highlighter-rouge">curiosity_icm_on_ppo.md</code>), so you know the big idea: to make an agent explore, hand it a <strong>bonus reward for surprise</strong> — for reaching states it couldn’t predict. Then we added a second curiosity method, <strong>RND</strong>, and the natural question is: <em>is RND just ICM with a tweak?</em></p> <p><strong>No.</strong> They share one sentence of philosophy (“prediction error = novelty”) and nothing else of substance. This tutorial explains — for someone who has seen ICM but not RND — exactly where they diverge, why RND came <em>after</em> ICM but is <em>simpler</em>, and why that simplicity is what saved our Mario runs. Every new term is defined the first time it appears. The code lives in <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/</code> and <code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd/</code>; both plug into the same PPO training loop.</p> <p>If you remember one sentence: <strong>ICM asks “can I predict what my <em>action</em> does here?”; RND asks “have I <em>seen</em> a state like this before?” — a dynamics question versus a visitation question.</strong></p> <ul> <li>ICM: Pathak, Agrawal, Efros &amp; Darrell, <em>Curiosity-driven Exploration by Self-supervised Prediction</em>, ICML 2017 (<code class="language-plaintext highlighter-rouge">papers/curiosity_driven_exploration_pathak2017/</code>).</li> <li>RND: Burda, Edwards, Storkey &amp; Klimov, <em>Exploration by Random Network Distillation</em>, ICLR 2019 (<code class="language-plaintext highlighter-rouge">papers/random_network_distillation_burda2018/</code>).</li> </ul> <hr/> <h2 id="part-0--the-one-thing-they-agree-on">Part 0 — The one thing they agree on</h2> <p>Both methods compute an <strong>intrinsic reward</strong> (a bonus the agent invents for itself, on top of the game’s <strong>extrinsic</strong> reward) as a <strong>prediction error</strong>: build a predictor, measure how wrong it is, pay the agent proportionally to the wrongness. High error = “I don’t understand this yet” = novel = go there.</p> <p>In both of our implementations that idea shows up as the <em>same two-method interface</em>:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>intrinsic_reward(transitions) -&gt; per-transition bonus, (T·N,)   # score novelty
learn(transitions)            -&gt; train the predictor            # make today's states cheap tomorrow
</code></pre></div></div> <p>The total reward the policy optimizes is <code class="language-plaintext highlighter-rouge">r = r_extrinsic + η · r_intrinsic</code> in both (<code class="language-plaintext highlighter-rouge">η</code> is <code class="language-plaintext highlighter-rouge">intrinsic_scale</code> in each config). And in both, the raw error is EMA-normalized by a <code class="language-plaintext highlighter-rouge">RewardNormalizer</code> so the bonus stays ~unit-scale as the predictor improves.</p> <p>That’s the entire overlap. Everything below is a difference.</p> <hr/> <h2 id="part-1--the-core-split-what-do-you-predict">Part 1 — The core split: <em>what</em> do you predict?</h2> <p>This is the whole ballgame. Both predict something and pay you the error. They predict <strong>different things</strong>.</p> <h3 id="icm-predicts-the-future--a-dynamics-model">ICM predicts the future — a <em>dynamics</em> model</h3> <p>ICM’s predictor is a <strong>forward model</strong>: given the current state’s features and the action you took, predict the <em>next</em> state’s features.</p> <blockquote> <p><strong>forward model:</strong> φ̂(s’) = f(φ(s), a). “If I’m here and press A, where do I end up?” <strong>ICM intrinsic reward:</strong> ‖φ̂(s’) − φ(s’)‖² — how wrong that guess was.</p> </blockquote> <p>The signal answers: <em>“Are the consequences of my actions still surprising here?”</em> It’s inherently about <strong>transitions</strong> — it needs <code class="language-plaintext highlighter-rouge">(state, action, next_state)</code>. Look at <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/module.py</code>: <code class="language-plaintext highlighter-rouge">intrinsic_reward</code> encodes both <code class="language-plaintext highlighter-rouge">states</code> and <code class="language-plaintext highlighter-rouge">next_states</code>, takes <code class="language-plaintext highlighter-rouge">actions</code>, and computes <code class="language-plaintext highlighter-rouge">_forward_error(phi_s, actions, phi_next)</code>.</p> <h3 id="rnd-predicts-a-fixed-random-function--a-visitation-counter">RND predicts a fixed random function — a <em>visitation</em> counter</h3> <p>RND has no future, no action, no dynamics. It sets up two networks over the <strong>current</strong> state only:</p> <ul> <li>a <strong>target</strong> network: randomly initialized weights, then <strong>frozen forever</strong>. It’s an arbitrary, meaningless-but-fixed function <code class="language-plaintext highlighter-rouge">f(s)</code>.</li> <li>a <strong>predictor</strong> network: trained to reproduce the target’s output, <code class="language-plaintext highlighter-rouge">f̂(s) ≈ f(s)</code>, on the states the agent actually visits.</li> </ul> <blockquote> <p><strong>RND intrinsic reward:</strong> ‖f̂(s) − f(s)‖² — how badly the predictor matches the frozen target <em>at this state</em>.</p> </blockquote> <p>Why is that novelty? Because a neural network only gets good at inputs it’s been <em>trained on</em>. If the agent has visited states like <code class="language-plaintext highlighter-rouge">s</code> many times, the predictor has had many gradient steps there, so it matches the target closely → <strong>low</strong> error → familiar. If <code class="language-plaintext highlighter-rouge">s</code> is somewhere new, the predictor has never been fit there → <strong>high</strong> error → novel. The error is, in effect, a <strong>soft visit counter</strong>: “how many times have I been near here?” See <code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd/model.py</code> — the <code class="language-plaintext highlighter-rouge">target</code>’s parameters get <code class="language-plaintext highlighter-rouge">requires_grad_(False)</code> and the <code class="language-plaintext highlighter-rouge">predictor</code> chases it.</p> <p><strong>This is the extension question, answered concretely:</strong> RND does not extend ICM’s forward model — it <em>deletes</em> it. Gone: the action input, the next-state prediction, the whole notion of dynamics. RND is strictly <em>less</em> machinery than ICM, not more.</p> <hr/> <h2 id="part-2--the-encoder-icms-clever-bit-that-rnd-doesnt-need">Part 2 — The encoder: ICM’s clever bit that RND doesn’t need</h2> <p>ICM’s subtlety was <strong>where</strong> it predicts. Predicting raw pixels invites the <strong>noisy-TV problem</strong>: a patch of random flicker (TV static, swaying leaves) is <em>forever</em> unpredictable, so a pixel-predictor pays curiosity forever for staring at noise. ICM’s fix is to predict in a <strong>learned feature space φ</strong> trained by an <strong>inverse model</strong> — a second head that predicts <em>which action</em> took you from <code class="language-plaintext highlighter-rouge">s</code> to <code class="language-plaintext highlighter-rouge">s'</code>. To do that job, φ must keep only what the agent can <em>control</em> and discard uncontrollable noise. So the flicker never enters φ, and the noisy-TV trap closes.</p> <p>That inverse model is real, load-bearing complexity in <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/</code>: an extra network head, a cross-entropy loss, and (as the ICM tutorial’s war story tells) a mandatory <code class="language-plaintext highlighter-rouge">detach</code> so the forward loss doesn’t collapse φ’s scale to zero.</p> <p><strong>RND sidesteps all of it.</strong> Its target is a <em>fixed random</em> function of the state, so there is no representation to shape, no inverse model, no encoder-collapse bug to guard against. RND’s only required preprocessing is <strong>observation normalization</strong> (whiten the pixels with a <code class="language-plaintext highlighter-rouge">RunningMeanStd</code> before the nets) — because a frozen random target is unlearnable on raw, arbitrarily-scaled pixels. That’s it. Compare the two <code class="language-plaintext highlighter-rouge">module.py</code> files: ICM juggles two losses and a <code class="language-plaintext highlighter-rouge">beta</code> blend; RND runs a single distillation MSE.</p> <table> <thead> <tr> <th> </th> <th><strong>ICM</strong></th> <th><strong>RND</strong></th> </tr> </thead> <tbody> <tr> <td>Predicts</td> <td>next-state features φ(s’) from φ(s) + action</td> <td>a frozen random embedding f(s) of the current state</td> </tr> <tr> <td>Uses the action?</td> <td><strong>yes</strong> (forward model input)</td> <td><strong>no</strong></td> </tr> <tr> <td>Uses the next state?</td> <td><strong>yes</strong></td> <td><strong>no</strong> — current state only</td> </tr> <tr> <td>Novelty means</td> <td>“consequences of my actions are surprising”</td> <td>“I haven’t visited states like this”</td> </tr> <tr> <td>Extra network to shape features</td> <td><strong>inverse model</strong> (predict the action)</td> <td>none — target is random &amp; fixed</td> </tr> <tr> <td>Main failure it fights</td> <td>noisy-TV (via the learned φ)</td> <td>none intrinsic; needs obs-normalization to work</td> </tr> <tr> <td>Trained parts</td> <td>encoder φ + inverse head + forward head</td> <td>predictor only (target is frozen)</td> </tr> </tbody> </table> <hr/> <h2 id="part-3--why-the-difference-mattered-on-mario">Part 3 — Why the difference <em>mattered</em> on Mario</h2> <p>This isn’t academic — it’s exactly why we have both in the repo. On our Atari-style Mario (4-stacked frames + frameskip), ICM’s forward model <strong>saturates in ~20k steps</strong>: consecutive frames are trivially predictable, so <code class="language-plaintext highlighter-rouge">forward_loss → ~0.003</code> <em>everywhere</em>, even in genuinely new regions. The curiosity signal dies, and no amount of tuning <code class="language-plaintext highlighter-rouge">entropy_coef</code> revives a reward that has gone to zero. Pure-curiosity ICM gets Mario stuck around x≈400.</p> <p>RND doesn’t have a “next frame” to find predictable, so it can’t saturate that way. Its error tracks <em>visitation</em>, which keeps rising as new areas appear. In our 80k-step pure-curiosity run (game reward off), RND’s <code class="language-plaintext highlighter-rouge">intrinsic_reward</code> stayed 0.04–0.13 and <em>rose</em> on reaching new areas, entropy stayed healthy, and Mario reached <strong>x≈1200 on curiosity alone</strong>. The mechanism that saved us is precisely the ICM machinery RND <em>removed</em> — dynamics prediction was the thing that saturated.</p> <blockquote> <p><strong>Takeaway for the exam:</strong> “RND is simpler than ICM” isn’t a footnote — the simplification (drop the forward model, count visitation instead) is <em>the fix</em> for the failure mode that sank ICM here.</p> </blockquote> <hr/> <h2 id="part-4--so-sibling-or-successor">Part 4 — So, sibling or successor?</h2> <p>Chronologically RND (2019) came after ICM (2017), from an overlapping group of authors, and was partly motivated by ICM’s known weaknesses (noisy-TV, and the saturation we hit). But <strong>it is not built on ICM</strong> — it’s a different, leaner answer to the same question. If you must draw a family tree:</p> <ul> <li><strong>Shared parent:</strong> “intrinsic reward = prediction error” (curiosity as self-supervised surprise).</li> <li><strong>ICM’s branch:</strong> predict <em>dynamics</em> in a <em>learned, control-aware</em> feature space. Powerful, but more moving parts and prone to saturating when dynamics are easy.</li> <li><strong>RND’s branch:</strong> predict a <em>fixed random</em> function of the <em>raw (normalized) state</em>. A soft visit counter. Fewer moving parts, robust to easy dynamics, but tells you nothing about <em>why</em> a state is novel (no controllability signal).</li> </ul> <p>Neither is strictly better. ICM’s controllability filter is genuinely useful when uncontrollable noise dominates the observation; RND’s visitation signal is more robust when the environment’s dynamics are too easy to stay surprising. In this repo they’re <strong>peers</strong> — two <code class="language-plaintext highlighter-rouge">PPOLearner</code> wrappers behind one interface — which is exactly how you should think of them.</p> <hr/> <h2 id="where-to-look-in-the-code">Where to look in the code</h2> <table> <thead> <tr> <th>Concept</th> <th>ICM</th> <th>RND</th> </tr> </thead> <tbody> <tr> <td>Reward + training loop</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/module.py</code></td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd/module.py</code></td> </tr> <tr> <td>Networks</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/model.py</code> (encoder + inverse + forward)</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd/model.py</code> (frozen target + predictor)</td> </tr> <tr> <td>Hyperparameters</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/config.py</code></td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/rnd/config.py</code></td> </tr> <tr> <td>Shared normalizers</td> <td>—</td> <td><code class="language-plaintext highlighter-rouge">rl_ablations/models/normalization.py</code> (<code class="language-plaintext highlighter-rouge">RewardNormalizer</code>, <code class="language-plaintext highlighter-rouge">RunningMeanStd</code>)</td> </tr> </tbody> </table> <p>Both are training-only (they never touch acting or evaluation — the policy is a plain PPO <code class="language-plaintext highlighter-rouge">PolicyAgent</code>) and neither is checkpointed. Run either with its config in <code class="language-plaintext highlighter-rouge">configs/icm.yaml</code> / <code class="language-plaintext highlighter-rouge">configs/rnd.yaml</code>; set <code class="language-plaintext highlighter-rouge">extrinsic_reward_scale: 0.0</code> (RND) or <code class="language-plaintext highlighter-rouge">reward_scale: 0</code> (ICM) to see <em>pure</em> curiosity, which is the only honest test that the intrinsic signal actually works.</p> <h2 id="results-on-mario-1-1">Results on Mario 1-1</h2> <p>Tested as <em>pure</em> curiosity, the design question becomes empirical: put the novelty split on raw <strong>pixels</strong> (RND) versus on the policy’s learned features <strong>φ</strong> (rndf). The feature-space variant <strong>collapses</strong> — the predictor fits the frozen random target across the compact φ-manifold almost instantly, so prediction error → 0 <em>everywhere</em> and the novelty signal dies within ~20k steps. RND-on-pixels stays alive:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/curves/rnd_vs_rndf-480.webp 480w,/assets/img/blog/curves/rnd_vs_rndf-800.webp 800w,/assets/img/blog/curves/rnd_vs_rndf-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/curves/rnd_vs_rndf.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p>That collapse is the whole lesson: <strong>any distillation-based novelty can saturate</strong> once its target becomes easy to predict. Which raises the obvious question — is there a novelty signal that <em>structurally cannot</em> saturate? There is: stop predicting, start counting → <a href="/blog/2026/knn/">kNN episodic novelty</a>.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="curiosity"/><category term="rnd"/><category term="icm"/><category term="exploration"/><summary type="html"><![CDATA[ICM asks a dynamics question, RND a visitation question — and on Mario the simpler visitation signal held up where a prediction-on-features variant collapsed.]]></summary></entry><entry><title type="html">ICM: giving Mario curiosity (and the bug that ate it)</title><link href="https://m-rr-j.github.io/blog/2026/icm/" rel="alternate" type="text/html" title="ICM: giving Mario curiosity (and the bug that ate it)"/><published>2026-07-03T09:00:00+00:00</published><updated>2026-07-03T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/icm</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/icm/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/M-RR-J/rl-ablations/tree/main/rl_ablations/models/icm"><code class="language-plaintext highlighter-rouge">rl_ablations/models/icm</code></a>, built on the <a href="https://github.com/M-RR-J/rl-factory">rl-factory</a> engine.</p> </blockquote> <p>This tutorial explains the <strong>Intrinsic Curiosity Module (ICM)</strong> we added to the project — what curiosity means for a reinforcement-learning agent, how we bolted it onto our existing PPO agent, and the story of a real bug where the curiosity signal quietly collapsed to zero and how we fixed it. It assumes you’ve read the PPO tutorial (<code class="language-plaintext highlighter-rouge">policy_gradients_reinforce_to_ppo.md</code>) or at least know that PPO learns a policy <code class="language-plaintext highlighter-rouge">π(a|s)</code> by nudging it toward actions that beat expectations. Every new term is defined the first time it appears. The code lives in <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/</code> (the curiosity model) and <code class="language-plaintext highlighter-rouge">rl_factory/training/rollout.py</code> + <code class="language-plaintext highlighter-rouge">rl_factory/training/on_policy.py</code> (where it plugs into training).</p> <p>If you only remember one sentence: <strong>we give the agent a bonus reward for reaching states it couldn’t predict, computed in a learned feature space, and the whole trick is making sure that “surprise” signal doesn’t accidentally shrink to nothing.</strong></p> <p>The paper is Pathak, Agrawal, Efros &amp; Darrell, <em>Curiosity-driven Exploration by Self-supervised Prediction</em>, ICML 2017 (<code class="language-plaintext highlighter-rouge">papers/curiosity_driven_exploration_pathak2017/</code>).</p> <hr/> <h2 id="part-0--why-an-agent-needs-curiosity">Part 0 — Why an agent needs curiosity</h2> <p>Our PPO agent learns from the game’s <strong>reward</strong> — roughly, how far right Mario moved. That works because the reward is <em>dense</em>: almost every step gives some signal. But many interesting problems have <strong>sparse rewards</strong>: you get nothing until you reach a far-off goal. Imagine Mario only scored at the flagpole. A policy that explores randomly would almost never reach it, so it would almost never see a reward, so it would never learn. This is the <strong>exploration problem</strong>.</p> <p>Curiosity is one answer. The idea: <strong>manufacture your own reward for exploring.</strong> On top of the game’s reward (the <strong>extrinsic</strong> reward, “from outside”), add an <strong>intrinsic</strong> reward (“from inside the agent”) that is high when the agent encounters something <em>novel</em> — something it hasn’t figured out yet. The agent then seeks out novelty on its own, which spreads it across the level even when the game is stingy with points.</p> <p>The famous demo from the paper: with the extrinsic reward turned <strong>completely off</strong>, a curiosity-only Mario still learns to run right, jump gaps, and dodge enemies — purely because doing so keeps producing states it can’t predict.</p> <blockquote> <p><strong>The total reward</strong> the policy learns from is <code class="language-plaintext highlighter-rouge">r = r_extrinsic + η · r_intrinsic</code>, where <code class="language-plaintext highlighter-rouge">η</code> (eta) is a knob for how much the curiosity bonus counts. In our code <code class="language-plaintext highlighter-rouge">η</code> is <code class="language-plaintext highlighter-rouge">ICMConfig.intrinsic_scale</code>.</p> </blockquote> <hr/> <h2 id="part-1--what-counts-as-novel-prediction-error">Part 1 — What counts as “novel”? Prediction error.</h2> <p>How does the agent know a state is novel? <strong>It tries to predict what happens next, and measures how wrong it is.</strong> If the agent can accurately predict the next state, that situation is familiar — boring. If its prediction is badly wrong, the situation is surprising — worth exploring. So:</p> <blockquote> <p><strong>intrinsic reward = how badly the agent predicted the next state.</strong></p> </blockquote> <p>The naive version: predict the next <em>screen of pixels</em> from the current screen and the action. This fails badly, for a reason the paper calls the <strong>noisy-TV problem</strong>. Suppose there’s a patch of the screen full of random flicker (TV static, swaying leaves, a particle effect). Pixel prediction there is <em>always</em> wrong, so the agent gets <em>permanent</em> curiosity reward for staring at noise. It becomes a couch potato hypnotized by static, never exploring the actual level.</p> <p>The fix is the heart of ICM: <strong>don’t predict pixels. Predict in a learned feature space that only keeps the parts of the world the agent can control or affect.</strong> If the feature space throws away the flickering noise (because the agent can’t influence it), then noise contributes no prediction error, and the noisy-TV trap disappears.</p> <p>Call that feature space <code class="language-plaintext highlighter-rouge">φ</code> (phi): a neural network — an <strong>encoder</strong> — that maps a raw state <code class="language-plaintext highlighter-rouge">s</code> (four stacked 84×84 game frames) to a vector of 512 numbers, <code class="language-plaintext highlighter-rouge">φ(s)</code>. The rest of ICM is about (a) shaping <code class="language-plaintext highlighter-rouge">φ</code> to keep only controllable stuff, and (b) using <code class="language-plaintext highlighter-rouge">φ</code> to measure surprise.</p> <hr/> <h2 id="part-2--the-three-networks-of-icm">Part 2 — The three networks of ICM</h2> <p>ICM is one network with three parts (<code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/model.py</code>, class <code class="language-plaintext highlighter-rouge">ICMNet</code>):</p> <p><strong>1. The encoder φ.</strong> Convolutional layers (the same “Nature-DQN” torso our PPO agent uses) ending in a 512-number feature vector. This is the learned feature space.</p> <p><strong>2. The inverse model.</strong> Given the features of two consecutive states, <code class="language-plaintext highlighter-rouge">φ(sₜ)</code> and <code class="language-plaintext highlighter-rouge">φ(sₜ₊₁)</code>, it tries to <strong>predict which action <code class="language-plaintext highlighter-rouge">aₜ</code> the agent took</strong> to get from one to the other. Think about what this forces <code class="language-plaintext highlighter-rouge">φ</code> to learn: to name the action, <code class="language-plaintext highlighter-rouge">φ</code> must encode the things that <em>changed because of the action</em> — Mario’s position, velocity, whether he jumped. It has no reason to encode the swaying background, because the background doesn’t tell you which button was pressed. <strong>Training the inverse model is what shapes <code class="language-plaintext highlighter-rouge">φ</code> to keep only controllable, action-relevant information.</strong> This is the anti-noisy-TV mechanism. Its loss is a standard classification loss (cross-entropy) over the 7 possible actions.</p> <p><strong>3. The forward model.</strong> Given the current features <code class="language-plaintext highlighter-rouge">φ(sₜ)</code> and the action <code class="language-plaintext highlighter-rouge">aₜ</code>, it predicts the <em>next</em> features, <code class="language-plaintext highlighter-rouge">φ̂(sₜ₊₁)</code> (the hat means “predicted”). The <strong>error</strong> between the prediction and the real next features,</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>forward error = ½ · ‖ φ̂(sₜ₊₁) − φ(sₜ₊₁) ‖²          # squared distance between two 512-vectors
</code></pre></div></div> <p><strong>is the curiosity reward.</strong> High error = the agent couldn’t predict this = novel = explore it.</p> <p>Notice the two opposite “directions,” which trip everyone up at first:</p> <table> <thead> <tr> <th>model</th> <th>trained to…</th> <th>its error is used as…</th> </tr> </thead> <tbody> <tr> <td><strong>inverse</strong></td> <td><strong>succeed</strong> (predict the action well)</td> <td>— (it just shapes <code class="language-plaintext highlighter-rouge">φ</code>)</td> </tr> <tr> <td><strong>forward</strong></td> <td>(we let it try)</td> <td>the <strong>reward</strong> — we <em>want</em> the agent to seek states where it <strong>fails</strong></td> </tr> </tbody> </table> <p>The combined training loss for the ICM is <code class="language-plaintext highlighter-rouge">(1−β)·inverse_loss + β·forward_loss</code>, with <code class="language-plaintext highlighter-rouge">β</code> (beta) = 0.2 tilting most of the weight onto shaping <code class="language-plaintext highlighter-rouge">φ</code> well. This all lives in <code class="language-plaintext highlighter-rouge">CuriosityModule</code> (<code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/module.py</code>).</p> <hr/> <h2 id="part-3--how-icm-rides-on-our-ppo-without-touching-the-ppo-loop">Part 3 — How ICM rides on our PPO, without touching the PPO loop</h2> <p>A design goal in this codebase: the training loop shouldn’t know or care <em>which</em> algorithm it’s running. So we added curiosity <strong>without changing a single line of the PPO update or the training strategy’s logic.</strong> Here’s the layering.</p> <ul> <li> <p>The <strong>agent</strong> (the thing that picks actions) is <em>unchanged</em> — it’s the same PPO <code class="language-plaintext highlighter-rouge">PolicyAgent</code>. Curiosity changes only the <em>reward</em>, not how Mario acts. So evaluation and checkpoints use the plain PPO policy; the ICM is a training-time scaffold that’s thrown away afterward.</p> </li> <li> <p>The <strong>strategy</strong> <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code> (<code class="language-plaintext highlighter-rouge">rl_factory/training/on_policy.py</code>) just <em>collects</em> experience: it steps the 8 parallel environments, records each transition into a <code class="language-plaintext highlighter-rouge">RolloutBuffer</code>, and hands the raw rollout to a <strong>learner</strong>. It does not compute rewards, advantages, or losses. It talks to the learner through a tiny interface (<code class="language-plaintext highlighter-rouge">OnPolicyLearner</code>): “here’s the rollout, give me back an update.” That’s the whole contract.</p> </li> <li> <p>For plain PPO, the learner is <code class="language-plaintext highlighter-rouge">PPOLearner</code>. For curiosity, the learner is <strong><code class="language-plaintext highlighter-rouge">ICMLearner</code></strong> (<code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/learner.py</code>), which <em>wraps</em> a <code class="language-plaintext highlighter-rouge">PPOLearner</code> and a <code class="language-plaintext highlighter-rouge">CuriosityModule</code>. Its one job is to insert the curiosity bonus before handing off to PPO’s math:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">learn</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">rollout</span><span class="p">,</span> <span class="n">last_values</span><span class="p">):</span>
    <span class="n">transitions</span> <span class="o">=</span> <span class="n">rollout</span><span class="p">.</span><span class="nf">transitions</span><span class="p">()</span>                      <span class="c1"># raw (s, a, s') tuples
</span>    <span class="n">intrinsic</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_curiosity</span><span class="p">.</span><span class="nf">intrinsic_reward</span><span class="p">(</span><span class="n">transitions</span><span class="p">)</span>  <span class="c1"># the bonus, per step
</span>    <span class="n">rollout</span><span class="p">.</span><span class="nf">compute_advantages</span><span class="p">(...,</span> <span class="n">intrinsic</span><span class="o">=</span><span class="n">intrinsic</span><span class="p">)</span>     <span class="c1"># fold bonus into the reward, then GAE
</span>    <span class="n">ppo_metrics</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_ppo</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="n">rollout</span><span class="p">)</span>                  <span class="c1"># PPO's clipped update — reused as-is
</span>    <span class="n">icm_metrics</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_curiosity</span><span class="p">.</span><span class="nf">learn</span><span class="p">(</span><span class="n">transitions</span><span class="p">)</span>         <span class="c1"># train the ICM for next time
</span>    <span class="k">return</span> <span class="n">merged_metrics</span>
</code></pre></div> </div> </li> </ul> <p>Because the bonus is added to the reward <em>before</em> GAE (the advantage calculation), PPO treats it exactly like any other reward — no special cases. Adding a <em>different</em> exploration method later (say RND) is “write a new learner,” never “edit the training loop.” That’s the payoff of keeping the strategy generic.</p> <blockquote> <p><strong>One subtlety about “next state.”</strong> The 8 environments auto-reset when Mario dies, so the frame returned after a death is actually the <em>first frame of a new life</em>, not a real successor. Feeding that bogus (s, a, s’) to the ICM would be garbage, so those transitions are <strong>masked out</strong> of both the reward and the ICM’s training. (Search <code class="language-plaintext highlighter-rouge">dones</code> / <code class="language-plaintext highlighter-rouge">valid</code> in <code class="language-plaintext highlighter-rouge">module.py</code>.)</p> </blockquote> <hr/> <h2 id="part-4--we-ran-it-and-the-curiosity-was-zero">Part 4 — We ran it, and the curiosity was… zero</h2> <p>We launched a full training run (<code class="language-plaintext highlighter-rouge">logs/icm_training.log</code>, config <code class="language-plaintext highlighter-rouge">configs/icm.yaml</code>). Every 10,000 steps the trainer logs the metrics. Here is what the curiosity numbers did:</p> <table> <thead> <tr> <th>step</th> <th><code class="language-plaintext highlighter-rouge">forward_loss</code></th> <th><code class="language-plaintext highlighter-rouge">intrinsic_reward</code></th> <th><code class="language-plaintext highlighter-rouge">inverse_loss</code></th> </tr> </thead> <tbody> <tr> <td>10k</td> <td>0.0002</td> <td>0.0001</td> <td>1.81</td> </tr> <tr> <td>300k</td> <td>0.0001</td> <td>0.00003</td> <td>1.25</td> </tr> <tr> <td>~930k</td> <td><strong>0.0000</strong></td> <td><strong>0.0000</strong></td> <td>0.68–1.33</td> </tr> </tbody> </table> <p>The <code class="language-plaintext highlighter-rouge">intrinsic_reward</code> printed as <code class="language-plaintext highlighter-rouge">0.0000</code> — the curiosity bonus had shrunk to essentially nothing. The agent was, in effect, running plain PPO with a useless dangling term. Curiosity was dead.</p> <p>Two clues in that table:</p> <ul> <li><code class="language-plaintext highlighter-rouge">inverse_loss</code> was fine (~0.7–1.3, well below the ~1.95 you’d get from random guessing over 7 actions). So <code class="language-plaintext highlighter-rouge">φ</code> was <em>not</em> completely broken — it still encoded enough to name actions.</li> <li>But <code class="language-plaintext highlighter-rouge">forward_loss</code> fell all the way to ~0. The forward model had become a <em>perfect</em> predictor. If you can predict the next state perfectly, your surprise — and thus your curiosity reward — is zero, forever.</li> </ul> <p>So the question became: <strong>why did the forward model’s error collapse to zero?</strong></p> <hr/> <h2 id="part-5--three-reasons-the-signal-died">Part 5 — Three reasons the signal died</h2> <p><strong>Reason 1 (the big one): the forward model was allowed to cheat by shrinking the features.</strong></p> <p>Here’s the trap. The forward error is the distance between two feature vectors, <code class="language-plaintext highlighter-rouge">φ̂(s')</code> and <code class="language-plaintext highlighter-rouge">φ(s')</code>. Our first implementation trained the <em>encoder</em> <code class="language-plaintext highlighter-rouge">φ</code> from <strong>both</strong> the inverse loss and the forward loss. But think about how you can make the forward error small. One way is to get better at predicting. A <em>lazier</em> way is to make the features themselves tiny — if <code class="language-plaintext highlighter-rouge">φ</code> outputs numbers close to zero for every state, then <code class="language-plaintext highlighter-rouge">φ̂(s')</code> and <code class="language-plaintext highlighter-rouge">φ(s')</code> are both ≈0, their distance is ≈0, and the forward loss is ≈0 <em>without predicting anything at all.</em> The forward loss, given a gradient path into the encoder, quietly pushed <code class="language-plaintext highlighter-rouge">φ</code> to <strong>collapse</strong> toward a small, low-variance blob. The distances vanished, and so did the curiosity. This is a classic ICM failure mode.</p> <p>The paper’s own logic already told us the fix: the inverse model is supposed to be <strong>the only thing that shapes <code class="language-plaintext highlighter-rouge">φ</code>.</strong> The forward model should be a <em>customer</em> of <code class="language-plaintext highlighter-rouge">φ</code>, not an <em>author</em> of it. So we <strong>detach</strong> the features before they enter the forward model — “detach” means “use these numbers but don’t send training gradients back through them.” Now the forward model can only improve by predicting better, never by shrinking <code class="language-plaintext highlighter-rouge">φ</code>, and <code class="language-plaintext highlighter-rouge">φ</code>’s scale is anchored by the inverse loss. In <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/module.py</code>:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pred_phi_next</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_net</span><span class="p">.</span><span class="nf">predict_next</span><span class="p">(</span><span class="n">phi_s</span><span class="o">=</span><span class="n">phi_s</span><span class="p">.</span><span class="nf">detach</span><span class="p">(),</span> <span class="n">actions</span><span class="o">=</span><span class="n">actions</span><span class="p">)</span>   <span class="c1"># .detach() = don't train φ here
</span><span class="k">return</span> <span class="mf">0.5</span> <span class="o">*</span> <span class="p">(</span><span class="n">pred_phi_next</span> <span class="o">-</span> <span class="n">phi_next</span><span class="p">.</span><span class="nf">detach</span><span class="p">()).</span><span class="nf">pow</span><span class="p">(</span><span class="mi">2</span><span class="p">).</span><span class="nf">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div> <p><strong>Reason 2: we divided the signal by 512.</strong> Our first version averaged the squared error over the 512 feature dimensions (<code class="language-plaintext highlighter-rouge">.mean()</code>). The paper <em>sums</em> them (<code class="language-plaintext highlighter-rouge">.sum()</code>). Averaging made the raw number ~512× smaller than it should be — starting us off at a tiny 1e-4 before anything else went wrong. We switched to <code class="language-plaintext highlighter-rouge">.sum(dim=1)</code>.</p> <p><strong>Reason 3: raw prediction error is the wrong unit, and it’s not comparable to the game reward.</strong> Even a “healthy” forward error is some arbitrary number in feature-distance units. Meanwhile the extrinsic reward, after scaling, is around ~0.8 per step. A curiosity bonus of 0.0001 next to an extrinsic reward of 0.8 is a rounding error — the policy can’t even feel it. And as the ICM learns, the raw error keeps drifting, so no fixed <code class="language-plaintext highlighter-rouge">η</code> would keep it balanced.</p> <p>The fix is <strong>normalization</strong>, and it comes straight from this repo’s companion paper (Burda et al. 2018, <em>Large-Scale Study of Curiosity-Driven Learning</em>, <code class="language-plaintext highlighter-rouge">papers/large_scale_study_curiosity_burda2018/</code>): <strong>divide the intrinsic reward by a running estimate of its standard deviation.</strong> This throws away the meaningless absolute scale and keeps only the <em>relative</em> structure — “which states are more surprising than the others right now” — rescaled to roughly unit size. Then <code class="language-plaintext highlighter-rouge">η</code> multiplies something scale-stable, so it actually means what you think it means. We keep that running standard deviation with a small streaming statistics helper, <code class="language-plaintext highlighter-rouge">RunningMeanStd</code>, updated every rollout:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">error</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_forward_error</span><span class="p">(...).</span><span class="nf">cpu</span><span class="p">().</span><span class="nf">numpy</span><span class="p">()</span>          <span class="c1"># raw surprise, per step
</span><span class="n">self</span><span class="p">.</span><span class="n">_reward_rms</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="n">error</span><span class="p">[</span><span class="n">valid</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">])</span>               <span class="c1"># update running std (skip auto-reset steps)
</span><span class="n">reward</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">config</span><span class="p">.</span><span class="n">intrinsic_scale</span> <span class="o">*</span> <span class="n">error</span> <span class="o">/</span> <span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">_reward_rms</span><span class="p">.</span><span class="n">std</span> <span class="o">+</span> <span class="mf">1e-8</span><span class="p">)</span>   <span class="c1"># normalized × η
</span></code></pre></div></div> <blockquote> <p>A wrinkle we found while testing: the <em>first</em> rollout has almost no history, so the running std is tiny and the first normalized reward spikes (and briefly inflates PPO’s value loss). It self-corrects within a few rollouts as the statistics warm up. If it ever mattered, you’d warm up the std or clip the reward — for us it settles on its own.</p> </blockquote> <hr/> <h2 id="part-6--after-the-fix">Part 6 — After the fix</h2> <p>Same short run, with all three fixes in place:</p> <table> <thead> <tr> <th>step</th> <th><code class="language-plaintext highlighter-rouge">forward_loss</code></th> <th><code class="language-plaintext highlighter-rouge">intrinsic_reward</code></th> <th>note</th> </tr> </thead> <tbody> <tr> <td>10k</td> <td>0.45</td> <td>0.44</td> <td>cold-start spike (std still warming up)</td> </tr> <tr> <td>20k</td> <td>0.03</td> <td>0.009</td> <td>settled</td> </tr> <tr> <td>30k</td> <td>0.05</td> <td>0.018</td> <td>rises again as Mario reaches new areas</td> </tr> </tbody> </table> <p>The curiosity reward is now <strong>alive</strong> — three-plus orders of magnitude larger than before — and, crucially, it <em>moves</em>: when the agent pushes into unfamiliar territory the forward error and the bonus rise together, which is exactly the behavior we want. <code class="language-plaintext highlighter-rouge">inverse_loss</code> stays healthy, meaning <code class="language-plaintext highlighter-rouge">φ</code> is still learning controllable features rather than collapsing.</p> <p>With the extrinsic reward left on (our default), curiosity is a <em>secondary</em> bonus — a nudge toward unexplored areas layered on the main “go right” signal. To reproduce the paper’s headline <em>no-reward</em> experiment, set <code class="language-plaintext highlighter-rouge">PPOConfig.reward_scale = 0</code>: the game gives zero points and curiosity becomes the <em>only</em> reward. That’s the real test of whether the intrinsic signal alone can drive Mario down the level.</p> <hr/> <h2 id="part-7--what-to-watch-and-the-knobs">Part 7 — What to watch, and the knobs</h2> <p>Watch these columns in <code class="language-plaintext highlighter-rouge">logs/icm_training.log</code> (all live in <code class="language-plaintext highlighter-rouge">ICMMetrics</code> / <code class="language-plaintext highlighter-rouge">ICMLearnerMetrics</code>):</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">intrinsic_reward</code></strong> — the average curiosity bonus handed to the policy. Should be nonzero and wobble as exploration ebbs and flows. If it’s <code class="language-plaintext highlighter-rouge">0.0000</code>, curiosity is dead (that was the bug).</li> <li><strong><code class="language-plaintext highlighter-rouge">inverse_loss</code></strong> — how well the inverse model names the action. Should sit clearly below ~1.95 (random). If it climbs back to ~1.95, <code class="language-plaintext highlighter-rouge">φ</code> has collapsed.</li> <li><strong><code class="language-plaintext highlighter-rouge">forward_loss</code></strong> — the ICM’s own prediction error while training. Healthy is “small but not exactly zero.”</li> <li><strong><code class="language-plaintext highlighter-rouge">ep_max_x</code> / <code class="language-plaintext highlighter-rouge">ep_return</code></strong> — how far Mario gets and his score. The bottom line: is curiosity helping him explore further than plain PPO? (Compare against <code class="language-plaintext highlighter-rouge">logs/ppo_training.log</code>.)</li> </ul> <p>The tuning knobs, all in <code class="language-plaintext highlighter-rouge">rl_ablations/models/icm/config.py</code> (<code class="language-plaintext highlighter-rouge">ICMConfig</code>) unless noted:</p> <ul> <li><code class="language-plaintext highlighter-rouge">intrinsic_scale</code> (<code class="language-plaintext highlighter-rouge">η</code>) — how loud curiosity is. Now that the reward is normalized, this is meaningful: ~0.1 for a gentle bonus, higher to make exploration dominate.</li> <li><code class="language-plaintext highlighter-rouge">beta</code> — inverse-vs-forward loss mix. Higher = train the forward model harder; lower = spend more on shaping <code class="language-plaintext highlighter-rouge">φ</code>.</li> <li><code class="language-plaintext highlighter-rouge">feature_dim</code>, <code class="language-plaintext highlighter-rouge">lr</code>, <code class="language-plaintext highlighter-rouge">epochs</code>, <code class="language-plaintext highlighter-rouge">num_minibatches</code> — the ICM network’s size and how hard we train it each rollout.</li> <li><code class="language-plaintext highlighter-rouge">reward_scale</code> (in <code class="language-plaintext highlighter-rouge">PPOConfig</code>) — set to <code class="language-plaintext highlighter-rouge">0</code> for pure, no-extrinsic curiosity.</li> </ul> <h2 id="the-one-paragraph-recap">The one-paragraph recap</h2> <p>Curiosity gives the agent a bonus for <em>surprise</em>, measured as how badly it predicts the next state — but done in a learned feature space <code class="language-plaintext highlighter-rouge">φ</code> that (thanks to an <strong>inverse</strong> model predicting the agent’s own actions) keeps only what the agent can control, dodging the noisy-TV trap. A <strong>forward</strong> model’s prediction error in that space is the bonus. We layered this onto PPO purely as a new <em>learner</em> (<code class="language-plaintext highlighter-rouge">ICMLearner</code>) that folds the bonus into the reward before PPO’s normal math, leaving the training loop untouched. Our first run looked fine but the curiosity silently died, because the forward loss was collapsing the features, the error was divided down by 512, and its raw scale was dwarfed by the game reward. Detaching the features (so only the inverse model shapes <code class="language-plaintext highlighter-rouge">φ</code>), summing instead of averaging, and normalizing the bonus by its running standard deviation brought the signal back to life.</p> <h2 id="results-on-mario-1-1">Results on Mario 1-1</h2> <p>An honest result: on 1-1’s fairly dense reward, curiosity is <strong>not</strong> a free win. Measuring how far right Mario gets, plain <strong>PPO (extrinsic only)</strong> pulls ahead, while PPO+ICM and PPO+RND sit lower. Curiosity’s payoff is an <em>exploration mechanism for sparse reward</em>, not a leaderboard boost on an easy level:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/curves/ppo_vs_curiosity-480.webp 480w,/assets/img/blog/curves/ppo_vs_curiosity-800.webp 800w,/assets/img/blog/curves/ppo_vs_curiosity-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/curves/ppo_vs_curiosity.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p>So we study curiosity where it’s actually testable — in <strong>pure-curiosity mode</strong>, with the extrinsic reward switched off, asking only: does the intrinsic signal stay alive and keep driving exploration? ICM has a failure mode there — its forward-prediction signal can collapse as the feature space becomes trivially predictable. That raises the real question — <em>is prediction error even the right novelty signal?</em> — which pushes us to RND → <a href="/blog/2026/rnd/">RND</a>.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="curiosity"/><category term="icm"/><category term="exploration"/><category term="reinforcement-learning"/><summary type="html"><![CDATA[Bolting an Intrinsic Curiosity Module onto PPO as just a new learner — and the real bug where the curiosity signal silently collapsed to zero.]]></summary></entry><entry><title type="html">PPO: on-policy policy gradients that climb past DQN’s ceiling</title><link href="https://m-rr-j.github.io/blog/2026/ppo/" rel="alternate" type="text/html" title="PPO: on-policy policy gradients that climb past DQN’s ceiling"/><published>2026-07-01T09:00:00+00:00</published><updated>2026-07-01T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/ppo</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/ppo/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/M-RR-J/rl-ablations/tree/main/rl_ablations/models/ppo"><code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo</code></a>, built on the <a href="https://github.com/M-RR-J/rl-factory">rl-factory</a> engine.</p> </blockquote> <p>This tutorial explains how our PPO agent actually learns — the <code class="language-plaintext highlighter-rouge">RolloutBuffer</code>, the advantage calculation, and the loss function — starting from the very first policy-gradient idea (REINFORCE, 1992) and building up historically through A2C to PPO. It assumes you know basic calculus and a little probability, and nothing else. Every term is defined the first time it appears. Wherever we describe code, it lives in <code class="language-plaintext highlighter-rouge">rl_factory/training/rollout.py</code> (the buffer</p> <ul> <li>advantage math) and <code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo/learner.py</code> (the loss).</li> </ul> <p>If you only remember one sentence: <strong>we nudge the network to make actions that turned out better-than-expected more likely, and we do it carefully enough that we can squeeze several gradient steps out of each batch of experience without the training blowing up.</strong></p> <hr/> <h2 id="part-0--the-problem-were-solving">Part 0 — The problem we’re solving</h2> <p>An <strong>agent</strong> interacts with an <strong>environment</strong> (for us, Super Mario Bros). At each timestep it sees a <strong>state</strong> <code class="language-plaintext highlighter-rouge">s</code> (four stacked game frames), picks an <strong>action</strong> <code class="language-plaintext highlighter-rouge">a</code> (press a button), and receives a <strong>reward</strong> <code class="language-plaintext highlighter-rouge">r</code> (roughly, how far right Mario moved). This repeats until Mario dies or finishes the level — one <strong>episode</strong>.</p> <p>The agent’s behaviour is a <strong>policy</strong>, written <code class="language-plaintext highlighter-rouge">π(a | s)</code> (“pi of a given s”): a function that, given a state, outputs a <em>probability for each possible action</em>. Our policy is a neural network. “Learning” means adjusting the network’s weights <code class="language-plaintext highlighter-rouge">θ</code> (theta) so that the policy collects more total reward.</p> <p>We measure “total reward” with the <strong>return</strong>. The return from timestep <code class="language-plaintext highlighter-rouge">t</code>, written <code class="language-plaintext highlighter-rouge">G_t</code>, is the sum of all rewards from <code class="language-plaintext highlighter-rouge">t</code> onward, with later rewards discounted by a factor <code class="language-plaintext highlighter-rouge">γ</code> (gamma, between 0 and 1, e.g. 0.99):</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>G_t = r_t + γ·r_{t+1} + γ²·r_{t+2} + γ³·r_{t+3} + ...
</code></pre></div></div> <p>Discounting says “a reward now is worth a bit more than the same reward later.” It also keeps the sum finite for long episodes. The goal of the whole enterprise:</p> <blockquote> <p>Adjust <code class="language-plaintext highlighter-rouge">θ</code> to maximize the <strong>expected return</strong> — the average <code class="language-plaintext highlighter-rouge">G</code> we’d get if we let the policy play many times.</p> </blockquote> <p>Everything below is a story about how to compute a good <em>gradient</em> — a direction to push <code class="language-plaintext highlighter-rouge">θ</code> — for that goal.</p> <hr/> <h2 id="part-1--reinforce-1992-the-original-idea">Part 1 — REINFORCE (1992): the original idea</h2> <h3 id="11-the-intuition">1.1 The intuition</h3> <p>Suppose Mario plays a whole episode and gets a great return. Whatever actions he took, they probably weren’t all brilliant — but <em>on average</em> they were good, so let’s make <strong>all of them a little more likely</strong> next time. If the episode went badly, make those actions <strong>less likely</strong>. Scale the nudge by how good the outcome was.</p> <p>That’s REINFORCE in one sentence. Now the math that makes it precise.</p> <h3 id="12-the-policy-gradient-formula">1.2 The policy-gradient formula</h3> <p>We want to increase expected return <code class="language-plaintext highlighter-rouge">J(θ)</code>. The famous <strong>policy-gradient theorem</strong> says its gradient can be written as an average over experience the policy itself generated:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>∇J(θ)  =  E[  Σ_t  ∇ log π(a_t | s_t) · G_t  ]
</code></pre></div></div> <p>Let’s decode every piece:</p> <ul> <li><code class="language-plaintext highlighter-rouge">E[ … ]</code> — “the average over many episodes played by the current policy.”</li> <li><code class="language-plaintext highlighter-rouge">Σ_t</code> — sum over every timestep in the episode.</li> <li><code class="language-plaintext highlighter-rouge">π(a_t | s_t)</code> — the probability the policy assigned to the action it actually took.</li> <li><code class="language-plaintext highlighter-rouge">log π(a_t | s_t)</code> — the natural log of that probability. (Logs show up because of a calculus identity, <code class="language-plaintext highlighter-rouge">∇π = π · ∇log π</code>; they also turn the tiny products of probabilities into stable sums. You don’t need to derive this — just know <code class="language-plaintext highlighter-rouge">∇log π(a_t|s_t)</code> is “the direction in weight space that makes action <code class="language-plaintext highlighter-rouge">a_t</code> more likely in state <code class="language-plaintext highlighter-rouge">s_t</code>.”)</li> <li><code class="language-plaintext highlighter-rouge">G_t</code> — the return that followed. This is the crucial weight: a <em>number</em>, not something we differentiate through.</li> </ul> <p>Read the formula in plain English: <strong>for each action taken, step in the direction that raises its probability, scaled by the return that followed.</strong> Good returns → push those actions up. Bad returns → the scaling is small (or, once we add a baseline in Part 2, negative) → push down.</p> <h3 id="13-from-gradient-to-loss">1.3 From gradient to “loss”</h3> <p>Deep-learning libraries <em>minimize</em> a loss by gradient descent. We want to <em>maximize</em> <code class="language-plaintext highlighter-rouge">J</code>, so we minimize its negative. The quantity whose gradient equals the formula above is:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Loss = − Σ_t  log π(a_t | s_t) · G_t
</code></pre></div></div> <p>We compute this number, call <code class="language-plaintext highlighter-rouge">.backward()</code>, and the optimizer does the rest. Note <code class="language-plaintext highlighter-rouge">G_t</code> is treated as a fixed constant (we <em>detach</em> it from the graph): we are not trying to change the rewards, only the probabilities.</p> <h3 id="14-why-reinforce-isnt-enough">1.4 Why REINFORCE isn’t enough</h3> <p>Two problems, and the rest of history is about fixing them:</p> <ol> <li> <p><strong>It’s extremely noisy (high variance).</strong> <code class="language-plaintext highlighter-rouge">G_t</code> depends on <em>everything</em> that happened after <code class="language-plaintext highlighter-rouge">t</code> — every later action, every bit of randomness in the game. The same good action can be attached to a huge return in one episode and a tiny one in the next, purely by luck. Training on such a jittery signal is slow and unstable.</p> </li> <li> <p><strong>It needs complete episodes and fresh data.</strong> You can’t compute <code class="language-plaintext highlighter-rouge">G_t</code> until the episode ends, and because the average is “over episodes the <em>current</em> policy generated,” the moment you update <code class="language-plaintext highlighter-rouge">θ</code> the data you collected is stale and must be thrown away. This is what <strong>on-policy</strong> means: <em>you may only learn from data your current policy produced.</em> (Contrast DQN, which is <strong>off-policy</strong> and reuses a giant replay buffer.)</p> </li> </ol> <hr/> <h2 id="part-2--baselines-and-the-birth-of-the-critic">Part 2 — Baselines and the birth of the critic</h2> <h3 id="21-subtracting-a-baseline">2.1 Subtracting a baseline</h3> <p>Here’s a small idea with a big payoff. Take the policy gradient and subtract a <strong>baseline</strong> <code class="language-plaintext highlighter-rouge">b(s_t)</code> — any number that depends on the state but not on the action:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>∇J(θ)  =  E[  Σ_t  ∇ log π(a_t | s_t) · ( G_t − b(s_t) )  ]
</code></pre></div></div> <p>Mathematically this changes <em>nothing</em> about the average (the baseline term averages out to zero, because probabilities sum to 1). But it can dramatically <strong>reduce the variance</strong> — the jitter — if we choose <code class="language-plaintext highlighter-rouge">b</code> well. Intuitively: instead of asking “was the return big?”, we ask “was the return <strong>bigger than what we normally get from this state?</strong>” That comparison is far more stable than the raw return.</p> <h3 id="22-the-best-baseline-is-the-value-function">2.2 The best baseline is the value function</h3> <p>The natural choice of baseline is the <strong>value function</strong> <code class="language-plaintext highlighter-rouge">V(s)</code>: the <em>expected</em> return if you start in state <code class="language-plaintext highlighter-rouge">s</code> and follow the current policy. “From this spot, how well do we usually do?”</p> <p>We don’t know <code class="language-plaintext highlighter-rouge">V</code>, so we <strong>learn it too</strong>, with a second neural network (or, in our code, a second <em>head</em> on the same network — see <code class="language-plaintext highlighter-rouge">ActorCriticNet</code> in <code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo/model.py</code>). This second learner is called the <strong>critic</strong>; the policy is the <strong>actor</strong>. The critic doesn’t choose actions; it just predicts how good states are, to serve as the baseline.</p> <p>Now <code class="language-plaintext highlighter-rouge">G_t − V(s_t)</code> has a name and a meaning: the <strong>advantage</strong>.</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Advantage A(s_t, a_t)  ≈  G_t − V(s_t)
                       =  (what actually happened)  −  (what we expected)
</code></pre></div></div> <ul> <li><code class="language-plaintext highlighter-rouge">A &gt; 0</code>: this action did <strong>better than average</strong> from this state → make it more likely.</li> <li><code class="language-plaintext highlighter-rouge">A &lt; 0</code>: it did <strong>worse than average</strong> → make it less likely.</li> </ul> <p>The advantage is the honest “was this action a good idea?” signal. From here on, the policy gradient is:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Loss_policy = − Σ_t  log π(a_t | s_t) · A_t
</code></pre></div></div> <p>Same shape as REINFORCE, but with the advantage in place of the raw return. This is the heart of every method that follows.</p> <hr/> <h2 id="part-3--a2c-advantage-actor-critic-the-practical-loop">Part 3 — A2C (Advantage Actor-Critic): the practical loop</h2> <p>A2C is the algorithm that turns the above into something you can actually run efficiently. It contributes three practical ideas, and <strong>all three are visible in our <code class="language-plaintext highlighter-rouge">RolloutBuffer</code> and <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code>.</strong></p> <h3 id="31-bootstrapping-dont-wait-for-the-episode-to-end">3.1 Bootstrapping: don’t wait for the episode to end</h3> <p>Waiting for a full episode to compute <code class="language-plaintext highlighter-rouge">G_t</code> is slow and noisy. Instead, use the critic to <strong>estimate the tail</strong>. The simplest advantage estimate uses just one real reward plus the critic’s guess for the rest:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>δ_t  =  r_t  +  γ·V(s_{t+1})  −  V(s_t)
</code></pre></div></div> <p>This <code class="language-plaintext highlighter-rouge">δ_t</code> (delta) is the <strong>TD error</strong> (“temporal-difference error”). Read it as: <em>“the reward I actually got, plus what the critic thinks the next state is worth, minus what the critic thought this state was worth.”</em> If that’s positive, things went better than the critic expected. Using a prediction of the future to improve an estimate of the present is called <strong>bootstrapping</strong>. It lets us learn from short slices of experience instead of whole episodes.</p> <h3 id="32-fixed-length-rollouts-across-many-parallel-environments">3.2 Fixed-length rollouts across many parallel environments</h3> <p>Rather than one episode at a time, A2C runs <strong>N environments in parallel</strong> and collects a fixed number of steps <code class="language-plaintext highlighter-rouge">T</code> from each — a <strong>rollout</strong> of shape <code class="language-plaintext highlighter-rouge">T × N</code>. This is exactly what our <code class="language-plaintext highlighter-rouge">RolloutBuffer</code> stores, and what <code class="language-plaintext highlighter-rouge">OnPolicyStrategy.step()</code> fills by stepping all N envs in lockstep (<code class="language-plaintext highlighter-rouge">rl_factory/training/on_policy.py</code>). More envs = more, less-correlated data per update = steadier gradients. We’ll come back to how episodes of <em>different lengths</em> fit into a fixed <code class="language-plaintext highlighter-rouge">T × N</code> grid in Part 5 — it’s the question that trips everyone up.</p> <h3 id="33-an-entropy-bonus-to-keep-exploring">3.3 An entropy bonus to keep exploring</h3> <p>A policy can prematurely become over-confident — always pressing the same button — and stop discovering better strategies. To fight this, A2C adds an <strong>entropy bonus</strong>. <strong>Entropy</strong> is a measure of how spread-out a probability distribution is: high when the policy is unsure (spreads probability across actions), low when it’s certain (all probability on one action). We add a small reward for keeping entropy high, which gently encourages the policy to keep its options open. In the loss it appears as a term we <em>subtract</em> (because we minimize loss but want to <em>increase</em> entropy).</p> <h3 id="34-training-the-critic">3.4 Training the critic</h3> <p>The critic is trained by plain regression: make <code class="language-plaintext highlighter-rouge">V(s_t)</code> predict the actual return <code class="language-plaintext highlighter-rouge">G_t</code> (in practice, the bootstrapped target). The loss is the <strong>mean squared error</strong> between prediction and target — literally <code class="language-plaintext highlighter-rouge">average of (V(s_t) − target_t)²</code>. As the critic gets better, the advantages get more accurate, which makes the actor’s updates better — a virtuous cycle.</p> <p><strong>At this point we essentially have A2C.</strong> PPO changes only <em>two</em> things: a smarter advantage estimate (GAE, Part 4) and a safety mechanism that lets us reuse each rollout several times (clipping, Part 6).</p> <hr/> <h2 id="part-4--gae-dialing-the-biasvariance-knob">Part 4 — GAE: dialing the bias/variance knob</h2> <h3 id="41-the-trade-off">4.1 The trade-off</h3> <p>We now have two ways to estimate the advantage, and they sit at opposite extremes:</p> <ul> <li><strong>One-step TD</strong> (<code class="language-plaintext highlighter-rouge">δ_t</code> from §3.1): uses one real reward and trusts the critic for everything after. <strong>Low variance</strong> (only one step of randomness), but <strong>biased</strong> — if the critic is wrong, the estimate is wrong.</li> <li><strong>Full Monte Carlo</strong> (<code class="language-plaintext highlighter-rouge">G_t − V(s_t)</code>, the REINFORCE-style version): uses all the real rewards to the episode’s end. <strong>Unbiased</strong>, but <strong>high variance</strong> — it drags in all that future noise.</li> </ul> <p>“Bias” = systematically off in one direction (a bad-but-consistent ruler). “Variance” = jittery, different each time. You’d like a knob to trade one for the other. <strong>Generalized Advantage Estimation (GAE)</strong> is that knob.</p> <h3 id="42-the-gae-formula">4.2 The GAE formula</h3> <p>GAE blends all the multi-step estimates with an exponentially decaying weight controlled by a new parameter <code class="language-plaintext highlighter-rouge">λ</code> (lambda, between 0 and 1):</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A_t  =  δ_t  +  (γλ)·δ_{t+1}  +  (γλ)²·δ_{t+2}  +  (γλ)³·δ_{t+3}  +  ...
</code></pre></div></div> <ul> <li><code class="language-plaintext highlighter-rouge">λ = 0</code>: only <code class="language-plaintext highlighter-rouge">δ_t</code> survives → <strong>one-step TD</strong> (low variance, more bias).</li> <li><code class="language-plaintext highlighter-rouge">λ = 1</code>: everything survives with only <code class="language-plaintext highlighter-rouge">γ</code> decay → <strong>Monte Carlo</strong> (unbiased, high variance).</li> <li><code class="language-plaintext highlighter-rouge">λ ≈ 0.95</code> (our default): mostly trusts a few real steps, leans on the critic for the far future. In practice this is the sweet spot.</li> </ul> <h3 id="43-how-the-code-computes-it--the-backward-loop">4.3 How the code computes it — the backward loop</h3> <p>You would never sum that infinite series directly. There’s a beautiful trick: the same series can be computed with a single <strong>backward pass</strong>, because each step’s advantage is just <em>its own TD error plus a discounted copy of the next step’s advantage.</em> Here is the actual code from <code class="language-plaintext highlighter-rouge">compute_gae</code> in <code class="language-plaintext highlighter-rouge">rl_factory/training/rollout.py</code>, annotated:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="nf">reversed</span><span class="p">(</span><span class="nf">range</span><span class="p">(</span><span class="n">horizon</span><span class="p">)):</span>          <span class="c1"># walk backward through the T steps
</span>    <span class="n">nonterminal</span> <span class="o">=</span> <span class="mf">1.0</span> <span class="o">-</span> <span class="n">dones</span><span class="p">[</span><span class="n">t</span><span class="p">]</span>            <span class="c1"># 0 if the episode ended at step t, else 1
</span>    <span class="n">delta</span> <span class="o">=</span> <span class="n">rewards</span><span class="p">[</span><span class="n">t</span><span class="p">]</span> <span class="o">+</span> <span class="n">gamma</span> <span class="o">*</span> <span class="n">next_values</span> <span class="o">*</span> <span class="n">nonterminal</span> <span class="o">-</span> <span class="n">values</span><span class="p">[</span><span class="n">t</span><span class="p">]</span>   <span class="c1"># δ_t
</span>    <span class="n">last_advantage</span> <span class="o">=</span> <span class="n">delta</span> <span class="o">+</span> <span class="n">gamma</span> <span class="o">*</span> <span class="n">gae_lambda</span> <span class="o">*</span> <span class="n">nonterminal</span> <span class="o">*</span> <span class="n">last_advantage</span>  <span class="c1"># A_t
</span>    <span class="n">advantages</span><span class="p">[</span><span class="n">t</span><span class="p">]</span> <span class="o">=</span> <span class="n">last_advantage</span>
    <span class="n">next_values</span> <span class="o">=</span> <span class="n">values</span><span class="p">[</span><span class="n">t</span><span class="p">]</span>                 <span class="c1"># for the next (earlier) iteration
</span></code></pre></div></div> <p>Walking backward, <code class="language-plaintext highlighter-rouge">last_advantage</code> carries the accumulated <code class="language-plaintext highlighter-rouge">(γλ)·δ + (γλ)²·δ + …</code> from all <em>later</em> steps, so each line adds one fresh <code class="language-plaintext highlighter-rouge">δ_t</code> and decays the rest — the whole infinite sum, in one cheap loop.</p> <p>The <code class="language-plaintext highlighter-rouge">nonterminal = 1 − dones[t]</code> factor is the important safety catch. On the step where an episode <strong>ended</strong>, it becomes <code class="language-plaintext highlighter-rouge">0</code>, which does two things at once: it drops the <code class="language-plaintext highlighter-rouge">γ·V(s_{t+1})</code> term (you must not bootstrap the value of a state in a <em>different</em> episode) and it zeroes the carried-over <code class="language-plaintext highlighter-rouge">last_advantage</code> (rewards from after a reset must not leak backward into the previous episode). This is what lets many independent episodes share one array safely.</p> <h3 id="44-the-value-target-falls-out-for-free">4.4 The value target falls out for free</h3> <p>At the end, <code class="language-plaintext highlighter-rouge">compute_gae</code> also returns the <strong>returns</strong>, computed as:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>returns = advantages + values
</code></pre></div></div> <p>Rearranged, that’s <code class="language-plaintext highlighter-rouge">return_t = A_t + V(s_t)</code> — an improved estimate of <code class="language-plaintext highlighter-rouge">G_t</code>. These are the regression targets the critic is trained against (§3.4). So one backward pass produces <em>both</em> things the loss needs: the advantages (for the actor) and the returns (for the critic).</p> <hr/> <h2 id="part-5--the-rolloutbuffer-concretely">Part 5 — The <code class="language-plaintext highlighter-rouge">RolloutBuffer</code>, concretely</h2> <p>Now we can read the buffer (<code class="language-plaintext highlighter-rouge">rl_factory/training/rollout.py</code>) end to end. It is the <strong>on-policy counterpart of a replay buffer</strong>: where DQN keeps a big pool of old transitions to sample randomly, the <code class="language-plaintext highlighter-rouge">RolloutBuffer</code> holds exactly <strong>one fresh batch</strong> of experience, learns from it, and throws it away — because on-policy methods may only learn from current-policy data (Part 1).</p> <p>It has a strict three-phase life:</p> <h3 id="phase-1--accumulate-add">Phase 1 — Accumulate (<code class="language-plaintext highlighter-rouge">add</code>)</h3> <p>During collection, <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code> steps all N envs and calls <code class="language-plaintext highlighter-rouge">add(...)</code> once per timestep, appending that step’s slice to per-field lists. Per step it records, for all N envs:</p> <table> <thead> <tr> <th>Stored each step</th> <th>Why it’s needed</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">states</code></td> <td>the input to re-evaluate the policy during learning</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">actions</code></td> <td>which action was taken (to score its probability)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">log_probs</code></td> <td><code class="language-plaintext highlighter-rouge">log π_old(a\|s)</code> <strong>at collection time</strong> — needed <em>only</em> by PPO (Part 6)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">values</code></td> <td><code class="language-plaintext highlighter-rouge">V(s)</code> at collection time — needed for GAE and as part of the value target</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">rewards</code></td> <td>the real reward, for the TD errors</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">dones</code></td> <td>episode-boundary flags, for the <code class="language-plaintext highlighter-rouge">nonterminal</code> mask</td> </tr> </tbody> </table> <h3 id="phase-2--finalize-compute_advantages">Phase 2 — Finalize (<code class="language-plaintext highlighter-rouge">compute_advantages</code>)</h3> <p>Once <code class="language-plaintext highlighter-rouge">T</code> steps are collected, this stacks the lists into <code class="language-plaintext highlighter-rouge">(T, N)</code> arrays, runs <code class="language-plaintext highlighter-rouge">compute_gae</code> (Part 4) to get advantages and returns, and <strong>flattens</strong> everything from <code class="language-plaintext highlighter-rouge">(T, N, …)</code> to <code class="language-plaintext highlighter-rouge">(T·N, …)</code>. After this the buffer is just a flat bag of <code class="language-plaintext highlighter-rouge">T·N</code> independent training examples, each a tuple <code class="language-plaintext highlighter-rouge">(state, action, old_log_prob, advantage, return)</code> — that’s the <code class="language-plaintext highlighter-rouge">RolloutBatch</code> NamedTuple.</p> <h3 id="phase-3--serve-minibatches">Phase 3 — Serve (<code class="language-plaintext highlighter-rouge">minibatches</code>)</h3> <p>Yields shuffled <strong>minibatches</strong> (small random subsets) as tensors on the GPU. The learner asks for these repeatedly — several passes over the same rollout — which is where PPO’s clipping becomes essential (Part 6).</p> <h3 id="51-the-question-everyone-asks-different-episode-lengths">5.1 The question everyone asks: different episode lengths</h3> <blockquote> <p>“Each environment’s episode has a different length. How does a fixed <code class="language-plaintext highlighter-rouge">T × N</code> buffer cope?”</p> </blockquote> <p>The answer is that <strong>the buffer never stores episodes</strong> — it stores a fixed-size <em>time window</em>. Every env is stepped exactly <code class="language-plaintext highlighter-rouge">T</code> times per rollout, in lockstep, so the buffer is always a full <code class="language-plaintext highlighter-rouge">T × N</code> rectangle, never ragged. Episodes simply don’t line up with the window or with each other, and that’s fine:</p> <ul> <li>When an env’s episode ends partway through the window, the environment worker <strong>auto-resets</strong> and immediately returns the first frame of the <em>next</em> episode (see <code class="language-plaintext highlighter-rouge">VectorEnv</code> in <code class="language-plaintext highlighter-rouge">rl_factory/vector_env.py</code>). That env just keeps producing steps — now belonging to a new episode. One env’s column can contain several episode boundaries; another’s may contain none.</li> <li>The <em>only</em> record that a boundary happened is the <code class="language-plaintext highlighter-rouge">done</code> flag at that step. And GAE’s <code class="language-plaintext highlighter-rouge">nonterminal = 1 − done</code> factor (§4.3) walls off the advantage math at each boundary, so reward never leaks from one episode into another, and each env’s column is handled independently.</li> </ul> <p>Picture <code class="language-plaintext highlighter-rouge">T = 5, N = 3</code> (<code class="language-plaintext highlighter-rouge">·</code> = ordinary step, <code class="language-plaintext highlighter-rouge">D</code> = episode ended and reset here):</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        env0   env1   env2
 t=0     ·      ·      ·
 t=1     ·      D      ·
 t=2     D      ·      ·
 t=3     ·      ·      D
 t=4     ·      ·      ·     ← at the window's edge, these episodes are still unfinished
</code></pre></div></div> <p>This whole grid is <strong>one rollout</strong>. It contains several partial episodes of different lengths, yet it’s a perfect rectangle. Two edges get special handling:</p> <ul> <li><strong>Episodes still running at <code class="language-plaintext highlighter-rouge">t = 4</code></strong> (the window’s end): we can’t compute their tail, so we ask the critic for one last estimate, <code class="language-plaintext highlighter-rouge">V(s_T)</code>, and seed the backward pass with it (the <code class="language-plaintext highlighter-rouge">last_values</code> argument to <code class="language-plaintext highlighter-rouge">compute_gae</code>, obtained via <code class="language-plaintext highlighter-rouge">agent.bootstrap_values</code>). That’s why we <em>can</em> learn from a slice of an unfinished episode.</li> <li><strong>Episodes that ended inside the window</strong>: the <code class="language-plaintext highlighter-rouge">done</code> mask zeroes the bootstrap there, as described.</li> </ul> <p>After Phase 2 flattens and Phase 3 shuffles, <strong>episode identity is gone entirely</strong> — every sample is an independent <code class="language-plaintext highlighter-rouge">(state, action, old_log_prob, advantage, return)</code>. That’s safe because everything temporal was already baked into <code class="language-plaintext highlighter-rouge">advantage</code> and <code class="language-plaintext highlighter-rouge">return</code> during GAE. The gradient step doesn’t need to know which env or episode a sample came from.</p> <hr/> <h2 id="part-6--ppo-reusing-each-rollout-without-blowing-up">Part 6 — PPO: reusing each rollout without blowing up</h2> <h3 id="61-the-motivation">6.1 The motivation</h3> <p>Plain A2C is wasteful: collect a rollout, take <strong>one</strong> gradient step, throw it away. Collection (stepping the game) is expensive; we’d love to take <strong>several</strong> gradient steps per rollout. But there’s a catch rooted in Part 1: the policy-gradient average is only valid for the policy that <em>collected</em> the data. After one update, the policy has moved, and taking another step on the same data is technically using the wrong distribution. Push too far and the update becomes nonsense — the policy can collapse. PPO (“Proximal Policy Optimization,” 2017) is the mechanism that makes reusing a rollout <strong>safe</strong>.</p> <h3 id="62-the-probability-ratio">6.2 The probability ratio</h3> <p>To reuse old data with an updated policy, we reweight each sample by how much more (or less) likely the <em>new</em> policy is to take the action than the <em>old</em> one did:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ratio  =  π_new(a | s)  /  π_old(a | s)  =  exp( new_log_prob − old_log_prob )
</code></pre></div></div> <p>(The <code class="language-plaintext highlighter-rouge">exp</code> of a difference of logs is just the ratio of the probabilities — cheaper and more stable to compute this way.) This is why the buffer stored <code class="language-plaintext highlighter-rouge">old_log_probs</code> back at collection time: it’s the “before” snapshot the ratio needs. A <code class="language-plaintext highlighter-rouge">ratio</code> of 1 means “the policy hasn’t changed its mind about this action”; above 1 means “now more likely”; below 1, “now less likely.”</p> <p>The reweighted objective for one action is <code class="language-plaintext highlighter-rouge">ratio · advantage</code>. If we optimized <em>just</em> this, we could still push the ratio arbitrarily far and break things — which is exactly the danger.</p> <h3 id="63-the-clip-ppos-one-real-idea">6.3 The clip: PPO’s one real idea</h3> <p>PPO caps how far the ratio is allowed to help. It compares the plain objective against a <strong>clipped</strong> version where the ratio is confined to <code class="language-plaintext highlighter-rouge">[1 − ε, 1 + ε]</code> (ε ≈ 0.2), and keeps the <strong>more pessimistic (smaller)</strong> of the two. Here’s the actual code, from <code class="language-plaintext highlighter-rouge">_clipped_policy_loss</code> in <code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo/learner.py</code>:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ratio</span>     <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">exp</span><span class="p">(</span><span class="n">new_log_probs</span> <span class="o">-</span> <span class="n">old_log_probs</span><span class="p">)</span>
<span class="n">unclipped</span> <span class="o">=</span> <span class="n">ratio</span> <span class="o">*</span> <span class="n">advantages</span>
<span class="n">clipped</span>   <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">clamp</span><span class="p">(</span><span class="n">ratio</span><span class="p">,</span> <span class="mf">1.0</span> <span class="o">-</span> <span class="n">clip_eps</span><span class="p">,</span> <span class="mf">1.0</span> <span class="o">+</span> <span class="n">clip_eps</span><span class="p">)</span> <span class="o">*</span> <span class="n">advantages</span>
<span class="k">return</span> <span class="o">-</span><span class="n">torch</span><span class="p">.</span><span class="nf">min</span><span class="p">(</span><span class="n">unclipped</span><span class="p">,</span> <span class="n">clipped</span><span class="p">).</span><span class="nf">mean</span><span class="p">()</span>
</code></pre></div></div> <p>Why <code class="language-plaintext highlighter-rouge">min</code> of the clipped and unclipped versions? It removes any incentive to move the policy <em>too far in the helpful direction</em>:</p> <ul> <li><strong>Good action (<code class="language-plaintext highlighter-rouge">advantage &gt; 0</code>):</strong> we want to raise its probability, so the ratio wants to grow. But once the ratio passes <code class="language-plaintext highlighter-rouge">1 + ε</code>, the <code class="language-plaintext highlighter-rouge">clipped</code> term stops increasing, and <code class="language-plaintext highlighter-rouge">min</code> picks it — so there’s <strong>no extra reward</strong> for pushing further. The update is content to nudge, not lunge.</li> <li><strong>Bad action (<code class="language-plaintext highlighter-rouge">advantage &lt; 0</code>):</strong> we want to lower its probability. The clip floors the ratio at <code class="language-plaintext highlighter-rouge">1 − ε</code>, and <code class="language-plaintext highlighter-rouge">min</code> (of two negative numbers) keeps the penalty from being softened by a runaway ratio — again capping how far one update moves.</li> </ul> <p>The <code class="language-plaintext highlighter-rouge">−</code> and <code class="language-plaintext highlighter-rouge">.mean()</code> turn it into a loss we minimize, averaged over the minibatch. The net effect: each rollout can be reused for several epochs of minibatch updates (our defaults: <code class="language-plaintext highlighter-rouge">epochs</code> passes × <code class="language-plaintext highlighter-rouge">num_minibatches</code> each) because no single step is allowed to move the policy far from the one that collected the data. <strong>That safety is the entire reason PPO is sample- efficient and stable.</strong></p> <h3 id="64-a-drift-monitor-approximate-kl">6.4 A drift monitor: approximate KL</h3> <p>As a diagnostic, the learner also computes <code class="language-plaintext highlighter-rouge">approx_kl = mean(old_log_prob − new_log_prob)</code>, a cheap estimate of how far the policy has drifted from the collector (KL is a standard “distance between two probability distributions”). We only log it here, but it’s the standard signal for “we’ve reused this rollout enough; stop before we drift too far.”</p> <hr/> <h2 id="part-7--putting-the-whole-loss-together">Part 7 — Putting the whole loss together</h2> <p>One PPO gradient step (<code class="language-plaintext highlighter-rouge">_gradient_step</code> in <code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo/learner.py</code>) combines three terms we’ve now met. In plain words:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>total_loss  =        clipped_policy_loss          # Part 6 — make good actions likelier, safely
            +  value_coef  · value_loss           # Part 3.4 — critic predicts returns better
            −  entropy_coef · entropy             # Part 3.3 — keep exploring
</code></pre></div></div> <p>and in the actual code (<code class="language-plaintext highlighter-rouge">_total_loss</code>):</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="n">policy_loss</span> <span class="o">+</span> <span class="n">config</span><span class="p">.</span><span class="n">value_coef</span> <span class="o">*</span> <span class="n">value_loss</span> <span class="o">-</span> <span class="n">config</span><span class="p">.</span><span class="n">entropy_coef</span> <span class="o">*</span> <span class="n">entropy</span>
</code></pre></div></div> <p>Reading the three pieces:</p> <ol> <li><strong><code class="language-plaintext highlighter-rouge">policy_loss</code></strong> — the clipped surrogate from Part 6. Drives the actor. Uses the <code class="language-plaintext highlighter-rouge">advantages</code> from GAE and the stored <code class="language-plaintext highlighter-rouge">old_log_probs</code>.</li> <li><strong><code class="language-plaintext highlighter-rouge">value_loss</code></strong> — <code class="language-plaintext highlighter-rouge">F.mse_loss(values, returns)</code>: mean squared error between the critic’s current predictions and the GAE <code class="language-plaintext highlighter-rouge">returns</code>. Drives the critic. <code class="language-plaintext highlighter-rouge">value_coef</code> (≈ 0.5) sets how much we care about critic accuracy relative to the policy.</li> <li><strong><code class="language-plaintext highlighter-rouge">entropy</code></strong> — the policy’s entropy, <em>subtracted</em> so that minimizing the loss <em>increases</em> it. <code class="language-plaintext highlighter-rouge">entropy_coef</code> (a small number like 0.01) sets how hard we push for exploration.</li> </ol> <p>The step itself (<code class="language-plaintext highlighter-rouge">_descend</code>) is standard deep learning with one extra guardrail:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">self</span><span class="p">.</span><span class="n">_optimizer</span><span class="p">.</span><span class="nf">zero_grad</span><span class="p">()</span>          <span class="c1"># clear old gradients
</span><span class="n">loss</span><span class="p">.</span><span class="nf">backward</span><span class="p">()</span>                      <span class="c1"># compute new gradients
</span><span class="nf">clip_grad_norm_</span><span class="p">(...,</span> <span class="n">max_grad_norm</span><span class="p">)</span>  <span class="c1"># cap the gradient size (a second safety belt)
</span><span class="n">self</span><span class="p">.</span><span class="n">_optimizer</span><span class="p">.</span><span class="nf">step</span><span class="p">()</span>               <span class="c1"># nudge the weights
</span></code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">clip_grad_norm_</code> scales the gradient down if it’s too large — a blunt but effective way to stop a single freak batch from taking a giant, destabilizing step. It complements PPO’s probability clipping: one bounds <em>how far the policy moves in probability</em>, the other bounds <em>how big the weight update is</em>.</p> <p>One more detail you’ll see in <code class="language-plaintext highlighter-rouge">_normalize</code>: before use, advantages are often <strong>whitened</strong> (shifted and scaled to mean 0, standard deviation 1) across the minibatch. This doesn’t change their <em>signs</em> (good stays good), just keeps their <em>scale</em> consistent from batch to batch, which makes the learning rate behave predictably. It’s a practical trick, not part of the theory.</p> <hr/> <h2 id="part-8--the-historical-arc-in-one-table">Part 8 — The historical arc, in one table</h2> <table> <thead> <tr> <th>Idea</th> <th>Who added it</th> <th>What it fixed</th> <th>Where it lives in our code</th> </tr> </thead> <tbody> <tr> <td>Push actions up/down by the return</td> <td><strong>REINFORCE</strong> (1992)</td> <td>got policy gradients working at all</td> <td>the <code class="language-plaintext highlighter-rouge">log π · (…)</code> shape of the policy loss</td> </tr> <tr> <td>Subtract a value baseline → advantage</td> <td>baselines / actor-critic</td> <td>cut the variance without adding bias</td> <td><code class="language-plaintext highlighter-rouge">values</code> stored in the buffer; <code class="language-plaintext highlighter-rouge">A = return − V</code></td> </tr> <tr> <td>Bootstrapped advantages, N parallel envs, entropy bonus</td> <td><strong>A2C</strong></td> <td>made it fast and stable enough to run</td> <td><code class="language-plaintext highlighter-rouge">RolloutBuffer</code>, <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code>, entropy term</td> </tr> <tr> <td>GAE — a λ knob between one-step and Monte Carlo</td> <td><strong>GAE</strong> (2015)</td> <td>tuned the bias/variance trade-off</td> <td><code class="language-plaintext highlighter-rouge">compute_gae</code> backward loop</td> </tr> <tr> <td>Clipped probability ratio</td> <td><strong>PPO</strong> (2017)</td> <td>let us reuse each rollout for several safe steps</td> <td><code class="language-plaintext highlighter-rouge">_clipped_policy_loss</code>, stored <code class="language-plaintext highlighter-rouge">old_log_probs</code></td> </tr> </tbody> </table> <p>Each method kept everything before it and added one idea. PPO <em>is</em> A2C-with-GAE plus a clip — which is why, once you understand the <code class="language-plaintext highlighter-rouge">RolloutBuffer</code> (collection + advantages) and the three- term loss, you understand the whole lineage.</p> <hr/> <h2 id="part-9--trace-it-yourself">Part 9 — Trace it yourself</h2> <p>The best way to lock this in is to follow one rollout through the code:</p> <ol> <li><strong>Collect</strong> — <code class="language-plaintext highlighter-rouge">OnPolicyStrategy.step()</code> in <code class="language-plaintext highlighter-rouge">rl_factory/training/on_policy.py</code>: the loop that steps all N envs <code class="language-plaintext highlighter-rouge">T</code> times and calls <code class="language-plaintext highlighter-rouge">buffer.add(...)</code>.</li> <li><strong>Bootstrap + advantages</strong> — the same method calls <code class="language-plaintext highlighter-rouge">agent.bootstrap_values(...)</code> then <code class="language-plaintext highlighter-rouge">buffer.compute_advantages(...)</code>, which runs <code class="language-plaintext highlighter-rouge">compute_gae</code> in <code class="language-plaintext highlighter-rouge">rl_factory/training/rollout.py</code>. Print <code class="language-plaintext highlighter-rouge">advantages</code> and <code class="language-plaintext highlighter-rouge">returns</code> and sanity-check a few by hand.</li> <li><strong>Learn</strong> — <code class="language-plaintext highlighter-rouge">PPOLearner.learn(rollout)</code> in <code class="language-plaintext highlighter-rouge">rl_ablations/models/ppo/learner.py</code> loops over <code class="language-plaintext highlighter-rouge">buffer.minibatches(...)</code> calling <code class="language-plaintext highlighter-rouge">_gradient_step</code>. Read <code class="language-plaintext highlighter-rouge">_clipped_policy_loss</code>, the <code class="language-plaintext highlighter-rouge">value_loss</code>, and <code class="language-plaintext highlighter-rouge">_total_loss</code>, and match each to Parts 6–7 above.</li> </ol> <p>If you can point at the line where each row of the Part 8 table lives, you’re done. ```</p> <h2 id="results-on-mario-1-1">Results on Mario 1-1</h2> <p>Plugged into the on-policy strategy, PPO overtakes DQN. Where DQN plateaued around a mean return of ~2774, PPO keeps climbing to ~4545 over 15M steps — the head-to-head curve is in the <a href="/blog/2026/dqn/">DQN post</a>. Same Trainer, same environment; the win comes from optimizing the policy <em>directly</em> instead of routing every improvement through an argmax over Q-values.</p> <h2 id="where-ppo-runs-out-of-road">Where PPO runs out of road</h2> <p>PPO is a strong baseline on 1-1 — but 1-1 hands out a fairly <strong>dense</strong> reward (move right, get reward). The moment reward is <strong>sparse</strong> — long stretches with no feedback until a distant payoff — a pure reward-maximizer has nothing to climb. The next posts add an <strong>intrinsic</strong> reward: a bonus for <em>surprise</em>, so the agent keeps exploring when the environment goes quiet. First up is ICM, curiosity from predicting the consequences of your own actions → <a href="/blog/2026/icm/">ICM</a>.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="ppo"/><category term="policy-gradients"/><category term="reinforcement-learning"/><summary type="html"><![CDATA[From REINFORCE through A2C to PPO — the on-policy learner that optimizes the policy directly and overtakes DQN on Mario 1-1.]]></summary></entry><entry><title type="html">DQN: value learning that beats the floor (and then hits a ceiling)</title><link href="https://m-rr-j.github.io/blog/2026/dqn/" rel="alternate" type="text/html" title="DQN: value learning that beats the floor (and then hits a ceiling)"/><published>2026-06-29T09:00:00+00:00</published><updated>2026-06-29T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/dqn</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/dqn/"><![CDATA[<blockquote> <p><strong>Code:</strong> <a href="https://github.com/M-RR-J/rl-ablations/tree/main/rl_ablations/models/dqn"><code class="language-plaintext highlighter-rouge">rl_ablations/models/dqn</code></a>, built on the <a href="https://github.com/M-RR-J/rl-factory">rl-factory</a> engine.</p> </blockquote> <p>This is the second post in the series (<a href="/blog/2026/rl-factory/">rl-factory</a> came first — the framework everything plugs into). It’s the <strong>first agent that actually learns</strong>. Before this, the only baseline was a random agent: on World 1-1 it reaches a mean <code class="language-plaintext highlighter-rouge">max_x ≈ 665</code> and completes the level <strong>0%</strong> of the time (the flagpole is at <code class="language-plaintext highlighter-rouge">x ≈ 3160</code>, so random Mario gets about a fifth of the way). That’s the floor. DQN’s job is to beat it.</p> <p>If you remember one sentence: <strong>DQN learns a function <code class="language-plaintext highlighter-rouge">Q(s, a)</code> — “how good is pressing button <code class="language-plaintext highlighter-rouge">a</code> in state <code class="language-plaintext highlighter-rouge">s</code>?” — and acts by picking the button with the highest <code class="language-plaintext highlighter-rouge">Q</code>; the entire trick is making that estimate stable enough to train against itself.</strong></p> <h2 id="part-0--where-dqn-sits-in-the-factory">Part 0 — where DQN sits in the factory</h2> <p>From the <a href="/blog/2026/rl-factory/">previous post</a>: the one real axis of variation is on-policy vs. off-policy, and <strong>DQN is the off-policy family</strong>. It inherits <code class="language-plaintext highlighter-rouge">OffPolicyStrategy</code> unchanged — async-ish collection into a <strong>replay buffer</strong>, learning from sampled minibatches. So this post is not about a training loop (that’s shared); it’s about one <code class="language-plaintext highlighter-rouge">Learner</code> — <code class="language-plaintext highlighter-rouge">DQNLearner</code> — and the network it trains.</p> <h2 id="part-1--the-q-network">Part 1 — the Q-network</h2> <p>The network is the Nature-DQN conv stack: a stacked <code class="language-plaintext highlighter-rouge">(4, 84, 84)</code> frame (four grayscale frames, so the agent can see motion) in, one Q-value per action out.</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">self</span><span class="p">.</span><span class="n">conv</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">Sequential</span><span class="p">(</span>
    <span class="n">nn</span><span class="p">.</span><span class="nc">Conv2d</span><span class="p">(</span><span class="n">in_channels</span><span class="p">,</span> <span class="mi">32</span><span class="p">,</span> <span class="n">kernel_size</span><span class="o">=</span><span class="mi">8</span><span class="p">,</span> <span class="n">stride</span><span class="o">=</span><span class="mi">4</span><span class="p">),</span> <span class="n">nn</span><span class="p">.</span><span class="nc">ReLU</span><span class="p">(),</span>  <span class="c1"># 84x84 -&gt; 20x20
</span>    <span class="n">nn</span><span class="p">.</span><span class="nc">Conv2d</span><span class="p">(</span><span class="mi">32</span><span class="p">,</span> <span class="mi">64</span><span class="p">,</span> <span class="n">kernel_size</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">stride</span><span class="o">=</span><span class="mi">2</span><span class="p">),</span> <span class="n">nn</span><span class="p">.</span><span class="nc">ReLU</span><span class="p">(),</span>           <span class="c1"># 20x20 -&gt; 9x9
</span>    <span class="n">nn</span><span class="p">.</span><span class="nc">Conv2d</span><span class="p">(</span><span class="mi">64</span><span class="p">,</span> <span class="mi">64</span><span class="p">,</span> <span class="n">kernel_size</span><span class="o">=</span><span class="mi">3</span><span class="p">,</span> <span class="n">stride</span><span class="o">=</span><span class="mi">1</span><span class="p">),</span> <span class="n">nn</span><span class="p">.</span><span class="nc">ReLU</span><span class="p">(),</span>           <span class="c1"># 9x9   -&gt; 7x7
</span>    <span class="n">nn</span><span class="p">.</span><span class="nc">Flatten</span><span class="p">(),</span>
<span class="p">)</span>
<span class="n">self</span><span class="p">.</span><span class="n">head</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="nc">Sequential</span><span class="p">(</span>
    <span class="n">nn</span><span class="p">.</span><span class="nc">Linear</span><span class="p">(</span><span class="mi">64</span> <span class="o">*</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">7</span><span class="p">,</span> <span class="mi">512</span><span class="p">),</span> <span class="n">nn</span><span class="p">.</span><span class="nc">ReLU</span><span class="p">(),</span>
    <span class="n">nn</span><span class="p">.</span><span class="nc">Linear</span><span class="p">(</span><span class="mi">512</span><span class="p">,</span> <span class="n">n_actions</span><span class="p">),</span>   <span class="c1"># one Q-value per action; no activation
</span><span class="p">)</span>
</code></pre></div></div> <p>No softmax, no value in <code class="language-plaintext highlighter-rouge">[0,1]</code> — the outputs are raw expected returns, one per button combination. Acting is <code class="language-plaintext highlighter-rouge">argmax</code> over them (with ε-greedy exploration: a small fraction of the time, press a random button so the agent keeps discovering).</p> <h2 id="part-2--the-three-ideas-that-make-it-trainable">Part 2 — the three ideas that make it trainable</h2> <p>Q-learning’s target is <em>bootstrapped</em>: you regress <code class="language-plaintext highlighter-rouge">Q(s, a)</code> toward <code class="language-plaintext highlighter-rouge">r + γ·maxₐ' Q(s', a')</code> — an estimate that uses the network’s own output. Do that naïvely and it diverges: you’re chasing a target that moves every time you take a step. DQN’s contribution is three stabilizers, all visible in <code class="language-plaintext highlighter-rouge">DQNLearner</code>:</p> <p><strong>1. Experience replay.</strong> Store transitions in a buffer and learn from <em>random</em> minibatches, not the latest step. This breaks the correlation between consecutive frames (which would otherwise make each gradient step redundant with the last) and lets each transition train the network many times.</p> <p><strong>2. A frozen target network.</strong> Keep a second copy of the Q-network, <code class="language-plaintext highlighter-rouge">self._target</code>, and compute the bootstrap target from <em>it</em>, not the live network. It’s a stationary goalpost:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">self</span><span class="p">.</span><span class="n">_target</span> <span class="o">=</span> <span class="n">copy</span><span class="p">.</span><span class="nf">deepcopy</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">_q</span><span class="p">)</span>   <span class="c1"># frozen goalpost
</span><span class="bp">...</span>
<span class="k">with</span> <span class="n">torch</span><span class="p">.</span><span class="nf">no_grad</span><span class="p">():</span>
    <span class="n">q_next</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_target</span><span class="p">(</span><span class="n">next_states</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">).</span><span class="n">values</span>
    <span class="n">td_target</span> <span class="o">=</span> <span class="n">rewards</span> <span class="o">+</span> <span class="n">config</span><span class="p">.</span><span class="n">gamma</span> <span class="o">*</span> <span class="n">q_next</span> <span class="o">*</span> <span class="p">(</span><span class="mf">1.0</span> <span class="o">-</span> <span class="n">dones</span><span class="p">)</span>   <span class="c1"># 0 bootstrap on terminal
</span></code></pre></div></div> <p>Every <code class="language-plaintext highlighter-rouge">target_sync_every</code> steps the target is refreshed from the live network. Between syncs, the thing you’re regressing toward holds still — which is the difference between training and diverging.</p> <p><strong>3. Huber loss + reward clipping.</strong> The TD error is squashed with <code class="language-plaintext highlighter-rouge">smooth_l1_loss</code> (Huber) and rewards are clipped to <code class="language-plaintext highlighter-rouge">[-1, 1]</code>, so a single surprising transition can’t produce a giant gradient. Mario’s raw reward has big spikes; this keeps the update well-behaved.</p> <p>The learner self-gates the whole schedule — warm up the buffer (<code class="language-plaintext highlighter-rouge">learning_starts</code>), then a gradient step every <code class="language-plaintext highlighter-rouge">train_every</code>, and a target sync on its own cadence:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">learn</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
    <span class="n">self</span><span class="p">.</span><span class="n">_steps</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="n">metrics</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_gradient_step</span><span class="p">()</span> <span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="nf">_is_train_step</span><span class="p">()</span> <span class="k">else</span> <span class="bp">None</span>
    <span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="nf">_is_sync_step</span><span class="p">():</span>
        <span class="n">self</span><span class="p">.</span><span class="nf">_sync_target</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">metrics</span>
</code></pre></div></div> <p>That’s DQN in one method: replay-sample, regress toward the frozen-target TD value, periodically move the goalpost.</p> <h2 id="part-3--does-it-beat-the-floor">Part 3 — does it beat the floor?</h2> <p>Yes — comfortably. Against the random baseline’s <code class="language-plaintext highlighter-rouge">max_x ≈ 665</code>, DQN climbs to a mean episode return around <strong>2774</strong> on Mario 1-1. It learns to run right, jump the first pits and Goombas, and make real progress into the level. Here it is against the PPO agent we build next:</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/blog/curves/dqn_vs_ppo-480.webp 480w,/assets/img/blog/curves/dqn_vs_ppo-800.webp 800w,/assets/img/blog/curves/dqn_vs_ppo-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/blog/curves/dqn_vs_ppo.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" data-zoomable="" loading="eager" onerror="this.onerror=null; document.querySelectorAll('.responsive-img-srcset').forEach(function (n) { n.remove(); });"/> </picture> </figure> <p>Read the blue curve first: DQN rises off the floor fast, then <strong>plateaus around ~2800 by roughly 8M steps</strong> and stops improving. It’s a real agent — but it’s stuck.</p> <h2 id="part-4--the-ceiling-and-why-we-move-on">Part 4 — the ceiling, and why we move on</h2> <p>DQN plateaus for reasons that are structural, not just tuning:</p> <ul> <li><strong>It’s value-based on a big discrete action space.</strong> Every improvement has to route through an <code class="language-plaintext highlighter-rouge">argmax</code> over Q-values; the policy is implicit and changes in lurches.</li> <li><strong>Off-policy replay mixes stale experience.</strong> The buffer is full of transitions from an older, worse policy, which is efficient but noisy for a fast-moving control problem like Mario.</li> <li><strong>Sparse, delayed reward is hard for TD.</strong> Progress in Mario often needs a committed <em>sequence</em> of jumps before any reward arrives; bootstrapped value estimates propagate that credit slowly.</li> </ul> <p>The orange curve above is the fix: <strong>PPO</strong>, an on-policy policy-gradient method that optimizes the policy directly and keeps climbing past DQN’s plateau to ~4500. That’s the <a href="/blog/2026/ppo/">next post</a> — and, in factory terms, it’s the <em>other</em> strategy family. Same Trainer, same env, a different half of the split.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="reinforcement-learning"/><category term="dqn"/><category term="q-learning"/><category term="pytorch"/><summary type="html"><![CDATA[The first learning agent — a Nature-DQN Q-network with replay and a frozen target network — dropped into the off-policy half of rl-factory. It beats the random baseline on Mario 1-1, then plateaus.]]></summary></entry><entry><title type="html">rl-factory: an RL codebase where a new algorithm is a plug-in, not a rewrite</title><link href="https://m-rr-j.github.io/blog/2026/rl-factory/" rel="alternate" type="text/html" title="rl-factory: an RL codebase where a new algorithm is a plug-in, not a rewrite"/><published>2026-06-28T09:00:00+00:00</published><updated>2026-06-28T09:00:00+00:00</updated><id>https://m-rr-j.github.io/blog/2026/rl-factory</id><content type="html" xml:base="https://m-rr-j.github.io/blog/2026/rl-factory/"><![CDATA[<p>This is the first post in a series that trains a reinforcement-learning agent to play <strong>Super Mario Bros</strong> from scratch, one algorithm at a time — DQN, PPO, ICM, RND, and kNN episodic novelty. Before any single algorithm, this post is about the <em>thing they all plug into</em>: a small framework I keep calling the <strong>rl-factory</strong>, because its whole job is to make new algorithms cheap to add.</p> <p>The claim I want to earn: <strong>adding a new RL algorithm to this codebase is registering a learner, not rewriting a training loop.</strong> Everything in the series after this one is an exercise in cashing that claim — each model drops into the same spine and the diff is tiny.</p> <p>If you remember one sentence: <strong>there is exactly one real axis of variation in these algorithms — on-policy vs. off-policy — so the framework encodes that as a single choice and treats everything else (DQN vs. PPO vs. curiosity) as a swapped-in <code class="language-plaintext highlighter-rouge">Learner</code>.</strong></p> <blockquote> <p><strong>The code.</strong> The engine lives in <a href="https://github.com/M-RR-J/rl-factory"><code class="language-plaintext highlighter-rouge">rl-factory</code></a>; the five models that plug into it live in <a href="https://github.com/M-RR-J/rl-ablations"><code class="language-plaintext highlighter-rouge">rl-ablations</code></a>. This post walks the engine.</p> </blockquote> <h2 id="part-0--the-trap-this-avoids">Part 0 — the trap this avoids</h2> <p>The naïve way to grow an RL repo is one script per algorithm: <code class="language-plaintext highlighter-rouge">train_dqn.py</code>, <code class="language-plaintext highlighter-rouge">train_ppo.py</code>, <code class="language-plaintext highlighter-rouge">train_icm.py</code>. Each re-implements the run loop (step budget, checkpointing, logging, the env-stepping), and they drift. By the fifth algorithm you have five subtly different training loops and no way to compare results fairly, because “the setup” isn’t one thing.</p> <p>The opposite trap is a single <code class="language-plaintext highlighter-rouge">train()</code> with a growing thicket of <code class="language-plaintext highlighter-rouge">if algo == "ppo": ... elif algo == "icm": ...</code>. That centralizes the loop but scatters each algorithm’s logic across every function it touches.</p> <p>rl-factory takes a third path: <strong>one shared loop, and a small typed contract that each algorithm implements.</strong> The loop never mentions a specific algorithm; an algorithm never re-implements the loop.</p> <h2 id="part-1--the-three-pieces">Part 1 — the three pieces</h2> <p>The spine is three things:</p> <ol> <li> <p><strong>Typed interfaces</strong> (<code class="language-plaintext highlighter-rouge">rl_factory/data_classes/</code>) — the contracts: <code class="language-plaintext highlighter-rouge">Agent</code> (something that acts), <code class="language-plaintext highlighter-rouge">Learner</code> (something that turns experience into a weight update), <code class="language-plaintext highlighter-rouge">Environment</code>, and the config objects. Nothing here knows about Mario or about DQN; they’re just shapes.</p> </li> <li> <p><strong>The <code class="language-plaintext highlighter-rouge">Trainer</code></strong> (<code class="language-plaintext highlighter-rouge">rl_factory/training/trainer.py</code>) — owns only the <em>shared</em> concerns: the env-step budget, periodic checkpointing, and stats logging. It drives a strategy through four methods and is otherwise blind to what’s being trained:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">self</span><span class="p">.</span><span class="n">_strategy</span><span class="p">.</span><span class="nf">setup</span><span class="p">()</span>
<span class="k">while</span> <span class="n">steps</span> <span class="o">&lt;</span> <span class="n">config</span><span class="p">.</span><span class="n">steps</span><span class="p">:</span>
    <span class="n">done</span><span class="p">,</span> <span class="n">metrics</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">_strategy</span><span class="p">.</span><span class="nf">step</span><span class="p">()</span>   <span class="c1"># collect + learn one unit
</span>    <span class="n">steps</span> <span class="o">+=</span> <span class="n">done</span>
    <span class="n">stats</span><span class="p">.</span><span class="nf">record</span><span class="p">(</span><span class="n">metrics</span><span class="o">=</span><span class="n">metrics</span><span class="p">)</span>
    <span class="c1"># ... checkpoint / log on schedule ...
</span><span class="n">self</span><span class="p">.</span><span class="n">_strategy</span><span class="p">.</span><span class="nf">teardown</span><span class="p">()</span>
</code></pre></div> </div> <p>That’s the entire training loop for <em>every</em> algorithm in the series. DQN and PPO and ICM all run this exact code.</p> </li> <li> <p><strong>The <code class="language-plaintext highlighter-rouge">TrainStrategy</code></strong> (<code class="language-plaintext highlighter-rouge">rl_factory/training/strategy.py</code>) — the pluggable “how.” A four-method contract: <code class="language-plaintext highlighter-rouge">setup / step / weights / teardown</code>.</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TrainStrategy</span><span class="p">(</span><span class="n">ABC</span><span class="p">):</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">setup</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span> <span class="bp">...</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">step</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">float</span><span class="p">]]:</span> <span class="p">...</span>   <span class="c1"># (env-steps done, metrics)
</span>    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">weights</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">StateDict</span><span class="p">:</span> <span class="bp">...</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span> <span class="nf">teardown</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span> <span class="bp">...</span>
</code></pre></div> </div> <p>A strategy owns <em>how experience is collected and learned from</em>; the Trainer owns <em>when to stop, save, and log</em>. Clean seam.</p> </li> </ol> <h2 id="part-2--the-one-branch-thats-real-on-policy-vs-off-policy">Part 2 — the one branch that’s real: on-policy vs off-policy</h2> <p>Here’s the design decision the whole thing pivots on. If you line up DQN, PPO, and the three curiosity methods, they differ in a hundred small ways — but those differences collapse onto <strong>one</strong> structural axis:</p> <ul> <li><strong>Off-policy</strong> (DQN, the random baseline): collect into a <strong>replay buffer</strong>, learn from sampled batches with a TD target against a shared Q-network.</li> <li><strong>On-policy</strong> (PPO, ICM, RND, kNN): collect a fresh <strong>rollout</strong> from a vectorized env, learn from it once with GAE, throw it away.</li> </ul> <p>So there are exactly <strong>two</strong> concrete strategies — <code class="language-plaintext highlighter-rouge">OffPolicyStrategy</code> and <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code> — and which one a model uses is not code, it’s <strong>data</strong>: a <code class="language-plaintext highlighter-rouge">family</code> each model declares when it registers.</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">StrategyFamily</span><span class="p">(</span><span class="n">StrEnum</span><span class="p">):</span>
    <span class="n">OFF_POLICY</span> <span class="o">=</span> <span class="sh">"</span><span class="s">off_policy</span><span class="sh">"</span>   <span class="c1"># async actors + replay buffer (DQN / random)
</span>    <span class="n">ON_POLICY</span>  <span class="o">=</span> <span class="sh">"</span><span class="s">on_policy</span><span class="sh">"</span>    <span class="c1"># vectorized env + on-policy rollout (PPO / ICM / RND / kNN)
</span>
<span class="c1"># ...and each model just names its family when it registers itself (rl_ablations/register.py):
</span><span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">dqn</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">dqn</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">dqn</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">OFF</span><span class="p">)</span>
<span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">ppo</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
<span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">icm</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">icm</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
</code></pre></div></div> <p>Adding ICM did not add an <code class="language-plaintext highlighter-rouge">if icm</code> anywhere in the training loop. ICM is on-policy, so it inherits <code class="language-plaintext highlighter-rouge">OnPolicyStrategy</code> <strong>unchanged</strong>. The only thing that makes it ICM instead of PPO is a different learner (more on that next). That is the single most important sentence in this whole codebase:</p> <blockquote> <p>ICM rides the on-policy strategy unchanged — it’s just a different <code class="language-plaintext highlighter-rouge">build_learner</code>.</p> </blockquote> <h2 id="part-3--a-model-factored">Part 3 — a model, factored</h2> <p>Every model is the same four files, so once you’ve read one you can read them all:</p> <table> <thead> <tr> <th>file</th> <th>responsibility</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">config.py</code></td> <td>the model’s hyper-parameters (a typed config)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">model.py</code></td> <td>the network(s) — nn.Modules only, no training logic</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">learner.py</code></td> <td>turns a batch of experience into a loss + weight update</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">factory.py</code></td> <td><code class="language-plaintext highlighter-rouge">build_agent(...)</code> and <code class="language-plaintext highlighter-rouge">build_learner(...)</code> — the entry points the factory calls</td> </tr> </tbody> </table> <p>The <code class="language-plaintext highlighter-rouge">Agent</code> (acting policy) and the <code class="language-plaintext highlighter-rouge">Learner</code> (weight update) are deliberately separate. That separation is why the curiosity models are so cheap: <strong>ICM, RND, and kNN all act with a plain PPO policy</strong> — they only change <em>how the update is computed</em>. You can read that straight off the registrations — they pass <strong>the same <code class="language-plaintext highlighter-rouge">build_agent</code></strong> (PPO’s) and differ only in <code class="language-plaintext highlighter-rouge">build_learner</code>:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># rl_ablations/register.py — icm/rnd/knn share PPO's acting policy; only the learner differs
</span><span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">ppo</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
<span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">icm</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">icm</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
<span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">rnd</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">rnd</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
<span class="nf">register_model</span><span class="p">(</span><span class="sh">"</span><span class="s">knn</span><span class="sh">"</span><span class="p">,</span> <span class="n">build_agent</span><span class="o">=</span><span class="n">ppo</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">build_learner</span><span class="o">=</span><span class="n">knn</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">family</span><span class="o">=</span><span class="n">ON</span><span class="p">)</span>
</code></pre></div></div> <p>Each curiosity <code class="language-plaintext highlighter-rouge">build_learner</code> wraps PPO’s update with an intrinsic-reward term. Same policy, same rollout, same loop; a different learner. Novelty in, exploration out.</p> <h2 id="part-4--the-factory-tie">Part 4 — the factory tie</h2> <p><code class="language-plaintext highlighter-rouge">rl_factory.algorithm(name)</code> is the one call that assembles a registered model into everything the Trainer needs — it looks the model up in the registry and closes over its <code class="language-plaintext highlighter-rouge">family</code>:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">algorithm</span><span class="p">(</span><span class="n">name</span><span class="p">):</span>
    <span class="n">spec</span> <span class="o">=</span> <span class="n">_REGISTRY</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>                       <span class="c1"># build_agent, build_learner, family
</span>
    <span class="k">def</span> <span class="nf">make_strategy</span><span class="p">(</span><span class="n">build_env</span><span class="p">,</span> <span class="n">config</span><span class="p">):</span>
        <span class="k">return</span> <span class="nf">_build_strategy</span><span class="p">(</span><span class="n">spec</span><span class="p">.</span><span class="n">family</span><span class="p">,</span> <span class="n">build_env</span><span class="p">,</span> <span class="n">spec</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">spec</span><span class="p">.</span><span class="n">build_learner</span><span class="p">,</span> <span class="n">config</span><span class="p">)</span>

    <span class="k">return</span> <span class="nc">Algorithm</span><span class="p">(</span><span class="n">build_agent</span><span class="o">=</span><span class="n">spec</span><span class="p">.</span><span class="n">build_agent</span><span class="p">,</span> <span class="n">make_strategy</span><span class="o">=</span><span class="n">make_strategy</span><span class="p">)</span>
</code></pre></div></div> <p>Note what <code class="language-plaintext highlighter-rouge">make_strategy</code> takes: a <code class="language-plaintext highlighter-rouge">build_env</code> <strong>passed in from outside</strong>. rl-factory never imports Mario — the environment is a parameter. That’s what keeps it a <em>framework</em> and not a Mario script, and it’s the seam this project splits along: <a href="https://github.com/M-RR-J/rl-factory"><code class="language-plaintext highlighter-rouge">rl-factory</code></a> is the engine in this post, and <a href="https://github.com/M-RR-J/rl-ablations"><code class="language-plaintext highlighter-rouge">rl-ablations</code></a> is the separate repo that brings the Mario environment and registers the five models on top of it.</p> <h2 id="part-5--so-what-does-add-a-model-actually-cost">Part 5 — so what does “add a model” actually cost?</h2> <p>Concretely, to add an algorithm:</p> <ol> <li>Write its four files (<code class="language-plaintext highlighter-rouge">config / model / learner / factory</code>).</li> <li>Call <code class="language-plaintext highlighter-rouge">register_model(name, build_agent=…, build_learner=…, family=…)</code> — one line.</li> </ol> <p>That’s the whole cost. No new training loop. No new checkpointing, logging, or env-stepping. If it’s on-policy, no new <em>strategy</em> either — you’re writing a <code class="language-plaintext highlighter-rouge">Learner</code> and nothing else structural. That’s the payoff, and it’s why the rest of this series can move fast: each post is one model dropped into this frame, and the interesting part is the <em>idea</em>, because the plumbing is already paid for.</p> <h2 id="whats-next">What’s next</h2> <p>From here the series walks the models in the order I built them, and each one earns its place by fixing a limitation of the last:</p> <ol> <li><strong>DQN</strong> — off-policy value learning; beat the random floor.</li> <li><strong>PPO</strong> — on-policy policy gradients; a more stable learner.</li> <li><strong>ICM</strong> — add curiosity so Mario explores when the reward is sparse.</li> <li><strong>RND</strong> — a simpler curiosity signal (and why simpler was better).</li> <li><strong>kNN episodic novelty</strong> — a count-based signal that <em>can’t</em> saturate the way the others did.</li> </ol> <p>Every one of them is the same spine from this post, plus a <code class="language-plaintext highlighter-rouge">Learner</code>. Let’s start beating the floor.</p>]]></content><author><name></name></author><category term="rl-from-scratch"/><category term="reinforcement-learning"/><category term="software-design"/><category term="factory-pattern"/><category term="pytorch"/><summary type="html"><![CDATA[The lean spine that lets DQN, PPO, ICM, RND, and kNN-novelty share one training loop — where adding a model means registering a learner, not branching the control flow.]]></summary></entry></feed>