Dotika MLOps · Bloc 3 — L'APIBlock 3 — The API
▶ Slides▶ Slides ← Sommaire← Contents
Bloc 3 · 3 h · théorie 0h30 · lab 2h30Block 3 · 3 h · theory 0h30 · lab 2h30

La porte
d'entrée
The front
door

Le cerveau du bloc 2 est excellent — et muet. On lui donne une adresse : une API FastAPI blindée, testée, documentée, que n'importe quelle application de la banque peut appeler. Block 2's brain is excellent — and mute. We give it an address: a FastAPI API that's armored, tested, documented, callable by any application in the bank.

Où on en estWhere we stand

Un cerveau muetA mute brain

Bloc 2 : le modèle est bon, et vous savez le prouver. Mais il vit dans un cadre en pointillés — un notebook, sur un laptop. À 3 h 47, quand l'app de la banque a 300 ms pour décider, personne ne peut lui demander un verdict. Block 2: the model is good, and you can prove it. But it lives inside a dotted frame — a notebook, on a laptop. At 3:47 am, when the bank's app has 300 ms to decide, nobody can ask it for a verdict.

Aujourd'huiToday

La porte apparaîtThe door appears

Une API FastAPI se dresse devant le modèle. Regardez la carte : les transactions ne vont plus directement au cerveau — elles passent par la porte. POST /predict, entrées validées par Pydantic, modèle chargé une fois au démarrage. A FastAPI API now stands in front of the model. Watch the map: transactions no longer reach the brain directly — they go through the door. POST /predict, inputs validated by Pydantic, model loaded once at startup.

RésultatThe result

Interrogeable en 300 msQueryable within 300 ms

N'importe quelle application envoie un JSON et reçoit un verdict en quelques millisecondes — et le verdict humain revient par /feedback. Le cerveau n'est plus muet. Mais tout ça tourne sur une machine : c'est le point de départ du bloc 4. Any application sends a JSON and gets a verdict back in a few milliseconds — and the human verdict comes home through /feedback. The brain is no longer mute. But all of it runs on one machine: that's where block 4 picks up.

Acte 1 · théorie 0h30Act 1 · theory 0h30

Un cerveau muet ne sert à rienA mute brain is useless

Servir un modèle, c'est rendre ses prédictions accessibles au moment où on en a besoin — sans humain dans la boucle. Toute la théorie du bloc tient en deux questions : quand répondre, et comment ne pas se trahir entre l'entraînement et le serving. Serving a model means making its predictions available at the moment they're needed — with no human in the loop. The block's whole theory fits in two questions: when to answer, and how not to betray yourself between training and serving.

300 ms
le budget du métier pour dire oui ou non pendant le paiementthe business's budget to say yes or no during the payment
~8 ms
la réponse de l'API quand le modèle est chargé au démarragethe API's answer when the model is loaded at startup
×250
le prix du bug du débutant : recharger le modèle à chaque requêtethe price of the rookie bug: reloading the model on every request
BATCH
Scorer la nuitScore overnight
Un gros calcul planifié, résultats déposés dans une table. Parfait quand la décision peut attendre demain : rapports, churn, relances.One big scheduled run, results dropped into a table. Perfect when the decision can wait until tomorrow: reports, churn, follow-ups.
TEMPS RÉELREAL TIME
Répondre pendant le paiementAnswer during the payment
Une API toujours debout, des millisecondes de latence. Obligatoire quand la décision est en cours — la fraude se joue maintenant.An always-up API, milliseconds of latency. Mandatory when the decision is in progress — the fraud happens now.
NOTRE CHOIXOUR CHOICE
Les deux, dans la même APIBoth, in the same API
Temps réel pour bloquer (/predict), batch pour analyser (/batch). C'est le métier qui dicte le mode — jamais la techno.Real time to block (/predict), batch to analyze (/batch). The business dictates the mode — never the tech.
TRAINING-SERVING SKEW
L'erreur invisibleThe invisible error
Des features calculées différemment à l'entraînement et au serving : pas de crash, juste des verdicts faux en silence, sous un 200 OK.Features computed differently in training and in serving: no crash, just silently wrong verdicts, under a 200 OK.
Les parades au skewSkew countermeasures

Une seule source de vérité pour le calcul des features (dans src/, importée par le train ET l'API), le préprocesseur sauvé avec le modèle, un test qui vérifie le pipeline complet — et au bloc 8, le monitoring des distributions. Un score parfait au test et faux en prod : cherchez le skew. One single source of truth for feature computation (in src/, imported by training AND the API), the preprocessor saved with the model, one test checking the full pipeline — and in block 8, distribution monitoring. Perfect at test time, wrong in production: hunt the skew.

Actes 2–3 · FastAPI, Pydantic, les guichetsActs 2–3 · FastAPI, Pydantic, the counters

Une API est un contratAn API is a contract

« Envoie-moi une transaction sous cette forme, je te renvoie un verdict sous cette forme. » L'app de paiement ne sait pas que c'est du scikit-learn derrière — et n'a pas besoin de le savoir. Tant que le contrat tient, on peut tout changer derrière la porte. "Send me a transaction in this shape, I'll send back a verdict in this shape." The payment app doesn't know there's scikit-learn behind the door — and doesn't need to. As long as the contract holds, everything behind the door can change.

FastAPI, le réceptionnisteFastAPI, the receptionist
Chaque route est un guichet, déclaré en trois lignes de Python typé. Les types valident, convertissent et documentent — et le framework tient largement nos 300 ms.Every route is a counter, declared in three lines of typed Python. The types validate, convert and document — and the framework easily holds our 300 ms.
Le modèle chargé au démarrageThe model loaded at startup
joblib.load() dans la fonction predict = ~2 s par requête. Dans le lifespan, une fois au démarrage = ~8 ms par requête. Même modèle, 250× plus vite : LE réflexe serving.joblib.load() inside the predict function = ~2 s per request. In the lifespan, once at startup = ~8 ms per request. Same model, 250× faster: THE serving reflex.
Pydantic, le videurPydantic, the bouncer
amount: float = Field(gt=0) — et toute requête invalide est refusée en 422, champ fautif désigné, avant de toucher le modèle. Le contrat, exécuté par la machine.amount: float = Field(gt=0) — and any invalid request is rejected with a 422, offending field named, before it touches the model. The contract, machine-enforced.
POST /predict
Le cœur : un verdict en temps réelThe heart: a real-time verdict
Une transaction entre, {"fraud_score": 0.93, "verdict": "ALERT"} ressort — avec le seuil justifié en euros au bloc 2.A transaction goes in, {"fraud_score": 0.93, "verdict": "ALERT"} comes out — with the threshold justified in euros in block 2.
POST /batch
10 000 d'un coup10,000 at once
Les analystes re-scorent l'historique en un appel : une liste, un passage vectorisé NumPy. Le batch, offert par la même API.Analysts re-score the history in one call: one list, one vectorized NumPy pass. Batch mode, offered by the same API.
POST /feedback
La boucle du verdict humainThe human-verdict loop
L'analyste renvoie la vérité terrain : vraie fraude ou fausse alerte. La donnée d'or qui ré-entraînera le modèle — la boucle se referme au bloc 8.The analyst sends ground truth back: real fraud or false alarm. The golden data that will retrain the model — the loop closes in block 8.
GET /health
Le pouls du serviceThe service's pulse
« Je suis vivant, le modèle est chargé. » Lu par des machines — dont Kubernetes, toutes les dix secondes, dès le bloc 7."I'm alive, the model is loaded." Read by machines — including Kubernetes, every ten seconds, from block 7 on.

Essayez la porte : envoyez une transactionTry the door: send a transaction

La même démo que dans les slides : une transaction valide reçoit un verdict en quelques millisecondes ; une transaction cassée est refusée par le videur — proprement, précisément, automatiquement : The same demo as in the slides: a valid transaction gets a verdict in a few milliseconds; a broken one is rejected by the bouncer — cleanly, precisely, automatically:

Requête → POST /predictRequest → POST /predict

# cliquez pour envoyer / click to send

RéponseResponse

# …

Prouver que ça marche — pytest et SwaggerProve it works — pytest and Swagger

TestClient fait tourner l'API en mémoire : quatre tests couvrent le contrat — le cas nominal, le 422, le batch, le pouls. Et /docs expose une documentation générée depuis le code, interactive, qui ne ment jamais : la doc manuelle ment au bout de deux semaines, celle-ci est reconstruite à chaque démarrage. Au bloc 6, la CI rejouera ces tests à chaque commit. TestClient runs the API in memory: four tests cover the contract — the happy path, the 422, the batch, the pulse. And /docs exposes documentation generated from the code, interactive, that never lies: hand-written docs start lying after two weeks, these are rebuilt at every startup. In block 6, the CI will replay these tests on every commit.

À retenirTakeaway

Le mode de serving vient du métier ; le modèle se charge au démarrage ; on valide à la porte ; mêmes features au train et au serving ; on teste le contrat, pas les entrailles. The serving mode comes from the business; the model loads at startup; validate at the door; same features in training and serving; test the contract, not the guts.

La mission du blocThe block's mission

« Donner une adresse au modèle : une API blindée et testée que n'importe quelle application de la banque peut appeler. » "Give the model an address: an armored, tested API that any application in the bank can call."

Votre modèle du bloc 2, servi en temps réel — quatre guichets, un videur, des tests, une doc qui ne ment pas. C'est cette API qu'on mettra en boîte au bloc 4. Your block 2 model, served in real time — four counters, a bouncer, tests, docs that don't lie. This is the API we'll box up in block 4.

Théorie 0h30 + lab 2h30 · jours 1–2Theory 0h30 + lab 2h30 · days 1–2

À vous de jouerYour turn

Une théorie courte, puis trois missions au clavier — à cheval sur les jours 1 et 2. Les durées viennent du plan de cours : A short theory, then three hands-on missions — spread over days 1 and 2. Durations come straight from the course plan:

0h30
Stratégies de serving (batch vs temps réel) & training-serving skew — la théorie du blocServing strategies (batch vs real time) & training-serving skew — the block's theory
théorietheory
1h20
API FastAPI — endpoint /predict, modèle chargé au démarrage (lifespan), lancé avec uvicorn, appelé en curlFastAPI API — /predict endpoint, model loaded at startup (lifespan), launched with uvicorn, called with curl
pratiquehands-on
0h50
Validation Pydantic + endpoints /batch, /feedback, /health — et le 422 vérifié avec une requête casséePydantic validation + /batch, /feedback, /health endpoints — with the 422 checked using a broken request
pratiquehands-on
0h20
Tests pytest (TestClient, 4 tests du contrat) & documentation Swagger — /docs essayé guichet par guichetpytest tests (TestClient, the contract's 4 tests) & Swagger documentation — /docs tried counter by counter
pratiquehands-on
Definition of done

`uvicorn src.api.main:app` tourne ; les quatre guichets répondent ; une transaction invalide reçoit un 422 avec le champ fautif ; pytest vert (4 tests minimum) ; /docs décrit toute l'API — le tout commité. C'est cette API qu'on conteneurise au bloc 4. `uvicorn src.api.main:app` runs; all four counters answer; an invalid transaction gets a 422 naming the offending field; pytest green (4 tests minimum); /docs describes the whole API — all committed. This is the API we containerize in block 4.

Pas à pas · en autonomieStep by step · self-paced

Faites-le vous-même, de A à ZDo it yourself, start to finish

Ce guide reprend les trois missions du lab, commande par commande, pour tout refaire seul. Prérequis : le bloc 2 terminé — le dépôt fraud-detection/, le venv .venv (Python 3.11), et les deux fichiers models/model.pkl et models/scaler.pkl produits par votre entraînement. À la fin : une API FastAPI à quatre guichets, validée par Pydantic, testée par pytest, documentée par Swagger — prête à être mise en boîte au bloc 4. This guide replays the lab's three missions, command by command, so you can redo everything on your own. Prerequisite: block 2 finished — the fraud-detection/ repo, the .venv virtualenv (Python 3.11), and the two files models/model.pkl and models/scaler.pkl produced by your training. At the end: a FastAPI API with four counters, validated by Pydantic, tested with pytest, documented by Swagger — ready to be boxed up in block 4.

Mission 1 — /predict, le premier guichet (1h20)Mission 1 — /predict, the first counter (1h20)

1Préparer l'environnementPrepare the environment

Placez-vous à la racine du dépôt, activez le venv du bloc 2, vérifiez que le modèle et le scaler sont bien là, puis installez les quatre briques du bloc. Move to the repo root, activate block 2's virtualenv, check that the model and the scaler are there, then install the block's four building bricks.

cd fraud-detection
source .venv/bin/activate
python --version
ls models/
pip install fastapi "uvicorn[standard]" pydantic pytest httpx
Vous devriez voir : Python 3.11.x, puis model.pkl et scaler.pkl dans models/, puis "Successfully installed" sans erreur.You should see: Python 3.11.x, then model.pkl and scaler.pkl inside models/, then "Successfully installed" with no error.
2Créer la structure du code de servingCreate the serving code structure

L'API vit dans src/serve/, les tests dans tests/, le feedback dans data/. Les fichiers __init__.py font de src un vrai package Python — sans eux, l'import src.serve.app échoue. The API lives in src/serve/, the tests in tests/, the feedback in data/. The __init__.py files make src a real Python package — without them, the src.serve.app import fails.

mkdir -p src/serve tests data
touch src/__init__.py src/serve/__init__.py tests/__init__.py
find src tests -name "__init__.py"
Vous devriez voir : les trois chemins src/__init__.py, src/serve/__init__.py et tests/__init__.py listés.You should see: the three paths src/__init__.py, src/serve/__init__.py and tests/__init__.py listed.
3Écrire l'API complète : src/serve/app.pyWrite the full API: src/serve/app.py

Le fichier entier, en une fois : le lifespan charge le modèle et le scaler une seule fois au démarrage ; le videur Pydantic décrit les 30 features (time, amount, v1 à v28 — mêmes colonnes, même ordre qu'à l'entraînement) ; le seuil 0.31 est une constante, pas un chiffre enfoui. The whole file, in one go: the lifespan loads the model and the scaler once at startup; the Pydantic bouncer describes all 30 features (time, amount, v1 through v28 — same columns, same order as in training); the 0.31 threshold is a named constant, not a buried number.

import json
from contextlib import asynccontextmanager
from pathlib import Path

import joblib
import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel, Field

THRESHOLD = 0.31
MODEL_PATH = Path("models/model.pkl")
SCALER_PATH = Path("models/scaler.pkl")
FEEDBACK_PATH = Path("data/feedback.jsonl")

ml = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    # load once at startup, not on every request
    ml["model"] = joblib.load(MODEL_PATH)
    ml["scaler"] = joblib.load(SCALER_PATH)
    yield
    ml.clear()


app = FastAPI(title="Fraud Detection API", version="0.1.0", lifespan=lifespan)


class Transaction(BaseModel):
    time: float = Field(ge=0)
    amount: float = Field(ge=0)
    v1: float = 0.0
    v2: float = 0.0
    v3: float = 0.0
    v4: float = 0.0
    v5: float = 0.0
    v6: float = 0.0
    v7: float = 0.0
    v8: float = 0.0
    v9: float = 0.0
    v10: float = 0.0
    v11: float = 0.0
    v12: float = 0.0
    v13: float = 0.0
    v14: float = 0.0
    v15: float = 0.0
    v16: float = 0.0
    v17: float = 0.0
    v18: float = 0.0
    v19: float = 0.0
    v20: float = 0.0
    v21: float = 0.0
    v22: float = 0.0
    v23: float = 0.0
    v24: float = 0.0
    v25: float = 0.0
    v26: float = 0.0
    v27: float = 0.0
    v28: float = 0.0


class Feedback(BaseModel):
    transaction_id: str
    fraud_confirmed: bool


def to_row(t: Transaction) -> list:
    # same column order as training: Time, V1..V28, Amount
    return [t.time] + [getattr(t, "v" + str(i)) for i in range(1, 29)] + [t.amount]


def score(rows: np.ndarray) -> np.ndarray:
    x = ml["scaler"].transform(rows)
    return ml["model"].predict_proba(x)[:, 1]


def as_verdict(s: float) -> dict:
    return {"fraud_score": round(float(s), 4),
            "verdict": "ALERT" if s >= THRESHOLD else "OK"}


@app.post("/predict")
def predict(transaction: Transaction):
    s = score(np.array([to_row(transaction)]))[0]
    return as_verdict(s)


@app.post("/batch")
def batch(transactions: list[Transaction]):
    rows = np.array([to_row(t) for t in transactions])
    return {"results": [as_verdict(s) for s in score(rows)]}


@app.post("/feedback")
def feedback(fb: Feedback):
    FEEDBACK_PATH.parent.mkdir(parents=True, exist_ok=True)
    with FEEDBACK_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps(fb.model_dump()) + "\n")
    return {"stored": True}


@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": "model" in ml}
Vous devriez voir : python -c "from src.serve.app import app" (lancé depuis la racine) ne renvoie rien — aucune erreur d'import.You should see: python -c "from src.serve.app import app" (run from the root) returns nothing — no import error.
4Lancer le serveur avec uvicornStart the server with uvicorn

Toujours depuis la racine du dépôt — c'est de là que partent les chemins models/ et data/. Le flag --reload redémarre le serveur à chaque sauvegarde du fichier. Always from the repo root — that is where the models/ and data/ paths resolve from. The --reload flag restarts the server on every file save.

uvicorn src.serve.app:app --reload --port 8000
Vous devriez voir : "Uvicorn running on http://127.0.0.1:8000" puis "Application startup complete." — le modèle est chargé, une seule fois.You should see: "Uvicorn running on http://127.0.0.1:8000" then "Application startup complete." — the model is loaded, exactly once.
5Premier verdict en curlFirst verdict with curl

Dans un second terminal (laissez uvicorn tourner dans le premier) : une transaction valide entre, un verdict sort. Les v manquants prennent leur valeur par défaut 0.0. In a second terminal (leave uvicorn running in the first): a valid transaction goes in, a verdict comes out. Missing v fields take their 0.0 default.

curl -s -X POST http://127.0.0.1:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"time": 3600, "amount": 129.90, "v14": -8.31, "v12": -5.20}'
Vous devriez voir : {"fraud_score": un nombre entre 0 et 1, "verdict": "ALERT" ou "OK"} — en quelques millisecondes.You should see: {"fraud_score": a number between 0 and 1, "verdict": "ALERT" or "OK"} — within a few milliseconds.

Mission 2 — Blinder la porte, ouvrir les guichets (0h50)Mission 2 — Armor the door, open the counters (0h50)

6Vérifier le videur : la transaction casséeCheck the bouncer: the broken transaction

Envoyez exprès un montant négatif : Pydantic doit la refuser avant qu'elle touche le modèle, en désignant le champ fautif. Deliberately send a negative amount: Pydantic must reject it before it touches the model, naming the offending field.

curl -s -X POST http://127.0.0.1:8000/predict \
  -H "Content-Type: application/json" \
  -w "\nHTTP %{http_code}\n" \
  -d '{"time": 3600, "amount": -50.00}'
Vous devriez voir : HTTP 422, avec "loc": ["body", "amount"] et "Input should be greater than or equal to 0".You should see: HTTP 422, with "loc": ["body", "amount"] and "Input should be greater than or equal to 0".
7Le guichet batch : une liste, un appelThe batch counter: one list, one call

Même API, mode analyste : une liste de transactions, un passage vectorisé NumPy, une liste de verdicts. Same API, analyst mode: a list of transactions, one vectorized NumPy pass, a list of verdicts.

curl -s -X POST http://127.0.0.1:8000/batch \
  -H "Content-Type: application/json" \
  -d '[{"time": 3600, "amount": 129.90, "v14": -8.31},
       {"time": 7200, "amount": 12.50}]'
Vous devriez voir : {"results": [ ... ]} avec exactement deux verdicts, un par transaction envoyée.You should see: {"results": [ ... ]} with exactly two verdicts, one per transaction sent.
8La boucle du verdict humain : /feedbackThe human-verdict loop: /feedback

L'analyste renvoie la vérité terrain ; l'API l'ajoute en JSONL dans data/feedback.jsonl — une ligne JSON par verdict, le futur carburant du ré-entraînement. The analyst sends ground truth back; the API appends it as JSONL to data/feedback.jsonl — one JSON line per verdict, the future fuel for retraining.

curl -s -X POST http://127.0.0.1:8000/feedback \
  -H "Content-Type: application/json" \
  -d '{"transaction_id": "tx-000042", "fraud_confirmed": true}'
cat data/feedback.jsonl
Vous devriez voir : {"stored": true}, puis la ligne {"transaction_id": "tx-000042", "fraud_confirmed": true} dans le fichier.You should see: {"stored": true}, then the line {"transaction_id": "tx-000042", "fraud_confirmed": true} in the file.
9Le pouls du service : /healthThe service's pulse: /health

Le guichet lu par des machines — dès le bloc 4 par Docker, au bloc 7 par Kubernetes, toutes les dix secondes. The counter read by machines — by Docker from block 4 on, by Kubernetes every ten seconds from block 7.

curl -s http://127.0.0.1:8000/health
Vous devriez voir : {"status": "ok", "model_loaded": true}.You should see: {"status": "ok", "model_loaded": true}.

Mission 3 — Prouver et documenter (0h20)Mission 3 — Prove and document (0h20)

10Écrire les tests du contrat : tests/test_api.pyWrite the contract tests: tests/test_api.py

TestClient fait tourner l'API en mémoire, sans serveur. Le bloc with est important : c'est lui qui déclenche le lifespan, donc le chargement du modèle. Quatre tests couvrent le contrat : le pouls, le cas nominal, le 422, le batch. TestClient runs the API in memory, no server needed. The with block matters: it is what triggers the lifespan, hence the model loading. Four tests cover the contract: the pulse, the happy path, the 422, the batch.

from fastapi.testclient import TestClient

from src.serve.app import app

VALID = {"time": 3600, "amount": 129.90, "v14": -8.31, "v12": -5.20}


def test_health():
    with TestClient(app) as client:
        r = client.get("/health")
        assert r.status_code == 200
        assert r.json() == {"status": "ok", "model_loaded": True}


def test_predict_valid():
    with TestClient(app) as client:
        r = client.post("/predict", json=VALID)
        assert r.status_code == 200
        body = r.json()
        assert 0.0 <= body["fraud_score"] <= 1.0
        assert body["verdict"] in ("ALERT", "OK")


def test_predict_negative_amount_is_rejected():
    with TestClient(app) as client:
        r = client.post("/predict", json={"time": 3600, "amount": -50.00})
        assert r.status_code == 422


def test_batch_returns_one_verdict_per_transaction():
    with TestClient(app) as client:
        payload = [VALID, {"time": 7200, "amount": 12.50}]
        r = client.post("/batch", json=payload)
        assert r.status_code == 200
        assert len(r.json()["results"]) == 2
Vous devriez voir : le fichier tests/test_api.py sauvegardé, aux côtés de tests/__init__.py.You should see: the tests/test_api.py file saved, next to tests/__init__.py.
11Lancer pytestRun pytest

Depuis la racine, avec python -m pour que le dossier courant soit sur le chemin d'import. From the root, using python -m so the current folder is on the import path.

python -m pytest -q
Vous devriez voir : "4 passed" en vert. Le contrat est prouvé — la CI du bloc 6 rejouera exactement ces tests.You should see: "4 passed" in green. The contract is proven — block 6's CI will replay exactly these tests.
12Visiter la doc qui ne ment pas : /docsVisit the docs that never lie: /docs

Uvicorn toujours lancé, ouvrez Swagger dans le navigateur et essayez chaque guichet avec "Try it out" — la doc est générée depuis le code, reconstruite à chaque démarrage. With uvicorn still running, open Swagger in the browser and try every counter with "Try it out" — the docs are generated from the code, rebuilt at every startup.

open http://127.0.0.1:8000/docs
# Linux: xdg-open http://127.0.0.1:8000/docs
Vous devriez voir : les quatre endpoints (predict, batch, feedback, health), le schéma Transaction avec ses 30 champs, et chaque requête exécutable depuis la page.You should see: the four endpoints (predict, batch, feedback, health), the Transaction schema with its 30 fields, and every request executable from the page.
Si ça coinceWhen it breaks

ModuleNotFoundError: No module named 'src' — vous n'êtes pas à la racine de fraud-detection/, ou il manque un des trois __init__.py de l'étape 2. FileNotFoundError: models/model.pkl — le modèle du bloc 2 n'est pas exporté, ou uvicorn est lancé depuis un sous-dossier : rejouez la sauvegarde joblib du bloc 2 et relancez depuis la racine. 422 inattendu sur une transaction valide — les noms de champs doivent correspondre exactement au modèle Pydantic : time, amount, v1 à v28, en minuscules. ERROR: [Errno 48] address already in use — un ancien uvicorn occupe le port : arrêtez-le (lsof -ti :8000 puis kill) ou lancez avec --port 8001. ModuleNotFoundError: No module named 'src' — you are not at the root of fraud-detection/, or one of step 2's three __init__.py files is missing. FileNotFoundError: models/model.pkl — block 2's model was never exported, or uvicorn was launched from a subfolder: replay block 2's joblib save and restart from the root. Unexpected 422 on a valid transaction — field names must match the Pydantic model exactly: time, amount, v1 through v28, lowercase. ERROR: [Errno 48] address already in use — an old uvicorn holds the port: stop it (lsof -ti :8000 then kill) or launch with --port 8001.