見出し画像

PyTorch深層学習⑪単純な画像分類1

前回は、ニューラルネットワークの一般的な枠組みを解説しました。今回は、ニューラルネットワークを使って畳み込み層を使わない簡単な画像分類を試みます。

使うデータはMNISTです。手書きの数字を集めたデータセットになります。以前にデータセット編でも扱いました。PyTorchの torchvision からデータを読み込むことができるので便利です。

では、さっそく始めましょう。


Python環境の設定

Pythonの仮想環境を作ってPyTorchとJupyterなどをインストールします。

mkdir neural_network
cd neural_network

python3 -m venv venv
source venv/bin/activate

# pip をアップグレードしておく
pip install --upgrade pip

# 必要なライブラリをインストール
pip install torch torchvision matplotlib jupyter

いつものようにJupyterノートブックを立ち上げてPython3のノートブックを作成してください。

データの準備

データセット

まずは、必要なライブラリをインポートします。

import numpy as np
import torch
import torch.nn as nn
import matplotlib.pyplot as plt

次に、torchvision からMNISTの訓練用とテスト用のデータセットを読み込みます。この辺は、データセット編でも詳しく解説してあります。

from torchvision import datasets, transforms

# Load the MNIST train dataset
train_data = datasets.MNIST(
    root = "data",
    train = True,
    download = True,
    transform = transforms.ToTensor()
)

# Load the MNIST test dataset
test_data = datasets.MNIST(
    root = "data",
    train = False,
    download = True,
    transform = transforms.ToTensor()
)

データの数を確認します。

len(train_data), len(test_data)

訓練用画像が6万、テスト用が1万個あります。

ちなみに、初めてMNISTデータセットのオブジェクトを作成すると以下のようなフォルダに画像データがダウンロードされます。

よって次回以降はローカルのフォルダからロードするので立ち上がりのスピードが速くなります。

MNIST画像

MNISTの画像の性質について見ていきましょう。

image, label = train_data[0]

print("データ型:", image.dtype)
print("シェイプ:", image.shape)
print("ラベル  :", label)

データ型は32ビットの浮動小数点型で、シェイプは1x28x28です。モノクロなのでチャンネルが一つしかありません。

ラベルは5になっています。画像を表示して確認しましょう。

plt.imshow(image.squeeze(), cmap='gray')
plt.show()

これが5ですか。まあ、他の数字には見えないので妥当でしょうか。このようにMNISTは手書きの画像を集めたものなので判別がつきにくいものが多数見受けられます。画像分類のデータセットとしてはチャレンジしがいのあるものになっています。

データローダー

今回はたくさんのデータを扱うのでデータローダーを使ってバッチを作ることにします。

from torch.utils.data import DataLoader

# 再現性のために、乱数シードを固定
torch.manual_seed(123)

# データローダの作成
train_loader = DataLoader(train_data, batch_size=32, shuffle=True)
test_loader = DataLoader(test_data, batch_size=32)

訓練用のデータローダ(train_loader)では shuffle=True を指定して画像の順番をランダムに入れ替えています。なお、再現性を確保するために乱数のシードを固定しています。

バッチサイズは32なので、一つのバッチに32個の画像とラベルが入ることになります。

では、最初のバッチを取り出して画像を表示します。

from torchvision.utils import make_grid

images, labels = next(iter(train_loader))

image_grid = make_grid(images, nrow=8)

plt.imshow(image_grid.permute(1, 2, 0), cmap='gray')
plt.show()

だいぶ変形した数字も見受けられますが、人間の目ではだいたい想像がつくのがわかります。これをモデルが判断できるように訓練していきます。

画像分類

モデル定義

画像分類を行うモデルを次のようなニューラルネットワークとして定義しました。

import torch.nn.functional as F


class ImageClassifier(nn.Module):
    
    def __init__(self):
        super().__init__()
        
        # Initialize layers
        self.linear1 = nn.Linear(784, 128)
        self.linear2 = nn.Linear(128, 10)
        
    def forward(self, x):
        # First linear layer
        x = self.linear1(x)
        
        # ReLU non-linearity
        x = F.relu(x)
        
        # Second linear layer
        x = self.linear2(x)

        return x

まず、二つの線形層(linear1、linear2)を定義しています。その間に活性化関数としてReLUを使っています。最終的に出力されるのはロジットになります。ロジットという名前はロジスティック回帰の理論編でも登場しましたが、ここでのロジットはちょっと意味が異なります。これは後述します。

一つ注意点として、最初の線形層の入力が 784 になっています。これは2次元である画像データを 28x28=784 と1次元のテンソルに変換して扱うからです。

後で出てきますが、次のようなコードを使って平坦化(フラット)にします。

x = image.flatten(start_dim=1)

x.shape

ソフトマックス

モデルの最後の線形層は10個の値を出力します。これが0から9までの数字に対するロジットになります。

$$
\boldsymbol{z} = [z_0, z_1, z_2, z_3, z_4, z_5, z_6, z_7, z_8, z_9]
$$

例えば、こんな感じです。

z = [1.2, -1.5, 0.01, 5.7, -0.3, 2.0, 3.8, 2.95, -0.7, 0.7]

これをソフトマックス関数に与えると、確率となります。

F.softmax(torch.FloatTensor(z))

出力は以下になります。

tensor([8.7864e-03, 5.9050e-04, 2.6730e-03, 7.9093e-01, 1.9605e-03, 
        1.9555e-02,1.1830e-01, 5.0563e-02, 1.3142e-03, 5.3292e-03])

分かりにくいので小数点3までに四捨五入すると、

0.009   # 0の確率
0.001   # 1の確率
0.003   # 2の確率
0.791   # 3の確率
0.002   # 4の確率
0.020   # 5の確率
0.118   # 6の確率
0.051   # 7の確率
0.001   # 8の確率
0.005   # 9の確率

よって、この場合は3が最も確率が高くなります。

予想された確率を次のように表記します。

$$
\hat{\boldsymbol{p}} = [ \hat{p}_0, \hat{p}_1, \hat{p}_2, \hat{p}_3, \hat{p}_4, \hat{p}_5, \hat{p}_6, \hat{p}_7, \hat{p}_8, \hat{p}_9] \\
 \\
\quad \hat{p}_k \in [0, 1], \sum \hat{p}_k = 1
$$

$${y_k \in [0, 1]}$$は$${p_k}$$は0から1までの値をとることができるという意味です。例えば、0.1 とか 0.34 など。

また、$${\sum \hat{p}_k = 1}$$は確率の合計は必ず1になる必要があるという意味です。

上記のように各数字に対する確率の予測は、ソフトマックスにロジットを与えることで計算できます。ソフトマックスは、ロジットの各値を指数関数に与えた後に標準化を行います。

$$
e^{\boldsymbol{z}} = [ e^{z_0}, e^{z_1}, e^{z_2}, e^{z_3}, e^{z_4}, e^{z_5}, e^{z_6}, e^{z_7}, e^{z_8}, e^{z_9} ]
$$

指数関数は常に0以上の値になるので、次のように標準化することで予測された確率$${\hat{p}_k}$$は0以上1以下の数値となります。

$$
\hat{p}_k = \dfrac{e^{z_k}}{\sum\limits_{l=0}^9 e^{z_l}}
$$

さて、このロジットは、ロジスティック回帰で登場したロジットのようにオッズの対数という意味はありません。そのような解釈は二項分類の場合のみ可能です。それでもロジットという名前を使うのが慣用となっています。

ここでのロジットはネットワークが出力した値で大きい値ほどそのクラスの可能性が高くなるものという解釈にとどまります。ただし、負の値を取ることもあるので、指数関数で一旦正の値にしてから標準化しています。

カテゴリ分布

MNISTの画像に対して予測する10個の確率はカテゴリ分布になります。カテゴリ分布とは、10個ある数字からどれか一つに決まる確率を意味します。

$$
\hat{\boldsymbol{p}} = [ \hat{p}_0, \hat{p}_1, \hat{p}_2, \hat{p}_3, \hat{p}_4, \hat{p}_5, \hat{p}_6, \hat{p}_7, \hat{p}_8, \hat{p}_9] \\
 \\
\quad y_k \in [0, 1], \sum \hat{p}_k = 1
$$

つまり、ネットワークはカテゴリ分布の予測をしていることになります。この分布を使うと正解ラベルに対する予想確率は次のように表現できます。これをカテゴリ確率と呼ぶことにします。

$$
\text{カテゴリ確率}(\hat{\boldsymbol{p}}, \boldsymbol{y}) = \hat{p}_0^{y_0} \, \hat{p}_1^{y_1} \, \hat{p}_2^{y_2} \, \hat{p}_3^{y_3} \, \hat{p}_4^{y_4} \, \hat{p}_5^{y_5} \, \hat{p}_6^{y_6} \, \hat{p}_7^{y_7} \, \hat{p}_8^{y_8} \, \hat{p}_9^{y_9} = \prod\limits_{k=0}^9 \hat{p}_k^{y_k}
$$

ここで$${\boldsymbol{y}}$$はラベルの定義で次のようになっています。

$$
\boldsymbol{y} = [ y_0,\, y_1,\, y_2,\, y_3,\, y_4,\, y_5,\, y_6,\, y_7,\, y_8,\, y_9 ] \\
 \\
\quad y_k \in \{0, 1\}, \sum y_k = 1
$$

$${y_k \in \{0, 1\}}$$は$${y_k}$$は0か1(のどちらか)の値をとることができるという意味です。また、$${\sum y_k = 1}$$は合計は必ず1になるということなので、どれか一つの$${y_k}$$のみが1となり、あとは全て0になるのでこれは当然ではあります。

つまりは、どれが正解の数字なのかを表しています。これをワンホットエンコーディング(One-hot encoding)とも呼びます。

例えば、正解の数字が3であれば、$${y_3 = 1}$$なので、次のようになります。

$$
\boldsymbol{y} = [ 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 ]
$$

よって、正解の数字が3である確率は、$${y_3 = 1}$$で、それ以外の$${y_k = 0 \ (k \ne 3)}$$なので、次のようになります。

$$
\begin{aligned}
&\text{カテゴリ確率}(\hat{\boldsymbol{p}}, \boldsymbol{y} =[ 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 ]) \\
&= \hat{p}_0^{y_0} \hat{p}_1^{y_1} \hat{p}_2^{y_2} \hat{p}_3^{y_3} \hat{p}_4^{y_4} \hat{p}_5^{y_5} \hat{p}_6^{y_6} \hat{p}_7^{y_7} \hat{p}_8^{y_8} \hat{p}_9^{y_9} \\
&= \hat{p}_0^{0}  \ \hat{p}_1^{0} \ \hat{p}_2^{0} \ \hat{p}_3^{1} \ \hat{p}_4^{0} \ \hat{p}_5^{0} \ \hat{p}_6^{0} \ \hat{p}_7^{0} \ \hat{p}_8^{0} \ \hat{p}_9^{0} \\
&= \hat{p}_3
\end{aligned}
$$

よって、モデルを訓練する際に、正解の数字に対してカテゴリ確率ができるだけ100%に近づくようにできれば良いことになります。

クロスエントロピー

カテゴリ確率が高くなると損失値が小さくなる損失関数があればニューラルネットワークを訓練することができます。そのためにカテゴリ確率に少々の変更を加えます。

まず、カテゴリ確率は確率の掛け算になっているので、対数をとって足し算に変換します。

$$
\begin{aligned}
\log \text{カテゴリ確率}(\hat{\boldsymbol{p}}, \boldsymbol{y}) &= \log \prod\limits_{k=0}^9 \hat{p}_k^{y_k} \\
&= \sum\limits_{k=0}^9 \log \hat{p}_k^{y_k} \\
&= \sum\limits_{k=0}^9 y_k \log \hat{p}_k
\end{aligned}
$$

後述しますが、こうすると複数の画像に対するカテゴリ確率を使った計算が簡単になります。

カテゴリ確率の対数が大きければ大きいほど予測の精度が高くなります。しかし、学習の過程では損失関数はより小さくするように訓練を行うので、マイナスをつけます。

$$
- \log \text{カテゴリ確率}(\hat{\boldsymbol{p}}, \boldsymbol{y}) = - \sum\limits_{k=0}^9 y_k \log \hat{p}_k
$$

負のカテゴリ確率の対数をバッチ内の全ての画像に対し計算して平均を計算します。この値が小さいほど全ての画像に対するカテゴリ確率が高くなるので、これを損失関数としクロスエントロピー(交差エントロピー、Cross-Entropy)と呼びます。

$$
\begin{aligned}
\text{クロスエントロピー} &= -\dfrac{1}{N} \sum\limits_{i=1}^N \log \text{カテゴリ確率}(\hat{\boldsymbol{p}}^{(i)}, \boldsymbol{y}^{(i)}) \\
&= -\dfrac{1}{N} \sum\limits_{i=1}^N \sum\limits_{k=0}^9 y_k^{(i)} \log {\hat{p}_k^{(i)}}
\end{aligned}
$$

ここで$${N}$$はバッチ内の画像の数です。また、$${i}$$はバッチ内の画像の番号を意味します。よって、$${\hat{\boldsymbol{p}}^{(i)}}$$は、$${i}$$番目の画像に対するカテゴリ分布を意味します。

このような計算ができるのは確率の対数を使っているおかげです。

例えば、バッチの中に画像が3つあるケースを考えます。正解のラベルが [2, 7, 4] だったとします。するとカテゴリ確率は、それぞれが$${p_2^{(1)}, p_7^{(2)}, p_4^{(3)}}$$となります。つまり、モデルはこのバッチに関して3つの正解のラベルが起こる確率を$${p_2^{(1)} p_7^{(2)} p_4^{(3)}}$$と予測していることになります。この値が大きいほど、バッチ内の画像に対する予測がより正しいことになります。

ただし、このままだと確率の積が非常に小さい値になったりして数値計算が不安定になる可能性があります。特に、バッチ内の画像の数が多いほど問題になりやすいです。これはコンピューターには数値を扱う際の精度に限界があるからです。あまりにも小さい値は0とみなされます。よって、対数を取ることを考えます。

$$
\log p_2^{(1)} p_7^{(2)} p_4^{(3)} = \log p_2^{(1)} + \log  p_7^{(2)} + \log  p_4^{(3)}
$$

このようにしても予想確率の対数が大きいほど予想がより正しくなります。よって数値計算の精度の問題も解決できて都合が良いです。また、各画像に対する予測を計算して平均を計算したりできるので便利です。以上が、対数を使う理由です。

なお、クロスエントロピーについてはエントロピーの観点から理解することもできます。

そこでは、正解の数字を意味する$${\boldsymbol{y}}$$を正解のカテゴリ分布と捉えます。つまり、正解の数字は100%であとは0%のカテゴリ分布です。これをカテゴリ分布の予想である$${\boldsymbol{\hat{p}}}$$と比較します。以上を踏まえて、正解のカテゴリ分布と予想のカテゴリ分布の差を表現したものがクロスエントロピーになると考えます。詳しくは、交差エントロピーの記事で解説しています。

PyTorchでは nn.CrossEntropyLoss を使うことでクロスエントロピーによる損失値の計算をすることができます。

loss_function = nn.CrossEntropyLoss()

例えば、このように使います。

loss = loss_function(z, y)

ここで、z と y はそれぞれロジットと正解の数字です。なお、訓練中はバッチを使うので、z と y もバッチになっています。

したがって、すべてのクラスの確率を計算したり、正解のワンホットエンコーディングを作成する必要はありません。nn.CrossEntropyLossが全ての詳細を効率的に処理してくれます。

この損失関数がどのように使用されるかについては、後で訓練ループと共に解説します。

アダム・オプティマイザ

今回、オプティマイザとして アダム(Adam)オプティマイザーを使用します。

    # Adam オプティマイザ
    learning_rate = 0.0001
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

Adam は、各パラメーターの学習率を個別に自動調整できます。 したがって、多くのパラメーターを持つモデルをトレーニングするのに適しています。逆に、SGDのように単一の固定された学習率を使うオプティマイザだと、パラメータが多く場合にはすべてのパラメーターに対して適切に機能しない可能性があります。

デバイス

今回のモデルには最適化すべきパラメーターが多数あるため、トレーニングを高速化するために利用可能なハードウェア アクセラレーション(計算を高速化するためのデバイスやソフトウェア)を利用する必要があります。

そのようなデバイスがない場合は、CPU を使用する必要があります。 これは、何も指定しない場合のデフォルトのデバイスでもあります。

NVIDIA(エヌビディア)のGPU(Graphical Processing Unit、グラフィック処理装置)を搭載したコンピューターがある場合、PyTorch は CUDA(クーダ)ライブラリを通じて並列コンピューティングを利用できます。 そのために、CUDA のデバイス オブジェクトを作成します。NVIDIAのGPUは元々はゲームの画像処理を高速化するために開発されたものですが、現在はAIの学習で頻繁に使われるようになっています。

Apple シリコンを搭載したコンピューターがある場合、PyTorch は Metal Performance Shaders(MPS)を通じて GPU パワーを活用できます。 これを行うには、MPS のデバイス オブジェクトを作成します。MPSもAppleのコンピュータ上での画像処理を高速化するものですが、PyTorchではAIの学習でも使えるようになっています。

デバイスをプリントして、どのデバイスが選択されているかを確認できます。

# Device
device = torch.device("cpu")

if torch.cuda.is_available():    
    device = torch.device("cuda")
elif torch.backends.mps.is_available():
    device = torch.device("mps")

print("Device:", device)

たとえば、私は Apple シリコンのコンピュータを使用しているので Device: mps とプリントされます。

計算でデバイスを活用できるように、モデル、入力データ、およびラベル データにデバイスを指定します。以下はデバイスを指定するコード例です。

# モデルにデバイスを指定
model = ImageClassifier()
model.to(device)
# 入力データにデバイスを指定
x = images.flatten(start_dim=1)
x = x.to(device)
# ラベルデータにデバイスを指定
y = labels.to(device)

こうすることで、計算が指定されたデバイス上で行われるようになります。なお、同じ計算で使われるモデルとデータは同じデバイスを使う必要があります。

トレーニングで例を見てみましょう。

訓練ループ

訓練と評価のモード

訓練ループでは、訓練用のデータセットを使用してモデルを訓練し、テスト 用のデータセットを使用してモデルの予測の精度を評価します。

各エポックでは、モデルを訓練用のモード、評価用のモードに交互に設定します。

for epoch in range(epochs):

    #--------------------
    # 訓練モード
    #--------------------
    model.train()

    ... モデルを訓練する

    #--------------------
    # 評価(テスト)モード
    #--------------------
    model.eval()

    ... モデルを評価する

訓練用モードではモデルを訓練するので、パラメータが更新されます。評価用モードではパラメータは更新せずに精度を測ります。

なお、それぞれのモードは、モジュールや関数に応じて動作に影響を与える場合もあれば、影響しない場合もあります。

たとえば、訓練中に活性化値(アクティベーション)をランダムにゼロに設定するドロップアウト(Dropout)層を使用するとします。これは、訓練用データに過剰適合(オーバーフィット、Overfit)する可能性が低くなるようにするためです。ただし、評価モードでは、ドロップアウト層は無視されるため、すべての活性化値が利用されます。

もう 1 つの例として、訓練中のバッチ間の活性化値の平均と分散を計算するバッチ正規化があります。これらの統計は活性化値を正規化するために使用されます。 バッチ正規化を使用する理由は、各バッチが異なる大きさの活性化値を生成するためです。活性化値が大きく変動すると、勾配が大幅に変化し、モデルの訓練が不安定になる可能性があります。評価モードでは、バッチ正規化によって統計が更新されません。これを行うと、テスト用のデータから訓練への情報漏洩が発生する可能性があるからです。

なお、バッチ正規化の詳細はこちらで解説しています。

今回は、ドロップアウトもバッチ正規化も使用していません。 したがって、評価モードは何も影響しません。ただし、将来行う訓練においてドロップアウトが発生したり、バッチ正規化によってテスト用のデータセットの情報が誤用されてパフォーマンスが向上することに気付かずにこれらのモジュールをモデル定義に追加してしまう可能性があるため、これらのモードは常に適切に設定することを勧めます。

次に訓練と評価の実装を解説します。

訓練の実装

訓練の実装は以下になります。これまでと同様に基本的なニューラルネットワークの訓練の流れに従った実装になっています。

        #--------------------
        # 訓練モード
        #--------------------
        model.train()

        losses = []
        for images, labels in train_loader:

            # 入力データを平坦化してデバイスを指定
            x = images.flatten(start_dim=1)
            x = x.to(device)

            # ラベルのデバイスを指定
            y = labels.to(device)

            # フィード・フォワード
            z = model(x)

            # 損失値の計算と誤差逆伝播
            optimizer.zero_grad() # 勾配をクリア
            loss = loss_function(z, y)
            loss.backward()
            optimizer.step() #パラメータの更新

            # 損失値のリストに追加(後で表示用の平均を計算する)
            losses.append(loss.item())

順に解説します。

まず、バッチ毎の損失値の値をリストにするための変数を用意します。後で損失値の平均を計算するのに使います。

losses = []

データローダを使って画像とラベルのバッチを取り出しバッチ毎に訓練(パラメータの更新)を行います。

for images, labels in train_loader:

    ...

入力データを平坦化した後、入力データとラベルデータのデバイスを指定します。

for images, labels in train_loader:

    # 入力データを平坦化してデバイスを指定
    x = images.flatten(start_dim=1)
    x = x.to(device)

    # ラベルのデバイスを指定
    y = labels.to(device)

入力データをモデルに渡すとロジットを出力します。

    # フィード・フォワード
    z = model(x)

クロスエントロピーの損失関数で損失値を計算し、オプティマイザを使ってパラメータを更新します。

    # 損失値の計算と誤差逆伝播
    optimizer.zero_grad() # 勾配をクリア
    loss = loss_function(z, y)
    loss.backward()
    optimizer.step() #パラメータの更新

    # 損失値のリストに追加(後で表示用の平均を計算する)
    losses.append(loss.item())

最後に、損失値をリストに加えています。

評価の実装

次に評価の実装になります。

        #--------------------
        # 評価(テスト)モード
        #--------------------
        model.eval()

        accuracies = []
        for images, labels in test_loader:

            # 入力データを平坦化してデバイスを指定
            x = images.flatten(start_dim=1)
            x = x.to(device)

            # ラベルのデバイスを指定
            y = labels.to(device)

            # フィード・フォワード
            with torch.no_grad():
                z = model(x)

            # 0から9の確率を計算
            p_hat = F.softmax(z, dim=1)

            # 一番確率の高い数字を選ぶ
            y_hat = torch.argmax(p_hat, dim=1)

            # 正解率の計算(後で表示用に平均を計算する)
            accuracy = torch.sum(y_hat == y) / len(y) * 100
            accuracies.append(accuracy.item())

正解率のリスト accuracies を準備します。そして、入力データとラベルデータの処理は訓練の時と同じです。

        accuracies = []
        for images, labels in test_loader:

            # 入力データを平坦化してデバイスを指定
            x = images.flatten(start_dim=1)
            x = x.to(device)

            # ラベルのデバイスを指定
            y = labels.to(device)

フィード・フォワードに関しては勾配に関連するデータを使う必要がないことを with torch.no_grad(): で指示しています。これによって無駄な処理を省くことができます。

            # フィード・フォワード
            with torch.no_grad():
                z = model(x)

次に、ソフトマックスを使って各画像のロジットに対して0から9の確率(カテゴリ分布)を計算します。

            # 0から9の確率を計算
            p_hat = F.softmax(z, dim=1)

さらに、torch.argmax で各画像に対して一番確率の高い数字を選びます。

            # 一番確率の高い数字を選ぶ
            y_hat = torch.argmax(p_hat, dim=1)

これが各画像に対して予想された数字になります。

最後に、正解率を計算します。

            # 正解率の計算(後で表示用に平均を計算する)
            accuracy = torch.sum(y_hat == y) / len(y) * 100
            accuracies.append(accuracy.item())

正解率は、正解した数 torch.sum(y_hat == y) をバッチ内の画像・ラベルの数 len(y) で割って 100 をかけたものです。正解率はリストに追加しておきます。

実装のまとめ

以下は、全てを train_model という関数としてまとめたものです。

def train_model(model: nn.Module, device: torch.device):
    # クロスエントロピー損失関数
    loss_function = nn.CrossEntropyLoss()

    # Adam オプティマイザ
    learning_rate = 0.0001
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

    # 訓練ループ
    epochs = 10

    for epoch in range(epochs):

        #--------------------
        # 訓練モード
        #--------------------
        model.train()

        losses = []
        for images, labels in train_loader:
            optimizer.zero_grad()

            # 入力データを平坦化してデバイスを指定
            x = images.flatten(start_dim=1)
            x = x.to(device)

            # ラベルのデバイスを指定
            y = labels.to(device)

            # フィード・フォワード
            z = model(x)

            # 損失値の計算と誤差逆伝播
            loss = loss_function(z, y)
            loss.backward()
            
            # 損失値のリストに追加(後で表示用の平均を計算する)
            losses.append(loss.item())

            # パラメータの更新
            optimizer.step()

        # 平均の損失値を計算(表示用)
        avg_loss = np.mean(losses)

        #--------------------
        # 評価(テスト)モード
        #--------------------
        model.eval()

        accuracies = []
        for images, labels in test_loader:
            # 入力データを平坦化してデバイスを指定
            x = images.flatten(start_dim=1)
            x = x.to(device)

            # ラベルのデバイスを指定
            y = labels.to(device)

            # フィード・フォワード
            with torch.no_grad():
                z = model(x)

            # 0から9の確率を計算
            p_hat = F.softmax(z, dim=1)

            # 一番確率の高い数字を選ぶ
            y_hat = torch.argmax(p_hat, dim=1)

            # 正解率の計算(後で表示用に平均を計算する)
            accuracy = torch.sum(y_hat == y) / len(y) * 100
            accuracies.append(accuracy.item())

        # 損失値の平均
        avg_loss = np.mean(losses)

        # 正解率の平均
        avg_accuracy = np.mean(accuracies)

        # エポック毎に損失値と正解率の平均を表示
        print(f'エポック={epoch:2d} 平均損失値={avg_loss:5.2f} 平均正解率={avg_accuracy:6.2f}%')

最後に、損失値の平均と正解率の平均を計算してプリントしています。

以上で実験する準備が整いました。

実行結果

訓練の実行

訓練は次のように行います。

# デバイスの指定
device = torch.device("cpu")
if torch.cuda.is_available():    
    device = torch.device("cuda")
elif torch.backends.mps.is_available():
    device = torch.device("mps")

print("Device:", device)

   
# 再現性の確保
torch.manual_seed(123)

# モデル作成
model = ImageClassifier()
model.to(device)

# 訓練を行う
train_model(model, device)

まずは、デバイスを設定します。

その次に再現性を確保するために乱数のシードを指定しました。ニューラルネットワークのパラメータは初期化の際にランダムに設定されるので、乱数のシードを固定しておくと毎回同じランダム値になります。こうすることで、このセルを何度実行しても同じ結果になるように配慮したものです。

モデルにデバイスを指定してから、訓練の関数を呼び出します。

訓練の結果を見ると正解率が95.96%とまずまずの成績になっています。

画像分類の評価

テストを行う理由

訓練し終わったモデルは、訓練データだけでなく、テストデータで評価することが必要です。なぜなら、モデルは訓練データから学習しているので、高い正解率が出たとしたも、訓練中に見たことのないデータでも同様の成果が出せるのかを確認する必要があるからです。

テストデータは訓練データと同じではないとはいえ、同様の性質を持ったデータセットです。よって訓練がうまくいったのならば、モデルはテストデータでも高い精度を出せるはずです。しかし、モデルが訓練データに過剰適合していると訓練データでは成績が良くともテストデータでは成績が悪いということがあります。テストデータで評価を行うのは、過剰適合がないことを確認する意味もあります。

本当は、データセットを訓練用、訓練中の評価用、訓練後の評価用の3つに分けるのが望ましいです。例えば、訓練用をランダムにシャッフルしてから、80%と20%に分けて訓練と訓練中の評価に使い、残りのテストセットを訓練後の評価に使うなどが考えられます。

なぜなら、訓練中の評価を見ながらハイパーパラメータなどを調節するとどうしても訓練中の評価用データセットの情報に合わせてしまいがちだからです。よって、訓練中には全く使われないデータセットを別にテスト用として用意しておくのがベストです。

しかし、今回は訓練後のテスト用に別のデータセットは用意していないので、訓練でも使われたテストセットを使いながら解説を続けます。

テスト画像とラベル

それでは、バッチを取り出してテスト画像を表示してみます。

from torchvision.utils import make_grid

images, labels = next(iter(test_loader))

image_grid = make_grid(images, nrow=8)

plt.imshow(image_grid.permute(1, 2, 0), cmap='gray')
plt.show()
テストデータのサンプル

こうしてみると訓練データと同様な画像であるのがわかります。手書き数字のデータセットなので微妙に異なる画像になります。

ラベルを表示します。

labels

ラベルの数字と画像とが一致しているのがわかります。

テストの実行

テストの実行は訓練中の評価の実装と同じです。本来ならデータが別の評価用データセットになっているのが理想です。ここではバッチを一つ使って正解率を計算しました。

model.eval()

x = images.flatten(start_dim=1)
x = x.to(device)
y = labels.to(device)

with torch.no_grad():
    z = model(x)
    
p_hat = F.softmax(z, dim=1)
y_hat = torch.argmax(p_hat, dim=1)

test_accuracy = torch.sum(y_hat == y) / len(y) * 100

print(f'テストの正解率={test_accuracy:6.2f}%')
print(f'予想ラベル:\n{y_hat}')

正解ラベルと比べると9番目の数字は正解が5であるのに対して、予測が6になって間違っています。ただし、画像をよくみるとかなり殴り書きになっているので判断が難しいのがわかります。ただし、人間なら5と読み取るでしょう。

ちなみに、上記で予想ラベルの表示をしていますが、device='mps:0' となっているのは私のマシンが Appleシリコンを搭載しており、MPSをデバイスとして使用しているからです。

なお、次のようにするとデバイス上のデータをCPU上のNumPyオブジェクトにすることができます。

y_hat.cpu().numpy()

あと、上記のコードでは確率を計算してから一番確率の高い数字を選んでいますが、実際にはソフトマックスで確率を計算しなくとも、ロジットが一番大きな数字を選んでも結果は同じになります。

torch.argmax(z, dim=1)

次回予告

次回は、モデルに層を追加して正解率を上げられるかどうかを実験します。また、PyTorchを使ってニューラルネットワークを実装する際のやり方がいくつかあるので、その紹介もしていきます。

お楽しみに!

いいなと思ったら応援しよう!