fix(stats): t_test/confidence_interval 收口 —— 307/308 是存根误报,CI 的夹取才是真 bug(L1 提前 2 个)

## 用户问的「t_test 307 / 308 两行报错」
- 那两行是 `float(res.statistic)` / `float(res.pvalue)`,报错来自 scipy 的 `TtestResult`:
  它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼出来的类,存根里没有这两个字段。
- 探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常 —— **不是真 bug**。
- 按决策 40 的路子补 `_statistic(result: Any) -> float`(与 `_pvalue` 成对),一行 `# type: ignore` 都没挂。
  详情见 `docs/04-environment.md` 坑 10(已扩写为 pvalue / statistic 两条)。

## 顺手查出来的真 bug:`confidence_interval`
- 原码 `return (float(min(0.0, lower)), float(max(upper, 1.0)))`:
  ① `min` / `max` 的**参数写反**,下界被钉死成 `0.0`;
  ② **无条件**把三种 method 的区间都夹到 `[0, 1]`。
- 实测 `confidence_interval(rng.normal(170, 8, 50), method="t")` 下界是 `0.0`(正确 `171.0 ± 2.1`);
  `confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
- 现在:**只有 `proportion` 夹**(决策 42),`normal` / `t` 返回 `mean ± z|t·se` 不夹;
  `method` 非法抛 `ValueError`(§4.7)、`confidence` 走 `(0, 1)` 开区间、样本量 < 2 抛 `ValueError`。
- 入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
  docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**。

## t_test
- docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7);
  `_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);
  `alpha` 开区间校验;`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
- **4 个口径未代选**(决策 39),已记进 `STATUS.md`「下一步」第 6 条待用户拍板:
  单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、`DataFrame` 摊平多列。

## 测试与文档
- `tests/test_stats.py` +10 条(36 → 46),含把原先挂 skip 的
  `test_confidence_interval_matches_scipy` 转正;对照一律用解析式(z 公式 / Welch df / t 公式)。
- 全量:**125 passed / 37 skipped / 0 xfail**,ruff 0,pyright 0。
- 个人易错点新增第 15 条,并把**停在 6 号**的索引表补齐到 15(只补索引,正文未动)。
- `docs/04-environment.md` 顺手修掉残留的 `[FENCE]` 代码围栏占位符与一处重复的 `---`。
- `STATUS.md`:L0 记 9/12;校正测试数为 162 条;新增决策 42 / 43。
This commit is contained in:
guzhujushi committed 2026-10-03 18:40:41 +08:00
1 parent 95f5266563
commit f95fbd2726
6 files changed
+422 -24

No files matched your search

+135 -1
View File
@@ -58,7 +58,6 @@ def test_bootstrap_ci_is_reproducible(rng):
assert a.params["low"] == b.params["low"]
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_confidence_interval_matches_scipy(rng):
from scipy import stats
@@ -70,6 +69,141 @@ def test_confidence_interval_matches_scipy(rng):
assert np.allclose([lo, hi], ref, rtol=1e-6)
def test_confidence_interval_normal_matches_z_formula(rng):
"""normal 走 z 解析式:`mean ± z·s/√n`,s 用**样本**标准差(ddof=1)。"""
from scipy import stats as sps
from mcm_toolkit.stats import confidence_interval
x = rng.normal(8.5, 1, size=200)
lo, hi = confidence_interval(x, confidence=0.95, method="normal")
half = float(sps.norm.ppf(0.975)) * x.std(ddof=1) / np.sqrt(len(x))
assert lo == pytest.approx(x.mean() - half)
assert hi == pytest.approx(x.mean() + half)
def test_confidence_interval_mean_is_not_clamped_to_unit_range(rng):
"""均值区间不该被夹到 [0, 1]:平均身高 170cm 的下界不可能是 0。"""
from mcm_toolkit.stats import confidence_interval
x = rng.normal(170, 8, size=50)
lo, hi = confidence_interval(x, method="t")
assert lo > 100.0
assert lo < hi
def test_confidence_interval_proportion_stays_in_unit_interval():
"""比例的区间必须落在 [0, 1] 里 —— 只有这一种 method 需要夹。"""
from mcm_toolkit.stats import confidence_interval
y = [0.0, 1.0, 1.0, 1.0, 0.0, 1.0]
lo, hi = confidence_interval(y, method="proportion")
assert 0.0 <= lo < hi <= 1.0
def test_confidence_interval_accepts_documented_containers():
"""docstring 的 Examples 传 list、L0 用例传 ndarray,两边都得收。"""
from mcm_toolkit.stats import confidence_interval
data = [1.0, 2.0, 3.0, 4.0, 5.0]
expected = confidence_interval(data)
assert confidence_interval(pd.Series(data)) == pytest.approx(expected)
assert confidence_interval(np.asarray(data)) == pytest.approx(expected)
assert confidence_interval(pd.DataFrame({"x": data})) == pytest.approx(expected)
def test_confidence_interval_rejects_bad_input(rng):
from mcm_toolkit.stats import confidence_interval
x = rng.normal(size=20)
with pytest.raises(TypeError):
confidence_interval("abc")
with pytest.raises(ValueError):
confidence_interval(x, method="bogus")
with pytest.raises(ValueError):
confidence_interval(x, confidence=1.5)
with pytest.raises(ValueError):
confidence_interval(x, confidence=0.0)
with pytest.raises(ValueError):
confidence_interval(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(ValueError):
confidence_interval([1.0])
# ------------------------------------------------------ stats: t_test
def test_t_test_one_sample_matches_analytic_formula(rng):
"""单样本:`t = (mean - value) / (s/√n)`,`df = n - 1`,`p = 2·P(T > |t|)`。"""
from scipy import stats as sps
from mcm_toolkit.stats import t_test
x = rng.normal(5, 1, size=30)
r = t_test(x, value=5.0)
t_stat = (x.mean() - 5.0) / (x.std(ddof=1) / np.sqrt(len(x)))
assert r["statistic"] == pytest.approx(t_stat)
assert r["pvalue"] == pytest.approx(float(2 * sps.t.sf(abs(t_stat), len(x) - 1)))
assert r["df"] == pytest.approx(29.0)
assert r["significant"] == (r["pvalue"] < 0.05)
assert isinstance(r["conclusion"], str) and r["conclusion"]
def test_t_test_two_sample_welch_df_matches_formula(rng):
"""默认 Welch:`df` 用 Welch–Satterthwaite 公式解析对照。"""
from mcm_toolkit.stats import t_test
a = rng.normal(0, 1, size=25)
b = rng.normal(1, 4, size=35)
r = t_test(a, b)
v1, v2 = a.var(ddof=1), b.var(ddof=1)
n1, n2 = len(a), len(b)
expected_df = (v1 / n1 + v2 / n2) ** 2 / ((v1 / n1) ** 2 / (n1 - 1) + (v2 / n2) ** 2 / (n2 - 1))
assert r["df"] == pytest.approx(expected_df)
assert r["pvalue"] < 0.05
def test_t_test_equal_var_uses_pooled_df(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(0, 1, size=25)
b = rng.normal(1, 1, size=35)
assert t_test(a, b, equal_var=True)["df"] == pytest.approx(58.0)
def test_t_test_paired_reports_key_set_and_df(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(size=20)
r = t_test(a, a + rng.normal(0, 0.1, 20), paired=True)
assert set(r) == {"statistic", "pvalue", "df", "significant", "conclusion"}
assert r["df"] == pytest.approx(19.0)
def test_t_test_rejects_bad_input(rng):
from mcm_toolkit.stats import t_test
a = rng.normal(size=20)
with pytest.raises(ValueError):
t_test(a, alpha=1.5)
with pytest.raises(ValueError):
t_test([1.0])
with pytest.raises(ValueError):
t_test(a, rng.normal(size=19), paired=True)
with pytest.raises(ValueError):
t_test(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(TypeError):
t_test("abc")
def test_correlation_matrix_diagonal_is_one(rng):
from mcm_toolkit.stats import correlation_matrix