MLOps · lesson 6 of 8
MLOps CI/CD for Machine Learning
Automate ML workflows with GitHub Actions — test data quality, validate models, and deploy only when performance thresholds are met.
By SkillByExample · Updated
Real-World Scenario
A fintech ML team deploys a credit scoring model. Before CI/CD, every deployment was manual: a data scientist ran a notebook, emailed results to an engineer, who then deployed via SSH. One night a corrupted training batch went unnoticed, and a degraded model ran in production for 6 hours. After implementing ML CI/CD, data quality checks catch schema errors, model gates block any model below the performance threshold, and the entire pipeline runs on every push.
ML Pipeline Structure
Push to main
↓
1. Data validation — schema check, null rates, distribution tests
↓
2. Model training — reproducible training script
↓
3. Model evaluation — metrics on holdout set
↓
4. Model gating — compare vs baseline; fail if below threshold
↓
5. Model registry — register version in MLflow
↓
6. Deployment — serve new version (blue/green or canary)
GitHub Actions Workflow
# .github/workflows/ml-pipeline.yml
name: ML Training Pipeline
on:
push:
branches: [main]
paths:
- 'src/**'
- 'data/**'
- 'requirements.txt'
jobs:
validate-data:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- name: Validate data quality
run: python scripts/validate_data.py
env:
DATA_PATH: data/training.csv
train-and-evaluate:
needs: validate-data
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- name: Train model
run: python scripts/train.py --output-dir models/
env:
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_URI }}
- name: Evaluate and gate
run: python scripts/evaluate_and_gate.py
env:
MIN_ACCURACY: 0.90
MIN_AUC: 0.88
- name: Upload model artifact
uses: actions/upload-artifact@v4
with:
name: trained-model
path: models/
deploy:
needs: train-and-evaluate
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Download model
uses: actions/download-artifact@v4
with:
name: trained-model
path: models/
- name: Deploy to staging
run: python scripts/deploy.py --env staging
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Data Validation Script
# scripts/validate_data.py
import sys
import json
import argparse
import numpy as np
import pandas as pd
from pathlib import Path
SCHEMA = {
"required_columns": ["age", "income", "credit_score", "debt_ratio", "label"],
"dtypes": {
"age": "numeric",
"income": "numeric",
"credit_score": "numeric",
"debt_ratio": "numeric",
"label": "numeric",
},
"ranges": {
"age": (18, 120),
"credit_score": (300, 850),
"debt_ratio": (0.0, 10.0),
},
"max_null_rate": 0.05, # fail if any column has > 5% nulls
"min_rows": 1000,
}
def validate(data_path: str) -> dict:
issues = []
df = pd.read_csv(data_path)
# Row count
if len(df) < SCHEMA["min_rows"]:
issues.append(f"Too few rows: {len(df)} < {SCHEMA['min_rows']}")
# Required columns
missing_cols = set(SCHEMA["required_columns"]) - set(df.columns)
if missing_cols:
issues.append(f"Missing columns: {missing_cols}")
return {"passed": False, "issues": issues} # can't continue without columns
# Null rates
for col in SCHEMA["required_columns"]:
null_rate = df[col].isnull().mean()
if null_rate > SCHEMA["max_null_rate"]:
issues.append(f"Column '{col}' null rate {null_rate:.1%} > {SCHEMA['max_null_rate']:.1%}")
# Numeric type check
for col, expected in SCHEMA["dtypes"].items():
if col in df.columns and expected == "numeric":
if not pd.api.types.is_numeric_dtype(df[col]):
issues.append(f"Column '{col}' is not numeric: {df[col].dtype}")
# Range checks
for col, (lo, hi) in SCHEMA["ranges"].items():
if col in df.columns:
out_of_range = ((df[col] < lo) | (df[col] > hi)).sum()
if out_of_range > 0:
issues.append(f"Column '{col}': {out_of_range} values outside [{lo}, {hi}]")
# Class balance check for label
if "label" in df.columns:
class_counts = df["label"].value_counts(normalize=True)
if class_counts.min() < 0.02:
issues.append(f"Severe class imbalance: minority class = {class_counts.min():.1%}")
return {"passed": len(issues) == 0, "issues": issues, "n_rows": len(df)}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--data-path", default="data/training.csv")
args = parser.parse_args()
result = validate(args.data_path)
print(json.dumps(result, indent=2))
if not result["passed"]:
print("\n❌ DATA VALIDATION FAILED", file=sys.stderr)
for issue in result["issues"]:
print(f" - {issue}", file=sys.stderr)
sys.exit(1)
print(f"\n✅ Data validation passed ({result['n_rows']:,} rows)")
Model Gating Script
# scripts/evaluate_and_gate.py
import sys
import json
import os
import joblib
import numpy as np
import mlflow
from sklearn.metrics import accuracy_score, roc_auc_score, f1_score
from sklearn.model_selection import train_test_split
# Thresholds from environment variables (set in CI/CD)
MIN_ACCURACY = float(os.environ.get("MIN_ACCURACY", 0.90))
MIN_AUC = float(os.environ.get("MIN_AUC", 0.88))
MIN_F1 = float(os.environ.get("MIN_F1", 0.85))
def evaluate_model(model_path: str, data_path: str) -> dict:
import pandas as pd
df = pd.read_csv(data_path)
X = df.drop("label", axis=1).values
y = df["label"].values
_, X_test, _, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = joblib.load(model_path)
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]
return {
"accuracy": accuracy_score(y_test, y_pred),
"auc_roc": roc_auc_score(y_test, y_prob),
"f1": f1_score(y_test, y_pred),
"n_test": len(y_test),
}
def gate_model(metrics: dict) -> tuple[bool, list[str]]:
"""Return (passed, list_of_failures)."""
failures = []
if metrics["accuracy"] < MIN_ACCURACY:
failures.append(f"Accuracy {metrics['accuracy']:.4f} < threshold {MIN_ACCURACY}")
if metrics["auc_roc"] < MIN_AUC:
failures.append(f"AUC-ROC {metrics['auc_roc']:.4f} < threshold {MIN_AUC}")
if metrics["f1"] < MIN_F1:
failures.append(f"F1 {metrics['f1']:.4f} < threshold {MIN_F1}")
return len(failures) == 0, failures
if __name__ == "__main__":
metrics = evaluate_model("models/model.pkl", "data/training.csv")
passed, failures = gate_model(metrics)
print("Model Evaluation Results:")
for k, v in metrics.items():
print(f" {k:12s}: {v:.4f}" if isinstance(v, float) else f" {k:12s}: {v}")
print(f"\nThresholds: accuracy≥{MIN_ACCURACY}, AUC≥{MIN_AUC}, F1≥{MIN_F1}")
if passed:
print("\n✅ Model passed all gates — eligible for promotion")
# Log to MLflow, register model
with mlflow.start_run(run_name="CI-evaluation"):
for name, val in metrics.items():
if isinstance(val, float):
mlflow.log_metric(name, val)
else:
print("\n❌ MODEL GATE FAILED:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
sys.exit(1)
DVC — Data Version Control
# Install DVC
pip install dvc dvc-s3
# Initialize in your ML repo
dvc init
git add .dvc .dvcignore
git commit -m "Initialize DVC"
# Track data file — creates data/training.csv.dvc (small file, goes to git)
dvc add data/training.csv
git add data/training.csv.dvc data/.gitignore
git commit -m "Track training data with DVC"
# Push data to S3 (configure remote first)
dvc remote add -d myremote s3://my-bucket/dvc-store
dvc push
# On another machine: pull the exact data version
git clone <repo>
dvc pull # downloads data from S3
# Create a pipeline stage (cached, only reruns when inputs change)
dvc stage add -n train \
-d data/training.csv -d src/train.py \
-o models/model.pkl \
-m metrics.json \
python src/train.py
dvc repro # runs the stage; skips it next time if no dependency changed
Practice
Do these in one scratch project with scripts/, data/, models/ and tests/ directories.
Save the lesson’s two scripts as scripts/validate_data.py and scripts/evaluate_and_gate.py,
unchanged. You need pandas, scikit-learn, joblib, mlflow and pytest. These run the
pipeline’s steps locally, the same commands the workflow’s run: lines call. Nothing here runs
on GitHub Actions.
- The validator needs a
data/training.csvto check. Write a seeded generator that produces 2,000 rows in the schema’s columns, with alabelthat depends oncredit_scoreanddebt_ratio. Then run the validator on it.
Solution
# scripts/make_data.py: a seeded synthetic credit dataset in the validator's schema
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n = 2000
df = pd.DataFrame({
"age": rng.integers(18, 80, n),
"income": rng.lognormal(10.8, 0.4, n).round(0),
"credit_score": rng.integers(300, 851, n),
"debt_ratio": rng.uniform(0.0, 3.0, n).round(3),
})
risk = (df["credit_score"] - 575) / 60 - (df["debt_ratio"] - 1.5) * 2
df["label"] = (risk + rng.normal(0, 0.8, n) > 0).astype(int)
df.to_csv("data/training.csv", index=False)
print(f"wrote data/training.csv: {len(df)} rows, label mean {df['label'].mean():.3f}")
$ python scripts/make_data.py
wrote data/training.csv: 2000 rows, label mean 0.504
$ python scripts/validate_data.py
{
"passed": true,
"issues": [],
"n_rows": 2000
}
✅ Data validation passed (2,000 rows)
The validator’s default --data-path is data/training.csv, so no flag is needed. It exits 0
here, which is what lets the workflow’s train-and-evaluate job start, since that job declares
needs: validate-data.
- Break the data on purpose and confirm the validator stops the pipeline. Copy the clean file
to
data/broken.csv, then make two changes: setcredit_scoreto 900 in the first 25 rows, and blankincomein every 15th row. Run the validator on the broken file and check the exit code.
Solution
# scripts/corrupt.py: two deliberate data bugs, written to a separate file
import numpy as np
import pandas as pd
df = pd.read_csv("data/training.csv")
df.loc[:24, "credit_score"] = 900 # 25 rows above the 850 maximum
df.loc[::15, "income"] = np.nan # every 15th row loses its income
df.to_csv("data/broken.csv", index=False)
print("wrote data/broken.csv")
$ python scripts/corrupt.py
wrote data/broken.csv
$ python scripts/validate_data.py --data-path data/broken.csv
{
"passed": false,
"issues": [
"Column 'income' null rate 6.7% > 5.0%",
"Column 'credit_score': 25 values outside [300, 850]"
],
"n_rows": 2000
}
❌ DATA VALIDATION FAILED
- Column 'income' null rate 6.7% > 5.0%
- Column 'credit_score': 25 values outside [300, 850]
$ echo $?
1
The validator reports every issue rather than stopping at the first, so one CI run tells you everything that’s wrong. Only the exit status of 1 actually matters to CI, though: that’s what fails the step and stops the jobs that depend on it. 134 of 2,000 rows (6.7%) lost their income, just over the 5% limit.
- The gate script loads
models/model.pkl, but the lesson never shows how it gets there. Writescripts/train.pyso that it holds out exactly the rows the gate tests on. Then run the gate twice: once with default thresholds, and once withMIN_ACCURACY=0.99set in the environment, the way the workflow’senv:block sets it.
Solution
# scripts/train.py: train on the same split evaluate_and_gate.py holds out
import joblib
import pandas as pd
from pathlib import Path
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
df = pd.read_csv("data/training.csv")
X, y = df.drop("label", axis=1).values, df["label"].values
X_train, _, y_train, _ = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = make_pipeline(StandardScaler(), LogisticRegression())
model.fit(X_train, y_train)
Path("models").mkdir(exist_ok=True)
joblib.dump(model, "models/model.pkl")
print(f"trained on {len(X_train)} rows -> models/model.pkl")
$ python scripts/train.py
trained on 1600 rows -> models/model.pkl
$ python scripts/evaluate_and_gate.py
Model Evaluation Results:
accuracy : 0.9450
auc_roc : 0.9878
f1 : 0.9455
n_test : 400
Thresholds: accuracy≥0.9, AUC≥0.88, F1≥0.85
✅ Model passed all gates — eligible for promotion
2026/10/11 11:16:19 INFO mlflow.store.db.utils: Creating initial MLflow database tables...
2026/10/11 11:16:19 INFO mlflow.store.db.utils: Updating database tables
$ MIN_ACCURACY=0.99 python scripts/evaluate_and_gate.py
Model Evaluation Results:
accuracy : 0.9450
auc_roc : 0.9878
f1 : 0.9455
n_test : 400
Thresholds: accuracy≥0.99, AUC≥0.88, F1≥0.85
❌ MODEL GATE FAILED:
- Accuracy 0.9450 < threshold 0.99
$ echo $?
1
The same model passes or fails depending only on the thresholds. That’s why they live in the
workflow’s env: and not in the code: tightening a gate is a config change. train.py must use
the identical test_size, random_state and stratify as the gate, or the gate would score
rows the model trained on. On a pass, the gate also logs to MLflow, which is why the local
mlflow.db gets created on the first passing run.
- CI should catch a broken gate before it ever sees a model. Write pytest tests that check three
things:
gate_modelpasses good metrics, it reports one failure per failing metric, andvalidateflags a single out-of-rangecredit_score.
Solution
# tests/test_pipeline.py: unit tests CI can run before any training happens
import sys
sys.path.insert(0, "scripts")
import pandas as pd
from validate_data import validate
from evaluate_and_gate import gate_model
GOOD = {"accuracy": 0.95, "auc_roc": 0.97, "f1": 0.94}
def test_gate_passes_good_model():
assert gate_model(GOOD) == (True, [])
def test_gate_names_every_failing_metric():
passed, failures = gate_model({"accuracy": 0.80, "auc_roc": 0.97, "f1": 0.70})
assert not passed
assert len(failures) == 2
def test_validate_flags_out_of_range_credit_score(tmp_path):
df = pd.read_csv("data/training.csv")
df.loc[0, "credit_score"] = 900
path = tmp_path / "one_bad_row.csv"
df.to_csv(path, index=False)
result = validate(str(path))
assert result["issues"] == ["Column 'credit_score': 1 values outside [300, 850]"]
$ python -m pytest -q tests/
... [100%]
3 passed in 2.31s
The gate and validator are plain functions, and the if __name__ == "__main__": blocks don’t run
on import, so they can be tested without training anything. In the workflow this would be one
more run: python -m pytest -q tests/ step in validate-data. That’s unexecuted config here: the
tests ran locally.
- The FAQ describes a stricter gate: the new model must beat the currently deployed champion by
at least 1 accuracy point. Write
models/champion.jsonwith{"accuracy": 0.94}, then write a gate that reusesevaluate_modeland exits 1 unless the challenger clears that bar. Run it again with the champion at0.92.
Solution
# scripts/gate_vs_champion.py: the challenger must beat the deployed model, not just a floor
import json
import sys
sys.path.insert(0, "scripts")
from evaluate_and_gate import evaluate_model
MIN_GAIN = 0.01 # at least one accuracy point better than the champion
with open("models/champion.json") as f:
champion = json.load(f)
challenger = evaluate_model("models/model.pkl", "data/training.csv")
gain = challenger["accuracy"] - champion["accuracy"]
print(f"champion accuracy {champion['accuracy']:.4f}")
print(f"challenger accuracy {challenger['accuracy']:.4f} (gain {gain:+.4f}, need {MIN_GAIN:+.4f})")
if gain < MIN_GAIN:
print("BLOCKED: challenger is not clearly better; keep the champion", file=sys.stderr)
sys.exit(1)
print("PROMOTE: challenger replaces the champion")
$ echo '{"accuracy": 0.94}' > models/champion.json
$ python scripts/gate_vs_champion.py
champion accuracy 0.9400
challenger accuracy 0.9450 (gain +0.0050, need +0.0100)
BLOCKED: challenger is not clearly better; keep the champion
$ echo $?
1
$ echo '{"accuracy": 0.92}' > models/champion.json
$ python scripts/gate_vs_champion.py
champion accuracy 0.9200
challenger accuracy 0.9450 (gain +0.0250, need +0.0100)
PROMOTE: challenger replaces the champion
The challenger clears every absolute threshold from task 3, but it’s blocked against a 0.94 champion. Half a point on 400 test rows is two predictions, which is too small to count as a real improvement. A relative gate like this stops a pipeline that runs on every push from swapping in models that are merely different, not better.