ニューラルネットワークを用いた教師あり学習のpythonプログラム

PyTorchでニューラルネットワークの教師あり学習を実装。誤差逆伝播の連鎖律による導出、勾配チェックによる検証、勾配消失問題の数値実験までを解説します。

ニューラルネットワーククラスを作成

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np

現在のPyTorchでは、torch.Tensorが標準で自動微分(autograd)に対応しているため、torch.autograd.Variableでラップする必要はありません(PyTorch 0.4以降、VariableTensorは統合されており、Variableは非推奨のエイリアスとして残っているだけです)。

nn.Module を継承して,ニューラルネットワーク(NN)のニューロン数,optimizer,損失関数を決める.
損失関数によって計算される損失(loss)を小さくするように、オプティマイザがニューラルネットワークのパラメータを最適化することで学習を行います。

class Model(nn.Module):
    def __init__(self, input_dim, output_dim):
        super(Model, self).__init__()
        # NNの入出力
        self.fc1 = nn.Linear(input_dim, 100)
        self.fc2 = nn.Linear(100, 100)
        self.fc3 = nn.Linear(100, output_dim)
        # 学習率とパラメータの更新方法
        self.optimizer = optim.SGD(self.parameters(), lr=0.01)
        # loss関数
        self.criterion = nn.MSELoss()

活性化関数を定義

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

誤差逆伝播によって勾配を計算し、オプティマイザでパラメータ更新を行います。

    def update(self,output, y):
        self.optimizer.zero_grad()
        loss = self.criterion(output, y)
        # 勾配の計算
        loss.backward()
        # パラメータの更新
        self.optimizer.step()
        return loss

loss.backward()が呼ばれた瞬間に何が起きているのか、次節で数式から丁寧に導出します。

誤差逆伝播の連鎖律による導出

loss.backward()はPyTorchの計算グラフを自動的に逆向きにたどり、各パラメータについて損失の勾配を計算します。この裏側で行われているのは、**連鎖律(chain rule)を系統的に適用する誤差逆伝播(backpropagation)**です。ここでは最も単純な2層MLP(入力→線形変換→活性化関数→線形変換→損失)を例に、全パラメータの勾配を手計算で導出します。

順伝播の定式化

入力ベクトル \(x \in \mathbb{R}^{d_{in}}\) 、目標値 \(y \in \mathbb{R}^{d_{out}}\) に対して、次の順伝播を考えます。

\[z^{(1)}_j = \sum_i x_i W^{(1)}_{ij} + b^{(1)}_j \qquad (j=1,\dots,d_h) \tag{1}\] \[a^{(1)}_j = \sigma\left(z^{(1)}_j\right) \tag{2}\] \[z^{(2)}_k = \sum_j a^{(1)}_j W^{(2)}_{jk} + b^{(2)}_k \qquad (k=1,\dots,d_{out}) \tag{3}\] \[L = \frac{1}{d_{out}}\sum_k \left(z^{(2)}_k - y_k\right)^2 \tag{4}\]

ここで \(W^{(1)} \in \mathbb{R}^{d_{in}\times d_h}\) 、\(W^{(2)} \in \mathbb{R}^{d_h \times d_{out}}\) は重み行列、\(\sigma\) はシグモイド関数、\(L\) はMSE損失です。式(1)・式(3)を行列形式で書くと \(z^{(1)} = xW^{(1)}+b^{(1)}\) 、\(z^{(2)} = a^{(1)}W^{(2)}+b^{(2)}\) となり、これはPyTorchのnn.Linear(および後述のNumPy実装)が行列積 X @ W + b として計算する形とそのまま対応します。

出力層の勾配

まず出力層の勾配 \(\delta^{(2)} := \partial L/\partial z^{(2)}\) を求めます。式(4)を \(z^{(2)}_k\) で偏微分すると、

\[\delta^{(2)}_k = \frac{\partial L}{\partial z^{(2)}_k} = \frac{2}{d_{out}}\left(z^{(2)}_k - y_k\right) \tag{5}\]

次に、式(3)から \(W^{(2)}_{jk}\) に対する \(z^{(2)}_k\) の偏微分は \(\partial z^{(2)}_k/\partial W^{(2)}_{jk} = a^{(1)}_j\) なので、連鎖律より

\[\frac{\partial L}{\partial W^{(2)}_{jk}} = \frac{\partial L}{\partial z^{(2)}_k}\cdot\frac{\partial z^{(2)}_k}{\partial W^{(2)}_{jk}} = \delta^{(2)}_k\, a^{(1)}_j \tag{6}\]

これを行列形式でまとめると、\(a^{(1)}\) を \(1\times d_h\) の行ベクトル、\(\delta^{(2)}\) を \(1\times d_{out}\) の行ベクトルとして、外積

\[\frac{\partial L}{\partial W^{(2)}} = \left(a^{(1)}\right)^{\!\top} \delta^{(2)} \in \mathbb{R}^{d_h\times d_{out}} \tag{7}\]

バイアスについては \(\partial z^{(2)}_k/\partial b^{(2)}_k = 1\) なので、単純に

\[\frac{\partial L}{\partial b^{(2)}} = \delta^{(2)} \tag{8}\]

隠れ層への逆伝播とヤコビアン

次に隠れ層のパラメータ \(W^{(1)}, b^{(1)}\) の勾配を求めるため、まず \(a^{(1)}\) に対する勾配を計算します。式(3)より \(\partial z^{(2)}_k/\partial a^{(1)}_j = W^{(2)}_{jk}\) なので、

\[\frac{\partial L}{\partial a^{(1)}_j} = \sum_k \delta^{(2)}_k\, W^{(2)}_{jk} \quad\Longrightarrow\quad \frac{\partial L}{\partial a^{(1)}} = \delta^{(2)}\left(W^{(2)}\right)^{\!\top} \tag{9}\]

ここからさらに活性化関数を遡って \(z^{(1)}\) に対する勾配を求める部分が、行列微分(ヤコビアン)の扱いが必要になる箇所です。活性化関数 \(a^{(1)} = \sigma(z^{(1)})\) は要素ごと(elementwise)の変換なので、そのヤコビアン \(J = \partial a^{(1)}/\partial z^{(1)} \in \mathbb{R}^{d_h\times d_h}\) は

\[J_{jl} = \frac{\partial a^{(1)}_j}{\partial z^{(1)}_l} = \begin{cases} \sigma'\!\left(z^{(1)}_j\right) & (j=l) \\ 0 & (j\neq l) \end{cases} \tag{10}\]

という対角行列になります(\(a^{(1)}_j\) は \(z^{(1)}_j\) のみに依存し、他の \(z^{(1)}_l\,(l\neq j)\) には依存しないため)。したがって連鎖律 \(\partial L/\partial z^{(1)} = J^{\top}\left(\partial L/\partial a^{(1)}\right)^{\top}\) は、対角行列との積であるため通常の行列積を計算するまでもなく、**要素ごとの積(アダマール積、\(\odot\) )**に簡約されます。

\[\delta^{(1)}_j = \frac{\partial L}{\partial a^{(1)}_j}\cdot \sigma'\!\left(z^{(1)}_j\right) \quad\Longrightarrow\quad \delta^{(1)} = \frac{\partial L}{\partial a^{(1)}} \odot \sigma'\!\left(z^{(1)}\right) \tag{11}\]

これがニューラルネットワークの逆伝播で「活性化関数の逆伝播は要素ごとの積でよい」とされる理由です(一般の(非要素ごとの)変換ではヤコビアンは非対角行列になり、フルの行列積が必要です。例えば Self-Attentionのsoftmax のヤコビアンは非対角です)。

最後に式(1)と同じ形の連鎖律を適用して、

\[\frac{\partial L}{\partial W^{(1)}} = x^{\top}\delta^{(1)} \in \mathbb{R}^{d_{in}\times d_h}, \qquad \frac{\partial L}{\partial b^{(1)}} = \delta^{(1)} \tag{12}\]

以上で \(\partial L/\partial W^{(1)}\) 、\(\partial L/\partial b^{(1)}\) 、\(\partial L/\partial W^{(2)}\) 、\(\partial L/\partial b^{(2)}\) の4つすべてを連鎖律のみから導出できました。バッチサイズ \(N\) のミニバッチに拡張する場合も自然です。式(7)・式(12)の外積を \(N\) サンプル分の和(=行列積 X.T @ dZ)に置き換えるだけで、勾配がバッチ全体で自動的に合算されます。

活性化関数とその微分

上の導出では活性化関数をシグモイドとしましたが、\(\sigma'\) の部分を差し替えるだけで任意の要素ごとの活性化関数に一般化できます。代表的な3つを比較します。

シグモイド

\[\sigma(z) = \frac{1}{1+e^{-z}}, \qquad \sigma'(z) = \sigma(z)\bigl(1-\sigma(z)\bigr) \tag{13}\]

\(\sigma'(z)\) は \(z=0\) で最大値 \(0.25\) を取り、\(|z|\to\infty\) で指数的に \(0\) へ減衰します(飽和)。

tanh

\[\tanh(z) = \frac{e^{z}-e^{-z}}{e^{z}+e^{-z}}, \qquad \tanh'(z) = 1-\tanh^2(z) \tag{14}\]

\(\tanh'(z)\) は \(z=0\) で最大値 \(1\) を取りますが、こちらも \(|z|\to\infty\) で \(0\) に飽和します。シグモイドより最大勾配が大きい分、後述の勾配消失はやや緩和されますが、根本的な解決にはなりません。

ReLU

\[\text{ReLU}(z) = \max(0, z), \qquad \text{ReLU}'(z) = \begin{cases} 1 & (z>0) \\ 0 & (z<0) \end{cases} \tag{15}\]

\(z=0\) では微分不可能ですが、実装上は劣勾配として \(0\) または \(1\) を割り当てます。正の領域では勾配が常に \(1\) で飽和しないため、多層に重ねても勾配が減衰しにくいという特性を持ちます(次節で数値的に確認します)。一方、\(z<0\) の領域では勾配が完全に \(0\) になり、ニューロンが更新されなくなる「dying ReLU」問題が知られています。

活性化関数最大微分値飽和領域での微分特徴
シグモイド\(0.25\) (\(z=0\) )\(\to 0\) (指数的)出力が \((0,1)\) 、多層で勾配消失しやすい
tanh\(1.0\) (\(z=0\) )\(\to 0\) (指数的)出力が \((-1,1)\) 、シグモイドより勾配消失に強い
ReLU\(1.0\) (\(z>0\) で一定)飽和しない(\(z>0\) )計算が軽量、dying ReLUのリスク

勾配チェックによる検証

導出した解析的勾配が正しいかどうかは、**勾配チェック(gradient checking)**という手法で機械的に検証できます。各パラメータを \(\pm\varepsilon\) だけ動かしたときの損失の変化から、中心差分近似(有限差分法)で数値的に勾配を計算し、解析解と比較します。

\[\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta_i+\varepsilon) - L(\theta_i-\varepsilon)}{2\varepsilon} \tag{16}\]

前節の導出(式7・式8・式11・式12)をそのままNumPyで実装し、\(\varepsilon=10^{-5}\) の中心差分と比較しました。

import numpy as np

np.random.seed(0)


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))


def forward(X, W1, b1, W2, b2):
    Z1 = X @ W1 + b1
    A1 = sigmoid(Z1)
    Z2 = A1 @ W2 + b2
    return Z1, A1, Z2


def loss_fn(Z2, Y):
    N, d_out = Z2.shape
    return np.sum((Z2 - Y) ** 2) / (N * d_out)


def analytic_grad(X, Y, W1, b1, W2, b2):
    N, d_out = Y.shape
    Z1, A1, Z2 = forward(X, W1, b1, W2, b2)

    dZ2 = (2.0 / (N * d_out)) * (Z2 - Y)   # 式(5)のバッチ版
    dW2 = A1.T @ dZ2                        # 式(7)
    db2 = dZ2.sum(axis=0)                   # 式(8)

    dA1 = dZ2 @ W2.T                        # 式(9)
    dZ1 = dA1 * (A1 * (1 - A1))             # 式(11): sigmoid'(Z1) = A1*(1-A1)

    dW1 = X.T @ dZ1                         # 式(12)
    db1 = dZ1.sum(axis=0)                   # 式(12)

    return dW1, db1, dW2, db2


def numerical_grad(param, X, Y, W1, b1, W2, b2, eps=1e-5):
    grad = np.zeros_like(param)
    it = np.nditer(param, flags=["multi_index"])
    while not it.finished:
        idx = it.multi_index
        orig = param[idx]
        param[idx] = orig + eps
        Lp = loss_fn(forward(X, W1, b1, W2, b2)[2], Y)
        param[idx] = orig - eps
        Lm = loss_fn(forward(X, W1, b1, W2, b2)[2], Y)
        param[idx] = orig
        grad[idx] = (Lp - Lm) / (2 * eps)   # 式(16)
        it.iternext()
    return grad


N, d_in, d_h, d_out = 5, 3, 4, 2
X = np.random.randn(N, d_in)
Y = np.random.randn(N, d_out)
W1 = np.random.randn(d_in, d_h) * 0.5
b1 = np.random.randn(d_h) * 0.5
W2 = np.random.randn(d_h, d_out) * 0.5
b2 = np.random.randn(d_out) * 0.5

dW1, db1, dW2, db2 = analytic_grad(X, Y, W1, b1, W2, b2)
dW1_num = numerical_grad(W1, X, Y, W1, b1, W2, b2)
db1_num = numerical_grad(b1, X, Y, W1, b1, W2, b2)
dW2_num = numerical_grad(W2, X, Y, W1, b1, W2, b2)
db2_num = numerical_grad(b2, X, Y, W1, b1, W2, b2)

for name, ana, num in [("W1", dW1, dW1_num), ("b1", db1, db1_num),
                        ("W2", dW2, dW2_num), ("b2", db2, db2_num)]:
    abs_err = np.max(np.abs(ana - num))
    rel_err = np.max(np.abs(ana - num) / (np.abs(ana) + np.abs(num) + 1e-8))
    print(f"{name}: max_abs_err={abs_err:.3e}  max_rel_err={rel_err:.3e}")

実行結果(\(N=5,\ d_{in}=3,\ d_h=4,\ d_{out}=2\) のランダムな重み・入力で検証):

W1: max_abs_err=6.087e-11  max_rel_err=3.444e-10
b1: max_abs_err=2.493e-11  max_rel_err=3.596e-11
W2: max_abs_err=6.114e-11  max_rel_err=1.208e-10
b2: max_abs_err=1.385e-11  max_rel_err=2.144e-11
overall relative error = 2.411e-11

すべてのパラメータで解析的勾配と数値勾配の誤差が \(10^{-10}\) オーダーに収まっており、これは中心差分法自体の丸め誤差(\(\varepsilon=10^{-5}\) に起因する打ち切り誤差)の範囲内です。したがって式(7)・式(8)・式(11)・式(12)の導出が正しいことが実行によって確認できました。

さらに、同じ計算をPyTorchのloss.backward()(自動微分)でも実行し、手動導出の結果と突き合わせました。

import torch

Xt, Yt = torch.tensor(X), torch.tensor(Y)
W1t = torch.tensor(W1, requires_grad=True)
b1t = torch.tensor(b1, requires_grad=True)
W2t = torch.tensor(W2, requires_grad=True)
b2t = torch.tensor(b2, requires_grad=True)

Z1t = Xt @ W1t + b1t
A1t = torch.sigmoid(Z1t)
Z2t = A1t @ W2t + b2t
Lt = torch.mean((Z2t - Yt) ** 2)
Lt.backward()

for name, ana, tgrad in [("W1", dW1, W1t.grad), ("b1", db1, b1t.grad),
                          ("W2", dW2, W2t.grad), ("b2", db2, b2t.grad)]:
    err = (torch.tensor(ana) - tgrad).abs().max().item()
    print(f"{name} 最大誤差: {err:.3e}")

実行結果:b1W2b2 は誤差 0.000e+00W11.110e-16(倍精度浮動小数点の丸め誤差レベル)で、手動導出とPyTorchのautogradが完全に一致することを確認しました。loss.backward()が内部で行っているのは、まさに式(5)〜式(12)の連鎖律の適用です。

勾配消失問題(Vanishing Gradient)

式(11)の \(\delta^{(1)} = (\partial L/\partial a^{(1)}) \odot \sigma'(z^{(1)})\) を多層に拡張すると、\(L\) 層のネットワークの入力に近い層の勾配は、各層の \(\sigma'\) の積が繰り返し掛かる形になります。

\[\delta^{(1)} \propto \sigma'\!\left(z^{(1)}\right) \odot \left(W^{(2)}\right)^{\!\top}\Bigl[\sigma'\!\left(z^{(2)}\right) \odot \left(W^{(3)}\right)^{\!\top}\bigl[\cdots\bigr]\Bigr] \tag{17}\]

シグモイドは \(\sigma'(z) \le 0.25\) なので、\(L\) 層重なると勾配には最大でも \(0.25^{L}\) のオーダーの係数がかかり、\(L\) が大きくなるにつれて指数的に勾配が消失します。これが**勾配消失問題(vanishing gradient problem)**です。ReLUは正の領域で \(\text{ReLU}'(z)=1\) であるため、この減衰要因がありません。

これを実際に検証するため、幅64・10層の全結合MLP(Xavier初期化)を構築し、シグモイドとReLUそれぞれで1回だけ逆伝播した際の各層の重み勾配のノルム \(\|\partial L/\partial W^{(l)}\|\) を層ごとに計測しました。

import torch
import torch.nn as nn

torch.manual_seed(0)

N_LAYERS, WIDTH, BATCH = 10, 64, 32


class DeepMLP(nn.Module):
    def __init__(self, n_layers, width, activation):
        super().__init__()
        self.layers = nn.ModuleList(nn.Linear(width, width) for _ in range(n_layers))
        self.out = nn.Linear(width, 1)
        self.activation = activation
        for layer in self.layers:
            nn.init.xavier_uniform_(layer.weight)
            nn.init.zeros_(layer.bias)
        nn.init.xavier_uniform_(self.out.weight)
        nn.init.zeros_(self.out.bias)

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


def run(activation):
    torch.manual_seed(0)
    model = DeepMLP(N_LAYERS, WIDTH, activation)
    x, y = torch.randn(BATCH, WIDTH), torch.randn(BATCH, 1)
    loss = nn.functional.mse_loss(model(x), y)
    loss.backward()
    return [layer.weight.grad.norm().item() for layer in model.layers]


sig_norms = run(torch.sigmoid)
relu_norms = run(torch.relu)

実行結果(層0が入力に最も近い層、層9が出力直前の層):

Sigmoid:  layer0=1.281e-06  layer1=6.133e-06  layer2=2.554e-05  ...  layer9=7.325e-01
          ratio (layer0 / layer9) = 1.749e-06

ReLU:     layer0=6.106e-02  layer1=5.718e-02  layer2=5.403e-02  ...  layer9=6.160e-02
          ratio (layer0 / layer9) = 9.912e-01

10層MLPにおける層ごとの重み勾配ノルム(対数スケール)。シグモイドは入力に近い層ほど勾配が指数的に減衰し層0と層9で6桁の差が生じる一方、ReLUは全層でほぼ一定のノルムを保つ

シグモイドでは、出力に最も近い層9の勾配ノルムが \(0.73\) であるのに対し、入力に最も近い層0では \(1.3\times 10^{-6}\) と、実に約57万分の1(比で \(1.7\times10^{-6}\) )まで減衰しています。これは式(17)で予想した「各層で \(\sigma'\le 0.25\) が掛け合わされる」効果がそのまま数値に表れた結果です。一方ReLUでは層0が \(0.061\) 、層9が \(0.062\) とほぼ同水準(比で \(0.99\) )に保たれており、正の領域で微分が常に \(1\) であることが勾配消失を防いでいることが確認できます。

これが、深いネットワークで隠れ層の活性化関数にシグモイドではなくReLU(あるいはその派生)が広く使われる理由です。入力層に近い層ほど勾配が消失すると、その層のパラメータはほとんど更新されず、実質的に学習が進まなくなります。なお、勾配消失は活性化関数の選択だけでなく、 LSTMのゲート機構 や残差接続、 Adamなどの適応的最適化手法 によっても緩和されます。

実験

以上の理論を踏まえ、冒頭で定義したModelクラス(隠れ層にReLUを使用)で、単純な回帰タスク \(f(x) = x^2 + 2x + 1\) を学習します。

# テストデータの作成
def math(x):
    return x*x + 2*x + 1

def make():
    x = np.random.rand(1000)
    y = math(x)
    return x,y

train_x, train_y = make()
test_x, test_y = make()
model = Model(1,1)

# テストデータ
gosa = 0
for i in range(1000):
    output = model(torch.tensor([[float(test_x[i])]]))
    gosa += abs((output - test_y[i]))

print("学習前の誤差平均:", gosa/1000)

# 訓練データを学習
for i in range(1000):
    output = model(torch.tensor([[float(train_x[i])]]))
    loss = model.update(output, torch.tensor([[float(train_y[i])]]))

# テストデータ
gosa = 0
for i in range(1000):
    output = model(torch.tensor([[float(test_x[i])]]))
    gosa += abs((output - test_y[i])[0])

print("学習後の誤差平均:", gosa/1000)

実行結果(torch.manual_seed(0)np.random.seed(0)で固定)

学習前の誤差平均: tensor([[2.4819]], grad_fn=<DivBackward0>)
学習後の誤差平均: tensor([0.0326], grad_fn=<DivBackward0>)

1000ステップのSGD学習だけで、テストデータに対する平均絶対誤差が \(2.48\) から \(0.033\) まで、約75分の1に減少しました。この裏側で行われているのが、本記事で導出した式(5)〜式(12)の連鎖律による勾配計算です。

関連記事

参考文献

  • Rumelhart, D. E., Hinton, G. E., & Williams, R. J. (1986). “Learning representations by back-propagating errors.” Nature, 323(6088), 533-536.
  • Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press. Chapter 6.
  • Glorot, X., & Bengio, Y. (2010). “Understanding the difficulty of training deep feedforward neural networks.” AISTATS 2010.
  • PyTorch Documentation: torch.autograd. https://pytorch.org/docs/stable/autograd.html