2025, Dec 24 00:02
Эквивалентность выражений в SymPy: структурный метод для log и exp
Разбираем, как в SymPy проверять эквивалентность выражений с log и exp без Poly: нормализация по выбранным переменным и сравнение структуры AST, примеры и код.
Сравнение двух выражений SymPy на эквивалентность кажется простым — до тех пор, пока не появляются неполиномиальные части. Часто пробуют привести обе стороны к многочленам по выбранным символам и сопоставить мономы и коэффициенты. Для «чистых» полиномов это срабатывает, но рушится, как только в игру вступают log, exp и другие неполиномиальные операции. Ниже — практический разбор этой ловушки и надежный структурный подход, который справляется со сложными выражениями и при этом оставляет параметры символическими.
Постановка задачи
Цель — определить, эквивалентны ли два выражения с точностью до линейной комбинации символических параметров относительно заданного набора переменных. Идею отражают проверки вида a*x + b против c*x + d, или a*log(b*x + c) + d против e + f*log(g + h*x). Предполагается, что коэффициенты и константные сдвиги могут быть разными символами, но структурная зависимость от выбранных переменных (например, x) должна совпадать.
Базовый подход, который ломается на неполиномах
Вот прямое сравнение через sympy.Poly. Оно разворачивает обе стороны, строит многочлены по заданным переменным и сверяет мономы и числовые коэффициенты.
import sympy
def poly_equiv(lhs, rhs, vars_list = []):
lhs = sympy.expand(lhs)
rhs = sympy.expand(rhs)
p1 = sympy.Poly(lhs, *vars_list)
p2 = sympy.Poly(rhs, *vars_list)
if p1 is None or p2 is None:
return False
if set(p1.monoms()) != set(p2.monoms()):
return False
for idx in range(0, len(p1.coeffs())):
if p1.coeffs()[idx].is_Number and p2.coeffs()[idx].is_Number:
if p1.coeffs()[idx] != p2.coeffs()[idx]:
return False
return True
# Минимальная демонстрация, которая падает на неполиномиальных выражениях
a, b, c, d, e, f, g, h, x = sympy.symbols('a b c d e f g h x')
left = sympy.sympify("a*log(b*x+c)+d")
right = sympy.sympify("e+log(g+x*h)*f")
# Этот вызов порождает sympy.polys.polyerrors.PolynomialError
poly_equiv(left, right, [x])
Почему это не работает
sympy.Poly ожидает многочлен по указанным генераторам. Как только в выражении появляются log, exp или другие неполиномиальные конструкции, построение многочлена не определено, и SymPy выбрасывает PolynomialError. Значит, стратегия «сравнить по мономам и коэффициентам полинома» неприменима для смешанных выражений.
В нашем контексте «эквивалентность» означает совпадение одной и той же линейной формы относительно целевых переменных при допускаемой свободе в символических параметрах, как показывают тесты. Например, a*x + b и c*x + d должны совпадать относительно x; так же a*log(b*x+c) + d должно соответствовать e + f*log(g + h*x) относительно x.
Структурная стратегия для log, exp и их соседей
Подход ниже полностью избегает полиномов. Он нормализует выражения относительно выбранных переменных, схлопывает подвыражения, не зависящие от этих переменных, и затем проверяет, что у обеих сторон совпадает форма дерева синтаксиса. Коммутативность учитывается сортировкой аргументов при сравнении. Метод прошел исходный набор тестов, включая логарифмы и экспоненты. Один случай отмечен как «скользкий» и на практике может вести себя нестабильно.
import sympy
# Каждый раз создавайте новый временный (фиктивный) символ
def fresh_atom():
return sympy.Dummy()
# Нормализуем выражение относительно набора опорных символов.
# Подвыражения, не содержащие ни одного опорного символа, схлопываются;
# в каждом Add/Mul сохраняется лишь один представитель-заглушка, остальные нейтрализуются.
def collapse_for_vars(expr, pivots):
expr = sympy.collect(expr, extract_terms(expr, pivots))
for arg in expr.args:
expr = expr.subs(arg, collapse_for_vars(arg, pivots))
expr = sympy.simplify(expr)
if expr.is_Add:
scalars = []
with_syms = []
no_syms = []
for arg in expr.args:
if arg.is_Number:
scalars.append(arg)
else:
found = False
for s in pivots:
if arg.has(s):
found = True
break
if found:
with_syms.append(arg)
else:
no_syms.append(arg)
if len(no_syms):
for n in scalars:
expr = expr.subs(n, 0)
for e in no_syms[1:]:
expr = expr.subs(e, 0)
expr = expr.subs(no_syms[0], fresh_atom())
expr = sympy.simplify(expr)
elif expr.is_Mul:
scalars = []
with_syms = []
no_syms = []
for arg in expr.args:
if arg.is_Number:
scalars.append(arg)
else:
found = False
for s in pivots:
if arg.has(s):
found = True
break
if found:
with_syms.append(arg)
else:
no_syms.append(arg)
if len(no_syms):
for n in scalars:
expr = expr.subs(n, 1)
for e in no_syms[1:]:
expr = expr.subs(e, 1)
expr = expr.subs(no_syms[0], fresh_atom())
expr = sympy.simplify(expr)
return expr
# Извлекаем сортируемый ключ для подвыражений, чтобы коммутативные аргументы
# сопоставлялись вне зависимости от порядка.
def extract_terms(expr, pivots):
parts = []
if expr.is_Add:
return [extract_terms(a, pivots)[0] for a in expr.args]
elif expr.is_Mul:
nums = []
syms = []
has_expr = False
for arg in expr.args:
if arg.is_Number:
nums.append(arg)
elif arg.is_Symbol:
syms.append(arg)
else:
has_expr = True
e = expr
l = len(syms)
syms = list(set(syms) - set(pivots))
if has_expr:
for n in nums:
e = e.subs(n, 1)
for s in syms:
e = e.subs(s, 1)
e = sympy.simplify(e)
else:
if l:
for n in nums:
e = e.subs(n, 1)
for s in syms:
e = e.subs(s, 1)
e = sympy.simplify(e)
parts = [e]
else:
parts.append(expr)
return parts
# Структурное равенство относительно опорных символов.
def same_shape(a, b, pivots):
if type(a) != type(b):
if not ((a.is_Number and b.is_Symbol) or (b.is_Number and a.is_Symbol)):
return False
else:
if a.is_Symbol:
if (a in pivots or b in pivots):
if a != b:
return False
elif a.is_number:
if a != b:
return False
if len(a.args) != len(b.args):
return False
a_args = a.args
if len(a_args):
e = [extract_terms(arg, pivots)[0] for arg in a_args]
e, a_args = list(zip(*sorted(zip(e, a_args), key=lambda x: str(x[0]))))
b_args = b.args
if len(b_args):
e = [extract_terms(arg, pivots)[0] for arg in b_args]
e, b_args = list(zip(*sorted(zip(e, b_args), key=lambda x: str(x[0]))))
return all(same_shape(u, v, pivots) for u, v in zip(a_args, b_args))
# Публичный API: нормализуем обе стороны относительно опор и сравниваем формы.
def shape_equal(lhs, rhs, pivots = []):
lhs = sympy.expand(sympy.sympify(lhs))
lhs = collapse_for_vars(lhs, pivots)
lhs = sympy.expand(lhs)
rhs = sympy.expand(sympy.sympify(rhs))
rhs = collapse_for_vars(rhs, pivots)
rhs = sympy.expand(rhs)
return same_shape(lhs, rhs, pivots)
# Пример: работает для логарифмической структуры относительно x
a, b, c, d, e, f, g, h, x = sympy.symbols('a b c d e f g h x')
expr_left = sympy.sympify("a*log(b*x+c)+d")
expr_right = sympy.sympify("e+log(g+x*h)*f")
shape_equal(expr_left, expr_right, [x])
Что изменилось и чем это помогает
Структурный метод сначала раскрывает и упрощает каждую сторону. Внутри Add и Mul он разделяет численные части, компоненты, зависящие от выбранных переменных, и те, что от них не зависят. Блоки, свободные от переменных, схлопываются, чтобы не влиять на структуру, при этом сохраняется лишь одна заглушка. Аргументы коммутативных узлов затем сравниваются после сортировки по производному ключу, что исключает расхождения из-за порядка. В финале алгоритм рекурсивно обходит деревья выражений и требует совпадения указанных переменных по обе стороны. Это позволяет признать a*log(b*x+c)+d и e + f*log(g + h*x) эквивалентными относительно x и одновременно корректно различать выражения при различии переменных.
На расширенном наборе тестов все проверки прошли. Один случай явно отмечен как непростой и может то срабатывать, то нет; эту нестабильность стоит учитывать, исследуя более экзотические композиции.
Зачем это держать под рукой
Реальные символьные задачи редко остаются в уютной зоне многочленов. Как только появляются логарифмы, экспоненты и прочие составные функции, нужен способ сравнения, который уважает структуру, а не пытается насильно превратить всё в полином. Нормализация плюс сравнение формы AST дает возможность рассуждать о семействе выражений с символическими параметрами, не фиксируя конкретные коэффициенты. Управление при этом остается прозрачным: смысл «эквивалентности» задается явно через множество переменных, которые вас интересуют.
Практические выводы
Используйте сравнение полиномов только для настоящих многочленов; иначе при наличии неполиномиальных конструкций ожидайте PolynomialError. Если нужна эквивалентность с точностью до символических параметров, нормализуйте выражения относительно интересующих переменных и сравнивайте их форму дерева. Пишете проверки — помните, что assert это оператор, а не функция, и для проверки на sentinel-значения предпочтительнее x is None, а не x == None.
Как и при любой символьной нормализации, следите за краевыми случаями. Если видите редкую нестабильность на сложном примере, оформите её отдельным тестом и исследуйте, как цепочка преобразований (expand, collect, simplify) взаимодействует с этой записью.
Итог простой: четко определяйте, что именно в вашей задаче значит «эквивалентность», не пытайтесь загонять неполиномиальные структуры в рамки полиномов и сравнивайте главное — структуру зависимости от тех переменных, которые для вас существенны.