为什么讲这个方法:pr11/pr12 已经把"怎么测"跑通了,但如果止步于"看到 p<0.05 就宣布赢家",等于把实证做成了抽签。要训练的是把结论建立在证据强度上——先写下你要检验什么,再看数据说话,最后报告"显著吗 / 大吗 / 可复现吗"三问。

阅读路径:§1 预注册(抗事后偏差),§2 因子设计与模型,§3 选检验,§4 效应量,§5 完整分析脚本(可直接跑),§6 报告表模板,§7 效度威胁,§8 可复现,§9 反面清单,§10 与前后案例的接口。

诚实标注:下文示例数字均为演示值,非实测结论;统计方法以标准教材与 scipy / statsmodels 官方文档为准。


1. 为什么要预注册

实证的可信度不取决于你算了多少种检验,而取决于分析计划在数据到手之前是否已经锁死。先看三种最常见的自欺:

  • p-hacking(p 值操纵):反复换指标、换分组、加删协变量,直到某个 p<0.05 出现为止。做的检验越多,纯靠运气撞显著的概率越高。
  • HARKing(已知结果后编假设)(Hypotheses After Results Known):先看到"8-bit 更快",再倒推一条"我早就猜到位宽越低越快"的假设,把探索性发现伪装成验证性结论。
  • 事后挑显著:跑了 TTFT、吞吐、显存、错误率十项,只把显著的那一项写进报告——分母被藏起来了。

1.1 预注册在抗什么

预注册不是形式主义,它把"我验证了一个假设"和"我观察到一件有意思的事"区分开。写死四要素后,任何偏离都必须在报告里显式声明为探索性分析,读者才知道证据的分量。

1.2 预注册模板(先写死再开跑)

把下面这份 YAML 随 traces.csv 一起提交进仓库,时间戳由 CI 记录(对接 §8 与 pr15)。四件套:假设 / 指标 / 样本量 / 分析计划。

YAML · prereg.yaml
# 预注册:在采集任何数据之前锁定,提交即哈希留痕
hypotheses:
  - id: H1
    text: "量化位宽 quant_bits 降低会缩短 TTFT(主效应)"
    direction: one_sided            # 事先声明方向,避免事后挑符号
  - id: H2
    text: "投机解码 spec_decode=on 提升吞吐(主效应)"
  - id: H3
    text: "quant_bits 与 spec_decode 对 TTFT 存在交互(收益不叠加)"
primary_metric: ttft_ms             # 唯一主指标,其余列为次级
secondary_metrics: [throughput_tps, vram_peak_mb]
design: 2x2_full_factorial         # 见 §2
sample_size:
  n_per_cell: 30                    # 事先做功效分析算出来的,不是"跑跑看"
  repeats_seed: [20260901, 20260902, 20260903]
analysis_plan:
  test: welch_t_and_ols_interaction # 见 §3 / §5,锁死不临场换
  effect_size: cohens_d_partial_eta # 见 §4,主报效应量而非只报 p
  alpha: 0.05
  multiplicity: holm                # 多个假设的校正方法也事先定
deviations_must_be_flagged: true    # 任何偏离都标 exploratory

实操要点:让读者先照模板写死,再打开数据。若结果与预注册不符,正确做法是如实报告负面/零结果(§8),而不是回头改假设——改了就回到 HARKing。

1.3 事前功效分析:n_per_cell 从哪来

模板里的 n_per_cell: 30 不能拍脑袋填。样本量决定统计功效(真有效应时能检出的概率,通常目标 0.8)。功效不足正是"结论效度"的头号威胁(§7)——要么漏检真效应,要么效应估计极不稳定。用 TTestIndPower 反解所需 n:

Python · power.py
from statsmodels.stats.power import TTestIndPower

analysis = TTestIndPower
# 想稳定检出的最小工程效应(而非"任意非零"):这里以 d=0.5 为例
n = analysis.solve_power(effect_size=0.5, alpha=0.05, power=0.8,
                         ratio=1.0, alternative="two-sided")
print("每组至少需 n =", int(n) + 1)          # 结果写进 prereg.yaml 的 n_per_cell

# 反向:若每组只有 30,最小可检出效应(mde)是多少?检不出更小说明功效不足
mde = analysis.solve_power(nobs1=30, alpha=0.05, power=0.8, ratio=1.0)
print("每组30时可检出的最小 d ≈", round(mde, 2))

关键区分:预注册的 effect_size 应填工程上关心的最小效应(比如"至少省 3 ms 才值得上线"),而不是"任意不为零"。否则你会为了检出一个毫无意义的 d=0.05 而盲目堆样本——又回到 §4 的教训。


2. 现成的 2×2 因子设计

pr11 扫了"量化位宽",pr12 扫了"投机解码开关",两批实验的因子恰好正交,天然拼成一个 2×2 全因子设计:两个主效应 + 一个交互项。这比"一次只改一个变量"省一半运行量,还能捕捉交互(一个因子的效应取决于另一个因子的水平)。

2.1 因子与水平

因子记号 / 水平来源预期方向
量化位宽 quant_bitsx₁:4-bit / 8-bitpr11位宽↓ → TTFT↓、精度风险↑
投机解码 spec_decodex₂:off / onpr12on → 吞吐↑
因变量(主)ttft_ms(首 token 延迟)pr08 口径越低越好
因变量(次)throughput_tps(token/s)pr08 口径越高越好

2.2 两因素线性模型

把两个因子编码成 x₁, x₂ ∈ {0,1},带交互项的模型写成:

$$y=\beta_0+\beta_A x_1+\beta_B x_2+\beta_{AB}\,(x_1 x_2)+\varepsilon,\quad \varepsilon\sim\mathcal{N}(0,\sigma^2)$$

其中 β_A、β_B 是两个主效应,β_AB 是交互效应——只有 β_AB 显著,才说明"位宽带来的收益会随投机解码开/关而改变",两个因子不能各算各的。这正好把 pr11、pr12 的独立结论升级为"是否耦合"的判断。

2.3 2×2 方格图(平均指标示意)

2×2 全因子:格内为平均 TTFT (ms),越低越好 spec_decode = off spec_decode = on quant_bits = 8-bit quant_bits = 4-bit 46.0 基准格 (8b / off) 40.5 主效应 B:投机↓ TTFT 38.0 主效应 A:量化↓ TTFT 35.2 4b+on:交互 β_AB 待检 若 (46−38) 与 (40.5−35.2) 不相等 → 存在交互,两因子不可独立解读(演示数值,非实测)
图 2-1 · 复用 pr11/pr12 数据拼成的 2×2 因子方格;对角线差值不等即提示交互项 β_AB ≠ 0

3. 选对检验

没有"万能检验",只有"匹配数据形态的检验"。选择依据三问:分布?配对否?几组?

3.1 先验假设:正态 + 方差齐性

t 检验要求残差近似正态(Shapiro-Wilk 粗检),经典 Student t 还要求两组方差齐(Levene)。pr11/pr12 里不同配置耗时波动常不一致,因此默认走 Welch t——它不假设等方差,几乎总是更安全的选择。

3.2 决策表

数据形态推荐检验理由
两组独立、近正态Welch t(equal_var=False)不假设等方差,稳健
配对(同种子/同任务前后)配对 t / Wilcoxon 符号秩消除个体差异,功效更高
小样本 / 明显非正态置换检验 / bootstrap不依赖分布假设,直接对观测重采样
≥3 组或带交互(本文 2×2)方差分析 / OLS 回归一次拟合主效应 + 交互项,比多次 t 更抗多重比较

为什么 2×2 优先用回归而非四场 t 检验:把 quant_bits、spec_decode、交互项一起塞进 OLS,系数即主/交互效应,一次给出,天然避免"跑很多次 t 抬高假阳性"。置换检验则在样本量小、正态存疑时兜底。

3.3 假设检查代码(先跑这段再选检验)

选检验不是凭感觉,先跑一遍正态 / 方差齐性检查,再照 §3.2 决策表落地。下面这段是"检验路由器"的雏形:

Python · pick_test.py
from scipy import stats

def check_assumptions(a, b, alpha=0.05):
    a, b = np.asarray(a, float), np.asarray(b, float)
    # 1) 正态性:对两组分别做 Shapiro-Wilk(样本小功效也低,仅作参考)
    pA = stats.shapiro(a).pvalue
    pB = stats.shapiro(b).pvalue
    normal = (pA > alpha) and (pB > alpha)
    # 2) 方差齐性:Levene;不齐则坚决用 Welch 而非 Student
    lev_p = stats.levene(a, b).pvalue
    equal_var = lev_p > alpha
    # 3) 路由结论
    if normal:
        route = "Welch t (equal_var=False)" if not equal_var else "Student t"
    else:
        route = "Mann-Whitney U + bootstrap CI"
    return dict(shapiro=(round(pA, 3), round(pB, 3)), levene=round(lev_p, 3),
                normal=normal, route=route)

print(check_assumptions(df[df.qbits == 4].ttft_ms, df[df.qbits == 8].ttft_ms))
# 输出 route 即应写回 prereg.yaml 的 analysis_plan.test,禁止跑完再改

注意:Shapiro-Wilk 在小样本上功效低,"不拒绝正态"不等于"一定正态"。pr11/pr12 每格重复次数不多时,稳妥做法是同时报 Welch t 与非参数旁证(§5.2/§5.3),两者结论一致才敢下判断。


4. 效应量才是重点

大样本下再小的差异也能压出 p<0.001;反过来 6σ 的显著性,若实际只快 0.3 ms,工程上毫无价值。报告的主角是效应量(差多大)与置信区间(范围多宽),不是 p(是否非零)。

4.1 Cohen's d

$$d=\frac{\mu_1-\mu_2}{s_{\text{pooled}}},\qquad s_{\text{pooled}}=\sqrt{\frac{(n_1-1)s_1^{2}+(n_2-1)s_2^{2}}{n_1+n_2-2}}$$

约定俗成:|d|≈0.2 小、0.5 中、0.8 大。判读永远是语境相关的——对 TTFT 这种关键指标,d=0.3 若对应稳定省 5 ms 也可能值得上线;反之 d=0.05 再显著也先别看。

4.2 置信区间

95% CI 回答"效应真实大小可能落在哪一段"。若区间跨过 0 → 与"无效应"相容;若区间整体很窄且贴 0 → 说明"确实没什么差别",这比一句"不显著"信息量大得多。

$$\text{CI}_{95\%}=(\hat\mu_1-\hat\mu_2)\ \pm\ t_{0.975,\,\nu}\cdot \sqrt{\frac{s_1^{2}}{n_1}+\frac{s_2^{2}}{n_2}}$$

核心一句话:6σ 显著但 d=0.05 → 统计显著 ≠ 工程有意义。永远把 d 与 CI 放在 p 前面讲。


5. 完整分析脚本(可直接跑)

以下代码把 traces.csv(pr11 量化扫描 + pr12 投机扫描合并,含 quant_bits, spec_decode, ttft_ms, throughput_tps, seed 列)从读取一路做到出图,覆盖 §3 检验、§4 效应量、§2 因子回归。骨架可直接跑,函数签名与 §1 预注册的分析计划一致。

5.1 读数据 + 组均值

Python · analysis.py
import pandas as pd, numpy as np
from scipy import stats

df = pd.read_csv("traces.csv")          # pr11 + pr12 合并后的实验轨迹
df["spec"] = (df.spec_decode == "on").astype(int)   # 0/1 编码
df["qbits"] = df.quant_bits                        # 4 / 8

# 每格均值(对应图 2-1 的 2x2)
cell = df.groupby(["quant_bits", "spec_decode"]).ttft_ms.agg(["mean", "std", "count"])
print(cell.round(2))

# 主效应方向粗看:位宽、投机各自的边际均值
print("4-bit mean TTFT :", df[df.qbits == 4].ttft_ms.mean)
print("8-bit mean TTFT :", df[df.qbits == 8].ttft_ms.mean)

5.2 Welch t + bootstrap CI + Cohen's d

Python
def welch_compare(a, b):
    a, b = np.asarray(a, float), np.asarray(b, float)
    t, p = stats.ttest_ind(a, b, equal_var=False)      # 不假设等方差
    n1, n2 = len(a), len(b)
    sp = np.sqrt(((n1 - 1) * a.var(ddof=1) + (n2 - 1) * b.var(ddof=1))
                 / (n1 + n2 - 2))
    d = (a.mean - b.mean) / sp                     # Cohen's d
    diff = a.mean - b.mean
    return dict(t=t, p=p, diff=diff, d=d, n1=n1, n2=n2)

def bootstrap_ci(a, b, stat=np.mean, n=5000, seed=42):
    """对差值的 95% 置信区间做 percentile bootstrap,分布无关。"""
    rng = np.random.default_rng(seed)
    a, b = np.asarray(a, float), np.asarray(b, float)
    boots = [rng.choice(a, len(a)) .mean - rng.choice(b, len(b)).mean
             for _ in range(n)]
    lo, hi = np.percentile(boots, [2.5, 97.5])
    return lo, hi

# 例:位宽主效应 4-bit vs 8-bit 的 TTFT
res = welch_compare(df[df.qbits == 4].ttft_ms, df[df.qbits == 8].ttft_ms)
lo, hi = bootstrap_ci(df[df.qbits == 4].ttft_ms, df[df.qbits == 8].ttft_ms)
print(f"Welch t={res['t']:.2f}  p={res['p']:.4f}  diff={res['diff']:.2f} ms  "
      f"Cohen's d={res['d']:.2f}  95%CI=[{lo:.2f},{hi:.2f}]  n={res['n1']}+{res['n2']}")
print("→ 先读 d 和 CI,再看 p;d 很小则工程上未必值得(§4)")

5.3 因子分析 + 交互项检验(OLS)

Python
import statsmodels.formula.api as smf

# y ~ A * B 展开即 A + B + A:B,一次拟合主效应 + 交互(对应 §2.2 模型)
model = smf.ols("ttft_ms ~ C(quant_bits) + spec", data=df).fit
model_int = smf.ols("ttft_ms ~ C(quant_bits) * spec", data=df).fit

print(model.summary.tables[1])         # 主效应系数表
print("\n交互项检验(比较是否加入 A:B 提升拟合):")
print("无交互 R^2 =", round(model.rsquared, 4),
      " 有交互 R^2 =", round(model_int.rsquared, 4))
print(model_int.compare_f_test(model))   # (F, p, df) —— p 小说明确有交互

# 小样本 / 非正态兜底:对交互项做置换检验
perm = stats.mannwhitneyu(df[df.qbits == 4].ttft_ms,
                          df[df.qbits == 8].ttft_ms, alternative="two-sided")
print("非参数旁证 Mann-Whitney U p =", round(perm.pvalue, 4))

5.4 出图:带误差棒的分组柱状 + 因子交互图

Python
import matplotlib.pyplot as plt

g = df.groupby(["quant_bits", "spec_decode"]).ttft_ms.agg(["mean", "sem"]).reset_index

# (1) 分组柱状 + 95% 误差棒(sem * t 临界值近似)
fig, ax = plt.subplots(figsize=(6, 4))
labels = [f"{r.quant_bits}b/{r.spec_decode}" for r in g.itertuples]
ax.bar(labels, g["mean"], yerr=1.96 * g["sem"], capsize=5, color="#2563eb")
ax.set_ylabel("平均 TTFT (ms)  越低越好")
ax.set_title("2x2 因子:均值 ± 95% CI")

# (2) 交互图:两条线不平行 = 存在交互
fig2, ax2 = plt.subplots(figsize=(6, 4))
for spec in ["off", "on"]:
    sub = g[g.spec_decode == spec].sort_values("quant_bits")
    ax2.plot(sub.quant_bits, sub["mean"], marker="o", label=f"spec={spec}")
ax2.set_xlabel("quant_bits"); ax2.set_ylabel("mean TTFT (ms)")
ax2.set_title("因子交互图(线不平行 → β_AB≠0)"); ax2.legend; ax2.grid(alpha=.3)
fig.tight_layout; fig.savefig("cells_bar.pdf"); fig2.savefig("interaction.pdf")

诚实提示:脚本里的 spec、quant_bits、列名以你实际合并的 traces.csv 为准;statsmodels 的公式字符串与分类编码 C 需按数据微调。换数据集只改列名不改分析逻辑——这正是把 pr11/pr12 复用为 pr14 输入的接口收益。


6. 报告段模板

结论必须同时回答三问:显著吗 / 大吗 / 可复现吗。把每个效应压进一张 workshop-paper 风格的结果表:

效应估计 (Δ)95% CIpCohen's dn结论三问
quant_bits 4→8(TTFT 主效应 A)+8.0 ms[6.1, 9.9]<0.0010.9230+30显著 / 大 / 可复现
spec on(TTFT 主效应 B)−5.5 ms[−7.2, −3.8]<0.0010.6130+30显著 / 中 / 可复现
A×B 交互(TTFT)−1.3 ms[−3.0, 0.4]0.130.1160不显著 / 极小 / 无交互证据
spec on(吞吐副效应)+2.4 tps[2.2, 2.6]<0.0011.0530+30显著 / 大 / 复现待 pr15

写法要点:先给 Δ 与 CI("多大、范围多宽"),再给 d("值不值"),p 放最后作为"是否非零"的附注。不显著 + CI 窄贴 0 要如实写成"没有证据表明有交互",而非含糊略过。

6.1 结论段范例(三问一次答清)

$$\text{结论}=\underbrace{\text{显著吗}}_{p}+\underbrace{\text{大吗}}_{d,\ \text{CI}}+\underbrace{\text{可复现吗}}_{\text{种子/数据/CI 复算}}$$

把表格翻译成一段可读结论,示范正确语序(先效应、后 p、再复现声明):

"在本机 模型-任务 组合下,4-bit 相比 8-bit 将 TTFT 平均降低 8.0 ms(95% CI [6.1, 9.9],Cohen's d=0.92,Welch p<0.001)——效应大且显著;投机解码开启再降 5.5 ms(d=0.61)。但两者的交互项不显著(Δ=−1.3 ms,CI [−3.0, 0.4] 跨过 0,d=0.11),因此可近似独立叠加地报告。结论基于固定 seed 的 pr11/pr12 轨迹,将在 pr15 CI 中自动复算。效度威胁:仅覆盖单一模型、单一提示分布,外推需谨慎(§7)。"

反面示范(禁用):"p<0.001,实验成功,4-bit 更好。"——只报 p、不报效应大小与区间、不报交互、不报复现与效度,等于没做实证。


7. 效度威胁清单

实证报告里必须有一节诚实列出"我的结论可能被什么推翻"。四类效度各给典型威胁与缓解:

效度类型威胁缓解
内部效度混淆变量(GPU 温度 / 后台进程);多次比较抬高假阳性随机化运行顺序、预热固定;§1 预注册 + Holm 校正
外部效度模型 / 任务代表性不足,结论难外推声明边界(本模型本任务);多任务重复
构念效度指标是否真的衡量目标(TTFT ≠ 用户体感全部)多指标交叉(TTFT + 吞吐 + 尾延迟);pr08 口径统一
结论效度功效不足(样本太小 → 漏检真效应)事前功效分析定 n(§1);报 CI 而非只报 p

动手练习:让读者为本文的 2×2 结论各写一条最可能致命的效度威胁,并给出"若补做哪个实验能排除它"。效度威胁写得越诚实,报告越可信。


8. 诚实与可复现

  • ☑ 报告负面 / 零结果:交互项不显著也要写进表 §6,不能只留赢家——这是抗发表偏差的基本功。
  • ☑ 种子与数据固定:traces.csv 每条含 seed(pr11/pr12 已写入),bootstrap 用 default_rng(seed) 保证重采样可复算。
  • ☑ 预注册随数据入库:prereg.yaml 与分析脚本同仓,时间戳由版本历史背书。
  • ☑ 对接 pr15 的 CI 跑分析:把 §5 脚本作为 CI 步骤,每次 traces.csv 变更自动重算 d、CI、交互检验,保证"结论可复算、不漂移"。
  • ☑ 统计方法可溯源:检验/效应量口径以 scipy、statsmodels 官方文档为准,不自定义算法。

9. 常见误用反面清单

反面做法为什么错正确姿势
只看 p 就下结论大样本什么都能显著,忽略效应大小先 d 与 CI,再 p(§4)
把相关当因果观测数据混杂变量在背后作祟用 §2 受控因子设计,随机化
样本不足硬下结论功效低,假阴性/不稳效应并存事前功效分析定 n(§1),报 CI
事后换指标 / 换分组典型 p-hacking,分母被藏主指标写死,偏离标 exploratory
跑很多次 t 挑显著多重比较抬高假阳性一次 OLS 拟合 + 预注册校正

一句话:反面清单的每一条,都在 §1 预注册和 §4 效应量里被正面堵住——预注册防事后偏差,效应量防"唯 p 论"。


10. 接口:与前后案例的呼应

↓
pr11 · pr12(供数据)

量化扫描 + 投机扫描的 traces.csv,天然拼成 §2 的 2×2 因子输入。

↔
pr08 · pr09(供口径/因变量)

指标口径与因变量定义来自 pr08,§4 的效应量判读靠它。

→
pr15(供可复算)

CI 里自动跑 §5 脚本,保证结论可复算不漂移(§8)。

◈
pr02 · pr17(案例呼应)

pr02 的实验设计与 §2/§3 同构;pr17 把本流水线用于真实缺陷复盘。

本文是"测量(pr11/pr12) → 结论(pr14) → 复算(pr15)"链条的中段:pr11/pr12 负责把数据采对,pr08 负责把口径统对,pr09 负责把因变量选对,pr14 负责把结论判对,pr15 负责让它可复算。


一页纸总结:预注册锁死四要素(假设/指标/样本量/分析计划)→ 复用 pr11/pr12 拼 2×2 因子(主效应 + 交互)→ 按数据形态选检验(Welch / 置换 / OLS)→ 主报 Cohen's d 与 95% CI → 三问式报告(显著吗/大吗/可复现吗)→ 效度威胁清单 → 负面结果如实报 + 种子固定 + 接 pr15 CI 复算。贯穿论点:统计显著 ≠ 工程有意义,p 只是其中一环。