LoginSignup
60
57

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-12-27

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

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

前編ではネットワークの中身には踏み込まず、オーバービューとして利用するアプリケーション側の実装について見ていきました。

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

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

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

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

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

畳み込み層(Convolutional Layer)

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

var conv1, conv2: MPSCNNConvolution

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

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です。

var relu: MPSCNNNeuronReLU
relu = MPSCNNNeuronReLU(device: device, a: 0)

プーリング層(Pooling Layer)

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

var pool: MPSCNNPoolingMax
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 とそのまんまなクラス名です。

var softmax : MPSCNNSoftMax
softmax = MPSCNNSoftMax(device: device)

全結合層(fully-connected layer)

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

var fc1, fc2: MPSCNNFullyConnected

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

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 インスタンスを、それぞれ別々に用意します。

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

MPSImageDescriptor でピクセルフォーマット、サイズ、特徴量の数を記述しておいて、

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を生成します。

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を用意したわけですが、本メソッド内でこれらを実際に繋げて、入力画像から推測結果を出力する、ということを行います。

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

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

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

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

最終出力

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

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

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

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


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

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

60
57
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
57