chore: 补 S 档卡片 / examples;clean.py 收尾,xfail 清零

实现收尾(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 那条后面
This commit is contained in:
guzhujushi committed 2026-10-02 15:48:12 +08:00
1 parent b6dc4bc5a6
commit 5749e50e83
8 files changed
+344 -51

No files matched your search

+28 -18
View File
@@ -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 顺序填实现。
+30 -21
View File
@@ -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)走完整流程,反向沉淀函数。
+1 -1
View File
@@ -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` | ⬜ |
+172
View File
@@ -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?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
三个问题都要在论文里先答,别等被问。
+61
View File
@@ -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()
+3
View File
@@ -4,6 +4,9 @@
> **当前状态**:这些脚本是**用法规格**,函数实现填好之前跑不通
> (会抛 `NotImplementedError`)。实现顺序见 `docs/03-roadmap.md`。
>
> `00_quickstart.py` 与 `01_clean_and_describe.py` 用到的函数大多已实现,可以跑;
> 两者末尾各有 `TODO(L0)`,等 `stats` / `viz` 补齐。
| 文件 | 对应模块 | 覆盖的 L 档 |
|---|---|---|
+14 -6
View File
@@ -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()
+35 -5
View File
@@ -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())