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

8.7 KiB
Raw Permalink Blame 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 法(默认):

m_i = (x_i < Q_1 - k·IQR)  or  (x_i > Q_3 + k·IQR)     默认 k = 1.5

z-score 法:

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. 最小示例

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