Sua test suite está verde. Seus logs estão silenciosos. Seus dashboards não mostram linhas vermelhas. E mesmo assim, 3% dos seus usuários estão recebendo faturas com totais negativos, ou seu modelo de recomendação está silenciosamente classificando produtos deletados em primeiro lugar, ou seu pipeline de agregação está contando duas vezes reembolsos de um fuso horário específico.

Esses são bugs de dados. Eles não lançam exceções. Eles não derrubam pods. Eles passam por cada camada da sua pilha de observabilidade porque cada camada assume que os dados estão corretos. O código executou exatamente como escrito. O problema é que o que ele registrou foi absurdo.

O statistical debugging é a prática de tratar seus dados de produção como um sinal e bugs como anomalias nesse sinal. Em vez de perguntar “o código travou?”, você pergunta “os dados parecem como costumam parecer?” Quando a resposta é não, você encontrou um bug que nenhum stack trace jamais lhe mostrará.

O que o statistical debugging realmente significa

O statistical debugging não é machine learning. Você não precisa de uma rede neural. Você precisa de um histograma e da disposição de se surpreender com ele.

A ideia central é que software correto produz dados com propriedades estatísticas previsíveis. Idades de usuários se concentram entre 18 e 80. Valores de compra seguem uma distribuição log-normal. Tempos de resposta de API têm uma cauda longa, mas uma mediana estável. Quando essas propriedades mudam, algo no pipeline as mudou. Um novo deploy, uma migration de schema, uma API de terceiros retornando strings vazias em vez de nulls. A mudança é o sintoma. O bug é a causa.

Esse é o inverso do debugging tradicional. O debugging tradicional começa com um erro e trabalha de trás para frente até o código. O statistical debugging começa com os dados e trabalha de trás para frente até o erro que os produziu.

Um bug que só o statistical debugging pegaria

Aqui está um padrão real. Um serviço de pagamentos faz o refactor da sua lógica de conversão de moedas. O novo código passa em todos os testes. integration tests mockam a API de taxa de câmbio e verificam que 100 USD viram 85 EUR na taxa mockada. Nenhuma asserção falha.

Em produção, a API de taxa de câmbio ocasionalmente retorna null para moedas secundárias. O código antigo lançava um erro e recorria a uma taxa em cache. O novo código, escrito por alguém que não sabia do fallback, coage null para 0 em JavaScript e armazena a transaction com uma taxa de câmbio zero. Nenhuma exceção. A transaction é committed. O usuário é cobrado zero.

Sua ferramenta de rastreamento de erros não vê nada. Seu gráfico de latência está plano. Mas a distribuição dos valores de exchange_rate para a moeda XOF acabou de brotar um pico massivo em zero. Um histograma mostraria isso em segundos. Uma test suite nunca encontraria.

Como detectar anomalias em dados de produção

A versão mais simples do statistical debugging é a comparação de distribuições. Você escolhe uma metric, calcula sua distribuição a partir de dados históricos, e a compara com a distribuição da última hora. Se elas diferem significativamente, algo mudou.

Aqui está uma implementação concreta em Python usando o teste de Kolmogorov-Smirnov, uma forma não-paramétrica de comparar duas amostras sem assumir nada sobre sua 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, ...}

Isso não é sofisticado. É um teste estatístico de duas amostras que existe desde 1939. Mas ele vai pegar o bug de taxa-de-câmbio-zero, o bug de reembolso-contado-duas-vezes, e o bug de fatura-negativa porque todos eles mudam a forma dos dados de formas mensuráveis.

A chave é escolher as metrics certas. Bons candidatos são qualquer coisa que deveria ser estável: razões (refund_rate, cart_abandonment_rate), limites (age, price, quantity), formas (a distribuição de códigos de status HTTP, o padrão horário de signups), e correlações (purchase_amount vs. session_duration). Se seu código está correto, essas relações são invariantes. Se elas mudam, seu código as mudou.

Os limites da comparação de distribuições

O teste KS tem pontos cegos. Ele é sensível a mudanças na distribuição geral, mas pode perder anomalias localizadas que não movem muito a forma global.

Suponha que seu bug só afete usuários na Lituânia entre 2h e 3h da manhã. A distribuição global dos valores de compra parece normal. O bug está enterrado sob o ruído de todos os outros fusos horários. Você não vai pegá-lo com uma única comparação global.

A correção é a estratificação. Em vez de um teste global, execute testes separados em fatias dos seus dados: por geografia, por tipo de dispositivo, por nível de usuário, por hora do dia. Um bug que é invisível globalmente pode gritar quando você olha a fatia certa.

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)

Isso troca simplicidade por cobertura. Você agora está executando N testes estatísticos em vez de um, o que significa que precisa se preocupar com a correção de comparações múltiplas. Um ajuste simples de Bonferroni, dividindo seu threshold pelo número de fatias, geralmente é suficiente para manter os falsos positivos administráveis.

O que fazer quando você encontra uma mudança

Um teste estatístico não lhe diz por que os dados mudaram. Ele lhe diz que os dados mudaram. O próximo passo é o isolamento da causa raiz, e a melhor ferramenta para isso é a análise diferencial.

Você tem duas populações: os dados antes da mudança e os dados depois. Compare-os em cada dimensão que conseguir imaginar. A mudança está concentrada em um país específico? Uma versão específica de API? Um shard específico de banco de dados? A dimensão com a maior diferença relativa é geralmente onde o bug mora.

Aqui está um analyzer diferencial leve:

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)

Se api_version: v2.3 mostrar um pico de 10× em transactions de valor zero enquanto todas as outras versões estão planas, você restringiu um bug de dados de produção a um deploy específico. Esse é um ponto de partida melhor do que “algo está errado em algum lugar.”

O que isso não pega

O statistical debugging não é um substituto para unit tests ou análise estática. Ele pega uma classe específica de bug: corrupção silenciosa de dados que se manifesta como anomalias estatísticas. Ele não vai pegar bugs que não mudam os dados de formas mensuráveis. Um bug que sempre retorna a resposta correta, mas leva dez segundos em vez de dez milissegundos, é invisível para a comparação de distribuições. Um bug que troca dois campos em uma entrada de log, mas não afeta a lógica de negócio, é invisível. Um bug que produz respostas erradas na mesma distribuição estatística que as respostas certas é invisível.

Ele também é inerentemente reativo. Você está comparando dados atuais com dados históricos, o que significa que o bug já aconteceu. O objetivo é reduzir o tempo médio de detecção de “quando um cliente reclama” para “dentro do mesmo ciclo de deploy”.

Por onde começar

Você não precisa de uma equipe de ciência de dados. Você precisa de um job agendado e um alert.

Escolha uma metric crítica no seu sistema. order_total é uma boa escolha. exchange_rate é outra. Calcule sua distribuição nos últimos sete dias como baseline. Execute o teste KS contra a última hora de dados a cada hora. Se o teste falhar, page alguém.

As primeiras semanas serão barulhentas. Você vai ajustar o threshold, adicionar dimensões para estratificar, e aprender quais mudanças são bugs reais e quais são Black Friday. Esse ruído é o custo da calibração. Uma vez calibrado, você tem uma rede de segurança que pega os bugs que seus testes não conseguem ver.

Se quiser ir mais longe, ferramentas como Great Expectations e Deequ formalizam esse padrão em suites reutilizáveis de qualidade de dados. Mas a ideia central cabe em cinquenta linhas de Python, e essas cinquenta linhas vão encontrar bugs que toda a sua test suite deixou passar.