MAD稳健统计:彻底解决数据清洗中Z-score漏检异常值的难题
2026/9/15 18:22:30 网站建设 项目流程

做数据清洗的这几年,我碰到过很多次让人挠头的情况:一列数值里肉眼可见有几个点不对劲,比如订单金额里混进一个几十万的测试单、传感器温度序列里冒出一个-9999,可是用Z-score跑一遍,这些点居然全都“合格”了,一个都检不出来。最开始我觉得是阈值没调好,后来才发现,问题出在“均值+标准差”这套参数本身就不够稳健。

后来换用MAD(Median Absolute Deviation,绝对中位差)做基于中位数的稳健Z-score,才算是把这类“漏网之鱼”一网打尽。这篇不是教科书式地复述定义,我尽量按照实际项目里的处理思路,把MAD为什么能扛住异常值、怎么用Python落地、有哪些坑会踩以及怎么绕过去,一次说清楚。

1. 先搞懂为什么Z-score和IQR会在异常值面前失灵

1.1 一个把Z-score“骗过去”的经典数据

Z-score的公式很简单:Z = (x - μ) / σ,通常认为绝对值大于3的点是异常值。但这里有个隐蔽的循环依赖:计算均值和标准差的时候,异常值本身也参与了计算。

我拿一组数据举例:[1, 2, 3, 4, 100]。这组数里100显然是个异常点。

  • 均值 μ = (1 + 2 + 3 + 4 + 100) / 5 = 22
  • 标准差 σ ≈ sqrt(((1-22)^2 + (2-22)^2 + (3-22)^2 + (4-22)^2 + (100-22)^2) / 5) ≈ 43.4
  • 100的Z-score = (100 - 22) / 43.4 ≈ 1.797

1.797离3差得远。也就是说,异常值把均值和标准差都拉大了,结果它的Z-score反而被“稀释”了,自己也跟着变得“正常”。这种情况在真实数据里太常见了:只要异常值够大或够多,Z-score就会失去敏感性。

1.2 IQR法的两个盲区

Z-score失灵之后,很多人会转向IQR(四分位距)法:计算第一四分位数Q1和第三四分位数Q3,把小于Q1 - 1.5*IQR或大于Q3 + 1.5*IQR的点视为异常。

IQR法比Z-score稳健一些,因为四分位数对极端值不那么敏感。但它有两个盲区:

盲区一:对偏斜分布的低估。IQR只用了25%和75%两个分位数,它本身不关心分布两侧的尾巴形状。数据严重右偏时,右侧尾巴本来就长,按1.5倍IQR去卡,可能漏掉右边一大片“虽然长尾但其实是正常业务波动”的点,也可能把左边本来集中分布的正常点误杀。总之,它拿一个固定倍数去套所有分布,效果要看运气。

盲区二:异常值成簇时的掩蔽效应。当异常值不是零星一两个,而是聚成一小簇的时候,它们会把Q1、Q3甚至IQR本身都往外推,导致异常值的判定边界被抬高,结果这一簇异常值反而被当作正常值的一部分。这就叫掩蔽效应(masking effect),在设备故障数据里特别常见——坏的那几分钟连续输出一串离群点。

1.3 MAD的漂亮之处:把均值和标准差整体换掉

MAD的思路,简单来说就是把Z-score里的“均值”换成“中位数”,把“标准差”换成“绝对中位差”。中位数对异常值的抵抗力远强于均值,绝对中位差对异常值的抵抗力也远强于标准差。所以用它俩构造出的统计量,天然不容易被异常值污染。

直观上理解:一群人里有人身高突然长到3米,你问“大家平均身高多少”,会被这个3米怪拉高;但如果你问“大家身高的中位数是多少”,站在队伍中间那个人多高就是多高,3米怪基本影响不了。MAD干的就是后一件事——先找到队伍中间那个人的高度,再去看每个个体偏离中间那个人的“典型距离”是多少,这个“典型距离”也取中位数,而不是平均。

2. 绝对中位差的定义与1.4826修正系数的来历

2.1 一步步算出一个MAD

MAD的数学定义是:

MAD = median(|x_i - median(x)|)

也就是说,先求整列数据的中位数,然后计算每个点与中位数的绝对偏差,再对这些绝对偏差取中位数。手搓一遍上面的例子:[1, 2, 3, 4, 100]

  • 第一步,中位数 median = 3
  • 第二步,绝对偏差:|1-3|=2、|2-3|=1、|3-3|=0、|4-3|=1、|100-3|=97
  • 第三步,绝对偏差排序后的中位数是1,所以 MAD = 1

你看,MAD本身只有1,说明除了100之外,其他点距离中心3的“典型偏差”也就1。于是当100出现时,它的偏离程度是97倍MAD,异常非常明显。

2.2 常数1.4826不是魔法,是正态分布下的标定

很多工具里会把MAD乘以一个系数再用,最常见的就是乘以1.4826。这个数字容易让人觉得神秘,其实来源很朴素:为了让MAD在正态分布下能够作为标准差的稳健估计量。

正态分布中,一个点落在中位数(也就是均值)左右0.6745个标准差范围内的概率是50%。换句话说,理论上正态分布数据会有一半的点落在μ ± 0.6745σ内,另一半落在外面。所以从MAD的角度看,这些点距离中位数的中位绝对偏差约等于0.6745σ。反过来,σ ≈ MAD / 0.6745 ≈ 1.4826 * MAD

把MAD乘上1.4826之后,算出的尺度就和标准差在同一个量纲上了。这样我们还能沿用“3倍”这个习惯:用|x - median| / (1.4826 * MAD)构造稳健Z-score,超过3就视为异常。

下表总结了两个口径的区别:

指标公式稳健性正态分布下与σ的关系
标准差σsqrt(mean((x-μ)^2))σ
MADmedian(|x - median(x)|)0.6745σ
1.4826*MAD1.4826 * median(|x - median(x)|)σ

2.3 MAD与标准差、IQR的横向对比

从应用角度,我更愿意把这三个指标放在一起看:

  • 标准差:适合数据干净、没有明显异常值、接近正态分布的场景。一旦数据里有脏点,它自己就先被污染。
  • MAD:适合异常值比例不太高(通常不超过50%)的任意连续数值列,尤其是你不想逐个看分布、希望自动识别尖峰异常时。
  • IQR:更关注分布中间50%的区间,常和箱线图一起用,但公式里的1.5倍是经验值,换分布就要重新调试。

MAD不是万能药,但在“稳健性优先”的数据清洗场景里,它是第一梯队的选择。

3. Python实现:把MAD封装成一个不删数据的清洗函数

3.1 造一份带尖峰的数据

实战里没人会拿一行数让你算,更多是面对一整个DataFrame。我先造一份模拟传感器温度的数据:正常情况下在20到30度之间波动,中间注入几个极端尖峰。

import numpy as np import pandas as pd rng = np.random.default_rng(42) n = 200 temp = rng.normal(25, 2.0, n) # 注入几个异常点 temp[10] = 80.0 temp[50] = -20.0 temp[120] = 95.0 df = pd.DataFrame({ 'ts': pd.date_range('2024-01-01', periods=n, freq='5min'), 'temp': temp })

如果你现在用普通Z-score跑,大概率只能检出80、-20、95这几个点里非常极端的一个,而用MAD能稳定地把它们全部挑出来。原因前面已经解释过。

3.2 稳健Z-score函数的核心代码

我习惯把MAD封装成一个独立函数,不做原地删除,只返回一个布尔掩码(mask)。这样做的好处是:中间结果可以留痕,后续想换成填充、插值或者剔除都方便。

def mad_score(x, k=3.0): median = np.nanmedian(x) mad = np.nanmedian(np.abs(x - median)) if mad == 0: # 全列完全相同或只有两种极端值,MAD退化 return np.zeros_like(x, dtype=bool) modified_z = 0.6745 * (x - median) / mad return np.abs(modified_z) > k

注意这里我用的是0.6745 * (x - median) / mad这个等价形式。有些资料写成(x - median) / (1.4826 * mad),两者完全一样,只是系数位置不同。

对刚才的DataFrame:

df['is_outlier'] = mad_score(df['temp'].values, k=3) outliers = df[df['is_outlier']] print(outliers)

k=3时,三个注入点基本都能检出来,少数临界值可能会被标记,这需要结合业务判断。

3.3 为什么“标记异常”比“删除异常”更重要

很多新手拿到mask第一反应是df = df[~mask]把异常行删掉。我强烈不建议上来就直接删。

异常值本身携带信息。它可能是传感器故障、录入错误,也可能是真实业务里的极端事件(比如大促瞬间爆单、突发流量)。一旦删掉,后面再想追溯就难了。

我的做法是在数据里保留一个is_outlier字段,然后分三步:

  1. 先看被标记的行是不是人工也认为异常;
  2. 再决定是剔除、填充中位数,还是单独拆出来做二次分析;
  3. 最后把清洗规则沉淀成配置,下次跑批自动带出。

如果非要填充,可以用中位数或前后合法值插值:

df.loc[df['is_outlier'], 'temp_clean'] = np.nan df['temp_clean'] = df['temp_clean'].ffill() # 或者中位数填充

3.4 处理NaN和全零列这两个边界

MAD在真实数据里最容易翻车的两个点是NaN和MAD=0。

np.nanmedian可以自动跳过NaN,但如果整列全是NaN,返回的也是NaN,后面比较就会全False。所以在函数入口最好先判断非空值数量,少于一定阈值直接返回全False。

另一个常见问题是:数据里有大量0值,比如统计某商品的销售数量,一天里大部分小时都是0,偶尔有几笔交易。这种情况下中位数是0,绝对偏差的中位数也是0,mad == 0,任何非零点都会被无限大的Z-score标记为异常,这显然不合理。

我的处理方式是把mad == 0的列单独拎出来:要么用业务规则过滤(比如销量>0就是正常),要么先只在非零子集里做MAD,要么给MAD加一个极小值的下限epsilon(比如1e-6),但加下限容易产生沉淀误差,没有业务依据时慎用。

4. 时间序列里的滚动MAD与分组MAD:别把正常变化误杀

4.1 直接把全序列中位数作基准,会把趋势末端的正常值判为异常

MAD是全局统计量,它假设整列数据围绕同一个中心波动。但真实时间序列往往有趋势和季节性:早上气温低、下午气温高,销量工作日低、周末高。如果你对整个序列算一个全局中位数,那下午的高温、周末的高销量都会因为偏离全局中心,被误判成异常。

这类问题最典型的例子是用户行为监控:一个产品上线后日活稳步增长,半年前的数据均值很低,最新一天的数据对比全局中位数肯定是“偏离的”,但那是正常增长,不是故障。所以处理时间序列之前,需要先把“整体中心”这个概念局部化。

4.2 滚动窗口MAD的写法与窗口选择

针对时间序列,更合适的做法是用滚动窗口:在每个时间点附近取一个滑动窗口,在窗口内计算中位数和MAD,再看当前点是否偏离局部中心。

Pandas里rolling没有现成的MAD方法,但可以通过两步近似实现:

def rolling_mad_series(s, window=30, k=3.0): local_median = s.rolling(window, center=True).median() abs_dev = (s - local_median).abs() local_mad = abs_dev.rolling(window, center=True).median() local_scale = 1.4826 * local_mad z = 0.6745 * (s - local_median) / local_mad return np.abs(z) > k

窗口大小的选择,我自己的经验是取“一个完整业务周期”的1.5到2倍。比如监控分钟级流量,一天有1440个点,窗口可以取2880;如果你只想抓短时抖动,窗口取30到60即可。窗口太小,MAD的估计波动很大,容易把正常点误杀;窗口太大,又会退化成全局MAD,失去局部意义。

另外注意center=True会在序列头尾产生NaN窗口,通常的做法是这些位置不做异常判断,或者用扩展窗口(.expanding())过渡。

4.3 分组数据用groupby.transform保持原索引

除了时间维度,分组维度也很常见:多个店铺、多款商品、多个区域的数据混在一起。不同组的正常水平完全不同,比如单价1000元的商品和单价20元的商品混在一起,全局MAD基本没法用。

正确做法是按组分别计算MAD:

df['group_median'] = df.groupby('sku')['price'].transform('median') def group_mad(x): return np.nanmedian(np.abs(x - x.median())) df['group_mad'] = df.groupby('sku')['price'].transform(group_mad) df['is_outlier'] = ( 0.6745 * (df['price'] - df['group_median']) / df['group_mad'] ).abs() > 3

注意transform会保持和原DataFrame相同的索引,这样得到的mask对齐得很稳,不会因为分组排序产生错位。这一步踩坑的人很多——有人用groupby().apply()返回一个按组长度拼接的结果,没对齐索引,后面join的时候错得莫名其妙。

4.4 零值过多导致MAD为0时的处理办法

分组之后再叠加“零值过多”会更麻烦。比如不同店铺的订单量,有些店晚上完全没订单,连续几小时都是0,中位数是0,MAD也是0。如果直接用公式,任何一笔非零订单都会被标成异常。

我在实际项目里遇到这种场景,一般先做一步业务约束:如果某组内MAD=0,说明该组数据在窗口内几乎没有波动,那就不应该用统计方法去判断,而是看“非零值是否真的不合理”。比如门店A每天只卖个位数订单,偶尔来一笔10单的订单,在统计上可能像异常,但业务上完全正常。这时候我会把阈值k调大,或者在组内用一个最小业务阈值垫底。统计方法说到底,是帮我们缩小怀疑范围,而不是替代业务判断。

5. 一次MAD“失灵”故障完整复盘

5.1 现象:该检出的异常一个没检出来

有一段时间我维护的一个监控报表里,某个指标需要从日志里提取。业务方反馈说“最近数据明显有问题,凌晨有一个小时的数据比其他天高了好几倍”,但我跑的MAD清洗脚本一个点都没标记出来。

我第一反应是不信,于是调出原始数据,肉眼直接看到了那个高得离谱的小时。既然肉眼都能看见,为什么MAD没检出来?

5.2 排查链路:从NaN到分布形态再到掩蔽效应

我按下面这条链路一步步排查:

第一步,检查数据里是否有NaN。我的函数用的是np.nanmedian,NaN会被跳过,但如果某个窗口的全部值都是NaN,返回的就是NaN,比较结果全是False。检查后,当天数据中有些小时确实为空,但异常点本身不是NaN,排除。

第二步,打印中位数和MAD。发现中位数本身已经被抬高了很多。原来那几天数据整体有上行趋势,凌晨那个点虽然比平时高,但它所在的一天整体都偏高,全局中位数也被拉到接近它的位置。问题回到前面说的“非平稳序列”,全局MAD在这种情况下会产生偏差。于是改用滚动窗口MAD重新跑。

第三步,滚动窗口跑完还是没检出来。这次我发现窗口里的MAD也变大了——因为那个异常点所在窗口里,还有几个相近的偏高值。它们不是同一个尖峰,而是连续几个小时都在爬坡。几个“偏高但不算离谱”的点凑在一起,把局部MAD抬高了,尖峰的相对高度就变小了。这就是典型的掩蔽效应。

第四步,减少窗口内的“噪声”。我先把窗口缩小到30分钟(业务上30分钟内的突增更有意义),再对原始序列做一次一阶差分,把趋势项去掉,然后在差分的残差上做MAD。差分之后,连续爬坡变成了零附近的小波动,真实尖峰就变得非常突出,最终成功检出了所有问题时段。

5.3 复盘总结:MAD适合处理什么样的异常

这次故障给我留下一个很深的印象:MAD擅长捕捉的是“在局部平稳区域内相对孤立的小尖峰”,它不擅长处理“整体水平平移”或“缓慢爬坡”。分不清这两种情况,就会在时间序列上翻车。

所以我后来在项目里定了一条规则:先对序列做平稳性检查,或者直接统一先做一阶差分再跑MAD;如果业务上关心的是水平突变而不是营养上升,那MAD的输入就不能用原始值,而应该用变化量。

6. 把MAD放进真实清洗流程:搭配、改进和验证

6.1 业务规则比统计方法优先级更高

统计方法再强,也代替不了业务规则。有些异常,不需要看分布就能判断:价格是负数、温度低于绝对零度、年龄超过150、订单数量出现小数。这一类应该写在所有统计清洗之前,先把脏数据粗暴地过滤掉。

我通常的处理顺序是:

  1. 硬规则清洗(范围、类型、唯一性);
  2. 缺失值处理;
  3. 用MAD做低级异常候选标记;
  4. 人工抽检或可视化确认;
  5. 确认后统一填充或剔除。

MAD是用来发现“那些业务上不直观但统计上很可疑”的点,而不是用来覆盖所有清洗需求的。

6.2 Hampel滤波:时间序列场景里的MAD升级版

如果你经常处理时间序列,可以把MAD升级成Hampel滤波。它的原理很简单:对每个点,取它前后各一个窗口,在窗口内计算中位数和MAD,如果当前点偏离中位数超过t倍MAD,就用该窗口的中位数替换它,或者仅标记为异常。

我之前在监控系统里用过一段Hampel滤波来清理指标曲线。它的优势是局部性强,实现也简单:

def hampel_filter(s, window=12, t=3.0): rolling_median = s.rolling(window, center=True).median() rolling_mad = (s - rolling_median).abs().rolling(window, center=True).median() z = 0.6745 * (s - rolling_median) / rolling_mad return s.mask(z.abs() > t, rolling_median)

注意这里如果rolling_mad为0,就会出除零问题,所以实际落地时我还会在外面加一层过滤,把MAD为0的窗口直接跳过。

6.3 阈值k的选择不是拍脑袋,要做敏感性分析

很多人把k固定在3,但这其实是个习惯值。正态分布下,3倍标准差对应的理论误报率很低,但实际数据往往有厚尾,3倍MAD会漏掉不少“分布尾部但业务上异常”的点。

我在不同项目里用过的k值从2.5到5.0都有。k值越小,检出的候选越多,漏检越少,但人工复核成本高;k值越大,越只挑极端尖峰。比较靠谱的做法是:

  • 跑几个k值,把标注结果列出一个对比表;
  • 抽查每个k值多出来的那些点,看业务上是否合理;
  • 选误报和漏报的平衡点。

如果你们有历史已确认异常点,还可以用它来做ROC曲线,选最适合的k。没有历史标签时,至少要做敏感性分析,不要盲目信任默认参数。

6.4 清洗后的验证与留痕

清洗完之后,我习惯输出一份“清洗报告”:原始行数、异常行数、按原因分组统计、被替换或删除的行清单。这份报告既是对账依据,也是后续优化规则的参考。

验证清洗效果,不能只看“异常是不是变少了”,还要看关键统计量有没有被打乱。比如你清洗销售数据,洗完之后每日总销售额的分布应该基本稳定;如果清洗完销售额整体下降了一截,那很可能把正常大单误杀了。MAD清理的是异常,不是波动,把握住这个度很重要。

最后再说一个我自己的小习惯:所有MAD相关的清洗代码,我都会把中位数、MAD、阈值k这些关键参数打印出来留档。因为三个月后回看,你大概率已经忘了当初为什么把k设成3.5。有参数、有数据、有报告,这套清洗流程才算真正闭环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询