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:
1 parent
95f5266563
commit
f95fbd2726
6 files changed
+422
-24
No files matched your search
+135
-1
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user