Home » Analytics » Classification déséquilibrée que faire sans SMOTE ?

Classification déséquilibrée que faire sans SMOTE ?

Je commence rarement par SMOTE. Sur une classification déséquilibrée, je regarde d’abord le coût métier, les bonnes métriques, les pondérations et le seuil de décision. C’est souvent là que le modèle devient utile, surtout en fraude, churn, diagnostic ou détection d’anomalies.

Pourquoi l’accuracy ment ?

L’accuracy ment parce qu’elle peut applaudir un modèle qui ne sert à rien.

Dans un dataset fraude avec 2% de transactions frauduleuses, un modèle peut prédire “transaction normale” tout le temps. Il aura 98% d’accuracy. Ça a l’air propre dans un dashboard. En vrai, il rate 100% des fraudes. Zéro valeur business.

C’est ça, une classification déséquilibrée. Vous avez une classe majoritaire, ici les transactions normales, et une classe minoritaire, ici les fraudes. Le déséquilibre peut être léger, comme 98:2, ou violent, comme 100:1. Ce ratio veut juste dire qu’on a beaucoup plus d’exemples d’un côté que de l’autre. Et le modèle, si on le laisse tranquille, va souvent apprendre le raccourci le plus rentable statistiquement : prédire la classe majoritaire.

Le problème, c’est que les coûts ne sont pas symétriques. Rater une fraude, c’est une perte financière, parfois une enquête, parfois un client qui ne revient pas. Bloquer une transaction normale, c’est aussi un problème, mais pas le même. On parle de faux négatif quand une fraude passe sous le radar. On parle de faux positif quand une transaction normale est bloquée. Les deux coûtent quelque chose, mais rarement au même niveau.

Je vois ce piège souvent, pas seulement en fraude. Il revient dès qu’on cherche un événement rare :

  • Fraude bancaire ou e-commerce.
  • Diagnostic médical, avec une maladie rare à détecter.
  • Churn, quand peu de clients partent mais qu’ils comptent beaucoup.
  • Détection d’anomalies sur logs, capteurs ou cybersécurité.
  • Pannes industrielles, surtout quand elles arrivent peu mais coûtent cher.
  • Événements rares en assurance, énergie, transport ou qualité produit.

À la place de l’accuracy, je regarde des métriques qui racontent vraiment ce qui se passe. La précision dit si les alertes sont fiables. Le rappel dit si on attrape les vrais cas importants. Le F1 équilibre précision et rappel. La PR-AUC, c’est l’aire sous la courbe précision-rappel, utile quand la classe minoritaire est rare. Et la matrice de confusion reste indispensable, parce qu’elle montre les vrais positifs, faux positifs, vrais négatifs et faux négatifs sans maquillage.

Métrique Ce qu’elle mesure Utilité concrète
Accuracy Part totale de bonnes prédictions. Utile si les classes sont équilibrées, trompeuse sinon.
Précision Parmi les alertes fraude, combien sont vraiment des fraudes. Réduit les fausses alertes et évite de bloquer trop de clients légitimes.
Rappel Parmi les vraies fraudes, combien sont détectées. Mesure la capacité à ne pas rater les cas importants.
PR-AUC Qualité du compromis précision-rappel sur plusieurs seuils. Très utile quand la classe minoritaire est rare, comme 2% de fraude.

Comment poser une baseline propre ?

Avant de toucher à SMOTE, à XGBoost ou à un modèle “magique”, je pose toujours une baseline propre. Sinon on ne sait pas si on améliore vraiment le modèle, ou si on a juste déplacé le problème. Sur des sujets type fraude, avec 2% de cas positifs, c’est très facile de se raconter une histoire.

Le split stratifié est obligatoire ici. Si je coupe le dataset au hasard sans stratification, je peux me retrouver avec un train ou un test qui ne contient presque pas de fraude. Le modèle semble bon, les métriques bougent, mais le terrain de jeu est faux. Stratifier veut dire que je garde à peu près la même proportion de classes dans le train et dans le test.

J’utilise scikit-learn pour générer les données, faire les splits, entraîner les modèles simples et calculer les métriques. J’utilise imbalanced-learn plus tard, pour les techniques spécialisées sur classes déséquilibrées. Les pipelines deviennent utiles dès qu’on ajoute du scaling, du rééchantillonnage ou plusieurs étapes, parce qu’ils évitent les fuites de données.

pip install scikit-learn imbalanced-learn xgboost lightgbm

Voilà le terrain d’essai minimal que je pose souvent chez un client avant de discuter technique avancée.

import numpy as np
import pandas as pd

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, average_precision_score, confusion_matrix

RANDOM_STATE = 42

# Crée un dataset synthétique type fraude.
# Classe 1 = fraude, environ 2% des lignes.
X, y = make_classification(
    n_samples=20000,
    n_features=20,
    n_informative=6,
    n_redundant=4,
    n_classes=2,
    weights=[0.98, 0.02],
    flip_y=0.001,
    class_sep=1.0,
    random_state=RANDOM_STATE
)

columns = [f"feature_{i}" for i in range(X.shape[1])]
df = pd.DataFrame(X, columns=columns)
df["target"] = y

print("Distribution des classes")
print(df["target"].value_counts())
print(df["target"].value_counts(normalize=True).round(4))

X = df.drop(columns="target")
y = df["target"]

# Split stratifié pour garder la même proportion de fraudes 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=RANDOM_STATE
)

def evaluate_model(name, model, X_test, y_test):
    y_pred = model.predict(X_test)
    y_score = model.predict_proba(X_test)[:, 1]

    print(f"\n{name}")
    print("Rapport précision / rappel / F1")
    print(classification_report(y_test, y_pred, zero_division=0))

    pr_auc = average_precision_score(y_test, y_score)
    print(f"PR-AUC: {pr_auc:.4f}")

    print("Matrice de confusion")
    print(confusion_matrix(y_test, y_pred))

# Baseline naïve: prédit toujours la classe majoritaire.
dummy = DummyClassifier(strategy="most_frequent")
dummy.fit(X_train, y_train)
evaluate_model("DummyClassifier most_frequent", dummy, X_test, y_test)

# Baseline simple: régression logistique.
log_reg = LogisticRegression(max_iter=2000)
log_reg.fit(X_train, y_train)
evaluate_model("LogisticRegression", log_reg, X_test, y_test)

Le DummyClassifier est volontairement bête. Il donne le niveau zéro. Si une méthode ne bat pas clairement ça sur le rappel, la F1 et surtout la PR-AUC, je ne la garde pas. La PR-AUC est souvent plus parlante que l’accuracy ici, parce qu’elle regarde la qualité du classement sur la classe rare. Sur de la fraude, une accuracy à 98% peut juste vouloir dire “je prédis jamais fraude”.

Pourquoi SMOTE bloque parfois ?

SMOTE peut donner de très bons résultats sur un notebook, puis bloquer dès qu’on passe sur un cas métier réel. Son principe est simple : il prend des exemples de la classe minoritaire, cherche leurs voisins proches, puis fabrique de nouveaux points entre eux. Sur le papier, c’est malin. Dans la vraie vie, ça peut inventer des clients, des fraudes ou des incidents qui n’existent pas vraiment.

Le risque principal, c’est que SMOTE crée du signal artificiel là où il y a déjà de l’ambiguïté. Si les classes se chevauchent, il peut générer des points minoritaires dans une zone qui ressemble plutôt à la classe majoritaire. Si les variables sont bruitées, il amplifie ce bruit. Si la fraude évolue vite, il apprend une photo du passé. Si les données ont beaucoup de dimensions, la notion de “voisin proche” devient moins fiable. Et si les faux positifs coûtent cher, comme bloquer un bon client, ça peut vite faire mal.

Le piège que je vois souvent chez les clients, c’est SMOTE appliqué avant le split train/test. Là, on crée une fuite de données. Le modèle voit indirectement des informations du test pendant l’entraînement. Le score a l’air propre, mais il est trop optimiste. SMOTE doit rester uniquement dans le pipeline d’entraînement.

from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate

# X et y sont vos données et votre cible
# SMOTE est dans le pipeline, donc appliqué uniquement sur les folds d'entraînement
pipe = Pipeline(steps=[
    ("smote", SMOTE(random_state=42)),
    ("model", LogisticRegression(max_iter=2000, class_weight=None))
])

cv = StratifiedKFold(
    n_splits=5,
    shuffle=True,
    random_state=42
)

scoring = {
    "average_precision": "average_precision",
    "recall": "recall",
    "precision": "precision",
    "f1": "f1"
}

scores = cross_validate(
    estimator=pipe,
    X=X,
    y=y,
    cv=cv,
    scoring=scoring,
    n_jobs=-1
)

print(scores)
Approche Ce que j’en pense
SMOTE Utile si les voisins ont du sens, risqué si les classes se mélangent.
RandomOverSampler Plus simple, il duplique la minorité sans inventer de nouveaux points.
Sans resampling Souvent mon premier test, avec seuil ajusté, métriques propres et parfois class_weight.

SMOTE n’est pas mauvais. Il est juste rarement la première réponse que je teste sur un cas business sérieux.

Quelles méthodes marchent mieux ?

Quand je travaille sur une classification déséquilibrée, j’essaie d’abord de corriger le coût de l’erreur avant de fabriquer des lignes artificielles. C’est souvent plus propre. Le modèle comprend que rater la classe rare coûte plus cher, sans qu’on lui raconte une histoire avec des données synthétiques.

Il y a quatre niveaux qui marchent bien en pratique.

  • Pondération des classes : Avec scikit-learn, class_weight=’balanced’ augmente automatiquement le poids des classes rares. Une erreur sur la classe minoritaire pèse plus lourd dans l’entraînement.
  • Modèles équilibrés : BalancedRandomForestClassifier rééchantillonne les classes dans chaque arbre. C’est utile quand les relations sont non linéaires.
  • Boosting avec poids : XGBoost utilise scale_pos_weight. Je le règle souvent avec nombre_majoritaire / nombre_minoritaire. C’est simple et très efficace quand le dataset est assez propre.
  • Fonctions de perte modernes : Focal loss force le modèle à se concentrer sur les exemples difficiles, au lieu de perdre trop de temps sur les cas faciles déjà bien classés. C’est très utilisé en deep learning, surtout quand la classe rare est noyée.
import pandas as pd

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from imblearn.ensemble import BalancedRandomForestClassifier
from xgboost import XGBClassifier

from sklearn.metrics import precision_score, recall_score, f1_score, average_precision_score

# Calcule le ratio entre la classe majoritaire et la classe minoritaire
class_counts = pd.Series(y_train).value_counts()
scale_pos_weight = class_counts.max() / class_counts.min()

# Définit les modèles à comparer
models = {
    "LogisticRegression balanced": LogisticRegression(
        class_weight="balanced",
        max_iter=1000,
        random_state=42
    ),
    "RandomForest balanced": RandomForestClassifier(
        class_weight="balanced",
        random_state=42,
        n_jobs=-1
    ),
    "BalancedRandomForest": BalancedRandomForestClassifier(
        random_state=42,
        n_jobs=-1
    ),
    "XGBoost weighted": XGBClassifier(
        scale_pos_weight=scale_pos_weight,
        eval_metric="logloss",
        random_state=42,
        n_jobs=-1
    )
}

results = []

for name, model in models.items():
    # Entraîne le modèle
    model.fit(X_train, y_train)

    # Prédit la classe finale
    y_pred = model.predict(X_test)

    # Récupère un score de probabilité pour calculer la PR-AUC
    y_score = model.predict_proba(X_test)[:, 1]

    results.append({
        "model": name,
        "precision": precision_score(y_test, y_pred),
        "recall": recall_score(y_test, y_pred),
        "f1": f1_score(y_test, y_pred),
        "pr_auc": average_precision_score(y_test, y_score)
    })

# Produit un tableau comparatif
results_df = pd.DataFrame(results).sort_values("pr_auc", ascending=False)
print(results_df)

Mon choix dépend surtout du contexte. Si j’ai besoin d’un modèle lisible, je pars sur une régression logistique pondérée. Si je soupçonne des interactions plus tordues entre variables, je teste une forêt équilibrée. Si la performance est prioritaire et que les données sont assez propres, XGBoost pondéré est souvent dur à battre. LightGBM propose aussi des options de pondération de classes, dans le même esprit.

Besoin Méthode que je teste d’abord
Interprétabilité LogisticRegression avec class_weight=’balanced’
Relations non linéaires BalancedRandomForestClassifier
Performance pure XGBClassifier avec scale_pos_weight
Deep learning ou cas très difficiles Focal loss

Comment régler le seuil final ?

Même un bon modèle peut devenir nul en production si je garde le seuil 0.5 par réflexe. Sur une classification déséquilibrée, ce seuil est souvent arbitraire. Le vrai sujet, c’est le compromis métier entre faux positifs et faux négatifs. Une alerte fraude inutile coûte du temps. Une fraude ratée coûte de l’argent. Ce n’est pas le même problème.

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 PR-AUC, ou aire sous la courbe précision-rappel, résume ce compromis sur tous les seuils. C’est souvent plus utile que la ROC-AUC quand la classe positive est rare.

import numpy as np
from sklearn.metrics import (
    precision_recall_curve,
    classification_report,
    average_precision_score,
    confusion_matrix
)

# On récupère le score de probabilité de la classe positive
y_score = model.predict_proba(X_valid)[:, 1]

# On calcule précision, rappel et seuils possibles
precision, recall, thresholds = precision_recall_curve(y_valid, y_score)

# Option 1 : choisir le seuil qui maximise le F2
# Le F2 donne plus de poids au rappel qu'à la précision
beta = 2
f2 = (1 + beta**2) * (precision[:-1] * recall[:-1]) / (
    (beta**2 * precision[:-1]) + recall[:-1] + 1e-9
)

best_idx = np.argmax(f2)
best_threshold_f2 = thresholds[best_idx]

# Option 2 : choisir un seuil qui respecte un rappel minimum
min_recall = 0.85
valid_idx = recall[:-1] >= min_recall

if valid_idx.any():
    best_idx_recall = np.argmax(np.where(valid_idx, precision[:-1], -1))
    best_threshold_recall = thresholds[best_idx_recall]
else:
    best_threshold_recall = best_threshold_f2

# Application du seuil choisi
threshold = best_threshold_recall
y_pred_custom = (y_score >= threshold).astype(int)

print("Seuil choisi :", threshold)
print("PR-AUC / Average Precision :", average_precision_score(y_valid, y_score))
print(confusion_matrix(y_valid, y_pred_custom))
print(classification_report(y_valid, y_pred_custom))

Je fais aussi attention à la calibration. Un score à 0.80 devrait représenter un risque plus élevé qu’un score à 0.40, et idéalement être comparable entre dossiers. C’est critique quand on trie des suspicions de fraude, des alertes médicales ou des clients à rappeler. Sinon, on croit prioriser les bons cas, mais on trie juste du bruit bien présenté.

Mon cadre pratique est simple :

  • Définir le coût métier d’un faux positif et d’un faux négatif.
  • Choisir la métrique principale, souvent rappel, précision, F2 ou PR-AUC.
  • Entraîner plusieurs modèles sans se bloquer sur un seul algorithme.
  • Comparer sur PR-AUC et rappel au seuil métier.
  • Ajuster le seuil sur validation, jamais sur intuition.
  • Tester sur des données récentes, parce que le passé propre ment souvent.
  • Monitorer la dérive des scores et des taux d’alerte en production.
Cas Métrique prioritaire Méthode à tester en premier
Fraude Rappel avec précision minimale Seuil sous contrainte de rappel + tri par score
Diagnostic Rappel ou F2 Seuil très sensible + validation clinique
Churn Précision ou gain métier Seuil basé sur ROI de campagne
Anomalie PR-AUC et taux d’alerte Seuil calibré sur capacité de traitement

On optimise quoi maintenant ?

Je ne dis pas que SMOTE ne sert jamais. Je dis juste que je ne le mets pas en premier par réflexe. Sur une classification déséquilibrée, je gagne souvent plus en posant une baseline propre, en arrêtant de regarder l’accuracy, en pondérant les erreurs, en testant des modèles adaptés et en réglant le seuil avec une vraie contrainte business. La bonne question n’est pas seulement quel modèle prédit le mieux, c’est quelle erreur vous acceptez de payer. Si vous faites ce travail proprement, vous obtenez un modèle plus fiable, plus lisible et surtout plus utile pour décider.

FAQ

  • Pourquoi l’accuracy est mauvaise sur une classification déséquilibrée ?
    Parce qu’elle peut être très haute même si le modèle ignore totalement la classe rare. Sur 2% de fraude, un modèle qui prédit toujours non fraude atteint 98% d’accuracy, mais il ne détecte aucune fraude. Je préfère regarder le rappel, la précision, la matrice de confusion et surtout la PR-AUC.
  • SMOTE est-il toujours une mauvaise idée ?
    Non. SMOTE peut aider dans certains cas, surtout quand les frontières entre classes sont propres. Le problème, c’est qu’il peut créer des exemples synthétiques peu réalistes, amplifier du bruit ou provoquer une fuite de données s’il est appliqué avant le split train/test. Je le teste, mais rarement en premier.
  • Quelle méthode tester avant SMOTE ?
    Je teste d’abord une baseline simple, puis des modèles avec pondération de classes comme class_weight=’balanced’, une forêt équilibrée ou un boosting avec scale_pos_weight. Ça garde les données réelles et ça force le modèle à mieux traiter les erreurs sur la classe minoritaire.
  • Quelle métrique utiliser pour un modèle de fraude ?
    La PR-AUC est souvent plus parlante que la ROC-AUC quand la fraude est rare. Ensuite, je regarde le rappel si je veux rater le moins de fraudes possible, et la précision si je veux limiter les fausses alertes. Le bon choix dépend du coût métier.
  • Pourquoi régler le seuil de décision ?
    Parce que le seuil 0.5 par défaut n’a souvent aucun sens en classification déséquilibrée. Un modèle peut bien classer les risques, mais prendre de mauvaises décisions si le seuil est trop haut ou trop bas. Régler le seuil permet d’aligner le modèle avec le coût réel des faux positifs et des faux négatifs.

 

 

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 qui doivent fiabiliser leurs données, industrialiser leurs modèles et transformer des scores en décisions business concrètes. 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 data, IA ou automatisation sérieusement, contactez-moi.

Retour en haut
DataMarket AI