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

128 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`。百分位法区间
```text
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. 最小示例
```python
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` 里那段循环说的是同一件事,
> 现在等于把同一段算法写了两遍,只实现了一遍。