Featured image of post AutomaticDifferentiation

AutomaticDifferentiation

Summary Of AutomaticDifferentiation

每个学过高等数学的大学生都知道最小量这一名词,但很少思考最小量有多小。计算机学科专业的同学可能会想到使用浮点数来表示最小量,比如常见的使用 1e-12 作为最小量。但这引入了浮点数精度问题以及数学上的不严谨。

这篇博客的目标是实现一个自动微分器,通过表达式自动求导数,重新审视并定义最小量的概念。

项目仓库:https://github.com/M4yGem1ni/AutomaticDifferentiation

开始

作为一个计算机科学与技术的学生,我相信你一定熟练掌握 numpy 这种科学计算库。

假如你现在有一个函数 $f(x) = e^{\sin(x)}$,你需要对其求导。我们小学二年级就学过导数的定义:

$$ f'(x) = \frac{f(x + \varepsilon) - f(x)}{\varepsilon} $$

由于计算机学科的学生经常使用浮点数,于是首先想到的方法是定义一个阈值,比如 1e-12 作为最小量 $\varepsilon$,直接计算:

$$ f'(x) \approx \frac{f(x + 1e^{-12}) - f(x)}{1e^{-12}} $$

$$ \lvert N_1 - N_2 \rvert \le 1e^{-12} $$

时,认为 $N_1$ 与 $N_2$ 相等。

但是,只要你使用浮点数,就会面临精度问题。例如计算机计算 $10^{10} + 10^{-12} = 10^{10}$,导致求导公式的分子直接为零,导数恒为 0。

无穷小量的数学定义

在数学上,无穷小量的定义是:

$\forall \varepsilon > 0$,$\exists \delta > 0$,当 $0 < \lvert x - x_0 \rvert < \delta$ 时,恒有 $\lvert f(x) \rvert < \varepsilon$。

观察这个定义我们不难发现:没有办法使用一个确切的数值作为最小量。如果使用 1e-12,则依旧可以找到 1e-151e-12 更小,以此类推,无穷无尽。

因此,想要正确地表示最小量,我们只能使用一个未知的符号来表示,数学中使用 $\varepsilon$。

定义最小量运算规则

我们需要抛弃使用数值的方式定义最小量,为最小量定制一套运算规则,让它参与函数表达式计算。这里直接给出答案:

$$ \varepsilon + \varepsilon = 2\varepsilon $$

$$ \varepsilon \times \varepsilon = 0 $$

由于无穷小量已经足够小了,所以只保留一阶无穷小量,丢弃所有的高阶无穷小量,同时保证一阶无穷小量参与计算。

对偶数定义

在这个基础上,我们需要两个维度的信息来表示自变量 $x$,对 $x$ 的类型进行重定义:

1
2
3
4
5
6
struct Dual {
    double value;  // 数值部分
    double deriv;  // 导数部分

    Dual(double v = 0.0, double d = 0.0) : value(v), deriv(d) {}
};

这种数据类型被称为对偶数(Dual Number),形式为 $a + b\varepsilon$。

加减乘除运算规则

根据上方给出的两条定义,我们重定义 +-*/ 这四种基础运算法则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
inline Dual operator+(const Dual& a, const Dual& b) {
    return Dual(a.value + b.value, a.deriv + b.deriv);
}

inline Dual operator-(const Dual& a, const Dual& b) {
    return Dual(a.value - b.value, a.deriv - b.deriv);
}

inline Dual operator*(const Dual& a, const Dual& b) {
    return Dual(a.value * b.value,
        a.value * b.deriv + a.deriv * b.value);
}

inline Dual operator/(const Dual& a, const Dual& b) {
    return Dual(a.value / b.value,
        (a.deriv * b.value - a.value * b.deriv) / (b.value * b.value));
}

初等函数运算规则

类似 sin(x)exp(x) 这种初等函数,无法使用之前的两条定义给出新的运算规则。这里我们引入泰勒公式

$$ f(x_0 + \Delta x) = f(x_0) + f'(x_0)\Delta x + \frac{f''(x_0)}{2!}(\Delta x)^2 + \cdots $$

任何初等函数都可以使用泰勒公式展开。令 $\Delta x = \varepsilon$,由于 $\varepsilon^2 = 0$,只需保留常数项和一次项即可。按照这种方法,可以轻松定义所有初等函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 三角函数
inline Dual sin(const Dual& x) {
    return Dual(std::sin(x.value), std::cos(x.value) * x.deriv);
}

inline Dual cos(const Dual& x) {
    return Dual(std::cos(x.value), -std::sin(x.value) * x.deriv);
}

// 指数和对数
inline Dual exp(const Dual& x) {
    double exp_val = std::exp(x.value);
    return Dual(exp_val, exp_val * x.deriv);
}

inline Dual log(const Dual& x) {
    return Dual(std::log(x.value), x.deriv / x.value);
}

// 幂函数
inline Dual pow(const Dual& x, double n) {
    double pow_val = std::pow(x.value, n);
    return Dual(pow_val, n * std::pow(x.value, n - 1) * x.deriv);
}

// 反三角函数
inline Dual asin(const Dual& x) {
    return Dual(std::asin(x.value), x.deriv / std::sqrt(1 - x.value * x.value));
}

inline Dual acos(const Dual& x) {
    return Dual(std::acos(x.value), -x.deriv / std::sqrt(1 - x.value * x.value));
}

inline Dual atan(const Dual& x) {
    return Dual(std::atan(x.value), x.deriv / (1 + x.value * x.value));
}

前向自动微分

单变量全导

定义了对偶数,下一步就是使用已定义好的初等函数,构造待求导的函数:

1
2
3
Dual f(Dual x) {
    return x + x * x + pow(x, 3);
}

计算时只需设置 x.deriv = 1,即为自变量 $x$ 添加一个最小量。在计算过程中,最小量由自变量传递到因变量,最终得到 y.deriv 即为导数值。

多变量偏导

1
2
3
Dual g(Dual x, Dual y, Dual z) {
    return pow(x, 3) + pow(y, 2) + z + cos(x * y * z);
}

当出现多个自变量时,依次遍历每个自变量,分别将 deriv = 1 写入,即可得到每个自变量的偏导。依次写入向量,得到的就是该函数的梯度

模板处理函数

最后使用模板,不限制自变量的数量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
ADResult diff(const Func& f, Args... args) {
    ADResult res;
    constexpr size_t N = sizeof...(Args);
    res.gradient.resize(N);

    // 1. 计算函数值(所有导数为 0)
    Dual value_res = f(Dual((double)args, 0.0)...);
    res.value = value_res.value;

    // 2. 对每个输入变量求偏导(seed 依次设为 1)
    double args_arr[] = { (double)args... };

    // 梯度
    [&]<size_t... Is>(std::index_sequence<Is...>) {
        ([&]() {
            // 创建 Dual 输入,第 Is 个变量导数=1,其余=0
            Dual inputs[N];
            for (size_t j = 0; j < N; ++j)
                inputs[j] = Dual(args_arr[j], j == Is ? 1.0 : 0.0);
            // 用内层 index_sequence 展开所有 N 个参数传给 f
            res.gradient[Is] = [&]<size_t... Js>(std::index_sequence<Js...>) {
                return f(inputs[Js]...).deriv;
            }(std::make_index_sequence<N>{});
        }(), ...);
    }(std::make_index_sequence<N>{});

    // 散度
    res.divergence = 0.0;
    [&]<size_t... Is>(std::index_sequence<Is...>) {
        ([&]() {
            res.divergence += [&]<size_t... Js>(std::index_sequence<Js...>) {
                return res.gradient[Is]; // 这里直接用梯度值,因为散度是梯度的和
            }(std::make_index_sequence<N>{});
        }(), ...);
    }(std::make_index_sequence<N>{});
    return res;
}

后向自动微分

学习了前向自动微分,我们不难发现:求函数的偏导,需要遍历所有的自变量才可以。当自变量数量非常大,而因变量的数量比较少时,可以使用后向自动微分

本质上两者的原理一样,后向自动微分是在遍历因变量的基础上,加入了链式法则。

链式节点

前向自动微分中,对偶数在计算过程中同时携带了数值和导数。

在后向自动微分中,我们无法直接通过自变量的变化计算因变量。因此,首先进行一次正向计算,将数值和计算方法存入每一级中,让每一步计算变成一个节点

正向走到因变量处后,依次为每个因变量提供一个最小量,通过每一级节点中保存的计算公式,倒推前一级对后一级变化的贡献。贡献由因变量层通过中间的节点传递到最表层,即可通过遍历因变量,直接确定自变量的梯度。

以 $f(x, y) = x \times y + \sin(x)$ 为例,其计算过程如下:

  graph LR
    x --> v2["x × y"]
    y --> v2
    x --> v3["sin(x)"]
    v3 --> v4["x × y + sin(x)"]
    v2 --> v4

节点定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Node {
    std::shared_ptr<NodeData> ptr;

    Node() = default;
    explicit Node(double v);

    double value() const;
    double gradient() const;
    void add_gradient(double g) const;
    void set_gradient(double g) const;
    const std::vector<Node>& inputs() const;
    std::vector<Node>& inputs();
    std::function<void()>& backward();
    const std::function<void()>& backward() const;
};

struct NodeData {
    double value;
    double gradient;
    std::vector<Node> inputs;
    std::function<void()> backward;  // 正向时保存计算函数,反向时调用

    explicit NodeData(double v) : value(v), gradient(0.0) {}
};

计算过程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
template <typename Func, typename... Args>
ADResult diff(const Func f, Args... args) {
    ADResult res;

    // 1. 创建输入节点
    std::vector<Node> inputs;
    inputs.reserve(sizeof...(Args));
    (inputs.emplace_back((double)args), ...);

    // 2. 正向计算
    Node output = [&]<size_t... Is>(std::index_sequence<Is...>) {
        return f(inputs[Is]...);
    }(std::make_index_sequence<sizeof...(Args)>{});

    // 3. 反向传播
    backward(output);

    // 4. 提取结果
    res.value = output.value();
    for (auto& in : inputs)
        res.gradient.push_back(in.gradient());

    // 散度
    res.divergence = 0.0;
    for (const auto& g : res.gradient)
        res.divergence += g;

    // 旋度(标量场的旋度始终为 0)
    res.curl.resize(inputs.size());
    for (size_t i = 0; i < inputs.size(); ++i)
        res.curl[i] = 0.0;

    return res;
}
使用 Hugo 构建
主题 StackJimmy 设计