Files
guzhujushi 5749e50e83 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 那条后面
2026-10-02 15:48:12 +08:00

173 lines
8.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
三个问题都要在论文里先答,别等被问。