feat(stats): describe 落地,修掉一个静默填 NaN 的 bug(L0 8/12)

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)
This commit is contained in:
guzhujushi committed 2026-10-03 13:17:58 +08:00
1 parent 4552aee41c
commit e9624cb3a7
6 files changed
+468 -34

No files matched your search

+33 -2
View File
@@ -39,6 +39,13 @@
`log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。 `log_transform` 的定义域平移;`one_hot_encode` 的自动识别、`drop_first`、列不存在报错。
其中两条是把原先挂着的 `skip` 转为实跑(见坑 9 / 个人易错点 9)。 其中两条是把原先挂着的 `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 ### Changed
- `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` / - `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` /
`Generator` / `None`,其余类型抛 `TypeError`;无参调用复用同一个会推进的共享生成器。 `Generator` / `None`,其余类型抛 `TypeError`;无参调用复用同一个会推进的共享生成器。
@@ -142,6 +149,27 @@
- `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 —— - `data.transform`:口径定稿后 ruff 剩的 4 条 RET505(`return` 后的 `elif`)一并清掉 ——
`All checks passed`;pyright **2 errors → 0 errors**;测试 **7 failed / 75 passed → 82 passed / 40 skipped**。 `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 ### Notes
- `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进 - `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进
`clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、
@@ -152,8 +180,11 @@
(`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`; (`min(x) + offset > 0`,见决策 35,**待用户确认**);④ 常量列行为写进 docstring 的 `Notes`;
另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。 另外 `Parameters` / `Returns` / `Raises` 已按 §4.7 补齐。
- 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。 - 新增易错点第 10 条:**把真报错当成"编辑器误报"** —— 红线先跑 CLI 与 `pytest` 再判断(`docs/dev/personal-pitfalls.md`)。
- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`, - `stats` 这条线开工了:`describe` 落地,并填掉 `examples/01_clean_and_describe.py` 的 `TODO(L0)`。
`Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。 - 还余**三处口径待用户拍板**:`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)` 标记)。 - 除 `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 顺序填实现。 这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
+42 -17
View File
@@ -10,7 +10,7 @@
**第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**, **第二阶段(进行中)**:按 `docs/03-roadmap.md` 的 L0 → L1 顺序**逐个实现函数**,
用户本人写 `src/`、Codex 负责审查 + `tests/`(决策 25)。 用户本人写 `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`), - [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名 `~/.ssh/config` 加了一条域名别名,remote 里仍写域名
**E. L0 实现进度(7/12)** **E. L0 实现进度(8/12)**
- [x] `core/result.py` 的 `Result` - [x] `core/result.py` 的 `Result`
- `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印 - `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印
(十万级数组的 repr 实测仍 < 200 字符);`success` 恒在 (十万级数组的 repr 实测仍 < 200 字符);`success` 恒在
@@ -135,13 +135,32 @@
改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35) 改成校验真正的定义域前提 `min(x) + offset > 0`(见决策 35)
- docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式) - docstring 按 §4.7 补齐 `Parameters` / `Returns` / `Raises`(Codex 代写,与 `io.py` 同一套 numpydoc 格式)
- 口径定稿后 ruff 4 条 RET505 一并清掉 → `All checks passed`;pyright **2 errors → 0** - 口径定稿后 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` | | 检查 | 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 项 | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 |
| `ruff check` | All checks passed | **All checks passed**(0 error) | | `ruff check` | All checks passed | **All checks passed**(0 error) |
| `scripts/doctor.ps1` | 跑通 | — | | `scripts/doctor.ps1` | 跑通 | — |
@@ -151,13 +170,13 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7) `docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) - 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、 - **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` 6、 `test_core.py` 15、`test_solve.py` 9、`test_viz.py` 8、`test_decision.py` 7、`test_stats.py` 25、
`test_models.py` 5 `test_models.py` 5
- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正, - **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正,
决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环
- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` - `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**, - **本轮回归(已修)**:`transform.py` 上一版有 3 处真 bug —— 修前 **7 failed / 75 passed**,
且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。 且 pyright 在同一处报 `reportArgumentType`。**不是静态误报**:pyright CLI 复现 + 测试真红。
结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10) 结论:见到红线先跑 CLI 与 `pytest`,**不许直接挂 `# type: ignore`**(见个人易错点 10)
@@ -170,8 +189,8 @@
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。 只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。 - [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。 note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
- [ ] L0 完成 **7/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、 - [ ] L0 完成 **8/12**(`Result`、`get_rng`/`set_seed`、`load_table`/`save_table`、`clean_missing`、
`detect_outliers`、`normalize`、`standardize`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; `detect_outliers`、`normalize`、`standardize`、`describe`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进;
详情见上面 E 节 详情见上面 E 节
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
(`helper_unknown_error: setup refresh had errors`),只有提权 (`helper_unknown_error: setup refresh had errors`),只有提权
@@ -222,15 +241,21 @@
| 31 | Codex 可**直接改** `src/` 的范围:纯 bug(不改接口与语义)、格式、死代码 | 决策 25 说实现归用户,但"能跑"是底线:`winsorize(pd.Series)` 因 `select_dtypes` 在 `Series` 上不存在而直接崩、测试变红,这种必须当场修。凡是要**选一个语义**的(文本列返回什么、mode 要不要就地改)一律留给用户本人,Codex 只把选项和影响列清楚 | | 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 自动跳过 | | 27 | base 里的 zstandard 残骸目录**改名禁用**,不删除、不重装 | 删掉不可逆;`pip install zstandard` 会给 base 塞一个 conda 记录里根本没有的包,下次 `conda update --all` 又要重新判断。改名后 `import` 变回正常 `ModuleNotFoundError`,pandas 自动跳过 |
| 35 | `log_transform` 的显式 `offset` 按**定义域前提**校验:`min(x) + offset > 0` 才放行 | | 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",改回即可 | 原码写的是 `if offset > 0: raise`,把唯一合法的用例(正 offset 正好抬定义域)全拒了,报错文案却是"offset 必须大于 0",自相矛盾。改成校验真正的前提:`log` 要求自变量取正。`offset=0` 对全正数据合法(`offset=None` 的自动分支本来就取 0)。**Codex 代选、待用户确认** —— 若原意是"只收正 offset",改回即可 |
## 下一步 ## 下一步
1. **`stats.describe` 是 L0 里下一个该写的**(A 档,`stats/descriptive.py`)—— 1. **`correlation_matrix` 是 L0 里下一个该写的**(A 档,`stats/inference.py`)——
`examples/00_quickstart.py` 与 `examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 都在等它。 写法和 `describe` 一样:先想"替代了哪段手写代码" → 先写一条会红的测试 → 再实现
写法照 `data/clean.py` 的 `clean_missing`:先想"替代了哪段手写代码" → 先写一条会红的测试 → 补 `examples/` 一行(`docs/03-roadmap.md` §3.2)。
→ 再实现 → 补 `examples/` 一行(`docs/03-roadmap.md` §3.2)。 2. 之后按 L0 剩余顺序推进:`bootstrap_ci`(**S 档,要写函数卡片**)→ `monte_carlo`(**S 档**)
2. 之后按 L0 剩余顺序推进:`correlation_matrix`(A)→ `bootstrap_ci`(**S 档,要写函数卡片**) → `save_figure`(A)。
→ `monte_carlo`(**S 档**)→ `save_figure`(A)。
3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 3. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
4. **待你确认(决策 35)**:`log_transform` 的显式 `offset` 现在按"定义域前提"校验 4. **三处口径待你拍板**(Codex 只列选项,不替你选):
(`min(x) + offset > 0`,否则 `ValueError`)。若你原意是"只收正 offset",说一声我改回去。 - **决策 35**:`log_transform` 的显式 `offset` 按"定义域前提"校验(`min(x) + offset > 0`)。
若原意是"只收正 offset",说一声我改回去。
- **`zscore(df)` 遇到非数值列会静默丢掉那一列**(`describe` 也一样只描述数值列)。
`detect_outliers` 走的是"保留列、掩码恒 False"(决策 32)—— 要不要统一?
- **`coefficient_of_variation` 在均值为负时返回负值**(`σ/μ` 原样输出)。
备选:① 取绝对值 `σ/|μ|`;② 均值为负直接抛 `ValueError`;③ 维持现状 + Notes 提醒(我暂时按 ③ 写的)。
+18
View File
@@ -159,3 +159,21 @@
才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行 才在**最小范围**加 `# type: ignore[具体错误码]`;裸 `# type: ignore` 会连带关掉后面所有行
(更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。 (更好的写法见 `docs/04-environment.md` 坑 8 的 `cast`)。
- 首次:2026-10-02|复发:0 - 首次: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
+5 -3
View File
@@ -1,7 +1,7 @@
"""data 包最小用法:读 → 清 → 补 → 判离群 → 缩尾。 """data + stats 最小用法:读 → 清 → 补 → 判离群 → 缩尾 → 描述。
每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。 每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。
`stats.describe`(描述统计)还没实现,最后留了 TODO(L0) 的位置。 `stats.describe` 收尾:行 = 指标、列 = 变量,可直接贴进论文。
""" """
from __future__ import annotations from __future__ import annotations
@@ -54,7 +54,9 @@ def main() -> None:
w = mcm.data.winsorize(filled, lower=0.01, upper=0.99) w = mcm.data.winsorize(filled, lower=0.01, upper=0.99)
print(f"gdp 最大值 {filled['gdp'].max():.1f} -> 缩尾后 {w['gdp'].max():.1f}") 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__": if __name__ == "__main__":
+194 -11
View File
@@ -6,35 +6,218 @@
from __future__ import annotations 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"] __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` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。 `weights` 非空时给出加权均值与加权标准差(权重是"频数"或"重要性"时用)。
Parameters
----------
x:
`pandas.DataFrame` / `pandas.Series` / list / tuple / `numpy.ndarray`。
**只描述数值列**,非数值列自动跳过。
weights:
与 `x` 行数等长的**非负**权重,类型同 `x` 可接受的那几种;
`None` 表示不给加权行。
Returns 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 Examples
-------- --------
>>> describe(df).loc["mean"] # doctest: +SKIP >>> describe(df).loc["mean"] # doctest: +SKIP
""" """
# TODO(L0) allowed_types = (pd.DataFrame, pd.Series, list, tuple, np.ndarray)
raise NotImplementedError 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: def zscore(x: Any, ddof: int = 0) -> Any:
"""标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。""" """标准化到 z 分数,顺带回答了"这个值离均值几个标准差"。
# TODO(L0)
raise NotImplementedError 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: def coefficient_of_variation(x: Any) -> float:
"""变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。""" """变异系数 `std / mean` —— 跨量纲比较"谁更波动"时的唯一正确做法。
# TODO(L1)
raise NotImplementedError 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)
+176 -1
View File
@@ -1,16 +1,21 @@
"""stats 包测试。 """stats 包测试。
原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。 原则(见 docs/02-architecture.md §5):**测已知解析解,不测实现细节**。
未实现的函数**逐条** `skip`——**不要**用模块级 `pytestmark`:那会把同文件里
已经写好的函数一起跳过,绿灯就是假的(决策 26 / 个人易错点 9)。
""" """
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pandas as pd
import pytest 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): def test_bootstrap_ci_matches_normal_theory_on_large_n(rng):
"""大样本下 bootstrap 百分位区间应逼近正态解析区间(±10% 容差)。""" """大样本下 bootstrap 百分位区间应逼近正态解析区间(±10% 容差)。"""
from mcm_toolkit.stats import bootstrap_ci 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 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): def test_bootstrap_ci_works_for_arbitrary_statistic(rng):
"""bootstrap 的核心价值:任意统计量都能给区间(这里用中位数)。""" """bootstrap 的核心价值:任意统计量都能给区间(这里用中位数)。"""
from mcm_toolkit.stats import bootstrap_ci 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"] assert r.params["low"] < np.median(x) < r.params["high"]
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_bootstrap_ci_is_reproducible(rng): def test_bootstrap_ci_is_reproducible(rng):
from mcm_toolkit.stats import bootstrap_ci 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"] assert a.params["low"] == b.params["low"]
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_confidence_interval_matches_scipy(rng): def test_confidence_interval_matches_scipy(rng):
from scipy import stats from scipy import stats
@@ -51,6 +59,7 @@ def test_confidence_interval_matches_scipy(rng):
assert np.allclose([lo, hi], ref, rtol=1e-6) assert np.allclose([lo, hi], ref, rtol=1e-6)
@pytest.mark.skip(reason=_NOT_IMPLEMENTED)
def test_correlation_matrix_diagonal_is_one(rng): def test_correlation_matrix_diagonal_is_one(rng):
from mcm_toolkit.stats import correlation_matrix 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) assert np.allclose(np.diag(np.asarray(c)), 1.0)
# ------------------------------------------------------ stats: describe
def test_describe_reports_expected_keys(rng): def test_describe_reports_expected_keys(rng):
from mcm_toolkit.stats import describe from mcm_toolkit.stats import describe
table = describe(rng.normal(size=(100, 3))) table = describe(rng.normal(size=(100, 3)))
for key in ["mean", "std", "min", "max", "median"]: for key in ["mean", "std", "min", "max", "median"]:
assert key in table.index 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)