Skip to content
AI360XpertBeta

ML Testing Strategies

Green code tests do not mean a safe model. Test the pipeline code, the incoming data, and the trained behavior separately, because each one fails in its own way.

One release passes code tests yet scores 0.95 overall while a slice of 80 rows scores 0.60, so only three separate gates catch the failure before deploy
One release passes code tests yet scores 0.95 overall while a slice of 80 rows scores 0.60, so only three separate gates catch the failure before deploy

Why Does This Exist?

A pipeline passes every unit test and still ships a broken model. The scaling function is correct, the pipeline stages connect, and the deploy succeeds. Then support tickets arrive: the model rejects a slice of users it used to accept. Nothing in the code changed, because the failure was never in the code. The incoming age field started arriving in months instead of years, or accuracy on one minority slice quietly fell to 0.60 while the global number stayed a comfortable 0.95.

ML testing exists because an ML system is three things that break independently: code, data, and learned behavior. Code tests cannot see a shifted column. Data checks cannot see a biased decision boundary. Aggregate accuracy cannot see a failing slice. Each layer needs its own gate, and a release passes only when all three pass. If the lifecycle is new, read the MLOps overview first.

This page covers pre-deploy gates: pipeline unit tests, input validation, and behavior checks. Watching those same distributions after release is data quality monitoring and model monitoring. Catching shifts in live traffic is model drift detection.

Basics first: a slice is a named subset you grade separately, like applicants under 25 or transactions above 10,000. Global accuracy is the average over everyone, which lets a large healthy majority hide a small failing slice. Everything below grades slices alongside the global number.

Think of It Like This

The restaurant with three inspections

A restaurant passes three separate inspections before opening night. The building inspector checks wiring and plumbing, which is code testing. The delivery inspector checks fish temperature and expiry dates, which is data testing. The tasting inspector orders the actual dishes, which is model testing.

A perfect kitchen with spoiled fish still poisons guests. Fresh fish in a kitchen with no heat still serves raw food. Both passing with a menu that fails every vegetarian tasting still loses the same customers nightly. No inspection substitutes for another, and the doors open only when all three sign off.

Where it stops: inspections pass dishes served that night, not next month suppliers. Pre-deploy tests say nothing about live shift, which is why monitoring keeps watching after the doors open.

How It Actually Works

Three gates run in order on every candidate release. Call the pipeline code CC, which means transforms plus wiring. Call the input batch DD, which means one dataframe with a schema and distributions. Call the trained model MM, which means weights plus a decision rule. Gate each one before it can hide behind the next.

Gate one: code behaves on fixed fixtures

Unit-test transforms with hand-computed fixtures. A scaler fitted on training data must map 42.32 to the same output every run, and an unknown category must raise rather than silently become zero. Integration tests push 50 mock rows through ingest to featurize to train to export and assert shapes match: 50 in, 50 out, no null prediction. These tests run in seconds and catch wiring breaks before any data question arises. Templates that provision this pipeline get the same treatment as infrastructure as code.

Gate two: data matches the contract

Validate schema first: expected columns, types, and bounds. Age stays within 18 to 120, income stays at or above 0, status stays inside the allowed set. Then compare distributions against the training baseline: means within tolerance, null rate below ceiling, category shares near history. A batch with 22 percent nulls in a column that trained at 1 percent never reaches training, no matter how green the code gate is.

Gate three: behavior holds on slices and probes

Grade accuracy per slice, not just globally. Add invariance probes: swapping a name that should not matter must not flip the decision. Add directional probes: raising income while holding everything else fixed should not lower approval probability. Block the release when any probe or any guarded slice fails, even when the global number passes.

Worked example

A candidate scores 950 correct of 1,000 rows, so global accuracy is 950/1,000=0.95950 / 1,000 = 0.95. Split it: the majority slice scores 902 of 920, which is 902/920=0.9804902 / 920 = 0.9804, while the minority slice of 80 rows scores 48 of 80, which is 48/80=0.6048 / 80 = 0.60. Check the sum: 902+48=950902 + 48 = 950 and 920+80=1,000920 + 80 = 1,000, so the global 0.95 is exactly the blend that hides the failing 0.60. The code gate passed, the schema gate passed, and only the slice gate blocks this release.

Code

The point is the hidden slice, computed with stdlib only. Same totals as the worked example, plus a schema check and two behavior probes.

import statistics
correct_major, n_major = 902, 920correct_minor, n_minor = 48, 80global_acc = (correct_major + correct_minor) / (n_major + n_minor)print(round(global_acc, 4))  # -> 0.95print(round(correct_major / n_major, 4))  # -> 0.9804print(round(correct_minor / n_minor, 4))  # -> 0.6

def schema_ok(rows: list[dict]) -> bool:    for r in rows:        if not (18 <= r["age"] <= 120):            return False        if r["income"] < 0:            return False        if r["status"] not in {"active", "inactive"}:            return False    return True

batch = [    {"age": 34, "income": 52.0, "status": "active"},    {"age": 17, "income": 40.0, "status": "active"},]print(schema_ok(batch))  # -> False
train_mean = statistics.mean([41.2, 42.5, 43.1, 42.0, 42.8])live_mean = statistics.mean([41.9, 42.3, 42.6, 42.1, 42.4])print(round(train_mean, 2), round(live_mean, 2))  # -> 42.32 42.26

Global 0.95 passes, minority 0.60 fails, one underage row fails the schema, and the two means 42.32 and 42.26 agree. Three gates, three verdicts, one blocked release.

Watch Out For

Shipping on a global metric alone

The symptom is a release with 0.95 accuracy and a support queue from one segment. The majority slice at 0.9804 carries the average while the minority slice at 0.60 pays for it. Dashboards stay green because they show one number.

Guard each slice that matters with its own floor before deploy, and report the worst guarded slice beside the global number. When a slice has too few rows to grade, say so and gather more rather than averaging over the gap.

Letting training rows leak into the test split

The symptom is perfect gates and a dead model: every probe passes in CI, then production accuracy collapses. A timestamp or ID join duplicated rows across the split, so the model was graded on answers it had already seen.

Test the splitter itself: assert zero ID overlap between train and test, split by time when data has time, and re-run the slice check after de-duplication. A behavior suite over leaked data proves nothing.

The Quick Version

  • Test code, data, and behavior separately, because each fails in ways the others cannot see.
  • Code gates use fixed fixtures and mock batches to catch wiring and transform breaks.
  • Data gates enforce schema bounds and compare means, null rates, and category shares to baseline.
  • Behavior gates grade guarded slices plus invariance and directional probes on the trained model.
  • A 0.95 global score can hide a 0.60 slice when 902 of 920 plus 48 of 80 blend into 950 of 1,000.
  • Block the release when any gate fails, even when the global number passes.
  • Leaked splits make every gate lie, so assert zero ID overlap before trusting a score.