iOSのMetalで畳み込みニューラルネットワーク - MPSCNNを用いた手書き数字認識の実装

MetalでCNNの計算を行うためのAPI群、MPSCNNを用いた手書き数字認識のサンプルを読む、という記事の続きです。

> iOS 10でMetal Performance Shadersフレームワークに、CNN（Convolutional Neural Network）演算機能群が追加されました。**iOSデバイスのGPUで畳み込みニューラルネットワークの計算をさせることができる**、という代物です。

[前編](http://qiita.com/shu223/items/28122f5668cf1547e8fe)ではネットワークの中身には踏み込まず、オーバービューとして利用するアプリケーション側の実装について見ていきました。


後編となる本記事ではいよいよMetal Performance Shadersを用いたCNN（Convolutional Neural Network / 畳み込みニューラルネットワーク）の実装について見ていきます。

![](https://qiita-image-store.s3.amazonaws.com/0/3180/e2cfc688-8812-8771-2fa8-405b4bb0492c.gif)


なお、CNN自体の解説はここでは省略しますが、概念をざっくり理解したい場合には下記記事が、

- [畳み込みニューラルネットワークの仕組み | コンピュータサイエンス | POSTD](http://postd.cc/how-do-convolutional-neural-networks-work/)

もうちょっとちゃんと学びたいときはCourseraの機械学習コース（無料）が大変わかりやすいです。

- [Machine Learning - Stanford University | Coursera](https://www.coursera.org/learn/machine-learning)


##CNN各層を担うオブジェクトを生成

本サンプルでは`MNISTDeepCNN`がネットワークの実装クラスになります。このクラスの初期化処理では、CNNの各層や活性化関数を担うオブジェクトを生成しています。

###畳み込み層(Convolutional Layer)

畳み込み層を担うクラスとして`MPSCNNConvolution`というものが用意されています。

```swift
var conv1, conv2: MPSCNNConvolution
```

本サンプルでは、モデルデータ読み込み等をラップした`SlimMPSCNNConvolution`というサブクラス [^1] を使用しています。

[^1]: この中身については長くなるので、また別記事で書きたいと思います

```swift
conv1 = SlimMPSCNNConvolution(kernelWidth: 5,
                              kernelHeight: 5,
                              inputFeatureChannels: 1,
                              outputFeatureChannels: 32,
                              neuronFilter: relu,
                              device: device,
                              kernelParamsBinaryName: "conv1")

conv2 = SlimMPSCNNConvolution(kernelWidth: 5,
                              kernelHeight: 5,
                              inputFeatureChannels: 32,
                              outputFeatureChannels: 64,
                              neuronFilter: relu,
                              device: device,
                              kernelParamsBinaryName: "conv2")
```

本ネットワークは**畳み込み層を2つ**持ち、

- どちらもカーネルサイズは5x5
- どちらも活性化関数はReLU
- 1つ目の層は入力が1チャンネル（グレースケール画像）、出力が32チャンネル
- 2つ目の層は入力が32チャンネル、出力が64チャンネル

ということが引数からわかります。"conv1", "conv2"は該当する学習済みパラメータの入ったバイナリデータファイルのプレフィックスです。


###Rectified Linear Units（ReLU）

上で出てきた活性化関数ReLUを担うクラスが、`MPSCNNNeuronReLU`です。

```swift
var relu: MPSCNNNeuronReLU
```

```swift
relu = MPSCNNNeuronReLU(device: device, a: 0)
```


###プーリング層(Pooling Layer)

`MPSCNNPoolingMax` や `MPSCNNPoolingAverage` というクラスが用意されています。本サンプルのネットワークでは最大値を取るプーリングを行っています。

```swift
var pool: MPSCNNPoolingMax
```

```swift
pool = MPSCNNPoolingMax(device: device, kernelWidth: 2, kernelHeight: 2, strideInPixelsX: 2, strideInPixelsY: 2)
pool.offset = MPSOffset(x: 1, y: 1, z: 0);
pool.edgeMode = MPSImageEdgeMode.clamp
```

###ソフトマックス関数

`MPSCNNSoftMax` とそのまんまなクラス名です。


```swift
var softmax : MPSCNNSoftMax
```

```swift
softmax = MPSCNNSoftMax(device: device)
```


###全結合層(fully-connected layer)

`MPSCNNFullyConnected` というクラスがMPSに用意されていますが、

```swift
var fc1, fc2: MPSCNNFullyConnected
```

本サンプルではモデル読み込み等の処理をラップした `SlimMPSCNNFullyConnected` というサブクラス [^2] を使用しています。

[^2]: これも、中身については別記事で書きたいと思います。

```swift
fc1 = SlimMPSCNNFullyConnected(kernelWidth: 7,
                               kernelHeight: 7,
                               inputFeatureChannels: 64,
                               outputFeatureChannels: 1024,
                               neuronFilter: nil,
                               device: device,
                               kernelParamsBinaryName: "fc1")

fc2 = SlimMPSCNNFullyConnected(kernelWidth: 1,
                               kernelHeight: 1,
                               inputFeatureChannels: 1024,
                               outputFeatureChannels: 10,
                               neuronFilter: nil,
                               device: device,
                               kernelParamsBinaryName: "fc2")
```


###各レイヤー間の入出力画像

ネットワーク全体の入力・出力、各層の出力（次の層への入力）となる画像の入れ物となる `MPSImage` インスタンスを、それぞれ別々に用意します。

```swift
var srcImage, dstImage : MPSImage
var c1Image, c2Image, p1Image, p2Image, fc1Image: MPSImage
```

`MPSImageDescriptor` でピクセルフォーマット、サイズ、特徴量の数を記述しておいて、
 
```swift
let sid = MPSImageDescriptor(channelFormat: .unorm8, width: 28, height: 28, featureChannels: 1)
let did = MPSImageDescriptor(channelFormat: .float16, width: 1, height: 1, featureChannels: 10)
let c1id  = MPSImageDescriptor(channelFormat: .float16, width: 28, height: 28, featureChannels: 32)
let p1id  = MPSImageDescriptor(channelFormat: .float16, width: 14, height: 14, featureChannels: 32)
let c2id  = MPSImageDescriptor(channelFormat: .float16, width: 14, height: 14, featureChannels: 64)
let p2id  = MPSImageDescriptor(channelFormat: .float16, width: 7 , height: 7 , featureChannels: 64)
let fc1id = MPSImageDescriptor(channelFormat: .float16, width: 1 , height: 1 , featureChannels: 1024)
```

ディスクリプタを渡して`MPSImage`を生成します。

```swift    
srcImage    = MPSImage(device: device, imageDescriptor: sid)
dstImage    = MPSImage(device: device, imageDescriptor: did)
c1Image     = MPSImage(device: device, imageDescriptor: c1id)
p1Image     = MPSImage(device: device, imageDescriptor: p1id)
c2Image     = MPSImage(device: device, imageDescriptor: c2id)
p2Image     = MPSImage(device: device, imageDescriptor: p2id)
fc1Image    = MPSImage(device: device, imageDescriptor: fc1id)
```


##フォワードプロパゲーション

`forward`メソッドで、順方向伝播（forward propagation）の計算を行います。


初期化処理ではCNNの各層を担うクラス群や入出力画像の入れ物となる`MPSImage`を用意したわけですが、本メソッド内でこれらを実際に繋げて、入力画像から推測結果を出力する、ということを行います。

```swift
if let inputImage = inputImage {
    conv1.encode(commandBuffer: commandBuffer, sourceImage: inputImage, destinationImage: c1Image)
} else{
    conv1.encode(commandBuffer: commandBuffer, sourceImage: srcImage, destinationImage: c1Image)
}    
pool.encode   (commandBuffer: commandBuffer, sourceImage: c1Image   , destinationImage: p1Image)
conv2.encode  (commandBuffer: commandBuffer, sourceImage: p1Image   , destinationImage: c2Image)
pool.encode   (commandBuffer: commandBuffer, sourceImage: c2Image   , destinationImage: p2Image)
fc1.encode    (commandBuffer: commandBuffer, sourceImage: p2Image   , destinationImage: fc1Image)
fc2.encode    (commandBuffer: commandBuffer, sourceImage: fc1Image  , destinationImage: dstImage)
softmax.encode(commandBuffer: commandBuffer, sourceImage: dstImage  , destinationImage: finalLayer)
```

こうやってみるとなんともシンプルなネットワークです。各層を抜き出して並べると、

> conv1 -> pool -> conv2 -> fc1 -> fc2 -> softmax

となっていることがわかります。

ここに、入出力画像も入れると、

> (srcImage) -> conv1 -> (c1Image) -> pool -> (p1Image) -> conv2 -> (c2Image) -> fc1 -> (fc1Image) -> fc2 -> (dstImage) -> softmax -> (finalLayer)

という感じのネットワークになっています。

![cnn.jpg](https://qiita-image-store.s3.amazonaws.com/0/3180/bb3858ce-e79c-eff4-7e58-3a7f4f69249b.jpeg)

（本ネットワークとは別のものになりますが、**イメージ図**として、WWDC16のセッションスライドより）

ここで呼んでいる `encode〜` というメソッドは、各層のクラスの基底クラスとなっている`MPSCNNKernel`が持っているメソッドで、**Metalのコマンドバッファに処理を登録**するものです。

```swift:MPSCNN
open func encode(commandBuffer: MTLCommandBuffer, sourceImage: MPSImage, destinationImage: MPSImage)
```


###最終出力

最終出力（ソフトマックス関数の出力）となる `finalLayer` は次のように初期化されており、

```swift
let finalLayer = MPSImage(device: commandBuffer.device, imageDescriptor: did)
```

ここで渡されているディスクリプタ `did` は、（再掲になりますが）次のように生成されています。

```swift
let did = MPSImageDescriptor(channelFormat: .float16, width: 1, height: 1, featureChannels: 10)
```

したがって、0〜9の10種類のラベルについての確率が示された1x1の画像であることがわかります。

あとは、`getLabel`というメソッドで、この1x1の画像のピクセル値を読み取り、最終的な0〜9の数字を出力するのですが、この処理内容もまた別記事で書きたいと思います。


##まとめ

MPSCNNのサンプルを題材に、どのように手書き数字認識のCNNが実装されているかを見てきました。

ざっくり&駆け足になってしまいましたが、細かい**行列計算等をほとんど意識することもなく、GPU-AcceleratedなCNNを比較的簡単に構築できる**ことが感じ取れたかと思います。

学習済みパラメータの読み込み等、今回書ききれなかったことはまた別記事で書きたいと思います。

（追記）書きました： [MPSCNNに渡すモデルパラメータのフォーマット - Qiita](http://qiita.com/shu223/items/c735ab888a83f214f89a)