如何实现 exp 函数

\newcommand{\d}{\mathrm{d}}

说到解析方法数质数,由于精度原因,如果要计算 π(1018)\pi(10^{18}) 的话,64 位浮点数已经不能满足要求了,所以我不得不实现自己的高精度。当然可以选用现成的库,例如著名的 MPFR,但是我觉得这样 overhead 太大了,毕竟我只需要 100 bit 就行了。

既然要实现浮点数,自然得实现一堆函数,其中有四个函数尤为重要:exp,log,sin,cos\exp, \log, \sin, \cos。当然,在实现之前,我们需要参考下别人的实现,例如 libm 。我们这里就拿 exp\exp 来举例,并假设我们现在已经支持基本的加减乘除了。

第一步应该是判断数的 regularity。由于只给自己用,所以我就 assume 不会出现 NaN、Inf 这种东西,这些判断也就都省了。

第二步是 normalize 输入:对于输入 xx,我们总能找到一个 kkrr,使得

e^x = 2^k e^r, \quad k \in \mathbb{N}, |r| \leq r_\max= \frac{\ln 2}{2}.

这里 2k2^k 是很好表示出来的,剩下的就是如何计算 ere^r 了。接下来我们就来讨论一下如何求 e^{r_\max}

Truncated Taylor series

既然 r|r| 不大,我们就直接在 r=0r = 0 出做 Taylor expansion 怎么样?

er=1+r+12!r2+13!r3+o(r3)e^r = 1 + r + \frac{1}{2!} r^2 + \frac{1}{3!} r^3 + o(r^3)

我们就取前四项,看看效果如何。于是我先在 Sage 里面初始化一下一些变量:

prec = 100
deg = 3
r = var('r')
F = RealField(prec)["r"]
r_max = F(log(2)) / 2

def plot_diff(expr, label):
    p = plot(exp(r) - expr, -r_max, r_max, legend_label=f"$e^r - {label}$")
    p.set_legend_options(font_size=14, shadow=False, loc="lower right")
    return p

然后再来画一下取前四项时的误差:

poly_taylor = exp(r).series(r, deg + 1).truncate()
print(F(poly_taylor))

plot_diff(poly_taylor, r"\operatorname{Taylor}(r)")

结果如下:最大误差在 r = r_\max 时取得,约为 6.45×1046.45 \times 10^{-4}

hmmm,看上去还凑合?

Chebyshev interpoloation

如你所愿!

我们先定义(第一类) Chebyshev 多项式如下:

T0(x)=1,T1(x)=x,Tn+1(x)=2x2Tn(x)Tn1(x).\begin{aligned} T_0(x) & = 1, \\ T_1(x) & = x, \\ T_{n+1}(x) & = 2x^2 T_n(x) - T_{n-1}(x). \end{aligned}

另外一种理解方式是,把 cosnθ\cos n\theta 看成是 cosθ\cos \theta 的一个多项式,则

Tn(cosθ)=cosnθ.T_n(\cos \theta) = \cos n\theta.

从这个定义可以直接得到:Tn(x)T_n(x) 的第 1kn1 \leq k \leq n 个根为 xi=k12nπx_i = \frac{k - \frac{1}{2}}{n} \pi。我们考虑令 n=4n = 4,然后用 {(xi,exp(xi))}i[n]\{(x_i, \exp(x_i))\}_{i \in [n]} 插值插出一个 3 次多项式出来:

cheby_nodes_a = [cos((k + 1/2) / (deg + 1) * pi) for k in range(deg + 1)]
values = [exp(r * r_max) for r in cheby_nodes_a]

poly_cheby_interp_a = F.lagrange_polynomial(list(zip(cheby_nodes_a, values)))
print(poly_cheby_interp_a)

plot_diff(poly_cheby_interp_a(r=r/r_max), r"\operatorname{ChebyInterpA}(r)")

结果如下:在 r = r_\max 时误差最大,为 8.10×1058.10 \times 10^{-5}

这个多项式的系数和 Taylor expansion 的系数有较大不同,但是误差却小很多!这说明了 Taylor expansion 得到的多项式不一定是最优的!

现在问题来了:Chebyshev interpolation 到底魔改了些啥,使得它能比 Taylor expansion 给出的多项式更优?从图上我们可以看出来:Taylor expansion 的误差集中在两端,而 Chebyshev interpolation 的误差则分布较为均匀,把 r_\max 对应的误差减小了,再把某些 rr 上的误差增大了,但是由于这些点上原来的误差很小,增大了也没啥大事,相当于把误差从原来的两极分化均摊到每个 rr 上了。

有人可能就要问了:为啥我们要选 Chebyshev 多项式的根作为 xx 来 interpolate 呢?这就不得不提到著名的 Runge’s Phenomenon 了,简单说来就是如果选的 xx 不好的话(例如 [1,1][-1, 1] 内均匀选点),那么有可能选的点越多拟合越差。这个链接稍微揭示了为什么 Chebyshev 多项式的根就不会出现这个问题。当然这里除了 Chebyshev 多项式的根外,还可以考虑 Legendre polynomial 的根。这两类多项式在数值计算中用的挺多的,特别是数值积分。最流行的两种数值积分方式,Newton-Legendre 方法和 Clenshaw-Curtis 方法,就分别基于 Legendre polynomial 和 Chebyshev polynomial。

另外我这里用的是 ChebyInterpA,第一类 Chebyshev nodes 也就是第一类 Chebyshev 多项式的根。事实上还有第二类 Chebyshev 多项式,对应的有第二类 Chebyshev nodes,分别为 xk=cosknπx_k = \cos \frac{k}{n} \pi ,其中 0kn0 \leq k \leq n。我试了一下,效果没有第一类 Chebyshev 多项式的好……

cheby_nodes_b = [cos(k / deg * pi) for k in range(deg + 1)]
values = [exp(r * r_max) for r in cheby_nodes_b]

poly_cheby_interp_b = F.lagrange_polynomial(list(zip(cheby_nodes_b, values)))
print(poly_cheby_interp_b)

plot_diff(poly_cheby_interp_b(r=r/r_max), r"\operatorname{ChebyInterpB}(r)")

结果如下:很显然,由于在 -1 和 1 处选了点,所以这两个点的误差均为 0,但是 0 处的误差就大很多了……

Truncated Chebyshev series

如你所愿!

刚才的两种方法都是基于 Chebyshev 多项式的。事实上,Chebyshev 多项式在 w(x)=11x2w(x) = \frac{1}{\sqrt{1 - x^2}} 下是正交的:

11Tn(x)Tm(x)w(x)dx={0,nm,π,n=m=0,π2,n=m0.\int_{-1}^{1} T_n(x) T_m(x) w(x) \d x = \begin{cases} 0, & n \neq m, \\ \pi, & n = m = 0, \\ \frac{\pi}{2}, & n = m \neq 0. \\ \end{cases}

于是我们可以把 Tn(x)T_n(x) 当成是一组基来表达函数 f(x)f(x)

f(x)=a02+k=1akTk(x),ak:=2π11f(x)Tk(x)w(x)dx.f(x) = \frac{a_0}{2} + \sum_{k=1}^\infty a_k T_k(x), \quad a_k := \frac{2}{\pi} \int_{-1}^1 f(x) T_k(x) w(x) \d x.

但是求 aka_k 似乎需要积分……但是没问题,我们变量代换 x=cosθx = \cos \theta 后用均匀撒点即可:

ak=2π11f(x)Tk(x)w(x)dx=2π0πf(cosθ)coskθdθ2Nj=1Nf(j12Nπ)cos(k(j12)Nπ).a_k = \frac{2}{\pi}\int_{-1}^1 f(x) T_k(x) w(x) \d x = \frac{2}{\pi}\int_0^\pi f(\cos \theta) \cos k \theta \d \theta \approx \frac{2}{N} \sum_{j=1}^N f\left( \frac{j - \frac{1}{2}}{N} \pi \right) \cos\left( \frac{k (j - \frac{1}{2})}{N} \pi \right).

只要 NN 足够大,aka_k 就能足够好。实际上,随着 NN 的增大,aka_k 收敛地相当快,几乎是指数级别的。N=100N = 100 时就能得到 a2a_2 的 100 位有效数字。

好了,你现在已经学会了 Chebyshev series 了,让我们来跑一下吧!

d = 50

poly_cheby_series = 0

for j in range(deg+1):
    c = 0
    for k in range(d):
        t = exp(cos(pi * (k + 1/2) / d) * r_max) * cos(pi * j * (k + 1/2) / d)
        c += F(t)
    c = c * 2 / d
    if j == 0:
        poly_cheby_series += c / 2
    else:
        poly_cheby_series += c * chebyshev_T(j, r)

print(poly_cheby_series)

plot_diff(poly_cheby_series(r=r/r_max), r"\operatorname{ChebySeries}(r)")

结果如下:

Q:咦,这和之前的图不是一模一样吗……

A:不会吧不会吧,不会真的有人看不出这个多项式在 r = r_\max 的时候误差仅有 7.83×1057.83 \times 10^{-5} 吧?(手动狗头)

Minimax approximation 和 Remez algorithm

如你所愿!

这次我们一步到位,直接找出最优的多项式!首先我们祭出以下定理:

[Theorem 10.1, ATAP, informal] 若一个函数 f:[1,1]Rf: [-1, 1] \to \mathbb{R} 连续,则其最优 nn 次多项式逼近 pn:=argminp:deg(p)nfpp_n^* := \arg\min_{p: \text{deg}(p)\leq n} \|f - p\|_\infty 唯一,且 fpnf - p^*_n 有至少 n+2n+2 个极值点,这些极值点的值的绝对值相等

代码我就不放了,因为是用 Julia 写的 Remez 算法……总之我们跑出来结果如下:

从这张图中我们也在来理解一下刚才的定理:多项式次数为 3,则 g(r)=erRemez(r)g(r) = e^r - \text{Remez}(r) 有 5 个极值点,分别为 x_1 \approx -r_\max, x_2 \approx -0.24, x_3 \approx 0, x_4 \approx 0.24, x_5 \approx r_\max,且 g(x1)=g(x2)=g(x3)=g(x4)=g(x5)g(x_1) = -g(x_2) = g(x_3) = -g(x_4) = g(x_5)。之前用 Chebyshev 多项式搞出的多项式已经接近于这个性质。

虽然 Remez 算法给出来的结果很接近 truncated Chebyshev series,但是从系数上来说它更接近于 truncated Taylor series 的结果。

自然,一个很有趣的问题就是:用 Chebyshev polynomial 得到的多项式有多好?这里给出一个定理:

[Theorem 16.1, ATAP, informal] 函数 f(x)f(x)[1,1][-1, 1] 上连续。令 fn(x)f_n(x)ffnn 次 truncated Chebyshev series,pn(x)p_n(x)ffnn 次 Chebyshev interpolant,则有:

fpn(2+2πlog(n+1))fpn,ffn(4+4π2log(n+1))fpn,\begin{aligned} \|f - p_n\|_\infty & \leq \left( 2 + \frac{2}{\pi} \log (n+1) \right) \|f - p^*_n\|_\infty, \\ \|f - f_n\|_\infty & \leq \left( 4 + \frac{4}{\pi^2} \log (n+1) \right) \|f - p^*_n\|_\infty, \end{aligned}

也就是说,Chebyshev interpolation 和 truncated Chebyshev series 给出的结果几乎是最优的了……

变量代换

如你所愿!

刚才瞎扯扯了好久,改进都不大,这次我们整点有用的!

不知道是否还有人记得我们最开始的目标:我们是想求 ere^r 来着。可是,我们真的想直接求 ere^r 吗?

我们先求 u=rer+1er1u = r \frac{e^r + 1}{e^r - 1},有了 uu 后可以由 er=u+rure^r = \frac{u + r}{u - r} 来求出 ere^r。求 uu 有什么好处呢?注意到 uu 是一个偶函数,所以对 uu 做 Taylor expansion 后,就只有偶数项了:

u=rer+1er1=2+r26r4360+r615120+o(r6).u = r \frac{e^r + 1}{e^r - 1} = 2 + \frac{r^2}{6} - \frac{r^4}{360} + \frac{r^6}{15120} + o(r^6).

虽然这个多项式次数比之前高,但是运算量却只大了一点点,只多了算出 r2r^2 的一步。让我们看看这个算法表现如何!

def u(x):
    return x * (exp(x) + 1) / (exp(x) - 1) if x != 0 else 2

poly_taylor2 = (r * (exp(r) + 1) / (exp(r) - 1)).series(r, 2 * (deg + 1)).truncate()
print(poly_taylor2)

plot_diff((poly_taylor2(r) + r) / (poly_taylor2(r) - r),
          r"\frac{\operatorname{Taylor2}(r) + r}{\operatorname{Taylor2}(r) - r}")

结果如下:

AAAmazing!手动微笑。之前 Chebyshev 多项式什么的都是渣渣……

变量代换 + Chebyshev interpolation

如你所愿!

虽然刚才喷了一发 Chebyshev 多项式,但是毕竟是另一个优化方向。我图方便就只试了 Chebyshev interpolation 了,代码如下:

cheby_nodes_a = [cos((k + 1/2) / (2 * deg + 1) * pi) for k in range(2 * deg + 1)]
values = [u(r * r_max) for r in cheby_nodes_a]

poly_cheby_interp_a = F.lagrange_polynomial(list(zip(cheby_nodes_a, values)))
print(poly_cheby_interp_a)

plot_diff((poly_cheby_interp_a(r=r/r_max) + r) / (poly_cheby_interp_a(r=r/r_max) - r),
          r"\frac{\operatorname{ChebyInterpA2}(r) + r}{\operatorname{ChebyInterpA2}(r) - r}")

结果为:

误差最大为 1.32×10121.32 \times 10^{-12} 左右。图中可以看出误差的分布并不均匀,这是因为误差均匀的 u=rer+rerru = r \frac{e^r + r}{e^r - r} 不代表 u+rur\frac{u + r}{u - r} 也误差均匀,但总之,这个方法比一开始的 Taylor expansion 展开 ere^r 不知好了多少。libm 用了一个 10 次的多项式来近似 uu,误差保证不超过 2592^{-59} 了。它号称用了 Remez 算法来计算系数。

需要注意的一点是,无论是 Chebyshev 多项式还是 Remez 算法,优化的都是绝对误差,但是在浮点数计算过程中,相对误差可能更重要。但是可喜可贺的是,无论是直接计算 ere^r 还是先计算 uu,在 [-r_\max, r_\max]ere^ruu 不小于某个常数,所以绝对误差也能 bound 住相对误差,但是对于另外的函数可能就不是这样了……

Reference