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.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.Both arms beat the untrained base, which is the part that did replicate.run_stdestimated 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 whilecompare()applied the t one, which is #616; since that fixeval_variancecarries the bandcompare()applies and says so withnoise_band_df, andrun.pyreads it off the report. The number judged against was 0.046 throughout, so nothing here moved.results.jsonis the record of the run as it happened, so it still carries both values undernoise_band_eval_varianceandnoise_band_applied; a re-run on the fixed library writesnoise_band_appliedandnoise_band_df.
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.pycounts gold steps from GSM8K’s<<a+b=c>>annotations, matches candidate numbers as a multiset to 1e-5, and scales by1.5 * gold / candidatepast 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-runis 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.