2️⃣ GRPO with verifiable rewards
Learning Objectives
- See why a verifiable reward lets us drop the KL-to-reference term, and what Dr. GRPO changes vs GRPO
- Understand why a ~50% solve rate makes the reward maximally informative for group-relative advantages
Now we build the trainer around a real model. As before, we have our dataclass for hyperparameters and training arguments. We'll explain these as we need them.
@dataclass
class RLVRArgs:
# Base model & LoRA (applied with `peft` below)
model_name: str = "Qwen/Qwen3-0.6B-Base"
lora_rank: int = 16
lora_alpha: float = 32
lora_dropout: float = 0.0
lora_targets: list[str] = field(
default_factory=lambda: ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
)
# Task & reward (the functions you wrote in section 1). `task(rng, difficulty)` -> (prompt, answer)
task: Callable = mult_byhand
reward_fn: Callable = int_reward # (completions of one problem, its answer) -> rewards (G,)
# Dynamic curriculum (section 2): keep each question near `target_success` so the group reward has variance
target_success: float = 0.5
initial_difficulty: float = 1.0 # D_0; the controller finds the frontier from here
difficulty_lr: float = 1.0 # alpha: D += alpha * (batch solve rate - target_success). 0 = fixed difficulty
min_difficulty: float = 0.0
max_difficulty: float = len(DIFFICULTY_LADDER) - 1
# Rollouts: each step samples `n_prompts` problems and `num_return_sequences` completions per problem (the GRPO
# group, size G in the maths) for `n_prompts * num_return_sequences` total
n_prompts: int = 16
num_return_sequences: int = 8
max_new_tokens: int = 400 # enough for 4x4 working; 350 or less truncates answers and learns less reliably
temperature: float = 1.0
top_k: int = 50
top_p: float = 0.95
sampler: str = "auto" # "vllm" (in-process vLLM, single weight copy; needs vllm + a big card), "fast" (utils.FastSampler:
# CUDA graphs + no-repeat GQA attention), "hf" (model.generate). "auto" = vllm if importable, else fast on CUDA, else hf
# Optimization
lr: float = 1e-4
log_samples: int = 1 # rollouts printed per step (0 = none)
micro_batch: int = 4 # sequences per forward pass when computing logprobs (memory, not maths; peak ~10 GiB, 8 needs ~17)
# Duration & reproducibility
n_steps: int = 20 # training steps, each one rollout batch + one gradient step (~6 min on an A40 with FastSampler)
seed: int = 0
HF Model + LoRA plumbing
The plumbing below is provided: it loads Qwen/Qwen3-0.6B-Base and its tokenizer, and adds LoRA adapters to all
seven linear layers with peft (the library version of the LoRALinear + apply_lora you wrote in [2.4.1], whose
LoRA section also covers the Qwen3 architecture, and where these seven layers sit). We'll
train it with Dr. GRPO (a de-biased GRPO variant) and a verifiable reward, with no KL penalty against the base
model: we don't care how far the rollouts drift from the base model's, as long as they reach a correct answer.
# Provided: load a small base model + a LoRA adapter
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from torchinfo import summary
MODEL_NAME = "Qwen/Qwen3-0.6B-Base"
cfg = RLVRArgs()
tokenizer = AutoTokenizer.from_pretrained(cfg.model_name)
tokenizer.padding_side = "left" # batched generation: pad on the left so all prompts end at the same position
# Rollout sampler. With vLLM installed we get samples ~2.5x faster (vLLM's weights and the HF model's are one copy).
# vLLM is optional and not in the ARENA requirements: `pip install vllm==0.30.0`, ideally in a separate environment,
# since it pins its own torch and transformers. It's only imported if it's installed and we use it here.
USE_VLLM = cfg.sampler == "vllm" or (cfg.sampler == "auto" and device.type == "cuda" and utils.vllm_available())
if USE_VLLM:
vllm_engine = utils.VLLMEngine(cfg.model_name, tokenizer, seed=cfg.seed, max_lora_rank=cfg.lora_rank,
temperature=cfg.temperature, top_k=cfg.top_k, top_p=cfg.top_p)
_base = vllm_engine.make_hf_base()
print("Using vLLM")
else:
vllm_engine = None
_base = AutoModelForCausalLM.from_pretrained(cfg.model_name, dtype=t.bfloat16, attn_implementation="sdpa").to(device)
print("no vLLM, falling back to FastSampler (CUDA) or HF generate")
_base.config.pad_token_id = tokenizer.pad_token_id
rlvr_model = get_peft_model(
_base,
LoraConfig(r=cfg.lora_rank,
lora_alpha=cfg.lora_alpha,
lora_dropout=cfg.lora_dropout,
target_modules=cfg.lora_targets),
)
Sampling from the model
Here, the tokenizer returns a dictionary with the keys input_ids and attention_mask.
We can unpack the dictionary and feed the arguments using .generate(**inputs),
instead of the much longer and verbose
.generate(input_ids = inputs["input_ids"], attention_mask = inputs["attention_mask"]).
We can then use the model like a normal Hugging Face model, and the LoRA adaptors will
automatically kick in. To query the base model underneath, use the disable_adapter context manager (peft's
version of the lora_disabled we gave you in [2.4.1]):
inputs = tokenizer("Hello! How are you?", return_tensors="pt").to(device)
# using the LoRA adaptors by default
output = rlvr_model.generate(**inputs, max_new_tokens=30, pad_token_id=tokenizer.pad_token_id)
print(tokenizer.batch_decode(output))
# based model with LoRA adaptors disabled
with rlvr_model.disable_adapter():
output = rlvr_model.generate(**inputs, max_new_tokens=30, pad_token_id=tokenizer.pad_token_id)
print(tokenizer.batch_decode(output))
The fine-tuned model doesn't act any differently than the base!
Not yet: as in [2.4.1], $B$ starts at zero, so the adapters do nothing until they're trained. (peft initializes
$A$ with PyTorch's default nn.Linear initialization,
kaiming uniform with
$a = \sqrt{5}$, rather than the Gaussian we used.)
We can see how much larger the model is with the LoRA adaptors:
>>> print(rlvr_model.print_trainable_parameters())
trainable params: 10,092,544 || all params: 606,142,464 || trainable%: 1.6650
We can see that Qwen3-0.6B-Base really has ~596M parameters, and our LoRA adaptors
add an extra ~10M. We have placed LoRA adaptors across every linear layer internal to the transformer,
excluding the embedding and unembedding. The modules
q_proj, k_proj, v_proj, o_proj cover the query, key value and output linear layers in the attention head respectively.
We can see how the LoRA models splice in to the attention heads (specifically, the query projection in layer 0)
as follows:
>>> print(_base.model.layers[0].self_attn.q_proj)
lora.Linear(
(base_layer): Linear(in_features=1024, out_features=2048, bias=False)
(lora_dropout): ModuleDict(
(default): Identity()
)
(lora_A): ModuleDict(
(default): Linear(in_features=1024, out_features=16, bias=False)
)
(lora_B): ModuleDict(
(default): Linear(in_features=16, out_features=2048, bias=False)
)
(lora_embedding_A): ParameterDict()
(lora_embedding_B): ParameterDict()
(lora_magnitude_vector): ModuleDict()
)
The query projection has been replaced with lora.Linear, which (like our LoRALinear) wraps the original layer as
base_layer, and adds the low-rank lora_A (down to rank 16) and lora_B (back up). These are nn.Linear layers,
so their weights are the transposes of our A and B.
The Dr. GRPO core
In [2.4.1] we implemented vanilla GRPO. Here we train with Dr. GRPO: GRPO Done Right, which removes two optimisation biases from it.
Question-difficulty bias
Vanilla GRPO standardises each group's returns by (r − mean) / std. Dr. GRPO drops the /std, and uses just
r − mean.
Why drop the /std?
Suppose you had a large rollout of questions where almost all answers were incorrect, and only a few were correct (or vice versa). This means the standard deviation is higher than it would otherwise be on a more balanced rollout. Consider the following three cases of rewards, where 0 is incorrect and 1 is correct:
>>> torch.std(torch.Tensor([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1])) # almost all incorrect
tensor(0.2294)
>>> torch.std(torch.Tensor([0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1])) # almost all correct
tensor(0.2294)
>>> torch.std(torch.Tensor([0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1])) # mix of correct and incorrect
tensor(0.5130)
and as such, this biases training: questions that are either very easy (the model gets almost all correct) or very hard (the model gets almost all incorrect) will have lower variance, so the returns get scaled up, and the corresponding gradients get amplified, and questions that are just right (the model gets a fair mix of correct and incorrect) will cause the gradients to get attenuated.
Response-length bias
Vanilla GRPO averages each completion's per-token objective over its length $|o_i|$. Note that the advantages used by Vanilla GRPO are not a function of time at all:
Here's the GRPO objective from [2.4.1] (without its KL term, which we drop), with Dr. GRPO's changes: the terms in red are removed, and the term in green is added.
- $\mathbf{q}$ is the prompt, and $\mathbf{o}_1, \ldots, \mathbf{o}_G$ are its rollouts.
- $G(\mathbf{q}, \mathbf{o}_i)$ is the return of rollout $\mathbf{o}_i$.
- $\rho_{i,t}(\theta)$ is the importance ratio for rollout $\mathbf{o}_i$ at timestep $t$.
- $L_\text{max}$ is the generation budget (
max_new_tokens), which can be more than $\max_i |\mathbf{o}_i|$, if every rollout in a batch stopped early.
The calculation of the advantages is now deceptively simple, so we've provided it here.
def drgrpo_advantages(returns: Float[Tensor, "n_prompts G"]) -> Float[Tensor, "n_prompts G"]:
"""Subtract each group's mean return (no std normalisation)."""
return returns - returns.mean(dim=-1, keepdim=True)
Exercise - Dr. GRPO Objective
drgrpo_objective: the same as the clipped surrogate objective you wrote in [2.4.1]
(calc_clipped_surrogate_objective), except that it returns a loss to minimise (the negative objective), with
two changes:
-
Constant loss normaliser. We divide each completion's summed token loss by the generation budget
max_gen_len, rather than by its actual lengthmask.sum(). Vanilla GRPO's length-dependent normalisation systematically favours shorter-correct / longer-incorrect responses. -
Masking. For RLHF, all generations were of the same length. Here, this may not be the case, so an extra argument
maskis provided for the token positions we actually want to compute the objective function for. Themaskis1.0where a token should be included for training, and0.0otherwise.
Return the sum of these per-completion losses over the batch, not the mean. The trainer computes the loss a micro-batch at a time, so it divides by the number of completions in the whole rollout buffer itself.
def drgrpo_objective(
new_logprobs: Float[Tensor, "batch gen_len"],
old_logprobs: Float[Tensor, "batch gen_len"],
advantages: Float[Tensor, " batch"],
mask: Float[Tensor, "batch gen_len"],
max_gen_len: int,
clip: float = 0.2,
) -> Float[Tensor, ""]:
"""Dr. GRPO loss to minimise, summed over the completions in the batch.
Args:
new_logprobs: Logprobs under the current policy.
old_logprobs: Logprobs under the sampling policy.
advantages: One advantage per completion.
mask: 1.0 on tokens that count, 0.0 on padding.
max_gen_len: Generation budget, for the constant normaliser.
clip: Ratio clipping range.
"""
raise NotImplementedError()
tests.test_drgrpo_objective(drgrpo_objective)
Solution
def drgrpo_objective(
new_logprobs: Float[Tensor, "batch gen_len"],
old_logprobs: Float[Tensor, "batch gen_len"],
advantages: Float[Tensor, " batch"],
mask: Float[Tensor, "batch gen_len"],
max_gen_len: int,
clip: float = 0.2,
) -> Float[Tensor, ""]:
"""Dr. GRPO loss to minimise, summed over the completions in the batch.
Args:
new_logprobs: Logprobs under the current policy.
old_logprobs: Logprobs under the sampling policy.
advantages: One advantage per completion.
mask: 1.0 on tokens that count, 0.0 on padding.
max_gen_len: Generation budget, for the constant normaliser.
clip: Ratio clipping range.
"""
adv = advantages.unsqueeze(-1)
ratio = (new_logprobs - old_logprobs).exp()
clipped_ratio = t.clamp(ratio, 1 - clip, 1 + clip)
surrogate = t.minimum(ratio * adv, clipped_ratio * adv)
return -(surrogate * mask).sum() / max_gen_len
Dynamic curricula
Group-relative advantages only carry information when at least two rollouts receive a different reward. If all rollouts get the same reward, then all the advantages are zero, and that question contributed zero to training. As such, questions where the model either always gets it right, or always gets it wrong, are a waste as there is nothing to learn from.
Assuming that the question has a hard incorrect/correct answer, if the model solves a question with probability $p$, on average, how much information do we learn from each answer? This is given by the binary entropy
So, we want to ask questions that the model gets right about half the time, as that way we learn the most from each answer. But as the model learns, its performance gets better, so any fixed difficulty of questions is either too easy at the end of training or too hard at the start. To solve this, we employ a dynamic curriculum.
The difficulty $d$ is integer valued (at least for our problem), so we make it continuous using stochastic rounding. Letting $\tilde{d} = n + \epsilon$ being a real-valued difficulty with integer part $n \in \mathbb{N}$ and fractional part $0 \leq \epsilon < 1$, we can then generate samples of $d$ by computing
We start from $D_0 = 1$ and adjust our estimate of $D$ after each rollout using a similar approach to temporal difference errors we saw earlier:
Exercise - Curriculum.update
The Curriculum class below poses problems (sample), scores the completions (score, using your reward function)
and measures the exact-match solve rate of a batch (solve_rate). Fill in update, which applies the rule above to
self.difficulty, using the class's lr, target, d_min and d_max.
def clamp(x : float, lo : float, hi : float) -> float:
return min(max(x, lo), hi)
class Curriculum:
"""Poses problems at difficulty D, scores the answers, and moves D towards the model's frontier."""
def __init__(
self,
task: Callable,
reward_fn: Callable,
initial: float = 1.0,
lr: float = 1.0,
target: float = 0.5,
d_min: float = 0.0,
d_max: float = len(DIFFICULTY_LADDER) - 1,
seed: int = 0,
):
self.task, self.reward_fn = task, reward_fn
self.difficulty, self.lr, self.target, self.d_min, self.d_max = initial, lr, target, d_min, d_max
self.rng = random.Random(seed) # for the task generator
self.np_rng = np.random.default_rng(seed) # for stochastic rounding
def sample_levels(self, n: int) -> Int[np.ndarray, " n"]:
"""`n` rungs by stochastic rounding of D: D = 2.3 gives rung 2 70% of the time and rung 3 30%."""
lo = np.floor(self.difficulty)
return (lo + (self.np_rng.random(n) < self.difficulty - lo)).astype(int)
def sample(self, n: int) -> list[str]:
"""Prompts for `n` problems at the current difficulty. Their rungs and answers are kept for scoring."""
self.levels = self.sample_levels(n).tolist()
prompts, self.answers = [], []
for level in self.levels:
prompt, answer = self.task(self.rng, level)
prompts.append(prompt)
self.answers.append(answer)
self.prompts = prompts
return prompts
def score(self, groups: list[list[str]]) -> Float[Tensor, "n_prompts G"]:
"""Reward of each completion: row p scores problem p's group of completions against its answer."""
return t.stack([self.reward_fn(group, answer) for group, answer in zip(groups, self.answers)])
def solve_rate(self, groups: list[list[str]]) -> float:
"""Fraction of completions whose boxed answer is exactly right."""
solved = [extract_boxed_int(c) == answer for group, answer in zip(groups, self.answers) for c in group]
return sum(solved) / len(solved)
def update(self, solve_rate: float) -> None:
"""Move D towards the frontier: D <- clamp(D + lr * (solve_rate - target), d_min, d_max)."""
raise NotImplementedError()
tests.test_curriculum_update(Curriculum)
Solution
def clamp(x : float, lo : float, hi : float) -> float:
return min(max(x, lo), hi)
class Curriculum:
"""Poses problems at difficulty D, scores the answers, and moves D towards the model's frontier."""
def __init__(
self,
task: Callable,
reward_fn: Callable,
initial: float = 1.0,
lr: float = 1.0,
target: float = 0.5,
d_min: float = 0.0,
d_max: float = len(DIFFICULTY_LADDER) - 1,
seed: int = 0,
):
self.task, self.reward_fn = task, reward_fn
self.difficulty, self.lr, self.target, self.d_min, self.d_max = initial, lr, target, d_min, d_max
self.rng = random.Random(seed) # for the task generator
self.np_rng = np.random.default_rng(seed) # for stochastic rounding
def sample_levels(self, n: int) -> Int[np.ndarray, " n"]:
"""`n` rungs by stochastic rounding of D: D = 2.3 gives rung 2 70% of the time and rung 3 30%."""
lo = np.floor(self.difficulty)
return (lo + (self.np_rng.random(n) < self.difficulty - lo)).astype(int)
def sample(self, n: int) -> list[str]:
"""Prompts for `n` problems at the current difficulty. Their rungs and answers are kept for scoring."""
self.levels = self.sample_levels(n).tolist()
prompts, self.answers = [], []
for level in self.levels:
prompt, answer = self.task(self.rng, level)
prompts.append(prompt)
self.answers.append(answer)
self.prompts = prompts
return prompts
def score(self, groups: list[list[str]]) -> Float[Tensor, "n_prompts G"]:
"""Reward of each completion: row p scores problem p's group of completions against its answer."""
return t.stack([self.reward_fn(group, answer) for group, answer in zip(groups, self.answers)])
def solve_rate(self, groups: list[list[str]]) -> float:
"""Fraction of completions whose boxed answer is exactly right."""
solved = [extract_boxed_int(c) == answer for group, answer in zip(groups, self.answers) for c in group]
return sum(solved) / len(solved)
def update(self, solve_rate: float) -> None:
"""Move D towards the frontier: D <- clamp(D + lr * (solve_rate - target), d_min, d_max)."""
d = self.difficulty + self.lr * (solve_rate - self.target)
self.difficulty = clamp(d, self.d_min, self.d_max)
The training loop
High level of the training loop:
Rollout Phase
- Generate a set of problems given the current difficulty level
- Sample
Gcompletions per question. - Score each answer with the reward function.
- Update the difficulty based on the new solve rate
- Store rollouts into the rollout buffer
Learning Phase
- Read all rollouts in the rollout buffer, split into microbatches
- Compute Dr.GRPO objective with gradient accumulation over microbatches
- Gradient ascent on the Dr. GRPO objective (one optimizer step)
Unlike PPO, (by default) new data is collected after every learning phase, and we always consider a single episode as the smallest atomic piece (PPO can sample a rollout that straddles the boundary between episodes, as the environment is not reset in between rollout phases), so this algorithm is more like the policy gradient methods we've seen earlier.
Why bother with the importance weight?
Strictly speaking, if all the data is generated, used, and then thrown away after a single gradient step, then we
are always learning on-policy, and the importance weight is always one. This is what we do here:
rollouts are sampled with weights $\theta$, every micro-batch's logprobs are computed with the same $\theta$,
but the training batch size is the same as the rollout batch size (we just use gradient accumulation to
save on memory). As such, $\pi_{\theta_\text{old}} = \pi_\theta$. However, if we were to directly use
logprobs in place of logprobs_old for the importance weight, then any perturbation would leave
it unchanged, and no gradients would flow through to $\theta$. So we instead use logprobs.detach()
as the old logprobs, which prevents gradients backfeeding through the denominator of the importance weight.
GRPO keeps PPO's ratio and clip because the general algorithm allows more than one optimizer step per rollout batch, and then the data is off-policy after the first step: Why do this?
- Reusing rollouts for several updates Generation is often the expensive part (especially for very long rollouts on hard math problems) so getting several updates out of each batch of rollouts is tempting.
- Several optimizer steps per generation batch, one per mini-batch (verl's
ppo_mini_batch_size, TRL'ssteps_per_generation). This is what the RLHF section does: 4 minibatches × 2 passes = 8 updates per rollout batch. - Sampler/trainer mismatch: vLLM's kernels give slightly different probabilities from the trainer's for the same weights, which is why TRL adds a truncated importance-sampling correction for vLLM rollouts.
With one step per batch the clipped objective collapses to REINFORCE with a group baseline, as the clip never fires.
Why does Dr. GRPO degenerate to REINFORCE?
With `old_logprobs = logprobs.detach()`, the ratio is $\rho_{i,t} = 1$ in value, which is inside the clip range $[1 - \epsilon, 1 + \epsilon]$. So the clip is the identity there and both arguments of the $\min$ are equal, and the gradient of each token's term isAs mentioned, there is no KL divergence penalty between the fine-tuned model and the base model, as we only care if the rollouts lead to high reward.
A few optimizations have been added that don't change the maths, but either speed up training, or reduce memory usage to reasonable amounts.
Logprobs
In [2.4.1] the completions were short (30-40 tokens), so a minibatch's logits fit comfortably in memory, even with
Qwen's 152k-token vocabulary (and get_logits only kept the last gen_len + 1 positions).
With RLVR, we are generating very long rollouts (up to 400 tokens), for a model with a much larger vocabulary (152k). * We can't change the vocab size, it's fixed. * We don't want to shrink the rollout size as this hampers the ability of the model to reason via long CoTs * We don't want to shrink the number of parallel rollouts as we get more effective toks/s with more parallel rollouts (on the hardware I was prototyping on, generation speed only started to saturate at 128-256 parallel rollouts).
The problem is that the full logit tensor (batch, seq, d_vocab) for all 128 rollouts is now ~10B floats,
or ~40GiB/20GiB at fp32/fp16, significantly larger than the model itself. This
problem is especially prevalent with tiny models like what we are using, where
the embedding/unembedding matrices are a significant fraction of the parameters
of the entire model.
Since we don't care about the KL divergence, nor do we care about the old logprobs (as we only do a single optimisation step for everything stored in the rollout buffer, ensuring everything is on-policy), gradient accumulation is sufficient to save us here.
This shrinks the intermediate result to (microbatch_size=4, seq=500, d_vocab=152k),
which is only ~0.3B floats (~1.2 GiB at fp32), very manageable, and it won't grow in size
if we swap in a larger model from the same family.
def generation_logprobs(
model: PeftModel,
input_ids: Int[Tensor, "batch seq"],
prompt_mask: Int[Tensor, "batch prefix_len"],
mask: Float[Tensor, "batch gen_len"],
) -> Float[Tensor, "batch gen_len"]:
"""Logprob of each generated token under `model` (0.0 where `mask` is 0)."""
prefix_len = prompt_mask.shape[1]
# Only the prompt's left padding needs masking: completion padding comes after every real token,
# so causal attention never reaches it, and `mask` discards the outputs at those positions.
attention_mask = t.cat([prompt_mask, t.ones_like(input_ids[:, prefix_len:])], dim=1)
logits = model(input_ids=input_ids, attention_mask=attention_mask).logits.float() # fp32 for the softmax over 152k tokens
logprobs = logits[:, prefix_len - 1:].log_softmax(-1) # same as `get_logprobs` in RLHF
return eindex(logprobs, input_ids[:, prefix_len - 1:], "b s [b s+1]") * mask # position t predicts token t + 1
Further Optimizations
-
Sampling is handled by vLLM if it's installed (it's optional, see the comment where we load the model), which is heavily optimized using PagedAttention. We won't be covering how or what that is here (you can read their blog post if interested, but in short expect the rollout phase to be ~2.5x faster with vLLM than with our fallback samplers. If vLLM isn't installed, we fall back to
utils.FastSampleron CUDA (CUDA graphs) or HFgenerateotherwise. Seeutils.pyfor the samplers'generatemethod (which matchesmodel.generate). -
No separate
oldpass: we take one gradient step per rollout, so the policy that sampled the batch is the current policy. We can passlogprobs.detach()asold_logprobs: the ratio is exactly 1 (so the clip never fires), but its gradient is still∇ logprobs, so the update is REINFORCE with the group-mean baseline. - Rows with zero advantage are skipped: under Dr. GRPO they contribute exactly zero to the loss so any question for which all answers get the same reward is skipped entirely.
- Length-sorted, trimmed micro-batches: completions are sorted by length and each micro-batch is cut at its own longest row, so short completions are grouped together, and padded out only to the length of the longest completion in that micro-batch.
def completion_mask(gen: Int[Tensor, "batch seq"], prefix_len: int, eos_id: int) -> Float[Tensor, "batch gen_len"]:
"""1.0 on generated tokens up to and including the first EOS, else 0.0."""
# Not `gen != pad`: Qwen's pad token IS its EOS, so that would drop the EOS and never train the model to stop
comp = gen[:, prefix_len:]
is_eos = (comp == eos_id).long()
eos_before = is_eos.cumsum(1) - is_eos # EOS tokens strictly before each position
return (eos_before == 0).float()
tests.test_completion_mask(completion_mask)
@dataclass
class RolloutBuffer:
"""The rollouts of one rollout phase. Rows are completions, `G` consecutive rows per prompt."""
gen: Int[Tensor, "batch seq"] # prompt + completion token ids
prompt_mask: Int[Tensor, "batch prefix_len"] # 0 on the prompt's left padding
mask: Float[Tensor, "batch gen_len"] # 1 on completion tokens that count for the loss (incl. the EOS)
advantages: Float[Tensor, " batch"] # one per completion: no time dependence
@classmethod
def from_rollouts(
cls,
gen: Int[Tensor, "n_prompts G seq"],
prompt_mask: Int[Tensor, "n_prompts prefix_len"],
advantages: Float[Tensor, "n_prompts G"],
eos_id: int,
) -> "RolloutBuffer":
"""Package a rollout as one row per completion: (prompt p, completion g) becomes row p * G + g."""
n_prompts, G, _ = gen.shape
rows = gen.flatten(0, 1) # (n_prompts * G, seq)
prompt_mask = prompt_mask[:, None].expand(n_prompts, G, -1).flatten(0, 1) # each prompt's mask, once per completion
return cls(rows, prompt_mask, completion_mask(rows, prompt_mask.shape[1], eos_id), advantages.flatten())
@property
def prefix_len(self) -> int:
return self.prompt_mask.shape[1]
def __getitem__(self, rows: slice | Tensor) -> "RolloutBuffer":
"""Select rows."""
return RolloutBuffer(self.gen[rows], self.prompt_mask[rows], self.mask[rows], self.advantages[rows])
def trim(self, gen_len: int) -> "RolloutBuffer":
"""Keep the first `gen_len` completion tokens (the rest must be padding)."""
return RolloutBuffer(self.gen[:, :self.prefix_len + gen_len], self.prompt_mask, self.mask[:, :gen_len], self.advantages)
def microbatches(self, size: int) -> list["RolloutBuffer"]:
"""Drop zero-advantage rows (they contribute nothing to the gradient) and split the rest into microbatches."""
buffer = self[self.advantages != 0]
chunks = utils.length_sorted_chunks(buffer.mask.sum(1).long(), size)
return [buffer[rows].trim(gen_len) for rows, gen_len in chunks]
Exercise - the rollout and learning phases
The two methods you need to implement:
rollout_phase:
1. Sample n_prompts prompts from the curriculum, and tokenize them (with padding=True).
2. Generate G completions for each prompt.
3. Decode them into groups, and score them.
4. Compute the advantages, and package the rollouts into a RolloutBuffer.
5. Log the rollout, update the curriculum with the solve rate, and return the buffer.
learning_phase: one optimizer step for the whole buffer.
1. Zero the gradients,
2. call backward() on the loss of every micro-batch so the gradients accumulate.
3. then one optimizer step for all the batches.
Return the total loss over the micro-batches as a float with .item().
These helpers are provided:
self.curriculum-sample(n)returnsnprompts at the current difficulty,score(groups)returns the rewards,solve_rate(groups)the exact-match solve rate, andupdate(solve_rate)is the method you wrote.self.sampler.generate(**enc, max_new_tokens=..., num_return_sequences=..., verbose=True)-Gcompletions for each tokenized prompt, as token ids(n_prompts, G, seq):gen[p, g]is prompt p (left-padded) followed by its g-th completion (right-padded).self.tokenizer.batch_decodetakes one batch dimension only, so decodegenone prompt at a time to get the completion texts grouped by prompt:groups[p][g](slice off the prompt first, and passskip_special_tokens=True).RolloutBuffer.from_rollouts(gen, prompt_mask, advantages, eos_id)- packages a rollout for the learning phase.self.log_rollout(...)- progress-bar stats and the training history.self.compute_loss(mb)- the Dr. GRPO loss of one micro-batch, scaled to its share of the buffer.
RolloutBuffer.microbatches() automatically gives you an iterable
that you can loop through to get all the microbatches automatically.
Use this for the learning phase:
for micro_batch in buffer.microbatches(self.args.micro_batch):
# do stuff ...
class RLVRTrainer:
def __init__(
self,
model: PeftModel,
tokenizer: PreTrainedTokenizerBase,
args: RLVRArgs,
engine: utils.VLLMEngine | None = None,
):
self.model, self.tokenizer, self.args = model, tokenizer, args
t.manual_seed(args.seed)
self.params = [p for p in model.parameters() if p.requires_grad] # the LoRA adapters
self.optimizer = t.optim.AdamW(self.params, lr=args.lr, fused=True)
self.sampler: utils.Sampler = utils.make_sampler(model, tokenizer, args.sampler, engine, args.temperature, args.top_k, args.top_p)
self.curriculum = Curriculum(args.task, args.reward_fn, initial=args.initial_difficulty, lr=args.difficulty_lr, target=args.target_success,
d_min=args.min_difficulty, d_max=args.max_difficulty, seed=args.seed)
self.history = [] # (difficulty, solve rate) per step
self.latest = utils.LatestText() # the newest rollout, overwriting the last one
def log_rollout(
self, groups: list[list[str]], rewards: Float[Tensor, "n_prompts G"], solve_rate: float, buffer: RolloutBuffer
) -> None:
"""Record this step's difficulty and solve rate, set the metrics for the live plot, and print a few rollouts."""
levels = self.curriculum.levels
self.history.append((self.curriculum.difficulty, solve_rate))
self.metrics = dict(solve=solve_rate, reward=rewards.mean().item(), D=self.curriculum.difficulty,
signal=(rewards.std(1) > 0).float().mean().item(), # groups whose rewards differ: they train
length=buffer.mask.sum(1).mean().item()) # mean completion length in tokens
if self.args.log_samples:
rungs = ", ".join(f"{levels.count(L)} x {DIFFICULTY_LADDER[L][0]}" for L in sorted(set(levels)))
lines = [f"--- step {len(self.history)}: D = {self.metrics['D']:.2f} ({rungs}), solve rate {solve_rate:.2f}"]
for p in range(min(self.args.log_samples, len(groups))): # the first completion of the first few problems
completion, answer = groups[p][0], self.curriculum.answers[p]
lines.append(utils.rollout_summary(self.curriculum.prompts[p], completion, answer,
extract_boxed_int(completion), rewards[p, 0]))
self.latest.update("\n".join(lines))
@t.no_grad()
def rollout_phase(self) -> RolloutBuffer:
"""Sample problems, generate completions, score them, and move the curriculum."""
raise NotImplementedError()
def compute_loss(self, mb: RolloutBuffer) -> Float[Tensor, ""]:
"""This microbatch's share of the loss averaged over every completion in the buffer."""
logprobs = generation_logprobs(self.model, mb.gen, mb.prompt_mask, mb.mask)
# One gradient step per rollout, so the sampling policy is the current one: ratio = 1, grad = grad logprobs
loss = drgrpo_objective(logprobs, logprobs.detach(), mb.advantages, mb.mask, self.args.max_new_tokens)
loss = loss / (self.args.n_prompts * self.args.num_return_sequences)
return loss
def learning_phase(self, buffer: RolloutBuffer) -> float:
"""Perform one gradient step over the entire buffer, accumulating gradients over each microbatch. Returns the
buffer's loss."""
raise NotImplementedError()
def train(self) -> list[tuple[float, float]]:
"""Alternate rollout and learning phases for `args.n_steps`, with live training curves. Returns
(difficulty, solve rate) per step."""
plot = LivePlot(range(self.args.n_steps), "solve reward signal | D", "length | loss", desc="RLVR", unit="steps")
plot["solve"].set_ylim(0, 1)
plot["D"].set_ylabel("difficulty D")
plot["length"].set_ylabel("mean completion tokens")
plot["loss"].set_ylabel("Dr. GRPO loss")
for _ in plot:
buffer = self.rollout_phase()
loss = self.learning_phase(buffer)
plot.log(**self.metrics, loss=loss) # also shown in the progress bar's postfix
plot.finish()
return self.history
def evaluate(
model: PeftModel,
tokenizer: PreTrainedTokenizerBase,
levels: tuple[int, ...] = (0, 2, 3, 5),
n: int = 64,
max_new_tokens: int = 500,
seed: int = 4444,
engine: utils.VLLMEngine | None = None,
) -> dict[str, float]:
"""Held-out pass@1 on each rung, on a fixed problem set."""
sampler = utils.make_sampler(model, tokenizer, RLVRArgs().sampler, engine)
out = {}
for L in levels:
rng = random.Random(seed + L)
prompts, answers = zip(*[mult_byhand(rng, L) for _ in range(n)])
enc = tokenizer(list(prompts), return_tensors="pt", padding=True).to(device)
t.manual_seed(seed)
gen = sampler.generate(**enc, max_new_tokens=max_new_tokens)[:, 0] # one completion per prompt
completions = tokenizer.batch_decode(gen[:, enc.input_ids.shape[1]:], skip_special_tokens=True)
out[DIFFICULTY_LADDER[L][0]] = sum(extract_boxed_int(c) == a for c, a in zip(completions, answers)) / n
print(" ".join(f"{k}: {v:.3f}" for k, v in out.items()), flush=True)
return out
print("=== base model performance ===")
before = evaluate(rlvr_model, tokenizer, engine=vllm_engine) # 3x2 / 3x3 / 4x3 / 4x4, n=64 each, 500-token budget
RLVRTrainer(rlvr_model, tokenizer, RLVRArgs(n_steps=40), engine=vllm_engine).train()
print("=== trained model performance ===")
after = evaluate(rlvr_model, tokenizer, engine=vllm_engine)
Solution
class RLVRTrainer:
def __init__(
self,
model: PeftModel,
tokenizer: PreTrainedTokenizerBase,
args: RLVRArgs,
engine: utils.VLLMEngine | None = None,
):
self.model, self.tokenizer, self.args = model, tokenizer, args
t.manual_seed(args.seed)
self.params = [p for p in model.parameters() if p.requires_grad] # the LoRA adapters
self.optimizer = t.optim.AdamW(self.params, lr=args.lr, fused=True)
self.sampler: utils.Sampler = utils.make_sampler(model, tokenizer, args.sampler, engine, args.temperature, args.top_k, args.top_p)
self.curriculum = Curriculum(args.task, args.reward_fn, initial=args.initial_difficulty, lr=args.difficulty_lr, target=args.target_success,
d_min=args.min_difficulty, d_max=args.max_difficulty, seed=args.seed)
self.history = [] # (difficulty, solve rate) per step
self.latest = utils.LatestText() # the newest rollout, overwriting the last one
def log_rollout(
self, groups: list[list[str]], rewards: Float[Tensor, "n_prompts G"], solve_rate: float, buffer: RolloutBuffer
) -> None:
"""Record this step's difficulty and solve rate, set the metrics for the live plot, and print a few rollouts."""
levels = self.curriculum.levels
self.history.append((self.curriculum.difficulty, solve_rate))
self.metrics = dict(solve=solve_rate, reward=rewards.mean().item(), D=self.curriculum.difficulty,
signal=(rewards.std(1) > 0).float().mean().item(), # groups whose rewards differ: they train
length=buffer.mask.sum(1).mean().item()) # mean completion length in tokens
if self.args.log_samples:
rungs = ", ".join(f"{levels.count(L)} x {DIFFICULTY_LADDER[L][0]}" for L in sorted(set(levels)))
lines = [f"--- step {len(self.history)}: D = {self.metrics['D']:.2f} ({rungs}), solve rate {solve_rate:.2f}"]
for p in range(min(self.args.log_samples, len(groups))): # the first completion of the first few problems
completion, answer = groups[p][0], self.curriculum.answers[p]
lines.append(utils.rollout_summary(self.curriculum.prompts[p], completion, answer,
extract_boxed_int(completion), rewards[p, 0]))
self.latest.update("\n".join(lines))
@t.no_grad()
def rollout_phase(self) -> RolloutBuffer:
"""Sample problems, generate completions, score them, and move the curriculum."""
prompts = self.curriculum.sample(self.args.n_prompts)
enc = self.tokenizer(prompts, return_tensors="pt", padding=True).to(device)
gen = self.sampler.generate(**enc, max_new_tokens=self.args.max_new_tokens,
num_return_sequences=self.args.num_return_sequences, verbose=True)
prefix_len = enc.input_ids.shape[1]
groups = [self.tokenizer.batch_decode(g[:, prefix_len:], skip_special_tokens=True) for g in gen] # groups[p][g]
rewards = self.curriculum.score(groups).to(device) # (n_prompts, G)
advantages = drgrpo_advantages(rewards) # (n_prompts, G)
buffer = RolloutBuffer.from_rollouts(gen, enc.attention_mask, advantages, self.tokenizer.eos_token_id)
solve_rate = self.curriculum.solve_rate(groups)
self.log_rollout(groups, rewards, solve_rate, buffer)
self.curriculum.update(solve_rate)
return buffer
def compute_loss(self, mb: RolloutBuffer) -> Float[Tensor, ""]:
"""This microbatch's share of the loss averaged over every completion in the buffer."""
logprobs = generation_logprobs(self.model, mb.gen, mb.prompt_mask, mb.mask)
# One gradient step per rollout, so the sampling policy is the current one: ratio = 1, grad = grad logprobs
loss = drgrpo_objective(logprobs, logprobs.detach(), mb.advantages, mb.mask, self.args.max_new_tokens)
loss = loss / (self.args.n_prompts * self.args.num_return_sequences)
return loss
def learning_phase(self, buffer: RolloutBuffer) -> float:
"""Perform one gradient step over the entire buffer, accumulating gradients over each microbatch. Returns the
buffer's loss."""
self.optimizer.zero_grad()
total = 0.0
buffer_loader = buffer.microbatches(self.args.micro_batch)
for micro_batch in tqdm(buffer_loader, desc="learning", leave=False):
loss = self.compute_loss(micro_batch)
loss.backward()
total += loss.item()
self.optimizer.step()
return total
def train(self) -> list[tuple[float, float]]:
"""Alternate rollout and learning phases for `args.n_steps`, with live training curves. Returns
(difficulty, solve rate) per step."""
plot = LivePlot(range(self.args.n_steps), "solve reward signal | D", "length | loss", desc="RLVR", unit="steps")
plot["solve"].set_ylim(0, 1)
plot["D"].set_ylabel("difficulty D")
plot["length"].set_ylabel("mean completion tokens")
plot["loss"].set_ylabel("Dr. GRPO loss")
for _ in plot:
buffer = self.rollout_phase()
loss = self.learning_phase(buffer)
plot.log(**self.metrics, loss=loss) # also shown in the progress bar's postfix
plot.finish()
return self.history
Let's save the trained adapter to disk. save_pretrained on a peft model only writes the LoRA weights (about 40MB
here) and a config recording which base model they belong to, so anything that can load the base model can load our
fine-tuned one.
The playground from section 1 lists every adapter in part42_rlvr/adapters/ in its model dropdown, next to the base
model, so you can compare the two side by side on the same problems (switching between them is instant, since the base
weights stay loaded). If it's still running from earlier, just refresh the page; otherwise start it again:
cd chapter2_rl/exercises/part42_rlvr/playground
python app.py
or point it straight at the adapter with python app.py --weights ../adapters/rlvr-mult.
adapter_dir = section_dir / "adapters" / "rlvr-mult"
rlvr_model.save_pretrained(adapter_dir)
print(f"Saved the LoRA adapter to {adapter_dir}")
We can look at how the model tries to solve a problem before and after training, to see what new strategies it has learned.
a, b = 4127, 386
prompt = f"Compute {a} * {b}. Reason step by step, then give the final answer as \\boxed{{N}}.\n"
sampler = utils.HFSampler(rlvr_model, tokenizer) # streams through the HF model, with the current LoRA
print(prompt)
print("=== base model ===")
with rlvr_model.disable_adapter():
base = sampler.generate_stream(prompt, max_new_tokens=500)
print("\n=== after RLVR ===")
trained = sampler.generate_stream(prompt, max_new_tokens=500)
print(f"\nanswer {a * b}: base boxed {extract_boxed_int(base)}, trained boxed {extract_boxed_int(trained)}")
RL as capability elicitation (optional)
A trained completion shows real step-by-step working, e.g. 4127 × 386 = 4127×300 + 4127×80 + 4127×6 = 1,238,100 + 330,160 + 24,762 → \boxed{1593022}.
In our 40-step run, 4×3 pass@1 went from ~0.15 to ~0.7 and 4×4 from near 0 to ~0.5 (your own numbers are printed above). But was anything new actually learned? Or did we just amplify existing behaviours of the base model?
One way to tell is to change how we measure success: Currently we give the model one
and only one chance to guess the answer, and the score it. This is called pass@1,
but it isn't the only way to measure ability. pass@k asks: out of k independent
samples, does at least one get it right? One should expect that pass@k score to grow with k
for tasks where the base model could already do it, but it was unlikely to sample the correct answer,
and be flat at zero for all k if it is virtually impossible for the base model to solve.
(Yue et al., 2025) claims that for many models and tasks,
even if the RLVR'd model beats the base model as measured by pass@1, for large $k$, the base model's
pass@k performance can catch up to, or exceed that of the RLVR'd model!
They argue that RLVR mostly sharpens the distribution of the base model, rather than teaching it
something new. Let's test whether this holds for our multiplication task.
import matplotlib.pyplot as plt
def pass_at_k(n: int, c: int, k: int) -> float:
"""Unbiased pass@k from `n` samples of which `c` are correct (Chen et al., 2021): 1 - C(n - c, k) / C(n, k)."""
if n - c < k:
return 1.0
return 1.0 - float(np.prod(1.0 - k / np.arange(n - c + 1, n + 1)))
@t.no_grad()
def count_correct(
model: PeftModel,
tokenizer: PreTrainedTokenizerBase,
level: int,
base: bool,
n_problems: int = 32,
n_samples: int = 256,
max_new_tokens: int = 500,
seed: int = 4444,
engine: utils.VLLMEngine | None = None,
) -> np.ndarray:
"""Sample `n_samples` completions for each of `n_problems` problems at `level`, from the base model (LoRA off) if
`base` else the trained one. Returns the number of correct completions per problem."""
rng = random.Random(seed + level)
problems = [mult_byhand(rng, level) for _ in range(n_problems)]
sampler = utils.make_sampler(model, tokenizer, engine=engine)
counts = []
for prompt, answer in tqdm(problems, desc=f"{DIFFICULTY_LADDER[level][0]} {'base' if base else 'RLVR'}"):
enc = tokenizer([prompt], return_tensors="pt").to(device)
if base and engine is not None: # vLLM holds its own copy of the LoRA, so switch it off there
gen = engine.generate(**enc, max_new_tokens=max_new_tokens, num_return_sequences=n_samples, use_lora=False)
else:
with model.disable_adapter() if base else contextlib.nullcontext():
gen = sampler.generate(**enc, max_new_tokens=max_new_tokens, num_return_sequences=n_samples)[0]
completions = tokenizer.batch_decode(gen[:, enc.input_ids.shape[1]:], skip_special_tokens=True)
counts.append(sum(extract_boxed_int(c) == answer for c in completions))
return np.array(counts)
def plot_pass_at_k(counts: dict[tuple[str, str], np.ndarray], n_samples: int):
"""One panel per level: mean pass@k over problems (with a standard-error band) for each model."""
levels = list(dict.fromkeys(level for level, _ in counts))
ks = [2**i for i in range(int(np.log2(n_samples)) + 1)]
fig, axes = plt.subplots(1, len(levels), figsize=(6 * len(levels), 4.5), squeeze=False)
for ax, level in zip(axes[0], levels):
curves = {}
for (lv, name), c in counts.items():
if lv != level:
continue
M = np.array([[pass_at_k(n_samples, ci, k) for k in ks] for ci in c])
curves[name] = mean = M.mean(0)
se = M.std(0) / np.sqrt(len(c))
ax.plot(ks, mean, "-o", ms=4, label=f"{name} (pass@1 {mean[0]:.2f}, pass@{ks[-1]} {mean[-1]:.2f})")
ax.fill_between(ks, mean - se, mean + se, alpha=0.2)
caught = [k for k, b, r in zip(ks, curves["base"], curves["RLVR"]) if b >= r]
if caught:
ax.axvline(caught[0], color="gray", ls="--", lw=1, label=f"base catches up at k = {caught[0]}")
ax.set_xscale("log", base=2)
ax.set_xticks(ks, [str(k) for k in ks])
ax.set(xlabel="k", ylabel="pass@k", ylim=(0, 1.02), title=f"{level}: {len(counts[level, 'base'])} problems, {n_samples} samples each")
ax.grid(alpha=0.3)
ax.legend(loc="lower right", fontsize=9)
fig.tight_layout()
return fig
if SLOW:
n_samples = 256
counts = {}
for level in (3, 5): # 4x3 and 4x4
for name, base in (("base", True), ("RLVR", False)):
counts[DIFFICULTY_LADDER[level][0], name] = count_correct(
rlvr_model, tokenizer, level, base, n_samples=n_samples, engine=vllm_engine
)
plot_pass_at_k(counts, n_samples)
plt.show()
Running this with SLOW = True on the model trained above (40 steps, an A40, ~20 minutes) gives:

- 4x3: RLVR is far ahead at
pass@1(0.71 vs 0.15) but plateaus at 0.94: on 2 of the 32 problems it never finds the answer in 256 tries, while the base model solves every problem at least 3 times. The base model catches up atk = 64and is ahead from there on. - 4x4: within 256 samples the base model doesn't catch up (0.97 vs 1.00 at
k = 256), but the gap shrinks from 0.47 atk = 1to 0.03, and the base model has a nonzero solve rate on 31 of the 32 problems.
So, as Yue et al. found, most of what RLVR did here is make answers the base model could already reach far more likely, at the cost of some it used to find occasionally.
What makes a good RLVR task? (discussion)
From sweeping many tasks on small models, some rules of thumb:
- Make sure it is a task that the base model can do at least on occasion (at least more than one per group rollout)
- If this is still too hard, either have a problem where you can adjust the difficulty dynamically as we have done, or have a problem where you can give partial credit for progress (watch out for reward hacking!)
- Spend time on the prompt. Sometimes the training loop can fail entirely if you don't give the model some instructions as to what it should be doing, as it will never make any progress to then reinforce.
- Avoid complex reward functions! Optimization pressure is clever and will find a way around it where possible.
- If you can't specify exactly what you want, writing a rubric and having another LLM as a judge is an option, but this again has the vulnerability of tricking the judge.