在 Pandas 中,Series 的常规运算是天然向量化的,例如 s * 2 + 1 会一次性处理所有元素。但实际项目里表达式经常不是写死在代码中,而是来自配置文件、规则平台或用户输入,这时它就是一个字符串。最直觉的实现是 s.apply(lambda x: eval(expr)),这种写法虽然灵活,却会让每个元素都重新走一遍 Python 解释器解析和执行,十万行数据就可能从几毫秒变成数秒。本文围绕既要动态、又要快这个目标,给出几条可落地的优化路径,并说明不同方案的适用边界。

一、先判断表达式类型:标量操作还是逐元素函数
动态表达式不能一概而论。如果表达式只使用 Series 本身、标量常量,以及加减乘除、比较、布尔运算、where 等基础运算,那么 numexpr 引擎可以把它变成高效的批量计算。pandas.eval 正是这种能力的入口。它接受一个字符串表达式,通过 local_dict 注入 Series 或 DataFrame 变量,底层交给 numexpr 做多线程计算。下面是最基本的用法。
import pandas as pd
import numpy as np
s = pd.Series(np.random.randn(100_000))
expr = "s * 2 + 1"
result = pd.eval(expr, local_dict={"s": s})这里的 local_dict 并不是可有可无。直接把变量名写进表达式,如果作用域里没有,pandas.eval 会尝试从调用栈获取,复杂环境里容易出错;显式传字典能明确变量来源,也方便在循环中复用不同数据。需要特别留意的是,pd.eval 的默认引擎是 numexpr,只支持算术、比较、布尔、位运算以及 where、sin、cos、log 等少量函数。超出这个范围的表达式会直接抛出异常或回退到 Python 引擎,性能反而更差。
所以在动手优化前,先确认表达式能否被 numexpr 接收。能接收就优先用它;不能接收再考虑后面的 np.select 或受限求值器。一个简单的判断方法是:把表达式里的函数名换成白名单函数,看是否只依赖 NumPy ufunc。若涉及自定义 Python 函数、正则、对象方法,通常就不能走这条快车道。
二、用 pd.eval 处理可向量化的动态表达式
假设规则平台传来的公式是 s * 0.8 + offset,其中 offset 是一个标量参数。把该参数和 Series 一起放入 local_dict,执行一次 pd.eval 就能得到结果。和 apply 相比,numexpr 会把数据分块,并利用 CPU 的多线程并行处理,同时减少 Python 对象装箱。对于百万行左右的数据,耗时通常能降低一个数量级。
import pandas as pd
import numpy as np
s = pd.Series(np.random.randn(1_000_000))
offset = 0.5
expr = "s * 0.8 + offset"
result = pd.eval(expr, local_dict={"s": s, "offset": offset})条件表达式同样可以交给 numexpr。需要注意的是,Python 风格的 if-else 不被支持,应使用 where 函数,例如 where(s > 0, s * 10, s * -1)。这个写法和 np.where 的语义一致,但注意 where 的调用是在表达式字符串内部完成的,不要写成 Python 函数调用。
expr = "where(s > 0, s * 10, s * -1)"
result = pd.eval(expr, local_dict={"s": s})多个条件也可以拼成布尔运算,例如 (s > -1) & (s < 1)。在 numexpr 表达式中,位运算符 & 和 | 是允许的,但每个比较结果必须加括号,否则会违反 Python 运算符优先级。对于需要按区间分段的场景,可以先算布尔条件向量,再交给 where 组合,避免写一长串嵌套 where。
三、多分支规则更适合 np.select 或 pd.cut
很多动态规则并不是单条公式,而是一组 if-elif-else 决策。例如风控分数:s 小于 -2 为高风险,-2 到 0 之间为观察,0 到 2 之间为普通,s 大于等于 2 为低风险。把它们转换成一系列条件和结果,再调用 np.select 会非常快。它内部仍然使用向量化比较和布尔掩码,每个分支只计算一次,不会像 apply 那样逐行判断。
conds = [
s < -2,
(s >= -2) & (s < 0),
(s >= 0) & (s < 2),
s >= 2,
]
choices = ["high", "watch", "normal", "low"]
result = np.select(conds, choices, default="unknown")np.select 的优点是规则可以用普通 Python 数据结构维护,例如从数据库读出的条件表,映射成 conds 和 choices 列表即可。它不涉及字符串表达式解析,安全性和可维护性都更好。不过它要求所有分支长度与 Series 对齐,并且 choices 必须是标量或等长数组。如果每个分支的返回值本身需要计算,可以先计算成数组再放进 choices,而不是把复杂公式塞进条件里。
如果区间是连续且互斥的,pd.cut 会比一长串比较更简洁。一行 pd.cut(s, bins=[-np.inf, -2, 0, 2, np.inf], labels=["high", "watch", "normal", "low"]) 就能完成同一件事。动态场景下只要 bins 和 labels 来自配置,就能同时保持灵活和高效。pd.cut 还支持 right=False 控制开闭区间,适合处理边界值比较多的业务规则。
四、必须逐元素执行时的降本策略
如果表达式里包含 str.upper()、正则匹配、自定义对象方法或第三方库调用,numexpr 就无能为力了。此时很多人会退回到 apply(eval),但仍有办法减少损失。第一原则是减少 Python 循环的次数:先抽出可以向量化的公共部分,只对剩余少量数据做逐元素处理。比如一个字符串清洗规则只需要处理非空值,可以先 mask = s.notna(),再用 s.loc[mask].map(func) 处理,避免每个元素都进入函数判断空值。
import re
pat = re.compile(r"^[A-Z]+$")
def clean_text(x):
if pd.isna(x):
return ""
x = x.strip()
return x.lower() if pat.match(x) else x
mask = s_str.notna()
s_str.loc[mask] = s_str.loc[mask].map(clean_text)第二原则是尽量不在循环内重复做昂贵初始化。上面的正则对象 compile 放在函数外面,只编译一次。若使用 apply,还会带来额外的包装开销,而 map 对 Series 的逐元素调用更直接、开销略低。对于可转换成 NumPy 数组的自定义逻辑,可以使用 np.frompyfunc 或 numba.jit,但前提是逻辑足够简单且没有太多 Python 对象往返。
第三原则是控制脚本语言的执行权限。即使万不得已要使用 eval,也绝不要直接 eval 原始表达式。可以先通过 ast 解析并校验节点类型,只允许 Name、Constant、BinOp、UnaryOp、Compare 和少量白名单函数,再在受控命名空间中执行。这样能避免任意代码执行,性能虽然不如向量化方案,但至少安全边界是明确的。
五、面向配置驱动的动态表达式框架设计
如果系统需要频繁上线新规则,建议把表达式统一收敛为三类:纯数值公式、区间规则、自定义函数映射。纯数值公式交给 pd.eval;区间规则交给 np.select 或 pd.cut;自定义函数映射则维护一张函数白名单,根据配置中的函数名查找已注册函数,再通过 map 执行。这样业务人员看到的仍然是表达式或规则配置,而系统内部不会出现危险的 eval 字符串。
例如配置文件里可以写 {type: formula, expr: s * 0.6 + base},解析时校验 type 后提取变量名,只从本地字典注入。对于自定义函数则写 {type: func, name: clean_text, column: name},代码里通过 FUNC_MAP 找到 clean_text。这个映射表还可以加上版本号和输入输出类型声明,避免动态调用出错。
整体执行链路上,先做条件拆分:能用 numexpr 的优先生成公式任务;能转成 conds 和 choices 的生成规则任务;只有真正需要逐元素逻辑的任务才走受控 map。这样大部分行会走向量化路径,极端逻辑也不会拖垮整体性能。对于实时性要求更高的场景,还可以把静态规则预编译成 NumPy ufunc 后缓存,后续请求直接套用。
最后补充一个测试建议:无论用哪种动态求值方式,都应构造一个十万行级别的样本,并对比 pd.eval、np.select、apply(eval) 三种实现的耗时。通常你会发现 pd.eval 和 np.select 在同一数量级,而 apply(eval) 会慢数倍到数十倍。把这个基准测试纳入持续集成,可以在规则变复杂时及时发现性能退化。

