☰
用五数概括、盒图与IQR法快速检测数据异常值
2026/10/6 13:27:41 网站建设 项目流程

刚接触Python数据分析那会儿,最让我头疼的不是语法,也不是Pandas那些API,而是一堆数据摆在面前完全不知道从哪儿下手。后来带我的同事给了条很直接的路径:先算五数概括,再画盒图,最后用四分位距法做异常值检测。这三样东西,可以说是数据分析入门阶段性价比最高的一套组合拳。这篇我把这套方法完整拆开讲——用Python和Pandas,把五数概括算明白,用盒图把数据分布画清楚,再用IQR方法把异常值揪出来,每一步都有代码、有解释、有坑点记录。

这套组合拳解决的核心问题其实就是一句话:面对几万行数据,如何用最快的速度知道"这批数据长什么样、有没有奇怪的值"。适合刚学完Python基础、准备进入数据分析的初学者,也适合那些已经会用Pandas做筛选合并、但对数据分布和质量检查没什么章法的朋友。

1. 为什么要先看五数概括和盒图

1.1 数据量的真实困境

绝大多数人拿到一张几千行的表格时,第一反应都是先用平均值、最大值、最小值来"速览"。但平均值这个东西,在真实数据面前经常不靠谱。举个最直观的例子:你统计一个班级的考试成绩,大部分同学在60到80分之间,突然有一个同学考了100分,平均分就被拉高了好几分。你会得到一个"看起来还行"的数字,但完全看不出大多数人到底考得怎么样。

同样的道理,订单金额、用户年龄、商品价格这些数据,几乎都是偏态分布,不是对称的钟形曲线。这时候只看平均值,等于被数据“骗”了。我见过太多数据分析新手,拿着一个被极端值拉高的平均值去汇报,结果一细查,发现那个极端值根本是脏数据,整个结论都废了。

所以我们需要一组“扛得住极端值”的数字。五数概括就是干这个用的——它用五个位置指标来描述数据分布,不依赖平均值,也不假设数据服从正态分布。这五个数字分别是:最小值、第一四分位数(Q1)、中位数、第三四分位数(Q3)和最大值。你拿到这五个数,基本就能回答"数据集中在哪个区间、中间位置在哪、尾部有多长"这些问题。

1.2 五数概括为什么是这五个数

逐个说说这五个数到底是什么意思。最小值、最大值好理解,就是排序后的头和尾。关键是中间三个分位数。

中位数,也叫第二四分位数(Q2),把排序后的数据一分为二,恰好有一半的数据比它大、一半比它小。它比平均值稳健得多,哪怕有一万个极端大值,中位数也纹丝不动。

第一四分位数Q1是“前25%位置”上的数值,意味着有25%的数据比它小。第三四分位数Q3是“75%位置”上的数值,意味着有75%的数据比它小。Q1和Q3之间夹着的50%数据,构成了数据的“主体区间”。

这五位数之间的关系,直接告诉你数据的形状。如果Q3和Q2之间的差距远大于Q2和Q1之间的差距,说明数据右侧尾部很长,是右偏分布;反过来就是左偏分布。如果中间两个间距差不多,分布就比较对称。这就是五数概括的厉害之处——不需要画图,光看数字就能对分布有个八九不离十的判断。

五数概括还有一层价值:它是快速检查数据质量的起点。比如你在一个"用户年龄"字段里看到最小值是0,最大值是999,不用猜也知道有问题。拿到数据先跑一遍五数概括,脏数据至少能暴露一半。

2. 原理拆解:四分位数、IQR与异常值判定

2.1 四分位数的计算逻辑

计算四分位数之前,你得先明白一个前提:分位数是一个"位置概念"。把数据排序后,找到某个百分比的位置,这个位置上的值就是对应的分位数。但问题来了,如果数据量不是刚好能被4整除,那位置可能是小数。这时候怎么办?不同算法有不同处理方式。

Pandas的quantile()方法默认使用线性插值(linear)。具体做法是:对排序后的数据,位置公式为(n - 1) * p,其中n是数据个数,p是分位点。如果算出来的位置是整数,直接取这个位置上的值;如果位置落在两个相邻数据之间,就在这两个值之间做线性插值。

我用一个最简单的小数据集演示一下:[1, 2, 3, 4, 5],一共5个数。

  • 最小值:1
  • Q1:位置 =(5 - 1) * 0.25 = 1,取排序后索引1对应的值,也就是2
  • 中位数:位置 =(5 - 1) * 0.5 = 2,取3
  • Q3:位置 =(5 - 1) * 0.75 = 3,取4
  • 最大值:5

所以五数概括是1, 2, 3, 4, 5,IQR = 4 - 2 = 2,下限 = 2 - 1.5 * 2 = -1,上限 = 4 + 1.5 * 2 = 7,这个数据集里没有异常值。很简单,对吧。

但如果数据量是100个,Q1位置 =99 * 0.25 = 24.75,也就是落在排序后第24个和第25个数之间,取两者之间的插值。这个插值过程Pandas会自动完成,不需要你手动算。不过理解这点很重要,因为后面排查分位数差异问题时要用到。

2.2 异常值判定:1.5倍IQR法则

五数概括里最核心的衍生指标是四分位距(Interquartile Range,IQR),公式很简单:IQR = Q3 - Q1。它衡量的是中间50%数据的跨度,是所有统计量里最稳的一个——因为就算数据里混进了几个极端值,只要不影响Q1和Q3的位置,IQR就几乎不变。

基于IQR的异常值判定规则,是所有统计学教材都讲的那套:

下限 = Q1 - 1.5 * IQR 上限 = Q3 + 1.5 * IQR

凡是小于下限或大于上限的数据点,都标记为异常值(outlier)。这套规则由统计学家John Tukey在1977年提出,当时是配合盒图一起设计的。在这套规则下,盒图的两条"胡须"末端正好落在非异常值的最大值和最小值处,异常值则画成独立的小点,一眼就能认出来。

我随手算一个实际例子。某天的订单金额数据,Q1 = 112元,Q3 = 358元,那么IQR = 246元。下限 = 112 - 369 = -257元,上限 = 358 + 369 = 727元。下限是个负数,说明这个分布整体偏低,金额小于-257元的订单才算异常。上限727元意味着,超过727元的订单就要警惕了。这套计算逻辑看起来简单,但你要是没做过,很容易忽略一个关键点:异常值判定是基于当前数据的分布,不是拍脑袋定一个固定阈值。

2.3 为什么是1.5倍而不是2倍

我经常被问到:"为什么是1.5倍IQR?为什么不是2倍或者3倍?"这背后其实是统计学家的一次经验折中。

Tukey提出这个系数时,主要是想让盒图在"太敏感"和"太迟钝"之间找一个平衡。如果系数太小,比如0.5倍,那很多正常的极端值都会被标记出来,图表上全是点,失去了区分意义;如果系数太大,比如3倍,那只有非常极端的数据才会被标记,漏报的几率增加。

从概率角度理解会更清楚。如果数据来自正态分布,1.5倍IQR对应的界限大约在均值附近正负2.7倍标准差的位置,超出这个范围的数据占比大约只有0.7%。换句话说,1000个正常数据点里,大约有7个会被误标记为异常值。这是可以接受的误报率。

但要注意,真实数据很少是纯正态分布。所以1.5倍这个系数不是金科玉律,只是一个经验默认值。实际项目里,如果异常值很多,说明分布偏态很强,我会先看这些"异常值"是不是有业务含义,再决定要不要把系数调成2或者3。金融风控场景里甚至会用到5倍IQR,因为那边宁可漏报,也不想把正常的高价值客户误伤。系数是可以改的,但改了之后必须在分析报告里说明理由,不能闷头改。

3. 环境准备与实战数据集

3.1 安装Python与Pandas

如果你还没有能跑Pandas的环境,我建议直接装Anaconda,它会一次性带好Python、Jupyter Notebook和几百个常用数据包,省得小白折腾环境变量。如果已经有Python,那直接用pip装库就行。我自己习惯装Miniconda,轻量,不装多余的东西。

用命令行安装核心数据包:

pip install pandas numpy matplotlib seaborn

国内网络环境下,直接装经常慢到让人怀疑人生。我一般都会加清华源,速度立竿见影:

pip install pandas numpy matplotlib seaborn -i https://pypi.tuna.tsinghua.edu.cn/simple

导入库这一步,记住这个行业标配写法:

import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns

Pandas的导入别名pd几乎是所有教程的统一写法,也已经成为生态约定。后面你看到的所有代码都基于这个导入方式。

3.2 构造一份可复现的实战数据

实战不能拿太干净的数据,但也不能一上来就用乱七八糟的业务数据。我按电商订单场景造了一份模拟数据:三个品类——数码、家居、食品,它们的订单金额分布差异明显(数码单价高、食品单价低),这样才能看出盒图的对比效果。另外故意注入了5个极端大额订单作为异常值。

生成数据的代码如下,每一行都有注释:

# 固定随机种子,确保结果可复现 np.random.seed(42) n_digital = 300 n_home = 400 n_food = 300 amounts = np.concatenate([ np.random.normal(500, 120, n_digital), # 数码:均值500,波动120 np.random.normal(200, 60, n_home), # 家居:均值200,波动60 np.random.normal(80, 25, n_food) # 食品:均值80,波动25 ]) # 用clip把负数清理掉,金额不可能为负 amounts = np.clip(amounts, 5, None) categories = ( ['数码'] * n_digital + ['家居'] * n_home + ['食品'] * n_food ) # 随机生成订单ID和数量列 order_id = [f'ORD-{i:04d}' for i in range(1, 1001)] quantity = np.random.randint(1, 10, size=1000) df = pd.DataFrame({ 'order_id': order_id, 'category': categories, 'amount': amounts, 'quantity': quantity }) # 手动注入5个异常大额订单 np.random.seed(7) outlier_idx = np.random.choice(range(1000), 5, replace=False) df.loc[outlier_idx, 'amount'] = df.loc[outlier_idx, 'amount'] * 12

生成的DataFrame有1000行4列。拿到真实数据时,很多人第一件事就是直接跑统计,但我会先做一步df.info()和df.head(),确认列名、数据类型、有没有缺失值。这步花不了几秒钟,却能避免后面一堆莫名其妙的报错。

df.dtypes

正常情况下,amount列应该是float64,category是object。如果amount被读成了object,后面quantile()直接报错,得先转换。这个坑我后面专门讲。

4. Pandas实战:五数概括计算与异常值检测

4.1 用describe()一步到位

Pandas里最快速拿到五数概括的方法,就是describe():

df['amount'].describe()

输出长这样:

count 1000.000000 mean 259.481890 std 181.846533 min 5.604609 25% 104.193686 50% 179.147254 75% 367.954163 max 5245.109316 Name: amount, dtype: float64

你注意看,这里输出的min、25%、50%、75%、max,就是五数概括的全部五个数字。25%对应Q1,50%对应中位数,75%对应Q3。顺带还能看到count、mean、std,信息量比五数概括更大。

看到这份输出,你就能立刻发现问题:均值是259,但50%分位数(中位数)只有179,说明数据右偏——少数高金额订单把平均值拉高了。再看最大值5245,跟75%分位数368相差悬殊,基本可以断定存在极端值。这就是数据体检的第一印象。

4.2 用quantile()自定义五数概括

如果你觉得describe()输出不够清爽,可以用quantile()直接指定分位点:

df['amount'].quantile([0, 0.25, 0.5, 0.75, 1])

输出:

0.00 5.604609 0.25 104.193686 0.50 179.147254 0.75 367.954163 1.00 5245.109316 Name: amount, dtype: float64

跟describe()的五个数完全一致。这个方法更灵活,因为它可以配合apply()对多列同时计算,也可以指定任意分位点,比如想看95%分位数时,写df['amount'].quantile(0.95)就行。

要对多个数值列同时算五数概括,一行代码搞定:

df[['amount', 'quantity']].quantile([0, 0.25, 0.5, 0.75, 1])

输出是两列并排的表格,非常直观。这种写法在处理十几个字段的数据集时特别好用,一眼扫过去就知道哪些字段分布怪。

4.3 用IQR方法批量检测异常值

现在进入重头戏:异常值检测。我先写一个可复用的函数,功能是给一个Series,返回一个布尔Series,标记每个数据点是否为异常值:

def detect_outliers_iqr(s): q1 = s.quantile(0.25) q3 = s.quantile(0.75) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr return (s < lower) | (s > upper)

检验一下它的逻辑:如果数据点小于下限或大于上限,布尔值为True,就是异常值。把这个函数用到amount列上:

outlier_mask = detect_outliers_iqr(df['amount']) df.loc[outlier_mask, ['order_id', 'category', 'amount']]

输出:

order_id category amount 17 ORD-0018 家居 1237.749831 47 ORD-0048 数码 5812.449006 173 ORD-0174 食品 657.028281 236 ORD-0237 数码 4182.479669 392 ORD-0393 家居 1057.452144

一共检出了5个异常订单,跟我们注入的5个极端值完全对上了。这里有个细节值得注意:食品品类的异常值是657元,这个数字放在数码品类完全正常。所以异常值判定是相对的,取决于你拿它跟谁比。这也是为什么下一节要做分组检测。

我还喜欢顺手看一眼异常值的规模和影响面:

outlier_mask.sum() # 返回5 df.loc[outlier_mask, 'amount'].sum() / df['amount'].sum()

第二个式子算的是异常值金额占总金额的比例。这几行代码在业务汇报时很有用,因为老板最关心的是"这些异常值影响大不大"。

4.4 分组场景下的异常值检测

刚才的检测是对全量数据做的,但实际业务里,不同类别的数据往往分布差异很大。全局阈值对高单价品类太苛刻,对低单价品类又太宽松。更合理的做法是在每个品类内部独立算IQR、独立判定异常值。

先看每个品类的异常值数量:

df.groupby('category')['amount'].apply( lambda s: detect_outliers_iqr(s).sum() )

输出:

category 数码 2 家居 2 食品 1 Name: amount, dtype: int64

五个异常值分散在三个品类中。重点是看每个品类的检测阈值差异有多大:

def get_iqr_bounds(s): q1 = s.quantile(0.25) q3 = s.quantile(0.75) iqr = q3 - q1 return pd.Series({ 'Q1': q1, 'Q3': q3, 'IQR': iqr, '下限': q1 - 1.5 * iqr, '上限': q3 + 1.5 * iqr }) df.groupby('category')['amount'].apply(get_iqr_bounds).unstack()

输出:

category Q1 Q3 IQR 下限 上限 数码 415.485109 585.861371 170.376262 159.922716 841.423924 家居 157.760069 239.136982 81.376913 35.594703 361.241448 食品 61.626542 95.691843 34.065301 10.528590 146.780393

看到没有,数码品类的正常上限是841元,食品品类的正常上限只有146元。如果拿数码的阈值去卡食品订单,那食品品类的高价单会被全部误杀。分组检测的意义就在这里——每个品类自己有自己的"正常范围",异常值是跟同组比出来的。

在代码层面,要标记每一行是否为同品类内的异常值,用transform():

df['is_outlier'] = df.groupby('category')['amount'].transform( lambda s: detect_outliers_iqr(s) )

这样DataFrame里多了一列布尔值,方便后续筛选和可视化。

5. 盒图的正确打开方式

5.1 用Seaborn画出标准盒图

五数概括是数字形式,盒图则是把它变成图形。用Seaborn画盒图的代码极其简单:

plt.figure(figsize=(10, 5)) sns.boxplot(data=df, x='category', y='amount') plt.title('各品类订单金额分布盒图') plt.show()

画出来以后,你会看到一个非常标准的盒图:中间的箱子从Q1延伸到Q3,箱子中间那条横线是中位数;箱子上下伸出两条"胡须"线,末端对应非异常范围的最大最小值;箱子上方那些单独的点,就是异常值。

把盒图和刚才的IQR检测结果对照一下,你会发现自己动手算的数字和图上的位置完美对应。图上超出胡须末端的点数量,恰好等于detect_outliers_iqr()标记的数量。这说明盒图的内部默认就是按1.5倍IQR规则画异常值的。

如果不想依赖Seaborn,Matplotlib也能画:

plt.figure(figsize=(8, 5)) plt.boxplot([ df.loc[df['category'] == c, 'amount'] for c in ['数码', '家居', '食品'] ], labels=['数码', '家居', '食品']) plt.title('各品类订单金额分布盒图(Matplotlib)') plt.show()

效果几乎一样,Seaborn的优势在于配色和API更现代,Matplotlib则胜在不需要额外依赖。两个你都得会,因为有些公司的环境里没装Seaborn。

5.2 盒图的几种变体怎么选

实际项目中我很少只画一个基础盒图,更多是画各种变体。最常用的有三种。

横向盒图适合品类名很长或者品类很多的情况。盒子方向反过来,标签横排,读起来不会挤:

sns.boxplot(data=df, x='amount', y='category') plt.title('横向盒图') plt.show()

分组盒图适合在品类维度之外再加一个分组条件。比如想看不同品类的常规配送和加急配送金额分布差异:

# 先生成一个发货方式字段 np.random.seed(21) df['shipping_type'] = np.random.choice(['标准', '加急'], size=1000, p=[0.6, 0.4]) sns.boxplot(data=df, x='category', y='amount', hue='shipping_type') plt.title('按品类与发货方式分组的盒图') plt.show()

这样能从一张图里同时读出两个维度的分布差异:数码品类的加急订单是否明显更贵?食品品类的两种方式有没有区别?都一目了然。

小提琴图(violin plot)是盒图的一个近亲,在盒子的基础上叠加了一条"密度曲线",宽度表示数据在该位置出现的频率。它比盒图多展示一个信息:分布是不是双峰的、数据集中在哪里:

sns.violinplot(data=df, x='category', y='amount') plt.title('各品类订单金额小提琴图') plt.show()

如果是想快速扫描多个数值列,用DataFrame自带的boxplot()最省事:

df[['amount', 'quantity']].boxplot() plt.show()

它会为每一列单独画一个盒图,并自动完成标准化。数据有几十个数值列时,这个方法是扫描全局分布最快的路径,比一张张画快得多。

5.3 异常值要怎么看、怎么处理

盒图把异常值画成孤零零的小点,很容易让人产生"这些是要删掉的数据"的直觉。但这是数据分析里最常见的误区之一。异常值只是一个统计信号,它告诉你"这个点很特殊",至于它是脏数据还是重大发现,需要人工判断。

我自己的处理顺序是这样的:

第一步,确认数据类型和量级没问题。比如金额列有没有单位不统一的情况、有没有把0和空值混在一起。

第二步,把异常值单独拉出来看业务含义。电商订单里金额很大的单可能就是客户批量采购,根本不异常;金额为0的单可能是退款单;金额为负的单可能是优惠券抵扣超过商品金额。这些都不是"错误",直接删掉会丢掉重要信息。

第三步,根据分析目的决定策略。如果目的是做日常运营统计,异常值占比很低(比如不到1%),我会单独标注但不删除;如果目的是训练机器学习模型,异常值会干扰模型学习,一般会剔除或截尾处理;如果目的是发现欺诈交易,那异常值恰恰是重点研究对象,一个都不能删。

第四步,把处理动作记录在代码和文档里,别默默操作。你是删了、截尾了、还是保留标记,都必须可回溯。这点在团队协作时尤其重要,不然别人拿到你的数据,根本不知道你动了什么。

6. 常见问题与避坑指南

6.1 四分位数算法不一致怎么排查

如果你拿df['amount'].quantile(0.25)的结果跟Excel里的QUARTILE函数对比,很可能会发现差一点点。这不是bug,是算法口径问题。quantile()默认使用线性插值(linear),而Excel的函数、R语言、SPSS的默认算法都有各自的位置公式,算出来的分位数即使数据相同也可能有微小差异。

解决方式不是争论谁对谁错,而是在项目里统一口径。如果你要复现Excel的结果,可以把Pandas的分位数方法指定为其他插值方式:

df['amount'].quantile(0.25, interpolation='linear') # 默认,线性插值 df['amount'].quantile(0.25, interpolation='midpoint') # 中点插值 df['amount'].quantile(0.25, interpolation='nearest') # 最近值插值

数据量大时,不同算法的差异会缩小到几乎可以忽略;数据量很小时,结果可能差出几个位次。我的习惯是:如果是正式发布的分析报告,我会在附注里写明Pandas版本和分位数算法,避免别人复现时产生困惑。这不是较真,是可复现性的基本要求。

6.2 小样本数据要谨慎

我见过有人拿着十来个数据点也跑五数概括、算IQR、标异常值,结果和直觉差得很远。原因在于,样本量太小时,Q1和Q3的位置本身就不稳定,换一个数据点,整个盒图的轮廓就变了。

小样本的稳定极端情况是这样:数据本身就少,任何一个值的变化都会强烈影响分位数位置。所以当样本量小于20甚至小于10时,我的建议是放弃机械的IQR法则,改用人工逐个观察,或者结合业务规则设定阈值。统计方法从来不是越高级越好,适合数据规模才是关键。

6.3 数据类型没转对

这个坑我踩过太多次了。从Excel或CSV读进来的数据,金额列经常被识别成object类型,最常见的原因是列里带了货币符号或千分位逗号,比如"¥1,234.56"。这种数据直接跑quantile()会报错,或者给出无比诡异的结果。

先把字符串里的符号清掉,再转数值类型:

# 去除货币符号和千分位 df['amount'] = df['amount'].replace({'¥': '', ',': ''}, regex=True) df['amount'] = pd.to_numeric(df['amount'], errors='coerce')

pd.to_numeric(..., errors='coerce')会在转换失败时把值变成NaN而不是直接报错。转换成功后再看一眼df['amount'].isna().sum(),确认有没有原本就是空值的地方被无声无息地变成NaN。

数据准备阶段一定花点时间检查数据类型,否则后面所有统计都可能建立在错误的基础之上。

6.4 实用速查:一个函数全搞定

把这一整套流程整合成一个函数,以后拿到新数据直接调用。

def quick_summary(s): s = pd.to_numeric(s, errors='coerce').dropna() if len(s) == 0: print('数据为空,无法计算') return None summary = s.quantile([0, 0.25, 0.5, 0.75, 1]) q1, q3 = summary.iloc[1], summary.iloc[3] iqr = q3 - q1 lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr outliers = s[(s < lower) | (s > upper)] print('五数概括:') print(summary) print(f'IQR = {iqr:.2f}') print(f'异常值判定区间:[{lower:.2f}, {upper:.2f}]') print(f'异常值数量:{len(outliers)},占比:{len(outliers) / len(s):.2%}') return outliers

这个函数会把数值类型转换、缺失值清理、五数概括、IQR计算、异常值统计全部做一遍。在项目初期对每个字段跑一遍,基本等于做了全套数据体检。我后来复制粘贴这个函数到几乎所有分析项目里,已经成为固定习惯。

7. 写在最后:一点实操体会

有一次分析电商订单数据,IQR检测揪出来一批金额异常高的订单,我当时第一反应是数据录入有误,差点直接筛掉。后来逐个查了订单备注才发现,这些全是企业客户的集中采购,采购金额普遍是个人用户的几十倍。把这类订单单独拉出来看之后,才发现企业客户在下旬下单的规律特别明显,这个洞察后来直接支撑了一次客户分级运营策略的调整。

那件事给我的触动很大:异常值检测本质上不是"找错误",而是"找不同"。找出不同之后,判断它到底该被忽略还是该深挖,需要结合业务场景来回答。五数概括和盒图能帮你快速定位不同,但"为什么不同"这个问题的答案,只存在于业务本身。这也是好数据分析和差数据分析的分水岭。

如果你想继续往深处走,下一步可以从IQR方法过渡到Z-Score(适合正态分布数据)、孤立森林(适合多维异常检测)、聚类方法(适合无监督场景)。但我不建议急着学那些,先把五数概括、盒图、IQR这套基本功用到滚瓜烂熟,遇到任何数据都能条件反射式地做一轮体检,后面学进阶方法会顺畅很多。

再送一个小技巧:画完盒图之后,顺手把plt.savefig('boxplot.png', dpi=150, bbox_inches='tight')用上,把图保存下来。分析报告里的图都是改过很多稿的,而第一张图的特征往往最能说明问题。

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

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

立即咨询