Chapter 05

満を持して MNIST に挑戦

ろぐみ
ろぐみ
2023.02.05に更新

ついに MNIST

前の章でなんとなく PyTorch による Deep Learning の流れを知りました。
今回は、実際に MNIST という手書き数字のデータセットを解いてみます。

MNIST はよく Deep Learning の Hello World と言われるデータセットです。

MNIST とは

MNIST は、手書き数字のデータセットです。
0 から 9 までの数字の画像が 70,000 枚あります。
そのうち、60,000 枚が学習用、10,000 枚がテスト用になっています。
Wikipediaより引用
MNIST の中身 (Wikipediaより引用)

MNIST で Deep Learning

今回はこの MNIST データセットを使って、Deep Learning により、手書き数字を認識するモデルを作ってみます。

データセットの準備

まずは、MNIST データセットを用意しましょう。

import torch
from torchvision import datasets, transforms

# バッチサイズ
BATCH_SIZE = 64

# 画像データの変換方法を指定
transform = transforms.Compose([
    transforms.ToTensor(),        # テンソルに変換 & 0-255 の値を 0-1 に変換
])

# MNIST を取得
train_dataset = datasets.MNIST(
    root='./data',        # データを保存するディレクトリ
    train=True,           # 学習用データを取得
    download=True,        # データがない場合はダウンロードする
    transform=transform,  # 画像データの変換方法を指定
)

# テスト用のデータを取得
test_dataset = datasets.MNIST(
    root='./data',
    train=False,
    download=True,
    transform=transform,
)

# データローダーの作成
train_loader = torch.utils.data.DataLoader(
    train_dataset,          # データセット
    batch_size=BATCH_SIZE,  # バッチサイズを指定
    shuffle=True,           # シャッフルする
)

test_loader = torch.utils.data.DataLoader(
    test_dataset,
    batch_size=BATCH_SIZE,
    shuffle=True,
)

# 入力データの Shape と中身を確認
print(train_dataset[0][0].shape)
print(train_dataset[0])
torch.Size([1, 28, 28])
(tensor([[[0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
...

画像データをそのまま PyTorch で扱って学習をさせるのはやや困難です。
そのため、画像データをテンソルに変換し、0-255 の値を 0-1 に変換しています。

通常機械学習を行う場合、生のデータをそのまま扱うことはほとんどありません。
このようにデータを前処理することで、学習を行いやすくします。

今回は torchvision というライブラリがデータセットの取得から変換までを行ってくれるのでありがたく使用します。

モデルの作成

次にモデルを作成します。
今回は、前の章よりもより柔軟なモデルにするため、クラスを用いてモデルを作成します。

前のセクションで入力されるデータの形が (1, 28, 28) であることを確認しました。
1の部分はチャンネル数を表しています。MNIST はグレースケール画像なのでチャンネル数は 1 です。(フルカラーなら RGB なので 3 になります)
28 はどちらも画像の縦横のピクセル数です。
28x28 の画像を入力することになります。

MNIST は 0 から 9 までの数字を分類する問題なので、出力は 10 次元のベクトルになります。

import torch.nn as nn

class MNISTModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(28 * 28, 512),
            nn.ReLU(),
            nn.Linear(512, 256),
            nn.ReLU(),
            nn.Linear(256, 10),
            nn.LogSoftmax(dim=1),
        )

    def forward(self, x: torch.Tensor):
        x = x.view(-1, 28 * 28)
        x = self.model(x)
        return x

またしても3層の Linear を用いてモデルを作成します。

図にするとこんな感じです。

縦長長方形の中の数字は次元数を表しています。
28*28 = 784 -> 512 -> 256 -> 10 と次元数が減っていきます。

クラスを用いてモデルを定義する場合、 nn.Module を継承したクラスを作成し、__init__ でモデルの構造を定義します。
今回は前回と異なり、活性化関数に nn.ReLU を用いています。

活性化関数 ReLU

ReLU はこのように、入力が 0 以下の場合は 0 を出力し、0 より大きい場合はそのまま出力します。

数式にすると以下のようになります。(例によって隠しておきます)

数式を表示
\text{ReLU}(x) = \begin{cases} x & (x \gt 0) \\ 0 & (x \le 0) \end{cases}

または1行で書くと

\text{ReLU}(x) = \max(0, x)

この関数は線形に見えるかもしれませんが、x=0 の部分でなんか尖っていて線形ではないですね。
とても単純な関数ですが、非線形な活性化関数として非常によく使われます。

LogSoftmax

Softmax は入力された値を正規化する関数で、以下のように定義されます。(例によって隠しておくので興味のある方は見てみてください)

数式を表示
\text{Softmax}(x_i) = \frac{\exp(x_i)}{\sum_{j=1}^n \exp(x_j)}

どういうものかというと、入力された値を全て正の値に変換し、合計が 1 になるように正規化します。
ちょうど確率値みたいなものが出せるわけですね。

LogSoftmax は Softmax に対して対数をとったものです。

数式を表示
\text{LogSoftmax}(x_i) = \log \frac{\exp(x_i)}{\sum_{j=1}^n \exp(x_j)}

ただの Softmax ではなく、LogSoftmax を使うのは、この方が計算が安定するためです。

うっかり数式を表示してしまった方は気づくかもしれませんが、単純な Softmax だと \exp(x_i) が大きくなりすぎてオーバーフローしてしまう可能性があります。
LogSoftmax にすることで、Softmax の計算を \log で置き換えることで、オーバーフローを防ぐことができます。

より詳しくは こちら を参照してください。英語かつ数学ですが。

forward

forward は順方向の計算、つまり入力データをどのようにモデルに通していくかを定義します。

今回は x.view(-1, 28 * 28) という処理を行っています。
画像のデータが 28x28 の 2 次元のデータなので、1 次元のデータに変換するために view を用いています。
-1 を指定することで、他の次元の値から自動的に計算してくれます。

このようにして1次元にしたデータを __init__ で定義したモデルに通して return を行っています。

モデルの学習

それでは先ほど作成したモデルを使って学習させていきましょう

from tqdm import tqdm

model = MNISTModel()

criterion = nn.NLLLoss()

optimizer = torch.optim.Adam(model.parameters(), lr=0.005)

for epoch in range(10):
    total_loss = 0
    for images, labels in tqdm(train_loader):
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    # loss は平均を取って表示する
    print(f'Epoch: {epoch + 1}, Loss: {total_loss / len(train_loader)}')

tqdm はなんかいい感じにプログレスバーを表示してくれるライブラリです。

criterion は例によって損失関数を定義しています。
今回は NLLLoss を使っています。
NLL とは Negative Log Likelihood の略で、負の対数尤度を表します。

・・・といってもややこしいのですが、ここではモデルの最終出力が LogSoftmax の nクラス分類なので、NLLLoss を使って損失を計算するんだと思ってください。

optimizer は例によって最適化アルゴリズムを定義しています。
今回も Adam を使っています。
lr=0.005 と xor のときより小さな学習率を指定しています。
これは xor のときよりもデータ数が圧倒的に多く、1つのデータに大きく引っ張られると学習がうまく進まない可能性がありそうだと判断したためです。

なんてそれっぽいこと言ってますが、ぶっちゃけ勘もあるので、実際にやってみて学習率を調整していくのが良いと思います。

学習率の設定って実は難しい

学習率の大小を間違うと、次で説明するようなことが起きます。

例えば以下の図でより低い Loss を目指して●を移動させることを考えてみます。

Loss は低ければ低いほどよいので、●を曲線の最下部に持っていくのが最大の目標になります。

ところが、ここで学習率が大きいと以下の図のように行き過ぎてしまいます。

学習率が大きいと●の位置が左右で BEST の位置を大きく超えて行ったり来たりを繰り返してしまいます。
すると一向に最適解にたどり着くことができません。

しかし、学習率が低すぎると今度はいつになっても●が進まなくなる他、以下の図のように目の前の山を超せなくなってしまうことがあります。
このような状態になると最適解にたどり着くことができず、局所解に陥ってしまいます。

そのため、学習率の設定は職人技かつ重要なポイントになります。

モデルの評価

学習が終わったら、テストデータで評価してみましょう。
model.eval() でモデルを評価モードに切り替えます。
これはモデルのパラメータを更新しないようにするためです。
また、train モードのときのみ作用させたいモジュールなどを無効化する効果もあります。

correct = 0
total = 0
model.eval()
with torch.no_grad():
    for images, labels in test_loader:
        outputs = model(images)
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print(f"Accuracy: {100 * correct / total}%")
Accuracy: 97.47%

私の環境ではこのような結果になりました。

ちなみにもし、再現性を担保したい場合は torch.manual_seed を使って乱数のシードを固定しておくと良いです。
論文を書いたりする場合は再現性の担保が必要になります。
その場合はしっかりシードを固定しておきましょう。

というわけでテストデータに対して 97.47% で 0 から 9 の数字を分類することができました。