神戸のデータ活用塾!KDL Data Blog

KDLが誇るデータ活用のプロフェッショナル達が書き連ねるブログです。

【第1回 基礎実装編】PyTorchとCIFAR-10で学ぶCNNの精度向上

株式会社神戸デジタル・ラボ DataIntelligenceチーム(以降DIチーム)の原口です。

本連載では、Batch Normalization*1やDropout*2などの様々な精度向上手法を利用することによって、CNNの精度がどのように変化するのかを画像データセットの定番であるCIFAR-10*3を用いて実験していきたいと思います。

そのため本記事は

  • PyTorch*4で画像分析を始めてみたい人
  • 書籍で画像分析を学んだが何ができるか分からない人
  • 理論は理解したが、実際の実装が難しいと感じている人

を対象としています。

はじめに

近年様々な問題解決に利用されている畳み込みニューラルネットワーク(以下CNN)*5。高精度化が進むにつれて、その内部構造の理解は難しくなり、どの手法がどのように精度に影響を与えているか初学者には理解が難しいのが現状です。

そこで本連載では、最終的にはCNN実装の一連の流れを理解できるよう、CNNの構築から精度評価、精度向上に利用される様々な手法を実際にコーディングしながら理解を深めていきます。

連載の内容として

  1. 基礎実装編
  2. 評価編
  3. Dropout導入編
  4. Normalization導入編
  5. 正則化項(過学習を抑制するための手法の一種)導入編

などを予定しております。

それでは第1回、基礎実装編を始めていきましょう!

実験環境

今回の実験はすべてGoogle Colaboratory*6上で行っています。 実験時の環境は次のようになっています。

  • Python : 3.7.12
  • PyTorch : 1.9.0
  • Cuda : 11.1*7

そもそもAIの学習とは?

まずAIの学習に関して、犬と猫の分類を題材におさらいしておきましょう。

AIは次のステップをたどりながら学習を進めていきます。

  1. AIは犬 or 猫の画像を受け取る
  2. 受け取った画像から犬 or 猫の予測結果を出力する
  3. AIの予測と正解ラベルから、誤差関数を用いて誤差を計測する
  4. 誤差関数から得られた誤差をAIにフィードバック
  5. AIはフィードバック内容から学習をする

このステップを複数回繰り返すことで、AIは犬と猫の分類ができるようになっていきます。

画像解析AIの実装

今回実装する画像解析AIは、CIFAR-10のデータを分類することができるモデルです。 を合わせて読んでもらえると、より理解しやすくなると思います。

必要モジュールをインポート

それでは実際に実装していきましょう。

今回利用するPyTorchや、それに付随するモジュールをインポートしていきます。

import torch
import torchvision
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms as T
from torch.utils.data import DataLoader
import torch.optim as optim
from torchvision import datasets
from torch.utils.data.dataset import Subset
import matplotlib.pyplot as plt
import random
import os
import numpy as np

今回インポートしたモジュールについて説明します。

  • torch.nn : ニューラルネットワークを構築する際に利用するモジュールが格納されています。
  • torchvision.transforms : データに対して前処理を行う際に利用するモジュールが格納されています。
  • torch.utils.data : データの扱い方を定義するモジュールが格納されています。
  • torch.optim : 最適化関数に関するモジュールが格納されています。
  • matplotlib.pyplot : グラフや画像を描画する際に利用するモジュールが格納されています。
  • numpy : Pythonで行列演算を扱うためのモジュールが格納されています。

乱数の固定

これから複数回に分けて様々な手法を試し比較検証を行うので、実験環境を統一するために乱数を固定します。

乱数を固定することで、実験に利用するAIモデルの初期値が常に一定になります。

これにより、初期値を固定しなかったことで発生する精度誤差を考慮する必要がなくなり、より正確に手法の比較検証が行えます。

乱数を固定するにあたって、簡単に乱数生成の仕組みを説明します。

乱数の生成

まずは乱数の定義を確認しましょう。

0,1,2,3,4,5,6,7,8,9という 10個の数字を,そこになんらの規則もないが,しかしおのおのの数字が現れる確率は等しく1/10であるように並べたもの。

引用元:https://kotobank.jp/word/%E4%B9%B1%E6%95%B0-9671

つまり、前回出力された数字から次の数字が全く予想できない数のことを指します。

しかしコンピュータはある法則に則った動作しかできないため、乱数を生成する際はアルゴリズムが必要となります。

アルゴリズムによって生成される乱数は前後の関係性が存在するため、完全な乱数とは呼べません。

このように、アルゴリズムによって生成された乱数のことを疑似乱数と言います。

疑似乱数の生成には初期値を必要とし、この初期値のことをシードと呼びます。

乱数生成アルゴリズムは、シードからアルゴリズムに則って疑似乱数を多数生成します。

つまり、シードの値を固定することで、生成する乱数を一定にすることができます。

では実際に乱数を固定してみましょう!

def seed_torch(seed=42):
    random.seed(seed)
    os.environ["PYTHONHASHSEED"] = str(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True

seed = 2021
seed_torch(seed)

シードを2021(2021年だったので・・・。)に固定したので、乱数生成を固定することができました。

ハイパーパラメータの設定

実装を行う前にハイパーパラメータ(人間が手動で定義する値のこと)を設定しておきます

BATCH_SIZE = 256
EPOCHS = 100
TRAIN_RATIO = 0.8
DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
print(DEVICE)

各要素は以下の通りです。

  • BATCH_SIZE:データセットから一度に取得する画像枚数
  • EPOCH:同じデータセットを何回学習させるか
  • TRAIN_RATIO:データセットをtrainとvalに分割する際に利用
  • DEVICE:GPUの利用可否で学習に利用するデバイスを自動で選択します

上記のコード出力でcudaと表示される場合はGPUが利用できる状態です。

CIFAR-10の理解

モデルの学習を行う前に、CIFAR-10データセットの画像データと教師ラベルについて理解しましょう。

実際にデータを利用するために、CIFAR-10のデータを呼び出します。

train_data = datasets.CIFAR10('./data', #データを保存するdir
                              train = True,  #True : 学習用データ False : テストデータ 
                              download=True,  # downloadするか否か
                              transform = T.Compose([T.ToTensor()]) #前処理の設定
                              )
train_loader = DataLoader(train_data,batch_size=64)

前処理を連続で行いたい場合は上のコードでもある通りCompose([])を利用します。

例えば、

  1. 画像の短辺を256 pixelに変更
  2. 変更した画像の中心から224pxの正方形をくりぬく

といった作業を行う場合は次のように記述します。

transforms = T.Compose([T.Resize(256),
                        T.CenterCrop(224),
                        ])

では、CIFAR-10の画像内容を見ていきましょう。CIFAR-10によって提供されるのラベルの対応は次のようになっています。

  • ラベル「0」: airplane(飛行機)
  • ラベル「1」: automobile(自動車)
  • ラベル「2」: bird(鳥)
  • ラベル「3」: cat(猫)
  • ラベル「4」: deer(鹿)
  • ラベル「5」: dog(犬)
  • ラベル「6」: frog(カエル)
  • ラベル「7」: horse(馬)
  • ラベル「8」: ship(船)
  • ラベル「9」: truck(トラック)
data,label = iter(train_loader).next()

fig = plt.figure(figsize = (10,4))
for i in range(1,11):
  ax = fig.add_subplot(2,5,i)
  ax.axis("off")
  ax.set_title(label[i].numpy())
  ax.imshow(data[i].permute(1,2,0))

上のような画像が表示されると思います。これがCIFAR-10の学習用画像の一部です。

少しぼやけた画像のように見えます。画像サイズを確認してみましょう。

data[0].shape
torch.Size([3, 32, 32])

32×32 pixelと小さいため、このようにぼけたようになります。

データセットの定義

各種データセットを作っていきましょう!

今回はホールドアウト検証を利用するため、CIFAR-10が提供しているtrainデータをtrainとvalに分割して利用します。 分割する前にホールドアウト検証について説明します。

ホールドアウト検証とは

一般的に機械学習では、学習用データ検証用データ評価用データの三種類を要します。

  • 学習用データ:機械学習モデルが学習する際に利用するデータ。複数回利用する
  • 検証用データ:学習データで学習したモデルが、どの程度汎化性能を持っているか評価する際に利用するデータ。複数回利用する
  • 評価用データ:学習したモデルが未知のデータに対してどれだけ適応できるかを検証する。学習したモデルに対して一度のみ利用する

学習用データのみを準備し検証・評価用データを準備しない場合、モデルは学習データに対して過剰に適合し、未知のデータに対応できなくなります。

一方、検証用データを準備すると、学習用データで学習したモデルを検証用データで検証することができます。

このように検証用データは学習用データで学習したモデルを検証し、パラメータのチューニングを行う際に役に立ちます。

ただ検証用データのみを準備した場合、モデルは検証用データに合わせたパラメータチューニングを行ったことになり、未知のデータに対応できる保証はありません。

そこで評価用データを用います。評価用データは、学習と検証から導き出した最も良いと思われるパラメータの評価を行います。この処理を行った後にパラメータを変更してはいけません。(パラメータを変更・再度評価を行うと評価データを検証用データとして利用していることと同じになります。)

このように、モデルを正しく評価するには学習用データ・検証用データ・評価用データの三種類を準備することが重要となります。

   

それではデータを学習用データ・検証用データ・評価用データの三種類に分けていきましょう。

まずは提供されているtrainデータとtestデータを読み出します。

train_data = datasets.CIFAR10('./data',train = True,download=True,transform = T.Compose([T.ToTensor()]))
test_data = datasets.CIFAR10('./data',train = False,download=True,transform = T.Compose([T.ToTensor()]))

続いてtrainデータをtrainとvalに分割していきます。

この分割比率はTRAIN_RATIOで設定しており、8 : 2で分割するようになっています。

data_len = len(train_data)
train_len = int(data_len*TRAIN_RATIO)
train_dataset = Subset(train_data,[i for i in range(0,train_len)])
val_dataset = Subset(train_data,[i for i in range(train_len,data_len)])

最後にDataLoaderに各データセットをセットして完成です。

train_loader = DataLoader(train_dataset,batch_size=BATCH_SIZE)
val_loader = DataLoader(val_dataset,batch_size=BATCH_SIZE)
test_loader = DataLoader(test_data,batch_size=BATCH_SIZE)

CNNモデルの構築

今回はLeNetを参考にモデルを構築します。(LeNetの論文はこちら)

LeNetはCNNの始祖とも呼ばれており、現在使われている様々なCNNの原型となったモデルです。

モデルの構成は次の表のとおりです。

層数 レイヤー名 出力サイズ
0 input 3×32×32
1 Conv2d 6×28×28
2 ReLU 6×14×14
3 MaxPooling 6×14×14
4 Conv2d 16×10×10
5 ReLU 16×10×10
6 MaxPooling 16×5×5
7 Flatten 400
8 Linear 120
9 ReLU 120
10 Linear 84
11 ReLU 84
12 Linear 10

では作っていきましょう。

PyTorchにおけるモデル構築のひな形は次のように記述します。

class BaseLineModel(nn.Module):
    def __init__(self):

    def forward(self, x):

        return x

モデルを構築するクラスには大きく二つの要素があります。

__init__():レイヤーの初期設定

ここでは利用するレイヤーや初期設定したい内容を記述します。

今回のモデル構築では、畳み込みやMaxPoolingなどを定義します。

    def __init__(self):
        super().__init__()
        #nn.Conv2d(入力チャンネル数,出力チャンネル数,カーネルサイズ)
        self.conv1 = nn.Conv2d(3, 6, 5) 
        #nn.MaxPool2d(カーネルサイズ,カーネルサイズ)
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        #nn.Linear(入力チャンネル数,出力チャンネル数)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

層を宣言する際は、学習を行う層は個別に、学習しない層は単体で宣言する必要があります。

今回の場合だと、畳み込み層・線形層は学習可能な層、マックスプーリング層は学習しない層となります。

また各層を宣言する際には引数が存在します。 畳み込み層nn.Conv2dでは引数順に

  • 入力チャンネル数
  • 出力チャンネル数
  • カーネルサイズ

線形層nn.Linearでは引数順に

  • 入力チャンネル数
  • 出力チャンネル数

と宣言します。

具体的なリファレンスは こちら です。各層ごとに確認しながら実装を進めてください。

forward():データの流れを定義

ここでは__init__()で記述したレイヤーを用いて、データの流れを定義していきます。

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = torch.flatten(x, 1) 
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

def forward(self, x)に存在するxが実際に入力されるデータと考えて実装します。

入力されたデータは畳み込み→ReLU→MaxPoolingの順番で進みます。 このデータの流れをコードとして記述している部分は x = self.pool(F.relu(self.conv1(x)))の部分です。

次のデータの流れは畳み込み→ReLU→MaxPoolingなので記述としてはほぼ同じです。畳み込み層は2番目のものに変わっていることに注意しましょう。

ではtorch.flatten(x, 1)はどのような役割をするのでしょうか?

線形層は1次元ベクトルしか入力することができません。しかし畳み込み層の出力は縦×横×チャネル数の三次元となります。

そのままでは線形層に入力することができないため、畳み込み層の出力を一度バラバラに分解し一次元化し、線形層に入力します。

このバラバラにする作業をtorch.flatten()が担っているということです。

学習部分の構築

最後に学習部分を構築していきます!

まずは学習時に利用する関数を定義していきます。

def show_score(epoch,max_epoch,itr,max_itr,loss,acc,is_val=False):
    print('\r{} EPOCH[{:03}/{:03}] ITR [{:04}/{:04}] LOSS:{:.05f} ACC:{:03f}'.format("VAL  " if is_val else "TRAIN",epoch,max_epoch,itr,max_itr,loss,acc*100),end = '')

def cal_acc(output,label):
  p_arg = torch.argmax(output,dim = 1)
  return torch.sum(label == p_arg)

show_score()は、学習の経過を視覚的にわかりやすくするのに利用します。

cal_acc()は、モデルがどの程度正解しているか計測する際に利用します。

続いて学習部分を作りこみましょう! 学習は上で説明した通り

  1. AI画像を受け取る
  2. 受け取った画像から予測結果を出力する
  3. AIの予測と正解ラベルから、誤差関数を用いて誤差を計測する
  4. 誤差関数から得られた誤差をAIにフィードバック
  5. AIはフィードバック内容から学習をする

という流れになります。 これをそのまま実装していきましょう!

def Train_Eval(model,criterion,optimizer,data_loader,device,epoch,max_epoch,is_val = False):
  total_loss = 0.0
  total_acc = 0.0
  counter = 0
  model.eval() if is_val else model.train()
  for n,(data,label) in enumerate(data_loader):
    counter += data.shape[0]
    optimizer.zero_grad()
    data = data.to(device)
    label = label.to(device)
    if is_val:
      with torch.no_grad():
        output = model(data)
    else:
      output = model(data)
    loss = criterion(output,label)
    total_loss += loss.item()
    total_acc += cal_acc(output,label)
    
    if is_val != True:
      loss.backward()
      optimizer.step()
    show_score(epoch+1,max_epoch,n+1,len(data_loader),total_loss/(n+1) , total_acc/counter,is_val=is_val)
  print()
  return total_loss , total_acc

上記関数は、学習と評価の両方を同じ関数内で行います。

大まかな流れとして

▼学習データの取り出し

for n,(data,label) in enumerate(data_loader):

▼モデルに保存されている勾配情報をゼロにする

optimizer.zero_grad()

▼モデルにデータを入力、予測結果を出力

output = model(data)

▼誤差関数を利用し、誤差を計測

loss = criterion(output,label)

▼AIに誤差をフィードバック

loss.backward()
optimizer.step()

この一連の流れを複数回繰り返すことでAIはどんどん賢くなっていきます。

実際に学習しよう

今回は入力された画像を10クラスに分類する問題なのでCrossEntropyLoss()を利用して学習を行います。

最適化関数は安定した学習が行いやすいAdamを利用します。

model = BaseLineModel().to(DEVICE)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())

最後に学習を行います!

train_loss_list = []
val_loss_list = []

train_acc_list = []
val_acc_list = []

for epoch in range(EPOCHS):
  train_loss,train_acc = Train_Eval(model,criterion,optimizer,train_loader,DEVICE,epoch,EPOCHS) 
  val_loss,val_acc    = Train_Eval(model,criterion,optimizer,val_loader,DEVICE,epoch,EPOCHS,is_val=True)

  train_loss_list.append(train_loss)
  train_acc_list.append(train_acc)
  val_loss_list.append(val_loss)
  val_acc_list.append(val_acc)

ここでEPOCHSは同じデータセットを何回AIに見せるかを定義しています。

今回はハイパーパラメータで100回と定義しているので、AIはCIFAR-10のデータを100回見て学習することになります。

ここまでのコードを実行すると次のような出力が表示されるはずです。

TRAIN EPOCH[001/100] ITR [0157/0157] LOSS:1.98725 ACC:26.352497
VAL   EPOCH[001/100] ITR [0040/0040] LOSS:1.80795 ACC:32.540001
TRAIN EPOCH[002/100] ITR [0157/0157] LOSS:1.71034 ACC:37.070000
VAL   EPOCH[002/100] ITR [0040/0040] LOSS:1.66391 ACC:38.270000
TRAIN EPOCH[003/100] ITR [0157/0157] LOSS:1.58926 ACC:41.740002
VAL   EPOCH[003/100] ITR [0040/0040] LOSS:1.59831 ACC:41.220001
TRAIN EPOCH[004/100] ITR [0157/0157] LOSS:1.51286 ACC:44.700001
VAL   EPOCH[004/100] ITR [0040/0040] LOSS:1.53103 ACC:44.039997

おお、何か学習らしきことが行われている・・・?

次回は、上記の内容の評価を行いたいと思います!

原口俊樹

データインテリジェンスチーム所属
データエンジニアを担当しています。画像認識を得意としており、画像認識・ニューラルネットワーク系の技術記事を発信していきます

*1:バッチノーマリゼーション:各バッチデータを使い、正規化することで学習を効率的にする手法

*2:ドロップアウト:ニューラルネットワークの過学習を防ぐために考案されたテクニックの一つ

*3:スィーファーテン:10種類の画像が6万枚入ったデータセット

*4:パイトーチ:metaが開発しているPythonのオープンソース機械学習ライブラリ

*5:Convolutional Neural Network:画像認識に特化したネットワークアーキテクチャ

*6:略称: Colab。ブラウザから Python を記述、実行できるサービス

*7:クーダ:グラフィック処理ユニット (GPU) 用に、NVIDIA が開発した並列コンピューティング プラットフォームおよびプログラミング モデル