見出し画像

Nishikaトレーニングコンペ:日本絵画に描かれた人物の顔分類に機械学習で挑戦!

概要

 CNNモデルの練習も含めてNishikaの「【トレーニングコンペ】日本絵画に描かれた人物の顔分類に機械学習で挑戦!」に参加してみました。

0.分析フロー

 基本的な分析フローはほぼ同じになるため、一度整理しました。

  1. データの準備/タスクの理解

  2. データの前処理

  3. 特徴量エンジニアリング(データの特徴量作成)

  4. データセット/データローダーの作成

  5. AIモデルの選定・作成

  6. ハイパーパラメータの選定(モデルのハイパラ、損失関数、最適化関数、学習率、乱数シード、データ分割数など)

  7. モデルの学習・保存

  8. モデルのチューニング(ハイパーパラメータ調整)

  9. 推論:学習済みモデルでtestデータを予測

  10. 提出・結果確認

1.データの準備/タスクの理解

 今回は下記コンペに参加します。練習用コンペのため期限なしで誰でも参加可能です。

1-1.データの確認

 Data詳細は「コンペ概要」と「データ」タブより確認できます。感想としては「人が見てもめっちゃ難しくない??」と思うので、自作の特徴量を作成(特徴量エンジニアリング)せずに、AIモデルに頑張ってもらう方向でいきます。

  • データは、カラー画像、ラベルは0~8(0:男-貴族, 1:男-武士, 2:男-化身, 3:男-庶民,4:女-貴族, 5:女-武士, 6:女-化身, 7:女-庶民)

  • 教師データ画像数は4238枚であり、枚数はかなり少ない

  • 画像サイズ=(256, 256) ※概要に記載はないが出典元の「顔コレデータセット」は256でデータ提供

  • 評価指標はAccuracy(正解数)

1-2.データのダウンロード

 ファイルはデータタブの下にある「一括ダウンロード」でDLします。

 ごちゃごちゃしていますが、最終的なフォルダの構成は下記の通りです。

2.データの前処理

 本章の全コードは下記の通りです。

[IN]
import os
import glob
from random import shuffle
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm
from PIL import Image

import torch
import torch.nn as nn
import torch.nn.functional as F
from torchinfo import summary
import torchvision
import torchvision.transforms as T
from torchvision import models
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from pytorchtools import EarlyStopping


#ファイルのDL/データの中身を簡易確認
def get_pathlist(root, phase='train'):
    path_target_jpg = os.path.join(root, phase, '*.jpg') #JPEGファイルのパスを取得
    path_target_png = os.path.join(root, phase, '*.png') #PNGファイルのパスを取得
    pathlist = glob.glob(path_target_jpg) + glob.glob(path_target_png) #JPEGとPNGのパスを結合
    return pathlist

class ImageTransform():
    def __init__(self, resize, mean, std):
        self.data_transform = {
            'train': T.Compose([
                T.RandomResizedCrop(resize, scale=(0.5, 1.0)), #画像をランダムに切り抜き 224×224にリサイズ
                T.RandomHorizontalFlip(), #画像をランダムに左右反転
                T.RandomRotation(degrees=30), #画像をランダムに回転
                T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), #画像の色調をランダムに変化
                T.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0)), #画像をランダムにガウシアンぼかし
                T.ToTensor(), #Tensorに変換
                T.Normalize(mean, std) #ImageNetの平均と標準偏差で正規化
            ]),
            'val': T.Compose([
                T.Resize(resize), #画像をリサイズ※このままだとtorch.Size([3, 224, 358])
                T.CenterCrop(resize), #画像を中央で切りぬいて224x224にする
                T.ToTensor(), #Tensorに変換
                T.Normalize(mean, std) #ImageNetの平均と標準偏差で正規化
            ])
        }
    
    def __call__(self, img, phase='train'):
        return self.data_transform[phase](img)


paths_img = get_pathlist('./', 'train') #trainフォルダの中の画像のパスを取得
path_trainlabel = glob.glob('train/*.csv')[0] #trainフォルダの中のcsvのパスを取得


#事前にNumpyでデータのサイズや値を確認
_imgs = [np.array(Image.open(i)) for i in paths_img[:5]] #OSError: [Errno 24] Too many open files:となるためnumpyに変換
imgs = np.array(_imgs) #List->numpyに変換
df_train_labels = pd.read_csv(path_trainlabel) #入力画像のラベル
print(type(imgs[0]), imgs[0].shape, imgs.min(), imgs.max()) #形状確認
print(df_train_labels['gender_status'].unique()) #ラベルの分布(カテゴリカルデータ)確認

# #前処理-欠損値確認
print(df_train_labels.isnull().sum()) #ラベルの欠損値確認

# #前処理
size = 224
# mean, std = (0.485, 0.456, 0.406), (0.229, 0.224, 0.225) #ImageNetの平均と標準偏差
mean, std = 0.5, 0.5
transform = ImageTransform(resize=size, mean=mean, std=std)

class Dataset_Image(Dataset):
    def __init__(self, file_list, transform=None, phase='train', return_path=False):
        self.file_list = file_list #画像のパスを格納したリスト
        self.transform = transform #前処理クラスのインスタンス
        self.phase = phase #train or valの指定
        self.return_path = return_path #画像のパスを返すかどうか
    
    def __len__(self):
        return len(self.file_list) #画像の枚数を返す
    
    def __getitem__(self, index):
        #画像の前処理
        img_path = self.file_list[index] #index番目の画像のパスを取得
        img = Image.open(img_path) #PIL形式で画像を読み込み
        img_transformed = self.transform(img, self.phase) #画像の前処理を実施
        
        #ラベルデータの取得
        df_labels = pd.read_csv(path_trainlabel) #データラベルをDataFrame形式で取得
        path = os.path.basename(img_path) #画像のパスからファイル名を取得
        
        label = df_labels[df_labels['image'] == path]['gender_status'].values[0] #画像のラベルを取得
            
        if self.return_path:
            return img_transformed, label, os.path.basename(img_path) #画像のtensor形式データ、ラベル、画像のパスを返す
        else:
            return img_transformed, label #画像のtensor形式データとラベルを返す


#Pytorchを使用するための前処理
def train_val_split(datas, val_ratio=0.1): #20%->10%に変更
    val_size = int(len(datas) * val_ratio) #valデータのサイズ
    train_size = len(datas) - val_size #trainデータのサイズ
    return (train_size, val_size)


#データの分割:trainとvalに分割 ※分割率を設定
train_size, _ = train_val_split(paths_img , val_ratio=0.2) #学習:検証 = 8:2
# index_random = np.random.permutation(len(paths_img)).tolist() #ランダムに並び替えたインデックスを取得
# paths_img = [paths_img[i] for i in index_random] #ランダムに並び替えた画像のパスを取得
paths_img_train, paths_img_val = paths_img[:train_size], paths_img[train_size:] #trainとvalに分割

train_dataset = Dataset_Image(file_list=paths_img_train, 
                               transform=ImageTransform(size, mean, std),
                               phase='train')

val_dataset = Dataset_Image(file_list=paths_img_val, 
                             transform=ImageTransform(size, mean, std),
                             phase='val')

# #前処理後のデータ確認
tensor, label = train_dataset[0]
print(tensor.shape, tensor.min(), tensor.max(), label)

train_dataloader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_dataloader = DataLoader(val_dataset, batch_size=32, shuffle=False) #valは意味がないためFalse
print('train:', len(train_dataloader), 'val:', len(val_dataloader)) #minibatch数の確認
print('train:', len(train_dataloader.dataset), 'val:', len(val_dataloader.dataset)) #データ数の確認

#train/valの使い分け用に辞書型変数にまとめる
dataLoaders_dict = {'train': train_dataloader, 'val': val_dataloader}


[OUT]
<class 'numpy.ndarray'> (256, 256, 3) 0 255
[4 0 3 1 2 7 6 5]
image            0
gender_status    0
dtype: int64
torch.Size([3, 224, 224]) tensor(-1.) tensor(0.6471) 4
train: 212 val: 27
train: 3391 val: 847

 2-1ー1.データの内容確認

 まずはデータをPandasで読み込んで中身を確認します。

  • 自作関数"getfile"で学習用画像のパスを取得

  • globでラベルデータを取得

  • (CNNを使用するため)画像の形状とラベルの値を確認

[IN]
#ファイルのDL/データの中身を簡易確認
def get_pathlist(root, phase='train'):
    path_target_jpg = os.path.join(root, phase, '*.jpg') #JPEGファイルのパスを取得
    path_target_png = os.path.join(root, phase, '*.png') #PNGファイルのパスを取得
    pathlist = glob.glob(path_target_jpg) + glob.glob(path_target_png) #JPEGとPNGのパスを結合
    return pathlist


paths_img = get_pathlist('./', 'train') #trainフォルダの中の画像のパスを取得
path_trainlabel = glob.glob('train/*.csv')[0] #trainフォルダの中のcsvのパスを取得


#事前にNumpyでデータのサイズや値を確認
_imgs = [np.array(Image.open(i)) for i in paths_img[:5]] #OSError: [Errno 24] Too many open files:となるためnumpyに変換
imgs = np.array(_imgs) #List->numpyに変換
df_train_labels = pd.read_csv(path_trainlabel) #入力画像のラベル
print(type(imgs[0]), imgs[0].shape, imgs.min(), imgs.max()) #形状確認
print(df_train_labels['gender_status'].unique()) #ラベルの分布(カテゴリカルデータ)確認

[OUT]
<class 'numpy.ndarray'> (256, 256, 3) 0 255
[4 0 3 1 2 7 6 5]

 2-1ー2.欠損値の確認・処理

 決定木モデルなら問題ないですが、一般的な機械学習モデルは欠損値を処理できません。欠損値を確認し存在するなら処理(埋める、削除)します。
 ラベルデータに欠損値が無いことを確認しました(入力値は画像なので確認不要)。

  • "dataframe.isnull().sum()"で各カラム(列)の欠損値の数を計算

[IN]
#前処理-欠損値確認
print(df_train_labels.isnull().sum()) #ラベルの欠損値確認

[OUT]
image            0
gender_status    0
dtype: int64

 2-1ー3.前処理・データオーギュメンテーション

 画像処理として前処理およびデータの水増し(データオーギュメンテーション)を実施します。詳細は下記の通りです。

  • 画像の正規化(T.Normalize)に必要なmean, stdを設定する

  • ★Try&Error★:データオーギュメンテーションとして切取り、反転、回転、色調、ぼかしをついか

  • 検証用ではデータオーギュメンテーションは不要のため学習用とは別に選択できるように作成

[IN]
class ImageTransform():
    def __init__(self, resize, mean, std):
        self.data_transform = {
            'train': T.Compose([
                T.RandomResizedCrop(resize, scale=(0.5, 1.0)), #画像をランダムに切り抜き 224×224にリサイズ
                T.RandomHorizontalFlip(), #画像をランダムに左右反転
                T.RandomRotation(degrees=30), #画像をランダムに回転
                T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), #画像の色調をランダムに変化
                T.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0)), #画像をランダムにガウシアンぼかし
                T.ToTensor(), #Tensorに変換
                T.Normalize(mean, std) #ImageNetの平均と標準偏差で正規化
            ]),
            'val': T.Compose([
                T.Resize(resize), #画像をリサイズ※このままだとtorch.Size([3, 224, 358])
                T.CenterCrop(resize), #画像を中央で切りぬいて224x224にする
                T.ToTensor(), #Tensorに変換
                T.Normalize(mean, std) #ImageNetの平均と標準偏差で正規化
            ])
        }
    
    def __call__(self, img, phase='train'):
        return self.data_transform[phase](img)


# 前処理
size = 224
# mean, std = (0.485, 0.456, 0.406), (0.229, 0.224, 0.225) #ImageNetの平均と標準偏差
mean, std = 0.5, 0.5
transform = ImageTransform(resize=size, mean=mean, std=std)

[OUT]
-

3.特徴量エンジニアリング

 画像処理ではCNN (Convolutional Neural Networks)そのものが強い特徴量抽出器になっているため、特に追加の特徴量エンジニアリングは不要としました。

4.データセット/データローダーの作成

4-1.データの分割

 通常は過学習を防止するためにデータは「train(学習), valid(検証), test(テスト)」用に分割します。今回は学習・検証のみで実施しました。

  • ★Try&Error★データを”学習:検証”に分割するための関数を作成して、データを分割します。(今回はデータ数が少ないためtrainとvalidのみに分け、trainの比率を0.75~0.90で調整しました。)

[IN]
#Pytorchを使用するための前処理
def train_val_split(datas, val_ratio=0.1): #20%->10%に変更
    val_size = int(len(datas) * val_ratio) #valデータのサイズ
    train_size = len(datas) - val_size #trainデータのサイズ
    return (train_size, val_size)


#データの分割:trainとvalに分割 ※分割率を設定
train_size, _ = train_val_split(paths_img , val_ratio=0.2) #学習:検証 = 8:2
# index_random = np.random.permutation(len(paths_img)).tolist() #ランダムに並び替えたインデックスを取得
# paths_img = [paths_img[i] for i in index_random] #ランダムに並び替えた画像のパスを取得
paths_img_train, paths_img_val = paths_img[:train_size], paths_img[train_size:] #trainとvalに分割


[OUT]
ー

4-2.データセット(ミニバッチ)の作成

 下記記事に記載の通り、PytorchのDataLoaderだと自分のデータにラベル付きでデータセットを(簡単に)作れないため、自作のデータセットを作成します。
※データセットを作成する時、ミスがあるとデータとラベルが正しく紐づけされないため、数個でよいので必ず出力結果は確認すること

  • データセットを作成:入力した画像のPath->PIL形式->前処理でTensorに変換->ラベルも取得して(Tensor, label)で出力(※最初はうまく紐づけできなかったため確認用として画像のPathも出力できるような形にしました)

[IN]
class Dataset_Image(Dataset):
    def __init__(self, file_list, transform=None, phase='train', return_path=False):
        self.file_list = file_list #画像のパスを格納したリスト
        self.transform = transform #前処理クラスのインスタンス
        self.phase = phase #train or valの指定
        self.return_path = return_path #画像のパスを返すかどうか
    
    def __len__(self):
        return len(self.file_list) #画像の枚数を返す
    
    def __getitem__(self, index):
        #画像の前処理
        img_path = self.file_list[index] #index番目の画像のパスを取得
        img = Image.open(img_path) #PIL形式で画像を読み込み
        img_transformed = self.transform(img, self.phase) #画像の前処理を実施
        
        #ラベルデータの取得
        df_labels = pd.read_csv(path_trainlabel) #データラベルをDataFrame形式で取得
        path = os.path.basename(img_path) #画像のパスからファイル名を取得
        
        label = df_labels[df_labels['image'] == path]['gender_status'].values[0] #画像のラベルを取得
            
        if self.return_path:
            return img_transformed, label, os.path.basename(img_path) #画像のtensor形式データ、ラベル、画像のパスを返す
        else:
            return img_transformed, label #画像のtensor形式データとラベルを返す


#Pytorchを使用するための前処理
def train_val_split(datas, val_ratio=0.1): #20%->10%に変更
    val_size = int(len(datas) * val_ratio) #valデータのサイズ
    train_size = len(datas) - val_size #trainデータのサイズ
    return (train_size, val_size)


#データの分割:trainとvalに分割 ※分割率を設定
train_size, _ = train_val_split(paths_img , val_ratio=0.2) #学習:検証 = 8:2
# index_random = np.random.permutation(len(paths_img)).tolist() #ランダムに並び替えたインデックスを取得
# paths_img = [paths_img[i] for i in index_random] #ランダムに並び替えた画像のパスを取得
paths_img_train, paths_img_val = paths_img[:train_size], paths_img[train_size:] #trainとvalに分割

train_dataset = Dataset_Image(file_list=paths_img_train, 
                               transform=ImageTransform(size, mean, std),
                               phase='train')

val_dataset = Dataset_Image(file_list=paths_img_val, 
                             transform=ImageTransform(size, mean, std),
                             phase='val')

# #前処理後のデータ確認
tensor, label = train_dataset[0]
print(tensor.shape, tensor.min(), tensor.max(), label)

train_dataloader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_dataloader = DataLoader(val_dataset, batch_size=32, shuffle=False) #valは意味がないためFalse
print('train:', len(train_dataloader), 'val:', len(val_dataloader)) #minibatch数の確認
print('train:', len(train_dataloader.dataset), 'val:', len(val_dataloader.dataset)) #データ数の確認

#train/valの使い分け用に辞書型変数にまとめる
dataLoaders_dict = {'train': train_dataloader, 'val': val_dataloader}

[OUT]
torch.Size([3, 224, 224]) tensor(-1.) tensor(0.6471) 4
train: 212 val: 27
train: 3391 val: 847

[OUT]

 上記の通り正しくラベリングできているか、また前処理がかかっているか確認します。

[IN]
#train_dataの前処理後画像を何個か可視化
fig, axes = plt.subplots(2, 5, figsize=(10, 5))
for i, (img, label) in enumerate(train_dataset):
    ax = axes[i//5, i%5]
    ax.imshow(img.numpy().transpose(1, 2, 0))
    ax.set_title(label)
    ax.axis('off')
    if i == 9:
            break

[OUT]

【コラム:データの紐づけ確認】
 最初の時にAccuracyがまともに出ないため原因を探っているとDatasetの作り方が悪いことに気づきました。今回の例だと画像だけでラベルが判断できないため、Datasetで処理した数値を再出力して確認しております。

[IN]
class HorizontalDisplay:
    def __init__(self, *args):
        self.args = args

    def _repr_html_(self):
        template = '<div style="float: left; padding: 10px;">{0}</div>'
        return "\n".join(template.format(arg._repr_html_())
                         for arg in self.args)

train_dataset_check = Dataset_Image(file_list=paths_img_train, 
                               transform=ImageTransform(size, mean, std),
                               phase='train',
                               return_path=True)

val_dataset_check = Dataset_Image(file_list=paths_img_val, 
                               transform=ImageTransform(size, mean, std),
                               phase='val',
                               return_path=True)

_d, _d_val = [], []
for i in range(20):
    img, label, path = train_dataset_check[i]
    _d.append([img.shape, label, path])
    # print(f'img.shape:{img.shape}, Filename: {path}, Label: {label}')

for i in range(20):
    img, label, path = val_dataset_check[i]
    _d_val.append([img.shape, label, path])
    
        
df_labels = pd.read_csv(path_trainlabel)
df_datasets = pd.DataFrame(_d, columns=['img.shape', 'label', 'path']).head(20)
df_datasets_val = pd.DataFrame(_d_val, columns=['img.shape', 'label', 'path']).head(20)
print(f'訓練用データセット:正解ラベルとデータセット')
display(HorizontalDisplay(df_labels, df_datasets))
print('検証用データセット:正解ラベルとデータセット')
display(HorizontalDisplay(df_labels.iloc[train_size:, :], df_datasets_val))


[OUT]

5.AIモデルの選定・作成

5-1.モデル設計

 今回は画像データ(+勉強用)のためCNN(畳み込み)モデルを使用します。モデル構造(Architecture)はいろいろ試しましたが最終的には「DenseNet121」が良かったです。

  • DenseNet

  • Inception V3(確かこれだけ入力画像サイズが違うため別処理が必要)

  • ResNet

  • VGG

  • ViT(現在学習中のため未検証ですが、一度やってみたいです)

5-2.モデルの作成:コード

 今回作成したモデルは下記の通りです。試した分はコメントアウトしておりますが、最終的なモデルは下記の通りです。

  • モデル:DenseNet121

  • データ比率 学習:検証=0.9:0.1

  • データアーギュメント:あり(ゴリゴリに追加)

  • 正規化の係数 mean, std = 0.5, 0.5

  • 学習方法:転移学習(モデルの一部だけ学習)

  • 学習Param:(epochs, lr, optim)=(200, 0.001, Adam)

https://udemy.benesse.co.jp/data-science/deep-learning/transfer-learning.html
[IN]

#Model1:VGG16をファインチューニング
# model_vgg16 = models.vgg16(pretrained=True) #VGG16のモデルを読み込み
# model_vgg16.classifier[6] = nn.Linear(model_vgg16.classifier[6].in_features, out_features=8) #出力層の変更
# print(model_vgg16) #モデルの確認


#Model2:ResNet18をファインチューニング
# model_resnet18 = models.resnet18(pretrained=True) #resnetのモデルを読み込み
# model_resnet18.fc = nn.Linear(model_resnet18.fc.in_features, out_features=8) #出力層の変更
# print(model_resnet18)

#Model3:resnet34をファインチューニング
# model_resnet34 = models.resnet34(pretrained=True) #resnetのモデルを読み込み
# model_resnet34.fc = nn.Linear(model_resnet34.fc.in_features, out_features=8) #出力層の変更
# print(model_resnet34)


#Model4:resnet152をファインチューニング
# model_resnet152 = models.resnet152(pretrained=True) #resnetのモデルを読み込み
# model_resnet152.fc = nn.Linear(model_resnet152.fc.in_features, out_features=8) #出力層の変更
# print(model_resnet152)


#Model5:inceptionをファインチューニング
# model_inception_v3 = models.inception_v3(pretrained=True) #resnetのモデルを読み込み
# model_inception_v3.fc = nn.Linear(model_inception_v3.fc.in_features, out_features=8) #出力層の変更
# # print(summary(model_inception_v3))
# print(model_inception_v3)


#Model6:VGG16を転移学習
# model_vgg16 = models.vgg16(pretrained=True) #VGG16のモデルを読み込み
# model_vgg16.classifier[6] = nn.Linear(model_vgg16.classifier[6].in_features, out_features=8) #出力層の変更

# for name, param in model_vgg16.named_parameters():
#     if 'classifier' in name:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False
        
# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_vgg16.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df # パラメータの名前と勾配の有無を確認


#Model7:VGG16を転移学習
# model_vgg16 = models.vgg16(pretrained=True) #VGG16のモデルを読み込み
# model_vgg16.classifier[6] = nn.Linear(model_vgg16.classifier[6].in_features, out_features=8) #出力層の変更

# for name, param in model_vgg16.named_parameters():
#     if 'classifier.3' in name or 'classifier.6' in name:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False

# #Dropoutの付け替え
# model_vgg16.classifier[2] = nn.Dropout(p=0.1, inplace=False)
# model_vgg16.classifier[5] = nn.Dropout(p=0.1, inplace=False)
        
# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_vgg16.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df # パラメータの名前と勾配の有無を確認


#Model7'':VGG16を転移学習
# model_vgg16 = models.vgg16(pretrained=True) #VGG16のモデルを読み込み
# model_vgg16.classifier[6] = nn.Linear(model_vgg16.classifier[6].in_features, out_features=8) #出力層の変更
# # print(model_vgg16)
# for idx, (name, param) in enumerate(model_vgg16.named_parameters()):
#     if idx >= 14:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False
        
# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_vgg16.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df # パラメータの名前と勾配の有無を確認


# Model8:resnet152を転移学習
# model_resnet152 = models.resnet152(pretrained=True) #resnetのモデルを読み込み
# model_resnet152.fc = nn.Linear(model_resnet152.fc.in_features, out_features=8) #出力層の変更
# for name, param in model_resnet152.named_parameters():
#     if 'fc' in name:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False

# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_resnet152.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df # パラメータの名前と勾配の有無を確認


#Model8:Densenet121_Fine Tuning
# model_densenet121 = models.densenet121(pretrained=True) #densenet121のモデルを読み込み
# model_densenet121.classifier = nn.Linear(model_densenet121.classifier.in_features, out_features=8) #出力層の変更



#Model9:Densenet121_転移学習 ※features.denseblock4.denselayer10移行を学習
# model_densenet121 = models.densenet121(pretrained=True) #densenet121のモデルを読み込み
# model_densenet121.classifier = nn.Linear(model_densenet121.classifier.in_features, out_features=8) #出力層の変更

# for idx, (name, param) in enumerate(model_densenet121.named_parameters()):
#     if idx >= 318:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False

# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_densenet121.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df.tail(50) # パラメータの名前と勾配の有無を確認



#Model8:Densenet169_Fine Tuning
# model_densenet169 = models.densenet169(pretrained=True) #densenet169のモデルを読み込み
# model_densenet169.classifier = nn.Linear(model_densenet169.classifier.in_features, out_features=8) #出力層の変更


#Model9:Densenet169_転移学習 ※402 features.denseblock4.denselayer16移行を学習
# model_densenet169 = models.densenet169(pretrained=True) #densenet169のモデルを読み込み
# model_densenet169.classifier = nn.Linear(model_densenet169.classifier.in_features, out_features=8) #出力層の変更

# for idx, (name, param) in enumerate(model_densenet169.named_parameters()):
#     if idx >= 402:
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False

# paramslist = [[name, params.size(), params.requires_grad] for name, params in model_densenet169.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# df.tail(110) # パラメータの名前と勾配の有無を確認


# Model11:Densenet121_転移学習 ※features.denseblock4.denselayer13移行を学習
model_densenet121 = models.densenet121(pretrained=True) #densenet121のモデルを読み込み
model_densenet121.classifier = nn.Linear(model_densenet121.classifier.in_features, out_features=8) #出力層の変更

for idx, (name, param) in enumerate(model_densenet121.named_parameters()):
    if idx >= 336:
        param.requires_grad = True
    else: 
        param.requires_grad = False

# print(model_densenet121)
paramslist = [[name, params.size(), params.requires_grad] for name, params in model_densenet121.named_parameters()] # パラメータの名前とサイズを取得
df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
df.tail(50) # パラメータの名前と勾配の有無を確認

#Model_12:

# super_net_name = "ofa_supernet_resnet50" 
# super_net = torch.hub.load('mit-han-lab/once-for-all', super_net_name, pretrained=True)
# super_net.classifier.linear.linear = nn.Linear(2048, 8)

# for idx, (name, param) in enumerate(super_net.named_parameters()):
#     if idx >= 156: 
#         param.requires_grad = True
#     else: 
#         param.requires_grad = False
        
        
# paramslist = [[name, params.size(), params.requires_grad] for name, params in super_net.named_parameters()] # パラメータの名前とサイズを取得
# df = pd.DataFrame(paramslist, columns=['name', 'size', 'requires_grad'])        
# # df.tail(50) # パラメータの名前と勾配の有無を確認
    


[OUT]
-

6.ハイパーパラメータの選定

 通常のハイパーパラメータはモデル内のパラメータですが、ここでは精度を良くするためのパラメータとして下記を設定します。
 なお学習時にデータを保存できるlogging関数も作成しました。

  • 最適化関数:Adam

  • 損失関数(目的関数):交差エントロピー

  • 学習率η

  • 学習回数(Epoch数)

[IN]
def logging_epoch(logs, epoch, loss, accuracy): #学習結果を辞書に格納する関数 
    logs['epoch'].append(epoch) #学習回数を格納 
    logs['loss'].append(loss) #損失関数を格納 
    logs['accuracy'].append(accuracy) #正解率を格納

logs = {'train':{'epoch':[], 'loss':[], 'accuracy':[]},
        'val':{'epoch':[], 'loss':[], 'accuracy':[]}} #学習結果を格納する辞書

def plot_logs(logs, title): #学習結果のグラフ化
    fig, ax = plt.subplots(1, 2, figsize=(15, 5))
    #損失関数のグラフ化
    ax[0].plot(logs['train']['epoch'], logs['train']['loss'], label='train', ls='--') #train
    ax[0].plot(logs['val']['epoch'], logs['val']['loss'], label='val') #val
    ax[0].set_xlabel('epoch')
    ax[0].set_ylabel('loss')
    ax[0].set_title('Time series of Loss')
    ax[0].legend(), ax[0].grid()
    #正解率のグラフ化
    ax[1].plot(logs['train']['epoch'], logs['train']['accuracy'], label='train', ls='--') #train
    ax[1].plot(logs['val']['epoch'], logs['val']['accuracy'], label='val') #val
    ax[1].set_xlabel('epoch')
    ax[1].set_ylabel('accuracy')
    ax[1].set_title('Time series of Accuracy')
    ax[1].legend(), ax[1].grid()
    fig.suptitle(f'Fig. {title}')
    plt.show()


#学習用パラメータの設定
epochs = 200 #学習回数
lr = 0.001 #学習率

#ハイパラ調整
# optimizer = optim.Adam(model_vgg16.parameters(), lr=lr)
# optimizer = optim.Adam(model_resnet18.parameters(), lr=lr)
# optimizer = optim.Adam(model_resnet34.parameters(), lr=lr)
# optimizer = optim.Adam(model_resnet152.parameters(), lr=lr)
# optimizer = optim.Adam(model_inception_v3.parameters(), lr=lr)
optimizer = optim.Adam(model_densenet121.parameters(), lr=lr)


criterion = nn.CrossEntropyLoss() #損失関数の設定:クロスエントロピー誤差

#GPUの設定
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
[OUT]
-

7.モデルの学習・保存

7-1.GPUへの転送

 GPUがないとまともに学習できないため変数をGPUに乗せます。GPUへの転送は”<tensor>.to(device)”で実行できます。
 なおGPU使用時はモデルと入力値ともにGPUに乗せる必要があるため、学習フェーズでまとめて実行します。

[IN]

[OUT]

7-2.学習・検証フェーズ

 それでは学習に入ります。学習では下記がありますが今回はミニバッチ学習を実施しました。

  • オンライン学習:1行ずつのデータで学習

  • バッチ学習:全データ(バッチ)を使用して学習

  • ミニバッチ学習:データを分割(ミニバッチ)

[IN]
%%time

early_stopping = EarlyStopping(patience=10, verbose=True)

#学習用関数の定義
def train_model(net,dataloaders, criterion, optimizer, epochs):
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    net = net.to(device)
    
    for epoch in range(epochs):
        print('Epoch {}/{}'.format(epoch+1, epochs))
        print('-------------')
        
        for phase in ['train', 'val']:
            if phase == 'train':
                net.train() #訓練モード
            else:
                net.eval() #評価モード
            
            epoch_loss = 0.0 #epochの損失和
            epoch_corrects = 0 #epochの正解数
            
            #未学習時のval性能を確認するため、epoch=0の時は学習させない
            if (epoch == 0) and (phase == 'train'):
                continue
            
            for inputs, labels in tqdm(dataloaders[phase]):
                #CUDAが使えるならGPUにデータを送る
                inputs = inputs.to(device) 
                labels = labels.to(device)
                
                optimizer.zero_grad() #勾配の初期化
                
                #順伝播(forward)の計算
                with torch.set_grad_enabled(phase == 'train'): #学習時は勾配を計算するが、評価時は勾配を計算しない
                    outputs = net(inputs) #順伝播
                    loss = criterion(outputs, labels) #損失の計算※ミニバッチの平均値
                    _, preds = torch.max(outputs, 1) #ラベルの予測
                    
                    if phase == 'train':
                        loss.backward() #逆伝播
                        optimizer.step() #重みの更新
                    
                    epoch_loss += loss.item() * inputs.size(0) #lossの合計を更新※ミニバッチの合計値
                    epoch_corrects += torch.sum(preds == labels.data) #正解数の合計を更新
        
            if epoch%5 == 0: #epochの表示間隔調整
                epoch_loss = epoch_loss / len(dataloaders[phase].dataset) #epochの損失和をデータ数で割る
                epoch_acc = epoch_corrects.double() / len(dataloaders[phase].dataset) #epochの正解率
                
                logging_epoch(logs[phase], epoch, epoch_loss, epoch_acc.item()) #ログの保存
                print(f'{phase}|epoch:{epoch}, Loss:{epoch_loss}, Accuracy:{epoch_acc}') #epochごとのlossと正解率の表示
            
            #EarlyStopping
            if phase == 'val':
                early_stopping(epoch_loss / len(dataloaders[phase].dataset), net) # 最良モデルならモデルパラメータ保存
                if early_stopping.early_stop: 
                    # 一定epochだけval_lossが最低値を更新しなかった場合、ここに入り学習を終了
                    break
            
            
# train_model(model_vgg16, dataLoaders_dict, criterion, optimizer, epochs=epochs)
# train_model(model_resnet18, dataLoaders_dict, criterion, optimizer, epochs=epochs)
# train_model(model_resnet34, dataLoaders_dict, criterion, optimizer, epochs=epochs)
# train_model(model_resnet152, dataLoaders_dict, criterion, optimizer, epochs=epochs)
# train_model(model_inception_v3, dataLoaders_dict, criterion, optimizer, epochs=epochs)

train_model(model_densenet121, dataLoaders_dict, criterion, optimizer, epochs=epochs) 

[OUT]
-

7-3.ログの可視化

 取得したログを可視化します(※epoch0のvalは1回目学習後の結果のためtrainより良くなります)。

[IN]
def plot_logs(logs, title): #学習結果のグラフ化
    fig, ax = plt.subplots(1, 2, figsize=(15, 5))
    #損失関数のグラフ化
    ax[0].plot(logs['train']['epoch'], logs['train']['loss'], label='train', ls='--') #train
    ax[0].plot(logs['val']['epoch'], logs['val']['loss'], label='val') #val
    ax[0].set_xlabel('epoch')
    ax[0].set_ylabel('loss')
    ax[0].set_title('Time series of Loss')
    ax[0].legend(), ax[0].grid()
    #正解率のグラフ化
    ax[1].plot(logs['train']['epoch'], logs['train']['accuracy'], label='train', ls='--') #train
    ax[1].plot(logs['val']['epoch'], logs['val']['accuracy'], label='val') #val
    ax[1].set_xlabel('epoch')
    ax[1].set_ylabel('accuracy')
    ax[1].set_title('Time series of Accuracy')
    ax[1].legend(), ax[1].grid()
    fig.suptitle(f'Fig. {title}')
    plt.show()

    
plot_logs(logs, title='densenet121')

[OUT]

7-4.モデルの保存

 作成したモデル(+学習履歴logs)を保存します。モデルの保存はtorch.save()を使用しますが、modelそのものをsave()しても後で読み込めなかったためパラメータも保存しました。

[IN]
import joblib

torch.save(model_densenet121, 'models/model_densenet121_転移7.pth') #モデルの重み保存
torch.save(model_densenet121.state_dict(), 'models/params_model_densenet121_転移7.pth') #モデルの重み保存
joblib.dump(logs, 'logs/model_densenet121_転移7.pkl') #ログの保存

[OUT]

8.モデルのチューニング

 CNNの転移学習では特にモデル側のチューニングは不要と思います。
 参考までにテーブルデータなどではOptunaが良く使われます。

9.推論・提出データ作成

 最後の流れは下記の通りです。

  • 学習済みモデルの呼び出し(学習直後にそのモデルを使用するなら不要)

  • テスト用データへの前処理

  • 推論

  • 提出用データの作成

[IN]
class Dataset_TestImage(Dataset):
    def __init__(self, file_list, transform=None):
        self.file_list = file_list #画像のパスを格納したリスト
        self.transform = transform #前処理クラスのインスタンス
        self.phase = 'val' #推論モードを設定
    
    def __len__(self):
        return len(self.file_list) #画像の枚数を返す
    
    def __getitem__(self, index):
        #画像の前処理
        img_path = self.file_list[index] #index番目の画像のパスを取得
        img = Image.open(img_path) #PIL形式で画像を読み込み
        img_transformed = self.transform(img, self.phase) #画像の前処理を実施
        return os.path.basename(img_path), img_transformed
        
# model_vgg16.load_state_dict(torch.load('models/params.pkl'))

paths_img_test = get_pathlist('./', 'test')

test_dataset = Dataset_TestImage(file_list=paths_img_test, 
                               transform=ImageTransform(size, mean, std))

test_dataloader = DataLoader(test_dataset, batch_size=64, shuffle=False)

def pred_testdata(model, device, dataloader):
    model.eval()
    
    model = model.to(device)
    y_pred = [] 
    imgpaths = []

    for imgpath, inputs in tqdm(dataloader): #test用のlabel=Noneで出力
        inputs = inputs.to(device)
        outputs = model(inputs)
        _, preds = torch.max(outputs, 1)
        imgpaths.extend(imgpath)
        y_pred.extend(preds.tolist())
        
    df_submit = pd.DataFrame([imgpaths, y_pred]).T
    df_submit.columns = ['image','gender_status']
    
    return df_submit
    
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 
df_submit = pred_testdata(model=model_densenet121, device=device, dataloader=test_dataloader)
df_submit
df_submit.to_csv('submits/densenet121.csv', header=True, index=False)

[OUT]

10.提出・結果確認

 データ提出はサイトの「結果を投稿」から提出可能です。

 結果は下記の通りです。そこそこ頑張ったのですが残念な結果でした。


11.全コード

 きれいな状態に整理する時間がないため追って

12.復習・反省

  • 結論としては「すごいモデルでも限界がある」ことが分かりました。こんなシンプルなタスクは”すごい機械学習モデルにGPUを使ってガンガン学習させれば簡単にいい結果でるやろ!”って思っていたのですが、想像以上に精度が上がらなかったです。

  • VGGは”シンプルでも性能強い”というイメージでしたが、特に今回のようにデータ数が少ない(4238枚)と過学習の影響が強く出てしまった。

  • ResNet以降で出てきたスキップ接続って今回のタスクでは過学習防止に非常に効果的だった!やっぱすごい。

  • DenseNetはResNetをベースに発展したらしいけど、結果的には今回の検証ではTOPだった(出典:代表的モデル「ResNet」、「DenseNet」を詳細解説!

  • タスクによってはCNNよりも高性能なモデル:ViTも出てきている。これは誰かが検証してくれるだろう・・・

  • 最初のDataset作成で画像とラベルの紐づけが出来ていなくて無駄な時間を過ごした。もっと確認する癖(作業手順の標準化)をつけんと・・

https://keras.io/ja/applications/

参考資料

あとがき

 みんな本気でやってなさそうなコンペで1位取れないのは不甲斐ない・・・・・

 

この記事が気に入ったらサポートをしてみませんか?