Je choisirais d’abord le coût métier, les bonnes métriques et le réglage du seuil avant SMOTE. Sur une fraude à 2 %, l’accuracy raconte souvent n’importe quoi. Le vrai sujet, c’est combien de cas rares vous ratez, et combien de faux positifs vous acceptez.
Pourquoi l’accuracy ment autant ?
Quand un client me dit “Mon modèle fait 98 % d’accuracy”, je regarde rarement ça comme une bonne nouvelle. Je demande plutôt : “Il détecte combien de fraudes ?”. Parce qu’un modèle peut avoir 98 % de bonnes réponses et être totalement inutile si les 2 % restants sont justement ce qui coûte cher.
La classification déséquilibrée, c’est quand une classe est beaucoup plus présente que l’autre dans les données. La classe majoritaire, c’est celle qu’on voit partout. La classe minoritaire, c’est l’événement rare qu’on cherche souvent à détecter. Une fraude, une panne, un cancer, un churn client, une anomalie machine. Le ratio d’imbalance, c’est simplement le rapport entre les deux. Si j’ai 99 000 transactions normales et 1 000 fraudes, j’ai un ratio de 99:1.
Dans ce cas, l’accuracy récompense surtout le fait de prédire “normal” tout le temps. Un modèle qui dit “pas de fraude” sur toutes les transactions obtient déjà 99 % d’accuracy. Et pourtant, il rate toutes les fraudes. C’est le genre de score qui rassure en réunion, puis qui fait mal dans le compte de résultat.
Le vrai sujet, c’est le coût des erreurs. Toutes les erreurs ne se valent pas.
- Un faux négatif, c’est une fraude réelle que le modèle laisse passer. Ça peut coûter cher.
- Un faux positif, c’est une transaction normale bloquée à tort. C’est gênant, mais parfois moins grave.
- Dans le médical, rater une maladie peut être dramatique. Déclencher un examen inutile coûte aussi quelque chose, mais ce n’est pas le même risque.
- Dans le churn, rater un client qui va partir peut coûter un abonnement annuel. Contacter un client qui n’allait pas partir coûte surtout un email ou une remise.
Pour la classe rare, je regarde plutôt des métriques qui racontent quelque chose d’utile. La matrice de confusion montre les vrais positifs, faux positifs, vrais négatifs et faux négatifs. La précision dit, parmi les alertes, combien sont vraiment positives. Le rappel dit, parmi les vrais cas positifs, combien j’en ai capturés. Le F1 mélange précision et rappel quand je veux un compromis.
La ROC-AUC peut rester flatteuse quand la classe positive est très rare, parce qu’elle tient beaucoup compte des vrais négatifs, qui sont nombreux. La PR-AUC, Precision-Recall AUC, se concentre sur la classe positive. Elle est souvent plus lisible quand ce qui m’intéresse, c’est justement l’événement rare.
| Métrique | Ce qu’elle raconte vraiment |
| Accuracy | Le taux global de bonnes prédictions, souvent trompeur si la classe rare est minuscule. |
| Précision | La qualité des alertes positives envoyées par le modèle. |
| Rappel | La capacité à ne pas rater les vrais cas rares. |
| PR-AUC | La performance sur la classe positive, surtout utile quand elle est rare. |
Comment créer un test propre ?
Pour tester sérieusement une méthode sur une classification déséquilibrée, je commence toujours simple. Un dataset stable, un split propre, une baseline bête, puis une métrique qui regarde vraiment la classe rare. Ici je simule une fraude avec 20 000 lignes et environ 2 % de positifs. C’est volontairement brutal, parce que dans la vraie vie, c’est souvent ça le problème.
J’utilise un environnement classique : scikit-learn pour les datasets, le split et les métriques, imbalanced-learn pour les méthodes type SMOTE plus tard, XGBoost et LightGBM pour les modèles solides sur données tabulaires.
pip install scikit-learn imbalanced-learn xgboost lightgbm
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.metrics import classification_report, confusion_matrix, average_precision_score, precision_recall_curve
# Dataset synthétique : 20 000 lignes, environ 2 % de classe positive
X, y = make_classification(
n_samples=20000,
n_features=30,
n_informative=8,
n_redundant=6,
n_clusters_per_class=2,
weights=[0.98, 0.02],
flip_y=0.01,
class_sep=1.0,
random_state=42
)
# Split stratifié pour garder le même taux de fraude en 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('Distribution train:', np.bincount(y_train))
print('Distribution test:', np.bincount(y_test))
# Baseline naïve : prédit toujours la classe majoritaire
baseline = DummyClassifier(strategy='most_frequent')
baseline.fit(X_train, y_train)
y_pred = baseline.predict(X_test)
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, zero_division=0))
# Fonction simple pour suivre la PR-AUC, plus pertinente sur classe rare
def evaluate_model(name, model, X_test, y_test):
y_pred = model.predict(X_test)
if hasattr(model, 'predict_proba'):
y_score = model.predict_proba(X_test)[:, 1]
pr_auc = average_precision_score(y_test, y_score)
else:
pr_auc = None
print('\n---', name, '---')
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred, zero_division=0))
print('PR-AUC:', pr_auc)
Le point le plus important ici, c’est stratify=y. Ça force le train et le test à garder à peu près le même ratio de fraude. Sans ça, vous pouvez vous retrouver avec un test trop facile ou trop bizarre, surtout quand la classe positive est rare.
La baseline avec DummyClassifier prédit toujours la classe majoritaire. Elle va probablement afficher une accuracy très haute, autour de 97 ou 98 %. Et pourtant elle ne détecte aucune fraude. C’est exactement pour ça que l’accuracy seule est dangereuse sur ce type de problème.
La fonction evaluate_model affiche la matrice de confusion, le rapport précision-rappel, et la PR-AUC. La PR-AUC, c’est l’aire sous la courbe précision-rappel. Elle est souvent bien plus utile que la ROC-AUC quand les positifs sont rares, parce qu’elle se concentre sur la qualité de détection de la classe minoritaire.
Avec cette base, je sais déjà si un modèle fait mieux qu’un modèle idiot. Et surtout, j’évite de comparer SMOTE et les autres méthodes sur une fondation bancale.
Pourquoi SMOTE déçoit souvent ?
SMOTE m’a souvent déçu pour une raison simple. Il crée des exemples minoritaires qui ont l’air propres mathématiquement, mais qui ne respectent pas toujours la vraie forme du problème.
SMOTE fonctionne par interpolation. Il prend un point de la classe rare, cherche ses voisins minoritaires les plus proches, puis fabrique un nouveau point entre les deux. Sur un jeu simple, avec deux variables bien séparées, ça peut marcher. La frontière devient plus lisible pour le modèle, surtout avec une régression logistique ou un SVM.
Il existe quelques variantes utiles, sans magie non plus :
- Borderline-SMOTE génère surtout autour de la frontière entre classes.
- SMOTEENN combine sur-échantillonnage et nettoyage avec Edited Nearest Neighbors, une méthode qui retire certains points ambigus.
- SMOTETomek ajoute SMOTE puis supprime des paires Tomek, c’est-à-dire des voisins opposés très proches qui brouillent la frontière.
Le problème arrive vite en réel. Des données bruitées, beaucoup de dimensions, des variables catégorielles mal encodées, et SMOTE commence à inventer des points dans des zones où personne ne devrait être. J’ai vu ça chez des clients sur de la fraude, du churn, du risque crédit. Le score du notebook monte, souvent l’AUC ou le recall. Puis on regarde les faux positifs, la stabilité par semaine, les segments métier, et là ça se dégrade.
Le piège, c’est que SMOTE peut créer une frontière de décision artificielle. Le modèle apprend des points synthétiques trop propres, parfois faux, et surapprend une géométrie qui n’existe pas vraiment. Je préfère souvent tester d’abord des approches coût-sensibles. Avec class_weight, on dit au modèle que rater la classe rare coûte plus cher. Avec XGBoost ou LightGBM, scale_pos_weight joue un rôle proche. Et les ensembles équilibrés comme Balanced Random Forest rééquilibrent les échantillons à chaque arbre, ce qui est souvent plus stable.
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from imblearn.pipeline import Pipeline as ImbPipeline
from imblearn.over_sampling import SMOTE
from imblearn.ensemble import BalancedRandomForestClassifier
from xgboost import XGBClassifier
# Modèle coût-sensible : on pénalise davantage les erreurs sur la classe rare
logreg_weighted = Pipeline(steps=[
('scaler', StandardScaler()),
('model', LogisticRegression(class_weight='balanced', max_iter=1000, random_state=42))
])
logreg_weighted.fit(X_train, y_train)
evaluate_model('Logistic Regression class_weight balanced', logreg_weighted, X_test, y_test)
# SMOTE doit être appliqué uniquement sur le train, jamais avant le split
smote_pipeline = ImbPipeline(steps=[
('scaler', StandardScaler()),
('smote', SMOTE(random_state=42)),
('model', LogisticRegression(max_iter=1000, random_state=42))
])
smote_pipeline.fit(X_train, y_train)
evaluate_model('SMOTE plus Logistic Regression', smote_pipeline, X_test, y_test)
# Ensemble pensé pour l’imbalance
brf = BalancedRandomForestClassifier(
n_estimators=300,
random_state=42,
n_jobs=-1
)
brf.fit(X_train, y_train)
evaluate_model('Balanced Random Forest', brf, X_test, y_test)
# XGBoost : scale_pos_weight approxime le ratio négatifs / positifs
neg, pos = np.bincount(y_train)
scale_pos_weight = neg / pos
xgb = XGBClassifier(
n_estimators=400,
max_depth=4,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
eval_metric='logloss',
scale_pos_weight=scale_pos_weight,
random_state=42,
n_jobs=-1
)
xgb.fit(X_train, y_train)
evaluate_model('XGBoost scale_pos_weight', xgb, X_test, y_test)
| Option | Quand l’utiliser |
| SMOTE | Quand les données sont numériques, propres, peu dimensionnelles, et que la classe rare forme des zones assez continues. |
| class_weight | Quand vous voulez une base simple, robuste, rapide à tester, sans fabriquer de données artificielles. |
| Ensemble équilibré | Quand les relations sont non linéaires et que vous voulez réduire l’impact du déséquilibre sans trop bidouiller les données. |
| XGBoost ou LightGBM | Quand le dataset est tabulaire, bruité, avec beaucoup de variables, et que vous avez besoin d’un modèle solide en production. |
Quel seuil faut-il choisir ?
Le seuil, c’est souvent le truc qu’on laisse à 0,5 par réflexe. Et en classification déséquilibrée, c’est souvent une très mauvaise idée. Beaucoup de modèles ne sortent pas directement une décision, ils sortent une probabilité. Par exemple : “Ce client a 7 % de risque de fraude”. Après, c’est nous qui décidons à partir de quel score on déclenche une alerte.
Le seuil 0,5 veut dire : “Je prédis positif seulement si le modèle est au moins à 50 % sûr”. Sur une classe rare, c’est parfois absurde. Si une fraude coûte très cher, un score à 12 % peut déjà mériter une vérification. J’ai vu ça chez un client : leur modèle était correct, mais leur seuil tuait complètement le rappel. Ils rataient presque tous les cas intéressants parce qu’ils avaient gardé 0,5 sans réfléchir.
Je règle le seuil avec la précision et le rappel. La précision répond à : “Parmi mes alertes, combien sont vraiment positives ?”. Le rappel répond à : “Parmi les vrais positifs, combien j’en attrape ?”. La courbe precision-recall permet de voir ce compromis seuil par seuil. Et le bon seuil, c’est celui qui respecte la contrainte métier. Par exemple attraper au moins 80 % des fraudes, ou ne pas générer plus d’alertes que l’équipe peut traiter.
import numpy as np
from sklearn.metrics import precision_recall_curve, confusion_matrix, classification_report
from sklearn.calibration import CalibratedClassifierCV
# Exemple avec le modèle XGBoost entraîné plus haut
y_score = xgb.predict_proba(X_test)[:, 1]
precision, recall, thresholds = precision_recall_curve(y_test, y_score)
# Choisir le seuil qui atteint un rappel cible avec la meilleure précision possible
recall_target = 0.80
valid_indexes = np.where(recall[:-1] >= recall_target)[0]
if len(valid_indexes) > 0:
best_index = valid_indexes[np.argmax(precision[valid_indexes])]
best_threshold = thresholds[best_index]
else:
best_index = None
best_threshold = 0.5
print('Seuil choisi:', best_threshold)
if best_index is not None:
print('Précision au seuil:', precision[best_index])
print('Rappel au seuil:', recall[best_index])
y_pred_tuned = (y_score >= best_threshold).astype(int)
print(confusion_matrix(y_test, y_pred_tuned))
print(classification_report(y_test, y_pred_tuned, zero_division=0))
# Calibration possible sur certains modèles si les probabilités sont mal calibrées
calibrated_model = CalibratedClassifierCV(xgb, method='isotonic', cv=3)
calibrated_model.fit(X_train, y_train)
calibrated_scores = calibrated_model.predict_proba(X_test)[:, 1]
Petite nuance importante : en vrai projet, je ne règle pas le seuil sur le test final. Je le fais sur un jeu de validation, ou en cross-validation. Le test final doit rester propre, sinon on triche sans s’en rendre compte.
Quand on veut interpréter les scores comme des risques, je regarde aussi la calibration. CalibratedClassifierCV peut aider à rendre les probabilités plus cohérentes. Ça ne rend pas le modèle magique. Ça rend juste les scores plus exploitables pour décider.
| Stratégie | Quand l’utiliser |
| Maximiser le rappel | Quand rater un positif coûte cher, comme fraude, panne, maladie. |
| Maximiser la précision | Quand chaque alerte coûte du temps humain ou crée une mauvaise expérience. |
| Optimiser selon un coût métier | Quand on peut chiffrer le coût des faux positifs et des faux négatifs. |
Quel cadre garder en production ?
En production, je garde rarement la solution la plus “maligne”. Je garde celle qui détecte correctement la classe rare, qui reste stable dans le temps, et qui se relie à une vraie décision métier. Une fraude bloquée, une alerte envoyée, un dossier revu par un analyste. Pas juste un score Kaggle.
Mon cadre tient en quatre niveaux. Niveau données : split stratifié, ou temporel si l’ordre des événements compte, nettoyage propre, et surtout zéro fuite de données. Niveau modèle : baseline simple, puis class_weight ou scale_pos_weight, c’est-à-dire des paramètres qui pénalisent plus les erreurs sur la classe rare. Niveau ensemble : Balanced Random Forest ou boosting si le signal est plus complexe. Niveau décision : calibration des probabilités, puis seuil métier selon la capacité réelle à traiter les alertes.
- Niveau données : Je vérifie l’imbalance, les doublons, les variables suspectes, et je garde un test set intouchable.
- Niveau modèle : Je commence par une baseline, puis je teste le coût-sensible avant de générer artificiellement des exemples.
- Niveau ensemble : Je passe aux modèles plus lourds seulement si le gain est net sur PR-AUC, rappel, précision et coût métier.
- Niveau décision : Je règle le seuil, parce qu’un modèle à 0,5 par défaut en fraude, c’est souvent une mauvaise blague.
Sur un pipeline fraude réaliste, ça ressemble à ça : ingestion des transactions, création de features sans fuite, modèle qui sort un score, seuil qui transforme le score en alerte, file de traitement analyste, retour terrain, puis monitoring. Les retours analystes sont précieux. Chez un client, c’est là qu’on a trouvé que le modèle était bon techniquement, mais mauvais opérationnellement : trop d’alertes le lundi matin, équipe saturée, donc faux gain.
# Pipeline logique de détection de fraude
# 1. Charger les transactions et les labels historiques
# 2. Construire les variables explicatives sans fuite de données
# 3. Séparer train, validation, test en stratifié ou temporel selon le contexte
# 4. Entraîner plusieurs modèles : baseline, coût-sensible, ensemble, boosting
# 5. Comparer sur PR-AUC, rappel, précision et coût métier estimé
# 6. Calibrer les probabilités si les scores doivent être interprétés
# 7. Choisir un seuil aligné avec la capacité de traitement des alertes
# 8. Monitorer dérive de données, taux d’alertes, faux positifs, faux négatifs
results = []
def add_result(name, pr_auc, precision_at_threshold, recall_at_threshold, alert_rate):
results.append({
'model': name,
'pr_auc': pr_auc,
'precision': precision_at_threshold,
'recall': recall_at_threshold,
'alert_rate': alert_rate
})
# L’objectif n’est pas le meilleur score isolé, mais le meilleur compromis exploitable
La détection d’anomalies peut aider quand les labels sont rares, bruités ou en retard. Je la vois comme une piste complémentaire, pas comme un remplacement automatique de la classification supervisée.
| Situation rencontrée | Méthode à tester en premier | Point de vigilance |
| Classe rare mais labels fiables. | class_weight ou scale_pos_weight. | Surveiller précision et rappel au seuil métier. |
| Signal non linéaire et données tabulaires. | Boosting coût-sensible. | Calibrer les scores avant décision. |
| Très peu de positifs. | Baseline robuste puis anomalie en complément. | Éviter les conclusions sur un test trop petit. |
| SMOTE améliore la validation. | Le tester, sans automatisme. | Vérifier la stabilité sur test réel. |
Parfois SMOTE aide, oui. Je ne le jette pas. Je ne le mets juste plus en premier réflexe.
Alors, on garde SMOTE ou on le remet à sa place ?
Je ne jette pas SMOTE à la poubelle, je le remets juste à sa place. Sur une classification déséquilibrée, je commence par comprendre le coût des erreurs, choisir les bonnes métriques, poser une baseline propre, puis tester des approches coût-sensibles, des ensembles équilibrés ou du boosting avec pondération. Le seuil de décision fait souvent plus de différence qu’un oversampling automatique. Et en production, la stabilité compte autant que le score. Si vous gardez cette logique, vous évitez le modèle joli en notebook mais inutile côté business. Le bénéfice est simple : vous détectez mieux les cas rares, avec moins de bruit inutile.
FAQ
- Pourquoi l’accuracy est mauvaise sur une classification déséquilibrée ?
Parce qu’un modèle peut prédire presque toujours la classe majoritaire et afficher un score élevé. Si 98 % des lignes ne sont pas des fraudes, un modèle qui dit jamais fraude peut déjà sembler bon. Sauf qu’il rate précisément ce qui compte. - SMOTE est-il inutile pour les classes rares ?
Pas forcément. SMOTE peut aider sur certains datasets propres et simples. Le problème, c’est qu’il crée des exemples synthétiques qui peuvent déformer la frontière de décision, surtout avec du bruit, beaucoup de variables ou des données réelles complexes. - Quelle métrique regarder en premier ?
Je regarde souvent le rappel, la précision et la PR-AUC. Le rappel dit combien de cas rares on retrouve. La précision dit combien d’alertes sont vraiment utiles. La PR-AUC donne une lecture plus adaptée que l’accuracy quand la classe positive est très rare. - Que tester avant SMOTE ?
Je teste d’abord une baseline, puis des modèles coût-sensibles avec class_weight, scale_pos_weight dans XGBoost ou LightGBM, et des ensembles équilibrés. Souvent, ces approches respectent mieux les données réelles que l’ajout automatique de points synthétiques. - Pourquoi régler le seuil du modèle ?
Parce que le seuil par défaut à 0,5 n’a souvent aucun sens sur une classe rare. Le bon seuil dépend du coût métier, du rappel attendu et de la capacité à traiter les alertes. C’est souvent là qu’on gagne beaucoup en performance exploitable.
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, IA appliquée en entreprise et SEO/GEO. Avec webAnalyste et Formations Analytics, j’accompagne des équipes qui veulent fiabiliser leurs données, leurs modèles et leurs 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. Si vous voulez cadrer un projet data, IA ou automatisation 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.






