✅ Club D.I.A.M

Corrections Officielles — Machine Learning 4 Semaines

5 exercices · Solutions complètes & expliquées · Focus Afrique 🌍

📋 Guide d'utilisation

Chaque correction est expandable — cliquez sur la carte pour voir la solution complète. Les solutions incluent la création du dataset, le code complet, les métriques et les visualisations.

Conseil : essayez de résoudre l'exercice par vous-même avant de consulter la correction. Comparez votre approche avec la solution proposée — il peut exister plusieurs solutions correctes.

5
Exercices corrigés
4
Semaines couvertes
6
Algorithmes ML
100%
Code exécutable
📅 Semaine 1 — Régression

Corrections · Semaine 1 — Linéaire · Logistique

Corr. 1.1 Régression Linéaire — Prix du Maïs au Kenya 🌽

Dataset 200 observations · EDA complète · LinearRegression · MSE/RMSE/MAE/R² · Visualisation prédictions vs réalité + résidus.

💡 Point clé : Le R² mesure la part de variance expliquée par le modèle. Un résidu aléatoire sans structure confirme que le modèle est bien ajusté.
solution_ex1_1.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
import matplotlib.pyplot as plt
import seaborn as sns

np.random.seed(123)
n = 200

# ── 1. Créer le dataset ──────────────────────────────────────
data = {
    'production_tonnes'    : np.random.randint(80000, 150000, n),
    'pluviometrie_mm'      : np.random.randint(400, 1200, n),
    'prix_carburant_ksh'   : np.random.uniform(100, 150, n),
    'demande_export_tonnes': np.random.randint(10000, 50000, n),
}
df = pd.DataFrame(data)
df['prix_mais_ksh_kg'] = (
    30 - 0.0001 * df['production_tonnes']
       + 0.01   * df['pluviometrie_mm']
       + 0.2    * df['prix_carburant_ksh']
       + 0.0003 * df['demande_export_tonnes']
       + np.random.normal(0, 3, n)
)

# ── 2. EDA complète ──────────────────────────────────────────
print("=== Statistiques descriptives ===")
print(df.describe().round(2))
print("\nCorrélations avec la cible :")
print(df.corr()['prix_mais_ksh_kg'].sort_values(ascending=False).round(4))

plt.figure(figsize=(8, 6))
sns.heatmap(df.corr(), annot=True, cmap='coolwarm', center=0, fmt='.2f')
plt.title('Matrice de Corrélation — Maïs Kenya')
plt.tight_layout()
plt.show()

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
for idx, col in enumerate(data.keys()):
    r, c = idx // 2, idx % 2
    axes[r, c].scatter(df[col], df['prix_mais_ksh_kg'], alpha=0.5, color='#2a5298')
    axes[r, c].set_xlabel(col)
    axes[r, c].set_ylabel('Prix (KSh/kg)')
    axes[r, c].set_title(f'Prix vs {col}')
plt.tight_layout()
plt.show()

# ── 3. Train / Test split ────────────────────────────────────
X = df.drop('prix_mais_ksh_kg', axis=1)
y = df['prix_mais_ksh_kg']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42)

# ── 4. Entraînement + métriques ──────────────────────────────
model  = LinearRegression()
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

rmse = np.sqrt(mean_squared_error(y_test, y_pred))
mae  = mean_absolute_error(y_test, y_pred)
r2   = r2_score(y_test, y_pred)
print(f"\nRMSE : {rmse:.2f} KSh/kg")
print(f"MAE  : {mae:.2f} KSh/kg")
print(f"R²   : {r2:.4f}")
print("\nCoefficients :")
for feat, coef in zip(X.columns, model.coef_):
    print(f"  {feat:<30} : {coef:+.6f}")

# ── 5. Visualisations ────────────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Prédictions vs Réalité
axes[0].scatter(y_test, y_pred, alpha=0.7, color=&#x27;#2a5298')
lims = [min(y_test.min(), y_pred.min()), max(y_test.max(), y_pred.max())]
axes[0].plot(lims, lims, &#x27;r--', lw=2, label='Ligne idéale')
axes[0].set_xlabel(&#x27;Valeurs Réelles (KSh/kg)')
axes[0].set_ylabel(&#x27;Prédictions (KSh/kg)')
axes[0].set_title(&#x27;Prédictions vs Réalité')
axes[0].legend()

# Analyse des résidus
residus = y_test - y_pred
axes[1].scatter(y_pred, residus, alpha=0.7, color=&#x27;#764ba2')
axes[1].axhline(y=0, color=&#x27;r', linestyle='--', lw=2)
axes[1].set_xlabel(&#x27;Prédictions (KSh/kg)')
axes[1].set_ylabel(&#x27;Résidus')
axes[1].set_title(&#x27;Analyse des Résidus')

plt.tight_layout()
plt.savefig(&#x27;resultats_mais_kenya.png', dpi=300, bbox_inches='tight')
plt.show()
Corr. 1.2 Régression Logistique — Abandon Scolaire au Nigeria 🎓

Dataset 500 élèves · StandardScaler · LogisticRegression · accuracy/precision/recall/F1 · Matrice de confusion · Courbe ROC.

💡 Point clé : Toujours utiliser stratify=y lors du split pour conserver les proportions de classes dans train et test.
solution_ex1_2.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (classification_report, confusion_matrix,
                             roc_auc_score, roc_curve, accuracy_score)
import matplotlib.pyplot as plt
import seaborn as sns

np.random.seed(42)
n = 500

# ── 1. Dataset — 500 élèves nigérians ────────────────────────
df = pd.DataFrame({
    &#x27;distance_km'  : np.random.uniform(0, 30, n),
    &#x27;revenu_naira' : np.random.randint(20000, 500000, n),
    &#x27;nb_absences'  : np.random.randint(0, 50, n),
    &#x27;note_moyenne' : np.random.uniform(20, 100, n),
    &#x27;genre'        : np.random.choice([0, 1], n),  # 0=F, 1=M
})

prob = (
    0.15 * df[&#x27;distance_km']   / 30
    + 0.30 * (1 - df[&#x27;revenu_naira']   / 500000)
    + 0.30 * df[&#x27;nb_absences'] / 50
    + 0.20 * (1 - df[&#x27;note_moyenne']   / 100)
    + 0.05 * df[&#x27;genre']
    + np.random.uniform(-0.1, 0.1, n)
)
df[&#x27;abandon'] = (prob > 0.35).astype(int)
print(f"Taux d&#x27;abandon : {df['abandon'].mean()*100:.1f}%")

# ── 2. Entraînement LogisticRegression ───────────────────────
X = df.drop(&#x27;abandon', axis=1)
y = df[&#x27;abandon']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

scaler     = StandardScaler()
X_train_sc = scaler.fit_transform(X_train)
X_test_sc  = scaler.transform(X_test)

model        = LogisticRegression(max_iter=1000, random_state=42)
model.fit(X_train_sc, y_train)
y_pred       = model.predict(X_test_sc)
y_pred_proba = model.predict_proba(X_test_sc)[:, 1]

# ── 3. Métriques ─────────────────────────────────────────────
print("\n=== Rapport de Classification ===")
print(classification_report(y_test, y_pred,
      target_names=["Pas d&#x27;abandon", 'Abandon']))
print(f"Accuracy : {accuracy_score(y_test, y_pred):.4f}")
print(f"ROC-AUC  : {roc_auc_score(y_test, y_pred_proba):.4f}")

# ── 4. Matrice de confusion ───────────────────────────────────
cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(6, 5))
sns.heatmap(cm, annot=True, fmt=&#x27;d', cmap='Blues',
            xticklabels=["Pas d&#x27;abandon", 'Abandon'],
            yticklabels=["Pas d&#x27;abandon", 'Abandon'])
plt.title(&#x27;Matrice de Confusion — Abandon Scolaire Nigeria')
plt.ylabel(&#x27;Réel')
plt.xlabel(&#x27;Prédit')
plt.tight_layout()
plt.savefig(&#x27;confusion_abandon_nigeria.png', dpi=300)
plt.show()

# ── 5. Courbe ROC + ROC-AUC ──────────────────────────────────
fpr, tpr, _ = roc_curve(y_test, y_pred_proba)
auc         = roc_auc_score(y_test, y_pred_proba)

plt.figure(figsize=(7, 5))
plt.plot(fpr, tpr, color=&#x27;#2a5298', lw=2, label=f'ROC (AUC = {auc:.4f})')
plt.plot([0, 1], [0, 1], &#x27;k--', label='Aléatoire (AUC = 0.5)')
plt.xlim([0.0, 1.0]); plt.ylim([0.0, 1.02])
plt.xlabel(&#x27;Taux Faux Positifs (FPR)')
plt.ylabel(&#x27;Taux Vrais Positifs (TPR)')
plt.title(&#x27;Courbe ROC — Abandon Scolaire Nigeria')
plt.legend(loc=&#x27;lower right')
plt.tight_layout()
plt.savefig(&#x27;roc_abandon_nigeria.png', dpi=300)
plt.show()
📅 Semaine 2 — Classification Supervisée & Arbres

Corrections · Semaine 2 — Random Forest · SMOTE · Grid Search

Corr. 2.1 Classification Qualité d'Eau au Rwanda 💧

Dataset 800 sources · One-hot encoding · Vérification déséquilibre → SMOTE automatique · GridSearchCV · Feature Importance.

💡 Point clé : Grid Search + Cross-Validation = on choisit les hyperparamètres sur le jeu d'entraînement UNIQUEMENT, jamais sur le test set.
solution_ex2_1.py
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, roc_auc_score
from imblearn.over_sampling import SMOTE
import matplotlib.pyplot as plt

np.random.seed(42)
n = 800

# ── 1. Dataset ───────────────────────────────────────────────
df = pd.DataFrame({
    &#x27;pH'             : np.random.uniform(5.5, 9.5, n),
    &#x27;turbidite_NTU'  : np.random.exponential(10, n).clip(max=50),
    &#x27;chlore_mg_L'    : np.random.uniform(0, 2, n),
    &#x27;coliformes'     : np.random.randint(0, 500, n),
    &#x27;conductivite'   : np.random.uniform(50, 2000, n),
    &#x27;temperature'    : np.random.uniform(15, 35, n),
    &#x27;source'         : np.random.choice(['puits', 'riviere', 'robinet', 'source'], n),
})

score = (
    ((df[&#x27;pH'] >= 6.5) & (df['pH'] <= 8.5)).astype(float) * 0.30
    + (df[&#x27;turbidite_NTU'] < 5).astype(float)              * 0.25
    + (df[&#x27;chlore_mg_L']   < 1).astype(float)              * 0.20
    + (df[&#x27;coliformes']    < 50).astype(float)              * 0.15
    + (df[&#x27;conductivite']  < 1000).astype(float)            * 0.10
    + np.random.uniform(-0.1, 0.1, n)
)
df[&#x27;potable'] = (score > 0.50).astype(int)
print(f"Eau potable : {df[&#x27;potable'].mean()*100:.1f}%")

# ── 2. Encodage + split ──────────────────────────────────────
df_enc = pd.get_dummies(df, columns=[&#x27;source'], drop_first=True)
X = df_enc.drop(&#x27;potable', axis=1)
y = df_enc[&#x27;potable']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

# ── 3. SMOTE si déséquilibre > 60/40 ─────────────────────────
dist = y_train.value_counts(normalize=True)
print(f"Distribution classes : {dist.to_dict()}")
if dist.min() < 0.40:
    sm = SMOTE(random_state=42, k_neighbors=5)
    X_train, y_train = sm.fit_resample(X_train, y_train)
    print(f"Après SMOTE : {dict(zip(*np.unique(y_train, return_counts=True)))}")

# ── 4. Grid Search ───────────────────────────────────────────
param_grid = {
    &#x27;n_estimators'     : [50, 100, 200],
    &#x27;max_depth'        : [5, 10, None],
    &#x27;min_samples_split': [2, 5],
}
gs = GridSearchCV(
    RandomForestClassifier(random_state=42, n_jobs=-1),
    param_grid, cv=5, scoring=&#x27;roc_auc', n_jobs=-1, verbose=0
)
gs.fit(X_train, y_train)
print(f"\nMeilleurs paramètres : {gs.best_params_}")
print(f"Meilleur ROC-AUC (CV) : {gs.best_score_:.4f}")

best_rf      = gs.best_estimator_
y_pred       = best_rf.predict(X_test)
y_pred_proba = best_rf.predict_proba(X_test)[:, 1]

print("\n=== Rapport de Classification ===")
print(classification_report(y_test, y_pred, target_names=[&#x27;Non Potable', 'Potable']))
print(f"ROC-AUC test : {roc_auc_score(y_test, y_pred_proba):.4f}")

# ── 5. Importance des features ───────────────────────────────
importances = pd.Series(best_rf.feature_importances_, index=X.columns)
importances.sort_values(ascending=True).plot(
    kind=&#x27;barh', figsize=(9, 6), color='#2a5298')
plt.title("Importance des Features — Qualité d&#x27;Eau Rwanda")
plt.xlabel(&#x27;Importance (Gini)')
plt.tight_layout()
plt.savefig(&#x27;feature_importance_eau_rwanda.png', dpi=300)
plt.show()

print("\n=== Top 5 Features ===")
for feat, imp in importances.sort_values(ascending=False).head(5).items():
    print(f"  {feat:<25} : {imp:.4f}")
📅 Semaine 3 — Boosting & Interprétabilité

Corrections · Semaine 3 — XGBoost · SHAP · Métriques avancées

Corr. 3.1 XGBoost + SHAP — Détection Paludisme (Tanzanie) 🏥

Dataset 2 000 zones · XGBClassifier avec scale_pos_weight · ROC-AUC / F1 / Precision / Recall · SHAP summary_plot · Top 3 facteurs.

💡 Point clé : scale_pos_weight = nb_négatifs / nb_positifs corrige le déséquilibre de classes dans XGBoost sans nécessiter de rééchantillonnage.
solution_ex3_1.py
import pandas as pd
import numpy as np
import xgboost as xgb
from sklearn.model_selection import train_test_split
from sklearn.metrics import (classification_report, roc_auc_score,
                             f1_score, precision_score, recall_score)
import shap
import matplotlib.pyplot as plt

np.random.seed(42)
n = 2000

# ── 1. Dataset — Tanzanie ────────────────────────────────────
df = pd.DataFrame({
    &#x27;temperature'            : np.random.uniform(18, 35, n),
    &#x27;humidite_pct'           : np.random.uniform(30, 95, n),
    &#x27;altitude_m'             : np.random.uniform(0, 3000, n),
    &#x27;densite_pop'            : np.random.exponential(100, n).clip(max=1000),
    &#x27;acces_eau_pct'          : np.random.uniform(10, 95, n),
    &#x27;couverture_moustiq_pct' : np.random.uniform(5, 85, n),
})

score = (
    (df[&#x27;temperature'] - 18) / 17 * 0.25
    + df[&#x27;humidite_pct'] / 95     * 0.25
    + (1 - df[&#x27;altitude_m'] / 3000) * 0.15
    + (df[&#x27;densite_pop'] / 500).clip(upper=1) * 0.15
    + (1 - df[&#x27;acces_eau_pct'] / 95) * 0.10
    + (1 - df[&#x27;couverture_moustiq_pct'] / 85) * 0.10
    + np.random.uniform(-0.10, 0.10, n)
)
df[&#x27;zone_risque'] = (score > 0.45).astype(int)
print(f"Zones à risque : {df[&#x27;zone_risque'].mean()*100:.1f}%")

# ── 2. XGBoost ───────────────────────────────────────────────
X = df.drop(&#x27;zone_risque', axis=1)
y = df[&#x27;zone_risque']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

scale_w = (y_train == 0).sum() / (y_train == 1).sum()
model   = xgb.XGBClassifier(
    n_estimators=100, max_depth=6, learning_rate=0.1,
    scale_pos_weight=scale_w, random_state=42, eval_metric=&#x27;logloss'
)
model.fit(X_train, y_train)

y_pred       = model.predict(X_test)
y_pred_proba = model.predict_proba(X_test)[:, 1]

# ── 3. Métriques ─────────────────────────────────────────────
print("\n=== Rapport de Classification ===")
print(classification_report(y_test, y_pred,
      target_names=[&#x27;Faible Risque', 'Haut Risque']))
print(f"ROC-AUC   : {roc_auc_score(y_test, y_pred_proba):.4f}")
print(f"F1-Score  : {f1_score(y_test, y_pred):.4f}")
print(f"Precision : {precision_score(y_test, y_pred):.4f}")
print(f"Recall    : {recall_score(y_test, y_pred):.4f}")

# ── 4. SHAP Summary Plot ─────────────────────────────────────
explainer   = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)

shap.summary_plot(shap_values, X_test, show=False)
plt.title(&#x27;SHAP Summary Plot — Détection Paludisme Tanzanie')
plt.tight_layout()
plt.savefig(&#x27;shap_paludisme_tanzanie.png', dpi=300, bbox_inches='tight')
plt.show()

# ── 5. Top 3 facteurs influents ──────────────────────────────
mean_abs_shap = np.abs(shap_values).mean(axis=0)
top3 = pd.Series(mean_abs_shap, index=X.columns).sort_values(ascending=False).head(3)
print("\n=== Top 3 Facteurs les Plus Influents (SHAP) ===")
for rang, (feat, val) in enumerate(top3.items(), 1):
    print(f"  {rang}. {feat:<35} : SHAP moyen = {val:.4f}")
print("\n=> Interprétation : une valeur SHAP élevée signifie que")
print("   la variable pousse fortement la prédiction vers &#x27;zone à risque'.")
📅 Semaine 4 — Déploiement & Projet Final

Corrections · Semaine 4 — Flask · joblib · API REST

Corr. 4.1 Déploiement — API Flask pour la Détection de Fraude M-Pesa 🔐

Deux fichiers : (1) entraîner & sauvegarder le modèle RF avec joblib, (2) API Flask complète avec /health · /api/predict · /api/batch_predict + tests cURL.

💡 Point clé : Séparer la logique métier (modèle) de l'API (Flask) facilite les mises à jour : on peut recharger le modèle sans modifier le code de l'API.

📄 Fichier 1 — Entraînement & sauvegarde du modèle

sauvegarde_modele.py
# Fichier : sauvegarde_modele.py
# Entraîner et sauvegarder le modèle Random Forest (Étape 1)

import pandas as pd, numpy as np
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score, classification_report
import joblib, os

np.random.seed(42)
n_normal, n_fraud = 4750, 250

normal = pd.DataFrame({
    &#x27;montant_ksh'         : np.random.lognormal(7, 1.5, n_normal),
    &#x27;heure'               : np.random.randint(6, 22, n_normal),
    &#x27;nb_transactions_jour': np.random.randint(1, 5, n_normal),
    &#x27;anciennete_jours'    : np.random.randint(30, 1000, n_normal),
    &#x27;localisation_change' : np.random.choice([0, 1], n_normal, p=[0.9, 0.1]),
    &#x27;fraude'              : np.zeros(n_normal, dtype=int),
})
fraud = pd.DataFrame({
    &#x27;montant_ksh'         : np.random.lognormal(9, 1, n_fraud),
    &#x27;heure'               : np.random.choice(list(range(0, 6)) + list(range(22, 24)), n_fraud),
    &#x27;nb_transactions_jour': np.random.randint(5, 20, n_fraud),
    &#x27;anciennete_jours'    : np.random.randint(1, 30, n_fraud),
    &#x27;localisation_change' : np.random.choice([0, 1], n_fraud, p=[0.3, 0.7]),
    &#x27;fraude'              : np.ones(n_fraud, dtype=int),
})

df = pd.concat([normal, fraud]).sample(frac=1, random_state=42).reset_index(drop=True)
X  = df.drop(&#x27;fraude', axis=1)
y  = df[&#x27;fraude']
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

model = RandomForestClassifier(
    n_estimators=100, class_weight=&#x27;balanced', random_state=42, n_jobs=-1)
model.fit(X_train, y_train)

y_pred  = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
print(classification_report(y_test, y_pred, target_names=[&#x27;Normal', 'Fraude']))
print(f"ROC-AUC : {roc_auc_score(y_test, y_proba):.4f}")

# Sauvegarde
os.makedirs(&#x27;model', exist_ok=True)
metadata = {
    &#x27;features'          : list(X.columns),
    &#x27;optimal_threshold' : 0.5,
    &#x27;roc_auc'           : roc_auc_score(y_test, y_proba),
    &#x27;model_name'        : 'Random Forest — Fraude M-Pesa',
}
joblib.dump(model,    &#x27;model/random_forest_fraude_mpesa.pkl')
joblib.dump(metadata, &#x27;model/model_metadata.pkl')
print("✅ Modèle sauvegardé dans model/")

📄 Fichier 2 — Application Flask (API REST)

app.py
# Fichier : app.py
# API Flask avec /health + /api/predict + /api/batch_predict (Étapes 2/3/4)

from flask import Flask, request, jsonify
from flask_cors import CORS
import joblib, pandas as pd, numpy as np
from datetime import datetime

app      = Flask(__name__)
CORS(app)
model    = joblib.load(&#x27;model/random_forest_fraude_mpesa.pkl')
metadata = joblib.load(&#x27;model/model_metadata.pkl')

FEATURES  = metadata[&#x27;features']
THRESHOLD = metadata[&#x27;optimal_threshold']


# ── 4. Endpoint /health ─────────────────────────────────────
@app.route(&#x27;/health', methods=['GET'])
def health():
    return jsonify({
        &#x27;status'      : 'healthy',
        &#x27;model_loaded': model is not None,
        &#x27;features'    : FEATURES,
        &#x27;roc_auc'     : round(metadata['roc_auc'], 4),
        &#x27;timestamp'   : datetime.now().isoformat(),
    })


# ── 2. Endpoint /api/predict (unitaire) ─────────────────────
@app.route(&#x27;/api/predict', methods=['POST'])
def predict():
    try:
        data  = request.get_json()
        df_in = pd.DataFrame([data])[FEATURES]
        proba = float(model.predict_proba(df_in)[:, 1][0])
        pred  = int(proba >= THRESHOLD)
        niveau = &#x27;ÉLEVÉ' if proba >= 0.70 else ('MOYEN' if proba >= 0.40 else 'FAIBLE')
        return jsonify({
            &#x27;decision'          : 'BLOQUÉE'  if pred else 'AUTORISÉE',
            &#x27;probabilite_fraude': round(proba, 4),
            &#x27;niveau_risque'     : niveau,
            &#x27;seuil_utilise'     : THRESHOLD,
            &#x27;timestamp'         : datetime.now().isoformat(),
        })
    except KeyError as e:
        return jsonify({&#x27;error': f'Feature manquante : {e}'}), 400
    except Exception as e:
        return jsonify({&#x27;error': str(e)}), 500


# ── 3. Endpoint /api/batch_predict (en lot) ──────────────────
@app.route(&#x27;/api/batch_predict', methods=['POST'])
def batch_predict():
    try:
        data         = request.get_json()
        transactions = data.get(&#x27;transactions', [])
        if not transactions:
            return jsonify({&#x27;error': 'Aucune transaction fournie'}), 400

        df_in  = pd.DataFrame(transactions)[FEATURES]
        probas = model.predict_proba(df_in)[:, 1]

        results = []
        for i, p in enumerate(probas):
            results.append({
                &#x27;index'              : i,
                &#x27;decision'           : 'BLOQUÉE' if p >= THRESHOLD else 'AUTORISÉE',
                &#x27;probabilite_fraude' : round(float(p), 4),
                &#x27;niveau_risque'      : 'ÉLEVÉ' if p >= 0.70 else ('MOYEN' if p >= 0.40 else 'FAIBLE'),
            })

        nb_fraudes = sum(1 for r in results if r[&#x27;decision'] == 'BLOQUÉE')
        return jsonify({
            &#x27;total'             : len(results),
            &#x27;fraudes_detectees' : nb_fraudes,
            &#x27;taux_fraude_pct'   : round(nb_fraudes / len(results) * 100, 2),
            &#x27;resultats'         : results,
        })
    except Exception as e:
        return jsonify({&#x27;error': str(e)}), 500


if __name__ == &#x27;__main__':
    app.run(debug=True, host=&#x27;0.0.0.0', port=5000)


# ── 5. Tests cURL ────────────────────────────────────────────
# Test santé:
# curl http://localhost:5000/health
#
# Prédiction unitaire:
# curl -X POST http://localhost:5000/api/predict \
#   -H "Content-Type: application/json" \
#   -d '{"montant_ksh":85000,"heure":3,"nb_transactions_jour":12,"anciennete_jours":5,"localisation_change":1}'
#
# Lot de transactions:
# curl -X POST http://localhost:5000/api/batch_predict \
#   -H "Content-Type: application/json" \
#   -d '{"transactions":[
#     {"montant_ksh":1200,"heure":14,"nb_transactions_jour":2,"anciennete_jours":400,"localisation_change":0},
#     {"montant_ksh":92000,"heure":2,"nb_transactions_jour":15,"anciennete_jours":3,"localisation_change":1}
#   ]}'
#
# Résultat attendu transaction 1 : AUTORISÉE (risque FAIBLE)
# Résultat attendu transaction 2 : BLOQUÉE   (risque ÉLEVÉ)