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?

はじめに

第6回では、学習率スケジューラ、TensorBoard、Hook、Grad-CAMを使って、訓練を制御し、記録し、モデル内部を観察する方法を整理しました。

今回扱うのは、訓練そのものを安定させ、未知データへの性能を保つための仕組みです。具体的には、L1/L2正則化、Weight Decay、Dropout、BatchNorm、LayerNorm、InstanceNorm、GroupNormを扱います。

これらはすべて「過学習を防ぐ方法」として一括りにされがちですが、実際の役割はかなり違います。Weight Decayはパラメータの大きさへ直接働きかけます。Dropoutは訓練中の活性値へランダムなノイズを入れます。Normalizationは特徴の平均や分散を整えて、最適化を進めやすくします。

目的は、API名を暗記することではありません。どの方法が何を変えているのか、train()eval() で挙動がどう変わるのか、どのパラメータにWeight Decayをかけるべきか、小さいbatchでBatchNormがなぜ不安定になるのかを、PyTorchのコードとつなげて理解することです。

目次

この記事で扱うこと

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

  • 正則化が何を目的にするのか
  • バイアス・バリアンス分解の見方
  • L1正則化とL2正則化の違い
  • Weight Decayの更新式
  • SGDのWeight DecayとAdamWの分離されたWeight Decay
  • biasやNormalizationパラメータをWeight Decayから外す理由
  • Dropoutのinverted dropout方式
  • train()eval() によるDropoutとBatchNormの挙動の違い
  • DropoutDropout1dDropout2dDropout3d
  • BatchNormの平均・分散・running statistics
  • BatchNorm1dBatchNorm2dBatchNorm3dの入力形状
  • LayerNormInstanceNormGroupNormの正規化軸
  • 小さいbatchでBatchNormが難しくなる理由
  • CNN、RNN、TransformerでNormalizationの選び方が変わる理由
  • 正則化とNormalizationを組み込んだ小さなCNN例

コードは、小さなTensorで挙動を確認できるものを中心にします。画像データセットは使いません。

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

第6回では、学習率スケジューラで訓練の進み方を制御し、TensorBoardやHookで訓練中の状態を観察しました。

今回の話は、その観察結果をどう改善するかに近いです。

第6回:訓練を制御・記録・観察する
  -> lossやaccuracyの動きが分かる
  -> 学習率や勾配の状態が見える

第7回:過学習や不安定さを抑える
  -> Weight Decayでパラメータを制御する
  -> Dropoutで活性値へランダム性を入れる
  -> Normalizationで特徴の尺度を整える

たとえば、訓練lossは下がるのに検証lossが悪化するなら、過学習を疑います。勾配や活性値の分布が極端に偏るなら、初期化やNormalizationを見直します。第6回の可視化と今回の正則化・Normalizationは、実践ではセットで使うことが多いです。

正則化とNormalizationの全体像

正則化は、モデルが訓練データだけに過度に合わせすぎることを抑え、未知データでも使える表現へ寄せるための考え方です。

汎化と訓練安定性を助ける方法
├── パラメータへ直接働く方法
│   ├── L1正則化
│   └── L2正則化 / Weight Decay
├── 活性値へランダム性を入れる方法
│   └── Dropout
└── 特徴の平均・分散・尺度を整える方法
    ├── BatchNorm
    ├── LayerNorm
    ├── InstanceNorm
    └── GroupNorm

ここで注意したいのは、Normalizationは必ずしも「正則化そのもの」ではないという点です。BatchNormはmini-batch統計量の揺らぎによって正則化に似た効果を持つことがありますが、主な目的は特徴のスケールを整え、最適化を進めやすくすることです。

そのため、Weight Decay、Dropout、Normalizationは「全部入れればよい」というものではありません。モデル、データ量、batch size、optimizer、学習率、タスクの性質に合わせて選びます。

汎化、バイアス、バリアンス、ノイズ

モデルの良さは、訓練データへの当てはまりだけでは決まりません。重要なのは、まだ見ていないデータに対してどれくらい正しく予測できるかです。これを汎化性能と呼びます。

平方誤差といくつかの統計的仮定のもとでは、予測誤差を概念的に次のように分けて考えます。

Expected Error = Bias^2 + Variance + Irreducible Noise
用語 意味 典型的な状態
Bias モデルの平均的な予測が真の関係からどれだけずれるか 表現力不足、仮定のずれ
Variance 訓練データが少し変わったとき予測がどれだけ揺れるか 訓練データへの過敏な適合
Irreducible Noise データ生成そのものに含まれる説明できない揺らぎ ラベルノイズ、観測誤差、未観測要因

よくある状態を表にすると、次のようになります。

状態 訓練誤差 検証誤差 よくある対処
欠学習 高い 高い モデルを大きくする、訓練を長くする、正則化を弱める
ほどよい学習 低い 低い 設定を保ち、別seedや別splitでも確認する
過学習 とても低い 高い データ拡張、Weight Decay、Dropout、早期終了などを検討する

正則化は、多くの場合バリアンスを下げる方向に働きます。ただし、強すぎる正則化はバイアスを増やし、欠学習につながります。目標はパラメータを小さくすることではなく、検証データや実運用のデータで性能が安定することです。

L1正則化

L1正則化は、パラメータの絶対値の和をlossに足す方法です。

R_L1(w) = sum_i |w_i|
Objective = DataLoss + lambda * sum_i |w_i|

L1正則化は、重みを0に近づけるだけでなく、一部の重みをちょうど0にしやすい性質があります。そのため、線形モデルでは特徴選択のような効果を持つことがあります。

ただし、深層学習では「値が0のパラメータが増える」ことと「実際に推論が速くなる」ことは別です。GPUや通常の行列演算では、構造化された疎性や専用の実装がないと、単に0が多いだけでは速度に直結しないことがあります。

PyTorchでは、L1正則化をoptimizerの引数で直接指定するのではなく、自分でlossに足す形が分かりやすいです。

import torch


# requires_grad=Trueにして、このTensor自体に対するペナルティ(L1項)を計算できるようにする
weight = torch.tensor(
    [[1.0, -2.0, 0.5], [0.0, 0.25, -0.75]],
    requires_grad=True,
)
bias = torch.tensor([0.1, -0.2], requires_grad=True)

# 全要素の絶対値を足し合わせたものがL1正則化項
l1_penalty = weight.abs().sum() + bias.abs().sum()
print(round(l1_penalty.item(), 4))
# 4.8

weight の絶対値の合計は 1.0+2.0+0.5+0.0+0.25+0.75=4.5bias の絶対値の合計は 0.1+0.2=0.3 なので、l1_penalty は合計 4.8 になります。実際の訓練では、これをデータlossに足します。data_loss(モデル本来の予測誤差)と l1_lambda(正則化の強さを決める係数)を使うと、目的関数はイメージとして次のようになります。

objective = data_loss + l1_lambda * l1_penalty

実務では、biasやNormalization層のパラメータをL1/L2正則化から外すことも多いです。すべてのパラメータに機械的に同じ正則化をかけるのではなく、どのパラメータを制約したいのかを決めます。

L2正則化

L2正則化は、パラメータの2乗和をlossに足す方法です。

R_L2(w) = (1/2) * sum_i w_i^2
Objective = DataLoss + (lambda / 2) * ||w||_2^2

1/2 を付けるのは、微分したときに係数がきれいになるためです。

∂R_L2 / ∂w = w
∂Objective / ∂w = ∂DataLoss / ∂w + lambda * w

L1が重みを0にしやすいのに対して、L2は大きな重みを連続的に縮める性質があります。通常は、L2だけで多くの重みがちょうど0になるわけではありません。

L2正則化の強さは、lambdaだけでは決まりません。lossのreduction、学習率、optimizer、訓練step数、batch sizeによって実際の効き方が変わります。

Weight Decayの考え方

Weight Decayは、更新のたびに重みを少し小さくする考え方です。SGDでL2正則化を入れると、次のような形になります。

w_(t+1)
= w_t - eta * (grad DataLoss(w_t) + lambda * w_t)
= (1 - eta * lambda) * w_t - eta * grad DataLoss(w_t)

ここで、eta は学習率です。(1 - eta * lambda) が重みに掛かっているので、更新のたびに重みが少しずつ縮みます。これがWeight Decayという名前の由来です。

PyTorchのSGDでは、weight_decay引数で指定できます。

import torch


w = torch.tensor([2.0], requires_grad=True)
# weight_decay=0.1は、勾配へ lambda * w を足す形でWeight Decayを実現する引数
optimizer = torch.optim.SGD([w], lr=0.1, weight_decay=0.1)

loss = 0.5 * (w - 5).pow(2).sum()  # wを5に近づけたいだけの単純な二乗誤差
loss.backward()

print("grad before weight_decay:", round(w.grad.item(), 4))
# grad before weight_decay: -3.0
optimizer.step()  # 内部でWeight Decay分(lambda * w = 0.2)が勾配に足されてから更新される
print("weight after step:", round(w.item(), 4))
# weight after step: 2.28

データlossだけを見ると、w=20.5 * (w - 5)^2 の勾配は -3 です。Weight Decayはここに lambda * w = 0.1 * 2 = 0.2 を足すので、optimizerが使う勾配は -2.8 になります。学習率が 0.1 なので、更新後の重みは 2 - 0.1 * (-2.8) = 2.28 です。上のコードの出力も、この手計算と一致しています。

SGDでは、このようにL2正則化とWeight Decayの関係を比較的素直に理解できます。ただし、momentumやAdamのような自適応optimizerでは、同じとは言い切れません。

次に、少ない点だけで1次元の直線を学習する小さな実験で、Weight Decayあり・なしを比べます。元の訓練データは10点だけなので、隠れ層の大きいMLPは訓練データへ合わせ込みやすい設定です。ここでは2つのモデルを同じ初期値から始め、片方だけ weight_decay=1e-2 を入れます。

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


torch.manual_seed(1)

def gen_data(num_data=10, x_range=(-1, 1)):
    w = 1.5
    train_x = torch.linspace(*x_range, num_data).unsqueeze(1)
    train_y = w * train_x + torch.normal(0, 0.5, size=train_x.size())
    test_x = torch.linspace(*x_range, num_data).unsqueeze(1)
    test_y = w * test_x + torch.normal(0, 0.3, size=test_x.size())
    return train_x, train_y, test_x, test_y


class TinyMLP(nn.Module):
    def __init__(self, hidden=64):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(1, hidden),
            nn.ReLU(),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Linear(hidden, 1),
        )

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


def weight_l2_norm(model):
    # 全パラメータのうちweightという名前が付くものだけを対象に、L2ノルム(大きさ)を1つの数値にまとめる
    total = torch.tensor(0.0)
    for name, parameter in model.named_parameters():
        if "weight" in name:
            total = total + parameter.detach().pow(2).sum()
    return total.sqrt().item()


train_x, train_y, test_x, test_y = gen_data()  # 訓練10点・検証10点の1次元データ

net_normal = TinyMLP()
net_wdecay = copy.deepcopy(net_normal)  # 初期値をそろえてweight_decayだけを比較する

optimizer_normal = torch.optim.SGD(
    net_normal.parameters(),
    lr=0.01,
    momentum=0.9,
)
optimizer_wdecay = torch.optim.SGD(
    net_wdecay.parameters(),
    lr=0.01,
    momentum=0.9,
    weight_decay=1e-2,  # net_normal用のoptimizerとの違いはこの1行だけ
)
loss_fn = nn.MSELoss()
writer = SummaryWriter(log_dir="runs/weight_decay_demo")

for epoch in range(400):
    pred_normal = net_normal(train_x)
    pred_wdecay = net_wdecay(train_x)
    loss_normal = loss_fn(pred_normal, train_y)
    loss_wdecay = loss_fn(pred_wdecay, train_y)

    optimizer_normal.zero_grad(set_to_none=True)
    optimizer_wdecay.zero_grad(set_to_none=True)
    loss_normal.backward()
    loss_wdecay.backward()
    optimizer_normal.step()
    optimizer_wdecay.step()

    if epoch + 1 in (1, 200, 400):
        with torch.no_grad():
            test_loss_normal = loss_fn(net_normal(test_x), test_y)
            test_loss_wdecay = loss_fn(net_wdecay(test_x), test_y)
        print(
            epoch + 1,
            round(loss_normal.item(), 4),
            round(loss_wdecay.item(), 4),
            round(test_loss_normal.item(), 4),
            round(test_loss_wdecay.item(), 4),
        )
        # epoch, train loss(通常), train loss(weight_decay), test loss(通常), test loss(weight_decay)の順
        # 1 0.6151 0.6151 0.8963 0.8963
        # 200 0.0538 0.0563 0.0829 0.0835
        # 400 0.0448 0.0525 0.0894 0.0862

    if (epoch + 1) % 200 == 0:
        # weightと勾配の分布はヒストグラム画像としてTensorBoardに記録される(テキストでは表示できない)
        for name, parameter in net_normal.named_parameters():
            writer.add_histogram(f"normal/{name}", parameter.detach(), epoch + 1)
            if parameter.grad is not None:
                writer.add_histogram(f"normal_grad/{name}", parameter.grad, epoch + 1)
        for name, parameter in net_wdecay.named_parameters():
            writer.add_histogram(f"weight_decay/{name}", parameter.detach(), epoch + 1)
            if parameter.grad is not None:
                writer.add_histogram(f"weight_decay_grad/{name}", parameter.grad, epoch + 1)

print("weight_norm:", round(weight_l2_norm(net_normal), 4), round(weight_l2_norm(net_wdecay), 4))
# weight_norm: 8.0911 5.6566
writer.close()

この例では、Weight Decayありのモデルは訓練lossが少し高く、重み全体のL2ノルムは小さくなっています(weight_norm の出力を比較)。検証用のtest lossはこの小さな乱数例では僅差ですが、Weight Decayが「訓練データへ完全に合わせ込む力」を少し抑えていることは、weight_norm の差から確認できます。writer.add_histogram() で記録したweightと勾配の分布は、TensorBoardを起動して runs/weight_decay_demo を開くとヒストグラム画像として確認できます(第6回で扱ったように、tensorboard --logdir=runs で起動できます)。この記事には画像として掲載していませんが、weight_decayありのヒストグラムのほうが0付近に集まりやすい形になります。

L2と分離されたWeight Decay

通常のL2正則化では、正則化項が勾配に足されます。

g_t = grad DataLoss(w_t) + lambda * w_t
optimizerは g_t を使って更新する

Adamのようなoptimizerでは、勾配の移動平均や2乗平均を内部で持ちます。ここに lambda * w_t が混ざると、正則化項までAdamの自適応スケーリングを受けます。

AdamWでは、Weight Decayを勾配から切り離して、パラメータに直接適用します。

w_t <- (1 - eta * lambda) * w_t
その後、Adamの自適応勾配更新を行う

PyTorchでは、AdamWを明示して使うのが分かりやすいです。

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-2,
)

整理すると、次のようになります。

方法 正則化項が勾配統計に入るか よく見る場面
SGD + weight_decay L2正則化に近い CNNの基準設定
Adam + 通常のweight decay 入る Adamの標準的な挙動を使いたいとき
AdamW 入らない Transformer、転移学習、分離されたWeight Decayを明示したいとき

Adam系でWeight Decayを使うなら、まずAdamWを候補にすると説明しやすいです。

パラメータグループと実践上の分け方

実際のモデルでは、すべてのパラメータに同じWeight Decayをかけるとは限りません。よくある経験則として、次のようなパラメータはWeight Decayから外します。

  • bias
  • BatchNorm、LayerNorm、GroupNormなどの weightbias
  • 1次元パラメータ
  • 凍結しているパラメータ

これは数学的な絶対ルールではありませんが、多くのCNNやTransformerで使われる実践的な分け方です。

import torch
from torch import nn


model = nn.Sequential(
    nn.Conv2d(3, 8, kernel_size=3, padding=1, bias=False),
    nn.BatchNorm2d(8),
    nn.Flatten(),
    nn.Linear(8 * 4 * 4, 2),
)

decay_names = []
no_decay_names = []
decay = []
no_decay = []

for name, parameter in model.named_parameters():
    if not parameter.requires_grad:
        continue

    if parameter.ndim == 1 or name.endswith("bias"):
        # BatchNormのweight/bias、Linearのbiasなど1次元パラメータはWeight Decayから外す
        no_decay_names.append(name)
        no_decay.append(parameter)
    else:
        decay_names.append(name)
        decay.append(parameter)

optimizer = torch.optim.AdamW(
    [
        {"params": decay, "weight_decay": 0.01},
        {"params": no_decay, "weight_decay": 0.0},
    ],
    lr=1e-3,
)

print("decay:", decay_names)
# decay: ['0.weight', '3.weight']
print("no_decay:", no_decay_names)
# no_decay: ['1.weight', '1.bias', '3.bias']

0.weight は畳み込み層の重み、3.weight はLinear層の重みなのでWeight Decayの対象にしています。一方、BatchNormの weightbias、Linear層のbiasは1次元パラメータまたはbiasなので、Weight Decayから外しています。

パラメータグループを作るときは、次の点を確認します。

  • 同じパラメータが複数グループに入っていないか
  • 凍結したパラメータがoptimizerへ入っていないか
  • schedulerが全グループの学習率を想定通りに変えているか
  • checkpoint再開時にoptimizerのグループ構造が変わっていないか

ここまでは、Weight Decayという「パラメータの大きさを直接抑える」方法を見てきました。次に見るDropoutは、パラメータそのものではなく、順伝播の途中で活性値へランダム性を入れることで過学習を抑える方法です。仕組みが根本的に違うので、区別して理解します。

Dropoutの原理

Dropoutは、訓練中に入力Tensorの一部の要素をランダムに0へ置き換える方法です。

m_i ~ Bernoulli(1 - p)
y_i = m_i * x_i / (1 - p)
記号 意味
p 要素を0にする確率
1 - p 要素を残す確率
m_i 残すなら1、落とすなら0のマスク
1 / (1 - p) 残った値に掛けるスケール

PyTorchのDropoutは、訓練時に残った値を 1 / (1 - p) 倍します。これをinverted dropoutと呼びます。

train: y = mask * x / (1 - p)
eval : y = x

評価時には何も掛け直しません。訓練時にすでにスケールを合わせているためです。

Dropoutが0にするのは、重みではなく活性値です。パラメータが永久に削除されるわけではありません。

PyTorchのDropout API

基本のDropoutは次の形です。

nn.Dropout(p=0.5, inplace=False)

役割:訓練時に入力Tensorの要素を確率 p で0にし、残った要素を 1 / (1 - p) 倍します。評価時は恒等写像になります。

主な引数

引数 意味
p 要素を0にする確率。0.5なら約半分を落とす
inplace 入力Tensorをその場で書き換えるか

小さな例で、訓練時と評価時の違いを見ます。

import torch
from torch import nn


torch.manual_seed(0)  # マスクのサンプリングを再現できるようにする
x = torch.ones(10)
dropout = nn.Dropout(p=0.5)

dropout.train()  # 訓練モード:要素を確率的に0にし、残った値を1/(1-p)倍する
y_train = dropout(x)

dropout.eval()  # 評価モード:恒等写像になり、何も変えない
y_eval = dropout(x)

print("train:", y_train)
# train: tensor([0., 0., 2., 0., 0., 0., 2., 2., 0., 2.])
print("eval:", y_eval)
# eval: tensor([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
print("zeros:", int((y_train == 0).sum()))
# zeros: 6

p=0.5 なので、訓練時には一部の要素が0になり、残った要素は 2.0 になっています。2.0 になるのは、1 / (1 - 0.5) = 2 倍されるためです。評価時にはDropoutは何もせず、入力の 1.0 がそのまま出力されます。

同じ性質を、たくさんの入力をLinear層で足し合わせる形でも確認できます。入力がすべて1で、Linear層の重みもすべて1なら、Dropoutを使わない評価時の出力はちょうど10000になります。訓練時は約半分が0になり、残った要素が2倍されるので、期待値としては評価時と同じくらいの値になります。

import torch
from torch import nn


torch.manual_seed(1)

class DropoutLinear(nn.Module):
    def __init__(self, input_num, d_prob=0.5):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Dropout(d_prob),
            nn.Linear(input_num, 1, bias=False),
            nn.ReLU(),
        )

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


input_num = 10000
x = torch.ones(input_num, dtype=torch.float32)

net = DropoutLinear(input_num, d_prob=0.5)
net.layers[1].weight.detach().fill_(1.0)  # 重みをすべて1にして、和がそのまま出力になるようにする

net.train()
print("training:", net(x))
# training: tensor([9890.], grad_fn=<ReluBackward0>)

net.eval()
print("eval:", net(x))
# eval: tensor([10000.], grad_fn=<ReluBackward0>)

訓練時の値がぴったり10000ではないのは、maskがランダムにサンプリングされるためです。期待値としては10000に近くなりますが、1回のforwardでは残った要素数に応じて少し上下します。

model.eval() はDropoutやBatchNormなどの層の動作を評価モードへ切り替えるだけです。勾配計算そのものは止めません。

import torch
from torch import nn


layer = nn.Dropout(p=0.5)
input_tensor = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

layer.eval()  # 評価モードなのでDropoutは恒等写像になる
output = layer(input_tensor).sum()
output.backward()

print("grad:", input_tensor.grad)
# grad: tensor([1., 1., 1.])

評価モードでも、requires_grad=True のTensorに対して計算すれば勾配は作られます。純粋な推論では、model.eval() と合わせて torch.inference_mode() を使うと、勾配計算を止められます。

inplace=True は慎重に使います。残差接続、Hook、autogradが保存しているTensorとぶつかると、分かりにくいエラーや意図しない挙動につながることがあります。

Dropoutの種類と使う場所

PyTorchには、通常のDropout以外にもいくつかの派生があります。

主な入力 何を落とすか
Dropout 任意形状 要素ごと
Dropout1d (N, C) または (N, C, L) チャンネル方向
Dropout2d (N, C, H, W) 画像特徴のチャンネル方向
Dropout3d (N, C, D, H, W) 3次元特徴のチャンネル方向
AlphaDropout SELU系ネットワーク 平均と分散を保ちやすい形で落とす
FeatureAlphaDropout チャンネル特徴 チャンネル方向のAlphaDropout

CNNの浅い層では、隣り合う画素や特徴が強く相関しています。この場合、要素単位でランダムに落としても効果が弱いことがあります。Dropout2d はチャンネル単位で落とすので、画像特徴ではこちらのほうが自然な場合があります。

import torch
from torch import nn


torch.manual_seed(0)
x = torch.ones(1, 3, 2, 2)  # (N, C, H, W) = (1枚, 3チャンネル, 2x2)
dropout2d = nn.Dropout2d(p=0.5)
dropout2d.train()

y = dropout2d(x)
print(y)
# tensor([[[[0., 0.],
#           [0., 0.]],
#
#          [[0., 0.],
#           [0., 0.]],
#
#          [[2., 2.],
#           [2., 2.]]]])
print("channel sums:", y.sum(dim=(0, 2, 3)))
# channel sums: tensor([0., 0., 8.])

この実行では、3チャンネルのうち最初の2チャンネルがまとめて0になり、最後の1チャンネルだけが残りました。残ったチャンネルは 1 / (1 - p) = 2 倍されているので、2x2の4要素を足すと 8 になります。

Dropoutを実際の小さな回帰問題に入れると、訓練lossは下がりにくくなります。これはDropoutが訓練中に情報を落としているためで、正常な挙動です。次の例では、Dropoutなし(d_prob=0.0)とDropoutあり(d_prob=0.5)のMLPを同じ初期値から始めて比べます。

import copy
import torch
from torch import nn


torch.manual_seed(1)

def gen_data(num_data=10, x_range=(-1, 1)):
    w = 1.5
    train_x = torch.linspace(*x_range, num_data).unsqueeze(1)
    train_y = w * train_x + torch.normal(0, 0.5, size=train_x.size())
    test_x = torch.linspace(*x_range, num_data).unsqueeze(1)
    test_y = w * test_x + torch.normal(0, 0.3, size=test_x.size())
    return train_x, train_y, test_x, test_y


class DropoutMLP(nn.Module):
    def __init__(self, hidden=64, d_prob=0.5):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(1, hidden),
            nn.ReLU(),
            nn.Dropout(d_prob),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Dropout(d_prob),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Dropout(d_prob),
            nn.Linear(hidden, 1),
        )

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


train_x, train_y, test_x, test_y = gen_data()
net_p0 = DropoutMLP(d_prob=0.0)
net_p05 = copy.deepcopy(net_p0)  # 同じ初期値から始め、d_probだけを変える
for module in net_p05.modules():
    if isinstance(module, nn.Dropout):
        module.p = 0.5

optimizer_p0 = torch.optim.SGD(net_p0.parameters(), lr=0.01, momentum=0.9)
optimizer_p05 = torch.optim.SGD(net_p05.parameters(), lr=0.01, momentum=0.9)
loss_fn = nn.MSELoss()

for epoch in range(600):
    net_p0.train()
    net_p05.train()
    pred_p0 = net_p0(train_x)
    pred_p05 = net_p05(train_x)
    loss_p0 = loss_fn(pred_p0, train_y)
    loss_p05 = loss_fn(pred_p05, train_y)

    optimizer_p0.zero_grad(set_to_none=True)
    optimizer_p05.zero_grad(set_to_none=True)
    loss_p0.backward()
    loss_p05.backward()
    optimizer_p0.step()
    optimizer_p05.step()

    if epoch + 1 in (200, 400, 600):
        net_p0.eval()  # 検証lossを見るときはDropoutを恒等写像に戻す
        net_p05.eval()
        with torch.no_grad():
            test_p0 = loss_fn(net_p0(test_x), test_y)
            test_p05 = loss_fn(net_p05(test_x), test_y)
        print(
            epoch + 1,
            round(loss_p0.item(), 4),
            round(loss_p05.item(), 4),
            round(test_p0.item(), 4),
            round(test_p05.item(), 4),
        )
        # epoch, train loss(Dropoutなし), train loss(Dropoutあり), test loss(Dropoutなし), test loss(Dropoutあり)の順
        # 200 0.0538 0.2197 0.0829 0.2512
        # 400 0.0448 0.0789 0.0894 0.2802
        # 600 0.0337 0.1308 0.0993 0.305

この小さな乱数データでは、p=0.5 のDropoutが必ず検証lossを改善するわけではありません。むしろ情報を落としすぎて、訓練lossも検証lossも高く残っています。ここで大切なのは、Dropoutは「入れれば必ず良くなる部品」ではなく、検証曲線を見ながら確率や入れる位置を調整する正則化手段だという点です。

Dropout率を高くしすぎると、モデルが必要な情報を使えず欠学習になります。特にBatchNormとDropoutを同じCNNに入れる場合は、順番や必要性を検証データで確認します。

ここまではDropoutのように、活性値にランダムなノイズを入れる方法を見てきました。次はNormalizationです。Normalizationはランダム性を入れるのではなく、特徴の平均や分散自体を整えることで、深いネットワークでも訓練を安定させます。

Batch Normalizationの数式

Batch Normalizationは、mini-batch内の統計量を使って特徴を標準化し、その後に学習可能なスケールとシフトをかける層です。

1つの特徴チャンネルについて、訓練時は次のように計算します。

mu_B = (1 / m) * sum_i x_i
sigma_B^2 = (1 / m) * sum_i (x_i - mu_B)^2
x_hat_i = (x_i - mu_B) / sqrt(sigma_B^2 + eps)
y_i = gamma * x_hat_i + beta
記号 意味
mu_B batch内の平均
sigma_B^2 batch内の分散
eps 0除算や数値不安定を防ぐ小さな値
gamma 学習可能なスケール
beta 学習可能なシフト

標準化だけだと、平均0・分散1の表現に固定されます。そこで、gammabeta を学習可能にして、必要なら元の尺度や別の尺度へ戻せるようにしています。

BatchNormの効果は、単にInternal Covariate Shift(各層への入力分布が訓練中に残し変わっていく現象)を減らすことだけでは説明しきれません。実際には、最適化のしやすさ、勾配の流れ、尺度の再パラメータ化、mini-batch統計量の揺らぎなど、複数の要因が関係していると考えるほうが自然です。

BatchNormが深いネットワークの活性値スケールをどのように整えるかを、小さなMLPで見ます。次の例では、各Linear層の重みを標準偏差1の正規分布で初期化します。BatchNormなしでは層を進むほど活性値の標準偏差が急激に大きくなりますが、BatchNormありでは各層でスケールが整えられます。

import torch
from torch import nn


torch.manual_seed(1)

class DeepMLP(nn.Module):
    def __init__(self, width=64, layers=12, use_bn=True):
        super().__init__()
        self.linears = nn.ModuleList(
            [nn.Linear(width, width, bias=False) for _ in range(layers)]
        )
        self.bns = nn.ModuleList(
            [nn.BatchNorm1d(width) for _ in range(layers)]
        ) if use_bn else None
        self.use_bn = use_bn

        for linear in self.linears:
            nn.init.normal_(linear.weight, mean=0.0, std=1.0)  # あえて大きめの標準偏差で初期化する

    def forward(self, x):
        stds = []
        for index, linear in enumerate(self.linears):
            x = linear(x)
            if self.use_bn:
                x = self.bns[index](x)  # BatchNormで各層の分布を毎回整え直す
            x = torch.relu(x)
            stds.append(round(x.std().item(), 4))  # その層の出力スケールを記録する
        return x, stds


inputs = torch.randn(16, 64)

for use_bn in (False, True):
    model = DeepMLP(use_bn=use_bn)
    _, stds = model(inputs)
    print("use_bn=", use_bn, "first5=", stds[:5], "last=", stds[-1])
    # use_bn= False first5= [5.0433, 27.9425, 144.6259, 789.2551, 4464.2241] last= 628074304.0
    # use_bn= True first5= [0.5718, 0.5771, 0.5668, 0.5868, 0.5663] last= 0.5722

BatchNormなしでは、わずか12層でも標準偏差が非常に大きくなっています(last= の値が数億に達しています)。BatchNormありでは、各層のLinear出力を標準化してからReLUへ通すため、ReLU後の標準偏差がだいたい同じ範囲に保たれます。ただし、これは「初期化が不要になる」という意味ではありません。BatchNormがあっても、学習率、初期化、活性化関数、batch sizeは引き続き重要です。

BatchNormの訓練時と評価時

BatchNormは、train()eval() で挙動が変わります。

モード 標準化に使う統計量 running statisticsの更新
訓練モード 現在のmini-batch統計量 更新する
評価モード 保存済みのrunning mean/var 更新しない

小さな入力で確認します。

import torch
from torch import nn


bn = nn.BatchNorm1d(3, affine=True, track_running_stats=True)
with torch.no_grad():
    bn.weight.fill_(1.0)  # gamma=1、beta=0にして、標準化の結果だけを見やすくする
    bn.bias.zero_()

x = torch.tensor(
    [[1.0, 2.0, 3.0],
     [3.0, 4.0, 5.0]]
)

y = bn(x)  # 訓練モード(既定)なので、このmini-batch自身の平均・分散で正規化する

print("output:", torch.round(y, decimals=4))
# output: tensor([[-1., -1., -1.],
#         [ 1.,  1.,  1.]], grad_fn=<RoundBackward1>)
print("running_mean:", torch.round(bn.running_mean, decimals=4))
# running_mean: tensor([0.2000, 0.3000, 0.4000])
print("running_var:", torch.round(bn.running_var, decimals=4))
# running_var: tensor([1.1000, 1.1000, 1.1000])

入力の各列の平均は [2, 3, 4] です。BatchNormの既定の momentum=0.1 では、running meanは次の式で更新されます。

running_new = (1 - momentum) * running_old + momentum * batch_stat

初期値のrunning meanは0なので、0.1 * [2, 3, 4] = [0.2, 0.3, 0.4] になります。

分散には注意が必要です。訓練時の正規化ではmini-batchのbiased variance(要素数Nで割る分散)が使われます。一方、running varianceへ保存される値にはunbiased estimator(N-1で割る分散)が使われます。上の例では各列の値が [1, 3][2, 4][3, 5] なので、unbiased varianceはすべて 2.0 です。初期running varianceは1なので、0.9 * 1 + 0.1 * 2 = 1.1 になります。

BatchNormの momentum はoptimizerのmomentumとは意味が違います。BatchNormでは、新しいbatch統計量をrunning statisticsへどれくらい混ぜるかを表します。

BatchNorm1d、2d、3d

BatchNormには、入力の次元に応じて3つの代表的なクラスがあります。

nn.BatchNorm1d(num_features, eps=1e-5, momentum=0.1,
               affine=True, track_running_stats=True,
               device=None, dtype=None)

nn.BatchNorm2d(num_features, eps=1e-5, momentum=0.1,
               affine=True, track_running_stats=True,
               device=None, dtype=None)

nn.BatchNorm3d(num_features, eps=1e-5, momentum=0.1,
               affine=True, track_running_stats=True,
               device=None, dtype=None)

役割:入力のチャンネルまたは特徴ごとに、batch方向と必要な空間方向をまとめて平均・分散を計算し、標準化します。

クラス 典型的な入力 num_features 統計量を取る軸
BatchNorm1d (N, C) または (N, C, L) C N、必要ならL
BatchNorm2d (N, C, H, W) C N、H、W
BatchNorm3d (N, C, D, H, W) C N、D、H、W

「1d/2d/3d」は、入力が持つ空間・系列方向の次元に対応します。weightbias は、通常どれもチャンネル数Cぶんの1次元パラメータです。

実際に入力shapeとBatchNorm内部状態のshapeを確認すると、num_features が常にチャンネル数Cに対応することが分かります。

import torch
from torch import nn


configs = [
    ("BatchNorm1d", nn.BatchNorm1d(5, momentum=0.3), (3, 5, 1)),
    ("BatchNorm2d", nn.BatchNorm2d(6, momentum=0.3), (3, 6, 2, 2)),
    ("BatchNorm3d", nn.BatchNorm3d(4, momentum=0.3), (3, 4, 2, 2, 3)),
]

for label, batch_norm, shape in configs:
    x = torch.ones(shape)
    for channel in range(shape[1]):
        x[:, channel] *= channel + 1  # チャンネルごとに値を変え、C軸だけがnum_featuresに対応することを確かめやすくする

    _ = batch_norm(x)
    print(
        label,
        "input",
        tuple(x.shape),
        "running_mean",
        tuple(batch_norm.running_mean.shape),
        "weight",
        tuple(batch_norm.weight.shape),
    )
    # BatchNorm1d input (3, 5, 1) running_mean (5,) weight (5,)
    # BatchNorm2d input (3, 6, 2, 2) running_mean (6,) weight (6,)
    # BatchNorm3d input (3, 4, 2, 2, 3) running_mean (4,) weight (4,)

入力が3次元、4次元、5次元に変わっても、running_meanweight(C,) の形です。BatchNormの学習可能パラメータは空間位置ごとではなく、チャンネルごとに1つずつ持つ、と理解しておくと整理しやすいです。

画像分類モデルでは、畳み込み層のあとに BatchNorm2d、全結合層のあとに BatchNorm1d を置く形がよく使われます。次は、32x32画像を想定したLeNet型モデルへBatchNormを入れる例です。

元になったコードでは、紙幣画像を2クラス(1元札・100元札)に分類する RMBDataset と、data/rmb_split ディレクトリに展開済みの画像を使っていました。このデータセットは限定的に配布されたものなので公開のダウンロード先はなく、Qiita記事にも同梱できません。そのため、この記事では次の2通りの代わり方を想定しています。

  • 第3回で作った BanknoteDataset(千円札・一万円札の画像を yen_images/split/train のようなフォルダへ配置して読み込むDataset)をそのまま再利用する。
  • 手元にある任意の2クラス画像フォルダ(train/クラスAtrain/クラスB のように分けた画像群)を、第3回と同じ要領で ImageFolder や自作のDatasetに読み込ませる。

どちらの場合も、下の LeNetBNforward() の入力として (N, 3, 32, 32) 程度のRGB画像を想定しています。画像サイズが異なる場合は、transforms.Resize などで揃えるか、fc1 の入力次元(16 * 5 * 5)を実際の特徴マップサイズに合わせて計算し直してください。

import torch
from torch import nn
import torch.nn.functional as F


class LeNetBN(nn.Module):
    def __init__(self, classes):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 6, kernel_size=5)
        self.bn1 = nn.BatchNorm2d(num_features=6)

        self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
        self.bn2 = nn.BatchNorm2d(num_features=16)

        self.fc1 = nn.Linear(16 * 5 * 5, 120)  # 32x32入力を二回のconv+poolに通した後のflatten次元
        self.bn3 = nn.BatchNorm1d(num_features=120)

        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, classes)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)  # 畳み込み特徴 (N, C, H, W) にはBatchNorm2dを使う
        x = F.relu(x)
        x = F.max_pool2d(x, kernel_size=2)

        x = self.conv2(x)
        x = self.bn2(x)
        x = F.relu(x)
        x = F.max_pool2d(x, kernel_size=2)

        x = x.view(x.size(0), -1)  # (N, C, H, W) -> (N, C*H*W) にflattenする

        x = self.fc1(x)
        x = self.bn3(x)  # flatten後の全結合特徴 (N, C) にはBatchNorm1dを使う
        x = F.relu(x)

        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x


net = LeNetBN(classes=2)
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, gamma=0.1)

# train_loader、valid_loader、max_epochは、上で説明した自前のDatasetから用意するプレースホルダーです。
# このブロックは画像データセットに依存するため、単体では実行できません(構造を示す骨組みです)。
for epoch in range(max_epoch):
    net.train()
    for images, labels in train_loader:
        outputs = net(images)
        loss = criterion(outputs, labels)

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

    scheduler.step()

    net.eval()
    with torch.inference_mode():
        for images, labels in valid_loader:
            outputs = net(images)
            valid_loss = criterion(outputs, labels)

このモデルでは、畳み込み特徴 (N, C, H, W) には BatchNorm2d を使い、flatten後の全結合特徴 (N, C) には BatchNorm1d を使っています。検証時には必ず net.eval() に切り替え、BatchNormがrunning statisticsを使うようにします。

BatchNormのパラメータと状態

BatchNormには、学習されるパラメータと、学習されない状態があります。

名前 種類 意味
weight Parameter gammaaffine=True のとき学習される
bias Parameter betaaffine=True のとき学習される
running_mean Buffer 評価時に使う移動平均
running_var Buffer 評価時に使う移動分散
num_batches_tracked Buffer 何batch見たかのカウンタ

track_running_stats=False は、訓練モードと評価モードを切り替えるための引数ではありません。この設定ではrunning mean/varを持たず、訓練時も評価時も現在の入力batch統計量を使います。つまり、同じサンプルでも、一緒に入る他のサンプルによって出力が変わる可能性があります。

batch sizeがとても小さい場合、BatchNormの統計量は不安定になります。検出、セグメンテーション、大きな画像、メモリに制約がある訓練では、次の選択肢を検討します。

  • GroupNorm
  • LayerNorm
  • SyncBatchNorm
  • 事前学習済みBatchNormのrunning statisticsを固定する
  • Ghost BatchNormのように大きなbatchを小さな仮想batchへ分ける考え方

ここまではBatchNormがbatch全体を使って正規化することを見てきました。次はLayerNormです。LayerNormはbatch内の他のサンプルに依存せず、サンプルごとに正規化する点がBatchNormとの大きな違いです。

Layer Normalization

LayerNormは、各サンプルの最後のいくつかの次元に対して平均と分散を計算します。BatchNormと違い、batch内の他のサンプルには依存しません。

nn.LayerNorm(
    normalized_shape,
    eps=1e-5,
    elementwise_affine=True,
    bias=True,
    device=None,
    dtype=None,
)

役割:入力の最後の normalized_shape に対応する次元を、サンプルごとに正規化します。RNNやTransformerのような系列モデルでよく使われます。

Transformerの入力が (N, L, D) の形なら、通常は最後のD次元を正規化します。

norm = nn.LayerNorm(D)
y = norm(x)

小さな例で見ます。

import torch
from torch import nn


x = torch.tensor(
    [[1.0, 2.0, 3.0],
     [10.0, 20.0, 30.0]]
)
layer_norm = nn.LayerNorm(3, elementwise_affine=False)  # 学習可能なgamma/betaを使わず、標準化だけを見る
y = layer_norm(x)

print(torch.round(y, decimals=4))
# tensor([[-1.2247,  0.0000,  1.2247],
#         [-1.2247,  0.0000,  1.2247]])
print("row means:", torch.round(y.mean(dim=1), decimals=4))
# row means: tensor([0., 0.])
print("row vars:", torch.round(y.var(dim=1, unbiased=False), decimals=4))
# row vars: tensor([1., 1.])

2行目は [10, 20, 30] と値のスケールが大きいですが、各行ごとに正規化されるため、出力の形は1行目と同じになります。LayerNormはbatch sizeに依存しないので、batch sizeが小さい場合や系列長が変わる場合でも扱いやすいです。

normalized_shape は入力の末尾次元と一致している必要があります。画像風の (N, C, H, W) 全体を1サンプルごとに正規化したいなら、末尾の (C, H, W) を指定します。

import torch
from torch import nn


x = torch.ones(8, 6, 3, 4)

layer_norm = nn.LayerNorm([6, 3, 4])  # 入力の末尾3次元(C, H, W)=(6, 3, 4)と一致させる
y = layer_norm(x)
print("weight:", tuple(layer_norm.weight.shape), "output:", tuple(y.shape))
# weight: (6, 3, 4) output: (8, 6, 3, 4)

try:
    wrong_layer_norm = nn.LayerNorm([6, 3])  # わざと入力の末尾2次元(3, 4)と食い違う指定にする
    wrong_layer_norm(x)
except RuntimeError as error:
    print(type(error).__name__, str(error).splitlines()[0])
    # RuntimeError Given normalized_shape=[6, 3], expected input with shape [*, 6, 3], but got input of size[8, 6, 3, 4]

nn.LayerNorm([6, 3]) は、入力の最後2次元が (6, 3) であることを期待します。しかし実際の入力の最後2次元は (3, 4) なのでエラーになります。LayerNormのエラーは、まず「入力の末尾次元」と「normalized_shape」を見比べると原因を見つけやすいです。

Instance Normalization

LayerNormは1サンプル内の特徴をまとめて正規化しましたが、InstanceNormはさらに細かく、チャンネルごとに分けて正規化します。画像でよく使われるNormalizationです。入力が (N, C, H, W) の場合、各サンプル・各チャンネルごとに、HとW方向の平均・分散を計算します。

各 (n, c) について、H, W方向で mean / variance を計算する

代表的な2次元版は次の形です。

nn.InstanceNorm2d(
    num_features,
    eps=1e-5,
    momentum=0.1,
    affine=False,
    track_running_stats=False,
    device=None,
    dtype=None,
)

BatchNormと違い、affine=Falsetrack_running_stats=False が既定です。つまり、初期状態では学習可能な weight/bias を持たず、訓練時も評価時も入力自身の統計量を使います。

次の例では、各チャンネルが定数値だけを持つ2x2画像を作り、InstanceNormを通します。各チャンネル内の空間方向の分散が0なので、出力は0になります。

import torch
from torch import nn


feature_map = torch.ones(2, 2)
feature_maps = torch.stack(
    [feature_map * (channel + 1) for channel in range(3)],
    dim=0,
)  # 3チャンネル、各チャンネルは定数値(1, 2, 3)だけを持つ
feature_maps_bs = torch.stack([feature_maps for _ in range(2)], dim=0)

instance_norm = nn.InstanceNorm2d(
    num_features=3,
    affine=False,
    track_running_stats=False,
)
outputs = instance_norm(feature_maps_bs)

print("input shape:", tuple(feature_maps_bs.shape))
# input shape: (2, 3, 2, 2)
print("output[0]:", outputs[0])
# output[0]: tensor([[[0., 0.],
#          [0., 0.]],
#
#         [[0., 0.],
#          [0., 0.]],
#
#         [[0., 0.],
#          [0., 0.]]])
print("channel means:", outputs.mean(dim=(0, 2, 3)))
# channel means: tensor([0., 0., 0.])

この例では、1枚の画像の各チャンネルがすべて同じ値なので、チャンネルごとの空間平均を引くと全要素が0になります。InstanceNormが「batch全体」ではなく「各サンプル・各チャンネル」を単位にしていることが分かります。

InstanceNormは、スタイル変換や画像生成でよく使われます。各画像・各チャンネルのコントラストやスタイル統計を弱めるため、内容とスタイルを分けたい場面で有効なことがあります。

ここまではInstanceNormがサンプル単位・チャンネル単位で正規化することを見ました。次はGroupNormです。GroupNormは、InstanceNormとLayerNormの中間に位置し、チャンネルをいくつかのグループに分けて正規化します。

Group Normalization

GroupNormは、チャンネルをいくつかのグループに分け、各サンプル・各グループごとに正規化します。

nn.GroupNorm(
    num_groups,
    num_channels,
    eps=1e-5,
    affine=True,
    device=None,
    dtype=None,
    *,
    bias=True,
)

役割:batch方向には依存せず、1サンプル内でチャンネルをグループに分けて、各グループと空間方向をまとめて正規化します。

引数 意味
num_groups グループ数G
num_channels チャンネル数C。CはGで割り切れる必要がある
eps 数値安定化のための値
affine チャンネルごとのスケール・シフトを学習するか
bias affine=True のときbiasを持つか

特殊な設定として、次のように理解できます。

num_groups = num_channels: InstanceNormに近い
num_groups = 1           : チャンネルと空間をまとめるLayerNormに近い

ただし、完全に同じという意味ではありません。affineパラメータの形や既定値、入力の解釈が異なる場合があります。

GroupNormはbatch sizeに依存しないので、小さいbatchの物体検出やセグメンテーション、大きな入力画像を扱うCNNで使いやすい選択肢です。

num_channelsnum_groups で割り切れる必要があります。割り切れない組み合わせにすると、forward前の初期化時点でエラーになります。

import torch
from torch import nn


try:
    group_norm = nn.GroupNorm(num_groups=3, num_channels=4)  # 4を3で割り切れない組み合わせ
    group_norm(torch.ones(2, 4, 2, 2))
except ValueError as error:
    print(type(error).__name__, str(error))
    # ValueError num_channels (4) must be divisible by num_groups (3)

4チャンネルを3グループに均等に分けることはできないため、この設定は無効です。たとえば4チャンネルなら、num_groups=124 のように割り切れる値を選びます。

ここまでBatchNorm、LayerNorm、InstanceNorm、GroupNormを個別に見てきました。どれも「平均と分散を揃えて標準化する」点は共通ですが、どの軸をまとめるかが异なります。次は、同じ入力を使って四つを並べて比較します。

BN、LN、IN、GNの正規化軸

同じ (N, C, H, W) の画像Tensorでも、どの軸をまとめて平均・分散を取るかが違います。

方法 統計量を取る範囲 batch方向Nをまたぐか affineパラメータ running statistics
BatchNorm2d 同じCについて、N、H、W はい チャンネルごと 通常あり
LayerNorm(C,H,W) 同じサンプル内のC、H、W いいえ 要素ごと なし
InstanceNorm2d 同じサンプル・同じCのH、W いいえ 既定なし、指定可 既定なし
GroupNorm 同じサンプル内のグループC/G、H、W いいえ チャンネルごと なし

小さなTensorで、どの層も出力shapeを変えないことを確認します。

import torch
from torch import nn


x = torch.arange(2 * 4 * 2 * 2, dtype=torch.float32).reshape(2, 4, 2, 2)

modules = [
    ("BN", nn.BatchNorm2d(4, affine=False, track_running_stats=False)),
    ("IN", nn.InstanceNorm2d(4, affine=False, track_running_stats=False)),
    ("GN", nn.GroupNorm(2, 4, affine=False)),
    ("LN", nn.LayerNorm((4, 2, 2), elementwise_affine=False)),
]

for label, module in modules:
    y = module(x)
    print(
        label,
        tuple(y.shape),
        round(y.mean().item(), 6),
        round(y.var(unbiased=False).item(), 6),
    )
    # BN (2, 4, 2, 2) -0.0 1.0
    # IN (2, 4, 2, 2) -0.0 0.999992
    # GN (2, 4, 2, 2) -0.0 0.999998
    # LN (2, 4, 2, 2) 0.0 1.0

どの層もshapeは (2, 4, 2, 2) のままです。出力全体の平均はほぼ0、分散はほぼ1になっています。ただし、どの単位で平均0・分散1にしているかは異なります。BatchNormはbatchをまたぎますが、LayerNorm、InstanceNorm、GroupNormはbatchをまたぎません。

RMSNorm、SyncBatchNorm、Frozen BatchNorm

代表的なNormalizationに加えて、実践では次のような方法も見かけます。

RMSNorm

RMSNormは、平均を引かず、二乗平均平方根でスケールだけを整える方法です。Transformer系のモデルでよく使われます。

nn.RMSNorm(
    normalized_shape,
    eps=None,
    elementwise_affine=True,
    device=None,
    dtype=None,
)

LayerNormと同じものではありません。平均を引かないため計算が少し軽くなる場合がありますが、効果はモデル構造に依存します。

SyncBatchNorm

分散訓練では、GPUごとのbatch sizeが小さくなり、BatchNormの統計量が不安定になることがあります。SyncBatchNorm は複数プロセス間でbatch統計量を同期し、有効なbatch sizeを大きく見せる方法です。

sync_model = nn.SyncBatchNorm.convert_sync_batchnorm(model)

通信コストが増えるので、単一GPUや十分大きなbatchでは必ずしも必要ありません。

Frozen BatchNorm

転移学習や検出モデルでは、事前学習済みのBatchNorm統計量を固定したいことがあります。この場合は、次の2つを分けて考えます。

  • affineパラメータの requires_grad を止めるか
  • eval() にしてrunning statisticsの更新を止めるか

片方だけでは意図と違う挙動になることがあります。たとえば、requires_grad=False にしても、モジュールが訓練モードのままならrunning mean/varは更新されます。

Weight Standardization

Weight Standardizationは、活性値ではなく畳み込みカーネルの重みを標準化する方法です。GroupNormと組み合わせて使われることがありますが、BatchNormやLayerNormのような活性値Normalization層とは役割が違います。

Normalization、初期化、正則化の関係

Normalizationを入れると、初期化や正則化をまったく気にしなくてよくなるわけではありません。

重要なのは、役割を分けて考えることです。

方法 主に効く場所 目的
初期化 訓練開始時の重み 勾配や活性値のスケールを初期状態で崩さない
Weight Decay パラメータ 重みの大きさを抑える
Dropout 活性値 訓練中にランダムな欠落を入れる
Normalization 活性値や特徴 平均・分散・尺度を整える

BatchNormがあるCNNでも、Weight Decayはよく使われます。Transformerでは、LayerNormやRMSNormを使いながら、DropoutやAdamWも同時に使うことが一般的です。

層の順序にも複数の流儀があります。

典型的なCNN:
Conv -> BatchNorm -> ReLU

Pre-activation ResNet:
BatchNorm -> ReLU -> Conv

TransformerのPre-Norm:
x -> Norm -> Attention/MLP -> Dropout -> Residual Add

すべてのモデルに対して唯一正しい順序があるわけではありません。アーキテクチャの設計と検証結果に合わせます。

小さなRegularizedCNN例

最後に、Weight Decay、Dropout、BatchNorm、GroupNormを含む小さなCNNをまとめます。ここでは、実データではなく32x32のダミー画像で形だけ確認します。

import torch
from torch import nn


class RegularizedCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1, bias=False),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),

            nn.Conv2d(64, 128, 3, padding=1, bias=False),
            nn.GroupNorm(8, 128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2),
        )
        self.pool = nn.AdaptiveAvgPool2d(1)
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Dropout(p=0.3),
            nn.Linear(128, num_classes),
        )

    def forward(self, x):
        x = self.features(x)   # (N, 3, 32, 32) -> (N, 128, 8, 8)
        x = self.pool(x)       # (N, 128, 8, 8) -> (N, 128, 1, 1)
        x = self.classifier(x) # (N, 128, 1, 1) -> (N, num_classes)
        return x


model = RegularizedCNN(num_classes=10)
example = torch.randn(2, 3, 32, 32)
print("output shape:", model(example).shape)
# output shape: torch.Size([2, 10])

no_decay = []
decay = []
for name, parameter in model.named_parameters():
    if parameter.ndim == 1 or name.endswith("bias"):
        no_decay.append(parameter)
    else:
        decay.append(parameter)

optimizer = torch.optim.AdamW(
    [
        {"params": decay, "weight_decay": 1e-2},
        {"params": no_decay, "weight_decay": 0.0},
    ],
    lr=1e-3,
)

print("decay count:", len(decay), "no_decay count:", len(no_decay))
# decay count: 3 no_decay count: 5

この例では、最初のブロックにBatchNorm、次のブロックにGroupNormを入れています。これはAPIをまとめて示すための小さな例です。実際のモデルでは、同じblock内でどのNormalizationを使うかを設計として決め、むやみに混ぜすぎないほうが読みやすくなります。

decay count が3なのは、2つの畳み込み層の重みと、最後のLinear層の重みがWeight Decay対象になっているためです。no_decay count が5なのは、BatchNormの weight/bias、GroupNormの weight/bias、Linear層のbiasをWeight Decayから外しているためです。

図として整理する

バイアスとバリアンス

モデル容量が小さい                         モデル容量が大きい
Bias:    高い --------------------------> 低い
Variance:低い --------------------------> 高い
検証誤差: 高い ---- 下がる ---- 再び上がる

正則化は、モデルの有効な複雑さを抑え、過学習側へ寄りすぎるのを防ぐために使います。

L1とL2の直感

L1制約: ひし形に近い制約領域。角が座標軸に乗りやすく、0が生まれやすい。
L2制約: 円に近い制約領域。なめらかに縮めるが、0ぴったりにはなりにくい。

Weight Decayの更新

今の重み w_t
  -> (1 - eta * lambda) を掛けて少し縮める
  -> データlossの勾配で更新する
  -> 次の重み w_(t+1)

AdamWでは、この「縮める」処理をAdamの勾配統計から切り離します。

Dropoutの流れ

訓練前:  o--o--o--o
mask:    1  0  1  0
訓練時:  o--x--o--x   残った値を 1/(1-p) 倍
評価時:  o--o--o--o   そのまま通す

Dropoutのmaskは訓練時のforwardごとに変わります。

BatchNormの流れ

x
 -> batchや空間方向から mean / var を計算する
 -> x_hat = (x - mean) / sqrt(var + eps)
 -> y = gamma * x_hat + beta

訓練時は現在のbatch統計量を使い、評価時はrunning statisticsを使うのが基本です。

BN、LN、IN、GNの軸

入力: (N, C, H, W)

BN: 同じCについて、N/H/Wをまとめる
LN: 同じサンプルについて、C/H/Wをまとめる
IN: 同じサンプル・同じCについて、H/Wをまとめる
GN: 同じサンプルのチャンネルをG組に分け、各組とH/Wをまとめる

Normalizationの違いは、「どの要素が同じmean/varianceを共有するか」の違いです。

よくあるつまずきどころ

つまずき 原因 対処
訓練lossだけを見て正則化を強くする 過学習か欠学習かを検証していない 検証lossやmetricも見る
L1正則化で必ず高速化すると思う 非構造化の0は通常の行列演算では速度に直結しにくい 構造化疎性や実装まで確認する
L2とWeight Decayを常に同じものとして扱う optimizerによって挙動が変わる Adam系ではAdamWを検討する
biasやNorm層へ強いWeight Decayをかける スケール・シフトまで不必要に縮めることがある パラメータグループで分ける
同じパラメータを複数グループへ入れる 手作業の分割ミス 名前一覧やidで確認する
Dropoutが重みを消すと思う Dropoutが落とすのは活性値 forward時の出力にmaskが掛かると理解する
評価時に手動で 1-p を掛ける PyTorchはinverted dropoutを使う 評価時はDropoutが恒等写像になる
model.eval() で勾配も止まると思う evalはモジュールの挙動切替だけ 推論では torch.inference_mode() も使う
Dropout2d を要素単位Dropoutだと思う 画像向けにチャンネル単位で落とす 入力shapeと落ち方を小さなTensorで確認する
BatchNormの訓練時と評価時を混同する batch統計量とrunning statisticsの違いが曖昧 train()eval() の表で確認する
BatchNormのmomentumをoptimizer momentumと同じ意味で読む 更新式が違う running statisticsへの混ぜ具合だと考える
track_running_stats=False をmode切替だと思う running statisticsを持つかどうかの設定 eval時も入力batch統計量を使う点に注意する
小さいbatchでBatchNormが不安定になる 統計量の推定が荒い GroupNormやLayerNormを検討する
BatchNormを凍結したつもりでrunning statsが更新される requires_grad=False だけでは不十分 必要に応じて eval() も使う
LayerNormの normalized_shape が合わない 入力の最後の次元と一致していない どの末尾次元を正規化するか確認する
InstanceNormとチャンネルを同一視する InstanceNormはサンプルごと・チャンネルごとの空間正規化 (n, c) ごとにH/Wを見る
GroupNormのチャンネル数がグループ数で割り切れない num_channels % num_groups != 0 グループ数を選び直す
GroupNormがeval時にrunning statsを使うと思う GroupNormはrunning statisticsを持たない train/evalどちらも入力統計量を使う
BatchNormがあればDropoutやWeight Decayが不要だと思う 役割が違う 検証結果で必要性を判断する
Normalizationの目的を1つの説明だけに固定する 実際には複数の効果がある 最適化、尺度、勾配、統計揺らぎを分けて考える

参考リンク

おわりに

今回は、正則化、Weight Decay、Dropout、Normalizationをまとめました。

L1とL2は、パラメータに対する制約です。Weight Decayは更新式の中で重みを縮める考え方で、SGDではL2正則化と近い関係を持ちます。一方、Adam系では勾配に正則化項を混ぜる方法と、AdamWのようにWeight Decayを勾配から分離する方法を分けて考える必要があります。

Dropoutは、訓練時に活性値をランダムに落とします。PyTorchではinverted dropoutが使われるため、評価時に手動でスケールを掛ける必要はありません。model.eval() はDropoutやBatchNormの挙動を切り替えますが、勾配計算を止めるわけではない点も重要です。

Normalizationでは、どの軸の平均・分散を共有するかが本質です。BatchNormはbatch方向をまたぎますが、LayerNorm、InstanceNorm、GroupNormは基本的にbatch内の他サンプルに依存しません。小さいbatch、系列モデル、画像生成、検出・分割など、タスクの条件に応じて選ぶ層が変わります。

正則化やNormalizationは、単体で万能な設定ではありません。第6回で扱ったTensorBoardやHookによる観察と合わせて、訓練loss、検証loss、勾配、パラメータ分布、入力条件を見ながら調整していくのが実践的です。

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?