自動微分(Automatic Differentiation)

目錄

什麼是自動微分

手動求解法

數值微分法

符號微分法

自動微分法

自動微分Forward Mode

自動微分Reverse Mode

參考引用


現代深度學習系統中(比如MXNet, TensorFlow等)都用到了一種技術——自動微分。在此之前,機器學習社區中很少發揮這個利器,一般都是用Backpropagation進行梯度求解,然後進行SGD等進行優化更新。手動實現過backprop算法的同學應該可以體會到其中的複雜性和易錯性,一個好的框架應該可以很好地將這部分難點隱藏於用戶視角,而自動微分技術恰好可以優雅解決這個問題。接下來我們將一起學習這個優雅的技術:-)。本文主要來源於陳天奇在華盛頓任教的課程CSE599G1: Deep Learning System《Automatic differentiation in machine learning: a survey》。

什麼是自動微分

微分求解大致可以分爲4種方式:

  • 手動求解法(Manual Differentiation)
  • 數值微分法(Numerical Differentiation)
  • 符號微分法(Symbolic Differentiation)
  • 自動微分法(Automatic Differentiation)

爲了講明白什麼是自動微分,我們有必要了解其他方法,做到有區分有對比,從而更加深入理解自動微分技術

手動求解法

手動求解其實就對應我們傳統的backprop算法,我們求解出梯度公式,然後編寫代碼,代入實際數值,得出真實的梯度。在這樣的方式下,每一次我們修改算法模型,都要修改對應的梯度求解算法,因此沒有很好的辦法解脫用戶手動編寫梯度求解的代碼,這也是爲什麼我們需要自動微分技術的原因。

數值微分法

數值微分法是根據導數的原始定義:

那麼只要h hh取很小的數值,比如0.0001,那麼我們可以很方便求解導數,並且可以對用戶隱藏求解過程,用戶只要給出目標函數和要求解的梯度的變量,程序可以自動給出相應的梯度,這也是某種意義上的“自動微分”?。不幸的是,數值微分法計算量太大,求解速度是這四種方法中最慢的,更加雪上加霜的是,它引起的roundoff error和truncation error使其更加不具備實際應用場景,爲了彌補缺點,便有如下center difference approximation:

可惜並不能完全消除truncation error,只是將誤差減小。雖然數值微分法有如上缺點,但是由於它實在是太簡單實現了,於是很多時候,我們利用它來檢驗其他算法的正確性,比如在實現backprop的時候,我們用的"gradient check"就是利用數值微分法。

符號微分法

符號微分是代替我們第一種手動求解法的過程,利用代數軟件,實現微分的一些公式比如:

然後對用戶提供的具有closed form的數學表達式進行“自動微分"求解,什麼是具有closed form的呢?也就是必須能寫成完整數學表達式的,不能有編程語言中的循環結構,條件結構等。因此如果能將問題轉化爲一個純數學符號問題,我們能利用現有的代數軟件進行符號微分求解,這種程度意義上的“自動微分"其實已經很完美了。然而缺點我們剛剛也提及過了,就是必須要有closed form的數學表達式,另一個有名的缺點是“表達式膨脹"(expression swell)問題,如果不加小心就會使得問題符號微分求解的表達式急速“膨脹",導致最終求解速度變慢,對於這個問題請看如下圖:

稍不注意,符號微分求解就會如上中間列所示,表達式急劇膨脹,導致問題求解也隨着變慢。

自動微分法

終於輪到我們的主角登場,自動微分的存在依賴於它識破如下事實:

所有數值計算歸根結底是一系列有限的可微算子的組合

自動微分法是一種介於符號微分和數值微分的方法:數值微分強調一開始直接代入數值近似求解;符號微分強調直接對代數進行求解,最後才代入問題數值;自動微分將符號微分法應用於最基本的算子,比如常數,冪函數,指數函數,對數函數,三角函數等,然後代入數值,保留中間結果,最後再應用於整個函數。因此它應用相當靈活,可以做到完全向用戶隱藏微分求解過程,由於它只對基本函數或常數運用符號微分法則,所以它可以靈活結合編程語言的循環結構,條件結構等,使用自動微分和不使用自動微分對代碼總體改動非常小,並且由於它的計算實際是一種圖計算,可以對其做很多優化,這也是爲什麼該方法在現代深度學習系統中得以廣泛應用。

自動微分Forward Mode

考察如下函數:

我們可以將其轉化爲如下計算圖:

轉化成如上DAG(有向無環圖)結構之後,我們可以很容易分步計算函數的值,並求取它每一步的導數值:
這裏寫圖片描述

上表中左半部分是從左往右每個圖節點的求值結果,右半部分是每個節點對於x_{1}的求導結果,比如,注意到每一步的求導都利用到上一步的求導結果,這樣不至於重複計算,因此也不會產生像符號微分法的"expression swell"問題。

自動微分的forward mode非常符合我們高數裏面學習的求導過程,只要您對求導法則還有印象,理解forward mode自不在話下。如果函數輸入輸出爲:

那麼利用forward mode只需計算一次如上表右邊過程即可,非常高效。對於輸入輸出映射爲如下的:

這樣一個有n個輸入的函數,求解函數梯度需要n遍如上計算過程。然而實際算法模型中,比如神經網絡,通常輸入輸出是極其不成比例的,也就是:

那麼利用forward mode進行自動微分就太低效了,因此便有下面要介紹的reverse mode。

自動微分Reverse Mode

如果您理解神經網絡的backprop算法,那麼恭喜你,自動微分的backward mode其實就是一種通用的backprop算法,也就是backprop是reverse mode自動微分的一種特殊形式。從名字可以看出,reverse mode和forward mode是一對相反過程,reverse mode從最終結果開始求導,利用最終輸出對每一個節點進行求導,其過程如下紅色箭頭所示:

其具體計算過程如下表所示:
這裏寫圖片描述

上表左邊和之前的forward mode一致,用於求解函數值,右邊則是reverse mode的計算過程,注意必須從下網上看,也就是一開始先計算輸出y對於節點v_{5}的導數,用表示,這樣的記號可以強調我們對當前計算結果進行緩存,以便用於後續計算,而不必重複計算。由鏈式法則我們可以計算輸出對於每個節點的導數。

比如對於節點v3 :

用另一種記法便得到:

比如對於節點v0:

如果用另一種記法,便可得出:

和backprop算法一樣,我們必須記住前向時當前節點發出的邊,然後在後向傳播的時候,可以蒐集所有受到當前節點影響節點。
如上的計算過程,對於像神經網絡這種模型,通常輸入是上萬到上百萬維,而輸出損失函數是1維的模型,只需要一遍reverse mode的計算過程,便可以求出輸出對於各個輸入的導數,從而輕鬆求取梯度用於後續優化更新。
#自動微分的實現
這裏主要講解reverse mode的實現方式,forward mode的實現基本和reverse mode一致,但是由於機器學習算法中大部分用reverse mode纔可以高效求解,所以它是我們理解的重心。代碼設計輪廓來源於CSE599G1的作業,通過分析完成作業,可以展示自動微分的簡潔性和靈活可用性。

首先自動微分會將問題轉化成一種有向無環圖,因此我們必須構造基本的圖部件,包括節點和邊。可以先看看節點是如何實現的:

首先節點可以分爲三種:

  • 常數節點
  • 變量節點
  • 帶操作算子節點

因此Node類中定義了op成員用於存儲節點的操作算子,const_attr代表節點的常數值,name是節點的標識,主要用於調試。
對於邊的實現則簡單的多,每個節點只要知道本身的輸入節點即可,因此用inputs來描述節點的關係。
有了如上的定義,利用操作符重載,我們可以很簡單構造一個計算圖,舉一個簡單的例子:

對於如上函數,只要重載加法和乘法操作符,我們可以輕鬆得到如下計算圖:

操作算子是自動微分最重要的組成部分,接下來我們重點介紹,先上代碼:

從定義可以看出,所有實際計算都落在各個操作算子中,上面代碼應該抽象一些,我們來舉一個乘法算子的例子加以說明:

我們重點講解一下gradient方法,它接收兩個參數,一個是node,也就是當前要計算的節點,而output_grad則是後面節點傳來的,我們來看看它到底是啥玩意,對於如下例子:

return [node.inputs[1] * output_grad, node.inputs[0] * output_grad]

再來介紹一個特殊的op——PlaceHolderOp,它的作用就如同名字,起到佔位符的作用,也就是自動微分中的變量,它不會參與實際計算,只等待用戶給他提供實際值,因此他的實現如下:

瞭解了節點和操作算子的定義,接下來我們考慮如何協調執行運算。首先是如何計算函數值,對於一幅計算圖,由於節點與節點之間的計算有一定的依賴關係,比如必須先計算node1之後纔可以計算node2,那麼如何能正確處理好計算關係呢?一個簡單的方式是對圖節點進行拓撲排序,這樣可以保證需要先計算的節點先得到計算。這部分代碼由Executor掌控:

Executor是實際計算圖的引擎,用戶提供需要計算的圖和實際輸入,Executor計算相應的值和梯度。

如何從計算圖中計算函數的值,上面我們已經介紹了,接下來是如何自動計算梯度。reverse mode的自動微分,要求從輸出到輸入節點,按照先後依賴關係,對各個節點求取輸出對於當前節點的梯度,那麼和我們上面介紹的剛好相反,爲了得到正確計算節點順序,我們可以將圖節點的拓撲排序倒序即可。代碼也很簡單,如下所示:

這裏先介紹一個新的算子——oneslike_op。他是一個和numpy自帶的oneslike函數一樣的算子,作用是構造reverse梯度圖的起點,因爲最終輸出關於本身的梯度就是一個和輸出shape一樣的全1數組,引入oneslike_op可以使得真實計算得以延後,因此gradients方法最終返回的不是真實的梯度,而是梯度計算圖,然後可以複用Executor,計算實際的梯度值。
緊接着是根據輸出節點,獲得倒序的拓撲排序序列,然後遍歷序列,構造實際的梯度計算圖。我們重點來介紹node_to_output_grad和node_to_output_grads_list這兩個字典的意義。
先關注node_to_output_grads_list,他key是節點,value是一個梯度列表,代表什麼含義呢?先看如下部分計算圖:

而對於Executor而言,它並不知道此時的圖是否被反轉,它只關注用戶實際輸入,還有計算相應的值而已。
#自動梯度的應用
有了上面的大篇幅介紹,我們其實已經實現了一個簡單的自動微分引擎了,接下來看如何使用:

使用相當簡單,我們像編寫普通程序一樣,對變量進行各種操作,只要提供要求導數的變量,還有提供實際輸入,引擎可以正確給出相應的梯度值。
下面給出一個根據自動微分訓練Logistic Regression的例子:

import autodiff as ad
import numpy as np


def logistic_prob(_w):
    def wrapper(_x):
        return 1 / (1 + np.exp(-np.sum(_x * _w)))
    return wrapper


def test_accuracy(_w, _X, _Y):
    prob = logistic_prob(_w)
    correct = 0
    total = len(_Y)
    for i in range(len(_Y)):
        x = _X[i]
        y = _Y[i]
        p = prob(x)
        if p >= 0.5 and y == 1.0:
            correct += 1
        elif p < 0.5 and y == 0.0:
            correct += 1
    print("總數:%d, 預測正確:%d" % (total, correct))


def plot(N, X_val, Y_val, w, with_boundary=False):
    import matplotlib.pyplot as plt
    for i in range(N):
        __x = X_val[i]
        if Y_val[i] == 1:
            plt.plot(__x[1], __x[2], marker='x')
        else:
            plt.plot(__x[1], __x[2], marker='o')
    if with_boundary:
        min_x1 = min(X_val[:, 1])
        max_x1 = max(X_val[:, 1])
        min_x2 = float(-w[0] - w[1] * min_x1) / w[2]
        max_x2 = float(-w[0] - w[1] * max_x1) / w[2]
        plt.plot([min_x1, max_x1], [min_x2, max_x2], '-r')

    plt.show()


def gen_2d_data(n):
    x_data = np.random.random([n, 2])
    y_data = np.ones(n)
    for i in range(n):
        d = x_data[i]
        if d[0] + d[1] < 1:
            y_data[i] = 0
    x_data_with_bias = np.ones([n, 3])
    x_data_with_bias[:, 1:] = x_data
    return x_data_with_bias, y_data


def auto_diff_lr():
    x = ad.Variable(name='x')
    w = ad.Variable(name='w')
    y = ad.Variable(name='y')
    
    # 注意,以下實現某些情況會有很大的數值誤差,
    # 所以一般真實系統實現會提供高階算子,從而減少數值誤差
    
    h = 1 / (1 + ad.exp(-ad.reduce_sum(w * x)))
    L = y * ad.log(h) + (1 - y) * ad.log(1 - h)
    w_grad, = ad.gradients(L, [w])
    executor = ad.Executor([L, w_grad])

    N = 100
    X_val, Y_val = gen_2d_data(N)
    w_val = np.ones(3)

    plot(N, X_val, Y_val, w_val)
    executor = ad.Executor([L, w_grad])
    test_accuracy(w_val, X_val, Y_val)
    alpha = 0.01
    max_iters = 300
    for iteration in range(max_iters):
        acc_L_val = 0
        for i in range(N):
            x_val = X_val[i]
            y_val = np.array(Y_val[i])
            L_val, w_grad_val = executor.run(feed_dict={w: w_val, x: x_val, y: y_val})
            w_val += alpha * w_grad_val
            acc_L_val += L_val
        print("iter = %d, likelihood = %s, w = %s" % (iteration, acc_L_val, w_val))
    test_accuracy(w_val, X_val, Y_val)
    plot(N, X_val, Y_val, w_val, True)


if __name__ == '__main__':
    auto_diff_lr()

看到吧,用戶可以完全感受不到微分求解過程,真正做到自動微分! 完整實現代碼可戳此處

參考引用


————————————————
版權聲明:本文爲CSDN博主「Carl-Xie」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/aws3217150/article/details/70214422

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章