🧠

[NumPy] 簡単なニューラルネットワークをゼロから実装する

2022/08/26に公開約28,700字2件のコメント

次回: https://zenn.dev/mory22k/articles/1a4fc1209a7287

シンプルな順伝播型ニューラルネットワークをNumPyで実装し、簡単な関数を回帰してみます。ただし、この記事では

  • 隠れ層は全結合 + ReLUだけ
  • 誤差は誤差逆伝播法で計算する
  • 学習は最急降下法だけ
  • ミニバッチ学習は実装しない

に絞って実装していきます。

import numpy as np
from matplotlib import pyplot as plt
from pprint import pprint

基本構成

以下のようにニューラルネットワークを構成します。

入力層 / input layer

元データXを扱いやすい形で入力します。この記事では、単に入力された値としておきます。

中間層 / hidden layer

入力層から入力されたデータに、ひと通りの処理を終えて得られるデータの集合です。

\textrm{input layer} \xrightarrow[\textrm{Affine}]{} \xrightarrow[\textrm{ReLU}]{} \textrm{hidden layer} \rightarrow \cdots

中間層を2つ以上重ねるような場合、

\cdots \rightarrow \textrm{hidden layer} \xrightarrow[\textrm{Affine}]{} \xrightarrow[\textrm{ReLU}]{} \textrm{hidden layer} \rightarrow \cdots

となるように重ねます。

出力層 / output layer

最後の中間層の出力を、整った形に変形して最終的な出力にします。シンプルな分類問題ではソフトマックス関数、回帰問題では恒等関数を使うことが多いです。この記事では恒等関数を利用します。

\cdots \rightarrow \textrm{hidden layer} \xrightarrow[\textrm{Affine}]{} \xrightarrow[\textrm{Identity}]{\textrm{loss function}} \textrm{output layer}

出力層で用いる関数と損失関数には、相性の良い組み合わせがいくつか知られています。ソフトマックス関数は交差エントロピー誤差と相性がよく、恒等関数は二乗和誤差や平均二乗誤差などと相性がよいとされています。この記事では恒等関数と相性の良い二乗和誤差を使用します。

順伝播

全結合層

理屈

全結合層は、次のような式で表される変換を行った結果が並んだ層です。

Y = X \cdot W + B

ここで、Xd_1次元の入力値がn個並んだ(n,d_1)形配列、Wは線形変換を担う(d_1, d_2)形配列、Bはバイアスを担う(d_2)形配列、そしてYd_2次元の計算結果がn個並んだ(n,d_2)形配列です。

\begin{align*} &X: && (n, d_1) \\ &W: && (d_1, d_2) \\ &B: && (d_2) \\ &Y: && (n, d_2) \end{align*}

計算時には、Xの中に並んだn個の(d_1)形配列がWによって(d_2)形配列に変形され、それにBが加算されます。

\begin{align*} &X: && (n, d_1) \\ &X \cdot W: && (n, d_2) \\ &X \cdot W + B: && (n, d_2) \end{align*}

X\cdot W(n,d_2)形配列、B(d_2)形配列ですが、加算する際に

B \leftarrow \underbrace{\begin{bmatrix}B&B&\cdots&B\end{bmatrix}}_{n\textrm{ times of }B}

という変形 (broadcast) が行われることによって、B(n,d_2)形配列に変形されます。

実装

class Affine:
    def __init__(self, input_size, output_size, init_std = 1):
        self.params = {
            'w': init_std * np.random.randn(input_size, output_size),
            'b': init_std * np.random.randn(output_size)
        }

    def forward(self, x):
        y = np.dot(x, self.params['w']) + self.params['b']
        return y

のちの扱いやすさのために、パラメータWBは辞書self.params内に格納します。

全結合層作成時には、__init__()メソッドによって、(d_1, d_2)形配列W(d_2)形配列Bがランダムに初期化されます。順伝播を計算する際には、forward()メソッドにより、全結合層による計算式に従って、出力Yが計算されます。

実際に計算してみましょう。

fc = Affine(input_size=2, output_size=5, init_std=1)
print(fc.forward([[1, 2]]))
[[ 1.52869208  0.34356321  1.23013326 -0.39181567  2.19090655]]

(1,2)形配列を入力した結果、(1,5)形配列が出力されたことが確認できます。

この時のパラメータは次のように取得できます。

pprint(fc.params)
{'b': array([ 0.37467003,  0.83047156, -2.28907322,  0.03322005,  1.20678626]),
 'w': array([[-0.66348505, -0.42013928,  1.65945094, -1.35021706,  1.08696611],
       [ 0.90875356, -0.03338454,  0.92987777,  0.46259067, -0.05142291]])}

ReLU関数

続いて、活性化関数を実装します。活性化関数についての詳しい説明はググってください。この記事では実装が簡単なReLUを実装します。

理屈

ReLUは次式で表される活性化関数です。

f_\textrm{ReLU}(x) = \max(x, 0)

グラフは次の通り。すごくシンプルです。

x = np.linspace(-1, 1, 1000)
y = np.maximum(x, 0)
plt.plot(x, y)
plt.show()

output

実装

class ReLU:
    def __init__(self):
        self.params = {}
        self.activated = None
    
    def forward(self, x):
        self.activated = (x > 0)
        y = x * self.activated
        return y

self.activatedに、入力値xの各要素が正であるかどうかをブール変数として記録します。そして、入力値とself.activatedの要素ごとの積を計算し、出力します。

全結合層の計算結果をこれに入力して計算してみましょう。

fc = Affine(2, 5)
relu = ReLU()

x = np.array([1, 2])

x = fc.forward(x)
print(x)
x = relu.forward(x)
print(x)
[-0.76683711 -1.13573292  0.59538767 -0.70590993 -0.14776884]
[-0.         -0.          0.59538767 -0.         -0.        ]

入力の負の値が切り捨てられ、0になっていることがわかります。

恒等関数

最後に出力層を設計します。出力層では、最終的な計算結果を整えて出力に渡します。回帰問題であれば恒等関数、0-1の2値回帰問題であればシグモイド関数、分類問題であればソフトマックス関数などが用いられるようです。

今回作成するニューラルネットワークでは特に計算結果を整えることはしないので、恒等関数を設計しておきます。

実装

class Identity:
    def __init__(self):
        self.params = {}

    def forward(self, x):
        return x

見て分かる通りですが、何もしません。

idt = Identity()
idt.forward(0)
0

ニューラルネットワークとして構成

中間層1つ

やることは簡単で、まず次のようにレイヤを定義していきます。

layers = {
    'fc1': Affine(1, 16),
    'fc2': Affine(16, 1),
    'out': Identity()
}

上記の実装はPython 3.7以降で有効です。3.6以前では辞書は原則として順序を記録しないことから、次のように書く必要があります:

from collections import OrderedDict
layers = OrderedDict([
    ('fc1', Affine(1, 16)),
    ('fc2', Affine(16, 1)),
    ('out', Identity())
])

そして、入力値を順次各レイヤに通していきます。

x = np.array([[1]]) # shape (1,1)
x = layers['fc1'].forward(x)
x = layers['fc2'].forward(x)
x = layers['out'].forward(x)
print(x)
[[3.90350012]]

次のように書くこともできます。

x = np.array([[1]]) # shape (1,1)
for layer in list(layers.values()):
    x = layer.forward(x)
print(x)
[[3.90350012]]

なお、list(layers.values())の箇所をlayers.values()にしても動くには動きますが、layers.values()のデータ型はdict_valuesであり、若干挙動が異なります。具体的には、環境によってreversed()の引数に使えなくなります。なのできちんとlist型に変換しておくことをおすすめします。

入力値の形状に注意

入力値は2階の配列であるとし、形状は(n, d_1)であるとします。d_1は最初の全結合層で指定したinput_sizeと一致していなければなりません。今はinput_size=1を指定しているので、次のようにd_1 \neq 1であるような場合にはエラーが生じます。

x = np.array([[1,1,1,1]]) # shape (1,4)
for layer in list(layers.values()):
    x = layer.forward(x)
print(x)
shapes (1,4) and (1,16) not aligned: 4 (dim 1) != 1 (dim 0)

複数個のデータを同時に流し込みたい場合には、次のように形状を整えてやる必要があります。

x = np.array([[1],[1],[1],[1]]) # shape (4,1)
for layer in list(layers.values()):
    x = layer.forward(x)
print(x)
[[3.90350012]
 [3.90350012]
 [3.90350012]
 [3.90350012]]

グラフに描く

計算結果をグラフに描いてみます。次のようにnp.linspace()で作成したデータを整えて入力します。

x = np.linspace(-1, 1, 1000) # shape (1000,)
x = x.reshape(x.size, 1)     # shape (1000, 1)
y = x
for layer in list(layers.values()):
    y = layer.forward(y)
plt.plot(x, y)
plt.show()

output

中間層1つ + 活性化関数ReLU

中間層の活性化関数としてReLUを使用します。

layers = {
    'fc1': Affine(1, 16),
    'relu': ReLU(),
    'fc2': Affine(16, 1),
    'out': Identity()
}

先ほどと同様にグラフを描いてみます。

x = np.linspace(-1, 1, 1000) # shape (1000,)
x = x.reshape(x.size, 1)     # shape (1000, 1)
y = x
for layer in list(layers.values()):
    y = layer.forward(y)
plt.plot(x, y)
plt.show()

output

折れ曲がったグラフが出力されました。

クラスとして実装する

以上のことをまとめます。

class simple_network:
    def __init__(self):
        self.layers = {
            'fc1': Affine(1, 16),
            'relu': ReLU(),
            'fc2': Affine(16, 1),
            'out': Identity()
        }

    def forward(self, inputs):
        x = inputs
        for layer in list(self.layers.values()):
            x = layer.forward(x)

        return x

順伝播はforward()メソッドに入力値を渡すことで実行されます。

net1 = simple_network()

x = np.linspace(-1, 1, 1000) # shape (1000,)
x = x.reshape(x.size, 1)     # shape (1000, 1)

y = net1.forward(x)

plt.plot(x, y)
plt.show()

output

二乗和誤差

ニューラルネットワークを訓練する際には、適当な入力値と、それを入力した結果として望ましい出力値のペア (教師データ) をいくつか準備し、それをもとにネットワーク全体のパラメータ (今回の場合、Affine層のparamsの中身) を調整していきます。そのためにまず、出力された値 (予測値) が真の値とどれだけずれているかを定義してやる必要があります。これを損失関数 (または誤差関数) といいます。

この記事では二乗和誤差を使用します。

理屈 (単一データ)

予測値をY、真の値をTとします。

\begin{align*} Y &= \begin{bmatrix} y_1 & y_2 & \cdots & y_d\end{bmatrix} \\ T &= \begin{bmatrix} t_1 & t_2 & \cdots & t_d\end{bmatrix} \end{align*}

二乗和誤差は次式で表されます。

L_\textrm{SSE} = \frac{1}{2}\sum_{i=1}^{d} (y_i - t_i)^2

理屈 (複数データ)

教師データが複数個のデータからなるような場合、つまりバッチ学習の場合を考えます。

\begin{align*} Y = \begin{bmatrix} Y_{1} \\ Y_{2} \\ \vdots \\ Y_{n} \end{bmatrix} &= \begin{bmatrix} \begin{bmatrix} y_{11} & y_{12} & \cdots & y_{1d} \end{bmatrix} \\ \begin{bmatrix} y_{21} & y_{22} & \cdots & y_{2d} \end{bmatrix} \\ \vdots \\ \begin{bmatrix} y_{n1} & y_{n2} & \cdots & y_{nd} \end{bmatrix} \end{bmatrix} \\ T = \begin{bmatrix} T_{1} \\ T_{2} \\ \vdots \\ T_{n} \end{bmatrix} &= \begin{bmatrix} \begin{bmatrix} t_{11} & t_{12} & \cdots & t_{1d} \end{bmatrix} \\ \begin{bmatrix} t_{21} & t_{22} & \cdots & t_{2d} \end{bmatrix} \\ \vdots \\ \begin{bmatrix} t_{n1} & t_{n2} & \cdots & t_{nd} \end{bmatrix} \end{bmatrix} \end{align*}

この場合、損失関数はそれらのデータの和として計算されます。

L_{\textrm{SSE}} = \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{d} (y_{ij} - t_{ij})^2

以上のような配列の表記の仕方はこの記事独自のものです。他の記事や論文では通用しないのでご注意ください。

実装

ここではIdentityクラスの名前をSSEに変更し、それに機能追加する形で実装します。

class SSE:
    def __init__(self):
        self.params = {}
    
    def forward(self, x):
        return x
    
    def loss(self, preds, trues):
        if preds.ndim == 1:
            preds = preds.reshape(1, preds.size)
            trues = trues.reshape(1, trues.size)
        l = 0.5 * np.sum(((preds - trues)**2))
        return l

きちんと計算できるか確認します。

sse = SSE()
print(sse.loss(np.array(1),np.array(2)))
print(sse.loss(np.array(1),np.array(3)))
print(sse.loss(np.array(1),np.array(4)))
0.5
2.0
4.5

これを用いて、先程のニューラルネットワークに損失関数を計算する関数を追加します。

class simple_network_2:
    def __init__(self):
        self.layers = {
            'fc1': Affine(1, 16),
            'relu': ReLU(),
            'fc2': Affine(16, 1),
            'out': SSE()
        }

    def forward(self, inputs):
        x = inputs
        for layer in list(self.layers.values()):
            x = layer.forward(x)

        return x
    
    def loss(self, preds, trues):
        l = self.layers['out'].loss(preds, trues)
        return l

適当に真の値を設計して予測し、損失関数の値を計算してみます。ここでは真の値を、入力値の自乗とします。

net2 = simple_network_2()

x = np.linspace(-1, 1, 50)
x = x.reshape(x.size, 1)

trues = x**2
preds = net2.forward(x)

loss = net2.loss(preds, trues)
print(loss)
104.85136633698987

学習前なので、当然ながらかなり損失が大きくなっているようです。

逆伝播

この記事では、ニューラルネットワークの訓練に用いられる最も基本的な方法である最急降下法を使用します。これを用いるには各層の各パラメータの勾配、具体的にいうとAffine層のparamsの勾配を計算する必要があります。ここでは誤差逆伝播によってそれらを計算します。

理屈

勾配計算の連鎖律

ある層における入出力が

Y = Y(X)

で表され、さらに最終的な損失関数値が

L = L(Y)

となっている場合、これに対する入力の勾配\textrm{grad}\, X = \partial L / \partial Xは、次式で表されます。

\frac{\partial L}{\partial X} = \frac{\partial Y}{\partial X} \cdot \frac{\partial L}{\partial Y}

同様に、Yがパラメータとして\thetaを持っている、すなわち

Y = Y(X; \theta)

であるとすると、そのパラメータの勾配も同様の式で計算できます。

\frac{\partial L}{\partial \theta} = \frac{\partial Y}{\partial \theta} \cdot \frac{\partial L}{\partial Y}

順伝播と逆向きの伝播により勾配が計算できる

これを見ると、\partial Y / \partial Xさえあれば、\partial L/ \partial Yの値を入力するだけでXの勾配が計算できるような関数f_\textrm{backward}が設計できることがわかります。

\frac{\partial L}{\partial X} = f_{\textrm{backward}}\!\left( \frac{\partial L}{\partial Y}\right)

詳しくは以下の記事をお読みください。

https://zenn.dev/mory22k/articles/57c45899353006

そして、ここに入力する\partial L / \partial Yの値は、後続するレイヤにおける\partial L / \partial Xの値をそのまま持ってくることができます。さらに、その後続するレイヤの\partial L / \partial Xを計算するために必要な\partial L/\partial Yの値は、その更に一つ後のレイヤの\partial L/ \partial Xの値を利用することができます。つまり…

\begin{align*} \frac{\partial L}{\partial X_1} &= f_{1}\!\left( \frac{\partial L}{\partial Y_1} \right) \\ &= f_{1}\!\left( f_{2}\!\left( \frac{\partial L}{\partial Y_2} \right) \right) \\ &= \cdots \\ &= f_{1}\!\left( \cdots f_{n}\!\left( \frac{\partial L}{\partial Y_n} \right) \cdots \right) \end{align*}

となるわけです。

さらに、パラメータ\theta_1の値は、

\begin{align*} \frac{\partial L}{\partial \theta_1} &= \frac{\partial Y_1}{\partial \theta_1} \cdot \frac{\partial L}{\partial Y_1} \\ &= \frac{\partial Y_1}{\partial \theta_1} \cdot f_{2}\!\left( \frac{\partial L}{\partial Y_2} \right) \\ &= \cdots \\ &= \frac{\partial Y_1}{\partial \theta_1} \cdot f_{2}\!\left( \cdots f_{n}\!\left( \frac{\partial L}{\partial Y_n} \right) \cdots \right) \end{align*}

により計算できます。

つまり、下図のように順伝播とは逆向きに値が流れていくことになります。

\frac{\partial L}{\partial \theta_1} \xleftarrow[\rm gradient]{} \frac{\partial L}{\partial Y_1} \xleftarrow[\rm backward]{} \frac{\partial L}{\partial Y_2} \xleftarrow[\rm backward]{} \cdots \xleftarrow[\rm backward]{} \frac{\partial L}{\partial Y_n}

このようにして勾配を計算する方法を誤差逆伝播法といいます。

全結合層

それでは、誤差逆伝播によって全結合層の各パラメータの勾配を求めてみます。

理屈

全結合層は、次のような式で表されました。

Y = X \cdot W + B
\begin{align*} &X: && (n, d_1) \\ &W: && (d_1, d_2) \\ &B: && (d_2) \\ &Y: && (n, d_2) \end{align*}

損失関数Lの出力はスカラーなので、微分すると

\begin{align*} &\frac{\partial L}{\partial X}: && (n, d_1) \\ &\frac{\partial L}{\partial W}: && (d_1, d_2) \\ &\frac{\partial L}{\partial B}: && (d_2) \\ &\frac{\partial L}{\partial Y}: && (n, d_2) \\ \end{align*}

となります。そして、細かい説明を端折って結論だけ書くと、計算式は次のようになります。

\begin{align*} \frac{\partial L}{\partial X} &= \frac{\partial L}{\partial Y} \cdot W^\mathsf{T} && (n, d_1) \\ \frac{\partial L}{\partial W} &= X^\mathsf{T} \cdot \frac{\partial L}{\partial Y} && (d_1, d_2) \\ \frac{\partial L}{\partial B} &= \sum_{i} \frac{\partial L}{\partial Y_i} && (d_2) \end{align*}

ただし、W^\mathsf{T}は、配列Wの形状の番号の最後と最後から2番めを入れ替えたものです。W(d_1, d_2)形配列であるならば、W^\mathsf{T}(d_2, d_1)形配列になります。同様に、X(n, d_1)形配列であるならば、X^\mathsf{T}(d_1, n)形配列になります。

\begin{align*} &W: && (d_1, d_2) \\ &W^\mathsf{T}: && (d_2, d_1) \\ &X: && (n, d_1) \\ &X^\mathsf{T}: && (d_1, n) \end{align*}

実装

\begin{align*} \frac{\partial L}{\partial X} &= \frac{\partial L}{\partial Y} \cdot W^\mathsf{T} && (n, d_1) \\ \frac{\partial L}{\partial W} &= X^\mathsf{T} \cdot \frac{\partial L}{\partial Y} && (d_1, d_2) \\ \frac{\partial L}{\partial B} &= \sum_{i} \frac{\partial L}{\partial Y_i} && (d_2) \end{align*}

を参考に勾配計算の機能を追加します。

class Affine:
    def __init__(self, input_size, output_size, init_std = 1):
        self.params = {
            'w': init_std * np.random.randn(input_size, output_size),
            'b': init_std * np.random.randn(output_size)
        }
        self.grads = {
            'w': None,
            'b': None
        }
        self.x = None

    def forward(self, x):
        self.x = x
        y = np.dot(x, self.params['w']) + self.params['b']
        return y

    def backward(self, dy):
        dx = np.dot(dy, self.params['w'].T)
        self.grads['w'] = np.dot(self.x.T, dy)
        self.grads['b'] = np.sum(dy, axis=0)
        return dx

backward()メソッドに\partial L / \partial Yを入力することで、各paramsの勾配が各gradsに格納されます。返り値は\partial L/\partial Xです。ここで、\partial L/ \partial Wの計算にXが必要であることから、forward()呼び出し時の引数をself.xに保存しています。

試しにdyとして要素がすべて1であるような配列を指定して、どのように動くのか確認してみます。

fc = Affine(1, 10)

x = np.array([[1],[2]])
y = fc.forward(x)

dy = np.ones_like(y)
dx = fc.backward(dy)

print('PARAMS:')
pprint(fc.params)
print('GRADS:')
pprint(fc.grads)
PARAMS:
{'b': array([ 0.15708638,  0.91049252,  1.78981455,  1.74976057,  0.89236687,
        1.61879684,  0.90720362,  1.37321918, -1.1448675 , -0.32245234]),
 'w': array([[-0.09537671,  1.17681743,  0.15849979,  1.65784163,  0.43699291,
        -0.9619521 , -0.12237613,  1.48546446,  0.02150892,  0.92276433]])}
GRADS:
{'b': array([2., 2., 2., 2., 2., 2., 2., 2., 2., 2.]),
 'w': array([[3., 3., 3., 3., 3., 3., 3., 3., 3., 3.]])}

ReLU関数

理屈

ReLUは次のように表せました。

f_\textrm{ReLU}(x) = \max(x, 0)

これは厳密にはx=0において微分不可能ですが、ここでは厳密さに目を瞑り、x=0においては\partial f/\partial x = 0として話を進めます。y = f_\textrm{ReLU}(x)とすると、

\frac{\partial y}{\partial x} = \begin{cases} 0 & (x > 0)\\ 1 & (x \le 0) \end{cases}

であり、したがって

\frac{\partial L}{\partial x} = \frac{\partial y}{\partial x} \frac{\partial L}{\partial y} = \begin{cases} \dfrac{\partial L}{\partial y} & (x > 0)\\ 0 & (x \le 0) \end{cases}

です。

実装

class ReLU:
    def __init__(self):
        self.params = {}
        self.grads = {}
        self.activated = None
    
    def forward(self, x):
        self.activated = (x > 0)
        out = x * self.activated
        return out
    
    def backward(self, dy):
        dx = dy * self.activated
        return dx

順伝播時に保存されたself.activateddyに掛けて返すだけです。

動作確認もしておきます。

fc = Affine(1, 10)
relu = ReLU()

x = np.array([[1],[2]])
y = x
y = fc.forward(y)
y = relu.forward(y)

dy = np.ones_like(y)
dx = dy
dx = relu.backward(dx)
dx = fc.backward(dx)

print('Y:')
pprint(y)
print('GRADS:')
pprint(fc.grads)
Y:
array([[ 0.72166321, -0.        ,  0.71714374, -0.        , -0.        ,
         0.31407927, -0.        ,  2.28652852,  0.56210083, -0.        ],
       [ 0.94318869, -0.        ,  0.41230738, -0.        , -0.        ,
         0.47419466, -0.        ,  4.2611444 , -0.        , -0.        ]])
GRADS:
{'b': array([2., 0., 2., 0., 0., 2., 0., 2., 1., 0.]),
 'w': array([[3., 0., 3., 0., 0., 3., 0., 3., 1., 0.]])}

二乗和誤差と恒等関数

理屈

恒等関数の層は

y_{ij} = x_{ij}

で表され、二乗和誤差は

L_{\textrm{SSE}} = \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{d} (y_{ij} - t_{ij})^2

で表されます。以上2つをひとまとめにすると、

L = \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{d} (x_{ij} - t_{ij})^2

となり、二乗和誤差のx_{ij}での偏微分は

\frac{\partial L}{\partial x_{ij}} = x_{ij} - t_{ij}

となります。Xでの偏微分はこれを並べたものなので、

\frac{\partial L}{\partial X} = X - T

です。

このように、出力層として恒等関数をセットし、損失関数として二乗和誤差を指定すると、出力層の勾配がシンプルに書けます。

実装

class SSE:
    def __init__(self):
        self.params = {}
        self.grads = {}
        self.preds = None
    
    def forward(self, x):
        self.preds = x
        return x

    def loss(self, preds, trues):
        l = 0.5 * np.sum(((preds - trues)**2))
        return l

    def backward(self, trues):
        dx = (self.preds - trues)
        return dx

ニューラルネットワークとして実装

以上を用いて誤差逆伝播を実装したものが以下のクラスです。

class simple_network_3:
    def __init__(self):
        self.layers = {
            'fc1': Affine(1, 16),
            'relu': ReLU(),
            'fc2': Affine(16, 1),
            'out': SSE()
        }

    def forward(self, inputs):
        x = inputs
        for layer in list(self.layers.values()):
            x = layer.forward(x)

        return x

    def loss(self, preds, trues):
        l = self.layers['out'].loss(preds, trues)
        return l

    def backward(self, trues):
        dy = trues
        for layer in reversed(list(self.layers.values())):
            dy = layer.backward(dy)

        return dy

forward()に入力値を入力して予測した後、backward()に真の出力値を入力することで損失関数の勾配が順次計算されます。

実際にやってみましょう。

net3 = simple_network_3()

x = np.linspace(-1, 1, 10)
x = x.reshape(x.size, 1)

trues = x**2
preds = net3.forward(x)
dx = net3.backward(trues)

pprint(net3.layers['fc1'].params)
print()
pprint(net3.layers['fc1'].grads)
{'b': array([-0.86515336,  1.32826643,  0.08597852, -1.61984865, -0.88640432,
        0.51478096, -0.48947617,  0.64805897, -0.10718695, -0.42283912,
       -1.60815725,  0.07539801, -0.56274617,  1.26252178,  1.52583772,
       -0.64239791]),
 'w': array([[ 2.51403523,  0.15074045,  0.55771344, -0.42027637,  0.55087978,
        -1.3985749 ,  0.59393456,  1.0399442 ,  1.68749873, -1.02953784,
        -0.57742383, -0.839773  , -0.36755515, -1.86183422, -0.91753511,
        -3.11126016]])}

{'b': array([ -0.05791729,  10.2369705 , -11.31998112,   0.        ,
         0.        ,  39.54594237,   0.21131447,   6.04770536,
         1.85340959,  23.75120653,   0.        ,  -1.14015288,
         0.        ,   3.91963939,  19.78882767,  -4.83558031]),
 'w': array([[ -0.02906721,  -4.7466692 ,  -1.22351427,   0.        ,
          0.        , -19.76312846,   0.21131447,  -1.05925001,
          0.47911337, -18.93938485,   0.        ,   0.7043346 ,
          0.        ,  -1.82047517,  -9.17566567,   3.4074029 ]])}

ここで値が0となっているのは、その層の出力が負の値となったため、ReLU層で勾配計算が打ち切られている箇所です。

最急降下法

理屈

いよいよネットワークを学習します。ある教師データ群が与えられたとき、そのデータに合わせてニューラルネットワークを学習するという計算は、損失関数Lを最小化する問題として取り扱うことができます。

\begin{align*} &\underset{\theta}{\textrm{minimize}} && L \end{align*}

ここではLを最小化する手法として 最急降下法 (勾配降下法) を用います。最急降下法では、あるパラメータ\thetaを最適化するために、勾配\partial L / \partial \thetaを計算したうえで、

\theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta}

とすることを繰り返します。ここで\etaは学習率 (learning rate) とよばれるハイパーパラメータです。これを用いてニューラルネットワークを学習するには、次の手順に従います。

1. initialize params
2. for epochs do
3.     y <- NN.forward(x)
4.     grads <- NN.backward(x)
5.     params <- params - learning_rate * grads

注意すべき点は、

  1. 調子に乗って学習率\etaを大きくしすぎると解が悪化する
  2. 少しだけ移動したときに損失関数の値が最も小さくなるような向きに移動することを繰り返すだけなので、容易に局所的最適解にハマる

という点です。

実験

最急降下法による学習を行ってみます。

net3 = simple_network_3()

x_train = np.linspace(-1, 1, 30)
x_train = x_train.reshape(x_train.size, 1)
y_train = x_train**2

learning_rate = 1e-3
epochs = 1000
loss_hist = []

preds = net3.forward(x_train)
for epoch in range(epochs):
    _ = net3.backward(y_train)

    for layer in list(net3.layers.values()):
        for key in list(layer.params.keys()):
            layer.params[key] -= learning_rate * layer.grads[key]

    preds = net3.forward(x_train)
    loss = net3.loss(preds, y_train)

    loss_hist.append(loss)
    print('='*25)
    print(f'epoch: {epoch}')
    print(f'loss: {loss}')
=========================
epoch: 0
loss: 20.42169669910677
=========================
...
=========================
epoch: 999
loss: 0.007042085353135882

初期には10のオーダーだった損失が、最終的には0.01のオーダーまで小さくなったようです。学習はできているようです。ただし、うまく学習できるかどうかはパラメータの初期値に大きく依存しており、場合によっては1のオーダー程度までしか学習が進まない場合もあります。

学習結果を可視化します。

x = np.linspace(-2, 2, 100)
x = x.reshape(x.size, 1)
y = net3.forward(x)
plt.plot(x, y, color='#cc3366')
plt.scatter(x_train, y_train, color='#000000', marker='x', s=20)
plt.show()

output

実装

最適化を行う箇所と、それ以外の計算を行う箇所を分けて実装します。まず、勾配降下計算を行う関数を定義します。

def gradient_descend(model, x_train, y_train, learning_rate):
    _ = model.forward(x_train)
    _ = model.backward(y_train)
    for layer in list(model.layers.values()):
        for key in list(layer.params.keys()):
            layer.params[key] -= learning_rate * layer.grads[key]

続いて、繰り返し処理を行う関数を新たにfit()メソッドとして実装します。

class simple_network_4:
    def __init__(self):
        self.layers = {
            'fc1': Affine(1, 16),
            'relu': ReLU(),
            'fc2': Affine(16, 1),
            'out': SSE()
        }
        self.loss_hist = []

    def forward(self, inputs):
        x = inputs
        for layer in list(self.layers.values()):
            x = layer.forward(x)

        return x

    def loss(self, preds, trues):
        l = self.layers['out'].loss(preds, trues)
        return l

    def backward(self, trues):
        dy = trues
        for layer in list(reversed(self.layers.values())):
            dy = layer.backward(dy)

        return dy
    
    def fit(self, x_train, y_train, epochs, method=gradient_descend, learning_rate=1e-3):
        self.loss_hist = []

        for epoch in range(epochs):
            method(self, x_train, y_train, learning_rate)
            loss = self.loss(
                preds = self.forward(x_train),
                trues = y_train
            )
            self.loss_hist.append(loss)
            print('='*25)
            print(f'epoch: {epoch}')
            print(f'loss: {loss}')

次のように使います。

net4 = simple_network_4()

x_train = np.linspace(-1, 1, 30)
x_train = x_train.reshape(x_train.size, 1)
y_train = x_train**2

net4.fit(x_train, y_train, 1000, gradient_descend, 1e-3)

x = np.linspace(-2, 2, 100)
x = x.reshape(x.size, 1)
y = net4.forward(x)

plt.plot(x, y, color='#cc3366')
plt.scatter(x_train, y_train, color='#000000', marker='x', s=20)
plt.show()
=========================
epoch: 0
loss: 220.94783718121778
=========================
...
=========================
epoch: 999
loss: 0.020992418938615448

output

損失関数値はself.loss_histにリスト型で格納されます。次のように表示できます。

plt.plot(net4.loss_hist, label='loss')
plt.xlabel('epoch')
plt.ylabel('value')
plt.ylim(0, 1.1)
plt.legend()
plt.show()

output

おまけ: もうちょっと使い勝手を良くする

全結合層と出力層の書き直し

ここまでで作成したモデルは、入力値として(n, d_1)形配列を想定しており、1階の配列は想定していません。そのため、1階の配列を入力して勾配計算を行うと、次のようにバグります。

model = simple_network_4()

x = np.array([1])
t = np.array([2])
y = model.forward(x)
_ = model.backward(t)
ValueError: shapes (16,) and (1,) not aligned: 16 (dim 0) != 1 (dim 0)

これに対処するため、全結合層を次のように書き直します。

class Affine:
    def __init__(self, input_size, output_size, init_std = 1):
        self.params = {
            'w': init_std * np.random.randn(input_size, output_size),
            'b': init_std * np.random.randn(output_size)
        }
        self.grads = {
            'w': None,
            'b': None
        }
        self.x = None

    def forward(self, x):
        if x.ndim==1:
            self.x = x.reshape(1, x.size)
        else:
            self.x = x
        y = np.dot(self.x, self.params['w']) + self.params['b']
        return y

    def backward(self, dy):
        dx = np.dot(dy, self.params['w'].T)
        self.grads['w'] = np.dot(self.x.T, dy)
        self.grads['b'] = np.sum(dy, axis=0)
        return dx

レイヤを引数として指定する

作成したニューラルネットワークのレイヤーは次のとおりです。

'fc1': Affine(1, 16),
'relu': ReLU(),
'fc2': Affine(16, 1),
'out': SSE()

これを変更したい場合、simple_network全体を定義し直す必要があります。いちいち全体を書き直すのは面倒なので、モデル作成時に引数で指定できるようにします。

class NeuralNetwork:
    def __init__(self, layers):
        self.layers = layers
        self.loss_hist = []

    def forward(self, inputs):
        x = inputs
        for layer in list(self.layers.values()):
            x = layer.forward(x)
        return x

    def loss(self, preds, trues):
        l = self.layers['out'].loss(preds, trues)
        return l

    def backward(self, trues):
        dy = trues
        for layer in reversed(list(self.layers.values())):
            dy = layer.backward(dy)
        return dy

    def fit(self, x_train, y_train, epochs, method=gradient_descend, learning_rate=1e-3):
        self.loss_hist = []

        for epoch in range(epochs):
            method(self, x_train, y_train, learning_rate)
            loss = self.loss(
                preds = self.forward(x_train),
                trues = y_train
            )
            self.loss_hist.append(loss)
            print('='*25)
            print(f'epoch: {epoch}')
            print(f'loss: {loss}')
model = NeuralNetwork(
    layers = {
        'fc1': Affine(1, 64),
        'relu': ReLU(),
        'fc2': Affine(64, 1),
        'out': SSE()
    })

x_train = np.linspace(-1, 3, 32).reshape(32, 1)
y_train = np.sin(x_train)

model.fit(x_train, y_train, 2000, gradient_descend, 1e-4)

x = np.linspace(-2, 4, 100)
x = x.reshape(x.size, 1)
y = model.forward(x)

plt.plot(x, y, color='#cc3366')
plt.scatter(x_train, y_train, color='#000000', marker='x', s=20)
plt.show()

plt.plot(model.loss_hist, label='loss')
plt.xlabel('epoch')
plt.ylabel('value')
plt.ylim(0, 10.1)
plt.show()
=========================
epoch: 0
loss: 100.6243967636242
=========================
...
=========================
epoch: 1999
loss: 0.08417009002528567

output

output

ここまで来るとモデル的にも学習方法的にも限界が見えてきますが、それでもある程度学習できていることはわかります。

本格的なNNの構築のために

以上で簡易的なニューラルネットワークの土台が見えたと思います。あとはこれに、この十数年間に考えられてきた数多くの工夫を実装していくことで、本格的な特徴抽出機へ拡張していくことができるはずです。

  1. Adamオプティマイザの実装
  2. ドロップアウトの実装
  3. 学習スケジューリングの実装
  4. 入力の正規化、畳み込み層、プーリング層、ソフトマックス関数、および交差エントロピー誤差を実装し、CNNを構成
  5. 隠れ状態とループの実装により、RNNを構成
  6. ベイズ最適化を活用したハイパーパラメータチューニング
  7. データ拡張による汎化性能の向上
  8. 敵対的生成ネットワークへの応用

また、Pythonだけで作っていくのは速度的にやがて限界が訪れるので、

  1. より低級な言語で作成してパッケージ化
  2. Juliaへの移植

なども視野に入れると良いでしょう。

参考文献

  1. 斎藤 康毅, “ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装,” オライリージャパン, 2016.
  2. 麻生 英樹 et al., “深層学習,” 人工知能学会, 2015.

Discussion

2022/8/29 追加

dict型データの順序が保存されるのはPython 3.7以降で、3.6以前は順序通りに動くことが保証されないことを明記しました。

2022/9/9 追加

二乗和誤差の添字がミスってたので直しました。

ログインするとコメントできます