Teach a language model to write correct Python functions using GRPO (Group Relative Policy Optimization). The reward is dead simple: does the generated code actually pass the test cases?
This is the same RL technique DeepSeek used for R1 — applied here to code generation instead of math. Unlike supervised fine-tuning, which memorizes one "correct" answer, GRPO generates multiple completions per prompt and reinforces whichever ones pass the tests. The model discovers its own solutions.
For each training prompt, the model generates several completions. A reward function scores each one, and GRPO reinforces the best within the group:
Prompt: "Write a function called `reverse_string` that reverses a string.
def reverse_string(s):"
├── Completion 1: "return s[::-1]" → 3/3 tests pass → reward: 1.0
├── Completion 2: "return ''.join(reversed(s))" → 3/3 tests pass → reward: 1.0
├── Completion 3: "return s[0]" → 0/3 tests pass → reward: 0.0
└── Completion 4: "return list(s)" → 0/3 tests pass → reward: 0.0
GRPO computes advantages within the group: completions 1 & 2 get positive
advantage, 3 & 4 get negative. The policy shifts toward code that passes tests.
The key insight: there's no single "correct" answer to learn. Any code that passes is good code. This is why RL works better than SFT here — it rewards the outcome, not a specific implementation.
One consequence drives every choice in this tutorial: the advantage is computed within a group, so a group only teaches the model something if its completions disagree. Sixteen completions that all fail produce exactly as much gradient as sixteen that all pass — none. Keep that in mind; it explains the base model, the reward, and the filter. See Choosing a Base Model ↓.
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌────────────┐
│ Prepare Data │──▶│ Filter? │──▶│ GRPO Training │──▶│ Evaluate │
│ (CPU task) │ │ (optional) │ │ (GPU task) │ │ (GPU task) │
└──────────────┘ └──────────────┘ └──────────────────┘ └────────────┘
MBPP candidate Opt-in via GRPO with sandboxed Base vs fine-tuned
pool from HF --use_filter code execution sandboxed comparison
(LoRA or full)
- Prepare data — Downloads the MBPP dataset from HuggingFace and builds a candidate pool of prompts, with function signatures extracted from the reference solutions.
- Filter for learnability (optional) — Samples the base model over the pool and keeps only problems it solves sometimes but not always — the zone where GRPO's within-group advantage is non-zero. Enable with
--use_filter; it's cached, so it runs once and later runs reuse the result. Details ↓ - Train with GRPO — For each prompt, generates multiple completions, executes them in a sandbox, and rewards code that passes all tests.
- Evaluate — Runs held-out problems through both the base and fine-tuned model, comparing pass rates side by side.
| File | What it does |
|---|---|
workflow.py |
Full pipeline — data prep, GRPO training, evaluation |
config.py |
Flyte task environments (CPU/GPU), image config, secrets |
report_helpers.py |
SVG chart generation and HTML styling for live reports |
requirements.txt |
Python dependencies |
cd tutorials/llm-fine-tuning-grpo-code
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -r requirements.txtIf you run against a local Flyte devbox, it must be started with --gpu:
flyte start devbox --gpuThe flag adds --gpus all to the underlying docker run and selects the
flyte-devbox:gpu-latest image. Both are fixed when the container is created, so a
devbox started without it can't be repaired by restarting — replace it:
docker rm -f flyte-devbox && flyte start devbox --gpuVerify the cluster can actually see the GPU before launching anything:
kubectl get node -o jsonpath='{.items[0].status.allocatable.nvidia\.com/gpu}' # want: 1An empty result means Kubernetes doesn't know the GPU exists. Training pods then sit in
Pending forever with 0/1 nodes are available: 1 Insufficient nvidia.com/gpu, and
because no container ever starts there are no task logs to explain it — it looks exactly
like a slow queue. kubectl describe pod -n flyte <pod> is what tells the two apart.
Note: the devbox holds your secrets, so replacing the container wipes them. Re-create
HF_TOKEN(below) after anydocker rm -f flyte-devbox.
config.py declares HF_TOKEN as a flyte.Secret
on both task environments, so it has to exist in two places — they are not
interchangeable.
1. Locally — used by flyte run --local and by the CLI itself:
echo "HF_TOKEN=hf_your_token_here" > .env2. In the Flyte backend — used by remote task pods:
flyte create secret HF_TOKEN # prompts for the value; nothing hits your shell history
flyte get secret # verifyA .env alone is not enough for a remote run. The secret is injected into the pod by
an admission webhook, and if the backend doesn't have it the webhook refuses to create the
pod at all — the run fails before any container starts, so there are no task logs. The only
trace is a cluster event:
kubectl get events -n flyte --sort-by=.lastTimestamp | grep -i secret
# admission webhook "flyte-pod-webhook.flyte.org" denied the request:
# none of the secret managers injected secret [key:"HF_TOKEN" ...]flyte run workflow.py pipelineThat's the default configuration, shown explicitly:
flyte run workflow.py pipeline \
--model_name "Qwen/Qwen2.5-Coder-7B" \
--method lora \
--no-use_chat_template \
--epochs 2 \
--lr 5e-5 \
--batch_size 8 \
--num_generations 8 \
--max_completion_length 192 \
--max_candidate_samples 300 \
--num_eval_examples 50Requirements: one large-memory GPU (~40 GB+; a 128 GB DGX Spark is comfortable) and roughly 2–3 hours end to end. The live reward and pass-rate charts climb visibly during training, and the evaluation report compares base vs. fine-tuned on held-out problems.
Three things about this configuration are load-bearing.
The base model is a 7B, and that is the point. GRPO's gradient comes entirely from groups where some completions pass and some fail. A sub-2B model almost never passes a raw MBPP problem, so nearly every group comes back all-zero and teaches nothing — the run looks busy while barely learning. A 7B code-pretrained base passes often enough to produce that variance. This is the single biggest lever on the task, bigger than any hyperparameter. Choosing a Base Model ↓ covers how to reason about it when you swap models.
Use the base, not -Instruct, and turn the chat template off. Qwen ships a ChatML
chat_template in its base tokenizer too, so --use_chat_template would wrap a base
model in a chat format it was never trained on, and it will ramble instead of completing
the function. --no-use_chat_template makes this a pure completion task — continue the
MBPP stub — which is what a base model is good at. The flag applies to both training
and evaluation, so the before/after comparison stays apples-to-apples either way. Use an
-Instruct model with the chat template if you prefer; just keep the two consistent.
Budget for the KL reference model. With --beta above 0, TRL keeps a frozen copy of
the base model to compute the KL term, so plan on roughly 2× the weights — about 28 GB
for a 7B in bf16, plus LoRA and activations. That's what makes a 7B comfortable and a 32B
dense model impractical here.
flyte run workflow.py pipeline \
--model_name "Qwen/Qwen2.5-Coder-0.5B" \
--max_candidate_samples 20 \
--max_train_samples 6 \
--epochs 1 \
--num_generations 2 \
--batch_size 2 \
--max_completion_length 64 \
--num_eval_examples 4Tiny model, tiny dataset — finishes in a few minutes on a small GPU. This verifies the pipeline runs end to end (data → sandbox → training → eval). It is not expected to produce a learning signal; a 0.5B on 6 problems is far below the threshold where GRPO has anything to work with. Use it to check plumbing, not results.
More data and more generations per group, which sharpens the advantage estimates:
flyte run workflow.py pipeline \
--model_name "Qwen/Qwen2.5-Coder-7B" \
--no-use_chat_template \
--epochs 2 \
--batch_size 16 \
--num_generations 16 \
--max_completion_length 192 \
--max_candidate_samples 800 \
--num_eval_examples 50Doubling --num_generations doubles the odds that any given prompt yields a mixed
pass/fail group, so it buys more usable gradient — but every step costs proportionally
more generation time, and generation is the bottleneck. Keep --batch_size equal to
--num_generations (one prompt group per step) unless you have memory to spare.
flyte run workflow.py pipeline --method full --model_name "Qwen/Qwen2.5-Coder-0.5B"By default the pipeline trains LoRA adapters, which freeze most weights and learn small
low-rank matrices. --method full updates all parameters — more expressive, but far more
memory and much slower. At 7B, full fine-tuning needs weights + gradients + optimizer
state + the KL reference in memory at once; prefer LoRA unless you have the headroom.
--method qlora loads the base in 4-bit, which fits a 7B on a 16 GB GPU at the cost of
slow generation. It saves the adapter rather than a merged model.
flyte run --local --tui workflow.py pipeline \
--model_name "Qwen/Qwen2.5-Coder-0.5B" \
--max_candidate_samples 15 --max_train_samples 8 --epochs 1Runs everything on your machine. Useful for debugging; slow without a GPU.
| Flag | Default | Description |
|---|---|---|
--model_name |
Qwen/Qwen2.5-Coder-7B |
HuggingFace model to fine-tune |
--method |
lora |
lora for LoRA adapters, full for all parameters, qlora for a 4-bit base |
--epochs |
2 |
Training epochs |
--lr |
5e-5 |
Learning rate |
--batch_size |
8 |
Completions per training step (must be divisible by --num_generations) |
--num_generations |
8 |
Completions generated per prompt (the "Group" in GRPO) |
--max_completion_length |
192 |
Max tokens per generated completion |
--use_chat_template |
False |
Wrap prompts in the tokenizer's chat template. Keep off for base models, on for -Instruct. Applied to training and eval |
--use_filter |
False |
Opt in to the learnability filter. Off by default — training uses the whole candidate pool |
--max_candidate_samples |
300 |
Size of the candidate pool (also what the filter draws from) |
--max_train_samples |
100 |
(only with --use_filter) Cap on the learnable subset kept by the filter. Ignored without --use_filter — the training pool is then just --max_candidate_samples |
--filter_samples |
4 |
(only with --use_filter) Base-model samples per candidate; keep if 1 ≤ all-pass < filter_samples |
--max_eval_samples |
50 |
Size of the held-out eval pool |
--num_eval_examples |
50 |
Problems used in the before/after comparison |
--eval_k |
4 |
Samples per problem at eval; the score is mean pass@1 over these |
--beta |
0.04 |
KL penalty vs. the base model — the main overfit/drift guard |
--lora_r |
16 |
LoRA rank — higher = more capacity, more params |
--lora_alpha |
32 |
LoRA scaling factor (effective scale = alpha/r) |
--precision |
auto |
auto picks bf16 where supported, else fp16. Force fp32 if generation crashes with inf/nan logits |
--batch_sizemust be divisible by--num_generations— a GRPO requirement, since each optimizer step processes whole generation groups.
If sampling crashes with
probability tensor contains inf/nan(which can surface as a CUDA device-side assert rather than an obvious numerical error), pass--precision fp32. Low-precision generation can producenanlogits on some newer GPUs; fp32 costs ~2× memory and is slower, but is numerically bulletproof.
This is the most important decision in the whole pipeline, and it is easy to get wrong in a way that looks like a hyperparameter problem.
GRPO can only sharpen what the model can already do occasionally. Advantages are computed within a group of completions for one prompt. If all of them fail, the group is flat and contributes no gradient. If all of them pass, likewise. Learning happens only in the middle — problems the base solves sometimes. So the useful question about a base model is not "how good is it?" but "how often does it land in the middle?"
That fraction collapses fast as models get smaller:
| Base | Behavior on raw MBPP | Result |
|---|---|---|
| Sub-2B general | Almost never passes | Nearly all groups all-zero → no gradient, flat run |
| Sub-2B code-pretrained | Rarely passes | Mostly dead groups → weak, noisy signal |
| 7B code-pretrained | Passes sometimes | Plenty of mixed groups → real gradient |
| 7B+ instruct / larger | Passes often | Signal, plus more trivial (all-pass) groups |
Qwen2.5-Coder-7B is the default because it's the smallest model in this pipeline that
reliably produces a held-out improvement on MBPP. Smaller models will run to completion
and produce charts — the training reward may even drift upward — while the held-out pass
rate barely moves.
If you swap in a different model, check this first. Before blaming --beta, --lr, or
the filter, look at whether the groups have any variance at all:
- Reward pinned near 0 for the whole run → the base almost never solves anything. The groups are dead. Reach for a bigger or more code-pretrained base, not a knob.
- Reward pinned near 1 → the problems are trivial for this base. Feed it harder data.
- Reward in the middle and moving → you have signal; now the hyperparameters matter.
This is the RLVR lesson worth internalizing, and it generalizes well beyond code: RL amplifies existing competence, it doesn't create it. Whether you get that variance from a stronger base or by curating the data for it, it's the thing that makes GRPO work — usually more impactful than any hyperparameter.
Scale is the other half of the story. Labs get large coding gains from RLVR because they pair a strong base with 10⁴–10⁶ problems; at a few hundred problems, each one is more of an island than an instance of a general skill, so transfer is limited even with a good base. This tutorial is sized to teach the mechanics — sandboxed verification, binary reward, the group, the training loop — on hardware you can actually book. For a task engineered to give a clean win even at small scale, and a framework for scoring your own task, see the Countdown tutorial's task-selection guide.
The table above lists the flags; this is how to reason about the ones that matter for GRPO specifically (the LoRA and learning-rate knobs behave as they do in any fine-tune). GRPO's failure modes are subtle — training reward can climb while the model gets worse on held-out problems — so it helps to know which knob addresses which symptom.
GRPO penalizes drift away from the base model's behavior, scaled by beta. Think of it
as a leash:
betatoo low (→ 0): long leash. The policy wanders wherever the reward points, including into degenerate or memorized behavior. Symptom: train reward climbs, held-out accuracy drops; output style drifts (chatty code that gets truncated); entropy collapses.betatoo high: short leash. The policy can barely move from the base, so it can't learn. Symptom: flat 0pp — the fine-tuned model is nearly identical to the base.- The sweet spot is narrow.
0.005–0.04is a reasonable range here. If you see a regression, this is the first knob to reach for — before touching the model or the data.
This is the "Group" in GRPO. Advantages are computed within the group of completions for one prompt, so the group needs variety of outcomes to produce a gradient. If all N completions get the same reward, that prompt teaches nothing that step.
- Higher (8–16): better advantage estimates, more exploration, more stable — and a higher chance any given prompt yields a mixed group. Every step costs proportionally more generation time, which is the GRPO bottleneck.
- Lower (2–4): faster, noisier, far more prone to zero-variance groups.
- It must divide
--batch_size.
More epochs over the same problems is the fastest route to memorization: the model
overfits and generalizes worse. Prefer 1–2 epochs. If you want more training signal, add
data (--max_candidate_samples) before adding epochs.
(Only relevant with --use_filter.) How many times the base model attempts each candidate
during the learnability filter. A problem is kept only if it's
solved 1 ≤ x < filter_samples times.
- Higher (6–8): a sharper estimate of the learnable zone, and a wider band — at linear cost to the (cached) filter pass.
- Lower (3): cheaper, coarser. Below 3, the "sometimes" signal is too noisy to mean much.
| You see... | Most likely cause | Reach for |
|---|---|---|
| Reward stuck near 0 the whole run | No learnable signal — the base can't solve these | A bigger / more code-pretrained base (above); more data |
| GRPO worse than base, output drifted/verbose | Policy drift, too little regularization | Raise --beta, lower --epochs |
| GRPO identical to base (0pp) | Policy can't move | Lower --beta, raise --epochs/--lr |
| Reward jumps to a degenerate constant | Reward is hackable | Make the reward stricter (see below) |
| Training very slow | Generation cost | Lower --num_generations / --max_completion_length |
| Held-out eval flat but noisy | Too few eval problems | Raise --num_eval_examples / --eval_k |
The pipeline uses MBPP (Mostly Basic Python Programming), a standard benchmark of ~970 Python programming problems from Google Research. Each problem has a natural-language description, a reference solution, and 3 executable test assertions.
The prepare_data task downloads MBPP, extracts the function signature from each reference
solution, and builds a prompt for the model to complete:
Problem text: "Write a function to find the maximum of two numbers."
Reference: def max_of_two(a, b): return a if a > b else b
→ Prompt sent to model:
"Write a function to find the maximum of two numbers.
def max_of_two(a, b):"
→ Tests:
assert max_of_two(3, 5) == 5
assert max_of_two(10, 2) == 10
assert max_of_two(4, 4) == 4
Problems range from simple (is_even, reverse_string) to moderate (fibonacci,
remove_duplicates) to harder (heap operations, regex matching). --max_candidate_samples
sets the pool size, --max_train_samples caps the problems kept for training (the
learnable subset when the filter is enabled, otherwise the pool itself), and
--max_eval_samples sets the held-out eval set.
The reward is binary — all or nothing:
| Result | Reward |
|---|---|
| All tests pass | 1.0 |
| Any test fails / invalid code | 0.0 |
This is deliberate. Partial credit (passed/total) is trivially hackable: a constant that
returns the right type passes a fraction of the asserts, and on an impossible problem that
fraction beats the genuine (failing) attempts — so GRPO learns to emit return True.
All-or-nothing removes that gradient entirely: an impossible problem produces an all-zero
group with no advantage and no gradient, so it is inert rather than hackable.
That's what makes it safe to train without the learnability filter. Impossible problems don't corrupt training; they just don't contribute. The signal comes from the learnable groups — problems where some completions pass and some don't — which is exactly what a strong enough base supplies.
Optional (--use_filter), off by default.
Two kinds of problem teach the model nothing:
- Impossible — the base never solves it; every completion scores 0. With a binary reward these contribute no gradient, so they're harmless — they only waste compute.
- Trivial — the base always solves it; every completion scores 1, so the advantage is zero.
With --use_filter, the filter_learnable task samples the base model filter_samples
times per candidate and keeps only the problems solved sometimes but not always
(1 ≤ all-pass count < filter_samples) — the learnable middle, where every group carries a
real gradient. The task is cached on its inputs, so the cost is paid once.
The filter is an efficiency lever, not a correctness requirement: it concentrates compute on the groups that matter. It is most valuable when the data is genuinely too hard for the base — and least valuable when the pool is already mostly in the learnable zone, where difficulty-filtering can strip useful signal. Turn it on when a run shows most groups are dead (reward pinned near 0) and you've already confirmed the base is strong enough.
The reward function is the task definition. GRPO doesn't know what "good code" means — it only knows the number your reward returns, and it will find the shortest path to a high number, whether or not that path is what you intended. Most of the work in an RL project is reward design, not RL.
1. The reward is a proxy — the model optimizes the proxy, literally. This is the central hazard, usually called reward hacking. If a partially-correct constant scores 0.33 and the honest attempts score 0.0, the model learns to emit the constant. The fix isn't a better optimizer; it's a reward with no cheap exploit. Ask of any reward: "what's the laziest output that scores well here?" If that output isn't what you want, the reward is wrong.
2. Sparse vs. dense is a real trade-off. A binary reward is unhackable but sparse — on hard problems every attempt scores 0, so there's no gradient. A dense reward (partial credit) gives signal on near-misses but opens a hacking surface. This tutorial keeps the reward binary and gets its gradient from the data: the signal lives in problems the base solves sometimes. If you genuinely need a dense reward, weight the parts so the cheap wins can't dominate.
3. Multi-part rewards encode multiple goals — weight them carefully. The sibling
math tutorial uses 1.0 × correctness + 0.2 × format:
correctness is the goal, format is a gentle nudge to keep output parseable. Keep the real
objective dominant, or the model will farm the cheap secondary reward (perfect formatting
around wrong answers).
4. Prefer verifiable rewards when you can get them. Code (run the tests) and math (check the answer) give an objective, ungameable oracle — no learned reward model to drift or be gamed. That's why they're the poster children for RL fine-tuning. The catch is that "verifiable" only covers what counts as correct — you still design the shaping, the data, and the regularization.
Why the reward changes with the use case. There's no universal reward — it's a function of what you can measure and what you're willing to accept:
| Use case | Natural reward signal | Watch out for |
|---|---|---|
| Code generation | Unit tests pass | Constants that pass a subset; gameable tests |
| Math / reasoning | Final answer matches | "Right answer, nonsense reasoning"; format parsing |
| SQL / tool use | Query runs & returns expected rows | Syntactically-valid-but-wrong; empty results scoring "no error" |
| Summarization / writing | No cheap verifier → LLM-as-judge or rubric | Judge bias, verbosity/sycophancy hacking |
| Style / safety constraints | Regex / classifier / format checks | Model satisfies the checker while violating the intent |
The rule of thumb: if a cheap program can verify the outcome, use it and keep the reward strict; if it can't, you're now also in the business of building (and defending) a reward model — a much harder problem, and where most reward-hacking horror stories come from. Start strict and binary; add density or sub-rewards only when the model can't learn — and every time you add a term, re-ask "what's the laziest way to score well now?"
The model generates arbitrary Python that has to be executed to compute rewards. Rather than
calling exec() in the training process (dangerous with untrusted code), this tutorial uses
Union interactive sandboxes
(union.sandbox.on_device):
- Network blocked — generated code cannot make network requests
- Process isolation — crashes in generated code don't affect the trainer
- Persistent session — one sandbox stays open for the whole training run, avoiding per-evaluation setup cost
- Timeout enforcement — each execution has a 5-second timeout, preventing infinite loops
The sandbox session is opened once per task, and the reward function calls into it from the
trainer thread with asyncio.run_coroutine_threadsafe().
During training, the Flyte report updates live:
- Progress bar — current step, epoch, and loss
- Training loss chart — loss curve over epochs
- Reward chart — average reward over training batches (running average + per-batch)
- Pass rate chart — percentage of completions passing all tests
- Stat grid — method, dataset size, epochs, learning rate, generations
The evaluation report scores each problem as mean pass@1 over --eval_k samples, not a
single greedy draw. This matters: GRPO raises P(correct) — it turns a problem the model
solves 1-in-4 times into one it solves 3-in-4. A single greedy sample collapses that
probability back into one pass/fail bit and throws most of the improvement away, which is how
a real training gain can show up as a flat eval.
Example problems are grouped so the comparison stays honest rather than cherry-picked:
- GRPO improved it — passes more often after training (sorted by largest gain)
- Both solved it — the base already had these
- Neither solved it — still out of reach
- GRPO regressed — the base passed more often; watch this bucket for drift
Each card shows a passing sample where one exists, and labels it as such — so the code you read is the code the badge is talking about.
The training curve can lie. Reward climbing is not proof of learning: a policy that drifts into a degenerate style can push training reward up while held-out accuracy falls. Always read the held-out eval and the actual generated code, not just the loss and reward charts.
Unlike standard SFT, where the trainer just does forward/backward passes, GRPO's inner loop is:
For each batch of prompts:
1. Generate `num_generations` completions per prompt (inference)
2. Score each completion with the reward function (sandbox execution)
3. Compute advantages within each group (normalize rewards)
4. Update the policy to increase the probability of high-reward completions (training)
Every training step involves both inference (generating code) and execution (running
tests), which is why GRPO is slower than SFT. Generation dominates the wall-clock and is
memory-bandwidth-bound, so cost scales with the bytes of weights read per token —
num_generations and max_completion_length are the two knobs that move it most.
This tutorial collapses generation, verification, and training into a single GPU task with one in-process sandbox — deliberately, so the pipeline fits on one machine and stays easy to follow. In real RLVR pipelines the wall-clock is dominated by rollout generation and reward verification, not the gradient step, so that's where you scale — and you can do it without changing how code is executed:
- Warm worker pools — put a
flyte.ReusePolicyon the GPU environment so the policy model loads once and stays hot across rollout batches instead of cold-starting a container per step (throughput = replicas × concurrency). - Fan out the rollouts — the group dimension (
num_generations) and the prompt batch are embarrassingly parallel. Spread them across the warm pool withflyte.map(...)/asyncio.gather()— each action in its own container — bounded by anasyncio.Semaphoreorflyte.map(concurrency=...)to respect GPU quota. - Keep verification on-device — each fanned-out worker verifies its own shard of completions
with its own
sb.on_device.session(). No shared bottleneck, and the same reward function you see here, just running on more boxes.
What Union does not do for you is the RL-engine internals: syncing updated policy weights to inference workers each step, off-policy correction, matching generation throughput to training. That lives in your trainer↔inference integration (TRL's vLLM mode, veRL). Union gives you the distributed substrate and the secure verifier; you plug in the RL engine.
Code is an ideal RL task because it is:
- Verifiable — run it and check; no subjective judgment needed
- Multi-solution —
s[::-1]and''.join(reversed(s))both work, and GRPO rewards either - Objectively scored — a passing test suite is an unambiguous signal
- Practical — this is how production code models are actually trained
None of the design choices here are arbitrary — each maps to a published finding.
| Choice in this tutorial | Why | Reference |
|---|---|---|
| A base strong enough to sometimes succeed | A group where every completion gets the same reward has zero advantage and zero gradient. Learning concentrates where the success rate is intermediate (~0.5). | DAPO · Competence–Difficulty Alignment |
| Learnability filter — keep only problems the base solves sometimes | The same principle applied to the data instead of the model. This is what DAPO calls Dynamic Sampling. | DAPO |
| Binary reward — 1.0 only if all tests pass | Partial pass-rate (passed/total) is hackable: constants like return True grab the easy tests (a skew VeRPO calls cardinality bias), and partial credit doesn't beat binary at convergence anyway. |
Beyond Binary / VeRPO |
KL penalty (--beta) |
With too little KL regularization the policy drifts from the base and reward-hacks — train reward climbs while held-out accuracy drops. | DeepSeekMath (GRPO) |
| GRPO itself — group-relative advantages, no value network | Introduced for math reasoning, then scaled up as the core of DeepSeek-R1. | DeepSeekMath · DeepSeek-R1 |
A few practical notes drawn from that work:
- This filter is a cached, offline approximation of DAPO's online dynamic sampling. DAPO re-filters every batch as the model improves, so problems that become trivial drop out mid-training; here difficulty is measured once up front and cached. Cheaper, but less adaptive.
- Filtering isn't a free lunch. It pays off when the data is genuinely too hard. On data already in the learnable zone it can strip useful signal — which is why the sibling math tutorial on GSM8K skips it too.
- Truncated completions are worth masking. Verbose outputs cut off at
max_completion_lengthbecome un-runnable; masking their reward (TRL'smask_truncated_completions, DAPO's "overlong filtering") avoids training on that garbage.