ニューラルネットワーク
ニューラルネットワークを学ぶ入口で大事なのは、層が増えること自体ではなく、「線形な境界では足りない問題に対して、どこで非線形性が入るのか」を追うことです。
このノートでは、単一ニューロンで 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)は、正解に高い確率を出すほど小さくなります。
- 正解が 1 のとき: 予測確率
pが 1 に近いほど損失は小さい - 正解が 0 のとき:
pが 0 に近いほど損失は小さい
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のような非線形境界を表現できます。
この後のコードで使う変数名:
z1: 隠れ層への入力h: 隠れ層の出力z2: 出力層への入力p: 予測確率d*: 損失の勾配(どちらへ動かせば損失が減るか)
特に 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 は非線形境界、逆伝播はその学習を支える勾配計算です。深いネットワークも、出発点はこの延長にあります。