
刚接触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个数。最小值1Q1位置 (5 - 1) * 0.25 1取排序后索引1对应的值也就是2中位数位置 (5 - 1) * 0.5 2取3Q3位置 (5 - 1) * 0.75 3取4最大值5所以五数概括是1, 2, 3, 4, 5IQR 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 RangeIQR公式很简单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 snsPandas的导入别名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 [fORD-{i:04d} for i in range(1, 1001)] quantity np.random.randint(1, 10, size1000) 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, replaceFalse) df.loc[outlier_idx, amount] df.loc[outlier_idx, amount] * 12生成的DataFrame有1000行4列。拿到真实数据时很多人第一件事就是直接跑统计但我会先做一步df.info()和df.head()确认列名、数据类型、有没有缺失值。这步花不了几秒钟却能避免后面一堆莫名其妙的报错。df.dtypes正常情况下amount列应该是float64category是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%对应Q150%对应中位数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(datadf, xcategory, yamount) plt.title(各品类订单金额分布盒图) plt.show()画出来以后你会看到一个非常标准的盒图中间的箱子从Q1延伸到Q3箱子中间那条横线是中位数箱子上下伸出两条胡须线末端对应非异常范围的最大最小值箱子上方那些单独的点就是异常值。把盒图和刚才的IQR检测结果对照一下你会发现自己动手算的数字和图上的位置完美对应。图上超出胡须末端的点数量恰好等于detect_outliers_iqr()标记的数量。这说明盒图的内部默认就是按1.5倍IQR规则画异常值的。如果不想依赖SeabornMatplotlib也能画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(datadf, xamount, ycategory) plt.title(横向盒图) plt.show()分组盒图适合在品类维度之外再加一个分组条件。比如想看不同品类的常规配送和加急配送金额分布差异# 先生成一个发货方式字段 np.random.seed(21) df[shipping_type] np.random.choice([标准, 加急], size1000, p[0.6, 0.4]) sns.boxplot(datadf, xcategory, yamount, hueshipping_type) plt.title(按品类与发货方式分组的盒图) plt.show()这样能从一张图里同时读出两个维度的分布差异数码品类的加急订单是否明显更贵食品品类的两种方式有没有区别都一目了然。小提琴图violin plot是盒图的一个近亲在盒子的基础上叠加了一条密度曲线宽度表示数据在该位置出现的频率。它比盒图多展示一个信息分布是不是双峰的、数据集中在哪里sns.violinplot(datadf, xcategory, yamount) 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, interpolationlinear) # 默认线性插值 df[amount].quantile(0.25, interpolationmidpoint) # 中点插值 df[amount].quantile(0.25, interpolationnearest) # 最近值插值数据量大时不同算法的差异会缩小到几乎可以忽略数据量很小时结果可能差出几个位次。我的习惯是如果是正式发布的分析报告我会在附注里写明Pandas版本和分位数算法避免别人复现时产生困惑。这不是较真是可复现性的基本要求。6.2 小样本数据要谨慎我见过有人拿着十来个数据点也跑五数概括、算IQR、标异常值结果和直觉差得很远。原因在于样本量太小时Q1和Q3的位置本身就不稳定换一个数据点整个盒图的轮廓就变了。小样本的稳定极端情况是这样数据本身就少任何一个值的变化都会强烈影响分位数位置。所以当样本量小于20甚至小于10时我的建议是放弃机械的IQR法则改用人工逐个观察或者结合业务规则设定阈值。统计方法从来不是越高级越好适合数据规模才是关键。6.3 数据类型没转对这个坑我踩过太多次了。从Excel或CSV读进来的数据金额列经常被识别成object类型最常见的原因是列里带了货币符号或千分位逗号比如¥1,234.56。这种数据直接跑quantile()会报错或者给出无比诡异的结果。先把字符串里的符号清掉再转数值类型# 去除货币符号和千分位 df[amount] df[amount].replace({¥: , ,: }, regexTrue) df[amount] pd.to_numeric(df[amount], errorscoerce)pd.to_numeric(..., errorscoerce)会在转换失败时把值变成NaN而不是直接报错。转换成功后再看一眼df[amount].isna().sum()确认有没有原本就是空值的地方被无声无息地变成NaN。数据准备阶段一定花点时间检查数据类型否则后面所有统计都可能建立在错误的基础之上。6.4 实用速查一个函数全搞定把这一整套流程整合成一个函数以后拿到新数据直接调用。def quick_summary(s): s pd.to_numeric(s, errorscoerce).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(fIQR {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, dpi150, bbox_inchestight)用上把图保存下来。分析报告里的图都是改过很多稿的而第一张图的特征往往最能说明问题。