实现收尾(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 那条后面
8.7 KiB
8.7 KiB
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| 时必须写明用的哪一个。 (分位数这边反而没问题:pandasquantile与numpy.percentile默认都是线性插值,结果一致。)
6. 常见错误
- 把返回值当筛选结果:
df = detect_outliers(df)→ 表变成布尔掩码。 正确做法是mask = detect_outliers(df),然后自己决定删不删。 - 对含文本列的真题表直接调用 → 老版本在
quantile()上崩。现在非数值列恒False, 但要记得"不崩"不等于"文本列进了模型"。 - 用 z-score 判右偏数据 → 整片上尾被标红,删完结论就变了。先画分布再选方法。
- 看到离群就删 → 论文里没有交代、评审必问。先报告个数与占比,再做删/不删对照。
- 只报"删了 5 个" → 没给比例、没给判据、没给
k。论文里这三个数缺一不可。 - 以为要设 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?② 删了多少、删完结论变了吗?③ 这些点是"错误"还是"真实极端值"? 三个问题都要在论文里先答,别等被问。