0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

第7回では、Weight Decay、Dropout、BatchNorm、LayerNormなどを使って、過学習や訓練の不安定さを抑える方法を整理しました。

今回扱うのは、訓練したモデルをどう保存し、どう再開し、どう別のタスクへ使い回し、GPU環境でどう安全に動かすかです。具体的には、state_dict、checkpoint、torch.save / torch.loadweights_only=True、ファインチューニング、GPUのdevice管理、AMP、DataParallel、DDP、そしてよくあるエラーの切り分けを扱います。

モデルを一度だけ動かすなら、保存や復元は後回しでも困らないかもしれません。しかし、実験を再開する、最良モデルを評価する、別のPCやGPUへ移す、推論サービスへ載せる、事前学習済みモデルを使う、といった段階では、保存と読み込みの設計がそのまま実験の信頼性になります。

目的は、単に .pt ファイルを作ることではありません。何を保存すべきか、どの順番で復元するか、なぜ model.eval() が必要なのか、なぜGPUで保存したファイルをCPUで読むときに map_location が要るのかを、PyTorchのコードとつなげて理解することです。

目次

この記事で扱うこと

この記事では、次の内容を扱います。

  • torch.savetorch.load の役割
  • モデル全体ではなく state_dict を保存する理由
  • checkpointに入れるべきもの
  • weights_only=True と安全な読み込み
  • map_location によるCPU/GPUの読み込み先指定
  • load_state_dictstrictmissing_keysunexpected_keys
  • module. prefixや分類headのshape mismatchへの対応
  • ファインチューニングの基本戦略
  • torchvisionの重みオブジェクトと前処理
  • requires_grad、凍結、部分解凍、層ごとの学習率
  • BatchNormとDropoutの train() / eval() の注意点
  • device、Tensorの .to()、Moduleの .to()
  • CUDAの論理番号と CUDA_VISIBLE_DEVICES
  • GPUメモリの見方と empty_cache() の限界
  • AMP、DataParallel、DDP、torchrun
  • Dataset、transform、shape、dtype、device、checkpoint関連のよくあるエラー

コードは、小さなモデルで動作を確認できるものを中心にします。データセットが必要な箇所では、どのようなデータを用意すればよいかを説明します。

第7回から第8回へのつながり

第7回では、訓練中のモデルを安定させるための部品を見ました。今回のテーマは、その訓練結果をどう扱うかです。

第7回:訓練を安定させる
  -> Weight Decayで重みを制御する
  -> Dropoutで活性値へランダム性を入れる
  -> Normalizationで特徴の尺度を整える

第8回:訓練結果を運用できる形にする
  -> state_dictとして保存する
  -> checkpointから訓練を再開する
  -> 事前学習済みモデルを別タスクへ使う
  -> GPUや分散環境で安全に動かす
  -> エラーを体系的に切り分ける

訓練lossが下がっただけでは、まだ実験は終わりません。どのepochのモデルを使うのか、optimizerの状態を含めて再開できるのか、CPU環境で読み込めるのか、推論時にDropoutやBatchNormが正しいモードになっているのかを確認して、はじめて再利用しやすいモデルになります。

訓練資産の全体像

PyTorchで保存したいものは、モデルの重みだけとは限りません。

訓練中に管理したいもの
├── model parameters / buffers
├── optimizerの内部状態
├── schedulerの進み具合
├── AMPのGradScaler状態
├── epoch / global_step
├── best metric
├── class_to_idxや前処理設定
└── 乱数状態、設定、環境メモ

推論だけなら、モデルの state_dict と前処理設定が中心になります。訓練を途中から再開したいなら、optimizer、scheduler、scaler、epochなども必要です。

保存する
  model / optimizer / scheduler / scaler / metadata
      |
      v
復元する
  オブジェクトを作り直す -> state_dictを読み込む -> deviceへ移す
      |
      v
使う
  推論 / 評価 / 追加訓練 / ファインチューニング

この「何を保存したいのか」を先に決めておくと、.pt ファイルの中身が曖昧になりにくくなります。

シリアライズとデシリアライズ

実際に保存の話に入る前に、「シリアライズ」「デシリアライズ」という基本用語を確認しておきます。どちらもこの後何度も登場する言葉です。

シリアライズは、Python上のオブジェクトをファイルへ保存できる形に変換することです。デシリアライズは、保存された内容からPythonオブジェクトを復元することです。

メモリ上のTensorや辞書
  -- torch.save -->
ディスク上の .pt / .pth ファイル
  -- torch.load -->
PythonオブジェクトやTensor状態

PyTorchの保存形式は、Tensorの実データを保持する storage という仕組み(後で詳しく扱います)と、Pythonのpickleの仕組みに関係します。そのため、読み込み元を信頼できるかは重要です。特に、モデル全体をpickleとして保存したファイルは、読み込み時にPythonコードの実行へつながる可能性があります。

拡張子は .pt.pth をよく使います。ただし、拡張子だけでは中身が「重みだけ」なのか「checkpoint」なのかは分かりません。プロジェクト内では、ファイル名やREADMEで中身を分かるようにしておくと扱いやすくなります。

torch.savetorch.load

保存は torch.save で行います。

torch.save(obj, f)

役割:Tensor、state_dict、checkpoint辞書などをファイルへ保存します。

主な引数

引数 意味
obj 保存したいオブジェクト。Tensor、辞書、state_dict など
f 保存先のパス、または書き込み可能なファイルオブジェクト

読み込みは torch.load で行います。

state = torch.load(
    path,
    map_location="cpu",
    weights_only=True,
)

役割:保存されたファイルから、Tensorやcheckpoint辞書を読み込みます。

主な引数

引数 意味
f 読み込み元のパス、または読み込み可能なファイルオブジェクト
map_location 保存時と違うdeviceへ読み込むための指定
weights_only Tensorや基本的な型を中心に読み込む安全寄りの指定
mmap 大きなファイルを必要に応じて読み込むための指定

まずCPUへ読み込んでからモデルへ入れ、最後にモデルを目的のdeviceへ移す流れは、多くの環境で扱いやすいです。

長期保存は state_dict を中心に考える

torch.save/torch.load 自体は、Tensorでも辞書でも保存できる汎用的なAPIです。モデルを保存するときは、その汎用的なAPIに「何を渡すか」が特に重要になります。PyTorchでは、モデルを丸ごと保存するより、重みやbufferを持つ state_dict を保存するほうが扱いやすいです。

torch.save(model.state_dict(), "model_weights.pt")

読み込み時は、まず同じ構造のモデルをPythonコードで作り直し、その中へ重みを入れます。

model = MyModel(...)
state = torch.load("model_weights.pt", map_location="cpu", weights_only=True)
model.load_state_dict(state)
model.eval()

state_dict には、Parameter と永続的な Buffer が含まれます。たとえば、BatchNormの running_meanrunning_var も含まれます。register_buffer(..., persistent=False) で登録したBufferは対象外になる点には注意します。一方、forward() のPythonコードそのものは含まれません。

小さなモデルで確認します。

from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn


class TinyClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(4, 3),  # 4次元入力を3次元へ
            nn.ReLU(),
            nn.Linear(3, 2),  # 3次元から2クラス分のlogitへ
        )

    def forward(self, x):
        return self.net(x)


torch.manual_seed(0)

with TemporaryDirectory() as workdir:
    path = Path(workdir) / "tiny_weights.pt"

    model = TinyClassifier()
    torch.save(model.state_dict(), path)  # 重みだけをファイルへ書き出す

    state = torch.load(path, map_location="cpu", weights_only=True)
    restored = TinyClassifier()  # modelとは別の、新しいインスタンス
    missing_keys, unexpected_keys = restored.load_state_dict(state)  # 読み込んだ重みをrestoredへ流し込む
    restored.eval()

    print("keys:", list(state.keys()))
    # keys: ['net.0.weight', 'net.0.bias', 'net.2.weight', 'net.2.bias']
    print("missing:", missing_keys, "unexpected:", unexpected_keys)
    # missing: [] unexpected: []

state_dict のキーは、moduleの名前とパラメータ名から作られます。nn.Sequential の中では、012 のような番号がmodule名になります。保存後にモデル構造や名前を変えると、読み込み時にキーの不一致が起きることがあります。

モデル全体を torch.save(model, path) で保存することもできますが、長期保存ではクラス定義やimport pathへの依存が強くなります。ここでは、LeNet風の小さなモデルを使い、パラメータをすべて「訓練後の重み」に見立てた定数で置き換えたうえで、state_dict を保存してから読み戻す流れを確認します。

from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn


class LeNetLike(nn.Module):
    def __init__(self, classes):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 6, 5),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),
            nn.Conv2d(6, 16, 5),
            nn.ReLU(),
            nn.MaxPool2d(2, 2),
        )  # 畳み込みとpoolingで特徴マップを抽出する部分
        self.classifier = nn.Sequential(
            nn.Linear(16 * 5 * 5, 120),
            nn.ReLU(),
            nn.Linear(120, 84),
            nn.ReLU(),
            nn.Linear(84, classes),
        )  # 抽出した特徴からクラスを判定する部分

    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)  # batch次元を残したままフラット化する
        return self.classifier(x)

    @torch.no_grad()
    def initialize_constant(self, value):
        # デモ専用: 全パラメータを同じ定数にして「訓練後の重み」を再現する(実際の訓練では使わない)
        for parameter in self.parameters():
            parameter.fill_(value)


torch.manual_seed(0)

with TemporaryDirectory() as workdir:
    workdir = Path(workdir)
    state_path = workdir / "model_state_dict.pkl"

    model = LeNetLike(classes=2)
    print("before:", round(model.features[0].weight[0, 0, 0, 0].item(), 4))
    # before: -0.0009
    model.initialize_constant(20191104.0)  # 「訓練後の重み」として全パラメータを同じ値にする
    print("after:", round(model.features[0].weight[0, 0, 0, 0].item(), 1))
    # after: 20191104.0

    torch.save(model.state_dict(), state_path) # 推奨しやすい保存形式

    restored = LeNetLike(classes=2)
    restored.initialize_constant(0.0)  # 読み込み前の値をわざと0にして、復元されたことを確認しやすくする
    state = torch.load(state_path, map_location="cpu", weights_only=True)
    restored.load_state_dict(state)

    print("state keys head:", list(state.keys())[:3])
    # state keys head: ['features.0.weight', 'features.0.bias', 'features.3.weight']
    print("restored:", round(restored.features[0].weight[0, 0, 0, 0].item(), 1))
    # restored: 20191104.0
    print("file:", state_path.name)
    # file: model_state_dict.pkl

restored は別インスタンスですが、state_dict を読み込むことで model と同じ重みになります。重要なのは、読み込み側でも同じ構造の LeNetLike をコードで定義してから、重みだけを流し込んでいる点です。

訓練を再開するcheckpoint

推論だけならモデル重みだけで足りることがあります。しかし、訓練を途中から再開したい場合は、optimizerやschedulerの状態も必要です。次の例は、自分の訓練ループの中にある変数(epochmodeloptimizerなど)を、そのままcheckpointとしてまとめる形です。

checkpoint = {
    "format_version": 1,
    "epoch": epoch,
    "global_step": global_step,
    "model": model.state_dict(),
    "optimizer": optimizer.state_dict(),
    "scheduler": scheduler.state_dict(),
    "scaler": scaler.state_dict() if scaler is not None else None,
    "best_metric": best_metric,
    "config": config,
    "class_to_idx": class_to_idx,
}
torch.save(checkpoint, "checkpoint.pt")

checkpointには、次のような情報も入れると復元しやすくなります。

  • train/validの分割方法
  • 入力画像のresize、normalizeなどの前処理
  • ラベル名とindexの対応
  • 閾値や評価指標
  • 乱数状態
  • 実験設定
  • 利用した事前学習済み重みの名前やライセンス

保存中にプロセスが止まると、壊れたcheckpointだけが残ることがあります。対策として、一度一時ファイルへ保存してから置き換える方法があります。

temporary = checkpoint_path.with_suffix(".tmp")
torch.save(checkpoint, temporary)
temporary.replace(checkpoint_path)

実験では、last.ptbest.pt を分けて保存することも多いです。last.pt は直近の再開用、best.pt は検証指標が最も良かったモデルの評価用、という役割分担です。

ここでは、乱数から作った小さな TensorDataset を使い、「途中まで訓練を進め、checkpointを保存してから続きを再開する」流れを確認します。実際の画像分類プロジェクトでは、この TensorDataset の部分が画像フォルダを読み込むDatasetクラスに置き換わるだけで、保存・復元の考え方は同じです。schedulerも state_dict で保存・復元しておくと、再開後の学習率がずれにくくなります。

from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset


torch.manual_seed(0)

features = torch.randn(32, 4)
targets = (features.sum(dim=1) > 0).long()
loader = DataLoader(TensorDataset(features, targets), batch_size=8, shuffle=False)


def build_objects():
    # 毎回まっさらな状態のmodel/optimizer/schedulerをまとめて作るヘルパー
    model = nn.Sequential(
        nn.Linear(4, 8),
        nn.ReLU(),
        nn.Linear(8, 2),
    )
    optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=2, gamma=0.1)
    return model, optimizer, scheduler


def train_one_epoch(model, optimizer, criterion):
    # loaderを1周してパラメータを更新し、平均lossを返す
    model.train()
    total_loss = 0.0
    for inputs, labels in loader:
        logits = model(inputs)
        loss = criterion(logits, labels)
        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(loader)


criterion = nn.CrossEntropyLoss()

with TemporaryDirectory() as workdir:
    checkpoint_path = Path(workdir) / "checkpoint_2_epoch.pt"
    model, optimizer, scheduler = build_objects()

    for epoch in range(3):
        loss = train_one_epoch(model, optimizer, criterion)
        scheduler.step()
        if epoch == 2:  # epoch 2まで進めた時点のcheckpointを保存する
            torch.save(
                {
                    "epoch": epoch,
                    "model_state_dict": model.state_dict(),
                    "optimizer_state_dict": optimizer.state_dict(),
                    "scheduler_state_dict": scheduler.state_dict(),
                    "loss": loss,
                },
                checkpoint_path,
            )

    restored_model, restored_optimizer, restored_scheduler = build_objects()  # 新しいオブジェクト群を作り、訓練継続をゼロから再現する
    checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
    restored_model.load_state_dict(checkpoint["model_state_dict"])
    restored_optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
    restored_scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
    start_epoch = checkpoint["epoch"]

    print("resume from epoch:", start_epoch)
    # resume from epoch: 2
    print("restored lr:", restored_optimizer.param_groups[0]["lr"])
    # restored lr: 0.010000000000000002

    for epoch in range(start_epoch + 1, 5):
        loss = train_one_epoch(restored_model, restored_optimizer, criterion)
        restored_scheduler.step()
        print("epoch:", epoch, "loss:", round(loss, 4), "lr:", restored_optimizer.param_groups[0]["lr"])
        # epoch: 3 loss: 0.4695 lr: 0.0010000000000000002
        # epoch: 4 loss: 0.4569 lr: 0.0010000000000000002

この例では、epoch 2まで進めた状態を保存し、別の restored_modelrestored_optimizerrestored_scheduler を作ってから読み戻しています。optimizer_state_dict を読み込むことでmomentumなどの内部状態も戻り、scheduler_state_dict を読み込むことで再開時の学習率も保存時点に揃います。

安全な読み込みと weights_only

ここまではcheckpointの中身を安全だと仮定して読み込んできましたが、実際にはファイルの出所によって注意が必要です。PyTorchの読み込みでは、保存されたファイルの中身によってpickleの仕組みが関係します。信頼できないファイルをそのまま読み込むのは避けます。

基本方針は次の通りです。

  • 自分で作った、または出所を確認したファイルだけを読み込む
  • 重みやcheckpointは weights_only=True を優先する
  • モデル全体pickleが必要な場合は、信頼できるファイルだけに限定する
  • ファイルの入手元、ハッシュ、重みの種類を記録しておく
state = torch.load(
    "model_weights.pt",
    map_location="cpu",
    weights_only=True,
)

weights_only=True は、読み込み時に扱えるオブジェクトをTensorや基本型などへ寄せます。ただし、これでファイルの中身が正しいモデルであることまで保証されるわけではありません。安全性とモデル品質は別の問題です。

読み込み時に許可されない型があるというエラーが出た場合は、まず「本当にそのファイルを信頼できるか」を確認します。可能なら、いったん信頼できる環境で純粋な state_dict やcheckpoint辞書へ変換してから使います。

device mapping、mmap、storageの注意点

GPU上で保存したTensorをCPUだけの環境で読み込む場合は、map_location="cpu" を指定します。

state = torch.load(
    "model_weights.pt",
    map_location="cpu",
    weights_only=True,
)

複数GPU環境で保存されたdevice名を別のGPUへ割り当てたい場合は、辞書で指定できます。

state = torch.load(
    "checkpoint.pt",
    map_location={"cuda:1": "cuda:0"},
    weights_only=True,
)

Tensorにはstorageという実体があります。小さなsliceだけを保存したつもりでも、大きな元storageを共有していると、ファイルサイズが想定より大きくなることがあります。

import torch


large = torch.arange(1_000_000)
small_view = large[:5]
small_to_save = small_view.clone()  # 必要な5要素だけを独立したTensorにする

small_view をそのまま保存すると、内部的には large 全体分のstorageが書き込まれます。small_to_save のようにcloneしてから保存すれば、5要素分のstorageだけが書き込まれます。

大きなcheckpointでは mmap=True が役立つ場合があります。ただし、ファイルシステム、アクセスパターン、checkpoint形式によって効果が変わるため、実際の環境で測って判断します。

load_state_dict とキーの不一致

load_state_dict は、モデル側のキーとcheckpoint側のキーを照合します。

result = model.load_state_dict(state, strict=False)
print(result.missing_keys)
print(result.unexpected_keys)
引数 意味
strict=True モデル側とcheckpoint側のキーが一致することを求める
strict=False 欠けているキーや余分なキーを許す

strict=False は便利ですが、すべてを無視してくれる魔法ではありません。同じキーが存在していてshapeだけ違う場合は、エラーになることがあります。分類headのクラス数を変えるときは、読み込み前にheadの重みを除く、またはbackboneだけを読み込む、といった対応が必要です。

小さな例で、headだけを読み込まない場合を確認します。

import torch
from torch import nn


class TinyTransferModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Linear(4, 3)  # 事前学習済みとして扱う部分
        self.head = nn.Linear(3, 2)  # タスクに合わせて後から差し替える部分

    def forward(self, x):
        x = torch.relu(self.backbone(x))
        return self.head(x)


model = TinyTransferModel()
state = model.state_dict()
backbone_only_state = {
    key: value
    for key, value in state.items()
    if not key.startswith("head.")
}  # "head."で始まらないキーだけを残す(=headの重みを除く)

result = model.load_state_dict(backbone_only_state, strict=False)
print("missing:", result.missing_keys)
# missing: ['head.weight', 'head.bias']
print("unexpected:", result.unexpected_keys)
# unexpected: []

DataParallelやDDPで包んだモデルを保存すると、キーの先頭に module. が付くことがあります。基本的には、包む前のmoduleを保存するほうが扱いやすいです。

raw_model = model.module if hasattr(model, "module") else model
torch.save(raw_model.state_dict(), "model_weights.pt")

すでに module. が付いたファイルを読む場合は、prefixを正確に取り除きます。

import torch


state = {
    "module.backbone.weight": torch.ones(2, 2),
    "module.head.bias": torch.zeros(2),
}  # DataParallel/DDPで保存された想定のキー
cleaned_state = {
    key.removeprefix("module."): value
    for key, value in state.items()
}  # 先頭の"module."だけを取り除く

print(list(cleaned_state.keys()))
# ['backbone.weight', 'head.bias']

単純に先頭7文字を削るのではなく、removeprefix("module.") のように、prefixがある場合だけ取り除く形にします。

途中再開と再現性の状態

訓練を途中から再開する流れは、次のように考えると整理しやすいです。

モデルを作る
 -> optimizer / scheduler / scalerを作る
 -> checkpointを読み込む
 -> model.load_state_dict
 -> optimizer.load_state_dict
 -> scheduler / scalerを復元する
 -> epochやglobal_stepを復元する
 -> 必要なら乱数状態も復元する
 -> 訓練を続ける

乱数状態を保存する例です。

rng_state = {
    "torch": torch.get_rng_state(),
    "cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
}

この rng_state を、先ほどの checkpoint 辞書に追加のキーとしてまとめておくと、再開時に乱数状態も一緒に復元できます。Pythonの random やNumPyを使っているなら、それらの状態も保存します。ただし、乱数状態を保存しても、GPUドライバ、cuDNN、DataLoader worker、分散構成、非決定的な演算などによって、完全に同じ結果にならないことがあります。

optimizerの状態にはTensorが含まれます。読み込み後にdeviceが合わない場合は、モデルをどの順番でdeviceへ移したか、optimizerをいつ作ったか、optimizer stateのTensorがどのdeviceにあるかを確認します。

分散checkpointとactivation checkpointing

ここまでは、1つのプロセスで完結する保存・復元を見てきました。モデルが大きくなり、複数プロセス・複数GPUで訓練するようになると、保存の考え方も少し変わります。

大きなモデルや分散訓練では、1つのファイルへ torch.save するだけでは扱いにくいことがあります。この場合は、torch.distributed.checkpoint のような仕組みを検討します。

分散checkpointでは、複数rankがそれぞれ保存し、読み込み時に再び必要な形へ割り当てます。FSDPなどでモデルが分割されている場合、通常の単一ファイルcheckpointとは設計が変わります。

ここで注意したいのは、activation checkpointingとは別物だという点です。

訓練checkpoint:
  訓練状態をディスクへ保存し、後で復元する

activation checkpointing:
  順伝播の中間活性を保存しすぎず、逆伝播時に再計算してGPUメモリを節約する

名前は似ていますが、目的も使うAPIも違います。

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

ここからは、保存・復元の話から離れて、事前学習済みモデルをどう活用するかというテーマに移ります。転移学習は、あるデータやタスクで学習した表現を、別のタスクへ活用する考え方です。ファインチューニングは、その事前学習済みモデルを目標タスクのデータでさらに訓練する方法です。

事前学習済みモデル
├── backbone / feature extractor
└── task head / classifier

よくある戦略は次の通りです。

戦略 内容 向いている場面
線形probe backboneを凍結し、新しいheadだけを訓練する 目標データが少ない、タスクが似ている
部分解凍 出力に近いblockだけを追加で訓練する 少し適応させたい
全体ファインチューニング 全パラメータを訓練する 目標データが多い、領域差が大きい
パラメータ効率のよい調整 adapterやLoRAなど追加パラメータを訓練する 大きなモデルを少ない更新量で調整したい

目標データが少ないほど、backboneを大きく動かすと過学習しやすくなります。逆に、事前学習のデータと目標データの性質が大きく違う場合は、凍結しすぎると十分に適応できないことがあります。検証データで確認しながら、どこまで解凍するかを決めます。

torchvisionの重みと前処理

torchvisionの事前学習済みモデルを使うときは、重みオブジェクトから前処理も取得できます。

from torchvision.models import resnet18, ResNet18_Weights


weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)
preprocess = weights.transforms()

重みオブジェクトには、次のような情報がまとまっています。

  • 推論時の前処理
  • class名
  • パラメータ数や評価指標などのメタ情報
  • 学習recipeへのリンク

resnet18(weights=weights) を初めて実行すると、重みファイル(resnet18-f37072fd.pth、約44.7MB)が公式サーバーからダウンロードされ、既定では ~/.cache/torch/hub/checkpoints/(Windowsでは C:\Users\<ユーザー名>\.cache\torch\hub\checkpoints\)にキャッシュされます。2回目以降はキャッシュから読み込まれるため、ダウンロードは通常1回だけで済みます。

ImageNet事前学習済みResNet-18では、入力画像を一定サイズへresize / cropし、[0, 1] の値域にしたうえで、ImageNetのmean/stdでNormalizeする前処理が使われます。訓練時のaugmentationはタスクに合わせて変えてよいですが、値域とNormalizeの前提を崩さないことが大切です。

事前学習済み重みを使うときは、重み自体や元データのライセンスも確認します。研究や個人実験では問題にならなくても、配布や商用利用では条件が変わることがあります。

凍結、部分解凍、層ごとの学習率

backboneを凍結するには、requires_grad_(False) を使います。

import torch
from torch import nn


class TinyFineTuneModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Linear(4, 4),
            nn.ReLU(),
        )  # 事前学習済みとして扱う部分(ここでは凍結する)
        self.head = nn.Linear(4, 2)  # タスク用に訓練する部分

    def forward(self, x):
        return self.head(self.backbone(x))


model = TinyFineTuneModel()
for parameter in model.backbone.parameters():
    parameter.requires_grad_(False)  # backboneの勾配計算を止める

optimizer = torch.optim.AdamW(
    (parameter for parameter in model.parameters() if parameter.requires_grad),  # 勾配計算が有効なパラメータだけをoptimizerに渡す
    lr=1e-3,
)

trainable_names = [
    name for name, parameter in model.named_parameters()
    if parameter.requires_grad
]
print(trainable_names)
# ['head.weight', 'head.bias']

学習率を0にするだけでもパラメータ更新は止まりますが、勾配計算やoptimizer stateは残る場合があります。計算量や意図の明確さを考えると、凍結したいパラメータには requires_grad=False を使うほうが分かりやすいです。

層ごとに学習率を変える場合は、optimizerのパラメータグループを使います。次の例は、layer4fcという属性名を持つResNetスタイルのモデルを想定しています(ResNet-18の具体例は後で示します)。

optimizer = torch.optim.AdamW(
    [
        {"params": model.layer4.parameters(), "lr": 1e-4},  # backbone終盤のblockは小さめの学習率
        {"params": model.fc.parameters(), "lr": 1e-3},       # 新しく置き換えたheadは大きめの学習率
    ],
    weight_decay=1e-2,
)

最初はheadだけを訓練し、次に最後のblockを小さい学習率で解凍し、必要なら全体をさらに小さい学習率で調整する、という段階的な進め方もよく使われます。

解凍したパラメータが、optimizerに入っているかも確認します。最初にheadだけでoptimizerを作った場合、後からbackboneを解凍しても、そのパラメータは自動ではoptimizerに追加されません。optimizerを作り直すか、add_param_group を使います。

BatchNormとDropoutのファインチューニング時の動き

requires_grad=False は勾配を止めるだけで、moduleの訓練モードを変えるわけではありません。

操作 Dropout BatchNorm
model.train() ランダムに落とす batch統計量を使い、running statisticsを更新する
model.eval() 何も落とさない running statisticsを使い、更新しない
requires_grad=False modeは変わらない running statisticsの更新も自動では止まらない

backboneを凍結してheadだけを訓練する場合、backbone内のBatchNormを eval() にして、事前学習済みのrunning statisticsを保つことがあります。

from torch import nn


model.train()  # モデル全体は訓練モードにする(Dropoutなどは有効なまま)
for module in model.backbone.modules():
    if isinstance(module, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)):
        module.eval()  # BatchNormだけrunning statisticsの更新を止める

小さいデータで事前学習済みBatchNormの統計量を更新すると、表現が崩れることがあります。一方、目標データの分布が大きく違う場合は、完全に固定するより更新したほうがよいこともあります。ここも検証データで決めます。

ResNet-18で2クラス分類を始める例

ここでは、ResNet-18のbackboneを使い、最後の分類層だけを2クラス用に置き換える例を示します。

import torch
from torch import nn
from torchvision.models import resnet18, ResNet18_Weights


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

weights = ResNet18_Weights.DEFAULT
model = resnet18(weights=weights)  # ImageNet事前学習済みの重みを読み込む

for parameter in model.parameters():
    parameter.requires_grad_(False)  # まずは全パラメータの勾配計算を止める

in_features = model.fc.in_features
model.fc = nn.Linear(in_features, 2)  # 新しいLinearに置き換える(新しいパラメータはrequires_grad=Trueが既定)
model.to(device)

optimizer = torch.optim.AdamW(model.fc.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
preprocess = weights.transforms()

このコードは、model.fc だけを訓練対象にしています。model.fc = nn.Linear(in_features, 2) で置き換えた時点で新しいパラメータが作られるため、その前にモデル全体を凍結していても、新しい model.fc のパラメータは requires_grad=True のままです。そのため、optimizer に渡すのも model.fc.parameters() だけで十分です。preprocess は事前学習済み重みが想定する推論前処理です。訓練時はランダムcropやflipなどを入れることがありますが、Normalizeのmean/stdや入力値域は重みの前提と合わせます。

2クラス分類の練習には、PyTorch公式チュートリアルで使われる hymenoptera_data(アリとハチの画像データセット)が使えます。訓練用がアリ・ハチ合わせて約240枚、検証用が合わせて約150枚という小さなデータセットで、展開すると train/antstrain/beesval/antsval/bees のようにクラスごとのフォルダへ分かれています。画像データそのものはこの記事に含まれていないため、使う場合は公式配布元(https://download.pytorch.org/tutorial/hymenoptera_data.zip)から取得して展開します。手元にある2クラス画像フォルダを使う場合も、同じように train/class_atrain/class_bval/class_aval/class_b のように分ければ、torchvision.datasets.ImageFolder で読み込める形になります。

データ量が小さい場合、1回のsplitやseedだけで結論を出すと揺れやすくなります。検証データを固定し、必要なら複数seedで確認します。

次のコードは、backbone側とhead側で学習率を分ける具体例です。重みのダウンロードや画像データなしでも構造を確認できるように、weights=None とダミー入力で動かしています。実データで使うときは、前のコードのように ResNet18_Weights.DEFAULTweights.transforms() を使います。

import torch
from torch import nn
from torchvision.models import resnet18


torch.manual_seed(0)
classes = 2
lr = 1e-3
model = resnet18(weights=None)  # 構造確認用。実データでは事前学習済みweightsを指定する

num_features = model.fc.in_features
model.fc = nn.Linear(num_features, classes)  # 最後の分類headを2クラス用に差し替える

fc_param_ids = {id(parameter) for parameter in model.fc.parameters()}
base_params = [
    parameter for parameter in model.parameters()
    if id(parameter) not in fc_param_ids
]
optimizer = torch.optim.SGD(
    [
        {"params": base_params, "lr": lr * 0.1},      # backboneは小さめの学習率
        {"params": model.fc.parameters(), "lr": lr}, # 新しいheadは大きめの学習率
    ],
    momentum=0.9,
)

example = torch.randn(2, 3, 224, 224)
logits = model(example)
print("fc in_features:", num_features)
# fc in_features: 512
print("fc out_features:", model.fc.out_features)
# fc out_features: 2
print("param group lrs:", [group["lr"] for group in optimizer.param_groups])
# param group lrs: [0.0001, 0.001]
print("logits shape:", logits.shape)
# logits shape: torch.Size([2, 2])

backbone側の学習率を 0 にする方法もあります。これは更新量を0にできますが、勾配計算自体は残ります。完全に凍結したい場合は、前の節で示したように requires_grad_(False) を使い、optimizerへ渡すパラメータからも外すほうが意図を読み取りやすくなります。

CPU、GPU、deviceの選び方

ここまでのコード例はCPU上で動く小さなモデルが中心でしたが、実際の画像分類やファインチューニングではGPUを使う場面が多くなります。ここからは、PyTorchでCPUとGPUをどう使い分けるかを見ていきます。PyTorchでは、TensorとModuleをどのdeviceに置くかを明示します。よく使う基本形は次の通りです。

import torch


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

GPUは、大きなTensor演算を大量に並列実行すると強いです。一方、小さなモデル、小さすぎるbatch、CPUとGPUの転送が多い処理では、GPUにしただけで必ず速くなるとは限りません。

device情報を確認する例です。

import torch


print("cuda available:", torch.cuda.is_available())
# cuda available: False
print("cuda device count:", torch.cuda.device_count())
# cuda device count: 0
if torch.cuda.is_available():
    print("device 0:", torch.cuda.get_device_name(0))

cuda:0 のように番号を指定して、特定のGPUへTensorを移動することもできます。CPU環境でも動作を確認できるコードにすると、次のようになります。

import torch


gpu_id = 0
gpu_str = f"cuda:{gpu_id}"
device = torch.device(gpu_str if torch.cuda.is_available() else "cpu")

x_cpu = torch.ones((3, 3))
x_device = x_cpu.to(device)

print("device:", x_device.device, "is_cuda:", x_device.is_cuda, "shape:", tuple(x_device.shape))
# device: cpu is_cuda: False shape: (3, 3)
print("device_count:", torch.cuda.device_count())
# device_count: 0
if torch.cuda.is_available():
    print("device_name:", torch.cuda.get_device_name(0))
else:
    print("device_name: CPU only")
    # device_name: CPU only

CUDA環境では devicecuda:0 になり、x_device.is_cudaTrue になります。CPU環境ではそのままCPU Tensorとして動くので、記事のコード確認にも使いやすいです。

CUDA以外にも、環境によってMPSやXPUなどのbackendがあります。ただし、使える演算や速度はbackendごとに違うため、実際のコードで確認します。

TensorとModuleの .to() の違い

deviceを選べるようになったところで、次はTensorとModuleそれぞれの .to() の挙動の違いを確認します。ここは初学者がつまずきやすいポイントです。Tensorの .to() は、変換が必要なとき新しいTensorを返します。そのため、戻り値を受け取る必要があります。

import torch
from torch import nn


x = torch.ones(2)
y = x.to(dtype=torch.float64)

print(x.dtype, y.dtype, x is y)
# torch.float32 torch.float64 False

linear = nn.Linear(2, 1)
returned = linear.to("cpu")
print(returned is linear)
# True

Moduleの .to() は、登録済みのParameterやBufferを移動し、module自身を返します。通常は、モデルをdeviceへ移してからoptimizerを作ると、状態管理が分かりやすくなります。

model = MyModel().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

入力、target、モデル、lossのweight、mask、新しく作るTensorが同じdevice上にあるかを確認します。既存Tensorと同じdevice/dtypeで新しいTensorを作るなら、次のように書けます。

mask = images.new_zeros(images.shape[0], dtype=torch.bool)

DataLoaderで pin_memory=True を使っている場合、GPU転送で non_blocking=True を指定することがあります。

images = images.to(device, non_blocking=True)
targets = targets.to(device, non_blocking=True)

実際に非同期転送になるかは、pinned memory、device、処理の重なり方に依存します。

CUDA deviceと論理番号

ここまでは1つのGPUを使う前提でしたが、複数GPUがある環境では、番号の付き方にも注意が必要です。複数GPU環境では、プロセスから見えるGPUを環境変数で絞ることがあります。

CUDA_VISIBLE_DEVICES=2,3 python train.py

この場合、プロセス内では次のように番号が付け直されます。

物理GPU 2 -> 論理 cuda:0
物理GPU 3 -> 論理 cuda:1

Pythonコードの中でCUDA初期化後に環境変数を変更しても、期待通りに効かないことがあります。GPUの割り当ては、起動コマンドやジョブスケジューラ側で決めるほうが安全です。

DDPでは、各プロセスが1枚のGPUを担当する形が基本です。その場合、local rankに合わせてdeviceを設定します。

torch.cuda.set_device(local_rank)
device = torch.device("cuda", local_rank)

乱数seedも、CPUとCUDAの両方を意識します。

torch.manual_seed(seed)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)

seedを固定しても、すべての演算が完全に同じになるとは限りません。厳密な再現性が必要な場合は、使用する演算、DataLoader worker、分散構成まで含めて設計します。

GPUメモリの見方

GPUを使ってさえいれば安心、というわけではありません。実際にどれくらいメモリを使っているかを把握することも重要です。PyTorchのCUDA allocatorでは、実際にTensorが使っているメモリと、allocatorが予約しているメモリを分けて考えます。

API 意味
memory_allocated() 生きているTensorが使っているメモリ
memory_reserved() PyTorch allocatorがCUDAから予約しているメモリ
max_memory_allocated() これまでの最大使用量
memory_summary() メモリ状況の概要
if torch.cuda.is_available():
    allocated = torch.cuda.memory_allocated() / 1024**2
    reserved = torch.cuda.memory_reserved() / 1024**2
    print("allocated MB:", round(allocated, 2))
    print("reserved MB:", round(reserved, 2))

GPU環境では、allocated MB: ...reserved MB: ...のように、実際に使用中/確保済みのメモリ量(MB)が表示されます。CPU環境(torch.cuda.is_available()False)では、このifブロック自体が実行されず、何も表示されません。

torch.cuda.empty_cache() は、使われていないcacheを他のプログラムへ返しやすくするためのものです。まだTensorが参照しているメモリは解放できません。また、毎iterationで呼べばGPUメモリ問題が解決する、というものでもありません。

CUDA Out Of Memoryが起きた場合は、次のような対策を順に検討します。

  • batch sizeや入力解像度を下げる
  • AMPを使う
  • gradient accumulationを使う
  • activation checkpointingを使う
  • 長く保持しているTensor参照を減らす
  • evaluation時に torch.inference_mode() を使う
  • allocatedreserved の差を見る

nvidia-smi はプロセスやdriver側の見え方、PyTorchのメモリAPIはallocator側の見え方です。数値が完全に一致しないことがあります。

shellから nvidia-smi の出力を解析し、空きメモリが最も多いGPUを自動選択するスクリプトもよく使われます。ただし、OSごとの出力形式の違いに依存しやすく、Windowsではそのまま動かないことがあります。手元で調べるだけなら nvidia-smi を直接見れば十分です。プログラム内で判断したい場合は、PyTorchのメモリAPIやNVML系ライブラリを使い、実行環境ごとに動作確認します。

AMPによる混合精度学習

メモリ使用量を減らす具体的な方法の1つが、次に紹介するAMP(自動混合精度)です。CUDAでは、AMPを使うと計算をfloat16やbfloat16へ切り替え、速度やメモリ使用量を改善できることがあります。

基本形は、torch.autocasttorch.amp.GradScaler の組み合わせです。次のコードでは、modeloptimizercriteriontrain_loader はすでに用意されているものとします。

scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda")  # CUDA以外ではno-opになる

for images, targets in train_loader:
    images = images.to(device, non_blocking=True)
    targets = targets.to(device, non_blocking=True)
    optimizer.zero_grad(set_to_none=True)

    with torch.autocast(
        device_type=device.type,
        dtype=torch.float16,
        enabled=device.type == "cuda",
    ):  # このwithの中だけ、対象演算がfloat16で実行される
        logits = model(images)
        loss = criterion(logits, targets)

    scaler.scale(loss).backward()  # lossをscaleしてからbackwardする
    scaler.step(optimizer)         # 勾配をunscaleしてからoptimizer.step相当を行う
    scaler.update()                # 次iterationのscaleを調整する

autocast は主にforwardとloss計算を包みます。通常、backward全体を autocast の中に入れる必要はありません。

勾配クリッピングを使う場合は、clip前にunscaleします。

scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()

AMPは便利ですが、数値が必ず同じになるわけではありません。lossがNaNになる、精度が落ちる、特定の演算が不安定になる場合は、AMPなしの最小構成でも確認します。

DataParallel

AMPは1つのGPU内での効率化ですが、複数のGPUを同時に使いたい場合は、また別の仕組みが必要になります。nn.DataParallel は、1プロセス内で複数GPUへbatchを分けて実行する仕組みです。

model = nn.DataParallel(model)

CUDA_VISIBLE_DEVICES を設定したうえで、小さな FooNetnn.DataParallel で包み、forward内のbatch sizeと出力shapeを確認する例です。CPU環境では分割は起きませんが、APIの形は確認できます。

import os
import torch
from torch import nn


os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")


class FooNet(nn.Module):
    def __init__(self, neural_num, layers=3):
        super().__init__()
        self.linears = nn.ModuleList([
            nn.Linear(neural_num, neural_num, bias=False)
            for _ in range(layers)
        ])

    def forward(self, x):
        print("batch size in forward:", x.size(0))
        # batch size in forward: 16
        for linear in self.linears:
            x = torch.relu(linear(x))
        return x


inputs = torch.randn(16, 3).to(device)
model = FooNet(neural_num=3, layers=3)
model = nn.DataParallel(model).to(device)
outputs = model(inputs)

print("model outputs.size:", outputs.size())
# model outputs.size: torch.Size([16, 3])
print("CUDA_VISIBLE_DEVICES:", os.environ["CUDA_VISIBLE_DEVICES"])
# CUDA_VISIBLE_DEVICES: 0
print("device_count:", torch.cuda.device_count())
# device_count: 0

CPU環境では batch size in forward: 16 と1回だけ表示されます。複数GPUで十分なbatch sizeがある場合は、GPUごとのreplicaに入力が分割されるため、forward内で見えるbatch sizeは小さくなります。

処理の流れは次のようになります。

主GPU上の入力batch
 -> batch次元でscatter
 -> 各GPUへmodel replicaを作る
 -> forward / backward
 -> 勾配を主モデルへ集約
 -> 出力をgather

制約もあります。

  • 1プロセス多threadなので主GPUが詰まりやすい
  • forwardごとにreplicaが作られる
  • Hookが各replicaで呼ばれる
  • Tensor以外の入れ物は浅いcopyになりやすい
  • scalar出力はGPUごとの値を持つvectorになることがある
  • batch sizeはGPU数より十分大きいほうがよい

単機の複数GPUでも、性能や拡張性を考えるとDDPを優先して検討することが多いです。

DistributedDataParallelとtorchrun

DistributedDataParallel、略してDDPは、基本的に1プロセスが1GPUを担当します。各プロセスが同じモデルのreplicaを持ち、backward時に勾配をall-reduceして同期します。

最小構成の形は次の通りです。

import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP


dist.init_process_group(device_id=local_rank)  # プロセス間の通信グループを初期化する
torch.cuda.set_device(local_rank)  # このプロセスが使うGPUを指定する

model = MyModel().to(local_rank)
model = DDP(model, device_ids=[local_rank])  # 勾配のall-reduceを自動化するラッパー

起動には torchrun を使います。

torchrun --standalone --nnodes=1 --nproc-per-node=gpu train.py

DDPは入力を自動では分割しません。各rankへ違うデータを渡すために、DistributedSampler を使います。

train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset)  # rankごとにデータを分担させる
train_loader = DataLoader(train_dataset, sampler=train_sampler, batch_size=batch_size)

for epoch in range(num_epochs):
    train_sampler.set_epoch(epoch)  # epochごとにシャッフル順を変える(再現性のため)
    train_one_epoch(...)

checkpointやTensorBoardログは、通常rank 0だけが書きます。複数rankが同じファイルへ同時に書くと壊れやすいためです。metricを全rankで集約したい場合は、all-reduceなどで同期します。

多GPU時のデータの流れ

DataParallelとDDPは、どちらも複数GPUを使えますが、データの流れが違います。

DataParallel:
1プロセス
batch -> scatter -> GPU replica -> gather -> 主GPUでloss
DDP:
rank 0: sampler shard 0 -> GPU 0 -> local loss -> gradients --+
rank 1: sampler shard 1 -> GPU 1 -> local loss -> gradients --+-> all-reduce
rank 2: sampler shard 2 -> GPU 2 -> local loss -> gradients --+

DDPでは、各rankが自分のoptimizerを持つのが普通です。all-reduce後は各rankの勾配が揃うため、同じ更新が行われます。

DataParallelでは、batch sizeがGPU数より小さいと、使われないGPUが出たり、分割が不均等になったりします。DDPでも、samplerの長さやdrop_lastの設定がrank間でずれるとhangの原因になります。

よくあるエラー

ここまで、保存・復元からGPU活用まで、PyTorchを実務で使うための土台を見てきました。最後に、実際につまずきやすいエラーを具体的に見ていきます。ここでは、PyTorchでよく見るエラーを、どの境界を確認すべきかという観点で整理します。

Dataset、transform、collate、Conv、DataParallel、lossなど、典型的な失敗をまとめて再現します。実行が途中で止まらないように、それぞれを try/except で囲んで確認します(実際にこれらのエラーに出会うときは、通常はtry/exceptなしでそのまま例外が発生します)。

import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset, TensorDataset
import torchvision.transforms as transforms


try:
    DataLoader(
        TensorDataset(torch.empty(0, 3), torch.empty(0, dtype=torch.long)),
        batch_size=4,
        shuffle=True,
    )
except ValueError as error:
    print("empty dataset:", str(error).splitlines()[0])
    # empty dataset: num_samples should be a positive integer value, but got num_samples=0

try:
    transforms.ToTensor()(torch.zeros(3, 32, 32))
except TypeError as error:
    print("totensor tensor:", str(error).splitlines()[0])
    # totensor tensor: pic should be PIL Image or ndarray. Got <class 'torch.Tensor'>


class VariableSizeDataset(Dataset):
    def __len__(self):
        return 2

    def __getitem__(self, index):
        size = 32 + index
        return torch.zeros(3, size, size), torch.tensor(index)


try:
    next(iter(DataLoader(VariableSizeDataset(), batch_size=2)))
except RuntimeError as error:
    print("collate shape:", str(error).splitlines()[0])
    # collate shape: stack expects each tensor to be equal size, but got [3, 32, 32] at entry 0 and [3, 33, 33] at entry 1

conv = nn.Conv2d(3, 6, kernel_size=5)
try:
    conv(torch.randn(4, 1, 32, 32))
except RuntimeError as error:
    print("conv channel:", str(error).splitlines()[0])
    # conv channel: Given groups=1, weight of size [6, 3, 5, 5], expected input[4, 1, 32, 32] to have 3 channels, but got 1 channels instead


class FooNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = nn.Linear(3, 3)

    def forward(self, x):
        return self.linear(x)


wrapped = nn.DataParallel(FooNet())
try:
    wrapped.linear
except AttributeError as error:
    print("dataparallel attr:", str(error).splitlines()[0])
    # dataparallel attr: 'DataParallel' object has no attribute 'linear'

criterion = nn.CrossEntropyLoss()
try:
    criterion(torch.randn(3, 2), torch.tensor([0, 1, 2]))
except (IndexError, RuntimeError) as error:
    print("target range:", str(error).splitlines()[0])
    # target range: Target 2 is out of bounds.

これらは別々の問題に見えますが、ほとんどは「Datasetが空」「transformの入力型が違う」「batch化できるshapeではない」「モデルが期待するshapeと違う」「wrapper後の参照先が違う」「lossが期待するtargetではない」という境界の不一致です。以下で、それぞれを個別に見ます。

num_samples=0

ValueError: num_samples should be a positive integer value,
but got num_samples=0

Datasetの長さが0です。RandomSampler は空のDatasetからsampleできません。

確認します。

print(len(dataset))
print(getattr(dataset, "samples", [])[:5])

ここでの dataset は、自分が使っているDatasetのインスタンスに読み替えてください。長さが0であれば、上流のパスや分割条件を疱います。

画像フォルダのパス、拡張子、split結果、__len__()、globの条件を確認します。DDPでは、rankごとの分割後にデータが空になっていないかも見ます。

PIL、ndarray、Tensorの型不一致

TypeError: pic should be PIL Image or ndarray. Got torch.Tensor

変換が期待している入力型と、実際の型が合っていません。ToTensor() を二重にかけていないか、PIL画像向けtransformとTensor向けtransformを混ぜていないかを確認します。torchvision transforms v2はTensor対応が進んでいますが、dtype、値域、TVTensorの扱いは確認します。

batch内画像サイズが違う

Sizes of tensors must match...

既定の collate_fn は、同じshapeのTensorをstackします。分類ならtransformでResizeやCropを入れてshapeを揃えます。検出やセグメンテーションで画像ごとにサイズを変えたい場合は、listを返す自作 collate_fn を使います。

ConvやLinearのshape mismatch

Convでは、入力チャンネル数と in_channels が合っていないことがあります。Linearでは、flatten後の特徴数と in_features が合っていないことがあります。

まず各層の前後でshapeを出します。

print(x.shape)

x は、実際に確認したい中間層への入力(または出力)を指します。forward() の中に一時的にこの行を挿んで、期待するshapeになっているかを確認します。

画像分類では、AdaptiveAvgPool2d を使うと、分類headが入力解像度へ依存しにくくなります。

DataParallelで属性が見つからない

AttributeError: 'DataParallel' object has no attribute 'linear'

DataParallelで包むと、元のモデルは model.module の下にあります。ただし、コード全体に .module を散らすより、包む前の raw_model を保持するほうが扱いやすいです。

GPUで保存したファイルをCPUで読み込めない

Attempting to deserialize object on a CUDA device...

CPU環境で読むなら、map_location="cpu" を指定します。

state = torch.load(path, map_location="cpu", weights_only=True)

モデル全体保存でクラスが見つからない

Can't get attribute 'FooNet'

モデル全体pickleは、読み込み時に同じクラス定義とimport pathが必要です。長く使うファイルでは、state_dict 保存を中心にします。すでに手元にある信頼済みファイルを変換したい場合は、同じクラス定義を用意して読み込み、改めて state_dict として保存します。

CrossEntropyLossのtarget範囲やdtypeが違う

target >= 0 && target < n_classes

CrossEntropyLoss のtargetは、torch.long のclass indexです。値は [0, C) に入る必要があります。ラベルが1始まりになっていないか、クラス数と分類headの出力数が合っているか、class_to_idx が訓練時と推論時で変わっていないかを確認します。

deviceやdtypeが揃っていない

Expected all tensors to be on the same device
Expected scalar type ...

モデル、入力、target、lossのweight、mask、新規作成Tensorのdeviceとdtypeを確認します。画像入力は多くの場合float32、CrossEntropyLoss のtargetはlong、BCE系のtargetはfloatが基本です。

DataLoader workerが落ちる

DataLoader worker ... killed by signal: Killed

まず num_workers=0 にして、直接のtracebackを見ます。

確認するものは次の通りです。

  • メインメモリ
  • 共有メモリ
  • 壊れた画像ファイル
  • Dataset内の例外
  • worker数と prefetch_factor
  • ファイルハンドル数
  • 画像デコードライブラリの問題

メモリを増やすだけが解決策ではありません。

CUDA Out Of Memory

batch size、入力解像度、保持しているTensor、validation時の勾配計算、不要なlist保存を見直します。loss.item() ではなく loss Tensorをlistに貯め続けると、計算グラフを保持してメモリが増えることがあります。

インプレース操作とautograd

one of the variables needed for gradient computation
has been modified by an inplace operation

relu_()add_()+=、inplace ReLU、Hook内の書き換えを確認します。原因を追うときだけ、次を使うことがあります。

torch.autograd.set_detect_anomaly(True)

開発時の調査用で、通常訓練では重くなります。

NaNやInf

入力、loss、勾配、学習率、AMPの有無を確認します。

assert torch.isfinite(loss)

除算、log、exp、sqrt、異常なtarget、学習率過大、AMPのoverflowなどが原因になります。

DDPが止まる

DDPのhangでは、rankごとの実行経路がずれていることが多いです。

  • rankによってcollectiveの回数が違う
  • あるrankだけ例外で止まっている
  • samplerの長さが合っていない
  • 条件分岐で使われるパラメータがrankごとに違う
  • 通信設定やportに問題がある

全rankのログを取り、まずは単GPU、次に1ノード少数GPUへ戻して確認します。

体系的な切り分け方

エラーが出たら、最後の1行だけで判断しないほうが安全です。次の順で小さくします。

1. traceback全体を読む
2. num_workers=0にする
3. 単GPUまたはCPUで再現する
4. AMPやcompileを一度外す
5. 1 batch、さらに1 sampleへ縮める
6. Dataset -> transform -> collate -> model -> lossの境界で確認する
7. shape、dtype、device、min/max、finiteを出す
8. 公式の最小例と比べる
9. 複雑な機能を1つずつ戻す

よく使う確認です。imagestargetsは、自分の訓練ループ内でモデルに渡す直前の変数に読み替えてください。

assert images.ndim == 4
assert images.shape[1] == 3
assert targets.dtype == torch.long
assert images.device == next(model.parameters()).device
assert torch.isfinite(images).all()

CUDAエラーは非同期に報告されることがあります。原因箇所を絞るときだけ、同期実行の設定を使って確認することがあります。ただし、普段の訓練設定として常用するものではありません。

保存と復元を含む訓練の骨組み

最後に、checkpointの読み込み、AMP、保存を含む訓練の骨組みをまとめます。build_modeltrain_loadervalid_loaderevaluatenum_epochscriterion は、実際のプロジェクト側で用意する前提です。

from pathlib import Path
import torch


checkpoint_path = Path("checkpoints/last.pt")
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = build_model().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda")

start_epoch = 0
best_metric = float("-inf")

if checkpoint_path.exists():  # 前回の続きがあれば読み込む
    checkpoint = torch.load(
        checkpoint_path,
        map_location="cpu",
        weights_only=True,
    )
    model.load_state_dict(checkpoint["model"])
    optimizer.load_state_dict(checkpoint["optimizer"])
    scheduler.load_state_dict(checkpoint["scheduler"])
    scaler.load_state_dict(checkpoint["scaler"])
    start_epoch = checkpoint["epoch"] + 1  # 保存時のepochの次から再開する
    best_metric = checkpoint["best_metric"]
    model.to(device)

for epoch in range(start_epoch, num_epochs):
    model.train()
    for images, targets in train_loader:
        images = images.to(device, non_blocking=True)
        targets = targets.to(device, non_blocking=True)
        optimizer.zero_grad(set_to_none=True)

        with torch.autocast(
            device_type=device.type,
            dtype=torch.float16,
            enabled=device.type == "cuda",
        ):
            logits = model(images)
            loss = criterion(logits, targets)

        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()

    scheduler.step()
    metric = evaluate(model, valid_loader)
    best_metric = max(best_metric, metric)

    raw_model = model.module if hasattr(model, "module") else model  # DataParallel/DDPで包まれていれば中身を取り出す
    checkpoint = {
        "format_version": 1,
        "epoch": epoch,
        "model": raw_model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "scheduler": scheduler.state_dict(),
        "scaler": scaler.state_dict(),
        "best_metric": best_metric,
    }
    temporary = checkpoint_path.with_suffix(".tmp")
    torch.save(checkpoint, temporary)  # まず一時ファイルへ保存してから
    temporary.replace(checkpoint_path)  # 置き換える(保存中断による破損を避ける)

この例は、epochごとに進むschedulerを想定しています。ReduceLROnPlateau のように検証指標を渡すschedulerでは、scheduler.step(metric) のように呼び方が変わります。

DDPでは、通常rank 0だけが保存します。保存後に他rankと同期したい場合は、dist.barrier() などを使います。

図として整理する

保存と復元

訓練中の状態
├── model parameters / buffers
├── optimizer moments
├── scheduler position
├── AMP scaler
└── epoch / best metric
        |
        v torch.save
checkpoint.pt
        |
        v torch.load + load_state_dict
復元された訓練状態

ファインチューニング

事前学習済みモデル
  -> 汎用特徴を持つbackbone
  -> 目標タスク用headへ置き換える
  -> freeze / partial unfreeze / full fine-tune

GPUの論理番号

CUDA_VISIBLE_DEVICES=2,3

物理GPU 2 -> 論理 cuda:0
物理GPU 3 -> 論理 cuda:1

DataParallel

batch 16
 -> scatter: 4 + 4 + 4 + 4
 -> 4つのreplica
 -> gather: [16, C]

DDP

4 processes x 1 GPU
 -> 各rankが別shardを読む
 -> local forward / backward
 -> gradient all-reduce
 -> 各rankでoptimizer.step()

エラーの境界

Dataset -> transform -> collate -> model -> loss -> backward -> optimizer
   |          |           |        |       |        |          |
 path       dtype       shape    device  target   NaN/Inf    state

よくあるつまずきどころ

つまずき 原因 対処
モデル全体pickleを長期保存に使う クラス定義やimport pathへ依存する state_dict を保存する
checkpointにoptimizerを入れていない 再開時にmomentumなどが失われる 訓練再開用にはoptimizerも保存する
model.eval() を忘れる DropoutやBatchNormが訓練時の挙動になる 推論・検証前に eval() を呼ぶ
GPU保存ファイルをCPUで読めない 保存時deviceへ復元しようとする map_location="cpu" を指定する
strict=False でshape mismatchも無視できると思う 同名キーのshape違いは別問題 読み込み前に分類headのキーを除く
module. prefixを雑に削る prefixがないキーまで壊す removeprefix("module.") を使う
凍結したつもりでBatchNorm統計が更新される requires_grad=False はmodeを変えない 必要ならBatchNormを eval() にする
Tensorの .to() の戻り値を捨てる Tensorは必要に応じて新Tensorを返す x = x.to(device) と受け取る
Moduleをdeviceへ移す前にoptimizerを作る stateやparameter参照が複雑になる model.to(device) の後にoptimizerを作る
CUDA_VISIBLE_DEVICES 後の番号を物理番号だと思う プロセス内で論理番号へ付け替わる cuda:0 がどの物理GPUか確認する
empty_cache() でOOMが解決すると思う 生きているTensorは解放されない batch、参照、AMP、checkpointingを見直す
AMPでbackwardまでautocastに入れる backwardは通常autocast外でよい forwardとlossを中心に包む
DataParallelとDDPを同じものとして扱う プロセス構成とデータ分割が違う 複数GPUではDDPを優先する
DDPでsamplerを使わない 各rankが同じデータを読む DistributedSampler を使う
workerエラーを最後の1行だけで判断する 本当の例外がworker内に隠れる num_workers=0 で直接確認する
deviceエラーをモデルだけ見て直そうとする targetやloss weightが別deviceの場合もある 入力、target、mask、新規Tensorも確認する

参考リンク

おわりに

今回は、モデル保存、checkpoint、ファインチューニング、GPU、AMP、DDP、よくあるエラーをまとめました。

モデル保存では、state_dict を中心に考えると、コード変更や環境変更に強くなります。訓練を再開するなら、モデルだけでなくoptimizer、scheduler、scaler、epoch、metric、前処理設定もcheckpointへ入れます。読み込みでは、信頼できるファイルか、weights_only=True を使えるか、CPU/GPUのdevice mappingが合っているかを確認します。

ファインチューニングでは、backboneとheadを分けて考えます。凍結する範囲、BatchNormのmode、Dropoutの挙動、層ごとの学習率、前処理の一致が結果に効きます。事前学習済みモデルを使うときは、重みだけでなく、その重みが想定するNormalizeや入力サイズも一緒に扱います。

GPU訓練では、device、dtype、メモリ、データ転送、AMP、複数GPUのプロセス構成を切り分けて考えます。エラーが起きたら、Dataset、transform、collate、model、loss、backward、optimizerのどこで前提が崩れているかを、小さな例へ戻して確認するのが近道です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?