6.12 RL Data, Curriculum & Replay Management¶
Every chapter in this part so far has treated the prompts as a given — a static .jsonl of math problems or coding tasks that flows into the rollout engine. That is a convenient fiction, and it is the single most expensive fiction in RL-for-LLMs. The dirty secret of reinforcement learning with verifiable rewards (RLVR) is that most of your rollout budget is wasted on prompts that teach the policy nothing. A prompt the model already solves 8/8 times produces a group with zero reward variance and therefore zero gradient under GRPO. A prompt the model solves 0/8 times produces the same zero-variance, zero-gradient group from the other end. You paid for \(G\) full autoregressive rollouts — the most expensive operation in the entire stack (The Anatomy of an RL-for-LLM System) — and got an all-zeros advantage vector for your trouble.
This chapter is about the data side of RL: the levers that determine which prompts the policy sees, when it sees them, and whether a rollout you already paid for gets reused. These levers — dataset construction and quality control, difficulty estimation, difficulty-targeted online selection, prompt curriculum, dynamic sampling, and rollout replay buffers — are collectively the largest free lunch in RL sample efficiency. They are also where theory (the variance of the policy-gradient estimator) and systems (keeping a heavy-tailed generation fleet saturated) collide most violently.
We assume you know GRPO and the RLVR recipe (GRPO, RLOO & Critic-Free RL; RL with Verifiable Rewards (RLVR) & The Reasoning Recipe), the throughput model from the previous chapter (Scaling RL: Throughput, Load Balancing & The Latest Tricks), and the reward/verifier machinery that turns a completion into a scalar (Reward Engineering, Verifiers & Sandboxes). This is the curriculum-and-data complement to those chapters.
Why difficulty is the master variable¶
Start from the gradient. For a prompt \(x\) with a binary verifiable reward \(r\in\{0,1\}\), sampled \(G\) times under the current policy, the empirical pass rate is \(\hat p = \frac{1}{G}\sum_{i} r_i\). GRPO centers each completion’s reward by the group mean and (in vanilla form) divides by the group standard deviation:
Two facts fall out immediately. First, if every \(r_i\) is identical (all \(0\) or all \(1\)), then \(\bar r = r_i\) for all \(i\), every \(A_i = 0\), and the prompt contributes exactly zero to the policy gradient. The group is dead. Second, the amount of usable signal in a group is governed by the variance of the reward, which for a Bernoulli is
This is a downward parabola, maximized at \(p = 0.5\) where \(\operatorname{Var}(r) = 0.25\), and falling to zero at both ends. The expected number of informative pairs — completions with different rewards inside a group, which is what GRPO’s centering actually exploits — is proportional to \(p(1-p)\) as well. So the gradient signal-to-noise of a prompt is a single-peaked function of one scalar: its pass rate under the current policy.
The practical consequence: you want to spend rollout compute on prompts whose current pass rate is near 0.5. A prompt at \(p=0.9\) gives variance \(0.09\); a prompt at \(p=0.5\) gives \(0.25\) — roughly \(2.8\times\) the signal per rollout. A prompt at \(p=0.99\) gives \(0.0099\), essentially nothing. This is the quantitative heart of curriculum learning in RL, and it is why “difficulty” is not a soft pedagogical nicety but the master variable controlling your estimator’s efficiency.
Optional: what dividing by the group std does to this argument
The clean \(p(1-p)\) law is exact for the unnormalized advantage \(A_i = r_i - \bar r\) used by Dr. GRPO and DAPO (GRPO, RLOO & Critic-Free RL): with \(k\) successes out of \(G\) and \(\hat p = k/G\), the total advantage mass in the group is
With vanilla GRPO’s std division, each success gets \(A_i = \sqrt{(1-\hat p)/\hat p}\) and each failure \(-\sqrt{\hat p/(1-\hat p)}\), so the mass becomes \(\sum_i |A_i| = 2G\sqrt{\hat p(1-\hat p)}\) — a square root, which is much flatter. At \(\hat p = 0.1\) versus \(\hat p = 0.5\) the unnormalized group carries \(0.09/0.25 = 36\%\) of the mass, the std-normalized one \(0.3/0.5 = 60\%\). That amplification of extreme-difficulty prompts is the “difficulty bias” Dr. GRPO objects to (it is not the gradient of any objective) and that others defend as a free curriculum.
Either way the targeting conclusion survives, for a reason independent of magnitude: at finite \(G\) a prompt only contributes at all if its group is non-degenerate, which happens with probability \(1 - p^G - (1-p)^G\). That function — the survival probability you will meet again under dynamic sampling — collapses at both ends no matter how the advantage is scaled. Extreme prompts are not merely quiet; most of the time they are silent.
There is a subtlety the variance argument hides. Pass rate is non-stationary: it is a property of the prompt and the current policy together, and the policy moves every step. A prompt that sits at \(p=0.5\) at step 0 may be at \(p=0.95\) by step 200 because the model learned it. A static difficulty label computed once, offline, decays. The whole architecture of online difficulty-targeted selection exists to track this moving target.
Aside: why not just always train at p=0.5?
Because a policy that only ever sees 50%-pass prompts never gets pulled into new regimes. The frontier of “things I can do half the time” advances only if some signal leaks from harder prompts (occasional lucky successes that the gradient amplifies) and is consolidated on easier ones. In practice you target a band — say \(p\in[0.2, 0.8]\) — not a razor at \(0.5\), and you let the band’s contents drift as the policy improves. The band is the curriculum; the policy walks up it.
Building the dataset: construction and quality control¶
Before any online selection can help, you need a corpus of prompts that is (a) verifiable, (b) de-duplicated against your eval sets, and © spread across a usable difficulty range. Garbage in the prompt pool is more corrosive in RL than in SFT, because RL will ruthlessly exploit any defect in the reward signal (Reward Hacking, Over-Optimization & Alignment Failures).
The non-negotiables of an RLVR prompt set¶
For each task you need a prompt and a checker. The checker is the verifier — a deterministic (or near-deterministic) function that maps a completion to a reward. For math it is symbolic/numeric answer-equivalence; for code it is a unit-test harness in a sandbox; for agentic tasks it is an environment that returns a terminal success bit (Reward Engineering, Verifiers & Sandboxes). The data record therefore carries more than text:
from dataclasses import dataclass, field
from typing import Callable, Optional
import hashlib
@dataclass
class RLTask:
task_id: str # stable hash, used for dedup + replay keys
prompt: str # the rendered chat prompt fed to the policy
answer: Optional[str] = None # ground-truth answer for math-style checking
tests: Optional[list] = None # unit tests for code tasks
domain: str = "unknown" # math / code / logic / agentic / ...
# difficulty state, maintained ONLINE (see later sections):
pass_rate_ema: float = 0.5 # exponential moving avg of empirical pass rate
n_attempts: int = 0 # how many groups we've spent on this task
n_solved_groups: int = 0 # groups where at least one sample passed
static_difficulty: Optional[float] = None # offline estimate, optional prior
def fingerprint(self) -> str:
# Normalize before hashing so trivial whitespace/casing diffs collapse.
norm = " ".join(self.prompt.lower().split())
return hashlib.sha256(norm.encode()).hexdigest()[:16]
# Do NOT hand-roll math answer matching. `math-verify` (HuggingFace,
# `pip install math-verify`) is the de-facto open-source checker: `parse` pulls the
# answer out of \boxed{...}/LaTeX/plain text and `verify` compares them with a
# SymPy-backed equivalence test, so 0.5 == 1/2 and (x+1)^2 == x^2+2x+1 instead of
# being scored wrong by string equality. (API tracks the current release.)
from math_verify import parse, verify
def make_checker(task: RLTask) -> Callable[[str], float]:
"""Return a verifier closure. In production the code path runs in a sandbox;
here we sketch the math path. The checker MUST be robust to extraction noise."""
if task.domain == "math":
gold = parse(f"${task.answer}$") # parse gold once, reuse per rollout
def check(completion: str) -> float:
pred = parse(completion) # extraction is part of the reward
return 1.0 if pred and verify(gold, pred) else 0.0
return check
# For domain == "code", the checker executes `task.tests` against the extracted
# program inside a resource-limited sandbox — see the verifiers chapter.
raise NotImplementedError(f"no checker for domain {task.domain}")
A few rules that separate a usable RL corpus from a frustrating one:
-
Verifiability over volume. Ten thousand prompts with airtight checkers beat a million with flaky ones. A checker that returns a false positive 2% of the time teaches the policy to find that 2% — RL is an adversary against your verifier. Audit checkers on known-good and known-bad completions before trusting them.
-
Answer extraction is part of the reward. If your checker only accepts
\boxed{}and the model writes “The answer is 42,” you will mislabel correct completions as failures, depressing pass rates and corrupting your difficulty estimates. Make extraction permissive for grading correctness but be careful it cannot be gamed. This is precisely why you usemath-verifyrather than a regex. -
Decontaminate against evals — twice. Run n-gram (e.g. 13-gram) and embedding-level overlap checks between your prompt pool and every benchmark you will report on. Then do it again after any synthetic augmentation. Contamination inflates eval and is the most common cause of “great training curve, flat real-world gains.” This mirrors pretraining decontamination (Data Cleaning, Deduplication & Quality Filtering) — and the tooling is the same: HuggingFace
datatrove’s MinHash and n-gram-decontamination pipelines at corpus scale, ordatasketch’sMinHashLSHfor a pool small enough to fit in memory. -
Dedup near-duplicates inside the pool. Two prompts that differ only in variable names are one prompt’s worth of signal but two prompts’ worth of compute. MinHash/LSH or embedding clustering collapses them. RLVR pools are small enough (104–106 prompts) that a single-machine
datasketchpass over shingled prompt text takes minutes; there is no excuse for skipping it. -
Track provenance. Keep
domain, source, and a difficulty prior per task. You will want to re-weight domains later, exactly as in pretraining data mixing (Data Mixing, Domain Weighting & Curriculum).
Where the prompts come from¶
Most strong RLVR runs blend several sources: curated competition/benchmark-style problems with known answers; synthetically generated variants (a stronger model or a generator produces new problems plus checkable answers, then a solver filters for solvability — see Synthetic Data for Pre- and Post-Training); and mined failures from a previous policy (prompts the last checkpoint got wrong become the next run’s curriculum, a data flywheel — Data Flywheels & Continuous Improvement). For agentic tasks the “prompt” is a whole environment seed (a repo state, a browser task, a tool sandbox), and construction means building reproducible, resettable environments (Agentic & Multi-Turn RL).
The 2026 packaging convention for all of this is the environment: prompt set plus parser plus a rubric of reward functions, behind one interface that several trainers can consume. willccbb/verifiers is the reference implementation and the emerging unit of exchange — you pip install (or write) an environment and hand it to a TRL- or veRL-style trainer instead of shipping a bespoke .jsonl plus a bespoke grader. Prefer it over hand-rolling: an environment carries its checker with it, which is exactly the coupling that keeps difficulty estimates meaningful. On disk, the pool itself is best kept as a HuggingFace datasets parquet table keyed by task_id so that the online difficulty state below can be checkpointed as a small sidecar against the same keys.
Difficulty estimation: offline priors and online truth¶
You ultimately want, for every task, an estimate of its pass rate under the current policy. There are two regimes: an offline prior computed once before the run, and the online estimate maintained during it.
Offline difficulty estimation¶
Before the run, sample the base policy (or a reference model) \(k\) times on each prompt and record \(\hat p_0 = (\text{successes})/k\). This is the maximum-likelihood estimate of the pass rate, with the obvious caveat that for small \(k\) it is coarse — with \(k=8\) you can only distinguish pass rates in steps of \(1/8 = 0.125\), and the standard error is
For \(k=8\) that worst-case SE is \(\approx 0.18\) — so a prompt you measured at \(\hat p_0 = 0.5\) might truly be anywhere in roughly \([0.32, 0.68]\). Offline difficulty is a prior, not a label. Its value is in bucketing and in discarding the unusable tails (prompts the base model solves \(0/k\) — possibly impossible or mis-checkered — and \(k/k\) — already mastered). A common offline pipeline:
import numpy as np
def estimate_offline_difficulty(tasks, policy, checker_fn, k=8, batch_gen=None):
"""One pass over the pool: k rollouts each, record empirical pass rate.
Returns buckets and prunes the dead tails. batch_gen() should call your
rollout engine (vLLM/SGLang) — batch ALL prompts*k together for throughput."""
for t in tasks:
completions = batch_gen(t.prompt, n=k) # k samples
rewards = [checker_fn(t)(c) for c in completions]
t.static_difficulty = float(np.mean(rewards)) # p_hat_0 in [0,1]
t.pass_rate_ema = t.static_difficulty # seed the online EMA
keep, pruned = [], []
for t in tasks:
# Prune the dead tails: never-solved (maybe broken) and always-solved.
if 0.0 < t.static_difficulty < 1.0:
keep.append(t)
else:
pruned.append(t)
return keep, pruned
def difficulty_bucket(p, edges=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0)):
"""Map a pass rate to a coarse bucket index. Buckets, not raw p, because
raw p from small k is too noisy to act on at fine granularity."""
for b in range(len(edges) - 1):
if edges[b] <= p < edges[b + 1]:
return b
return len(edges) - 2 # p == 1.0 edge case
The \(0/k\)-and-\(k/k\) pruning typically removes 20–50% of a raw competition-math pool: a chunk is trivial for a strong base model, and another chunk is currently impossible (genuinely too hard, or the checker is wrong). Both contribute zero gradient and pure rollout waste, so removing them is a direct sample-efficiency win before training begins.
Online difficulty: the EMA and Beta views¶
During training the truth is the online pass rate. Each time task \(t\) is rolled out as a group of \(G\), you observe \(\hat p_t = (\text{successes})/G\) and update an exponential moving average:
with \(\alpha\) around \(0.1\)–\(0.3\). The EMA tracks the moving policy: a prompt that was hard becomes easy as the policy learns, and the EMA follows. A cleaner Bayesian alternative is a Beta–Bernoulli posterior per task: maintain counts \((s, f)\) of successes and failures (optionally discounted over time so old observations decay), and the posterior pass rate is \(\text{Beta}(s+1, f+1)\) with mean \(\frac{s+1}{s+f+2}\). The Beta view gives you not just a point estimate but a credible interval, which is exactly what you need to decide whether a prompt is “near 0.5” with confidence or just under-sampled — and it plugs naturally into a Thompson-sampling selection policy (below).
This state is small and you must checkpoint it with the model. Three floats per task (\(s\), \(f\), n_groups) keyed by task_id is a few megabytes for a million-prompt pool — write it as a parquet/JSON sidecar next to every model checkpoint. If you do not, a preempted run resumes with a warm policy and a cold curriculum: the selector spends its first hundred steps re-discovering difficulties it already paid to measure, and (worse) re-samples the mastered prompts you had correctly stopped selecting.
Common pitfall: stale difficulty labels
The most common difficulty-curriculum bug is computing pass rates once and treating them as fixed. Within a few hundred steps a well-chosen 50%-band collapses toward 100% as the policy masters it, and if you keep sampling the same “medium” bucket you are now training on solved prompts — zero gradient, wasted compute, and a training curve that mysteriously plateaus. Difficulty MUST be re-estimated online (EMA or decayed Beta). The whole point is that difficulty is a function of the current policy, which is a moving target.
Difficulty-targeted online selection: keeping prompts near p≈0.5¶
Now the central mechanism. Given a pool with online difficulty estimates, how do you choose the next batch of prompts to roll out so that the realized groups cluster near \(p\approx0.5\) and almost none come back zero-variance?
Selection as a bandit over difficulty¶
Frame it as a multi-armed bandit where each task (or each difficulty bucket) is an arm and the “reward” is the informativeness of the group it produces — for example \(\hat p_t(1-\hat p_t)\), the realized Bernoulli variance, or simply \(\mathbb{1}[\text{group had non-zero variance}]\). You want to pull arms that are currently informative while exploring arms whose difficulty you are unsure about (because the policy moved). Two clean policies:
- Greedy-to-target: score each task by closeness of its estimated pass rate to the target \(p^\star\) (usually \(0.5\)), e.g. \(\text{score}(t) = -\,|\bar p_t - p^\star|\), and sample the top-scoring tasks (with noise / temperature so you do not over-commit to a handful). Simple, effective, but blind to estimate uncertainty.
- Thompson sampling on the Beta posterior: draw \(\tilde p_t \sim \text{Beta}(s_t+1, f_t+1)\) for each candidate and select tasks whose sampled pass rate is nearest the target. This automatically explores under-sampled tasks (wide posteriors get drawn far from their mean), so freshly-promoted-difficulty prompts get re-evaluated. This is the principled version and is what I reach for in practice.
A worked implementation appears in the code section. The key design choice is the band, not the point: select for \(\bar p \in [p_{\text{lo}}, p_{\text{hi}}]\) (say \([0.2, 0.8]\)) rather than exactly \(0.5\), so the batch retains some easier prompts (stability, anti-forgetting) and some harder prompts (frontier signal, exploration). Within the band you can still weight toward the center.
The relationship to dynamic sampling¶
Difficulty-targeted selection and dynamic sampling (DAPO-style) attack the same enemy — zero-variance groups — from opposite ends of the loop. Selection works before generation: pick prompts likely to land in-band. Dynamic sampling works after generation: discard the groups that came back all-same-reward anyway and oversample to refill the batch. You want both, because difficulty estimates are noisy (small-\(k\) SE is large) and the policy moves between selection and rollout, so even a well-targeted batch will produce some dead groups. Selection reduces how many you have to throw away; dynamic sampling guarantees the batch you train on is fully informative.
Dynamic sampling: never train on a zero-variance batch¶
Dynamic sampling, popularized by DAPO, is mechanically simple and statistically important. After generating \(G\) completions per prompt and scoring them, drop every prompt whose group has zero reward variance (all correct or all wrong), then keep generating more prompts until you have collected a full target batch of \(B_{\text{keep}}\) prompts that all have non-zero variance.
The benefit is that every gradient step now operates on a batch where every prompt contributes signal — no dead weight diluting the update, no wasted optimizer step. The cost is throughput: you must oversample. If a fraction \(\rho\) of generated groups survive the filter, you must generate \(B_{\text{keep}}/\rho\) groups to fill the batch — and \(\rho\) shrinks as the policy improves and more prompts saturate to \(p=1\). This is the throughput-vs-statistics tension flagged in the scaling chapter (Scaling RL: Throughput, Load Balancing & The Latest Tricks): dynamic sampling is cheap on an async, oversubscribed generation layer and brutal on a synchronous one, where the extra rollouts serialize against training.
It is worth being precise about what dynamic sampling does and does not do to the estimator, because “we filter the batch” sounds like it should bias something. Dropping a zero-variance group is exactly gradient-neutral: that group’s contribution was already the zero vector, so removing it changes only the denominator you average over — the direction of the update is untouched, and the loss stops being diluted. The bias enters through the refill: the prompts you generate to replace the discards are, by construction, drawn conditional on being informative, so you are optimizing a difficulty-reweighted objective — expected reward under a prompt distribution tilted toward mid-difficulty — rather than uniform expected reward over the pool. That is almost always what you want, but it means your training reward curve is not comparable across runs with different filters, and it is why you must evaluate on a fixed, unfiltered held-out set (Building Eval Harnesses) rather than reading progress off the training reward.
Difficulty-targeted selection is the throughput rescue for dynamic sampling: by feeding the generator prompts that are already likely to be in-band, you raise the survival fraction \(\rho\), so you oversample less to fill the batch. The two are complementary — selection raises \(\rho\), dynamic sampling guarantees correctness when \(\rho<1\).
Where these knobs live in the real libraries. You will rarely write the filter loop yourself. In veRL (veRL: HybridFlow & The Single-Controller Architecture) the DAPO recipe turns dynamic sampling on with an algorithm.filter_groups block (enable it, name the metric it filters on — the per-group accuracy — and cap how many generation batches it may consume before giving up), together with a data.gen_batch_size set larger than data.train_batch_size: that ratio is the oversampling factor \(1/\rho\) made into a config field, and the cap is what stops a saturated pool from spinning forever. OpenRLHF exposes an equivalent group-filtering flag on its GRPO/REINFORCE++ path. TRL’s GRPOConfig (TRL: HuggingFace’s RL Library) gives you the group and clipping knobs (num_generations = \(G\), epsilon/epsilon_high, scale_rewards) but leaves prompt selection to you — the natural hook is a custom Sampler over the training Dataset that reads the difficulty sidecar and yields the in-band task_ids, which is precisely the select_candidates function below. Field names drift between releases; read the config dataclass in the version you install. At the other end of the scale ladder, the capstone implements the whole filter inline in a single-GPU loop, because at 100M parameters generation is cheap enough that oversampling costs seconds (Post-Training: SFT, DPO, and Narrow RLVR (GRPO) That Works at 100M).
Worked example: the oversampling tax, with and without targeting
Suppose you want a training batch of \(B_{\text{keep}} = 256\) informative prompts, \(G = 8\) samples each.
Naive uniform sampling (mid-training). Your pool’s pass-rate distribution, under the current policy, is roughly: 40% of prompts at \(p\approx0.95\) (nearly mastered), 25% at \(p\approx0.05\) (nearly impossible), 35% spread in \([0.2,0.8]\). What survives the zero-variance filter? A group at \(p=0.95\) comes back all-correct with probability \(0.95^8 \approx 0.66\), i.e. it is dead 66% of the time, surviving only 34%. A group at \(p=0.05\) is dead \(0.95^8\approx0.66\) of the time too (all-wrong), surviving 34%. The in-band prompts survive far more often: \(1 - 0.5^8 - 0.5^8 \approx 0.99\) at the center, falling to \(1 - 0.8^8 - 0.2^8 \approx 0.83\) at the band edge \(p=0.8\) — take \(\approx 0.83\) as a conservative average across the band. Survival fraction:
To fill 256 you must generate \(256 / 0.51 \approx 502\) groups — roughly 2× the rollout compute thrown away as zero-variance, \(502 \times 8 \approx 4016\) completions for 2048 kept.
Difficulty-targeted selection. Now you pre-select prompts whose online EMA sits in \([0.2,0.8]\). Even accounting for estimate noise and policy drift (so realized in-band fraction is, say, 80% rather than 100%), survival climbs to roughly
Now you generate \(256/0.73 \approx 351\) groups — you have cut the oversampling tax from \(\sim\)2× down to \(\sim\)1.37×, saving about 30% of generation compute for the same informative batch. On a run where generation is 75% of wall-clock, that is a \(\sim\)20% end-to-end speedup, for free, from data-side bookkeeping.
Prompt curriculum: walking the policy up the difficulty ladder¶
Difficulty-targeted selection keeps you near \(p=0.5\) at each step. A curriculum is the trajectory of that band over the whole run. Three flavors, increasingly automatic:
Static / staged curriculum¶
Sort prompts into difficulty tiers offline and present them in stages: easy first, then medium, then hard, switching tiers on a schedule or when an aggregate pass-rate threshold is hit. This is the RL analogue of pretraining curriculum (Data Mixing, Domain Weighting & Curriculum). It is simple and sometimes helps warm-start, but it is brittle: the offline tiers decay (the master-variable problem), and a hard stage switch can destabilize the policy.
Automatic / online curriculum¶
Let the curriculum emerge from online difficulty targeting. Because difficulty is re-estimated every step and you always select for the band, the contents of the band naturally drift from easy to hard as the policy masters the easier material — the curriculum is an emergent property, not a hand-authored schedule. This is the modern default: you do not write a curriculum, you write a selection rule and the curriculum is its trajectory.
Self-paced / regret-based curriculum¶
A more aggressive variant (rooted in the unsupervised-environment-design and automatic-curriculum-learning literature) selects prompts by learning progress or regret: prioritize tasks where the policy is improving fastest, or where there is the largest gap between achievable and current performance. Practically, score a task by the recent change in its pass-rate EMA (a prompt whose \(\bar p\) is climbing is where learning is happening) and up-weight it. This pushes compute exactly to the frontier of competence. It is powerful but adds estimator complexity and can be unstable if the progress signal is noisy.
The figure is the whole idea: each tier rises and saturates; the band (the prompts currently near \(p^\star\)) slides from easy through medium to hard. A good online curriculum keeps the training batch riding the diagonal where the tiers cross \(p^\star\).
Replay buffers: reusing rollouts you already paid for¶
Generation is the dominant cost (Scaling RL: Throughput, Load Balancing & The Latest Tricks). A rollout you used once and discarded is money burned. Replay reuses past rollouts — but RL-for-LLMs is on-policy by nature (the policy gradient is an expectation under the current policy), so naive replay is off-policy and biased. Replay in this setting comes in two distinct flavors that solve different problems.
Prompt-level replay (revisiting tasks)¶
The cheap, safe kind: replay which prompts to attempt, not the old completions. A prioritized prompt buffer stores tasks with a priority equal to their current informativeness (e.g. \(|\bar p_t - p^\star|^{-1}\), or recent learning progress) and re-samples high-priority prompts more often — this is exactly the prioritized-experience-replay idea (Schaul et al.) applied at the task level rather than the transition level. The completions are always freshly generated under the current policy, so it stays on-policy. This is just difficulty-targeted selection with persistence and is essentially free.
Trajectory-level replay (reusing completions)¶
The expensive, dangerous kind: store the actual completions and their per-token log-probs \(\pi_{\text{old}}(a_t\mid s_t)\), and reuse them for a few extra gradient steps. Because they were generated under an older policy, you must importance-correct with the PPO ratio and rely on clipping to bound the bias (Policy Gradients & PPO for Language Models; Advantage Estimation, KL Control & Stability Tricks):
This is the mechanism behind asynchronous / off-policy RL (the generator runs ahead of the trainer; rollouts are 1–4 steps stale by the time they are consumed — Prime-RL, Async RL & Decentralized Training). The replay “buffer” here is shallow — a few steps of staleness, not a DQN-style million-transition reservoir — because the importance weights blow up and the clipped gradient goes to zero once \(\pi_\theta\) has drifted too far from \(\pi_{\text{old}}\). Staleness is the half-life of a stored trajectory. Beyond a few steps the IS weights are so far from 1 that clipping zeroes the contribution, so the trajectory is dead weight. A practical buffer evicts trajectories older than a staleness bound \(\tau_{\max}\) (e.g. 2–4 policy versions) and tracks the fraction of tokens being clipped as a health metric — if most tokens are clipped, your buffer is too stale and you are training on noise.
A third, increasingly important pattern is the experience / success buffer for agentic RL: store successful trajectories (especially for hard, rarely-solved tasks) and replay them as on-policy-ish positive examples or as SFT-style anchors, to keep the policy from forgetting a hard-won capability. This blurs into expert iteration / rejection-sampling fine-tuning (the policy generates, you keep the winners, you train on them) and is a robust way to bank progress on sparse-reward agentic tasks (Agentic & Multi-Turn RL).
Practitioner tip: keep three buffers, not one
In a mature RL stack you typically maintain (1) a prioritized prompt buffer (which tasks to attempt next — on-policy, free, the curriculum), (2) a shallow staleness buffer of recent completions with stored log-probs for async overlap (off-policy, importance-corrected, half-life of a few steps), and (3) a success/hard-case buffer of banked winning trajectories for anti-forgetting on sparse tasks. They are different objects with different correctness constraints — do not conflate them. The first is about throughput and signal, the second about latency overlap, the third about retention.
Code: a dynamic-sampling + difficulty-bucketing rollout loop¶
Here is a self-contained, heavily-commented loop that ties the chapter together: difficulty-targeted selection via Thompson sampling, generation, zero-variance filtering with oversampling (dynamic sampling), online difficulty (Beta posterior) updates, and a prioritized prompt buffer. The rollout engine and trainer are stubbed so the data logic is in focus; in a real system engine.generate calls vLLM/SGLang and trainer.step calls your GRPO update.
import numpy as np
from dataclasses import dataclass, field
rng = np.random.default_rng(0)
# ---------------------------------------------------------------------------
# Task state: a Beta(s+1, f+1) posterior over the *current-policy* pass rate.
# We decay old counts so the posterior tracks the moving policy (not lifetime).
# ---------------------------------------------------------------------------
@dataclass
class TaskState:
task_id: int
true_p: float # SIMULATION ONLY: the latent pass rate
s: float = 1.0 # decayed success pseudo-count (+1 prior)
f: float = 1.0 # decayed failure pseudo-count (+1 prior)
n_groups: int = 0 # how many groups we've spent here (priority/age)
def posterior_mean(self):
return self.s / (self.s + self.f)
def sample_p(self):
# Thompson draw: sample a plausible pass rate from the posterior.
return rng.beta(self.s, self.f)
def update(self, successes, G, decay=0.9):
# Decay then add this group's evidence. Decay makes the posterior
# forget stale (old-policy) observations so it tracks current p.
self.s = decay * self.s + successes
self.f = decay * self.f + (G - successes)
self.n_groups += 1
# ---------------------------------------------------------------------------
# Stubs standing in for the real rollout engine and trainer.
# ---------------------------------------------------------------------------
class FakeEngine:
"""Simulates generating G samples for a task; returns #successes ~ Binomial.
A real engine returns text completions; the checker turns them into r in {0,1}.
We also let true_p drift UP a touch each time a task is trained on, to mimic
the policy mastering material (the non-stationarity the EMA/Beta must track)."""
def rollout(self, task: TaskState, G: int) -> int:
succ = int(rng.binomial(G, task.true_p))
return succ
def learn_drift(self, task: TaskState, lr=0.02):
# Mastering nudges pass rate toward 1; harder material drifts slower.
task.true_p = min(0.999, task.true_p + lr * (1.0 - task.true_p))
# ---------------------------------------------------------------------------
# Difficulty-targeted selection by Thompson sampling toward a target pass rate.
# ---------------------------------------------------------------------------
def select_candidates(tasks, n_select, target_p=0.5):
scored = []
for t in tasks:
p_tilde = t.sample_p() # explore via posterior uncertainty
score = -abs(p_tilde - target_p) # prefer tasks near the target band
scored.append((score, t))
scored.sort(key=lambda x: x[0], reverse=True)
return [t for _, t in scored[:n_select]]
# ---------------------------------------------------------------------------
# One RL step: select -> generate -> DYNAMIC SAMPLING filter -> update -> train.
# Dynamic sampling: keep only non-zero-variance groups; oversample to refill.
# ---------------------------------------------------------------------------
def rl_step(tasks, engine, B_keep=64, G=8, target_p=0.5,
oversample_cap=6, buckets=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0)):
kept = [] # (task, successes) that have signal
generated_groups = 0
rounds = 0
while len(kept) < B_keep and rounds < oversample_cap:
rounds += 1
# Oversample a generous candidate set so we can refill after filtering.
need = B_keep - len(kept)
cands = select_candidates(tasks, n_select=2 * need, target_p=target_p)
for t in cands:
successes = engine.rollout(t, G)
generated_groups += 1
t.update(successes, G) # online difficulty (Beta) update
# DYNAMIC SAMPLING: drop zero-variance groups (all pass / all fail).
if 0 < successes < G:
kept.append((t, successes))
if len(kept) >= B_keep:
break
# --- "Train" on the kept (informative) groups: here we just record stats
# and apply the simulated learning drift so difficulty is non-stationary.
bucket_counts = np.zeros(len(buckets) - 1, dtype=int)
for t, successes in kept:
engine.learn_drift(t) # policy improves -> p drifts up
p_hat = successes / G
b = min(np.searchsorted(buckets, p_hat, side="right") - 1, len(buckets) - 2)
bucket_counts[max(b, 0)] += 1
survival = len(kept) / max(generated_groups, 1)
return {
"kept": len(kept),
"generated_groups": generated_groups,
"survival_rho": survival, # what fraction survived the filter
"oversample_factor": generated_groups / max(len(kept), 1),
"bucket_counts": bucket_counts, # distribution of kept difficulties
}
# ---------------------------------------------------------------------------
# Run it. Watch survival rho recover toward 1 (selection feeds in-band prompts)
# and the kept-difficulty histogram concentrate near the target band.
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# A pool spanning the full difficulty range, incl. the dead tails we must avoid.
pool = [TaskState(i, true_p=rng.uniform(0.02, 0.98)) for i in range(4000)]
# OFFLINE PASS: k rollouts per task, used to (a) seed each Beta posterior with
# real pseudo-counts and (b) prune the dead tails. Do NOT skip this. Without
# it every posterior starts at the uninformative Beta(1,1), Thompson draws are
# pure noise, and selection is no better than uniform sampling -- for this pool
# the uniform baseline is E[1 - p^8 - (1-p)^8] ~= 0.81, which is essentially
# what an unseeded run scores. The offline pass is what buys the edge.
K_OFFLINE = 8
for t in pool:
succ = int(rng.binomial(K_OFFLINE, t.true_p)) # a real run calls the engine
t.s += succ
t.f += K_OFFLINE - succ
n_raw = len(pool)
pool = [t for t in pool if 0 < (t.s - 1.0) < K_OFFLINE] # drop 0/k and k/k
print(f"offline pass: kept {len(pool)}/{n_raw} tasks "
f"({100 * (1 - len(pool) / n_raw):.0f}% pruned as dead tails)")
for step in range(8):
stats = rl_step(pool, FakeEngine(), B_keep=64, G=8, target_p=0.5)
print(f"step {step:2d} | rho={stats['survival_rho']:.2f} "
f"| oversample={stats['oversample_factor']:.2f}x "
f"| buckets[0.0-1.0]={stats['bucket_counts']}")
Three behaviors to watch when you run this. First, the survival fraction \(\rho\) sits around \(0.9\) (oversampling factor \(\approx1.1\times\)) against a uniform-sampling baseline of \(\mathbb{E}[1 - p^8 - (1-p)^8] \approx 0.81\) for this pool — that is the oversampling-tax saving from the worked example, made mechanical. Second, the kept-difficulty histogram concentrates in the middle buckets (typically \(\approx 80\%\) of kept groups in buckets 1–3): even though the pool spans \([0.02, 0.98]\), the batch you actually train on is the informative middle, by construction. (Read buckets 1–3 together: at \(G=8\) the middle bucket \([0.4, 0.6)\) contains only the single realizable value \(\hat p = 0.5\), so its count is structurally low — a discretization artifact, not a dip in the band.) Third, as learn_drift pushes mastered tasks toward \(p=1\), the Beta posteriors follow, those tasks stop being selected, and harder tasks rotate into the band — the emergent curriculum, in code.
There is an honest caveat worth measuring yourself. Swap the Thompson draw for the deterministic posterior mean (Exercise 5’s select_band_greedy) and \(\rho\) climbs to \(\approx 0.97\). Thompson sampling pays an exploration tax, and the tax is worse the larger the candidate pool: picking the top 4% of 3200 tasks by a sampled pass rate selects partly on posterior noise, so some genuinely-easy tasks ride a lucky draw into the batch. That exploration is not wasted — it is what re-checks stale estimates as the policy drifts, which greedy selection never does — but the trade is real. The practical compromise is to restrict Thompson to a shortlist (greedy-filter to a few hundred plausible tasks, then sample within it), or to widen the offline \(k\) so the posteriors are sharp enough that the draws are not noise.
# Bolt-on: a prioritized PROMPT buffer (PER at the task level). Priority = how
# close a task is to the target band, with a small age bonus so we revisit
# under-sampled tasks. This is the on-policy, FREE kind of replay.
def prompt_priority(t: TaskState, target_p=0.5, age_w=0.05):
closeness = 1.0 / (abs(t.posterior_mean() - target_p) + 0.05) # near band -> high
uncertainty = (t.s * t.f) / ((t.s + t.f) ** 2 * (t.s + t.f + 1)) # Beta variance
return closeness + age_w * uncertainty # exploit band + explore uncertain tasks
def sample_from_buffer(tasks, n, target_p=0.5, temperature=1.0):
pr = np.array([prompt_priority(t, target_p) for t in tasks])
probs = (pr ** (1.0 / temperature))
probs /= probs.sum()
idx = rng.choice(len(tasks), size=n, replace=False, p=probs)
return [tasks[i] for i in idx]
The prioritized prompt buffer is the persistent, sampling-without-replacement-per-step version of the Thompson selector: priority rewards proximity to the band (exploit) plus posterior variance (explore under-sampled tasks). Note this buffer stores tasks, never old completions — it is strictly on-policy and therefore free of importance-weighting concerns, unlike the staleness buffer discussed earlier.
Putting it together: the data-side knobs that move sample efficiency¶
Step back. The levers in this chapter form a pipeline, and each one removes a different category of wasted rollout:
Each numbered stage is a multiplier on sample efficiency, and they compound: decontamination protects the validity of everything downstream; tail-pruning removes the prompts that can never contribute; targeting raises survival \(\rho\) so dynamic sampling oversamples less; dynamic sampling guarantees the gradient is never diluted by dead groups; and online difficulty closes the loop so the whole thing tracks the policy as it improves. A run with all six tuned can be several times more sample-efficient — in rollouts-per-unit-improvement — than the same algorithm reading a static shuffled .jsonl, with no change to the loss function at all.
Interview Corner
Q: You’re running GRPO on a math corpus and notice your training reward climbs for 200 steps then plateaus, while GPU utilization stays high and the loss is non-zero but tiny. What’s happening, and how do you diagnose and fix it on the data side — without touching the RL algorithm?
A: This is the classic difficulty-saturation failure. Early on, many prompts sit near \(p=0.5\) where reward variance \(p(1-p)\) — and thus the policy-gradient signal — is maximal. As the policy improves, those prompts drift toward \(p=1\); their groups increasingly come back all-correct, contribute zero advantage variance, and the effective batch (the prompts that actually carry gradient) shrinks even though every GPU is busy generating. Utilization stays high because you’re still rolling out; the loss is tiny because most groups are near-zero-variance.
To diagnose, I’d log the per-group pass-rate histogram and the fraction of zero-variance groups per step; the smoking gun is that fraction climbing over training. The fix is entirely data-side: (1) turn on dynamic sampling so you only train on \(0<\hat p<1\) groups and oversample to refill — this immediately restores a fully-informative batch; (2) maintain an online difficulty estimate (EMA or decayed Beta posterior) and target selection toward the \(p\approx0.5\) band, so you stop spending rollouts on mastered prompts and the emergent curriculum slides toward harder material; (3) if survival fraction \(\rho\) is dropping, that confirms saturation and also tells you the oversampling tax you’re paying. If the pool itself is exhausted of in-band prompts (everything is now easy), that’s a corpus problem — mine the current policy’s failures or add harder synthetic prompts to extend the curriculum. Crucially none of this changes the GRPO objective; it changes which prompts the objective sees.
Key Takeaways¶
Key Takeaways
- Difficulty is the master variable. For a binary verifiable reward, gradient signal per prompt scales as \(p(1-p)\) — exactly so for the unnormalized Dr. GRPO/DAPO advantage, as \(\sqrt{p(1-p)}\) if you keep GRPO’s std division — peaking at pass rate \(p=0.5\) and vanishing at both ends, so a prompt’s current pass rate decides its worth.
- Pass rate is non-stationary. It’s a property of the prompt and the current policy; a static difficulty label decays as the policy learns. Estimate difficulty online (EMA or a decayed Beta–Bernoulli posterior), never once-and-forever.
- Construction and QC dominate validity. Verifiable checkers, eval decontamination, and near-duplicate removal matter more in RL than SFT because RL adversarially exploits any reward defect; prune the \(0/k\) and \(k/k\) tails before training to delete prompts that can never contribute.
- Difficulty-targeted online selection (greedy-to-target or Thompson sampling on the Beta posterior) keeps realized groups near a \(p\approx0.5\) band, raising the dynamic-sampling survival fraction \(\rho\) and cutting the oversampling tax — often a ~20–30% end-to-end win, free.
- Dynamic sampling drops zero-variance groups and oversamples to refill, guaranteeing every gradient step trains on signal — cheap on an async/oversubscribed generator, brutal on a synchronous one.
- Curriculum is emergent, not authored: with online targeting, the band’s contents drift from easy to hard automatically as the policy masters material; regret/learning-progress weighting pushes compute to the competence frontier.
- Three buffers, three jobs: a prioritized prompt buffer (on-policy, free, the curriculum); a shallow staleness buffer of completions with stored log-probs for async overlap (off-policy, importance-corrected, half-life of a few steps); and a success/hard-case buffer for anti-forgetting on sparse agentic tasks.
- Trajectory replay has a half-life: stored completions go stale as the policy drifts; importance weights blow up and clipping zeroes their gradient, so evict beyond a staleness bound and monitor the clipped-token fraction.
- Use the real tools:
math-verifyfor math checking (never a regex),datatrove/datasketchfor pool dedup and decontamination,verifiers-style environments as the packaging unit, and veRL’salgorithm.filter_groups+ oversizeddata.gen_batch_size(or OpenRLHF’s equivalent) for dynamic sampling — TRL gives you \(G\) and the clipping knobs but leaves prompt selection to a custom sampler.
State of the Art & Resources (2026)
Data-side RL has converged on a recognizable stack: verifiable corpora with aggressive decontamination, offline difficulty bucketing to prune dead tails, online difficulty tracking, difficulty-targeted selection toward a ~50%-pass band, and DAPO-style dynamic sampling as default practice. The frontier is automatic curriculum (learning-progress/regret weighting) and principled off-policy replay for fully-async, agentic RL.
Foundational work
- Schaul et al., Prioritized Experience Replay (2016) — the priority-and-importance-weight machinery that prompt/trajectory buffers adapt to the LLM setting.
- Graves et al., Automated Curriculum Learning for Neural Networks (2017) — learning-progress signals for ordering tasks; the conceptual root of regret-based curricula.
Recent advances (2024–2026)
- Yu et al., DAPO: An Open-Source LLM Reinforcement Learning System at Scale (2025) — dynamic sampling (drop zero-variance groups, oversample to refill) as standard RLVR practice.
- DeepSeek-AI, DeepSeek-R1 (2025) and DeepSeekMath — the GRPO/RLVR baseline whose advantage structure makes pass rate the master variable.
- Kimi Team, Kimi k1.5 (2025) — curriculum and prioritized sampling at scale alongside partial-rollout infrastructure.
- Kong et al., Rethinking the Sampling Criteria in RL for LLM Reasoning: A Competence-Difficulty Alignment Perspective (2025) — CDAS: stable difficulty estimation by aggregating historical performance, selecting problems matched to current competence — the difficulty-targeted-selection idea of this chapter, made rigorous.
- Zhang et al., Improving Sampling Efficiency in RLVR through Adaptive Rollout and Response Reuse (2025) — AR3PO: allocate more rollouts to hard prompts and reuse past correct responses to fight the vanishing-advantage problem, reporting up to ~4x rollout-cost reduction.
Open-source & tools
- verl-project/verl and OpenRLHF/OpenRLHF — production RL frameworks with dynamic sampling, dataset/curriculum hooks, and async replay.
- PrimeIntellect-ai/prime-rl — fully-async agentic RL where shallow staleness replay and importance correction are first-class.
- willccbb/verifiers — RLVR environments (prompt set + parser + reward rubric, single- or multi-turn) behind one interface; the 2026 packaging unit for the prompt pools this chapter manages.
- huggingface/Math-Verify —
parse+verify, SymPy-backed answer equivalence. The checker quality that your difficulty estimates are only as good as. - huggingface/datatrove and ekzhu/datasketch — MinHash/LSH dedup and n-gram decontamination for the prompt pool, before a single rollout is spent.
Further reading¶
- Schaul, Quan, Antonoglou & Silver, Prioritized Experience Replay — priority and importance weighting; the buffer mechanics this chapter ports to prompts and trajectories.
- Graves et al., Automated Curriculum Learning for Neural Networks — learning-progress-driven task ordering; the basis for regret/progress curricula.
- Yu et al., DAPO: An Open-Source LLM Reinforcement Learning System at Scale — dynamic sampling and the zero-variance-group filter in an RLVR context.
- DeepSeek-AI, DeepSeekMath (GRPO) and DeepSeek-R1 — the group-relative advantage that makes pass-rate variance \(p(1-p)\) the signal-to-noise of a prompt.
- Kimi Team, Kimi k1.5: Scaling Reinforcement Learning with LLMs — curriculum, prioritized sampling, and long-context RL data infrastructure.
- Noukhovitch et al., Asynchronous RLHF: Faster and More Efficient Off-Policy RL for Language Models — the staleness/importance-weight regime that bounds trajectory-replay reuse.
- Schulman et al., Proximal Policy Optimization Algorithms — the clipped importance-sampling objective that makes shallow off-policy replay safe.
- Dennis et al., Emergent Complexity and Zero-shot Transfer via Unsupervised Environment Design (PAIRED) — regret-based automatic curriculum, the theoretical cousin of difficulty-targeted selection.
Exercises¶
1. (Conceptual.) Under GRPO with a binary verifiable reward, a group of \(G\) completions for a single prompt comes back either all-correct (\(\hat p = 1\)) or all-wrong (\(\hat p = 0\)). Explain, from the advantage formula in this chapter, why both of these extremes contribute exactly zero to the policy gradient — even though one looks like “success” and the other like “failure.” Why does this make pass rate, rather than absolute correctness, the quantity you must control?
Solution
GRPO centers each completion’s reward by the group mean before forming the advantage: $$ A_i = \frac{r_i - \bar r}{\operatorname{std}® + \varepsilon}, \qquad \bar r = \hat p. $$ If every \(r_i\) is identical (all \(1\) when \(\hat p = 1\), all \(0\) when \(\hat p = 0\)), then \(\bar r = r_i\) for every \(i\), so the numerator \(r_i - \bar r = 0\) for all \(i\). Every advantage is zero, and the per-prompt gradient contribution \(\sum_i A_i \nabla_\theta \log \pi_\theta\) is therefore exactly zero. The group is “dead” regardless of which extreme it sits at: the all-correct prompt is dead because the model already mastered it, the all-wrong prompt is dead because the model cannot get any purchase on it — but the mechanism is identical, namely zero reward variance inside the group.
The signal GRPO exploits is relative — the contrast between completions of the same prompt — not the absolute reward level. An all-correct group and an all-wrong group both have zero within-group contrast, hence zero gradient. This is why the master variable is the pass rate \(p\): the usable signal scales as the Bernoulli variance \(p(1-p)\), which vanishes at both \(p=0\) and \(p=1\) and peaks at \(p=0.5\). You cannot make a prompt informative by making the model “more correct”; you make it informative by keeping its pass rate away from the two dead ends.
2. (Quantitative.) The chapter states a prompt at \(p = 0.5\) yields roughly \(2.8\times\) the signal of a prompt at \(p = 0.9\). Compute the exact ratio of gradient signal (proportional to \(p(1-p)\)) between a prompt at \(p = 0.5\) and one at \(p = 0.75\). If a fixed rollout budget must be split between the two difficulty levels to maximize total signal per rollout, which should get more of it, and by roughly what factor per prompt?
Solution
The per-prompt signal is proportional to the Bernoulli variance \(p(1-p)\).
- At \(p = 0.5\): \(\;0.5 \times 0.5 = 0.25\).
- At \(p = 0.75\): \(\;0.75 \times 0.25 = 0.1875\).
Ratio: $$ \frac{0.25}{0.1875} = \frac{4}{3} \approx 1.33. $$ So a \(p=0.5\) prompt delivers about \(1.33\times\) the gradient signal per rollout of a \(p=0.75\) prompt. Per prompt, the \(p=0.5\) prompt is worth about a third more, so it should receive proportionally more of the budget. (Contrast this with the \(p=0.9\) case: \(0.9 \times 0.1 = 0.09\), and \(0.25 / 0.09 \approx 2.78\) — matching the chapter’s \(\sim 2.8\times\). The signal cliff steepens fast as you leave the center of the band, which is exactly why targeting a band around \(0.5\) pays off.)
3. (Quantitative.) You want a training batch of \(B_{\text{keep}} = 256\) informative (non-zero-variance) prompts with \(G = 8\) samples each. Under the current policy your candidate pool is, in effect, two populations: half the prompts at \(p = 0.5\) and half at \(p = 0.9\). A group at pass rate \(p\) survives the dynamic-sampling filter (has non-zero variance) with probability \(1 - p^G - (1-p)^G\). Compute the survival fraction \(\rho\) for uniform sampling from this pool, the number of groups you must generate to fill the batch, and the fraction of generated completions thrown away.
Solution
Per-population survival probability, \(G = 8\):
- At \(p = 0.5\): \(\;1 - 0.5^8 - 0.5^8 = 1 - 2\cdot\frac{1}{256} = 1 - 0.0078 = 0.9922.\)
- At \(p = 0.9\): \(\;0.9^8 = 0.4305\), and \(0.1^8 \approx 10^{-8}\) (negligible), so survival \(= 1 - 0.4305 - 0 \approx 0.5695.\)
Uniform mix (half and half): $$ \rho = 0.5\,(0.9922) + 0.5\,(0.5695) = 0.4961 + 0.2848 = 0.7809. $$
Groups to generate to fill \(B_{\text{keep}} = 256\): $$ \frac{256}{0.7809} \approx 327.8 \;\Rightarrow\; 328 \text{ groups}. $$
Completions generated: \(328 \times 8 = 2624\); completions kept: \(256 \times 8 = 2048\). Fraction thrown away: $$ 1 - \frac{2048}{2624} = 1 - 0.7809 = 0.219 \approx 22\%. $$ So even with a fairly benign pool, uniform sampling wastes about a fifth of your generation on zero-variance groups — the “oversampling tax.” Difficulty-targeted selection attacks this by raising \(\rho\): if selection could feed only the \(p=0.5\) population, \(\rho \to 0.9922\) and the tax nearly vanishes.
4. (Quantitative.) A task is tracked with the decayed Beta-Bernoulli posterior from the chapter’s code: start at the prior \(s = 1,\ f = 1\), use \(G = 8\) and \(\text{decay} = 0.9\), with update \(s \leftarrow 0.9\,s + \text{successes}\), \(f \leftarrow 0.9\,f + (G - \text{successes})\). The policy is improving, so the task returns \(3\) successes in its first group and then \(6\) successes in its second. Compute the posterior mean pass rate after each group, and explain in one line why the decay factor is what lets this estimate chase a non-stationary pass rate.
Solution
Posterior mean is \(\dfrac{s}{s+f}\).
After group 1 (3 successes, 5 failures): $$ s = 0.9(1) + 3 = 3.9, \qquad f = 0.9(1) + 5 = 5.9. $$ $$ \bar p_1 = \frac{3.9}{3.9 + 5.9} = \frac{3.9}{9.8} = 0.398. $$
After group 2 (6 successes, 2 failures): $$ s = 0.9(3.9) + 6 = 3.51 + 6 = 9.51, \qquad f = 0.9(5.9) + 2 = 5.31 + 2 = 7.31. $$ $$ \bar p_2 = \frac{9.51}{9.51 + 7.31} = \frac{9.51}{16.82} = 0.565. $$
The estimate moved from \(0.40\) up toward \(0.57\) in a single step, following the policy as it mastered the task. The decay factor is what makes this possible: multiplying the old counts by \(0.9\) before adding new evidence down-weights stale (old-policy) observations, so the posterior forgets the past at a controlled rate instead of averaging over the task’s entire lifetime. Without decay, \(s\) and \(f\) accumulate forever and the mean becomes rigidly anchored to early, now-obsolete observations — the “stale difficulty label” pitfall the chapter warns about.
5. (Implementation.) The chapter’s select_candidates uses Thompson sampling and scores every task by closeness to a single point \(p^\star\). Implement an alternative selector, select_band_greedy, that (a) uses the deterministic posterior mean (posterior_mean()) rather than a Thompson draw, (b) selects for a band \([p_{\text{lo}}, p_{\text{hi}}]\) — preferring the center of the band but ranking any in-band task above any out-of-band task — and returns the top n_select. Then write expected_survival(tasks, G) that estimates the dynamic-sampling survival fraction \(\rho\) a candidate set would yield, using each task’s posterior mean and the formula \(1 - p^G - (1-p)^G\). Keep the chapter’s TaskState interface.
Solution
import numpy as np
def select_band_greedy(tasks, n_select, p_lo=0.2, p_hi=0.8, target_p=0.5):
"""Deterministic band selector. In-band tasks are scored by closeness to
the band center (target_p) and always outrank out-of-band tasks; out-of-band
tasks are ranked by distance to the nearest band edge so the batch degrades
gracefully when the band is under-populated."""
scored = []
for t in tasks:
p = t.posterior_mean()
if p_lo <= p <= p_hi:
# In band: 0 (best) at the edge-worst, more negative penalty as we
# move off-center. Offset by +1 so every in-band task beats any
# out-of-band task (whose score is <= 0 below).
score = 1.0 - abs(p - target_p)
else:
# Out of band: strictly negative, penalized by distance past the edge.
edge = p_lo if p < p_lo else p_hi
score = -abs(p - edge)
scored.append((score, t))
scored.sort(key=lambda x: x[0], reverse=True)
return [t for _, t in scored[:n_select]]
def expected_survival(tasks, G=8):
"""Estimated dynamic-sampling survival fraction rho for a candidate set:
the mean over tasks of P(group has non-zero variance) = 1 - p^G - (1-p)^G,
using each task's posterior-mean pass rate as the estimate of p."""
p = np.array([t.posterior_mean() for t in tasks])
surv = 1.0 - p**G - (1.0 - p)**G
return float(surv.mean())
Usage mirrors the chapter’s loop: swap select_band_greedy in for select_candidates, and call expected_survival(selected, G) as a health metric before generating. In-band tasks always sort above out-of-band ones because their scores lie in \([1 - 0.3,\, 1] = [0.7, 1]\) (the \(|p - target_p|\) term never exceeds the half-band width \(0.3\), so in-band scores stay above \(0.7\)), while every out-of-band score is \(\le 0\). expected_survival gives you a cheap pre-generation estimate of \(\rho\): if it is near \(1\) your selector is feeding a clean in-band batch and the oversampling tax is small; if it is dropping over training, that is the saturation signal from the Interview Corner — the band is emptying and you need harder prompts.
6. (Conceptual + quantitative.) The chapter says prompt-level replay is “free” while trajectory-level replay has a “half-life.” Consider one stored token whose old policy assigned probability \(\pi_{\text{old}} = 0.1\); after a few trainer steps the current policy has drifted to \(\pi_\theta = 0.6\) on that token, and its advantage is positive (\(A_t > 0\)). Using the clipped objective with \(\epsilon = 0.2\), compute the importance ratio and show that this token’s contribution to the gradient is zeroed. Then explain why the prompt buffer never hits this problem.
Solution
The importance ratio is $$ \rho_t = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\text{old}}(a_t \mid s_t)} = \frac{0.6}{0.1} = 6.0. $$ The clipped objective is $$ L = \min!\big(\rho_t A_t,\; \operatorname{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\,A_t\big), \qquad \operatorname{clip}(6.0,\, 0.8,\, 1.2) = 1.2. $$ With \(A_t > 0\): the unclipped branch is \(6.0\,A_t\) and the clipped branch is \(1.2\,A_t\); since \(A_t > 0\), \(\min(6.0\,A_t,\, 1.2\,A_t) = 1.2\,A_t\). The clipped branch wins, and \(1.2\,A_t\) is a constant in \(\theta\) (the ratio has been clipped to a flat \(1.2\)), so \(\nabla_\theta L = 0\) for this token. The trajectory has drifted far enough that clipping flattens its gradient to zero — it is now dead weight in the buffer. This is the half-life: once \(\pi_\theta\) has moved a few steps away from \(\pi_{\text{old}}\), the ratios leave the \([1-\epsilon, 1+\epsilon]\) window, clipping zeroes the contribution, and the stored completion is worthless. Hence you evict beyond a staleness bound \(\tau_{\max}\) and monitor the clipped-token fraction as a buffer-health metric.
The prompt buffer never suffers this because it stores only which task to attempt, never the old completions. Every completion is freshly generated under the current policy \(\pi_\theta\), so there is no \(\pi_{\text{old}}\), no importance ratio, and no clipping — the samples are exactly on-policy by construction. That is why prompt-level replay is “free” (a persistence layer over difficulty-targeted selection) while trajectory replay is off-policy and bounded by a short half-life.