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

+162 -8
View File
@@ -36,7 +36,17 @@ def _pvalue(result: Any) -> float:
return float(result.pvalue)
def _statistic(result: Any) -> float:
"""把 scipy 检验结果取成**统计量**(float)。
与 `_pvalue` 同一个原因:`TtestResult` 也是 `_make_tuple_bunch` 运行时拼的类,
存根里看不到 `statistic` 字段(见 `docs/04-environment.md` 坑 10)。
"""
return float(result.statistic)
_VALID_CORR_METHODS = ("pearson", "kendall", "spearman")
_VALID_CONFIDENCE_METHODS = ("normal", "t", "proportion")
def _check_corr_args(data: Any, method: str) -> pd.DataFrame:
@@ -198,6 +208,8 @@ def correlation_table(
for j in range(len(corr.columns)):
table.iloc[i, j] = _star(corr.iloc[i, j], p_values.iloc[i, j])
return table
def confidence_interval(
x: Any,
confidence: float = 0.95,
@@ -207,29 +219,171 @@ def confidence_interval(
Parameters
----------
x:
样本。`list` / `tuple` / `numpy.ndarray` / `pandas.Series` / `pandas.DataFrame`;
传 DataFrame 时**所有数值列会被摊平成一个样本**,非数值列跳过。
confidence:
置信水平,取 `(0, 1)` 开区间,默认 0.95。
method:
`"normal"`:`mean ± z·s/√n`(n 大时用);
`"t"`:小样本用 t 分布(更保守,n < 30 建议用这个);
`"proportion"`:比例的标准误 `√(p(1-p)/n)`。
`"proportion"`:比例的标准误 `√(p(1-p)/n)`,结果会被夹到 `[0, 1]`。
Returns
-------
tuple[float, float]
`(下限, 上限)`,双侧区间。
Raises
------
TypeError
`x` 不是受支持的容器类型,或里面的元素转不成数值。
ValueError
`x` 里没有可用数值;`confidence` 不在 `(0, 1)` 内;
`method` 不在支持列表内;样本量不足 2(估不出标准差)。
Notes
-----
**只有 `proportion` 会把结果夹到 `[0, 1]`** —— 比例的区间不可能越界。
`normal` / `t` 是**均值**的区间,量纲上完全可以落在 `[0, 1]` 之外
(平均身高 170cm 的区间下界不可能是 0);夹一下只会给出**错的下界**。
Examples
--------
>>> lo, hi = confidence_interval([1, 2, 3, 4, 5], confidence=0.95)
"""
# TODO(L0)
raise NotImplementedError
if not isinstance(x, (list, tuple, np.ndarray, pd.Series, pd.DataFrame)):
raise TypeError(f"x 必须是 list / tuple / ndarray / Series / DataFrame,而你传入了{type(x)}")
if method not in _VALID_CONFIDENCE_METHODS:
raise ValueError(f"不支持的方法:{method},仅支持{list(_VALID_CONFIDENCE_METHODS)}")
if not 0 < confidence < 1:
raise ValueError(f"confidence 必须在 (0, 1) 开区间内,而你传入了{confidence}")
if isinstance(x, pd.DataFrame):
numeric_cols = x.select_dtypes(include=[np.number]).columns
if len(numeric_cols) == 0:
raise ValueError("x 里没有数值列")
arr = cast(pd.DataFrame, x[numeric_cols]).to_numpy(dtype=float).ravel()
else:
try:
arr = np.asarray(x, dtype=float).ravel()
except (ValueError, TypeError) as e:
raise TypeError(f"x 必须是数值型数据,无法转成 float 数组:{e}") from None
arr = arr[~np.isnan(arr)]
n = len(arr)
if n < 2:
raise ValueError(f"样本量至少要有 2 个才估得出标准差,而你只有{n}个")
if method == "proportion":
p = float(np.mean(arr))
se = float(np.sqrt(p * (1 - p) / n))
z = float(stats.norm.ppf(1 - (1 - confidence) / 2))
return (max(0.0, p - z * se), min(1.0, p + z * se))
mean = float(np.mean(arr))
se = float(np.std(arr, ddof=1)) / np.sqrt(n)
if method == "normal":
half = float(stats.norm.ppf(1 - (1 - confidence) / 2)) * se
else:
half = float(stats.t.ppf(1 - (1 - confidence) / 2, df=n - 1)) * se
return (mean - half, mean + half)
def t_test(a: Any, b: Any | None = None, value: float = 0.0, equal_var: bool = False, paired: bool = False, alpha: float = 0.05) -> Any:
"""t 检验。`b=None` 时是单样本检验。
"""t 检验:两组均值有没有统计学差异。`b=None` 时是单样本检验。
Parameters
----------
a:
第一组样本(`DataFrame` / `Series` / list / tuple / `ndarray`),缺失值自动剔除。
b:
第二组样本;`None` 表示单样本检验(对照 `value`)。
value:
单样本检验的原假设均值,`b is None` 时生效。
equal_var:
`True` 走学生 t(假设方差齐性),`False` 走 Welch(不假设,默认,更稳)。
paired:
配对样本检验;要求 `a` 与 `b` 等长。
alpha:
显著性水平,取 `(0, 1)` 开区间。
Returns
-------
dict:`{"statistic", "pvalue", "df", "significant", "conclusion"}`。
`conclusion` 是一句能直接抄进论文的中文结论。
dict
`{"statistic", "pvalue", "df", "significant", "conclusion"}`。
`conclusion` 是一句能直接抄进论文的中文结论。
Raises
------
TypeError
样本转不成数值数组。
ValueError
`alpha` 不在 `(0, 1)` 内;样本里没有数值列;样本量不足 2;
配对检验时两组长度不一致。
Notes
-----
传 `DataFrame` 时**所有数值列会被摊平成一个样本** —— 多列不同量纲时请先自己取列。
"""
# TODO(L1)
raise NotImplementedError
if not (0 < alpha < 1):
raise ValueError(f"显著性水平 alpha 必须在 (0, 1) 之间,你传入了 {alpha}")
def _clean_data(data: Any) -> np.ndarray:
if isinstance(data, pd.DataFrame):
numeric_cols = data.select_dtypes(include=[np.number]).columns
if len(numeric_cols) == 0:
raise ValueError("没有找到数值列")
arr = cast(pd.DataFrame, data[numeric_cols]).to_numpy(dtype=float).ravel()
else:
try:
arr = np.asarray(data, dtype=float).ravel()
except (ValueError, TypeError) as e:
raise TypeError(f"样本必须是数值型数据,无法转成 float 数组:{e}") from None
return arr[~np.isnan(arr)]
a_arr = _clean_data(a)
if len(a_arr) < 2:
raise ValueError("样本 a 的数据量少于 2 个,无法进行 t 检验")
if b is None:
res = stats.ttest_1samp(a_arr, popmean=value)
df = len(a_arr) - 1
test_name = f"单样本 t 检验 (检验均值是否等于 {value})"
else:
b_arr = _clean_data(b)
if paired:
if len(a_arr) != len(b_arr):
raise ValueError("配对 t 检验要求两组数据长度一致")
res = stats.ttest_rel(a_arr, b_arr)
df = len(a_arr) - 1
test_name = "配对样本 t 检验"
else:
res = stats.ttest_ind(a_arr, b_arr, equal_var=equal_var)
if equal_var:
df = len(a_arr) + len(b_arr) - 2
test_name = "独立双样本 t 检验 (假设方差齐性)"
else:
v1, v2 = np.var(a_arr, ddof=1), np.var(b_arr, ddof=1)
n1, n2 = len(a_arr), len(b_arr)
df = (v1/n1 + v2/n2)**2 / ((v1/n1)**2/(n1-1) + (v2/n2)**2/(n2-1))
test_name = "Welch's 独立双样本 t 检验"
statistic = _statistic(res)
pvalue = _pvalue(res)
significant = bool(pvalue < alpha)
if significant:
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果显著 (p={pvalue:.4f} < {alpha}),拒绝原假设,认为两组数据存在统计学上的显著差异。"
else:
conclusion = f"在 {alpha} 显著性水平下,{test_name} 结果不显著 (p={pvalue:.4f} >= {alpha}),不能拒绝原假设,认为两组数据没有统计学上的显著差异。"
# 6. 返回字典
return {
"statistic": statistic,
"pvalue": pvalue,
"df": float(df),
"significant": significant,
"conclusion": conclusion
}
def anova(*groups: Any, alpha: float = 0.05) -> Any: