▲点击上方“CocoaChina”关注即可免费学习iOS开发
原文:Convolutional Neural Networks in iOS 10 and macOS
作者:Geppy Parziale
译者:ALEX吴浩文
苹果在iOS 10和macOS 10.12的Metal Performance Shaders框架和Accelerate框架里,引入了新的卷积神经网络APIs。
我在一篇之前的文章里已经介绍了iOS上的机器学习(ML)和人工神经网络(ANN)。如果你对这些不熟悉,建议你先读读那篇文章。
我最近参加了CVPR 2016,一个计算机视觉与模式识别会议。我在那得知最近卷积神经网络被世界各地的大学和公司用于几乎所有的研究工作。卷积神经网络在计算机视觉的不同领域的流行,再加上手机上又快又强的GPU,使卷积神经网络也成为移动开发的一个极具吸引力的利器。卷积神经网络和深度学习打开了移动应用创新的大门。
我从五年前在苹果工作时开始接触卷积神经网络(CNNs)。当时可不像今天,可用的文献和工具都极其有限。我曾用CNNs建立一个iOS和OS X上的光学字符识别(OCR)。它是iOS 5的一个实现。OCR的准确度是惊人的,即使当时设备上的实现是用的CPU。
在这之后,我继续对其他类型的应用CNNs。最近,我用CNNs来进行人脸识别和面部表情识别。我们得到的结果是惊人的。
卷积(Convolution)
CNN把一种大量使用的很常见的信号处理操作称为卷积。卷积是把数组(或矩阵)的邻近元素进行加权求和。其中使用的权重是由一个输入数组定义的,它通常被称为核(kernel)、滤镜(filter)或卷积的遮罩(mask)。
卷积是一种非常重要的数字信号(音频、视频、图像)处理,因此图形处理单元(GPU)优化了它。如果你想从事CNNs工作,GPU是最重要的实现工具。
作为人类,我们也在我们的日常活动中使用卷积,尤其那些涉及到我们五感的活动。例如,当我们听音乐或盯着东西看时,我们的大脑对外部世界的声音和光信息在执行着每秒数百万次的卷积。
一维卷积的例子
让我们构建一个例子来更好地理解卷积是如何工作的。下面的图显示了一个输入数组或一维信号x[n]与一维核数组w[n]的卷积。
在这个例子中,我任意假设输入数组的值是1、2、…7,核数组的值是1、1、2、1、1。前面的图显示了输出序列y[n]的元素(或样本)y[2]是如何被计算的。
在一般情况下,核的个数往往是奇数,这使得在被计算元素周围的加权和计算是对称的。远小于输入序列x[n]的核也是很常见的。核的中央元素被用作我们想处理的输入信号中的元素的重量,其他元素则作为被计算元素左右两边的元素的权重。
概括这个例子,如果x[n]是一个输入序列,w[m]是一个核序列,那么卷积操作的结果y[n]可以用以下的数学表达式表示:
注意一下序列w[m]在操作中第一个被反转和转化。
如果我们用以前的数学公式来计算之前例子中y[n]的每个元素,我们得到以下结果:
既然卷积由相邻元素的顺序定义,那么靠近数组结尾的输出元素自然存在边界条件。为了避免这个问题,一钟很常见的做法是在输入序列x[n]的两端添加足够的元素(称为鬼元素)。如果你添加0,这个操作被称为零填充。其他方法也可以。在实现卷积时,你需要解决填充问题。
Swift的卷积
让我们来看看如何用Swift实现卷积。假设我们有以下的输入数组x和核数组w:
let x: [Float] = [1, 2, 3, 4, 5], M = x.count
let w: [Float] = [1, 2, 3], N = w.count
let T = N+M-1 // 这个之后需要
在我们开始之前,如上所述让我们添加N-1个0到序列x,和M-1个0到核来容纳计算。你可以使用以下函数:
func pad(sequence x: [Float], other sequence: [Float]) -> [Float] {
return x + [Float](repeatElement(0, count: sequence.count-1))
}
所以,填充过的新序列是:
let paddedX: [Float] = pad(sequence: x, other: kernel)
let paddedK: [Float] = pad(sequence: kernel, other: x)
现在,我们可以建立paddedX和paddedK之间的一个卷积:
最后,卷积的结果是:
// y = [1, 4, 10, 16, 22]
Accelerate的卷积
如果你想加速卷积处理,你可以使用Accelerate框架提供的vDSP_conv函数。同样,我需要处理边界条件和核反转。这一次,我对输入数组和核换个零填充的方式。另外,我需要反转核(文档里有解释),否则我得到的是两个序列的相关性。
以下是用Accelerate的实现:
import Accelerate
let x: [Float] = [1, 2, 3, 4, 5], M = x.count
let kernel: [Float] = [1, 2, 3], N = kernel.count
let T = N+M-1
var res = [Float](repeatElement(0, count: T))
let zeros = [Float](repeatElement(0, count: N-1))
let newXin = zeros + x + zeros
vDSP_conv(newXin, 1, kernel.reverse(), 1, &res, 1, vDSP_Length(T), vDSP_Length(N))
对于这个很短的输入序列,你不会感激Accelerate框架带来的加速。但如果我创建了100,000个元素的输入数组,并用和之前示例相同的w内核进行卷积。在我的MacBook Pro上,Swift的实现需要318 ms,而Accelerate的vDSP_conv方法只要159 ns。
Metal的卷积
让我们看一下如何用Metal实现相同的例子。看这篇文章学习如何配置一个GPU计算的Metal项目。
在这个特殊的例子中,我们需要创建3个Metal纹理(遵守MTLTexture协议的对象):第一个纹理存储输入序列,第二个纹理存储核,第三个纹理存储最终结果。
以下是创建这些纹理的源代码:
import Metal
let paddedX: [Float] = input + [Float](repeatElement(0, count: N-1))
let paddedK: [Float] = kernel + [Float](repeatElement(0, count: M-1))
let inputTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(with: .r32Float, width: paddedX.count, height: 1, mipmapped: false)
inputTextureDescriptor.usage = .shaderRead
inTexture = metalContext.device.newTexture(with: inputTextureDescriptor)
let region = MTLRegionMake2D(0, 0, paddedX.count, 1)
inTexture?.replace(region, mipmapLevel: 0, withBytes: paddedX, bytesPerRow: paddedX.count * sizeof(Float32.self))
let kernelTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(with: .r32Float, width: paddedK.count, height: 1, mipmapped: false)
kernelTexture = metalContext.device.newTexture(with: kernelTextureDescriptor)
let kernelRegion = MTLRegionMake2D(0, 0, paddedK.count, 1)
kernelTexture?.replace(kernelRegion, mipmapLevel: 0, withBytes: paddedK, bytesPerRow: paddedK.count * sizeof(Float32.self))
let outputTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(with: .r32Float, width: paddedX.count, height: 1, mipmapped: false)
outputTextureDescriptor.usage = .shaderWrite
outTexture = metalContext.device.newTexture(with: outputTextureDescriptor)
executeConvolution()
在前面的源代码里,metalContext是下面的类的一个实例:
final class MetalContext: NSObject {
let device: MTLDevice
let commandQueue: MTLCommandQueue
let library: MTLLibrary
override init() {
// Get the device
self.device = MTLCreateSystemDefaultDevice()!
// Create a command queue
self.commandQueue = device.newCommandQueue()
// Get the default library
self.library = device.newDefaultLibrary()!
super.init()
}
}
这只是一个助手类,我通常用来配置一个Metal栈的主要对象。
最后一个executeConvolution()方法用来编码GPU命令:
func executeConvolution() {
guard let outTexture = self.outTexture else { return }
let commandBuffer = metalContext.commandQueue.commandBuffer()
let computeCommandEncoder = commandBuffer.computeCommandEncoder()
computeCommandEncoder.setComputePipelineState(computePipelineState!)
computeCommandEncoder.setTexture(inTexture, at: 0)
computeCommandEncoder.setTexture(kernelTexture, at: 1)
computeCommandEncoder.setTexture(outTexture, at: 2)
computeCommandEncoder.dispatchThreadgroups(MTLSizeMake(T, 1, 1), threadsPerThreadgroup: MTLSizeMake(1, 1, 1))
computeCommandEncoder.endEncoding()
commandBuffer.commit()
let region = MTLRegionMake1D(0, T)
var buffer = [Float32](repeatElement(0, count: T))
outTexture.getBytes(&buffer, bytesPerRow: T*sizeof(Float32.self), from: region, mipmapLevel: 0)
}
最后,我们需要用Metal kernel函数。这里我们执行卷积的地方。一个非常简单的实现可以这样:
二维卷积
在处理图像时,卷积在二维数据上进行。这时,图像是由矩阵X[n,m]而不是一维数组来表示。
下图显示了如何计算输出矩阵Y的元素Y[1,2]的卷积结果,高亮显示的元素是卷积运算的中央。
和一维卷积一样,我也可以在这里给出二维情况下Swift的例子,Accelerate框架的和Metal的,但我把这个留给你们当作练习。记得沿行和列反转W。此外,记得填充P-1和Q-1个零到矩阵X的两端。
卷积神经网络
下图突出了一个全连接的神经网络,并且有2个隐藏层(L1和L2)。
正如前一篇文章中讨论的,网络由层组成,每一层由神经元组成。让我们看看隐藏层L1的神经元N0,它的输入是上一层L0的每个神经元的输出的加权和:
关于这个表达式,是L1层的神经元N0的输入,是L0层的神经元Ni的输出,是L0层的神经元Ni和L1层的神经元N0之间的权重。
同样的,下面的方程表示了L1层神经元N1的输入:
类似的方程适用于L1层其余的神经元,L2层和L3层也是如此。
如果我们用一个列数组表示L1层的输入,用一个矩阵表示L0层和L1层之间的权重,用一个列数组表示输出层L0,我们可以得到以下方程:
现在,如果我想要用这个全连接的神经网络来处理图像,输入层必须有一定数量的神经元,其个数与输入图像的像素相等。所以,我们需要一个有10000个神经元的输入层来处理仅仅100x100像素的图像。
这意味着在前面的方程(4)中矩阵W的列数是10000。这计算实在消耗巨大。此外,每个像素独立于相邻像素处理。
因此,我们需要优化处理。如果我们仔细观察前面的方程(2)和(3),我们会注意到,它们看起来非常类似于卷积方程(1)。因此,与其计算不同矩阵的乘法(每层一个),我们可以使用快速卷积算法。这使我们能够用CNN实现取代全连接的神经网络实现。
类似于全连接神经网络,CNN就是一连续的层。下面的图强调了一个典型的CNN结构(其他结构在文献中有被提出):
每个CNN层是由两个操作组成:一个卷积后跟一个池。下图突出了一个CNN的第一个卷积层:
在iOS 10和macOS 10.12中,Metal Performance Shaders框架(Accelerate框架也有)提供了一个新的类来配置CNN的特定的卷积操作。
正如你在前面的图中所看到的,输入图像被分解成3个通道(红、绿、蓝)。每个通道与不同的训练得到的核进行卷积。这3个结果再组成特征图。在前面的图中,我展示的只是4特征图。在一个真正的CNN里,通常有16、32或更多特征图。所以,你需要16x3或32x3个核。
下面的代码展示了如何创建CNN层的卷积操作。
let convDesc = MPSCNNConvolutionDescriptor(kernelWidth: 3, kernelHeight: 3, inputFeatureChannels: 3, outputFeatureChannels: 4, neuronFilter: nil)
var conv0 = MPSCNNConvolution(device: device, convolutionDescriptor: convDesc, kernelWeights: featureFilters, biasTerms: convBias, flags: .none)
类似于其他Metal对象,我们需要使用描述符来创建卷积。在这个特殊的例子里,我们使用MPSCNNConvolutionDescriptor类。之后,我们可以创建MPSCNNConvolution类的一个实例。
让我们看看池操作。
池
CNN层中的另一个操作是池。它基本上是一个压缩操作,目的是缩减图像大小,它贯穿图像处理的输入和输出。
池有双重功能。首先,它降低了图像分辨率,减少了传递到下一CNN层的图像的详细信息。第二,它降低了下一层的计算量。
有不同的技术来减少图像大小。苹果提供了两种类型的池:Max池和Average池。下面的图显示了Max池和Average池是如何工作的。
Max池取图像在一个区域内的最大像素值。Average池则取平均值。例如,前例中的图像在Average池里产生一个值为(90 + 96 + 75 + 96)/ 4 = 84.75的像素。
使用MPSCNNPoolingMax类,我们可以用下面的代码转化之前的图:
var pool = MPSCNNPoolingMax(device: device, kernelWidth: 2, kernelHeight: 2, strideInPixelsX: 2, strideInPixelsY: 2)
类似地,你可以使用MPSCNNPoolingAverage得到Average池:
var pool = MPSCNNPoolingAverage(device: device, kernelWidth: 2, kernelHeight: 2, strideInPixelsX: 2, strideInPixelsY: 2)
全连接层
链接不同的卷积层后,CNN的最后一层是全连接层。因为它可以被认为是一个特殊的卷积层,Metal Performance Shaders框架给全连接层提供了一个非常类似的API:
let fcDesc = MPSCNNConvolutionDescriptor(kernelWidth: kWidth, kernelHeight: kHeight, inputFeatureChannels: 128, outputFeatureChannels: 1)
var fc = MPSCNNFullyConnected(device: device, convolutionDescriptor: fcDesc, kernelWeights: fcFeatureFilters, biasTerms: fcBias, flags: .none)
MPSImage和MPSTemporaryImage
我们如何处理Metal CNN中的数据?Metal Performance Shaders框架提供了两个新类:MPSImage和MPSTemporaryImage。
正如先前所展示的,卷积层的输出生成多个特征图(16或32)。MPSImage用通道来组成这些特征图。因为MTLTexure只有4个通道(RGBA),苹果推出了两个新类来处理多于4通道的情况。所以,MPSImage实际是Metal的一个二维纹理数组的多个片。由于图像是组成的,所以MPSImage的每个像素包含4个通道。也因此,32特征图是由8(=32/4)片组成的MPSImage表示的。
这是你用MPSImage所需要的API:
let imgDesc = MPSImageDescriptor(channelFormat: .float16, width: width, height: height, featureChannels: 32)
var img = MPSImage(device: device, imageDescriptor: imgDesc)
你使用MPSImage来作CNN的输入和输出图像。对于中间结果,你应该使用MPSTemporaryImage类。使用这个临时图像的好处是一旦命令被提交到缓冲区它就会被丢弃。这减少了内存分配和CPU耗费。
创建一个MPSTemporaryImage和MPSImage非常相似:
let img1Desc = MPSImageDescriptor(channelFormat: float16, width: 40, height: 40, featureChannels: 16)
img1 = MPSTemporaryImage(device: device, imageDescriptor: img1Desc)
CNN训练
为了用CNN,你需要先训练。训练生成一组权重,然后在推理阶段使用它们。Metal Performance Shaders的APIs只允许你实现CNN推理。训练阶段不可以用这些APIs。苹果建议使用第三方工具。
神经网络的结构、层数和每层的神经元数量需要在这个领域的一定的经验。除了知道它背后的数学,你需要大量的CNNs的实践经验。
总结
在这篇文章中,我为你们概述了iOS 10和macOS 10.12中Metal Performance Shaders框架的新的APIs。在之前的文章中,我也为你们介绍了iOS上的机器学习(ML)和人工神经网络(ANN)。
微信号:CocoaChinabbs
▲长按二维码“识别”关注即可免费学习 iOS 开发
月薪十万、出任CEO、赢娶白富美、走上人生巅峰不是梦
--------------------------------------
商务合作QQ:2408167315
投稿邮箱:[email protected]