Index Ai Academy
Phase 6 · Modern LLM engineering · Post-training

Training reasoning models

Reinforcement learning on verifiable problems.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Since 2024, the biggest change in AI models has been reasoning. Reasoning models think at length before they answer. That makes them far better at math, code and problems with many steps. The training recipe behind them is surprisingly simple: reinforcement learning against answers a program can check. Knowing how it works tells you what these models are good at. It also explains why "thinking effort" settings exist (Phase 1), and where the models still go wrong.

What you'll be able to do

  • Explain reinforcement learning with verifiable rewards and why it scales without human labels
  • Implement GRPO's group-relative advantage and train a small policy with it
  • Recognize reward hacking against weak checkers
  • Relate test-time compute (sampling, voting, thinking longer) to accuracy

What it covers

  1. Rewards you can check
  2. GRPO: compare each answer with its siblings
  3. Worked example: three right, one wrong
  4. Watching GRPO learn addition
  5. Checkers can be gamed too
  6. Thinking more at answer time
  7. What's still hard

Rewards you can check

Think about two kinds of school test. A math quiz has an answer key, so anyone can mark it in seconds. An essay contest needs judges, and judges disagree and have blind spots.

RLHF, from the earlier lessons, is like the essay contest. Its reward comes from a reward model that learned what people prefer. That score is noisy, and a model can learn to game it.

Many tasks are more like the math quiz. They have a checker: a program that marks an answer right or wrong. A math problem has a known answer. Code either passes its tests or it doesn't. A puzzle's solution can be verified.

Training against a checker is called reinforcement learning with verifiable rewards, or RLVR. "Verifiable" means "can be checked." Each round works like this:

  1. The model attempts a problem.
  2. The checker says right or wrong. In the simplest setup, right earns a reward of 1 and wrong earns 0.
  3. The model is updated, so whatever led to right answers becomes more likely.

Nobody writes the reasoning

Here's the surprising part. No one shows the model how to reason. OpenAI's o1 (2024) was the first widely used model trained to reason with large-scale RL. OpenAI published few details, though.

DeepSeek's R1 paper (January 2025) showed the recipe in the open. The rewards covered only two things: a correct final answer, and using the expected format. Yet long, careful chains of thought emerged from that training. A chain of thought is the step-by-step working a model writes before its answer. R1's chains included checking its own work and backtracking ("wait, that's wrong...").

DeepSeek also trained a model called R1-Zero this way, starting straight from a base model. Its thinking got steadily longer as training went on. Thinking more paid off, so training made it more common.

Quick check
Which of these tasks fits RLVR best, with no extra work?
  1. Writing a warm thank-you note
  2. Summarizing a meeting in a friendly way
  3. Choosing a brand's tone of voice
  4. Solving equations whose answers are known
Show the answer

Solving equations whose answers are known

An equation's answer can be checked by a program. The other tasks need someone's judgment, so there's no automatic checker to supply the reward.

GRPO: compare each answer with its siblings

RLVR needs a training method that turns right-or-wrong marks into updates. DeepSeek's method is GRPO, short for group relative policy optimization. As in the RLHF lesson, the model being trained is called the policy.

Picture a cook trying a new recipe four times in one evening. They don't need a food critic to say what "good" means. They compare the four plates with each other. Whatever went into the plates that beat the evening's average, they do more of.

PPO, from the RLHF lesson, nudges the policy toward answers that scored better than expected. It needs a whole extra model, the value model, to say what to expect. GRPO drops the value model. It gets "expected" from the group instead:

  1. For each problem, sample a group of answers, such as 8 attempts at the same problem.
  2. Score each answer with the checker.
  3. Compare each answer's reward with the group's average. That comparison is the answer's advantage.

Answers that beat their siblings get a positive advantage and are pushed up. Answers below the group's average get a negative advantage and are pushed down.

Here is the formula. First, the symbols:

  • GG is the group size: how many answers are sampled for one problem.
  • rir_i is the reward of answer number ii, so r1r_1 is the first answer's reward.
  • mean\text{mean} is the plain average of the group's rewards.
  • std\text{std} is the standard deviation from Phase 2. It measures how spread out the rewards are.
  • AiA_i is the advantage of answer ii.
  • The dots in r1,…,rGr_1, \dots, r_G mean "and so on, up to the last answer's reward."
Ai=ri−mean(r1,…,rG)std(r1,…,rG)A_i = \frac{r_i - \text{mean}(r_1, \dots, r_G)}{\text{std}(r_1, \dots, r_G)}

In plain words: subtract the group's average reward from this answer's reward. Then divide by how spread out the group's rewards are. A positive result means "better than its siblings."

Dividing by the spread puts every group's advantages on a similar scale, whether the problem was easy or hard.

Try it now

Nobody writes the reasoning. It emerges because answers with good reasoning get rewarded more often.

Worked example

Worked example: three right, one wrong

A group of four answers gets rewards 1, 1, 1 and 0. Three are right and one is wrong. What advantage does each answer get? Then try a group where all four answers are wrong.

import statistics

def advantages(rewards):
    mean = statistics.mean(rewards)
    spread = statistics.stdev(rewards)
    return [round((r - mean) / (spread + 1e-4), 2) for r in rewards]

print(advantages([1, 1, 1, 0]))
print(advantages([0, 0, 0, 0]))
It runs right here in your browser, with nothing to install.

Work it by hand. The mean is (1 + 1 + 1 + 0) / 4 = 0.75.

For the spread, find each reward's gap from the mean: 0.25, 0.25, 0.25 and −0.75. Square them to get 0.0625 three times, and 0.5625. They add up to 0.75. Divide by one less than the group size, 3, to get 0.25. The square root of 0.25 is 0.5. This is the sample standard deviation, which statistics.stdev and PyTorch's std compute by default.

Each right answer gets (1 − 0.75) / 0.5 = 0.5. The wrong one gets (0 − 0.75) / 0.5 = −1.5. The rare answer gets the big push, here downward. The four advantages add up to 0.

When all four answers are wrong, every reward equals the mean. So every advantage is 0. The standard deviation is 0 too, and the tiny 1e-4 added to it avoids dividing by zero.

Quick check
In GRPO, a problem where all 8 sampled answers are wrong contributes what to the update?
  1. It stops training
  2. Nothing: every reward equals the group mean, so every advantage is zero
  3. A large push toward the correct answer
  4. A penalty on all 8 answers
Show the answer

Nothing: every reward equals the group mean, so every advantage is zero

GRPO learns only from differences within a group. When every answer fails (or every answer succeeds), there's no difference, so there's no signal.

Watching GRPO learn addition

Below, a small network learns to add two single digits, such as 3 + 5. It learns from the checker alone. It never sees a correct answer. It only finds out whether each of its own sampled answers was right.

Here's what the code does:

  1. Each problem is two digits. The encode function turns each digit into a one-hot list: ten numbers, all 0 except a 1 in that digit's spot.
  2. The policy is a small neural network. For each problem, it gives a chance to each of the 19 possible answers, 0 through 18.
  3. Each training step picks 32 random problems. For each one, it samples a group of answers using those chances.
  4. The checker gives 1 for right and 0 for wrong. Each answer then gets its advantage within its group.
  5. The loss raises the chances of answers with a positive advantage. It lowers the chances of answers with a negative advantage.
  6. Every 200 steps, it tests the policy's top answer on all 100 problems, from 0 + 0 to 9 + 9.

It runs twice: first with groups of 2, then with groups of 16. It's slow, so the Run button counts the seconds.

import torch
import torch.nn as nn
import torch.nn.functional as F

def encode(a, b):
    return torch.cat([F.one_hot(a, 10), F.one_hot(b, 10)], dim=1).float()

all_a, all_b = torch.arange(100) // 10, torch.arange(100) % 10      # every problem from 0+0 to 9+9

def train(group_size, steps=801):
    torch.manual_seed(0)
    policy = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 19))   # answers 0..18
    opt = torch.optim.Adam(policy.parameters(), lr=3e-3)
    for step in range(steps):
        a, b = torch.randint(0, 10, (32,)), torch.randint(0, 10, (32,))
        logits = policy(encode(a, b))
        answers = torch.multinomial(torch.softmax(logits, dim=-1), group_size, replacement=True)
        reward = (answers == (a + b).unsqueeze(1)).float()                # the checker: right or wrong
        advantage = (reward - reward.mean(dim=1, keepdim=True)) / (reward.std(dim=1, keepdim=True) + 1e-4)
        logp = torch.log_softmax(logits, dim=-1).gather(1, answers)
        loss = -(advantage * logp).mean()
        opt.zero_grad()
        loss.backward()
        opt.step()
        if step % 200 == 0:
            with torch.no_grad():
                accuracy = (policy(encode(all_a, all_b)).argmax(dim=-1) == all_a + all_b).float().mean().item()
            print(f"  group of {group_size:>2}, step {step:>3}: {accuracy:.0%} of all 100 problems right")

train(2)
train(16)

Both runs start near chance, about 1 right in 19. With groups of 16, the policy gets 95% of all problems right within 400 steps. With groups of 2, it learns far more slowly. After 800 steps it reaches only 66%.

The reason is worth remembering. When every answer in a group gets the same reward, every advantage is zero. Both wrong, or both right, and the problem teaches nothing. Bigger groups make it likelier that at least one attempt succeeds. So even hard problems give a learning signal.

The 16-group run's last 5% stalls for the same reason. On those problems, the policy has settled on a wrong answer. It almost never samples the right one, so it has no right answer to learn from.

That's why real training tunes how hard the problems are. A problem the model always solves, or never solves, is wasted compute. (torch here is the course's in-browser PyTorch subset.)

Go deeper What the loss line does

The line loss = -(advantage * logp).mean() drives each update. logp is the log of the chance the policy gave each sampled answer. Training makes the loss smaller.

For an answer with a positive advantage, a smaller loss means a higher log-chance. So that answer becomes more likely. For a negative advantage, it means a lower log-chance. An advantage of 0 gives that answer no push at all.

This is the basic policy gradient idea from reinforcement learning. GRPO as DeepSeek published it adds two things this toy leaves out. It caps the size of each update, as PPO does. It also adds a KL fine that keeps the policy close to a reference model, like the leash in RLHF.

Quick check
A problem the policy solves 1 time in 10. How often do all the attempts in a group fail, so the problem teaches nothing? What does this print for groups of 2 and of 16?
p_wrong = 0.9
print(round(p_wrong ** 2, 2), round(p_wrong ** 16, 2))
  1. 0.18 0.9
  2. 0.2 1.6
  3. 0.81 0.19
  4. 0.81 0.81
Show the answer

0.81 0.19

Each attempt fails with chance 0.9. Two failures in a row: 0.9 × 0.9 = 0.81. Sixteen in a row: sixteen 0.9s multiplied together, about 0.19. With groups of 2, this hard problem is wasted 81% of the time. With groups of 16, only about 19% of the time.

Checkers can be gamed too

A verifiable reward is only as good as its checker. Picture a student who memorizes the answers to the practice test. They ace it, but they can't solve a new problem.

If the tests are weak, a policy can learn to pass them without solving the problem. RL is very good at finding shortcuts like that. Labs have reported coding models trained this way that special-case the tests, writing code that only handles the test inputs. Others edited the tests instead of fixing the code.

The code below has two "solutions" to adding two numbers. honest adds them. hack memorizes the two test cases and returns 0 for anything else. Two checkers judge them. weak_tests tries the two known cases. strong_tests tries 100 random pairs of numbers between −99 and 99.

import random

def honest(a, b):
    return a + b

def hack(a, b):                                  # memorizes the visible test cases
    return {(2, 3): 5, (10, 4): 14}.get((a, b), 0)

def weak_tests(f):
    return f(2, 3) == 5 and f(10, 4) == 14

def strong_tests(f):
    random.seed(0)
    return all(f(a, b) == a + b for a, b in [(random.randint(-99, 99), random.randint(-99, 99)) for _ in range(100)])

for f in (honest, hack):
    print(f"{f.__name__:<7} weak tests pass: {weak_tests(f)!s:<5}  strong tests pass: {strong_tests(f)}")

Both "solutions" earn full reward from the weak tests. Only the honest one passes the strong tests. Defenses include:

  • hidden and randomized tests;
  • many varied problems;
  • checks for suspicious edits;
  • monitoring what the model writes in its reasoning.
Quick check
What does hack(7, 8) return?
def hack(a, b):
    return {(2, 3): 5, (10, 4): 14}.get((a, b), 0)

print(hack(7, 8))
  1. 15
  2. 14
  3. An error
  4. 0
Show the answer

0

(7, 8) isn't one of the memorized pairs, so .get returns its default, 0. That's how the strong tests catch the hack.

Thinking more at answer time

Training is one place to spend compute. Answering is another. Reasoning models spend it by thinking longer. That's the "effort" or "thinking budget" setting from Phase 1.

Two simpler tricks work with any model:

  • Majority vote, also called self-consistency: sample several answers and go with the most common one. It's like asking a room full of people and going with the most popular reply.
  • pass@k (say "pass at k"): make several attempts and keep any answer a checker accepts. This needs a checker, such as tests for code.

The chance that at least one attempt is right

Say each attempt is right with chance pp, and the attempts don't affect each other. The chance that at least one of kk attempts is right is:

1−(1−p)k1 - (1 - p)^k

Here pp is the chance that one attempt is right, and kk is the number of attempts.

In plain words: 1−p1 - p is the chance one attempt is wrong. Multiply kk copies of it together to get the chance that every attempt is wrong. Subtract that from 1.

For example, say each attempt is right 40% of the time, so p=0.4p = 0.4. One attempt is wrong with chance 0.6. All five of five attempts are wrong with chance 0.6 × 0.6 × 0.6 × 0.6 × 0.6, about 0.078. So at least one is right with chance 1 − 0.078 = 0.922, about 92%.

p, k = 0.4, 5
all_wrong = (1 - p) ** k
print(round(all_wrong, 3), round(1 - all_wrong, 3))

Real attempts from one model aren't fully independent, so treat this as a best case.

Majority vote

Majority vote needs no checker. It needs something else: wrong answers that disagree with each other. Say five attempts at 17 + 25 give 42, 42, 41, 43 and 32. The right answer, 42, wins with only two votes, because the three wrong answers split.

The simulation below tests both tricks. Each attempt is right 40% of the time. A wrong attempt lands on one of 6 different wrong answers, at random. It runs 2,000 trials for each number of attempts.

import numpy as np

rng = np.random.default_rng(0)
p_right, n_wrong_answers = 0.4, 6           # each attempt is right 40% of the time; wrong ones scatter

def vote(samples):
    values, counts = np.unique(samples, return_counts=True)
    return rng.choice(values[counts == counts.max()])      # break ties at random

for n in [1, 5, 15, 45]:
    trials, by_vote, any_right = 2000, 0, 0
    for _ in range(trials):
        samples = np.where(rng.random(n) < p_right, -1, rng.integers(0, n_wrong_answers, n))   # -1 = correct
        by_vote += vote(samples) == -1
        any_right += (samples == -1).any()
    print(f"{n:>2} attempts: majority vote right {by_vote / trials:.0%}   at least one right (pass@{n}) {any_right / trials:.0%}")

With one attempt, both measures sit near 40%. As attempts grow, pass@k climbs fastest: about 92% at 5 attempts, as the formula predicted. Majority vote climbs more slowly, but it reaches about 99% at 45 attempts.

So a model that's right only 40% of the time per attempt becomes very reliable with enough votes. That holds as long as its wrong answers disagree with each other. This is the logic of test-time compute: you can buy accuracy at answer time, paid for in tokens and waiting time. It explains why reasoning models get more accurate as their thinking budget grows. It's also why the best results on hard benchmarks often use a lot of compute per problem.

Quick check
Suppose every wrong attempt gave the same wrong answer, 41. Each attempt is still right 40% of the time. What happens to majority voting as you add attempts?
  1. It picks 41 more and more often, because 41 gets about 60% of the votes
  2. It stays at exactly 40% right
  3. It still climbs to about 99% right
  4. It can no longer pick a winner
Show the answer

It picks 41 more and more often, because 41 gets about 60% of the votes

Voting backs whichever answer is most common. When the wrong answers agree, the wrong answer is the most common one, and more votes make its win more certain. Voting helps only when mistakes scatter.

What's still hard

  • Fuzzy tasks. RLVR shines where answers can be checked. Writing, strategy and advice have no checker. Those still lean on preference training and AI feedback.
  • Faithfulness. A model's written reasoning isn't guaranteed to be the real reason for its answer. In 2025, Anthropic researchers found that reasoning models often didn't mention hints they had used. Treat a chain of thought as useful evidence, not a guaranteed record.
  • Overthinking. Long reasoning costs tokens and time, even on easy questions. Choosing the effort level for each task is part of using these models well.

For marketers: for analytical tasks, such as attribution math, budget allocation or cleaning messy data, use a reasoning model or higher effort. For brainstorming taglines, a fast model sampled several times usually serves better than long deliberation.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What makes RL with verifiable rewards scale better than RLHF for math and code?

  1. The reward comes from an automatic checker, so no human labels are needed per example and it's hard to fool when the checks are strong
  2. It uses a much larger reward model, trained on more preference pairs than RLHF ever had, so that its scores are harder for the policy to fool
  3. It doesn't need any problems to practice on, only a base model, since it can generate its own and grade its own answers without any outside help
  4. It trains on human-written chains of thought, so the model copies how experts really work, and these are cheaper to collect than preference labels

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson