Données manquantes et valeurs aberrantes#

Dans un monde idéal, toutes les mesures seraient complètes et exemptes d’erreurs. En pratique, les données réelles sont presque toujours trouées et bruyantes. La façon dont on gère ces imperfections peut transformer une analyse correcte en une conclusion complètement fausse — ou vice-versa. Ce chapitre couvre les mécanismes de manquance, les méthodes d’imputation (de la plus naïve à la plus sophistiquée), et la détection des valeurs aberrantes.

Hide code cell source

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.ensemble import IsolationForest, RandomForestRegressor
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'figure.dpi': 110,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'font.size': 11,
})
rng = np.random.default_rng(42)

Mécanismes de manquance#

La taxonomie de Rubin (1976) distingue trois mécanismes fondamentalement différents, avec des implications très différentes pour l’analyse.

MCAR — Missing Completely At Random#

Les données manquent de façon entièrement aléatoire, sans lien avec les valeurs observées ou manquantes. La probabilité qu’une valeur soit manquante ne dépend ni de la variable elle-même ni d’aucune autre variable.

Exemple : un capteur défaillant de façon aléatoire, des questionnaires perdus au hasard.

Impact : l’analyse sur les données complètes reste non biaisée (mais perd en puissance).

Test (Little, 1988) : on peut tester MCAR via la statistique de Little (disponible dans pyampute ou rpy2).

MAR — Missing At Random#

Les données manquent de façon aléatoire conditionnellement aux données observées. La probabilité de manquance peut dépendre d’autres variables, mais pas de la valeur manquante elle-même.

Exemple : les femmes répondent moins souvent à la question « salaire » (probabilité de manquance liée au sexe, observé), mais pas liée au salaire lui-même.

Impact : les méthodes d’imputation qui tiennent compte des autres variables produisent des estimations non biaisées.

MNAR — Missing Not At Random#

La probabilité de manquance dépend de la valeur manquante elle-même. C’est le mécanisme le plus problématique.

Exemple : les personnes avec un salaire très élevé refusent de divulguer leur salaire. Les patients qui guérissent vite ne reviennent pas pour les visites de suivi.

Impact : toute méthode d’imputation basée uniquement sur les données observées introduira un biais. Des modèles de sélection spécifiques sont nécessaires.

Hide code cell source

# Simulation des trois mécanismes de manquance
n = 300
# Données complètes : revenu ~ Normal, âge ~ Uniforme
revenu = 30000 + rng.exponential(20000, n)  # distribution asymétrique
age    = rng.integers(25, 65, n).astype(float)

# MCAR : 25% manquants aléatoirement
mask_mcar = rng.random(n) < 0.25

# MAR : probabilité de manquance dépend de l'âge (les jeunes répondent moins)
prob_mar = 1 / (1 + np.exp(-(age - 40) / 5))  # sigmoïde croissante avec l'âge
# → plus on est âgé, plus on répond → plus on est jeune, plus on est MNAR
mask_mar = rng.random(n) < (0.4 - 0.3 * (age - 25) / 40)
mask_mar = np.clip(mask_mar, 0, 1).astype(bool)

# MNAR : probabilité de manquance dépend du revenu lui-même (hauts revenus absents)
seuil_mnar = np.percentile(revenu, 70)
prob_mnar = 0.05 + 0.70 * (revenu > seuil_mnar)
mask_mnar = rng.random(n) < prob_mnar

# Comparaison des distributions
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
bins = np.linspace(0, 150000, 30)

for ax, mask, titre, couleur in zip(
    axes,
    [mask_mcar, mask_mar, mask_mnar],
    ['MCAR', 'MAR', 'MNAR'],
    ['steelblue', 'seagreen', 'orangered']
):
    revenu_obs = revenu[~mask]
    revenu_man = revenu[mask]
    ax.hist(revenu_obs, bins=bins, alpha=0.6, color=couleur, label=f'Observé (n={len(revenu_obs)})', density=True)
    ax.hist(revenu_man, bins=bins, alpha=0.4, color='gray',   label=f'Manquant (n={len(revenu_man)})', density=True)
    ax.set_title(f'{titre}\n({mask.mean()*100:.0f}% manquants)')
    ax.set_xlabel('Revenu (€)')
    ax.legend(fontsize=8)
    # Statistiques
    ax.text(0.98, 0.95, f'Moy. obs  : {revenu_obs.mean():.0f}\nMoy. réelle: {revenu.mean():.0f}€',
            transform=ax.transAxes, ha='right', va='top', fontsize=8,
            bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))

plt.suptitle('Trois mécanismes de manquance : impact sur la distribution observée', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/5c424c4dffc2a78fc736c6f1a33c54af316c14c84b3dc56cfc9e21a60b68447b.png

Note

La différence fondamentale : sous MCAR et MAR, la distribution observée est représentative (éventuellement après conditionnement). Sous MNAR, les données observées sont systématiquement biaisées — les hauts revenus sont sous-représentés, et aucune méthode d’imputation purement mécanique ne peut corriger ce biais.


Détection et visualisation des données manquantes#

Heatmap et patterns de manquance#

Hide code cell source

# Création d'un jeu de données avec plusieurs variables et patterns de manquance
n_obs = 200
np.random.seed(42)

# Variables simulées (données médicales fictives)
df_base = pd.DataFrame({
    'age'           : rng.integers(18, 80, n_obs),
    'imc'           : rng.normal(25, 4, n_obs),
    'systolique'    : rng.normal(120, 15, n_obs),
    'diastolique'   : rng.normal(80, 10, n_obs),
    'glycemie'      : rng.normal(5.5, 1.2, n_obs),
    'cholesterol'   : rng.normal(2.0, 0.4, n_obs),
    'creatinine'    : rng.exponential(90, n_obs),
    'tabac'         : rng.choice([0, 1], n_obs, p=[0.7, 0.3]).astype(float),
})

# Induction de manquances avec différents patterns
df_manquant = df_base.copy().astype(float)

# IMC : MAR dépendant de l'âge (jeunes moins souvent mesurés)
mask_imc = rng.random(n_obs) < (0.05 + 0.30 * (df_base['age'] < 30))
df_manquant.loc[mask_imc, 'imc'] = np.nan

# Glycémie : MNAR (valeurs extrêmes manquantes)
mask_glyc = (df_base['glycemie'] > 7.5) & (rng.random(n_obs) < 0.6)
df_manquant.loc[mask_glyc, 'glycemie'] = np.nan

# Cholestérol et créatinine : MCAR
mask_chol = rng.random(n_obs) < 0.20
mask_creat = rng.random(n_obs) < 0.15
df_manquant.loc[mask_chol, 'cholesterol'] = np.nan
df_manquant.loc[mask_creat, 'creatinine']  = np.nan

# Statistiques de manquance
taux_manquants = df_manquant.isnull().mean() * 100
print("Taux de données manquantes par variable :")
for col, taux in taux_manquants.items():
    barre = '█' * int(taux / 2)
    print(f"  {col:<15} : {taux:5.1f}%  {barre}")

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

# Heatmap de manquance
ax = axes[0]
missing_matrix = df_manquant.isnull().T.values.astype(int)
im = ax.imshow(missing_matrix, aspect='auto', cmap='RdBu', interpolation='none')
ax.set_yticks(range(len(df_manquant.columns)))
ax.set_yticklabels(df_manquant.columns, fontsize=9)
ax.set_xlabel('Individus')
ax.set_title('Heatmap de manquance\n(rouge = manquant, bleu = présent)')
plt.colorbar(im, ax=ax, shrink=0.7)

# Corrélation entre patterns de manquance
ax = axes[1]
miss_ind = df_manquant.isnull().astype(int)
corr_miss = miss_ind.corr()
mask_upper = np.triu(np.ones_like(corr_miss), k=1).astype(bool)
sns.heatmap(corr_miss, ax=ax, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, square=True, linewidths=0.5, mask=False,
            annot_kws={'size': 8})
ax.set_title('Corrélation entre indicateurs de manquance\n(corrélation élevée → pattern commun)')

plt.suptitle('Analyse des données manquantes', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
Taux de données manquantes par variable :
  age             :   0.0%  
  imc             :  12.0%  ██████
  systolique      :   0.0%  
  diastolique     :   0.0%  
  glycemie        :   2.5%  █
  cholesterol     :  21.5%  ██████████
  creatinine      :  11.0%  █████
  tabac           :   0.0%  
_images/82f53d60dbbc8f11175a4d366720cb7732b457545318ab33c1fdd833f6b07824.png

Méthodes d’imputation#

Imputation simple#

Les méthodes simples remplacent chaque valeur manquante par une statistique de la distribution observée.

Méthode

Formule

Problèmes

Moyenne

\(\hat{x}_{ij} = \bar{x}_j\)

Sous-estime la variance, détruit les corrélations

Médiane

\(\hat{x}_{ij} = \text{med}(x_j)\)

Robuste aux outliers, mêmes problèmes

Mode

\(\hat{x}_{ij} = \text{mode}(x_j)\)

Pour variables catégorielles

Constante

\(\hat{x}_{ij} = c\)

Parfois utile (0 pour les comptages)

Pour les séries temporelles, le forward fill (reporter la dernière valeur connue) et le backward fill sont naturels mais supposent une continuité temporelle.

Imputation par modèle#

# Comparaison des méthodes d'imputation sur la variable 'glycemie'
var_cible = 'glycemie'
vraies_valeurs = df_base[var_cible].values
masque = df_manquant[var_cible].isnull()

# Variables prédictives pour l'imputation
vars_pred = [c for c in df_manquant.columns if c != var_cible]

methodes = {}

# 1. Imputation par la moyenne
imp_mean = SimpleImputer(strategy='mean')
df_mean = df_manquant.copy()
df_mean[var_cible] = imp_mean.fit_transform(df_manquant[[var_cible]]).ravel()
methodes['Moyenne'] = df_mean[var_cible][masque].values

# 2. Imputation par la médiane
imp_med = SimpleImputer(strategy='median')
df_med = df_manquant.copy()
df_med[var_cible] = imp_med.fit_transform(df_manquant[[var_cible]]).ravel()
methodes['Médiane'] = df_med[var_cible][masque].values

# 3. KNN imputer
imp_knn = KNNImputer(n_neighbors=10)
df_knn_imputed = imp_knn.fit_transform(df_manquant)
methodes['KNN (k=10)'] = df_knn_imputed[masque, list(df_manquant.columns).index(var_cible)]

# 4. MICE (Multiple Imputation by Chained Equations) — une imputation simple pour l'évaluation
imp_iter = IterativeImputer(max_iter=10, random_state=42)
df_mice_imputed = imp_iter.fit_transform(df_manquant)
methodes['MICE (itératif)'] = df_mice_imputed[masque, list(df_manquant.columns).index(var_cible)]

# Évaluation
print("Évaluation des méthodes d'imputation (sur valeurs manquantes de glycémie)\n")
print(f"  Vraie moyenne (données manquantes) : {vraies_valeurs[masque].mean():.3f}")
print(f"  Vraie valeur observée (moyenne)    : {vraies_valeurs[~masque].mean():.3f}")
print()
print(f"{'Méthode':>15} | {'RMSE':>8} | {'Biais':>10} | {'Corrélation':>12}")
print("-" * 52)
for nom, imputed in methodes.items():
    vraies = vraies_valeurs[masque]
    rmse = np.sqrt(np.mean((imputed - vraies)**2))
    biais = np.mean(imputed - vraies)
    corr = np.corrcoef(imputed, vraies)[0, 1]
    print(f"{nom:>15} | {rmse:>8.3f} | {biais:>+10.3f} | {corr:>12.3f}")
Évaluation des méthodes d'imputation (sur valeurs manquantes de glycémie)

  Vraie moyenne (données manquantes) : 7.973
  Vraie valeur observée (moyenne)    : 5.460

        Méthode |     RMSE |      Biais |  Corrélation
----------------------------------------------------
        Moyenne |    2.526 |     -2.513 |          nan
        Médiane |    2.423 |     -2.409 |          nan
     KNN (k=10) |    2.543 |     -2.540 |        0.871
MICE (itératif) |    2.521 |     -2.506 |       -0.584

Hide code cell source

# Visualisation : distributions avant/après imputation
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
bins_glyc = np.linspace(2, 12, 30)

for ax, (nom, valeurs_imp) in zip(axes.flatten(), methodes.items()):
    # Distribution complète reconstituée
    df_plot = df_manquant[var_cible].copy()
    df_plot.iloc[np.where(masque)[0]] = valeurs_imp

    ax.hist(df_base[var_cible], bins=bins_glyc, alpha=0.4, color='seagreen',
            density=True, label='Distribution réelle')
    ax.hist(df_manquant[var_cible].dropna(), bins=bins_glyc, alpha=0.4, color='steelblue',
            density=True, label='Observé seul')
    ax.hist(df_plot, bins=bins_glyc, alpha=0.4, color='orangered',
            density=True, label=f'Après imputation ({nom})')
    ax.set_title(f'Imputation : {nom}')
    ax.set_xlabel('Glycémie (mmol/L)')
    ax.legend(fontsize=8)

plt.suptitle('Impact des méthodes d\'imputation sur la distribution (glycémie)', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
_images/b860412cfbf0450d165471a9c43bc92380ea5096a7fb7b9d3a773e2496ad41b3.png

Imputation multiple (MICE)#

La MICE (Multiple Imputation by Chained Equations) est la méthode de référence pour les données MAR. Le principe :

  1. Remplissage initial : imputation par la moyenne pour initialiser

  2. Itérations cycliques : pour chaque variable avec des manquantes, entraîner un modèle (régression, forêt aléatoire…) sur les autres variables et imputer les valeurs manquantes par une valeur tirée de la distribution prédictive

  3. M imputations : répéter le processus M fois (typiquement M = 5 à 20) pour obtenir M jeux de données complets

  4. Analyse de chaque jeu : appliquer le modèle d’analyse à chacun des M jeux

  5. Combinaison des résultats (règles de Rubin) :

\[\hat{\theta} = \frac{1}{M}\sum_{m=1}^{M} \hat{\theta}_m\]
\[\text{Var}(\hat{\theta}) = \underbrace{\bar{U}}_{\text{variance intra}} + \underbrace{\left(1 + \frac{1}{M}\right)B}_{\text{variance inter}}\]

Hide code cell source

# Imputation multiple : M imputations et combinaison des estimateurs
M = 20  # nombre d'imputations
estimateurs = []

for m in range(M):
    imp_m = IterativeImputer(max_iter=10, random_state=m, sample_posterior=True)
    df_m  = pd.DataFrame(imp_m.fit_transform(df_manquant), columns=df_manquant.columns)

    # Statistique d'intérêt : moyenne de glycémie
    moy_m = df_m['glycemie'].mean()
    var_m = df_m['glycemie'].var() / len(df_m)
    estimateurs.append((moy_m, var_m))

# Règles de Rubin
theta_hat = np.mean([e[0] for e in estimateurs])
U_bar = np.mean([e[1] for e in estimateurs])               # variance intra
B     = np.var([e[0] for e in estimateurs], ddof=1)         # variance inter
var_totale = U_bar + (1 + 1/M) * B
se_totale  = np.sqrt(var_totale)

# IC combiné (degrés de liberté approximés selon Rubin)
df_rubin = (M - 1) * (1 + U_bar / ((1 + 1/M) * B))**2
t_crit = stats.t.ppf(0.975, df=df_rubin)
ic_low  = theta_hat - t_crit * se_totale
ic_high = theta_hat + t_crit * se_totale

print("Imputation multiple (MICE) — Règles de Rubin")
print(f"  M = {M} imputations")
print(f"  Moyenne glycémie (vraie)       : {df_base['glycemie'].mean():.4f}")
print(f"  Moyenne glycémie (complète)    : {theta_hat:.4f}")
print(f"  IC 95% combiné                 : [{ic_low:.4f}, {ic_high:.4f}]")
print(f"  Variance intra (Ū)             : {U_bar:.6f}")
print(f"  Variance inter (B)             : {B:.6f}")
print(f"  Fraction d'information manq.   : {(1 + 1/M)*B / var_totale:.2%}")

# Visualisation des M estimateurs
fig, ax = plt.subplots(figsize=(10, 4))
thetas = [e[0] for e in estimateurs]
ax.scatter(range(1, M+1), thetas, color='steelblue', zorder=5, s=60, label='Estimateur m')
ax.axhline(theta_hat, color='orangered', linewidth=2, label=f'Combiné = {theta_hat:.4f}')
ax.axhline(df_base['glycemie'].mean(), color='seagreen', linewidth=2, linestyle='--',
           label=f'Vraie valeur = {df_base["glycemie"].mean():.4f}')
ax.fill_between([0, M+1], ic_low, ic_high, alpha=0.15, color='orangered', label='IC 95% combiné')
ax.set_xlabel('Imputation m')
ax.set_ylabel('Moyenne glycémie imputée')
ax.set_title(f'Variabilité entre imputations (M={M}) et estimateur combiné')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Imputation multiple (MICE) — Règles de Rubin
  M = 20 imputations
  Moyenne glycémie (vraie)       : 5.5230
  Moyenne glycémie (complète)    : 5.4585
  IC 95% combiné                 : [5.3085, 5.6084]
  Variance intra (Ū)             : 0.005655
  Variance inter (B)             : 0.000189
  Fraction d'information manq.   : 3.39%
_images/3127c9bfe08cfb5044915fa4d34bf76e7be9b642a2057f9d81abebcabc7d3e50.png

Valeurs aberrantes#

Détection univariée#

Z-score : \(z_i = (x_i - \bar{x}) / s\). On considère comme aberrante toute valeur avec \(|z_i| > 3\). Problème : la moyenne et l’écart-type sont eux-mêmes sensibles aux outliers.

IQR (méthode de Tukey) : les valeurs situées au-delà de \(Q_1 - 1.5 \cdot \text{IQR}\) ou \(Q_3 + 1.5 \cdot \text{IQR}\) sont signalées (boîtes à moustaches). Le facteur 1.5 capture environ 0.7% des observations d’une normale.

Test de Grubbs : test de la valeur la plus extrême, sous hypothèse de normalité.

Hide code cell source

# Données avec outliers : créatinine (asymétrique + quelques valeurs extrêmes)
creatinine = df_base['creatinine'].copy()
# Ajout artificiel d'outliers
outliers_idx = rng.choice(n_obs, 5, replace=False)
creatinine.iloc[outliers_idx] += rng.uniform(300, 600, 5)

# Z-score
z_scores = np.abs(stats.zscore(creatinine))

# IQR
Q1, Q3 = creatinine.quantile(0.25), creatinine.quantile(0.75)
IQR = Q3 - Q1
borne_inf = Q1 - 1.5 * IQR
borne_sup = Q3 + 1.5 * IQR

# Test de Grubbs (valeur maximale)
def grubbs_test(x, alpha=0.05):
    n = len(x)
    mu, s = x.mean(), x.std()
    G = np.max(np.abs(x - mu)) / s
    # Valeur critique (approximation)
    t_crit = stats.t.ppf(1 - alpha / (2*n), df=n-2)
    G_crit = ((n-1) / np.sqrt(n)) * np.sqrt(t_crit**2 / (n - 2 + t_crit**2))
    return G, G_crit, G > G_crit

G, G_crit, outlier_detecte = grubbs_test(creatinine)

print("Détection d'outliers univariés — Créatinine")
print(f"  Z-score > 3         : {(z_scores > 3).sum()} outliers")
print(f"  IQR (Tukey)         : {((creatinine < borne_inf) | (creatinine > borne_sup)).sum()} outliers")
print(f"  Test de Grubbs      : G = {G:.3f}, G_crit = {G_crit:.3f}, outlier = {outlier_detecte}")

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# Histogramme
ax = axes[0]
ax.hist(creatinine, bins=40, color='steelblue', alpha=0.7, edgecolor='white')
ax.axvline(borne_sup, color='orangered', linestyle='--', linewidth=2, label=f'Tukey sup = {borne_sup:.0f}')
ax.axvline(creatinine.mean() + 3*creatinine.std(), color='seagreen', linestyle='--',
           linewidth=2, label=f'z=3 → {creatinine.mean() + 3*creatinine.std():.0f}')
ax.set_xlabel('Créatinine (μmol/L)')
ax.set_title('Distribution + seuils')
ax.legend(fontsize=8)

# Boxplot
ax = axes[1]
ax.boxplot(creatinine, vert=True, patch_artist=True,
           boxprops=dict(facecolor='steelblue', alpha=0.5))
for idx in outliers_idx:
    ax.scatter([1], [creatinine.iloc[idx]], color='orangered', s=60, zorder=5)
ax.set_title('Boxplot (points rouges = vrais outliers)')
ax.set_ylabel('Créatinine (μmol/L)')

# Z-score
ax = axes[2]
ax.scatter(range(len(creatinine)), z_scores, s=15, alpha=0.5, color='steelblue')
outlier_z = np.where(z_scores > 3)[0]
ax.scatter(outlier_z, z_scores[outlier_z], s=60, color='orangered', zorder=5, label='Outlier z>3')
ax.axhline(3, color='orangered', linestyle='--', linewidth=1.5, label='Seuil z=3')
ax.set_xlabel('Indice')
ax.set_ylabel('|z-score|')
ax.set_title('Z-scores')
ax.legend(fontsize=9)

plt.suptitle('Détection univariée des valeurs aberrantes', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
Détection d'outliers univariés — Créatinine
  Z-score > 3         : 6 outliers
  IQR (Tukey)         : 13 outliers
  Test de Grubbs      : G = 4.377, G_crit = 3.606, outlier = True
_images/118b0874327afc4452878885210034fae402dd96652d277813dfae441bcf7196.png

Détection multivariée#

La détection univariée peut manquer des outliers qui ne sont aberrants qu’en combinaison de plusieurs variables (masqués), ou signaler des points normaux qui ont des valeurs extrêmes sur une seule variable.

Distance de Mahalanobis : généralise le z-score en tenant compte des corrélations.

\[D_M(\mathbf{x}_i) = \sqrt{(\mathbf{x}_i - \bar{\mathbf{x}})^\top \hat{\boldsymbol{\Sigma}}^{-1} (\mathbf{x}_i - \bar{\mathbf{x}})}\]

Sous normalité multivariée, \(D_M^2 \sim \chi^2_p\), ce qui fournit un seuil de détection.

Isolation Forest : algorithme basé sur des arbres de décision aléatoires. L’idée : les outliers sont plus faciles à isoler (moins de coupures nécessaires pour les isoler). Le score d’anomalie est la profondeur moyenne d’isolation.

Hide code cell source

# Détection multivariée : Mahalanobis + Isolation Forest
vars_multiv = ['age', 'imc', 'systolique', 'diastolique', 'glycemie', 'cholesterol']
df_num = df_base[vars_multiv].copy()

# Ajout d'outliers multivariés (plausibles univariément, aberrants ensemble)
n_outliers = 8
indices_out = rng.choice(n_obs, n_outliers, replace=False)
df_num.loc[indices_out, 'systolique'] += 30  # TA systolique très élevée
df_num.loc[indices_out, 'diastolique'] -= 20  # mais diastolique basse (incohérent)

# Distance de Mahalanobis
mu_hat = df_num.mean().values
Sigma_hat = df_num.cov().values
diff = df_num.values - mu_hat
mahal_sq = np.array([d @ np.linalg.inv(Sigma_hat) @ d for d in diff])
seuil_mahal = stats.chi2.ppf(0.975, df=len(vars_multiv))

# Isolation Forest
iso_forest = IsolationForest(contamination=0.05, random_state=42)
labels_iso  = iso_forest.fit_predict(df_num)  # -1 = outlier, 1 = normal
scores_iso  = -iso_forest.score_samples(df_num)  # score d'anomalie (plus élevé = plus anormal)

print("Comparaison des méthodes de détection multivariée")
print(f"  Distance de Mahalanobis (χ²₀.₉₇₅) : {(mahal_sq > seuil_mahal).sum()} outliers détectés")
print(f"  Isolation Forest (5%)               : {(labels_iso == -1).sum()} outliers détectés")
print(f"  Vrais outliers                       : {n_outliers}")

# Vrais positifs
tp_mahal = np.intersect1d(np.where(mahal_sq > seuil_mahal)[0], indices_out)
tp_iso   = np.intersect1d(np.where(labels_iso == -1)[0], indices_out)
print(f"  Vrais positifs Mahalanobis : {len(tp_mahal)}/{n_outliers}")
print(f"  Vrais positifs Iso Forest  : {len(tp_iso)}/{n_outliers}")

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Mahalanobis
ax = axes[0]
couleurs_mah = ['orangered' if d > seuil_mahal else 'steelblue' for d in mahal_sq]
ax.scatter(range(n_obs), mahal_sq, c=couleurs_mah, s=20, alpha=0.6)
ax.axhline(seuil_mahal, color='orangered', linestyle='--', linewidth=2,
           label=f'χ²₀.₉₇₅ = {seuil_mahal:.1f}')
for idx in indices_out:
    ax.scatter(idx, mahal_sq[idx], s=80, color='orangered', edgecolors='black',
               zorder=10, linewidth=1.5)
ax.set_xlabel('Individu')
ax.set_ylabel('Distance de Mahalanobis²')
ax.set_title('Détection par distance de Mahalanobis')
ax.legend()

# Isolation Forest
ax = axes[1]
couleurs_if = ['orangered' if l == -1 else 'steelblue' for l in labels_iso]
ax.scatter(range(n_obs), scores_iso, c=couleurs_if, s=20, alpha=0.6)
for idx in indices_out:
    ax.scatter(idx, scores_iso[idx], s=80, color='orangered', edgecolors='black',
               zorder=10, linewidth=1.5, label='Vrais outliers' if idx == indices_out[0] else '')
ax.set_xlabel('Individu')
ax.set_ylabel('Score d\'anomalie (Isolation Forest)')
ax.set_title('Détection par Isolation Forest')
ax.legend()

plt.suptitle('Détection multivariée des valeurs aberrantes', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
Comparaison des méthodes de détection multivariée
  Distance de Mahalanobis (χ²₀.₉₇₅) : 5 outliers détectés
  Isolation Forest (5%)               : 10 outliers détectés
  Vrais outliers                       : 8
  Vrais positifs Mahalanobis : 4/8
  Vrais positifs Iso Forest  : 4/8
_images/043a6efec176a205a82186ea6da3d9eb77415d077381b79431707e5286d29ae4.png

Traitement des valeurs aberrantes#

Une fois les outliers identifiés, quatre options principales s’offrent :

1. Supprimer#

Approprié si l’outlier est dû à une erreur de saisie ou de mesure clairement identifiée. Risqué : peut introduire un biais si les valeurs extrêmes sont réelles.

2. Winsoriser#

La winsorisation remplace les valeurs extrêmes par les percentiles correspondants (ex : tout ce qui dépasse le 99e percentile est remplacé par la valeur du 99e percentile).

from scipy.stats.mstats import winsorize

# Winsorisation
creat_winsoriee = winsorize(creatinine, limits=[0.01, 0.01])

print(f"Avant winsorisation : max = {creatinine.max():.1f}, std = {creatinine.std():.1f}")
print(f"Après winsorisation : max = {np.max(creat_winsoriee):.1f}, std = {np.std(creat_winsoriee):.1f}")
Avant winsorisation : max = 570.5, std = 107.7
Après winsorisation : max = 536.5, std = 106.7

3. Transformer#

Une transformation logarithmique réduit l’influence des valeurs extrêmes dans les distributions asymétriques positives (revenus, créatinine, durées…).

4. Modèles robustes#

Utiliser des estimateurs robustes (médiane à la place de la moyenne, régression de Huber, régression quantile) qui pèsent moins les valeurs extrêmes.

Hide code cell source

from sklearn.linear_model import HuberRegressor, LinearRegression

# Comparaison régression OLS vs Huber en présence d'outliers
x_reg = np.linspace(0, 10, 100)
y_reg = 2 * x_reg + rng.normal(0, 1, 100)
# Ajout d'outliers
x_out = rng.uniform(0, 10, 8)
y_out = -5 + x_out * 0.5  # faux modèle
x_all = np.concatenate([x_reg, x_out]).reshape(-1, 1)
y_all = np.concatenate([y_reg, y_out])

ols   = LinearRegression().fit(x_all, y_all)
huber = HuberRegressor(epsilon=1.35).fit(x_all, y_all)

fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(x_reg, y_reg, s=15, alpha=0.5, color='steelblue', label='Données normales')
ax.scatter(x_out, y_out, s=60, color='orangered', zorder=5, label='Outliers')
x_plot = np.linspace(0, 10, 100).reshape(-1, 1)
ax.plot(x_plot, ols.predict(x_plot), color='orangered', linewidth=2, label='OLS (sensible)')
ax.plot(x_plot, huber.predict(x_plot), color='seagreen', linewidth=2, label='Huber (robuste)')
ax.plot(x_plot, 2 * x_plot, '--', color='gray', linewidth=1.5, label='Vrai modèle')
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.set_title('Régression OLS vs Huber en présence d\'outliers')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()

print(f"OLS   : a = {ols.coef_[0]:.3f}, b = {ols.intercept_:.3f}")
print(f"Huber : a = {huber.coef_[0]:.3f}, b = {huber.intercept_:.3f}")
print(f"Vrai  : a = 2.000, b = 0.000")
_images/b11c507654ceb6d5ebf9ff7da567e41209234ad1cee78111277ae3a1a33ce0c2.png
OLS   : a = 1.925, b = -0.523
Huber : a = 1.943, b = 0.116
Vrai  : a = 2.000, b = 0.000

Résumé et recommandations#

Protocole recommandé face aux données manquantes

  1. Diagnostiquer le mécanisme : analyser si la manquance est liée à d’autres variables (MAR) ou à la variable elle-même (MNAR).

  2. Quantifier : calculer le taux de manquance par variable. Au-delà de 40-50%, l’imputation devient très incertaine.

  3. Choisir la méthode selon le mécanisme :

    • MCAR : n’importe quelle méthode raisonnable (KNN, médiane)

    • MAR : MICE ou KNN imputer

    • MNAR : modèles de sélection spécifiques ou analyse de sensibilité

  4. Utiliser l’imputation multiple (MICE) pour les analyses inférentielles importantes — l’imputation simple sous-estime l’incertitude.

  5. Toujours comparer les distributions avant/après imputation.

Protocole pour les valeurs aberrantes

  1. Distinguer erreur de mesure (corriger ou supprimer) et valeur extrême réelle (conserver ou robustifier).

  2. Ne jamais supprimer un outlier uniquement parce qu’il gêne le résultat.

  3. Analyser avec et sans les outliers et rapporter les deux résultats.

  4. Préférer les méthodes robustes (régression de Huber, médiane, quantiles) si les outliers sont courants dans le domaine.

  5. Pour la détection multivariée : utiliser la distance de Mahalanobis (sous normalité) ou l’Isolation Forest (général).