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:
1 parent
b70a96dd0c
commit
a1cc26e042
7 files changed
+373
-20
No files matched your search
+76
-3
@@ -26,7 +26,6 @@ def _pearson_p_from_r(r: float, n: int) -> float:
|
||||
return float(2 * sps.t.sf(abs(t_stat), n - 2))
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||
def test_bootstrap_ci_matches_normal_theory_on_large_n(rng):
|
||||
"""大样本下 bootstrap 百分位区间应逼近正态解析区间(±10% 容差)。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
@@ -37,7 +36,6 @@ def test_bootstrap_ci_matches_normal_theory_on_large_n(rng):
|
||||
assert abs((r.params["high"] - r.params["low"]) - 2 * 1.96 * se) < 0.1 * 2 * 1.96 * se
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||
def test_bootstrap_ci_works_for_arbitrary_statistic(rng):
|
||||
"""bootstrap 的核心价值:任意统计量都能给区间(这里用中位数)。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
@@ -48,7 +46,6 @@ def test_bootstrap_ci_works_for_arbitrary_statistic(rng):
|
||||
assert r.params["low"] < np.median(x) < r.params["high"]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
|
||||
def test_bootstrap_ci_is_reproducible(rng):
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
@@ -58,6 +55,82 @@ def test_bootstrap_ci_is_reproducible(rng):
|
||||
assert a.params["low"] == b.params["low"]
|
||||
|
||||
|
||||
def test_bootstrap_ci_reports_the_original_estimate(rng):
|
||||
"""`params["estimate"]` 必须是**原样本**上的统计量,不是重采样分布的均值。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
x = rng.normal(10, 2, size=200)
|
||||
r = bootstrap_ci(x, statistic=np.mean, n_resamples=500, random_state=0)
|
||||
|
||||
assert r.params["estimate"] == pytest.approx(float(np.mean(x)))
|
||||
assert set(r.params) == {"estimate", "low", "high", "confidence", "n_resamples"}
|
||||
assert r.params["n_resamples"] == 500
|
||||
assert r.metrics["statistic_name"] == "mean"
|
||||
|
||||
|
||||
def test_bootstrap_ci_interval_widens_with_confidence(rng):
|
||||
"""置信水平越高区间必须越宽 —— 结构性检查,不依赖任何具体分布。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
x = rng.normal(size=300)
|
||||
width = [
|
||||
bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["high"]
|
||||
- bootstrap_ci(x, np.mean, confidence=c, n_resamples=2000, random_state=7).params["low"]
|
||||
for c in (0.80, 0.95)
|
||||
]
|
||||
assert width[0] < width[1]
|
||||
|
||||
|
||||
def test_bootstrap_ci_accepts_the_documented_containers(rng):
|
||||
"""DataFrame 只取数值列后摊平;Series / ndarray / list 都收。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
x = rng.normal(size=200)
|
||||
ref = bootstrap_ci(x, np.mean, n_resamples=500, random_state=0).params
|
||||
|
||||
for wrapped in (pd.Series(x), pd.DataFrame({"a": x}), list(x), tuple(x)):
|
||||
got = bootstrap_ci(wrapped, np.mean, n_resamples=500, random_state=0).params
|
||||
assert got["low"] == pytest.approx(ref["low"])
|
||||
|
||||
|
||||
def test_bootstrap_ci_works_for_a_custom_lambda(rng):
|
||||
"""不认识的统计量走逐样本回退分支(这里是极差)。"""
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
x = rng.normal(size=200)
|
||||
r = bootstrap_ci(x, statistic=lambda s: float(s.max() - s.min()), n_resamples=500, random_state=0)
|
||||
|
||||
# 极差的重采样值**永远不会超过原样本极差**(原样本本身就是上界),
|
||||
# 所以百分位区间的上界会正好落在 estimate 上,不能再断言 estimate < high。
|
||||
assert r.params["estimate"] == pytest.approx(float(x.max() - x.min()))
|
||||
assert r.params["low"] > 0
|
||||
assert r.params["low"] < r.params["high"] <= r.params["estimate"]
|
||||
assert r.metrics["statistic_name"] == "custom"
|
||||
|
||||
|
||||
def test_bootstrap_ci_follows_get_rng_when_no_seed_is_given(rng):
|
||||
"""不给种子时必须跟随 `get_rng` 的模块级生成器(决策 24),不能另开一个 default_rng。"""
|
||||
from mcm_toolkit.core import set_seed
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
x = rng.normal(size=200)
|
||||
set_seed(0)
|
||||
first = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
|
||||
set_seed(0)
|
||||
second = bootstrap_ci(x, np.mean, n_resamples=300).params["low"]
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_bootstrap_ci_rejects_unknown_method(rng):
|
||||
from mcm_toolkit.stats import bootstrap_ci
|
||||
|
||||
with pytest.raises(ValueError, match="bogus"):
|
||||
bootstrap_ci(rng.normal(size=50), np.mean, method="bogus", n_resamples=10)
|
||||
with pytest.raises(NotImplementedError):
|
||||
bootstrap_ci(rng.normal(size=50), np.mean, method="bca", n_resamples=10)
|
||||
|
||||
|
||||
def test_confidence_interval_matches_scipy(rng):
|
||||
from scipy import stats
|
||||
|
||||
|
||||
Reference in new issue
Block a user