Dev Study
← 評価のための統計
レッスン 10 / 10·約 8 分·第22期 評価のための統計

裾を測るには、裾に落ちた件数が要る

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

このコースは「その差は、何も変えなくても出る大きさではないのか」を問い続けてきました。最終回は、遅い側の裾を表す1つの数字がどれだけ揺れるかを測ります。

まず結論

p99 を決めているのは、上位1%に落ちた少数の点です。1000件集めても、その点は約10件しかありません。この模型では、同じ1000件でも p99 は p50 の相対で約3倍の幅で揺れました。

裾に点が少ないと、パーセンタイルの定義(補間するか、実在の値を選ぶか)でも値が変わります。10件の p90 は、定義しだいで 909 にも 181 にも 90 にもなります。

? 読む前に予測

中央値50の対数正規分布から1000件取り、p50 と p99 を出す作業を500回繰り返します。p50 は真値の約±6%に収まりました。p99 はどうなるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

推定値を決めているのは、何件か

第13期『平均は、誰のことも指していない』では、平均より p99 を見る理由を見ました。今回は同じ p99 を、何件から出したかで読みます。

p50 は並びの真ん中です。1000件なら、両側に約500件ずつあります。1件増えても減っても、真ん中はあまり動きません。

p99 は違います。上位1%、つまり1000件のうち約10件の並び方で決まります。たとえば上位に落ちたのが7件か13件か、そのうち1件が飛び抜けて遅いか。それだけで値が動きます。遅い側の裾は値の間隔も広いので、1件の差が大きく出ます。

全体の件数は同じでも、推定に効いている件数が違う。これが今回の原理です。

同じ1000件でも、推定に効いている件数が違う
1000件の観測並べると、遅い側の裾が長い
どの点で決まるか
p50真ん中。両側に約500件ずつ
p99上位1%の約10件で決まる
測り直すと
あまり動かないseed 1 で真値の約±6%
大きく動くseed 1 で真値の約±18%
  • ・幅は、500回測り直したときの中央95%(対数正規・中央値50の模型)
  • ・裾の精度を上げるのは全体の件数ではなく、裾に落ちる件数
具体実物

1000件でも、p99 は ±18% 揺れた

中央値50、裾の長い対数正規分布を模型にします。真の p99 は理論値で 321.5 です。n 件取って p99・p50・平均を出す作業を500回繰り返し、中央95%の範囲を出します。p99 と p50 は nearest-rank で数えます。

# L10: how far p99 / p50 / mean move between samples. lognormal (median 50, sigma 0.8). stdlib only.
import random, math, statistics, sys
seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1
rng = random.Random(seed)
def nearest_rank(x, q):
    s = sorted(x)
    return s[max(1, math.ceil(q * len(s))) - 1]
def sample(n):
    return [rng.lognormvariate(math.log(50), 0.8) for _ in range(n)]
print(f"seed={seed}  500 samples for each n, middle 95% of the 500 estimates")
for n in (100, 1000, 10000):
    p99, p50, mean = [], [], []
    for _ in range(500):
        x = sample(n)
        p99.append(nearest_rank(x, 0.99)); p50.append(nearest_rank(x, 0.5)); mean.append(statistics.fmean(x))
    r = lambda v: (round(sorted(v)[12], 1), round(sorted(v)[487], 1))
    print(f"  n={n}: p99 {r(p99)}  p50 {r(p50)}  mean {r(mean)}  points above p99 ~{n // 100}")
for i in range(2):
    x = sample(1000)
    bs = sorted(nearest_rank([rng.choice(x) for _ in range(1000)], 0.99) for _ in range(2000))
    print(f"  one sample n=1000 #{i + 1}: p99={nearest_rank(x, 0.99):.1f}  bootstrap 95% [{bs[50]:.1f}, {bs[1950]:.1f}]")
$ python3 tail.py 1(macOS・Python 3.14.6。Docker の python:3.8-slim でも同じ出力)コマンド出力
seed=1 500 samples for each n, middle 95% of the 500 estimates
n=100: p99 (184.2, 486.2) p50 (40.4, 60.8) mean (57.4, 84.6) points above p99 ~1
n=1000: p99 (263.9, 380.4) p50 (47.1, 53.2) mean (65.0, 72.9) points above p99 ~10
n=10000: p99 (303.3, 341.0) p50 (49.0, 51.0) mean (67.6, 70.1) points above p99 ~100
one sample n=1000 #1: p99=328.0 bootstrap 95% [292.8, 438.2]
one sample n=1000 #2: p99=327.1 bootstrap 95% [281.5, 433.5]

n=1000 の行で、p50 は 47.1〜53.2、p99 は 263.9〜380.4 です。何も変えずに1000件ずつ測り直すだけで、p99 はこの範囲のどこにでも出ます。 改善前後で p99 が 300 から 340 に動いても、それだけでは何も言えません。

n=100 では上位1%が約1件で、p99 は 184.2〜486.2。n=10000 で約100件になると 303.3〜341.0 まで締まり、n=1000 の p50 とほぼ同じ相対幅になりました。端の値は seed で動きます(seed 2 では n=100 の上端が 462.7)。

✓ ここまでの確認

同じ1000件から出したのに、p99 が p50 より大きく揺れる理由として最も近いものはどれですか。

抽象原理

点が少ないと、定義まで効いてくる

裾に点が少ないと、揺れるだけでは済みません。パーセンタイルの定義の違いが値に出ます。 点と点の間をどう埋めるかについて、NIST の手引きはこう書きます(綴りは原文のまま)。

There is not a standard univerally accepted way to perform this interpolation.

同じ手引きは、Hyndman と Fan が nine different methods を比べたと紹介しています。10件の最小例で3つを比べます。

# L10: p90 of 10 points under three definitions. no randomness.
import math, statistics
x = [10, 20, 30, 40, 50, 60, 70, 80, 90, 1000]
print("exclusive (Python default):", statistics.quantiles(x, n=10)[8])
print("inclusive                 :", statistics.quantiles(x, n=10, method="inclusive")[8])
print("nearest-rank              :", sorted(x)[math.ceil(0.9 * len(x)) - 1])
$ python3 defs.py(Python 3.14.6 と 3.8.20 で同じ出力)コマンド出力
exclusive (Python default): 909.0
inclusive : 181.0
nearest-rank : 90

既定の exclusive は、90 と 1000 の間を補間して 909.0 を返します。観測に無い値です。

第13期では、パーセンタイルは位置で選ぶので実在の1回が出る、と見ました。あれは nearest-rank で数えた場合で、同回の用語集にもその条件が付いています。選び出す定義なら実在の1回、補間する定義なら観測に無い値もありえます。 否定ではなく、条件の補足です。

逆向きの言い過ぎもあります。NIST は代表的な3方式について fairly similar, but not exactly the same (particularly for small samples) と書きます。件数が十分なら定義の差は小さく、差が露わになるのは裾に点が少ないときです。

具体実物

全体の6.70%を占める関数が、12回中4回写らなかった

第13期『写っていないことは、起きていない証拠ではない』で、自作のサンプリングプロファイラを 100 Hz で12回走らせました。rare_spike は決定的な計時で全体の6.70%を占めるのに、1回あたりのサンプル数の平均は 1.00 で、12回中4回は0でした。

構造は n=100 の p99 と同じです。稀な部分に落ちる点が1回あたり1件前後しかなければ、推定は大きく揺れ、ときに0になります。 全体で何サンプル取ったかは効きません。効くのは、その関数の上に何件落ちたかです。

同回が付けた限定もそのまま引き継ぎます。6.70% も「12回中4回」も、その環境のその試行の値です。

✓ ここまでの確認

`[10, 20, 30, 40, 50, 60, 70, 80, 90, 1000]` の p90 が、Python の既定では 909.0、nearest-rank では 90 になりました。この差の読み方として適切なのはどれですか。

⇄ 2つを見比べる
具体例 A

レイテンシの p99

上位1%に落ちるのは n=100 で約1件、n=1000 で約10件。n=100 の p99 は真値321.5に対し 184.2〜486.2 まで動きました(seed 1)。

tail.py 1 の出力(2行目)コマンド出力
n=100: p99 (184.2, 486.2) p50 (40.4, 60.8) mean (57.4, 84.6) points above p99 ~1
具体例 B

プロファイラの rare_spike

全体の6.70%を占めるのに、100 Hz では1回あたりのサンプル数の平均が 1.00。12回中4回は0でした(第13期の実演)。

the-profiler-blinks の出力(100 Hz の部分)コマンド出力
rare_spike sample counts per run: [2, 0, 2, 1, 2, 1, 0, 1, 1, 2, 0, 0]
runs where rare_spike got ZERO samples (invisible): 4/12
mean samples/run: 1.00

遅延の分布の裾と、プロファイルの中の小さな関数。題材は違いますが、推定が揺れる理由として共通するものは何でしょうか。

それぞれ、推定を実際に決めている点は1回あたり何件あったかを数えてみてください。

この章のまとめ

  • p99 を決めるのは上位1%に落ちた点。1000件でも約10件で、同じ n の p50 の相対で約3倍の幅で揺れた(この模型)。
  • p99 を p50 並みの幅で言うには、この模型では約10倍の件数が要った。
  • 裾に点が少ないと定義の差が出る。10件の p90 は 909.0・181.0・90。件数が多ければ代表的な方式は近い。
  • 第13期の「実在の1回」は nearest-rank の場合。補間する定義なら観測に無い値も出る。
  • ブートストラップ区間も標本ごとに揺れ、左右対称とも限らない。

このコースは1本目から同じ問いを繰り返してきました。その差は、何も変えなくても出る大きさではないのか。 A/A の率の差、平方根でしか縮まないばらつき、差の区間、覗くたびに生まれる勝者、何もしなくても戻る最悪の回。今回の p99 も同じで、何も変えずに測り直すだけで大きく動きます。裾の数字を比べる前に、裾に何件あったかを数えてください。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

本文の模型(seed 1)で、n=10000 の p99 の幅(真値の −5.7%〜+6.1%)とほぼ同じ幅になったのはどれですか。

用語集(3語)
nearest-rank
小さい順に並べ、小さいほうから ceil(p×n) 番目の観測値をそのまま選ぶ定義。必ず実在の値が出る。
exclusive / inclusive
Python の statistics.quantiles の方式。既定の exclusive は標本より極端な値がありうる前提で補間し、標本の外側の値も返しうる。inclusive は最小値と最大値を0%と100%とみなす。
ブートストラップ
手元の標本から重複を許して同じ件数を取り直し、推定値を何度も計算してばらつきを見る方法。取り直す元が1つの標本なので、区間そのものも標本ごとに揺れる。