ゼロから作る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の書式文字列の説明と下記の記事を参考にした。
(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-type
がt
でない)に対応できていなかったので、array-operations
を使って書き直した。
<function>
クラスに対するforward
メソッドやbackward
メソッドは消しておく。単にエラーメッセージを出力するデフォルトメソッドを用意するより、メソッドが存在しないときにデバッガに落ちる方がCommon Lispらしいやりかただろう。
ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ18)
ソースコード
ステップ18 メモリ使用量を減らすモード
計算の途中で得た微分は不要な場合が多いため、用が済んだら消去するように改良する。 また、逆伝播を無効にするモードも実装する。
モード切替を実装するにあたり、backward
総称関数にキーワード引数を追加する代わりにスペシャル変数を定義することにした。
というのも、オリジナルのコードでretain_grad
は一カ所しか使っていないし、enable_backprop
とコンテキストマネージャの件はCommon Lispが持つスペシャル変数の典型的な用例ではないかと思ったからである。その特性から言って、総称関数はインターフェースとして安定しているべきなんだろう。
<variable>
クラスのbackward
メソッドでは、uiop:if-let
を使った計算をwith-accessorsを使って置き換え、読みやすさを改善してみた。loop
をdolist
で置き換え、インデントも直した。
ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ17)
ソースコード
ステップ17 メモリ管理と循環参照
弱参照を使うことで、循環参照を解消し、メモリ使用量を減らす。 循環参照があると、参照カウント方式のメモリ管理では解放が難しいという。ガベージコレクタの方式によっては循環参照になっているオブジェクトでも解放できるが、コストが高いらしい。マニュアルによればSBCLは世代別GCを持っているとのことである。
Common Lispでは弱参照がANSI Common Lispの範囲にないため、処理系ごとにAPIがばらばらに定義されている。そこで、統一的に扱うためのラッパーとしてtrivial-garbageというライブラリがあるので使うことにした。
Pythonのweakref.ref
関数のように弱参照を作る関数はtg:make-weak-pointer
である。同様に弱参照からオブジェクトを得るにはtg:weak-pointer-value
関数を呼べばよい。
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))
ステップ15 複雑な計算グラフ(理論編)
説明だけなので省略。
ステップ16 複雑な計算グラフ(実装編)
関数や変数に世代を覚えさせることで、逆伝播を正しく計算できるようにする。
そのためにgeneration
というスロットを用意し、世代順にソートを行う。
Common Lispのsort関数は実践 Common Lispの著者がいうところのリサイクルな関数なので、必ず返り値を受け取ってsetfする。
実践 Common Lispは、載せられている事例が古びてきているものの、Common Lispを学ぶための最初の一冊として素晴らしいと思う。原書は無料公開されているので、読める人はぜひ。
ゼロから作るDeep LearningをCommon Lispで書き直す(ステップ11~13)
ソースコード
ステップ11 可変長の引数(順伝播編)
可変長引数を取り扱うため、call
やforward
のシグネチャを変更し、関数の入力も出力もリストとなるよう変更する。
総称関数の特性上影響が大きく、コンパイルエラーを参考に直していくことになる。
ステップ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
マクロを呼ぶことにした。