當前位置:
首頁 > 科技 > 代碼詳解:通過模擬API來理解TensorFlow

代碼詳解:通過模擬API來理解TensorFlow

全文共6530字,預計學習時長13分鐘

TensorFlow是一個非常強大的開源庫,用於實現和部署大規模機器學習模型。多年來,TensorFlow已成為最受歡迎的深度學習庫之一。

這篇文章的目的是建立對深度學習庫,特別是TensorFlow的工作原理的理解。為了實現這一目標,我們將模仿Tensor的API並從頭開始實施其核心構建塊。你將對內部工作有一個深刻的概念性理解,進一步了解變數、張量、會話或操作等內容。

理論

TensorFlow是一個由兩個核心構建塊組成的框架——用於定義計算圖的庫和用於在各種不同硬體上執行此類圖形的運行。計算圖有很多優點,但之後會有更多優點。

現在你可能會問的問題是,計算圖究竟是什麼?

計算圖

簡而言之,計算圖是將計算描述為有向圖的抽象方式。有向圖是由節點(頂點)和邊組成的數據結構。它是由有向邊成對連接的一組頂點。

這是一個非常簡單的例子:

有向無環圖的簡單示例

圖形具有多種形狀和大小,用於解決許多現實問題,例如代表網路,包括電話網路、電路網路、道路網路甚至社交網路。它們也常用於計算機科學中以描述相關性,用於調度或在編譯器中用於表示直線代碼(沒有循環和條件分支的語句序列)。使用後者的圖表允許編譯器有效地消除公共子表達式。

當然,在技術面試中,它們經常被用來拷問候選人。

現在我們對有向圖有了基本的了解,讓我們回到計算圖。

TensorFlow在內部使用有向圖來表示計算,他們稱之為數據流圖(或計算圖)。

雖然有向圖中的節點可以是任何東西,但計算圖中的節點主要表示操作、變數或佔位符。

操作根據特定規則創建或操作數據。在TensorFlow中,這些規則稱為Ops,是操作(operations)的縮寫。另一方面,變數也代表可以通過對這些變數運行Ops來操縱的共享持久狀態。

邊緣對應於流經不同操作的數據或多維數組(所謂的張量)。換句話說,邊緣將信息從一個節點傳送到另一個節點。一個操作(一個節點)的輸出成為另一個操作的輸入,連接兩個節點的邊緣攜帶該值。

這是一個非常簡單的程序示例:

為了從該程序中創建計算圖,我們為程序中的每個操作創建節點,以及輸入變數a和b。實際上,如果a和b不改變,它們可能是常數。如果一個節點用作另一個操作的輸入,我們繪製從一個節點到另一個節點的有向箭頭。

該程序的計算圖可能如下所示:

代表簡單程序及其數據流的計算圖

此圖表從左到右繪製,但也可以找到從上到下繪製的圖形,反之亦然。我選擇前者的原因僅僅是因為我覺得它更具可讀性。

上面的計算圖表示我們需要執行的不同計算步驟,以達到最終結果。首先,創建兩個常量a和b。然後,將它們相乘,得到它們的總和並使用這兩個操作的結果將一個除以另一個。最後,列印出結果。

這不是很難,但問題是為什麼我們需要一個計算圖?將計算組織為有向圖有什麼好處?

首先,計算圖是描述計算機程序及其計算的更抽象的方式。在最基本的層面上,大多數計算機程序主要由兩部分組成——原始操作和執行這些操作的順序,通常是逐行的。這意味著我們首先將a和b相乘,並且只有當這個表達式被評估時,才會得到它們的總和。因此,程序指定執行的順序,但計算圖形專門指定操作的依賴性。換句話說,這些操作的輸出將如何從一個操作流向另一個操作。

這允許並行或依賴性驅動調度。如果我們看一下計算圖,我們就會看到可以並行執行乘法和加法。因為這兩個操作並不相互依賴。因此,我們可以使用圖的拓撲來驅動操作的調度並以最有效的方式執行它們。例如, 在一台機器上使用多個GPU,甚至在多台機器上分配執行。TensorFlow正是這樣做的,TensorFlow正是這樣做的,它可以將不依賴於彼此的操作分配給不同的內核,只需要構造一個有向圖,就可以從實際編寫程序的人那裡獲得最少的輸入。

另一個關鍵優勢是便攜性。該圖是與代碼無關的語言表示。因此,如果你想要非常快的效果,我們可以在Python中構建圖形,保存模型(TensorFlow使用協議緩衝區),使用不同的語言(如C )恢復模型。

現在我們已經有了堅實的基礎,來看看構成TensorFlow計算圖的核心部分。 這些是我們稍後將從頭開始重新執行的部分。

TensorFlow基礎

TensorFlow中的計算圖由以下幾部分組成:

· 變數:將TensorFlow變數視為計算機程序中的常規變數。變數可以在任何時間點修改,但不同之處在於它們必須在會話中運行圖形之前進行初始化。 它們代表圖表中的可變參數。變數的一個很好的例子是神經網路中的權重或偏差。

· 佔位符:佔位符允許我們從外部將數據提供到圖表中,而不像變數那樣,它們不需要初始化。佔位符只是定義形狀和數據類型。我們可以將佔位符視為圖中的空節點(empty nodes),稍後會提供該值。它們通常用於輸入和標籤。

· 常量:無法更改的參數。

· 操作:操作表示圖形中執行Tensors計算的節點。

· 圖形:圖形就像一個中心樞紐,它將所有變數、佔位符、常量連接到操作。

· 會話:會話創建運行時,在該運行時執行操作並評估張量。它還分配內存並保存中間結果和變數的值。

還記得一開始我們說過TensorFlow由兩部分組成,一個用於定義計算圖形的庫和一個用於執行這些圖形的運行嗎?這便是圖形和會話。圖形類用於構造計算圖,會話用於執行和評估所有節點或子集節點。延遲執行的主要優點是在計算圖的定義期間,可以構造非常複雜的表達式,而無需直接評估它們並在所需的內存中分配空間。

例如,如果我們使用NumPy定義一個大的矩陣,比如萬億分之一,會立即得到一個內存不足的錯誤。在TensorFlow中,我們將定義一個Tensor,它是多維數組的描述。它可能具有形狀和數據類型,但它沒有實際值。

在上面的代碼片段中,我們使用tf.zeros和np.zeros來創建矩陣,其中所有元素都設置為零。雖然NumPy會立即實例化一萬億個矩陣填充零所需的內存量,但TensorFlow只會聲明形狀和數據類型,但在圖形的這一部分執行之前不會分配內存。

聲明和執行之間的核心區別非常重要,因為這是允許TensorFlow在連接到不同機器的不同設備(CPU,GPU,TPU)上分配計算負載的原因。

有了這些核心構建塊,讓我們將簡單程序轉換為TensorFlow程序。一般來說,這可以分為兩個階段:

1. 計算圖的構建

2. 運行會話

我們的簡單程序在TensorFlow中的運行如下:

我們從導入tensorflow開始。接下來,在with語句中創建一個會話對象。這樣做的好處是在塊執行後會話自動關閉,我們不必自己調用sess.close()。 而且,這些帶有塊的非常常用。

現在,在with-block中,可以開始構造新的TensorFlow操作(節點),從而定義邊緣(Tensors)。例如:

a = tf.constant(15, name="a")

這將創建一個名為a的新Constant Tensor,它將生成值15。該名稱是可選的,但是當你想要查看生成的圖表時非常有用,我們稍後會看到。

但現在的問題是,圖表在哪裡?我們還沒有創建圖表,但已經添加了這些操作。這是因為TensorFlow為當前線程提供了一個默認圖形,它是同一上下文中所有API函數的隱式參數。一般來說,僅僅依靠默認圖就足夠了。但是,對於高級用例,還可以創建多個圖形。

好的,現在可以為b創建另一個常量,並對基本算術運算進行定義,例如乘法、加法和除法。所有這些操作都會自動添加到默認圖表中。

就是這樣!我們完成了第一步並構建了計算圖。現在是時候計算結果了。請記住,到目前為止還沒有評估任何內容,也沒有為這些張量中的任何一個賦予實際數值。我們要做的是運行會話以明確告訴TensorFlow執行圖形。

好的,這個很容易。我們已經創建了一個會話對象,所要做的就是調用sess.run(res)並傳遞一個想要評估的操作(這裡是res)。這將只計算res值所需的計算圖形。這意味著為了計算res,我們必須計算prod和sum以及a和b。最後,可以列印結果,即run()返回的Tensor。

讓我們導出圖形並使用TensorBoard將其可視化:

生成的圖由TensorBoard可視化

這看起來很熟悉,是嗎?

順便說一下,TensorBoard不僅非常適合可視化學習,而且還可以查看和調試計算圖,所以一定要查看它。

好的,上面的內容都只存在於理論層面,讓我們直接進入編碼。

從頭實現TensorFlow的API

我們的目標是模仿TensorFlow的基本操作,以便用自己的API模擬簡單的程序,就像我們剛才用TensorFlow做的那樣。

之前,我們了解了一些核心構建塊,例如Variable,Operation或Graph。 這些是我們想要從頭開始實現的構建塊,所以讓我們開始吧。

圖表

第一個缺失的部分是圖表。圖表包含一組Operationobjects,它們代表計算單位。此外,圖形包含一組佔位符和變數對象,它們表示在操作之間流動的數據單位。

對於我們的實現,我們基本上需要三個列表來存儲所有這些對象。此外,我們的圖需要一個名為as_default的方法,可以調用它來創建一個用於存儲當前圖形實例的全局變數。這樣,在創建操作、佔位符或變數時,不必傳遞對圖形的引用。

讓我們開始吧:

class Graph():

def __init__(self):

self.operations = []

self.placeholders = []

self.variables = []

self.constants = []

def as_default(self):

global _default_graph

_default_graph = self

操作

下一個缺失的部分是操作。回想一下,操作是計算圖中的節點,並在Tensors上執行計算。大多數操作將零或多個張量作為輸入,併產生零個或多個Tensors對象作為輸出。

簡而言之,操作的特徵如下:

1. 它有一個input_nodes列表

2. 實現前向功能

3. 實現後向功能

4. 記住輸出

5. 將其添加到默認圖表中

因此,每個節點只知道它的直接周圍,這意味著它知道它本地輸入和輸出直接傳遞給正在消耗它的下一個節點。

輸入節點是進入此操作的Tensors(≥0)列表。

前向和後向都只是佔位符方法,它們必須由每個特定操作實現。在實行中,在前向傳遞(或前向傳播)期間叫做向前,其計算操作的輸出,而在向後傳遞(或反向傳播)期間叫做向後,其中我們計算關於每個輸入的操作的梯度變數。這並不是TensorFlow的工作方式,但我發現一個操完全自治的操作更容易推理,這意味著它知道如何計算輸出和每個輸入變數的局部梯度。

每個操作都在默認圖表中註冊也很重要。當你想要使用多個圖形時,這會派上用場。

讓我們一步步進行,首先實現基類:

class Operation():

def __init__(self, input_nodes=None):

self.input_nodes = input_nodes

self.output = None

# Append operation to the list of operations of the default graph

_default_graph.operations.append(self)

def forward(self):

pass

def backward(self):

pass

我們可以使用這個基類來實現各種操作。但事實證明,我們將在短時間內實施的操作都是具有兩個參數a和b的操作。為了簡化我們的工作並避免不必要的代碼重複,創建一個BinaryOperation,它只需要將a和b初始化為輸入節點。

class BinaryOperation(Operation):

def __init__(self, a, b):

super().__init__([a, b]

現在,我們可以使用BinaryOperation並實現一些更具體的操作,例如add,multiply,divide或matmul(用於乘以兩個矩陣)。對於所有操作,假設輸入是簡單的標量或NumPy數組。這使得操作實現變得簡單,因為NumPy已經實現了,尤其是更複雜的操作,例如兩個矩陣之間的點積。後者允許我們輕鬆評估一批樣品的圖形,並計算批次中每個觀察的輸出。

class add(BinaryOperation):

"""

Computes a b, element-wise

"""

def forward(self, a, b):

return a b

def backward(self, upstream_grad):

raise NotImplementedError

class multiply(BinaryOperation):

"""

Computes a * b, element-wise

"""

def forward(self, a, b):

return a * b

def backward(self, upstream_grad):

raise NotImplementedError

class divide(BinaryOperation):

"""

Returns the true division of the inputs, element-wise

"""

def forward(self, a, b):

return np.true_divide(a, b)

def backward(self, upstream_grad):

raise NotImplementedError

class matmul(BinaryOperation):

"""

Multiplies matrix a by matrix b, producing a * b

"""

def forward(self, a, b):

return a.dot(b)

def backward(self, upstream_grad):

raise NotImplementedError

佔位符

當我們查看簡單程序及其計算圖時,可以注意到並非所有節點都是操作,尤其是a和b。相反,當我們想要計算會話中圖形的輸出時,它們是必須提供的圖形輸入。

在TensorFlow中,有不同的方法為圖形提供輸入值,例如佔位符、變數或常量。我們已經對其進行了簡要的討論,現在是時候實際執行第一個——佔位符。

class Placeholder():

def __init__(self):

self.value = None

_default_graph.placeholders.append(self)

我們可以看到,佔位符的實現非常簡單。它沒有使用值(即,名稱)進行初始化,並且僅將其自身附加到默認圖形。使用Session.run()的feed_dict可選參數提供佔位符的值,但在實現會話時更多。

常量

我們要實現的下一個構建塊是常量。常量與變數完全相反,因為初始化後它們無法更改。另一方面,變數表示計算圖中的可變參數。例如,神經網路中的權重和偏差。

使用佔位符作為輸入和標籤而不是變數是絕對有意義的,因為它們總是在每次迭代時更改。此外,區別非常重要,因為變數在向後傳遞期間被優化,而常量和佔位符則不是。所以我們不能簡單地使用一個變數來輸入常數。佔位符可以起作用,但這也有點誤用。為了提供這樣的功能,我們引入了常量。

class Constant():

def __init__(self, value=None):

self.__value = value

_default_graph.constants.append(self)

@property

def value(self):

return self.__value

@value.setter

def value(self, value):

raise ValueError("Cannot reassign value.")

在上面的例子中,我們利用了Python的一個特性,以使類更加像常量。

Python中的下劃線具有特定含義。有些實際上只是慣例,有些則由Python解釋器強制執行。使用單個下劃線_大多數是按慣例。因此,如果我們有一個名為_foo的變數,那麼這通常被視為提示開發人員將名稱視為私有。但這並不是解釋器強制執行的任何操作,也就是說,Python在私有變數和公共變數之間沒有這些明顯的區別。

但是後來有雙下劃線__或者也叫做「dunder」。解釋者對待dunder的方式不同,它不僅僅是一個慣例。它實際上適用於命名修改。查看我們的實行的情況,可以看到在類構造函數中定義了一個屬性__value。由於屬性名稱中有雙下劃線,Python會在內部將屬性重命名為_Constant__value,因此它會在屬性前加上類名稱。此功能實際上是為了防止在使用繼承時命名衝突。但是,我們可以結合getter使用此行為來創建一些私有屬性。

我們所做的是創建了一個dunder屬性__value,通過另一個「公開」可用屬性值公開該值,並在有人試圖設置該值時引發ValueError。這樣,API的用戶不能簡單地重新分配值,除非他們會投入更多的工作並發現我們在內部使用dunder。所以它不是真正的常量,更像是JavaScript中的const,但出於我們的目的,它完全沒問題。 至少可以保護價值不被輕易重新分配。

變數

計算圖的輸入與正在調整和優化的「內部」參數之間存在質的差異。例如,採用一個簡單的感知器來計算y = w * x b。雖然x表示輸入數據,但w和b是可訓練的參數,即計算圖中的變數。沒有變數訓練,神經網路是不可能的。在TensorFlow中,變數在調用Session.run()時保持圖中的狀態,這與每次調用運行()時必須提供的佔位符不同。

實現變數很容易。它們需要初始值並將其自身附加到默認圖形。僅此而已。

class Variable():

def __init__(self, initial_value=None):

self.value = initial_value

_default_graph.variables.append(self)

會話

在這一點上,我會說我們對構建計算圖非常有信心,我們已經實現了最重要的構建塊來模擬TensorFlow的API,並使用自己的API重寫簡單的程序。我們必須構建最後一個缺失的部分——那就是會話。

因此,我們必須開始考慮如何計算操作的輸出。如果從頭開始回想起來,這正是會話的作用。它是一個運行時,在其中執行操作並評估圖中的節點。

從TensorFlow我們知道會話有一個運行方法,當然還有其他幾種方法,但我們只對這個特定方法感興趣。

最後,我們希望能夠使用的會話如下:

session = Session()output = session.run(some_operation, { X: train_X # [1,2,...,n_features]})

因此,運行需要兩個參數,一個要執行的操作和一個將圖元素映射到值的dictionary feed_dict。此dictionary用於為圖表中的佔位符提供值。提供的操作是我們要為其計算輸出的圖元素。

為了計算給定操作的輸出,必須在拓撲上對圖中的所有節點進行排序,以確保以正確的順序對其進行執行。這意味著在評估常量a和b之前,我們無法評估Add。

拓撲排序可以被定義為有向非循環圖(DAG)中的節點的排序,其中對於從節點A到節點B的每個有向邊,節點B在排序中出現在A之前。

該演算法非常簡單:

1. 選擇任何未訪問的節點。在示例中,這是圖中傳遞給Session.run()的最後一個計算節點。

2. 通過遞歸迭代每個節點的input_nodes來執行深度優先搜索(DFS)。

3. 如果到達一個沒有更多輸入的節點,請將該節點標記為已訪問並將其添加到拓撲排序中。

這是特定計算圖的演算法動畫插圖:

計算圖的拓撲排序

當我們從Div開始對拓撲計算圖進行拓撲排序時,最終會得到一個排序,其中首先評估常數,然後是操作Mul和Add,最後是Div。請注意,拓撲排序不是唯一的。排序也可以是5,15,Add,Mul,Div,它實際上取決於處理input_nodes的順序。這很有道理,不是嗎?

創建一個微小的實用工具方法,在拓撲上從給定節點開始對計算圖進行排序。

def topology_sort(operation):

ordering = []

visited_nodes = set()

def recursive_helper(node):

if isinstance(node, Operation):

for input_node in node.input_nodes:

if input_node not in visited_nodes:

recursive_helper(input_node)

visited_nodes.add(node)

ordering.append(node)

# start recursive depth-first search

recursive_helper(operation)

return ordering

既然可以對計算圖進行排序並確保節點的順序正確,那麼就可以開始研究實際的會話類了。這意味著創建類並實現運行方法。

我們要做的是以下內容:

1. 拓撲從提供的操作開始對圖表進行排序

2. 遍歷所有節點

3. 區分不同類型的節點並計算其輸出。

按照這些步驟,最終得到一個可能是這樣的結果:

class Session():

def run(self, operation, feed_dict={}):

nodes_sorted = topology_sort(operation)

for node in nodes_sorted:

if type(node) == Placeholder:

node.output = feed_dict[node]

elif type(node) == Variable or type(node) == Constant:

node.output = node.value

else:

inputs = [node.output for node in node.input_nodes]

node.output = node.forward(*inputs)

return operation.output

重要的是區分不同類型的節點,因為每個節點的輸出可以以不同的方式計算。 請記住,在執行會話時,我們只有變數和常量的實際值,但佔位符仍在等待它們的值。因此,當我們計算佔位符的輸出時,必須查找作為參數提供的feed_dict中的值。對於變數和常量,可以簡單地使用它們的值作為輸出,對於操作,必須收集每個input_node的輸出並在操作上調用forward。

哇!我們做到了。至少已經實現了所有需要模擬簡單的TensorFlow程序所需的部分。讓我們看看它是否真的有用。

為此,我們將API的所有代碼放在一個名為tf_api.py的單獨模塊中。現在可以導入此模塊並開始使用已實現的內容。

import tf_api as tf

# create default graph

tf.Graph().as_default()

# construct computational graph by creating some nodes

a = tf.Constant(15)

b = tf.Constant(5)

prod = tf.multiply(a, b)

sum = tf.add(a, b)

res = tf.divide(prod, sum)

# create a session object

session = tf.Session()

# run computational graph to compute the output for "res"

out = session.run(res)

print(out)

當我們運行此代碼時,假設到目前為止已經完成了所有操作,它將正確地列印出3.75到控制台。這正是我們希望看到的輸出。

這看起來有點類似於我們對TensorFlow所做的,對吧?唯一的區別是資本化,這是有意為之。雖然在TensorFlow中,一切都只是一種操作——甚至佔位符和變數——我們並沒有將它們實現為操作。為了做區分,可以將操作用小寫來表示並將其餘部分資本化。


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 讀芯術 的精彩文章:

使用Triplet Networks學習
2019五大頂尖數據科學GitHub項目和Reddit熱帖

TAG:讀芯術 |