, ,

Pipelines and CI/CD for Machine Learning on Infra Data (Infra to Data Science Series, Part 19)

A pipeline and a CI gate that refuse to promote a model unless it clears a metric on a time aware split, built on the incident classifier from earlier parts, with the real failures that break each stage.

Infra to Data Science Series · Part 19 of 26

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.

Who this is for: An infrastructure engineer or SRE who has trained, served and monitored the incident classifier across Parts 13 to 18 and still promotes new versions by hand. You already write CI for application code. Terms on first use: a pipeline is an ordered, rerunnable sequence of steps that turns raw data into a validated model; continuous integration, CI, runs automated checks on every change before it merges; continuous delivery, CD, promotes an artifact automatically once those checks pass; a gate is a check that blocks promotion when a number falls below a line you set in advance.

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.

DimensionApplication CIMachine learning CI
What changescode onlycode and data both
Pass conditiontests are greentests green and a metric clears a line
Main failurea broken functiona quietly worse model
First guardunit testdata validation on inputs
Deploy triggertests passgate passes on held out data
flowchart LR
  D[Raw metrics CSV] --> V[Validate schema]
  V --> T[Train pipeline]
  T --> E[Evaluate on time split]
  E --> G{AUC over gate}
  G -->|yes| R[Register and promote]
  G -->|no| B[Fail the build]
One linear pipeline, one decision. Everything left of the diamond is automation you already write; the diamond is the gate that makes promotion a decision instead of a habit.

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)
War story: A retrain job I put on a nightly cron had no gate. One night the upstream feature job wrote a column of nulls after a schema change, the model trained on it, and cross validated AUC fell from 0.89 to 0.71. Because promotion was automatic and my dashboard watched only latency and error rate, the worse model served real clusters for nine days before an on call engineer asked why incident predictions had gone quiet. Fixing it cost forty lines of pytest that refused to promote any model scoring under 0.85 on a frozen holdout, plus a pandera schema that would have failed the null column on minute one. Both of those guards are in this part.

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.

The gate blocks one commit, promotes the restcandidate model AUC per commit against the 0.85 promotion gate0.850.890.900.880.830.870.91c1c2c3c4 blockedc5c6
Six commits, one gate. Commit four dipped to 0.83 and the build went red, so a regression that would have shipped on a Friday never reached a cluster.

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.

StageWhat it checksFailure it catches
Data validation, panderaschema, ranges, allowed valuesa unit bug, cpu at 3200
Training, pipeline.pyreproducible fit, versions loggeda non-deterministic model
Evaluation gate, pytestAUC on a time aware split vs 0.85a silent metric regression
Model load smoke testpickle loads on the CI sklearna version mismatch
Drift check from Part 18PSI on key featuresa stale training window
Register and promotegate passed, version taggedpromoting 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.

Verdict: Start with the CI you already run, GitHub Actions or its equivalent, plus a plain pipeline.py and a pytest gate. That is a single workflow file and about sixty lines of Python, and it runs where your application CI already runs. Avoid standing up Kubeflow Pipelines or Airflow for one model on day one; a scheduler and a cluster to operate is a second production system that earns its keep only once you have many models, many schedules and real branching between steps. Graduate to an orchestrator when the pipeline outgrows a single linear run, not before.

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.

Do this on Monday: Take one training CSV of your own telemetry, write pipeline.py with the make_pipeline and cross_val_score above, and add one pytest that asserts its AUC on a time ordered split clears a line you pick. Put both behind on pull_request in a workflow file. By the afternoon you will have a build that goes red the moment a model gets worse, which is the single highest leverage thing you can automate on this project.

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.

Infra to Data Science Series · Part 19 of 26
« Previous: Part 18  |  Guide  |  Next: Part 20 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading