"""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_series_keeps_index_and_name(rng): """Series 进 Series 出,索引和 name 都要带回来 —— 否则管道里 concat 会丢列名。""" from mcm_toolkit.stats import zscore s = pd.Series(rng.normal(size=50), index=[f"r{i}" for i in range(50)], name="price") out = zscore(s) assert isinstance(out, pd.Series) assert out.name == "price" assert list(out.index) == list(s.index) 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)