Day 117 — 転移学習入門 — 事前学習済みResNetの活用・Feature ExtractionとFine-tuningの違い

2026-08-07 紫 / Phase 5 理論→コーディング ResNet18 / Feature Extraction / Fine-tuning / requires_grad

📚 背景知識(読んでから問題へ)

🟣
転移学習(Transfer Learning)は「他人の勉強ノートを借りて、自分の科目だけ追加で勉強する」やり方です。ImageNet(120万枚・1000クラス)で何日もかけて学習済みのResNetなどのモデルは、すでに「エッジの検出」「色やテクスチャの識別」「形の認識」といった画像理解の基礎体力を持っています。この基礎体力を借りて、自分が解きたい問題だけを追加で教え込むのが転移学習です。
🔒
Feature Extraction(特徴抽出): 借りたノートには一切書き込まず、最後のページ(最終層)だけ自分用に作り直す。既存の重みはrequires_grad=Falseで完全凍結し、新しい最終層だけ学習する。データが少ない・計算資源が少ないときに向く。

Fine-tuning(微調整): 借りたノートの後半部分だけ自分の問題に合わせて書き換える。入力に近い汎用的な層は凍結したまま、出力に近いタスク固有の層だけrequires_grad=Trueにして再学習する。データがある程度あり、元タスクとの相性が良いときにより高い精度が出やすい。
🔑
キーワード
weights=ResNet18_Weights.IMAGENET1K_V1: ImageNetで事前学習済みの重みを指定してロード
param.requires_grad = False: そのパラメータの勾配計算・更新を停止=凍結
model.fc.in_features: 最終層の直前から渡ってくるベクトルの次元数
catastrophic forgetting(破滅的忘却): 大きすぎる学習率で事前学習済みの有用な重みを壊してしまう現象

🧊 凍結範囲の違い — どこまで「借りたまま」使うか

特徴抽出モード(Feature Extraction) conv1 layer1 layer2 layer3 layer4 fc(新規) 🔒 凍結(requires_grad=False) 🔓 学習対象 Fine-tuningモード(layer4 + fc) conv1 layer1 layer2 layer3 layer4 fc(新規) 🔒 凍結(汎用的な特徴はそのまま保持) 🔓 学習対象(小さめの学習率で微調整)

左(入力側)ほど「エッジ・色・テクスチャ」のような汎用的な特徴、右(出力側)ほど「1000クラス分類に特化した抽象的な特徴」を捉える。Fine-tuningモードでは出力に一番近いlayer4だけ凍結を解除し、自分のタスク(CIFAR10の10クラス分類)に合わせて特徴を寄せていく。

🎯 問題

CIFAR10(32×32・3チャンネル・10クラス)を題材に、torchvision.models.resnet18の事前学習済み重みを使って、以下3つを実装してください。

1
特徴抽出モードのモデルを構築する関数を書く: 事前学習済みresnet18の全パラメータを凍結し、最終層model.fcをCIFAR10用(出力10クラス)の新しいnn.Linearに置き換える
2
Fine-tuningモードのモデルを構築する関数を書く: 全パラメータを一旦凍結後、model.layer4だけrequires_grad=Trueに戻し、fcも同様に置き換える
3
両方をそれぞれ3エポック学習し、検証accuracyを比較する(メイン): optimizerには学習対象のパラメータのみ渡し、どちらが高精度になったか・その理由をコメントで記述する

💡 ヒント

ヒント1方向性

なぜ「全レイヤーを常に自由に学習させる」のではなく、あえて一部を凍結するのでしょうか。CIFAR10の訓練データは5万枚とそれなりの量がありますが、もし手元のデータが数百枚しかない小規模なコンペだったら、ResNet18の全パラメータ(1000万以上)を自由に動かして学習すると何が起きるか、Day 088の過学習の話とつなげて考えてみましょう。

ヒント2アプローチ
  • models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)で事前学習済みモデルを取得できる
  • for param in model.parameters(): param.requires_grad = Falseで全パラメータを凍結できる
  • model.fc = nn.Linear(model.fc.in_features, 10)のように既存のfcin_featuresを読み取ってから置き換えると、畳み込み部分の構造を変えずに済む
  • model.layer4resnet18の中で出力に一番近い畳み込みブロックの名前。ここだけrequires_grad=Trueに戻す
  • optimizerにはfilter(lambda p: p.requires_grad, model.parameters())で学習対象だけを絞り込んで渡す
ヒント3コード骨格
def build_feature_extractor():
    model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for param in model.parameters():
        param.requires_grad = False
    model.fc = nn.Linear(model.fc.in_features, 10)
    return model.to(device)

def build_fine_tune_model():
    model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for param in model.parameters():
        param.requires_grad = False
    # ここでlayer4だけ凍結を解除する
    model.fc = nn.Linear(model.fc.in_features, 10)
    return model.to(device)

def train_and_eval(model, epochs, lr):
    trainable_params = filter(lambda p: p.requires_grad, model.parameters())
    optimizer = optim.Adam(trainable_params, lr=lr)
    # --- 学習・評価ループ(Day115, 116と同様)---

模範解答

データ準備(ImageNetのmean/stdで正規化)

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
from torchvision.models import ResNet18_Weights

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 事前学習済みResNetはImageNetの統計量(mean/std)で正規化された224x224入力を前提にしている
transform = transforms.Compose([
    transforms.Resize(224),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

train_dataset = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
val_dataset = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

タスク1・2: 特徴抽出モードとFine-tuningモードの構築

def build_feature_extractor():
    model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for param in model.parameters():
        param.requires_grad = False
    model.fc = nn.Linear(model.fc.in_features, 10)  # 新規作成なのでrequires_grad=Trueがデフォルト
    return model.to(device)


def build_fine_tune_model():
    model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
    for param in model.parameters():
        param.requires_grad = False
    for param in model.layer4.parameters():
        param.requires_grad = True  # 出力に一番近い畳み込みブロックだけ凍結解除
    model.fc = nn.Linear(model.fc.in_features, 10)
    return model.to(device)

タスク3: 学習・評価・比較

def train_and_eval(model, epochs, lr):
    criterion = nn.CrossEntropyLoss()
    trainable_params = filter(lambda p: p.requires_grad, model.parameters())
    optimizer = optim.Adam(trainable_params, lr=lr)

    for epoch in range(epochs):
        model.train()
        running_loss, correct, total = 0.0, 0, 0
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)
            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item() * images.size(0)
            preds = outputs.argmax(dim=1)
            correct += (preds == labels).sum().item()
            total += labels.size(0)

        train_loss = running_loss / total
        train_acc = correct / total

        model.eval()
        val_correct, val_total = 0, 0
        with torch.no_grad():
            for images, labels in val_loader:
                images, labels = images.to(device), labels.to(device)
                outputs = model(images)
                preds = outputs.argmax(dim=1)
                val_correct += (preds == labels).sum().item()
                val_total += labels.size(0)
        val_acc = val_correct / val_total

        print(f"Epoch {epoch+1}/{epochs}: train_loss={train_loss:.4f}, train_acc={train_acc:.4f}, val_acc={val_acc:.4f}")

    return val_acc


print("=== 特徴抽出モード(Feature Extraction)===")
fe_model = build_feature_extractor()
fe_val_acc = train_and_eval(fe_model, epochs=3, lr=0.001)

print("=== Fine-tuningモード(layer4 + fc を再学習)===")
ft_model = build_fine_tune_model()
# layer4は事前学習済みの重みなので、fcより小さい学習率で「壊しすぎない」ように調整する
ft_val_acc = train_and_eval(ft_model, epochs=3, lr=0.0001)

print(f"特徴抽出モード  最終val_acc: {fe_val_acc:.4f}")
print(f"Fine-tuningモード 最終val_acc: {ft_val_acc:.4f}")
# 実行結果例(環境・乱数により変動する):
# 特徴抽出モード  最終val_acc: 0.7412
# Fine-tuningモード 最終val_acc: 0.8156
# → Fine-tuningの方が高くなりやすい。CIFAR10は5万枚と学習データが十分にあり、
#   layer4を自分のタスク用に微調整できる余地が活きるため。

📊 実行結果の比較イメージ(検証accuracy)

模範解答の実行例(実際の数値は初期化・環境により変動します)。

特徴抽出 Epoch 1
0.5820
fcのみ学習
特徴抽出 Epoch 3(最終)
0.7412
頭打ちが早い
Fine-tuning Epoch 1
0.6305
layer4+fc学習
Fine-tuning Epoch 3(最終)
0.8156
特徴抽出を上回る
🏁
3エポックという短い学習でも、Fine-tuningモードは特徴抽出モードより高い検証accuracyに到達しやすい傾向がある。学習対象のパラメータ数が多いぶん学習は重くなるが、CIFAR10のように学習データが十分ある場合はそのコストに見合う精度向上が得られやすい。

🪜 Step-by-Step 解説

1事前学習済みモデルをロードし、全パラメータを凍結する

model = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
for param in model.parameters():
    param.requires_grad = False
🔧
なぜこうするか: requires_grad = Falseにすると、そのパラメータはloss.backward()をしても勾配が計算されず、optimizer.step()をしても更新されなくなる。「借りたノートには書き込まない」状態を明示的に指定している。この一行を忘れると意図せず1000万以上のパラメータ全部を再学習してしまい、Fine-tuningのつもりが実質「ゼロから学習」に近くなる。

2最終層だけを自分のクラス数に合わせて置き換える

model.fc = nn.Linear(model.fc.in_features, 10)
📝
なぜこうするか: 元のfc層は1000クラス分類用に出力1000次元になっている。model.fc.in_featuresで直前の層から渡ってくる次元数を読み取ってから新しいnn.Linearを作ることで、畳み込み部分の構造を一切変更せずに最終層だけをすげ替えられる。新しい層はランダム初期化され、デフォルトでrequires_grad=Trueになるため、全部凍結した後でも自動的に学習対象になる。

3layer4だけ凍結を解除してFine-tuningモードを作る

for param in model.layer4.parameters():
    param.requires_grad = True
🧩
なぜこうするか: resnet18の層は入力に近いほど汎用的な特徴(エッジなど)、出力に近いほどタスク固有の特徴を捉える。layer4は出力に一番近い畳み込みブロックであり、ここを自分のデータで再学習することで「ImageNetの1000クラス分類向けの特徴」を「CIFAR10の10クラス分類向けの特徴」に寄せていく。layer1layer3は凍結したままにすることで汎用的な特徴を壊さずに保持する。

4optimizerには学習対象のパラメータだけを渡す

trainable_params = filter(lambda p: p.requires_grad, model.parameters())
optimizer = optim.Adam(trainable_params, lr=lr)
🚧
なぜこうするか: model.parameters()は凍結済みのものも含む全パラメータを返す。凍結パラメータをそのまま渡してもエラーにはならないが、無駄にメモリ・計算資源を使ううえ、コードを読んだ人に意図が伝わりにくくなる。filterで学習対象だけに絞り込むことで意図が明確になる。

5Fine-tuningモードの学習率をFeature Extractionより小さくする

ft_val_acc = train_and_eval(ft_model, epochs=3, lr=0.0001)  # 特徴抽出モードは lr=0.001
🎯
なぜこうするか: layer4はすでにそこそこ良い状態まで学習済みの重みであり、ランダム初期化されたfcとは状態が違う。同じ大きな学習率で更新すると、有用な情報を大きく壊してしまう「catastrophic forgetting」が起きやすい。Fine-tuningモードでは事前学習済み部分を大きく動かしすぎないよう、小さめの学習率を使うのが定石。

🧮 数学・統計の補足(文系向け)

📝
「凍結(freeze)」は数式で言うと「勾配を0にする」のと同じ効果: ニューラルネットワークの学習は各パラメータを「損失を減らす方向」に少しずつ動かす作業です(勾配降下法)。requires_grad=Falseにするということは、そのパラメータについて「動かす方向・量の計算(勾配計算)」自体を行わない、という意味になります。結果として何エポック回しても値が一切変わりません。「動かす量を毎回0にする」のとほぼ同じ結果ですが、計算自体をスキップするぶん高速・省メモリになります。
📐
なぜImageNetのmean/stdで正規化するのか: Day 007で扱った標準化(Zスコア)と同じ考え方。ResNet18は学習時、ImageNet全画像から計算した平均・標準偏差(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])で正規化した状態で学習されている。別の統計量で正規化すると、モデルが「見慣れた入力の分布」からずれた数値を受け取り、事前学習で得た知識をうまく活かせなくなる。「借りたノートの前提条件に自分のデータも合わせる」イメージ。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 画像認識(CV) / ☐ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 時系列(Time Series)

出発点

🧠 ゼロから学習するCNNはほぼ存在しない

上位陣は必ず事前学習済みから始める

画像系コンペで上位に入るチームはほぼ例外なくImageNet等で事前学習済みのバックボーン(ResNet・EfficientNet・ConvNeXtなど、多くはtimm経由)から出発する。

実務テクニック

🪜 段階的unfreeze(Progressive Unfreezing)

最初は全凍結、徐々に解凍範囲を広げる

まず全部凍結してfcだけ数エポック学習し、その後layer4→layer3と徐々に解凍範囲を広げる。ランダム初期化のfcが暴れて事前学習済み重みを巻き込むリスクを避けられる。

上級テクニック

⚖️ 差別学習率(Discriminative LR)

層ごとに学習率を変える

入力に近い層ほど小さく、出力に近い層ほど大きい学習率を設定する。optim.Adam([{"params": ..., "lr": 1e-5}, ...])のようにパラメータグループごとに指定できる。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
全レイヤーを常にFine-tuningした方が精度が上がると思い込む「たくさん学習させる=良いこと」という単純な連想データが少ない・元タスクとの相性が良い場合、全体を自由に動かすとかえって過学習しやすい。データ量とタスクの近さで凍結範囲を決める
独自のmean/stdで正規化してしまう自作CNNのDay 114〜116で(0.5, 0.5)を使っていた癖が抜けない事前学習済みモデルを使うときは、そのモデルが学習された時と同じ正規化(ImageNetならmean/stdの決まった値)を使う必要がある
requires_grad=Falseを設定する前にfcを置き換えてしまうコードの順序を意識していないfcを先に置き換えてから凍結すると、新しいfcまで凍結され学習が全く進まなくなる。「全部凍結→必要な部分だけ解凍・置き換え」の順序を守る
Feature ExtractionとFine-tuningで同じ学習率を使う学習率はハイパーパラメータの1つ程度の認識で深く考えない事前学習済みの重みとランダム初期化の重みでは適切な学習率が異なる。事前学習済み部分は小さめのlrで「壊しすぎない」ように調整する
optimizermodel.parameters()をそのまま渡してしまうfilterで絞り込む手間を省略する凍結パラメータを一緒に渡すこと自体はエラーにならないが、無駄な計算コストがかかり、意図が伝わりにくくなる

🚀 次のステップ

  • 発展: model.layer3も追加で凍結解除し、「fcのみ」「layer4+fc」「layer3+layer4+fc」の3パターンで検証accuracyと学習時間を比較し、「解凍範囲を広げるほど精度は上がるが計算コストも増える」トレードオフを実際の数値で確認してみましょう
  • 次回予告: Day 118 — EfficientNet・timmライブラリ入門(より高精度なバックボーンの選び方)。今日はtorchvision標準のresnet18を使いましたが、次回はKaggleで定番のtimmライブラリを使い、幅広いバックボーンの中から用途に合ったモデルを選ぶ方法を学びます

Phase 5 の学習マップ(全20テーマ予定)

1-5 DL基礎(PyTorch) 完了
6-10 NLP
11-15 CV ← 本日(①転移学習・ResNet)
16-18 時系列
19-20 マルチモーダル

Day 114〜116で自作CNNの構造・学習・過学習検知を一通り体験し、Day 117から事前学習済みモデルを使う転移学習フェーズへ移行。CVブロック(11-15)はResNet・EfficientNet・Augmentationの応用テクニックを扱う。

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: