Reviewing someone's ML code
ML code can be flawless as code and still produce a worthless model, so an ML review checks the split, the leaks, the labels and the score itself — four questions ordinary code review never asks.
- 12 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
In ordinary code, a bug makes something break. In ML code, a bug usually makes the score go up — so a reviewer has to check the marking, not the handwriting.
Imagine a student who does the exam and also marks their own paper. The handwriting is neat, the working is shown, the arithmetic is correct. And the paper is marked 98%.
Nobody sensible accepts that mark without checking how it was awarded. Not because the student is dishonest — because the person doing the work should never also be the only person checking it.
ML code is exactly that student. The same script trains the model and computes its score. Reviewing only the code is reviewing only the handwriting.
Why it exists
Normal code review works because normal bugs are loud. A missing bracket does not compile. A wrong loop crashes or prints something absurd.
ML bugs are quiet, and they mostly point the same way: upward. Accidentally letting the answer sheet into the practice material makes the score better. Testing on data the model already trained on makes the score better. Measuring the wrong thing usually makes the score better.
So a team reviewing ML code the ordinary way approves beautiful, readable, well-tested code. Then the model fails the moment it meets real users.
The review needs four extra questions that no linter and no test framework asks for you.
How it works
THE FOUR QUESTIONS
1. Did any of the same rows end up in both the practice
pile and the exam pile?
2. Does any single column already contain the answer?
(a column that is only known AFTER the thing you predict)
3. Is the score suspiciously good for this problem?
4. If you scramble the answers and retrain, does the score
drop to chance? If not, the scoring itself is broken.Question 4 is the one people find strange, and it is the most powerful. A model trained on scrambled answers has nothing real to learn, so it must score like a coin-toss. If it does not, the measurement is broken and every other number on the page is meaningless.
A real example you have seen
A weighing scale at a shop that reads 200 grams heavy. Every purchase looks fine. The numbers are plausible, the arithmetic is right, nobody argues.
The only way to find it is to put a known weight on the scale and check what it says. Question 4 is putting a known weight — scrambled answers, expected score 50% — on your evaluation.
Remember this
- ML bugs usually make the score better, so a good score is not evidence of correct code.
- Review the split, the columns, the score and the scoring — in that order.
- The scrambled-answer test checks your measuring instrument, and almost nobody runs it.
What to learn next
- Target leakage — the bug check 2 was built to find.
- Too good to be true — what to do when check 3 fires.
- Reproducing a failure you only see in production — the review that happens after the model ships.
Developer — Code and libraries.
Setup
pip install scikit-learnVerified with scikit-learn 1.7.2, numpy 1.26.4, Python 3.10, CPU. Runs in about five seconds. This is a script you can paste into any review, unchanged.
The four checks, as code
The dataset below has one planted leak, of the exact kind that reaches production regularly: a column recorded after the outcome it is supposed to predict.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(0)
n = 600
X = rng.normal(size=(n, 6))
y = (X[:, 0] + 0.5*X[:, 1] + rng.normal(0, 0.8, n) > 0).astype(int)
X = np.column_stack([X, y * 0.9 + rng.normal(0, 0.3, n)]) # column 6 leaks the label
names = [f"f{i}" for i in range(6)] + ["days_since_outcome"]
def review(X, y, names):
Xtr, Xte, ytr, yte, itr, ite = train_test_split(
X, y, np.arange(len(y)), test_size=0.3, random_state=0)
print("check 1 - do any rows appear in both splits?")
print(f" overlap: {len(set(itr) & set(ite))} rows")
print("check 2 - can one feature alone predict the label?")
for j, name in enumerate(names):
auc = roc_auc_score(y, X[:, j])
flag = " <-- LEAK?" if max(auc, 1-auc) > 0.90 else ""
print(f" {name:20s} single-feature AUC {max(auc, 1-auc):.3f}{flag}")
m = RandomForestClassifier(n_estimators=60, random_state=0).fit(Xtr, ytr)
real = roc_auc_score(yte, m.predict_proba(Xte)[:, 1])
print(f"check 3 - real score: test AUC {real:.3f}")
shuffled = rng.permutation(ytr) # the known weight on the scale
m2 = RandomForestClassifier(n_estimators=60, random_state=0).fit(Xtr, shuffled)
fake = roc_auc_score(yte, m2.predict_proba(Xte)[:, 1])
print(f"check 4 - shuffled-label score: test AUC {fake:.3f} (must be near 0.5)")
review(X, y, names)check 1 - do any rows appear in both splits?
overlap: 0 rows
check 2 - can one feature alone predict the label?
f0 single-feature AUC 0.829
f1 single-feature AUC 0.655
f2 single-feature AUC 0.532
f3 single-feature AUC 0.502
f4 single-feature AUC 0.536
f5 single-feature AUC 0.512
days_since_outcome single-feature AUC 0.985 <-- LEAK?
check 3 - real score: test AUC 0.987
check 4 - shuffled-label score: test AUC 0.475 (must be near 0.5)Four checks, five seconds, and the review is already better than most.
Reading the four answers
Check 1 passed, and that is worth knowing. Zero overlap means the split itself is clean. When it is not zero, nothing else on the page matters — see train-test contamination.
Check 2 found the bug. days_since_outcome scores 0.985 on its own. No single ordinary feature does that. The name is the second clue: a column measured in "days since the outcome" cannot exist at prediction time, because the outcome has not happened yet. This is the most common leak in industry, and target leakage is the full treatment.
Check 3 is a smell, not a verdict. An AUC of 0.987 on a noisy behavioural problem should stop a reviewer cold. It is not proof of a bug — some problems really are easy — but it moves the burden of proof onto the author. Too good to be true lists the questions to ask next.
Check 4 passed, and passing is the useful outcome here. 0.475 is close to the 0.5 expected from scrambled answers, so the evaluation harness is sound: the split, the metric and the label alignment are wired correctly. That matters, because it tells the reviewer the 0.987 is a real leak in the data rather than a broken measurement. Two very different bugs, distinguished by one extra fit.
The review checklist that follows from this
Ask these in the pull request, in this order. The first three take under a minute each.
- Where does the split happen, and is it the first thing that touches the data? Any preprocessing fitted before the split leaks test statistics into training (preprocessing leakage).
- Is the split random when it should be grouped or time-ordered? One customer with 40 rows across both sides is group leakage; a random split on a forecasting problem is temporal leakage. A random
train_test_splitis the default and it is wrong for most real datasets. - For every feature: would this value exist, with this value, at the moment of prediction? Ask it column by column. This single question catches more production failures than every other item combined.
- Is the metric the one the decision uses? Accuracy on a 2% positive class is a well-dressed lie (macro, micro and weighted averaging).
- Is the test set used anywhere except the final number? Threshold picking, feature selection and early stopping on the test set are all test-set overfitting.
- Are the numbers in the description reproducible from this branch? Ask for the seed and the command. If neither exists, the result is an anecdote (reproducing your own result).
- Then review it as ordinary code — naming, dead code, error handling, tests. This part still matters; it is not where the expensive bugs are.
How to say it
Review comments on ML work go wrong in a predictable way, so two conventions help.
Ask about the data, not about the author. "What is days_since_outcome measured from?" is a question anybody can answer without defensiveness. "This is leaking" is a verdict that invites an argument before the facts are in.
Separate blocking findings from suggestions, explicitly. A leak blocks. A variable name does not. Marking every comment with one of the two words saves an entire round trip, and it stops a genuine leak from being lost in a list of eleven style notes.
Common mistakes
Approving because the tests pass. Unit tests check that code does what it was written to do. Every bug in this lesson is code doing exactly what it was written to do.
Reviewing the diff instead of the pipeline. A three-line diff can leak, because the leak lives in the interaction between the new line and the split that happens sixty lines above it. Read the data's path from load to metric, every time.
Skipping the review because the score improved. An improvement is the strongest reason to review, not the weakest. In ML the direction of a bug's effect is usually upward.
Running the checks and not saving them. Paste the four checks into tests/ so the next pull request gets them for free. A review habit that depends on the reviewer remembering is a habit that ends on a busy day — the same argument data validation makes about the gate.
Try it yourself
Make check 4 fail on purpose. In review, replace roc_auc_score(yte, ...) with roc_auc_score(ytr[:len(yte)], ...) so the scorer compares test predictions against training labels. Notice that check 3 still returns a number that looks plausible, and only check 4 exposes it. That is what a broken scale looks like from the inside.
What to learn next
- Target leakage — the bug check 2 was built to find.
- Too good to be true — what to do when check 3 fires.
- Reproducing a failure you only see in production — the review that happens after the model ships.
Researcher — Mathematics and papers.
Why the error distribution is asymmetric
The empirical claim underneath this lesson — that ML defects disproportionately inflate reported performance — has a selection-theoretic explanation rather than a mysterious one. Errors that lower the score are detected and removed during development, because the author keeps working until the number is acceptable. Errors that raise the score terminate the search. The observed defect population in shipped ML code is therefore filtered toward the score-inflating subset, independent of the underlying rate of each kind of mistake. This is the same survivorship mechanism that makes test-set overfitting accumulate over a project's lifetime, and it implies that review effort should be allocated asymmetrically toward the checks that can only fire on inflation.
Kapoor and Narayanan (2023), Leakage and the Reproducibility Crisis in Machine-Learning-Based Science (Patterns), survey 294 papers across 17 fields and find leakage-driven overoptimism in all of them, proposing a taxonomy — no clean separation between train and test, feature legitimacy failures, and test-set-dependent selection — that maps onto checks 1, 2 and 5 of the list above. Their proposed remedy, model info sheets, is a review artefact: a structured set of questions the author answers in advance, which is the same intervention as a checklist attached to a pull request.
The permutation test as an instrument check
Check 4 is a permutation test in the sense of Ojala and Garriga (2010), Permutation Tests for Studying Classifier Performance (JMLR). Under the null hypothesis that features and labels are independent, permuting $y$ within the training set and refitting yields a score distribution centred on chance; the observed score's position in that distribution is an exact $p$-value with no distributional assumptions. Two distinct nulls are available and mean different things: permuting labels globally tests feature-label dependence, while permuting within class-stratified folds tests dependence conditional on the marginal distribution.
Used as a review tool with a single permutation, it is not a hypothesis test but an instrument check — a smoke test for the evaluation harness. Its false-negative rate is high (one draw), and its false-positive rate is negligible for the failure modes it targets: a non-chance score under permuted labels can only arise from label misalignment, index misalignment between predictions and targets, a metric computed against the wrong array, or contamination between the fitted object and the evaluation set. Each of those is a wiring defect, and none of them can be found by reading the diff. sklearn.model_selection.permutation_test_score runs the full version when a real $p$-value is wanted.
What review can and cannot buy
The software-engineering literature is well established on the general case. Fagan (1976) introduced formal inspection with defect-removal rates that remain unmatched by testing alone; Bacchelli and Bird (2013), Expectations, Outcomes, and Challenges of Modern Code Review (ICSE), found that in practice modern review delivers less defect-finding than expected and more knowledge transfer and design discussion, with reviewer understanding of the change's context the dominant predictor of usefulness. That finding transfers sharply to ML: a reviewer who does not know what days_since_outcome is measured from cannot evaluate the change at any level of diligence, which is why the review question is about the data's provenance and not about the code.
This bounds what review can achieve and identifies what must be automated instead. Checks with a mechanical decision rule — split overlap, single-feature AUC scans, permuted-label scores, schema and range assertions — belong in CI, where they run without a human remembering (testing and CI for ML). Checks requiring domain knowledge — feature legitimacy at prediction time, whether the metric matches the decision, whether the population matches deployment — are irreducibly human and are where reviewer attention should be spent. Breck et al. (2017), The ML Test Score, organise this split explicitly, and report that data-facing tests are simultaneously the least implemented category and the one whose absence dominates production incident lists.
A final structural note: the CACE property (Sculley et al., 2015) means a small diff has unbounded blast radius in a training pipeline, so the ordinary heuristic "small diff, quick review" is actively harmful here. Review scope should follow the data path, not the line count.
What to learn next
- Target leakage — the bug check 2 was built to find.
- Too good to be true — what to do when check 3 fires.
- Reproducing a failure you only see in production — the review that happens after the model ships.