5.6 配套代码:非参数检验与 Bootstrap 置信区间
对应小节:5.6 统计基础 纯 Python 标准库实现,不依赖 scipy——这样在任何环境都能跑。
一、Mann-Whitney U 检验
# tools/stats_test.py <baseline.json...> -- <candidate.json...>
# 或者从两个目录读取:--baseline-dir DIR --candidate-dir DIR
"""
Mann-Whitney U 检验(又称 Wilcoxon 秩和检验)。
为什么不用 t 检验:
- t 检验假设数据服从正态分布
- 延迟分布是重尾的(大部分很小,少数极大)
- t 检验对异常值敏感
Mann-Whitney U 的原理:
把两组数据混合排序,看两组的值是否"交错"。
完全不交错 → 差异显著
完全交错 → 差异不显著
"""
import json
import pathlib
import sys
from math import sqrt, erf
def load_metric(paths, metric="p(99)"):
"""从 k6 summary 里提取指定指标"""
vals = []
for p in paths:
try:
m = json.loads(pathlib.Path(p).read_text())["metrics"]["http_req_duration"]
vals.append(m.get(metric, m.get("med", 0)))
except Exception as e:
print(f"⚠️ 跳过 {p}: {e}", file=sys.stderr)
return vals
def rankdata(values):
"""计算秩(处理并列时取平均秩)"""
order = sorted(range(len(values)), key=lambda i: values[i])
ranks = [0.0] * len(values)
i = 0
while i < len(order):
j = i
while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
j += 1
avg_rank = (i + j) / 2.0 + 1
for k in range(i, j + 1):
ranks[order[k]] = avg_rank
i = j + 1
return ranks
def mannwhitneyu(a, b):
"""
返回 (U 统计量, 双尾 p 值)。
用正态近似(大样本)+ 连续性校正。
"""
n1, n2 = len(a), len(b)
if n1 == 0 or n2 == 0:
return None, None
combined = list(a) + list(b)
ranks = rankdata(combined)
r1 = sum(ranks[:n1])
u1 = r1 - n1 * (n1 + 1) / 2.0
u2 = n1 * n2 - u1
u = min(u1, u2)
mu = n1 * n2 / 2.0
# 处理并列的方差校正(简化版)
sigma = sqrt(n1 * n2 * (n1 + n2 + 1) / 12.0)
if sigma == 0:
return u, 1.0
z = (u - mu + 0.5) / sigma # 连续性校正
p = 2 * (1 - 0.5 * (1 + erf(abs(z) / sqrt(2))))
return u, max(0.0, min(1.0, p))
def median(xs):
s = sorted(xs)
n = len(s)
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
def describe(vals, label):
if not vals:
print(f"{label}: (无数据)")
return
s = sorted(vals)
print(f"{label}: n={len(vals)} 中位数={median(vals):.2f} "
f"最小={s[0]:.2f} 最大={s[-1]:.2f}")
print(f" 值 = {[round(v, 1) for v in vals]}")
def main(argv):
if len(argv) < 3:
print(__doc__)
return
# 解析参数:支持 -- 分隔的两组文件,或两个目录
if "--baseline-dir" in argv:
bi = argv.index("--baseline-dir")
ci = argv.index("--candidate-dir")
bdir, cdir = pathlib.Path(argv[bi + 1]), pathlib.Path(argv[ci + 1])
b_paths = sorted(bdir.glob("*.json"))
c_paths = sorted(cdir.glob("*.json"))
elif "--" in argv:
sep = argv.index("--")
b_paths = [pathlib.Path(p) for p in argv[1:sep]]
c_paths = [pathlib.Path(p) for p in argv[sep + 1:]]
else:
print("❌ 请用 '基线文件... -- 实验文件...' 或 '--baseline-dir X --candidate-dir Y'")
return
print("═" * 74)
print("Mann-Whitney U 检验")
print("═" * 74)
print()
for metric, label in [("med", "P50"), ("p(95)", "P95"), ("p(99)", "P99"), ("p(99.9)", "P999")]:
a = load_metric(b_paths, metric)
b = load_metric(c_paths, metric)
if len(a) < 3 or len(b) < 3:
continue
print(f"【{label}】")
describe(a, " 基线 ")
describe(b, " 实验 ")
u, p = mannwhitneyu(a, b)
ma, mb = median(a), median(b)
delta = (mb - ma) / ma * 100 if ma else 0
print(f" 差异 : {delta:+.2f}%")
print(f" U 统计量 : {u:.1f}")
print(f" p 值 : {p:.4f}")
if p < 0.01:
verdict = "高度显著(p < 0.01)"
elif p < 0.05:
verdict = "显著(p < 0.05)"
else:
verdict = "不显著(p ≥ 0.05)—— 无法确认差异"
print(f" 判定 : {verdict}")
# 提醒:显著 ≠ 值得做
if p < 0.05 and abs(delta) < 10:
print(f" ⚠️ 虽然显著,但幅度仅 {abs(delta):.1f}%,可能小于用户感知阈值")
print(f" 统计显著 ≠ 值得做(第 5.6 节)")
if len(a) < 5 or len(b) < 5:
print(f" ⚠️ 样本量偏小({len(a)}/{len(b)}),p 值的可靠性有限")
if len(set(a) | set(b)) < 5:
print(f" ⚠️ 数据取值太少(可能有并列),正态近似的 p 值不够准")
print()
print("═" * 74)
print("判读提醒:")
print(" ① p < 0.05 只说明「差异不太可能是偶然」,还需要看幅度是否大于噪声底线")
print(" ② 样本量 ≥ 5 时结论较可靠;3–4 时只能作为参考")
print(" ③ 统计显著 ≠ 值得做(还要看用户是否可感知、成本是否合理)")
print("═" * 74)
if __name__ == "__main__":
main(sys.argv)
二、Bootstrap 置信区间(更适合重尾分布)
# tools/bootstrap_ci.py <round-*.json>... [--metric p(99)] [--iters 10000]
"""
Bootstrap 重采样置信区间。
为什么用它:
- 不假设分布形态(延迟是重尾的)
- 比"均值 ± 1.96 × 标准误"更稳健
- 对小样本也适用
原理:
① 从原始样本中有放回地抽取同样大小的样本
② 计算该样本的统计量(中位数)
③ 重复 10000 次
④ 取这 10000 个统计量的 2.5% 与 97.5% 分位 → 95% 置信区间
"""
import json
import pathlib
import random
import sys
def load_metric(paths, metric):
vals = []
for p in paths:
try:
m = json.loads(pathlib.Path(p).read_text())["metrics"]["http_req_duration"]
vals.append(m.get(metric, m.get("med", 0)))
except Exception:
pass
return vals
def median(xs):
s = sorted(xs)
n = len(s)
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
def bootstrap_ci(sample, iters=10000, alpha=0.05, seed=42):
rnd = random.Random(seed)
n = len(sample)
stats = []
for _ in range(iters):
resample = [sample[rnd.randrange(n)] for _ in range(n)]
stats.append(median(resample))
stats.sort()
lo = stats[int(iters * alpha / 2)]
hi = stats[int(iters * (1 - alpha / 2))]
return lo, hi
def main(argv):
metric = "p(99)"
iters = 10000
if "--metric" in argv:
metric = argv[argv.index("--metric") + 1]
if "--iters" in argv:
iters = int(argv[argv.index("--iters") + 1])
paths = [pathlib.Path(p) for p in argv[1:] if not p.startswith("--") and not p.isdigit()]
vals = load_metric(paths, metric)
if len(vals) < 3:
print("❌ 至少需要 3 轮数据")
return
med = median(vals)
lo, hi = bootstrap_ci(vals, iters)
print("═" * 66)
print(f"Bootstrap 95% 置信区间(指标:{metric},{iters} 次重采样)")
print("═" * 66)
print()
print(f"样本({len(vals)} 轮): {[round(v, 1) for v in vals]}")
print(f"点估计(中位数): {med:.2f}")
print(f"95% 置信区间 : [{lo:.2f}, {hi:.2f}]")
print(f"区间宽度 : {hi - lo:.2f} (越窄越精确)")
print()
width_pct = (hi - lo) / med * 100 if med else 0
print("判读:")
if width_pct < 10:
print(f" ✅ 区间宽度占中位数 {width_pct:.1f}%,样本量足够")
elif width_pct < 25:
print(f" ⚠️ 区间宽度占中位数 {width_pct:.1f}%,偏宽")
print(f" 建议增加轮数(当前 {len(vals)} 轮)")
else:
print(f" ❌ 区间宽度占中位数 {width_pct:.1f}%,太宽")
print(f" 说明环境噪声大或轮数太少 —— 先修环境(第 5.4 节)")
print()
print("用法示例:")
print(f" 「P99 中位数为 {med:.1f} ms,95% 置信区间 [{lo:.1f}, {hi:.1f}]」")
print()
print(" 判断达标:如果区间上限 < SLO 阈值,可以认为达标")
print(" 判断优化:如果优化前后的置信区间【不重叠】,差异很可能是真实的")
print(" (重叠也不代表没差异,需要 Mann-Whitney U 检验)")
print("═" * 66)
if __name__ == "__main__":
main(sys.argv)
三、完整分析流程(一键)
#!/usr/bin/env bash
# tools/analyze-experiment.sh <BASELINE_DIR> <CANDIDATE_DIR> <NOISE_FLOOR_P99_PCT>
#
# 把本章的统计工具串成一条流程。
set -uo pipefail
BASE_DIR="${1:?usage: analyze-experiment.sh <baseline_dir> <candidate_dir> <noise_floor_pct>}"
CAND_DIR="${2:?}"
NOISE="${3:?}"
echo "══════════════════════════════════════════════════════════════"
echo "实验分析流程"
echo " 基线 : $BASE_DIR"
echo " 实验 : $CAND_DIR"
echo " 噪声底线: ±${NOISE}%"
echo "══════════════════════════════════════════════════════════════"
echo
echo "─── ① 稳健统计(基线)───"
python3 tools/robust_stats.py "$BASE_DIR"
echo
echo "─── ② 稳健统计(实验)───"
python3 tools/robust_stats.py "$CAND_DIR"
echo
echo "─── ③ Bootstrap 置信区间(基线)───"
python3 tools/bootstrap_ci.py "$BASE_DIR"/*.json --metric 'p(99)'
echo
echo "─── ④ Bootstrap 置信区间(实验)───"
python3 tools/bootstrap_ci.py "$CAND_DIR"/*.json --metric 'p(99)'
echo
echo "─── ⑤ 显著性检验 ───"
python3 tools/stats_test.py --baseline-dir "$BASE_DIR" --candidate-dir "$CAND_DIR"
echo
echo "─── ⑥ 与噪声底线对比 ───"
python3 - "$BASE_DIR" "$CAND_DIR" "$NOISE" <<'PY'
import json, pathlib, statistics as st, sys
bdir, cdir, noise = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), float(sys.argv[3])
def med_p99(d):
vals = []
for f in sorted(d.glob("*.json")):
try:
vals.append(json.loads(f.read_text())["metrics"]["http_req_duration"]["p(99)"])
except Exception:
pass
return st.median(vals) if vals else None
mb, mc = med_p99(bdir), med_p99(cdir)
if mb and mc:
delta = (mc - mb) / mb * 100
mdd = noise * 2
print(f"基线 P99 中位数 : {mb:.2f} ms")
print(f"实验 P99 中位数 : {mc:.2f} ms")
print(f"差异 : {delta:+.2f}%")
print(f"噪声底线 : ±{noise}%")
print(f"最小可检测差异 : ±{mdd}%")
print()
if abs(delta) < noise:
print("结论:❌ 差异在噪声范围内 —— 无法确认优化有效")
elif abs(delta) < mdd:
print("结论:⚠️ 差异超过噪声底线但小于 MDD —— 方向可信,幅度需验证")
else:
print("结论:✅ 差异超过最小可检测差异 —— 优化有效,可信")
else:
print("❌ 无法计算(数据缺失)")
PY
四、动手改造
| 改动 | 观察什么 |
|---|---|
用两组明显不同的数据跑 stats_test.py |
p 值应该很小(显著) |
| 用两组几乎相同的数据跑 | p 值应该很大(不显著) |
| 把轮数从 5 减到 3 | 置信区间明显变宽——理解"样本量决定精度" |
把 --iters 从 10000 改成 1000 |
置信区间会有轻微抖动(重采样次数不足) |
| 在数据里加一个极端异常值 | 中位数几乎不变,平均值大幅变化——这就是不用平均值的原因 |
五、这段代码的局限
- Mann-Whitney U 用正态近似:样本很小(n < 5)时应该用精确分布表,近似会不够准。
- 并列值的处理是简化版:大量并列时 p 值会偏保守。
- Bootstrap 假设样本能代表总体:如果 5 轮数据本身就有系统性偏差(比如都偏高),Bootstrap 也会偏。
- p 值不是一切:它只回答"差异是否可能是偶然",不回答"差异是否重要"——必须结合噪声底线与用户可感知性(第 5.6、5.7 节)。