はじめに
第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の熱図をどう解釈すべきかを、実際の訓練コードに接続して理解することです。
目次
- この記事で扱うこと
- 第5回から第6回へのつながり
- 学習率スケジューラの全体像
LRSchedulerと呼び出し順序StepLRMultiStepLRExponentialLR- 余弦アニーリングとWarm Restarts
ReduceLROnPlateauLambdaLR- Warmup、OneCycle、組み合わせスケジューラ
- スケジューラの選び方とチェックポイント
- TensorBoardの基本
SummaryWriter- Scalarと複数曲線
- Histogramでパラメータと勾配を見る
- Imageと
make_grid - Graphとモデル概要
- 中間特徴を取り出す方法
- Hookの基本
Tensor.register_hook- Moduleのforward hook
- Moduleのbackward hook
- CAM
- Grad-CAM
- Grad-CAMの実装例
- 可視化結果の限界と検証
- 図として整理する
- 訓練監視の小さなテンプレート
- よくあるつまずきどころ
- 参考リンク
- おわりに
この記事で扱うこと
この記事では、次の内容を扱います。
- 学習率スケジューラを使う理由
- schedulerとoptimizerの正しい呼び出し順序
-
StepLR、MultiStepLR、ExponentialLR -
CosineAnnealingLRとCosineAnnealingWarmRestarts -
ReduceLROnPlateauと検証指標の関係 -
LambdaLR、warmup、OneCycleLR、SequentialLR - scheduler stateを含めたチェックポイント保存
- TensorBoardのイベントファイルとlog directory
-
SummaryWriter、add_scalar、add_scalars -
add_histogramによるパラメータ・勾配監視 -
add_image、add_images、torchvision.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数に応じて更新するための基底クラスです。普段はこのクラスを直接使うより、StepLR や CosineAnnealingLR などの具体的なクラスを使います。
主な引数:
| 引数 | 意味 |
|---|---|
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_loader と max_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番号では49、124、159の時点ですでに学習率が下がります。
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.09025は0.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=49(T_maxと同じ長さだけ進んだ直後)で学習率がほぼ0まで下がっているのに対し、epoch=99ではほぼ0.1(最初のbase_lr)まで戻っている点です。epoch=100の値はepoch=0の値とほぼ一致しており、2 * T_max epochを1周期として値が上下していることが分かります。CosineAnnealingLRはT_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に相当する値を固定して渡します。途中で一度だけ改善させると、patience や cooldown の効き方を確認しやすくなります。
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_loss を 0.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 // 20 が 1 のあいだ(epoch=20〜39)は 0.01 を維持し続けます。一方group 1は毎epoch少しずつなめらかに下がり続けます。同じ LambdaLR でも、lr_lambda に渡す関数の形をそのまま学習率の形にできることが分かります。
複雑な状態を持つ無名関数を使うと、チェックポイント再開時の見通しが悪くなることがあります。長く使う訓練コードでは、名前付き関数や専用schedulerを使うほうが読みやすいです。
Warmup、OneCycle、組み合わせスケジューラ
Warmup
warmupは、訓練の最初だけ小さい学習率から始め、数stepまたは数epochかけて目標学習率まで上げる考え方です。大きなbatch size、大きなモデル、不安定な初期化では、最初から大きい学習率を使うとlossが乱れやすいことがあります。
PyTorchでは、LinearLR や ConstantLR を SequentialLR と組み合わせて書けます。次の例は、最初の5回を LinearLR でbase_lrの10%から100%まで直線的に立ち上げ(warmup)、6回目以降は CosineAnnealingLR(T_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)からは cosine(CosineAnnealingLR)へ切り替わり、そこから緩やかに下がり始めます。total_iters=5 と T_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単位で進めます。
SequentialLR と ChainedScheduler
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.load の weights_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つの図の中にxsinx・xcosx・arctanxの3本の曲線がまとめて重ねて表示され、add_scalarsが複数系列の比較に向いていることを確認できます。
複数の実験を同じlog directoryへ混ぜると、あとで比較しづらくなります。実験ごとに runs/exp001、runs/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/train と loss/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=0とstep=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の記事には紙幣画像のデータセットそのものを同梱できないため、BanknoteDataset と train_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=True と scale_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)が weight と bias を持ちます。畳み込み層のパラメータ数は「出力チャンネル数×入力チャンネル数×kernel高さ×kernel幅」で決まるため、チャンネル数が32→64→128と増えるにつれて、1層あたりのパラメータ数も大きく増えていくことが分かります。
どの層が何個のパラメータを持つかを自分で見られるようになると、モデル概要ツールの出力も読みやすくなります。
中間特徴を取り出す方法
中間特徴を取り出す方法はいくつかあります。
-
forward()を書き換えて中間出力も返す - forward hookで出力を保存する
-
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 = 2、dy/db = a = 3 です。a は w にも x にも依存するため、連鎖律(chain rule)により dy/dw = dy/da * da/dw + dy/db * db/dw = 2*1 + 3*1 = 5、dy/dx = dy/da * da/dx = 2*1 = 2 となります。Hookが受け取る grad は、まさにこの dy/da(a_grad[0])の値です。w や x のように最初から 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.grad は 5.0 になるはずでした。scale_grad が w に流れる勾配を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_input と grad_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.0、max() が 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を含めた訓練ループの形をまとめます。ここでは model、train_loader、valid_loader、criterion、evaluate は事前に用意されているものとします。
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は近似的な説明 | 遮蔽テストや安定性確認と併用する |
参考リンク
- PyTorch TensorBoard: https://docs.pytorch.org/docs/2.13/tensorboard.html
- PyTorch LR Scheduler: https://docs.pytorch.org/docs/2.13/optim.html#how-to-adjust-learning-rate
-
LRScheduler: https://docs.pytorch.org/docs/2.13/generated/torch.optim.lr_scheduler.LRScheduler.html -
StepLR: https://docs.pytorch.org/docs/2.13/generated/torch.optim.lr_scheduler.StepLR.html -
ReduceLROnPlateau: https://docs.pytorch.org/docs/2.13/generated/torch.optim.lr_scheduler.ReduceLROnPlateau.html -
SequentialLR: https://docs.pytorch.org/docs/2.13/generated/torch.optim.lr_scheduler.SequentialLR.html - Module Hook: https://docs.pytorch.org/docs/2.13/generated/torch.nn.Module.html
- Tensor Hook: https://docs.pytorch.org/docs/2.13/generated/torch.Tensor.register_hook.html
- torchvision
make_grid: https://docs.pytorch.org/vision/stable/generated/torchvision.utils.make_grid.html - torchvision feature extraction: https://docs.pytorch.org/vision/stable/feature_extraction.html
- TensorBoard: https://www.tensorflow.org/tensorboard
- CAM paper: https://openaccess.thecvf.com/content_cvpr_2016/html/Zhou_Learning_Deep_Features_CVPR_2016_paper.html
- Grad-CAM paper: https://arxiv.org/abs/1610.02391
おわりに
今回は、訓練を進めるための学習率スケジューラ、訓練を記録するTensorBoard、モデル内部を見るHook、画像分類モデルの判断領域を調べるCAMとGrad-CAMを整理しました。
学習率スケジューラでは、どのクラスを選ぶかだけでなく、scheduler.step() をどのタイミングで呼ぶかが重要です。epoch単位、batch単位、検証指標単位を混同すると、学習率の変化が意図から外れます。
TensorBoardでは、lossやmetricだけでなく、学習率、勾配ノルム、パラメータ分布、入力画像などを記録できます。ただし、ログは多ければよいわけではありません。安定したtag、適切なglobal step、実験ごとのlog directory、必要十分な記録頻度を決めることが大切です。
Hookは、モデル本体を書き換えずに中間特徴や勾配を観察できる強力な仕組みです。その一方で、handleの管理、detach()、メモリ、Hookによる挙動変更には注意が必要です。
Grad-CAMは、画像分類モデルの判断を空間的に確認する助けになります。ただし、熱図は因果説明でも正解マスクでもありません。遮蔽テストやランダム化確認などと組み合わせ、モデル理解の補助として扱うのが現実的です。