Pandas 数据处理指南:异常值、抽样和数据聚合
前阵子帮一个做电商数据的朋友看报表,他拿一份两百多万行的订单明细跑groupby('channel').agg({'amount':'sum'}),出来的 GMV 跟业务方口径差了将近三成。他一开始怀疑是 SQL 导出口径不一致,翻来覆去对了两天,最后定位到三个问题叠在一起:几条测试账号的大额订单没剔掉,一批amount为空的记录被sum当 0 算进了分母,还有一个渠道因为抽样的关系样本量只有几十条却被当成正常渠道参与排名。这三个问题分别对应数据处理的三个基本功——异常值处理、抽样、聚合,恰好也是绝大多数人觉得自己"会用"、真到业务里却频繁翻车的三个地方。
这篇内容想聊的就是这三件事。它适合已经能写df.groupby()、df.sample(),但经常在结果对不上、口径说不清、跑得慢这几件事上卡住的人。我会把每个环节里那些文档上不写、但实际项目里一定会遇到的东西讲透,包括检测方法什么条件下会失效、抽样参数背后代表什么、聚合结果怎么组织才能直接进报表。文中用到的代码都可以直接复制到 Notebook 里跑,数据表结构我尽量写得通用,换成你自己的字段名就能用。
1. 异常值先分三类:错误值、极端值、结构性偏移,处理方式完全不同
很多人对异常值的处理方式是"跑个 describe,看到最大值离谱就删掉",这个做法在真实业务里十次有八次会出错。异常值不是一个统计学概念,它是统计概念和业务语义的交叉点,同样一个数值,在不同的字段上有完全不同的含义。我习惯先把它分成三类,因为这三类的处置逻辑是互斥的。
第一类是错误值,本质是数据在采集、传输、录入环节出了问题。典型表现是单位错位(金额字段里混进了一条以分为单位的记录)、编码错误(缺失被填成-1、9999、0,而不是NaN)、类型错位(日期列里混进了字符串)。这类值不属于"偏大偏小",它压根就是错的,处理方式应该是修正或者置空,而不是简单删除。
第二类是极端值,数据本身是对的,只是落在分布尾部。比如某个大客户单笔下了五十万的订单,这个值真实发生了,删掉它等于把真实业务抹掉。这类值要考虑的是"它对我要算的指标有没有影响"——算平均值它影响巨大,算中位数几乎没影响。
第三类是结构性偏移,指的不是单条记录异常,而是某个分组的整体分布和其他分组不在一个量级上。比如某个渠道的客单价整体是其他渠道的十倍,这往往不是渠道用户质量高,而是这个渠道的金额字段包含了运费或者税费,采集口径跟别人不一样。这类问题用单变量检测方法根本发现不了,必须按分组切开看。
1.1 用 describe 和 value_counts 做第一轮体检
动手之前先做体检,不要上来就写过滤条件。我通常跑这么几行:
import pandas as pd import numpy as np df.info() df.describe(include='all').T df['amount'].value_counts().head(20) df['amount'].value_counts().tail(20)describe看的是分布轮廓,重点看三个数:min是不是负数(金额类字段出现负数通常意味着退款混进来了)、max是不是比75%分位大出好几个数量级、mean和50%分位的差距有多大。均值远大于中位数,说明右偏严重,后面选检测方法时就不能用基于正态假设的那套。
value_counts的头部和尾部是在找"哨兵值"。所谓哨兵值,就是用特定数字表示特殊含义的约定,-1表示未填写、0表示未知、9999表示不适用。这类值在统计上看起来可能不离谱(-1甚至不大),但它会严重污染均值。我遇到过age字段大量填0的情况,算出来平均年龄 26 岁,实际主力用户是 35 岁以上,就是被这批 0 拉下去的。
这一步的经验是:先看value_counts的尾部,再看describe。因为哨兵值往往在尾部出现得很突然,比如正常金额都是几十到几千,尾部突然出现一个-999,一眼就能看出来。而describe只会告诉你最小值是 -999,不会告诉你这个值出现了 800 次。
1.2 IQR 的上下界怎么算,什么时候它会骗你
IQR 法是箱线图的数学基础,计算很直接:
q1 = df['amount'].quantile(0.25) q3 = df['amount'].quantile(0.75) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr mask_normal = df['amount'].between(lower, upper) print(f"下界 {lower:.2f} 上界 {upper:.2f} 异常占比 {(~mask_normal).mean():.2%}")1.5这个系数不是拍脑袋的,在正态分布下它对应的区间大约覆盖 99.3% 的数据,所以正常数据被判为异常的比例应该在 1% 以内。这个数字很重要——如果你用 IQR 算出来的异常比例是 15%,那不是数据有问题,是方法用错了,说明数据分布严重偏离正态,或者 Q1、Q3 本身就被污染了。
IQR 最容易骗人的场景是右偏严重的数据,比如订单金额、用户消费额、页面停留时长。这类数据 Q1 和 Q3 都挤在左侧,IQR 很小,算出来的上界就会很低,把大量正常的大额订单判成异常。我实测过一份客单价数据,Q1 是 38,Q3 是 96,上界算出来只有 183,但实际上千的单子占了 8%,这些全是真实的大客户订单。
通用的补救办法是先做对数变换再算 IQR,或者直接用分位数缩尾(比如把上下 1% 的值截断到 1% 和 99% 分位的值上),后者对业务解释更友好,因为它不改变数据的单调性:
lo, hi = df['amount'].quantile([0.01, 0.99]) df['amount_winsor'] = df['amount'].clip(lower=lo, upper=hi)1.3 删除、缩尾、标记、分箱:四种处置各自适用什么场景
检测出来之后怎么处理,这是最见功力的地方。我整理了一张对照表,基本覆盖了日常遇到的场景:
| 处置方式 | 适用场景 | 风险点 |
|---|---|---|
| 直接删除 | 确认为错误值,且占比低于 1% | 占比高时会造成样本偏差 |
| 缩尾(clip) | 极端值真实存在,但要算均值类指标 | 会压缩真实差异,需在报告里注明 |
| 打标记不删 | 极端值本身是分析对象(如大客户识别) | 下游所有计算都要带上标记过滤 |
| 分箱处理 | 用于建模特征,或做分布对比 | 分箱边界选择主观性强 |
| 单独分组 | 结构性偏移,如某渠道口径不同 | 需要额外解释,容易让报表难读 |
删除这条我想多说一句。很多教程写"删除 3σ 之外的记录",但从来没说删完之后必须重新检查分布。原因很简单:删掉尾部会改变均值、方差、分位数,如果下游还有别的过滤条件,两次过滤叠加可能把某个分组删空。我的习惯是删除前后各跑一次groupby.size(),确认每个分组的剩余量都还够做结论(经验阈值是单组不低于 30 条),不够就别删,改成标记。
还有一个更隐蔽的坑:删除操作会破坏索引连续性。如果后续用位置索引(iloc)或者把索引当成 ID 用,删完就容易错位。所以删除之后一律加.reset_index(drop=True),或者干脆养成习惯,任何过滤之后都重置一次。
2. 检测手段的选型:从 MAD 到孤立森林,每种方法的失效条件是什么
搞清楚异常值的分类之后,接下来的问题是"用什么方法找出来"。这里有个很普遍的误区:方法越复杂越好。实际上孤立森林、LOF 这些算法在业务数据上的表现经常不如一个老老实实的分位数阈值,原因是它们对参数和数据尺度非常敏感,而业务数据往往是脏的、混合分布的、量纲不统一的。
我的选型逻辑是按数据形态走:单变量近似对称的用 Z-score,单变量偏态的用 MAD,多变量相关的用马氏距离或孤立森林,时间序列用滚动统计量。下面逐个说清楚它们的前提和失效边界。
2.1 Z-score 的前提是近似正态,偏态数据上它会误伤
Z-score 就是标准分,(x - mean) / std,阈值一般取 3:
z = (df['amount'] - df['amount'].mean()) / df['amount'].std() outliers = df[z.abs() > 3]写法简单到没什么可说,问题在于它的两个隐含假设:均值能代表中心,标准差能代表离散程度。这两个假设只有在分布近似对称时才成立。右偏数据里,尾部的大值会把均值往上拉,同时把标准差撑大,结果是真正的异常反而算不出高分。这个现象叫掩蔽效应,是 Z-score 在业务数据上最致命的问题。
还有一个技术细节容易被忽略:std()默认是样本标准差(ddof=1),如果跟别的系统比对结果时对方用的是总体标准差,阈值会有一点差异。数据量大的时候影响可以忽略,数据量小的时候(比如几百条)值得注意。
2.2 修正 Z-score 用中位数和 MAD,抗噪但要注意 MAD 为 0
修正 Z-score 把均值和标准差换成中位数和绝对中位差(MAD),这两个统计量的崩溃点高得多,少量极端值影响不了它们:
med = df['amount'].median() mad = (df['amount'] - med).abs().median() modified_z = 0.6745 * (df['amount'] - med) / mad outliers = df[modified_z.abs() > 3.5]0.6745这个常数是为了让 MAD 在正态分布下和标准差可比,等价于1 / 1.4826。阈值3.5是文献里通用的经验值,对应正态分布下大约 0.02% 的比例。
这个方法有一个必须处理的边界情况:MAD 等于 0。当超过一半的数据取值完全相同时就会发生,典型场景是大量用户的下单次数都是 1,此时(x - med)对大多数记录是 0,MAD 就是 0,除零之后全是inf或nan。处理方式是加一个极小值保护,或者在这种场景下换成基于计数的异常定义:
mad = max(mad, 1e-9)我个人的经验是,对于"次数型"字段(下单次数、登录次数、点击次数),MAD 和 IQR 都不太好使,因为分布是离散且高度集中的。这时候更靠谱的做法是直接用业务规则,比如"下单次数大于 50 次且 7 天内完成"这类组合条件。
2.3 多变量异常:马氏距离和孤立森林的落地写法
单变量检测看不到"组合起来才异常"的记录。比如一个用户年龄 25 岁、年消费 80 万,这两个值单独看都不算极端,但放在一起就非常可疑。这类问题需要多变量方法。
马氏距离考虑变量间的相关性,要求数据近似多元正态,而且样本量要大于特征数,否则协方差矩阵不可逆:
from scipy.spatial.distance import mahalanobis cols = ['age', 'amount', 'order_cnt'] X = df[cols].dropna().to_numpy() mu = X.mean(axis=0) cov_inv = np.linalg.pinv(np.cov(X, rowvar=False)) d = np.array([mahalanobis(row, mu, cov_inv) for row in X]) threshold = np.quantile(d, 0.99)注意这里用了pinv而不是inv,因为特征之间高度相关时协方差矩阵奇异,inv会直接报错。另外mahalanobis是逐行调用的,几百万行会非常慢,实际项目里建议用矩阵运算重写:d = np.sqrt(np.einsum('ij,jk,ik->i', X - mu, cov_inv, X - mu))。
孤立森林不依赖分布假设,思路是随机切分空间,异常点因为稀疏所以更容易被孤立出来,路径更短。落地写法:
from sklearn.ensemble import IsolationForest model = IsolationForest( n_estimators=200, contamination=0.01, max_samples='auto', random_state=42, n_jobs=-1, ) model.fit(df[cols].fillna(0)) df['if_flag'] = model.predict(df[cols].fillna(0)) # -1 为异常 df['if_score'] = model.score_samples(df[cols].fillna(0))contamination是最关键的参数,它直接决定判定比例,设成 0.01 就意味着一定有 1% 的记录被判为异常,不管数据里到底有没有这么多。这是一个很强的先验假设,所以我一般不用predict的硬标签,而是用score_samples拿连续分数,再结合业务规则人工圈定阈值。这样既能利用算法的排序能力,又不会因为参数设置错误而误删大量正常数据。
2.4 时间序列上的异常:滚动统计量比全局阈值靠谱
时间序列的异常判定不能用全局阈值,因为业务本身有周期性和趋势。春节期间的订单量比平时高五倍,那是正常的;但某个工作日的订单量突然掉了一半,那才是异常。
常用做法是滚动中位数加滚动 MAD,用局部统计量代替全局统计量:
df = df.sort_values('dt').reset_index(drop=True) roll_med = df['amount'].rolling(window=7, min_periods=3).median() roll_mad = (df['amount'] - roll_med).abs().rolling(window=7, min_periods=3).median() score = 0.6745 * (df['amount'] - roll_med) / roll_mad.replace(0, np.nan) df['ts_flag'] = score.abs() > 3.5min_periods必须显式设置,否则前 6 行的结果全是NaN,容易在后面被误当成正常值。窗口大小按业务周期定,日数据用 7,周数据用 4 到 8,太小容易把正常波动判成异常,太大会让异常点污染窗口内的统计量,导致检测滞后。
还有一个更实用的做法是同比环比结合,即(今日 - 上周同日) / 上周同日,超过某个比例就标记。这个方法业务解释性极强,报表上可以直接展示"较上周同日下降 62%",比一堆统计量更容易让人相信。
3. sample 不只是随机抽几行:抽样参数、分层方案与代表性校验
抽样这件事在很多人心里就是"数据太大跑不动,抽一万行先看看"。但抽样的目的是让样本能代表总体,一旦这个前提不成立,后面所有的分析结论都是废的。我见过最典型的翻车是:拿df.sample(1000)抽出来的样本去评估转化率,抽出来的结果比全量高了两个百分点,查到最后是因为原始数据按时间排序,随机抽样恰好抽到了一个促销时段。
3.1 n、frac、weights、replace 的实际含义
sample的参数不多,但每个都有实际影响:
df.sample(n=1000, random_state=42) # 固定条数 df.sample(frac=0.1, random_state=42) # 固定比例 df.sample(n=1000, replace=True, random_state=42) # 有放回 df.sample(n=1000, weights='weight_col', random_state=42) # 加权random_state在需要复现的场景必须固定,尤其是做 A/B 对比分析时,两次抽样用了不同的种子,结果差异可能完全来自抽样本身而不是业务变化。
replace=True只在一种情况下需要:样本量要超过总体,或者要做 Bootstrap 估计。日常抽样基本不用,因为重复样本会让统计量的方差被低估。
weights是加权抽样,适合做重要性抽样或者过采样。有个坑值得说:weights可以传列名也可以传数组,但如果传入的 Series 索引和 DataFrame 索引不一致,pandas 会按位置对齐而不是按索引,结果可能完全错乱。稳妥做法是直接传.to_numpy()。
frac和n不能同时指定,这个大家都知道。但有个隐蔽问题:frac大于 1 且没设replace=True时会报错,而frac=1返回的是打乱后的全量数据,不是原始顺序,很多人在这一步误以为数据被改了。
3.2 分层抽样:手写循环比 groupby.apply 更省心
分层抽样保证每个分组在样本里的比例和总体一致,这是报表抽样几乎必做的事。常见写法有两种,我更推荐手写循环:
def stratified_sample(df, col, n_per_group=None, frac=None, seed=42): parts = [] for key, g in df.groupby(col, observed=True, sort=False): if n_per_group is not None: k = min(n_per_group, len(g)) parts.append(g.sample(n=k, random_state=seed)) else: parts.append(g.sample(frac=frac, random_state=seed)) return pd.concat(parts, ignore_index=True) sample_df = stratified_sample(df, 'channel', frac=0.05)为什么不用groupby.apply(lambda x: x.sample(...))?两个原因。一是新版 pandas 对返回结果形状不一致的apply会有弃用警告,行为在版本间有变化,维护成本高。二是apply会把每个分组当独立函数调用,几万个分组的时候速度慢一个数量级。手写循环虽然多几行,但行为完全可控,出问题好排查。
min(n_per_group, len(g))这个保护必须加。小分组的数据量小于要求抽样量时,sample(n=k)会直接抛ValueError,整个流程中断。用min兜底,配合后面的一致性核对,能马上发现哪些分组样本不足。
observed=True也不能省。如果你的分组字段是category类型,默认行为会把所有类别组合都列出来,包括数据里根本不存在的组合,白跑一堆空分组。新版 pandas 里这个默认值已经改了,但如果代码要兼容老版本,显式写上更保险。
3.3 样本量怎么估,以及抽完必须做的一致性核对
样本量不是拍脑袋定的。判断比例类指标(比如转化率)时,用这个公式:
n = z² × p × (1-p) / e²z是置信水平对应的分位数(95% 对应 1.96),p是预期比例(不知道就取 0.5,此时方差最大),e是允许误差。取p=0.5、e=0.03,算出来 n 约等于 1067。如果总体本身不大,还要做有限总体修正:
n_adj = n / (1 + (n - 1) / N)总体 5000 条时,修正后的样本量是 878,比 1067 小了不少。这个修正很容易被忽略,导致对小数据集做了不必要的过度抽样。
抽完之后必须做一致性核对,这一步比抽样本身更重要。我通常跑三件事:分组占比对比、关键字段的分位数对比、空值率对比。
total_ratio = df['channel'].value_counts(normalize=True) sample_ratio = sample_df['channel'].value_counts(normalize=True) compare = pd.DataFrame({'总体': total_ratio, '样本': sample_ratio}) compare['偏差'] = (compare['样本'] - compare['总体']).round(4) print(compare.sort_values('偏差', key=abs, ascending=False))偏差超过 1 个百分点就要警惕。如果发现某个分组偏差明显,先检查这个分组的总量是不是太小(小于 100 条时抽样波动本身就大),排除之后再考虑加大样本量或者改成按权重抽样。
对连续型字段,用quantile([0.1, 0.25, 0.5, 0.75, 0.9])对比总体和样本,两列并排看。这一步能发现随机抽样抽偏了分布的情况,比如整体样本量够了但高分位被系统性低估。
3.4 分层抽样的一个变体:按业务重要性分配配额
严格按比例分层有时候不实用。比如做渠道分析时,主力渠道占 90% 的量,长尾渠道加起来只占 10% 但包含几十个渠道,按比例抽的话每个长尾渠道只能分到几条,根本没法单独看。
这时候用配额抽样更合理:主力渠道按比例抽,长尾渠道强制保底若干条。
big = stratified_sample(df[df['channel'].isin(['A', 'B'])], 'channel', frac=0.02) small = stratified_sample(df[~df['channel'].isin(['A', 'B'])], 'channel', n_per_group=50) sample_df = pd.concat([big, small], ignore_index=True)这样做出来的样本不能直接用来算整体均值,因为它经过了人为加权。但如果用途是"看每个渠道的画像",那完全合理。关键在于样本用途决定了抽样方案,而不是反过来。这一点我会在交付分析结果时明确写出来,避免下游拿着加权样本去算全站 GMV。
4. groupby 的执行逻辑与 agg 写法:把聚合结果做成能直接进报表的形状
聚合是三个环节里最常用的,也是最容易产生"结果对不上"的环节。原因往往不在groupby本身,而在于 NaN 的处理、索引的形状、以及聚合函数的语义选择——sum()对全 NaN 分组返回 0,mean()对全 NaN 分组返回 NaN,这两个行为的差异能让报表数字出现明显偏差。
4.1 groupby 到底做了什么,为什么要关注 as_index 和 observed
groupby的执行过程是拆分-应用-合并:按 key 把数据切成若干块,对每块执行聚合函数,再把结果拼成新表。理解这一点,很多行为的来源就清楚了。
as_index=False决定了分组字段是留在索引上还是变成普通列。大多数接报表的场景都应该设成False,因为后续要merge、要导出 Excel、要写进数据库,索引里的列很麻烦:
result = df.groupby('channel', as_index=False).agg(gmv=('amount', 'sum'))dropna决定分组字段为 NaN 的记录要不要参与。默认是丢弃,这在统计上合理,但在业务上经常出问题——空的分组字段往往代表"未归类",直接丢掉会让总数对不上。我一般会先把空值填成'未知'再分组,这样总量是守恒的。
observed=True前面提过,主要影响 categorical 类型。还有一个相关的坑:如果分组字段的 dtype 是object但实际取值只有少数几个,可以先转成category,分组速度能快好几倍,内存也能省一半以上。
4.2 命名聚合比传字典更清晰,也更好接下游
聚合的写法有好几种,从差到好排下来:
# 写法一:多函数聚合,结果是 MultiIndex 列 df.groupby('channel').agg({'amount': ['sum', 'mean', 'count']}) # 写法二:传字典,只能给一列配一个函数(或函数列表) df.groupby('channel').agg({'amount': 'sum', 'order_id': 'nunique'}) # 写法三:命名聚合,推荐 summary = df.groupby('channel', as_index=False).agg( gmv=('amount', 'sum'), order_cnt=('order_id', 'nunique'), avg_price=('amount', 'mean'), p90=('amount', lambda x: x.quantile(0.9)), )写法一的问题是多函数聚合出来是二级列名,result['amount']['sum']这种取法在导出、重命名、拼接时都很别扭,得用flatten处理。写法三直接给每个聚合结果命名,列名是扁平的,可以直接进报表。
用lambda算分位数很方便,但性能很差——它是逐组调用 Python 函数,几万个分组时能慢十倍以上。数据量大时改用functools.partial或者先算分位数列再聚合:
from functools import partial q90 = partial(pd.Series.quantile, q=0.9) summary = df.groupby('channel', as_index=False).agg(p90=('amount', q90))另外还有一个语义陷阱:count和size的区别。count统计非空值数量,size统计行数(含空值)。做订单数统计时如果用错,空值多的分组会少算。nunique默认不统计空值,这个行为一般符合预期,但如果需要把空值也算一种取值,得显式dropna=False。
4.3 transform 和 apply 的分工,以及性能差距
transform的作用是"按组计算,但结果保持原表形状",用来做组内标准化、组内占比、缺失值组内填充特别方便:
df['channel_avg'] = df.groupby('channel')['amount'].transform('mean') df['amount_ratio'] = df['amount'] / df['channel_avg'] df['rank_in_channel'] = df.groupby('channel')['amount'].rank(ascending=False, method='dense')如果不要求保留原形状,只是想拿到每组一个值,那就用agg,它比transform快,因为不需要把结果广播回每一行。
apply最灵活,可以返回任意形状,但也是三者里最慢的。它的价值在于逻辑复杂、没法用内置函数表达的场景,比如组内要做时间序列插值、要做排序后取第 N 行:
top3 = df.groupby('channel', group_keys=False).apply( lambda g: g.nlargest(3, 'amount'), include_groups=False )新版 pandas 里apply作用在 DataFrame 上时,分组列默认会包含在数据里,需要在函数里排除,否则会报错或者产生重复列。加include_groups=False是比较稳妥的写法(老版本没有这个参数,需要用g.drop(columns=...)手动剔除)。
4.4 pivot_table 和 crosstab 的适用边界
pivot_table本质上是groupby加透视,适合做二维交叉表。跟groupby的区别在于它能一次把行维度、列维度都铺开,适合直接给人看:
pivot = pd.pivot_table( df, index='channel', columns='month', values='amount', aggfunc='sum', fill_value=0, margins=True, margins_name='合计' )fill_value=0很重要,否则交叉表里没有数据的位置是NaN,做后续计算时会传播。margins=True会自动加行列合计,省去手工算总数的一步,但要注意合计行在索引里叫'All'(可以自定义),跟数据行混在一起,如果后面还要做运算记得先剔除。
crosstab适合算频次分布,比pivot_table写起来简洁:
pd.crosstab(df['channel'], df['status'], normalize='index', margins=False)normalize='index'按行归一化,输出的就是每个渠道里各状态的占比,做转化率分析很方便。normalize='columns'按列,normalize=True按总数。这三个值容易搞混,我的记法是:归一化的方向就是"分母所在的方向"。
5. 三个环节串成一条流水线:可复用函数与几个高频报错
分开看每个环节都不难,真到项目里,麻烦出在三者交织的地方。异常值处理改变了分布,抽样就要重新校验;抽样让部分分组数据变少,聚合时就要处理小样本分组;聚合结果又要回到异常值判断里做交叉验证。把这些串起来,我的做法是把每一步写成独立函数,中间产物落盘,方便回溯。
5.1 函数拆分与参数设计
整条流水线拆成四个函数,每个函数只做一件事,参数显式传入,不依赖全局变量:
def profile(df, cols): """输出字段体检表:空值率、唯一值数、分位数、疑似哨兵值""" rows = [] for c in cols: s = df[c] rows.append({ 'col': c, 'dtype': str(s.dtype), 'null_rate': round(s.isna().mean(), 4), 'nunique': s.nunique(), 'min': s.min() if pd.api.types.is_numeric_dtype(s) else None, 'p01': s.quantile(0.01) if pd.api.types.is_numeric_dtype(s) else None, 'p50': s.quantile(0.5) if pd.api.types.is_numeric_dtype(s) else None, 'p99': s.quantile(0.99) if pd.api.types.is_numeric_dtype(s) else None, 'max': s.max() if pd.api.types.is_numeric_dtype(s) else None, }) return pd.DataFrame(rows) def flag_outliers(df, col, method='mad', k=3.5): """只打标记,不删数据""" s = df[col] if method == 'mad': med = s.median() mad = max((s - med).abs().median(), 1e-9) score = 0.6745 * (s - med) / mad return (score.abs() > k).fillna(False) if method == 'iqr': q1, q3 = s.quantile([0.25, 0.75]) iqr = q3 - q1 return ~s.between(q1 - 1.5 * iqr, q3 + 1.5 * iqr) raise ValueError(f'unknown method: {method}') def build_sample(df, group_col, frac=0.05, min_n=30, seed=42): """分层抽样,小分组保底 min_n 条""" parts = [] for key, g in df.groupby(group_col, observed=True, sort=False): k = max(min_n, int(round(len(g) * frac))) parts.append(g.sample(n=min(k, len(g)), random_state=seed)) return pd.concat(parts, ignore_index=True) def build_summary(df, group_col, value_col, id_col): return df.groupby(group_col, as_index=False).agg( cnt=(id_col, 'nunique'), total=(value_col, 'sum'), avg=(value_col, 'mean'), p50=(value_col, 'median'), )这样拆的好处是每一步都能单独验证。profile的输出可以直接贴进分析报告当数据质量说明,flag_outliers加了_flag列而不删数据,出问题随时能切换策略重跑。
5.2 SettingWithCopyWarning 的根因和三种消除方式
这个警告几乎人人都见过,很多人直接warnings.filterwarnings('ignore')了事,但它是真实问题的信号。根因在于 pandas 无法确定你拿到的 DataFrame 是原始数据的视图还是副本,赋值操作可能改了原表也可能没改,行为不确定。
最常见的触发写法是链式索引:
# 触发警告 df[df['status'] == 'paid']['discount'] = 0.9 # 正确写法一:先 copy sub = df[df['status'] == 'paid'].copy() sub['discount'] = 0.9 # 正确写法二:用 loc 一次定位 df.loc[df['status'] == 'paid', 'discount'] = 0.9写法二最推荐,一次完成定位和赋值,不会产生中间对象。写法的关键是copy()不能省,很多人觉得加了copy()会多占内存,实际上 pandas 3.0 之后写时复制是默认行为,copy()的开销比想象中小得多,而且能避免一堆难以排查的问题。
还有一类更隐蔽的情况:merge之后修改某一列时触发这个警告。原因是merge结果的某些块可能跟原始 DataFrame 共享内存。处理方法一样,merge之后如果要做原地修改,先.copy()。
5.3 dtype 被悄悄改掉:整数变浮点、空值被当成 0
这个坑比上一个大得多,因为它不报警告,只给你一个错误的数字。
第一个典型场景:原本是int64的列里出现空值,pandas 会自动把它转成float64。看起来只是类型变了,实际影响是:id列变成浮点之后,作为字典键或者跟字符串拼接会出现123.0这种结果,去重、关联、导出全都会出问题。
解法是用可空整型:
df['qty'] = df['qty'].astype('Int64') # 注意大写 IInt64(大写)是 pandas 扩展类型,支持pd.NA;int64(小写)是 numpy 类型,不支持。这个大小写区别坑过很多人。同理还有string和object、boolean和bool。
第二个场景:sum()把 NaN 当 0 加。这是sum的设计行为,skipna=True是默认值。影响在于分母——如果算客单价时用amount_sum / order_count,而amount有一批是 NaN,那分子少算、分母照算,客单价就被系统性低估。正确做法是先把空值处理掉再算,或者在聚合里显式指定:
df.groupby('channel').agg( gmv=('amount', lambda x: x.sum(min_count=1)), # 全空时返回 NaN 而不是 0 cnt=('order_id', 'nunique'), )min_count=1的意思是"至少要有 1 个非空值才计算结果",全空分组返回 NaN,这样在报表里一眼就能看出哪些分组数据缺失,而不是被 0 悄悄糊过去。
第三个场景:groupby之后 dtype 发生变化。分类字段转成索引之后类型可能变,尤其是用了category的地方。做完聚合之后加一句print(result.dtypes)检查一下,养成习惯能省掉大量"为什么数字不对"的排查时间。
5.4 内存与速度:category 和避免 apply 的实测差距
数据量上到千万行之后,性能就变成必须考虑的事。我做过一轮对比,同一份 800 万行的订单数据,几个改动带来的差异很明显:
| 改动 | 大致效果 | 说明 |
|---|---|---|
分组字段转category | 分组耗时下降 40%–60% | 取值基数低时收益最大 |
用agg命名聚合替代apply | 聚合耗时下降一个数量级 | apply是逐组 Python 调用 |
数值列降精度float32 | 内存占用减少约一半 | 精度要求不高的场景可用 |
读数据时指定dtype和usecols | 读取阶段内存减少 60% 以上 | 从源头控制最有效 |
用pd.NA替代np.nan | 语义更清晰,内存略增 | 整数列可空时必需 |
category的收益来自两点:一是值实际只存一份,每个位置存的是整数编码,内存从"每行一个字符串"变成"每行一个小整数";二是分组时可以直接按整数编码做哈希,比字符串比较快得多。转的时候要注意,如果某列取值基数接近行数(比如用户 ID),转成 category 反而更慢更占内存,转之前先看nunique() / len(df)这个比值,低于 0.1 再考虑。
读取阶段优化是最划算的,因为省的是全流程的内存峰值:
dtype_map = { 'order_id': 'string', 'channel': 'category', 'qty': 'Int32', 'amount': 'float32', } df = pd.read_csv('orders.csv', usecols=list(dtype_map), dtype=dtype_map)usecols只读需要的列,这一步经常能省掉一半以上的内存和时间。很多人的习惯是先全量读进来再删列,实际上源头就过滤掉更高效。float32要注意精度,金额类字段如果对小数位有严格要求(比如到分),用float32可能产生累积误差,建议保留float64,或者干脆用整数存"分"。
最后提一个容易被忽略的性能陷阱:在groupby之后链式调用多次聚合,不如一次agg里全写完。每次链式调用都会重新触发一次分组计算,虽然 pandas 内部有一些优化,但显式一次写完更稳定。我在几个项目里对比过,把三次独立的groupby().sum()、groupby().mean()、groupby().count()合并成一个agg,耗时大约能降到三分之一。
还有一个我个人的小习惯:所有聚合结果在导出之前,先跑一次"总量守衡校验"——用原始数据的amount.sum()和聚合结果的total.sum()对比,两者必须完全相等(浮点用np.isclose)。这一步能抓出绝大多数过滤条件写错、空值被丢弃、分组字段意外为 NaN 的问题。写完一条流水线,加上这个校验,交付时心里就有底了。