Reward hacking and Goodhart

The last chapter ended on a warning, and this one collects on it. Once a scorer is a reward, the policy will optimize the scorer, and a policy optimizing a scorer is not the same thing as a policy getting good at the task. The scorer is a proxy for what I actually want, and every proxy has a gap between what it measures and what it means, and reinforcement learning is a machine for finding that gap and driving a truck through it. This is Goodhart's law stated operationally: the moment my SDA verifier became the target of optimization, it started to become a worse measure of SDA reasoning, because the policy is now rewarded for anything that makes the verifier fire, reasoning or not. This chapter is about seeing that happen, catching it early, and knowing when to stop. I will lay out a taxonomy of the hacks I actually expect, the signals that reveal them (KL, entropy, and the divergence between a training verifier and a held-out one), the honeypot tasks that bait hacks into the open, and a concrete stop-criteria doctrine. Then the lab does the thing you have to do at least once to believe it: deliberately builds an exploitable verifier, watches the policy hack it, and catches the hack with a held-out variant.

Theory

Goodhart, and the proxy-true reward gap

Write the thing I care about as the true reward : does this completion exhibit genuine SDA reasoning that reaches the correct answer for the right reasons. I cannot compute ; if I could, I would not need to train, I could just select. What I can compute is a proxy reward : my verifier's judgment. A good verifier makes correlate strongly with across the completions a model actually produces, which is exactly what made it a good measurement instrument in Part III. But correlation measured on the distribution of un-optimized completions is not a promise about the distribution of optimized ones. GRPO deliberately shifts the policy toward high- regions, and if there exist completions that are high- but low- (the verifier's blind spots), the policy will find them, because finding high-reward regions is the only thing the algorithm does. The gap between and is empty at the start of training and fills up as optimization proceeds, and reward hacking is just the name for the policy living in that gap.

This reframes the whole enterprise. In Part III I worried about construct validity for measurement: does the score reflect the construct on the model as it is. Here I need construct validity under optimization pressure: does the score still reflect the construct on the model the reward is actively reshaping. Those are different and the second is much harder, because the adversary generating the inputs to my verifier is a policy I am personally training to defeat it. Every verifier weakness that was a harmless rounding error during measurement becomes an attractor during training.

A taxonomy of the hacks I expect

Hacks are not infinitely varied; they cluster, and knowing the clusters tells me what to monitor.

Format gaming. When the format reward is too large relative to correctness (the exact failure the last chapter's scaling derivation warned about), the policy maxes the dense structural signal without improving the sparse correctness signal. The symptom is beautifully-tagged completions whose <answer> contents are no better than before. This is the mildest hack and the easiest to catch, because the format and correctness curves visibly decouple: format saturates and correctness flatlines, and I logged them separately in the last chapter precisely so I could see it.

Length gaming. GRPO has a known length bias (Part V), and depending on the loss aggregation and the reward, the policy can discover that longer, or shorter, completions score better for reasons unrelated to correctness. If the verifier gives partial credit for containing certain tokens, longer completions have more chances to include them; if there is a per-token penalty, completions collapse to terse non-reasoning. The symptom is mean completion length drifting hard in one direction while correctness does not follow. This is why completion length is a first-class monitored metric, not an afterthought.

Verifier exploits (spec gaming). The sharpest hacks target the verifier's implementation rather than the task. If the correctness check is a loose regex that scores a completion correct whenever the target answer appears anywhere in the text, the policy learns to print the answer inside its reasoning, or to enumerate many candidate answers so one matches, without ever doing the deduction. If a code-execution verifier can be satisfied by output that game the test harness (printing the expected string, catching and swallowing the real computation), the policy finds that. These are the dangerous ones because the completions can look plausible and the training-verifier reward looks great; only a different, stricter verifier reveals that never moved. The lab induces exactly this hack.

Reference drift and mode collapse. Even without a specific verifier weakness, hard optimization can push the policy far from the base model into a narrow, degenerate region: it finds one high-reward template and emits minor variations of it for every prompt, losing the diversity that made the group informative. The symptoms are a large and rising KL to the reference policy and a collapsing entropy: the policy has stopped exploring and is exploiting a mode. This can co-occur with any of the above and is the thing the KL leash (beta) exists to restrain.

The monitoring signals

Three families of signal catch these, and the art is watching them together, because any one alone is ambiguous.

KL to the reference policy. GRPO already computes for its penalty, so the KL is logged for free, and it measures how far the policy has drifted from the base model. Healthy training shows KL rising gradually as the policy genuinely improves. Pathological over-optimization shows KL rising while the true reward stalls or falls: the policy is moving a lot to gain nothing real, which is the signature of climbing into the proxy-true gap. The classic over-optimization plot, from the reward-model literature but identical in shape here, is held-out (true) reward rising, peaking, and then declining as KL keeps climbing. The peak is the moment to have stopped.

Entropy. The policy's output entropy measures how much it is still exploring. A slow decline is normal as the policy commits to good strategies. A sharp collapse means mode collapse: the policy has found one template and is exploiting it, which kills the group diversity GRPO's advantage estimate needs and usually precedes or accompanies a hack. Entropy near a floor with high KL is the mode-collapse fingerprint.

Training-verifier versus held-out-verifier divergence. This is the strongest and most specific signal, and it is the one designed to catch verifier exploits that KL and entropy can miss. I score completions with two verifiers: the one used for training (, possibly exploitable) and a held-out variant (, stricter, structurally different, never used to compute a gradient). While the policy is genuinely improving, both rise together. When the policy starts exploiting a weakness specific to the training verifier, keeps rising while stalls or falls, and the gap is a direct, quantitative reward-hacking meter. Because the held-out verifier never enters the loss, the policy is not optimizing against it, so it remains an honest-ish estimate of for as long as its weaknesses differ from the training verifier's.

Why a held-out verifier works and when it stops working

The held-out verifier is the same idea as a held-out test set, moved from data to grading. Its power comes entirely from being outside the optimization loop: the policy gets no gradient telling it how behaves, so 's blind spots are not attractors the way 's are. But the protection is only as good as the independence of the two verifiers' weaknesses. If and share the same answer-extraction regex and the hack targets that regex, both fire and the gap stays closed while collapses, and I learn nothing. So a held-out verifier must fail differently: score a different aspect (process versus answer), use a different extraction (parse structure versus canonicalize value), or apply a stricter comparison. The lab's held-out verifier canonicalizes and checks only the <answer> tag, while the exploitable training verifier accepts the answer appearing anywhere, so their weaknesses are deliberately disjoint and the gap opens exactly when the hack lands. When you cannot guarantee disjoint weaknesses, rotate several held-out variants and treat any one of them diverging as a flag.

Honeypots and held-out variants as bait

A held-out verifier catches a hack after it starts. A honeypot is more aggressive: a small set of items constructed so that the easiest way to score well under the training verifier is the hack, so that if the policy is hacking, the honeypot lights up loudly and early. For the "answer appears anywhere" exploit, a honeypot item is one whose problem statement happens to contain a number that is not the answer, so a policy that learned to echo salient numbers into its reasoning will regurgitate the wrong one, scoring correct under the lax verifier (the target does appear somewhere if the model enumerates) but wrong under the strict one, widening the gap dramatically on exactly those items. Honeypots are cheap (a handful of adversarially-designed items) and they convert a slow, noisy divergence signal into a fast, loud one, because they are engineered to maximize the gap the instant hacking begins.

When to stop

All of this feeds one decision: when to end the run. "Train until max steps" is a non-answer, because the best checkpoint is often well before the last one, sitting at the peak of the held-out reward before over-optimization set in. My stop doctrine is a small set of criteria, any of which ends the run, plus a rule for which checkpoint to keep.

  • Held-out reward peak. Keep the checkpoint at the maximum held-out (true-proxy) reward, not the last one. If held-out reward has declined for consecutive evaluations while training reward keeps rising, stop: you are past the over-optimization peak.
  • KL ceiling. If KL to the reference exceeds a threshold I set in advance, stop or raise beta, because the policy has drifted further than I am willing to trust regardless of what the reward says.
  • Entropy floor. If entropy falls below a floor, stop: the policy has mode-collapsed and further training only entrenches it.
  • Verifier-gap alarm. If (especially on the honeypots) exceeds a threshold, stop and inspect: a verifier exploit is underway and every further step makes the model worse at the real task while the training number lies to you.
  • Format-correctness decoupling. If the format reward is saturated and correctness has been flat for a long window, stop: the dense signal has nothing left to teach and the sparse one is not moving, so the run is spending compute to hack format.

The unifying principle is that no single number, least of all the training reward, is trusted to say the model is improving. Improvement is a joint claim across the training reward, the held-out reward, KL, entropy, and the honeypot gap, and the run stops when those signals stop agreeing.

Tooling

The tooling is monitoring infrastructure bolted onto the same GRPO run: a callback that, on a schedule, samples the current policy on a fixed held-out and honeypot set, scores those samples with both the training verifier and the held-out verifier, and logs the reward, the gap, the completion length, and a diversity proxy to MLflow alongside the KL that TRL already logs. None of this touches the loss; it is pure observation. The two artifacts are declarative: a dashboard config that names exactly which metrics to chart and which thresholds draw the alarm lines, and a stop-criteria doc that writes the doctrine above into a checked, versioned specification rather than leaving it in my head.

```admonish gotcha title="The held-out verifier must never enter reward_funcs" The single most important implementation rule: the held-out verifier is computed in a callback, never passed to GRPOTrainer(reward_funcs=...). The instant it enters reward_funcs, TRL sums it into the training reward, the policy starts optimizing it, and it is no longer held out; its blind spots become attractors and the gap signal goes blind. Keep a hard architectural wall between the functions that produce gradients (in reward_funcs) and the functions that only observe (in the callback). If you find yourself wanting the held-out score to influence training, you do not want a held-out verifier, you want a better training verifier, and you should fix that one instead.


## Lab: induce a hack, then catch it

The lab does the uncomfortable, instructive thing: it trains against a *deliberately exploitable* correctness verifier (one that scores a completion correct if the target answer appears anywhere in the text, a real and common bug), watches the policy discover the exploit, and catches it with a strict held-out verifier and a honeypot set. The artifacts are the two governance files, a `dashboard.yaml` and a `stop_criteria.md`, that turn the monitoring doctrine into checked configuration.

Set up on the same stack.

```bash title="shell"
uv init labs/grpo-hack
cd labs/grpo-hack
uv add "unsloth" "unsloth_zoo" "vllm" "trl" "peft" "bitsandbytes" mlflow sympy
uv add "thesis-suite @ ../../thesis-suite"
uv lock
export MLFLOW_TRACKING_URI=http://127.0.0.1:5000

The two verifiers, one exploitable (for training) and one strict (held out), with deliberately disjoint weaknesses.

"""An exploitable training verifier and a strict held-out verifier.

Their weaknesses are deliberately DISJOINT: the lax one checks whether the
target appears anywhere (regex over the whole completion); the strict one
canonicalizes ONLY the <answer> tag and compares symbolically. The gap between
them is the reward-hacking meter.
"""
import re
from thesis_suite import verify_sda_answer  # strict, canonicalizing

ANSWER_RE = re.compile(r"<answer>(.*?)</answer>", re.DOTALL)


def lax_correctness(text: str, target: str) -> float:
    """EXPLOITABLE: target appearing anywhere counts as correct."""
    return 1.0 if str(target) in text else 0.0


def strict_correctness(text: str, target: str) -> float:
    """HELD OUT: only the canonicalized <answer> tag counts."""
    m = ANSWER_RE.search(text)
    if not m:
        return 0.0
    try:
        return 1.0 if verify_sda_answer(text, str(target)) else 0.0
    except Exception:
        return 0.0

The training reward uses the lax verifier (this is the induced weakness). A monitoring callback samples a fixed held-out and honeypot batch every monitor_every steps, scores with both verifiers, and logs the gap, length, and a diversity proxy.

"""A TrainerCallback that observes hacking signals without touching the loss."""
from collections import Counter

import mlflow
from transformers import TrainerCallback
from vllm import SamplingParams

from verifiers import lax_correctness, strict_correctness


def _diversity(texts) -> float:
    """Cheap entropy proxy: mean fraction of unique tokens per completion."""
    fracs = []
    for t in texts:
        toks = t.split()
        fracs.append(len(set(toks)) / max(len(toks), 1))
    return sum(fracs) / max(len(fracs), 1)


class HackMonitor(TrainerCallback):
    def __init__(self, model, tokenizer, probe_prompts, probe_targets,
                 monitor_every=25, max_new_tokens=512):
        self.model, self.tok = model, tokenizer
        self.prompts, self.targets = probe_prompts, probe_targets
        self.every, self.max_new = monitor_every, max_new_tokens

    def on_step_end(self, args, state, control, **kwargs):
        if state.global_step == 0 or state.global_step % self.every:
            return
        # Sample the CURRENT policy via the in-process vLLM engine.
        sampling_params = SamplingParams(temperature=1.0, max_tokens=self.max_new)
        outs = self.model.fast_generate(
            self.prompts, sampling_params=sampling_params)
        texts = [o.outputs[0].text if hasattr(o, "outputs") else str(o)
                 for o in outs]
        lax = [lax_correctness(t, tg) for t, tg in zip(texts, self.targets)]
        strict = [strict_correctness(t, tg) for t, tg in zip(texts, self.targets)]
        lax_m = sum(lax) / len(lax)
        strict_m = sum(strict) / len(strict)
        mlflow.log_metrics({
            "heldout/lax_reward": lax_m,
            "heldout/strict_reward": strict_m,
            "heldout/hack_gap": lax_m - strict_m,     # the meter
            "heldout/mean_len_tokens": sum(len(t.split()) for t in texts) / len(texts),
            "heldout/diversity": _diversity(texts),
        }, step=state.global_step)

The training script wires the lax reward into training and the monitor into the callback list. It also includes the honeypot probe set (items whose statements contain a distractor number).

"""Train against the exploitable verifier; monitor with the strict held-out one."""
from unsloth import FastLanguageModel  # noqa: E402

import mlflow
import torch
from trl import GRPOConfig, GRPOTrainer

from verifiers import lax_correctness
from monitor import HackMonitor
from thesis_suite.data import load_sda_split, load_sda_honeypots

MODEL, LORA_R, GROUP = "unsloth/Qwen3-4B", 16, 8


def lax_reward(prompts, completions, target, **kwargs):
    def text(c): return c[-1]["content"] if isinstance(c, list) else c
    return [lax_correctness(text(c), tg) for c, tg in zip(completions, target)]


def main() -> None:
    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=MODEL, max_seq_length=2048, load_in_4bit=True,
        fast_inference=True, max_lora_rank=LORA_R, gpu_memory_utilization=0.16)
    model = FastLanguageModel.get_peft_model(
        model, r=LORA_R,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                        "gate_proj", "up_proj", "down_proj"],
        lora_alpha=LORA_R, use_gradient_checkpointing="unsloth")

    train_ds = load_sda_split("train")
    honeypots = load_sda_honeypots()          # statements carry distractor numbers
    probe_prompts = [h["prompt"] for h in honeypots]
    probe_targets = [h["target"] for h in honeypots]

    cfg = GRPOConfig(
        output_dir="outputs", num_generations=GROUP,
        per_device_train_batch_size=GROUP, gradient_accumulation_steps=2,
        max_prompt_length=512, max_completion_length=1024,
        learning_rate=5e-6, beta=0.02, epsilon=0.2, loss_type="dr_grpo",
        temperature=1.0, max_steps=600, logging_steps=1,
        report_to="mlflow", run_name="grpo-hack-demo", seed=0)

    mlflow.set_experiment("p7-grpo")
    with mlflow.start_run(run_name="grpo-hack-demo"):
        trainer = GRPOTrainer(
            model=model, processing_class=tokenizer,
            reward_funcs=[lax_reward],           # ONLY the exploitable one trains
            args=cfg, train_dataset=train_ds,
            callbacks=[HackMonitor(model, tokenizer,
                                   probe_prompts, probe_targets, monitor_every=25)],
        )
        trainer.train()
        model.save_lora("outputs/hacked-adapter")


if __name__ == "__main__":
    main()
uv run python train_hack.py

Now the two governance artifacts. The dashboard config names the monitored metrics and the alarm thresholds, so the same chart layout and alarms can be recreated for any run.

# Monitoring dashboard for a GRPO run. Panels map to logged MLflow metrics;
# alarms fire when a metric crosses a threshold. Thresholds are starting
# points, to be calibrated on the baseline machine (record value, date, driver).
experiment: p7-grpo
panels:
  - title: "Reward: training vs held-out"
    metrics: ["rewards/lax_reward/mean", "heldout/strict_reward"]
    note: "Healthy: both rise together. Hack: training rises, held-out stalls/falls."
  - title: "Reward-hacking meter"
    metrics: ["heldout/hack_gap"]
    alarm: {metric: "heldout/hack_gap", op: ">", threshold: 0.15}
  - title: "KL to reference"
    metrics: ["kl"]                          # TRL GRPOTrainer logs this as `kl`
    alarm: {metric: "kl", op: ">", threshold: 10.0}
  - title: "Entropy / diversity proxy"
    metrics: ["heldout/diversity"]
    alarm: {metric: "heldout/diversity", op: "<", threshold: 0.35}
  - title: "Completion length"
    metrics: ["heldout/mean_len_tokens", "completions/mean_length"]
    note: "Hard drift in either direction with flat correctness = length gaming."
  # (No format-vs-correctness panel here: this lab trains with reward_funcs=[lax_reward]
  #  only, so `rewards/format_reward/mean` is never logged. Add that panel back when the
  #  run carries a separate format_reward, as the 7.3 run does.)
# Stop criteria for a GRPO run

The run stops when ANY criterion below fires. Keep the checkpoint at the
maximum held-out (strict) reward, not the last checkpoint.

1. Held-out reward peak: strict held-out reward has declined for 3 consecutive
   monitor evaluations while training reward kept rising. -> stop, keep the peak.
2. KL ceiling: kl > 10.0 (calibrate per run). -> stop or raise beta.
3. Entropy floor: diversity proxy < 0.35. -> stop; policy has mode-collapsed.
4. Verifier-gap alarm: heldout/hack_gap > 0.15, especially on honeypots.
   -> stop and inspect completions; a verifier exploit is underway.
5. Format-correctness decoupling: format reward saturated AND strict correctness
   flat for 5 monitor evaluations. -> stop; nothing real is being learned.

No single number, least of all the training reward, is trusted alone.
Improvement is a joint claim across training reward, held-out reward, KL,
entropy, and the honeypot gap. The run stops when they stop agreeing.

```admonish gotcha title="beta=0 removes your KL signal along with your KL leash" Some strong GRPO recipes set beta=0, relying on the PPO clip alone to limit per-step movement. That can train well, but it has a monitoring cost: with no KL term in the objective, TRL may not log kl, and you lose one of your three over-optimization signals. If you run beta=0, add KL logging back in the monitor callback (compute reference logprobs with the adapter disabled, as in chapter 7.1) so the KL panel is not silently empty. Losing the leash is a choice; losing the gauge should not be.


### What you should see

The artifacts are `dashboard.yaml` and `stop_criteria.md`, the governance files, plus the (deliberately compromised) `outputs/hacked-adapter/`. The story is in the MLflow charts the dashboard describes. Early on, `rewards/lax_reward/mean` and `heldout/strict_reward` rise together, because at first the only way to make the target appear anywhere is to actually answer correctly. Then, somewhere in the run, they part company: the training (lax) reward keeps climbing toward 1.0 while the strict held-out reward stalls or *falls*, and `heldout/hack_gap` opens up and crosses its 0.15 alarm line. That divergence is the policy discovering it can score under the lax verifier by echoing the target number into its `<think>` block (or enumerating candidates) without committing a correct `<answer>`, and it is loudest on the honeypots, whose distractor numbers make the wrong-but-present answers score lax-correct and strict-wrong. Concurrently you should see the fingerprints: KL to the reference rising as the policy moves into the exploit region, and the diversity proxy sagging as completions converge on the same number-echoing template. If you dump a few honeypot completions at the end, you will read the hack in plain text, well-formed-looking reasoning that name-drops the target and never actually derives it. The lesson the run burns in is that the *training reward looked great the whole time*: without the held-out verifier and the honeypots, the lax curve climbing to 1.0 would have read as triumph. Record the step at which the gap alarm fired, the KL and diversity at that step, and the peak strict-reward checkpoint, all with date and driver (measured on the baseline machine, record value, date, driver). Then, having seen it, fix the verifier (score only the `<answer>` tag, canonicalized, as chapter 7.3 does) and rerun to confirm the gap stays shut, which is the whole point: you induce the hack once, in a lab, so you recognize it instantly when it shows up uninvited in a real run.

```mermaid
flowchart LR
    A[Policy] -->|samples| B[Completions]
    B --> C[Training verifier<br/>lax, exploitable]
    B --> D[Held-out verifier<br/>strict, observe only]
    C -->|gradient| A
    D -->|no gradient| E[hack_gap = lax - strict]
    E -->|alarm| F[Stop + inspect]

Note

Pair this with [RLHF] Lambert on over-optimization and reward-model hacking; the held-out-reward-peaks-then-declines-as-KL-rises curve is the same phenomenon whether the proxy is a learned reward model or a programmatic verifier, and Lambert's treatment of the KL-reward frontier is the theoretical backbone of the stop doctrine here. Read it as the general case of which this chapter's verifier exploit is one sharp, fully-reproducible instance.

Note

"I trained a model to cheat on purpose, so I'd recognize it when it happened by accident." Goodhart's law has a precise operational form in reinforcement learning: the moment your eval becomes your reward, it starts to become a worse eval, because the policy is now an adversary hunting your verifier's blind spots. The post is the lab as a story: wire up a correctness check with one common, innocent-looking bug (it counts the answer as correct if it appears anywhere in the text), turn a reasoning model loose on it, and watch. For a while the reward climbs because the honest way to make the answer appear is to compute it. Then the policy finds the shortcut, starts echoing the target number into its scratch work without ever deriving it, and the training reward sails to a perfect score while the model quietly gets worse at the actual task. The kicker is what saves you: a second verifier you never train against, a handful of booby-trapped test items, and the discipline to believe the divergence between two numbers over the one number that looks like winning.