ニューラルネットワーク

ニューラルネットワークを学ぶ入口で大事なのは、層が増えること自体ではなく、「線形な境界では足りない問題に対して、どこで非線形性が入るのか」を追うことです。

このノートでは、単一ニューロンで OR を解き、同じやり方では XOR が解けないことを確認し、2 層 MLP でその壁を越えます。最後に、逆伝播、ミニバッチ、PyTorch 実装までを一本の流れでつなぎます。

XOR が出てきた瞬間に、単層の限界が露わになる

ニューラルネットワークの必要性は、層を増やせば強いからではありません。OR のように 1 本の直線で分けられる問題と、XOR のようにそれでは足りない問題を並べたときに、はじめて「隠れ層で表現を作り直す」意味が出てきます。

単一ニューロンでうまくいく OR を見た直後に、同じ発想では XOR が崩れる様子を見ると、多層化の意味がかなり具体的になります。ここではその差を起点にして、隠れ層、逆伝播、PyTorch 実装までを同じ題材の延長として追います。

読みどころは「境界」と「勾配」の 2 本です

前半では、モデルがどんな境界を引けるかを見ます。後半では、その境界を実際に動かす勾配計算を見ます。XOR を解くことと、loss.backward() の意味を同じ題材で結び付けるのが、この notebook の中心です。

まずは OR を片付けて、どこで詰まるかを見る

いきなり複雑なネットワークに入るより、まずは単層で解ける OR を通して BCE と更新式の最小形を確認します。そのあとで XOR に移ると、何が不足していたのかがかなりはっきり見えます。

単層では届かない場所に、隠れ層がどう手を伸ばすか

2 層 MLP は、入力空間をいったん別の座標系へ写してから境界を引きます。だから XOR のような問題でも分けられるようになります。ここでは式の一般論より、「何が解けるようになるのか」を先に確かめます。

まずは OR を単一ニューロンで学習する

最初に、線形境界で解ける OR を使って、1 個のニューロンでも学習が進むことを確認します。ここでは BCE と勾配更新の最小形を見るのが目的です。

import numpy as np
import matplotlib.pyplot as plt

try:
    import torch
    import torch.nn as nn
    import torch.optim as optim
    TORCH_AVAILABLE = True
except ModuleNotFoundError:
    torch = None
    nn = None
    optim = None
    TORCH_AVAILABLE = False

np.random.seed(42)

まずは単一ニューロンで OR 問題を学習します。
ここで使う損失(BCE)は、正解に高い確率を出すほど小さくなります。

grad_w, grad_b は「どちらへ重みを動かすと損失が下がるか」を表す量です。

logits は sigmoid を通す前の生のスコアで、まだ確率ではありません。

X_or = np.array([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
])

y_or = np.array([[0.0], [1.0], [1.0], [1.0]])


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


def bce_from_logits(y_true, logits):
    # log(1 + exp(logits)) - y*logits
    return float(np.mean(np.logaddexp(0.0, logits) - y_true * logits))


def train_logistic(X, y, lr=0.5, epochs=800):
    w = np.zeros((X.shape[1], 1))
    b = 0.0
    loss_history = []

    for _ in range(epochs):
        logits = X @ w + b
        prob = sigmoid(logits)
        loss = bce_from_logits(y, logits)

        grad_w = (X.T @ (prob - y)) / len(X)
        grad_b = float(np.mean(prob - y))

        w -= lr * grad_w
        b -= lr * grad_b

        loss_history.append(loss)

    return w, b, loss_history

次に、NumPy で逆伝播の部品を追う

ここでは実装を少し分解して、順伝播で作った値が逆伝播でどう使い回されるかを見ます。勾配は突然現れるのではなく、前向き計算の途中結果を使って作られます。

w_or, b_or, loss_or = train_logistic(X_or, y_or, lr=0.5, epochs=1000)
prob_or = sigmoid(X_or @ w_or + b_or)
pred_or = (prob_or >= 0.5).astype(int)

print('w =', np.round(w_or.ravel(), 4), 'b =', round(float(b_or), 4))
print('prob =', np.round(prob_or.ravel(), 4))
print('pred =', pred_or.ravel())
print('true =', y_or.ravel().astype(int))

ミニバッチにすると、更新の揺れ方はどう変わるか

同じ MLP でも、毎回全データで更新するのか、一部ずつ更新するのかで損失曲線の揺れ方が変わります。ここでは batch size が学習の質感をどう変えるかを見ます。

fig, ax = plt.subplots(figsize=(6.2, 3.6))
ax.plot(loss_or, color='#2b6cb0')
ax.set_title('OR: Logistic Regression Loss')
ax.set_xlabel('epoch')
ax.set_ylabel('BCE loss')
plt.tight_layout()
plt.show()

次に XOR 問題で同じモデルを試します。
XOR では正例 (0,1), (1,0) と負例 (0,0), (1,1) が対角にあり、1本の直線では分けられません。

X_xor = np.array([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
])

y_xor = np.array([[0.0], [1.0], [1.0], [0.0]])

w_xor, b_xor, loss_xor_logistic = train_logistic(X_xor, y_xor, lr=0.5, epochs=5000)
prob_xor_logistic = sigmoid(X_xor @ w_xor + b_xor)
pred_xor_logistic = (prob_xor_logistic >= 0.5).astype(int)

print('logistic prob =', np.round(prob_xor_logistic.ravel(), 4))
print('logistic pred =', pred_xor_logistic.ravel())
print('true          =', y_xor.ravel().astype(int))

XOR で「線形では足りない」を体感する

次は XOR です。ここで単一ニューロンが詰まるのはバグではなく、境界の形の問題です。対角に分かれた正例・負例は、1 本の直線では分けられません。

fig, ax = plt.subplots(figsize=(6.2, 3.6))
ax.plot(loss_xor_logistic, color='#c05621')
ax.set_title('XOR: Logistic Regression Loss')
ax.set_xlabel('epoch')
ax.set_ylabel('BCE loss')
plt.tight_layout()
plt.show()

ここで 2層MLP(入力→隠れ層→出力)を使います。
隠れ層の非線形変換が入ることで、XORのような非線形境界を表現できます。

この後のコードで使う変数名:

特に tanh の微分は 1 - tanh(z)^2 を使います。

def train_mlp_xor(X, y, hidden_dim=4, lr=0.2, epochs=5000, batch_size=None, seed=0):
    rng = np.random.default_rng(seed)

    W1 = rng.normal(0.0, 0.6, size=(X.shape[1], hidden_dim))
    b1 = np.zeros((1, hidden_dim))
    W2 = rng.normal(0.0, 0.6, size=(hidden_dim, 1))
    b2 = np.zeros((1, 1))

    loss_history = []

    if batch_size is None:
        batch_size = len(X)

    for _ in range(epochs):
        indices = rng.permutation(len(X))
        X_shuf = X[indices]
        y_shuf = y[indices]

        for start in range(0, len(X), batch_size):
            end = start + batch_size
            xb = X_shuf[start:end]
            yb = y_shuf[start:end]

            z1 = xb @ W1 + b1
            h = np.tanh(z1)
            z2 = h @ W2 + b2
            p = sigmoid(z2)

            dz2 = (p - yb) / len(xb)
            dW2 = h.T @ dz2
            db2 = np.sum(dz2, axis=0, keepdims=True)

            dh = dz2 @ W2.T
            dz1 = dh * (1 - np.tanh(z1) ** 2)
            dW1 = xb.T @ dz1
            db1 = np.sum(dz1, axis=0, keepdims=True)

            W2 -= lr * dW2
            b2 -= lr * db2
            W1 -= lr * dW1
            b1 -= lr * db1

        full_logits = np.tanh(X @ W1 + b1) @ W2 + b2
        loss_history.append(bce_from_logits(y, full_logits))

    params = {"W1": W1, "b1": b1, "W2": W2, "b2": b2}
    return params, loss_history


def mlp_predict_prob(X, params):
    z1 = X @ params["W1"] + params["b1"]
    h = np.tanh(z1)
    z2 = h @ params["W2"] + params["b2"]
    return sigmoid(z2)

2 層 MLP を入れると何が変わるか

隠れ層を入れると、入力をいったん別の座標系へ写してから分類できます。ここで初めて XOR のような非線形境界を扱えるようになります。

params_full, loss_xor_mlp = train_mlp_xor(X_xor, y_xor, hidden_dim=4, lr=0.2, epochs=5000, batch_size=4, seed=1)
prob_xor_mlp = mlp_predict_prob(X_xor, params_full)
pred_xor_mlp = (prob_xor_mlp >= 0.5).astype(int)

print('mlp prob =', np.round(prob_xor_mlp.ravel(), 4))
print('mlp pred =', pred_xor_mlp.ravel())
print('true     =', y_xor.ravel().astype(int))

NumPy 実装が本当に合っているかを確かめる

勾配チェックは、逆伝播の実装が正しいかを見るための定番手順です。数値微分と解析的勾配が近ければ、実装は少なくとも大きくは壊れていないと判断できます。

fig, ax = plt.subplots(figsize=(6.4, 3.7))
ax.plot(loss_xor_logistic, label='logistic (single neuron)', color='#c05621')
ax.plot(loss_xor_mlp, label='2-layer MLP', color='#2b6cb0')
ax.set_title('XOR: Loss Comparison')
ax.set_xlabel('epoch')
ax.set_ylabel('BCE loss')
ax.legend()
plt.tight_layout()
plt.show()

PyTorch では同じ流れがどう見えるか

最後に、同じ XOR を PyTorch で学習します。ここで見るべきなのは、NumPy で手で書いた順伝播・逆伝播・更新が、forward, backward, step へどう対応しているかです。

def mlp_forward(X, params):
    z1 = X @ params["W1"] + params["b1"]
    h = np.tanh(z1)
    z2 = h @ params["W2"] + params["b2"]
    p = sigmoid(z2)
    return z1, h, z2, p


def mlp_backward(X, y, params):
    z1, h, z2, p = mlp_forward(X, params)
    dz2 = (p - y) / len(X)
    dW2 = h.T @ dz2
    db2 = np.sum(dz2, axis=0, keepdims=True)

    dh = dz2 @ params["W2"].T
    dz1 = dh * (1 - np.tanh(z1) ** 2)
    dW1 = X.T @ dz1
    db1 = np.sum(dz1, axis=0, keepdims=True)

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


def mlp_loss(X, y, params):
    _, _, z2, _ = mlp_forward(X, params)
    return bce_from_logits(y, z2)


g = mlp_backward(X_xor, y_xor, params_full)
checks = [
    ("W1", (0, 0)),
    ("W1", (1, 2)),
    ("W2", (0, 0)),
    ("W2", (3, 0)),
]

eps = 1e-5
grad_map = {"W1": g["dW1"], "W2": g["dW2"]}

for name, idx in checks:
    params_plus = {k: v.copy() for k, v in params_full.items()}
    params_minus = {k: v.copy() for k, v in params_full.items()}

    params_plus[name][idx] += eps
    params_minus[name][idx] -= eps

    num = (mlp_loss(X_xor, y_xor, params_plus) - mlp_loss(X_xor, y_xor, params_minus)) / (2 * eps)
    ana = float(grad_map[name][idx])
    rel = abs(num - ana) / max(1e-12, abs(num) + abs(ana))

    print(f"{name}{idx} -> analytic={ana:.8f}, numeric={num:.8f}, rel_err={rel:.3e}")

同じMLPでも、バッチサイズを変えると更新の揺れ方が変わります。
このデータは4件なので、full batch は毎回4件で更新、mini-batch(2) は2件ずつ更新します。

params_batch2, loss_xor_batch2 = train_mlp_xor(X_xor, y_xor, hidden_dim=4, lr=0.2, epochs=5000, batch_size=2, seed=1)

fig, ax = plt.subplots(figsize=(6.4, 3.7))
ax.plot(loss_xor_mlp, label='full batch (4)', color='#2b6cb0')
ax.plot(loss_xor_batch2, label='mini-batch (2)', color='#2f855a')
ax.set_title('Batch Size Effect on XOR Training')
ax.set_xlabel('epoch')
ax.set_ylabel('BCE loss')
ax.legend()
plt.tight_layout()
plt.show()

prob_batch2 = mlp_predict_prob(X_xor, params_batch2)
pred_batch2 = (prob_batch2 >= 0.5).astype(int)
print('mini-batch pred =', pred_batch2.ravel())

最後に同じXORを PyTorch でも学習します。
PyTorch が未導入の環境ではこの節をスキップするので、ノート全体はそのまま読み進められます。

if TORCH_AVAILABLE:
    torch.manual_seed(42)

    X_t = torch.tensor(X_xor, dtype=torch.float32)
    y_t = torch.tensor(y_xor, dtype=torch.float32)

    model = nn.Sequential(
        nn.Linear(2, 4),
        nn.Tanh(),
        nn.Linear(4, 1),
    )

    criterion = nn.BCEWithLogitsLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.2)

    torch_loss = []
    for _ in range(3000):
        logits = model(X_t)
        loss = criterion(logits, y_t)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        torch_loss.append(float(loss.detach()))

    with torch.no_grad():
        logits_t = model(X_t)
        prob_t = torch.sigmoid(logits_t).numpy()
        pred_t = (prob_t >= 0.5).astype(int)

    print('torch prob =', np.round(prob_t.ravel(), 4))
    print('torch pred =', pred_t.ravel())

    fig, ax = plt.subplots(figsize=(6.2, 3.6))
    ax.plot(torch_loss, color='#6b46c1')
    ax.set_title('PyTorch MLP Loss (XOR)')
    ax.set_xlabel('epoch')
    ax.set_ylabel('BCE loss')
    plt.tight_layout()
    plt.show()
else:
    print('PyTorchが未導入のため、この節はスキップしました。')

ここまでの流れをまとめると、単一ニューロンは線形境界、2 層 MLP は非線形境界、逆伝播はその学習を支える勾配計算です。深いネットワークも、出発点はこの延長にあります。