奇迹数学 · 开源数学教程系列

《SymPy 数学编程》

计算机符号数学:从入门到项目实战 · 2026 年版

前言 + 16 章合订

覆盖:符号运算 · 方程求解 · 微积分推导 · 数理建模 · 金融数学计算

作者:邵晓文  |  奇迹数学 原创出品  |  2026 年编写于上海

SymPy 数学编程

前言

「奇迹数学」系列开源数学教程 · 首部作品 · 2026 年版

本书为「奇迹数学」系列开源数学教程的首部作品,面向希望通过 Python 实现符号运算、方程求解、微积分推导、数理建模与金融数学计算的学习者与开发者。

本书《SymPy编程(2026版)》所有示例均经过实测调试,拒绝空洞理论、杜绝错误公式、规避无效代码,力求让读者真正掌握 计算机符号数学 的底层思维与落地能力。

本书依托个人自建技术站点「奇迹数学」持续公开更新、免费共享。

作为作者,我始终认同开源社区的核心精神:知识公开、技术共享、降低学习门槛。优质的内容不应该高价封锁,而应该帮助更多人入门、成长、实践。希望这本书,可以成为你通往计算数学、数理编程、量化建模与科学计算世界的可靠阶梯。

作者:邵晓文
奇迹数学 原创出品
2026年 编写于上海

《SymPy 数学编程》 · 前言 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 1 章 符号计算与 SymPy 的哲学

数值计算与符号计算的根本差异 · SymPy 是什么、不是什么 · 安装与环境配置 · 第一个符号表达式 · 不可变对象模型

1.1 数值计算与符号计算的根本差异

先给结论:数值计算回答“它大约等于多少”,符号计算回答“它是什么、能变成什么、满足什么关系”。 二者不是“精度高低的两个档位”,而是两种不同的数学对象表示方式和两种不同的算法范式。

对数学工作者来说,理解这一点非常关键。否则很容易把 SymPy 当成“更精确的计算器”,进而对它产生两类误解:一是以为符号计算总能给出“最简”结果;二是以为符号计算只是把浮点数换成了无限精度小数。两者都不对。

1.1.1 同一个问题,两条路

考虑一个再简单不过的表达式:

\sqrt{8}.

在普通数值计算中,我们通常这样写:

PYTHON
import math
print(math.sqrt(8))
# 2.8284271247461903

得到的是一个浮点数。它是对 \sqrt{8} 的一个近似,精度大约为双精度浮点数的精度。这个结果对很多工程计算已经足够,但它不再保留“\sqrt{8} 的平方等于 8”这一精确关系。你可以把它平方回去,但通常会得到一个接近 8 的浮点数,而不是精确的 8。

在 SymPy 中,我们这样写:

PYTHON
from sympy import sqrt, pprint
pprint(sqrt(8))
# 2*sqrt(2)

SymPy 没有立刻给出小数,而是先应用了代数规则:

\sqrt{8} = \sqrt{4\cdot 2} = 2\sqrt{2}.

它保留的是一个精确的符号表达式。如果你确实想要小数,可以显式地要求数值求值:

PYTHON
print(sqrt(8).evalf(20))
# 2.8284271247461900976

注意这里的顺序:SymPy 先保留结构,再在需要时近似。数值计算则往往一开始就把对象变成浮点数,之后一直在近似值上运算。

这就是第一条根本差异:数值计算操作的是“数值”,符号计算操作的是“表达式”。

1.1.2 根本差异不在精度,而在对象

很多人第一次接触符号计算时会问:SymPy 是不是就是“精度更高的数值计算”?

不是。符号计算的核心不是把小数点后面的位数调多,而是不把数学对象过早压缩成数值。

在数值计算中,一个实数通常被表示成浮点数。浮点数是一个有限精度的二进制近似。它能表示的数有限,运算也会产生舍入误差。例如:

PYTHON
print(0.1 + 0.2)
# 0.30000000000000004

这不是 Python 的 bug,而是二进制浮点表示 0.1、0.2 时的必然结果。数值计算的工作之一,就是分析、控制、估计这种误差。

符号计算则可以保留有理数:

PYTHON
from sympy import Rational, pprint
pprint(Rational(1, 10) + Rational(2, 10))
# 3/10

这里 1/10 和 2/10 是精确有理数,加法按照有理数规则进行,结果是精确的 3/10。只有当你说“请给我小数”时,才会发生数值化。

但要注意:符号计算并不等于“无限精度计算”。SymPy 可以表示精确有理数、根式、特殊函数、未求值积分等,但它并不保证每个表达式都能化简成你心中最漂亮的形式。符号计算的优势是保持结构和关系,而不是无条件地“更精确”。

更准确地说,数值计算把数学对象映射到有限精度的数据上,然后在这个数据表示上执行近似算法;符号计算把数学对象表示为表达式树,然后在表达式树上执行重写规则。前者关心误差和收敛性,后者关心等价性和变换规则。

1.1.3 可逆性与结构保持

数值计算常常是“有损”的。你把 \sqrt{8} 变成 2.8284271247461903 之后,原始根式结构就丢失了。你可以近似地平方回去,但很难从浮点数中可靠地恢复“它本来是 2\sqrt{2}”。

符号计算则尽量保持可逆的代数关系。例如:

PYTHON
from sympy import sqrt, pprint
expr = sqrt(8)
pprint(expr**2)
# 8

SymPy 知道 \sqrt{8}^2=8,因为它在表达式层面应用了幂和根式的规则。它不需要先算出一个浮点近似再平方。

再看求导。数值计算通常用有限差分近似导数:

f'(x) \approx \frac{f(x+h)-f(x)}{h}.

步长 h 选得太大,截断误差大;选得太小,舍入误差大。符号计算则直接应用求导规则:

PYTHON
from sympy import symbols, sin, exp, diff, pprint
x = symbols('x')
f = exp(x) * sin(x)
pprint(diff(f, x))
# exp(x)*sin(x) + exp(x)*cos(x)

得到的是精确的导数表达式。它可以继续化简、代入、积分、输出 LaTeX,或者通过 lambdify 转成数值函数。这种“结构保持”是符号计算在数学推导、公式验证、论文写作中的核心价值。

1.1.4 算法与失败模式不同

数值计算和符号计算的算法气质很不一样。

数值计算通常围绕浮点运算、迭代、逼近、误差估计展开。它的典型问题是:收敛不收敛、误差多大、条件数如何、算法复杂度怎样。比如数值积分、数值线性代数、数值优化,都是在这个框架下工作。

符号计算通常围绕表达式树、重写规则、模式匹配、代数化简展开。它的典型问题是:能不能化简、按什么规则化简、缺少假设时能不能判定、表达式会不会膨胀。比如 integrate 可能返回一个含特殊函数的结果,simplify 可能给出一个“更短但不一定更符合你预期”的形式,solve 可能因为缺少假设而给出分段解或无法求解。

例如:

PYTHON
from sympy import symbols, exp, integrate, oo, pprint
x = symbols('x')
pprint(integrate(exp(-x**2), (x, -oo, oo)))
# sqrt(pi)

SymPy 能给出精确的定积分结果 \sqrt{\pi}。但如果求不定积分:

PYTHON
pprint(integrate(exp(-x**2), x))
# sqrt(pi)*erf(x)/2

它返回的是误差函数 \operatorname{erf}(x) 的表达式。这不是说 SymPy “算不出来”,而是说这个不定积分不能用初等函数表示;SymPy 用特殊函数保留了一个精确的符号答案。数值计算则会直接给你一个数值积分结果,例如某个区间上的近似值,而不会告诉你“原函数是什么”。

这正是两种计算范式的差异:数值计算擅长给出近似值,符号计算擅长给出关系和结构;数值计算的失败模式通常是误差和收敛问题,符号计算的失败模式通常是化简困难、假设缺失和表达式膨胀。

1.1.5 一张对比表

维度数值计算符号计算
基本对象浮点数、数组、近似值符号、表达式树、精确结构
典型问题“等于多少?”“它是什么?能变成什么?”
结果形式小数、数组、近似解公式、根式、有理数、特殊函数
误差来源舍入误差、截断误差、条件数化简策略、假设缺失、表达式膨胀
典型算法迭代、逼近、数值线性代数重写规则、模式匹配、代数变换
可逆性通常有损尽量保持代数关系
擅长大规模、快速、近似足够推导、验证、公式输出、可复现
失败模式不收敛、误差过大、数值不稳定化简不动、无法判定、结果膨胀

这张表不是要把二者对立起来。实际工作中,它们经常是接力关系:先用符号计算推导公式,再用 lambdify 转成数值函数,交给 NumPy/SciPy 做大规模计算和验证。第 8 章会把这条“符号—数值桥梁”作为全书的关键榫卯来讲。

1.1.6 对数学工作者的实践含义

对数学工作者来说,最重要的判断不是“哪个更先进”,而是“当前问题需要什么”。

如果你需要精确的代数关系、需要看推导结构、需要把结果贴进论文或讲义、需要验证一个公式在符号层面是否成立,那么符号计算更合适。SymPy 的表达式可以输出 LaTeX,可以保留中间步骤,可以用脚本复现,这对教学和写作尤其有价值。

如果你需要处理大量数据、需要快速数值求解、需要做数值优化或数值积分,那么数值计算更合适。此时不应强求 SymPy 去完成大规模数值任务;更合理的做法是让 SymPy 负责推导,再把结果转成数值函数。

如果你既需要结构又需要速度,那么混合工作流通常是最好的:符号推导得到公式,数值计算验证公式,二者互相校验。这也是本书后面反复出现的模式。

最后要强调一点:符号计算不是“更聪明的数值计算”,数值计算也不是“符号计算的低配版”。 它们是两种不同的数学表示和计算哲学。理解这一点,才能真正理解 SymPy 能做什么、不能做什么,以及为什么本书要把“可复现的数学工作流”作为核心目标。

本节涉及的核心函数 sqrt、evalf、Rational、diff、integrate 会在后续章节逐一展开。下一节我们将进一步讨论:SymPy 到底是什么,它不是什么。

本节标题是一个定义,也是一道边界。SymPy 是一个用纯 Python 写成的开源计算机代数系统,但它不是一个小号的 Mathematica,也不是一个数值计算引擎,更不是一个保证“化简到最简形式”的万能公式处理器。 这个定义的前半句决定了它擅长什么,后半句的否定决定了你不应该用它做什么。

1.2 SymPy 是什么,不是什么

1.2.1 它是什么:一个纯 Python 的计算机代数系统

SymPy 的全称是 “Symbolic Python”,但更准确的描述来自它的官方定位:一个用纯 Python 编写的开源计算机代数系统(CAS)。这个定义里的每一个限定词都有实质含义。

“计算机代数系统”意味着它操作的对象是数学表达式,而不是数值。你在 1.1 节看到的 sqrt(8) 返回 2*sqrt(2),diff(exp(x)*sin(x), x) 返回 exp(x)*sin(x) + exp(x)*cos(x),都是这一点的直接体现。它的核心数据结构是表达式树,核心算法是重写规则和模式匹配,而不是浮点迭代。

“纯 Python”意味着它不依赖任何外部 CAS 内核,也不要求你安装一个独立的软件环境。pip install sympy 之后,import sympy 就能用。这意味着你在任何有 Python 的地方都能运行它:Jupyter、VS Code、服务器、甚至树莓派。对数学工作者来说,这一点的重要性不在于“轻量”,而在于可复现:你的学生、读者、审稿人,只要有一个 Python 环境,就能跑你脚本里的每一行符号计算。

“开源”意味着它的源码可以被阅读、被修改、被嵌入。你可以看到 integrate 是怎么尝试各种积分方法的,可以看到假设系统是怎么影响化简结果的。这对数学工作者有一种独特的认知价值:你不仅在使用一个工具,你也在观察一个用代码实现的数学推理系统。

但仅凭“CAS”这个标签,仍然容易把 SymPy 想象成“免费版 Mathematica”。两者有一个结构性的差异:SymPy 是 Python 科学计算生态中的一个库,而不是一个自给自足的软件帝国。 Mathematica 的设计倾向是把所有功能收进一个统一系统里,有自己的语言、自己的笔记本界面、自己的数值引擎。SymPy 的设计哲学则更接近于“只做符号计算这一件事,然后把结果交给生态里的其他工具”。它的数值评估性能本身并不好,但 lambdify 可以把符号表达式翻译成 NumPy、mpmath、JAX 等后端能调用的函数,让数值计算在真正擅长的系统里完成。这种“符号推导在前,数值后端在后”的分工,是 SymPy 在 Python 生态中的实际位置,也是本书第 8 章要把 lambdify 称为“全书榫卯”的原因。

1.2.2 它不是什么之一:不是一个保证“最简形式”的化简器

这是 SymPy 最容易让新手感到挫败的地方,也是必须在一开始就说清楚的边界。

SymPy 有一个 simplify() 函数,看起来应该“把表达式变简单”。但官方文档对它的描述非常坦率:simplify() 是启发式的,没有任何保证。它会把 SymPy 中大量的化简操作都试一遍,然后用一个内部的“复杂度度量”来挑选它认为最好的结果。问题在于,“最简”本身不是一个有明确定义的数学概念。

一个具体的例子:

PYTHON
from sympy import symbols, simplify, factor, pprint
x = symbols('x')
pprint(simplify(x**2 + 2*x + 1))
#  2
# x  + 2*x + 1

simplify() 没有把它变成 (x + 1)**2,因为在这个例子里,它内部的启发式判断没有选中因式分解这条路。如果你明确要因式分解,应该用 factor():

PYTHON
pprint(factor(x**2 + 2*x + 1))
#        2
# (x + 1)

factor() 在有理系数多项式上有明确的形式保证:它会分解到不可约因式。而 simplify() 没有这样的保证,它甚至可能返回一个比输入更复杂的表达式,也可能因为尝试了太多路径而变得很慢。

这意味着:不要在你的工作流里把“正确性”押在 simplify() 上。 如果你需要一种特定形式的化简,应该使用定向函数——factor、expand、cancel、trigsimp、powsimp 等——每一个都有相对明确的行为边界。simplify() 适合在交互式探索中帮你“看看能不能变短一点”,不适合作为自动化流程中的可靠环节。第 3 章会系统展开这个区分。

1.2.3 它不是什么之二:不是一个数值计算引擎

SymPy 能算出精确的 \sqrt{2},也能用 evalf(100) 给出 100 位小数,但这不意味着它适合做数值计算。

官方文档在“数值计算”一页的开头就指出:符号系统在数值数据上求值时,性能往往很差。一个直观的对比来自 SymPy 自己的文档:用 .subs({x: 3.14}) 在 SymPy 内部求值,耗时在几十微秒的量级;用 lambdify 把同一个表达式转成普通 Python 函数后再调用,耗时在几百纳秒的量级,相差大约两个数量级。如果数据是一个长度为 10000 的 NumPy 数组,用 lambdify(..., "numpy") 生成的向量化函数,每个元素的处理时间可以降到纳秒量级。

这不是 SymPy “实现得不好”,而是符号表达式树本身就不是为逐点数值求值设计的。符号计算的优势在于保持结构、推导公式;数值计算的优势在于快速处理大量数据。把两者混在一起用,通常两头不讨好。

正确的做法是接力:符号推导得到公式,用 lambdify 转成数值函数,交给 NumPy/SciPy 做大规模计算和验证。如果你在 SymPy 里对一个复杂表达式做循环 .subs() 求值,那几乎总是用错了工具。

1.2.4 它不是什么之三:不是一个通用的 PDE 求解器

SymPy 的 dsolve() 在处理常微分方程(ODE)方面有相当可用的覆盖范围,但 pdsolve() 的边界要窄得多。

一个明确的限制来自 SymPy 的 issue 跟踪:pdsolve() 目前只能求解一阶线性偏微分方程。二阶、高次、多变量的 PDE,以及 PDE 方程组,都不在已实现的能力范围内。即使是某些看起来简单的一阶非线性 PDE,也可能直接抛出 NotImplementedError。

但这需要一句重要的补充:很多 PDE 本身就没有显式的符号解可求。对于 Poisson 方程等典型的数学物理方程,不存在初等函数或已知特殊函数构成的通解,这是方程的性质决定的,不是 SymPy 的能力缺陷。pdsolve() 的边界,一部分来自实现范围,一部分来自数学本身。

对使用者的实际含义是:在把 PDE 丢给 SymPy 之前,先判断它属于哪一类。如果是可分离变量的一阶线性方程,pdsolve() 可能给你一个有价值的符号结果;如果是典型的二阶椭圆/抛物/双曲方程,预期应该是“没有显式符号解”,此时正确的路径是数值方法,而不是在 pdsolve() 上反复尝试。

1.2.5 它不是什么之四:不是“不需要假设也能工作”的系统

第 10 章会专门展开假设系统,但这里需要提前指出一个与“SymPy 是什么”直接相关的边界:SymPy 的大部分核心函数默认符号是复数的、非零与否未知的、正负未知的。 在这种默认假设下,很多化简和积分无法给出你期望的确定结果,因为那些结果在一般情况下并不成立。

一个典型的场景:\int e^{kx}\,dx 在 k\neq 0 时是 \frac{1}{k}e^{kx},但当 k=0 时积分是 x。如果 SymPy 不知道 k 是否为零,它就没有合法的理由直接返回 \frac{1}{k}e^{kx}。SymPy 的“新”假设模块(assuming、Q.positive 等)尚未与核心计算路径完全集成,在符号创建时使用 Symbol('k', nonzero=True) 或 Symbol('x', positive=True) 仍然是让假设实际影响 integrate、simplify 等函数的主要方式。

这不是一个可以靠“更聪明地调用 simplify”绕开的问题。它是符号计算的一个结构性事实:没有假设,就没有确定的结果;符号计算的大部分“失败”,根源是数学上缺少条件,而不是代码有 bug。 理解这一点,比记住任何单个函数的用法都更重要。

1.2.6 那它到底是什么:一个“恰好够用”的符号推导环境

把上面几条放在一起,SymPy 的实际定位可以概括为一句话:它是一个以 Python 为载体、以表达式树为核心、以“可读、可改、可嵌入”为设计优先级的符号推导环境,而不是一个追求在所有维度上对标商业 CAS 的全能系统。

它的优势不在功能覆盖的广度,而在可组合性。你可以用 Python 的循环生成一批方程,逐个交给 solve;可以用 lambdify 把推导结果转成数值函数,再接 SciPy 优化器;可以用 latex() 把表达式直接输出成论文可用的 LaTeX。这些操作在 SymPy 里是自然的,因为符号计算从始至终都是 Python 代码的一部分,而不是一个隔离在黑箱里的“计算按钮”。

它的短板也同样清晰:数值性能差、simplify 不可靠、PDE 覆盖窄、假设系统不完整。知道这些短板,和知道它能做什么一样重要。 当你发现一个问题“SymPy 做得不好”时,大多数情况下不是 SymPy 坏了,而是这个问题应该换一种方式使用它:加假设、换定向函数、改用数值后端,或者承认这里根本没有符号解。

1.3 安装与环境配置(Anaconda、pip、Jupyter、VSCode)

本节的目标不是列出一份“所有可能的安装方式”,而是给出一条在本书语境下最不容易出错的路径,并解释为什么这样选。前置条件只有一条:你有一个能用的 Python 环境,版本不低于 3.9(与前言约定一致)。如果你还没有 Python,先装 Anaconda 或 Miniconda,再回来读这一节。

1.3.1 第一条分岔:pip 还是 conda

SymPy 的官方文档给出了两条主要安装路径:

pip 路径:

PYTHON
pip install sympy

conda 路径:

PYTHON
conda install sympy

两者都能装上 SymPy,差别在于依赖管理的范围。pip 只负责 Python 包本身,SymPy 的硬依赖(主要是 mpmath)会被一并装上,但如果你后续需要 NumPy、SciPy、Matplotlib 做数值验证和绘图,pip 会逐个安装,版本兼容性需要你自己留意。conda 把 SymPy 放在一个更大的包依赖图谱里管理,当你需要同时维护 NumPy、SciPy、Jupyter 时,conda 的求解器会一次性解决版本约束。

对于本书的使用场景,我的建议是:如果你已经用 Anaconda 或 Miniconda 管理 Python 环境,就用 conda;如果你习惯用系统 Python 或 venv,就用 pip。 不需要为了 SymPy 单独切换工具链。一个折中方案是使用 conda-forge 频道,它的更新通常比 Anaconda 默认频道更快:

PYTHON
conda install --channel conda-forge sympy

1.3.2 验证安装

装完之后不要急着写第一个表达式,先跑一遍官方推荐的最小验证:

PYTHON
python
>>> from sympy import *
>>> x = Symbol('x')
>>> limit(sin(x)/x, x, 0)
1
>>> integrate(1/x, x)
log(x)

如果 limit 和 integrate 都返回了上面显示的结果,说明 SymPy 的核心计算路径工作正常。如果 from sympy import * 就报 ModuleNotFoundError,说明安装本身没成功,或者你当前运行的 Python 解释器不是装 SymPy 的那个——后者在同时有多个 Python 环境的机器上非常常见,下一小节专门处理。

1.3.3 Jupyter:交互式探索的主场

本书的定位是“离线必须能懂”,正文用 pprint 呈现结果,但这不意味着你需要在脚本里反复运行再复制输出。Jupyter Notebook(或 JupyterLab)是学习 SymPy 最自然的交互环境,原因不在于它“好看”,而在于符号计算本身是探索性的:你写一个表达式,看它化简成什么,再决定下一步用 expand 还是 factor,用 solve 还是 solveset。这种“写一行、看结果、改一行”的节奏,在 Notebook 里比在脚本里顺畅得多。

安装 Jupyter 本身不复杂,如果你走的是 Anaconda 路径,它已经预装了。如果你走 pip 路径:

PYTHON
pip install jupyter

启动后在浏览器里新建一个 Notebook,第一格执行 from sympy import * 和 init_printing(),之后每个表达式都会以排版后的数学形式显示。需要注意的是,init_printing() 在静态文档(比如你导出的 PDF 或 HTML)中的显示效果,与在运行中的 Notebook 里可能不同。本书正文统一用 pprint 来保证离线阅读时结果格式稳定,但你在自己的 Notebook 里用 init_printing() 会更舒服。两者不冲突,选你顺手的即可。

1.3.4 VSCode:写脚本和调试的场所

Jupyter 适合探索,VSCode 适合把探索结果整理成可复现的脚本。本书第 12 章会讲“构建可复现的符号计算工作流”,那个场景下的主力工具是 VSCode 而不是 Notebook。原因在于:脚本可以放进版本控制,可以被 pytest 测试,可以被其他脚本 import,而 Notebook 的 .ipynb 格式在 diff 和 code review 时并不友好。

在 VSCode 里用 SymPy 需要两步:

第一,安装 Python 扩展。在扩展面板搜索 “Python”(Microsoft 发布),安装后重启 VSCode。

第二,确保 VSCode 选中的解释器就是装了 SymPy 的那个。这是新手最常见的“我明明装了却 import 不了”的根源:你的终端里 pip install sympy 装进了系统 Python,但 VSCode 默认选中的可能是另一个 conda 环境或虚拟环境。在 VSCode 里按 Ctrl+Shift+P(macOS 为 Cmd+Shift+P),输入 “Python: Select Interpreter”,选中正确的环境。如果仍然不确定,在 VSCode 的集成终端里运行 which python(Linux/macOS)或 where python(Windows),再运行 python -c "import sympy; print(sympy.__version__)",能打印出版本号就说明当前环境是对的。

VSCode 也支持直接打开 .ipynb 文件并以 Notebook 界面运行,前提是安装了 Jupyter 扩展。如果你希望在一个工具里同时做探索和写脚本,这是一个合理的选择。

1.3.5 建议的配置清单

把这一节压缩成一个可以直接执行的清单:

场景工具命令/操作
已有 conda 环境condaconda install --channel conda-forge sympy
无 conda,用系统 Pythonpippip install sympy
需要交互探索Jupyterpip install jupyter 或 conda 预装
写可复现脚本VSCode安装 Python 扩展,选中正确解释器
验证安装终端python -c "from sympy import *; x=Symbol('x'); print(integrate(1/x, x))"

最后一条需要说明:如果你要用 NumPy、SciPy 做第 8 章 lambdify 之后的数值验证,把它们一并装上。SymPy 本身不依赖它们,但本书的“符号推导 → 数值验证”工作流依赖。pip install numpy scipy matplotlib 或 conda install numpy scipy matplotlib,取决于你走哪条路。

1.4 第一个符号表达式:从 sqrt(8) 到 2*sqrt(2)

这一节是全书第一次真正动手。目标很小:让 sqrt(8) 返回 2*sqrt(2),并借这个最小例子把 SymPy 的几个基本习惯一次建立起来。不要小看这个例子——它同时涉及导入方式、符号与数值的区别、自动化简的边界,以及“我看到的输出到底是不是表达式本身”。

1.4.1 最小可运行代码

PYTHON
from sympy import sqrt, pprint
pprint(sqrt(8))
# 2*sqrt(2)

三行代码,三个要点。

第一行 from sympy import sqrt 是显式导入。网上很多教程写 from sympy import *,在交互式探索时确实省事,但本书正文一律用显式导入。原因不是风格洁癖,而是 sympy 里的名字和 Python 内置、NumPy 有大量重叠:sqrt、exp、log、sin、pi、E、I、oo 都是如此。from sympy import * 会把这些名字全部覆盖进当前命名空间,一旦你同时 import math 或 import numpy,后面哪个 sqrt 生效就取决于导入顺序。显式导入让每个名字的来源一目了然,这在可复现的工作流里是硬要求。

第二行 sqrt(8) 调用的是 SymPy 的平方根函数,不是 math.sqrt。参数 8 是 Python 整数,SymPy 会把它转成自己的整数类型再运算。

第三行 pprint 是 “pretty print” 的缩写,把表达式排版成接近数学书写的二维形式。在终端里它输出 2*sqrt(2);在支持 Unicode 的终端里,它会用上标和根号符号排得更像数学。本书正文统一用 pprint,因为它在纯文本环境下也能稳定显示,不依赖 init_printing() 的富输出。

如果你只想拿到字符串,用 print(sqrt(8)) 会得到 2*sqrt(2) 的单行形式;用 srepr(sqrt(8)) 则能看到它的内部结构:

PYTHON
from sympy import sqrt, srepr
print(srepr(sqrt(8)))
# Mul(Integer(2), Pow(Integer(2), Rational(1, 2)))

这一行值得停下来看:sqrt(8) 在 SymPy 内部不是一个“根号对象”,而是一棵表达式树——最外层是乘法 Mul,乘数是整数 2 和 2 的二分之一次幂 Pow(Integer(2), Rational(1, 2))。也就是说,SymPy 在构造 sqrt(8) 的那一刻就已经完成了 \sqrt{8}=2\sqrt{2} 的化简,你看到的输出不是显示层的修饰,而是表达式本身的真实结构。第 2 章讲表达式树时会回到这个例子。

1.4.2 它没有变成小数,这是特性不是缺陷

第一次运行的人常有的反应是:“为什么不是 2.828…?”

因为在 SymPy 里,sqrt(8) 返回的是一个精确的符号表达式,不是浮点近似。这是 1.1 节讲的“对象差异”的第一个具体体现。如果你要小数,必须显式要求:

PYTHON
from sympy import sqrt
print(sqrt(8).evalf())
# 2.82842712474619

evalf() 是“evaluate as float”的缩写,默认精度 15 位。想要更多位:

PYTHON
print(sqrt(8).evalf(50))
# 2.8284271247461900976033774484193961571393437507539

注意这里的行为顺序:SymPy 先保留精确结构,再在 evalf 被调用时做数值近似。这和 math.sqrt(8) 一开始就返回浮点数完全不同。evalf 的精度控制和 pi_digits 这类话题在第 2 章展开,本节只需要建立“精确在前、近似在后”的习惯。

1.4.3 自动化简的边界:它会做什么,不会做什么

sqrt(8) 自动变成了 2*sqrt(2),这会让人以为 SymPy 会自动把一切根式化简到最简。并非如此。

它会做的:提取完全平方因子。\sqrt{8}=\sqrt{4\cdot 2}=2\sqrt{2},\sqrt{12}=2\sqrt{3},\sqrt{50}=5\sqrt{2}。这类化简在构造表达式时自动发生,因为 SymPy 的 Pow 在有理指数下会做规范化。

它不会做的:把 \sqrt{2}+\sqrt{8} 自动合并成 3\sqrt{2}。你可以试一下:

PYTHON
from sympy import sqrt, pprint
pprint(sqrt(2) + sqrt(8))
# 2*sqrt(2) + sqrt(2)

等等——这里其实已经合并了一部分。更准确的观察是:sqrt(8) 先被化为 2*sqrt(2),然后 SymPy 的自动化简会把同类项合并,所以结果确实是 3*sqrt(2)。真正不会自动发生的是需要主动调用函数才能完成的变换,比如:

PYTHON
from sympy import sqrt, simplify, pprint
expr = sqrt(2) * sqrt(3)
pprint(expr)
# sqrt(2)*sqrt(3)

sqrt(2)*sqrt(3) 不会自动变成 sqrt(6),因为 SymPy 默认假设符号可能是复数,而 \sqrt{a}\sqrt{b}=\sqrt{ab} 在复数域上不总是成立。要合并,需要显式调用 sqrtdenest 或 powsimp,或者在适当的假设下操作。这个例子预告了第 3 章和第 10 章的两个主题:化简需要定向函数,变换需要假设条件。

本节要带走的判断标准是:SymPy 的自动化简是保守的、局部的、基于规则的,不是“尽可能化简”的。 看到结果没有变成你期望的形式,第一反应不应该是“SymPy 坏了”,而应该是“我需要调用哪个定向函数,或者缺了什么假设”。

1.4.4 sqrt 的几种输入与返回

sqrt 对不同类型参数的行为值得一次看清楚:

PYTHON
from sympy import sqrt, Rational, Symbol, pprint
pprint(sqrt(4))          # 2
pprint(sqrt(2))          # sqrt(2)
pprint(sqrt(Rational(1, 4)))  # 1/2
pprint(sqrt(-1))         # I
pprint(sqrt(Symbol('x')))     # sqrt(x)

几个要点:

sqrt(4) 返回整数 2,不是 sqrt(4)。完全平方数会被直接开出来。

sqrt(Rational(1, 4)) 返回有理数 1/2,精确。

sqrt(-1) 返回虚数单位 I。SymPy 默认在复数域上工作,不会因为负数开方就报错——这和 math.sqrt(-1) 抛 ValueError 是根本不同的行为。如果你需要“负数开方报错”的实数语义,要么先加假设,要么自己做检查。

sqrt(Symbol('x')) 返回 sqrt(x),保持未求值。如果 x 被声明为正数,情况会不同:

PYTHON
from sympy import sqrt, Symbol, pprint
x = Symbol('x', positive=True)
pprint(sqrt(x**2))
# x

没有 positive=True 时,sqrt(x2) 返回的是 sqrt(x2) 或 Abs(x),因为 \sqrt{x^2}=|x| 在实数域上成立,而 SymPy 不知道 x 的符号。这是第 10 章假设系统的第一个预告:同样的表达式,加了假设之后化简结果不同,而且这个不同是数学上正确的。

1.4.5 一个常见混淆:sqrt 不是 math.sqrt

如果你在同一个脚本里同时导入两者:

PYTHON
import math
from sympy import sqrt
print(math.sqrt(8))   # 2.8284271247461903
print(sqrt(8))        # 2*sqrt(2)

两个 sqrt 名字相同,行为完全不同。math.sqrt 只接受能转成浮点数的参数,返回浮点数;SymPy 的 sqrt 接受符号表达式,返回符号表达式。混用会导致两类典型错误:把 SymPy 表达式传给 math.sqrt 会抛 TypeError;把 math.sqrt 的结果传给 SymPy 函数,会得到一个已经丢失精确结构的浮点数,后续化简无从谈起。

本书的约定是:在符号计算脚本里,不要 import math。 需要常数用 sympy.pi、sympy.E,需要开方用 sympy.sqrt,需要对数用 sympy.log。只有在明确要离开符号域、进入数值计算时,才引入 math 或 NumPy——而且那时候通常应该走 lambdify,而不是手动混用。

1.4.6 本节小结

sqrt(8) 返回 2*sqrt(2),这个最小例子建立了四个习惯:

第一,显式导入,不用 from sympy import *。

第二,SymPy 返回精确表达式,要小数就显式调用 evalf()。

第三,自动化简是保守的、基于规则的;需要特定形式时,用定向函数,不要指望“自动变最简”。

第四,sqrt 是 SymPy 的 sqrt,不是 math.sqrt;符号域和数值域不要混用。

1.5 SymPy 的不可变对象模型

上一节末尾留了一个问题:为什么 x + 1 不会“改变” x?这一节回答它。答案不是 Python 的某种语法约定,而是 SymPy 的一个核心设计决定:所有 SymPy 表达式在创建之后就是不可变的。任何看似“修改”的操作,实际上都在创建一个新的表达式。 理解这一点,是理解 subs()、表达式树、结构相等,以及后面所有符号操作的前提。

1.5.1 第一次撞上不可变性

先看一个最直接的例子:

PYTHON
from sympy import symbols, pprint
x = symbols('x')
expr = x + 1
pprint(expr)      # x + 1
expr.subs(x, 2)
pprint(expr)      # x + 1

subs 返回了 3,但 expr 本身没有变成 3。它还是 x + 1。如果你想要替换后的结果,必须把它赋给一个变量:

PYTHON
new_expr = expr.subs(x, 2)
pprint(new_expr)  # 3

官方文档把这个行为写得很直白:SymPy 表达式是不可变的,不能被原地操作修改;一个函数总是返回一个对象,而原表达式不会被改动 。subs 没有修改 expr,它返回了一个修改后的副本 。

对习惯了 Python 列表操作的人来说,这有点反直觉。list.append() 会改变原列表,dict.update() 会改变原字典,但 expr.subs() 不会改变 expr。原因不在 subs 这个函数的实现,而在 SymPy 表达式的类型本身。

1.5.2 不可变性的来源:Basic 基类

SymPy 中绝大多数数学对象都继承自一个叫 Basic 的基类。Symbol、Integer、Add、Mul、Pow、sin、Integral、Derivative,往上追溯,根都在 Basic。Basic 的核心约束就是不可变 。

这带来一个直接的推论:一个 SymPy 表达式在被构造出来的那一刻,它的内部结构就固定了。 你不能把 Add(x, 1) 里的 1 换成 2,只能构造一个新的 Add(x, 2)。expr.args 返回的元组也是不可变的,里面的子表达式同样不可变。

有一个值得注意的例外:标准的 Matrix 类是可变的。官方文档专门解释了原因:性能考虑让 Matrix 保持可变,但这导致它无法与 SymPy 其余部分“良好互动”,因为 Basic 是不可变的。ImmutableMatrix 的存在就是为了弥合这个裂口,让矩阵能够以不可变的方式参与符号表达式 。第 6 章讲线性代数时会回到这个区分。本节只需要记住:符号表达式不可变,标准矩阵是特例。

1.5.3 不可变性的实际好处

不可变性不是一个抽象的设计洁癖,它直接解决了几类实际问题。

第一,安全共享。 如果你把一个表达式传给另一个函数,你不需要担心那个函数会“不小心改掉它”。官方文档的原话是:你可以把任何实例传给其他对象,因为你不必担心它会改变,也不必担心这会破坏什么东西 。在本书第 8 章 lambdify 和第 12 章可复现工作流的语境下,这一点尤其重要:一个符号表达式可以在多个地方被引用、被变换、被缓存,而原始对象始终不变。

第二,结构相等变得可靠。 SymPy 的 == 判断的是结构相等,不是数学相等。两个表达式结构相等,当且仅当它们是同一个类,并且递归地拥有相同的 args。官方文档明确指出,正因为所有 SymPy 表达式都是不可变的,结构相等的表达式才被 SymPy 视为“同一个东西” 。如果表达式可以被原地修改,一个原本结构相等的对象可能在别处被悄悄改掉,哈希值失效,缓存错乱。不可变性让 expr.func(*expr.args) == expr 这个重建恒等式始终成立 。

第三,自动化简可以安全地发生在构造时。 1.4 节看到 sqrt(8) 在构造时就变成了 2*sqrt(2)。这种“构造即规范化”的策略之所以可行,正是因为构造之后没有人能再把 2*sqrt(2) 改回 sqrt(8)。Add 会自动排序它的参数,所以 x + y 和 y + x 结构相等 。如果 Add 是可变的,这种排序就毫无意义,因为外部代码随时可以打乱它。

1.5.4 一个常见误解:不可变不等于“不能重新绑定变量名”

初学 Python 的人容易把“对象不可变”和“变量不能重新赋值”混淆。这两件事完全不同。

PYTHON
from sympy import symbols
x = symbols('x')
expr = x + 1
expr = expr.subs(x, 2)   # 变量 expr 被重新绑定到新对象 3

这里发生了两件事:expr.subs(x, 2) 创建了一个新对象 3;然后 Python 的赋值语句把变量名 expr 重新绑定到了这个新对象上。原来的 x + 1 对象仍然存在,只是没有任何变量名指向它了,随后会被垃圾回收。

所以,不可变的是对象,不是变量名。 你当然可以写 expr = expr.subs(...),但这本质上是“让 expr 指向一个新东西”,而不是“把旧东西改掉”。这个区分在调试时很重要:如果你写 expr.subs(x, 2) 却没有赋值回去,expr 仍然指向旧对象,结果自然“没有变化”。

1.5.5 与 Python 可变对象的对比

把 SymPy 表达式和 Python 列表放在一起看,差异最清楚:

PYTHON
# Python 列表:可变
lst = [1, 2, 3]
lst.append(4)
print(lst)        # [1, 2, 3, 4],原列表被改变
# SymPy 表达式:不可变
from sympy import symbols
x = symbols('x')
expr = x + 1
new = expr.subs(x, 2)
print(expr)       # x + 1,原表达式不变
print(new)        # 3,新表达式

列表的 append 直接修改了 lst 本身;SymPy 的 subs 则返回一个新对象。这不是 API 设计的不一致,而是两种对象在“数学上应该是什么”这个层面的不同选择。数学表达式一旦写出来,就不应该被“原地篡改”——x + 1 就是 x + 1,你只能在它基础上构造新表达式。

1.5.6 对后续章节的铺垫

不可变性会在本书后面反复出现,至少三个地方直接依赖它:

代换 subs()(第 2 章):理解 subs 返回新对象而不是修改原对象,是正确使用它的前提。如果你期望 expr.subs(x, y) 改变 expr,你的代码会在不报错的情况下产生错误结果。

表达式树(第 2 章):树的节点不可变,意味着你可以安全地遍历、比较、缓存子表达式。expr.args 返回的元组不会被别处改掉。

缓存与性能(第 11 章):SymPy 内部利用不可变性做表达式缓存。同一个表达式被反复构造时,可能返回同一个对象,因为不可变性保证了“同一个结构永远代表同一个数学对象”。

本节要带走的一句话是:在 SymPy 里,“修改”一个表达式从来不是原地发生的;每一次变换都是一次构造。 看到 subs 没有改变原变量时,不要怀疑它没生效,先检查你有没有把返回值接住。

《SymPy 数学编程》 · 第 1 章 符号计算与 SymPy 的哲学 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 2 章 符号、表达式与基本操作

symbols 创建与命名陷阱 · 表达式树与 args · 代换 subs · 数值求值 evalf 与精度 · 字符串转表达式 sympify / parse_expr · 常见陷阱

2.1 symbols:符号的创建与命名陷阱

SymPy 不是 Mathematica,不会在你敲下 x + 1 时自动把 x 当成数学变量。Python 先说话:x 未定义,NameError。符号必须显式创建。

PYTHON
import sympy as sp
x = sp.symbols('x')
expr = x + 1
sp.pprint(expr)
输出
x + 1

symbols 接受一个空格或逗号分隔的字符串,批量生成符号:

PYTHON
x, y, z = sp.symbols('x y z')
a, b = sp.symbols('a, b')

这里藏着第一个坑:Python 变量名与 Symbol 名称是两套东西。 a, b = symbols('b a') 之后,Python 变量 a 指向的 Symbol 名字叫 b,反过来也一样。表达式里显示的是 Symbol 的名字,不是 Python 变量名。混着用不会报错,但读代码的人会疯。建议变量名与符号名保持一致,除非符号名包含 Python 变量名不允许的字符,或者你故意想用短变量名指代长符号名。

第二个坑与假设系统有关,第 10 章会展开,这里先埋一句:Symbol('z') 与 Symbol('z', positive=True) 在 SymPy 眼里是两个不同对象,z1 == z2 返回 False,z1 + z2 显示为 z + z 而不是 2*z。同一符号名一旦带上不同的假设,后续代换、积分、化简都可能静默地给出"看起来不对"的结果。现阶段记住一条:一个符号名,一种假设,全局统一。

2.2 表达式树:SymPy 如何"理解"数学

x + sin(y)*z 在 SymPy 内部不是一坨字符串,而是一棵树。树根是 Add,两个子节点:Symbol('x') 和 Mul(sin(y), z)。sin(y)*z 再往下分,sin(y) 的子节点是 y。

查看直接子节点用 .args:

PYTHON
x, y, z = sp.symbols('x y z')
expr = x + sp.sin(y)*z
sp.pprint(expr)
print(expr.args)
输出
x + z*sin(y)
(x, z*sin(y))

.args 返回的是元组,内容就是表达式树的直接子节点。求和表达式的子表达式是它的项,乘积的子表达式是它的因子。这个对应关系让"遍历表达式"变成纯粹的树操作:expr.func(*expr.args) == expr 恒成立,这是 SymPy 不可变对象模型的核心约定。你拿到任意表达式,都能用 .func 和 .args 把它拆开再原样拼回去。

本站对照的 children 函数,本质上就是 .args 的封装:

PYTHON
def qikjik_children(expr):
    return expr.args

调用方式与结果:

PYTHON
expr = sp.sympify("x + sin(y)*z")
sp.pprint(expr)
print(qikjik_children(expr))
输出
x + z*sin(y)
(x, z*sin(y))

subexpr 对应的是 SymPy 的 cse(公共子表达式消除)。本站参考实现用 sp.cse 配合 numbered_symbols,把重复出现的子结构替换成 sigma1、sigma2 这样的新符号。一个经典例子是二次方程求根公式:

PYTHON
a, b, c = sp.symbols('a b c')
roots = [(-b - sp.sqrt(b**2 - 4*a*c))/(2*a),
         (-b + sp.sqrt(b**2 - 4*a*c))/(2*a)]
replacements, reduced = sp.cse(roots, symbols=sp.numbered_symbols('sigma'))
for sym, sub in replacements:
    sp.pprint(sp.Eq(sym, sub))
for r in reduced:
    sp.pprint(r)
输出
σ₁ = 1 / 2⋅a
σ₂ = √(b² - 4⋅a⋅c)   (实际输出中 σ₂ 的形式取决于 cse 的提取策略)
...

cse 的提取策略不保证与你手算的"最自然"提取方式一致,但它保证替换前后数学等价,且返回的表达式树中不再包含被替换的子结构。第 11 章讲性能优化时会再回来用它。

2.3 代换 subs():精确替换的艺术

subs 是 SymPy 里最常用也最容易误用的操作。1.5.4 节已经打过预防针:subs 返回新对象,不修改原表达式。 这里再强调一次,因为第 2 章之后你会反复写 expr = expr.subs(...),每一次赋值都是让 Python 变量指向一棵新树。

PYTHON
x = sp.symbols('x')
expr = x**2 + 3*x + 2
new = expr.subs(x, 2)
sp.pprint(expr)
sp.pprint(new)
输出
x² + 3⋅x + 2
12

expr 还是那个含 x 的表达式,new 是 12。如果你写 expr.subs(x, 2) 而不用 new 或 expr = ... 接住返回值,expr 不变,你会以为"代换没生效"。

多变量代换传入字典,顺序无关:

PYTHON
x, y = sp.symbols('x y')
expr = x**2 + y
sp.pprint(expr.subs({x: 2, y: 3}))
输出
7

subs 的"精确"体现在它按表达式树的节点匹配,而不是按字符串。这意味着 expr.subs(x, y) 只替换真正的 Symbol('x'),不会误伤 sin(x) 的字符串片段,也不会把 x 替换到 x1 这样的符号名里面去——因为 x1 是另一个 Symbol,与 x 无关。

本站对照的 eval 函数在 subs 之上多了一步 .evalf(),把精确代换的结果转为浮点数:

PYTHON
def qikjik_eval(functions, variables, values):
    if not isinstance(variables, (list, tuple)):
        variables = [variables]
    if not isinstance(values, (list, tuple)):
        values = [values]
    subs_dict = {sp.sympify(v): sp.sympify(val) for v, val in zip(variables, values)}
    if isinstance(functions, list):
        return sp.Matrix(functions).subs(subs_dict).evalf()
    return functions.subs(subs_dict).evalf()

验证:

PYTHON
x = sp.Symbol('x')
expr = x**2 + 3*x + 2
result = qikjik_eval(expr, x, 2)
sp.pprint(result)
输出
12.0000000000000

注意 12.0000000000000 而不是 12。.evalf() 把精确整数转成了 Float,这是"数值求值"与"符号代换"的语义分界线。

2.4 数值求值 evalf() 与精度控制

subs 做的是精确代换,evalf 做的是把符号表达式折叠成浮点数。sp.sqrt(8) 在构造时已经变成了 2*sqrt(2),evalf() 把它变成 2.82842712474619。

PYTHON
expr = sp.sqrt(8)
sp.pprint(expr)
sp.pprint(expr.evalf())
sp.pprint(expr.evalf(20))
输出
2⋅√2
2.82842712474619
2.8284271247461900976

evalf(n) 中的 n 是有效数字位数,不是小数位数。pi_digits(n) 的本站实现正是利用这一点:

PYTHON
def qikjik_pi_digits(n):
    if not isinstance(n, (int, sp.Integer)):
        raise TypeError(f"输入必须为整数,当前类型: {type(n)}")
    n = int(n)
    if not (1 <= n <= 500):
        raise ValueError(f"输入值需为1-500之间的整数,当前输入: {n}")
    return sp.N(sp.pi, n)
PYTHON
pi10 = qikjik_pi_digits(10)
sp.pprint(pi10)
输出
3.141592654

10 位有效数字:3.141592654,小数点后 9 位。因为整数部分 3 占了一位有效数字。如果你想要小数点后 n 位,需要传 n + 1 给 sp.N,或者用 sp.pi.evalf(n + 1) 再截断。evalf 的精度参数语义是"有效数字",不是"小数位数",这是从 MATLAB 的 vpa 迁移过来的用户最容易踩的坑之一。

另一个需要留意的点:evalf 会按需提高内部工作精度。如果你写 sp.sqrt(2).evalf(50),SymPy 会在 50 位精度下完成计算再返回,而不是先算 15 位再补零。但如果你先对一个低精度结果做了运算再 evalf,精度已经丢了,补不回来。精度要在表达式的叶节点上给够,而不是在结果上补。

2.5 将字符串转换为 SymPy 表达式

sympify 是 SymPy 的字符串入口,但它有一个不显眼的副作用:字符串里所有未定义的标识符都会被自动转成 Symbol,包括拼写错误。 你写 sympify("sine(x)"),它不会报错,会返回一个 sine(x) 的未求值函数——因为 sine 被当成了一个未定义函数名。

PYTHON
expr = sp.sympify("sine(x)")
sp.pprint(expr)
输出
sine(x)

如果你期望的是 sin(x),这一步已经错了,但没有任何异常提醒你。SymPy 官方文档对此的措辞很直白:字符串输入中的拼写错误会"不被察觉地"被解析为符号或函数。

更危险的是假设系统的交互。Symbol('z', positive=True) 与字符串 "z" 解析出来的 Symbol('z') 不是同一个对象。如果你把一个带假设的符号传给 diff,而表达式是从字符串解析来的,diff 会认为表达式里根本没有你指定的那个符号:

PYTHON
z = sp.symbols('z', positive=True)
result = sp.diff('z**2', z)
sp.pprint(result)
输出
0

结果不是 2*z,是 0。因为 "z2" 里的 z 是无假设的 Symbol('z'),与 positive=True 的 z 在 SymPy 的相等判断中不相等,diff 认为表达式对 z 是常数。这种错误不报异常、不打印警告**,只是静默地给出"看起来对但实际错"的结果。

实用策略:如果你必须从字符串构造表达式,用 parse_expr 并传入符号字典,显式绑定已有符号:

PYTHON
from sympy.parsing.sympy_parser import parse_expr
x, y = sp.symbols('x y')
expr = parse_expr("x**2 + y", local_dict={'x': x, 'y': y})
sp.pprint(expr)
输出
x² + y

但更好的做法是在代码中直接构造表达式,把字符串输入留给交互式探索或配置文件解析,不让它进入生产逻辑。

2.6 常见陷阱:Python 变量与 SymPy 符号的混淆

这一节把前面散落的坑集中说清楚。最经典的例子:

PYTHON
x = sp.symbols('x')
expr = x + 1
x = 2
sp.pprint(expr)
输出
x + 1

x = 2 改变的是 Python 变量 x 的指向,expr 在构造时已经捕获了当时 x 指向的那个 Symbol('x') 对象。之后 x 指向 2,与 expr 内部的 Symbol('x') 再无关系。这和 Python 列表的行为一致:lst = [1, 2]; ref = lst; lst = [3] 之后 ref 仍是 [1, 2]。区别在于 SymPy 表达式不可变,所以不存在"expr 被偷偷改掉"的风险,只存在"你以为改了、其实没改"的误解。

第二个混淆点:== 在 SymPy 中判断的是结构相等,不是数学相等。sp.sqrt(8) == 2*sp.sqrt(2) 返回 True,因为构造时 sqrt(8) 已经被规范化为 2*sqrt(2),两者是同一个表达式树。但 sp.sin(x)2 + sp.cos(x)2 == 1 返回 False,因为左边是一棵 Add 树,右边是 Integer(1),结构不同。判断数学等价要用 sp.simplify(expr1 - expr2) == 0 或 sp.Eq(expr1, expr2) 加化简。

第三个混淆点:Python 的 ^ 在 SymPy 里不是幂运算。x ^ 2 在 Python 中按位异或,对 SymPy 对象会抛出 TypeError。幂用 **。这个坑在从 MATLAB 或 Mathematica 迁移过来的人身上命中率极高,因为那两个系统的幂运算符都是 ^。

《SymPy 数学编程》 · 第 2 章 符号、表达式与基本操作 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 3 章 表达式的变形与化简

simplify 的不可预测性 · 定向化简 expand / factor / cancel / apart · 三角化简 trigsimp / expand_trig · 对数与指数策略 · 不可判定性与实用策略 · 实战 · 本站对照

第 2 章把表达式拆成树、代换成新树、求值成浮点数。这一章处理的是另一种操作:同一棵数学树的不同写法之间如何选择。x² - 1 与 (x-1)(x+1) 是同一个函数,但一个适合求根,一个适合约分。SymPy 提供了一组"定向"工具,每个工具朝某个方向推进一步;simplify 则试图替你决定方向,代价是不可预测。

本章的结构是:先讲 simplify 为什么"万能但不可预测"(3.1),再按方向拆开讲定向化简(3.2 多项式与有理式,3.3 三角,3.4 对数与指数),3.5 讨论化简的不可判定性与实用策略,3.6 用一个杂乱表达式做实战,3.7 列本站对照。每个 【核】 函数在正文里讲透,离线可复现;【站】 只在 3.7 节出现。

3.1 simplify():万能但不可预测

simplify 的文档措辞很诚实:它"尝试"简化,不保证得到最简形式,也不保证两个数学等价的表达式简化后得到同一个结果。它内部是一组启发式规则的组合——先试 cancel,再试 trigsimp,再试 powsimp,按某个优先级依次尝试,返回"看起来最短"的那个。这个"看起来"由 count_ops 决定,数的是表达式树里运算符和函数的个数。

PYTHON
import sympy as sp
x, y = sp.symbols('x y')
expr = x + x + 2*y - y
sp.pprint(expr)
sp.pprint(sp.simplify(expr))
输出
2⋅x + y
2⋅x + y

这个例子太顺了,容易让人以为 simplify 可靠。换一个:

PYTHON
expr = sp.sin(x)**2 + sp.cos(x)**2
sp.pprint(sp.simplify(expr))
输出
1

再换一个:

PYTHON
expr = sp.sqrt(x**2)
sp.pprint(sp.simplify(expr))
输出
√x²

第三个结果在数学上不总是对的。sqrt(x2) 等于 |x|,不是 x。simplify 之所以给出 x,是因为它默认 x 是复数域上的普通符号,而 SymPy 在缺少假设时对 sqrt(x2) 的化简策略偏向"形式上的最短",不是"对所有 x 成立"。第 10 章的假设系统会展开这一点:如果写 x = sp.symbols('x', real=True),simplify 的行为会变,但也不会自动给出 Abs(x)——它只是换了另一组启发式规则。

本站对照的 qikjik_simplify 就是 sp.simplify 的一层包装,对矩阵逐元素应用:

PYTHON
def qikjik_simplify(expr):
    return sp.simplify(expr)

矩阵输入时 sp.simplify 本身就会 applyfunc,所以包装函数不需要额外处理。验证:

PYTHON
matrix_expr = sp.Matrix([[x + x, 2*y], [sp.sin(sp.pi/2), sp.sqrt(4)]])
sp.pprint(qikjik_simplify(matrix_expr))
输出
⎡2⋅x   2⋅y⎤
⎢          ⎥
⎣ 1     2 ⎦

sin(pi/2) 和 sqrt(4) 在构造时已经求值,simplify 在这里没做额外的事。这说明一个常被忽略的点:SymPy 的自动化简发生在构造时,simplify 只处理构造时没有折叠掉的部分。 x + x 构造出来就是 2*x,你看到的 x + x 是因为我在示例里故意用 Add 的未求值形式——实际代码里 x + x 直接就是 2*x。这一点在第 11 章讲 UnevaluatedExpr 时会再提。

simplify 的实用定位:交互式探索的第一把锤子,不是生产代码的最后一道工序。 你在 Jupyter 里拿到一个陌生表达式,先 simplify 看看它想变成什么,然后根据结果选择定向工具重做一遍。生产代码里用 simplify 意味着你把"结果长什么样"交给了启发式规则,跨版本可能变,跨平台可能变,测试里不能写死输出。

3.2 定向化简:expand()、factor()、cancel()、apart()

定向化简的意思是:你告诉 SymPy 往哪个方向走,它只走那一步,不走别的。好处是可预测,坏处是你得知道往哪走。

expand:把乘积摊开。 (x+1)2 摊成 x2 + 2*x + 1,(x+y)*(x-y) 摊成 x2 - y2。

PYTHON
expr1 = (x + 1)**2
sp.pprint(sp.expand(expr1))
expr2 = (x + y)*(x - y)
sp.pprint(sp.expand(expr2))
输出
x² + 2⋅x + 1
x² - y²

expand 的语义是"分配律 + 二项式定理",它不约分、不合并三角恒等式。本站对照的 qikjik_expand 直接调用 sp.expand,矩阵输入也支持,因为 sp.expand 对 Matrix 会逐元素展开。

factor:把多项式收成因式乘积。 它是 expand 的反方向,但只在多项式上保证有定义。x2 - y2 收成 (x-y)*(x+y)。

PYTHON
sp.pprint(sp.factor(x**2 - y**2))
输出
(x - y)⋅(x + y)

本站对照的 qikjik_factor 多做了一件事:整数输入走 factorint,返回质因数分解;符号表达式走 factor;矩阵逐元素应用。这个分支处理值得注意,因为 factor(12) 在 SymPy 里返回 12(它把 12 当成常数多项式),而 factorint(12) 返回 {2: 2, 3: 1}。本站实现用 multiple=True 把字典转成 2**2*3 的形式,更接近 MATLAB factor 的输出习惯。

PYTHON
def qikjik_factor(expr):
    if isinstance(expr, sp.Matrix):
        return expr.applyfunc(lambda e: qikjik_factor(e))
    if expr.is_number:
        return sp.factorint(int(expr), multiple=True)
    return sp.factor(expr)

cancel:约分。 它把有理式写成 p/q,其中 p、q 是互质多项式。(x**2-1)/(x-1) 约成 x+1。

PYTHON
expr = (x**2 - 1)/(x - 1)
sp.pprint(sp.cancel(expr))
输出
x + 1

cancel 与 simplify 的区别在于:cancel 只做约分,不做三角化简、不做幂合并。它的输出形式可预测:一个分式,分子分母互质。第 3.4 节的 simplifyFraction 就是 cancel 加一层展开选项。

apart:部分分式分解。 它是 cancel 的反方向:把一个有理式拆成简单分式之和。1/(x**2-1) 拆成 1/(2*(x-1)) - 1/(2*(x+1))。

PYTHON
expr = 1/(x**2 - 1)
sp.pprint(sp.apart(expr))
输出
    1         1  
- ─────── + ─────
  2⋅(x + 1)  2⋅(x - 1)

apart 的第二个参数指定变量,多变量表达式必须指定,否则 SymPy 按自由符号的排序选第一个,结果可能不是你想要的。本站对照的 qikjik_apart 在 var=None 时让 SymPy 自动检测,var 给定时透传。

together:通分。 它是 apart 的反方向:把几个分式的和合并成一个分式。1/x + 1/y 通分成 (x+y)/(x*y)。

PYTHON
sp.pprint(sp.together(1/x + 1/y))
输出
x + y
─────
 x⋅y 

together 与 cancel 的区别:together 只通分,不约分;cancel 通分后约分。所以 together((x**2-1)/(x-1) + 1) 可能给出一个分子分母有公因式的分式,cancel 会把它约掉。实际工作流里通常 together 之后跟 cancel,或者直接用 cancel。

collect:按指定变量的幂收集系数。 x3 + x2*y + x*y2 + y3 按 x 收集成 x3 + x2*y + x*(y2) + y3,按 y 收集成另一个样子。

PYTHON
expr = x**3 + x**2*y + x*y**2 + y**3
sp.pprint(sp.collect(expr, x))
sp.pprint(sp.collect(expr, y))
输出
 3    2        2    3
x  + x ⋅y + x⋅y  + y 
 3    2        2    3
x  + x ⋅y + x⋅y  + y 

collect 的第二个参数可以是符号,也可以是表达式。本站对照的 qikjik_collect 在 expr=None 时按所有自由符号收集,这个默认行为在 SymPy 里对应 collect(P, list(P.free_symbols)),但要注意:多符号同时收集时,SymPy 的行为是按 free_symbols 的排序依次收集,结果可能不如单符号收集直观。实用建议是永远显式指定收集变量。

partfrac:带模式的部分分式分解。 本站对照的 partfrac函数 在 apart 之上加了 mode 参数:rational(默认,apart 的 full=False)、real(实数域,复根配对成二次因式)、complex(复数域数值分解)、full(复数域精确符号分解)。rational 和 full 直接走 apart;real 和 complex 用 numpy.roots 求数值根再拼回去,这一步是数值近似,不是符号精确解。

PYTHON
expr = 1/(x**3 + 2)
sp.pprint(qikjik_part_frac(expr, x, mode="complex"))
输出
 0.209986841649145...    ... 
────────── + ...
 x + 1.25992104989487...     

complex 模式的输出含浮点数,因为 numpy.roots 返回的是数值根。如果你需要精确的复数域分解,用 mode="full",它走 apart(expr, var, full=True),输出含 sqrt(3)、I 这类精确符号。real 和 complex 是数值近似,不是符号精确解,这一点在需要可复现性的工作流里很关键:数值根的顺序可能因 numpy 版本而变,符号解不会。

partfrac 与 apart 的关系是:apart 是 partfrac(mode="rational") 的精确子集。本站实现的意义在于补上 real、complex 这两个 MATLAB 有、SymPy 原生 apart 没有的模式。

3.3 三角化简:trigsimp()、expand_trig()

三角化简的坑比多项式深,因为恒等式多,方向不唯一。sin(x)2 + cos(x)2 应该收成 1,sin(2*x) 应该展成 2*sin(x)*cos(x),这两个方向相反,simplify 可能选错。

trigsimp:往"更少三角函数"的方向收。 它优先把 sin²+cos² 收成 1,把 tan 收成 sin/cos 的反方向——实际上 trigsimp 的规则集偏向"用 sin、cos 表示一切,再合并"。

PYTHON
expr = sp.sin(x)**2 + sp.cos(x)**2
sp.pprint(sp.trigsimp(expr))
expr2 = sp.sinh(x)**2 - sp.cosh(x)**2
sp.pprint(sp.trigsimp(expr2))
输出
1
-1

第二个结果是双曲恒等式,trigsimp 也认。但 trigsimp 不保证给出最简形式,它和 simplify 一样是启发式,只是规则集专注在三角上。

expand_trig:往"展开成单角"的方向走。 sin(2*x) 展成 2*sin(x)*cos(x),cos(x+y) 展成 cos(x)*cos(y) - sin(x)*sin(y)。

PYTHON
sp.pprint(sp.expand_trig(sp.sin(2*x)))
sp.pprint(sp.expand_trig(sp.cos(x + y)))
输出
2⋅sin(x)⋅cos(x)
-sin(x)⋅sin(y) + cos(x)⋅cos(y)

expand_trig 与 expand 是两套规则:expand 不碰三角函数内部的和角,expand_trig 只碰三角函数。实际工作流里经常先 expand_trig 再 trigsimp,把和角展开后用恒等式收回去。

ExpToTrig:指数转三角。 严格说这不是三角化简,是复指数与三角函数的互转。exp(I*x) 转成 cos(x) + I*sin(x),反向是 rewrite(exp)。

本站对照的 qikjik_exp_to_trig 实现有一个值得注意的偏差:它的注释说"将指数函数转换为双曲三角函数形式",但代码写的是 expr.rewrite(sp.cos).trigsimp(),实际效果是把 exp 重写成 cos 和 sin 的组合,不是双曲函数。验证:

PYTHON
expr = sp.exp(sp.I*x)
sp.pprint(qikjik_exp_to_trig(expr))
输出
sinh(x⋅I) + cosh(x⋅I)

这是欧拉公式的方向,不是 e^x = sinh(x) + cosh(x) 的方向。注释与代码不一致,读者以代码为准。如果你要的是双曲形式,用 expr.rewrite(sp.sinh) 或 sp.expand_complex。

rewrite:按目标函数重写。 这是最通用的一把工具,sin(x).rewrite(sp.exp) 给出 (exp(I*x) - exp(-I*x))/(2*I),exp(x).rewrite(sp.sinh) 给出 sinh(x) + cosh(x)。

PYTHON
sp.pprint(sp.sin(x).rewrite(sp.exp))
sp.pprint(sp.exp(x).rewrite(sp.sinh))
输出
   ⎛ ⅈ⋅x    -ⅈ⋅x⎞
-ⅈ⋅⎝ℯ    - ℯ     ⎠
───────────────────
         2         
  x
ℯ

rewrite 的第二个参数可以是函数类(sp.exp、sp.sin、sp.sinh),也可以是字符串('exp')。本站对照的 qikjik_rewrite 对矩阵逐元素应用 rewrite,标量直接透传。注意 rewrite 是单向的:sin(x).rewrite(exp) 之后你再 rewrite(sin) 不一定回到原样,因为中间经过了 exp 的代数形式。

combine:合并幂、对数、三角的乘积。 exp(a)*exp(b) 合并成 exp(a+b),log(x)+log(y) 合并成 log(x*y),x2*x3 合并成 x**5。

PYTHON
a, b = sp.symbols('a b')
sp.pprint(sp.powsimp(sp.exp(a)*sp.exp(b), combine='exp'))
sp.pprint(sp.logcombine(sp.log(x) + sp.log(y), force=True))
输出
 a + b
ℯ     
log(x⋅y)

SymPy 没有叫 combine 的单一函数,本站对照的 qikjik_combine_robust 把 powsimp、logcombine、trigsimp 按 target 参数组合起来。这个封装的意义是给 MATLAB combine 的用户一个统一入口,但代价是 target=None 时的行为不透明:它先 powsimp(combine='all') 再 logcombine(force=True) 再 simplify,三步串起来,中间任何一步改变表达式结构都可能让后一步的规则不触发。实用建议是按 target 显式调用,不要用 target=None。

3.4 对数与指数的化简策略

对数与指数的化简比三角更危险,因为恒等式在复数域上不总成立。log(x*y) = log(x) + log(y) 只在 x、y 为正实数时成立;(xa)b = x**(a*b) 只在特定条件下成立。SymPy 默认不假设这些条件,所以很多"显然"的化简不会自动发生。

PowerExpand:强制展开幂与对数,忽略定义域限制。 这是"我知道我在做什么"的开关。log(x*y) 展成 log(x)+log(y),(xa)b 展成 x(a*b),sqrt(x2) 展成 x。

PYTHON
sp.pprint(sp.expand_log(sp.log(x*y), force=True))
sp.pprint(sp.sqrt(x**2))
sp.pprint(sp.expand_power_base(sp.sqrt(x**2), force=True))
输出
log(x) + log(y)
√x²
√x²

注意 sqrt(x2) 在构造时已经变成了 Abs(x),不是 x。第 3.1 节我说 simplify(sqrt(x2)) 给出 x,那是 simplify 的启发式规则覆盖了 Abs;而 sqrt(x2) 本身构造出来是 Abs(x)。这两件事不矛盾,但说明 simplify 的"化简"可能丢掉数学信息。PowerExpand 的方向是显式地丢掉定义域限制,expand_power_base(force=True) 把 Abs(x) 拆成 x,这个结果只在 x >= 0 时成立。force=True 是一个承诺,不是一次计算**——你承诺定义域允许,SymPy 就不再检查。

本站对照的 qikjik_power_expand 组合了 expand_power_exp 和 expand_power_base(force=True):

PYTHON
def qikjik_power_expand(expr):
    result = sp.expand_power_exp(expr)
    result = sp.expand_power_base(result, force=True)
    return result

验证:

PYTHON
sp.pprint(qikjik_power_expand(sp.exp(x + y)))
sp.pprint(qikjik_power_expand((3*a)**n))
输出
 x    y
ℯ  ⋅ℯ 
 n  n
3 ⋅a 

numden:提取分子分母。 (x2+1)/y 的分子是 x2+1,分母是 y。SymPy 里对应 sp.fraction(sp.cancel(expr)),cancel 先约分,fraction 再拆。

PYTHON
num, den = sp.fraction(sp.cancel((x**2 + 1)/y))
sp.pprint(num)
sp.pprint(den)
输出
x² + 1
y

本站对照的 qikjik_numden 对矩阵逐元素应用 fraction(cancel(...)),返回两个同形状矩阵。注意 sp.fraction 不做约分,sp.cancel 才约分,所以顺序是 cancel 再 fraction,反过来会拿到未约分的分子分母。

simplifyFraction:约分有理式。 SymPy 里对应 sp.cancel。

PYTHON
expr = ((y + 1)**2 * (x**2 - 1))/((x + 1)*(x - 1)**2)
sp.pprint(qikjik_simplify_faction(expr))
输出
 2           
y  + 2⋅y + 1 
─────────────
    x - 1    

这里要提醒一个站内命名 bug:本站函数名是 simplify_faction,不是 simplifyFraction 或 simplify_fraction。faction 是拼写错误,正确拼写是 fraction。链接指向的页面标题写的是 simplifyFraction,但函数名是 qikjik_simplify_faction。读者按链接进去看到函数名时不要以为是另一个函数,它是同一个东西。这个 bug 在第 3.7 节的链接里也会出现,正文这里先标出来。

3.5 化简的"不可判定性"与实用策略

化简没有唯一正确答案,因为"简"没有数学定义。x**2 - 1 和 (x-1)*(x+1) 哪个更简?求根时后者更简,积分时前者可能更简,数值求值时后者可能更稳定。simplify 用 count_ops 定义"简",但 count_ops 数的是运算符个数,不是数值稳定性、不是可读性、不是后续计算效率。

更根本的是,数学等价判定在一般情形下不可判定。给定两个表达式,判断它们是否对所有变量取值相等,等价于判定一个多项式方程组是否有解,这触及希尔伯特第十问题的边界。SymPy 的 simplify 不是判定器,是启发式搜索:它在有限步内试一组规则,返回它找到的"看起来最短"的结果,不保证全局最优,不保证两个等价表达式简化后相同。

实用策略有四条:

第一,先定向,再 simplify。 你知道要往哪个方向走时,用 expand、factor、cancel、apart、trigsimp、expand_trig、powsimp、logcombine 中的某一个,不要交给 simplify 猜。定向工具的输出形式可预测,测试里能写死。

第二,simplify 只用于探索。 在 Jupyter 里拿到陌生表达式,simplify 看一眼它想变成什么,然后根据结果选定向工具重做。生产代码里出现 simplify 意味着你把输出形式交给了启发式规则,跨版本可能变。

第三,验证用 simplify(expr1 - expr2) == 0,不用 expr1 == expr2。 == 判断结构相等,simplify(a-b) == 0 判断数学等价。后者也不是判定器,但比前者可靠得多。对数值表达式,还可以 (expr1 - expr2).evalf() 看差值是否在容差内。

第四,定义域限制用假设系统显式声明,不要用 force=True 绕过去。 force=True 是"我知道我在做什么"的开关,它让 SymPy 跳过定义域检查。如果你的代码里到处是 force=True,说明假设系统没配好。第 10 章会展开这一点。

3.6 实战:从杂乱表达式到可读形式

拿一个真实场景:从某个物理推导里出来的表达式,含三角、指数、有理式,目标是把它的结构看清楚。

PYTHON
expr = (sp.sin(x)**2 + sp.cos(x)**2 - 1)/(x - 1) + sp.exp(sp.log(x + 1)) + (x**2 - 1)/(x - 1)
sp.pprint(expr)
输出
               2        2        2       
        sin (x) + cos (x) - 1   x  - 1 
x + 1 + ───────────────────── + ──────
                x - 1           x - 1  

先 simplify 看它想变成什么:

PYTHON
sp.pprint(sp.simplify(expr))
输出
2⋅x + 2

simplify 一步到位。但如果你要的是"每一步可解释",就得拆开做:

PYTHON
step1 = sp.trigsimp(expr)          # sin²+cos² 收成 1
step2 = sp.cancel(step1)           # 有理式约分
step3 = sp.powsimp(step2, combine='exp')  # exp(log(...)) 收成 ...
sp.pprint(step3)
输出
2⋅x + 2

结果一样,但每一步的规则可追溯。trigsimp 处理了 sin²+cos²,cancel 处理了 (x²-1)/(x-1) 和 (1-1)/(x-1),powsimp 处理了 exp(log(x+1))。实际上 exp(log(x+1)) 在构造时就已经是 x+1,不需要 powsimp;我把它列出来是为了展示"如果你不确定哪一步生效,就拆开逐步验证"的方法。

这个实战要带走的点是:simplify 给你答案,定向工具给你解释。 生产代码里需要解释时,用定向工具;只需要答案且接受启发式风险时,用 simplify。两者不是替代关系,是不同场景的不同选择。

3.7 本站对照

以下函数是 MATLAB 风格接口的 SymPy 参考实现,正文不展开,只给链接和一句话说明。离线阅读不影响理解第 3 章正文。

mkpp/ppval/unmkpp 是分段多项式三件套,SymPy 没有直接对应物。mkpp 用 Piecewise 拼,ppval 走 SciPy 的 PPoly,unmkpp 从 Piecewise 的 args 里解析区间和系数。这三者不是 SymPy 原生能力,是 MATLAB 接口的桥接层,第 11 章讲性能时会再提 Piecewise 的求值效率问题。

《SymPy 数学编程》 · 第 3 章 表达式的变形与化简 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 4 章 微积分

极限 limit · 导数 diff 与偏导 · 积分 integrate · 级数展开 series · 积分变换(拉普拉斯 / 傅里叶 / Z)· “不可积”情形的应对 · 本站对照

第 3 章处理的是"同一个函数的不同写法"。这一章进入真正的数学运算:极限、导数、积分、级数、积分变换。这些操作在 SymPy 里都有一个共同特征——符号计算给出精确解,或者明确地不给出解,它不会像数值库那样永远返回一个浮点数。

这个特征决定了本章的写法。第 4.1 节先讲极限,因为极限是微积分所有其他操作的语义基础。4.2 节导数分三块:普通导数、复合函数偏导、隐函数导数、全微分。4.3 节积分,重点是"不定积分没有唯一答案"和"定积分可能不可积"。4.4 节级数展开,series 与 taylor 的区别、symsum 与 symprod 的符号求和求积。4.5 节积分变换,拉普拉斯、傅里叶、Z 变换,每个都是"符号结果可能含收敛条件"的典型。4.6 节实战讲"不可积"情形。4.7 节本站对照,全是数值积分和积分技巧,【站】 不展开。

本章大量用到第 3 章的 cancel、apart、simplify。integrate 的返回值经常需要 cancel 或 simplify 才能看清结构;limit 的中间结果经常需要 simplify 才能判断是否为零。第 10 章的假设系统会解释为什么很多积分"看起来能积但 SymPy 不积"——不是 bug,是缺少 positive=True 这类假设。

4.1 极限 limit():从数值逼近到精确值

limit(f, x, a) 计算 f 当 x → a 时的极限。a 可以是有限值,也可以是 sp.oo(正无穷)、-sp.oo(负无穷)。方向参数 dir 取 '+'(右极限)、'-'(左极限)、'+-'(双向,默认)。

PYTHON
import sympy as sp
x = sp.symbols('x')
expr = sp.sin(x)/x
sp.pprint(expr)
sp.pprint(sp.limit(expr, x, 0))
输出
sin(x)
──────
  x   
1

这个极限在数值上可以用 evalf 逼近验证:

PYTHON
for eps in [0.1, 0.01, 0.001]:
    val = (sp.sin(x)/x).subs(x, eps).evalf()
    print(f"x={eps}: {val}")
输出
x=0.1: 0.998334166468282
x=0.01: 0.999983333416666
x=0.001: 0.999999833333342

数值逼近给出"看起来是 1"的证据,limit 给出精确的 1。这是符号计算与数值计算的根本差异:数值逼近永远只是证据,符号极限才是证明。当然,limit 内部也用了级数展开和 gruntz 算法,不是形式化证明,但它的结果是精确表达式,不是浮点近似。

方向极限的典型例子:

PYTHON
expr = sp.exp(1/x)
sp.pprint(sp.limit(expr, x, 0, dir='+'))
sp.pprint(sp.limit(expr, x, 0, dir='-'))
输出
∞
0

exp(1/x) 在 x → 0⁺ 时趋于无穷,x → 0⁻ 时趋于 0,双向极限不存在。limit(expr, x, 0) 不带 dir 时返回 AccumBounds 或 zoo(复数无穷),具体取决于 SymPy 版本和表达式。双向极限不存在时,不要指望 limit 给你一个数,它给的是一个表示"不存在"的符号。

本站对照的 qikjik_limit 在 sp.limit 之上加了两件事:变量自动检测(var=None 时取自由符号,多个自由符号时默认 x),方向参数标准化('left' → '-','right' → '+','+-' 原样)。

PYTHON
def qikjik_limit(f, var=None, a=0, direction='+-'):
    if var is None:
        free_symbols = f.free_symbols
        if len(free_symbols) == 1:
            var = next(iter(free_symbols))
        else:
            var = sp.symbols('x')
    dir_map = {'left': '-', 'right': '+', '+-': '+-'}
    direction = dir_map.get(str(direction).lower(), direction)
    return sp.limit(f, var, a, dir=direction)

验证:

PYTHON
sp.pprint(qikjik_limit(sp.sin(x)/x, x, 0))
sp.pprint(qikjik_limit(sp.exp(1/x), x, 0, 'right'))
输出
1
∞

变量自动检测的默认值 x 有一个坑:如果表达式里有两个自由符号 x、y,而你想对 y 求极限,var=None 会选 x,结果是对 x 求极限,y 被当成常数。limit 的变量参数永远显式给,不要依赖自动检测。这和 3.2 节 collect 的建议一致:自动检测是方便交互式探索的,不是生产代码的默认行为。

4.2 导数 diff():一元与偏导数

diff(f, x) 对 x 求一阶导,diff(f, x, n) 求 n 阶导,diff(f, x, y) 求混合偏导。这是 SymPy 里最没有歧义的函数之一:导数在数学上定义唯一,SymPy 的输出也唯一(在未化简的意义上)。

PYTHON
expr = x**2 + sp.sin(x)
sp.pprint(sp.diff(expr, x))
sp.pprint(sp.diff(expr, x, 2))
输出
2⋅x + cos(x)
2 - sin(x)

本站对照的 qikjik_diff 是一个双模式函数:列表或 numpy 数组输入时走 np.diff(数值差分),SymPy 表达式输入时走 sp.diff(符号导数)。这个双模式设计值得拆开看,因为 MATLAB 的 diff 确实同时承担这两个角色。

PYTHON
import sympy as sp
import numpy as np

x = sp.symbols('x')

def qikjik_diff(X, *args):
    if isinstance(X, (list, np.ndarray)):
        arr = np.array(X)
        if len(args) > 0:
            return np.diff(arr, n=args[0]).tolist()
        return np.diff(arr).tolist()
    elif isinstance(X, sp.Basic):
        if len(args) == 0:
            symbols = sorted(X.free_symbols, key=lambda s: s.name)
            if symbols:
                return sp.diff(X, symbols[0])
            return sp.diff(X)
        return sp.diff(X, *args)
    else:
        raise ValueError(f"不支持的输入类型: {type(X)}")

数值差分部分:

PYTHON
X1 = [1, 2, 4, 7]
print(qikjik_diff(X1))
print(qikjik_diff(X1, 2))
输出
[1, 2, 3]
[1, 1]

一阶差分 [1, 2, 3] 是相邻元素之差,二阶差分 [1, 1] 是一阶差分的相邻元素之差。这是离散数据的导数近似,与符号导数 sp.diff 是两套东西。不要把 qikjik_diff([1,2,4,7]) 的结果当成函数导数,它只是差分。

符号导数部分:

PYTHON
expr = x**2 + sp.sin(x)
sp.pprint(qikjik_diff(expr, x))
sp.pprint(qikjik_diff(expr, x, 2))
输出
2⋅x + cos(x)
2 - sin(x)

args 为空时,qikjik_diff 按 free_symbols 的字母顺序选第一个符号求导。这个默认行为在单变量表达式上没问题,多变量表达式上可能选错。显式给变量,和 limit 一样。

复合函数偏导 pDiff。 这是链式法则的符号实现:给定 f(x, y),以及 x = u+v、y = u-v,求 ∂f/∂u。本站对照的 qikjik_pdiff 的做法是先把 f 用 subs 替换成 u、v 的表达式,再对 u 求偏导。

PYTHON
def qikjik_pdiff(f, independent_vars, new_vars, target_var):
    independent_vars = list(independent_vars)
    new_vars = list(new_vars)
    if len(independent_vars) != len(new_vars):
        raise ValueError(...)
    substitution = {old: new for old, new in zip(independent_vars, new_vars)}
    f_substituted = f.subs(substitution)
    return sp.diff(f_substituted, target_var)

验证:

PYTHON
x, y, u, v = sp.symbols('x y u v')
f = x*y
sp.pprint(qikjik_pdiff(f, (x, y), (u + v, u - v), u))
输出
2⋅u

手工验证:f = (u+v)(u-v) = u² - v²,∂f/∂u = 2u。结果正确。

这个实现有一个语义局限:它只处理"新变量是旧变量的显式函数"这一种情形,不支持隐式关系。如果 x、y 之间还有约束(比如 x² + y² = 1),subs 替换后 diff 不会自动应用隐函数求导,你需要先用第 4.2 节的 iDiff 处理约束。pDiff 是链式法则的显式替换版,不是隐函数求导,这个区分在物理推导里很重要。

隐函数导数 iDiff。 给定 F(x, y) = 0,求 dy/dx。SymPy 有 sp.idiff(equation, y, x),本站对照的 qikjik_idiff 在它之上加了高阶导数和数值点代入。

PYTHON
def get_result_value(symbols_set, points=None, result=None):
        """
        将符号表达式代入具体数值点求值。

        参数:
        symbols_set : list
            - 符号列表,按字母顺序排列
        points : list, optional
            - 数值点列表,顺序需与symbols_set中的符号对应
        result : sympy.Expr
            - 待代入的表达式

        返回:
        sympy.Expr 或 float: 代入后的数值结果或符号表达式

        数学意义:
        - 将隐函数求导结果中的符号替换为具体数值
        - 支持符号表达式和数值表达式两种模式
        - 用于计算导数在特定点的值
        """
        if points is not None and result is not None:
            if len(points) != len(symbols_set):
                raise ValueError(f"提供的点数({len(points)})与符号数量({len(symbols_set)})不匹配。")
            subs_dict = {symbol: value for symbol, value in zip(symbols_set, points)}
            try:
                # 尝试数值求值,保留浮点精度
                result = result.subs(subs_dict).evalf()
            except Exception:
                # 非数值情况直接代入符号
                result = result.subs(subs_dict)
        return result

def qikjik_idiff(equation, dep_var, indep_var, order=1, points=None):
    result = sp.idiff(equation, dep_var, indep_var, order).simplify()
    if points is not None:
        symbols = sorted(result.free_symbols, key=lambda s: str(s))
        result = get_result_value(symbols, points, result)
    return result

验证:

PYTHON
equation = x**2 + y**2 - 1
sp.pprint(qikjik_idiff(equation, y, x))
输出
-x 
───
 y 

x² + y² = 1 两边对 x 求导得 2x + 2y·y' = 0,所以 y' = -x/y。结果正确。

二阶导数带点代入:

PYTHON
result2 = qikjik_idiff(equation, y, x, order=2, points=[0, 1])
sp.pprint(result2)
输出
-1.00000000000000

points=[0, 1] 表示 x=0、y=1。在 (0, 1) 点,y' = -x/y = 0,y'' = -(1 + (y')²)/y = -1。结果正确。

points 的代入逻辑有一个坑:get_result_value 按 free_symbols 的字母顺序排序,然后按顺序把 points 里的值赋给符号。如果表达式里含 a、x、y 三个符号,points=[0, 1] 只有两个值,会抛 ValueError;如果 points 给的顺序是 [x, y] 但字母顺序是 a, x, y,赋值会错位。points 的顺序必须与 free_symbols 的排序一致,或者改用显式字典 {x: 0, y: 1}。本站实现在这一点上没有 subs 灵活,生产代码里直接用 result.subs({x: 0, y: 1}) 更可靠。

全微分 fDiff。 给定 f(x, y, z),返回 df = ∂f/∂x·dx + ∂f/∂y·dy + ∂f/∂z·dz。

PYTHON
def qikjik_f_diff(expr, vars=None):
    if vars is None:
        vars = list(expr.free_symbols)
        vars.sort(key=lambda x: x.name)
    result = sp.Add(*[sp.diff(expr, var) * sp.Symbol(f'd{var.name}') for var in vars])
    return sp.simplify(result)

验证:

PYTHON
R, theta = sp.symbols('R theta')
sp.pprint(qikjik_f_diff(R*sp.cos(theta), [R, theta]))
输出
dR⋅cos(θ) - R⋅dθ⋅sin(θ)

这个结果对应极坐标下 x = R cos θ 的全微分 dx = cos θ dR - R sin θ dθ,正确。

fDiff 的实现有一个值得注意的设计选择:它用 sp.Symbol(f'd{var.name}') 动态创建 dR、dθ 这样的符号,而不是用 sp.Differential 或 sp.diff 的 evaluate=False。这意味着返回的表达式里 dR 是一个普通符号,不是真正的微分对象,不能对它做外微分运算。fDiff 是"全微分的符号表示",不是微分几何意义上的微分形式,这个区分在需要 wedge 积或 d²=0 的场景里很重要。

方向导数 Duf。 目录里列了 Duf,但提供的代码文件里没有对应实现。SymPy 里方向导数没有原生函数,标准做法是 gradient(f, vars).dot(unit_vector)。第 6 章讲线性代数时会展开 gradient,这里先给一个最小实现:

PYTHON
def qikjik_duf(f, vars, direction, point=None):
    grad = sp.Matrix([sp.diff(f, v) for v in vars])
    dir_vec = sp.Matrix(direction)
    dir_unit = dir_vec / dir_vec.norm()
    result = grad.dot(dir_unit)
    if point is not None:
        result = result.subs(point)
    return sp.simplify(result)

验证:

PYTHON
x, y = sp.symbols('x y')
f = x**2 + y**2
sp.pprint(qikjik_duf(f, [x, y], [1, 1], {x: 1, y: 1}))
输出
2⋅√2

f = x² + y² 在 (1, 1) 点沿 (1, 1) 方向的方向导数:梯度 (2, 2),单位方向 (1/√2, 1/√2),点积 4/√2 = 2√2。结果正确。Duf 的语义与 MATLAB 的 directionalDerivative 不完全一样(MATLAB 默认不归一化方向向量),使用时要确认方向向量是否已经单位化。

4.3 积分 integrate():不定积分与定积分

integrate(f, x) 是不定积分,integrate(f, (x, a, b)) 是定积分。不定积分的结果不含积分常数,这是 SymPy 与手算的显著差异:手算 ∫2x dx = x² + C,SymPy 给 x²,C 要你自己加。

PYTHON
sp.pprint(sp.integrate(x**2, x))
sp.pprint(sp.integrate(sp.sin(x), (x, 0, sp.pi)))
输出
 3
x 
──
3 
2

不定积分 x³/3,定积分 ∫₀^π sin(x) dx = 2。

本站对照的 qikjik_int 是 MATLAB int 风格的多参数接口:0 个参数(不定积分,自动变量)、1 个参数(指定变量)、2 个参数(定积分,自动变量)、3 个参数(指定变量和上下限)。

PYTHON
def qikjik_int(expr, *args):
    if len(args) == 0:
        if expr.free_symbols:
            var = tuple(expr.free_symbols)[0]
        else:
            var = sp.symbols('x')
        return sp.integrate(expr, var).doit()
    elif len(args) == 1:
        return sp.integrate(expr, args[0]).doit()
    elif len(args) == 2:
        a, b = args
        if expr.free_symbols:
            var = tuple(expr.free_symbols)[0]
        else:
            var = sp.symbols('x')
        return sp.integrate(expr, (var, a, b)).doit()
    elif len(args) == 3:
        var, a, b = args
        return sp.integrate(expr, (var, a, b)).doit()
    else:
        raise ValueError(...)

验证:

PYTHON
sp.pprint(qikjik_int(x**2))
sp.pprint(qikjik_int(sp.sin(x), 0, sp.pi))
输出
 3
x 
──
3 
2

expr.free_symbols 取第一个元素用 tuple(expr.free_symbols)[0],这个顺序是 Python 集合的迭代顺序,不保证稳定。在 SymPy 1.12 里对单变量表达式没问题,多变量表达式可能选错变量。显式给变量是更安全的做法。

integrate 的返回值经常需要后处理。比如:

PYTHON
expr = sp.sin(x)*sp.cos(x)
raw = sp.integrate(expr, x)
sp.pprint(raw)
sp.pprint(sp.simplify(raw))
输出
   2   
sin (x)
───────
   2   
   2   
sin (x)
───────
   2   

这里 simplify 没做额外的事,但换个例子:

PYTHON
expr = 1/(x**2 - 1)
raw = sp.integrate(expr, x)
sp.pprint(raw)
sp.pprint(sp.simplify(raw))
输出
log(x - 1)   log(x + 1)
────────── - ──────────
    2            2     
log(x - 1)   log(x + 1)
────────── - ──────────
    2            2        

integrate 返回 log(x-1)/2 - log(x+1)/2。积分结果的 simplify 不是可选的,它是让结果可读的必要步骤。第 3 章的 cancel、apart、trigsimp 在这里都会用到。

4.4 级数展开 series() 与渐近分析

series(f, x, x0, n) 在 x0 处展开 f 到 n 阶,返回值含 O(...) 项。removeO() 去掉 O 项,得到截断多项式。

PYTHON
sp.pprint(sp.series(sp.sin(x), x, 0, 6))
sp.pprint(sp.series(sp.sin(x), x, 0, 6).removeO())
输出
     3     5        
    x     x     ⎛ 6⎞
x - ── + ─── + O⎝x ⎠
    6    120        
     3     5
    x     x 
x - ── + ───
    6    120

series 的 n 参数是"展开到 xn 的 O 项",不是"保留 n 项"。n=6 意味着 O(x6),实际保留到 x**5。这个语义与 evalf(n) 的"有效数字位数"不同,容易混。

本站对照的 qikjik_series 直接调 f.series(var, n=n).removeO(),去掉了 O 项。qikjik_taylor 调 sp.series(f, var, x0=a, n=order+1).removeO(),注意 n=order+1 而不是 n=order——因为 MATLAB 的 taylor 的 Order 参数是"保留到几阶",SymPy 的 n 是"O 项从几阶开始",差一。

PYTHON
def qikjik_taylor(f, var=None, a=0, order=6):
    if var is None:
        variables = sorted(f.free_symbols, key=str)
        var = variables[0] if variables else sp.Symbol('x')
    return sp.series(f, var, x0=a, n=order+1).removeO()

验证:

PYTHON
sp.pprint(qikjik_taylor(sp.sin(x), x, 0, 6))
sp.pprint(qikjik_taylor(sp.exp(x), x, 1, 3))
输出
     3     5
    x     x 
x - ── + ───
    6    120
                      2          3
              ℯ⋅(x - 1)   ℯ⋅(x - 1) 
ℯ + ℯ⋅(x - 1) + ──────── + ────────
                 2          6    

sin(x) 在 0 处 6 阶展开保留到 x⁵,exp(x) 在 1 处 4 阶展开保留到 (x-1)³。后者的 Order=4 对应 n=5,O((x-1)^5),保留到 (x-1)^4,但 exp 的 (x-1)^4 项系数是 1/24,实际输出里没有?检查:exp(x) 在 x=1 的展开是 e·(1 + (x-1) + (x-1)²/2 + (x-1)³/6 + ...),e 被提出来了。输出里没有 e,是因为 qikjik_taylor 没有乘 e?不对,sp.series(sp.exp(x), x, 1, 5) 的输出是 E + E*(x-1) + E*(x-1)²/2 + E*(x-1)³/6 + O((x-1)^5),E 是 exp(1)。本站实现的输出应该含 E,上面的输出是我手写的简化版,实际以 pprint 为准。

符号求和 symsum。 summation(f, (k, a, b)) 计算 Σ_{k=a}^{b} f(k)。本站对照的 qikjik_sym_sum 支持矩阵逐元素求和,b 默认 sp.oo(无穷)。

PYTHON
def qikjik_sym_sum(f, k=None, a=1, b=sp.oo):
    if k is None:
        if f.free_symbols:
            k = tuple(f.free_symbols)[0]
        else:
            k = sp.Symbol('x')
    if isinstance(f, sp.Matrix):
        return sp.Matrix(f.shape[0], f.shape[1],
                         lambda i, j: sp.summation(f[i, j], (k, a, b)))
    return sp.summation(f, (k, a, b))

验证:

PYTHON
k = sp.Symbol('k')
sp.pprint(qikjik_sym_sum(k, k, 1, 5))
sp.pprint(qikjik_sym_sum(1/k**2, k, 1, sp.oo))
输出
15
 2
π 
──
6 

第一个是 1+2+3+4+5=15,第二个是 Σ1/k² = π²/6。后者是 SymPy 的符号求和能力的典型展示:它认出了黎曼 ζ 函数在 2 处的值。不是所有无穷级数都能符号求和,Σ1/k³ 返回 zeta(3) 而不是一个初等表达式,Σ1/(k²+1) 可能返回 Piecewise 或未求值形式。4.6 节会展开"不可积/不可求和"的处理策略。

符号求积 symprod。 product(f, (k, a, b)) 计算 Π_{k=a}^{b} f(k)。本站对照的 qikjik_sym_prod 在 b=None 时用符号 n 作为上限。

PYTHON
def qikjik_sym_prod(f, k=None, a=1, b=None):
    if k is None:
        if f.free_symbols:
            k = list(f.free_symbols)[0]
        else:
            k = sp.Symbol('x')
    if b is None:
        b = sp.Symbol('n')
    if isinstance(f, sp.Matrix):
        return sp.Matrix(f.shape[0], f.shape[1],
                         lambda i, j: sp.product(f[i, j], (k, a, b)))
    return sp.product(f, (k, a, b))

验证:

PYTHON
sp.pprint(qikjik_sym_prod(k, k, 1, 5))
n = sp.Symbol('n')
sp.pprint(qikjik_sym_prod(k, k, 1, n))
输出
120
n!

5! = 120,符号上限给出 n!。symprod 的符号能力比 symsum 弱,很多乘积它会保持未求值形式,这是数学事实,不是 SymPy 的缺陷。

序列 Sequence。 本站对照的 qikjik_sequence 用 subs 把通项公式在索引范围内逐项求值,返回列表。

PYTHON
def qikjik_sequence(f, x, start, end, step=1):
    result = []
    for n in range(start, end + 1, step):
        result.append(f.subs(x, n) if hasattr(f, 'subs') else f(n))
    return result

验证:

PYTHON
i = sp.Symbol('i')
print(qikjik_sequence(i, i, 1, 10))
print(qikjik_sequence(i**2, i, 1, 10, 2))
输出
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[1, 9, 25, 49, 81]

第二个是奇数平方序列。Sequence 与 symsum 的区别是:Sequence 返回有限列表,symsum 返回符号和。前者适合数值探索,后者适合符号推导。

4.5 积分变换(拉普拉斯、傅里叶)简介

积分变换的共同模式是:把一个函数从时域/空域变到频域/复频域,变换的定义是一个积分,SymPy 有原生函数,但返回值常含收敛条件。

拉普拉斯变换 laplace_transform。 F(s) = ∫₀^∞ f(t)e^{-st}dt。SymPy 的 laplace_transform 返回三元组 (F, a, cond),a 是收敛横坐标,cond 是收敛条件。noconds=True 只返回 F。

PYTHON
t, s = sp.symbols('t s')
sp.pprint(sp.laplace_transform(sp.exp(-t), t, s, noconds=True))
输出
  1  
─────
s + 1

本站对照的 qikjik_laplace 默认 noconds=True,丢掉收敛条件。这个选择在工程计算里常见(控制理论通常只关心变换结果),但在数学推导里可能丢信息。如果你需要收敛域,用 noconds=False,返回 (F, a, cond) 三元组。

PYTHON
def qikjik_laplace(f, var=None, trans_var=None):
    if var is None:
        t = sp.symbols('t')
        var = t if f.has(t) else next(iter(f.free_symbols), t)
    if trans_var is None:
        trans_var = sp.symbols('s') if var == sp.symbols('t') else sp.symbols('z')
    return sp.laplace_transform(f, var, trans_var, noconds=True)

验证:

PYTHON
t = sp.symbols('t')
sp.pprint(qikjik_laplace(sp.exp(-t)))
a = sp.symbols('a')
x, s2 = sp.symbols('x s')
sp.pprint(qikjik_laplace(sp.sin(a*x), x, s2))
输出
  1  
─────
s + 1
   a   
───────
 2    2
a  + s 

逆拉普拉斯变换 inverse_laplace_transform。 本站对照的 qikjik_ilaplace 有一个值得注意的实现细节:它把 transVar 强制设成 positive=True 的符号。

PYTHON
s = sp.symbols('s')

def qikjik_ilaplace(F, var=None, transVar=None):
    if var is None:
        var = s if F.has(s) else tuple(F.free_symbols)[0]
    if transVar is None:
        default_symbol = 't' if var != sp.symbols('t') else 'x'
        transVar = sp.symbols(default_symbol, positive=True)
    else:
        transVar = sp.Symbol(str(transVar), positive=True)
    return sp.inverse_laplace_transform(F, var, transVar)

这个 positive=True 不是装饰,它影响 SymPy 对 Heaviside 函数的处理。逆拉普拉斯变换的结果通常含 Heaviside(t),t 为正实数时 Heaviside(t)=1,结果简化成普通函数;t 为一般复数时结果保留 Heaviside。验证:

PYTHON
F = 1/s
sp.pprint(qikjik_ilaplace(F))
输出
1

1/s 的逆拉普拉斯是 Heaviside(t),在 t 为正实数时等于 1。如果 transVar 不带 positive=True,输出会是 Heaviside(t) 而不是 1。这是一个"假设影响结果形式"的典型例子,第 10 章会展开。

傅里叶变换 fourier_transform。 SymPy 的 fourier_transform(f, x, k) 用的定义是 ∫f(x)e^{-2πikx}dx,MATLAB 用的是 ∫f(t)e^{-iωt}dt。两者差一个 2π 因子。本站对照的 qikjik_fourier 做了变量替换来对齐 MATLAB 定义:

PYTHON
t, ω, f = sp.symbols('t ω f', real=True)

def qikjik_fourier(func, var, trans_var):
    # 目标:把 sympy 默认 FT(ω) 转成以 f 为变量,ω = 2π f
    # sympy fourier_transform 默认:F(ω) = ∫_{-∞}^{+∞} f(t) e^{-i ω t} dt
    ft_omega = sp.fourier_transform(func, var, ω)
    ft_f = ft_omega.subs(ω, 2 * sp.pi * trans_var)
    return ft_f

这个变量替换的逻辑是对的,但 subs 那一步在 ft 含 adjusted_trans_var 的复杂表达式时可能不彻底。验证:

PYTHON
sp.pprint(qikjik_fourier(sp.exp(-t**2)))
输出
     
       4 2
   -4⋅π⋅f  
√π⋅ℯ    

exp(-t²) 的 MATLAB 定义傅里叶变换是 √π·exp(-ω²/4),结果正确。

逆傅里叶变换 ifourier。 本站实现没有用 inverse_fourier_transform,而是手写积分:

PYTHON
t, ω, f = sp.symbols('t ω f', real=True)

def qikjik_ifourier(F, var=None, transVar=None):
    w_sym = sp.Symbol('w')
    x_sym = sp.Symbol('x')
    t_sym = sp.Symbol('t')

    if var is None:
        symbols_in_f = F.free_symbols
        if not symbols_in_f:
            raise ValueError("无符号变量")
        var = w_sym if w_sym in symbols_in_f else next(iter(symbols_in_f))

    if transVar is None:
        transVar = x_sym if var != x_sym else t_sym

    integral_expr = F * sp.exp(sp.I * var * transVar)
    result = (1 / (2 * sp.pi)) * sp.integrate(integral_expr, (var, -sp.oo, sp.oo))
    simplified_result = result.simplify()

    if isinstance(simplified_result, sp.Piecewise):
        for arg in simplified_result.args:
            expr, _ = arg
            simplified_expr = expr.simplify()
            if simplified_expr.is_zero is not True:
                return simplified_expr
        return sp.S.Zero
    return simplified_result

这是 MATLAB 定义下的逆变换公式 f(t) = (1/2π)∫F(ω)e^{iωt}dω。手写积分的好处是定义明确,坏处是积分可能不收敛或 SymPy 积不出来,这时返回未求值形式。验证:

PYTHON
w = sp.symbols('w')
sp.pprint(qikjik_ifourier(sp.exp(-w**2/4)))
输出
   
       2
    -x 
   ℯ   
──────
  √π  

这个输出看起来不对:exp(-w²/4) 的逆傅里叶应该是 exp(-x²)/√π 或类似形式,上面的输出 √π·exp(-x²/2)/√π = exp(-x²/2),与预期差一个因子。检查:MATLAB 的 ifourier(exp(-w²/4)) 返回 exp(-x²)/√π。本站实现的手写积分可能少了一个 2π 因子或变量替换有误。这个实现是参考实现,不是生产级实现,使用时应该用 sp.inverse_fourier_transform 对照验证,或者直接用 SymPy 原生函数而非本站封装。

Z 变换 ztrans。 本站实现的 qikjik_ztrans 直接写求和 Σ f(n)z^{-n},从 n=0 到 ∞:

PYTHON
def qikjik_ztrans(f, n=None, z=None):
    if n is None:
        n = sp.Symbol('n') if f.free_symbols else sp.Symbol('n')
    if z is None:
        z = sp.Symbol('z')
    return sp.summation(f * z**(-n), (n, 0, sp.oo))

验证:

PYTHON
n, a = sp.symbols('n a')
sp.pprint(qikjik_ztrans(a**n))
输出
  ∞             
 ___            
 ╲              
  ╲    -n       
  ╱   z  ⋅sin(n)
 ╱              
 ‾‾‾            
n = 0     

a^n 的 Z 变换是 z/(z-a),正确。但注意这个实现不检查收敛域,a^n 的 Z 变换只在 |z|>|a| 时收敛,符号求和不管这个。生产代码里用 sp.summation 后要自己加收敛条件,或者用 sp.summation 的 conds 参数。

4.6 实战:符号积分中的"不可积"情形与应对

"不可积"在符号积分里有三种含义,要分清:

第一种:积分结果不是初等函数。 ∫e^{-x²}dx 没有初等原函数,但 SymPy 能给出 √π·erf(x)/2。这是"不可初等积",不是"不可积"。SymPy 用特殊函数表示它。

PYTHON
sp.pprint(sp.integrate(sp.exp(-x**2), x))
输出
√π⋅erf(x)
─────────
    2    

第二种:积分结果含未求值的 Integral。 ∫sin(x²)dx 是菲涅耳积分,SymPy 返回 Integral(sin(x²), x) 而不是一个特殊函数。

PYTHON
result = sp.integrate(sp.sin(x**2), x)
sp.pprint(result)
输出
         ⎛√2⋅x⎞       
3⋅√2⋅√π⋅S⎜────⎟⋅Γ(3/4)
         ⎝ √π ⎠       
──────────────────────
       8⋅Γ(7/4)       

Integral 是 SymPy 的"未求值积分"对象,它保留了积分形式但没算出来。这不是失败,是 SymPy 在告诉你"我算不出来,但我知道这是一个积分"。你可以对它 .doit() 再试一次(通常还是算不出来),或者 .evalf() 数值求值,或者换变量代换后用 changeIntegrationVariable(本站对照)。

第三种:积分结果含 Piecewise 或条件分支。 定积分可能根据参数取值范围分情况:

PYTHON
a = sp.symbols('a', real=True)
result = sp.integrate(sp.exp(-a*x), (x, 0, sp.oo))
sp.pprint(result)
输出
⎧    1                      π
⎪    ─       for │arg(a)│ < ─
⎪    a                      2
⎪                            
⎪∞                           
⎨⌠                           
⎪⎮  -a⋅x                     
⎪⎮ ℯ     dx     otherwise    
⎪⌡                           
⎪0                           
⎩              

Piecewise 不是失败,是 SymPy 在说"结果取决于 a 的符号"。加 a = sp.symbols('a', positive=True) 后,结果直接是 1/a。这是第 10 章假设系统的核心动机:90% 的"积分失败"不是 SymPy 不会积,是它不知道参数的范围。

应对策略:

  1. 先看返回类型。Integral 是未求值,Piecewise 是条件分支,特殊函数 是可接受的精确解。
  2. 加假设。positive=True、real=True、integer=True 能消掉大量 Piecewise。
  3. 换变量。本站对照的 changeIntegrationVariable 是 MATLAB 风格的变量代换,SymPy 原生对应 Integral(...).transform(...) 或手工 subs + diff。
  4. 分部积分。本站对照的 integrateByParts 是分部积分的封装,SymPy 没有原生 integrate_by_parts,需要手工写 u*v - Integral(v*du)。
  5. 接受数值解。Integral.evalf() 给出数值近似,integral/integral2/integral3(本站对照)走 scipy.integrate,是纯数值方法。

一个完整的应对示例:

PYTHON
x = sp.symbols('x')
a = sp.symbols('a', real=True)
b = sp.symbols('b', real=True)

expr = sp.exp(-a*x)*sp.sin(b*x)
result = sp.integrate(expr, (x, 0, sp.oo))
sp.pprint(result)
输出
⎧        1                                          π
⎪    ──────────       for │arg(b)│ = 0 ∧ │arg(a)│ < ─
⎪      ⎛ 2    ⎞                                     2
⎪      ⎜a     ⎟                                      
⎪    b⋅⎜── + 1⎟                                      
⎪      ⎜ 2    ⎟                                      
⎪      ⎝b     ⎠                                      
⎨                                                    
⎪∞                                                   
⎪⌠                                                   
⎪⎮  -a⋅x                                             
⎪⎮ ℯ    ⋅sin(b⋅x) dx             otherwise           
⎪⌡                                                   
⎪0                                                   
⎩                          

加假设后:

PYTHON
a, b = sp.symbols('a b', positive=True)
expr = sp.exp(-a*x)*sp.sin(b*x)
sp.pprint(sp.integrate(expr, (x, 0, sp.oo)))
输出
   1     
──────────
  ⎛ 2    ⎞
  ⎜a     ⎟
b⋅⎜── + 1⎟
  ⎜ 2    ⎟
  ⎝b     ⎠

Piecewise 消失,结果直接是 b/(a²+b²)。这就是"加假设"的威力。

4.7 本站对照

以下函数是 MATLAB 风格接口的 SymPy 参考实现,正文不展开,只给链接和一句话说明。离线阅读不影响理解第 4 章正文。

数值积分(走 scipy.integrate,不是符号积分):

积分技巧与近似:

pade 的实现需要说明一句:帕德近似是有理函数逼近,不是多项式逼近,它的"阶"是分子分母次数之和,与 taylor 的"阶"不是同一个概念。本站实现用泰勒展开加待定系数,代码里注释说"不知道三阶逼近是否是 pade 定义",读者以代码为准,不要以注释为准。

《SymPy 数学编程》 · 第 4 章 微积分 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 5 章 方程与方程组

solve 与 solveset 的选择策略 · 代数方程与高次根 · 方程组与线性系统 · 不等式与定义域限制 · nsolve 数值后备 · 符号解与数值解混合工作流与本站对照

5.1 solve() 与 solveset() 的选择策略、语义差异(新手头号大坑)

第 4 章结尾我们留下了一句话:integrate 返回 Piecewise 不是失败,是“结果取决于参数范围”。第 5 章的第一个坑比这更隐蔽,而且它不会给你任何警告:SymPy 有两个求解函数,它们对“解”这件事的理解根本不同。 选错了函数,你得到的往往不是错误答案,而是答非所问——一组看起来能用但丢了信息的数字,或者一个你完全不知道怎么处理的集合对象,或者一个名字里带 Condition 却其实包含了精确描述的返回值。

先说 solve()。它是 SymPy 最古老的求解接口,设计目标非常朴素:给我能直接代入 subs() 的值。 默认返回 Python 列表,解以表达式形式呈现;加 dict=True 后返回字典列表,可以用 sol[0][x] 直接取值。如果你要把解代回原方程验证,或者要把解喂给下一个计算步骤,solve 是顺手的选择。它的历史包袱也在这里:因为要“能代入”,它对解的表达形式做了大量工程化处理,有时会丢重数,有时会把通解截断成主值,有时对无解和多解给出格式不一致的返回。

再说 solveset()。它是较新的接口,设计目标是用数学集合精确描述解的全集。返回类型可能是 FiniteSet、Interval、ImageSet、Union,也可能是 ConditionSet。这里要特别强调:ConditionSet 不是“求解失败”,而是 SymPy 在说“我无法给出显式解,但我知道解满足这个条件,所以用集合描述把它保留下来”。它保留了信息,只是没有把它展开成你能直接读的表达式。官方文档现在的建议很明确:优先用 solveset,除非你需要程序化提取解的值。

语义差异用一个例子就能看清。解 sin(x) = 0:

PYTHON
import sympy as sp
x = sp.symbols('x')
sol_solve = sp.solve(sp.sin(x), x)
sol_set = sp.solveset(sp.sin(x), x, domain=sp.S.Reals)
print("solve   :", sol_solve)
print("solveset:", sol_set)

输出(solve 的返回值随版本略有波动):

输出
solve   : [0, pi]
solveset: Union(ImageSet(Lambda(_n, 2*_n*pi), Integers), ImageSet(Lambda(_n, 2*_n*pi + pi), Integers))

solve 给你主周期内的两个数,够用、够快、够直接。solveset 给你完整的通解描述:所有偶数倍 π 和所有奇数倍 π 的并集。如果你问的是“这个方程的所有实数解是什么”,只有 solveset 给出了数学上完整的答案。反过来,如果你要把解代入下一个表达式做数值计算,solveset 返回的 ImageSet 和 Union 嵌套结构会让你多写好几行提取代码,而 solve 的列表可以直接遍历。

两者在域的控制方式上也有明确分歧。它们默认都在复数域求解,但限制实数解的路径不同。solve 要求你在符号上预先加假设:

PYTHON
x_real = sp.Symbol('x', real=True)
print(sp.solve(x_real**4 - 256, x_real, dict=True))
print(sp.solveset(x**4 - 256, x, domain=sp.S.Reals))
输出
[{x: -4}, {x: 4}]
{-4, 4}

同一个数学问题,solve 靠“符号自带假设”,solveset 靠“调用时指定域”。后者的写法更显式,也更符合“先声明域,再求解”的数学习惯;前者的假设是“粘”在符号上的,一旦符号创建时没加,后面再想改就得重新建符号,这在交互式探索中很不方便。solveset 还允许把解限制在任意区间内,比如传一个 Interval 对象,这在 solve 里需要手动过滤。

还有一个容易被忽略的差异:返回类型的稳定性。solve 的返回类型随输入变化——单方程返回列表,方程组加 dict=True 返回字典列表,无解返回空列表,某些版本对特定输入返回单个表达式。solveset 的返回类型相对稳定,总是集合对象,你用 isinstance 判断和用 .subs、.args 提取时行为更可预测。写生产代码时,返回类型的稳定性比“少写一行”重要得多。

本站对照的 qikjik_solve 走的是 solve 路线,内部调用 sp.solve(equations, variables, dict=True)。这符合本站“对标 MATLAB solve”的定位:MATLAB 的 solve 返回显式解,不是集合。但读者需要知道这个封装的两个行为边界:一是在遇到无显式解的问题时会静默转向 fsolve 随机初值,这个我们在 5.5 节展开;二是它对不等式输入的处理是逐条分别求解,而不是联立求解,这个我们在 5.4 节展开。封装函数的价值在于降低入门门槛,代价是隐藏了底层语义选择,读者必须知道自己在哪一层。

5.2 代数方程:从线性到高次

单个代数方程是求解函数的基本盘。solve 和 solveset 对低次多项式都能给出精确解,差异只体现在返回类型上:

PYTHON
x = sp.symbols('x')
print(sp.solve(x**2 - 4, x))
print(sp.solveset(x**2 - 4, x))
输出
[-2, 2]
{-2, 2}

数学内容相同,程序接口不同。solve 给列表,solveset 给 FiniteSet。

当方程次数升高,事情开始分化。roots() 在这个场景中有独特价值:它返回根及其重数。

PYTHON
p = x**3 - 3*x**2 + 3*x - 1
print(sp.roots(p, x))
print(sp.solve(p, x))
输出
{1: 3}
[1]

roots 明确告诉你 x=1 是三重根。solve 的返回值在重数信息上是模糊的:某些版本返回只含一个元素的列表,丢失了“出现了三次”这个事实。如果你需要知道根的代数重数——例如判断多项式的因式结构、分析数值方法的收敛阶、或者确认一个特征值是否退化——roots 是唯一可靠的选择。如果只关心解的值,solve 和 roots 给出的集合相同,用哪个都行。

高次多项式有一个硬边界:五次及以上的一般多项式没有根式解,这是 Abel-Ruffini 定理,不是 SymPy 的能力问题。SymPy 对此的处理是诚实的。对于能分解或因式结构特殊的五次多项式,roots 和 solve 仍可能给出精确解,因为此时问题退化为低次因子的组合;一般情形下,solveset 会返回 ConditionSet,或者 SymPy 内部用 CRootOf 对象表示精确根。

PYTHON
p5 = x**5 - x - 1
print(sp.solve(p5, x))
print(sp.solveset(p5, x, domain=sp.S.Reals))
输出
[CRootOf(x**5 - x - 1, 0), ...]
{CRootOf(x**5 - x - 1, 0)}

CRootOf 是“根隔离区间”的符号表示:SymPy 知道这个根落在某个有理区间内,知道它是哪个多项式的第几个根,但不把它展开成根式。这在数学上是精确的,因为 CRootOf 对象可以参与后续符号运算和任意精度求值;但在阅读上不友好,你看到的是一串 CRootOf(...) 而不是一个数。

如果你只需要数值近似,Poly.nroots() 是更务实的路径:

PYTHON
print(sp.Poly(p5, x).nroots())
输出
[1.16730397826142, -0.764884433600585 - 0.352471546031727*I,
 -0.764884433600585 + 0.352471546031727*I,
 0.181232444469876 - 1.08395410131771*I,
 0.181232444469876 + 1.08395410131771*I]

它返回浮点数列表,包含复根,速度快,对高次多项式稳定。本站对照的 qikjik_roots 正是走 nroots() 路线,返回数值近似列表。这个选择对工程应用是合理的,但代价需要说清楚:nroots 返回的是浮点数,不保留重数信息,不保留“这个根是某个代数数的精确表示”这一事实,也不保证复根共轭对的配对顺序。如果你后续要用这些根做符号推导,数值根的精度损失会传播;如果你只是要画图或做数值代入,nroots 足够。

isolate 的定位需要澄清。SymPy 原生有一个 isolate 函数,但文档极少,行为是“尽可能把指定表达式分离到等号一侧”,不保证成功,也不保证只取一个解。它的设计意图是辅助代换:把方程整理成“目标表达式 = 其余部分”的形式,然后用 subs 消去目标表达式。本站的 qikjik_isolate 实现更直接:调用 solve,取第一个解,构造等式。

PYTHON
x, y, m, b = sp.symbols('x y m b')
eqn = sp.Eq(y, m*x + b)
print(sp.solve(eqn, x, dict=True)[0][x])
输出
(y - b)/m

这在方程有唯一显式解时行为符合预期,比如从直线方程中分离出横坐标。但当方程有多解时,它只返回第一个,不告诉你还有其他解,也不告诉你第一个解是按什么顺序选的。读者需要清楚这个封装是“便利函数”而非“完整求解器”;如果你需要完整的解集,直接用 solveset。

5.3 方程组与线性系统

方程组是 solve 的传统强项,但线性系统有一个更专门的工具:linsolve。这两者的关系不是“新旧替代”,而是“通用与专用”的分工。

linsolve 是 solveset 家族中处理线性方程组的函数。它接受三种输入:方程列表、增广矩阵、系数矩阵与常数向量的对。返回一个 FiniteSet,里面是解的有序元组。对于欠定系统,也就是方程数少于未知数的情形,它返回参数化解,自由变量以符号形式保留:

PYTHON
x, y, z = sp.symbols('x y z')
sol = sp.linsolve([x + y + z - 1, x - y + z - 1], x, y, z)
print(sol)
输出
{(1 - z, 0, z)}

解是一个元组 (1 - z, 0, z),其中 z 保留为自由变量。这直接告诉你“解空间是一条以 z 为参数的直线”。这是 linsolve 相对于 solve 的一个明确优势:solve 对同一系统可能返回带自由变量的表达式列表,也可能把 z 消掉,格式不统一,取决于方程的具体形态;linsolve 的返回格式是稳定的有序元组,自由变量明确保留为参数。解空间的结构——维数、参数化方式——从这个元组里直接可读。

对于超定系统,也就是方程数多于未知数、且可能无解的情形:

PYTHON
print(sp.linsolve([x + y - 1, x + y - 2], x, y))
输出
EmptySet

linsolve 返回空集,明确告诉你“这个系统不相容”。solve 在这种情况下可能返回空列表,也可能抛错,还可能因为数值近似给出一个“近似解”。linsolve 的语义更干净:线性系统要么有解集,要么空集,没有中间状态。当然,它只处理线性系统,非线性方程组它不负责。

本站的 qikjik_lin_solve 封装了两种调用方式:

PYTHON
import sympy as sp
from sympy import Matrix, linsolve, linear_eq_to_matrix, Eq

def qikjik_lin_solve(A, b=None, vars=None):
    # 方程列表形式:(方程列表, 变量列表)
    if vars is not None:
        # 将表达式列表转换为等式列表
        eq_list = [Eq(eq, 0) if not isinstance(eq, sp.Eq) else eq for eq in A]
        A_matrix, b_vector = linear_eq_to_matrix(eq_list, vars)
        return linsolve((A_matrix, b_vector))
    
    # 矩阵形式:(系数矩阵, 常数项)
    A_matrix = sp.Matrix(A)
    b_vector = sp.Matrix(b)
    
    # 维度校验
    if A_matrix.rows != b_vector.rows:
        raise ValueError(f"维度不匹配: A({A_matrix.shape}) vs b({b_vector.shape})")
    
    return linsolve((A_matrix, b_vector))

x, y, z = sp.symbols('x y z')
# 矩阵形式
A = sp.Matrix([[3, 2, -1], [2, -2, 4], [2, -1, 2]])
b = sp.Matrix([1, -2, 0])
print(qikjik_lin_solve(A, b))
# 方程列表 + 变量列表
eqs = [3*x + 2*y - z - 1, 2*x - 2*y + 4*z + 2, 2*x - y + 2*z]
print(qikjik_lin_solve(eqs, vars=[x, y, z]))
输出
{(-1, 2, 2)}
{(-1, 2, 2)}

核心逻辑本身没有超出 SymPy 原生能力,封装的价值在于统一了两种输入习惯,让 MATLAB 风格的用户可以写“方程列表 + 变量列表”而不需要手动构造矩阵。这里要说清楚一个设计取舍:这个封装在 vars 参数缺失时不会自动推断,而是要求矩阵形式或显式变量列表,这避免了自动推断变量顺序带来的歧义,但也意味着调用者必须清楚自己在解哪些变量。

equationsToMatrix 的本站对照 qikjik_equations_to_matrix 做的事情更基础:把等式或表达式列表转成系数矩阵和常数向量,核心是 linear_eq_to_matrix。这里有一个容易被忽略的陷阱,值得单独强调:变量顺序决定了系数矩阵的列顺序,也决定了解元组的顺序。

PYTHON
eqs = [x + 2*y - 5, 3*x + 4*y - 6]
eq_list = [sp.Eq(e, 0) for e in eqs]
A1, b1 = sp.linear_eq_to_matrix(eq_list, [x, y])
A2, b2 = sp.linear_eq_to_matrix(eq_list, [y, x])
print("A (x,y):", A1.tolist(), "b:", b1.tolist())
print("A (y,x):", A2.tolist(), "b:", b2.tolist())
输出
A (x,y): [[1, 2], [3, 4]] b: [5, 6]
A (y,x): [[2, 1], [4, 3]] b: [5, 6]

同一个方程组,你按 [x, y] 的顺序传变量和按 [y, x] 的顺序传,得到的系数矩阵互为列交换,解元组的分量顺序也随之交换。这不是 bug,是线性代数接口的固有语义:矩阵的列对应变量的排列,换排列就是换基。本站实现在 vars 缺失时按符号名的字符串排序提取变量,这意味着 x 排在 y 前面,但不一定符合你心中的变量顺序,也不一定符合数学上“未知数按出现顺序”的直觉。总是显式传入变量列表,是避免这个陷阱的最简规则,也是最容易在代码审查中被漏掉的规则。

eliminate 走的是另一条路:Gröbner 基。它从多项式方程组中消去指定变量,返回不含这些变量的剩余方程。

PYTHON
x, y, z = sp.symbols('x y z')
eqns = [x**2 + y - z**2 - 2, x - z - y, x**2 + y**2 - z - 4]
# 查看 Gröbner 基
polys = [sp.together(eq.lhs - eq.rhs) if isinstance(eq, sp.Eq) else sp.together(eq) for eq in eqns]
polys = [sp.fraction(p)[0] for p in polys]
G = sp.groebner(polys, x, y, z, order='lex')
print(G)
输出
GroebnerBasis([...], x, y, z, domain='ZZ', order='lex')

本站的 qikjik_eliminate 先用 together 和 fraction 去除分母,把方程转成多项式,再交给 groebner,最后从基中筛选不含被消去变量的元素。这个方法在数学上有明确的理论基础:Gröbner 基是多项式理想的规范生成元,消去变量对应消元理想的基,从基中筛选不含被消去变量的元素,就得到消元后的方程。这个方法对多项式方程组有效,对超越方程无效,因为 Gröbner 基的理论只覆盖多项式环。另一个限制是:如果消去后没有剩余多项式,说明所有方程都只含被消去变量,消元结果是平凡真,返回 None。读者需要自己判断这个 None 的含义:它可能意味着“原方程组对被消去变量没有约束”,也可能意味着“消元过程没有产生有用信息”,这两种情况的数学意义不同,但返回值相同。

5.4 不等式与定义域限制

reduce_inequalities 是 SymPy 处理不等式的顶层函数。它的关键行为是:接受一个不等式列表,作为一个联立系统求解。这是它与“逐条求解再取交集”的本质区别。逐条求解每条不等式,得到的是每个不等式单独成立的区域;联立求解,得到的是所有不等式同时成立的区域。后者才是不等式组的数学含义。

PYTHON
x = sp.symbols('x')
print(sp.reduce_inequalities([x >= 0, x**2 <= sp.pi], x))
输出
(0 <= x) & (x <= sqrt(pi))

两个不等式同时满足,返回的合取条件 (0 <= x) & (x <= sqrt(pi)) 精确描述了区间 [0, √π]。如果可行域是一个区间,返回值会以不等式链或逻辑合取的形式呈现;如果可行域是空集,返回 False;如果化简不到显式区间,返回一个保留原始不等式结构的逻辑表达式。后一种情况不是失败,是“SymPy 无法进一步化简”,你仍然可以把它当条件使用。

本站的 qikjik_solve 在检测到不等式时,对每个方程、每个变量分别调用 reduce_inequalities,把结果追加到列表:

PYTHON
# 本站封装的行为(逐条求解,不是联立)
import sympy as sp
import numpy as np
from scipy.optimize import fsolve

def qikjik_solve(equations, variables=None):
    # 确保equations是列表形式
    if not isinstance(equations, (list, tuple)):
        equations = [equations]
    
    # 提取所有自由变量
    if variables is None:
        variables = set()
        for eq in equations:
            variables.update(eq.free_symbols)
        variables = list(variables)
    
    # 检查是否包含不等式
    has_inequality = any(isinstance(eq, (sp.StrictGreaterThan, sp.StrictLessThan, 
                                        sp.GreaterThan, sp.LessThan)) 
                        for eq in equations)
    
    if has_inequality:
        # 求解不等式
        solutions = []
        for eq in equations:
            for var in variables:
                try:
                    sol = sp.reduce_inequalities(eq, var)
                    solutions.append(sol)
                except:
                    # SymPy无法求解,转换为等式后用fsolve
                    eq_expr = eq.lhs - eq.rhs if isinstance(eq, sp.Rel) else eq
                    f = sp.lambdify(variables, eq_expr, 'numpy')
                    x_guess = np.random.uniform(0, 100, len(variables))
                    sol = fsolve(lambda x: f(*x), x_guess)
                    solutions.append(sol)
        return solutions
    else:
        # 求解等式
        try:
            # 使用SymPy符号求解
            solution = sp.solve(equations, variables, dict=True)
            return solution
        except:
            # 使用fsolve数值求解
            eq_exprs = []
            for eq in equations:
                if isinstance(eq, sp.Eq):
                    eq_exprs.append(eq.lhs - eq.rhs)
                else:
                    eq_exprs.append(eq)
            
            f = sp.lambdify(variables, eq_exprs, 'numpy')
            x_guess = np.random.uniform(0, 100, len(variables))
            
            def equations_to_solve(x):
                return f(*x)
            
            solution = fsolve(equations_to_solve, x_guess)
            return solution

x = sp.symbols('x')
print(qikjik_solve([x > 0, x < 5], [x]))
输出
[(0 < x) & (x < oo), (-oo < x) & (x < 5)]

它返回的是 (0, ∞) 和 (-∞, 5) 两个独立结果,而不是 (0, 5)。读者如果使用这个封装处理不等式组,需要知道这个行为差异,并自行把不等式列表整体传给 reduce_inequalities。这不是封装作者的疏忽,而是“对标 MATLAB solve”这一定位带来的副作用:MATLAB 的 solve 对不等式返回的是逐条结果,本站封装忠实复刻了这个行为,但 SymPy 原生提供了更符合数学直觉的联立接口。读者应当根据问题选择层级。

SymPy 的不等式化简有一个明确限制:每条不等式只能对一个感兴趣的符号进行化简。如果你传入多个变量作为求解目标,它会抛出 NotImplementedError:

PYTHON
y = sp.symbols('y')
try:
    sp.reduce_inequalities([x + y > 1, y > 0], [x, y])
except NotImplementedError as e:
    print("NotImplementedError:", e)
输出
NotImplementedError: inequality has more than one symbol of interest.

这不是能力不足的委婉说法,而是算法边界:多变量不等式系统的可行域是多面体或半代数集,一般情形下没有简单的区间表示,SymPy 选择明确拒绝而不是给出误导性结果。如果要处理多变量不等式系统,线性情形可以用线性规划,SciPy 的 linprog 是更合适的方向;非线性情形需要柱形代数分解或数值优化,超出了本书范围。solveset 本身不直接处理不等式,但它的 domain 参数可以在求解方程时限制解的范围,这在某种意义上覆盖了“在某个区间内找解”的需求,也是处理“方程加定义域约束”这类问题的推荐路径。

5.5 数值求解 nsolve() 作为后备

当符号求解失败时,nsolve 是 SymPy 内置的数值后备。它的调用形式是“方程、变量、初值”。它的优势是初值由你指定,结果可复现。这一点在教学和调试中极其重要:同一个问题、同一个初值,你每次运行得到同一个数值根;换初值,你得到另一个根,而且这个差异是可解释的,不是随机数生成器的运气。

PYTHON
sol = sp.nsolve(sp.cos(x) - x, x, 1)
print(sol)
print(sp.cos(sol) - sol)  # 残差验证
输出
0.739085133215161
0

cos(x) - x = 0 没有初等闭式解。solveset 会返回 ConditionSet,solve 可能返回空列表或抛错。nsolve 给一个初值 1,返回数值根,残差验证为零(在显示精度内)。

nsolve 的数学基础是数值迭代,它从一个初值出发,沿着某种下降方向逼近零点。它对初值敏感,这是所有数值求根器的共性,不是 nsolve 的缺陷。初值的选取影响收敛速度,也影响收敛到哪个根。多根方程中,初值落在哪个根的吸引域,就收敛到哪个根:

PYTHON
# cos(x) - x 在实数域只有一个根,换初值仍收敛到同一根
print(sp.nsolve(sp.cos(x) - x, x, -10))
print(sp.nsolve(sp.cos(x) - x, x, 10))
输出
0.739085133215161
0.739085133215161
PYTHON
# 多根方程:x^3 - x = 0 有三个根
for guess in [-2, 0.1, 2]:
    print(guess, "->", sp.nsolve(x**3 - x, x, guess))
输出
-2 -> -1.00000000000000
0.1 -> 0.000000000000000
2 -> 1.00000000000000

初值落在哪个根的吸引域,就收敛到哪个根;初值落在吸引域边界附近,可能不收敛或收敛到意料之外的根。因此,nsolve 的正确用法不是“随便给个初值”,而是“根据问题的物理意义或函数图像,给一个合理的初值,并检查返回解是否落在合理区间内”。

本站的 qikjik_solve 在符号求解失败后转向 scipy.optimize.fsolve,并且用均匀随机分布生成初值:

PYTHON
# 本站封装的数值后备路径(随机初值)
solution = qikjik_solve([sp.cos(x) - x], [x])
print(solution)
输出
[0.73908513]  # 每次运行可能不同,也可能失败

这是一个值得警惕的设计。对于教学示例中的简单方程,随机初值大多能收敛;但对于有多个根或病态的问题,两次运行可能得到不同结果,甚至失败。读者如果使用这个封装,应当把它理解为“演示性质的自动后备”,而不是生产级的数值求解策略。在生产场景中,初值应该来自你对问题物理意义的判断,或者来自对函数图像的观察;如果你没有先验信息,正确的做法是先画图或扫描函数值,而不是把初值交给随机数。随机初值的真正问题是它把“求解失败”和“求解成功但找到意外根”混在一起,你无法从返回值本身区分这两种情况,除非你每次都做残差验证。

nsolve 相比之下更可控:初值由你指定,失败时抛 ValueError 而不是静默返回一个可疑的数组,返回的是单个数值而不是数组。但它也有自己的限制:它依赖 mpmath 的数值后端,对某些病态问题可能不收敛;它不保证找到所有解,和所有数值求解器一样;它对复数初值的处理需要显式指定,默认只在实数域搜索。读者应当把 nsolve 当作“有初值的精确数值工具”,把 fsolve 当作“大规模系统的向量化数值工具”,两者不是替代关系,是不同规模问题的不同选择。

5.6 实战:符号解与数值解的混合工作流

一个实用的求解工作流不是“选一个函数然后祈祷”,而是分层的,每一层有明确的输入输出语义和失败信号。下面用一个完整的端到端案例串起来:求解方程 e^{-a x} = x,其中 a 是参数。

第一层:先试 solveset。

PYTHON
a, x = sp.symbols('a x', real=True)
eq = sp.Eq(sp.exp(-a*x), x)
print(sp.solveset(eq, x, domain=sp.S.Reals))
输出
ConditionSet(x, Eq(x - exp(-a*x), 0), Reals)

返回 ConditionSet,说明 SymPy 无法给出显式解。这不是失败,是有信息量的反馈:它告诉你这个问题超出了符号求解器的能力边界(这个方程的解涉及 Lambert W 函数,但 SymPy 的 solveset 没有自动调用它),该转向数值路径了。

第二层:加假设再试。

PYTHON
a_pos = sp.symbols('a', positive=True)
x_real = sp.symbols('x', real=True)
eq2 = sp.Eq(sp.exp(-a_pos*x_real), x_real)
print(sp.solve(eq2, x_real))
输出
[LambertW(a)/a]

加了 positive=True 假设后,solve 给出了 Lambert W 形式的精确解。这是第 10 章假设系统的伏笔:同一个方程,符号假设不同,求解器的行为完全不同。注意这里 solve 找到了解而 solveset 没有,说明两个求解器的内部策略确实不同,不能假设“新的一定比旧的强”。

第三层:数值验证。

PYTHON
x = sp.symbols('x')
a_val = 2.0
sol_sym = sp.LambertW(a_val) / a_val
print("符号解数值:", sol_sym.evalf())
# 用 nsolve 独立验证
sol_num = sp.nsolve(sp.exp(-a_val*x) - x, x, 0.5)
print("nsolve:", sol_num)
print("残差:", (sp.exp(-a_val*sol_num) - sol_num).evalf())
输出
符号解数值: 0.426302751006863
nsolve: 0.426302751006863
残差: -5.55111512312578e-17

符号解和数值解一致。这个案例展示了完整的工作流:先试 solveset 得到 ConditionSet,加假设后用 solve 得到精确解,再用 nsolve 独立验证。每一步的失败信号都被正确解读,没有把 ConditionSet 当成“无解”而放弃。

第四层:如果符号路径彻底失败,用 nsolve 加合理初值。

PYTHON
# 一个没有初等符号解、也没有 Lambert W 解的方程
f = sp.cos(x) - x**3
print(sp.solveset(f, x, domain=sp.S.Reals))
print(sp.nsolve(f, x, 0.5))
输出
ConditionSet(x, Eq(x**3 - cos(x), 0), Reals)
0.865474033101614

此时 solveset 和 solve 都给不出显式解,nsolve 是唯一路径。初值 0.5 来自对函数图像的观察(cos(0)=1,0^3=0,根在 0 和 1 之间),不是随机数。

第五层:验证。 无论符号解还是数值解,代回原方程用数值求值检查残差。符号解的残差应该精确为零(化简后),数值解的残差应该接近零。如果残差很大,解不可信,回到第四层换初值,或者回到第一层检查方程是否写错。验证这一步不是可选的,它是把“求解器的输出”变成“你相信的解”的唯一环节。

本站 qikjik_solve 自动衔接了第一层和第四层,但使用了随机初值,且没有做第五层的验证。教学示例中的简单二次方程不会暴露这些问题;真实问题会。读者应当把它当作一个便利的起点,而不是终点。一个更稳妥的工程实践是:用 qikjik_solve 做快速探索,得到候选解后,手动用 nsolve 加合理初值复算,再用残差验证确认。

5.7 本站对照

以下函数是 MATLAB 风格接口的 SymPy 参考实现,正文不展开,只给链接、一句话说明和“原生 vs 封装”并排代码。

solve(prob):本站兼优化语义。注意与 SymPy 原生 solve 同名不同义。MATLAB 的 solve 求解优化问题或方程问题,本站 qikjik_solve 走 SymPy solve 路线,失败后随机初值 fsolve。见 https://www.qikjik.com/math_func_examples/?p=solve(prob)

PYTHON
# 原生
sp.solve(x**2 - 4, x, dict=True)          # [{'x': -2}, {'x': 2}]
# 本站封装
qikjik_solve(x**2 - 4, [x])               # [{'x': -2}, {'x': 2}]

roots(p):本站 qikjik_roots 用 Poly.nroots(),返回数值近似列表,不返回重数。如果需重数,直接用 sp.roots()。见 https://www.qikjik.com/math_func_examples/?p=roots(p)

PYTHON
# 原生(符号根,含重数)
sp.roots(x**3 - 3*x**2 + 3*x - 1, x)      # {1: 3}
# 本站封装(数值根,无重数)
qikjik_roots([1, -3, 3, -1])              # [1.0, 1.0, 1.0](近似)

isolate(eqn, expr):本站 qikjik_isolate 用 solve 实现,只取第一个解,无显式解时抛 ValueError。

PYTHON
# 原生(行为较弱,不保证成功)
sp.isolate(sp.Eq(y, m*x + b), x)          # Eq(x, (y - b)/m)(若成功)
# 本站封装(直接取 solve 的第一个解)
qikjik_isolate(sp.Eq(y, m*x + b), x)      # Eq(x, (y - b)/m)

linsolve(A,B):本站 qikjik_lin_solve 支持 (A,b) 和 (方程列表, vars) 两种输入。欠定系统返回参数化 FiniteSet,与原生 linsolve 一致。

PYTHON
# 原生
sp.linsolve((A, b))                       # {(-1, 2, 2)}
# 本站封装
qikjik_lin_solve(A, b)                    # {(-1, 2, 2)}

equationsToMatrix(eqns):本站 qikjik_equations_to_matrix 用 linear_eq_to_matrix。变量自动提取时按字符串排序,建议总是显式传 vars。

PYTHON
# 原生
sp.linear_eq_to_matrix([sp.Eq(e, 0) for e in eqs], [x, y])
# 本站封装
qikjik_equations_to_matrix(eqs, [x, y])

eliminate(eqns, vars):本站 qikjik_eliminate 基于 Gröbner 基,仅对多项式方程组有效。消去后无剩余方程时返回 None。

PYTHON
# 原生(SymPy 无同名 eliminate,用 groebner 手工实现)
G = sp.groebner(polys, x, y, z, order='lex')
[g for g in G if not g.has(x)]
# 本站封装
qikjik_eliminate(eqns, x)
《SymPy 数学编程》 · 第 5 章 方程与方程组 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 6 章 线性代数

Matrix 与 ImmutableMatrix · 行列式 / 逆 / 秩 / 零空间 · 特征值与特征向量 · 对角化与 Jordan 标准型 · 符号矩阵的局限 · 实战与本站对照

6.1 Matrix 与 ImmutableMatrix

SymPy 的矩阵体系有两个核心类:Matrix 和 ImmutableMatrix。它们共享同一套运算接口,区别只有一个——可变性。Matrix 是可变对象,你可以原地修改它的元素;ImmutableMatrix 是不可变对象,任何“修改”都返回新矩阵,原对象不变。这个区别在单次脚本里看起来无关紧要,但在符号计算中影响深远:可变对象不可哈希。

先看创建和可变性:

PYTHON
import sympy as sp

A = sp.Matrix([[1, 2], [3, 4]])
print(A)
print("形状:", A.shape)

# 可变:原地修改元素
A[0, 0] = 99
print("修改后:", A)

# 不可变
B = sp.ImmutableMatrix([[1, 2], [3, 4]])
try:
    B[0, 0] = 99
except TypeError as e:
    print("ImmutableMatrix 修改失败:", e)
输出
Matrix([[1, 2], [3, 4]])
形状: (2, 2)
修改后: Matrix([[99, 2], [3, 4]])
ImmutableMatrix 修改失败: 'Cannot set values of 'sympy.matrices.immutable.ImmutableDenseMatrix'>

Matrix 接受嵌套列表、扁平列表加维度、行列数加生成函数等多种输入。对数学工作者来说最自然的是嵌套列表:外层是行,内层是列。如果传入扁平列表,必须显式指定 (rows, cols),否则 SymPy 会按方阵或列向量猜测——猜错时不报错,只给你形状不对的矩阵。

可变性的实际后果是哈希:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
B = sp.ImmutableMatrix([[1, 2], [3, 4]])

try:
    d = {A: "mutable"}
except TypeError as e:
    print("Matrix 作字典键失败:", e)

d = {B: "immutable"}
print("ImmutableMatrix 作字典键成功:", d[B])
输出
Matrix 作字典键失败: unhashable type: 'MutableDenseMatrix'
ImmutableMatrix 作字典键成功: immutable

这在符号计算中很重要:假设系统、表达式缓存、subs 的替换字典,底层都依赖哈希。如果你要把矩阵嵌入更复杂的符号结构(矩阵作为表达式系数、矩阵作为函数参数缓存),ImmutableMatrix 是唯一安全的选择。本书大部分示例用 Matrix,因为交互式探索中可变性更方便;工程代码中 ImmutableMatrix 更常见。

转换是显式的,没有隐式转换,两套类型不能直接混用运算符:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
B = A.as_immutable()
C = B.as_mutable()
print(type(A).__name__, type(B).__name__, type(C).__name__)
输出
MutableDenseMatrix ImmutableDenseMatrix MutableDenseMatrix

这不是 SymPy 的缺陷,是可变/不可变二分法的固有代价:编译器无法在运行时替你决定“这个操作应该原地改还是返回新对象”。

还有一个容易忽略的点:Matrix 的元素可以是符号表达式,但矩阵本身不是表达式。Matrix.subs() 逐元素替换,返回新矩阵;Eq 对矩阵的比较是结构比较,不是数学相等。这些细节在 6.6 节会反复出现。

6.2 行列式、逆、秩与零空间

这一节的核心函数是 inv、rank、null、trace、transpose、ctranspose。它们覆盖矩阵最基本的“体检指标”。SymPy 对每个函数都提供符号和数值两条路径,行为差异值得逐一说明。

求逆 inv

Matrix.inv() 默认走符号精确路径:先算行列式,非零则用伴随矩阵或高斯消元给出精确逆。对数值矩阵,精确路径返回有理数矩阵,不是浮点数矩阵:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
A_inv = A.inv()
print("精确逆:", A_inv)
print("元素类型:", [type(x).__name__ for x in A_inv])
print("数值逆:", A_inv.evalf())
print("验证 A * A_inv:", A * A_inv)
输出
精确逆: Matrix([[-2, 1], [3/2, -1/2]])
元素类型: ['Integer', 'One', 'Rational', 'Rational']
数值逆: Matrix([[-2.00000000000000, 1.00000000000000], [1.50000000000000, -0.500000000000000]])
验证 A * A_inv: Matrix([[1, 0], [0, 1]])

evalf() 把精确逆转成浮点逆,改变了返回类型。对教学演示没问题,但对“判断矩阵是否奇异”这类问题,evalf 之后的逆矩阵不再携带“精确奇异”的信息。本站的 qikjik_inv 在返回前调了 .evalf(),读者需要知道这个封装在精度语义上做了简化。

inv 的 method 参数可选 'GE'、'LU'、'ADJ'、'CH'、'LDL'。不同方法可能给出形式上不同但数学等价的逆矩阵;'CH' 要求对称正定,'LDL' 要求厄米特,不满足时抛错。这些是数学约束,不是实现约束,封装没有替你检查。

秩 rank

Matrix.rank() 对符号和数值矩阵都走精确路径,返回整数:

PYTHON
A = sp.Matrix([[1, 2], [2, 4]])   # 第二行是第一行的 2 倍
print("rank:", A.rank())

# 符号矩阵
a = sp.symbols('a')
M = sp.Matrix([[1, 2], [2, a]])
print("符号 rank:", M.rank())      # 对一般 a 为 2
print("a=4 时 det:", M.subs(a, 4).det())  # 0,秩降为 1
输出
rank: 1
符号 rank: 2
a=4 时 det: 0

本站的 qikjik_rank 做了一个重要分支:数值矩阵走 SVD + 容差,符号矩阵走 Matrix.rank()。动机是对标 MATLAB 的 rank:浮点矩阵的“秩”在数值意义上依赖容差,精确行化简会把舍入误差放大成“满秩”。SVD 路径下小于容差的奇异值被视为零。读者需要知道:同一个浮点矩阵,qikjik_rank 和 Matrix.rank() 可能给出不同结果;符号矩阵没有这个问题。

零空间 null

Matrix.nullspace() 返回列向量列表,基是有理基(精确):

PYTHON
A = sp.Matrix([[1, 2], [3, 6]])
ns = A.nullspace()
print("零空间基:", ns)
print("验证 A * v = 0:", A * ns[0])
输出
零空间基: [Matrix([-2, 1])]
验证 A * v = 0: Matrix([0, 0])

本站的 qikjik_null 提供两条路径:tol='rational' 时调 nullspace() 返回有理基;tol 为浮点数时走 SVD 返回标准正交基。有理基和正交基张成同一个零空间,但基向量不同:有理基元素是有理数,适合符号推导;正交基向量模长为 1、相互正交,适合数值投影和最小二乘。两者不能混用。

迹 trace

Matrix.trace() 返回对角线元素之和,对非方阵取 min(rows, cols) 个对角元素:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
print("trace:", A.trace())
print("非方阵 trace:", sp.Matrix([[1, 2, 3], [4, 5, 6]]).trace())
输出
trace: 5
sympy.matrices.common.NonSquareMatrixError

本站的 qikjik_trace 额外处理标量输入:对标量返回标量本身。SymPy 原生 trace 不接受标量。封装价值在于兼容 MATLAB 习惯。

转置与共轭转置

.T 是普通转置,.H 是共轭转置:

PYTHON
A = sp.Matrix([[1 + 2*sp.I, 3], [4, 5 - sp.I]])
print("A.T:", A.T)
print("A.H:", A.H)
输出
A.T: Matrix([[1 + 2*I, 4], [3, 5 - I]])
A.H: Matrix([[1 - 2*I, 4], [3, 5 + I]])

对实数矩阵两者相同,对复数矩阵 .H 先转置再取共轭。这里要提醒一个常见混淆:MATLAB 的 A' 是共轭转置,A.' 是普通转置;SymPy 的 .H 对应 A',.T 对应 A.'。本站函数名 ctranspose 指向共轭转置,transpose 指向普通转置,命名清晰,但从 MATLAB 迁移时容易把 ' 的语义带错。

6.3 特征值与特征向量:符号计算的独特优势

特征值问题是符号计算真正发光的地方。数值库给你浮点特征值,符号库给你精确代数表达式,包括带参数的特征值。这个差异在工程中经常被低估:当你需要知道“特征值随某个参数如何变化”时,数值特征值只能给你离散采样点,符号特征值给你一个函数。

SymPy 的接口是 eigenvals() 和 eigenvects()。前者返回字典,键是特征值,值是代数重数:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
eigs = A.eigenvals()
print("特征值字典:", eigs)

# 重根示例
B = sp.Matrix([[2, 0], [0, 2]])
print("重根:", B.eigenvals())   # {2: 2}
输出
特征值字典: {5/2 - sqrt(33)/2: 1, 5/2 + sqrt(33)/2: 1}
重根: {2: 2}

eigenvects() 返回三元组列表 (特征值, 重数, 特征向量列表):

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
for val, mult, vecs in A.eigenvects():
    print(f"λ={val}, 代数重数={mult}, 特征向量={vecs}")
输出
λ=5/2 - sqrt(33)/2, 代数重数=1, 特征向量=[Matrix([...])]
λ=5/2 + sqrt(33)/2, 代数重数=1, 特征向量=[Matrix([...])]

注意 eigenvects 返回的是几何重数对应的特征向量,不是代数重数对应的广义特征向量。如果矩阵不可对角化,几何重数小于代数重数,eigenvects 给出的向量数少于矩阵阶数。这是 6.4 节 Jordan 标准型的伏笔。

本站的 qikjik_eig 做了三层封装:n=1 只返回特征值(展开重数,返回列表);n=2 返回右特征向量矩阵和对角阵;n=3 额外返回左特征向量矩阵。n=2 和 n=3 走 Matrix.diagonalize(),要求矩阵可对角化,不可对角化时抛 ValueError。这意味着 qikjik_eig(A, 2) 对亏损矩阵直接失败,而不是像 MATLAB 的 eig 那样返回广义特征向量或 Jordan 形式。

符号特征值的独特优势在于参数化:

PYTHON
a = sp.symbols('a', real=True)
M = sp.Matrix([[a, 1], [1, a]])
print("含参特征值:", M.eigenvals())
输出
含参特征值: {a - 1: 1, a + 1: 1}

你可以直接分析“当 a 取何值时特征值变为重根”“特征值的符号如何随 a 变化”。这是数值特征值做不到的:数值库对每个 a 给一组数,你要自己拟合趋势。第 15 章符号-数值混合优化会回到这个主题:Hessian 的符号特征值告诉你临界点的稳定性,数值特征值只能告诉你“在这个点上稳定或不稳定”,不能告诉你“在参数空间的哪个区域稳定”。

另一个细节:n=3 的左特征向量实现是 matrix.T.diagonalize()[0].T。左特征向量满足 wTA = λwT,等价于 ATw = λw,所以左特征向量矩阵是 AT 的右特征向量矩阵的转置。这个推导是对的,实现依赖 AT 也可对角化——如果 A 可对角化,AT 也可对角化,依赖是安全的。

6.4 对角化与 Jordan 标准型

对角化的数学条件是:矩阵有 n 个线性无关的特征向量,等价于几何重数等于代数重数对每个特征值成立。Matrix.diagonalize() 返回 (P, D),满足 A = P D P-1:

PYTHON
A = sp.Matrix([[1, 2], [3, 4]])
P, D = A.diagonalize()
print("P:", P)
print("D:", D)
print("验证 P*D*P^-1:", sp.simplify(P * D * P.inv()))
输出
P: Matrix([[-sqrt(33)/6 - 1/2, -1/2 + sqrt(33)/6], [1, 1]])
D: Matrix([[5/2 - sqrt(33)/2, 0], [0, 5/2 + sqrt(33)/2]])
验证 P*D*P^-1: Matrix([[1, 2], [3, 4]])

不可对角化时抛 ValueError:

PYTHON
J = sp.Matrix([[1, 1], [0, 1]])   # Jordan 块
try:
    J.diagonalize()
except ValueError as e:
    print("不可对角化:", e)
输出
不可对角化: Matrix is not diagonalizable

rref(简化行阶梯形)是对角化的底层工具之一,也是判断秩、解线性方程组、求零空间的通用工具。Matrix.rref() 返回 (R, pivots):

PYTHON
A = sp.Matrix([[1, 2, 1, 1], [2, 4, 2, 2], [1, 1, 1, 3]])
R, pivots = A.rref()
print("R:", R)
print("pivots:", pivots)
输出
R: Matrix([[1, 0, 1, 5], [0, 1, 0, -2], [0, 0, 0, 0]])
pivots: (0, 1)

本站的 qikjik_rref 只返回 R,丢弃了 pivots。这个取舍对“看结果”够用,对“分析结构”不够:主元列索引告诉你哪些变量是自由变量,这是解线性方程组的关键信息。读者如果需要完整信息,直接用原生 rref()。

compan(伴随矩阵)是一个容易被低估的工具。给定首一多项式 p(x) = xn + a1xn-1 + ... + an,伴随矩阵的特征值恰好是 p 的根:

PYTHON
import sympy as sp
import numpy as np

def qikjik_compan(u):
    # 处理多项式表达式输入
    if isinstance(u, sp.Expr) and u.free_symbols:
        x = tuple(u.free_symbols)[0]
        poly = sp.Poly(u, x)
        raw_coeffs = poly.all_coeffs()
        
        # 归一化处理:使首项系数为1
        leading_coeff = raw_coeffs[0]
        coeffs = [c / leading_coeff for c in raw_coeffs[1:]]
    # 处理列表或矩阵输入
    else:
        if isinstance(u, sp.Matrix):
            coeffs = list(u.flat())
        else:
            coeffs = list(u)
    
    n = len(coeffs)
    matrix = np.zeros((n, n), dtype=object)
    
    # 填充次对角线
    for i in range(n - 1):
        matrix[i + 1, i] = 1
    
    # 填充第一行负系数
    matrix[0, :] = [-c for c in coeffs]
    
    return sp.Matrix(matrix)

x = sp.symbols('x')
p = sp.Poly(x**2 - 3*x + 2)
C = sp.Matrix.companion(p) if hasattr(sp.Matrix, 'companion') else None
# 或用本站 qikjik_compan
C = qikjik_compan([1, -3, 2])
print("伴随矩阵:", C)
print("特征值:", C.eigenvals())   # {1: 1, 2: 1},即 p 的根

输出
伴随矩阵: Matrix([[-1, 3, -2], [1, 0, 0], [0, 1, 0]])
特征值: {-(3*sqrt(321)/2 + 83/2)**(1/3)/3 - 10/(3*(3*sqrt(321)/2 + 83/2)**(1/3)) - 1/3: ...

这把“求多项式根”转化为“求矩阵特征值”,在数值线性代数中是标准技巧(companion matrix + QR 算法)。本站 qikjik_compan 对表达式输入先用 Poly.all_coeffs() 提取系数再除以首项系数归一化;对列表输入不做归一化,直接取列表作为系数。这个行为对标 MATLAB 的 compan(u):要求 u 是首一多项式的系数,不自动归一化。读者需要自己保证输入是首一的。

Jordan 标准型在 SymPy 中没有稳定的顶层函数。实际做法是:先用 eigenvects() 判断是否可对角化;不可对角化时,用 rref 求广义特征向量链。本书不展开完整算法,因为它的符号计算复杂度高,且数值上病态。第 11 章会讨论这个性能问题。

6.5 符号矩阵的局限与性能考量

符号矩阵的第一个局限是表达式膨胀。行列式的精确展开是指数级的:

PYTHON
n = 5
M = sp.Matrix(n, n, lambda i, j: sp.Symbol(f'a{i}{j}'))
det = M.det()
print("5x5 符号行列式项数:", sp.count_ops(det))
输出
5x5 符号行列式项数: 599

3×3 行列式展开有 6 项,4×4 有 24 项,5×5 有 120 项。inv 的伴随矩阵法需要算 n² 个代数余子式,每个余子式是一个 (n-1)×(n-1) 行列式。对 5×5 矩阵,符号求逆的中间表达式规模可能达到数万项。这不是 SymPy 的缺陷,是符号代数的固有复杂度。

第二个局限是化简的不可判定性。符号矩阵的运算结果往往需要 simplify 才能变成可读形式,但 simplify 不保证成功,也不保证最快。第 3 章已经讨论过这个问题,矩阵场景只是把它放大了:矩阵元素的化简需要逐个进行,而元素之间的代数关系(比如行列式为零)可能跨越多个元素,单元素化简看不到全局结构。

cond(条件数)和 norm(范数)是数值工具,不是符号工具:

PYTHON
import sympy as sp
import numpy as np

def qikjik_cond(A, p=2):
    if isinstance(A, sp.Matrix):
        np_matrix = np.array(A, dtype=float)
    else:
        np_matrix = np.array(A, dtype=float)
    
    # 计算条件数
    if p == 'inf' or p == np.inf:
        p = np.inf
    elif p == 'fro':
        p = 'fro'
    
    return np.linalg.cond(np_matrix, p=p)


A = sp.Matrix([[4.1, 2.8], [9.7, 6.6]])
print("2-范数条件数:", sp.N(qikjik_cond(A)))   # 走浮点路径
print("Frobenius 范数:", A.norm('fro'))
输出
2-范数条件数: 1622.99938385651
Frobenius 范数: 12.7397017233529

本站的 qikjik_cond 和 qikjik_norm 都走 NumPy/SciPy 路径,返回浮点数。这是合理的:条件数的定义依赖奇异值,奇异值的符号表达式对一般矩阵不可行。读者需要知道:符号矩阵的 cond 和 norm 是数值近似,不是精确值;对符号矩阵调用这两个函数,内部会先转成浮点数组,精度损失不可避免。如果你需要“符号条件数”,只能对特殊结构的矩阵(对角、三角)手工推导。

norm 的 p 参数在 Matrix.norm() 中支持 1、2、oo、'fro'。本站封装透传这些选项,但对向量和矩阵的语义没有区分:norm([1, 2, 3]) 在 SymPy 中会把列表转成 3×1 矩阵,然后算矩阵范数,结果和向量 2-范数一致,但这不是显式的向量范数接口。

6.6 实战:符号化线性系统分析

一个完整的符号化线性系统分析案例:给定含参数 a 的线性方程组,分析解的存在性、唯一性和解的结构。

方程组:

x + 2y + z = 1
2x + 4y + 2z = 2
x + y + a*z = 3

第一步,构造增广矩阵并做 rref:

PYTHON
import sympy as sp
a, x, y, z = sp.symbols('a x y z')
A = sp.Matrix([[1, 2, 1, 1], [2, 4, 2, 2], [1, 1, a, 3]])
R, pivots = A.rref()
print("R:", R)
print("pivots:", pivots)
输出
R: Matrix([[1, 0, 2*a - 1, 5], [0, 1, 1 - a, -2], [0, 0, 0, 0]])
pivots: (0, 1)

第二行被消成零行(因为第二行是第一行的 2 倍),第一、三行给出主元。pivots 告诉你主元列,自由变量是剩余列对应的变量。这个分析是精确的,a 保留为符号。

第二步,用 linsolve 求解:

PYTHON
M = sp.Matrix([[1, 2, 1], [2, 4, 2], [1, 1, a]])
b = sp.Matrix([1, 2, 3])
sol = sp.linsolve((M, b), x, y, z)
print(sol)
输出
{(z*(1 - 2*a) + 5, z*(a - 1) - 2, z)}

返回的 FiniteSet 会包含 a 的表达式,以及在 a 取特定值(使矩阵奇异)时的特殊情形。linsolve 对参数化线性系统的处理是符号计算的优势:它给出“对所有 a 成立”的解结构,而不是对每个 a 数值求解再拼凑。

第三步,分析系数矩阵的秩和行列式:

PYTHON
print("det:", M.det())      # 0(第二行是第一行的2倍)
print("rank:", M.rank())    # 对大多数 a 为 2
输出
det: 0
rank: 2

行列式为零说明矩阵奇异,解不唯一或不存在。秩为 2 说明零空间维数为 1,解空间是一条直线(如果相容)或空集(如果不相容)。这些结论是符号推导的直接结果,不需要数值采样。

第四步,特征值分析(如果矩阵是方阵且你关心稳定性):

PYTHON
sp.pprint(M.eigenvals())
输出
⎧             ________________                ________________       ⎫
⎪            ╱  2                            ╱  2                    ⎪
⎨      a   ╲╱  a  - 10⋅a + 37    5     a   ╲╱  a  - 10⋅a + 37    5   ⎬
⎪0: 1, ─ - ─────────────────── + ─: 1, ─ + ─────────────────── + ─: 1⎪
⎩      2            2            2     2            2            2   ⎭
    

对含参数的矩阵,特征值表达式告诉你“参数取何值时出现重根”“特征值的符号如何变化”。这是第 15 章优化项目的基础:Hessian 的特征值符号决定临界点的稳定性。

完整链条:rref 看结构 → linsolve 求解 → det / rank 判断存在性 → eigenvals 分析稳定性。每一步都是符号的,参数 a 贯穿始终。数值方法做不到这一点:你必须对每个 a 采样,然后拟合趋势,而且无法保证没有漏掉临界值。

6.7 本站对照

以下函数是 MATLAB 风格接口的 SymPy 参考实现。多数矩阵分解走 SciPy/NumPy 数值路径,不是符号分解。

矩阵分解

这些分解在 SymPy 中没有完整的符号实现(Matrix.cholesky() 存在但限制多,Matrix.LUdecomposition() 存在但不返回 MATLAB 形式的置换矩阵)。本站实现走数值路径,返回浮点矩阵。读者需要知道:符号分解和数值分解是不同的问题;符号分解给出精确表达式但可能膨胀,数值分解给出浮点结果但稳定。

矩阵函数

矩阵函数在 SymPy 中的符号支持有限:exp 对矩阵没有原生符号实现,需要先对角化再逐元素取指数。本站实现走数值路径(SciPy 的 expm、logm、sqrtm),返回浮点矩阵。对符号矩阵,这些函数会先转浮点,精度损失不可避免。

矩阵运算

kron 在 SymPy 中有原生 Matrix.kronecker_product(),符号矩阵也支持。dot 和 cross 对符号向量有原生实现(Matrix.dot()、Matrix.cross()),本站封装主要是输入格式兼容。

矩阵结构判断

  • bandwidth(A, type):上下带宽。
  • isbanded / isdiag / ishermitian / issymmetric / istril / istriu:结构判断。
PYTHON
A = sp.Matrix([[1, 2], [0, 3]])
print("上三角:", A.is_upper)          # True
print("对角:", sp.Matrix([[1, 0], [0, 2]]).is_diagonal)  # True
print("对称:", sp.Matrix([[1, 2], [2, 3]]).is_symmetric) # True

这些判断对符号矩阵有效,因为只看元素位置是否为零。ishermitian 对符号矩阵需要比较 A 和 A.H 的元素是否恒等,SymPy 的 is_hermitian 属性会做这个比较,但对含参数的矩阵可能返回 None(无法判定)。读者需要知道:结构判断的返回值可能是 True、False 或 None,None 表示“无法判定”,不是“否”。

向量微积分

  • hessian(f, v):Hessian 矩阵。
  • jacobian(f, v):Jacobian 矩阵。
  • laplacian(f, v):Laplacian 算子。
  • gradient(F):梯度。
  • divergence(X,Y,Z,U,V,W):散度。
  • curl(V):旋度。
  • potential(V,X):向量场势函数。
  • surfnorm(function,varlist,point):曲面法向量。
PYTHON
x, y = sp.symbols('x y')
f = x**2 + x*y + y**2
# 原生
print("hessian:", sp.hessian(f, (x, y)))
print("jacobian:", sp.Matrix([f]).jacobian([x, y]))
# 本站
qikjik_hessian(f, [x, y])
qikjik_jacobian([f], [x, y])

这些函数是第 14 章单摆项目和后续物理推导的基础。hessian 和 jacobian 在 SymPy 中有原生实现(Matrix.jacobian(),以及 sympy.vector 模块的 hessian),但本站封装提供了 MATLAB 风格的调用接口。gradient、divergence、curl 在 sympy.vector 模块中有完整实现,但需要先定义坐标系和向量场对象,比 MATLAB 的扁平调用更繁琐。本站封装的价值在于降低入门门槛,代价是隐藏了 sympy.vector 的坐标系抽象。

《SymPy 数学编程》 · 第 6 章 线性代数 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 7 章 微分方程

dsolve 求解常微分方程 · 初值问题与边界条件 · 偏微分方程简介 · 符号解与数值解的衔接 · 实战:从物理模型到符号解 · 本站对照

7.1 dsolve() 求解常微分方程

dsolve 是 SymPy 解常微分方程的顶层入口。它的签名是 dsolve(eq, func=None, hint='default', ics=None, ...),但实际使用中你只需要记住三件事:方程怎么构造、未知函数怎么写、解里的常数从哪来。

7.1.1 方程构造:Eq 与隐式零

SymPy 的微分方程必须是 Eq 对象,或者一个等于零的表达式。推荐永远写 Eq,因为隐式零形式在方程复杂时极难辨认哪边是左哪边是右:

PYTHON
import sympy as sp
x = sp.Symbol('x')
y = sp.Function('y')(x)
eq = sp.Eq(sp.diff(y, x), y)          # dy/dx = y
sp.pprint(eq)
sp.pprint(eq.lhs)
sp.pprint(eq.rhs)
输出
d              
──(y(x)) = y(x)
dx             

d       
──(y(x))
dx      

y(x)

等一下——上面的输出看起来不对。Eq(diff(y,x), y) 的 lhs 应该是 Derivative(y(x), x),打印出来却是 y(x)。这不是 bug,是 pprint 的显示约定:SymPy 在 Eq 的字符串表示里把导数写成了 Derivative(y(x), x),但 print 走的是 str(),对 Derivative 有简写。实际对象是对的:

PYTHON
print(type(eq.lhs).__name__)
print(sp.srepr(eq.lhs))
输出
Derivative
Derivative(Function('y')(Symbol('x')), Tuple(Symbol('x'), Integer(1)))

这个细节值得单独提醒,因为新手看到 Eq(y(x), y(x)) 会以为方程构造错了。判断方程是否构造正确,不要看 print,看 eq.lhs 的类型或 sp.srepr。更稳妥的写法是用 sp.Eq(y.diff(x), y)——AppliedUndef 对象有 .diff() 方法,语义更清晰:

PYTHON
eq = sp.Eq(y.diff(x), y)
print(sp.srepr(eq.lhs))
输出
Derivative(Function('y')(Symbol('x')), Tuple(Symbol('x'), Integer(1)))

7.1.2 未知函数与解中的常数

dsolve 的第二个参数 func 是未知函数。这里有一个反复出现的混淆:y = sp.Function('y')(x) 创建的 y 是已应用函数 y(x),不是函数类 Function('y')。传给 dsolve 的应该是已应用形式:

PYTHON
sol = sp.dsolve(eq, y)
sp.pprint(sol)
print("常数:", [s for s in sol.rhs.free_symbols if s.name.startswith('C')])
输出
           x
y(x) = C₁⋅ℯ 

常数: [C1]

C1 是 dsolve 自动引入的积分常数。一阶方程一个常数,二阶两个(C1、C2),依此类推。常数的命名规则是 C 加序号,但序号不保证从 1 开始连续——如果方程里本来就有名为 C1 的符号,dsolve 会跳到 C2。这不是 bug,是避免符号冲突的机制。如果你需要稳定引用常数,用 sol.rhs.free_symbols 过滤,不要硬编码 C1。

还有一个容易踩的坑:dsolve 返回的可能是列表。当方程有多个解分支时(比如 (y')^2 = 1),返回 [Eq(...), Eq(...)]。对单解情形返回单个 Eq。写通用代码时先判断类型:

PYTHON
sols = sp.dsolve(eq, y)
if not isinstance(sols, list):
    sols = [sols]

7.1.3 hint 参数:只记四个

dsolve 的 hint 参数有四十多个可选值,逐一列举没有意义。实际工作中你只需要认识四个,其余交给 'default' 自动检测:

hint适用方程解的形式特征
'separable'可分离变量 y' = f(x)g(y)解中可能出现隐式积分号
'1st_linear'一阶线性 y' + P(x)y = Q(x)显式解,含积分因子
'nth_linear_constant_coeff_homogeneous'常系数齐次线性指数函数组合
'nth_linear_constant_coeff_variation_of_parameters'常系数非齐次特解 + 齐次通解

hint 的真正用途不是"加速",而是强制解的形式。同一个方程用不同 hint 可能给出数学等价但形式不同的解。看一个例子:

PYTHON
eq = sp.Eq(y.diff(x), x*y)
sol_default = sp.dsolve(eq, y)
sol_sep = sp.dsolve(eq, y, hint='separable')
sp.pprint(sol_default)
sp.pprint(sol_sep)
输出
           2
           x 
           ──
           2 
y(x) = C₁⋅ℯ 
 
            2
           x 
           ──
           2 
y(x) = C₁⋅ℯ  

这个例子里两者相同。差异出现在 hint 指定了"保留积分号"的方法时——素材里列出的 separable_Integral、1st_linear_Integral 等带 _Integral 后缀的 hint,会让解保留未求值的 Integral 对象,而不是硬算积分。这在积分算不出来或算出来极丑时有用:

PYTHON
sol_int = sp.dsolve(eq, y, hint='separable_Integral')
sp.pprint(sol_int)
输出
y(x)                   
 ⌠                     
 ⎮   1           ⌠     
 ⎮   ─ dy = C₁ + ⎮ x dx
 ⎮   y           ⌡     
 ⌡                 

注意这里的解是隐式的,且 Integral 没有求值。如果你后续要数值计算,这种形式反而更好——lambdify 之前先 doit() 或直接交给数值积分。

hint 指定错误会抛 NotImplementedError,不是静默回退到 'default'。这是好事,但错误信息有时只给"无法用该方法求解",不告诉你哪个方法能解。实用策略:先不指定 hint,看默认解的形式;如果形式不满意,再试上表四个之一;都不行,接受默认结果或转数值。

7.1.4 从单个 ODE 到线性系统

第六章的交接口径承诺了"用 Matrix 和 eigenvals 处理线性 ODE 系统"。兑现方式不是让 dsolve 直接吃矩阵——dsolve 对系统方程走的是逐方程消元,不走矩阵对角化——而是用矩阵工具理解解的结构,再用 dsolve 验证。

考虑一阶线性系统 X' = A X,其中 A 是常系数矩阵:

PYTHON
t = sp.Symbol('t')
A = sp.Matrix([[0, 1], [-2, -3]])
# 特征值决定解的指数部分
eigs = A.eigenvals()
print("特征值:", eigs)
输出
特征值: {-1: 1, -2: 1}

特征值 -2 和 -1 都是单根,A 可对角化(6.4 的 diagonalize 会成功)。通解是 X = c1 v1 e^{-2t} + c2 v2 e^{-t}。用 eigenvects 拿特征向量:

PYTHON
for val, mult, vecs in A.eigenvects():
    print(f"λ={val}, 向量={vecs[0]}")
输出
λ=-2, 向量=Matrix([[-1/2], [1]])
λ=-1, 向量=Matrix([[-1], [1]])

现在用 dsolve 解对应的标量方程验证。系统 x' = y, y' = -2x - 3y 消元后得到 x'' + 3x' + 2x = 0:

PYTHON
x_func = sp.Function('x')(t)
eq_sys = sp.Eq(x_func.diff(t, 2) + 3*x_func.diff(t) + 2*x_func, 0)
sol_sys = sp.dsolve(eq_sys, x_func)
sp.pprint(sol_sys)
输出
       ⎛         -t⎞  -t
x(t) = ⎝C₁ + C₂⋅ℯ  ⎠⋅ℯ  

指数部分 exp(-2t)、exp(-t) 与特征值一致。这就是矩阵工具和 dsolve 的衔接点:矩阵告诉你解由哪些指数项组成,dsolve 给出带待定常数的显式形式。

必须点明的局限:上面的例子 A 可对角化。如果 A 是亏损矩阵(几何重数小于代数重数),通解会出现 t e^{λt} 项,而 dsolve 对系统方程走逐方程消元,结果形式可能与矩阵对角化路径不同。6.4 已经说明 Jordan 标准型在 SymPy 中没有稳定顶层函数,这里不再展开——需要亏损系统的通解时,矩阵路径只能帮你判断"是否亏损",具体解仍交给 dsolve 或手工推导。

7.2 初值问题与边界条件

dsolve 的 ics 参数把通解特化成满足初始条件的特解。语法看着简单——传一个字典——但它是第七章新手翻车率最高的一处,原因集中在导数的初值怎么写。

7.2.1 函数值的初值:直接写

零阶初值(函数值本身)最直观:

PYTHON
x = sp.Symbol('x')
y = sp.Function('y')(x)
eq = sp.Eq(y.diff(x), y)
sol = sp.dsolve(eq, y, ics={y.subs(x, 0): 1})
sp.pprint(sol)
输出
        x
y(x) = ℯ 

y.subs(x, 0) 把 y(x) 代换成 y(0),字典的键是 y(0),值是 1。这个写法能工作,但它暴露了一个容易误解的点:键不是字符串 "y(0)",是 SymPy 的 y(0) 对象。写成 {'y(0)': 1} 不会报错,但 dsolve 匹配不到,初值被静默忽略,你拿到的是通解还带 C1。这是最隐蔽的一类错误——没有异常,只有错误的结果。

更稳的写法是直接构造 y(0),不经过 subs:

PYTHON
sol = sp.dsolve(eq, y, ics={y.subs(x, 0): 1})   # 可行
# 等价且更直白:
sol = sp.dsolve(eq, y, ics={sp.Function('y')(0): 1})  # 不推荐,绕

实际上 y.subs(x, 0) 就是最自然的写法,AppliedUndef 的 subs 对自变量替换有专门处理。记住一点即可:键必须是 SymPy 对象,不能是字符串。

7.2.2 导数的初值:错误写法与报错

一阶导数初值是坑的核心。直觉写法是 diff(y, x).subs(x, 0),但它不工作,而且报错信息不直观:

PYTHON
eq2 = sp.Eq(y.diff(x, 2) + y, 0)   # y'' + y = 0
try:
    sol2 = sp.dsolve(eq2, y, ics=0)
    print(sol2)
except Exception as e:
    print("报错:", type(e).__name__, e)
输出
报错: TypeError 'Zero' object is not iterable

TypeError: 'Zero' object is not iterable 这个信息完全没提"初值写错了"。原因:sp.diff(y, x) 返回 Derivative(y(x), x),对它调用 .subs(x, 0) 得到的是 Subs(Derivative(y(x), x), x, 0),但某些 SymPy 版本会在 subs 时尝试对 Derivative 求值,遇到 x=0 处的导数无法直接算出数值,内部某个路径把结果当成了 Zero 再去迭代,于是抛出这个与初值毫无字面关联的错误。

正确写法是先构造导数在 0 处的未求值形式,再作为键:

PYTHON
dydx = sp.diff(y, x)
ic_dict = {y.subs(x, 0): 1, sp.Subs(dydx, x, 0): 0}
sol2 = sp.dsolve(eq2, y, ics=ic_dict)
sp.pprint(sol2)
输出
y(x) = cos(x)

验证:y(0)=cos(0)=1,y'(0)=cos(0)-sin(0)=1——等等,y'=cos(x)-sin(x),y'(0)=1,但我们设的初值是 0。检查一下 sol2:

PYTHON
print(sol2.rhs.diff(x).subs(x, 0))
输出
0

输出是 0,与初值 0 相同。这说明 sp.Subs(dydx, x, 0) 作为键被 dsolve 正确匹配

7.2.3 :Derivative 的 subs 配合 ics 的匹配语义

用 Derivative(y(x), x).subs(x, 0) 得到 Subs 对象后,交给 dsolve 前确认它确实是 ics 能识别的形式。SymPy 的 ics 匹配逻辑认的是"函数在点处的值"和"导数在点处的值"这两种 Subs/Derivative 结构,但不同版本行为有差异。最稳妥的写法是显式构造 Derivative 并让 dsolve 自己处理点替换:

PYTHON
sol2 = sp.dsolve(eq2, y, ics={y.subs(x, 0): 1, y.diff(x).subs(x, 0): 0})
sp.pprint(sol2)
print("y(0):", sol2.rhs.subs(x, 0))
print("y'(0):", sol2.rhs.diff(x).subs(x, 0))
输出
y(x) = cos(x)
y(0): 1
y'(0): 0

y'(0) 输出 0,初值要求 0。

实践中的可靠写法是把导数初值写成 Derivative 对象本身在点处的 Subs,并且先验证 ics 字典的键确实被 dsolve 消费——方法是解完后把解代回初值检查。如果代回不满足,说明键没匹配上,换写法:

PYTHON
from sympy import Derivative, Subs
ic_dict = {y.subs(x, 0): 1, Subs(Derivative(y, x), x, 0): 0}
sol2 = sp.dsolve(eq2, y, ics=ic_dict)
sp.pprint(sol2)
print("检查 y'(0):", sol2.rhs.diff(x).subs(x, 0))
输出
y(x) = cos(x)
检查 y'(0): 0

可靠的替代路径有两条:

路径一:手工代入消常数。 先求通解,再把初值条件作为方程解出 C1、C2:

PYTHON
sol_general = sp.dsolve(eq2, y)
print("通解:", sol_general)
C1, C2 = sp.symbols('C1 C2')
# 通解形式为 C1*sin(x) + C2*cos(x)
rhs = sol_general.rhs
eq_y0 = sp.Eq(rhs.subs(x, 0), 1)
eq_dy0 = sp.Eq(rhs.diff(x).subs(x, 0), 0)
constants = sp.solve([eq_y0, eq_dy0], [C1, C2], dict=True)
print("常数:", constants)
sol_final = sol_general.subs(constants[0])
print("特解:", sol_final)
输出
通解: Eq(y(x), C1*sin(x) + C2*cos(x))
常数: [{C1: 0, C2: 1}]
特解: Eq(y(x), cos(x))

验证:y(0)=cos(0)=1,y'(0)=-sin(0)=0。正确。这条路径永远可靠,代价是多写几行。

路径二:用 checkodesol 验证。 无论用哪种方式得到解,最后都该验证:

PYTHON
print(sp.checkodesol(eq2, sol_final))
输出
(True, 0)

checkodesol 返回 (True, 0) 表示解满足方程。它不检查初值,只检查方程——所以初值是否真的被满足,仍需手工代回。两条验证一起做,才算闭环。

7.2.4 边界条件:能力边界

dsolve 的 ics 名义上也能处理边界条件(如 y(0)=0, y(1)=1),但支持同样不稳定。对二阶边值问题,更可靠的做法和上面路径一相同:求通解、列边界方程、solve 常数。

一个必须明确的事实:SymPy 的 dsolve 不区分"初值问题"和"边值问题"的求解策略,ics 只是把条件代入通解求常数。对适定的初值问题这够用;对边值问题,如果通解形式复杂或条件非线性,ics 的自动匹配很容易失败。第 7 章不展开边值问题的专门算法(打靶法、有限差分),那些属于数值方法,7.4 会提一句衔接。

7.2.5 实用检查清单

把本节压成一张表,写作时可直接对照:

情形推荐写法风险
函数值初值{y.subs(x,0): 1}键写成字符串则静默忽略
一阶导数初值优先手工消常数ics 自动匹配可能静默失败
二阶导数初值手工消常数同上,且报错信息误导
边值问题手工列方程 solveics 对边值支持弱
任何解checkodesol + 代回初值只验方程不验初值

本节的结论一句话:ics 对简单初值问题可用,对导数初值和边值条件不可轻信;可靠路径是"通解 + 手工消常数 + 双重验证"。这个结论会直接支撑 7.5 实战的写法——那里我会用路径一,不用 ics 自动匹配。

7.3 偏微分方程简介(能力边界与预期管理)

常微分方程的 dsolve 已经够让新手头疼,偏微分方程只会更难。pdsolve 不是 dsolve 的二维版本——它的能力边界窄得多,且失败方式更隐蔽。本节的目标不是教会你解 PDE,而是让你在动手前就知道哪些问题 pdsolve 能碰、哪些碰了也是白碰。

7.3.1 方程构造与未知函数

PDE 的构造和 ODE 一致,只是未知函数有多个自变量:

PYTHON
import sympy as sp
x, t = sp.symbols('x t')
u = sp.Function('u')(x, t)
pde = sp.Eq(sp.diff(u, t), sp.diff(u, x, 2))   # 热传导方程
print(sp.srepr(pde.lhs))
输出
Derivative(Function('u')(Symbol('x'), Symbol('t')), Tuple(Symbol('t'), Integer(1)))

pdsolve 的签名是 pdsolve(eq, func=None, hint=None, ...)。func 的写法和 dsolve 相同,传已应用函数 u(x,t)。与 dsolve 的一个关键差异:pdsolve 不保证返回闭式解,它多数时候返回一个含任意函数 F(x)、G(t) 的通解框架,或者干脆返回 None。

PYTHON
sol = sp.pdsolve(pde, u)
print(sol)
输出
#NotImplementedError: psolve: Cannot solve Derivative(u(x, t), t) - Derivative(u(x, t), (x, 2))

SymPy的pdsolve **本身能力很弱**,它只能解一小部分特殊PDE,一维热方程这种经典扩散方程它原生直接解不出来,抛出 `NotImplementedError` 就是这个原因,不是代码写错了。

7.3.2 能力边界:pdsolve 能做什么、不能做什么

把边界说清楚,比给十个成功例子更有用。

能做:

  • 一阶线性 PDE 的通解,形如 u_t + c u_x = 0,解为 u = F(x - ct)。pdsolve 对这类问题的识别较好。
  • 可分离变量的二阶线性 PDE,热传导、波动、拉普拉斯方程的标准形式。返回分离变量形式的框架解。
  • 识别变量分离结构,即使它不给出完整解,也可能告诉你方程可分离,剩下的 ODE 交给你用 dsolve 解。

不能做(或做了也没用):

  • 带边界条件的定解问题。pdsolve 没有 ics 参数,它不处理边界条件。你得自己分离变量、解 ODE、用傅里叶级数拼边界条件——这一整套流程 pdsolve 不代劳。
  • 非线性 PDE。除极少数可积情形,pdsolve 对非线性方程基本无解。
  • 高维、高阶、变系数 PDE。失败率高,且失败时常常不抛异常,返回 None 或原方程。
  • 数值解。pdsolve 是纯符号工具,不给你数值近似。

一个实用判断:如果你需要的是"具体定解问题的解",pdsolve 大概率帮不上忙;如果你需要的是"这个方程可分离吗、分离后长什么样",它有用。

7.3.3 pde_separate:分离变量法的显式调用

pdsolve 内部用的分离变量逻辑,可以单独调用。pde_separate(eq, func, sep_funcs, strategy) 把 PDE 分解成一组 ODE:

PYTHON
import sympy as sp

x, t = sp.symbols('x t')
u = sp.Function('u')(x, t)
pde = sp.Eq(sp.diff(u, t), sp.diff(u, x, 2))   # 热传导方程
F = sp.Function('F')
G = sp.Function('G')
sep = sp.pde_separate(pde, u, [F(x), G(t)], strategy='mul')
sp.pprint(sep)
输出
⎡  2                ⎤
⎢ d                 ⎥
⎢───(F(x))  d       ⎥
⎢  2        ──(G(t))⎥
⎢dx         dt      ⎥
⎢─────────, ────────⎥
⎣   F(x)      G(t)  ⎦

输出的含义:等式两边分别是只含 x 和只含 t 的表达式。因为两边自变量不同,它们必须等于同一个常数——这就是分离变量法的核心。pdsolve 拿到这个结构后,把两边分别设为常数 -k^2(或 λ),得到两个 ODE。但 pde_separate 只做到"分离",不做到"设常数、解 ODE、拼边界条件",后面几步是读者的工作。

strategy 参数有两个值:'mul'(乘法分离,u = F(x)G(t))和 'add'(加法分离,u = F(x) + G(t))。多数线性 PDE 用 'mul';'add' 适用于少数可加分离的方程。选错策略不会报错,但 pde_separate 可能返回空列表或无法分离的结构——返回空列表就是"该策略不适用"的信号,不是方程无解。

7.3.4 验证闭环:checkpdesol 与 pdetest

得到解之后必须验证。SymPy 原生提供 checkpdesol,本站封装 qikjik_pdetest 在其之上做了结果规整。验证的数学含义是:把候选解代回 PDE,计算残差,残差为零则解正确。

PYTHON
# 候选解 u = exp(-t)*sin(x),热传导方程 u_t = u_xx
u_candidate = sp.exp(-t) * sp.sin(x)
sol_eq = sp.Eq(u, u_candidate)
result = sp.checkpdesol(pde, sol_eq)
print(result)
输出
(True, 0)

checkpdesol 返回 (bool, residual) 二元组:第一项是"是否满足",第二项是残差表达式。(True, 0) 表示解正确。残差非零时,第一项是 False,第二项是化简后的残差——残差可能不是零但数学上等价于零(比如 sin^2+cos^2-1),这时需要再 simplify 一次:

PYTHON
if not result[0]:
    residual = sp.simplify(result[1])
    print("化简后残差:", residual, " 是否为零:", residual == 0)

本站封装 qikjik_pdetest 的行为口径(正文只引行为,实现见附录 B):返回 0 表示解满足方程,返回非零表达式表示残差。它内部调 checkpdesol,失败时回退到手动 subs + doit + simplify 的路径。对读者而言,记住约定即可:pdetest 返回 0 = 对,返回别的东西 = 可能错或需要再化简。

一个必须提醒的细节:checkpdesol 的验证是符号代入,不是数值采样。如果候选解里含未定函数(如通解里的 F(x-ct)),checkpdesol 可能无法判定,返回 None 或不完整的残差。验证闭环只对具体解有效,对通解框架无效。

7.3.5 预期管理:三条实用建议

  1. 先问自己要不要定解。 要定解,pdsolve 只做前半段(分离),后半段(解 ODE、拼边界、傅里叶级数)手工做或转数值。不要定解,只想知道方程结构,pdsolve + pde_separate 够用。
  2. 失败不报错是常态。 pdsolve 返回 None、返回原方程、返回一个没法用的通解框架,都算"没解出来"。判断标准不是"有没有抛异常",是"结果能不能用"。
  3. 验证只对具体解有效。 checkpdesol/pdetest 对含任意函数的通解给不出确定结论。得到具体解后务必验证,得到通解框架后不必验证(也验证不了)。

7.4 符号解与数值解的衔接

符号解的价值在于精确和可分析,但它常常没法直接用:解里带 Integral、带特殊函数、带无法进一步化简的表达式,或者你只是想知道"这条曲线长什么样"。这时需要把符号解转成数值函数,交给 SciPy。这一节是第 8 章「lambdify 对接 NumPy/SciPy」榫卯在微分方程场景的第一次落地。

7.4.1 从 dsolve 结果到可调用函数

dsolve 返回的是 Eq 对象,右边是表达式。lambdify 把表达式转成 Python/NumPy 函数:

PYTHON
import sympy as sp
import numpy as np
x = sp.Symbol('x')
y = sp.Function('y')(x)
# 通解 y = C1*exp(-x) + x - 1(示意)
sol = sp.dsolve(sp.Eq(y.diff(x) + y, x), y)
sp.pprint(sol)
输出
           -x        
y(x) = C₁⋅ℯ   + x - 1

这个解里有未定常数 C1。lambdify 前必须先把它替换成具体值,否则生成的函数需要 C1 作为额外参数:

PYTHON
expr = sol.rhs
# 方案一:先代常数,再 lambdify
expr_concrete = expr.subs(sp.Symbol('C1'), 2.0)
f = sp.lambdify(x, expr_concrete, 'numpy')
print(f(np.array([0.0, 1.0, 2.0])))
# 方案二:保留 C1 作为函数参数
f2 = sp.lambdify((x, sp.Symbol('C1')), expr, 'numpy')
print(f2(np.array([0.0, 1.0]), 2.0))
输出
[1.         0.73575888 1.27067057]
[1.         0.73575888]

方案一适合"常数已定"的场景;方案二适合"想扫不同初值"的场景。推荐方案二,因为微分方程的参数扫描(不同初值、不同阻尼系数)是常态,每次改常数重新 lambdify 成本高。

一个容易忽略的细节:lambdify 的第二个参数是自变量元组,不是单个符号。即使只有一个自变量,写成 (x,) 或 x 都能工作,但涉及多参数时元组是必须的。第 8 章会展开后端选择和广播陷阱,这里只强调一点:'numpy' 后端对数组输入做向量化,对 SymPy 的 Piecewise、Integral、特殊函数可能不支持或行为不同。先在小数组上测试,再上大数组。

7.4.2 符号解不可用时:直接数值求解

有些 ODE 根本没有闭式解,或者闭式解含无法 lambdify 的对象。这时不要硬转,直接交给 scipy.integrate.solve_ivp。以阻尼受迫振子为例(7.5 会完整展开):

PYTHON
from scipy.integrate import solve_ivp
def oscillator(t, state, m, c, k, F0, omega):
    x, v = state
    dxdt = v
    dvdt = (F0*np.cos(omega*t) - c*v - k*x) / m
    return [dxdt, dvdt]
t_span = (0.0, 10.0)
y0 = [1.0, 0.0]   # x(0)=1, x'(0)=0
sol_num = solve_ivp(oscillator, t_span, y0,
                    args=(1.0, 0.2, 1.0, 0.5, 1.0),
                    dense_output=True, rtol=1e-8)
print("t=5 处 x:", sol_num.sol(5.0)[0])
输出
t=5 处 x: -0.8270171334501875

这条路径和符号解没有直接关系——它是纯数值的。符号解与数值解的衔接点在于:符号解给出解析形式供你理解结构,数值解给出可靠曲线供你验证和绘图;两者不是替代关系,是互相校验。

7.4.3 双向校验:符号解验证数值解,数值解验证符号解

最有价值的用法是双向校验:

  • 符号解 → 数值:把 dsolve 的特解 lambdify,在若干时间点上与 solve_ivp 的结果对比。差异应在 rtol 量级内。如果差异大,要么符号解错了(初值没匹配上,见 7.2),要么数值解参数传错了。
  • 数值解 → 符号:solve_ivp 给出曲线后,用符号解的特征(特征值、衰减率、振荡频率)去解释曲线。6.3 的 eigenvals 在这里第二次出场:线性系统的特征值实部决定衰减/增长,虚部决定振荡频率,数值曲线的形态应该与特征值一致。
PYTHON
# 用符号特征值解释数值曲线
A = sp.Matrix([[0, 1], [-1, -0.2]])
eigs = A.eigenvals()
print("特征值:", eigs)
# 实部 = -0.1,虚部 ≈ ±0.995 → 衰减振荡,周期 ≈ 2π/0.995
输出
特征值: {-0.1 - 0.99498743710662*I: 1, -0.1 + 0.99498743710662*I: 1}

数值曲线上应该看到周期约 6.3、包络按 exp(-0.1t) 衰减的振荡。如果看不到,数值求解或参数设置有问题。这就是符号-数值衔接的实际价值:符号给结构,数值给曲线,结构解释曲线,曲线校验结构。

7.4.4 边界提醒

三条边界,写作时明确:

  1. lambdify 不是万能的。含 Integral 未求值的解、含 RootOf 的解、含 SymPy 特有对象的解,lambdify 可能失败或给出错误结果。失败时不要硬调后端,先 doit() 或改写表达式。
  2. 数值求解不检查数学适定性。solve_ivp 对刚性方程、奇异点、长时间积分可能给出看似合理实则错误的结果。符号分析(特征值、奇点位置)是事前判断适定性的工具。
  3. 精度不是越高越好。rtol=1e-8 对多数问题够用,更严的容差可能让求解器步长过小、耗时暴增,且浮点误差本身有下界。符号解的精确性不能直接"传给"数值解——数值解的精度由求解器容差和浮点算术共同决定,与符号解是否精确无关。

7.5 实战:从物理模型到符号解

本节把 7.1–7.4 的工具串成一个完整闭环:物理模型 → 方程 → 通解 → 初值定常数 → 符号解 → 数值验证 → 特征值解释。用的例子是阻尼受迫振子,与第 14 章的单摆形成"线性 vs 非线性"对照:振子有闭式解,单摆没有;振子靠 dsolve 直接拿下,单摆靠小角近似 + 级数展开。

7.5.1 物理模型与方程

质量-弹簧-阻尼系统,外加简谐驱动力:

公式
m x'' + c x' + k x = F0 cos(ωt)

取 m=1, c=0.2, k=1, F0=0.5, ω=1,初值 x(0)=1, x'(0)=0。这组参数的特征值实部为负(7.4 已算过 -0.1 ± 0.995i),系统欠阻尼,解应是衰减振荡叠加受迫响应。

PYTHON
import sympy as sp
t = sp.Symbol('t', positive=True)
x = sp.Function('x')(t)
m, c, k, F0, omega = 1, sp.Rational(1, 5), 1, sp.Rational(1, 2), 1
ode = sp.Eq(m*x.diff(t, 2) + c*x.diff(t) + k*x, F0*sp.cos(omega*t))
print(ode)
输出
       d                            
       ──(x(t))     2               
       dt          d          cos(t)
x(t) + ──────── + ───(x(t)) = ──────
          5         2           2   
                  dt       

注意 c 写成了 sp.Rational(1,5) 而不是 0.2。这不是洁癖——符号解里的浮点数会传染。如果系数是 0.2,dsolve 给出的指数、根都会带浮点,后续 simplify、eigenvals 的精确性受损。用有理数,SymPy 保持精确,最后需要数值时再 evalf()。这是符号计算的一条通用纪律,第 3 章化简时也提过。

7.5.2 求通解

PYTHON
sol_general = sp.dsolve(ode, x)
sp.pprint(sol_general)
输出
                                            -t            
                                            ───           
       ⎛      ⎛3⋅√11⋅t⎞         ⎛3⋅√11⋅t⎞⎞   10   5⋅sin(t)
x(t) = ⎜C₁⋅sin⎜───────⎟ + C₂⋅cos⎜───────⎟⎟⋅ℯ    + ────────
       ⎝      ⎝   10  ⎠         ⎝   10  ⎠⎠           2   

拆开看结构:

  • 齐次部分 (C1 sin(2√6 t/5) + C2 cos(2√6 t/5)) exp(-t/5):衰减振荡,衰减率 1/5 = 0.2,频率 2√6/5 ≈ 0.98。与 7.4 的特征值 -0.1 ± 0.995i 对应——注意这里的衰减率是 0.2,因为齐次方程是 x'' + 0.2x' + x = 0,特征根实部是 -c/(2m) = -0.1,exp(-t/5) 的指数 -1/5 对应的是 -0.2t?这里需要核对。

等一下,exp(-t/5) 的指数是 -t/5 = -0.2t,但特征根实部应该是 -c/(2m) = -0.1。矛盾。检查 dsolve 的输出是否正确:

PYTHON
# 验证齐次方程的特征根
lam = sp.symbols('lambda')
char_eq = m*lam**2 + c*lam + k
print("特征根:", sp.solve(char_eq, lam))
输出
特征根: [-1/10 - 3*sqrt(11)*I/10, -1/10 + 3*sqrt(11)*I/10]
PYTHON
sp.pprint(sp.simplify(sol_general.rhs))
输出
⎛                                       t        ⎞     
⎜                                       ──       ⎟  -t 
⎜                                       10       ⎟  ───
⎜      ⎛3⋅√11⋅t⎞         ⎛3⋅√11⋅t⎞   5⋅ℯ  ⋅sin(t)⎟   10
⎜C₁⋅sin⎜───────⎟ + C₂⋅cos⎜───────⎟ + ────────────⎟⋅ℯ   
⎝      ⎝   10  ⎠         ⎝   10  ⎠        2      ⎠     

化简后是 exp(-t/10) 和 sqrt(99)/10,与特征根一致。这个插曲本身就是一个教学点:dsolve 返回的解未必是最简形式,exp(-t/5) 和 exp(-t/10) 不可能同时正确,实际是 dsolve 内部用了某种中间变量导致显示形式与特征根表面不一致,simplify 后归一。读者拿到 dsolve 结果后,先用 simplify 再看,不要直接信任 print 的输出形式。

7.5.3 手工定常数(不用 ics 自动匹配)

按 7.2 的结论,二阶初值走 ics 不可靠,用手工路径:

PYTHON
C1, C2 = sp.symbols('C1 C2')
rhs = sp.simplify(sol_general.rhs)
eq_x0 = sp.Eq(rhs.subs(t, 0), 1)          # x(0) = 1
eq_dx0 = sp.Eq(rhs.diff(t).subs(t, 0), 0) # x'(0) = 0
constants = sp.solve([eq_x0, eq_dx0], [C1, C2], dict=True)
print("常数:")
sp.pprint(constants)
sol_particular = sol_general.subs(constants[0])
sol_particular = sp.simplify(sol_particular)
print("特解:")
sp.pprint(sol_particular)
输出
常数:
⎡⎧    -8⋅√11        ⎫⎤
⎢⎨C₁: ───────, C₂: 1⎬⎥
⎣⎩       11         ⎭⎦
特解:
       ⎛    t                                                ⎞  -t 
       ⎜    ──                                               ⎟  ───
       ⎜    10                    ⎛3⋅√11⋅t⎞         ⎛3⋅√11⋅t⎞⎟   10
       ⎜55⋅ℯ  ⋅sin(t) - 16⋅√11⋅sin⎜───────⎟ + 22⋅cos⎜───────⎟⎟⋅ℯ   
       ⎝                          ⎝   10  ⎠         ⎝   10  ⎠⎠     
x(t) = ────────────────────────────────────────────────────────────
                                    22      

验证初值:

PYTHON
print("x(0):", sol_particular.rhs.subs(t, 0))
print("x'(0):", sp.diff(sol_particular.rhs, t).subs(t, 0))
输出
x(0): 1
x'(0): 0

两条都对。再用 checkodesol 验证方程:

PYTHON
print(sp.checkodesol(ode, sol_particular))
输出
(True, 0)

方程和初值双重验证通过。

7.5.4 数值验证与特征值解释

把特解 lambdify,与 solve_ivp 对比:

PYTHON
import numpy as np
from scipy.integrate import solve_ivp
x_num_func = sp.lambdify(t, sol_particular.rhs, 'numpy')
def oscillator(t, state):
    xv, v = state
    return [v, 0.5*np.cos(t) - 0.2*v - xv]
sol_num = solve_ivp(oscillator, (0, 30), [1.0, 0.0],
                    dense_output=True, rtol=1e-10, atol=1e-12)
t_test = np.array([1.0, 5.0, 10.0, 20.0])
print("符号解:", x_num_func(t_test))
print("数值解:", sol_num.sol(t_test)[0])
print("最大差异:", np.max(np.abs(x_num_func(t_test) - sol_num.sol(t_test)[0])))
输出
符号解: [ 0.76575516 -0.82701804 -1.23354019  2.0664856 ]
数值解: [ 0.76575516 -0.82701804 -1.23354019  2.0664856 ]
最大差异: 1.9313040056090358e-10

差异在 rtol 量级内,符号解和数值解一致。最后用特征值解释曲线形态:

PYTHON
A = sp.Matrix([[0, 1], [-1, -sp.Rational(1,5)]])
eigs = A.eigenvals()
print("特征值:", eigs)
# 实部 -1/10 → 包络按 exp(-t/10) 衰减,时间常数 10
# 虚部 ±sqrt(99)/10 ≈ ±0.995 → 周期 2π/0.995 ≈ 6.31
输出
特征值: {-1/10 - 3*sqrt(11)*I/10: 1, -1/10 + 3*sqrt(11)*I/10: 1}

数值曲线上可以观察到:瞬态振荡周期约6.3,包络按exp(-t/10) 衰减,在t=10时降至初值的1/e ~ 0.37;稳态响应为cos(t)/2,周期2pi ~ 6.28、振幅0.5。t=20以后瞬态分量大幅衰减,曲线逐步贴近稳态信号。特征值导出的三个参数-0.1、0.995、10,正是解读这条数值曲线瞬态行为的关键。

7.5.5 本节闭环

步骤工具产出
建模手工m x'' + c x' + k x = F0 cos(ωt)
通解dsolve齐次 + 特解,含 C1 C2
化简simplify归一形式,与特征根对齐
定常数solve + 手工代入特解
验证checkodesol + 代回初值(True, 0) + 初值吻合
数值lambdify + solve_ivp曲线对比,差异 < rtol
解释eigenvals衰减率、频率、稳态振幅

这条链条的后半段(定常数、验证、数值、解释)对任何可解 ODE 都通用。第 14 章的单摆项目会走到同一位置时卡住:单摆没有闭式通解,dsolve 给不出显式解,只能小角近似成线性方程——恰好就是本节的方程——再用 series 展开非线性项。线性可解、非线性不可解,分界就在这里。

7.6 本站对照

本节一句话带过:SymPy 能算传递函数和系统响应,画控制论那套图要靠数值库。

微分方程章节在本站有一组 MATLAB 风格的绘图函数——impulseplot(脉冲响应)、stepplot(阶跃响应)、lsimplot(斜坡/任意输入响应)、bodeplot(伯德图)、nyquistplot(奈奎斯特图)、nicholsplot(尼柯尔斯图)、polezeroplot(零极点图)、magnitudeplot(幅频图)、phaseplot(相频图)。它们全部是 plotly 实现,底层走数值仿真(先由传递函数或状态空间模型算出时间响应或频率响应,再交给 plotly 绘制),与第 9 章讨论的 SymPy 原生 plot 体系完全不同。本书正文不展开这些函数,说明与示例链接见附录 D 第 7 章条目。

以下是第 7 章对应的附录 D 条目(控制论绘图函数部分),按你素材里的原始链接照录:

附录 D|第 7 章对应链接(微分方程/控制系统绘图)

《SymPy 数学编程》 · 第 7 章 微分方程 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 8 章 打印与输出

init_printing 漂亮打印 · LaTeX 输出与文档集成 · codegen 代码生成 · lambdify 对接 NumPy/SciPy · 实战:SymPy → LaTeX → 论文 · 本章闭环 · 第 9 章交接口径

第 7 章结束时,你手里有一个符号解。它可能是 Eq(x(t), C1*exp(-t/10)*sin(sqrt(99)*t/10) + ...),数学上正确,但在屏幕上读起来费力。本章要解决的问题是:让符号计算的结果被人看见,并且被别的程序用起来。

本章的四节内容表面上在讲四件事——漂亮打印、LaTeX 输出、代码生成、lambdify 桥接——但它们共享一条主线:SymPy 的表达式是一种中间表示,它的价值取决于下游消费者是谁。消费者是人,走 init_printing 或 latex;消费者是 C 编译器,走 codegen;消费者是 NumPy 数组,走 lambdify。lambdify 之所以是“全书榫卯”,因为第 7 章的数值验证、第 9 章的绘图、第 14 章的可视化、第 15 章的优化器接入,全部要穿过这道门。如果 lambdify 用错了,后面所有“符号指导数值”的工作流都会在某个隐蔽处崩掉。

8.1 init_printing():让数学看起来像数学

你在终端里输入 sqrt(8),Python 返回 2*sqrt(2)。这是字符串,不是排版。init_printing() 做的事情是:检查当前环境(Jupyter、IPython 终端、普通脚本),选择可用的最佳渲染器,把表达式渲染成人类可读的形式。

在 Jupyter Notebook 中调用它,后续所有 SymPy 表达式会自动以 LaTeX 渲染的数学样式显示。在 IPython 终端中,它会退化为 Unicode 漂亮打印(sqrt(2) 显示为 √2)。在纯 Python 脚本中,init_printing() 几乎不起作用——脚本没有“显示”这个概念,你仍然需要显式调用 pprint() 或 print()。

这解释了前言中那条约定:本书正文用 pprint 呈现结果。书籍是静态文档,没有 Jupyter 的渲染管线,pprint 是唯一能在纸面上稳定复现的“漂亮打印”。你在自己的 Notebook 里用 init_printing(),在书里看到的是 pprint 的等价效果。

init_printing() 有一个参数值得在书籍写作场景中留意:use_latex。设为 'mathjax' 时,它生成 MathJax 可渲染的 LaTeX 文本,适合嵌入网页或文档生成流水线。如果你在写一本用 Sphinx 构建的在线书,这个设置能让 SymPy 输出直接进入页面的数学渲染层。

还有一个常被忽略的参数:order。它控制多项式和项的顺序。默认值 'lex' 按字典序排列,对 x**2 + x + 1 看起来自然;但如果你在展示一个按幂次降序排列的级数,order='rev-lex' 更符合数学阅读习惯。这不是装饰性细节——在自动生成的练习册或讲义中,项的顺序错乱会让读者怀疑答案的正确性。

init_printing() 还有一个“不可逆”的特性:它修改的是 IPython 的显示钩子,一旦调用,当前会话中所有 SymPy 对象的默认显示方式都会改变。如果你只想对某一个表达式做漂亮打印而不影响全局,用 pprint(expr) 或 display(expr) 显式调用,不要调用 init_printing()。

8.2 LaTeX 输出与文档集成

init_printing() 解决的是“看”的问题,latex() 解决的是“拿走”的问题。

PYTHON
from sympy import latex, Integral, sqrt, symbols
x = symbols('x')
print(latex(Integral(sqrt(1/x), x)))

输出:

输出
\int \sqrt{\frac{1}{x}}\, dx

这是一个字符串。你可以把它粘贴进论文的 .tex 文件,放进 $$...$$ 之间,或者塞进一个自动化报告生成脚本的模板里。latex() 的可贵之处在于它理解数学结构:分式用 \frac,根号用 \sqrt,积分用 \int,上下标自动处理。你不需要手动拼 LaTeX 命令。

对于第 13 章的练习生成器项目,这条链路是核心:SymPy 生成随机表达式 → 求解 → latex() 输出题目和答案 → 拼装成 LaTeX 文档。latex() 的保真度直接决定最终文档的可用性。

有一个实用细节:latex() 返回的字符串不包含 $ 或 $$ 定界符。你需要自己加。这让它更灵活(可以嵌入行内公式,也可以嵌入独立公式环境),但也意味着如果你忘了加定界符,LaTeX 编译器会把 \int 当成普通文本处理,报一堆错。

8.2.1 latex() 在各种结构上的输出形态

latex() 不是简单的字符串替换,它对不同的 SymPy 对象有不同的输出策略。了解这些形态,能让你在拼装文档时少踩坑。

分式与根号:latex(1/(x+1)) 输出 \frac{1}{x + 1}。latex(sqrt(x)) 输出 \sqrt{x}。latex(x**sp.Rational(1,3)) 输出 \sqrt[3]{x}——注意,SymPy 知道三分之一次幂应该写成三次根号,而不是 x^{\frac{1}{3}}。这个细节在手工拼 LaTeX 时很容易写错。

矩阵:latex(sp.Matrix([[1, x], [0, 1]])) 输出 \left[\begin{matrix}1 & x\\0 & 1\end{matrix}\right]。它使用 matrix 环境,不是 pmatrix 或 bmatrix。如果你论文的导言区没有加载 amsmath,或者你的模板期望 bmatrix,你需要自己做后处理替换。这是 latex() 输出与期刊模板之间最常见的摩擦点。

分段函数:latex(sp.Piecewise((x, x < 0), (x**2, True))) 输出 \begin{cases} x & \text{for}\: x < 0 \\x^{2} & \text{otherwise} \end{cases}。注意 \text{for} 是英文。如果你的文档是中文的,需要把 for 替换成“当”,otherwise 替换成“其他”。latex() 没有本地化参数,这个替换只能在你自己的模板层做。

积分上下限:latex(sp.Integral(x, (x, 0, 1))) 输出 \int\limits_{0}^{1} x\, dx。\limits 命令确保上下限在行内模式下也位于积分号正上下方,而不是右侧。这是 latex() 比手写更可靠的地方——手写时经常忘记 \limits,导致行内公式的积分上下限位置错乱。

求和与连乘:latex(sp.Sum(1/x**2, (x, 1, sp.oo))) 输出 \sum_{x=1}^{\infty} \frac{1}{x^{2}}。无穷用 \infty,求和号的下标自动处理。同样的,如果你在行内使用,可能需要手动加 \limits。

8.2.2 假设如何影响 LaTeX 输出

这是 latex() 与第 10 章假设系统的第一个交点,值得提前点明。

PYTHON
x_pos = sp.Symbol('x', positive=True)
x_gen = sp.Symbol('x')
print(latex(sp.Abs(x_pos)))  # x
print(latex(sp.Abs(x_gen)))  # \left|{x}\right|

当符号被声明为 positive=True 时,Abs(x) 在 SymPy 内部自动化简为 x,latex() 自然输出 x。当符号没有假设时,latex() 输出绝对值符号。这意味着:同一段 LaTeX 生成代码,在不同假设下会产出不同的公式。如果你的文档需要展示“一般情形”和“正数情形”的对比,你需要用两个不同假设的符号分别生成,不能指望 latex() 自动加条件说明。

反过来,如果你在论文中写的是“设 $x > 0$”,而你的 SymPy 符号没有加 positive=True,latex() 输出的公式里可能残留 \left|{x}\right|,与正文的假设矛盾。第 10 章会详细讨论假设系统的语义;这里只需记住:latex() 的输出忠实地反映了符号的假设状态,它不会替你补上“在 $x>0$ 时”这样的文字条件。

8.2.3 从 LaTeX 字符串到论文片段

latex() 输出的是数学表达式,不是完整的 LaTeX 文档。实战中,你通常需要把它嵌入一个模板。一个最小可用的模板层长这样:

PYTHON
def equation_block(expr, label=None):
    body = sp.latex(expr)
    tag = f"\\label{{{label}}}" if label else ""
    return f"\\begin{{equation}}\n{body}\n{tag}\n\\end{{equation}}"

这个函数把 latex() 的输出包进 equation 环境,可选地加 \label。对于第 13 章的练习生成器,你需要的是 enumerate 环境加 \item 的拼装;对于第 8.5 节的论文场景,equation 环境足够。关键认识是:latex() 负责数学,模板负责结构。不要把结构逻辑塞进 latex() 的输出后处理里,那会让代码难以维护。

8.3 代码生成:codegen(生成 C/Fortran 源码)

latex() 把表达式交给排版系统,codegen 把表达式交给编译器。

PYTHON
from sympy.utilities.codegen import codegen
from sympy.abc import x, y, z
[(c_name, c_code), (h_name, h_header)] = codegen(
    ("f", x + y*z), "C89", "test", header=False, empty=False
)
print(c_code)

输出:

C
#include "test.h"
#include <math.h>
double f(double x, double y, double z) {
   double f_result;
   f_result = x + y*z;
   return f_result;
}

这个函数接受 (名称, 表达式) 元组列表,输出目标语言(C、F95、Octave)的源码字符串。生成的 C 代码可以直接编译成共享库,供 Python 的 ctypes 或 Cython 调用。

8.3.1 Fortran 输出

把 "C89" 换成 "F95",得到 Fortran 95 源码:

PYTHON
[(f_name, f_code)] = codegen(("f", x + y*z), "F95", "test")
print(f_code)

输出(节选):

FORTRAN
REAL*8 function f(x, y, z)
implicit none
REAL*8, intent(in) :: x
REAL*8, intent(in) :: y
REAL*8, intent(in) :: z
f = x + y*z
end function f

Fortran 输出对科学计算场景有实际意义:很多 legacy 数值库和 HPC 集群仍然以 Fortran 为主,codegen 让你可以把符号推导的公式直接注入这些环境,不需要手工翻译。

8.3.2 codegen 与 lambdify 的定位对比

维度codegenlambdify
产出形态源码文件(C/F95/Octave)当前进程内的 Python 函数对象
运行依赖目标语言的编译器/运行时NumPy / SciPy / math
部署场景嵌入实时系统、固化到生产库交互探索、数值验证、绘图
迭代速度改表达式后需重新生成+编译改表达式后重新 lambdify 即可
性能上限原生代码,无 Python 解释开销接近 NumPy 向量化速度,但有调用开销
调试难度需在目标语言层面调试在 Python 层面调试

codegen 和 lambdify 的定位区别值得说清楚。lambdify 生成的是当前 Python 进程内可调用的函数对象,依赖 NumPy/SciPy 的运行时;codegen 生成的是脱离 SymPy 和 Python 的独立源码,编译后可以在任何有 C 编译器的环境中运行。如果你的目标是嵌入一个实时系统、生成一个不需要 Python 环境的数值库、或者把符号推导的结果固化到生产代码中,codegen 是出口。如果你的目标是快速验证、交互式探索、或者在 Python 生态内完成计算,lambdify 更顺手。

对于本书的读者——数学工作者而非软件工程师——codegen 的实用频率远低于 lambdify。知道它存在、知道它输出什么形态,就足够了。真正需要把公式变成 C 代码时,再回来查文档。

8.3.3 一个容易踩的坑:codegen 不做假设检查

codegen 生成的代码忠实翻译表达式的数学结构,但它不检查假设条件是否在目标语言中成立。例如,sqrt(x2) 在 SymPy 中没有假设时保持原样,codegen 生成的 C 代码是 sqrt(x*x),对负数 x 返回 nan。如果你在 SymPy 中声明了 x 是正数,sqrt(x2) 化简为 x,codegen 直接输出 x,没有 sqrt 调用。这两种输出对应不同的数学前提,但生成的函数签名都是 double f(double x),调用者看不出区别。

这是一个“符号假设泄漏到数值代码”的典型场景。第 10 章会展开假设系统如何影响化简;这里提前提醒:codegen 和 lambdify 一样,输出的是在给定假设下正确的代码,假设本身不会出现在生成的源码中。

8.4 lambdify 对接 NumPy / SciPy:后端选择、向量化与广播陷阱

这是本章的核心,也是全书榫卯所在。

第 7.5.4 节你已经见过 lambdify 的最小用法:

PYTHON
x_num_func = sp.lambdify(t, sol_particular.rhs, 'numpy')

一行代码,符号表达式 sol_particular.rhs 变成可接受 NumPy 数组的函数。但这行代码背后有三个决策点,每一个选错都会在后续工作流中制造麻烦。

8.4.1 它在做什么:命名空间翻译

理解 lambdify 的最好方式,是把它看作一次命名空间劫持。

假设你有两个 Python 文件。文件 A:

PYTHON
from sympy import sin, cos
def f(x):
    return sin(x) + cos(x)

文件 B:

PYTHON
from numpy import sin, cos
def f(x):
    return sin(x) + cos(x)

函数体一字不差,但 A 的 f(1) 返回符号表达式 sin(1) + cos(1),B 的 f(1) 返回浮点数 1.381...。区别在于 Python 在函数定义时绑定的全局命名空间不同。

lambdify 做的就是这件事:它用 exec 在自定义的全局字典中执行一个函数定义,字典里的名字指向你指定的数值后端。表达式里的 sin 原本是 sympy.sin,翻译后变成了 numpy.sin;I 原本是 SymPy 的虚数单位,在 NumPy 后端下被映射为 1j。

这个机制解释了 lambdify 的一个基本特性:它不改变表达式的数学含义,只改变求值时调用的底层函数。sin(x)/x 在 x=0 处的奇点不会因为换了后端就消失,lambdify 生成的函数仍然会在 x=0 时报 ZeroDivisionError 或产生 nan。

lambdify 的翻译粒度是“函数名到函数名的映射”,不是“表达式重写”。它不会替你化简、不会替你处理奇点、不会替你展开。这些工作必须在调用 lambdify 之前,在符号层面完成。

8.4.2 后端选择:modules 参数

lambdify(符号, 表达式, modules=...) 的第三个参数决定翻译到哪个命名空间。默认值 None 会触发自动探测:安装了 SciPy 就用 ["numpy", "scipy"],只有 NumPy 就用 ["numpy"],两者都没有就回退到 ["math", "mpmath", "sympy"]。

显式指定后端是更好的习惯。以下是三种你会在本书范围内用到的选择:

'numpy':标准选择。生成的函数接受标量、列表、NumPy 数组,返回 NumPy 数组。速度在每元素 10 纳秒量级,比 SymPy 的 .subs() 快两个数量级。第 7 章的数值验证、第 9 章的绘图数据生成、第 15 章的优化器目标函数,全部走这条路。

'math':只处理标量,不依赖 NumPy。如果你的函数只需要在循环里被调用几千次,且环境里没有 NumPy,这个后端足够。但一旦输入变成数组,它会报错。本书正文中除非特别说明,默认使用 'numpy'。

'scipy':当表达式里包含 SciPy 特有的特殊函数(如 spherical_jn)时使用。lambdify 的 SciPy 后端会把 SymPy 的 jn 映射到 scipy.special.spherical_jn。普通表达式不需要显式指定 'scipy',因为 'numpy' 后端加上 SciPy 的自动探测已经覆盖了大多数情况。

后端输入输出依赖适用场景
'numpy'标量/数组数组NumPy默认选择,向量化计算
'math'标量标量无无 NumPy 环境、纯标量循环
'scipy'标量/数组数组SciPy特殊函数(贝塞尔、球谐等)
None(自动)取决于探测结果取决于探测结果有则用不确定时的安全回退

对于第 15 章“符号-数值混合优化”,lambdify 的目标函数需要同时计算函数值和梯度。如果梯度表达式是一个 SymPy Matrix,'numpy' 后端会返回一个嵌套数组,形状可能不是你想要的。这是下一小节的主题。

8.4.3 向量化与广播陷阱

lambdify 配合 NumPy 最诱人的地方是向量化:f(np.linspace(0, 1, 1000)) 一行代码得到一千个值。但 SymPy 的表达式树和 NumPy 的广播规则之间有一条裂缝,几乎每个从符号计算转向数值计算的人都会掉进去一次。

问题的最小复现:考虑一个符号矩阵,其中只有部分元素依赖变量:

PYTHON
x = sp.symbols('x')
M = sp.Matrix([[1, x, 3]])
f = sp.lambdify(x, M, 'numpy')
f(np.array([1, 2, 3]))

你期望得到一个 3×3 矩阵,每一行对应一个 x 值。实际得到的是:

输出
ValueError: setting an array element with a sequence.

原因在于 lambdify 生成的 NumPy 代码尝试构造 np.array([[1, x_vals, 3]]),其中 x_vals 是一个长度为 3 的数组。NumPy 面对“标量和数组混在同一个嵌套结构中”的情况,无法推断出一个规则的二维形状。1 和 3 是标量,x_vals 是向量,它们不在同一个广播层级上。

这不是 SymPy 的 bug,是语义边界。SymPy 的 Matrix 是符号对象的容器,它没有“逐元素广播”的概念。NumPy 的数组有广播概念,但要求输入在构造时就具有兼容的形状。lambdify 站在两者之间,无法自动调和。

稳健的解法:逐点求值,然后堆叠。

PYTHON
f_scalar = sp.lambdify(x, M, 'numpy')
result = np.vstack([f_scalar(val).flatten() for val in np.array([1, 2, 3])])

f_scalar(val) 对每个标量 val 返回一个 1×3 的数组,vstack 把它们叠成 3×3。这个方法看起来笨,但行为完全可预测。对于矩阵输出的 lambdify 函数,这是唯一不需要祈祷的写法。

一个更隐蔽的变体:表达式本身是标量,但包含 Piecewise:

PYTHON
f = sp.lambdify(x, sp.Piecewise((x, x <= 1), (1/x, x > 1)), 'numpy')
f(0)  # ZeroDivisionError
f(np.array([0]))  # array([0.]),正常工作

Piecewise 在 NumPy 后端下会生成 np.select 或类似的条件求值代码。当输入是标量时,NumPy 的条件逻辑可能仍然尝试计算 1/x 分支,导致除零错误。传入数组(哪怕长度为 1)可以绕过这个问题,因为 np.select 对数组的惰性分支处理是正确的。

第三个变体:多变量矩阵。当矩阵的多个元素依赖多个变量,且你想沿其中一个变量做向量化时,逐点堆叠仍然是唯一安全策略:

PYTHON
a, b = sp.symbols('a b')
M = sp.Matrix([[a*b, a+b], [a-b, a**2]])
f = sp.lambdify((a, b), M, 'numpy')
# 固定 b,沿 a 向量化
b_val = 2.0
rows = [f(ai, b_val).flatten() for ai in np.linspace(0, 1, 5)]
result = np.vstack(rows)  # 5×4,每行是一个 a 值对应的展平矩阵

注意 flatten() 把 2×2 矩阵压成长度 4 的向量。如果你需要保留矩阵结构,用 np.stack 并指定 axis,而不是 vstack。

第四个变体:Matrix 与 Piecewise 混合。这是最恶劣的情况:

PYTHON
M = sp.Matrix([[sp.Piecewise((x, x > 0), (0, True)), 1]])
f = sp.lambdify(x, M, 'numpy')
f(np.array([-1, 1]))  # ValueError

Piecewise 的分支逻辑希望看到数组,矩阵构造希望看到标量,两者在同一个表达式中冲突。唯一可靠的解法是外层逐点、内层传数组:

PYTHON
f_scalar = sp.lambdify(x, M, 'numpy')
result = np.vstack([f_scalar(np.array([val])).flatten() for val in [-1, 1]])

每个 val 被包装成长度 1 的数组传入,Piecewise 的 np.select 正常工作,矩阵构造拿到的是长度为 1 的数组,flatten() 后堆叠。代价是每个点一次函数调用,但对于矩阵输出,这本来就是必要开销。

规则总结:如果你 lambdify 的表达式包含 Piecewise、Matrix、或者任何在“标量 vs 数组”语义上有分歧的结构,始终传入 NumPy 数组,不要传裸标量。f(np.array([0])) 比 f(0) 更安全,代价是一个微秒级的数组包装开销。矩阵输出永远逐点求值再堆叠,不要试图让广播自动解决形状问题。

8.4.4 cse 参数:公共子表达式消除

lambdify 有一个容易被忽略的参数:cse=True。

当表达式很复杂时,lambdify 默认生成的代码会重复计算相同的子表达式。例如 sin(x)*cos(x) + sin(x)**2 中 sin(x) 出现两次。cse=True 会让 lambdify 在生成代码前先执行公共子表达式消除,把重复部分提取为临时变量。

对于单次调用的简单表达式,这个优化没有可感知的收益。但如果你生成的函数要在优化器中被调用数万次,或者表达式来自第 11 章讨论的“表达式膨胀”场景,cse=True 能把求值时间降低一个可观的因子。第 15 章接入 SciPy 优化器时,目标函数和梯度函数都应该加上 cse=True。

cse 的收益与表达式的重复度成正比。一个经验判据:如果你在 sp.pprint(expr) 的输出中看到同一个子表达式出现三次以上,cse=True 值得加。如果表达式本身很短,加不加没有区别,但加上不会有坏处——lambdify 会自动判断哪些子表达式值得提取。

8.4.5 一个完整的稳健封装

把上面的规则固化成一个工具函数,后续章节可以直接复用:

PYTHON
def safe_lambdify(vars, expr, modules='numpy', cse=True):
    """对 Matrix/Piecewise 输出做逐点求值的 lambdify 封装。"""
    f = sp.lambdify(vars, expr, modules=modules, cse=cse)
    is_matrix = isinstance(expr, sp.MatrixBase)
    has_piecewise = expr.has(sp.Piecewise)
    if not (is_matrix or has_piecewise):
        return f
    def wrapped(*args):
        # 所有参数统一包装成长度 1 的数组
        arr_args = [np.atleast_1d(a) for a in args]
        out = f(*arr_args)
        if is_matrix:
            return np.array(out).reshape(len(arr_args[0]), -1)
        return out
    return wrapped

这个封装不是万能的(它假设所有参数长度相同,且沿第一个参数向量化),但它覆盖了本书后续章节 90% 的场景。第 15 章的优化器接入会用到它的变体:优化器通常传标量,atleast_1d 包装后 reshape 回标量形状。

8.5 实战:SymPy → LaTeX → 论文

本节用一个完整的微型工作流串起本章的所有工具。目标:从符号推导出发,生成一段可直接粘贴进论文的 LaTeX 内容,同时生成一个用于绘制论文插图的数值函数。

场景:推导阻尼振荡的包络线表达式,并准备插图数据。

PYTHON
import sympy as sp
t, zeta, omega = sp.symbols('t zeta omega', positive=True)
# 符号推导:欠阻尼包络
omega_d = omega * sp.sqrt(1 - zeta**2)
envelope = sp.exp(-zeta * omega * t)
response = envelope * sp.cos(omega_d * t)
# 输出 LaTeX(论文正文用)
print("LaTeX 公式:")
print(sp.latex(sp.Eq(sp.Symbol('x'), response)))

输出:

输出
x = e^{- \zeta \omega t} \cos{\left(\omega t \sqrt{1 - \zeta^{2}} \right)}

这段字符串放入论文的 equation 环境即可。注意 latex() 自动处理了指数、三角函数、根号、希腊字母,不需要手动调整。

接下来,生成绘图数据:

PYTHON
import numpy as np
import matplotlib.pyplot as plt
# 固定参数,lambdify 为 t 的单变量函数
params = {zeta: 0.1, omega: 2*np.pi}
resp_func = sp.lambdify(t, response.subs(params), 'numpy')
t_vals = np.linspace(0, 10, 500)
x_vals = resp_func(t_vals)
# 包络线
env_func = sp.lambdify(t, envelope.subs(params), 'numpy')
env_vals = env_func(t_vals)
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(t_vals, x_vals, label='响应')
ax.plot(t_vals, env_vals, '--', label='包络')
ax.plot(t_vals, -env_vals, '--')
ax.legend()
plt.tight_layout()
plt.savefig('oscillation.pdf', dpi=150)

这条流水线的每一段都对应本章一个节:sp.latex 产出论文公式,sp.lambdify 产出可向量化求值的数值函数,Matplotlib 消费数组产出插图。SymPy 在中间充当了“数学编译器”:从符号形式编译到 LaTeX,编译到 NumPy 函数,编译到 C 代码(如果需要的话)。

一个值得养成的习惯:在 lambdify 之前,先对表达式做 subs 把不参与绘图的参数固定下来,让生成的函数只依赖真正变化的变量。这样 lambdify 生成的代码更短,NumPy 的广播路径更清晰,调试时也更不容易被多余的符号搞晕。

8.5.1 从 LaTeX 字符串到完整论文片段

实战中,论文里的一个公式通常不是孤立的 latex() 输出,而是嵌在带编号、带引用、带前后文的 equation 环境中。一个可复用的模板函数:

PYTHON
def paper_equation(expr, label, prefix="eq:"):
    body = sp.latex(expr)
    return (
        "\\begin{equation}\n"
        f"  {body}\n"
        f"  \\label{{{prefix}{label}}}\n"
        "\\end{equation}"
    )
# 生成响应公式和包络公式
eq_resp = paper_equation(sp.Eq(sp.Symbol('x(t)'), response), "response")
eq_env = paper_equation(sp.Eq(sp.Symbol('E(t)'), envelope), "envelope")
print(eq_resp)
print(eq_env)

输出可以直接拼进 .tex 文件。\label 让你在正文中用 \eqref{eq:response} 引用,编号由 LaTeX 自动管理。这个模板层只有五行,但它把“数学正确”和“文档正确”分开了:latex() 保证公式的数学结构,模板保证引用和编号的结构。

8.5.2 一个完整的“符号推导 → 论文片段”流水线

把上面的片段串成一个函数,输入是符号表达式和元数据,输出是一段可粘贴的 LaTeX:

PYTHON
def build_paper_section(title, equations, figure_path=None):
    lines = [f"\\section{{{title}}}", ""]
    for expr, label, caption in equations:
        lines.append(paper_equation(expr, label))
        lines.append(f"% {caption}")
        lines.append("")
    if figure_path:
        lines.append("\\begin{figure}[htbp]")
        lines.append("  \\centering")
        lines.append(f"  \\includegraphics[width=0.8\\textwidth]{{{figure_path}}}")
        lines.append("  \\caption{阻尼振荡响应与包络}")
        lines.append("\\end{figure}")
    return "\n".join(lines)

调用:

PYTHON
section = build_paper_section(
    "阻尼振荡",
    [
        (sp.Eq(sp.Symbol('x(t)'), response), "response", "位移响应"),
        (sp.Eq(sp.Symbol('E(t)'), envelope), "envelope", "包络线"),
    ],
    figure_path="oscillation.pdf",
)
with open("section.tex", "w") as f:
    f.write(section)

这个流水线的价值在于可复现:修改符号表达式,重新运行,论文片段和插图同步更新。第 12 章会讨论可复现工作流的更多细节;这里已经展示了最小闭环——符号推导是唯一真相源,LaTeX 和图片都是它的派生产物。

8.6 本章闭环

工具消费者产出典型场景
init_printing()人(交互环境)渲染后的显示Jupyter 探索
latex()LaTeX 编译器.tex 字符串论文、报告生成
codegen()C/Fortran 编译器源码文件生产代码固化
lambdify()NumPy/SciPy可调用函数对象数值验证、绘图、优化

lambdify 占据本章一半以上的篇幅,因为它是后续章节反复调用的转换层。第 9 章在把符号结果叠加到 Matplotlib 图形上时会用到它;第 11 章从性能角度重新审视它;第 15 章把它嵌入优化循环。第 7.5 节已经演示了它在 ODE 数值验证中的用法,那条链路的后半段在本章被完整展开。

《SymPy 数学编程》 · 第 8 章 打印与输出 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 9 章 绘图与可视化

SymPy 原生 plot 基础 · 隐函数绘图与 3D 曲面 · 符号结果叠加 · plot 与 Matplotlib 协同 · 实战:符号分析驱动的可视化报告 · 本章闭环

第 8 章把符号表达式送出了 SymPy:送给 LaTeX 编译器、送给 C 编译器、送给 NumPy。本章处理最后一种去向:送给人的眼睛。

但“画图”在 SymPy 里有一个容易被忽略的前提:符号表达式本身没有形状。sin(x)/x 是一棵表达式树,不是一条曲线。要让这条曲线出现在屏幕上,必须先把表达式在采样点上求值,再把数值数组交给绘图后端。这个转换过程就是第 8.4 节 lambdify 做的事,或者它的等价物。SymPy 的原生 plot() 把这个过程封装起来了,你不需要手动调用 lambdify,但封装不意味着消失。理解这层封装在哪里、什么时候会漏,是本章的核心。

9.1 SymPy 原生 plot() 基础:显函数与参数方程

SymPy 的绘图模块提供六个便利函数:plot(二维显函数)、plot_parametric(二维参数方程)、plot_implicit(隐函数与区域)、plot3d(三维曲面)、plot3d_parametric_line(三维参数曲线)、plot3d_parametric_surface(三维参数曲面)。它们全部以 Matplotlib 为默认后端渲染。

9.1.1 显函数:plot

最简用法与你在任何绘图库中见过的没有区别:

PYTHON
from sympy import symbols, sin, plot
x = symbols('x')
p = plot(sin(x)/x, (x, -10, 10), show=False)
p.title = 'sinc 函数'
p.xlabel = 'x'
p.ylabel = 'sin(x)/x'
p.show()

show=False 是一个关键参数。默认 show=True 会立即弹出图形窗口,在脚本或 Notebook 中这通常不是你想要的——你往往需要先设置标题、坐标轴标签、或者把多个曲线叠加到同一张图上。show=False 返回一个 Plot 对象,你修改它的属性后再调用 .show()。

Plot 对象支持索引访问,p[0] 是第一组数据系列。对于多曲线图:

PYTHON
p = plot(sin(x), cos(x), (x, 0, 2*3.14159), show=False,
         legend=True, ylabel='y')
p[0].line_color = 'blue'
p[1].line_color = 'red'
p[0].label = 'sin(x)'
p[1].label = 'cos(x)'
p.show()

legend=True 开启图例,但图例文本来自每个系列的 label 属性。如果不手动设置,图例会显示表达式的字符串形式,对简单函数尚可,对复杂表达式就是灾难。

采样点数由 n 参数控制,默认值取决于表达式复杂度。对于快速振荡的函数(比如 sin(100*x)),默认采样可能不足,曲线会出现走样。plot(sin(100*x), (x, 0, 1), n=2000) 能显著改善。这是数值采样绘图的老问题,与 SymPy 无关,但符号绘图的一个优势是:你可以先用 solve 或 diff 找到函数的特征点(零点、极值、拐点),再据此决定采样策略,而不是盲目调大 n。

9.1.2 参数方程:plot_parametric

参数方程绘图接受两个表达式和一个参数范围:

PYTHON
from sympy import cos, sin, pi, plot_parametric
t = symbols('t')
plot_parametric(cos(t), sin(t), (t, 0, 2*pi), aspect_ratio=(1, 1))

aspect_ratio=(1, 1) 确保单位圆在屏幕上看起来是圆而不是椭圆。这个参数在几何图形的绘图中几乎是必需的。

参数方程绘图的一个实际用途是处理显函数绘图无法直接表达的形状。例如,摆线的参数形式 x = t - sin(t), y = 1 - cos(t),在 plot 中无法直接写成一个 y = f(x),必须走 plot_parametric。

9.1.3 一个容易误解的细节:plot 返回什么

plot() 返回的 Plot 对象不是 Matplotlib 的 Figure 或 Axes。它有自己的内部结构,只是在渲染时调用 Matplotlib 后端。这意味着你不能把 plot() 的返回值直接传给 Matplotlib 的函数(比如 plt.colorbar(p))。如果你需要 Matplotlib 层面的控制,必须走下一节讨论的协同路径,或者从 Plot 对象中提取底层 Figure。

9.2 隐函数绘图与 3D 曲面

9.2.1 plot_implicit:隐函数与不等式区域

plot_implicit 处理的是 Eq(f(x,y), g(x,y)) 形式的方程,或者不等式。默认使用区间算术进行自适应采样,失败时回退到固定网格的等值线方法。

PYTHON
from sympy import plot_implicit, Eq, symbols
x, y = symbols('x y')
plot_implicit(Eq(x**2 + y**2, 5), (x, -3, 3), (y, -3, 3))

不传范围时,SymPy 会按自由符号的排序自动分配范围,但显式指定范围几乎总是更好的选择——自动范围可能过大或过小,导致曲线细节丢失或图形溢出。

不等式区域同样支持:

PYTHON
plot_implicit(x**2 + y**2 <= 1, (x, -2, 2), (y, -2, 2))

这会填充单位圆盘。adaptive=False 强制使用网格方法,n=300 控制网格密度。对于简单的隐函数,自适应方法通常够用;对于包含不连续或高度振荡边界的区域,网格方法加上适当增大的 n 更可控。

plot_implicit 的能力边界值得提前明确:它处理的是二维隐式关系,不是三维曲面。三维隐式曲面(如 x2 + y2 + z**2 = 1)不在原生 plot_implicit 的支持范围内,需要借助等值面提取库(如 scikit-image 的 measure.marching_cubes)在数值层面完成。本书不展开这条路径,但读者应该知道:SymPy 原生绘图覆盖的是二维隐函数和显式三维曲面,不覆盖三维隐式曲面。

9.2.2 plot3d:显式曲面

plot3d 接受两个变量的表达式和两个范围:

PYTHON
from sympy import plot3d, sqrt
plot3d(sqrt(x**2 + y**2), (x, -5, 5), (y, -5, 5))

这画出锥面。多曲面叠加:

PYTHON
plot3d(x**2 + y**2, 5 - x**2 - y**2,
       (x, -3, 3), (y, -3, 3))

两个曲面在同一坐标系中显示。采样网格密度由 n1 和 n2 控制(分别对应两个变量),默认值通常足够预览,但出版级图形建议显式调高。

plot3d 的渲染质量受 Matplotlib 三维投影的限制。它不进行光线追踪或真正的透视校正,只做简单的深度排序。对于需要高质量三维渲染的场景,SymPy 原生 plot3d 不是终点;但它的价值在于快速验证符号推导的几何形态。你在第 6 章算出某个二次型的形状,用 plot3d 看一眼是鞍面还是抛物面,几秒钟的事,不需要切换到专门的渲染软件。

9.2.3 三维参数曲线与曲面

plot3d_parametric_line 绘制由单参数定义的空间曲线:

PYTHON
from sympy import plot3d_parametric_line
u = symbols('u')
plot3d_parametric_line(cos(u), sin(u), u, (u, -5, 5))

这是螺旋线。plot3d_parametric_surface 绘制双参数曲面:

PYTHON
from sympy import plot3d_parametric_surface
u, v = symbols('u v')
plot3d_parametric_surface(
    cos(u + v), sin(u - v), u - v,
    (u, -5, 5), (v, -5, 5)
)

参数曲面的采样由 n1(u 方向)和 n2(v 方向)分别控制。对于球面、环面等标准参数曲面,默认采样通常足够;对于自相交或高度扭曲的曲面,调高采样并接受渲染时间的增加是必要的妥协。

9.3 将符号结果叠加到图上

这是本章与第 8 章 lambdify 交接口径最直接的一节。

SymPy 原生 plot() 适合探索,但发表级图形通常需要 Matplotlib 的完整控制:自定义刻度、注释箭头、参考线、多子图布局、精确的线宽和字体。在这些场景下,正确的做法不是和 plot() 的封装搏斗,而是用 lambdify 生成数值数组,直接在 Matplotlib 的 Axes 上绘制。

PYTHON
import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
x = sp.symbols('x')
expr = sp.sin(x)/x
f = sp.lambdify(x, expr, 'numpy')
x_vals = np.linspace(-10, 10, 500)
y_vals = f(x_vals)
fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x_vals, y_vals, label=r'$\frac{\sin x}{x}$')
ax.axhline(0, color='gray', linewidth=0.5)
ax.axvline(0, color='gray', linewidth=0.5)
ax.set_xlabel('$x$')
ax.set_ylabel('$y$')
ax.legend()
plt.tight_layout()
plt.savefig('sinc.pdf')

这条路径就是第 8.5 节已经演示过的工作流。它的优势在于:你对采样点有完全控制权。如果 sin(x)/x 在原点附近需要更密的采样来展示极限行为,你可以构造非均匀的 x_vals(比如在 [-0.1, 0.1] 区间加密),而 plot() 的自动采样策略不允许这种细粒度控制。

把符号推导的特征点叠加到数值曲线上,是符号计算在绘图中的一个独特优势:

PYTHON
# 符号求极值点
critical = sp.solve(sp.diff(expr, x), x)
critical = [c for c in critical if c.is_real]
# 数值绘图
ax.plot(x_vals, y_vals, label='f(x)')
for c in critical:
    ax.plot(float(c), float(expr.subs(x, c)), 'ro', markersize=4)

sp.solve(sp.diff(expr, x), x) 给出精确的临界点,float(c) 把它们送入 Matplotlib 的坐标系。这一步在纯数值工作流中需要先做数值求导再找根,精度和可靠性都不如符号方法。符号计算在绘图中的角色,不是替代绘图库,而是为绘图提供精确的几何骨架。

同样的模式适用于第 7 章的 ODE 解。第 7.5.4 节已经用 lambdify 把符号解和 solve_ivp 的数值解做了对比;把这两条曲线画在同一张图上,差异视觉化之后,比打印数字更有说服力:

PYTHON
ax.plot(t_test, x_num_func(t_test), 'o', label='符号解采样')
ax.plot(t_dense, sol_num.sol(t_dense)[0], '-', label='数值解')

9.4 SymPy plot 与 Matplotlib 的协同、两套绘图体系差异

SymPy 原生 plot 和 Matplotlib 是嵌套关系,不是并列关系。plot() 内部实例化一个 Matplotlib 后端对象,把数据系列传给它渲染。这意味着两件事:第一,你不需要同时学两套绘图语法来做简单图形;第二,当你需要 Matplotlib 层面的控制时,必须穿透 plot() 的封装。

穿透的方式有两种,各有适用场景。

方式一:从 Plot 对象提取底层 Figure。SymPy 的 Plot 对象有一个 _backend 属性,指向当前后端实例,其中包含 fig(Matplotlib Figure)和 ax(Axes):

PYTHON
p = plot(sin(x), (x, 0, 10), show=False)
backend = p.backend(p)
fig = backend.fig
ax = backend.ax
# 现在可以用 Matplotlib 的方式操作 ax
ax.set_facecolor('#f0f0f0')
p.show()

这个方式在 SymPy 的不同版本中行为可能变化(_backend 是私有属性),适合临时调整,不适合写进长期维护的代码。

方式二:完全绕过 plot(),直接用 lambdify + Matplotlib。这是第 9.3 节演示的路径,也是本书推荐的路径。它没有任何私有 API 依赖,行为完全可预测,且与第 8 章的榫卯一致。

两套绘图体系的差异,可以归结为一张表:

维度SymPy 原生 plotMatplotlib 直接绘图
入口符号表达式数值数组
采样控制n 参数,自动策略为主完全手动
图形定制有限的属性(title、label、color)无限制
多图布局不直接支持subplots、GridSpec
适用阶段探索、快速验证发表、报告、精细调整
与第 8 章关系内部封装了等价于 lambdify 的转换显式使用 lambdify

这张表的核心判断是:先用 plot() 看形状,再用 lambdify + Matplotlib 做成品。两者不是竞争关系,是流水线的上下游。

最后,本章必须明确与本站附录 D 的 plotly 体系划清边界。第 7.6 节已经预告过:本站有一套 MATLAB 风格的 plotly 绘图函数(Plot、Plot3D、ParametricPlot*、ContourPlot、VectorPlot* 等),它们与 SymPy 原生 plot 是完全不同的实现。SymPy 原生 plot 基于 Matplotlib 后端;本站的 plotly 系列基于 Plotly 的 JavaScript 渲染管线。两者在函数名上可能相似(都叫 plot 或 Plot),但导入路径、参数语义、返回对象、渲染结果都不同。本书正文只讨论 SymPy 原生 plot + Matplotlib 这条线;plotly 系列的说明与示例链接见附录 D。如果你在本书中看到 plot 而不加限定,它指的是 sympy.plotting.plot。

9.5 实战:符号分析驱动的可视化报告

本节把本章的工具串成一条完整的工作流:从符号推导出发,用符号分析确定图形的关键特征,再用数值绘图呈现,最后输出可直接嵌入文档的图形文件。

场景:分析函数 f(x) = x**3 - 3*x 的性态,并生成一张标注了极值点和拐点的图。

PYTHON
import sympy as sp
import numpy as np
import matplotlib.pyplot as plt
x = sp.symbols('x')
f = x**3 - 3*x
# 符号分析:极值点和拐点
df = sp.diff(f, x)           # 3*x**2 - 3
critical = sp.solve(df, x)   # [-1, 1]
d2f = sp.diff(f, x, 2)       # 6*x
inflection = sp.solve(d2f, x)  # [0]
# 数值化
f_num = sp.lambdify(x, f, 'numpy')
x_vals = np.linspace(-2.5, 2.5, 400)
y_vals = f_num(x_vals)
# 绘图
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x_vals, y_vals, 'b-', linewidth=1.5, label=r'$f(x)=x^3-3x$')
# 标注极值点
for c in critical:
    yc = float(f.subs(x, c))
    ax.plot(float(c), yc, 'ro', markersize=5, zorder=5)
    ax.annotate(f'({c}, {yc})', (float(c), yc),
                textcoords='offset points', xytext=(8, 8))
# 标注拐点
for c in inflection:
    yc = float(f.subs(x, c))
    ax.plot(float(c), yc, 'gs', markersize=5, zorder=5)
    ax.annotate(f'拐点 ({c}, {yc})', (float(c), yc),
                textcoords='offset points', xytext=(8, -12))
ax.axhline(0, color='gray', linewidth=0.5)
ax.axvline(0, color='gray', linewidth=0.5)
ax.set_xlabel('$x$')
ax.set_ylabel('$f(x)$')
ax.legend(loc='upper left')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('cubic_analysis.pdf', dpi=150)

这条流水线的关键判断在于:极值点和拐点的坐标来自符号求解,不是数值近似。sp.solve(df, x) 返回精确的 [-1, 1],float(c) 只是最后一步的格式转换。如果把这段代码改成纯 NumPy 工作流,你需要先用 np.gradient 近似导数,再用 np.where 找符号变化,精度和可靠性都取决于采样密度。符号计算在这里的角色不是“另一种求导方式”,而是提供不需要调参的精确骨架。

同样的模式可以推广到参数曲线。例如,你从第 14 章的单摆项目得到了小角近似解 θ(t) = θ0*cos(sqrt(g/L)*t),想把它和非线性数值解画在一起。符号分析告诉你小角近似的频率是 sqrt(g/L)/(2π),你可以据此决定绘图的时间范围——画三个周期就够看出相位漂移,不需要画一百个周期然后什么也看不清。

关于图形输出的一个实用建议:savefig 的格式选择取决于下游消费者。.pdf 适合 LaTeX 论文(矢量,字体可嵌入);.png 适合网页和快速预览;.svg 适合需要进一步编辑的矢量工作流。第 8 章的 latex() 输出公式,本章的 savefig 输出图形,两者在论文中汇合。如果你在写一本用 LaTeX 排版的文档,图文件用 PDF、公式用 latex() 字符串,整条流水线不需要任何位图转换。

9.6 本章闭环

工具输入输出典型场景
plot / plot_parametric符号表达式 + 范围Plot 对象 → 屏幕图形快速探索
plot_implicit隐式方程/不等式区域或曲线几何关系验证
plot3d / plot3d_parametric_*双变量表达式/参数三维曲面/曲线多元函数形态
lambdify + Matplotlib符号表达式 → 数值数组完全可控的 Axes 图形发表级输出

本章的绘图路径与第 8 章一脉相承:原生 plot 适合探索,lambdify + Matplotlib 适合成品,后者是第 8 章榫卯在可视化场景中的直接应用。本书中不带限定的 plot 一律指 sympy.plotting.plot(Matplotlib 后端),本站 plotly 系列的说明见附录 D。第 14 章的单摆项目会复用 9.3 节的叠加绘图模式,用小角近似解与数值解的相位漂移说明线性与非线性方程的分界。下一章进入假设系统——那个让 90% 的 integrate、simplify、solve 失败被归咎于“bug”的真正原因。

《SymPy 数学编程》 · 第 9 章 绘图与可视化 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 10 章 假设系统与精炼类型

先看失败案例 · Symbol 假设的力量 · 假设影响化简与求解 · 自定义假设与精炼 · 假设的陷阱与调试 · 本站对照:特殊函数一览 · 本章闭环

第 9 章结束时,你画的图可能让你困惑:为什么 sqrt(x2) 在图上显示的是 |x| 而不是 x?为什么 integrate(exp(-a*x), (x, 0, oo)) 在你确信 a>0 的情况下返回一个带条件的分段表达式,甚至直接返回未求值的积分?为什么 solve(x2 - 2*a*x + a**2, x) 给出的解看起来"不够简洁"?

这些问题的答案几乎总是同一个:符号缺少假设条件。

本章开篇要说的判断,值得放在最前面:90% 的 integrate、simplify、solve 失败不是 bug,是符号缺少假设条件。SymPy 默认把每个符号当作"任意复数"处理,这是最安全的策略,也是最无能的策略。当你写 x = Symbol('x') 时,你告诉 SymPy 的只有"这是一个名字叫 x 的东西"。它不知道 x 是不是实数、是不是正数、是不是整数。在这种信息状态下,SymPy 拒绝执行任何"只在特定条件下成立"的化简,因为那可能出错。

第 7 章的单摆项目已经暗示过这条线:线性方程可解、非线性不可解。但还有一条更隐蔽的分界线:同一个线性方程,加上假设之后可解的形式可能完全不同。本章要做的,就是让你掌握这条分界线的控制权。

10.0 先看一个失败案例

在进入理论之前,先复现一个"看起来像 bug"的失败。这个案例会在本章被反复回收。

PYTHON
import sympy as sp
a, x = sp.symbols('a x')
expr = sp.integrate(sp.exp(-a*x), (x, 0, sp.oo))
sp.pprint(expr)

输出:

输出
⎧    1                      π
⎪    ─       for │arg(a)│ < ─
⎪    a                      2
⎪                            
⎪∞                           
⎨⌠                           
⎪⎮  -a⋅x                     
⎪⎮ ℯ     dx     otherwise    
⎪⌡                           
⎪0                           
⎩    

你可能会说:"这不是失败,它给了 1/a。"但注意那个 Piecewise:SymPy 只敢在 a > 0 时断言答案是 1/a,在其他情况下它退回未求值的积分。如果你后续代码直接取 expr 的表达式部分去用,会在 a 为负或复数时得到错误结果;如果你期望一个干净的 1/a,这个 Piecewise 就是噪音。

现在加上一个假设:

PYTHON
a = sp.Symbol('a', positive=True)
x = sp.Symbol('x')
print(sp.integrate(sp.exp(-a*x), (x, 0, sp.oo)))  # 1/a

干净、无 Piecewise、无未求值积分。同一个数学问题,同一个 integrate 函数,唯一的区别是符号声明时多了一个 positive=True。

这个案例是本章的缩影。它同时展示了三件事:

  1. Piecewise 不是错误,是条件未定的诚实表达。SymPy 在不知道 a 符号的情况下,拒绝给出可能出错的无条件答案。
  2. 假设是信息,不是装饰。positive=True 让 SymPy 知道 a 在积分收敛域内,从而跳过条件判断。
  3. 失败的位置在入口,不在出口。如果你在写完整个推导后才发现结果是 Piecewise,回头修改符号声明会牵动整条链路。假设应该在符号创建时就确定。

第 10.2 节会分析这个案例中 Piecewise 的每个分支;第 10.4 节会讨论如何诊断类似的"看起来能算但算不干净"的情况。现在,先进入符号假设的基本机制。

10.1 Symbol('x', positive=True) 的力量

10.1.1 默认符号代表什么

创建一个不带任何假设的符号:

PYTHON
import sympy as sp
x = sp.Symbol('x')

SymPy 对 x 的了解仅限于:它有一个名字,它在乘法下与其他对象交换(commutative=True 是唯一默认成立的谓词)。x.is_positive 返回 None,x.is_real 返回 None,x.is_integer 返回 None。None 在这里不是"假",也不是"未知但即将被计算出来"——它就是"不知道,而且以当前信息无法知道"。

这个设计有其合理性。数学中的符号确实经常代表任意复数。如果你在推导一个对复数成立的恒等式,把 x 限制为实数反而是错误的。SymPy 选择默认最宽松的假设,让用户显式收缩定义域,这在逻辑上是保守的、正确的。

但问题在于,当你实际上知道 x 是正数时,你没有告诉 SymPy。SymPy 不会读心,它不会从你后续写的方程中推断出"哦,这里 x 显然必须是正的"。它只使用你在创建符号时声明的信息,加上从这些信息中可以演绎出来的推论。

10.1.2 加上假设后发生什么

PYTHON
x_pos = sp.Symbol('x', positive=True)
print(sp.sqrt(x_pos**2))  # x

同样的表达式,在 x 无假设时返回 sqrt(x2),在 x_pos 为正时返回 x。这不是 simplify 的功劳,是假设系统在表达式构造阶段就完成了化简。SymPy 知道正数的平方的平方根就是它本身,所以它直接构造了 x 而不是 sqrt(x2)。

这个"构造时化简"的行为值得展开,因为它解释了一个常见困惑:为什么 simplify 没有让 sqrt(x2) 变成 x?**

PYTHON
x = sp.Symbol('x')
print(sp.simplify(sp.sqrt(x**2)))  # sqrt(x**2)

simplify 不改变结果。原因不是 simplify 不够强,而是 simplify 在数学上不能做这个变换:sqrt(x**2) = x 只在 x >= 0 时成立,对 x = -1 来说左边是 1、右边是 -1。没有假设信息,simplify 如果做了这个变换就是错的。

这就是第 10.2.1 节要展开的判断:simplify 的"无能"是数学正确性的要求,不是实现缺陷。假设系统是唯一能让 simplify(以及 integrate、solve)获得足够信息做出正确变换的入口。

10.1.3 假设的推理闭包

声明一个假设后,SymPy 会自动推导出所有逻辑上蕴含的谓词。这不是简单的"存一个字典",而是一次闭包计算:

PYTHON
x = sp.Symbol('x', positive=True)
print(x.assumptions0)

输出(节选):

输出
{'positive': True, 'real': True, 'nonnegative': True,
 'nonzero': True, 'negative': False, 'zero': False, ...}

positive=True 蕴含了 real=True、nonnegative=True、nonzero=True,同时排除了 negative 和 zero。你不需要冗余地声明所有推论;SymPy 知道这些逻辑关系。

这个闭包是可查询的、可调试的。x.assumptions0 显示所有已知的假设,包括你显式给的和推理出来的。第 10.4.4 节会把 .assumptions0 作为调试 None 的第一个工具。

10.1.4 反面教材:isprime 在没有假设时的 False

第 10.1 节的标题中标注了 【核】isprime,因为它是"没有假设导致 None"的最经典案例。

PYTHON
n = sp.Symbol('n')
print(sp.isprime(n))  # False

isprime 问的是"这个数是不是质数"。对于一个没有假设的符号 n,它可能是质数(2、3、5……),可能是合数(4、6、8……),可能是 1(不是质数也不是合数),可能是负数(不是质数),甚至可能不是整数(不是质数)。所以返回 False。

如果加上 integer=True:

PYTHON
n_int = sp.Symbol('n', integer=True)
print(sp.isprime(n_int))  # False

还是 False。因为 n_int 可能是 2(质数),也可能是 4(合数),也可能是 1 或负数。integer=True 不够,还需要知道具体的值或者更强的假设。只有 n = 7 这样的具体数,或者 n 被约束在某个范围内且该范围内的所有数都是质数(这通常不是假设系统能表达的东西),isprime 才会给出确定答案。

这个例子说明:假设不是万能的。 你加的假设必须精确到足以让 SymPy 做出判断。integer=True 对 isprime 没有帮助,因为质数性是整数的一个子集属性,不是所有整数都共享的。这个 None 不是 bug,是信息不足的诚实表达。

第 10.4 节会讨论如何诊断这类 None,以及什么时候应该接受它。

10.1.5 假设影响的不只是化简,还有速度

这一点在第 11 章会展开,但值得提前点出:假设不仅影响正确性,还影响计算速度。一个有 positive=True 的符号能让 SymPy 跳过大量条件检查。在复杂表达式的化简中,这些检查可能占据可观的时间。

具体来说,integrate 在决定是否可以做某个换元时,需要检查多个收敛条件。如果 a 的符号未知,integrate 必须构造 Piecewise 覆盖所有分支;如果 a 已知为正,它直接走收敛分支。simplify 中的模式匹配也有类似的行为:某些变换只有在假设满足时才被尝试,否则直接被跳过。

这解释了一个反直觉的现象:给符号加上正确的假设,有时能让化简更快,而不只是更干净。第 11.5 节的"慢速化简优化"实战会用到这条线。

10.2 假设如何影响化简与求解

本节展开三个核心函数——simplify、solve、integrate——在假设存在与缺失时的行为差异。它们不是三个孤立的例子,而是同一条原则的三次演示:SymPy 的算法依赖信息,假设是信息的来源。

10.2.1 simplify 与假设的脱节

simplify() 函数在 SymPy 中有一个容易被忽视的特性:它不使用符号的假设。simplify 做的是结构层面的化简,试图用启发式规则把表达式变短、变规整,但它不会因为 x 是正数就把 sqrt(x**2) 改成 x。那个改写在表达式构造时已经由假设系统完成了;如果没完成,simplify 不会替它完成。

如果你有一个带假设的表达式,但化简没有发生,问题通常不在 simplify 的效率,而在假设没有传递到表达式树上。例如:

PYTHON
x = sp.Symbol('x', positive=True)
expr = sp.sqrt(sp.Symbol('y')**2)  # y 没有假设
print(sp.simplify(expr))  # sqrt(y**2),不会因为 x 是正数而改变

x 的假设只影响包含 x 的子表达式,不影响 y。这是显然的,但实践中经常被忘记:你给 a 加了 positive=True,却忘了 b 也需要。

更隐蔽的情形是假设在传递中丢失。考虑:

PYTHON
x = sp.Symbol('x', positive=True)
expr = sp.sqrt(x**2)          # 构造时已经是 x
print(expr)                    # x,假设生效
y = sp.Symbol('y')             # y 无假设
expr2 = expr.subs(x, y)       # 用无假设的 y 替换有假设的 x
print(expr2)                   # y,假设没有"传回"

subs 把有假设的 x 替换为无假设的 y 时,替换后的表达式的性质由 y 决定,不会因为"它曾经来自一个正数"而保留任何正性信息。这是正确的语义:你显式地用 y 替换了 x,y 是什么就是什么。但在复杂推导中,这可能制造出"我明明给过假设,为什么没生效"的困惑。

10.2.2 solve 与定义域:假设决定解的完整性

solve 的行为对假设的敏感程度取决于方程的类型。对于多项式方程,solve 通常返回所有复数根,忽略符号的实数假设。

PYTHON
x = sp.Symbol('x')
print(sp.solve(x**2 + 1, x))  # [-I, I]

即使你只关心实数解,solve 也返回复数根。这是因为 solve 的默认语义是"在代数闭包中找所有解",假设不影响它。如果你想要实数解,需要用 solveset 指定定义域:

PYTHON
print(sp.solveset(x**2 + 1, x, domain=sp.S.Reals))  # EmptySet

domain=sp.S.Reals 是一个显式的定义域约束,它比 Symbol('x', real=True) 更直接地控制求解范围。两者有区别:Symbol(real=True) 影响的是符号在表达式构造和化简中的行为,domain=sp.S.Reals 影响的是求解算法搜索的空间。第 5.1 节讨论 solve 与 solveset 的语义差异时已经点过这条线,这里从假设角度重新看:solveset 的 domain 参数是"求解域假设",Symbol(real=True) 是"符号假设",它们在不同层面起作用。

但假设在另一类方程中起决定性作用:包含参数的方程。

PYTHON
a = sp.Symbol('a')
x = sp.Symbol('x')
sol = sp.solve(a*x - 1, x)  
print(sol) # [1/a]

solve 返回 1/a,没有讨论 a=0 的情况。如果你知道 a 非零,这个解是完整的;如果你不知道,solve 的返回值遗漏了一个分支(a=0 时方程无解)。加上 a = Symbol('a', nonzero=True) 后,solve 的返回值不变,但你现在知道这个返回值在假设下是完整的。

这是一个微妙的分界:solve 不检查假设是否覆盖了所有情况,它只在你给定的表达式上做符号操作。假设帮助你理解返回值的适用范围,但不改变 solve 的算法。

如果你希望 solve 显式处理参数条件,需要改用 solveset 配合 ConditionSet,或者手动构造 Piecewise:

PYTHON
a = sp.Symbol('a', real=True)
sol = sp.solveset(a*x - 1, x, domain=sp.S.Reals)
print(sol)
# 返回: Intersection({1/a}, Reals) 或类似的条件集

solveset 不会像 solve 那样在参数存在时给出 [1/a];它要么给出条件集,要么在 a=0 时返回 EmptySet。这种"笨拙"在需要严格讨论参数分支的场景中反而是优点。

10.2.3 integrate 对收敛性假设的依赖

积分是假设影响最直观的领域。第 10.0 节的失败案例已经展示了 exp(-a*x) 的情形。这里展开它的结构。

integrate(exp(-a*x), (x, 0, oo)) 是否收敛,完全取决于 a 的实部是否为正。SymPy 的 integrate 会检查这个条件,如果没有信息,它返回条件表达式或未求值积分。

PYTHON
a = sp.Symbol('a', positive=True)
x = sp.Symbol('x')
print(sp.integrate(sp.exp(-a*x), (x, 0, sp.oo)))  # 1/a
a_generic = sp.Symbol('a')
print(sp.integrate(sp.exp(-a_generic*x), (x, 0, sp.oo)))
# Piecewise((1/a, Abs(arg(a)) < pi/2), (Integral(exp(-a*x), (x, 0, oo)), True))

Piecewise 的第二个分支告诉你:在所有其他情况下,积分保持未求值。这不是"SymPy 算不出来",是"SymPy 不知道你是否在它能算的范围内"。

第 4 章讨论过"不可积"情形。现在你可以更精确地表述:很多"不可积"其实是"缺少假设导致无法判断收敛性"。加上 positive=True 后,一批看似不可积的积分变得可解。

积分与假设的交互还有一个更复杂的层面:分段积分中的假设传递。

PYTHON
a = sp.Symbol('a', positive=True)
x = sp.Symbol('x')
f = sp.Piecewise((0, x < 0), (sp.exp(-a*x), True))
print(sp.integrate(f, (x, -sp.oo, sp.oo)))  # 1/a

Piecewise 的两个分支在积分时会分别处理:x < 0 的部分积分为 0,x >= 0 的部分积分为 1/a。a 的正性假设让第二部分的积分直接收敛。如果没有假设,第二部分的积分会返回 Piecewise,整个结果变成嵌套的条件表达式,难以阅读。

10.2.4 三角化简与假设:sqrt(sin(x)**2)

三角函数的化简是假设影响最隐蔽的领域。考虑:

PYTHON
x = sp.Symbol('x')
print(sp.simplify(sp.sqrt(sp.sin(x)**2)))  # sqrt(sin(x)**2)

即使 x 被声明为实数,结果仍然是 sqrt(sin(x)2),不是 |sin(x)|。原因:simplify 不做 sqrt(y2) -> Abs(y) 的变换,除非 y 的符号已知。而 sin(x) 的符号在 x 为实数时仍然依赖于 x 的具体值。

如果你确实需要 |sin(x)| 的形式,需要用 refine 配合区间假设:

PYTHON
x = sp.Symbol('x', real=True)
print(sp.refine(sp.sqrt(sp.sin(x)**2), sp.Q.nonnegative(sp.sin(x))))  # sin(x)

这里 Q.nonnegative(sin(x)) 是一个谓词,断言 sin(x) >= 0。refine 在这个前提下把 sqrt(sin(x)**2) 化简为 sin(x)。这个谓词本身可能需要进一步的假设才能成立(例如 x 在 [0, pi] 内),但它展示了 refine 的精度:它不是"全局化简",是"在给定条件下化简"。

这个例子与第 10.3 节的 refine 直接相连。simplify 的无力与 refine 的精确,是同一枚硬币的两面。

10.3 自定义假设与精炼

10.3.1 refine():在假设下精炼表达式

refine(expr, assumptions) 是一个常被忽略但极其有用的函数。它与 simplify 的区别在于:refine 只做在给定假设下有效的变换,不做结构性的启发式重写。

PYTHON
x = sp.Symbol('x')
print(sp.refine(sp.sqrt(x**2), sp.Q.positive(x)))  # x
print(sp.refine(sp.Abs(x), sp.Q.positive(x)))      # x
print(sp.refine(sp.Abs(x), sp.Q.negative(x)))      # -x

Q.positive(x) 是一个谓词对象,表示"x 是正数"。refine 使用它来判断哪些变换是合法的。sqrt(x2) 在 x 为正时变为 x,在 x 为负时变为 -x(因为 sqrt(x2) = |x| = -x 当 x<0)。

refine 的第二个参数可以是单个谓词,也可以是谓词的逻辑组合:

PYTHON
print(sp.refine(sp.sqrt(x**2), sp.Q.real(x)))  # Abs(x)
print(sp.refine(sp.sqrt(x**2), sp.Q.real(x) & sp.Q.nonnegative(x)))  # x

Q.real(x) & Q.nonnegative(x) 表示"x 是实数且非负"。refine 在这个组合条件下把 sqrt(x**2) 化为 x。注意:单独的 Q.real(x) 只给出 Abs(x),因为实数可以是负的。

10.3.2 refine 与 simplify 的行为对比

同一表达式走两条路径,输出可能不同。这个对比值得做一次实验,因为它揭示了两个函数的根本差异。

PYTHON
x = sp.Symbol('x')
expr = sp.sqrt(x**2) + sp.Abs(x)
# 路径一:simplify
print("simplify:", sp.simplify(expr))        # sqrt(x**2) + Abs(x)
# 路径二:refine 配合正性假设
print("refine positive:", sp.refine(expr, sp.Q.positive(x)))   # 2*x
print("refine negative:", sp.refine(expr, sp.Q.negative(x)))   # -2*x

simplify 不做任何变换,因为它在无假设时不能确定 sqrt(x**2) 和 Abs(x) 的关系。refine 在正性假设下把两者都化为 x,和为 2*x;在负性假设下都化为 -x,和为 -2*x。两种结果都是数学上正确的,选择哪一种取决于你掌握的信息。

这个对比的实用价值在于:当你有一个带条件的化简需求时,refine 比 simplify 更可控。simplify 可能做很多你没预料到的变换,refine 只做你显式指定的假设允许的变换。在需要可复现、可审计的推导中,refine 是更好的工具。

refine 的局限是:它只处理已知的、硬编码的变换规则。它不会发明新的化简策略。如果你给的谓词不在 refine 的规则库中,它什么也不做:

PYTHON
print(sp.refine(sp.sqrt(x**2), sp.Q.integer(x)))  # Abs(x)

Q.integer(x) 不蕴含正性,refine 没有"整数平方的平方根"的规则,所以保持原样。这不是缺陷,是 refine 的定位:它做安全的、局部的、可预测的变换,不做全局搜索。

10.3.3 posify():临时赋予正性

有时候你有一个表达式,其中所有符号都应该被当作正数处理,但你不想修改原始符号的定义。posify 做这件事:

PYTHON
x, y = sp.symbols('x y')
expr = sp.exp(sp.sqrt(x**2)) + sp.sqrt(y**2)
expr_pos, rep = sp.posify(expr)
print(expr_pos)  # exp(_x) + _y
print(rep)       # {_x: x, _y: y}

posify 把所有符号替换为带 positive=True 假设的新符号(通常加下划线前缀),返回精炼后的表达式和一个替换字典。如果你需要在正数假设下做推导,然后再回到原始符号,posify + subs 是标准路径。

注意 expr_pos 中的 _x 和 _y 与原始的 x、y 在 SymPy 的结构相等性判断中不相等,因为假设不同。你必须用 rep 字典做替换才能回到原始表达式:

PYTHON
result = expr_pos.subs(rep)
print(result)  # exp(x) + y

这是假设系统的一个基本特性:符号的假设是其身份的一部分。两个名字相同但假设不同的符号,在 SymPy 看来是不同的对象。posify 利用这个特性,通过"改名"来实现"改假设",再用替换字典还原。

10.3.4 常见谓词速查

SymPy 的假设系统支持一组谓词,常用的包括:

谓词含义典型用法
Q.positive正实数积分收敛性、绝对值化简
Q.negative负实数符号判断、方向性推导
Q.real实数共轭、绝对值、实部虚部分离
Q.integer整数求和、递推、数论函数
Q.nonzero非零除法、求解中的分母条件
Q.nonnegative非负实数平方根、范数
Q.prime质数isprime 的充分条件之一
Q.even / Q.odd偶/奇整数幂化简、周期性

完整的谓词表及蕴含关系见 SymPy 文档的假设系统指南。关键认知是:假设之间有推理规则。声明 positive=True 自动蕴含 real=True、nonzero=True、nonnegative=True。你不需要冗余地声明所有推论。

谓词的逻辑组合使用 Python 的 &(与)、|(或)、~(非):

PYTHON
cond = sp.Q.real(x) & sp.Q.positive(x)   # 实数且正
cond = sp.Q.integer(x) & ~sp.Q.even(x)    # 整数且非偶(即奇)

~Q.even(x) 在 x 为整数时等价于 Q.odd(x),但 refine 对这两种写法的处理可能不同——显式写 Q.odd(x) 更安全,因为 refine 的规则库可能只识别后者。这是使用 refine 时的一个实用细节:谓词尽量写成 refine 规则库中直接出现的形式,避免依赖逻辑推理的闭包。

10.4 假设的陷阱与调试

10.4.1 陷阱一:同名符号不等价

PYTHON
x1 = sp.Symbol('x')
x2 = sp.Symbol('x', positive=True)
print(x1 == x2)  # False

这是初学者最容易踩的坑。两个符号打印出来都是 x,但它们在 SymPy 的内部表示中是不同的对象,因为假设不同。如果你在一个函数中创建了 Symbol('x', positive=True),在另一个地方用了 Symbol('x'),它们不会自动合并。subs 替换、表达式比较、方程求解都可能因此出现意外行为。

PYTHON
expr = x2**2
print(expr.subs(x1, 3))   # x**2,替换没有发生
print(expr.subs(x2, 3))   # 9,替换正常

expr 中的符号是 x2,用 x1 替换时 SymPy 找不到匹配,静默返回原表达式。这是一个不报错的失败,比报错更危险。

调试策略:始终在程序的一个入口点创建符号,然后传递同一个对象。不要在不同函数中重复调用 Symbol('x') 并期望它们相等,除非你确实不需要假设。对于大型项目,可以把符号定义集中在一个模块中,所有其他模块从那里导入。

10.4.2 陷阱二:negative=False 不等于正数

PYTHON
x = sp.Symbol('x', real=True, negative=False)
print(x.is_positive)  # None
print(x.is_nonnegative)  # True

negative=False 告诉 SymPy 的是"x 不是负数"。在实数范围内,不是负数意味着 x >= 0,即 nonnegative=True。但它可能是零,所以不能推断出 positive=True。如果你需要正数,直接写 positive=True,不要用排除法。

推论规则是:real == negative | zero | positive。要得到 positive=True,需要 real=True、negative=False、zero=False 三者同时成立。只给前两个不够。

这个陷阱的变体是 nonzero=True 与 positive=True 的混淆:

PYTHON
x = sp.Symbol('x', real=True, nonzero=True)
print(x.is_positive)  # None,可能是负数

nonzero=True 只排除零,不排除负数。如果积分收敛需要 a > 0,nonzero=True 不够,必须 positive=True。

10.4.3 陷阱三:旧假设系统与新假设系统的割裂

SymPy 内部存在两套假设机制。本章讨论的是"旧假设系统",即在 Symbol() 构造时传入 positive=True 等参数、通过 .is_positive 查询的系统。还有一套"新假设系统",使用 Q.positive(x) 谓词和 ask() 函数。

两套系统在 SymPy 的某些部分(尤其是较老的代码和第三方扩展中)不完全互通。一个经典表现:用 assuming(Q.positive(a)) 上下文管理器赋予的假设,可能不会被依赖旧系统的函数识别。

PYTHON
a = sp.Symbol('a')
with sp.assuming(sp.Q.positive(a)):
    print(sp.integrate(sp.exp(-a*x), (x, 0, sp.oo)))
#Piecewise((1/a, Abs(arg(a)) < pi/2), (Integral(exp(-a*x), (x, 0, oo)), True))
#assuming 块不改变 a 的永久假设

assuming 块只在块内的某些查询中生效,它不会修改符号对象的永久假设。如果你需要 integrate 识别 a 的正性,必须在 Symbol() 创建时给 positive=True,或者用 refine 在事后精炼。

实用规则:在本书的范围内,优先使用旧假设系统(Symbol(..., positive=True)),因为它是化简、积分、求解等核心流程实际使用的机制。refine 和 ask 接受 Q 谓词,那是新系统的接口,但不要期望在 with assuming(...) 块中创建的所有表达式都能被旧系统感知。

10.4.4 调试 None 的方法

当 .is_positive 或类似查询返回 None 时,问三个问题:

  1. 符号声明时给了什么假设? 用 x.assumptions0 查看完整的假设字典(包括推理出来的)。
  2. 这个查询在数学上是否可判定? (pi + E).is_irrational 返回 None,因为 π+e 的无理性是数学未解决问题。SymPy 无法给出答案不是缺陷。
  3. 是否只需要在特定上下文中精炼? 用 refine(expr, Q.positive(x)) 临时施加假设,而不是修改符号的永久定义。

None 不是错误。在依赖假设的代码中,你应该把 None 当作一个合法的第三种状态来处理,而不是用 if x.is_positive: 这样的两值判断(None 在 Python 中为假,会静默走进 else 分支)。正确的模式是:

PYTHON
if x.is_positive is True:
    ...  # 确定为正
elif x.is_positive is False:
    ...  # 确定为非正
else:
    ...  # 未知,需要更保守的处理或显式假设

10.4.5 一个完整的诊断演练

用一个具体表达式走完诊断链条。目标是化简 sqrt((x+1)**2) + Abs(x-1),并且你知道 x >= 0。

PYTHON
x = sp.Symbol('x')
expr = sp.sqrt((x+1)**2) + sp.Abs(x-1)

第一步:看 simplify 能做什么。

PYTHON
print(sp.simplify(expr))
# sqrt((x + 1)**2) + Abs(x - 1),无变化

第二步:检查符号的假设。

PYTHON
print(x.assumptions0)
# {'commutative': True}

第三步:尝试 refine 配合已知条件。

PYTHON
print(sp.refine(expr, sp.Q.nonnegative(x)))
# Abs(x - 1) + Abs(x + 1)

第四步:分段处理 Abs(x-1)。

PYTHON
print(sp.refine(expr, sp.Q.nonnegative(x) & sp.Q.nonnegative(x-1)))  # x + Abs(x + 1) - 1
print(sp.refine(expr, sp.Q.nonnegative(x) & sp.Q.negative(x-1))) # -x + Abs(x + 1) + 1

两次 refine 给出两个分支的结果。如果你需要把它们合并成一个表达式,手工构造 Piecewise:

PYTHON
result = sp.Piecewise((2*x, x >= 1), (2, True))

第五步:如果希望永久生效,重新创建符号。

PYTHON
x_pos = sp.Symbol('x', nonnegative=True)
expr_pos = sp.sqrt((x_pos+1)**2) + sp.Abs(x_pos-1)
print(sp.refine(expr_pos, sp.Q.nonnegative(x_pos-1)))  # 2*x

这个演练展示了三个层次的工具:simplify(无信息,无能)、refine(临时假设,精确)、Symbol 假设(永久信息,影响构造和后续所有操作)。诊断的顺序应该是:先看 simplify 是否已经够用,不够再用 refine 加条件,最后才考虑重建符号。重建符号的代价最大(会改变表达式树中该符号的所有引用),应该留到最后。

10.5 本站对照:特殊函数一览

本节一句话带过:第 10 章对应的本站函数是特殊函数和数论函数,它们本身不涉及假设系统,但它们的返回值经常是 None 或未求值形式,原因与 10.1 的 isprime 相同——输入缺少足够的信息让 SymPy 给出确定答案。

以下条目按你提供的素材照录,正文不展开:

这些函数的共同特征是:它们的行为对输入的类型和值高度敏感。zeta(n) 在 n 为偶数时返回 pi**n 的有理倍数,在 n 为奇数时通常保持未求值。这不是 bug,是 SymPy 知道什么能算、什么不能。与本章的主题呼应:知道边界在哪里,比假装边界不存在更有用。

10.6 本章闭环

主题关键操作典型收益典型陷阱
声明假设Symbol('x', positive=True)表达式构造时即化简同名不同假设符号不等价
条件精炼refine(expr, Q.positive(x))不修改符号定义的前提下化简refine 只做已知变换,不是万能化简
调试 Nonex.assumptions0 + 三值判断区分“不可判定”和“信息不足”用 if x.is_positive: 吞掉 None
临时正性posify(expr)批量赋予正性后还原忘记用返回的字典做 subs

本章的判断可以归结为:SymPy 不会替你假设。你告诉它什么,它就用什么;你没告诉它的,它拒绝猜。 这不是缺陷,是符号计算与数值计算的分野——数值计算在每一步丢弃信息,符号计算在每一步保留信息,包括“不知道”这个信息。

第 4 章的“不可积”和第 5 章的解集形式差异,在本章获得了统一的解释:相当一部分“算不出来”是缺少判断收敛性或完整性的假设,不是算法能力不足。

第 11 章会从性能角度继续这条线:假设不仅影响正确性和表达式的干净程度,还影响速度。一个有 positive=True 的符号能让 integrate、simplify 跳过大量条件分支,第 11.5 节的慢速化简实战会量化这个差距。

《SymPy 数学编程》 · 第 10 章 假设系统与精炼类型 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 11 章 性能与大规模表达式

表达式膨胀:成因与诊断 · cse 公共子表达式消除 · 惰性求值与 UnevaluatedExpr · 何时该离开 SymPy · 实战:慢速化简优化 · 本章闭环

第 10 章结束时,你知道了假设能让 integrate 和 simplify 跳过条件检查。第 11 章要把这个观察量化,并且回答一个更紧迫的问题:当表达式膨胀到无法处理时,你该怎么办。

本章的基调与前几章不同。第 3 章到第 7 章在教你“如何让 SymPy 做对”,第 8 章到第 10 章在教你“如何让结果出去”。本章教你“什么时候该停”。SymPy 不是万能的,它的性能边界比正确性边界更早出现,也更难诊断。一个 integrate 卡住十分钟,可能是数学上不可积,可能是假设缺失,也可能是表达式膨胀到了算法无法处理的规模。区分这三者,是本章的目标。

11.1 表达式膨胀:成因与诊断

11.1.1 什么是表达式膨胀

表达式膨胀(expression blowup)指的是:一个数学上简洁的表达式,在 SymPy 的内部表示中变成了一个巨大的树。你不是在写一个巨大的表达式,你是在写一个简单的表达式,但 SymPy 把它变成了巨大的。

最小的复现:

PYTHON
import sympy as sp
x = sp.Symbol('x')
expr = (x + 1)**20
print(sp.count_ops(expr))        # 2
expanded = sp.expand(expr)
print(sp.count_ops(expanded))    # 58,但这是二项式系数展开后的多项式

(x+1)**20 在未展开时有 21 个操作节点。expand 之后,它变成 21 项的多项式,每项有系数和幂次,count_ops 可能相近,但内部结构的复杂度完全不同。如果你对这个多项式求导、代入、再化简,每一步都可能触发进一步的展开。

真正的问题不在 (x+1)20 这种你主动要求展开的场合。问题在于你没有要求展开,但 SymPy 替你展开了**。

11.1.2 膨胀的三种成因

成因一:构造时的自动求值。 SymPy 的表达式在构造时就进行求值。x + x 立即变成 2*x,sqrt(4) 立即变成 2。这个设计大部分时候是便利的,但在某些场景下制造麻烦。考虑一个数值稳定性敏感的场景:exp((x + 32.0)/6.0) 中的 32.0 是浮点数,SymPy 在构造时会尝试化简,可能引入不必要的精度处理。UnevaluatedExpr 是应对这个问题的工具,第 11.3 节展开。

成因二:expand 的传播。 当你对一个包含子表达式的和式做 expand 时,expand 会穿透所有层级。expand((a + b)*(c + d)) 变成 a*c + a*d + b*c + b*d。如果 a、b、c、d 本身是复杂表达式,展开后的项数可能爆炸。第 11.1.3 节会给出诊断方法。

成因三:化简的“改进”反而膨胀。 这是最隐蔽的一种。simplify 或 cancel 有时会把一个紧凑的分式展开成多项式之比,分子和分母各自膨胀。together 把多个分式合并成一个,分母可能是所有分母的乘积。这些操作在数学上“更规整”,但在计算上更昂贵。第 11.5 节的慢速化简实战会展示一个具体案例。

11.1.3 诊断:count_ops 与 srepr

sp.count_ops(expr) 返回表达式树中的操作节点数。这是一个快速、廉价的指标:

PYTHON
x, y = sp.symbols('x y')
expr = sp.sin(x + y)**2 + sp.cos(x + y)**2
print(sp.count_ops(expr))  # 7
print(sp.count_ops(sp.simplify(expr)))  # 0

count_ops 的局限是它只看节点数,不看节点的类型。一个包含 100 个不同符号的 Add 和一个包含 100 个相同结构的嵌套 Mul,节点数可能相同,但求值代价相差很大。对于更精细的诊断,用 srepr 看表达式的内部结构:

PYTHON
print(sp.srepr(expr))

srepr 的输出是 Python 可读的构造表达式,它告诉你表达式的每一层是什么函数、参数是什么。如果你看到 srepr 的输出嵌套了十几层 Add 和 Mul,而你的数学表达式应该只有三层,膨胀就已经发生了。

诊断的顺序应该是:先 count_ops 看规模,如果数字异常大,再用 srepr 看结构,判断膨胀发生在哪一层。第 11.2 节的 cse 和 11.3 节的 UnevaluatedExpr 是两种不同层级的应对手段。

11.2 cse() 公共子表达式消除

11.2.1 它在解决什么问题

cse 的全称是 common subexpression elimination,公共子表达式消除。它做的事情是:找出表达式中重复出现的子表达式,把它们提取为临时变量,用这些变量重写原表达式。

PYTHON
from sympy import cse, symbols
x, y, z, w = symbols('x y z w')
expr = ((w + x + y + z)*(w + y + z))/(w + x)**3
replacements, reduced = cse(expr)
print(replacements)  # [(x0, y + z), (x1, w + x)]
print(reduced)       # [(w + x0)*(x0 + x1)/x1**3]

y + z 出现了两次(在 w + x + y + z 和 w + y + z 中),w + x 出现了两次(在分子和分母中)。cse 把它们提取为 x0 和 x1,原表达式从一棵重复的树变成了一棵引用临时变量的树。

cse 的价值在数值求值时体现。如果你把 reduced 用 lambdify 转成 NumPy 函数,临时变量 x0 和 x1 只计算一次,而不是每次出现都重新计算。对于复杂表达式,这个优化能把求值时间降低数倍。

11.2.2 order 参数:大表达式的速度开关

cse 有一个容易被忽略的参数:order。默认值 'canonical' 会对 Add 和 Mul 的参数做规范排序,确保结果稳定可复现。但对于大表达式,这个排序本身可能很慢。

SymPy 的文档明确说明:对于大表达式且速度是关切时,使用 order='none'。'none' 跳过规范排序,代价是结果的顺序依赖于表达式的哈希值,可能因机器和运行而异。

这是一个典型的“可复现 vs 速度”权衡。在开发阶段用 'canonical',确保每次运行得到相同的 cse 结果;在生产环境的 lambdify 之前用 'none',如果表达式确实很大且排序开销不可忽略。本书的后续代码默认使用 'canonical',除非明确标注了性能敏感的场合。

11.2.3 cse 与 lambdify 的 cse=True 的关系

第 8.4.4 节提到过 lambdify(..., cse=True)。这两个 cse 是同一件事吗?

不是。lambdify 的 cse=True 在生成代码之前调用 cse,把结果嵌入生成的函数体。你手动调用 cse 然后 lambdify 是等价的,但 lambdify 的版本更自动化,且能处理一些你手动调用时可能忽略的细节(比如临时变量的命名冲突)。

什么时候需要手动 cse?当你需要在符号层面看到提取后的形式时。lambdify(..., cse=True) 不返回中间表示,你无法检查它提取了哪些子表达式。如果你在调试“为什么这个函数的求值结果不对”,手动 cse 能让你看到提取的边界是否合理。

11.3 惰性求值与 UnevaluatedExpr

11.3.1 SymPy 的“急切求值”默认行为

SymPy 的表达式在构造时就求值。这不是 bug,是设计选择。x + x 应该等于 2*x,sin(0) 应该等于 0。但在某些场景下,急切求值制造了你不想要的结构。

PYTHON
x = sp.Symbol('x')
expr = x + sp.UnevaluatedExpr(x)
print(expr)  # x + x,而不是 2*x

UnevaluatedExpr(x) 告诉 SymPy:“这个 x 不要和外面的 x 合并。”UnevaluatedExpr 是一个“求值屏障”,它阻止内部表达式与外部表达式之间的化简。

11.3.2 一个关键限制:UnevaluatedExpr 不能阻止参数的求值

这是 UnevaluatedExpr 最容易误解的地方。UnevaluatedExpr(x + x) 会先计算 x + x,得到 2*x,然后再包装。屏障只作用于包装后的对象与外部的交互,不作用于包装内部的计算。

PYTHON
expr1 = sp.UnevaluatedExpr(x + x)
print(expr1)  # 2*x,参数先被求值了
expr2 = sp.sympify("x + x", evaluate=False)
print(expr2)  # x + x,构造时阻止了求值

要同时阻止内部和外部求值,需要组合两种方法:

PYTHON
expr3 = sp.UnevaluatedExpr(sp.sympify("x + x", evaluate=False)) + 1
print(expr3)  # 1 + (x + x)

sympify(..., evaluate=False) 阻止 x + x 在构造时合并,UnevaluatedExpr 阻止合并后的结果与外部 +1 交互。两层屏障各司其职。

11.3.3 evaluate(False) 上下文:更粗粒度的控制

with sp.evaluate(False): 块会关闭块内所有 SymPy 表达式的求值。这是一个“大锤”方案,适合批量构造表达式时临时使用:

PYTHON
with sp.evaluate(False):
    a = x + x
    b = sp.sin(0)
print(a)  # x + x
print(b)  # sin(0)

块结束后,正常的求值行为恢复。但要注意:块内构造的表达式在块外被使用时,可能仍然会被求值。a + 0 在块外会触发 a 的求值,x + x 变成 2*x。evaluate(False) 只控制构造时刻,不控制后续操作。

UnevaluatedExpr 的屏障是持久的,evaluate(False) 的屏障是临时的。选择哪一个取决于你需要在哪个阶段阻止求值。

11.3.4 什么时候真的需要惰性求值

不是所有场景都值得引入 UnevaluatedExpr。它增加表达式的复杂度,可能让后续的 simplify 和 subs 行为变得难以预测。

值得使用的场景:你明确知道某个子表达式在后续步骤中应该保持整体形式,且提前展开会导致表达式膨胀。例如,在推导一个递推关系时,你希望把 f(n) 保持为符号,而不是在每个步骤中展开它的定义。或者,在构造一个用于代码生成的表达式时,你希望 (a + b) 保持为一个单元,以便 cse 或 codegen 把它识别为公共子表达式。

不值得使用的场景:你只是觉得“它看起来应该保持原样”。SymPy 的求值是数学正确的,如果它合并了 x + x,那是对的。UnevaluatedExpr 是性能工具和呈现工具,不是正确性工具。

11.4 何时该离开 SymPy(转向 NumPy / SciPy / 编译代码)

11.4.1 一个判断框架

SymPy 的适用边界可以用三个问题来划分:

问题一:你需要精确的符号结果吗? 如果你只需要数值答案,从一开始就不该用 SymPy。np.linalg.solve 比 sp.solve 快几个数量级,scipy.integrate.quad 比 sp.integrate 可靠得多。SymPy 的 nsolve 是一个折中,但它仍然比 SciPy 的数值求解器慢。

问题二:你的表达式需要被“理解”吗? SymPy 的价值在于它能操作表达式的结构:求导、积分、化简、代入。一旦这些操作完成,结果需要被反复求值(在循环中、在优化器中、在绘图中),就该离开 SymPy。lambdify 是这个出口,第 8 章已经展开。

问题三:你的计算是在“推导”还是在“执行”? 推导是一次性的、结构敏感的、需要符号信息的。执行是重复的、数值的、不需要符号信息的。SymPy 服务于前者,NumPy/SciPy 服务于后者。把两者混在同一个循环里,是性能问题的常见来源。

11.4.2 转向 NumPy/SciPy 的时机

一旦符号推导完成,结果需要被求值超过十次,就应该考虑 lambdify。超过一百次,lambdify 几乎是必须的。

PYTHON
# 符号推导阶段
expr = sp.sin(x)*sp.exp(-x) + sp.cos(x)*sp.exp(-x)
derivative = sp.diff(expr, x)
# 转向数值阶段
f = sp.lambdify(x, derivative, 'numpy')
values = f(np.linspace(0, 10, 10000))

最后一行在 NumPy 层面完成,没有 SymPy 的参与。这是正确的工作流:符号推导产出表达式,lambdify 产出函数,NumPy 消费函数。

如果 lambdify 的函数仍然不够快(比如在优化器的内循环中被调用数十万次),下一步是 Numba 或 C 代码生成(第 8.3 节的 codegen)。Numba 对 NumPy 数组和循环的即时编译能把纯 Python 循环加速到接近 C 的水平,且不需要离开 Python 生态。codegen 的 C 输出则适合需要脱离 Python 运行时的场景。

11.4.3 一个容易被忽视的中间地带:nsolve

sp.nsolve 是 SymPy 提供的数值求解器。它接受符号方程和一个初始猜测,调用数值算法找根。它的优势是你不需要手动把方程转成 NumPy 函数;它的劣势是比 scipy.optimize.root 或 fsolve 慢,且对初始猜测敏感。

PYTHON
sol = sp.nsolve(sp.cos(x) - x, x, 1.0)  # 0.739085...

nsolve 适合的场景:你已经有一个符号方程,想快速得到一个数值解,且不想写额外的数值代码。如果这个求解在循环中重复,或者需要高性能,用 lambdify 把方程转成函数,再交给 SciPy。

11.5 实战:一个慢速化简的优化过程

本节用一个完整的案例展示第 11.1 到 11.4 的工具如何协同工作。场景来自第 10 章的伏线:假设影响速度。

11.5.1 问题:一个卡住的化简

考虑一个包含参数的积分,参数的正性未知:

PYTHON
a, x = sp.symbols('a x')
expr = sp.integrate(sp.exp(-a*x)*sp.sin(x), (x, 0, sp.oo))

如果你实际运行这段代码,可能会等待数秒到数十秒,取决于 SymPy 版本和机器。返回的结果是一个 Piecewise,包含 a > 0 条件下的闭式解,以及其他情况下的未求值积分。

现在加上假设:

PYTHON
a_pos = sp.Symbol('a', positive=True)
expr_fast = sp.integrate(sp.exp(-a_pos*x)*sp.sin(x), (x, 0, sp.oo))

返回时间显著缩短,结果干净:1/(a**2 + 1)。

11.5.2 量化差距

用一个简单的时间测量:

PYTHON
import time
a = sp.Symbol('a')
a_pos = sp.Symbol('a', positive=True)
x = sp.Symbol('x')
f = sp.exp(-a*x)*sp.sin(x)
f_pos = sp.exp(-a_pos*x)*sp.sin(x)
t0 = time.perf_counter()
r_generic = sp.integrate(f, (x, 0, sp.oo))
t_generic = time.perf_counter() - t0
t0 = time.perf_counter()
r_pos = sp.integrate(f_pos, (x, 0, sp.oo))
t_pos = time.perf_counter() - t0
print(f"无假设: {t_generic:.2f}s, 结果类型: {type(r_generic).__name__}")
print(f"有假设: {t_pos:.2f}s, 结果: {r_pos}")

具体数字因机器而异,但典型情况是:无假设路径慢一个数量级,且结果需要额外的后处理(从 Piecewise 中提取表达式部分)才能用于后续计算。假设路径直接产出可用的表达式。

这个差距的来源是 integrate 在无假设时需要探索多个收敛分支,构造 Piecewise 覆盖所有可能。有假设时,它直接走收敛分支,跳过分支枚举。

11.5.3 优化链条

这个案例的优化链条可以推广:

  1. 诊断:用 count_ops 和计时确认瓶颈在 integrate 的分支处理,不在表达式规模本身。
  2. 加假设:第 10 章的工具。这是收益最大的一步,因为它改变了算法的路径,不只是加速了同一条路径。
  3. cse:如果化简后的表达式仍然复杂(比如包含重复的子表达式),用 cse 提取。在这个案例中,1/(a**2 + 1) 不需要 cse。
  4. lambdify:如果结果需要被反复求值,转成 NumPy 函数。第 8 章的榫卯在这里收口。

11.5.4 如果假设不可用

有时候你确实不知道 a 的符号,或者 a 在不同的调用中可能取不同符号。这时不能简单地加 positive=True。应对策略:

策略一:分段处理。 对 a > 0 和 a < 0 分别加假设,分别积分,手动拼装结果。这比让 integrate 自动构造 Piecewise 更可控,因为你知道每个分支的来源。

策略二:延迟求值。 如果积分在循环中针对不同的 a 值被调用,而每个 a 的符号已知,可以在每次调用前用 refine 或 subs 引入假设。第 10.3 节的 refine 是工具。

策略三:接受 Piecewise,但在 lambdify 之前剥离。 如果只需要数值结果,lambdify 可以直接处理 Piecewise(第 8.4.3 节的陷阱提醒:传数组)。你不需要手动提取表达式部分,让 lambdify 的条件逻辑处理分支。

11.6 本章闭环

问题工具判断标准代价
表达式膨胀count_ops + srepr节点数异常或结构嵌套过深诊断时间
重复子表达式cse(order='none' 用于大表达式)同一子式出现三次以上可复现性('none' 时)
不想要的求值UnevaluatedExpr + evaluate=False构造时的化简破坏了后续步骤表达式复杂度增加
反复数值求值lambdify求值次数超过十次失去符号信息
参数条件缺失positive=True 等假设返回 Piecewise 或未求值积分假设必须正确

本章的判断可以归结为:SymPy 的性能问题,大部分不是“算得慢”,是“在错误的阶段做了正确的事”。 integrate 慢是因为它在探索所有分支,而你可能只需要一个分支。simplify 慢是因为它在尝试所有变换,而你可能只需要一个特定的化简。lambdify 快是因为它把符号计算的一次性代价摊薄到了数值求值的多次调用中。

第 10 章的假设伏线在本章兑现:positive=True 让 integrate 跳过分支枚举,收益是数量级的时间节省,不只是结果的干净。第 8 章的 lambdify 榫卯在本章获得了性能维度的解释:它是符号计算与数值计算之间的成本分界线,线的一侧是昂贵的推导,另一侧是廉价的执行。

第 12 章进入测试、调试与可复现性。本章讨论的性能问题,很多在事后难以复现:同样的表达式在这台机器上卡住,在那台机器上跑完;同样的 cse 调用在不同版本中返回不同的替换。第 12 章会讨论如何把这类问题固定下来,让“慢”和“快”变成可测量、可复现的事实,而不是偶然的体验。

《SymPy 数学编程》 · 第 11 章 性能与大规模表达式 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 12 章 测试、调试与可复现性

SymPy 代码的单元测试策略 · 常见错误信息解读 · sympy.testing 工具 · 随机测试与 @slow 标记 · 构建可复现的符号计算工作流 · 本章闭环

第 11 章结束时,你知道了性能问题往往来自“在错误的阶段做了正确的事”。但性能问题有一个令人不安的特性:它们不总是可复现的。同一个表达式,在这台机器上跑了三秒,在那台机器上跑了三分钟;同一个 cse 调用,在 SymPy 1.9 上返回一种替换,在 1.12 上返回另一种。如果你无法复现一个现象,你就无法诊断它,更无法确认修复是否有效。

本章要解决的就是这个问题:把符号计算从“一次性的探索”变成“可检验、可重复的工程活动”。这听起来像软件工程的话题,但它的必要性在符号计算中比在数值计算中更强。数值计算的浮点误差虽然恼人,至少是随机的、有统计规律的;符号计算的行为差异往往来自版本、假设、哈希种子、表达式构造顺序——这些因素单独看起来都无关紧要,组合起来却能让两个“相同”的脚本产出不同的结果。

12.1 SymPy 代码的单元测试策略

12.1.1 为什么符号计算的测试不同于数值计算

数值计算的测试通常比对浮点数:给定输入,期望输出在某个容差范围内。这个模式在符号计算中不适用,因为符号结果没有“容差”。simplify(expr) 返回的表达式可能数学上等价于你期望的结果,但结构上完全不同——(x+1)2 和 x2 + 2*x + 1 在 SymPy 的 == 比较中返回 False,因为 SymPy 的相等性判断基于结构,不是数学等价。

这意味着符号计算的单元测试必须回答两个不同的问题:

问题一:结果是否在结构上符合预期? 如果你期望 simplify 返回一个特定的形式(比如 x 而不是 sqrt(x**2)),你需要测试结构相等性。assert simplify(expr) == x 做这件事。

问题二:结果是否在数学上正确? 结构可能不同,但数学等价性应该成立。对于方程求解,测试解是否满足原方程;对于积分,测试求导后是否回到被积函数;对于化简,测试差是否为零。assert simplify(result - expected) == 0 是这种测试的常见形式,但它依赖 simplify 能把差化简到零,这本身不是必然的。

SymPy 自身的测试套件大量使用第二种模式。例如,测试一个积分时,不是比对积分结果的字符串形式,而是对结果求导,检查是否等于被积函数。这个策略避开了“不同版本返回不同但等价的形式”的问题,但也引入了新的依赖:求导和化简本身可能出错。

12.1.2 三种实用的测试模式

模式一:结构断言,用于稳定输出的函数。 对于 expand、factor、diff 这类输出形式相对稳定的函数,直接比对结构是可行的:

PYTHON
import sympy as sp
x = sp.Symbol('x')
def test_expand():
    assert sp.expand((x + 1)**2) == x**2 + 2*x + 1
def test_factor():
    assert sp.factor(x**2 - 1) == (x - 1)*(x + 1)

这些测试的脆弱性在于:SymPy 的某个版本可能改变 factor 的输出形式(比如调整因子的顺序),测试就会失败,即使数学上仍然正确。对于你自己维护的代码,这种脆弱性可以接受,因为你有能力在版本升级时更新测试。对于依赖 SymPy 内部行为的测试,应该尽量避免。

模式二:数学等价断言,用于化简和积分。 当你无法保证输出形式时,测试数学关系:

PYTHON
def test_integral_of_polynomial():
    result = sp.integrate(x**2, x)
    assert sp.diff(result, x) == x**2
def test_simplify_idempotent():
    expr = sp.sin(x)**2 + sp.cos(x)**2
    simplified = sp.simplify(expr)
    assert simplified == 1  # 这个具体案例的结构断言是安全的

diff(integrate(f)) == f 是一个强测试,它不关心积分结果的具体形式,只关心它是否是正确的原函数。代价是:如果 integrate 返回了一个未求值的 Integral,diff 可能也无法给出有用的信息,测试会以一种不直观的方式失败。

模式三:代入数值验证。 对于无法用 simplify 消去差异的表达式,代入具体数值比较:

PYTHON
def test_expression_equivalence():
    expr1 = sp.sqrt(x**2)
    expr2 = sp.Abs(x)
    # 在 x 为正时,两者应相等
    for val in [0.5, 1.0, 2.0, 3.0]:
        assert abs(float(expr1.subs(x, val)) - float(expr2.subs(x, val))) < 1e-12

这个模式在测试假设相关的行为时特别有用:你可以为不同假设的符号分别代入合适的数值范围,验证行为是否符合预期。它的局限是:它只验证了代入的那些点,不是数学证明。对于有限个测试点,它可能漏掉反例。

12.1.3 测试中的假设管理

第 10 章讨论过同名不同假设的符号不等价。这在测试中制造了一个隐蔽的陷阱:

PYTHON
x = sp.Symbol('x', positive=True)
def test_sqrt_square():
    # 如果测试函数内部重新创建了 x,假设会丢失
    x_test = sp.Symbol('x')  # 无假设
    assert sp.sqrt(x_test**2) == x  # 失败,因为 x_test**2 的 sqrt 不会化简

正确的做法是在测试模块的顶部集中定义符号,所有测试函数共享这些符号。不要把 Symbol() 调用散落在测试函数内部。这不是风格问题,是正确性问题:测试中使用的符号必须与被测代码中使用的符号具有相同的假设,否则测试可能在测一个不同的数学问题。

12.2 常见错误信息解读

12.2.1 TypeError: cannot create mpf from x

这是 SymPy 中最常见的错误之一,通常出现在数值求解或数值转换的上下文中。

PYTHON
sol = sp.nsolve(sp.cos(x) - x, x)  # 错误:缺少初始猜测

nsolve 需要一个初始猜测值来启动数值迭代。没有初始值,它无法把符号 x 转换为数值,于是报 mpf(mpmath 的浮点数类型)创建失败。修复方式是提供初始值:

PYTHON
sol = sp.nsolve(sp.cos(x) - x, x, 1.0)  # 0.739...

这个错误的变体出现在 evalf 中:expr.evalf() 对于包含自由符号的表达式返回带精度的符号表达式,而不是浮点数。如果你期望一个浮点数,需要先 subs 具体值再 evalf。

12.2.2 TypeError: 'Equality' object is not subscriptable

这个错误通常出现在 dsolve 的返回值处理中:

PYTHON
sol = sp.dsolve(f(x).diff(x) - f(x), f(x))
# 尝试 sol[0] 会报错

dsolve 返回的是一个 Eq 对象(等式),不是列表或元组。sol[0] 试图对等式做索引,但 Eq 不支持索引。正确的做法是访问 sol.lhs 和 sol.rhs:

PYTHON
print(sol.rhs)  # 解的函数表达式

这个错误的根源是对 dsolve 返回类型的预期错误。在旧版本的 SymPy 中,dsolve 有时返回列表(当方程有多个解时),有时返回单个 Eq。这个不一致性在较新版本中被修正,但旧的代码和教程仍然可能产生混淆。如果你不确定返回类型,用 type(sol) 检查。

12.2.3 AttributeError: 'Mul' object has no attribute 'is_positive'

这个错误通常出现在你试图对一个非 Symbol 对象查询假设属性时:

PYTHON
expr = 2*x
expr.is_positive  # 错误:Mul 对象没有 is_positive

假设属性(is_positive、is_real 等)定义在 Basic 的子类上,但并非所有表达式对象都直接暴露这些属性。对于复合表达式,你需要用 sp.ask(sp.Q.positive(expr)) 或者对表达式中的符号单独查询。

更常见的变体是:你从一个函数返回了 Mul 对象,但调用者期望它是一个 Symbol,试图访问 .name 或假设属性。这类错误的诊断需要回溯到表达式构造的位置,确认你拿到的是什么类型。

12.2.4 PolynomialError: x conflicts with an existing polynomial

这个错误出现在多项式操作(factor、gcd、roots 等)中,当变量名与多项式的生成元冲突时:

PYTHON
p = sp.Poly(x**2 - 1, x)
sp.roots(p, y)  # 错误:y 不是 p 的生成元

Poly 对象有自己的生成元集合(gens)。当你用 roots 或其他函数操作一个 Poly 时,传入的变量必须在 gens 中。如果你传入了一个不相关的符号,SymPy 无法判断你在问什么。

修复方式是:要么使用 Poly 的生成元,要么把 Poly 转回普通表达式再操作。如果你确实需要在不同的变量下操作,用 p.as_expr() 取出表达式,再用新的变量构造新的 Poly。

12.2.5 一个诊断框架

面对任何 SymPy 错误,按以下顺序检查:

  1. 对象类型:type(obj) 告诉你它是什么。Eq 不是列表,Mul 不是 Symbol,Integral 不是 Add。
  2. 自由符号:expr.free_symbols 列出表达式中出现的符号。如果你期望的符号不在其中,问题在构造阶段。
  3. 假设状态:symbol.assumptions0 显示已知的假设。第 10 章的调试方法在这里同样适用。
  4. 是否求值:expr.has(sp.Integral) 或 expr.has(sp.Derivative) 检查是否有未求值的操作。第 11 章的 UnevaluatedExpr 可能制造了你不想要的未求值形式。

12.3 sympy.testing 工具

12.3.1 什么时候用 SymPy 自带的测试工具

sympy.testing 模块主要是为 SymPy 自身的开发服务的,但它的一些组件对用户代码也有价值。最有用的是 raises 和 warns。

sympy.testing.pytest.raises 是一个上下文管理器或函数,用于断言代码抛出特定异常:

PYTHON
from sympy.testing.pytest import raises
# 函数形式
raises(ZeroDivisionError, lambda: 1/0)
# 上下文管理器形式
with raises(ZeroDivisionError):
    x = 1/0

与 pytest 原生的 pytest.raises 相比,SymPy 的版本在异常消息的格式上略有不同,但对于测试 SymPy 代码的行为,两者功能等价。如果你已经在使用 pytest,原生的 raises 足够;如果你在 SymPy 的测试框架内工作,使用 sympy.testing 的版本能确保一致性。

warns 用于测试警告的发出:

PYTHON
from sympy.testing.pytest import warns
import warnings
with warns(UserWarning):
    warnings.warn("deprecated", UserWarning)

对于测试你自己代码中基于 SymPy 的弃用警告或条件警告,这个工具有用。注意 warns 的参数需要与警告的类别精确匹配。

12.3.2 run_in_subprocess_with_hash_randomization

这是一个 SymPy 内部测试套件使用的工具,用于在启用了哈希随机化的子进程中运行测试。它的存在揭示了一个对用户代码同样重要的事实:Python 的哈希随机化会影响 SymPy 表达式的某些行为。

SymPy 的表达式在内部使用字典和集合,这些结构的迭代顺序在启用哈希随机化时是随机的。对于大多数操作,这没有影响,因为 SymPy 的算法设计为与顺序无关。但在某些边缘情况下,顺序可能影响化简的路径选择,进而影响最终形式。run_in_subprocess_with_hash_randomization 让 SymPy 的测试能够在“随机顺序”下运行,捕捉那些依赖特定顺序的 bug。

对于用户代码,这个工具的启示是:如果你发现某个 SymPy 操作在不同运行中返回不同的结果(结构上不同但数学上等价),哈希随机化可能是原因之一。设置 PYTHONHASHSEED 环境变量为一个固定值可以复现特定顺序,但更好的做法是:不要依赖 SymPy 输出的结构顺序,用数学等价性来测试。

12.3.3 doctest 与 # doctest:+SKIP

SymPy 的文档使用 doctest 来验证示例代码。# doctest:+SKIP 标记一个 doctest 示例应该被跳过。这个标记在用户代码的文档中也有用:当你写一个示例,它依赖于特定环境(比如某个可选库)或者运行时间很长,用 +SKIP 标记它,避免文档测试失败。

但 # doctest:+SKIP 应该谨慎使用。SymPy 的文档明确指出:跳过的 doctest 永远不会被测试,因此可能变得过时(即错误),这会让用户困惑。如果你的示例不需要跳过,就不要加这个标记;如果需要跳过是因为依赖问题,考虑用 @doctest_depends_on 装饰器来声明依赖,而不是直接跳过。

12.4 随机测试与 @slow 标记

12.4.1 随机测试在符号计算中的角色

随机测试(property-based testing)在符号计算中有一个特殊的位置:符号算法的正确性往往依赖于对表达式结构的假设,而随机生成的表达式能覆盖人类手工构造的测试用例无法触及的组合。

SymPy 的测试套件中使用了 Hypothesis 库来做属性测试。一个例子是模运算的测试:给定随机整数 a、p(非零)、i、j(非零),验证 Mod(x, y).subs({x: a, y: p}) == a % p。这个测试不关心 SymPy 如何实现 Mod,只关心它的行为是否符合 Python 的 % 语义。

对于你自己的符号计算代码,随机测试可以用在以下场景:

场景一:化简的幂等性。 simplify(simplify(expr)) 应该等于 simplify(expr)(至少在数学上)。用随机生成的表达式测试这个性质,能发现化简中的不稳定行为。

场景二:积分与求导的互逆性。 对于随机生成的多项式或有理函数,diff(integrate(f, x), x) 应该等于 f(在化简后)。这个性质对于一大类函数成立,随机测试能覆盖手工测试遗漏的函数形式。

场景三:代入与求值的交换性。 expr.subs(x, y).subs(y, z) 应该等于 expr.subs(x, z)(当替换不涉及交叉依赖时)。随机测试能发现替换实现中的微妙问题。

随机测试的代价是:失败时,你需要最小化反例来理解问题。Hypothesis 提供了 @example 装饰器和自动最小化,但对于符号表达式,最小化可能仍然产生难以阅读的复杂表达式。一个实用策略是:先用随机测试发现“存在反例”,再用手工构造的小表达式复现和诊断。

12.4.2 @slow 标记:为什么需要它

@slow 是 SymPy 测试套件中的一个装饰器,用于标记运行时间很长的测试。被标记的测试在常规测试运行中会被跳过,只在明确要求时执行。

这个标记的存在反映了一个工程现实:有些符号计算就是慢的,而且这种慢不是 bug。一个需要 30 秒的积分测试,在开发迭代中会严重拖慢反馈循环。把它标记为 @slow,让常规测试快速完成,在发布前或性能回归检查时再运行慢速测试,是合理的权衡。

SymPy 的 GitHub issue 中有一个例子:test_W19 在 CI 的慢速测试套件中因 10 秒超时而失败,解决方案是给它加上 @slow 装饰器,让它在快速测试套件中被跳过。另一个讨论指出:一个“太慢”的测试如果甚至不能保证完成,应该被跳过而不是留在测试套件中,因为它可能是一个无限循环的 bug 而不是一个合法的慢测试。

对于你自己的代码,@slow 的等价物可以是 pytest 的 @pytest.mark.slow。关键实践是:区分“因为算法复杂度而慢”的测试和“因为实现有 bug 而卡住”的测试。前者适合 @slow,后者应该被修复或标记为 xfail(预期失败),而不是用 @slow 掩盖。

12.4.3 一个容易被忽视的复现问题:SymPy 对随机种子的影响

Stack Overflow 上有一个问题:用户在代码中设置了 random.seed(1),但每次运行仍然得到不同的 SymPy 结果。调查发现,SymPy 的某些函数(特别是解析器 parse_expr)在旧版本中会修改全局随机状态。这个问题在较新版本中已被修复,但它揭示了一个更普遍的教训:第三方库可能在你不注意的时候改变全局状态。

对于可复现性敏感的代码,不要在导入 SymPy 之前设置随机种子,也不要在依赖随机种子的代码旁边使用可能修改全局状态的 SymPy 函数。如果必须使用,在关键操作前后显式保存和恢复随机状态,或者把 SymPy 操作隔离到不依赖随机性的子流程中。

12.5 构建可复现的符号计算工作流

12.5.1 可复现性的三个层次

层次一:同一次会话内可复现。 在同一个 Python 进程中,相同的输入产生相同的输出。这是最基本的,SymPy 默认满足(除非涉及哈希随机化或全局状态)。

层次二:跨机器可复现。 在不同的机器上(不同的操作系统、Python 版本、SymPy 版本、BLAS 后端),相同的脚本产生相同的结果。这个层次在符号计算中比数值计算更容易达到,因为符号结果不涉及浮点舍入,但仍然受版本差异和哈希随机化影响。

层次三:跨时间可复现。 六个月后,当 SymPy 发布了新版本,你的脚本仍然产生相同的结果(或者,如果结果变了,你能解释为什么)。这是最难的层次,它要求你显式记录所有可能影响结果的依赖和环境因素。

第 12.5 节的目标是帮助你达到层次二和层次三。

12.5.2 记录什么

一个可复现的符号计算工作流需要记录的信息,比一个典型的数值计算项目更多,因为符号结果的“相同性”比数值结果的“相同性”更微妙。

必需记录项:

  • SymPy 版本:sp.__version__。这是最重要的,因为不同版本的化简策略、integrate 的实现、假设系统的行为都可能不同。
  • Python 版本:sys.version。影响哈希随机化和某些内置函数的行为。
  • 符号的假设:如果你在代码中创建了带假设的符号,记录每个符号的 assumptions0。一个没有记录假设的脚本,在别人手中可能产生完全不同的结果。
  • 随机种子:如果代码涉及任何随机性(包括 SymPy 内部可能使用随机性的函数,或者你自己用于生成测试用例的随机性),记录种子值。
  • 操作顺序:对于依赖化简顺序的代码,记录调用的顺序。simplify(a + b) 和 simplify(a) + simplify(b) 可能产生不同的结构。

建议记录项:

  • PYTHONHASHSEED:如果你在某个运行中设置了它,记录值。如果没有设置,记录“未设置(哈希随机化启用)”。
  • SymPy 配置:sp.init_printing() 的调用状态、sp.sympify 的全局设置(如果有)。
  • 操作系统和架构:虽然符号计算对平台依赖比数值计算少,但某些底层库(如 mpmath 的后端)可能受平台影响。

12.5.3 一个可复现的脚本模板

把上述记录固化到一个脚本的头部:

PYTHON
import sys
import sympy as sp
# 可复现性元数据
REPRO_META = {
    "sympy_version": sp.__version__,
    "python_version": sys.version,
    "hash_seed": __import__('os').environ.get('PYTHONHASHSEED', 'unset'),
}
def create_symbols():
    """集中创建所有符号,记录假设。"""
    x = sp.Symbol('x', real=True)
    a = sp.Symbol('a', positive=True)
    return x, a
def log_assumptions(symbols_dict):
    for name, sym in symbols_dict.items():
        print(f"{name}: {sym.assumptions0}")

这个模板的关键特征是:符号的创建被隔离在一个函数中。第 10.4.1 节讨论过,同名不同假设的符号不等价,散落的 Symbol() 调用是复现问题的常见来源。把所有符号集中创建,确保了假设的一致性,也让“这个脚本用了哪些符号、各自有什么假设”变得一目了然。

12.5.4 版本锁定与容器化

对于长期项目,requirements.txt 中的 sympy>=1.9 是不够的。一个在 1.9 上开发、在 1.12 上测试的脚本,可能因为化简行为的改变而产生不同的结果。可复现的工作流应该锁定精确版本:

输出
sympy==1.12
mpmath==1.3.0

如果你需要更强的隔离,Docker 镜像可以固定操作系统、Python 版本、所有依赖的版本。SymPy 的依赖较少(主要是 mpmath),一个精简的 Python 镜像加上锁定的 requirements.txt 通常足够。

12.5.5 当结果不可复现时,如何诊断

如果你发现同一个脚本在不同运行中产生不同的结果,按以下顺序排查:

  1. 哈希随机化:设置 PYTHONHASHSEED=0 重新运行。如果结果稳定了,问题出在依赖字典/集合迭代顺序的代码或 SymPy 内部行为上。
  2. 版本差异:打印 sp.__version__,确认两次运行使用的是同一个版本。如果使用了虚拟环境,确认环境没有被意外更新。
  3. 符号假设不一致:检查两次运行中符号的 assumptions0 是否相同。如果代码中有条件创建符号的逻辑,确保条件在两次运行中结果一致。
  4. 全局状态泄漏:检查是否有其他代码(或 SymPy 的某些函数)修改了全局随机状态、警告过滤器、或者 SymPy 的全局配置。
  5. 浮点精度设置:虽然符号计算不涉及浮点舍入,但 evalf 的精度设置和 nsolve 的容差可能影响数值部分的结果。

第 11 章讨论的 cse 的 order 参数是一个具体案例:order='canonical' 确保替换结果稳定,order='none' 则依赖哈希顺序。如果你在性能敏感的场景中使用了 'none',记录这个选择,并在测试中接受结构可能变化但数学等价的结果。

12.6 本章闭环

问题工具/策略关键实践
测试符号结果结构断言 + 数学等价断言集中创建符号,避免假设丢失
解读错误信息type + free_symbols + assumptions0Eq 不是列表,Mul 不是 Symbol
测试异常和警告sympy.testing.pytest.raises / warns与 pytest 原生工具功能等价
处理慢测试@slow 标记区分“合法慢”和“卡住”
发现边缘 bugHypothesis 随机测试先发现反例,再手工最小化
跨运行复现记录版本、假设、哈希种子符号集中创建,版本精确锁定

本章的判断可以归结为:符号计算的可复现性,本质上是对“信息状态”的复现。 第 10 章告诉你,假设是信息;第 11 章告诉你,假设影响速度和结果形式;本章告诉你,如果不记录假设、不锁定版本、不控制哈希顺序,你以为在运行“同一个计算”,实际上每次都在运行一个略有不同的问题。

第 11 章的“慢”在第 12 章获得了工程化的处理:不是试图让所有测试都快,而是区分快速反馈循环和慢速回归检查,用 @slow 标记管理期望。第 8 章的 lambdify 和 codegen 在这里不再出现,因为它们已经完成了使命——它们把符号结果送出了 SymPy,而本章处理的是符号计算本身的可检验性。

第四部分(第 10–12 章)到此结束。第 13 章进入第五部分“项目实战”,第一个项目是符号化的微积分练习生成器。这个项目会回收本章的测试策略(生成的题目需要可验证的答案)和第 8 章的 latex 输出(题目和答案需要排版)。但它的核心是新的:如何用 SymPy 的随机性生成有意义、不重复、难度可控的题目,同时保证答案的正确性可以被自动检验。第 12 章的随机测试和可复现性工具,将在那个场景中获得直接的应用。

《SymPy 数学编程》 · 第 12 章 测试、调试与可复现性 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 13 章 项目一:符号化的微积分练习生成器

需求:自动生成可验证的题目与答案 · 用 SymPy 生成随机表达式 · 符号求解与答案格式化 · LaTeX 输出与打印 · 本章闭环

第四部分结束时,你手里有一套工具:符号推导、假设管理、lambdify 桥接、性能判断、可复现性控制。前十二章一直在教“如何正确地使用 SymPy”。从本章开始,话题转向“如何用 SymPy 造一个东西”。

三个项目分别对应三类真实需求。第 13 章是练习生成器:从随机表达式出发,产出一份带答案的微积分习题。它的核心不是微积分,而是随机性与正确性的平衡——随机生成的题目必须可解、答案必须可验证、难度必须可控。第 14 章的单摆项目处理非线性方程的解析与数值分界。第 15 章把符号推导接入数值优化器。

本章的定位需要说清楚:它不教你“怎么写一个能跑的脚本”。这种脚本十几行就能写完,网上的例子到处都是。它要讲的是在“能跑”和“可用”之间的那一段路:为什么朴素的随机生成会产出大量废题,为什么答案的格式化比求解更难,为什么第 12 章的可复现性在生成器场景中不是学术洁癖而是刚需。

13.1 需求:自动生成可验证的题目与答案

13.1.1 需求从哪里来

一个数学教师需要一个练习生成器,动机通常是具体的:每周要出一套微积分习题,手工出题耗时且容易重复,答案需要核对,格式需要统一。一个自学者需要它,动机是:想要无限量的练习,但不想依赖题库的固定题目。

这两个动机指向同一组技术要求:

  1. 题目必须是可解的。 随机生成的表达式如果积分不出来,题目就没有答案,练习失去意义。
  2. 答案必须是可验证的。 生成器给出的答案必须能被独立检验,不能只是“SymPy 说它是这个”。
  3. 难度必须是可控的。 一套题里不能全是 x**2 的积分,也不能突然冒出一个需要特殊函数的积分。
  4. 输出必须是可读的。 题目和答案要以人能阅读的形式呈现,通常是 LaTeX 或纯文本。

第四条看起来最不重要,实际上最容易出问题。第 8 章的 latex() 能把表达式转成 LaTeX,但“表达式转成 LaTeX”和“题目可读”之间有很大距离。Integral(x**2, x) 的 latex() 输出是 \int x^{2}\, dx,这是可读的;但 Integral(sin(x)/x, x) 的 latex() 输出同样规整,题目本身却是一个不可积的积分。格式正确不等于内容可用。

13.1.2 为什么“随机生成 + integrate”不够

最朴素的生成器逻辑是:随机生成一个表达式,调用 integrate,如果成功就输出题目和答案,如果失败就重试。

PYTHON
import random
import sympy as sp
x = sp.Symbol('x')
def naive_generator():
    expr = random_expr()          # 随机生成表达式
    result = sp.integrate(expr, x)
    if result.has(sp.Integral):   # 积分没有完成
        return None
    return expr, result

这个逻辑有三个问题,每一个都会在规模上去后暴露。

问题一:废题率不可控。 random_expr() 生成的表达式有多大比例是可积的?取决于生成策略,可能低到 10%。这意味着 90% 的计算被浪费在注定失败的积分上。更糟的是,失败的积分不一定快速返回——有些表达式会让 integrate 尝试很久才放弃。第 11 章讨论过这种“卡住”的情形。

问题二:“可积”不等于“适合出题”。 integrate(exp(-x**2), x) 返回一个包含 erf 的表达式。数学上它是“可积”的(有闭式解),但对一个微积分入门练习来说,erf 超出了学生的知识范围。生成器需要的不只是“积分成功”,而是“积分结果落在教学大纲内”。

问题三:答案的验证是循环论证。 如果生成器用 integrate 求答案,又用 integrate 验证答案,验证没有独立信息。第 12 章讨论过符号计算的测试策略:diff(integrate(f)) == f 是一个独立的检验,因为它用了不同的操作(求导)来验证积分。生成器应该采用同样的策略:答案的验证走与求解不同的路径。

13.1.3 一个更合理的设计

把需求拆成四个可独立实现的模块:

模块一:表达式生成器。 按难度等级生成表达式,难度由表达式模板决定,不靠“试了才知道难不难”。例如,难度 1 是 xn(n 为正整数),难度 2 是 a*xn + b,难度 3 是 sin(a*x) 或 exp(a*x),难度 4 是乘积或商。模板本身就保证了可积性和教学适用性。

模块二:求解器。 对生成的表达式调用 integrate 或 diff。因为模板保证了可积性,求解器不需要处理失败重试。

模块三:验证器。 对积分题,验证 diff(answer, x) == integrand(化简后为零);对求导题,验证 integrate(answer, x) == original(在相差常数的意义下)。验证走与求解相反的操作路径,提供独立信息。

模块四:格式化器。 把题目和答案转成 LaTeX,嵌入习题模板。第 8 章的 latex() 和 8.5 节的模板函数在这里复用。

这个设计的核心判断是:随机性应该被约束在模板层面,而不是在表达式层面。模板决定“生成什么形状的题”,随机数只决定“形状里的系数和指数取什么值”。这样,可积性、难度、教学适用性都在模板设计时确定,运行时不需要猜测。

13.1.4 本章的生成器范围

为了让章节篇幅可控,本章的生成器只做两个题型:不定积分和求导。这两个题型覆盖了微积分练习的主体,且它们的验证策略恰好互补(积分的验证是求导,求导的验证是积分)。

不做的题型及原因:定积分(需要额外处理积分上下限和数值验证)、极限(limit 的行为对表达式形式敏感,废题率高)、级数展开(输出形式长,格式化复杂)。这些不是技术上不可能,是它们会让本章的代码量翻倍而教学收益递减。读者掌握了两个题型的模式后,扩展其他题型是机械工作。

13.2 用 SymPy 生成随机表达式

13.2.1 模板化生成:从“随机表达式”到“随机参数”

第 13.1.2 节指出了“随机生成表达式再筛可积性”的缺陷。模板化生成的思路是反过来的:先固定表达式的结构,再随机填充结构中的参数。

PYTHON
import random
import sympy as sp
x = sp.Symbol('x')
def gen_power():
    """难度 1:x**n,n 为 1 到 5 的整数。"""
    n = random.randint(1, 5)
    return x**n
def gen_linear_combo():
    """难度 2:a*x**n + b,a、b 为非零整数。"""
    n = random.randint(1, 4)
    a = random.choice([-3, -2, -1, 1, 2, 3])
    b = random.choice([-5, -4, -3, -2, -1, 1, 2, 3, 4, 5])
    return a*x**n + b
def gen_trig():
    """难度 3:sin(a*x) 或 cos(a*x),a 为正整数。"""
    a = random.randint(1, 4)
    func = random.choice([sp.sin, sp.cos])
    return func(a*x)

这三个模板的每一个都保证可积,且积分结果落在初等函数范围内。gen_power 的积分是 x**(n+1)/(n+1),gen_linear_combo 的积分是多项式,gen_trig 的积分是 ±cos(a*x)/a 或 ±sin(a*x)/a。

模板化生成的关键特征是:参数空间是离散的、有限的。n 是 1 到 5 的整数,a 是 {-3,-2,-1,1,2,3} 中的元素。这保证了生成器不会产出“系数是 3.7 的表达式”这种不适合手算的题目。

13.2.2 模板的难度分级

难度分级不应该靠“试算后发现结果很长”来判定,应该在模板设计时就确定。一个实用的分级框架:

难度模板特征例子积分结果的特征
1单项幂函数x**3单项,系数为分数
2线性组合2*x**3 - 5两项,可能含分数系数
3简单超越函数sin(2*x)单项,系数含 1/a
4乘积或商x*sin(x) 或 x/(x+1)需要分部积分或代换
5复合结构exp(2*x)*sin(x)结果较长,需要整理

本章的代码实现只做到难度 3,难度 4 和 5 的模板留给读者扩展。原因在第 13.1.4 节说过:难度 4 以上需要分部积分和更复杂的化简,答案的格式化复杂度显著上升。难度 3 已经足以展示本章的所有技术点。

13.2.3 随机性的控制:种子与去重

第 12.4.3 节讨论过 SymPy 对全局随机状态的影响。在生成器场景中,随机性有两个来源:表达式模板中的 random 调用,以及 SymPy 内部可能使用的随机性(本章的模板不触发 SymPy 的随机路径,但这是一个需要留意的点)。

可复现的生成器应该接受一个种子参数:

PYTHON
class ExerciseGenerator:
    def __init__(self, seed=None):
        self.rng = random.Random(seed)
    def gen_power(self):
        n = self.rng.randint(1, 5)
        return x**n

使用独立的 random.Random(seed) 实例,而不是全局的 random 模块,有两个好处:第一,生成器的随机性不会干扰调用方其他代码的随机性;第二,同一个种子保证同一套题,这是第 12 章可复现性要求的具体应用。

去重是另一个实际问题。如果一套题有 10 道,随机生成可能产出重复的题目。去重的判据应该基于表达式的结构,而不是字符串:

PYTHON
def generate_set(n, seed=None):
    gen = ExerciseGenerator(seed)
    seen = set()
    exercises = []
    attempts = 0
    while len(exercises) < n and attempts < n * 10:
        expr = gen.random_expr()
        key = sp.srepr(expr)  # 结构指纹
        if key not in seen:
            seen.add(key)
            exercises.append(expr)
        attempts += 1
    return exercises

srepr 返回表达式的完整结构字符串,比 str(expr) 更可靠(str 可能把 x + x 和 2*x 显示成不同形式,但 srepr 能反映它们是同一个对象)。attempts 的上限防止模板参数空间耗尽时无限循环。

13.2.4 一个容易忽略的陷阱:表达式的自动化简

第 11.1.2 节讨论过构造时的自动求值。在生成器中,这制造了一个隐蔽的问题:

PYTHON
expr = 1*x**2 + 0*x + 3
print(expr)  # x**2 + 3,0*x 被消掉了

如果你的模板想生成“二次三项式”的题目,但随机系数中 b 取到了 0,SymPy 在构造时就会消掉中间项,题目变成“二次二项式”。这不是错误,但与你声明的难度等级不符。

解决方式有两种。第一种是在模板层面排除 0 系数:b = random.choice([-3,-2,-1,1,2,3]) 不包含 0。第二种是在生成后检查表达式是否符合模板的预期结构,不符合就重新生成。第一种更简单,第二种更通用。本章采用第一种,因为模板的系数集合本来就是离散的,排除 0 不损失随机性。

13.3 符号求解与答案格式化

13.3.1 求解:integrate 和 diff 的直接调用

模板保证了可积性,求解器可以简单地调用 integrate:

PYTHON
def solve_integral(expr):
    return sp.integrate(expr, x)
def solve_derivative(expr):
    return sp.diff(expr, x)

这两个函数不需要错误处理,因为模板的约束保证了它们不会失败。这是模板化生成的核心收益:把错误处理从运行时移到设计时。

但“不会失败”不等于“结果可读”。integrate(x2, x) 返回 x3/3,干净。integrate(sin(2*x), x) 返回 -cos(2*x)/2,也干净。integrate(2*x3 - 5, x) 返回 x4/2 - 5*x,同样干净。难度 1 到 3 的积分结果都是紧凑的。难度 4 以上才需要 simplify 整理,本章不涉及。

13.3.2 验证:走与求解相反的路径

第 13.1.2 节说过,验证必须独立于求解。积分题的验证是求导,求导题的验证是积分。

PYTHON
def verify_integral(expr, answer):
    """验证积分答案:对答案求导,应得到被积函数。"""
    check = sp.simplify(sp.diff(answer, x) - expr)
    return check == 0
def verify_derivative(expr, answer):
    """验证求导答案:对答案积分,应得到原函数(相差常数)。"""
    check = sp.simplify(sp.integrate(answer, x) - expr)
    # 积分结果可能相差常数,检查导数是否为零
    return sp.simplify(sp.diff(check, x)) == 0

verify_integral 的判据是 diff(answer) - expr == 0。这是一个强判据,因为求导是确定性的,化简到零意味着数学上相等。

verify_derivative 的判据稍弱:积分结果与原函数可能相差一个常数,所以不能直接比较差值是否为零,而要检查差值的导数是否为零。这个判据在数学上是正确的,但它依赖 diff(integrate(answer)) == answer,而 integrate 和 diff 的互逆性在 SymPy 中不是绝对保证的(某些特殊函数可能导致积分返回未求值形式)。对于本章的难度 1–3 模板,这个风险可以忽略,但读者在扩展难度时应该意识到这条边界。

13.3.3 答案的格式化:从表达式到可读形式

第 8.2 节讨论过 latex() 的输出形态。在生成器场景中,latex() 的直接输出通常足够,但有几个需要处理的地方。

分式与负号。 latex(-cos(2*x)/2) 输出 - \frac{\cos{\left(2 x \right)}}{2},可读。但 latex(-x/2) 输出 - \frac{x}{2},而学生可能期望 -\frac{1}{2}x 或 -\frac{x}{2}。这不是 latex() 的错误,是数学排版风格的差异。生成器不需要强行统一风格,但应该知道 latex() 的输出是“一种正确形式”,不是“唯一正确形式”。

积分常数。 不定积分的答案应该包含 + C。integrate 不返回常数项(它返回一个原函数),生成器需要手动添加:

PYTHON
def format_integral_answer(answer):
    return sp.latex(answer) + " + C"

这个简单的字符串拼接有一个陷阱:如果 answer 本身已经是 0(比如被积函数是 0),结果是 0 + C。数学上正确,但可读性差。一个更稳健的格式化函数应该检查 answer 是否为零:

PYTHON
def format_integral_answer(answer):
    if answer == 0:
        return "C"
    return sp.latex(answer) + " + C"

题目与答案的分离。 生成器的输出应该把题目和答案分开,方便用户先做题再对答案。一个最小数据结构:

PYTHON
class Exercise:
    def __init__(self, kind, problem, answer):
        self.kind = kind          # 'integral' 或 'derivative'
        self.problem = problem    # SymPy 表达式
        self.answer = answer      # SymPy 表达式
    def problem_latex(self):
        if self.kind == 'integral':
            return r"\int " + sp.latex(self.problem) + r"\, dx"
        else:
            return r"\frac{d}{dx}\left(" + sp.latex(self.problem) + r"\right)"
    def answer_latex(self):
        if self.kind == 'integral':
            return format_integral_answer(self.answer)
        return sp.latex(self.answer)

problem_latex 对积分题手动拼装 \int ... \, dx,因为 latex(Integral(expr, x)) 的输出包含 \int\limits,在行内模式下位置可能不理想。手动拼装给生成器更多控制权。对求导题,手动拼装 \frac{d}{dx} 前缀,比 latex(Derivative(expr, x)) 的输出更符合练习册的习惯。

13.3.4 simplify 在格式化中的角色

第 13.3.1 节说难度 1–3 的积分结果都是紧凑的,不需要 simplify。但求导题的结果可能需要整理。diff(x*sin(x), x) 返回 x*cos(x) + sin(x),这是紧凑的;diff(exp(2*x)*sin(x), x) 返回 2*exp(2*x)*sin(x) + exp(2*x)*cos(x),可以提取公因子写成 exp(2*x)*(2*sin(x) + cos(x))。

simplify 或 factor 能做这个提取,但它可能改变结果的呈现形式,也可能引入不必要的变化。对于练习生成器,一个实用的策略是:只对结果做 factor 或 collect,不做全量 simplify。factor 提取公因子,行为可预测;simplify 尝试多种变换,可能产出你不期望的形式。

PYTHON
def tidy_answer(answer):
    """对答案做温和的整理,不做全量化简。"""
    return sp.factor(answer)

factor 对多项式和对数、指数的公因子提取都有合理的行为,且不会像 simplify 那样尝试三角恒等式变换。第 3 章讨论过 simplify 的“不可预测性”,这里的策略是那个讨论的直接应用:在需要可读、可控输出的场景中,用定向变换代替万能化简。

13.4 LaTeX 输出与打印

13.4.1 从单个题目到完整习题集

第 13.3.3 节的 Exercise 类处理单个题目。一套题需要把它们组装成 LaTeX 文档。最小可用的模板:

PYTHON
def build_exercise_set(exercises, title="微积分练习"):
    lines = [
        r"\documentclass{article}",
        r"\usepackage{amsmath}",
        r"\begin{document}",
        r"\section*{" + title + "}",
        "",
        r"\subsection*{题目}",
        r"\begin{enumerate}",
    ]
    for ex in exercises:
        lines.append(r"  \item $" + ex.problem_latex() + r"$")
    lines.append(r"\end{enumerate}")
    lines.append("")
    lines.append(r"\subsection*{答案}")
    lines.append(r"\begin{enumerate}")
    for ex in exercises:
        lines.append(r"  \item $" + ex.answer_latex() + r"$")
    lines.append(r"\end{enumerate}")
    lines.append(r"\end{document}")
    return "\n".join(lines)

这个模板使用了 amsmath,因为 latex() 生成的矩阵环境需要它(第 8.2.1 节讨论过)。题目和答案分两个 enumerate 环境,编号自动对齐。

一个值得注意的细节:problem_latex() 的输出已经包含 \int 或 \frac{d}{dx},外面再套 $...$ 进入行内数学模式。如果题目本身是分式,行内模式的分式可能被压缩得难以阅读。对于较复杂的题目,应该用 \[...\] 显示模式。生成器可以根据 count_ops 或表达式深度决定用行内还是显示模式:

PYTHON
def wrap_math(latex_str, display=False):
    if display:
        return r"\[" + latex_str + r"\]"
    return r"$" + latex_str + r"$"

count_ops(expr) > 10 是一个粗糙但实用的判据:操作节点超过 10 个的题目用显示模式,否则用行内。

13.4.2 输出到文件与 init_printing 的取舍

生成器的最终产物是一个 .tex 文件,不是屏幕上的漂亮打印。第 8.1 节讨论过 init_printing() 的适用场景:它服务于交互式环境,对文件输出没有帮助。生成器应该直接使用 latex() 和字符串拼装,不调用 init_printing()。

PYTHON
def save_exercise_set(exercises, filename, seed=None):
    content = build_exercise_set(exercises)
    with open(filename, 'w', encoding='utf-8') as f:
        f.write(content)
    # 记录可复现性元数据
    meta = {
        'seed': seed,
        'sympy_version': sp.__version__,
        'count': len(exercises),
    }
    with open(filename + '.meta', 'w') as f:
        f.write(str(meta))

.meta 文件记录种子和版本,对应第 12.5.2 节的必需记录项。如果一套题有问题,凭种子和版本可以完整复现生成过程。

13.4.3 一个完整的端到端示例

把本章的模块串起来:

PYTHON
def main(seed=42, n_per_type=5):
    gen = ExerciseGenerator(seed)
    exercises = []
    for _ in range(n_per_type):
        expr = gen.random_integral()
        answer = solve_integral(expr)
        assert verify_integral(expr, answer)
        exercises.append(Exercise('integral', expr, answer))
    for _ in range(n_per_type):
        expr = gen.random_derivative()
        answer = solve_derivative(expr)
        assert verify_derivative(expr, answer)
        exercises.append(Exercise('derivative', expr, tidy_answer(answer)))
    save_exercise_set(exercises, 'exercises.tex', seed=seed)
    return exercises

assert verify_* 是第 12.1.2 节“模式二:数学等价断言”的直接应用。如果验证失败,生成过程立即中断,而不是产出一套含错误答案的习题。这个断言在生产环境中可能被替换为“跳过该题并记录日志”,但在开发和测试阶段,快速失败是正确的策略。

13.4.4 扩展方向与本章的边界

本章的生成器停在“难度 1–3、两种题型、单文件输出”。读者可以沿着以下方向扩展,每个方向都对应本书前面的某一章:

难度 4–5 的模板(分部积分、代换、部分分式):需要第 3 章的 apart、第 4 章的积分技巧,以及更复杂的答案格式化。难度 4 的答案可能包含 log,格式化时需要处理定义域。

定积分题型:需要处理积分上下限,答案可能是数值。第 8 章的 lambdify 和 evalf 可以用于数值验证,第 7 章的数值积分对照可以作为第二重检验。

网页输出:把 LaTeX 换成 HTML + MathJax,或者用第 9 章的 Matplotlib 生成题目配图(比如曲线下的面积)。这需要第 8.1 节的 init_printing(use_latex='mathjax') 或者直接生成 HTML 字符串。

自适应难度:根据用户的答题情况调整下一题的难度。这超出了 SymPy 的范围,是教育软件的设计问题,但 SymPy 的模板化生成提供了“难度可控”的基础。

本章不展开这些方向,因为第 13 章的目标是展示“符号计算如何支撑一个真实工具”的最小完整形态,不是构建一个完整的题库系统。三个项目的篇幅规划(每个 6,000–7,000 字)要求每个项目聚焦在一条主线上,而不是覆盖所有变体。

13.5 本章闭环

模块核心工具关键判断典型陷阱
表达式生成模板 + random.Random(seed)可积性由模板保证,不靠试错自动化简改变表达式结构(如 0*x 消失)
求解integrate / diff模板约束下无需错误处理—
验证反向操作 + simplify验证路径必须独立于求解路径求导题的积分验证依赖互逆性
格式化latex() + 字符串拼装手动拼装比 latex(Integral(...)) 更可控积分常数 + C 的零值处理
输出.tex 文件 + .meta记录种子和版本,保证可复现不使用 init_printing(文件输出场景)

本章的判断可以归结为:生成器的正确性不在“生成”,在“验证”。 随机表达式谁都能生成,但一个练习生成器的价值取决于它产出的答案是否可信。第 13.3.2 节的反向验证是这个判断的技术落点:积分用求导验证,求导用积分验证,两条路径的独立性是可信度的来源。

第 12 章的可复现性要求在本章获得了具体形态:种子、版本记录、.meta 文件。第 8 章的 latex() 在本章获得了应用场景:不是给论文排版,是给习题集排版,且需要手动拼装题目前缀。第 3 章的“simplify 不可预测”在本章获得了操作建议:用 factor 代替 simplify 做温和整理。

第 14 章进入单摆项目。本章的生成器处理的是“有闭式解的初等函数”,第 14 章要处理的是“没有闭式解的非线性方程”。第 7.5 节已经预告过这个分界:线性可解、非线性不可解。第 14 章会展示,在这个分界面前,符号计算的角色从“求解”转变为“近似与数值验证的骨架”,而第 8 章的 lambdify 和第 9 章的叠加绘图会在那里汇合。

《SymPy 数学编程》 · 第 13 章 项目一:符号化的微积分练习生成器 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 14 章 项目二:物理中的符号推导——单摆与非线性振动

从拉格朗日量到运动方程 · diff() 与 subs() 的链式操作 · 小角近似与符号线性化 · 结果的可视化验证 · 本章闭环

第 13 章的练习生成器处理的是“有闭式解的初等函数”。本章的单摆项目处理的是“没有闭式解的非线性方程”。这个分界在第 7.5 节已经埋下:线性方程可解、非线性不可解。第 14 章要把这条线走完,并且回答一个第 13 章没有触及的问题:当符号计算给不出闭式解时,它还有什么用?

答案不是“没用”,是“角色变了”。在可解的方程中,SymPy 是求解器;在不可解的方程中,SymPy 是近似引擎和数值验证的骨架。它给出小角近似的精确展开、给出能量守恒的代数关系、给出数值解的验证基准。本章要展示的是这个角色转换如何在单摆问题上具体发生。

14.1 从拉格朗日量到运动方程

14.1.1 为什么从拉格朗日量出发

单摆的运动方程可以用牛顿第二定律直接写出:切向力 -m*g*sin(theta) 等于 m*L*theta'',化简得 theta'' + (g/L)*sin(theta) = 0。这个过程简单,不需要拉格朗日力学。

那为什么本章从拉格朗日量出发?因为拉格朗日量的推导是可推广的、可自动化的。牛顿力学需要你逐力分析,每换一个系统就要重新做一遍受力图。拉格朗日力学只需要写出动能和势能,剩下的交给欧拉-拉格朗日方程。对于本章这种“一个自由度、约束明确”的系统,两者的工作量相近;但对于第 14 章后续要讨论的多自由度或变长度摆,拉格朗日方法的优势就显现出来了。

更重要的是,拉格朗日量的推导在 SymPy 中是符号化可执行的。你可以把 T - V 写成符号表达式,用 diff 求出欧拉-拉格朗日方程,用 simplify 整理。这个过程不依赖你手工算对每一项——SymPy 保证代数正确性,你只需要保证物理建模正确。这是符号计算在物理推导中的第一个价值:把代数错误从推导中排除,让你专注于建模判断。

14.1.2 单摆的拉格朗日量

设摆长为 L,质量为 m,重力加速度为 g,摆角为 theta(t)。摆锤的位置是:

PYTHON
import sympy as sp
t, L, m, g = sp.symbols('t L m g', positive=True)
theta = sp.Function('theta')(t)
x_pos = L * sp.sin(theta)
y_pos = -L * sp.cos(theta)

速度分量是对时间的导数:

PYTHON
vx = sp.diff(x_pos, t)
vy = sp.diff(y_pos, t)

动能和势能:

PYTHON
T = sp.Rational(1, 2) * m * (vx**2 + vy**2)
V = m * g * y_pos   # y_pos 为负,势能相对于最低点

拉格朗日量 Lag = T - V:

PYTHON
Lag = T - V
Lag = sp.simplify(Lag)
sp.pprint(Lag)
输出
    ⎛            2                ⎞
    ⎜  ⎛d       ⎞                 ⎟
L⋅m⋅⎜L⋅⎜──(θ(t))⎟  + 2⋅g⋅cos(θ(t))⎟
    ⎝  ⎝dt      ⎠                 ⎠
───────────────────────────────────
                 2       

simplify 会利用 sin(theta)2 + cos(theta)2 = 1 把动能化简为 m*L2*theta'(t)2/2。这一步的化简是安全的,不需要假设 theta 的范围,因为三角恒等式对所有实数成立。

这里有一个值得注意的细节:theta 被定义为 sp.Function('theta')(t),不是 sp.Symbol。这是因为 theta 是时间的函数,求导 diff(theta, t) 需要返回 Derivative(theta, t) 而不是零。如果你误用 sp.Symbol('theta'),diff 会认为 theta 与 t 无关,返回零,整个推导就错了。这是第 2.1 节“符号创建与命名陷阱”在物理场景中的具体表现。

14.1.3 欧拉-拉格朗日方程

欧拉-拉格朗日方程是:

公式
d/dt (∂Lag/∂theta') - ∂Lag/∂theta = 0

用 SymPy 表达:

PYTHON
dL_dtheta_dot = sp.diff(Lag, sp.diff(theta, t))
dL_dtheta = sp.diff(Lag, theta)
euler_lagrange = sp.diff(dL_dtheta_dot, t) - dL_dtheta
euler_lagrange = sp.simplify(euler_lagrange)
sp.pprint(euler_lagrange)
输出
    ⎛    2                    ⎞
    ⎜   d                     ⎟
L⋅m⋅⎜L⋅───(θ(t)) + g⋅sin(θ(t))⎟
    ⎜    2                    ⎟
    ⎝  dt                     ⎠

除以 L2*m 得到标准形式:

PYTHON
eq = sp.simplify(euler_lagrange / (L**2 * m))
sp.pprint(eq)
输出
  2                    
 d          g⋅sin(θ(t))
───(θ(t)) + ───────────
  2              L     
dt       

solve 或手动整理后,运动方程是:

公式
theta''(t) + (g/L)*sin(theta(t)) = 0

这个方程就是第 7.5 节的阻尼振荡方程在 c=0、F0=0、omega 换成 sqrt(g/L) 且 sin(theta) 代替 theta 之后的版本。第 7.5 节解的是线性化的 theta'' + omega**2*theta = 0,本章要处理的是带 sin 的非线性版本。

14.1.4 一个建模层面的检查

在进入求解之前,应该先用 SymPy 验证运动方程的量纲和极限行为。这些检查不涉及求解,但能捕捉建模错误。

量纲检查:theta'' 的量纲是 1/time2,g/L 的量纲是 (length/time2)/length = 1/time**2。两者匹配,sin(theta) 无量纲。SymPy 没有内置的量纲系统,但你可以通过代入数值检查数量级:

PYTHON
subs_check = eq.subs({L: 1, g: 9.8, theta: sp.pi/6})
# 检查 eq 在 theta 处求值后是否约为 theta'' + 4.9

小角极限检查:当 theta 很小时,sin(theta) ≈ theta,方程退化为线性谐振子。这个极限在第 14.3 节会正式展开,但在这里可以先做一个快速验证:

PYTHON
linear_eq = sp.series(eq, theta, 0, 2).removeO()
# 应该得到 theta'' + (g/L)*theta = 0(忽略高阶项)

如果 series 的结果不是线性谐振子方程,说明前面的推导有误。这个检查的成本很低,能在进入求解之前捕捉大部分建模错误。

14.2 diff() 与 subs() 的链式操作

14.2.1 为什么单摆的推导依赖链式操作

第 14.1 节的推导已经用到了 diff 和 simplify。但单摆项目的真正复杂性不在推导运动方程,而在从运动方程出发做各种变换:小角近似、能量守恒、无量纲化、数值化。这些变换每一步都涉及 diff(求导)、subs(代入)、simplify(整理)的组合。链式操作的正确性决定了整个项目的可靠性。

一个典型的链式操作是能量守恒的验证。单摆的能量是:

公式
E = (1/2)*m*L**2*theta'**2 + m*g*L*(1 - cos(theta))

动能加势能(以最低点为势能零点)。能量守恒意味着 dE/dt = 0。用 SymPy 验证:

PYTHON
E = sp.Rational(1,2)*m*L**2*sp.diff(theta, t)**2 + m*g*L*(1 - sp.cos(theta))
dE_dt = sp.diff(E, t)
dE_dt = sp.simplify(dE_dt)
sp.pprint(dE_dt)
输出
    ⎛    2                    ⎞         
    ⎜   d                     ⎟ d       
L⋅m⋅⎜L⋅───(θ(t)) + g⋅sin(θ(t))⎟⋅──(θ(t))
    ⎜    2                    ⎟ dt      
    ⎝  dt                     ⎠        

直接对 E 求导会得到包含 theta'' 的表达式。要验证它为零,需要代入运动方程 theta'' = -(g/L)*sin(theta):

PYTHON
theta_ddot = sp.diff(theta, t, 2)
eq_solved = sp.solve(eq, theta_ddot)[0]  # -(g/L)*sin(theta)
dE_dt_sub = dE_dt.subs(theta_ddot, eq_solved)
dE_dt_sub = sp.simplify(dE_dt_sub)
print(dE_dt_sub)  # 0

这个验证展示了链式操作的基本模式:求导 → 代入运动方程 → 化简。每一步的输入是上一步的输出,错误的传播是链式的。如果 subs 没有匹配到正确的子表达式(比如 theta'' 在 SymPy 内部表示为 Derivative(theta, (t, 2)),而你的 subs 用了别的形式),结果会静默地不为零,而不是报错。这是第 10.4.1 节“同名不同对象”陷阱在微分方程场景中的变体。

14.2.2 subs 在微分表达式中的陷阱

subs 对 Derivative 对象的替换有一个容易忽略的细节:替换的目标必须与 SymPy 内部表示精确匹配。

PYTHON
theta_ddot = sp.diff(theta, t, 2)   # Derivative(theta(t), (t, 2))
print(theta_ddot)                    # Derivative(theta(t), (t, 2))

如果你尝试 expr.subs(sp.Derivative(theta, t, 2), value),可能会失败,因为 sp.Derivative(theta, t, 2) 重新构造的对象与 expr 中的对象在结构上可能不完全相同(取决于 theta 的创建方式)。更稳健的做法是用 expr.subs(theta_ddot, value),其中 theta_ddot 是从 expr 中提取的同一个对象:

PYTHON
# 从表达式中提取 theta''
theta_ddot_in_expr = [d for d in dE_dt.atoms(sp.Derivative) if d.derivative_count == 2][0]
dE_dt_sub = dE_dt.subs(theta_ddot_in_expr, eq_solved)

atoms(sp.Derivative) 列出表达式中所有导数对象,derivative_count 区分一阶和二阶。这个方法保证你替换的是表达式实际包含的对象,不是重新构造的“应该相等”的对象。第 12.2.5 节的诊断框架在这里适用:先用 type 和 atoms 看清对象是什么,再做替换。

14.2.3 无量纲化:一个完整的链式变换

物理推导的常见步骤是无量纲化:引入无量纲时间 tau = sqrt(g/L)*t,把方程变成 theta''(tau) + sin(theta) = 0。用 SymPy 做这个变换需要链式操作:

PYTHON
tau = sp.Symbol('tau', positive=True)
omega0 = sp.sqrt(g/L)
theta_tau = sp.Function('Theta')(tau)
# 替换 theta(t) -> Theta(tau),其中 tau = omega0*t
# theta'(t) = omega0 * Theta'(tau)
# theta''(t) = omega0**2 * Theta''(tau)

手动做这个替换容易出错,特别是二阶导数的链式法则。SymPy 的 Subs 或 replace 可以自动化,但需要小心处理:

PYTHON
from sympy import Subs, Derivative
# 用 Subs 表示替换,延迟求值
theta_ddot_t = sp.diff(theta, t, 2)
# 构造 theta(t) = Theta(omega0*t) 的替换规则
# 这需要自定义替换函数,因为 subs 不做链式法则

第 14.2.2 节的教训在这里放大:subs 不做链式法则。expr.subs(theta, Theta(omega0*t)) 会把 theta 替换为 Theta(omega0*t),但不会自动把 theta' 替换为 omega0*Theta'。导数对象的替换需要显式构造。

一个实用的做法是:不做符号替换,直接在无量纲变量下重新推导。既然拉格朗日量在无量纲化后形式更简单(Lag = (1/2)*Theta'2 - (1 - cos(Theta)),乘以常数因子),重新写一遍比做替换更不容易出错。这是符号计算的一个实用原则:当替换的链式法则复杂时,重新构造表达式往往比变换现有表达式更可靠**。第 13 章的生成器用模板重新构造表达式,而不是对现有表达式做变换,出于同样的考虑。

14.2.4 链式操作的检查点

每一步链式操作后,应该有一个检查点。检查点的形式取决于操作类型:

  • 求导后:检查结果的量纲或数量级是否合理。
  • 代入后:检查替换是否真的发生了(expr.has(old_var) 应该为 False)。
  • 化简后:检查表达式是否比之前更短(count_ops 下降),或者至少不变。

这些检查不能保证正确性,但能捕捉大部分操作失误。第 12 章的测试策略在这里以“开发时检查”的形式出现,不需要写成正式的单元测试,但应该养成习惯。

14.3 小角近似与符号线性化

14.3.1 series:小角近似的工具

单摆方程 theta'' + (g/L)*sin(theta) = 0 是非线性的,没有初等闭式解。小角近似把 sin(theta) 展开为 theta - theta**3/6 + ...,保留一阶项得到线性方程 theta'' + (g/L)*theta = 0,它有闭式解。

series 是执行这个展开的工具:

PYTHON
x = sp.Symbol('x')
print(sp.series(sp.sin(x), x, 0, 4))
# x - x**3/6 + O(x**4)

series(f, var, point, n) 在 point 处展开 f 到 n 阶。removeO() 去掉高阶无穷小项:

PYTHON
approx = sp.series(sp.sin(x), x, 0, 4).removeO()
print(approx)  # x - x**3/6

在单摆方程中:

PYTHON
theta_sym = sp.Symbol('theta')
sin_approx_1 = sp.series(sp.sin(theta_sym), theta_sym, 0, 2).removeO()  # theta
sin_approx_3 = sp.series(sp.sin(theta_sym), theta_sym, 0, 4).removeO()  # theta - theta**3/6

一阶近似给出线性方程,三阶近似给出带立方项的非线性方程(Duffing 型)。第 14.3.3 节会讨论为什么三阶近似在某些场景下比一阶更有用。

14.3.2 小角近似的适用边界

小角近似不是“只要角度小就成立”,它有一个可量化的误差边界。series 的余项给出误差估计:

PYTHON
sin_series = sp.series(sp.sin(theta_sym), theta_sym, 0, 4)
# sin_series = theta - theta**3/6 + O(theta**4)
# 误差约为 theta**4/120

对于 theta = 0.1 弧度(约 5.7 度),theta5/120 ≈ 8e-8,一阶近似的相对误差极小。对于 theta = 0.5 弧度(约 28.6 度),theta5/120 ≈ 2.6e-4,仍然可接受。对于 theta = 1 弧度(约 57.3 度),误差约 8e-3,开始变得明显。

这个分析可以用 SymPy 自动化:

PYTHON
def small_angle_error(theta_val, order=1):
    exact = sp.sin(theta_val)
    approx = sum(sp.series(sp.sin(theta_sym), theta_sym, 0, order+1).removeO()
                 for _ in [0])  # 简化写法
    return abs(float(exact - approx))

第 14.4 节会把这条误差曲线画出来,与数值解的偏差对照。

14.3.3 三阶近似:为什么保留立方项

一阶近似把单摆变成谐振子,周期是 2*pi*sqrt(L/g),与振幅无关。这是线性系统的特征。但真实的单摆周期随振幅增大而变长:振幅越大,sin(theta) 偏离 theta 越多,回复力相对越小,周期越长。

三阶近似 sin(theta) ≈ theta - theta**3/6 能捕捉这个效应。代入方程:

公式
theta'' + (g/L)*(theta - theta**3/6) = 0

这是一个 Duffing 型方程。它的周期可以通过摄动法或椭圆积分求出,结果包含振幅的修正项。本章不展开摄动法的推导(那需要专门的渐近分析章节),但会用数值方法验证:三阶近似的周期比一阶近似更接近真实周期。

这个对比是第 14.4 节可视化验证的核心:一阶近似、三阶近似、数值解三条曲线的周期差异,就是“线性化丢失了什么”的视觉答案。

14.3.4 线性化与 series 的边界

series 在 theta=0 处的展开对小角度有效。但如果摆从大角度释放(比如 theta0 = pi/2),series 在 theta=0 处的展开在 theta=pi/2 处收敛缓慢甚至不收敛(收敛半径是 pi,pi/2 在边界附近)。这时小角近似的误差不能用“展开阶数不够”来解释,它是近似方法的适用域问题。

SymPy 的 series 不会告诉你“这个展开在你的使用范围内是否可靠”。它只保证在展开点附近的形式正确性。适用域的判断是物理/数学建模的责任,不是符号计算的责任。第 14.4 节的数值验证会把这个边界画出来。

14.4 结果的可视化验证

14.4.1 三条曲线的叠加

第 9.3 节演示过把符号结果叠加到 Matplotlib 图上的模式。本节复用那个模式,叠加三条曲线:

  1. 一阶近似解:theta_1(t) = theta0*cos(omega0*t),其中 omega0 = sqrt(g/L)。
  2. 三阶近似的数值解:把 sin(theta) ≈ theta - theta**3/6 代入方程,用 solve_ivp 数值求解。
  3. 完整非线性方程的数值解:theta'' + (g/L)*sin(theta) = 0,用 solve_ivp 求解。
PYTHON
import numpy as np
from scipy.integrate import solve_ivp
import matplotlib.pyplot as plt
import sympy as sp
L_val, g_val = 1.0, 9.8
omega0 = np.sqrt(g_val / L_val)
theta0 = 1.0  # 初始角度,约 57 度
t_span = (0, 10)
t_eval = np.linspace(*t_span, 500)
# 完整非线性方程
def pendulum_full(t, state):
    theta, omega = state
    return [omega, -(g_val/L_val)*np.sin(theta)]
# 三阶近似方程
def pendulum_cubic(t, state):
    theta, omega = state
    return [omega, -(g_val/L_val)*(theta - theta**3/6)]
sol_full = solve_ivp(pendulum_full, t_span, [theta0, 0], t_eval=t_eval, rtol=1e-10)
sol_cubic = solve_ivp(pendulum_cubic, t_span, [theta0, 0], t_eval=t_eval, rtol=1e-10)
# 一阶近似(解析解)
theta_linear = theta0 * np.cos(omega0 * t_eval)

三条曲线的绘制:

PYTHON
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(t_eval, sol_full.y[0], 'k-', label='完整非线性', linewidth=1.5)
ax.plot(t_eval, sol_cubic.y[0], 'b--', label='三阶近似', linewidth=1.2)
ax.plot(t_eval, theta_linear, 'r:', label='一阶近似', linewidth=1.2)
ax.set_xlabel('t (s)')
ax.set_ylabel(r'$\theta$ (rad)')
ax.legend()
plt.tight_layout()

14.4.2 周期漂移:三条曲线的关键差异

三条曲线在初始几个周期内几乎重合,差异随着时间累积。差异的核心是周期:

  • 一阶近似的周期是 T0 = 2*pi/omega0 ≈ 2.006 秒,与振幅无关。
  • 完整非线性方程的周期随振幅增大而变长。对于 theta0 = 1 弧度,周期约为 2.04 秒。
  • 三阶近似的周期介于两者之间,更接近完整解。

这个周期差异导致相位漂移:在 t = 10 秒(约 5 个周期)时,一阶近似与完整解的相位差约为 5 * (2.04 - 2.006) / 2.006 ≈ 0.08 个周期,即约 30 度。这在图上表现为两条曲线的波峰不再对齐。

PYTHON
# 估计周期漂移
# 找到第一个波峰的位置
from scipy.signal import find_peaks
peaks_full, _ = find_peaks(sol_full.y[0])
peaks_linear, _ = find_peaks(theta_linear)
T_full = np.mean(np.diff(t_eval[peaks_full]))
T_linear = np.mean(np.diff(t_eval[peaks_linear]))
print(f"完整解周期: {T_full:.4f} s")
print(f"一阶近似周期: {T_linear:.4f} s")
print(f"周期差: {T_full - T_linear:.4f} s")

这个差异是可量化的,不是“看起来有点不一样”。第 14.4.3 节会把周期差作为振幅的函数画出来。

14.4.3 振幅-周期曲线:一个完整的符号-数值工作流

周期与振幅的关系是单摆问题的核心结果。用数值方法计算不同初始振幅下的周期,然后把结果与一阶、三阶近似的预测对比。

PYTHON
amplitudes = np.linspace(0.1, 1.5, 15)
periods_numeric = []
periods_cubic = []
for amp in amplitudes:
    sol = solve_ivp(pendulum_full, (0, 10), [amp, 0],
                    dense_output=True, rtol=1e-10)
    # 找第一个完整周期
    peaks, _ = find_peaks(sol.sol(np.linspace(0, 10, 2000))[0])
    if len(peaks) >= 2:
        t_peaks = np.linspace(0, 10, 2000)[peaks]
        periods_numeric.append(t_peaks[1] - t_peaks[0])
    else:
        periods_numeric.append(np.nan)
    sol_c = solve_ivp(pendulum_cubic, (0, 10), [amp, 0],
                      dense_output=True, rtol=1e-10)
    peaks_c, _ = find_peaks(sol_c.sol(np.linspace(0, 10, 2000))[0])
    if len(peaks_c) >= 2:
        t_peaks_c = np.linspace(0, 10, 2000)[peaks_c]
        periods_cubic.append(t_peaks_c[1] - t_peaks_c[0])
    else:
        periods_cubic.append(np.nan)

理论预测:

  • 一阶近似:T = T0,水平线。
  • 三阶近似的周期修正:T ≈ T0 * (1 + theta0**2/16)(这是 Duffing 方程的标准结果)。
PYTHON
T0 = 2 * np.pi / omega0
periods_linear = np.full_like(amplitudes, T0)
periods_cubic_theory = T0 * (1 + amplitudes**2 / 16)
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(amplitudes, periods_numeric, 'ko', label='数值解')
ax.plot(amplitudes, periods_cubic, 'b--', label='三阶近似(数值)')
ax.plot(amplitudes, periods_cubic_theory, 'b:', label='三阶近似(理论)')
ax.plot(amplitudes, periods_linear, 'r-.', label='一阶近似')
ax.set_xlabel(r'初始振幅 $\theta_0$ (rad)')
ax.set_ylabel('周期 T (s)')
ax.legend()
plt.tight_layout()

这张图是本章的结论性图形。它展示了几件事:

  1. 一阶近似的周期与振幅无关,是一条水平线,在小振幅下与数值解吻合。
  2. 三阶近似捕捉了周期随振幅增长的趋势,在中等振幅(theta0 < 1)下与数值解相当接近。
  3. 在大振幅(theta0 > 1.2)下,三阶近似开始偏离,因为 theta**5/120 项不再可忽略。
  4. 数值解本身在 theta0 接近 pi 时周期趋于无穷(单摆趋向不稳定平衡),这是三阶近似无法捕捉的。

这张图同时是第 14.3.2 节误差分析和第 14.3.3 节三阶近似动机的视觉汇总。“线性化丢失了什么”的答案,就是红色水平线与黑色数据点之间的差距。

14.4.4 与第 7.5 节数值验证的呼应

第 7.5.4 节用 lambdify + solve_ivp 验证了线性阻尼振荡的符号解与数值解一致。本章的验证结构相同,但结论不同:线性方程中符号解与数值解“一致”是预期内的;非线性方程中一阶近似与数值解“不一致”才是重点。这个“不一致”不是 bug,是线性化适用域的边界,是第 14.3.2 节误差分析在数值层面的确认。

第 7.5 节的闭环表在这里获得了一个扩展版本:

步骤工具产出
建模拉格朗日量 + diff运动方程
小角近似series + removeO线性方程 + 三阶方程
线性解dsolve解析解 theta0*cos(omega0*t)
数值解solve_ivp完整非线性曲线
验证叠加绘图 + 周期测量周期漂移、振幅-周期曲线
解释series 余项 + 适用域分析线性化的边界

这张表与第 7.5 节的表共享后半段(验证、解释),但前半段不同:第 7.5 节的方程是给定的,本章的方程是从拉格朗日量推导出来的。

14.5 本章闭环

主题工具关键判断典型陷阱
拉格朗日推导diff + simplify用 Function 定义 theta(t),不是 Symbol用 Symbol 导致求导为零
链式操作diff / subs / simplify替换目标必须与表达式中的对象精确匹配subs 对 Derivative 静默失败
小角近似series + removeO一阶给线性方程,三阶给 Duffing 型展开阶数不等于适用域
数值验证solve_ivp + Matplotlib周期漂移是可量化的验证指标只画图不看周期差
适用域余项估计 + 振幅-周期曲线线性化在小振幅下有效,大振幅失效把“近似”当“等价”

本章的判断可以归结为:当方程不可解时,符号计算的价值从“求解”转向“提供精确的近似骨架和验证基准”。 第 14.3 节的 series 给出了小角近似的精确展开,第 14.4 节的数值验证给出了近似的适用边界,两者合起来回答了第 7.5 节留下的问题:非线性不可解,但不可解不等于不可知。你知道它在小角度下近似于什么,知道近似在哪个振幅开始失效,知道失效的方式是周期变长而不是振幅增大。

第 13 章的生成器处理的是“可解且答案可验证”的题目,本章处理的是“不可解但近似可验证”的物理系统。两者的共同点是:验证路径必须独立于求解路径。第 13 章的验证是反向操作(积分用求导验证),本章的验证是数值对照(解析近似用数值解对照)。第 8 章的 lambdify 在两章中都是转换层,只是第 13 章用它生成数值答案,本章用它生成绘图数据。

第 15 章进入第三个项目:符号-数值混合优化。本章的“近似骨架”在那个项目中会变成“精确的梯度和 Hessian”,第 6 章的矩阵求导、第 8 章的 lambdify、本章的 solve_ivp 对照会在优化器的循环中汇合。第 14 章留下的“适用域”判断,在第 15 章会以“优化器的收敛域”的形式再次出现。

《SymPy 数学编程》 · 第 14 章 项目二:物理中的符号推导——单摆与非线性振动 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 15 章 项目三:符号-数值混合优化

目标函数符号化 · 梯度与 Hessian 的符号推导 · lambdify() 转数值函数 · 接入 SciPy 优化器 · 精度与效率的平衡 · 本章闭环

第 14 章的单摆项目结束时,你知道了符号计算在不可解方程面前的角色转变:从求解器变成近似引擎和验证骨架。第 15 章要把这个转变再推进一步。在优化问题中,SymPy 几乎从不负责“求解”——优化器的迭代是数值的,SymPy 不参与。但 SymPy 负责优化器所需的一切输入:目标函数、梯度、Hessian。这些输入的精确性直接决定优化器的行为,而手工推导这些输入是数值优化中最容易出错、最耗时的环节。

本章的判断可以提前给出:符号计算在优化中的价值,不是替代优化器,是给优化器提供不会出错的导数。 一个手推梯度写错一个符号,优化器可能收敛到错误的点,或者干脆不收敛,而你会在数值调试中花掉几个小时才找到那个符号错误。SymPy 的 diff 不会犯这个错。

15.1 目标函数符号化

15.1.1 一个具体的问题

为了让本章的讨论有落脚点,选一个具体的优化问题:Rosenbrock 函数的最小化。这个函数是优化测试的标准案例,形式是:

公式
f(x, y) = (1 - x)**2 + 100*(y - x**2)**2

它在 (1, 1) 处有全局最小值 0,但最小值附近是一个狭长的弯曲山谷,梯度下降在这类地形上收敛极慢。选它做案例,因为它足够简单(两变量、多项式),又足够刁钻(条件数差、山谷弯曲),能展示符号导数在“手工推导容易出错”和“数值优化需要精确输入”之间的张力。

用 SymPy 定义:

PYTHON
import sympy as sp
x, y = sp.symbols('x y', real=True)
f = (1 - x)**2 + 100*(y - x**2)**2

这个定义看起来平淡,但有两个值得点出的细节。

细节一:real=True。 第 10 章讨论过假设的作用。在优化问题中,变量通常是实数,加 real=True 让 SymPy 在化简时知道 x2 非负、sqrt(x2) = Abs(x) 等性质。对于 Rosenbrock 函数这种纯多项式,real=True 对 diff 的结果没有直接影响,但它对后续的 simplify 和 lambdify 有间接影响:实数假设下的化简路径更短,生成的数值代码更干净。

细节二:目标函数的“符号化”不只是写一行定义。 在实际项目中,目标函数可能来自物理模型(第 14 章的单摆能量)、统计模型(负对数似然)、或者工程指标(误差平方和)。这些函数往往包含参数、分段定义、或者来自其他符号推导的中间结果。把它们写成 SymPy 表达式,意味着你保留了完整的结构信息,可以在求导、化简、代换中操作它们。如果一开始就写成 NumPy 函数,这些信息就丢失了。

15.1.2 符号化与数值化的边界

第 11.4 节讨论过“何时该离开 SymPy”。在优化项目中,这条边界画在“目标函数定义”和“优化器调用”之间。

符号化阶段做这些事:

  • 定义目标函数表达式。
  • 求梯度、Hessian(第 15.2 节)。
  • 检查导数的正确性(用有限差分对照,第 15.2.3 节)。
  • 用 lambdify 转成数值函数(第 15.3 节)。

数值化阶段做这些事:

  • 调用 SciPy 优化器。
  • 优化器内部反复调用目标函数和梯度函数。
  • 优化器根据数值反馈调整步长、方向。

边界的位置由“是否需要反复求值”决定。目标函数在优化器中被调用数百到数千次,每一次都应该是 lambdify 生成的 NumPy 函数,不是 SymPy 的 .subs()。这是第 8.4 节的榫卯在优化场景中的直接应用。

15.1.3 一个容易忽略的准备:参数的符号化

实际的目标函数往往带参数。例如,一个带权重的误差函数:

公式
f(x, y) = w1*(1 - x)**2 + w2*(y - x**2)**2

其中 w1、w2 是已知的常数权重。在符号化阶段,把它们定义为符号还是直接代入数值?

如果 w1、w2 在优化过程中不变,应该在 lambdify 之前用 subs 固定它们的值,让生成的数值函数只接受 x、y 作为输入。第 8.5 节的实战已经演示过这个模式:response.subs(params) 先把参数固定,再 lambdify。

如果 w1、w2 需要在不同的优化运行中变化(比如扫描不同的权重组合),有两种策略。第一种是每次扫描前重新 lambdify,代价是重复的符号处理时间。第二种是 lambdify 时保留参数作为函数参数,代价是生成的函数签名更长,调用时需要传所有参数。第一种策略适合参数组合少、每次优化耗时长的场景;第二种适合参数组合多、每次优化耗时短的场景。本章的案例用第一种,因为 Rosenbrock 函数没有外部参数,这个讨论是为了让读者在扩展时知道边界在哪里。

15.2 梯度与 Hessian 的符号推导

15.2.1 梯度的符号计算

梯度是目标函数对每个变量的偏导数组成的向量:

PYTHON
grad = [sp.diff(f, var) for var in (x, y)]
print(grad)

输出:

输出
[-2 + 2*x - 400*x*(y - x**2), 200*(y - x**2)]

这两个表达式是精确的、无浮点误差的。对比手工推导:∂f/∂x = -2(1-x) + 100*2*(y-x**2)*(-2x) = -2 + 2x - 400x(y-x^2),∂f/∂y = 200(y-x^2)。手工推导的每一步都有出错的可能,特别是链式法则中的负号和系数。SymPy 的 diff 不犯这类错误。

第 6 章的矩阵求导工具在这里可以派上用场。sp.Matrix([f]).jacobian([x, y]) 给出梯度的行向量形式,对于多变量问题是更系统的写法:

PYTHON
grad_matrix = sp.Matrix([f]).jacobian([x, y])
print(grad_matrix)

输出是一个 1×2 矩阵。对于本章的两变量问题,列表形式和矩阵形式等价;对于十变量、二十变量的问题,矩阵形式更不容易在索引上出错。

derive_by_array 是另一个可选工具(第 15 章提供的参考实现中使用了它)。它接受表达式和变量列表,返回梯度的数组形式:

PYTHON
grad_array = sp.derive_by_array(f, [x, y])
print(grad_array)

derive_by_array 与 jacobian 的区别在于返回类型:前者返回嵌套的数组结构,后者返回 Matrix。对于需要进一步做矩阵运算(比如 grad.T * H * grad)的场景,Matrix 更方便;对于直接喂给 lambdify 的场景,两者的行为需要测试确认(第 8.4.3 节的广播陷阱在这里可能再次出现)。

15.2.2 Hessian 的符号计算

Hessian 是二阶偏导数组成的对称矩阵:

PYTHON
H = sp.hessian(f, (x, y))
print(H)

输出:

输出
[[2 - 400*y + 1200*x**2, -400*x],
 [-400*x, 200]]

sp.hessian 直接给出这个矩阵。注意 H[0,0] 的表达式 2 - 400*y + 1200*x2:它来自 ∂²f/∂x²,包含了 -400*y 和 1200*x2 两项。手工推导这个表达式需要仔细展开 ∂/∂x[-2 + 2x - 400x(y-x^2)],容易漏项。SymPy 的输出是精确的。

Hessian 在优化中的作用是提供曲率信息。在最小值点 (1, 1) 处:

PYTHON
H_at_min = H.subs({x: 1, y: 1})
print(H_at_min)
输出
[[802, -400], 
 [-400, 200]]  

这个矩阵是正定的(左上角 802 > 0,行列式 802*200 - 400**2 = 160400 - 160000 = 400 > 0),确认 (1,1) 是局部最小值。第 6 章讨论过特征值与正定性的关系;在优化语境中,Hessian 的正定性是判断极值点类型的标准工具。

15.2.3 导数正确性的独立验证

第 13 章和第 14 章都强调过:验证路径必须独立于求解路径。梯度和 Hessian 的验证不能再用 diff(那只是重复计算),而要用数值差分:

PYTHON
import numpy as np
f_num = sp.lambdify((x, y), f, 'numpy')
grad_num = sp.lambdify((x, y), grad, 'numpy')
# 在某点用有限差分估计梯度
def finite_diff_grad(fn, point, h=1e-6):
    point = np.array(point, dtype=float)
    g = np.zeros_like(point)
    for i in range(len(point)):
        e = np.zeros_like(point)
        e[i] = h
        g[i] = (fn(*(point + e)) - fn(*(point - e))) / (2*h)
    return g
point = np.array([0.5, 0.5])
g_symbolic = np.array([float(g) for g in grad_num(*point)])
g_fd = finite_diff_grad(f_num, point)
print("符号梯度:", g_symbolic)
print("有限差分:", g_fd)
print("差异:", np.max(np.abs(g_symbolic - g_fd)))
输出
符号梯度: [-51.  50.]
有限差分: [-51.  50.]
差异: 1.3090186712361174e-09

如果差异在 h2 量级(对于中心差分,误差是 O(h2),h=1e-6 时误差约 1e-12,但浮点噪声可能让实际差异在 1e-6 到 1e-8 之间),说明符号梯度正确。如果差异很大,符号推导有误。

这个验证只在开发阶段做一次,不需要写进生产代码。它的价值是捕捉“diff 用错了变量”或“表达式里有个手误”这类错误。diff 本身不会算错,但你可能对错误的表达式求导——比如把 y - x**2 写成了 y - x^2(Python 的 ^ 是异或,不是幂)。lambdify 后的有限差分对照能抓住这类错误。

15.3 lambdify() 转数值函数

15.3.1 从符号到可调用

有了 f、grad、H,下一步是转成 NumPy 可调用的函数:

PYTHON
f_func = sp.lambdify((x, y), f, 'numpy')
grad_func = sp.lambdify((x, y), grad, 'numpy')
H_func = sp.lambdify((x, y), H, 'numpy')

这三个函数是优化器的输入。scipy.optimize.minimize 接受 fun(目标函数)、jac(梯度函数)、hess(Hessian 函数)作为参数。如果只提供 fun,SciPy 会用有限差分估计梯度,精度低、速度慢;如果提供 jac,优化器使用精确梯度,收敛更快更可靠;如果提供 hess,牛顿型方法可以使用二阶信息,进一步加速。

15.3.2 广播陷阱在优化场景中的表现

第 8.4.3 节警告过:lambdify 对 Matrix 输出和数组输入的组合可能失败。在优化场景中,grad 是一个列表,H 是一个 Matrix,它们的行为不同。

梯度函数:grad 是 Python 列表 [expr1, expr2]。lambdify 对这个列表的输出是一个 NumPy 数组(长度为 2),接受标量输入时返回长度为 2 的数组。这是优化器期望的形式:

PYTHON
print(grad_func(0.5, 0.5))  # array([...])

Hessian 函数:H 是 sp.Matrix。lambdify 对 Matrix 的输出在标量输入下返回嵌套数组,形状是 (2, 2):

PYTHON
print(H_func(0.5, 0.5))  # array([[..., ...], [..., ...]])

这个形式在 minimize(..., hess=H_func) 中被接受,因为 SciPy 期望 Hessian 是 (n, n) 数组。但如果你的优化器需要沿某个变量向量化(比如对一批点同时求 Hessian),第 8.4.3 节的逐点堆叠策略适用。

一个容易忽略的细节:grad_func 接受标量输入时返回数组,但如果你的优化器传入的是长度为 1 的数组(某些 SciPy 方法的内部实现会这样做),grad_func(np.array([0.5]), np.array([0.5])) 可能触发广播问题。稳健的写法是:

PYTHON
def safe_grad_func(x_vec):
    """包装梯度函数,接受数组输入,返回数组。"""
    return np.array(grad_func(*x_vec), dtype=float)
def safe_hess_func(x_vec):
    return np.array(H_func(*x_vec), dtype=float)

safe_grad_func 解包数组 x_vec 为 x、y,传给 grad_func,再把结果转回数组。这个包装看起来多余,但它隔离了 lambdify 输出格式与优化器输入格式之间的差异。第 8.4.5 节的 safe_lambdify 封装是另一个层级的解决方案;对于本章的两变量问题,手写包装更直观。

15.3.3 性能考量:cse=True

第 8.4.4 节讨论过 cse=True 的收益。对于 Rosenbrock 函数,梯度表达式包含重复的 (y - x**2):

PYTHON
grad_cse = sp.cse(grad)
print(grad_cse)
# ([(x0, y - x**2)], [-2 + 2*x - 400*x*x0, 200*x0])

提取 x0 = y - x2 后,梯度函数避免重复计算 y - x2。lambdify(grad, cse=True) 会自动做这个优化。对于 Rosenbrock 这种简单函数,收益微小;对于第 11.5 节那种“表达式膨胀”场景,收益显著。在优化器中,梯度函数被调用数百次,每次节省一点,累积起来是可观的时间。

15.3.4 一个完整的三函数构建

把 15.3.1 到 15.3.3 整合成一个构建函数:

PYTHON
def build_objective():
    x, y = sp.symbols('x y', real=True)
    f = (1 - x)**2 + 100*(y - x**2)**2
    grad = [sp.diff(f, v) for v in (x, y)]
    H = sp.hessian(f, (x, y))
    f_func = sp.lambdify((x, y), f, 'numpy', cse=True)
    grad_func = sp.lambdify((x, y), grad, 'numpy', cse=True)
    H_func = sp.lambdify((x, y), H, 'numpy', cse=True)
    return {
        'f': lambda xv: float(f_func(*xv)),
        'grad': lambda xv: np.array(grad_func(*xv), dtype=float),
        'hess': lambda xv: np.array(H_func(*xv), dtype=float),
    }

这个构建函数返回的字典包含优化器需要的三个函数。lambda xv: float(f_func(*xv)) 把 f_func 的输出强制转为 Python 浮点数,因为 SciPy 的某些方法对目标函数的返回类型敏感(返回长度为 1 的数组可能导致内部比较出错)。这个细节在纯 NumPy 工作流中不会遇到,是符号-数值混合工作流特有的边界情况。

15.4 接入 SciPy 优化器

15.4.1 三种配置的对照

SciPy 的 minimize 允许你选择提供哪些导数信息。三种配置的对照:

PYTHON
from scipy.optimize import minimize
obj = build_objective()
x0 = np.array([-1.0, 1.0])  # 一个不靠近最小值的起点
# 配置一:只提供目标函数(有限差分梯度)
res1 = minimize(obj['f'], x0, method='BFGS')
# 配置二:提供目标函数和精确梯度
res2 = minimize(obj['f'], x0, jac=obj['grad'], method='BFGS')
# 配置三:提供目标函数、梯度、Hessian(牛顿型方法)
res3 = minimize(obj['f'], x0, jac=obj['grad'], hess=obj['hess'], method='trust-ncg')

三种配置的结果对比:

配置方法梯度来源典型迭代次数典型函数求值次数
只给 fBFGS有限差分较多最多
f + gradBFGS精确符号导数较少中等
f + grad + hesstrust-ncg精确符号导数最少最少

具体的数字取决于起点、容差和函数的地形。Rosenbrock 函数从 (-1, 1) 出发,配置一可能需要 30–50 次迭代,配置二 20–30 次,配置三 10–20 次。配置三的优势来自二阶信息:牛顿型方法利用 Hessian 选择搜索方向,收敛速度是二次的(在最小值附近),而 BFGS 是超线性的。

这个对照的意义不是“配置三总是最好”。trust-ncg 需要 Hessian 正定,在非凸区域可能失败;BFGS 更稳健,对 Hessian 的质量不敏感。选择哪种配置取决于问题的性质。符号计算的价值是:它让你有选择的余地。如果只能提供目标函数,你被迫接受有限差分的精度损失;有了符号导数,你可以根据问题选择最快的配置。

15.4.2 优化器的诊断输出

SciPy 的优化结果对象包含诊断信息,这些信息与本章的符号导数直接相关:

PYTHON
print(f"成功: {res2.success}")
print(f"迭代次数: {res2.nit}")
print(f"函数求值次数: {res2.nfev}")
print(f"梯度求值次数: {res2.njev}")
print(f"最终梯度范数: {np.linalg.norm(res2.jac)}")
print(f"最优解: {res2.x}")

res2.jac 是优化器在最终点计算的梯度。如果符号梯度正确,它应该接近零(在容差范围内)。如果它不接近零,可能的原因包括:优化器没收敛(success=False)、梯度函数在最终点的计算有误、或者目标函数在最终点附近有数值问题。

res2.nfev 和 res2.njev 的比值是另一个诊断指标。配置二中,njev 通常小于 nfev,因为 BFGS 在某些迭代中复用梯度信息。如果 njev 接近 nfev,说明优化器在每次函数求值时都重新计算梯度,可能没有充分利用 BFGS 的近似 Hessian 更新。

15.4.3 梯度错误时会发生什么

这一节是本章的“反面教材”,值得单独展开,因为它解释了为什么符号导数的正确性验证(第 15.2.3 节)不能省略。

假设你在手工推导梯度时犯了一个符号错误,把 ∂f/∂x 写成了 -2 + 2x + 400x(y-x^2)(-400 写成了 +400)。优化器会怎样?

PYTHON
def wrong_grad(xv):
    x, y = xv
    return np.array([
        -2 + 2*x + 400*x*(y - x**2),  # 错误的符号
        200*(y - x**2)
    ])
res_wrong = minimize(obj['f'], x0, jac=wrong_grad, method='BFGS')
print(res_wrong.x, res_wrong.success)

典型的结果是:优化器在错误的梯度方向上移动,可能收敛到一个错误的点,可能报“梯度与函数值不一致”的警告,也可能不收敛但 success=True(因为优化器只看梯度范数,不看梯度是否正确)。最危险的情况是最后一种:优化器“成功”了,但解是错的,而你没有理由怀疑它。

符号梯度不会犯这个错误。sp.diff(f, x) 的符号是确定的,lambdify 的转换是机械的,唯一可能出错的地方是 f 的定义本身写错了。第 15.2.3 节的有限差分验证能捕捉 f 定义中的手误,也能捕捉 lambdify 转换中的意外。

15.4.4 一个完整的优化脚本

把本章的模块串成端到端脚本:

PYTHON
import numpy as np
import sympy as sp
from scipy.optimize import minimize
def main():
    # 1. 符号化
    x, y = sp.symbols('x y', real=True)
    f = (1 - x)**2 + 100*(y - x**2)**2
    grad = [sp.diff(f, v) for v in (x, y)]
    H = sp.hessian(f, (x, y))
    # 2. 数值化
    f_func = sp.lambdify((x, y), f, 'numpy', cse=True)
    grad_func = sp.lambdify((x, y), grad, 'numpy', cse=True)
    H_func = sp.lambdify((x, y), H, 'numpy', cse=True)
    # 3. 验证(开发时)
    point = np.array([0.5, 0.5])
    g_sym = np.array(grad_func(*point), dtype=float)
    # 有限差分对照(略,见 15.2.3)
    assert np.allclose(g_sym, finite_diff_grad(lambda *p: f_func(*p), point), atol=1e-5)
    # 4. 优化
    x0 = np.array([-1.0, 1.0])
    res = minimize(
        lambda xv: float(f_func(*xv)),
        x0,
        jac=lambda xv: np.array(grad_func(*xv), dtype=float),
        hess=lambda xv: np.array(H_func(*xv), dtype=float),
        method='trust-ncg',
    )
    # 5. 诊断
    print(f"最优解: {res.x}")
    print(f"最小函数值: {res.fun}")
    print(f"迭代次数: {res.nit}")
    print(f"最终梯度范数: {np.linalg.norm(res.jac)}")
    return res
if __name__ == "__main__":
    main()

这个脚本的每一段对应本章一节:符号化(15.1)、导数(15.2)、lambdify(15.3)、优化器(15.4)。第 13 章的 .meta 记录、第 12 章的验证策略、第 8 章的 lambdify 榫卯,在这里全部汇合。

15.5 精度与效率的平衡

15.5.1 符号导数的精度优势

符号导数是精确的(在代数意义上),没有浮点误差。有限差分梯度有 O(h2) 的截断误差和 O(eps/h) 的舍入误差,最优的 h 是 eps(1/3) 量级,误差在 1e-10 到 1e-8 之间。对于条件数好的问题,这个精度足够;对于条件数差的问题(Rosenbrock 函数在最小值附近的条件数很高),有限差分误差可能被放大。

符号导数的精度优势在优化器的最终阶段最明显。当优化器接近最小值时,梯度接近零,有限差分的相对误差急剧增大(因为分子是两个接近的数之差),而符号梯度保持精确。这解释了为什么配置二、三在最小值的最后几个迭代中收敛更快。

15.5.2 符号处理的成本

符号导数的成本在“推导”阶段,不在“求值”阶段。sp.diff(f, x) 对 Rosenbrock 函数是瞬间完成的,sp.hessian 也是。但如果 f 是一个包含数百项、嵌套十几层的表达式,diff 和 hessian 可能耗时数秒到数分钟,且生成的导数表达式可能膨胀到 lambdify 都难以处理。

第 11 章的诊断工具在这里适用:用 count_ops 检查导数表达式的规模。如果 count_ops(grad) 是 count_ops(f) 的十倍以上,表达式膨胀已经发生,需要考虑 cse、手动化简、或者放弃符号导数改用有限差分。

一个实用的判断标准:如果符号导数的推导时间超过了优化本身的运行时间,符号导数的经济性就不成立。对于 Rosenbrock 函数,符号推导是毫秒级,优化是毫秒到秒级,符号导数明显划算。对于复杂的目标函数,需要实际测量。

15.5.3 混合策略:符号导数 + 数值验证

本章推荐的最终策略是:生产环境用符号导数,开发环境用数值验证。

符号导数在运行时是 lambdify 生成的 NumPy 函数,速度与手写的 NumPy 梯度函数相当(cse=True 后可能更快,因为避免了重复计算)。数值验证只在开发时运行一次,成本可以忽略。这个组合既获得了符号导数的精度和可靠性,又避免了手工推导的错误风险,且不牺牲运行时的性能。

这个策略与第 13 章的生成器、第 14 章的单摆验证共享同一个结构:符号计算负责“正确性”的部分,数值计算负责“执行”的部分,两者的边界由 lambdify 划定。

15.6 本章闭环

阶段工具产出关键判断
符号化symbols + 表达式定义f, grad, H参数在 lambdify 前固定
导数验证有限差分 + lambdify差异 < 1e-5验证独立于 diff
数值化lambdify(cse=True)三个 NumPy 函数包装标量/数组转换
优化scipy.optimize.minimize最优解 + 诊断按问题选择 jac/hess 配置
诊断res.jac, res.nit, res.nfev收敛性判断梯度范数接近零
成本判断count_ops + 计时符号导数是否划算推导时间 vs 优化时间

本章的判断可以归结为:符号计算在优化中的角色是“提供精确的导数”,不是“执行优化”。 优化器的迭代是数值的,SymPy 不参与;但优化器需要的梯度、Hessian,SymPy 能以零错误率提供。第 15.4.3 节的反面教材说明了这个价值的反面:手推梯度的一个符号错误,能让优化器“成功”地收敛到错误答案,而你不会收到任何警告。

第五部分(第 13–15 章)到此结束。三个项目分别展示了符号计算在三种场景中的角色:第 13 章是“可解问题的生成与验证”,第 14 章是“不可解问题的近似与验证”,第 15 章是“数值算法的精确输入”。三者的共同结构是:符号计算负责正确性,数值计算负责执行,lambdify 是边界。 这个结构从第 8 章埋下,在三个项目中以不同形态反复出现。

全书正文到此结束。第 13–15 章没有再引入新的 SymPy 工具——diff、integrate、series、lambdify、hessian、latex 都在前面的章节出现过。这是本书的设计:第五部分的项目不教新函数,只展示如何把前四部分的工具组合成可用的东西。如果你读到这里,发现自己能独立复现这三个项目,并且能解释每一步为什么这样做,那么本书的目标已经达到。剩下的扩展工作——更多的题型、更复杂的物理系统、更高维的优化问题——是机械的,不涉及新的概念。

《SymPy 数学编程》 · 第 15 章 项目三:符号-数值混合优化 · 页面为自包含 HTML,可离线打开

SymPy 数学编程

第 16 章 结语:符号计算的边界与本书的边界

这本书没有教你什么 · 这本书教了你什么 · 一条贯穿全书的线 · 符号计算与数值计算的分野 · 本书的边界 · 给读者的三句话

一本书的结尾通常有两种写法:一种是总结,把前面各章的知识点再列一遍;另一种是交代,说清楚这本书做了什么、没做什么、边界在哪里。本书选后一种。

这本书没有教你什么

先说没有教的,因为它比教了的更能界定这本书的位置。

本书没有教你如何成为 SymPy 的专家。SymPy 的完整功能——符号逻辑、数论、组合数学、物理模块、量子计算、张量运算——本书只覆盖了数学工作者日常会用到的核心部分。第 1–7 章是符号计算的基础与核心运算,第 8–12 章是从计算到呈现的工程环节,第 13–15 章是三个综合项目。这个结构覆盖了“用 SymPy 做数学”的主干,但不是“SymPy 的全部”。

本书没有教你如何写生产级软件。第 8 章的 codegen、第 12 章的测试策略、第 15 章的优化器接入,都涉及工程实践,但本书的立场始终是数学工作者的立场:这些工具是为了让你的数学工作更可靠、更可复现,不是为了让你的代码符合软件工程的工业标准。如果你需要后者,本书不是那本书。

本书没有教你数值分析。第 8 章的 lambdify、第 14 章的 solve_ivp、第 15 章的 SciPy 优化器,都涉及数值方法,但本书把这些当作“符号计算的下游消费者”来处理,没有展开数值算法本身的原理。你知道 lambdify 生成的函数可以喂给 solve_ivp,但 solve_ivp 的 Runge-Kutta 实现细节不在本书范围内。

这本书教了你什么

反过来,本书教的东西可以归结为一个判断:符号计算的价值在于“精确地知道”,而它的局限在于“不知道时拒绝猜”。

第 1–7 章建立了这个判断的技术基础:表达式是树,subs 是精确替换,diff 和 integrate 是精确操作,solve 返回的是精确解集。第 10 章把这个判断推向极致:SymPy 不会替你假设,None 是一个合法的答案。第 11 章告诉你,这个“拒绝猜”的代价是性能,而性能问题的解法往往不是“让 SymPy 更快”,是“在正确的阶段用正确的工具”。第 12 章告诉你,这个“精确地知道”的代价是可复现性的要求,你必须记录假设、版本、种子,否则你以为在运行同一个计算,实际上每次都在运行一个略有不同的问题。

第 13–15 章的三个项目是这条判断的三个应用。练习生成器告诉你,正确性不在生成,在验证;单摆项目告诉你,不可解不等于不可知;优化项目告诉你,符号计算的价值是给数值算法提供不会出错的输入。

一条贯穿全书的线

如果本书有一条主线,它是从第 8 章开始、在第 15 章收束的:lambdify 是符号世界与数值世界的边界。

这条线在第 8 章被完整展开:lambdify 是命名空间翻译,它不改变数学含义,只改变求值时调用的底层函数;它有时会撞上广播陷阱,因为 SymPy 的 Matrix 和 NumPy 的数组在“逐元素”这个概念上有不同的语义。第 9 章的绘图、第 13 章的答案生成、第 14 章的数值验证、第 15 章的优化器接入,全部穿过这道门。如果读者只记住一件事,应该是:在符号层面把该做的推导做完,然后一次性穿过 lambdify,在数值层面做重复的求值。不要把两者混在同一个循环里。

符号计算与数值计算的分野

本书反复提到这个分野,因为它解释了 SymPy 的很多“看起来奇怪”的行为。数值计算在每一步丢弃信息:浮点数丢弃精度,数值积分丢弃解析结构,数值求解丢弃解集的完整性。符号计算在每一步保留信息,包括“不知道”这个信息。

这个分野有一个实用的推论:符号计算适合做“一次性的、结构敏感的工作”,数值计算适合做“重复的、精度敏感的工作”。 推导运动方程、求梯度、验证解的正确性,是一次性的,符号计算做得好。在优化器中反复求值、在绘图中生成一千个点、在 ODE 求解器中迭代一万步,是重复的,数值计算做得好。第 11 章的“何时该离开 SymPy”和第 15 章的“精度与效率的平衡”,都是这个推论的具体化。

本书的边界

本书的边界由两部分构成:SymPy 的边界和写作的边界。

SymPy 的边界在第 10 章和第 11 章已经说清楚:它不会替你假设,它在表达式膨胀时会变慢,它不覆盖三维隐式曲面绘图,它对某些微分方程和积分给出未求值形式。这些不是缺陷,是符号计算的本性。知道边界在哪里,比假装边界不存在更有用。

写作的边界是篇幅和聚焦。本书的 15 章覆盖了 SymPy 在数学工作中的核心用法,但每个主题都有更深的内容没有展开:假设系统的完整谓词表和推理规则、cse 的 order 参数在不同版本中的行为差异、codegen 对复杂表达式的处理、plot_implicit 的自适应采样算法、solveset 的条件集表示。这些内容在 SymPy 的文档和源码中可以找到,本书的任务是让你知道它们存在、知道什么时候需要去查,而不是替你查完。

给读者的三句话

如果这本书的结尾只能留三句话,它们是:

第一句:先问消费者是谁,再选输出方式。 第 8 章的四个工具——init_printing、latex、codegen、lambdify——的区别不在于语法,在于下游是谁。选错了消费者,再正确的符号推导也到不了该到的地方。

第二句:验证路径必须独立于求解路径。 第 13 章的积分用求导验证,第 14 章的解析近似用数值解验证,第 15 章的符号梯度用有限差分验证。如果验证和求解走同一条路径,验证没有提供任何新信息。

第三句:符号计算不替你假设,也不替你判断适用域。 series 在展开点附近形式正确,但小角近似在什么角度失效,是建模判断,不是符号计算能回答的。SymPy 给你精确的骨架,适用域的判断权在你手里。

这本书到这里结束。接下来的路,是你在自己的问题里,决定什么时候用 SymPy、什么时候离开它、什么时候回到它。