CS336 // FIELD MAP
← field map
ASSIGNMENT 5 · ALIGNMENT66 pts · v1.0.0 · Spring 2025

Alignment and Reasoning RL

Stanford CS336 · Spring 2025 · assignment 5 of 5 · walkthrough of the official handout, everything linked

Lectures behind it: L15 Alignment: SFT and RLHF, L16 Alignment: RL 1, L17 Alignment: RL 2 · supporting: L10 Inference (vLLM, paged KV cache), L12 Evaluation (MATH, MMLU, AlpacaEval, judges)

TL;DR — You take Qwen 2.5 Math 1.5B Base, measure it zero-shot on MATH with the R1-Zero prompt and a verified answer-checking reward, then climb a ladder of three ways to make it better at reasoning: supervised finetuning on DeepSeek-R1 traces, expert iteration on its own correct rollouts, and GRPO with verified rewards. Eleven small tensor functions carry all of it — tokenize-and-mask, entropy, log-probs, two normalizers, three loss variants and two microbatch steps — and every one is snapshot-tested. Everything after that is experiments: a learning-rate sweep, four ablations that re-derive the Dr. GRPO paper by hand, an off-policy sweep, and a leaderboard capped at 4 hours on 2 H100s. Budget roughly 52 H100-hours of compute across the graded runs; the code itself is a couple of days, the runs are the week. An entirely optional supplement adds instruction tuning and DPO on Llama 3.1 8B.

A5 is where the course stops building the model and starts building the behaviour. Every prior assignment optimized cross-entropy against a fixed corpus; here cross-entropy is explicitly abandoned as the objective, because the thing you want — a model that solves competition math problems and formats the answer where a grader can find it — is not a likelihood. You get a downstream metric instead, and you optimize it directly. The course also draws a hard boundary around what counts as yours: you may call vLLM to generate and HuggingFace to run forward passes, but the RL is yours. No Trainer, no TRL, no PPO library. That constraint is the point — GRPO is about two hundred lines once you strip the framework, and the assignment exists so you find that out by writing them.

We expect you to build most of the RL related components from scratch. You may use tools like vLLM to generate text from language models (§3.1). In addition, you may use HuggingFace Transformers to load the Qwen 2.5 Math 1.5B model and tokenizer and run forward passes (§4.1), but you may not use any of the training utilities (e.g., the Trainer class).§1, handout

Map of the assignment

Twenty-six graded problems across nine sections, 66 points by my count (the handout never prints a total, and grpo_off_policy carries no point value of its own — it is an implementation step the sweep that follows depends on). Eleven of them are unit-tested; the other fifteen are scripts, runs and write-ups. Only two test files are mandatory.

You are only expected to pass the tests in tests/test_sft.py and tests/test_grpo.py—the rest of the tests are for the non-mandatory parts of the assignment.§1, on what tests/*.py contains
§ Problem Pts Deliverable Graded by Lecture
3.2math_baseline4script + write-upwrite-upL16
4.2tokenize_prompt_and_output2codetest_sft.pyL15
4.2compute_entropy1codetest_sft.pyL16
4.2get_response_log_probs2codetest_sft.pyL15
4.2masked_normalize1codetest_sft.pyL16
4.2sft_microbatch_train_step3codetest_sft.pyL15
4.2log_generations1code— (untested)L16
4.3sft_experiment2run + write-upwrite-up · 2 H100 hrsL15
5expert_iteration_experiment2run + write-upwrite-up · 6 H100 hrsL16
7.2compute_group_normalized_rewards2codetest_grpo.pyL16
7.2compute_naive_policy_gradient_loss1codetest_grpo.pyL16
7.2compute_grpo_clip_loss2codetest_grpo.pyL16
7.2compute_policy_gradient_loss1codetest_grpo.pyL16
7.2masked_mean1codetest_grpo.pyL17
7.2grpo_microbatch_train_step3codetest_grpo.pyL17
7.2grpo_train_loop5code + write-upwrite-upL17
8grpo_learning_rate2write-upwrite-up · 6 H100 hrsL17
8grpo_baselines2write-upwrite-up · 2 H100 hrsL16
8think_about_length_normalization1write-upwrite-upL16
8grpo_length_normalization2write-upwrite-up · 2 H100 hrsL16
8grpo_group_standard_deviation2write-upwrite-up · 2 H100 hrsL16
8grpo_off_policy—code— (untested)L17
8grpo_off_policy_sweep4write-upwrite-up · 12 H100 hrsL17
8grpo_off_policy_clip_ablation2code + write-upwrite-up · 2 H100 hrsL17
8grpo_prompt_ablation2write-upwrite-up · 2 H100 hrsL16
9leaderboard16run + screenshot + PRleaderboard PR · 16 H100 hrsL17

The optional supplement adds sixteen more problems and 61 more points on a different model and a different set of benchmarks; it has its own map further down. The two halves share only tests/adapters.py.

Setup: environment, data, tests

Environment. Same uv pattern as the rest of the course, with one wrinkle the README calls out in so many words: FlashAttention-2 is weird. It needs torch present at build time, so you install everything else first and then install it. Run uv sync --no-install-package flash-attn, then uv sync. The pyproject.toml pins vllm==0.7.2 and flash-attn==2.7.4.post1 exactly, sets no-build-isolation-package = ["flash-attn"], and pulls alpaca-eval from a course fork on a branch called forward_kwargs_to_vllm — do not swap in the PyPI release, it cannot pass sampling kwargs through to a local vLLM annotator.

Models and data. Nothing is downloaded. Everything lives on the Together cluster and the handout asks you explicitly not to re-download it — a 70B judge model replicated across a class is a lot of disk.

WhatCluster pathUsed by
Qwen 2.5 Math 1.5B Base/data/a5-alignment/models/Qwen2.5-Math-1.5Beverything in the required assignment
Llama 3.1 8B Base/data/a5-alignment/models/Llama-3.1-8Boptional supplement — the model you tune
Llama 3.3 70B Instruct/data/a5-alignment/models/Llama-3.3-70B-Instructoptional supplement — AlpacaEval and safety judge
MATH train split/data/a5-alignment/MATH/train.jsonlexpert iteration, GRPO, leaderboard
MATH validation split (5K)/data/a5-alignment/MATH/validation.jsonlevery reported number
R1 reasoning traces/data/a5-alignment/MATH/sft.jsonlSFT — {"prompt": str, "response": str} per line

MATH itself is Hendrycks et al. 2021 and is not publicly redistributable — a copyright claim pulled it. If you are following along outside Stanford the handout names four substitutes: Countdown (the synthetic arithmetic task TinyZero made popular), GSM8K (easier, best for debugging the pipeline), Tulu 3 SFT Math (synthetic, some labels are wrong), and DART-Math's comparison table of math SFT sets. If your substitute has long-form solutions rather than short labels, run the answer column through Math-Verify to get something a string comparison can grade.

The test harness. Same adapter pattern as A1–A4: tests/adapters.py holds sixteen run_* stubs that all raise NotImplementedError, and the tests import those, never your code. You write your implementation wherever you like inside cs336_alignment/ and wire it up in one line per adapter. Run everything with uv run pytest, or one problem at a time with uv run pytest -k test_masked_mean.

Almost every A5 test is a snapshot test, which is different from A1's reference-implementation tests and changes how you debug. conftest.py defines a numpy_snapshot fixture that loads a stored .npz from tests/_snapshots/ named after the test function and compares your returned arrays to it at rtol=1e-4, atol=1e-2 (pass --snapshot-exact to demand bit equality). Two consequences worth internalizing before you start. First, the tolerance is loose enough that a subtly wrong reduction can still pass — check shapes yourself. Second, the snapshot compares a dict of named arrays, so your return keys must match the reference exactly: "log_probs" and "token_entropy", not "logprobs".

Three fixtures are worth knowing about before the first failure. The tokenizer and model fixtures both load from the hard-coded cluster path /data/a5-alignment/models/Qwen2.5-Math-1.5B — so test_tokenize_prompt_and_output and test_get_response_log_probs simply cannot run off-cluster without editing conftest. The reward_fn fixture is not the real grader: it is a deterministic SHA-256 hash of the response string mapped into {0.0, 0.1, …, 0.9}, chosen so the group-normalization test has spread in its rewards without needing sympy. And group_size is 4 over 8 rollouts, so the group test really does exercise two groups rather than degenerating to one.

2 · Reasoning with language models

Two things change relative to every prior assignment, and the handout is unusually explicit about both. You stop using your own model, because a 100M-parameter transformer trained on TinyStories has no mathematical reasoning to elicit — you cannot RL your way to competition math from a model that never had it. And you stop using cross-entropy as the yardstick, because the whole premise now is the gap between likelihood and downstream behaviour. Qwen 2.5 Math 1.5B Base (Yang et al. 2024) was continually pretrained on synthetic math, which is exactly why it has latent ability that post-training can surface.

The intellectual arc packed into §2.2 runs: scratchpads (Nye et al. 2021) showed a model can be finetuned to externalize intermediate steps; chain-of-thought prompting (Wei et al. 2023) showed you can get the same for free from a big enough model; STaR turned it into a bootstrap loop — sample chains, keep the ones that reach the right answer, finetune on those, repeat — which is expert iteration with a string-match verifier standing in for a search oracle; and then o1, R1 and Kimi k1.5 replaced the filter-and-finetune step with an actual policy gradient. You will implement that whole ladder in order, which is the best argument for doing §5 before §7 even though GRPO is the headline.

The R1-Zero prompt. Everything in the required assignment uses the prompt in cs336_alignment/prompts/r1_zero.prompt — copy it from the file, not from the PDF, which is exactly why the file exists. It instructs the assistant to think inside <think> </think> and answer inside <answer> </answer>, and it pre-fills the opening <think> so the model starts mid-reasoning. Two tags, two jobs: <answer> gives the grader something to parse, and </answer> gives vLLM a stop string, which is what keeps rollouts from running to 1024 tokens every time.

The handout then undercuts its own choice, and this is worth reading carefully because it sets up the last ablation. Liu et al. 2025 (Dr. GRPO) found that prompting Qwen 2.5 Math with nothing but the bare question starts far higher and takes the R1-Zero prompt 100+ RL steps just to catch up — evidence the model was pretrained on question-answer pairs in roughly that shape. The staff pick R1-Zero anyway, on the grounds that it improves visibly fast and therefore makes a correct implementation obvious within a handful of steps. You verify the claim yourself in grpo_prompt_ablation.

3 · Measuring zero-shot MATH performance

3.1 · vLLM for offline inference. RL is generation-bound: every GRPO step samples hundreds of complete solutions before a single gradient flows. Writing your own sampler (as you did in A1) is fine for a demo and hopeless here, so the assignment hands you vLLM — continuous batching, paged KV cache (Kwon et al. 2023), the whole machine L10 takes apart. The starter snippet in the handout is lifted from vLLM's own offline-inference example: build SamplingParams, build LLM(model=...), call llm.generate(prompts, sampling_params), read output.outputs[0].text.

Sampling parameterValueWhy
temperature1.0fixed for every reported number, baseline through leaderboard
top_p1.0no nucleus truncation — the policy is the policy
max_tokens1024generation budget per solution
min_tokens4added from §5 on — blocks empty strings that NaN the loss
stop["</answer>"]stop at the closing answer tag (from Dr. GRPO's train script)
include_stop_str_in_outputTruethe reward function looks for </answer> — strip it and every reward is 0
gpu_memory_utilization0.85leaves headroom on the vLLM device

That last row is the single most common day-one bug in this assignment. stop strings are excluded from vLLM output by default; r1_zero_reward_fn requires "</answer>" in response. Forget the flag and you get a uniformly zero baseline and conclude the model is broken.

3.2 · The reward function. Math answers cannot be string-compared: the gold label 0.5 and the model's 1/2 are the same answer. The course hands you a parser rather than making you write one — cs336_alignment/drgrpo_grader.py, about a thousand lines lifted from Dr. GRPO, with LaTeX normalization, sympy equivalence and a fast path. The entry point is r1_zero_reward_fn(response, ground_truth), which returns a dict with "format_reward", "answer_reward" and "reward". Read its first line before you run anything: the format gate is the literal substring "</think> <answer>", one space, plus "</answer>" somewhere after. Also note the asymmetry that the write-up asks you to explain — a formatted-but-wrong answer gets format_reward 1.0 and total reward 0.0, deliberately, so a model cannot farm reward by emitting well-formed nonsense.

Problem (math_baseline): 4 points

Deliverable: an evaluation script, plus a three-part write-up on where the base model and the parser each fail.

Part (a) is the script: load validation.jsonl, format each example with the R1-Zero prompt, generate, score, and — the part people skip and regret — serialize examples, generations and per-example scores to disk. Parts (b) and (c) require you to go back and read those generations, and re-running 5,000 rollouts because you only kept the aggregate is a waste of an hour. The handout suggests factoring the work into evaluate_vllm(vllm_model, reward_fn, prompts, eval_sampling_params), and it is worth taking that suggestion literally: you will call this function again in sft_experiment, in the EI loop and inside the GRPO train loop's validation pass.

Part (b) is the real content. Bucket the 5,000 generations three ways — format 1 / answer 1, format 1 / answer 0, format 0 / answer 0 — then read at least ten from each of the failure buckets and argue whether the fault is the model's or the parser's. Both happen. Format-0 is usually the base model genuinely never closing </think>, or drifting into a fresh User: turn, or hitting the 1024-token cap mid-derivation; but it is also sometimes the parser's exact-space requirement rejecting </think><answer> or </think>\n<answer>. Format-1/answer-0 splits between honest arithmetic errors and equivalence the grader misses — an interval written differently, an unsimplified radical, a unit tacked on. The point of the exercise is calibration: your entire RL signal is this function, and you should know its failure modes before you spend 50 H100-hours optimizing against it.

4 · Supervised finetuning for MATH

Algorithm 1 in the handout is four lines and holds no surprises: sample a batch of (question, response) pairs, cross-entropy on the response, step. What makes it interesting is what the responses are. You are not finetuning the model to emit correct answers; you are finetuning it to emit a chain of thought and then an answer, using traces distilled from DeepSeek R1 in MATH/sft.jsonl. Distillation of a reasoning process, not of an answer key.

The handout also explains why SFT and RL are treated as separate phases here rather than composed as they are in practice: at 1.5B parameters the effects do not stack visibly, so chaining them would only add compute and confusion. Two motivations for the ordering survive anyway — SFT needs annotated traces where RL needs only an answer key, and RL can find policies better than anything in the SFT set.

4.1 · HuggingFace mechanics. Load with torch_dtype=torch.bfloat16 and attn_implementation="flash_attention_2", forward with model(input_ids).logits, save with save_pretrained(save_directory=output_dir) under /data/yourusername (checkpoints are large), and save the tokenizer alongside so the directory is self-contained. The section closes with a page on gradient accumulation — divide the loss by gradient_accumulation_steps before backward(), call optimizer.step() and zero_grad() every k microbatches — which matters because both *_microbatch_train_step problems are graded on getting exactly this scaling right.

Problem (tokenize_prompt_and_output): Prompt and output tokenization · 2 points

Deliverable: tokenize prompt and response separately, concatenate, pad, and build a response mask.

This is the boring function that everything else silently depends on, and it is where most of the assignment's off-by-one bugs live. You tokenize prompt and output separately — not the concatenated string — because you need to know where the boundary is; a joint tokenization can merge the last prompt token with the first response token and you lose the split. Then you concatenate, pad to the batch max, and produce three tensors of width max(prompt_and_output_lens) - 1: input_ids is the concatenation with the final token dropped, labels is the same shifted left by one, and response_mask is 1 exactly on the response positions of the labels. That last clause is the trap. The mask indexes labels, not input_ids, so it is shifted too; mask the input positions instead and every loss you compute for the rest of the assignment is off by one token, which is subtle enough to pass a smoke test and wrong enough to ruin a training run. Padding must be masked to 0 as well.

Problem (compute_entropy): Per-token entropy · 1 point

Deliverable: entropy of the next-token distribution at every position, from logits.

One line of math — H(p) = −Σ p(x) log p(x) over the vocabulary — and one instruction that carries the point: do it in a numerically stable way with logsumexp. Naively exponentiating raw logits over a 150K-token vocabulary in bfloat16 overflows. The stable form computes logZ = logsumexp(logits, dim=-1), then H = logZ - sum(softmax(logits) * logits, dim=-1), which never materializes an unnormalized exponential. Input is (batch, seq, vocab), output is (batch, seq) — you reduce the vocabulary axis only. Entropy is not part of any loss; it is the diagnostic you will stare at for the rest of the assignment, because a collapsing entropy curve is how over-confident RL announces itself before the reward curve does.

Problem (get_response_log_probs): Response log-probs (and entropy) · 2 points

Deliverable: per-token conditional log-probabilities from a causal LM, optionally with per-token entropy.

The workhorse. Run the model, take logits, log-softmax over the vocabulary, and gather the entry corresponding to each label — log p(x_t | x_<t) at every position. Return a dict with key "log_probs", plus "token_entropy" when return_token_entropy=True, both shaped (batch, seq). Two notes. The adapter's type annotation says -> torch.Tensor and is simply wrong — the docstring and the test both want a dict; trust the docstring. And read the second half of the docstring carefully: nothing is masked here. Prompt and padding positions come back with real numbers in them, and it is the training loop's job to mask them. People who helpfully zero out the prompt inside this function fail the snapshot and then break the entropy statistics later.

Use torch.gather on the log-softmax rather than indexing with fancy tensors, and be aware that this is the memory peak of a training step: log-softmax over (batch, seq, 150K) in fp32 is larger than the model. When you need old log-probs for off-policy GRPO you call the same function under torch.inference_mode().

Problem (masked_normalize): Masked normalize · 1 point

Deliverable: sum a tensor over a dimension, counting only masked-in elements, then divide by a constant.

Six lines: (tensor * mask).sum(dim) / normalize_constant, with dim=None meaning sum everything. The reason it is a graded problem at all is that it is one half of the length-normalization debate you will run as an experiment in §8 — this is the "sum and divide by a fixed constant" branch, against masked_mean's "divide by the actual token count" branch. Choosing between them changes which tokens get gradient weight, and think_about_length_normalization asks you to predict the difference before measuring it.

Verify the argument order against the adapter, not the PDF. The handout's recommended signature is (tensor, mask, normalize_constant, dim=None); the actual adapter is (tensor, mask, dim=None, normalize_constant=1.0). The handout's later worked example calls it with a keyword constant_normalizer=, which does not exist under either spelling. Keyword-call it as the adapter declares it and the discrepancy is harmless.

Problem (sft_microbatch_train_step): Microbatch train step · 3 points

Deliverable: one forward-and-backward on a microbatch — masked NLL, normalized, scaled for gradient accumulation, backpropagated.

The three points are for getting the scaling right, because everything else is arithmetic. Negate the log-probs, sum them over response tokens only via masked_normalize, divide by gradient_accumulation_steps, call loss.backward() inside the function, and return the scaled scalar plus a metadata dict. Two things people get wrong. They forget the division, which multiplies the effective learning rate by the accumulation count and produces a run that diverges for no visible reason. And they call zero_grad() here — this function must not; accumulation across microbatches is the entire point, and the ten-step test exists precisely to catch it.

That test is worth understanding: test_sft_microbatch_train_step_10_steps calls the step ten times on the same policy_log_probs tensor without ever zeroing, stacks the ten gradients, and snapshots the stack. If your gradients are being cleared, or scaled inconsistently, the stack is wrong even when a single step passes.

Problem (log_generations): Logging generations · 1 point

Deliverable: a function that samples from the current policy on validation prompts and logs what came out.

The cheapest point in the assignment and the one that saves you the most time later. The handout lists six things to log per example: prompt, generated response, ground truth, the reward breakdown (format / answer / total), the average token entropy of the response, and average response length split three ways — overall, correct-only, incorrect-only. That last split is the diagnostic that catches length hacking, the classic RL-on-reasoning failure where the model discovers that rambling correlates with reward and response length climbs while accuracy does not. Build this before sft_experiment, not after, and call it from every loop you write afterwards.

Problem (sft_experiment): Run SFT on the MATH dataset · 2 points · 2 H100 hrs

Deliverable: validation-accuracy curves across five SFT dataset sizes, plus a second run on a correctness-filtered dataset with its size reported.

Part 1: run SFT on {128, 256, 512, 1024} unique examples and on the full set, tuning learning rate and batch size until the full-dataset run clears 15% validation accuracy. Part 2: filter sft.jsonl down to only those examples whose response actually produces the correct answer under r1_zero_reward_fn, train on that, report both the filtered size and the accuracy, and compare. The expected finding is the one STaR is built on — a smaller, verified dataset beats a larger, noisy one — and it is the direct motivation for §5, where you generate the data instead of being given it.

Two GPUs: one holds the policy, one holds a vLLM instance for periodic validation. The handout gives you the plumbing, and you should copy it rather than reinvent it. init_vllm() monkeypatches two things borrowed from TRL — torch.distributed.get_world_size forced to 1 so vLLM will sit on a chosen device, and a memory-profiling assertion disabled because it does not hold in this setup. load_policy_into_vllm_instance() pushes policy.state_dict() straight into llm.llm_engine.model_executor.driver_worker.model_runner.model before every evaluation, so vLLM scores the current weights rather than the checkpoint it booted with. Forgetting that call gives you a validation curve that is flat at the base model's accuracy forever — a genuinely confusing failure, since training loss looks fine. Gradient clipping at 1.0 throughout.

5 · Expert iteration for MATH

The bridge between SFT and RL, and the cheapest way to see that a verifier is worth more than a teacher. Algorithm 2 wraps SFT in an outer loop: freeze a copy of the policy, sample G solutions per question from it, score them, throw away everything with reward 0, finetune on what survives, repeat. It is STaR exactly, and the same idea appears as Cobbe et al. 2021, Language Model Cascades and ReST in the handout's citation list. L16 calls it the RFT baseline in the DeepSeekMath comparison plot, which is the right frame: expert iteration is the strongest thing you can do without a policy gradient, and GRPO has to beat it to justify itself.

Problem (expert_iteration_experiment): Run expert iteration on the MATH dataset · 2 points · 6 H100 hrs

Deliverable: validation curves for at least two rollout counts × two epoch counts, a model above 15% validation accuracy, a two-sentence comparison against SFT and across EI steps, and an entropy-over-training plot.

Run on MATH/train.jsonl with n_ei_steps = 5, sweeping G (rollouts per question) and the number of SFT epochs per EI step. Both knobs trade the same currency. Larger G means more rollouts per question and therefore more surviving correct traces from hard questions — the coverage that makes EI work at all — at linear generation cost. More epochs per step squeezes harder on the traces you already have, at the cost of overfitting the model to its own outputs. The entropy plot is the requested deliverable because it shows the mechanism directly: EI is self-distillation, so entropy falls, and the interesting question is whether it falls faster than accuracy rises. Compare to the entropy curve you get from GRPO later — the handout explicitly asks you to in grpo_off_policy_sweep.

Two operational notes. Pass min_tokens=4 to SamplingParams; without it vLLM will occasionally return an empty string, which becomes an empty response mask, which becomes a division by zero, which becomes a NaN that quietly poisons the run. And keep the stop=["</answer>"] setup from §3 — the handout repeats the instruction here because people drop it when they switch from the eval path to the rollout path. Gradient clipping at 1.0 again.

6 · Primer on policy gradients

No problems in this section — it is four pages of derivation you need before §7 makes sense, based on Spinning Up and Nathan Lambert's RLHF Book. The framing: a causal LM is a categorical stochastic policy, with the text prefix as state and the next token as action; the environment is deterministic (the next state is the old prefix plus the emitted token) and rewards are terminal only — 1 if the finished solution grades correct, 0 otherwise, nothing in between.

From there: the REINFORCE identity ∇J = E[Σ_t ∇ log π(a_t|s_t) · R(τ)], derived in six lines from the log-derivative trick plus the observation that ρ₀, P and R do not depend on θ. Then baselines — subtract any b(s_t) that depends only on state and the estimator stays unbiased, because the expected score function is zero, while the variance drops. Then off-policy: REINFORCE demands fresh rollouts for every single gradient step, which is absurd when generation dominates the cost, so you importance-weight by π_θ(a|s)/π_θold(a|s) and accept the approximation, which is fine as long as the two policies stay close (Degris et al. 2013).

One paragraph in §6.5 is worth memorizing, because it explains why every deliverable in §8 asks for reward curves and not loss curves:

pg_loss is not a loss in the canonical sense—it's not meaningful to report pg_loss on the train or validation set as an evaluation metric, and a good validation pg_loss doesn't indicate that our model is generalizing well. The pg_loss is really just some scalar such that when we call pg_loss.backward(), the gradients we obtain through backprop are the approximate policy gradient.§6.5, "A note on policy gradient 'losses'"

7 · Group Relative Policy Optimization

GRPO's one idea: get your baseline for free by sampling a group. Sample G solutions to the same question, and the mean reward across that group is an estimate of V(s) for that prompt — no learned value network to train, no second set of weights resident on the GPU, no critic that can be wrong. Advantage A⁽ⁱ⁾ = (r⁽ⁱ⁾ − mean(r)) / (std(r) + advantage_eps), per DeepSeekMath and R1. The advantage is constant across every token of a response, which is why it arrives shaped (batch_size, 1) and broadcasts along the sequence.

Algorithm 3 is the loop: for each GRPO step, sample a batch of questions, snapshot π_old ← π, sample G outputs per question, reward them, group-normalize, then take n_train_steps_per_rollout_batch gradient steps on the GRPO-Clip objective. The handout notes in a footnote that this is a special case of DeepSeekMath's GRPO with a verified reward function, no KL term, and no iterative reference or reward model update — the KL term is dropped because in the staff's experiments and in the literature it made no difference to performance while costing a resident reference model's worth of memory.

The clipping is PPO's, and the handout's case analysis is the clearest way to hold it. When A > 0, the objective is min(ratio, 1+ε)·A: you are rewarded for making the token more likely, but the reward saturates once the ratio exceeds 1+ε, so there is no gradient incentive to run away from π_old. When A < 0, symmetric: you push the token down but stop caring below 1−ε. Clipping only matters when π_θ has had a chance to drift from π_old — that is, when you take more than one gradient step per rollout batch.

Problem (compute_group_normalized_rewards): Group normalization · 2 points

Deliverable: score a flat list of rollouts, reshape into groups, normalize within each group, return normalized rewards, raw rewards and metadata.

The bookkeeping is the whole problem. rollout_responses arrives flat, of length rollout_batch_size = n_prompts_per_rollout_batch × group_size, with repeated_ground_truths the same length because each question's answer is repeated group_size times. Reshape to (n_prompts, group_size), subtract the per-group mean, and — if normalize_by_std — divide by the per-group std plus advantage_eps. Flatten back. Get the reshape order wrong (interleaving groups instead of blocking them) and you will normalize across questions instead of within them, which produces a plausible-looking tensor and an advantage signal that is pure noise.

The normalize_by_std flag is the Dr. GRPO ablation baked into the API: Liu et al. 2025 argue that dividing by the group std up-weights questions where the group barely disagreed — the too-easy and too-hard ones, where std is near zero — so they drop the division entirely and use A = r − mean(r). You implement both and test the difference in grpo_group_standard_deviation.

One repo wart to know: the adapter's return annotation reads tuple[torch.Tensor, dict[str, float]] but the docstring and both tests unpack three values — (advantages, raw_rewards, metadata). The annotation is stale; follow the docstring.

Problem (compute_naive_policy_gradient_loss): Naive policy gradient · 1 point

Deliverable: the per-token loss −A_t · log p_θ(o_t | q, o_<t).

A multiply and a negate: -raw_rewards_or_advantages * policy_log_probs, with the (batch, 1) scalar broadcasting over (batch, seq). The single argument name doing double duty is deliberate — pass raw rewards and you get the no_baseline variant, pass group-normalized advantages and you get reinforce_with_baseline. No reduction happens here; you return the full per-token tensor and someone upstream decides how to sum it. The only way to lose the point is to get the sign backwards, and the symptom is unmistakable: reward marches steadily downward.

Problem (compute_grpo_clip_loss): GRPO-Clip loss · 2 points

Deliverable: the per-token clipped surrogate, plus metadata recording which tokens clipped.

Form the ratio in log space — ratio = torch.exp(policy_log_probs - old_log_probs), never a division of probabilities — then -torch.min(ratio * advantages, ratio.clamp(1-cliprange, 1+cliprange) * advantages). Note the negation applies to the whole min, and the min is over the two products, not over the ratios: clipping the ratio first and multiplying afterwards gives a different (wrong) answer whenever the advantage is negative, which is the classic PPO implementation bug and the reason there are two tests here at different clip ranges. The metadata dict should record, per token, whether the clipped branch won — that is your clip fraction, the single best diagnostic for how far off-policy you have drifted.

The two tests, test_compute_grpo_clip_loss_large_cliprange and ..._small_cliprange, exist to catch exactly the sign/order confusion above: with a large clip range the clipping is inert and a broken implementation can still match, so the small-range snapshot is the one that actually discriminates.

Problem (compute_policy_gradient_loss): Policy-gradient wrapper · 1 point

Deliverable: a dispatcher over the three loss types with argument validation.

Trivial to write, and the point is really for the assertions. no_baseline requires raw_rewards and routes to the naive loss; reinforce_with_baseline requires advantages and routes to the same function with a different tensor; grpo_clip requires advantages, old_log_probs and cliprange and routes to the clipped loss. Assert the required arguments are non-None for each branch — the handout says so explicitly, and it pays off in §8 when you are switching loss types from a CLI flag at 2am and would otherwise get a silent None broadcast instead of an error. Merge whatever metadata the underlying routine returns into a single dict so the caller sees one shape regardless of branch.

Problem (masked_mean): Masked mean · 1 point

Deliverable: mean over masked-in elements along a dimension, or over all of them when dim=None.

(tensor * mask).sum(dim) / mask.sum(dim). The counterpart to masked_normalize: this one divides by the actual number of unmasked tokens, so every sequence contributes equally to the batch loss regardless of length, while masked_normalize divides by a fixed constant so longer sequences contribute proportionally more. The handout flags that the sequence-length division is canonical in RL-on-LLM codebases but is not present in the policy-gradient estimator as derived in §6.4 — there is no 1/T factor in equation 21. That observation is the seed of two problems in §8. Beyond loss reduction you will reuse this constantly for statistics: mean response-token entropy, clip fraction over response tokens, mean response length.

Problem (grpo_microbatch_train_step): Microbatch train step · 3 points

Deliverable: per-token loss → masked mean over the sequence → mean over the batch → divide by accumulation steps → backward().

The GRPO twin of sft_microbatch_train_step, and the same three points for the same reason. The reduction order is specified and matters: reduce the sequence dimension with masked_mean(..., dim=-1) to get one scalar per rollout, then average over the batch, then scale. Averaging over everything in one shot is not the same thing when responses have different lengths, and it will pass casual inspection while producing a different gradient. Call loss.backward() inside, do not zero gradients, return the scaled loss plus merged metadata.

The docstring mentions a constant_normalize_factor in its Args list "as in Dr. GRPO" which does not appear in the signature. It is vestigial — the signature is the contract. When you get to grpo_length_normalization you will want a variant of this function that reduces with masked_normalize instead; write it as a separate path in your own code rather than changing the adapter's shape, or the snapshots will stop matching.

Problem (grpo_train_loop): GRPO train loop · 5 points

Deliverable: a complete train loop, a validation-reward-vs-steps plot showing improvement, and a few example rollouts sampled at different points in training.

Assemble Algorithm 3 from the parts. The handout gives starter hyperparameters that are known to work, which turns this from a search into an integration exercise — if it does not improve with these numbers, the bug is in your code, not your configuration.

HyperparameterDefaultNote
n_grpo_steps200outer rollout-and-train iterations
learning_rate1e-5the thing you sweep first
advantage_eps1e-6guards the std division
rollout_batch_size256total rollouts per GRPO step
group_size8→ 32 prompts per rollout batch
sampling_temperature1.0same as eval
sampling_min_tokens4no empty responses
sampling_max_tokens1024same as eval
epochs_per_rollout_batch1on-policy
train_batch_size256= rollout_batch_size ⇒ on-policy
gradient_accumulation_steps128⇒ microbatch of 2, fits an H100
gpu_memory_utilization0.85vLLM device
loss_typereinforce_with_baselineclipping is inert on-policy
use_std_normalizationTruethe DeepSeekMath form
optimizerAdamW(lr, weight_decay=0.0, betas=(0.9, 0.95))note zero weight decay
grad clip1.0as in SFT and EI

The derived quantities are worth writing as asserts, exactly as the handout does, because every off-policy configuration in §8 has to keep them consistent: micro_train_batch_size = train_batch_size // gradient_accumulation_steps; n_prompts_per_rollout_batch = rollout_batch_size // group_size; n_microbatches_per_rollout_batch = rollout_batch_size // micro_train_batch_size; and train_batch_size >= group_size. When you change epochs_per_rollout_batch later you must change gradient_accumulation_steps in step or memory usage moves under you and you will misattribute an OOM to the algorithm.

The two tips that separate a run that trains from a run that looks like it trains:

With our implementation of the losses, GRPO-Clip should only be used when off-policy (since it requires the old log-probabilities). … You should not differentiate with respect to the old log-probabilities.§7.2, train-loop tips

Both are quiet failures. On-policy, π_θ and π_old are identical so the ratio is exactly 1, clipping never binds, and GRPO-Clip is just the naive loss with extra steps — but if you compute old_log_probs with gradients attached, the ratio's denominator gets a gradient too, the two cancel, and the update dies without an error. Compute old log-probs once per rollout batch under torch.inference_mode() and reuse them across epochs; recomputing per epoch is pure waste.

Validate every 5–10 steps on at least 1024 validation examples — the handout is explicit that fewer is too noisy to compare hyperparameters against. Log loss, gradient norm, token entropy, clip fraction (off-policy only), and train rewards broken into total / format / answer. L17 gives the ordered suspect list when a run trains but does not improve: rewards all tied inside each group so every advantage is zero; gradients flowing through π_old; or a reward function with an unintended shortcut. Watch reward, not loss.

8 · GRPO experiments

Nine problems, 19 points, roughly 30 H100-hours. Each run takes 2 GPUs (one vLLM, one policy). The handout gives you permission up front to kill a configuration early if it has clearly diverged before 200 steps — the hour estimates assume you exercise it. Each experiment fixes its winner before the next one starts, so the sequence is a greedy coordinate descent through the design space rather than a grid.

Problem (grpo_learning_rate): Tune the learning rate · 2 points · 6 H100 hrs

Deliverable: validation reward curves across several learning rates, a model above 25% validation accuracy, and two sentences on trends in your other logged metrics.

Start from the defaults and sweep around 1e-5. The 25% bar is the first checkpoint that GRPO is genuinely beating both SFT and EI, which were only asked for 15%. Note divergence explicitly where it happens rather than silently dropping the run — divergence at a given learning rate is a data point. This is where logging gradient norm earns its keep: a run that is about to diverge shows it in the gradient norm well before the reward curve turns over, which is what lets you kill it at step 40 instead of 200. Whatever wins here is fixed for every remaining experiment.

Problem (grpo_baselines): Effect of baselining · 2 points · 2 H100 hrs

Deliverable: validation reward curves for no_baseline versus reinforce_with_baseline, plus two sentences on other metrics.

The cleanest demonstration in the assignment of a piece of theory you derived by hand. §6.5 proved that subtracting a state-dependent baseline leaves the estimator unbiased while cutting variance; here you watch what that buys in practice. Stay on-policy, keep use_std_normalization = True, change only the loss type. With binary rewards the effect is stark: without a baseline, every token of every correct solution gets a positive push and every token of every wrong one gets nothing, so on an easy question where all eight rollouts succeed you take a large gradient step that teaches nothing. With the group baseline, that question contributes exactly zero. Look at gradient norm alongside reward — that is where the variance reduction is visible.

Problem (think_about_length_normalization): Think about length normalization · 1 point

Deliverable: pros and cons of masked_mean versus masked_normalize, reasoned out before running anything, with settings where one looks better.

The handout hands you a worked numerical example from the RLHF Book: a batch of two responses, one 4 tokens and one 7, all ratios 1 and all advantages 2. Under masked_mean both sequences yield 2.0 and the per-token gradients come out 0.25 for the short sequence and 0.1429 for the long one. Under masked_normalize with a constant of 7 they yield 1.1429 and 2.0, and every token in the batch gets an identical 0.1429 gradient. That is the whole trade in one table.

ReductionLoss (4-tok, 7-tok)Per-token gradEffect
masked_mean(dim=1)2.0, 2.00.2500 / 0.1429each response weighs the same; tokens in short responses weigh more
masked_normalize(dim=1, const=7)1.1429, 2.00.1429 / 0.1429each token weighs the same; long responses weigh more

The argument for masked_mean is that it is per-sequence unbiased and keeps a single rambling rollout from dominating the batch. The argument against is that it introduces a 1/T factor the policy-gradient derivation in §6.4 never had, and that this factor systematically discounts each token of a long response — which, when the advantage is negative, means wrong long answers are penalized less per token than wrong short ones. That is the length-bias Dr. GRPO and DAPO both identify as a driver of response-length inflation. The constant-denominator form removes the bias at the cost of letting a single long rollout carry more of the batch gradient.

Problem (grpo_length_normalization): Effect of length normalization · 2 points · 2 H100 hrs

Deliverable: end-to-end runs with each reduction, validation answer-reward curves, commentary on any other metric with a visible trend.

Now measure what you just predicted. The handout's hint points at stability metrics, gradient norm in particular — the constant-denominator form does not damp long sequences, so its gradient norm is both larger and noisier, and whether that costs you depends on your clip value and learning rate. Track mean response length too, split by correct and incorrect: the mechanism you argued about in the previous problem shows up there before it shows up in reward. Fix the winner for everything downstream.

Problem (grpo_group_standard_deviation): Effect of standard deviation normalization · 2 points · 2 H100 hrs

Deliverable: use_std_normalization True versus False, validation reward curves, commentary.

The second Dr. GRPO ablation, and a flag you already implemented in compute_group_normalized_rewards. The claim under test: dividing by the group std up-weights questions where the group agreed, because a small denominator makes small reward differences into large advantages. Those low-variance groups are exactly the questions that are too easy (all eight correct) or too hard (all eight wrong) — precisely the ones with the least to teach — so the standard normalization spends gradient where there is no signal, amplified by advantage_eps being all that stands between you and a division by zero. Watch gradient norm and per-group advantage magnitudes.

Problem (grpo_off_policy): Implement off-policy GRPO

Deliverable: the off-policy training path. No points of its own; it gates the two problems after it.

Three changes to the loop. Take multiple epochs of gradient steps per rollout batch, with rollout_batch_size, epochs_per_rollout_batch and train_batch_size jointly determining how many optimizer updates each batch of rollouts funds. Compute response log-probs from the policy once, after the rollout phase and before the inner gradient loop, and keep them as old_log_probs — under torch.inference_mode(), so no graph is built and no gradient can leak into the ratio's denominator. And switch loss_type to grpo_clip, which is the first point in the assignment where clipping does anything at all. Depending on how you structured grpo_train_loop you may already have all of this.

Problem (grpo_off_policy_sweep): Off-policy GRPO hyperparameter sweep · 4 points · 12 H100 hrs

Deliverable: a broad sweep at <50 steps then a focused sweep at 200, an experiment log justifying the ranges you chose, comparison against the on-policy run in both step-count and wall-clock, and commentary including entropy and response length.

The biggest single compute item outside the leaderboard, and the one where the two-stage protocol matters: cheap broad sweep first to find the shape of the landscape, then a focused sweep at full length. Fix rollout_batch_size = 256 and vary epochs_per_rollout_batch and train_batch_size; the ratio of the two controls how far off-policy you drift before the next rollout. Remember to move gradient_accumulation_steps in lockstep to hold the microbatch size — and therefore memory — constant, or you will be comparing configurations that also differ in how much they fit on the GPU.

The wall-clock plot is the point of the whole exercise. Off-policy will usually look worse per gradient step and better per hour, because rollouts dominate the cost and you are amortizing each batch of them over more updates. That trade is why anyone runs off-policy at all. The handout also asks you to compare the entropy curve here against the one from expert_iteration_experiment — two different mechanisms for the same collapse, worth a paragraph.

Problem (grpo_off_policy_clip_ablation): Off-policy GRPO-Clip ablation · 2 points · 2 H100 hrs

Deliverable: implement an unclipped loss type, run it at your best off-policy settings, compare against GRPO-Clip.

Add a "GRPO-No-Clip" loss that is the bare importance-weighted per-token loss −(π_θ/π_old)·A_t — the objective from equation 27 with no min and no clamp. Everything else held fixed. This tests whether the clipping does real work or is cargo-culted from PPO, and it is the most direct payoff from the case analysis in §7.1. Watch gradient norm above all: without the clip, tokens whose probability has moved a long way from π_old contribute unboundedly large ratios, so the unclipped run should be the noisier one, and how much noise costs you depends on how far off-policy your winning configuration went. Entropy and response length are the other two metrics the handout asks about.

Problem (grpo_prompt_ablation): Prompt ablation · 2 points · 2 H100 hrs

Deliverable: validation answer-reward curves for the R1-Zero prompt versus the question-only prompt, with an explanation of the difference.

Swap r1_zero.prompt for question_only.prompt — which is literally {question} — for both training and validation, and swap the reward function to question_only_reward_fn to match. The second swap is not optional: the R1-Zero reward requires </think> <answer> tags that the question-only prompt never asks for, so keeping the old grader would score a uniform zero. question_only_reward_fn instead pulls the answer out of a \boxed{} via extract_answer.

This is the experiment that cashes the warning from §3. The expected result, from Liu et al. 2025, is that question-only starts far higher because Qwen 2.5 Math was pretrained on data in exactly that shape — the R1-Zero prompt begins by fighting the model's prior and spends its first hundred steps recovering ground the bare prompt had for free. The honest reading is that a large part of "RL improvement" on this setup is the model relearning a format it already knew, and noticing that is the real deliverable. Compare entropy, response length and gradient norm between the two.

9 · Leaderboard: GRPO on MATH

Problem (leaderboard): Leaderboard · 16 points · 16 H100 hrs

Deliverable: the best validation accuracy you can reach within 4 hours of training on 2 H100s, plus a screenshot of accuracy against wall-clock time whose x-axis ends at ≤4 hours.

Sixteen points, a quarter of the assignment, and the only open-ended problem. The clock is the constraint, so this is as much a systems problem as an RL one — which is deliberate, and the handout says so: in the naive two-GPU layout at least one device is idle at all times, either the policy waiting on rollouts or vLLM waiting on a gradient step. It suggests lower precision for rollouts or training, torch.compile, and explicitly frees you from the one-vLLM-one-policy split, encouraging better parallelism. Everything you learned in L10 about inference throughput applies directly.

You are not allowed to use any other data or do SFT on reasoning chains from stronger models, etc. You must report validation accuracy on the entire validation set (all 5K examples), using the sampling hyperparameters given above (temperature 1.0, max tokens 1024). You are allowed to filter the train set, or design a curriculum over the data, as you desire. You must use the R1-Zero prompt for validation, and during validation, you must use exactly the r1_zero_reward_fn reward function provided in the starter code.§9, Dataset

Read that carefully: the algorithm is unconstrained, the evaluation is nailed down, and the training-side freedom is real — you may filter the train set or build a curriculum, and you may use a different reward function during training so long as validation uses the official one. The handout also volunteers that omitting the KL penalty cost nothing in the staff's experiments and in the literature (Liu et al. 2025, GRPO-Zero) while saving a resident reference model, though you are welcome to try KL or other regularizers. The one hard model constraint: Qwen 2.5 Math 1.5B Base, or something you derived from it.

For ideas the handout points at four production RL codebases: veRL, TRL, torchtune and oat. Reading them for systems ideas is encouraged; importing their trainers is not.

Submission is a pull request against assignment5-alignment-leaderboard adding a row to the Markdown table, sorted by decreasing accuracy, with your final whole-validation-set accuracy, a link to a learning curve that clearly shows wall-clock time (upload to ./images or link a public W&B report), and a description of what you did. Top 3 get a prize; the staff reproduce the top 5 runs and re-rank them, so save a snapshot of your best configuration. For calibration, the Spring 2025 board topped out at 68.75%, with the top ten between roughly 62% and 69% — against a zero-shot baseline in the single digits and a 25% bar for a passing GRPO run.

Optional supplement · Instruction tuning and RLHF

We provide, as an entirely optional supplement to the required course materials, an assignment on training language models to follow instructions and aligning language models to pairwise preference judgments.§1, safety/RLHF supplement

A parallel assignment on the same repo: different model (Llama 3.1 8B Base), different benchmarks, different failure modes. Where the required half optimizes a verifiable reward on a narrow task, this half builds a generalist assistant and confronts the fact that "helpful" and "harmless" have no verifier at all — you end up with an LLM judge and a preference dataset instead. This is where L15's second half lands as code, and where L12's material on judges, arenas and length bias becomes concrete.

Version note. The Spring 2025 copy of this supplement is no longer served from the repo. The live file is the Spring 2026 edition on the main branch, which is the version described below; it runs on Modal rather than the Together cluster and quotes B200 rather than H100 hours. Its three test files — test_data.py, test_dpo.py, test_metrics.py — and their five adapters exist unchanged on master alongside the 2025 handout, so you can do the supplement from either branch. The prompts moved: master keeps them in cs336_alignment/prompts/, main keeps them in cs336_alignment/prompts_safety/, separate from the RL prompts. The Alpaca SFT template is byte-identical in both locations, which matters because test_per_instance_dpo_loss asserts an exact numeric value computed against it.

§ Problem Pts Deliverable Graded by Lecture
3.1mmlu_baseline4code + write-uptest_metrics.pyL12
3.2gsm8k_baseline4code + write-uptest_metrics.pyL12
3.3alpaca_eval_baseline4script + write-upwrite-upL12
3.4sst_baseline4script + write-upwrite-upL15
4.1look_at_sft4write-upwrite-upL15
4.2.1data_loading3codetest_data.pyL15
4.2.2sft_script4codewrite-upL15
4.2.2sft6run + write-upwrite-up · 3 B200 hrsL15
5.1mmlu_sft4write-upwrite-upL12
5.2gsm8k_sft4write-upwrite-upL12
5.3alpaca_eval_sft4write-upwrite-upL12
5.4sst_sft4write-upwrite-upL15
5.5red_teaming4write-upwrite-upL15
6.2look_at_hh2code + write-upwrite-upL15
6.3dpo_loss2codetest_dpo.pyL15
6.4dpo_training4run + write-upwrite-up · 1 B200 hrL15

Prompting setup. Every zero-shot evaluation nests two templates: a task prompt formatted with the example, then inserted into the {instruction} slot of zero_shot_system_prompt.prompt. That system prompt frames the interaction as alternating # Query: / # Answer: blocks inside Markdown fences, which gives you a stop string: when the model emits # Query: it has started a new turn and you cut. Generation is greedy throughout — temperature 0.0, top-p 1.0 — for all four benchmarks. The ↪ character in the PDF's prompt blocks marks a visual line continuation, not a newline; copy the prompt files, not the PDF.

Problem (mmlu_baseline): Zero-shot MMLU baseline · 4 points

Deliverable: a parser, an evaluation script, and four short write-ups — parse-failure count, throughput, accuracy, and an error analysis over ten wrong predictions.

The parsing problem is the interesting half. A base model asked a multiple-choice question in free-form text will answer with the letter, or the option's text, or a paraphrase of the option, or a short essay that arrives at the answer. Rather than write a parser for all of that, mmlu_zero_shot.prompt constrains the output format up front — "Respond with a single sentence of the form 'The correct answer is _'" — and your run_parse_mmlu_response extracts the letter, returning None when it cannot. That None path is graded: part (c) asks how many generations fail to parse and what they look like. This is the same evaluation-is-a-design-problem point L12 makes at length, met head-on.

Problem (gsm8k_baseline): Zero-shot GSM8K baseline · 4 points

Deliverable: a numeric parser, an evaluation script, and the same four write-ups.

The prompt is two lines — the question and Answer: — and the parsing rule is one line: take the last number in the output. "She sold 15 clips." parses to 15. Simple and deliberately imperfect; it will misfire on a model that restates a figure after its conclusion, and noticing that in the error analysis is part of the point. The supplement flags explicitly that this is a different prompt and a different parser from the RL half's question_only.prompt, which asks for boxed answers — do not reuse question_only_reward_fn here.

Problem (alpaca_eval_baseline): Zero-shot AlpacaEval baseline · 4 points

Deliverable: a generation script emitting AlpacaEval-shaped JSON, a throughput estimate, the winrate and length-controlled winrate, and an error analysis on ten dispreferred examples.

The first benchmark with no ground truth at all. You generate a response to each instruction, and a judge — Llama 3.3 70B Instruct, configured in scripts/alpaca_eval_vllm_llama3_3_70b_fn — decides whether it prefers yours or GPT-4 Turbo's. The output format is load-bearing: a JSON array, one object per instruction, with keys instruction, output, generator (constant across entries) and dataset (carried through from the source). Then alpaca_eval --model_outputs … --reference_outputs data/alpaca_eval/alpaca_eval_gpt4_turbo.json --annotators_config scripts/alpaca_eval_vllm_llama3_3_70b_fn --base-dir .

Report both winrate and length-controlled winrate, and note the gap — L12's point about AlpacaEval's length bias is that judges reliably prefer longer answers, and the LC metric exists to correct for it. Part (d) sends you into annotations_seed0_configs.json to read ten losses and asks whether you agree with the judge, which is the honest version of "is this benchmark measuring what I think".

Problem (sst_baseline): Zero-shot SimpleSafetyTests baseline · 4 points

Deliverable: a generation script emitting JSON-lines with prompts_final and output, throughput, the safe-output proportion, and an error analysis on ten unsafe generations.

A hundred prompts (data/simple_safety_tests/simple_safety_tests.csv) covering critical safety risks, judged by the same 70B model via the provided scripts/evaluate_safety.py. The supplement is candid about why the judge is a model: it is an imperfect proxy for human evaluation, but far less emotionally taxing than asking people to read hundreds of harmful completions. Expect a base model to score poorly — a model with no instruction tuning and no refusal training simply continues the text, and there is nothing in pretraining that makes "how do I evade taxes" a question to decline. That gap is the motivation for everything after it.

Problem (look_at_sft): Inspect instruction tuning data · 4 points

Deliverable: 2–4 sentences on ten random training examples — which classic NLP tasks show up implicitly, and how good the prompts and responses actually are.

Four points for reading ten examples, which tells you how seriously the course takes the habit. The mix is UltraChat-200K plus SafetyTunedLlamas, flattened to single-turn {prompt, response} pairs. You will find QA, summarization, rewriting, classification and open generation all present without ever being labelled as such — instruction tuning is the observation that these are one task in disguise — alongside a certain amount of model-generated filler, since UltraChat is synthetic. Forming an opinion about quality here is what lets you interpret the benchmark movements in §5 rather than just reporting them.

Problem (data_loading): Implement data loading · 3 points

Deliverable: a packed PyTorch Dataset and a batching function over it.

Part (a): format each pair with alpaca_sft.prompt, tokenize, concatenate all documents into one stream with the Llama end-of-text token between them, then chop into consecutive non-overlapping chunks of seq_length, dropping the final short chunk. __getitem__ returns {"input_ids", "labels"}, both (seq_length,), with labels the inputs shifted by one — which means the chunk you slice for a given index needs seq_length + 1 tokens available. Packing rather than padding is a throughput decision: no wasted positions, at the cost of documents bleeding across chunk boundaries. Part (b) is the batcher, with shuffle, one pass = one epoch.

The test compares your output against a stored fixture element by element, so ordering and boundary handling must match exactly, and it separately asserts that shuffle=True produces a different sequence than shuffle=False — shuffle the documents before concatenation, as the docstring says, not the chunks afterward.

Problem (sft_script): Training script: instruction tuning · 4 points

Deliverable: a finetuning script for Llama 3.1 8B on the instruction data.

Configurable model and optimizer hyperparameters, gradient accumulation, periodic train and validation logging. You may adapt an earlier training script; you may not use Trainer. Mechanically identical to the SFT section of the required assignment — bfloat16, FlashAttention-2, model(input_ids).logits, cross-entropy, accumulate, step, save_pretrained — at 8B instead of 1.5B, so memory pressure is the difference you will feel.

Problem (sft): Instruction tuning · 6 points · 3 B200 hrs

Deliverable: your training setup, the final validation loss, a learning curve, and the serialized model and tokenizer.

One epoch over the data. Save the tokenizer with the model — DPO reloads both, twice.

SettingValue
context length512
total batch size32 sequences per gradient step
learning rate2e-5
schedulecosine decay, linear warmup over 3% of total steps
weight decay0.1
gradient clipping1.0
epochs1

Problem (mmlu_sft): Evaluate SFT on MMLU · 4 points

Deliverable: throughput versus baseline, accuracy versus baseline, and an error analysis over ten wrong predictions comparing the two models qualitatively.

One rule governs all four §5 evaluations and is easy to miss: drop the zero-shot system prompt and format everything with the Alpaca template you trained on instead. For MMLU and GSM8K that means nesting twice — format the task prompt with the example, then put that formatted string into the Alpaca {instruction} slot. Evaluate a finetuned model with the wrong wrapper and you measure the mismatch, not the model. The throughput question is not filler either: an instruction-tuned model stops rambling and starts terminating, so examples-per-second usually goes up sharply, and the reason is worth a sentence.

Problem (gsm8k_sft): Evaluate SFT on GSM8K · 4 points

Deliverable: throughput comparison, accuracy comparison, and an error analysis over ten wrong predictions.

Same protocol, same nesting: gsm8k_zero_shot.prompt inside alpaca_sft.prompt. The number to watch is whether reasoning accuracy actually improves. Instruction tuning on general chat data is not math training, and the interesting outcomes are either a modest gain from better answer formatting (the last-number parser now finds a number where it used to find prose) or a small loss — the first hint of the alignment tax that dpo_training part (d) asks about directly.

Problem (alpaca_eval_sft): Evaluate SFT on AlpacaEval · 4 points

Deliverable: throughput, winrate and LC winrate against the baseline, and ten dispreferred examples analysed.

This is where instruction tuning should show its largest single gain, because the benchmark measures exactly what you trained for. Format instructions with alpaca_sft.prompt and run the same annotator config. In the annotations file, entries with "preference" equal to 1.0 are the ones where the judge preferred GPT-4 Turbo — that is your dispreferred set. Compare winrate and LC winrate movement separately: if raw winrate jumps much more than LC winrate, a good part of your gain is that the tuned model writes longer.

Problem (sst_sft): Evaluate SFT on SimpleSafetyTests · 4 points

Deliverable: throughput, safe-output proportion against the baseline, and ten unsafe examples analysed.

The SafetyTunedLlamas portion of the training mix is the reason this number should move, and it is a small fraction of the data — the demonstration is that a modest amount of refusal data goes a long way, which is the empirical claim that paper makes. Re-run scripts/evaluate_safety.py and, as before, judge the judge: part (c) asks where you disagree with it.

Problem (red_teaming): Red-team the instruction-tuned model · 4 points

Deliverable: three misuse categories beyond those already discussed, plus three documented red-teaming attempts on your own model.

Interactive rather than scripted, and the write-up wants method and honesty, not a scoreboard: what you tried, how long you tried it, which strategies you used, whether you succeeded. The framing follows Ganguli et al. 2022 — red-teaming is an evaluation method for understanding failure, not a compliance checkbox. Doing it on a model you finetuned yourself an hour earlier is the point; the hundred-prompt benchmark you just ran gives an aggregate number, and this gives you the texture of how a refusal actually breaks.

Problem (look_at_hh): Inspect HH preference data · 2 points

Deliverable: a loader for the Anthropic HH dataset, plus 2–4 sentences on three "helpful" and three "harmless" examples and whether you agree with the annotators.

Four gzipped JSON-lines files on main at data/hh: harmless-base, helpful-base, helpful-online, helpful-rejection-sampled (training splits only). Each line has a chosen and a rejected conversation sharing a prefix. Three processing rules: drop multi-turn conversations where the human sent more than one message, because those diverge past the shared prompt and are no longer a clean pairwise comparison; split each example into instruction plus chosen/rejected responses; and remember which file each came from, since the write-up compares the two categories. gzip and json are all you need.

The write-up question is the good one. Anthropic deliberately declined to define "helpful" or "harmless", leaving it to annotator judgment, so the labels encode a distribution of opinions rather than a rule. You will find pairs where the difference is obvious and pairs where you would have chosen the other way — and that is the actual epistemic status of the preference signal DPO is about to optimize.

Problem (dpo_loss): DPO loss · 2 points

Deliverable: the per-instance DPO loss for one (prompt, chosen, rejected) triple under a policy and a frozen reference.

The whole of DPO in one function: −log σ(β·[log π_θ(y_w|x) − log π_ref(y_w|x)] − β·[log π_θ(y_l|x) − log π_ref(y_l|x)]). The derivation in §6.1 is worth following because it explains why this is legitimate — the optimal policy for a reward model can be inverted to express the reward in terms of the policy, and when you plug that into the Bradley-Terry reward-model loss the intractable partition function Z(x) cancels between the two responses. No reward model, no sampling, no RL loop; just four forward passes.

Three implementation details the test enforces. Format prompt and response with the Alpaca template and append the EOS token after each response — forget it and the arithmetic is close but not equal. Sum log-probs over the whole concatenated string rather than conditioning on the prompt; the supplement notes the prompt's contribution cancels in the difference under the same model, so unconditional sums are equivalent and simpler. And return the loss on the same device as the policy — the two models are expected to be on different GPUs.

Problem (dpo_training): DPO training · 4 points · 1 B200 hr

Deliverable: a DPO training script and a validation-accuracy screenshot, then AlpacaEval, SimpleSafetyTests, GSM8K and MMLU evaluations of the result.

One epoch over HH starting from your SFT checkpoint. The suggested path trades throughput for simplicity: two GPUs holding two copies of the instruction-tuned model (one frozen as π_ref), no batching at all — one example at a time, because four forward passes through an 8B model is already a lot of memory — with gradient accumulation supplying the effective batch size. RMSprop rather than AdamW, following the original DPO work, because AdamW's optimizer state is unaffordable here without quantization tricks.

SettingValue
effective batch size64 (via gradient accumulation)
β0.1
learning rate1e-6
optimizerRMSprop
validation set~200 held-out examples
epochs1
checkpointkeep the highest validation accuracy

Track the implicit reward model's classification accuracy on validation: for each pair, check whether the chosen response has higher log-probability than the rejected one under π_θ. That single number is your training signal made legible, and it is what selects the checkpoint.

Parts (b) through (d) are the payoff. AlpacaEval and SimpleSafetyTests should improve, since HH contains exactly the helpfulness and refusal behaviour they test. GSM8K and MMLU are the control, and the question is whether they fall — the alignment tax, observed in the original HH paper and much of the alignment literature since. Whether you see it, and how large it is at 8B on one epoch, is the actual finding.

What you hand in

Gradescope, two files. writeup.pdf with every written question answered and typeset, and code.zip with everything you wrote. The repo ships test_and_make_submission.sh to build the archive: it runs uv run pytest -v ./tests --junitxml=test_results.xml (with || true, so failures do not block packaging), then zips the tree into cs336-spring2025-assignment-5-submission.zip, excluding .venv, caches, ./data, and every large binary extension (.pt, .bin, .safetensors, .pkl). It also excludes *.txt, *.json and *.log — so if any of your generation dumps or configs live in those formats and you want them submitted, move them or adjust the exclusion list.

What the write-up owes. Fifteen of the twenty-six required problems are wholly or partly write-up: one commentary on baseline failure modes, seven experiment reports with reward curves, one pre-registered prediction about length normalization, one train-loop report with sample rollouts, and one leaderboard screenshot with the wall-clock x-axis capped at four hours. Every experiment deliverable asks for validation reward or accuracy curves, never loss curves — §6.5 explains why, and grading follows it.

Leaderboard. Separate from Gradescope: a pull request against assignment5-alignment-leaderboard with your row, your learning curve (uploaded to ./images or a public W&B link), and a description of your approach. Keep a reproducible snapshot of your best configuration — the staff re-run the top five.

Compute. Roughly 52 H100-hours across the graded runs of the required assignment: 2 for SFT, 6 for expert iteration, 6 for the learning-rate sweep, 12 for the off-policy sweep, 2 each for the four remaining ablations, and 16 for the leaderboard. The supplement adds 3 B200-hours for instruction tuning and 1 for DPO. Every GRPO run wants 2 GPUs. Start early; this is not an assignment you can compress into a weekend.

Materials, verified

The assignment itself

Models and data the handout names

Reasoning and RL papers

Alignment and safety papers (supplement)

Reference implementations and further reading

That's the last assignment — the course ends here. Back to the assignments.