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 的变体)
This commit is contained in:
guzhujushi committed 2026-10-03 19:59:38 +08:00
1 parent b70a96dd0c
commit a1cc26e042
7 files changed
+373 -20

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ S 档(自己实现的招牌算法)的**函数卡片**放这里,模板见
| 卡片 | 对应函数 | 状态 |
|---|---|---|
| `detect_outliers.md` | `data.detect_outliers` | ✅ 2026-10-02 |
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ⬜ |
| `bootstrap_ci.md` | `stats.bootstrap_ci` | ✅ 2026-10-03(§10 有 5 个口径待拍板)|
| `monte_carlo.md` | `solve.monte_carlo` | ⬜ |
| `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ |
| `monte_carlo_integral.md` | `solve.monte_carlo_integral` | ⬜ |
+127
View File
@@ -0,0 +1,127 @@
# 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` 里那段循环说的是同一件事,
> 现在等于把同一段算法写了两遍,只实现了一遍。
+23 -1
View File
@@ -38,6 +38,7 @@
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
| 16 | 同一段入口转换抄了 N 遍,还当它是"本项目的写法" | 2026-10-03 |
| 17 | `hasattr(f, "__name__")` 挡不住 lambda,`else` 分支是死代码 | 2026-10-03 |
---
@@ -151,7 +152,9 @@
- 以后怎么防:每次写完实现,用 `pytest -q -rs`(或 `--collect-only`)**核对 skip 条数掉没掉**;
条数没变就说明实现没被验证过。另一个信号是 `pytest --collect-only` 的收集数 ——
新写的测试没进收集列表,就是名字或位置不对。
- 首次:2026-10-02|复发:0
- 复发(2026-10-03):`bootstrap_ci` 写完了,`tests/test_stats.py` 里等它的 3 条仍然挂着 skip,
全量照样 134 passed / 37 skipped 全绿 —— 连 `_NOT_IMPLEMENTED` 这个常量名都没提示你。
- 首次:2026-10-02|复发:0 → **1(2026-10-03)**
### 10. 把真报错当成"编辑器误报"
@@ -287,3 +290,22 @@
③ 抽出来的函数只干**一件事**:把容器转成数组、并告诉调用方原来是什么。
选列 / 摊平 / 删缺失这些是**语义**,必须留在各函数里(否则重演第 15 条与 `paired=True` 的 NaN 错位)。
- 首次:2026-10-03|复发:0
### 17. `hasattr(f, "__name__")` 判断"是不是具名函数"—— lambda 也有 `__name__`
- 现象:`bootstrap_ci` 想给 `metrics["statistic_name"]` 一个名字,写成了
`statistic.__name__ if hasattr(statistic, '__name__') else "custom"`。
本意是"认得的内置统计量给名字、自定义的标 custom",实际传 lambda 时得到的是
**字符串 `"<lambda>"`** —— 因为 lambda 有 `__name__`,只是值恒为 `"<lambda>"`。
也就是说 `else` 那支**几乎永远走不到**,是死代码。
- 为什么会错:`__name__` 在脑子里等于"函数的名字",于是 `hasattr` 就等于"这是个函数"。
实际上 `__name__` 是**属性存在性**,不是"名字有意义":lambda、`functools.partial` 包过的函数、
`numpy` 的 ufunc(`np.mean.__name__` 是 `"mean"`,能过)各有各的情况,
真正拿不到的只有**没有 `__name__` 的可调用对象**(比如带 `__call__` 的类实例)。
同类陷阱还有 `hasattr(x, "__len__")` 判容器(`str` 也有)、`hasattr(x, "__iter__")` 判列表。
- 以后怎么防:① 判"是不是我想要的那类函数",用**显式白名单**(`if statistic in [np.mean, np.median, ...]`),
别用属性存在性推断语义;② 真要取名字就写成
`name = getattr(f, "__name__", None)` 再判断 `name in (None, "<lambda>")`,
把"没有名字"和"名字是占位符"两种情况都覆盖;③ 每个 `else`/兜底分支都问一句
"**这条到底测得到吗**"——走不到的分支就是 bug 伪装成的防御性代码。
- 首次:2026-10-03|复发:0