ディープラーニング基礎

ディープラーニングは神秘的なブラックテクノロジーではありません。本質的には数学的な関数フィッティング器です。大量の入出力データを与えると、それらの間の複雑な関係を自動的に見つけ出します。

例を挙げると、100万枚の猫の画像を与え、各画像に「猫」というラベルを対応させます。ディープラーニングはどのようなピクセルの組み合わせが猫のように見えるかを自動的に見つけ出します。

もう一つの例:10億文の人間の言語を与え、各文の次の単語をターゲットにします。ディープラーニングは、与えられた文脈に対して次の単語が最も可能性が高いものを自動的に学習します。

ディープラーニングの核となる利点は、データ量が増えるにつれて性能が向上し続けることです。これは従来の機械学習にはできないことです。

この章では、ゼロからディープラーニングの各構成要素を一つずつ分解していきます:

  • ニューラルネットワークの数学基礎
  • 順伝播の計算プロセス
  • 活性化関数の役割
  • 損失関数の設計
  • 逆伝播の原理
  • オプティマイザの選択
  • 正則化技術
  • 正規化手法
  • 学習率スケジューリング

最後に、PyTorchを使って完全な多層パーセプトロン(MLP)を実装し、すべての知識ポイントを結び付けます。

この章ではいくつかの数学が出てきますが、心配はいりません。すべての公式を直感的に説明するので、暗記する必要はありません。重要なのは「なぜこのように設計するのか」を理解することで、「どう計算するか」ではありません。


ニューラルネットワークの数学基礎

ディープラーニングは3つの数学分野の上に成り立っています:線形代数、微積分、確率論です。

数学の専門家になる必要はありませんが、いくつかの核となる概念を理解する必要があります。

線形代数:行列乗算

ニューラルネットワークの最も基本的な演算は行列乗算です。

なぜ行列を使うのでしょうか?それは「複数の入力が複数のニューロンによる変換を経る」というプロセスを簡潔に表現できるからです。

まず直感的な例を見てみましょう:

実例

# ============================================
# 行列乗算の直感的理解
# ============================================

import numpy as np

# 入力は3次元ベクトルと仮定:[身長, 体重, 年齢]
# 単位はそれぞれ:cm、kg、歳
x = np.array([175, 70, 25])  # 入力ベクトル、形状 (3,)
print(f"入力 x: {x}")
print(f"入力形状: {x.shape}")

# 重み行列 W:2つのニューロン、各ニューロンは3つの入力を受け取る
# 形状は (出力次元, 入力次元) = (2, 3)
W = np.array([
    [0.1, 0.2, 0.3],   # 1つ目のニューロンの重み
    [0.4, 0.5, 0.6],   # 2つ目のニューロンの重み
])
print(f"\n重み行列 W:\n{W}")
print(f"重み形状: {W.shape}")

# バイアス b:各ニューロンに1つのバイアス
b = np.array([0.1, 0.2])  # 形状 (2,)
print(f"\n"バイアス b: {b}")
print(f"バイアス形状: {b.shape}")

# 行列乗算:y = W · x + b
# 注意:numpy の @ 演算子は行列乗算を表す
y = W @ x + b
print(f"\n"出力 y: {y}")
print(f"出力形状: {y.shape}")

# 手動計算で検証
y1 = 0.1 * 175 + 0.2 * 70 + 0.3 * 25 + 0.1  # 1つ目のニューロン
y2 = 0.4 * 175 + 0.5 * 70 + 0.6 * 25 + 0.2  # 2つ目のニューロン
print(f"\n"手動計算による検証: [{y1}, {y2}]")

行列乗算の幾何学的な意味は「線形変換」です。ベクトル空間を回転・拡大縮小・伸縮できます。

しかし、線形変換だけでは不十分です。

すべての層が単なる行列乗算であれば、ネットワークがどれだけ深くても、全体は単一層のネットワークと等価です。複数の線形変換の合成は依然として線形変換だからです。

これが活性化関数が必要な理由です——非線形性を導入するためです。

微積分:導関数と連鎖律

ニューラルネットワークのトレーニングの核心は「勾配降下法」です。損失を最小にするパラメータの方向を見つけます。

勾配とは「多変数関数の導関数」です。各パラメータが少し変化したときに、損失がどれだけ変化するかを教えてくれます。

まず簡単な例を見てみましょう:

実例

# ============================================
# 導関数の直感的理解
# ============================================

def f(x):
    """簡単な関数:f(x) = x²"""
    return x ** 2

def numerical_derivative(f, x, h=1e-6):
    """数値微分:微小な増分 h を使って導関数を近似計算
導関数の定義:f'(x) = lim(h→0) [f(x+h) - f(x)] / h
    """

    return (f(x + h) - f(x)) / h

# x=3 での導関数を計算
x = 3.0
df_dx = numerical_derivative(f, x)
print(f"f({x}) = {f(x)}")
print(f"f'({x}) ≈ {df_dx}")
print(f"解析解(正確な値):2 * {x} = {2 * x}")

# 導関数の意味を理解する:
# 導関数 6 は:x=3 で、x が 1 増えると、f(x) は約 6 増えることを示す
x_new = x + 0.01
f_new = f(x_new)
print(f"\n"x は {x} から {x_new} に増加")
print(f"f(x) は {f(x)} から {f_new} に変化")
print(f"実際の増加:{f_new - f(x)}")
print(f"導関数による予測:{df_dx * 0.01}")

多変数関数の場合、各変数について偏導関数を計算し、それらを1つのベクトルにまとめます。これが勾配です。

連鎖律は逆伝播の数学的基礎です。合成関数の導関数を「層ごとに分解」することを可能にします。

実例

# ============================================
# 連鎖律の直感的理解
# ============================================

# 合成関数を考える:y = f(g(x))
# ここで:g(x) = x², f(z) = z³
# すると:y = (x²)³ = x⁶

def g(x):
    return x ** 2

def f(z):
    return z ** 3

def y(x):
    return f(g(x))

# 導関数を手動計算(連鎖律):
# dy/dx = df/dz * dz/dx = 3*z² * 2*x = 3*(x²)² * 2*x = 6*x⁵

x = 2.0
z = g(x)  # z = 4
dy_dx_chain = 3 * (z ** 2) * 2 * x  # 連鎖律による計算
print(f"連鎖律による計算:dy/dx = {dy_dx_chain}")

# 数値微分による検証
def numerical_derivative_y(x, h=1e-6):
    return (y(x + h) - y(x)) / h

dy_dx_numerical = numerical_derivative_y(x)
print(f"数値微分による検証:dy/dx ≈ {dy_dx_numerical}")

# 解析解:6*x^5 = 6*32 = 192
print(f"解析解:6 * {x}^5 = {6 * (x ** 5)}")

連鎖律の核心的な考え方は:複雑な関数を単純な関数に分解し、それぞれ微分してから掛け合わせる。

逆伝播は、ニューラルネットワークにおける連鎖律の応用です。出力から始めて、層ごとに勾配を逆向きに伝播させます。

確率論:条件付き確率

分類問題では、ニューラルネットワークの出力はしばしば「確率分布」です。入力が与えられたとき、各カテゴリの確率がどれだけかを表します。

条件付き確率 P(Y|X) は「X が起こったと分かっている場合に、Y が起こる確率」を表します。

:.2%}"

:.2%}"

実例

# ============================================
# ニューラルネットワークで確率分布を出力する
# ============================================

import numpy as np

def softmax(x):
    """Softmax 関数:任意の実数を確率分布に変換する
出力のすべての値の合計が1になり、各値は [0, 1] の範囲になる
    """

    # 最大値を引いて数値オーバーフローを防ぐ
    exp_x = np.exp(x - np.max(x))
    return exp_x / np.sum(exp_x)

# ニューラルネットワークの最終層が3つの「スコア」(logits)を出力したと仮定する
# それぞれ「猫」「犬」「鳥」に対応
logits = np.array([2.0, 1.0, 0.5])
print(f"ニューラルネットワーク出力(logits):{logits}")

# Softmax を使って確率に変換する
probabilities = softmax(logits)
print(f"確率分布:{probabilities}")
print(f"確率の合計:{np.sum(probabilities)}")

# 解釈:
# P(猫|入力画像) ≈ 0.67
# P(犬|入力画像) ≈ 0.24
# P(鳥|入力画像) ≈ 0.09
print("\nクラス確率:)
print(f" 猫: {probabilities)
print(f" 犬: {probabilities)
print(f" 鳥: {probabilities)

3つの数学的基礎の核心ポイント:

数学分野中核概念深層学習における用途
線形代数行列積、ベクトル、テンソルネットワーク構造を表現し、順伝播を効率的に計算する
微積分導関数、連鎖律、勾配逆伝播、パラメータ更新
確率論条件付き確率、確率分布不確実性のモデリング、損失関数の設計

順伝播(フォワードプロパゲーション)

順伝播とは、入力データがニューラルネットワークを「流れ」、入力層から隠れ層、出力層へと進む計算プロセスです。

計算グラフ

ニューラルネットワークを計算グラフとして表現すると、順伝播と逆伝播のロジックがより明確になります。

計算グラフはノード(演算)とエッジ(データの流れ)で構成されます。

単純な2層ニューラルネットワークの計算グラフ:

実例

# ============================================
# 計算グラフを使って順伝播を理解する
# ============================================

import numpy as np

def relu(x):
    """ReLU 活性化関数:max(0, x)"""
    return np.maximum(0, x)

# 単純な2層ニューラルネットワーク
# 入力層 (2) → 隠れ層 (3) → 出力層 (2)

# 入力:x = [x1, x2]
x = np.array([1.0, 2.0])
print(f"入力 x: {x}")

# 第1層:入力 → 隠れ層
W1 = np.array([
    [0.1, 0.2],  # 第1の隠れニューロン
    [0.3, 0.4],  # 第2の隠れニューロン
    [0.5, 0.6],  # 第3の隠れニューロン
])
b1 = np.array([0.1, 0.2, 0.3])

# 第2層:隠れ層 → 出力層
W2 = np.array([
    [0.1, 0.2, 0.3],  # 第1の出力ニューロン
    [0.4, 0.5, 0.6],  # 第2の出力ニューロン
])
b2 = np.array([0.1, 0.2])

# 順伝播の計算グラフ
# ステップ1: 隠れ層の線形変換
z1 = W1 @ x + b1
print(f"\n"隠れ層の線形変換 z1: {z1}")

# ステップ2: 隠れ層の活性化関数
a1 = relu(z1)
print(f"隠れ層の活性化後 a1: {a1}")

# ステップ3: 出力層の線形変換
z2 = W2 @ a1 + b2
print(f"出力層の線形変換 z2: {z2}")

# ステップ4: 出力層の活性化(分類問題の場合は通常 Softmax を使う)
a2 = softmax(z2)  # 前に定義した softmax 関数を使用
print(f"出力層の活性化後 a2: {a2}")

計算グラフの利点は、各ステップが明確で、逆伝播の際は逆順に勾配を計算するだけでよいことです。

行列乗算から見たニューラルネットワーク

行列積の観点から見ると、ニューラルネットワークは「線形変換 + 非線形活性化」の連続です。

各層は次のように抽象化できます:

aᵢ = activation(Wᵢ · aᵢ₋₁ + bᵢ)

ここで a₀ は入力 x そのものです。

この式は簡潔ですが強力で、単純なロジスティック回帰から複雑な Transformer まで、ほぼすべてのニューラルネットワーク構造を表現できます。

行列積の次元の一致に注意してください。入力ベクトルが n 次元で、次の層に m 個のニューロンがある場合、重み行列 W の形状は必ず m×n になります。これにより W·x の結果は m 次元ベクトルになります。

活性化関数の役割

前に述べたように、活性化関数がないと、深層ネットワークは単層ネットワークと同等です。

簡単な例でこれを証明します:

実例

# ============================================
# なぜ活性化関数が必要か
# ============================================

import numpy as np

# 2層ネットワークがあるが、どちらにも活性化関数がないと仮定する

# 入力
x = np.array([1.0, 2.0])

# 第1層の重みとバイアス
W1 = np.array([[0.1, 0.2], [0.3, 0.4]])
b1 = np.array([0.1, 0.2])

# 第2層の重みとバイアス
W2 = np.array([[0.5, 0.6], [0.7, 0.8]])
b2 = np.array([0.3, 0.4])

# 2層をそれぞれ計算
z1 = W1 @ x + b1
z2 = W2 @ z1 + b2
print(f"2層をそれぞれ計算した結果: {z2}")

# 1つの層にまとめて計算
# 数学的に証明できる:z2 = (W2·W1)·x + (W2·b1 + b2)
W_combined = W2 @ W1
b_combined = W2 @ b1 + b2
z_combined = W_combined @ x + b_combined
print(f"1つの層にまとめて計算した結果: {z_combined}")

# 結果は完全に同じ!
print(f"\n"2つの結果は等しい: {np.allclose(z2, z_combined)}")
print("結論:活性化関数がない場合、深層ネットワーク = 単層ネットワーク")

活性化関数こそが、深層ネットワークに意味をもたらす重要な構成要素です。非線形性を導入することで、ネットワークが複雑なパターンを学習できるようにします。


活性化関数の詳細

活性化関数は、ニューロンの出力が入力にどのように反応するかを決定します。

優れた活性化関数はいくつかの条件を満たす必要があります:

  • 非線形性:必須
  • 微分可能:勾配を計算できるようにするため
  • 計算が簡単:順伝播と逆伝播の両方が高速
  • 飽和しない:勾配が消失したり爆発したりしない

最もよく使われるいくつかの活性化関数を見てみましょう。

Sigmoid:飽和と勾配消失

Sigmoid は最も初期に使われた活性化関数の1つです。任意の実数を (0, 1) の範囲に圧縮します。

公式:σ(x) = 1 / (1 + e⁻ˣ)

実例

# ============================================
# Sigmoid 活性化関数
# ============================================

import numpy as np
import matplotlib.pyplot as plt

def sigmoid(x):
    """Sigmoid 関数"""
    return 1 / (1 + np.exp(-x))

def sigmoid_derivative(x):
    """Sigmoid の導関数:σ'(x) = σ(x) * (1 - σ(x))"""
    s = sigmoid(x)
    return s * (1 - s)

# いくつかの値をテストする
x_values = [-10, -5, -2, 0, 2, 5, 10]
print("x    | sigmoid(x) | sigmoid'(x)")
print("-" * 40)
for x in x_values:
    s = sigmoid(x)
    d = sigmoid_derivative(x)
    print(f"{x:4} | {s:10.6f} | {d:12.6f}")

# 観察:x が非常に大きいか非常に小さい場合、導関数は 0 に近づく
# これが「勾配消失」問題です
x_big = 10.0
print(f"\n"x = {x_big} のとき:")
print(f" sigmoid(x) = {sigmoid(x_big):.10f} (ほぼ 1)")
print(f" sigmoid'(x) = {sigmoid_derivative(x_big):.10f} (ほぼ 0)")
print("勾配が消えた!逆伝播のときに勾配が伝わらない。")

Sigmoid の問題は明らかです:

  • |x| > 6 のとき、勾配はほぼ 0 になり、勾配消失が起こります
  • 出力が 0 を中心としていないため、最適化に影響します
  • 指数計算は比較的遅い

これらの問題があるため、Sigmoid は現代の深層学習では隠れ層であまり使われなくなりました。

ReLU とその変種

ReLU(Rectified Linear Unit)は、現在最もよく使われている活性化関数です。

公式:ReLU(x) = max(0, x)

単純ですが非常に効果的です。

実例

# ============================================
# ReLU とその変種
# ============================================

import numpy as np

def relu(x):
    """ReLU:max(0, x)"""
    return np.maximum(0, x)

def relu_derivative(x):
    """ReLU の導関数:x > 0 のとき 1、それ以外は 0"""
    return (x > 0).astype(float)

def leaky_relu(x, alpha=0.01):
    """Leaky ReLU:x > 0 のとき x、それ以外は alpha*x"""
    return np.where(x > 0, x, alpha * x)

def gelu(x):
    """GELU:Gaussian Error Linear Unit
これは Transformer でよく使われる活性化関数です
近似式:0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
    """

    cdf = 0.5 * (1.0 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * np.power(x, 3))))
    return x * cdf

# テスト
x_values = [-5, -2, -1, 0, 1, 2, 5]
print("x    | ReLU | Leaky | GELU")
print("-" * 45)
for x in x_values:
    r = relu(x)
    lr = leaky_relu(x)
    g = gelu(x)
    print(f"{x:4} | {r:4.2f} | {lr:5.2f} | {g:5.2f}")

# ReLU の利点:
print("\n"ReLU の利点:")
print("1. 計算が簡単:max 操作だけでよい")
print(2. 飽としない(正の区間):勾配は常に 1)
print("3. スパース性:一部のニューロンの出力が 0 になり、モデルのロバスト性が向上する")

# ReLU の問題:「死んだReLU」
print("\n"ReLU の問題:死んだReLU")
print("あるニューロンの入力が常に負の場合:")
print(- 出力は常に0)
print(- 勾配は常に0)
print(- パラメータは決して更新されない)
print(- このニューロンは「死」んでしまう)

いくつかの一般的な活性化関数の比較:

活性化関数公式長所短所適用シーン
Sigmoid1/(1+e⁻ˣ)出力は(0,1)で、確率として解釈可能勾配消失、非ゼロ中心、計算が遅い二分類の出力層(隠れ層にはほとんど使われない)
ReLUmax(0, x)計算が速い、飽和しない、スパース性死亡ReLU、出力が非ゼロ中心ほとんどの隠れ層(デフォルトの選択)
Leaky ReLUmax(αx, x)死亡ReLU問題を解決αを手動で選択する必要があるReLUが機能しない場合の代替
GELUx·Φ(x)滑らかで、Transformerで効果が良い計算がやや複雑Transformer、BERT、GPTなど
Swish/SwiGLUx·σ(βx)深層ネットワークでReLUより優れている計算が複雑PaLM、LLaMAなどの大規模モデル

選択の原則

活性化関数の選択に関するアドバイス:

  • 隠れ層:優先的にReLUを使用。シンプルで効果的
  • 死亡ReLUに遭遇した場合:Leaky ReLUまたはGELUを試す
  • Transformer:GELUまたはSwiGLUが標準的な選択
  • 二分類の出力層:Sigmoid(出力確率)
  • 多分類の出力層:Softmax(出力確率分布)
  • 回帰の出力層:活性化関数を追加しない(任意の実数を出力)

活性化関数の選択にあまりこだわりすぎないこと。通常はReLUで十分です。ReLUがボトルネックだと確認できたときに、他のものに変えても遅くはありません。


損失関数

損失関数は「モデルの予測」と「正解」の間のギャップを測定します。

トレーニングの目標は、このギャップをできるだけ小さくすることです。

クロスエントロピー損失(分類)

分類問題で最もよく使われる損失関数は交差エントロピー損失(Cross-Entropy Loss)です。

その直感的な意味は、「モデルが予測する確率分布と真の分布の間にどれだけの差があるか」です。

実例

# ============================================
# 交差エントロピー損失
# ============================================

import numpy as np

def cross_entropy_loss(probs, target_one_hot):
    """交差エントロピー損失
probs: モデルが予測する確率分布、形状 (batch_size, num_classes)
target_one_hot: 正解ラベルのone-hotエンコーディング、形状は上と同じ
    """

    # epsilonを加えて log(0) を防ぐ
    epsilon = 1e-12
    probs = np.clip(probs, epsilon, 1.0 - epsilon)
    # 交差エントロピー = -sum(target * log(pred))
    return -np.sum(target_one_hot * np.log(probs)) / len(probs)

def softmax(x):
    """Softmax関数"""
    exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
    return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

# 例:三分類問題、カテゴリは「猫」、「犬」、「鳥」

# 正解ラベルが「猫」(インデックス0)だと仮定、one-hotエンコーディング
target = np.array([[1, 0, 0]])  # 正解は第0クラス

# シーン1:モデルが自信を持って「猫」と予測
logits1 = np.array([[5.0, 0.5, 0.1]])  # 猫のスコアが最高
probs1 = softmax(logits1)
loss1 = cross_entropy_loss(probs1, target)
print("シーン1:モデルが自信を持って正しく予測")
print(f" 確率分布: {probs1)
print(f" 損失: {loss1:.6f} (非常に小さい)")

# シーン2:モデルが不確か
logits2 = np.array([[1.0, 0.9, 0.8]])  # 3つのカテゴリのスコアがほぼ同じ
probs2 = softmax(logits2)
loss2 = cross_entropy_loss(probs2, target)
print("\nシーン2:モデルが不確か")
print(f" 確率分布: {probs2)
print(f" 損失: {loss2:.6f} (中程度)")

# シーン3:モデルが誤って予測
logits3 = np.array([[0.1, 5.0, 0.5]])  # 犬のスコアが最高
probs3 = softmax(logits3)
loss3 = cross_entropy_loss(probs3, target)
print("\nシーン3:モデルが誤って予測")
print(f" 確率分布: {probs3)
print(f" 損失: {loss3:.6f} (非常に大きい)")

交差エントロピー損失の簡略版:ラベルがone-hotエンコーディングではなくカテゴリインデックスの場合、より効率的な計算方法を使えます:

loss = -log(probs[target_class])

これが、PyTorch で CrossEntropyLoss がカテゴリインデックスをラベルとして直接受け付ける理由です。

MSE損失(回帰)

回帰問題(連続値を予測)で最もよく使われる損失は平均二乗誤差(Mean Squared Error,MSE)です。

公式:MSE = mean((pred - target)²)

実例

# ============================================
# MSE損失
# ============================================

import numpy as np

def mse_loss(pred, target):
    """平均二乗誤差損失"""
    return np.mean((pred - target) ** 2)

def mae_loss(pred, target):
    """平均絶対誤差損失"""
    return np.mean(np.abs(pred - target))

# 例:住宅価格の予測(単位:万元)

# 実際の住宅価格
target = np.array([100, 200, 150])

# シーン1:予測が正確
pred1 = np.array([102, 195, 148])
mse1 = mse_loss(pred1, target)
mae1 = mae_loss(pred1, target)
print("シーン1:予測が正確")
print(f" 予測: {pred1}")
print(f" 実際: {target}")
print(f"  MSE: {mse1:.2f}")
print(f"  MAE: {mae1:.2f}")

# シーン2:予測に大きな誤差がある
pred2 = np.array([102, 280, 148])  # 2番目の住宅は80万高く予測された
mse2 = mse_loss(pred2, target)
mae2 = mae_loss(pred2, target)
print("\nシーン2:大きな誤差がある")
print(f" 予測: {pred2}")
print(f" 実際: {target}")
print(f" MSE: {mse2:.2f} (大きな誤差で大幅に増幅される)")
print(f"  MAE: {mae2:.2f}")

# MSE vs MAE
print("\nMSE vs MAE:")
print("- MSEは大きな誤差により敏感(二乗のため)")
print("- MAEは外れ値に対してよりロバスト")
print(- 一般的にはMSEをより多く使う)

言語モデルの次語予測損失

大規模言語モデル(GPTなど)のトレーニング目標はシンプル:与えられた文脈から次の単語を予測する。

これは本質的に多分類問題です——語彙内の各単語が1つのカテゴリです。

実例

# ============================================
# 言語モデルの損失関数
# ============================================

import numpy as np

def language_model_loss(logits, targets, vocab_size):
    """言語モデル損失
本質的には各位置での交差エントロピー損失
    """

    batch_size, seq_len, _ = logits.shape
    total_loss = 0.0

    # シーケンス内の各位置で損失を計算
    for i in range(seq_len):
        # この位置の予測スコア
        step_logits = logits[:, i, :]
        # 確率に変換
        step_probs = softmax(step_logits)
        # ターゲット単語のインデックス
        step_target = targets[:, i]
        # ターゲット単語の確率だけを取得
        for b in range(batch_size):
            target_idx = step_target[b]
            target_prob = step_probs[b, target_idx]
            # 損失 = -log(p)
            total_loss += -np.log(target_prob + 1e-12)

    return total_loss / (batch_size * seq_len)

# 簡単な例
vocab_size = 10000  # 語彙サイズが1万だと仮定
batch_size = 2
seq_len = 3

# モデル出力:(batch_size, seq_len, vocab_size)
logits = np.random.randn(batch_size, seq_len, vocab_size)

# 正解ターゲット:各位置で予測すべき単語インデックス
targets = np.array([
    [123, 456, 789],   # 最初のシーケンスのターゲット単語
    [234, 567, 890],   # 2番目のシーケンスのターゲット単語
])

loss = language_model_loss(logits, targets, vocab_size)
print(f"言語モデル損失: {loss:.4f}")
print("\nこの損失の意味:")
print("- シーケンス内の各位置について")
print("- 次の単語を予測")
print("- 全位置の平均交差エントロピーを計算")

一般的な損失関数のまとめ:

タスクの種類損失関数出力層の活性化関数ラベル形式
二分類Binary Cross-EntropySigmoid0 または 1
多分類Cross-EntropySoftmaxカテゴリインデックス
マルチラベル分類Binary Cross-EntropySigmoid複数の0/1
回帰MSE / MAE无連続値
言語モデルCross-EntropySoftmax単語インデックス

逆伝播(バックプロパゲーション)

バックプロパゲーション(Backpropagation)はニューラルネットワークを訓練するための中核アルゴリズムです。

その目標は:損失関数の各パラメータに対する勾配を計算し、勾配降下法でパラメータを更新することです。

連鎖律による導出

簡単な例を使って、バックプロパゲーションを段階的に導出します。

実例

# ============================================
# バックプロパゲーションを手動で実装
# ============================================

import numpy as np

def relu(x):
    return np.maximum(0, x)

def relu_derivative(x):
    return (x > 0).astype(float)

def mse_loss(pred, target):
    return np.mean((pred - target) ** 2)

# 最も単純なニューラルネットワーク:1層の線形変換
# y = w * x + b
# 損失 = (y - target)^2

# 入力とターゲット
x = np.array(2.0)
target = np.array(7.0)

# パラメータを初期化
w = np.array(1.0)
b = np.array(0.0)

print(f"初期パラメータ: w = {w}, b = {b}")
print(f"ターゲット: {target}")

# ============================================
# ステップ1:順伝播
# ============================================
y = w * x + b
loss = (y - target) ** 2
print(f"\n順伝播:")
print(f"  y = {y}")
print(f"  loss = {loss}")

# ============================================
# ステップ2:バックプロパゲーション(勾配を手動計算)
# ============================================
# 計算が必要:d_loss/d_w, d_loss/d_b

# 連鎖律を使って段階的に進める:
# loss = (y - target)^2
# d_loss/d_y = 2 * (y - target)
d_loss_d_y = 2 * (y - target)

# y = w * x + b
# d_y/d_w = x
# d_y/d_b = 1
d_y_d_w = x
d_y_d_b = 1.0

# 連鎖律:
# d_loss/d_w = d_loss/d_y * d_y/d_w
# d_loss/d_b = d_loss/d_y * d_y/d_b
d_loss_d_w = d_loss_d_y * d_y_d_w
d_loss_d_b = d_loss_d_y * d_y_d_b

print(f"\nバックプロパゲーション:")
print(f"  d_loss/d_y = {d_loss_d_y}")
print(f"  d_loss/d_w = {d_loss_d_w}")
print(f"  d_loss/d_b = {d_loss_d_b}")

# ============================================
# ステップ3:勾配降下法でパラメータを更新
# ============================================
learning_rate = 0.1
w_new = w - learning_rate * d_loss_d_w
b_new = b - learning_rate * d_loss_d_b

print(f"\nパラメータ更新:")
print(f"  w: {w} -> {w_new}")
print(f"  b: {b} -> {b_new}")

# 検証:新しいパラメータで順伝播
y_new = w_new * x + b_new
loss_new = (y_new - target) ** 2
print(f"\n検証:")
print(f" 旧予測: {y}, 旧損失: {loss}")
print(f" 新予測: {y_new}, 新損失: {loss_new}")
print(f" 損失が減少しました!")

この例は単純ですが、逆伝播のすべての核心的な考え方が含まれています:

  1. 順伝播:すべての中間変数を計算する
  2. 逆伝播:出力から始めて、連鎖律を使って層ごとに勾配を計算する
  3. パラメータ更新:勾配降下法を使ってパラメータを更新する

計算グラフにおける勾配の流れ

多層ニューラルネットワークでは、勾配は計算グラフ内を逆方向に流れます。

各層の勾配は、後方の層から伝わってきた勾配に依存します。

実例

# ============================================
# 2層ネットワークの逆伝播
# ============================================

import numpy as np

def relu(x):
    return np.maximum(0, x)

def relu_derivative(x):
    return (x > 0).astype(float)

# 2層ネットワーク
# 入力 (2) → 隠れ層 (3) → 出力 (1)

# データ
x = np.array([1.0, 2.0])  # 入力
target = np.array([5.0])  # 目標出力

# パラメータ初期化
W1 = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])  # (3, 2)
b1 = np.array([0.1, 0.2, 0.3])  # (3,)
W2 = np.array([[0.7, 0.8, 0.9]])  # (1, 3)
b2 = np.array([0.4])  # (1,)

print("初期パラメータが設定されました")

# ============================================
# 順伝播
# ============================================
print("\n=== 順伝播 ===")

# 第1層
z1 = W1 @ x + b1  # (3,)
a1 = relu(z1)     # (3,)
print(f"z1 = {z1}")
print(f"a1 = {a1}")

# 第2層
z2 = W2 @ a1 + b2  # (1,)
a2 = z2  # 回帰タスクなので、出力層には活性化関数を付けない
print(f"z2 = {z2}")
print(f"a2 = {a2}")

# 損失
loss = np.mean((a2 - target) ** 2)
print(f"loss = {loss}")

# ============================================
# 逆伝播
# ============================================
print("\n=== 逆伝播 ===")

# 出力層の勾配
d_loss_d_a2 = 2 * (a2 - target)  # (1,)
d_a2_d_z2 = 1.0  # 活性化関数なし
d_loss_d_z2 = d_loss_d_a2 * d_a2_d_z2  # (1,)
print(f"d_loss_d_z2 = {d_loss_d_z2}")

# 第2層のパラメータ勾配
d_z2_d_W2 = a1  # (3,) です。なぜなら z2 = W2@a1 + b2 だから
d_z2_d_b2 = 1.0  # (1,)
d_z2_d_a1 = W2[0]  # (3,)

# パラメータ勾配を計算
d_loss_d_W2 = d_loss_d_z2.reshape(-1, 1) @ d_z2_d_W2.reshape(1, -1)  # (1, 3)
d_loss_d_b2 = d_loss_d_z2 * d_z2_d_b2  # (1,)
print(f"d_loss_d_W2 = {d_loss_d_W2}")
print(f"d_loss_d_b2 = {d_loss_d_b2}")

# 隠れ層に伝える勾配
d_loss_d_a1 = d_loss_d_z2 @ d_z2_d_a1  # (3,)
print(f"d_loss_d_a1 = {d_loss_d_a1}")

# 隠れ層の勾配
d_a1_d_z1 = relu_derivative(z1)  # (3,)
d_loss_d_z1 = d_loss_d_a1 * d_a1_d_z1  # (3,)
print(f"d_loss_d_z1 = {d_loss_d_z1}")

# 第1層のパラメータ勾配
d_z1_d_W1 = x  # (2,)
d_z1_d_b1 = 1.0  # (3,)

# パラメータ勾配を計算
d_loss_d_W1 = d_loss_d_z1.reshape(-1, 1) @ d_z1_d_W1.reshape(1, -1)  # (3, 2)
d_loss_d_b1 = d_loss_d_z1 * d_z1_d_b1  # (3,)
print(f"d_loss_d_W1 = {d_loss_d_W1}")
print(f"d_loss_d_b1 = {d_loss_d_b1}")

# ============================================
# パラメータ更新
# ============================================
print("\n=== パラメータ更新 ===")
learning_rate = 0.01

W1_new = W1 - learning_rate * d_loss_d_W1
b1_new = b1 - learning_rate * d_loss_d_b1
W2_new = W2 - learning_rate * d_loss_d_W2
b2_new = b2 - learning_rate * d_loss_d_b2

print("パラメータが更新されました")

この例は逆伝播の完全な流れを示しています。

核心となる規則は次のとおりです:

  • 線形層 y = Wx + b の場合:dL/dW = (dL/dy)·xᵀ、dL/db = dL/dy
  • 活性化関数 y = f(z) の場合:dL/dz = dL/dy ⊙ f'(z)(⊙ は要素ごとの乗算)
  • 勾配は後ろから前へ伝播し、各層は連鎖律を使って勾配を「蓄積」します

良い知らせは、現代の深層学習フレームワーク(PyTorch、TensorFlow など)が自動的に逆伝播を計算してくれることです。順伝播を定義するだけで、フレームワークが自動微分(autograd)を使ってすべての勾配計算を行います。


勾配降下オプティマイザ

勾配を計算したら、オプティマイザを使ってパラメータを更新する必要があります。

最も単純なオプティマイザは確率的勾配降下法(SGD)ですが、他にも多くの高度なオプティマイザがあります。

SGD:確率的勾配降下法

公式:θ = θ - η·∇L(θ)

ここで η は学習率です。

実例

# ============================================
# SGD オプティマイザ
# ============================================

import numpy as np

def f(x):
    """最小化したい関数:f(x) = x²"""
    return x ** 2

def f_grad(x):
    """勾配:f'(x) = 2x"""
    return 2 * x

# SGD 最適化
x = 10.0  # 初期値
learning_rate = 0.1  # 学習率
steps = 20

print(f"初期 x = {x}, f(x) = {f(x)}")
print("-" * 40)

for step in range(steps):
    grad = f_grad(x)
    x = x - learning_rate * grad  # SGD 更新
    print(f"Step {step+1}: x = {x:.6f}, f(x) = {f(x):.6f}")

print("-" * 40)
print(f"最終 x = {x:.6f}, f(x) = {f(x):.6f}")
print("理論上の最適解:x = 0, f(x) = 0")

SGD は単純ですが欠点があります:

  • すべてのパラメータに同じ学習率を使用する
  • 収束が遅い可能性がある
  • 局所最適解や鞍点に陥りやすい
  • ノイズに敏感

Momentum:モメンタムによる加速

Momentum は「速度」の概念を導入します——勾配更新に慣性を持たせます。

公式:

v = β·v + (1-β)·∇L(θ)
θ = θ - η·v

β は通常 0.9 に設定します。

実例

# ============================================
# Momentum オプティマイザ
# ============================================

import numpy as np

def f(x):
    """目的関数"""
    return x ** 2

def f_grad(x):
    """勾配"""
    return 2 * x

def sgd_optimize(x_init, learning_rate, steps):
    """純粋な SGD"""
    x = x_init
    history = [x]
    for _ in range(steps):
        grad = f_grad(x)
        x = x - learning_rate * grad
        history.append(x)
    return np.array(history)

def momentum_optimize(x_init, learning_rate, beta, steps):
    """Momentum"""
    x = x_init
    v = 0.0  # 初期速度
    history = [x]
    for _ in range(steps):
        grad = f_grad(x)
        v = beta * v + (1 - beta) * grad
        x = x - learning_rate * v
        history.append(x)
    return np.array(history)

# 比較
x_init = 10.0
learning_rate = 0.1
beta = 0.9
steps = 20

sgd_history = sgd_optimize(x_init, learning_rate, steps)
momentum_history = momentum_optimize(x_init, learning_rate, beta, steps)

print("Step |    SGD    |  Momentum")
print("-" * 35)
for i in range(steps + 1):
    print(f"{i:4} | {sgd_history[i]:9.6f} | {momentum_history[i]:9.6f}")

print("\n観察:Momentum の方が収束が速い!")
print(過去の勾配を蓄積しているため、慣性があるからです。)

Adam:適応的学習率

Adam(Adaptive Moment Estimation)は、現在最もよく使われているオプティマイザの1つです。

Momentum(1次モーメント)と RMSProp(2次モーメント)の考え方を組み合わせ、各パラメータに適応的な学習率を維持します。

公式:

m = β₁·m + (1-β₁)·∇L(θ)
v = β₂·v + (1-β₂)·(∇L(θ))²
m̂ = m / (1-β₁ᵗ)
v̂ = v / (1-β₂ᵗ)
θ = θ - η·m̂ / (√v̂ + ε)

ハイパーパラメータのデフォルト値:β₁=0.9、β₂=0.999、ε=1e-8、η=1e-3。

実例

# ============================================
# Adam オプティマイザ
# ============================================

import numpy as np

def adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps):
    """Adam 最適化"""
    x = x_init
    m = 0.0  # 1次モーメント
    v = 0.0  # 2次モーメント
    history = [x]

    for t in range(1, steps + 1):
        grad = 2 * x  # f(x) = x² の勾配
        # 1次と2次のモーメントを更新
        m = beta1 * m + (1 - beta1) * grad
        v = beta2 * v + (1 - beta2) * (grad ** 2)
        # バイアス補正
        m_hat = m / (1 - beta1 ** t)
        v_hat = v / (1 - beta2 ** t)
        # パラメータ更新
        x = x - learning_rate * m_hat / (np.sqrt(v_hat) + epsilon)
        history.append(x)

    return np.array(history)

# パラメータ
x_init = 10.0
learning_rate = 0.5
beta1 = 0.9
beta2 = 0.999
epsilon = 1e-8
steps = 20

# 最適化
history = adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps)

print("Adam 最適化プロセス:")
print("Step |     x     |   f(x)")
print("-" * 35)
for i in range(steps + 1):
    print(f"{i:4} | {history[i]:9.6f} | {history[i]**2:9.6f}")

AdamW:重み減衰の修正

AdamW は Adam の改良版です——重み減衰(Weight Decay)を勾配から分離します。

これは正則化に役立ちます。

オプティマイザの比較:

オプティマイザ公式長所短所適用シーン
SGDθ = θ - η·∇L単純、安定収束が遅い、学習率に敏感データ量が多い、ハイパーパラメータ調整の経験が豊富
SGD + Momentumv = βv + (1-β)∇L
θ = θ - ηv
収束を加速、振動を軽減β の調整が必要一般的に純粋な SGD より良い
Adam適応的学習率収束が速い、ハイパーパラメータ調整が少なくて済む汎化性能は SGD に劣る可能性があるほとんどのシーン(デフォルトの選択肢)
AdamWAdam + 重み減衰より良い正則化重み減衰係数の調整が必要Transformer、大規模モデル

オプティマイザの選択に関するアドバイス:まず Adam を試し、学習率を 1e-3 に設定します(3e-4 の方がより安全な選択です)。効果が満足できない場合は、ハイパーパラメータを調整するか、他のオプティマイザに切り替えてみてください。


正則化技術

正則化の目的は過学習を防ぐことです——未見のデータでもモデルが良好に機能するようにします。

L1/L2 正則化

L1 正則化と L2 正則化は、最も基本的な正則化手法です。

それらは損失関数にパラメータに対するペナルティを追加します:

L_total = L + λ·L_reg

L1 正則化(Lasso):L_reg = |w|

L2 正則化(Ridge):L_reg = w²

実例

# ============================================
# L1/L2 正則化
# ============================================

import numpy as np

def l1_regularization(weights, lambda_l1):
    """L1 正則化:正則化項と勾配を返す"""
    reg = lambda_l1 * np.sum(np.abs(weights))
    grad = lambda_l1 * np.sign(weights)
    return reg, grad

def l2_regularization(weights, lambda_l2):
    """L2 正則化:正則化項と勾配を返す"""
    reg = 0.5 * lambda_l2 * np.sum(weights ** 2)
    grad = lambda_l2 * weights
    return reg, grad

# 例
weights = np.array([1.0, -2.0, 3.0, 0.5])
print(f"重み: {weights}")

lambda_l1 = 0.1
lambda_l2 = 0.1

l1_reg, l1_grad = l1_regularization(weights, lambda_l1)
print(f"\n"L1 正則化項: {l1_reg:.4f}")
print(f"L1 勾配: {l1_grad}")

l2_reg, l2_grad = l2_regularization(weights, lambda_l2)
print(f"\n"L2 正則化項: {l2_reg:.4f}")
print(f"L2 勾配: {l2_grad}")

print("\nL1 vs L2:")
print("- L1:スパースな解(多くのパラメータが 0 になる)を生み出す傾向があり、特徴選択に使える")
print("- L2:すべてのパラメータを小さくする傾向があるが、0 にはならない")
print("- 深層学習では L2 の方がよく使われる(または AdamW の重み減衰を使う)")

Dropout

Dropout は単純だが効果のな正規化技術です:トレーニング時にニューロンの一部をランダムに「オフ」にします。

これによりモデルはよりロバストな特徴を学習せざるを得なくなります——特定のニューロンに依存できなくなるからです。

実例

# ============================================
# Dropout
# ============================================

import numpy as np

def dropout_forward(x, dropout_rate, training=True):
    """Dropout 順伝播"""
    if not training:
        # テスト時は dropout せず、そのまま返す
        return x

    # mask を生成:ランダムに一部のニューロンを 0 にする
    mask = (np.random.rand(*x.shape) > dropout_rate).astype(float)
    # スケーリング:テスト時はスケーリング不要なので、トレーニング時は (1-dropout_rate) で割る
    scale = 1.0 / (1.0 - dropout_rate)
    # dropout を適用
    out = x * mask * scale

    return out, mask

# テスト
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dropout_rate = 0.4

print(f"入力 x: {x}")
print(f"Dropout 率: {dropout_rate}")

# 複数回実行して、ランダム性を観察
print("\n"複数回の Dropout 結果:")
for i in range(3):
    out, mask = dropout_forward(x, dropout_rate, training=True)
    print(f" 第 {i+1} 回: mask={mask}, out={out}")

# テストモード
out_test = dropout_forward(x, dropout_rate, training=False)
print(f"\nテストモード(Dropoutなし): {out_test}")

データ拡張

データ拡張は最も効果的な正則化手法の1つです——既存のデータを変換して、より多くの訓練サンプルを作り出します。

画像の場合、よく使われるデータ拡張には、反転、回転、スケーリング、クロップ、色のジッターなどがあります。

テキストの場合、よく使われるデータ拡張には、同義語置換、ランダム挿入、ランダム削除、バック翻訳などがあります。

正則化手法のまとめ:

手法原理利点欠点
L2 正則化大きい重みにペナルティを課すシンプルで安定効果は限定的
Dropoutニューロンをランダムにオフにするシンプルで効果的訓練が遅くなる
データ拡張訓練データの多様性を高める効果が顕著ドメイン知識が必要
早期停止(Early Stopping)検証セットが改善しなければ停止シンプルで効果的検証セットの監視が必要
バッチ正規化層の入力を正規化する訓練の高速化、正則化計算コストが増加

バッチ正規化 vs レイヤー正規化

正規化手法は訓練を加速し、最適化をより安定させられます。

内部共変量シフト

深いネットワークを訓練するとき、各層の入力分布は前の層のパラメータの変化に応じて変化します。これが「内部共変量シフト」(Internal Covariate Shift)です。

これにより、各層が新しい分布に絶えず適応する必要があるため、訓練が遅くなります。

正規化手法の目標は、各層の入力分布を安定に保つことです。

バッチ正規化(Batch Normalization)

バッチ正規化(BatchNorm)は batch 次元で正規化を行います。

式:

μ = 1/N · sum(xᵢ)
σ² = 1/N · sum((xᵢ - μ)²)
x̂ᵢ = (xᵢ - μ) / √(σ² + ε)
yᵢ = γ·x̂ᵢ + β

ここで γ と β は学習可能なパラメータです。

実例

# ============================================
# バッチ正規化
# ============================================

import numpy as np

def batch_norm(x, gamma, beta, epsilon=1e-5, training=True, running_mean=None, running_var=None, momentum=0.1):
    """バッチ正規化
x: 入力、形状 (batch_size, features)
gamma: スケールパラメータ
beta: シフトパラメータ
    """

    if training:
        # 訓練モード:現在の batch の統計量を使う
        batch_mean = np.mean(x, axis=0)
        batch_var = np.var(x, axis=0)

        # running 統計量を更新(テスト時用)
        if running_mean is not None and running_var is not None:
            running_mean[:] = momentum * running_mean + (1 - momentum) * batch_mean
            running_var[:] = momentum * running_var + (1 - momentum) * batch_var

        # 正規化
        x_normalized = (x - batch_mean) / np.sqrt(batch_var + epsilon)
    else:
        # テストモード:running 統計量を使う
        x_normalized = (x - running_mean) / np.sqrt(running_var + epsilon)

    # スケールとシフト
    out = gamma * x_normalized + beta
    return out

# テスト
batch_size = 4
features = 3

# 1つの batch のデータ
x = np.array([
    [1.0, 2.0, 3.0],
    [2.0, 3.0, 4.0],
    [3.0, 4.0, 5.0],
    [4.0, 5.0, 6.0],
])
print(f"入力 x:\n{x}")

# 初期パラメータ
gamma = np.ones(features)  # 初期はスケールしない
beta = np.zeros(features)  # 初期はシフトしない
print(f"\ngamma: {gamma}")
print(f"beta: {beta}")

# バッチ正規化を適用
out = batch_norm(x, gamma, beta, training=True)
print(f"\nバッチ正規化後:\n{out}")

# 検証:各特徴量の平均は 0 に近く、分散は 1 に近いはず
print(f"\n正規化後の平均: {np.mean(out, axis=0)}")
print(f"正規化後の分散: {np.var(out, axis=0)}")

バッチ正規化の利点:

  • 訓練を加速
  • より大きな学習率を許容
  • 初期化への敏感さを軽減
  • わずかな正則化効果がある

バッチ正規化の欠点:

  • batch サイズに依存する——batch が小さすぎると効果が良くない
  • 訓練時とテスト時で動作が異なる
  • 系列モデル(RNN、Transformer など)には不向き

レイヤー正規化(Layer Normalization)

レイヤー正規化(LayerNorm)は batch 次元ではなく特徴量次元で正規化を行います。

これにより batch サイズに依存せず、系列モデルに非常に適しています。

実例

# ============================================
# レイヤー正規化
# ============================================

import numpy as np

def layer_norm(x, gamma, beta, epsilon=1e-5):
    """レイヤー正規化
x: 入力、形状 (batch_size, features) または (batch_size, seq_len, features)
    """

    # 最後の次元(特徴量次元)で平均と分散を計算
    mean = np.mean(x, axis=-1, keepdims=True)
    var = np.var(x, axis=-1, keepdims=True)

    # 正規化
    x_normalized = (x - mean) / np.sqrt(var + epsilon)

    # スケールとシフト
    out = gamma * x_normalized + beta
    return out

# テスト
batch_size = 2
seq_len = 3
features = 4

# Transformer の入力を模擬:(batch_size, seq_len, features)
x = np.array([
    [[1.0, 2.0, 3.0, 4.0],
     [2.0, 3.0, 4.0, 5.0],
     [3.0, 4.0, 5.0, 6.0]],
    [[4.0, 5.0, 6.0, 7.0],
     [5.0, 6.0, 7.0, 8.0],
     [6.0, 7.0, 8.0, 9.0]],
])
print(f"入力 x の形状: {x.shape}")

# 初期パラメータ
gamma = np.ones(features)
beta = np.zeros(features)

# レイヤー正規化を適用
out = layer_norm(x, gamma, beta)
print(f"レイヤー正規化後の形状: {out.shape}")

# 検証:各位置の平均が 0 に近く、分散が 1 に近い
mean_check = np.mean(out, axis=-1)
var_check = np.var(out, axis=-1)
print(f"\n各位置の平均:\n{mean_check}")
print(f"各位置の分散:\n{var_check}")

2つの正規化手法の比較:

手法正規化次元batch 依存適用シーン代表的なモデル
BatchNormbatch 次元であるCNN、固定 batch サイズResNet、VGG
LayerNorm特徴量次元否Transformer、RNN、可変長シーケンスGPT、BERT、LLaMA

Transformer や大規模言語モデルにとって、LayerNorm は標準的な選択です。各位置で独立に統計量を計算し、batch サイズに依存せず、running 統計量を維持する必要もありません。


学習率スケジューリング

学習率は最も重要なハイパーパラメータの1つです——大きすぎると収束せず、小さすぎると収束が遅すぎます。

学習率スケジューリング(Learning Rate Scheduling)とは、訓練中に学習率を動的に調整することです。

ウォームアップ(Warmup)

訓練初期は、まず小さな学習率で「ウォームアップ」してモデルを安定させ、その後目標の学習率まで引き上げます。

これは Transformer にとって特に重要です。

コサインアニーリング

コサインアニーリング(Cosine Annealing)は、学習率をコサイン関数の形に沿って徐々に低下させます。

実例

# ============================================
# 学習率スケジューリング
# ============================================

import numpy as np

def warmup_schedule(step, warmup_steps, base_lr):
    """線形ウォームアップスケジュール"""
    if step < warmup_steps:
        return base_lr * (step + 1) / warmup_steps
    return base_lr

def cosine_annealing_schedule(step, total_steps, base_lr, min_lr=0.0):
    """コサインアニーリングスケジュール"""
    return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * step / total_steps))

def warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr=0.0):
    """ウォームアップ + コサインアニーリング"""
    if step < warmup_steps:
        return base_lr * (step + 1) / warmup_steps
    return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps)))

# パラメータ
total_steps = 100
warmup_steps = 10
base_lr = 1e-3
min_lr = 1e-5

print("異なる学習率スケジュール:")
print("Step |  Warmup  |  Cosine  |  Warmup+Cosine")
print("-" * 55)

for step in range(0, total_steps + 1, 10):
    lr_warmup = warmup_schedule(step, warmup_steps, base_lr)
    lr_cosine = cosine_annealing_schedule(step, total_steps, base_lr, min_lr)
    lr_combined = warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr)
    print(f"{step:4} | {lr_warmup:.6f} | {lr_cosine:.6f} | {lr_combined:.6f}")

学習率がトレーニングに与える影響

学習率の影響:

  • 大きすぎる:訓練が不安定で、発散する可能性がある
  • 小さすぎる:収束が遅く、局所最適に留まる可能性がある
  • 適切:速く安定して収束する

よく使われる戦略は「学習率レンジテスト」(Learning Rate Finder)です。最初に小さい学習率から大きい学習率に向かって数ステップ訓練し、どの学習率で損失が最も速く下がるかを確認します。

学習率スケジューリング手法のまとめ:

スケジュール方法特徴適用シーン
固定学習率最もシンプル小規模モデル、単純なタスク
Step Decay数ステップごとに下げるCNN など
Cosine Annealing滑らかに下降ほとんどのシーン
Warmup + Cosine最初に上げてから下げるTransformer、大規模モデル(推奨)
Reduce on Plateau検証セットが改善しなければ下げる検証セットの監視が必要

実践:PyTorchでMLPをゼロから実装する

ここまで学んだすべての知識を統合し、PyTorch で完全な多層パーセプトロン(MLP)を実装し、単純な分類タスクで訓練します。

実例

# ============================================
# PyTorch で MLP の完全な訓練フローを実装
# ============================================

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np

# ============================================
# 1. データを準備
# ============================================

class SimpleDataset(Dataset):
    """単純な二値分類データセット"""

    def __init__(self, n_samples=1000, n_features=10):
        # 合成データを生成
        np.random.seed(42)
        self.X = np.random.randn(n_samples, n_features).astype(np.float32)
        # 単純な分類ルール:y = 1 if sum(x[:5]) > 0 else 0
        self.y = (np.sum(self.X[:, :5], axis=1) > 0).astype(np.int64)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return torch.tensor(self.X[idx]), torch.tensor(self.y[idx])

# データセットとデータローダーを作成
train_dataset = SimpleDataset(n_samples=800)
val_dataset = SimpleDataset(n_samples=200)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)

print(f"訓練セットのサイズ: {len(train_dataset)}")
print(f"検証セットのサイズ: {len(val_dataset)}")

# ============================================
# 2. モデルを定義
# ============================================

class MLP(nn.Module):
    """多層パーセプトロン"""

    def __init__(self, input_dim, hidden_dims, output_dim, dropout_rate=0.1):
        super().__init__()

        layers = []
        prev_dim = input_dim

        # 隠れ層
        for hidden_dim in hidden_dims:
            layers.append(nn.Linear(prev_dim, hidden_dim))
            layers.append(nn.ReLU())
            layers.append(nn.LayerNorm(hidden_dim))  # レイヤー正規化
            layers.append(nn.Dropout(dropout_rate))  # Dropout
            prev_dim = hidden_dim

        # 出力層
        layers.append(nn.Linear(prev_dim, output_dim))

        self.model = nn.Sequential(*layers)

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

# モデルを作成
model = MLP(
    input_dim=10,
    hidden_dims=[64, 32],
    output_dim=2,
    dropout_rate=0.1
)
print("\nモデル構造:")
print(model)

# ============================================
# 3. 損失関数とオプティマイザーを定義
# ============================================

criterion = nn.CrossEntropyLoss()  # 交差エントロピー損失
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)  # AdamW オプティマイザ

# 学習率スケジューリング:ウォームアップ + コサイン減衰
total_epochs = 20
warmup_epochs = 2
total_steps = len(train_loader) * total_epochs
warmup_steps = len(train_loader) * warmup_epochs

def lr_lambda(step):
    if step < warmup_steps:
        return (step + 1) / warmup_steps
    else:
        progress = (step - warmup_steps) / (total_steps - warmup_steps)
        return 0.5 * (1 + np.cos(np.pi * progress))

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

print("\n学習設定:)
print(f"  Epochs: {total_epochs}")
print(f"  Warmup epochs: {warmup_epochs}")
print(f"  Total steps: {total_steps}")
print(f"  Warmup steps: {warmup_steps}")

# ============================================
# 4. トレーニングループ
# ============================================

print("\nトレーニング開始...)
print("-" * 50)

global_step = 0
best_val_acc = 0.0

for epoch in range(total_epochs):
    # トレーニング段階
    model.train()
    train_loss = 0.0
    train_correct = 0
    train_total = 0

    for batch_X, batch_y in train_loader:
        # 順伝播
        logits = model(batch_X)
        loss = criterion(logits, batch_y)

        # 逆伝播
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        scheduler.step()

        # 統計
        train_loss += loss.item()
        predicted = torch.argmax(logits, dim=1)
        train_total += batch_y.size(0)
        train_correct += (predicted == batch_y).sum().item()

        global_step += 1

    train_loss /= len(train_loader)
    train_acc = train_correct / train_total

    # 検証段階
    model.eval()
    val_loss = 0.0
    val_correct = 0
    val_total = 0

    with torch.no_grad():
        for batch_X, batch_y in val_loader:
            logits = model(batch_X)
            loss = criterion(logits, batch_y)

            val_loss += loss.item()
            predicted = torch.argmax(logits, dim=1)
            val_total += batch_y.size(0)
            val_correct += (predicted == batch_y).sum().item()

    val_loss /= len(val_loader)
    val_acc = val_correct / val_total

    # 最良のモデルを保存
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        # ここでモデルのチェックポイントを保存できます

    # ログを出力
    current_lr = optimizer.param_groups[0]['lr']
    print(f"Epoch {epoch+1:2d} | lr: {current_lr:.6f} | "
          f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
          f"Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}")

print("-" * 50)
print(fトレーニング完了!最高検証精度: {best_val_acc:.4f})

# ============================================
# 5. 推論の例
# ============================================

print("\n推論の例:)
model.eval()

# いくつかのテストサンプル
test_samples = [
    np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32),  # 1のはず
    np.array([-1.0, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32),  # 0のはず
]

for i, sample in enumerate(test_samples):
    with torch.no_grad():
        input_tensor = torch.tensor(sample).unsqueeze(0)
        logits = model(input_tensor)
        probs = torch.softmax(logits, dim=1)
        predicted_class = torch.argmax(probs, dim=1).item()

    print(f" サンプル {i+1}: {sample[:5]}...")
    print(f" 予測クラス: {predicted_class}")
    print(f" 確率: クラス0={probs[0,0]:.4f}, クラス1={probs[0,1]:.4f}")

この例には、ディープラーニングトレーニングのすべての標準コンポーネントが含まれています:

  • データ処理:Dataset、DataLoader
  • モデル定義:MLP(LayerNorm と Dropout 付き)
  • 損失関数:CrossEntropyLoss
  • オプティマイザ:AdamW
  • 学習率スケジューリング:Warmup + Cosine
  • トレーニングループ:トレーニング段階 + 検証段階
  • モデルの保存と推論

このプロセスは、より複雑なタスクやモデルに直接拡張できます。

その他の拡張