「ゼロから作る Deep Learning 3 フレームワーク編」の読書まとめ。思い出すためのまとめメモです。まとめるつもりで読まないと頭に入ってこないので。
コードは以下。
第1ステージ 微分を自動で求める
変数を管理するクラスに自動微分の仕組みを入れて、計算結果の微分値を保持できるようにします。また処理する関数は順伝播と逆伝播をセットで実装して、結果をこの変数に格納するような仕組みです。
数値微分・解析微分・合成関数の微分・連鎖率・計算グラフなど基本がわかっていれば、その実装を少し回りくどくやっているだけなので、難解ではありません。やはり1巻は読んでおく必要がありそうです。
Variableクラスで値を管理し、Functionクラスで関数を管理する。というのが基本構造になります。それぞれのクラスがステップを経て成長していきます。
Variableクラスの実装
まずVariableクラスです。最終的にはテンソルなど変数のベースであり、微分値を保持しておくクラスです。最初は値を入れておくだけ、
class Variable:
def __init__(self, data):
self.data = data
続くステップ6で誤差逆伝播のための勾配を記憶できるように改変されます。
class Variable:
def __init__(self, data):
self.data = data
self.grad = None # 追加
さらにステップ7でcreator(この値を生み出した関数・クラス)を記憶するようにし、加えて逆伝播の挙動を記述しています。
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None # 追加
def set_creator(self, func):
self.creator = func
def backward(self):
f = self.creator # 1. Get a function
if f is not None:
x = f.input # 2. Get the function's input
x.grad = f.backward(self.grad) # 3. Call the function's backward
x.backward()
自分の一つ前の値(自分の材料)の勾配は、材料から自分を作った人(f)と自分自身が受け取った勾配(self.grad)から、(順伝播が済んでいれば)作った人(f)に問い合わせれば計算できそうです。この計算結果を自分の材料(x)の勾配(x.grad)として代入しています。その後再帰的に材料(x)の逆伝播を呼んでいます。
自分を作った人(f)の解析微分(f.backward)を順伝播の際の入力(f.input)で計算し、その結果に自分の勾配(gy)を乗ずるという作業になります。
日本語だと誤解を招きそうなのでコードにすると、x.grad=f'(f.input.data)*self.gradこんな感じ。
概念的にはこのステップで終わりなのですが、続くステップ8で再帰をループに展開しています。
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
def backward(self):
funcs = [self.creator]
while funcs:
f = funcs.pop() # 1. Get a function
x, y = f.input, f.output # 2. Get the function's input/output
x.grad = f.backward(y.grad) # 3. Call the function's backward
if x.creator is not None:
funcs.append(x.creator)
これも読めばなんとなくわかりますが、この段階ではループ展開の必然はわかりません。計算コストという話もありますが、この先の分岐を伴う計算グラフの時に真価を発揮します。
最終的にはこの形でこのステージは終わりです。バカ除けがついただけです。
class Variable:
def __init__(self, data):
if data is not None:
if not isinstance(data, np.ndarray): # バカ除け1
raise TypeError('{} is not supported'.format(type(data)))
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
def backward(self):
if self.grad is None: # バカ除け2
self.grad = np.ones_like(self.data)
funcs = [self.creator]
while funcs:
f = funcs.pop()
x, y = f.input, f.output
x.grad = f.backward(y.grad)
if x.creator is not None:
funcs.append(x.creator)
Functionクラスの実装
続いてFunctionクラスの推移を確認します。このクラスは多くの関数の基底クラスとして定義されています。通常使う関数(x^2やexp(x)など)はこの関数の派生クラスとして実装されます。まずは__call__関数で順伝播関数(仮想関数)を呼ぶ動きを強制しています。
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(y)
return output
def forward(self, in_data):
raise NotImplementedError()
このinputに先ほどのVariableが入るイメージです。出力もVariableクラスにして返します。forwardは派生クラスで実装が必要な仮想関数です。
続いて入力を記憶するためのメンバ変数(self.input)が追加されました。
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()
これは微分するために順伝播で入ってきた値を保持する必要があるためです。併せて逆伝播(解析微分する関数)の仮想関数も追加されています。
続いてVariableに対してcreatorが追加されたことに対しての実装です。作った人が作ったタイミングでcreator(自分自身)をセットします。
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(y)
output.set_creator(self) # Set parent(function)
self.input = input
self.output = output # Set output
return output
def forward(self, x):
raise NotImplementedError()
def backward(self, gy):
raise NotImplementedError()
outputも覚えていますがまだしばらく登場しません。概念的にはこれでいったん完成です。
このステージでの最終形はやはりバカ除け(as_array)がついた
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
def forward(self, x):
raise NotImplementedError()
def backward(self, gy):
raise NotImplementedError()
これです。as_arrayはyがスカラだった際にも強制的にnp.arrayにするための関数です。
派生クラスの実装
二乗とexpが載ってます。これはFunctionクラスの仮想関数を実装しただけのシンプルなものです。
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
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
あとはこんな関数さえ用意しておけば
def square(x):
return Square()(x)
def exp(x):
return Exp()(x)
順伝播・逆伝播の計算は
x = Variable(np.array(0.5))
y = square(exp(square(x)))
y.backward() # 出力yから逆伝播
print(x.grad) # 入力xの勾配算出
この記述だけで終わってしまいます。可読性もよいし、記述量も少ない。Variableのyに対してbackwardする際に入力となる勾配(y.grad)(通常はLoss関数の結果)を入れてやる必要がありますが、これはバカ除けによって初期値1で動いています。
テスト
ポイントは
pythonのunittestを使う
期待値は計算が簡単な数値微分の結果を使う
これにより期待値生成に乱数を使うことができ、カバレッジの範囲が広がってよいね。ということです。ただ完全一致はしないので、許容値をもってOKとする必要があります。
コード的には
def numerical_diff(f, x, eps=1e-4):
x0 = Variable(x.data - eps)
x1 = Variable(x.data + eps)
y0 = f(x0)
y1 = f(x1)
return (y1.data - y0.data) / (2 * eps)
class SquareTest(unittest.TestCase):
def test_gradient_check(self):
x = Variable(np.random.rand(1))
y = square(x)
y.backward()
num_grad = numerical_diff(square, x)
flg = np.allclose(x.grad, num_grad)
self.assertTrue(flg)
allcloseが近い値か否かを判定するものですが、もちろん用いる関数によってその範囲は厳密には異なります。
以上
第1ステージでした。
コメント