<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://engineering-manager.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://engineering-manager.com/" rel="alternate" type="text/html" /><updated>2026-07-31T15:14:18+00:00</updated><id>https://engineering-manager.com/feed.xml</id><title type="html">Engineering Manager</title><subtitle>Engineering-manager.com is the blog to learn about and grow as software engineering manager. Lessons from managers at top tech companies.</subtitle><author><name>Kuba Niechcial</name><email>jakub.niechcial@gmail.com</email></author><entry><title type="html">How reinforcement learning actually works</title><link href="https://engineering-manager.com/2026-07-31/how-reinforcement-learning-actually-works" rel="alternate" type="text/html" title="How reinforcement learning actually works" /><published>2026-07-31T00:00:00+00:00</published><updated>2026-07-31T00:00:00+00:00</updated><id>https://engineering-manager.com/2026-07-31/how-reinforcement-learning-actually-works</id><content type="html" xml:base="https://engineering-manager.com/2026-07-31/how-reinforcement-learning-actually-works"><![CDATA[<p>This is the sequel to <a href="/2026-07-27/how-supervised-fine-tuning-actually-works">my post on supervised fine-tuning</a>. If you have not read that one, I encourage you to go give it a try for some basics, but overall this one should be understandable if you have at least some basic instinct on how ML training works.</p>

<p>Same disclaimer as last time: everything I learned comes from HuggingFace’s <a href="https://huggingface.co/learn/llm-course">LLM Course</a> and DeepLearning.AI’s <a href="https://www.deeplearning.ai/courses/fine-tuning-and-reinforcement-learning-for-llms-intro-to-post-training">Fine-tuning &amp; RL for LLMs: Intro to Post-training</a>, and the best way I know to check whether I understand something is to try to explain it!</p>

<h2 id="what-rl-is-and-where-it-sits">What RL is, and where it sits</h2>

<p>Reinforcement Learning is a type of training you apply to LLMs, primarily to make them more helpful, more aligned to human preferences, and increasingly to make them great at reasoning and problem solving — especially in the specific environments of your own software or systems.</p>

<p>It is different from SFT. In SFT we never let the model generate anything. Instead, we give it an expected answer and iteratively adapt its weights to make that exact answer more likely. In RL we do the opposite: we let the model generate, we score what it produced with a reward, and according to that reward we adapt its weights towards the behaviours that scored well, and away from those that didn’t.</p>

<figure class="fig">
  <span class="fig__label">Figure 1</span>
  <div class="fig__frame">
    <p class="fig__title">Two ways to move a weight</p>
    <p class="fig__note">The machinery underneath is the same — a forward pass, log probabilities,
      backpropagation, an optimizer. What changes is where the tokens come from.</p>

    <div class="duo duo--top">
      <div class="pane">
        <div class="pane__head pane__head--cold">SFT</div>
        <div class="pane__body">
          <p class="pane__prompt">You supply the answer</p>
          <p class="pane__out">1. Hand the model prompt + answer
2. Ask how likely that answer was
3. Push those exact tokens up</p>
        </div>
        <div class="pane__foot">The model never generates. You already decided what good looks like, token by
          token.</div>
      </div>

      <div class="pane">
        <div class="pane__head pane__head--warm">RL</div>
        <div class="pane__body">
          <p class="pane__prompt">The model supplies the answer</p>
          <p class="pane__out">1. Hand the model a prompt only
2. Let it generate, then score it
3. Push those tokens up <em>or</em> down</p>
        </div>
        <div class="pane__foot">You never say what good looks like. You only say how good what came back
          was.</div>
      </div>
    </div>

    <div class="strip">
      <span class="strip__icon">🔁</span>
      <p>That third step is the whole difference. In SFT the sign is always "more of this". In RL the sign
        comes from the reward, which means the same machinery can push a rollout <strong>away</strong> from
        the model — something SFT structurally cannot do.</p>
    </div>
  </div>
</figure>

<p>RL is extremely powerful if the data is well prepared. It is the primary way of making models feel personal, helpful, safe and smart. It is also more expensive than SFT, less predictable, and far more experiment-driven. No free lunch.</p>

<p>It helps to see where all of this sits. Training a modern model runs roughly in this order: pretraining, then SFT, then — if you want human preferences — training a reward model, and finally RL. The previous post covered the second box; this one covers the last two.</p>

<figure class="fig fig--wide">
  <span class="fig__label">Figure 2</span>
  <div class="fig__frame">
    <p class="fig__title">Where RL sits</p>
    <p class="fig__note">The previous post covered the second box. This one covers the last two — and the
      reward model is optional, because a verifier can do its job when the answer is checkable.</p>
    <div class="flow">
      <div class="flow__box flow__box--in">
        <span class="flow__kind">Stage 1</span>
        <span class="flow__name">Pretraining</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--in">
        <span class="flow__kind">Stage 2</span>
        <span class="flow__name">SFT</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--next">
        <span class="flow__kind">Stage 3 · optional</span>
        <span class="flow__name">Reward model</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--act">
        <span class="flow__kind">Stage 4</span>
        <span class="flow__name">RL</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--out">
        <span class="flow__kind">Output</span>
        <span class="flow__name">The model you ship</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">Stage 3 is only there when the thing you want cannot be checked by a program.
    If you are rewarding "did the test suite pass" or "was the right tool called", you skip it entirely and
    go straight from SFT to RL.</figcaption>
</figure>

<p>Reasoning is the easiest category to understand. RL is powerful because you let the model explore plenty of potential solutions (because you ask it to generate tokens), and reinforce those that lead to successful answers. You don’t care that much how it arrived at the solution. This also lets LLMs find solution strategies that nobody wrote down in the training data, sometimes ones that surprise the people running the training.</p>

<h2 id="which-architecture-of-rl">Which architecture of RL?</h2>

<p>There are plenty of different algorithms and types of reinforcement learning. We won’t dive into them too much, because they share the same fundamental principles and differ in the details. I will focus on a particular subset — <strong>GRPO</strong>, trained against verifier feedback and against rewards learned from human feedback.</p>

<p>As I was learning about the other techniques — <a href="https://arxiv.org/abs/1707.06347">PPO</a> and <a href="https://arxiv.org/abs/2305.18290">DPO</a> — I felt that GRPO gives the right foundation, and based on a few signals about how software companies are doing RL for their own needs, it also feels the most common. It comes from the <a href="https://arxiv.org/abs/2402.03300">DeepSeekMath paper</a> and it is the algorithm behind <a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1</a>.</p>

<h2 id="what-the-data-looks-like">What the data looks like</h2>

<p>The data for SFT is pairs of inputs and outputs. The data for RL is quite different. To train the model with RL, you need a set of inputs (prompts), and a way to reward the model for what it generates from them. The key, unique insight is that <strong>you don’t need to know the solution</strong> — you only need to know what to ask, and how to score whatever comes back.</p>

<figure class="fig" id="fig-rldata">
  <span class="fig__label">Figure 3 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">A row of RL data has no answer in it</p>
    <p class="fig__note">Three kinds of task, three ways of scoring. In none of them does the dataset
      contain the working — only the question, and something you can check at the end.</p>

    <div class="tabs" data-role="tabs">
      <button class="btn" type="button" data-k="math" aria-pressed="true">Maths</button>
      <button class="btn" type="button" data-k="agent" aria-pressed="false">Agentic</button>
      <button class="btn" type="button" data-k="pref" aria-pressed="false">Helpfulness</button>
    </div>

    <div class="samples">
      <div class="sample" style="background:var(--card)">
        <span class="sample__n">Prompt — this is what you must curate</span>
        <div class="msg msg--user">
          <span class="msg__role">prompt</span>
          <span class="msg__content" data-role="prompt"></span>
        </div>
      </div>

      <div class="sample" style="background:var(--tint-2)">
        <span class="sample__n" data-role="checklabel">Checkable end state — ships with the prompt</span>
        <div class="msg">
          <span class="msg__role" style="background:var(--accent-2)">check</span>
          <span class="msg__content" data-role="check"></span>
        </div>
      </div>

      <div class="sample" style="background:transparent;border-style:dashed">
        <span class="sample__n">Not in the dataset · never was · not needed</span>
        <div class="msg">
          <span class="msg__role" style="background:transparent;border-style:dashed">soln</span>
          <span class="msg__content" style="color:var(--meta)" data-role="missing"></span>
        </div>
      </div>
    </div>

    <div class="readout">
      <span class="chip" data-role="kind">verifier</span>
      <span class="readout__note" data-role="note"></span>
    </div>
  </div>
  <figcaption class="fig__cap">This is the load-bearing difference from SFT. There, every row had to contain
    a written-out answer, which is why good SFT data is expensive. Here you need a question and a way to
    check — and a model that already gets it right <em>sometimes</em> supplies the rest.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-rldata');
  if (!root) return;

  var DATA = {
    math: {
      prompt: 'Natalia sold clips to 48 friends in April, and then she sold half as many clips in May. How many clips did she sell altogether?',
      checkLabel: 'Final answer — ships with the prompt',
      check: '72',
      missing: 'The five lines of arithmetic that get you to 72. Nobody wrote them down.',
      kind: 'verifier · exact match',
      note: 'Parse the number the model finishes with, compare it to 72. A twelve-line solution and a one-line lucky guess score identically — which is a problem we will come back to.'
    },
    agent: {
      prompt: 'The invoice for ACME from last Tuesday has the wrong VAT rate on it. Fix it and let their account owner know.',
      checkLabel: 'End state — ships with the prompt',
      check: 'invoice.vat_rate == 0.19 AND notification sent to account_owner(ACME)',
      missing: 'Which tools to call, in what order, and what to do when the search returns three invoices.',
      kind: 'verifier · environment assert',
      note: 'You seed a throwaway environment, let the model loose in it, and assert on what is true at the end. The entire trace — every tool call, every result — is one rollout with one score.'
    },
    pref: {
      prompt: 'My manager keeps rescheduling our 1:1s. How do I bring it up without sounding like I am complaining?',
      checkLabel: 'Nothing ships with the prompt',
      check: '— there is no correct answer to check against —',
      missing: 'A good reply. There are hundreds, and they disagree with each other.',
      kind: 'reward model',
      note: 'Nothing here can be regexed or asserted on. This is the case that forces you to train a reward model first — a model whose only job is to score answers the way your humans would have.'
    }
  };

  var els = {
    prompt: root.querySelector('[data-role="prompt"]'),
    checkLabel: root.querySelector('[data-role="checklabel"]'),
    check: root.querySelector('[data-role="check"]'),
    missing: root.querySelector('[data-role="missing"]'),
    kind: root.querySelector('[data-role="kind"]'),
    note: root.querySelector('[data-role="note"]')
  };

  var btns = Array.prototype.slice.call(root.querySelectorAll('[data-role="tabs"] .btn'));

  function show(k) {
    var d = DATA[k];
    btns.forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.k === k ? 'true' : 'false'); });
    els.prompt.textContent = d.prompt;
    els.checkLabel.textContent = d.checkLabel;
    els.check.textContent = d.check;
    els.missing.textContent = d.missing;
    els.kind.textContent = d.kind;
    els.kind.className = 'chip ' + (k === 'pref' ? 'chip--warn' : 'chip--scored');
    els.note.textContent = d.note;
  }

  btns.forEach(function (b) {
    b.addEventListener('click', function () { show(b.dataset.k); });
  });

  show('math');
})();
</script>

<p>In this type of dataset, notice that while we do have the final answer in the row, we don’t have the actual solution. We just know the answer is 5, or 10, or 72. Let’s see how to design a reward for a dataset like that.</p>

<h2 id="reward-with-verifiers">Reward with verifiers</h2>

<p>The most basic example of reward modelling for mathematical prompts is just checking the final answer of the model and giving it a point — or points. The absolute value doesn’t really matter as long as it is consistent across the entire training run.</p>

<figure class="fig fig--wide" id="fig-verifier">
  <span class="fig__label">Figure 4 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">Scoring what came back</p>
    <p class="fig__note">One prompt — <em>Natalia sold clips to 48 friends in April, then half as many in
      May. How many altogether?</em> — and four things the model actually produced. Click a rollout, then
      switch the reward scheme.</p>

    <div class="ctl" style="margin-bottom:1rem">
      <span class="ctl__label">Reward</span>
      <button class="btn" type="button" data-role="sparse" aria-pressed="true">Binary</button>
      <button class="btn" type="button" data-role="shaped" aria-pressed="false">Shaped</button>
    </div>

    <div class="presets" data-role="picker"></div>

    <pre class="tmpl" data-role="out"></pre>

    <hr class="fig__hr" />

    <p class="fig__eyebrow">What the verifier checks</p>
    <div class="ledger" data-role="ledger"></div>

    <div class="readout">
      <span data-role="total"></span>
      <span class="readout__note" data-role="note"></span>
    </div>

    <hr class="fig__hr" />

    <p class="fig__eyebrow">All four rollouts under this scheme</p>
    <div class="group" data-role="allrows"></div>

    <div class="stats">
      <div class="stat" data-role="spreadbox">
        <span class="stat__k">Distinct scores</span>
        <span class="stat__v" data-role="distinct">—</span>
      </div>
      <div class="stat" data-role="rangebox">
        <span class="stat__k">Spread</span>
        <span class="stat__v" data-role="spread">—</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">Binary rewards are honest and unhackable, and they give you exactly two
    values to rank a group by. Shaping adds cheap, exactly-checkable structure — did it think in a
    <strong>&lt;think&gt;</strong> block, did it answer in the format you asked for — and separates rollouts
    that a binary reward would have declared identical. Rollout 4 is the warning: it is paid for reasoning
    <em>words</em> without doing any reasoning.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-verifier');
  if (!root) return;

  // component keys: think block, answer tag, reasoning language, correct answer
  var ROLLOUTS = [
    {
      id: 'R1', label: 'R1 · correct, tidy',
      text: '<think>\nApril: 48 clips.\nMay: half as many, so 48 / 2 = 24.\nTotal: 48 + 24 = 72.\n</think>\n<answer>72</answer>',
      think: 1, tag: 1, words: 0, correct: 1,
      note: 'Everything you wanted. Under a binary reward this is worth exactly the same as R2 — the shaped reward is the only one that notices it also followed the format.'
    },
    {
      id: 'R2', label: 'R2 · correct, messy',
      text: 'ok so 48 in april and then half of that\n48/2 = 24\n48+24 = 72\n\nthe answer is 72',
      think: 0, tag: 0, words: 0, correct: 1,
      note: 'Right answer, none of the structure. Your parser has to be forgiving enough to find the 72, and this is the rollout that makes shaping tempting in the first place.'
    },
    {
      id: 'R3', label: 'R3 · four right steps, one slip',
      text: '<think>\nApril: 48 clips.\nMay: half as many, therefore 48 / 2 = 24.\nTotal: 48 + 24 = 62.\n</think>\n<answer>62</answer>',
      think: 1, tag: 1, words: 1, correct: 0,
      note: 'Everything correct up to the final addition. Under a binary reward this scores identically to R4, which is nonsense from the first line. That is what "sparse" costs you.'
    },
    {
      id: 'R4', label: 'R4 · nonsense',
      text: 'Therefore, hence, thus we can see that therefore the answer follows.\n\n<answer>7</answer>',
      think: 0, tag: 1, words: 1, correct: 0,
      note: 'And here is the bill for the clever part of your shaping. It says "therefore" and "hence", collects the reasoning-language credit, and has not done a single sum. This is reward hacking in miniature.'
    }
  ];

  var COMPONENTS = [
    { k: 'think',   pts: 0.2, lbl: 'working inside a &lt;think&gt; block' },
    { k: 'tag',     pts: 0.1, lbl: 'answer wrapped in &lt;answer&gt;' },
    { k: 'words',   pts: 0.1, lbl: 'reasoning language ("therefore", "hence")' },
    { k: 'correct', pts: 1.0, lbl: 'final answer == 72' }
  ];

  var pickerEl   = root.querySelector('[data-role="picker"]');
  var outEl      = root.querySelector('[data-role="out"]');
  var ledgerEl   = root.querySelector('[data-role="ledger"]');
  var totalEl    = root.querySelector('[data-role="total"]');
  var noteEl     = root.querySelector('[data-role="note"]');
  var allRowsEl  = root.querySelector('[data-role="allrows"]');
  var distinctEl = root.querySelector('[data-role="distinct"]');
  var spreadEl   = root.querySelector('[data-role="spread"]');
  var spreadBox  = root.querySelector('[data-role="spreadbox"]');
  var rangeBox   = root.querySelector('[data-role="rangebox"]');
  var sparseBtn  = root.querySelector('[data-role="sparse"]');
  var shapedBtn  = root.querySelector('[data-role="shaped"]');

  var current = 0;
  var shaped = false;

  function esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

  function score(r) {
    if (!shaped) return r.correct * 1.0;
    return COMPONENTS.reduce(function (a, c) { return a + (r[c.k] ? c.pts : 0); }, 0);
  }

  var pickBtns = ROLLOUTS.map(function (r, i) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'btn';
    b.textContent = r.id;
    b.setAttribute('aria-pressed', 'false');
    b.addEventListener('click', function () { current = i; render(); });
    pickerEl.appendChild(b);
    return b;
  });

  function render() {
    var r = ROLLOUTS[current];

    sparseBtn.setAttribute('aria-pressed', shaped ? 'false' : 'true');
    shapedBtn.setAttribute('aria-pressed', shaped ? 'true' : 'false');
    pickBtns.forEach(function (b, i) {
      b.setAttribute('aria-pressed', i === current ? 'true' : 'false');
    });

    outEl.textContent = r.text;

    ledgerEl.innerHTML = COMPONENTS.map(function (c) {
      var counts = shaped || c.k === 'correct';
      var hit = !!r[c.k];
      var cls = 'ledger__line' + (counts ? '' : ' ledger__line--off');
      var val = counts ? (hit ? '+' + c.pts.toFixed(1) : '0.0') : 'ignored';
      return '<div class="' + cls + '">' +
        '<span class="lbl">' + (hit ? '✔ ' : '✘ ') + c.lbl + '</span>' +
        '<span class="dots"></span>' +
        '<span class="val">' + val + '</span></div>';
    }).join('') +
      '<div class="ledger__line ledger__line--sum"><span class="lbl">reward</span>' +
      '<span class="dots"></span><span class="val">' + score(r).toFixed(1) + '</span></div>';

    totalEl.innerHTML = 'reward(' + r.id + ') = <b>' + score(r).toFixed(1) + '</b>';
    noteEl.textContent = r.note;

    var scores = ROLLOUTS.map(score);
    var max = Math.max.apply(null, scores);
    var min = Math.min.apply(null, scores);

    allRowsEl.innerHTML =
      '<div class="group__head"><span>id</span><span>what it did</span><span>reward</span>' +
      '<span>relative</span><span></span></div>' +
      ROLLOUTS.map(function (rr, i) {
        var s = scores[i];
        var w = max > 0 ? (s / max) * 100 : 0;
        return '<div class="roll ' + (s === max && max > min ? 'roll--win' : s === min && max > min ? 'roll--lose' : '') + '">' +
          '<span class="roll__id">' + rr.id + '</span>' +
          '<span class="roll__what">' + esc(rr.label.replace(/^R\d · /, '')) + '</span>' +
          '<span class="roll__reward">' + s.toFixed(1) + '</span>' +
          '<span class="diverge"><span class="diverge__fill diverge__fill--pos" style="left:0;width:' +
            w.toFixed(0) + '%"></span></span>' +
          '<span class="roll__adv"></span></div>';
      }).join('');

    var uniq = scores.filter(function (v, i, a) { return a.indexOf(v) === i; }).length;
    distinctEl.textContent = uniq + ' of 4';
    spreadEl.textContent = (max - min).toFixed(1);
    spreadBox.className = 'stat' + (uniq <= 2 ? ' stat--dead' : ' stat--hot');
    rangeBox.className = 'stat' + (uniq <= 2 ? ' stat--dead' : ' stat--hot');
  }

  sparseBtn.addEventListener('click', function () { shaped = false; render(); });
  shapedBtn.addEventListener('click', function () { shaped = true; render(); });

  render();
})();
</script>

<p>See one challenge with this scoring mechanism - the answer is either correct, or not. It’s only binary. A model can reason in the right direction, or perform actions in the right direction, make a mistake at the last step and it’s still a zero score. This is called <strong>sparse reward</strong>: it’s all-or-nothing.</p>

<p>You can make a sparse reward denser, by handing out partial credit for getting parts of it right. This is called <strong>reward shaping</strong>. These can be more narrow checks like following the right format you expect or calling tools that you would expect to be called.</p>

<p>During the exercises in the HuggingFace and DeepLearning.AI courses you also experiment with rewarding content indications that the model is going in the right direction. Say 0.1 points if the answer uses reasoning words like “therefore” or “hence”, 0.1 points if it contains actual mathematical operations. There is an inherent risk to that, called <strong>reward hacking</strong>, that we will dive into later in the post — and you can already see it in rollout R4 above. But it’s a good example to visualise the technique, and it did give positive results during the course exercises.</p>

<p>Before we go further, let’s align on some nomenclature that will come up from now on.</p>

<figure class="fig">
  <span class="fig__label">Figure 5</span>
  <div class="fig__frame">
    <p class="fig__title">One prompt, eight attempts, one training example</p>

    <div class="flow" style="margin-bottom:1.2rem">
      <div class="flow__box flow__box--in">
        <span class="flow__kind">1 row of your data</span>
        <span class="flow__name">Prompt</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--act">
        <span class="flow__kind">Generate ×8</span>
        <span class="flow__name">Rollouts</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--out">
        <span class="flow__kind">One number each</span>
        <span class="flow__name">Rewards</span>
      </div>
    </div>

    <p class="fig__eyebrow">The group</p>
    <div class="tok-row">
      <span class="tok tok--completion">1.0</span>
      <span class="tok tok--prompt">0.0</span>
      <span class="tok tok--completion">1.0</span>
      <span class="tok tok--prompt">0.0</span>
      <span class="tok tok--prompt">0.0</span>
      <span class="tok tok--completion">1.0</span>
      <span class="tok tok--prompt">0.0</span>
      <span class="tok tok--prompt">0.0</span>
    </div>

    <div class="annot" style="margin-top:1.3rem">
      <div class="annot__row">
        <span class="annot__sym">prompt</span>
        <span>One input from your dataset. Nothing else in the row is required.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym annot__sym--hot">rollout</span>
        <span>One output the model generates for that prompt. If the model is an agent making tool calls,
          the entire trace — every step, every tool result, all the way to the end — is <em>one</em>
          rollout.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym">reward</span>
        <span>The single number attached to a finished rollout.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym">group</span>
        <span>All the rollouts from the same prompt. Not eight training examples — one training example
          made of eight attempts.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym">trajectory</span>
        <span>Another word for a rollout. Papers use both interchangeably.</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">Eight is the default in <a href="https://huggingface.co/docs/trl/main/en/grpo_trainer">TRL's GRPO trainer</a> and the number this
    post assumes throughout. Anything from 4 to 16 is common; DeepSeekMath, the paper GRPO comes from,
    sampled 64.</figcaption>
</figure>

<h2 id="training-the-model-with-rl">Training the model with RL</h2>

<p>RL from human preferences is something that end-user-facing labs will do. My bet is that most of the RL training inside software companies will happen around verifiers instead, because there is far more value in agents performing actions and reasoning reliably in your environment than in adjusting their helpfulness or character. But it’s still interesting to know how the frontier and open source models get to where they are.</p>

<p>We look into that later, after walking through the training loop, because the loop is exactly the same for rewards from verifiers and rewards from human feedback. Since we just talked through how to design rewards from verifiers, let’s dive into the training.</p>

<h3 id="what-you-need-for-rl-training">What you need for RL training</h3>

<p><strong>(1) A reward signal.</strong> That’s either a verifier, which we just covered, or a Reward Model trained on human preferences, which we’ll build later in this post.</p>

<p><strong>(2) A training dataset.</strong> You need input prompts that represent the behaviour you want to teach your model. If it’s about reasoning, these prompts need to be reasoning tasks — maths or coding — <strong>with answers that you can verify</strong>. If it’s agentic behaviour navigating your product’s tools, the prompts need to represent your users’ needs and expect those tools to be used — and here too you don’t need the solution, you need <strong>an end state you can check</strong>: the right record got created, the right tool got called with the right arguments, the tests pass. If it’s helpfulness, the prompts need to represent when you expect your model to be more helpful — <strong>but in this case, because you will use a Reward Model trained from human feedback, these prompts don’t come with expected answers at all</strong>.</p>

<p>RL input prompts are the most important thing to get right. You are always trying to teach your model something specific, and you want to make sure the prompts are specific enough.</p>

<p><strong>(3) A training environment.</strong> If it’s just maths reasoning, you only need inference of the model itself. But if you want to teach your model to use tools or interact with a sandbox through the filesystem, you will need to provide a fresh, throwaway environment <strong>for every single rollout</strong>. As you’ll see in a moment, GRPO generates 8 rollouts from each prompt, which means 8 independent environments, and none of them can leak state into another. A lot of software engineering is hidden in creating reliable environments.</p>

<h3 id="the-training-loop">The training loop</h3>

<p>The training loop is basically about going through your input prompts, and each time giving the model a fresh environment to operate in.</p>

<p><strong>(1) Take a prompt from your training dataset and ask the model to generate N rollouts.</strong> This might be a one-shot output, or multi-step tool use navigating your environment with seeded tools, files, or data.</p>

<p>As I said earlier, I am writing about GRPO. The core concept is that you generate N rollouts from the <em>same</em> prompt. Typically that’s somewhere between 4 and 16 — it’s 8 by default in <a href="https://huggingface.co/docs/trl/main/en/grpo_trainer">TRL</a>, and 8 is what this post assumes throughout. I will explain in a second why it matters so much, but it already highlights that RL training is computationally expensive.</p>

<p><strong>(2) Apply the reward to each rollout.</strong> If you are using a verifier, parse the output and score it — checking the final answer to a maths problem, running the code to see whether the tests pass, counting which tools have been called, or checking that the output has the structure you asked for. If you are using a Reward Model, run the output through it.</p>

<p><strong>(3) Average the rewards across the group.</strong> This is your <strong>baseline</strong>, and it describes what this model roughly scores on this prompt, given the shape of its weights right now.</p>

<p><strong>(4) For each rollout, subtract the baseline from its reward.</strong> This number is the <strong>advantage</strong>, and it answers a very specific question: <em>compared to how this model usually does on this prompt, was this particular attempt better or worse?</em> Positive advantage means this rollout was above the model’s own average. Negative means below it. We also divide by the standard deviation of the group, so that a prompt where all 8 rollouts scored between 0.42 and 0.48 produces advantages on a similar scale to a prompt where they scored between 0 and 1.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>advantage = (reward - group_mean) / group_std
</code></pre></div></div>

<figure class="fig fig--wide" id="fig-adv">
  <span class="fig__label">Figure 6 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">The group grades itself</p>
    <p class="fig__note">Eight rollouts of one prompt. The baseline is their own average — nothing else is
      consulted. Pick a scenario and watch what survives into the advantage column.</p>

    <div class="presets" data-role="presets"></div>

    <div class="group" data-role="rows"></div>

    <div class="stats">
      <div class="stat"><span class="stat__k">Baseline (mean)</span><span class="stat__v" data-role="mean">—</span></div>
      <div class="stat"><span class="stat__k">Group std</span><span class="stat__v" data-role="std">—</span></div>
      <div class="stat" data-role="gradbox"><span class="stat__k">Gradient</span><span class="stat__v" data-role="grad">—</span></div>
    </div>

    <div class="strip">
      <span class="strip__icon" data-role="icon">🔍</span>
      <p data-role="note"></p>
    </div>
  </div>
  <figcaption class="fig__cap"><code>advantage = (reward &minus; group_mean) / group_std</code>. Notice how
    little of the original reward survives that: not its units, not its scale, not its absolute size. Only
    the <strong>ordering</strong> and the <strong>relative gaps</strong> make it through.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-adv');
  if (!root) return;

  var SCENARIOS = [
    {
      k: 'Mixed', r: [1, 0, 1, 0, 0, 1, 0, 0],
      note: 'The useful case. Three rollouts landed, five did not, and the group has something to rank. This prompt is at the right difficulty for this model right now — which is a property of the pair, not of the prompt alone.'
    },
    {
      k: '1000× the reward', r: [1000, 0, 1000, 0, 0, 1000, 0, 0],
      note: 'Same scenario, a verifier handing out 1000 points instead of 1. Every advantage is identical to the last scenario, to the decimal. This is why the absolute value of your reward never matters — only that it is consistent across the run.'
    },
    {
      k: 'All correct', r: [1, 1, 1, 1, 1, 1, 1, 1],
      note: 'The model has outgrown this prompt. Every reward equals the mean, every advantage is zero, the loss is zero and no weight moves. You just paid for eight generations and bought nothing. Too-easy prompts are pure inference bill.'
    },
    {
      k: 'All failed · binary', r: [0, 0, 0, 0, 0, 0, 0, 0],
      note: 'The same dead end from the other side. A prompt the model never gets right teaches it exactly as little as one it always gets right. Both are wasted compute — which is the whole argument for curating prompts by difficulty.'
    },
    {
      k: 'All failed · shaped', r: [0.3, 0.1, 0.4, 0.0, 0.3, 0.2, 0.1, 0.3],
      note: 'Same eight failures, scored by the shaped reward from Figure 4. Nobody got the answer, but one of them got the format right and another got three steps in — so the group has an ordering again, and the prompt teaches something instead of being thrown away. This is the real argument for reward shaping.'
    },
    {
      k: 'Tight cluster', r: [0.44, 0.46, 0.45, 0.48, 0.42, 0.47, 0.43, 0.45],
      note: 'Every rollout scored between 0.42 and 0.48 — raw differences of hundredths. Dividing by the group standard deviation rescales them to roughly the same range as any other prompt, so this prompt gets a fair say in the update instead of being drowned out by a prompt whose rewards happened to span 0 to 1.'
    }
  ];

  var presetsEl = root.querySelector('[data-role="presets"]');
  var rowsEl    = root.querySelector('[data-role="rows"]');
  var meanEl    = root.querySelector('[data-role="mean"]');
  var stdEl     = root.querySelector('[data-role="std"]');
  var gradEl    = root.querySelector('[data-role="grad"]');
  var gradBox   = root.querySelector('[data-role="gradbox"]');
  var noteEl    = root.querySelector('[data-role="note"]');
  var iconEl    = root.querySelector('[data-role="icon"]');

  var current = 0;

  var btns = SCENARIOS.map(function (s, i) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'btn';
    b.textContent = s.k;
    b.setAttribute('aria-pressed', 'false');
    b.addEventListener('click', function () { current = i; render(); });
    presetsEl.appendChild(b);
    return b;
  });

  function fmt(x) {
    return Math.abs(x) >= 100 ? x.toFixed(0) : x.toFixed(2);
  }

  function render() {
    var s = SCENARIOS[current];
    btns.forEach(function (b, i) { b.setAttribute('aria-pressed', i === current ? 'true' : 'false'); });

    var n = s.r.length;
    var mean = s.r.reduce(function (a, b) { return a + b; }, 0) / n;
    var variance = s.r.reduce(function (a, b) { return a + (b - mean) * (b - mean); }, 0) / n;
    var std = Math.sqrt(variance);
    var dead = std < 1e-9;

    var adv = s.r.map(function (x) { return dead ? 0 : (x - mean) / std; });
    var maxAbs = Math.max(1e-9, Math.max.apply(null, adv.map(Math.abs)));

    rowsEl.innerHTML =
      '<div class="group__head"><span>id</span><span>rollout</span><span>reward</span>' +
      '<span>advantage</span><span>value</span></div>' +
      s.r.map(function (x, i) {
        var a = adv[i];
        var half = (Math.abs(a) / maxAbs) * 50;
        var cls = a > 1e-9 ? 'roll--win' : a < -1e-9 ? 'roll--lose' : '';
        var bar = Math.abs(a) < 1e-9 ? '' :
          '<span class="diverge__fill diverge__fill--' + (a > 0 ? 'pos' : 'neg') + '" style="left:' +
            (a > 0 ? 50 : 50 - half).toFixed(2) + '%;width:' + half.toFixed(2) + '%"></span>';
        var vcls = a > 1e-9 ? '' : a < -1e-9 ? 'neg' : 'zero';
        return '<div class="roll ' + cls + '">' +
          '<span class="roll__id">R' + (i + 1) + '</span>' +
          '<span class="roll__what">' + (x > mean ? 'better than this model usually does' :
                                          x < mean ? 'worse than this model usually does' :
                                          'exactly average') + '</span>' +
          '<span class="roll__reward">' + fmt(x) + '</span>' +
          '<span class="diverge"><span class="diverge__zero"></span>' + bar + '</span>' +
          '<span class="roll__adv"><b class="' + vcls + '">' + (a >= 0 ? '+' : '') + a.toFixed(2) +
          '</b></span></div>';
      }).join('');

    meanEl.textContent = fmt(mean);
    stdEl.textContent = fmt(std);
    gradEl.textContent = dead ? 'none' : 'yes';
    gradBox.className = 'stat ' + (dead ? 'stat--dead' : 'stat--hot');
    iconEl.textContent = dead ? '🪦' : '🔍';
    noteEl.textContent = s.note;
  }

  render();
})();
</script>

<p>Why bother with a baseline at all? Because without it, we would only ever be pushing the model towards what it just did. If every reward is positive, every rollout gets reinforced — the good ones a bit more, the bad ones a bit less, but everything moves in the same direction. That still points roughly the right way on average, but it is an incredibly noisy way to get there. Subtracting the average is what lets us say “this one, more” and “that one, less” in the same update.</p>

<p>Notice that the absolute values of the reward has now disappeared. The only thing that stays consistently after this operation are gaps (differences) between these rewards. That’s why I said earlier that absolute rewards don’t matter as long as they’re consistent within the run.</p>

<p>This is finally the answer to why we generate N rollouts instead of one. We need something to compare against. PPO trains a whole separate model, a “critic”, whose only job is to predict what reward to expect for a given prompt — which means another model to train, tune and keep in memory. GRPO throws that away and does something much simpler: generate a group, use the group’s own average as the baseline. That is literally what “Group Relative” in Group Relative Policy Optimization means. You pay for it with 8× the generation, but you don’t need another model at all, which means less complexity and, most of all, less memory.</p>

<p>There is also a significant consequence here for what kind of prompts provide best value for buck. If all 8 rollouts get exactly the same reward — all of them solved the problem, or all of them failed — then the group average equals every individual reward, every advantage is zero, and this prompt teaches your model absolutely nothing. You only lost money on compute. That’s why a lot of researcher instinct is in what kind of prompts to include in RL training set. Ideally those that your model sometimes gets right, sometimes wrong, and you want to make sure that it gets those right ones more often.</p>

<p>This is also the strong reason to apply reward shaping. Notice that the reward is still one number per finished rollout — shaping doesn’t feed the model anything mid-generation, it just stops the score being all-or-nothing. What that buys you is <strong>spread inside the group</strong>. On a hard prompt where all 8 rollouts fail, a binary reward gives you eight zeros and zero gradient. A shaped reward separates them — this one at least got the format right, that one got three steps in — and suddenly the group has something to rank, so the prompt still teaches the model something instead of being thrown away.</p>

<p><strong>(5) Now calculate the loss.</strong> In SFT we handed the model an expected answer and nudged its weights to make that answer more likely. We never let it generate. Here we do the opposite — we let the model generate, and then we nudge its weights to make what it generated more likely, or less likely, depending on the sign of the advantage for that specific rollout.</p>

<p>So we take each rollout, run it again through the model in a forward pass (exactly like SFT — the tokens are already fixed from the rollout, we just want to know how likely the model was to produce them), and get the log probability of each token it generated. Then we multiply by that rollout’s advantage:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loss_for_rollout_k = -advantage_k * (sum of log probs of its tokens) / number_of_tokens_k
</code></pre></div></div>

<p>That’s it. If the advantage is positive, minimising this loss pushes the probability of those exact tokens up — the model becomes more likely to produce that kind of output next time. If the advantage is negative, the sign flips and the same machinery pushes those tokens down. If the advantage is zero, the whole term is zero and nothing moves.</p>

<p>We divide by the number of tokens so that a long, rambling output doesn’t get a bigger weight update just for being long.</p>

<p>This is somewhat simplified form. Real GRPO implementations wrap it in something called <strong>clipping</strong>, which exists because in practice you reuse the same batch of rollouts for a few gradient steps, and the model doing the updating slowly drifts away from the model that generated them. I am skipping this here as this is not that important to build instinct on how RL works.</p>

<p>That formula is for a single rollout, and we generated 8 of them. Each one has its own advantage, and each one produces its own loss. To get the loss for this prompt, we average across the group:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>         1    N
loss  =  ─ ·  Σ   loss_for_rollout_k
         N   k=1

where N = 8, the number of rollouts in the group
</code></pre></div></div>

<p>The group is not 8 training examples — it is one training example made of 8 attempts.</p>

<p>Which gives us the shortest possible summary of what RL actually is: <strong>it’s SFT on the model’s own output, where each sample is weighted by how much better or worse it was than the model’s average attempt.</strong></p>

<p><strong>(6) There’s one problem left.</strong> If you just run steps 1–5 over and over, the model will happily optimise for higher rewards while quietly losing everything it learned during SFT or previous RL runs — it drifts towards whatever scores well and away from being a decent model in general. This is the same <strong>catastrophic forgetting</strong> from the SFT post, arriving by a different route.</p>

<p>The standard fix is the <strong>KL penalty</strong>. You keep a frozen copy of the model you started from — the <strong>reference model</strong> — and add a term to the loss that charges the model for moving too far from what the reference would have said. A parameter called <strong>beta</strong> sets the price. Too tight and your model can’t learn anything new; too loose and it wanders off somewhere that scores beautifully and reads terribly. The nice property is that it’s cheap: no extra generation is involved, just a second forward pass over tokens you already have. Keep in mind that forward pass is different than generating rollout, and generally is pretty cheap compared with generating a rollout. The details of how that distance actually gets measured are more maths than this post needs, so I’m leaving them for you to dig into.</p>

<figure class="fig" id="fig-kl">
  <span class="fig__label">Figure 7 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">Beta is the price of drifting</p>
    <p class="fig__note">A frozen copy of the model you started from is the reference. The KL term charges
      the model for moving away from what that reference would have said. Move the slider.</p>

    <div class="ctl">
      <span class="ctl__label">Beta</span>
      <input class="slider" type="range" min="0" max="5" value="2" step="1" data-role="beta" aria-label="KL penalty coefficient beta" />
      <span class="ctl__value" data-role="betaval">0.01</span>
      <span class="chip" data-role="verdict">balanced</span>
    </div>

    <div class="track">
      <span class="track__mark" data-role="mark" style="left:55%"></span>
    </div>
    <div class="track__ends">
      <span>← stays the model you trained</span>
      <span>chases the reward →</span>
    </div>

    <hr class="fig__hr" />

    <div class="pane" style="border-width:2px">
      <div class="pane__head pane__head--flat">What it now says to <em>"summarise this in one line"</em></div>
      <div class="pane__body">
        <p class="pane__out" data-role="out"></p>
      </div>
    </div>

    <div class="stats">
      <div class="stat"><span class="stat__k">Mean reward</span><span class="stat__v" data-role="reward">—</span></div>
      <div class="stat"><span class="stat__k">Drift from reference</span><span class="stat__v" data-role="drift">—</span></div>
    </div>

    <div class="strip">
      <span class="strip__icon" data-role="icon">🔍</span>
      <p data-role="note"></p>
    </div>
  </div>
  <figcaption class="fig__cap">The reward curve and the usefulness curve come apart somewhere in the middle
    of that slider, and where exactly is an experiment, not a formula. The cheap part is that no extra
    generation is involved — the reference only needs a second forward pass over tokens you already have.
    The expensive part is that you are now holding two copies of the model in memory.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-kl');
  if (!root) return;

  var STEPS = [
    { b: '0', drift: 100, reward: 100, verdict: 'unleashed', cls: 'chip--warn', icon: '🏃',
      out: 'Summary summary summary. In summary: therefore, in summary, the summary is summarised. Score: 10/10.',
      note: 'No reference model at all — this is TRL’s current default, and it is a reasonable one for a short run against a verifier that is genuinely hard to fake. Against a reward model it is how you end up here.' },
    { b: '0.001', drift: 82, reward: 97, verdict: 'loose', cls: 'chip--warn', icon: '👀',
      out: 'The document argues for X. The document argues for X. Key point: X. (Confidence: high.)',
      note: 'A very light touch — the setting DeepSeek used for R1. Enough to stop the model falling apart, not enough to stop it developing tics. Fine when your reward is a verifier and you are watching outputs.' },
    { b: '0.01', drift: 55, reward: 88, verdict: 'balanced', cls: 'chip--calm', icon: '🔍',
      out: 'The document makes the case for X, mainly on cost grounds, and concedes one risk in passing.',
      note: 'The zone you are usually aiming for. The model has clearly learned something — it is more direct and better calibrated than the reference — and it still writes like a language model.' },
    { b: '0.04', drift: 32, reward: 76, verdict: 'balanced', cls: 'chip--calm', icon: '🔍',
      out: 'The document makes a cost-based case for X and notes one risk.',
      note: 'The value used in the original GRPO paper. Conservative: you keep almost all of the reference model’s behaviour and buy a smaller, safer improvement. A good place to start a first run.' },
    { b: '0.2', drift: 12, reward: 44, verdict: 'tight', cls: 'chip--dropped', icon: '🪢',
      out: 'The document discusses X, including several considerations relevant to the decision at hand.',
      note: 'The leash is now shorter than the distance the model needs to travel. Reward barely moves across the whole run, and you will spend a day wondering why training "does not work".' },
    { b: '1.0', drift: 3, reward: 14, verdict: 'frozen', cls: 'chip--dropped', icon: '🧊',
      out: 'The document discusses X and presents information about the topic being discussed.',
      note: 'Effectively no training at all. Any move away from the reference costs more than the reward it earns, so the optimiser correctly decides to do nothing.' }
  ];

  var slider  = root.querySelector('[data-role="beta"]');
  var betaVal = root.querySelector('[data-role="betaval"]');
  var verdict = root.querySelector('[data-role="verdict"]');
  var mark    = root.querySelector('[data-role="mark"]');
  var outEl   = root.querySelector('[data-role="out"]');
  var rewardEl= root.querySelector('[data-role="reward"]');
  var driftEl = root.querySelector('[data-role="drift"]');
  var noteEl  = root.querySelector('[data-role="note"]');
  var iconEl  = root.querySelector('[data-role="icon"]');

  function render() {
    var s = STEPS[parseInt(slider.value, 10)];
    betaVal.textContent = s.b;
    verdict.textContent = s.verdict;
    verdict.className = 'chip ' + s.cls;
    mark.style.left = s.drift + '%';
    outEl.textContent = s.out;
    rewardEl.textContent = s.reward;
    driftEl.textContent = s.drift + '%';
    noteEl.textContent = s.note;
    iconEl.textContent = s.icon;
  }

  slider.addEventListener('input', render);
  render();
})();
</script>

<p>Worth flagging that this is one of the places where practice has moved: TRL now ships with <code class="language-plaintext highlighter-rouge">beta=0.0</code> by default, on the grounds that for verifiable rewards over short runs the KL term costs memory and buys little. That is a defensible default for maths and code, and a much less defensible one when your reward is a model trained on human preferences.</p>

<p>Also worth noting that at this point you are holding the model you are training, a frozen reference copy of it, and possibly a reward model, all in memory at once — while also running generation. This is the real reason RL is so much more expensive than SFT.</p>

<h2 id="reward-hacking--the-thing-that-will-actually-go-wrong">Reward hacking — the thing that will actually go wrong</h2>

<p>Let’s get back to our dense reward example from before, where we gave partial credit for using reasoning language in mathematical prompts. The inherent risk is that the model learns to repeat those reasoning words and collect the reward, instead of actually reasoning. That was rollout R4 in Figure 4, and it is not a contrived example — it is the first thing that happens.</p>

<p>This is <strong>reward hacking</strong>: the model optimises the thing you measured instead of the thing you meant, and it is very good at finding the gap between the two. It’s basically Goodhart’s law of Machine Learning.</p>

<p>Rewards from verifiers are the safest ground here, because “did the test suite pass” is hard to fake. There are far more variables in rewards from human feedback, and it can lead to models becoming sycophantic (remember GPT-4o?), repetitive, or too constrained.</p>

<p>The KL penalty from step (6) is a partial defence, in that it limits how far the model can go chasing the reward. The real defences are boring: look at the outputs, and evaluate with something other than the thing you trained on. Which brings us to the next section.</p>

<h2 id="how-do-you-know-it-worked">How do you know it worked?</h2>

<p>In SFT you can mostly watch the loss go down. In RL, we track the mean reward over time. However, mean reward over time is an indication of the model getting better at getting our rewards, not necessarily of the model getting better for the users. Those are the same thing only if your reward function is perfect, and it isn’t.</p>

<p>So the eval setup needs:</p>

<ul>
  <li><strong>A held-out set of prompts</strong>, never trained on, scored the same way. It catches ordinary overfitting similarly to verfication set in SFT.</li>
  <li><strong>An eval that doesn’t use your reward function at all.</strong> If you trained against a verifier, evaluate with a benchmark, an LLM judge, or humans reading outputs. This is the one that catches reward hacking.</li>
  <li><strong>A general capability eval with nothing to do with your task</strong>, to catch the model getting worse at everything else while it gets better at yours.</li>
  <li><strong>Actually reading the outputs.</strong> Most reward hacks are instantly visible to a human and completely invisible in a metric.</li>
</ul>

<h2 id="reward-from-human-feedback--training-a-reward-model">Reward from human feedback — training a reward model</h2>

<p>Let’s say your goal is to make the model more helpful, or more enjoyable to use.</p>

<figure class="fig">
  <span class="fig__label">Figure 8</span>
  <div class="fig__frame">
    <p class="fig__title">Both of these are correct</p>
    <p class="fig__note">Same prompt, same base weights, one of them has been through RL against a reward
      model. Nothing in the left-hand answer is wrong.</p>

    <div class="duo duo--top">
      <div class="pane">
        <div class="pane__head pane__head--cold">After SFT</div>
        <div class="pane__body">
          <p class="pane__prompt">My manager keeps rescheduling our 1:1s. How do I bring it up without sounding like I'm complaining?</p>
          <p class="pane__out">There are several approaches you could consider:

1. Communicate openly and honestly
2. Use "I" statements
3. Choose an appropriate time and place
4. Focus on solutions rather than problems
5. Be respectful of their schedule

It's important to remember that managers are often very busy.</p>
        </div>
        <div class="pane__foot">Complete, safe, and no use to anyone. It has answered the category of question
          rather than the question.</div>
      </div>

      <div class="pane">
        <div class="pane__head pane__head--warm">After RL</div>
        <div class="pane__body">
          <p class="pane__prompt">My manager keeps rescheduling our 1:1s. How do I bring it up without sounding like I'm complaining?</p>
          <p class="pane__out">Don't frame it as a complaint — frame it as a scheduling problem you'd like their help solving.

"I've noticed our 1:1s have moved a few times recently. Is there a slot that would be more reliable for you? I'd rather have a shorter one that holds than a long one we keep pushing."

That gives them an easy yes and doesn't ask them to admit anything.</p>
        </div>
        <div class="pane__foot">Specific, usable, and it hands over an actual sentence. Same knowledge,
          different judgement about what the person wanted.</div>
      </div>
    </div>

    <div class="strip">
      <span class="strip__icon">🤷</span>
      <p>Now try to write the verifier. There is no number to parse, no test suite to run, no end state to
        assert on — and no single right answer to compare against, because there are dozens of good replies
        here and they disagree with each other. <strong>This is the case that forces you to train a reward
        model.</strong></p>
    </div>
  </div>
</figure>

<p>How do we score rollouts like these? There’s no answer to regex out, no test suite to run. And if you employed humans to score them live during training, that would be prohibitively expensive, slow and unreliable — a human would definitely have a different bar in the first 30 minutes of work than after 6 hours of reading AI outputs (um, what was score 4 again? what was 5?). All of this because, contrary to SFT, the reward has to be applied <em>during</em> training, on output the model has only just generated.</p>

<p>It turns out that LLMs make great reward models, and one LLM can read the generated answer from another and give it a score. The bet we’re making is that judging which of two answers is better is an easier task than writing the better one — so we train that judge first, and then use it to RL the model we actually care about. The previous section on reward hacking is largely about the ways this bet goes wrong.</p>

<p>Training a reward model is still based on actual human preferences, but this time those preferences can be collected asynchronously, ahead of time, which makes it far more tractable than trying to put a human in the training loop. This is the shape <a href="https://arxiv.org/abs/1706.03741">Christiano et al. proposed in 2017</a> and <a href="https://arxiv.org/abs/2203.02155">InstructGPT</a> turned into the standard recipe.</p>

<h3 id="data-preparation-for-a-reward-model">Data preparation for a reward model</h3>

<p>To train a reward model, you need:</p>

<p><strong>(1) An LLM</strong> — often your SFT checkpoint, the same model you’re about to RL — with a scalar <strong>reward head</strong> instead of the usual language modelling head, so that instead of generating the next token, it returns a single number. However, I would expect that many companies figure out how to use smaller base models for reward models (like InstructGPT later mentioned).</p>

<p>What does that really mean? Again, I recommend <a href="/2026-07-27/how-supervised-fine-tuning-actually-works">the SFT post</a> for some basics. I explained there that an LLM produces, for each token position, a vector of logits that can be softmaxed into a probability distribution over the entire vocabulary. So when we ask the model to generate text, it does that, we sample a token from that distribution, and repeat.</p>

<p>If you go one step earlier, it turns out these models are performing multiple layered matrix operations to produce what’s called a <strong>hidden state</strong> for each token, which is later transformed by the <strong>language modelling head</strong> into the logits I mentioned. The reward head does the exact same job with a different output size: it takes that same hidden state and turns it into one number instead of one per token in the vocabulary. Same body, different head. Everything the model already learned about reading text stays exactly where it was, and we only swap the thing sitting on top of it.</p>

<p><strong>(2) A set of training examples.</strong> This is a different training dataset than the one you will use for RL. These consist of an input prompt, a set of different outputs, and a human-picked ranking of those outputs from best to worst. In the early days of ChatGPT you would have seen it generate two outputs and ask which one you preferred. Or you may have used something like LMArena, which shows you outputs from different models and asks you to pick a winner. All of this data can be used to train a reward model.</p>

<p>Before we look at the training loop, let’s talk about preparing the dataset. Say we have an input prompt and 4 outputs ranked by humans. From this data, we create all possible pairs — with 4 outputs, that’s 6 pairs. Each pair is just “this one was preferred, this one was not”.</p>

<figure class="fig fig--wide" id="fig-pairs">
  <span class="fig__label">Figure 9 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">One ranking, many pairs</p>
    <p class="fig__note">A human ranks the answers to a single prompt from best to worst. That ranking is
      the only thing they gave you — no scores — so every pair you can read off it becomes a training
      example.</p>

    <div class="ctl" style="margin-bottom:1rem">
      <span class="ctl__label">Answers ranked</span>
      <input class="slider" type="range" min="2" max="9" value="4" step="1" data-role="k" aria-label="Number of answers ranked per prompt" />
      <span class="ctl__value" data-role="kval">4</span>
    </div>

    <p class="fig__eyebrow">Human ranking, best first</p>
    <div class="rank" data-role="rank"></div>

    <hr class="fig__hr" />

    <p class="fig__eyebrow">Training pairs extracted — <span data-role="npairs">6</span></p>
    <div class="pairgrid" data-role="pairs"></div>

    <div class="readout">
      <span>pairs = K(K&minus;1)/2 = <b data-role="calc">6</b></span>
      <span class="readout__note">InstructGPT's labellers ranked between K=4 and K=9 answers per prompt, so
        a single prompt yielded anywhere from 6 to 36 pairs. It also means the pairs from one prompt are
        heavily correlated, which is why they are trained together in a single batch rather than shuffled
        across the dataset.</span>
    </div>
  </div>
  <figcaption class="fig__cap">Nobody ever said the best answer is worth 9 points and the worst is worth 2.
    All you can extract from an ordering is <strong>"A beats B"</strong> — which turns the whole thing into a
    yes/no question, and that is what makes reward modelling a classification problem.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-pairs');
  if (!root) return;

  var ANSWERS = [
    'Frame it as a scheduling problem and offer them an easier slot.',
    'Ask for a shorter, more reliable recurring slot.',
    'Send a message saying the reschedules are hard to plan around.',
    'Bring it up in your next 1:1, whenever that happens.',
    'Mention it to your skip-level instead.',
    'Wait and see whether it settles down on its own.',
    'Put the meeting back in their calendar yourself.',
    'Stop booking them and rely on Slack.',
    'Say nothing; managers are busy.'
  ];
  var LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'];

  var slider   = root.querySelector('[data-role="k"]');
  var kVal     = root.querySelector('[data-role="kval"]');
  var rankEl   = root.querySelector('[data-role="rank"]');
  var pairsEl  = root.querySelector('[data-role="pairs"]');
  var nPairsEl = root.querySelector('[data-role="npairs"]');
  var calcEl   = root.querySelector('[data-role="calc"]');

  function render() {
    var k = parseInt(slider.value, 10);
    kVal.textContent = k;

    rankEl.innerHTML = ANSWERS.slice(0, k).map(function (a, i) {
      return '<div class="rank__row" style="background:' +
        (i === 0 ? 'var(--accent)' : i === k - 1 ? 'var(--rule-soft)' : 'var(--card)') + '">' +
        '<span class="rank__n">' + LETTERS[i] + '</span>' +
        '<span class="rank__txt">' + a + '</span></div>';
    }).join('');

    var out = [];
    for (var i = 0; i < k; i++) {
      for (var j = i + 1; j < k; j++) {
        out.push('<div class="pairbox"><b>' + LETTERS[i] + '</b> ≻ <s>' + LETTERS[j] + '</s></div>');
      }
    }
    pairsEl.innerHTML = out.join('');
    nPairsEl.textContent = out.length;
    calcEl.textContent = out.length;
  }

  slider.addEventListener('input', render);
  render();
})();
</script>

<p>Notice that we did not assign any score to any output. Nobody ever said that the best answer is worth 9 points and the worst one is worth 2. Our humans only gave us an ordering, so all we can extract from it is “A beats B”.</p>

<p>The question we are teaching the model to answer is: <em>given these two answers to the same prompt, which one did the human prefer?</em> That is a yes/no question — first or second — and that’s what makes this a classification problem. In essence, we will be training our base LLM to become a classifier of human preferences.</p>

<h3 id="training-the-reward-model">Training the reward model</h3>

<p>The actual loop is fairly simple and similar to SFT, with one important twist. In SFT we always had a ground truth to compare against — the expected token. Here we don’t. There is no “correct reward” anywhere in our data, only the information that one output beat another. So the loop is built around pairs:</p>

<p><strong>(1) Take one pair</strong> from your dataset — the preferred output and the dispreferred one, both answering the same prompt.</p>

<p><strong>(2) Run both of them through the model</strong> in a forward pass. The model reads the whole prompt-plus-answer sequence, and the reward head takes the hidden state at the very last token — the point where the model has seen everything — and turns it into a single number. Two forward passes, two scalars. Call them <code class="language-plaintext highlighter-rouge">r_preferred</code> and <code class="language-plaintext highlighter-rouge">r_dispreferred</code>.</p>

<p><strong>(3) Subtract them:</strong> <code class="language-plaintext highlighter-rouge">r_preferred - r_dispreferred</code>. This single number is what the entire training is about. If it’s positive, our model agrees with the human. If it’s negative, our model disagrees. If it’s zero, our model has no opinion at all.</p>

<p><strong>(4) Now, some maths.</strong> How do we take that scalar and turn it into a loss? We apply a <strong>sigmoid</strong> to it. The sigmoid returns 0.5 at 0, and asymptotically approaches 0 and 1 at −∞ and +∞. So a difference of zero gives 0.5 — a coin flip, the model telling us it has no idea which one the human liked. Anything positive gets closer to 1; anything negative gets closer to 0.</p>

<p>What the sigmoid gives us is exactly this: <strong>the probability that our model would agree with the human on this pair.</strong></p>

<p><strong>(5) And now we calculate the loss the same way we did in SFT.</strong> We have a probability, and we want it to be as close to 1 as possible, so we take the negative log of it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loss = -log( sigmoid(r_preferred - r_dispreferred) )
</code></pre></div></div>

<p>If the model agrees strongly with the human, the sigmoid is close to 1, the log is close to 0, and the loss is tiny — nothing to fix. If the model got the pair backwards, the sigmoid is close to 0, the negative log explodes, and the loss is huge. Exactly the same shape of feedback as in SFT, just measured on a comparison instead of on an expected token. This is called the <strong>Bradley-Terry</strong> loss.</p>

<figure class="fig fig--wide" id="fig-sigmoid">
  <span class="fig__label">Figure 10 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">From two numbers to one loss</p>
    <p class="fig__note">The reward model reads the prompt plus each answer and returns a single scalar.
      Only their <em>difference</em> ever matters. Move either one.</p>

    <div class="ctl">
      <span class="ctl__label">r&#8202;(preferred)</span>
      <input class="slider" type="range" min="-30" max="30" value="12" step="1" data-role="rp" aria-label="Score for the preferred answer" />
      <span class="ctl__value" data-role="rpval">1.2</span>
    </div>
    <div class="ctl" style="margin-top:.6rem">
      <span class="ctl__label">r&#8202;(rejected)</span>
      <input class="slider" type="range" min="-30" max="30" value="-4" step="1" data-role="rd" aria-label="Score for the rejected answer" />
      <span class="ctl__value" data-role="rdval">-0.4</span>
    </div>

    <div class="curve" style="margin-top:1.1rem">
      <svg viewBox="0 0 620 250" role="img" aria-label="A sigmoid curve mapping the score difference to a probability between 0 and 1.">
        <!-- plot frame -->
        <line x1="60" y1="220" x2="590" y2="220" stroke="#000" stroke-width="2"></line>
        <line x1="60" y1="20" x2="60" y2="220" stroke="#000" stroke-width="2"></line>
        <!-- p = 0.5 -->
        <line x1="60" y1="120" x2="590" y2="120" stroke="#000" stroke-width="2" stroke-dasharray="5 5" opacity="0.35"></line>
        <text x="52" y="124" font-size="11" text-anchor="end" font-family="'SF Mono', Menlo, monospace">0.5</text>
        <text x="52" y="24" font-size="11" text-anchor="end" font-family="'SF Mono', Menlo, monospace">1.0</text>
        <text x="52" y="224" font-size="11" text-anchor="end" font-family="'SF Mono', Menlo, monospace">0.0</text>
        <!-- diff = 0 -->
        <line x1="325" y1="20" x2="325" y2="228" stroke="#000" stroke-width="2" stroke-dasharray="5 5" opacity="0.35"></line>
        <text x="325" y="243" font-size="11" text-anchor="middle" font-family="'SF Mono', Menlo, monospace">0</text>
        <text x="70" y="243" font-size="11" font-family="'Space Grotesk', sans-serif">model disagrees with the human</text>
        <text x="580" y="243" font-size="11" text-anchor="end" font-family="'Space Grotesk', sans-serif">model agrees</text>
        <!-- the curve -->
        <path data-role="curve" fill="none" stroke="#000" stroke-width="3" stroke-linecap="round"></path>
        <!-- marker -->
        <line data-role="drop" x1="0" y1="0" x2="0" y2="220" stroke="#ff90e8" stroke-width="3"></line>
        <circle data-role="dot" r="8" fill="#ff90e8" stroke="#000" stroke-width="3"></circle>
      </svg>
    </div>

    <div class="stats">
      <div class="stat"><span class="stat__k">Difference</span><span class="stat__v" data-role="diff">—</span></div>
      <div class="stat stat--hot"><span class="stat__k">σ(difference)</span><span class="stat__v" data-role="sig">—</span></div>
      <div class="stat"><span class="stat__k">Loss</span><span class="stat__v" data-role="loss">—</span></div>
    </div>

    <div class="readout">
      <span data-role="eq"></span>
      <span class="readout__note" data-role="note"></span>
    </div>
  </div>
  <figcaption class="fig__cap">σ turns the score difference into <strong>the probability that the model
    would agree with the human on this pair</strong>. From there the loss is the same negative log you met
    in SFT — just measured on a comparison instead of on an expected token. This is the
    <strong>Bradley-Terry</strong> loss, and it is the whole of reward model training.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-sigmoid');
  if (!root) return;

  var X0 = 60, X1 = 590, Y0 = 20, Y1 = 220;   // plot box
  var TMIN = -6, TMAX = 6;                     // difference range drawn

  var rp = root.querySelector('[data-role="rp"]');
  var rd = root.querySelector('[data-role="rd"]');
  var rpVal = root.querySelector('[data-role="rpval"]');
  var rdVal = root.querySelector('[data-role="rdval"]');
  var curve = root.querySelector('[data-role="curve"]');
  var drop  = root.querySelector('[data-role="drop"]');
  var dot   = root.querySelector('[data-role="dot"]');
  var diffEl = root.querySelector('[data-role="diff"]');
  var sigEl  = root.querySelector('[data-role="sig"]');
  var lossEl = root.querySelector('[data-role="loss"]');
  var eqEl   = root.querySelector('[data-role="eq"]');
  var noteEl = root.querySelector('[data-role="note"]');

  function sigmoid(t) { return 1 / (1 + Math.exp(-t)); }
  function px(t) { return X0 + ((t - TMIN) / (TMAX - TMIN)) * (X1 - X0); }
  function py(p) { return Y1 - p * (Y1 - Y0); }

  // draw the curve once
  var d = [];
  for (var i = 0; i <= 120; i++) {
    var t = TMIN + (i / 120) * (TMAX - TMIN);
    d.push((i === 0 ? 'M ' : 'L ') + px(t).toFixed(1) + ' ' + py(sigmoid(t)).toFixed(1));
  }
  curve.setAttribute('d', d.join(' '));

  function render() {
    var a = parseInt(rp.value, 10) / 10;
    var b = parseInt(rd.value, 10) / 10;
    rpVal.textContent = a.toFixed(1);
    rdVal.textContent = b.toFixed(1);

    var t = a - b;
    var p = sigmoid(t);
    var loss = -Math.log(Math.max(p, 1e-12));

    var tc = Math.max(TMIN, Math.min(TMAX, t));
    var x = px(tc), y = py(sigmoid(tc));
    dot.setAttribute('cx', x.toFixed(1));
    dot.setAttribute('cy', y.toFixed(1));
    drop.setAttribute('x1', x.toFixed(1));
    drop.setAttribute('x2', x.toFixed(1));
    drop.setAttribute('y1', y.toFixed(1));

    diffEl.textContent = (t >= 0 ? '+' : '') + t.toFixed(1);
    sigEl.textContent = p.toFixed(3);
    lossEl.textContent = loss.toFixed(3);

    eqEl.innerHTML = '&minus;log σ(' + a.toFixed(1) + ' &minus; ' + b.toFixed(1) + ') = <b>' +
      loss.toFixed(3) + '</b>';

    noteEl.textContent =
      Math.abs(t) < 0.15 ? 'A difference of zero is σ = 0.5 — a coin flip. The model is telling you it has no opinion about which answer the human liked, and the loss is log 2 ≈ 0.693.' :
      t >= 2.5 ? 'Confidently right. σ is close to 1, the log is close to 0, the loss is tiny — there is nothing here to fix, and the gradient reflects that.' :
      t > 0 ? 'Right, but not by much. Real reward models live around here: about 70% agreement with the humans is a normal number, not a broken one.' :
      t <= -2.5 ? 'Confidently wrong. σ is close to 0, the negative log explodes, and this single pair dominates the batch. That is the shape you want — being sure and wrong should cost more than being unsure.' :
      'Wrong, but hesitantly. Loss above log 2, and the update pushes the two scores back past each other.';
  }

  rp.addEventListener('input', render);
  rd.addEventListener('input', render);
  render();
})();
</script>

<p><strong>(6)</strong> We go through backpropagation and gradient calculation, and <strong>(7)</strong> we update the weights of the model to minimise the loss using an optimizer. Both exactly as in SFT.</p>

<h2 id="how-much-data-do-you-actually-need">How much data do you actually need?</h2>

<h3 id="for-the-reward-model">For the reward model</h3>

<p>The published numbers span a wide range, depending on how much you care about the result.</p>

<figure class="fig fig--wide">
  <span class="fig__label">Figure 11</span>
  <div class="fig__frame">
    <p class="fig__title">What people actually collected</p>
    <p class="fig__note">Preference data used to train reward models, from the papers that published
      it. The spread is three orders of magnitude, and it tracks how general the preference is.</p>

    <div class="fig__scroll">
      <table class="reftab">
        <thead>
          <tr><th>Project</th><th>Preference data</th><th>What it was for</th></tr>
        </thead>
        <tbody>
          <tr>
            <td><a href="https://arxiv.org/abs/2203.02155">InstructGPT</a><br /><span style="color:var(--meta)">OpenAI, 2022</span></td>
            <td>33k RM prompts<br />K = 4–9 ranked each</td>
            <td>General instruction-following. At K=4–9 that is 6 to 36 pairs per prompt. The reward model
              itself was 6B — deliberately far smaller than the 175B policy.</td>
          </tr>
          <tr>
            <td><a href="https://huggingface.co/datasets/Anthropic/hh-rlhf">HH-RLHF</a><br /><span style="color:var(--meta)">Anthropic, 2022</span></td>
            <td>161k train<br />8.55k test</td>
            <td>Helpfulness and harmlessness for a general assistant. Still the most-used open preference
              dataset, largely because it is a realistic size to actually work with.</td>
          </tr>
          <tr>
            <td><a href="https://arxiv.org/abs/2307.09288">Llama 2</a><br /><span style="color:var(--meta)">Meta, 2023</span></td>
            <td>1,418,091 Meta<br />2,919,326 total</td>
            <td>Two separate reward models — helpfulness and safety — fed by weekly batches of annotation
              and retrained as the data arrived, across five successive RLHF rounds. Each new policy shifted
              the distribution of outputs away from what the previous reward model had been trained on.</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
  <figcaption class="fig__cap">Read the spread as a statement about scope, not quality. Llama 2's number is
    what "reliable across everything a general assistant does, for a year" costs. If what you need is
    <em>does this answer follow our support tone</em>, you are at the top of this table, not the bottom.</figcaption>
</figure>

<p>The useful way to read that spread: a reward model for a narrow, well-defined preference is a much smaller project than a reward model for general helpfulness. If you only need “does this answer follow our support tone”, you are closer to the low end than the high end.</p>

<p>InstructGPT’s reward model was <strong>6B parameters against a 175B policy (policy means the actual LLM being trained)</strong>. And Llama 2 collected its annotations in weekly batches and retrained its reward models as the data arrived, across five successive RLHF rounds — because each new policy shifted the distribution of outputs away from what the previous reward model had been trained on. Reward models go stale, and they go stale because your training is working.</p>

<h3 id="for-the-rl-run-itself">For the RL run itself</h3>

<p>The number that matters isn’t prompts, it’s total generations, because that’s what you’re paying for.</p>

<figure class="fig fig--wide" id="fig-cost">
  <span class="fig__label">Figure 12 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">The number you are actually paying</p>
    <p class="fig__note">Prompts are the wrong unit for an RL run. Every prompt is generated from N times,
      every epoch, and generation is the expensive half.</p>

    <div class="presets" data-role="presets"></div>

    <div class="calc">
      <div class="ctl">
        <span class="ctl__label">Prompts</span>
        <input class="slider" type="range" min="0" max="12" value="6" step="1" data-role="p" aria-label="Number of prompts in the RL dataset" />
        <span class="ctl__value" data-role="pval">10,000</span>
      </div>
      <div class="ctl">
        <span class="ctl__label">Rollouts each</span>
        <input class="slider" type="range" min="0" max="6" value="3" step="1" data-role="g" aria-label="Rollouts generated per prompt" />
        <span class="ctl__value" data-role="gval">8</span>
      </div>
      <div class="ctl">
        <span class="ctl__label">Epochs</span>
        <input class="slider" type="range" min="1" max="4" value="2" step="1" data-role="e" aria-label="Passes over the dataset" />
        <span class="ctl__value" data-role="eval">2</span>
      </div>

      <div class="calc__out">
        <b data-role="total">160,000</b>
        full generations
      </div>

      <div class="stats">
        <div class="stat"><span class="stat__k">Concurrent environments</span><span class="stat__v" data-role="env">8</span></div>
        <div class="stat"><span class="stat__k">Tokens generated ≈</span><span class="stat__v" data-role="tok">96M</span></div>
        <div class="stat"><span class="stat__k">Weight updates</span><span class="stat__v" data-role="steps">20,000</span></div>
      </div>
    </div>

    <div class="strip">
      <span class="strip__icon">💸</span>
      <p data-role="note"></p>
    </div>
  </div>
  <figcaption class="fig__cap">The "concurrent environments" figure is the one that catches software teams
    out. If your rollouts touch a filesystem, a database or your own product's API, you need that many
    isolated, seeded, throwaway environments standing up and tearing down for <em>every prompt</em>, with no
    state leaking between them. A lot of the engineering in an RL project is there, not in the training loop.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-cost');
  if (!root) return;

  var PROMPTS  = [100, 250, 500, 1000, 2500, 5000, 10000, 25000, 30000, 50000, 100000, 144000, 250000];
  var ROLLOUTS = [1, 2, 4, 8, 16, 32, 64];
  var TOKENS_PER_ROLLOUT = 600;

  var PRESETS = [
    { k: 'A first experiment', p: 3, g: 3, e: 1,
      note: 'A thousand prompts, eight rollouts, one pass. Eight thousand generations is a run you can babysit on a single box — and it is enough to tell you whether your reward function is measuring what you think it is. Start here.' },
    { k: 'This post’s example', p: 6, g: 3, e: 2,
      note: 'Ten thousand prompts, eight rollouts, two passes. The 160,000 generations here are the reason people say RL is expensive — and note that not all of them teach the model anything, because every group where all eight rollouts scored the same contributes exactly zero gradient.' },
    { k: 'Tülu 3 RLVR set', p: 8, g: 3, e: 1,
      note: 'AI2’s open RLVR mix is 29,946 prompts — 7,473 from GSM8K, 7,500 from MATH, and 14,973 instruction-following prompts with programmatically checkable constraints. That prompt count is real; the eight rollouts here are this post’s assumption, not theirs.' },
    { k: 'DeepSeekMath GRPO', p: 11, g: 6, e: 1,
      note: 'The paper GRPO comes from: around 144,000 chain-of-thought maths questions, and 64 samples per question. Both numbers are theirs. Nine million generations — and this is the small, well-scoped, verifiable case.' }
  ];

  var pEl = root.querySelector('[data-role="p"]');
  var gEl = root.querySelector('[data-role="g"]');
  var eEl = root.querySelector('[data-role="e"]');
  var pVal = root.querySelector('[data-role="pval"]');
  var gVal = root.querySelector('[data-role="gval"]');
  var eVal = root.querySelector('[data-role="eval"]');
  var totalEl = root.querySelector('[data-role="total"]');
  var envEl = root.querySelector('[data-role="env"]');
  var tokEl = root.querySelector('[data-role="tok"]');
  var stepsEl = root.querySelector('[data-role="steps"]');
  var noteEl = root.querySelector('[data-role="note"]');
  var presetsEl = root.querySelector('[data-role="presets"]');

  var customNote = 'Every combination on these three sliders is a different bet about where the signal is. More prompts buys coverage; more rollouts per prompt buys a cleaner baseline and fewer dead groups; more epochs buys very little and risks a lot.';
  var activeNote = null;

  function group(n) { return n.toLocaleString('en-US'); }

  function human(n) {
    if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
    if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
    if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k';
    return String(n);
  }

  var btns = PRESETS.map(function (s, i) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'btn';
    b.textContent = s.k;
    b.setAttribute('aria-pressed', 'false');
    b.addEventListener('click', function () {
      pEl.value = s.p; gEl.value = s.g; eEl.value = s.e;
      activeNote = i;
      render();
    });
    presetsEl.appendChild(b);
    return b;
  });

  function render() {
    var p = PROMPTS[parseInt(pEl.value, 10)];
    var g = ROLLOUTS[parseInt(gEl.value, 10)];
    var e = parseInt(eEl.value, 10);

    pVal.textContent = group(p);
    gVal.textContent = g;
    eVal.textContent = e;

    var total = p * g * e;
    totalEl.textContent = group(total);
    envEl.textContent = g;
    tokEl.textContent = human(total * TOKENS_PER_ROLLOUT);
    stepsEl.textContent = group(p * e);

    var match = -1;
    PRESETS.forEach(function (s, i) {
      if (s.p === parseInt(pEl.value, 10) && s.g === parseInt(gEl.value, 10) && s.e === e) match = i;
    });
    if (match !== activeNote) activeNote = match;
    btns.forEach(function (b, i) { b.setAttribute('aria-pressed', i === match ? 'true' : 'false'); });
    noteEl.textContent = match >= 0 ? PRESETS[match].note : customNote;
  }

  [pEl, gEl, eEl].forEach(function (el) { el.addEventListener('input', render); });
  render();
})();
</script>

<p>Two open reference points to anchor against. AI2’s <a href="https://arxiv.org/abs/2411.15124">Tülu 3</a> — the most completely documented open post-training recipe there is — runs its RL-with-verifiable-rewards stage on <a href="https://huggingface.co/datasets/allenai/RLVR-GSM-MATH-IF-Mixed-Constraints">29,946 prompts</a>: 7,473 from GSM8K, 7,500 from MATH, and 14,973 instruction-following prompts with programmatically checkable constraints bolted on. At the other end, <a href="https://arxiv.org/abs/2402.03300">DeepSeekMath</a>, the paper GRPO comes from, ran GRPO over roughly 144,000 chain-of-thought maths questions, sampling 64 outputs per question.</p>

<p>That is a factor of five in prompts and a factor of forty in total generations between two runs that are both, in the scheme of things, narrow and verifiable. Which is the honest summary of this section: there is no rule of thumb here comparable to the SFT ladder. Thirty thousand well-chosen, correctly-difficult prompts beat three hundred thousand that the model either always gets right or never does.</p>

<p>And it is worth remembering that none of these stages happen in isolation. DeepSeek-R1’s pipeline alternates them — a small cold-start SFT, then RL, then ~600k reasoning samples collected by rejection-sampling from that RL checkpoint, mixed with ~200k non-reasoning samples, SFT on all 800k, and then RL again. The two posts describe two halves of one loop, and in a real recipe you go round it more than once.</p>

<h2 id="where-to-go-next">Where to go next</h2>

<p>If you want to actually run this, TRL’s <a href="https://huggingface.co/docs/trl/main/en/grpo_trainer"><code class="language-plaintext highlighter-rouge">GRPOTrainer</code></a> is about as few lines as <a href="https://huggingface.co/docs/trl/main/en/sft_trainer"><code class="language-plaintext highlighter-rouge">SFTTrainer</code></a> was — you supply a dataset of prompts and a Python function that returns a number, and the group generation, the baseline, the advantages and the loss all happen inside <code class="language-plaintext highlighter-rouge">trainer.train()</code>.</p>

<p>If you need the reward model half too, that is <a href="https://huggingface.co/docs/trl/main/en/reward_trainer"><code class="language-plaintext highlighter-rouge">RewardTrainer</code></a>. You hand it a preference dataset of <code class="language-plaintext highlighter-rouge">chosen</code>/<code class="language-plaintext highlighter-rouge">rejected</code> pairs, it swaps in the scalar reward head for you, and it applies exactly the Bradley-Terry loss from the section above.</p>

<p>Which, exactly as with SFT, is why it is worth knowing what they are. The code will not tell you that a group of eight identical rewards taught your model nothing, that your reward function is being gamed, or that your prompts are the wrong difficulty. Those are the failures that actually happen, and every one of them is invisible in the loss curve.</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[A visual, ground-up walkthrough of RL for LLMs — how GRPO turns a group of rollouts into a gradient, why the absolute reward never matters, what reward hacking looks like up close, and how to train a reward model from nothing but human rankings.]]></summary></entry><entry><title type="html">How supervised fine-tuning actually works</title><link href="https://engineering-manager.com/2026-07-27/how-supervised-fine-tuning-actually-works" rel="alternate" type="text/html" title="How supervised fine-tuning actually works" /><published>2026-07-27T00:00:00+00:00</published><updated>2026-07-27T00:00:00+00:00</updated><id>https://engineering-manager.com/2026-07-27/how-supervised-fine-tuning-actually-works</id><content type="html" xml:base="https://engineering-manager.com/2026-07-27/how-supervised-fine-tuning-actually-works"><![CDATA[<p>I spent my garden leave between companies diving deep into how transformer models work — how the architectures differ and how they are actually trained. The best way I know to check whether you understand something is to try to explain it, so this is me explaining supervised fine-tuning to myself, in public. And I already started writing another post about Reinforcement Learning.</p>

<p>Everything I learned comes from two courses, and I would recommend both without reservation: HuggingFace’s <a href="https://huggingface.co/learn/llm-course">LLM Course</a>, which is free, genuinely excellent, and the best explanation of transformers and tokenizers I have found anywhere; and DeepLearning.AI’s <a href="https://www.deeplearning.ai/courses/fine-tuning-and-reinforcement-learning-for-llms-intro-to-post-training">Fine-tuning &amp; RL for LLMs: Intro to Post-training</a>, which expanded specifically on RL and more math and theory behind it. If any part of this post leaves you wanting more depth or try these in practice, those are where to go.</p>

<p>It is written for people with a solid technical background / math understanding but no particular machine learning one. There is some maths, but I have tried to keep it to the parts you can see on the peripherals of the models, rather than actual internals, like how the attention mechanism works, etc.</p>

<h2 id="1-what-a-language-model-actually-does">1. What a language model actually does</h2>

<p>I am not going to dive into transformer internals. There is a lot of nuance in the architectures, and <a href="https://huggingface.co/learn">HuggingFace’s material</a> covers it far better than I would. (Although I will probably write another post about that at some point!)</p>

<p>The short version is that a language model does one thing: it understands, predicts, and ultimately picks the next token.</p>

<p>Predicting the next token means <strong>producing a score</strong> for every single token in the model’s vocabulary - this is called <strong>distribution</strong>. The vocabulary is the fixed list of text chunks a model can read and emit — typically 50,000 to 250,000 of them, and they are fragments rather than whole words, so a real one holds entries like <code class="language-plaintext highlighter-rouge">" the"</code>, <code class="language-plaintext highlighter-rouge">" Berlin"</code>, <code class="language-plaintext highlighter-rouge">" fine"</code>, <code class="language-plaintext highlighter-rouge">"tun"</code>, <code class="language-plaintext highlighter-rouge">"ing"</code> and <code class="language-plaintext highlighter-rouge">"!"</code>.</p>

<p>For computational reasons those scores are not probabilities — they are unbounded floats called <strong>logits</strong>. What the model emits at each position is a <strong>logit vector</strong>: one number per vocabulary entry, so a vector 150,000-odd numbers long. In code you will meet it with a batch and a sequence wrapped around it, as a tensor of shape <code class="language-plaintext highlighter-rouge">[batch, sequence, vocab_size]</code>, but at any single position it is just that one long vector.</p>

<p>To actually generate text, you need to sample from that, and to sample you first need real probabilities. <strong>Softmax</strong> is the transformation that gets you there: it exponentiates every logit and then divides by the total. After softmax, the whole vocabulary sums to 1.</p>

<figure class="fig">
  <span class="fig__label">The formula</span>
  <div class="fig__frame">
    <div class="formula">
      <span class="formula__lhs">P(token<sub>i</sub>)</span>
      <span class="formula__eq">=</span>
      <span class="frac">
        <span class="frac__num"><em>e</em><sup>z<sub>i</sub></sup></span>
        <span class="frac__den">Σ<sub>j</sub> <em>e</em><sup>z<sub>j</sub></sup></span>
      </span>
    </div>

    <div class="annot">
      <div class="annot__row">
        <span class="annot__sym">z<sub>i</sub></span>
        <span>The <strong>logit</strong> the model emitted for token <em>i</em> — a plain, unbounded
          number. It can be negative.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym annot__sym--hot">e<sup>x</sup></span>
        <span>Exponentiation, and the only part that really matters. It makes every value positive, and
          it is why a <em>gap</em> of 2 between two logits always means the same ratio of probabilities,
          whatever the raw numbers are. This is also the knob temperature turns.</span>
      </div>
      <div class="annot__row">
        <span class="annot__sym">Σ<sub>j</sub></span>
        <span>The sum runs over <strong>every token in the vocabulary</strong> — all 150,000 or so of
          them for a real model. Dividing by it is what forces the whole thing to add up to 1.</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">Plain normalisation — just dividing each logit by the total — would also
    make things sum to 1. It is the exponentiation that makes the gaps between logits meaningful, and it
    is the reason this particular formula is the one everyone uses.</figcaption>
</figure>

<figure class="fig" id="fig-sampling">
  <span class="fig__label">Figure 1 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">What the model actually outputs</p>
    <p class="fig__note">A model with a six-word vocabulary, one prompt. Move the temperature to reshape
      the distribution, then draw from it.</p>

    <div class="tok-row" style="margin-bottom:1.1rem">
      <span class="tok tok--prompt">Kuba</span>
      <span class="tok tok--prompt">lives</span>
      <span class="tok tok--prompt">in</span>
      <span class="tok tok--ghost">? ? ?</span>
    </div>

    <div class="dist dist--tagged" data-role="dist"></div>

    <hr class="fig__hr" />

    <div class="ctl">
      <span class="ctl__label">Temperature</span>
      <input class="slider" type="range" min="10" max="200" value="100" step="5" data-role="temp" aria-label="Sampling temperature" />
      <span class="ctl__value" data-role="tempval">1.00</span>
    </div>

    <div class="ctl" style="margin-top:.9rem">
      <button class="btn" type="button" data-role="draw">Draw a token</button>
      <button class="btn" type="button" data-role="reset">Clear</button>
      <span class="ctl__label" style="color:var(--meta)">Greedy always picks the argmax</span>
      <span class="tok tok--special" data-role="greedy">Berlin</span>
    </div>

    <div style="margin-top:.9rem">
      <p class="fig__eyebrow">Draws</p>
      <div class="tok-row" data-role="history">
        <span class="tok tok--ghost">nothing drawn yet</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">The left column is what the model really emits — <strong>logits</strong>, plain
    unbounded numbers. Softmax turns them into probabilities that sum to 1. Turn the temperature down and the
    distribution collapses onto the top token; turn it up and everything flattens toward a coin flip. Note that
    the <em>ordering</em> never changes — temperature only changes how much the gaps matter.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-sampling');
  if (!root) return;

  var VOCAB = [
    { t: 'Berlin', z: 5.1 },
    { t: 'Warsaw', z: 3.2 },
    { t: 'Dublin', z: 2.8 },
    { t: 'a',      z: 2.1 },
    { t: 'the',    z: 1.2 },
    { t: 'London', z: 0.4 }
  ];

  var distEl = root.querySelector('[data-role="dist"]');
  var tempEl = root.querySelector('[data-role="temp"]');
  var tempVal = root.querySelector('[data-role="tempval"]');
  var histEl = root.querySelector('[data-role="history"]');
  var draws = [];

  // Build the rows once, then only mutate the numbers.
  var rows = VOCAB.map(function (v, i) {
    var row = document.createElement('div');
    row.className = 'dist__row' + (i === 0 ? ' dist__row--argmax' : '');
    row.innerHTML =
      '<span class="dist__tok">' + v.t + '</span>' +
      '<span class="dist__logit">' + v.z.toFixed(1) + '</span>' +
      '<span class="dist__track"><span class="dist__bar"></span></span>' +
      '<span class="dist__p">0.000</span>' +
      '<span>' + (i === 0 ? '<span class="dist__tag dist__tag--argmax">argmax</span>' : '') + '</span>';
    distEl.appendChild(row);
    return { bar: row.querySelector('.dist__bar'), p: row.querySelector('.dist__p') };
  });

  function softmax(temp) {
    var max = -Infinity, i;
    for (i = 0; i < VOCAB.length; i++) max = Math.max(max, VOCAB[i].z / temp);
    var ex = VOCAB.map(function (v) { return Math.exp(v.z / temp - max); });
    var sum = ex.reduce(function (a, b) { return a + b; }, 0);
    return ex.map(function (e) { return e / sum; });
  }

  var probs = [];

  function render() {
    var temp = parseInt(tempEl.value, 10) / 100;
    tempVal.textContent = temp.toFixed(2);
    probs = softmax(temp);
    probs.forEach(function (p, i) {
      rows[i].bar.style.setProperty('--w', (p * 100).toFixed(1) + '%');
      rows[i].p.textContent = p.toFixed(3);
    });
  }

  function drawToken() {
    var r = Math.random(), acc = 0, i;
    for (i = 0; i < probs.length; i++) {
      acc += probs[i];
      if (r <= acc) break;
    }
    var picked = VOCAB[Math.min(i, VOCAB.length - 1)].t;
    draws.push(picked);
    if (draws.length > 14) draws.shift();
    histEl.innerHTML = draws.map(function (d) {
      return '<span class="tok' + (d === 'Berlin' ? ' tok--special' : '') + '">' + d + '</span>';
    }).join('');
  }

  tempEl.addEventListener('input', render);
  root.querySelector('[data-role="draw"]').addEventListener('click', drawToken);
  root.querySelector('[data-role="reset"]').addEventListener('click', function () {
    draws = [];
    histEl.innerHTML = '<span class="tok tok--ghost">nothing drawn yet</span>';
  });

  render();
})();
</script>

<p>What you then do with that distribution is a separate choice. You can take the most probable token every time — <strong>greedy decoding</strong> — or you can genuinely draw from it, which is what temperature and top-p are shaping. Those are the knobs you meet on day one of any inference API.</p>

<p>Taking the most probable token has a name worth knowing, because it turns up in the figures below and in every piece of ML writing you will read: the <strong>argmax</strong>. Literally “the argument that maximises” — not the largest probability itself, but <em>which</em> token it belongs to. When a figure marks a row as argmax, it just means “this is the token the model would have picked, if you had asked it to generate here”</p>

<p>Here is the thing to carry forward: <strong>sampling is an inference-time activity</strong>. It plays no part in SFT training. We never need the model to pick anything. We only ever need the distribution — and in a moment you will see why that is enough.</p>

<h2 id="2-what-supervised-fine-tuning-is-for">2. What supervised fine-tuning is for</h2>

<p>The goal of SFT is to teach the model format, conversational ability, and an awareness of who is saying what. Base models do not have this. They do not know that “user” means you and “assistant” means them. Frontier labs fix that by showing the model an enormous number of example conversations, and out the other side comes something that can hold a coherent chat.</p>

<figure class="fig">
  <span class="fig__label">Figure 2</span>
  <div class="fig__frame">
    <p class="fig__title">Same weights, same prompt, different job</p>
    <p class="fig__note">Nothing about the base model is broken here. It is doing exactly what it was
      trained to do — continue the text. It just has no idea that it is supposed to be someone.</p>

    <div class="duo">
      <div class="pane">
        <div class="pane__head pane__head--cold">Base model</div>
        <div class="pane__body">
          <p class="pane__prompt">What's a good way to learn Rust?</p>
          <p class="pane__out">What's the best book for it?
How long does it take to get productive?
Is it worth it in 2026?

I've been writing Go for four years and</p>
        </div>
        <div class="pane__foot">It continued your document — with three more questions someone might plausibly
          have asked next, then a fragment of the post they came from.</div>
      </div>

      <div class="pane">
        <div class="pane__head pane__head--warm">After SFT</div>
        <div class="pane__body">
          <p class="pane__prompt">What's a good way to learn Rust?</p>
          <p class="pane__out">Start with the official Book — it's genuinely good and free. Then rewrite something you've already built in another language, so the borrow checker is the only new variable.</p>
        </div>
        <div class="pane__foot">It answered. It knows there are two parties, that it is the second one, and that
          its turn ends.</div>
      </div>
    </div>

    <div class="strip">
      <span class="strip__icon">🔍</span>
      <p>The knowledge of what a good Rust answer looks like was already in there — the base model read the
        whole internet, including a lot of good Rust advice. What SFT added is the knowledge that
        <strong>right now is the moment to produce one</strong>.</p>
    </div>
  </div>
</figure>

<p>The base model has read millions of dialogues; it already knows what a helpful answer looks like. What it lacks is the knowledge that it should be producing one <em>right now</em>, rather than continuing your text with three more plausible user questions. SFT helps with exactly this.</p>

<p>Keep this in mind when you get to the dataset sizes below. A thousand samples cannot teach a model to converse from scratch. It can absolutely tell it which of its existing behaviours to bring to the front.</p>

<figure class="fig">
  <span class="fig__label">Figure 3</span>
  <div class="fig__frame">
    <p class="fig__title">Where SFT sits</p>
    <div class="flow">
      <div class="flow__box flow__box--in">
        <span class="flow__kind">Input</span>
        <span class="flow__name">Base model</span>
      </div>
      <div class="flow__arrow">+</div>
      <div class="flow__box flow__box--in">
        <span class="flow__kind">Input</span>
        <span class="flow__name">Conversation dataset</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--act">
        <span class="flow__kind">Training</span>
        <span class="flow__name">SFT</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--out">
        <span class="flow__kind">Output</span>
        <span class="flow__name">Fine-tuned model</span>
      </div>
      <div class="flow__arrow">→</div>
      <div class="flow__box flow__box--next">
        <span class="flow__kind">Usually next</span>
        <span class="flow__name">RL</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">In practice this loop runs many times, with different datasets, at different
    stages of a model's life. The picture is the same every time.</figcaption>
</figure>

<p>Essentially every model you actually chat with has been through this. It is the standard path, and in practice it gets repeated many times, with different datasets, at different stages of a model’s life.</p>

<h2 id="3-what-an-sft-dataset-looks-like">3. What an SFT dataset looks like</h2>

<p>Conceptually a dataset is a list of input–output pairs. In practice you will almost never see it stored as raw concatenated strings. It is structured messages — a list of <code class="language-plaintext highlighter-rouge">{"role": ..., "content": ...}</code> objects — and the flattening into a single string happens at tokenization time, via the model’s <strong>chat template</strong>.</p>

<figure class="fig fig--wide" id="fig-dataset">
  <span class="fig__label">Figure 4 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">A dataset row, and what the tokenizer turns it into</p>
    <p class="fig__note">Switch the template to see the same three messages flattened three different ways.
      Pink is structure the template adds; teal is your actual content.</p>

    <div class="duo duo--top">
      <div class="pane">
        <div class="pane__head pane__head--flat">Four rows of the dataset</div>
        <div class="pane__body">
          <div class="samples">
            <div class="sample">
              <span class="sample__n">Row 1</span>
              <div class="msg msg--system"><span class="msg__role">system</span><span class="msg__content">You are terse.</span></div>
              <div class="msg msg--user"><span class="msg__role">user</span><span class="msg__content">Reverse: tic tac</span></div>
              <div class="msg msg--assistant"><span class="msg__role">assistant</span><span class="msg__content">tac tic</span></div>
            </div>
            <div class="sample">
              <span class="sample__n">Row 2</span>
              <div class="msg msg--user"><span class="msg__role">user</span><span class="msg__content">Reverse: cat dog</span></div>
              <div class="msg msg--assistant"><span class="msg__role">assistant</span><span class="msg__content">dog cat</span></div>
            </div>
            <div class="sample">
              <span class="sample__n">Row 3 · multi-turn</span>
              <div class="msg msg--user"><span class="msg__role">user</span><span class="msg__content">Reverse: x y z</span></div>
              <div class="msg msg--assistant"><span class="msg__role">assistant</span><span class="msg__content">z y x</span></div>
              <div class="msg msg--user"><span class="msg__role">user</span><span class="msg__content">Again, but shout.</span></div>
              <div class="msg msg--assistant"><span class="msg__role">assistant</span><span class="msg__content">Z Y X</span></div>
            </div>
            <div class="sample">
              <span class="sample__n">Row 4</span>
              <div class="msg msg--user"><span class="msg__role">user</span><span class="msg__content">Reverse: 1 2 3</span></div>
              <div class="msg msg--assistant"><span class="msg__role">assistant</span><span class="msg__content">3 2 1</span></div>
            </div>
          </div>
        </div>
        <div class="pane__foot">This is how it is stored, and how you will find it on the Hub — a list of
          <span class="tok" style="font-size:.72rem">role</span> /
          <span class="tok" style="font-size:.72rem">content</span> objects. No special tokens anywhere.</div>
      </div>

      <div class="pane">
        <div class="pane__head pane__head--good">Row 1, after <span style="text-transform:none">apply_chat_template()</span></div>
        <div class="pane__body">
          <div class="tabs">
            <button class="btn" type="button" data-tmpl="chatml" aria-pressed="true">ChatML</button>
            <button class="btn" type="button" data-tmpl="llama" aria-pressed="false">Llama 3</button>
            <button class="btn" type="button" data-tmpl="gemma" aria-pressed="false">Gemma</button>
          </div>
          <div class="tmpl" data-role="out"></div>
          <p style="margin:.7rem 0 0;font-size:.84rem;line-height:1.5;color:var(--meta)" data-role="tmplnote"></p>
        </div>
      </div>
    </div>

    <div class="strip">
      <span class="strip__icon">⚠️</span>
      <p><strong>Fine-tune with one of these and serve with another</strong> and you get a model that is quietly
        worse for reasons that never appear in your loss curve — the mismatch only exists at inference. This is
        the single most common real-world SFT bug. Call the tokenizer's own
        <span class="tok" style="font-size:.72rem">apply_chat_template</span> and never hand-roll the string.</p>
    </div>
  </div>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-dataset');
  if (!root) return;

  var M = function (s) { return '<mark>' + s + '</mark>'; };
  var C = function (s) { return '<em>' + s + '</em>'; };

  var TEMPLATES = {
    chatml: {
      body:
        M('&lt;|im_start|&gt;') + 'system\n' + C('You are terse.') + M('&lt;|im_end|&gt;') + '\n' +
        M('&lt;|im_start|&gt;') + 'user\n' + C('Reverse: tic tac') + M('&lt;|im_end|&gt;') + '\n' +
        M('&lt;|im_start|&gt;') + 'assistant\n' + C('tac tic') + M('&lt;|im_end|&gt;'),
      note: 'Used by Qwen and a lot of the open ecosystem. Roles are plain words on their own line, wrapped in two special tokens.'
    },
    llama: {
      body:
        M('&lt;|begin_of_text|&gt;') + M('&lt;|start_header_id|&gt;') + 'system' + M('&lt;|end_header_id|&gt;') + '\n\n' +
        C('You are terse.') + M('&lt;|eot_id|&gt;') +
        M('&lt;|start_header_id|&gt;') + 'user' + M('&lt;|end_header_id|&gt;') + '\n\n' +
        C('Reverse: tic tac') + M('&lt;|eot_id|&gt;') +
        M('&lt;|start_header_id|&gt;') + 'assistant' + M('&lt;|end_header_id|&gt;') + '\n\n' +
        C('tac tic') + M('&lt;|eot_id|&gt;'),
      note: 'Different token names, a mandatory beginning-of-text token, and the blank line after the header is load-bearing.'
    },
    gemma: {
      body:
        M('&lt;bos&gt;') + M('&lt;start_of_turn&gt;') + 'user\n' +
        C('You are terse.') + '\n\n' + C('Reverse: tic tac') + M('&lt;end_of_turn&gt;') + '\n' +
        M('&lt;start_of_turn&gt;') + 'model\n' + C('tac tic') + M('&lt;end_of_turn&gt;'),
      note: 'Gemma has no system role at all — the template folds your system message into the first user turn. It also calls the assistant "model". Same data, structurally different sequence.'
    }
  };

  var out = root.querySelector('[data-role="out"]');
  var note = root.querySelector('[data-role="tmplnote"]');
  var btns = root.querySelectorAll('[data-tmpl]');

  function show(key) {
    out.innerHTML = TEMPLATES[key].body;
    note.textContent = TEMPLATES[key].note;
    btns.forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-tmpl') === key ? 'true' : 'false');
    });
  }

  btns.forEach(function (b) {
    b.addEventListener('click', function () { show(b.getAttribute('data-tmpl')); });
  });

  show('chatml');
})();
</script>

<p>Chat template is the model-specific recipe for which special tokens wrap each role, and every model family has its own.</p>

<p>So where the data comes from? Broadly, two sources: (1) humans writing the answers — expensive, high quality, and that’s what initial GPT-3 was trained on; (2) <strong>distillation</strong> - where you generate answers with a stronger model and train your smaller one on them. Most open datasets you will find today are the second kind.</p>

<p>Using the right techniques, even small numbers of samples can have an observable impact. The rule of thumb I learned — though I have not personally gone above a few hundred — looks like this:</p>

<figure class="fig">
  <span class="fig__label">Figure 5</span>
  <div class="fig__frame">
    <p class="fig__title">How many samples buy you what</p>
    <p class="fig__note">Rules of thumb, not laws. The bars are on a log scale, which is the only honest way to
      draw a range that spans five orders of magnitude.</p>

    <div class="ladder">
      <div class="ladder__step">
        <span class="ladder__n">10s</span>
        <span class="ladder__bar" style="--w:32%">Simple formatting. Always answer in JSON. Stop using bullet points.</span>
      </div>
      <div class="ladder__step">
        <span class="ladder__n">100s</span>
        <span class="ladder__bar" style="--w:50%">Behaviour starts to shift. Tone, register, how it handles an edge case.</span>
      </div>
      <div class="ladder__step">
        <span class="ladder__n">1,000s</span>
        <span class="ladder__bar" style="--w:72%">The sweet spot for tuning a model to your own needs.</span>
      </div>
      <div class="ladder__step">
        <span class="ladder__n">100k–1M</span>
        <span class="ladder__bar" style="--w:100%">Frontier-lab territory. Teaching chat itself, or the first stage of reasoning.</span>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap">This ladder only makes sense because SFT mostly <em>elicits</em> rather than
    installs. A thousand samples cannot teach a model to converse from scratch — but it can absolutely tell it
    which of its existing behaviours to bring to the front.</figcaption>
</figure>

<h2 id="4-what-actually-happens-during-training">4. What actually happens during training</h2>

<p>It is called <em>supervised</em> because we are directly teaching the model to increase the probability of specific tokens given specific inputs. “Here is the answer; be more likely to say it.”</p>

<p>Let us do this with an example small enough to hold in your head. Imagine a language model with a five-token vocabulary that is quite good at repeating exactly what the user said. We want to teach it something new: answer the user with their tokens in reverse order.</p>

<ul>
  <li><strong>Vocabulary:</strong> <code class="language-plaintext highlighter-rouge">&lt;user&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;asst&gt;</code>, <code class="language-plaintext highlighter-rouge">tic</code>, <code class="language-plaintext highlighter-rouge">tac</code>, <code class="language-plaintext highlighter-rouge">&lt;eos&gt;</code> — that last one means end of sequence, and tells generation to stop</li>
  <li><strong>Input:</strong> <code class="language-plaintext highlighter-rouge">&lt;user&gt; tic tac &lt;asst&gt;</code></li>
  <li><strong>Expected output:</strong> <code class="language-plaintext highlighter-rouge">tac tic &lt;eos&gt;</code></li>
</ul>

<p>First we need to know how the model is doing on this sample. And here is the part that surprises people: <strong>we never let the model generate anything.</strong> We hand it the expected output and ask how likely it was to have produced it. How surprised it was is the loss.</p>

<p>Models predict one token at a time, but our training samples are multiple tokens long. So the loss is calculated for each token separately and then aggregated over the sample.</p>

<p>Because we do not need the model to generate, we can give it the entire concatenated sequence at once:</p>

<div class="fig">
  <div class="fig__frame">
    <p class="fig__eyebrow">What the model is shown during training</p>
    <div class="tok-row">
      <span class="tok tok--prompt">&lt;user&gt;</span>
      <span class="tok tok--prompt">tic</span>
      <span class="tok tok--prompt">tac</span>
      <span class="tok tok--prompt">&lt;asst&gt;</span>
      <span class="tok tok--completion">tac</span>
      <span class="tok tok--completion">tic</span>
      <span class="tok tok--completion">&lt;eos&gt;</span>
    </div>
  </div>
</div>

<p>That is it. The whole thing, prompt and answer together, in one go. We then ask the model what it would have predicted at each position, based on tokens before it (differently said, <strong>conditioned only on the tokens before it</strong>). What comes back, for every position in the sequence, is a distribution over the vocabulary.</p>

<p><strong>The distribution at position <em>n</em> is a prediction of the token at position <em>n+1</em>.</strong> So the distribution that ought to be putting its mass on our first output token <code class="language-plaintext highlighter-rouge">tac</code> does not live at <code class="language-plaintext highlighter-rouge">tac</code> — it lives one slot to the left, at <code class="language-plaintext highlighter-rouge">&lt;asst&gt;</code>. Every position predicts its successor.</p>

<figure class="fig fig--wide" id="fig-shift">
  <span class="fig__label">Figure 6 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">Every position predicts the <em>next</em> token</p>
    <p class="fig__note">One forward pass over the whole sequence. Click any position to see the
      distribution the model produced there, and which token it gets compared against.</p>

    <div class="fig__scroll">
      <div class="seq" data-role="seq">
        <div class="seq__row" data-role="idxrow"></div>
        <div class="seq__row" data-role="tokrow"></div>
        <div class="seq__band">
          <svg data-role="svg" aria-hidden="true">
            <path data-role="path" fill="none" stroke="#000" stroke-width="3" stroke-linecap="round"></path>
            <path data-role="head" fill="#000"></path>
            <rect data-role="lblbg" fill="#ff90e8" stroke="#000" stroke-width="2"></rect>
            <text data-role="lbl" font-size="10" font-weight="700" text-anchor="middle" font-family="'Space Grotesk', sans-serif" letter-spacing="0.06em">SCORED AGAINST</text>
          </svg>
        </div>
        <div class="seq__row" data-role="rolerow"></div>
      </div>
    </div>

    <div class="key">
      <span class="key--prompt"><i></i> prompt — given, never scored</span>
      <span class="key--completion"><i></i> answer — what we're teaching</span>
    </div>

    <hr class="fig__hr" />

    <div class="ctl" style="margin-bottom:1rem">
      <button class="btn" type="button" data-role="prev">◀ Prev</button>
      <button class="btn" type="button" data-role="next">Next ▶</button>
      <span class="chip" data-role="status">scored</span>
    </div>

    <p class="fig__eyebrow" data-role="ctx"></p>
    <div class="dist dist--tagged" data-role="dist"></div>

    <div class="readout">
      <span data-role="loss"></span>
      <span class="readout__note" data-role="note"></span>
    </div>
  </div>
  <figcaption class="fig__cap">The distribution that has to put its mass on our first answer token
    <strong>tac</strong> does not live at <strong>tac</strong> — it lives one slot to the left, at
    <strong>&lt;asst&gt;</strong>. Position 7 produces a distribution too, but there is no token 8 to score it
    against, so it is thrown away. Positions 1–3 are computed and then discarded by the mask.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-shift');
  if (!root) return;

  var VOCAB = ['<user>', '<asst>', 'tic', 'tac', '<eos>'];
  var SEQ = ['<user>', 'tic', 'tac', '<asst>', 'tac', 'tic', '<eos>'];
  var PROMPT_UNTIL = 4; // positions 1..4 are the prompt

  var POS = [
    { logits: [-1.2, -0.8,  2.1,  1.8, -0.5], state: 'masked',
      note: 'A real distribution, a real loss. Thrown away — we are not here to re-teach the model what the user said.' },
    { logits: [-1.5,  0.9,  1.1,  1.6, -0.3], state: 'masked',
      note: 'Also computed, also discarded. The mask is the only thing separating this from plain pretraining on the concatenated text.' },
    { logits: [-1.0,  2.6,  0.4,  0.6,  0.2], state: 'masked',
      note: 'The model is confident the turn is about to switch. Still masked — the prompt ends here.' },
    { logits: [-0.5, -0.2,  2.3,  0.6,  0.1], state: 'scored',
      note: 'The interesting one. The model wants to echo "tic" — it is copying, not reversing. Low probability on the target means high loss, which means this position drives most of the gradient.' },
    { logits: [-0.6, -0.3,  2.6,  0.2,  0.9], state: 'scored',
      note: 'Confident and correct. Note "tic" is now the right answer at 0.730, where one position earlier it was the wrong answer at 0.696 — the distribution is a function of the prefix, not of the token itself.' },
    { logits: [-0.4, -0.1,  0.8,  1.1,  2.0], state: 'scored',
      note: 'Right, but shaky — nearly half the mass still wants to keep talking. Knowing when to stop is a real thing SFT has to teach, and it is usually the last skill to firm up.' },
    { logits: null, state: 'dropped',
      note: 'The model predicted something here too. There is no token 8 to compare it against, so the position is sliced off before the loss is computed.' }
  ];

  var seq     = root.querySelector('[data-role="seq"]');
  var idxRow  = root.querySelector('[data-role="idxrow"]');
  var tokRow  = root.querySelector('[data-role="tokrow"]');
  var roleRow = root.querySelector('[data-role="rolerow"]');
  var svg     = root.querySelector('[data-role="svg"]');
  var path    = root.querySelector('[data-role="path"]');
  var head    = root.querySelector('[data-role="head"]');
  var lbl     = root.querySelector('[data-role="lbl"]');
  var lblbg   = root.querySelector('[data-role="lblbg"]');
  var distEl  = root.querySelector('[data-role="dist"]');
  var statusEl= root.querySelector('[data-role="status"]');
  var ctxEl   = root.querySelector('[data-role="ctx"]');
  var lossEl  = root.querySelector('[data-role="loss"]');
  var noteEl  = root.querySelector('[data-role="note"]');

  var current = 4; // 1-based; open on the position that carries the lesson
  var cells = [];

  function esc(s) { return s.replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

  SEQ.forEach(function (tok, i) {
    var n = i + 1;

    var idx = document.createElement('div');
    idx.className = 'seq__idx';
    idx.textContent = 't = ' + n;
    idxRow.appendChild(idx);

    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'seqtok ' + (n <= PROMPT_UNTIL ? 'seqtok--prompt' : 'seqtok--completion');
    btn.innerHTML = esc(tok);
    btn.setAttribute('aria-pressed', 'false');
    btn.addEventListener('click', function () { select(n, true); });
    tokRow.appendChild(btn);
    cells.push(btn);

    var role = document.createElement('div');
    role.className = 'seq__role';
    roleRow.appendChild(role);
  });

  function softmax(logits) {
    var max = Math.max.apply(null, logits);
    var ex = logits.map(function (z) { return Math.exp(z - max); });
    var sum = ex.reduce(function (a, b) { return a + b; }, 0);
    return ex.map(function (e) { return e / sum; });
  }

  function drawArrow(n) {
    var hasNext = n < SEQ.length;
    [path, head, lbl, lblbg].forEach(function (el) {
      el.style.display = hasNext ? '' : 'none';
    });
    if (!hasNext) return;

    var base = seq.getBoundingClientRect();
    var a = cells[n - 1].getBoundingClientRect();
    var b = cells[n].getBoundingClientRect();
    var band = svg.getBoundingClientRect();

    var x1 = a.left - base.left + a.width / 2;
    var x2 = b.left - base.left + b.width / 2;
    var h = band.height;

    svg.setAttribute('viewBox', '0 0 ' + base.width + ' ' + h);
    svg.setAttribute('width', base.width);
    svg.setAttribute('height', h);

    var dip = h - 8;
    path.setAttribute('d', 'M ' + x1 + ' 0 C ' + x1 + ' ' + dip + ', ' + x2 + ' ' + dip + ', ' + x2 + ' 8');
    head.setAttribute('d', 'M ' + x2 + ' 0 l -6 10 l 12 0 z');

    var mx = (x1 + x2) / 2;
    var my = h - 6;
    lbl.setAttribute('x', mx);
    lbl.setAttribute('y', my);
    lblbg.setAttribute('x', mx - 56);
    lblbg.setAttribute('y', my - 11);
    lblbg.setAttribute('width', 112);
    lblbg.setAttribute('height', 15);
  }

  // On narrow screens the strip scrolls; keep the selected position and the
  // one it points at inside the viewport.
  var scroller = root.querySelector('.fig__scroll');
  function reveal(n, smooth) {
    if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return;
    var cell = cells[n - 1];
    var want = cell.offsetLeft - (scroller.clientWidth - cell.offsetWidth * 2) / 2;
    scroller.scrollTo({ left: Math.max(0, want), behavior: smooth ? 'smooth' : 'auto' });
  }

  function select(n, smooth) {
    current = n;
    var p = POS[n - 1];
    var targetTok = n < SEQ.length ? SEQ[n] : null;

    cells.forEach(function (c, i) {
      c.setAttribute('aria-pressed', i + 1 === n ? 'true' : 'false');
    });
    roleRow.childNodes.forEach(function (el, i) {
      var isTarget = targetTok !== null && i === n;
      el.textContent = isTarget ? '↑ target' : '';
      el.className = 'seq__role' + (isTarget ? ' seq__role--target' : '');
    });

    statusEl.textContent = p.state === 'scored' ? 'scored' :
                           p.state === 'masked' ? 'masked · prompt token' : 'dropped · no next token';
    statusEl.className = 'chip chip--' + p.state;

    ctxEl.textContent = 'Distribution at t = ' + n + ', conditioned on: ' +
      SEQ.slice(0, n).join(' ');

    distEl.innerHTML = '';
    if (!p.logits) {
      lossEl.innerHTML = '<span style="color:var(--meta)">no target, no loss</span>';
      noteEl.textContent = p.note;
      drawArrow(n);
      reveal(n, smooth);
      return;
    }

    var probs = softmax(p.logits);
    var argmax = probs.indexOf(Math.max.apply(null, probs));
    var ti = VOCAB.indexOf(targetTok);

    VOCAB.forEach(function (v, i) {
      var row = document.createElement('div');
      var cls = 'dist__row';
      if (i === ti) cls += ' dist__row--target';
      else if (i === argmax) cls += ' dist__row--argmax';
      row.className = cls;
      var tag = i === ti ? '<span class="dist__tag dist__tag--target">target</span>'
              : i === argmax ? '<span class="dist__tag dist__tag--argmax">argmax</span>' : '';
      row.innerHTML =
        '<span class="dist__tok">' + esc(v) + '</span>' +
        '<span class="dist__logit">' + p.logits[i].toFixed(1) + '</span>' +
        '<span class="dist__track"><span class="dist__bar" style="--w:' +
          (probs[i] * 100).toFixed(1) + '%"></span></span>' +
        '<span class="dist__p">' + probs[i].toFixed(3) + '</span>' +
        '<span>' + tag + '</span>';
      distEl.appendChild(row);
    });

    var loss = -Math.log(probs[ti]);
    lossEl.innerHTML = '&minus;log p(' + esc(targetTok) + ') = <b>' + loss.toFixed(3) + '</b>' +
      (p.state === 'masked' ? ' <span style="color:var(--meta)">→ discarded</span>' : '');
    noteEl.textContent = p.note;

    drawArrow(n);
    reveal(n, smooth);
  }

  root.querySelector('[data-role="prev"]').addEventListener('click', function () {
    select(current > 1 ? current - 1 : SEQ.length, true);
  });
  root.querySelector('[data-role="next"]').addEventListener('click', function () {
    select(current < SEQ.length ? current + 1 : 1, true);
  });

  window.addEventListener('resize', function () { drawArrow(current); });
  if (document.fonts && document.fonts.ready) {
    document.fonts.ready.then(function () { drawArrow(current); });
  }

  select(current);
})();
</script>

<p>On the left you see the logits, the actual output of the model. On the right, those logits after softmax. Note again that plain normalisation would also make things sum to 1; it is the exponentiation that makes the <em>gaps</em> meaningful.</p>

<p>The loss for a single token is <code class="language-plaintext highlighter-rouge">-log(probability of the expected token)</code>. Higher probability, smaller loss. Lower probability, larger loss. That is the whole idea. It is called <strong>cross-entropy loss</strong>, and it is the name you will see in every training codebase.</p>

<p>If you ever want to translate a loss number into something intuitive, exponentiate it: <code class="language-plaintext highlighter-rouge">perplexity = exp(mean loss)</code>. A loss of 2.3 is a perplexity of about 10, which reads as “the model was about as unsure as if it were picking uniformly between 10 options”. It is the same number your tooling reports, and a friendlier scale to eyeball.</p>

<h3 id="teacher-forcing">Teacher forcing</h3>

<p>We then move to the next token — but we do not let the model’s own pick from the previous step influence anything. We ask it to predict token <em>n+1</em> at every position <em>n</em>, always assuming the previous tokens are the ground-truth ones from our sequence. This is called <strong>teacher forcing</strong>. The model cannot cheat by peeking ahead, because LLM has a causal mask baked into it: each position sees only itself and its predecessors (well, this is true only about decoder-type models, but that’s for different post).</p>

<p>The main reason for doing it this way is that it makes the whole thing parallel. All of this happens in a <em>single forward pass</em>. We do not run the model once per token; every position gets its prediction simultaneously, because every position’s input is already known up front. That is what makes training feasible on today’s compute. There is a secondary benefit too: if we fed the model its own picks, early-training mistakes would compound and drag the rest of the sequence somewhere unrelated to what we are trying to teach.</p>

<h3 id="masking">Masking</h3>

<p>Not every position counts. We want to teach the model the answer, not to re-teach it the user’s question, so the losses on prompt tokens are masked out and thrown away. In code you will see them labelled <code class="language-plaintext highlighter-rouge">-100</code>, which is the value PyTorch’s cross-entropy ignores by default (assuming that you use HuggingFace Transformers library).</p>

<p>One thing that is worth calling out here is <code class="language-plaintext highlighter-rouge">&lt;eos&gt;</code>. It is a real token, it is the last one in our target, and it is trained exactly like the rest: the model learns to predict “stop here” from the tokens before it. If you don’t teach your models to generate end of sequence tokens, you end up with a model that answers your question correctly and then keeps going forever, inventing a new user turn and answering that too.</p>

<h3 id="adding-it-up">Adding it up</h3>

<p>The loss for the whole sequence is the sum of all the surviving tokens’ losses.</p>

<figure class="fig">
  <span class="fig__label">Figure 7</span>
  <div class="fig__frame">
    <p class="fig__title">Adding it up</p>

    <div class="ledger">
      <div class="ledger__line ledger__line--off"><span class="lbl">t = 1, 2, 3 &nbsp;masked</span><span class="dots"></span><span class="val">—</span></div>
      <div class="ledger__line"><span class="lbl">t = 4 &nbsp;&minus;log p(tac)</span><span class="dots"></span><span class="val">2.062</span></div>
      <div class="ledger__line"><span class="lbl">t = 5 &nbsp;&minus;log p(tic)</span><span class="dots"></span><span class="val">0.314</span></div>
      <div class="ledger__line"><span class="lbl">t = 6 &nbsp;&minus;log p(&lt;eos&gt;)</span><span class="dots"></span><span class="val">0.653</span></div>
      <div class="ledger__line ledger__line--off"><span class="lbl">t = 7 &nbsp;dropped</span><span class="dots"></span><span class="val">—</span></div>
      <div class="ledger__line ledger__line--sum"><span class="lbl">Sum over the sample</span><span class="dots"></span><span class="val">3.029</span></div>
    </div>

    <p style="margin:1.2rem 0 .9rem;font-size:.9rem;line-height:1.55">
      One of the three positions that count carries <strong>68%</strong> of the loss. That is the normal state of affairs:
      most tokens in a fine-tuning corpus are already easy for the base model, and the signal comes from the
      few places where the right answer is not what the model would have said.
    </p>

  </div>
  <figcaption class="fig__cap">Three numbers, added together. That single figure is what the whole forward pass
    reduces to, and it is the only thing backpropagation is handed.</figcaption>
</figure>

<p>So for each input–output sequence, we have a loss. Now we need to work out how each model weight — and there are billions of them, recently trillions — influenced the output, and therefore this loss. That is genuinely complex differential calculus, but let us take it on trust that it works. Using <strong>gradient backpropagation</strong>, we can evaluate whether any particular weight contributed positively or negatively to the loss. We want to minimise the loss, so that the model has the highest chance of producing the output we gave it, and backprop tells us whether to increase or decrease each weight.</p>

<p>Then the <strong>optimizer</strong> applies that adjustment. And we repeat.</p>

<details class="deepdive">
  <summary>
    <span class="deepdive__bulb">💡</span>
    <span class="deepdive__text">
      <span class="deepdive__kicker">Deep dive · optional · ~3 min</span>
      <span class="deepdive__title">Why a negative log, and why a sum?</span>
    </span>
    <span class="deepdive__toggle"></span>
  </summary>
  <div class="deepdive__body">

    <p>Skip this if you are happy to take cross-entropy on faith — nothing later depends on it. But it is one of
      those cases where the formula stops looking arbitrary the moment you derive it, so it is worth three
      minutes.</p>

    <p>Start from first principles. The probability of the model producing our whole three-token answer is the
      product of the probability of each token given everything before it:</p>

    <div class="eq">P(output | input) = P(<b>tac</b> | input)
                 × P(<b>tic</b> | input + "tac")
                 × P(<b>&lt;eos&gt;</b> | input + "tac tic")</div>

    <p>We want to maximise that. So far so good. The problem is that every one of those factors is a
      probability, sitting somewhere between 0 and 1, and multiplying hundreds of numbers below 1 drives the
      result toward zero <em>fast</em>. Here is what happens if every token is a comfortable 0.9:</p>

    <div class="underflow">
      <div class="underflow__row"><span>10 tokens</span><span><b>0.349</b></span></div>
      <div class="underflow__row"><span>100 tokens</span><span><b>2.7 × 10⁻⁵</b></span></div>
      <div class="underflow__row"><span>500 tokens</span><span><b>1.3 × 10⁻²³</b></span></div>
      <div class="underflow__row underflow__row--dead"><span>~830 tokens</span><span><b>1.2 × 10⁻³⁸ — the smallest number float32 can hold</b></span></div>
      <div class="underflow__row underflow__row--dead"><span>1,000 tokens</span><span><b>0.0 — there is no precision left to represent it</b></span></div>
    </div>

    <p>A thousand tokens is a short answer. So the quantity we actually care about is, in the most literal
      sense, not representable. And even where it is representable, the gradient of a long product is a messy
      thing to define and a messy thing to implement.</p>

    <p>Logs fix both problems at once, because a log turns a product into a sum:</p>

    <div class="eq">log(A × B) = log(A) + log(B)</div>

    <p>Apply that to the whole sequence, and flip the sign so we have something to <em>minimise</em> rather than
      maximise:</p>

    <div class="eq">loss = &minus;log P(output | input)
     = &minus;log P(tac) + &minus;log P(tic) + &minus;log P(&lt;eos&gt;)
     = 2.062 + 0.314 + 0.653
     = <b>3.029</b></div>

    <p>That is the sum from the section above — it was never an arbitrary choice of aggregation. It is what the
      product becomes once you take its log. Those thousand tokens at 0.9 now add up to about 105 instead of
      underflowing to zero, and each term contributes independently, which is exactly the shape backpropagation
      wants.</p>

    <p>The same reasoning explains something you will notice the first time you read real training code: nobody
      computes softmax and <em>then</em> takes its log. Frameworks fuse the two into a single
      <span class="tok" style="font-size:.74rem">log_softmax</span> — going through the raw probability on the
      way would reintroduce the underflow you just designed around.</p>

  </div>
</details>

<h2 id="5-what-about-reasoning">5. What about reasoning?</h2>

<p>One of the big discoveries of 2022 was that you can dramatically improve a model’s reasoning if you prompt it to think step by step before answering. That is really the core of what “reasoning models” are: models trained to think step by step so that you do not have to ask.</p>

<p>Training reasoning is a multi-step process, a mixture of SFT and reinforcement learning — more on RL another time. SFT is usually where it starts. You teach the model to wrap its thinking in <code class="language-plaintext highlighter-rouge">&lt;think&gt;...&lt;/think&gt;</code> using samples like this:</p>

<figure class="fig">
  <span class="fig__label">Figure 8</span>
  <div class="fig__frame">
    <p class="fig__title">Teaching a model to think first</p>
    <p class="fig__note">Structurally, nothing changes. It is the same sequence, the same mask, the same
      per-token loss. The answer just got longer, and part of it is now the model talking to itself.</p>

    <div>
      <div class="tok-row">
        <span class="tok tok--prompt">&lt;user&gt;</span>
        <span class="tok tok--prompt">tic</span>
        <span class="tok tok--prompt">tac</span>
        <span class="tok tok--prompt">&lt;asst&gt;</span>
        <span class="tok tok--think">&lt;think&gt;</span>
        <span class="tok tok--think">user said tic tac so reverse it</span>
        <span class="tok tok--think">&lt;/think&gt;</span>
        <span class="tok tok--completion">tac</span>
        <span class="tok tok--completion">tic</span>
        <span class="tok tok--completion">&lt;eos&gt;</span>
      </div>
    </div>

    <div class="key">
      <span class="key--prompt"><i></i> prompt</span>
      <span><i style="background:var(--tint-4)"></i> thinking — scored like everything else</span>
      <span class="key--completion"><i></i> answer</span>
    </div>

    <hr class="fig__hr" />

    <div class="duo duo--tight">
      <div class="pane">
        <div class="pane__head pane__head--warm">SFT teaches</div>
        <div class="pane__body"><p>The <em>shape</em> of thinking. Where the thinking goes, what wraps it, that
          it comes before the answer, and roughly how long it runs.</p></div>
      </div>
      <div class="pane">
        <div class="pane__head pane__head--good">RL teaches</div>
        <div class="pane__body"><p>Whether what is <em>inside</em> it is any good. SFT has no way to tell a
          correct chain of thought from a confident wrong one — they are equally likely training samples.</p></div>
      </div>
    </div>
  </div>
  <figcaption class="fig__cap"><a href="https://arxiv.org/abs/2501.12948v1">DeepSeek showed with R1-Zero</a>
    that you can get reasoning out of pure RL on a base model, with no SFT at all. It came out unreadable — so
    the actual R1 used a small SFT step first, purely to fix the format. A neat illustration of the same
    division of labour.</figcaption>
</figure>

<h2 id="6-knobs-worth-turning">6. Knobs worth turning</h2>

<p>There are a handful of things you can tune, and they interact more than you would like.</p>

<ul>
  <li><strong>Batch size</strong> — how many samples you push through before touching the weights at all. You run the forward pass on all of them, aggregate their losses into a single number, and backpropagate <em>once</em>. That one gradient is therefore an average over many examples rather than a reaction to a single one, so an odd or badly written sample cannot yank the weights on its own — that averaging is the whole of why batching stabilises training. The cost is fewer updates per epoch: at batch size 8, a 1,000-sample dataset gives you 125 optimizer steps instead of 1,000. On a small dataset that can leave you without enough updates to teach the model anything at all.</li>
  <li><strong>Loss normalisation</strong> — which is the question the batch immediately raises: aggregate <em>how</em>? You sum the per-token losses, but what you divide that sum by is a choice — the number of tokens in the batch, or the number of samples. Divide per token and a long answer contributes more gradient than a short one, so length quietly becomes weight. Most libraries default to per-token. It only bites if your dataset mixes one-line answers with long ones — but then it decides which of them your model is really learning from.</li>
  <li><strong>Learning rate</strong> — how much we adjust the weights each step. Higher can mean faster learning, or oscillation. For SFT it is typically <code class="language-plaintext highlighter-rouge">1e-5</code> to <code class="language-plaintext highlighter-rouge">2e-5</code>, roughly an order of magnitude below pretraining, because we are nudging an already-capable model rather than building one.</li>
  <li><strong>Epochs</strong> — how many passes over the same data. Usually 1 to 3. Beyond that you start memorising.</li>
</ul>

<p>It is also worth saying how you know it worked. Loss going down on a held-out set is necessary but not sufficient — a model can get better at reproducing your dataset while getting worse at everything else. You have to actually look at the outputs and ideally have a set of evals that proves that your model continues to work well in other domains that it previously worked well in.</p>

<p>That failure mode has a name: <strong>catastrophic forgetting</strong>, sometimes called the alignment tax. Train hard on a narrow dataset and the model gets great at your task while quietly losing capability everywhere else. It was never told to preserve anything, only to lower loss on what you showed it.</p>

<p>The standard mitigation is boring and effective: mix general-purpose data into your narrow dataset, so the training signal keeps pulling in both directions. The other half is evaluation. Keep a small set of prompts that have nothing to do with your task, and check them after every run.</p>

<h2 id="7-can-you-do-this-yourself">7. Can you do this yourself?</h2>

<p>Yes. The default path is just expensive.</p>

<p>To adjust every parameter, you need to hold three things in memory at once: the weights, the gradients for those weights, and the <strong>optimizer state</strong>.</p>

<p>That last one deserves a sentence, because it is where most of the memory goes. The optimizer is the piece from the end of section 4 — the thing that takes backprop’s verdict on each weight and decides how far to actually move it. In practice that optimizer is almost always <a href="https://arxiv.org/abs/1412.6980"><strong>Adam</strong></a>, and Adam does not just apply the raw gradient. For every single weight it keeps a running average of that weight’s recent gradients, and a running average of their squared magnitudes. It uses the two together to give each parameter its own effective step size, so weights with small or noisy gradients still move sensibly instead of being drowned out. It works very well. It also means two extra numbers stored per parameter, forever, plus a full-precision master copy of the weights themselves.</p>

<p>Add it all up and the rule of thumb is around 16 bytes per parameter.</p>

<figure class="fig fig--wide" id="fig-memory">
  <span class="fig__label">Figure 9 · Interactive</span>
  <div class="fig__frame">
    <p class="fig__title">What has to fit in GPU memory</p>
    <p class="fig__note">Mixed-precision Adam, roughly 16 bytes per parameter. Neither bar includes
      activations — those pile on top, and scale with batch size and sequence length. Each square below is
      one 80&nbsp;GB datacenter GPU.</p>

    <div class="tabs">
      <button class="btn" type="button" data-model="nano" aria-pressed="true">Nemotron 3 Nano</button>
      <button class="btn" type="button" data-model="glm" aria-pressed="false">GLM-5.2</button>
    </div>

    <div class="facts" data-role="facts"></div>

    <div class="stack">
      <div class="stack__row">
        <div class="stack__head">
          <span>Full fine-tune</span>
          <span class="stack__total" data-role="fulltotal"></span>
        </div>
        <div class="stack__bar" data-role="fullbar"></div>
        <div class="key">
          <span><i style="background:var(--tint-3)"></i> weights, bf16</span>
          <span><i style="background:var(--accent)"></i> gradients</span>
          <span><i style="background:var(--accent-2)"></i> fp32 master copy + Adam's two moments</span>
        </div>
        <div class="gpus" data-role="fullgpus"></div>
      </div>

      <div class="stack__row">
        <div class="stack__head">
          <span>LoRA</span>
          <span class="stack__total" data-role="loratotal"></span>
        </div>
        <div class="stack__bar" data-role="lorabar"></div>
        <div class="key">
          <span><i style="background:var(--tint-3)"></i> the same weights, frozen</span>
          <span><i style="background:var(--accent-4)"></i> adapters + their gradients and optimizer state</span>
        </div>
        <div class="gpus" data-role="loragpus"></div>
      </div>
    </div>

    <div class="strip" data-role="verdict"></div>

    <div class="strip" style="background:var(--tint-3)">
      <span class="strip__icon">🧩</span>
      <p><strong>Both of these are Mixture-of-Experts models, and it does not help here.</strong> GLM-5.2
        only activates about 40B of its 744B parameters for any given token, which is an enormous saving at
        inference. During a full fine-tune you are training all the experts, so every one of those 744B
        parameters still needs a gradient and an optimizer slot. Sparsity buys you compute, not memory.</p>
    </div>

    <div class="strip">
      <span class="strip__icon">🔁</span>
      <p>Because you are training so few parameters, LoRA wants a much <strong>higher</strong> learning rate
        than a full fine-tune — around <span class="tok" style="font-size:.72rem">1e-4</span> rather than
        <span class="tok" style="font-size:.72rem">1e-5</span>. Carrying over the full fine-tune's learning
        rate is a classic way to watch nothing happen for three epochs.</p>
    </div>
  </div>
  <figcaption class="fig__cap">LoRA is not simply a cheaper full fine-tune. It tends to match full
    fine-tuning on style, format and behaviour, and to fall behind when you are pushing genuinely new
    knowledge in — though it also forgets less of the original model, which is the flip side of touching
    fewer weights.</figcaption>
</figure>

<script>
(function () {
  var root = document.getElementById('fig-memory');
  if (!root) return;

  var MODELS = {
    nano: {
      facts: ['31.6B total params', '3.6B active / token', 'hybrid Mamba-2 + MoE', 'NVIDIA · Dec 2025'],
      full: {
        total: '506 GB',
        segs: [
          { w: 12.5, cls: 'weights', label: '63 GB' },
          { w: 12.5, cls: 'grads',   label: '63 GB' },
          { w: 25,   cls: 'optim',   label: '126 GB' },
          { w: 50,   cls: 'optim',   label: '253 GB' }
        ],
        gpus: 7
      },
      lora: {
        total: '64 GB',
        segs: [
          { w: 12.5, cls: 'weights', label: '63 GB' },
          { w: 0.6,  cls: 'tiny',    label: '' },
          { w: 86.9, cls: 'free',    label: 'frozen — no gradients, no optimizer state' }
        ],
        gpus: 1
      },
      verdict: 'LoRA turns this one from a <strong>small cluster into a single GPU</strong>. That is the ' +
               'case LoRA is famous for, and for style, format and behaviour work it is genuinely the ' +
               'right default.'
    },
    glm: {
      facts: ['744B total params', '40B active / token', 'Mixture-of-Experts', 'Zhipu AI · June 2026'],
      full: {
        total: '11,904 GB · ~11.9 TB',
        segs: [
          { w: 12.5, cls: 'weights', label: '1,488 GB' },
          { w: 12.5, cls: 'grads',   label: '1,488 GB' },
          { w: 25,   cls: 'optim',   label: '2,976 GB' },
          { w: 50,   cls: 'optim',   label: '5,952 GB' }
        ],
        gpus: 149
      },
      lora: {
        total: '1,494 GB · ~1.5 TB',
        segs: [
          { w: 12.5, cls: 'weights', label: '1,488 GB' },
          { w: 0.6,  cls: 'tiny',    label: '' },
          { w: 86.9, cls: 'free',    label: 'frozen — no gradients, no optimizer state' }
        ],
        gpus: 19
      },
      verdict: 'At this scale LoRA does not remove the wall, it just <strong>moves it</strong> — 149 GPUs ' +
               'down to 19. A real 8× saving, and still a cluster, because you have to hold the frozen ' +
               'weights somewhere no matter what you are training.'
    }
  };

  var factsEl = root.querySelector('[data-role="facts"]');
  var verdictEl = root.querySelector('[data-role="verdict"]');
  var btns = root.querySelectorAll('[data-model]');

  function bar(el, segs) {
    el.innerHTML = segs.map(function (s) {
      return '<span class="stack__seg stack__seg--' + s.cls + '" style="--w:' + s.w + '%">' +
             s.label + '</span>';
    }).join('');
  }

  function gpus(el, n, cls) {
    el.innerHTML = '<span class="gpus__n">' + n + ' × 80GB GPU' + (n === 1 ? '' : 's') + '</span>' +
      new Array(n + 1).join('<span class="gpu ' + cls + '"></span>');
  }

  function show(key) {
    var m = MODELS[key];
    factsEl.innerHTML = m.facts.map(function (f) { return '<span>' + f + '</span>'; }).join('');

    root.querySelector('[data-role="fulltotal"]').textContent = m.full.total;
    root.querySelector('[data-role="loratotal"]').textContent = m.lora.total;
    bar(root.querySelector('[data-role="fullbar"]'), m.full.segs);
    bar(root.querySelector('[data-role="lorabar"]'), m.lora.segs);
    gpus(root.querySelector('[data-role="fullgpus"]'), m.full.gpus, '');
    gpus(root.querySelector('[data-role="loragpus"]'), m.lora.gpus, 'gpu--lora');

    verdictEl.innerHTML = '<span class="strip__icon">📉</span><p>' + m.verdict + '</p>';

    btns.forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-model') === key ? 'true' : 'false');
    });
  }

  btns.forEach(function (b) {
    b.addEventListener('click', function () { show(b.getAttribute('data-model')); });
  });

  show('nano');
})();
</script>

<p>The cheaper way is <a href="https://arxiv.org/abs/2106.09685"><strong>LoRA</strong></a>. You freeze the original weights entirely and train small adapter matrices alongside them, which ends up being well under 1% of the parameters. Nothing frozen needs a gradient or an optimizer slot, so the entire optimizer tower — the biggest block in that bar — simply disappears, and you are left holding little more than the weights themselves.</p>

<p>And honestly, this is not much code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">datasets</span> <span class="kn">import</span> <span class="n">load_dataset</span>
<span class="kn">from</span> <span class="n">peft</span> <span class="kn">import</span> <span class="n">LoraConfig</span>
<span class="kn">from</span> <span class="n">trl</span> <span class="kn">import</span> <span class="n">SFTConfig</span><span class="p">,</span> <span class="n">SFTTrainer</span>

<span class="n">dataset</span> <span class="o">=</span> <span class="nf">load_dataset</span><span class="p">(</span><span class="sh">"</span><span class="s">HuggingFaceH4/ultrachat_200k</span><span class="sh">"</span><span class="p">,</span> <span class="n">split</span><span class="o">=</span><span class="sh">"</span><span class="s">train_sft[:2000]</span><span class="sh">"</span><span class="p">)</span>

<span class="n">trainer</span> <span class="o">=</span> <span class="nc">SFTTrainer</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="sh">"</span><span class="s">Qwen/Qwen2.5-1.5B</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">train_dataset</span><span class="o">=</span><span class="n">dataset</span><span class="p">,</span>
    <span class="n">peft_config</span><span class="o">=</span><span class="nc">LoraConfig</span><span class="p">(</span><span class="n">r</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span> <span class="n">lora_alpha</span><span class="o">=</span><span class="mi">32</span><span class="p">,</span> <span class="n">task_type</span><span class="o">=</span><span class="sh">"</span><span class="s">CAUSAL_LM</span><span class="sh">"</span><span class="p">),</span>
    <span class="n">args</span><span class="o">=</span><span class="nc">SFTConfig</span><span class="p">(</span>
        <span class="n">output_dir</span><span class="o">=</span><span class="sh">"</span><span class="s">qwen-sft</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">num_train_epochs</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
        <span class="n">learning_rate</span><span class="o">=</span><span class="mf">1e-4</span><span class="p">,</span>           <span class="c1"># LoRA wants ~10x a full fine-tune
</span>        <span class="n">per_device_train_batch_size</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span>
        <span class="n">max_length</span><span class="o">=</span><span class="mi">1024</span><span class="p">,</span>
        <span class="n">completion_only_loss</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>    <span class="c1"># mask the prompt, score only the answer
</span>        <span class="n">bf16</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="p">),</span>
<span class="p">)</span>

<span class="n">trainer</span><span class="p">.</span><span class="nf">train</span><span class="p">()</span>
</code></pre></div></div>

<p>Every concept in this post is somewhere in those twenty lines. <code class="language-plaintext highlighter-rouge">SFTConfig</code> picks the learning rate and the number of epochs. <code class="language-plaintext highlighter-rouge">completion_only_loss</code> is the mask. <code class="language-plaintext highlighter-rouge">LoraConfig</code> is the reason it fits on one GPU. The chat template comes along with the tokenizer, and the shift-by-one, the softmax, the negative log and the sum all happen inside <code class="language-plaintext highlighter-rouge">trainer.train()</code> without ever asking your permission.</p>

<p>Which is exactly why it is worth knowing what they are.</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[A visual, ground-up walkthrough of supervised fine-tuning for LLMs — what the loss actually measures, why the off-by-one trips everyone up, what masking really does, and how much of this you can do on one GPU.]]></summary></entry><entry><title type="html">Building a watering system for my balcony</title><link href="https://engineering-manager.com/2026-07-21/building-a-watering-system-for-my-balcony" rel="alternate" type="text/html" title="Building a watering system for my balcony" /><published>2026-07-21T00:00:00+00:00</published><updated>2026-07-21T00:00:00+00:00</updated><id>https://engineering-manager.com/2026-07-21/building-a-watering-system-for-my-balcony</id><content type="html" xml:base="https://engineering-manager.com/2026-07-21/building-a-watering-system-for-my-balcony"><![CDATA[<p>My balcony faces south on the 4th floor in Berlin. In summer that means it absolutely bakes. Several days of direct heat in a row, and if you forget to water your plants for even 2 to 3 days, they’re gone.</p>

<p>We had been through this once already. It’s painful to watch your plants die like that, and it’s also a waste of plants, money, and time.</p>

<p>So I decided to fix it properly. I finished a masters in control engineering, and over a decade ago I spent a lot of time with microcontrollers and electronics. I hadn’t had a good excuse to get back into it since, and this felt like the perfect one. <strong>What if I could build a small, controlled watering system that adjusts to the moisture of the soil and the weather outside?</strong></p>

<p><img src="https://engineering-manager.com/_imgs/hydration-station/board-and-water-tank.png" alt="The prototyping board wired up on the balcony floor, next to the water carboy tucked into the corner" /></p>

<h3 id="the-scope-and-where-i-was-wrong">The scope, and where I was wrong</h3>

<p>My initial scope was ambitious: a wifi-enabled controller so I could adjust things remotely, the ability to water a few plants on the balcony, and moisture sensors driving electronic valves to regulate the flow of water.</p>

<p><strong>The moisture sensors turned out to be very low quality and never gave reliable readings, so I dropped the idea, along with the solenoid valves, and replaced them with micro-drippers from a Gardena garden watering system.</strong></p>

<p>A classic lesson to start from the simplest thing and add scope only if you prove initial value :)</p>

<h3 id="no-taps-on-berlin-balconies">No taps on Berlin balconies</h3>

<p>The biggest constraint had nothing to do with electronics. <strong>In Berlin apartments you don’t get running water on the balcony, so the whole system has to carry its own water and generate its own pressure.</strong></p>

<p>I solved it with a carboy — the kind used for home wine production, bought at a local construction market — and a 12V pump that pushes water from the carboy through the drip lines. Refillable, self-contained, and it sits happily in the corner of the balcony.</p>

<h3 id="the-components">The components</h3>

<p>The core build stayed small:</p>

<ul>
  <li><strong>MOSFET</strong> to switch the water pump from the ESP32 — <a href="https://www.ebay.de/itm/233000146342">eBay</a></li>
  <li><strong>Schottky diode</strong> for the pump’s flyback spike — <a href="https://www.ebay.de/itm/233998850597">eBay</a></li>
  <li><strong>Boost converter</strong> stepping 5V up to 12V to power the pump — <a href="https://www.ebay.de/itm/233627491422">eBay</a></li>
  <li><strong>12V water pump</strong> — <a href="https://www.ebay.de/itm/232597865686">eBay</a></li>
  <li><strong>ESP32 starter kit</strong> as the controller — <a href="https://www.amazon.de/dp/B0CTMQQ1W4">Amazon</a></li>
</ul>

<p>The extended scope I ended up shelving:</p>

<ul>
  <li><strong>Moisture sensors</strong> — <a href="https://www.ebay.de/itm/234556768072">eBay</a></li>
  <li><strong>Solenoid valves</strong> — <a href="https://www.ebay.de/itm/315185626029">eBay</a></li>
</ul>

<p>And the Gardena-compatible micro-drip parts that replaced the valves:</p>

<ul>
  <li><strong>Pipe (4/7mm)</strong> — <a href="https://www.amazon.de/-/en/20-meter-garden-irrigation-diameter-watering/dp/B0G48GQMPC/">Amazon</a></li>
  <li><strong>Drippers and holders</strong> — <a href="https://www.amazon.de/-/en/dp/B08K2ZJD1K">Amazon</a></li>
  <li><strong>T-connectors</strong> — <a href="https://www.amazon.de/-/en/Irrigation-T-Connector-Universal-Connector-Fittings/dp/B0FBGX5BSL/">Amazon</a></li>
  <li><strong>Carboy</strong> for home wine production, from the local construction market</li>
</ul>

<p><img src="https://engineering-manager.com/_imgs/hydration-station/gardena-drip.png" alt="A Gardena micro-dripper feeding one of the balcony's petunia pots" /></p>

<h3 id="the-electrical-diagram">The electrical diagram</h3>

<p>The wiring is quite simple and I entirely built it on a prototyping board. A single USB-C 5V input feeds everything, and one MOSFET does all the switching. I used Claude to remind me all the nuances of how to keep the MOSFET switching safe.</p>

<p>USB-C 5V powers both the ESP32 and a boost converter that steps 5V up to an adjustable 12V. The ESP32’s GPIO drives the gate of an N-MOSFET with PWM.</p>

<p>The MOSFET low-side switches the 12V pump: pump positive to +12V, pump negative to the drain, source to ground. A 10kΩ resistor pulls the gate to ground, holding the FET off during ESP32 boot and reset.</p>

<p>A Schottky flyback diode clamps the pump’s inductive turn-off spike, cathode to +12V and anode to the drain. Three rails run through it all: +5V from the USB/boost input, +12V from the boost output to the pump, and a common ground tying the USB-C, ESP32, boost, MOSFET source, and diode return together.</p>

<p><img src="https://engineering-manager.com/_imgs/hydration-station/hydration-station-electric-diagram.png" alt="The electrical diagram: USB-C 5V in, a boost converter to 12V, the ESP32 driving an N-MOSFET that switches the pump, and a Schottky flyback diode across it" /></p>

<h3 id="the-code">The code</h3>

<p>The firmware lives on GitHub and was generated in 2025, mainly with the help of GitHub Copilot back then: <a href="https://github.com/jniechcial/hydration-station">github.com/jniechcial/hydration-station</a>.</p>

<h3 id="the-control-plane">The control plane</h3>

<p>I didn’t want to depend on the cloud for something this small, so the ESP32 serves a simple website on my local network. <strong>Everything I need to run the system — trigger a watering, set a schedule, check the state — is a page on my LAN, no app and no external service.</strong></p>

<p><img src="https://engineering-manager.com/_imgs/hydration-station/control-plane.png" alt="The hydration station's control website, showing manual pump control, scheduling, and system status" /></p>

<h3 id="lessons-and-what-id-do-next">Lessons, and what I’d do next</h3>

<p>It’s a very reliable basic watering system, but it still needs a bit of manual oversight for the best outcome, especially during extremely hot spells. That said, it did keep the flowers alive during 2 weeks we were away on holiday, with a neighbour just topping up the water tank twice in that time.</p>

<p><strong>The main limitation is that the watering is uniform, but the plants are not.</strong> You can tune each dripper, but what you really want is per-plant logic: water this one once a day, that one twice. Leave the system with a lot of active time to keep the thirsty pots happy, and the smaller pots quietly get over-watered.</p>

<p>I’d love to get back to the valves-and-sensors idea. If I could find moisture sensors I actually trust, there’s still the visual problem of routing meters and meters of individual power and control cables around the balcony without it looking like a bomb.</p>

<p>Moving everything off the prototype board onto a proper custom PCB, with a 3D-printed enclosure, would be the satisfying finish. Maybe at some point, if I find the time!</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[A weekend electronics project to keep my Berlin balcony's plants alive through the summer heat — an ESP32-controlled drip system, the components, the wiring, and what I'd do differently next time.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/hydration-station/board-and-water-tank.png" /><media:content medium="image" url="https://engineering-manager.com/_imgs/hydration-station/board-and-water-tank.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Reflections from my time at HubSpot</title><link href="https://engineering-manager.com/2026-07-07/reflections-from-hubspot" rel="alternate" type="text/html" title="Reflections from my time at HubSpot" /><published>2026-07-07T00:00:00+00:00</published><updated>2026-07-07T00:00:00+00:00</updated><id>https://engineering-manager.com/2026-07-07/reflections-from-hubspot</id><content type="html" xml:base="https://engineering-manager.com/2026-07-07/reflections-from-hubspot"><![CDATA[<p>After 2.5 years, I’m leaving HubSpot. I am super proud of my time here. It was exceptionally rewarding, fast-paced, and dense with learning. Big change is always a great opportunity for reflection, and this post is an aggregation of high-level learnings from my time there. Most of them deserve a deeper dive that I’ll write up over next months, and this post is just a start.</p>

<h3 id="what-did-i-do-at-hubspot">What did I do at HubSpot?</h3>

<p>I led engineering in the Flywheel Product Line, and our mission was to reinvent our own GTM motions with AI. The opportunity is obviously huge with over 4000 people in GTM roles, and SMB-centric, 300k-strong customer base.</p>

<p>This mission only solidified around mid-to-late 2023, after the ChatGPT moment, and the opportunity and shape of the org shifted from a more traditional business-systems team to a product-led innovation team within HubSpot. It was a truly creative and innovative time where we tried tens of ideas, rebuilt products many times as models got stronger, and learned a ton about both GTM and AI more broadly.</p>

<p>We were early with our ideas. We prototyped and productionized internally plenty of things you now see live in HubSpot, Salesforce, Gong or many other GTM startups. Things like call summaries with structured data extraction against sales methodology, smart deal progression, deal coaching and best-next-actions, demo environment generation, customer agents, and agentic enrichment systems. Many, many more.</p>

<p>We worked hand-in-hand with our RevOps folks, business strategists, and business analysts. They bring exceptionally deep domain expertise, and when paired with a strong, high-agency product team, you can deploy AI effectively.</p>

<p>But it wasn’t an easy ride from the start. When I started in 2024, the org was in need of a strong turnaround. The mission was too big for the shape of the talent, processes, and principles the team had at the time. I had to turn that org around, elevate our best folks, and help us push through the high-density learning ahead of us.</p>

<h3 id="turning-around-the-org-of-20-teams">Turning around the org of 20+ teams</h3>

<p>The first challenge was the sheer size of the org. We were over 110 engineers, over 20 teams, and 10+ Director+ stakeholders on the GTM side.</p>

<p>The first step is correctly identifying problems, based on a triangulated view from teams on the ground. I believe that in a turnaround scenario, when you join, the classic listening tour only works at a small scale of a few teams. With this much diversity in engineers and levels, and a lack of trust on both sides, you need to get close to the work and be willing to step down a few levels. <strong>You just shouldn’t blindly trust anything, stay sceptical and validate and understand very closely.</strong></p>

<p>I spent over 3 months working most of my day as an engineer, directly with 4 teams (and some adjacent teams from time to time), in different parts of the org. The embed gave me a rare opportunity to build trust and deeply triangulate the problems and opportunities. I could hear them, probe them with other folks in the team or stakeholders, and validate them myself as someone who is part of the day-to-day team. If I ever join an engineering org for turnaround purposes, I would definitely repeat that.</p>

<p>After that, my direct team of senior managers and my product partners built our first pass at a new mission, vision, and strategy. This turnaround was pretty big (from order-taker to innovation hub), and landing a change like this is all about messaging. This takes quarters to land, and one of my team members gave me a rule of thumb I still use: <strong>if you feel like you’re communicating just right and it’s not uncomfortable for you, you’re not communicating enough.</strong></p>

<p>During my embed, I spent a lot of time identifying high-performers, validating that with my management team, and later elevating them and strengthening their ownership and agency. You need folks who will champion the new world at every level, and I think it’s critical to connect a few levels down in your org to make sure the message lands exactly right, without anything lost in translation. <strong>Folks look up to other successful folks, and peer pressure, peer coaching and peer role modeling are one of the highest leverage options.</strong></p>

<p><strong>Last, speed needs to be uncomfortable, even for you and your most trusted people.</strong> I learned that after Eoghan McCabe came back to Intercom in 2022 as CEO and really shook us deeply in how fast you can move. It stayed with me as a beacon of how fast you need to swing the pendulum to see the change. In HubSpot, everyone told me I was moving too fast. Many tenured people told me that rolling out change this way wasn’t the HubSpot way. This might have been true in the past, but in turnaround you need to swing hard.</p>

<h3 id="reinventing-with-ai-means-flip-flopping-between-a-blank-check-and-scrutiny-of-impact">Reinventing with AI means flip-flopping between a blank check and scrutiny of impact</h3>

<p>If you’re following X right now (July 2026), you’ve definitely seen the growing concern about token cost, ROI, and budgeting. None of this existed just four months ago. We’ve seen this cycle repeat multiple times internally since I started (not about costs though, but about impact). Generally, I’ve seen broad conviction on AI across execs and senior leadership, but at some point expectations shift and you move abruptly from “that’s awesome, can you also do X” to “okay, but where’s the impact.”</p>

<p><strong>There is so much cool stuff you can build with AI. It’s very easy to spend weeks to months building exactly the wrong thing.</strong> We spent a lot of time breaking down the customer journey — what are reps’ actual jobs-to-be-done, what are they doing day to day, and which of those tasks are both time-consuming and feasible for AI, at its current state, to deliver with great quality.</p>

<p>From there, we found ways to measure performance on these jobs — leading metrics, often output metrics. Where possible, we showed the connection to outcome metrics, and in a few places even built causal models that proved causality.</p>

<p>One interesting observation: <strong>incentives and the bar need to move up as you build conviction that your product is making people more efficient</strong>. That’s what actually forces a behavior shift and lets you capitalize on the time you’ve earned back. Without that, your beta testers will usually show outsized impact, and as you roll out from a population of 150 people to 1,500, you’ll see lower impact numbers because some percentage of people just take it easier.</p>

<h3 id="introducing-ai-to-your-gtm-team-has-to-compound-over-time">Introducing AI to your GTM team has to compound over time</h3>

<p>Throwing in tools without a clear vision for how they compound isn’t helpful. It makes your data murky and makes it hard to compound value. We rolled out three huge tools at a similar time, in an uncoordinated way - Lovable, Glean, and Claude. Within months we lost trust in our data, saw big shifts in rep behavior, and watched people drift away from proven tools toward more exploratory builder tools. That was a mistake I wish we avoided.</p>

<p><strong>Instead, choose a small set of tools that you’re going to manage, enable, and iterate on.</strong> They need to be highly extensible, continuously invested in, and built to enable experimentation. Claude Code/Cowork is a good example, and I can totally see why they are winning the market share that fast. They are just an awesome compounding platform.</p>

<p>On the other hand, this also means that there is often a potential compounding effect on your impact metrics. Many of our bets quickly showed good indicative metrics, but still took almost a year to play out with visible, double-digit impact on the entire rep population.</p>

<h3 id="lessons-from-building-products-translate-to-building-internal-ai-for-your-gtm">Lessons from building products translate to building internal AI for your GTM</h3>

<p>My product partner and I came from Twilio and Intercom respectively, where we spent our time building core products. We brought some of that approach to internal GTM.</p>

<p>We anchored on user research and deep understanding of problems and user jobs. We always started with a small alpha group that had a mandate to be early adopters and partner with us. We sometimes had to give them a small quota relief to ensure they had time.</p>

<p>Later, once we had feedback that the solution was proving valuable and covered most edge cases, we’d expand to a larger beta group (no mandate, no relief). This gave us high-confidence numbers and let us compare beta-group performance to non-beta-group performance to prove impact.</p>

<p>After having that data, we would roll out, enable, and sometimes even force usage after alignment with rep leadership.</p>

<p><strong>The most important lesson here: if you move too fast through these stages, you lose the opportunity to prove impact, and you’ll most likely ship an underwhelming product to a broad audience, losing their interest and burning the bridge early.</strong> While distribution is easier for internal products than for commercial ones, you still can’t cheat real value or user perception.</p>

<h3 id="ai-adoption-is-led-by-leadership-and-managers">AI adoption is led by leadership and managers</h3>

<p>I think every company has already realized that to push AI adoption, you need to push from the top, keep people accountable, and role-model the behavior.</p>

<p>We saw great acceleration when our sales leadership set a quota for AI adoption across their teams. This forces the org to be more open-minded and active in product development - providing feedback and giving you more quantitative insight.</p>

<p><strong>To make that work, though, your managers need to see the value in AI first. They need to use it. That’s what gets them talking about it, role-modeling it, and getting their team to follow.</strong> One of our products have seen very fast adoption among sales reps, mostly because we optimized it and enabled for first for managers. They loved it, and used it on their pipeline reviews with their teams and created great word of mouth marketing.</p>

<h3 id="revops-as-code">RevOps-as-code</h3>

<p>We haven’t gotten there yet fully, but I believe Operations will go through a shift similar to DevOps and infra-as-code.</p>

<p>Everything will become defined in code, instead of visual workflows and a patchwork of tools stitched together. Code is testable, observable, reversible, and Claude can navigate it and reason through second- and third-order consequences in ways that visual workflows, lists, and recipes just can’t.</p>

<p>We’ve seen this play out many times: workflows stitched together through tools become safer to change, more reliable, and better tested and understood once they move to code. <strong>And non-engineers can easily suggest changes by working with Claude Code, opening PRs, and — after review by engineers — getting them safely into production.</strong></p>

<p>I don’t think there exists a good platform yet to actually do that at scale, and with x-company transferable knowledge (what terraform was). If this prediction comes true, I would bet we will see inspiration in tools like Apache Airflow, and I wouldn’t be surprised if Workato goes this direction, or AWS comes up with a more generic solution.</p>

<h3 id="the-ml-data-science-and-engineering-roles-are-getting-closer-together">The ML, Data Science, and Engineering roles are getting closer together</h3>

<p>Especially if you are building AI products, it will be hard to say when traditional software engineering ends and machine learning starts, and vice-versa. You just can’t build successful products without evaluations, creating benchmark datasets, experimentation. Not even going further into fine tuning or your own RL.</p>

<p>You still definitely need experts to set direction and make a handful of significant, multi-quarter decisions. <strong>But everything in between seems to boil down to great engineering talent that has good fundamentals, can stretch across domains and has access to frontier model with sizable budget.</strong></p>

<p>There’s nuance in speed, though. A product engineer building a classification engine with good recall and precision is totally feasible, but it’ll probably take a few weeks and some oversight from an expert. An expert would probably do it in days. But as the muscle gets stronger, it gets faster.</p>

<p>I think the solution will more and more frequently be one team - MLEs and data scientists working hand in hand with product engineers, with frequent tours of duty with software engineers building up their MLE chops by leading the work under supervision.</p>

<h3 id="everyone-needs-platform-wide-ai-primitives-that-accelerate-the-entire-company">Everyone needs platform-wide AI primitives that accelerate the entire company</h3>

<p>Our DevEx team at HubSpot did an exceptional job building critical AI primitives that power core HubSpot features and are highly extensible. We were lucky that HubSpot’s business also relies on those so they received a big amount of investment that we didn’t need to pay for, but there are great open-source options out there too.</p>

<p><strong>First, our principle was to expose everything as MCP endpoints.</strong> All internal systems should be MCP-first. That lets us integrate with Claude, HubSpot Breeze, or whatever other tool pops up in the coming months.</p>

<p><strong>Second, you need an agent harness that’s highly extensible.</strong> We built our custom, but Agno was also used in a few places at HubSpot. I didn’t play around with Claude Managed Agents, but I would be vary of locking yourself down to just Anthropic models.</p>

<p>You also need a knowledge base that’s accessible programmatically via RAG, text search, and with metadata filters. We had to build something specifically for our use-case, but there are plenty of good products out there.</p>

<p><strong>I strongly believe that you also need storage for the AI insights and signals your data scientists and AI engineers generate.</strong> Discoverability is key and we quickly realised that there is huge amount of insight that we generate, don’t scale, and duplicate. That’s why we built our own platform, that mapped entities 1:1 with our HubSpot instance. It acted as an extension of a CRM, but for unstructured data and agent-first discoverability.</p>

<p>And last but not least, you need an evals system. We had some custom built solutions but really struggled to take it off at scale of many teams. Later on, we migrated to Braintrust and its ease of use let us dramatically ramp up eval coverage and get both engineers and PMs close to agent traces.
&lt;/content&gt;</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[After 2.5 years leading engineering in HubSpot's Flywheel Product Line, I reflect on turning around an org of 20+ teams and the lessons I learned building internal AI products for GTM.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/lessons.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/lessons.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Thew new talent bar for software engineers</title><link href="https://engineering-manager.com/2026-01-26/new-talent-bar-for-software-engineers" rel="alternate" type="text/html" title="Thew new talent bar for software engineers" /><published>2026-01-26T00:00:00+00:00</published><updated>2026-01-26T00:00:00+00:00</updated><id>https://engineering-manager.com/2026-01-26/new-talent-bar-for-software-engineers</id><content type="html" xml:base="https://engineering-manager.com/2026-01-26/new-talent-bar-for-software-engineers"><![CDATA[<p>Web software engineering is changing rapidly. While this shift is not equally distributed and is often easy to overlook in the day-to-day, it is coming whether you are ready or not.</p>

<p>The reality is that writing software is becoming cheaper and faster at an incredible rate, but the systems we’ve built around that production are lagging. Our expectations of who does what - between the engineer, tech lead, PM, or designer - remain rooted in an older era. Our product development cycles, release processes, customer access protocols, and even interview loops were all battle-tested for a world where building a feature took a month. They are not built for a world where that same feature takes a couple of days.</p>

<h2 id="the-innovators-dilemma-in-engineering">The Innovator’s Dilemma in engineering</h2>
<p>We must embrace this change through the lens of the Innovator’s Dilemma. We see AI startups and frontier labs moving with exceptional speed. If you are an incumbent, you need to disrupt your own delivery model. This is about far more than just giving your team Claude Code; it’s about rethinking the entire system: tools, processes, performance expectations, and roles.
Engineers are smart and they don’t stay idle. Currently, we see more code being produced everywhere, but too often that code is channeled into areas most comfortable for software engineers: tech debt, low-priority to-dos, minor refactoring or unambiguous small features. While valuable, that isn’t where the real business impact lies.</p>

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">It is possible to have incredibly capable models, 6 OpenCode windows open all day, 12 ralph loops running 24/7, make 600 commits and close 400 linear issues a day, and yet ship absolutely nothing of value to anyone. <a href="https://t.co/oRG4N1L6i2">pic.twitter.com/oRG4N1L6i2</a></p>&mdash; Nate Berkopec (@nateberkopec) <a href="https://twitter.com/nateberkopec/status/2009886843115065715?ref_src=twsrc%5Etfw">January 10, 2026</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>A useful mental model of you can expand your software as a company gives you three dimensions:</p>
<ul>
  <li><strong>Horizontal expansion:</strong> Moving into new product lines. However, doing that successfully in a way that contributes to the business growth is constrained by your GTM engine and distribution. It’s pretty ambiguous both from product and GTM perspective and no “pure” software engineer could drive anything like this.</li>
  <li><strong>Vertical deepening:</strong> Making existing features more powerful. However, this dimension is constrained by your user ability to absorb change and adopt new software. It’s pretty ambiguous from a product perspective and usually only very senior software engineers would autonomously drive changes like this.</li>
  <li><strong>Surface quality improvements:</strong> Small fixes, product improvements, performance, COGS and refactors. This area is usually pretty safe, predictable and unambiguous. That’s what most engineers usually focus on.</li>
</ul>

<p>The first two buckets are today constrained by PM and UX bottleneck. Therefore, most AI-driven acceleration in writing software ends up in the third bucket. The real challenge for leaders is shifting those gains into the first and second ones and it’s very hard to do this without evolving the entire system of product delivery.</p>

<h2 id="the-new-talent-bar">The new talent bar</h2>
<p>To address this, I am explicitly operationalizing three dimensions of talent within my organization at HubSpot through performance expectations and growth plans. To remain competitive, software engineers need to embrace these three pillars:</p>

<h3 id="1-you-need-to-be-a-product-engineer">1. You need to be a Product Engineer</h3>
<p>AI is an extension of your agency in delivering business value. Historically, there has been a “fat-head” distribution of impact, where certain, usually very senior, engineers had outsized influence because they could identify and disambiguate high-value problems.</p>

<p>I think that in the coming year we are going to see massive compression of expected behaviours among software engineering levels. What was once a Staff-level behavior - taking ownership of the “surround system” of PMs and designers - is becoming the baseline for mid-level engineers. What will still be obviously different is the expected level of impact - you would expect significantly more differentiation and value from staff than from mid-level engineer. But if an engineer can ship 50-70% more code today, but the PM and design “surround system” isn’t ready for that velocity, they become the bottleneck.</p>

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Software is no longer the bottleneck.</p>&mdash; martin_casado (@martin_casado) <a href="https://twitter.com/martin_casado/status/2007622894327869755?ref_src=twsrc%5Etfw">January 4, 2026</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>To bridge this, engineers must become subject matter experts in the products they build. You must understand the customer and the business model. You need to be opinionated, research and develop some bets that you want to explore proactively, be able to ask the right questions and find answers yourself, and even think about and act on things like activation, enablement or support.</p>

<p>Being a Product Engineer is about taking ownership of the surrounding system of the past. You need to become a subject matter expert in products you are developing, understand your customers and how you ultimately make money as a business, and be able to identify, and take ownership of actual business outcomes. Performance will increasingly be measured by business impact rather than output metrics like PR counts.</p>

<p>If you would like to learn more about that, some great posts about this:</p>
<ul>
  <li>https://blog.pragmaticengineer.com/the-product-minded-engineer/</li>
  <li>https://leerob.com/product-engineers</li>
  <li>https://x.com/levie/status/2010055953157357622</li>
</ul>

<h3 id="2-you-need-to-be-fullstack">2. You need to be fullstack</h3>
<p>While specialization will always exist, for example in infrastructure, my bet is that the total number of “siloed” engineers will decline. If your value is tied to your ability to deliver business outcomes, you cannot be limited by the stack; business value rarely stops at the boundary of a frontend or backend change.
With AI-assisted coding, there is no longer an excuse to specialize in a single language. You must be an engineer who understands the entire system - from browser behavior to distributed system design. You should still cultivate “T-shaped” depth in one or two areas, but you must feel comfortable navigating the entire stack with AI as your navigator.</p>

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">A timely read on product engineering: and why the frontend/backend split just doesn’t make much sense any more (at startups, the very least, and at nimble teams and companies) - by <a href="https://twitter.com/leerob?ref_src=twsrc%5Etfw">@leerob</a> <a href="https://t.co/PLWzrei4rJ">https://t.co/PLWzrei4rJ</a> <a href="https://t.co/4014jBUmCI">pic.twitter.com/4014jBUmCI</a></p>&mdash; Gergely Orosz (@GergelyOrosz) <a href="https://twitter.com/GergelyOrosz/status/2006430245839143171?ref_src=twsrc%5Etfw">December 31, 2025</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<h3 id="3-ai-is-a-key-part-of-the-stack">3. AI is a key part of the stack</h3>
<p>AI used to be the exclusive domain of MLEs. Today, it is being democratized. However, the best products are more than just thin wrappers around an LLM; they are complex systems involving RAG, online/offline evaluations, experimentation pipelines, and context management.</p>

<p>These principles are becoming as fundamental as distributed systems design. Just as a backend expert needs to feel comfortable with the frontend, they must now feel comfortable operating within AI systems. AI is no longer a “plugin”; it is a core component of the modern stack.</p>

<h3 id="its-not-your-decision-its-a-market-decision">It’s not your decision. It’s a market decision.</h3>
<p>If you don’t lean in now, you risk waking up one day to find you are no longer competitive. This shift toward high-agency, commercially-minded, fullstack engineers is already the “bread and butter” of startups.</p>

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">With future advances in AI, engineers with this talent stack will be unstoppable<br /><br />-strong eng skills<br />-good (top 10%) product sense<br />-decent (top 25%) commercial sense<br />-high agency<br />-clear communication<br /><br />They’re valuable today, but with AI they won’t need large teams nor any PM help</p>&mdash; Shreyas Doshi (@shreyas) <a href="https://twitter.com/shreyas/status/1747063964088099316?ref_src=twsrc%5Etfw">January 16, 2024</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>For incumbents, the clock is ticking. You cannot catch up to the speed of a hungry startup just by buying tools. Their advantage comes from the entire system of delivery and the shape of the talent they attract. As an engineer, you must grow into these categories. As a leader, you must operationalize them. The tools are available to everyone — the system is what will set you apart.</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[Web software engineering is changing rapidly. While this shift is not equally distributed and is often easy to overlook in the day-to-day, it is coming whether you are ready or not.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/lessons.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/lessons.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Lessons from Intercom on being a product engineering leader</title><link href="https://engineering-manager.com/2024-01-30/lessons-from-intercom" rel="alternate" type="text/html" title="Lessons from Intercom on being a product engineering leader" /><published>2024-01-30T00:00:00+00:00</published><updated>2024-01-30T00:00:00+00:00</updated><id>https://engineering-manager.com/2024-01-30/lessons-from-intercom</id><content type="html" xml:base="https://engineering-manager.com/2024-01-30/lessons-from-intercom"><![CDATA[<p>I am finishing my time at Intercom and this post is an attempt of reflection and summary of lessons from my time there. I had a privilege to work there for 6 years, across 3 roles, in 3 product groups, with tens of unique, talented and engaged folks.</p>

<p>There is way more that came to my mind that I decided to not put online at this stage. After 6 years with intercom, I am not sure how much I take for granted at this stage vs. what is a genuine good lesson to share. I am curious to hear how these things resonate with you and especially if you have counter lessons!</p>

<h2 id="leadership">Leadership</h2>

<h3 id="1-always-start-from-the-problem-and-ensure-alignment-with-your-stakeholders-that-this-is-the-right-framing-scope-and-priority-of-the-problem">1. Always <a href="https://www.intercom.com/blog/intercom-product-principles-start-with-the-problem/">start from the problem</a> and ensure alignment with your stakeholders that this is the right framing, scope and priority of the problem.</h3>
<p>Hands down, starting from the problem is the most powerful, life-changing lesson from Intercom. It applies to everything and provides an almost magical amount of focus in everything you do. What is the problem really? How do you know it? How will you know it’s solved? Does this sound worth spending time on? Applies to product, processes, org shape, home decoration, car repair, anything. It provides clarity like nothing else.</p>

<h3 id="2-mission-vision-ownership-and-strategy-are-the-most-impactful-tools-to-achieve-high-performing-orgs">2. Mission, vision, ownership and strategy are the most impactful tools to achieve high performing orgs.</h3>
<p>I <a href="https://engineering-manager.com/2023-07-27/turning-around-an-org">wrote more about it here</a> in a slightly different context, but with a similar conclusion - work with orgs on mission, vision, ensure they have and feel real ownership to create their own strategy and then keep them accountable for progress. Autonomy and sense of identity drive exceptional engagement and motivation which is critical whenever times get hard (and they will!). It trumps tactical goals setting or even worse, zooming in as a leader to micromanage, despite these tools providing faster but limited return.</p>

<h3 id="3-when-choosing-long-term-strategy---conviction-resiliency-and-communication-are-critical">3. When choosing long-term strategy - conviction, resiliency and communication are critical.</h3>
<p>We created and executed a 2-year long product and technical strategy that shipped a new outbound product for Intercom and migrated all existing customers into it from legacy systems. We doubted ourselves plenty of times. It’s critical to maintain conviction, resiliency and ways of ensuring you are on the right track, according to schedule. Underpromise, overdeliver, keep shipping, stay focused. At the same time, you need to keep your stakeholders and supporters informed and close enough that they don’t get misaligned on timelines, progress or complexity of the work you are going through.</p>

<h3 id="4-quick-and-dirty-process-accelerators-are-great-to-test-locally-and-scale-globally-when-proven-to-work">4. Quick and dirty process accelerators are great to test locally and scale globally when proven to work.</h3>
<p>When I was EM in one of my teams, we had over 200 open issues. I couldn’t wrap my head around that and either understand how our state changes or what’s most important to work on. I came up with a script that scored each issue based on how many customers were affected . This quick and dirty prioritisation system got huge traction at Intercom and became our default and org-wide tool with its own Tableau dashboards. Similarly, when I was tasked to create an escalation process for our sales team, we ran a local experimental version with one product group and simple Coda doc before making it a global default with its own tooling. Test processes locally and <a href="https://www.intercom.com/blog/engineering-processes-need-solve-problems/">make sure they solve a problem</a>.</p>

<h3 id="5-you-can-achieve-spectacular-outcomes-when-focusing-on-one-thing-but-with-real-trade-offs">5. You can achieve spectacular outcomes when focusing on one thing, but with real trade-offs.</h3>
<p>The last year at Intercom (2023) was a year of impatience and moving fast under the new CEO. Moving fast was the most important thing across the company. We did spectacular things, we made aggressive and ambitious decisions, we moved the fastest I saw Intercom move, despite being 3x bigger than when I first joined. This was the right thing to do in the wake of AI even though it came with real trade-offs on our product health that we had to pay down year later.</p>

<h2 id="people--management">People &amp; management</h2>

<h3 id="1-fair-objective-and-transparent-are-the-most-important-principles-in-compensation-and-performance-evaluation">1. Fair, objective and transparent are the most important principles in compensation and performance evaluation.</h3>
<p>I deeply believe in these three principles when evaluating performance or deciding on compensation incentives or raises. There are plenty of processes that help you achieve them - transparent and written expectations across levels, calibration and promotion meetings, obligatory peer feedback, compensation bands or compensation policies.</p>

<h3 id="2-find-the-right-shaped-opportunities-to-help-people-thrive-and-achieve-impact">2. Find the right-shaped opportunities to help people thrive and achieve impact.</h3>
<p>Sometimes folks don’t perform and it’s important to ask yourself if there is a better place in the organisation that would benefit from their skillset, instead of managing them out from their current position or coaching them to grow where they are. I found a few folks who performed amazingly after we found the right-shaped opportunities for them. But don’t let under-performance slip when there are no high-confidence opportunities in sight. You can’t manage underperformance by moving people around.</p>

<h3 id="3-there-is-always-a-reason-why-someone-is-underperforming-learn-that-before-you-act">3. There is always a reason why someone is underperforming, learn that before you act.</h3>
<p>Empathy and trust will let you understand why someone is underperforming. As a manager, my job is to get the best out of my team and it’s critical to understand the problem - why someone might be underperforming? Stress at home, financial problems, personal conflict or health problems can happen behind the scenes without your reports notifying them about them upfront. Dig and understand what’s the reason to find the right shaped way forward before you act.</p>

<h3 id="4-building-managerial-support-networks-in-your-team">4. Building managerial support networks in your team.</h3>
<p>Management is a lonely job, your managers should rely on each other. I <a href="https://engineering-manager.com/2023-07-17/power-of-peer-coaching-for-managers">wrote more about it here</a>.</p>

<h3 id="5-routines-can-help-you-avoid-burn-out-and-keep-you-on-track">5. Routines can help you avoid burn-out and keep you on track.</h3>
<p>Planning, reflections, operation data reviews - it all creates a personal operating system of routines that provide time and activities that help you get perspective. It’s self-management practice that helps me to not burn out and stay on top of what’s going on. It helps me to identify and know when to take a break. Dropping my routines is usually the first sign that I am going sideways and need to take care of myself.</p>

<h2 id="industry-and-customers-trends">Industry and customers trends</h2>

<h3 id="1-support-is-a-slow-moving-industry-it-takes-a-lot-of-energy-and-work-to-see-the-change">1. Support is a slow moving industry. It takes a lot of energy and work to see the change.</h3>
<p>Support orgs on average have high inertia. Change management and switching costs are expensive, and if it’s not showing clear ROI, it’s very tough to drive any change in tools or processes. It works, but it’s tougher than it sounds. Adoption is a big challenge.</p>

<h3 id="2-support-has-a-chance-to-generate-money-not-only-be-a-cost-centre">2. Support has a chance to generate money, not only be a cost centre.</h3>
<p>There is a huge opportunity ahead of Customer Support orgs to generate revenue and not only be a cost centre. AI is the tool to enable it. Remove the friction by engaging with AI bots and create exceptional co-pilot experiences that help agents drive value, not just manage questions.</p>

<h3 id="3-sales-and-marketing-are-moving-very-fast-experiment-and-expect-immediate-results">3. Sales and marketing are moving very fast. Experiment and expect immediate results.</h3>
<p>Sales and marketing have easier adoption curves, but way harder to retain. Showing ROI and before/after data is critical. If you can’t deliver, the tool or the process is out. I ran a rollout of an embedded iPaaS solution for our high-spend customers and failed. With reflection, we have set the revenue bar too high to show ROI quickly and never earned sales team trust that this can work. Go for quick, visible ROI.</p>

<h3 id="4-omnichannel-is-great-but-email-is-still-considered-the-primary-channel-for-many">4. Omnichannel is great, but email is still considered the primary channel for many.</h3>
<p>During my time in Outbound product at Intercom, it was clear that customers wanted email. They loved our omnichannel. Especially in-apps with their impressive engagement rates. But new things do not always replace old, and often rather expand the landscape. Marketers need reliable, powerful email tools to be your primary tool.</p>

<h3 id="5-in-app-is-the-best-channel-for-engagement-support-and-x-re-up-selling">5. In-app is the best channel for engagement, support and x-/re-/up-selling.</h3>
<p>I loved to see some exceptionally well designed proactive support, consisting of product tours, banners, tooltips and timely send in-app messages. It makes the experience of onboarding magical. I loved that we could message tens of our customers and ask them for feedback, and they would reply in less than 24h, with additional thoughts. It’s magical both ways.</p>

<h3 id="6-partners-need-well-designed-incentives-and-reporting-to-drive-action-and-its-harder-than-sales-because-its-based-on-trust-and-relationship-not-a-work-agreement">6. Partners need well designed incentives and reporting to drive action, and it’s harder than sales because it’s based on trust and relationship, not a work agreement.</h3>
<p>Intercom had to deprioritise our investment in App Partnerships and thus we weren’t able to build valuable long-term relationships with our key developers. When we ultimately had some ideas on how they could help us and our common customers, it was tough to get traction. Partnerships are amazing but need to be nurtured, based on the right incentives and give insight into the right reporting to drive behaviours.</p>

<h3 id="7-data-management-is-evolving-painful-and-its-tough-to-find-a-silver-bullet">7. Data management is evolving, painful, and it’s tough to find a silver bullet.</h3>
<p>We owned a relatively flexible, broadly used data platform. It was the area of product with the biggest amount of feedback. But making an impact in this space was very tough. The market is evolving fast, with new approaches like ETL, reverse-ETLs, point-integrations, CDPs. Your customer base will be spread over multiple different “best ways of managing your data”. And because of that, there was never a single problem. It’s always been long lists of problems, similar in some shapes but different. To see meaningful change and improvement in data management, it’s all about strategy and consistency. Small investment shots here and there rarely work.</p>

<h3 id="8-data-integrations-are-getting-more-complex-the-more-you-dive-into-them">8. Data integrations are getting more complex the more you dive into them.</h3>
<p>I learned to never underestimate x-system integrations. From the distance, they always sound relatively easy - if this then that, sync objects in system A with the same objects in system B. But the closer you get there, the more differences you discover. Small nuances, API rate limits, race conditions, retries and lack of idempotence, and many more angles. The devil is always in the details, and the jobs your customers are trying to achieve with these integrations are often similar but fundamentally unique. Integrations are super complex the deeper you get and don’t underestimate it.</p>

<h3 id="9-building-easy-to-adopt-integrations-is-removing-friction-but-reduces-the-tam">9. Building easy to adopt integrations is removing friction, but reduces the TAM.</h3>
<p>The most easy to use integrations are layers of abstractions on top of core capabilities (like APIs or iPaaS blocks). They remove a lot of friction, usually working out-of-the-box. What I realised over the years is that building broadly adoptable integrations is very tough as every company has unique needs, setup and their own internal IT mess to navigate. Building these abstractions makes it easier to adopt, but significantly reduces the TAM, which can end up with a lower number of customers using them versus a more complex but powerful version.</p>

<h3 id="10-ai-will-fundamentally-change-how-we-build-integrations">10. AI will fundamentally change how we build integrations.</h3>
<p>However, I believe it will play out differently for deterministic, high-volume integrations and non-predictable, dynamic integrations. Co-pilot experiences will dramatically accelerate users in iPaaS tools like Zapier or Workato while creating repeatable, high-volume, predictable process automation. This will accelerate them, while maintaining today’s reliability and ultimately deterministic behaviour of these integrations.</p>

<p>AI agents that can reason about which tools to use and adapt to dynamic and unpredictable input will remove a need for building any integrations (outside of API capabilities) in human-triggered activities like reporting, data exploration or asking for help.</p>

<h2 id="product--technology">Product &amp; Technology</h2>

<h3 id="1-betting-only-on-the-future-is-like-shorting-the-present-and-it-carries-opportunity-cost">1. Betting only on the future is like shorting the present and it carries opportunity cost.</h3>
<p>Intercom is an extremely innovative company and our senior leadership is excellent in having great insight into what trends will make waves in the market. But betting only on that, building only for the future, is like shorting the present and thus carries the opportunity cost. I think we lived that both with emails and phone, being confident for years that these channels were dying just to realise year by year that they were not shrinking at all. I find it a useful mental model when you think about the future trends - would you short present and be willing to pay the premium if you continue to be wrong?</p>

<h3 id="2-every-developer-needs-to-have-ai-in-their-toolbox">2. Every developer needs to have AI in their toolbox.</h3>
<p>When we worked on Fin, our AI team was fundamental to making it work, we wouldn’t be able to do it as a product team. However, as time goes by, more and more excellent features have been built into that system directly by our product teams. The initial push might need specialisation, but it’s important to expose AI to your org as soon as possible. Another engineer shared with me this anecdote - 2008 companies had whole teams that did “mobile design”. Only a few years later it was part of everyone’s job.</p>

<h3 id="3-monoliths-can-scale-very-well-give-solid-deployment-safety-and-high-leverage-for-developer-experience-and-observability">3. Monoliths can scale very well, give solid deployment safety and high leverage for developer experience and observability.</h3>
<p>I continue to be amazed how well Intercom Rails monolith scaled over the years. Exceptional engineering decisions, keeping it simple, huge leverage that our only developer experience team has, and sticking to proven cloud technologies helped us navigate growth really well. Monolith is also something that keeps our ability to ship very fast, very often (by sweating things like fast rollbacks or quick CI). We found a few services we owned painful to work with in comparison - teams had to be slowed down by maintaining their own dependencies, deployment pipelines or infrastructure updates. I never worked in professional, high-scale microservices architecture but can’t wait to learn about the trade-offs there.</p>

<h3 id="4-how-often-you-ship-is-critical-its-your-heartbeat">4. How often you ship is critical. <a href="https://www.intercom.com/blog/shipping-is-your-companys-heartbeat/">It’s your heartbeat.</a></h3>
<p>You might not ship it to customers, but there is always a way to ship to production in a safe way. If you can’t find it, keep looking. It builds muscles for accelerating even more when necessary, and keeps you from losing impatience culturally. Show off the progress, ideally on a regular company-wide demo.</p>

<h3 id="5-create-plenty-of-system-models-mental-models-apply-different-layers-of-abstractions-looking-at-the-same-problem-from-different-angles-really-expands-your-horizons-and-builds-alignment">5. Create plenty of system models, mental models, apply different layers of abstractions. Looking at the same problem from different angles really expands your horizons and builds alignment.</h3>
<p>At Intercom I learned that the best meeting starts when someone picks up the marker. System models, made up on the spot, adapted, evolved as you talk - all of this really help sharpen your understanding of the problem, its complexities and dependencies. Write them down, show them in your next conversation, ask for someone else’s mental model. Doing this work together with your partners and stakeholders accelerates how fast you collaborate and removes misalignment. Shared mental models are very useful for moving fast.</p>

<h3 id="6-own-and-know-your-data-without-excuses">6. Own and know your data, without excuses.</h3>
<p>I had plenty of excuses in the past for my understanding of product data without the help of data analyst to craft queries and analysis techniques. I have no mercy for myself since ChatGPT. The quality and confidence in which I can navigate my product space without a dedicated analyst has increased dramatically and I expect that everyone at least slightly technical can do it now.</p>

<p>I also want to say big thank you to everyone I worked with and that helped me form these lessons over the years. Intercom is a truly special company!</p>

<p>Last but not least, thank you to my two long-term partners in crime that provide me with great review and feedback on that blog post, Stephen Forbes and Aidan Lynch!</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[I am finishing my time at Intercom and this post is an attempt of reflection and summary of lessons from my time there. I had a privilege to work there for 6 years, across 3 roles, in 3 product groups, with tens of unique, talented and engaged folks.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/lessons.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/lessons.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Turning around a group of teams</title><link href="https://engineering-manager.com/2023-07-27/turning-around-an-org" rel="alternate" type="text/html" title="Turning around a group of teams" /><published>2023-07-27T00:00:00+00:00</published><updated>2023-07-27T00:00:00+00:00</updated><id>https://engineering-manager.com/2023-07-27/turning-around-an-org</id><content type="html" xml:base="https://engineering-manager.com/2023-07-27/turning-around-an-org"><![CDATA[<p>At the beginning of this year as Intercom was going through a reorg, I took on leadership of a group of teams standing on shaky ground. We had experienced layoffs, endured challenging quarters, and accumulated multifaceted debt—product, personnel, and technology.</p>

<p>Addressing such complex issues without being hands-on or without deep comprehension of the problems can feel overwhelming.</p>

<p>Here is the strategy my leadership team and I adopted to improve our situation. This approach incorporated cross-functional collaboration among product, design, and engineering teams.</p>

<h2 id="commit-and-understand-constraints">Commit and understand constraints</h2>
<p>First, it’s crucial to commit to the turnaround alongside your leadership team. This is especially significant for those who have been part of the setup for an extended period, who may feel inertia or hopelessness. Set a time-bound, feasible goal to instigate a positive shift—something like, “We will establish a positive trajectory for the organization with a long-term improvement plan within the next quarter.”</p>

<p>Second, align with your leadership team on what are your constraints. What cannot be changed? There are three main categories that come to mind:</p>
<ul>
  <li>Headcount: Is it possible to bring in new team members to alleviate pressure or inject a positive spirit, or is your headcount fixed?</li>
  <li>Priorities: What commitments must your group deliver? Ensure that you stay focused here—nothing demoralizes a team more than missing an important commitment.</li>
  <li>Performance: How are the members of your leadership team performing? Know who you can depend on and who needs closer management, or potentially, who needs to transition out.</li>
</ul>

<h2 id="understand-your-problems">Understand your problems</h2>
<p>Listening to diverse voices within your teams and assessing problems before committing to priorities is vital. Don’t get swayed by the most vocal issues; they might not necessarily be the most crucial to solve. To understand and triangulate problems, we used multiple sources:</p>

<ul>
  <li>Work with your management team - they will most probably have great understanding of problems, but it’s important to validate and triangulate further.</li>
  <li>Engagement Survey - for the purpose of moving fast, we used ChatGPT to quickly come up with a good first pass of questions.</li>
  <li>Skip-levels - as director of engineering, I met with all 20 engineers in the group.</li>
</ul>

<p>Conduct your first round of prioritization with your management team. It’s impractical to involve literally every engineer in such a working sessions.</p>

<h2 id="share-back-to-the-teams">Share back to the teams</h2>
<p>Share everything you’ve learned with your teams. Create space for them to offer additional context or challenge your framing of the problems. For us, this involved one and a half hour sessions of unstructured conversation with each team. We also shared a prioritized list of problems, inviting further feedback in a less intimidating setup.</p>

<h2 id="get-alignment-with-your-senior-leadership">Get alignment with your senior leadership.</h2>
<p>Ensure that you have the support of senior leadership and their understanding of the situation’s gravity.</p>

<p>This is especially important if one of your problems is working under too much pressure. To alleviate it and create slack you need to be ruthless with priorities and only focus on what’s the most important, which is very tough without senior leadership alignment.</p>

<h2 id="move-fast-on-whats-obvious">Move fast on what’s obvious</h2>
<p>After problem discovery exercise, you might see some high-priority ones that are obvious to solve, just no-one before pulled the trigger on the solution. I suggest to fix such problems immediately and communicate that you will learn and adapt, but right now optimising for building momentum.</p>

<p>For us, some things that we did immediately were better balancing the time spent on net new product vs. issues, planning for a bit of slack for escalations from our customers that were consistently happening and kicking off work on product and technical strategy, as we knew it will take time to develop those.</p>

<h2 id="recharge-everyones-batteries">Recharge everyone’s batteries</h2>
<p>To drive change, everyone’s energy and optimism are essential. I used my tried-and-tested strategy to manage team burnout following an incident.</p>

<p>We sent everyone on a brief break, not counted against their holiday allocation. This forced recharge helped maintain a positive outlook.</p>

<h2 id="create-a-vision-of-an-org-that-everyone-wants-to-be-part-of">Create a vision of an org that everyone wants to be part of</h2>
<p>Creating a visual description of your goals is important. A vision that balances your teams’ challenges and the broader organization’s needs can inspire optimism and aspiration, as long as it’s grounded in reality.</p>

<p>Our vision was rooted in the problems identified earlier. We focused on five pillars—product, customers, people, structure, technology—as we had experienced issues in each. For example, one of the pillars was framed as:</p>

<p><strong>Product - Building what is aligned to broader Intercom strategy, carrying our own confidence in it and delivering impact</strong></p>
<ul>
  <li>We follow our product strategy to ensure that we contribute to long-term success of Intercom. We are adaptive and embrace change coming from outside but we always think through and are transparent about trade-offs.</li>
  <li>We build a support channel first and foremost, and thus we keep end user experience top of mind.</li>
  <li>We work aligned to our R&amp;D principles. We move fast, we start from the problem, we ship often to learn and we deliver outcomes.</li>
</ul>

<p>As you see, I described each pillar using three bullet points to set the direction, leaving room for ambiguity where we had to do deeper and more detailed work to align on.</p>

<p>We then collaborated with EMs, PMs, and designers to build alignment through a short workshop where we walked through each pillar and together brainstormed examples of behaviours that support these pillars and those that are anti-patterns.</p>

<p>Lastly, we shared the vision with teams for feedback and alignment.</p>

<h2 id="assess-your-teams-against-the-vision-to-start-tracking-progress-and-trajectory-of-qualitative-change">Assess your teams against the vision to start tracking progress and trajectory of qualitative change.</h2>
<p>We conducted a survey for an assessment of how teams felt about aligning with the aspirational vision. This gave us a benchmark and reality check of the gap between our current state and aspirations.</p>

<p>We re-prioritized problems based on effort and impact. We then shared this back to the teams and engaged volunteers to initiate low-effort, high-impact changes, creating immediate momentum.</p>

<h2 id="rinse-repeat">Rinse, repeat.</h2>
<p>This is a long journey, and your vision serves as a beacon for everyone on how to operate. While I plan to repeat the assessment and problem prioritization every quarter, smaller organizations may need to do this monthly. Remember, nothing is set in stone; stay open to refining the vision over time.</p>

<h2 id="summary">Summary</h2>
<p>Managing turnarounds is strenuous. It’s essential to have a clear plan to avoid burning out. I hope this framework equips you with a practical strategy to navigate towards stability and sustainability.</p>

<p>I believe though that speed here is extremely important. There will be a lot of inertia to fight and a lot of engagement to build. Both of these things take a lot of time. The faster you move, the better chance that your team will give you a chance and believe in the change in the trajectory before they start scouting for new jobs. Speed is life here.</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[When you realise that the org you are leading became dysfunctional, it can feel hopeless. The role of engineering leader is to identify, diagnose and improve. Here is a framework that I used at Intercom to quickly turn around a group of teams that was in trouble.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/woods.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/woods.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The power of peer coaching for engineering managers</title><link href="https://engineering-manager.com/2023-07-17/power-of-peer-coaching-for-managers" rel="alternate" type="text/html" title="The power of peer coaching for engineering managers" /><published>2023-07-17T00:00:00+00:00</published><updated>2023-07-17T00:00:00+00:00</updated><id>https://engineering-manager.com/2023-07-17/power-of-peer-coaching-for-managers</id><content type="html" xml:base="https://engineering-manager.com/2023-07-17/power-of-peer-coaching-for-managers"><![CDATA[<p>In a fast-paced environment of software today, it’s crucial for engineering managers to continually improve their skills and seek support to grow in their roles. While traditional coaching by your manager and mentoring have their merits, there’s a powerful tool that often goes untapped: peer coaching within your team of engineering managers.</p>

<p>One of the most impactful factors contributing to my growth has been the peers I had the fortune of meeting throughout my career and establishing coaching relationships with them. These relationships provided me with multiple perspectives for problem-solving and project development. I hate working alone; my instinct is always to find partners in crime or coaching partners to bounce off ideas.</p>

<p>Peer coaching has not only helped me navigate tough times, but it has also alleviated anxiety by teaching me that I’m not alone in experiencing emotions and struggles – it’s normal.</p>

<p>Seeking peers to talk to was one of the most important goals for my first year at Intercom a few years ago. Many of these relationships remain my most trusted partnerships, though some have moved on, and we don’t communicate as frequently. Most of these connections were established during the earlier days of the company when it was smaller than Intercom is today. I’ve observed that managers who joined in the past two years often lack this natural coaching circle.</p>

<p>While it’s common for managers to suggest coaching partners for specific projects requiring growth or when someone is underperforming, I believe as managers of managers, we are missing an opportunity. We should ensure that coaching partners are always available for our teams, not just when there’s specific guidance on what to focus on, but also as a means for our directs to determine what is most valuable for them.</p>

<h2 id="coaching-assignments">Coaching assignments</h2>
<p>Approximately a year ago, I introduced the concept of rotating peer coaching to my team of four managers who report to me. We decided that every six weeks, they would form two pairs and hold weekly coaching meetings, regardless of their current projects.</p>

<p>We defined goals for these meetings as follows:</p>
<ol>
  <li>Build relationships through collaboration</li>
  <li>Enhance coaching skills in areas with limited context</li>
  <li>Increase accountability by having someone invested in personal growth</li>
</ol>

<h2 id="building-relationships-through-working-together">Building relationships through working together</h2>
<p>Of these three goals, I consider this the most crucial. In the age of remote work, establishing long-term relationships can be incredibly challenging, especially outside of one’s primary team. These managers excel at fostering relationships within their teams, whether between engineers or product managers. However, what about their own relationships as managers?</p>

<p>Having colleagues at the same level, without a power imbalance, whom you can trust, is a tremendous asset that can help navigate difficult times. If this peer also reports to the same manager as you, it adds further value. Shared experiences among managers, including their mistakes and subsequent learnings, create a sense of optimism that despite any errors made, there is a secondary support system. Additionally, more tenured reports can offer guidance on how to work effectively with you, often pointing out unconscious patterns.</p>

<h2 id="enhancing-coaching-skills-in-areas-with-limited-context">Enhancing Coaching Skills in Areas with Limited Context</h2>
<p>This goal is also significant. It provides a safe space for improving coaching skills. Coaching can feel awkward, especially when starting out and attempting to “talk less, question more.” While building relationships remains a priority, trying out different coaching techniques within this safe space offers excellent practice.</p>

<h2 id="increasing-accountability-through-invested-support">Increasing Accountability through Invested Support</h2>
<p>While this is the last priority, it is still a valuable goal. Essentially, it brings healthy peer pressure. Participants discuss their work and what they aim to achieve, creating an unspoken expectation of self-accountability during subsequent coaching sessions a week or two later.</p>

<h2 id="closing-thoughts">Closing thoughts</h2>
<p>So far we had great positive feedback about these sessions. Folks in my team really enjoy them, they built stronger relationships with each other, they are less dependent on me and continuously growing their exposure. For a few quarters, this was consistently the highlight of the culture in our team. As much as it feels awkward at the start, it’s definitely worth doing.</p>

<p>If you are managing managers, consider starting it. If you are an engineering manager, suggest that to your team!</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[One of the most influential thing on your growth are the peers you work with. They provide you new perspectives, play devil advocate or brainstorm new ideas. Find them as engineering manager.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/peer-coaching.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/peer-coaching.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Software documentation in growing companies</title><link href="https://engineering-manager.com/2021-01-15/software-documentation-in-growing-companies" rel="alternate" type="text/html" title="Software documentation in growing companies" /><published>2021-01-15T00:00:00+00:00</published><updated>2021-01-15T00:00:00+00:00</updated><id>https://engineering-manager.com/2021-01-15/software-documentation-in-growing-companies</id><content type="html" xml:base="https://engineering-manager.com/2021-01-15/software-documentation-in-growing-companies"><![CDATA[<p>Maintaining functional and correct architecture documentation for your product is basically an unsolved problem. There are companies where this is critical and rooted in compliance regulations - like aviation - and they do it right, but at a high cost. Architecture documentation is quickly outdated, and in fast-growing companies, the product evolves so fast that trying to keep your documentation up to date continuously slows you down. The moment it gets outdated and entropy consumes it, the moment it loses credibility and trust.</p>

<p>Finding the right balance between moving fast and keeping high-quality documentation is very hard for scaling companies. In practically all cases, it’s always better to make trade-offs on documentation and move faster building product.</p>

<p>Another common problem of scaling engineering organisations is how to scale your best practices, cultivate culture and maintain knowledge. The most critical moments that shape engineering culture at the organisation are when you have to make trade-offs and rationalise decisions. Even well done, architecture documentation misses the rationale, representing only the result. And usually, it’s easy to see the decision being made. For example, we decided to model this system in that way, which is generally understandable from the codebase. But what’s missing is why did we make this decision?</p>

<p>If you feel that these problems are present in your organisation, experiment with Architecture Design Records.</p>

<h2 id="what-are-architecture-design-records">What are Architecture Design Records?</h2>
<p>Architecture Design Records are documents that describe:</p>

<ul>
  <li>the engineering problem that the team is facing at a time,</li>
  <li>requirements that we need to meet to solve this problem,</li>
  <li>a variety of solutions that we brainstormed before choosing a solution with their trade-offs</li>
  <li>and the detailed description of the solution we chose</li>
</ul>

<p>The exact template that the organisation uses will vary depending on the evolution of the process as you adopt it, your organisation’s current need, and your specific product.</p>

<p>At Intercom, we call them Tech Design documents and use Google Docs to write and store them. I know about other companies that write them in markdown and store in Github, in respective locations to code files that they touch.</p>

<p>I am not opinionated on the shape and format of this document. I think organisations should experiment and evolve their processes continuously, so it’s more important to start and iterate than come up with a perfect template.</p>

<p>However, I firmly believe that the past rationale and trade-offs analysis is one of the most valuable lessons that your organisation can take. Environment changes, constraints come and go, but the way someone distilled a problem, compared the options and made a decision is a prize on its own. That’s your engineering culture manifesting itself in real life. If your values and principles show in these documents, your culture is strong. But if they don’t, they are aspirational - and you need to work harder as a leader to ingrain them.</p>

<p>Who is a stakeholder of such documents? The team that wrote the document is a primary stakeholder. It increases the team’s engineering excellence and will be an artefact that the team itself will mostly use in the future. Additionally, in the case, you are a software consulting business, ADR stakeholder is also your customer. You can keep them up to date and aligned with your work.</p>

<p>Who is an approver? By default, especially in smaller or growing companies, it’s the team itself. More mature companies or those with higher costs of making mistakes, tend to have Architecture Review Boards that approve these docs.</p>

<h2 id="how-you-create-them-is-as-important-as-the-documents-itself">How you create them is as important as the documents itself.</h2>
<p>Writing Application Design Records is a process in itself. And the way your organisation does it is equally important as the output document!</p>

<p>First of all, creating ADRs can easily enforce collaboration and mentoring. A well-rounded collaboration group can have a technical leader overseeing the solution, more junior members learning through practice and someone detached from the team to bring unbiased opinion.</p>

<p>This process also scales your engineering excellence. By writing ADRs for significant problems, you allow your most senior leaders to scale their influence. If your Principal Engineer collaborates with a team of 15-20 engineers, it’s challenging for her to track all the critical decisions organically. ADRs ensure that teams will solicit other senior leaders’ inputs in a scalable and organised way.</p>

<p>You also create opportunities for peer review of the work even before the first line of code is written. As the saying goes, an hour of planning can save a week of development.</p>

<p>And last but not least, you share knowledge between teams that don’t work together organically. Especially in the remote environment!</p>

<h2 id="how-to-trial-and-adopt-the-process">How to trial and adopt the process?</h2>
<p>Writing documents can be a gruelling and disengaging task for engineers if they don’t understand the reasoning, can’t immediately see the value or leaders leading by example.</p>

<p>Try the following process to introduce ADRs as an experiment.</p>

<p><strong>1. Lead by your most senior engineers.</strong></p>

<p>The first ADS should be created under the leadership of one of your most tenured and experienced engineers. Lead by example and show the value of knowledge sharing and engineering excellence to your organisation from your best folks.</p>

<p><strong>2. Adopt only for a few significant design decisions.</strong></p>

<p>Don’t say that it’s obligatory to write ADRs for almost everything from now on. You might get there if your organisation becomes hard fans of the process, but don’t start there. Writing one or two every quarter to solidify the most important developments is already a great start. On the other hand, at Intercom, each team plan in 6-week cycles, and we would usually expect 5-6 tech design documents per team per cycle.</p>

<p><strong>3. Share widely and continuously look for feedback.</strong></p>

<p>Share your ADRs and ask for feedback as often as possible. We created a separate channel on Slack to share all our ADRs. It’s a big stream of artefacts, but every engineer can pick the ones they are most interested in. You work in customer-facing team day-to-day but would like to grow your skills and learn a bit about infrastructure? Pick and read ADRs from our infrastructure teams!</p>

<p>Did you try some form of Architecture Design Records in your organisation? Do you have lessons or observations you would like to share?</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[Finding the right balance between moving fast and keeping high-quality documentation is very hard for growing engineering organisations. If you feel that these problems are present in your company, experiment with Architecture Design Records.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/documentation.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/documentation.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Learnings from Nonviolent Communication</title><link href="https://engineering-manager.com/2020-11-21/lessons-nonviolent-communication" rel="alternate" type="text/html" title="Learnings from Nonviolent Communication" /><published>2020-11-21T00:00:00+00:00</published><updated>2020-11-21T00:00:00+00:00</updated><id>https://engineering-manager.com/2020-11-21/lessons-nonviolent-communication</id><content type="html" xml:base="https://engineering-manager.com/2020-11-21/lessons-nonviolent-communication"><![CDATA[<p>I just finished reading <a href="https://www.amazon.com/Nonviolent-Communication-Language-Life-Changing-Relationships-ebook/dp/B014OISVU4">Nonviolent Communication by Marshall Rosenberg</a>. From the very beginning, I had this feeling that I should have read it a few years ago. I am so impressed by its simplicity in comparison to the value it brings that I decided to write a few minutes summary of my learnings and personal reflection!</p>

<p>I could say that I recommend this book to every manager at every stage of their career. In people management and leadership, the situations where you will benefit from reading this book happen all the time. However, the reality is that these situations happen way more often in our private life - with our partners, parents or kids. This framework will help you be a better person for everyone around you.</p>

<h2 id="about-the-nonviolent-communication-framework">About the Nonviolent Communication framework</h2>
<p>The book describes how we can both talk and listen in a way that is not violent and capable of building connection and deepening the relationships by embracing any emotion, good or bad. Used in a sentence, it looks like this:</p>

<blockquote>
  <p>“When I (see, hear, smell, etc.) …, I feel …, because I need …. . Would you be willing to …”</p>
</blockquote>

<p>As an example:</p>

<blockquote>
  <p>When I see that you are late for the meeting, I feel angry, because I need to know that I can rely on you at situations like this one. Would you be willing to be on time tomorrow?</p>
</blockquote>

<p>In other words:</p>
<ol>
  <li>We describe the concrete actions that are happening that affect us.</li>
  <li>We talk about how we feel in relation to what we observe.</li>
  <li>We explain our needs and desires that created our feelings.</li>
  <li>And we ask for concrete actions that will make the situation better for us.</li>
</ol>

<p>When listening to someone, you can paraphrase what they talk about using this framework. It will help you identify their emotions and drive the conversation to talk about feelings and needs openly.</p>

<p>If you reflect on a few of your last emotional conversations, you will quickly realise how surprisingly often we don’t try to understand each other. You would say that if I didn’t try to understand my partner in conversation, at least I was trying to explain what I wanted. With reflection, I am not even sure if I understood myself what I wanted!</p>

<p>Obviously, no one speaks like that. It feels artificial. But being aware of it and trying to cover all four points of the framework (observation, emotion, need, request) will make your conversations more productive.  As soon as you try to think about your emotions and what you need and request, you will connect with the other human being. After that, you are set to find the needs and requests of your partners.</p>

<h2 id="realisation-of-unexpected-weakness">Realisation of unexpected weakness</h2>
<p>I realised how poor in emotions my vocabulary is day to day. I don’t reflect or express how I feel. While I often have to diagnose emotions at work, I realised that with a broader palette of words, I could connect more to my partners. This helps in building emotional intelligence and ability to go through and lead difficult situations.</p>

<h2 id="five-lessons-to-take-away">Five lessons to take away</h2>

<h4 id="analyses-of-others-are-actually-expressions-of-our-own-needs-and-values"><strong>Analyses of others are actually expressions of our own needs and values.</strong></h4>
<p>So simple yet powerful statement. Especially when being a manager of someone who is analysing or judging the performance of their peers. You need to keep it in mind and help them understand what that need is.</p>

<h4 id="observations-specific-to-time-and-context-are-a-foundation-of-nonviolent-communication"><strong>Observations specific to time and context are a foundation of nonviolent communication.</strong></h4>
<p>Evaluations - observations that are generalised - bring negative emotions and can only be read as unfair criticism. There is a big difference between <em>“You are late today”</em> and <em>“You are always late”</em>. The first is an observation; the second is an evaluation.</p>

<h4 id="use-positive-language-when-making-requests"><strong>Use positive language when making requests.</strong></h4>
<p>People are often confused when you use negative requests. Even more, they tend to be more resistant. When you say <em>“I don’t want you to act like that”</em>, you don’t mean that you will be happy about any other behaviour. You look for a specific behaviour, tell it.</p>

<h4 id="ask-before-offering-advice-or-reassurance"><strong>Ask before offering advice or reassurance.</strong></h4>
<p>It is often frustrating for someone needing empathy to have us assume that they want reassurance or “fix-it” advice.</p>

<h4 id="reflect-back-messages-that-are-emotionally-charged"><strong>Reflect back messages that are emotionally charged.</strong></h4>
<p>Empathy is a respectful understanding of what others are experiencing. It’s not about feeling the same emotions as the other person or agreeing with these emotions. It’s about understanding them and making another person feel understood.</p>]]></content><author><name>Kuba</name></author><summary type="html"><![CDATA[Nonviolent Communication is a game changer tool for every people manager. It helps creating meaningful relationships by embracing any emotion, good or bad. And the best part is, it is even more useful in private life.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://engineering-manager.com/_imgs/nonviolent-communication.jpg" /><media:content medium="image" url="https://engineering-manager.com/_imgs/nonviolent-communication.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>