原文: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)。