快速评估大量输入值的数学表达式(函数)

时间:2015-12-05 14:15:04

标签: python eval abstract-syntax-tree sympy numexpr

以下问题

和他们各自的答案让我思考如何解析一个(或多或少可信的)用户有效地为20k到30k输入提供的单个数学表达式(一般来说,就这个答案的行https://stackoverflow.com/a/594294/1672565)来自数据库的值。我实施了一个快速而肮脏的基准测试,因此我可以比较不同的解决方案。

# Runs with Python 3(.4)
import pprint
import time

# This is what I have
userinput_function = '5*(1-(x*0.1))' # String - numbers should be handled as floats
demo_len = 20000 # Parameter for benchmark (20k to 30k in real life)
print_results = False

# Some database, represented by an array of dicts (simplified for this example)

database_xy = []
for a in range(1, demo_len, 1):
    database_xy.append({
        'x':float(a),
        'y_eval':0,
        'y_sympya':0,
        'y_sympyb':0,
        'y_sympyc':0,
        'y_aevala':0,
        'y_aevalb':0,
        'y_aevalc':0,
        'y_numexpr': 0,
        'y_simpleeval':0
        })

#解决方案#1:eval [是的,完全不安全]

time_start = time.time()
func = eval("lambda x: " + userinput_function)
for item in database_xy:
    item['y_eval'] = func(item['x'])
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('1 eval: ' + str(round(time_end - time_start, 4)) + ' seconds')

#解决方案#2a:sympy - evalf(http://www.sympy.org

import sympy
time_start = time.time()
x = sympy.symbols('x')
sympy_function = sympy.sympify(userinput_function)
for item in database_xy:
    item['y_sympya'] = float(sympy_function.evalf(subs={x:item['x']}))
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('2a sympy: ' + str(round(time_end - time_start, 4)) + ' seconds')

#Solution#2b:sympy - lambdify(http://www.sympy.org

from sympy.utilities.lambdify import lambdify
import sympy
import numpy
time_start = time.time()
sympy_functionb = sympy.sympify(userinput_function)
func = lambdify(x, sympy_functionb, 'numpy') # returns a numpy-ready function
xx = numpy.zeros(len(database_xy))
for index, item in enumerate(database_xy):
    xx[index] = item['x']
yy = func(xx)
for index, item in enumerate(database_xy):
    item['y_sympyb'] = yy[index]
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('2b sympy: ' + str(round(time_end - time_start, 4)) + ' seconds')

#Solution#2c:sympy - lambdify with numexpr [and numpy](http://www.sympy.org

from sympy.utilities.lambdify import lambdify
import sympy
import numpy
import numexpr
time_start = time.time()
sympy_functionb = sympy.sympify(userinput_function)
func = lambdify(x, sympy_functionb, 'numexpr') # returns a numpy-ready function
xx = numpy.zeros(len(database_xy))
for index, item in enumerate(database_xy):
    xx[index] = item['x']
yy = func(xx)
for index, item in enumerate(database_xy):
    item['y_sympyc'] = yy[index]
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('2c sympy: ' + str(round(time_end - time_start, 4)) + ' seconds')

#解决方案#3a:asteval [基于ast] - 使用字符串魔法(http://newville.github.io/asteval/index.html

from asteval import Interpreter
aevala = Interpreter()
time_start = time.time()
aevala('def func(x):\n\treturn ' + userinput_function)
for item in database_xy:
    item['y_aevala'] = aevala('func(' + str(item['x']) + ')')
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('3a aeval: ' + str(round(time_end - time_start, 4)) + ' seconds')

#Solution#3b(M Newville):asteval [基于ast] - 解析&运行(http://newville.github.io/asteval/index.html

from asteval import Interpreter
aevalb = Interpreter()
time_start = time.time()
exprb = aevalb.parse(userinput_function)
for item in database_xy:
    aevalb.symtable['x'] = item['x']
    item['y_aevalb'] = aevalb.run(exprb)
time_end = time.time()
print('3b aeval: ' + str(round(time_end - time_start, 4)) + ' seconds')

#Solution#3c(M Newville):asteval [基于ast] - 解析&使用numpy(http://newville.github.io/asteval/index.html

运行
from asteval import Interpreter
import numpy
aevalc = Interpreter()
time_start = time.time()
exprc = aevalc.parse(userinput_function)
x = numpy.array([item['x'] for item in database_xy])
aevalc.symtable['x'] = x
y = aevalc.run(exprc)
for index, item in enumerate(database_xy):
    item['y_aevalc'] = y[index]
time_end = time.time()
print('3c aeval: ' + str(round(time_end - time_start, 4)) + ' seconds')

#解决方案#4:simpleeval [基于ast](https://github.com/danthedeckie/simpleeval

from simpleeval import simple_eval
time_start = time.time()
for item in database_xy:
    item['y_simpleeval'] = simple_eval(userinput_function, names={'x': item['x']})
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('4 simpleeval: ' + str(round(time_end - time_start, 4)) + ' seconds')

#Solution#5 numexpr [and numpy](https://github.com/pydata/numexpr

import numpy
import numexpr
time_start = time.time()
x = numpy.zeros(len(database_xy))
for index, item in enumerate(database_xy):
    x[index] = item['x']
y = numexpr.evaluate(userinput_function)
for index, item in enumerate(database_xy):
    item['y_numexpr'] = y[index]
time_end = time.time()
if print_results:
    pprint.pprint(database_xy)
print('5 numexpr: ' + str(round(time_end - time_start, 4)) + ' seconds')

在我的旧测试机器上(Python 3.4,Linux 3.11 x86_64,两个内核,1.8GHz),我得到以下结果:

1 eval: 0.0185 seconds
2a sympy: 10.671 seconds
2b sympy: 0.0315 seconds
2c sympy: 0.0348 seconds
3a aeval: 2.8368 seconds
3b aeval: 0.5827 seconds
3c aeval: 0.0246 seconds
4 simpleeval: 1.2363 seconds
5 numexpr: 0.0312 seconds

显而易见的是 eval 令人难以置信的速度,尽管我不想在现实生活中使用它。第二个最佳解决方案似乎是 numexpr ,这取决于 numpy - 我想避免的依赖,尽管这不是一个硬性要求。接下来最好的事情是 simpleeval ,它是围绕 ast 构建的。 aeval ,另一种基于ast的解决方案,我必须首先将每个浮点输入值转换为字符串,我无法找到方法。 sympy 最初是我最喜欢的,因为它提供了最灵活,最安全的解决方案,但它最终与最后一个解决方案有一些令人印象深刻的距离。

更新1 :使用 sympy 的方法要快得多。见解2b。它几乎与 numexpr 一样好,但我不确定 sympy 是否实际在内部使用它。

更新2 sympy 实施现在使用 sympify 而非简化(根据其首席开发人员的建议) ,asmeurer - 谢谢)。它不是使用 numexpr ,除非明确要求它这样做(参见解决方案2c)。我还基于 asteval 添加了两个明显更快的解决方案(感谢M Newville)。

我还有哪些方法可以进一步加快任何相对安全的解决方案?是否有其他安全(-ish)方法直接使用ast?

5 个答案:

答案 0 :(得分:2)

由于您询问了asteval, 是一种使用它并获得更快结果的方法:

aeval = Interpreter()
time_start = time.time()
expr = aeval.parse(userinput_function)
for item in database_xy:
    aeval.symtable['x'] = item['x']
    item['y_aeval'] = aeval.run(expr)
time_end = time.time()

也就是说,您可以先解析("预编译")用户输入函数,然后将每个新值x插入符号表并使用{{1}评估该值的已编译表达式。在你的规模上,我认为这将使你接近0.5秒。

如果您愿意使用Interpreter.run(),那就是混合解决方案:

numpy

应该更快,并且在运行时与使用aeval = Interpreter() time_start = time.time() expr = aeval.parse(userinput_function) x = numpy.array([item['x'] for item in database_xy]) aeval.symtable['x'] = x y = aeval.run(expr) time_end = time.time() 相当。

答案 1 :(得分:1)

如果您要将字符串传递给sympy.simplify(不建议使用;建议您明确使用sympify),那将会使用{{1}将它转换为SymPy表达式,在内部使用sympy.sympify

答案 2 :(得分:1)

CPython(和pypy)使用非常简单的堆栈语言来执行函数,使用ast模块自己编写字节码相当容易。

import sys
PY3 = sys.version_info.major > 2
import ast
from ast import parse
import types
from dis import opmap

ops = {
    ast.Mult: opmap['BINARY_MULTIPLY'],
    ast.Add: opmap['BINARY_ADD'],
    ast.Sub: opmap['BINARY_SUBTRACT'],
    ast.Div: opmap['BINARY_TRUE_DIVIDE'],
    ast.Pow: opmap['BINARY_POWER'],
}
LOAD_CONST = opmap['LOAD_CONST']
RETURN_VALUE = opmap['RETURN_VALUE']
LOAD_FAST = opmap['LOAD_FAST']
def process(consts, bytecode, p, stackSize=0):
    if isinstance(p, ast.Expr):
        return process(consts, bytecode, p.value, stackSize)
    if isinstance(p, ast.BinOp):
        szl = process(consts, bytecode, p.left, stackSize)
        szr = process(consts, bytecode, p.right, stackSize)
        if type(p.op) in ops:
            bytecode.append(ops[type(p.op)])
        else:
            print(p.op)
            raise Exception("unspported opcode")
        return max(szl, szr) + stackSize + 1
    if isinstance(p, ast.Num):
        if p.n not in consts:
            consts.append(p.n)
        idx = consts.index(p.n)
        bytecode.append(LOAD_CONST)
        bytecode.append(idx % 256)
        bytecode.append(idx // 256)
        return stackSize + 1
    if isinstance(p, ast.Name):
        bytecode.append(LOAD_FAST)
        bytecode.append(0)
        bytecode.append(0)
        return stackSize + 1
    raise Exception("unsupported token")

def makefunction(inp):
    def f(x):
        pass

    if PY3:
        oldcode = f.__code__
        kwonly = oldcode.co_kwonlyargcount
    else:
        oldcode = f.func_code
    stack_size = 0
    consts = [None]
    bytecode = []
    p = ast.parse(inp).body[0]
    stack_size = process(consts, bytecode, p, stack_size)
    bytecode.append(RETURN_VALUE)
    bytecode = bytes(bytearray(bytecode))
    consts = tuple(consts)
    if PY3:
        code = types.CodeType(oldcode.co_argcount, oldcode.co_kwonlyargcount, oldcode.co_nlocals, stack_size, oldcode.co_flags, bytecode, consts, oldcode.co_names, oldcode.co_varnames, oldcode.co_filename, 'f', oldcode.co_firstlineno, b'')
        f.__code__ = code
    else:
        code = types.CodeType(oldcode.co_argcount, oldcode.co_nlocals, stack_size, oldcode.co_flags, bytecode, consts, oldcode.co_names, oldcode.co_varnames, oldcode.co_filename, 'f', oldcode.co_firstlineno, '')
        f.func_code = code
    return f

这具有产生与eval基本相同的功能的明显优势,并且它几乎与compile + eval完全相同(compile步骤略有缩放慢于evaleval将预先计算任何内容(1+1+x编译为2+x)。

为了进行比较,eval在0.0125秒内完成20k测试,makefunction在0.014秒内完成。将迭代次数增加到2,000,000,eval在1.23秒内完成,makefunction在1.32秒内完成。

有趣的是,pypy认识到evalmakefunction产生的功能基本相同,因此第一次的JIT热身加速了第二次。

答案 3 :(得分:1)

我不是Python编码器,因此我无法提供Python代码。但我认为我可以提供一个简单的方案来消除你的依赖关系并且仍然运行得非常快。

这里的关键是构建一个接近eval而不是eval的东西。所以你想要做的是将用户方程式“编译”成可以快速评估的东西。 OP已经展示了许多解决方案。

另一个是基于将等式评估为Reverse Polish

为了便于讨论,假设您可以将公式转换为RPN(反向抛光表示法)。这意味着操作数出现在运算符之前,例如,用户公式:

        sqrt(x**2 + y**2)

你从左到右获得RPN等效读数:

          x 2 ** y 2 ** + sqrt

实际上,我们可以将“操作数”(例如,变量和常量)视为采用零操作数的运算符。现在每个RPN都是一个运营商。

如果我们将每个运算符元素视为一个标记(假设下面为每个标记为“ RPNelement ”的唯一小整数)并将它们存储在数组“RPN”中,我们可以评估这样的公式使用下推式堆栈非常快:

       stack = {};  // make the stack empty
       do i=1,len(RPN),1
          case RPN[i]:
              "0":  push(stack,0);
              "1": push(stack,1);
              "+":  push(stack,pop(stack)+pop(stack));break;
               "-": push(stack,pop(stack)-pop(stack));break;
               "**": push(stack,power(pop(stack),pop(stack)));break;
               "x": push(stack,x);break;
               "y": push(stack,y);break;
               "K1": push(stack,K1);break;
                ... // as many K1s as you have typical constants in a formula
           endcase
       enddo
       answer=pop(stack);

你可以内联push和pop的操作来加快它的速度。 如果提供的RPN格式正确,则此代码非常安全。

现在,如何获得RPN?答:构建一个小的递归下降解析器,其操作将RPN操作符附加到RPN数组。有关典型方程,请参阅my SO answer for how to build a recursive descent parser easily

你必须组织将解析中遇到的常数放入K1,K2 ......如果它们不是特殊的,通常会出现的值(正如我为“0”和“1”所示;你可以添加更多,如果有帮助)。

此解决方案最多应为几百行,并且对其他包的依赖性为零。

(Python专家:随意编辑代码使其成为Pythonesque)。

答案 4 :(得分:1)

我过去使用过C ++ ExprTK库并取得了巨大的成功。 Here是其他C ++解析器(例如Muparser,MathExpr,ATMSP等)中的基准速度测试,ExprTK排在最前面。

有一个名为cexprtk的ExprTK的Python包装器,我已经使用它并且发现非常快。您只需编译一次数学表达式,然后根据需要多次计算此序列化表达式。以下是使用cexprtkuserinput_function的简单示例代码:

import cexprtk
import time

userinput_function = '5*(1-(x*0.1))' # String - numbers should be handled as floats
demo_len = 20000 # Parameter for benchmark (20k to 30k in real life)

time_start = time.time()
x = 1

st = cexprtk.Symbol_Table({"x":x}, add_constants = True) # Setup the symbol table
Expr = cexprtk.Expression(userinput_function, st) # Apply the symbol table to the userinput_function

for x in range(0,demo_len,1):
    st.variables['x'] = x # Update the symbol table with the new x value
    Expr() # evaluate expression
time_end = time.time()

print('1 cexprtk: ' + str(round(time_end - time_start, 4)) + ' seconds')

在我的机器上(Linux,双核,2.5GHz),演示长度为20000,完成时间为0.0202秒。

对于2,000,000 cexprtk的演示长度,在1.23秒内完成。