How do you know the model running in production today is better than the one it replaced last week, was the question a platform lead put to me in a design review, and I did not have a clean answer. I had a training notebook, a saved pickle and a deploy script, and nothing in that chain would have stopped a worse model from shipping. That gap, between a model that merely runs and one that has earned its slot, is what a pipeline and a continuous integration gate close, using the same automation reflex you already bring to application code.
CI and CD for a Model, Not an App
Where the project stands: last part we watched the served classifier by hand, running KS and PSI checks against a reference window. This part folds ingest, validation, training, evaluation and that drift check into one rerunnable pipeline, then puts a gate in front of promotion. Application CI answers one question, does the code still work. A model pipeline has to answer two more, is the data still shaped the way the model expects, and is the retrained model at least as good as the one it would replace.
Same code plus different data gives you a different model, so a green build that only ran your unit tests tells you nothing about model quality. That is the first place the obvious instinct misleads an operator moving over: a passing CI run is not a passing model unless the gate scored the model on data it never trained on. At least as good also needs a definition, which is why every gate carries a number and a comparison, a metric on held out data measured against either a fixed floor or the model already in production.
| Dimension | Application CI | Machine learning CI |
|---|---|---|
| What changes | code only | code and data both |
| Pass condition | tests are green | tests green and a metric clears a line |
| Main failure | a broken function | a quietly worse model |
| First guard | unit test | data validation on inputs |
| Deploy trigger | tests pass | gate passes on held out data |
A Training Pipeline as Ordered, Rerunnable Steps
A pipeline is just your deploy script grown a spine, ordered steps that run the same way on your laptop and in CI. Keep it boring: a function per step, plain returns between them, and a single entry point that runs the lot. Here is the whole thing for the incident classifier, small enough to read in one sitting.
# pipeline.py tested with python 3.12, scikit-learn 1.6.1, pandas 2.2.3
import pandas as pd, joblib, sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import cross_val_score
FEATURES = ['cpu_p95', 'mem_p95', 'load1_mean']
GATE = 0.85
def load(path='features_train.csv'):
df = pd.read_csv(path)
return df[FEATURES], df['incident']
def train(X, y):
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
return model.fit(X, y)
def evaluate(model, X, y):
return cross_val_score(model, X, y, cv=5, scoring='roc_auc').mean()
if __name__ == '__main__':
X, y = load()
model = train(X, y)
auc = evaluate(model, X, y)
joblib.dump(model, 'model.joblib')
print(f'sklearn {sklearn.__version__} cv auc {auc:.3f} gate {GATE}')
print('gate', 'PASS' if auc >= GATE else 'FAIL')
sklearn 1.6.1 cv auc 0.891 gate 0.85 gate PASS
Two details earn their place. Scaling and the model live inside one make_pipeline object, so the scaler is fit only on the training rows of each fold and never sees the validation rows, which is the leak Part 14 warned about. And cross_val_score with five folds gives a mean AUC that survives a single unlucky split, a more honest number than one holdout. Read the warehouse credentials from an environment variable, never a literal in the file; once the CSV becomes a table, the load step would take os.environ[‘METRICS_DB_URL’] and nothing sensitive lands in git.
One thing this small script does not yet do is pin down randomness. LogisticRegression is deterministic here, but the moment you swap in a random forest or a gradient booster, set random_state on the estimator and log sklearn.__version__ and a data hash next to the model, or two runs on the same CSV will hand you two different models and you will not be able to tell which one is serving. Reproducibility is not a nicety in a pipeline, it is the property that lets the gate mean anything.
A Data Validation Gate With pandera
Before a single row reaches the model, check that it is shaped the way training expected. pandera lets you declare that shape once and validate against it, and it belongs as the first step of the pipeline so bad data fails fast instead of quietly training a bad model. Here is the schema for the classifier, and the first run caught a real bug.
# validate.py tested with python 3.12, pandera 0.20.4, pandas 2.2.3
import pandas as pd
import pandera.pandas as pa
schema = pa.DataFrameSchema({
'cpu_p95': pa.Column(float, pa.Check.in_range(0, 100)),
'mem_p95': pa.Column(float, pa.Check.in_range(0, 100)),
'load1_mean': pa.Column(float, pa.Check.ge(0)),
'incident': pa.Column(int, pa.Check.isin([0, 1])),
})
df = pd.read_csv('features_train.csv')
schema.validate(df)
print('validation passed', df.shape)
pandera.errors.SchemaError: Column 'cpu_p95' failed element-wise validator number 0: in_range(0, 100). failure cases: 3200.0, 2870.0, 3050.0
cpu_p95 held values like 3200 because one exporter reported cpu in millicores, not percent, and the schema caught it on the first run. Here the tutorial instinct is exactly wrong: do not widen the range to 0 through 4000 to make the red go away, because that hides a real unit bug that will wreck every downstream feature and every threshold you set. Fix it upstream, divide by the core count, then let validation pass and exit nonzero when it does not, so CI stops before training ever starts.
import sys
from pandera.errors import SchemaError
df['cpu_p95'] = df['cpu_p95'] / df['cores'] # millicores to percent, upstream fix
try:
schema.validate(df.drop(columns=['cores']), lazy=True)
except SchemaError as e:
print('DATA VALIDATION FAILED')
print(e)
sys.exit(1)
print('validation passed', df.shape)
validation passed (100000, 5)
An Evaluation Gate That Fails the Build
A gate is one test that turns a number into a red or green build, the model world take on an eval set you fix before building an LLM feature, a bar the change clears or it does not ship. Write it as a normal pytest so it runs in the CI you already have, and make it assert the metric you care about against a line you chose in advance. Here is the version most people write first, and it passes for the wrong reason.
# test_model_gate.py tested with python 3.12, scikit-learn 1.6.1, pytest 8.3.4
import pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split, cross_val_score, TimeSeriesSplit
from sklearn.metrics import roc_auc_score
FEATURES = ['cpu_p95', 'mem_p95', 'load1_mean']
GATE = 0.85
def test_gate_leaky(): # green for the wrong reason
df = pd.read_csv('features_train.csv')
X, y = df[FEATURES], df['incident']
Xs = StandardScaler().fit_transform(X)
Xtr, Xte, ytr, yte = train_test_split(Xs, y, test_size=0.3, random_state=0)
m = LogisticRegression(max_iter=1000).fit(Xtr, ytr)
auc = roc_auc_score(yte, m.predict_proba(Xte)[:, 1])
assert auc >= GATE, f'auc {auc:.3f} below gate {GATE}'
def test_gate_timeaware(): # honest, splits by time
df = pd.read_csv('features_train.csv').sort_values('window_start')
X, y = df[FEATURES], df['incident']
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
auc = cross_val_score(model, X, y, cv=TimeSeriesSplit(5), scoring='roc_auc').mean()
assert auc >= GATE, f'time-aware auc {auc:.3f} below gate {GATE}'
$ pytest -q test_model_gate.py::test_gate_leaky . [100%] 1 passed in 1.92s # reported auc 0.941
That 0.94 is a lie told by a random split. Metric windows overlap in time, so a random train_test_split drops the 10:00 window into training and the overlapping 10:05 window into test, and the model scores its own neighbours. Split by time instead, and the honest number appears.
$ pytest -q test_model_gate.py::test_gate_timeaware
F [100%]
=================================== FAILURES ===================================
______________________________ test_gate_timeaware ______________________________
> assert auc >= GATE, f'time-aware auc {auc:.3f} below gate {GATE}'
E AssertionError: time-aware auc 0.823 below gate 0.85
E assert 0.823 >= 0.85
test_model_gate.py:26: AssertionError
=========================== 1 failed in 3.14s =================================
Read against the gate, the real AUC is 0.82, under the 0.85 line, so the honest test goes red and refuses to promote a model the leaky test would have waved through. That is the gate doing its only job. Never use a random split on time ordered telemetry; reach for TimeSeriesSplit, and see the Data Science Series on leakage and cross validation for why this single mistake inflates more scores than any other. This test, test_model_gate.py, is the reference artifact of this part: forty lines that stand between a metric regression and production.
Pick the gate number from cost, not habit. A 0.85 floor on AUC is a placeholder; the line worth defending is the one where a false negative costs a missed incident and a false positive costs a wasted page, priced in your own on call hours. Set it once, write it in the repo, and move it only with a reason attached.
GitHub Actions as the Pipeline Runner
With a pipeline and a gate as plain scripts, CI is just the runner that calls them on every pull request. A single workflow file installs the pins, validates the data, runs the pipeline and then the gate, and any nonzero exit turns the build red. For the same wiring on a pure data team, the Data Science Series covers CI/CD for ML pipelines end to end.
# .github/workflows/ml-ci.yml
name: ml-ci
on:
pull_request:
branches: [main]
jobs:
gate:
runs-on: ubuntu-latest
env:
METRICS_DB_URL: ${{ secrets.METRICS_DB_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -r requirements.txt
- run: python validate.py
- run: python pipeline.py
- run: pytest -q test_model_gate.py
One failure will find you the first time you promote. A requirements.txt without pins let CI install scikit-learn 1.8, but the committed model.joblib had been trained under 1.6.1, and the serving smoke test threw on load.
InconsistentVersionWarning: Trying to unpickle estimator LogisticRegression from version 1.6.1 when using version 1.8.0. This might lead to breaking code or invalid results.
Loading a model across scikit-learn versions is unsupported and can return wrong predictions with no error at all, which is worse than a crash. Pin the exact training version in requirements.txt, and turn that warning into a hard failure in CI so a mismatch can never pass quietly.
# conftest.py, loaded by pytest before any test runs
import warnings
from sklearn.exceptions import InconsistentVersionWarning
warnings.simplefilter('error', InconsistentVersionWarning)
Two smaller choices keep this workflow honest. Skip the Python version matrix here: testing a model repo against 3.10, 3.11 and 3.12 triples the run for something that deploys on exactly one interpreter, so pin the one you serve on and spend the minutes on the gate instead. And cache pip, because a gate that takes eight minutes is a gate people learn to skip, while one under a minute is one they trust.
A Stage by Stage Failure Map
Once the pieces exist, the useful artifact is a map of which stage catches which failure, so when a build goes red you know where to look before you open a log. Each row below is a stage that already exists as a script above.
| Stage | What it checks | Failure it catches |
|---|---|---|
| Data validation, pandera | schema, ranges, allowed values | a unit bug, cpu at 3200 |
| Training, pipeline.py | reproducible fit, versions logged | a non-deterministic model |
| Evaluation gate, pytest | AUC on a time aware split vs 0.85 | a silent metric regression |
| Model load smoke test | pickle loads on the CI sklearn | a version mismatch |
| Drift check from Part 18 | PSI on key features | a stale training window |
| Register and promote | gate passed, version tagged | promoting an untested model |
Pin this map next to the pipeline. Every failure in the right column is one I have shipped at least once, and each maps to a single stage you can open first. Together with the gate test, this map is what turns a pile of scripts into a promotion process you can defend in a review.
Start With a Gate, Not an Orchestrator
None of this is a new discipline for you. You already gate merges on tests, pin dependencies, and refuse to ship code that fails a check. A model pipeline points that same habit at data and at a metric, and the one genuinely new idea is that your test asserts a number from held out data rather than a boolean from a unit test. Build the gate first; the orchestration can wait until you feel its absence.
Next part gives every model this pipeline promotes a home, an experiment tracker and a model registry, so a run you gate is also a run you can find again, compare against its predecessor, and roll back when it turns out worse.
References
- scikit-learn cross_val_score, evaluating a model by cross validation
- scikit-learn InconsistentVersionWarning, unpickling across versions
- pandera, DataFrame schemas and validation
- GitHub Actions, building and testing Python
- Data Science Series, CI/CD for Machine Learning Pipelines
- AI Engineering Series, Build the Eval Set Before the Feature


DrJha