パーセプトロンからニューラルネットへ:XORを標準Pythonで実測
パーセプトロンはANDとORを学習できてもXORは永遠に解けない。手書きの誤差逆伝播法で解けるが、tanh・隠れ2ユニットでは100シード中59回失敗した。失敗の正体、勾配チェック、二つの月での精度を実測。
G検定や E資格の勉強で「単純パーセプトロンは XOR を解けない」「多層にして誤差逆伝播法で学習すれば解ける」と覚えた人は多いと思います。この記事では、その2つを実際のプログラム出力で確かめます。インストール不要の Python 約150行です。結果を先に書くと、パーセプトロンは AND を6エポック、OR を4エポックで学習し、XOR では同じ重みを永遠に回り続けました。手書きの誤差逆伝播法で学習する 2-H-1 ネットワークは XOR を解きますが、隠れ2ユニットでは100通りの初期値のうち成功は41回だけ。意外だったのは、残り59回の失敗の中身です。
この記事はシリーズ「標準ライブラリだけで作る機械学習」の第4回です。第1回は線形回帰と勾配降下法、第2回はk-means クラスタリング、第3回はナイーブベイズによる文章分類でした。最終回は TF-IDF と BM25 の検索エンジンです。
実行環境: Python 3.9.6 標準ライブラリのみ(math、random、time)、Apple Silicon Mac(M1 Pro)。シードはすべて固定しているので、同じ数字が出るはずです。
パーセプトロンの学習規則:AND・OR・XOR
パーセプトロンは w1·x1 + w2·x2 + b > 0 なら1、そうでなければ0を出力します。学習規則は「間違えたときだけ重みを直す」というものです。本当は1だったなら入力を足し、0だったなら引きます。パーセプトロンの収束定理によれば、分離する直線が存在すれば、有限回の間違いで止まります。
# perceptron.py - Rosenblatt perceptron rule on AND / OR / XOR (stdlib only)
DATA = [(0, 0), (0, 1), (1, 0), (1, 1)]
TARGETS = {"AND": [0, 0, 0, 1], "OR": [0, 1, 1, 1], "XOR": [0, 1, 1, 0]}
def train(targets, lr=1.0, max_epochs=100):
w1 = w2 = b = 0.0
history = []
for epoch in range(1, max_epochs + 1):
errors = 0
for (x1, x2), t in zip(DATA, targets):
y = 1 if w1 * x1 + w2 * x2 + b > 0 else 0
if y != t: # update only on a mistake
w1 += lr * (t - y) * x1
w2 += lr * (t - y) * x2
b += lr * (t - y)
errors += 1
history.append((epoch, errors, (w1, w2, b)))
if errors == 0:
return epoch, (w1, w2, b), history
return None, (w1, w2, b), history
for name, t in TARGETS.items():
epoch, w, hist = train(t)
if epoch:
print(f"{name}: converged after {epoch} epochs, w1={w[0]} w2={w[1]} b={w[2]}")
else:
print(f"{name}: NOT converged in {len(hist)} epochs")
for e, err, ww in hist[:6] + hist[-2:]:
print(f" epoch {e:3d} errors={err} w={ww}")
seen = {}
for e, err, ww in hist:
if ww in seen:
print(f" weights at epoch {e} repeat epoch {seen[ww]} -> cycle of {e - seen[ww]}")
break
seen[ww] = e
AND: converged after 6 epochs, w1=2.0 w2=1.0 b=-2.0
OR: converged after 4 epochs, w1=1.0 w2=1.0 b=0.0
XOR: NOT converged in 100 epochs
epoch 1 errors=2 w=(-1.0, 0.0, 0.0)
epoch 2 errors=3 w=(-1.0, 0.0, 1.0)
epoch 3 errors=4 w=(-1.0, 0.0, 1.0)
epoch 4 errors=4 w=(-1.0, 0.0, 1.0)
…
epoch 100 errors=4 w=(-1.0, 0.0, 1.0)
weights at epoch 3 repeat epoch 2 -> cycle of 1
AND で得た直線 2·x1 + 1·x2 − 2 > 0 が真になるのは (1, 1) だけです。重みが左右対称でない点に注目してください。この規則は「きれいな直線」ではなく、「最初に見つかった、正しく分けられる直線」で止まります。
面白いのは XOR の出力です。3エポック目以降、毎エポック4サンプルすべてを間違えているのに、エポック終了時の重みはまったく同じです。1エポック分を手で追うと理由が分かります。(−1, 0, 1) から始めると、(0,0) で誤って発火して b が0に下がります。(0,1) では発火せず、w2 と b が上がって (−1, 1, 1)。(1,0) でも発火せず (0, 1, 2)。(1,1) で誤って発火し (1, 1, 1) を引くと、(−1, 0, 1) に戻ります。直すたびに、直前の修正を打ち消しているわけです。(0,1) と (1,0) を片側に、(0,0) と (1,1) をもう片側に置く直線はないので、収束定理は何も保証せず、規則は永遠にループします。「最大エポック数」は飾りではありません。
誤差逆伝播法を手で書いた 2-H-1 ネットワーク
解決策は隠れ層です。入力2つ、tanh の隠れユニット H 個、sigmoid の出力1つ、損失は二値交差エントロピー。sigmoid 出力と交差エントロピーを組み合わせると、出力層の活性化前の誤差は y − t だけになります。隠れ層の誤差は、それに出力側の重みと tanh の微分を掛けたものです。tanh の微分は、出力 h を使って書くと 1 − h² です。
このファイルが記事の中心です。後のスクリプトはここから Net を import するので、xor_mlp.py という名前で保存してください。
# xor_mlp.py - 2-H-1 network, backprop by hand, trained on XOR (stdlib only)
# usage: python3 xor_mlp.py [tanh|sigmoid] [init_scale] [seeds]
import math, random, sys, time
X = [(0.0, 0.0), (0.0, 1.0), (1.0, 0.0), (1.0, 1.0)]
T = [0.0, 1.0, 1.0, 0.0]
def sigmoid(z):
return 1.0 / (1.0 + math.exp(-z))
ACTS = { # activation, and its derivative written in terms of the output h
"tanh": (math.tanh, lambda h: 1.0 - h * h),
"sigmoid": (sigmoid, lambda h: h * (1.0 - h)),
}
class Net:
def __init__(self, n_in, n_hid, rng, act="tanh", scale=1.0):
self.f, self.df = ACTS[act]
self.W1 = [[rng.uniform(-scale, scale) for _ in range(n_in)] for _ in range(n_hid)]
self.b1 = [0.0] * n_hid
self.W2 = [rng.uniform(-scale, scale) for _ in range(n_hid)]
self.b2 = 0.0
def forward(self, x):
h = [self.f(sum(w * xi for w, xi in zip(row, x)) + b)
for row, b in zip(self.W1, self.b1)]
y = sigmoid(sum(w * hj for w, hj in zip(self.W2, h)) + self.b2)
return h, y
def loss(self, x, t): # binary cross-entropy
y = min(max(self.forward(x)[1], 1e-12), 1 - 1e-12)
return -(t * math.log(y) + (1 - t) * math.log(1 - y))
def backward(self, x, t):
h, y = self.forward(x)
dz2 = y - t # dL/dz2 for sigmoid + BCE
gW2 = [dz2 * hj for hj in h]
dz1 = [dz2 * w * self.df(hj) for w, hj in zip(self.W2, h)]
gW1 = [[d * xi for xi in x] for d in dz1]
return gW1, dz1, gW2, dz2 # dz1 = gb1, dz2 = gb2
def step(self, grads, lr):
gW1, gb1, gW2, gb2 = grads
for j in range(len(self.W2)):
for i in range(len(self.W1[j])):
self.W1[j][i] -= lr * gW1[j][i]
self.b1[j] -= lr * gb1[j]
self.W2[j] -= lr * gW2[j]
self.b2 -= lr * gb2
def train_xor(n_hid, seed, act="tanh", scale=1.0, lr=0.5, max_epochs=5000):
rng = random.Random(seed)
net = Net(2, n_hid, rng, act, scale)
order = list(range(4))
for epoch in range(1, max_epochs + 1):
rng.shuffle(order)
for k in order: # plain SGD, one sample at a time
net.step(net.backward(X[k], T[k]), lr)
if all(abs(net.forward(x)[1] - t) < 0.1 for x, t in zip(X, T)):
return epoch, net
return None, net
if __name__ == "__main__":
act = sys.argv[1] if len(sys.argv) > 1 else "tanh"
scale = float(sys.argv[2]) if len(sys.argv) > 2 else 1.0
seeds = range(int(sys.argv[3]) if len(sys.argv) > 3 else 100)
for n_hid in (2, 4, 8):
t0 = time.perf_counter()
solved = sorted(e for e in (train_xor(n_hid, s, act, scale)[0] for s in seeds) if e)
dt = time.perf_counter() - t0
med = solved[len(solved) // 2] if solved else "-"
rng_ = f"{solved[0]}-{solved[-1]}" if solved else "-"
print(f"{act} init±{scale} hidden={n_hid}: solved {len(solved)}/{len(seeds)} "
f"median epochs {med} range {rng_} time {dt:.1f}s")
「解けた」の基準は、4つの出力すべてが正解から0.1以内に入ることです。5,000エポック経っても解けなければ「行き詰まった」と数えます。
設定ごとに100シード:どのくらい行き詰まるか
最初の版では、初期値を ±0.71(1/√2、入力数に応じた定番の決め方)から取っていました。すると隠れ2・4・8ユニットで、100シード中の成功が13・21・48回しかありませんでした。予想よりかなり悪かったので、初期値の幅を変え、隠れ層を sigmoid にした版も試しました。
$ python3 xor_mlp.py tanh 1.0
tanh init±1.0 hidden=2: solved 41/100 median epochs 68 range 33-101 time 7.1s
tanh init±1.0 hidden=4: solved 73/100 median epochs 54 range 30-96 time 5.3s
tanh init±1.0 hidden=8: solved 97/100 median epochs 37 range 23-81 time 1.2s
$ python3 xor_mlp.py tanh 0.5
tanh init±0.5 hidden=2: solved 0/100 median epochs - range - time 13.1s
tanh init±0.5 hidden=4: solved 1/100 median epochs 81 range 81-81 time 18.4s
tanh init±0.5 hidden=8: solved 5/100 median epochs 89 range 77-93 time 29.7s
$ python3 xor_mlp.py tanh 2.0
tanh init±2.0 hidden=2: solved 54/100 median epochs 47 range 22-115 time 6.1s
tanh init±2.0 hidden=4: solved 97/100 median epochs 30 range 15-235 time 0.8s
tanh init±2.0 hidden=8: solved 100/100 median epochs 17 range 9-40 time 0.1s
$ python3 xor_mlp.py sigmoid 1.0
sigmoid init±1.0 hidden=2: solved 63/100 median epochs 392 range 170-3482 time 5.9s
sigmoid init±1.0 hidden=4: solved 100/100 median epochs 327 range 173-1046 time 1.4s
sigmoid init±1.0 hidden=8: solved 100/100 median epochs 230 range 143-443 time 1.6s
$ python3 xor_mlp.py sigmoid 0.5
sigmoid init±0.5 hidden=2: solved 77/100 median epochs 488 range 330-3128 time 4.2s
sigmoid init±0.5 hidden=4: solved 100/100 median epochs 446 range 326-849 time 1.8s
sigmoid init±0.5 hidden=8: solved 100/100 median epochs 383 range 298-507 time 2.6s
目立つ点は3つです。
- 隠れユニットを増やすと大きく改善する。 tanh・±1.0 では成功が 41 → 73 → 97 回、解けるまでのエポック数の中央値は 68 → 54 → 37 と下がります。
- 成功するときはすぐ成功する。 tanh・±1.0 で101エポックを超えた成功例はありません。上限を2,000エポックにして回し直しても成功は同じ41・73・97回で、追加の3,000エポックで救われた試行は1つもありませんでした。tanh で数百エポック以内に解けないなら、待っても無駄です。
- 初期値の幅は構造と同じくらい効く。 ±0.5 の tanh はほぼ全滅(100回中0・1・5回)。同じ ±0.5 でも sigmoid の隠れ層は77・100・100回成功しました。遅い(H=2 で中央値488エポック)代わりに、ずっと壊れにくいです。
時間の列は、ほぼ行き詰まった試行の分です。1つにつき5,000エポックを最後まで回すので、±0.5 の8ユニットは100シードで29.7秒、±2.0 の8ユニットは0.1秒でした。
行き詰まった試行の中身
「局所解にはまった」というのがよくある説明です。今回は実際に数えてみました。
# failmodes.py - what do the stuck runs look like?
import sys
from collections import Counter
from xor_mlp import train_xor, X
act, scale, n_hid = sys.argv[1], float(sys.argv[2]), int(sys.argv[3])
kinds, example = Counter(), {}
for seed in range(100):
epoch, net = train_xor(n_hid, seed, act, scale)
if epoch:
continue
biggest = max(abs(w) for row in net.W1 for w in row)
outs = tuple(round(net.forward(x)[1], 2) for x in X)
kind = "collapsed (weights ~0)" if biggest < 1e-6 else "local minimum"
kinds[kind] += 1
example.setdefault(kind, (seed, outs, f"{biggest:.1e}"))
print(dict(kinds))
for k, v in example.items():
print(f" {k}: seed {v[0]} outputs {v[1]} max|W1| {v[2]}")
$ python3 failmodes.py tanh 1.0 2
{'collapsed (weights ~0)': 54, 'local minimum': 5}
collapsed (weights ~0): seed 0 outputs (0.49, 0.49, 0.49, 0.49) max|W1| 6.2e-49
local minimum: seed 23 outputs (0.0, 1.0, 0.41, 0.41) max|W1| 6.4e+00
$ python3 failmodes.py tanh 1.0 4
{'collapsed (weights ~0)': 27}
collapsed (weights ~0): seed 0 outputs (0.47, 0.47, 0.47, 0.47) max|W1| 5.1e-49
$ python3 failmodes.py sigmoid 1.0 2
{'local minimum': 37}
local minimum: seed 8 outputs (0.0, 1.0, 0.51, 0.51) max|W1| 1.1e+01
$ python3 failmodes.py sigmoid 0.5 2
{'local minimum': 23}
local minimum: seed 12 outputs (0.0, 0.67, 0.67, 0.67) max|W1| 9.7e+00
tanh・隠れ2ユニットの失敗59回のうち54回、4ユニットの27回すべてが、そもそも局所解ではありませんでした。隠れ層の重みが全部1e-49前後まで縮み、どの入力にも約0.5を返していたのです。理由は次のように読んでいます。重みがすべて0だと tanh の出力は0になり、出力側の重みに勾配が来ません。出力側の重みが0なら、隠れ側の重みにも勾配が来ません。つまりそこは停留点です。その近くでは tanh はほぼ直線なので、ネットワーク全体が線形モデルのように振る舞います。XOR に対する最良の線形モデルは「常に0.5」なので、勾配降下は重みを0へ引き寄せます。初期値が小さいと、最初からその引力圏の中にいます。±0.5 がほぼ全滅したのはそのためです。
sigmoid の隠れ層は、今回の試行では一度もこの崩壊を起こしませんでした。失敗はどれも本物の局所解で、1〜2個の入力は完璧に当て、残りは同じ中途半端な値(上の例では0.51と0.67)を返していました。教科書どおりの局所解で、隠れ4ユニットにすると消えました(100/100)。
勾配チェック:逆伝播は合っているか
微分を間違えてもプログラムは落ちません。学習が少し遅い、少し精度が低い、というだけなので、学習率のせいにしがちです。確認は簡単です。各パラメータを ±eps だけ動かして損失の変化を測り、backward の返した値と比べます。
# gradcheck.py - compare backprop gradients with central differences
import random
from xor_mlp import Net, X, T
def flat(net):
return [w for row in net.W1 for w in row] + net.b1 + net.W2 + [net.b2]
def load(net, v):
k, n_in, n_hid = 0, len(net.W1[0]), len(net.W2)
for row in net.W1:
row[:] = v[k:k + n_in]; k += n_in
net.b1 = v[k:k + n_hid]; k += n_hid
net.W2 = v[k:k + n_hid]; k += n_hid
net.b2 = v[k]
def flat_grads(g):
gW1, gb1, gW2, gb2 = g
return [w for row in gW1 for w in row] + gb1 + gW2 + [gb2]
def check(net, eps, broken=False):
if broken: # simulate a classic bug: forget tanh'
net.df = lambda h: 1.0
worst_abs = worst_rel = 0.0
for x, t in zip(X, T):
p = flat(net)
ana = flat_grads(net.backward(x, t))
for k in range(len(p)):
q = p[:]; q[k] += eps; load(net, q); lp = net.loss(x, t)
q[k] -= 2 * eps; load(net, q); lm = net.loss(x, t)
load(net, p)
num = (lp - lm) / (2 * eps)
d = abs(num - ana[k])
worst_abs = max(worst_abs, d)
worst_rel = max(worst_rel, d / max(abs(num) + abs(ana[k]), 1e-12))
return worst_abs, worst_rel, len(p)
for act in ("tanh", "sigmoid"):
for eps in (1e-2, 1e-4, 1e-6, 1e-8):
a, r, n = check(Net(2, 4, random.Random(42), act), eps)
print(f"{act:7s} eps={eps:.0e} max abs diff {a:.2e} max rel diff {r:.2e} ({n} params x 4 samples)")
a, r, n = check(Net(2, 4, random.Random(42), "tanh"), 1e-4, broken=True)
print(f"tanh with the bug eps=1e-04 max abs diff {a:.2e} max rel diff {r:.2e}")
tanh eps=1e-02 max abs diff 1.57e-05 max rel diff 1.67e-05 (17 params x 4 samples)
tanh eps=1e-04 max abs diff 1.57e-09 max rel diff 1.70e-09 (17 params x 4 samples)
tanh eps=1e-06 max abs diff 1.86e-10 max rel diff 1.44e-08 (17 params x 4 samples)
tanh eps=1e-08 max abs diff 1.76e-08 max rel diff 2.43e-06 (17 params x 4 samples)
sigmoid eps=1e-02 max abs diff 1.38e-06 max rel diff 4.17e-06 (17 params x 4 samples)
sigmoid eps=1e-04 max abs diff 1.37e-10 max rel diff 6.54e-10 (17 params x 4 samples)
sigmoid eps=1e-06 max abs diff 1.39e-10 max rel diff 3.73e-08 (17 params x 4 samples)
sigmoid eps=1e-08 max abs diff 1.15e-08 max rel diff 5.27e-06 (17 params x 4 samples)
tanh with the bug eps=1e-04 max abs diff 3.31e-01 max rel diff 4.11e-01
eps = 1e-4 のとき、正しい逆伝播との差は最大 1.6e-9(tanh)と 1.4e-10(sigmoid)でした。差は eps に対して単調ではありません。eps が大きいと打ち切り誤差が乗り(1e-2 で1.6e-5)、小さすぎるとほぼ等しい2つの損失の引き算で桁落ちします(1e-8 で1.8e-8)。float64 では 1e-4〜1e-6 あたりがちょうどよい範囲です。(1 − h²) を書き忘れたバグ版は0.33に跳ね上がり、正しいコードより8桁大きくなります。見落としようがありません。
小さな実データ風の課題:二つの月と同心円
XOR は4点しかないので、テスト精度に意味がありません。そこで、テスト用に分けても意味のある定番の合成データを2つ使います。互い違いに組んだ半円2つ(two moons)と、同心の輪2つ(circles)です。どちらも1,000点、ガウスノイズ0.15、seed 7 で生成し、600点で学習、400点でテストします。比較対象はロジスティック回帰、つまり隠れ層のないネットワークです。
# moons.py - linear model vs 2-H-1 network on two moons / circles (needs xor_mlp.py)
import math, random, time
from xor_mlp import Net, sigmoid
def two_moons(n, noise, rng):
pts = []
for k in range(n):
a = rng.uniform(0, math.pi)
if k % 2 == 0:
x, y, label = math.cos(a), math.sin(a), 0
else:
x, y, label = 1 - math.cos(a), 0.5 - math.sin(a), 1
pts.append(((x + rng.gauss(0, noise), y + rng.gauss(0, noise)), label))
return pts
def circles(n, noise, rng):
pts = []
for k in range(n):
a, r = rng.uniform(0, 2 * math.pi), (1.0 if k % 2 == 0 else 0.5)
pts.append(((r * math.cos(a) + rng.gauss(0, noise),
r * math.sin(a) + rng.gauss(0, noise)), k % 2))
return pts
def logistic(train, epochs, lr, rng): # the linear baseline: no hidden layer
w, b = [0.0, 0.0], 0.0
for _ in range(epochs):
rng.shuffle(train)
for x, t in train:
d = sigmoid(w[0] * x[0] + w[1] * x[1] + b) - t
w[0] -= lr * d * x[0]; w[1] -= lr * d * x[1]; b -= lr * d
return lambda x: sigmoid(w[0] * x[0] + w[1] * x[1] + b)
def mlp(train, epochs, lr, rng, n_hid):
net = Net(2, n_hid, rng, "tanh", scale=1.0)
for _ in range(epochs):
rng.shuffle(train)
for x, t in train:
net.step(net.backward(x, t), lr)
return lambda x: net.forward(x)[1]
def acc(predict, data):
return sum((predict(x) > 0.5) == (t == 1) for x, t in data) / len(data)
for name, make in (("two moons", two_moons), ("circles", circles)):
rng = random.Random(7)
data = make(1000, 0.15, rng)
train, test = data[:600], data[600:]
print(f"{name}: train {len(train)}, test {len(test)}, noise 0.15, 300 epochs, lr 0.05")
models = [("logistic", lambda r: logistic(train[:], 300, 0.05, r))]
models += [(f"2-{h}-1 tanh", lambda r, h=h: mlp(train[:], 300, 0.05, r, h)) for h in (2, 4, 8, 16)]
for label, fit in models:
accs, secs = [], []
for seed in range(5):
t0 = time.perf_counter()
predict = fit(random.Random(seed))
secs.append(time.perf_counter() - t0)
accs.append(acc(predict, test))
print(f" {label:13s} test acc mean {sum(accs)/5:.3f} (min {min(accs):.3f} max {max(accs):.3f})"
f" train time {sum(secs)/5:.2f}s/run")
two moons: train 600, test 400, noise 0.15, 300 epochs, lr 0.05
logistic test acc mean 0.872 (min 0.868 max 0.875) train time 0.13s/run
2-2-1 tanh test acc mean 0.889 (min 0.885 max 0.892) train time 0.95s/run
2-4-1 tanh test acc mean 0.990 (min 0.988 max 0.993) train time 1.45s/run
2-8-1 tanh test acc mean 0.988 (min 0.985 max 0.993) train time 2.42s/run
2-16-1 tanh test acc mean 0.988 (min 0.985 max 0.993) train time 4.43s/run
circles: train 600, test 400, noise 0.15, 300 epochs, lr 0.05
logistic test acc mean 0.507 (min 0.490 max 0.547) train time 0.12s/run
2-2-1 tanh test acc mean 0.746 (min 0.725 max 0.757) train time 0.92s/run
2-4-1 tanh test acc mean 0.922 (min 0.877 max 0.948) train time 1.46s/run
2-8-1 tanh test acc mean 0.944 (min 0.940 max 0.955) train time 2.47s/run
2-16-1 tanh test acc mean 0.940 (min 0.938 max 0.945) train time 4.47s/run
二つの月では線形モデルも悪くなく、87.2%でした。月が重なるのは先端だけなので、斜めの直線1本で大半の点を正しく分けられます。隠れ2ユニットはほとんど上積みがなく(88.9%)、4ユニットで99.0%に跳ね上がります。それ以上増やしても精度は上がらず、時間だけが比例して増えました。16ユニットは1回4.43秒、4ユニットは1.45秒です。
同心円では線形モデルが 50.7%、当てずっぽうと同じです。内側の円盤と外側の輪を分ける直線はありません。隠れ2ユニットで74.6%(直線2本で帯は切り出せても輪は作れない)、8ユニットで94.4%。ノイズ0.15 では輪同士が重なるので100%には届きません。4ユニットのばらつきにも注意してください。5シードで87.7%〜94.8%です。シード1つだけで報告すると、運を報告することになります。
素の Python での学習時間
ここまでの数字はすべて、CPython がリストに対する入れ子の for ループを実行した結果です。2-8-1 ネットワークの SGD 更新1回(順伝播・逆伝播・更新)は約 13 µs でした。600サンプル × 300エポック = 180,000回の更新で2.42秒です。2-16-1 は1回約25 µs(4.43秒)で、ループ構造から予想されるとおり、隠れユニット数にほぼ比例します。パラメータ3つを1つのループに直書きしたロジスティック回帰は1回0.7 µs(0.13秒)です。
学習用や、特徴量が数個で数千点程度のデータなら十分です。しかし画像や文章の埋め込みには向きません。入力784・隠れ128の層は、順伝播だけで1サンプルあたり約10万回の積和が必要で、ここでの2-8-1 は24回です。2-8-1 はパラメータ33個で更新1回13 µs、つまりパラメータ1個あたり約0.4 µs。この割合だと、重み10万個の層は1サンプルの更新に40ミリ秒程度かかる計算になります。フレームワークが存在する理由は、この差です。
ReLU・softmax・フレームワークで何が変わるか
ここでは何もインストールも実行もしていません。言葉での説明にとどめます。
- ReLU(
max(0, z))は、いまの隠れ層では tanh の代わりに使われるのが普通です。微分は1か0なので計算が軽く、正の入力で飽和しません。tanh の微分が掛け合わされて極小になる深い層では、ここが効きます。ReLU 固有の失敗は、どの入力にも0を出して勾配を受け取れなくなる「死んだユニット」です。今回測った崩壊の親戚と言えます。 - softmax と交差エントロピーは、sigmoid 出力を K クラスに一般化したものです。便利な性質はそのまま残り、出力層の誤差は t を one-hot ベクトルにした
y − tのままです。dz2 = y - tの1行がベクトルの引き算になるだけです。 - 初期化の方式(tanh 向けの Xavier/Glorot、ReLU 向けの He)は、初期値の幅を層の入力数に合わせて決めます。±0.5・±1.0・±2.0 の表を見れば、わざわざ名前を付けて決めている理由が分かります。XOR では、同じ隠れ2ユニットでも幅だけで成功率が0%から54%まで動きました。
- フレームワーク(PyTorch、TensorFlow、JAX)は
backwardを自動微分に置き換えます。書くのはforwardだけで、あとはloss.backward()を呼びます。ループはベクトル化された C/CUDA のカーネルで回り、パラメータごとに歩幅を調整する Adam などの最適化手法も使えます。それでも勾配チェックはなくなりません。手書きの微分は今でもいちばん静かにバグが入る場所なので、自作の演算向けにフレームワーク側でも用意されています。
線形モデルか隠れ層か:判断表
| 状況 | 選ぶもの | この記事での根拠 |
|---|---|---|
| クラスが直線で分かれる(AND、OR) | パーセプトロン / ロジスティック回帰 | AND は6エポック、OR は4エポック |
| ほぼ分かれていて、境界だけ重なる | まず線形で測り、差を見る | 二つの月:線形87.2%、隠れ8ユニット98.8% |
| 組み合わせで決まる(XOR、「AかBの片方だけ」) | 隠れ層、または x1·x2 のような特徴量を手で作る | パーセプトロンは永久ループ、2-H-1 は解く |
| 片方のクラスがもう片方を囲む(輪、半径で決まる) | 隠れ層、または x1² + x2² のような特徴量 | 同心円:線形50.7%、隠れ8ユニット94.4% |
| 重み1つ1つを説明する必要がある | 線形 | 特徴量ごとの重みにそのまま意味がある |
| 素の Python で安く学習したい | 線形 | 600点で1回0.13秒 vs 2.42秒 |
| 小さなネットがよく行き詰まる | 隠れユニットを増やす、初期値を広げる、活性化関数を変える | tanh ±1.0:2→8ユニットで41%→97% |
特徴量を1つ作るだけで線形になる(XOR なら x1·x2、同心円なら半径)なら、たいていそちらの方が良い答えです。速く、説明でき、行き詰まりません。隠れ層がコストに見合うのは、どんな特徴量を作ればよいか分からないときです。
正直な限界
- XOR は4点しかない。 ここでの「成功率」は学習の挙動を測ったもので、汎化性能ではありません。成功率は、学習率0.5、素の SGD、バイアスの初期値0、許容誤差0.1、上限5,000エポックという選択に依存します。どれかを変えれば数字も動きます。
- 崩壊の説明は、測った重み(全部1e-49、出力0.5)からの解釈です。証明はしていません。sigmoid の隠れ層がなぜ崩壊しないのかも突き止めていません。sigmoid の600試行で崩壊が1つもなく、失敗60回(すべて隠れ2ユニット)がすべて局所解だった、という観察だけです。
- データセットのシードは1つ。 二つの月と同心円は seed 7 で1回だけ生成し、学習シードを5つ回しました。ノイズの大きさが変われば、何ユニットで「十分」かも変わります。
- 正則化・検証用データ・早期終了なし。 600点に16ユニットでは過学習は見えませんでしたが、実データでは出ます。
- 測定は Python 3.9.6 のみ。 使っているのは
math・random・timeだけで非推奨の API もないので、3.12 以降でもそのまま動くはずです。時間は変わります。新しい CPython はこの種のループが速くなっています。 - ReLU・softmax・フレームワークは説明だけで、測定していません。 この記事のために何もインストールしていません。
次回はニューラルネットを離れて文章に戻ります。TF-IDF と BM25 で自分のブログ記事を検索する回です。そこには学習に使うラベルが1つもありません。
よくある質問
小さなニューラルネットは XOR でどのくらい学習に失敗する?
隠れ層 tanh、重みの初期値 ±1.0、学習率0.5の SGD で、隠れ2ユニットは100シード中41回、4ユニットは73回、8ユニットは97回成功しました。失敗の大半は局所解ではなく、重みが1e-49程度まで縮んで全入力に0.5を返す状態でした。
誤差逆伝播法の実装が正しいかどうやって確かめる?
各パラメータを ±eps だけ動かした中心差分 (L(w+eps) − L(w−eps)) / 2eps と、backward が返した勾配を比べます。eps = 1e-4 で最大差は1.6e-9でした。tanh の微分を書き忘れた版では0.33になり、一目で分かります。
線形モデルで十分なのはどんなとき?
クラスが直線でほぼ分かれるときです。二つの月のデータではロジスティック回帰が87.2%、隠れ8ユニットが98.8%。同心円では線形が50.7%(当てずっぽうと同じ)、隠れ8ユニットが94.4%でした。