Быстрое вычисление математического выражения (функции) для большого количества входных значений

Следующие вопросы

и их соответствующие ответы заставили меня задуматься, как я могу проанализировать одно математическое выражение (в общих чертах в соответствии со строками этого ответа https://stackoverflow.com/a/594294/1672565), заданный (более или менее доверенным) пользователем, эффективно для 20-30 тысяч входных значений, поступающих из базы данных. Я реализовал быстрый и грязный тест, чтобы сравнивать разные решения.

# 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')

# Решение № 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')

# Решение № 2c: sympy - lambdify с numexpr [и 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')

# Решение № 3b (М. Ньювилл): 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')

# Решение № 3c (М. Ньювилл): 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')

# Решение № 5 numexpr [и 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,8 ГГц) я получил следующие результаты:

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 вместо simpleify (как рекомендовано его ведущим разработчиком, asmeurer - Благодарность). Он не использует numexpr, если его не попросят сделать это явно (см. Решение 2c). Я также добавил два значительно более быстрых решения на основе asteval (спасибо M Newville).


Какие варианты у меня есть, чтобы еще больше ускорить использование относительно безопасных решений? Существуют ли другие, безопасные (-истские) подходы, например, с прямым использованием ast?


person s-m-e    schedule 05.12.2015    source источник
comment
Поскольку вы упомянули, что это происходит из базы данных, рассматривали ли вы возможность реализации функции в запросе SQL, чтобы увидеть, как она работает? Таким образом, вместо того, чтобы пытаться передать более 20 тысяч входных данных функции по сети (медленно), вы просто передадите в скрипт один результат?   -  person code_dredd    schedule 05.12.2015
comment
@ray Интересная идея, но в моем случае это просто локальный mongodb, работающий на той же машине, что и приложение, обращающееся к нему. Однако это может измениться в будущем.   -  person s-m-e    schedule 05.12.2015
comment
Но вам все равно нужно пройти через сетевой стек, чтобы ваш скрипт и база данных могли взаимодействовать, даже если он работает локально (например, localhost).   -  person code_dredd    schedule 05.12.2015
comment
Почему вы не хотите использовать eval?   -  person Ira Baxter    schedule 05.12.2015
comment
@IraBaxter 'eval - это зло' - я доверяю своим пользователям ... а потом нет. stackoverflow.com/a/9558001/1672565   -  person s-m-e    schedule 06.12.2015
comment
eval - зло, если вы используете его без ограничений. В противном случае это просто инструмент, скорее всего, все остальное на вашем языке программирования. Если вы хотите быстро оценить выражение, вам нужно, чтобы оно было скомпилировано; в частности, вы не хотите, чтобы он интерпретировался, даже если вы пишете собственный интерпретатор. Теперь, если для вас важнее избегать eval, чем быть медленным, тогда вы можете избежать eval. Жизнь - это компромисс. (Если вы действительно ненавидите eval, у меня может возникнуть соблазн записать формулу в блок текста Python и передать его как файл для импорта Python).   -  person Ira Baxter    schedule 06.12.2015
comment
Как насчет того, чтобы использовать ast для синтаксического анализа пользовательского выражения, проверяя, что оно содержит только операции из белого списка (и вам лучше быть действительно строго в этом отношении), затем используя eval / compile? (Однако это не предотвращает DoS.)   -  person Ry-♦    schedule 06.12.2015
comment
@ RyanO'Hara Это то, что должны делать asteval и simpleeval ... Но да, я, вероятно, стремлюсь к тому, что вы предлагаете, - но я все еще ищу хороший пример кода.   -  person s-m-e    schedule 06.12.2015
comment
simpleeval не фильтрует AST и не использует eval; он делает свою интерпретацию. github.com/danthedeckie/simpleeval/blob/master/simpleeval.py   -  person Ry-♦    schedule 06.12.2015
comment
lambdify не использует numexpr, если вы не установите modules='numexpr'.   -  person asmeurer    schedule 08.12.2015
comment
@asmeurer Thx, я соответственно добавил новое решение для сравнения.   -  person s-m-e    schedule 14.12.2015
comment
sympify() почти небезопасно как eval(). Не могли бы вы также добавить для них записку?   -  person user    schedule 03.03.2016
comment
За время, прошедшее с момента публикации этого вопроса, я собрал пакет plusminus, основанный на pyparsing, чтобы предложить безопасный, встраиваемый, расширяемый художественный синтаксический анализатор / вычислитель, поддерживающий выражения, включая многие математические операторы Unicode, |a-b| для представления abs(a-b), а также как выражения отложенной оценки, которые соответствуют вашим предварительно скомпилированным выражениям. У plusminus есть онлайн-демонстрация, открытая для Интернета, которую вы можете опробовать.   -  person PaulMcG    schedule 08.03.2021


Ответы (5)


Поскольку вы спросили об 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 в таблицу символов и использовать Interpreter.run() для оценки скомпилированного выражения для этого значения. В вашем масштабе, я думаю, это приблизит вас к 0,5 секунды.

Если вы хотите использовать 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()

должно быть намного быстрее и сравнимо по времени выполнения с использованием numexpr.

person M Newville    schedule 10.12.2015
comment
Большое спасибо. Я добавил ваш код (плюс раз) в список решений, см. 3b и 3c. Ваш второй вырезанный код на самом деле работает почти так же хорошо, как пустой оператор eval. - person s-m-e; 14.12.2015

В прошлом я с большим успехом использовал библиотеку C ++ ExprTK. Здесь приведен эталонный тест скорости среди других синтаксических анализаторов C ++ (например, Muparser, MathExpr, ATMSP и т. Д.), И ExprTK выходит на первое место.

Для ExprTK есть оболочка Python под названием cexprtk, которую я использовал и нашел ее очень быстрой. Вы можете скомпилировать математическое выражение только один раз, а затем вычислить это сериализованное выражение столько раз, сколько потребуется. Вот простой пример кода с использованием cexprtk с userinput_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,5 ГГц) для демонстрационной длины 20000 это выполняется за 0,0202 секунды.

Для демонстрации длиной 2 000 000 cexprtk завершается за 1,23 секунды.

person Yeti    schedule 12.10.2017

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, и масштабируется почти так же, как _3 _ + _ 4_ (шаг compile немного медленнее, чем у eval, и eval будет предварительно вычислять все, что может (1+1+x компилируется как 2+x).

Для сравнения, eval завершает тест 20k за 0,0125 секунды, а makefunction завершает за 0,014 секунды. При увеличении количества итераций до 2 000 000, eval завершается за 1,23 секунды, а makefunction - за 1,32 секунды.

Интересное замечание: pypy признает, что eval и makefunction производят, по сути, одну и ту же функцию, поэтому JIT-разминка для первого ускоряет выполнение второго.

person Perkins    schedule 29.07.2016

Если вы передаете строку в sympy.simplify (что не рекомендуется; рекомендуется использовать sympify явно), это будет использовать sympy.sympify для преобразования ее в выражение SymPy, которое использует eval внутри.

person asmeurer    schedule 07.12.2015
comment
Я изменил решения sympy в своем вопросе по вашей рекомендации. - person s-m-e; 14.12.2015

Я не программист на Python, поэтому не могу предоставить код Python. Но я думаю, что могу предоставить простую схему, которая минимизирует ваши зависимости и при этом работает довольно быстро.

Ключевым моментом здесь является создание чего-то, что близко к eval, но не является eval. Итак, что вы хотите сделать, так это «скомпилировать» пользовательское уравнение во что-то, что можно быстро оценить. ОП показал ряд решений.

Вот другой, основанный на оценке уравнения как Обратное полирование.

Для обсуждения предположим, что вы можете преобразовать уравнение в RPN (обратная полировка). Это означает, что операнды предшествуют операторам, например, для пользовательской формулы:

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

вы получите эквивалентное чтение RPN слева направо:

          x 2 ** y 2 ** + sqrt

Фактически, мы можем рассматривать «операнды» (например, переменные и константы) как операторы, которые принимают нулевые операнды. Теперь все в РПН - это оператор.

Если мы будем рассматривать каждый элемент оператора как токен (предположим, что уникальное маленькое целое число, записанное ниже как «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. См. мой SO-ответ о том, как легко создать синтаксический анализатор рекурсивного спуска для типичных уравнений.

Вам нужно будет организовать, чтобы константы, встречающиеся при синтаксическом анализе, помещались в K1, K2, ... если они не являются специальными, часто встречающимися значениями (как я показал для "0" и "1"; вы можете добавить больше, если это полезно ).

Это решение должно состоять максимум из нескольких сотен строк и не иметь никаких зависимостей от других пакетов.

(Эксперты по Python: не стесняйтесь редактировать код, чтобы сделать его Pythonesque).

person Ira Baxter    schedule 29.07.2016