Files
MCM-ICM_Toolkit/tests/test_stats.py
T
guzhujushi 95f5266563 feat(stats): correlation_matrix 按方案 A 拆成三个函数,收口返回契约(L0 9/12)
用户 2026-10-03 拍板方案 A(见决策 41),由 Codex 落地。

拆成三个函数:
- correlation_matrix  -> 数值系数矩阵(对角线 1.0)
- correlation_pvalues -> 数值 p 值矩阵(对角线 NaN)
- correlation_table   -> 星标字符串表(对角线 1.000 不带星)
三者共用 _check_corr_args 守卫与成对删缺失口径;correlation_table 在最外层调另外两个。
理由: 字符串表做不了后续计算(挑高相关对、喂聚类/PCA、画热力图),反过来随时能格式化;
      一旦返回字符串,数值信息不可逆地丢失。

顺带修掉两处:
- 对角线 p 值由 0.0 改成 NaN —— 写 0 会被下游读成"极显著",而"自己和自己"没有 p 值
- p 值与 r 现在用同一批观测: 先成对 dropna() 再算,并同样尊重 min_periods。
  此前 corr 是成对删缺失、pearsonr 不删,两者样本量不同,r 与 p 对不上
- p 值只算上三角再镜像(成对变量的 p 天然对称),省一半重复计算

tests: test_correlation_matrix_diagonal_is_one 摘掉 xfail 转正;新增 8 条
  (数值性+对称性、非数值列跳过、p 值对照解析式 t = r*sqrt((n-2)/(1-r^2))、
   对角线 NaN、成对删缺失口径、min_periods、对角线无星标、表格与数值输出一致)。
  测试 105 passed / 38 skipped / 1 xfail -> 114 passed / 38 skipped / 0 xfail。

验证: pytest 114 passed / 38 skipped;ruff All checks passed;pyright 0 errors。

docs: STATUS.md(L0 9/12 + 决策 41 + 下一步);CHANGELOG;
      personal-pitfalls 追加第 14 条(把"能看的表"和"能算的表"塞进同一个返回值);
      roadmap 记录 correlation_matrix 的两个伴随函数
2026-10-03 14:53:05 +08:00

384 lines
13 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"]
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
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_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)