ゼロから作るDeep Learning3 フレームワーク編を読む その①ステップ1~7
はじめに
ゼロから作るDeep Learningの①と②を読み終えて(=写経した≠理解した)、Pytorchやtensorflowといったライブラリを使って開催中のいくつかのコンペにチャレンジしています。
一応各種ライブラリは使うことはできてコンペにsubmissionファイルを提出する程度のことはできるようになりましたが、いまいち理解しきれていない感じがしたのと、特にPytorchで多用するclassの扱いに全然慣れていないことから、ゼロから作るDeep Learning③フレームワーク編を読み始めました。
ちゃんと理解しながら進めたいと思い、こちらでつまずいたところを中心にアウトプットしていきたいと思います。機械学習もですがプログラミングも初心者だと今作はしんどい…
ゼロから作るDeep Learning③フレームワーク編について
オリジナルのDeep Learningフレームワーク"DeZero"をゼロから作ることを通して、Deep LearningそのものだけでなくPytorch、tensorflowといったライブラリの裏側まで理解しようという本です。
まだ読み始めたところですが各種レビューで言われている通り名著だと思います!できる限り簡易な言葉で書かれているので初心者でも理解しやすいですし、本当にゼロから作っていくので読破した時に理解が深まってること請け合です。
ただ、初心者でも理解しやすいといってもある程度の知識は必要で、Pythonの基本文法(しょっぱなからclassの継承など多様されます)や微分・線形代数の基礎知識は必須です。
また、Deep Learningの基礎知識(学習がどうやっての進むかくらい)もなくとも読めなくはないと思いますが、全体像の説明は無しにステップバイステップで進んでいくので「今これなんのためにやってるの?」となりそうなので、モチベーション維持の意味でもあった方が無難かと思います。
本題
コードを載せると長くなりすぎるので適宜はしょります。気になる方は書籍のGitHubにステップ別にまとめられているのでそちらご確認ください。
ステップ1 箱としての変数
しょっぱなからつまずきました。「変数を実装しましょう」といって変数のクラスを作るのですがなぜそんなことをするのか分かりません。変数なんてクラスにしなくてもa=0とかで指定してやれば良いだけでは…と思っていたのですが、読み進めるにつれてなんとなく理由が分かってきました。
このクラスから作られる変数には後々各ノードでの計算結果などが格納されることになります。そして誤差逆伝播などを考えると「自分自身の値」だけでなく「自分の位置と対応する勾配」などの情報も持つ必要がでてきます。その際、シンプルな変数宣言だと(できないことは無いと思いますがコードが複雑になるので)情報を持ちきれなくなってしまうため、クラスにしつつ各値をプロパティとして持つ必要があります。
ここのステップだけ読んでいても必要性は分かりづらいのと、やっていること自体は簡単なので理由は諦めて先に進むことをお勧めします。
ステップ2 変数を生み出す関数
ここはクラスの継承や__call__メソッドが普通に出てくるのでクラスに慣れていないと厳しいですが、やってることはシンプルです。様々な関数を扱うクラスを作成します。
ステップ3 関数の連結
ここもつまずきは無し。ステップ2で作成した関数を扱うクラスを継承した関数(のクラス)を作成し、実際に計算を試してみるだけです。
ステップ4 数値微分
数学の授業では習わなかった数値微分の中心差分近似が出てきますが微分を理解していれば特につまずかないと思います。(実際は微分の理解が甘くの微分でつまずいたのですが後程)
ステップ5 バックプロパゲーションの理論
話は難しいですが誤差逆伝播法の知識があったり前々作を読んでいればつまずかないと思います。もしつまずくようでも次のステップで具体例を確認しながら理解した方が良いと思います。
ステップ6 手作業によるバックプロパゲーション
つまずき①微分
自分の数学力が低すぎただけですが、試しにやってみた手計算の結果とプログラムの結果が合わず、ちゃんと手を動かさないとダメだなと思いました…誤差逆伝播の復讐を兼ねて詳しめにまとめます。
以下のような計算グラフの順伝播/逆伝播を計算します。は入力もしくは出力、は入力を二乗する関数、はの入力乗を出力する関数で、です。
順伝播、微分、逆伝播三種類の計算を行います。
1. の値がどうなるか(順伝播)
普通に計算するだけで、以下の様になります。
2. を求める(数式微分)
をについて微分していきます。この際、「は微分しても」でこれも変わらないと思っていたのですが、の肩が関数の場合は以下の様になるのを初めて知りました。数学力の低さが課題。
というわけで、この公式を利用すると以下の様になります。
仮にとすると、
3. を求める(誤差逆伝播で解く)
以下のチェインルールと、を思い出しつつ、
から、で先ほどの結果と一致します。
つまずき②クラスの関係がややこしくなってきた
一応理解はできたのですが、この辺りから各クラスのインスタンス変数やメソッドが多くなってきて「何でその情報をこのクラスに持たせるの?」という混乱との戦いが始まりました。
オブジェクトの関係性を整理しないと訳が分からなくなるので、以降のことも考え図でまとめながら読み進めることにします。まずは各クラスを個別にみてみます。
Variableクラス
変数のためのクラスで、初期値や各ノードで計算された計算結果および勾配といった様々な値は(今のところ)すべてこのクラスのインスタンスとします。ステップ1でも触れましたが自分自身の値dataに加えて、今後勾配gradを保存していくためのインスタンス変数を持ちます。
class Variable: def __init__(self, data): self.data = data self.grad = None
今のところのイメージは↓のような感じです。表の様に見えるものは正面から見た箱か棚だと思って書いています。
Variableという名前の箱に、現状はdataとgradというステータスの保管場所だけがあります。この箱は複製可能で複製先で名前を変えることも可能で引数にdata(ndarray型)を渡すと複製したやつのdataにそれが保管されます。gradは最初は空っぽですが後から中身が入れられます。
Functionクラスとそのサブクラス
様々な関数の親となるFunctionクラスがありますがこれは計算は行わず、計算の種類ごとにサブクラスを作ります。おそらくですが今後ノードや活性化関数の計算に使われると思われます。
Functionクラスはただの入れ物です。__call__メソッドにサブクラス共通の処理だけ書かれています。ざっくり言えば「入力を受け取って計算して出力する」の流れだけを持っています。
他のメソッドにforwardとbackwardを持ちますが、これは間違ってサブクラスではなくFunctionクラスをインスタンス化してしまったときに例外処理をするためで、計算上の意味はありません。
サブクラスは現状↑ででてきた2つの関数(入力の二乗を返すものとの入力乗を返すもの)だけが定義されています。
class Function: def __call__(self, input): x = input.data y = self.forward(x) output = Variable(y) self.input = input return output def forward(self, x): raise NotImplementedError() def backward(self, gy): raise NotImplementedError() # サブクラスその1 class Square(Function): def forward(self, x): y = x ** 2 return y def backward(self, gy): x = self.input.data gx = 2 * x * gy return gx # サブクラスその2 class Exp(Function): def forward(self, x): y = np.exp(x) return y def backward(self, gy): x = self.input.data gx = np.exp(x) * gy return gx
イメージは↓の感じでしょうか。
Functionはいろいろな情報が入ったinputという名前の箱を受け取ります(そして、それを自分の中にも保存しておきます)。箱の設計図は先ほどのVariableの通りです。その中の値(data)だけを取り出してxとし、まだ中身の決まっていないforwardというxに何かしらの計算をする関数に投げて、返ってきた値yをまたVariableの設計図通りにできた箱に入れて、その箱をoutputと名付け誰かに渡します。この一連の流れはすべて__call__メソッドで行われます。
サブクラスは__call__メソッドはFunctionと同じで「inputを受け取り~outputを誰かに渡す」という働きをします。ただ、サブクラス毎に異なる働きをする2つのメソッドを持ちます(フローは同じ)。
- forward
Functionで中身の決まっていなかったやつです(入ってきた値を二乗して返す、とか)。これでちゃんとxを別の値yに変えてoutputに繋げることができます。 - backward
外からやってくるgyを引数にとり、__call__メソッドで保存しておいたinputの中の値(data)をxとしたものの2つを用いて新たな勾配gxを計算し、次の誰かに渡します。
自分的には整理できたのですが他の人にも伝わりますかね…こういった図を書くうまいフレームワーク、きっとあるんですよね。。IT業界を目指さなかった過去の自分を恨む。
ステップ7 バックプロパゲーションの自動化
ここも理解はできましたが書籍の具体例を用いつつクラス関係図を更新します。いきなり表現方法が変わりすみません。
黒線が順伝播、赤線と青線が逆伝播です。
順伝播
(略された→→→の出力である)をにinputとして渡す
の__call__を呼ぶとそれと対になっているforwardも呼び出され、が出力される
また、同時に__call__内ではのset_creatorが呼び出され、creatorにが格納される(が自分の生みの親をと記憶する。)
逆伝播
- のgradに
np.array(1.0)
を格納(をで微分したら1。意味がなさそうだが後続の処理をするために必要。) - のbackwardを実行(赤線のフロー)
- のcreatorを確認し、生みの親がだったことを取得する
- 生みの親の__call__に保存しているinputを確認し、がinputだったことを取得する
- 生みの親のbackwardを呼び出す。引数には先ほど格納しておいたのgradを渡す
- __call__からinputされたの値(data)を持ってきてxとする
- 勾配を計算(引数になったのgrad 自身のxにおける勾配)
- 自分のinputだったのgradを、↑の値で更新する
- 2-2で取得していたのbackwardが自動実行(青線のフロー、2に戻り、以降2-1で「生みの親」が見つからなくなるまで繰り返す)
最後に
今日のところはここまでです。図が人に伝わる形になっているのすごく不安ですが、自分の中では理解を深めながら進められています。1か月くらいで読破+記事化を済ませたい…次は~ステップ14まで進める予定。