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