Tu suite de pruebas está verde. Tus registries están tranquilos. Tus paneles no muestran líneas rojas. Y sin embargo, el 3% de tus usuarios están recibiendo facturas con totales negativos, o tu modelo de recomendación está clasificando silenciosamente productos eliminados primero, o tu pipeline de agregación está contando dos veces los reembolsos de una zona horaria específica.

Estos son bugs de datos. No lanzan excepciones. No causan fallas en los pods. Pasan a través de cada capa de tu stack de observabilidad porque cada capa asume que los datos son correctos. El código se ejecutó exactamente como fue escrito. El problema es que lo que escribió era un sinsentido.

La depuración estadística es la práctica de tratar tus datos de producción como una señal y los bugs como anomalías en esa señal. En lugar de preguntar “¿el código falló?”, preguntas “¿los datos se ven como usualmente se ven?” Cuando la respuesta es no, has encontrado un bug que ningún stack trace te mostrará nunca.

Qué significa realmente la depuración estadística

La depuración estadística no es aprendizaje automático. No necesitas una red neuronal. Necesitas un histograma y la disposición para sorprenderte con él.

La idea central es que el software correcto produce datos con propiedades estadísticas predecibles. Las edades de los usuarios se agrupan entre 18 y 80. Los montos de compra siguen una distribución log-normal. Los tiempos de respuesta de la API tienen una cola larga pero una mediana estable. Cuando estas propiedades cambian, algo en el pipeline las cambió. Un nuevo despliegue, una migration de esquema, una API de terceros devolviendo cadenas vacías en lugar de nulos. El cambio es el síntoma. El bug es la causa.

Este es el inverso de la depuración tradicional. La depuración tradicional comienza con un error y trabaja hacia atrás hasta el código. La depuración estadística comienza con los datos y trabaja hacia atrás hasta el error que los produjo.

Un bug que solo la depuración estadística detectaría

Aquí hay un patrón real. Un servicio de pagos hace refactor de su lógica de conversión de divisas. El nuevo código pasa todas las pruebas. Las integration tests hacen mock de la API de tipos de cambio y verifican que 100 USD se convierten en 85 EUR con el tipo simulado. Ninguna aserción falla.

En producción, la API de tipos de cambio ocasionalmente devuelve null para divisas secundarias. El código anterior lanzaba un error y caía en un fallback a una tasa en caché. El nuevo código, escrito por alguien que no sabía del fallback, coacciona null a 0 en JavaScript y almacena la transaction con un tipo de cambio de cero. Ninguna excepción. La transaction se confirma. Al usuario se le cobra cero.

Tu herramienta de tracing de errores no ve nada. Tu gráfico de latencia está plano. Pero tu distribución de valores de exchange_rate para la divisa XOF acaba de brotar con un pico masivo en cero. Un histograma lo mostraría en segundos. Una suite de pruebas nunca lo encontraría.

Cómo detectar anomalías en datos de producción

La versión más simple de la depuración estadística es la comparación de distribuciones. Elijes una metric, calculas su distribución a partir de datos históricos, y la comparas con la distribución de la última hora. Si difieren significativamente, algo cambió.

Aquí hay una implementación concreta en Python usando la prueba de Kolmogorov-Smirnov, una forma no paramétrica de comparar dos muestras sin asumir nada sobre su forma.

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, ...}

Esto no es sofisticado. Es una prueba estadística de dos muestras que ha existido desde 1939. Pero atrapará el bug del tipo de cambio cero, el bug del reembolso contado dos veces, y el bug de la factura negativa porque todos cambian la forma de los datos de maneras medibles.

La clave es elegir las metrics correctas. Buenos candidatos son cualquier cosa que debería ser estable: ratios (refund_rate, cart_abandonment_rate), límites (age, price, quantity), formas (la distribución de códigos de estado HTTP, el patrón horario de registries), y correlaciones (purchase_amount vs. session_duration). Si tu código es correcto, estas relaciones son invariantes. Si cambian, tu código las cambió.

Los límites de la comparación de distribuciones

La prueba KS tiene puntos ciegos. Es sensible a cambios en la distribución general pero puede pasar por alto anomalías localizadas que no mueven mucho la forma global.

Supón que tu bug solo afecta a usuarios en Lituania entre las 2 AM y las 3 AM. La distribución global de montos de compra se ve bien. El bug está enterrado bajo el ruido de todas las demás zonas horarias. No lo atraparás con una sola comparación global.

La solución es la estratificación. En lugar de una prueba global, ejecuta pruebas separadas en segmentos de tus datos: por geografía, por tipo de dispositivo, por nivel de usuario, por hora del día. Un bug que es invisible globalmente puede gritar cuando miras el segmento correcto.

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)

Esto intercambia simplicidad por cobertura. Ahora estás ejecutando N pruebas estadísticas en lugar de una, lo que significa que necesitas preocuparte por la corrección de comparaciones múltiples. Un simple ajuste de Bonferroni, dividiendo tu umbral por el número de segmentos, suele ser suficiente para mantener los falsos positivos manejables.

Qué hacer cuando encuentras un cambio

Una prueba estadística no te dice por qué cambiaron los datos. Te dice que los datos cambiaron. El siguiente paso es el aislamiento de la causa raíz, y la mejor herramienta para eso es el análisis diferencial.

Tienes dos poblaciones: los datos antes del cambio y los datos después. Compara los datos en cada dimensión que se te ocurra. ¿El cambio está concentrado en un país específico? ¿En una versión específica de la API? ¿En un shard específico de la base de datos? La dimensión con la mayor diferencia relativa es usualmente donde vive el bug.

Aquí hay un analizador diferencial ligero:

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 muestra un pico de 10× en transactions de monto cero mientras todas las demás versiones están planas, has reducido un bug de datos de producción a un despliegue específico. Ese es un mejor punto de partida que “algo está mal en algún lugar”.

Esto no detecta

La depuración estadística no es un reemplazo de las unit tests ni del análisis estático. Atrapa una clase específica de bug: corrupción de datos silenciosa que se manifiesta como anomalías estadísticas. No atrapará bugs que no cambien los datos de maneras medibles. Un bug que siempre devuelve la respuesta correcta pero tarda diez segundos en lugar de diez milisegundos es invisible para la comparación de distribuciones. Un bug que intercambia dos campos en una entrada de registry pero no afecta la lógica de negocio es invisible. Un bug que produce respuestas incorrectas en exactamente la misma distribución estadística que las respuestas correctas es invisible.

También es inherentemente reactiva. Estás comparando datos actuales con datos históricos, lo que significa que el bug ya ocurrió. El objetivo es reducir el tiempo medio de detección de “cuando un cliente se queja” a “dentro del mismo ciclo de despliegue”.

Dónde empezar

No necesitas un equipo de ciencia de datos. Necesitas un trabajo programado y una alert.

Elige una metric crítica en tu sistema. order_total es una buena opción. exchange_rate es otra. Calcula su distribución durante los últimos siete días como línea base. Ejecuta la prueba KS contra la última hora de datos cada hora. Si la prueba falla, alert a alguien.

Las primeras semanas serán ruidosas. Ajustarás el umbral, agregarás dimensiones para estratificar, y aprenderás qué cambios son bugs reales y cuáles son el Black Friday. Ese ruido es el costo de la calibración. Una vez calibrado, tienes una red de seguridad que atrapa los bugs que tus pruebas no pueden ver.

Si quieres ir más allá, herramientas como Great Expectations y Deequ formalizan este patrón en suites reutilizables de calidad de datos. Pero la idea central cabe en cincuenta líneas de Python, y esas cincuenta líneas encontrarán bugs que toda tu suite de pruebas pasó por alto.