Skip to content

Latest commit

 

History

History
626 lines (406 loc) · 23.2 KB

ml_sprint10.md

File metadata and controls

626 lines (406 loc) · 23.2 KB

Sprint10課題 深層学習スクラッチニューラルネットワーク(機械学習コースワークサンプル用)


この課題の目的

  • スクラッチを通してニューラルネットワークの基礎を理解する
  • 画像データの簡単な扱い方を知る

スクラッチによる実装

NumPyなど最低限のライブラリのみを使いアルゴリズムを実装していきます。

今回は多クラス分類を行う3層のニューラルネットワークを作成します。層の数などは固定した上でニューラルネットワークの基本を確認しましょう。次のSprintで層を自由に変えられる設計にしていきます。

データセットの用意

MNISTデータセットを使用します。以下のコードを実行すればKerasによりデータセットをダウンロードし、展開まで行えます。

データセットをダウンロードするコード

from keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()

MNISTとは?

画像分類のための定番データセットで、手書き数字認識を行います。このデータセットには学習用6万枚、テスト用1万枚の28×28ピクセルの白黒画像、およびそれらが0〜9のどの数字であるかが含まれています。

画像データとは?

デジタル画像は点の集合で、これをピクセルと呼びます。一般的に白黒画像であればピクセルには0〜255の値が含まれます。一方、カラー画像であればR(赤)、G(緑)、B(青)それぞれに対応する0〜255の値が含まれます。機械学習をする上では、この0〜255の値一つひとつが特徴量として扱われます。0〜255は符号なしの8ビット整数で表せる範囲になるため、NumPyであれば「uint8」型の変数として保持できます。

データセットの確認

まず、どういったデータなのかを見てみます。

サンプルコード

print(X_train.shape) # (60000, 28, 28)
print(X_test.shape) # (10000, 28, 28)
print(X_train[0].dtype) # uint8
print(X_train[0])

各データは28×28ピクセルの白黒画像です。

平滑化

(1, 28, 28)の各画像を、(1, 784)に変換します。これまで学んできた機械学習手法や、今回扱う全結合層のみのニューラルネットワークではこの形で扱います。全てのピクセルが一列になっていることを、平滑化(flatten)してあるという風に表現します。

サンプルコード

X_train = X_train.reshape(-1, 784)
X_test = X_test.reshape(-1, 784)

補足

ここまで機械学習を学んでくる中で、特徴量の数を「次元」と呼んできました。その視点ではMNISTは784次元のデータです。一方で、NumPyのshapeが(784,)の状態を1次元配列とも呼びます。画像としての縦横の情報を持つ(28, 28)の状態であれば、2次元配列です。この視点では2次元のデータです。さらに、もしもカラー画像であれば(28, 28, 3)ということになり、3次元配列です。先ほどの視点では3次元のデータになります。しかし、白黒でもカラーでも平面画像であり、立体データではないという視点で、2次元のデータです。画像データを扱う際にはこのように「次元」という言葉が複数の意味合いで使われることに注意してください。

画像データの可視化

画像データを可視化します。plt.imshowに渡します。

サンプルコード

import matplotlib.pyplot as plt
%matplotlib inline
index = 0
image = X_train[index].reshape(28,28)
# X_train[index]: (784,)
# image: (28, 28)
plt.imshow(image, 'gray')
plt.title('label : {}'.format(y_train[index]))
plt.show()

numpy.reshape — NumPy v1.15 Manual

matplotlib.pyplot.imshow — Matplotlib 3.0.2 documentation

発展的話題

画像データは符号なし8ビット整数のuint8型で保持されることが一般的ですが、plt.imshowはより自由な配列を画像として表示することが可能です。例えば、以下のようにマイナスの値を持ったfloat64型の浮動小数点であってもエラーにはならないし、先ほどと全く同じ風に表示されます。

index = 0
image = X_train[index].reshape(28,28)
image = image.astype(np.float) # float型に変換
image -= 105.35 # 意図的に負の小数値を作り出してみる
plt.imshow(image, 'gray')
plt.title('label : {}'.format(y_train[index]))
plt.show()
print(image) # 値を確認

これは、自動的に値を0〜255の整数に変換して処理するように作られているからです。uint8型であっても最小値が0、最大値が255でない場合には色合いがおかしくなります。それを防ぐためには次のように引数を入れてください。

plt.imshow(image, 'gray', vmin = 0, vmax = 255)

画像関係のライブラリではこの自動的なスケーリングが思わぬ結果を生むことがあるので、新しいメソッドを使うときには確認しておきましょう。

前処理

画像は0から255のuint8型で表されますが、機械学習をする上では0から1のfloat型で扱うことになります。色は理想的には連続値であり、それを特徴量とするからです。以下のコードで変換可能です。

サンプルコード

X_train = X_train.astype(np.float)
X_test = X_test.astype(np.float)
X_train /= 255
X_test /= 255
print(X_train.max()) # 1.0
print(X_train.min()) # 0.0

また、正解ラベルは0から9の整数ですが、ニューラルネットワークで多クラス分類を行う際にはone-hot表現に変換します。scikit-learnのOneHotEncoderを使用したコードが以下です。このone-hot表現による値はそのラベルである確率を示していることになるため、float型で扱います。

サンプルコード

from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(handle_unknown='ignore', sparse=False)
y_train_one_hot = enc.fit_transform(y_train[:, np.newaxis])
y_test_one_hot = enc.transform(y_test[:, np.newaxis])
print(y_train.shape) # (60000,)
print(y_train_one_hot.shape) # (60000, 10)
print(y_train_one_hot.dtype) # float64

sklearn.preprocessing.OneHotEncoder — scikit-learn 0.20.0 documentation

さらに、学習用データ6万枚の内2割を検証用データとして分割してください。学習用データが48000枚、検証用データが12000枚となります。

サンプルコード

X_train, X_val, y_train, y_val = train_test_split(X_train, y_train, test_size=0.2)
print(X_train.shape) # (48000, 784)
print(X_val.shape) # (12000, 784)

【問題1】ニューラルネットワーク分類器のクラスを作成

ニューラルネットワーク分類器のクラスScratchSimpleNeuralNetrowkClassifierを作成してください。

以下が雛形です。基本的な構成は機械学習編の線形回帰やロジスティック回帰などと同様です。

雛形

class ScratchSimpleNeuralNetrowkClassifier():
    """
    シンプルな三層ニューラルネットワーク分類器

    Parameters
    ----------

    Attributes
    ----------
    """

    def __init__(self, verbose = True):
        self.verbose = verbose
        pass
    def fit(self, X, y, X_val=None, y_val=None):
        """
        ニューラルネットワーク分類器を学習する。

        Parameters
        ----------
        X : 次の形のndarray, shape (n_samples, n_features)
            学習用データの特徴量
        y : 次の形のndarray, shape (n_samples, )
            学習用データの正解値
        X_val : 次の形のndarray, shape (n_samples, n_features)
            検証用データの特徴量
        y_val : 次の形のndarray, shape (n_samples, )
            検証用データの正解値
        """

        if self.verbose:
            #verboseをTrueにした際は学習過程などを出力する
            print()
        pass


    def predict(self, X):
        """
        ニューラルネットワーク分類器を使い推定する。

        Parameters
        ----------
        X : 次の形のndarray, shape (n_samples, n_features)
            サンプル

        Returns
        -------
            次の形のndarray, shape (n_samples, 1)
            推定結果
        """

        pass
        return

ミニバッチ処理

これまでの機械学習スクラッチでは、全てのサンプルを一度に計算していました。しかし、ニューラルネットワークではデータを分割して入力する確率的勾配降下法が一般的です。分割した際のひとかたまりをミニバッチ、そのサンプル数をバッチサイズと呼びます。

今回はバッチサイズを20とします。今回使う学習用データは48000枚ですから、48000/20で2400回の更新を繰り返すことになります。ニューラルネットワークではこれを2400回イテレーション(iteration)すると呼びます。学習データを一度全て見ると1回のエポック(epoch)が終わったことになります。このエポックを複数回繰り返し、学習が完了します。

これを実現するための簡素なイテレータを用意しました。for文で呼び出すと、ミニバッチを取得できます。

コード

class GetMiniBatch:
    """
    ミニバッチを取得するイテレータ

    Parameters
    ----------
    X : 次の形のndarray, shape (n_samples, n_features)
      学習データ
    y : 次の形のndarray, shape (n_samples, 1)
      正解値
    batch_size : int
      バッチサイズ
    seed : int
      NumPyの乱数のシード
    """
    def __init__(self, X, y, batch_size = 20, seed=0):
        self.batch_size = batch_size
        np.random.seed(seed)
        shuffle_index = np.random.permutation(np.arange(X.shape[0]))
        self._X = X[shuffle_index]
        self._y = y[shuffle_index]
        self._stop = np.ceil(X.shape[0]/self.batch_size).astype(np.int)

    def __len__(self):
        return self._stop

    def __getitem__(self,item):
        p0 = item*self.batch_size
        p1 = item*self.batch_size + self.batch_size
        return self._X[p0:p1], self._y[p0:p1]        

    def __iter__(self):
        self._counter = 0
        return self

    def __next__(self):
        if self._counter >= self._stop:
            raise StopIteration()
        p0 = self._counter*self.batch_size
        p1 = self._counter*self.batch_size + self.batch_size
        self._counter += 1
        return self._X[p0:p1], self._y[p0:p1]

このクラスをニューラルネットワークのクラス内でインスタンス化し、for文を使うことでミニバッチが取り出せます。

# 以下をニューラルネットワークのクラス内で呼び出す

get_mini_batch = GetMiniBatch(X_train, y_train, batch_size=20)

print(len(get_mini_batch)) # 2400
print(get_mini_batch[5]) # 5番目のミニバッチが取得できる
for mini_X_train, mini_y_train in get_mini_batch:
    # このfor文内でミニバッチが使える
    pass

__getitem____next____init__などと同じ特殊メソッドの一種です。

フォワードプロパゲーション

三層のニューラルネットワークのフォワードプロパゲーションを作成します。以下の説明ではノード数は1層目は400、2層目は200としますが、変更しても構いません。

各層の数式を以下に示します。今回はそれぞれの記号が表す配列が、実装上どのようなndarrayのshapeになるかを併記してあります。

batch_size = 20 # バッチサイズ
n_features = 784 # 特徴量の数
n_nodes1 = 400 # 1層目のノード数
n_nodes2 = 200 # 2層目のノード数
n_output = 10 # 出力のクラス数(3層目のノード数)

「1層目」

$$ A_1 = X \cdot W_1 + B_1 $$

$X$ : 特徴量ベクトル (batch_size, n_features)

$W_1$ : 1層目の重み (n_features, n_nodes1)

$B_1$ : 1層目のバイアス (n_nodes1,)

$A_1$ : 出力 (batch_size, n_nodes1)

「1層目の活性化関数」

$$ Z_1 = f(A_1) $$

$f()$ : 活性化関数

$Z_1$ 出力 (batch_size, n_nodes1)

「2層目」

$$ A_2 = Z_1 \cdot W_2 + B_2 $$

$W_2$ : 2層目の重み (n_nodes1, n_nodes2)

$B_2$ : 2層目のバイアス (n_nodes2,)

$A_2$ : 出力 (batch_size, n_nodes2)

「2層目の活性化関数」

$$ Z_2 = f(A_2) $$

$f()$ : 活性化関数

$Z_2$ 出力 (batch_size, n_nodes2)

「3層目(出力層)」

$$ A_3 = Z_2 \cdot W_3 + B_3 $$

$W_3$ : 3層目の重み (n_nodes2, n_output)

$B_3$ : 3層目のバイアス (n_output,)

$A_3$ : 出力 (batch_size, n_output)

「3層目の活性化関数」

$$ Z_3 = softmax(A_3) $$

$softmax()$ : ソフトマックス関数

$Z_3$ 出力 (batch_size, n_output)

$Z_3$ は各ラベル(0〜9)に対する確率の配列である。

重みの初期値

ニューラルネットワークにおいては重みの初期値は重要な要素です。様々な方法が提案されていますが、今回はガウス分布による単純な初期化を行います。バイアスに関しても同様です。

以下のコードを参考にしてください。標準偏差の値sigmaはハイパーパラメータです。発展的な重みの初期化方法については次のSprintで扱います。

サンプルコード

n_features = 784
n_nodes1 = 400
sigma = 0.01 # ガウス分布の標準偏差
W1 = sigma * np.random.randn(n_features, n_nodes1)
# W1: (784, 400)

numpy.random.randn — NumPy v1.15 Manual

活性化関数(フォワードプロバゲーション)

活性化関数を作成し、フォワードプロパゲーションの中で使用します。切り替えられるように実装することを推奨しますが、片方でも構いません。

「シグモイド関数」

$$ f(Z) = sigmoid(A) = \frac{1}{1+exp(-A)} $$

指数関数 $exp(-A)$ の計算はnp.expを使用してください。

numpy.exp — NumPy v1.15 Manual

「ハイパボリックタンジェント関数」

次の数式で表されますが、np.tanhひとつで実現できます。

$$ f(Z) = tanh(A) = \frac{exp(A) - exp(-A)}{exp(A) + exp(-A)} $$

numpy.tanh — NumPy v1.15 Manual

*現在ではこれらの代わりにReLUと呼ばれる活性化関数が一般的です。次のSprintで扱います。

ソフトマックス関数

ソフトマックス関数を作成し、フォワードプロパゲーションの中で使用します。これも活性化関数の一種ですが、多クラス分類の出力層で使われる特性上、区別して扱われることが多いです。

次の数式です。

$$ Z_{3_k} = \frac{exp(A_{3_k})}{\sum_{i=1}^{n_c}exp(A_{3_i})} $$

$Z_{3_k}$ : $k$ 番目のクラスの確率ベクトル (batch_size,)

$A_{3_k}$ : $k$ 番目のクラスにあたる前の層からのベクトル (batch_size,)

$n_c$ : クラスの数、n_output。今回のMNISTでは10。

分母は全てのクラスに相当する値を指数関数に通した上で足し合わせたものです。その中で、分子に $k$ 番目のクラスを持ってくることで、 $k$ 番目のクラスである確率が求まります。

これを10クラス分計算し、合わせたものが $Z_3$ です。

交差エントロピー誤差

目的関数(損失関数)を作成します。

多クラス分類の目的関数である交差エントロピー誤差 $L$ は次の数式です。

$$ L = - \frac{1}{n_b}\sum_{j}^{n_b}\sum_{k}^{n_c}y_{jk} log(z_{3_jk}) $$

$y_{ij}$ : $j$ 番目のサンプルの $k$ 番目のクラスの正解ラベル(one-hot表現で0か1のスカラー)

$z_{3_ij}$ : $j$ 番目のサンプルの $k$ 番目のクラスの確率(スカラー)

$n_{b}$ : バッチサイズ、batch_size

$n_{c}$ : クラスの数、n_output(今回のMNISTでは10)

サンプル1つあたりの誤差が求まります。

バックプロパゲーション

三層のニューラルネットワークのバックプロパゲーションを作成します。確率的勾配降下法を行う部分です。

数式を以下に示します。

まず、i層目の重みとバイアスの更新式です。 $W_i$$B_i$ に対し、更新後の $W_i^{\prime}$$B_i^{\prime}$ は次の数式で求められます。

$$ W_i^{\prime} = W_i - \alpha \frac{\partial L}{\partial W_i} \

B_i^{\prime} = B_i - \alpha \frac{\partial L}{\partial B_i} $$

$\alpha$ : 学習率(層ごとに変えることも可能だが、基本的には全て同じとする)

$\frac{\partial L}{\partial W_i}$ : $W_i$ に関する損失 $L$ の勾配

$\frac{\partial L}{\partial B_i}$ : $B_i$ に関する損失 $L$ の勾配

この更新方法はSprint3線形回帰やsprint4ロジスティック回帰における最急降下法と同様です。より効果的な更新方法が知られており、それは次のSprintで扱います。

勾配 $\frac{\partial L}{\partial W_i}$$\frac{\partial L}{\partial B_i}$ を求めるために、バックプロパゲーションを行います。以下の数式です。ハイパボリックタンジェント関数を使用した例を載せました。シグモイド関数の場合の数式はその後ろにあります。

「3層目」

$$ \frac{\partial L}{\partial A_3} = \frac{1}{n_b}\sum_{j}^{n_b}(Z_{3_j} - Y_{j})\

\frac{\partial L}{\partial B_3} = \frac{\partial L}{\partial A_3}\

\frac{\partial L}{\partial W_3} = (\frac{1}{n_b}\sum_{j}^{n_b}Z_{2_j}^T) \cdot \frac{\partial L}{\partial A_3}\

\frac{\partial L}{\partial Z_2} = \frac{\partial L}{\partial A_3} \cdot W_3^T $$

$\frac{\partial L}{\partial A_3}$ : $A_3$ に関する損失 $L$ の勾配 (1, n_output)

$\frac{\partial L}{\partial B_3}$ : $B_3$ に関する損失 $L$ の勾配 (1, n_output)

$\frac{\partial L}{\partial W_3}$ : $W_3$ に関する損失 $L$ の勾配 (n_nodes2, n_output)

$\frac{\partial L}{\partial Z_2}$ : $Z_2$ に関する損失 $L$ の勾配 (1, n_nodes2)

$Z_{3_j}$ : $j$ 番目のサンプルの3層目の出力 (1, n_output)

$Y_{j}$ : $j$ 番目のサンプルの正解ラベル (1, n_output)

$Z_{2_j}$ : $j$ 番目のサンプルの2層目の出力 (n_nodes2, 1)

$W_3$ : 3層目の重み (n_nodes2, n_output)

「2層目」

$$ \frac{\partial L}{\partial A_2} = \frac{\partial L}{\partial Z_2} × {1-tanh^2(\frac{1}{n_b}\sum_{j}^{n_b}A_{2_j})}\

\frac{\partial L}{\partial B_2} = \frac{\partial L}{\partial A_2}\

\frac{\partial L}{\partial W_2} = (\frac{1}{n_b}\sum_{j}^{n_b}Z_{1_j}^T) \cdot \frac{\partial L}{\partial A_2}\

\frac{\partial L}{\partial Z_1} = \frac{\partial L}{\partial A_2} \cdot W_2^T $$

$\frac{\partial L}{\partial A_2}$ : $A_2$ に関する損失 $L$ の勾配 (1, n_nodes2)

$\frac{\partial L}{\partial B_2}$ : $B_2$ に関する損失 $L$ の勾配 (1, n_nodes2)

$\frac{\partial L}{\partial W_2}$ : $W_2$ に関する損失 $L$ の勾配 (n_nodes1, n_nodes2)

$\frac{\partial L}{\partial Z_2}$ : $Z_2$ に関する損失 $L$ の勾配 (1, n_nodes2)

$Z_{2_j}$ : $j$ 番目のサンプルの2層目の出力 (1, n_nodes2)

$A_{2_j}$ : $j$ 番目のサンプルの2層目の活性化関数の出力 (1, n_nodes2)

$Z_{1_j}$ : $j$ 番目のサンプルの1層目の出力 (n_nodes1, 1)

$W_2$ : 2層目の重み (n_nodes1, n_nodes2)

「1層目」

$$ \frac{\partial L}{\partial A_1} = \frac{\partial L}{\partial Z_1} × {1-tanh^2(\frac{1}{n_b}\sum_{j}^{n_b}A_{1_j})}\

\frac{\partial L}{\partial B_1} = \frac{\partial L}{\partial A_1}\

\frac{\partial L}{\partial W_1} = (\frac{1}{n_b}\sum_{j}^{n_b}X_{j}^T) \cdot \frac{\partial L}{\partial A_1}\ $$

$\frac{\partial L}{\partial A_1}$ : $A_1$ に関する損失 $L$ の勾配 (1, n_nodes1)

$\frac{\partial L}{\partial B_1}$ : $B_1$ に関する損失 $L$ の勾配 (1, n_nodes1)

$\frac{\partial L}{\partial W_1}$ : $W_1$ に関する損失 $L$ の勾配 (n_features, n_nodes1)

$\frac{\partial L}{\partial Z_1}$ : $Z_1$ に関する損失 $L$ の勾配 (1, n_nodes1)

$Z_{1_j}$ : $j$ 番目のサンプルの1層目の出力 (1, n_nodes2)

$A_{1_j}$ : $j$ 番目のサンプルの1層目の活性化関数の出力 (1, n_nodes1)

$X_j$ : $j$ 番目のサンプル (n_features, 1)

$W_1$ : 1層目の重み (n_features, n_nodes1)

補足

活性化関数にシグモイド関数を使用した場合は、次のようになります。

$$ \frac{\partial L}{\partial A_2} = \frac{\partial L}{\partial Z_2} × {1-sigmoid(\frac{1}{n_b}\sum_{j}^{n_b}A_{2_j})}sigmoid(\frac{1}{n_b}\sum_{j}^{n_b}A_{2_j}) \\ \frac{\partial L}{\partial A_1} = \frac{\partial L}{\partial Z_1} × {1-sigmoid(\frac{1}{n_b}\sum_{j}^{n_b}A_{1_j})}sigmoid(\frac{1}{n_b}\sum_{j}^{n_b}A_{1_j}) $$

推定

推定を行うメソッドを作成します。

フォワードプロパゲーションによって出力された10個の確率の中で、最も高いものはどれかを判定します。

numpy.argmax — NumPy v1.15 Manual

検証

【問題2】学習曲線のプロット

学習曲線をプロットしてください。

ニューラルネットワークは過学習が発生しやすいため、学習曲線の確認が重要です。trainデータとvalデータに対するエポックごとの損失(交差エントロピー誤差)を記録できるようにする必要があります。

【問題3】指標値の算出

分類に関する指標値で精度を確認してください。

(オプション)誤分類の確認

誤分類した画像はどのようなものだったかを見てみましょう。推定値を用意し、以下のコードを実行してください。

コード

"""
語分類結果を並べて表示する。画像の上の表示は「推定結果/正解」である。

Parameters:
----------
y_pred : 推定値のndarray (n_samples,)
y_val : 検証用データの正解ラベル(n_samples,)
X_val : 検証用データの特徴量(n_samples, n_features)
"""
import numpy as np
import matplotlib.pyplot as plt

num = 36 # いくつ表示するか

true_false = y_pred==y_val
false_list = np.where(true_false==False)[0].astype(np.int)

if false_list.shape[0] < num:
    num = false_list.shape[0]
fig = plt.figure(figsize=(6, 6))
fig.subplots_adjust(left=0, right=0.8,  bottom=0, top=0.8, hspace=1, wspace=0.5)
for i in range(num):
    ax = fig.add_subplot(6, 6, i + 1, xticks=[], yticks=[])
    ax.set_title("{} / {}".format(y_pred[false_list[i]],y_val[false_list[i]]))
    ax.imshow(X_val.reshape(-1,28,28)[false_list[i]], cmap='gray')