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

+34 -10
View File
@@ -205,13 +205,28 @@
- 两条硬规矩:**默认绝不摊平**(要摊平显式 `ndim=1`);**不删缺失值**(删缺失是各函数的语义,
见决策 45 与易错点 15)。`numeric_only` 按"输出是否与输入同形"选,不按喜好选(决策 44)。
- `check_array`(L1,未实现)实现时直接复用 `as_array`,一份实现两张脸
- [ ] 剩余 3 项:`bootstrap_ci` `monte_carlo` `save_figure`
- [x] `stats/bootstrap.py` 的 `bootstrap_ci`(L0 第 10 项,**S 档**,实现由用户本人写,Codex 审查 + 代修)
- 移植正确性已用**解析解**钉住:大样本正态下百分位区间半宽 ≈ `2 × 1.96 × se`(容差 10%)
- **Codex 代修的三处**:
① `random_state=None` 时原来另开了 `np.random.default_rng()`,**绕过了 `get_rng`** ——
与决策 24 冲突(`set_seed` 后仍然不可复现)。改成无条件的 `get_rng(random_state)`;
② `statistic_name` 写成 `hasattr(statistic, "__name__")`,而 lambda 的 `__name__` 恒为 `"<lambda>"`,
导致 `else` 分支是**死代码**、自定义统计量拿不到 `"custom"`(见易错点 17);
③ ruff 两条(I001 导入块、F541 空 f-string),并把 docstring 的 `Parameters` / `Returns` / `Raises` 补齐(§4.7)
- **测试是重点**:用户实现写完后,`tests/test_stats.py` 里等它的 3 条**仍然挂着 `skip`**,
全量照样显示"全绿" —— 正是易错点 9 的复发(已把该条 `复发` 记到 1)。
已摘掉 3 条 skip,并新增 6 条:`estimate` 等于原样本统计量、`confidence` 单调性、
五种容器一致、自定义 lambda 走回退分支、**不给种子时跟随 `set_seed`**、非法 `method` 抛错。
- **S 档函数卡片已写**:`docs/algorithms/bootstrap_ci.md`(第 2 张),
记了"极差统计量的区间上界必然塌到 `estimate`"这个非平凡性质,以及 5 个待拍板口径
- 测试:`tests/test_stats.py` 的 bootstrap 段共 **9 条**
- [ ] 剩余 2 项:`monte_carlo` `save_figure`
### 验证结果
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **134 passed, 37 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **143 passed, 34 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — |
@@ -221,8 +236,8 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **171 条用例:134 passed / 37 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 47、
- **177 条用例:143 passed / 34 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 53、
`test_models.py` 5
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
@@ -310,17 +325,18 @@
用户 2026-10-03 提"每个函数都抄一遍入口/校验/交换太麻烦"。能统一的只有**入口**:出口要求"输出与输入同形",而 `describe` / `correlation_*` / `confidence_interval` / `t_test` 天生不同形,硬套会重演易错点 14。用户明确"不要三个函数""转换回去自己手打",所以 `as_array` 只返回 `(arr, kind)`,`ArraySpec` / `restore` 都不做。`numeric_only` 默认 `True`(用户拍的板),但**按输出契约选**:同形函数必须显式 `False` |
| 45 | `as_array` **不删缺失值**;容器类型/内容转换失败**统一抛 `TypeError`** |
删缺失是**语义**(`df.corr` 成对删、`describe` 整列统计、`paired=True` 的 t 检验必须成对删),在入口层删掉就把行对应关系永久丢了 —— 这正是 `t_test` 待拍板的口径之一。错误口径统一为 `TypeError` 是**Codex 代选、待用户确认**:`normalize(含文本列的表)` 原先由 numpy 抛 `ValueError`,现在是 `TypeError`(内容转不成 float);`describe` 的 `weights` 同类情况目前仍抛 `ValueError`(决策 39 说不统一就列出来) |
| 46 | `bootstrap_ci` 的随机数**无条件走 `core.get_rng`**;`statistic_name` 用 `getattr(f, "__name__", None)` 再判 `"<lambda>"` |
原码 `random_state=None` 时另开 `np.random.default_rng()`,等于绕开决策 24(`set_seed` 对它无效,区间不可复现)。**Codex 代改**:`get_rng(None)` 本来就是设计好的"复用模块级生成器且推进",直接传下去即可。`statistic_name` 的 `else "custom"` 是死代码 —— lambda 也有 `__name__`(值恒为 `"<lambda>"`),实测拿不到 `"custom"`,见易错点 17 |
`PearsonRResult` / `SignificanceResult` 是 `_make_tuple_bunch` 在**运行时**拼出来的类,存根里没有 `pvalue` 字段:解包会退化成 `object`、属性访问会报"未知"。运行时完全正常(实测三个函数返回的都是 `np.float64`)。收成 `Any` 是**一处**收窄,调用点仍然干净;`# type: ignore` 要挂在每一处,且会连带关掉该行后面的检查。见 `docs/04-environment.md` 坑 10 |
用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" |
docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 |
原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 |
## 下一步
1. **`bootstrap_ci` 是 L0 里下一个该写的**(**S 档**,`stats/bootstrap.py`)——
除了函数本身,还要配一张完整函数卡片(模板 `docs/dev/function-card-template.md`),
因为它是"没有解析解时怎么给区间"的通用答案(`docs/03-roadmap.md` §3.1)。
注意 `tests/test_stats.py` 里已有 3 条现成的用例在等它(现在挂着 skip),写完就是现成的验收标准。
2. 之后 L0 剩余:`monte_carlo`(**S 档**)→ `save_figure`(A)。
1. **`bootstrap_ci` 已完成**(L0 10/12,卡片也写了)。**L0 里下一个是 `monte_carlo`**(**S 档**,`solve/simulate.py`)——
同样要配一张函数卡片;它的三个兄弟(`monte_carlo_probability` / `monte_carlo_integral` / `random_walk`)
都在同一份卡片体系里,先把 `monte_carlo` 的形状定下来。
2. L0 的最后一项是 `save_figure`(A 档,`viz/export.py`)。
3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
4. 已拍板的旧口径(留档):`log_transform` 的 `offset` 维持现状(决策 35);
`describe` / `zscore` 默认丢掉非数值列(决策 37);变异系数用 `σ / |μ|`(决策 38);
@@ -346,4 +362,12 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
- 内容转不成 float 时统一抛 `TypeError`(决策 45)—— `normalize(含文本列的表)` 原来抛 `ValueError`。
要不要改回 `ValueError`("取值问题")?
- 现在 `kind == "frame"` 时你要自己再写一遍 `x.select_dtypes(include=[np.number]).columns` 取列名。
嫌烦的话可以让 `as_array` 顺带把选中的列返回(改成三元组),一句话的事。
嫌烦的话可以让 `as_array` 顺带把选中的列返回(改成三元组),一句话的事。
8. **`bootstrap_ci` 的 5 个待拍板口径**(Codex 一律没动,按决策 39;详见 `docs/algorithms/bootstrap_ci.md` §10):
- `x` 里的 **NaN** 静默传进统计量 → `nan` 区间(`confidence_interval` / `t_test` 都是先丢掉的,口径不齐)
- `n_resamples <= 0` → 现在抛 `IndexError: index -1 is out of bounds`(应在入口给可读 `ValueError`)
- `confidence` 越界 → 现在由 numpy 抛 `Percentiles must be in the range [0, 100]`
- `method="bca"` → docstring 承诺过、实测 `NotImplementedError`(补实现 ≈25 行,还是从 docstring 删掉?)
- `bootstrap_statistic` 仍是 `NotImplementedError`,而 `bootstrap_ci` 把那段重采样循环**内联**了 ——
建议抽出去让 `bootstrap_ci` 调它(易错点 16 的变体:同一段算法写了两遍,只实现了一遍)