逼近 haskell 中的导数时令人费解的行为

Perplexing behaviour when approximating the derivative in haskell

我已经定义了一个类型类 Differentiable 可以由任何可以对无穷小进行运算的类型来实现。 这是一个例子:

class Fractional a => Differentiable a where
    dif :: (a -> a) -> (a -> a)
    difs :: (a -> a) -> [a -> a]
    difs = iterate dif

instance Differentiable Double where
    dif f x = (f (x + dx) - f(x)) / dx
        where dx = 0.000001

func :: Double -> Double
func = exp

我还定义了一个简单的Double -> Double函数来区分。

但是当我在 ghc 中测试时,会发生这种情况:

... $ ghci
GHCi, version 8.8.4: https://www.haskell.org/ghc/  :? for help
Prelude> :l testing
[1 of 1] Compiling Main             ( testing.hs, interpreted )
Ok, one module loaded.
*Main> :t func
func :: Double -> Double
*Main> derivatives = difs func
*Main> :t derivatives
derivatives :: [Double -> Double]
*Main> terms = map (\f -> f 0) derivatives
*Main> :t terms
terms :: [Double]
*Main> take 5 terms
[1.0,1.0000004999621837,1.000088900582341,-222.0446049250313,4.440892098500626e8]
*Main>

e^x|x=0 的 n 阶导数的近似值是:

[1.0,1.0000004999621837,1.000088900582341,-222.0446049250313,4.440892098500626e8]

给定设置,一阶和二阶导数是完全合理的近似值,但突然间,func0 的三阶导数是... -222.0446049250313怎么办!!?

您在此处使用的方法是 finite difference method 一阶精度

外行翻译:它可以工作,但从数字上来说是很垃圾的。具体来说,因为它只有一阶精度,所以即使使用 exact-real-arithmetic,您也需要那些非常小的步骤才能获得良好的精度。您确实选择了一个小步长,这样很好,但是小步长会带来另一个问题:舍入误差。您需要取 f (x+δx) - f x 与小 δx 的差异,这意味着差异很小,而个别值可能很大。这总是会导致 floating-point 不准确——例如考虑

Prelude> (1 + pi*1e-13) - 1
3.141931159689193e-13

这可能实际上并没有那么大的伤害,但是因为你需要除以 δx 你会增加错误。

当你进入高阶导数时,这个问题只会得到 worse/compounded,因为现在 f' xf' (x+δx) 中的每一个都已经 (non-identical!) 提升了错误,所以取差值并再次提升显然是灾难的根源。

解决该问题的最简单方法是切换到二阶精确方法,最明显的是中心差异。然后你可以让步长大很多,从而在很大程度上避免舍入问题:

Prelude> let dif f x = (f (x + δx) - f(x - δx)) / (2*δx) where δx = 1e-3
Prelude> take 8 $ ([=11=]) <$> iterate dif exp
[1.0,1.0000001666666813,1.0000003333454632,1.0000004990740052,0.9999917560676863,0.9957312752106873,8.673617379884035,7806.255641895632]

你看到前几个导数现在很好,但最终它也会变得不稳定——当你迭代它时,任何 FD 方法都会发生这种情况。但这无论如何都不是一个好的方法:请注意,n-th 导数的每个评估都需要对 n−1-th 进行 2 次评估。所以,复杂度是指数的导数。

近似不透明函数的第 n 阶导数的更好方法是将第 n 阶多项式拟合到它并区分这个 symbolically/automatically。或者,如果函数不是不透明的,则区分自身 symbolically/automatically.

tl;dr:dx 分母以指数方式快速变小,这意味着即使分子中的小错误也会被夸大。

让我们对一阶“坏”近似,即三阶导数进行一些方程式推理。

dif (dif (dif exp))
= { definition of dif }
dif (dif (\x -> (exp (x+dx) - exp x)/dx))
= { definition of dif }
dif (\y -> ((\x -> (exp (x+dx) - exp x)/dx) (y+dx)
          - (\x -> (exp (x+dx) - exp x)/dx) y
           )/dx)
= { questionable algebra }
dif (\y -> (exp (y + 2*dx) - 2*exp (y + dx) + exp y)/dx^2)
= { alpha }
dif (\x -> (exp (x + 2*dx) - 2*exp (x + dx) + exp x)/dx^2)
= { definition of dif and questionable algebra }
\x -> (exp (x + 3*dx) - 3*exp (x + 2*dx) + 3*exp (x + dx) - exp x)/dx^3

希望现在您可以看到我们正在进入的模式:随着我们采用越来越多的导数,分子中的误差变得更糟(因为我们正在计算 exp 离原始点,x + 3*dx 是原来的三倍远,例如)同时对分母误差的敏感性变得更高(因为我们正在计算 dx^nnth 导数)。通过三阶导数,这两个因素就变得站不住脚了:

> exp (3*dx) - 3*exp (2*dx) + 3*exp (dx) - exp 0
-4.440892098500626e-16
> dx^3
9.999999999999999e-19

所以你可以看到,虽然分子的误差只有 5e-16 左右,但分母对误差的敏感度非常高,以至于你开始看到无意义的答案。