基於 Go 語言來理解 Tensorflow

運和憑發表於2017-06-12

Tensorflow並非一套特定機器學習庫——相反,其屬於一套通用型計算庫,負責利用圖形表達計算過程。其核心通過C++語言實現,同時亦繫結有多種其它語言。與Python繫結不同的是,Go程式語言繫結不僅允許使用者在Go環境當中使用TensorFlow,同時亦可幫助大家深入瞭解TensorFlow的內部運作原理。

什麼是繫結?

從官方說明的角度來看,TensorFlow的開發者們公佈了:

  • C++原始碼:TensorFlow的真正核心,負責具體實現這套機器學習庫的各高/低層級操作。
  • Python繫結與Python庫:這些繫結由C++實現程式碼所自動生成,這意味著我們能夠藉此利用Python呼叫C++函式:舉例來說,我們可以藉此實現numpy。另外,這套庫還將呼叫與繫結相結合,旨在定義TensorFlow使用者們所熟知的各類高層級API。
  • Java繫結。
  • Go繫結。

作為Go的忠誠支持者,我當然對Go繫結給予了高度關注,希望瞭解其適用於支援哪些任務型別。

Go繫結說明

Gopher (由Takuya Ueda(@tenntenn)建立,基於Creative Commons 3.0 Attribution許可)

與TensorFlow Logo結合在一起。

首先需要強調的是用於進行自身維護的Go API缺少Variable支援能力:此API的設計目標在於使用經過訓練的模型,而非從零開始執行模型訓練。這一點在說明文件中的“Go語言環境下TensorFlow安裝”部分作出了明確提示:

TensorFlow提供多種可在Go程式設計中使用之API。這些API的主要作用在於載入由Python語言建立的模型,並在Go應用程式之內執行這些模型。

如果我們不關注機器學習模型的訓練,那麼這些API不會引發任何麻煩。但如果大家需要進行模型訓練,那麼請注意以下建議:

作為一位真正的Go語言支持者,請以簡單作為基本指導原則!使用Python以定義並訓練模型; 您始終可以載入經過訓練的模型並隨後在Go環境中加以使用。

簡而言之:Go繫結可用於匯入並定義常量圖; 在這裡的語境下,所謂常量是指不涉及任何訓練過程,因此不存在經過訓練的變數。

現在我們將利用Go語言深入探索TensorFlow世界:建立我們的第一款應用程式。

在接下來的內容中,我們假定大家已經擁有一套Go環境,並根據README文件中的講解對TensorFlow繫結進行了編譯與安裝。

瞭解TensorFlow結構

讓我們再次對TensorFlow的概念進行重申(當然,這裡是我個人總結出的概念,與TensorFlow網站中的描述有所不同):

TensorFlow™為一套開源軟體庫,負責利用資料流圖進行數值計算。圖形中的各個節點代表數學運算,而圖形邊緣則代表著各節點之間進行通訊的多維資料陣列(即張量)。

我們可以將TensorFlow視為一種描述性語言,其與SQL有點類似,大家可以在其中描述您所需要的內容,並由底層引擎(即資料庫)解析您的查詢、檢查語法與語義錯誤,將其轉換為專有表達、優化並得出計算結果:通過這一系列流程,我們將最終得出正確結果。

因此,在我們使用任何可用的API時,我們實際上是在對一個圖形進行描述:此圖形的評估起點始於我們將其放置於Session當中並明確決定在該會話內Run此圖形。

瞭解到這一點,接下來讓我們嘗試定義一個計算圖,並在一個Session當中對其進行評估。根據API說明文件的內容,我們可以明確找到tensorflow(簡稱為tf)& op軟體包之內的可用方法列表。

如大家所見,這兩個軟體包當中包含一切對圖形進行定義與評估所必需的要素。

前者包含構建基礎性“空”結構——例如Graph本身——所需要的函式,而後者則包含各類最為重要的包,薦為由C++實現程式碼所自動生成的繫結。

然而,假定我們需要計劃A與x之間的矩陣乘法,其中:

這裡,假定大家已經熟悉了張量圖的定義方式,並清楚瞭解佔位符的概念及其實際作用。以下程式碼為TensorFlow Python繫結使用者所作出的初步嘗試。我們在這裡將此檔案命名為attempt1.go

package main
import (
        "fmt"
        tf "github.com/tensorflow/tensorflow/tensorflow/go"
        "github.com/tensorflow/tensorflow/tensorflow/go/op")
func main() {
        // Let's describe what we want: create the graph

        // We want to define two placeholder to fill at runtime
        // the first placeholder A will be a [2, 2] tensor of integers
        // the second placeholder x will be a [2, 1] tensor of intergers

        // Then we want to compute Y = Ax

        // Create the first node of the graph: an empty node, the root of our graph
        root := op.NewScope()

        // Define the 2 placeholders
        A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
        x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))

        // Define the operation node that accepts A & x as inputs
        product := op.MatMul(root, A, x)

        // Every time we passed a `Scope` to an operation, we placed that
        // operation **under** that scope.
        // As you can see, we have an empty scope (created with NewScope): the empty scope
        // is the root of our graph and thus we denote it with "/".

        // Now we ask tensorflow to build the graph from our definition.
        // The concrete graph is created from the "abstract" graph we defined
        // using the combination of scope and op.

        graph, err := root.Finalize()
        if err != nil {
                // It's useless trying to handle this error in any way:
                // if we defined the graph wrongly we have to manually fix the definition.

                // It's like a SQL query: if the query is not syntactically valid
                // we have to rewrite it
                panic(err.Error())
        }

        // If here: our graph is syntatically valid.
        // We can now place it within a Session and execute it.

        var sess *tf.Session
        sess, err = tf.NewSession(graph, &tf.SessionOptions{})
        if err != nil {
                panic(err.Error())
        }

        // In order to use placeholders, we have to create the Tensors
        // containing the values to feed into the network
        var matrix, column *tf.Tensor

        // A = [ [1, 2], [-1, -2] ]
        if matrix, err = tf.NewTensor([2][2]int64{ {1, 2}, {-1, -2} }); err != nil {
                panic(err.Error())
        }
        // x = [ [10], [100] ]
        if column, err = tf.NewTensor([2][1]int64{ {10}, {100} }); err != nil {
                panic(err.Error())
        }

        var results []*tf.Tensor
        if results, err = sess.Run(map[tf.Output]*tf.Tensor{
                A: matrix,
                x: column,
        }, []tf.Output{product}, nil); err != nil {
                panic(err.Error())
        }
        for _, result := range results {
                fmt.Println(result.Value().([][]int64))
        }}

我們為以上程式碼編寫了詳盡的註釋,希望大家認真關注以提升理解效果。

現在,TensorFlow-Python使用者認為上述程式碼能夠順利完成編譯並確切起效。讓我們看看其判斷是否正確:

go run attempt1.go

下面來看得出的結果:

panic: failed to add operation "Placeholder": Duplicate node name in graph: 'Placeholder'

很明顯,這裡出現了問題。可以看到,同一“Placeholder”名稱之下存在兩個計算“Placeholder”。

結論一:節點ID

每當我們呼叫一項方法以定義一項運算時,Python API都會生成不同節點——無論此前該方法是否曾經接受過呼叫。事實上,以下程式碼能夠返回結果3,且不會引發任何問題。

import tensorflow as tf
a = tf.placeholder(tf.int32, shape=())
b = tf.placeholder(tf.int32, shape=())
add = tf.add(a,b)
sess = tf.InteractiveSession()
print(sess.run(add, feed_dict={a: 1,b: 2}))

我們可以驗證此程式是否正確建立兩個節點並輸出其佔位符名稱: print(a.name, b.name)生成Placeholder:0 Placeholder_1:0。因此, b 佔位符為Placeholder_1:0 而a 佔位符為Placeholder:0。

不過在Go語言中,上述程式會發生錯誤,這是因為A與x皆會被稱為Placeholder。我們可以得出以下結論:

Go API不會在我們每次呼叫一項用於定義運算的函式時自動生成新的名稱:因此,運算名稱是固定的,意味著我們無法加以修改。

提問時間:

  • 到現在,我們瞭解到關於TensorFlow架構的哪些結論? 一套圖形中的每個節點皆必須擁有一個惟一名稱。每個節點皆由其名稱作為標識。
  • 節點的名稱與用於定義該節點的運算名稱是否相同? 是的,或者更具體地講,節點名稱屬於運算名稱中的最後一部分。

為了進一步澄清第二個問題,下面我們嘗試解決節點名稱重複問題。

結論二:範圍

如大家所見,Python API會在每次定義一項運算時自動建立一個新的名稱。著眼於底層,Python API會呼叫Scope類中的C++方法WithOpName。以下為scope.h當中列出的方法說明及其特徵:

/// Return a new scope. All ops created within the returned scope will have
/// names of the form <name>/<op_name>[_<suffix].
Scope WithOpName(const string& op_name) const;

大家可能已經注意到,此方法用於對節點進行命名以返回Scope,這意味著節點名稱實際上就是一個Scope。所謂Scope,即為一條由root /(空圖形)到op_name的完整路徑。

當我們嘗試新增一個擁有同樣從/到op_name路徑的節點時,WithOpName方法會相應新增一條_<suffix>字尾(其中<suffix>的為一個計數器),這意味著同一範圍之內可存在重複節點。

瞭解了這一點,為解決節點名稱重複的問題,我們顯然需要在type Scope當中找到WithOpName方法。遺憾的是,此方法並不存在。

相反,通過查詢type Scope相關說明文件,我們發現惟一能夠返回新Scope的方法只有SubScope(namespace string)。

下面來看文件中的說明內容:

SubScope會返回一個新的Scope,此Scope負責確保全部被新增至圖形中的運算被命名為“namespace”。如果此名稱空間與範圍內的現有名稱空間相沖突,則為其新增一個字尾。

使用字尾的衝突管理機制與C++ WithOpName方法有所區別:WithOpName會在同一範圍內的運算名稱之後新增suffix(因此Placeholder會變為Placeholder_1); 而Go的SubScope會將suffix新增至範圍名稱之後。

這種差異意味著最終生成的圖形也將完全不同,然而這種圖形層面的區別(即將節點放置在不同範圍之下)並不會對計算結果造成任何改變——二者在計算上仍然等效。

下面我們變更該佔位符定義以定義兩個不同的節點,而後Print其Scope名稱。

我們通過變更以下程式碼行建立檔案attempt2.go:

A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))

變更之後:

// define 2 subscopes of the root subscopes, called "input". In this
// way we expect to have a input/ and a input_1/ scope under the root scope
A := op.Placeholder(root.SubScope("input"), tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root.SubScope("input"), tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1)))
fmt.Println(A.Op.Name(), x.Op.Name())

照常對其進行編譯及執行: go run attempt2.go。結果如下所示:

input/Placeholder input_1/Placeholder

提問時間:

到現在,我們瞭解到關於TensorFlow架構的哪些結論? 一個節點完全由其定義所在的Scope負責標識。該範圍為一條路徑,我們利用其實現由圖形root到目標節點的追蹤。我們可以通過兩種節點定義方式確保其執行同樣的運算:在不同Scope當中定義該運算(Go風格)或者變更運算名稱(Python會自動執行這一操作,我們亦可在C++中以手動方式執行)。

到這裡,我們已經解決了節點命名重複的問題,但仍有另一個問題需要加以探討。

panic: failed to add operation "MatMul": Value for attr 'T' of int64 is not in the list of allowed values: half, float, double, int32, complex64, complex128

為何MatMul節點會出現定義錯誤?我們只是希望將兩項tf.int64指標相乘!看起來,MatMul似乎單單無法接受int64類指標。

int64的attr ‘T’值並不符合允許值的定義要求: half, float, double, int32, complex64, complex128

這裡列出的定義要求到底是什麼意思?為什麼我們能夠將兩項int32指標相乘,卻無法對兩項int64指標進行同樣的運算?

下面我們將逐步解決這個問題。

結論三:TensorFlow型別系統

下面我們著眼於原始碼內容,看看C++對MatMul運算作出的宣告:

REGISTER_OP("MatMul")
    .Input("a: T")
    .Input("b: T")
    .Output("product: T")
    .Attr("transpose_a: bool = false")
    .Attr("transpose_b: bool = false")
    .Attr("T: {half, float, double, int32, complex64, complex128}")
    .SetShapeFn(shape_inference::MatMulShape)
    .Doc(R"doc(
Multiply the matrix "a" by the matrix "b".
The inputs must be two-dimensional matrices and the inner dimension of
"a" (after being transposed if transpose_a is true) must match the
outer dimension of "b" (after being transposed if transposed_b is
true).
*Note*: The default kernel implementation for MatMul on GPUs uses
cublas.
transpose_a: If true, "a" is transposed before multiplication.
transpose_b: If true, "b" is transposed before multiplication.
)doc");

此行程式碼為MatMul運算定義了一個介面:具體來講,我們可以利用REGISTER_OP巨集對該運算作出以下描述:

  • 名稱: MatMul
  • 引數: a、b
  • 屬性(可選引數): transpose_a、transpose_b
  • 支援的模板T型別: half,      float, double, int32, complex64, complex128
  • 輸出形式: 自動推斷
  • 文件

這套巨集不會呼叫任何C++程式碼,但我們可以從中看到,在對一項運算進行定義時,即使使用一套模板,我們亦必須保證其中的T型別(或者屬性)存在於受支援型別列表當中。實際上,.Attr(“T: {half, float, double, int32, complex64, complex128}”)屬性會將T型別約束為該列表當中的一個具體值。

正如教程當中所提到,即使是在使用模板T時,我們同樣需要面向各受支援過載明確進行核心註冊。此核心採用CUDA方式以引用以併發方式執行的各C/C++函式。

正因為如此,MatMul的作者決定僅支援以上列出的幾種型別,並將int64排除在外。其作出這一決定的理由有二:

  1. 用於監督:有可能是這樣,畢竟TensorFlow的作者仍然是人類!
  2. 為了支援那些無法完全支援int64運算的裝置——具體來講,一部分受支援硬體可能無法充分完成這類運算過程。

再回到問題身上來:現在解決辦法已經非常明確。我們需要將受支援型別的引數傳遞至MatMul處。

這裡我們建立attempt3.go以利用int32引用每一行程式碼中的int64。

這裡只需要注意一點:Go繫結擁有自己的一組型別,且其與Go型別(幾乎)屬於1:1對映關係。當我們將各值包饋送至圖形當中時,我們必須尊重這一原始對映關係(例如在定義tf.Int32佔位符時饋送Int32)。在從圖形中提取數值時同樣遵循此理。 返回自Tensor評估的*tf.Tensor型別擁有Value()方法,而此方法則返回一個必須被轉換為正確型別的interface{}(這一點已經在圖形架構當中有所體現)。

編譯並執行go run attempt3.go。結果如下:

input/Placeholder input_1/Placeholder
[[210] [-210]]

萬歲!

到這裡,我們已經展示了完整的attempt3程式碼; 大家可以對其進行構建與執行(當然,如果發現了改進空間,您亦可為其作出貢獻)。

提問時間:

到現在,我們瞭解到關於TensorFlow架構的哪些結論? 每一項運算都擁有自己的一組關聯核心。作為一種描述性語言,TensorFlow屬於強型別語言。其不僅要求使用者遵守C++型別規則,同時亦要求在運算註冊階段指定特定型別方可實現功能。

總結

通過利用Go語言定義並執行圖形,我們得以更好地理解TensorFlow框架的底層結構。而通過試錯法,我們亦得以一步步解決各個簡單問題,最終掌握與圖形、節點以及型別系統相關的重要知識。

相關文章