「ゼロから作る Deep Learning 3 フレームワーク編」の読書まとめ。思い出すためのまとめメモ。第2ステージ、目的は自然なコードです。
コードは以下。
第2ステージ 自然なコードで表現する
ここではDeep Learningの仕組み(微分の仕組み)を新たに作るというよりは、使い勝手を向上させるためのステージという意味合いが強いです。基本的な構成は大きく変わりません。多くのPythonの文法知識が必要なステージです。
複数の入力を受け付けるような関数にしたり、メモリ節約の仕組みを入れたり、演算子のオーバーロードをさせたりといった拡張になっています。
Functionクラス
いったんFunctionの変遷を追います。まず前ステップからの入出力が1つの初期状態
1 2 3 4 5 6 7 8 9 | class Function: def __call__( self , input ): x = input .data y = self .forward(x) output = Variable(as_array(y)) output.set_creator( self ) self . input = input self .output = output return output |
引数がリストになって
1 2 3 4 5 6 7 8 9 10 11 | class Function: def __call__( self , inputs): xs = [x.data for x in inputs] # dataのリスト作成 ys = self .forward(xs) # 複数入力 outputs = [Variable(as_array(y)) for y in ys] # ysも同様 for output in outputs: # 複数出力 output.set_creator( self ) self .inputs = inputs self .outputs = outputs return outputs |
こうなります。inputsとsがついて複数になっています。forwardもリスト入力に対応させる必要があります。
続いて可変長引数になり
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class Function: def __call__( self , * inputs): # ココで可変長 xs = [x.data for x in inputs] ys = self .forward( * xs) # 可変長引数 if not isinstance (ys, tuple ): # バカ除け(出力を強制タプル化) ys = (ys,) outputs = [Variable(as_array(y)) for y in ys] for output in outputs: output.set_creator( self ) self .inputs = inputs self .outputs = outputs return outputs if len (outputs) > 1 else outputs[ 0 ] def forward( self , xs): raise NotImplementedError() def backward( self , gys): raise NotImplementedError() |
こんな感じ。*がつくことで引数が可変長になります。このクラスを継承したAddクラスでは、
1 2 3 4 | class Add(Function): def forward( self , x0, x1): y = x0 + x1 return y |
こんな感じで自然な2変数入力、1出力の関数が記述できるようになります。
これでいったんFunctionクラスに対する修正は休憩。大改造が入るのは次のVariableクラスに対してです。
Variableクラス
まずは1入力1出力の初期状態
1 2 3 4 5 6 7 8 9 10 11 | class Variable: : def backward( self ): : while funcs: f = funcs.pop() x, y = f. input , f.output # ココが1入力1出力 x.grad = f.backward(y.grad) if x.creator is not None : funcs.append(x.creator) |
これを多出力に対応させます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class Variable: : def backward( self ): : while funcs: f = funcs.pop() gys = [output.grad for output in f.outputs] # 出力のgradのリスト化 gxs = f.backward( * gys) # 複数入力に対応したbackward if not isinstance (gxs, tuple ): # バカ除け(強制タプル) gxs = (gxs,) for x, gx in zip (f.inputs, gxs): # 複数入力でループ x.grad = gx # 微分結果を前の勾配へ渡す(ココがダメ) if x.creator is not None : funcs.append(x.creator) # 前の作成者を追加 |
コメントに書いた通りです。ただココがダメのところがあります。すでにxさんが他の誰かから勾配を取得済みだった場合(同じ値xが複数回使われる場合)に上書きしてしまいます。複数の勾配は加算しなくてはいけません。そこでココがダメの部分を
12 13 14 15 16 | for x, gx in zip (f.inputs, gxs): # 複数入力でループ if x.grad is None : x.grad = gx else : x.grad = x.grad + gx # ここで加算 |
こうしてやります。ちなみに16行目の x.grad = x.grad + gx を x.grad += gx としてはいけません。この2つの記述は意味が異なります。
続いてVariableの使いまわしに対応します。別の計算で同じ変数を使った場合に勾配が常に可算されてしまうので、これをNoneにクリアする関数を作ります。使いまわす際にはこの関数を呼んでおけばOK。
1 2 | def cleargrad( self ): self .grad = None |
creatorは上書きなのでクリアの必要はないです。
次により複雑な計算グラフに対応させます。今のままだとリストにappendしたものをpopで取ってきた順に逆伝播させているので正しい順番になっていません。後に入ったものから順に処理するため。そこで優先度の概念を入れて正しい順に計算させます。この優先度は順伝播の際に決定させることができます。ここでは世代という表現でそれを表しています。
順伝播の際に(creatorを設定する際に)この世代をインクリメントさせるという手段でその逆伝播の優先度を決めます。VariableにもFunctionにもこのgenerationの変数を追加します。
1 2 3 4 5 6 7 8 9 | class Variable: def __init__( self , data): : self .creator = None self .generation = 0 def set_creator( self , func): self .creator = func self .generation = func.generation + 1 # ここで自身の世代を設定 |
自分は、自分を生み出した人(creator)の次の世代という設定です。じゃあ自分を生み出した人(Function)の世代はどのように決めるかというと、Functionに入力される素材の中で最も大きい世代を使う。というルールで決めます。第3世代と第4世代を使って(入力して)Function計算するのであればそのFunctionは第4世代(このFunctionが生み出すものは第5世代)。ということです。
その計算は
1 2 3 4 5 6 | class Function: def __call__( self , * inputs): : outputs = [Variable(as_array(y)) for y in ys] self .generation = max ([x.generation for x in inputs]) |
入力のgenerationのリストを作ってその最大値を求めています。入力の素材(Variable)の中で一番大きい世代を持つものをこのFunctionの世代とする。これで順伝播で世代(計算順序)を設定することができるようになりました。
続いてbackwardです。逆から(世代が大きい順から)たどる必要がありますので、そのようにVariableを改造します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class Variable: : def backward( self ): : funcs = [] seen_set = set () # イマイチ集合を使う意味がわからない def add_func(f): # 関数内の関数(funcsにアクセスしたいから) if f not in seen_set: # ここでfの重複回避できてるのでは・・・? funcs.append(f) seen_set.add(f) funcs.sort(key = lambda x: x.generation) # generationの小さい順に並び替え add_func( self .creator) while funcs: f = funcs.pop() # ここは変わっていない。リストの最後からfを取得 gys = [output.grad for output in f.outputs] gxs = f.backward( * gys) : for x, gx in zip (f.inputs, gxs): : x.grad = x.grad + gx if x.creator is not None : add_func(x.creator) # もとはここはfuncsへのappendだった |
機能的な拡張はひとまずここまでで、続いては軽量化。メモリ管理や、不要な勾配の削除、微分計算Offモードの実装などそんな感じの実装になります。
Pythonのメモリ管理の知識で特にDeepLearningとは関係ないですね。参照カウントによるメモリ管理をしているので、一部循環参照になっている変数があるからそれを弱参照にしましょう。という話です。weakrefという参照カウントは増やさないけど、そっと覗き見できるそんな参照形式でその変数を宣言しましょう。
その変数はFunctionにいるoutputs(Variable)とそのoutputにいるcreator(Function)が循環してます。この片方(Function側のoutputs)を弱参照に変えます。
1 2 3 | self .outputs = outputs ↓ self .outputs = [weakref.ref(output) for output in outputs] |
に変えて、このoutputを参照する箇所(Variable)を
1 2 3 | gys = [output.grad for output in f.outputs] # output is weakref ↓ gys = [output().grad for output in f.outputs] # output is weakref |
へ変えます。
次の節約が勾配の非保持モードの追加。このモードでは、backwardしていく際に最後の勾配以外は捨てる。というモードです。通常とある式の微分値を保持したいのはその式の入力であって、途中計算の値全てで保持しておく必要はないでしょう。というモード。具体的にはフラグで判断して、funcのoutputに対して、使用済み勾配削除しています。
次がそもそも逆伝播不要モード。推論やテストするときには逆伝播不要ですので、そのコネクションを断っておいてメモリを節約するモードです。こっちはフラグではなくて、with文で切り替える形式のおしゃれな実装で紹介されています。
軽量化に関してはここまで。
続いてVariableクラスをよりおしゃれに使いやすくする対策。名前をつけたり、ndarrayの関数をバイパスしたり、演算子をオーバーロードしたりと。これもやはりDeepLearningそのものとは関係ないですが、変数をより直感的に扱うことができるようになります。ただpythonのテクニック的な章になってます。
なのでキーワードだけ。
・@propertyでself.data.ndarrayの機能をバイパスさせる。
・len関数(__len__)、print関数(__repr__)を作る。
・四則演算のFunctionを作って演算子のオーバーロードをする。
・演算子のオーバーロードはValiable同士はもちろん、int/floatのようなスカラやndarrayも対象とする。
・演算子(+,,-,/,*)に対応
こんなところ。いろいろPythonの知識を知っておく必要があります。後はパッケージ化してます。
最後にコラム。ここもキーワードだけ。フレームワークとして、Define-and-RunとDefine-by-Runの2つの方法があって、それぞれいいところがあるよ。というもの。and-Runは最初にモデルを定義して後に走り出すのに対して、by-Runは走りながら作るもの。最近のモデルはほとんどこのby-Run。PyTorch,MXNet,DyNet,TenorFlowはこっちだそうです。
以上
第2ステージ終了 以下自分のための補助資料
+=の罠
可変長引数
デコレータ
コメント