記録は作業の証

鉄道とコンピュータ

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ19)

ステップ19 変数を使いやすく

このステップでは次の機能を実装する。

  • 変数に名前を付けられるようにする。
  • 変数から配列の形状を知ることができるアクセサを実装する。
  • プリティプリンタを実装する。

変数に名前を付ける

<variable>クラスにnameスロットを足し、コンストラクタ関数から指定できるようにする。

(defclass <variable> ()
  ((data :initarg :data :accessor @data)
   (name :initarg :name :initform nil :accessor @name) ; 名前を記録する
   (gradient :initform nil :accessor @gradient)
   (creator :initform nil :accessor @creator)
   (generation :initform 0 :accessor @generation)))

(defun <variable> (data &optional name) ; (<variable> #(3 4) "var A")
  (make-instance '<variable> :data data :name name))

アクセサ関数を増やす

NumPyのそれを参考にarray-operationsの関数を使って実装する。 ndarrayに対するlenって最初の次元の数であってるんだろうか?

(defmethod shape ((var <variable>))
  "list of array dimensions"
  (aops:dims (@data var)))

(defmethod ndim ((var <variable>))
  "number of array dimenstions"
  (aops:rank (@data var)))

(defmethod size ((var <variable>))
  "number of elements in the array"
  (aops:size (@data var)))

(defmethod dtype ((var <variable>))
  "data type of array's elements"
  (aops:element-type (@data var)))

(defmethod len ((var <variable>))
  "number of array's first dimention"
  (aops:dim (@data var) 0))

プリティプリンタを実装する

デバッグのときに変数の中身が容易に見えると便利なのでprint-object総称関数にメソッドを実装する。 CLHSの書式文字列の説明と下記の記事を参考にした。

qiita.com

(defmethod print-object ((var <variable>) stream)
  (print-unreadable-object (var stream :type t :identity nil)
    (format stream
            "~:@_~<data: ~W ~_name: ~W ~_gradient: ~W ~_creator: ~W ~_generation: ~W~:>"
            (list (@data var) (@name var)
                  (@gradient var) (@creator var) (@generation var)))))

(<variable> #(3 2) "var A")
 ; => #<<VARIABLE> data: #(3 2) name: "var A" gradient: NIL creator: NIL generation: 0>

(defmethod print-object ((func <function>) stream)
  (print-unreadable-object (func stream :type t :identity nil)
    (format stream
            "~<generation: ~W~:>"
            (list (@generation func)))))

そのほか

array-operationsのマニュアルを読んでいたら、vectorize-reduceというマクロがあることに気がついたので、勾配確認のときに書いたall-close関数を書き直して試してみた。最初のバージョンのall-close関数であるall-close-efvより、vectorize-reduceを使ったall-close-vrのほうが3割から4割短い時間で計算できることが分かった。マクロ展開してみると、all-close-efvではアキュムレータとなる配列を割り当てていたが、all-close-vrではそれがなかったため、おそらくそれが要因だろうと思う。

(defun all-close-efv (x y)
  (every (lambda (x) (<= x 1d-08))
         (aops:flatten
          (aops:vectorize (x y)
            (/ (abs (- x y)) (abs x))))))

(defun all-close-vr (x y)
  (>= 1d-08 (aops:vectorize-reduce #'max (x y)
              (/ (abs (- x y)) (abs x)))))

(let ((x (aops:generate* 'double-float (lambda () (1+ (random 1.0d0))) '(5000 5000)))
      (y (aops:rand '(5000 5000) 'double-float)))
  ;; warm-up
  (all-close-efv x y)
  (all-close-vr x y)
  ;; benchmark
  (time (all-close-efv x y))
  (time (all-close-vr x y)))

;; Evaluation took:
;;   1.400 seconds of real time
;;   1.381522 seconds of total run time (1.283582 user, 0.097940 system)
;;   [ Real times consist of 0.500 seconds GC time, and 0.900 seconds non-GC time. ]
;;   [ Run times consist of 0.499 seconds GC time, and 0.883 seconds non-GC time. ]
;;   98.71% CPU
;;   5,287,418,680 processor cycles
;;   2,599,993,824 bytes consed

;; Evaluation took:
;;   0.939 seconds of real time
;;   0.944718 seconds of total run time (0.894239 user, 0.050479 system)
;;   [ Real times consist of 0.090 seconds GC time, and 0.849 seconds non-GC time. ]
;;   [ Run times consist of 0.087 seconds GC time, and 0.858 seconds non-GC time. ]
;;   100.64% CPU
;;   3,585,169,280 processor cycles
;;   2,400,026,576 bytes consed

ほかにはzeros-like関数やones-like関数が特殊化された配列(:element-typetでない)に対応できていなかったので、array-operationsを使って書き直した。 <function>クラスに対するforwardメソッドやbackwardメソッドは消しておく。単にエラーメッセージを出力するデフォルトメソッドを用意するより、メソッドが存在しないときにデバッガに落ちる方がCommon Lispらしいやりかただろう。

gist.github.com.

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ18)

ソースコード

ステップ18 メモリ使用量を減らすモード

計算の途中で得た微分は不要な場合が多いため、用が済んだら消去するように改良する。 また、逆伝播を無効にするモードも実装する。

モード切替を実装するにあたり、backward総称関数にキーワード引数を追加する代わりにスペシャル変数を定義することにした。 というのも、オリジナルのコードretain_gradは一カ所しか使っていないし、enable_backpropとコンテキストマネージャの件はCommon Lispが持つスペシャル変数の典型的な用例ではないかと思ったからである。その特性から言って、総称関数はインターフェースとして安定しているべきなんだろう。

<variable>クラスのbackwardメソッドでは、uiop:if-letを使った計算をwith-accessorsを使って置き換え、読みやすさを改善してみた。loopdolistで置き換え、インデントも直した。

gist.github.com

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ17)

ソースコード

ステップ17 メモリ管理と循環参照

弱参照を使うことで、循環参照を解消し、メモリ使用量を減らす。 循環参照があると、参照カウント方式のメモリ管理では解放が難しいという。ガベージコレクタの方式によっては循環参照になっているオブジェクトでも解放できるが、コストが高いらしい。マニュアルによればSBCL世代別GCを持っているとのことである。

Common Lispでは弱参照がANSI Common Lispの範囲にないため、処理系ごとにAPIがばらばらに定義されている。そこで、統一的に扱うためのラッパーとしてtrivial-garbageというライブラリがあるので使うことにした。 Pythonweakref.ref関数のように弱参照を作る関数はtg:make-weak-pointerである。同様に弱参照からオブジェクトを得るにはtg:weak-pointer-value関数を呼べばよい。

gist.github.com

Common Lisp標準のtimeマクロとSBCLのsb-sprofというプロファイラを使ってメモリ使用量を測ってみたところ、ステップ16とステップ17ではメモリ使用量にあまり差は出なかった。何がおかしいのだろうか?

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ14~16)

ソースコード

ステップ14 同じ変数を繰り返し使う

微分を累算して、同じ変数を使っても結果がおかしくならないようにする。 オリジナルのコードにある次のコード片をCommon Lispにどう翻訳するか少し悩んだ。そのままだと(@gradient x)という式があちこちに出てくるし、aops:vectorizeマクロで書けないのである。

if x.grad is None:
    x.grad = gx
else:
    x.grad = x.grad + gx

解決策として、UIOPというライブラリで定義されたuiop:if-letというマクロを使うことにした。uiop:if-letは判定に使う値に名前を付けて、フォームの中で使えるようにしてくれるマクロである。 ここで、UIOPはよくある処理に対するユーティリティを多数提供するライブラリである。Common Lisp処理系には、CのMakeにあたるASDFというビルドツールが付属しており、UIOPはASDFの部品として同梱されているので、準標準ライブラリとして使える。

(setf (@gradient x)
      (uiop:if-let ((agx (@gradient x)))
        (aops:vectorize (agx gx)
          (+ agx gx))
        gx))

見直してみて気が付いたが、fletを使ってローカル関数を定義する、with-accessorマクロを使うことでも解決できそう。

微分をリセットするためのclear-gradientメソッドも用意した。

(defmethod clear-gradient ((var <variable>))
  (setf (@gradient var) nil))

gist.github.com

ステップ15 複雑な計算グラフ(理論編)

説明だけなので省略。

ステップ16 複雑な計算グラフ(実装編)

関数や変数に世代を覚えさせることで、逆伝播を正しく計算できるようにする。 そのためにgenerationというスロットを用意し、世代順にソートを行う。 Common Lispsort関数は実践 Common Lispの著者がいうところのリサイクルな関数なので、必ず返り値を受け取ってsetfする。

gist.github.com

実践 Common Lispは、載せられている事例が古びてきているものの、Common Lispを学ぶための最初の一冊として素晴らしいと思う。原書は無料公開されているので、読める人はぜひ。

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ11~13)

ソースコード

ステップ11 可変長の引数(順伝播編)

可変長引数を取り扱うため、callforwardシグネチャを変更し、関数の入力も出力もリストとなるよう変更する。 総称関数の特性上影響が大きく、コンパイルエラーを参考に直していくことになる。

ステップ12 可変長の引数(改善編)

Common Lispの関数は、仮引数リストの中で&restを使うことで可変長引数を受け取れるようになる。 forwardは総称関数として実装しているので、すべてのクラスで同じシグネチャにしなければならない。 オリジナルを見ると、仮引数リストが異なる同名のメソッドが定義されており、これでも問題ないところに違いを感じた。 apply関数を使って、リストをアンパッキングし、可変長の引数を渡すようにする。

ステップ13 可変長の引数(逆伝播編)

<variable>クラスのbackwardメソッドを直し、可変長引数に対応できるようにする。 また、<square>クラスも可変長引数に対応させる。だんだんややこしいコードになってきた。

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ9~10)

ソースコード

ステップ9 関数をより便利に

まず、呼び出し側が毎回gradientに全要素が1の配列を設定しなければならないのを直す。 ユーティリティ関数として次のようなものを定義する。

(defun full-like (array fill-value)
  (let ((dims (array-dimensions array)))
    (make-array dims :initial-element fill-value)))

(defun ones-like (array)
  (full-like array 1))

あとは次の行を書いておけば、gradientが未設定の時、初期値を設定できる。

(unless (@gradient var)
    (setf (@gradient var) (ones-like (@data var))))

defclass:initformオプションを使ってもよかったかもしれない。

次に、<variable>クラスに好き勝手な値を設定できないようにする。これにはcheck-typeを使うことで実行時に型を検査できる。インスタンスを作るときの挙動を変更するにはinitialize-instance総称関数をオーバーライドすればよい。

(defmethod initialize-instance :after ((var <variable>) &key)
  (check-type (@data var) array))

渡された値が数値なら配列に包み、配列であればそのまま返す関数も用意する。

(defun ensure-array (x)
  (if (numberp x)
      (vector x)
      x))

ステップ10 テストを行う

Common Lispではテストフレームワークがいくつか知られており、特にFiveAMが最も代表的なものとされている。今回はroveを試すことにした。

数値微分バックプロパゲーションの結果を比較し、計算が正しいか検証するため勾配確認という方法を使う。オリジナルではNumPyのallclose関数を使うことでほぼ等しいか判定できていたがCommon Lispにはそういうものはない。そこで次のような関数を定義することにした。

(defun all-close (x y)
  (every (lambda (x) (<= x 1d-08))
         (aops:flatten
          (aops:vectorize (x y)
            (/ (abs (- x y)) (abs x))))))

配列の各要素について差を求め、1次元配列につぶし、差が定数以下であればほぼ等しいと判定する。効率は良くないかもしれないが、今のところはこれで十分だろう。この辺りはmsyksphinzさんの記事を参考にさせていただいた。

ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ4~8)

ソースコード

ステップ4 数値微分

Common Lispでは0.942のような浮動小数点数リテラルを読み取ると、デフォルトで単精度浮動小数点数型(single-float)として扱ってしまう。この挙動は

 (setf *read-default-float-format* 'double-float)

という行を挿入すれば変更できる。自分はそうする代わりに0.942d0のようにリテラルで型を明示する方針を取った。

オリジナルのPythonコードでは__call__メソッドを利用することで、関数とDeZeroの関数を同じように扱える。そのため普通の関数を使って合成関数を表すことができる。RubyでもMethod#callメソッドがあるため、callメソッドを実装しておけば同じ扱い方でルーチンを起動できる。しかしCommon Lispの関数ではそうするわけにはいかないため、合成関数を表現するための<composed-function>クラスを定義した。

numerical-diff関数の実装はaops:vectorizeマクロのおかげで分かりやすいものになった。ありがたい。

ステップ5

説明のみ。

ステップ6 手作業によるバックプロパゲーション

手作業でバックプロパゲーションを行う。

ステップ7 バックプロパゲーションの自動化

<variable>クラスにbackwardメソッドを実装する。 Pythonではメソッドがクラスの名前空間に閉じ込められており、同じ名前だがシグネチャが違うメソッドを定義できる。オリジナルのDeZeroではVariableクラスとFunctionクラスにbackwardメソッドを定義しているが、前者は無引数、後者は引数を一つ取る。 しかし、Common Lispの総称関数は違うクラスで異なるシグネチャを持つことができない。仕方がないので、<variable>クラスでは仮引数を記述するが、利用しないということにした。

(declare (ignore gy))

この行を<variable>クラスのbackwardメソッドの中に挿入しておくことで、Common Lispコンパイラからの警告を抑制する。

ステップ8 再帰からループへ

コールスタックを利用する代わりに、自分でスタックを作って管理する。 スタックはリストを使って表現することにし、pushマクロとpopマクロを呼ぶことにした。