テストスイートはグリーンだ。ログは静かだ。ダッシュボードに赤い線はない。それでも、ユーザーの3%がマイナスの合計金額の請求書を受け取っているかもしれないし、推薦モデルが削除された製品を静かに先頭にランキングしているかもしれないし、集計パイプラインが特定のタイムゾーンからの返金を二重にカウントしているかもしれない。

これらはデータバグだ。例外を投げない。podをクラッシュさせない。observability stackのすべての層を通過する。なぜならすべての層がデータが正しいと仮定しているからだ。コードは書かれた通りに正確に実行された。問題は、書き込まれた内容がでたらめだったことだ。

statistical debuggingは、本番データを信号として扱い、バグをその信号の中の異常として捉える実践である。「コードはクラッシュしたか?」と問うのではなく、「データはいつも通りに見えるか?」と問う。答えがノーなら、stack traceが決して示さないバグを発見したことになる。

statistical debuggingが実際に何を意味するか

statistical debuggingはmachine learningではない。ニューラルネットワークは不要だ。必要なのはヒストグラムと、それに驚く意思だ。

核心的な考え方は、正しいソフトウェアは予測可能な統計的性質を持つデータを生成するということだ。ユーザーの年齢は18歳から80歳に集中する。購入金額は対数正規分布に従う。API応答時間は長い裾を持つが、中央値は安定している。これらの性質が変化したら、パイプラインの何かがそれを変化させた。新しいデプロイメント、スキーママイグレーション、サードパーティのAPIがnullの代わりに空文字列を返すようになった。変化は症状だ。バグが原因だ。

これは従来のデバッグの逆だ。従来のデバッグはエラーから始めてコードに遡る。statistical debuggingはデータから始めて、それを生み出したエラーに遡る。

statistical debuggingだけが捉えられるバグ

以下に実際のパターンを示す。支払いサービスが通貨換算ロジックをリファクタリングした。新しいコードはすべてのテストにパスする。統合テストは為替レートAPIをモックし、モックされたレートで100 USDが85 EURになることを検証する。アサーションは1つも失敗しない。

本番環境では、為替レートAPIは時々マイナー通貨に対してnullを返す。古いコードはエラーを投げてキャッシュされたレートにフォールバックした。フォールバックの存在を知らない誰かによって書かれた新しいコードは、JavaScriptでnull0に強制変換し、ゼロの為替レートでトランザクションを保存する。例外は出ない。トランザクションはコミットされる。ユーザーにはゼロが請求される。

エラー追跡ツールは何も見えない。レイテンシグラフは平坦だ。しかし、XOF通貨のexchange_rate値の分布には、ゼロにおいて巨大なスパイクが発生した。ヒストグラムなら数秒でそれを示すだろう。テストスイートでは決して見つけられない。

本番データの異常を検出する方法

最も単純なstatistical debuggingは分布比較である。メトリックを選び、履歴データからその分布を計算し、直近1時間の分布と比較する。大きく異なるなら、何かが変化した。

以下に、Kolmogorov-Smirnov検定を用いたPythonでの具体的な実装を示す。これは、分布の形状について何も仮定せずに2つのサンプルを比較するノンパラメトリックな方法だ。

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年から存在する2標本統計検定だ。しかし、ゼロ為替レートバグ、二重返金カウントバグ、マイナス請求書バグを捉える。なぜなら、それらすべてがデータの形状を測定可能な方法で変化させるからだ。

鍵は適切なメトリックを選ぶことだ。優れた候補は安定しているはずのものすべてだ:比率(refund_ratecart_abandonment_rate)、境界(agepricequantity)、形状(HTTPステータスコードの分布、時間ごとの登録パターン)、相関(purchase_amountsession_duration)。コードが正しければ、これらの関係性は不変だ。変化したら、コードが変化させたのだ。

分布比較の限界

KS検定には盲点がある。全体的な分布の変化には敏感だが、グローバルな形状をあまり動かさない局所的な異常は見逃す可能性がある。

バグがリトアニアのユーザーにのみ影響し、午前2時から3時の間にしか発生しないとしよう。購入金額のグローバルな分布は正常に見える。バグは他のすべてのタイムゾーンのノイズの下に埋もれている。単一のグローバル比較では捉えられない。

解決策は層化(stratification)だ。1つのグローバルテストの代わりに、データのスライスごとに個別のテストを実行する:地理ごと、デバイスタイプごと、ユーザーティアごと、時間帯ごとに。グローバルには見えないバグも、適切なスライスを見れば叫ぶように明らかになる。

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)

これは単純性とカバレッジを交換するものだ。今や1つではなくN個の統計検定を実行している。つまり、多重比較の補正を考慮する必要がある。単純なBonferroni adjustment、閾値をスライス数で割る方法で、通常は偽陽性を管理可能なレベルに抑えるのに十分だ。

変化を発見したときにすべきこと

統計検定はデータがなぜ変化したかを教えてくれない。データが変化したことを教えてくれるだけだ。次のステップは根本原因の切り分けであり、そのための最良のツールはdifferential analysisだ。

2つの母集団がある:変化前のデータと変化後のデータだ。思いつくすべての次元でそれらを比較する。変化は特定の国に集中しているか?特定のAPIバージョンか?特定のデータベースシャードか?最も大きな相対的差異を持つ次元が、通常バグが存在する場所だ。

以下に軽量なdifferential analyzerを示す。

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がゼロ金額トランザクションで10倍のスパイクを示し、他のすべてのバージョンが平坦なら、本番データのバグを特定のデプロイメントに絞り込んだことになる。「どこかで何かがおかしい」よりもはるかに優れた出発点だ。

これが捉えられないもの

statistical debuggingはunit testsやstatic analysisの代替ではない。特定のクラスのバグ、統計的異常として現れる静かなデータ破損を捉える。データを測定可能な方法で変化させないバグは捉えられない。正しい答えを常に返すが、10ミリ秒ではなく10秒かかるバグは、分布比較からは見えない。ログエントリの2つのフィールドを入れ替えるがビジネスロジックに影響しないバグは見えない。正しい答えとまったく同じ統計分布で間違った答えを生成するバグは見えない。

これは本質的にリアクティブでもある。現在のデータを履歴データと比較している。つまり、バグはすでに発生しているのだ。目標は、平均検出時間を「顧客が苦情を言ったとき」から「同じデプロイメントサイクル内」に短縮することだ。

どこから始めるか

データサイエンスチームは不要だ。1つの定期ジョブと1つのアラートが必要だ。

システム内の重要なメトリックを1つ選ぶ。order_totalは良い選択だ。exchange_rateもまた良い。過去7日間の分布をベースラインとして計算する。毎時、直近1時間のデータに対してKS検定を実行する。検定が失敗したら、誰かにページを送る。

最初の数週間はノイズが多いだろう。閾値を調整し、層化する次元を追加し、どの変化が本物のバグでどれがブラックフライデーなのかを学ぶ。そのノイズは較正の代償だ。一度較正されれば、テストでは見えないバグを捉える安全網が手に入る。

さらに進みたい場合、Great ExpectationsやDeequのようなツールはこのパターンを再利用可能なデータ品質スイートに形式化する。しかし、核心的な考え方はPythonの50行に収まり、その50行がテストスイート全体が見逃したバグを見つける。