パーセプトロンからニューラルネットへ: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² です。

2段のフロー図。上段が順伝播で、入力 x、隠れ層 h = tanh(W1·x + b1)、出力 y = sigmoid(W2·h + b2)、正解 t との二値交差エントロピー損失の順。下段は右から左への逆伝播で、出力の誤差 dz2 = y − t、隠れ層の誤差 dz1 = dz2 · W2 · (1 − h²)、そして W1・b1・W2・b2 から学習率×勾配を引く更新。更新から入力へ点線の矢印が戻り、次のサンプルに進む。
2-H-1 ネットワークの学習1ステップ。順伝播、損失、逆伝播、更新の順に進む。逆伝播では順伝播で計算した h と y をそのまま使う。

このファイルが記事の中心です。後のスクリプトはここから 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
XOR の成功率(100シード中)の棒グラフ。隠れ層 tanh:2ユニット41%、4ユニット73%、8ユニット97%。隠れ層 sigmoid:2ユニット63%、4ユニット100%、8ユニット100%。
100シードのうち、5,000エポック以内に XOR を学習できた割合(4出力すべてが正解から0.1以内)。SGD、学習率0.5、重みの初期値は±1.0の一様分布。Python 3.9 標準ライブラリ、Apple M1 Pro。

目立つ点は3つです。

  1. 隠れユニットを増やすと大きく改善する。 tanh・±1.0 では成功が 41 → 73 → 97 回、解けるまでのエポック数の中央値は 68 → 54 → 37 と下がります。
  2. 成功するときはすぐ成功する。 tanh・±1.0 で101エポックを超えた成功例はありません。上限を2,000エポックにして回し直しても成功は同じ41・73・97回で、追加の3,000エポックで救われた試行は1つもありませんでした。tanh で数百エポック以内に解けないなら、待っても無駄です。
  3. 初期値の幅は構造と同じくらい効く。 ±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%、隠れ2ユニット88.9%、隠れ8ユニット98.8%。同心円:ロジスティック回帰50.7%、隠れ2ユニット74.6%、隠れ8ユニット94.4%。
2種類の合成データ(seed 7、ノイズ0.15、300エポック)での線形モデルと 2-H-1 tanh ネットワークのテスト精度(400点、5シードの平均)。

二つの月では線形モデルも悪くなく、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%でした。

  • #機械学習
  • #ニューラルネットワーク
  • #誤差逆伝播法
  • #Python
  • #パーセプトロン
  • #スクラッチ実装

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑