譯者注:本文通過一個簡單的Go繫結例項,讓讀者一步一步地學習到Tensorflow有關ID、作用域、型別等方面的知識。以下是譯文。
Tensorflow並不是機器學習方面專用的庫,而是一個使用圖來表示計算的通用計算庫。它的核心是用C++實現的,並且還有不同語言的繫結。Go語言繫結是一個非常有用的工具,它與Python繫結不同,使用者不僅可以通過Go語言使用Tensorflow,還可以瞭解Tensorflow的底層實現。
繫結
Tensorflow的開發者正式釋出了:
- C++原始碼:真正的Tensorflow核心,實現了具體的高階和低階操作。
- Python繫結和Python庫:這個繫結是由C++實現自動生成的,這樣我們可以使用Python來呼叫C++函式。此外,這個庫將呼叫融合到了繫結中,以便定義更高階別的API。
- Java繫結。
- Go繫結。
作為一個Go開發者而不是一個Java愛好者,我開始關注Go繫結,以便了解他們建立了什麼樣的任務。
Go繫結
首先要注意的是,Go API缺少對Variable的支援:該API旨在使用已經訓練過的模型,而不是從頭開始訓練模型。安裝Tensorflow for Go的時候已經明確說明了:
TensorFlow提供了可用於Go程式的API。這些API特別適合於載入用Python建立並需要在Go程式中執行的模型。
如果我們對培訓ML模型不感興趣,萬歲!相反,如果你對培訓模型感興趣,那就有一個建議:
作為一個真正的Go開發者,保持簡單!使用Python定義並訓練模型;你可以隨時使用Go來載入並使用訓練過的模型!
簡而言之,go繫結可用於匯入和定義常量圖;在這種情況下,常量指的是沒有經過訓練的過程,因此沒有可訓練的變數。
現在,開始用Go來深入學習Tensorflow吧:讓我們來建立第一個應用程式。
在下文中,我假設讀者已經準備好Go環境,並按照README中的說明編譯並安裝了Tensorflow繫結。
理解Tensorflow結構
讓我們來重複一下什麼是Tensorflow:
TensorFlow™是一款使用資料流圖進行數值計算的開源軟體庫。圖中的節點表示數學運算,而圖的邊表示在節點之間傳遞的多維資料陣列(張量)。
我們可以把Tensorflow視為一種描述性語言,這有點像SQL,你可以在其中描述你想要的內容,並讓底層引擎(資料庫)解析你的查詢、檢查句法和語義錯誤、將其轉換為內部表示形式、進行優化並計算出結果:所有這一切都會給你正確的結果。
因此,當我們使用任何一個API時,我們真正做的是描述一個圖:當我們把圖放到Session中並顯式地在Session中執行圖時,圖的計算就開始了。
知道了這一點之後,讓我們試著來定義一個計算圖並在一個Session中進行計算吧。API文件為我們提供了tensorflow(簡寫為 tf)和op包中所有方法的列表。
我們可以看到,這兩個包包含了我們需要定義和計算圖形的所有內容。
前者包含了構建一個基本的“空”結構(就像Graph本身)的功能,後者是包含由C++實現自動生成繫結的最重要的包。
然而,假設我們要計算A與x的矩陣乘法,其中
我假設讀者已經熟悉了tensorflow圖定義的基本思想,並且知道佔位符是什麼以及它們如何工作。下面的程式碼是對Tensorflow Python繫結的第一次嘗試。我們來呼叫這個檔案attempt1.go
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
package main import ( "fmt" tf "github.com/tensorflow/tensorflow/tensorflow/go" "github.com/tensorflow/tensorflow/tensorflow/go/op" ) func main() { // 這裡,我們打算要: 建立圖 // 我們要定義兩個佔位符用於在執行的時候傳入 // 第一個佔位符 A 將是一個 [2, 2] 的整數張量 // 第二個佔位符 x 將是一個 [2, 1] 的整數張量 // 然後,我們要計算 Y = Ax // 建立圖的第一個節點: 一個空的節點,位於圖的根 root := op.NewScope() // 定義兩個佔位符 A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2))) x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1))) // 定義接收A和x作為輸入引數的操作節點 product := op.MatMul(root, A, x) // 每次我們把一個`Scope`穿給操作符的時候,把操作放在作用域的下面。 // 這樣,我們有了一個空的作用域(由NewScope建立):空的作用域是圖的根,因此可以用“/”來表示。 // 現在,我們讓tensorflow按照我們的定義來建立圖。 // 把作用域和OP結合起來,建立具體的圖。 graph, err := root.Finalize() if err != nil { // 這裡沒辦法處理這個錯誤: // 如果我們錯誤的定義了圖,我們必須手工修改這個定義。 // 這就跟SQL查詢一樣:如果查詢語句在語法上有問題,我們只能重新寫 panic(err.Error()) } // 如果在這裡,圖在語法上是正確的。 // 我們就可以把它放到一個Session裡,並執行。 var sess *tf.Session sess, err = tf.NewSession(graph, &tf.SessionOptions{}) if err != nil { panic(err.Error()) } // 要使用佔位符,我們必須建立一個Tensors,這個Tensors包含要反饋到網路的數值 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使用者期望該程式碼進行編譯並正常工作。我們來看看它是否正確:
1 |
go run attempt1.go |
這是他看到的結果:
1 |
panic: failed to add operation "Placeholder": Duplicate node name in graph: 'Placeholder' |
等等,這裡發生了什麼? 顯然,存在兩個名稱都為“Placeholder”的操作。
第一節課: 節點ID
每當我們呼叫一個方法來定義一個操作時,Python API都會生成不同的節點,無論是否已經被呼叫過。下面的程式碼返回3。
1 2 3 4 5 6 |
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中:
1 2 3 |
/// 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是從根 /(空的圖)到op_name的完整路徑。
當我們嘗試新增一個具有與/到op_name相同路徑的節點時,WithOpName方法會新增一個字尾_<suffix>(其中<suffix>是一個計數器),因此它將成為同一範圍內的重複的節點。
知道了這一點之後,為了解決重複節點名的問題,我們期望在Scope型別中找到WithOpName方法。可悲的是,這種方法並不存在。
相反,檢視Scope型別的文件,我們可以看到唯一的一個方法:SubScope,它返回一個新的Scope。
文件裡是這麼說的:
SubScope返回一個新的Scope,這將導致新增到圖中的所有操作都將以“namespace”為名稱空間。如果名稱空間與作用域內現有的名稱空間衝突,則會新增一個字尾。
使用字尾的衝突管理與C++的WithOpName不同:WithOpName是在操作名之後新增字尾,但還是在同一作用域內(因此佔位符變為了Placeholder_1),而Go的SubScope是在作用域名稱後新增字尾。
這種差異會產生完全不同的圖,但它們在計算上是等效的。
我們來改變佔位符的定義,以此來定義兩個不同的節點,此外,我們來列印一下作用域的名稱。
讓我們建立檔案attempt2.go,把這幾行從:
1 2 |
A := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 2))) x := op.Placeholder(root, tf.Int64, op.PlaceholderShape(tf.MakeShape(2, 1))) |
改成:
1 2 3 4 5 |
// 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,輸出結果:
1 |
input/Placeholder input_1/Placeholder |
提問時間:
關於Tensorflow的架構,我們學到了什麼?節點完全是由被定義的作用域來標識的。作用域是我們從圖的根到達節點的路徑。有兩種定義節點的方法:在不同的作用域(Go語言)中定義操作或更改操作名稱。
我們解決了重複節點名稱的問題,但另一個問題顯示在我們的終端上。
1 |
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矩陣!從這段錯誤提示來看,int64是MatMul唯一不接受的型別。
int64型別的attr ‘T’的值不在允許的值列表中:half,float,double,int32,complex64,complex128
這個列表是什麼?為什麼我們可以做兩個int32型別矩陣的乘法,而不是int64?
我們來解決這個問題,瞭解為什麼會出現這種情況。
第三節課:Tensorflow的型別系統
我們來看一下原始碼,尋找MatMul操作的C++宣告:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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巨集來宣告瞭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。有兩個可能的原因:
- 疏忽了:這很有可能,因為Tensorflow的作者是人類!
- 對尚未完全支援int64操作的裝置相容,因此核心的這種具體實現不足以在每個支援的硬體上執行。
回到剛才的錯誤提示:修改方法是顯而易見的。我們必須將引數以支援的型別傳遞給MatMul。
我們來建立attempt3.go,把所有引用int64的行改為int32。
有一點需要注意:Go繫結有自己的一組型別,與Go的型別的一一對應。當我們將值輸入到圖中時,我們必須關注對映關係。從圖形中獲取值時,必須做同樣的事情。
執行go run attempt3.go。結果:
1 2 |
input/Placeholder input_1/Placeholder [[210] [-210]] |
萬歲!
提問時間
關於Tensorflow的架構,我們學到了什麼?每個操作都與自己的一組核心相關聯。被視為描述性語言的Tensorflow是一種強大的型別語言。它不僅要遵守C++型別規則,而且還要在op的註冊階段只實現某些指定型別的能力。
結論
使用Go來定義並執行一個圖,使我們有機會更好地瞭解Tensorflow的底層結構。使用試錯法,我們解決了這個簡單的問題,我們一步一步地學到了有關圖、節點和型別系統這些新東西。