递归符号计算 - 提升性能

3

在我的研究中,我试图解决Kolmogorov反向方程,即对以下式子感兴趣: $$Af = b(x)f'(x)+\sigma(x)f''(x)$$

根据特定的b(x)和\sigma(x),我想看到在计算更高的Af幂时,表达式的系数增长速度有多快。我很难从分析上推导出这一点,因此尝试通过经验主义来观察趋势。

首先,我使用了sympy

from sympy import *
import matplotlib.pyplot as plt
import re
import math
import numpy as np
import time
np.set_printoptions(suppress=True)

x = Symbol('x')
b = Function('b')(x)
g = Function('g')(x)

def new_coef(gamma, beta, coef_minus2, coef_minus1, coef):
    return expand(simplify(gamma*coef_minus2 + beta*coef_minus1 + 2*gamma*coef_minus1.diff(x)\
            +beta*coef.diff(x)+gamma*coef.diff(x,2)))
def new_coef_first(gamma, beta, coef):
    return expand(simplify(beta*coef.diff(x)+gamma*coef.diff(x,2)))
def new_coef_second(gamma, beta, coef_minus1, coef):
    return expand(simplify(beta*coef_minus1 + 2*gamma*coef_minus1.diff(x)\
            +beta*coef.diff(x)+gamma*coef.diff(x,2)))
def new_coef_last(gamma, beta, coef_minus2):
    return lambda x: gamma(x)*coef_minus2(x)
def new_coef_last(gamma, beta, coef_minus2):
    return expand(simplify(gamma*coef_minus2 ))
def new_coef_second_to_last(gamma, beta, coef_minus2, coef_minus1):
    return expand(simplify(gamma*coef_minus2 + beta*coef_minus1 + 2*gamma*coef_minus1.diff(x)))

def set_to_zero(expression):
    expression = expression.subs(Derivative(b, x, x, x), 0)
    expression = expression.subs(Derivative(b, x, x), 0)
    expression = expression.subs(Derivative(g, x, x, x, x), 0)
    expression = expression.subs(Derivative(g, x, x, x), 0)
    return expression

def sum_of_coef(expression):
    sum_of_coef = 0
    for i in str(expression).split(' + '):
        if i[0:1] == '(':
            i = i[1:]
        integers = re.findall(r'\b\d+\b', i)
        if len(integers) > 0:
            length_int = len(integers[0])
            if i[0:length_int] == integers[0]:
                sum_of_coef += int(integers[0])
            else:
                sum_of_coef += 1
        else:
            sum_of_coef += 1
    return sum_of_coef
power = 6
charar = np.zeros((power, power*2), dtype=Symbol)
coef_sum_array = np.zeros((power, power*2))
charar[0,0] = b
charar[0,1] = g
coef_sum_array[0,0] = 1
coef_sum_array[0,1] = 1
for i in range(1, power):
    #print(i)
    for j in range(0, (i+1)*2):
        #print(j, ':')
        #start_time = time.time()
        if j == 0:
            charar[i,j] = set_to_zero(new_coef_first(g, b, charar[i-1, j]))
        elif j == 1:
            charar[i,j] = set_to_zero(new_coef_second(g, b, charar[i-1, j-1], charar[i-1, j]))
        elif j == (i+1)*2-2:
            charar[i,j] = set_to_zero(new_coef_second_to_last(g, b, charar[i-1, j-2], charar[i-1, j-1]))
        elif j == (i+1)*2-1:
            charar[i,j] = set_to_zero(new_coef_last(g, b, charar[i-1, j-2]))
        else:
            charar[i,j] = set_to_zero(new_coef(g, b, charar[i-1, j-2], charar[i-1, j-1], charar[i-1, j]))
        #print("--- %s seconds for expression---" % (time.time() - start_time))
        #start_time = time.time()
        coef_sum_array[i,j] = sum_of_coef(charar[i,j])
        #print("--- %s seconds for coeffiecients---" % (time.time() - start_time))
coef_sum_array

然后,研究了自动微分并使用了autograd:

import autograd.numpy as np
from autograd import grad
import time
np.set_printoptions(suppress=True)

b = lambda x: 1 + x
g = lambda x: 1 + x + x**2

def new_coef(gamma, beta, coef_minus2, coef_minus1, coef):
    return lambda x: gamma(x)*coef_minus2(x) + beta(x)*coef_minus1(x) + 2*gamma(x)*grad(coef_minus1)(x)\
            +beta(x)*grad(coef)(x)+gamma(x)*grad(grad(coef))(x)
def new_coef_first(gamma, beta, coef):
    return lambda x: beta(x)*grad(coef)(x)+gamma(x)*grad(grad(coef))(x)
def new_coef_second(gamma, beta, coef_minus1, coef):
    return lambda x: beta(x)*coef_minus1(x) + 2*gamma(x)*grad(coef_minus1)(x)\
            +beta(x)*grad(coef)(x)+gamma(x)*grad(grad(coef))(x)
def new_coef_last(gamma, beta, coef_minus2):
    return lambda x: gamma(x)*coef_minus2(x)
def new_coef_second_to_last(gamma, beta, coef_minus2, coef_minus1):
    return lambda x: gamma(x)*coef_minus2(x) + beta(x)*coef_minus1(x) + 2*gamma(x)*grad(coef_minus1)(x)

power = 6
coef_sum_array = np.zeros((power, power*2))
coef_sum_array[0,0] = b(1.0)
coef_sum_array[0,1] = g(1.0)
charar = [b, g]
for i in range(1, power):
    print(i)
    charar_new = []
    for j in range(0, (i+1)*2):
        if j == 0:
            new_funct = new_coef_first(g, b, charar[j])
        elif j == 1:
            new_funct = new_coef_second(g, b, charar[j-1], charar[j])
        elif j == (i+1)*2-2:
            new_funct = new_coef_second_to_last(g, b, charar[j-2], charar[j-1])
        elif j == (i+1)*2-1:
            new_funct = new_coef_last(g, b, charar[j-2])
        else:
            new_funct = new_coef(g, b, charar[j-2], charar[j-1], charar[j])
        coef_sum_array[i,j] = new_funct(1.0)
        charar_new.append(new_funct)
    charar = charar_new
coef_sum_array

然而,我对它们的速度都不满意。在运行simpy方法3天后,我只得到了30个迭代,而我希望至少能做1000个迭代。

我期望第二种(数值)方法可以进行优化,以避免每次重新计算表达式。不幸的是,我自己没有看出解决方案。此外,我尝试过Maple,但也没有成功。

2个回答

6

概述

因此,这里有两个与导数相关的公式:

  1. Faa di Bruno's formula,它是一种快速找到 f(g(x)) 的第 n 阶导数的方法,看起来很像多项式定理
  2. General Leibniz rule,它是一种快速找到 f(x)*g(x) 的第 n 阶导数的方法,看起来很像二项式定理

这两个公式都在pull request #13892中讨论过,使用了一般莱布尼兹法加速了 n 阶导数的计算。

我正在尝试查看表达式系数的增长速度。
在您的代码中,计算 c [i] [j] 的一般公式如下: c [i] [j] = g * c [i-1] [j-2] + b * c [i-1] [j-1] + 2 * g * c' [i-1] [j-1] + g * c'' [i-1] [j] (其中 c' [i] [j] 和 c'' [i] [j] 是 c [i] [j] 的第一和第二阶导数)。
由于这个原因,根据上面提到的莱布尼兹法则,我认为直觉上计算出的系数应该与Pascal三角形有关(或者至少应该有一些组合关系)。

优化#1

在原始代码中,函数 sum_to_coef(f) 将表达式 f 序列化为字符串,然后丢弃所有看起来不像数字的内容,然后对剩余的数字求和。
我们可以避免序列化,只需遍历表达式树并收集所需内容。
def sum_of_coef(f):
    s = 0
    if f.func == Add:
        for sum_term in f.args:
            res = sum_term if sum_term.is_Number else 1
            if len(sum_term.args) == 0:
                s += res
                continue
            first = sum_term.args[0]
            if first.is_Number == True:
                res = first
            else:
                res = 1
            s += res
    elif f.func == Mul:
        first = f.args[0]
        if first.is_Number == True:
            s = first
        else:
            s = 1
    elif f.func == Pow:
        s = 1
    return s

优化 #2

在函数set_to_zero(expr)中,将b的所有二阶和三阶导数以及g的三阶和四阶导数替换为零。

我们可以将所有这些替换合并为一个语句,如下所示:

b3,b2=b.diff(x,3),b.diff(x,2)
g4,g3=g.diff(x,4),g.diff(x,3)

def set_to_zero(expression):
    expression = expression.subs({b3:0,b2:0,g4:0,g3:0})
    return expression

优化 #3

在原始代码中,对于每个单元格c[i][j],我们都调用了simplify。这实际上对性能有很大影响,但实际上我们可以跳过这个调用,因为幸运的是我们的表达式只是导数或未知函数的积和的。

所以该行可以省略:

charar[i,j] = set_to_zero(expand(simplify(expr)))

成为

charar[i,j] = set_to_zero(expand(expr))

优化 #4

尝试了以下方法,但对性能影响很小。

对于连续的两个j值,我们计算了c'[i-1][j-1]两次。

j-1       c[i-1][j-3] c[i-1][j-2] c[i-1][j-1]
  j                   c[i-1][j-2] c[i-1][j-1] c[i-1][j]

如果您查看else分支中的循环公式,您会发现已经计算了c'[i-1][j-1]。它可以被缓存,但是这个优化对SymPy版本的代码影响不大。
在这里还需要重要提到的是,可以可视化SymPy调用树来计算这些导数。实际上它更大,但这里只展示了其中一部分。

nth derivative call tree

我们还可以使用 py-spy 模块生成火焰图,以查看时间花费在哪里:

flamegraph

据我所知,在 _eval_derivative_n_times 函数中花费的时间占总时间的34%,在 assumptions.py 中的 getit 函数中花费的时间占10%,在 subs(..) 函数中花费的时间占12%,在 expand(..) 函数中花费的时间也占了12%。

优化 #5

显然,当 pull request #13892 被合并到 SymPy 中时,它还引入了性能回归。

在关于该回归的 评论中,Ondrej Certik 建议 使用 SymEngine 来提高大量使用导数的代码的性能。

所以我已经将提到的代码移植到SymEngine.py,发现它在power=8时运行快了98倍(在power=30时快了4320倍)。

可以通过pip3 install --user symengine安装所需的模块。

#!/usr/bin/python3
from symengine import *
import pprint
x=var("x")
b=Function("b")(x)
g=Function("g")(x)

b3,b2=b.diff(x,3),b.diff(x,2)
g4,g3=g.diff(x,4),g.diff(x,3)

def set_to_zero(e):
    e = e.subs({b3:0,b2:0,g4:0,g3:0})
    return e

def sum_of_coef(f):
    s = 0
    if f.func == Add:
        for sum_term in f.args:
            res = 1
            if len(sum_term.args) == 0:
                s += res
                continue
            first = sum_term.args[0]
            if first.is_Number == True:
                res = first
            else:
                res = 1
            s += res
    elif f.func == Mul:
        first = f.args[0]
        if first.is_Number == True:
            s = first
        else:
            s = 1
    elif f.func == Pow:
        s = 1
    return s

def main():
    power = 8
    charar = [[0] * (power*2) for x in range(power)]
    coef_sum_array = [[0] * (power*2) for x in range(power)]
    charar[0][0] = b
    charar[0][1] = g
    init_printing()
    for i in range(1, power):
        jmax = (i+1)*2
        for j in range(0, jmax):
            c2,c1,c0 = charar[i-1][j-2],charar[i-1][j-1],charar[i-1][j]
            #print(c2,c1,c0)
            if   j == 0:
                expr =                                b*c0.diff(x) + g*c0.diff(x,2)
            elif j == 1:
                expr =        b*c1 + 2*g*c1.diff(x) + b*c0.diff(x) + g*c0.diff(x,2)
            elif j == jmax-2:
                expr = g*c2 + b*c1 + 2*g*c1.diff(x)
            elif j == jmax-1:
                expr = g*c2
            else:
                expr = g*c2 + b*c1 + 2*g*c1.diff(x) + b*c0.diff(x) + g*c0.diff(x,2)
            charar[i][j] = set_to_zero(expand(expr))
            coef_sum_array[i][j] = sum_of_coef(charar[i][j])

    pprint.pprint(Matrix(coef_sum_array))

main()

优化后的性能 #5

我认为检查 c[i][j] 中项数的数量以确定表达式增长的速度会非常有趣。这肯定有助于估计当前代码的复杂性。

但出于实际目的,我已经绘制了 SymEngine 代码的当前时间和内存消耗,并得到了以下图表:

performance_modif6

时间和内存似乎都随着输入(原始代码中的power参数)呈多项式增长。

同一张图表,但作为对数-对数图可以在这里查看:

performance_modif6_loglog

就像维基百科页面所说的那样,在对数-对数图上的一条直线对应于一个单项式。这提供了一种恢复单项式指数的方法。

因此,如果我们考虑两个点N = 16和N = 32,在它们之间的对数-对数图看起来像一条直线

import pandas as pd
df=pd.read_csv("modif6_bench.txt", sep=',',header=0)

def find_slope(col1,col2,i1,i2):
    xData = df[col1].to_numpy()
    yData = df[col2].to_numpy()
    
    x0,x1 = xData[i1],xData[i2]
    y0,y1 = yData[i1],yData[i2]
    
    m = log(y1/y0)/log(x1/x0)
    return m

print("time slope = {0:0.2f}".format(find_slope("N","time",16,32)))
print("memory slope = {0:0.2f}".format(find_slope("N","memory",16,32)))

输出:

time slope = 5.69
memory slope = 2.62

非常粗略地估算时间复杂度将会是O(n^5.69),而空间复杂度的近似值将会是O(2^2.62)
关于如何确定增长速率是多项式还是指数级别,这里有更多细节(需要绘制半对数和双对数图表,并查看数据在哪里呈现为直线)。

使用定义的bg函数的性能

在第一个原始代码块中,函数bg是未定义的函数。这意味着SymPy和SymEngine对它们一无所知。
第二个原始代码块定义了 b=1+xg=1+x+x**2。如果我们再次运行已知 bg 的所有内容,代码运行速度会更快,并且运行时间曲线和内存使用曲线比未知函数更好。

enter image description here

enter image description here

time slope = 2.95
memory slope = 1.35

已知增长率的记录数据拟合

我想更深入地研究匹配观察到的资源消耗(时间和内存),因此我编写了以下Python模块,将每个增长率(来自常见增长率目录)与记录的数据进行拟合,然后向用户显示图表。

可以通过pip3 install --user matchgrowth进行安装。

像这样运行:

match-growth.py --infile ./tests/modif7_bench.txt --outfile time.png --col1 N --col2 time --top 1

它生成资源使用情况的图表,以及最接近的增长率匹配情况。在这种情况下,它发现多项式增长是最接近的:

enter image description here

其他注意事项

如果您在symengine代码中运行此程序以获取power=8,则系数将如下所示:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 5, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 17, 40, 31, 9, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 53, 292, 487, 330, 106, 16, 1, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 161, 1912, 6091, 7677, 4693, 1520, 270, 25, 1, 0, 0, 0, 0, 0, 0]
[1, 485, 11956, 68719, 147522, 150706, 83088, 26573, 5075, 575, 36, 1, 0, 0, 0, 0]
[1, 1457, 73192, 735499, 2568381, 4118677, 3528928, 1772038, 550620, 108948, 13776, 1085, 49, 1, 0, 0]
[1, 4373, 443524, 7649215, 42276402, 102638002, 130209104, 96143469, 44255170, 13270378, 2658264, 358890, 32340, 1876, 64, 1]

所以事实证明,第二列与 A048473 相符合,根据 OEIS 的描述是“在 Sierpiński 三角形中进行 n 次刻度后,所有大小的三角形(包括空洞)的数量”。这里也可以在this repo中找到所有代码。

1
把OEIS带入讨论中总是一个不错的点子,我会给你加上一个+1。如果可以的话,我还想再给你一个+1,以表彰你在编程方面的杰出努力,但遗憾的是,我只有一个赞。只能说继续保持好工作! - Robert Dodier
1
非常感谢您关注这个问题,@wsdookadr :) 您认为在这个特定问题中应该选择符号计算还是数值计算呢? - Gabrielė Mongirdaitė
@GabrielėMongirdaitė 我在上面添加了一些关于性能的注释。我还使用了你最初的 b=1+xg=1+x+x**2 来观察运行时间和内存使用情况。通常情况下,当使用符号方法太昂贵时(并且数值方法存在精度惩罚)才会使用数值方法。但是,如果您定义了函数 bg,则运行时间并不那么糟糕(请参见上文)。我认为这也很大程度上取决于您的实际 bg 函数是什么。它们实际上是 1+x1+x+x**2 还是其他东西? - wsdookadr
@GabrielėMongirdaitė,我能问一下上面的斜率计算是否正确吗?(对于某些“正确”的定义)。这通常是实践中的做法吗?(我对这部分还很新,所以才问)谢谢! - wsdookadr
@wsdookadr,总体的想法是检查系数之和除以各自的阶乘是否开始收敛到0。例如,使用特定的bg函数的前150个,我们仍然看到增长趋势。回答您的问题,目前仅需要处理1+x1+x+x ** 2。而且在我看来,斜率计算是正确的 :) - Gabrielė Mongirdaitė
对于各自阶乘的除法,我想到了values.append(sum(coef_sum_array[i]))values.insert(0, 2) answer = [] for i in range(1, power): answer.append(values[i - 1] / math.factorial(i)) plt.scatter(range(1, power), answer, marker='.', color='black') plt.show()` - Gabrielė Mongirdaitė

1

第i行多项式系数与第(i-1)行系数之间的关系

在上一篇文章中,已经计算出了c[i][j]。可以验证deg(c[i][j])=j+1

这可以通过初始化一个单独的二维数组,并像下面这样计算度数来检查:

deg[i][j] = degree(poly(parse_expr(str(charar[i][j]))))

垂直公式:

然后,如果我们用u(i,j,k)表示c[i][j]x^k的系数,我们可以尝试找到u(i,j,k)相对于u(i-1,_,_)的公式。对于u(i,j,_)的公式将与u(i+1,j,_)(以及所有后续行)的公式相同,因此在这里有一些缓存机会。

水平公式:

有趣的是,当我们固定 i 时,发现 u(i,j,_) 的公式与 u(i,j+1,_) 的公式看起来相同,除了最后三个 k 值。但我不确定这是否可以利用。

上述提到的缓存步骤可能有助于跳过不必要的计算。

在此处查看更多相关信息

关于解析、闭式解和渐近线性的一些注释

我正在努力从理论上推导这个问题

是的,这似乎很难。与此相关的递归序列中最接近的类别被称为Holonomic sequences(也称为D-finite或P-recursive)。序列c[i][j]不是C-finite,因为它具有多项式系数(在一般情况下,带有多项式系数的递推关系的渐近性质甚至是一个未解决的问题)。

然而,c[i][j]的递推关系由于导数的存在而不符合这一点。如果我们在c[i][j]的公式中省略导数,则它将符合Holonomic序列的要求。以下是我找到的一些解决方案:

  1. "The Concrete Tetrahedron: Symbolic Sums, Recurrence Equations, Generating Functions, Asymptotic Estimates" by Kauers and Paule - 第7章:Holonomic Sequences 和 Power Series
  2. Analytic Combinatorics by Flajolet and Sedgewick - 附录B.4:Holonomic Functions

但是,c[i][j] 是一个多变量递归,这就是它不符合上述理论的另一个原因。

然而,还有一本书叫做Analytic Combinatorics in Several Variables by Robin Pemantle and Mark C. Wilson,它可以处理多变量递归。

所有上述提到的书都需要大量复杂分析,并且远远超出了我目前所知道的数学范畴,因此希望一些对这种数学有更扎实理解的人可以尝试一下。

最先进的CAS,它具有生成函数相关操作,并且可以操作这种类型的序列是 Maplegfun package gfun repo(目前仅处理单变量情况)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接