数据里最会撒谎的往往是那几个离群点,五数概括就是用来治它的
我最早做数据分析的时候,拿到一份数千行的销售流水,第一件事就是算平均值。算出个日均销售额,觉得差不多了,交上去被打回来三次。后来导师跟我说了一句话:“你report里的那个数字,被十几个大促订单抬高了,真实的日常水平根本不是那样。”我才意识到,平均值是个很容易被骗的统计量,而五数概括(Five-Number Summary)和盒图(Box Plot)这套东西,才是看清数据真实面目的基本功。
这篇文章就围绕一个非常经典的数据分析入门话题展开:五数概括是什么、盒图怎么读、异常值怎么用 IQR 方法检测。全部用 Pandas 实战演示,代码放在文章里可以直接跑。适合刚学完 Pandas 基础、想进入真正“分析”阶段的初学者,也适合已经写了几个月 SQL 但没系统接触过描述性统计的同学。
我一直觉得,数据分析入门最容易被忽略的,不是模型,不是算法,而是“你手里这份数据到底长什么样”。五数概括和盒图,就是回答这个问题最锋利的两把刀。
1. 为什么第一个要学的描述性统计是五数概括,而不是平均值
很多人学统计学的第一课就是均值、中位数、众数,然后做数据分析的时候也习惯性先看均值。这个习惯不能说错,但在真实数据面前,均值往往带偏你的直觉。
均值的本质是把所有数据加在一起除以个数。它非常敏感,只要数据里混入哪怕一个极端值,均值就会被“拽”过去。举个最简单的例子:一条街上住了五户人家,年收入分别是 10 万、12 万、11 万、13 万、200 万。均值算下来是 49.2 万,你拿这个数字去描述这条街的普通家庭收入,完全失真。但中位数是 12 万,明显更贴近真实情况。
而五数概括比中位数更进一步,它用五个数字直接“素描”出整组数据的轮廓:
- 最小值(Minimum)
- 第一四分位数(Q1,25% 分位)
- 中位数(Median,Q2,50% 分位)
- 第三四分位数(Q3,75% 分位)
- 最大值(Maximum)
如果你把一组数据按从小到大排序,Q1 表示“前 25% 的数据都小于等于这个值”,Q3 表示“前 75% 的数据都小于等于这个值”。也就是说,中间那 50% 的数据全部落在 Q1 和 Q3 之间。这一段的宽度,我们叫四分位距(IQR,Interquartile Range),等于 Q3 - Q1。
那为什么这五个数字比均值更适合做数据探索的第一步?原因有三点。
第一,抗极端值干扰。最大值和最小值会受离群点影响,但 Q1、Q2、Q3 这三个分位数非常稳定。哪怕数据里有一百个极端大值,只要它们集中在顶部那一小撮,中位数和四分位数几乎不动。
第二,能看出分布形状。如果中位数在 Q1 和 Q3 之间偏左,说明数据整体偏右分布(右尾长);如果偏右,说明左尾长。你只看均值和标准差是看不出这种偏态的。
第三,它就是盒图的内核。五数概括学会之后,盒图你拿起就能读,异常值检测也能无缝衔接。可以说,五数概括是整个描述性分析的地基。
在 Pandas 里看五数概括特别简单,一行describe()就能输出大部分内容:
import pandas as pd import numpy as np # 构造一份模拟数据,包含正常值和少量离群点 rng = np.random.default_rng(42) data = pd.DataFrame({ "销售额": np.concatenate([ rng.normal(500, 50, 200), # 200个正常值,均值500,标准差50 np.array([900, 950, 1100]) # 3个明显离群点 ]) }) print(data["销售额"].describe())输出结果大概是:
count 203.000000 mean 517.625616 std 89.452859 min 365.489534 25% 468.830002 50% 498.214458 75% 530.263361 max 1100.00000025%、50%、75%那三行就是 Q1、中位数、Q3,min和max是首尾。这五个数字一出来,你对这组数据的认知就不再是“均值 517”这么单薄了:中间一半的销售额集中在 468 到 530 之间,但最大到 1100,说明尾部有非常突出的高值——均值 517 明显是被这几个大值抬上去的。
用describe()是偷懒做法,如果只想拿分位数,quantile()更精确:
q1 = data["销售额"].quantile(0.25) q2 = data["销售额"].quantile(0.50) q3 = data["销售额"].quantile(0.75) print(f"Q1={q1:.2f}, 中位数={q2:.2f}, Q3={q3:.2f}")Pandas 默认的quantile()方法是线性插值法,这意味着它会根据相邻两个排序位置的值估算分位点。这个方法来自 NumPy 的linear模式,对应统计学里常见的第 7 型分位数定义,也是 R 语言里type=7的默认算法。绝大多数工程场景用这个就够了,不需要去折腾其他插值方式。
到这里你会发现一个很有意思的事情:五数概括其实没有用到任何复杂公式,就是排序、找位置、取值。但它描述数据的能力,远超一个均值和标准差组合。
2. 盒图的结构拆解:从四个线段到离群点的视觉逻辑
盒图我第一次看的时候,觉得它画得神神秘秘的,中间一个矩形,上下伸出两根线,线上偶尔还挂着几个零散的小点。后来真正读懂之后才发现,这图就是把五数概括画成了一张图,信息密度极高。
盒图的核心结构是这样拆的:
- 盒子的下边是 Q1,上边是 Q3,盒子内部那条横线是中位数。
- 盒子高度就是 IQR,也就是 Q3 - Q1,它表示中间 50% 数据的跨度。
- 盒子上下各有一条“须”(whisker),上须顶端的位置一般是 Q3 + 1.5 * IQR 和数据实际最大值中较小的那个,下须底端是 Q1 - 1.5 * IQR 和数据实际最小值中较大的那个。
- 须之外的点,就是超过上下须范围的数字,也就是我们常用的盒图法定义的异常值。
这里要注意一个细节:上须的顶端并不是“最大值”。很多人看盒图以为须的端点就是 min 和 max,其实不是。当数据里有特别极端的大值,上须会在 1.5 倍 IQR 的限制位置停住,极端点则单独画出来。这么设计的原因是为了让“正常数据范围”和“异常数据”在视觉上一眼分开。
1.5 这个系数,如果你翻统计学的书,会发现它只是一个经验常数,不是严格的数学定理。它的原理是:当数据来自正态分布时,理论上大约有 99.3% 的数据落在 Q1 - 1.5 * IQR 和 Q3 + 1.5 * IQR 之间。换句话说,如果数据完全服从正态分布,超过这个范围的点非常罕见,概率不到 0.7%。因此用这个标准来标异常,具备基本的统计学依据。
但如果你的数据不是正态分布呢?那 1.5 倍 IQR 就只是一个工程默认值,不代表数学真理。在偏态分布、长尾分布的数据集上,你完全可以根据业务场景调整系数。比如金融反欺诈领域,有人用 3 倍 IQR 来避免把高净值客户误判为异常。这个我们后面实战再展开。
用 Matplotlib 画盒图非常简单:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 4)) plt.boxplot(data["销售额"], vert=True, patch_artist=True, showfliers=True, flierprops=dict(marker='o', markerfacecolor='red', markersize=6)) plt.title("销售额盒图") plt.ylabel("销售额") plt.grid(axis='y', linestyle='--', alpha=0.4) plt.show()跑完之后你会看到:盒子主体在 468 到 530 之间,中位数线接近盒子中间略微偏上,上须拉着三个小红点(900、950、1100),这就是盒图法识别出来的离群值。
还有一个常用组合是seaborn.boxplot,尤其在需要按分组画图的时候,Seaborn 的表达力更强。Pandas 本身也提供了df.plot.box()方法,本质上是对 Matplotlib 盒图的一层封装。入门阶段我建议三种都试一遍,选一个自己顺手的方式固定下来。
这里额外说一个经验:小样本数据画盒图容易翻车。当数据量只有四五个的时候,Pandas 画盒图有时会报RuntimeWarning,提示没有足够的数据确定须的位置;数据量少于 3 个时,四分位数本身就不稳定,画出来的盒子几乎失去意义。所以盒图适合样本量 >= 20 的场景,样本量太小时,直接用排序后的五数概括列表反而更准确。
读盒图还有一个高级技巧:把多个盒图并排画,对比组间差异。比如对比各销售区域的业绩分布、各时段的响应延迟,并排盒图比柱状图加误差线直观得多。误差线只告诉你均值和波动范围,盒图能告诉你每个组的中位数差异、尾部厚度、离群点分布,信息量完全不在一个层级。
# 分组盒图示例 df_group = pd.DataFrame({ "区域": np.repeat(["A区", "B区", "C区"], 100), "销售额": np.concatenate([ rng.normal(500, 50, 100), rng.normal(600, 80, 100), rng.normal(450, 30, 100) ]) }) df_group.boxplot(column="销售额", by="区域", figsize=(8, 5)) plt.suptitle("") # 去掉自动生成的标题 plt.title("各区域销售额盒图对比") plt.show()盒图一旦会读,你对一组数据的感知能力会明显提升一个台阶。你不光知道“大概是什么水平”,还能说出“数据散布在什么范围、中间一半有多集中、有没有极端个体”,这在探索性数据分析里是最关键的一步。
3. 用 IQR 方法检测异常值:公式背后的边界逻辑
盒图法检测异常值的底层逻辑就是 IQR 法,也叫 Tukey 法,得名于提出盒图的统计学家 John Tukey——对,就是那个统计学界非常有名的老头,他同时还提出了箱线图和快速排序算法里的“Tukey’s hinge”。工程界把这个方法简化为一条规则:超过 Q3 + 1.5 * IQR 或低于 Q1 - 1.5 * IQR 的点,记为异常值。
先手工推导一遍,你才能真正理解代码里的每一行在干什么。
假设前面那份销售数据:
- Q1 = 468.83
- Q3 = 530.26
- IQR = 530.26 - 468.83 = 61.43
那么上界(upper fence)就是:
530.26 + 1.5 * 61.43 = 622.41下界(lower fence)就是:
468.83 - 1.5 * 61.43 = 376.68任何销售额大于 622.41 或小于 376.68 的点,都会被标记为异常值。按这个规则,900、950、1100 是异常值,而数据里那些 400 左右的低值因为还没跌破 376.68,不算异常。
在 Pandas 里实现这个检测逻辑,核心代码只有几行:
def detect_outliers_iqr(series, k=1.5): Q1 = series.quantile(0.25) Q3 = series.quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - k * IQR upper_bound = Q3 + k * IQR return (series < lower_bound) | (series > upper_bound) data["is_outlier"] = detect_outliers_iqr(data["销售额"]) print(data[data["is_outlier"]])上面函数里的k是可以调的系数,默认 1.5。你把 k 改成 3,就得到“极端异常值”的判定标准。很多实际工作中,业务上会把 k=1.5 判出来的叫“潜在异常值”,k=3 判出来的叫“强异常值”。前者需要人为复核,后者几乎可以直接处理。
这里有个很容易踩的坑:异常值检测不能只看单列分布,要结合数据生成的背景。比如监控系统里的响应时间,99% 的请求在 100ms 以内,但压测期间的请求冲到 3000ms,从分布上看是异常值,从业务上看却是真实的高负载信号,不能粗暴删除。
IQR 方法本身也有局限,最明显的是数据量少的时候不稳定,样本量低于 10 时四分位距波动非常大,检测结果基本没有参考价值。另外,如果数据本身是偏态分布,单侧异常值会被系统性地识别出来,而另一侧几乎没有,这时需要结合对数变换或分位数变换后再检测。
还有一个很多人忽略的细节:IQR 方法默认是“无分布假设”的。跟 3σ 法则不同,它不要求数据服从正态分布。这是它的普适性优势,但也意味着它没有利用数据的分布形态信息。如果你明确知道数据服从正态分布,直接用均值 ± 3 倍标准差效果更稳定。
那我们什么时候应该放弃 IQR、改用其他方法?我总结过一套最简单的判断逻辑:
- 如果数据是对称分布或近似对称,用均值 ± 3 标准差;
- 如果数据明显偏态、又说不清属于什么分布,用 IQR;
- 如果数据有强季节性或多模态特征,IQR 效果也很差,更适合用时间序列的异常检测方法。
把公式吃透之后,代码只是壳。上面detect_outliers_iqr这个函数,我建议直接存进你的工具脚本里,以后任何 DataFrame 列都能一行调用。
4. Pandas 完整实战:构造数据、画图、标记异常的一站式流程
理论说完了,这部分跑一遍完整流程。为了让你看到真实效果,我模拟了一份“某电商店铺每日订单量”数据,包含 180 个正常观测和几个促销日拉出来的极端值。
import pandas as pd import numpy as np import matplotlib.pyplot as plt # 设置随机种子,保证结果可复现 rng = np.random.default_rng(2024) # 生成 180 天日常订单量:均值 200,标准差 30 daily = rng.normal(200, 30, 180) # 加入 5 天促销日的大订单量 promotion = np.array([420, 510, 385, 468, 550]) df = pd.DataFrame({ "订单量": np.concatenate([daily, promotion]) }) # 打乱顺序,模拟真实时间序列的随机性 df = df.sample(frac=1, random_state=7).reset_index(drop=True) # 第一步:五数概括 print("=== 五数概括 ===") q1 = df["订单量"].quantile(0.25) q2 = df["订单量"].quantile(0.50) q3 = df["订单量"].quantile(0.75) q0 = df["订单量"].min() q4 = df["订单量"].max() iqr = q3 - q1 print(f"Min = {q0:.2f}") print(f"Q1 = {q1:.2f}") print(f"Median= {q2:.2f}") print(f"Q3 = {q3:.2f}") print(f"Max = {q4:.2f}") print(f"IQR = {iqr:.2f}")输出会是类似这样:
Min = 131.45 Q1 = 179.82 Median= 200.13 Q3 = 221.56 Max = 550.00 IQR = 41.74看到没有,中位数 200,Q1 和 Q3 的跨度只有 42 左右,日常订单量相当稳定,但 Max 拉到 550,这个尾巴一眼就不正常。
第二步:画盒图,让异常值“现形”。
plt.figure(figsize=(9, 5)) box = plt.boxplot(df["订单量"], patch_artist=True, flierprops=dict(marker='o', markerfacecolor='red', markersize=7)) box["boxes"][0].set_facecolor("#a0d8ef") plt.axhline(q3 + 1.5 * iqr, color="orange", linestyle="--", linewidth=1.2, label="Upper fence") plt.axhline(q1 - 1.5 * iqr, color="orange", linestyle="--", linewidth=1.2, label="Lower fence") plt.title("每日订单量盒图(含异常值标记)") plt.ylabel("订单量") plt.legend() plt.grid(axis='y', linestyle='--', alpha=0.4) plt.show()这一步会画出一个盒子和几条横线。红色圆点就是 IQR 规则识别出来的异常值,橙色虚线是上下界,你能直观看到哪些点越过了边界。
第三步:用 IQR 方法量化标记异常值,并输出一个便于人工核查的表格。
def flag_outliers(series, k=1.5): q1 = series.quantile(0.25) q3 = series.quantile(0.75) iqr = q3 - q1 lower = q1 - k * iqr upper = q3 + k * iqr return (series < lower) | (series > upper) df["异常标记"] = flag_outliers(df["订单量"]) df["上界"], df["下界"] = df["订单量"].quantile(0.75) + 1.5 * (df["订单量"].quantile(0.75) - df["订单量"].quantile(0.25)), df["订单量"].quantile(0.25) - 1.5 * (df["订单量"].quantile(0.75) - df["订单量"].quantile(0.25)) # 只查看被标记为异常值的记录 outliers = df[df["异常标记"]] print(f"检测到 {len(outliers)} 个异常值") print(outliers.head(10))输出会把这 5 个促销日的订单量捞出来:420、510、385、468、550。恭喜,这个流程一次跑通,你已经完成了一次标准的数据质量审查。
第四步,也是最容易被忽略的一步:回归业务背景做判断。检测出异常值之后,不要着急删。我列了一个异常值处理决策清单:
- 如果是录入错误、传感器故障、数据清洗遗漏导致的异常:直接删除或修正。
- 如果是真实业务事件(促销、故障、突发流量):保留,或在建模时单独打标。
- 如果是建模场景:看模型类型。树模型对异常值不敏感,线性模型和距离类模型非常敏感。
对应到这份订单量数据,那 5 个促销日的订单量肯定不能删,它们是真实业务行为。但如果你要用这份数据训练一个“预测日常订单量”的模型,最好把这 5 天标记为“促销日”单独建模,或者干脆在训练时剔除。
最后把这套流程封装成一个函数,方便复用:
def iqr_summary(series, k=1.5): q1 = series.quantile(0.25) q2 = series.quantile(0.50) q3 = series.quantile(0.75) iqr = q3 - q1 lower = q1 - k * iqr upper = q3 + k * iqr outlier_mask = (series < lower) | (series > upper) return { "count": len(series), "min": series.min(), "q1": q1, "median": q2, "q3": q3, "max": series.max(), "iqr": iqr, "lower_fence": lower, "upper_fence": upper, "outlier_count": outlier_mask.sum(), "outlier_indices": series[outlier_mask].index.tolist() } result = iqr_summary(df["订单量"]) for key, value in result.items(): print(f"{key}: {value}")这一整个流程下来,你是先理解了统计量的意义,再看到盒图的直观展示,再通过公式批量检测,最后结合业务下结论。这也是我个人认为最合理的分析路径:先理解,再可视化,再量化,再决策。反过来先跑代码再看文档,往往容易把工具用得很熟练但分析没有灵魂。
5. 检测只是第一步:异常值的处理策略与取舍经验
异常值检测出来之后,很多人直接问“该不该删”。这问题没有标准答案,答案完全取决于你的分析目标。我按场景分享一些实际操作中的经验。
场景一:做数据清洗,目标是建一个干净的训练集。
这时候异常值处理的标准是“区分来源”。如果是明显的数据录入错误——比如负数价格、超长字符串、日期 2 月 30 日——直接删。如果是真实的高波动数据,尽量保留,然后用裁剪(winsorize)或者变换的方式来降低影响。我之前在一个图像处理项目里处理像素灰度值,就遇到过传感器偶尔打出的全白噪点,那批数据直接置为缺失值,不参与后续统计。
场景二:做业务分析报告,目标是描述真实情况。
这种情况我不建议删除异常值,而是建议把它们单独拆出来分析。比如前面那个销售数据,正常店铺日均 500,碰到大促能到 1000+,那你的报告应该写“日常销售额中位数 498,波动区间 468~530,大促日销售额可达 900~1100”。这样写,业务方一眼就看明白日常情况和峰值情况,比一个总均值 517 有用得多。
场景三:做算法模型的特征工程。
有些模型(线性回归、逻辑回归、KMeans、PCA)对异常值非常敏感,一个极端值就可能让系数偏移很大。这时候有三个常用处理手段:
- 删除异常值:适合异常占比很小(<5%)且确认是噪声的情况。
- 用上下界截断:把超过上界的值拉回上界,低于下界的拉回下界,这叫 winsorize。
- 做对数变换 / Box-Cox 变换:把右偏的长尾压回正态形态。
这三种手段没有绝对优劣。实战中我会先看异常值的比例,如果占比约 1%~2%,而且业务上能解释,我会倾向截断而不是删除,保留部分信息;如果占比超过 10%,说明你的“正常范围”定义有问题,应该回去重新理解业务,而不是机械地用 1.5 倍 IQR 处理。
另外补充一个检测和处理的协作技巧:永远不要在原数据上原地修改,养成“先标记、后决策、再过录日志”的习惯。我的做法是新增一列异常标记,再写一个字段记录处理方式,比如“删除”“截断”“保留”,这样分析流程是可审计的。等报告做完,回看数据清理日志,你还能知道每一步为什么这么做,不会出现“当时为什么删了那 20 条数据”的尴尬。
坊间还流行一种偏见,觉得“只有回归模型在乎异常值,树模型无所谓”。这个说法只对了一半。随机森林、XGBoost 这类树模型确实对单个异常值有很强的鲁棒性,但当异常值数量偏多或者碰巧聚集在某些特征区间时,树模型在划分节点时依然会被带偏。我的建议是:不要因为模型不敏感就不做异常值分析,尤其是在探索阶段,异常值本身就是情报,有时候那个突兀的点背后藏着一个业务漏洞。
6. 收尾前再分享两个实战技巧
最后说两个我用了很久、非常实用的技巧。
第一个技巧:在 Pandas 里画多个数值列的盒图,一键对比所有特征的离群情况。很多初学者只能单列画图,效率太低。你可以这样写:
# 选择所有数值列 numeric_cols = df.select_dtypes(include=[np.number]).columns df[numeric_cols].boxplot(figsize=(12, 6), rot=45) plt.title("所有数值特征的盒图总览") plt.show()如果有几十个特征,这样一张总览图能帮你快速定位哪几个字段的异常值比例偏高,再做针对性分析。我在早期做用户画像分析的时候,就是用这种方式从 30 多个字段里两三眼就锁定了“登录次数”和“消费金额”两个高异常字段。
第二个技巧:使用seaborn.boxen画增强箱线图。当数据量很大(几万行以上)且分布比较复杂时,普通盒图只有一个盒子和两条须,很多细节被压缩掉了。Seaborn 的boxenplot(以前叫lvplot)在 IQR 基础上做了多层分位分解,可以看到更细的尾部结构,特别适合探索阶段找规律。
import seaborn as sns sns.boxenplot(x=df["订单量"]) plt.title("订单量增强箱线图") plt.show()增强箱线图在分析长尾数据、日志耗时、耗时分布时非常好用,推荐大家试一下。
我做了这些年数据分析,回头再看,真正拉开分析水平的往往不是会不会用 XGBoost、会不会调参,而是面对一份陌生数据时,能不能在十分钟内准确地说出它的大致分布、离散程度和可疑点位。五数概括、盒图、IQR 异常值检测,就是练这个基本功最直接的工具。这套东西看似简单,但每一次实战都能帮你看清数据最真实的那张脸。