ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Pandas数据处理:异常值、抽样与聚合避坑指南

Pandas数据处理:异常值、抽样与聚合避坑指南 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(includeall).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 是 38Q3 是 96上界算出来只有 183但实际上千的单子占了 8%这些全是真实的大客户订单。通用的补救办法是先做对数变换再算 IQR或者直接用分位数缩尾比如把上下 1% 的值截断到 1% 和 99% 分位的值上后者对业务解释更友好因为它不改变数据的单调性lo, hi df[amount].quantile([0.01, 0.99]) df[amount_winsor] df[amount].clip(lowerlo, upperhi)1.3 删除、缩尾、标记、分箱四种处置各自适用什么场景检测出来之后怎么处理这是最见功力的地方。我整理了一张对照表基本覆盖了日常遇到的场景处置方式适用场景风险点直接删除确认为错误值且占比低于 1%占比高时会造成样本偏差缩尾clip极端值真实存在但要算均值类指标会压缩真实差异需在报告里注明打标记不删极端值本身是分析对象如大客户识别下游所有计算都要带上标记过滤分箱处理用于建模特征或做分布对比分箱边界选择主观性强单独分组结构性偏移如某渠道口径不同需要额外解释容易让报表难读删除这条我想多说一句。很多教程写删除 3σ 之外的记录但从来没说删完之后必须重新检查分布。原因很简单删掉尾部会改变均值、方差、分位数如果下游还有别的过滤条件两次过滤叠加可能把某个分组删空。我的习惯是删除前后各跑一次groupby.size()确认每个分组的剩余量都还够做结论经验阈值是单组不低于 30 条不够就别删改成标记。还有一个更隐蔽的坑删除操作会破坏索引连续性。如果后续用位置索引iloc或者把索引当成 ID 用删完就容易错位。所以删除之后一律加.reset_index(dropTrue)或者干脆养成习惯任何过滤之后都重置一次。2. 检测手段的选型从 MAD 到孤立森林每种方法的失效条件是什么搞清楚异常值的分类之后接下来的问题是用什么方法找出来。这里有个很普遍的误区方法越复杂越好。实际上孤立森林、LOF 这些算法在业务数据上的表现经常不如一个老老实实的分位数阈值原因是它们对参数和数据尺度非常敏感而业务数据往往是脏的、混合分布的、量纲不统一的。我的选型逻辑是按数据形态走单变量近似对称的用 Z-score单变量偏态的用 MAD多变量相关的用马氏距离或孤立森林时间序列用滚动统计量。下面逐个说清楚它们的前提和失效边界。2.1 Z-score 的前提是近似正态偏态数据上它会误伤Z-score 就是标准分(x - mean) / std阈值一般取 3z (df[amount] - df[amount].mean()) / df[amount].std() outliers df[z.abs() 3]写法简单到没什么可说问题在于它的两个隐含假设均值能代表中心标准差能代表离散程度。这两个假设只有在分布近似对称时才成立。右偏数据里尾部的大值会把均值往上拉同时把标准差撑大结果是真正的异常反而算不出高分。这个现象叫掩蔽效应是 Z-score 在业务数据上最致命的问题。还有一个技术细节容易被忽略std()默认是样本标准差ddof1如果跟别的系统比对结果时对方用的是总体标准差阈值会有一点差异。数据量大的时候影响可以忽略数据量小的时候比如几百条值得注意。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)对大多数记录是 0MAD 就是 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(axis0) cov_inv np.linalg.pinv(np.cov(X, rowvarFalse)) 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_estimators200, contamination0.01, max_samplesauto, random_state42, 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(dropTrue) roll_med df[amount].rolling(window7, min_periods3).median() roll_mad (df[amount] - roll_med).abs().rolling(window7, min_periods3).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(n1000, random_state42) # 固定条数 df.sample(frac0.1, random_state42) # 固定比例 df.sample(n1000, replaceTrue, random_state42) # 有放回 df.sample(n1000, weightsweight_col, random_state42) # 加权random_state在需要复现的场景必须固定尤其是做 A/B 对比分析时两次抽样用了不同的种子结果差异可能完全来自抽样本身而不是业务变化。replaceTrue只在一种情况下需要样本量要超过总体或者要做 Bootstrap 估计。日常抽样基本不用因为重复样本会让统计量的方差被低估。weights是加权抽样适合做重要性抽样或者过采样。有个坑值得说weights可以传列名也可以传数组但如果传入的 Series 索引和 DataFrame 索引不一致pandas 会按位置对齐而不是按索引结果可能完全错乱。稳妥做法是直接传.to_numpy()。frac和n不能同时指定这个大家都知道。但有个隐蔽问题frac大于 1 且没设replaceTrue时会报错而frac1返回的是打乱后的全量数据不是原始顺序很多人在这一步误以为数据被改了。3.2 分层抽样手写循环比 groupby.apply 更省心分层抽样保证每个分组在样本里的比例和总体一致这是报表抽样几乎必做的事。常见写法有两种我更推荐手写循环def stratified_sample(df, col, n_per_groupNone, fracNone, seed42): parts [] for key, g in df.groupby(col, observedTrue, sortFalse): if n_per_group is not None: k min(n_per_group, len(g)) parts.append(g.sample(nk, random_stateseed)) else: parts.append(g.sample(fracfrac, random_stateseed)) return pd.concat(parts, ignore_indexTrue) sample_df stratified_sample(df, channel, frac0.05)为什么不用groupby.apply(lambda x: x.sample(...))两个原因。一是新版 pandas 对返回结果形状不一致的apply会有弃用警告行为在版本间有变化维护成本高。二是apply会把每个分组当独立函数调用几万个分组的时候速度慢一个数量级。手写循环虽然多几行但行为完全可控出问题好排查。min(n_per_group, len(g))这个保护必须加。小分组的数据量小于要求抽样量时sample(nk)会直接抛ValueError整个流程中断。用min兜底配合后面的一致性核对能马上发现哪些分组样本不足。observedTrue也不能省。如果你的分组字段是category类型默认行为会把所有类别组合都列出来包括数据里根本不存在的组合白跑一堆空分组。新版 pandas 里这个默认值已经改了但如果代码要兼容老版本显式写上更保险。3.3 样本量怎么估以及抽完必须做的一致性核对样本量不是拍脑袋定的。判断比例类指标比如转化率时用这个公式n z² × p × (1-p) / e²z是置信水平对应的分位数95% 对应 1.96p是预期比例不知道就取 0.5此时方差最大e是允许误差。取p0.5、e0.03算出来 n 约等于 1067。如果总体本身不大还要做有限总体修正n_adj n / (1 (n - 1) / N)总体 5000 条时修正后的样本量是 878比 1067 小了不少。这个修正很容易被忽略导致对小数据集做了不必要的过度抽样。抽完之后必须做一致性核对这一步比抽样本身更重要。我通常跑三件事分组占比对比、关键字段的分位数对比、空值率对比。total_ratio df[channel].value_counts(normalizeTrue) sample_ratio sample_df[channel].value_counts(normalizeTrue) compare pd.DataFrame({总体: total_ratio, 样本: sample_ratio}) compare[偏差] (compare[样本] - compare[总体]).round(4) print(compare.sort_values(偏差, keyabs, ascendingFalse))偏差超过 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, frac0.02) small stratified_sample(df[~df[channel].isin([A, B])], channel, n_per_group50) sample_df pd.concat([big, small], ignore_indexTrue)这样做出来的样本不能直接用来算整体均值因为它经过了人为加权。但如果用途是看每个渠道的画像那完全合理。关键在于样本用途决定了抽样方案而不是反过来。这一点我会在交付分析结果时明确写出来避免下游拿着加权样本去算全站 GMV。4. groupby 的执行逻辑与 agg 写法把聚合结果做成能直接进报表的形状聚合是三个环节里最常用的也是最容易产生结果对不上的环节。原因往往不在groupby本身而在于 NaN 的处理、索引的形状、以及聚合函数的语义选择——sum()对全 NaN 分组返回 0mean()对全 NaN 分组返回 NaN这两个行为的差异能让报表数字出现明显偏差。4.1 groupby 到底做了什么为什么要关注 as_index 和 observedgroupby的执行过程是拆分-应用-合并按 key 把数据切成若干块对每块执行聚合函数再把结果拼成新表。理解这一点很多行为的来源就清楚了。as_indexFalse决定了分组字段是留在索引上还是变成普通列。大多数接报表的场景都应该设成False因为后续要merge、要导出 Excel、要写进数据库索引里的列很麻烦result df.groupby(channel, as_indexFalse).agg(gmv(amount, sum))dropna决定分组字段为 NaN 的记录要不要参与。默认是丢弃这在统计上合理但在业务上经常出问题——空的分组字段往往代表未归类直接丢掉会让总数对不上。我一般会先把空值填成未知再分组这样总量是守恒的。observedTrue前面提过主要影响 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_indexFalse).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, q0.9) summary df.groupby(channel, as_indexFalse).agg(p90(amount, q90))另外还有一个语义陷阱count和size的区别。count统计非空值数量size统计行数含空值。做订单数统计时如果用错空值多的分组会少算。nunique默认不统计空值这个行为一般符合预期但如果需要把空值也算一种取值得显式dropnaFalse。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(ascendingFalse, methoddense)如果不要求保留原形状只是想拿到每组一个值那就用agg它比transform快因为不需要把结果广播回每一行。apply最灵活可以返回任意形状但也是三者里最慢的。它的价值在于逻辑复杂、没法用内置函数表达的场景比如组内要做时间序列插值、要做排序后取第 N 行top3 df.groupby(channel, group_keysFalse).apply( lambda g: g.nlargest(3, amount), include_groupsFalse )新版 pandas 里apply作用在 DataFrame 上时分组列默认会包含在数据里需要在函数里排除否则会报错或者产生重复列。加include_groupsFalse是比较稳妥的写法老版本没有这个参数需要用g.drop(columns...)手动剔除。4.4 pivot_table 和 crosstab 的适用边界pivot_table本质上是groupby加透视适合做二维交叉表。跟groupby的区别在于它能一次把行维度、列维度都铺开适合直接给人看pivot pd.pivot_table( df, indexchannel, columnsmonth, valuesamount, aggfuncsum, fill_value0, marginsTrue, margins_name合计 )fill_value0很重要否则交叉表里没有数据的位置是NaN做后续计算时会传播。marginsTrue会自动加行列合计省去手工算总数的一步但要注意合计行在索引里叫All可以自定义跟数据行混在一起如果后面还要做运算记得先剔除。crosstab适合算频次分布比pivot_table写起来简洁pd.crosstab(df[channel], df[status], normalizeindex, marginsFalse)normalizeindex按行归一化输出的就是每个渠道里各状态的占比做转化率分析很方便。normalizecolumns按列normalizeTrue按总数。这三个值容易搞混我的记法是归一化的方向就是分母所在的方向。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, methodmad, k3.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(funknown method: {method}) def build_sample(df, group_col, frac0.05, min_n30, seed42): 分层抽样小分组保底 min_n 条 parts [] for key, g in df.groupby(group_col, observedTrue, sortFalse): k max(min_n, int(round(len(g) * frac))) parts.append(g.sample(nmin(k, len(g)), random_stateseed)) return pd.concat(parts, ignore_indexTrue) def build_summary(df, group_col, value_col, id_col): return df.groupby(group_col, as_indexFalse).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.NAint64小写是 numpy 类型不支持。这个大小写区别坑过很多人。同理还有string和object、boolean和bool。第二个场景sum()把 NaN 当 0 加。这是sum的设计行为skipnaTrue是默认值。影响在于分母——如果算客单价时用amount_sum / order_count而amount有一批是 NaN那分子少算、分母照算客单价就被系统性低估。正确做法是先把空值处理掉再算或者在聚合里显式指定df.groupby(channel).agg( gmv(amount, lambda x: x.sum(min_count1)), # 全空时返回 NaN 而不是 0 cnt(order_id, nunique), )min_count1的意思是至少要有 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, usecolslist(dtype_map), dtypedtype_map)usecols只读需要的列这一步经常能省掉一半以上的内存和时间。很多人的习惯是先全量读进来再删列实际上源头就过滤掉更高效。float32要注意精度金额类字段如果对小数位有严格要求比如到分用float32可能产生累积误差建议保留float64或者干脆用整数存分。最后提一个容易被忽略的性能陷阱在groupby之后链式调用多次聚合不如一次agg里全写完。每次链式调用都会重新触发一次分组计算虽然 pandas 内部有一些优化但显式一次写完更稳定。我在几个项目里对比过把三次独立的groupby().sum()、groupby().mean()、groupby().count()合并成一个agg耗时大约能降到三分之一。还有一个我个人的小习惯所有聚合结果在导出之前先跑一次总量守衡校验——用原始数据的amount.sum()和聚合结果的total.sum()对比两者必须完全相等浮点用np.isclose。这一步能抓出绝大多数过滤条件写错、空值被丢弃、分组字段意外为 NaN 的问题。写完一条流水线加上这个校验交付时心里就有底了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表