学習率スケジュールの種類と選び方

学習率(Learning Rate)はニューラルネットワークの学習において最も重要なハイパーパラメータの一つです。学習率スケジュールは、学習の進行に応じて学習率を動的に変化させることで、収束速度と最終性能を向上させます。

本記事では、主要な学習率スケジュールの理論から実装まで詳しく解説します。

本記事の内容

  • 各学習率スケジュールの数学的定義
  • Warmupの重要性
  • PyTorchでの実装と比較実験

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

学習率の役割

学習率が大きすぎる場合

  • 更新が大きすぎて発散する
  • 最適解を飛び越えて振動する
  • 学習が不安定になる

学習率が小さすぎる場合

  • 収束が非常に遅い
  • 局所最適解に陥りやすい
  • 計算資源の無駄

理想的な学習率スケジュール

  1. 学習初期: 適度に大きい学習率で高速に損失を下げる
  2. 学習中期: 徐々に学習率を下げて精度を上げる
  3. 学習終期: 小さい学習率で微調整

Step Decay

数学的定義

一定のエポック数ごとに学習率を定数倍します:

$$ \eta_t = \eta_0 \cdot \gamma^{\lfloor t / s \rfloor} $$

ここで: – $\eta_0$: 初期学習率 – $\gamma$: 減衰率(通常0.1) – $s$: ステップサイズ(エポック数) – $\lfloor \cdot \rfloor$: 床関数

特徴

利点 欠点
シンプルで実装が容易 急激な変化で学習が不安定に
理解しやすい ステップ数の選択が難しい
広く使われている 滑らかでない

Exponential Decay

数学的定義

各エポックで学習率を指数的に減衰させます:

$$ \eta_t = \eta_0 \cdot \gamma^t $$

または連続版:

$$ \eta_t = \eta_0 \cdot e^{-\lambda t} $$

特徴

Step Decayよりも滑らかに減衰しますが、後半で学習率が急速に小さくなりすぎる問題があります。

Cosine Annealing

数学的定義

学習率をコサイン曲線に従って減衰させます:

$$ \eta_t = \eta_{\min} + \frac{1}{2}(\eta_{\max} – \eta_{\min})\left(1 + \cos\left(\frac{t}{T} \pi\right)\right) $$

ここで: – $\eta_{\max}$: 最大学習率 – $\eta_{\min}$: 最小学習率 – $T$: 総エポック数 – $t$: 現在のエポック

コサイン関数の性質

$t = 0$ のとき $\cos(0) = 1$ なので $\eta_0 = \eta_{\max}$ $t = T$ のとき $\cos(\pi) = -1$ なので $\eta_T = \eta_{\min}$

Cosine Annealing with Warm Restarts

学習率を周期的にリセットする拡張版:

$$ \eta_t = \eta_{\min} + \frac{1}{2}(\eta_{\max} – \eta_{\min})\left(1 + \cos\left(\frac{T_{\text{cur}}}{T_i} \pi\right)\right) $$

$T_i$ は $i$ 番目のリスタート周期、$T_{\text{cur}}$ は現周期内の経過エポックです。

特徴

利点 欠点
滑らかな減衰 $T$(総エポック数)を事前に決める必要
学習初期と終期で緩やかに変化 ハイパーパラメータが増える
Transformerで広く使用

Linear Warmup

数学的定義

学習の最初期に、学習率を0から徐々に増加させます:

$$ \eta_t = \begin{cases} \eta_{\max} \cdot \frac{t}{T_{\text{warmup}}} & \text{if } t < T_{\text{warmup}} \\ \eta_{\max} & \text{if } t \geq T_{\text{warmup}} \end{cases} $$

なぜWarmupが必要か

  1. 大規模バッチ: バッチサイズが大きいと勾配の分散が小さくなり、学習初期に大きなステップを踏みやすい
  2. Transformerの安定化: Self-Attentionは初期化に敏感で、Warmupが安定化に寄与
  3. 適応的オプティマイザ: Adam等は初期のモメンタム推定が不安定

Warmup + Cosine Annealing

実践では、WarmupとCosine Annealingを組み合わせることが多いです:

$$ \eta_t = \begin{cases} \eta_{\max} \cdot \frac{t}{T_{\text{warmup}}} & \text{if } t < T_{\text{warmup}} \\ \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min})\left(1 + \cos\left(\frac{t - T_{\text{warmup}}}{T - T_{\text{warmup}}} \pi\right)\right) & \text{otherwise} \end{cases} $$

OneCycleLR

数学的定義

OneCycleLR(One Cycle Policy)は、学習全体を1つのサイクルとして、学習率を増加→減少させます。

フェーズ1(増加): 最初の30-50%のイテレーションで学習率を $\eta_{\min}$ から $\eta_{\max}$ に増加

フェーズ2(減少): 残りのイテレーションで $\eta_{\max}$ から $\eta_{\min}$ に減少

さらに、最後の数%で学習率を非常に小さくする「annihilation」フェーズを含むことも。

特徴

利点 欠点
Super-convergence(超高速収束)を実現 最大学習率の選択が重要
学習の全ステップ数を活用 総イテレーション数を事前に決める必要
多くのタスクで高性能

ReduceLROnPlateau

アルゴリズム

検証損失が改善しなくなったら学習率を下げる適応的なスケジュール:

  1. 各エポックで検証損失を監視
  2. patience エポック連続で改善がなければ
  3. 学習率を factor 倍に減少

$$ \eta_{\text{new}} = \eta_{\text{old}} \times \text{factor} $$

特徴

利点 欠点
適応的で手動調整が少ない 検証セットが必要
過学習を検知して対応 反応が遅れる可能性
汎用性が高い

Pythonでの実装

各スケジュールの実装と可視化

import numpy as np
import matplotlib.pyplot as plt

def step_decay(epoch, initial_lr=0.1, drop_rate=0.1, epochs_drop=30):
    """Step Decay"""
    return initial_lr * (drop_rate ** (epoch // epochs_drop))

def exponential_decay(epoch, initial_lr=0.1, decay_rate=0.95):
    """Exponential Decay"""
    return initial_lr * (decay_rate ** epoch)

def cosine_annealing(epoch, total_epochs=100, lr_max=0.1, lr_min=0.0):
    """Cosine Annealing"""
    return lr_min + 0.5 * (lr_max - lr_min) * (1 + np.cos(np.pi * epoch / total_epochs))

def warmup_cosine(epoch, total_epochs=100, warmup_epochs=10, lr_max=0.1, lr_min=0.0):
    """Warmup + Cosine Annealing"""
    if epoch < warmup_epochs:
        return lr_max * epoch / warmup_epochs
    else:
        progress = (epoch - warmup_epochs) / (total_epochs - warmup_epochs)
        return lr_min + 0.5 * (lr_max - lr_min) * (1 + np.cos(np.pi * progress))

def one_cycle(epoch, total_epochs=100, lr_max=0.1, lr_min=0.001, pct_start=0.3):
    """OneCycleLR"""
    if epoch < total_epochs * pct_start:
        # 増加フェーズ
        progress = epoch / (total_epochs * pct_start)
        return lr_min + (lr_max - lr_min) * progress
    else:
        # 減少フェーズ
        progress = (epoch - total_epochs * pct_start) / (total_epochs * (1 - pct_start))
        return lr_max - (lr_max - lr_min) * progress

def cosine_warm_restarts(epoch, total_epochs=100, lr_max=0.1, lr_min=0.0, T_0=20, T_mult=2):
    """Cosine Annealing with Warm Restarts"""
    # 現在の周期を特定
    T_cur = epoch
    T_i = T_0

    while T_cur >= T_i:
        T_cur -= T_i
        T_i *= T_mult
        if T_i > total_epochs:
            T_i = total_epochs - epoch
            break

    return lr_min + 0.5 * (lr_max - lr_min) * (1 + np.cos(np.pi * T_cur / T_i))

# 可視化
epochs = np.arange(100)

plt.figure(figsize=(14, 10))

schedules = {
    'Step Decay': [step_decay(e) for e in epochs],
    'Exponential Decay': [exponential_decay(e) for e in epochs],
    'Cosine Annealing': [cosine_annealing(e) for e in epochs],
    'Warmup + Cosine': [warmup_cosine(e) for e in epochs],
    'OneCycleLR': [one_cycle(e) for e in epochs],
    'Cosine Warm Restarts': [cosine_warm_restarts(e) for e in epochs],
}

for idx, (name, lr_values) in enumerate(schedules.items(), 1):
    plt.subplot(2, 3, idx)
    plt.plot(epochs, lr_values, linewidth=2)
    plt.xlabel('Epoch')
    plt.ylabel('Learning Rate')
    plt.title(name)
    plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

PyTorchでの使用例

import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import (
    StepLR, ExponentialLR, CosineAnnealingLR,
    CosineAnnealingWarmRestarts, OneCycleLR, ReduceLROnPlateau
)
import matplotlib.pyplot as plt

# ダミーモデル
model = nn.Linear(10, 2)
optimizer = optim.SGD(model.parameters(), lr=0.1)

# 各スケジューラの学習率推移を取得
def get_lr_history(scheduler_fn, n_epochs=100):
    """スケジューラの学習率履歴を取得"""
    model = nn.Linear(10, 2)
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    scheduler = scheduler_fn(optimizer)

    lr_history = []
    for epoch in range(n_epochs):
        lr_history.append(optimizer.param_groups[0]['lr'])
        # ダミーの学習ステップ
        optimizer.step()
        scheduler.step()

    return lr_history

# 各スケジューラを作成
schedulers = {
    'StepLR': lambda opt: StepLR(opt, step_size=30, gamma=0.1),
    'ExponentialLR': lambda opt: ExponentialLR(opt, gamma=0.95),
    'CosineAnnealingLR': lambda opt: CosineAnnealingLR(opt, T_max=100, eta_min=0.001),
    'CosineAnnealingWarmRestarts': lambda opt: CosineAnnealingWarmRestarts(opt, T_0=20, T_mult=2),
}

# 可視化
plt.figure(figsize=(12, 8))

for idx, (name, scheduler_fn) in enumerate(schedulers.items(), 1):
    lr_history = get_lr_history(scheduler_fn)
    plt.subplot(2, 2, idx)
    plt.plot(lr_history, linewidth=2)
    plt.xlabel('Epoch')
    plt.ylabel('Learning Rate')
    plt.title(f'PyTorch {name}')
    plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

Warmup付きスケジューラの自作

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

class WarmupCosineScheduler:
    """Warmup + Cosine Annealing スケジューラ"""

    def __init__(self, optimizer, warmup_epochs, total_epochs, lr_max, lr_min=0.0):
        self.optimizer = optimizer
        self.warmup_epochs = warmup_epochs
        self.total_epochs = total_epochs
        self.lr_max = lr_max
        self.lr_min = lr_min
        self.current_epoch = 0

    def step(self):
        if self.current_epoch < self.warmup_epochs:
            # Warmupフェーズ
            lr = self.lr_max * self.current_epoch / self.warmup_epochs
        else:
            # Cosine Annealingフェーズ
            progress = (self.current_epoch - self.warmup_epochs) / (self.total_epochs - self.warmup_epochs)
            lr = self.lr_min + 0.5 * (self.lr_max - self.lr_min) * (1 + np.cos(np.pi * progress))

        for param_group in self.optimizer.param_groups:
            param_group['lr'] = lr

        self.current_epoch += 1

    def get_lr(self):
        return self.optimizer.param_groups[0]['lr']

# 使用例
model = nn.Linear(10, 2)
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = WarmupCosineScheduler(
    optimizer,
    warmup_epochs=10,
    total_epochs=100,
    lr_max=0.001,
    lr_min=1e-6
)

lr_history = []
for epoch in range(100):
    lr_history.append(scheduler.get_lr())
    scheduler.step()

plt.figure(figsize=(10, 5))
plt.plot(lr_history, linewidth=2)
plt.axvline(x=10, color='red', linestyle='--', label='End of Warmup')
plt.xlabel('Epoch')
plt.ylabel('Learning Rate')
plt.title('Warmup + Cosine Annealing')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

実験:各スケジュールの学習曲線比較

import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import StepLR, CosineAnnealingLR, OneCycleLR
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt

def create_dataset(n_samples=5000, input_dim=20, num_classes=10):
    np.random.seed(42)
    X = np.random.randn(n_samples, input_dim).astype(np.float32)
    W = np.random.randn(input_dim, num_classes).astype(np.float32)
    logits = X @ W + np.random.randn(n_samples, num_classes).astype(np.float32) * 0.3
    y = np.argmax(logits, axis=1)
    split = int(0.8 * n_samples)
    return (X[:split], y[:split]), (X[split:], y[split:])

class MLP(nn.Module):
    def __init__(self, input_dim=20, hidden_dim=128, num_classes=10):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, num_classes)
        )

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

def train_with_scheduler(scheduler_name, train_loader, test_loader, n_epochs=100):
    torch.manual_seed(42)
    model = MLP()
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9)

    # スケジューラの選択
    if scheduler_name == 'No Schedule':
        scheduler = None
    elif scheduler_name == 'StepLR':
        scheduler = StepLR(optimizer, step_size=30, gamma=0.1)
    elif scheduler_name == 'CosineAnnealingLR':
        scheduler = CosineAnnealingLR(optimizer, T_max=n_epochs, eta_min=0.001)
    elif scheduler_name == 'OneCycleLR':
        scheduler = OneCycleLR(
            optimizer,
            max_lr=0.1,
            epochs=n_epochs,
            steps_per_epoch=len(train_loader)
        )
    else:
        raise ValueError(f"Unknown scheduler: {scheduler_name}")

    train_losses = []
    test_accuracies = []
    lr_history = []

    for epoch in range(n_epochs):
        # Training
        model.train()
        epoch_loss = 0
        for X_batch, y_batch in train_loader:
            optimizer.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()

            if scheduler_name == 'OneCycleLR':
                scheduler.step()

            epoch_loss += loss.item()

        train_losses.append(epoch_loss / len(train_loader))
        lr_history.append(optimizer.param_groups[0]['lr'])

        # スケジューラの更新(OneCycleLR以外)
        if scheduler is not None and scheduler_name != 'OneCycleLR':
            scheduler.step()

        # Evaluation
        model.eval()
        correct = 0
        total = 0
        with torch.no_grad():
            for X_batch, y_batch in test_loader:
                outputs = model(X_batch)
                _, predicted = torch.max(outputs, 1)
                total += y_batch.size(0)
                correct += (predicted == y_batch).sum().item()
        test_accuracies.append(correct / total)

    return train_losses, test_accuracies, lr_history

# データ準備
(X_train, y_train), (X_test, y_test) = create_dataset()
train_dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))
test_dataset = TensorDataset(torch.tensor(X_test), torch.tensor(y_test))
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

# 各スケジューラで学習
schedulers = ['No Schedule', 'StepLR', 'CosineAnnealingLR', 'OneCycleLR']
results = {}

for name in schedulers:
    print(f"Training with {name}...")
    train_losses, test_accs, lr_history = train_with_scheduler(name, train_loader, test_loader)
    results[name] = {
        'train_loss': train_losses,
        'test_acc': test_accs,
        'lr_history': lr_history
    }
    print(f"  Final Test Accuracy: {test_accs[-1]:.4f}")

# 可視化
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

colors = {'No Schedule': 'gray', 'StepLR': 'blue', 'CosineAnnealingLR': 'green', 'OneCycleLR': 'red'}

# 学習率
ax1 = axes[0]
for name in schedulers:
    ax1.plot(results[name]['lr_history'], label=name, color=colors[name], linewidth=1.5)
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Learning Rate')
ax1.set_title('Learning Rate Schedule')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.set_yscale('log')

# 訓練損失
ax2 = axes[1]
for name in schedulers:
    ax2.plot(results[name]['train_loss'], label=name, color=colors[name], linewidth=1.5)
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Training Loss')
ax2.set_title('Training Loss')
ax2.legend()
ax2.grid(True, alpha=0.3)

# テスト精度
ax3 = axes[2]
for name in schedulers:
    ax3.plot(results[name]['test_acc'], label=name, color=colors[name], linewidth=1.5)
ax3.set_xlabel('Epoch')
ax3.set_ylabel('Test Accuracy')
ax3.set_title('Test Accuracy')
ax3.legend()
ax3.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

学習率スケジュールの選び方

一般的な推奨

タスク 推奨スケジュール
画像分類(CNN) Cosine Annealing or Step Decay
Transformer(NLP) Warmup + Cosine Annealing
大規模事前学習 Warmup + Linear Decay
ファインチューニング Cosine Annealing (小さいlr)
素早い実験 OneCycleLR

パラメータ選択の指針

パラメータ 指針
初期学習率 LR Range Testで探索
Warmup期間 全体の5-10%
最小学習率 初期の1/100〜1/1000

まとめ

本記事では、学習率スケジュールについて解説しました。

  • Step Decayはシンプルだが、急激な変化で不安定になりやすい
  • Cosine Annealingは滑らかで、Transformerで広く使用される
  • Warmupは学習初期の安定化に重要、特に大規模モデルで必須
  • OneCycleLRは超高速収束を実現し、多くのタスクで有効

次のステップとして、以下の記事も参考にしてください。