利用 Core ML 3.0 的 API 一步步製作個人化的塗鴉 app


在這幾年來,機器學習 (Machine Learning) 的應用如雨後春筍般大量地出現,也越來越貼近一般人的日常生活,包括語音、影像辨識,語意分析,行為分析等等,都跟機器學習脫離不了關係。雖然機器學習的技術在這幾年來已經十分成熟,不過手機的應用程式在這方面還是大多扮演著媒介資料的角色,由手機端接受使用者來的資料,上傳到 server 完成分析後再將結果回傳到手機端。這個資料傳遞的過程中,手機程式是跟機器學習一點關聯都沒有的。

在 2017 年的 WWDC 上,Apple 推出了 Core ML framework,讓開發者可以用非常間單的方式,將既有的機器學習 model 匯入手機 app,讓手機可以直接利用匯入的 model 來做分類或預測。這個改變讓手機上機器學習的應用有了更多可能,同時也可以讓使用者的資料在使用過程中都一直保持在手機裡,而不需要上傳到雲端。

雖然 Core ML 讓我們能夠在手機上使用已經訓練好的 model,不過如果我們想要再進一步個人化這些 model,比方說提供專屬於個人的臉部辨識功能,技術上就不是那麼的容易。除了個人的臉型資料不足以訓練出完整的 model 之外,也因為使用者通常不會希望這些資料被上傳到別的地方做訓練,而讓在雲端製作個人化 model 特別困難。

所幸,在 2019 年的 WWDC 上, Apple 推出了新版的 Core ML 3.0,在這個新版的 framework 中,Apple 提供了幾個非常方便的 API,讓我們可以直接拿已經在外面訓練好的 model,匯入手機 app 後,在 app 上直接利用少量的個人化資料,來「更新」 model,產生新的個人化機器學習 model,整個過程除了不需要大量資料之外,也不需要把資料上傳到 server 重新做訓練。

在這邊文章裡,我們將要來簡單地介紹一下這個能夠在手機上「更新」model 的技術,還有實際利用 Core ML 3.0 提供的 API 來製作一個個人化的塗鴉 app!

大綱

  • k-Nearest Neighbor (k-NN) classifier
  • Neural Network classifier
  • 行動裝置上的機器學習方案
  • 用 Core ML Tools 製作一個 updatable model
  • 在 app 上更新跟使用 updatable model

我們會先介紹兩個機器學習的 model 當做背景知識,接著會解釋一下如何在手機上訓練一個 model,再來會親手用 Core ML 製作一個可以更新的 model,最後會用一個 app 當範例來看看如何做應用。似乎有點充實啊!

這篇文章並不會討論太多機器學習的技術,如果你已經很熟悉機器學習,那麼你可以跳過前情提要,直接滾到最後一起手把手寫 code 就好。文章也不會從零開始講解機器學習,但是還是會針對 Core ML 背後的一些演算法做簡單介紹。如果你對機器學習完全沒有概念的話,推薦先了解一些專有名詞像是 feature、classifier、model、label 等等,可以參閱這篇文章的前面幾個章節,會有詳盡的名詞介紹。如果你對機器學習很有興趣,在文章最後也有附上相關的參考文章,你可以透過這些文章繼續深入研究!

讓我們開始吧!

K-Nearest Neighbors (k-NN) classifier

想像一下,我們手邊現在有一堆不同形狀的圖形,可能有三角形、方形跟圓形。我們想要用機器來做一個分類器,可以在未來拿到一個新的圖形時,自動判斷這個圖形是三角形、方形還是圓形。

一個可能的做法是,先依照這些圖形的特徵 (feature),把手邊既有的圖分成一群一群,「畫」在某個平面上,像下面這張圖一樣:

圖形的 feature 可以是一維到多維的,如果是 32 x 32 的 RGB 圖形的話,它的 feature 就可以是 32 x 32 x 3 = 3,072 pixel 的 vector,也就是把整張圖的每一個點都當成一種 feature。而上面的圖就是把 feature 簡化成二維的概念圖,實際上的 feature 是畫不出來的。而先把資料分群、再根據分好的群來分類新的資料的這種做法,就是 k-Nearest Neighbors (k-NN)。

你可以把訓練 k-NN 的過程當做是機器先看過所有的資料,把這些資料依照 feature vector 算出彼此之間的距離,把距離相近的圖形圈成一群,分成 k 群「記」在機器的腦袋中。當看到新的資料的時候,就從腦海中回想這個資料跟哪一群的資料比較符合,這樣就可以把新資料分到對應的類別了。

K-NN 的運算量不大,直接在手機上訓練也不會負擔過重。另外,k-NN 要增加類別也非常容易,這個優點很適合拿來訓練個人化的 model:我們不用每次新增一種類別或事件,就要整個 model 重新訓練一遍。對 k-NN 來說,新增一種類別就等同於在原本已經畫好的圖上再放入新的訓練資料,最後調整圈圈的大小分出 k+1 個群就好,非常愜意。

雖然 k-NN 對手機硬體來說十分友善,不過它也有一些先天上的限制:

  • 如果 feature vector 的 dimension 太高,做出來的群就會很不準(參考資料:curse of dimensionality)。像是一張圖片如果大小是 300 x 300,用 pixel 當成 feature 的話產生出來的 feature vector 就會是 270,000 dimension 的 vector,這樣分出來的群很容易會不具代表性。
  • Feature 的選擇對 model 的準確度有非常關鍵的影響。Feature vector 可以直接使用圖片的 pixel 來當做 feature vector,也可以由人類來設定一些 feature 的類別,像是圖片的 meta data、或者經過圖形處理後產生的資訊。不同的 Feature 如果是來自不同領域的資料,還需要考慮加上權重 (weight),讓每一個 feature 的影響力差不多(參考資料:normalization)。
  • 容易受到極端值 (outlier) 的影響:如果有一個極端值,可以想成訓練的時候有一個點因為太特別所以被畫在最邊緣的地方,k-NN 在分群的過程中,會因為要把這個點納入某一群,導致這個群整個往那個特別的點偏移,而原本應該被分到這群的其它點很有可能就會因此掉出這群之外。

考慮到上面這幾樣因素,單純使用 k-NN 可能還沒有辦法讓我們在手機上訓練個人化的 model。

我們再來看看另外一個 Core ML 支援的機器學習 model 類形。

Neural Network classifier

雖然 Neural Network 的概念早在 1969 年就已經被提出來,但一直等到近幾年運算技術的進步才逐漸成為 machine learning 的主流演算法。它主要的概念就像下面這張圖:

這個架構主要是模仿人類神經的結構,利用神經元 (neuron) 作為最小的儲存跟傳遞單位,neuron 負責接收輸入的訊號再把訊號輸出給下一個 neuron。連結 neuron 的線有它們代表的權重,這些線會在資料從上一個 neuron 傳遞出來之後乘上它們代表的權重後傳給下一個 neuron。Neuron 收到從四面八方來的、加乘上權重的訊號之後,會判斷有沒有需要把計算後的訊號傳遞給下一層 (activated)。 而所謂的「學習」指的就是利用訓練資料調整這些 neuron 跟連結的權重的過程。

Neural network 有非常多不同的變型,不過我們可以把最基本的概念簡化成下面這張圖:

最左邊是我們輸入的圖片。這個圖片如果是一個 RGB 300 x 300 的圖片,它就可以被轉成一個 300 x 300 x 3 = 270,000 dimension 的 feature vector,當成整個 neural network 的輸入,也就是上圖 Input Layer 的部份。圖的最右邊 Output Layer 則是我們想要預測的結果 (label),在這個 layer 裡,每一個 neuron 代表的就是一種可能的結果。中間的 Hidden Layer 是由一到多層的 neural 所構成,這些層的任務就是負責傳遞訊息跟修正權重。以上面這張圖來說,Hidden Layer 就是兩層。

在訓練的過程中,我們會輸入許許多多的圖片,並且在一開始指定特定數量跟大小的 hidden layer,然後隨機給予 neuron 之間的權重。這樣跑完一次初始的預測之後,可以預期結果一定會很不好,因為權重一開始是隨機給的。不過針對這樣的結果,對照我們一開始給的正確答案,機器可以得到一個調整的方向,回頭去調整前面 neuron 之間的權重。調整完之後,再跑一次預測,再利用新的結果去修正前面的權重。一直重覆這樣的過程,直到最後預測的結果非常接近我們一開始給的正確答案,這樣就算完成一次學習了。

當然現行的 neural network 已經遠遠不止這樣,中間的 hidden layer 又可以再細分不同功能,也可以代不同的 function 到 layer 上,不過最基本的概念還是不變的。Neural Network 已經被證明可以達到非常高的準確度,也已經被廣泛地用在我們日常生活中,不過它目前還是有一些限制:

  • 訓練的過程需要大量的運算資源
  • 需要大量的訓練資料才能達到一定的準確度

這也就是我們不建議直接在手機上訓練 neural network model 的原因,除了手機運算資源不足之外,個人資料也不足以訓練出準確度高的 model。

如果對於 neural network 的原理有興趣,這個 YouTube 上簡短的影片很適合當做入門。

了解了這兩種不一樣的機器學習演算法之後,我們離能夠在手機上訓練 model 只剩下 200 行 code 的距離了!我們現在知道要在手機上訓練一般的 model 有著許多限制,那有沒有甚麼折衷的方法既能夠兼顧準確性,同時又不需要消耗太多資源呢?

行動裝置上的機器學習方案

讓我們先回到 neural network 的世界,用一個塗鴉辨識的訓練來當例子,我們要訓練一個可以辨識我們的塗鴉的 model。我們輸入一張手繪太陽的圖片跟「太陽」的標記給 neural network classifier,它要怎麼學到這個東西就是「太陽」呢?

用人類的角度來理解的話,可以看成像上面這張圖這樣,在前面幾層的 layer,neural network 學習到的是一些基本的圖樣,像是筆觸、線條等等。等訊號走到後面的 layer,neural network 學習到的是利用前面接收到的筆觸、線條資訊,判斷更完整的圖型。

要注意的是實際上在中間的 layer 出來的結果並不會長得像上圖那樣,甚至如果你真的截取中間層的結果畫成圖,也大多是人類無法辨識的圖樣,不過概念上還是很接近的,以圖型辨識來說,靠近前面的 layer 學習到的是細節的部份,後面的 layer 則是學習到理解樣式。

這樣的特性能為我們帶來甚麼好處呢?再回顧一下,開頭介紹的 k-NN classifier 在訓練的時候,如果資料量不夠、或者資料的維度太高,都會讓訓練的準確度變低,但是如果我們在訓練的時候,不是放整張圖像轉成的 feature vectore,而是放 neural network 中 output layer 之前的 layer 所產生的 array 呢?如下圖這樣:

中間的部份是已經訓練過、能夠辨識塗鴉對應物件名稱的 hidden layers,把 output layer 跟後面某些特定的 layer 移除後,直接把輸入圖片的中間計算結果當成要餵給 k-NN 的 feature。因為上面提到的 neural network 的特性,把 neural network model 的中間產物當成 feature 的好處就是可以延續上一個 model 所「學習」到的關於筆觸、線條等等的知識。在原始圖像經過這個 model 後,可以得到一些已經經過「整理」、維度也小很多的 feature vector。只要輸入的圖像性質都是類似的(像是都是塗鴉),neural network 的這個特性就可以幫助下一個 classfier,用更少的資料訓練出準確度高的 model。在這個模式底下,neural network model 的主要任務就是抽取 feature (feature extraction),而不是分類,分類器的訓練跟預測的工作主要還是由 k-NN 來完成。

利用這樣的機制,我們可以在手機上匯入已經用 neural network 訓練好的 model,把它當成 feature extractor,然後再讓使用者輸入少量的資料當做訓練資料,我們就能夠在手機上短時間內用少量的資源訓練 k-NN model。

來做個小 app!

簡介一下我們今天的任務。我們今天要來試做一個塗鴉 app,可以從使用者的塗鴉來產生對應的精緻圖像,讓任何人都可以輕鬆成為(假)插畫家!我們來看看這個 app 的運作方式:

在這個 app 裡,你可以登錄你某個特別的塗鴉,讓它對應某張圖片,這樣只要畫簡單的線條就可以轉成精緻的圖片。當然,在這個 app 變的聰明之前,使用者需要教會 app 如何辨識塗鴉:

在 training 的階段,使用者需要選定一張目標的圖片,並且畫下三張自己覺得最能代表這張圖片的塗鴉。App 就會利用這張圖片當做訓練的標準答案 (label),拿三張塗鴉對照這個 label 去更新 machine learning 的 model。在使用的階段,app 就會不斷地拿使用者的塗鴉去問 model,針對該塗鴉應該要給出那一張圖片。

要做到這樣的功能,首先我們需要一個可以更新的 model (updatable model)。

用 Core ML Tools 製作一個 updatable model

Apple 在 2019 年釋出的 CoreML 3 上加入了更新 model 的機制,你可以利用已經從 Keras 或 Tensorflow 等工具訓練好的 model,在 Core ML Tools 裡把 model 標記成 updatable,接著就可以把 CoreML 產生出來的 updatable model 放到手機裡,讓手機執行個人化訓練的任務。

Core ML Tools 有支援能夠更新的 model 有這幾種:

  • k-NN classifier
  • Neural network classifier
  • Pipeline classifier

而我們在上一個段落所提到的 neural network + k-NN 的解決方案就是第三種,pipeline classifier。

接下來,我們要用 Core ML Tools 來產生可以被更新的 updatable model,你可以參考這篇文章來安裝這個工具。除此之外,我們會需要寫一點 python code。不過不用擔心,這邊大部份的 code 都比較像是設定 meta data 跟寫 script 而已。

如果你只想要試手機更新的部份,可以直接跳過這個段落,Apple 已經幫你做好了一個能夠被更新的 pipeline classifier!你可以直接從官網下載名為 UpdatableDrawingClassifier 的 model ,並且毫不猶豫地滾到下一個段落。

接下來我們的流程會是這樣:

  1. 下載已經訓練好的 neural network classifier
  2. 移除 classifier 的最後幾層 layer,讓它成為一個 feature extractor
  3. 產生一個 k-NN classifier
  4. 產生一個 pipeline classifier 串接兩個不同的 models

讓我們來一步一步進行!

1. 下載已經訓練好的 neural network classifier

首先,先到這個網站下載 MNIST 這個 model。它是一個能夠接收手寫輸入,判斷寫的字是 0 – 9 哪一個數字的 neural network model。這個 model 是使用 Turi Create 產生的,所以下面的步驟也都是以 Turi Create 產生的 model 為主。如果你手邊的 model 不是 Turi Create 產生的,也可以用 Core ML Tools 提供的轉換工具先把 model 轉成 .mlmodel,再來進行下面的步驟。

接下來,我們要來用 Core ML Tools 來修改這個 model,首先我們先打開一個 python 檔案,讀取下載好的 model :

import os
import coremltools

# 設定剛剛下載的 model 的位置
coreml_model_path = "./MNISTClassifier.mlmodel"

# 讀取 model 的 spec,關於 model 的所有資訊都會被存在這邊
spec = coremltools.utils.load_spec(coreml_model_path)

# 將 classifier 存下來方便接下來利用
classifier = spec.neuralNetworkClassifier

2. 移除 classifier 的最後幾層 layer,讓它成為一個 feature extractor

如同前面段落提到的,我們要把 neural network model 當成 feature extractor ,專門提供 feature vector 給 k-NN 做分類。實際上的做法,就是將 neural network 原本負責作分類的 layer 砍掉🔪。在下刀之前,我們需要看一下要移除掉的 layer 有哪些。為了方便起見,先定義一個簡單的 print function,讓它可以印出 layer 的 index 跟類型:

def printLayers(layers):
    for (i, layer) in enumerate(layers):
        print(" layer index = %d\ttype: %s, input: %s, output: %s" %(i-len(layers), layer.WhichOneof('layer'), layer.input[0], layer.output[0]))

印出剛剛讀取的 classifier 的 layers 來看看:

printLayers(classifier.layers)

可以看到目前有這些 layer:

 layer index = -14    type: convolution
 layer index = -13    type: activation
 layer index = -12    type: pooling
 layer index = -11    type: convolution
 layer index = -10    type: activation
 layer index = -9        type: pooling
 layer index = -8        type: convolution
 layer index = -7        type: activation
 layer index = -6        type: pooling
 layer index = -5        type: flatten
 layer index = -4        type: innerProduct
 layer index = -3        type: activation
 layer index = -2        type: innerProduct
 layer index = -1        type: softmax

在這個範例中,我們要移除最後兩個 layer:innerProductsoftmax。在這個 model 的最後兩層到四層中,被標記為 innerProduct 的 layer 主要負責綜合前面的結果,協助最後 label 判斷。因為我們想要把這個 model 直接當成 feature extractor,所以判斷 label 的部份我們可以直接拿掉。

在移除 layer 之前,我們要先把跟這些 layer 對接的 vector dimension 記下來:

numberOfChannels = classifier.layers[-2].innerProduct.inputChannels
# 以 MNIST 為例,這個值是 128

這邊我們也可以看到,這個 feature extractor 產生的 feature vector 形狀就是 128 dimension。這個數字在之後的步驟會用得到,所以我們先存起來。

現在,我們可以直接大刀把後面兩個 layer 砍掉!

del classifier.layers[-1]
del classifier.layers[-1]

這樣這個 model 就變成一個 feature extractor,能夠接收一張圖片,並且輸出成一個 128 dimension 的 feature vector。

不過我們還有一些善後的工作要做。這個 model 一開始的類型是一個 neuralNetworkClassifier

# 印出 model 的類型
print(spec.WhichOneof('Type'))

# neuralNetworkClassifier

classifier 這個 postfix,代表這個 model 總是會根據輸入產生一個 label,是一個名副其實的分類器。但經過剛剛上面的修改之後,我們的 model 已經不再是一個分類器了,我們把能夠分類的功能拿掉了。所以我們必須要修改這個 model 的 type,讓接下來利用它的人可以知道這個 model 的正確類型。

還記得我們一開始是用 classifier = spec.neuralNetworkClassfier 來取得這個 classifier 的嗎?現在我們要把這個 neuralNetworkClassfier 清空,改成把值都放到 neuralNetwork 這個 attribute 裡面:

# 把修改過的 layer 加到 neuralNetwork 裡
spec.neuralNetwork.layers.extend(classifier.layers)

# 移除掉 neuralNetworkClassfier 裡的 layers 
del spec.neuralNetworkClassifier.layers[:]

# 把原本 preprocessing 的部份也複製到 neuralNetwork 裡
spec.neuralNetwork.preprocessing.extend(spec.neuralNetworkClassifier.preprocessing)

然後我們要修改一下 model 的 output spec:

# 1
del spec.description.output[-1]
del spec.description.output[-1]

# 2
new_output = spec.description.output.add()
new_output.name = 'features'
new_output.shortDescription = 'Penultimate layer output'

new_output_params = new_output.type.multiArrayType
new_output_params.dataType = coremltools.proto.FeatureTypes_pb2.ArrayFeatureType.FLOAT32

# 3
new_output_params.shape.extend([numberOfChannels])

# 4
spec.neuralNetwork.layers[-1].output[0] = "features"
  1. 最原本的 model 最後的 output 有兩種,class label 跟 prediction probabilities,這些分類相關的我們都不會用到,所以直接砍掉。
  2. 新增一個名稱為 “features” 的 output,來正確地描述目前 model 的行為,並且加上一些描述跟資料型別。
  3. 還記得我們上面存下來的 numberOfChannel 嗎?這個就是 model 最後輸出的維度,我們要在這個新的 output 裡面指定好維度。
  4. 最後,將最後一層 layer 的 output 指向我們剛剛新增的 “features” output。

在這邊,我們可以印出 model type 來看看設定是不是正確的:

print(spec.WhichOneof('Type'))

# neuralNetwork

嗯看起來非常正確,這樣可以來進行下一個步驟了!

3. 產生一個 k-NN classifier

完成了上面的步驟後,我們得到了一個 feature extractor,接著我們需要做一個 k-NN 的 classifier 來負責做分類。這個步驟相對單純很多:

from coremltools.models.nearest_neighbors import KNearestNeighborsClassifierBuilder

knn_builder = KNearestNeighborsClassifierBuilder(input_name='features',
                                                 output_name='label',
                                                 number_of_dimensions=numberOfChannels,
                                                 default_class_label='unknown',
                                                 k=5,
                                                 weighting_scheme='inverse_distance',
                                                 index_type='linear')

# 只是設定描述
knn_builder.description = 'Classifies ' + str(numberOfChannels) + ' dimension vector based on 5 nearest neighbors'
knn_spec = knn_builder.spec
knn_spec.description.input[0].shortDescription = 'Feature vector from CNN'
knn_spec.description.output[0].shortDescription = 'Predicted label. Defaults to \'unknown\''
knn_spec.description.output[1].shortDescription = 'Probabilities / score for each possible label.'

這邊我們會初始化一個 build,來負責建構一個 model。這個 builder 有幾個參數需要特別注意:

  • input_name:這個就是接收上面那個 model 的 output,所以我們擺上 “features”
  • number_of_dimensions:這個 classifier 的輸入維度,我們就用已經存好的 numberOfChannels
  • k:這個從名稱就可以看得出來,就是 k-NN 的 k。預設值是 5,並且允許 k 值可以介於 1~1000 之間

這邊我們就完成一個空的 k-NN classifier 了。

4. 產生一個 pipeline classifier 串接 models

Pipeline classifier 是 Core ML 支援的一種 classifier,它的功用就跟字面上的意思一樣,可以把幾個 classifier 接在一起,變成一個獨立的 classifier,這剛好可以用在我們目前的情境當中。

我們先新增一個 pipeline classifier:

pipeline_spec = coremltools.proto.Model_pb2.Model()
pipeline_spec.specificationVersion = coremltools._MINIMUM_UPDATABLE_SPEC_VERSION
pipeline_spec.isUpdatable = True

要記得把 isUpdatable 標記成 True,這樣手機上的 CoreML 才能認得。

接下來就是繁瑣的描述設定,後面你可以看到這些描述會被用在甚麼地方。

# 讓 pipeline classifier 的 input 描述跟前面做好的 feature extractor 一樣,並且加上一些跟 training 相關的描述
pipeline_spec.description.input.extend(spec.description.input[:])
pipeline_spec.description.trainingInput.extend([spec.description.input[0]])
pipeline_spec.description.trainingInput[0].shortDescription = 'Example drawing'
pipeline_spec.description.trainingInput.extend([knn_spec.description.output[0]])
pipeline_spec.description.trainingInput[1].shortDescription = 'Label for the example drawing'

# 讓 pipeline classifier 的 output 描述跟 k-NN 的 output 一致 
pipeline_spec.description.output.extend(knn_spec.description.output[:])
pipeline_spec.description.predictedFeatureName = knn_spec.description.predictedFeatureName
pipeline_spec.description.predictedProbabilitiesName = knn_spec.description.predictedProbabilitiesName

# 一些額外的描述
pipeline_spec.description.metadata.author = 'A Developer'
pipeline_spec.description.metadata.license = 'A License'
pipeline_spec.description.metadata.shortDescription = ('An updatable model which can be used to train a tiny 28 x 28 drawing classifier based on user examples.'
                                                       ' It uses a drawing embedding model trained on the MINST)')

寫完文件之後,我們要來真的做事了:

# feature extractor
pipeline_spec.pipelineClassifier.pipeline.models.add().CopyFrom(spec)

# k-NN
pipeline_spec.pipelineClassifier.pipeline.models.add().CopyFrom(knn_spec)

# 把 model 存下來
updatable_mlmodel = coremltools.models.MLModel(pipeline_spec)
updatable_mlmodel.save("./UpdatableDrawingClassifier.mlmodel")

這邊我們把兩個剛剛產生的 model 都加到了 pipeline 裡面,並且存在檔案 UpdatableDrawingClassifier,mlmodel

如果把這個 mlmodel 直接拉到 Xcode 的專案裡,你可以看到這樣的描述:

最上面的 type 就標示了這是一個 pipeline classifier。你也可以從底下的 tab 看出這是一個可以被更新的 model:

剛剛上面輸入的描述也可以在這邊看得到(就不是做白工👀)。

到這裡,我們終於完成了我們的前置作業了!沒錯,就像重構 code 一樣,在開始重構三個月後只完成了登入頁面也是常有的事 🙈。不過不用擔心,後面的部份也是相當直觀,而且你可以拿現成的 model 直接套用!

這部份的流程如果還想知道更多細節,可以參考以下文章:

在 app 上更新跟使用 updatable model

我們現在要來看看,如何在手機上更新一個機器學習的 model。這邊的 model 你可以用上面產生的 UpdatableDrawingClassifier.mlmodel、或者直接上這個網站下載,兩者的建構方式是一樣的, 不過後者用的訓練資料更完整(參考網站:Quick, Draw!),不只包含 0 – 9 的圖樣,還有很多各式各樣的圖形,準確度應該會比較高(實測上也的確是比較高)。

訓練階段

準備訓練資料

再次回顧一下我們這個 model 的目標:給定一個塗鴉,model 要能預測這個塗鴉對應的圖片。從這個任務我們可以知道,我們需要蒐集使用者的塗鴉,還有使用者選定的正確答案圖片。

首先,把 UpdatableDrawingClassifier.mlmodel 拖拉進 Xcode project,從它的資訊頁面中,我們可以看到需要準備甚麼格式的資料:

可以看到輸入需要是 28 x 28 的灰階圖片,而 label 的部份則需要是 string。

我們先產生一個類似下面這樣的 view:

上面的方塊可以讓使用者選擇想要訓練的目標圖形,下面則是可以讓使用者塗鴉的介面。訓練的流程會像下面的圖片這樣:

因為 model 的訓練跟預測目標都需要是 string,所以我們可以先給每一張圖一個唯一的字串當做 label。

接著我們要開始來寫點 code 了(認真)。首先,我們需要初始化我們的 model 跟設定一些路徑:

// 還沒更新前的 classifier
let originalClassfier: UpdatableDrawingClassifier = {
    do {
        return try UpdatableDrawingClassifier(configuration: MLModelConfiguration())
    } catch let error {
        fatalError("Couldn't load classfier: \(error.localizedDescription)")
    }
}()

// 要擺放更新後的 classifier
var updatedClassfier: UpdatableDrawingClassifier?

// 如果有更新過的 classifier,這邊就會直接取用更新的那一個
var currentClassfier: UpdatableDrawingClassifier {
    return updatedClassfier ?? originalClassfier
}

// 原始 model 的 URL,要注意 type 是 "mlmodelc"
let originalModelUrl = URL(fileURLWithPath: Bundle.main.path(forResource: "UpdatableDrawingClassifier", ofType: "mlmodelc")!)

// 更新後 model 的 URL
var updatedModelUrl: URL {
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendPathComponent("personalized.mlmodelc")
}

在你把 UpdatableDrawingClassifier.mlmodel 拉近 Xcode 後,就會自動產生 UpdatableDrawingClassifier 的 class,你可以用 ⌘ + Click 去看看它定義的 method 有哪些。

接著,我們需要將接收到的使用者塗鴉做一些處理:

// 截取目前使用者畫的圖案
let drawing: PKDrawing = drawingView.drawing

// 將 PKDrawing 轉成 UIImage
let featureImage = drawing.image(from: drawing.bounds.containingSquare, scale: UIScreen.main.scale * 2.0)

上面的 containingSquare 是一個 GCRect 的 extension,可以把一個長方形的 CGRect 轉換成正方形:

extension CGRect {
    var containingSquare: CGRect {
        let centerX = minX + width/2
        let centerY = minY + height/2
        let dimension = max(width, height)
        return CGRect(x: centerX - dimension/2, y: centerY - dimension/2, width: dimension, height: dimension)
    }
}

我們會需要轉換正方形的圖形,是因為 model 輸入要的是一個 28 x 28 的正方型圖片,先在這邊轉換之後處理會比較方便。接著,我們要把圖片轉換成 model 能夠用的格式 (feature value),我們在訓練跟預測的時候會需要這個轉換,所以我們準備了一個 function 跟一些 helper functions:

func extractFeatureValue(from image: UIImage) -> MLFeatureValue {
    guard let cgImage = image.cgImage else {
        fatalError("Failed to get cgImage")
    }
    // 1
    let imageConstraint = currentClassfier.imageConstraint

    do {
        // 2
        let imageFeatureValue = try MLFeatureValue(cgImage: cgImage.whiteTinted,
                                                   constraint: imageConstraint)
        return imageFeatureValue
    } catch let error {
        fatalError("Couldn't extract feature: \(error.localizedDescription)")
    }
}

extension UpdatableDrawingClassifier {
    var imageConstraint: MLImageConstraint {
        let description = model.modelDescription
        // 3
        let imageInputDescription = description.inputDescriptionsByName["drawing"]!

        let imageConstraint = imageInputDescription.imageConstraint!

        return imageConstraint
    }
}

extension CGImage {
    private static let ciContext = CIContext()
    // 4
    var whiteTinted: CGImage {
        let ciContext = Self.ciContext

        let parameters = [kCIInputBrightnessKey: 1.0]
        let ciImage = CIImage(cgImage: self).applyingFilter("CIColorControls", parameters: parameters)
        return ciContext.createCGImage(ciImage, from: ciImage.extent)!
    }
}
  1. 雖然 CoreML 提供了可以把圖片當成輸入的功能,不過每個 model 接收的圖片格式不太一樣,在這邊我們需要從 model 截取它所能接受的圖片格式,存下來準備讓之後產生 feature value 時可以用。
  2. CoreML 提供了一個 class MLFeatureValue,讓你可以直接拿 CGImage 當成 feature 的輸入,不需要自己把 CGImage 轉成 pixel vector。這邊另外還需要指定圖片的格式,數值來自於我們剛剛存下來的 imageConstraint
  3. 我們的圖片格式是直接從 model 的 meta data 來截取的,所以這邊我們產生一個 UpdatableDrawingClassifier 的 extension,讓我們可以方便取用。要注意的是,這邊 description.inputDescriptionsByName 是一個 dictionary,它的 key 要從 model 的 meta data 來看:

Updates tab 裡面,Input 這個欄位標示了 input 的名稱為 “drawing”,所以我們在上面的 description.inputDescriptionsByName 也要指定 “drawing”,才能夠抓到正確的 imageConstraint。如果你一開始是用我們上面自己產生的 model,那這邊就會是 “image”,你也可以在產生 model 的時候直接修改這個 input name。

  1. 因為最原本的圖片 classifier 就是用白色筆觸來訓練的,所以這邊我們需要把顏色都轉換成白色。基本上我們需要了解這些 model 原本訓練的格式跟類型,在預測或更新的時候也使用一樣的格式或類型,這樣出來的結果才會準確。

接著我們要來準備更新 model 所需要的 training data。在訓練的介面上,我們針對同一張圖 (label) 請使用者畫了三張不同的塗鴉,所以我們現在就有三筆塗鴉 (drawing) 對應圖像 (label) 的訓練資料。

在 Core ML 裡面,批次的訓練資料是被裝在一個 MLBatchProvider 的容器裡面:

一個 MLBatchProvider 裡面裝著 n 筆的 MLFeatureProvider,一個 MLFeatureProvider 就對應一筆訓練資料。有這樣的概念之後我們來看一下 code:

func buildBatchTrainingData(drawings: [UIImage], label: String) -> MLBatchProvider {
    var featureProviders = [MLFeatureProvider]()

    // 可以參考 Xcode 的 model meta data 來得知正確的 name
    let inputName = "drawing"
    let outputName = "label"

    for drawing in drawings {
        let inputValue = extractFeatureValue(from: drawing.cgImage!)
        let outputValue = MLFeatureValue(string: label)
        let dataPointFeatures: [String: MLFeatureValue] = [inputName: inputValue,
                                                           outputName: outputValue]

        if let provider = try? MLDictionaryFeatureProvider(dictionary: dataPointFeatures) {
            featureProviders.append(provider)
        }
    }

   return MLArrayBatchProvider(array: featureProviders)
}

這段 code 可以輸入一群塗鴉的圖片,跟一個對應的 label,輸出能夠直接餵給 .mlmodelMLBatchProvider

前面鋪了這麼久的路,接著我們可以來準備更新 model 了!

更新 updatable model

// 確認 update model 的檔案是不是存在
var isUpdatedModelAvailable: Bool {
    FileManager.default.fileExists(atPath: updatedModelUrl.path)
}

// 更新 model 的主程式
func updateClassfier(images: [UIImage], label: String, completionHandler: @escaping () -> Void) {

    // 取得剛剛包裝好的 training data
    let dataProvider = buildBatchTrainingData(images: images, label: String)
    let config = MLModelConfiguration()

    // 確認這是不是第一次更新,如果是的話,那我們就需要用最原始 model 當做基礎
    let url = isUpdatedModelAvailable ? updatedModelUrl : originalModelUrl

    let progressHandler = MLUpdateProgressHandlers(forEvents: [.trainingBegin, .miniBatchEnd, .epochEnd], progressHandler: { context in
        // 印出一些訓練過程的資料
        print(context.event, context.metrics)
    }, completionHandler: { [weak self] context in
        // 這個是完成時的 closure

        // 把更新好的 model 存下來,之後可以重覆利用
        self?.saveUpdatedModel(context: context)

        // 把更新完的 model 替換到 updatedClassifier 這個 propery 上
        do {
            updatedClassfier = try UpdatableDrawingClassifier(contentsOf: updatedModelUrl)
        } catch let error {
            print("Load classfier failed: \(error.localizedDescription)")
        }

        completionHandler()
    })

    // 這邊執行真正的更新任務
    do {
        let task = try MLUpdateTask(forModelAt: url, trainingData: dataProvider, configuration: config, progressHandlers: progressHandler)
        task.resume()
    } catch let error {
        print("Update model error: \(error.localizedDescription)")
    }
}

上面這段 code 利用了 MLUpdateTask 來產生更新任務,並且指定我們想要更新的 model 的 URL (url)、training data (dataProvider)、還有聽取訓練進度的 progressHandler。在 task.resume() 之後,model 會開始更新,在更新完之後,上面的 completionHandler 就會被呼叫。

我們在 completionHandler 裡面呼叫了 saveUpdatedModel(context:) 這個 function,把已經更新完的 model 存下來。這個 function 的實作非常單純:

func saveUpdatedModel(context: MLUpdateContext) {
    let updatedModel = context.model

    var tmpModelUrl = URL(fileURLWithPath: NSTemporaryDirectory())
    tmpModelUrl.appendPathComponent("personalized_tmp.mlmodelc")

    let fileManager = FileManager.default
    do {
        try updatedModel.write(to: tmpModelUrl)
        _ = try fileManager.replaceItemAt(updatedModelUrl, withItemAt: tmpModelUrl)
    } catch {
        print("Save model error: \(error.localizedDescription)")
    }
}

因為 model 可能會很大,我們先把 model 存到一個暫存的地方,等到寫入完畢後,再移動到我們原本指定的 updatedModelUrl 上,取代原本的 model。

這樣我們就完成了 model 的更新,現在手機裡面應該已經存有訓練完的 model 了。

接下來,我們要來看看如何用這個 model 做預測。

預測階段

這邊我們需要一個 app 的 UI,能夠接收使用者的塗鴉,並且在等 0.5 秒沒有新的輸入之後,用塗鴉來判斷使用者想要的是那一張圖片。介面大概會長這樣:

預測的部份相對很簡單,在接收到使用者的塗鴉之後,我們可以用這個 function 來做預測:

func predict(from drawing: UIImage) -> String? {
    let feature = extractFeatureValue(from: drawing.cgImage!)

    if let label = try? currentClassfier.prediction(drawing: feature.imageBufferValue!).label {
        return label
    } else {
        return nil
    }
}

這邊我們一樣會利用剛剛做好的 extractFeatureValue(from:),來製作 MLFeatureValue 的 instance,再把這個 instance 丟到 prediction(drawing:) 這個自動生成的 function 之中。這個 function 的 parameter label 會對應你在產生 model 時設定的 input name,像是 “drawing” 就是這個 UpdatableDrawingClassifier.mlmodel 所設定的 input name。

這個 function 完成預測之後,會回傳一個 UpdatableDrawingClassifierOutput 的物件,如果你打開它的 source code 會發現它有兩個 properties:

class UpdatableDrawingClassifierOutput : MLFeatureProvider {
    /// Predicted label. Defaults to 'unknown' as string value
    lazy var label: String = {
        [unowned self] in return self.provider.featureValue(for: "label")!.stringValue
    }()

    /// Probabilities / score for each possible label. as dictionary of strings to doubles
    lazy var labelProbs: [String : Double] = {
        [unowned self] in return self.provider.featureValue(for: "labelProbs")!.dictionaryValue as! [String : Double]
    }()
}

這兩個就是我們在產生 model 時所定義的 output,其中 label 就是我們想要的圖片名稱。這邊要注意的是,k-NN classifier 預設一定會產出一個 label,依照我們原本的設定,如果沒有任何預測結果,預設會回傳 “unknow”,這邊處理的時候就要小心。

接著只要用這個 label 去找對應的圖片,然後再把圖片秀在 UI 上,我們就完成了我們的塗鴉大師 app 了!🙌

結論

我們來復習一下這次學到的東西吧:

  • k-NN 跟 Neural Network 的運作原理和優缺點
  • 如何在兼顧效能跟準確度的情況下,在手機上訓練 model:feature extractor + k-NN classifier
  • 如何利用 CoreML 製作 updatable pipeline model
  • 如何在手機上利用 CoreML 更新 model 和預測

雖然現在圖形辨識、聲音辨識等等的資料跟已經訓練過的 model 在網路上都可以輕易的找到,不過不論任何機器學習的方法,都沒有辦法保證有一定的準確度,還是要看輸出輸入的匹配、feature 的選擇、資料的量級等等,經過實驗之後才有辦法知道。舉例來說,像是這篇文章介紹的 pipeline model,如果前面的 feature extractor 用的是照片訓練的 model,那麼拿來做塗鴉辨識效果一定會不好,因為訓練跟預測的輸入是不匹配的。這個部份其實非常地有趣,有興趣的話可以多多試幾種 feature 的搭配、model 參數的調整,也可以翻閱文獻看看前人已經試過的方法。

這篇文章因為篇幅的關係,大多數的 UI code 都沒有寫在文章裡,跟 app 相關的 code 可以直接下載 Apple 的範例,裡面有完整的 code 跟教學。對於原理有興趣的話可以看 2019 年的 WWDC 有關 Core ML 3 的影片(是實體的 WWDC!),裡面有簡單介紹在裝置上更新 model 的原理(也就是這篇文章前段的部份)。Core ML 的使用文件雖然寫得很不錯,不過有很多細節還是沒有在文件裡出現,On-device training with Core ML 這篇文章補完了非常多的細節,推薦想更深入了解 Core ML 的人看。最後關於用 neural network model 當成 feature extractor 的部份,Processing with AI 這篇文章用了很淺顯易懂的圖片來解識這樣的概念。想要了解更多相關的技術,可以上網查詢 transfer learning,方法遠不只有這篇文章介紹的部份!💪


I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!

blog comments powered by Disqus
Shares
Share This