Files
MCM-ICM_Toolkit/tests/test_stats.py
T
guzhujushi f95fbd2726 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。
2026-10-03 18:40:41 +08:00

518 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""stats 包测试。
原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。
未实现的函数**逐条** `skip`——**不要**用模块级 `pytestmark`:那会把同文件里
已经写好的函数一起跳过,绿灯就是假的(决策 26 / 个人易错点 9)。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1"
def _pearson_p_from_r(r: float, n: int) -> float:
"""Pearson 相关系数的 p 值**解析式**(测解析解,不测实现细节):
``t = r * sqrt((n - 2) / (1 - r^2))``,``p = 2 * P(T > |t|)``,``T ~ t(n - 2)``。
"""
from scipy import stats as sps
t_stat = r * np.sqrt((n - 2) / (1 - r**2))
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
x = rng.normal(10, 2, size=5000)
r = bootstrap_ci(x, statistic=np.mean, confidence=0.95, n_resamples=5000, random_state=0)
se = x.std(ddof=1) / np.sqrt(x.size)
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
x = rng.lognormal(0, 1, size=2000)
r = bootstrap_ci(x, statistic=np.median, random_state=0)
assert r.params["low"] < r.params["high"]
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
x = rng.normal(size=500)
a = bootstrap_ci(x, statistic=np.mean, random_state=123)
b = bootstrap_ci(x, statistic=np.mean, random_state=123)
assert a.params["low"] == b.params["low"]
def test_confidence_interval_matches_scipy(rng):
from scipy import stats
from mcm_toolkit.stats import confidence_interval
x = rng.normal(5, 1, size=50)
lo, hi = confidence_interval(x, confidence=0.95, method="t")
ref = stats.t.interval(0.95, len(x) - 1, loc=x.mean(), scale=stats.sem(x))
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
x = pd.DataFrame(rng.normal(size=(200, 4)), columns=["a", "b", "c", "d"])
c = correlation_matrix(x)
assert np.allclose(np.diag(np.asarray(c)), 1.0)
def test_correlation_matrix_is_numeric_and_symmetric(rng):
"""方案 A:返回**数值**矩阵(不是带星标的字符串表),能直接参与后续计算。"""
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame(rng.normal(size=(200, 3)), columns=["a", "b", "c"])
c = correlation_matrix(x)
assert all(np.issubdtype(dtype, np.floating) for dtype in c.dtypes)
assert np.allclose(np.asarray(c), np.asarray(x.corr(method="pearson", min_periods=1)))
assert np.allclose(np.asarray(c), np.asarray(c.T))
def test_correlation_matrix_runs_on_multicolumn_frame(rng):
"""回归:`if numeric_cols == 0` 拿 Index 跟 0 比,多列数值表当场 ValueError 崩掉。"""
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame(rng.normal(size=(50, 3)), columns=["a", "b", "c"])
out = correlation_matrix(x)
assert out.shape == (3, 3)
assert list(out.columns) == ["a", "b", "c"]
assert list(out.index) == ["a", "b", "c"]
def test_correlation_matrix_skips_non_numeric_columns(rng):
from mcm_toolkit.stats import correlation_matrix
x = pd.DataFrame({"a": rng.normal(size=30), "b": rng.normal(size=30), "city": ["X"] * 30})
assert list(correlation_matrix(x).columns) == ["a", "b"]
def test_correlation_matrix_rejects_bad_input():
from mcm_toolkit.stats import correlation_matrix
with pytest.raises(TypeError):
correlation_matrix([[1.0, 2.0]])
with pytest.raises(ValueError):
correlation_matrix(pd.DataFrame({"city": ["X", "Y"]}))
with pytest.raises(ValueError):
correlation_matrix(pd.DataFrame({"a": [1.0, 2.0]}), method="bogus") # type: ignore[arg-type]
def test_correlation_pvalues_matches_analytic_formula(rng):
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues
x = pd.DataFrame(rng.normal(size=(80, 3)), columns=["a", "b", "c"])
corr = correlation_matrix(x)
p = correlation_pvalues(x)
for i in ["a", "b", "c"]:
for j in ["a", "b", "c"]:
if i == j:
continue
assert p.loc[i, j] == pytest.approx(_pearson_p_from_r(corr.loc[i, j], 80))
def test_correlation_pvalues_diagonal_is_nan(rng):
"""对角线写 0 会被下游读成"极显著";"自己和自己相关"没有 p 值,必须是 NaN。"""
from mcm_toolkit.stats import correlation_pvalues
p = correlation_pvalues(pd.DataFrame(rng.normal(size=(50, 2)), columns=["a", "b"]))
assert np.isnan(p.loc["a", "a"])
assert np.isnan(p.loc["b", "b"])
def test_correlation_pvalues_uses_the_same_observations_as_r(rng):
"""含缺失时 p 要和 r 用**同一批**观测:成对删缺失,而不是整行删、也不是干脆不删。"""
from mcm_toolkit.stats import correlation_pvalues
a = rng.normal(size=40)
b = rng.normal(size=40)
a[[3, 7, 11]] = np.nan
x = pd.DataFrame({"a": a, "b": b})
pair = x.dropna()
r = float(np.corrcoef(pair["a"], pair["b"])[0, 1])
assert correlation_pvalues(x).loc["a", "b"] == pytest.approx(_pearson_p_from_r(r, len(pair)))
def test_correlation_pvalues_respects_min_periods(rng):
from mcm_toolkit.stats import correlation_pvalues
a = rng.normal(size=10)
a[:5] = np.nan
x = pd.DataFrame({"a": a, "b": rng.normal(size=10)})
assert np.isnan(correlation_pvalues(x, min_periods=6).loc["a", "b"])
assert np.isfinite(correlation_pvalues(x, min_periods=5).loc["a", "b"])
def test_correlation_table_diagonal_has_no_stars():
"""对角线是"自己和自己",不该有显著性星标;完全负相关则必然是 ***。"""
from mcm_toolkit.stats import correlation_table
x = pd.DataFrame({"a": np.arange(30.0), "b": np.arange(30.0) * -2.0})
table = correlation_table(x)
assert table.loc["a", "a"] == "1.000"
assert table.loc["a", "b"] == "-1.000***"
def test_correlation_table_matches_the_numeric_outputs(rng):
from mcm_toolkit.stats import correlation_matrix, correlation_pvalues, correlation_table
x = pd.DataFrame(rng.normal(size=(60, 3)), columns=["a", "b", "c"])
corr = correlation_matrix(x)
p = correlation_pvalues(x)
table = correlation_table(x)
assert list(table.columns) == list(corr.columns)
assert table.loc["a", "a"] == "1.000"
assert np.isnan(p.loc["a", "a"])
pv = p.loc["a", "b"]
stars = "***" if pv < 0.001 else "**" if pv < 0.01 else "*" if pv < 0.05 else ""
value = corr.loc["a", "b"]
assert table.loc["a", "b"] == f"{value:.3f}" + stars
# ------------------------------------------------------ stats: describe
def test_describe_reports_expected_keys(rng):
from mcm_toolkit.stats import describe
table = describe(rng.normal(size=(100, 3)))
for key in ["mean", "std", "min", "max", "median"]:
assert key in table.index
def test_describe_rows_are_metrics_and_columns_are_variables():
"""方向约定:行 = 指标,列 = 变量。多转置一次就反了。"""
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [10.0, 20.0, 30.0]})
table = describe(df)
assert list(table.columns) == ["a", "b"]
assert table.loc["mean", "a"] == pytest.approx(2.0)
assert table.loc["max", "b"] == pytest.approx(30.0)
assert table.loc["median", "b"] == pytest.approx(20.0)
def test_describe_missing_and_shape_rows_are_not_nan():
"""曾经的静默 bug:转置后 `.loc[指标名] = <按变量索引的 Series>` 对不上列,
整行会被填成 NaN —— 不报错,但数据全丢。"""
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, np.nan, 3.0, 4.0], "b": [1.0, 2.0, 3.0, 4.0]})
table = describe(df)
assert table.loc["missing", "a"] == pytest.approx(1.0)
assert table.loc["missing", "b"] == pytest.approx(0.0)
assert table.loc["missing_ratio", "a"] == pytest.approx(0.25)
assert table.loc["missing_ratio", "b"] == pytest.approx(0.0)
assert table.loc["skewness", "b"] == pytest.approx(0.0, abs=1e-12)
extra = table.loc[["missing", "missing_ratio", "skewness", "kurtosis"]]
assert not bool(extra.isna().all(axis=None))
def test_describe_skips_non_numeric_columns():
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
table = describe(df)
assert list(table.columns) == ["a"]
def test_describe_accepts_ndarray_list_and_series(rng):
"""兄弟函数都收 ndarray / list,describe 没理由只收 DataFrame。"""
from mcm_toolkit.stats import describe
assert "mean" in describe(rng.normal(size=(50, 2))).index
assert "mean" in describe([[1.0, 2.0], [3.0, 4.0]]).index
assert "mean" in describe(pd.Series([1.0, 2.0, 3.0])).index
def test_describe_weighted_rows_match_manual_formula():
from mcm_toolkit.stats import describe
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
w = np.array([1.0, 1.0, 2.0])
table = describe(df, weights=w)
expect_mean = np.average(df["a"], weights=w)
expect_std = np.sqrt(np.average((df["a"] - expect_mean) ** 2, weights=w))
assert table.loc["weighted_mean", "a"] == pytest.approx(expect_mean)
assert table.loc["weighted_std", "a"] == pytest.approx(expect_std)
@pytest.mark.parametrize(
("weights", "exc"),
[
(np.ones(2), ValueError),
(np.zeros(3), ValueError),
(np.array([1.0, -1.0, 1.0]), ValueError),
(np.array([1.0, np.nan, 1.0]), ValueError),
("abc", TypeError),
],
)
def test_describe_rejects_bad_weights(weights, exc):
from mcm_toolkit.stats import describe
with pytest.raises(exc):
describe(pd.DataFrame({"a": [1.0, 2.0, 3.0]}), weights=weights)
def test_describe_rejects_unsupported_type_and_all_text_frame():
from mcm_toolkit.stats import describe
with pytest.raises(TypeError):
describe({"a": [1.0]})
with pytest.raises(ValueError):
describe(pd.DataFrame({"city": ["X", "Y"]}))
# ------------------------------------------------------ stats: zscore
def test_zscore_matches_manual_formula():
from mcm_toolkit.stats import zscore
x = np.array([1.0, 2.0, 3.0, 4.0])
assert np.allclose(np.asarray(zscore(x)), (x - x.mean()) / x.std())
def test_zscore_is_per_column_and_keeps_frame(rng):
from mcm_toolkit.stats import zscore
df = pd.DataFrame({"a": rng.normal(size=200), "b": rng.normal(5, 3, size=200)})
out = zscore(df)
assert isinstance(out, pd.DataFrame)
assert list(out.columns) == ["a", "b"]
assert np.allclose(np.asarray(out).mean(axis=0), 0.0, atol=1e-12)
assert np.allclose(np.asarray(out).std(axis=0), 1.0, atol=1e-12)
def test_zscore_constant_column_stays_zero():
from mcm_toolkit.stats import zscore
assert np.allclose(np.asarray(zscore([2.0, 2.0, 2.0])), 0.0)
def test_zscore_rejects_bad_type_and_all_text_frame():
from mcm_toolkit.stats import zscore
with pytest.raises(TypeError):
zscore("abc")
with pytest.raises(ValueError):
zscore(pd.DataFrame({"city": ["X"]}))
# ------------------------------------------- stats: coefficient_of_variation
def test_coefficient_of_variation_matches_manual_formula():
from mcm_toolkit.stats import coefficient_of_variation
x = np.array([1.0, 2.0, 3.0, 4.0])
assert coefficient_of_variation(x) == pytest.approx(x.std() / x.mean())
def test_coefficient_of_variation_accepts_single_column_frame():
from mcm_toolkit.stats import coefficient_of_variation
df = pd.DataFrame({"a": [1.0, 2.0, 3.0]})
single = coefficient_of_variation([1.0, 2.0, 3.0])
assert coefficient_of_variation(df) == pytest.approx(single)
def test_coefficient_of_variation_uses_absolute_mean():
"""均值为负时 CV 仍是**正数**:符号不该被读成"波动更小"。"""
from mcm_toolkit.stats import coefficient_of_variation
x = np.array([-1.0, -2.0, -3.0, -4.0])
assert coefficient_of_variation(x) == pytest.approx(x.std() / abs(x.mean()))
assert coefficient_of_variation(x) > 0
def test_coefficient_of_variation_zero_mean_is_value_error():
"""均值为 0 是**取值**问题不是类型问题:必须是 ValueError 而不是 TypeError。"""
from mcm_toolkit.stats import coefficient_of_variation
with pytest.raises(ValueError):
coefficient_of_variation([-1.0, 1.0])
def test_coefficient_of_variation_rejects_empty_2d_and_bad_type():
from mcm_toolkit.stats import coefficient_of_variation
with pytest.raises(ValueError):
coefficient_of_variation([])
with pytest.raises(ValueError):
coefficient_of_variation([[1.0, 2.0], [3.0, 4.0]])
with pytest.raises(ValueError):
coefficient_of_variation(pd.DataFrame({"a": [1.0], "b": [2.0]}))
with pytest.raises(TypeError):
coefficient_of_variation(5.0)