如何用C++實現自己的Tensorflow

csdn研發技術發表於2017-11-10

原文:How To Write Your Own Tensorflow in C++
作者:Ray Zhang
翻譯:無阻我飛揚

摘要:TensorFlow是由谷歌基於DistBelief進行研發的第二代人工智慧學習系統,其命名來源於本身的執行原理,它完全開源,作者通過自己的一個小專案,闡述瞭如何用C++實現自己的TensorFlow,這篇文章看起來可能會有點晦澀,你需要對相關知識有所瞭解。以下是譯文。

在我們開始之前,以下是程式碼:

  1. Branch with Eigen backend
  2. Branch that only supports scalars

我和Minh Le一起做了這個專案。

為什麼?

如果你是CS專業的人員,可能聽過這句“不要使自己陷入_”的話無數次。CS有加密、標準庫、解析器等等。我覺得現在還應該包含ML庫。

不管事實如何,它仍然是一個值得學習的驚人的教訓。人們現在認為TensorFlow和類似的庫是理所當然的;把它們當成是一個黑盒子,讓其執行。沒有多少人知道後臺發生了什麼。這真是一個非凸的優化問題!不要停止攪拌那堆東西,直到它看起來合適為止(結合下圖及機器學習系統知識去理解這句話)。

這裡寫圖片描述

Tensorflow

TensorFlow是由Google開源的一個深度學習庫。在TensorFlow的核心,有一個大的元件,將操作串在一起,行成一個叫做 運算子圖 的東西。這個運算子圖是一個有向圖G=(V,E)

G = (V,E)
,在某些節點u1,u2,,un,vV
u_1,u_2,…,u_n,v \in V
e1,e2,,enE,ei=(ui,v)
e_1,e_2,…,e_n \in E, e_i = (u_i,v)
存在某些運算子將u1,,un
u_1,…,u_n
對映到 v
v

例如,如果我們有x + y = z,那麼 (x,z),(y,z)E

(x,z), (y,z) \in E

這對於評估算術表示式非常有用。我們可以通過尋找運算子圖中的 sinks來得到結果。 Sinks是諸如 vV,∄e=(v,u)

v \in V, \nexists e = (v, u)
這樣的頂點。換句話說,這些頂點沒有到其它頂點的有向邊。同樣的, sourcesvV,∄e=(u,v)
v \in V, \nexists e = (u, v)

對我們來說, 總是把值放在sources,值會傳播到Sinks。

反向模式求導

如果認為我的解釋不夠好,這裡有一些幻燈片

求導是TensorFlow所需的許多模型的核心要求,因為需要它來執行 梯度下降演算法。每個高中畢業的人都知道什麼是求導; 它只是獲取函式的導數,如果函式是由基本函式組成的複雜組合,那麼就做 鏈式法則

超級簡單的概述

如果有一個這樣的函式:

f(x,y) = x * y

那麼關於X的求導將產生:

df(x,y)dx=y

\frac{df(x,y)}{dx} = y

關於Y的求導將產生:

df(x,y)dy=x

\frac{df(x,y)}{dy} = x

另外一個例子:

f(x1,x2,...,xn)=f(x)=xTx

f(x_1,x_2,...,x_n) = f(\mathbf{x}) = \mathbf{x}^T\mathbf{x}

這個導數是:

df(x)dxi=2xi

\frac{df(\mathbf{x})}{dx_i} = 2x_i

所以梯度就是:

xf(x)=2x

\nabla_x f(\mathbf{x}) = 2\mathbf{x}

鏈式法則,譬如應用於複雜的函式f(g(h(x)))

f ( g ( h(x) ))

df(g(h(x)))dx=df(g(h(x)))dg(h(x))dg(h(x))dh(x)dh(x)x

\frac{df(g(h(x)))}{dx} = \frac{df(g(h(x)))}{dg(h(x))} \frac{dg(h(x))}{dh(x)} \frac{dh(x)}{x}

5分鐘內反向模式

現在記住運算子圖的DAG結構,以及上一個例子中的鏈式法則。如果要評估,我們可以看到:

x -> h -> g -> f

作為圖表。會給出答案f。但是,我們也可以採取反向求解:

dx <- dh <- dg <- df

這看起來像鏈式法則!需要將導數相乘在一起,以獲得最終結果。

下圖是一個運算子圖的例子:

這裡寫圖片描述

所以這基本上退化成圖遍歷問題。 有誰發覺拓撲排序和DFS / BFS嗎?

所以要支援雙向拓撲排序的話,需要包含一組父節點和一組子節點,Sinks是另一個方向的Sources, 反之亦然

實施

在開學之前,Minh Le和我開始設計這個專案。我們決定使用Eigen 庫後臺進行線性代數運算。它們有一個稱為MatrixXd的矩陣類。我們在這裡使用它。

每個變數節點由var類表示:

class var {
// Forward declaration
struct impl;

public:
// For initialization of new vars by ptr
var(std::shared_ptr<impl>);

var(double);
var(const MatrixXd&);
var(op_type, const std::vector<var>&);    
...

// Access/Modify the current node value
MatrixXd getValue() const;
void setValue(const MatrixXd&);
op_type getOp() const;
void setOp(op_type);

// Access internals (no modify)
std::vector<var>& getChildren() const;
std::vector<var> getParents() const;
...
private: 
// PImpl idiom requires forward declaration of the     class:
std::shared_ptr<impl> pimpl;
};

struct var::impl{
public:
impl(const MatrixXd&);
impl(op_type, const std::vector<var>&);
MatrixXd val;
op_type op; 
std::vector<var> children;
std::vector<std::weak_ptr<impl>> parents;
};

在這裡,我們採用 pImpl慣用法,這意味著“通過指標來實現”。這在許多方面是非常好的,例如介面解耦實現, 當在堆疊上有一個本地shell介面時,允許在堆疊上例項化。pImpl的副作用是執行時間稍慢,但是編譯時間縮短了很多。這讓我們通過多個函式呼叫/返回來保持資料結構的永續性。像這樣的樹狀資料結構應該是持久的。

有幾個 列舉,告訴我們目前正在執行哪些操作:

enum class op_type {
plus,
minus,
multiply,
divide,
exponent,
log,
polynomial,
dot,
...
none // no operators. leaf.
};

執行該樹評價的實際類稱為expression:

class expression {
public:
expression(var);
...
// Recursively evaluates the tree.
double propagate();
...
// Computes the derivative for the entire graph.
// Performs a top-down evaluation of the tree.
void backpropagate(std::unordered_map<var, double>& leaves);
...    
private:
var root;
};

反向傳播的內部,有一些類似於此的程式碼:

backpropagate(node, dprev):
derivative = differentiate(node)*dprev
for child in node.children:
    backpropagate(child, derivative)    

這相當於做一個DFS; 你看到了嗎?

為什麼選擇C ++?

事實上,C ++語言用於此不是特別合適。我們可以花 更少的時間用OCaml等功能性語言來開發。現在我明白了為什麼Scala被用於機器學習,主要看你喜歡;)。

然而,C ++有明顯的好處:

Eigen

例如,可以直接使用tensorflow的線性代數庫,稱之為Eigen。這是一個多模板惰性計算的線性代數庫。類似於表示式樹的樣子,構建表示式,只有在需要時才會對錶達式進行評估。然而,對於Eigen來說, 在編譯的時候就確定何時使用模板,這意味著執行時間的減少。我特別讚賞寫Eigen的人,因為審視模板的錯誤,讓我的眼睛充血。

Eigen的程式碼看起來像:

Matrix A(...), B(...);
auto lazy_multiply = A.dot(B);
typeid(lazy_multiply).name(); // the class name is something like Dot_Matrix_Matrix.
Matrix(lazy_multiply); // functional-style casting forces evaluation of this matrix.

Eigen庫是非常強大的,這就是為什麼它是tensorflow自我使用的主要後臺。這意味著除了這種惰性計算技術之外,還有其他方面的優化。

運算子過載

用Java開發這些庫會非常好—沒有shared_ptrs, unique_ptrs, weak_ptrs程式碼;我們可以採取 實際的,能勝任的,GC演算法。使用Java開發可以節省許多開發時間,更不用說執行速度也會變得更快。可是,Java不允許運算子過載,因而它們就不能這樣:

// These 3 lines code up an entire neural network!
var sigm1 = 1 / (1 + exp(-1 * dot(X, w1)));
var sigm2 = 1 / (1 + exp(-1 * dot(sigm1, w2)));
var loss = sum(-1 * (y * log(sigm2) + (1-y) * log(1-sigm2)));

順便說一下,上面的是實際程式碼。這不是很漂亮嗎?我認為 這比用於TensorFlow的python包裝更漂亮。只想讓你知道,這些也都是矩陣。

在Java語言中,這將是極其醜陋的,有著一堆add(), divide()…等等程式碼。更為重要的是, 使用者將被隱式強制使用PEMDAS(括號 ,指數、乘、除、加、減),這一點上,C++的運算子表現的很好。

效能,而不是Bug

有一些東西,你可以在這個庫中實際指定,TensorFlow沒有明確的API,或者我不知道。比如,如果想訓練某個特定子集的權重,可以只反向傳播到感興趣的具體來源。這對於卷積神經網路的 轉移學習非常有用,一些大的網路,如VGG19網路,很容易用TensorFlow實現,其附加的幾個額外的層的權重是根據新的域樣本進行訓練的。

基準

用Python的Tensorflow庫,在Iris資料集上對10000個歷史紀元進行分類訓練,這些歷史紀元具有相同的超引數,結果是:

  1. Tensorflow的神經網路 23812.5 ms
  2. Scikit的神經網路庫: 22412.2 ms
  3. Autodiff的神經網路,迭代,優化: 25397.2 ms
  4. Autodiff的神經網路,具有迭代,無優化: 29052.4 ms
  5. Autodiff的神經網路,具有遞迴,無優化: 28121.5 ms

如此看來,令人驚訝的是,Scikit在所有這些中執行最快。這可能是因為我們沒有做大量的矩陣乘法運算。也可能是因為tensorflown不得不通過變數初始化採用額外的編譯步驟。或者,也許可能不得不在python中執行迴圈,而不是在C語言中(python迴圈 真的很糟糕!)。我自己也不確定這到底是因為什麼。

我完全意識到這絕對不是一個全面的基準測試,因為它只適用於在特定情況下的單個資料點。不過,這個庫的效能並不是最先進的技術,因為我們不希望把自己捲進TensorFlow。

相關文章