第4回 ディープラーニングへの入り口: MNIST

  1. ディープラーニングとは
  2. MNIST (手書き文字認識) とは
  3. NumPy入門
  4. NumPyを使ったニューラルネットワークの実装
  5. MNIST を実装する
  6. まとめ

1. ディープラーニングとは

ディープ ニューラル ネットワーク (deep neural network) とは、 レイヤーを非常に多く (深く) 重ねたニューラルネットワークのことである。 ディープ ラーニング (深層学習、deep learning) も ほぼ同じ意味で使われている。

ディープラーニングは、もともと画像処理の分野で発展してきたものである。 現在では音声やテキスト処理にも応用されているが、画像処理での利用が多い。 ディープラーニングと従来の機械学習方式との違いは、 従来の機械学習の多くがデータに関するなんらかの前処理 (輪郭抽出など) が 必要だったのに対して、ディープラーニングはその前処理工程もふくめて 学習できるため「生のデータをそのまま入力できる」点であるとされる。

生のデータ 出力 前処理 従来の 機械学習 ディープ ラーニング
従来の機械学習とディープラーニングの違い

今回はディープラーニングの基礎を学ぶために、 代表的な 2つの認識タスクである MNIST と CIFAR-10 を 実装してみよう。その過程で、認識精度を上げ、 より実用的なニューラルネットワークを設計するための いくつかのテクニックについて説明する。

2. MNIST (手書き文字認識) とは

MNISTデータベース は 手書き文字認識のためのデータセットで、 ディープラーニングの入門書で必ずといってよいほど扱われている。 MNIST を使った手書き文字認識は、様々な機械学習アルゴリズムの 性能を試験するときの指標のひとつとなっている。

ここでは入力と出力は以下のように定義される:

ニューラルネットワークでは、10種類のラベルを区別するために 10個の値の出力を使うことにする。これは 「特定の値だけが 1 で、それ以外は 0 である」 ようなベクトルである。 このようなベクトルは、one-hot ベクトル などと呼ばれる:

このタスクは前回実装したニューラルネットワークを使って、 簡単に実行することができる:

  1. 入力が 784個で、出力が 10個の値をもつニューラルネットワークを作る。
  2. 訓練データの画像とラベルを使って学習する:

さて、2次元の画像を 1次元のリストに変換してしまっているが、 これでは上下左右の関係が学習できないのでは? と思うかもしれない。 しかし、たとえば以下のような対応関係があったとして:

0 1 2 ... 27 (0,0) (0,1) (0,2) ... (0,27) 0 1 ... 27 28 29 ... 55 756 757 ... 783
画像と各要素との対応

ここで左右のピクセル関係は、たとえば 「0番目の要素と 1番目の要素の関係」であり、 上下のピクセル関係は「0番目の要素と 28番目の要素の関係」などに相当する。 ニューラルネットワークのノードは全接続されているので、 位置に関係なく「すべてのピクセル間の関係」を学習しようとする。 したがって、画像サイズがつねに一定で、各ピクセルがつねに 同じ要素に対応していれば、この方法でも十分に上下左右のピクセル間の関係を 学習できることになる。 このように、どんなデータでもベクトルに変換して学習できることは ニューラルネットワークの大きな特長である。

3. NumPy入門

前回のニューラルネットワークの実装では、 数値の表現に Python のリストを使っていた。 これをそのまま使うこともできるが、効率が悪いため MNIST の認識をおこなう前に NumPy と呼ばれる Python ライブラリを使って、コードをもう少し高速化する。 NumPy を使うと Python 上で固定長の数値配列を簡単かつ高速に演算することができる。

3.1. NumPy を使う準備

まず numpy モジュールは素の Python には含まれていないので、 インストールする必要がある (Anaconda あるいは Google Colab を使っている場合は不要):

C:\> pip install numpy

Python 中でモジュールを使うときは、 以下のように import する (なぜか numpy を np という名前にするのが慣例):

import numpy as np

3.2. ndarray型とは

NumPy が提供する機能は、基本的には ndarray型だけである。 これは通常の Python リストと似ているが、以下の点が異なっている:

ndarray配列を作成するには、以下の方法のどれかを使う:

>>> np.array([1,2,3,4])           # 4要素のPythonリストからndarrayを作成
>>> np.array([[1,2,3], [4,5,6]])  # 2×3要素のPythonリストからndarrayを作成
>>> np.zeros(4)                   # 4要素すべてゼロ
>>> np.zeros((2, 3))              # 2列3行すべてゼロ
>>> np.random.random(4)           # 4要素の乱数 (0〜1の範囲)
>>> np.random.random((2, 3))      # 2列3行の乱数 (0〜1の範囲)
演習4-1. ndarray配列を作成する
  1. Pythonで10×10要素のリスト(のリスト) を作り、 これを ndarray配列に変換せよ。
  2. np.random.random((3, 3)) の値を表示せよ。

3.3. ndarray型における演算

ndarray型は Python のリストに比べ、 演算がより簡単に行えるよう拡張されている。 ここでは代表的な例をいくつか説明する。

まず単一の値と ndarray配列を演算すると、 その値は配列中の各要素に分配 (broadcast) される:

>>> 5 + np.array([1,2,3])
array([6, 7, 8])
5 + np.array([1, 2, 3])
ひとつの値が ndarray 中のすべての要素に分配される。

これは演算子の左右が逆転しても同じである:

>>> np.array([1,2,3]) * 5
array([ 5, 10, 15])
np.array([1, 2, 3]) * 5
右側の値が ndarray のすべての要素に分配される。

さて、通常の Python リストとは異なり、ndarray配列どうしは + 演算子では連結できない+ 演算子は要素ごとの計算 (element-wise) として解釈される。

>>> np.array([1,2,3]) + np.array([4,5,6])
array([5, 7, 9])
np.array([1, 2, 3]) + np.array([4, 5, 6])
各要素ごとに値が計算される。

他の *, / などの演算子でも同様である:

>>> np.array([1,2,3]) * np.array([4,5,6])
array([4, 10, 18])

ただし、このとき 2つの ndarray配列は 同じ要素数でなければならない:

>>> np.array([1,2,3]) + np.array([4,5,6,7,8])
ValueError: 長さが異なるものは演算不可

さらに注意が必要なのは、多次元配列 (2次元以上) の場合である。 NumPy では 多次元配列は「配列の配列 (ndarrayndarray)」として 表現されているが、多次元配列の要素にも同様のルールが再帰的に適用されるのである:

>>> 5 + np.array([[1,2,3], [4,5,6]])
array([[ 6,  7,  8],
       [ 9, 10, 11]])
5 + np.array([[1, 2, 3], [4, 5, 6]]) 5 [1, 2, 3] 5 [4, 5, 6]
まず 5 が np.array の 2要素に分配され (broadcast)、 さらに内側の 3要素にも分配される (broadcast)。

Element-wisebroadcast が両方適用される場合もある。 以下の例はやや変則的で、1要素の配列がひとつの値と同様に扱われている:

>>> np.array([[-1],[1]]) * np.array([[1,2,3], [4,5,6]])
array([[-1, -2, -3],
       [ 4,  5,  6]])
np.array([[-1], [1]) * np.array([[1, 2, 3], [4, 5, 6]]) [-1] [1, 2, 3] [1] [4, 5, 6]
2つの np.array が各要素ごとに対応し (element-wise)、 つぎに各配列どうしの演算が行われる (broadcast)。

配列の次数が異なる場合 (1次元 + 2次元) でも broadcast が適用される:

>>> np.array([-1,0,1]) + np.array([[1,2,3], [4,5,6]])
array([[0, 2, 4],
       [3, 5, 7]])
np.array([-1,0,1])+np.array([[1, 2, 3], [4, 5, 6]]) [-1,0,1] [1, 2, 3] [-1,0,1] [4, 5, 6]
まず左側の np.array が 2要素に分配され (broadcast)、 つぎに各配列どうしの演算が行われる (element-wise)。

(なお、NumPy の用語では「次元 (=ひとつの要素を特定するのに必要な添字の数)」 のことを axis と呼んでいる。)

以下の例は少しわかりにくいかもしれない。 broadcast の結果、全体の要素数が増えることもある:

>>> np.array([[-1],[1]]) * np.array([1,2,3])
array([[-1, -2, -3],
       [ 1,  2,  3]])
np.array([[-1], [1]) * np.array([1, 2, 3]) [-1] [1, 2, 3] [1] [1, 2, 3]
右側の np.array が左側の 2要素に分配され (broadcast)、 さらに各要素が配列に分配される (broadcast)。
演習4-2. ndarray型の演算

以下の ndarray配列の演算をしたときの結果を予想し、 実際に実行してみて結果を確認せよ。

>>> np.array([1,2]) * np.array([3,4])
>>> np.array([1,2]) * 4
>>> np.array([[1,2], [3,4]]) + np.array([[5,6], [7,8]])
>>> np.array([[1],[2],[3]]) * np.array([1,2,3])
>>> np.array([1,2]) * np.array([3])
>>> np.array([1,2]) * np.array([3, 4, 5])

3.4. ndarray型の参照・変更

ndarray配列の参照・変更は、基本的に Python リストと 同じように扱える。多次元配列の ndarray の場合、 a[i][j] とともに a[i,j] という表記も許されている。

>>> a = np.array([[1,2,3], [4,5,6]])
>>> a[0]        # 0行目を取得。
array([1, 2, 3])
>>> a[1][2]     # 1行2列目の値を取得。
6
>>> a[1][1:3]   # 1行1〜2列目の値を取得。
array([5, 6])
>>> a[1,2]      # 上と同じ。
6
>>> a[0,1] = 0  # 0行1列目の値を変更。
>>> a
array([[1, 0, 3],
       [4, 5, 6]])
>>> a.fill(0)   # すべての要素を 0 にする。

ndarray独自の (Python の多次元配列にはない) 機能として、 配列の部分列を 2次元で切り出す機能がある。 これは通常の多次元配列の仕組みとは異なるため、添え字に , を使った表記でないと実現できないことに注意。 この機能は、後に畳み込みニューラルネットワークを実装する際に活用する。

>>> a = np.array([[1,2,3], [4,5,6], [7,8,9]])
>>> a[0:2,1:3]   # (0〜1行×1〜2列)目を2次元で切り出す。
array([[2, 3],
       [5, 6]])
>>> a[0:2][1:3]  # これではうまくいかない (a[1:2]と同じ結果)。
array([[4, 5, 6]])
1 2 3 4 5 6 7 8 9 1:3 0:2 0 1 2 3 0 1 2 3

ndarray配列の大きさを知るためには複数の方法がある:

>>> a = np.array([[1,2,3], [4,5,6]])
>>> len(a)   # リストとして見たときの要素数 (行数)。
2
>>> a.size   # 全要素数。
6
>>> a.shape  # 配列の「形状」。
(2, 3)

さて、ndarray配列には要素を追加・削除することはできないが、 reshape() メソッドを使って「形を変える」ことはできる。 これは後でデータを効率よく処理したいときに使える:

>>> a = np.array([[1,2,3], [4,5,6]])  # もとは 2行×3列の配列。
>>> a.reshape(3,2)  # 3行×2列の配列に変換。
array([[1, 2],
       [3, 4],
       [5, 6]])
>>> a.reshape(6)    # フラットな1次元配列に変換。
array([1, 2, 3, 4, 5, 6])

3.5. ndarray型を便利に使うための関数

他にも、NumPy には ndarray配列を便利に使うための 関数がいくつも用意されている。代表的なものを以下に示す:

>>> np.sum(np.array([1, 2, 3]))                  # 1+2+3 を計算。
6
>>> np.sqrt(np.array([1, 2, 3]))                 # sqrt(1), sqrt(2), sqrt(3) を計算。
array([1., 1.41421356, 1.73205081])
>>> np.exp(np.array([1, 2, 3]))                  # exp(1), exp(2), exp(3) を計算。
array([2.71828183, 7.3890561, 20.08553692])
>>> np.dot(np.array([1,2,3]), np.array([4,5,6])  # 内積 (1*4 + 2*5 + 3*6) を計算。
32
>>> np.max(np.array([5, 9, 4, 0]))               # もっとも大きな要素。
9
>>> np.argmax(np.array([5, 9, 4, 0]))            # もっとも大きな要素の添字
1

4. NumPyを使ったニューラルネットワークの実装

では前回の Layerクラスを ndarray型を用いて書き直してみよう。

インスタンスの初期化部分では、Python のリスト内包表記を ndarray型の作成にすればよい。 ここで定義される self.w, self.dw は、 それぞれ nout行×nin列の2次元配列となり、 self.b, self.db はそれぞれ nout要素の配列となる (以前の部分は薄灰色で表示されている):

class Layer:
    def __init__(self, nin, nout):
        self.nin = nin
        self.nout = nout
        # 重み・バイアスを初期化する。
        # self.w = [ [ random()-.5 for j in range(self.nin) ] for i in range(self.nout) ]
        # self.b = [ random()-.5 for i in range(self.nout) ]
        self.w = np.random.random((self.nout, self.nin)) - .5
        self.b = np.random.random(self.nout) - .5
        # 計算用の変数を初期化する。
        self.x = self.y = None
        # self.dw = [ [ 0 for j in range(self.nin) ] for i in range(self.nout) ]
        # self.db = [ 0 for i in range(self.nout) ]
        self.dw = np.zeros((self.nout, self.nin))
        self.db = np.zeros(self.nout)
        self.loss = 0
        return

forward() メソッドは、以下のように変更する:

    def forward(self, x):
        # xは nin個の要素をもつ入力値のリスト。
        # 与えられた入力に対する各ノードの出力を計算する。
        self.x = x
        # self.y = [
        #     sigmoid(sum( w1*x1 for (w1,x1) in zip(w, x) ) + b)
        #     for (w,b) in zip(self.w, self.b)
        # ]
        self.y = sigmoid(np.dot(self.w, x) + self.b)
        # yは nout個の要素をもつ出力値のリスト
        return self.y

ここでは出力の計算を np.dot() を使って 1行でおこなっている:

nin nin nout np.dot self.w x [[w11, w12, ...], [w21, w22, ...], ... ] [x1, x2, ...]
np.dot(self.w, x)
右側の x が左側の self.w の各要素に分配され (broadcast)、それぞれの np.dot が計算される。

なお NumPy 用の sigmoid() 関数は、以下のようにする。 これは ndarray型の各要素に対して シグモイド関数を計算するような関数となっている:

def sigmoid(x):
    # return 1 / (1 + exp(-x))
    return 1 / (1 + np.exp(-x))
def d_sigmoid(y):
    return y * (1-y)

mse_loss(), backward(), update() の 各メソッドも同様に書き直す。ndarray を使うことにより 簡潔になっている部分に注目してほしい:

    def mse_loss(self, ya):
        # 与えられた正解に対する損失を求める。
        # self.loss += sum( (y1-ya1)**2 for (y1,ya1) in zip(self.y, ya) )
        self.loss += np.sum((self.y - ya)**2)
        # 損失関数の微分を計算する。
        # delta = [ 2*(y1-ya1) for (y1,ya1) in zip(self.y, ya) ]
        delta = 2*(self.y - ya)
        return delta
    def backward(self, delta):
        # self.y が計算されたときのシグモイド関数の微分を求める。
        # ds = [ d_sigmoid(y1) for y1 in self.y ]
        ds = d_sigmoid(self.y)
        # 各偏微分を計算する。
        # for i in range(self.nout):
        #     for j in range(self.nin):
        #         self.dw[i][j] += delta[i] * ds[i] * self.x[j]
        # for i in range(self.nout):
        #     self.db[i] += delta[i] * ds[i]
        self.dw += (delta * ds).reshape(self.nout, 1) * self.x
        self.db += delta * ds
        # 各入力値の微分を求める。
        # dx = [
        #     sum( delta[j]*ds[j]*self.w[j][i] for j in range(self.nout) )
        #     for i in range(self.nin)
        # ]
        dx = np.dot(delta * ds, self.w)
        return dx
    def update(self, alpha):
        # 現在の勾配をもとに、損失が減る方向へ重み・バイアスを変化させる。
        # for i in range(self.nout):
        #     for j in range(self.nin):
        #         self.w[i][j] -= alpha * self.dw[i][j]
        # for i in range(self.nout):
        #     self.b[i] -= alpha * self.db[i]
        self.w -= alpha * self.dw
        self.b -= alpha * self.db
        # 計算用の変数をクリアしておく。
        # for i in range(self.nout):
        #     for j in range(self.nin):
        #         self.dw[i][j] = 0
        # for i in range(self.nout):
        #     self.db[i] = 0
        self.dw.fill(0)
        self.db.fill(0)
        self.loss = 0
        return
演習4-3. NumPyを使ったニューラルネットワークを実行する

上の NumPy を使ったニューラルネットワークの実装を使って、 前回のピタゴラスの定理を学習する例題を実行せよ。

# 100個分のランダムな訓練データを作成する。
# つねに乱数値を一定にする。
np.random.seed(0)
data = []
for i in range(100):
    x = np.random.random(3)   # 入力
    ya = np.sqrt((x**2) / 3)  # 正解
    data.append((x, ya))

layer1 = Layer(3, 3)
layer2 = Layer(3, 1)
# ... 以下は同じ ...

実際に実行してみると NumPy を使ったバージョンのほうが 若干遅いことに気づくが、これは ndarrayの処理にかかる オーバーヘッドがあるためで、扱っている要素数が少ない場合はこのようになる。 配列の要素が増えると NumPy のほうが速くなる。

5. MNIST を実装する

では NumPy で実装したニューラルネットワークを使って、 実際に MNIST の認識タスクをやってみよう。 訓練データおよびテストデータは MNISTページからダウンロードする。 4つの .gz形式のファイルを保存すればよい。 (注意: macOS などでは、.gz形式のファイルが勝手に展開されてしまうことがあるので、 リンクを Control + クリック して リンク先のファイルをダウンロード を選ぶ。)

今回作成するのは次のような 3層の (Layer を 2つ使った) ニューラルネットワークである:

layer1 = Layer(784, 100)  # 784個の入力、100個の出力。
layer2 = Layer(100, 10)   # 100個の入力、10個の出力。
... ... ... 784 100 10 [0 0 0 1 0 0 0 0 0 0]
MNIST 用ニューラルネットワークの構造

ダウンロードした MNISTのデータを読み込むため、 mnist.py ファイルで load_mnist() 関数が 定義されている。 これは、ファイルの内容をひとつの巨大な ndarray型に変換する。 以下のようにして訓練データ用の画像とラベルを取得する。 変数 train_imagestrain_labels の内容は、 それぞれ次のようになっている:

train_images = load_mnist('train-images-idx3-ubyte.gz')  # [, , ...]
train_labels = load_mnist('train-labels-idx1-ubyte.gz')  # [3, 9, ...]

どちらの変数にも、60,000個ずつの訓練データが含まれている。 train_images 中の各画像はさらに 28×28 の2次元配列であるので、 全体として train_images は 60,000×28×28要素 の ndarray となり、 train_labels は 60,000要素 の ndarray となっている。 これら2つの要素を zip() 関数で対にして、 画像とラベルをひとつずつニューラルネットワークに学習させていく:

    for (image,label) in zip(train_images, train_labels):
        # 28×28の画像を784要素のフラットな配列に変換。
        x = (image/255).reshape(784)
        # 正解部分だけが 1 になっている 10要素の配列を作成。
        ya = np.zeros(10)
        ya[label] = 1
        ...

ここでは各画像 image を255で割り、 reshape() でフラットな1次元配列に変換している。 これは元画像がグレースケールのため各ピクセルの範囲が 0〜255 の 整数となっているのを、 0〜1 の小数に変換するためである。 正解データ ya はラベルの値を元に 10要素の配列を作成している。

まとめると、次のようなプログラムになる:

mnist_slow.py (遅いバージョン)
# 訓練データの画像・ラベルを読み込む (パス名は適宜変更)。
train_images = load_mnist('train-images-idx3-ubyte.gz')
train_labels = load_mnist('train-labels-idx1-ubyte.gz')
# レイヤーを 2つ作成。
layer1 = Layer(784, 100)
layer2 = Layer(100, 10)
# 100回繰り返す。
for i in range(100):
    for (image,label) in zip(train_images, train_labels):
        # 28×28の画像を784要素のフラットな配列に変換。
        x = (image/255).reshape(784)
        # 正解部分だけが 1 になっている 10要素の配列を作成。
        ya = np.zeros(10)
        ya[label] = 1
        # 損失・勾配を計算。
        y = layer1.forward(x)
        y = layer2.forward(y)
        delta = layer2.mse_loss(ya)
        delta = layer2.backward(delta)
        delta = layer1.backward(delta)
   print(layer2.loss)
   layer1.update(0.01)
   layer2.update(0.01)

実は上のプログラムをそのまま実行しても一応動くのだが、 非常に時間がかかってしまう。 そこで、以下にもうすこし実用的な方法を説明しよう。

5.1. ミニバッチと SGD法

本来、勾配降下法は訓練データ全部に対する勾配の平均を使って 重み・バイアスを調整していく方法であった。 しかし MNIST の訓練データは非常に大きく、 これを一度処理するだけでも時間がかかる。 もしこれを数千回も反復させると、学習に非常に時間がかかることになる。

そこで、訓練データを全部見ずに、 一部を見た時点でパラメータを漸進的に更新していく、という方法が考えられる。 訓練データをいくつかの ミニバッチ (minibatch) に区切り、 ミニバッチごとに重み・バイアスを更新していくのである:

訓練データ全部 (バッチ) ミニバッチ ミニバッチ ミニバッチ ...

このためには、たとえば上のプログラムを次のように変更すればよい:

mnist_minibatch.py (ミニバッチバージョン)
# カウンタを初期化する。
n = 0
for (image,label) in zip(train_images, train_labels):
    # 1個のデータに対して損失・勾配を計算。
    ...
    n += 1
    # 50個ごとに重み・バイアスを更新する。
    if (n % 50) == 0:
        print(layer2.loss)
        layer1.update(0.01)
        layer2.update(0.01)

この例では、入力50個ごとに重み・バイアスを更新している。 MNIST の訓練データは全部で 60,000個あるので、 60000÷50 = 300回更新されることになる (300ミニバッチ)。 ミニバッチを使って重みを更新していくと、 訓練データを全部見ているわけではないので、 学習の結果は訓練データが現れる順序、 つまり確率にある程度左右されてしまう。 そのため、この方法は確率的 勾配降下法 (Stochastic Gradient Decent)、通称 SGD と呼ばれている。 実際には、全訓練データを1回見るだけでは学習が足りないので、 さらにこのプロセス全体を何回か繰り返す。 この「全訓練データに対する繰り返し回数」をエポック (epoch) という。 SGD は、今日のニューラルネットワークでは標準的な学習方法である。

たとえばエポックを 5 とすると:

# カウンタを初期化する。
n = 0
# 全訓練データに対して5回繰り返す (5エポック) 。
for epoch in range(5):
    for (image,label) in zip(train_images, train_labels):
        # 1個のデータに対して損失・勾配を計算。
        ...

注意: 本来は 1エポックごとに訓練データの順序をシャッフルして、 なるべく訓練データの順序によって学習に偏りが出ないようにするべきである。 今回はその処理は省略している。

演習4-4. MNISTを学習する
  1. 上のプログラム mnist_minibatch.py を完成させ、実際に実行せよ。 ただし、エポック 5回は実行時間が長すぎるので、最初は 1回でよい。
  2. エポックを 2回に増やすと、表示される損失はどう変化するか?

5.2. 学習結果を使う

学習が完了したら、実際にそのニューラルネットワークを使って認識をさせてみよう。 テストデータを読み込み、これをいま学習したネットワークに通す。 本来、出力である 10要素のベクトルには、該当する数字の要素が 「1」になっているはずだが、 計算結果が正確に 1 になることはまずありえないので、 ここでは「もっとも大きな値」の要素を正解の数字とする。 このためには、np.argmax() 関数を使う:

>>> y = np.array([0.1, 0.0, 0.2, 0.9, 0.1, 0.1, 0.5, 0.2, 0.6, 0.4])
>>> np.argmax(y)
3

実際に認識精度を測定する部分は、次のようになる:

# テストデータを使って認識精度を測定する。
test_images = load_mnist('t10k-images-idx3-ubyte.gz')
test_labels = load_mnist('t10k-labels-idx1-ubyte.gz')
correct = 0  # 正解した数。
for (image,label) in zip(test_images, test_labels):
    x = (image/255).reshape(784)
    # ニューラルネットワークで推論をおこなう。
    y = layer1.forward(x)
    y = layer2.forward(y)
    # 10要素の出力ベクトルのうち、値がもっとも大きな要素を選ぶ。
    i = np.argmax(y)
    if i == label:
        correct += 1  # label と等しければ正解。
# テストデータの数と、正解した数とを表示する。
print(len(test_labels), correct)
演習4-5. MNISTの正解数を測定する
  1. 演習4-4. で完成させた mnist_minibatch.py の 末尾に上のコードを追加し、認識精度を測定せよ。
  2. エポックが 1回のときと 2回のときで認識精度の違いを観察せよ。
  3. レイヤー1 と レイヤー2 の間に、さらに 100ノードの中間レイヤーを 挿入すると、認識精度はどう変化するか?
    layer1 = Layer(784, 100)
    layerx = Layer(100, 100)
    layer2 = Layer(100, 10)
    
  • ここで注目すべきことは、レイヤーをいくら増やしても ネットワーク全体は依然として微分可能になっているということである。 一般に、レイヤーを増やせば増やすほどニューラルネットワークの 学習能力は向上すると言われている。 しかし同時に重み・バイアスが変化する速度も遅くなるため (勾配消失問題)、より学習に時間がかかるようになる。

    5.3. エポックは何回やれば充分か?

    これまでエポックの回数を多くすればニューラルネットワークが収束し、 損失が減少すると説明してきた。 ではエポックは多くすればするほどよいのだろうか? 実はそうではない。 損失が少ないからといって、誤りが少ないとは限らないのである。 ニューラルネットワークの損失が少なすぎると、 これは訓練データの正解をただ記憶しているだけになってしまう。 このような現象を過学習 (overfitting) という。 過学習は機械学習システムが「過去問 (訓練データ)」に適合することにばかり 注力してしまい、「本番の試験 (テストデータ)」に対応できなくなって しまっている状態である。過学習はどのような機械学習システムでも存在しうるが、 ニューラルネットワークの場合、これはエポックを多くしすぎると発生する。

    誤り 損失 過学習 エポック
    損失が減少しても、誤りは増加する (過学習)

    このような事態を防ぐために使われているのが 検証データ (validation data) である。これは現在の学習状況を判断するための小規模なテストデータのことで、 エポックごとにこれを使って毎回小規模な精度測定をおこない、 精度が低下していないかチェックする。 検証データを使った訓練は、以下のようなステップでおこなう:

    1. 全データを「訓練データ」「検証データ」そして「テストデータ」の3つに分ける。
    2. 毎エポックごとに、訓練データでニューラルネットワークを訓練したあと、 検証データを使って簡単に精度測定をおこなう。
    3. 精度が改善している場合は、2. を繰り返す。
    4. テストデータで最終的な精度を測定する。

    以上を疑似コードで表すと以下のようになる:

    prev_accuracy = 0
    for epoch in range(100):
        # 訓練データで訓練する。
        train(train_data)
        # 検証データで精度を測定する。
        accuracy = test(val_data)
        # 前よりも悪化していたら、そこで訓練をやめる。
        if accuracy <= prev_accuracy:
            break
        prev_accuracy = accuracy
    

    本講座では検証データを使った実装は省略するが、 精度の高いニューラルネットワークを実際に設計・訓練しようとする際には 検証データは必要不可欠である。 多くのデータセットでは「訓練データ」と「テストデータ」の 2種類しか与えられていないため、検証データを使う場合は 訓練データをさらに分割し、「学習に使うデータ + 検証データ」として使う。 このさい「訓練データ」の意味が混乱しやすいので注意。

    5.4. Softmax 活性化関数と交差エントロピー損失

    以上で MNIST をニューラルネットワークで実装することができた。 演習4-5. をやってみると、おそらく 92% 程度の精度が出るはずである。 これは単純なニューラルネットワークにしては、そこそこの性能といえるかもしれないが、 まだそれほど高いとはいえない。

    認識精度をさらに上げるには、どうすればよいだろうか? これまで、精度を上げる方法として以下のようなものを見てきた:

    1. 訓練データの数を増やす。
    2. 各レイヤーごとのノードの数を増やす。
    3. レイヤーの数 (深さ) を増やす。
    4. 反復 (エポック) の数を増やす。

    実はニューラルネットワークの世界では、これ以外にも精度を上げるための 数多くのテクニックがある。ここではそのひとつとして 「損失関数を改良する」方法を説明する。

    これまでのニューラルネットワークでは、 損失関数として平均二乗誤差 (MSE loss) を使ってきた。 平均二乗誤差を下げるということは、ニューラルネットワークの出力が 訓練データの正解に近づくということを意味する。 しかし、MNIST のようなタスクの損失として平均二乗誤差を使うことは 必ずしも理想的ではない。たとえば、以下のようなケースがあったとする:

    出力: [ 0.1, 0.0, 0.2, 0.9, 0.1, 0.1, 0.5, 0.2, 0.6, 0.4]
    正解: [   0,   0,   0,   1,   0,   0,   0,   0,   0,   0]
    

    まず、このニューラルネットワークの出力ベクトルのうち、 重要なのは「何番目の要素が最大か」という情報だけである。 「0.9」などの具体的な値は重要ではない。 また、正解ベクトルでも重要なのは「何番目の要素が最大か」 という情報だけであって、1 という具体的な値が重要なわけではない。 しかし平均二乗誤差を使ったニューラルネットワークではとにかく 出力ベクトルの個々の値を正解ベクトルに近づけようとするため、 これらの余計な情報まで学習しようとする。

    さらに、MNISTのようなタスクでは、ひとつの入力画像に対する正解は 1つだけである。 つまり、正解ベクトルの 2箇所以上が 1 になることはない。 しかしニューラルネットワークはこの特性を利用できておらず、 出力のすべての数値になんらかの意味があるものとして学習している。

    以上の 2つの問題を一気に解決するのが 「Softmax 活性化関数」と「交差エントロピー損失」である。 これは MNIST のように「複数のカテゴリから 1つのものを選ぶ」ような 判定タスクに使われる。その具体的な方法は以下のとおりである:

    1. ニューラルネットワークの最後のレイヤーだけ、 シグモイド関数のかわりに Softmax関数 を活性化関数として使う。
    2. 損失関数として、平均二乗誤差のかわりに 交差エントロピー誤差 (cross entropy error) というものを使う。

    Softmax関数とは、文字どおり最大値を返す max関数を 「ソフトに」したものである。微分可能な max関数といってもよい。 これは与えられたベクトルの各要素 ai を 以下のような値で置き換える:

    ai → exp(ai) / (exp(a1) + exp(a2) + ... + exp(an))

    通常の Python では、これは以下のように書ける:

    def softmax(x):
        x = [ exp(a) for a in x ]
        z = sum(x)
        return [ a/z for a in x ]
    
    NumPy版では:
    def softmax(x):
        x = np.exp(x)
        return x / np.sum(x)
    

    いっぽう交差エントロピーとは、 本来は 2つの確率分布の差を求めるものである。 じつは Softmax関数の各要素を合計すると 1 になるので、 Softmax関数の出力は確率分布として解釈できるといってもよい。 正解データも同様にひとつの要素だけが 1 で、 あとはすべて 0 なので、 確率分布としての解釈が可能である。

    n種類の可能性の確率をあらわす、ふたつの確率分布 [p1, p2, ..., pn] および [q1, q2, ..., qn] があるとき、 交差エントロピー誤差 H(p, q) は、次のように表される:

    H(p, q) = -(p1 · log(q1) + p2 · log(q2) + ... + pn · log(qn))
    演習4-6. Softmax関数と交差エントロピー誤差を求める
    1. あるニューラルネットワークの重み合計が [3, -2, 0, 9, 4] だったとする。これらの値に対して Softmax関数を適用したときの値を求めよ。
    2. さらに、この値と [0.1, 0.1. 0.5, 0.2, 0.1] という 確率分布との交差エントロピー誤差を求めよ。

    MNISTの判定においては、正解データのベクトルは ひとつの要素だけが 1 である。 したがって、これを上の式に適用すると、ひとつの項だけが残り、 交差エントロピー誤差の計算は Python で以下のように簡単化できる:

    def nll_loss(y, i):
        return -np.log(y[i])
    

    ここで y は Softmaxレイヤーの出力、i は正解の要素である。 この関数は一般に Negative Log Likelihood (NLL) とも呼ばれる。

    さて、Softmax関数と交差エントロピー誤差を同時に紹介したのには理由がある。 「Softmax関数 + 交差エントロピー誤差」の組み合わせを使うと、 勾配を計算するのが非常に簡単になるためである。

    計算の詳細は省略するが、 交差エントロピー誤差の損失を LCEE とすると、 以下のようになる:

    LCEE/wi = LCEE/y · y/wij = (y - y0) · xi
    LCEE/b = LCEE/y · y/bi = (y - y0)

    では実際に Softmax関数と交差エントロピー誤差を使った 新しいレイヤークラス SoftmaxLayer を定義しよう。 __init__() メソッドと update() メソッドは 以前と同じである:

    # 入力 nin個、出力 nout個のSoftmaxレイヤーを定義する。
    class SoftmaxLayer:
    
        def __init__(self, nin, nout):
            ...
    
        def update(self, alpha):
            ...
    

    forward() メソッドでは、 シグモイド関数のかわりに Softmax関数を使うよう変更する:

        def forward(self, x):
            # xは nin個の要素をもつ入力値のリスト。
            # 与えられた入力に対する各ノードの出力を計算する。
            self.x = x
            self.y = softmax(np.dot(self.w, x) + self.b)
            # yは nout個の要素をもつ出力値のリスト
            return self.y
    

    最後に、損失関数の計算および勾配降下法の部分を実装する。 先に説明した「Softmax関数 + 交差エントロピー誤差」で勾配計算が 簡単化されたため、これはひとつのメソッドでやってしまうことにする:

        def cross_entropy_loss_backward(self, ya):
            # 与えられた正解に対する損失を求める。
            i = np.argmax(ya)
            self.loss += nll_loss(self.y, i)
            # 損失関数の微分を計算する。
            delta = (self.y - ya)
            # 各偏微分を計算する。
            self.dw += delta.reshape(self.nout, 1) * self.x
            self.db += delta
            # 各入力値の微分を求める。
            dx = np.dot(delta, self.w)
            return dx
    
    演習4-7. Softmaxレイヤーを使った MNIST

    演習4-5. で作成したコードの最後のレイヤーを SoftmaxLayer に変更し、認識精度の違いを確認せよ:

    layer1 = Layer(784, 100)        # 784個の入力、100個の出力。
    layer2 = SoftmaxLayer(100, 10)  # 100個の入力、10個の出力。(Softmax + 交差エントロピー誤差)
    

    5.5. LogSoftmax関数

    ちなみに第6章で説明する PyTorch では効率のため、 普通の Softmax関数ではなく、そのlogをとったものを使っている。

    LogSoftmax(ai) = log( exp(ai) / (Σ exp(ai) )
    = ai - log(Σ exp(ai))

    NumPy ではこのようなコードになる:

    def log_softmax(x):
        return x - np.log(np.sum(np.exp(x)))
    

    LogSoftmax を使うと、交差エントロピー誤差の計算 (Negative Log Likelihood) が簡単になる:

    def nll_loss(y, i):
        # return -np.log(y[i])
        return -y[i]
    

    6. まとめ


    クリエイティブ・コモンズ・ライセンス
    この作品は、クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスの下に提供されています。
    Yusuke Shinyama