VKVlad KulikovPublic / lectures
RL for LLM10 / 13

Module 10. Search, verification, and test-time compute

Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.

Download course files (.zip) 382.1 KB

The archive preserves the source folder structure, both languages, and supporting files.

Four compute budgets

A system generates one answer for easy prompts and eight for hard prompts. Each answer may contain up to 2,000 tokens, and the two best prefixes receive additional branching.

Assign each design element to parallel, sequential, search-based, or adaptive compute. Explain why “eight attempts” does not specify total cost.

Show answer and solution
  • Adaptive: easy prompts receive one candidate and difficult prompts receive eight.
  • Parallel: eight candidates are generated for a difficult prompt.
  • Sequential: each candidate may use up to 2,000 tokens.
  • Search: the two most promising prefixes are expanded further.

“Eight attempts” describes only one width parameter. It does not report candidate length, evaluator calls, expanded nodes, batching, latency, or monetary cost. A reproducible comparison should at least record generated tokens, generator passes, verifier calls, latency, and cost.

Coverage and deployed accuracy

A prompt set has pass@32 equal to 0.98, while the answer selected from 32 candidates is correct only 61% of the time.

What does this say about the generator and ranker? Why is 0.98 not system accuracy?

Show answer and solution

pass@32 = 0.98 says that at least one correct candidate is present in 98% of 32-candidate sets under the stated generator and correctness test. An output accuracy of 0.61 says that the ranker often fails to select the available correct candidate.

The generator has high coverage at this budget, while selection is a major bottleneck. The useful follow-up is conditional selection accuracy on sets that contain a correct candidate, together with comparisons against exact verification, voting, and simpler ranking rules. Calling 0.98 “system accuracy” would be wrong because the user receives one selected output, not the entire set.

Repeated attempts and conditional independence

The formula 1−(1−p)N1-(1-p)^N predicts a large coverage gain, but twenty samples repeat nearly the same wrong template.

Does this observation alone establish a failure of conditional independence? Give alternative explanations and name two diversity diagnostics that should accompany pass@N.

Show answer and solution

No. Independent samples from a low-entropy distribution can repeat the same error. A small prompt-specific success probability, an inaccurate estimate of p, or pooling prompts with different p can also explain the discrepancy. Reused prefixes or search branches are possible dependence mechanisms, but must be established from the sampling protocol. Compare empirical coverage across repeated groups with the per-prompt iid prediction and its uncertainty.

Useful diagnostics include the number of unique canonical answers, pairwise semantic similarity, answer-cluster entropy, the mass of the largest cluster, and the gap between the empirical pass@N curve and the iid prediction from pass@1. No single diversity metric is sufficient because paraphrases can preserve the same error.

When self-consistency hurts

The correct canonical answer has probability 0.32. One systematic error has probability 0.44, and the remaining mass is spread over rare answers.

What happens to voting as sample count grows? Can pass@N still approach one?

Show answer and solution

The wrong answer with probability 0.44 is the unique mode. As the number of iid samples grows, empirical frequencies converge to their probabilities, so plurality voting selects the systematic error with probability tending to one. Voting accuracy therefore tends to zero.

At the same time,

1−(1−0.32)N→1,1-(1-0.32)^N\to1,

so pass@N for the correct answer tends to one. Candidate coverage and aggregation quality are different objects.

Scope of the binary-verifier limit

In the teaching model, the system samples uniformly among candidates accepted by a binary verifier, so large-NN accuracy approaches verifier precision.

Why does this limit not apply to a continuous score followed by an argmax?

Show answer and solution

The teaching protocol has binary acceptance and uniform sampling among accepted candidates. With many samples, the probability of no accepted candidate vanishes, so success approaches the fraction of correct candidates among accepted ones.

A continuous scorer followed by an argmax is an extreme-value problem. Its behavior depends on the joint tails of true quality and score error. Increasing NN can select increasingly positive evaluator errors. The binary-uniform limit does not apply.

Proxy up, quality down

Increasing BoN from 32 to 512 raises reward-model score but lowers independent human evaluation.

Give two plausible explanations and three protocol changes that help distinguish real improvement from proxy overoptimization.

Show answer and solution

Possible explanations include proxy overoptimization, distribution shift in the winners, length/style shortcuts, and sampling noise in the independent evaluation.

A better protocol uses a held-out prompt set and independent evaluator, plots proxy and target curves on the same NN grid with uncertainty, audits length and style, performs blinded human review, defines stopping by the independent metric, and refreshes evaluator data with candidates from the new policy or search distribution.

What a PRM label estimates

For each prefix, ten continuations from the current policy are sampled and their final success fraction becomes the label. The continuation policy is later changed.

Is the old label a timeless “true state value”? What did it estimate, and why can it become stale?

Show answer and solution

The label estimates success under a particular continuation policy, decoding rule, horizon, and verifier:

V^(s)=1M∑j=1M1{continuation j succeeds}.\widehat V(s)=\frac1M\sum_{j=1}^{M}\mathbf 1\{\text{continuation }j\text{ succeeds}\}.

It is not an eternal property of the prefix. Once the continuation policy changes, the distribution of futures and therefore the value can change. Store the policy version, decoding parameters, sample count, verifier, and labeling date.

Complex search versus a simple baseline

PRM-guided MCTS improves pass@1 but uses four times more tokens and twenty times more evaluator calls than repeated sampling. What comparisons are required before claiming that MCTS is better?

Show answer and solution

Compare at equal generated-token budgets, generator passes, evaluator calls, latency or monetary cost, and context limits. Report pass@1, coverage, selected-answer quality, failures, tokens, latency, and cost. Strong baselines include repeated sampling, BoN, self-consistency, and sequential revision. “MCTS is better” is undefined without a resource constraint and target metric.

pass@N by hand

A single attempt succeeds with probability p=0.35p=0.35, and attempts are conditionally independent.

  1. Compute pass@6.

  2. Compute the gain of the sixth attempt over five attempts.

  3. State what these numbers do not imply without a selector.

Show answer and solution
pass@⁡6=1−0.656=0.924581109375.\operatorname{pass@}6=1-0.65^6=0.924581109375.

The sixth sample adds

0.35⋅0.655=0.040610171875.0.35\cdot0.65^5=0.040610171875.

These are coverage values. They do not specify the probability that a selector returns the successful candidate.

Finite-sample pass@k

A prompt has n=100n=100 generated candidates, of which c=7c=7 pass an exact verifier. Compute

1−(n−ck)(nk)1-\frac{\binom{n-c}{k}}{\binom{n}{k}}

for k=10k=10. Report four decimal places.

Show answer and solution
1−(9310)(10010)≈0.5332595857,1-\frac{\binom{93}{10}}{\binom{100}{10}} \approx0.5332595857,

hence

0.5333.\boxed{0.5333}.

The estimator corresponds to selecting ten elements without replacement from the already generated set of one hundred.

A binary verifier with two fallback protocols

A candidate is correct with probability p=0.25p=0.25. The verifier has TPR α=0.8\alpha=0.8 and FPR β=0.05\beta=0.05. If any candidate is accepted, one accepted candidate is selected uniformly.

For N=6N=6, compute:

  1. the acceptance probability qq;

  2. correctness among accepted candidates ρacc\rho_{\mathrm{acc}};

  3. correctness among rejected candidates ρrej\rho_{\mathrm{rej}};

  4. final success when an empty accepted set falls back to the same rejected pool;

  5. final success when it instead draws a fresh unverified candidate;

  6. the limit as N→∞N\to\infty.

Explain why the two fallback rules cannot share the same term.

Show answer and solution

The acceptance probability is

q=pα+(1−p)β=0.25⋅0.8+0.75⋅0.05=0.2375.q=p\alpha+(1-p)\beta =0.25\cdot0.8+0.75\cdot0.05 =0.2375.

Correctness among accepted candidates is

ρacc=pαq=0.20.2375≈0.8421053.\rho_{\mathrm{acc}} =\frac{p\alpha}{q} =\frac{0.2}{0.2375} \approx0.8421053.

Correctness conditional on rejection is

ρrej=p(1−α)1−q=0.050.7625≈0.0655738.\rho_{\mathrm{rej}} =\frac{p(1-\alpha)}{1-q} =\frac{0.05}{0.7625} \approx0.0655738.

The no-accept probability is

P0=(1−q)6=0.76256≈0.1965346.P_0=(1-q)^6=0.7625^6\approx0.1965346.

Fallback to the rejected pool gives

S6rej=(1−P0)ρacc+P0ρrej≈0.6894899.S_6^{\mathrm{rej}} =(1-P_0)\rho_{\mathrm{acc}}+P_0\rho_{\mathrm{rej}} \approx0.6894899.

A fresh unverified fallback gives

S6fresh=(1−P0)ρacc+P0p≈0.7257361.S_6^{\mathrm{fresh}} =(1-P_0)\rho_{\mathrm{acc}}+P_0p \approx0.7257361.

For q>0q>0, both converge to

S∞=ρacc≈0.8421053.\boxed{S_\infty=\rho_{\mathrm{acc}}\approx0.8421053}.

Observing rejection changes the conditional correctness probability. The term pp is valid for a fresh independent draw, not for a candidate already known to have been rejected.

Voting limits

Compare

p(1)=(0.34,0.33,0.20,0.13)p^{(1)}=(0.34,0.33,0.20,0.13)

and

p(2)=(0.30,0.40,0.30).p^{(2)}=(0.30,0.40,0.30).

The first answer is correct in both cases. What is the limiting plurality-vote accuracy under independent sampling and no asymptotic tie?

Show answer and solution

For (0.34,0.33,0.20,0.13)(0.34,0.33,0.20,0.13), the correct answer is the unique mode, so plurality accuracy tends to one, although convergence is slow because the gap is only 0.01.

For (0.30,0.40,0.30)(0.30,0.40,0.30), a wrong answer is the unique mode, so plurality accuracy tends to zero.

Four PRM aggregations

Step scores are

(0.9,0.7,0.8).(0.9,0.7,0.8).

Compute the minimum, mean, product, and last score. What happens to the product if ten additional steps with score 0.9 are appended?

Show answer and solution

For (0.9,0.7,0.8)(0.9,0.7,0.8):

min⁡=0.7,mean=0.8,product=0.504,last=0.8.\min=0.7,\qquad \text{mean}=0.8,\qquad \text{product}=0.504,\qquad \text{last}=0.8.

Adding ten scores of 0.9 gives

0.504⋅0.910≈0.1757339338.0.504\cdot0.9^{10}\approx0.1757339338.

The product introduces a length effect unless the scores have a compatible conditional-probability semantics.

Adaptive allocation and break-even

Part 1. Three prompts have independent single-attempt success probabilities

p=(0.1,0.3,0.6).p=(0.1,0.3,0.6).

The total budget is nine, with at least one attempt per prompt. Repeatedly assign the next sample to the largest pi(1−pi)nip_i(1-p_i)^{n_i}. Find the allocation and mean coverage.

Part 2. Additional training costs 5,000 units and reduces per-query cost from 0.08 to 0.03. Find the cost-only break-even query count.

Show answer and solution

Greedy marginal allocation from the initial (1,1,1)(1,1,1) yields

(n1,n2,n3)=(2,4,3).(n_1,n_2,n_3)=(2,4,3).

The mean coverage is

(1−0.92)+(1−0.74)+(1−0.43)3=0.6286333333.\frac{(1-0.9^2)+(1-0.7^4)+(1-0.4^3)}{3} =0.6286333333.

For costs,

5000+0.03Q=0.08Q,5000+0.03Q=0.08Q,

so

Q=100000.Q=100000.

This is a monetary break-even calculation only.

pass@N curves

Implement numerically stable pass_at_n and aggregate_pass_curve. Handle p=0p=0, p=1p=1, empty input, and invalid probabilities.

Python
from __future__ import annotations

from collections.abc import Iterable, Sequence
import numpy as np


def pass_at_n(p: float, n: int) -> float:
    raise NotImplementedError


def aggregate_pass_curve(ps: Sequence[float], budgets: Iterable[int]) -> np.ndarray:
    raise NotImplementedError

Checks:

Python
assert abs(pass_at_n(0.5, 4) - 0.9375) < 1e-12
curve = aggregate_pass_curve([0.5, 0.2, 0.05, 0.9], [1, 2, 4])
assert np.allclose(curve, [0.4125, 0.549375, 0.6783234375])
Show answer and solution
Python
from __future__ import annotations

from collections.abc import Iterable, Sequence
import math
import numpy as np


def pass_at_n(p: float, n: int) -> float:
    p = float(p)
    if not math.isfinite(p) or not 0.0 <= p <= 1.0:
        raise ValueError("p must lie in [0, 1]")
    if isinstance(n, bool) or int(n) != n or int(n) <= 0:
        raise ValueError("n must be a positive integer")
    n = int(n)
    if p == 0.0:
        return 0.0
    if p == 1.0:
        return 1.0
    return float(-np.expm1(n * np.log1p(-p)))


def aggregate_pass_curve(ps: Sequence[float], budgets: Iterable[int]) -> np.ndarray:
    probabilities = np.asarray(ps, dtype=np.float64)
    budgets = list(budgets)
    if probabilities.ndim != 1 or probabilities.size == 0:
        raise ValueError("ps must be non-empty")
    if not np.isfinite(probabilities).all() or np.any((probabilities < 0) | (probabilities > 1)):
        raise ValueError("every probability must lie in [0, 1]")
    if not budgets:
        raise ValueError("budgets must be non-empty")
    return np.asarray(
        [np.mean([pass_at_n(float(p), int(n)) for p in probabilities]) for n in budgets],
        dtype=np.float64,
    )

log1p and expm1 improve numerical behavior for small pp and large NN.

Plurality voting with neutral tie-breaking

Implement a plurality-voting simulation. If several categories tie for the largest count, select uniformly among the tied categories. correct_index identifies the correct category.

Python
from __future__ import annotations
from collections.abc import Sequence
import numpy as np


def plurality_accuracy_mc(
    probabilities: Sequence[float],
    k: int,
    *,
    correct_index: int = 0,
    trials: int = 100_000,
    seed: int = 10,
) -> float:
    raise NotImplementedError

The following checks allow Monte Carlo error:

Python
assert abs(plurality_accuracy_mc([0.4, 0.15, 0.15, 0.15, 0.15], 16) - 0.7942) < 0.01
assert abs(plurality_accuracy_mc([0.3, 0.45, 0.25], 64) - 0.0771) < 0.01

Why is plain np.argmax not a neutral tie rule?

Show answer and solution
Python
from __future__ import annotations
from collections.abc import Sequence
import numpy as np


def plurality_accuracy_mc(
    probabilities: Sequence[float],
    k: int,
    *,
    correct_index: int = 0,
    trials: int = 100_000,
    seed: int = 10,
) -> float:
    p = np.asarray(probabilities, dtype=np.float64)
    if p.ndim != 1 or p.size < 2:
        raise ValueError("at least two categories are required")
    if not np.isfinite(p).all() or np.any(p < 0) or not np.isclose(p.sum(), 1.0):
        raise ValueError("probabilities must form a distribution")
    if isinstance(k, bool) or int(k) != k or int(k) <= 0:
        raise ValueError("k must be a positive integer")
    if isinstance(trials, bool) or int(trials) != trials or int(trials) <= 0:
        raise ValueError("trials must be a positive integer")
    if not 0 <= int(correct_index) < p.size:
        raise ValueError("correct_index is out of range")

    rng = np.random.default_rng(seed)
    counts = rng.multinomial(int(k), p, size=int(trials))
    correct = 0
    for row in counts:
        winners = np.flatnonzero(row == row.max())
        selected = int(rng.choice(winners))
        correct += selected == int(correct_index)
    return correct / int(trials)

np.argmax returns the first maximal index. If the correct answer is stored at index 0, every tie artificially raises accuracy; under another index it lowers accuracy. That is an array-layout artifact, not a voting protocol.

Finite N with an explicit fallback rule

Implement the binary-verifier formula with fallback_mode equal to "rejected_pool" or "fresh_unverified". Return qq, correctness among accepted and rejected candidates, the no-accept probability, and final success.

Python
from __future__ import annotations


def binary_verifier_success(
    n: int,
    p_correct: float,
    true_positive_rate: float,
    false_positive_rate: float,
    *,
    fallback_mode: str = "rejected_pool",
) -> dict[str, float]:
    raise NotImplementedError

For (8, 0.2, 0.9, 0.1):

Python
r = binary_verifier_success(8, 0.2, 0.9, 0.1, fallback_mode="rejected_pool")
assert abs(r["q"] - 0.26) < 1e-12
assert abs(r["accepted_precision"] - 9/13) < 1e-12
assert abs(r["rejected_precision"] - 0.02/0.74) < 1e-12
assert abs(r["success"] - 0.632486004809728) < 1e-12

Protected tests should cover boundary probabilities and an invalid mode.

Show answer and solution
Python
from __future__ import annotations
import math


def binary_verifier_success(
    n: int,
    p_correct: float,
    true_positive_rate: float,
    false_positive_rate: float,
    *,
    fallback_mode: str = "rejected_pool",
) -> dict[str, float]:
    if isinstance(n, bool) or int(n) != n or int(n) <= 0:
        raise ValueError("n must be a positive integer")
    values = [float(p_correct), float(true_positive_rate), float(false_positive_rate)]
    if any((not math.isfinite(v)) or not 0.0 <= v <= 1.0 for v in values):
        raise ValueError("probabilities must lie in [0, 1]")
    if fallback_mode not in {"rejected_pool", "fresh_unverified"}:
        raise ValueError("unknown fallback mode")

    p, tpr, fpr = values
    q = p * tpr + (1.0 - p) * fpr
    accepted_precision = p if q == 0.0 else p * tpr / q
    rejected_precision = p if q == 1.0 else p * (1.0 - tpr) / (1.0 - q)
    no_accept = (1.0 - q) ** int(n)
    fallback = rejected_precision if fallback_mode == "rejected_pool" else p
    success = (1.0 - no_accept) * accepted_precision + no_accept * fallback
    return {
        "q": q,
        "accepted_precision": accepted_precision,
        "rejected_precision": rejected_precision,
        "no_accept": no_accept,
        "fallback_success": fallback,
        "success": success,
    }

When q == 1, the conditional distribution of rejected candidates is undefined, but the fallback event has zero probability. The returned convention cannot affect success.

Process-score aggregation

Implement min, mean, product, last, and log_product. Validate non-empty input and the range [0,1][0,1].

Python
from __future__ import annotations

from collections.abc import Sequence


def process_score(step_scores: Sequence[float], method: str) -> float:
    raise NotImplementedError

For [0.95, 0.95, 0.30, 0.95], the product is 0.2572125.

Open checks:

Python
assert abs(process_score([0.95, 0.95, 0.30, 0.95], "mean") - 0.7875) < 1e-12
assert abs(process_score([0.95, 0.95, 0.30, 0.95], "product") - 0.2572125) < 1e-12
Show answer and solution
Python
from __future__ import annotations

from collections.abc import Sequence
import numpy as np


def process_score(step_scores: Sequence[float], method: str) -> float:
    values = np.asarray(step_scores, dtype=np.float64)
    if values.ndim != 1 or values.size == 0:
        raise ValueError("step_scores must be non-empty")
    if not np.isfinite(values).all() or np.any((values < 0) | (values > 1)):
        raise ValueError("step scores must lie in [0, 1]")
    method = method.lower()
    if method == "min":
        return float(values.min())
    if method == "mean":
        return float(values.mean())
    if method == "product":
        return float(values.prod())
    if method == "last":
        return float(values[-1])
    if method == "log_product":
        if np.any(values == 0):
            return float("-inf")
        return float(np.log(values).sum())
    raise ValueError("unknown aggregation method")

Log products avoid numerical underflow but do not make an unjustified probabilistic interpretation valid.

Proxy overoptimization under BoN

Reproduce

proxy=g+h,\text{proxy}=g+h, goal=g−max⁡(h−1,0)2,\text{goal}=g-\max(h-1,0)^2,

with independent standard-normal gg and hh. For each budget, select the candidate with maximum proxy.

Python
from __future__ import annotations

from collections.abc import Sequence
import numpy as np


def proxy_overoptimization_curve(
    budgets: Sequence[int],
    *,
    trials: int = 10_000,
    seed: int = 202_610,
) -> tuple[np.ndarray, np.ndarray]:
    raise NotImplementedError

Plot both curves on [1,2,4,8,16,32,64,128,256,512]. Compare the selected proxy score with the independent goal: locate the observed peak and check across several seeds whether larger budgets degrade the goal. Either nested prefixes of one shared candidate pool or a fresh independent pool for each budget is acceptable; document your choice. The exact peak depends on the finite sample and random-number layout and is not a grading condition.

Open checks:

Python
budgets = [1, 2, 4, 8, 16, 32, 64]
proxy, goal = proxy_overoptimization_curve(budgets, trials=2000, seed=1)
assert proxy.shape == goal.shape == (len(budgets),)
assert np.isfinite(proxy).all() and np.isfinite(goal).all()
again = proxy_overoptimization_curve(budgets, trials=2000, seed=1)
assert np.array_equal(proxy, again[0]) and np.array_equal(goal, again[1])
other = proxy_overoptimization_curve(budgets, trials=2000, seed=2)
assert not np.array_equal(proxy, other[0])
assert not np.array_equal(goal, other[1])
Show answer and solution
Python
from __future__ import annotations

from collections.abc import Sequence
import numpy as np


def proxy_overoptimization_curve(
    budgets: Sequence[int],
    *,
    trials: int = 10_000,
    seed: int = 202_610,
) -> tuple[np.ndarray, np.ndarray]:
    budgets = np.asarray(budgets, dtype=np.int64)
    if budgets.ndim != 1 or budgets.size == 0 or np.any(budgets <= 0):
        raise ValueError("budgets must be positive integers")
    rng = np.random.default_rng(seed)
    maximum = int(budgets.max())
    useful = rng.normal(size=(trials, maximum))
    spurious = rng.normal(size=(trials, maximum))
    proxy = useful + spurious
    goal = useful - np.maximum(spurious - 1.0, 0.0) ** 2
    rows = np.arange(trials)
    proxy_means, goal_means = [], []
    for n in budgets:
        selected = np.argmax(proxy[:, : int(n)], axis=1)
        proxy_means.append(proxy[rows, selected].mean())
        goal_means.append(goal[rows, selected].mean())
    return np.asarray(proxy_means), np.asarray(goal_means)

This reference uses a shared candidate pool, so the selected proxy maximum is nondecreasing as the prefix grows in every trial. With independent pools, monotonicity holds in expectation; individual sample means can fluctuate. Under this reference protocol (trials=10000, seed=202610, and the stated grid), the observed goal peaks at N=32N=32; another valid sampling layout can have a different peak. The open checks cover shape, finiteness, reproducibility, and use of the seed. Also verify the goal formula and candidate selection on a few hand-calculated pools; these checks alone do not establish algorithmic correctness.

Greedy budget allocation

Allocate each next sample to the largest marginal gain

pi(1−pi)ni.p_i(1-p_i)^{n_i}.
Python
from __future__ import annotations

from collections.abc import Sequence
import numpy as np


def allocate_budget_greedy(
    ps: Sequence[float],
    total_samples: int,
    *,
    minimum_per_prompt: int = 1,
) -> np.ndarray:
    raise NotImplementedError

Check:

Python
allocation = allocate_budget_greedy([0.02, 0.08, 0.2, 0.5], 16)
assert np.array_equal(allocation, [1, 5, 6, 4])

Explain why greedy optimality belongs to the stated independent concave curves, not to an arbitrary real LLM system.

Show answer and solution
Python
from __future__ import annotations

from collections.abc import Sequence
import numpy as np


def allocate_budget_greedy(
    ps: Sequence[float],
    total_samples: int,
    *,
    minimum_per_prompt: int = 1,
) -> np.ndarray:
    p = np.asarray(ps, dtype=np.float64)
    if p.ndim != 1 or p.size == 0:
        raise ValueError("ps must be non-empty")
    if not np.isfinite(p).all() or np.any((p < 0) | (p > 1)):
        raise ValueError("probabilities must lie in [0, 1]")
    minimum = int(minimum_per_prompt)
    if minimum < 0 or minimum != minimum_per_prompt:
        raise ValueError("minimum_per_prompt must be a non-negative integer")
    if total_samples < minimum * p.size:
        raise ValueError("budget is smaller than the required minimum")
    allocation = np.full(p.size, minimum, dtype=np.int64)
    for _ in range(int(total_samples) - minimum * p.size):
        gains = p * np.power(1.0 - p, allocation)
        allocation[int(np.argmax(gains))] += 1
    return allocation

The greedy result is optimal for the stated independent curves with diminishing discrete marginal gains. Real LLM systems may violate every one of those assumptions.