CI/CD, validation des données & gouvernance. Le système est en boîte et tracé — mais chaque livraison dépend encore de la discipline humaine. Aujourd'hui, on monte la chaîne : si un feu est rouge, rien ne part. CI/CD, data validation & governance. The system is boxed and traced — but every delivery still depends on human discipline. Today we build the line: if one light is red, nothing ships.
Bloc 4 : Docker. Bloc 5 : MLflow et son registry. Le système est propre — et pourtant chaque livraison repose sur la discipline d'un humain qui pense à tout, même un vendredi à 17 h 40. C'est le dernier maillon fragile. Block 4: Docker. Block 5: MLflow and its registry. The system is clean — yet every delivery rests on the discipline of a human who remembers everything, even on a Friday at 5:40 pm. The last fragile link.
Regardez la carte : la chaîne CI/CD apparaît et alimente la boîte. Désormais, tout part d'un commit — lint, tests, validation des données, contrôle du modèle, build, push. Une machine neutre rejoue tout, à chaque fois. Look at the map: the CI/CD line appears and feeds the box. From now on, everything starts from a commit — lint, tests, data validation, model check, build, push. A neutral machine replays it all, every time.
La pièce maîtresse : le gate PR-AUC. Un modèle qui détecte moins bien que le champion en place ne peut plus atteindre la production — la CI refuse, le commit vire au rouge. Le contrat qualité promis au bloc 2 est enfin signé. The centerpiece: the PR-AUC gate. A model that detects worse than the reigning champion can no longer reach production — the CI refuses, the commit turns red. The quality contract promised in block 2 is finally signed.
« C'est juste un petit fix » — un scp vers la prod un vendredi à 17 h 40, et le week-end est ruiné. En logiciel classique, on teste le code. En ML, le code peut être parfait et le système mauvais quand même : "It's just a small fix" — one scp to prod on a Friday at 5:40 pm, and the weekend is ruined. In classic software, you test the code. In ML, the code can be perfect and the system still bad:
À chaque changement, une machine neutre rejoue toutes les vérifications — et si tout est vert, elle livre. CI : tester chaque commit, automatiquement. CD : construire et pousser l'artefact prêt à déployer. Le contraire exact du scp de vendredi soir. On every change, a neutral machine replays every check — and if all is green, it ships. CI: test every commit, automatically. CD: build and push the deploy-ready artifact. The exact opposite of the Friday-night scp.
Un workflow, c'est une fiche recette posée dans le dépôt : « quand ceci arrive (un push), fais cela (installe, teste, construis) ». Vous l'écrivez une fois — GitHub l'exécute à chaque commit, sur une machine neuve, jamais fatigué, jamais un vendredi soir. A workflow is a recipe card sitting in the repo: "when this happens (a push), do that (install, test, build)". You write it once — GitHub runs it on every commit, on a brand-new machine, never tired, never on a Friday night.
Un mardi, l'équipe amont renomme une colonne. Personne ne prévient. Le pipeline tourne, l'API répond, les dashboards sont verts — et chaque prédiction est fausse. Le ML échoue en silence. La riposte : des assertions sur les données — des tests unitaires dont le sujet est le CSV, pas la fonction. One Tuesday, the upstream team renames a column. Nobody says a word. The pipeline runs, the API answers, the dashboards are green — and every prediction is wrong. ML fails in silence. The response: data assertions — unit tests whose subject is the CSV, not the function.
La suite de validation tourne en job CI à chaque changement de données : une assertion échoue → exit 1 → commit rouge, avec le nom de l'assertion violée. Un CSV qui change en silence, désormais, fait du bruit.
The validation suite runs as a CI job on every data change: one assertion fails → exit 1 → red commit, naming the violated assertion. A CSV that changes in silence now makes noise.
Le code est testé, les données sont testées — reste le troisième suspect : le modèle. Le gate entraîne le challenger sur un échantillon en CI, compare sa PR-AUC à celle du champion (le modèle en production, béni par le registry du bloc 5), et bloque si ça régresse. Avec une tolérance ε : le bruit d'entraînement n'est pas une régression — un gate trop strict finit débranché, et un gate débranché ne protège personne. The code is tested, the data is tested — one suspect remains: the model. The gate trains the challenger on a sample in CI, compares its PR-AUC to the champion's (the production model, blessed by block 5's registry), and blocks on regression. With an ε tolerance: training noise is not a regression — a gate that's too strict ends up unplugged, and an unplugged gate protects no one.
Le premier bouton lance un commit sain — les feux passent au vert un à un. Le second livre un modèle dégradé : tout passe… jusqu'au gate. The first button fires a healthy commit — the lights turn green one by one. The second ships a degraded model: everything passes… until the gate.
Au bloc 2, on promettait : « la PR-AUC deviendra une barrière automatique dans la CI ». C'est fait — la métrique choisie parce que classe rare devient un contrat exécutable. Personne ne peut plus livrer un détecteur qui détecte moins bien : la machine tient la promesse à votre place. In block 2, we promised: "the PR-AUC will become an automatic barrier in the CI". Done — the metric chosen because of the rare class becomes an executable contract. Nobody can ship a detector that detects worse: the machine keeps the promise on your behalf.
Un régulateur bancaire ne demande pas si votre modèle est bon : il demande de prouver comment il décide, sur quelles données, avec quelles limites. La gouvernance, c'est cette preuve — écrite avant qu'on la réclame. A banking regulator doesn't ask whether your model is good: it asks you to prove how it decides, on what data, with what limits. Governance is that proof — written before anyone asks.
« Un commit suffit — tests, données, modèle, build : tout se vérifie seul. Si un feu est rouge, rien ne part. » "A commit is enough — tests, data, model, build: everything checks itself. If one light is red, nothing ships."
Plus personne ne livre à la main. La qualité n'est plus une promesse — c'est un mécanisme. Nobody ships by hand anymore. Quality is no longer a promise — it's a mechanism.
Quatre missions, en binômes — un qui écrit, un qui lit les logs du runner, on échange à la mi-temps. Les durées viennent du plan de cours : Four missions, in pairs — one writes, one reads the runner logs, swap at half-time. Durations come straight from the course plan:
.github/workflows/ci.yml : lint + pytest, build de l'image Docker, push vers ghcr.io avec un secret. Cassez un test exprès : le commit doit virer au rougeThe line — .github/workflows/ci.yml: lint + pytest, Docker image build, push to ghcr.io with a secret. Break a test on purpose: the commit must turn redsrc/data/validate.py : schéma, bornes, manquants, distribution des classes, en job CI. Renommez une colonne : la chaîne doit crierValidate the data — src/data/validate.py: schema, bounds, missing values, class distribution, as a CI job. Rename a column: the line must screamsrc/eval/gate.py : entraîner sur échantillon, comparer au champion avec tolérance ε, bloquer la régression. Sabotez le modèle : le gate doit refuserThe PR-AUC gate — src/eval/gate.py: train on a sample, compare to the champion with ε tolerance, block regression. Sabotage the model: the gate must refusedocs/model_card.md : données, métriques, limites, usage prévu, supervision humaine. Une page, honnête, relue par un autre binômeThe model card — docs/model_card.md: data, metrics, limits, intended use, human oversight. One honest page, reviewed by another pairUn push → badge vert, image fraîche au registry. Une colonne renommée ou un modèle dégradé → CI rouge, avec un message exploitable. Et une model card commitée à côté du code. Cette chaîne devient le seul chemin vers la prod — c'est elle qui alimentera Kubernetes au bloc 7. One push → green badge, fresh image in the registry. A renamed column or a degraded model → red CI, with a usable message. And a model card committed next to the code. This line becomes the only road to production — it's what feeds Kubernetes in block 7.
Tout le lab, rejouable seul : chaque commande, chaque fichier en entier, un point de contrôle par étape. Prérequis : le repo fraud-detection/ des blocs 2–5 (Dockerfile du bloc 4, tests pytest du bloc 3, requirements.txt), un compte GitHub, git — et idéalement la CLI gh.
The whole lab, replayable on your own: every command, every file in full, one checkpoint per step. Prerequisites: the fraud-detection/ repo from blocks 2–5 (block 4's Dockerfile, block 3's pytest tests, requirements.txt), a GitHub account, git — and ideally the gh CLI.
D'abord le .gitignore : le venv, les runs MLflow et surtout creditcard.csv (144 Mo, au-dessus de la limite GitHub) ne se commitent jamais. Puis le push — option A avec la CLI, option B via le site.
The .gitignore first: the venv, the MLflow runs and above all creditcard.csv (144 MB, above GitHub's limit) never get committed. Then the push — option A with the CLI, option B via the website.
cd fraud-detection
cat >> .gitignore <<'EOF'
.venv/
mlruns/
__pycache__/
data/creditcard.csv
EOF
git add -A
git commit -m "chore: prepare repo for CI"
# option A: GitHub CLI
gh auth login
gh repo create fraud-detection --private --source . --push
# option B: create an empty private repo on github.com, then
git remote add origin https://github.com/YOUR_USER/fraud-detection.git
git push -u origin main
La recette complète : quatre jobs — lint + tests (ceux du bloc 3), validation des données, gate PR-AUC, et le build-push Docker qui n'a lieu que si les trois autres sont verts. Avant de pousser, vérifiez en local : pip install pytest ruff puis ruff check src et pytest.
The full recipe: four jobs — lint + tests (block 3's), data validation, PR-AUC gate, and the Docker build-push that only happens if the other three are green. Before pushing, check locally: pip install pytest ruff then ruff check src and pytest.
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt pytest ruff
- run: ruff check src
- run: pytest
data-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- run: pip install pandas
- run: python scripts/validate_data.py data/sample.csv
model-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- run: pip install pandas scikit-learn
- run: python scripts/gate.py
build-push:
runs-on: ubuntu-latest
needs: [lint-test, data-validation, model-gate]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
Deux jobs vont rester rouges : les scripts qu'ils appellent n'existent pas encore. C'est voulu — les missions 2 et 3 les fournissent, et vous verrez la chaîne passer au vert morceau par morceau. Two jobs will stay red: the scripts they call don't exist yet. That's intended — missions 2 and 3 provide them, and you'll watch the line turn green piece by piece.
git add .github/workflows/ci.yml
git commit -m "ci: quality line - lint, data check, gate, build"
git push
La CI a besoin de données, mais on ne commite JAMAIS le dataset complet — seulement un échantillon stratifié de 5 000 lignes qui préserve le taux de fraude. Créez scripts/make_sample.py :
The CI needs data, but you NEVER commit the full dataset — only a 5,000-row stratified sample that preserves the fraud rate. Create scripts/make_sample.py:
import pandas as pd
from sklearn.model_selection import train_test_split
df = pd.read_csv("data/creditcard.csv")
sample, _ = train_test_split(
df, train_size=5000, stratify=df["Class"], random_state=42)
sample.to_csv("data/sample.csv", index=False)
print(sample["Class"].value_counts())
mkdir -p scripts
python scripts/make_sample.py
wc -l data/sample.csv
Les quatre familles de l'acte 3, en assertions exécutables : schéma (31 colonnes, types numériques), bornes (Amount ≥ 0, Class ∈ {0, 1}), manquants (zéro NaN), distribution (taux de fraude entre 0,05 % et 1 %). Une violation → exit 1 → commit rouge, avec un message clair.
Act 3's four families, as executable assertions: schema (31 columns, numeric types), bounds (Amount ≥ 0, Class ∈ {0, 1}), missing values (zero NaN), distribution (fraud rate between 0.05% and 1%). One violation → exit 1 → red commit, with a clear message.
import sys
import pandas as pd
EXPECTED_COLUMNS = ["Time"] + [f"V{i}" for i in range(1, 29)] + ["Amount", "Class"]
def fail(message):
print(f"DATA VALIDATION FAILED: {message}")
sys.exit(1)
def main(path):
df = pd.read_csv(path)
if list(df.columns) != EXPECTED_COLUMNS:
fail(f"schema mismatch, expected 31 known columns, got: {list(df.columns)}")
for col in EXPECTED_COLUMNS:
if not pd.api.types.is_numeric_dtype(df[col]):
fail(f"column {col} is not numeric")
if df.isna().any().any():
fail("dataset contains NaN values")
if (df["Amount"] < 0).any():
fail("negative Amount found")
if not df["Class"].isin([0, 1]).all():
fail("Class must be 0 or 1")
fraud_rate = df["Class"].mean()
if not 0.0005 <= fraud_rate <= 0.01:
fail(f"fraud rate {fraud_rate:.4%} outside [0.05%, 1%]")
print(f"data OK: {len(df)} rows, fraud rate {fraud_rate:.4%}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "data/sample.csv")
python scripts/validate_data.py data/sample.csv
Poussez, vérifiez le vert — puis cassez volontairement : un Amount négatif dans l'échantillon doit faire crier la chaîne. Réparez en régénérant l'échantillon propre. Push, check the green — then break it on purpose: one negative Amount in the sample must make the line scream. Fix it by regenerating a clean sample.
git add scripts/ data/sample.csv
git commit -m "ci: data validation + committed sample"
git push
# break it on purpose: one negative Amount
python -c "import pandas as pd; d = pd.read_csv('data/sample.csv'); d.loc[d.index[0], 'Amount'] = -50; d.to_csv('data/sample.csv', index=False)"
git commit -am "test: corrupt the sample on purpose"
git push
# repair
python scripts/make_sample.py
git commit -am "fix: restore a clean sample"
git push
D'abord le champion à battre — créez metrics/baseline.json (valeur plancher au départ) :
First, the champion to beat — create metrics/baseline.json (a floor value to start):
{
"pr_auc": 0.60,
"updated": "2026-07-06",
"reason": "floor value - replace with your first local gate run"
}
Puis le gate : il entraîne le challenger sur l'échantillon, mesure sa PR-AUC et refuse toute régression au-delà d'une tolérance ε = 0,02 — le bruit d'entraînement n'est pas une régression. Créez scripts/gate.py :
Then the gate: it trains the challenger on the sample, measures its PR-AUC and refuses any regression beyond an ε = 0.02 tolerance — training noise is not a regression. Create scripts/gate.py:
import json
import sys
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
EPSILON = 0.02
BASELINE_PATH = "metrics/baseline.json"
def main():
df = pd.read_csv("data/sample.csv")
X = df.drop(columns=["Class"])
y = df["Class"]
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
model = Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression(class_weight="balanced", max_iter=1000)),
])
model.fit(X_tr, y_tr)
proba = model.predict_proba(X_te)[:, 1]
pr_auc = average_precision_score(y_te, proba)
with open(BASELINE_PATH) as f:
baseline = json.load(f)["pr_auc"]
print(f"challenger pr_auc={pr_auc:.4f} baseline={baseline:.4f} epsilon={EPSILON}")
if pr_auc < baseline - EPSILON:
print("GATE FAILED: model regresses beyond tolerance")
sys.exit(1)
print("GATE PASSED")
if __name__ == "__main__":
main()
mkdir -p metrics
python scripts/gate.py
Poussez : les quatre jobs doivent passer, build-push compris. Puis sabotez le gate — un baseline gonflé simule un challenger qui régresse — et réparez d'un revert. Push: all four jobs must pass, build-push included. Then sabotage the gate — an inflated baseline simulates a regressing challenger — and repair with a revert.
git add scripts/gate.py metrics/baseline.json
git commit -m "ci: PR-AUC gate against committed baseline"
git push
# sabotage: inflate the baseline - the gate must refuse
python -c "import json; json.dump({'pr_auc': 0.99}, open('metrics/baseline.json', 'w'))"
git commit -am "test: inflated baseline on purpose"
git push
# repair: restore the honest baseline
git revert --no-edit HEAD
git push
La carte d'identité du modèle — une page, honnête. Les chiffres ci-dessous sont des exemples : remplacez-les par les valeurs de vos runs MLflow (bloc 5), y compris les segments. The model's identity card — one honest page. The numbers below are examples: replace them with the values from your MLflow runs (block 5), segments included.
# Model card - fraud-detector
## Model
- Type: scikit-learn pipeline (StandardScaler + LogisticRegression, class_weight=balanced)
- Version: MLflow registry "fraud-detector", alias @production (v2)
- Owner: your-name - date: 2026-07-06
## Training data
- Source: creditcard.csv (Kaggle ULB) - 284 807 transactions, 492 frauds (0.17%)
- Features: Time, V1-V28 (anonymized PCA components), Amount
- Split: 80/20 stratified, random_state=42 - scaler fitted on train only
## Metrics (test set)
- PR-AUC global: 0.79
- PR-AUC amounts over 1000 EUR: 0.71 - weakest segment, see limits
- PR-AUC night (00h-06h): 0.74
- Threshold 0.42, chosen to minimize business cost:
a missed fraud costs its amount, a false alert costs 15 EUR
## Limits
- 2013 data, European cardholders: drift expected elsewhere (monitored in block 7)
- Anonymized features: individual decisions are hard to explain to a customer
- Weaker on amounts over 1000 EUR - exactly where an error costs most
## Intended use
- Real-time scoring of card transactions, alerts reviewed by a human analyst
- Out of scope: automatic blocking without human review, creditworthiness scoring
## Contact
- Team fraud-ml: fraud-ml@dotika.example - escalation: see the on-call runbook
mkdir -p docs
git add docs/model_card.md
git commit -m "docs: model card v1"
git push
denied: permission_denied au push ghcr ? Le job n'a pas le droit d'écrire : gardez permissions: packages: write dans build-push, et Settings → Actions → Workflow permissions → Read and write.
pytest: command not found ? Chaque job est une machine neuve : installez pytest et ruff dans le job (pip install ... pytest ruff), pas seulement sur votre laptop.
Le push refuse un fichier trop gros ? creditcard.csv a été commité (144 Mo, limite GitHub 100 Mo) : gardez-le dans .gitignore — seul data/sample.csv (≈ 2 Mo) se commite.
Faut-il créer un secret pour ghcr ? Non : GITHUB_TOKEN est fourni automatiquement à chaque run. Les secrets (Settings → Secrets) servent aux registries externes — jamais de credentials en clair dans le YAML.
denied: permission_denied pushing to ghcr? The job isn't allowed to write: keep permissions: packages: write in build-push, and Settings → Actions → Workflow permissions → Read and write.
pytest: command not found? Every job is a brand-new machine: install pytest and ruff inside the job (pip install ... pytest ruff), not just on your laptop.
Push refused, file too large? creditcard.csv got committed (144 MB, GitHub limit 100 MB): keep it in .gitignore — only data/sample.csv (about 2 MB) gets committed.
Do you need a secret for ghcr? No: GITHUB_TOKEN is provided automatically on every run. Secrets (Settings → Secrets) are for external registries — never plain-text credentials in the YAML.