译者注:这篇文章从如何在Keras中建立自定义层,讲到如何建立、训练Keras模型,如何转换为Core ML模型,以及如何在app中使用自定义层,如何使用Accelerate加速代码,如何使用GPU加速代码。内容非常全面,学习Core ML自定义层不可错过的优秀文章,译者笔力有限,英文水平过得去的可以看英文原文。
苹果新的Core ML框架使得在iOS app中添加机器学习模型变得很容易。但有一个很大的局限是Core ML只支持有限的神经网络层类型。更糟糕的是,作为应用程序开发人员,不可能扩展Core ML的功能。
好消息:从iOS 11.2开始,Core ML现在支持定制层!在我看来,这使Coew ML更加有用。
在本文中,我将展示如何将具有自定义层的Keras模型转换为Core ML。
步骤如下:
- 创建具有自定义层的Keras模型
- 使用coremltools将Keras转换为mlmodel
- 为自定义层实现Swift类
- 将Core ML模型放到iOS应用程序中并运行它
- 利润!
像往常一样,您可以在GitHub上找到源代码。运行环境为Python 2、TensorFlow、Keras、coremltools和Xcode 9。
注意:我选择Keras作为这个博客帖子,因为它易于使用和解释,但是使定制层以相同的方式工作,而不管您使用什么工具来训练模型。
Swish!
让我们实现一个名为Swish的激活函数(activation function),演示如何创建自定义层。
“等等…”,你可能会说,“我以为这篇文章是关于自定义层的,而不是定制激活函数?”哦,这要看你怎么看待事物。
您可以认为激活函数是非线性的应用于层的输出,但是您也可以将激活函数视为它们自己的层。在许多深度学习软件包中,包括Keras,激活功能实际上被看作独立的层。
Core ML只支持一组固定的激活函数,比如标准的ReLU和sigmoid激活。(完整的列表在NeuralNetwork.proto中,它是mlmodel规范的一部分。)
但时常有人发明一种奇特的新激活函数,如果你想在Core ML模型中使用它,那你只能编写自己的自定义层。这就是我们要做的。
我们将实现Swish激活函数。公式是:
swish(x) = x * sigmoid(beta * x)
其中sigmoid是著名的logistic sigmoid函数1/(1+exp(-x))。因此,Swish的完整定义是:
swish(x) = x / (1 + exp(-beta * x))
这里,x是输入值,beta可以是常数或可训练的参数。不同的beta会改变Swish函数的曲线。
用beta=1.0进行刷新看起来是这样的:
是不是很像无处不在的ReLU激活函数,不同的是Swish在左手边是平滑的,而不是在x=0处进行突然改变(这给Swish提供了一个不错的、干净的导数)。
beta值越大,Swish看起来越像ReLU。beta越接近0,Swish看起来越像直线。(如果你好奇,试试看。)
显然,这种Swish激活使您的神经网络比ReLU更容易学习,并且也给出了更好的结果。您可以在“Searching for Activation Functions”一文中阅读更多关于Swish的信息。
为了简化示例,最初我们将使用beta=1,但是稍后我们将使用beta作为一个可学习的参数。
Keras模型
撰写本文时,Swish还不够流行,没有进入Keras。所以我们还要编写一个定制的Keras层。它很容易实现:
from keras import backend as K
def swish(x):
return K.sigmoid(x) * x
这里,x是一个张量,我们简单地把它和K.sigmoid函数的结果相乘。K是对Keras后端的引用,后者通常是TensorFlow。现在我将beta排除在代码之外(这与beta=1相同)。
为了在Keras模型中使用这个自定义激活函数,我们可以编写以下代码:
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)。这在前一层的输出上调用新的swish函数,该层在本例中是卷积层。
Lambda层是一个特殊的Keras类,它非常适合于只使用函数或lambda表达式(类似于Swift中的闭包)编写快速但不完善的层。Lambda对于没有状态的层很有用,在Keras模型中通常用于进行基本计算。
注意:您还可以通过创建Layer子类在Keras中创建更高级的自定义层,稍后我们将看到一个示例。
激活呢?
如果您是Keras用户,那么您可能习惯于为这样的层指定激活函数:
x = Conv2D(..., activation="swish")(x)
或者像这样
x = Conv2D(6, (3, 3), padding="same")(inp)
x = Activation(swish)(x)
在Keras中我们通常使用Activation层,而不是使用Lambda作为激活函数。
不幸的是,0.7版的coremltools不能转换自定义激活,只能转换自定义层。如果试图转换使用Activation(...),而它不是Keras内置激活函数之一,coremltools将给出错误消息:
RuntimeError: Unsupported option activation=swish in layer Activation
解决方法是使用Lambda层替代Activation。
特别指出来,因为这是一个稍微令人讨厌的限制。我们可以使用自定义层来实现不支持的激活函数,但是模型编码中不能使用Activation(func)或activation="func"。在使用coremltools Keras转换器之前,必须先用Lambda层替换它们。
注意:或者,您可以使用coremltools的NeuralNetworkBuilder类从头创建模型。这样,您不受Keras转换器理解的限制,但是也不太方便。
在我们将这个模型转换为Core ML之前,应该先给它一些权重。
“训练”模型
在这篇文章的源代码中,我创建了Keras模型,它写在转换脚本_lambda.py之中。在实践中,您可能有不同的用于训练和转换的脚本,但是对于这个示例,我们不会烦恼训练。(不管怎么说,这是个粗糙的模型。)
首先,我们使用您刚才看到的create_model()函数创建模型的实例:
model = create_model()
model.compile(loss="categorical_crossentropy", optimizer="Adam",
metrics=["accuracy"])
model.summary()
我们不训练模型,而是给它随机加权:
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)
通常训练过程会填补这些权重,但是为了这个博客的目的,我们只是假装。
为了获得一些输出,我们在输入图像上测试模型:
这是一个256×256像素的RGB图像。你可以使用任何你想要的图像,但是我的猫自愿做这份工作。以下是加载图像、将其加入神经网络并输出结果的代码:
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)
预测输出是:
[[ 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]]
这些数字没有任何意义……毕竟,这只是一个非常基本的模型,我们没有对其进行训练。没关系,在这个阶段,我们只是想得到一些有关输入图像的输出。
在将模型转换为Core ML之后,我们希望iOS应用程序为相同的输入图像提供完全相同的输出。如果做到了,可以证明转换是正确的,我们的自定义层可以正常工作。
注:有可能你的电脑会有不同的输出。不用担心,只要每次运行脚本时得到相同的数字就好。
转换模型
现在让我们将这个非常基本的模型转换为Core ML mlmodel文件。如果一切顺利,生成的mlmodel文件将不仅包含标准Keras层,而且还包含我们的自定义lambda层。然后,我们将编写这个层的Swift实现,以便可以在iOS上运行模型。
注意:我使用coremltools 0.7版本进行转换。随着软件的不断改进,在您阅读本文时,它的行为可能会稍有不同。有关使用和安装说明,请查看文档。
将Keras模型转换为Core ML非常简单,只需调用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 })
这引用了我们刚刚创建的模型,以及模型的输入和输出的名称。
对于我们的目的来说特别重要的是add_custom_layers=True,它告诉转换器检测自定义层。但是转换器还需要知道一旦找到这样的层该做什么——这就是custom_conversion_functions的用途。
custom_conversion_functions参数接受一个字典,该字典将层类型的名称映射为所谓的“转换函数”。我们还需要编写这个函数:
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
此函数接收Keras层对象,并应返回CustomLayerParams对象。CustomLayerParams对象告诉Core ML如何处理这个层。
CustomLayerParams在NeuralNetwork.proto中定义。它具有以下字段:
- className
- description
- parameters
- weights
至少你应该填写className字段。这是在iOS上实现这一层的Swift或Objective-C类的名称。我选择简单地将这个类命名为Swish。
如果不填写className,Xcode将显示以下错误,并且不能使用模型:
其他字段是可选的。description显示在Xcode的mlmodel查看器中,parameters是一个带有附加定制选项的字典,weights包含层的学习参数(如果有的话)。
现在我们有了转换函数,我们可以使用coremltools.converters.keras.convert() 运行Keras转换器,它将为模型中遇到的任何Lambda层调用convert_lambda()。
注意:convert_lambda()函数将针对网络中的每个Lambda层调用,因此如果具有具有不同函数的多个Lambda层,则需要在它们之间消除歧义。这就是为什么我们首先执行layer.function == swish的原因。
转换过程中的最后一步是填充模型的元数据并保存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")
当您运行转换脚本时,coremltools将打印出它所找到的所有层并转换:
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激活功能的层。转换没有给出任何错误,这意味着我们已经准备好将.mlmodel文件放入应用程序中!
注意:您不是必须使用转换函数。另一种填写自定义层详细信息的方法是传递custom_conversion_functions={}。(省略它就会出错,但是空字典也可以。)然后调用coremltools.converters.keras.convert()。这将在模型中包括您的自定义层,但不会给它任何属性。然后,执行以下操作:
layer = coreml_model._spec.neuralNetwork.layers[1]
layer.custom.className = "Swish"
这将获取层并直接更改其属性。无论哪种方式都可以,只要在保存mlmodel文件时已经填充了className。
将模型放入app
在应用程序中添加Core ML模型非常简单:只需将mlmodel文件拖放到Xcode项目中即可。
Xcode mlmodel查看器展示转换后的模型如下所示:
它像往常一样显示输入和输出,并且在新的Dependencies部分列出自定义层以及哪些类实现它们。
我已经创建了一个演示应用程序,它使用Vision框架运行模型,并与Python脚本使用的相同图片。它将预测数字打印到Xcode输出窗格。回想一下,这个模型实际上没有计算任何有意义的内容——因为我们没有训练它——但是它应该给出与Python相同的结果。
在将mlmodel文件添加到应用程序之后,您需要提供一个实现自定义层的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的类,因为我们告诉转换脚本类名是这个,但是它找不到这个类。所以我们需要在Swish.swift中实现它:
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运行时可见,并实现MLCustomLayer协议。该协议由四个必需的方法和一个可选的方法组成:
- init(parameters) 构造函数。参数是一个字典,它为该层提供了附加的配置选项(稍后将详细介绍)。
- setWeightData() 为具有可训练权重的层赋值(稍后将详细介绍)。
- outputShapes(forInputShapes) 这决定了层如何修改输入数据的大小。我们的Swish激活函数不会改变层的大小,因此我们只是返回输入形状。
- evaluate(inputs, outputs) 执行实际的计算-这是魔术发生的地方!此方法是必需的,当模型在CPU上运行时将调用此方法。
- encode(commandBuffer, inputs, outputs) 此方法是可选的。它也实现了在GPU上的计算。
所以有两种不同的函数提供层的实现:一个用于CPU,一个用于GPU。CPU方法是必需的——您必须始终至少提供层的CPU版本。GPU方法是可选的,但是推荐使用。
目前,Swish类没有做任何事情,但它足以在设备上(或在模拟器中)实际运行模型。给定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
显然,首先调用init(parameters),它的参数字典包含一个项目“engineName”,其值是Swish。很快我将向您展示如何将自己的参数添加到这个字典中。
其次调用setWeightData(),这将得到一个空数组。那是因为我们没有在这个层中加入任何可学习的权重(稍后我们将讨论)。
然后一行多次调用outputShapes(forInputShapes:)。我不确定为什么它被如此频繁地调用,但是没什么大不了的,因为无论如何我们没有用那种方法做很多工作。
注意,这些形状是以五个维度给出的。这使用了以下约定:
[ sequence, batch, channel, height, width ]
我们的Swish层接收一个6个通道的256×256像素的图像。(为什么有6个频道?回想一下模型定义,这个Swish层应用于Conv2D层的输出,而卷积层有6个滤波器。)
最后,调用evaluate(inputs, outputs)来执行该层的计算。它接受一个MLMultiArray对象数组作为输入,并生成一个新MLMultiArray对象数组作为输出(这些输出对象已经被分配,所以很方便——我们只需要填充它们)。
它获得MLMultiArray对象数组的原因是某些类型的层可以接受多个输入或产生多个输出。在上面的调试输出中可以看到,我们只得到了其中的一个,因为我们的模型非常简单。
好的,让我们真正实现这个Swish激活函数:
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)
}
}
}
与大多数激活函数一样,Swish是按元素进行的操作,因此它循环遍历输入数组中的所有值,计算x/(1+exp(-x))并将结果写入输出数组。
重点:MLMultiArray支持不同的数据类型。在这种情况下,我们假设数据类型是.float32,即单精度浮点数,这对我们的模型是正确的。但是,MLMultiArray也可以支持int32和double,因此需要确保层类能够处理Core ML抛出的任何数据类型。(这里我使用了一个简单的断言来使应用程序崩溃,但是最好抛出一个错误并让Core ML进行适当的清理。)
如果我们现在运行应用程序,预测的输出是:
[0.02245793305337429, 0.06994961202144623, 0.007555192802101374,
0.00138940173201263, 0.005514328368008137, 0.8003641366958618,
0.01428837608546019, 0.0003574613947421312, 0.005404338706284761,
0.07271922379732132]
这与Keras输出完全匹配!
那么现在我们完成了吗?是的,如果你不介意代码变慢的话。我们可以加快一点(实际上很多)。
使用Accelerate加速代码
evaluate(inputs, outputs)函数是在CPU上执行的,我们使用一个简单的for循环。这对于实现和调试层算法的第一个版本很有用,但是它运行速度不快。
更糟糕的是,当我们以这种方式使用MLMultiArray时,我们访问的每个值都会得到NSNumber对象。直接访问MLMultiArray内存中的浮点值要快得多。
我们将使用向量化的CPU函数代替for循环。幸运的是,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循环,我们将公式output=input/(1+exp(-input))应用于每个数组值。但是在这里,我们将这个公式分成单独的步骤,并且同时将每个步骤应用于所有数组值。
首先,我们使用vDSP_vneg()一次性计算输入数组中所有值的-input。中间结果被写入输出数组。然后,使用vvexpf()一次性对数组中的每个值进行指数化。我们使用vDSP_vsadd()对每个值添加1,最后执行vvdivf()给出最终结果的除法。
结果和前面完全一样,但是它是通过利用CPU的SIMD指令集以更有效的方式完成的。如果您要编写自己的自定义层,我建议您尽可能多地使用Accelerate框架(这也是Core ML内部为其自己的层使用的)。
即使启用了优化,for循环版本在iPhone 7上也花费了0.18秒。加速版本花费了0.0012秒。快150倍!
您可以在repo中的CPU only文件夹中找到此代码。您可以在设备上或在模拟器中运行此应用程序。试试看!
更快的速度:在GPU上运行
与其他机器学习框架相比,使用Core ML的优势在于,Core ML可以在CPU上或是在GPU上运行模型,而不需要您做任何额外的工作。对于大型神经网络,它通常尝试使用GPU,但是在没有非常强大的GPU的较老设备上,它将回到使用CPU。
事实证明,Core ML也可以混合匹配。如果您的自定义层只有一个CPU实现(就像我们刚刚做的那样),那么它仍然会在GPU上运行其他层,切换到用于自定义层的CPU,然后切换回GPU用于神经网络的其余部分。
因此,在自定义层中只使用CPU实现不会降低模型的其余部分的性能。然而,为什么不充分利用GPU呢?
对于Swish激活功能,GPU实现非常简单。这是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);
}
我们将对输入数组中的每个数据元素调用这个计算内核一次。因为Swish是按元素进行的操作,所以我们可以在这里简单地编写熟悉的公式x/(1.0f+exp(-x))。
与以前使用MLMultiArray不同,这里的数据放在Metal纹理对象中。MLMultiArray的数据类型是32位浮点数,但这里我们实际处理的是16位浮点数或者half。请注意,即使纹理类型是half,我们也要使用浮点值进行实际计算,否则会损失太多的精度,而答案将是完全错误的。
回想一下,数据有6个通道深。这就是为什么计算内核使用texture_array,它是由多个“片”组成的Metal纹理。在我们的演示应用程序中,纹理数组只包含2个切片(总共8个通道,所以最后两个通道被忽略),但是上面的计算内核将处理任意数量的切片/通道。
要使用这个GPU计算内核,我们必须向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内核函数加载到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()方法确保对输入纹理中的每个通道中的每个像素调用一次计算内核。有关详细信息,请参阅源代码。
现在,当您运行应用程序(在一个相当新的设备上)时,encode(commandBuffer, inputs, outputs) 函数被调用,而不是evaluate(inputs, outputs),GPU有幸计算swish激活函数。
您应该得到与以前相同的输出。这很有意义——您希望自定义层的CPU和GPU版本计算完全相同的答案!
注意:您不能在模拟器上运行Metal应用程序,所以这个版本的应用程序只能在真机上运行。一部iPhone 6或者更好的就行了。如果设备太旧,Core ML仍将使用CPU而不是GPU运行模型。
进一步:如果您以前使用过MPSCNN,那么请注意,Core ML使用GPU有一些不同。对于MPSCNN,您处理的是MPSImage对象,但是Core ML为您提供了MTLTexture。像素格式似乎是.rgba16Float,这与MPSImage的.float16通道格式相对应。
使用MPSCNN,具有4个通道或更少通道的图像使用type2D纹理,超过4个通道的图像使用type2DArray纹理。这意味着,对于MPSCNN,您可能必须编写两个版本的计算内核:一个采用texture对象,另一个采用texture_array对象。据我所知,对于Core ML,纹理总是type2DArray,即使有4个通道或更少,因此只需要编写一个版本的计算内核。
参数和权重
现在我们有了一个带有相应的Swift实现的自定义层。不错,但这只是一个非常简单的层。
我们还可以向该层添加参数和权重。“参数”在此上下文中表示可配置设置,例如卷积层的内核大小和在该层周围添加的填充量。
在我们的例子中,我们可以将beta设置为一个参数。还记得beta吗?beta的值决定了Swish函数有多陡峭。到目前为止,我们已经实现的Swish版本是:
swish(x) = x * sigmoid(x)
但是记住完整的定义是这样
swish(x) = x * sigmoid(beta * x)
beta是一个数字。到目前为止,我们假设beta总是1.0,但是我们可以把它配置为一个参数,或者甚至让模型在训练时学习beta的值,在这种情况下,我们将它看作一个权重。
要向自定义层添加参数或权重,请按以下方式更改转换函数:
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
现在,当您运行该应用程序时,Swish类将在init(parameters)方法中接收带有这些整数和字符串值的参数字典,通过setWeightData()中的Data对象接收权重。
让我们添加beta作为参数。为此,我们应该远离Lambda层,并将Swish激活函数转换为适当的Keras层对象。Lambda层非常适合于简单的计算,但是现在我们希望给Swish层一些状态(beta的值),创建一个Layer子类是更好的方法。
在Python脚本 convert_subclass.py中,我们现在将Swish函数定义为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
注意,这如何在构造函数中采用beta值。Keras中的call()函数等价于swift中的evaluate(inputs, outputs)。在call()函数中,我们计算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的值是一个超参数,它是在模型构建时定义的。这里我选择使用beta=0.01,这样我们就会得到与以前不同的预测。
顺便说一下,这里是Swish在beta 0.01中的样子,它几乎是一条直线:
为了将这个层转换为Core ML,我们需要为它建一个转换函数:
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
这与以前非常相似,只是现在我们从层(这是我们刚刚创建的新Swish类的实例)读取beta属性,并将其粘贴到CustomLayerParams的参数字典中。注意,这个字典不支持32位浮点,只支持64位双精度浮点(以及整数和布尔值),所以我们使用.doubleValue。
当我们调用Keras转换器时,我们必须告诉它这个新的转换函数:
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 })
这一切都非常类似于我们之前所做的,除了现在Swish不是包装在Lambda对象中的基本Python函数,而是从Keras Layer基类派生的一个成熟的类。
在iOS方面,我们需要调整Swish.swift以从参数字典中读出这个“beta”值并将其应用于计算。
@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) 时,我们现在用self.beta乘以输入。
同样,对于Metal compute shader,在encode(commandBuffer, inputs, outputs)中,我们可以将self.beta传递到计算内核中,如下所示:
var beta = self.beta
encoder.setBytes(&beta, length: MemoryLayout<Float>.size, index: 0)
然后在Metak内核中:
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));
...
}
请参阅源代码以获得完整的更改。我希望解释的足够清楚,使您能很容易的配置参数添加到定制层中。
注意:当我运行这个新版本的iOS应用程序时,预测结果与Keras的结果并不100%匹配。当Core ML使用GPU时,这种不匹配的情况很常见。卷积层在GPU上运行,带有16位浮点数,这降低了精度,而Keras对一切都使用32位浮点数。所以您一定会看到来自iOS模型和来自原始Keras模型的预测之间的差异。只要差别很小(大约1e-3或更小)就可以接受。
可学习权重
最后一件事我想告诉你。机器学习的全部意义在于学习东西,所以对于许多定制层,您希望能够赋予它们可学习的权重。因此,让我们再一次改变Swish层的实现以使beta可以学习。这让模型学习激活函数的最佳形状是什么。
Swish层仍然是Layer的一个子类,但是这次我们通过add_weight()函数来赋予它一个可学习权重:
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
我们将为输入数据中的每个通道创建可学习的权重,而不是单个beta值,这就是为什么我们使用shape=(input_shape[3], )。在该示例中,因为来自前一个Conv2D层的输出有6个通道,所以该层将学习6个不同的beta值。beta的初始值为1,这似乎是一个合理的缺省值。
现在,当您调用model.fit(...)来训练模型时,它将学习每个通道的最佳beta值。
在转换函数中,我们必须执行以下操作以将这些学习到的权重放入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应用程序时,您将注意到setWeightData() 现在接收一个包含24字节的Data对象。就是6通道乘以每个浮点数的4字节。
使用Swish.swift层代码从这个权重数组读取beta并在计算中使用它,这相当简单。与以前的主要区别是,我们知道,在数据中有许多不同的beta值。我将把这个留给读者作为练习。