【写経】ニューラルネットワークによる画像識別(MNIST)

先の記事で書いたように、まずは基本からということで、今回はニューラルネットワークによる画像識別問題を扱います.最初くらいは手を動かしたほうがいいか、ということで以下のサイトを参考に写経します.口が裂けてももコーディングとは言えない.

tsunotsuno.hatenablog.com


概要
- 目標:2層ニューラルネットによる画像識別を行う
- 使用するデータ:MNIST(0~9の数値データ)
- 環境:Google Colaboratory


ひたすら写経しているだけでなのですが、ベースとなっているオライリーの本がある方が間違いなく理解しやすいかと思います.


また今回、Google Colaboratory上で作業するため、全てコードはColaboのノート上で行いました.そのため、ところどころ不要になったところを消したりしています.

スクリプトファイルをColaboratory上にアップロードしてもいいとは思うのですが、12時間立つと全部消えてしまうので、今回はそのようにいたしました. では早速、実装もとい写経に入っていきます.

Colaboratory上のnoteはコチラ


必要なライブラリのインポート

ここでは、いつもと同じようにnumpy、matplotlibなどをインポートしています.
OrderedDIctは、辞書なんですが、作成する際の順番が維持されたまま格納されます.一方で、標準のdictでは維持されるかどうかは環境に依存するようです.確実に順序を保持したいのであればOrderdDictを使いましょう.

# 必要なライブラリのインポート:
import numpy as np
import matplotlib.pylab as plt
import sys, os
from collections import OrderedDict

MNISTデータのダウンロードおよび展開

  • MNISTデータはurl_baseでURLからダウンロードしています.kery_fileで指定したファイルがダウンロードされます.

  • MNISTデータの読み込みは、load_mnist関数を定義して行っています.ここで、読み込む際のオプションは以下のような意味を持ちます.

    • normalize : 入力画像を0.0~1.0の値に正規化するかどうかの設定。Falseの場合は、ピクセルは元の0~255のまま。
    • flatten : 入力画像を平らにする(1次元配列にする)かどうかを設定する。Falseに設定すると、1x28x28の3次元配列、Trueにすると784個の要素からなる1次元配列として格納される。これをFalseにしても問題なく動くのですが、何でなんでしょう.ニューラルネットの入力層のノード数784と合わせて、28x28のデータを784の一次元配列にしないといけないのではないのだろうか.
    • one_hot_label : [0,0,1,0,0,0...] などのように正解となるラベルだけが1、それ以外が0の配列として表記される。

抜粋:

url_base = "http://yann.lecun.com/exdb/mnist/"
key_file = {
    "train_img":"train-images-idx3-ubyte.gz",
    "train_label":"train-labels-idx1-ubyte.gz",
    "test_img":"t10k-images-idx3-ubyte.gz",
    "test_label":"t10k-labels-idx1-ubyte.gz"
}
:
省略
:
# ここで入れてるのは、デフォルト値(引数として入力されなかった場合の値)
def load_mnist(normalize=True, flatten=True, one_hot_label=True):
:
省略
:
    if normalize:
        for key in ("train_img", "test_img"):
            dataset[key] = dataset[key].astype(np.float32)
            dataset[key] /= 255.0

    if one_hot_label:
        dataset["train_label"] = _change_one_hot_label(dataset["train_label"])
        dataset["test_label"]  = _change_one_hot_label(dataset["test_label"])

    if not flatten:
         for key in ("train_img', 'test_img"):
            dataset[key] = dataset[key].reshape(-1, 1, 28, 28)

    return (dataset["train_img"], dataset["train_label"]), (dataset["test_img"], dataset["test_label"])


if __name__ == "__main__":
    init_mnist()
省略
:
# ネットワークの初期化
def init_network():
    TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
    return network


Affine変換

入力と重みを掛け合わせ、それにバイアスを加える(xw + b )をAffine(アフィン)変換というようです.
行列積のフォワード計算、予測値と正解との差分の計算などを行っています

class Affine:
    def __init__(self, W, b):
        self.W =W
        self.b = b
        self.x = None
        self.original_x_shape = None

        # 重み・バイアスパラメータの微分
        self.dW = None
        self.db = None

    def forward(self, x):
        # バックワード計算のときに使うため、x.shapeの形状を記録しておく
        self.original_x_shape = x.shape
        #-1とした場合、行数だけがx.shape[0]で決まり、列数は元の形状から推測される
        x = x.reshape(x.shape[0], -1) 
        self.x = x

        #affine変換
        out = np.dot(self.x, self.W) + self.b

        return out

    def backward(self, dout):
        dx = np.dot(dout, self.W.T)

        #更新する重みの差分量計算
        self.dW = np.dot(self.x.T, dout)
        self.db = np.sum(dout, axis=0)

        #直前の層への入力
        dx = dx.reshape(*self.original_x_shape)  # 入力データの形状に戻す(テンソル対応)
        return dx


Sigmoid

活性化関数の1つ目、Sigmoid関数を定義するクラスです.
forward計算では、入力値xを使って以下の式で-1~1までの値を出力します.

\begin{align}y = \frac{1}{1+e^{-x}}\end{align}

backward計算時は、Sigmoid関数自体は重みと関係ないため、直前の層への入力の計算だけになります.直前の層への入力は以下のようになります.

\begin{align}\frac{\partial L}{\partial y}y(1-y)\end{align}

# シグモイド関数のクラス
class Sigmoid:
    def __init__(self):
        self.out = None

    #シグモイド関数
    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))

    #順伝播の場合
    def forward(self, x):
        out = self.sigmoid(x)
        self.out = out
        return out

    #逆伝播
    def backward(self, dout):

        return dout*(1.0 - self.out) * self.out


SoftMax

分類問題の順伝播で出力層では、確率分布を算出するため、 今回は簡単な方法としてSoftmax関数を用います.

Softmax関数では出力の総和が1となる.つまり各出力は、その分類となる確率と捉えることができます.

Softmax関数の式は以下のようになります \begin{align} \frac{e^{a_k}}{\sum_{i=1}^{n}}e^{a_i}\end{align}


SoftMax関数では、入力値xの値が大きすぎるとべき乗計算をする関係で計算が簡単にオーバーフローします。 そのため、実装ではオーバーフローしないように工夫してあります。

次に、損失関数を計算しています。今回は 損失関数として、クロスエントロピー関数を使用しています。

最後に2つの関数(softmaxおよびcross_entorpy)を使用して、forward計算を行っています.

backward計算においては、Sigmoid関数と同様に、直前の層に対する入力を計算するだけです。 計算する値は下のようになります。

\begin{align} \frac{\partial L}{\partial y}y(1-y)\end{align}

class Softmax:

    #ソフトマックス関数
    def softmax(self, x):
        if x.ndim == 2:
            x =x.T
            x = x - np.max(x, axis=0) # オーバーフロー対策①
            y = np.exp(x) / np.sum(np.exp(x), axis=0)
            return y.T

        x = x - np.max(x) # オーバーフロー対策②
        return np.exp(x) / np.sum(np.exp(x))
:
省略
:
    def forward(self, x, t):
        self.t = t # 正解
        self.y = self.softmax(x) # 予測値
        self.loss = self.cross_entropy_error(self.y, self.t)

        return self.loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]

        if self.t.size == self.y.size :
            dx = (self.y - self.t) / batch_size
        else :
            dx = self.y.copy()
            dx[np.arange(batch_size), self.t] -= 1
            dx = dx / batch_size

        return dx


2層ニューラルネットの定義

省略します

履歴を描画するグラフの定義

iters_numはトータルで行う重みの更新回数を示し、パラメータで与えます. 今回使用したMNISTデータの場合、訓練データの数は60000であるため、60000/バッチサイズが1エポックで行う更新回数となります. トレーニング中のデータはlistにappendで追加しています.

# グラフ描画用変数(ヒストリーデータを格納していく)
train_loss_list = []
train_acc_list = []
test_acc_list = []
:
省略
:
def plot():
    # グラフの描画
    markers = {'train': 'o', 'test': 's'}
    x = np.arange(len(train_acc_list))
    plt.plot(x, train_acc_list, label='train acc')
    plt.plot(x, test_acc_list, label='test acc', linestyle='--')
    plt.xlabel("epochs")
    plt.ylabel("accuracy")
    plt.ylim(0, 1.0)
    plt.legend(loc='lower right')
    plt.show()


学習実行

訓練データの数は60000、バッチサイズを100とするとき、60000/100=600回の計算で1epochとなる. 私の環境では、16epochで精度は95%弱となりました.

f:id:shirakonotempura:20181219145030p:plain
学習履歴

中途半端な知識の人間が写経とは言え、コードを書いていくことは余計に自分の首を絞めることになります.はい、なりました.
写経だからなんとかなると思ったのですが、甘かった.分かってないことがわんさか沸いてきて手がつかなかったです.
使うだけでいいなら、そう割りきって深入りしないのもありだと思います.

次回は、引き続きCNNによる画像識別(MNIST)に取り組んでみたいと思います.