SymPy 数值求根实战指南:nsolve 解方程与方程组、复数根、区间约束与精度控制
【免费下载链接】sympyA computer algebra system written in pure Python项目地址: https://gitcode.com/GitHub_Trending/sy/sympy
本文基于 SymPy 官方指南 solve-numerically.md,系统讲解如何用nsolve数值求解单个方程与多元方程组,涵盖复数根的求解、用二分法把根约束在指定区间、prec精度控制,以及lambdify与 SciPy 结合的数值计算工作流。读完后你可以直接复制文中的全部示例代码,并理解nsolve在 solvers.py 中的底层实现机制(lambdify + mpmathfindroot),从而知道每一步结果从哪里来。
什么时候应该用数值求解
SymPy 面向符号数学设计,其符号求解函数 {func}~.solve和 {func}~.solveset不会尝试寻找数值解,只会寻找数学上精确的符号解。当你需要数值解时,应使用 {func}~.nsolve。典型适用场景是:
- 只需要数值结果,不关心符号形式;
- 方程不存在闭式解,或闭式解过于庞杂。SymPy 官方指南在 solving-guidance.md 的 "When You Might Prefer a Numeric Solution" 一节中也指出:即使问题存在闭式解,如果表达式太长而不便使用,也可以改用数值方式(例如对 4 次方程
x**4 + 10*x**2 + x + 1的符号解,每项都含大量嵌套根式,而evalf后的数值解只有 8 个数字宽度)。
同时要注意边界:SymPy 并非为纯数值计算优化。如果不需要符号操作,纯数值任务用 NumPy 或 SciPy 这类包会更快、支持数组运算且算法更丰富。选择 SymPy(或其依赖 mpmath)做数值计算的核心理由是:
- 在符号计算的上下文里顺带做数值计算(例如符号推导完成后求根);
- 需要 float64 之外的任意精度,获取更多有效位数。
与 nsolve 相关的替代方案
在动手前可以先评估一下其他工具,nsolve本身底层就是调用它们:
- SciPy 的
scipy.optimize.fsolve:求解(非线性)方程组; - NumPy 的
numpy.linalg.solve:求解线性标量方程组; - mpmath 的
mpmath.findroot:nsolve实际调用的函数,且nsolve允许向其透传参数(如solver、verify等关键字参数)。
从源码看,nsolve对一维方程的处理就是在 solvers.py 中先lambdify(fargs, f, modules)把符号表达式转成数值函数,再执行x = sympify(findroot(f, x0, **kwargs));多元方程组路径则额外构造 Jacobian 矩阵后传给findroot。所以 mpmathfindroot支持的求解器与参数,nsolve基本都能透传使用。
单个方程的数值求解
最简单的用法:给定方程f(x) = 0、待求变量和一个初值起点。例如数值求解 $\cos(x) = x$:
>>> from sympy import cos, nsolve, Symbol >>> x = Symbol('x') >>> nsolve(cos(x) - x, x, 1) 0.739085133215161nsolve支持 2 参或 3 参两种调用形式,从 solvers.py 的参数解析逻辑可以看到:
- 3 参形式
nsolve(f, x, x0):显式给出函数、变量、初值,适用于方程组; - 2 参形式
nsolve(f, x0):只允许一维函数,此时自动取f.free_symbols中唯一的符号作为待求变量(见fargs = syms.copy().pop()分支)。
因此对一维函数,nsolve(sin(x), x, 2)与nsolve(sin(x), 2)等价,都返回3.14159265358979。另外注意两个约束:
nsolve不接受不等式,传入关系型对象会抛出TypeError('nsolve cannot accept inequalities');- 传入
method关键字参数会被显式拒绝——nsolve(以及findroot)中控制求解器的关键字是solver而不是method(见 solvers.py 的防护代码)。
数值求解方程组
求解多维函数方程组时,需要按元组提供三个部分:
- 函数组
(f1, f2) - 待求变量
(x1, x2) - 初值向量
(-1, 1)
>>> from sympy import Symbol, nsolve >>> x1 = Symbol('x1') >>> x2 = Symbol('x2') >>> f1 = 3 * x1**2 - 2 * x2**2 - 1 >>> f2 = x1**2 - 2 * x1 + x2**2 + 2 * x2 - 8 >>> print(nsolve((f1, f2), (x1, x2), (-1, 1))) Matrix([[-1.19287309935246], [1.27844411169911]])多元路径的实现在 solvers.py:先把方程列表组装成矩阵f,随后
- 用
J = f.jacobian(fargs)符号推导 Jacobian; - 分别
lambdify函数向量f与 JacobianJ; - 调用
x = findroot(f, x0, J=J, **kwargs),将解析 Jacobian 交给 mpmath 以避免差分近似。
两条边界条件值得注意:
- 超定方程组受支持(方程数多于变量数);但反之,若变量数多于方程数,会抛出
NotImplementedError('need at least as many equations as variables'); - 方程也可以直接写成
Eq对象列表,源码会把每个Eq自动改写为lhs - rhs再求解。
测试用例 test_numeric.py 中验证了多个不同初值点(-1, 1)、(1, -2)、(4, 4)、(-4, -4)都能收敛到满足mnorm(F(*x)) <= 1e-10的解,并包含了朱世杰 700 年前求解的三维非线性方程组示例,可作为收敛性的参考。
求实函数的复数根
要解实函数的复数根,必须指定一个非实的初值(纯虚数或复数)。以x**2 + 2 = 0为例:
>>> from sympy import nsolve >>> from sympy.abc import x >>> nsolve(x**2 + 2, 1) # Real initial point returns no root Traceback (most recent call last): ... ValueError: Could not find root within given tolerance. (4.18466446988997098217 > 2.16840434497100886801e-19) Try another starting point or tweak arguments. >>> from sympy import I >>> nsolve(x**2 + 2, I) # Imaginary initial point returns a complex root 1.4142135623731*I >>> nsolve(x**2 + 2, 1 + I) # Complex initial point returns a complex root 1.4142135623731*I实初值会让迭代停留在实数域内而无法收敛,最终因达不到默认容差而报ValueError;换成虚初值I或复初值1 + I即可收敛到sqrt(2)*I。测试 test_numeric.py 中的test_nsolve_complex断言了nsolve(x**2 + 2, I) == sqrt(2.)*I,并验证了复初值同样适用于方程组(如[x**2 + 2, y**2 + 2]配初值[I, I])。
确保求得的根落在指定区间内
需要特别小心:nsolve不保证找到离初值最近的根。看这个反例——x**2 - 1在-1和1处都有根,而-1离初值-0.1更近,但nsolve找到的却是1:
>>> from sympy import nsolve >>> from sympy.abc import x >>> nsolve(x**2 - 1, -0.1) 1.00000000000000这是牛顿类迭代法的固有行为,初值只决定迭代收敛到哪个吸引域,而非"取最近的根"。如果希望确保找到的根落在某个已知存在根的区间内,可以指定solver='bisect'(二分法),并把区间以元组形式传入:
>>> from sympy import nsolve >>> from sympy.abc import x >>> nsolve(x**2 - 1, (-10, 0), solver='bisect') -1.00000000000000二分法在有符号变化端点的区间上单调收敛,天然保证根位于区间内。solver只是透传给 mpmathfindroot的关键字参数,其他求解器名称同理。nsolve的 docstring(solvers.py)还给了一个典型组合:nsolve(f, bounds, solver='bisect', verify=False)在已知根的上下界时跳过结果验证,直接加速收敛。
提高求解精度:prec 参数
默认解只保留 15 位十进制有效数字。要得到更高精度,用prec参数(单位是十进制位数):
>>> from sympy import Symbol, nsolve >>> x1 = Symbol('x1') >>> x2 = Symbol('x2') >>> f1 = 3 * x1**2 - 2 * x2**2 - 1 >>> f2 = x1**2 - 2 * x1 + x2**2 + 2 * x2 - 8 >>> print(nsolve((f1, f2), (x1, x2), (-1, 1), prec=25)) Matrix([[-1.192873099352460791205211], [1.278444111699106966687122]])从源码看,prec的处理逻辑在 solvers.py:
if 'prec' in kwargs: import mpmath mpmath.mp.dps = kwargs.pop('prec')即直接修改 mpmath 全局工作精度mp.dps。这里源码用# XXX: This should use local_workprec instead of changing the global precision.的注释自我提醒了它改的是全局状态——也就是说,一次prec调用之后,同一个 Python 进程内后续所有 mpmath 运算都会沿用该精度,需要留意副作用。测试 test_numeric.py 的test_nsolve_precision用prec=128求解x**2 - pi,断言结果与sqrt(pi).evalf(128)之差小于1e-128,且返回值类型是Float——可以推断任意精度是nsolve相对 float64 数值库的核心差异化能力之一。
与 SciPy 结合:符号推导 + 数值求解的高性能工作流
SymPy 专注于符号计算,单次nsolve调用开销不小。如果需要反复调用数值求解器(例如做参数扫描、优化迭代),更快的做法是用 SciPy 等数值库。官方指南推荐的工作流是:
- 用 SymPy 符号化推导(化简或解方程)得到目标数学表达式;
- 用 {func}
~.lambdify把它转成 Python lambda 函数; - 交给 SciPy(如
scipy.optimize.root_scalar)做数值求解。
>>> from sympy import simplify, cos, sin, lambdify >>> from sympy.abc import x, y >>> from scipy.optimize import root_scalar >>> expr = cos(x * (x + x**2)/(x*sin(y)**2 + x*cos(y)**2 + x)) >>> simplify(expr) # 1. symbolically simplify expression cos(x*(x + 1)/2) >>> lam_f = lambdify(x, cos(x*(x + 1)/2)) # 2. lambdify >>> sol = root_scalar(lam_f, bracket=[0, 2]) # 3. numerically solve using SciPy >>> sol.root 1.3416277185114782这里的simplify把分母中 $x(\sin^2 y + \cos^2 y)$ 利用三角恒等式约掉,得到远为简洁的cos(x*(x + 1)/2)——这正是"符号工具先行、数值工具收尾"组合拳的价值:符号引擎负责减少计算量,数值引擎负责快速求根。
使用求解结果:evalf(subs=...) 而非 subs
拿到数值解后,把它代回原表达式验证时,官方指南的最佳实践是使用evalf的subs参数:
>>> from sympy import cos, nsolve, Symbol >>> x = Symbol('x') >>> f = cos(x) - x >>> x_value = nsolve(f, x, 1); x_value 0.739085133215161 >>> f.evalf(subs={x: x_value}) -5.12757857962640e-17这个极小的非零残差(约-5e-17)恰好证明数值解不是精确根,而是带截断误差的近似值。如果改用f.subs(x, x_value),由于subs的舍入行为,结果会被"四舍五入"成0,掩盖了真实残差:
>>> f.subs(x, x_value) 0因此验证残差时务必用evalf(subs=...),它能保留并如实显示数值误差。evalf还支持部分替换——把一些符号换成数值,另一些保留为变量:
>>> from sympy import cos, nsolve, Symbol >>> x = Symbol('x') >>> f = cos(x) - x >>> x_value = nsolve(f, x, 1); x_value 0.739085133215161 >>> y = Symbol('y') >>> z = Symbol('z') >>> g = x * y**2 >>> values = {x: x_value, y: 1} >>> (x + y - z).evalf(subs=values) 1.73908513321516 - z另外,若希望nsolve返回与solve(..., dict=True)结构一致的结果以便统一处理,可传dict=True,它返回解的映射列表(如[{x: sqrt(2.)}]),这一点由 test_numeric.py 的test_nsolve_dict_kwarg覆盖。
无解方程与失败模式
nsolve是数值方法,因此能解许多代数上无解的方程;但对确实无解的方程会报错。例如 $e^x = 0$ 无解:
>>> from sympy import nsolve, exp >>> from sympy.abc import x >>> nsolve(exp(x), x, 1, prec=20) Traceback (most recent call last): ... ValueError: Could not find root within given tolerance. (5.4877893607115270300540019e-18 > 1.6543612251060553497428174e-24) Try another starting point or tweak arguments.报错信息给出两条自救路径:换一个起点,或调整参数。结合 docstring 中的说明,还可以考虑:
- 对根附近函数变化极陡的情形,验证步骤可能误判失败,可用
verify=False跳过验证并独立核对(例如检查f/f.diff(x)在该点的量级); - 分式方程中,有时用分子(
eq.as_numer_denom()[0])比保留分母更易收敛——测试 test_numeric.py 中标记了test_nsolve_fail为 XFAIL(保留分母时该方程无法从初值 0 收敛),而test_nsolve_denominator则验证了nsolve默认使用完整表达式(分子+分母),不会错误地找到被约去的根。
总结与延伸阅读
| 需求 | 做法 | 关键参数 |
|---|---|---|
| 解单个方程 | nsolve(f, x, x0)或nsolve(f, x0) | 一维时可省略变量 |
| 解方程组 | nsolve((f1, f2), (x1, x2), (x0_1, x0_2)) | 需方程数 ≥ 变量数,支持超定 |
| 求复数根 | 指定非实初值 | I、1 + I |
| 根必须落在区间 | 区间元组 +solver='bisect' | (-10, 0) |
| 更高精度 | prec=n(十进制位数) | 全局修改 mpmathdps |
| 结构化返回 | dict=True | 与solve(dict=True)一致 |
| 高频数值求解 | simplify→lambdify→ SciPy | — |
nsolve的完整实现位于 sympy/solvers/solvers.py,行为回归测试集中在 sympy/solvers/tests/test_numeric.py,更宏观的"何时选数值解/符号解"的决策逻辑参见 solving-guidance.md。若发现nsolve的缺陷,官方建议提交到 SymPy 社区的 issue 跟踪渠道;在问题解决前,可以临时改用本文"替代方案"一节列出的 SciPy/NumPy/mpmath 求解器绕开。
【免费下载链接】sympyA computer algebra system written in pure Python项目地址: https://gitcode.com/GitHub_Trending/sy/sympy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考