上个月帮朋友处理一份网约车订单数据,几百万行,光清洗就写了快两百行循环代码,跑一次要二十多分钟。后来我把数据清洗、特征构建的过程全部换成 Pandas 高级函数重写,代码量缩到六十行以内,单次运行压到三分钟。那次之后我才认真整理出这份清单——10 个能实打实提升数据处理效率的函数,覆盖筛选、转换、分组聚合、分箱编码和窗口计算五个高频场景,适合每天跟 DataFrame 打交道的分析师、数据工程师和数据科学初学者。
先说清楚,我挑的这 10 个不是那种冷门到一年用一次的偏招,而是我在真实项目中反复用、每次用都能感受到"省事"的常用函数。如果你现在还在用for循环逐行改数据,或者每次筛选都写一长串df[(df['a'] > 1) & (df['b'] < 2)]这种代码,那这篇文章正好可以帮你把效率提上去。
1. query 和 eval:把筛选和计算从"手写布尔索引"里解放出来
1.1 query:让筛选条件变成可读的字符串表达式
日常做数据分析时,最烦的不是筛选本身,而是一长串括号、&、|、~堆在一起,稍不留神优先级就搞错。比如你想筛出"城市为上海、订单金额大于 90、支付状态为已支付"的记录,传统写法长这样:
df[(df["城市"] == "上海") & (df["订单金额"] > 90) & (df["支付状态"] == 1)]这段代码本身没错,问题在于条件一多,可读性直线下降。你回头再看这段代码,得一个一个拆括号才能确认逻辑。用query()改写之后,整段条件变成一条接近自然语言的字符串:
df.query("城市 == '上海' and 订单金额 > 90 and 支付状态 == 1")它的优势不只是好看。query()内部会把表达式交给 pandas 自己的解析器处理,在某些场景下能避免 Python 解释器逐层做布尔运算的开销。更重要的是,当你的列名重复出现、条件嵌套变多时,字符串表达式的维护成本远低于括号表达式。
这里有个容易被忽略的实用技巧:如果筛选条件里的比较值是外部变量,直接在字符串里拼变量会报错或者被当成列名,需要在变量前加@前缀。
min_amount = 90 df.query("城市 == '上海' and 订单金额 > @min_amount")我见过不少同事在这个地方踩坑,因为他们觉得query()就是简单地把 Python 表达式换成字符串,忘了变量引用需要特殊标记。
1.2 eval:在大数据量下减少中间变量和内存压力
eval()和query()是一对兄弟,一个管筛选,一个管计算。最典型的用法是批量生成新列,比如同时算出折扣价、含税价、实收金额:
df.eval("折扣价 = 订单金额 * 0.88") df.eval("含税价 = 订单金额 * 1.06")每调用一次eval()就会新增一列。你也可以用inplace=True直接改原表,不过我更建议保留返回值,方便链式调用。
真正让eval()值得一用的是大数据量场景。它可以指定engine="numexpr",利用 numexpr 库做多线程表达式计算,对于几百万行的数据,速度提升非常明显。如果你还开启了inplace=True,还能省掉中间临时 DataFrame 的内存占用。
1.3 这里的坑:列名特殊字符、引擎参数、赋值陷阱
query()和eval()最经典的坑有两个。第一个是列名里带空格、点号或者运算符时,必须用反引号括起来。比如列名叫"订单.金额",直接写订单.金额 > 100会被当成 DataFrame 的层级索引访问,正确写法是df.query("`订单.金额` > 100")。第二个是eval()里不能随便用 Python 内置函数,比如abs()、round()这种,除非你在表达式里传入local_dict做映射。
df.eval("金额绝对值 = abs(订单金额)", local_dict={"abs": abs})实际项目里,我最推荐的做法是:列名统一用下划线命名,避免特殊字符,这样query()和eval()的表达式会干净很多。命名规范本身就是一种效率投资,能省掉后面一堆转义和调试的工夫。
2. 链式操作三件套:assign、filter、pipe 把流程写成流水线
2.1 assign:一次构建多列,还能引用同批次新列
数据清洗里最常做的一件事是加列:从时间戳里拆出小时、从地址里提取城市、把金额从分转成元。以前我习惯写df['新列'] = ...一行一行加,列一多代码就显得很碎。assign()可以把多个加列操作合并成一次调用:
df = df.assign( 小时=lambda x: x["下单时间"].dt.hour, 金额_元=lambda x: x["订单金额"] / 100, 时段=lambda x: pd.cut(x["小时"], bins=[0, 12, 18, 24], labels=["上午", "下午", "晚上"]) )注意这里的lambda x,x是当前的整个 DataFrame。assign()有个特别方便的特性:同一个assign()调用内,后面的列可以直接引用前面刚创建的列。比如我想先算出金额_元,再判断它是否超过平均值,可以这样写:
df.assign( 金额_元=lambda x: x["订单金额"] / 100, 是否高于均值=lambda x: x["金额_元"] > x["金额_元"].mean() )这比拆成两步写省事得多,而且代码意图非常清楚:这一批操作都在构建特征。
2.2 filter:按列名模式筛选,比手写列表推导靠谱
filter()的问题在于名字太普通,很多人以为它跟query()一样是筛选行数据的,结果一看发现完全不是一回事。它筛选的是列名或者索引标签,而不是单元格值。常见的三种用法:
# 精确指定要保留的列 df.filter(items=["城市", "订单金额", "支付状态"]) # 用正则匹配列名 df.filter(regex="金额|状态") # 用子串匹配列名 df.filter(like="订单")我用得最多的是regex参数。比如一张网约车订单表有几十列,其中支付相关列名都带"支付",评分相关列名都带"评分",想快速把这些列拿出来做相关性分析,一行df.filter(regex="支付|评分")就完事,比手写列表推导式[col for col in df.columns if '支付' in col or '评分' in col]短一大截。
filter()的like和regex参数不能同时给,这是一个容易忽略的报错点。另外它默认是axis=0筛行为主?其实不是,filter()默认筛列,也就是axis=1,需要筛索引标签时要显式传axis=0。这个细节和大多数人直觉相反,我见过有人为了筛列特意传axis=1,结果发现不传才是对的。
2.3 pipe:把自定义函数接进链式流程
assign()和filter()终究是 pandas 内置能力,你总是要写自己的清洗逻辑的。这时候pipe()就派上用场了。它的作用是把一个自定义函数接入链式调用,并且把前一步的 DataFrame 作为第一个参数传进去。
def clean_orders(df, min_amount=0): return df[df["订单金额"] > min_amount].reset_index(drop=True) def add_fee(df, fee_rate=0.01): return df.assign(手续费=df["订单金额"] * fee_rate) df.pipe(clean_orders, min_amount=50).pipe(add_fee, fee_rate=0.02)这段代码读起来很像一条流水线:先清洗,再算手续费。每个函数只做一件事,参数清晰,回头改逻辑时只需要改对应的函数。相比把所有步骤堆在一个七八十行的函数里,这种写法在团队协作里优势特别大——新同事看代码时不需要从头读到尾,而是顺着pipe一个个函数看下去就行。
pipe()还能接带多个参数的函数。我常用的套路是写一个"读配置"的函数,把清洗阈值、分箱边界等参数都放在配置里,然后df.pipe(transform_by_config, config=cfg),这样业务要调整规则时,我只需要改配置文件,完全不用碰数据处理代码。
3. 分组聚合三把刀:agg、transform、pivot_table
3.1 agg:多列多函数的聚合一次算完
分组聚合是 pandas 使用频率最高的操作,但很多人只用了groupby().mean()或者groupby().sum()这种单函数形式。一旦需要同时算多个指标,代码就变成好几行重复的groupby。agg()能一次传多个聚合函数,也可以用pd.NamedAgg给结果列起名字。
# 传元组:新列名 = (原列名, 聚合函数) df.groupby("城市").agg( 订单数=("订单金额", "count"), 总金额=("订单金额", "sum"), 平均金额=("订单金额", "mean"), 最大单价=("订单金额", "max"), )这个写法最大的好处是结果列名完全由你控制。如果不希望用多层索引的话,这样命名比agg(["count", "sum", "mean"])出来的 MultiIndex 列名更干净,导出 Excel 时也更好看。
agg()还不限于内置函数。我想算"金额大于 100 的订单占比"这种稍微复杂的指标时,可以直接传 lambda:
df.groupby("城市").agg( 高金额占比=lambda x: (x > 100).mean() )注意这里x是一整个分组列,lambda 返回的必须是一个标量,否则会报"aggregate function returns an array"之类的错误。真遇到要返回数组的聚合,应该用下一节的transform()。
3.2 transform:让分组结果保留原索引广播回去
agg()把多行压缩成一行,transform()则相反,它会保留原始的行数,把分组计算的结果广播回每一行。这是做组内标准化、组内缺失值填充、组内排名时最顺手的工具。
# 组内标准化 df["金额_组内标准化"] = df.groupby("城市")["订单金额"].transform( lambda x: (x - x.mean()) / x.std() ) # 用组内均值填充缺失值 df["金额_填充"] = df.groupby("城市")["订单金额"].transform(lambda x: x.fillna(x.mean()))我实际项目里用transform()最多的场景是"同组内做归一化"。比如网约车订单数据里,不同城市的订单金额天然差异大,直接对整个数据集做标准化会把小城市的高价值订单压得很低。先按城市分组再transform()标准化,就能在保留原始分布的同时消除城市之间的基数差异。
transform()的坑在于:传入的函数的返回值必须和分组长度一致。你写lambda x: x.mean()返回一个标量虽然也能广播,但我更建议直接写lambda x: x - x.mean()这种形式,语义更清晰,也不容易触发长度检查的报错。
3.3 pivot_table:宽表重塑,透视统计一站完成
很多人一提到"数据透视表"就想到 Excel,其实 pandas 的pivot_table()比 Excel 透视表还灵活。它的核心参数是index(行维度)、columns(列维度)、values(要聚合的值)和aggfunc(聚合方式)。
pd.pivot_table( df, values="订单金额", index="城市", columns="支付状态", aggfunc="sum", fill_value=0, margins=True )这段代码会生成一张以城市为行、支付状态为列、单元格为订单金额汇总的宽表,margins=True会自动加上总计行和总计列。对于"不同城市的支付结构分析"这种需求,这一行代码的效果顶得上 Excel 里手动拖半天。
pivot_table()默认聚合方式是mean,这和很多人的直觉不一样。如果你想算总和,必须显式传aggfunc="sum",否则结果会变成平均值。另一个细节是fill_value=0只对最终透视结果里的空值生效,它不会影响聚合计算本身。比如某城市没有"未支付"的订单,透视后这个单元格是 NaN,fill_value=0会把它填成 0,但不会影响其他单元格的求和结果。
4. 分箱、编码与指数加权:cut/qcut、factorize、ewm
4.1 cut 和 qcut:连续变量离散化的两种切法
连续变量转离散变量是特征工程的常规操作,比如把订单金额划分成"低、中低、中高、高"四档。pandas 提供了两种切法:cut()按数值区间等距切分,qcut()按分位数等频切分。
# 等距切分:区间宽度一致 bins = [0, 50, 100, 200, 10000] labels = ["低", "中低", "中高", "高"] df["金额档位"] = pd.cut(df["订单金额"], bins=bins, labels=labels) # 等频切分:每个区间样本数大致相等 df["金额分位"] = pd.qcut(df["订单金额"], q=4, labels=["Q1", "Q2", "Q3", "Q4"])两者的区别很好理解:cut()关心区间宽度,适合你已经有业务上明确的金额区间,比如"50 以下算低、50-100 算中低";qcut()关心样本分布,适合你想让每个档位的人数大致相同,比如分析用户分层时不希望某一档人数过少。
qcut()有个实际中很容易触发的报错:当数据里某个值出现频率过高,导致分位数边界重复时,pandas 会报Bin edges must be unique错误。解决办法是在qcut()里加duplicates="drop",它会把重复的边界合并。这个参数我第一次遇到时不知道,排查了很久才找到问题根源。
4.2 factorize:类别特征编码的隐藏利器
做机器学习时要把城市、品牌、渠道这种文本型类别特征转成数值。很多人第一反应是sklearn.preprocessing.LabelEncoder,其实 pandas 自带一个更轻量的工具factorize()。
codes, uniques = pd.factorize(df["城市"], sort=True) df["城市编码"] = codes它返回两个值:编码数组和去重后的唯一值列表。编码从 0 开始自增,sort=True会先排序再编码,保证同一类别在多次运行中得到同样的编码。
我推荐factorize()的原因有两个。一是它的编码顺序和uniques是严格对齐的,后期要做类别和编码的映射时直接dict(zip(uniques, range(len(uniques))))就行。二是它天然处理缺失值:缺失值会被编码为-1,这跟很多模型的处理方式兼容。
factorize()最大的坑是很多人不知道-1表示 NaN。如果你直接把编码结果喂给模型,缺失值编码-1可能会被当成一个真实的类别。我习惯先把缺失值单独处理掉,再走factorize(),避免这种隐性问题。
4.3 ewm:指数加权窗口,时间序列里的"近期更重"
最后一个是热搜里频繁出现的ewm(),也就是指数加权移动平均。它和rolling()的区别在于:rolling()对窗口内的数据等权重平均,而ewm()离当前时刻越近的数据权重越大,衰减速度由参数控制。
df = df.sort_values("下单时间") df["金额_平滑"] = df["订单金额"].ewm(span=7, adjust=False).mean()这里span=7的意思大致相当于"7 日移动平均的平滑效果",但它的实际含义是衰减因子alpha = 2 / (span + 1),也就是约等于 0.25。adjust=False表示从序列第一个值开始就参与计算,这样得到的平滑曲线在序列前段不会出现大量 NaN。
ewm()在订单趋势分析里特别有用。原始订单金额可能每天波动很大,直接看图很难看出趋势,用ewm()平滑之后,上升或下降的趋势一目了然。它比rolling().mean()好在:不会出现窗口期前段的 NaN,而且反应速度更快、曲线更平滑。
参数选择上,除了span,还有halflife和alpha两种指定方式。halflife=3表示每过 3 个周期,权重衰减一半;alpha是直接的衰减系数,数值越接近 1,越注重近期数据。我一般按业务周期选:日维度数据用span=7或span=30,分钟维度数据用halflife=30,需要精细控制时直接给alpha。
ewm()的细节要注意min_periods参数。如果数据开头有缺失值,或者你希望在某个周期之后才开始产出平滑结果,可以给min_periods设置一个阈值,比如min_periods=3,这样前两期的结果会是 NaN。这在做实时指标监控时很实用,能避免因为数据量不足导致前期指标失真。
最后再分享一个我自己用下来的习惯:不要试图一次把所有高级函数都塞进代码里。先从query()和assign()开始,把最碍眼的循环和长布尔索引替换掉;等习惯链式调用后,再加pipe()组织自定义逻辑;分组聚合的场景优先想agg()和transform()能不能一次解决。每次只改一小块,跑通验证、对比结果没问题后再改下一块。数据清洗这种活儿,不是写得越炫越好,而是"改得动、看得懂、跑得快",这三个标准里,高级函数真正帮你解决的是"改得动"和"跑得快"这两件事。