從人臉識別到自動駕駛,從機器翻譯到遊戲AI,機器學習已是現在計算機應用領域當仁不讓的明星。其只需改變訓練所用的數據集便可適應不一樣的應用場景的特性,更是讓論者如 Pedro Domingos 期待這條路的終點或可找到可以完全理解世界規律的「終極算法」。一直以來,這個重要的領域由 Python 和 C++ 主導。前者是數據科學家的萬能膠水,後者則承擔着對接硬件與優化性能的苦工。可是近年來業界不斷爆出數據安全和隱私醜聞,使得人們對於服務提供者是否有能力保護其受託代管的數據,以及是否有足夠自律不濫用數據,心生疑慮。緩解這種疑慮的一種方法是在客戶端處理用戶數據,而不將數據上傳到別處。但若是想要在客戶端利用用戶的數據來優化模型,又沒有如谷歌或蘋果那樣對客戶端平臺的壟斷地位,就必須使用 JavaScript 的運行環境。 TensorFlow.js 應運而生。javascript
根據 StackOverflow 的調查數據,JavaScript 已經連續六年蟬聯用戶最多的編程語言。可是因爲 JavaScript 本來設計用於瀏覽器環境,即便有了可在服務器上運行的 Node.js ,其開發者生態也多偏重網頁應用,並未重視機器學習領域。但願 TensorFlow.js 可以將機器學習的威能帶給 JavaScript 開發者,也將 JavaScript 社區的多樣化融入機器學習社區,交流提升。html
本文接下來從機器學習的基本概念開始,簡單介紹 TensorFlow.js 的使用方法,並介紹如何用 TensorFlow.js 與一個已經訓練好的圖像分類模型,利用一種叫作遷移學習的方法,在客戶端利用用戶數據,實現一個用攝像頭控制的《吃豆人》遊戲。java
機器學習是人工智能分支中的一支。與人工智能相似的概念,最先在 Ada Lovelace 的書信中就有說起,可說是從襁褓就伴隨着計算機科學的關鍵領域。在探索如何用計算來模擬智能的征途上,學界提出了三條路徑:模擬理性思惟;模擬學習能力;模擬智能行爲。機器學習便是來自第二條路上的成果。它旨在讓計算過程模擬人類獲取知識的學習過程,從大量經驗數據中總結出規律。某種程度上,它是對統計學回歸方法的延伸擴展。git
現代機器學習大部分是圍繞張量(Tensor)進行的。張量是對標量(即單個數字)和向量(亦可理解爲數組)的邏輯延伸;標量是零維張量,向量是一維張量。在此之上則有二維張量(矩陣),三維張量(矩陣組)等。在機器學習的實際應用中,一般使用向量描述模型的輸入,如一張黑白圖片中的全部像素的灰度值;使用標量或向量描述模型的輸出,如輸入圖片的意義是數字「1」的機率。github
目前機器學習使用的主流數據結構是神經網絡。名符其實,神經網絡是從人腦的神經元鏈接網絡中獲取靈感,將統計學回歸過程鏈接成一個網絡。如圖所示:web
上圖中,輸入層和輸出層各自由一個一維張量表示。輸入和輸出的每個節點能夠根據須要解決的問題不一樣,擁有的數據不一樣,須要的輸出不一樣,而表達不一樣的意義。在經典手寫識別問題 MNIST 中,輸入是一張含有手寫數字的黑白圖片中全部像素的灰度值,而輸出是數字「0」到數字「9」的機率。算法
神經網絡的隱藏層是其關鍵。在隱藏層的節點中,對於每個鏈接,會進行線性組合,可是組合的結果會再輸入到一個非線性函數中,以模擬神經元的激發。這個非線性函數同線性組合以及神經網絡的結構一塊兒,使得整個神經網絡得以近似各類各樣複雜的函數,以做出識別人臉、下圍棋等看上去富含智慧的行動。通常將這個非線性函數稱爲激活函數。編程
圖中可見每一層的每個節點都同下一層的每個節點相連,所以輸入層和隱藏層之間有 3 * 4 = 12 個鏈接,而隱藏層和輸出層之間有 4 * 2 = 8 個鏈接。層與層之間的鏈接便是以二維張量(矩陣或二維數組)表示,能夠方便地經過前一層的節點編號和下一層的節點編號來獲取鏈接的權重。更重要的是,利用張量表示法,能夠利用硬件加速的線性代數運算來顯著加快訓練模型和運行模型的速度。json
請回想一下本身記憶一個書本上新知識點的過程。這個過程多是這樣:在閱讀幾遍後,嘗試默背知識點的內容,而後對照書本上原來的內容,發現不一樣之處,再加以修正。機器學習便是模仿這個過程。書本上的內容即輸入,記憶後再回想起的內容即輸出,在這個特殊的例子中,書本上的內容也同時是標準的輸出。訓練一個機器學習模型的過程像是這樣:將輸入放入神經網絡,獲得其輸出,測量網絡輸出和標準輸出之間的誤差,再根據誤差修正網絡中的權重。通常將這一過程當中所用到的輸入和標準輸出統稱爲訓練用數據,將用來測量誤差的工具稱爲損失函數,將根據誤差修正權重的過程稱爲反向傳播。後端
損失函數和反向傳播的過程是緊密相連的。如同記憶知識點時但願將知識點完整正確地記住,反向傳播的目的是經過調整網絡權重,來將損失函數的值降到最低。所以反向傳播問題能夠認爲是一個求函數最小值的問題。這一問題在這裏採用的解法是隨機梯度降低。請想象一片地貌,有平原、山脈、丘陵和盆地。若是將這一片地貌近似地看做以經度和緯度爲參數,以海拔高度爲輸出的函數,那麼梯度就是讀者坐在地貌的任一點上,受重力做用滑動的方向。隨機梯度降低,就是隨着重力滑到地貌的最低點的過程。讀者可能已經想到一些這個過程當中可能發生的問題,但限於筆者智識及本文篇幅,沒法詳述,慚愧。
進行隨機梯度降低的過程當中,有一個不得不考慮的細節。神經網絡的運算是由一個個節點的運算組成,所以梯度降低須要修正每一個節點之間鏈接的權重,可是損失函數只描述神經網絡總體的輸出誤差。如何讓損失函數參與到每一個節點的修正中呢?其實,損失函數包含神經網絡的輸出函數,而網絡總體的輸出函數包含每一個節點的激活函數。所以,求損失函數相對於輸入數據的梯度時,因爲鏈式法則,其實已經須要對每一個節點的激活函數也求導數。這樣,在輸出層計算的損失函數的梯度,就經過鏈式法則一層一層向輸入層,即反向,傳播回去,在途徑每一層時修正那一層與前一層的鏈接權重。這也就是反向傳播之名的由來。
利用節點的激活函數導數修正權重是訓練機器學習模型中運算量最大的操做,所以學界一直致力於找到更有效率的激活函數。傳統上學界使用的是與統計學中的指數迴歸相同的 Sigmoid 函數,其導數正好是 sigmoid * (1 - sigmoid) ,易於計算。近期的新成果則廣泛採用一個分段函數,f(x) = x 如 x ≥ 0; 不然 f(x) = 0,名之線性修正單元(ReLU)。它放棄不過重要的在 0 處的連續性,在 ≥0 時導數就是 1 ,無需計算,在不影響訓練結果的前提下,顯著加快了訓練速度。
回到記憶知識點的類比,當知識點已經記住,修正就再也不必要,學習的過程也就告一段落。對於機器學習而言,若是損失函數降到一個谷底沒法再降,也標誌着訓練的告一段落。訓練是否真的成功,還要看訓練獲得損失函數值是否達標。就如同考試不及格須要繼續學習補考,機器學習的結果若是不及格,也要回頭檢討,調整再來。
TensorFlow.js 是 TensorFlow 庫在 JavaScript 中的實現。目前其後端支持 CPU 加速和 GPU 加速,前者對接的是 V8 及 Node.js 運行環境,然後者對接的是 WebGL 接口,性能約是本來 C++ 後端的一半。對於本來 C++ 後端的支持會稍後推出。不過考慮到在客戶端瀏覽器中應用的場景,不說 C++ 後端難以部署到客戶端,就是可以使用,以客戶端的硬件性能,也沒法用來訓練大規模的模型。 TensorFlow.js 的主要用法應當是以利用已經訓練好的模型爲主。 TensorFlow.js 對利用已有模型的支持十分到位,能夠轉譯任意的 Keras 的模型爲一個 JSON 文件導入,感興趣的讀者能夠參照官方網站的示例嘗試。
已經訓練好的模型用處雖有,但不能適應用戶的使用習慣來提供更好的服務。在不從新訓練整個模型的前提下,要在客戶端將數據整合到模型當中,就須要遷移學習。簡而言之,遷移學習是取來已經訓練好的模型,砍掉最後的幾層,暴露出本來的一個隱藏層,在其上嫁接一個簡單得多的模型,在客戶端中只訓練嫁接上去的部分,而獲得的一個新模型的過程。新的模型能夠有與原來的模型徹底不一樣的輸出層。好比在《吃豆人》的示例中,利用了爲移動終端優化的小型圖像分類模型 MobileNet ,其本來的輸出層是上萬個圖像類別的機率,在示例中則被嫁接上了一個僅僅輸出四個方向操做的機率的輸出層。
這樣的作法能夠奏效,是因爲 MobileNet 的隱藏層包含了輸入圖像中的規律。 MobileNet 是一個卷積神經網絡。卷積神經網絡是爲了圖像處理而設計的。上文所描述的前饋神經網絡在應用到圖像上時,因爲其隱藏層每一節點與前一層全部節點所有鏈接,在處理經常是上萬、十萬甚至百萬圖像像素的輸入層時,鏈接的數量呈指數級增加,計算需求太大。卷積神經網絡吸收了對動物大腦視覺處理區域的研究成果帶來的靈感,其隱藏層並不是每一個節點所有鏈接到前一層的全部節點,而是僅鏈接到前一層對應的一部分節點,這種對應鏈接表現爲一個隱藏層中的每一個像素與前一層位置對應的一個邊長几像素的正方形窗口相鏈接。這個窗口被稱爲卷積核。好比,某一隱藏層的 (1,1) 像素與前一層從 (0,0) 到 (2,2) 的邊長爲 3 個像素的正方形窗口相鏈接; (1,2) 與 (0,1) 到 (2,3) 相鏈接;以此類推。這樣,上一層對應的窗口中出現的圖形規律,好比是否出現物體的邊緣,就能夠被隱藏層捕捉到。因爲捕捉的結果常常像是在原圖上加了濾鏡,因此卷積核也稱爲卷積濾鏡。在 MobileNet 的隱藏層上嫁接一個小模型,小模型就能夠經過不多的訓練,化隱藏層所提取出的圖像的規律爲己用。
接下來筆者按順序摘取幾段重要的示例代碼,展現 TensorFlow.js 的實際應用。示例代碼來自 TensorFlow.js 官方 Github 倉庫。如下代碼來自其中的 index.js
文件。爲了簡潔,筆者省略了一些代碼中本來的英文註釋。
index.js
文件版本連接:github.com/tensorflow/…// [第18行]
import * as tf from '@tensorflow/tfjs';
複製代碼
新的 JavaScript 標準已經支持在瀏覽器環境中使用模塊系統和 import
語句。此處將 TensorFlow.js 的接口導入到 tf
名下。
// [第39行]
async function loadMobilenet() {
const mobilenet = await tf.loadModel(
'https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json');
// Return a model that outputs an internal activation.
const layer = mobilenet.getLayer('conv_pw_13_relu');
return tf.model({inputs: mobilenet.inputs, outputs: layer.output});
}
複製代碼
此處使用 loadModel
函數從 URL 中讀取 JSON 文件形式的模型數據,並從中加載模型。而後,使用 getLayer
函數得到指向中間隱藏層的變量,再用 tf.model
截取從輸入層到隱藏層的模型,並返回截取的結果。另外值得注意的是,示例代碼使用 async / await
語法來書寫異步指令。 async
用以標記返回異步結果的函數,而 await
用來等待 async
函數完成,獲取其結果。
// [第26行]
const NUM_CLASSES = 4;
// [第35行]
let model;
// [第64行]
async function train() {
// [第72行]
model = tf.sequential({
layers: [
tf.layers.flatten({inputShape: [7, 7, 256]}),
tf.layers.dense({
units: ui.getDenseUnits(),
activation: 'relu',
kernelInitializer: 'varianceScaling',
useBias: true
}),
tf.layers.dense({
units: NUM_CLASSES,
kernelInitializer: 'varianceScaling',
useBias: false,
activation: 'softmax'
})
]
});
// [...]
}
複製代碼
此處用 tf.sequential
來建立一個新的僅有三層的模型。
tf.layers.flatten
建立的對接 MobileNet 隱藏層的輸入層;tf.layers.dense
建立的隱藏層,使用的激活函數是 ReLU ;tf.layers.dense
建立的對應上下左右四個方向控制的輸出層,使用的激活函數是 Softmax 。async function train() {
// [第97行]
const optimizer = tf.train.adam(ui.getLearningRate());
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});
// [...]
}
複製代碼
此處指定模型的反向傳播算法和損失函數。此處使用的反向傳播算法, tf.train.adam
,是隨機梯度降低的一個優化版本。此處使用的損失函數是 Categorical Cross Entropy ,是分類問題的經典損失函數,用在將圖像分紅上下左右四類的示例中十分合適。
async function train() {
// [第115行]
model.fit(controllerDataset.xs, controllerDataset.ys, {
// [...]
});
}
複製代碼
此處用 model.fit
啓動模型訓練,傳入訓練用數據和一些本文沒有涉及的工程上的參數,所以省略。
// [第129行]
async function predict() {
ui.isPredicting();
while (isPredicting) {
const predictedClass = tf.tidy(() => {
const img = webcam.capture();
const activation = mobilenet.predict(img);
const predictions = model.predict(activation);
return predictions.as1D().argMax();
});
const classId = (await predictedClass.data())[0];
predictedClass.dispose();
ui.predictClass(classId);
await tf.nextFrame();
}
ui.donePredicting();
}
複製代碼
此處開始利用上文訓練好的模型來將攝像頭拍到的畫面分類到上下左右其中一類。
webcam.capture
來獲取攝像頭的畫面。這個函數是由示例中 webcam.js
模塊定義的;mobilenet.predict
,來獲取 MobileNet 的隱藏層結果;model.predict
,獲得最終的分類結果,上下左右四類的機率;predictions.as1D().argMax()
獲得機率最大的分類,做爲最終的分類結果;另外值得注意的是, tf.tidy
和 predictedClass.dispose
這兩個函數是用於指導 TensorFlow.js 進行 GPU 內存管理的。傳給 tf.tidy
的閉包中的 const
常量若是是 Tensor ,那麼就會由 tf.tidy
負責在閉包執行完畢後回收其所佔內存。可是 tf.tidy
不能清理掉返回值,所以在用 predictedClass.data
取得模型分類的結果後,須要用 predictedClass.dispose
回收其所佔用的內存。 tf.tidy
還要求所接受的閉包不能是異步函數,而且不會清理閉包中的 let
變量。合理使用這兩個函數管理內存是優化 TensorFlow.js 實現性能的一個要點。
tf.nextFrame
是 TensorFlow.js 對於 window.requestAnimationFrame
的異步封裝,用來與瀏覽器的重繪時間同步。
本文從機器學習的基礎概念開始,簡要介紹了張量、前饋神經網絡、卷積神經網絡、神經網絡模型訓練、和遷移學習,並以 TensorFlow.js 的《吃豆人》示例項目爲例子,簡短介紹了實際使用 TensorFlow.js 的代碼結構和最佳實踐。拋磚引玉,但願本文對你們入門 TensorFlow 與機器學習有所幫助。