Files

172 lines
8.7 KiB
Markdown
Raw Permalink Normal View History

# 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?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
三个问题都要在论文里先答,别等被问。