【JSConf EU 2018】用 JavaScript 來在客戶端上訓練和執行機器學習模型

洛園發表於2018-06-13

簡介機器學習與TensorFlow.js

從人臉識別到自動駕駛,從機器翻譯到遊戲AI,機器學習已經是如今計算機應用領域當仁不讓的明星。其只需改變訓練所用的資料集即可適應不同的應用場景的特性,更是讓論者如 Pedro Domingos 期待這條路的終點或可找到能夠徹底理解世界規律的“終極演算法”。一直以來,這個重要的領域由 Python 和 C++ 主導。前者是資料科學家的萬能膠水,後者則承擔著對接硬體與優化效能的苦工。但是近年來業界不斷爆出資料安全和隱私醜聞,使得人們對於服務提供者是否有能力保護其受託代管的資料,以及是否有足夠自律不濫用資料,心生疑慮。緩解這種疑慮的一種方法是在客戶端處理使用者資料,而不將資料上傳到別處。但如果想要在客戶端利用使用者的資料來優化模型,又沒有如谷歌或蘋果那樣對客戶端平臺的壟斷地位,就必須使用 JavaScript 的執行環境。 TensorFlow.js 應運而生。

根據 StackOverflow 的調查資料,JavaScript 已經連續六年蟬聯使用者最多的程式語言。但是由於 JavaScript 原本設計用於瀏覽器環境,即使有了可在伺服器上執行的 Node.js ,其開發者生態也多偏重網頁應用,並未重視機器學習領域。希望 TensorFlow.js 能夠將機器學習的威能帶給 JavaScript 開發者,也將 JavaScript 社群的多樣化融入機器學習社群,交流提高。

本文接下來從機器學習的基本概念開始,簡單介紹 TensorFlow.js 的使用方法,並介紹如何用 TensorFlow.js 與一個已經訓練好的影象分類模型,利用一種叫做遷移學習的方法,在客戶端利用使用者資料,實現一個用攝像頭控制的《吃豆人》遊戲。

簡介機器學習基本概念

機器學習是人工智慧分支中的一支。與人工智慧類似的概念,最早在 Ada Lovelace 的書信中就有提及,可說是從襁褓就伴隨著電腦科學的關鍵領域。在探索如何用計算來模擬智慧的征途上,學界提出了三條路徑:模擬理性思維;模擬學習能力;模擬智慧行為。機器學習即是來自第二條路上的成果。它旨在讓計算過程模擬人類獲取知識的學習過程,從大量經驗資料中總結出規律。某種程度上,它是對統計學回歸方法的延伸擴充套件。

現代機器學習大部分是圍繞張量(Tensor)進行的。張量是對標量(即單個數字)和向量(亦可理解為陣列)的邏輯延伸;標量是零維張量,向量是一維張量。在此之上則有二維張量(矩陣),三維張量(矩陣組)等。在機器學習的實際應用中,通常使用向量描述模型的輸入,如一張黑白圖片中的所有畫素的灰度值;使用標量或向量描述模型的輸出,如輸入圖片的意義是數字“1”的概率。

目前機器學習使用的主流資料結構是神經網路。名符其實,神經網路是從人腦的神經元連線網路中獲取靈感,將統計學回歸過程連線成一個網路。如圖所示:

人工前饋神經網路結構圖示一例,包含一個三節點輸入層,一個四節點隱藏層,一個兩節點輸出層。
人工前饋神經網路結構圖示一例,包含一個三節點輸入層,一個四節點隱藏層,一個兩節點輸出層。圖片來源:commons.wikimedia.org/wiki/File:A… 作者:Cburnett 共享協議:CC-BY-SA 3.0

上圖中,輸入層和輸出層各自由一個一維張量表示。輸入和輸出的每一個節點可以根據需要解決的問題不同,擁有的資料不同,需要的輸出不同,而表達不同的意義。在經典手寫識別問題 MNIST 中,輸入是一張含有手寫數字的黑白圖片中所有畫素的灰度值,而輸出是數字“0”到數字“9”的概率。

神經網路的隱藏層是其關鍵。在隱藏層的節點中,對於每一個連線,會進行線性組合,但是組合的結果會再輸入到一個非線性函式中,以模擬神經元的激發。這個非線性函式同線性組合以及神經網路的結構一起,使得整個神經網路得以近似各種各樣複雜的函式,以作出識別人臉、下圍棋等看上去富含智慧的行動。一般將這個非線性函式稱為啟用函式。

圖中可見每一層的每一個節點都同下一層的每一個節點相連,因此輸入層和隱藏層之間有 3 * 4 = 12 個連線,而隱藏層和輸出層之間有 4 * 2 = 8 個連線。層與層之間的連線即是以二維張量(矩陣或二維陣列)表示,可以方便地通過前一層的節點編號和下一層的節點編號來獲取連線的權重。更重要的是,利用張量表示法,可以利用硬體加速的線性代數運算來顯著加快訓練模型和執行模型的速度。

簡介機器學習模型的訓練

請回想一下自己記憶一個書本上新知識點的過程。這個過程可能是這樣:在閱讀幾遍後,嘗試默背知識點的內容,然後對照書本上原來的內容,發現不同之處,再加以修正。機器學習即是模仿這個過程。書本上的內容即輸入,記憶後再回想起的內容即輸出,在這個特殊的例子中,書本上的內容也同時是標準的輸出。訓練一個機器學習模型的過程像是這樣:將輸入放入神經網路,得到其輸出,測量網路輸出和標準輸出之間的偏差,再根據偏差修正網路中的權重。一般將這一過程中所用到的輸入和標準輸出統稱為訓練用資料,將用來測量偏差的工具稱為損失函式,將根據偏差修正權重的過程稱為反向傳播。

損失函式和反向傳播的過程是緊密相連的。如同記憶知識點時希望將知識點完整正確地記住,反向傳播的目的是通過調整網路權重,來將損失函式的值降到最低。因此反向傳播問題可以認為是一個求函式最小值的問題。這一問題在這裡採用的解法是隨機梯度下降。請想象一片地貌,有平原、山脈、丘陵和盆地。如果將這一片地貌近似地看作以經度和緯度為引數,以海拔高度為輸出的函式,那麼梯度就是讀者坐在地貌的任一點上,受重力作用滑動的方向。隨機梯度下降,就是隨著重力滑到地貌的最低點的過程。讀者可能已經想到一些這個過程中可能發生的問題,但限於筆者智識及本文篇幅,無法詳述,慚愧。

進行隨機梯度下降的過程中,有一個不得不考慮的細節。神經網路的運算是由一個個節點的運算組成,因此梯度下降需要修正每個節點之間連線的權重,但是損失函式只描述神經網路整體的輸出偏差。如何讓損失函式參與到每個節點的修正中呢?其實,損失函式包含神經網路的輸出函式,而網路整體的輸出函式包含每個節點的啟用函式。因此,求損失函式相對於輸入資料的梯度時,由於鏈式法則,其實已經需要對每個節點的啟用函式也求導數。這樣,在輸出層計算的損失函式的梯度,就通過鏈式法則一層一層向輸入層,即反向,傳播回去,在途徑每一層時修正那一層與前一層的連線權重。這也就是反向傳播之名的由來。

利用節點的啟用函式導數修正權重是訓練機器學習模型中運算量最大的操作,因此學界一直致力於找到更有效率的啟用函式。傳統上學界使用的是與統計學中的指數迴歸相同的 Sigmoid 函式,其導數正好是 sigmoid * (1 - sigmoid) ,易於計算。近期的新成果則普遍採用一個分段函式,f(x) = x 如 x ≥ 0; 否則 f(x) = 0,名之線性修正單元(ReLU)。它放棄不太重要的在 0 處的連續性,在 ≥0 時導數就是 1 ,無需計算,在不影響訓練結果的前提下,顯著加快了訓練速度。

回到記憶知識點的類比,當知識點已經記住,修正就不再必要,學習的過程也就告一段落。對於機器學習而言,如果損失函式降到一個谷底無法再降,也標誌著訓練的告一段落。訓練是否真的成功,還要看訓練得到損失函式值是否達標。就如同考試不及格需要繼續學習補考,機器學習的結果如果不及格,也要回頭反省,調整再來。

簡介 TensorFlow.js 與遷移學習

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 檔案。為了簡潔,筆者省略了一些程式碼中原本的英文註釋。

// [第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 來建立一個新的僅有三層的模型。

  1. 第一層是用 tf.layers.flatten 建立的對接 MobileNet 隱藏層的輸入層;
  2. 第二層是用 tf.layers.dense 建立的隱藏層,使用的啟用函式是 ReLU ;
  3. 第三層是用 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();
}
複製程式碼

此處開始利用上文訓練好的模型來將攝像頭拍到的畫面分類到上下左右其中一類。

  1. webcam.capture 來獲取攝像頭的畫面。這個函式是由示例中 webcam.js 模組定義的;
  2. 將畫面傳入 mobilenet.predict ,來獲取 MobileNet 的隱藏層結果;
  3. 將 MobileNet 的隱藏層結果傳入上文訓練好的 model.predict ,得到最終的分類結果,上下左右四類的概率;
  4. predictions.as1D().argMax() 得到概率最大的分類,作為最終的分類結果;

另外值得注意的是, tf.tidypredictedClass.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 與機器學習有所幫助。

註記

  1. 本文結構及遷移學習的例子來自 Ashi Krishnan 在 JSConf EU 2018 上所作的演講 Deep Learning in JS 。 如果對於機器學習有興趣,且英語過關,筆者十分推薦接下來完整觀看 Ashi 的演講,在她鮮明易懂的圖例和優美的書法中獲得對 JavaScript 中的深度學習更詳細的理解。 演講介紹及 Ashi 的個人簡介:2018.jsconf.eu/speakers/as…; 演講錄影:www.youtube.com/watch?v=SV-…
  2. 本文涉及的眾多知識點及擴充閱讀,可參考《最全的 DNN 概述論文:詳解前饋、卷積和迴圈神經網路技術》:zhuanlan.zhihu.com/p/29141828
  3. TensorFlow.js 的官方網站:js.tensorflow.org/
  4. 本文涉及卷積神經網路的內容可擴充閱讀《CS231n Convolutional Neural Networks for Visual Recognition》:cs231n.github.io/convolution…

相關文章