『ゼロから作るDeep Learning 3』のコードを全て読んでみた(1)

今話題のこの本を読もうと思ったのだが、アマゾンで在庫切れになっていた。

そこで、GitHubに公開されているこの本のソースコードだけを読んでおり、自分自身の学習メモを何回かに分けて記載する。いきなりライブラリ本体を読むのではなく、ライブラリを作っていくプロセスが60個の.pyファイルに分けられているので、まずはこちらから読んでいく。今回は第1回で、step01.pyからstep06.pyまでである。

step01.py

ソースコードは以下。

https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/master/steps/step01.py

ここではVariableクラスを作る。__init__()はコンストラクタである。メンバ変数はdataのみで、変数の値を示す。メンバ関数はまだない。

その下のサンプルコードでは、スカラーの1.0や2.0を引数にVariableクラスのインスタンスを生成して、値を出力している。

step02.py

ソースコードは以下。

https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/master/steps/step02.py

ここではFunctionクラスを作る。__call__()はインスタンス名()で外部から関数のように呼び出すためのものである。このクラスは文字通り関数を表すものであるため、()演算子を定義して、インスタンスを関数のように呼び出そうとしている。これはC++でいうところのoperator()という演算子を定義するのと似ている。このように()演算子を持つものを関数オブジェクトやFunctorなどと呼ぶことがある。

__call()__ではVariableのインスタンスを受け取って、forward()関数を回し、計算結果を格納したVariableインスタンスを返す。forward()には関数内部での計算処理を書くわけだが、このFunctionはインターフェイスだけを定義するクラスであるため、forward()は実装されておらず、Functionクラスを継承した子クラスで定義する。

Squareクラスはインプットの2乗を返すクラスで、Functionクラスを継承し、forward()を実装している。このforward()の中に、インプットの2乗を返す、という関数内部の処理を書く。

その下のサンプルコードでは、10の2乗を計算して表示している。
f = Square()
で関数のインスタンスを作り、
y = f(x)
で__call__()に引数xを入れて呼び出している。インスタンス名()というのはここでいうf()のことである。このようにしてインスタンスがあたかも関数であるかのように書ける。

step03.py

ソースコードは以下。

https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/master/steps/step03.py

ここでは追加でExpクラスを定義する。ExpクラスはFunctionクラスを継承しており、forward()を実装する。インプットxを受け取りアウトプットexp(x)を返す関数である。

その下のサンプルコードでは、二乗を返す関数A(x)、指数変換の結果を返す関数B(x)、二乗を返す関数C(x)を組み合わせて、合成関数C(B(A(x)))を作っている。つまり、関数
y=(exp(x^2))^2
である。この関数に0.5をインプットした結果を表示する。

ニューラルネットワークは線形変換と非線形変換を組み合わせた合成関数なので、ここでそのための準備を行っているのだろう。

step04.py

ソースコードは以下。

https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/master/steps/step04.py

ここでは関数の数値微分を計算する。それをやっているのが関数
numerical_diff()
である。関数オブジェクトf、インプットx、xをずらす幅epsを受け取り、1階の数値微分の結果を返す。数値微分にも色々なやり方があるが、ここでは中心差分を使っている。つまり、インプットxを増やした場合のf(x)の値と、xを減らした場合のf(x)の値から求める。つまり、
( f(x+dx) – f(x-dx) ) / 2dx
で微分を計算する。

Functionクラスには新たにメンバ変数
self.input
self.output
が追加されている。

下のほうにあるサンプルコードでは、合成関数
y=(exp(x^2))^2
を作って、関数f()と名前を付け、関数numerical_diff()を呼んで、この関数fのx=0.5における数値微分を求めている。

ディープラーニングでは動かすインプットxの数が多すぎることもあり、微分を計算するのに数値微分は効率が悪すぎるため、その代わりにリバースモードの自動微分(誤差逆伝播法:バックプロパゲーション)を用いる。バックプロパゲーションをこれから作っていくのだが、ここではそのテスト用あるいは説明用に数値微分の関数numerical_diff()を作ったのだろう。

step05.py

ソースコードは空っぽである。

step06.py

ソースコードは以下。

https://github.com/oreilly-japan/deep-learning-from-scratch-3/blob/master/steps/step06.py

ここからバックプロパゲーションが登場する。Variableクラスにメンバ変数gradを追加する。Variableクラスは、変数の値を示すメンバdataと、微分の値を示すメンバgradを、両方とも保持する、というのがポイントである。

Functionクラスには新たなインターフェイスとしてメンバ関数backward()を追加する。Functionはインターフェイスを定義するクラスなのでbackward()もここでは実装を定義しない。

backward()の2つ目の引数gyは、自分よりも1つ出力層寄りの微分結果である。合成関数f(g(x))の微分は、fをgで微分したものと、gをxで微分したものの掛け算で求まる。バックプロパゲーションでは、入れ子になっている合成関数の外側から、つまり出力層側から順に微分を求め掛け算していく。順番としてはfをgで微分し、その次にgをxで微分する。fをgで微分したものが上記の引数gyで、gをxで微分したものはこの関数backward()の中で定義する。gyという名前はgradient of yから来ているのだろう(y=g(x), z=f(y)=f(g(x)とおけば、fをgで微分したもの、つまりgyはfをyで微分したものに対応する)。

具体的な関数の微分をbackward()の中に定義していく。
まずSquareクラスでは、y=x^2の微分は2xなので、backward()では、2xに引数gyを掛け算したものを返す。
次にExpクラスでは、y=exp(x)の微分はexp(x)のままなので、backward()では、exp(x)に引数gyを掛け算したものを返す。

最後のサンプルコードでは、合成関数
y=(exp(x^2))^2
を例にして、バックプロパゲーションによりyのxに関する微分を求めている。
インプットをVariable型で定義して、
a = x^2
b = exp(a) = exp(x^2)
y = b^2 = ( exp(x^2) )^2 = exp(2 x^2)
と定義し、yをxで微分する。

この例では解析的に微分できて、
(4x) exp(2 x^2)
となるが、これをバックプロパゲーションで微分しよう、というのがこのサンプルコードである。

∂y/∂x = (∂y/∂y)(∂y/∂b)(∂b/∂a)(∂a/∂x)
と分解して左から順に求めていく。
ここで、初めの(∂y/∂y)は1で自明だが、
C.backward(y.grad)
でインプットするy.gradの値が必要である。このy.gradはyをyで微分したもの、ということだが、これは1で自明であり、初めに
y.grad = np.array(1.0)
と1を与えている。

(∂y/∂y)(∂y/∂b) = (1) (2b)
を求めている箇所が
b.grad = C.backward(y.grad)
である。

(∂y/∂y)(∂y/∂b)(∂b/∂a) = (1) (2b) (exp(a))
を求めている箇所が
a.grad = B.backward(b.grad)
である。

(∂y/∂y)(∂y/∂b)(∂b/∂a)(∂a/∂x) = (1) (2b) (exp(a)) (2x)
を求めている箇所が
x.grad = A.backward(a.grad)
である。

この計算結果を書き直すと、
(1) (2b) (exp(a)) (2x)
= (2 exp(x^2)) (exp(x^2)) (2x)
= (4x) exp(2 x^2)
となり、上で解析的に微分した結果と一致するのがわかる。