Module 10. Search, verification, and test-time compute
Read the explanations and code in the existing notebooks, or download the files. Code does not run on this site.
Download course files (.zip) 382.1 KBThe archive preserves the source folder structure, both languages, and supporting files.
Module 10. Test-Time Compute: Sampling, Verification, and Search
Main module notebook
Download .ipynb module-10-test-time-search.en.ipynb · 19.6 KB
Show notebook text
Module 10. Test-Time Compute: Sampling, Verification, and Search
This notebook connects the module’s mathematics to a reproducible CPU laboratory. Coverage, selection quality, verifier uncertainty, proxy pressure, and search efficiency are measured separately. The core route needs neither network access nor a GPU; the optional real-Qwen3 branch is disabled and does not contribute to the release results.
0. Reproducible setup
smoke executes the complete required route. full increases the number of independent runs. The code locates the unpacked course root automatically.
from __future__ import annotations
from pathlib import Path
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display
PROFILE = "smoke" # "smoke" or "full"
RUN_REAL_MODEL = False
SEEDS = range(40 if PROFILE == "smoke" else 100)
ROOT = Path.cwd()
if not (ROOT / "modules").exists():
if (ROOT.parent / "modules").exists():
ROOT = ROOT.parent
else:
raise RuntimeError("Run the notebook from the course root or notebooks directory")
MODULE = ROOT / "modules" / "module-10"
sys.path.insert(0, str(MODULE))
from module_10_reference import (
pass_at_n, pass_curve, unbiased_pass_at_k,
plurality_accuracy_exact, plurality_accuracy_mc,
binary_verifier_success, process_score, prefix_success_estimate,
wilson_interval, proxy_overoptimization_curve,
allocate_budget_greedy, mean_coverage, pareto_frontier,
break_even_queries, cost_regularized_utility,
)
from module_10_search import (
ReasoningMaze, repeated_sampling, best_of_n_proxy, beam_search,
best_first_search, mcts_search, evaluate_search_methods, maze_to_array,
)
LABELS = {'coverage': 'probability of at least one success', 'budget': 'budget N', 'exact': 'exact', 'mc': 'Monte Carlo', 'vote': 'voting accuracy', 'proxy': 'proxy score', 'goal': 'independent objective', 'score': 'score', 'success': 'successful-run rate', 'calls': 'model calls', 'good': 'good scorer', 'biased': 'biased scorer', 'allocation': 'allocated samples', 'prompt': 'prompt', 'quality': 'quality', 'cost': 'cost', 'skipped': 'The real-model branch is disabled. Enable it only in a prepared environment with model access.'}
np.set_printoptions(precision=4, suppress=True)
print(f"profile={PROFILE}, independent seeds={len(list(SEEDS))}")1. pass@N coverage and finite-sample pass@k
The formula describes the presence of at least one success under conditional independence and constant success probability . We also compute the unbiased finite-sample pass@k estimate for an already generated pool.
budgets = np.array([1, 2, 4, 8, 16, 32, 64])
fig, ax = plt.subplots(figsize=(8.8, 5.0))
for p in (0.0, 0.05, 0.2, 0.5):
ax.plot(budgets, pass_curve(p, budgets), marker="o", label=f"p={p:g}")
ax.set_xscale("log", base=2)
ax.set_xticks(budgets, labels=budgets)
ax.set_xlabel(LABELS["budget"])
ax.set_ylabel(LABELS["coverage"])
ax.set_ylim(-0.02, 1.02)
ax.grid(True, alpha=0.25)
ax.legend()
plt.show()
finite = pd.DataFrame({
"n": [100, 100, 100, 20],
"c": [1, 5, 20, 3],
"k": [1, 10, 10, 5],
})
finite["pass@k"] = [unbiased_pass_at_k(int(r.n), int(r.c), int(r.k)) for r in finite.itertuples()]
display(finite.round(6))
assert np.isclose(pass_at_n(0.2, 8), 1 - 0.8**8)2. Voting and ties
Voting is useful only when the correct answer is the largest mode. Ties are split uniformly here, avoiding the hidden positional bias of a plain argmax.
cases = {
"scattered_errors": np.array([0.40, 0.15, 0.15, 0.15, 0.15]),
"systematic_error": np.array([0.30, 0.45, 0.25]),
}
rows = []
for name, probs in cases.items():
for k in ((1, 4, 8, 16) if len(probs) == 5 else (1, 8, 32, 64)):
exact = plurality_accuracy_exact(probs, k)
mc = plurality_accuracy_mc(probs, k, trials=20_000 if PROFILE == "smoke" else 80_000, seed=10+k)
rows.append({"case": name, "k": k, "exact": exact, "Monte Carlo": mc})
votes = pd.DataFrame(rows)
display(votes.round(6))
fig, ax = plt.subplots(figsize=(8.6, 4.8))
for name, group in votes.groupby("case"):
ax.plot(group["k"], group["exact"], marker="o", label=name)
ax.set_xscale("log", base=2)
ax.set_xlabel("k")
ax.set_ylabel(LABELS["vote"])
ax.set_ylim(0, 1.02)
ax.grid(True, alpha=0.25)
ax.legend()
plt.show()
assert abs(votes["exact"].iloc[0] - 0.4) < 1e-123. Binary verification and the fallback protocol
When no candidate is accepted, finite-budget accuracy depends on whether the system selects from the already rejected pool or draws a fresh unverified candidate. The two protocols require different formulas.
p, alpha, beta = 0.2, 0.9, 0.1
ns = np.arange(1, 65)
rejected = np.array([
binary_verifier_success(int(n), p, alpha, beta, fallback_mode="rejected_pool").success_probability
for n in ns
])
fresh = np.array([
binary_verifier_success(int(n), p, alpha, beta, fallback_mode="fresh_unverified").success_probability
for n in ns
])
example_rej = binary_verifier_success(8, p, alpha, beta, fallback_mode="rejected_pool")
example_fresh = binary_verifier_success(8, p, alpha, beta, fallback_mode="fresh_unverified")
display(pd.DataFrame([example_rej.__dict__, example_fresh.__dict__]).round(6))
fig, ax = plt.subplots(figsize=(8.7, 4.8))
ax.plot(ns, rejected, label="fallback: rejected pool")
ax.plot(ns, fresh, label="fallback: fresh draw")
ax.axhline(example_rej.accepted_precision, linestyle="--", label="accepted-pool precision")
ax.set_xscale("log", base=2)
ax.set_xlabel(LABELS["budget"])
ax.set_ylabel(LABELS["success"])
ax.grid(True, alpha=0.25)
ax.legend()
plt.show()
assert np.isclose(example_rej.success_probability, 0.632486004809728)
assert np.isclose(example_fresh.success_probability, 0.6480396435591987)4. Proxy overoptimization under Best-of-N
A larger pool improves the selected proxy score, while an independent objective may peak and then decline. This is a course-owned synthetic mechanism, not an external benchmark.
budgets_proxy = np.array([1, 2, 4, 8, 16, 32, 64, 128, 256, 512])
proxy, independent = proxy_overoptimization_curve(
budgets_proxy, trials=4_000 if PROFILE == "smoke" else 20_000, seed=202_610
)
proxy_table = pd.DataFrame({"N": budgets_proxy, "proxy": proxy, "independent_objective": independent})
display(proxy_table.round(4))
fig, ax = plt.subplots(figsize=(8.8, 4.9))
ax.plot(budgets_proxy, proxy, marker="o", label=LABELS["proxy"])
ax.plot(budgets_proxy, independent, marker="o", label=LABELS["goal"])
ax.set_xscale("log", base=2)
ax.set_xlabel(LABELS["budget"])
ax.set_ylabel(LABELS["score"])
ax.grid(True, alpha=0.25)
ax.legend()
plt.show()
peak_n = int(budgets_proxy[np.argmax(independent)])
print("independent-objective peak N =", peak_n)5. Process scores and prefix uncertainty
A prefix label estimated by Monte Carlo continuations has finite-sample uncertainty. We compare several step-score aggregators and Wilson intervals.
successes = np.arange(9)
rows = []
for s in successes:
lo, hi = wilson_interval(int(s), 8)
rows.append({"successes": s, "estimate": prefix_success_estimate(int(s), 8), "Wilson_low": lo, "Wilson_high": hi})
prefix_table = pd.DataFrame(rows)
display(prefix_table.round(4))
fig, ax = plt.subplots(figsize=(8.6, 4.8))
mid = prefix_table["estimate"].to_numpy()
lo = prefix_table["Wilson_low"].to_numpy()
hi = prefix_table["Wilson_high"].to_numpy()
ax.errorbar(successes, mid, yerr=np.vstack([mid-lo, hi-mid]), fmt="o", capsize=4)
ax.set_xlabel("successes out of 8")
ax.set_ylabel("prefix-success estimate and 95% Wilson interval")
ax.set_ylim(-0.03, 1.03)
ax.grid(True, alpha=0.25)
plt.show()
step_scores = [0.92, 0.78, 0.61, 0.85]
aggregation = {m: process_score(step_scores, m) for m in ("min", "mean", "product", "last", "log_product")}
display(pd.Series(aggregation, name="aggregate").to_frame())6. Reasoning maze: environment and individual trajectories
The maze is a controlled analogue of a reasoning tree. The proposal model is frozen while the answer algorithm changes: repeated sampling, Best-of-N, beam, best-first, and MCTS spend the same kind of budget differently.
env = ReasoningMaze.course_maze()
methods = {
"repeated": repeated_sampling(env, 48, seed=7),
"best_of_n_proxy": best_of_n_proxy(env, 48, seed=7),
"beam": beam_search(env, 48, beam_width=3, score_mode="good", seed=7),
"best_first": best_first_search(env, 48, score_mode="good", seed=7),
"mcts": mcts_search(env, 48, score_mode="good", seed=7),
}
summary = pd.DataFrame([
{"method": name, "success": r.success, "model_calls": r.model_calls,
"generated_trajectories": r.generated_trajectories, "path_length": r.selected_state.depth}
for name, r in methods.items()
])
display(summary)
chosen = methods["best_first"]
grid = maze_to_array(env, chosen.selected_state)
fig, ax = plt.subplots(figsize=(6.3, 5.5))
ax.imshow(grid)
for i in range(env.height):
for j in range(env.width):
if (i, j) not in env.walls:
ax.text(j, i, "·", ha="center", va="center", fontsize=10)
ax.set_title(f"best-first: success={chosen.success}, calls={chosen.model_calls}")
ax.set_xticks(range(env.width)); ax.set_yticks(range(env.height))
plt.show()7. Search curves with a good and a biased scorer
The same search procedure may improve rapidly under an informative process score and fail under a systematically attractive false branch. Search quality therefore cannot be separated from scorer quality.
search_budgets = np.array([12, 16, 24, 32, 48])
good = evaluate_search_methods(search_budgets, seeds=SEEDS, score_mode="good")
biased = evaluate_search_methods(search_budgets, seeds=SEEDS, score_mode="biased")
fig, axes = plt.subplots(1, 2, figsize=(13.8, 4.9), sharey=True)
for ax, curves, title in zip(axes, (good, biased), (LABELS["good"], LABELS["biased"])):
for method, values in curves.items():
ax.plot(search_budgets, values, marker="o", label=method)
ax.set_title(title)
ax.set_xlabel(LABELS["calls"])
ax.grid(True, alpha=0.25)
axes[0].set_ylabel(LABELS["success"])
axes[0].legend(fontsize=8)
plt.show()
comparison = pd.DataFrame({
("good", method): values for method, values in good.items()
} | {
("biased", method): values for method, values in biased.items()
}, index=search_budgets)
comparison.index.name = "model_calls"
display(comparison.round(3))8. Adaptive allocation and the Pareto frontier
A uniform quota is simple but need not maximize mean coverage. We compare it with greedy marginal-gain allocation, then identify non-dominated quality–cost points.
ps = np.array([0.02, 0.08, 0.20, 0.50])
total = 20
uniform = np.full(len(ps), total // len(ps), dtype=int)
greedy = allocate_budget_greedy(ps, total, minimum_per_prompt=1)
allocation = pd.DataFrame({"p": ps, "uniform": uniform, "greedy": greedy})
display(allocation)
print("uniform mean coverage:", round(mean_coverage(ps, uniform), 6))
print("greedy mean coverage:", round(mean_coverage(ps, greedy), 6))
costs = np.array([8, 12, 16, 24, 32, 48, 64], dtype=float)
quality = np.array([0.42, 0.55, 0.69, 0.76, 0.79, 0.80, 0.795])
front = pareto_frontier(quality, costs)
pareto_table = pd.DataFrame({"cost": costs, "quality": quality, "Pareto": front})
pareto_table["utility(lambda=0.004)"] = [cost_regularized_utility(q, c, 0.004) for q, c in zip(quality, costs)]
display(pareto_table)
print("break-even queries:", break_even_queries(12_000, 0.12, 0.07))
fig, ax = plt.subplots(figsize=(7.6, 4.8))
ax.scatter(costs[~front], quality[~front], label="dominated")
ax.scatter(costs[front], quality[front], label="Pareto")
ax.plot(costs[front], quality[front], linestyle="--")
ax.set_xlabel(LABELS["cost"]); ax.set_ylabel(LABELS["quality"])
ax.grid(True, alpha=0.25); ax.legend(); plt.show()9. Optional real-Qwen3 branch
The template below performs real multi-candidate generation and extracts normalized transition log-probabilities. It was not executed in the release gate and supports no claims about quality, latency, or memory.
if not RUN_REAL_MODEL:
print('The real-model branch is disabled. Enable it only in a prepared environment with model access.')
else:
# Install Transformers in a dedicated real-model environment and record
# the actually resolved version before a reproducible run.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, set_seed
model_id = "Qwen/Qwen3-0.6B"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype="auto",
device_map="auto",
)
prompt = "Solve carefully and end with a boxed integer: 17 * 23"
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer([text], return_tensors="pt").to(model.device)
set_seed(10)
generated = model.generate(
**inputs,
do_sample=True,
temperature=0.8,
top_p=0.95,
max_new_tokens=192,
num_return_sequences=8,
return_dict_in_generate=True,
output_scores=True,
)
transition_scores = model.compute_transition_scores(
generated.sequences,
generated.scores,
normalize_logits=True,
)
prompt_len = inputs.input_ids.shape[1]
responses = tokenizer.batch_decode(generated.sequences[:, prompt_len:], skip_special_tokens=True)
rows = []
for i, response in enumerate(responses):
rows.append({
"candidate": i,
"response": response,
"sum_logprob": float(transition_scores[i].sum().cpu()),
"mean_logprob": float(transition_scores[i].mean().cpu()),
})
display(pd.DataFrame(rows))