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

+84 -5
View File
@@ -10,6 +10,10 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
import numpy as np
from ..core import Result, as_array, get_rng
__all__ = ["bootstrap_ci", "bootstrap_statistic"]
@@ -42,23 +46,46 @@ def bootstrap_ci(
Parameters
----------
x:
样本:`pandas.DataFrame` / `pandas.Series` / `numpy.ndarray` / list / tuple。
走 `core.as_array(x, numeric_only=True, ndim=1)` —— DataFrame 只取数值列,再摊平成一串样本。
statistic:
任意把样本映射成标量的函数,默认 `np.mean`。
换成 `np.median` / `np.std` / 自定义的 `lambda s: s.max() - s.min()` 都能用,
这是 bootstrap 相对于 `mean ± z·s/√n` 的核心优势。
confidence:
置信水平,取 `(0, 1)` 开区间,默认 0.95。
n_resamples:
重采样次数,默认 10000。论文里必须写明。
method:
`"percentile"`:直接取经验分布的 α/2 与 1-α/2 分位(最常用,默认);
`"bca"`:偏差校正 + 加速,区间更准但慢,样本有偏时用。
`"bca"`:偏差校正 + 加速,区间更准但慢,样本有偏时用 —— **尚未实现**,传了抛
`NotImplementedError`。
random_state:
随机种子。传 `None` 时复用 `core.get_rng` 的**模块级生成器且会推进**,
所以同一段脚本里连调两次结果不同;要完全可复现就显式给种子,或先 `core.set_seed`。
Returns
-------
`Result`:`params={"estimate", "low", "high", "confidence", "n_resamples"}`。
`Result`:`params={"estimate", "low", "high", "confidence", "n_resamples"}`;
`metrics["statistic_name"]` 是统计量的名字(自定义 lambda 时是 `"custom"`)。
Raises
------
TypeError
`x` 不是受支持的容器,或内容转不成数值(由 `as_array` 抛)。
ValueError
`DataFrame` 里一列数值都没有;`method` 不在支持列表内。
NotImplementedError
`method="bca"`。
Notes
-----
* 重采样时必须**按块处理**,不要一次生成 `(10000, n)` 的整矩阵(n 大时会爆内存)。
* 样本量 n < 10 时 bootstrap 不可靠,先补数据。
* 比赛写论文时要注明:n_resamples、seed、用的是 percentile 还是 BCa。
* 比赛写论文时要注明:n_resamples、seed、用的是 percentile 还是 BCa(目前只实现了 percentile)。
* **缺失值目前不处理**:`x` 里的 NaN 会直接进统计量,`np.mean` 于是给出 nan 区间。
`confidence_interval` / `t_test` 都是先丢掉 NaN 的,两边口径尚未统一。
Examples
--------
@@ -67,5 +94,57 @@ def bootstrap_ci(
>>> r.params["low"] < 3 < r.params["high"]
True
"""
# TODO(L0) ★ S 档:配完整函数卡片
raise NotImplementedError
if statistic is None:
statistic = np.mean
rng = get_rng(random_state)
arr, _ = as_array(x=x, dtype=float, ndim=1)
original_estimate = float(statistic(arr))
n = len(arr)
batch_size = 1000
boot_stats = []
for i in range(0, n_resamples, batch_size):
current_batch = min(batch_size, n_resamples - i)
indices = rng.integers(0, n, size=(current_batch, n))
batch_samples = arr[indices]
if statistic in [np.mean, np.median, np.std, np.min, np.max]:
batch_stats = statistic(batch_samples, axis=1)
else:
batch_stats = np.array([statistic(s) for s in batch_samples])
boot_stats.extend(np.asarray(batch_stats).ravel().tolist())
boot_stats = np.array(boot_stats)
if method == 'percentile':
alpha = 1 - confidence
lower = np.percentile(boot_stats, alpha / 2 * 100)
upper = np.percentile(boot_stats, (1 - alpha / 2) * 100)
elif method == 'bca':
# TODO(L1): BCa = 偏差校正 + 加速,属于 S 档、留给用户自己补的算法。
raise NotImplementedError("BCa 还没实现,先用 method='percentile'")
else:
raise ValueError(f"不支持的方法:{method},仅支持'percentile'")
# lambda 也有 __name__(值恒为 "<lambda>"),所以只判断 hasattr 会让 "custom" 这支变成死代码。
name = getattr(statistic, "__name__", None)
statistic_name = "custom" if name in (None, "<lambda>") else str(name)
return Result(
name="bootstrap_ci",
params={
"estimate": original_estimate,
"low": float(lower),
"high": float(upper),
"confidence": confidence,
"n_resamples": n_resamples
},
metrics={
"statistic_name": statistic_name,
}
)