Ваш набор тестов зелёный. Ваши логи тихие. Ваши дашборды не показывают красных линий. И всё же 3% ваших пользователей получают счета с отрицательными итогами, или ваша рекомендательная модель тихо ранжирует удалённые продукты первыми, или ваш агрегационный пайплайн дважды считает возвраты из определённого часового пояса.

Это баги данных. Они не бросают исключений. Они не крашат поды. Они проходят через каждый слой вашего стека observability, потому что каждый слой предполагает, что данные верны. Код выполнился точно так, как написан. Проблема в том, что то, что он записал, была чепуха.

Statistical debugging — это практика рассмотрения ваших продакшен-данных как сигнала, а багов — как аномалий в этом сигнале. Вместо вопроса «код упал?» вы спрашиваете «данные выглядят так же, как обычно?» Когда ответ — нет, вы нашли баг, который ни один stack trace вам никогда не покажет.

Что на самом деле означает statistical debugging

Statistical debugging — это не машинное обучение. Вам не нужна нейронная сеть. Вам нужна гистограмма и готовность удивляться ей.

Основная идея в том, что корректное ПО порождает данные с предсказуемыми статистическими свойствами. Возраст пользователей кластеризуется между 18 и 80. Суммы покупок следуют логнормальному распределению. Время ответа API имеет длинный хвост, но стабильную медиану. Когда эти свойства сдвигаются, что-то в пайплайне их сдвинуло. Новый деплой, миграция схемы, сторонний API, возвращающий пустые строки вместо null. Сдвиг — это симптом. Баг — это причина.

Это обратная сторона традиционной отладки. Традиционная отладка начинается с ошибки и движется назад к коду. Statistical debugging начинается с данных и движется назад к ошибке, которая их породила.

Баг, который поймает только statistical debugging

Вот реальный паттерн. Платёжный сервис делает рефакторинг логики конвертации валют. Новый код проходит все тесты. Интеграционные тесты мокают API курсов обмена и проверяют, что 100 USD становятся 85 EUR по замоканному курсу. Ни один assertion не падает.

В продакшене API курсов обмена время от времени возвращает null для малых валют. Старый код бросал ошибку и откатывался к кешированному курсу. Новый код, написанный человеком, который не знал о fallback, приводит null к 0 в JavaScript и сохраняет транзакцию с нулевым обменным курсом. Никакого исключения. Транзакция коммитится. Пользователю выставляется ноль.

Ваш инструмент отслеживания ошибок ничего не видит. Ваш график латентности ровный. Но распределение значений exchange_rate для валюты XOF только что дало массивный всплеск на нуле. Гистограмма показала бы это за секунды. Набор тестов никогда бы этого не нашёл.

Как обнаруживать аномалии в продакшен-данных

Самая простая версия statistical debugging — это сравнение распределений. Вы выбираете метрику, вычисляете её распределение по историческим данным и сравниваете с распределением за последний час. Если они значительно отличаются, что-то изменилось.

Вот конкретная реализация на Python с использованием теста Колмогорова-Смирнова — непараметрического способа сравнить две выборки, ничего не предполагая об их форме.

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

Это не что-то изысканное. Это двухвыборочный статистический тест, существующий с 1939 года. Но он поймает баг с нулевым обменным курсом, баг двойного счёта возвратов и баг отрицательного счёта, потому что все они меняют форму данных измеримым образом.

Ключ — в выборе правильных метрик. Хорошие кандидаты — всё, что должно быть стабильным: соотношения (refund_rate, cart_abandonment_rate), границы (age, price, quantity), формы (распределение HTTP-статусов, почасовой паттерн регистраций) и корреляции (purchase_amount vs. session_duration). Если ваш код корректен, эти отношения инвариантны. Если они меняются, ваш код их изменил.

Ограничения сравнения распределений

У теста KS есть слепые зоны. Он чувствителен к сдвигам в общем распределении, но может пропустить локализованные аномалии, которые сильно не сдвигают глобальную форму.

Допустим, ваш баг затрагивает только пользователей в Литве между 2 и 3 часами ночи. Глобальное распределение сумм покупок выглядит нормально. Баг зарыт под шумом всех остальных часовых поясов. Вы не поймаете его одним глобальным сравнением.

Решение — стратификация. Вместо одного глобального теста запускайте отдельные тесты на срезах ваших данных: по географии, по типу устройства, по уровню пользователя, по часу дня. Баг, невидимый глобально, может кричать, когда вы смотрите на правильный срез.

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)

Это обмен простоты на покрытие. Теперь вы запускаете N статистических тестов вместо одного, а значит, вам нужно заботиться о поправке на множественные сравнения. Простая поправка Бонферрони, делящая ваш порог на количество срезов, обычно достаточна, чтобы держать ложные срабатывания под контролем.

Что делать, когда вы нашли сдвиг

Статистический тест не говорит вам, почему данные изменились. Он говорит вам, что данные изменились. Следующий шаг — изоляция root cause, и лучший инструмент для этого — дифференциальный анализ.

У вас две популяции: данные до сдвига и данные после. Сравните их по каждому измерению, которое только придёт в голову. Сконцентрирован ли сдвиг в конкретной стране? Конкретной версии API? Конкретном шарде базы данных? Измерение с наибольшим относительным различием — обычно там и живёт баг.

Вот лёгкий дифференциальный анализатор:

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)

Если api_version: v2.3 показывает десятикратный всплеск транзакций с нулевой суммой, в то время как все остальные версии ровные, вы сузили продакшен-баг данных до конкретного деплоя. Это лучшая отправная точка, чем «где-то что-то не так».

Что это не ловит

Statistical debugging — не замена юнит-тестам или статическому анализу. Он ловит определённый класс багов: тихое повреждение данных, проявляющееся как статистические аномалии. Он не поймает баги, которые не меняют данные измеримым образом. Баг, который всегда возвращает правильный ответ, но занимает десять секунд вместо десяти миллисекунд, невидим для сравнения распределений. Баг, который меняет местами два поля в записи лога, но не влияет на бизнес-логику, невидим. Баг, который порождает неправильные ответы в точно таком же статистическом распределении, как и правильные, невидим.

Он также по своей природе реактивен. Вы сравниваете текущие данные с историческими, а значит, баг уже произошёл. Цель — сократить среднее время обнаружения от «когда пожаловался клиент» до «в рамках того же цикла деплоя».

С чего начать

Вам не нужна команда data science. Вам нужна одна запланированная джоба и один алерт.

Выберите одну критическую метрику в вашей системе. order_total — хороший выбор. exchange_rate — ещё один. Вычислите её распределение за последние семь дней как baseline. Запускайте тест KS против последнего часа данных каждый час. Если тест не проходит, пейджите кого-нибудь.

Первые несколько недель будут шумными. Вы будете настраивать порог, добавлять измерения для стратификации и учиться отличать реальные баги от Чёрной пятницы. Этот шум — цена калибровки. Как только она откалибрована, у вас есть защитная сетка, которая ловит баги, невидимые для ваших тестов.

Если хотите углубиться, инструменты вроде Great Expectations и Deequ формализуют этот паттерн в reusable наборы для качества данных. Но основная идея помещается в пятьдесят строк Python, и эти пятьдесят строк найдут баги, которые пропустил весь ваш набор тестов.