あなたのモデルが出力した 0.87。誰かがそれをダッシュボードに「成功確率 87%」と表示した。顧客がそれで判断を下す。

その数値に意味を持たせるには、実際に何件の解決済み結果が必要だったのか?

閉形式の答えは存在し、我々全員より古い。しかしほとんどのチームはそれを計算していない。

その数値

95% 信頼度で ±ε 以内に収めたい割合の場合:

n ≥ p̂(1 − p̂) · (z / ε)²

Enter fullscreen mode Exit fullscreen mode

95% の場合、z = 1.96。コードを実行してみよう:

from math import ceil

def min_n(p_hat: float, eps: float, z: float = 1.96) -> int:
    """Resolved outcomes needed to quote p_hat within ±eps."""
    return ceil(p_hat * (1 - p_hat) * (z / eps) ** 2)

for p in (0.5, 0.8, 0.9):
    print(p, min_n(p, 0.05))
# 0.5 385
# 0.8 246
# 0.9 139

Enter fullscreen mode Exit fullscreen mode

「50% ±5 ポイント」と言うために必要な解決済み結果は 385 件。トレーニングセットの 385 行ではない——実際に何が起きたかを把握した 385 件のケースだ。

痛いところ

精度は二乗で効く。ε を 5 倍厳しくすると、必要なデータは 25 倍になる:

for eps in (0.10, 0.05, 0.02, 0.01):
    print(eps, min_n(0.5, eps))
# 0.1   97
# 0.05  385
# 0.02  2401
# 0.01  9604

Enter fullscreen mode Exit fullscreen mode

「だいたい半分」から「50% ±1 ポイント」へ移行するには約 9,500 件の結果が必要になる。結果が出るまでに 90 日かかる領域では、それはスプリントではなく年単位になる。

逆から読む

より役立つ方向は式を反転させることだ。必要なデータ量を聞く代わりに、現在のデータが何を語ることを許しているかを聞く:

def widest_claim(p_hat: float, n: int, z: float = 1.96) -> float:
    """The ± band your evidence actually supports."""
    return z * (p_hat * (1 - p_hat) / n) ** 0.5

print(round(widest_claim(0.5, 50), 3))   # 0.139

Enter fullscreen mode Exit fullscreen mode

n=50 の場合、正直な主張は ±14 ポイント。「50%」は実際には「36%〜64% のどこか」という意味になる。金額の隣に印刷すべき確率ではない。

これで全体の見え方が変わる。問題は精度ではなく語彙だ:n=50 ではパーセントではなく幅を言う権利がある。幅を言おう。

ウィルソン区間——素朴な区間ではない

補足:上記の式は正規近似であり、小さい n や極端な p̂ で大きく劣化する。1.0 を超える区間を平然と出力してしまう。代わりにウィルソン区間を使おう:

def wilson(successes: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (0.0, 1.0)
    p = successes / n
    d = 1 + z**2 / n
    centre = (p + z**2 / (2 * n)) / d
    half = z * ((p * (1 - p) / n + z**2 / (4 * n**2)) ** 0.5) / d
    return (centre - half, centre + half)

print(wilson(9, 10))   # (0.5958, 0.9821)

Enter fullscreen mode Exit fullscreen mode

10 件中 9 件の成功は「信頼性 90%」ではない。「60%〜98% のどこか」という意味であり、顧客の前に提示する文章としては大きく異なる。

ガイドラインではなく制約にする

本番環境で重要なのはこれだ:誰も強制しないルールは、締め切りのプレッシャーで形骸化する。ドキュメントでは守れない。コードパスにゲートを設置しよう。

MIN_N = 385

def render_score(p_hat: float, n_resolved: int) -> str:
    if n_resolved < MIN_N:
        lo, hi = wilson(round(p_hat * n_resolved), n_resolved)
        return f"band: {lo:.0%}{hi:.0%} (n={n_resolved})"
    return f"{p_hat:.0%} (n={n_resolved})"

Enter fullscreen mode Exit fullscreen mode

こうすれば、正直なバージョンがデフォルトのパスになる。過大主張するにはレビュー済みの PR でコードを削除する必要がある。これは「確率には注意しよう」と書かれた Wiki ページとは全く異なる組織的事実だ。

落とし穴:どの n か?

n解決済み結果であり、解決のプロセスにバイアスが潜む。

勝ちは下流データから自動解決されるが、負けは誰かが手動でマークする必要がある場合、解決済み集合は現実の無作為標本ではない。勝ちに偏っており、そこから計算される区間はすべて自信たっぷりに間違っている。欠測データが「欠測ではないランダム」であることは自ら宣言しない——キャリブレーションが素晴らしく見えるだけだ。

数値を信用する前に、結果を生成するメカニズムが結果の種類によって異なっていないかを確認しよう。その確認は、私にとってどんなモデル診断よりも多くの不良ダッシュボードを発見してきた。

なぜこれを気にするのか

私は請求ソフトウェアを開発している。当社のシステムが「回収の見込みが高い」と言うと、誰かがその数字に基づいて人員を割り当て、診療所は給与の判断を下す。自信たっぷりに間違ったパーセントの代償は、誰かのひと月分になる。

だから我々はボリュームが十分に溜まるまで幅を表示し、それ以前に「確率」という言葉を言うことをアーキテクチャレベルで禁止している。デモの見栄えは悪くなるが、顧客を失ったことは一度もない。


より長い議論——システムが構造的に過大主張を禁じられることの意味、そして主張する主体がその正しさを判断してはならない理由——については、こちらに書いた:The Uncapturable Judge

あなたのチームでは、スコアが確率と名乗るためのルールはどのようなものですか?