Files
MCM-ICM_Toolkit/docs/algorithms/bootstrap_ci.md
T
guzhujushi a1cc26e042 feat(stats): bootstrap_ci 落地(L0 10/12,S 档)+ 卡片;修掉 rng 绕过 get_rng 与 lambda 名字死分支
## 审查结论
算法移植是对的:大样本正态下百分位区间半宽与解析 `2 × 1.96 × se` 差 < 2%,
按块重采样(batch_size=1000)也照 docstring 的 Notes 做了。以下是要改的部分。

## Fixed
- `random_state=None` 时另开 `np.random.default_rng()`,**绕过了 `core.get_rng`** ——
  与决策 24 冲突:`set_seed` 对它无效,同一段脚本重跑区间就变。改成无条件 `get_rng(random_state)`(决策 46)。
- `metrics["statistic_name"]` 用 `hasattr(statistic, "__name__")` 判不出 lambda:
  lambda 的 `__name__` 恒为 `"<lambda>"`,于是 `else "custom"` 是**死代码**,
  实测自定义统计量拿到的是 `"<lambda>"`。改成 `getattr(f, "__name__", None)` + 判 `"<lambda>"`(易错点 17)。
- docstring 补齐 `Parameters` / `Returns` / `Raises`(§4.7),并如实写明 `method="bca"` **尚未实现**
  (此前 docstring 承诺支持、实现却抛 `NotImplementedError`)。
- ruff 两条:I001(导入块拆成三行且用了绝对导入)、F541(`f"暂不支持bca"` 无占位符)。

## Tests
- **摘掉 3 条 `skip`**:等 `bootstrap_ci` 的那 3 条一直挂着,实现写完照样全绿 —— 易错点 9 复发,
  已在 `personal-pitfalls.md` 把该条 `复发` 记到 1 并注明本次经过。
- 新增 6 条:`estimate` 等于原样本统计量、`confidence` 单调性、五种容器一致、
  自定义 lambda 走回退分支、**不给种子时跟随 `set_seed`**(钉住上面那个 rng 修复)、非法/未实现 method。
- 写测试时踩到一个真性质:极差的重采样值**永远 ≤ 原样本极差**,`high` 会正好等于 `estimate`,
  断言不能写 `estimate < high`。这条已写进卡片 §5。
- 全量 **177 条:143 passed / 34 skipped / 0 xfail**;ruff 0,pyright 0;`examples/01` 跑通。

## Docs
- `docs/algorithms/bootstrap_ci.md`:**第 2 张 S 档卡片**(模板九节 + §10 待拍板)。
  `docs/algorithms/README.md` 进度表打勾。
- `docs/dev/personal-pitfalls.md`:新增第 17 条(`hasattr(f, "__name__")` 挡不住 lambda,`else` 分支即死代码),
  索引补到 17;第 9 条记一次复发。
- `STATUS.md`:E 节记 `bootstrap_ci`(L0 10/12 → 剩余 `monte_carlo` `save_figure`),决策 46,
  「下一步」第 1 条改写、新增第 8 条列 5 个待拍板口径。

## 待用户拍板(Codex 一律未动,按决策 39)
- `x` 里的 NaN 静默传进统计量 → `nan` 区间(`confidence_interval` / `t_test` 都是先丢 NaN 的,口径不齐)
- `n_resamples <= 0` 现在抛 `IndexError: index -1 is out of bounds`(应在入口给可读 ValueError)
- `confidence` 越界交给 numpy 抛 `Percentiles must be in the range [0, 100]`
- `method="bca"`:补实现(≈25 行,S 档)还是从 docstring 删掉
- `bootstrap_statistic` 仍是 `NotImplementedError`,而 `bootstrap_ci` 把那段重采样循环内联了 ——
  建议抽出去让 `bootstrap_ci` 调它(易错点 16 的变体)
2026-10-03 19:59:38 +08:00

7.2 KiB
Raw Blame History

bootstrap_ci

项 内容
模块 mcm_toolkit.stats.bootstrap
档位 S
参考来源 Efron (1979);Efron & Tibshirani (1993) An Introduction to the Bootstrap;
scipy.stats.bootstrap(只做对照,没有调用)
状态 实现已落地(2026-10-03),§10 有 4 个口径待拍板

1. 数学定义

给定样本 x_1..x_n 与统计量 T(·):从 x 中有放回抽 n 个得到 x*,重复 B 次, 得 T*_1..T*_B。百分位法区间

estimate = T(x)                        # 用**原样本**,不是重采样分布的均值
alpha    = 1 - confidence
low      = T*_(alpha/2)                # 经验分布的 alpha/2 分位
high     = T*_(1 - alpha/2)

输入是一个一维样本,输出是「点估计 + 区间」两个标量 —— 这是非参数区间, 不假设正态、也不需要 T 的标准误解析式。

2. 适用场景

用:T 没有解析分布(中位数、极差、自定义指标);样本有偏或分布未知; 想给区间但不想假设正态(mean ± z·s/√n 的替代品)。

不用:n < 10 —— 经验分布太粗,x 能重采样出的不同样本极少; 数据有强时间相关性(股票、气温),此时必须用 block bootstrap; 要估的是极值(最大值本身就是上界,见 §5)。

3. 参数与返回值

参数 类型 默认 含义 取值影响
x Any — 样本;走 core.as_array(x, numeric_only=True, ndim=1) DataFrame 只取数值列,再摊平
statistic Callable np.mean 任意 f(sample) -> float 换 np.median 就得到中位数的区间
confidence float 0.95 置信水平 越大区间越宽
n_resamples int 10000 重采样次数 B 越大端点越稳;10000 通常够
method str "percentile" 区间算法 "bca" 尚未实现,传了抛 NotImplementedError
random_state int | None None 随机种子 None 时复用 core.get_rng 的模块级生成器且会推进

返回 Result:

字段 内容
params estimate / low / high / confidence / n_resamples
metrics statistic_name(np.mean → "mean";lambda → "custom")
data 不使用

4. 复杂度

  • 时间:O(B · n)(B 次重采样,每次都要在 n 个值上算一次 T)
  • 空间:O(B) —— 按块处理,每批 batch_size = 1000 行,绝不落地 B × n 的整矩阵
  • 什么时候会跑不完:
    • n = 1e4, B = 1e4 → 1e8 次操作,秒级,正常;
    • n = 1e6, B = 1e4 → 1e10 次操作,跑不完;而且 1000×1e6 的单批索引矩阵就要 8 GB, 此时应改用解析近似,或把 B 降到 1e3 以下、换更便宜的 T。

5. 数值稳定性

  • 统计量有上界时,百分位区间会「塌」到上界。 极差 max - min 的重采样值永远不可能超过 原样本极差,B 个值里有相当比例正好等于它(抽到原样本极值点的那些), 于是 high == estimate 精确成立。这不是 bug,是 bootstrap 的固有性质 —— 区间端点是样本的经验分位数,不能给出样本之外的结论。要估极值请用极值理论。
  • 分块只是省内存,不改结果:每批都从同一个 rng 按顺序抽索引,等价于一次抽 B × n。
  • 随机数只来自 core.get_rng(决策 24):给了种子可复现,不给则跟随 set_seed。
  • NaN 目前不处理:x 里的 NaN 会直接进 T,np.mean 于是给出 nan 区间。见 §10。

6. 常见错误

  1. 不写 seed → 每次跑出来的区间都不一样,论文里的数字对不上 → 传 random_state 或先 set_seed。
  2. 以为 bootstrap 能得出比样本更极端的结论 → 区间端点就是样本的经验分位数, "95% 置信上限 173.2cm" 不可能超过样本最大值。
  3. 拿它估最大值 / 最小值 → 区间会塌成一个点(原因同 §5),该换极值理论。
  4. n < 10 硬跑 → 能抽出的不同重采样样本太少,区间没有统计意义。
  5. 把 n_resamples 当成"样本量"写进论文 → 论文里要同时写清 n、B、seed、method。

7. 最小示例

import numpy as np

from mcm_toolkit.stats import bootstrap_ci

r = bootstrap_ci(heights, statistic=np.median, n_resamples=10000, random_state=42)
print(r.params["low"], r.params["estimate"], r.params["high"])
print(r.summary())          # 可直接贴进论文的 Markdown 表

8. 单元测试要点

测什么 期望 对应测试
大样本正态,statistic=np.mean 区间半宽 ≈ 2 × 1.96 × se,容差 10% test_bootstrap_ci_matches_normal_theory_on_large_n
任意统计量(中位数,对数正态样本) 区间包含样本中位数 test_bootstrap_ci_works_for_arbitrary_statistic
同种子两次 low 完全相等 test_bootstrap_ci_is_reproducible
不给种子 跟随 set_seed(决策 24) test_bootstrap_ci_follows_get_rng_when_no_seed_is_given
estimate 等于原样本上的 T(x) test_bootstrap_ci_reports_the_original_estimate
confidence 0.80 → 0.95 区间单调变宽 test_bootstrap_ci_interval_widens_with_confidence
极差作为统计量 high <= estimate(原子点塌陷) test_bootstrap_ci_works_for_a_custom_lambda
五种容器 DataFrame/Series/ndarray/list/tuple 结果一致 test_bootstrap_ci_accepts_the_documented_containers
method 非法 / "bca" ValueError / NotImplementedError test_bootstrap_ci_rejects_unknown_method

9. MCM/ICM 用法

  • 哪类题:任何要"给结论 + 给不确定性"的题 —— 评价类(排名稳定性)、预测类(区间预测)。
  • 论文里怎么表述:"采用非参数 bootstrap(B = 10000,seed = 42)估计中位数的 95% 置信区间。"
  • 图:预测曲线 + 阴影置信带(viz.plot / viz.save_figure)。
  • 踩坑提醒:必须报 B 和 seed;说清用的是 percentile 还是 BCa(目前只有 percentile); 被问到"为什么不用 ±1.96σ"时就答"统计量是中位数,没有解析标准误"。

10. 待拍板(未定稿,Codex 不代选)

# 口径 现状 可选
1 x 里的 NaN 静默传进 T → nan 区间 像 confidence_interval 一样丢掉 / 直接报错 / 维持现状
2 n_resamples <= 0 走到 np.percentile 抛 IndexError: index -1 is out of bounds 入口 ValueError(必须 ≥ 1)
3 confidence 越界 由 numpy 抛 ValueError: Percentiles must be in the range [0, 100] 入口 ValueError + 可读文案
4 method="bca" docstring 承诺过,实测 NotImplementedError 补 BCa 实现(S 档,约 25 行)/ 从 docstring 删掉
5 bootstrap_statistic 仍是 NotImplementedError,而 bootstrap_ci 把重采样循环内联了 抽出去让 bootstrap_ci 调它(易错点 16 的变体)

口径 5 尤其值得做:bootstrap_statistic 的 docstring 与 bootstrap_ci 里那段循环说的是同一件事, 现在等于把同一段算法写了两遍,只实现了一遍。