発展PyTorch第１章~転移学習~ 個人勉強メモ

# はじめに

この記事は、「作りながら学ぶ発展PyTorch Deep Learning」の参考書を進めるにあたり、自分が残しておきたいメモを残すだけの記事です。本自体の発行年度も5~6年前なので、今のバージョンでより綺麗に書けるコードなどがあれば、個人的に変更しながら進めます。

## 第１章　転移学習とファインチューニング

### 概要
ImageNetで学習したVGG-16モデルで転移学習およびファインチューニングを行う。

### 公開モデルの取得

PyTorchでは、ImageNetデータセットで学習された学習済み分類モデルが取得可能である。以下が一覧
```bash
AlexNet
ConvNeXt
DenseNet
EfficientNet
EfficientNetV2
GoogLeNet
Inception V3
MaxVit
MNASNet
MobileNet V2
MobileNet V3
RegNet
ResNet
ResNeXt
ShuffleNet V2
SqueezeNet
SwinTransformer
VGG
VisionTransformer
Wide ResNet
```

`torchvision.models.list_models`で使用可能モデルの一覧も取得可能

```python
from torchvision.models import list_models
all_models = list_models()
# printすれば一覧取得可能
classification_models = list_models(module=torchvision.models)

# モデルのロードの仕方は色々ある
vgg16 = model.vgg16(weigths=VGG16_Weights.IMAGENET1K_V1)

vgg16 = get_model("vgg16", weights="DEFAULT")
```

その他の公開モデルに関して以下の公式ドキュメントに全て記載されているので適宜参照

https://pytorch.org/vision/main/models.html

### VGG16モデル

VGG16モデルの構成は以下のようになっている。全部で38層あるのにVGG-16なのは、畳み込み層と全結合層のみをカウントしているため。
基本的には`(CNN⇨ReLU)x2+MaxPooling`が5回続いて、画像サイズがだんだん小さく、特徴空間は`3(RGB)⇨64⇨128⇨...⇨512`と変化していく。最終的にAdaptiveAvgPool2d層で画像サイズは`7x7`に変換される。
そこからは全結合層で25088⇨4096⇨1000と出力チャネル数が変化し、1000クラス分類をする。

```bash
VGG(
  (features): Sequential(
    (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU(inplace=True)
    (2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU(inplace=True)
    (4): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (6): ReLU(inplace=True)
    (7): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (8): ReLU(inplace=True)
    (9): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (10): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (11): ReLU(inplace=True)
    (12): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (13): ReLU(inplace=True)
    (14): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (15): ReLU(inplace=True)
    (16): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (17): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (18): ReLU(inplace=True)
    (19): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (20): ReLU(inplace=True)
    (21): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (22): ReLU(inplace=True)
    (23): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (24): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (25): ReLU(inplace=True)
    (26): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (27): ReLU(inplace=True)
    (28): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (29): ReLU(inplace=True)
    (30): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(7, 7))
  (classifier): Sequential(
    (0): Linear(in_features=25088, out_features=4096, bias=True)
    (1): ReLU(inplace=True)
    (2): Dropout(p=0.5, inplace=False)
    (3): Linear(in_features=4096, out_features=4096, bias=True)
    (4): ReLU(inplace=True)
    (5): Dropout(p=0.5, inplace=False)
    (6): Linear(in_features=4096, out_features=1000, bias=True)
  )
)
```

#### (補足) CNNの計算

第１層目の`Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))`は、入力チャネル3(RGB)を64に拡張する。このとき、RGBの各チャネルにそれぞれ独立した64個のカーネルを用意するので、カーネルとしては`3x64=192個`用意される。したがって、第１層の重みは
```bash
3x64x3x3=1728個
```
次の層への入力となる出力64チャネルは、以下のように計算される。
```
y_j = b_j + Σ[i=1, C_in] x_i * W_{j,i}
```
`y_j`: 出力の特徴マップ（j番目のチャネル）
`b_j`: 出力チャネルごとのバイアス
`x_i`: 入力の特徴マップ（i番目のチャネル）
`W_{j,i}`: 出力チャネルj、入力チャネルiに対するカーネル（フィルタ）
*: 畳み込み演算（2次元）
`C_in`: 入力チャネル数
`C_out`: 出力チャネル数(jは1からC_outまで)

#### (補足)適応的プーリング層
Pytorchでは`nn.AdaptiveAvgPool2d(h, w)`で適応的プーリング層を呼び出せる。領域を適応的に計算するので、前後で割り切れない場合もいい感じに指定したサイズ変換をしてくれる。
```python
pool = nn.AdaptiveAvgPool2d((5, 10))  # 5(縦方向), 10(横方向)
```

### 画像の前処理
PyTorchでは、`torchvision.transforms.Compose`で前処理を定義できる。一般的な自然画像の場合、以下が基本
```python
transforms.Compose([
    transforms.Resize(resize), # 短い方がresizeに合わせられる。aspectは保たれる。
    transforms.ToTensor(),  # ← ココでRGBが0〜1に正規化される
    transforms.Normalize(mean, std),　# ImageNetの平均と分散に合わせて標準化
])
```
そのほかにも色々前処理の手法はあるので勉強

・**CenterCrop(size)** 画像中央をsize x sizeに切り取り
・**RandomHorizontalFlip()** 水平方向をランダムに反転
・**RandomResizedCrop(resize, scale=(0.5, 1.0), ratio = (3 / 4, 4 / 3))** scale指定の範囲で画像を拡大縮小、ratioに指定した範囲にアスペクト比を変更、最後にresizeで指定した大きさに画像切り出し


### 転移学習とファインチューニング

#### 違い
**転移学習**は出力層および出力層に近い部分のパラメータのみを再学習する。
一**ファインチューニング**は、全層のパラメータを再学習させる。ただし、入力層に近い部分のパラメータは学習率を小さくし、出力層に近い部分のパラメータは大きく設定するのが一般的である。

どちらもPyTorchでの処理は以下のような流れになる。以下はファインチューニングの例
モデル構造をprintか何かで把握し、学習させる層のパラメータを`required_grad=True`にする。


```python
# ファインチューニングで学習させるパラメータを、変数params_to_updateの1～3に格納する

params_to_update_1 = []
params_to_update_2 = []
params_to_update_3 = []

# 学習させる層のパラメータ名を指定
update_param_names_1 = ["features"]
update_param_names_2 = ["classifier.0.weight",
                        "classifier.0.bias", "classifier.3.weight", "classifier.3.bias"]
update_param_names_3 = ["classifier.6.weight", "classifier.6.bias"]

# パラメータごとに各リストに格納する
for name, param in net.named_parameters():
    if update_param_names_1[0] in name:
        param.requires_grad = True
        params_to_update_1.append(param)
        print("params_to_update_1に格納：", name)

    elif name in update_param_names_2:
        param.requires_grad = True
        params_to_update_2.append(param)
        print("params_to_update_2に格納：", name)

    elif name in update_param_names_3:
        param.requires_grad = True
        params_to_update_3.append(param)
        print("params_to_update_3に格納：", name)

    else:
        param.requires_grad = False
        print("勾配計算なし。学習しない：", name)

# 最適化手法の設定(入力層に近いほど学習率は小さくする)
optimizer = optim.SGD([
    {'params': params_to_update_1, 'lr': 1e-4},
    {'params': params_to_update_2, 'lr': 5e-4},
    {'params': params_to_update_3, 'lr': 1e-3}
], momentum=0.9)
```

#### (補足) 転移学習時の最適化手法について

本書において、転移学習時の最適化手法に**Momentum付きSGD**が使われていた。そこをAdamにするとどうなるか試したところ、初期エポックでの性能が大きく低下した。

結論：転移学習ならAdamより**Momentum付きSGD**を使う。

Adamは学習の最初期に学習率をパラメータごとに大きく調整する。そのため、すでに良い位置に事前学習されたパラメータを過剰に変化させてしまい、精度を大きく下げる懸念がある。
一方、**Momentum付きSGD**は、パラメータを慎重に更新するため、転移学習と相性が良い。