1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Python】PyTorchでCNNからの多値分類(CIFAR-10)

Posted at

はじめに

前回は、PoTorchでニューラルネットワークを実装し、MNIST を使って多クラス分類をしました。今回は、畳み込みNNを実装し、CIFAR-10の分類を行います。

できたこと

  • データセット: CIFAR-10
    • 3x32x32
    • 10クラス
    • 50,000枚の訓練画像, 10,000枚の検証用画像
  • 機械学習モデル: CNN
    • 畳み込み
      • 位置の移動に無関係な特徴量を抽出
    • プーリング
      • Max Pooling
      • 物体の大きさによらない普遍的な特徴量を抽出
  • 活性化関数: ReLU
  • 損失関数: 交差エントロピー関数
  • 最適化手法: SGD

分類結果のイメージ
ch_09_5_result_images_labels - コピー.png

学習曲線
ch_09_3_learning_curve_loss - コピー.png
ch_09_4_learning_curve_acc - コピー.png

できていないこと

チューニングはほとんどやっていないので、きっちり過学習しています。伸びしろですね。

ライブラリ

%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
from IPython.display import display

# PyTorch関連ライブラリ
import torch
import torch.nn as nn
import torch.optim as optim
from torchinfo import summary
from torchviz import make_dot
import torchvision.datasets as datasets # データセット
import torchvision.transforms as transforms # 前処理
from torch.utils.data import DataLoader # データローダー
from tqdm.notebook import tqdm # プログレスバー

# warning表示off
import warnings
warnings.simplefilter('ignore')
# デフォルトフォントサイズ変更
plt.rcParams['font.size'] = 14
# デフォルトグラフサイズ変更
plt.rcParams['figure.figsize'] = (6,6)
# デフォルトで方眼表示ON
plt.rcParams['axes.grid'] = True
# numpyの表示桁数設定
np.set_printoptions(suppress=True, precision=5)

GPUチェック

# デバイスの割り当て
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(device)

前処理の設定

一階テンソル化(一階化関数: flatten)ありバージョンとなしバージョンがありますが、本記事では前者しか使ってないはずです。

# transform1: 一階テンソル化有り
transform1 = transforms.Compose([
    transforms.ToTensor(), # テンソル化
    transforms.Normalize(0.5, 0.5), # 正規化
    transforms.Lambda(lambda x: x.view(-1)), # 一階テンソル化
])

# transform2: 一階テンソル化無し
transform2 = transforms.Compose([
    transforms.ToTensor(), # テンソル化
    transforms.Normalize(0.5, 0.5), # 正規化
])

データ準備

CIFAR-10をダウンロードします。

# ダウンロード先ディレクトリ名
data_root = './ignore_dir/data'

# 訓練データセット 1階テンソル版
train_set1 = datasets.CIFAR10(
    root = data_root, train = True, 
    download = True, transform = transform1)

# 検証データセット 1階テンソル版
test_set1 = datasets.CIFAR10(
    root = data_root, train = False, 
    download = True, transform = transform1)

# 訓練データセット 3階テンソル版
train_set2 = datasets.CIFAR10(
    root =  data_root, train = True, 
    download = True, transform = transform2)

# 検証データセット 3階テンソル版
test_set2 = datasets.CIFAR10(
    root = data_root, train = False, 
    download = True, transform = transform2)

訓練データが5万件、検証データが1万件です。確認は割愛します。

データローダー

訓練用のデータローダーはシャッフルありですが、検証用のデータローダーはシャッフルなしです。

# ミニバッチのサイズ指定
batch_size = 100
# 訓練用データローダー
train_loader1 = DataLoader(train_set1, batch_size=batch_size, shuffle=True) # 訓練用なので、シャッフルをかける
# 検証用データローダー
test_loader1 = DataLoader(test_set1,  batch_size=batch_size, shuffle=False) # 検証時にシャッフルは不要
# 訓練用データローダー
train_loader2 = DataLoader(train_set2, batch_size=batch_size, shuffle=True) # 訓練用なので、シャッフルをかける
# 検証用データローダー
test_loader2 = DataLoader(test_set2,  batch_size=batch_size, shuffle=False)  # 検証時にシャッフルは不要

イメージ確認

現物確認はしておいた方がよいと思うので、実装します。まずはデータローダーを使って、最初の1セット(50個)を取得します。

# train_loader1から1セット取得
for images1, labels1 in train_loader1:
    break

# train_loader2から1セット取得
for images2, labels2 in train_loader2:
    break

# それぞれのshape確認
print(images1.shape)
print(images2.shape)

show_images_labelsという関数名でまとめておきます。

# イメージとラベル表示のための関数
def show_images_labels(loader, classes, net, device):

    # データローダーから最初の1セットを取得する
    for images, labels in loader:
        break
    # 表示数は50個とバッチサイズのうち小さい方
    n_size = min(len(images), 50)

    if net is not None:
      # デバイスの割り当て
      inputs = images.to(device)
      labels = labels.to(device)

      # 予測計算
      outputs = net(inputs)
      predicted = torch.max(outputs,1)[1]
      #images = images.to('cpu')

    # 最初のn_size個の表示
    plt.figure(figsize=(20, 15))
    for i in range(n_size):
        ax = plt.subplot(5, 10, i + 1)
        label_name = classes[labels[i]]
        # netがNoneでない場合は、予測結果もタイトルに表示する
        if net is not None:
          predicted_name = classes[predicted[i]]
          # 正解かどうかで色分けをする
          if label_name == predicted_name:
            c = 'k'
          else:
            c = 'b'
          ax.set_title(label_name + ':' + predicted_name, c=c, fontsize=20)
        # netがNoneの場合は、正解ラベルのみ表示
        else:
          ax.set_title(label_name, fontsize=20)
        # TensorをNumPyに変換
        image_np = images[i].numpy().copy()
        # 軸の順番変更 (channel, row, column) -> (row, column, channel)
        img = np.transpose(image_np, (1, 2, 0))
        # 値の範囲を[-1, 1] -> [0, 1]に戻す
        img = (img + 1)/2
        # 結果表示
        plt.imshow(img)
        ax.set_axis_off()
    plt.show()

正解ラベルを定義し、イメージを表示します。

# 正解ラベル定義
classes = ('plane', 'car', 'bird', 'cat',
           'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

# 検証データ最初の50個の表示
show_images_labels(test_loader2, classes, None, None)

ch_09_1_images_labels.png

1000画素程度なので、だいぶ粗いです。鹿と馬とか見分けるのは大変そう。

モデル定義

冒頭で書いた通り、畳み込みニューラルネットワークを実装します。畳み込み層は2つで、全結合層は1つです。プーリング層はmax pooling、活性関数はReLUです。

class CNN(nn.Module):
  def __init__(self, n_output, n_hidden):
    super().__init__()
    self.conv1 = nn.Conv2d(3, 32, 3)
    self.conv2 = nn.Conv2d(32, 32, 3)
    self.relu = nn.ReLU(inplace=True)
    self.maxpool = nn.MaxPool2d((2,2))
    self.flatten = nn.Flatten()
    self.l1 = nn.Linear(6272, n_hidden)
    self.l2 = nn.Linear(n_hidden, n_output)

    self.features = nn.Sequential(
        self.conv1,
        self.relu,
        self.conv2,
        self.relu,
        self.maxpool)
    
    self.classifier = nn.Sequential(
       self.l1,
       self.relu,
       self.l2)

  def forward(self, x):
    x1 = self.features(x)
    x2 = self.flatten(x1)
    x3 = self.classifier(x2)
    return x3    

パラメータ設定&モデル概要確認

その他のハイパーパラメータを設定し、モデルの概要を確認しておきます。まずは乱数固定から。

# PyTorch乱数固定用

def torch_seed(seed=123):
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.backends.cudnn.deterministic = True
    torch.use_deterministic_algorithms = True

# 乱数初期化
torch_seed()

次は入出力の次元数確認と、モデルインスタンスの確認を行います。

# 出力次元数
# 分類先クラス数 今回は10になる
n_output = len(classes)

# 隠れ層のノード数
n_hidden = 128

# 結果確認
print(f'n_hidden: {n_hidden} n_output: {n_output}')

# モデルインスタンス生成
net = CNN(n_output, n_hidden).to(device)

学習率なども設定します。

# 学習率
lr = 0.01

# 損失関数: 交差エントロピー関数
criterion = nn.CrossEntropyLoss()


# 最適化関数: 勾配降下法
optimizer = torch.optim.SGD(net.parameters(), lr=lr)

# 繰り返し回数
num_epochs = 50

# 評価結果記録用
history2 = np.zeros((0,5))

モデルの概要を確認します。

# モデルの概要表示
print(net)
CNN(
  (conv1): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1))
  (conv2): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1))
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  (flatten): Flatten(start_dim=1, end_dim=-1)
  (l1): Linear(in_features=6272, out_features=128, bias=True)
  (l2): Linear(in_features=128, out_features=10, bias=True)
  (features): Sequential(
    (0): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1))
    (1): ReLU(inplace=True)
    (2): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1))
    (3): ReLU(inplace=True)
    (4): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (classifier): Sequential(
    (0): Linear(in_features=6272, out_features=128, bias=True)
    (1): ReLU(inplace=True)
    (2): Linear(in_features=128, out_features=10, bias=True)
  )
)
# モデルのサマリー表示
summary(net,(100,3,32,32),depth=1)
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
CNN                                      [100, 10]                 --
├─Sequential: 1-1                        [100, 32, 14, 14]         10,144
├─Sequential: 1-4                        --                        (recursive)
├─Sequential: 1-5                        --                        (recursive)
├─Sequential: 1-4                        --                        (recursive)
├─Sequential: 1-5                        --                        (recursive)
├─Flatten: 1-6                           [100, 6272]               --
├─Sequential: 1-7                        [100, 10]                 804,234
==========================================================================================
Total params: 823,626
Trainable params: 823,626
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 886.11
==========================================================================================
Input size (MB): 1.23
Forward/backward pass size (MB): 43.22
Params size (MB): 3.26
Estimated Total Size (MB): 47.71
==========================================================================================

パラメータ数は82万個。単回帰から始めたことを考えると、ずいぶん感慨深いですが、近年のLLMと比べると軽いです。

訓練

訓練用のコードをfitという関数名でまとめます。

# 学習用関数
def fit(net, optimizer, criterion, num_epochs, train_loader, test_loader, device, history):

    # tqdmライブラリのインポート
    from tqdm.notebook import tqdm

    base_epochs = len(history)
  
    for epoch in range(base_epochs, num_epochs+base_epochs):
        # 1エポックあたりの正解数(精度計算用)
        n_train_acc, n_val_acc = 0, 0
        # 1エポックあたりの累積損失(平均化前)
        train_loss, val_loss = 0, 0
        # 1エポックあたりのデータ累積件数
        n_train, n_test = 0, 0

        #訓練フェーズ
        net.train()

        for inputs, labels in tqdm(train_loader):
            # 1バッチあたりのデータ件数
            train_batch_size = len(labels)
            # 1エポックあたりのデータ累積件数
            n_train += train_batch_size
    
            # GPUヘ転送
            inputs = inputs.to(device)
            labels = labels.to(device)

            # 勾配の初期化
            optimizer.zero_grad()

            # 予測計算
            outputs = net(inputs)

            # 損失計算
            loss = criterion(outputs, labels)

            # 勾配計算
            loss.backward()

            # パラメータ修正
            optimizer.step()

            # 予測ラベル導出
            predicted = torch.max(outputs, 1)[1]

            # 平均前の損失と正解数の計算
            # lossは平均計算が行われているので平均前の損失に戻して加算
            train_loss += loss.item() * train_batch_size 
            n_train_acc += (predicted == labels).sum().item() 

        #予測フェーズ
        net.eval()

        for inputs_test, labels_test in test_loader:
            # 1バッチあたりのデータ件数
            test_batch_size = len(labels_test)
            # 1エポックあたりのデータ累積件数
            n_test += test_batch_size

            # GPUヘ転送
            inputs_test = inputs_test.to(device)
            labels_test = labels_test.to(device)

            # 予測計算
            outputs_test = net(inputs_test)

            # 損失計算
            loss_test = criterion(outputs_test, labels_test)
 
            # 予測ラベル導出
            predicted_test = torch.max(outputs_test, 1)[1]

            #  平均前の損失と正解数の計算
            # lossは平均計算が行われているので平均前の損失に戻して加算
            val_loss +=  loss_test.item() * test_batch_size
            n_val_acc +=  (predicted_test == labels_test).sum().item()

        # 精度計算
        train_acc = n_train_acc / n_train
        val_acc = n_val_acc / n_test
        # 損失計算
        avg_train_loss = train_loss / n_train
        avg_val_loss = val_loss / n_test
        # 結果表示
        print (f'Epoch [{(epoch+1)}/{num_epochs+base_epochs}], loss: {avg_train_loss:.5f} acc: {train_acc:.5f} val_loss: {avg_val_loss:.5f}, val_acc: {val_acc:.5f}')
        # 記録
        item = np.array([epoch+1, avg_train_loss, train_acc, avg_val_loss, val_acc])
        history = np.vstack((history, item))
    return history

記録した学習ログ(history)から、最初と最後の損失&精度を表示し、学習曲線を描画するためのコードを実装します。関数名はevaluate_historyとします。

# 学習ログ解析

def evaluate_history(history):
    #損失と精度の確認
    print(f'初期状態: 損失: {history[0,3]:.5f} 精度: {history[0,4]:.5f}') 
    print(f'最終状態: 損失: {history[-1,3]:.5f} 精度: {history[-1,4]:.5f}' )

    num_epochs = len(history)
    unit = num_epochs / 10

    # 学習曲線の表示 (損失)
    plt.figure(figsize=(9,8))
    plt.plot(history[:,0], history[:,1], 'b', label='訓練')
    plt.plot(history[:,0], history[:,3], 'k', label='検証')
    plt.xticks(np.arange(0,num_epochs+1, unit))
    plt.xlabel('繰り返し回数')
    plt.ylabel('損失')
    plt.title('学習曲線(損失)')
    plt.legend()
    plt.show()

    # 学習曲線の表示 (精度)
    plt.figure(figsize=(9,8))
    plt.plot(history[:,0], history[:,2], 'b', label='訓練')
    plt.plot(history[:,0], history[:,4], 'k', label='検証')
    plt.xticks(np.arange(0,num_epochs+1,unit))
    plt.xlabel('繰り返し回数')
    plt.ylabel('精度')
    plt.title('学習曲線(精度)')
    plt.legend()
    plt.show()

いよいよ訓練を開始します。

# 学習
history2 = fit(net, optimizer, criterion, num_epochs, train_loader2, test_loader2, device, history2)

プログレスバーが表示されます。

ch_09_2_tqdm.png

手元のPC(GPUなし)では30分程度かかったので、訓練済みのモデルを保存しておきます。

# 訓練済みモデル保存
net.to('cpu')
params = net.state_dict()
torch.save(params, 'ignore_dir/trained_model/model_ch09_cnn.prm')
net.to(device) # GPUに送る

結果確認

evaluate_history関数を使って、結果を確認します。

# 評価
evaluate_history(history2)

精度は65%まで到達したみたいです。

初期状態: 損失: 1.85420 精度: 0.35170
最終状態: 損失: 1.91180 精度: 0.65520

学習曲線も表示されるはずです。エポック数が25を過ぎたあたりから損失が増大しています。過学習しています。冒頭でも書いた通り、正則化を行っていないので、納得の結果です。

ch_09_3_learning_curve_loss.png
ch_09_4_learning_curve_acc.png

イメージも確認します。

# 最初の50個の表示
show_images_labels(test_loader2, classes, net, device)

ch_09_5_result_images_labels.png

おわりに

  • PyTorchを使って畳み込みニューラルネットワークを実装しました
  • 過学習気味、20回程度で止めておくべきだったと考えられる
  • 精度は65%程度まで到達
    • 記事にはしていないが、全結合版だと精度は53%程度だったので、畳み込みによる効果は実感できた
  • まだまだ誤認識が多い
  • 次はチューニングを行う

出典

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?