Common Lispで多層ニューラルネットを実装してみる

去年買ってはいたが積読状態だったこのディープラーニングの青い本。

深層学習 (機械学習プロフェッショナルシリーズ)

深層学習 (機械学習プロフェッショナルシリーズ)

正月ヒマを持て余してたので、箱根駅伝を眺めながら、この本を見て多層ニューラルネットを実装してみた。大体4章くらいまでの基本的な内容になる。実装言語は例によってCommon Lispである。
全ソースコードはGistに置いておく。(nn.lisp)
変数名などは本の中の数式と大体対応している。

データ構造

とりあえず一つのレイヤーの入力と出力の関係を記述するための構造体を用意する。ネットワーク全体はレイヤーのベクトルということにすればよろしい。

(defstruct layer
  in-dim                ; 入力次元
  out-dim               ; 出力次元 (ユニット数)
  w-mat                 ; 出力次元 × 入力次元の重み配列
  u-vec                 ; 総入力ベクトル
  z-vec                 ; 出力ベクトル
  delta-vec             ; デルタ(誤差関数の総入力についての微分)
  activation-func       ; 活性化関数
  activation-func-diff) ; 活性化関数の導関数

(defstruct nn
  n-of-layers
  layer-vec
  learning-rate)        ; 学習率

一つのニューロン(ユニット)に入ってくる入力に重みを掛けて和を取ったものが総入力uでありニューロンの興奮度合いを表す。uに活性化関数という普通はシグモイド型の関数をかませることで、ニューロンの出力はメリハリ(?)のあるものになる。
活性化関数はレイヤーごとに別々のものを使ってよいらしく、特に出力層では回帰なら恒等写像、二値分類ならロジスティック関数、多値分類ならソフトマックス関数と、問題に合わせて活性化関数を変えるらしい。

重みw-matをランダムに初期化してこれらの構造体のインスタンスを作る関数make-random-weight、make-random-layer、make-random-nnも定義しておく。

(defun make-random-weight (in-dim out-dim)
  (let ((w (make-array (list out-dim in-dim) :element-type 'double-float)))
    (loop for i from 0 to (1- out-dim) do
      (loop for j from 0 to (1- in-dim) do
        ;; initialize between -0.1 and 0.1
        (setf (aref w i j) (- (random 0.2d0) 0.1d0))))
    w))

(defun make-random-layer (in-dim out-dim activation-func activation-func-diff)
  (make-layer :in-dim in-dim
              :out-dim out-dim
              :w-mat (make-random-weight in-dim out-dim)
              :u-vec (make-array out-dim :element-type 'double-float :initial-element 0d0)
              :z-vec (make-array out-dim :element-type 'double-float :initial-element 0d0)
              :delta-vec (make-array out-dim :element-type 'double-float :initial-element 0d0)
              :activation-func activation-func
              :activation-func-diff activation-func-diff))

(defun make-random-nn (dimension-list activation-func-pair-list &optional (learning-rate 0.01d0))
  (labels ((make-layers (product dimension-list activation-func-pair-list)
             (if (< (length dimension-list) 2)
               (nreverse product)
               (make-layers (cons (make-random-layer (car dimension-list) (cadr dimension-list)
                                                     (caar activation-func-pair-list)
                                                     (cadar activation-func-pair-list))
                                  product)
                            (cdr dimension-list) (cdr activation-func-pair-list)))))
    (make-nn :n-of-layers (1- (length dimension-list))
             :layer-vec (apply #'vector (make-layers nil dimension-list activation-func-pair-list))
             :learning-rate learning-rate)))

予測: 順伝播

まずは重みw-matは学習済みだと考えて、任意の入力から予測出力を計算するところをつくる。
総入力uは入力ベクトルとw-matの一つのユニットに対応する部分の線形結合を計算することによって、出力zはuに対して活性化関数をかませることで得られる。layer構造体のu-vecとz-vecを順に破壊的に更新することで一つのレイヤーの出力が計算できる。

(defun calc-u-vec (in-vec layer)
  (loop for j from 0 to (1- (layer-out-dim layer)) do
    (setf (aref (layer-u-vec layer) j)
          (loop for i from 0 to (1- (layer-in-dim layer))
                summing
                (* (aref (layer-w-mat layer) j i)
                         (aref in-vec i)))))
  (layer-u-vec layer))

(defun calc-z-vec (layer)
  (loop for i from 0 to (1- (layer-out-dim layer)) do
    (setf (aref (layer-z-vec layer) i)
          (funcall (layer-activation-func layer) (aref (layer-u-vec layer) i))))
  (layer-z-vec layer))

あとは一つ前のレイヤーの出力を次のレイヤーの入力にして出力を計算するということを繰り返せばネットワーク全体の出力が計算できる。計算は入力層から出力層に向かって順方向に進むので順伝播と呼ぶ。

(defun forward (in-vec nn)
  (loop for i from 0 to (1- (nn-n-of-layers nn)) do
    (if (zerop i)
      (progn (calc-u-vec in-vec (aref (nn-layer-vec nn) i))
             (calc-z-vec (aref (nn-layer-vec nn) i)))
      (progn (calc-u-vec (layer-z-vec (aref (nn-layer-vec nn) (1- i))) (aref (nn-layer-vec nn) i))
             (calc-z-vec (aref (nn-layer-vec nn) i))))))

学習: 誤差逆伝播

学習は、大枠としては普通の勾配法と一緒で、入力に対して順伝播で計算した予測と観測された教師信号の二乗誤差を重みで微分して勾配を得る。それから重みを勾配方向に少し更新する(この度合いを決めるのが学習率)。これを各データに対して繰り返すだけである。なので基本的にはオンライン処理になる。

この勾配を計算するために、誤差関数を総入力で微分した量(この本ではデルタと呼んでいる)が必要になるのだが、これが一つ後のレイヤーのデルタから計算できるというのがミソであり、出力層から入力層に向かって各ユニットのデルタを計算していく逆伝播のアルゴリズムが構成できる。これもlayer内のdelta-vecを破壊的に更新する。

(defun backward (train-vec nn)
  ;; calculate last layer's delta
  (let ((last-layer (aref (nn-layer-vec nn) (1- (nn-n-of-layers nn)))))
    (loop for j from 0 to (1- (layer-out-dim last-layer)) do
      (setf (aref (layer-delta-vec last-layer) j)
            (- (aref (layer-z-vec last-layer) j)
                     (aref train-vec j)))))
  ;; calculate other deltas
  (loop for l from (- (nn-n-of-layers nn) 2) downto 0 do
    (let ((layer (aref (nn-layer-vec nn) l))
          (next-layer (aref (nn-layer-vec nn) (1+ l))))
      (loop for j from 0 to (1- (layer-in-dim next-layer)) do
        (setf (aref (layer-delta-vec layer) j)
              (* (funcall (layer-activation-func-diff layer) (aref (layer-u-vec layer) j))
                          (loop for k from 0 to (1- (layer-out-dim next-layer))
                                summing
                                (* (aref (layer-delta-vec next-layer) k)
                                         (aref (layer-w-mat next-layer) k j)))))))))

最後に、forwardで計算したユニットの出力z-vec、backwardで計算したデルタdelta-vecを使って勾配を計算し、学習率をかけて重みw-matを更新する部分を書く。

(defun update (in-vec train-vec nn)
  (forward in-vec nn)
  (backward train-vec nn)
  ;; update first layer
  (let ((first-layer (aref (nn-layer-vec nn) 0)))
    (loop for i from 0 to (1- (layer-in-dim first-layer)) do
      (loop for j from 0 to (1- (layer-out-dim first-layer)) do
        (setf (aref (layer-w-mat first-layer) j i)
              (- (aref (layer-w-mat first-layer) j i)
                       (* (nn-learning-rate nn)
                          (aref in-vec i)
                          (aref (layer-delta-vec first-layer) j)))))))
  ;; update other layer
  (loop for l from 1 to (1- (nn-n-of-layers nn)) do
    (let ((layer (aref (nn-layer-vec nn) l))
          (prev-layer (aref (nn-layer-vec nn) (1- l))))
      (loop for i from 0 to (1- (layer-in-dim layer)) do
        (loop for j from 0 to (1- (layer-out-dim layer)) do
          (setf (aref (layer-w-mat layer) j i)
                (- (aref (layer-w-mat layer) j i)
                         (* (nn-learning-rate nn)
                            (aref (layer-z-vec prev-layer) i)
                            (aref (layer-delta-vec layer) j)))))))))

使い方

データを用意する

例によってサインカーブを近似する。入力ベクトルにはバイアスとして常に値が1になる次元を付けておく必要があることに注意(これをいつも忘れる)。

(defparameter input-data
  (loop repeat 100 collect (vector (- (random (* 2 pi)) pi) 1d0)))

(defparameter train-data
  (mapcar (lambda (x) (vector (sin (aref x 0)))) input-data))
ネットワークを定義する

make-random-nnで重みw-matを初期化したネットワークを定義する。make-random-nnの第一引数は各層の次元数のリストであり、このリストの最初の要素が入力次元で、最後の要素が出力次元となる。第二引数は活性化関数と活性化関数の導関数の連想リストで、各レイヤーでどの活性化関数を使うかを指定する。第三引数は学習率であり、重みの更新幅に影響する。
とりあえず2層と3層の場合のネットワークの例を1つずつ示す。

;; 2層ネットワークの例(入力層を加えたら3層)
(defparameter *nn*
  (make-random-nn
   '(2 50 1)                       ; 入力層2次元、隠れ層50次元、出力層1次元
   (list (list #'RLF #'RLF-diff)   ; 隠れ層の活性化関数: 正規化線形関数
         (list #'identity #'one))  ; 出力層の活性化関数: 回帰問題なので恒等写像
   0.05d0))                        ; 学習率

;; 3層ネットワークの例
(defparameter *nn*
  (make-random-nn
   '(2 20 10 1)
   (list (list #'logistic #'logistic-diff) ; 第1隠れ層の活性化関数をロジスティック関数にしてみる
         (list #'RLF #'RLF-diff)
         (list #'identity #'one))
   0.05d0))
1000サイクル学習する
(dotimes (i 1000)
  (mapc (lambda (in out) (update in out *nn*)) input-data train-data))
予測線をプロットしてみる

予測にはpredict関数を使う。先の*nn*の定義例2つのそれぞれで予測をプロットしてみる。

(defparameter *result*
  (loop for x from -3.14 to 3.14 by 0.01 collect
    (aref (predict (vector (* x 1d0) 1d0) *nn*) 0)))



と、まあ近似できている。
このままだとユニット数を増やすと過学習するので、更新するときに正則化項を加えたり、ドロップアウトといって、一部のニューロンをキャンセルして学習したりといったことをする。この辺の効果を見るのは今後の課題としておく。