diff --git a/CHANGELOG.md b/CHANGELOG.md index 58bb5c4..76f5675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/STATUS.md b/STATUS.md index 533045b..1c1ae2c 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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`、强制先自己取列? diff --git a/docs/04-environment.md b/docs/04-environment.md index 07f0164..9dc981c 100644 --- a/docs/04-environment.md +++ b/docs/04-environment.md @@ -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 记录,可回滚: diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 532665b..34cc57a 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -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 diff --git a/src/mcm_toolkit/stats/inference.py b/src/mcm_toolkit/stats/inference.py index 1c5abdc..2729db5 100644 --- a/src/mcm_toolkit/stats/inference.py +++ b/src/mcm_toolkit/stats/inference.py @@ -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: diff --git a/tests/test_stats.py b/tests/test_stats.py index ff94ad3..b277810 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -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