ML Code That Survives

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.

On this page 5
  1. Why it exists
  2. How it works
  3. A real example you have seen
  4. Remember this
  5. What to learn next

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

Developer — Code and libraries.

Setup

bash
pip install scikit-learn

Verified 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.

review_checks.py
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)
Output
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.

  1. 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).
  2. 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_split is the default and it is wrong for most real datasets.
  3. 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.
  4. Is the metric the one the decision uses? Accuracy on a 2% positive class is a well-dressed lie (macro, micro and weighted averaging).
  5. 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.
  6. 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).
  7. 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

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