From e9624cb3a7243e883dfa3f680bcf749f76942876 Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Sat, 3 Oct 2026 13:17:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(stats):=20describe=20=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=8E=89=E4=B8=80=E4=B8=AA=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A1=AB=20NaN=20=E7=9A=84=20bug=EF=BC=88L0=208/12=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/stats/descriptive.py 由用户本人实现,这里做审查 + 代修。 describe: - 真 bug:df_num.describe().T 把表转反(行=变量、列=指标),而 docstring 与 test_describe_reports_expected_keys 定的是"行=指标、列=变量"。转置后 .loc[指标名] = <按变量索引的 Series> 与列名零交集,pandas 不报错,直接把 missing / missing_ratio / skewness / kurtosis 四行整行填成 NaN(静默丢数据); weights 分支则报 cannot set a row with mismatched columns。去掉 .T 一处修好三件事 - 补 median 行(pandas 只有 50%,测试契约定的是 median) - 入参放宽到 list / tuple / ndarray(00_quickstart.py 传的就是 ndarray) - weights 校验补齐:维度 / 长度 / NaN / 负值 / 和为 0(原先和为 0 时裸抛 ZeroDivisionError) - docstring 按 4.7 补 Parameters / Returns / Raises zscore: 修 RET505;docstring 补齐;DataFrame 取列处按坑 8 用 cast 收窄 coefficient_of_variation: 均值为 0 改抛 ValueError(取值问题不是类型问题); 空数组提前报错(原先静默 nan + RuntimeWarning);错别字与文案修正 tests: 去掉 test_stats.py 的模块级 pytestmark —— 它把 6 条测试全部跳过(含已实现的 describe),正是决策 26 要禁的写法。改为逐条 skip 未实现的 5 条,并新增 20 条用例。 验证:pytest 102 passed / 39 skipped(原 82/40);ruff All checks passed;pyright 0 errors。 examples/01_clean_and_describe.py 填掉 TODO(L0),实测 exit 0。 docs: STATUS.md L0 8/12 + 决策 35/36 + 三处待拍板口径;CHANGELOG 记录; personal-pitfalls 追加第 11 条(pandas 对不齐就静默填 NaN) --- CHANGELOG.md | 35 ++++- STATUS.md | 59 +++++--- docs/dev/personal-pitfalls.md | 18 +++ examples/01_clean_and_describe.py | 8 +- src/mcm_toolkit/stats/descriptive.py | 205 +++++++++++++++++++++++++-- tests/test_stats.py | 177 ++++++++++++++++++++++- 6 files changed, 468 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c296a..df17051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ `log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。 其中两条是把原先挂着的 `skip` 转为实跑(见坑 9 / 个人易错点 9)。 +- `tests/test_stats.py`:新写 **20 条**用例 —— `describe` 的方向约定(行 = 指标、列 = 变量)、 + `missing` / `missing_ratio` / `skewness` / `kurtosis` 四行**不能是 NaN**、非数值列自动跳过、 + 接受 ndarray / list / Series、加权均值与加权标准差对照手算公式、5 种非法 `weights`; + `zscore` 的手算对照 / 按列 / 常量列 / 非法类型;`coefficient_of_variation` 的手算对照、 + 单列 DataFrame、均值为 0 抛 `ValueError`、空数组与二维输入。 +- `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出(实测 exit 0)。 + ### Changed - `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` / `Generator` / `None`,其余类型抛 `TypeError`;无参调用复用同一个会推进的共享生成器。 @@ -142,6 +149,27 @@ - `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 —— `All checks passed`;pyright **2 errors → 0 errors**;测试 **7 failed / 75 passed → 82 passed / 40 skipped**。 +- `stats.describe`:实现落地(**由用户本人写**,Codex 审查 + 代修),并修掉一个**静默 bug** —— + `df_num.describe().T` 把表转反了(行 = 变量、列 = 指标),而 docstring 与 + `test_describe_reports_expected_keys` 定的是"行 = 指标、列 = 变量"。 + 转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错, + 直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**; + `weights` 分支则报 `cannot set a row with mismatched columns`。去掉 `.T` 一处修好三件事。 + 另补 `median` 行(pandas 只有 `50%`,测试契约定的是 `median`)、入参放宽到 + `list` / `tuple` / `ndarray`(`00_quickstart.py` 传的就是 ndarray)、 + `weights` 校验补齐(维度 / 长度 / NaN / 负值 / 和为 0,原先和为 0 时裸抛 `ZeroDivisionError`)、 + docstring 按 §4.7 补 `Parameters` / `Returns` / `Raises`。 +- `stats.zscore`:修 RET505;docstring 按 §4.7 补齐;DataFrame 取列处按坑 8 用 `cast` 收窄 + (pyright 的 `DataFrame | Series` 重载)。 +- `stats.coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError` + (那是**取值**问题不是类型问题,§4.7);空数组提前抛 `ValueError`(原先静默返回 nan + RuntimeWarning); + 报错文案里"异变系数"的错别字与 "N 维数组" 改为 "N 列"。 +- `tests/test_stats.py`:去掉**模块级** `pytestmark = pytest.mark.skip` —— 它把该文件 6 条测试 + **全部**跳过(包括已经实现好的 `describe`),正是决策 26 要禁的写法:实现零覆盖而测试全绿。 + 改为逐条 skip 未实现的 5 条。 +- 测试计数:**82 passed / 40 skipped → 102 passed / 39 skipped**(新增 20 条); + ruff 7 error → **All checks passed**;pyright **1 error → 0**。 + ### Notes - `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进 `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 @@ -152,8 +180,11 @@ (`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`; 另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。 - 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。 -- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`, - `Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。 +- `stats` 这条线开工了:`describe` 落地,并填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`。 +- 还余**三处口径待用户拍板**:`log_transform` 的显式 `offset`、`zscore` 丢掉非数值列、 + `coefficient_of_variation` 在均值为负时返回负值。见 `STATUS.md`「下一步」第 4 条与决策 35/36。 +- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `bootstrap_ci` / `viz`, + `Result` 的用法届时并进去。 - 已知缺口: - 除 `core.Result`、`core.get_rng`、`core.set_seed`、`data.load_table`、`data.save_table` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。 这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。 diff --git a/STATUS.md b/STATUS.md index 279d4a9..cddba99 100644 --- a/STATUS.md +++ b/STATUS.md @@ -10,7 +10,7 @@ **第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**, 用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。 -当前 7/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`、`normalize`/`standardize`。 +当前 8/12:`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、`detect_outliers`、`normalize`/`standardize`、`describe`。 ## 任务清单 @@ -62,7 +62,7 @@ - [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`), `~/.ssh/config` 加了一条域名别名,remote 里仍写域名 -**E. L0 实现进度(7/12)** +**E. L0 实现进度(8/12)** - [x] `core/result.py` 的 `Result` - `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印 (十万级数组的 repr 实测仍 < 200 字符);`success` 恒在 @@ -135,13 +135,32 @@ 改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35) - docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式) - 口径定稿后 ruff 4 条 RET505 一并清掉 → `All checks passed`;pyright **2 errors → 0** -- [ ] 剩余 5 项:`describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` +- [x] `stats/descriptive.py` 的 `describe`(L0 第 8 项,**由用户本人实现**,Codex 审查 + 代修) + - **一个静默 bug(最危险的那种)**:`df_num.describe().T` 把表转反了(行 = 变量、列 = 指标), + 而 docstring 与 `test_describe_reports_expected_keys` 都要求"行 = 指标、列 = 变量"。 + 转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名**零交集**,pandas 不报错, + 直接把这四行(`missing` / `missing_ratio` / `skewness` / `kurtosis`)**整行填成 NaN**。 + 去掉 `.T` 一处即修好三件事:方向对了、NaN 没了、`weights` 分支不再报 + `cannot set a row with mismatched columns`(见易错点 11、决策 36) + - `median` 行补上(pandas 的 `describe()` 只有 `50%`,而测试契约定的是 `median`) + - 入参放宽到 `list` / `tuple` / `ndarray`(兄弟函数都收,`00_quickstart.py` 传的就是 ndarray) + - `weights` 校验补齐:长度、非一维、NaN/inf、负值、和为 0 都抛带原因的错误 + (原先和为 0 时裸抛 `ZeroDivisionError`) + - `zscore`:修 RET505,docstring 补 `Parameters` / `Returns` / `Raises`; + `coefficient_of_variation`:均值为 0 由 `TypeError` 改为 `ValueError`(那是取值问题不是类型问题)、 + 空数组提前报错(原先静默返回 nan + RuntimeWarning)、改掉"异变系数"错别字 + - pyright 的 `DataFrame | Series` 重载问题按坑 8 用 `cast` 收窄;ruff 7 条清到 0 + - 测试:`test_stats.py` 去掉**模块级** `pytestmark`(它把 6 条测试**全部**跳过了 —— + 决策 26 说的就是这个,实现写完了却零覆盖),改成逐条 skip 未实现的 5 条, + 并新写 20 条 describe / zscore / 变异系数用例 +- [x] `examples/01_clean_and_describe.py`:填掉末尾的 `TODO(L0)`,加一段 `describe` 输出 +- [ ] 剩余 4 项:`correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **82 passed, 40 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **102 passed, 39 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` | 跑通 | — | @@ -151,13 +170,13 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **122 条用例:82 passed / 40 skipped**。收集数按文件:`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` 6、 +- **141 条用例:102 passed / 39 skipped**。收集数按文件:`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` 25、 `test_models.py` 5 - **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正, 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 - `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` -- 40 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等) +- 39 条 skip 是等实现的算法(`train_test_split`、`describe`、`bootstrap_ci`、`monte_carlo` 等) - **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**, 且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。 结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10) @@ -170,8 +189,8 @@ 只差 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 完成 **7/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 - `detect_outliers`、`normalize`、`standardize`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; +- [ ] 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 节 - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** (`helper_unknown_error: setup refresh had errors`),只有提权 @@ -222,15 +241,21 @@ | 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 | | 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 | | 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 | +| 36 | `describe` 的返回表**不转置**:行 = 指标、列 = 变量 | +docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向,实现里多做的 `.T` 是净负担 —— 且不是"风格不同"那么轻:转置后 `.loc[指标名] = <按变量索引的 Series>` 与列名零交集,pandas **不报错**、把四行整行填成 NaN。方向约定以"能按列名对齐"为准,见易错点 11 | 原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 | ## 下一步 -1. **`stats.describe` 是 L0 里下一个该写的**(A 档,`stats/descriptive.py`)—— - `examples/00_quickstart.py` 与 `examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。 - 写法照 `data/clean.py` 的 `clean_missing`:先想"替代了哪段手写代码" → 先写一条会红的测试 - → 再实现 → 补 `examples/` 一行(`docs/03-roadmap.md` §3.2)。 -2. 之后按 L0 剩余顺序推进:`correlation_matrix`(A)→ `bootstrap_ci`(**S 档,要写函数卡片**) - → `monte_carlo`(**S 档**)→ `save_figure`(A)。 +1. **`correlation_matrix` 是 L0 里下一个该写的**(A 档,`stats/inference.py`)—— + 写法和 `describe` 一样:先想"替代了哪段手写代码" → 先写一条会红的测试 → 再实现 + → 补 `examples/` 一行(`docs/03-roadmap.md` §3.2)。 +2. 之后按 L0 剩余顺序推进:`bootstrap_ci`(**S 档,要写函数卡片**)→ `monte_carlo`(**S 档**) + → `save_figure`(A)。 3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 -4. **待你确认(决策 35)**:`log_transform` 的显式 `offset` 现在按"定义域前提"校验 - (`min(x) + offset > 0`,否则 `ValueError`)。若你原意是"只收正 offset",说一声我改回去。 +4. **三处口径待你拍板**(Codex 只列选项,不替你选): + - **决策 35**:`log_transform` 的显式 `offset` 按"定义域前提"校验(`min(x) + offset > 0`)。 + 若原意是"只收正 offset",说一声我改回去。 + - **`zscore(df)` 遇到非数值列会静默丢掉那一列**(`describe` 也一样只描述数值列)。 + `detect_outliers` 走的是"保留列、掩码恒 False"(决策 32)—— 要不要统一? + - **`coefficient_of_variation` 在均值为负时返回负值**(`σ/μ` 原样输出)。 + 备选:① 取绝对值 `σ/|μ|`;② 均值为负直接抛 `ValueError`;③ 维持现状 + Notes 提醒(我暂时按 ③ 写的)。 diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 6efcd72..971f4b8 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -159,3 +159,21 @@ 才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行 (更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。 - 首次:2026-10-02|复发:0 + +### 11. pandas 对不齐就静默填 NaN,不报错 + +- 现象:`describe` 里为了"行 = 变量、列 = 指标"多做了一次 `.T`,然后 + `table.loc["missing"] = df_num.isna().sum()`。跑起来**一声不响**,表里却多出四行 + **全 NaN**(`missing` / `missing_ratio` / `skewness` / `kurtosis`)。 + `.loc[新行标签] = Series` 是按**列名**对齐的:Series 的索引是变量名、表的列名是 + 指标名,两边**没有一个交集**,pandas 于是把整行填成 NaN —— 不抛异常。 + 同一个 `.T` 让 `weights` 分支换成 ndarray 后反而直接报 + `ValueError: cannot set a row with mismatched columns` —— 那个报错才是救命的。 +- 为什么会错:脑子里想的是"给这一行赋值",pandas 做的是"按标签对齐再赋值"。 + 对齐失败在 pandas 里是**合法结果**(就是缺失),不是错误 —— 它不会替你判断 + "这次对齐失败是不是手滑"。 +- 以后怎么防:① 往表里加一行前,先确认 Series 的索引 == 目标轴的标签(列名), + 拿不准就 `print(list(series.index))` 对着 `list(table.columns)` 看一眼; + ② 新加的行/列**立刻断言不是全 NaN**,别只看"代码跑过去了"; + ③ 记住这次的信号差异:长度不对(ndarray)会报错,Series **不会** —— 后者更危险。 +- 首次:2026-10-03|复发:0 diff --git a/examples/01_clean_and_describe.py b/examples/01_clean_and_describe.py index e54a8c0..1a88553 100644 --- a/examples/01_clean_and_describe.py +++ b/examples/01_clean_and_describe.py @@ -1,7 +1,7 @@ -"""data 包最小用法:读 → 清 → 补 → 判离群 → 缩尾。 +"""data + stats 最小用法:读 → 清 → 补 → 判离群 → 缩尾 → 描述。 每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。 -`stats.describe`(描述统计)还没实现,最后留了 TODO(L0) 的位置。 +`stats.describe` 收尾:行 = 指标、列 = 变量,可直接贴进论文。 """ from __future__ import annotations @@ -54,7 +54,9 @@ def main() -> None: w = mcm.data.winsorize(filled, lower=0.01, upper=0.99) print(f"gdp 最大值 {filled['gdp'].max():.1f} -> 缩尾后 {w['gdp'].max():.1f}") - # TODO(L0):stats.describe(filled[["gdp", "pop"]]) 还没实现,实现后在这里加一段描述统计。 + # 7) 描述统计:行 = 指标、列 = 变量,可直接贴进论文 + table = mcm.stats.describe(filled[["gdp", "pop"]]) + print(table.round(2)) if __name__ == "__main__": diff --git a/src/mcm_toolkit/stats/descriptive.py b/src/mcm_toolkit/stats/descriptive.py index 9170bb0..dc5b3f7 100644 --- a/src/mcm_toolkit/stats/descriptive.py +++ b/src/mcm_toolkit/stats/descriptive.py @@ -6,35 +6,218 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast + +import numpy as np +import pandas as pd __all__ = ["coefficient_of_variation", "describe", "zscore"] -def describe(x: Any, weights: Any | None = None) -> Any: +def describe(x: Any, weights: Any | None = None) -> pd.DataFrame: """一张表看完集中趋势、离散程度、偏度峰度、缺失情况。 `weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。 + Parameters + ---------- + x: + `pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。 + **只描述数值列**,非数值列自动跳过。 + weights: + 与 `x` 行数等长的**非负**权重,类型同 `x` 可接受的那几种; + `None` 表示不给加权行。 + Returns ------- - DataFrame,行是指标,列是被描述的变量。 + pandas.DataFrame + 行是指标,列是被描述的变量。指标依次为 + `count` / `mean` / `std` / `min` / `25%` / `50%` / `75%` / `max` / + `median` / `missing` / `missing_ratio` / `skewness` / `kurtosis`; + 给了 `weights` 时再追加 `weighted_mean` / `weighted_std`。 + 返回的是新表,原表不改。 + + Raises + ------ + TypeError + `x` 或 `weights` 的类型不支持。 + ValueError + `x` 里没有数值列;或 `weights` 无法转成数值、不是一维、长度与行数不符、 + 含 NaN / inf、含负值、求和为 0。 + + Notes + ----- + 每个指标都**按列独立**计算;含缺失时 pandas 会自动跳过该单元格(`count` 因此 + 可能小于行数,缺失量看 `missing` / `missing_ratio`)。 + 加权标准差用总体口径(`ddof=0`)。含 NaN 的列加权后仍是 NaN —— + 加权前请先用 `impute_missing` 补好。 Examples -------- >>> describe(df).loc["mean"] # doctest: +SKIP """ - # TODO(L0) - raise NotImplementedError + allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray) + if not isinstance(x, allowed_types): + raise TypeError(f"x 必须是{allowed_types},而你输入了{type(x)}") + + if not isinstance(x, pd.DataFrame): + x = pd.DataFrame(x) + + numeric_cols = x.select_dtypes(include=["number"]).columns + if len(numeric_cols) == 0: + raise ValueError("x 里没有数值列,无法描述") + + # df[Index] 在 pandas 存根里是 DataFrame | Series 重载,按运行时真值收窄(坑 8) + df_num = cast(pd.DataFrame, x[numeric_cols]) + + # 这里**不要转置**。describe() 出来就是"行 = 指标、列 = 变量",正好让下面 + # `.loc[指标名] = <按变量索引的 Series>` 能按列名对齐;一旦转置,Series 的索引 + # 与表的列名不相交,pandas 会不报错地**把整行填成 NaN**(旧的静默 bug)。 + table = df_num.describe() + table.loc["median"] = df_num.median() + table.loc["missing"] = df_num.isna().sum() + table.loc["missing_ratio"] = df_num.isna().mean() + table.loc["skewness"] = df_num.skew() + table.loc["kurtosis"] = df_num.kurt() + + if weights is not None: + allowed_weight_types = (list, tuple, np.ndarray, pd.Series) + if not isinstance(weights, allowed_weight_types): + raise TypeError(f"weights 必须是{allowed_weight_types},而你输入了{type(weights)}") + + try: + w_arr = np.asarray(weights, dtype=float) + except (ValueError, TypeError) as e: + raise ValueError(f"weights 无法转成数值数组,请检查是否含非数值字符:{e}") from None + + if w_arr.ndim != 1: + raise ValueError(f"weights 必须是一维数组,而你传入了 {w_arr.ndim} 维") + if len(w_arr) != len(df_num): + raise ValueError(f"权重 weights 的长度({len(w_arr)})必须与数据的行数({len(df_num)})一致") + if not np.isfinite(w_arr).all(): + raise ValueError("weights 含 NaN 或 inf,无法加权") + if (w_arr < 0).any(): + raise ValueError("weights 不能含负值") + if w_arr.sum() == 0: + raise ValueError("weights 之和为 0,无法加权") + + weighted_mean = np.average(df_num, axis=0, weights=w_arr) + weighted_std = np.sqrt(np.average((df_num - weighted_mean) ** 2, axis=0, weights=w_arr)) + + table.loc["weighted_mean"] = weighted_mean + table.loc["weighted_std"] = weighted_std + + return table def zscore(x: Any, ddof: int = 0) -> Any: - """标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。""" - # TODO(L0) - raise NotImplementedError + """标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。 + + Parameters + ---------- + x: + `pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。 + DataFrame **只取数值列**,非数值列不会出现在结果里。 + ddof: + 标准差的自由度修正,与 `numpy.std` 同名参数含义一致;`0` 为总体标准差。 + + Returns + ------- + numpy.ndarray / pandas.Series / pandas.DataFrame + 与 `x` 同类型;DataFrame 只保留数值列,列名与索引不变。 + 常量列(`std == 0`)不产生 nan / inf,固定输出 0(决策 34)。 + + Raises + ------ + TypeError + `x` 类型不支持。 + ValueError + DataFrame 里一列数值列都没有。 + + Notes + ----- + 与 `data.transform.standardize` 是同一个公式;差别只在 DataFrame 里遇到 + 非数值列时:这里**跳过**,`standardize` 会直接抛错。 + """ + allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray) + if not isinstance(x, allowed_types): + raise TypeError(f"仅接受{allowed_types},不接受{type(x)}") + + is_df = isinstance(x, pd.DataFrame) + is_series = isinstance(x, pd.Series) + if is_df: + numeric_cols = x.select_dtypes(include=[np.number]).columns + if len(numeric_cols) == 0: + raise ValueError("数据中没有数值列,无法计算 Z 分数") + target_data = cast(pd.DataFrame, x[numeric_cols]) + original_cols = target_data.columns + original_index = target_data.index + else: + target_data = x + original_cols = None + original_index = x.index if is_series else None + + arr = np.asarray(target_data, dtype=float) + + mean = np.mean(arr, axis=0) + std = np.std(arr, axis=0, ddof=ddof) + + std = np.where(std == 0, 1.0, std) + + result = (arr - mean) / std + if is_df: + return pd.DataFrame(result, columns=original_cols, index=original_index) + if is_series: + return pd.Series(result, index=original_index) + return result def coefficient_of_variation(x: Any) -> float: - """变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。""" - # TODO(L1) - raise NotImplementedError + """变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。 + + Parameters + ---------- + x: + 一维数据:`pandas.Series` / list / tuple / `numpy.ndarray`, + 或只有一列的 `pandas.DataFrame`。 + + Returns + ------- + float + `std / mean`,标准差按总体口径(`ddof=0`)。 + + Raises + ------ + TypeError + `x` 类型不支持。 + ValueError + 数据为空、不是一维(或 DataFrame 列数不为 1)、或均值为 0。 + + Notes + ----- + CV 只有在**均值为正**的比率尺度数据上才有意义:均值为负时本函数返回负值, + 那不是"波动更小",只是符号跟着均值走;均值接近 0 时 CV 会被放大到任意大。 + 这两类数据请换用别的离散度指标。 + """ + allowed_type = (list, tuple, np.ndarray, pd.Series) + if isinstance(x, pd.DataFrame): + if x.shape[1] != 1: + raise ValueError(f"变异系数只适用于一维数据,而你传入了 {x.shape[1]} 列的 DataFrame") + arr = np.asarray(x.iloc[:, 0], dtype=float) + elif isinstance(x, allowed_type): + arr = np.asarray(x, dtype=float) + if arr.ndim != 1: + raise ValueError(f"变异系数只适用于一维数据,而你传入了 {arr.ndim} 维数组") + else: + raise TypeError(f"x 必须是{allowed_type}或单列 pd.DataFrame,而你传入了{type(x)}") + + if arr.size == 0: + raise ValueError("数据为空,无法计算变异系数") + + mean = np.mean(arr) + std = np.std(arr, ddof=0) + + if mean == 0: + raise ValueError("均值为 0,无法计算变异系数(分母为 0)") + + return float(std / mean) diff --git a/tests/test_stats.py b/tests/test_stats.py index 918c705..1593ffd 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,16 +1,21 @@ """stats 包测试。 原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。 + +未实现的函数**逐条** `skip`——**不要**用模块级 `pytestmark`:那会把同文件里 +已经写好的函数一起跳过,绿灯就是假的(决策 26 / 个人易错点 9)。 """ from __future__ import annotations import numpy as np +import pandas as pd import pytest -pytestmark = pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L1") +_NOT_IMPLEMENTED = "未实现:见 docs/03-roadmap.md L1" +@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 @@ -21,6 +26,7 @@ def test_bootstrap_ci_matches_normal_theory_on_large_n(rng): 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 @@ -31,6 +37,7 @@ def test_bootstrap_ci_works_for_arbitrary_statistic(rng): 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 @@ -40,6 +47,7 @@ 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 @@ -51,6 +59,7 @@ def test_confidence_interval_matches_scipy(rng): assert np.allclose([lo, hi], ref, rtol=1e-6) +@pytest.mark.skip(reason=_NOT_IMPLEMENTED) def test_correlation_matrix_diagonal_is_one(rng): from mcm_toolkit.stats import correlation_matrix @@ -59,9 +68,175 @@ def test_correlation_matrix_diagonal_is_one(rng): assert np.allclose(np.diag(np.asarray(c)), 1.0) +# ------------------------------------------------------ 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_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)