Open In Colab

ステップ9 関数をより便利に

前ステップまでに実装したコード

[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つの改善を行います。

9.1 Pythonの関数として利用

これまで私たちは、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)


上記のように、squareexpという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つ目の改善点です。

9.2 backwardメソッドの簡略化

2つ目の改善点は、逆伝播におけるユーザの手間を減らすためのものです。具体的には、先ほど書いたコードにあるy.grad = np.array(1.0)を省略します。というのも、私たちは逆伝播を行う際に、毎回y.grad = np.array(1.0)というコードを書いています。その作業を省略できるように、Variablebackwardメソッドの中に次の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)


上記のように、もし変数のgradNoneの場合、自動で微分を生成します。ここでは、np.ones_like(self.data)によって、self.dataと同じ形状かつ同じデータ型で、その要素が1のndarrayインスタンスを生成します。self.dataがスカラの場合は、self.gradもスカラになります。

NOTE

これまでは出力の微分をnp.array(1.0)としていましたが、上のコードではnp.ones_like()を使いました。その理由は、Variabledatagradのデータ型を同じにするためです。たとえば、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

9.3 ndarrayだけを扱う

DeZeroのVariableは、データとしてndarrayインスタンスだけを扱う仕様です。しかし、使う人によっては、誤ってfloatintなどのデータ型を使ってしまうケース――たとえばVariable(1.0)Variable(3)など――も十分に考えられます。それを見越して、ここではVariablendarrayインスタンスだけの「箱」になるように一工夫加えることにします。具体的には、Variablendarrayインスタンス以外のデータを入れた場合には、即座にエラーを出すようにします(ただし、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)


上記のように、引数で与えられたdataNoneでなく、かつ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.float64numpy.float32など――になってしまうのです。そのため、DeZeroの関数の出力となるVariablenumpy.float64型やnumpy.float32型のデータを持ってしまう場合が出てきます。しかし、Variableのデータは、ndarrayインスタンスだけを持つ仕様です。これに対応するには、まずは便利関数として、次の関数を準備します。

[ ]:
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x


ここでは、np.isscalar関数を使って、numpy.float64などのスカラ系の型を判定します(これはPythonのintfloatも判別できます)。実際に、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)xndarrayインスタンスかどうかを判定できます。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()


上のとおり、順伝播の結果となるyVariableで包むときに、as_array(y)とします。そうすることで、出力結果のoutputndarrayインスタンスであることを保証できます。これで0次元のndarrayインスタンスを使った計算でも、すべてのデータがndarrayインスタンスになります。

以上で、本ステップの作業は終了です。次のステップでは、DeZeroの「テスト」についての話をします。