Skip to main content
The scripts are in the repository, not in the installed package. Clone it, then cd recipes/community/process-vs-outcome-reward before running the commands below. Browse this recipe on GitHub.
arXiv:2607.02869 (Palandye et al., July 2026) trains Qwen2.5-0.5B with GRPO on GSM8K under five reward regimes and reports that scoring the steps beats scoring the answer: 63.73% test accuracy for process-only against 53.75% for outcome-only. Single runs, no intervals, no seeds. This recipe asks the smaller question an outsider can afford: with one hour of one GPU and the same one change, does the gap appear at all, and is it bigger than the noise? What you will learn: how to score a public benchmark with whileai’s measurement calls, how eval_variance turns three base passes into a band that a delta has to clear, and what compare(proxy=...) says when the reward you trained on is not the metric you care about. You need a Modal account for the two arms; --dry-run needs nothing.

The one change

Everything is held fixed between arms except the reward the group is scored with. Both are programs, not judges. rewards.py writes the process reward because the paper does not say how a “step” comes out of a free-form completion, nor how big the length penalty is. Both choices are at the point they are made, and --dry-run prints the table that shows the two rewards genuinely come apart:

Run it

What you get

Both rewards work. The gap between them does not survive its own interval. Qwen2.5-0.5B-Instruct, 192 GSM8K training prompts (one epoch, 48 GRPO steps, 5 rollouts each, LoRA r=16), 200 held-out GSM8K test questions at k=2, sampled at T=0.7. Held-out pass@1, paired, 95% interval from a task bootstrap: The paper reports process-only beating outcome-only by +9.98 points (63.73 vs 53.75). Here the same one change is worth +3.0 points, with an interval that contains zero and whose upper bound (+8.5) sits just under the paper’s number. The verdict rule is the interval, not the sign: a delta has to clear zero and clear the noise band, and compare() puts that band at 0.046 from three base passes. +0.030 clears neither.
Which noise band. 0.046, not 0.021. run_std estimated from three re-runs is an estimate, not the eval’s exact spread, so the quantile is t(df=2)=4.30 rather than 1.96 — and the difference decides this run, since +0.030 clears the 1.96 form and not the real one. When this run was made, eval_variance()["noise_band"] returned the 1.96 form while compare() applied the t one, which is #616; since that fix eval_variance carries the band compare() applies and says so with noise_band_df, and run.py reads it off the report. The number judged against was 0.046 throughout, so nothing here moved. results.json is the record of the run as it happened, so it still carries both values under noise_band_eval_variance and noise_band_applied; a re-run on the fixed library writes noise_band_applied and noise_band_df.
Both arms beat the untrained base, which is the part that did replicate. compare(proxy="marker:process") also flags the arm comparison OVER-OPTIMIZED: the process metric moved +0.084 [+0.045, +0.122] while final-answer accuracy moved +0.030 inside the noise. Training on the steps moves the steps a lot more than it moves the answer.

The mechanism is visible even though the result is flat

From the two training logs, on the same rollout budget: This is the paper’s argument, measured: a binary outcome reward leaves 37% of GRPO groups with all five rollouts scoring the same, so those groups produce no gradient at all. The dense process reward wastes 8%. That is 4.5x more usable groups per step — it simply has not turned into a separable accuracy difference by step 48.

Did either reward get gamed?

hack_scan(endorsed=["marker:process"]) on each trained arm’s held-out rollouts, with numbers_emitted added by hand because a process reward invites exactly that cheat — pad the chain with numbers and hope one matches a gold step: Nothing above the noise floor except the endorsed feature: not length, not truncation, not numbers_emitted. The scan also reaches the dead-group finding from the eval side — only a quarter of held-out asks carry gradient at all — which is the same story the training logs tell. Its own warning applies: at k=2 the floor is coarse, and it asks for k>=8 before acting on a close call.

Cost

Two L40S containers, ~20 minutes each including image build and model download, run in parallel: ~40 L40S-minutes, about $1.30 at Modal’s posted L40S rate. No model API key was used anywhere in this recipe; the reward is a program and the eval is the same program.

What did not work

  • The paper does not define its own process reward well enough to reimplement. “Correct steps / total steps” leaves open what a step is in a free-form completion, and “a penalty when generated chains exceed 1.5x the ground-truth step count” gives no magnitude. rewards.py counts gold steps from GSM8K’s <<a+b=c>> annotations, matches candidate numbers as a multiset to 1e-5, and scales by 1.5 * gold / candidate past the threshold. A different reading of the same two sentences is a different experiment, and that is the largest single source of disagreement between this number and the paper’s.
  • My first process reward scored the gold solution 0.27. It counted every number in the completion as a step, so the length penalty fired on correct chains. Checking the reward against the reference answer before spending any GPU time caught it; --dry-run is that check, kept.
  • This holdout cannot settle the paper’s claim. holdout_size(0.10, base=0.35, k=2) asks for 187 paired tasks to prove a 10-point gain and this recipe uses 200, which is enough for the paper’s effect but not for the effect actually observed: compare() reports that proving +0.030 would take about 1956 tasks. A flat verdict here is “not shown at this scale”, not “shown absent”.
  • One epoch on 192 prompts is not the paper’s run. The paper does 5 epochs over 7,473 prompts with a full fine-tune at lr 1e-6 on VERL+vLLM; this is one epoch over 192 with LoRA at lr 2e-5 on TRL. The base is Qwen2.5-0.5B-Instruct rather than the base model, and eval is sampled at T=0.7 rather than greedy, so that a noise floor exists at all.
  • The three base passes were not three draws. Same seeds, same model and same batching give byte-identical passes, so the second container reproduced the first’s [0.2725, 0.2575, 0.2625] exactly. The spread here is sampling noise across seeds, which is what the band needs; it is not container-to-container variance, and this recipe cannot speak to that.

Next

python run.py analyze writes results.json. To push further: raise N_TRAIN past one epoch of 192 prompts, or add the paper’s three hybrid arms (Weighted([(process, l), (outcome, 1 - l)]) is already in the SDK) and see whether l = 0.9 sits where the paper puts it.
Last modified on September 21, 2026