2025-12-17

ラビット・チャレンジ - Stage 3. 深層学習 前編 (Day 1)

提出したレポートです。

絶対書きすぎですが、行間を埋めたくなるので仕方ない。


Rabbit Challenge - Stage 3. 深層学習 前編 (Day 1)

0. 深層学習とは何か

この講義(Day1)の内容では、ニューラルネットワークを用いた学習方法として、順伝播・誤差関数・勾配降下法・誤差逆伝播法を扱う。

しかし講義資料の冒頭では、「深層学習とは何か」という全体像や、その中でニューラルネットワークがどのような役割を持つかが明示されていない。

そこで、まず深層学習の枠組みを整理したうえで、本講義が扱う内容の位置づけを明確にする。

0.1 深層学習とは何か

深層学習とは、多層のニューラルネットワークをモデルとして用い、そのパラメータを誤差最小化によって学習する機械学習手法 である。

ここでいう学習とは、与えられたデータに対して定義された誤差(損失)を小さくするように、ニューラルネットワークの重みやバイアスといったパラメータを調整する過程を指す。

この意味で深層学習は、

  • 入力データ
  • ニューラルネットワークという関数モデル
  • 出力と正解との差を定義する誤差関数
  • 誤差を最小化するための最適化手法

から構成される枠組みとして理解できる。

線形回帰やロジスティック回帰も同様に誤差最小化問題として定式化できるが、深層学習では モデルとして多層構造を持つニューラルネットワークを用いる 点が本質的に異なる。

0.2 深層学習におけるニューラルネットワーク

深層学習で用いられるニューラルネットワークは、入力に対して

  1. 線形結合
  2. 非線形変換

を層ごとに繰り返すことで、入力から出力への写像を表現する関数モデルである。

単一の線形変換だけでは表現能力が限定されるが、ニューラルネットワークでは、

  • 各層が重みとバイアスというパラメータを持ち
  • 中間層で非線形変換を挟む

ことにより、複雑な非線形関数を表現できる。

深層学習において「深い」とは、このニューラルネットワークが 複数の中間層を持つ ことを意味する。

すなわち、深層学習とは

多層ニューラルネットワークを用いて誤差最小化を行う学習の枠組み

として理解できる。


1. 入力層〜中間層

まず、深層学習におけるニューラルネットワークの基本構造として、入力層から中間層までの処理を整理する。

誤差逆伝播法や勾配降下法を理解するためには、まず順伝播において「何が計算されているのか」を明確にしておく必要がある。

1.1 ニューラルネットワークの基本構造

ニューラルネットワークは、入力層・中間層・出力層から構成される。

このうち、入力層は外部から与えられたデータを受け取る役割を持ち、パラメータを持たない。一方、中間層および出力層には、重みとバイアスという学習対象となるパラメータが存在する。

深層学習では、これらのパラメータを調整することで、入力から出力への写像をデータに適合させていく。そのため、どの層にどのパラメータが存在するかを明確に区別して理解しておくことが重要である。

1.2 線形結合としての中間層の入力

中間層における処理は、まず入力ベクトルに対する線形結合として表される。

入力ベクトルを $\boldsymbol{x}$ 、重みを $\boldsymbol{w}$ 、バイアスを $b$ とすると、中間層の各ユニットに入力される値は

$$ z = \boldsymbol{w}^\top \boldsymbol{x} + b $$

と書ける。

この処理は、入力データを重み付きで足し合わせ、そこに定数項としてバイアスを加える操作であり、行列演算としてまとめて扱うことができる。ニューラルネットワークの順伝播は、このような線形結合を層ごとに繰り返す処理として理解できる。

1.3 バイアス項の役割

バイアスは、入力がすべてゼロであっても出力を調整できるようにするための項である。

バイアスが存在しない場合、線形結合の結果は必ず原点を通る形に制限されてしまい、表現力が低下する。

深層学習では、重みと同様にバイアスも学習対象となり、誤差最小化の過程で同時に更新される。そのため、バイアスは「補助的な定数」ではなく、モデルの表現能力に直接関与する重要なパラメータとして扱う必要がある。

1.4 中間層出力の位置づけ

中間層では、線形結合の結果に対して活性化関数が適用され、その出力が次の層への入力となる。

この段階では、まだ最終的な予測結果は得られていないが、入力データは中間層を通じて段階的に変換されている。

この中間層の出力は、しばしば「特徴表現」として解釈される。

実装例:順伝播(3層・複数ユニット)

活性化関数。

import numpy as np

def sigmoid(x: np.ndarray) -> np.ndarray:
    x = np.clip(x, -50, 50)  # オーバーフロー回避(最低限)
    return 1.0 / (1.0 + np.exp(-x))

def relu(x: np.ndarray) -> np.ndarray:
    return np.maximum(0.0, x)

def softmax(x: np.ndarray) -> np.ndarray:
    # 1次元・2次元の両方に対応(2次元は行方向)
    x = np.asarray(x)
    if x.ndim == 1:
        m = np.max(x)
        ex = np.exp(x - m)
        return ex / np.sum(ex)
    elif x.ndim == 2:
        m = np.max(x, axis=1, keepdims=True)
        ex = np.exp(x - m)
        return ex / np.sum(ex, axis=1, keepdims=True)
    else:
        raise ValueError("softmax expects 1D or 2D array")

3 層順伝播。

def init_params(n_in: int, n_h1: int, n_h2: int, n_out: int, seed: int = 0):
    """パラメータを初期化する"""

    rng = np.random.default_rng(seed)

    # ここではシンプルに小さい乱数(学習が目的ではなく順伝播確認のため)
    W1 = rng.normal(0.0, 0.1, size=(n_in, n_h1))
    b1 = np.zeros((n_h1,))

    W2 = rng.normal(0.0, 0.1, size=(n_h1, n_h2))
    b2 = np.zeros((n_h2,))

    W3 = rng.normal(0.0, 0.1, size=(n_h2, n_out))
    b3 = np.zeros((n_out,))

    return {"W1": W1, "b1": b1, "W2": W2, "b2": b2, "W3": W3, "b3": b3}

def forward_3layer(
    X: np.ndarray,
    params: dict,
    act1=relu,
    act2=relu,
    out_act=softmax,
):
    """
    3層で順伝播させる
    """
    X = np.asarray(X)
    if X.ndim == 1:
        X = X.reshape(1, -1)  # (1, n_in)

    W1, b1 = params["W1"], params["b1"]
    W2, b2 = params["W2"], params["b2"]
    W3, b3 = params["W3"], params["b3"]

    Z1 = X @ W1 + b1  # (batch, n_h1)
    A1 = act1(Z1)  # (batch, n_h1)

    Z2 = A1 @ W2 + b2  # (batch, n_h2)
    A2 = act2(Z2)  # (batch, n_h2)

    Z3 = A2 @ W3 + b3  # (batch, n_out)
    Y  = out_act(Z3)  # (batch, n_out)

    cache = {"X": X, "Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2, "Z3": Z3, "Y": Y}
    return Y, cache

n_in, n_h1, n_h2, n_out = 3, 10, 5, 4
params = init_params(n_in, n_h1, n_h2, n_out, seed=42)

# バッチ入力の例(2サンプル)
X = np.array([
    [1.0, 0.5, -0.2],
    [0.0, -1.0, 2.0]
])

Y, cache = forward_3layer(X, params, act1=relu, act2=relu, out_act=softmax)

print("X shape:", X.shape)
print("W1,b1:", params["W1"].shape, params["b1"].shape)
print("W2,b2:", params["W2"].shape, params["b2"].shape)
print("W3,b3:", params["W3"].shape, params["b3"].shape)
print("Y shape:", Y.shape)
print("Y (probabilities):\n", Y)
print("Row sums (should be 1):", np.sum(Y, axis=1))

入力層〜中間層 - 順伝播(3層・複数ユニット)

実装例:多クラス分類(3-5-6)

def init_params_3_5_6(seed=0):
    """パラメータを初期化する"""

    rng = np.random.default_rng(seed)

    n_in, n_hidden, n_out = 3, 5, 6

    W1 = rng.normal(0.0, 0.1, size=(n_in, n_hidden))
    b1 = np.zeros((n_hidden,))

    W2 = rng.normal(0.0, 0.1, size=(n_hidden, n_out))
    b2 = np.zeros((n_out,))

    return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}

def forward_3_5_6(X, params):
    """
    3-5-6 のノード構成で、順伝播させる
    """
    X = np.asarray(X)
    if X.ndim == 1:
        X = X.reshape(1, -1)

    W1, b1 = params["W1"], params["b1"]
    W2, b2 = params["W2"], params["b2"]

    Z1 = X @ W1 + b1  # (batch, 5)
    A1 = relu(Z1)  # (batch, 5)

    Z2 = A1 @ W2 + b2  # (batch, 6)
    Y  = softmax(Z2)  # (batch, 6)

    return Y, {"X": X, "Z1": Z1, "A1": A1, "Z2": Z2, "Y": Y}

params = init_params_3_5_6(seed=42)

# バッチ入力(例)
X = np.array([
    [1.0,  0.5, -0.2],
    [0.0, -1.0,  2.0]
])

Y, cache = forward_3_5_6(X, params)

print("X shape:", X.shape)
print("W1,b1:", params["W1"].shape, params["b1"].shape)
print("W2,b2:", params["W2"].shape, params["b2"].shape)
print("Y shape:", Y.shape)
print("Y (class probabilities):\n", Y)
print("Row sums (should be 1):", np.sum(Y, axis=1))

入力層〜中間層 - 多クラス分類(3-5-6)

実装例:回帰(3-5-4)

def init_params_3_5_4(seed=0):
    """パラメータを初期化する"""

    rng = np.random.default_rng(seed)

    n_in, n_hidden, n_out = 3, 5, 4

    W1 = rng.normal(0.0, 0.1, size=(n_in, n_hidden))
    b1 = np.zeros((n_hidden,))

    W2 = rng.normal(0.0, 0.1, size=(n_hidden, n_out))
    b2 = np.zeros((n_out,))

    return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}

def forward_3_5_4_regression(X, params):
    """
    3-5-4 のノード構成の回帰で、順伝播させる
    """
    X = np.asarray(X)
    if X.ndim == 1:
        X = X.reshape(1, -1)

    W1, b1 = params["W1"], params["b1"]
    W2, b2 = params["W2"], params["b2"]

    Z1 = X @ W1 + b1  # (batch, 5)
    A1 = relu(Z1)  # 中間層の非線形変換

    Z2 = A1 @ W2 + b2  # (batch, 4)
    Y  = Z2  # 回帰なので恒等関数

    return Y, {"X": X, "Z1": Z1, "A1": A1, "Y": Y}

params = init_params_3_5_4(seed=42)

X = np.array([
    [1.0,  0.5, -0.2],
    [0.0, -1.0,  2.0]
])

Y, cache = forward_3_5_4_regression(X, params)

print("X shape:", X.shape)
print("W1,b1:", params["W1"].shape, params["b1"].shape)
print("W2,b2:", params["W2"].shape, params["b2"].shape)
print("Y shape:", Y.shape)
print("Y (regression output):\n", Y)

入力層〜中間層 - 回帰(3-5-4)

実装例:2 値分類(5-10-20-1)

def init_params_5_10_20_1(seed=0):
    """パラメータを初期化する"""

    rng = np.random.default_rng(seed)

    n_in, n_h1, n_h2, n_out = 5, 10, 20, 1

    W1 = rng.normal(0.0, 0.1, size=(n_in, n_h1))
    b1 = np.zeros((n_h1,))

    W2 = rng.normal(0.0, 0.1, size=(n_h1, n_h2))
    b2 = np.zeros((n_h2,))

    W3 = rng.normal(0.0, 0.1, size=(n_h2, n_out))
    b3 = np.zeros((n_out,))

    return {"W1": W1, "b1": b1, "W2": W2, "b2": b2, "W3": W3, "b3": b3}

def forward_5_10_20_1_binary(X, params):
    """
    5-10-20-1 のノード構成の2 値分類で、順伝播させる
    """
    X = np.asarray(X)
    if X.ndim == 1:
        X = X.reshape(1, -1)

    W1, b1 = params["W1"], params["b1"]
    W2, b2 = params["W2"], params["b2"]
    W3, b3 = params["W3"], params["b3"]

    Z1 = X @ W1 + b1  # (batch, 10)
    A1 = relu(Z1)

    Z2 = A1 @ W2 + b2  # (batch, 20)
    A2 = relu(Z2)

    Z3 = A2 @ W3 + b3  # (batch, 1)
    Y  = sigmoid(Z3)  # (batch, 1) 確率

    return Y, {"X": X, "Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2, "Z3": Z3, "Y": Y}

params = init_params_5_10_20_1(seed=42)

# バッチ入力(例): 2サンプル, 入力次元5
X = np.array([
    [1.0,  0.5, -0.2,  0.0,  2.0],
    [0.0, -1.0,  2.0, -0.5,  0.3]
])

Y, cache = forward_5_10_20_1_binary(X, params)

print("X shape:", X.shape)
print("W1,b1:", params["W1"].shape, params["b1"].shape)
print("W2,b2:", params["W2"].shape, params["b2"].shape)
print("W3,b3:", params["W3"].shape, params["b3"].shape)
print("Y shape:", Y.shape)
print("Y (binary probability):\n", Y)
print("Y range:", (np.min(Y), np.max(Y)))

入力層〜中間層 - 2 値分類(5-10-20-1)

確認テスト

この図式に動物分類の実例を入れてみよう

どのような入力 $x_i$ が考えられるかという問いなので、例えば、以下の通り。

  • 脊椎動物、無脊椎動物
  • 哺乳類、鳥類、爬虫類、両生類、魚類
  • 脚の本数
  • 体長
  • 体重
  • 翼の有無
  • 体表が毛で覆われているかどうか

この数式を Python で書け

u = np.dot(w, x) + b

1-1 のファイルから、中間層の出力を定義しているソースを抜き出せ

関数 forward 内の、以下の行。

  # 1層の総入力
  u1 = np.dot(x, W1) + b1

  # 1層の総出力(=中間層出力1)
  z1 = functions.relu(u1)

  # 2層の総入力
  u2 = np.dot(z1, W2) + b2

  # 2層の総出力(=中間層出力2)
  z2 = functions.relu(u2)

参考図書 / 関連記事

参考記事では、全結合層(Fully Connected Layer)は、前の層のすべてのユニットと結合し、次の層の各ユニットに値を伝える層として説明されている。

この構造により、入力された特徴量は重み付けされた線形結合としてまとめられ、中間層の入力となる。

入力ベクトルを $\boldsymbol{x}$、重み行列を $W$、バイアスを $\boldsymbol{b}$ とすると、全結合層での計算は

$$ \boldsymbol{u} = \boldsymbol{x} W + \boldsymbol{b} $$

と表され、これは行列積として実装できる。

このように、全結合層は入力を単に次の層へ渡すのではなく、重み行列によって各要素を組み合わせ、新たな表現へ変換する役割 を持つ。

また、全結合層におけるユニット数は、中間層の表現の次元数を決定する。

ユニット数を増やすことで、より多様な特徴の組み合わせを表現できる一方、パラメータ数も増加するため、モデルの複雑さとのバランスを考慮する必要がある。

このように、全結合層はニューラルネットワークにおいて、入力データを別の特徴空間へ写像するための基本的な構成要素 として位置づけられる。

このような全結合層の役割や行列演算としての見方は、一般的なニューラルネットワークの解説記事でも共通して述べられている。


2. 活性化関数

前項で見たように、中間層では重みとバイアスによる線形結合が計算されるが、これに非線形変換を加えなければ、層を重ねても表現力は向上しない。

この非線形変換を担うのが活性化関数である。

2.1 活性化関数の役割

活性化関数は、線形結合の結果 $u$ に対して適用され、次の層へ渡す出力 $z$ を生成する。

$$ z = f(u) $$

活性化関数が存在しない場合、ニューラルネットワーク全体は単なる線形変換として表されてしまう。そのため、複雑な非線形関係を表現するためには、各層において非線形な活性化関数を導入する必要がある。

2.2 ステップ関数

ステップ関数は、入力がある閾値を超えたかどうかによって出力が切り替わる関数であり、次のように定義される。

$$ f(u) = \begin{cases} 1 & (u \ge 0) \\ 0 & (u < 0) \end{cases} $$

ステップ関数は、初期のパーセプトロンで用いられていた活性化関数であり、出力が $0$ または $1$ のいずれかになる。

しかし、この関数は 微分が定義できない(またはほとんどの点で 0) ため、勾配降下法や誤差逆伝播法を用いた学習には適していない。そのため、現在の深層学習ではほとんど用いられない。

2.3 シグモイド関数

シグモイド関数は、入力を $0$ から $1$ の範囲に滑らかに写像する関数であり、次のように定義される。

$$ f(u) = \frac{1}{1 + e^{-u}} $$

この関数は連続かつ微分可能であるため、誤差逆伝播法を用いた学習が可能である。また、出力を確率として解釈できる点から、2 値分類の出力層などで用いられることがある。

一方で、入力の絶対値が大きくなると勾配が非常に小さくなる性質を持ち、深いネットワークでは学習が進みにくくなる場合がある。

2.4 ReLU (Rectified Linear Unit)

ReLU は、現在最も広く用いられている活性化関数の一つであり、次のように定義される。

$$ f(u) = \max(0, u) $$

入力が正の場合はそのまま出力し、負の場合は $0$ を出力する単純な関数である。計算が軽量であり、シグモイド関数に比べて勾配が消失しにくいという利点がある。

実装例

活性化関数を図示する。

import numpy as np
import matplotlib.pyplot as plt

def step_function(x):
    return (x >= 0).astype(int)

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

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

x = np.linspace(-5, 5, 100)

plt.figure()
plt.plot(x, step_function(x), label="Step function")
plt.plot(x, sigmoid(x), label="Sigmoid function")
plt.plot(x, relu(x), label="ReLU function")

plt.xlabel("x")
plt.ylabel("f(x)")
plt.ylim(-0.1, 1.1)  # ← 表示範囲を 0〜1 付近に制限
plt.legend()
plt.grid(True)

plt.show()

活性化関数

確認テスト

線形と非線形の違いを図にかいて、簡易に説明せよ。

線形と非線形の違い

線形関数とは、加法性斉次性 を満たす関数である。 すなわち、任意の入力 $x_1, x_2$ とスカラー $a$ に対して、

$$ f(x_1 + x_2) = f(x_1) + f(x_2) \\ f(ax) = a f(x) $$

が成り立つ。このような関数は、図にすると直線として表される。

一方、非線形関数はこれらの性質を満たさず、入力と出力の関係が直線では表せない。

ニューラルネットワークでは、中間層に非線形関数を導入しない限り、層を重ねても全体として線形変換にしかならない。

配布されたソースコードより、該当する箇所を抜き出せ

# 中間層出力
z = functions.sigmoid(u)
print_vec("中間層出力", z)

参考図書 / 関連記事

上記以外に、ReLU の拡張や代替として用いられる代表的な活性化関数について、簡単に整理する。

Leaky ReLU

Leaky ReLU は、ReLU において負の入力に対する勾配が $0$ になる問題を緩和するために提案された活性化関数である。 定義式は次の通りである。

$$ f(x) = \begin{cases} x & (x > 0) \\ \alpha x & (x \le 0) \end{cases} \quad (\alpha > 0) $$

通常、$\alpha$ は $0.01$ 程度の小さな値が用いられる。

負の領域でもわずかな勾配を持つことで、学習が停止する(Dead ReLU)問題を軽減できる。

tanh(双曲線正接関数)

tanh 関数は、シグモイド関数を拡張した形の活性化関数であり、出力が $-1$ から $1$ の範囲に収まる。

定義式は次の通りである。

$$ f(x) = \tanh(x) = \frac{e^{x} - e^{-x}}{e^{x} + e^{-x}} $$

出力が $0$ を中心とした対称な分布となるため、シグモイド関数と比べて学習が安定しやすい場合がある。

一方で、入力が大きい場合には勾配が小さくなり、勾配消失が起こりやすいという欠点も持つ。

GELU(Gaussian Error Linear Unit)

GELU は、入力を確率的に通過させるという考え方に基づいた活性化関数であり、近年の深層学習モデルでも用いられている。

定義式は次のように表される。

$$ f(x) = x \Phi(x) $$

ここで $\Phi(x)$ は標準正規分布の累積分布関数である。

実装上は、次の近似式がよく用いられる。

$$ f(x) \approx 0.5x \left(1 + \tanh\left(\sqrt{\frac{2}{\pi}}\left(x + 0.044715x^3\right)\right)\right) $$

GELU は ReLU のような単純な閾値処理ではなく、入力の大きさに応じて滑らかに出力を調整する点が特徴である。


3. 出力層

前項までで見てきた中間層は、入力データを段階的に変換し、有用な特徴表現を獲得する役割を担っていた。一方、出力層は、モデルの最終的な出力を決定する層 である。

3.1 出力層の役割

出力層では、中間層の出力に対して線形結合を行い、その結果に適切な活性化関数を適用することで、タスクに応じた形式の出力を得る。

$$ y = g(u) $$

ここで $u$ は出力層への総入力、$g(\cdot)$ は出力層の活性化関数である。

出力層の設計は、何を予測したいか(回帰か分類か) によって明確に異なる。

3.2 誤差関数(損失関数)

出力層の出力がどれだけ正解に近いかを評価するために、誤差関数(損失関数) が用いられる。

誤差関数は、学習において最小化すべき量として定義される。

二乗誤差 (Mean Squared Error)

回帰問題において用いられる代表的な誤差関数が二乗誤差である。

出力 $y_i$ と正解値 $t_i$ に対して、二乗誤差は次のように定義される。

$$ E = \frac{1}{2} \sum_{i=1}^{N} (y_i - t_i)^2 $$

ここで、

  • $N$ は出力次元数(あるいはデータ数)
  • $y_i$ はモデルの出力
  • $t_i$ は対応する正解値

を表す。

係数 $\frac{1}{2}$ は、微分計算を簡単にするために導入されている。

この誤差関数は、出力層に恒等写像を用いる回帰問題と組み合わせて用いられる。

交差エントロピー誤差(2 値分類)

2 値分類では、出力 $y_i \in (0,1)$ をクラス 1 に属する確率とみなし、正解ラベル $t_i \in \{0,1\}$ に対して、交差エントロピー誤差を次のように定義する。

$$ E = - \sum_{i=1}^{N} \left[ t_i \log y_i + (1 - t_i)\log(1 - y_i) \right] $$

この誤差関数は、確率分布としての出力を自然に評価できるため、出力層の活性化関数としてシグモイド関数を用いる場合に適している。

交差エントロピー誤差(多クラス分類)

多クラス分類では、正解ラベルを one-hot ベクトル

$$ \boldsymbol{t}^{(i)} = (t^{(i)}_1, \dots, t^{(i)}_K) $$

として表し、ソフトマックス関数の出力

$$ \boldsymbol{y}^{(i)} = (y^{(i)}_1, \dots, y^{(i)}_K) $$

に対して、交差エントロピー誤差を次のように定義する。

$$ E = - \sum_{i=1}^{N} \sum_{k=1}^{K} t^{(i)}_k \log y^{(i)}_k $$

ここで、

  • $N$:データ数
  • $K$:クラス数

を表す。

この式では、各データについて正解クラスに対応する成分のみが誤差に寄与する。

3.3 出力層の活性化関数

出力層で用いられる活性化関数は、タスクの種類に応じて選択される。

恒等写像

回帰問題では、出力を連続値として扱うため、恒等写像が用いられる。

$$ f(u) = u $$

この場合、出力は実数全体を取り得る。

シグモイド関数

2 値分類では、出力を 0〜1 の範囲に写像するため、シグモイド関数が用いられる。

$$ f(u) = \frac{1}{1 + e^{-u}} $$

この出力は、クラス 1 に属する確率として解釈できる。

ソフトマックス関数

多クラス分類では、各クラスに対応する出力を確率分布として正規化するため、ソフトマックス関数が用いられる。

$$ f(u_k) = \frac{e^{u_k}}{\sum_j e^{u_j}} $$

ソフトマックス関数の出力は、全クラスについて和が 1 となる確率分布になる。

3.4 出力層と誤差関数の対応関係

出力層の活性化関数と誤差関数は、以下のように対応づけて用いられる。

  • 回帰問題
    • 活性化関数:恒等写像
    • 誤差関数:二乗誤差
  • 2 値分類
    • 活性化関数:シグモイド
    • 誤差関数:交差エントロピー
  • 多クラス分類
    • 活性化関数:ソフトマックス
    • 誤差関数:交差エントロピー

実装例

実際に、回帰問題、2 値分類、多クラス分類について、活性化関数と誤差関数の様子を確認する。

import numpy as np

##################################################
#  活性化関数
##################################################

def identity(x):
    return x

def sigmoid(x):
    x = np.clip(x, -50, 50)
    return 1 / (1 + np.exp(-x))

def softmax(x):
    x = np.asarray(x)
    if x.ndim == 1:
        x = x - np.max(x)
        ex = np.exp(x)
        return ex / np.sum(ex)
    elif x.ndim == 2:
        x = x - np.max(x, axis=1, keepdims=True)
        ex = np.exp(x)
        return ex / np.sum(ex, axis=1, keepdims=True)
    else:
        raise ValueError("softmax expects 1D or 2D")

##################################################
#  誤差関数(損失)
##################################################

def mse(y, t):
    """
    二乗誤差(バッチ平均)
    y, t: (batch, dim)
    """
    y = np.asarray(y)
    t = np.asarray(t)
    return 0.5 * np.mean(np.sum((y - t) ** 2, axis=1))

def binary_cross_entropy(y, t, eps=1e-12):
    """
    2 値交差エントロピー(バッチ平均)
    y: (batch, 1) sigmoid出力を想定(0,1)
    t: (batch, 1) 0/1
    """
    y = np.clip(np.asarray(y), eps, 1 - eps)
    t = np.asarray(t)
    return -np.mean(t * np.log(y) + (1 - t) * np.log(1 - y))

def categorical_cross_entropy(y, t, eps=1e-12):
    """
    多クラス交差エントロピー(バッチ平均)
    y: (batch, K) softmax出力
    t: (batch, K) one-hot
    """
    y = np.clip(np.asarray(y), eps, 1.0)
    t = np.asarray(t)
    return -np.mean(np.sum(t * np.log(y), axis=1))

##################################################
#  線形出力(ロジット)を作る簡単なモデル
#    y = XW + b
##################################################

def linear_logits(X, W, b):
    return X @ W + b

##################################################
#  1) 回帰:恒等写像 + 二乗誤差
##################################################

np.random.seed(0)

X_reg = np.array([
    [1.0, 0.5, -0.2],
    [0.0, -1.0, 2.0],
])
t_reg = np.array([
    [0.3, -0.2, 0.1, 0.0],
    [-0.1, 0.4, 0.2, -0.3],
])  # (batch, 4)

W_reg = np.random.normal(0, 0.1, size=(3, 4))
b_reg = np.zeros((4,))

logits_reg = linear_logits(X_reg, W_reg, b_reg)   # (2, 4)
y_reg = identity(logits_reg)                      # 恒等写像
loss_reg = mse(y_reg, t_reg)

print("=== Regression (3->4) ===")
print("logits shape:", logits_reg.shape)
print("y shape:", y_reg.shape)
print("mse:", loss_reg)
print()

##################################################
#  2) 2 値分類:sigmoid + 交差エントロピー
##################################################

X_bin = np.array([
    [1.0, 0.5, -0.2, 0.0, 2.0],
    [0.0, -1.0, 2.0, -0.5, 0.3],
])
t_bin = np.array([
    [1.0],
    [0.0],
])  # (batch, 1)

W_bin = np.random.normal(0, 0.1, size=(5, 1))
b_bin = np.zeros((1,))

logits_bin = linear_logits(X_bin, W_bin, b_bin)   # (2, 1)
y_bin = sigmoid(logits_bin)                       # sigmoid
loss_bin = binary_cross_entropy(y_bin, t_bin)

print("=== Binary Classification (5->1) ===")
print("logits shape:", logits_bin.shape)
print("y shape:", y_bin.shape)
print("y (prob):\n", y_bin)
print("bce:", loss_bin)
print()

##################################################
#  3) 多クラス分類:softmax + 交差エントロピー
##################################################

X_mc = np.array([
    [1.0, 0.5, -0.2],
    [0.0, -1.0, 2.0],
])
K = 6
t_mc = np.array([
    [0, 0, 1, 0, 0, 0],  # 正解クラス=2
    [0, 1, 0, 0, 0, 0],  # 正解クラス=1
])  # one-hot (batch, 6)

W_mc = np.random.normal(0, 0.1, size=(3, K))
b_mc = np.zeros((K,))

logits_mc = linear_logits(X_mc, W_mc, b_mc)       # (2, 6)
y_mc = softmax(logits_mc)                         # softmax
loss_mc = categorical_cross_entropy(y_mc, t_mc)

print("=== Multiclass Classification (3->6) ===")
print("logits shape:", logits_mc.shape)
print("y shape:", y_mc.shape)
print("row sums (should be 1):", np.sum(y_mc, axis=1))
print("y (prob):\n", y_mc)
print("cce:", loss_mc)

出力層

確認テスト

なぜ、引き算でなく二乗するか述べよ

単純な引き算 $y - t$ を誤差とすると、正と負の誤差が打ち消し合ってしまい、誤差の大きさを正しく評価できない。

二乗することで、誤差の符号に関係なく常に正の値となり、ずれの大きさそのもの を評価できるようになるためである。

下式の $1/2$ はどういう意味を持つか述べよ

$$ E_n(\boldsymbol{w}) = \frac{1}{2}\sum_{j=1}^{J}(y_j - d_j)^2 = \frac{1}{2} \lVert \boldsymbol{y} - \boldsymbol{d} \rVert^2 $$

この式における $1/2$ は、誤差関数を微分した際の計算を簡単にするための係数 である。

誤差関数は、出力ベクトル $\boldsymbol{y}$ と教師信号 $\boldsymbol{d}$ の差のユークリッドノルムの二乗として定義されている。

このとき、各成分について微分を行うと、

$$ \frac{\partial E_n}{\partial y_j} = \frac{\partial}{\partial y_j} \left( \frac{1}{2}\sum_{j=1}^{J}(y_j - d_j)^2 \right) = y_j - d_j $$

となり、微分結果から係数 $2$ が打ち消される。

もし係数 $1/2$ が存在しなければ、微分結果は

$$ 2(y_j - d_j) $$

となり、以降の勾配計算に不要な定数が残ってしまう。

①~③の数式に該当するソースコードを示し、一行づつ処理の説明をせよ

def softmax(x):
    if x.ndim == 2:
        x = x.T
        x = x - np.max(x, axis=0)
        y = np.exp(x) / np.sum(np.exp(x), axis=0)
        return y.T

    x = x - np.max(x) # オーバーフロー対策
    return np.exp(x) / np.sum(np.exp(x))

まず、①~③の数式に該当するソースコードは、以下の通り。

  • ①:この関数そのものか、強いて言うなら np.exp(x) / np.sum(np.exp(x), ...)
  • ②:np.exp(x)
  • ③:np.sum(np.exp(x), ...)

次に、一行ずつ処理の説明をする。

def softmax(x):

関数を定義する。

    if x.ndim == 2:

入力 x が2次元(バッチ入力)かどうかで処理を分岐する。

        x = x.T

バッチごと(各サンプルごと)に $\sum_{k}$ を取りやすい形にするため転置する。

        x = x - np.max(x, axis=0)

各サンプル(列)ごとに最大値を引く。

これは $\exp(\cdot)$ の計算でオーバーフローを避けるための数値安定化であり、ソフトマックスの値自体(①)を変えない変形である。

        y = np.exp(x) / np.sum(np.exp(x), axis=0)

ここが図の①〜③に直接対応する本体である。

        return y.T

転置していたので、元の形(入力と同じ並び)に戻して返す。

    x = x - np.max(x) # オーバーフロー対策

入力が 1 次元(単一サンプル)の場合も同様に最大値を引いて数値安定化する(①の値は不変)。

    return np.exp(x) / np.sum(np.exp(x))

1 次元の場合のソフトマックス本体。

①~②の数式に該当するソースコードを示し、一行づつ処理の説明をせよ

def cross_entropy_error(d, y):
    if y.ndim == 1:
        d = d.reshape(1, d.size)
        y = y.reshape(1, y.size)

    # 教師データがone-hot-vectorの場合、正解ラベルのインデックスに変換
    if d.size == y.size:
        d = d.argmax(axis=1)

    batch_size = y.shape[0]
    return -np.sum(np.log(y[np.arange(batch_size), d] + 1e-7)) / batch_size

まず、①~②の数式に該当するソースコードは、以下の通り。

  • ①:この関数そのものか、強いて言うなら -np.sum(...) / batch_size
  • ②:np.sum(np.log(y[...]))

次に、一行ずつ処理の説明をする。

def cross_entropy_error(d, y):

関数を定義する。

    if y.ndim == 1:

入力が 1 次元(単一サンプル)の場合かどうかを判定する。

        d = d.reshape(1, d.size)
        y = y.reshape(1, y.size)

単一サンプルの場合でも $(\text{batch}, \text{class})$ の形で扱えるように 2 次元配列へ変形する。

    # 教師データがone-hot-vectorの場合、正解ラベルのインデックスに変換
    if d.size == y.size:

教師データ d が one-hot ベクトルかどうかを判定する。

        d = d.argmax(axis=1)

one-hot 表現

$$ (d_1, d_2, \ldots, d_I) $$

を、正解クラスのインデックス $i$ に変換する。

これにより、$d_i = 1$ の位置のみを参照できるようにする。

    batch_size = y.shape[0]

バッチサイズ $N$(データ数)を取得する。

    return -np.sum(np.log(y[np.arange(batch_size), d] + 1e-7)) / batch_size

ここが①〜②の数式に直接対応する本体である。特に、

  • y[np.arange(batch_size), d] → 各データについて 正解クラスの (y_i) を取り出す
  • / batch_size → バッチ平均(実装上の正規化)
  • + 1e-7 → $\log(0)$ を防ぐための数値安定化処理であり、数式①②そのものには含まれない

参考図書 / 関連記事

回帰問題の誤差関数として二乗誤差(MSE)を扱ったが、目的やデータ特性に応じて他の損失関数が用いられる場合もある。

ここでは代表的なものを簡単に紹介する。

平均絶対誤差(MAE:Mean Absolute Error)

平均絶対誤差は、予測値と正解値の差の絶対値を平均した損失関数である。

$$ E = \frac{1}{N}\sum_{n=1}^{N} |y_n - t_n| $$

誤差を二乗しないため、外れ値の影響を受けにくいという特徴がある。

一方で、誤差が $0$ 付近で微分が不連続となるため、最適化の観点では扱いづらい場合がある。

平均二乗誤差平方根(RMSE:Root Mean Squared Error)

RMSE は、二乗誤差の平方根を取った損失関数である。

$$ E = \sqrt{\frac{1}{N}\sum_{n=1}^{N}(y_n - t_n)^2} $$

MSE と同様に大きな誤差を強く評価する性質を持つが、誤差の単位が元の目的変数と同じになるため、直感的に解釈しやすいという利点がある。

Huber 損失(Huber Loss)

Huber 損失は、誤差が小さい場合は二乗誤差、大きい場合は絶対誤差として振る舞う損失関数である。

$$ E = \begin{cases} \frac{1}{2}(y - t)^2 & (|y - t| \le \delta) \\ \delta|y - t| - \frac{1}{2}\delta^2 & (|y - t| > \delta) \end{cases} $$

この損失関数は、MSE の滑らかさと MAE の外れ値耐性を両立させることを目的として用いられる。


4. 勾配降下法

学習とは、誤差関数 $E(\boldsymbol{w})$ を小さくするようにパラメータ(重み $\boldsymbol{w}$ やバイアス $b$ )を更新することであり、その基本となる更新則が勾配降下法である。

4.1 勾配降下法(Gradient Descent)

勾配降下法では、誤差関数の勾配 $\nabla E(\boldsymbol{w})$ を用いて、誤差が減少する方向(負の勾配方向)へパラメータを更新する。

$$ \boldsymbol{w} \leftarrow \boldsymbol{w} - \eta \nabla E(\boldsymbol{w}) $$

ここで、

  • $\eta$:学習率( 1 回の更新幅)
  • $\nabla E(\boldsymbol{w})$:誤差関数の勾配(各パラメータで偏微分したベクトル)

を表す。

勾配降下法(ここではバッチ勾配降下法を指す)は、全ての訓練データを用いて 勾配を計算し、その勾配に基づいて 1 回更新する方法である。

データ数を $N$ とすると、例えばデータごとの損失を $E_n(\boldsymbol{w})$ として、全体の損失を

$$ E(\boldsymbol{w}) = \frac{1}{N}\sum_{n=1}^{N} E_n(\boldsymbol{w}) $$

と定義した場合、勾配も

$$ \nabla E(\boldsymbol{w}) = \frac{1}{N}\sum_{n=1}^{N}\nabla E_n(\boldsymbol{w}) $$

として計算される。

4.2 確率的勾配降下法(Stochastic Gradient Descent)

確率的勾配降下法(SGD)は、1 回の更新に用いるデータを 1 件(1 サンプル) にする方法である。

すなわち、あるサンプル $n$ を選び、その損失 $E_n(\boldsymbol{w})$ の勾配で更新する。

$$ \boldsymbol{w} \leftarrow \boldsymbol{w} - \eta \nabla E_n(\boldsymbol{w}) $$

全データを用いる場合に比べて、1 回の更新は高速である一方、勾配の推定がばらつくため更新は不安定になり得る。ただし、このばらつきが局所的な停滞を抜ける効果を持つ場合もある。

4.3 ミニバッチ勾配降下法(Mini-batch Gradient Descent)

ミニバッチ勾配降下法は、バッチ勾配降下法と確率的勾配降下法の中間であり、1 回の更新に 小さなデータ集合(ミニバッチ) を用いる。

ミニバッチのサイズを $B$ とし、ミニバッチを $\mathcal{B}$ とすると、更新式は

$$ \boldsymbol{w} \leftarrow \boldsymbol{w} - \eta \nabla E_{\mathcal{B}}(\boldsymbol{w}) $$

ここで、ミニバッチ損失を

$$ E_{\mathcal{B}}(\boldsymbol{w})=\frac{1}{B}\sum_{n\in\mathcal{B}} E_n(\boldsymbol{w}) $$

とすれば、勾配は

$$ \nabla E_{\mathcal{B}}(\boldsymbol{w})=\frac{1}{B}\sum_{n\in\mathcal{B}} \nabla E_n(\boldsymbol{w}) $$

として計算される。

ミニバッチ勾配降下法は、

  • SGD より勾配が安定しやすい
  • 全データより計算コストが軽い
  • 行列演算としてまとめやすく実装上も効率的

という理由から、深層学習では標準的に用いられる。

実装例

配布されたコードを参考に、「1 次元線形回帰 + 二乗誤差」を題材として、3 種類の更新式と処理の違いを確認する。

問題設定

入力 $x$ に対して、出力 $y$ を

$$ \hat{y} = wx + b $$

で近似する線形回帰問題を考える。

誤差関数には二乗誤差を用いる。

$$ E = \frac{1}{2N}\sum_{n=1}^{N}(\hat{y}_n - y_n)^2 $$

データ生成

import numpy as np

np.random.seed(0)

# データ数
N = 50

# 入力と正解
x = np.linspace(0, 1, N)
y = 2.0 * x + 1.0 + 0.1 * np.random.randn(N)

モデルと誤差関数

def predict(x, w, b):
    return w * x + b

def mse_loss(x, y, w, b):
    y_hat = predict(x, w, b)
    return 0.5 * np.mean((y_hat - y) ** 2)

def gradients(x, y, w, b):
    """
    二乗誤差に対する勾配
    """
    y_hat = predict(x, w, b)
    dw = np.mean((y_hat - y) * x)
    db = np.mean(y_hat - y)
    return dw, db

1. 勾配降下法(バッチ)

全データを用いて勾配を計算し、1回更新する。

w, b = 0.0, 0.0
lr = 0.1
epochs = 50

for epoch in range(epochs):
    dw, db = gradients(x, y, w, b)
    w -= lr * dw
    b -= lr * db

loss = mse_loss(x, y, w, b)
print("Batch GD:", w, b, loss)

2. 確率的勾配降下法(SGD)

1 サンプルずつ勾配を計算し、逐次更新する。

w, b = 0.0, 0.0
lr = 0.1
epochs = 50

for epoch in range(epochs):
    for i in range(N):
        xi = x[i]
        yi = y[i]
        y_hat = w * xi + b

        dw = (y_hat - yi) * xi
        db = (y_hat - yi)

        w -= lr * dw
        b -= lr * db

loss = mse_loss(x, y, w, b)
print("SGD:", w, b, loss)

3. ミニバッチ勾配降下法

小さなデータ集合(ミニバッチ)ごとに勾配を計算して更新する。

w, b = 0.0, 0.0
lr = 0.1
epochs = 50
batch_size = 10

for epoch in range(epochs):
    idx = np.random.permutation(N)
    x_shuffled = x[idx]
    y_shuffled = y[idx]

    for i in range(0, N, batch_size):
        xb = x_shuffled[i:i+batch_size]
        yb = y_shuffled[i:i+batch_size]

        dw, db = gradients(xb, yb, w, b)
        w -= lr * dw
        b -= lr * db

loss = mse_loss(x, y, w, b)
print("Mini-batch GD:", w, b, loss)

勾配降下法_1

勾配降下法_2

3 手法はいずれも同じ誤差関数・同じモデルを用いているが、 勾配の計算に使うデータの単位が異なる 点が本質的な違いである。

  • 勾配降下法
    • 勾配は全データの平均として計算される
  • 確率的勾配降下法
    • 1サンプルごとに勾配を計算し、更新が頻繁に行われる
  • ミニバッチ勾配降下法
    • 小規模なデータ集合で勾配を計算し、計算効率と安定性のバランスを取る

確認テスト

該当するソースコードを探してみよう

  • ① パラメータ更新式(勾配降下法)

$$ \boldsymbol{w}^{(t+1)} = \boldsymbol{w}^{(t)} - \varepsilon \nabla E $$

network[key]  -= learning_rate * grad[key]
  • ② 勾配の定義

$$ \nabla E = \frac{\partial E}{\partial \boldsymbol{w}} = \left[ \frac{\partial E}{\partial w_1}, \ldots, \frac{\partial E}{\partial w_M} \right] $$

grad = backward(x, d, z1, y)

オンライン学習とは何か。2 行でまとめよ

オンライン学習とは、データが逐次与えられるたびに、1 サンプルまたは少数サンプルを用いてモデルのパラメータを更新する学習方法である。

全データを一度に用いず、リアルタイム性や大規模データへの適応に適している。

この数式の意味を図に書いて説明せよ

$$ \boldsymbol{w}^{(t+1)} = \boldsymbol{w}^{(t)} - \varepsilon \nabla E_t $$

import numpy as np
import matplotlib.pyplot as plt

# 損失関数(例:2次関数)
def E(w):
    return (w - 2)**2

# 勾配
def grad_E(w):
    return 2 * (w - 2)

# 現在の重み
w_t = 4.0
lr = 0.2

# ミニバッチで計算された勾配(ここでは代表値)
grad = grad_E(w_t)

# 更新後
w_next = w_t - lr * grad

# 描画用
w = np.linspace(-1, 5, 200)

plt.figure()
plt.plot(w, E(w), label="Loss function E(w)")
plt.scatter(w_t, E(w_t), color="red", label="w(t)")
plt.scatter(w_next, E(w_next), color="blue", label="w(t+1)")
plt.arrow(
    w_t, E(w_t),
    w_next - w_t, E(w_next) - E(w_t),
    length_includes_head=True,
    head_width=0.2,
    color="black"
)

plt.xlabel("w")
plt.ylabel("E(w)")
plt.legend()
plt.grid(True)
plt.show()

勾配降下法 - この数式の意味を図に書いて説明せよ

現在のパラメータ $\boldsymbol{w}^{(t)}$ において、ミニバッチから計算した勾配 $\nabla E_t$ は、誤差が最も増加する方向を示す。

勾配降下法では、その逆方向に学習率 $\varepsilon$ だけ移動することで、誤差を減少させる。

この操作を繰り返すことで、誤差が最小となるパラメータへと更新される。

参考図書 / 関連記事

勾配降下法を基礎として、他に提案される代表的な最適化手法について、その更新式の形を簡単に整理する。

モメンタム(Momentum)

モメンタム法は、現在の勾配だけでなく、過去の更新方向を考慮してパラメータを更新する手法である。 速度ベクトル (\boldsymbol{v}) を導入し、更新は次式で与えられる。

$$ \boldsymbol{v}_{t+1} = \alpha \boldsymbol{v}_t - \eta \nabla E_t $$

$$ \boldsymbol{w}_{t+1} = \boldsymbol{w}_t + \boldsymbol{v}_{t+1} $$

ここで $\alpha$ はモメンタム係数である。

過去の勾配情報を蓄積することで、更新方向のばらつきを抑える効果がある。

AdaGrad

AdaGrad は、各パラメータごとに学習率を調整する最適化手法である。

過去の勾配の二乗和を用いて更新量を制御する。

$$ \boldsymbol{h}_{t+1} = \boldsymbol{h}_t + (\nabla E_t)^2 $$

$$ \boldsymbol{w}_{t+1} = \boldsymbol{w}_t - \eta \frac{\nabla E_t}{\sqrt{\boldsymbol{h}_{t+1}} + \varepsilon} $$

頻繁に更新されるパラメータほど学習率が小さくなる特徴を持つ。

RMSProp

RMSProp は、AdaGrad において学習率が急激に小さくなる問題を緩和するために提案された手法である。

勾配の二乗和を指数移動平均として蓄積する。

$$ \boldsymbol{h}_{t+1} = \rho \boldsymbol{h}_t + (1 - \rho)(\nabla E_t)^2 $$

$$ \boldsymbol{w}_{t+1} = \boldsymbol{w}_t - \eta \frac{\nabla E_t}{\sqrt{\boldsymbol{h}_{t+1}} + \varepsilon} $$

これにより、学習率の減少が緩やかになり、安定した学習が可能となる。

Adam(Adaptive Moment Estimation)

Adam は、モメンタムと RMSProp の考え方を組み合わせた最適化手法である。

勾配の一次モーメントと二次モーメントの両方を推定し、それを用いて更新を行う。

$$ \boldsymbol{m}_{t+1} = \beta_1 \boldsymbol{m}_t + (1 - \beta_1)\nabla E_t $$

$$ \boldsymbol{v}_{t+1} = \beta_2 \boldsymbol{v}_t + (1 - \beta_2)(\nabla E_t)^2 $$

これらを補正した値を用いてパラメータを更新することで、学習の安定性と収束速度の両立を図る。


5. 誤差逆伝播法

前項で見た勾配降下法では、誤差関数の勾配 $\frac{\partial E}{\partial \boldsymbol{w}}$ が必要となるが、ニューラルネットワークは多層構造であるため、単純に偏微分を展開すると計算が煩雑かつ非効率になる。

誤差逆伝播法は、連鎖律(chain rule) を用いて勾配を効率的に計算する手法である。

5.1 誤差逆伝播法の目的

誤差逆伝播法の目的は、誤差関数 $E$ を各パラメータ(重み・バイアス)で偏微分した勾配を求めることである。

$$ \frac{\partial E}{\partial W},\ \frac{\partial E}{\partial b} $$

これらが求まれば、勾配降下法によりパラメータ更新が可能となる。

5.2 計算グラフと連鎖律

ニューラルネットワークの計算は、線形結合や活性化関数などの演算を順に行う計算過程であり、計算グラフとして表現できる。

誤差逆伝播法では、順伝播で得られた中間結果を利用しつつ、出力側から入力側へ向かって勾配を伝播させる。

連鎖律は、合成関数

$$ E = E(y),\quad y = f(u),\quad u = g(w) $$

に対して、

$$ \frac{\partial E}{\partial w} = \frac{\partial E}{\partial y} \frac{\partial y}{\partial u} \frac{\partial u}{\partial w} $$

と分解できることを意味する。

誤差逆伝播法は、この分解を層ごとに適用し、同じ計算を繰り返さないようにすることで計算量を抑える。

5.3 出力層からの誤差の伝播

出力層では、出力 $y$ と教師信号 $t$ から誤差関数 $E$ が定義される。

誤差逆伝播ではまず $\frac{\partial E}{\partial y}$ を求め、そこから前段の変数へと勾配を伝える。

例として二乗誤差

$$ E = \frac{1}{2}\sum_{j=1}^{J}(y_j - t_j)^2 $$

を考えると、

$$ \frac{\partial E}{\partial y_j} = y_j - t_j $$

となる。

この $(y_j - t_j)$ が「出力誤差」として逆伝播の起点となる。

(※交差エントロピー+ソフトマックスの場合は計算が簡単化されるが、ここでは逆伝播の一般形を重視し、詳細は実装演習で扱う。)

5.4 1 層(全結合層)における勾配

全結合層の順伝播を

$$ u = XW + b,\quad z = f(u) $$

とする。ここで、$X$:入力、$W$:重み、$b$:バイアス、$f$:活性化関数、である。

このとき、上流から伝わる勾配を $\delta = \frac{\partial E}{\partial u}$ とおくと、パラメータ勾配は次の形になる。

$$ \frac{\partial E}{\partial W} = X^\top \delta,\quad \frac{\partial E}{\partial b} = \sum \delta $$

また、1つ前の層へ誤差を伝えるには、

$$ \frac{\partial E}{\partial X} = \delta W^\top $$

を用いる。

ここで重要なのは、順伝播で計算した $X$ や $u$ を保持しておけば、逆伝播時にそれらを再利用できる点である。これにより、多層のネットワークであっても効率よく勾配を計算できる。

5.5 活性化関数の微分(例)

逆伝播では活性化関数の微分が必要になる。例えば ReLU は

$$ f(u) = \max(0, u) $$

であり、導関数は

$$ f'(u) = \begin{cases} 1 & (u > 0) \\ 0 & (u \le 0) \end{cases} $$

となる。 このように、各層で $f'(u)$ を掛け合わせることで勾配が伝播する。

実装例

配布されたコードを参考に、誤差逆伝播法により学習ループでパラメータが更新されるところを確認するコードを作成する。

問題設定

題材は分かりやすくするために、

  • 2 層の全結合 NN(入力→隠れ→出力)
  • 隠れ層:ReLU
  • 出力層:Softmax
  • 損失:交差エントロピー
  • ミニバッチ学習(SGDでも回せる)

で、順伝播→損失→逆伝播(勾配)→更新 を確認できるようにします。

活性化関数・損失関数

import numpy as np

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

def relu_grad(x):
    # ReLUの導関数:x>0なら1、それ以外0
    return (x > 0).astype(x.dtype)

def softmax(x):
    # x: (batch, K)
    x = x - np.max(x, axis=1, keepdims=True)  # 数値安定化
    ex = np.exp(x)
    return ex / np.sum(ex, axis=1, keepdims=True)

def cross_entropy(y, t, eps=1e-12):
    """
    y: (batch, K) softmax出力(確率)
    t: (batch, K) one-hot
    """
    y = np.clip(y, eps, 1.0)
    return -np.mean(np.sum(t * np.log(y), axis=1))

データ生成

def make_3class_data(N_per_class=100, seed=0):
    rng = np.random.default_rng(seed)
    centers = np.array([
        [-1.0,  0.0],
        [ 1.0,  0.0],
        [ 0.0,  1.2],
    ])
    X_list, y_list = [], []
    for k in range(3):
        Xk = centers[k] + 0.4 * rng.normal(size=(N_per_class, 2))
        yk = np.full((N_per_class,), k, dtype=int)
        X_list.append(Xk)
        y_list.append(yk)
    X = np.vstack(X_list)          # (N, 2)
    y = np.concatenate(y_list)     # (N,)
    return X, y

def one_hot(y, K):
    t = np.zeros((y.size, K), dtype=float)
    t[np.arange(y.size), y] = 1.0
    return t

モデル(2 層全結合)

def init_params(D, H, K, seed=0):
    rng = np.random.default_rng(seed)
    # 小さい乱数で初期化(講義の意図:勾配計算の確認が主)
    W1 = rng.normal(0.0, 0.1, size=(D, H))
    b1 = np.zeros((H,))
    W2 = rng.normal(0.0, 0.1, size=(H, K))
    b2 = np.zeros((K,))
    return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}

def forward(X, params):
    """
    X: (batch, D)
    """
    W1, b1 = params["W1"], params["b1"]
    W2, b2 = params["W2"], params["b2"]

    Z1 = X @ W1 + b1      # (batch, H)
    A1 = relu(Z1)         # (batch, H)
    Z2 = A1 @ W2 + b2     # (batch, K)
    Y  = softmax(Z2)      # (batch, K)

    cache = {"X": X, "Z1": Z1, "A1": A1, "Z2": Z2, "Y": Y}
    return Y, cache

def backward(t, params, cache):
    """
    t: (batch, K) one-hot
    交差エントロピー + softmax の組で、dZ2 = (Y - t) / batch
    """
    W2 = params["W2"]
    X, Z1, A1, Y = cache["X"], cache["Z1"], cache["A1"], cache["Y"]
    B = X.shape[0]

    # 出力層:softmax+CE の簡約形
    dZ2 = (Y - t) / B               # (batch, K)

    dW2 = A1.T @ dZ2                # (H, K)
    db2 = np.sum(dZ2, axis=0)       # (K,)

    dA1 = dZ2 @ W2.T                # (batch, H)
    dZ1 = dA1 * relu_grad(Z1)       # (batch, H)

    dW1 = X.T @ dZ1                 # (D, H)
    db1 = np.sum(dZ1, axis=0)       # (H,)

    grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
    return grads

def update(params, grads, lr):
    params["W1"] -= lr * grads["dW1"]
    params["b1"] -= lr * grads["db1"]
    params["W2"] -= lr * grads["dW2"]
    params["b2"] -= lr * grads["db2"]

学習(ミニバッチ)

X, y = make_3class_data(N_per_class=120, seed=42)
K = 3
t_all = one_hot(y, K)

D = X.shape[1]
H = 10
params = init_params(D, H, K, seed=1)

lr = 0.5
epochs = 30
batch_size = 32

rng = np.random.default_rng(0)

for epoch in range(1, epochs + 1):
    idx = rng.permutation(X.shape[0])
    Xs = X[idx]
    ts = t_all[idx]

    # ミニバッチ学習
    for i in range(0, Xs.shape[0], batch_size):
        Xb = Xs[i:i+batch_size]
        tb = ts[i:i+batch_size]

        yb, cache = forward(Xb, params)
        grads = backward(tb, params, cache)
        update(params, grads, lr)

    # 進捗(全体損失・精度)
    y_all, _ = forward(X, params)
    loss = cross_entropy(y_all, t_all)
    pred = np.argmax(y_all, axis=1)
    acc = np.mean(pred == y)

    if epoch % 5 == 0 or epoch == 1:
        print(f"epoch={epoch:02d} loss={loss:.4f} acc={acc:.3f}")

print("Done.")

誤差逆伝播法

  • 順伝播:

$$ \begin{align*} & Z_1=XW_1+b_1 \\ & \rightarrow A_1=\mathrm{ReLU}(Z_1) \\ & \rightarrow Z_2=A_1W_2+b_2 \\ & \rightarrow Y=\mathrm{softmax}(Z_2) \end{align*} $$

  • 逆伝播:

Softmax+交差エントロピーの簡約形 として、

$$ dZ_2=(Y-T)/B $$

を起点に、連鎖律で $dW_2,db_2,dW_1,db_1$ を計算する。

  • 更新:

$$ W \leftarrow W-\eta \nabla E $$

確認テスト

誤差逆伝播法では不要な再帰的処理を避ける事が出来る。既に行った計算結果を保持しているソースコードを抽出せよ。

    # 出力層でのデルタ
    delta2 = functions.d_mean_squared_error(d, y)
    # b2の勾配
    grad['b2'] = np.sum(delta2, axis=0)
    # W2の勾配
    grad['W2'] = np.dot(z1.T, delta2)
    # 中間層でのデルタ
    #delta1 = np.dot(delta2, W2.T) * functions.d_relu(z1)

    ## 試してみよう
    delta1 = np.dot(delta2, W2.T) * functions.d_sigmoid(z1)

    delta1 = delta1[np.newaxis, :]
    # b1の勾配
    grad['b1'] = np.sum(delta1, axis=0)
    x = x[np.newaxis, :]
    # W1の勾配
    grad['W1'] = np.dot(x.T, delta1)

「保持」という表現はやや分かりにくいが、誤差逆伝播法では一度計算した誤差に関する偏微分を次の計算に利用することで、同じ微分計算を繰り返さない点が重要である。

2 つの空欄に該当するソースコードを探せ

$$ \frac{\partial E}{\partial y} \frac{\partial y}{\partial u} $$

delta2 = functions.d_mean_squared_error(d, y)

二乗誤差の微分により $\frac{\partial E}{\partial y}$ を求め、活性化関数を含めた出力層のデルタ $\delta_2 = \frac{\partial E}{\partial u}$ を計算している。

$$ \frac{\partial E}{\partial y} \frac{\partial y}{\partial u} \frac{\partial u}{\partial w^{(2)}_{ji}} $$

grad['W2'] = np.dot(z1.T, delta2)

出力層のデルタ $\delta_2$ に対して、$\frac{\partial u}{\partial W^{(2)}} = z_1$ を用いることで、 重み $W^{(2)}$ に関する勾配を計算している。

参考図書 / 関連記事

記事にあるような計算グラフを NetworkX で書いてみる。

import networkx as nx
import matplotlib.pyplot as plt

# グラフ定義
G = nx.DiGraph()

# ノード
nodes = [
    "x1", "w1", "x2", "w2", "w3",
    "m1\n(x1*w1)", "m2\n(x2*w2)",
    "s\n(m1+m2)", "y\n(s*w3)"
]
G.add_nodes_from(nodes)

# 順伝播(値の流れ)
forward_edges = [
    ("x1", "m1\n(x1*w1)"),
    ("w1", "m1\n(x1*w1)"),
    ("x2", "m2\n(x2*w2)"),
    ("w2", "m2\n(x2*w2)"),
    ("m1\n(x1*w1)", "s\n(m1+m2)"),
    ("m2\n(x2*w2)", "s\n(m1+m2)"),
    ("s\n(m1+m2)", "y\n(s*w3)"),
    ("w3", "y\n(s*w3)")
]
G.add_edges_from(forward_edges)

# 逆伝播(勾配の流れ:出力→入力)
backward_edges = [
    ("y\n(s*w3)", "s\n(m1+m2)"),
    ("y\n(s*w3)", "w3"),
    ("s\n(m1+m2)", "m1\n(x1*w1)"),
    ("s\n(m1+m2)", "m2\n(x2*w2)"),
    ("m1\n(x1*w1)", "x1"),
    ("m1\n(x1*w1)", "w1"),
    ("m2\n(x2*w2)", "x2"),
    ("m2\n(x2*w2)", "w2")
]

# 座標(左→右)
pos = {
    "x1": (0, 1.0), "w1": (0, 0.2),
    "x2": (0, -0.6), "w2": (0, -1.4),
    "m1\n(x1*w1)": (1, 0.6),
    "m2\n(x2*w2)": (1, -1.0),
    "s\n(m1+m2)": (2, -0.2),
    "w3": (2, -1.4),
    "y\n(s*w3)": (3, -0.2),
}

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

# ノード
nx.draw_networkx_nodes(G, pos, node_size=1800)

# 順伝播(黒・直線)
nx.draw_networkx_edges(
    G, pos,
    edgelist=forward_edges,
    arrows=True,
    arrowstyle="-|>",
    arrowsize=25,
    edge_color="black",
    width=2,
    connectionstyle="arc3,rad=0.0",
    min_source_margin=15,
    min_target_margin=15,
)

# 逆伝播(赤・曲線)
nx.draw_networkx_edges(
    G, pos,
    edgelist=backward_edges,
    arrows=True,
    arrowstyle="-|>",
    arrowsize=25,
    edge_color="red",
    width=2,
    style="dashed",
    connectionstyle="arc3,rad=0.25",
    min_source_margin=15,
    min_target_margin=15,
)

# ラベル
nx.draw_networkx_labels(G, pos, font_size=10)

ys = [p[1] for p in pos.values()]
ymin, ymax = min(ys), max(ys)
plt.ylim(ymin - 0.5, ymax + 0.5)

plt.title("Computation graph with forward (black) and backward (red) flows")
plt.axis("off")
plt.show()

誤差逆伝播法 - 参考図書 / 関連記事

参考記事では、

$$ y = (x_1 w_1 + x_2 w_2) w_3 $$

という計算を例に、計算グラフを用いて誤差逆伝播法を説明している。

計算グラフでは、各演算(積・和)をノードとして表し、左から右へ値が伝播することで出力が計算される(順伝播)。

黒矢印は順伝播(値の流れ)、赤の破線矢印は逆伝播(誤差勾配の流れ)を表している。

逆伝播では、出力側から入力側へ向かって偏微分が伝播していることが分かる。

特に、加算ノード $s = m_1 + m_2$ においては、出力に関する偏微分がそのまま両入力に分配されるため、出力側で計算した偏微分結果を $w_1$ と $w_2$ の勾配計算で共通に利用できる。

このように、誤差逆伝播法では一度計算した偏微分を再利用することで、同じ微分計算を繰り返す必要がなくなり、計算量を大幅に削減できる。

この考え方は、多層ニューラルネットワークにおいても同様であり、計算グラフと連鎖律を組み合わせることで、効率的な勾配計算が可能となる。

ラビット・チャレンジ - Stage 3. 深層学習 前編 (Day 1)

提出したレポートです。 絶対書きすぎですが、行間を埋めたくなるので仕方ない。 Rabbit Challenge - Stage 3. 深層学習 前編 (Day 1) 0. 深層学習とは何か この講義(Day1)の内容では、ニューラルネットワークを用いた学習方法として、順...