Closed-loop reward-hacking auto-mitigation (v0.71.26)

The reward-hack and echo-trap detectors from loop hardening can halt a degenerate RL run. v0.71.26 closes the loop: the GRPO/PPO trainer now detects reward hacking mid-run and self-corrects — it raises the KL penalty, shapes the reward, rolls back to the last-good checkpoint, and only early-stops as a last resort.

We have not found an open-source RLHF library that closes the loop from detection → automatic correction → continue. The ones we checked detect, or they halt. Soup steers the run back on course. That is the whitespace this release aims at.

The one flag

bash
soup train --config grpo.yaml \
  --reward-hack-detector info_rm \
  --reward-hack-mitigation kl_control

The flag takes one of off, log_only, kl_control or pid_lagrangian; the example picks kl_control. It requires a reward_hack_detector (info_rm or rm_ensemble, set by the second flag above or in the config) on a grpo / ppo transformers run. The config-file equivalent is training.reward_hack_mitigation.

Four modes, escalating

log_only — the eyes (no control action)

Streams a per-step mitigation_log.jsonl under the run directory: raw signal, health, drop_pct, OK / WARN / HACK verdict, current β / kl_coef, reward mean/std, completion-length mean and trend, and n-gram repetition. It provably never mutates β or kl_coef — run it first to see hacking happen before you let the controller act. The log writer is cwd-contained with rotation, secret redaction, and symlink-reject-on-rotate.

kl_control — reversible bang-bang controller with hysteresis

When a multi-signal vote (InfoRM cluster-separation or RM-ensemble divergence, plus completion length_trend and n-gram repetition) stays above the trip band for a dwell window, the controller raises β (the KL coefficient) multiplicatively, clamped to [reward_hack_beta_floor, reward_hack_beta_ceil], never crossing zero. Once the signal recovers below the release band for the patience window, it relaxes β back toward the floor. Dwell + release-patience are the hysteresis that stops it flapping.

β is written to both trainer.beta and trainer.args.beta, so it takes effect on stock GRPO and Soup's GRPO variants (gspo / dapo / dr_grpo / …), and to trainer.args.kl_coef on PPO.

pid_lagrangian — principled control + a safety net

Replaces bang-bang with a PID-Lagrangian controller (Stooke et al. 2020, "Responsive Safety in RL via PID Lagrangian Methods"): it treats "hacking signal ≤ target" as a constraint whose Lagrange multiplier (the β / KL coefficient) is updated by a PID law with integral anti-windup and an output clamp. On persistent hacking it climbs an escalation ladder:

  1. Raise KL continuously toward the target.
  2. Roll back to the last-good RL checkpoint (requires training.rl_checkpoint_save_every_steps), dropping LR / raising β on resume.
  3. Early-stop cleanly with a plain-English give-up explanation — which signal, for how long, and what the controller tried — surfaced through soup why.

Anti-gaming hardening (any control mode)

So the controller itself can't be gamed: per-signal EMA or median smoothing, conservative-on-disagreement voting (when detectors disagree, keep KL high), a reward-distribution-drift guard (catches a policy shifting its reward distribution to fool the top/bottom split), and optional bounded reward shaping — a capped penalty on the gamed proxy (length / repetition / sentinel token) applied over the reward-function seam and always logged.

Example config

yaml
# soup.yaml
base: HuggingFaceTB/SmolLM2-135M
task: grpo
data:
  train: ./prompts.jsonl
training:
  reward_hack_detector: info_rm
  reward_hack_mitigation: pid_lagrangian  # off | log_only | kl_control | pid_lagrangian
  rl_checkpoint_save_every_steps: 50      # required by reward_hack_rollback below
  reward_hack_beta_floor: 0.02            # positive floor, so β never crosses 0
  reward_hack_beta_ceil: 1.0
  reward_hack_trip_band: 0.30             # raise β above this drop_pct
  reward_hack_release_band: 0.10          # relax β below this
  reward_hack_dwell_steps: 2              # consecutive trips before acting (hysteresis)
  reward_hack_release_patience: 3
  reward_hack_signals: ["info_rm", "length_trend"]
  reward_hack_kl_gain: 1.5                # how hard a trip raises beta
  # pid_lagrangian only. These are REFUSED at config load under any other
  # mitigation mode, so commenting out the mode rather than these keys does
  # not work: a YAML comment does not disable a key that is still set.
  reward_hack_pid_kp: 0.5
  reward_hack_pid_ki: 0.05
  reward_hack_pid_kd: 0.1
  reward_hack_signal_target: 0.15         # the setpoint the controller holds
  reward_hack_integral_clamp: 1.0         # anti-windup bound on the I term
  reward_hack_rollback: true              # enable the rollback rung
  reward_hack_rollback_patience: 3        # trips before rolling back
  reward_hack_max_recovery_attempts: 2    # then early-stop with a soup why
  # optional hardening
  reward_hack_signal_smoothing: ema       # none | ema | median
  reward_hack_conservative_on_disagreement: true

The config above is the pid_lagrangian shape, because the PID tunables and reward_hack_rollback are refused at config load under kl_control rather than ignored (when set to a non-default value), and reward_hack_rollback: true separately requires rl_checkpoint_save_every_steps. For plain kl_control, keep everything down to reward_hack_kl_gain and delete the PID block.

Defaults and bounds

Setting any key below to a non-default value is refused at config load while reward_hack_mitigation is off. The example above is not a set of defaults: it sets reward_hack_pid_ki: 0.05 and reward_hack_pid_kd: 0.1, where the schema's own defaults are 0.1 and 0.05.

KeyDefaultBound or note
reward_hack_beta_floor0.02greater than 0, so beta never reaches zero
reward_hack_beta_ceil1.0greater than 0 and at most 1000; must be above the floor
reward_hack_trip_band0.30 to 1; must be above the release band
reward_hack_release_band0.10 to 1
reward_hack_dwell_steps21 to 100000
reward_hack_release_patience31 to 100000
reward_hack_kl_gain1.5greater than 1, at most 100
reward_hack_signals[info_rm]up to three entries: the active detector (info_rm or rm_ensemble, required) plus length_trend and repetition; the default [info_rm] is refused when the detector is rm_ensemble, so set it explicitly there
reward_hack_pid_kp / _ki / _kd0.5 / 0.1 / 0.050 to 1000 each; pid_lagrangian only
reward_hack_signal_target0.150 up to but not including 1; pid_lagrangian only
reward_hack_integral_clamp1.0greater than 0, at most 1000; pid_lagrangian only
reward_hack_rollbackfalseneeds rl_checkpoint_save_every_steps; pid_lagrangian only
reward_hack_rollback_patience31 to 100000; pid_lagrangian only
reward_hack_max_recovery_attempts20 to 1000 rollbacks before the early stop; pid_lagrangian only
reward_hack_signal_smoothingnonenone, ema or median
reward_hack_smoothing_window82 to 256 recent samples kept for smoothing
reward_hack_conservative_on_disagreementfalsewhen detectors disagree, use the highest signal and keep the KL penalty up
reward_hack_reward_shapingfalseneeds kl_control or pid_lagrangian (log_only only observes) and a shaping strength above 0
reward_hack_shaping_kindlengthwhich proxy the penalty targets: length, repetition or sentinel
reward_hack_shaping_strength0.00.0 to 1.0, the size of the capped penalty

Reward shaping is therefore off twice over: the switch defaults to false, and the strength defaults to 0.0, so turning on reward_hack_reward_shaping without also raising reward_hack_shaping_strength is refused rather than silently doing nothing. Every key marked pid_lagrangian only is likewise refused, when set to a non-default value, under log_only and kl_control, as the paragraph above says.

pid_lagrangian has never had a valid run (upstream #371, still open at v0.75.0). The mechanism is implemented and the mode is selectable; its efficacy is not established. kl_control is the mode with a demonstrated mid-run rollback behind it.

Honest limitations

This is an RL feature, so it cannot ship "schema-only" — a controller that mutates training dynamics is only real if a logged run shows it working. On the maintainer's single RTX 3050 that means proof-of-mechanism only:

  • Validated on SmolLM2-135M + a synthetic length-hacking task, with all four modes live including a real mid-run rollback.
  • PPO ships BETA — the signal buffer and kl_coef mutation are wired and unit-tested, but the on-GPU proof is GRPO-only.
  • Whether the loop suppresses hacking without collapsing true reward on 7B+ models with real reward models is an open, community-validatable question, tracked in issue #286. Contributors with larger GPUs (or soup train --cloud modal) can reproduce the experiments and report back.
  • reward_hack_mitigation in {kl_control, pid_lagrangian} is mutually exclusive with a DPO-style β schedule / ref_model_ema_alpha (both drive the KL / ref dynamics).

Numbers

+184 tests in tests/test_v07126.py, taking the suite to 14,788 across ~298 files.

See also

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.