Core ML中的自定义层(译)

原文:Custom Layers in Core ML

译者注:这篇文章从如何在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。

步骤如下:

  1. 创建具有自定义层的Keras模型
  2. 使用coremltools将Keras转换为mlmodel
  3. 为自定义层实现Swift类
  4. 将Core ML模型放到iOS应用程序中并运行它
  5. 利润!

像往常一样,您可以在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值。我将把这个留给读者作为练习。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容