A fraud detector that flags nothing scores 98% accuracy and catches zero fraud — this lesson is the arithmetic that stops you from shipping that.
ai-eng-wiki/examples/evals/metrics.pyEvery eval that produces a label or a ranked list reduces to two families of arithmetic, and they are the most common place senior candidates embarrass themselves. Classification metrics answer "is this prediction right?" by counting four buckets (TP, FP, FN, TN) and dividing — but the choice of which ratio you report is a product decision disguised as a math decision. Retrieval metrics answer "did the right documents come back, near the top?" and the subtlety is entirely about order. The single mental model: a metric is a lossy projection from a confusion matrix (or a ranked list) down to one number, and your job is to pick the projection that aligns with the cost of being wrong. Accuracy throws away the asymmetry between a missed fraud and a false alarm; recall@k throws away rank; nDCG keeps rank but needs graded labels. Know what each one discards, and you can answer almost any eval question by reasoning forward from the four buckets.
The words first.
Step by step.
Remember this: the metric you pick is a statement about which mistake you're willing to make.
Every classification number is a ratio of four counts. Lay them out as predicted (rows) vs actual (columns):
| Actual positive | Actual negative | |
|---|---|---|
| Pred positive | TP | FP (Type I) |
| Pred negative | FN (Type II) | TN |
From these four numbers, everything follows. accuracy = (TP + TN) / total. precision = TP / (TP + FP) — the column of what you flagged. recall = TP / (TP + FN) — the column of what was actually positive. The reason interviewers start here is that a candidate who can derive any metric from the matrix never has to memorize formulas; one who memorizes formulas falls apart the moment you ask for something non-standard like specificity or balanced accuracy.
Raise the threshold → precision up, recall down. Lower it → catch everything (high recall) but more false positives. There's no free lunch; you pick the point that matches the cost of each error.
Imagine 1,000 emails, 50 of them spam. A model that labels everything "not spam" gets 950/1000 = 95% accuracy while catching zero spam. That's the accuracy trap: when one class dominates, accuracy is mostly measuring the majority class, and a degenerate model scores high. The same failure shows up in fraud (often <1% positive), safety classifiers, and rare-intent routing. The fix is to use metrics that ignore the giant TN bucket — precision and recall both exclude TN, so they can't be inflated by an ocean of easy negatives.
We have 1,000 emails; 50 are truly spam (positive), 950 are ham. Our filter produces this matrix: TP = 40 (spam caught), FP = 5 (ham wrongly quarantined), FN = 10 (spam that reached the inbox), TN = 945 (ham left alone).
TP / (TP + FP) = 40 / (40 + 5) = 40 / 45 = 0.889. Of the 45 emails we quarantined, 89% really were spam.TP / (TP + FN) = 40 / (40 + 10) = 40 / 50 = 0.800. We caught 80% of all spam.2 · P · R / (P + R) = 2 · 0.889 · 0.800 / (0.889 + 0.800) = 1.4222 / 1.6889 = 0.842.(40 + 945) / 1000 = 0.985.Compare the lazy "everything is ham" baseline: accuracy 950/1000 = 0.950, but recall 0/50 = 0.0. Accuracy rose almost to our real model's, yet the baseline is useless. What this did: F1 (0.842) exposed the quality gap that accuracy (0.985 vs 0.950) nearly hid.
Precision and recall pull in opposite directions, and which you favor is dictated by the cost of each error type:
beta > 1 favors recall, beta < 1 favors precision.The lever connecting them is the decision threshold. Most classifiers (and LLM judges that emit a probability or score) output a continuous score; you call "positive" when score ≥ threshold. Lower the threshold and you flag more things — recall rises, precision falls. Raise it and you flag fewer, more confidently — precision rises, recall falls. This is why "what's your precision?" is an incomplete question without a threshold, and why the demo above lets you drag it.
To compare two models independent of where you'll eventually set the threshold, sweep all thresholds and summarize the curve.
FPR = FP/(FP+TN) on the x-axis, across every threshold. ROC-AUC is the area underneath, and it has a beautiful interpretation: it equals the probability that a randomly chosen positive scores higher than a randomly chosen negative. AUC of 1.0 is perfect ranking; 0.5 is a coin flip. It's threshold-independent and insensitive to class ratio because both axes are within-class rates.FP+TN is enormous (lots of easy negatives), so even thousands of false positives barely move the x-axis. PR-AUC keeps the spotlight on the positive class, so it degrades honestly when the model floods you with false alarms. Rule of thumb: ROC-AUC for roughly balanced data and discrimination questions; PR-AUC when positives are rare and false positives are costly.A retriever returns a ranked list, and a good answer buried at rank 9 is nearly as bad as missing. So retrieval metrics differ in how much they reward early placement:
(# relevant in top-k) / (total relevant). Coverage, order-unaware. "Did we get the right docs into the window at all?" The first metric to check, because if the answer isn't in the top-k the generator cannot be faithful — recall@k is the ceiling on RAG quality.1 / (rank of first relevant). First relevant at rank 1 → 1.0, rank 4 → 0.25. Rewards getting one good result early; ignores everything after the first hit. Right when the user reads only the top result.DCG@k = Σ gain_i / log2(i+1); the log2(i+1) discount means rank-1 gets full credit, rank-2 gets /1.585, rank-3 gets /2, etc. Normalize by the ideal ordering's DCG (IDCG) so the score lands in [0, 1]. nDCG is the dominant RAG-retrieval metric because it's the only common one that uses graded relevance (a perfect doc beats a merely-okay one) and penalizes putting good results late.precision@i at each relevant hit, then over queries. Binary relevance, precision-weighted, penalizes burying relevant items below the cutoff. Standard in ranking/recsys.The retriever returns the ranked list [d3, d7, d1, d9, d2]. Graded relevance: d1 is highly relevant (gain 3), d2 is somewhat relevant (gain 1), everything else is irrelevant (gain 0).
gain_i / log2(rank + 1). Only d1 (rank 3) and d2 (rank 5) contribute: 3 / log2(4) + 1 / log2(6) = 3/2 + 1/2.585 = 1.500 + 0.387 = 1.887.d1 first, d2 second: 3 / log2(2) + 1 / log2(3) = 3/1 + 1/1.585 = 3.000 + 0.631 = 3.631.DCG / IDCG = 1.887 / 3.631 = 0.520.What this did: the 0.520 says our ranking captured only about half the achievable graded quality — the strong doc d1 sat at rank 3 instead of rank 1, and the log discount punished that exactly. (Run examples/evals/metrics.py and you'll see nDCG@5 = 0.520 printed.)
examples/evals/metrics.py implements all of these from the literal formulas — no sklearn — so the definition is readable straight off the code. Below is the core: a confusion matrix that derives every classification metric, plus a threshold-free AUC via the rank-sum identity (no curve sweep needed).
from dataclasses import dataclass
@dataclass(frozen=True)
class Confusion:
tp: int; fp: int; fn: int; tn: int
def precision(self): # of flagged, how many were right
d = self.tp + self.fp
return self.tp / d if d else 0.0
def recall(self): # of real positives, how many caught
d = self.tp + self.fn
return self.tp / d if d else 0.0
def f_beta(self, beta=1.0):
p, r = self.precision(), self.recall()
if p == 0 and r == 0: return 0.0
b2 = beta * beta
return (1 + b2) * p * r / (b2 * p + r) # beta>1 favors recall
def confusion_at_threshold(scores, labels, threshold):
tp = fp = fn = tn = 0
for s, y in zip(scores, labels):
pred = 1 if s >= threshold else 0 # the lever
if pred and y: tp += 1
elif pred and not y: fp += 1
elif not pred and y: fn += 1
else: tn += 1
return Confusion(tp, fp, fn, tn)
def roc_auc(scores, labels):
"""AUC = P(random positive scores above random negative). Ties get 0.5."""
pos = [s for s, y in zip(scores, labels) if y == 1]
neg = [s for s, y in zip(scores, labels) if y == 0]
if not pos or not neg: return float("nan")
wins = sum((sp > sn) + 0.5 * (sp == sn) for sp in pos for sn in neg)
return wins / (len(pos) * len(neg))Running the file sweeps the threshold over an imbalanced (4-of-100) problem and prints the tradeoff directly. At threshold 0.6 you get recall 1.000 but precision 0.154 (26 flags, 22 of them false); push to 0.7 and precision jumps to 1.000 while recall drops to 0.750 — the same model, a different operating point. The "always-negative" baseline prints accuracy 0.960 with recall 0.000, the accuracy trap in one line, while ROC-AUC reports 0.982 because the ranking is actually good regardless of where you cut. The retrieval section computes recall@3 = 0.500, MRR = 0.333, nDCG@5 = 0.520, and AP@5 = 0.367 on one query so you can see how each metric scores the same ranked list differently.
| Metric | What it captures | Best when | Failure mode |
|---|---|---|---|
| Accuracy | Overall hit rate | Classes ~balanced | Lies on imbalance; degenerate models win |
| Precision | Cost of false alarms | Auto-actions are expensive | Ignore recall → you miss everything quietly |
| Recall | Cost of misses | Misses are catastrophic | Ignore precision → flag everything, useless |
| F1 / F-beta | P/R balance, one number | Comparing model versions | Hides which of P or R moved |
| ROC-AUC | Threshold-free ranking | Balanced, discrimination | Over-optimistic under heavy imbalance |
| PR-AUC | Positive-class ranking | Rare positives, costly FP | Less intuitive; baseline shifts with prevalence |
| Recall@k | Retrieval coverage | RAG context ceiling | Order-blind; rank-9 == rank-1 |
| MRR | First-hit earliness | User reads top result only | Ignores everything after first hit |
| nDCG@k | Graded, order-aware quality | RAG/search ranking | Needs graded labels; harder to annotate |
Cost & latency. Counting-based metrics (precision, recall, F1, recall@k, MRR, nDCG) are essentially free — pennies of CPU, run on every CI build over a golden set. The expensive part is the labels: judging "is this chunk relevant" or "is this email spam" at scale is what costs money, especially with graded relevance for nDCG (every doc needs a 0–3 grade, not just a yes/no). That annotation cost is why teams report recall@k (binary) on large sets and reserve nDCG for a smaller, carefully graded slice.
Quality & what changes at scale. A single-query metric is noise; you need enough queries that the mean is stable, and you should report confidence intervals, not point estimates, when comparing model versions in a CI gate (e.g., fail the build if nDCG@10 drops more than 2 points below the production baseline). At scale the dominant failure is distribution shift: your offline golden set was curated months ago, production traffic drifts, and your metric stays green while users suffer. Pair the offline gate with online monitoring — recompute the metric (or a proxy like click-through / thumbs-down) on sampled production traffic and alarm on KL-divergence of the input embedding distribution. The other scale failure is label leakage / saturation: once a model is iterated against the same golden set repeatedly, the score overfits and stops predicting downstream quality; refresh the set from new production failures. See /evals for the offline-vs-online split and /rag for where these metrics gate the retriever.
FPR = FP/(FP+TN), and with a huge negative population the +TN denominator is so large that even a flood of false positives barely moves it, so ROC-AUC stays optimistically high. PR-AUC plots precision against recall, both anchored to the positive class, so it degrades honestly when the model produces many false alarms. For a 1-in-10,000 fraud problem I report PR-AUC and pick the operating threshold off the precision-recall curve at the recall the business mandates.Flashcard. Precision =
TP/(TP+FP)(of flagged, how many right — punishes false alarms). Recall =TP/(TP+FN)(of real positives, how many caught — punishes misses). F1 = their harmonic mean. ROC-AUC = P(random positive ranks above random negative). nDCG = graded, order-aware retrieval quality,Σ gain_i/log2(i+1)normalized by the ideal ordering.
Next: LLM-as-Judge — bias, calibration, and rubrics — what to do when there's no clean label to count and your "metric" is itself a model.