Play Console の段階公開を 5% に動かした翌朝、Crashlytics には「Crash-free users 99.76%」と出ていました。手元の基準は 99.7% です。超えているのだから 25% へ進めてよい、と指が動きかけて止まりました。母数の欄が 420 だったからです。
数字は同じでも、420 人の 99.76% と 2 万人の 99.76% は、別の情報です。前者は「たまたま 1 人しか落ちなかった」可能性を含み、後者はほぼ確定した値です。画面は両方に同じ書式で同じ顔をした数字を出します。
私は 5% → 25% → 50% → 100% の四段で出し、Crash-free users が 99.7% を下回るか ANR が 0.20% を超えたら halt(比率を戻す)と決めています。以前の記事(Android 壁紙アプリ v2.1.0 公開後の保守)に書いたとおり、v2.0.0 では二度止めました。その線引き自体は変えません。変えたのは、線と比べる「数字の信頼度」を一緒に見ることです。
ここでは、母数込みで進む・待つ・止めるを決める小さな Python スクリプトと、それを毎朝回したときに起きる意外な副作用を、手元で乱数を回して確かめた結果としてお伝えします。
同じ 99.76% でも、母数で読める範囲がこれだけ変わる
Crash-free を「クラッシュしなかったユーザーの割合」と読むと、それはユーザー数 n のうち k 人が落ちたという二項データです。観測値が同じでも、真の値の幅は n で決まります。95% の Wilson 区間(後述)で並べると次のようになります。
| ユーザー数 | 落ちた人数 | 観測の Crash-free | 真の値の範囲(95%) | 判定(目標 99.7%) |
|---|---|---|---|---|
| 420 | 1 | 99.76% | 98.66〜99.96% | 待つ |
| 2,000 | 5 | 99.75% | 99.42〜99.89% | 待つ |
| 5,000 | 12 | 99.76% | 99.58〜99.86% | 待つ |
| 20,000 | 48 | 99.76% | 99.68〜99.82% | 待つ |
4 行とも観測は 99.76% 前後で、目標の 99.7% を上回っています。それでも 2 万人の時点ですら、区間の下端 99.68% は目標をわずかに割っています。「超えているから進める」は、思っていたよりずっと遅い段階まで成立しません。
最初の 5% で見える数字は、おおむね「方向の目安」です。これを判定の根拠にするなら、根拠にしてよい条件を先に決めておく必要があります。
進む・待つ・止めるを、区間と目標の位置関係で決める
判定は単純です。真の値の区間が、許容ライン(99.7% なら クラッシュ率 0.3%)に対してどこにあるかを見ます。
- 区間の上端でも許容内なら、確かに良いので進む
- 区間の下端でも許容を超えていて、件数も偶然で済まないなら、確かに悪いので止める
- 区間が許容ラインをまたいでいるなら、まだ決められないので待つ
以下がそのまま動くスクリプトです。標準ライブラリだけで、Crashlytics の画面に出ている「ユーザー数」と「クラッシュしたユーザー数」を手で渡します。
#!/usr/bin/env python3
"""段階公開の「進む/待つ/止める」を、母数込みで判定する。
使い方: python3 rollout_gate.py --users 420 --crashed 1 --target 99.7
標準ライブラリのみ。Crashlytics / Play Console に出ている「ユーザー数」と
「クラッシュしたユーザー数」を手で入れて使います。"""
import argparse
import math
import sys
Z = {90: 1.645, 95: 1.960} # 片側 95% = 1.645 / 両側 95% = 1.960
def wilson(k: int, n: int, z: float) -> tuple[float, float]:
"""クラッシュ率 k/n の Wilson 区間 (low, high)。n が小さくても 0〜1 に収まる。"""
if n <= 0:
raise ValueError("users must be > 0")
p = k / n
z2 = z * z
denom = 1 + z2 / n
center = (p + z2 / (2 * n)) / denom
half = z * math.sqrt(p * (1 - p) / n + z2 / (4 * n * n)) / denom
return max(0.0, center - half), min(1.0, center + half)
def judge(users: int, crashed: int, target_pct: float, z: float = Z[95],
min_crashed: int = 3) -> dict:
allowed = 1 - target_pct / 100 # 許容するクラッシュ率 (99.7 → 0.003)
low, high = wilson(crashed, users, z)
if low > allowed and crashed >= min_crashed:
verdict = "HALT" # 下限でも許容超え、かつ件数が偶然では済まない
elif high <= allowed:
verdict = "PROCEED" # 上限でも許容内 = 確実に良い
else:
verdict = "WAIT" # 区間が許容ラインをまたぐ = まだ決められない
return {
"users": users, "crashed": crashed,
"observed_crash_free": 100 * (1 - crashed / users),
"crash_free_low": 100 * (1 - high), "crash_free_high": 100 * (1 - low),
"verdict": verdict,
}
def users_needed_for_zero_crash(target_pct: float, z: float = Z[95]) -> int:
"""クラッシュ 0 件のまま PROCEED と言えるのは何ユーザー目からか。
k=0 の Wilson 上限 = z²/(n+z²) <= allowed を n について解く。"""
allowed = 1 - target_pct / 100
return math.ceil(z * z * (1 / allowed - 1))
def days_to_decide(users: int, needed: int, users_per_day: float) -> int:
"""あと何日で needed ユーザーに届くか。users_per_day は「この段階で新規に
新バージョンを開くユーザー数」の直近の実測値を入れる。"""
if users >= needed:
return 0
if users_per_day <= 0:
raise ValueError("users_per_day must be > 0")
return math.ceil((needed - users) / users_per_day)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--users", type=int, required=True)
ap.add_argument("--crashed", type=int, required=True)
ap.add_argument("--target", type=float, default=99.7)
ap.add_argument("--per-day", type=float, default=0,
help="この段階で 1 日に増える新バージョン利用ユーザー数(実測)")
ap.add_argument("--min-crashed", type=int, default=3,
help="この件数に満たない間は HALT にせず WAIT にする")
ap.add_argument("--conf", type=int, choices=sorted(Z), default=95)
a = ap.parse_args()
if a.crashed > a.users:
print("crashed が users を超えています", file=sys.stderr)
return 2
r = judge(a.users, a.crashed, a.target, Z[a.conf], a.min_crashed)
print(f"{r['verdict']}: {r['crashed']}/{r['users']} users crashed "
f"(観測 {r['observed_crash_free']:.2f}% / 真の値は "
f"{r['crash_free_low']:.2f}〜{r['crash_free_high']:.2f}% の範囲)")
if r["verdict"] == "WAIT":
need = users_needed_for_zero_crash(a.target, Z[a.conf])
print(f" 目標 {a.target}% を 0 件で確かめるには約 {need} ユーザー必要")
if a.per_day > 0:
print(f" 今の増え方なら、あと約 {days_to_decide(a.users, need, a.per_day)} 日")
return {"PROCEED": 0, "WAIT": 10, "HALT": 20}[r["verdict"]]
if __name__ == "__main__":
sys.exit(main())Wilson 区間を選んだ理由は、母数が小さく、クラッシュが 0〜数件という領域で、普通の正規近似(観測値 ± 誤差)が壊れるからです。k が 0 のとき正規近似の幅は 0 になり、「0 件だから完全に安全」という誤った自信を生みます。Wilson は k=0 でも幅を保ち、0〜1 の外にもはみ出しません。終了コードを 0・10・20 に分けているので、シェルスクリプトや CI から呼んで分岐にも使えます。
実際に動かすと、こう返ります。
$ python3 rollout_gate.py --users 420 --crashed 1
WAIT: 1/420 users crashed (観測 99.76% / 真の値は 98.66〜99.96% の範囲)
目標 99.7% を 0 件で確かめるには約 1277 ユーザー必要
$ python3 rollout_gate.py --users 2000 --crashed 14
HALT: 14/2000 users crashed (観測 99.30% / 真の値は 98.83〜99.58% の範囲)