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:
1 parent
b6dc4bc5a6
commit
5749e50e83
8 files changed
+344
-51
No files matched your search
@@ -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?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"?
|
||||
三个问题都要在论文里先答,别等被问。
|
||||
Reference in new issue
Block a user