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?

はじめに

第5回では、重み初期化、損失関数、optimizer、勾配クリッピング、チェックポイント保存までを整理しました。ここまでで、zero_grad -> forward -> loss -> backward -> step という訓練ループの骨格は書けるようになりました。

次に必要になるのは、訓練をただ回すだけでなく、訓練の進み方を制御し、記録し、必要なときにモデル内部を観察する力です。

この記事では、学習率スケジューラ、TensorBoard、Hook、CAM/Grad-CAMを扱います。学習率スケジューラは「いつ大きく進み、いつ細かく詰めるか」を決めます。TensorBoardは「訓練中に何が起きているか」を可視化します。Hookは、forward() の本体を書き換えずに中間出力や勾配を観察する仕組みです。CAMやGrad-CAMは、画像分類モデルがどの空間領域を手がかりにしているかを調べるための代表的な方法です。

目的は、API名を並べて覚えることではありません。schedulerをどのタイミングで呼ぶのか、TensorBoardには何をどの頻度で書くのか、Hookで保存してよいTensorと危ないTensorは何か、Grad-CAMの熱図をどう解釈すべきかを、実際の訓練コードに接続して理解することです。

目次

この記事で扱うこと

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

  • 学習率スケジューラを使う理由
  • schedulerとoptimizerの正しい呼び出し順序
  • StepLRMultiStepLRExponentialLR
  • CosineAnnealingLRCosineAnnealingWarmRestarts
  • ReduceLROnPlateau と検証指標の関係
  • LambdaLR、warmup、OneCycleLRSequentialLR
  • scheduler stateを含めたチェックポイント保存
  • TensorBoardのイベントファイルとlog directory
  • SummaryWriteradd_scalaradd_scalars
  • add_histogram によるパラメータ・勾配監視
  • add_imageadd_imagestorchvision.utils.make_grid
  • add_graph とモデル概要の見方
  • Hookで中間出力や勾配を観察する方法
  • Tensor.register_hook、forward hook、pre-hook、full backward hook
  • CAMとGrad-CAMの考え方
  • Grad-CAMをHookで実装する流れ
  • 熱図を解釈するときの限界と検証方法

コードは、実データがなくても考え方を確認できるように、小さなダミーTensorを中心にします。TensorBoardやGrad-CAMの一部は、実際の画像データやブラウザ表示を前提にするため、出力画像そのものではなく、コードの役割と注意点を中心に説明します。

第5回から第6回へのつながり

第5回の訓練ループは、最小限に書くと次の形でした。

optimizer.zero_grad(set_to_none=True)
logits = model(inputs)
loss = criterion(logits, targets)
loss.backward()
optimizer.step()

今回扱う要素を足すと、実践的な訓練ループは少し広がります。

for epoch:
  train loop
    forward / loss / backward / optimizer.step
    TensorBoardへlossやlrを書く
    必要ならbatch単位scheduler.step

  validation
    valid lossやmetricを計算する
    epoch単位scheduler.step、またはReduceLROnPlateauへmetricを渡す
    checkpointへmodel/optimizer/schedulerを保存する

必要なとき:
  Hookで中間特徴や勾配を取り出す
  Grad-CAMで画像上の注目領域を確認する

つまり、今回のテーマは「訓練を制御する」「訓練を記録する」「モデル内部を見る」の3つです。

学習率スケジューラの全体像

学習率は、1回の更新でパラメータをどれくらい動かすかを決めます。

新しい重み = 今の重み - 学習率 * 勾配

訓練の最初は、ある程度大きく動いたほうが早くよい領域へ近づけることがあります。一方、後半では学習率が大きいままだと、よい領域の周りで振動しやすくなります。そこで、訓練の進み方に合わせて学習率を変えるのが学習率スケジューラです。

代表的な考え方は次の通りです。

方針 向いている場面
固定間隔で下げる StepLR まず動く基準コードを作る
指定したepochで下げる MultiStepLR 既知の訓練レシピに合わせる
毎回なめらかに下げる ExponentialLR 単純な指数減衰を使いたい
余弦曲線で下げる CosineAnnealingLR 後半をなめらかに詰めたい
停滞したら下げる ReduceLROnPlateau 検証lossやmetricを見て調整したい
最初だけ上げる warmup 大きなモデルや不安定な初期訓練
1周期で上げ下げする OneCycleLR batch単位で設計された訓練

大切なのは、schedulerは「どの単位でstepするか」まで含めて設計されることです。epochごとに呼ぶものをbatchごとに呼ぶと、学習率が想定より速く変わります。

LRScheduler と呼び出し順序

多くのスケジューラは、optimizerを受け取り、その中のparam groupの lr を更新します。

torch.optim.lr_scheduler.LRScheduler(optimizer, last_epoch=-1)

役割:optimizerに登録された学習率を、step数やepoch数に応じて更新するための基底クラスです。普段はこのクラスを直接使うより、StepLRCosineAnnealingLR などの具体的なクラスを使います。

主な引数

引数 意味
optimizer 学習率を更新したいoptimizer
last_epoch 直前までに処理済みのstepまたはepoch番号。新規作成では通常 -1

よく使うメソッド

メソッド 意味
step() schedulerを1つ進め、必要ならoptimizerのlrを更新する
get_last_lr() 直近で設定された各param groupのlrを返す
state_dict() scheduler内部状態を保存用のdictとして返す
load_state_dict() 保存したscheduler状態を読み込む

普通のschedulerでは、optimizerでパラメータを更新したあとにschedulerを進めます。

optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
scheduler.step()

ただし、ReduceLROnPlateau は例外です。このschedulerは検証lossや検証accuracyのような指標を見て判断するため、検証が終わったあとにmetricを渡します。

valid_loss = evaluate(model, valid_loader)
scheduler.step(valid_loss)

現在の学習率を記録したいときは、内部計算用の get_lr() ではなく、get_last_lr() かoptimizerのparam groupを見ます。

current_lrs = scheduler.get_last_lr()
current_lr = optimizer.param_groups[0]["lr"]

StepLR

StepLR は、一定間隔で学習率を階段状に下げるschedulerです。

torch.optim.lr_scheduler.StepLR(
    optimizer,
    step_size,
    gamma=0.1,
    last_epoch=-1,
)

役割step_size 回schedulerを進めるごとに、学習率へ gamma を掛けます。

lr = base_lr * gamma ^ floor(step / step_size)

主な引数

引数 意味
optimizer 対象のoptimizer
step_size 何stepごとに学習率を下げるか
gamma 下げる倍率。0.1なら10分の1にする
last_epoch 再開時に使う内部カウンタ

使い方

import torch
from torch import nn


model = nn.Linear(10, 2)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer,
    step_size=5,  # 5回step()を呼ぶごとに下げる
    gamma=0.1,    # lrを0.1倍にする
)

for epoch in range(12):
    optimizer.step()   # 実際の訓練ではbackward後に呼ぶ(ここでは形だけ確認するので勾配は使わない)
    scheduler.step()   # schedulerを1回進める。何回目の呼び出しかをschedulerが内部で数えている
    print(epoch, scheduler.get_last_lr())

出力は次のようになります。

0 [0.1]
1 [0.1]
2 [0.1]
3 [0.1]
4 [0.010000000000000002]
5 [0.010000000000000002]
6 [0.010000000000000002]
7 [0.010000000000000002]
8 [0.010000000000000002]
9 [0.0010000000000000002]
10 [0.0010000000000000002]
11 [0.0010000000000000002]

ここで注意したいのは、学習率が下がるタイミングです。step_size=5 は「scheduler.step() を5回呼んだ時点で下げる」という意味であり、forループのepoch変数が5になった時点ではありません。forループのepochは0から始まるため、5回目の呼び出しは epoch == 4 のときに行われます。そのため、上の出力では epoch == 4 の時点ですでに学習率が 0.01 に下がっています。同じことが9回目の呼び出し(epoch == 9)でも起こり、学習率は 0.001 になります。「step_size 回目ちょうどで下がる」と考えると、実際の出力より1 epoch遅れて理解してしまうので注意します。

画像分類の訓練コードでは、optimizerを作った直後にschedulerを作り、1epoch分の訓練が終わってから scheduler.step() を呼びます。次は、第4回で組み立てた BanknoteCNN を使い、第3回から続く紙幣画像分類の流れに合わせた位置関係です。train_loadermax_epoch は、第3回の DataLoader と、訓練を回すepoch数がすでに用意されているものとします。

net = BanknoteCNN(num_classes=2)  # 第4回で定義したCNN。重み初期化はデフォルトのまま使う

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(
    net.parameters(),
    lr=0.01,
    momentum=0.9,
)
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer,
    step_size=10,  # 10回のscheduler.step()ごとに下げる(=10 epochごと)
    gamma=0.1,     # 学習率を0.1倍にする
)

for epoch in range(max_epoch):
    net.train()  # BatchNormなどを訓練モードにする

    for images, labels in train_loader:
        outputs = net(images)           # (N, 2) のlogit
        loss = criterion(outputs, labels)

        optimizer.zero_grad(set_to_none=True)  # 前のbatchの勾配を消す
        loss.backward()                        # 勾配を計算する
        optimizer.step()                       # 現在の学習率でパラメータを更新する

    scheduler.step()  # 1epochの訓練ループを抜けたあとで学習率を更新する

注意点StepLR は単純で読みやすい反面、学習率を下げるタイミングを自分で決め打ちします。検証lossの停滞を見て自動判断するわけではありません。

lr
|---------
|         |------
|                |----
+---------------------- epoch

MultiStepLR

MultiStepLR は、指定したepochやstepに到達したときだけ学習率を下げます。

torch.optim.lr_scheduler.MultiStepLR(
    optimizer,
    milestones,
    gamma=0.1,
    last_epoch=-1,
)

役割milestones に含まれるタイミングで、現在の学習率に gamma を掛けます。

主な引数

引数 意味
optimizer 対象のoptimizer
milestones 学習率を下げるタイミングの昇順リスト
gamma milestone到達時に掛ける倍率
last_epoch 再開時の内部カウンタ

使い方

scheduler = torch.optim.lr_scheduler.MultiStepLR(
    optimizer,
    milestones=[30, 60, 90],  # 30, 60, 90 epochで下げる
    gamma=0.1,
)

学習率の曲線を確認したいだけなら、1つのダミーパラメータでも十分です。次のコードでは、50、125、160 epochで学習率が落ちる様子を記録します。weightはloss計算のためだけのダミーパラメータで、値そのものに意味はありません。

import torch
import matplotlib.pyplot as plt  # 可視化のみに使う。未インストールなら pip install matplotlib


weight = torch.randn(1, requires_grad=True)
target = torch.zeros(1)
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)

milestones = [50, 125, 160]
scheduler = torch.optim.lr_scheduler.MultiStepLR(
    optimizer,
    milestones=milestones,
    gamma=0.1,
)

lr_history = []
epoch_history = []

for epoch in range(200):
    loss = (weight - target).pow(2).sum()  # ダミーのMSE風loss(weightを0へ近づけるだけ)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)

    scheduler.step()
    lr_history.append(scheduler.get_last_lr()[0])
    epoch_history.append(epoch)

for epoch in (0, 48, 49, 124, 125, 159, 160, 199):
    print(epoch, lr_history[epoch])

plt.plot(epoch_history, lr_history, label=f"milestones={milestones}")
plt.xlabel("Epoch")
plt.ylabel("Learning rate")
plt.legend()
plt.show()

print の出力は次の通りです(plt.show() は、この値が0.1→0.01→0.001→0.0001と3回階段状に下がるグラフを表示します)。

0 0.1
48 0.1
49 0.010000000000000002
124 0.010000000000000002
125 0.0010000000000000002
159 0.0010000000000000002
160 0.00010000000000000003
199 0.00010000000000000003

StepLRのときと同じ理由で、milestoneの数値と実際に下がるepoch番号には1つずれがあります。milestones=[50, 125, 160]は「50回目、125回目、160回目のscheduler.step()呼び出しで下げる」という意味なので、0始まりのepoch番号では49124159の時点ですでに学習率が下がります。

StepLR が「固定間隔」なのに対して、MultiStepLR は「指定した時刻」です。既存の訓練レシピで「30、60、90 epochで下げる」と決まっている場合などに向いています。

ExponentialLR

ExponentialLR は、schedulerを進めるたびに学習率へ同じ係数を掛けます。

torch.optim.lr_scheduler.ExponentialLR(
    optimizer,
    gamma,
    last_epoch=-1,
)

役割:毎回 lr = lr * gamma のように、指数的に学習率を下げます。

lr_t = base_lr * gamma^t

主な引数

引数 意味
optimizer 対象のoptimizer
gamma 1回stepするごとに掛ける倍率
last_epoch 再開時の内部カウンタ

使い方

scheduler = torch.optim.lr_scheduler.ExponentialLR(
    optimizer,
    gamma=0.95,  # stepごとに5%ずつ下げる
)

曲線を記録すると、毎epoch同じ比率で下がっていくことが分かります。ここでもMultiStepLRのときと同じ形のダミーパラメータを、あらためて新しく用意します(前のoptimizerをそのまま使うと、そちらで下がった学習率を引き継いでしまうため)。

import torch
import matplotlib.pyplot as plt


weight = torch.randn(1, requires_grad=True)
target = torch.zeros(1)
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)

gamma = 0.95
scheduler = torch.optim.lr_scheduler.ExponentialLR(
    optimizer,
    gamma=gamma,
)

lr_history = []
epoch_history = []

for epoch in range(200):
    loss = (weight - target).pow(2).sum()
    loss.backward()
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)
    scheduler.step()

    lr_history.append(scheduler.get_last_lr()[0])
    epoch_history.append(epoch)

for epoch in (0, 1, 10, 199):
    print(epoch, lr_history[epoch])

plt.plot(epoch_history, lr_history, label=f"gamma={gamma}")
plt.xlabel("Epoch")
plt.ylabel("Learning rate")
plt.legend()
plt.show()

出力は次の通りです(plt.show()はなめらかな右下がりの指数曲線を表示します)。

0 0.095
1 0.09025
10 0.05688000922764597
199 3.5052666248828686e-06

gamma を二重に指数計算しないように注意します。scheduler自身がstepごとに内部状態を進めるので、自分で毎epoch gamma ** epoch を掛け直す必要はありません。実際、epoch=1の値0.090250.1 * 0.95 * 0.95と一致しており、直前の学習率へgammaを1回掛けているだけだと確認できます。

余弦アニーリングとWarm Restarts

CosineAnnealingLR

CosineAnnealingLR は、余弦曲線に沿って学習率をなめらかに下げます。

torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max,
    eta_min=0.0,
    last_epoch=-1,
)

役割:最大値から最小値 eta_min へ、cosineの形で徐々に近づけます。

eta_t = eta_min + 0.5 * (eta_max - eta_min) * (1 + cos(pi * T_cur / T_max))

主な引数

引数 意味
optimizer 対象のoptimizer
T_max 高い学習率から低い学習率へ下がるまでの長さ
eta_min 下限の学習率
last_epoch 再開時の内部カウンタ

T_max の単位は、schedulerを呼ぶ単位に依存します。epochごとに呼ぶならepoch数、batchごとに呼ぶならiteration数として考えます。

次のコードでは、T_max=50 として余弦曲線の形を記録します。同じ理由で、ここでも新しいダミーパラメータとoptimizerを用意します。

import torch
import matplotlib.pyplot as plt


weight = torch.randn(1, requires_grad=True)
target = torch.zeros(1)
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)

t_max = 50
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max=t_max,
    eta_min=0.0,
)

lr_history = []
epoch_history = []

for epoch in range(200):
    loss = (weight - target).pow(2).sum()
    loss.backward()
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)
    scheduler.step()

    lr_history.append(scheduler.get_last_lr()[0])
    epoch_history.append(epoch)

for epoch in (0, 25, 49, 50, 75, 99, 100, 199):
    print(epoch, lr_history[epoch])

plt.plot(epoch_history, lr_history, label=f"T_max={t_max}")
plt.xlabel("Epoch")
plt.ylabel("Learning rate")
plt.legend()
plt.show()

出力は次の通りです。

0 0.09990133642141358
25 0.046860474023534326
49 0.0
50 9.866357858642206e-05
75 0.05313952597646564
99 0.10000000000000002
100 0.09990133642141359
199 0.10000000000000005

ここで注目してほしいのは、epoch=49T_maxと同じ長さだけ進んだ直後)で学習率がほぼ0まで下がっているのに対し、epoch=99ではほぼ0.1(最初のbase_lr)まで戻っている点です。epoch=100の値はepoch=0の値とほぼ一致しており、2 * T_max epochを1周期として値が上下していることが分かります。CosineAnnealingLRT_maxepochで学習率が下限に固定されるスケジューラではなく、T_maxepochを超えてstep()を呼び続けると、この例のように学習率が再び上昇します。T_maxepochで訓練を終える設計にするか、周期的に上げ下げしたい場合は次のCosineAnnealingWarmRestartsを使うか、どちらかをはっきり決めておくことが大切です。

CosineAnnealingWarmRestarts

余弦曲線を周期的にやり直したい場合は、Warm Restarts用のschedulerを使います。

torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
    optimizer,
    T_0,
    T_mult=1,
    eta_min=0.0,
    last_epoch=-1,
)

役割:一定周期ごとに学習率を高い値へ戻し、そこから再び余弦で下げます。

主な引数

引数 意味
T_0 最初の周期の長さ
T_mult restart後に周期長を何倍にするか
eta_min 各周期の下限学習率

通常の CosineAnnealingLR は、名前だけで自動restartすると考えないほうが安全です。restartを明確に使いたい場合は、Warm Restarts用のクラスを選びます。

ReduceLROnPlateau

ReduceLROnPlateau は、epoch番号ではなく検証指標を見て学習率を下げます。

torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode="min",
    factor=0.1,
    patience=10,
    threshold=1e-4,
    threshold_mode="rel",
    cooldown=0,
    min_lr=0,
    eps=1e-8,
)

役割:検証lossや検証accuracyなどが十分改善しなくなったとき、学習率を下げます。

主な引数

引数 意味
optimizer 対象のoptimizer
mode "min" は小さいほどよい指標、"max" は大きいほどよい指標
factor 学習率へ掛ける倍率。通常1より小さい値
patience 改善なしを何回まで許すか
threshold 改善とみなす最小差
threshold_mode "rel" は相対差、"abs" は絶対差
cooldown lrを下げたあと、次の判定まで待つ回数
min_lr 学習率の下限。param groupごとのリストも指定できる
eps lr差がこの値より小さい場合は更新しない

使い方

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode="min",
    factor=0.5,
    patience=3,
)

for epoch in range(num_epochs):
    train_one_epoch(...)
    valid_loss = evaluate(...)
    scheduler.step(valid_loss)  # 検証が終わってからmetricを渡す

改善が止まった状況を小さく再現するなら、検証lossに相当する値を固定して渡します。途中で一度だけ改善させると、patiencecooldown の効き方を確認しやすくなります。

import torch


weight = torch.randn(1, requires_grad=True)
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)

valid_loss = 0.5
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode="min",
    factor=0.1,
    patience=10,
    cooldown=10,
    min_lr=1e-4,
)

for epoch in range(50):
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)

    if epoch == 5:
        valid_loss = 0.4  # ここだけ改善したとみなす

    scheduler.step(valid_loss)
    if epoch in (0, 15, 16, 26, 27, 37, 49):
        print(epoch, optimizer.param_groups[0]["lr"])

出力は次の通りです。

0 0.1
15 0.1
16 0.010000000000000002
26 0.010000000000000002
27 0.010000000000000002
37 0.0010000000000000002
49 0.0010000000000000002

epoch=5 で一度だけ valid_loss0.4 に改善させたあとは、ずっと 0.4 のまま変化しません。patience=10 なので、改善なしの状態が11回続いた epoch=16 で最初の低下(0.1 -> 0.01)が起こります。そのあとは cooldown=10 により、低下直後の10epoch(epoch=17 から epoch=26)は改善判定そのものが休止し、epoch=27 から再び「改善なし」のカウントが始まります。そこからまた11回後の epoch=37 で2回目の低下(0.01 -> 0.001)が起こります。patience は「何回様子を見るか」、cooldown は「下げた直後にどれだけ休むか」という役割の違いを、この2回の低下タイミングのずれから確認できます。

このschedulerには、最後のtraining batch lossではなく、validation全体を集計した安定した指標を渡します。

LambdaLR

LambdaLR は、自分で定義した関数で学習率の倍率を決めるschedulerです。

torch.optim.lr_scheduler.LambdaLR(
    optimizer,
    lr_lambda,
    last_epoch=-1,
)

役割base_lr に対して、lr_lambda(step) が返す倍率を掛けます。返す値は絶対学習率ではなく倍率です。

主な引数

引数 意味
optimizer 対象のoptimizer
lr_lambda step番号を受け取り、lr倍率を返す関数。param groupごとの関数リストも可
last_epoch 再開時の内部カウンタ

使い方

def factor(epoch):
    return 0.95 ** epoch  # base_lrに掛ける倍率


scheduler = torch.optim.lr_scheduler.LambdaLR(
    optimizer,
    lr_lambda=factor,
)

param groupごとに別々の倍率関数を使うこともできます。次の例では、1つ目のgroupは20 epochごとに階段状に下げ、2つ目のgroupは毎epoch 0.95 倍にします。

weights_1 = torch.randn(6, 3, 5, 5, requires_grad=True)  # 1つ目のparam group(畳み込みカーネル風の形)
weights_2 = torch.ones(5, 5, requires_grad=True)         # 2つ目のparam group

optimizer = torch.optim.SGD(
    [
        {"params": [weights_1]},  # group 0。lambda1で倍率を決める
        {"params": [weights_2]},  # group 1。lambda2で倍率を決める
    ],
    lr=0.1,
)

def lambda1(epoch):
    return 0.1 ** (epoch // 20)  # 20 epochごとに0.1倍(0.1 -> 0.01 -> 0.001)


def lambda2(epoch):
    return 0.95 ** epoch  # 毎epoch0.95倍


scheduler = torch.optim.lr_scheduler.LambdaLR(
    optimizer,
    lr_lambda=[lambda1, lambda2],  # リストの順番がparam groupの順番に対応する
)

for epoch in range(60):
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)
    scheduler.step()
    if epoch in (0, 1, 19, 20, 39, 40, 59):
        print(epoch, scheduler.get_last_lr())

出力は次の通りです。1つ目の値がgroup 0(lambda1)、2つ目の値がgroup 1(lambda2)の学習率です。

0 [0.1, 0.095]
1 [0.1, 0.09025]
19 [0.010000000000000002, 0.03584859224085419]
20 [0.010000000000000002, 0.03405616262881148]
39 [0.0010000000000000002, 0.012851215656510312]
40 [0.0010000000000000002, 0.012208654873684797]
59 [0.00010000000000000003, 0.004606979898695194]

group 0は epoch=19 から epoch=20 にかけて 0.01 のまま変わらず、epoch=20 以降も epoch // 201 のあいだ(epoch=2039)は 0.01 を維持し続けます。一方group 1は毎epoch少しずつなめらかに下がり続けます。同じ LambdaLR でも、lr_lambda に渡す関数の形をそのまま学習率の形にできることが分かります。

複雑な状態を持つ無名関数を使うと、チェックポイント再開時の見通しが悪くなることがあります。長く使う訓練コードでは、名前付き関数や専用schedulerを使うほうが読みやすいです。

Warmup、OneCycle、組み合わせスケジューラ

Warmup

warmupは、訓練の最初だけ小さい学習率から始め、数stepまたは数epochかけて目標学習率まで上げる考え方です。大きなbatch size、大きなモデル、不安定な初期化では、最初から大きい学習率を使うとlossが乱れやすいことがあります。

PyTorchでは、LinearLRConstantLRSequentialLR と組み合わせて書けます。次の例は、最初の5回を LinearLR でbase_lrの10%から100%まで直線的に立ち上げ(warmup)、6回目以降は CosineAnnealingLRT_max=95)に切り替えて0まで下げる、合計100 epoch分の設計です。

import torch


weight = torch.randn(1, requires_grad=True)
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)

warmup = torch.optim.lr_scheduler.LinearLR(
    optimizer,
    start_factor=0.1,  # 最初はbase_lrの10%から始める
    total_iters=5,     # 5回かけてbase_lrへ近づける
)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer,
    T_max=95,  # warmup後に残る95epoch分で、base_lrから0まで下げる
)
scheduler = torch.optim.lr_scheduler.SequentialLR(
    optimizer,
    schedulers=[warmup, cosine],
    milestones=[5],  # 5回stepしたらcosineへ切り替える
)

lr_history = []
for epoch in range(100):
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)
    scheduler.step()
    lr_history.append(scheduler.get_last_lr()[0])

for epoch in (0, 1, 2, 3, 4, 5, 6, 50, 99):
    print(epoch, lr_history[epoch])

出力は次の通りです。

0 0.028000000000000004
1 0.046000000000000006
2 0.064
3 0.082
4 0.1
5 0.09997266286704631
6 0.09989068136093875
50 0.05247918773366114
99 0.0

epoch=0 から epoch=4 にかけて学習率が 0.028 -> 0.046 -> 0.064 -> 0.082 -> 0.1 と直線的に増えているのがwarmup区間です。milestones=[5] で指定した通り、6回目の scheduler.step()epoch=5)からは cosineCosineAnnealingLR)へ切り替わり、そこから緩やかに下がり始めます。total_iters=5T_max=95 の合計がちょうど 100 になっていることにも注目してください。warmupで使うstep数と、その後cosineが下げきるまでのstep数を足し合わせて、全体の訓練epoch数に合わせるのがこの組み合わせの基本設計です。

OneCycleLR

OneCycleLR は、訓練全体を1つの周期と見なし、学習率を一度上げてから下げるschedulerです。momentumも逆向きに調整できます。

torch.optim.lr_scheduler.OneCycleLR(
    optimizer,
    max_lr,
    epochs=None,
    steps_per_epoch=None,
    total_steps=None,
)

主な引数

引数 意味
max_lr 周期中で到達する最大学習率
epochs 総epoch数
steps_per_epoch 1epochあたりのbatch数
total_steps 総step数。指定するなら epochs * steps_per_epoch の代わりに使う

OneCycleLR は通常、batchごとに scheduler.step() します。epochごとに呼ぶschedulerと混ぜるときは、呼び出し単位を必ず分けて考えます。

CyclicLR

CyclicLR は、下限と上限の間で周期的に学習率を変えます。

torch.optim.lr_scheduler.CyclicLR(
    optimizer,
    base_lr,
    max_lr,
    step_size_up=2000,
    step_size_down=None,
)

学習率レンジの探索や、周期的に学習率を動かす訓練戦略で使われます。こちらも基本的にはbatch単位で進めます。

SequentialLRChainedScheduler

SequentialLR は、指定したmilestoneでschedulerを切り替えます。

torch.optim.lr_scheduler.SequentialLR(
    optimizer,
    schedulers,
    milestones,
)

ChainedScheduler は、1回の step() で複数のschedulerを順番に呼びます。

torch.optim.lr_scheduler.ChainedScheduler(schedulers)

名前は似ていますが、SequentialLR は「期間で切り替える」、ChainedScheduler は「同時に連鎖して適用する」という違いがあります。

スケジューラの選び方とチェックポイント

最初に選ぶときは、次の表を目安にすると整理しやすいです。

目的 候補 呼び出し単位
固定間隔で下げたい StepLR epochが多い
決まった時点で下げたい MultiStepLR epochが多い
なめらかに下げたい ExponentialLR 設計次第
後半をなめらかに詰めたい CosineAnnealingLR T_max に合わせる
指標が止まったら下げたい ReduceLROnPlateau 検証後
自分で倍率を決めたい LambdaLR 設計次第
warmup後に別戦略へ移りたい SequentialLR 統一した単位
batchごとの1周期訓練 OneCycleLR batch

訓練を再開したい場合は、modelとoptimizerだけでなくschedulerの状態も保存します。

checkpoint = {
    "model": model.state_dict(),
    "optimizer": optimizer.state_dict(),
    "scheduler": scheduler.state_dict(),
    "epoch": epoch,
    "global_step": global_step,
}

torch.save(checkpoint, "checkpoint.pt")

再開時は、同じ構造のmodel、optimizer、schedulerを作ってから、それぞれのstate dictを読み込みます。

checkpoint = torch.load("checkpoint.pt", map_location=device, weights_only=True)
model.load_state_dict(checkpoint["model"])
optimizer.load_state_dict(checkpoint["optimizer"])
scheduler.load_state_dict(checkpoint["scheduler"])
start_epoch = checkpoint["epoch"] + 1
global_step = checkpoint["global_step"]

torch.loadweights_only=True は、チェックポイントファイルを読み込む際に任意のPythonオブジェクトを復元できないよう制限し、Tensorやstate dictのような数値データだけを安全に読み込むためのオプションです。出所が分からないチェックポイントファイルを weights_only=False で読み込むと、細工されたファイル経由で任意のコードが実行される危険があるため、自分やチームで作成したファイルであっても、既定に近い安全側の設定として weights_only=True を使う習慣をつけておくと安心です。

schedulerの状態を保存しないと、再開後の学習率が意図しない値に戻ることがあります。

ここまでは、学習率という1つの数値をどう変化させるかという話でした。ここからは視点を変えて、訓練中に何が起きているかを目で確認する方法に移ります。学習率をprintで確認するだけでも動作は分かりますが、loss、accuracy、パラメータ分布、画像といった複数の情報を並べて時系列で追うには、TensorBoardのようなツールが向いています。

TensorBoardの基本

TensorBoardは、訓練中のloss、metric、学習率、画像、パラメータ分布などをブラウザで確認するための可視化ツールです。PyTorchでは torch.utils.tensorboard からイベントファイルを書き出し、TensorBoardがそのファイルを読みます。

PyTorchの訓練スクリプト
  -> SummaryWriter
  -> event fileをlog_dirへ書き込む
  -> tensorboard --logdir runs
  -> ブラウザでダッシュボードを見る

インストールは通常のPythonパッケージとして行います。

python -m pip install tensorboard

バージョン確認と起動は次のようにします。

tensorboard --version
tensorboard --logdir runs --port 6006

ローカルPCで見るだけなら、まずは既定のlocalhostで十分です。リモートサーバーで --host 0.0.0.0--bind_all を使う場合は、実験ログが外部から見える可能性があります。SSHトンネル、ファイアウォール、認証付きプロキシなどの扱いを確認してから使います。

SummaryWriter

PyTorchからTensorBoard形式のイベントファイルを書く中心が SummaryWriter です。

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter(
    log_dir=None,
    comment="",
    purge_step=None,
    max_queue=10,
    flush_secs=120,
    filename_suffix="",
)

役割:lossや画像などのイベントを、TensorBoardが読める形式でファイルに書き出します。

主な引数

引数 意味
log_dir イベントファイルを書き込むディレクトリ。省略すると runs/日時_ホスト名... のようなディレクトリが作られる
comment 自動生成されるlog_dir名に付くコメント
purge_step 再開時に、指定step以降の古いイベントを隠すための値
max_queue ファイルへ書く前にメモリ上へ貯めるイベント数
flush_secs 定期的にファイルへflushする秒数
filename_suffix イベントファイル名へ付ける接尾辞

使い方

from torch.utils.tensorboard import SummaryWriter


with SummaryWriter(log_dir="runs/exp01") as writer:
    writer.add_scalar("Loss/train", 0.5, 1)

with を使わない場合は、最後に flush() または close() を呼びます。

writer.flush()
writer.close()

動作確認だけなら、簡単な曲線をいくつか書き込むだけで十分です。次のコードを実行したあと、tensorboard --logdir runs を起動すると、scalarの曲線を確認できます。

import numpy as np
from torch.utils.tensorboard import SummaryWriter


writer = SummaryWriter(comment="test_tensorboard")

for step in range(100):
    writer.add_scalar("y=2x", step * 2, step)
    writer.add_scalar("y=pow(2, x)", 2 ** step, step)
    writer.add_scalars(
        "data/scalar_group",
        {
            "xsinx": step * np.sin(step),
            "xcosx": step * np.cos(step),
            "arctanx": np.arctan(step),
        },
        step,
    )

writer.close()

TensorBoardを起動してブラウザで開くと、「SCALARS」タブに3種類の曲線が表示されます。y=2x は右肩上がりの直線、y=pow(2, x)stepが増えるほど急激に立ち上がる指数曲線になります(step=99では2**99という非常に大きな値になるため、グラフの縦軸も大きく伸びます)。data/scalar_group は1つの図の中にxsinxxcosxarctanxの3本の曲線がまとめて重ねて表示され、add_scalarsが複数系列の比較に向いていることを確認できます。

複数の実験を同じlog directoryへ混ぜると、あとで比較しづらくなります。実験ごとに runs/exp001runs/baseline_lr1e-3 のような区別できる名前を付けるのがおすすめです。

Scalarと複数曲線

add_scalar

writer.add_scalar(
    tag,
    scalar_value,
    global_step=None,
    walltime=None,
    new_style=False,
    double_precision=False,
)

役割:1本の数値系列を記録します。loss、accuracy、learning rateなどを記録する基本メソッドです。

主な引数

引数 意味
tag TensorBoard上の名前。Loss/train のように階層化できる
scalar_value Python数値または1要素Tensor
global_step 横軸になるstepやepoch
walltime イベント時刻を明示したい場合に使う
new_style 新しいtensor形式で書き込むかどうか
double_precision new_style=True のときに倍精度で保存するか

使い方

writer.add_scalar("Loss/train", train_loss, epoch)
writer.add_scalar("Accuracy/valid", valid_accuracy, epoch)
writer.add_scalar("LearningRate/group0", optimizer.param_groups[0]["lr"], epoch)

訓練中のiterationごとに記録する場合は、epoch番号ではなく通し番号の global_step を使います。

global_step = 0

for epoch in range(max_epoch):
    net.train()

    for images, labels in train_loader:
        global_step += 1

        outputs = net(images)
        loss = criterion(outputs, labels)

        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        optimizer.step()

        predicted = outputs.argmax(dim=1)
        batch_accuracy = (predicted == labels).float().mean().item()

        writer.add_scalar("Loss/train_step", loss.item(), global_step)
        writer.add_scalar("Accuracy/train_step", batch_accuracy, global_step)

global_step はTensorBoardの横軸です。epoch単位の値ならepoch番号、batch単位の値ならglobal stepを入れると見やすくなります。

add_scalars

writer.add_scalars(
    main_tag,
    tag_scalar_dict,
    global_step=None,
    walltime=None,
)

役割:同じ図の中に複数の曲線をまとめて表示します。train lossとvalid lossの比較に向いています。

主な引数

引数 意味
main_tag 図全体の名前
tag_scalar_dict サブタグから数値へのdict
global_step 横軸のstep
walltime イベント時刻

使い方

writer.add_scalars(
    "Loss/epoch",
    {
        "train": train_loss,
        "valid": valid_loss,
    },
    epoch,
)

複数の関数を同じ図で比べるデモとして、次のような使い方もできます。

for step in range(100):
    writer.add_scalars(
        "data/scalar_group",
        {
            "xsinx": step * np.sin(step),
            "xcosx": step * np.cos(step),
        },
        step,
    )

タグ名は途中で変えないことが大切です。Loss/trainloss/train のように大文字小文字が混ざると、別の系列として扱われます。

Histogramでパラメータと勾配を見る

add_histogram は、Tensorの値の分布を記録します。

writer.add_histogram(
    tag,
    values,
    global_step=None,
    bins="tensorflow",
    walltime=None,
    max_bins=None,
)

役割:重み、bias、勾配、活性値などの分布が、訓練中にどう変化しているかを見るために使います。

主な引数

引数 意味
tag 直方図の名前
values Tensor、NumPy配列、または変換可能な値
global_step 横軸になるstepやepoch
bins 分箱方法。文字列またはNumPy互換のbins指定
walltime イベント時刻
max_bins 最大bin数

使い方

for name, parameter in model.named_parameters():
    writer.add_histogram(f"Parameters/{name}", parameter.detach(), epoch)
    if parameter.grad is not None:
        writer.add_histogram(f"Gradients/{name}", parameter.grad.detach(), epoch)

まず直方図の見え方だけを確認したい場合は、NumPyの配列でも試せます。

import numpy as np
from torch.utils.tensorboard import SummaryWriter


writer = SummaryWriter(comment="histogram_demo")

for step in range(2):
    np.random.seed(step)
    uniform_values = np.arange(100)
    normal_values = np.random.normal(size=1000)

    writer.add_histogram("distribution/uniform", uniform_values, step)
    writer.add_histogram("distribution/normal", normal_values, step)

writer.close()

TensorBoardの「HISTOGRAMS」タブで確認すると、distribution/uniformは0から99までの値が平坦に並んだ平坦な形、distribution/normalは0を中心に山形に集中した正規分布の形になります。step=0step=1で乱数のシードが違うため、normal側の山の細かい形は2つのstep間で少し異なります。

実際の訓練では、lossやaccuracyに加えて、各epochで重みと勾配を記録できます。

for epoch in range(max_epoch):
    train_one_epoch(...)

    for name, parameter in net.named_parameters():
        writer.add_histogram(f"Weights/{name}", parameter.detach(), epoch)
        if parameter.grad is not None:
            writer.add_histogram(f"Gradients/{name}", parameter.grad.detach(), epoch)

直方図は便利ですが、書き込みコストとファイルサイズが大きくなりやすいです。毎batchすべてのパラメータを記録するより、数epochごと、または問題が起きた範囲だけに絞るほうが実用的です。

見るポイントは次のようなものです。

  • 重み分布が訓練中に極端に広がっていないか
  • 勾配がほぼ0に張り付いていないか
  • 勾配に極端な外れ値が出ていないか
  • BatchNormやLayerNormのパラメータが不自然に偏っていないか
  • ReLU後の活性値が0に偏りすぎていないか

Imageとmake_grid

add_image

writer.add_image(
    tag,
    img_tensor,
    global_step=None,
    walltime=None,
    dataformats="CHW",
)

役割:1枚の画像をTensorBoardへ記録します。入力画像、予測マスク、Grad-CAMの熱図などを確認できます。

主な引数

引数 意味
tag 画像の名前
img_tensor 画像Tensor。dataformats に応じてCHW、HWC、HWなど
global_step stepやepoch
walltime イベント時刻
dataformats Tensorの次元順。例:"CHW", "HWC", "HW"

float画像は通常 [0, 1]、uint8画像は [0, 255] の範囲にします。Normalize済みの訓練用Tensorをそのまま書くと、色や明るさが不自然に表示されることがあります。表示用には反標準化して、必要なら clamp(0, 1) します。

dataformats の違いは、ランダム画像で小さく確認できます。

import torch
from torch.utils.tensorboard import SummaryWriter


writer = SummaryWriter(comment="image_demo")

chw_image = torch.rand(3, 512, 512)
writer.add_image("fake_img/chw", chw_image, 1)

hw_image = torch.rand(512, 512)
writer.add_image("fake_img/hw", hw_image, 2, dataformats="HW")

hwc_image = torch.rand(512, 512, 3)
writer.add_image("fake_img/hwc", hwc_image, 3, dataformats="HWC")

writer.close()

3つともtorch.randによる一様乱数なので、TensorBoardの「IMAGES」タブではどれも同じような砂嵐(ランダムノイズ)状の画像に見えます。ここで確認したいのは絵の内容ではなく、dataformatsを正しく指定さえすれば、(C, H, W)(H, W)(H, W, C)のどの並び順のTensorでも同じように画像として表示できる、という点です。dataformatsの指定を間違えると、チャンネルと空間軸が入れ替わった、崩れた画像として表示されます。

add_images

writer.add_images(tag, img_tensor, global_step=None, walltime=None, dataformats="NCHW")

役割:複数画像をまとめて記録します。batchの中身をまとめて見たいときに使います。

dataformats="NCHW" の場合、img_tensor(N, C, H, W) の形です。第3回で扱った画像batchと同じ形なので、訓練データの確認に使いやすいです。

torchvision.utils.make_grid

複数画像を1枚のグリッド画像にまとめたいときは、make_grid が便利です。

from torchvision.utils import make_grid

grid = make_grid(
    images[:16],
    nrow=4,
    padding=2,
    normalize=True,
    value_range=None,
    scale_each=False,
    pad_value=0.0,
)
writer.add_image("Batch/train", grid, global_step)

役割(B, C, H, W) の画像batchや画像リストを、1枚の画像グリッドに変換します。

主な引数

引数 意味
tensor 画像batch、または同じサイズの画像Tensorリスト
nrow 1行に並べる画像の枚数
padding 画像同士の間隔
normalize 表示用に値を [0, 1] へ正規化するか
value_range 正規化に使う最小値・最大値
scale_each 画像ごとに別々に正規化するか
pad_value 余白部分の値

nrow は行数ではなく、1行あたりの画像枚数です。たとえば10枚の画像を nrow=4 にすると、4枚、4枚、2枚の3行になります。

第3回のような画像Datasetがある場合は、batchをそのままグリッド化してTensorBoardへ書けます。Qiitaの記事には紙幣画像のデータセットそのものを同梱できないため、BanknoteDatasettrain_dir は第3回・第4回で説明したディレクトリ構成の画像がすでに手元に用意されているものとして進めます。同じ画像がない場合でも、torchvision.datasets.CIFAR10(root="./data", train=True, download=True) のように公開データセットを使えば、download=True により自動でダウンロードされる画像で同じ形のコードを試せます。

from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
from torchvision import transforms
from torchvision.utils import make_grid


writer = SummaryWriter(comment="make_grid_demo")

transform = transforms.Compose([
    transforms.Resize((32, 64)),
    transforms.ToTensor(),
])
train_data = BanknoteDataset(data_dir=train_dir, transform=transform)
train_loader = DataLoader(train_data, batch_size=16, shuffle=True)

images, labels = next(iter(train_loader))
image_grid = make_grid(
    images,
    nrow=4,
    normalize=True,
    scale_each=True,
)
writer.add_image("input_images", image_grid, 0)
writer.close()

TensorBoardで確認すると、batch内の16枚の紙幣画像が4列×4行のグリッドとして、1枚の画像にまとめて表示されます。normalize=Truescale_each=True により、画像ごとに値の範囲が [0, 1] へ調整されるため、明るさの異なる画像が混ざっていても見やすく並びます。

Graphとモデル概要

add_graph

writer.add_graph(
    model,
    input_to_model=None,
    verbose=False,
    use_strict_trace=True,
)

役割:モデルにサンプル入力を流し、TensorBoard上で計算グラフに近い構造を表示します。

主な引数

引数 意味
model 対象の nn.Module
input_to_model モデルへ渡すサンプル入力。tuple入力も可
verbose trace情報を詳しく出すか
use_strict_trace trace時にPythonコンテナなどを厳密に扱うか

使い方

example = torch.randn(1, 3, 224, 224, device=device)
writer.add_graph(model, example)

第4回のような小さなCNNなら、32x32のダミー画像を1枚渡してgraphを記録できます。

from torch import nn
from torch.utils.tensorboard import SummaryWriter


writer = SummaryWriter(comment="graph_demo")

fake_image = torch.randn(1, 3, 32, 32)
model = nn.Sequential(
    nn.Conv2d(3, 16, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Flatten(),
    nn.Linear(16 * 16 * 16, 2),  # 32x32がMaxPool2d(2)で16x16に、チャンネル数は16
)

writer.add_graph(model, fake_image)
writer.close()

TensorBoardの「GRAPHS」タブを開くと、Conv2d -> ReLU -> MaxPool2d -> Flatten -> Linear の順で、このミニCNNの各層がノードとしてつながった図が表示されます。

add_graph は構造確認に便利ですが、データ依存の分岐、辞書を含む複雑な入出力、自作演算などではうまくtraceできないことがあります。TensorBoard上のgraphは、すべての実行経路を保証するものではありません。

モデル概要を見る

外部ツールとしては torchinfo.summary() などがよく使われます。外部依存を増やしたくない場合は、まずPyTorchだけでパラメータを確認できます。第4回の BanknoteCNN を例に見てみます。

model = BanknoteCNN(num_classes=2)  # 第4回で定義したCNN

print(model)

total = 0
for name, parameter in model.named_parameters():
    count = parameter.numel()
    total += count
    print(name, parameter.shape, count)

print("total parameters:", total)

print(model) は、nn.Module の入れ子構造をそのまま表示します(Sequential の中身や、Conv2d/BatchNorm2d/ReLU/MaxPool2dの引数などが読める形で並びます)。続くループの出力は次の通りです。

features.0.weight torch.Size([32, 3, 3, 3]) 864
features.1.weight torch.Size([32]) 32
features.1.bias torch.Size([32]) 32
features.4.weight torch.Size([64, 32, 3, 3]) 18432
features.5.weight torch.Size([64]) 64
features.5.bias torch.Size([64]) 64
features.8.weight torch.Size([128, 64, 3, 3]) 73728
features.9.weight torch.Size([128]) 128
features.9.bias torch.Size([128]) 128
classifier.weight torch.Size([2, 128]) 256
classifier.bias torch.Size([2]) 2
total parameters: 93730

features.0(最初のConv2d)は bias=False で作られているため、独立した bias パラメータを持ちません。代わりに直後の features.1(BatchNorm2d)が weightbias を持ちます。畳み込み層のパラメータ数は「出力チャンネル数×入力チャンネル数×kernel高さ×kernel幅」で決まるため、チャンネル数が32→64→128と増えるにつれて、1層あたりのパラメータ数も大きく増えていくことが分かります。

どの層が何個のパラメータを持つかを自分で見られるようになると、モデル概要ツールの出力も読みやすくなります。

中間特徴を取り出す方法

中間特徴を取り出す方法はいくつかあります。

  1. forward() を書き換えて中間出力も返す
  2. forward hookで出力を保存する
  3. torchvision.models.feature_extraction を使う

torchvisionには、FXを使って指定ノードの出力を返す仕組みがあります。

from torchvision.models.feature_extraction import create_feature_extractor


return_nodes = {
    "features.2": "pool1",
    "features.5": "pool2",
}
extractor = create_feature_extractor(model, return_nodes=return_nodes)
features = extractor(x)

create_feature_extractor の役割:モデルをtraceし、指定した中間ノードを出力として返す新しいmoduleを作ります。

主な引数

引数 意味
model 対象のmodel
return_nodes 取り出したいノード名から出力名へのdict

Hookのhandle管理が不要になる一方、FX traceが苦手な動的モデルでは使いにくいことがあります。単純なCNNでは有効な選択肢です。

畳み込みカーネルを可視化したい場合は、重みを取り出します。

weights = model.features[0].weight.detach().cpu()

AlexNetの最初の畳み込み層の重みをTensorBoardへ並べるなら、次のように書けます。weights=models.AlexNet_Weights.DEFAULT を指定すると、初回実行時にImageNetで学習済みの重み(約233MB)が自動でダウンロードされます。ネットワーク環境によっては時間がかかるので、実行前にインターネット接続を確認してください。利用可能な重みの一覧や仕様は、torchvisionの公式ドキュメントで確認できます。

import torch.nn as nn
import torchvision.models as models
from torch.utils.tensorboard import SummaryWriter
from torchvision.utils import make_grid


writer = SummaryWriter(comment="kernel_demo")
alexnet = models.alexnet(weights=models.AlexNet_Weights.DEFAULT)

for layer_index, module in enumerate(alexnet.features):
    if isinstance(module, nn.Conv2d):
        kernels = module.weight.detach().cpu()
        out_channels, in_channels, kernel_h, kernel_w = kernels.shape

        if in_channels == 3:
            kernel_grid = make_grid(
                kernels,
                nrow=8,
                normalize=True,
                scale_each=True,
            )
            writer.add_image(f"conv{layer_index}/kernels", kernel_grid, 0)
        break

writer.close()

AlexNetの最初の畳み込み層は入力チャンネル数が3(RGB画像用)なので、in_channels == 3 の条件に一致してすぐ break します。TensorBoardで確認すると、64種類の小さな3チャンネルカーネルが、8列×8行のグリッド画像として並びます。学習済みモデルのカーネルは、エッジや色の勾配を捉えるパターンになっていることが多く、ランダム初期化直後の粒状のパターンとは見え方が異なります。

特徴マップも画像として記録できます。入力画像を1枚通し、対象層の出力を (チャンネル数, 1, H, W) に並べ替えてから make_grid に渡します。"your_image.jpg" の部分は、手元にある任意のRGB画像ファイルのパスに置き換えてください。

from PIL import Image
from torchvision import transforms


image = Image.open("your_image.jpg").convert("RGB")  # 任意のRGB画像に置き換える
transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.49139968, 0.48215827, 0.44653124],
        std=[0.24703233, 0.24348505, 0.26158768],
    ),
])
image_tensor = transform(image).unsqueeze(0)

first_conv = alexnet.features[0]
feature_map = first_conv(image_tensor).detach().cpu()
feature_map = feature_map.transpose(0, 1)  # (1, C, H, W) -> (C, 1, H, W)
feature_grid = make_grid(feature_map, normalize=True, scale_each=True, nrow=8)

writer = SummaryWriter(comment="feature_map_demo")
writer.add_image("feature_map/conv1", feature_grid, 0)
writer.close()

入力チャンネルが3ならRGB画像として並べられる場合があります。チャンネル数が多い特徴では、適当な1チャンネルを選ぶ、平均を取る、PCAで圧縮するなど、可視化の意味を明確にします。

ここまでの create_feature_extractor や、直接 model.features[0] のように層を取り出す方法は、モデルの構造がシンプルで、あらかじめ「どの層を見たいか」が分かっている場合に向いています。一方、forward() の内部で実際に何が起きているかをその場で観察したい場合や、モデルを書き換えずに一時的に値を覗きたい場合には、次に説明するHookの仕組みのほうが柔軟です。

Hookの基本

Hookは、TensorやModuleの途中に関数を登録し、forwardやbackwardのタイミングで呼び出してもらう仕組みです。

Module呼び出し
  -> forward_pre_hook
  -> forward
  -> forward_hook
  -> loss
  -> backward
  -> Tensor gradient hook / full backward hook

Hook登録メソッドは、基本的に RemovableHandle を返します。

handle = module.register_forward_hook(hook)
...
handle.remove()

注意点:登録したHookを外さないと、同じcallbackが何度も呼ばれたり、古い状態が残ったりします。検証や可視化のために一時的に使うHookは、使い終わったら必ず remove() します。

Hook内で中間Tensorを保存するときは、計算グラフを保持したままにならないように注意します。

activations[name] = output.detach().cpu()

detach() せずに保存すると、backward用の計算グラフが解放されにくくなり、メモリを圧迫することがあります。

Tensor.register_hook

Tensorに対して勾配Hookを登録するには、register_hook を使います。

handle = tensor.register_hook(hook)

役割:そのTensorに対する勾配が計算されたタイミングで、登録した関数を呼びます。

hook関数の形

hook(grad) -> Tensor または None

戻り値

戻り値 意味
None 勾配を置き換えず、そのまま使う
Tensor 返したTensorでその場所の勾配を置き換える

使い方

import torch


x = torch.tensor(2.0, requires_grad=True)
y = x ** 2  # y = x^2 なので dy/dx = 2x

def print_grad(grad):
    print("grad:", grad)
    return None


handle = x.register_hook(print_grad)
y.backward()
handle.remove()

出力は次の通りです。

grad: tensor(4.)

x=2.0 なので dy/dx = 2 * 2.0 = 4.0 となり、backward() の実行中にHookが呼ばれてこの値が表示されます。register_hook は、loss.backward() の裏側で実際に何が起きているかを、途中のTensorの勾配という形で覗き見できる仕組みです。

中間TensorにHookを登録すると、そのTensorを通る勾配を観察できます。次の例では、$y=(w+x)(w+1)$ の中間値 a にHookを登録しています。

import torch


w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

a = w + x  # a = 1.0 + 2.0 = 3.0
b = w + 1  # b = 1.0 + 1.0 = 2.0
y = a * b  # y = 3.0 * 2.0 = 6.0

a_grad = []

def save_grad(grad):
    a_grad.append(grad.detach().clone())  # 勾配を書き換えず、コピーを保存するだけ


handle = a.register_hook(save_grad)
y.backward()

print("w.grad:", w.grad)
print("x.grad:", x.grad)
print("a_grad:", a_grad[0])

handle.remove()

出力は次の通りです。

w.grad: tensor([5.])
x.grad: tensor([2.])
a_grad: tensor([2.])

y = a * b = (w + x) * (w + 1) なので、dy/da = b = 2dy/db = a = 3 です。aw にも x にも依存するため、連鎖律(chain rule)により dy/dw = dy/da * da/dw + dy/db * db/dw = 2*1 + 3*1 = 5dy/dx = dy/da * da/dx = 2*1 = 2 となります。Hookが受け取る grad は、まさにこの dy/daa_grad[0])の値です。wx のように最初から requires_grad=True で作ったTensorだけでなく、a のような計算途中のTensorについても、その場所を流れる勾配の値を直接のぞき見できるのが register_hook の便利な点です。

Hookが返したTensorで勾配を置き換えることもできます。たとえば次のコードでは、w に流れる勾配を3倍にしています。

w = torch.tensor([1.0], requires_grad=True)
x = torch.tensor([2.0], requires_grad=True)

y = (w + x) * (w + 1)  # 前の例と同じ式(Hookがなければw.gradは5になるはず)

def scale_grad(grad):
    return grad * 3  # 返したTensorで、この場所を流れる勾配を置き換える


handle = w.register_hook(scale_grad)
y.backward()

print("w.grad:", w.grad)
handle.remove()

出力は次の通りです。

w.grad: tensor([15.])

前の例と同じ式なので、Hookがなければ w.grad5.0 になるはずでした。scale_gradw に流れる勾配を3倍に置き換えるため、実際の出力は 15.0= 5.0 * 3)になります。勾配を返して置き換えることもできますが、これは訓練そのものを変えます。監視目的なら、まずは値を読むだけにします。渡された grad をHook内でインプレースで変更しないことも重要です。

Moduleのforward hook

register_forward_hook

module.register_forward_hook(
    hook,
    *,
    prepend=False,
    with_kwargs=False,
    always_call=False,
)

役割:moduleの forward() が終わったあとにhookを呼びます。中間出力の保存、shape確認、簡単な出力差し替えに使えます。

主な引数

引数 意味
hook 呼び出される関数
prepend 既存Hookより先に呼ぶか
with_kwargs forward() のキーワード引数もhookへ渡すか
always_call forward中に例外が出た場合でもhookを呼ぶか

基本形は次の通りです。

hook(module, args, output) -> None または新しい output

使い方

activations = {}

def save_activation(name):
    def hook(module, args, output):
        activations[name] = output.detach().cpu()
    return hook


handle = model.features[-1].register_forward_hook(
    save_activation("last_conv")
)

_ = model(images)
handle.remove()

複数のConv2d層から特徴マップを集める場合は、各層にforward hookを登録します。次の例では、AlexNetのConv2d出力をTensorBoardへ画像グリッドとして記録します。

import math
import torch
import torch.nn as nn
import torchvision.models as models
from torch.utils.tensorboard import SummaryWriter
from torchvision.utils import make_grid


writer = SummaryWriter(comment="hook_feature_map_demo")
model = models.alexnet(weights=models.AlexNet_Weights.DEFAULT).eval()

# 実際には前節で説明したPIL+transformsの前処理を通した本物の画像を使う。
# ここではHook登録の仕組みそのものを確認したいので、ダミーの1枚のRGB画像で代用する。
image_tensor = torch.randn(1, 3, 224, 224)

feature_maps = {}
handles = []

def save_feature_map(name):
    def hook(module, args, output):
        feature_maps[name] = output.detach().cpu()
    return hook


for name, module in model.named_modules():
    if isinstance(module, nn.Conv2d):
        handle = module.register_forward_hook(save_feature_map(name))
        handles.append(handle)

_ = model(image_tensor)  # image_tensorのshapeは (1, 3, 224, 224)

for name, feature_map in feature_maps.items():
    channels = feature_map.transpose(0, 1)  # (1, C, H, W) -> (C, 1, H, W)
    nrow = max(1, int(math.sqrt(channels.shape[0])))
    grid = make_grid(channels, normalize=True, scale_each=True, nrow=nrow)
    writer.add_image(f"feature_map/{name}", grid, 0)

for handle in handles:
    handle.remove()

writer.close()

model.named_modules() はAlexNetの中の全てのConv2d層(5層)を巡回するので、feature_maps には5つの中間特徴マップが保存されます。TensorBoardで確認すると、feature_map/features.0 のような名前で、層が深くなるほどチャンネル数が増え、空間サイズが小さくなっていく様子が並びます。

register_forward_pre_hook

module.register_forward_pre_hook(
    hook,
    *,
    prepend=False,
    with_kwargs=False,
)

役割forward() が呼ばれる直前にhookを呼びます。入力のshape確認や、特殊な調査で入力を差し替えたいときに使います。

基本形は次の通りです。

hook(module, args) -> None または新しい args

forward hook、pre-hook、backward hookの呼ばれ方は、小さな畳み込みネットワークで確認できます。

import torch
from torch import nn


class TinyConvNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 2, kernel_size=3)
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)

    def forward(self, x):
        x = self.conv1(x)
        x = self.pool1(x)
        return x


def forward_hook(module, args, output):
    print("forward_hook output shape:", output.shape)


def forward_pre_hook(module, args):
    print("forward_pre_hook input shape:", args[0].shape)


def backward_hook(module, grad_input, grad_output):
    print("backward_hook grad_output shape:", grad_output[0].shape)


net = TinyConvNet()
net.conv1.weight.data[0].fill_(1.0)  # 出力チャンネル0のカーネルをすべて1.0にする
net.conv1.weight.data[1].fill_(2.0)  # 出力チャンネル1のカーネルをすべて2.0にする
net.conv1.bias.data.zero_()

handles = [
    net.conv1.register_forward_pre_hook(forward_pre_hook),
    net.conv1.register_forward_hook(forward_hook),
    net.conv1.register_full_backward_hook(backward_hook),
]

fake_image = torch.ones(1, 1, 4, 4)  # 全要素が1.0の4x4画像1枚
output = net(fake_image)
target = torch.randn_like(output)
loss = nn.L1Loss()(output, target)
loss.backward()

for handle in handles:
    handle.remove()

出力は次の通りです(実行順は forward_pre_hook → forward_hook → backward_hook)。

forward_pre_hook input shape: torch.Size([1, 1, 4, 4])
forward_hook output shape: torch.Size([1, 2, 2, 2])
backward_hook grad_output shape: torch.Size([1, 2, 2, 2])

conv1 はkernel_size=3、paddingなしなので、4x4の入力は2x2まで小さくなります(4 - 3 + 1 = 2)。出力チャンネル数は2なので、forward_hookで観察する出力shapeは (1, 2, 2, 2) です。backward_hook が受け取る grad_output は、この2x2の出力に対応する勾配で、同じ (1, 2, 2, 2) という形になります(conv1のあとのpool1は独立したMaxPool2dなので、Hookは登録していません)。

このコードを実際に実行すると、UserWarning: Full backward hook is firing when gradients are computed with respect to module outputs... という警告も表示されます。これは、fake_image = torch.ones(1, 1, 4, 4)requires_grad=False のまま作られているためです。register_full_backward_hook は本来module入力側・出力側どちらの勾配も扱えますが、入力Tensor自体が勾配を必要としない場合、PyTorchは出力側の勾配だけをもとにhookを発火させ、その旨を警告として知らせます。このデモではgrad_inputを使っていないので実害はありませんが、実際のモデルで入力側の勾配そのものが必要な場面(Grad-CAMのように入力画像への勾配を使いたい場合など)では、この警告が出ていないか確認する価値があります。

入力を書き換えるHookは、モデルの挙動そのものを変えます。デバッグや研究目的で使う場合も、通常の訓練コードと混ざらないようにします。

Moduleのbackward hook

Module単位のbackward hookでは、入力勾配や出力勾配を観察できます。現在のコードでは、次のfull backward hookを使うのが基本です。

module.register_full_backward_pre_hook(hook)
module.register_full_backward_hook(hook)

register_full_backward_hook の役割:対象moduleに対してbackwardが流れたとき、moduleの入力側・出力側の勾配をhookへ渡します。

基本形は次の通りです。

hook(module, grad_input, grad_output) -> None または新しい grad_input

引数の意味

引数 意味
module Hookを登録したmodule
grad_input module入力に対する勾配のtuple
grad_output module出力に対する勾配のtuple

grad_inputgrad_output はtupleです。すべての要素がTensorとは限らず、勾配が存在しない場所は None になることがあります。

Grad-CAMでは、Moduleのbackward hookを使う代わりに、forward hookで得た出力Tensorへ output.register_hook(...) を登録して勾配を集める方法もよく使われます。このほうが、対象Tensorの勾配を直接扱えるため見通しがよい場合があります。

CAM

CAMはClass Activation Mappingの略です。画像分類モデルが、あるクラスを判断するときにどの空間位置を強く使ったかを可視化する考え方です。

典型的なCAMでは、最後の畳み込み特徴マップにGlobal Average Poolingをかけ、その後に線形分類器を置く構造を想定します。

最後の畳み込み特徴 A_k(x, y)
  -> Global Average Poolingで F_k にする
  -> Linear分類器の重み w_k^c を使う
  -> クラスcのスコア y^c = sum_k w_k^c F_k

クラスcに対するCAMは、分類器の重みで特徴マップを足し合わせます。

M_c(x, y) = sum_k w_k^c * A_k(x, y)
Heatmap = ReLU(M_c)

CAMは仕組みが分かりやすい一方で、GAPと線形分類器を前提にします。複雑な全結合層を持つCNNや、空間構造を最後に失っているモデルには、そのまま適用できません。

Grad-CAM

Grad-CAMは、対象クラスのlogitを最後の畳み込み特徴マップで微分し、その勾配を通じてチャンネルごとの重要度を求めます。

対象クラスのスコアを $y^c$、特徴マップを $A^k$ とすると、チャンネル重みは次の形で表されます。

alpha_k^c = (1 / Z) * sum_i sum_j d y^c / d A_ij^k

これは、特徴マップの空間方向に勾配を平均していると考えられます。

熱図は次のように作ります。

L_Grad-CAM^c = ReLU(sum_k alpha_k^c * A^k)

その後、入力画像のサイズへ補間し、[0, 1] に正規化し、色を付けて元画像へ重ねます。

Grad-CAMはCAMと違い、GAPと線形分類器を必須としません。ただし、対象層には意味のある空間構造が残っている必要があります。通常は、最後の畳み込み層や、最後の畳み込みブロックを選びます。

Grad-CAMの実装例

次の実装は、forward hookで対象層の出力を保存し、その出力Tensorへ勾配Hookを登録する形です。

import torch
import torch.nn.functional as F


class GradCAM:
    def __init__(self, model, target_layer):
        self.model = model
        self.activations = None
        self.gradients = None
        self.forward_handle = target_layer.register_forward_hook(
            self._forward_hook
        )

    def _forward_hook(self, module, args, output):
        self.activations = output                  # 対象層の特徴マップを保存する
        output.register_hook(self._gradient_hook)  # この特徴マップへの勾配を保存する

    def _gradient_hook(self, grad):
        self.gradients = grad

    def __call__(self, inputs, class_index=None):
        self.model.zero_grad(set_to_none=True)
        logits = self.model(inputs)  # shapeは通常 (N, num_classes)

        if class_index is None:
            class_index = logits.argmax(dim=1)  # 指定がなければ予測クラスを見る

        score = logits.gather(1, class_index[:, None]).sum()
        score.backward()

        weights = self.gradients.mean(dim=(-2, -1), keepdim=True)
        cam = (weights * self.activations).sum(dim=1, keepdim=True)
        cam = F.relu(cam)
        cam = F.interpolate(
            cam,
            size=inputs.shape[-2:],
            mode="bilinear",
            align_corners=False,
        )

        minimum = cam.amin(dim=(-2, -1), keepdim=True)
        maximum = cam.amax(dim=(-2, -1), keepdim=True)
        cam = (cam - minimum) / (maximum - minimum).clamp_min(1e-8)
        return logits, cam.detach()

    def close(self):
        self.forward_handle.remove()

使い方:小さなCNNで一連の流れを確認します。target_layer には、空間構造がまだ残っている最後の畳み込み層(ここでは conv2)を渡します。

class TinyCAMNet(nn.Module):
    def __init__(self, num_classes=2):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 4, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(4, 8, kernel_size=3, padding=1)
        self.pool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(8, num_classes)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))  # conv2の出力をGrad-CAMの対象にする
        x = self.pool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)
        return x


torch.manual_seed(0)
model = TinyCAMNet(num_classes=2)
inputs = torch.randn(2, 3, 16, 16)  # batch size 2、3チャンネル、16x16のダミー画像

cam_extractor = GradCAM(model, model.conv2)
logits, heatmap = cam_extractor(inputs)

print("logits.shape:", logits.shape)
print("heatmap.shape:", heatmap.shape)
print("heatmap.min():", heatmap.min().item(), "heatmap.max():", heatmap.max().item())

cam_extractor.close()

出力は次の通りです。

logits.shape: torch.Size([2, 2])
heatmap.shape: torch.Size([2, 1, 16, 16])
heatmap.min(): 0.0 heatmap.max(): 1.0

logits(バッチサイズ, クラス数) の形です(class_index を指定しなかったので、内部で予測クラスが自動選択されます)。heatmap は、conv2 の出力チャンネル数(8)を重み付き和でつぶし、入力画像と同じ 16x16 へ拡大した1チャンネルの熱図で、(バッチサイズ, 1, 高さ, 幅) の形になります。min()0.0max()1.0 になっているのは偶然ではなく、GradCAM.__call__ 内の最後の正規化処理(各画像ごとに最小値0・最大値1へスケーリングする)による結果です。熱図の中でどこが実際に強く反応しているか(空間的なパターンそのもの)は、モデルの重みやHookが捉えた勾配によって決まるため、初期化のたびに変わります。

この実装で特に大切なのは、次の点です。

  • Grad-CAMでは、Softmax後の確率ではなく対象クラスのlogitからbackwardする
  • model.eval() で評価モードにしてよいが、torch.no_grad()torch.inference_mode() の中では実行しない
  • Hookで保存したTensorを長く持ち続ける場合は、計算グラフやGPUメモリに注意する
  • 可視化が終わったら close() でHookを外す
  • mixed precision下では、説明用だけfloat32で計算したほうが見やすい場合がある

可視化結果の限界と検証

CAMやGrad-CAMの熱図は、モデルの判断に関係した空間的な手がかりを示す近似的な説明です。因果関係の証明でも、セグメンテーションの正解マスクでもありません。

主な限界は次の通りです。

  • 解像度は対象特徴マップの空間サイズに制限される
  • どの層を選ぶかで見え方が変わる
  • 補間やカラーマップで印象が変わる
  • 背景や共起物を強く見ている場合もある
  • Softmax飽和などで勾配が弱くなることがある
  • Vision Transformerに適用する場合は、tokenと空間配置の対応を別途定義する必要がある

確認方法としては、次のようなものがあります。

  • 熱い領域を隠したときに予測が変わるかを見る
  • 正解クラスと誤分類クラスの熱図を比較する
  • ランダム化したモデルで熱図が意味を失うかを見る
  • 少し画像を変えたとき、熱図が極端に不安定でないかを見る
  • 高リスクな判断では、熱図だけを根拠にしない

図として整理する

学習率戦略

戦略 曲線イメージ 特徴
StepLR 階段 固定間隔で下がる
MultiStepLR 不等間隔の階段 指定したmilestoneで下がる
ExponentialLR なめらかな指数 毎step同じ割合で下がる
CosineAnnealingLR 半余弦 なめらかに下限へ近づく
Warm Restarts 余弦を繰り返す 周期ごとに高いlrへ戻る
ReduceLROnPlateau 指標で階段 改善が止まったら下がる
OneCycleLR 先に上げて下げる batch単位で1周期を作る

TensorBoardのデータの流れ

訓練ループ
  ├── add_scalar: loss / metric / lr
  ├── add_histogram: parameter / gradient
  ├── add_image: input / prediction / heatmap
  └── add_graph: model trace
        |
        v
SummaryWriter -> event file -> TensorBoard -> ブラウザ表示

Hookの呼び出し順

入力 args / kwargs
    |
forward_pre_hook
    |
module.forward
    |
forward_hook
    |
loss
    |
backward
    |
Tensor gradient hook / full backward hook

Grad-CAMの流れ

対象クラスのlogit
  -> 対象層特徴マップへの勾配を計算
  -> 勾配を空間平均してチャンネル重み alpha を作る
  -> 特徴マップを重み付き和にする
  -> ReLU
  -> 入力画像サイズへリサイズ
  -> 正規化して元画像へ重ねる

訓練監視の小さなテンプレート

最後に、schedulerとTensorBoardを含めた訓練ループの形をまとめます。ここでは modeltrain_loadervalid_loadercriterionevaluate は事前に用意されているものとします。

from pathlib import Path
from torch.utils.tensorboard import SummaryWriter


log_dir = Path("runs") / "experiment_001"
writer = SummaryWriter(log_dir=str(log_dir))

global_step = 0

for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0

    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)
        logits = model(images)
        loss = criterion(logits, targets)
        loss.backward()
        optimizer.step()

        running_loss += loss.item()
        writer.add_scalar("Loss/train_step", loss.item(), global_step)
        writer.add_scalar(
            "LearningRate/group0",
            optimizer.param_groups[0]["lr"],
            global_step,
        )
        global_step += 1

        # OneCycleLRのようにbatch単位で進めるschedulerならここでstepする
        # scheduler.step()

    train_loss = running_loss / len(train_loader)
    valid_loss, valid_accuracy = evaluate(model, valid_loader)

    writer.add_scalars(
        "Loss/epoch",
        {
            "train": train_loss,
            "valid": valid_loss,
        },
        epoch,
    )
    writer.add_scalar("Accuracy/valid", valid_accuracy, epoch)

    if isinstance(scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
        scheduler.step(valid_loss)
    else:
        scheduler.step()

    if epoch % 5 == 0:
        for name, parameter in model.named_parameters():
            writer.add_histogram(f"Parameters/{name}", parameter.detach(), epoch)
            if parameter.grad is not None:
                writer.add_histogram(
                    f"Gradients/{name}",
                    parameter.grad.detach(),
                    epoch,
                )

writer.close()

このコードはあくまで形を示すテンプレートです。実際には、schedulerがepoch単位なのかbatch単位なのかを先に決め、scheduler.step() の場所をそれに合わせます。isinstance だけで全戦略を自動判定しようとすると、特殊なschedulerで読みづらくなることがあります。

よくあるつまずきどころ

つまずき 原因 対処
optimizer.step() の前に通常schedulerを進める 更新前にlr段階がずれる 通常は optimizer.step() の後に scheduler.step()
epoch用schedulerをbatchごとに呼ぶ 学習率が想定より速く下がる schedulerの呼び出し単位を確認する
ReduceLROnPlateau.step() にmetricを渡さない 改善判定ができない 検証後に scheduler.step(valid_loss) と書く
lr記録に get_lr() を使う 内部計算用メソッドを見ている get_last_lr() またはoptimizerのparam groupを見る
再開後にlrが変になる scheduler状態を復元していない checkpointにscheduler stateも入れる
CosineAnnealingLR がrestartすると思い込む restart用クラスと混同している restartしたいなら CosineAnnealingWarmRestarts を使う
TensorBoardの実験が混ざる 同じlog_dirへ書いている 実験ごとにlog_dirを分ける
writer.close() を忘れる event fileがflushされないことがある with SummaryWriter(...) または最後にclose
直方図ログが重すぎる 毎batch全パラメータを記録している epoch単位や一定間隔に絞る
Normalize済み画像をそのまま書く 値域が表示用ではない 反標準化して [0, 1] にclampする
make_grid.nrow を行数だと思う 引数名の意味を取り違えている nrow は1行あたりの画像枚数
model.forward(x) を直接呼ぶ Module呼び出しの仕組みを通らない model(x) と呼ぶ
HookでTensorをそのまま保存する 計算グラフが残る 監視用は detach() する
Hookを外し忘れる 同じhookが何度も動く handleを保存し、最後に remove()
backward hookの古い書き方に頼る 挙動が分かりにくい full backward hookやTensor hookを使う
no_grad() の中でGrad-CAMする 勾配が計算されない Grad-CAM時は勾配計算を有効にする
Softmax確率からGrad-CAMする 勾配が弱くなったり解釈が変わる 対象クラスのlogitからbackwardする
Linear層をGrad-CAM対象にする 空間構造が残っていない 最後の畳み込み層を選ぶ
熱図を正解マスクのように扱う Grad-CAMは近似的な説明 遮蔽テストや安定性確認と併用する

参考リンク

おわりに

今回は、訓練を進めるための学習率スケジューラ、訓練を記録するTensorBoard、モデル内部を見るHook、画像分類モデルの判断領域を調べるCAMとGrad-CAMを整理しました。

学習率スケジューラでは、どのクラスを選ぶかだけでなく、scheduler.step() をどのタイミングで呼ぶかが重要です。epoch単位、batch単位、検証指標単位を混同すると、学習率の変化が意図から外れます。

TensorBoardでは、lossやmetricだけでなく、学習率、勾配ノルム、パラメータ分布、入力画像などを記録できます。ただし、ログは多ければよいわけではありません。安定したtag、適切なglobal step、実験ごとのlog directory、必要十分な記録頻度を決めることが大切です。

Hookは、モデル本体を書き換えずに中間特徴や勾配を観察できる強力な仕組みです。その一方で、handleの管理、detach()、メモリ、Hookによる挙動変更には注意が必要です。

Grad-CAMは、画像分類モデルの判断を空間的に確認する助けになります。ただし、熱図は因果説明でも正解マスクでもありません。遮蔽テストやランダム化確認などと組み合わせ、モデル理解の補助として扱うのが現実的です。

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?