Skip to main content

107 - ML-Ops: testing a model with invariants, thresholds and data

How to test a model that has no exact answer: data tests, invariants, thresholds chosen after measuring variability, and service tests.

What do you think about this article?

107 - ML-Ops: testing a model with invariants, thresholds and data
Machine Learning Engineerfedericocalo.dev

At a Glance

How to test a model that has no exact answer: data tests, invariants, thresholds chosen after measuring variability, and service tests.

Testing ordinary code is easy because there is an exact answer: 2 + 2 is 4. A machine learning model has no exact answer: it has a quality, which is measured with a number, and that number changes with the data, with the split and with the seed. The question of the test becomes a different one: "is the model still good enough, and does it behave sensibly?". In this lesson you write three families of tests (on data, on invariants, on thresholds) for a customer churn classifier, and you see how they react when you break the model on purpose. It builds on the basics of pytest.

The words of evaluation

The label is the right answer in the training data, here "the customer churned, yes or no", like the solution at the back of the exercise book. The split separates the data into one part to train the model and one to evaluate it: you evaluate on data the model has never seen, like an exam with new questions, because on the very exercises it studied the grade would only tell how well it memorized them. The seed is the number that makes what is random repeatable: given the same seed the "draw" of the split comes out identical every time. The metric is a number that summarizes the model's quality; the threshold is the minimum value of a metric below which the test fails.

The AUC (area under the ROC curve) answers "if I pick at random a customer who churned and one who stayed, with what probability does the model give a higher score to the first?": 1.0 is the perfect ordering, 0.5 is like guessing, below 0.5 is worse than chance. An invariant is a property that must always hold, whatever the exact values. A flaky test sometimes passes and sometimes fails without the code having changed, and it is worse than a red test because it teaches people to ignore red. Overfitting is when the model has learned the training data by heart and does badly on new data, and it is the reason we evaluate on separate data.

The three kinds of test in this lesson look at the model from three sides. If you break how it works on purpose, they must turn red:

The three families of tests for a model: input data, behavior invariants, metric thresholds1. Datawhat goes in2. Invariantssensible behaviors3. Thresholdsminimum qualityModelbroken on purpose = the tests must turn red

The analogy: the inspection of a bridge

Nobody inspects a bridge by checking that it is identical to another one: you inspect it by checking that it bears minimum loads (a threshold), that it does not deform in absurd ways (invariants), and that the materials are the declared ones (input data). Three different questions, three kinds of check. For a model it is the same.

The test project

Synthetic customer data with three columns: tenure (months of seniority), complaints (number of complaints), churn (1 if the customer churned). A logistic regression model. The examples were run on 2026-11-12 with Python 3.13.13, scikit-learn 1.9.1, pandas 3.1.1, NumPy 2.5.4, pytest 9.2.0, FastAPI 0.162.5, httpx 0.28.1, managed by UV (UV, environments and dependencies), twice with identical results. The data generator is part of the project and it is worth having it in front of you, because all the numbers of the tests depend on it. Names of files, functions and tests are kept as in the Italian original (for example servizio is "service"), so that the outputs match exactly:

# src/churn/data.py
import numpy as np
import pandas as pd

def make_dataset(n: int = 800, seed: int = 0) -> pd.DataFrame:
    """Synthetic customers: more complaints raise the risk, more tenure lowers it."""
    rng = np.random.default_rng(seed)
    tenure = rng.integers(1, 61, size=n)            # months of tenure, from 1 to 60
    complaints = rng.poisson(1.2, size=n)           # number of complaints
    logit = -0.2 - 0.04 * tenure + 0.8 * complaints
    p = 1 / (1 + np.exp(-logit))
    churn = (rng.random(n) < p).astype(int)
    return pd.DataFrame({"tenure": tenure, "complaints": complaints, "churn": churn})

The churn risk follows a known logistic formula (each complaint weighs 0.8, each month of tenure takes away 0.04), so we know in advance which invariants must hold; the presence of rng.random supplies the noise that makes the problem non-trivial. The model is this:

# src/churn/model.py
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

FEATURES = ["tenure", "complaints"]

def train(df: pd.DataFrame, seed: int = 0, C: float = 1.0):
    X_tr, X_te, y_tr, y_te = train_test_split(
        df[FEATURES], df["churn"], test_size=0.25,
        random_state=seed, stratify=df["churn"],
    )
    model = LogisticRegression(C=C, max_iter=500).fit(X_tr, y_tr)
    auc = roc_auc_score(y_te, model.predict_proba(X_te)[:, 1])
    return model, auc

train splits the data (25% for evaluation, with stratify so that the two classes keep the same proportion in the two parts), trains and returns the model with the AUC on the evaluation part. The seed controls the split; C is the regularization. With seed 0 the AUC is 0.7483 and the estimated coefficients are −0.042 for tenure and +0.824 for complaints, close to the true ones in the formula.

First family: tests on data

Most ML failures are born in the data before the model: a column that changes format, missing values, a label that is almost always the same. These tests are cheap and stop the problem at the source:

import pytest
from churn.data import make_dataset

@pytest.fixture(scope="module")
def df():
    return make_dataset(n=800, seed=0)

def test_nessun_valore_mancante(df):
    assert not df.isna().any().any()

def test_intervalli_plausibili(df):
    assert df["tenure"].between(1, 60).all()
    assert (df["complaints"] >= 0).all()

def test_etichetta_binaria_e_non_degenere(df):
    assert set(df["churn"].unique()) == {0, 1}
    quota = df["churn"].mean()
    assert 0.1 < quota < 0.9, f"suspicious churn rate: {quota:.2f}"

The message in the last assert is deliberate: when the test fails in a pipeline, at midnight, the message is the only explanation you have. Here the churn rate is 0.39, inside the interval. The fixture has scope="module", so the dataset is generated once per file.

Second family: invariants

An invariant is a property that must hold for any reasonable model, regardless of the exact values. It does not say "the output is 0.81"; it says "the output is a probability" or "more complaints do not lower the risk".

import pandas as pd
from churn.model import FEATURES, train

@pytest.fixture(scope="module")
def trained():
    return train(make_dataset(n=800, seed=0), seed=0)

def test_probabilita_valide(trained):
    model, _ = trained
    df = make_dataset(n=50, seed=3)
    proba = model.predict_proba(df[FEATURES])[:, 1]
    assert ((proba >= 0) & (proba <= 1)).all()

def test_piu_reclami_non_abbassano_il_rischio(trained):
    model, _ = trained
    poco = pd.DataFrame({"tenure": [12], "complaints": [0]})
    molto = pd.DataFrame({"tenure": [12], "complaints": [5]})
    assert model.predict_proba(molto)[0, 1] > model.predict_proba(poco)[0, 1]

The fixture uses scope="module" (see the lesson on pytest): the model is trained once per file, not once per test. The compromise is that the tests share the same object, so none of them must modify it. Other useful invariants are further below: the order of the input rows does not change the predictions, the saved and reloaded model produces the same predictions as the original, the same request with the same model is deterministic.

Third family: metric thresholds

Here lies the delicate point. A threshold that is too low detects nothing; too high, it makes the test fail for pure noise. To choose in a non-arbitrary way I measured first: same dataset, different splits with 10 seeds.

import numpy as np
from churn.data import make_dataset
from churn.model import train

df = make_dataset(800, seed=0)
auc = [train(df, seed=s)[1] for s in range(10)]
print(f"min {min(auc):.3f}  max {max(auc):.3f}  mean {np.mean(auc):.3f}")
# Output: min 0.737  max 0.835  mean 0.772

train(df, seed=s) repeats the training 10 times changing only the seed of the split; the AUCs are collected and their extremes and mean printed. The variability due to the split alone is about ten AUC points (from 0.737 to 0.835), a piece of information no notebook shows you if you look at a single number. The practical consequence: a single number is not a measurement, it is a sample. To decide whether one model is better than another you need to look at the distribution, use several splits (cross-validation) or a much larger evaluation set.

I then measured three deliberately broken models, with the same split: with the labels shuffled the AUC is 0.578; with the tenure feature alone it is 0.643; with the complaints feature alone it is 0.711. A threshold at 0.70 sits below the minimum observed for the healthy model (0.737) and above the first two failures, but not above the third:

def test_auc_sopra_la_soglia(trained):
    _, auc = trained
    assert auc > 0.70

With the seed fixed (0) the value is deterministic (0.7483), so the test is not flaky. The threshold does not measure "the model is excellent", it measures "the model has not broken", and only against breakages larger than its variability. To improve a model you need experiments (MLflow: tracking experiments); to notice that you broke it you need this. A higher threshold, 0.72 for example, would catch the third failure too but would leave a margin of 0.017 from the observed minimum (0.737): it is the compromise between sensitivity and false alarms, and its choice is in the hands of whoever knows the cost of a false alarm.

Checking the check: mutation

A test that never fails when it should is worse than no test, because it gives confidence. After writing them, break the model on purpose. With the six tests written so far (6 passed) I removed the complaints feature from FEATURES and reran:

FAILED tests/test_model.py::test_piu_reclami_non_abbassano_il_rischio - ValueError: The feature names should match those that were passed during fit.
FAILED tests/test_model.py::test_auc_sopra_la_soglia - assert 0.6434426229508...
2 failed, 4 passed

Two tests out of six reacted, each from a different angle (the quality dropped to 0.643; the expected input no longer exists). With the code restored, all turn green again. Then I removed tenure instead of complaints: only one test reacted, the one on feature names, while the threshold test stayed green because the AUC with complaints alone is 0.711, above 0.70. It is a useful discovery: mutation not only confirms that the tests work, it also shows which damage the tests do not see. This check takes a minute and tells you that the net under you is real, with its holes.

The AUC with numbers you can check by hand

A metric whose meaning you do not understand cannot be used as a threshold. Three cases built by hand, with four customers (two who stayed, 0, and two who churned, 1) and the scores a model gave them:

from sklearn.metrics import roc_auc_score

roc_auc_score([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9])   # 1.0  perfect ordering
roc_auc_score([0, 0, 1, 1], [0.9, 0.8, 0.2, 0.1])   # 0.0  inverted ordering
roc_auc_score([0, 1, 0, 1], [0.5, 0.5, 0.5, 0.5])   # 0.5  the model distinguishes nothing

In the first case the two who churn have scores (0.8, 0.9) higher than those who stay (0.1, 0.2): every "churns/stays" pair is ordered correctly, hence 1.0. In the second the ordering is the opposite: 0.0. In the third the model gives the same score to everyone: no pair is distinguishable, 0.5. The outputs shown are those of the run. If a library update changed the meaning of the function, you would know at once by writing these three cases as tests. A real model with AUC 0.75 sits in the middle: in about 75% of the "churns/stays" pairs it gives the higher score to the one who churns.

Other invariants worth having

Besides "the probabilities are valid" and "more complaints do not lower the risk", there are very useful checks on any saved and served model. I wrote and tried the following tests (the project's suite has reached 16 tests, all green, including the 5 of the service further below).

import joblib

def test_modello_salvato_e_ricaricato_predice_uguale(trained, tmp_path):
    model, _ = trained
    percorso = tmp_path / "modello.joblib"
    joblib.dump(model, percorso)
    ricaricato = joblib.load(percorso)
    X = make_dataset(n=30, seed=5)[FEATURES]
    assert np.allclose(model.predict_proba(X), ricaricato.predict_proba(X))

The saved and reloaded model makes the same predictions as the original: it is the test that shows that the artifact you release (Dockerfile and ML service) loads and answers as before. tmp_path is the temporary folder from the pytest lesson; np.allclose is the comparison between arrays of decimals with tolerance (the equivalent of approx). If this test fails after a scikit-learn update, you have found in advance the problem of different versions between training and serving.

def test_l_ordine_delle_righe_non_conta(trained):
    model, _ = trained
    X = make_dataset(n=30, seed=5)[FEATURES]
    mescolato = X.sample(frac=1, random_state=1)
    p1 = pd.Series(model.predict_proba(X)[:, 1], index=X.index)
    p2 = pd.Series(model.predict_proba(mescolato)[:, 1], index=mescolato.index)
    assert np.allclose(p1.sort_index(), p2.sort_index())

The order of the rows does not change the predictions: the prediction for a customer cannot depend on who precedes them in the list. X.sample(frac=1) shuffles all the rows; the predictions are compared by customer, not by position, relinking them through the index. An error of this kind (a normalization computed on the batch instead of on the single customer, for example) is subtle and very real.

def test_stesso_seed_stessa_auc():
    df = make_dataset(n=800, seed=0)
    assert train(df, seed=0)[1] == pytest.approx(train(df, seed=0)[1])

Determinism: the same seed produces the same result. If this test becomes flaky, it means there is an uncontrolled source of randomness in the code (a second generator without a seed, for example), and all the other tests with a threshold become unreliable. It is the test that guarantees the foundation of reproducibility.

More invariants: the properties the domain gives you for free

The best invariants come from knowledge of the problem. In our example, the coefficient of complaints is +0.824 and that of tenure is −0.042: more complaints raise the risk, more tenure lowers it. Two properties a domain expert would sign off immediately, and that we can turn into tests (the first is already in the lesson):

def test_piu_anzianita_non_alza_il_rischio(trained):
    model, _ = trained
    nuovo = pd.DataFrame({"tenure": [3], "complaints": [1]})
    vecchio = pd.DataFrame({"tenure": [48], "complaints": [1]})
    assert model.predict_proba(vecchio)[0, 1] <= model.predict_proba(nuovo)[0, 1]

These properties are called monotonicities, and they are valuable because they do not depend on the exact values: they survive every healthy retraining and fail when something has gone truly wrong (for example, an inverted label in the training data). For models more complex than a logistic regression (trees, networks) monotonicity is not guaranteed by the structure of the model, and verifying it with a test is even more useful.

The "golden" test: a snapshot of behavior

Sometimes you do not know exactly which property to protect, but you want to know whether something changed after a refactoring. The golden test (or snapshot) saves the predictions on a few fixed inputs and compares them with tolerance:

CASI = [(3, 4), (48, 0), (12, 2)]       # (tenure, complaints)
ATTESE = [0.9524, 0.1008, 0.725]        # saved from a verified run

def test_predizioni_golden(trained):
    model, _ = trained
    X = pd.DataFrame(CASI, columns=FEATURES)
    assert model.predict_proba(X)[:, 1] == pytest.approx(ATTESE, abs=1e-3)

The values are those printed by a first run of the fixture's model (trained with C=1.0 and seed 0) on the three customers; with a tolerance of 0.001 the test passes. The test is useful as long as the model does not have to change: if you retrain and the numbers change, the test turns red, and the question is "is it an intended change?". If so, you update the expected values consciously, with a commit that says why; if not, you have found a regression. Its limit is fragility: every retraining shifts it, and so does a library update that changes the last digits. It is therefore used for the surrounding code (data preparation, output format) with a frozen model, not for the training process.

Testing by subgroup: the average hides

A global AUC of 0.748 can hide important differences between groups of customers. Evaluating by slices is an elementary and often decisive practice. On our model (200 evaluation customers), I computed the AUC for four slices, with a 95% interval obtained by resampling the customers of that slice 1,000 times (bootstrap, fixed seed):

SliceCustomersChurnersAUC95% interval
Global200780.7480.68 - 0.82
Tenure < 24 months68400.7420.61 - 0.86
Tenure >= 24 months132380.6700.57 - 0.78
Zero complaints59120.5910.39 - 0.76
Two or more complaints64410.6940.56 - 0.83

How to read it. The differences between slices (from 0.59 to 0.74) look large, but look at the intervals beside them: they all overlap, and the one for the "zero complaints" slice (only 12 churners) goes from 0.39 to 0.76, that is, from worse than chance to a good model. This is the trap of slice evaluation: dividing the data reduces the number of cases in each group and therefore the precision of each estimate. The prudent rule is not to draw conclusions from a slice with a few dozen cases, and to use slices to look for big problems (a slice with an AUC of 0.55 with a narrow interval, while the others sit at 0.75, would be a signal), not to pronounce on small differences. For models that decide about people, evaluation by group is also a fairness check: that topic needs more care than an automatic test can give, but the starting point is always this, looking at the metric by group and not only on average.

The flaky test: why it is a problem, how to hunt it down

A test that sometimes passes and sometimes does not teaches the team to rerun until it turns green and to ignore reds: after a few weeks, nobody looks at a real red. Typical causes in ML projects: seeds not fixed; a threshold too close to the typical value (that is why you choose below the observed minimum, not near the mean); non-deterministic data order (sets, dicts of different origin); dependencies on the time of day or the network. To hunt it down: repeat the test many times and see whether a red appears.

for i in $(seq 20); do uv run pytest -q tests/test_model.py || break; done
# in the test: 20 runs out of 20 green (green count with a variant of the loop)

The loop stops at the first failure (|| break); twenty greens out of twenty do not prove that the test is stable but, with a fixed seed, they are consistent with the absence of uncontrolled randomness. A flaky test must be fixed or removed: it must not be left there "because sometimes it passes".

Why overfitting is not tested with unit tests

A frequent intuition is "if the model does well on the test set it is fine". But the lesson's test set is the same one you use to decide the threshold, and after months of tweaking you end up adapting the model to it (an indirect overfitting). The defense is methodological: keep a third set, for final validation, that you look at only at the end and not to guide choices. The automatic tests of this lesson protect from regressions (something breaks), not from the illusion of a model better than it is. They are two different problems and need two tools.

The why: a model can break in three different places

When a model "does badly", the first useful question is not how much but where. The failures of a machine learning system fall into three families, and each has its own kind of test:

  1. A data failure: a column has changed format, a source has stopped updating, a special value (like -999) has ended up among the real numbers. The code is correct and the model is correct: what goes in is wrong. Test: on the data.
  2. A code failure: a refactoring moved an operation, an ordering changed, a normalization uses the wrong data. The model has not worsened "in itself": the program around it does something different. Test: on behavior (invariants) and regression.
  3. A concept failure: the code and the data are the usual ones, but the world has changed and the model no longer describes reality. It is drift (discussed in the module introduction), which unit tests do not see because nothing is broken in the software. Test: monitoring in production, not unit tests.

The first two families are covered by this lesson's suite. The third requires another tool, and it is right to know it: a green test does not say the model is good for today's world, it says it has not broken compared to yesterday.

Tests on the service: from the object to the HTTP request

So far we tested the model as a Python object. But what the user uses is the service (Dockerfile and ML service): if the model works and the endpoint does not, the product does not work. FastAPI offers a test client that simulates HTTP requests without starting a real server (it requires the httpx library). The service is a reduced version of the one in the Dockerfile lesson: it loads the model from MODEL_PATH in the lifespan and validates the input with Pydantic.

# src/churn/servizio.py (reduced)
import os
from contextlib import asynccontextmanager
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field

state = {}

@asynccontextmanager
async def lifespan(app):
    state["model"] = joblib.load(os.environ.get("MODEL_PATH", "modello.joblib"))
    yield
    state.clear()

app = FastAPI(lifespan=lifespan)

class Customer(BaseModel):
    tenure: int = Field(ge=0, le=600)
    complaints: int = Field(ge=0, le=100)

@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": "model" in state}

@app.post("/predict")
def predict(c: Customer):
    p = state["model"].predict_proba(pd.DataFrame([c.model_dump()]))[0, 1]
    return {"churn_probability": round(float(p), 4)}

The tests do not start a server: the fixture saves a trained model in a temporary folder, sets MODEL_PATH and opens the client.

import joblib
import pytest
from fastapi.testclient import TestClient
from churn.data import make_dataset
from churn.model import train
from churn import servizio

@pytest.fixture(scope="module")
def client(tmp_path_factory):
    percorso = tmp_path_factory.mktemp("modello") / "modello.joblib"
    joblib.dump(train(make_dataset(n=800, seed=0), seed=0)[0], percorso)
    mp = pytest.MonkeyPatch()
    mp.setenv("MODEL_PATH", str(percorso))
    with TestClient(servizio.app) as c:     # the "with" runs the lifespan: it loads the model
        yield c
    mp.undo()

def test_health_dice_che_il_modello_e_caricato(client):
    r = client.get("/health")
    assert r.status_code == 200 and r.json()["model_loaded"] is True

def test_predict_restituisce_una_probabilita(client):
    r = client.post("/predict", json={"tenure": 3, "complaints": 4})
    assert r.status_code == 200
    assert 0.0 <= r.json()["churn_probability"] <= 1.0

@pytest.mark.parametrize("corpo", [{"tenure": -1, "complaints": 0}, {"tenure": 3}, {"tenure": "x", "complaints": 1}])
def test_input_invalido_e_respinto_con_422(client, corpo):
    assert client.post("/predict", json=corpo).status_code == 422

Result: the five tests pass (16 in the whole suite). The fixture starts the application with with TestClient(...), and the with is essential because it runs the startup phase (lifespan) that loads the model: without it, /health answers {'status': 'ok', 'model_loaded': False} (verified). The test on /health verifies the contract that ties the service to its health check; the one on /predict checks that the output is a probability; the parametrized test verifies three invalid inputs (negative value, missing field, wrong type) and that all of them produce 422, the HTTP code for "unprocessable request".

To see whether the test is worth anything, I removed ge=0 from the tenure field: the case with tenure: -1 turned red (1 failed, 15 passed). It is a light and valuable integration test: it is the net that warns you if a change in the input schema breaks the callers. pytest also showed a Starlette deprecation warning about using httpx with the test client: it is not an error, but it indicates that this mechanism will change in future versions.

The real case: the evaluation "gates" of an ML service

This site's ML service has the same structure, on a larger scale. In its pyproject.toml the pytest markers declare families of evaluation tests with different roles: eval_gate for the code-based evaluation dimensions that block the build; judge for those in which a language model acts as a judge, which remain report only until they are calibrated; rpg_eval for those to be run by hand before the deploy (verified by reading the file). It is a sensible distinction: a check that is not yet reliable must not be able to block a release, but must be measured in order to become one.

A second detail, from the same file: a comment reports that the baseline coverage, measured locally without the external services, is 57% with 33 infrastructure tests failing because the services are not there (a historical measurement from the comment, not re-measured here). The instructive side is that some tests depend on the environment, and a good project says which, instead of making you believe the suite is always green. The criterion is not an absolute zero of red tests, it is not getting worse compared to before the change.

How much coverage is needed

The percentage of lines executed by the tests (uv run pytest --cov, with the pytest-cov plugin) tells you what has never been executed, not whether what is executed is well verified. A test without assert raises the coverage and protects nothing. Use it as a map of blind zones, not as a goal to inflate.

Latency is a requirement: a speed test

A service can be correct and unusable because it is slow. I measured the lesson's model on my machine in four runs, each with 2,000 averaged predict_proba calls: between about 0.6 and 1.3 ms for a single prediction and between about 0.7 and 1.5 ms for a batch of 1,000 rows. The values depend on the computer and the moment, and are not a promise. Two readings. For this lightweight model the cost is dominated by the fixed overhead of the call, not by the number of rows: a thousand rows cost as much as one, so serving a batch is far more efficient than serving a thousand single requests. And a latency test makes sense only with a wide margin (for example "under 50 ms" for a model that takes about 1 ms), otherwise it becomes a flaky test that fails when the machine is loaded. It is used to catch gross regressions (someone replaced a lightweight model with one 100 times heavier), not to measure to a tenth of a millisecond.

Traps

A threshold chosen by eye is the first: measure the variability across seeds and splits first, then decide. The test on the production dataset is the second: it is slow, not repeatable and depends on data that changes; use small, synthetic or fixed data. Fixing only the model's seed and not that of the split or of the data leaves the outcome unstable. Tests on exact prediction values (== 0.8123) break at every library update without anything being wrong, and it is better to prefer inequalities and properties. Finally, slow tests in the fast loop must be marked and separated, as seen in the lesson on pytest.

What not to test with pytest

Some questions do not lend themselves to an automatic test, and it is healthy to recognize it. "Is the model useful for the business?" depends on the cost of an error, the benefit of a correct prediction and the alternative, and is evaluated with the people who use the result and measured in production. "Are the explanations sensible?" is a human judgment that a threshold does not replace: a language model acting as a judge can support the review but, as the configuration of this site's ML service says, it stays report only until calibrated (the judge marker). "Is the model fair?" has in slice tests a beginning, not an answer. "Is the model getting worse in production?" requires continuous monitoring, not a suite run at release time; the series on drift detection and automated retraining covers it.

Knowing how to say what a test does not cover is part of its quality. A model card that lists the tests run and the known limits does more for trust than a thousand unexplained assertions.

Try it yourself: four guided exercises

Exercise 1: measure the variability before choosing the threshold

With the churn project (or a model of your own), train the same model on ten different split seeds and print the minimum, maximum and mean of your metric (the script is in the lesson). You should see a wide interval (in my test, AUC from 0.737 to 0.835, mean 0.772). Choose a threshold below the observed minimum and above the value of a deliberately broken model. What you learn: that a test's threshold is measured, not guessed.

Exercise 2: break the model on purpose

With the lesson's tests green (6 passed in the base suite), remove a feature from FEATURES and rerun. You should see at least two red tests reacting from different angles if you remove complaints (in my test: the AUC threshold, with 0.643, and the invariant on complaints, because of mismatched column names), and only one if you remove tenure. Restore the code and check that everything goes green again. Then shuffle the labels of the data and watch the AUC fall (in my test 0.578, on a single permutation: the AUC with random labels oscillates around 0.5). What you learn: to distrust a test never seen failing, and to discover which damage your test does not see.

Exercise 3: an evaluation by slices

Compute the AUC on the evaluation set separately for two bands of a feature of yours (for example tenure below and above 24 months), noting for each how many cases there are and how many positives. You should see numbers different from the global one, and slices with a few dozen positives (in my test, from 12 to 41). Before concluding that a slice does "worse", repeat the computation with other split seeds or resample the slice: does the difference hold? What you learn: that dividing the data reduces the precision of each estimate, and that slices serve to look for big problems.

Exercise 4: the test on the service

Given a FastAPI service with /health and /predict (the one from the Dockerfile lesson), write with TestClient three tests: health with the model loaded, a valid prediction in [0, 1], an invalid input with code 422. Remember the with TestClient(app) as c: without it, the model is not loaded. Run them with uv run pytest after uv add --dev httpx. You should see all green (in my test, 5 passed with three parametrized invalid inputs). Then break something (remove the ge=0 from the input model) and watch which test turns red. What you learn: that the service's contract is tested like any other behavior.

If you want an extra exercise: write the golden test with the predictions of three customers and try to retrain with a different parameter. It should turn red, and that red is the test doing its job: it forces you to decide whether the change is intended.

What you can do now

You can tell the three families of tests for a model apart: data (what goes in), invariants (sensible behaviors) and thresholds (minimum quality). You can choose a threshold after measuring the variability across splits, knowing that it serves to detect a broken model and not to certify its excellence. You can break the model on purpose to check that the tests react, and read what the mutation tells you about the damage they do not see. You know that in real projects there are tests that depend on the environment, and that they must be told apart and declared. Next lesson: environment variables and secrets.

The same series also includes ML-Ops: Git, the time machine for your project, ML-Ops: GitHub, remotes, protected branches and pull requests, ML-Ops: model registry, versions, aliases and promoting a model and ML-Ops: deployment, where the model runs and how to choose.

Sources

  • pytest documentation - fixtures, scope, approx, tmp_path, consulted on 2026-11-12
  • Model evaluation in scikit-learn - the definition and use of the AUC (roc_auc_score), consulted on 2026-11-12
  • FastAPI: testing the application - TestClient and the with to run the lifespan, consulted on 2026-11-12
  • pytest-cov documentation - the coverage mentioned in the dedicated section, consulted on 2026-11-12
  • The file ml-service/pyproject.toml in the site's repository - the eval_gate, judge, rpg_eval markers and the comment on coverage (direct reading, 2026-11-12)

All the executable blocks (Python 3.13.13, versions listed above, 2026-11-12) were run twice with identical results, times excluded, which are reported as ranges. The data generator and the values derived from it (AUC, coefficients, churn rate) are synthetic and concern only this example. The numbers in this lesson do not match those of other lessons in the module written with a different generator. Not run: the comparison with pytest-cov in the coverage section, which is described and not shown with an output.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects