From 5749e50e8315c4b85383475d703d41a9798bd76d Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Fri, 2 Oct 2026 15:48:12 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=A1=A5=20S=20=E6=A1=A3=E5=8D=A1?= =?UTF-8?q?=E7=89=87=20/=20examples=EF=BC=9Bclean.py=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=8Cxfail=20=E6=B8=85=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现收尾(Codex 代写,用户授权): - impute_missing 的 mode 分支改为返回新表(原先就地改调用方的 DataFrame,另外四支都返回新对象) - detect_outliers 补入参类型校验(§4.7:非法类型抛 TypeError,不静默兜底): 接受 DataFrame / Series / list / tuple / ndarray;tuple 同时补进 is_ld_array; dict 不再被接受(以前靠 pd.DataFrame() 碰巧能过,见决策 33) 测试: - 摘掉最后一条 xfail(mode 就地改)并加强断言 - 新增 3 条:非法类型抛 TypeError、元组按一维数组型处理、全 NaN 列与单元素列不抛异常 - 64 passed / 42 skipped / 0 xfailed(xfail 清零,7 条契约全部转正);ruff 0;pyright 0 文档: - 新增 docs/algorithms/detect_outliers.md —— 第一张 S 档函数卡片(roadmap 要求约 15 张) 除模板九节外多记三条判断:为什么不返回 Result、非数值列恒 False 的两条备选路各错在哪、 ddof 口径与 stats.zscore 不一致(前者 1、后者 0,小样本差一成) - 新增 examples/01_clean_and_describe.py —— 六个 data 函数的最小用法,实测跑通(exit 0) - STATUS.md:E 节收尾、验证结果表、下一步重写(transform/standardize 的待定项)、决策 33 - CHANGELOG.md:把 [Unreleased] 里重复 7 次的 Added/Changed 小节合并成 Added/Changed/Notes; 并修掉上轮遗留的缺陷 —— pyright 那条 bullet 的开头被吞掉,半句挂在 ruff 那条后面 --- CHANGELOG.md | 46 +++++--- STATUS.md | 51 +++++---- docs/algorithms/README.md | 2 +- docs/algorithms/detect_outliers.md | 172 +++++++++++++++++++++++++++++ examples/01_clean_and_describe.py | 61 ++++++++++ examples/README.md | 3 + src/mcm_toolkit/data/clean.py | 20 +++- tests/test_data.py | 40 ++++++- 8 files changed, 344 insertions(+), 51 deletions(-) create mode 100644 docs/algorithms/detect_outliers.md create mode 100644 examples/01_clean_and_describe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfaff3..60b78b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,20 @@ - `scripts/setup-conda.ps1` / `scripts/setup-venv.ps1` / `scripts/doctor.ps1` - `environment.yml`:需联网时的精确复现方案 - `pyproject.toml`:核心依赖只 4 个,其余走 extras +- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、 + 非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。 +- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读** +- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、 + `impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。 +- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、 + `impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。 +- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。 +- `docs/algorithms/detect_outliers.md`:**第一张 S 档函数卡片**(roadmap §3.1 要求 + 约 15 张,这是第 1 张)。除模板九节外,额外记了三条容易踩的判断:为什么不返回 `Result`、 + 非数值列恒 `False` 的两条备选路各错在哪、以及 `ddof` 口径与 `stats.zscore` 不一致。 +- `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` / + `impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测跑通(exit 0); + 末尾留 `TODO(L0)` 等 `stats.describe`。 ### Changed - `core.get_rng` / `core.set_seed`:实现(L0 第 2 项)。`get_rng` 收 `int` / `np.integer` / @@ -34,25 +48,12 @@ - `data.load_table` / `data.save_table`:docstring 补 `Parameters` / `Raises` (`docs/02-architecture.md` §4.7 对每个公开函数的硬要求)。 - `data.load_table` 的返回注解 `Any` → `pd.DataFrame`,与 docstring 的承诺对齐。 - -### Added -- `docs/02-architecture.md` §4.7「类型约定」:注解要写全、numpy 标量的判断写法、 - 非法类型必须抛 `TypeError`。配套在 `docs/03-roadmap.md` 的开发纪律里加了指针。 -- `docs/dev/personal-pitfalls.md`:个人易错点清单,**只追加、不通读** - -### Added -- `data.clean`:四个函数落地(**由用户本人实现**)——`clean_missing`(L0 第 4 项)、 - `impute_missing` / `detect_outliers` / `winsorize`(原属 L1,顺手写了)。 -- `tests/test_data.py`:新增 13 条 clean 行为测试,覆盖 `clean_missing` 的阈值语义与轴向、 - `impute_missing` 的 mean/ffill/mode 与全 NaN 列、`winsorize` 的分位点与异常类型。 -- `tests/test_data.py`:另加 7 条 `xfail`,把**已知但未修的契约**钉在测试报告里(见 Notes)。 - -### Changed - `data.clean`:docstring 按 `docs/02-architecture.md` §4.7 补全 Parameters / Returns / Raises (Codex 代写,与 io.py 同一套 numpydoc 格式)。 - `data.clean`:ruff 从 6 条清到 1 条——导入分组(I001)、elif 链改 if 加末尾无条件 return (RET505 / RET503)、去掉多余 f 前缀(F541)、删掉只会被覆盖的 mask 初始化(死代码)、 去掉行尾空白(W293)。 +- `pyright` 报"无法解析导入 numpy / pandas"(17 errors)的原因:分析器会去 PATH 上找 python, 本机 PATH 上只有 Codex 运行时的 3.14 shim(无科学栈),且 pyright 不认 `pyvenv.cfg` 的 `include-system-site-packages`。 改为 `[tool.pyright]` 指定 `venvPath = "."` / `venv = ".venv"` / @@ -96,14 +97,23 @@ (文本列必须恒 `False`);新增 `test_detect_outliers_all_text_frame_returns_all_false` 钉住"整表没有数值列"这条边界。测试 **58 passed / 2 xfailed → 60 passed / 1 xfailed**。 - `docs/dev/personal-pitfalls.md`:追加第 8 条(限制掩码 ≠ 限制统计量)。 +- `data.impute_missing` 的 mode 分支改为**返回新表**(原先 `data[col] = ...` 会就地改调用方的 + DataFrame,另外四支都返回新对象)。最后一条 `xfail` 随之转正,**测试里 `xfail` 清零**。 +- `data.detect_outliers` 补**入参类型校验**(§4.7:非法类型抛 `TypeError`,不静默兜底): + 接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`。`tuple` 同时补进 `is_ld_array`, + 与 `list` 同等对待;`dict` 不再被接受(以前靠 `pd.DataFrame()` 碰巧能过,见决策 33)。 +- `tests/test_data.py`:摘掉最后一条 `xfail`(mode 就地改)并加强断言;新增 3 条 + (非法类型抛 `TypeError`、元组按一维数组型处理、全 NaN 列与单元素列不抛异常)。 + 测试 **60 passed / 1 xfailed → 64 passed / 0 xfailed**。 + ### Notes - `detect_outliers` 原有的三个 bug 加一处设计空白**已全部收口**:`threshold` 范围校验挪进 `clean_missing`、`is_series` / `is_ld_array` 提到类型转换**之前**、统计量与掩码都限制在数值列、 - 非数值列恒 `False`(决策 32)。最后一条 `xfail` 已按 XPASS 信号摘掉。 - **仍缺 S 档函数卡片**(`docs/algorithms/`)与 `examples/` 最小用法 —— 见 `STATUS.md`「下一步」第 1/4 条。 -- `impute_missing` 的 mode 分支会**就地修改**调用方的 DataFrame,其余四个分支都返回新表。 - 同样属于"要选语义",留给用户本人写 —— 见 `STATUS.md`「下一步」第 2 条。 + 非数值列恒 `False`(决策 32)、入参类型校验补上(决策 33)。 + S 档卡片与 `examples/` 用法也已落地 —— `detect_outliers` 这条线**结清**。 +- 已实现函数的 `examples/` 最小用法仍不全:`00_quickstart.py` 要等 `stats` / `viz`, + `Result` 的用法届时并进去。见 `STATUS.md`「下一步」第 3 条。 - 已知缺口: - 除 `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 cfa7bfa..3176003 100644 --- a/STATUS.md +++ b/STATUS.md @@ -99,11 +99,19 @@ - 语义由用户本人定:**非数值列不参与统计,掩码里恒 `False`**(见决策 32) - 三轮修掉的 4 个 bug:`threshold` 范围校验放错函数、IQR 上界符号、 `is_series` / `is_ld_array` 判在类型转换之后(永远为 False)、文本列上算分位数直接崩 - - 最后这轮的关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...` + - 关键教训:**限制掩码 ≠ 限制统计量** —— 只把 `mask[numeric_cols] = ...` 收窄是不够的,`quantile()` / `mean()` / `std()` 也要一起收窄(见坑 8 / 个人易错点 8) - 返回形状与输入同形;Series 进 Series 出(列名保留)、一维数组进一维布尔数组出 - - 测试:`tests/test_data.py` 该函数共 11 条,含「全文本表返回全 False」这条边界 -- [ ] **待补:** `detect_outliers` 的 S 档函数卡片(`docs/algorithms/`)与 `examples/` 最小用法 + - **S 档函数卡片已写**:`docs/algorithms/detect_outliers.md` + (含"为什么不返回 `Result`"、非数值列恒 `False` 的两条备选路、`ddof` 口径不一致等) + - 测试:`tests/test_data.py` 该函数共 **9 条** +- [x] `data/clean.py` 的两处收尾(**Codex 代写,用户授权**) + - `impute_missing` 的 mode 分支改为**返回新表**(原先就地改调用方的表,与另外四支不一致) + —— 最后一条 `xfail` 随之转正,**`xfail` 清零** + - `detect_outliers` 补**入参类型校验**(§4.7 要求非法类型抛 `TypeError`): + 接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 不再接受(决策 33) +- [x] `examples/01_clean_and_describe.py`:`load_table` / `save_table` / `clean_missing` / + `impute_missing` / `detect_outliers` / `winsorize` 六个函数的最小用法,实测可直接跑通 - [ ] 剩余 7 项:`normalize` `standardize` `describe` `correlation_matrix` `bootstrap_ci` `monte_carlo` `save_figure` @@ -111,7 +119,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **60 passed, 42 skipped, 1 xfailed** | +| `pytest` | 见下(单列) | **64 passed, 42 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` | 跑通 | — | @@ -121,10 +129,10 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- 60 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 29 条 `data` 行为测试 -- 只剩 1 条 `xfail`:`impute_missing` 的 mode 分支就地改调用方的表。 - 这条**不是漏改,是要选语义**;修好后会自动变 XPASS,那时把 `xfail` 摘掉即可(决策 30) -- 累计 6 条 `xfail` 正是靠这个 XPASS 信号确认修好并摘掉的(本轮摘掉 1 条 + 撤销 1 条过严的判定) +- 64 条通过 = 20 条公开 API 契约测试 + 11 条 `core` 行为测试 + 33 条 `data` 行为测试 +- **`xfail` 清零**:累计 7 条"契约已写进 docstring、实现没跟上"的用例全部转正, + 决策 30 那套信号(XPASS → 摘 mark)跑完了一个完整闭环 +- `examples/01_clean_and_describe.py` 实测跑通(exit 0);`00_quickstart.py` 仍等 `stats` / `viz` - 42 条 skip 是等实现的算法(`normalize` / `standardize` / `train_test_split` 等) - 三个 `.ps1` 通过 PowerShell AST 语法解析 @@ -180,23 +188,24 @@ | 30 | 已知待修的契约用 `@pytest.mark.xfail(strict=False)`,不用 skip | skip 会让"契约还没写"和"写了但实现有问题"混在一起,5 条 skip 曾把 `clean.py` 的 bug 全藏住。xfail 照样进报告,修好后自动变 XPASS,是"修好了"的信号,而且 XPASS 不失败,不会卡住提交 | | 29 | 静态分析器钉死到工作区 `.venv`,并把 base 的 site-packages 用 `.pth` 链进去 | pyright / Pylance **不认** `pyvenv.cfg` 的 `include-system-site-packages`,`.venv` 自己只有 `pip`;不钉的话它们会去 PATH 上找 python,本机 PATH 上那个是 Codex 运行时的 3.14 shim,没有科学栈,于是报"无法解析导入"。`.pth` 是唯一被 pyright 认的转发手段 | | 28 | 不启用 ruff 的 preview 规则集 | `E302`(函数间要 2 个空行)在 ruff 0.16 里是 preview 规则,`select = ["E"]` 默认**不包含**它——所以少一个空行 ruff 照样报 "All checks passed"。行数空行得自己盯,别指望 lint | +| 33 | `detect_outliers` 只接受 `DataFrame` / `Series` / `list` / `tuple` / `ndarray`,`dict` 一律 `TypeError` | §4.7 要求非法类型抛 `TypeError` 而非静默兜底。`dict` 以前能"碰巧"被 `pd.DataFrame()` 转成表,但那不是文档承诺的形状,静默接受会让调用方以为传什么都行;`tuple` 则补进文档与 `is_ld_array`,与 `list` 同等对待 | | 32 | `detect_outliers` 对非数值列恒返回 `False`(既不跳过也不报错) | **用户本人在两条路里选的**。跳过会让 docstring 里"与原数据同形"变成谎话(形状对不上,没法按位置对回原表);报错则让一张带 `city` 列的真题表直接不可用。`False` 的读法是"这一列没有离群点",对文本列是最不坏的解释,也让整表能一次跑完 | | 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 自动跳过 | ## 下一步 -1. **写 `detect_outliers` 的 S 档函数卡片**(`docs/algorithms/` 下,照 `docs/dev/function-card-template.md`)。 - roadmap 要求 S 档先写卡片再写码 —— `detect_outliers` 是反着来的,于是 bug 全翻在 - "该想清楚"的地方:上界符号、布尔优先级、维度。卡片至少要把这几条钉住: - 两种方法的数学定义与失效场景、非数值列为什么恒 `False`、`k` / `threshold` 默认值从哪来、 - 以及"只标不删"这个设计选择。 -2. 顺手把 `impute_missing` 的 mode 分支改成**返回新表** - (现在就地改调用方的 DataFrame,和另外四个分支不一致;`data = data.copy()` 一行的事)。 - 注意它和 `detect_outliers` 恰好相反:那个函数全程只读,加 `copy()` 是纯浪费 —— - 同一个 `copy()`,一处必须要、一处必须不要。 -3. **下一个文件**:`data/transform.py` 的 `normalize` / `standardize` - (`tests/test_data.py` 里两条 skip 已就位;写之前先定 `method` 的取值集合与 `axis` 语义)。 -4. 补齐 `examples/`:`Result`、`load_table`、`clean_missing`、`detect_outliers` 都还差一行最小用法 - (roadmap §3.2 第 4 步)。 +1. **`data/transform.py` 的 `normalize` / `standardize`**(用户本人开始写) + - 签名已经定好(`normalize(x, method="minmax", feature_range=(0.0, 1.0))`、 + `standardize(x, ddof=0)`),动手前只需要定两件事:`normalize` 的 `method` 取值集合 + (默认 `"minmax"`;要不要再放 `"maxabs"` / `"robust"`?YAGNI 的话就只留 `"minmax"`)、 + 以及 `axis` 语义 —— 赛题里几乎总是**按列**,那就明确默认按列,别留模糊。 + - 注意 `standardize` 的 `ddof=0` 与 `detect_outliers` 里 pandas 默认的 `ddof=1` 不一致, + 两处都要在 docstring 里写明,否则以后对不上账(见 `docs/algorithms/detect_outliers.md` §5)。 + - `tests/test_data.py` 里两条 skip 已就位(`test_normalize_minmax_range`、 + `test_standardize_has_zero_mean_unit_std`)。 +2. `stats.describe` + `data/transform.py` 的 `log_transform` / `one_hot_encode` + (`examples/01_clean_and_describe.py` 末尾的 `TODO(L0)` 就等 `describe`)。 +3. 补齐 `examples/00_quickstart.py`:它要等 `stats` / `viz`;`Result` 的用法届时一并写进去。 +4. 每写完一个函数:docstring(§4.7 全套)+ 一条 pytest + `examples/` 一行用法 + 更新 `STATUS.md`。 5. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 diff --git a/docs/algorithms/README.md b/docs/algorithms/README.md index 784affd..3b20b45 100644 --- a/docs/algorithms/README.md +++ b/docs/algorithms/README.md @@ -7,7 +7,7 @@ S 档(自己实现的招牌算法)的**函数卡片**放这里,模板见 | 卡片 | 对应函数 | 状态 | |---|---|---| -| `detect_outliers.md` | `data.detect_outliers` | ⬜ | +| `detect_outliers.md` | `data.detect_outliers` | ✅ 2026-10-02 | | `bootstrap_ci.md` | `stats.bootstrap_ci` | ⬜ | | `monte_carlo.md` | `solve.monte_carlo` | ⬜ | | `monte_carlo_probability.md` | `solve.monte_carlo_probability` | ⬜ | diff --git a/docs/algorithms/detect_outliers.md b/docs/algorithms/detect_outliers.md new file mode 100644 index 0000000..ebf685b --- /dev/null +++ b/docs/algorithms/detect_outliers.md @@ -0,0 +1,172 @@ +# detect_outliers + +| 项 | 内容 | +|---|---| +| 模块 | `mcm_toolkit.data.clean` | +| 档位 | S | +| 参考来源 | Tukey (1977) *Exploratory Data Analysis*(1.5·IQR 围栏的出处);3σ 准则见 NIST/SEMATECH e-Handbook §1.3.5.17;实现直接调用 `pandas.DataFrame.quantile` / `mean` / `std` | +| 实现状态 | 已实现(2026-10-02),`tests/test_data.py` 中 9 条用例覆盖 | + +## 1. 数学定义 + +设某列数值样本 `x_1..x_n`,记 `Q_1 = quantile(0.25)`、`Q_3 = quantile(0.75)`, +四分位距 `IQR = Q_3 - Q_1`。对每个 `x_i` 给出布尔判定: + +**IQR 法**(默认): + +```text +m_i = (x_i < Q_1 - k·IQR) or (x_i > Q_3 + k·IQR) 默认 k = 1.5 +``` + +**z-score 法**: + +```text +z_i = (x_i - x̄) / s s 为样本标准差(ddof = 1) +m_i = |z_i| > t 默认 t = 3.0 +``` + +输入:一张表(`DataFrame`)或一维序列;输出:**与原数据同形的布尔掩码**, +`True` 表示该位置是离群点。数学上它是逐元素定义的二值函数,不依赖行与行之间的关系。 + +## 2. 适用场景 + +**用**: + +- 数据录入错误(身高 260 cm、年龄 −10)这类"明显不可能"的点,先标出来给人看。 +- 建模前的敏感性分析:删与不删各跑一遍,看结论动不动 —— 这比直接删更有说服力。 +- 论文要交代"异常值怎么处理"时,这是最容易被评审接受的判据(有文献、可复现、无随机性)。 + +**不用**: + +- **别拿它当自动清洗**。偏态、幂律、小样本(`n < 20`)时 IQR 围栏会误杀 —— + 重尾数据里"离群"常常就是信号本身。 +- z-score 法**前提是近似正态**。右偏数据(收入、人口、成绩分布)用它会把整片上尾标红。 +- 时间序列里的"离群"可能来自趋势或季节项,应先去趋势/差分再判,否则会把拐点当异常。 +- 多变量离群(单变量看都正常、组合起来才异常)它看不出来,那要用马氏距离或 `IsolationForest`。 + +## 3. 参数与返回值 + +| 参数 | 类型 | 默认 | 含义 | 取值影响 | +|---|---|---|---|---| +| `data` | `DataFrame` / `Series` / `list` / `tuple` / `ndarray` | 必填 | 待判数据 | 非数值列不参与统计,掩码里恒 `False`(见下) | +| `method` | `"iqr"` / `"zscore"` | `"iqr"` | 判据 | IQR 对重尾稳健;zscore 只在近似正态时可靠 | +| `k` | `float` | `1.5` | 仅 IQR:围栏倍数 | 1.5 是 Tukey 的 "outlier"、3.0 是 "far outlier";调小 → 标出更多 | +| `threshold` | `float` | `3.0` | 仅 zscore:\|z\| 阈值 | 3.0 ≈ 正态下的 0.27%;调小 → 标出更多 | + +`k` 与 `threshold` 通过 `**kwargs` 传入,**传错了不会报错**(`k` 只在 iqr 分支被读)。 + +### 为什么它不返回 `Result` + +模板里"返回 `Result`"这一节对它**不适用**,这是刻意的:`Result` 承载的是"一次计算的 +参数 + 指标 + 数据",而 `detect_outliers` 不改数据、不产生标量指标,它只是一个逐元素的 +布尔判断。包成 `Result` 会多一层壳,调用方反而要写 `r.data` 才能拿到掩码。 + +返回值按输入形状分派: + +| 输入 | 输出 | +|---|---| +| `DataFrame` | 同形布尔 `DataFrame` | +| `Series` | 布尔 `Series`,**列名保留** | +| 一维 `list` / `tuple` / `ndarray` | 一维布尔 `ndarray` | +| 二维 `ndarray` | 同形布尔 `DataFrame` | + +### 非数值列为什么恒 `False` + +两条备选路都被否掉了: + +- **跳过**(把非数值列从掩码里去掉)→ docstring 承诺的"与原数据同形"就是假话, + 形状对不上,也就没法按位置把掩码和原表对回去; +- **报错** → 一张带 `city` 列的真题表直接不可用,而 `city` 列本来就不该影响数值列判定。 + +`False` 的读法是"这一列没有离群点",对文本列而言这是最不坏的解释, +而且让整表能一次跑完。**但要注意**:这只是"不报错", +不代表文本列可以当数值列进模型 —— 编码是 `one_hot_encode` 的事。 + +## 4. 复杂度 + +- 时间:`O(n·p)`(n 行、p 列;分位数和均值各扫一遍,比较再扫一遍)。 +- 空间:`O(n·p)` —— 掩码与原表同形,这是主要开销。 +- **什么时候会跑不完**:单机几千万行以上时,同形掩码会把内存吃满 + (1e7 行 × 20 列 `float64` 约 1.6 GB,掩码再来一份 `bool` 约 0.2 GB, + 但 `quantile` 内部还会再复制)。真到那一步应该分块流式处理,而不是加内存。 + +## 5. 数值稳定性 + +- **`std == 0`(整列同值)**:代码里先 `std.replace(0, np.nan)`,让该列 z 全为 NaN、 + 一律判 `False`。少这一步会得到 `inf` / `0/0` 并伴随 RuntimeWarning。 +- **NaN**:比较结果是 `False`,所以缺失值**不会**被标成离群。这是有意的 —— + 缺失归 `clean_missing` / `impute_missing` 管,两件事不该混。 +- **`n = 1`**:`std` 是 NaN、分位数等于该值本身,掩码全 `False`。小样本别指望它。 +- **边界值**:`x_i` 恰好等于围栏值时**不算**离群(用的是严格 `<` / `>`)。 + 这是刻意选的:边界值不该被当异常。 +- **`ddof` 口径**:本函数用 pandas 的 `Series.std()`,默认 `ddof = 1`(样本标准差), + 而 `numpy.std` 默认 `ddof = 0`(总体标准差)。小样本下差别不小:`[1,2,3,4,1000]` 的 + std 是 446.1(ddof=1)对 399.0(ddof=0),z 值直接差一成。**本项目 `stats.zscore` 的默认 + 也是 `ddof = 0`**,两处口径不一致是已知问题 —— 论文里报 |z| 时必须写明用的哪一个。 + (分位数这边反而没问题:pandas `quantile` 与 `numpy.percentile` 默认都是线性插值,结果一致。) + +## 6. 常见错误 + +1. 把返回值当筛选结果:`df = detect_outliers(df)` → 表变成布尔掩码。 + 正确做法是 `mask = detect_outliers(df)`,然后**自己决定**删不删。 +2. 对含文本列的真题表直接调用 → 老版本在 `quantile()` 上崩。现在非数值列恒 `False`, + 但要记得"不崩"不等于"文本列进了模型"。 +3. 用 z-score 判右偏数据 → 整片上尾被标红,删完结论就变了。先画分布再选方法。 +4. 看到离群就删 → 论文里没有交代、评审必问。先报告个数与占比,再做删/不删对照。 +5. 只报"删了 5 个" → 没给比例、没给判据、没给 `k`。论文里这三个数缺一不可。 +6. 以为要设 seed → 本函数**不含随机成分**;但后续若用 bootstrap 比较"删前后"的差异, + 那条链必须 `set_seed`,否则"可复现"是假的。 + +## 7. 最小示例 + +```python +import numpy as np +import pandas as pd + +from mcm_toolkit.data import detect_outliers + +df = pd.DataFrame({"city": list("abcd"), "v": [1.0, 2.0, 3.0, 1000.0]}) + +mask = detect_outliers(df) # IQR,默认 k=1.5;只标不删 +print(mask) +# city v +# 0 False False +# 1 False False +# 2 False False +# 3 False True + +print(np.asarray(mask).sum()) # 1 +print(detect_outliers(df["v"]).tolist()) # Series 进 Series 出 +# [False, False, False, True] + +# 想改成更激进的判定:k 调小 +print(np.asarray(detect_outliers(df, k=0.5)).sum()) +``` + +## 8. 单元测试要点 + +| 测什么 | 期望 | +|---|---| +| 埋 3 个远离主体的点(IQR 默认) | 正好命中 3 个,且位置对得上 | +| 埋 1 个点(zscore 默认 t=3) | 正好命中 1 个 | +| 返回形状 | 与输入同形;Series 进 Series 出且列名保留;一维数组进一维布尔出 | +| 含文本列的表 | 不崩,文本列恒 `False`,数值列照常判定 | +| 整表没有数值列 | 不崩,返回全 `False` 掩码 | +| `method="bogus"` | `ValueError`,报错信息里带方法名 | +| `data = 42 / 3.14 / "123" / None / dict` | `TypeError`(§4.7:非法类型不静默兜底) | +| 全 NaN 列、单元素列 | 全 `False`,不抛异常 | +| 元组入参 | 按一维数组型处理,结果与传 list 一致 | + +## 9. MCM/ICM 用法 + +- **哪类题**:所有要写"数据预处理"小节的题(几乎全部)。评价类与预测类尤其要做, + 因为离群点对均值、回归系数的影响最大。 +- **论文里怎么表述**: + "采用 Tukey (1977) 的 1.5×IQR 围栏识别异常值:记 Q1、Q3 为第一、三四分位数, + 凡落在 [Q1 − 1.5·IQR, Q3 + 1.5·IQR] 之外者标为可疑点,共 N 个(占 x%)。 + 我们保留全部原始数据,并给出剔除/不剔除两版对照结果(见附录 B)。" +- **图**:箱线图(标出被标红的点)+ 直方图叠围栏竖线;用 `viz.plot(kind="box")` + 与 `viz.plot(kind="hist")`,保存用 `viz.save_figure`。 +- **踩坑提醒**:评审最可能追问三件事 —— + ① 为什么是 1.5 而不是 3?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"? + 三个问题都要在论文里先答,别等被问。 diff --git a/examples/01_clean_and_describe.py b/examples/01_clean_and_describe.py new file mode 100644 index 0000000..e54a8c0 --- /dev/null +++ b/examples/01_clean_and_describe.py @@ -0,0 +1,61 @@ +"""data 包最小用法:读 → 清 → 补 → 判离群 → 缩尾。 + +每一步都对应 `tests/test_data.py` 里的一条用例;体量刻意压到最小。 +`stats.describe`(描述统计)还没实现,最后留了 TODO(L0) 的位置。 +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import pandas as pd + +import mcm_toolkit as mcm + + +def main() -> None: + mcm.set_seed(20260101) + + # 1) 假装这是从 Excel 导出的赛题数据:带文本列、带缺失、带两个录入错误 + df = pd.DataFrame( + { + "city": ["A", "B", "C", "D", "E", "F"], + "gdp": [1000.0, 1100.0, np.nan, 1300.0, 1400.0, 9999.0], + "pop": [10.0, 11.0, np.nan, np.nan, 20.0, 15.0], + "notes": ["ok", "ok", np.nan, "ok", np.nan, "异常"], + } + ) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "demo.csv" + + # 2) 写盘再读回:load_table 按扩展名分派,.csv 会自动探测编码 + mcm.data.save_table(df, path) + loaded = mcm.data.load_table(path) + print(f"读回 {loaded.shape[0]} 行 × {loaded.shape[1]} 列") + + # 3) 缺失太多的行先删:缺失比例 > 0.5 的走人(4 列,阈值 0.5 -> 至少要 2 个非空) + kept = mcm.data.clean_missing(loaded, axis=0, threshold=0.5) + print(f"按缺失比例删行:{loaded.shape[0]} -> {kept.shape[0]}") + + # 4) 剩下的缺失做填补。mean 只作用于数值列,notes 列原样保留缺失 + filled = mcm.data.impute_missing(kept, method="mean") + print("填补后每列缺失数:", filled.isna().sum().to_dict()) + + # 5) 找离群点:只标不删,删不删由人决定 + mask = mcm.data.detect_outliers(filled, method="iqr", k=1.5) + print("检出离群点:") + print(mask) + print(f"共 {int(mask.sum().sum())} 个;notes 列恒 False(非数值列不参与统计)") + + # 6) 不删也能用:缩尾把极端值压到分位点上,而不是丢掉整行 + 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"]]) 还没实现,实现后在这里加一段描述统计。 + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 907bd3f..c4749d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,9 @@ > **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通 > (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。 +> +> `00_quickstart.py` 与 `01_clean_and_describe.py` 用到的函数大多已实现,可以跑; +> 两者末尾各有 `TODO(L0)`,等 `stats` / `viz` 补齐。 | 文件 | 对应模块 | 覆盖的 L 档 | |---|---|---| diff --git a/src/mcm_toolkit/data/clean.py b/src/mcm_toolkit/data/clean.py index 3fe45f2..bf2aa86 100644 --- a/src/mcm_toolkit/data/clean.py +++ b/src/mcm_toolkit/data/clean.py @@ -109,12 +109,15 @@ def impute_missing(data: Any, method: str = "mean", **kwargs: Any) -> Any: if method == 'median': return data.fillna(data.median(numeric_only=True),**kwargs) if method == 'mode': - mode_df = data.mode() - for col in data.columns: + # 只有这一支要逐列赋值,所以在这里单独复制一份,别就地改调用方的表 + # (其余四支走 fillna / ffill / interpolate,本来就返回新对象)。 + result = data.copy() + mode_df = result.mode() + for col in result.columns: if mode_df[col].empty: continue - data[col] = data[col].fillna(mode_df[col].iloc[0]) - return data + result[col] = result[col].fillna(mode_df[col].iloc[0]) + return result if method == 'ffill': return data.ffill(**kwargs) return data.interpolate(**kwargs) @@ -126,7 +129,7 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg Parameters ---------- data: - `pandas.DataFrame` / `pandas.Series`,或能转成表的数组型。 + `pandas.DataFrame` / `pandas.Series`,或 `list` / `tuple` / `numpy.ndarray`。 method: `"iqr"`:Q1-1.5·IQR 之外(`k` 可调,默认 1.5); `"zscore"`:|z| > threshold(默认 3.0)。 @@ -139,6 +142,8 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg Raises ------ + TypeError + `data` 既不是 `pandas.DataFrame` / `pandas.Series`,也不是 `list` / `tuple` / `numpy.ndarray`。 ValueError `method` 既不是 `"iqr"` 也不是 `"zscore"`。 @@ -156,8 +161,11 @@ def detect_outliers(data: Any, method: Literal["iqr", "zscore"] = "iqr", **kwarg >>> bool(mask[-1]) True """ + if not isinstance(data, (pd.DataFrame, pd.Series, list, tuple, np.ndarray)): + raise TypeError(f"data必须是pandas DataFrame/Series 或数组型,你传入了{type(data)}") + is_series = isinstance(data, pd.Series) - is_ld_array = isinstance(data, (list, np.ndarray)) and np.array(data).ndim == 1 + is_ld_array = isinstance(data, (list, tuple, np.ndarray)) and np.array(data).ndim == 1 if isinstance(data,pd.Series): data = data.to_frame() diff --git a/tests/test_data.py b/tests/test_data.py index 86c427e..595ca95 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -238,14 +238,43 @@ def test_detect_outliers_rejects_unknown_method(): detect_outliers([1.0, 2.0], method="bogus") # type: ignore[arg-type] -# ------------------- 契约先写进 docstring,实现逐条跟上(剩余 1 条 xfail) +def test_detect_outliers_rejects_bad_type(): + """§4.7:类型不对必须抛 TypeError,不能让它一路飘进 pandas 里去。""" + from mcm_toolkit.data import detect_outliers + + for bad in (42, 3.14, "123", None, {"a": [1, 2]}): + with pytest.raises(TypeError): + detect_outliers(bad) + + +def test_detect_outliers_survives_degenerate_columns(): + """全 NaN 列与单元素列都不能抛异常,判定一律为 False。""" + from mcm_toolkit.data import detect_outliers + + df = pd.DataFrame({"all_nan": [np.nan, np.nan, np.nan], "one": [1.0, 2.0, 3.0]}) + assert not bool(np.asarray(detect_outliers(df)).any()) + assert not bool(np.asarray(detect_outliers([5.0])).any()) + assert not bool(np.asarray(detect_outliers([5.0], method="zscore")).any()) + + +def test_detect_outliers_accepts_tuple_as_1d(): + """元组按一维数组型处理,返回值与传 list 一致。""" + from mcm_toolkit.data import detect_outliers + + mask = np.asarray(detect_outliers((1.0, 2.0, 3.0, 4.0, 1000.0))) + assert mask.shape == (5,) + assert mask.dtype == bool + assert bool(mask[-1]) + + +# ------------------------ 曾经 xfail 的契约,现已全部实现(留作回归) # # 这些用例写的是**文档里已经承诺的行为**。实现没跟上时用 xfail,让它们照样出现在 # 报告里(显示成 x);修好之后会自动变成 XPASS —— 看到 XPASS 就把 mark 摘掉。 # # 已摘掉:threshold 范围校验、IQR 的 UnboundLocalError、(n,1) 形状、zscore 默认阈值、 -# winsorize 的文本列、detect_outliers 的文本列。剩 1 条不是漏改,是**设计还没定**: -# - impute_missing 的 mode 分支该不该就地改调用方的表(其余分支都返回新表) +# winsorize 的文本列、detect_outliers 的文本列、impute_missing 的 mode 就地修改。 +# **当前 0 条 xfail** —— 以后新增契约照旧:先写测试 + xfail,实现跟上就摘 mark。 def test_detect_outliers_finds_planted_points(): @@ -315,12 +344,13 @@ def test_clean_missing_rejects_out_of_range_threshold(): clean_missing(pd.DataFrame({"a": [1.0, np.nan]}), threshold=1.5) -@pytest.mark.xfail(reason="mode 分支在调用方的表上就地改,其余分支都返回新表", strict=False) def test_impute_missing_mode_does_not_mutate_input(): + """mode 分支也必须返回新表:不能顺手把调用方的表改了。""" from mcm_toolkit.data import impute_missing df = pd.DataFrame({"a": [1.0, np.nan, 1.0]}) - impute_missing(df, method="mode") + out = impute_missing(df, method="mode") + assert out["a"].tolist() == [1.0, 1.0, 1.0] assert bool(df["a"].isna().any())