最佳实践

本页概述了 SymPy 用户的一些最佳实践。这里的最佳实践将有助于避免在使用 SymPy 时可能出现的一些常见错误和陷阱。

本页面主要关注适用于 SymPy 所有部分的最佳实践。特定于某些 SymPy 子模块或函数的最佳实践在那些特定函数的文档中有详细说明。

基本用法

定义符号

  • 使用 symbols()Symbol() 定义符号。 symbols() 函数是最方便的创建符号的方式。它支持一次性创建一个或多个符号:

    >>> from sympy import symbols
    >>> x = symbols('x')
    >>> a, b, c = symbols('a b c')
    

    此外,它支持向符号添加假设

    >>> i, j, k = symbols('i j k', integer=True)
    

    并定义 Function 对象:

    >>> from sympy import Function
    >>> f, g, h = symbols('f g h', cls=Function)
    

    它还支持一次性定义多个编号符号的简写方式:

    >>> symbols('x:10')
    (x0, x1, x2, x3, x4, x5, x6, x7, x8, x9)
    

    Symbol() 构造函数也可以直接使用。与 symbols() 不同,Symbol() 总是创建一个符号。如果你想用不寻常的字符来命名符号,或者如果你是编程创建符号,这是最好的选择。

    >>> from sympy import Symbol
    >>> x_y = Symbol('x y') # This creates a single symbol named 'x y'
    

    应避免使用 var() 函数,除非在交互式工作时。它的工作方式类似于 symbols() 函数,只是它会自动将符号名称注入调用命名空间。此函数仅设计用于交互式输入的便利,不建议用于编程使用。

    不要使用 sympify()S() 来创建符号。这可能会看似有效:

    >>> from sympy import S
    >>> x = S("x") # DO NOT DO THIS
    

    然而,S()/sympify() 并不是用来创建符号的。它们被设计用来解析整个表达式。如果输入的字符串不是有效的Python,这个方法会失败。如果字符串解析为一个更大的表达式,它也会失败:

    >>> # These both fail
    >>> x = S("0x") 
    Traceback (most recent call last):
    ...
    SyntaxError: invalid syntax (<string>, line 1)
    >>> x = S("x+") 
    Traceback (most recent call last):
    ...
    SyntaxError: invalid syntax (<string>, line 1)
    

    任何 Python 字符串都可以用作有效的符号名称。

    此外,下面 避免字符串输入 部分中描述的所有相同问题也适用于此处。

  • 在已知的情况下为符号添加假设。 假设 可以通过将相关关键字传递给 symbols() 来添加。最常见的假设是 real=Truepositive=True(或 nonnegative=True)和 integer=True

    假设从来不是必需的,但如果已知这些假设,建议将其包括在内,因为这将允许某些操作简化。如果没有提供假设,则假定符号为一般的复数,并且除非它们对所有复数都成立,否则不会进行简化。

    例如:

    >>> from sympy import integrate, exp, oo
    >>> a = symbols('a') # no assumptions
    >>> integrate(exp(-a*x), (x, 0, oo))
    Piecewise((1/a, Abs(arg(a)) < pi/2), (Integral(exp(-a*x), (x, 0, oo)), True))
    
    >>> a = symbols('a', positive=True)
    >>> integrate(exp(-a*x), (x, 0, oo))
    1/a
    

    这里,\(\int_0^\infty e^{-ax}\,dx\)a 未作任何假设时给出分段结果,因为该积分仅在 a 为正时收敛。将 a 设为正数可消除这种分段情况。

    当你使用假设时,最佳实践是始终为每个符号名称使用相同的假设。SymPy允许相同的符号名称定义不同的假设,但这些符号将被视为彼此不相等:

    >>> z1 = symbols('z')
    >>> z2 = symbols('z', positive=True)
    >>> z1 == z2
    False
    >>> z1 + z2
    z + z
    

另请参阅 避免字符串输入不要在Python函数中硬编码符号名称 以了解定义符号相关的最佳实践。

避免字符串输入

不要使用字符串作为函数的输入。相反,使用符号和适当的 SymPy 函数以符号方式创建对象,并对其进行操作。

不要

>>> from sympy import expand
>>> expand("(x**2 + x)/x")
x + 1

>>> from sympy import symbols
>>> x = symbols('x')
>>> expand((x**2 + x)/x)
x + 1

最好总是使用 Python 运算符显式地创建表达式,但有时你确实从一个字符串输入开始,比如当你接受用户输入的表达式时。如果你确实有一个字符串作为起点,你应该使用 parse_expr() 显式地解析它。最好尽早解析所有字符串,然后仅从此处开始使用符号操作。

>>> from sympy import parse_expr
>>> string_input = "(x**2 + x)/x"
>>> expr = parse_expr(string_input)
>>> expand(expr)
x + 1

原因

使用字符串作为 SymPy 函数的输入有许多缺点:

  • 这是不Pythonic的,并且使代码更难阅读。参见 Python之禅 “显式胜于隐式”。

  • 在一般的 SymPy 函数中对字符串输入的支持大多是偶然的。这是因为这些函数在输入上调用 sympify() 以将 Python int 转换为 SymPy Integer。然而,sympify() 也会将字符串解析为 SymPy 表达式,除非使用了 strict=True 标志。对于一般的 SymPy 函数(除了 sympify()parse_expr())自动解析字符串 可能会在未来的 SymPy 版本中消失

  • 符号或函数名称中的拼写错误可能不会被注意到。这是因为字符串中所有未定义的名称都会被自动解析为符号或函数。如果输入有拼写错误,字符串仍会正确解析,但输出将不是预期的结果。例如

    >>> from sympy import expand_trig
    >>> expand_trig("sine(x + y)")
    sine(x + y)
    

    与不使用字符串时得到的显式错误进行比较:

    >>> from sympy import sin, symbols
    >>> x, y = symbols('x y')
    >>> expand_trig(sine(x + y)) # The typo is caught by a NameError
    Traceback (most recent call last):
    ...
    NameError: name 'sine' is not defined
    >>> expand_trig(sin(x + y))
    sin(x)*cos(y) + sin(y)*cos(x)
    

    在第一个例子中,sinesin 的拼写错误,被解析为 Function("sine"),并且看起来 expand_trig 无法处理它。在第二个例子中,我们立即从未定义的名称 sine 得到一个错误,修正拼写错误后,我们看到 expand_trig 确实可以完成我们想要的操作。

  • 在使用字符串输入时,最大的陷阱来自于使用假设。在 SymPy 中,如果两个符号具有相同的名字但不同的假设,它们被认为是不同的:

    >>> z1 = symbols('z')
    >>> z2 = symbols('z', positive=True)
    >>> z1 == z2
    False
    >>> z1 + z2
    z + z
    

    通常建议避免这样做,因为它可能导致如上所述的令人困惑的表达(参见上文的 定义符号)。

    然而,字符串输入总是会创建不带假设的符号。因此,如果你有一个带有假设的符号,然后尝试使用它的字符串版本,你将会得到令人困惑的结果。

    >>> from sympy import diff
    >>> z = symbols('z', positive=True)
    >>> diff('z**2', z)
    0
    

    这里的答案显然是错误的,但问题在于 "z**2" 中的 z 被解析为没有任何假设的 Symbol('z'),而 SymPy 认为这与 z = Symbol('z', positive=True) 是不同的符号,后者被用作 diff() 的第二个参数。因此,就 diff 而言,表达式被视为常数,结果为 0。

    这种情况特别糟糕,因为它通常不会导致任何错误。它只会默默地给出“错误”的答案,因为 SymPy 会将你认为相同的符号视为不同。通过不使用字符串输入可以避免这种情况。

    如果你正在解析字符串,并且希望其中的某些符号具有特定的假设,你应该创建这些符号并将它们传递给字典以 parse_expr()。例如:

    不要

    >>> a, b, c = symbols('a b c', real=True)
    >>> # a, b, and c in expr are different symbols without assumptions
    >>> expr = parse_expr('a**2 + b - c')
    >>> expr.subs({a: 1, b: 1, c: 1}) # The substitution (apparently) doesn't work
    a**2 + b - c
    

    >>> # a, b, and c are the same as the a, b, c with real=True defined above
    >>> expr = parse_expr('a**2 + b - c', {'a': a, 'b': b, 'c': c})
    >>> expr.subs({a: 1, b: 1, c: 1})
    1
    
  • 许多 SymPy 操作被定义为方法,而不是函数,也就是说,它们像 sympy_obj.method_name() 这样调用。这些方法对字符串不起作用,因为它们还不是 SymPy 对象。例如:

    >>> "x + 1".subs("x", "y")
    Traceback (most recent call last):
    ...
    AttributeError: 'str' object has no attribute 'subs'
    

    与之对比的是:

    >>> x, y = symbols('x y')
    >>> (x + 1).subs(x, y)
    y + 1
    
  • 符号名称可以包含任何字符,包括那些在Python中无效的字符。但是,如果你使用字符串作为输入,就无法使用这些符号。例如

    >>> from sympy import solve
    >>> solve('x_{2} - 1') 
    ValueError: Error from parse_expr with transformed code: "Symbol ('x_' ){Integer (2 )}-Integer (1 )"
    ...
    SyntaxError: invalid syntax (<string>, line 1)
    

    这不起作用,因为 x_{2} 不是有效的 Python 代码。但将其用作符号名称是完全可能的:

    >>> x2 = symbols('x_{2}')
    >>> solve(x2 - 1, x2)
    [1]
    

    实际上,上述情况是最佳情景,你会得到一个错误。也有可能你会得到一些意想不到的结果:

    >>> solve('x^1_2 - 1')
    [-1, 1, -I, I, -1/2 - sqrt(3)*I/2, -1/2 + sqrt(3)*I/2, 1/2 - sqrt(3)*I/2, 1/2 + sqrt(3)*I/2, -sqrt(3)/2 - I/2, -sqrt(3)/2 + I/2, sqrt(3)/2 - I/2, sqrt(3)/2 + I/2]
    

    这里发生的情况是,x^1_2 没有被解析为 \(x^1_2\),而是被解析为 x**12^ 被转换为 **,并且 _ 在 Python 的数值字面量中被忽略)。

    如果我们创建一个符号,符号名称的实际内容将被忽略。它总是被表示为一个单一的符号。

    >>> x12 = symbols('x^1_2')
    >>> solve(x12 - 1, x12)
    [1]
    
  • 如果你使用字符串,语法错误直到该行运行时才会被捕获。如果你构建表达式,语法错误将在任何代码运行之前立即被捕获。

  • 代码编辑器中的语法高亮通常不会识别并着色字符串内容,而它可以识别Python表达式。

避免将表达式作为字符串操作

如果你发现自己对符号表达式进行了大量的字符串或正则表达式操作,这通常表明你使用SymPy的方式不正确。更好的做法是直接使用 +-*/ 等运算符以及SymPy的各种函数和方法来构建表达式。基于字符串的操作可能会引入错误,迅速变得复杂,并且失去符号表达式结构的优势。

原因在于,字符串中没有符号表达式的概念。对Python来说,"(x + y)/z""/x+)(y z " 没有区别,后者只是字符顺序不同的同一字符串。相比之下,SymPy表达式实际上知道它代表哪种类型的数学对象。SymPy有许多方法和函数用于构建和操作表达式,它们都作用于SymPy对象,而不是字符串。

例如

不要

>>> expression_str = '+'.join([f'{i}*x_{i}' for i in range(10)])
>>> expr = parse_expr(expression_str)
>>> expr
x_1 + 2*x_2 + 3*x_3 + 4*x_4 + 5*x_5 + 6*x_6 + 7*x_7 + 8*x_8 + 9*x_9

>>> from sympy import Add, Symbol
>>> expr = Add(*[i*Symbol(f'x_{i}') for i in range(10)])
>>> expr
x_1 + 2*x_2 + 3*x_3 + 4*x_4 + 5*x_5 + 6*x_6 + 7*x_7 + 8*x_8 + 9*x_9

另请参阅 避免向函数输入字符串的上一节

精确有理数 vs. 浮点数

如果一个数值已知精确等于某个量,避免将其定义为浮点数。

例如,

不要

>>> expression = x**2 + 0.5*x + 1

>>> from sympy import Rational
>>> expression = x**2 + Rational(1, 2)*x + 1
>>> expression = x**2 + x/2 + 1 # Equivalently

然而,这并不是说在 SymPy 中你永远不应该使用浮点数,只是如果已知更精确的值,应该优先使用。SymPy 确实支持 任意精度浮点数,但某些操作可能无法与它们一样高效。

这也适用于可以精确表示的非有理数。例如,应避免使用 math.pi 而更倾向于使用 sympy.pi,因为前者是对 \(\pi\) 的数值近似,而后者是精确的 \(\pi\)(另见下方 分离符号代码和数值代码;一般来说,在使用 SymPy 时应避免导入 math)。

不要

>>> import math
>>> import sympy
>>> math.pi
3.141592653589793
>>> sympy.sin(math.pi)
1.22464679914735e-16

>>> sympy.pi
pi
>>> sympy.sin(sympy.pi)
0

这里 sympy.sin(math.pi) 并不完全等于 0,因为 math.pi 并不完全等于 \(\pi\)

还应避免编写 integer/integer ,其中两个整数都是显式整数。这是因为 Python 会在 SymPy 解析之前将此计算为浮点值。

不要

>>> x + 2/7 # The exact value of 2/7 is lost
x + 0.2857142857142857

在这种情况下,使用 Rational 来创建一个有理数,或者使用 S() 简写来节省输入。

>>> from sympy import Rational, S
>>> x + Rational(2, 7)
x + 2/7
>>> x + S(2)/7 # Equivalently
x + 2/7

原因

如果已知确切值,出于以下原因,应优先于浮点数使用:

  • 一个精确的符号值通常可以进行符号简化或操作。浮点数表示对一个精确实数的近似值,因此不能被精确简化。例如,在上面的例子中,sin(math.pi)不会产生0,因为math.pi并不是精确的\(\pi\)。它只是一个近似\(\pi\)到15位小数的浮点数(实际上,是一个接近\(\pi\)的有理数近似值,但并不是精确的\(\pi\))。

  • 某些算法如果存在浮点数值则无法计算结果,但如果值是分数则可以。这是因为分数具有使这些算法更容易处理它们的特性。例如,对于浮点数,可能会出现一个数应该为0的情况,但由于近似误差,并不完全等于0。

    一个特别值得注意的例子是使用浮点数指数。例如,

    >>> from sympy import factor
    >>> factor(x**2.0 - 1)
    x**2.0 - 1
    >>> factor(x**2 - 1)
    (x - 1)*(x + 1)
    
  • SymPy 浮点数存在与使用有限精度浮点近似值相同的精度损失问题:

    >>> from sympy import expand
    >>> expand((x + 1.0)*(x - 1e-16)) # the coefficient of x should be slightly less than 1
    x**2 + 1.0*x - 1.0e-16
    >>> expand((x + 1)*(x - Rational('1e-16'))) # Using rational numbers gives the coefficient of x exactly
    x**2 + 9999999999999999*x/10000000000000000 - 1/10000000000000000
    

    在许多情况下,通过谨慎使用 evalf 及其在任意精度下进行评估的能力,可以避免 SymPy 中的这些问题。这通常涉及使用符号值计算表达式,并在之后用 expr.evalf(subs=...) 替换它们,或者从精度高于默认 15 位数的 Float 值开始:

    >>> from sympy import Float
    >>> expand((x + 1.0)*(x - Float('1e-16', 20)))
    x**2 + 0.9999999999999999*x - 1.0e-16
    

一个 Float 数字可以通过传递给 Rational 来转换为其精确的有理数等价物。或者,你可以使用 nsimplify 来找到最漂亮的有理数近似。如果数字本来就是有理数(尽管再次强调,如果可以的话,最好从一开始就使用有理数),这有时可以再现原本的数字:

>>> from sympy import nsimplify
>>> Rational(0.7)
3152519739159347/4503599627370496
>>> nsimplify(0.7)
7/10

避免使用 simplify()

simplify()(不要与 sympify() 混淆)被设计为一个通用启发式算法。它尝试对输入表达式应用各种简化算法,并根据某些度量标准返回看起来“最简单”的结果。

simplify() 非常适合交互式使用,在这种情况下,您只是希望 SymPy 对表达式进行任何可能的处理。然而,在编程使用中,最好避免使用 simplify(),而是使用更 有针对性的简化函数 (例如,cancel()expand(),或 collect())。

有几个原因使得这通常是首选的:

  • 由于其启发式特性,simplify() 可能会很慢,因为它尝试了许多不同的方法来试图找到最佳的简化方案。

  • 经过 simplify() 处理后,表达式的形式没有任何保证。它实际上可能会变得“更不简单”,无论你希望使用什么标准。相比之下,有针对性的简化函数对其行为和输出的保证非常具体。例如,

    • factor() 总是将一个多项式分解为不可约因子。

    • cancel() 总是将一个有理函数转换为形式 \(p/q\),其中 \(p\)\(q\) 是展开的多项式,且没有公因子。

    每个函数的文档都准确描述了它将对输入表达式产生的行为。

  • 如果表达式包含意外的形式或意外的子表达式,有针对性的简化不会做出意外的行为。特别是当简化函数以 deep=False 应用时,仅对顶层表达式应用简化,这种情况尤为明显。

其他一些简化函数本质上是启发式的,因此在使用时也应谨慎。例如,trigsimp() 函数是一个针对三角函数的启发式函数,但 sympy.simplify.fu 子模块中的例程允许应用特定的三角恒等式。

教程的简化部分简化模块参考列出了各种目标简化函数。

在某些情况下,您可能确切地知道希望对表达式应用哪些简化操作,但可能没有一套完全符合这些操作的简化函数。当这种情况发生时,您可以使用 replace() 创建自己的针对性简化,或者通常情况下,使用 高级表达式操作 手动进行。

不要在Python函数中硬编码符号名称

与其在函数定义内部硬编码 Symbol 名称,不如将符号作为函数的参数。

例如,考虑一个计算 theta 算子 \(\theta = zD_z\) 的函数 theta_operator

不要

def theta_operator(expr):
    z = symbols('z')
    return z*expr.diff(z)

def theta_operator(expr, z):
    return z*expr.diff(z)

硬编码的符号名称的缺点是要求所有表达式都使用该确切的符号名称。在上面的例子中,不可能计算 \(\theta = xD_x\),因为它被硬编码为 \(zD_z\)。更糟糕的是,试图这样做会静默地导致错误的结果而不是错误,因为 x 被视为一个常量表达式:

>>> def theta_operator(expr):
...     z = symbols('z')
...     return z*expr.diff(z)
>>> theta_operator(x**2) # The expected answer is 2*x**2
0

如果函数接受任意用户输入,这尤其成问题,因为用户可能会使用在其数学上下文中更有意义的变量名。如果用户已经将符号 z 用作常量,他们需要在能够使用该函数之前使用 subs 交换内容。

这种反模式有问题的另一个原因是,带有假设的符号被认为与不带假设的符号不相等。如果有人使用

>>> z = symbols('z', positive=True)

例如,为了使进一步简化成为可能(见上文 定义符号),函数硬编码 Symbol('z') 而不带假设将无法工作:

>>> theta_operator(z**2)
0

通过将符号作为函数的参数,例如 theta_operator(expr, z),这些问题都迎刃而解。

分离符号代码和数值代码

SymPy 与 Python 生态系统中的大多数其他库不同,它以符号方式操作,而其他库,如 NumPy,则是以数值方式操作。这两种范式差异很大,因此最好尽可能将它们分开。

重要的是,SymPy 不是设计来与 NumPy 数组一起工作的,反之亦然,NumPy 也不能直接与 SymPy 对象一起工作。

>>> import numpy as np
>>> import sympy
>>> a = np.array([0., 1., 2.])
>>> sympy.sin(a)
Traceback (most recent call last):
...
AttributeError: 'ImmutableDenseNDimArray' object has no attribute 'as_coefficient'
>>> x = Symbol('x')
>>> np.sin(x) # NumPy functions do not know how to handle SymPy expressions
Traceback (most recent call last):
...
TypeError: loop of ufunc does not support argument 0 of type Symbol which has no callable sin method

如果你想同时使用 SymPy 和 NumPy,你应该使用 lambdify() 显式地将你的 SymPy 表达式转换为 NumPy 函数。在 SymPy 中的典型工作流程是使用 SymPy 符号化地建模你的问题,然后将结果转换为可以通过 lambdify() 在 NumPy 数组上评估的数值函数。对于高级用例,lambdify()/NumPy 可能不够,你可能需要使用 SymPy 的更通用的 代码生成 例程来为其他快速数值语言(如 Fortran 或 C)生成代码。

>>> # First symbolically construct the expression you are interested in with SymPy
>>> from sympy import diff, sin, exp, lambdify, symbols
>>> x = symbols('x')
>>> expr = diff(sin(x)*exp(x**2), x)

>>> # Then convert it to a numeric function with lambdify()
>>> f = lambdify(x, expr)

>>> # Now use this function with NumPy
>>> import numpy as np
>>> a = np.linspace(0, 10)
>>> f(a) 
[ 1.00000000e+00  1.10713341e+00  1.46699555e+00 ... -3.15033720e+44]

这些是一些通常应避免的反模式

  • 不要使用 import math 实际上,在与 SymPy(或 NumPy)一起使用时,几乎不需要使用 标准库 math 模块math 中的每个函数在 SymPy 中都已经存在。SymPy 可以使用 evalf 进行数值计算,这比 math 提供了更高的精度和准确性。或者更好的是,SymPy 默认情况下会以符号方式进行计算。math 中的函数和常量是浮点数,这是不精确的。SymPy 在可能的情况下总是更好地处理精确的量。例如,

    >>> import math
    >>> math.pi # a float
    3.141592653589793
    >>> import sympy
    >>> sympy.sin(math.pi)
    1.22464679914735e-16
    

    sympy.sin(math.pi) 的结果并不是你预期的 0,因为 math.pi 只是 \(\pi\) 的一个近似值,精确到16位。另一方面,sympy.pi 完全等于 \(\pi\),因为它以符号形式表示,因此能够给出精确答案:

    >>> sympy.sin(sympy.pi)
    0
    

    因此,一般来说,人们应该 倾向于使用符号表示。但即使你确实想要一个浮点数,使用 SymPy 的 evalf() 也比 math 更好。这避免了 math 函数只能操作 float 对象,而不是符号表达式的陷阱。

    >>> x = Symbol('x')
    >>> math.sin(x)
    Traceback (most recent call last):
    ...
    TypeError: Cannot convert expression to float
    

    此外,SymPy 的 evalf()math 更准确,因为它使用任意精度算术,并允许您指定任意数量的位数。

    >>> sympy.sin(1).evalf(30)
    0.841470984807896506652502321630
    >>> math.sin(1)
    0.8414709848078965
    

    即使在使用 NumPy 时,也应避免使用 math。NumPy 函数比 math 函数更快,支持更大范围的数值数据类型,并且可以对值数组进行操作,而 math 函数一次只能操作一个标量。

  • 不要将 SymPy 表达式传递给 NumPy 函数。 你不应该将 SymPy 表达式传递给 NumPy 函数。这包括 numpyscipy 命名空间中的任何内容,以及其他 Python 库(如 matplotlib)中的大多数函数。这些函数仅设计用于处理带有数值的 NumPy 数组。

  • 不要将 SymPy 表达式传递给 lambdify 函数。 与前一点类似,你不应该将 SymPy 表达式传递给使用 lambdify 创建的函数。实际上,lambdify 返回的函数 NumPy 函数,所以这里的情况完全相同。在某些情况下,从 lambdify() 创建的函数可能会与 SymPy 表达式一起工作,但这只是其工作方式的一个意外。有关为什么会发生这种情况的更多详细信息,请参阅 lambdify() 文档的“工作原理”部分

  • 避免在NumPy数组中存储SymPy表达式。 虽然从技术上讲,可以将SymPy表达式存储在NumPy数组中,但这样做通常代表了一个错误。如果NumPy数组的dtypeobject(而不是像float64int64这样的数值dtype),这就是这种情况发生的迹象。

    正如在进行符号计算时应该避免使用 NumPy 一样,一旦计算转移到使用 NumPy 的数值计算方面,就应该停止使用 SymPy。

    包含 SymPy 表达式的 NumPy 数组实际上与直接在 SymPy 表达式上调用 NumPy 函数存在相同的问题。它们不知道如何操作 SymPy 对象,因此会失败。即使 SymPy 对象都是 SymPy Floats,这也适用。

    >>> import numpy as np
    >>> import sympy
    >>> a = np.asarray([sympy.Float(1.0), sympy.Float(0.0)]) # Do not do this
    >>> print(repr(a)) # Note that the dtype is 'object'
    array([1.00000000000000, 0.0], dtype=object)
    >>> np.sin(a)
    Traceback (most recent call last):
    ...
    TypeError: loop of ufunc does not support argument 0 of type Float which has no callable sin method
    

    如果你正在做这个,你可能应该使用原生的 NumPy 浮点数,或者,如果你真的想要存储一个 SymPy 表达式的数组,你应该使用 SymPy 的 MatrixNDimArray 类。

高级用法

比较和排序符号对象时要小心

在编程代码中比较数值量时要小心,无论是直接使用不等式(<, <=, >, >=)还是间接使用类似 sorted 的方法。问题在于,如果一个不等式是未知的,结果将会是符号化的,例如

>>> x > 0
x > 0

如果对符号不等式调用 bool(),由于其模糊性,将会引发异常:

>>> bool(x > 0)
Traceback (most recent call last):
...
TypeError: cannot determine truth value of Relational

像这样的检查

if x > 0:
    ...

如果你只对数值 x 进行测试,它可能工作得很好。但如果 x 可以是符号化的,上面的代码就是错误的。它会失败并抛出 TypeError: cannot determine truth value of Relational。如果你遇到这个异常,意味着在某个地方出现了这个错误(有时错误在 SymPy 本身;如果看起来是这种情况,请 提交一个 issue)。

当使用 sorted 时,同样的问题也会发生,因为这在内部使用了 >

>>> sorted([x, 0])
Traceback (most recent call last):
...
TypeError: cannot determine truth value of Relational

有几种方法可以解决这个问题,选择正确的方法取决于你在做什么:

  • 禁止符号输入。 如果你的函数不能处理符号输入,你可以明确禁止它们。这里的主要好处是给用户提供一个比 TypeError:  cannot determine truth value of Relational 更易读的错误信息。is_number 属性可以用来检查一个表达式是否可以通过 evalf() 计算为一个特定的数值。如果你想只接受整数,你可以检查 isinstance(x, Integer)(在调用 sympify() 将Python整数转换之后)。注意,is_integer 使用假设系统,对于像 Symbol('x', integer=True) 这样的符号对象,它也可能是True。

  • 使用假设系统。 如果你支持符号输入,你应该使用假设系统来检查诸如 x > 0 的情况,例如使用 x.is_positive。在这样做时,你应该始终 了解细微差别 假设系统中使用的 三值模糊逻辑。也就是说,始终要注意一个假设可能是 None,这意味着其值是未知的,可能是真也可能是假。例如,

    if x.is_positive:
        ...
    

    只有在 x.is_positiveTrue 时才会运行该块,但你可能希望在 x.is_positiveNone 时执行某些操作。

  • 返回一个分段结果。 如果函数的结果取决于不等式或其他布尔条件,您可以使用 Piecewise 返回一个代表两种可能性的符号结果。这通常是首选的,因为它提供了最大的灵活性。这是因为结果是以符号形式表示的,这意味着,例如,稍后可以为这些符号代入特定值,即使它与其他表达式结合,也会评估为特定情况。

    例如,而不是

    if x > 0:
        expr = 1
    else:
        expr = 0
    

    这可以符号化地表示为

    >>> from sympy import Piecewise, pprint
    >>> expr = Piecewise((1, x > 0), (0, True))
    >>> pprint(expr, use_unicode=True)
    ⎧1  for x > 0
    
    ⎩0  otherwise
    >>> expr.subs(x, 1)
    1
    >>> expr.subs(x, -1)
    0
    
  • 使用 ordered() 将表达式排序为规范顺序。 如果你试图使用 sorted 是因为你想要一个规范的排序,但你不特别关心那个排序是什么,你可以使用 ordered

    >>> from sympy import ordered
    >>> list(ordered([x, 0]))
    [0, x]
    

    或者,尝试以一种方式编写函数,使得结果的正确性不依赖于参数的处理顺序。

自定义 SymPy 对象

SymPy 设计为可以通过自定义类进行扩展,通常通过继承 BasicExprFunction 来实现。SymPy 本身的所有符号类都是以这种方式编写的,这里提到的点同样适用于用户定义的类。

要深入了解如何编写 Function 子类,请参阅 编写自定义函数的指南

参数不变性

自定义 SymPy 对象应始终满足以下不变性:

  1. all(isinstance(arg, Basic) for arg in args)

  2. expr.func(*expr.args) == expr

第一个表示 args 中的所有元素都应该是 Basic 的实例。第二个表示表达式应该可以从其 args 重建(注意 func 通常与 type(expr) 相同,尽管并不总是如此)。

这两个不变量在整个SymPy中都被假定,并且对于任何操作表达式的函数都是必不可少的。

例如,考虑这个简单的函数,它是 xreplace() 的简化版本:

>>> def replace(expr, x, y):
...     """Replace x with y in expr"""
...     newargs = []
...     for arg in expr.args:
...         if arg == x:
...             newargs.append(y)
...         else:
...             newargs.append(replace(arg, x, y))
...     return expr.func(*newargs)
>>> replace(x + sin(x - 1), x, y)
y + sin(y - 1)

该函数通过递归遍历 exprargs,并重新构建它,除了任何 x 的实例被替换为 y

如果参数的不变性不成立,很容易看出这个函数会如何崩溃:

  1. 如果一个表达式有非 Basic 的参数,它们会在递归调用时因 AttributeError 而失败,因为非 Basic 的参数不会有 .args.func 属性。

  2. 如果一个表达式没有从其 args 重新构建,那么 return exr.func(*newargs) 这一行将会失败,即使在所有参数都没有被替换改变的简单情况下,这应该实际上是一个无操作。

使所有 args 实例成为 Basic 通常意味着在类的输入上调用 _sympify(),以便它们成为基本实例。如果你想在类上存储一个字符串,你应该使用 Symbolsympy.core.symbols.Str

在某些情况下,一个类可能接受多种等效形式的参数。重要的是,无论args中存储的是哪种形式,都必须是可以用来重建类的形式之一。只要规范化后的形式被接受为输入,对args进行规范化是可以的。例如,Integral总是将变量参数存储为元组,以便于内部处理,但这种形式也由类构造函数接受:

>>> from sympy import Integral
>>> expr = Integral(sin(x), x)
>>> expr.args # args are normalized
(sin(x), (x,))
>>> Integral(sin(x), (x,)) # Also accepted
Integral(sin(x), x)

请注意,大多数用户定义的自定义函数应通过子类化 Function 来定义(参见编写自定义函数的指南)。Function 类会自动处理两个参数的不变性,因此如果您使用它,则无需担心这一点。

避免过度自动评估

在定义自定义函数时,避免进行过多的自动求值(即在 eval__new__ 方法中进行求值)。

通常,自动评估只应在速度快且没有人希望它不发生的情况下进行。自动评估很难撤销。一个很好的经验法则是仅对显式数值进行评估(isinstance(x, Number)),并将其他所有内容保持为符号未评估状态。进一步使用更高级的恒等式进行简化应在特定的简化函数或 doit 中完成(参见 自定义函数指南 以获取可在 SymPy 对象上定义的常见简化例程列表)。

自定义函数指南》对此进行了深入探讨(但请注意,此指南同样适用于所有 SymPy 对象,而不仅仅是函数)。简而言之,这样做的原因是防止自动评估的唯一方法是使用 evaluate=False,这是脆弱的。此外,代码将不可避免地基于自动评估所保持的不变性来编写,这意味着使用 evaluate=False 创建的表达式可能会导致该代码得出错误的结果。这也意味着以后移除自动评估可能会很困难。

可能代价高昂的评估(例如,应用符号恒等式)本身就是不好的,因为它可以使创建一个表达式而不对其做任何操作成为可能。这也适用于检查符号假设(如 x.is_integer),因此在类构造函数中也应避免这种情况。

不要

class f(Function):
    @classmethod
    def eval(cls, x):
        if x.is_integer: # Bad (checking general assumptions)
            return 0
        if isinstance(x, Add): # Bad (applying symbolic identities)
            return Add(*[f(i) for i in x.args])

class f(Function):
    @classmethod
    def eval(cls, x):
        if isinstance(x, Integer): # Good (only evaluating on explicit integers)
            return 0

    # Good (applying simplification on assumptions in doit())
    def doit(self, deep=True, **hints):
        x = self.args[0]
        if deep:
           x = x.doit(deep=deep, **hints)
        if x.is_integer:
           return S(0)
        return self

    # Good (applying symbolic identities inside of simplification functions)
    def _eval_expand_func(self, **hints):
        x = self.args[0]
        if isinstance(x, Add):
            return Add(*[f(i) for i in x.args])
        return self

请注意,SymPy 中的所有类目前并不完全遵循这一指南,但这是我们正在改进的地方。

不要嵌套集合

接受任意数量参数的函数和类应该直接接受参数,例如 f(*args),或者作为一个单一参数,例如 f(args)。它们不应该同时支持这两种方式。

原因在于这使得无法表示嵌套集合。例如,考虑 FiniteSet 类。它的构造方式为 FiniteSet(x, y, z)(即,使用 *args)。

>>> from sympy import FiniteSet
>>> FiniteSet(1, 2, 3)
{1, 2, 3}

支持 FiniteSet([1, 2, 3]) 可能很诱人,以匹配内置的 set。然而,这样做将使得无法表示包含单个 FiniteSet 的嵌套 FiniteSet,例如 \(\{\{1, 2, 3\}\}\)

>>> FiniteSet(FiniteSet(1, 2, 3)) # We don't want this to be the same as {1, 2, 3}
FiniteSet({1, 2, 3})

关于应该使用 args 还是 *args,如果只可能存在有限数量的参数,通常使用 *args 更好,因为这使得使用对象的 args 更容易处理,因为 obj.args 将是类的直接参数。然而,如果可能需要支持符号化的无限集合以及有限的集合,比如 IntegersRange,那么最好使用 args,因为这是无法通过 *args 实现的。

避免在对象上存储额外属性

你可能想要创建一个自定义 SymPy 对象的一个常见原因是,你希望在该对象上存储额外的属性。然而,以一种简单的方式来做这件事,即直接将数据作为 Python 属性存储在对象上,几乎总是一个坏主意。

SymPy 不期望对象在其 args 之外存储额外数据。例如,这会破坏 == 检查,因为它只比较对象的 args。请参阅下面的 不要覆盖 __eq__ 部分,了解为什么重写 __eq__ 是一个坏主意。本节与那一节密切相关。

通常,根据您具体情况的具体细节,有更好的方法来完成您正在尝试做的事情:

  • 将额外数据存储在对象的 args 中。 如果你想存储的额外数据是你对象的 数学 描述的一部分,这是最好的方法。

    只要数据可以使用其他 SymPy 对象表示,它就可以存储在 args 中。请注意,对象的 args 应该可用于 重新创建对象(例如,类似 YourObject(*instance.args) 的操作应重新创建 instance)。

    此外,值得一提的是,如果你计划在 args 中存储额外内容,子类化 Symbol 并不是一个好主意。Symbol 的设计围绕着没有 args。你最好子类化 Function(参见 编写自定义函数)或直接子类化 Expr。如果你只是想让两个符号彼此区分,最好的方法通常是给它们不同的名称。如果你担心它们如何打印,可以在打印时用更规范的名称替换它们,或者使用 自定义打印机

  • 将对象的数据单独存储。 如果额外数据与对象的数学属性没有直接关系,这是最佳方法。

    记住,SymPy 对象是可哈希的,因此它们可以很容易地用作字典键。因此,维护一个 {对象: 额外数据} 对字典是直接的。

    请注意,一些 SymPy API 已经允许单独重新定义它们如何操作对象,而不是对象本身。这方面的一个大例子是 打印机,它允许定义 自定义打印机,这些打印机在不修改对象本身的情况下改变任何 SymPy 对象的打印方式。像 lambdify()init_printing() 这样的函数允许传入自定义打印机。

  • 使用不同的子类表示属性。 如果属性的可能值只有几个(例如,布尔标志),这通常是一个好主意。通过使用一个共同的父类,可以避免代码重复。

  • 如果要存储的数据是一个Python函数,最好直接将其用作类的方法。在许多情况下,该方法可能已经适合于 现有的可重写SymPy方法 之一。如果你想定义一个函数如何进行数值计算,可以使用 implemented_function()

  • 通过修改对象的 func 来表示信息。 这种方法比其他方法复杂得多,只有在必要时才应使用。在某些极端情况下,仅使用 args 无法表示对象的每个数学方面。例如,由于 args 应仅包含 Basic 实例 的限制,可能会发生这种情况。在这些情况下,仍然可以通过使用与 type(expr) 不同的自定义 func 来创建自定义 SymPy 对象(在这种情况下,您将在 func 上重写 __eq__ 而不是在类上)。

    然而,这种情况很少见。

不要覆盖 __eq__

在构建自定义 SymPy 对象时,有时会诱人重写 __eq__ 以定义 == 操作符的自定义逻辑。这几乎总是一个坏主意。自定义 SymPy 类应保持 __eq__ 未定义,并使用 Basic 超类中的默认实现。

在 SymPy 中,== 使用 结构相等性 来比较对象。也就是说,a == b 意味着 ab 是完全相同的对象。它们具有相同的类型和相同的 参数== 不会执行任何形式的 数学 相等性检查。例如,

>>> x*(x - 1) == x**2 - x
False

== 也总是返回一个布尔值 TrueFalse。符号方程可以用 Eq 表示。

这有几个原因

  • 数学等式检查的计算可能非常昂贵,并且通常,它是 计算上不可能确定的

  • Python 本身在多个地方自动使用 ==,并假设它会返回一个布尔值且计算成本低。例如,如果 b 是像 listdictset 这样的内置 Python 容器,那么 a in b 会使用 ==[1]

  • SymPy 在内部到处使用 ==,无论是显式还是通过 in 或字典键等隐式方式。这种用法都隐含地假设 == 是结构化操作的。

实际上,结构相等 意味着如果 a == bTrue,那么 ab 在所有实际用途上都是同一个对象。这是因为所有 SymPy 对象都是 不可变的。当 a == 时,任何 SymPy 函数都可以在任何子表达式中自由地将 a 替换为 b

Basic 上的默认 __eq__ 方法检查两个对象是否具有相同类型和相同的 args。SymPy 的许多部分也隐含地假设,如果两个对象相等,那么它们具有相同的 args。因此,试图通过覆盖 __eq__ 来避免在对象的 args 中存储一些识别信息并不是一个好主意。对象的 args 应该包含重新创建它所需的一切(参见 args)。请注意,对象的构造函数可以接受多种形式的参数,只要它接受存储在 args 中的形式即可(例如,某些参数具有默认值是完全正常的)。

以下是一些你可能想要重写 __eq__ 的原因及其推荐的替代方案:

  • 为了让 == 执行比纯粹结构相等更智能的相等检查。如上所述,这是一个坏主意,因为太多东西隐含地假设 == 仅在结构上起作用。相反,使用一个函数或方法来实现更智能的相等检查(例如,equals 方法)。

    另一个选项是定义一个 规范化 方法,将对象转换为规范形式(例如,通过 doit),这样,例如,x.doit() == y.doit()xy 数学上相等时为真。这并不总是可能的,因为并非每种类型的对象都有一个可计算的规范形式,但当存在这样的形式时,这是一个方便的方法。

  • 为了使 == 检查表达式的 args 之外的一些额外属性。请参阅上面的 避免在对象上存储额外属性 部分,了解为什么直接在 SymPy 对象上存储额外属性是一个坏主意,以及最佳替代方案是什么。

  • 为了使 == 比较等于某些非 SymPy 对象。最好扩展 sympify 以能够将此对象转换为 SymPy 对象。默认的 __eq__ 实现会自动在另一个参数上调用 sympify,如果它不是 Basic 实例(例如,Integer(1) == int(1) 返回 True)。可以通过定义 _sympy_ 方法来扩展你控制的 sympify 对象,并通过扩展 converter 字典来扩展你无法控制的对象。有关更多详细信息,请参阅 sympify() 文档。

避免从假设处理器产生的无限递归

在编写自定义函数(如 _eval_is_positive)的假设处理程序时(有关如何执行此操作的详细信息,请参阅自定义函数指南),需要牢记两件重要的事情:

首先,避免在假设处理器内部创建新的表达式。你应该总是直接分解函数的参数。 这样做的原因是,创建一个新的表达式可能会导致假设查询。这很容易导致无限递归。即使不会导致无限递归,创建一个本身可能导致多次递归假设查询的新表达式,相比于更直接地查询所需属性,对性能是不利的。

这通常意味着使用 as_independent()as_coeff_mul() 等方法,并直接检查表达式的 args(参见 自定义函数指南 中的示例)。

其次,不要在假设处理器中递归地评估 self 上的假设。假设处理器应该只检查 self.args 上的假设。全局假设系统将自动处理不同假设之间的推论。

例如,你可能会想写一些类似的东西

# BAD

class f(Function):
    def _eval_is_integer(self):
        # Quick return if self is not real (do not do this).
        if self.is_real is False:
            return False
        return self.args[0].is_integer

然而,if self.is_real is False 检查是完全不必要的。假设系统已经知道 integer 意味着 real,如果它已经知道 is_real 是 False,它就不会费心去检查 is_integer

如果你以这种方式定义函数,它将导致无限递归:

>>> class f(Function):
...     def _eval_is_integer(self):
...         if self.is_real is False:
...             return False
...         return self.args[0].is_integer
>>> f(x).is_real
Traceback (most recent call last):
...
RecursionError: maximum recursion depth exceeded while calling a Python object

相反,仅根据函数的参数定义处理程序:

# GOOD

class f(Function):
    def _eval_is_integer(self):
        return self.args[0].is_integer