- 原文地址:Understanding Tensorflow using Go
- 原文作者:Paolo Galeone
- 譯文出自:掘金翻譯計劃
- 譯者:lsvih
- 校對者:whatbeg,yifili09
用 Go 語言理解 Tensorflow
Tensorflow 並不是一個嚴格意義上的機器學習庫,它是一個使用圖來表示計算的通用計算庫。它的核心功能由 C++ 實現,通過封裝,能在各種不同的語言下執行。它的 Golang 版和 Python 版不同,Golang 版 Tensorflow 不僅能讓你通過 Go 語言使用 Tensorflow,還能讓你理解 Tensorflow 的底層實現。
封裝
根據官方說明,Tensorflow 開發者釋出了以下內容:
C++ 原始碼:底層和高層的具體功能由 C ++ 原始碼實現,它是真正 Tensorflow 的核心。
Python 封裝與Python 庫:由 C++ 實現自動生成的封裝版本,通過這種方式我們可以直接用 Python 來呼叫 C++ 函式:這也是 numpy 的核心實現方式。
Python 庫通過將 Python 封裝版的各種呼叫結合起來,組成了各種廣為人知的高層 API。
Java 封裝
Go 封裝
作為一名 Gopher 而非一名 java 愛好者,我對 Go 封裝給予了極大的關注,希望瞭解其適用於何種任務。
譯註,這裡說的”封裝“也有說法叫做”語言介面“
Go 封裝
圖為 Gopher(由 Takuya Ueda @tenntenn 建立,遵循 CC 3.0 協議)與 Tensorflow 的 Logo 結合在一起。
首先要注意的是,程式碼維護者自己也承認了,Go API 缺少 Variable
支援,因此這個 API 僅用於使用訓練好的模型,而不能用於進行模型訓練。
在文件 Installing Tensorflow for Go 中已經明確提到:
TensorFlow 為 Go 程式設計提供了一些 API。這些 API 特別適合載入在 Python 中建立的模型,讓其在 Go 應用 中執行。
如果我們對訓練機器學習模型沒興趣,那這個限制是 OK 的。
但是,如果你打算自己訓練模型,請看下面給的建議:
作為一名 Gopher,請讓 Go 保持簡潔!使用 Python 去定義、訓練模型,在這之後你隨時都可以用 Go 來載入訓練好的模型!(意思就是他們懶得開發唄)
簡而言之,golang 版 tensorflow 可以匯入與定義常數圖(constant graph)。這個常數圖指的是在圖中沒有訓練過程,也沒有需要訓練的變數。
讓我們用 Golang 深入研究 Tensorflow 吧!首先建立我們的第一個應用。
我建議讀者在閱讀下面的內容前,先準備好 Go 環境,以及編譯、安裝好 Tensorflow Go 版(編譯、安裝過程參考 README)。
理解 Tensorflow 的結構
先複習一下什麼是 Tensorflow 吧!(這是我個人的理解,和官網的有所不同)
TensorFlow™ 是一個採用資料流圖(data flow graphs),用於數值計算的開源軟體庫。節點(Nodes)在圖中表示數學操作,圖中的線(edges)則表示在節點間相互聯絡的多維資料陣列,即張量(tensor)。
我們可以把 Tensorflow 看做一種類似於 SQL 的描述性語言,首先你得確定你需要什麼資料,它會通過底層引擎(資料庫)分析你的查詢語句,檢查你的句法錯誤和語法錯誤,將查詢語句轉換為私有語言表示式,進行優化之後運算得出計算結果。這樣,它能保證將正確的結果傳達給你。
因此,我們無論使用什麼 API 實質上都是在描述一個圖。我們將它放在 Session
中作為求值的起點,這樣做確定了這個圖將會在這個 Session 中執行。
瞭解這一點,我們可以試著定義一個計算操作的圖,並將其放在一個 Session
中進行求值。
API 文件中明確告知了 tensorflow
(簡稱 tf
)包與 op
包中的可用方法列表。
在這個列表中我們可以看到,這兩個包中包含了一切我們需要用來定義與評價圖的方法。
tf
包中包含了各種構建基礎結構的函式,例如 Graph
(圖)。op
包是最重要的包,它包含了由 C++ 實現自動生成的繫結等功能。
現在,假設我們要計算 AAA 與 xxx 的矩陣乘法:
我假定你們都熟悉 tensorflow 圖的定義,都瞭解 placeholder 並知道它們的工作原理。
下面的程式碼是一位 Tensorflow Python 使用者第一次嘗試時會寫的程式碼。讓我們給這個檔案取名為 attempt1.go
。
package main
import (
"fmt"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)
func main() {
// 第一步:建立圖
// 首先我們需要在 Runtime 定義兩個 placeholder 進行佔位
// 第一個 placeholder A 將會被一個 [2, 2] 的 interger 型別張量代替
// 第二個 placeholder x 將會被一個 [2, 1] 的 interger 型別張量代替
// 接下來我們要計算 Y = Ax
// 建立圖的第一個節點:讓這個空節點作為圖的根
root := op.NewScope()
// 定義兩個 placeholder
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 輸入的 op 節點
product := op.MatMul(root, A, x)
// 每次我們傳遞一個域給一個操作的時候,
// 我們都要將操作放在在這個域下。
// 如你所見,現在我們已經有了一個空作用域(由 newScope)建立。這個空作用域
// 是我們圖的根,我們可以用“/”表示它。
// 現在讓 tensorflow 按照我們的定義建立圖吧。
// 依據我們定義的 scope 與 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())
}
// 為了使用 placeholder,我們需要建立傳入網路的值的張量
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'
等等,為什麼會這樣呢?
問題很明顯。上面程式碼裡出現了 2 個重名的“Placeholder”操作。
第 1 課:node IDs
每次在我們呼叫方法定義一個操作的時候,不管他是否在之前被呼叫過,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}))複製程式碼
我們可以驗證一下這個問題,看看程式是否建立了兩個不同的 placeholder 節點: print(a.name, b.name)
它列印出 Placeholder:0 Placeholder_1:0
。
這樣就清楚了,a
placeholder 是 Placeholder:0
而 b
placeholder 是 Placeholder_1:0
。
但是在 Go 中,上面的程式會報錯,因為 A
與 x
都叫做 Placeholder
。我們可以由此得出結論:
每次我們呼叫定義操作的函式時,Go API 並不會自動生成新的名稱。因此,它的操作名是固定的,我們沒法修改。
提問時間:
關於 Tensorflow 的架構我們學到了什麼?
圖中的每個節點都必須有唯一的名稱。所有節點都是通過名稱進行辨認。
節點名稱與定義操作符的名稱是否相同?
是的,也可說節點名稱是操作符名稱的最後一段。
接下來讓我們修復節點名稱重複的問題,來弄明白上面的第二個提問。
第 2 課:作用域
正如我們所見,Python API 在定義操作時會自動建立新的名稱。如果研究底層會發現,Python API 呼叫了 C++ Scope
類中的 WithOpName
方法。
下面是該方法的文件及特性,參考 scope.h:
/// 返回新的作用域。所有在返回的作用域中的 op 都會被命名為
/// <name>/<op_name>[_<suffix].
Scope WithOpName(const string& op_name) const;複製程式碼
注意這個方法,返回一個作用域 Scope
來對節點進行命名,因此節點名稱事實上就是作用域 Scope
。
Scope
就是從根 /
(空圖)追溯至 op_name
的完整路徑。
WithOpName
方法在我們嘗試新增一個有著相同的 /
到 op_name
路徑的節點時,為了避免在相同作用域下有重複的節點,會為其加上一個字尾 _<suffix>
(<suffix>
是一個計數器)。
瞭解了以上內容,我們可以通過在 type Scope
中尋找 WithOpName
來解決重複節點名稱的問題。然而,Go tf API 中沒有這個方法。
如果查閱 type Scope 的文件,我們可以看到唯一能返回新 Scope
的方法只有 SubScope(namespace string)
。
下面引用文件中的內容:
SubScope 將會返回一個新的 Scope,這個 Scope 能確保所有的被加入圖中的操作都被放置在 ‘namespace’ 的名稱空間下。如果這個名稱空間和作用域中已經存在的名稱空間衝突,將會給它加上字尾。
這種加字尾的衝突處理和 C++ 中的 WithOpName
方法不同,WithOpName
是在操作名後面加suffix
,它們都在同樣的作用域內(例如 Placeholder
變成 Placeholder_1
),而 Go 的 SubScope
是在作用域名稱後面加 suffix
。
這將導致這兩種方法會生成完全不同的圖(節點在不同的作用域中了),但是它們的計算結果卻是一樣的。
讓我們試著改一改 placeholder 定義,讓它們定義兩個不同的節點,然後列印 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)))複製程式碼
改成
// 在根定義域下定義兩個自定義域,命名為 input。這樣
// 我們就能在根定義域下擁有 input/ 和 input_1/ 兩個定義域了。
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 的架構我們學到了什麼?
節點完全由其定義所在的作用域標識。這個”作用域“是我們從圖的根節點追溯到指定節點的一條路徑。有兩種方法來定義執行同一種操作的節點:1、將其定義放在不同的作用域中(Go 風格)2、改變操作名稱(我們在 C++ 中可以這麼做,Python 版會自動這麼做)
現在,我們已經解決了節點命名重複的問題,但是現在我們的控制檯中出現了另一個問題:
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
的型別。
Value for attr ‘T’ of int64 is not in the list of allowed values: half, float, double, int32, complex64, complex128
上面這個列表是什麼?為什麼我們能計算 2 個 int32
矩陣的乘積卻不能計算 int64
的乘積?
下面我們將解決這個問題。
第 3 課: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.複製程式碼
這幾行程式碼為 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
的型別限制在了這個型別列表中。
tensorflow 教程中提到,當時模版 T
時,我們需要對所有支援的過載運算在核心進行註冊。這個核心會使用 CUDA 方式引用 C/C++ 函式,進行併發執行。
MatMul
的作者可能是出於以下 2 個原因僅支援上述型別而將 int64
排除在外的:
- 疏忽:這個是有可能的,畢竟 Tensorflow 的作者也是人類呀!
- 為了支援不能使用
int64
的裝置,可能這個特性的核心實現不能在各種支援的硬體上執行。
回到我們的問題中,已經很清楚如何解決問題了。我們需要將 MatMul
支援型別的引數傳給它。
讓我們建立 attempt3.go
,將所有 int64
的地方都改成 int32
。
有一點需要注意:Go 封裝版 tf 有自己的一套型別,基本與 Go 本身的型別 1:1 相對映。當我們要將值傳入圖中時,我們必須遵循這種對映關係(例如定義 tf.Int32
型別的 placeholder 時要傳入 int32
)。從圖中取值同理。
*tf.Tensor
型別將會返回一個張量 evaluation,它包含一個 Value()
方法,此方法將返回一個必須轉換為正確型別的 interface{}
(這是從圖的結構瞭解到的)。
執行 go run attempt3.go
,得到結果:
input/Placeholder input_1/Placeholder
[[210] [-210]]複製程式碼
成功了!
下面是 attempt3
的完整程式碼,你可以編譯並執行它。(這是一個 Gist,如果你發現有啥可以改進的話歡迎來gist.github.com/galeone/096…
package main
import (
"fmt"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
)
func main() {
// 第一步:建立圖
// 首先我們需要在 Runtime 定義兩個 placeholder 進行佔位
// 第一個 placeholder A 將會被一個 [2, 2] 的 interger 型別張量代替
// 第二個 placeholder x 將會被一個 [2, 1] 的 interger 型別張量代替
// 接下來我們要計算 Y = Ax
// 建立圖的第一個節點:讓這個空節點作為圖的根
root := op.NewScope()
// 定義兩個 placeholder
// 在根定義域下定義兩個自定義域,命名為 input。這樣
// 我們就能在根定義域下擁有 input/ 和 input_1/ 兩個定義域了。
A := op.Placeholder(root.SubScope("input"), tf.Int32, op.PlaceholderShape(tf.MakeShape(2, 2)))
x := op.Placeholder(root.SubScope("input"), tf.Int32, op.PlaceholderShape(tf.MakeShape(2, 1)))
fmt.Println(A.Op.Name(), x.Op.Name())
// 定義接受 A 與 x 輸入的 op 節點
product := op.MatMul(root, A, x)
// 每次我們傳遞一個域給一個操作的時候,
// 我們都要將操作放在在這個域下。
// 如你所見,現在我們已經有了一個空作用域(由 newScope)建立。這個空作用域
// 是我們圖的根,我們可以用“/”表示它。
// 現在讓 tensorflow 按照我們的定義建立圖吧。
// 依據我們定義的 scope 與 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())
}
// 為了使用 placeholder,我們需要建立傳入網路的值的張量
var matrix, column *tf.Tensor
// A = [ [1, 2], [-1, -2] ]
if matrix, err = tf.NewTensor([2][2]int32{{1, 2}, {-1, -2}}); err != nil {
panic(err.Error())
}
// x = [ [10], [100] ]
if column, err = tf.NewTensor([2][1]int32{{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().([][]int32))
}
}複製程式碼
提問時間:
關於 Tensorflow 的架構我們學到了什麼?
每個操作都有自己的一組關聯核心。Tensorflow 是一種強型別的描述性語言,它不僅遵循 C++ 型別規則,同時要求在 op 註冊時需定義好型別才能實現其功能。
總結
使用 Go 來定義與處理一個圖讓我們能夠更好地理解 Tensorflow 的底層結構。通過不斷地試錯,我們最終解決了這個簡單的問題,一步一步地掌握了圖、節點以及型別系統的知識。
如果你覺得這篇文章有用,請點個贊或者分享給別人吧~
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。