犬と猫の分類をPytorchでファインチューニングをしてやってみた

# はじめに
[コチラ](https://book.mynavi.jp/ec/products/detail/id=104855)の書籍でPytorchの勉強をしているのですが、実際に使わないと理解できないと思ったので、Kaggleの[犬猫コンペ](https://www.kaggle.com/c/dogs-vs-cats-redux-kernels-edition)をPytorchを使ってやってみた記録です。

モデルにはefficientnetB7を使ってファインチューニングを行いました。

# 環境
Google colabを使います。

Pytorch: 1.9.0+cu102
python: 3.7.11

# 実装の流れ
1.データの用意
2.データの前処理
3.データセットの作成
4.データローダーの作成
5.モデルの構築
6.損失関数、最適化アルゴリズムの定義
7.学習・検証
8.テストデータで推論

となります。1つずつ紹介します。

## 1. データの用意

今回の犬猫コンペの画像データは容量が重いのでGoogleDriveにアップロードするのもそこからデータ読み込ませるのも時間がかかります。

ですのでcolabのcontent直下に直接Kaggle APIを利用してデータをダウンロードします。

```python
from google.colab import files

uploaded = files.upload()

for fn in uploaded.keys():
  print('User uploaded file "{name}" with length {length} bytes'.format(
      name=fn, length=len(uploaded[fn])))

# Then move kaggle.json into the folder where the API expects to find it.
!mkdir -p ~/.kaggle/ && mv kaggle.json ~/.kaggle/ && chmod 600 ~/.kaggle/kaggle.json
```
上記コードを実行すると
jsonファイルをアップロードするように言われるのでkaggle.jsonファイルをアップロードします。

>Saving kaggle.json to kaggle.json
User uploaded file "kaggle.json" with length 63 bytes

こんな表示がされればokです。

```shell
!kaggle competitions download -c dogs-vs-cats-redux-kernels-edition
```
コンペのDataのページに表示されているコードをそのまま実行すればデータをダウンロードしてくれます。

ただこのままだとzipファイルのままなのでzipファイルを解凍する必要があります。

```python
import zipfile

with zipfile.ZipFile('/content/train.zip') as existing_zip:
    existing_zip.extractall('/content/train_img/')

with zipfile.ZipFile('/content/test.zip') as existing_zip:
    existing_zip.extractall('/content/test_img/')
```

`zipfile`モジュールを利用して、train_imgとtest_imgという名前で解凍してます。

---
データセットの用意の前に必要なものをimportしておきます

```python
import numpy as np
import random
import glob
from PIL import Image
import torch
import torch.nn as nn
import torch.optim as optim
import torch.utils.data as data
import torchvision
from tqdm import tqdm
from torchvision import models, transforms

from sklearn.model_selection import train_test_split
``` 

また再現性を担保するため乱数シードも設定します。

```python
torch.manual_seed(46)
np.random.seed(46)
random.seed(46)
```
櫻坂46が好きなので、乱数シードはいつも46です。

## 2. データの前処理

前処理を行うImageTransformクラスを定義します。

```python
class ImageTransform():
    def __init__(self, resize, mean, std):
        self.data_transform = {
            'train': transforms.Compose([
                transforms.Resize((resize, resize)),
                transforms.RandomHorizontalFlip(p=0.5),
                transforms.RandomCrop(size =(150, 150), padding=18),
                transforms.ToTensor(),
                transforms.Normalize(mean,std)
            ]),
            'val': transforms.Compose([
                transforms.Resize((resize,resize)),
                transforms.ToTensor(),
                transforms.transforms.Normalize(mean, std)
            ]),
            'test' : transforms.Compose([
                transforms.Resize((resize,resize)),
                transforms.ToTensor(),
                transforms.transforms.Normalize(mean, std)
            ])
        }

    def __call__(self, img, phase):
        return self.data_transform[phase](img)
```
trainデータ、valデータ、testデータそれぞれに行う前処理の内容をComposeを使ってまとめ、辞書化しております。

trainデータに関しては150×150にリサイズ、ランダム水平反転、ランダムクロップ、テンソル化、正規化を行なっています。

valデータとtestデータに関しては、水増しは行わず、リサイズとテンソル化、正規化だけ行います。

## 3.データセットの用意
次にDatasetを作成するDogCatDatasetクラスを定義します。

```python
class DogCatDataset(data.Dataset):
    def __init__(self, file_list, phase, transform = None):
        self.file_list = file_list
        self.transform = transform
        self.phase = phase

    def __len__(self):
        return len(self.file_list)
    
    def __getitem__(self, index):
        img_path = self.file_list[index]
        img = Image.open(img_path)

        img_trans = self.transform(
            img, self.phase)
        
        label = img_path.split('/')[-1].split('.')[0]

        if label == 'cat':
            label = 0
        elif label == 'dog':
            label = 1

        return img_trans, label
```

最終的なスコア提出の際に、犬である確率を提出する必要があるので、ラベルはcatを0,dogを1としています。

普段、画像の読み取りには、cv2を使ってたのでPILのImageに慣れるのに苦労しました。

---

実際に定義したクラスを使ってデータセットを作成していきます。

```python
train_list = glob.glob('/content/train_img/train/*')
test_path_list = glob.glob('/content/test_img/test/*')
```
trainデータとtestデータの画像パスを全部取得してリスト化します。

trainデータのうち25%を検証データとするため、画像パスをtrain_test_splitを使って分割します。
分けられたインデックスを使って、trainとvalの画像パスをリスト化します。

```python
train_idx, valid_idx = train_test_split(range(len(train_list)), test_size = 0.25, random_state = 46)

train_path_list = []
val_path_list = []
for index in train_idx:
    i = train_list[index]
    train_path_list.append(i)

for index in valid_idx:
    i = train_list[index]
    val_path_list.append(i)
```
画像のインデックス番号を分ける使い方は初めてだったのでちょっと感動しました。

```python
len(train_path_list), len(val_path_list), len(test_path_list)
# -> (18750, 6250, 12500)
```

データの枚数をチェックしておきましょう。

さて画像パスをリスト化できたので、このリストを元にdatasetを作ります。

```python
size = 150
mean = (0.5,0.5,0.5)
std = (0.5,0.5,0.5)

train_dataset = DogCatDataset(
    file_list = train_path_list, transform = ImageTransform(size, mean, std), phase = 'train')

val_dataset = DogCatDataset(
    file_list = val_path_list, transform = ImageTransform(size, mean, std), phase = 'val')

test_dataset = DogCatDataset(
    file_list = test_path_list, transform = ImageTransform(size, mean, std), phase = 'test')
```
今回全画像データの平均や標準偏差を計算するのがめんどくさかったので良くみられる、全て0.5で処理しています。

## 4. データローダーの作成

datasetが作成できれば、ここは簡単です。

```python
batch_size = 64
train_dataloader = data.DataLoader(
    train_dataset, batch_size=batch_size, shuffle = True)

val_dataloader = data.DataLoader(
    val_dataset, batch_size=batch_size, shuffle = False)

test_dataloader = data.DataLoader(
    test_dataset, batch_size=batch_size, shuffle = False)

dataloaders_dict = {'train': train_dataloader,
                    'val': val_dataloader,
                    'test': test_dataloader}
```
後でdataloaderを簡単に取り出しやすいようにまとめて辞書化しておきます。

## 5. モデル構築

まずPytorchでeffiientnetを使うために学習済みモデルをインストールします

```shell
!pip install efficientnet_pytorch
```

Efficientnet-b7をインスタンス化します。

```python
from efficientnet_pytorch import EfficientNet

model = EfficientNet.from_pretrained('efficientnet-b7')
```

最後の全結合層を今回は2値分類なので出力ユニットを変更します。

```python
num_ftrs = model._fc.in_features
model._fc = nn.Linear(num_ftrs, 2)
```

## 6. 損失関数、最適化アルゴリズムの定義

```python
loss_fn = nn.CrossEntropyLoss()

optimizer = optim.SGD(model.parameters(), lr = 0.1, momentum=0.9)

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)
```

損失関数はCrossEntropyLoss
最適化アルゴリズムはファインチューニングだとモーメンタム付きSGDが良さげとのことなので使用。
また学習率スケジューラーも使用します。

## 7. 学習・検証

学習を行う関数を定義します。

```python
def train_model(model, dataloaders_dict, loss_fn, optimizer, epochs, scheduler):
    history = {'loss':[], 'acc':[], 'val_loss':[], 'val_acc':[]}
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    model = model.to(device)
    for epoch in range(epochs):
        print(f'{epoch + 1} start')
        print('----------')

        for phase in ['train', 'val']:
            if phase == 'train':
                model.train()
            else:
                model.eval()

            epoch_loss = 0.0
            epoch_corrects = 0.0

            if (epoch == 0) and (phase == 'train'):
                continue

            for inputs, labels in tqdm(dataloaders_dict[phase]):
                inputs = inputs.to(device)
                labels = labels.to(device)

                optimizer.zero_grad()
                
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    loss = loss_fn(outputs, labels)
                    pred_value, pred_label = torch.max(outputs, 1)

                    if phase == 'train':
                        loss.backward()
                        optimizer.step()
                        
                    epoch_loss += loss.item() * inputs.size(0)
                    epoch_corrects += torch.sum(pred_label == labels.data)
            
            if phase == 'train':
                scheduler.step()
                
            epoch_loss /= len(dataloaders_dict[phase].dataset)*1.0
            epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset)*1.0

            print(f'{phase} Loss: {epoch_loss :.4f} Acc : {epoch_acc :.4f}')
            if phase == 'train':
                history['loss'].append(epoch_loss)
                history['acc'].append(epoch_acc)
            else:
                history['val_loss'].append(epoch_loss)
                history['val_acc'].append(epoch_acc)
```

Pytorchだと学習時に必要な情報を自分でカスタマイズできるので、好きですね。

じゃ学習を実行します。

```python
EPOCHS = 100

train_model(model, dataloaders_dict, loss_fn, optimizer, epochs=EPOCHS, scheduler)
```

100エポック終わらせるのに7時間くらいかかりました。

## 8. テストデータで推論

```python
labels = []
ids = []
cat_preds = []
dog_preds = []
with torch.no_grad():
    for image_path in tqdm(test_path_list):
        img = Image.open(image_path)
        test_transformer = ImageTransform(size, mean, std)
        img = test_transformer(img, phase = 'test')
        img = img.unsqueeze(0)
        img = img.to(device)
        model.eval()
        output = model(img)
        pred = nn.functional.softmax(output, dim = 1)[:].tolist()
        cat_preds.append(pred[0][0])
        dog_preds.append(pred[0][1])
        ids.append(int(image_path.split('/')[-1].split('.')[0]))
```

このうちのdog_predsリストに犬である確率が格納されているのでこれを提出します。

```python
import pandas as pd

submit = pd.DataFrame({'id': ids,
                       'label': dog_preds})


submit.sort_values(by='id', inplace=True)

submit.reset_index(drop=True, inplace=True)

submit.to_csv('/content/drive/MyDrive/dog vs cat/dogs-vs-cats-redux-kernels-edition/submission8.csv', index=False)
```

スコア⇩

![スクリーンショット 2021-09-16 2.05.23（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890361/b672fcfa-2e97-8be0-8450-fee81c89c55c.png)



# まとめ
普段Tensorflowを使ってますが、Pytorchだとコードは長くなる分、丁寧に記述していくので、理解が深まるなと思った。他の深層学習（GAN、物体検出など）もPytorchを使って実装していきたい。