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
soup train --config grpo.yaml \
--reward-hack-detector info_rm \
--reward-hack-mitigation kl_controlThe 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:
- Raise KL continuously toward the target.
- Roll back to the last-good RL checkpoint (requires
training.rl_checkpoint_save_every_steps), dropping LR / raising β on resume. - 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
# 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: trueThe 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.
| Key | Default | Bound or note |
|---|---|---|
reward_hack_beta_floor | 0.02 | greater than 0, so beta never reaches zero |
reward_hack_beta_ceil | 1.0 | greater than 0 and at most 1000; must be above the floor |
reward_hack_trip_band | 0.3 | 0 to 1; must be above the release band |
reward_hack_release_band | 0.1 | 0 to 1 |
reward_hack_dwell_steps | 2 | 1 to 100000 |
reward_hack_release_patience | 3 | 1 to 100000 |
reward_hack_kl_gain | 1.5 | greater 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 / _kd | 0.5 / 0.1 / 0.05 | 0 to 1000 each; pid_lagrangian only |
reward_hack_signal_target | 0.15 | 0 up to but not including 1; pid_lagrangian only |
reward_hack_integral_clamp | 1.0 | greater than 0, at most 1000; pid_lagrangian only |
reward_hack_rollback | false | needs rl_checkpoint_save_every_steps; pid_lagrangian only |
reward_hack_rollback_patience | 3 | 1 to 100000; pid_lagrangian only |
reward_hack_max_recovery_attempts | 2 | 0 to 1000 rollbacks before the early stop; pid_lagrangian only |
reward_hack_signal_smoothing | none | none, ema or median |
reward_hack_smoothing_window | 8 | 2 to 256 recent samples kept for smoothing |
reward_hack_conservative_on_disagreement | false | when detectors disagree, use the highest signal and keep the KL penalty up |
reward_hack_reward_shaping | false | needs kl_control or pid_lagrangian (log_only only observes) and a shaping strength above 0 |
reward_hack_shaping_kind | length | which proxy the penalty targets: length, repetition or sentinel |
reward_hack_shaping_strength | 0.0 | 0.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_lagrangianhas 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_controlis 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_coefmutation 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_mitigationin{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
- Loop hardening (v0.70 → live v0.71.11) — the detectors this feature turns from "halt" into "self-correct".
- Lean install + live wiring (v0.71) — the release line this caps.
- GRPO Plus — the GRPO variants β mutation reaches on both code paths.
- soup ship — the post-training SHIP / DON'T-SHIP verdict that pairs with a hardened run.
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.