MNIST手書き数字のCNN画像認識

MNIST手書き数字のCNN画像認識（畳み込み）は至る所にありますが、ここでは **PyTorchチュートリアル** を紹介したいと思います。
[PyTorchチュートリアル/[6] torch.nnの解説](https://yutaroogawa.github.io/pytorch_tutorials_jp/)


【関連記事】
[MNIST手書き数字のCNN画像認識 - Qiita](https://qiita.com/sand/items/2e97f3de76fcc303e91c)
[CNN 畳み込み層のメモ - Qiita](https://qiita.com/sand/items/638b5ad3e5e9d4cce713)
[Softmax+CrossEntropy の実装 - Qiita](https://qiita.com/sand/items/c107753ff4adb7aca558)
[機械学習のウォーミングアップ(Numpy) - Qiita](https://qiita.com/sand/items/e9438060005226a677ad)



# 1. Data の取得と処理


* Python の **pathlib** や **requests、pickle** を使って Data の取得・読み込みを行います。
* 次に読み込んだ Numpy配列をテンソル化します。
* 最後にTensorDataset と DataLoader で fit の繰り返し処理に対応しておきます。

```python:
# Data のダウンロード
from pathlib import Path
import requests

DATA_PATH = Path("data")
PATH = DATA_PATH / "mnist"
PATH.mkdir(parents=True, exist_ok=True)
URL = "https://github.com/pytorch/tutorials/raw/master/_static/"
FILENAME = "mnist.pkl.gz"
if not (PATH / FILENAME).exists():
        content = requests.get(URL + FILENAME).content
        (PATH / FILENAME).open("wb").write(content)

# Data の読み込み
import pickle
import gzip
with gzip.open((PATH / FILENAME).as_posix(), "rb") as f:
    ((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding="latin-1")

# Numpy配列 Data のテンソル化
import torch
x_train, y_train, x_valid, y_valid = map(
    torch.tensor, (x_train, y_train, x_valid, y_valid)
)

# TensorDataset と DataLoader で fit の繰り返し処理に対応
from torch.utils.data import TensorDataset
from torch.utils.data import DataLoader
train_ds = TensorDataset(x_train, y_train)
valid_ds = TensorDataset(x_valid, y_valid)

# train と valid の DataLoader を同時取得
def get_data(train_ds, valid_ds, bs):
    return (
        DataLoader(train_ds, batch_size=bs, shuffle=True),
        DataLoader(valid_ds, batch_size=bs * 2),
    )
```

# 2. 損失関数

損失関数 **F.cross_entropy 関数** は以下の記事における **log_softmax 関数** と **nil 関数**を組み合わせた処理になります。是非、以下の記事を参照してください。
[Softmax+CrossEntropy の実装 - Qiita](https://qiita.com/sand/items/c107753ff4adb7aca558)


```python:
import torch.nn.functional as F
loss_func = F.cross_entropy
```


# 3. fit 関数

**fit 関数** は訓練（学習）の繰り返し処理を行う部分を切り出したものです。

まず **fit 関数** を簡潔にするために、損失計算と勾配計算の部分を **loss_batch 関数** として切り出しておきます。

```python:
def loss_batch(model, loss_func, xb, yb, opt=None):
    loss = loss_func(model(xb), yb)

    if opt is not None:
        loss.backward()
        opt.step()
        opt.zero_grad()

    return loss.item(), len(xb)
```

訓練（学習）の繰り返し処理を行う **fit 関数** です。

```python:
import numpy as np

def fit(epochs, model, loss_func, opt, train_dl, valid_dl):
    for epoch in range(epochs):
        model.train()
        for xb, yb in train_dl:
            loss_batch(model, loss_func, xb, yb, opt)

        model.eval()
        with torch.no_grad():     # 訓練でない時は、計算履歴を保持しない。
            losses, nums = zip(
                *[loss_batch(model, loss_func, xb, yb) for xb, yb in valid_dl]
            )
        val_loss = np.sum(np.multiply(losses, nums)) / np.sum(nums)

        print(epoch, val_loss)
```

model.train() と model.eval() でトレーニングモードと予測モードの切り替えを行っています。モードによって処理内容が異なる Pytorch 関数 があるので必要となります。

```
[loss_batch(model, loss_func, xb, yb) for xb, yb in valid_dl]
```
上のリスト内包表記の結果は以下のようなリストになります。
$[ (loss_1, num_1), (loss_2, num_2), ...., (loss_n, num_n) ]$
但し、n は validデータの総数をバッチサイズで割った数とします。（正確にはバッチサイズの2倍で割る）。

このリストをunpackしzipして、loss と num のタプルを取得しています。loss はもともとバッチの平均値だったので、バッチサイズのnumを掛けて、その総和を求めることで、valid データ全体の loss の総和を求めています。それを valid データ全体の長さで割っています。

unpack と zip の動作は以下のテストを確認してください。タプルはそのまま numpy で処理できるようですね。
```python:
>>> import numpy as np
>>> test_list = [ (1, 11), (2, 12), (3, 13) ]
>>> x,y = zip(*test_list)
>>> x
(1, 2, 3)
>>> y
(11, 12, 13)
>>> np.multiply(x,y)
array([11, 24, 39])
```


# 4. Model の定義

ここではCNN バージョンのみ示していますが、チュートリアルでは、線形 model を試してから、 CNN model を試しています。model の定義を変更するだけで、その他の処理はそのまま使えるというストーリで記述されています。
[PyTorchチュートリアル/[6] torch.nnの解説](https://yutaroogawa.github.io/pytorch_tutorials_jp/)

::: note info
**畳み込み処理の出力の高さ・横幅**
一般的に H, W を入力の高さ・横幅、 **stride=S, padding=P** とすれば 
```
OH = (H + 2P - FH)/S + 1
OW = (W + 2P - FW)/S + 1
```

例えば、H=28, stride=2, padding=1の場合は以下の通りとなる。
```
OH = (H + 2P - FH)/S + 1 = (H+2-3)/2 +1 = (H-1)/2 + 1 =(28-1)/2 +1 = 14
```
:::

以下が model の定義です。それぞれ **畳み込み処理** と **プーリング処理** の **出力の高さ・横幅** の計算結果をコメントにしてみました。


```python:
class Mnist_CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1)  # OH = 14
        self.conv2 = nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1) # OH = 7
        self.conv3 = nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1) # OH = 4

    def forward(self, xb):
        xb = xb.view(-1, 1, 28, 28)  # [64, 1, 28, 28]
        xb = F.relu(self.conv1(xb))  # [64, 16, 14, 14]
        xb = F.relu(self.conv2(xb))  # [64, 16, 7, 7]
        xb = F.relu(self.conv3(xb))  # [64, 10, 4, 4]
        xb = F.avg_pool2d(xb, 4)     # [64, 10, 1, 1]
        return xb.view(-1, xb.size(1))  # [64, 10]
```

**F.relu** は **活性化関数 ReLU** ですが、定義は以下の記事を参照してください。
[機械学習のウォーミングアップ(Numpy) - Qiita](https://qiita.com/sand/items/e9438060005226a677ad)

最初に xb.view(-1, 1, 28, 28) で入力データを4次元に変更しています。これは CNN のデータの流れが4次元で行われることに対応しています。入力データはもともと (N, 784) の形で流れてくるので、線形model ではそのまま扱いますが、CNN model ではここで (N, 1, 28, 28) に変形してCNN のネットワークに流しています。  

この Model の特徴は線形関数を含んでいないことです。**このModelの最終目的は出力を [64, 10] にして、10個の分類を行うことです。**　そのために、線形関数の classfier を用意して調整することがあります。しかしここでは Conv2d と avg_pool2d を巧みに使って、出力データを [64, 10, 1, 1] に落とし込み、最後に view(-1, xb.size(1)) で [64, 10] にすることで 特別な classfier を用いずに済ませています。


# 5. Model のインスタンスと最適関数

Model のインスタンスと最適関数の取得を同時に行えるようにします。Modelで使用するパラメータは model.parameters() で取得できるので、それに対して最適化関数を適用します。momentum を指定することで直近の勾配地だけでなく、過去の勾配地も考慮するようにします。

```python:
lr = 0.1
def get_model():
    model = Mnist_CNN()
    return model, optim.SGD(model.parameters(), lr=lr, momentum=0.9)
```

# 6. 訓練

ここまでの準備で、実質以下の3行で訓練（学習）を行うことができるようになりました。


```python:
bs = 64  # batch size
epochs = 10

train_dl, valid_dl = get_data(train_ds, valid_ds, bs)
model, opt = get_model()
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
```

実行結果です。損失値がゼロに近づいているのがわかります。おおむね成功ですね。

```
0 0.36378984227180483
1 0.267542066693306
2 0.21404471072554587
3 0.1797706115782261
4 0.19904899366497994
5 0.15275968505442142
6 0.1643963666319847
7 0.15317361530661583
8 0.13119296333193778
9 0.13225660784840584
```

今回は以上です