ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

提升pandas效率:数据科学家必会的10个高级函数

提升pandas效率:数据科学家必会的10个高级函数 如果你每天都在和 DataFrame 打交道大概率遇到过这种情况明明逻辑不复杂就是为了给数据补一列、做一次分组汇总、按条件筛一遍代码却绕来绕去跑起来还慢得让人想砸电脑。pandas 的基础函数确实够用但“够用”和“高效”是两码事。真正拉开效率差距的往往不是你会不会写循环而是有没有用对那几个能直接向量化解决问题的高级函数。这篇文章要讲的 10 个函数是我在实际项目中反复验证过、几乎每个正式分析任务都会碰到的pipe、assign、melt、pivot_table、crosstab、cut、qcut、transform、merge_asof、query/eval。它们解决的核心问题很集中数据重塑、条件分箱、管道式处理、分组变换、时间靠近匹配、表达式加速。下面我会按工作场景来拆而不是按官方文档的目录生搬硬套。1. 先想清楚你为什么要用“高级函数”1.1 pandas 的慢多半是“写法”造成的很多人对 pandas 的印象是“处理小数据还行数据一多就卡”。这话对但只说对了一半。pandas 的慢绝大多数时候不是引擎本身的问题而是写法把引擎的优势浪费掉了。pandas 底层的很多操作走的是 numpy 的向量化通道。所谓向量化可以理解为“一次性把一列当作整体来算”而不是 for 循环里一行一行取出来再拼回去。前者是批量操作后者等于把批量工具拆成了手工工具。同样是算一列新值df[new_col] df[a] * 2几乎瞬间完成而for i in range(len(df)): df.loc[i, new_col] df.loc[i, a] * 2在几万行数据上就能明显感觉到卡顿到百万行基本就是一个等咖啡的时长。高级函数不是玄学它是在帮你把“循环 条件判断 逐行赋值”这种低效模式替换成“列与列之间的批量映射”。所以理解这些函数的价值首先要理解你在避免什么。1.2 高级函数解决的三类问题我把日常数据处理里最常见、也最容易把人绕晕的问题归纳成三类结构变换、分组扩展、条件映射。结构变换宽表变长表、长表变宽表、交叉表。代表函数是 melt、pivot_table、crosstab。分组扩展按组做聚合但聚合结果需要保持与原表行数一致或者需要把结果贴回每一行。代表函数是 transform。条件映射把连续值切成段、把一组条件批量映射到列上、把复杂业务规则串成一条处理链。代表函数是 cut、qcut、assign、pipe。这三类问题覆盖了 Excel 用户最常做的数据透视、分组汇总、新增计算列、条件分档也覆盖了程序员最头痛的“写循环改数据”的场景。把这 10 个函数吃透相当于把一个 500 行的数据处理脚本缩到 80 行以内而且每一步都能被复核。2. 重塑型函数melt、pivot_table、crosstab把表格翻来覆去才是基本功2.1 melt宽表变长表先看一个很典型的报表数据。月度销售宽表长这样每一行是一个门店每一列是一个月份store jan feb mar A 120 135 142 B 98 110 116 C 210 225 230这种表人看着舒服但不适合分析。你想按月份做趋势、按门店做对比或者喂给后面的可视化库都得先把“月份”和“销售额”拆成两列。这时就是 melt 的主场import pandas as pd df pd.DataFrame({ store: [A, B, C], jan: [120, 98, 210], feb: [135, 110, 225], mar: [142, 116, 230] }) long_df pd.melt( df, id_vars[store], value_vars[jan, feb, mar], var_namemonth, value_namesales )输出结果就是标准的长表结构store month sales A jan 120 A feb 135 A mar 142 B jan 98 ...我在项目里见到最多的 melt 用法是把多期问卷数据、多季度财务数据、多时点监控数据转换成可分析格式。melt 之后的数据天然适合 groupby 聚合和按时间排序。需要注意的一个细节是value_vars不填时默认会把id_vars之外的所有列都展开如果你的表里还有备注列它也会被当成指标展开所以显式指定要展开哪些列更稳妥。2.2 pivot_table长表变宽表melt 的反向操作是 pivot_table。回到上面的长表如果想看每个门店每个月的销售额矩阵直接用 pivot_tablewide_df long_df.pivot_table( indexstore, columnsmonth, valuessales, aggfuncsum, fill_value0 )为什么不用pivot因为pivot要求行和列的组合必须唯一一旦数据里有重复项直接抛 ValueError。pivot_table 内置 aggfunc遇到重复值时会自动聚合默认是求平均但我建议显式指定一般用 sum 或 np.mean 更符合预期。pivot_table 还有两个参数非常实用marginsTrue会在结果里追加总计行和总计列快速看合计fill_value0会把空值填充掉避免透视表里出现大片的 NaN后续计算也不用额外处理。做报表输出时这两个参数相当于帮你省掉了两步后处理。2.3 crosstab交叉表crosstab 可以理解成“专门做分组计数/占比的透视表”。它不关心你原来的数据结构只要你给它两列就能算出交叉频次。比如一份用户订单表有city和category两列想看不同城市的用户在不同品类上的订单量分布cross pd.crosstab( df[city], df[category], marginsTrue, normalizeindex )normalizeindex是按行归一化也就是每个城市内部各品类的占比。这个参数在生产环境里非常常用比如做渠道转化分析、地区品类偏好分析。crosstab 看起来只是一个高级的 groupby但实际差别在于它天然会整理成二维透视结构省掉了后续的 unstack。3. 条件分箱与离散化cut、qcut 让连续值变成业务分档3.1 cut按业务边界分箱连续数据的处理里有一个高频需求按订单金额分档比如“低客单、中客单、高客单”按年龄分段比如“青年、中年、老年”按响应时长分桶比如“快、中、慢”。这种把连续值切成分段的操作叫分箱pandas 里对应的是 cut。bins [0, 50, 100, 500, float(inf)] labels [低客单, 中客单, 高客单, 超高客单] df[price_level] pd.cut( df[order_amount], binsbins, labelslabels )注意这里我用float(inf)做了右开区间兜底避免超高价订单被切到 NaN。cut 的边界设计非常讲究要用业务口径而不是用数据分布。比如促销规则是“满 50 减 10、满 100 减 20”那么分箱边界就应该切在 50、100 这些规则节点上而不是 52、98 这种统计节点。3.2 qcut按分位数均匀分箱有些场景下你不知道业务边界或者数据分布严重偏斜比如用户消费金额集中在 100 到 300 之间但偶尔有几个几十万的超级订单。这时用 cut 按固定边界切会出现某些箱人数为 0某些箱人数爆炸。qcut 会按数据的实际分位数来切保证每一箱的数据量大致相等。比如把用户按消费能力分成四等份df[spend_quantile] pd.qcut( df[total_spend], q4, labels[第一梯队, 第二梯队, 第三梯队, 第四梯队] )qcut 最典型的应用是 RFM 模型里的打分把最近消费时间、消费频率、消费金额分别切成 1 到 5 分再组合出用户分层。核心优势是它不依赖人拍脑袋定边界而是从数据自身分布出发。不过要注意如果数据里有大量重复值分箱边界可能重复此时会报错需要加duplicatesdrop这个坑我在第 8 节会展开。3.3 真实应用用户分群我在一个零售项目里做过一次会员分层。原始数据是几万用户的消费总金额和下单次数。直接把金额放进去做平均数十几个大额订单就把均值拉得很离谱。后来用 qcut 把金额切成 5 档再用 cut 把下单频率切成“低频、中频、高频”两个维度透视一下立刻就能看出哪些人群贡献了核心收入。整个过程核心就是 cut 和 qcut 两行代码却比太多花哨的聚类算法更可解释、更可落地。业务部门开会时直接看图不需要我解释什么是轮廓系数。4. 管式数据处理assign 和 pipe 让代码像流水线4.1 assign链式创建新列很多人建新列还是这老一套df[total_price] df[price] * df[quantity] df[discount_price] df[total_price] * 0.9 df[profit] df[discount_price] - df[cost]没问题但一旦你有十几个新列脚本就会变成一片赋值语句改起来非常痛苦中间变量满天飞。assign 可以把多个新列放在一次链式调用里完成df df.assign( total_pricedf[price] * df[quantity], discount_pricelambda x: x[total_price] * 0.9, profitlambda x: x[discount_price] - x[cost] )这里有个关键点后面的列想引用前面刚创建的列必须用 lambda 表达式来引用。如果你直接写df[discount_price]此时 df 还没有这一列会报 KeyError。lambda 里的x代表当前阶段的数据框它能感知到同一次 assign 里已经创建出的列。这个特性被文档讲得很少但实际是链式处理的核心。assign 还有一个隐蔽优势它不会修改原 DataFrame而是返回一个新对象。这个特性在“多次探索性分析”时特别重要——你可以在不破坏原始数据的前提下反复构建不同的衍生字段。4.2 pipe把“步骤”变成“管道”assign 解决的是“建列”问题pipe 解决的是“流程复用”问题。当一段处理逻辑需要传参、需要复用、需要被多步骤串联时写成一个函数然后用 pipe 接起来是远优于嵌套调用的做法。举个例子你有一个数据清洗流程先去掉异常值再补缺失值最后生成一个日期字段def drop_outliers(df, col, lower, upper): return df[(df[col] lower) (df[col] upper)] def fill_na(df, col, value): return df[col].fillna(value) def add_weekday(df, date_col): return df.assign(weekdaypd.to_datetime(df[date_col]).dt.day_name()) df ( pd.read_csv(orders.csv) .pipe(drop_outliers, colamount, lower10, upper5000) .pipe(fill_na, colquantity, value1) .pipe(add_weekday, date_colorder_time) )如果你用嵌套函数写就是add_weekday(fill_na(drop_outliers(df, ...), ...), ...)。“括号套括号”看着头疼一旦流程超过三步缩进和可读性都崩了。pipe 把括号拆成了纵向的链条每一步都是独立一行阅读顺序和业务执行顺序完全一致。调试的时候只需要在某一个 pipe 后面打断点就能看到当前环节的数据长什么样。4.3 什么时候用 pipe 而不是 apply一个很常见的误区是把 pipe 和 apply 混着用。apply 是针对行或列做逐条运算pipe 是针对整个 DataFrame 做阶段处理。你可以在 pipe 的链式结构里自由调用 apply、assign、query 等任何函数但反过来不行。我的经验是凡是把“整个表”作为输入、输出也是“整个表”的函数优先用 pipe凡是“按行按值算字段”的逻辑优先走向量化操作或 apply但 apply 只当最后手段。理由我在第 7 节会再提。5. 分组变换transform 把聚合结果“贴回”每一行5.1 transform 与 agg 的区别groupby 之后的 agg 大家用得很多但 agg 有个天然限制结果的行数等于分组数没法直接和原始表对齐。比如你按城市分组求平均消费额得到的结果是“每个城市一行”而你想做的是“每一行订单都带上它所属城市的人均消费额”用于看单笔订单和城市平均水平的差距。transform 就是为这个需求设计的。它进行分组计算但结果会保持与原 DataFrame 相同的行数df[city_avg_amount] df.groupby(city)[amount].transform(mean) df[gap] df[amount] - df[city_avg_amount]这一招在分析里太常用了。比如找出每个城市中消费显著高于本地平均的订单比如把每个用户的第一单时间、最新一单时间回贴到每一行。前者是均值差后者可以写成df[first_order_time] df.groupby(user_id)[order_time].transform(min) df[last_order_time] df.groupby(user_id)[order_time].transform(max)5.2 常用场景组内标准化、填缺失值transform 更强的地方在于支持自定义函数。比如按组做标准化df[amount_zscore] df.groupby(city)[amount].transform( lambda x: (x - x.mean()) / x.std() )还有一类经典场景是组内缺失值填充。同一用户的历史订单里有些订单的会员等级字段是空的而你希望用该用户其他订单的等级来填充。groupby transform 可以做到df[member_level] df.groupby(user_id)[member_level].transform( lambda x: x.fillna(x.mode()[0]) # 用队里最频繁值填 )不过要小心如果某用户所有记录都是空值x.mode()[0]会报错。稳妥的做法是提前过滤或者自定义一个带回退的函数。5.3 transform 写法的注意点transform 里的函数要求输入是一个 Series输出要么是一个和输入等长的 Series要么是一个标量pandas 会自动广播。最容易踩的坑是函数里用了条件过滤导致返回长度不一致。比如# 错误的用法x[x 100].mean() 会返回一个标量但 transform 要求对齐 df.groupby(city)[amount].transform(lambda x: x[x 100].mean())报错信息通常是“length of returned values does not match length of index”。你的函数必须始终返回和传入一样长度的结果或者返回单个用来广播的标量。这个细节我帮同事排查过很多次每次都写在上文提到的“注意事项”里。6. 合并进阶merge_asof 和时间序列移位6.1 merge_asof最近时间匹配如果你的数据处理经常涉及“把两张表按时间匹配”那么 merge_asof 可能是 pandas 里被你严重低估的宝藏函数。想象一个场景你有订单表每一行是订单下单时间你还有一张商品价格表记录的是每个商品在不同时间点的最新价格。价格表不是每天都更新而是隔几天调一次价。你想知道每笔订单下单当日适用的是哪个价格直接 merge 会失败因为时间戳对不上用日期差匹配又容易糊。merge_asof 解决的就是这种“最近时间匹配”问题。它要求两张表都按时间字段排序然后为左表的每一行找到右表中时间不晚于左表时间的最近一行price_changes price_changes.sort_values(effective_date) orders orders.sort_values(order_time) merged pd.merge_asof( orders, price_changes, left_onorder_time, right_oneffective_date, directionbackward )directionbackward表示向左回溯是默认方向意思就是找“最新生效但还没失效的价格”。还有directionnearest和directionforward两种变体分别适用于找最近调价、或找下一次调价时间点的场景。这个函数我第一次用是在一个供应链项目里把所有入库记录和最新采购价对齐代码从原来的几十行两两匹配缩成了一行速度还快了几个量级。6.2 shift 和 diff环比、同比、增量时间序列分析里shift 和 diff 是绕不开的基础工具。shift 可以把某一列整体向后移动从而和上一期数据对齐。比如算日销售额的环比变化df[prev_sales] df[sales].shift(1) df[diff_sales] df[sales] - df[prev_sales]更高效的是直接用 diffdf[diff_sales] df[sales].diff(1)diff 就是“本期减上期”的语法糖。如果你要算百分比环比可以这样df[pct_change_sales] df[sales].pct_change()还有两个容易被忽略的参数。shift的默认效果在缺失时会补 NaN不想让第一行出现空值可以用fill_value0在多组数据里做组内移位要先 groupby 再 shift而不是对全表直接移位否则会串组df[sales_prev_user] df.groupby(user_id)[sales].shift(1)我见过不少项目里环比口径算错就是因为没按用户分组直接 shift导致统计把 A 用户的上一期接到了 B 用户的当前期。错得离谱还很难发现。6.3 rolling 和 ewm滚动窗口与指数加权除了环比运营看板上最常出现的另一类指标是“近 7 日平均”“近 30 日累计”。rolling 可以轻松搞定df[sales_ma7] df[sales].rolling(window7, min_periods1).mean() df[sales_sum30] df[sales].rolling(window30, min_periods1).sum()min_periods1表示窗口内至少要 1 个有效数据才能出结果。它的意义在于数据量不足窗口大小的时候不要直接给 NaN而是用已有数据先算出结果保证序列前 6 天也有值可看。如果你想对最近数据更敏感比如分析用户活跃度老数据权重应该递减rolling 就不太合适了。此时用 ewm指数加权移动平均df[sales_ewm] df[sales].ewm(span7, adjustFalse).mean()ewm 里的span参数近似于移动窗口平均的周期但权重是平滑递减的。我对业务方解释时常用一句话它像是一个“记得昨天比前天更重要的平均线”特别适合做销售趋势中短期信号观察。这个函数在外卖订单量分析、资源调度预测里我用得很多因为业务本身对近期变化更敏感。7. 表达式加速query、eval 和隐藏的技巧7.1 query用字符串替代布尔索引每天都要做条件筛选但大多数人还在用这种写法df[(df[amount] 100) (df[city] 上海) (df[status] ! canceled)]这行代码我要盯着括号看上半天才能确认逻辑对。用 query 写则清爽很多df.query(amount 100 and city 上海 and status ! canceled)query 还有一个非常实用的变量引用方式。想引用 Python 变量时在变量名前面加min_amount 100 df.query(amount min_amount)这个语法一开始会觉得别扭但用过几次之后就回不去了。query 解决的不只是可读性它内部会做一些表达式解析优化在数据量大时往往比手动布尔索引快。不过小数据量下差异不明显我一般是列数多、条件多的时候用 query把逻辑字符串留在代码里本身就成了一种文档。7.2 eval减少中间变量、省内存eval 和 query 有同一个底层好处它可以在不生成完整中间数组的前提下完成计算降低内存占用。比如df[profit] df.eval(price * quantity * (1 - discount) - cost)再比如一次算多列df df.eval( gross price * quantity net gross * (1 - discount) profit net - cost )eval 对超大数据集的意义尤其明显。假设你有几千万行df[temp] df[a] * df[b]会产生一个新的临时列然后你再用它算下一步内存里可能就是翻倍的压力。eval 把表达式挂在引擎里中间结果不落地省下不少开销。我个人的习惯是当 DataFrame 大到跑一次df.info()要犹豫半秒时就值得用 eval。7.3 map、applymap、where被低估的小工具最后补一组容易被忽略但实用性极高的函数。Series.map适合做字典映射。比如把城市编码映射成城市名字city_map {021: 上海, 010: 北京, 020: 广州} df[city_name] df[city_code].map(city_map)DataFrame.applymap对整个 DataFrame 的每个元素做操作。比如把所有数字列统一保留两位小数df[[price, cost]] df[[price, cost]].applymap(lambda x: round(x, 2))不过 pandas 2.1 以后applymap更名为map老代码里如果收到 FutureWarning升级时注意调整写法。DataFrame.where做条件替换它的逻辑是“不满足条件的位置替换成 other”。批量清理异常值非常方便df[amount] df[amount].where(df[amount] 0, 0)这句代码的意思是把 amount 列里小于等于 0 的值替换成 0大于 0 的保留原值。很多人在这个需求上用df.loc[df[amount] 0, amount] 0其实 where 写起来更直接。同理mask是 where 的反向操作它满足条件的位置才替换。这两个函数一旦习惯会大大减少筛选赋值的样板代码。8. 常见问题与排查技巧速查表这一节我从实际项目中把最常见、也最容易让人卡住的报错和坑整理成一个表贴在下面。遇到问题时先对号入座。现象可能原因解决方案query 字符串里写变量名报错变量没有被当成外部变量识别变量前加例如df.query(amount min_value)pivot 报 ValueError: Index contains duplicate entries数据里有重复的行与列组合改用pivot_table并指定aggfuncqcut 报 Bin edges must be unique分位数边界重复数据重复过多加duplicatesdrop或先用 drop_duplicates 处理transform 报 returned length does not match自定义函数返回长度不对确保函数返回与输入等长或用标量groupby().shift() 串组没有先 groupby 就直接 shift改成df.groupby(组别)[目标列].shift(1)merge_asof 结果对不上左右两表没有按时间升序排序先sort_values再 merge_asof链式赋值出现 SettingWithCopyWarning在切片副本上修改避免链式赋值用.copy()或直接写df.loc[条件, 列]...assign 里引用了刚创建的列报 KeyError直接写df[新列名]而不是lambda x: ...后续列必须用 lambda 方式引用eval 里使用列名但是列名带空格字符串表达式被当作运算符号用反引号包裹列名df.eval(order id 100)applymap 报警告提示改名为 mappandas 版本升级根据警告改为df.map同时确认 pandas 版本这里要单独说一下链式赋值的问题。很多人喜欢这么写df[df[amount] 100][new_col] 1 # 这样写大概率排在副本上它可能不报错但你改完之后发现原表根本没变或者不知道哪个环节把数据弄丢了。我的建议很简单要么用df.loc要么在切片前加.copy()只这两种做法靠谱其他写法都很容易埋雷。另外一个排查技巧是报错信息要看完。pandas 的错误信息近几年写得很详细很多已经带上了修复建议比如告诉你应该改用什么函数。不要一看红字就立刻抄一遍网上的模板先读 30 秒错误信息很多问题就自己解决了。9. 我个人的一套固定分析习惯踩过很多次坑之后我现在拿到一份数据基本固定成一条流水线先看 shape 和 dtypes再判断结构是否需要 reshape然后用 assign 一口气把字段算好再做缺失值和异常值的清洗如果数据带时间先 shift、diff、rolling 把该算的窗口算好最后 query 筛选、groupby 汇总、crosstab 出交叉表。整个流程里pipe 负责把每一步串起来eval 在内存吃紧时中途替换普通写法。这一套组合下来最直观的感受是代码量少了将近一半更重要的是每一步都可以被 review。以前写数据处理脚本最怕的是别人问你“这个字段怎么来的”你得从头翻赋值语句现在每个 pipe 节点就是一道清楚的工序把中间结果 print 出来谁都能看懂。如果你只打算记住一条经验我想说的是不要为了用高级函数而用高级函数而是先把数据形状和业务规则想清楚。修过哪些数据、要不要分组、按什么口径切边界这些思考清楚了函数选择自然就出来了。pandas 的函数再多也永远只是你头脑里那套业务逻辑的翻译器。函数用得熟不熟决定了你翻译得顺不顺但业务想得清不清楚才决定了结果对不对。
返回列表