Real evaluation with Inspect AI#
Track 08 - Production · Notebook 08 · Runtime: ~2 min LIVE, <1s replay
Build an Inspect AI task: 10 math word problems, a custom numeric scorer, two models head-to-head. Same shape used by AISI for the public benchmarks they publish.
from llm_systems_cookbook.nb import bootstrap
from llm_systems_cookbook._utils import repo_root
import json
import os
import re
from pathlib import Path
s = bootstrap("08_production_08_inspect_ai_eval_harness")
try:
import inspect_ai # noqa: F401
HAS_INSPECT = True
except ImportError:
HAS_INSPECT = False
LIVE = HAS_INSPECT and bool(os.environ.get("ANTHROPIC_API_KEY")) and bool(os.environ.get("INSPECT_LIVE"))
FIXTURE = json.loads((Path(repo_root()) / "notebooks/08_production/_fixtures/08_inspect.json").read_text())
print(f"mode={'LIVE' if LIVE else 'REPLAY'} inspect_installed={HAS_INSPECT}")
Dataset — 10 math word problems#
Each sample is (input, target). Inspect AI represents these as Sample objects; we serialise to a list of dicts in the fixture so we can replay scoring without spinning up the framework.
samples = FIXTURE["samples"]
for sm in samples[:3]:
print(f" [{sm['id']}] target={sm['target']:>5} {sm['input']}")
Task definition#
A task wires three things: dataset, solver (generate() is the default — just call the model), and scorer. The custom scorer here extracts the final integer from the model output and matches it against target with a 0.05 tolerance for floats.
TASK_SOURCE = """
from inspect_ai import Task, task, eval
from inspect_ai.dataset import Sample
from inspect_ai.solver import generate
from inspect_ai.scorer import scorer, Score, Scorer, Target
@scorer(metrics=[\"accuracy\", \"stderr\"])
def numeric_match(tolerance: float = 0.05) -> Scorer:
async def score(state, target: Target) -> Score:
text = state.output.completion.strip()
# Extract the last number in the answer.
m = re.findall(r\"-?\\d+(?:\\.\\d+)?\", text)
if not m:
return Score(value=0, answer=text, explanation=\"no number\")
got = float(m[-1])
want = float(target.text)
ok = abs(got - want) / max(abs(want), 1) <= tolerance
return Score(value=int(ok), answer=str(got), explanation=f\"want {want}, got {got}\")
return score
@task
def math_word_problems():
return Task(
dataset=[Sample(input=s[\"input\"], target=s[\"target\"], id=s[\"id\"])
for s in samples],
solver=generate(),
scorer=numeric_match(),
)
"""
print(TASK_SOURCE)
Run on two models#
In LIVE mode eval(math_word_problems(), model="anthropic/claude-haiku-4-5") returns an EvalLog with per-sample scores, latency, and cost. We replay the same shape from the fixture below.
MODELS = ["claude_haiku_4_5", "claude_sonnet_4_6"]
def run_eval(model_key: str) -> dict:
if not LIVE:
return FIXTURE["aggregates"][model_key]
# LIVE: this is what actually runs the eval — see the source above.
# from inspect_ai import eval
# logs = eval(math_word_problems(), model=f"anthropic/{model_key.replace('_', '-')}")
# ... extract aggregates from logs[0] ...
raise NotImplementedError("LIVE branch — install inspect-ai and set INSPECT_LIVE=1")
results = {m: run_eval(m) for m in MODELS}
print(f"{'model':<22} {'accuracy':>10} {'$ total':>10} {'mean lat':>10}")
for m, r in results.items():
print(f"{m:<22} {r['accuracy']:>10.1%} ${r['total_cost_usd']:>8.5f} {r['mean_latency_s']:>8.2f}s")
Per-sample diff#
The most useful Inspect AI affordance is per-sample inspection. Below: every problem, both models’ answers, and where the smaller model failed.
print(f"{'id':<6} {'haiku':>9} {'sonnet':>9} problem")
print("-" * 80)
for sm in samples:
h = sm["claude_haiku_4_5"]; hk = "✓" if h["correct"] else "✗"
so = sm["claude_sonnet_4_6"]; sk = "✓" if so["correct"] else "✗"
diff = " ←" if h["correct"] != so["correct"] else ""
print(f"{sm['id']:<6} {hk:>9} {sk:>9} {sm['input'][:55]}{diff}")
Visualisation#
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9.5, 3.4))
colors = ["tab:orange", "tab:green"]
ax1.bar([m.replace("_", "-") for m in MODELS],
[results[m]["accuracy"] * 100 for m in MODELS], color=colors)
ax1.set_ylabel("accuracy (%)"); ax1.set_ylim(0, 105)
ax1.set_title(f"{len(samples)}-problem accuracy")
for i, m in enumerate(MODELS):
ax1.text(i, results[m]["accuracy"] * 100 + 1,
f"{results[m]['correct']}/{results[m]['total']}", ha="center")
ax2.bar([m.replace("_", "-") for m in MODELS],
[results[m]["total_cost_usd"] * 1000 for m in MODELS], color=colors)
ax2.set_ylabel("total cost (×1e-3 $)")
ax2.set_title("eval cost across both models")
for i, m in enumerate(MODELS):
ax2.text(i, results[m]["total_cost_usd"] * 1000,
f"${results[m]['total_cost_usd']:.4f}", ha="center", va="bottom")
fig.tight_layout(); plt.show()
Checks#
haiku = results["claude_haiku_4_5"]
sonnet = results["claude_sonnet_4_6"]
s.check(
"fixture_samples_match_aggregate",
lambda: sum(s["claude_haiku_4_5"]["correct"] for s in samples) == haiku["correct"]
and sum(s["claude_sonnet_4_6"]["correct"] for s in samples) == sonnet["correct"],
msg=f"haiku per-sample agrees with aggregate ({haiku['correct']}); "
f"sonnet too ({sonnet['correct']})",
)
s.check(
"sonnet_at_least_as_accurate_as_haiku",
lambda: sonnet["accuracy"] >= haiku["accuracy"],
msg=f"haiku={haiku['accuracy']:.1%} sonnet={sonnet['accuracy']:.1%}",
)
s.check(
"sonnet_more_expensive_than_haiku",
lambda: sonnet["total_cost_usd"] > haiku["total_cost_usd"],
msg=f"haiku=${haiku['total_cost_usd']:.5f} sonnet=${sonnet['total_cost_usd']:.5f}",
)
# Numeric extraction — verify the scorer behaves on representative outputs.
def _extract(text: str) -> float | None:
m = re.findall(r"-?\d+(?:\.\d+)?", text)
return float(m[-1]) if m else None
s.check(
"scorer_extracts_last_number_from_text",
lambda: _extract("3 * 7 = 21") == 21 and _extract("$4/12 per egg * 30 eggs = $10.") == 10,
)
s.check(
"more_than_half_correct_on_smaller_model",
lambda: haiku["accuracy"] >= 0.5,
msg=f"haiku acc = {haiku['accuracy']:.1%}",
)
Notes for production#
inspect evalCLI:inspect eval task.py --model anthropic/claude-sonnet-4-6 --limit 50runs the file and saves a log to./logs/.inspect viewopens a browser UI to inspect every sample, retry, and tool trace.Built-in datasets:
inspect_ai.dataset.hf_dataset(...), plus first-class loaders for HumanEval, GSM8K, MMLU, IFEval, MATH. Building on those is one line.Model-graded scorers:
model_graded_qa(),model_graded_fact()use a judge LLM with a rubric. Cheaper than human eval, more flexible than substring match. Pin the judge model and rubric — drift will invalidate week-over-week comparisons.Solvers compose.
chain(prompt_template(), generate(), self_critique())runs prompt → completion → second-pass critique. Useful for measuring how much “agent scaffolding” actually buys you on a fixed benchmark.Cost-aware sweeps:
eval_set([task1, task2], models=[...], max_samples=50)runs the matrix and writes one log per (task, model). Pin a budget with--max-tasksand--max-samples.
s.summary()
s.save()