ひろしまQuest2020:画像データを使ったレモンの外観分類(ステージ1)チュートリアル

create date : Feb. 25, 2021 at 15:15:23
こちらは、コンペティション「ひろしまQuest2020:画像データを使ったレモンの外観分類(ステージ1)」のチュートリアルです。

概要

こちらのチュートリアルでは、シンプルなモデルを使って、データの学習を行い、予測値を算出し、提出物を作成する方法について説明します。
説明は以下の流れで構成されています。

  1. 準備
    • 分析環境
    • ファイル構成
    • ライブラリのインポート
  2. データの読み込みと確認
    • データの読み込み
    • 画像データの可視化
    • 各画像に対してのラベル比率の確認
  3. モデリング
    • データ分割方法の定義
    • データセットのクラス定義
    • データ前処理の定義
    • データローダーの作成
    • 損失関数、最適化手法の定義
    • モデルの学習
    • 層化k分割交差検証によるスコアの算出
  4. 推論
    • 推論用データセットの作成
    • 評価用データに対しての推論¶
    • 提出物の作成
  5. 評価スコアの改善に向けて

1.準備

分析環境

当ファイルのソースコードは、以下の環境で動作することを確認しています。

  • OS: Ubuntu 16.04.7
  • 言語: Python==3.6.7
  • GPU: TITAN X (Pascal)
  • ライブラリ
    • pandas
    • numpy
    • Pillow
    • torch
    • albumentations
    • sklearn
    • matplotlib

ファイル構成

当ファイルは以下のディレクトリ構成のもと実行されることを想定しています。
inputフォルダに今回のデータセットを解凍したものを全て配備しましょう。

  • tutorial/
    • input/
      • train_images/
      • train_images.csv
      • test_images/
      • test_images.csv
      • sample_submit.csv
    • output/
    • src/
      • 当チュートリアルの実行ファイル

ライブラリのインポート

必要なライブラリのインポートを行います。

In [1]:
import cv2
import numpy as np
import pandas as pd
import time

from glob import glob
from PIL import Image
from tqdm import tqdm

import torch
import torch.nn as nn
import torchvision.models as models
from torch.optim import Adam
from torch.optim.lr_scheduler import ReduceLROnPlateau
from torch.utils.data import DataLoader, Dataset

from albumentations import Compose, Normalize, Resize, RandomResizedCrop,CenterCrop,HorizontalFlip,VerticalFlip,Rotate,RandomContrast,IAAAdditiveGaussianNoise
from albumentations.pytorch import ToTensorV2
from sklearn.metrics import cohen_kappa_score
from sklearn.model_selection import StratifiedKFold

import matplotlib.pyplot as plt
%matplotlib inline

print(torch.__version__)
print(torch.cuda.is_available())
1.5.0
True
In [2]:
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

2.データの読み込みと確認

データの読み込み

データの読み込みを行います。
まずは「学習用画像」「評価用画像」のファイルパスをそれぞれ取得しましょう。

In [3]:
train_images_path_list = sorted(glob('../input/train_images/*.jpg'))
test_images_path_list = sorted(glob('../input/test_images/*.jpg'))

print('================')
print('学習用画像: ')
print(len(train_images_path_list))
print(train_images_path_list[:5])
print('================')
print('精度評価用画像: ')
print(len(test_images_path_list))
print(test_images_path_list[:5])
================
学習用画像: 
1102
['../input/train_images/train_0000.jpg', '../input/train_images/train_0001.jpg', '../input/train_images/train_0002.jpg', '../input/train_images/train_0003.jpg', '../input/train_images/train_0004.jpg']
================
精度評価用画像: 
1651
['../input/test_images/test_0000.jpg', '../input/test_images/test_0001.jpg', '../input/test_images/test_0002.jpg', '../input/test_images/test_0003.jpg', '../input/test_images/test_0004.jpg']

学習用画像が1102枚、評価用画像が1651枚であることが確認できました。

画像データの可視化

画像データの可視化を実行します。
上で取得したファイルパスを用いて画像データを読み込み、可視化してみましょう。

In [4]:
# 画像の読み込み
image_0000 = Image.open('../input/train_images/train_0000.jpg')

# 画像の表示
plt.imshow(image_0000);

指定する画像のファイル名を変えてみると、様々な形状のレモンがあることが確認できます。

各画像に対してのラベル比率の確認

学習用画像のラベル比率を確認してみましょう。

In [5]:
train_df = pd.read_csv('../input/train_images.csv')
train_df['class_num'].value_counts().plot.bar(figsize=(10, 3),rot=0);

優良、良、加工、規格外の順番で画像数が少なくなっていることが確認できます。

3.モデリング

データ分割方法の定義

評価用データに対しての予測をサブミットしなくても、どのくらいの精度が自分の作成したモデルで出ているかを確認するために、交差検証方法を考えましょう。
検証方法は今回のような分類問題の場合でよく使われる層化k分割交差検証を用います。

層化k分割交差検証はトレーニングデータセットをラベルの割合が元々のデータセットの割合に差がないようk分割する検証方法です。
層化k分割交差検証はscikit-learnと呼ばれるライブラリに実装されているため、そちらを用いて今回はデータを5分割(5FOLD)します。

In [6]:
train_df['fold']=0
kf = StratifiedKFold(n_splits=5,shuffle=True,random_state=0)
for fold,(train_index, test_index) in enumerate(kf.split(train_df, train_df['class_num'])):
    print('FOLD{}'.format(fold))
    train_df.loc[test_index,'fold']=fold
FOLD0
FOLD1
FOLD2
FOLD3
FOLD4

データセットのクラスを定義

torch.utils.data.Datasetクラスを継承したクラスを作成します。

In [7]:
N_CLASSES = 4

class LemonTrainDataset(Dataset):
    def __init__(self, df,transform=None):
        self.df = df
        self.labels = df['class_num']
        self.transform = transform
        
    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        file_name = self.df['id'].values[idx]
        file_path = f'../input/train_images/{file_name}'
        image = cv2.imread(file_path)
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        
        if self.transform:
            augmented = self.transform(image=image)
            image = augmented['image']
            
        label = self.labels.values[idx]
        target = torch.zeros(N_CLASSES)

        target[int(label)] = 1
        
        return image, target
    

class LemonTestDataset(Dataset):
    def __init__(self, df, transform=None):
        self.df = df
        self.transform = transform
        
    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        file_name = self.df['id'].values[idx]
        file_path = f'../input/test_images/{file_name}'
        image = cv2.imread(file_path)
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        
        if self.transform:
            augmented = self.transform(image=image)
            image = augmented['image']
        
        return image
In [8]:
train_dataset = LemonTrainDataset(train_df)

print(train_dataset.__getitem__(0)[0].shape)
print(train_dataset.__getitem__(0)[1].shape)
(640, 640, 3)
torch.Size([4])

データの前処理はこの段階では一切行なっていないため、640*640のサイズの画像であることが確認できます。

データ前処理の定義

albumentationsと呼ばれるデータ加工用のライブラリを使用して、画像に対しての前処理を定義します。
画像を見ると中心部分にレモンが写っていることが多いので、画像のサイズを512*512でセンタークロップしたものを使用します。
その他は、基本的なデータオーグメンテーションを定義しています。

In [9]:
HEIGHT=512
WIDTH=512
def get_transforms(*, data):
    if data == 'train':
        return Compose([
            CenterCrop(height=HEIGHT, width=WIDTH, p=1.0),
            HorizontalFlip(0.5),
            VerticalFlip(0.5),
            Rotate(0.5),
            RandomContrast(0.5),
            IAAAdditiveGaussianNoise(p=0.25),
            Normalize(
                mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225],
            ),
            ToTensorV2(),
        ])
    
    elif data == 'valid':
        return Compose([
            CenterCrop(height=HEIGHT, width=WIDTH, p=1.0),
            Normalize(
                mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225],
            ),
            ToTensorV2(),
        ])

データローダーの作成

In [10]:
FOLD=0
BATCH_SIZE=16
# データセットの作成
train_dataset = LemonTrainDataset(train_df[train_df['fold']!=FOLD].reset_index(drop=True),transform=get_transforms(data='train'))
valid_dataset = LemonTrainDataset(train_df[train_df['fold']==FOLD].reset_index(drop=True),transform=get_transforms(data='valid'))

# データローダーの作成
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
valid_loader = DataLoader(valid_dataset, batch_size=BATCH_SIZE, shuffle=False)

分類モデルの定義

ここでは、torchvisionと呼ばれるライブラリに標準で実装されているresnet18モデルを使用します。
またimagenetと呼ばれる大規模データセットを学習データとしたモデルのパラメータを流用するために、pretrained=Trueとすることに注意しましょう。
モデルの出力層は今回のレモンのラベル数となるので、4に変更します。

In [11]:
def get_resnet18_model(trained=True,class_num=N_CLASSES):
    model = models.resnet18(pretrained=trained)
    model.fc = nn.Linear(512, class_num)
    model.to(device)
    return model
In [12]:
model = get_resnet18_model(trained=True,class_num=N_CLASSES)
In [13]:
model.fc
Out[13]:
Linear(in_features=512, out_features=4, bias=True)

損失関数、最適化手法の定義

In [14]:
lr = 1e-4
n_epochs = 5
optimizer = Adam(model.parameters(), lr=lr)
scheduler = ReduceLROnPlateau(optimizer, 'min', factor=0.75, patience=4, verbose=True, eps=1e-6)
criterion = nn.BCEWithLogitsLoss()

モデルの学習

モデルの学習を行います。
学習を行なっていく中で、スコアの高いモデルのパラメータをfold_best_score.pthとしてoutputフォルダに保存します。

In [15]:
model.to(device)
best_score = 0.
best_thresh = 0.
best_loss = np.inf
for epoch in range(n_epochs):
    print('epoch={}'.format(epoch))
    start_time = time.time()
    model.train()
    avg_loss = 0.
    optimizer.zero_grad()
    tk0 = tqdm(enumerate(train_loader), total=len(train_loader))

    for i, (images, labels) in tk0:
        images = images.to(device)
        labels = labels.to(device)
        y_preds = model(images)
        loss = criterion(y_preds, labels)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        avg_loss += loss.item() / len(train_loader)

    model.eval()
    avg_val_loss = 0.
    preds = []
    valid_labels = []
    tk1 = tqdm(enumerate(valid_loader), total=len(valid_loader))

    for i, (images, labels) in tk1:
        images = images.to(device)
        labels = labels.to(device)

        with torch.no_grad():
            y_preds = model(images)

        _, y_preds_labels = torch.max(y_preds, 1)
        _, labels_new = torch.max(labels, 1)


        preds.append(y_preds_labels.to('cpu').numpy())
        valid_labels.append(labels_new.to('cpu').numpy())

        loss = criterion(y_preds, labels)
        avg_val_loss += loss.item() / len(valid_loader)


    scheduler.step(avg_val_loss)
    preds = np.concatenate(preds)
    valid_labels = np.concatenate(valid_labels)
    score = cohen_kappa_score(valid_labels, preds, weights="quadratic")

    if score>best_score:
        best_score = score

        print(f'  Epoch {epoch+1} - Save Best Score: {best_score:.4f}')
        torch.save(model.state_dict(), f'../output/fold{FOLD}_best_score.pth')
0%|          | 0/56 [00:00

層化k分割交差検証によるスコアの算出

層化k分割交差検証として分割したデータを使って、スコアを算出してみましょう。

In [16]:
print('qwk={}'.format(cohen_kappa_score(valid_labels, preds,weights="quadratic")))
qwk=0.8457739485197413

4.推論

推論用データセットの作成

評価用のデータに対して、データローダーを作成し、先ほど学習させたモデルを使って推論を行いましょう。

In [17]:
test_df = pd.read_csv('../input/test_images.csv')
test_dataset = LemonTestDataset(test_df,transform=get_transforms(data='valid'))
test_loader = DataLoader(test_dataset, batch_size=1, shuffle=False)

評価用データに対しての推論

In [18]:
tk_test = tqdm(enumerate(test_loader), total=len(test_loader))
preds = []
for i, (images) in tk_test:

    images = images.to(device)
    y_preds = model(images)
    
    _, y_preds_labels = torch.max(y_preds, 1)
    
    preds.extend(y_preds_labels.to('cpu').numpy())

len(preds)
100%|██████████| 1651/1651 [00:18<00:00, 90.39it/s]
Out[18]:
1651

提出物の作成

In [19]:
test_df['preds']=preds
In [20]:
test_df[['id','preds']].to_csv('../output/fold{}_submission.csv'.format(FOLD),index=False,header=None)

4.評価スコア改善に向けて

前述したソースコードはごく簡易的な手法を用いているので、工夫次第で更に評価値が改善する余地があります。
以下に、評価改善のための工夫例をいくつか提示します。

  • 精度改善のための工夫例
    • より高性能なネットワークの採用
    • データオーギュメンテーション(データ水増し)の実行
    • 画像の拡大・縮小のサイズや手法の変更
    • 損失関数・最適化手法の変更

当チュートリアルは以上となります。
皆様のご参加をお待ちしています。

create date : Feb. 25, 2021 at 15:15:23