ゼロからの画像認識①（理論とMNIST）

非情報系大学生が、画像認識についてゼロから学んだことを順にまとめた記事です。今回のゴールはニューラルネットワークの大雑把な理解と、MNISTの一番単純な解き方の理解です。

環境：Python 3.8.5、Jupyter 1.0.0、Tensorflow 2.4.0



## 理論

これに関しては次の連載を読んである程度理解しました。線形代数のある程度の知識を前提とします。【要約】に書いたキーワードが理解できれば、画像認識のチュートリアルとして使用されるMNISTのコードを理解できます。機械に学習させると言っても、人間が手作業で設定しなければいけない項目（ハイパーパラメータ）が複数あるので基礎の理解が必要になります。

1. [AIを学ぶ前にー「空前のAIブーム」を冷静に捉えなおす - Think IT](https://thinkit.co.jp/article/13986)
【要約】機械学習では、写像の意味での「関数」を頑張ってコンピュータに近似させるよ。

2. [「複雑」をたくさんの「単純」に分解する〜順伝播は「1次関数」と「単純な非線形」の繰り返し - Think IT](https://thinkit.co.jp/article/14103)
【要約】どんな関数でも「線形変換」と「活性化関数」の繰り返しで近似できるよ。

3. [ニューラルネットワークの学習を司る「微分」を学ぶ - Think IT](https://thinkit.co.jp/article/14586)
【要約】「誤差関数」を小さくする方向に何度も「重み」を調整するよ。

4. [「線形代数」でニューラルネットワークを記述する？ - Think IT](https://thinkit.co.jp/article/15738)
【要約】（おまけ）重みは行列になるよ。

5. [連鎖律の原理で、誤差を「後ろ」に伝えよう - Think IT](https://thinkit.co.jp/article/15787)
【要約】（おまけ）合成関数の微分はコンピューターに任せてね。




## MNIST

### 「Hello World」の解読

[TensorflowのHP](https://www.tensorflow.org/overview/?hl=ja)にて「初心者向けのHello World」として紹介されているコードの解読を行いました。HPを見ると複数の書き方（API）があるみたいです。MNISTとは、0から9の手書き文字画像データセットです。
参考：[MNIST：手書き数字の画像データセット - @IT](https://www.atmarkit.co.jp/ait/articles/2001/22/news012.html)


```python3:ライブラリの読み込み
import tensorflow as tf
```


```python3:データの読み込みと正規化
mnist = tf.keras.datasets.mnist

(x_train, y_train),(x_test, y_test) = mnist.load_data()
x_train, x_test = x_train / 255.0, x_test / 255.0
```
こうやるとMNISTのデータを貰えるみたいです。各ピクセルは0~255の値を持っているので、割り算で0~1に潰します。

```python3:モデルの作成
model = tf.keras.models.Sequential([
  tf.keras.layers.Flatten(input_shape=(28, 28)),
  tf.keras.layers.Dense(128, activation='relu'),
  tf.keras.layers.Dropout(0.2),
  tf.keras.layers.Dense(10, activation='softmax')
])
```
モデルの層をそれぞれ作ってます。

`Flatten(input_shape=(28, 28))`
28×28だった画像の行列を、平らにして（*Flatten*）ベクトルに変えます。784個の数値を持ったベクトルです。

`Dense(128, activation='relu')`
784個の入力を128個の神経細胞に全部繋ぎます。活性化関数はReLUにしてます。
参考：[活性化関数一覧 - Qiita](https://qiita.com/kuroitu/items/73cd401afd463a78115a)

`Dropout(0.2)`
2割の値を0にしてます。過学習防止になります。
参考：[Dropout：ディープラーニングの火付け役、単純な方法で過学習を防ぐ - DeepAge](https://deepage.net/deep_learning/2016/10/17/deeplearning_dropout.html)

`Dense(10, activation='softmax')`
128個の入力を10個の神経細胞に全部繋ぎます。10個の神経細胞はそれぞれ、0から9までのグループを表します。活性化関数はSoftmaxにしてます。Softmaxは全部足すと1になるので、確率みたいな感じの出力になります。確率が高い群を分類結果として使う予定です。

```python3:設定と学習
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

model.fit(x_train, y_train, epochs=5)
```
`optimizer='adam'`
Adamというアルゴリズムで求める設定です。
参考：[【決定版】スーパーわかりやすい最適化アルゴリズム -損失関数からAdamとニュートン法-  - Qiita](https://qiita.com/omiita/items/1735c1d048fe5f611f80#7-adam)

`loss='sparse_categorical_crossentropy'`
誤差関数（損失関数）を交差エントロピーに設定しています。分類問題ではほぼこれ一択らしい（？）。調べると色々な損失関数がヒットするけど、大体は回帰問題の話。
sparseというのは、y_trainデータの形です。「8群」を表したい時に、`8`なのか`[0,0,0,0,0,0,0,0,1,0]`なのかの違い。あまり気にしなくていい。
参考：[損失関数とは？？〜機械学習の用語まとめ〜 - Qiita](https://qiita.com/Becon147/items/988b637fb890b796d02e#%E4%BA%A4%E5%B7%AE%E3%82%A8%E3%83%B3%E3%83%88%E3%83%AD%E3%83%94%E3%83%BC%E8%AA%A4%E5%B7%AE)

`metrics=['accuracy']`
評価関数を正解率に設定しています。出力で正解率を確認できます。

`epochs=5`
5回繰り返して重みを更新させます。やりすぎると過学習になるので注意。

```python3:結果のテスト
model.evaluate(x_test, y_test)
```
>[0.07569769769906998, 0.9785000085830688]

x_textを分類器に入れてみたときの、損失関数と評価関数の値を教えてくれます。97%超え、驚くほど高い数字です。


### 結果を表示する

素人が見ても解釈できるように表示してみます。どこからコピペしたのか忘れました。

```python3:必要ライブラリの追加
import numpy as np #行列やベクトルの計算用
import matplotlib.pyplot as plt #描画用
```

````python3:予測を行う
predictions = model.predict(x_test)
````

```python3:描画のデザイン
def plot_image(i):
    prediction, true_label, img = np.argmax(predictions[i]), y_test[i], x_test[i]
    plt.grid(False)
    plt.xticks([])
    plt.yticks([])
    plt.imshow(x_test[i], cmap='gray')

    if prediction == true_label:
        color = 'blue' #不正解は赤文字
    else:
        color = 'red' #正解は青文字

    plt.xlabel(f"pred:{prediction},{100*np.max(predictions[i]):.2f}% (true:{true_label})",
                                    color=color)
    
def plot_value_array(i):
    predictions_array, true_label = predictions[i], y_test[i]
    plt.grid(False)
    plt.xticks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
    plt.yticks([])
    thisplot = plt.bar(range(10), predictions_array, color="#777777")
    plt.ylim([0, 1]) 
    predicted_label = np.argmax(predictions_array)

    thisplot[predicted_label].set_color('red') #不正解は赤の棒グラフ
    thisplot[true_label].set_color('blue') #正解は青の棒グラフ
```

```python3:1180番目を表示
i = 1180

plt.figure(figsize=(6, 3))
plt.subplot(1, 2, 1)
plot_image(i)
plt.subplot(1, 2, 2)
plot_value_array(i)
```
>![1180.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/ae4a5720-f444-d7a9-0d49-54e829b45143.png)

正解してます。

```python3:1182番目を表示
i = 1182

plt.figure(figsize=(6, 3))
plt.subplot(1, 2, 1)
plot_image(i)
plt.subplot(1, 2, 2)
plot_value_array(i)
```
>![1182.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/f285416e-d801-cdfd-6503-33287e514103.png)

5や8と間違えてしまったみたいです。

```python3:必要ライブラリの追加
import seaborn as sns #綺麗な用
from sklearn.metrics import confusion_matrix #混同行列作成用
```


```python3:混同行列の図示
y_pred = np.argmax(model.predict(x_test), axis=-1)
cm = confusion_matrix(y_test, y_pred) #混同行列を作る

fig, ax = plt.subplots(figsize = (10,7))
sns.heatmap(cm, annot=True, square=True, cmap='Blues', fmt="d") #図示
ax.set(xlabel = 'predict', ylabel = 'true' )
plt.show()
```
>![cm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/91a20a09-1aac-b190-a8df-23863bd25e95.png)

特に偏って間違えやすい文字はないですね。


### 自分で描いた画像を判定する

他のデータを判定するために作った分類器ですから、やっぱりこれが重要。正解率が低かったので、切り取って拡大してから判定させたものと比較しています。

```python3:必要なライブラリの追加
from PIL import Image #画像操作用
```

```python3:棒グラフの再デザイン
def plot_value_array(predictions_array):
    plt.grid(False)
    plt.xticks([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
    plt.yticks([])
    thisplot = plt.bar(range(10), predictions_array, color="#777777")
    plt.ylim([0, 1]) 
    predicted_label = np.argmax(predictions_array)

    thisplot[predicted_label].set_color('blue')
    
    plt.xlabel(f"Your figure is \"{predicted_label}\" !! ({100*np.max(predictions_array):.7f}%)")
```
同じディレクトリにmy_imageというディレクトリを作り、黒地に白で書いた画像の0.png、1.png、……、 9.pngを入れています。

```python3:一気に描画できるように設定
def predict_image(i):
    img = Image.open(f'my_image/{i}.png').convert('L') #インポート、グレースケール化
    img_resize = img.resize((28, 28)) #リサイズ(793, 793) -> (28, 28)
    img_array = np.array(img_resize) #配列化
    img_array = img_array / 255 #正規化
    imgs = img_array[np.newaxis, :, :] #(28, 28) -> (1, 28, 28)

    #予測させる
    pred = model.predict(imgs)

    #描画
    plt.figure(figsize=(20, 4))
    plt.subplot(1, 5, 1)
    plt.imshow(img, cmap='gray')
    plt.subplot(1, 5, 2)
    plt.imshow(img_resize, cmap='gray')
    plt.subplot(1, 5, 3)
    plot_value_array(pred[0])
    
    #切り取ってみる
    img = img.crop((100, 100, 693, 693)) #真ん中を切り取って拡大
    img_resize = img.resize((28, 28))
    img_array = np.array(img_resize)
    img_array = img_array / 255
    imgs = img_array[np.newaxis, :, :]
    pred = model.predict(imgs)
    
    #描画
    plt.subplot(1, 5, 4)
    plt.imshow(img_resize, cmap='gray')
    plt.subplot(1, 5, 5)
    plot_value_array(pred[0])
```
`img_array[np.newaxis, :, :]`が謎に思うかもしれません。今回の`model.predict(引数)`は、28×28画像がn個入ったリストを引数にとります。そのため、(28,28)行列を1つ並べた(1,28,28)配列を作っています。

```python3:一気に描画
for i in range(10):
    predict_image(i)
```

>![0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/d3e08623-beaf-68be-2e14-f58fb94c21fc.png)
>![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/e762ec64-05bd-72e6-0604-d13828220cdf.png)
>![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/9d55436a-c284-d7a9-caa0-674168481da5.png)
>![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/605c2b80-3a5f-b110-2eba-b9025380bd4a.png)
>![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/5559951e-c2a8-d986-dd0f-fe66943ea074.png)
>![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/125f2ab3-e1ae-c84b-3e90-749f29205448.png)
>![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/938a78d5-5116-7c71-bffa-44a049a908fc.png)
>![7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/fe59cbe3-506d-e786-b6d5-c9c89309f052.png)
>![8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/935ef982-d37c-dde4-4284-8620fcf1bb11.png)
>![9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/650522/01301502-1000-6d4a-04df-ffdea3a2ac98.png)

|png|0|1|2|3|4|5|6|7|8|9|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|拡大前|○|○|○|×|○|×|×|○|○|×|
|拡大後|×|×|○|○|×|○|×|○|○|×|

3、9をそれぞれ別の知り合いに書いてもらって、残りは自分で書きました。3、9が小さかったのかなと思い、拡大してもう一度試しています。結果、拡大によって判定が大きく変わりました。どうやらMNISTデータに見慣れていて、無意識に似た字を書いていたみたいです。


## 考察

文字の大きさや位置ズレによって判定精度が変わります。理論からわかるように、画像が1ピクセルでもずれるとFlatten層で全く違うベクトルになってしまうからです。これを克服するために、Flattenで1次元にする前に2次元のままうまく神経を繋ぐ、畳み込みニューラルネットワーク（CNN）という方法があるようです。

次回、CNNを勉強してMNISTを改良していきたいと思います。