Evaluation Metrics: Choosing and Defending the Right One
Picking the metric is a product decision disguised as a technical one. Learn to derive the right metric from the cost of each error type — and to defend it.
Covers: Precision/recall/F1, ROC-AUC vs PR-AUC, threshold selection, calibration, regression metrics, ranking metrics
Metric questions are the most reliable seniority filter in an ML interview. A junior answer names a metric. A senior answer asks what the model's decisions cost, derives the metric from that, and then names the threshold too.
30-second answer
Precision is the fraction of positive predictions that are correct — it answers 'when I flag something, am I right?'. Recall is the fraction of actual positives caught — 'of everything I should have found, how much did I?'. F1 is their harmonic mean. Optimise precision when false positives are expensive (spam filters, auto-blocking payments), recall when false negatives are expensive (cancer screening, fraud triage, safety).
| Domain | Costly error | Optimise | Why |
|---|---|---|---|
| Cancer screening | Missing a tumour (FN) | Recall, at ~95%+ | A false positive costs a follow-up scan; a false negative costs a life |
| Spam filter | Blocking real mail (FP) | Precision | One lost job offer is worse than ten spam emails in the inbox |
| Fraud triage queue | Depends on stage | High recall at stage 1, high precision at stage 2 | Cheap model catches everything; expensive review decides |
| Auto-blocking payments | Blocking a good customer (FP) | Precision, very high | A blocked legitimate purchase can permanently lose a customer |
| Content moderation | Both | Precision on auto-remove, recall on human queue | Different thresholds for different actions on the same score |
Why F1 is usually the wrong default
F1 weights precision and recall equally, which is almost never what your business wants. If a false negative costs 10× a false positive, use with :
Better still, skip F-scores and optimise expected cost directly. F1 is a convenience metric for leaderboards; expected cost is what your company actually experiences.
import numpy as np
from sklearn.metrics import precision_recall_curve
# Business inputs — get these from your PM, not from a paper.
COST_FP = 5 # analyst review time for a false alarm
COST_FN = 400 # average loss from a missed fraud
def optimal_threshold(y_true, scores, cost_fp=COST_FP, cost_fn=COST_FN):
thresholds = np.unique(scores)
best = (None, np.inf, None)
for t in thresholds:
pred = scores >= t
fp = int(((pred == 1) & (y_true == 0)).sum())
fn = int(((pred == 0) & (y_true == 1)).sum())
cost = fp * cost_fp + fn * cost_fn
if cost < best[1]:
best = (t, cost, (fp, fn))
return best
t, cost, (fp, fn) = optimal_threshold(y_val, scores_val)
prec, rec, thr = precision_recall_curve(y_val, scores_val)
i = int(np.searchsorted(thr, t))
print(f"optimal threshold : {t:.3f} (NOT 0.5)")
print(f"expected cost : ${cost:,} FP={fp} FN={fn}")
print(f"operating point : precision={prec[i]:.3f} recall={rec[i]:.3f}")
# optimal threshold : 0.083 (NOT 0.5)
# expected cost : $41,215 FP=1843 FN=80
# operating point : precision=0.214 recall=0.906
#
# 21% precision looks terrible on a leaderboard and is exactly right here:
# reviewing 5 false alarms is far cheaper than missing one $400 fraud.Finally, be precise about averaging in the multi-class case. Macro averages the per-class metric equally, so rare classes count as much as common ones. Micro pools all TP/FP/FN, so it is dominated by the frequent classes (and equals accuracy in single-label multi-class). Weighted averages by support. Reporting 'F1 = 0.82' without saying which average is an incomplete answer.
Say these points
- Precision divides by predicted positives; recall divides by actual positives.
- Choose based on the relative cost of FP vs FN — ask before answering.
- F1's equal weighting is rarely what the business wants; F_β or expected cost is better.
- Score and threshold are separate artefacts with separate owners.
- Always state macro vs micro vs weighted averaging in multi-class.
Avoid these mistakes
- Defaulting to threshold 0.5 with no justification.
- Reporting F1 without stating the averaging scheme.
- Optimising a metric that nobody in the business recognises.
Expect these follow-ups
- How do you set thresholds when the cost of an error varies per example?
- Your recall target is 95%. How do you report the model's quality at that fixed point?
- What is precision@k and when is it the right metric?
30-second answer
ROC-AUC uses the false positive rate, whose denominator is the (huge) negative count, so thousands of extra false positives barely move it. PR-AUC's precision has false positives in the denominator against a tiny true-positive count, so it responds sharply. At 1:1000 imbalance a model can show 0.95 ROC-AUC and 0.08 PR-AUC — the second number is the one that describes what a user experiences.
Everything follows from one asymmetry: TN appears in ROC and never in PR. With 1,000,000 negatives and 1,000 positives, going from 1,000 to 11,000 false positives changes FPR from 0.001 to 0.011 — visually nothing on an ROC curve. But it changes precision at fixed recall from 0.50 to 0.08, a catastrophe your users will feel on every page.
import numpy as np
from sklearn.metrics import roc_auc_score, average_precision_score
rng = np.random.default_rng(0)
def simulate(n_neg, n_pos, separation=2.0):
neg = rng.normal(0.0, 1.0, n_neg)
pos = rng.normal(separation, 1.0, n_pos)
y = np.r_[np.zeros(n_neg), np.ones(n_pos)]
s = np.r_[neg, pos]
return y, s
print(f"{'ratio':>12} {'ROC-AUC':>9} {'PR-AUC':>8} {'baseline PR':>12}")
for n_neg, n_pos in [(5_000, 5_000), (50_000, 500), (500_000, 500), (1_000_000, 200)]:
y, s = simulate(n_neg, n_pos)
print(f"{n_neg//n_pos:>10}:1 {roc_auc_score(y, s):>9.3f}"
f" {average_precision_score(y, s):>8.3f} {n_pos/(n_pos+n_neg):>12.4f}")
# ratio ROC-AUC PR-AUC baseline PR
# 1:1 0.921 0.919 0.5000
# 100:1 0.922 0.348 0.0099
# 1000:1 0.921 0.089 0.0010
# 5000:1 0.923 0.041 0.0002
#
# ROC-AUC is FLAT across four orders of magnitude of imbalance.
# PR-AUC collapses -- because the user-facing experience collapses.Reading the numbers correctly
- The PR baseline is the positive rate. A random model gets PR-AUC ≈ prevalence, not 0.5. So 0.089 against a 0.001 baseline is an 89× lift — genuinely good, and it still means only ~9% of flags are real.
- ROC's baseline is always 0.5, which is why it feels comfortable and tells you less.
- Precision@k is often the true metric. If analysts can review 200 cases a day, only precision in the top 200 matters. Report precision@200 and recall@200.
- PR curves are not linearly interpolatable — connecting points with straight lines overestimates area.
average_precision_scorecomputes the correct step-wise sum;auc(recall, precision)does not.
Two related metrics worth having ready: ROC-AUC has a clean probabilistic reading — it equals , which is also the Mann–Whitney U statistic. And for heavily imbalanced ranking problems, partial AUC restricted to the low-FPR region (say FPR < 0.01) is a reasonable compromise when stakeholders insist on an ROC-family number.
Say these points
- TN appears in ROC and never in PR — that single asymmetry explains everything.
- ROC-AUC is nearly invariant to imbalance; PR-AUC is not.
- The PR baseline is the positive rate, so judge PR-AUC as a lift over prevalence.
- Use precision@k when review capacity is the real constraint.
- Use average_precision_score, not auc(recall, precision).
Avoid these mistakes
- Reporting 0.95 ROC-AUC on a 1:10,000 problem as if it were good news.
- Comparing PR-AUC across datasets with different prevalence.
- Linearly interpolating a PR curve.
Expect these follow-ups
- Your PR-AUC is 0.089 with a 0.001 baseline. Is that a good model? Defend it.
- How would you report performance when the review team can only handle 200 cases a day?
- Explain the Mann–Whitney interpretation of ROC-AUC.
30-second answer
A calibrated model's predicted probabilities match observed frequencies: among all cases scored 0.7, about 70% should be positive. It matters whenever a score feeds an expected-value calculation — pricing, bidding, ranking by expected revenue, or thresholding on cost. Fix it post-hoc with Platt scaling (sigmoid) or isotonic regression, fitted on a held-out set, and measure with ECE and a reliability diagram.
Discrimination and calibration are independent. A model that outputs 0.5 + 0.001 × true_score ranks perfectly (AUC 1.0) and is hopelessly miscalibrated. A model that always outputs the base rate is perfectly calibrated and useless. You need both, and AUC only measures the first.
| Model family | Typical calibration | Why |
|---|---|---|
| Logistic regression | Well calibrated | It directly optimises log-loss, a proper scoring rule |
| Neural nets (modern, large) | Overconfident | Trained to near-zero loss; softmax saturates |
| Random forest | Under-confident at extremes | Averaging pulls predictions toward 0.5 |
| Gradient boosting (log-loss) | Reasonable, often slightly overconfident | Optimises log-loss but early stopping and shrinkage distort it |
| SVM (decision_function) | Not probabilities at all | Output is a margin, not a likelihood |
| Any model trained on resampled data | Badly miscalibrated | Calibrated to the resampled prevalence, not the real one |
Measuring it
Expected calibration error: bin predictions, compare mean confidence to observed accuracy in each bin, weight by bin size.
import numpy as np
from sklearn.calibration import CalibratedClassifierCV
from sklearn.metrics import brier_score_loss, log_loss
def expected_calibration_error(y_true, prob, n_bins=10):
edges = np.linspace(0, 1, n_bins + 1)
idx = np.clip(np.digitize(prob, edges) - 1, 0, n_bins - 1)
ece, rows = 0.0, []
for b in range(n_bins):
m = idx == b
if not m.any():
continue
conf, acc, w = prob[m].mean(), y_true[m].mean(), m.mean()
ece += w * abs(acc - conf)
rows.append((f"[{edges[b]:.1f},{edges[b+1]:.1f})", int(m.sum()), conf, acc))
return ece, rows
ece, rows = expected_calibration_error(y_val, raw_probs)
print(f"{'bin':>12} {'n':>6} {'predicted':>10} {'observed':>9}")
for b, n, conf, acc in rows:
print(f"{b:>12} {n:>6} {conf:>10.3f} {acc:>9.3f}")
print(f"ECE = {ece:.4f}")
# bin n predicted observed
# [0.0,0.1) 6210 0.021 0.048 <- underconfident at the low end
# [0.4,0.5) 381 0.447 0.402
# [0.9,1.0) 712 0.961 0.804 <- OVERCONFIDENT at the top: 96% -> 80%
# ECE = 0.0731
# Platt (sigmoid): 2 parameters, works with little calibration data
platt = CalibratedClassifierCV(base_model, method="sigmoid", cv="prefit")
platt.fit(X_cal, y_cal)
# Isotonic: non-parametric & monotone, more flexible, needs >~1000 calibration rows
iso = CalibratedClassifierCV(base_model, method="isotonic", cv="prefit")
iso.fit(X_cal, y_cal)
for name, m in [("raw", base_model), ("platt", platt), ("isotonic", iso)]:
p = m.predict_proba(X_test)[:, 1]
e, _ = expected_calibration_error(y_test, p)
print(f"{name:9s} ECE={e:.4f} Brier={brier_score_loss(y_test, p):.4f}"
f" logloss={log_loss(y_test, p):.4f}")
# raw ECE=0.0731 Brier=0.0894 logloss=0.3120
# platt ECE=0.0121 Brier=0.0831 logloss=0.2874
# isotonic ECE=0.0089 Brier=0.0826 logloss=0.2861When calibration is non-negotiable
- Expected value decisions — ad bidding pays
P(click) × value. A 2× overconfidence is a 2× overspend. - Cost-based thresholds — the optimal threshold formula assumes the score is a probability.
- Combining models — averaging or stacking miscalibrated scores mixes incompatible scales.
- Human-in-the-loop — reviewers learn what '80% confident' should mean and lose trust when it means 55%.
- Regulated decisions — credit and insurance require probabilities that mean what they say.
Say these points
- Calibration and discrimination are independent; AUC measures only discrimination.
- Measure with reliability diagrams, ECE and Brier score.
- Platt for small calibration sets, isotonic for large, temperature scaling for deep nets.
- Always calibrate on held-out data, and recalibrate after any change.
- Resampling for imbalance destroys calibration — recalibrate or apply prior correction.
Avoid these mistakes
- Assuming a softmax output is a probability.
- Fitting the calibrator on training data.
- Reporting only AUC for a model whose scores drive money.
Expect these follow-ups
- Explain temperature scaling and why it cannot change accuracy.
- How does class rebalancing affect calibration, and what is the prior-correction formula?
- Why is Brier score a proper scoring rule and accuracy not?
30-second answer
RMSE penalises large errors quadratically and targets the conditional mean; MAE is robust to outliers and targets the median; MAPE is scale-free but explodes near zero and asymmetrically punishes over-prediction; R² is a relative measure that flatters models on high-variance data. For demand forecasting I would use MAE or weighted MAE for the headline, plus a pinball loss if the downstream decision is inventory, since that needs a quantile rather than a mean.
| Metric | Formula | Estimates | Watch out for |
|---|---|---|---|
| RMSE | Conditional mean | Dominated by outliers | |
| MAE | Conditional median | Ignores error magnitude structure | |
| MAPE | — | Undefined at y=0; asymmetric; unbounded above | |
| SMAPE | symmetric variant | — | Still unstable near zero |
| R² | Variance explained | Depends on target variance, so not comparable across datasets | |
| Pinball / quantile loss | Conditional quantile τ | Requires you to know the cost asymmetry |
import numpy as np
from scipy.optimize import minimize_scalar
rng = np.random.default_rng(0)
# Right-skewed demand: mostly small, occasional huge spike
demand = np.r_[rng.poisson(20, 950), rng.poisson(400, 50)]
rmse = lambda c: np.sqrt(((demand - c) ** 2).mean())
mae = lambda c: np.abs(demand - c).mean()
best_rmse = minimize_scalar(rmse, bounds=(0, 500), method="bounded").x
best_mae = minimize_scalar(mae, bounds=(0, 500), method="bounded").x
print(f"mean = {demand.mean():7.2f} optimal constant under RMSE = {best_rmse:7.2f}")
print(f"median = {np.median(demand):7.2f} optimal constant under MAE = {best_mae:7.2f}")
# mean = 39.02 optimal constant under RMSE = 39.02
# median = 20.00 optimal constant under MAE = 20.00
#
# Nearly 2x apart. If you stock to the RMSE answer you carry 95% excess
# inventory on a typical day; if you stock to the MAE answer you stock out
# on every spike. NEITHER is right -- inventory needs a QUANTILE.
def pinball(y, pred, tau):
e = y - pred
return np.mean(np.maximum(tau * e, (tau - 1) * e))
for tau in (0.5, 0.9, 0.95):
q = np.quantile(demand, tau)
print(f"tau={tau}: stock {q:6.1f} pinball={pinball(demand, q, tau):.3f}")
# tau=0.5: stock 20.0 pinball=9.512
# tau=0.9: stock 355.1 pinball=6.284
# tau=0.95: stock 391.0 pinball=4.017The demand-forecasting answer in full
Ask what the forecast drives
Inventory? Staffing? Financial planning? Inventory has asymmetric costs — a stockout loses a sale and a customer; overstock costs holding and markdown. That asymmetry defines the metric.Use quantile loss for inventory
The classic newsvendor result: the optimal service level is where is understock cost and overstock cost. If a stockout costs 4× the holding cost, forecast the 80th percentile, not the mean.Weight by business value
A 10-unit error on a high-margin SKU is not a 10-unit error on a low-margin one. Weighted MAE (weights = margin or revenue) aligns the metric with the P&L.Avoid MAPE on intermittent demand
Half of retail SKUs have zero demand on a given day. MAPE is undefined there and explodes at 1 unit. Use MASE (scaled against a seasonal-naive baseline) or WAPE (= total absolute error / total actual) instead.Always benchmark against seasonal naive
A model that cannot beat 'same day last week' is not a model. MASE < 1 is the minimum bar, and reporting it pre-empts the interviewer's next question.
Say these points
- RMSE targets the conditional mean, MAE the median — pick the one the decision needs.
- MAPE is undefined at zero and biases forecasts downward; use WAPE or MASE.
- R² is relative to target variance and is not comparable across datasets.
- Inventory decisions need a quantile: τ = c_u / (c_u + c_o), optimised with pinball loss.
- Weight the metric by business value and always beat seasonal naive.
Avoid these mistakes
- Reporting MAPE on intermittent/zero-heavy demand.
- Comparing R² across different product categories.
- Forecasting the mean when the downstream system needs a service level.
Expect these follow-ups
- Derive the newsvendor optimal quantile.
- How would you produce prediction intervals rather than point forecasts?
- What is MASE and why is it preferred in forecasting competitions?
30-second answer
Use top-k metrics because users only see the top: precision@k and recall@k for binary relevance, MAP for ordering within the relevant set, MRR when only the first hit matters, and NDCG@k when relevance is graded and position matters. Complement these with beyond-accuracy metrics — coverage, diversity, novelty — and validate offline conclusions with interleaving before a full A/B test.
| Metric | Answers | Use when |
|---|---|---|
| Precision@k | What fraction of the top k is relevant? | Fixed slate size, binary relevance |
| Recall@k | What fraction of all relevant items made the top k? | Retrieval / candidate generation |
| MRR | How high is the first relevant item? | Navigational search, QA, autocomplete |
| MAP@k | Precision averaged over each relevant hit | Multiple relevant items, order matters |
| NDCG@k | Graded relevance discounted by position | Ratings or multi-level relevance; the industry default |
| Hit rate@k | Did we get at least one? | Sparse implicit feedback |
Two design choices in that formula are worth calling out. The exponential gain means a 'perfect' item is worth far more than two 'good' ones — it deliberately rewards getting the best item to the top. The logarithmic discount encodes the empirical fact that click-through decays roughly logarithmically with rank. Normalising by the ideal DCG makes queries with different numbers of relevant items comparable.
import numpy as np
def dcg(rels, k):
rels = np.asarray(rels, dtype=float)[:k]
return float(((2 ** rels - 1) / np.log2(np.arange(2, len(rels) + 2))).sum())
def ndcg_at_k(rels, k=10):
ideal = dcg(sorted(rels, reverse=True), k)
return dcg(rels, k) / ideal if ideal > 0 else 0.0
def mrr(rels):
hits = np.nonzero(np.asarray(rels) > 0)[0]
return 1.0 / (hits[0] + 1) if len(hits) else 0.0
def average_precision(rels, k=10):
rels = np.asarray(rels)[:k]
hits, score = 0, 0.0
for i, r in enumerate(rels, start=1):
if r > 0:
hits += 1
score += hits / i
return score / max(1, (np.asarray(rels) > 0).sum())
a = [3, 2, 3, 0, 1, 2] # good stuff at the top
b = [0, 1, 2, 3, 3, 2] # same items, reversed quality order
print(f"ranking A: NDCG@6={ndcg_at_k(a,6):.4f} MRR={mrr(a):.3f} MAP={average_precision(a):.3f}")
print(f"ranking B: NDCG@6={ndcg_at_k(b,6):.4f} MRR={mrr(b):.3f} MAP={average_precision(b):.3f}")
# ranking A: NDCG@6=0.9491 MRR=1.000 MAP=0.797
# ranking B: NDCG@6=0.7269 MRR=0.500 MAP=0.629
# Identical item sets, very different user experience -- exactly what these
# metrics are designed to capture and what plain AUC cannot.The three traps in offline ranking evaluation
Position bias in the labels
Your click logs say position 1 is relevant because it was at position 1. Training and evaluating on raw clicks measures your old ranker, not relevance. Fix with an examination model (click = examination × relevance), inverse-propensity weighting, or a small randomised-position bucket to estimate the propensities.Missing-not-at-random labels
You only have feedback on items the old system showed. A genuinely better item that was never surfaced scores zero. This is why offline gains so often fail to reproduce online, and why an exploration bucket is infrastructure, not a nice-to-have.Accuracy is not the whole product
A recommender that shows the same five blockbusters to everyone can win on NDCG and destroy long-term engagement. Track catalogue coverage (fraction of items ever recommended), intra-list diversity, novelty (inverse popularity), and serendipity alongside accuracy.
Finally, be explicit about the split. Ranking data must be split by time (train on the past, evaluate on the future) and by user where appropriate. A random row split leaks a user's future interactions into their training history and inflates every metric above.
Say these points
- Users see the top-k, so evaluate at k: precision@k, recall@k, MRR, MAP, NDCG@k.
- NDCG's exponential gain rewards putting the best item first; the log discount models click decay.
- Position bias and missing-not-at-random labels are the core offline evaluation traps.
- Track coverage, diversity and novelty alongside accuracy.
- Interleaving bridges offline and A/B testing with far less traffic.
Avoid these mistakes
- Evaluating a ranker with global AUC instead of a top-k metric.
- Training on raw clicks without correcting for position bias.
- Random row splits that leak a user's future behaviour into training.
Expect these follow-ups
- Design an exploration policy that gives you unbiased evaluation data.
- How does team-draft interleaving attribute credit fairly?
- How would you measure recommendation diversity quantitatively?
Showing 5 of 5 questions for evaluation-metrics-interview-questions.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Build a metric-selection report
For one imbalanced classification problem, produce the analysis you would hand a product manager.
Requirements
- Accuracy, ROC-AUC, PR-AUC and the trivial baseline for each, side by side, with commentary.
- A cost matrix elicited from assumptions you state explicitly, plus the derived optimal threshold and expected cost.
- Reliability diagram and ECE before and after Platt and isotonic calibration.
- A precision@k table for the k values your (hypothetical) review team can actually handle.
Stretch goals
- Add bootstrap confidence intervals for PR-AUC and for precision at the chosen threshold.
- Simulate a 20% prevalence shift and show which metrics move and which stay put.