この記事は Zenn にも同じ内容で公開しています(筆者本人による転載)。
この記事の表は、すべて記事末尾のスクリプトの出力です。外部データは使っていません。乱数だけで動きます。
AIエージェントに施策を検証させると、人間の手作業とは桁の違う数を試せます。**セグメントを100通り切って、全部測って、勝ったものを報告させる。**数分で終わります。
そこで出てきた「勝ち」を、私は最初、発見だと思っていました。
違いました。
以下はすべて、乱数だけで動くスクリプトの実測値です。外部データは使っていません。スクリプトは記事の最後に置いたので、手元で同じ数字が出ます。
まず、いちばん怖い表
A と B がまったく同一の仕組みだったとき(真の効果はゼロ)、それでもどれだけ「改善」に見えるかを測りました。元の成功率3%、20,000回の試行です。
1群の人数 引き分け 改善 +20%以上 +50%以上 倍以上
50 24.4% 20.7% 20.7% 16.8% 6.9%
100 16.5% 36.9% 36.2% 24.2% 13.1%
500 7.3% 46.4% 30.2% 13.0% 2.8%
1,000 5.2% 47.9% 23.7% 6.3% 0.4%
5,000 2.3% 48.4% 5.3% 0.0% 0.0%
20,000 1.3% 49.4% 0.1% 0.0% 0.0%
**n=100 のとき、13.1% が「倍以上に改善した」ように見えます。**効果はゼロなのに。
+20%以上の改善が偶然では出なくなるのは、1群 20,000人まで増やしてからです。
そして、AIだから起きること
ここまでは古典的な統計の話です。AIを使うと、これが別の問題に変わります。
全セグメントの真の効果がゼロのまま、切る数だけを増やしてみます。1群1,000人、有意水準5%。
切った数 1つ以上が有意になる確率 有意になる個数の中央値
1 5.0% 0
5 22.8% 0
20 63.3% 1
50 91.7% 2
100 99.8% 5
200 100.0% 10
100通り切れば、99.8%の確率で「有意な発見」が出ます。中央値で5個です。
中身は全部ノイズです。
人間が手で5通り試していた頃は、これは滅多に起きませんでした(22.8%)。AIに任せて100通り試させた瞬間、99.8%の確率で何かが見つかる状態に変わります。
そして厄介なことに、AIは見つけたものを、それらしい理由付きで説明してくれます。「このセグメントは〇〇の傾向があるため」と。理由は後からいくらでも付きます。
「効いた」と言う前に通す3つの検定
検定1 ― ヌル較正(偶然だけでどこまで出るか)
やること: 情報を一切含まない条件で、同じ分析を大量に回す。
本物のデータ → 施策あり/なしのラベルを**ランダムに振り直す**
→ 同じ分析を1,000回
→ 「改善幅」の分布を作る
**本物の結果が、この分布のどのあたりに来るかを見ます。**上位30%なら、それは偶然の範囲です。
これをやらないと、比較する相手がいません。「+20%改善しました」は、偶然でも+20%が出る世界なのかどうかが分からなければ、意味を持ちません。
検定2 ― 時期分割(前半と後半で同じ向きに出るか)
やること: データを時間で2つに割り、両方で同じ向きに出ることを条件にする。
同じ総量のデータを、1回で測る場合と、前後半に割って両方通す場合の比較です。
真の効果 1回で測って + 前後半とも +
0% 47.9% 22.3%
5% 59.5% 30.5%
10% 68.9% 39.1%
30% 93.3% 72.5%
効果ゼロのとき、通過率は 47.9% → 22.3% に落ちます。
本物(+30%)は 93.3% → 72.5%。落ちますが、まだ大半が通ります。
**偽物のほうが強く落ちる。**これがこの検定の値打ちです。
**時間で割ることに意味があります。**ランダムに半分に割ると、季節・キャンペーン・仕様変更といった「時期に紐づくもの」が両側に混ざり、両方で同じ向きに出てしまいます。前半で見つけて後半で確かめる、という順序でなければ、将来に効くことの証明になりません。
検定3 ― 検出力(その規模で、そもそも何が見えるか)
やること: 測定を始める前に、「この人数で検出できる最小の効果」を計算する。
有意水準5%、検出力80%のとき:
1群の人数 これ未満は見つけられない
100 225.2%
500 100.7%
1,000 71.2%
5,000 31.8%
20,000 15.9%
100,000 7.1%
1群1,000人では、+71%未満の改善は原理的に見つけられません。
それでも画面上の数字は動きます。動いた分は、測定できていない範囲の揺れです。
「1,000人でテストして+15%改善しました」は、測れないものを読んでいます。
**この計算は測定前にできます。**そして「必要な人数が集まらない」と分かったなら、**その施策は今は検証できない、というのが正しい結論です。**測ってから気づくと、出てしまった数字を捨てられなくなります。
私が実際にやらかしたこと
この3つを自動で通す検証スクリプトを書きました。そのスクリプト自身にバグがありました。
「偶然の分布と比べる」の閾値を、分布の平均値にしていました。
平均を閾値にする → 分布の**約半分**が閾値を超える
→ ノイズの半分が「有意」として通る
検定を実装したことと、検定が効いていることは別です。
直すときに、閾値を平均の1.2倍に変え、**なぜそう変えたかをコードのコメントに残しました。**同じ場所を後で誰か(未来の自分を含む)が「なぜ1.2なのか」と思って戻すからです。
検証の仕組みを入れるときは、その仕組み自体が壊れていないかを一度は確かめる必要があります。
もうひとつ、同じ時期にありました。データの異常率を検査したら、6,480ファイル全部が異常と出ました。
全部が異常なら、疑うべきは検査のほうです。実際、検査の側が全件ずれていました。そして、その誤検知が、本当に1件だけあった異常を覆い隠していました。
以来、こう決めています。異常率が0%か100%に張り付いたら、まず検査を疑う。
まとめ
① ヌル較正 偶然だけでどこまで出るかを、先に測る
② 時期分割 前半で見つけ、後半で確かめる
③ 検出力 測る前に「この規模で何が見えるか」を計算する
+ 探した数 何通り試したかを数え、その数で補正する
**4つ目が、AIを使うときにいちばん抜けます。**人間は自分が何通り試したか覚えていますが、AIに任せると数えなくなります。
「たくさん試して、いちばん良かったものを採用する」は、たくさん試すほど悪くなります。
スクリプト
上の表は全部、下のスクリプトの出力です。外部データを使いません。乱数だけで動きます。
pip install numpy のあと python null_calibration.py で、同じ表が出ます(乱数の種を固定しています)。
# -*- coding: utf-8 -*-
"""「効いた」と言う前に通す3つの検定 ― 記事の数字を再現するスクリプト。
外部データを一切使わない。乱数だけで動く。
python null_calibration.py
記事中の表がそのまま出る。
"""
import sys
import numpy as np
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
RNG = np.random.default_rng(20260831)
BASE_RATE = 0.03 # 元の成功率 3%
TRIALS = 20000 # 1条件あたりの試行数
def line(c="-", n=76):
print(c * n)
# ------------------------------------------------------------------
# 検定1 ヌル較正 ― 効果ゼロのとき、偶然だけでどこまで「改善」が出るか
# ------------------------------------------------------------------
print("=== 検定1 ヌル較正(真の効果は完全にゼロ)===")
print(f"元の成功率 {BASE_RATE:.1%} / A と B は同一の仕組み / 試行 {TRIALS:,}回")
print("※ n が小さいと成功数が同じ(引き分け)になりやすいので、別に数える\n")
print(f"{'1群の人数':>10} {'引き分け':>9} {'改善':>8} {'+20%以上':>10} {'+50%以上':>10} {'倍以上':>8}")
line()
for n in (50, 100, 500, 1000, 5000, 20000):
a = RNG.binomial(n, BASE_RATE, TRIALS) / n
b = RNG.binomial(n, BASE_RATE, TRIALS) / n
tie = float((b == a).mean())
ok = a > 0
lift = np.full(TRIALS, np.nan)
lift[ok] = (b[ok] - a[ok]) / a[ok]
up = float(np.nanmean(lift > 0))
up20 = float(np.nanmean(lift > 0.20))
up50 = float(np.nanmean(lift > 0.50))
up100 = float(np.nanmean(lift > 1.00))
print(f"{n:>10,} {tie*100:>8.1f}% {up*100:>7.1f}% {up20*100:>9.1f}% "
f"{up50*100:>9.1f}% {up100*100:>7.1f}%")
print("\n→ 効果が本当にゼロでも、n が小さいほど大きな「改善」が出る。")
print(" n=50 では 6回に1回は『+50%改善』に、n=100 では4回に1回は倍以上に見える。")
print(" n=20,000 まで増やして、はじめて +20%が偶然では出なくなる。\n")
# ------------------------------------------------------------------
# 検定1b 多重性 ― セグメントを何個も切ると、必ず「勝ち」が見つかる
# ------------------------------------------------------------------
print("=== 検定1b 多重性(全セグメントの真の効果はゼロ)===")
print("1群 1,000人 / 有意水準 5%(両側)\n")
print(f"{'切った数':>10} {'1つ以上が有意になる確率':>26} {'有意になる個数の中央値':>24}")
line()
n = 1000
for k in (1, 5, 20, 50, 100, 200):
a = RNG.binomial(n, BASE_RATE, (TRIALS // 20, k)) / n
b = RNG.binomial(n, BASE_RATE, (TRIALS // 20, k)) / n
pooled = (a + b) / 2
se = np.sqrt(np.maximum(pooled * (1 - pooled) * 2 / n, 1e-12))
sig = np.abs(b - a) / se > 1.96
print(f"{k:>10,} {sig.any(axis=1).mean()*100:>25.1f}% "
f"{np.median(sig.sum(axis=1)):>23.0f}")
print("\n→ 20個切れば6割、100個切ればほぼ確実に『有意な発見』が出る。")
print(" 探した数を数えていないと、これを発見と読んでしまう。\n")
# ------------------------------------------------------------------
# 検定2 時期分割 ― 前半と後半で同じ向きに出るか
# ------------------------------------------------------------------
print("=== 検定2 時期分割 ===")
print("同じ量のデータを、1回で測る場合と、前後半に割って両方通す場合\n")
print(f"{'真の効果':>10} {'1回で測って +':>16} {'前後半とも +':>16}")
line()
n_half = 1000
for true_lift in (0.00, 0.05, 0.10, 0.30):
p_b = BASE_RATE * (1 + true_lift)
a1 = RNG.binomial(n_half, BASE_RATE, TRIALS) / n_half
b1 = RNG.binomial(n_half, p_b, TRIALS) / n_half
a2 = RNG.binomial(n_half, BASE_RATE, TRIALS) / n_half
b2 = RNG.binomial(n_half, p_b, TRIALS) / n_half
one = float((((b1 + b2) - (a1 + a2)) > 0).mean())
both = float((((b1 - a1) > 0) & ((b2 - a2) > 0)).mean())
print(f"{true_lift:>9.0%} {one*100:>15.1f}% {both*100:>15.1f}%")
print("\n→ 効果ゼロのとき、1回測ると半分は『改善』に見えるが、")
print(" 前後半の両方を通す条件にすると2割強まで落ちる。")
print(" 本物(+30%)は7割強が通る。**偽物だけがふるい落とされる。**\n")
# ------------------------------------------------------------------
# 検定3 検出力 ― その規模で、そもそも何%の改善なら見つかるか
# ------------------------------------------------------------------
print("=== 検定3 検出力(その規模で検出できる最小の効果)===")
print("有意水準 5% / 検出力 80% のときに必要な改善幅\n")
print(f"{'1群の人数':>12} {'これ未満は見つけられない':>28}")
line()
for n in (100, 500, 1000, 5000, 20000, 100000):
delta = (1.96 + 0.84) * np.sqrt(2 * BASE_RATE * (1 - BASE_RATE) / n)
print(f"{n:>12,} {delta / BASE_RATE * 100:>27.1f}%")
print("\n→ 1群1,000人では、+71%未満の改善は原理的に見つけられない。")
print(" それでも数字は動く。動いた分は、測定できていない範囲の揺れである。")
print(" 『+15%改善しました』と1,000人で言うのは、測れないものを読んでいる。")
次の問いは「では、これをどう自動で回すか」です。
3つの検定を毎回手で通すのは続きません。エージェントが「効きました」と報告してくる前に、機械が自動で通す関門にする必要があります。私はそれを1ファイルにまとめて、結果が出るたびに通しています。
この連載
AIエージェントを実際に回して、かかった費用と壊れた箇所を測って書いています。推測は書きません。実測値だけです。
- Claude Code のトークン使用量を実測したら1ターン23万 ― 節約に効いたのは「セッションを切る」だった
https://qiita.com/manabu49-ai/items/2598a30d5140e4445ab6 - 57分間、死んだジョブを誰も見ていなかった ― AIエージェントの見張りを機械に渡す
https://qiita.com/manabu49-ai/items/5c7506c94b5be58c040e - 本記事 ― AIに100通り試させたら、効果ゼロなのに5個が「有意」だった
- 仕様書は2日前から手元にあった ― 読まずに実装した対処が、データを永久に失う設計だった
https://qiita.com/manabu49-ai/items/9b947604d3dc4958fa36 - 次 ― AI初心者が3日でエージェントを24時間回すまで(未公開)
番外 ― Claude のモデル提供終了、猶予は告知から約60日 ― 過去9回を数えて、手元を検査するスクリプトを書いた
https://qiita.com/manabu49-ai/items/bc2a7be2a6ee4ceb6185
**無料記事は結論まで全部書きます。**出し惜しみはしません。
この記事は Zenn にも同じ内容で公開しています(筆者本人による転載)。