Haskellでいってみよう

日曜プログラマにも満たないレベルでもHaskellで何かソフトウェアを作りたい!

DeepLearning(1): まずは順伝播(上)

さて、今回からは新たなネタとしてDeepLearningに取り組もう。 DeepLearningといえばすでにブームは去って、今は現実の課題に どんどん活用されている状況だ。使いやすく性能のいいツールや ライブラリがいろいろあり、ちょっと学べば簡単にその恩恵に 与ることができる(かもしれない)。

しかし、仕組みがわからないのをただ使うのは面白くないし 個人的に写真の自動分類をしたいというニーズもあるし、遅まきながら 自分で作ってみよう、というわけだ。

目標と参考資料

最終的には「一般物体認識」まで行きたい(というかそうでないと 写真の分類にならない)が、目標が高すぎると頓挫するのでここでは 以下の2つのステップまでとする。

  • ステップ1: yusugomoriさんのpython実装を写経する
  • ステップ2: 上を使ってお決まりのMNISTデータを使った性能検証をする

「自分で作ってみよう」と大口を叩いていながら他の方の実装を写経する とは詐欺的だが、そこは当方素人なので。。。以後、yusugomoriさんのpython実装を 「yusugomori実装」と表記する。

作るのは、畳み込みニューラルネットワーク(Convolutional Neural Network, CNN)による画像認識プログラムとする。以下、参考にした書籍や Webサイトを列挙。

ひとつの本やWebサイトだけでは理解が進まないので、多数に目を通して同じ概念を いろいろな説明で読むほうがよいと思う。

まずは型を考える

(ちゃんと理解できているとは言い難いが)CNNによる画像認識は 次の図のように「画像」を各層で変換していって最終的に分類結果 (どれに該当するかを確率で示す)を得るようだ。

f:id:eijian:20160830205123p:plain

そこでまずは「画像(Image)」型と「層(Layer)」型を定義しよう。 カラー画像だと、$X \times Y$二次元のドットで構成された平面(Plain)が 赤緑青の3色(チャネルとする)集まってできている。一般の画像ファイル では各ドットを0-255の整数で表すことが多いが、CNNでは強さを実数で表すようだ。 畳み込み層の変換後のデータも多チャンネルの「画像」とみなせそうなので、 Imageは次のように定義した。実体はDoubleの3次元リストだ。

(Image.hs)

type Plain = [[Double]]    -- 2D: X x Y pixels
type Image = [Plain]       -- n channel

わざわざImagePlainのリストにしたのは、Plainごとに処理することが 多そうだからだ。 あとついでに、「学習」の際に必要となる教師データも定義しておく。

(Image.hs)

type Class = [Double]    -- trainer vector
type Trainer = (Image, Class)

実数のリストだが、教師データは要素のどれか一つが1.0、他が0.0となる。

次にレイヤを考える。CNNでは次のようなレイヤが使用される。

  • 畳み込み層 (convolution layer)
  • プーリング層 (pooling layer)
  • 活性化層/活性化関数 (activation layer)
  • 全結合層 (fully connected layer)

このうち、活性化層というのは一般的な表現かどうかよくわからない。 最初は型クラスとしてLayerを定義し各層を型としてやればいいと考えた。 が、各層をつなげて一連の変換処理を表現するのに「リスト」を使いたかったので (私の知識範囲では)うまい方法が思いつかなかった。 ということで、Layer型を次のように代数データ型で定義することにした。

(LayerType.hs)

data Layer = NopLayer
           | ConvLayer Int [FilterC]
           | MaxPoolLayer Int
           | ActLayer ActFunc
           | HiddenLayer [FilterH]
           | FlattenLayer (Image -> Image)

NopLayerは何も変換しないダミーの層(テスト用)、 プーリング層は実用上Max Poolingだけでよいと考えてMaxPoolLayer、 全結合層は隠れ層ということでHiddenLayerとしている。 各行に出てくる引数の型はそれぞれ以下のように定義してある。 ActFuncは活性化関数、FilterCは畳み込み層のためのフィルタ、 FilterHは全結合層(隠れ層)のためのフィルタ、をそれぞれ表す型だ。

(LayerType.hs)

-- for Activation Layer
type ActFunc = [Double] -> [Double]

-- filter for Convolution Layer
type Kernel = [[Double]]
type Bias   = Double
type FilterC = (Kernel, Bias)

-- filter for Hidden Layer
type FilterH = [Double]

学習用プログラム

deep learningで画像認識させるためのステップは大雑把には以下の手順を踏む 必要があると思っている。

  1. 大量のサンプル画像を用意し、それらを分類する。
  2. サンプル画像から「教師データ」を作成する。
  3. 教師データを使って「学習」を行う。(学習とは、各層で使われるフィルタを 教師データを元に調整していくこと、と理解)
  4. 画像を調整済みフィルタを使って分類する(各分類の確率を計算する)。

1と2はプログラム以前の準備段階だ。が、yusugomori実装では教師データと 学習状況の確認に使うテストデータをプログラム内で生成している。 最終的には学習データとテストデータは外から与える形にしたいが、 まずは同じようにプログラム内で生成するようにしよう。

教師データの生成

ゆくゆく教師データを外から与えることも考え、「画像データの集合」 の型??Poolを作ろう。これらの集合から幾つかの画像を取り出して 学習に使いたい。そこで、データに追番をつけてMapに登録 することにした。とりあえずはオンメモリで処理できれば良いので 以下のようにプール(オンメモリ版、MemPool)を定義した。

(Pool.hs)

class Pool p where
  getImages :: p -> Int -> Int -> IO [Trainer]
  nSample :: p -> Int

newtype MemPool = MemPool { m :: Map.Map Int Trainer }

yusugomori実装に倣って教師データを生成する関数も用意した。

(Pool.hs)

initSamplePool :: Int -> (Int, Int) -> Int -> Double -> Int -> IO MemPool
initSamplePool c (sx, sy) o p n = do
  s0 <- forM [0..(n-1)] $ \i -> do
    let cl = i `mod` o  -- class of this image

    -- Image data
    s1 <- forM [1..c] $ \j -> do
      s2 <- forM [0..(sy-1)] $ \y -> do
        let p' = if y `div` st == cl then p else (1-p)
        s3 <- forM [1..sx] $ \x -> do
          a <- pixel p'
          return a
        return s3
      return s2

    -- Trainer data
    e1 <- forM [0..(o-1)] $ \j -> do
      return $ if j == cl then 1.0 else 0.0
    return (s1, e1)

  return (MemPool (Map.fromList $ zip [0..] s0))
  where
    st = sy `div` o
    pixel :: Double -> IO Double
    pixel p = do
      v <- MT.randomIO :: IO Double
      let v' = if v < p then 0.5 else 0.0
      return v'

結構ごちゃごちゃしているが、以下のような3種類の画像データを生成してプール として返しているのだ。

f:id:eijian:20160830011222p:plain

実際は上記のようなキッチリした画像ではなく、灰色部分に一定割合で白が 混ざっていて、白色部分は逆に灰色が混ざっている。教師データではその 割合を5%、テストデータは10%としている。

つぎに、プールから指定枚数の画像を取り出す関数getImagesとプール内の 画像総数を返す関数nSampleを示そう。

(Pool.hs)

{-
getImages
  IN : pool
       epoch number
       batch size
-}

instance Pool MemPool where
  getImages p@(MemPool m) e b = do
    let s = nSample p
        o = (e-1) * b `mod` s
        mx0 = o + b - 1
        mx2 = mx0 - s
        mx1 = if mx2 < 0 then mx0 else s - 1
        im0 = mapMaybe (\x -> Map.lookup x m) [o..mx1]
        im1 = mapMaybe (\x -> Map.lookup x m) [0..mx2]
    return (im0 ++ im1)

  nSample (MemPool m) = Map.size m

getImagesは若干ややこしいが、要は教師データを満遍なく学習させたいので epoch毎に違う画像を取り出すようにしている(最初のepochで1番目から10個 取り出したら次のepochでは11番目から10個取り出す)。 ゆくゆくは順番に取り出すのではなくランダムに取り出すオプションも実装したい。

main関数(全体の流れ)

ここで、全体の流れを説明しておきたいのでmain関数の話をしよう。 大雑把には次のような流れだ。

  • パラメータ定義(main以前):フィルタの大きさとかバッチサイズとか (将来的にパラメータを設定ファイルから読み込むようにしたい)
  • 教師データ、テストデータの準備
  • 各レイヤの初期フィルタの生成
  • レイヤの組み立て
  • 学習の繰り返し

まずパラメータの説明から。画像は3種類に分類する(k)。教師データが 50x3=150個、テストデータが10x3=30個だ。画像サイズ、フィルタは 先に示した各レイヤの仕様どおり。なおバッチサイズは教師データが少ないので 毎回全教師データを学習するために150個としている。

(Main-cnn.hs)

-- PARAMETERS

-- training/test dataset
k = 3   -- number of class
n = 50  -- number of teacher data for each class
m = 10  -- number of test data for each class

train_N    = n * k
test_N     = m * k

-- image size
image_size = [12, 12]
channel    = 1

-- filter specs
n_kernels    = [10, 20]
kernel_sizes = [3, 2]
pool_sizes   = [2, 2]
n_hidden     = 20
n_out = k

-- loop parameters
epochs = 200
opStep = 5
epoch0 = 1
batch  = 150

-- others
learning_rate = 0.1

次にメインルーチンだ。教師データとテストデータは次のように生成 している。説明はいらないだろう。

(Main-cnn.hs)

main :: IO ()
main = do
  putStrLn "Building the model..."
  poolT <- initSamplePool 1 (12, 12) 3 0.95 train_N   -- trainer data pool
  poolE <- initSamplePool 1 (12, 12) 3 0.90 test_N    -- test data pool
  sampleE <- getImages poolE 1 test_N

次はレイヤの定義だ。まずフィルタを初期化してからレイヤを複数並べる。

(Main-cnn.hs)

  fc1 <- initFilterC 10 1 12 12 3 2
  fc2 <- initFilterC 20 10 5 5 2 2
  fh1 <- initFilterH n_hidden (2*2*20)
  fh2 <- initFilterH n_out n_hidden

  let layers = [ConvLayer 3 fc1, ActLayer relu, MaxPoolLayer 2,
                ConvLayer 2 fc2, ActLayer relu, MaxPoolLayer 2,
                FlattenLayer flatten,
                HiddenLayer fh1, ActLayer relu,
                HiddenLayer fh2, ActLayer softmax]

ここでは、畳み込み層1(活性化関数ReLU)→プーリング層1→ 畳み込み層2(活性化関数ReLU)→プーリング層2→隠れ層(活性化関数ReLU)→ 出力層(活性化関数softmax) という多層構造にした。 なお、FlattenLayerは畳み込み+プーリングの処理から全結合へデータ形式を 変換する処理である。

最後が学習の繰り返しだ。

(Main-cnn.hs)

  tm0 <- getCurrentTime
  putStatus 0 tm0 [([0.0], 0.0)] layers
  loop is tm0 layers batch poolT sampleE

時間の記録と学習前の状態をダミーで表示したあと、繰り返し学習する loopを呼んでいる。loopの詳細は次の通り。

(Main-cnn.hs)

loop :: Pool p => [Int] -> UTCTime -> [Layer] -> Int -> p -> [Trainer]
     -> IO ()
loop [] _ _ _ _ _ = putStr ""
loop (i:is) tm0 ls b pt se = do
  teachers <- getImages pt i b
  ops <- mapM (train ls) teachers
  let (_, dls) = unzip ops
      ls' = updateLayer ls dls   -- dls = diff of layers
  if i `mod` opStep == 0 then putStatus i tm0 (evaluate ls' se) ls'
                         else putStr ""
  loop is tm0 ls' b pt se

大したことはやってなくて、epoch毎に繰り返しloopを呼び、 epoch番号がなくなったら(空リストになったら)、繰り返しを終了する。 中身は、学習する教師データをプールから取り出して、それぞれ学習をし、 各層を学習結果を使って更新する。とはいえ、まだ「学習」は実装しておらず、 この部分はダミーである。

ここまでがmain関数だ。

長くなってしまったので、各層(レイヤ)の処理については次に 回すことにする。

まとめ

今回はCNNの画像認識プログラムの初回として以下に取り組んだ。

  • CNNで使われるいろいろな型を定義した。
  • 入力値を変換するいろいろな「層」を定義した。
  • これらを使って順伝播の処理を実装した。

次回は各層についての説明。

(ここまでのソースはこちら