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
+421 -23

No files matched your search

+33
View File
@@ -238,6 +238,39 @@
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 已装进 conda 环境 `mcm-toolkit`,`.venv` 里没有。
### Fixed
- `stats.confidence_interval`:修掉一个**会给出错误数字**的硬 bug —— 原码返回
`(float(min(0.0, lower)), float(max(upper, 1.0)))`,`min` / `max` 的**参数写反**(下界被钉死成 `0.0`),
而且**无条件**把区间夹到 `[0, 1]`。实测 `confidence_interval(rng.normal(170, 8, 50), method="t")`
的下界是 `0.0`(正确是 `171.0 ± 2.1`);`confidence=1.5` 被夹成 `(0.0, nan)` 而不报错。
现在**只有 `proportion` 夹到 `[0, 1]`**(决策 42),`normal` / `t` 直接返回 `mean ± z|t·se`;
`method` 非法改抛 `ValueError`、`confidence` 按 `(0, 1)` 开区间校验、样本量 < 2 抛 `ValueError`。
- `stats.confidence_interval` 的入参曾只收 `DataFrame`,而 docstring 的 Examples 传 `list`、
L0 用例传 `ndarray` —— **两条都会 `TypeError`**。现放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`。
### Added
- `stats.inference._statistic(result: Any) -> float` 助手,与 `_pvalue` 成对(决策 43)。
scipy 的 `TtestResult` 与 `PearsonRResult` 同源,都是 `_make_tuple_bunch` 运行时拼的类,
存根里 `.statistic` / `.pvalue` 都看不到。用户报告的 `t_test` 「307 / 308 两行报错」即此,
**不是真 bug**(探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时完全正常);
换成助手后 pyright 归零,**一行 `# type: ignore` 都没挂**。见 `docs/04-environment.md` 坑 10。
- `stats.t_test`(L1 提前落地):docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`;
`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`);`alpha` 开区间校验;
`df` 按 Welch–Satterthwaite / pooled / paired 分别给。
- `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 `skip` 的
`test_confidence_interval_matches_scipy` 转正。对照一律用解析式(z 公式、Welch `df` 公式、
`t = (mean − value)/(s/√n)`),不拿实现对自己。
- `docs/dev/personal-pitfalls.md`:新增第 15 条「区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反」;
并把**停在 6 号就没人管**的索引表补齐到 15(只补索引,正文一个字没动)。
- `docs/04-environment.md`:坑 10 扩写成 `pvalue` / `statistic` 两条;顺手修掉残留的
`[FENCE]` 代码围栏占位符与一处重复的 `---`。
### Changed
- `STATUS.md`:L0 记为 **9/12**(另 L1 的 `confidence_interval` / `t_test` 提前落地);
校正测试数为 **162 条 / 125 passed / 37 skipped**;新增决策 42 / 43;「下一步」列出
**`t_test` 待用户拍板的 4 个口径**(单样本结论文案、`paired` 的 NaN 对齐、`value` 被静默忽略、
`DataFrame` 摊平多列)—— 按决策 39,Codex 只列选项、不代选。
## [0.0.1] - 2026-10-01
### Added
+43 -9
View File
@@ -1,6 +1,6 @@
# STATUS — 当前进度
> 更新日期:2026-10-02
> 更新日期:2026-10-03
## 本次任务
@@ -10,7 +10,7 @@
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。
当前 9/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`。
当前 **L0 9/12**:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`;另**提前落地**了 L1 的 `confidence_interval` / `t_test`。
## 任务清单
@@ -174,13 +174,32 @@
顺带修掉两处:**对角线不再带 `***`**(p 值矩阵的对角线是 NaN、"自己和自己"没有显著性);
**p 值与 r 用同一批观测**(都成对删缺失、都尊重 `min_periods`),不再各算各的样本量。
p 值只算上三角再镜像,省一半重复计算。测试 **+8 条**(含用解析式 `t = r·sqrt((n-2)/(1-r²))` 对照)
- [x] `stats/inference.py` 的 `confidence_interval` / `t_test`(**L1 提前落地,实现由用户本人写**,Codex 审查 + 代修)
- **用户问的「307/308 两行报错」不是真错**:那两行是 `float(res.statistic)` / `float(res.pvalue)`,
报错来自 scipy 的 `TtestResult` —— 它和 `PearsonRResult` 一样是 `_make_tuple_bunch` **运行时**拼的类,
存根里没有这两个字段。探针实测 `TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常。
按决策 40 的路子补了一个 `_statistic(result: Any) -> float` 助手(与 `_pvalue` 成对),
**一行 `# type: ignore` 都没挂**。详见 `docs/04-environment.md` 坑 10
- **`confidence_interval` 有真 bug(已修)**:原来返回 `(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`
- `confidence_interval` 的入参放宽到 `list` / `tuple` / `ndarray` / `Series` / `DataFrame`:
docstring 的 Examples 传 list、L0 用例传 ndarray,原来**两条都直接 `TypeError`**
- `t_test`:`_clean_data` 改用 `isinstance(data, pd.DataFrame)` 收窄(坑 8 的 `cast`)、`alpha` 开区间校验、
`df` 按 Welch–Satterthwaite / pooled / paired 分别给,docstring 补齐 `Parameters` / `Returns` / `Raises` / `Notes`(§4.7)
- 测试 `tests/test_stats.py`:**+10 条(36 → 46)**,含把原先挂着 skip 的 `test_confidence_interval_matches_scipy` 转正;
对照一律用解析式(z 公式、Welch `df` 公式、`t = (mean − value)/(s/√n)`),不拿实现对自己
- ⚠️ **`t_test` 还有 3 个口径待用户拍板**(Codex 未改,见「下一步」第 6 条)
- [ ] 剩余 3 项:`bootstrap_ci` `monte_carlo` `save_figure`
### 验证结果
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **114 passed, 38 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **125 passed, 37 skipped(0 xfail)** |
| `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — |
@@ -190,13 +209,13 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **152 条用例:114 passed / 38 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 36、
- **162 条用例:125 passed / 37 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 46、
`test_models.py` 5
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz`
- 38 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等)
- 37 条 skip 是等实现的算法(`train_test_split`、`bootstrap_ci`、`monte_carlo`、`anova`、`fit_distribution` 等)
- **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10)
@@ -209,9 +228,9 @@
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
- [ ] L0 完成 **8/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`、`standardize`、`describe`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
详情见上面 E 节
- [ ] L0 完成 **9/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`、`standardize`、`describe`、`correlation_matrix`),
另 L1 的 `confidence_interval` / `t_test` 已提前落地;其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进,详情见 E 节
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
(`helper_unknown_error: setup refresh had errors`),只有提权
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
@@ -270,6 +289,10 @@
| 40 | scipy 的检验结果统一走 `_pvalue(result: Any) -> float` 助手取 p 值,不逐处挂 `# type: ignore` |
| 41 | `correlation_matrix` **只返回数值矩阵**;星标表拆 `correlation_table`、p 值拆 `correlation_pvalues` |
用户 2026-10-03 选方案 A。数值矩阵能做的事(挑高相关对、喂聚类 / PCA、画热力图)字符串表都做不了,而反过来随时能格式化 —— 展示需求属于**输出端**,不该侵占返回值;一旦返回字符串,数值信息不可逆地丢失。三个函数共用 `_check_corr_args` 守卫与成对删缺失口径,`correlation_table` 在**最外层**调另外两个(计算型当主、展示型当辅)。见易错点 14 |
| 42 | `confidence_interval` **只有 `proportion` 夹到 `[0, 1]`**;`normal` / `t` 是**均值**区间,一律不夹 |
原码把三种 method 的区间**无条件**夹进 `[0, 1]`,且 `min` / `max` 参数写反(见易错点 15),实测把身高均值区间下界压成 `0.0`。均值区间的量纲本来就不在 `[0, 1]` 里;只有比例 `p ± z·√(p(1−p)/n)` 的边界才有意义。**Codex 代改、待用户确认** —— 若原意是"什么区间都夹",改回 `_clip` 一行即可 |
| 43 | scipy 检验结果的**统计量**也走 `_statistic(result: Any) -> float` 助手,与决策 40 的 `_pvalue` 成对 |
`TtestResult` 与 `PearsonRResult` 同源(`_make_tuple_bunch` 运行时拼类),存根里 `.statistic` / `.pvalue` 都没有。用户报的「307 / 308 行报错」就是这两处,**不是真 bug**(探针实测取值正常)。见 `docs/04-environment.md` 坑 10 |
`PearsonRResult` / `SignificanceResult` 是 `_make_tuple_bunch` 在**运行时**拼出来的类,存根里没有 `pvalue` 字段:解包会退化成 `object`、属性访问会报"未知"。运行时完全正常(实测三个函数返回的都是 `np.float64`)。收成 `Any` 是**一处**收窄,调用点仍然干净;`# type: ignore` 要挂在每一处,且会连带关掉该行后面的检查。见 `docs/04-environment.md` 坑 10 |
用户 2026-10-03 明确:没考虑到的非法情况"告诉我我自己改"——因为那是设计决策,得由写实现的人形成判断;而 lint / 进度板 / 测试这类机械活再由用户动手只是浪费。这条与决策 25 / 31 配套:25 定"谁写 src",31 定"Codex 可改的 bug 范围",39 定"非算法事务的默认归口" |
docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 |
@@ -287,3 +310,14 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
分工见决策 39;`correlation_matrix` 走方案 A(决策 41)。
5. **仍未答的一个小问题**:`correlation_matrix` 现在只收 `DataFrame`,
而 `describe` / `zscore` / `transform` 都收 ndarray —— 要不要也放开?(你说了算)
6. **`t_test` 有 4 个口径等你拍板(Codex 一律没动,按决策 39)**:
- **① 单样本的结论文案说错了。** `b=None` 时 `test_name` 已经是"单样本 t 检验",
但 `conclusion` 仍写"两组数据存在统计学上的显著差异" —— 单样本根本没有第二组。
建议按分支拼文案(单样本说"该样本均值与 `value` 有显著差异")。
- **② `paired=True` 时 NaN 是按各自的位置丢的**,会把不同行配成一对(静默错位,不报错)。
两个方向:配对前先**成对删缺失**,或**直接拒绝含 NaN 的配对输入**。你定。
- **③ `value` 只在 `b is None` 时生效**,同时传 `b` 和 `value` 会被静默忽略 —— 要不要改成报错?
- **④ `t_test(DataFrame)` 会把所有数值列摊平成一个样本**(已写进 `Notes`)。
多列不同量纲(`gdp` + `pop`)会被混在一起,而且**没法**拿"一张表里两列"当两组比。
要不要改成:多数值列的 DataFrame 直接 `ValueError`、强制先自己取列?
+14 -6
View File
@@ -304,9 +304,7 @@ dtype is specified. This behavior is deprecated and will be removed in a future
---
---
**坑 10 —— scipy 的检验结果在类型检查器里没有 `pvalue` 字段。**
**坑 10 —— scipy 的检验结果在类型检查器里没有 `pvalue` / `statistic` 字段。**
现象:`_, p = stats.pearsonr(x, y)` 运行没问题,但 pyright 报
`无法将 "_T_co@tuple" 类型的参数分配给 ... 的 "float"`;改成 `.pvalue` 又报
@@ -323,11 +321,21 @@ dtype is specified. This behavior is deprecated and will be removed in a future
```python
def _pvalue(result: Any) -> float:
return float(result.pvalue)
def _statistic(result: Any) -> float:
return float(result.statistic)
```
一处收窄、调用点全干净,后面 `t_test` / `anova` / `chi_square_test` 可以复用。
一处收窄、调用点全干净,`t_test` / `anova` / `chi_square_test` / `fit_distribution` 都能复用。
**`TtestResult` 同理。** `stats.ttest_1samp` / `ttest_ind` / `ttest_rel` 返回的也是
`_make_tuple_bunch` 在运行时拼出来的类,存根里 `.statistic` 和 `.pvalue` **两个字段都没有**。
`inference.py` 的 `t_test` 里 `float(res.statistic)` / `float(res.pvalue)` 两行就是这么被 pyright 标红的
(实测:`TtestResult(statistic=3.873, pvalue=0.0305)`,运行时取值完全正常)。
换成 `_statistic(res)` / `_pvalue(res)` 后红线消失,**一行 `# type: ignore` 都没挂**。
---
## 五、两套环境的实际版本(已对齐)
**2026-10-01 已把 base 从 Anaconda 2025.12 升到 2026.07,两套环境版本已对齐。**
@@ -349,10 +357,10 @@ def _pvalue(result: Any) -> float:
### base 升级记录
[FENCE]powershell
```powershell
conda update -n base --all -y # 365 个包更新,下载 638 MB,耗时 1033 秒
conda install -n mcm-toolkit numpy=2.4.6 -y # 把项目环境对齐到 base 的 numpy
[FENCE]
```
* `conda` 本身从 25.11.1 升到 **26.7.3**。
* 升级前 `conda list -n base --revisions` 里已留有 revision 记录,可回滚:
+35
View File
@@ -28,6 +28,15 @@
| 4 | 只覆盖自己的状态,忘了第三方库还在用 numpy 旧全局 | 2026-10-01 |
| 5 | 参数校验排在副作用(建目录 / 写文件)之后 | 2026-10-01 |
| 6 | 把 lint 的"没报错"当成"没问题" | 2026-10-01 |
| 7 | 写"守卫子句"时,忘了不是所有入参都长一样 | 2026-10-02 |
| 8 | 改了一处,忘了同源的另一处 | 2026-10-02 |
| 9 | 实现写完了,测试还挂着 skip —— 绿灯是假的 | 2026-10-02 |
| 10 | 把真报错当成"编辑器误报" | 2026-10-02 |
| 11 | pandas 对不齐就静默填 NaN,不报错 | 2026-10-03 |
| 12 | 三个并列的 `if`,后面的静默覆盖前面的 | 2026-10-03 |
| 13 | `Index == 标量` 得到布尔数组,`if` 要等到第 2 列才炸 | 2026-10-03 |
| 14 | 把"能看的表"和"能算的表"塞进同一个返回值 | 2026-10-03 |
| 15 | 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 参数写反 | 2026-10-03 |
---
@@ -234,3 +243,29 @@
③ 拆的时候让**计算型**当主函数、**展示型**去调它(`correlation_table` 调
`correlation_matrix` + `correlation_pvalues`),而不是反过来。
- 首次:2026-10-03|复发:0
### 15. 区间被无条件夹到 `[0, 1]`,且 `min` / `max` 的参数写反了
- 现象:`confidence_interval` 的返回写成了
`return (float(min(0.0, lower)), float(max(upper, 1.0)))`,本意是"比例区间不能越界"。
三个毛病叠在一起:
① **参数写反**:要夹下界应该写 `max(0, lower)`,写成了 `min(0.0, lower)` —— `0.0` 比任何正的下界都小,
`min` 取到的永远是它,于是**下界恒为 0**;上界那条 `max(upper, 1.0)` 同样是反的,
只是它在 `upper > 1` 时凑巧看不出问题。
② **无条件夹**:三种 method(含 `normal` / `t` 这两种**均值**区间)全被夹。
均值区间的量纲根本不在 `[0, 1]` 里:实测
`confidence_interval(rng.normal(170, 8, 50), method="t")` 的下界是 **0.0**,
而正确结果是 `171.0 ± 2.1`。这种错**不抛异常**,只有把数字打印出来才会发现"身高的下界是 0"。
③ **夹子会吞掉报错信号**:`confidence=1.5` 时 `z` 是 `nan`,`min(0.0, nan)` 返回 `0.0`
(`min` 在遇到 `nan` 之前先锁定了第一个参数),于是本该报错的入参被盖成 `(0.0, nan)`。
另外 `confidence=0` 也一路放行(定义域应是 `(0, 1)` **开区间**)。
- 为什么会错:脑子里记的是"clamp(x, lo, hi)"这个整体,手上却从两个方向各写了一个 `min` / `max`;
更要命的是**试数据选了 `[1, 2, 3, 4, 5]`** —— 它恰好落在 `[0, 1]` 附近,夹不夹都看不出来。
换一个量纲明显不在 `[0, 1]` 的例子(身高 170、GDP 十亿)跑第一遍,这个 bug 一秒现形。
- 以后怎么防:① 夹子只写一个方向:`np.clip(x, lo, hi)`,或 `max(lo, min(hi, x))`;
不要手写两个 `min` / `max` —— 它们看起来对称,其实一个管下界一个管上界,最容易写反。
② **写"区间"函数先做量纲检查**:拿一个边界外的数值当输入,结果被夹到 0 / 1 就是 bug。
③ 边界参数一律按开区间校验(`0 < confidence < 1`),并用 `np.isnan` 兜住参数异常产生的 nan。
④ 记住 `min` / `max` **不传播 nan、结果还取决于参数顺序**(`min(0.0, nan)` → `0.0`、
`min(nan, 0.0)` → `nan`)—— "夹一下"就能把 nan 悄悄换成边界值。
- 首次:2026-10-03|复发:0
+161 -7
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"}`。
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:
+135 -1
View File
@@ -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