前ステップまでに実装したコード
[1]:
import numpy as np
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()
x, y = f.input, f.output
x.grad = f.backward(y.grad)
if x.creator is not None:
funcs.append(x.creator)
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(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()
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
私たちのDeZeroは、今ではバックプロパゲーションで計算ができます。さらには、Define-by-Runという、実行時に計算の「つながり」を作る特徴を持ち合わせています。ここでは、今のDeZeroをより使いやすくするために、DeZeroの関数に対して3つの改善を行います。
これまで私たちは、DeZeroで使用する関数を「Pythonのクラス」として実装してきました。そのため、たとえばSquare
クラスを使って計算を行うには、次のようなコードを書く必要がありました。
[2]:
x = Variable(np.array(0.5))
f = Square()
y = f(x)
上記のように、2乗の計算を行うには、Square
クラスのインスタンスを生成し、そのインスタンスを呼び出すという2段階のステップを踏みます。しかし、使う側からしてみれば、この2段階の作業は少し手間です(y = Square()(x)
とも書けますが、それも不格好です)。より好ましいのは、「Pythonの関数」として利用できることでしょう。そこで、次の実装を加えます。
[3]:
def square(x):
f = Square()
return f(x)
def exp(x):
f = Exp()
return f(x)
上記のように、square
とexp
という2つの関数を実装しました。これで「DeZeroの関数」を「Pythonの関数」として利用できるようになります。ちなみに上のコードは、次のように1行で書くこともできます。
[4]:
def square(x):
return Square()(x) # 1行でまとめて書く
def exp(x):
return Exp()(x)
f = Square()
のようにf
という変数名で参照することなく、直接Square()(x)
と書くこともできます。それでは、ここで実装した2つの関数を使ってみましょう。
[5]:
x = Variable(np.array(0.5))
a = square(x)
b = exp(a)
y = square(b)
y.grad = np.array(1.0)
y.backward()
print(x.grad)
3.297442541400256
このとおり、最初のnp.array(0.5)
をVariable
で包めば、通常の数値計算を行うような感覚で――NumPyを使って計算するように――コーディングできます。なお、上のコードは、関数を連続して適用することも可能です。その場合は、次のように書くことができます。
[6]:
x = Variable(np.array(0.5))
y = square(exp(square(x))) # 連続して適用
y.grad = np.array(1.0)
y.backward()
print(x.grad)
3.297442541400256
これで、より自然なコードで計算ができるようになりました。これが1つ目の改善点です。
2つ目の改善点は、逆伝播におけるユーザの手間を減らすためのものです。具体的には、先ほど書いたコードにあるy.grad = np.array(1.0)
を省略します。というのも、私たちは逆伝播を行う際に、毎回y.grad = np.array(1.0)
というコードを書いています。その作業を省略できるように、Variable
のbackward
メソッドの中に次の2行を追加します。
[7]:
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):
if self.grad is None: # 追加したコード
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)
上記のように、もし変数のgrad
がNone
の場合、自動で微分を生成します。ここでは、np.ones_like(self.data)
によって、self.data
と同じ形状かつ同じデータ型で、その要素が1のndarray
インスタンスを生成します。self.data
がスカラの場合は、self.grad
もスカラになります。
NOTE
これまでは出力の微分をnp.array(1.0)
としていましたが、上のコードではnp.ones_like()
を使いました。その理由は、Variable
のdata
とgrad
のデータ型を同じにするためです。たとえば、data
の型が32ビットの浮動小数点数であれば、grad
の型も32ビットの浮動小数点数になります。ちなみに、np.array(1.0)
と書いた場合、そのデータ型は64ビットの浮動小数点数になります。
これで、何らかの計算を行えば、後は最終出力の変数に対してbackward
メソッドを呼ぶだけで微分が求まります。実際に試してみると、次のようになります。
[8]:
x = Variable(np.array(0.5))
y = square(exp(square(x)))
y.backward()
print(x.grad)
3.297442541400256
DeZeroのVariable
は、データとしてndarray
インスタンスだけを扱う仕様です。しかし、使う人によっては、誤ってfloat
やint
などのデータ型を使ってしまうケース――たとえばVariable(1.0)
やVariable(3)
など――も十分に考えられます。それを見越して、ここではVariable
がndarray
インスタンスだけの「箱」になるように一工夫加えることにします。具体的には、Variable
にndarray
インスタンス以外のデータを入れた場合には、即座にエラーを出すようにします(ただし、None
は保持できるようにします)。そうすることによって、問題の早期発見が期待できます。それでは、Variable
クラスの初期化部分に次のコードを追加します。
[9]:
class Variable:
def __init__(self, data):
if data is not None: # 追加したコード
if not isinstance(data, np.ndarray): # 追加したコード
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:
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)
上記のように、引数で与えられたdata
がNone
でなく、かつndarray
インスタンスでもない場合には、TypeError
という例外を発生させます。このとき、エラーとして出力する文字列も上記のように用意します。これで、次のようにVariable
を使うことができます。
[10]:
x = Variable(np.array(1.0)) # OK
x = Variable(None) # OK
x = Variable(1.0) # NG:エラーが発生!
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-10-01543c067d7d> in <module>()
2 x = Variable(None) # OK
3
----> 4 x = Variable(1.0) # NG:エラーが発生!
<ipython-input-9-709cc5af525f> in __init__(self, data)
3 if data is not None: # 追加したコード
4 if not isinstance(data, np.ndarray): # 追加したコード
----> 5 raise TypeError('{} is not supported'.format(type(data))) # 追加したコード
6
7 self.data = data
TypeError: <class 'float'> is not supported
上記のように、ndarray
もしくはNone
の場合は問題なくVariable
を生成できます。しかし、それ以外のデータ型では――上の例ではfloat
の場合は――例外が発生します。それによって、間違ったデータ型を使っていることが即座に分かります。
さて、この変更に伴って注意点が1つ増えます。それは、NumPy独自の作法に起因するものです。それを説明するにあたって、まずは次のNumPyのコードを見てみましょう。
[ ]:
x = np.array([1.0])
y = x ** 2
print(type(x), x.ndim)
print(type(y))
ここでは、x
は1次元のndarray
です。このとき、x ** 2
(2乗計算)の結果となるy
のデータ型はndarray
になります。これが期待どおりの結果です。問題になるのは、次のケースです。
[ ]:
x = np.array(1.0)
y = x ** 2
print(type(x), x.ndim)
print(type(y))
ここで、x
は0次元のndarray
です。このとき、x ** 2
の結果はnp.float64
になってしまいます。これはNumPyの仕様によるものです。つまり、0次元のndarray
インスタンスを使って計算した場合、結果がndarray
インスタンス以外のデータ型――numpy.float64
やnumpy.float32
など――になってしまうのです。そのため、DeZeroの関数の出力となるVariable
がnumpy.float64
型やnumpy.float32
型のデータを持ってしまう場合が出てきます。しかし、Variable
のデータは、ndarray
インスタンスだけを持つ仕様です。これに対応するには、まずは便利関数として、次の関数を準備します。
[ ]:
def as_array(x):
if np.isscalar(x):
return np.array(x)
return x
ここでは、np.isscalar
関数を使って、numpy.float64
などのスカラ系の型を判定します(これはPythonのint
やfloat
も判別できます)。実際に、np.isscalar
関数を使ってみると、次のようになります。
[ ]:
print(np.isscalar(np.float64(1.0)))
print(np.isscalar(2.0))
print(np.isscalar(np.array(1.0)))
print(np.isscalar(np.array([1, 2, 3])))
このように、np.isscalar(x)
でx
がndarray
インスタンスかどうかを判定できます。as_array
関数ではこれを利用し、ndarray
インスタンス以外の場合はndarray
インスタンスに変換します。このas_array
という便利関数が用意できれば、後はFunction
クラスに次のコードを追加します。
[ ]:
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()
上のとおり、順伝播の結果となるy
をVariable
で包むときに、as_array(y)
とします。そうすることで、出力結果のoutput
はndarray
インスタンスであることを保証できます。これで0次元のndarray
インスタンスを使った計算でも、すべてのデータがndarray
インスタンスになります。
以上で、本ステップの作業は終了です。次のステップでは、DeZeroの「テスト」についての話をします。