譯者注:這篇文章從如何在Keras中建立自定義層,講到如何建立、訓(xùn)練Keras模型,如何轉(zhuǎn)換為Core ML模型,以及如何在app中使用自定義層,如何使用Accelerate加速代碼,如何使用GPU加速代碼。內(nèi)容非常全面,學(xué)習(xí)Core ML自定義層不可錯過的優(yōu)秀文章,譯者筆力有限,英文水平過得去的可以看英文原文。
蘋果新的Core ML框架使得在iOS app中添加機器學(xué)習(xí)模型變得很容易。但有一個很大的局限是Core ML只支持有限的神經(jīng)網(wǎng)絡(luò)層類型。更糟糕的是,作為應(yīng)用程序開發(fā)人員,不可能擴展Core ML的功能。
好消息:從iOS 11.2開始,Core ML現(xiàn)在支持定制層!在我看來,這使Coew ML更加有用。
在本文中,我將展示如何將具有自定義層的Keras模型轉(zhuǎn)換為Core ML。
步驟如下:
- 創(chuàng)建具有自定義層的Keras模型
- 使用coremltools將Keras轉(zhuǎn)換為mlmodel
- 為自定義層實現(xiàn)Swift類
- 將Core ML模型放到iOS應(yīng)用程序中并運行它
- 利潤!
像往常一樣,您可以在GitHub上找到源代碼。運行環(huán)境為Python 2、TensorFlow、Keras、coremltools和Xcode 9。
注意:我選擇Keras作為這個博客帖子,因為它易于使用和解釋,但是使定制層以相同的方式工作,而不管您使用什么工具來訓(xùn)練模型。
Swish!
讓我們實現(xiàn)一個名為Swish的激活函數(shù)(activation function),演示如何創(chuàng)建自定義層。
“等等…”,你可能會說,“我以為這篇文章是關(guān)于自定義層的,而不是定制激活函數(shù)?”哦,這要看你怎么看待事物。
您可以認(rèn)為激活函數(shù)是非線性的應(yīng)用于層的輸出,但是您也可以將激活函數(shù)視為它們自己的層。在許多深度學(xué)習(xí)軟件包中,包括Keras,激活功能實際上被看作獨立的層。
Core ML只支持一組固定的激活函數(shù),比如標(biāo)準(zhǔn)的ReLU和sigmoid激活。(完整的列表在NeuralNetwork.proto中,它是mlmodel規(guī)范的一部分。)
但時常有人發(fā)明一種奇特的新激活函數(shù),如果你想在Core ML模型中使用它,那你只能編寫自己的自定義層。這就是我們要做的。
我們將實現(xiàn)Swish激活函數(shù)。公式是:
swish(x) = x * sigmoid(beta * x)
其中sigmoid是著名的logistic sigmoid函數(shù)1/(1+exp(-x))。因此,Swish的完整定義是:
swish(x) = x / (1 + exp(-beta * x))
這里,x是輸入值,beta可以是常數(shù)或可訓(xùn)練的參數(shù)。不同的beta會改變Swish函數(shù)的曲線。
用beta=1.0進行刷新看起來是這樣的:

是不是很像無處不在的ReLU激活函數(shù),不同的是Swish在左手邊是平滑的,而不是在x=0處進行突然改變(這給Swish提供了一個不錯的、干凈的導(dǎo)數(shù))。
beta值越大,Swish看起來越像ReLU。beta越接近0,Swish看起來越像直線。(如果你好奇,試試看。)
顯然,這種Swish激活使您的神經(jīng)網(wǎng)絡(luò)比ReLU更容易學(xué)習(xí),并且也給出了更好的結(jié)果。您可以在“Searching for Activation Functions”一文中閱讀更多關(guān)于Swish的信息。
為了簡化示例,最初我們將使用beta=1,但是稍后我們將使用beta作為一個可學(xué)習(xí)的參數(shù)。
Keras模型
撰寫本文時,Swish還不夠流行,沒有進入Keras。所以我們還要編寫一個定制的Keras層。它很容易實現(xiàn):
from keras import backend as K
def swish(x):
return K.sigmoid(x) * x
這里,x是一個張量,我們簡單地把它和K.sigmoid函數(shù)的結(jié)果相乘。K是對Keras后端的引用,后者通常是TensorFlow?,F(xiàn)在我將beta排除在代碼之外(這與beta=1相同)。
為了在Keras模型中使用這個自定義激活函數(shù),我們可以編寫以下代碼:
import keras
from keras.models import *
from keras.layers import *
def create_model():
inp = Input(shape=(256, 256, 3))
x = Conv2D(6, (3, 3), padding="same")(inp)
x = Lambda(swish)(x) # look here!
x = GlobalAveragePooling2D()(x)
x = Dense(10, activation="softmax")(x)
return Model(inp, x)
這只是一個帶有一些基本層類型的簡單模型。重要的部分是x=Lambda(swish)(x)。這在前一層的輸出上調(diào)用新的swish函數(shù),該層在本例中是卷積層。
Lambda層是一個特殊的Keras類,它非常適合于只使用函數(shù)或lambda表達式(類似于Swift中的閉包)編寫快速但不完善的層。Lambda對于沒有狀態(tài)的層很有用,在Keras模型中通常用于進行基本計算。
注意:您還可以通過創(chuàng)建Layer子類在Keras中創(chuàng)建更高級的自定義層,稍后我們將看到一個示例。
激活呢?
如果您是Keras用戶,那么您可能習(xí)慣于為這樣的層指定激活函數(shù):
x = Conv2D(..., activation="swish")(x)
或者像這樣
x = Conv2D(6, (3, 3), padding="same")(inp)
x = Activation(swish)(x)
在Keras中我們通常使用Activation層,而不是使用Lambda作為激活函數(shù)。
不幸的是,0.7版的coremltools不能轉(zhuǎn)換自定義激活,只能轉(zhuǎn)換自定義層。如果試圖轉(zhuǎn)換使用Activation(...),而它不是Keras內(nèi)置激活函數(shù)之一,coremltools將給出錯誤消息:
RuntimeError: Unsupported option activation=swish in layer Activation
解決方法是使用Lambda層替代Activation。
特別指出來,因為這是一個稍微令人討厭的限制。我們可以使用自定義層來實現(xiàn)不支持的激活函數(shù),但是模型編碼中不能使用Activation(func)或activation="func"。在使用coremltools Keras轉(zhuǎn)換器之前,必須先用Lambda層替換它們。
注意:或者,您可以使用coremltools的NeuralNetworkBuilder類從頭創(chuàng)建模型。這樣,您不受Keras轉(zhuǎn)換器理解的限制,但是也不太方便。
在我們將這個模型轉(zhuǎn)換為Core ML之前,應(yīng)該先給它一些權(quán)重。
“訓(xùn)練”模型
在這篇文章的源代碼中,我創(chuàng)建了Keras模型,它寫在轉(zhuǎn)換腳本_lambda.py之中。在實踐中,您可能有不同的用于訓(xùn)練和轉(zhuǎn)換的腳本,但是對于這個示例,我們不會煩惱訓(xùn)練。(不管怎么說,這是個粗糙的模型。)
首先,我們使用您剛才看到的create_model()函數(shù)創(chuàng)建模型的實例:
model = create_model()
model.compile(loss="categorical_crossentropy", optimizer="Adam",
metrics=["accuracy"])
model.summary()
我們不訓(xùn)練模型,而是給它隨機加權(quán):
import numpy as np
W = model.get_weights()
np.random.seed(12345)
for i in range(len(W)):
W[i] = np.random.randn(*(W[i].shape)) * 2 - 1
model.set_weights(W)
通常訓(xùn)練過程會填補這些權(quán)重,但是為了這個博客的目的,我們只是假裝。
為了獲得一些輸出,我們在輸入圖像上測試模型:

這是一個256×256像素的RGB圖像。你可以使用任何你想要的圖像,但是我的貓自愿做這份工作。以下是加載圖像、將其加入神經(jīng)網(wǎng)絡(luò)并輸出結(jié)果的代碼:
from keras.preprocessing.image import load_img, img_to_array
img = load_img("floortje.png", target_size=(256, 256))
img = np.expand_dims(img_to_array(img), 0)
pred = model.predict(img)
print("Predicted output:")
print(pred)
預(yù)測輸出是:
[[ 2.24579312e-02 6.99496120e-02 7.55519234e-03 1.38940173e-03
5.51432837e-03 8.00364137e-01 1.42883752e-02 3.57461395e-04
5.40433871e-03 7.27192238e-02]]
這些數(shù)字沒有任何意義……畢竟,這只是一個非?;镜哪P?,我們沒有對其進行訓(xùn)練。沒關(guān)系,在這個階段,我們只是想得到一些有關(guān)輸入圖像的輸出。
在將模型轉(zhuǎn)換為Core ML之后,我們希望iOS應(yīng)用程序為相同的輸入圖像提供完全相同的輸出。如果做到了,可以證明轉(zhuǎn)換是正確的,我們的自定義層可以正常工作。
注:有可能你的電腦會有不同的輸出。不用擔(dān)心,只要每次運行腳本時得到相同的數(shù)字就好。
轉(zhuǎn)換模型
現(xiàn)在讓我們將這個非?;镜哪P娃D(zhuǎn)換為Core ML mlmodel文件。如果一切順利,生成的mlmodel文件將不僅包含標(biāo)準(zhǔn)Keras層,而且還包含我們的自定義lambda層。然后,我們將編寫這個層的Swift實現(xiàn),以便可以在iOS上運行模型。
注意:我使用coremltools 0.7版本進行轉(zhuǎn)換。隨著軟件的不斷改進,在您閱讀本文時,它的行為可能會稍有不同。有關(guān)使用和安裝說明,請查看文檔。
將Keras模型轉(zhuǎn)換為Core ML非常簡單,只需調(diào)用coremltools.converters.keras..():
import coremltools
coreml_model = coremltools.converters.keras.convert(
model,
input_names="image",
image_input_names="image",
output_names="output",
add_custom_layers=True,
custom_conversion_functions={ "Lambda": convert_lambda })
這引用了我們剛剛創(chuàng)建的模型,以及模型的輸入和輸出的名稱。
對于我們的目的來說特別重要的是add_custom_layers=True,它告訴轉(zhuǎn)換器檢測自定義層。但是轉(zhuǎn)換器還需要知道一旦找到這樣的層該做什么——這就是custom_conversion_functions的用途。
custom_conversion_functions參數(shù)接受一個字典,該字典將層類型的名稱映射為所謂的“轉(zhuǎn)換函數(shù)”。我們還需要編寫這個函數(shù):
from coremltools.proto import NeuralNetwork_pb2
def convert_lambda(layer):
# Only convert this Lambda layer if it is for our swish function.
if layer.function == swish:
params = NeuralNetwork_pb2.CustomLayerParams()
# The name of the Swift or Obj-C class that implements this layer.
params.className = "Swish"
# The desciption is shown in Xcode's mlmodel viewer.
params.description = "A fancy new activation function"
return params
else:
return None
此函數(shù)接收Keras層對象,并應(yīng)返回CustomLayerParams對象。CustomLayerParams對象告訴Core ML如何處理這個層。
CustomLayerParams在NeuralNetwork.proto中定義。它具有以下字段:
- className
- description
- parameters
- weights
至少你應(yīng)該填寫className字段。這是在iOS上實現(xiàn)這一層的Swift或Objective-C類的名稱。我選擇簡單地將這個類命名為Swish。
如果不填寫className,Xcode將顯示以下錯誤,并且不能使用模型:

其他字段是可選的。description顯示在Xcode的mlmodel查看器中,parameters是一個帶有附加定制選項的字典,weights包含層的學(xué)習(xí)參數(shù)(如果有的話)。
現(xiàn)在我們有了轉(zhuǎn)換函數(shù),我們可以使用coremltools.converters.keras.convert() 運行Keras轉(zhuǎn)換器,它將為模型中遇到的任何Lambda層調(diào)用convert_lambda()。
注意:convert_lambda()函數(shù)將針對網(wǎng)絡(luò)中的每個Lambda層調(diào)用,因此如果具有具有不同函數(shù)的多個Lambda層,則需要在它們之間消除歧義。這就是為什么我們首先執(zhí)行l(wèi)ayer.function == swish的原因。
轉(zhuǎn)換過程中的最后一步是填充模型的元數(shù)據(jù)并保存mlmodel文件:
coreml_model.author = "AuthorMcAuthorName"
coreml_model.license = "Public Domain"
coreml_model.short_description = "Playing with custom Core ML layers"
coreml_model.input_description["image"] = "Input image"
coreml_model.output_description["output"] = "The predictions"
coreml_model.save("NeuralMcNeuralNet.mlmodel")
當(dāng)您運行轉(zhuǎn)換腳本時,coremltools將打印出它所找到的所有層并轉(zhuǎn)換:
0 : input_1, <keras.engine.topology.InputLayer object at 0x1169995d0>
1 : conv2d_1, <keras.layers.convolutional.Conv2D object at 0x10a50ae10>
2 : lambda_1, <keras.layers.core.Lambda object at 0x1169b0650>
3 : global_average_pooling2d_1, <keras.layers.pooling.GlobalAveragePooling2D object at 0x1169d7110>
4 : dense_1, <keras.layers.core.Dense object at 0x116657f50>
5 : dense_1__activation__, <keras.layers.core.Activation object at 0x116b56350>
名為lambda_1的層是具有swish激活功能的層。轉(zhuǎn)換沒有給出任何錯誤,這意味著我們已經(jīng)準(zhǔn)備好將.mlmodel文件放入應(yīng)用程序中!
注意:您不是必須使用轉(zhuǎn)換函數(shù)。另一種填寫自定義層詳細信息的方法是傳遞custom_conversion_functions={}。(省略它就會出錯,但是空字典也可以。)然后調(diào)用coremltools.converters.keras.convert()。這將在模型中包括您的自定義層,但不會給它任何屬性。然后,執(zhí)行以下操作:
layer = coreml_model._spec.neuralNetwork.layers[1]
layer.custom.className = "Swish"
這將獲取層并直接更改其屬性。無論哪種方式都可以,只要在保存mlmodel文件時已經(jīng)填充了className。
將模型放入app
在應(yīng)用程序中添加Core ML模型非常簡單:只需將mlmodel文件拖放到Xcode項目中即可。
Xcode mlmodel查看器展示轉(zhuǎn)換后的模型如下所示:

它像往常一樣顯示輸入和輸出,并且在新的Dependencies部分列出自定義層以及哪些類實現(xiàn)它們。
我已經(jīng)創(chuàng)建了一個演示應(yīng)用程序,它使用Vision框架運行模型,并與Python腳本使用的相同圖片。它將預(yù)測數(shù)字打印到Xcode輸出窗格?;叵胍幌?,這個模型實際上沒有計算任何有意義的內(nèi)容——因為我們沒有訓(xùn)練它——但是它應(yīng)該給出與Python相同的結(jié)果。
在將mlmodel文件添加到應(yīng)用程序之后,您需要提供一個實現(xiàn)自定義層的Swift或Objective-C類。如果沒有,那么一旦嘗試實例化MLModel對象,您將得到以下錯誤:
[coreml] A Core ML custom neural network layer requires an implementation
named 'Swish' which was not found in the global namespace.
[coreml] Error creating Core ML custom layer implementation from factory
for layer "Swish".
[coreml] Error in adding network -1.
[coreml] MLModelAsset: load failed with error Error Domain=com.apple.CoreML
Code=0 "Error in declaring network."
Core ML試圖實例化一個名為Swish的類,因為我們告訴轉(zhuǎn)換腳本類名是這個,但是它找不到這個類。所以我們需要在Swish.swift中實現(xiàn)它:
import Foundation
import CoreML
import Accelerate
@objc(Swish) class Swish: NSObject, MLCustomLayer {
required init(parameters: [String : Any]) throws {
print(#function, parameters)
super.init()
}
func setWeightData(_ weights: [Data]) throws {
print(#function, weights)
}
func outputShapes(forInputShapes inputShapes: [[NSNumber]]) throws
-> [[NSNumber]] {
print(#function, inputShapes)
return inputShapes
}
func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
print(#function, inputs.count, outputs.count)
}
}
這是你需要做的最低限度的工作。該類需要擴展NSObject,使用@objc()修飾符使其對Objective-C運行時可見,并實現(xiàn)MLCustomLayer協(xié)議。該協(xié)議由四個必需的方法和一個可選的方法組成:
- init(parameters) 構(gòu)造函數(shù)。參數(shù)是一個字典,它為該層提供了附加的配置選項(稍后將詳細介紹)。
- setWeightData() 為具有可訓(xùn)練權(quán)重的層賦值(稍后將詳細介紹)。
- outputShapes(forInputShapes) 這決定了層如何修改輸入數(shù)據(jù)的大小。我們的Swish激活函數(shù)不會改變層的大小,因此我們只是返回輸入形狀。
- evaluate(inputs, outputs) 執(zhí)行實際的計算-這是魔術(shù)發(fā)生的地方!此方法是必需的,當(dāng)模型在CPU上運行時將調(diào)用此方法。
- encode(commandBuffer, inputs, outputs) 此方法是可選的。它也實現(xiàn)了在GPU上的計算。
所以有兩種不同的函數(shù)提供層的實現(xiàn):一個用于CPU,一個用于GPU。CPU方法是必需的——您必須始終至少提供層的CPU版本。GPU方法是可選的,但是推薦使用。
目前,Swish類沒有做任何事情,但它足以在設(shè)備上(或在模擬器中)實際運行模型。給定256×256像素輸入圖像,Swish.swift打印中的打印語句輸出如下:
init(parameters:) ["engineName": Swish]
setWeightData []
outputShapes(forInputShapes:) [[1, 1, 6, 256, 256]]
outputShapes(forInputShapes:) [[1, 1, 6, 256, 256]]
outputShapes(forInputShapes:) [[1, 1, 6, 256, 256]]
outputShapes(forInputShapes:) [[1, 1, 6, 256, 256]]
outputShapes(forInputShapes:) [[1, 1, 6, 256, 256]]
evaluate(inputs:outputs:) 1 1
顯然,首先調(diào)用init(parameters),它的參數(shù)字典包含一個項目“engineName”,其值是Swish。很快我將向您展示如何將自己的參數(shù)添加到這個字典中。
其次調(diào)用setWeightData(),這將得到一個空數(shù)組。那是因為我們沒有在這個層中加入任何可學(xué)習(xí)的權(quán)重(稍后我們將討論)。
然后一行多次調(diào)用outputShapes(forInputShapes:)。我不確定為什么它被如此頻繁地調(diào)用,但是沒什么大不了的,因為無論如何我們沒有用那種方法做很多工作。
注意,這些形狀是以五個維度給出的。這使用了以下約定:
[ sequence, batch, channel, height, width ]
我們的Swish層接收一個6個通道的256×256像素的圖像。(為什么有6個頻道?回想一下模型定義,這個Swish層應(yīng)用于Conv2D層的輸出,而卷積層有6個濾波器。)
最后,調(diào)用evaluate(inputs, outputs)來執(zhí)行該層的計算。它接受一個MLMultiArray對象數(shù)組作為輸入,并生成一個新MLMultiArray對象數(shù)組作為輸出(這些輸出對象已經(jīng)被分配,所以很方便——我們只需要填充它們)。
它獲得MLMultiArray對象數(shù)組的原因是某些類型的層可以接受多個輸入或產(chǎn)生多個輸出。在上面的調(diào)試輸出中可以看到,我們只得到了其中的一個,因為我們的模型非常簡單。
好的,讓我們真正實現(xiàn)這個Swish激活函數(shù):
func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
for i in 0..<inputs.count {
let input = inputs[I]
let output = outputs[I]
assert(input.dataType == .float32)
assert(output.dataType == .float32)
assert(input.shape == output.shape)
for j in 0..<input.count {
let x = input[j].floatValue
let y = x / (1 + exp(-x)) // look familiar?
output[j] = NSNumber(value: y)
}
}
}
與大多數(shù)激活函數(shù)一樣,Swish是按元素進行的操作,因此它循環(huán)遍歷輸入數(shù)組中的所有值,計算x/(1+exp(-x))并將結(jié)果寫入輸出數(shù)組。
重點:MLMultiArray支持不同的數(shù)據(jù)類型。在這種情況下,我們假設(shè)數(shù)據(jù)類型是.float32,即單精度浮點數(shù),這對我們的模型是正確的。但是,MLMultiArray也可以支持int32和double,因此需要確保層類能夠處理Core ML拋出的任何數(shù)據(jù)類型。(這里我使用了一個簡單的斷言來使應(yīng)用程序崩潰,但是最好拋出一個錯誤并讓Core ML進行適當(dāng)?shù)那謇怼#?/p>
如果我們現(xiàn)在運行應(yīng)用程序,預(yù)測的輸出是:
[0.02245793305337429, 0.06994961202144623, 0.007555192802101374,
0.00138940173201263, 0.005514328368008137, 0.8003641366958618,
0.01428837608546019, 0.0003574613947421312, 0.005404338706284761,
0.07271922379732132]
這與Keras輸出完全匹配!
那么現(xiàn)在我們完成了嗎?是的,如果你不介意代碼變慢的話。我們可以加快一點(實際上很多)。
使用Accelerate加速代碼
evaluate(inputs, outputs)函數(shù)是在CPU上執(zhí)行的,我們使用一個簡單的for循環(huán)。這對于實現(xiàn)和調(diào)試層算法的第一個版本很有用,但是它運行速度不快。
更糟糕的是,當(dāng)我們以這種方式使用MLMultiArray時,我們訪問的每個值都會得到NSNumber對象。直接訪問MLMultiArray內(nèi)存中的浮點值要快得多。
我們將使用向量化的CPU函數(shù)代替for循環(huán)。幸運的是,Accelerate框架使此操作變得簡單——但是我們必須使用指針,這使得代碼的可讀性稍微降低。
func evaluate(inputs: [MLMultiArray], outputs: [MLMultiArray]) throws {
for i in 0..<inputs.count {
let input = inputs[i]
let output = outputs[i]
let count = input.count
let iptr = UnsafeMutablePointer<Float>(OpaquePointer(input.dataPointer))
let optr = UnsafeMutablePointer<Float>(OpaquePointer(output.dataPointer))
// output = -input
vDSP_vneg(iptr, 1, optr, 1, vDSP_Length(count))
// output = exp(-input)
var countAsInt32 = Int32(count)
vvexpf(optr, optr, &countAsInt32)
// output = 1 + exp(-input)
var one: Float = 1
vDSP_vsadd(optr, 1, &one, optr, 1, vDSP_Length(count))
// output = x / (1 + exp(-input))
vvdivf(optr, iptr, optr, &countAsInt32)
}
}
對于for循環(huán),我們將公式output=input/(1+exp(-input))應(yīng)用于每個數(shù)組值。但是在這里,我們將這個公式分成單獨的步驟,并且同時將每個步驟應(yīng)用于所有數(shù)組值。
首先,我們使用vDSP_vneg()一次性計算輸入數(shù)組中所有值的-input。中間結(jié)果被寫入輸出數(shù)組。然后,使用vvexpf()一次性對數(shù)組中的每個值進行指數(shù)化。我們使用vDSP_vsadd()對每個值添加1,最后執(zhí)行vvdivf()給出最終結(jié)果的除法。
結(jié)果和前面完全一樣,但是它是通過利用CPU的SIMD指令集以更有效的方式完成的。如果您要編寫自己的自定義層,我建議您盡可能多地使用Accelerate框架(這也是Core ML內(nèi)部為其自己的層使用的)。
即使啟用了優(yōu)化,for循環(huán)版本在iPhone 7上也花費了0.18秒。加速版本花費了0.0012秒。快150倍!
您可以在repo中的CPU only文件夾中找到此代碼。您可以在設(shè)備上或在模擬器中運行此應(yīng)用程序。試試看!
更快的速度:在GPU上運行
與其他機器學(xué)習(xí)框架相比,使用Core ML的優(yōu)勢在于,Core ML可以在CPU上或是在GPU上運行模型,而不需要您做任何額外的工作。對于大型神經(jīng)網(wǎng)絡(luò),它通常嘗試使用GPU,但是在沒有非常強大的GPU的較老設(shè)備上,它將回到使用CPU。
事實證明,Core ML也可以混合匹配。如果您的自定義層只有一個CPU實現(xiàn)(就像我們剛剛做的那樣),那么它仍然會在GPU上運行其他層,切換到用于自定義層的CPU,然后切換回GPU用于神經(jīng)網(wǎng)絡(luò)的其余部分。
因此,在自定義層中只使用CPU實現(xiàn)不會降低模型的其余部分的性能。然而,為什么不充分利用GPU呢?
對于Swish激活功能,GPU實現(xiàn)非常簡單。這是Metal shader代碼:
#include <metal_stdlib>
using namespace metal;
kernel void swish(
texture2d_array<half, access::read> inTexture [[texture(0)]],
texture2d_array<half, access::write> outTexture [[texture(1)]],
ushort3 gid [[thread_position_in_grid]])
{
if (gid.x >= outTexture.get_width() ||
gid.y >= outTexture.get_height()) {
return;
}
const float4 x = float4(inTexture.read(gid.xy, gid.z));
const float4 y = x / (1.0f + exp(-x)); // recognize this?
outTexture.write(half4(y), gid.xy, gid.z);
}
我們將對輸入數(shù)組中的每個數(shù)據(jù)元素調(diào)用這個計算內(nèi)核一次。因為Swish是按元素進行的操作,所以我們可以在這里簡單地編寫熟悉的公式x/(1.0f+exp(-x))。
與以前使用MLMultiArray不同,這里的數(shù)據(jù)放在Metal紋理對象中。MLMultiArray的數(shù)據(jù)類型是32位浮點數(shù),但這里我們實際處理的是16位浮點數(shù)或者half。請注意,即使紋理類型是half,我們也要使用浮點值進行實際計算,否則會損失太多的精度,而答案將是完全錯誤的。
回想一下,數(shù)據(jù)有6個通道深。這就是為什么計算內(nèi)核使用texture_array,它是由多個“片”組成的Metal紋理。在我們的演示應(yīng)用程序中,紋理數(shù)組只包含2個切片(總共8個通道,所以最后兩個通道被忽略),但是上面的計算內(nèi)核將處理任意數(shù)量的切片/通道。
要使用這個GPU計算內(nèi)核,我們必須向Swift類添加一些代碼:
@objc(Swish) class Swish: NSObject, MLCustomLayer {
let swishPipeline: MTLComputePipelineState
required init(parameters: [String : Any]) throws {
// Create the Metal compute kernels.
let device = MTLCreateSystemDefaultDevice()!
let library = device.makeDefaultLibrary()!
let swishFunction = library.makeFunction(name: "swish")!
swishPipeline = try! device.makeComputePipelineState(
function: swishFunction)
super.init()
}
這是將Metal swish內(nèi)核函數(shù)加載到MTLComputePipelineState對象中的樣式化代碼。我們還需要添加以下方法:
func encode(commandBuffer: MTLCommandBuffer,
inputs: [MTLTexture], outputs: [MTLTexture]) throws {
if let encoder = commandBuffer.makeComputeCommandEncoder() {
for i in 0..<inputs.count {
encoder.setTexture(inputs[i], index: 0)
encoder.setTexture(outputs[i], index: 1)
encoder.dispatch(pipeline: swishPipeline, texture: inputs[I])
encoder.endEncoding()
}
}
}
如果MLCustomLayer類中存在此方法,那么該層將在GPU上運行。在這個方法中,您將“compute pipeline state”編碼為MTLCommandBuffer。多半又是樣式化代碼。encoder.dispatch()方法確保對輸入紋理中的每個通道中的每個像素調(diào)用一次計算內(nèi)核。有關(guān)詳細信息,請參閱源代碼。
現(xiàn)在,當(dāng)您運行應(yīng)用程序(在一個相當(dāng)新的設(shè)備上)時,encode(commandBuffer, inputs, outputs) 函數(shù)被調(diào)用,而不是evaluate(inputs, outputs),GPU有幸計算swish激活函數(shù)。
您應(yīng)該得到與以前相同的輸出。這很有意義——您希望自定義層的CPU和GPU版本計算完全相同的答案!
注意:您不能在模擬器上運行Metal應(yīng)用程序,所以這個版本的應(yīng)用程序只能在真機上運行。一部iPhone 6或者更好的就行了。如果設(shè)備太舊,Core ML仍將使用CPU而不是GPU運行模型。
進一步:如果您以前使用過MPSCNN,那么請注意,Core ML使用GPU有一些不同。對于MPSCNN,您處理的是MPSImage對象,但是Core ML為您提供了MTLTexture。像素格式似乎是.rgba16Float,這與MPSImage的.float16通道格式相對應(yīng)。
使用MPSCNN,具有4個通道或更少通道的圖像使用type2D紋理,超過4個通道的圖像使用type2DArray紋理。這意味著,對于MPSCNN,您可能必須編寫兩個版本的計算內(nèi)核:一個采用texture對象,另一個采用texture_array對象。據(jù)我所知,對于Core ML,紋理總是type2DArray,即使有4個通道或更少,因此只需要編寫一個版本的計算內(nèi)核。
參數(shù)和權(quán)重
現(xiàn)在我們有了一個帶有相應(yīng)的Swift實現(xiàn)的自定義層。不錯,但這只是一個非常簡單的層。
我們還可以向該層添加參數(shù)和權(quán)重。“參數(shù)”在此上下文中表示可配置設(shè)置,例如卷積層的內(nèi)核大小和在該層周圍添加的填充量。
在我們的例子中,我們可以將beta設(shè)置為一個參數(shù)。還記得beta嗎?beta的值決定了Swish函數(shù)有多陡峭。到目前為止,我們已經(jīng)實現(xiàn)的Swish版本是:
swish(x) = x * sigmoid(x)
但是記住完整的定義是這樣
swish(x) = x * sigmoid(beta * x)
beta是一個數(shù)字。到目前為止,我們假設(shè)beta總是1.0,但是我們可以把它配置為一個參數(shù),或者甚至讓模型在訓(xùn)練時學(xué)習(xí)beta的值,在這種情況下,我們將它看作一個權(quán)重。
要向自定義層添加參數(shù)或權(quán)重,請按以下方式更改轉(zhuǎn)換函數(shù):
def convert_lambda(layer):
if layer.function == swish:
params = NeuralNetwork_pb2.CustomLayerParams()
. . .
# Set configuration parameters
params.parameters["someNumber"].intValue = 100
params.parameters["someString"].stringValue = "Hello, world!"
# Add some random weights
my_weights = params.weights.add()
my_weights.floatValue.extend(np.random.randn(10).astype(float))
return params
else:
return None
現(xiàn)在,當(dāng)您運行該應(yīng)用程序時,Swish類將在init(parameters)方法中接收帶有這些整數(shù)和字符串值的參數(shù)字典,通過setWeightData()中的Data對象接收權(quán)重。
讓我們添加beta作為參數(shù)。為此,我們應(yīng)該遠離Lambda層,并將Swish激活函數(shù)轉(zhuǎn)換為適當(dāng)?shù)腒eras層對象。Lambda層非常適合于簡單的計算,但是現(xiàn)在我們希望給Swish層一些狀態(tài)(beta的值),創(chuàng)建一個Layer子類是更好的方法。
在Python腳本 convert_subclass.py中,我們現(xiàn)在將Swish函數(shù)定義為Layer的子類:
from keras.engine.topology import Layer
class Swish(Layer):
def __init__(self, beta=1., **kwargs):
super(Swish, self).__init__(**kwargs)
self.beta = beta
def build(self, input_shape):
super(Swish, self).build(input_shape)
def call(self, x):
return K.sigmoid(self.beta * x) * x
def compute_output_shape(self, input_shape):
return input_shape
注意,這如何在構(gòu)造函數(shù)中采用beta值。Keras中的call()函數(shù)等價于swift中的evaluate(inputs, outputs)。在call()函數(shù)中,我們計算Swish公式——這次包含beta。
新的模型定義如下所示:
def create_model():
inp = Input(shape=(256, 256, 3))
x = Conv2D(6, (3, 3), padding="same")(inp)
x = Swish(beta=0.01)(x) # look here!
x = GlobalAveragePooling2D()(x)
x = Dense(10, activation="softmax")(x)
return Model(inp, x)
beta的值是一個超參數(shù),它是在模型構(gòu)建時定義的。這里我選擇使用beta=0.01,這樣我們就會得到與以前不同的預(yù)測。
順便說一下,這里是Swish在beta 0.01中的樣子,它幾乎是一條直線:

為了將這個層轉(zhuǎn)換為Core ML,我們需要為它建一個轉(zhuǎn)換函數(shù):
def convert_swish(layer):
params = NeuralNetwork_pb2.CustomLayerParams()
params.className = "Swish"
params.description = "A fancy new activation function"
# Add the hyperparameter to the dictionary
params.parameters["beta"].doubleValue = layer.beta
return params
這與以前非常相似,只是現(xiàn)在我們從層(這是我們剛剛創(chuàng)建的新Swish類的實例)讀取beta屬性,并將其粘貼到CustomLayerParams的參數(shù)字典中。注意,這個字典不支持32位浮點,只支持64位雙精度浮點(以及整數(shù)和布爾值),所以我們使用.doubleValue。
當(dāng)我們調(diào)用Keras轉(zhuǎn)換器時,我們必須告訴它這個新的轉(zhuǎn)換函數(shù):
coreml_model = coremltools.converters.keras.convert(
model,
input_names="image",
image_input_names="image",
output_names="output",
add_custom_layers=True,
custom_conversion_functions={ "Swish": convert_swish })
這一切都非常類似于我們之前所做的,除了現(xiàn)在Swish不是包裝在Lambda對象中的基本Python函數(shù),而是從Keras Layer基類派生的一個成熟的類。
在iOS方面,我們需要調(diào)整Swish.swift以從參數(shù)字典中讀出這個“beta”值并將其應(yīng)用于計算。
@objc(Swish) class Swish: NSObject, MLCustomLayer {
let beta: Float
required init(parameters: [String : Any]) throws {
if let beta = parameters["beta"] as? Float {
self.beta = beta
} else {
self.beta = 1
}
...
}
在evaluate(inputs, outputs) 時,我們現(xiàn)在用self.beta乘以輸入。
同樣,對于Metal compute shader,在encode(commandBuffer, inputs, outputs)中,我們可以將self.beta傳遞到計算內(nèi)核中,如下所示:
var beta = self.beta
encoder.setBytes(&beta, length: MemoryLayout<Float>.size, index: 0)
然后在Metak內(nèi)核中:
kernel void swish(
texture2d_array<half, access::read> inTexture [[texture(0)]],
texture2d_array<half, access::write> outTexture [[texture(1)]],
constant float& beta [[buffer(0)]],
ushort3 gid [[thread_position_in_grid]])
{
...
const float4 y = x / (1.0f + exp(-x * beta));
...
}
請參閱源代碼以獲得完整的更改。我希望解釋的足夠清楚,使您能很容易的配置參數(shù)添加到定制層中。
注意:當(dāng)我運行這個新版本的iOS應(yīng)用程序時,預(yù)測結(jié)果與Keras的結(jié)果并不100%匹配。當(dāng)Core ML使用GPU時,這種不匹配的情況很常見。卷積層在GPU上運行,帶有16位浮點數(shù),這降低了精度,而Keras對一切都使用32位浮點數(shù)。所以您一定會看到來自iOS模型和來自原始Keras模型的預(yù)測之間的差異。只要差別很?。ù蠹s1e-3或更?。┚涂梢越邮堋?/p>
可學(xué)習(xí)權(quán)重
最后一件事我想告訴你。機器學(xué)習(xí)的全部意義在于學(xué)習(xí)東西,所以對于許多定制層,您希望能夠賦予它們可學(xué)習(xí)的權(quán)重。因此,讓我們再一次改變Swish層的實現(xiàn)以使beta可以學(xué)習(xí)。這讓模型學(xué)習(xí)激活函數(shù)的最佳形狀是什么。
Swish層仍然是Layer的一個子類,但是這次我們通過add_weight()函數(shù)來賦予它一個可學(xué)習(xí)權(quán)重:
class LearnableSwish(Layer):
def __init__(self, **kwargs):
super(LearnableSwish, self).__init__(**kwargs)
def build(self, input_shape):
self.beta = self.add_weight(
name="beta",
shape=(input_shape[3], ),
initializer=keras.initializers.Constant(value=1),
trainable=True)
super(LearnableSwish, self).build(input_shape)
def call(self, x):
return K.sigmoid(self.beta * x) * x
def compute_output_shape(self, input_shape):
return input_shape
我們將為輸入數(shù)據(jù)中的每個通道創(chuàng)建可學(xué)習(xí)的權(quán)重,而不是單個beta值,這就是為什么我們使用shape=(input_shape[3], )。在該示例中,因為來自前一個Conv2D層的輸出有6個通道,所以該層將學(xué)習(xí)6個不同的beta值。beta的初始值為1,這似乎是一個合理的缺省值。
現(xiàn)在,當(dāng)您調(diào)用model.fit(...)來訓(xùn)練模型時,它將學(xué)習(xí)每個通道的最佳beta值。
在轉(zhuǎn)換函數(shù)中,我們必須執(zhí)行以下操作以將這些學(xué)習(xí)到的權(quán)重放入mlmodel文件中:
def convert_learnable_swish(layer):
params = NeuralNetwork_pb2.CustomLayerParams()
. . .
beta_weights = params.weights.add()
beta_weights.floatValue.extend(layer.get_weights()[0].astype(float))
return params
以上是Keras中所需要做的所有工作。
在運行iOS應(yīng)用程序時,您將注意到setWeightData() 現(xiàn)在接收一個包含24字節(jié)的Data對象。就是6通道乘以每個浮點數(shù)的4字節(jié)。
使用Swish.swift層代碼從這個權(quán)重數(shù)組讀取beta并在計算中使用它,這相當(dāng)簡單。與以前的主要區(qū)別是,我們知道,在數(shù)據(jù)中有許多不同的beta值。我將把這個留給讀者作為練習(xí)。