📚 背景知識(読んでから問題へ)
requires_grad=Falseで完全凍結し、新しい最終層だけ学習する。データが少ない・計算資源が少ないときに向く。Fine-tuning(微調整): 借りたノートの後半部分だけ自分の問題に合わせて書き換える。入力に近い汎用的な層は凍結したまま、出力に近いタスク固有の層だけ
requires_grad=Trueにして再学習する。データがある程度あり、元タスクとの相性が良いときにより高い精度が出やすい。
weights=ResNet18_Weights.IMAGENET1K_V1: ImageNetで事前学習済みの重みを指定してロードparam.requires_grad = False: そのパラメータの勾配計算・更新を停止=凍結model.fc.in_features: 最終層の直前から渡ってくるベクトルの次元数catastrophic forgetting(破滅的忘却): 大きすぎる学習率で事前学習済みの有用な重みを壊してしまう現象
🧊 凍結範囲の違い — どこまで「借りたまま」使うか
左(入力側)ほど「エッジ・色・テクスチャ」のような汎用的な特徴、右(出力側)ほど「1000クラス分類に特化した抽象的な特徴」を捉える。Fine-tuningモードでは出力に一番近いlayer4だけ凍結を解除し、自分のタスク(CIFAR10の10クラス分類)に合わせて特徴を寄せていく。
🎯 問題
CIFAR10(32×32・3チャンネル・10クラス)を題材に、torchvision.models.resnet18の事前学習済み重みを使って、以下3つを実装してください。
resnet18の全パラメータを凍結し、最終層model.fcをCIFAR10用(出力10クラス)の新しいnn.Linearに置き換えるmodel.layer4だけrequires_grad=Trueに戻し、fcも同様に置き換える💡 ヒント
なぜ「全レイヤーを常に自由に学習させる」のではなく、あえて一部を凍結するのでしょうか。CIFAR10の訓練データは5万枚とそれなりの量がありますが、もし手元のデータが数百枚しかない小規模なコンペだったら、ResNet18の全パラメータ(1000万以上)を自由に動かして学習すると何が起きるか、Day 088の過学習の話とつなげて考えてみましょう。
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)のように既存のfcのin_featuresを読み取ってから置き換えると、畳み込み部分の構造を変えずに済むmodel.layer4はresnet18の中で出力に一番近い畳み込みブロックの名前。ここだけrequires_grad=Trueに戻す- optimizerには
filter(lambda p: p.requires_grad, model.parameters())で学習対象だけを絞り込んで渡す
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)
模範解答の実行例(実際の数値は初期化・環境により変動します)。
🪜 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クラス分類向けの特徴」に寄せていく。layer1〜layer3は凍結したままにすることで汎用的な特徴を壊さずに保持する。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モードでは事前学習済み部分を大きく動かしすぎないよう、小さめの学習率を使うのが定石。🧮 数学・統計の補足(文系向け)
requires_grad=Falseにするということは、そのパラメータについて「動かす方向・量の計算(勾配計算)」自体を行わない、という意味になります。結果として何エポック回しても値が一切変わりません。「動かす量を毎回0にする」のとほぼ同じ結果ですが、計算自体をスキップするぶん高速・省メモリになります。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で「壊しすぎない」ように調整する |
optimizerにmodel.parameters()をそのまま渡してしまう | filterで絞り込む手間を省略する | 凍結パラメータを一緒に渡すこと自体はエラーにならないが、無駄な計算コストがかかり、意図が伝わりにくくなる |
🚀 次のステップ
- 発展:
model.layer3も追加で凍結解除し、「fcのみ」「layer4+fc」「layer3+layer4+fc」の3パターンで検証accuracyと学習時間を比較し、「解凍範囲を広げるほど精度は上がるが計算コストも増える」トレードオフを実際の数値で確認してみましょう - 次回予告: Day 118 — EfficientNet・timmライブラリ入門(より高精度なバックボーンの選び方)。今日はtorchvision標準の
resnet18を使いましたが、次回はKaggleで定番のtimmライブラリを使い、幅広いバックボーンの中から用途に合ったモデルを選ぶ方法を学びます
Phase 5 の学習マップ(全20テーマ予定)
Day 114〜116で自作CNNの構造・学習・過学習検知を一通り体験し、Day 117から事前学習済みモデルを使う転移学習フェーズへ移行。CVブロック(11-15)はResNet・EfficientNet・Augmentationの応用テクニックを扱う。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: