Je traite d’abord le coût métier des erreurs, pas le ratio des classes. SMOTE peut aider, mais il casse vite sur des données réelles. Je vais cadrer les bonnes métriques, les pertes adaptées, les modèles équilibrés, le réglage de seuil et un pipeline fraud detection reproductible.
Pourquoi l’accuracy vous piège ?
L’accuracy vous piège parce qu’elle récompense souvent le modèle qui ignore la classe rare. C’est brutal, mais c’est exactement ce qui arrive sur beaucoup de sujets réels : fraude bancaire, maladie rare, churn client, panne machine, défaut qualité, incident de sécurité. Le cas intéressant est souvent celui qu’on voit le moins.
Quand je parle de déséquilibre de classes, je parle d’un dataset où une classe domine largement l’autre. Par exemple un ratio d’imbalance de 100:1 veut dire qu’il y a 100 exemples de la classe majoritaire pour 1 exemple de la classe minoritaire. Dit autrement, si vous avez 10 000 lignes, vous pouvez avoir 9 900 cas normaux et seulement 100 fraudes. Et souvent, ces 100 cas portent toute la valeur business, ou tout le risque.
Un modèle à 98 % d’accuracy peut donc être complètement inutile. S’il prédit “pas de fraude” presque tout le temps, il aura un très bon score global, parce qu’il a raison sur la majorité des lignes. Mais il rate précisément ce qu’on voulait détecter. C’est le genre de score qui rassure en comité, puis qui coûte cher en production.
Dans ces cas-là, je regarde plutôt ces métriques :
- Le rappel : Combien de vrais cas rares le modèle retrouve. Très utile quand rater un cas coûte cher.
- La précision : Parmi les alertes remontées, combien sont vraiment positives. Utile quand les faux positifs fatiguent les équipes.
- Le F1-score : Un compromis entre précision et rappel, pratique quand je veux équilibrer les deux.
- La PR-AUC et l’average precision : Beaucoup plus parlantes quand la classe positive est très rare.
- La matrice de confusion : Le tableau simple qui montre vrais positifs, faux positifs, vrais négatifs, faux négatifs.
- Le coût métier par erreur : Parce qu’un faux négatif et un faux positif n’ont presque jamais le même prix.
La ROC-AUC peut rester utile, bien sûr. Elle mesure la capacité du modèle à séparer les classes. Mais sur des classes très déséquilibrées, elle peut donner une impression trop optimiste. J’ai déjà vu un client vouloir optimiser le score global, alors que son vrai problème était simple : ne pas rater les rares dossiers à risque. On a changé la métrique, et toute la discussion produit a changé avec.
| Métrique | Ce qu’elle mesure | Quand je l’utilise | Piège possible |
| Accuracy | Part totale de bonnes prédictions | Classes équilibrées, erreurs de même coût | Masque les échecs sur la classe rare |
| Rappel | Part des vrais positifs détectés | Fraude, maladie, panne critique | Peut générer trop de fausses alertes |
| Précision | Qualité des alertes positives | Quand chaque alerte coûte du temps | Peut rater trop de vrais cas |
| F1 | Équilibre entre précision et rappel | Quand je veux un compromis simple | Cache le coût réel des erreurs |
| ROC-AUC | Séparation globale des classes | Comparaison générale de modèles | Trop optimiste si la classe positive est rare |
| PR-AUC | Performance sur précision et rappel | Classes très déséquilibrées | Moins intuitive au début |
Comment préparer un test fiable ?
Je prépare un test fiable avec un split stratifié, une baseline simple et des métriques orientées classe rare. C’est la base avant de parler de SMOTE, de pondération ou de modèles plus costauds. J’ai vu trop de projets où le score avait l’air bon, juste parce que le test était bancal.
Le split stratifié garde la même proportion de classes dans le train et dans le test. Si votre classe rare représente 2%, elle reste autour de 2% des deux côtés. Sans ça, vous pouvez vous retrouver avec un test set qui ne représente pas le vrai problème.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
confusion_matrix,
classification_report,
average_precision_score,
roc_auc_score,
)
# Création d'un dataset reproductible.
# Ici, environ 2% des lignes appartiennent à la classe minoritaire.
X, y = make_classification(
n_samples=20_000,
n_features=20,
n_informative=8,
n_redundant=4,
n_classes=2,
weights=[0.98, 0.02],
flip_y=0,
random_state=42,
)
# Inspection simple de la distribution.
classes, counts = np.unique(y, return_counts=True)
print("Distribution globale :")
for cls, count in zip(classes, counts):
print(f"Classe {cls} : {count} lignes ({count / len(y):.2%})")
# Split stratifié.
# La proportion de classe rare reste stable dans train et test.
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.25,
stratify=y,
random_state=42,
)
print("\nDistribution train :", np.bincount(y_train))
print("Distribution test :", np.bincount(y_test))
# Baseline simple.
# StandardScaler aide la régression logistique, surtout avec des variables d'échelles différentes.
model = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=1000, random_state=42),
)
model.fit(X_train, y_train)
# Probabilités nécessaires pour PR-AUC et ROC-AUC.
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
print("\nMatrice de confusion :")
print(confusion_matrix(y_test, y_pred))
print("\nRapport de classification :")
print(classification_report(y_test, y_pred, digits=4))
print("Average precision score :", average_precision_score(y_test, y_proba))
print("ROC AUC score :", roc_auc_score(y_test, y_proba))
L’Average Precision est souvent plus parlant que l’accuracy quand la classe rare compte vraiment. L’accuracy peut être excellente avec un modèle qui prédit presque toujours la classe majoritaire. Pas très utile si vous cherchez une fraude, une panne, un churn ou un défaut qualité.
Point important : je n’applique jamais de rééchantillonnage avant le split. Jamais. Si vous faites de l’oversampling avant de séparer train et test, vous risquez de copier de l’information du train vers le test. C’est de la fuite de données, et ça gonfle artificiellement vos scores.
| Erreur fréquente | Pourquoi c’est un problème |
| Split non stratifié | Le test peut contenir trop peu, ou trop, de classe rare. |
| Oversampling avant split | Ça crée une fuite de données entre train et test. |
| Optimisation de l’accuracy | Le modèle peut ignorer la classe rare et sembler performant. |
| Absence de baseline | Vous ne savez pas si votre solution complexe apporte vraiment quelque chose. |
| Test set trop petit | Quelques exemples rares en plus ou en moins changent tout le score. |
Pourquoi SMOTE déçoit souvent ?
SMOTE déçoit souvent parce qu’il fabrique des points plausibles mathématiquement, pas forcément réalistes métier. C’est le piège classique. SMOTE crée des exemples synthétiques de la classe minoritaire en interpolant entre des voisins proches. En gros, il prend deux fraudes qui se ressemblent, puis il invente une fraude “entre les deux”. Sur un dataset propre, simple, avec des variables continues bien comportées, ça peut marcher. Je l’ai déjà vu aider sur des problèmes industriels assez cadrés. Mais sur des vraies données métier, c’est rarement mon premier réflexe.
Ses variantes corrigent certains défauts sans changer le fond du sujet. Borderline-SMOTE se concentre sur les zones proches de la frontière de décision. ADASYN pousse plus fort sur les exemples difficiles. SMOTEENN et SMOTETomek mélangent sur-échantillonnage et nettoyage des points ambigus. C’est utile à tester, pas magique.
Les limites arrivent vite sur le terrain :
- Les données en haute dimension rendent la notion de “voisin proche” beaucoup moins fiable.
- Les variables catégorielles mal encodées peuvent générer des profils absurdes.
- Le bruit et les outliers sont amplifiés, donc le modèle apprend parfois les mauvais signaux.
- Les frontières complexes entre classes sont mal représentées par une simple interpolation.
- La fraude évolue, donc synthétiser le passé peut figer des comportements déjà dépassés.
- Les données temporelles supportent mal un rééquilibrage naïf, surtout si on mélange passé et futur.
- Le surapprentissage devient discret : les scores montent en validation, puis tombent en production.
Le point non négociable, c’est d’appliquer SMOTE uniquement sur le train. Le test doit rester intact, avec la vraie distribution. Sinon, on se raconte une histoire.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, recall_score
from sklearn.pipeline import Pipeline
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
# Jeu déséquilibré, proche d'un cas rare type fraude
X, y = make_classification(
n_samples=10000,
n_features=20,
weights=[0.98, 0.02],
random_state=42
)
# Split stratifié pour garder le même taux de positifs dans train et test
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.25,
stratify=y,
random_state=42
)
# Baseline sans SMOTE
baseline = Pipeline([
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=1000))
])
# Pipeline correct : SMOTE est appliqué seulement pendant fit, donc seulement sur le train
with_smote = ImbPipeline([
("scaler", StandardScaler()),
("smote", SMOTE(random_state=42)),
("model", LogisticRegression(max_iter=1000))
])
for name, model in [("Baseline", baseline), ("SMOTE", with_smote)]:
model.fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= 0.5).astype(int)
pr_auc = average_precision_score(y_test, proba)
recall = recall_score(y_test, pred)
print(f"{name} | PR-AUC: {pr_auc:.3f} | Rappel: {recall:.3f}")
Je compare toujours sur le même test set, avec les mêmes métriques. Sur classes rares, la PR-AUC, c’est souvent plus parlant que l’accuracy. Le rappel compte aussi, mais jamais seul, sinon on finit par tout classer positif.
| Situation | Décision | À essayer à la place |
| Données propres, continues, peu bruitées | Tester SMOTE | SMOTE simple, Borderline-SMOTE |
| Beaucoup de catégories ou encodage fragile | Éviter | Class weights, modèles arbres, encodage propre |
| Fraude, churn rare, signaux instables | Être très prudent | Seuil métier, pondération, validation temporelle |
| Outliers et bruit importants | Éviter SMOTE naïf | Nettoyage, SMOTETomek, SMOTEENN |
| Données temporelles | Éviter le mélange aléatoire | Split temporel, pondération, features plus robustes |
Quelles méthodes marchent mieux ?
Les méthodes qui marchent mieux sont souvent le coût sensible, les fonctions de perte adaptées, les ensembles équilibrés et parfois la détection d’anomalies. Dans mes missions, le gain vient rarement d’une technique spectaculaire. Il vient plutôt d’une pondération propre, d’une métrique adaptée, puis d’un seuil choisi pour le vrai coût métier.
Premier niveau, je pondère les erreurs. Avec scikit-learn, class_weight=’balanced’ donne plus de poids à la classe rare. Avec XGBoost, scale_pos_weight fait pareil côté gradient. Avec LightGBM, je regarde scale_pos_weight ou is_unbalance. C’est simple, robuste, et souvent suffisant.
from sklearn.metrics import average_precision_score, roc_auc_score, precision_score, recall_score, f1_score
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from imblearn.ensemble import BalancedRandomForestClassifier
from xgboost import XGBClassifier
def eval_model(model, X_test, y_test, seuil=0.5):
# Probabilité de la classe positive
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= seuil).astype(int)
print("PR-AUC :", average_precision_score(y_test, proba))
print("ROC-AUC :", roc_auc_score(y_test, proba))
print("Précision :", precision_score(y_test, pred, zero_division=0))
print("Rappel :", recall_score(y_test, pred, zero_division=0))
print("F1 :", f1_score(y_test, pred, zero_division=0))
lr = LogisticRegression(class_weight="balanced", max_iter=1000)
lr.fit(X_train, y_train)
eval_model(lr, X_test, y_test)
rf = RandomForestClassifier(class_weight="balanced", n_estimators=300, random_state=42)
rf.fit(X_train, y_train)
eval_model(rf, X_test, y_test)
brf = BalancedRandomForestClassifier(n_estimators=300, random_state=42)
brf.fit(X_train, y_train)
eval_model(brf, X_test, y_test)
neg = (y_train == 0).sum()
pos = (y_train == 1).sum()
scale = neg / pos
xgb = XGBClassifier(
scale_pos_weight=scale,
eval_metric="logloss",
random_state=42
)
xgb.fit(X_train, y_train)
eval_model(xgb, X_test, y_test)
Deuxième niveau, j’utilise des pertes modernes comme la focal loss. Une fonction de perte, c’est ce qui dit au modèle à quel point il s’est trompé. La focal loss force le modèle à moins regarder les exemples faciles, et à se concentrer sur les cas difficiles. Très utile quand la classe rare est noyée.
Troisième niveau, je passe aux ensembles adaptés. BalancedRandomForestClassifier sous-échantillonne intelligemment la classe majoritaire dans chaque arbre. EasyEnsembleClassifier entraîne plusieurs modèles sur des sous-échantillons différents. C’est souvent plus stable qu’un bricolage manuel.
Quatrième niveau, je bascule parfois en détection d’anomalies. Si j’ai très peu de positifs fiables, IsolationForest ou OneClassSVM peuvent aider à repérer ce qui sort du comportement normal. Mais ce n’est pas magique. Si les anomalies ressemblent aux cas normaux, le modèle va galérer.
| Problème rencontré | Méthode conseillée | Risque à surveiller |
| Classe rare mais bien labellisée | class_weight, scale_pos_weight | Seuil par défaut trop mauvais |
| Beaucoup de cas faciles | Focal loss | Réglage plus sensible |
| Modèle instable | BalancedRandomForest, EasyEnsemble | Temps d’entraînement |
| Très peu de positifs fiables | IsolationForest, OneClassSVM | Trop de faux positifs |
Comment choisir le bon seuil ?
Je choisis le bon seuil en fonction du coût des faux positifs et des faux négatifs, pas avec le seuil 0,5 par défaut.
Quand j’utilise predict_proba, le modèle ne me donne pas une décision. Il me donne un score, souvent interprété comme une probabilité. Par exemple 0,82 veut dire “ce dossier ressemble fortement à la classe positive”. Le seuil transforme ce score en action : au-dessus du seuil, je déclenche une alerte, en dessous, je laisse passer.
Sur un problème déséquilibré, 0,5 est rarement le bon seuil. En fraude, si seulement 1% des transactions sont frauduleuses, attendre 0,5 peut faire rater beaucoup trop de fraudes. Chez un client, on a baissé le seuil et accepté plus d’alertes inutiles, parce que chaque fraude ratée coûtait beaucoup plus cher qu’un contrôle manuel.
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_recall_curve, average_precision_score, confusion_matrix
from sklearn.calibration import CalibratedClassifierCV
# Jeu de données déséquilibré, proche d'un cas fraude
X, y = make_classification(
n_samples=20000,
n_features=20,
weights=[0.98, 0.02],
random_state=42
)
# Split stratifié pour garder la même proportion de fraude dans train et test
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.3,
stratify=y,
random_state=42
)
# Modèle pondéré pour donner plus d'importance à la classe rare
model = LogisticRegression(class_weight="balanced", max_iter=1000)
model.fit(X_train, y_train)
# Score de probabilité pour la classe positive
y_score = model.predict_proba(X_test)[:, 1]
# PR-AUC utile quand la classe positive est rare
pr_auc = average_precision_score(y_test, y_score)
precision, recall, thresholds = precision_recall_curve(y_test, y_score)
# thresholds a une valeur de moins que precision et recall
df = pd.DataFrame({
"threshold": thresholds,
"precision": precision[:-1],
"recall": recall[:-1]
})
df["f1"] = 2 * df["precision"] * df["recall"] / (df["precision"] + df["recall"] + 1e-12)
def business_cost(y_true, y_score, threshold, cost_fp=5, cost_fn=100):
# Exemple métier : fraude ratée = 100, alerte inutile = 5
# Ces chiffres sont à adapter avec les équipes métier
y_pred = (y_score >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
return fp * cost_fp + fn * cost_fn
df["cost"] = df["threshold"].apply(lambda t: business_cost(y_test, y_score, t))
best_f1 = df.sort_values("f1", ascending=False).head(1)
min_recall = 0.85
best_recall_constraint = (
df[df["recall"] >= min_recall]
.sort_values("precision", ascending=False)
.head(1)
)
best_cost = df.sort_values("cost", ascending=True).head(1)
result = pd.concat([
best_f1.assign(strategy="Max F1"),
best_recall_constraint.assign(strategy="Rappel minimal 85%"),
best_cost.assign(strategy="Coût métier minimal")
])
print("PR-AUC:", round(pr_auc, 4))
print(result[["strategy", "threshold", "precision", "recall", "f1", "cost"]])
# Si les probabilités doivent être interprétables, je calibre le modèle
calibrated_model = CalibratedClassifierCV(model, method="isotonic", cv=3)
calibrated_model.fit(X_train, y_train)
calibrated_scores = calibrated_model.predict_proba(X_test)[:, 1]
La calibration sert quand je veux que “0,20” veuille vraiment dire environ 20% de risque. CalibratedClassifierCV aide à corriger des probabilités trop confiantes ou mal alignées. Ce n’est pas obligatoire pour classer, mais c’est précieux quand le score part dans un outil métier.
Dans un pipeline fraud detection propre, je fais simple : split stratifié, baseline, modèle pondéré, comparaison avec la PR-AUC, réglage du seuil, validation métier, puis monitoring dans le temps. Le seuil n’est pas gravé dans le marbre. Si la fraude change, il bouge.
| Décision | Question à trancher |
| Métrique principale | F1, rappel minimal, précision minimale ou coût métier ? |
| Coût des erreurs | Combien coûte un faux négatif et un faux positif ? |
| Seuil | Quel seuil déclenche une alerte ? |
| Recalibrage | À quelle fréquence je recalcule les probabilités ? |
| Dérive des données | Les transactions changent-elles avec le temps ? |
| Classe rare | Le taux de fraude reste-t-il stable ? |
Et si le vrai sujet était le coût de vos erreurs ?
Le déséquilibre de classes n’est pas un problème de cosmétique statistique. C’est un problème de décision. SMOTE peut rendre service, mais je ne le mets jamais au centre sans preuve. Je préfère partir d’une baseline propre, mesurer avec PR-AUC, précision et rappel, pondérer les erreurs, tester des ensembles équilibrés, puis régler le seuil selon le coût métier réel. C’est moins sexy qu’une recette magique, mais ça tient mieux en production. Le bénéfice pour vous est simple : vous détectez mieux les cas rares qui comptent vraiment, sans vous laisser rassurer par une accuracy flatteuse.
FAQ
- Qu’est-ce qu’un déséquilibre de classes en machine learning ?
C’est une situation où une classe est beaucoup plus rare que l’autre. Par exemple, 2% de fraudes et 98% de transactions normales. Le modèle peut obtenir un bon score global tout en ratant presque tous les cas utiles. - Pourquoi SMOTE n’est pas toujours une bonne solution ?
SMOTE crée des exemples synthétiques entre voisins proches. Sur des données simples, ça peut aider. Sur des données bruitées, haute dimension, temporelles ou très métier, ça peut fabriquer des points peu réalistes et dégrader la généralisation. - Quelle métrique utiliser avec une classe rare ?
Je regarde surtout le rappel, la précision, le F1 et la PR-AUC. L’accuracy est souvent trompeuse. La bonne métrique dépend aussi du coût métier : rater une fraude, une maladie ou une panne n’a pas le même impact qu’une fausse alerte. - Quelle alternative simple tester avant SMOTE ?
Je commence souvent par un modèle baseline avec pondération des classes, comme class_weight=’balanced’ dans scikit-learn ou scale_pos_weight dans XGBoost et LightGBM. C’est simple, lisible, et souvent plus robuste qu’un oversampling mal contrôlé. - Pourquoi régler le seuil de décision ?
Parce que le seuil 0,5 par défaut n’est presque jamais le meilleur choix sur une classe rare. En ajustant le seuil, vous choisissez le compromis entre faux positifs et faux négatifs selon votre coût business réel.
A propos de l’auteur
Je suis Franck Scandolera, expert et formateur en tracking avancé server-side, Analytics Engineering, automatisation No/Low Code avec n8n, intégration de l’IA en entreprise et SEO/GEO. J’accompagne des équipes sur des sujets data très concrets : qualité de mesure, pipelines, scoring, modèles exploitables et décisions business. J’ai travaillé avec des références comme Logis Hôtel, Yelloh Village, BazarChic, la Fédération Française de Football ou Texdecor. Je dirige l’agence webAnalyste et l’organisme Formations Analytics. Si vous voulez cadrer un projet IA ou data sans bullshit, contactez-moi.
⭐ Analytics engineer, Data Analyst et Automatisation IA indépendant ⭐
- Ref clients : Logis Hôtel, Yelloh Village, BazarChic, Fédération Football Français, Texdecor…
Mon terrain de jeu :
- Data Analyst & Analytics engineering : tracking avancé (GTM server, e-commerce, CAPI, RGPD), entrepôt de données (BigQuery, Snowflake, PostgreSQL, ClickHouse), modèles (Airflow, dbt, Dataform), dashboards décisionnels (Looker, Power BI, Metabase, SQL, Python).
- Automatisation IA des taches Data, Marketing, RH, compta etc : conception de workflows intelligents robustes (n8n, App Script, scraping) connectés aux API de vos outils et LLM (OpenAI, Mistral, Claude…).
- Engineering IA pour créer des applications et agent IA sur mesure : intégration de LLM (OpenAI, Mistral…), RAG, assistants métier, génération de documents complexes, APIs, backends Node.js/Python.






