Votre suite de tests est verte. Vos logs sont silencieux. Vos tableaux de bord ne montrent aucune ligne rouge. Et pourtant, 3 % de vos utilisateurs reçoivent des factures avec des totaux négatifs, ou votre modèle de recommandation classe silencieusement les produits supprimés en premier, ou votre pipeline d’agrégation compte en double les remboursements d’un fuseau horaire spécifique.

Ce sont des bugs de données. Ils ne lèvent pas d’exceptions. Ils ne font pas crasher de pods. Ils passent à travers chaque couche de votre stack d’observabilité parce que chaque couche suppose que les données sont correctes. Le code s’est exécuté exactement comme écrit. Le problème est que ce qu’il a écrit était absurde.

Le statistical debugging est la pratique consistant à traiter vos données de production comme un signal et les bugs comme des anomalies dans ce signal. Au lieu de demander « le code a-t-il crashé ? », vous demandez « les données ressemblent-elles à ce qu’elles sont d’habitude ? » Quand la réponse est non, vous avez trouvé un bug qu’aucune stack trace ne vous montrera jamais.

Ce que signifie réellement le statistical debugging

Le statistical debugging n’est pas de l’apprentissage automatique. Vous n’avez pas besoin d’un réseau de neurones. Vous avez besoin d’un histogramme et de la volonté d’être surpris par lui.

L’idée fondamentale est qu’un logiciel correct produit des données avec des propriétés statistiques prévisibles. Les âges des utilisateurs se regroupent entre 18 et 80 ans. Les montants d’achat suivent une distribution log-normale. Les temps de réponse des API ont une longue queue mais une médiane stable. Quand ces propriétés changent, quelque chose dans le pipeline les a changées. Un nouveau déploiement, une migration de schéma, une API tierce retournant des chaînes vides au lieu de nulls. Le changement est le symptôme. Le bug est la cause.

C’est l’inverse du débogage traditionnel. Le débogage traditionnel commence par une erreur et remonte jusqu’au code. Le statistical debugging commence par les données et remonte jusqu’à l’erreur qui les a produites.

Un bug que seul le statistical debugging attraperait

Voici un schéma réel. Un service de paiements refactorise sa logique de conversion de devises. Le nouveau code passe tous les tests. Les tests d’intégration simulent l’API de taux de change et vérifient que 100 USD deviennent 85 EUR au taux simulé. Aucune assertion n’échoue.

En production, l’API de taux de change retourne occasionnellement null pour les devises mineures. L’ancien code levait une erreur et revenait à un taux mis en cache. Le nouveau code, écrit par quelqu’un qui ne connaissait pas le fallback, coerce null en 0 en JavaScript et stocke la transaction à un taux de change nul. Aucune exception. La transaction est validée. L’utilisateur n’est pas facturé.

Votre outil de suivi des erreurs ne voit rien. Votre graphique de latence est plat. Mais votre distribution des valeurs de exchange_rate pour la devise XOF vient de produire un pic massif à zéro. Un histogramme le montrerait en quelques secondes. Une suite de tests ne le trouverait jamais.

Comment détecter des anomalies dans les données de production

La version la plus simple du statistical debugging est la comparaison de distributions. Vous choisissez une métrique, calculez sa distribution à partir des données historiques, et la comparez à la distribution de la dernière heure. Si elles diffèrent significativement, quelque chose a changé.

Voici une implémentation concrète en Python utilisant le test de Kolmogorov-Smirnov, une méthode non paramétrique pour comparer deux échantillons sans rien supposer sur leur forme.

import numpy as np
from scipy import stats

def detect_distribution_shift(
    baseline: np.ndarray,
    current: np.ndarray,
    threshold: float = 0.05
) -> dict:
    """
    Compare two samples using the two-sample KS test.
    Returns whether the distributions differ significantly.
    """
    # Drop NaNs; they are often the bug themselves
    baseline = baseline[~np.isnan(baseline)]
    current = current[~np.isnan(current)]

    if len(baseline) == 0 or len(current) == 0:
        return {"shift_detected": True, "reason": "empty_sample"}

    statistic, p_value = stats.ks_2samp(baseline, current)

    return {
        "shift_detected": p_value < threshold,
        "ks_statistic": statistic,
        "p_value": p_value,
        "baseline_mean": np.mean(baseline),
        "current_mean": np.mean(current),
        "baseline_std": np.std(baseline),
        "current_std": np.std(current),
    }


# Example: compare yesterday's purchase amounts to the last hour
baseline = np.random.lognormal(mean=3.0, sigma=1.0, size=10_000)

# Simulate the bug: 5% of transactions now have a zero amount
current = np.concatenate([
    np.random.lognormal(mean=3.0, sigma=1.0, size=950),
    np.zeros(50)
])

result = detect_distribution_shift(baseline, current)
print(result)
# {'shift_detected': True, 'ks_statistic': 0.052, ...}

Ce n’est pas sophistiqué. C’est un test statistique à deux échantillons qui existe depuis 1939. Mais il attrapera le bug du taux de change nul, le bug du double comptage des remboursements, et le bug de la facture négative parce que tous modifient la forme des données de manières mesurables.

La clé est de choisir les bonnes métriques. De bons candidats sont tout ce qui devrait être stable : des ratios (refund_rate, cart_abandonment_rate), des bornes (age, price, quantity), des formes (la distribution des codes de statut HTTP, le pattern horaire des inscriptions), et des corrélations (purchase_amount vs. session_duration). Si votre code est correct, ces relations sont invariantes. Si elles changent, votre code les a changées.

Les limites de la comparaison de distributions

Le test KS a des angles morts. Il est sensible aux changements dans la distribution globale mais peut manquer des anomalies localisées qui ne déplacent pas beaucoup la forme globale.

Supposez que votre bug n’affecte que les utilisateurs en Lituanie entre 2h et 3h du matin. La distribution globale des montants d’achat a l’air bien. Le bug est enfoui sous le bruit de tous les autres fuseaux horaires. Vous ne l’attraperez pas avec une seule comparaison globale.

La solution est la stratification. Au lieu d’un test global, exécutez des tests séparés sur des tranches de vos données : par géographie, par type d’appareil, par niveau d’utilisateur, par heure de la journée. Un bug invisible globalement peut hurler quand vous regardez la bonne tranche.

from dataclasses import dataclass
from typing import Iterator

@dataclass
class DataSlice:
    dimension: str          # e.g. "country_code"
    value: str              # e.g. "LT"
    baseline: np.ndarray
    current: np.ndarray

def stratified_checks(
    records: list[dict],
    dimensions: list[str],
    baseline_window: int,
    current_window: int
) -> Iterator[DataSlice]:
    """Yield slices that differ significantly from baseline."""
    for dim in dimensions:
        for value in set(r[dim] for r in records):
            baseline = np.array([
                r["amount"] for r in records
                if r[dim] == value and r["hour"] < baseline_window
            ])
            current = np.array([
                r["amount"] for r in records
                if r[dim] == value and r["hour"] >= current_window
            ])

            result = detect_distribution_shift(baseline, current)
            if result["shift_detected"]:
                yield DataSlice(dim, value, baseline, current)

Cela échange la simplicité contre la couverture. Vous exécutez maintenant N tests statistiques au lieu d’un, ce qui signifie que vous devez vous soucier de la correction pour comparaisons multiples. Un simple ajustement de Bonferroni, divisant votre seuil par le nombre de tranches, est généralement suffisant pour garder les faux positifs gérables.

Que faire quand vous trouvez un changement

Un test statistique ne vous dit pas pourquoi les données ont changé. Il vous dit que les données ont changé. L’étape suivante est l’isolation de la cause racine, et le meilleur outil pour cela est l’analyse différentielle.

Vous avez deux populations : les données avant le changement et les données après. Comparez-les selon chaque dimension imaginable. Le changement est-il concentré dans un pays spécifique ? Une version d’API spécifique ? Un shard de base de données spécifique ? La dimension avec la plus grande différence relative est généralement là où se cache le bug.

Voici un analyseur différentiel léger :

def differential_analysis(
    baseline_records: list[dict],
    current_records: list[dict],
    dimensions: list[str]
) -> list[dict]:
    """Find dimensions where the before/after ratios differ most."""
    baseline_total = len(baseline_records)
    current_total = len(current_records)
    findings = []

    for dim in dimensions:
        baseline_counts = {}
        current_counts = {}
        for r in baseline_records:
            baseline_counts[r[dim]] = baseline_counts.get(r[dim], 0) + 1
        for r in current_records:
            current_counts[r[dim]] = current_counts.get(r[dim], 0) + 1

        for value in set(baseline_counts) | set(current_counts):
            b_rate = baseline_counts.get(value, 0) / baseline_total
            c_rate = current_counts.get(value, 0) / current_total
            if b_rate > 0:
                ratio = c_rate / b_rate
                if ratio > 2.0 or ratio < 0.5:
                    findings.append({
                        "dimension": dim,
                        "value": value,
                        "baseline_rate": b_rate,
                        "current_rate": c_rate,
                        "ratio": ratio,
                    })

    return sorted(findings, key=lambda x: abs(1 - x["ratio"]), reverse=True)

Si api_version: v2.3 montre une multiplication par dix des transactions de montant nul alors que toutes les autres versions sont plates, vous avez réduit un bug de données de production à un déploiement spécifique. C’est un meilleur point de départ que « quelque chose ne va pas quelque part ».

Ce que cela n’attrape pas

Le statistical debugging ne remplace pas les tests unitaires ou l’analyse statique. Il attrape une classe spécifique de bug : la corruption silencieuse de données qui se manifeste sous forme d’anomalies statistiques. Il n’attrapera pas les bugs qui ne changent pas les données de manières mesurables. Un bug qui retourne toujours la bonne réponse mais qui prend dix secondes au lieu de dix millisecondes est invisible pour la comparaison de distributions. Un bug qui échange deux champs dans une entrée de log mais qui n’affecte pas la logique métier est invisible. Un bug qui produit de mauvaises réponses dans exactement la même distribution statistique que les bonnes réponses est invisible.

C’est aussi fondamentalement réactif. Vous comparez les données actuelles aux données historiques, ce qui signifie que le bug s’est déjà produit. Le but est de réduire le temps moyen de détection de « quand un client se plaint » à « dans le même cycle de déploiement ».

Par où commencer

Vous n’avez pas besoin d’une équipe de data science. Vous avez besoin d’un job planifié et d’une alerte.

Choisissez une métrique critique dans votre système. order_total est un bon choix. exchange_rate en est un autre. Calculez sa distribution sur les sept derniers jours comme référence. Exécutez le test KS contre la dernière heure de données toutes les heures. Si le test échoue, appelez quelqu’un.

Les premières semaines seront bruyantes. Vous affinerez le seuil, ajouterez des dimensions pour stratifier, et apprendrez quels changements sont de vrais bugs et lesquels sont le Black Friday. Ce bruit est le coût de l’étalonnage. Une fois étalonné, vous avez un filet de sécurité qui attrape les bugs que vos tests ne peuvent pas voir.

Si vous voulez aller plus loin, des outils comme Great Expectations et Deequ formalisent ce pattern en suites de qualité de données réutilisables. Mais l’idée fondamentale tient en cinquante lignes de Python, et ces cinquante lignes trouveront des bugs que votre suite de tests entière a manqués.