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:
1 parent
4552aee41c
commit
e9624cb3a7
6 files changed
+468
-34
No files matched your search
+33
-2
@@ -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 顺序填实现。
|
||||
|
||||
@@ -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 提醒(我暂时按 ③ 写的)。
|
||||
@@ -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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
+176
-1
@@ -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)
|
||||
Reference in new issue
Block a user