Symbolica 2.0:适用于 Python 和 Rust 的可编程符号系统
阅读原文· symbolica.ioSymbolica 2.0 发布,这是一款可编程符号系统,支持 Python 和 Rust 语言。该版本在 Hacker News 上获得 100 点热度。
引言
Symbolica 是一个面向 Python 和 Rust 的高性能符号计算框架。你可以用它来操作符号表达式,并将其转化为用于计算雅可比矩阵、数值优化、积分等任务的快速数值内核。
今天迎来 Symbolica 2.0 版本发布,带来了许多令人兴奋的新功能和改进。本次发布的主题是“可编程符号”:Symbolica 的更多行为现在可以由用户自定义。这使得用户可以定义像内置对象一样能进行化简、求导、展开、打印和计算求值的数学对象。
自 1.0 版本以来,Symbolica 在多个方向上进行了改进:
- 更简单的 Rust API,增加了更多运算符重载和构建器风格的 API
- 符号注册系统,支持命名空间、别名、标签、用户数据和自定义钩子
- 重新设计的求值器接口,支持双精度浮点运算和 JIT 编译
- 更丰富的笔记本和文档输出,包括 HTML 输出、图形与多项式显示、Typst 输出、彩色打印,以及更结构化的多行格式化
- 新增内置数学函数,包括伽马函数、多对数函数、贝塞尔函数、黎曼 ζ 函数,以及相关的级数/求值钩子
请参阅迁移指南了解这些变化以及如何更新你的代码。
更好的输出
Symbolica 增加了自动换行输出模式,并使用彩色括号,类似于代码的样式。这使得阅读大型嵌套表达式更加容易。在 Jupyter 或 Marimo 等笔记本中,默认输出为彩色 HTML 模式,你可以轻松切换到 LaTeX 模式。现在也支持 Typst 输出。

改进的 Rust API
最明显的变化之一是普通 Rust 程序需要的导入更少,冗长的类型路径也更少。新的预导入模块汇集了大多数用户需要的常用 trait、宏、域和求值器类型。Rust 的人机工程学也得到了改进,增加了额外的重载、自动类型转换、构建器模式,以及符号上的 call 方法:
- Symbolica 2.0
- Symbolica 1.0
use symbolica::prelude::*;
fn main() {
let (x, y, f) = symbol!("x", "y", "f");
let e: Atom = 2 + (x + 1).pow(-2) + f.call((1 + y, y)) / 3;
let s = e.series(x, 0, 1).unwrap();
println!("{e}"); // -> 2+1/3*f(1+y,y)+1/(1+x)^2
println!("{s}"); // -> 3+1/3*f(1+y,y)-2*x+𝒪(x^2)
}use symbolica::{
atom::{Atom, AtomCore},
function, symbol,
};
fn main() {
let (x, y, f) = symbol!("x", "y", "f");
let e = Atom::num(2) + (Atom::var(x) + 1).npow(-2) + function!(f, Atom::num(1) + y, y) / 3;
let s = e.series(x, Atom::num(0), 1.into(), true).unwrap();
println!("{e}"); // -> 2+1/3*f(1+y,y)+1/(1+x)^2
println!("{s}"); // -> 3+1/3*f(1+y,y)-2*x+𝒪(x^2)
}用于设置和包含多个参数的函数的结构现在采用构建器模式。例如,高性能数值评估器的构造现在看起来像这样:
- Symbolica 2.0
- Symbolica 1.0
use symbolica::prelude::*;
fn main() -> Result<(), EvaluationError> {
let mut evaluator = parse!("x^2 + 2*x + 1 + f(x)")
.evaluator(&[parse!("x")])
.add_function(symbol!("f"), vec![symbol!("y")], parse!("cos(y + 1)"))?
.horner_iterations(2)
.build()?
.map_coeff(&|c| c.re.to_f64());
println!("{}", evaluator.evaluate_single(&[3.0]));
Ok(())
}use symbolica::{atom::AtomCore, evaluate::{FunctionMap, OptimizationSettings}, parse, symbol};
fn main() {
let mut fn_map = FunctionMap::new();
fn_map
.add_function(
symbol!("f"),
"f".to_string(),
vec![symbol!("y")],
parse!("cos(y + 1)"),
)
.unwrap();
let params = vec![parse!("x")];
let optimization_settings = OptimizationSettings {
horner_iterations: 2,
..OptimizationSettings::default()
};
let mut evaluator = parse!("x^2 + 2*x + 1 + f(x)")
.evaluator(&fn_map, ¶ms, optimization_settings)
.unwrap()
.map_coeff(&|c| c.re.to_f64());
println!("{}", evaluator.evaluate_single(&[3.0]));
}借助构建器模式,原本属于不同子结构(例如 `add_function` 属于 `FunctionMap`,`horner_iterations` 属于 `OptimizationSettings`)的设置参数现在可以更流畅地一起设置。另外请注意,可能失败的操作现在会返回专门的错误类型。
可编程符号
在 Symbolica 1.0 中,符号已经可以携带重要的代数属性:
- Python
- Rust
from symbolica import *
x, y = S("x", "y")
dot = S("dot", is_symmetric=True, is_linear=True)
print(dot(3*x + y, x + 2*y)) # -> 3*dot(x,x)+7*dot(x,y)+2*dot(y,y)use symbolica::prelude::*;
fn main() {
let dot = symbol!("dot"; Symmetric, Linear);
println!("{}", parse!("dot(3*x+y, x+2*y)")); // -> 3*dot(x,x)+7*dot(x,y)+2*dot(y,y)
}在 2.0 中,符号现在可以安装更多钩子,这些钩子在代数生命周期的特定点运行:
| 钩子 | 使用场景 |
|---|---|
| 规范化 | 在符号或函数被规范化时重写它。 |
| 打印 | 重写 Symbolica、LaTeX 或自定义模式的输出。 |
| 导数 | 定义自定义求导规则。 |
| 级数 | 教会 Symbolica 函数在奇点附近的行为。 |
| 求值 | 为评估器注册数值实现。 |
例如,让我们定义一个 gamma 函数。由于 gamma 在 0 处有一个极点,我们无法在 0 附近进行泰勒展开。利用级数钩子,我们可以告诉 Symbolica 如何通过恒等式 \(\Gamma(a + 1) = a \Gamma(a)\) 在该点附近对该函数进行正则化:
- Python
- Rust
from typing import Sequence
from symbolica import *
x = S("x")
def regularize_gamma(args: Sequence[Series]) -> tuple[Expression, Expression] | None:
if args[0].get_coefficient(0) == 0:
a = args[0].to_expression()
return (1 / a, S("gamma")(a + 1))
gamma = S(
"gamma",
derivative=lambda t, _: t * E("digamma")(t[0]),
series=regularize_gamma,
)
print(gamma(x).series(x, 0, 0)) # gamma(1)*x^-1+gamma(1)*digamma(1)+𝒪(x^1)use symbolica::prelude::*;
fn main() {
let _gamma = symbol!(
"gamma",
der = |f, index, out| {
if index == 0 {
let arg = f.as_fun_view().unwrap().get(0);
**out = symbol!("gamma").call(arg) * symbol!("digamma").call(arg);
}
},
series = |args| {
if args[0].coefficient(0.into()) == Atom::Zero {
let a = args[0].to_atom();
Some((1 / &a, symbol!("gamma").call(a + 1)))
} else {
None
}
}
);
let x = symbol!("x");
println!("{}", parse!("gamma(x)").series(x, 0, 0).unwrap());
}在这个例子中,内置的 gamma 函数进一步化简为 1/x-γ。
评估器:从表达式树到编译内核
表达式求值是自 1.0 以来工程改动最大的部分。
高层流程仍然相同:Symbolica 将一个表达式重写为一个小指令程序,对其进行优化,然后针对不同的数值输入多次执行评估。关于其工作原理的详细信息,请参见这篇博客文章。
符号的评估器
让我们定义一个自定义符号 cosh,并教会 Symbolica 如何针对不同的数值域对其进行求值。我们可以通过注册一个求值钩子来实现:
- Python
- Rust
import cmath
import math
from symbolica import *
x = S("x")
cosh = S(
"cosh",
eval={
"float": lambda args: math.cosh(args[0]),
"complex": lambda args: cmath.cosh(args[0]),
"cpp": "template<typename T> T python_cosh2(T a) { return std::cosh(a); }",
},
)
expr = cosh(1/2) + x
expr.to_float() # -> 1.1276259652063807 + xuse symbolica::prelude::*;
fn main() {
let cosh = symbol!(
"cosh",
eval = EvaluationInfo::new()
.register(|args: &[f64]| args[0].cosh())
.register(|args: &[Complex<f64>]| args[0].cosh())
);
let x = symbol!("x");
let expr = cosh.call(Atom::num(1) / 2) + x;
println!("{}", expr.to_float(16)); // -> 1.127625965206381+x
}由于已知如何求值 cosh,Symbolica 在调用 `to_float` 时会查找合适的评估器。
JIT 编译
除了生成自定义的 ASM、C++ 和 CUDA 代码外,Symbolica 现在还可以即时编译求值器。这使用了 Shahriar Iravanian 开发的 symjit crate,我们与他紧密合作,使集成过程对 Symbolica 而言显得原生自然。
JIT 路径支持自定义求值器钩子,现在已成为 Python 的默认求值后端。在实践中,它的性能与自定义 ASM 后端相当,同时将编译时间控制在合理范围内。
双精度浮点算术
Symbolica 2.0 新增了双精度浮点求值路径。双精度浮点算术将数字存储为两个 f64 的未求和结果,提供大约 106 位精度(相当于 31 位十进制数字,普通双精度为 16 位),同时速度比任意精度 Float 算术快 3 倍以上。在 Python 中,`evaluate_with_prec(..., 32)` 会自动采用此路径。
特殊函数
新的钩子系统带来的另一个重大进展是,Symbolica 拥有了更丰富的数学词汇表。Symbolica 现在支持多伽马函数、多重对数函数、贝塞尔函数、黎曼ζ函数、几何函数及其反函数,并提供了求值钩子以及围绕极点的 Laurent/Puiseux 级数行为。
某些特殊值会立即归一化:
from symbolica import *
print(E("gamma(1/2)")) # 𝜋^(1/2)
print(E("polylog(-3, z)")) # z*(1+4*z+z^2)/(1-z)^4所有特殊函数都可进行求值:
g = E("gamma(5/6)")
print(g) # gamma(5/6)
print(g.to_float()) # 1.128787029908126Symbolica 可以在优化的求值器内部使用特殊函数:
from symbolica import *
import numpy as np
x = S('x')
e = E('pi + zeta(3) + gamma(x)')
ev = e.evaluator([x])
print(e) # gamma(x)+zeta(3)+𝜋
print(ev.evaluate(np.array([[2.0]]))) # [[5.34364956]]在导出代码时,诸如 \(\pi\) 和 \(\zeta(3)\) 等常量会被替换为数值,因此生成的代码无需依赖特殊函数库。
更快的操作
并非所有重要变化都体现在公共 API 中。表达式操作底层机制也获得了显著改进:
- 模式匹配可以证明结构不匹配并提前终止
- 项排序现在可以基于字节切片比较进行(归一化速度提升 30%)
- Horner 方案可应用于线性表达式结构(内存减少超过 2 倍)
- 公共对消除使用的内存大幅减少
- 多项式 GCD 计算得到改进,包括一种新的模块化算法
这些合并的改进使实际用例的性能提升了 2 到 10000 倍。对于这些改进,用户反馈是无价的。如果您有计算缓慢的情况,请与我们分享,以便我们让 Symbolica 变得更快。
技术深度解析:类型擦除的回调评估
本节面向技术好奇者。它解释了 Symbolica 的评估钩子如何在不牺牲易用性或性能的情况下支持多个数值域。
评估钩子 API 有一个有趣的内部问题:单个符号可能有多个数值实现,每个实现具有不同的 Rust 类型,该类型实现了一个特定 trait,其成员函数形式为 fn(&[T]) -> T,其中 T 是某个数值域。
例如,一个函数可能针对以下情况有不同的快速实现:
|args: &[f64]| -> f64
|args: &[Complex<f64>]| -> Complex<f64>
|args: &[Float]| -> Float
|args: &[Complex<Float>]| -> Complex<Float>这些函数类型不能全部直接存储在一个同构字段¹中。Symbolica 通过注册时对回调进行类型擦除,并在构建数值域求值器时恢复具体类型来解决此问题。
¹ 所有可能变体的枚举不可行,因为用户可以定义自己的类型 T 并为其注册回调。
核心形状是:
pub trait ExternalFunction<T>: Fn(&[T]) -> T + Send + Sync + DynClone {}
pub type EvalFn<T> = Box<dyn ExternalFunction<T>>;
pub struct EvaluationInfo {
eval_fns: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}当用户编写:
use symbolica::prelude::*;
let eval = EvaluationInfo::new()
.register(|args: &[f64]| 2.0 * args[0])
.register(|args: &[Complex<f64>]| args[0] + args[0]);每个回调都存储在 TypeId::of::<T>() 下,其中 T 是由回调参数切片携带的数值域。实际回调被装箱为 Any,从而从外部容器中移除具体类型。
pub fn register<T: 'static>(mut self, f: impl ExternalFunction<T> + 'static) -> Self {
let f: EvalFn<T> = Box::new(f);
self.eval_fns.insert(
TypeId::of::<T>(),
Box::new(f) as Box<dyn Any + Send + Sync>,
);
self
}稍后,当求值器专门用于某个域时,Symbolica 请求该确切类型的实现:
impl EvaluationInfo {
pub fn get_evaluator<T: 'static>(&self) -> Option<EvalFn<T>> {
let e = self.eval_fns.get(&TypeId::of::<T>())?;
let f = e.downcast_ref::<EvalFn<T>>()?;
Some(f.clone())
}
}重要设计点是:类型擦除不在求值器的热循环中。它位于注册和专门化路径上。一旦为 f64、Complex<f64>、Float 或其他域构建了求值器,它就在指令流中存储具体回调,或将具体调用交给 JIT 后端。
选择评估域
求值器故事的另一个方面是 Symbolica 一开始如何选择数值类型。在内部,这通过 EvaluationDomain trait 表达。简化形式下,该 trait 如下所示:
pub trait EvaluationDomain: Sized + 'static {
fn get_evaluator(info: &EvaluationInfo) -> Option<Box<dyn ExternalFunction<Self>>> {
info.get_evaluator()
}
}因此,对于类型 T,默认实现会在 EvaluationInfo 中查找为 T 注册的回调函数(如果存在)。然而,有时某个领域可以提供有用的回退方案。例如,SIMD 领域可以在未注册原生 SIMD 回调时对标量 f64 回调进行向量化,而 double-float 计算则可以回退到任意精度实现,然后再将结果转换回 double-float。
impl EvaluationDomain for f64 {}
impl EvaluationDomain for wide::f64x4 {
fn get_evaluator(
info: &EvaluationInfo,
) -> Option<Box<dyn ExternalFunction<Self>>> {
if let Some(f) = info.get_evaluator::<wide::f64x4>() {
return Some(f);
}
// create a vectorized version of the scalar function if it exists
if let Some(f) = f64::get_evaluator(info) {
Some(Box::new(move |args| {
let mut buffer = vec![0.; args.len()];
let mut res = [0.; 4];
for i in 0..4 {
for (b, v) in buffer.iter_mut().zip(args) {
*b = v.as_array()[i];
}
res[i] = f(&buffer);
}
res.into()
}))
} else {
None
}
}
}AI 的使用
AI 是软件开发领域中不言自明的大问题,因此有必要说明它是如何融入 Symbolica 2.0 的开发过程的。Symbolica 在超过两年的时间里基本没有借助 AI 辅助进行开发,这一过程涉及大量规划、优化工作以及设计权衡。
最初,我对 AI 在这样一个高性能项目中的实用性持怀疑态度。然而,如果一个工具能比我更快地完成我所能做的事情,那么忽视它就显得愚蠢了。因此,我进行了一些独立的实验:将小型项目从 Mathematica 移植到 Symbolica,进行网站和文档工作,并尝试了更严肃的实现和研究任务。
其中一个实验是让 Codex 5.5 根据一篇研究论文实现 GCD 算法,并以已有的代码片段作为上下文。结果很有趣:它识别出了论文中的关键点,并正确实现了大部分算法,尽管有时会使用无意义的辅助函数。然而,当遇到 bug 时,它有时会得出错误结论,并开始修补越来越具体的情况,而不是识别根本原因——即测试输入不满足某个假设,并触发了无限循环^2。最终,编写关键代码和调试困难案例仍然是一个需要人工完成的过程。
^2 当它无法解决问题时,会添加一个回退例程到另一个 GCD 算法,而不是解决实际问题!
使用 AI 带来的最大收益体现在大规模重构和外围任务上,例如网站重新设计以及检查网站上的示例是否仍然有效。这极大地节省了时间。
即将推出的功能
一项即将推出的新功能将使 GMP 后端在任意精度算术中变为可选(改用 malachite 和 astro-float),这将以牺牲性能为代价,实现支持 WASM 的 Symbolica 构建(并简化 Windows 上的编译)。更多详情敬请期待!
Symbolica 对爱好者免费,单核实例对非商业用途也免费。如果你想要新功能、更完善的文档或针对你工作流的支持,你的组织可以购买许可证并直接支持该项目。
结语
Symbolica 2.0 使符号系统更具可编程性:用户可教会符号如何进行归一化、打印、求导、在奇点处展开以及数值求值。
欢迎在评论区分享你的看法!