Détectez les anomalies
Dans l’enquête de salaires du service carrières, un diplômé déclare 650 k$ par an. Génie de la finance, ou 65 k$ saisi avec un zéro de trop ? Au chapitre 2, une seule valeur de ce genre suffisait à faire passer le « salaire typique » de 60 à 87 k$. Repérer ces valeurs avant d’apprendre est donc vital. Et parfois, les repérer est le but : une transaction bancaire frauduleuse, une panne de capteur, une intrusion dans un réseau.
Ce chapitre est un complément : la détection d’anomalies ne figurait pas dans les notes de ce cours, mais c’est un chapitre classique des cours de data mining, et elle prolonge directement l’étape 1 de la roue (la véracité des données). Notre fil rouge : l’enquête de salaires, puis les couples (GPA, salaire).
Qu’est-ce qu’une anomalie ?
Section intitulée « Qu’est-ce qu’une anomalie ? »Une anomalie (anomaly, outlier) est une observation qui s’écarte tellement des autres qu’on soupçonne qu’elle a été produite par un autre mécanisme. Cette définition, volontairement floue, recouvre trois réalités très différentes :
- une erreur : saisie en dollars au lieu de milliers de dollars, capteur débranché, GPA de 34 au lieu de 3,4 ;
- un cas rare mais réel : le fondateur de start-up, la transaction frauduleuse, le patient atteint d’une maladie orpheline ;
- une nouveauté (novelty) : un comportement jamais vu jusqu’ici, par exemple un nouveau type de fraude.
On distingue aussi les anomalies par leur forme. Une anomalie ponctuelle est atypique en elle-même (650 k$). Une anomalie contextuelle n’est atypique que dans son contexte : 45 k$ est un salaire banal en général, mais étonnant pour un ingénieur logiciel à New York. Une anomalie collective est un groupe d’observations normales une à une, mais anormales ensemble (cent petits retraits en une heure).
La difficulté principale est l’absence d’étiquettes : en général, personne n’a marqué à l’avance quelles lignes sont anormales. La détection d’anomalies est donc le plus souvent non supervisée, ce qui complique beaucoup l’évaluation (dernière section).
Les règles univariées
Section intitulée « Les règles univariées »Commençons par une seule variable, le salaire déclaré. Voici dix réponses à l’enquête, en k$ : 48, 52, 55, 58, 60, 61, 63, 66, 70 et 650.
Le z-score et son piège
Section intitulée « Le z-score et son piège »La règle la plus connue mesure l’écart à la moyenne en nombre d’écarts-types :
où est la moyenne de l’échantillon et son écart-type. On déclare anormale une valeur telle que : pour une loi normale, moins de 0,3 % des valeurs dépassent ce seuil.
Appliquons-la. La moyenne vaut et l’écart-type : la valeur 650 obtient . Elle n’est pas détectée.
Comment est-ce possible ? La valeur aberrante gonfle elle-même la moyenne (de 59 à 118) et surtout l’écart-type (de 6,9 à 187), et se cache ainsi derrière sa propre influence. C’est l’effet de masque (masking). Pire, il existe une borne : avec l’écart-type d’échantillon, aucune valeur ne peut dépasser
où est le nombre de valeurs. Pour , cette borne vaut . Avec dix valeurs, la règle ne peut jamais rien détecter, quelle que soit l’énormité de l’erreur.
Des statistiques robustes : médiane et MAD
Section intitulée « Des statistiques robustes : médiane et MAD »La solution consiste à remplacer moyenne et écart-type par des statistiques qu’une valeur extrême ne peut pas faire bouger : la médiane et l’écart absolu médian (MAD, median absolute deviation), la médiane des écarts à la médiane. Le z-score robuste s’écrit
Le facteur 0,6745 rend le MAD comparable à un écart-type pour des données normales. On signale généralement les valeurs telles que .
Sur nos salaires, la médiane vaut 60,5 et le MAD 5,5. La valeur 650 obtient : elle est détectée sans la moindre ambiguïté, et toutes les autres restent entre et .
La règle de l’écart interquartile
Section intitulée « La règle de l’écart interquartile »Autre règle robuste, celle qu’utilisent les boîtes à moustaches : on calcule le premier quartile , le troisième quartile et l’écart interquartile , puis on signale toute valeur hors de l’intervalle
import pandas as pd
salaires = pd.Series([48, 52, 55, 58, 60, 61, 63, 66, 70, 650]) # en k$
# z-score classique : victime de l'effet de masquez = (salaires - salaires.mean()) / salaires.std()print(salaires[z.abs() > 3]) # aucune valeur signalée
# z-score robuste (médiane et MAD)med = salaires.median()mad = (salaires - med).abs().median()z_rob = 0.6745 * (salaires - med) / madprint(salaires[z_rob.abs() > 3.5]) # 650
# Règle de l'écart interquartileq1, q3 = salaires.quantile([0.25, 0.75])iqr = q3 - q1print(salaires[(salaires < q1 - 1.5 * iqr) | (salaires > q3 + 1.5 * iqr)]) # 650Ces règles univariées sont précieuses pour l’étape 1 de la roue, variable par variable. Mais elles ne voient pas les anomalies contextuelles : un GPA de 3,9 est normal, un salaire de 40 k$ aussi, mais un diplômé à 3,9 de GPA et 40 k$ de salaire, dans une filière où tout le monde gagne 90 k$, est atypique. Il faut alors des méthodes multivariées.
Isolation Forest : isoler les cas faciles à séparer
Section intitulée « Isolation Forest : isoler les cas faciles à séparer »L’idée d’Isolation Forest est d’une simplicité désarmante : une anomalie est facile à isoler. Choisissez une variable au hasard, puis un seuil au hasard entre son minimum et son maximum, et coupez les données en deux. Recommencez dans chaque moitié, jusqu’à ce que chaque point soit seul. Un point perdu loin des autres se retrouve seul après très peu de coupes ; un point au cœur d’un nuage dense en demande beaucoup.
On construit ainsi une forêt de plusieurs centaines d’arbres de découpe aléatoire, chacun sur un petit sous-échantillon (256 points par défaut), et l’on mesure pour chaque point sa profondeur moyenne d’isolement . Le score d’anomalie est
- est le nombre de coupes nécessaires pour isoler dans un arbre ;
- est la taille du sous-échantillon de chaque arbre ;
- est la profondeur moyenne attendue pour un point quelconque, qui sert à normaliser ().
Un score proche de 1 signale une anomalie (isolée bien plus vite que la moyenne), un score autour de 0,5 ou en dessous un point ordinaire. Par exemple, avec , un point isolé en 2 coupes en moyenne obtient , un point isolé en 10 coupes .
La méthode a de beaux atouts : elle ne calcule aucune distance, passe très bien à l’échelle, et n’a pas besoin de standardiser les variables (les coupes se font variable par variable). Elle voit surtout les anomalies globales, éloignées de l’ensemble des données.
LOF : comparer une densité à celle de ses voisins
Section intitulée « LOF : comparer une densité à celle de ses voisins »Le facteur local d’anomalie (local outlier factor, LOF) répond au cas contextuel. Pour chaque point, on estime une densité locale à partir de la distance à ses plus proches voisins : plus les voisins sont proches, plus la densité est forte. Le LOF d’un point est le rapport entre la densité moyenne de ses voisins et sa propre densité.
- LOF proche de 1 : le point est aussi dense que son voisinage, il est normal.
- LOF nettement supérieur à 1 : le point est beaucoup moins dense que ses voisins, il est anormal localement.
Pourquoi « local » ? Imaginez deux groupes de diplômés dans le plan (GPA, salaire) : un groupe très serré d’ingénieurs logiciels autour de 90 k$, et un groupe plus dispersé de diplômés en lettres autour de 45 k$. Un ingénieur à 70 k$ est à distance modeste de son groupe, mais ce groupe est si serré que, relativement, il est très isolé. Une règle fondée sur la distance globale ne le verrait pas, car les littéraires sont souvent plus loin de leurs voisins que lui. Le LOF le repère, car il compare chaque point à son propre voisinage.
import numpy as npfrom sklearn.ensemble import IsolationForestfrom sklearn.neighbors import LocalOutlierFactorfrom sklearn.preprocessing import StandardScaler
# X : une ligne par diplômé, colonnes GPA et salaire (en k$)X = np.column_stack([gpa, salaire])
# Isolation Forest : pas besoin de standardiseriso = IsolationForest(n_estimators=300, contamination="auto", random_state=0).fit(X)score_iso = -iso.score_samples(X) # plus c'est grand, plus c'est anormal
# LOF : fondé sur des distances, il faut standardiserXs = StandardScaler().fit_transform(X)lof = LocalOutlierFactor(n_neighbors=20)etiquettes = lof.fit_predict(Xs) # -1 pour les anomalies, 1 sinonscore_lof = -lof.negative_outlier_factor_ # le LOF lui-même (proche de 1 : normal)
# Les 10 cas les plus suspects selon chaque méthode, à montrer à un expertprint(np.argsort(score_iso)[::-1][:10])print(np.argsort(score_lof)[::-1][:10])| Méthode | Type d’anomalie visé | Standardisation | Points forts | Limites |
|---|---|---|---|---|
| z-score | ponctuelle, univariée | sans objet | simple | effet de masque, suppose une loi normale |
| Médiane et MAD, IQR | ponctuelle, univariée | sans objet | robustes | une variable à la fois |
| Isolation Forest | globale, multivariée | inutile | rapide, grands volumes | voit mal les anomalies locales |
| LOF | locale, multivariée | indispensable | densités variables | coûteux, sensible à |
Les méthodes de partitionnement comme DBSCAN, qui étiquettent comme « bruit » les points hors des zones denses, peuvent aussi servir de détecteurs. Elles sont présentées dans le cours d’Analyse et fouille de données, avec k-means et le partitionnement hiérarchique.
Évaluer sans étiquettes
Section intitulée « Évaluer sans étiquettes »Voici le point délicat. Sans étiquettes, impossible de construire une matrice de confusion comme au chapitre 6. Comment savoir si le détecteur fonctionne ? Quelques pratiques :
- Faire valider par un expert. On trie les observations par score décroissant et l’on montre les 20 ou 50 premières à quelqu’un qui connaît le domaine. La proportion de vraies anomalies parmi elles, la précision dans les premiers (precision at k), est souvent la seule mesure disponible, et c’est la plus utile : c’est elle qui dit si l’équipe qui vérifie les alertes perd son temps.
- Utiliser les quelques étiquettes existantes. Si l’on connaît une poignée de fraudes avérées, on vérifie qu’elles obtiennent des scores élevés (en regardant leur rang, ou l’aire sous la courbe précision-rappel, avec la prévalence comme référence du hasard).
- Injecter des anomalies connues. On ajoute quelques erreurs synthétiques (un salaire multiplié par 10) et l’on vérifie qu’elles remontent en tête.
- Comparer les méthodes entre elles. Des cas signalés à la fois par Isolation Forest et par LOF sont de bons candidats ; des désaccords massifs invitent à creuser.
En résumé
Section intitulée « En résumé »- Une anomalie peut être une erreur, un cas rare réel ou une nouveauté ; elle peut être ponctuelle, contextuelle ou collective. On la signale, on ne la supprime pas automatiquement.
- Le z-score souffre de l’effet de masque et ne peut pas dépasser : avec 10 valeurs, la règle ne détecte rien.
- Les règles robustes (médiane et MAD, écart interquartile avec les bornes et ) résistent aux valeurs extrêmes.
- Isolation Forest repère les points qui s’isolent en peu de coupes aléatoires (score proche de 1) ; il est rapide et n’exige pas de standardisation.
- LOF compare la densité d’un point à celle de ses voisins ; il détecte les anomalies locales mais demande de standardiser et de choisir .
- Sans étiquettes, on évalue par validation experte des cas les mieux classés (précision dans les premiers), par quelques cas connus ou par des anomalies injectées.
Exercices
Section intitulée « Exercices »Exercice · Appliquer la règle de l'écart interquartile
facileVoici dix GPA déclarés : 3,1 ; 3,3 ; 3,4 ; 3,5 ; 3,6 ; 3,6 ; 3,7 ; 3,8 ; 3,9 ; 0,4. Avec la méthode d’interpolation de NumPy, on trouve et . Appliquez la règle de l’écart interquartile. La valeur signalée est-elle forcément une erreur ?
Voir la solution
, donc . Les bornes sont et . Seule la valeur 0,4 est hors de l’intervalle.
Ce n’est pas forcément une erreur : un GPA de 0,4 est possible (un étudiant qui a presque tout échoué), même s’il serait étonnant chez un diplômé. L’hypothèse la plus probable est une faute de frappe pour 3,4, mais seule une vérification à la source (le relevé de notes) permet de trancher. Remarquez qu’ici le z-score classique vaut environ : il ne signalerait rien.
En code
import numpy as np
gpa = np.array([3.1, 3.3, 3.4, 3.5, 3.6, 3.6, 3.7, 3.8, 3.9, 0.4])q1, q3 = np.percentile(gpa, [25, 75]) # interpolation linéaire par défautiqr = q3 - q1bas, haut = q1 - 1.5 * iqr, q3 + 1.5 * iqrprint(round(q1, 3), round(q3, 3), round(bas, 3), round(haut, 3)) # 3.325 3.675 2.8 4.2print(gpa[(gpa < bas) | (gpa > haut)]) # [0.4]
# Le z-score classique ne signale rienz = (gpa - gpa.mean()) / gpa.std(ddof=1)print(round(z[-1], 2)) # -2.77gpa <- c(3.1, 3.3, 3.4, 3.5, 3.6, 3.6, 3.7, 3.8, 3.9, 0.4)q <- quantile(gpa, c(0.25, 0.75)) # type 7 par défaut, comme NumPyiqr <- q[2] - q[1]bornes <- c(q[1] - 1.5 * iqr, q[2] + 1.5 * iqr)q; bornes # 3.325 3.675 2.8 4.2gpa[gpa < bornes[1] | gpa > bornes[2]] # 0.4
# Le z-score classique ne signale rienz <- (gpa - mean(gpa)) / sd(gpa)z[10] # -2.77Exercice · La borne du z-score
moyen- Calculez la valeur maximale possible de (avec l’écart-type d’échantillon) pour , , et valeurs.
- À partir de quelle taille d’échantillon la règle peut-elle, au moins en théorie, signaler quelque chose ?
- Pourquoi, même au-delà de cette taille, la règle reste-t-elle fragile ?
Voir la solution
- La borne vaut : environ pour , pour , pour et pour .
- Il faut . Pour , on a ; pour , . La règle ne peut donc rien signaler en dessous de 11 valeurs.
- La borne n’est atteinte que dans le cas extrême d’une seule valeur isolée face à des valeurs toutes identiques. En pratique, la valeur aberrante gonfle toujours l’écart-type (effet de masque), et s’il y a plusieurs valeurs aberrantes, elles se masquent mutuellement. De plus, le seuil 3 suppose des données à peu près normales, ce que les salaires (asymétriques) ne sont pas. D’où l’intérêt des versions robustes.
En code
import numpy as np
borne = lambda n: (n - 1) / np.sqrt(n) # |z| maximal possibleprint([round(float(borne(n)), 2) for n in (5, 10, 20, 100)]) # [1.79, 2.85, 4.25, 9.9]print(next(n for n in range(2, 100) if borne(n) > 3)) # 11
# La borne est atteinte : une valeur isolée face à n - 1 valeurs identiquesx = np.array([0.0] * 9 + [1000.0])print(round(abs((x[-1] - x.mean()) / x.std(ddof=1)), 2)) # 2.85, jamais plus de 3
# Effet de masque : deux salaires aberrants parmi 20 (borne 4,25)x = np.r_[np.arange(50, 68), 650, 700]print(np.round((x - x.mean()) / x.std(ddof=1), 2)[-2:]) # [2.79 3.05]borne <- function(n) (n - 1) / sqrt(n) # |z| maximal possibleborne(c(5, 10, 20, 100)) # 1.79 2.85 4.25 9.90which(borne(2:100) > 3)[1] + 1 # 11
# La borne est atteinte : une valeur isolée face à n - 1 valeurs identiquesx <- c(rep(0, 9), 1000)abs((x[10] - mean(x)) / sd(x)) # 2.85, jamais plus de 3
# Effet de masque : deux salaires aberrants parmi 20 (borne 4,25)x <- c(50:67, 650, 700)round(((x - mean(x)) / sd(x))[19:20], 2) # 2.79 3.05Exercice · Choisir une méthode
difficilePour chaque situation, proposez une méthode de détection et une façon d’évaluer le résultat.
- Vérifier, avant toute modélisation, les 600 salaires déclarés d’une enquête.
- Repérer, parmi 2 millions de transactions bancaires décrites par 15 variables numériques, les plus suspectes, sachant qu’une équipe peut en vérifier 200 par jour.
- Repérer, dans le plan (GPA, salaire), les diplômés atypiques au sein de leur filière, sachant que les filières ont des niveaux et des dispersions de salaire très différents.
Voir la solution
- Une variable, un volume modeste : une règle robuste (médiane et MAD, ou écart interquartile). On examine à la main chaque valeur signalée, en revenant si possible au questionnaire d’origine. Une boîte à moustaches par filière aide aussi à repérer les valeurs contextuelles.
- Gros volume, plusieurs variables, pas de notion de voisinage évidente : Isolation Forest, rapide et sans standardisation. On classe les transactions par score et l’on transmet chaque jour les 200 premières. L’évaluation naturelle est la précision dans les 200 premiers (combien de fraudes réelles l’équipe confirme), complétée par le rang des fraudes déjà connues.
- Densités très différentes d’une filière à l’autre : c’est le cas d’école du LOF, après standardisation, avec un assez grand pour couvrir le voisinage d’un diplômé dans sa filière (ou, plus simplement, un z-score robuste calculé filière par filière). On fait valider les cas signalés par le service carrières, qui connaît les métiers et les salaires de chaque filière.
En code
import numpy as np, pandas as pdfrom sklearn.neighbors import LocalOutlierFactorfrom sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(0)df = pd.concat([ pd.DataFrame({"filiere": "info", "gpa": rng.normal(3.5, 0.2, 100), "salaire": rng.normal(90, 2, 100)}), pd.DataFrame({"filiere": "lettres", "gpa": rng.normal(3.2, 0.3, 100), "salaire": rng.normal(45, 8, 100)}), pd.DataFrame({"filiere": ["info"], "gpa": [3.6], "salaire": [70.0]}), # le cas atypique], ignore_index=True)
def z_robuste(s): return 0.6745 * (s - s.median()) / (s - s.median()).abs().median()
# z robuste global : le cas atypique (dernière ligne) passe inaperçuprint(round(z_robuste(df["salaire"]).iloc[-1], 2)) # 0.0# z robuste filière par filière : il est signaléprint(round(df.groupby("filiere")["salaire"].transform(z_robuste).iloc[-1], 1)) # -10.7
# LOF sur (GPA, salaire) standardisés : rang du cas atypique parmi 201 diplôméslof = LocalOutlierFactor(n_neighbors=20).fit(StandardScaler().fit_transform(df[["gpa", "salaire"]]))score = -lof.negative_outlier_factor_print(round(score[-1], 1), int((score > score[-1]).sum()) + 1) # LOF 3.2, classé 1erset.seed(0)df <- rbind( data.frame(filiere = "info", gpa = rnorm(100, 3.5, 0.2), salaire = rnorm(100, 90, 2)), data.frame(filiere = "lettres", gpa = rnorm(100, 3.2, 0.3), salaire = rnorm(100, 45, 8)), data.frame(filiere = "info", gpa = 3.6, salaire = 70) # le cas atypique)
# mad() de R inclut déjà le facteur 1,4826 = 1 / 0,6745z_robuste <- function(s) (s - median(s)) / mad(s)
# z robuste global : le cas atypique (dernière ligne) passe inaperçutail(z_robuste(df$salaire), 1) # -0.04# z robuste filière par filière : il est signalétail(ave(df$salaire, df$filiere, FUN = z_robuste), 1) # -8.94