誤差逆伝播法の理論と実装 — 計算グラフで理解する

ニューラルネットワークを学習させるとき、「損失関数を最小にするように重みを調整する」と言います。しかし、数千〜数億個のパラメータに対して、それぞれの勾配をどうやって効率的に計算するのでしょうか。各パラメータを微小量ずらして損失の変化を観測する「数値微分」では、パラメータ数に比例した計算コストがかかり、現代のネットワークではまったく実用的ではありません。

この問題を解決するのが誤差逆伝播法(backpropagation, 略してバックプロップ)です。1986年にルメルハート(Rumelhart)、ヒントン(Hinton)、ウィリアムズ(Williams)が発表した論文で広く知られるようになりました。逆伝播法を使えば、順伝播1回分とほぼ同じ計算コストで全てのパラメータの勾配を同時に計算できます。

誤差逆伝播法を理解すると、以下の応用や発展に進めます。

  • 深層学習フレームワークの理解: PyTorchやTensorFlowの自動微分(Autograd)は逆伝播法の一般化であり、内部動作を理解できる
  • カスタム層の設計: 独自の活性化関数や損失関数を設計するとき、勾配の導出が必要
  • 学習の問題診断: 勾配消失・勾配爆発の原因を理論的に理解し、対策を講じる
  • メタ学習・2次最適化: MAML等のメタ学習アルゴリズムは、勾配の勾配(2次微分)を必要とする

本記事の内容

  • 計算グラフの基本と連鎖律の可視化
  • 多層ネットワークでの順伝播と逆伝播の導出
  • 各層の勾配の行列表現
  • 数値勾配によるグラディエントチェック
  • Pythonによるスクラッチ実装と学習実験

前提知識

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

計算グラフとは

合成関数を「グラフ」で表す

誤差逆伝播法を理解する最も明快な方法は、計算グラフ(computational graph)を使うことです。

計算グラフとは、数式の計算過程をノード(演算)とエッジ(データの流れ)で表現した有向グラフです。日常的な例で考えてみましょう。

スーパーで りんご $a = 2$ 個を1個あたり $p = 100$ 円で買い、消費税率 $r = 1.1$ をかけるとします。支払金額は

$$ y = a \times p \times r = 2 \times 100 \times 1.1 = 220 \text{ 円} $$

この計算を分解すると、まず「りんごの個数 $\times$ 単価」を計算し($q = a \times p = 200$)、次に「小計 $\times$ 税率」を計算します($y = q \times r = 220$)。

計算グラフの各ノードは1つの演算(加算、乗算、活性化関数の適用など)を表し、入力から出力に向かう順方向の計算が順伝播です。

なぜ計算グラフが便利なのか

計算グラフの真価は、逆方向に辿ると勾配が計算できることにあります。

先ほどの買い物の例で、「りんごの個数 $a$ が1個増えたら支払金額 $y$ はいくら変わるか」を知りたいとします。つまり $\partial y / \partial a$ を求めたいのです。

連鎖律を適用すると

$$ \frac{\partial y}{\partial a} = \frac{\partial y}{\partial q} \cdot \frac{\partial q}{\partial a} = r \times p = 1.1 \times 100 = 110 $$

この計算は、出力側 $y$ から入力側 $a$ に向かって「逆方向」に各局所勾配を掛け合わせていくことで実行されます。これが逆伝播の本質です。

各ノードでは、順伝播で計算した値を保存しておき、逆伝播ではそれを使って局所勾配を計算します。ノードが行う仕事は「上流から流れてきた勾配 $\times$ 局所勾配」を下流に流すだけです。

計算グラフの考え方を理解したところで、次にニューラルネットワークの具体的な演算ノードについて局所勾配を求めていきましょう。

基本演算の局所勾配

加算ノード

$z = x + y$ のとき

$$ \frac{\partial z}{\partial x} = 1, \quad \frac{\partial z}{\partial y} = 1 $$

加算ノードは上流からの勾配をそのまま両方の入力に流します。これは「勾配の分配」(gradient distributor)と呼ばれます。

乗算ノード

$z = x \times y$ のとき

$$ \frac{\partial z}{\partial x} = y, \quad \frac{\partial z}{\partial y} = x $$

乗算ノードは上流の勾配に「相手方の入力値」を掛けて流します。これは「勾配の交換」(gradient switcher)と呼ばれます。乗算の一方の入力が0に近いと、他方への勾配も0に近くなることに注意が必要です。

シグモイド関数

$y = \sigma(x) = \frac{1}{1 + e^{-x}}$ のとき

$$ \frac{\partial y}{\partial x} = \sigma(x)(1 – \sigma(x)) = y(1 – y) $$

シグモイドの導関数は出力 $y$ のみで表せるため、順伝播の出力を保存しておけば逆伝播で効率的に計算できます。$y = 0.5$ のとき最大値 $0.25$ をとり、$y \to 0$ または $y \to 1$ のとき $0$ に近づきます。

ReLU関数

$y = \max(0, x)$ のとき

$$ \frac{\partial y}{\partial x} = \begin{cases} 1 & (x > 0) \\ 0 & (x \leq 0) \end{cases} $$

ReLUの逆伝播は、$x > 0$ なら勾配をそのまま通し、$x \leq 0$ なら勾配を遮断します。これは「勾配のゲート」の役割を果たします。$x = 0$ での微分は厳密には存在しませんが、実装上は0として扱います。

アフィン変換(行列積 + バイアス)

$\bm{z} = \bm{W}\bm{x} + \bm{b}$ のとき($\bm{W} \in \mathbb{R}^{m \times n}$, $\bm{x} \in \mathbb{R}^n$, $\bm{b} \in \mathbb{R}^m$)

上流から $\frac{\partial L}{\partial \bm{z}} \in \mathbb{R}^m$ が流れてくるとき

$$ \begin{align} \frac{\partial L}{\partial \bm{x}} &= \bm{W}^\top \frac{\partial L}{\partial \bm{z}} \\ \frac{\partial L}{\partial \bm{W}} &= \frac{\partial L}{\partial \bm{z}} \bm{x}^\top \\ \frac{\partial L}{\partial \bm{b}} &= \frac{\partial L}{\partial \bm{z}} \end{align} $$

$\frac{\partial L}{\partial \bm{x}}$ は前の層への逆伝播に使われ、$\frac{\partial L}{\partial \bm{W}}$ と $\frac{\partial L}{\partial \bm{b}}$ はパラメータ更新に使われます。

これらの局所勾配の部品を組み合わせて、多層ネットワーク全体の逆伝播を構成していきましょう。

多層ネットワークの逆伝播

問題設定

$L$ 層のニューラルネットワークを考えます。入力 $\bm{x}$、正解 $\bm{t}$ に対して、以下の計算が行われます。

順伝播($l = 1, 2, \ldots, L$):

$$ \begin{align} \bm{z}^{(l)} &= \bm{W}^{(l)} \bm{h}^{(l-1)} + \bm{b}^{(l)} \\ \bm{h}^{(l)} &= \sigma_l(\bm{z}^{(l)}) \end{align} $$

ここで $\bm{h}^{(0)} = \bm{x}$、$\hat{\bm{y}} = \bm{h}^{(L)}$ です。損失関数 $\mathcal{L}(\hat{\bm{y}}, \bm{t})$ を最小化するために、各 $\bm{W}^{(l)}$ と $\bm{b}^{(l)}$ の勾配が必要です。

出力層の勾配

逆伝播は出力層から始まります。まず、損失関数の出力層プレアクティベーションに対する勾配 $\bm{\delta}^{(L)} = \frac{\partial \mathcal{L}}{\partial \bm{z}^{(L)}}$ を計算します。

連鎖律を適用すると

$$ \bm{\delta}^{(L)} = \frac{\partial \mathcal{L}}{\partial \bm{z}^{(L)}} = \frac{\partial \mathcal{L}}{\partial \hat{\bm{y}}} \odot \sigma_L'(\bm{z}^{(L)}) $$

二値分類(シグモイド出力 + 交差エントロピー損失)の場合、この計算は特に簡潔になります。

$$ \mathcal{L} = -[t \ln \hat{y} + (1-t) \ln(1-\hat{y})] $$

$\hat{y} = \sigma(z)$ を代入して $z$ で微分すると

$$ \frac{\partial \mathcal{L}}{\partial z} = \hat{y} – t $$

シグモイドの導関数 $\sigma’ = \sigma(1-\sigma)$ と交差エントロピーの導関数 $\frac{\partial \mathcal{L}}{\partial \hat{y}} = \frac{\hat{y} – t}{\hat{y}(1-\hat{y})}$ が掛け合わされて、きれいに $\hat{y} – t$ となるのです。

多クラス分類(ソフトマックス出力 + 交差エントロピー損失)の場合も同様に

$$ \bm{\delta}^{(L)} = \hat{\bm{y}} – \bm{t} $$

となります。予測と正解の差が誤差信号になるという直感的に自然な結果です。

隠れ層の勾配(逆伝播の再帰式)

出力層の誤差信号 $\bm{\delta}^{(L)}$ が計算できたら、隠れ層の誤差信号を逆方向に伝播させます。

$l$ 層目の誤差信号は、$(l+1)$ 層目の誤差信号から次のように計算されます。

$$ \begin{equation} \bm{\delta}^{(l)} = \left((\bm{W}^{(l+1)})^\top \bm{\delta}^{(l+1)}\right) \odot \sigma_l'(\bm{z}^{(l)}) \end{equation} $$

この式の直感を考えましょう。

$(\bm{W}^{(l+1)})^\top \bm{\delta}^{(l+1)}$ は、$(l+1)$ 層目の誤差信号を重み行列の転置で「逆方向に」伝播させています。順伝播で $\bm{z}^{(l+1)} = \bm{W}^{(l+1)} \bm{h}^{(l)} + \bm{b}^{(l+1)}$ と計算したので、$\bm{h}^{(l)}$ に対する勾配は $(\bm{W}^{(l+1)})^\top$ を掛けることで得られます。

$\odot \sigma_l'(\bm{z}^{(l)})$ は、$l$ 層目の活性化関数の「ゲート」効果です。活性化関数の勾配が0に近い領域では誤差信号が減衰し、1に近い領域ではそのまま通過します。

パラメータの勾配

各層の誤差信号 $\bm{\delta}^{(l)}$ が計算できたら、パラメータの勾配は次のように求められます。

$$ \begin{align} \frac{\partial \mathcal{L}}{\partial \bm{W}^{(l)}} &= \bm{\delta}^{(l)} (\bm{h}^{(l-1)})^\top \\ \frac{\partial \mathcal{L}}{\partial \bm{b}^{(l)}} &= \bm{\delta}^{(l)} \end{align} $$

重みの勾配は「誤差信号 $\times$ 入力の転置」です。これは、誤差が大きく($\bm{\delta}^{(l)}$ が大きい)、かつ入力が大きい($\bm{h}^{(l-1)}$ が大きい)重みほど大きく更新されることを意味します。

ミニバッチでの計算

$B$ 個のサンプルからなるミニバッチの場合、各サンプルの勾配の平均をとります。データ行列 $\bm{H}^{(l-1)} \in \mathbb{R}^{n_{l-1} \times B}$ と誤差信号行列 $\bm{\Delta}^{(l)} \in \mathbb{R}^{n_l \times B}$ を使うと

$$ \begin{align} \frac{\partial J}{\partial \bm{W}^{(l)}} &= \frac{1}{B} \bm{\Delta}^{(l)} (\bm{H}^{(l-1)})^\top \\ \frac{\partial J}{\partial \bm{b}^{(l)}} &= \frac{1}{B} \sum_{i=1}^{B} \bm{\delta}_i^{(l)} \end{align} $$

行列演算としてまとめることで、NumPyやGPUでの高速な並列計算が可能になります。

逆伝播アルゴリズムの全体像

ここまでの結果を整理して、逆伝播アルゴリズムの全体像をまとめます。

ステップ1: 順伝播

入力 $\bm{x}$ から出力 $\hat{\bm{y}}$ まで計算し、途中の $\bm{z}^{(l)}$ と $\bm{h}^{(l)}$ を保存します。

ステップ2: 出力層の誤差信号

$$ \bm{\delta}^{(L)} = \hat{\bm{y}} – \bm{t} $$

ステップ3: 逆伝播($l = L-1, L-2, \ldots, 1$)

$$ \bm{\delta}^{(l)} = \left((\bm{W}^{(l+1)})^\top \bm{\delta}^{(l+1)}\right) \odot \sigma_l'(\bm{z}^{(l)}) $$

ステップ4: パラメータ勾配の計算($l = 1, 2, \ldots, L$)

$$ \frac{\partial \mathcal{L}}{\partial \bm{W}^{(l)}} = \bm{\delta}^{(l)} (\bm{h}^{(l-1)})^\top, \quad \frac{\partial \mathcal{L}}{\partial \bm{b}^{(l)}} = \bm{\delta}^{(l)} $$

ステップ5: パラメータ更新

$$ \bm{W}^{(l)} \leftarrow \bm{W}^{(l)} – \eta \frac{\partial \mathcal{L}}{\partial \bm{W}^{(l)}}, \quad \bm{b}^{(l)} \leftarrow \bm{b}^{(l)} – \eta \frac{\partial \mathcal{L}}{\partial \bm{b}^{(l)}} $$

計算量は、順伝播とほぼ同じオーダー $O(\sum_l n_l \times n_{l-1})$ です。パラメータ数が $p$ 個あっても勾配計算は1回の逆伝播で完了するため、数値微分の $O(p)$ 倍と比べて圧倒的に効率的です。

逆伝播の理論を導出できたので、次にこの勾配が正しいことを検証する方法を紹介します。

グラディエントチェック

数値勾配との比較

逆伝播の実装にバグがないことを確認するために、数値勾配(numerical gradient)と比較する方法がよく使われます。

パラメータ $\theta$ に対する数値勾配は、中心差分公式で計算します。

$$ \frac{\partial J}{\partial \theta} \approx \frac{J(\theta + \epsilon) – J(\theta – \epsilon)}{2\epsilon} $$

$\epsilon = 10^{-5}$ 程度を使います。前方差分 $(J(\theta + \epsilon) – J(\theta))/\epsilon$ よりも、中心差分の方が誤差が $O(\epsilon^2)$ と小さくなります。

相対誤差で評価

解析勾配(逆伝播で計算した勾配)を $g_a$、数値勾配を $g_n$ とすると、相対誤差は

$$ \text{relative error} = \frac{|g_a – g_n|}{\max(|g_a|, |g_n|) + \epsilon’} $$

で評価します($\epsilon’ = 10^{-8}$ は0除算防止の小さな定数)。

相対誤差の目安は以下の通りです。

相対誤差 判定
$< 10^{-7}$ 非常に良好
$10^{-7} \sim 10^{-5}$ 問題なし
$10^{-5} \sim 10^{-3}$ 要注意(活性化関数の kink 付近でない限り)
$> 10^{-3}$ バグの可能性が高い
import numpy as np

np.random.seed(42)

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(a):
    return a * (1 - a)

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

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

def compute_loss(y_pred, t):
    """二値交差エントロピー損失"""
    eps = 1e-8
    return -np.mean(t * np.log(y_pred + eps)
                    + (1 - t) * np.log(1 - y_pred + eps))

# ネットワークのパラメータ
input_dim, hidden_dim, output_dim = 3, 4, 1
W1 = np.random.randn(hidden_dim, input_dim) * 0.5
b1 = np.zeros((hidden_dim, 1))
W2 = np.random.randn(output_dim, hidden_dim) * 0.5
b2 = np.zeros((output_dim, 1))

# テストデータ
X = np.random.randn(input_dim, 5)
t = np.array([[1, 0, 1, 0, 1]])

# --- 解析勾配(逆伝播) ---
# 順伝播
z1 = W1 @ X + b1
h1 = sigmoid(z1)
z2 = W2 @ h1 + b2
h2 = sigmoid(z2)

# 逆伝播
m = X.shape[1]
delta2 = h2 - t
dW2_analytical = (1/m) * delta2 @ h1.T
db2_analytical = (1/m) * np.sum(delta2, axis=1, keepdims=True)
delta1 = (W2.T @ delta2) * sigmoid_derivative(h1)
dW1_analytical = (1/m) * delta1 @ X.T
db1_analytical = (1/m) * np.sum(delta1, axis=1, keepdims=True)

# --- 数値勾配 ---
epsilon = 1e-5

def forward_loss(W1, b1, W2, b2, X, t):
    z1 = W1 @ X + b1
    h1 = sigmoid(z1)
    z2 = W2 @ h1 + b2
    h2 = sigmoid(z2)
    return compute_loss(h2, t)

def numerical_gradient(param, param_name, W1, b1, W2, b2, X, t):
    """中心差分による数値勾配"""
    grad = np.zeros_like(param)
    it = np.nditer(param, flags=['multi_index'])
    while not it.finished:
        idx = it.multi_index
        old_val = param[idx]

        param[idx] = old_val + epsilon
        loss_plus = forward_loss(W1, b1, W2, b2, X, t)

        param[idx] = old_val - epsilon
        loss_minus = forward_loss(W1, b1, W2, b2, X, t)

        grad[idx] = (loss_plus - loss_minus) / (2 * epsilon)
        param[idx] = old_val
        it.iternext()
    return grad

# 各パラメータの数値勾配を計算
dW1_numerical = numerical_gradient(W1, "W1", W1, b1, W2, b2, X, t)
db1_numerical = numerical_gradient(b1, "b1", W1, b1, W2, b2, X, t)
dW2_numerical = numerical_gradient(W2, "W2", W1, b1, W2, b2, X, t)
db2_numerical = numerical_gradient(b2, "b2", W1, b1, W2, b2, X, t)

# 相対誤差の計算
def relative_error(analytical, numerical):
    eps = 1e-8
    return np.max(np.abs(analytical - numerical)
                  / (np.maximum(np.abs(analytical),
                                np.abs(numerical)) + eps))

print("グラディエントチェック結果:")
print(f"  dW1: 相対誤差 = {relative_error(dW1_analytical, dW1_numerical):.2e}")
print(f"  db1: 相対誤差 = {relative_error(db1_analytical, db1_numerical):.2e}")
print(f"  dW2: 相対誤差 = {relative_error(dW2_analytical, dW2_numerical):.2e}")
print(f"  db2: 相対誤差 = {relative_error(db2_analytical, db2_numerical):.2e}")

このコードの出力では、全てのパラメータの相対誤差が $10^{-7}$ 以下になっているはずです。これは、逆伝播で計算した解析勾配が数値勾配と一致していることを意味し、実装が正しいことの強い証拠です。グラディエントチェックは逆伝播の実装時に必ず行うべきデバッグ手法であり、PyTorchの torch.autograd.gradcheck もこの原理に基づいています。

グラディエントチェックで実装の正しさを確認できたので、次にモジュール化されたMLPを実装して、より実践的な問題に適用してみましょう。

Pythonでの実装 — モジュール化されたMLP

層をクラスとして実装する

計算グラフの考え方に基づいて、各層を「順伝播」と「逆伝播」のメソッドを持つクラスとして実装します。この設計はPyTorchの nn.Module の考え方に通じるものです。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

np.random.seed(42)

class Linear:
    """全結合層(アフィン変換)"""

    def __init__(self, in_features, out_features):
        # He初期化
        self.W = np.random.randn(out_features, in_features) * np.sqrt(2.0 / in_features)
        self.b = np.zeros((out_features, 1))
        self.dW = None
        self.db = None

    def forward(self, X):
        """順伝播: z = Wx + b"""
        self.X = X  # 逆伝播で使用
        return self.W @ X + self.b

    def backward(self, dout):
        """逆伝播: 上流の勾配doutから各勾配を計算"""
        m = self.X.shape[1]
        self.dW = (1/m) * dout @ self.X.T
        self.db = (1/m) * np.sum(dout, axis=1, keepdims=True)
        return self.W.T @ dout  # 前の層への勾配


class ReLU:
    """ReLU活性化関数"""

    def forward(self, Z):
        self.Z = Z
        return np.maximum(0, Z)

    def backward(self, dout):
        return dout * (self.Z > 0).astype(float)


class Sigmoid:
    """シグモイド活性化関数"""

    def forward(self, Z):
        self.out = 1 / (1 + np.exp(-np.clip(Z, -500, 500)))
        return self.out

    def backward(self, dout):
        return dout * self.out * (1 - self.out)


class BCELoss:
    """二値交差エントロピー損失"""

    def forward(self, y_pred, t):
        self.y_pred = y_pred
        self.t = t
        eps = 1e-8
        return -np.mean(t * np.log(y_pred + eps)
                        + (1 - t) * np.log(1 - y_pred + eps))

    def backward(self):
        # シグモイド + BCEの場合の簡約形
        return self.y_pred - self.t

各クラスは forwardbackward の2つのメソッドを持ち、計算グラフの1ノードに対応しています。forward では逆伝播に必要な中間値を保存し、backward では上流の勾配に局所勾配を掛けて下流に流します。

この設計の利点は、層を自由に組み合わせてネットワークを構築できることです。新しい活性化関数を追加するときも、forwardbackward を実装するだけで済みます。

次に、これらのモジュールを組み合わせて多層ネットワークを構築し、非線形な分類問題を解いてみます。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

np.random.seed(42)

# --- 層クラスの定義(前のコードブロックと同じ) ---
class Linear:
    def __init__(self, in_features, out_features):
        self.W = np.random.randn(out_features, in_features) * np.sqrt(2.0 / in_features)
        self.b = np.zeros((out_features, 1))
        self.dW = None
        self.db = None

    def forward(self, X):
        self.X = X
        return self.W @ X + self.b

    def backward(self, dout):
        m = self.X.shape[1]
        self.dW = (1/m) * dout @ self.X.T
        self.db = (1/m) * np.sum(dout, axis=1, keepdims=True)
        return self.W.T @ dout

class ReLU:
    def forward(self, Z):
        self.Z = Z
        return np.maximum(0, Z)

    def backward(self, dout):
        return dout * (self.Z > 0).astype(float)

class Sigmoid:
    def forward(self, Z):
        self.out = 1 / (1 + np.exp(-np.clip(Z, -500, 500)))
        return self.out

    def backward(self, dout):
        return dout * self.out * (1 - self.out)

class BCELoss:
    def forward(self, y_pred, t):
        self.y_pred = y_pred
        self.t = t
        eps = 1e-8
        return -np.mean(t * np.log(y_pred + eps)
                        + (1 - t) * np.log(1 - y_pred + eps))

    def backward(self):
        return self.y_pred - self.t

class MLP:
    """多層パーセプトロン"""

    def __init__(self, layer_dims, lr=0.1):
        self.layers = []
        self.lr = lr
        for i in range(len(layer_dims) - 1):
            self.layers.append(Linear(layer_dims[i], layer_dims[i+1]))
            if i < len(layer_dims) - 2:
                self.layers.append(ReLU())
            else:
                self.layers.append(Sigmoid())
        self.loss_fn = BCELoss()

    def forward(self, X):
        out = X
        for layer in self.layers:
            out = layer.forward(out)
        return out

    def backward(self):
        dout = self.loss_fn.backward()
        for layer in reversed(self.layers):
            dout = layer.backward(dout)

    def update(self):
        for layer in self.layers:
            if isinstance(layer, Linear):
                layer.W -= self.lr * layer.dW
                layer.b -= self.lr * layer.db

    def train_step(self, X, t):
        y_pred = self.forward(X)
        loss = self.loss_fn.forward(y_pred, t)
        self.backward()
        self.update()
        return loss

# --- 同心円データセット ---
X_data, y_data = make_circles(n_samples=600, noise=0.1,
                               factor=0.3, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X_data, y_data, test_size=0.2, random_state=42)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

X_tr = X_train.T
t_tr = y_train.reshape(1, -1)
X_te = X_test.T
t_te = y_test.reshape(1, -1)

# --- 異なる深さのネットワークで学習 ---
configs = [
    ([2, 16, 1], "1 hidden layer (16 units)"),
    ([2, 16, 16, 1], "2 hidden layers (16-16)"),
    ([2, 16, 16, 16, 1], "3 hidden layers (16-16-16)"),
]

fig, axes = plt.subplots(2, 3, figsize=(16, 10))

for col, (dims, title) in enumerate(configs):
    model = MLP(dims, lr=0.5)
    losses = []

    for epoch in range(3000):
        loss = model.train_step(X_tr, t_tr)
        losses.append(loss)

    # 損失の推移
    ax = axes[0, col]
    ax.plot(losses, linewidth=1.5, color="steelblue")
    ax.set_xlabel("Epoch", fontsize=11)
    ax.set_ylabel("Loss", fontsize=11)
    ax.set_title(title, fontsize=12)
    ax.set_yscale("log")
    ax.grid(True, alpha=0.3)

    # 判定境界
    ax = axes[1, col]
    xx, yy = np.meshgrid(np.linspace(-3, 3, 200),
                          np.linspace(-3, 3, 200))
    grid = np.c_[xx.ravel(), yy.ravel()].T
    zz = model.forward(grid).reshape(xx.shape)

    ax.contourf(xx, yy, zz, levels=50, cmap="RdBu_r", alpha=0.7)
    ax.contour(xx, yy, zz, levels=[0.5], colors="black", linewidths=2)

    for label, marker, color in [(0, "o", "red"), (1, "^", "blue")]:
        mask = y_train == label
        ax.scatter(X_train[mask, 0], X_train[mask, 1], s=15, c=color,
                   marker=marker, alpha=0.5, edgecolors="none")

    y_pred_test = (model.forward(X_te) > 0.5).astype(int)
    acc = np.mean(y_pred_test == t_te)
    ax.set_title(f"Test accuracy = {acc:.1%}", fontsize=12)
    ax.set_xlim(-3, 3)
    ax.set_ylim(-3, 3)
    ax.set_aspect("equal")

plt.tight_layout()
plt.savefig("backprop_depth_comparison.png", dpi=150, bbox_inches="tight")
plt.show()

この実験結果から、ネットワークの深さが判定境界と学習ダイナミクスに与える影響が読み取れます。

  1. 上段(損失の推移): 1隠れ層のネットワーク(左)は比較的滑らかに損失が減少しています。2隠れ層(中央)と3隠れ層(右)は初期の損失減少がやや不安定ですが、最終的には同程度の損失まで収束しています。深いネットワークほど損失曲面が複雑になるため、学習の振る舞いが変わります

  2. 下段(判定境界): 同心円データに対して、全ての構成が円形に近い判定境界を学習できています。1隠れ層でも16ユニットあれば十分に円を表現でき、深くなるにつれてより滑らかな境界が得られています。テスト精度はいずれも高く、この程度の問題では深さの差は大きくありません

  3. 深さと表現力: この実験では、どの構成でも良好な結果が得られました。しかし、より複雑なデータ(高次元、複雑な判定境界)では、浅いネットワークは指数的に多くのユニットを必要とし、深いネットワークの方が効率的に複雑な関数を表現できます

勾配消失と勾配爆発

問題の本質

逆伝播では、誤差信号が出力層から入力層に向かって伝播します。各層を通過するたびに、活性化関数の導関数 $\sigma'(\bm{z}^{(l)})$ が乗じられます。

$L$ 層のネットワークで、第1層の勾配は大まかに

$$ \bm{\delta}^{(1)} \propto \prod_{l=2}^{L} \bm{W}^{(l)} \cdot \prod_{l=1}^{L-1} \sigma'(\bm{z}^{(l)}) $$

の形になります。

勾配消失(vanishing gradients): $\sigma’$ の値が常に1未満の場合(シグモイドでは最大 $0.25$)、$L$ 層の積は指数的に小さくなります。入力層に近い重みの勾配がほぼ0になり、学習が停滞します。

勾配爆発(exploding gradients): $\bm{W}^{(l)}$ のスペクトルノルムが1を大きく超える場合、$L$ 層の積は指数的に大きくなります。パラメータが発散し、学習が不安定になります。

対策

勾配消失・爆発に対する代表的な対策は以下の通りです。

ReLU活性化関数: 正の領域で $\sigma'(z) = 1$ なので、勾配が減衰しません。ただし、負の領域で $\sigma'(z) = 0$ となる「dead neuron」問題があり、Leaky ReLUやGELUなどの変種で対処されます。

適切な重み初期化: 各層の出力の分散を一定に保つ初期化(Xavier初期化、He初期化)を使うことで、順伝播・逆伝播の両方で信号の大きさが安定します。

BatchNorm / LayerNorm: 各層の出力を正規化することで、内部共変量シフト(internal covariate shift)を軽減し、勾配の流れを安定させます。

残差接続(Skip Connection): ResNetで提案された手法で、$\bm{h}^{(l)} = F(\bm{h}^{(l-1)}) + \bm{h}^{(l-1)}$ とすることで、恒等写像の勾配が直接流れるパスを確保します。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# --- 勾配消失の可視化: シグモイド vs ReLU ---
depth = 20
hidden_dim = 50
input_dim = 50

fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

for ax, (act_name, act_fn, act_deriv, init_scale) in zip(
    axes,
    [("Sigmoid", lambda z: 1/(1+np.exp(-np.clip(z, -500, 500))),
      lambda a: a * (1 - a), 1.0),
     ("ReLU", lambda z: np.maximum(0, z),
      lambda z: (z > 0).astype(float), np.sqrt(2.0 / hidden_dim))]
):
    grad_norms = []

    for trial in range(50):
        # ネットワークの構築
        Ws = []
        for l in range(depth):
            d_in = input_dim if l == 0 else hidden_dim
            Ws.append(np.random.randn(hidden_dim, d_in) * init_scale)

        # 順伝播
        x = np.random.randn(input_dim, 1)
        hs = [x]
        zs = []
        for l in range(depth):
            z = Ws[l] @ hs[-1]
            zs.append(z)
            h = act_fn(z)
            hs.append(h)

        # 逆伝播(出力を仮にhsの最終層として、delta = 1)
        delta = np.ones_like(hs[-1])
        norms = []
        for l in range(depth - 1, -1, -1):
            if act_name == "Sigmoid":
                delta = delta * act_deriv(hs[l+1])
            else:
                delta = delta * act_deriv(zs[l])
            delta = Ws[l].T @ delta
            norms.append(np.linalg.norm(delta))

        grad_norms.append(norms[::-1])

    grad_norms = np.array(grad_norms)
    mean_norms = np.mean(grad_norms, axis=0)
    std_norms = np.std(grad_norms, axis=0)

    layers = np.arange(1, depth + 1)
    ax.semilogy(layers, mean_norms, "o-", linewidth=2, markersize=4,
                color="steelblue")
    ax.fill_between(layers,
                    np.maximum(mean_norms - std_norms, 1e-20),
                    mean_norms + std_norms,
                    alpha=0.2, color="steelblue")
    ax.set_xlabel("Layer (from input)", fontsize=12)
    ax.set_ylabel("Gradient norm (log scale)", fontsize=12)
    ax.set_title(f"Gradient Flow with {act_name}", fontsize=13)
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("gradient_flow.png", dpi=150, bbox_inches="tight")
plt.show()

この可視化から、活性化関数が勾配の流れに与える影響が明確に読み取れます。

  1. シグモイド(左図): 勾配のノルムが入力層に近づくほど指数的に減衰しています。20層のネットワークでは、第1層の勾配は出力層と比べて何桁も小さくなっています。これが勾配消失問題です。シグモイドの導関数の最大値は0.25であるため、各層を通過するたびに勾配が4分の1以下に縮小されていきます

  2. ReLU(右図): 勾配のノルムが層を通じてほぼ一定に保たれています。He初期化 ($\sqrt{2/n}$) と組み合わせることで、勾配の大きさが安定します。ただし、ばらつき(水色の帯)はあり、ランダムな初期化による影響は存在します。ReLUの導関数は正の領域で1であるため、勾配が減衰しにくいことが理論通り確認できます

この結果は、深層学習でReLU(およびその変種)が標準的に使われる理由を端的に示しています。

自動微分との関係

現代のフレームワーク

本記事で実装した逆伝播は、各層の forwardbackward を手動で定義する方法でした。PyTorchやTensorFlow、JAXなどの現代のフレームワークでは、自動微分(automatic differentiation, AD)が使われています。

自動微分は、計算グラフを動的に構築し、連鎖律を自動適用して勾配を計算する仕組みです。ユーザーは forward だけを定義すれば、backward はフレームワークが自動生成します。

import numpy as np

# PyTorchでの自動微分の例(概念的なコード)
# import torch
# import torch.nn as nn
#
# x = torch.randn(2, requires_grad=True)
# y = x[0]**2 + 3*x[1]
# y.backward()    # 自動微分で勾配を計算
# print(x.grad)   # [2*x[0], 3]

# NumPyで同等の手動計算
x = np.array([2.0, 1.0])
y = x[0]**2 + 3*x[1]

# 解析的な勾配
dy_dx0 = 2 * x[0]  # = 4.0
dy_dx1 = 3.0

print("手動計算による勾配:")
print(f"  dy/dx0 = {dy_dx0}")
print(f"  dy/dx1 = {dy_dx1}")
print(f"  y = {y}")

このコードは、自動微分の考え方を示すものです。PyTorchでは requires_grad=True を指定するだけで計算グラフが構築され、backward() を呼ぶだけで全ての勾配が計算されます。本記事で学んだ逆伝播の理論が、まさにこの自動微分の基盤になっています。

フォワードモードとリバースモード

自動微分には2つのモードがあります。

フォワードモード AD: 入力から出力に向かって微分を伝播する。$n$ 個の入力に対する勾配を計算するには $n$ 回の伝播が必要

リバースモード AD: 出力から入力に向かって微分を伝播する(= 逆伝播)。1つの出力(スカラー損失)に対する全パラメータの勾配が1回の伝播で計算可能

ニューラルネットワークの学習では、損失関数はスカラーで、パラメータは数百万〜数十億個なので、リバースモードが圧倒的に効率的です。これが逆伝播法が広く使われる理由です。

まとめ

本記事では、誤差逆伝播法の理論を計算グラフの視点から導出し、Pythonで実装しました。

  • 計算グラフは合成関数をノードとエッジで表現したもので、各ノードは局所勾配を計算する。逆方向に辿ると連鎖律が自動的に適用される
  • 基本演算の局所勾配として、加算(勾配の分配)、乗算(勾配の交換)、シグモイド($y(1-y)$)、ReLU(ゲート)、アフィン変換を導出した
  • 多層ネットワークの逆伝播は、出力層の誤差信号 $\bm{\delta}^{(L)} = \hat{\bm{y}} – \bm{t}$ を起点に、再帰式 $\bm{\delta}^{(l)} = ((\bm{W}^{(l+1)})^\top \bm{\delta}^{(l+1)}) \odot \sigma'(\bm{z}^{(l)})$ で計算される
  • グラディエントチェックで解析勾配と数値勾配を比較し、実装の正しさを検証した
  • 勾配消失問題はシグモイドの導関数が常に1未満であることに起因し、ReLUや適切な初期化で対策できる
  • 現代のフレームワークはリバースモード自動微分を使い、逆伝播を自動化している

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