퍼셉트론에서 신경망까지: 순수 파이썬으로 XOR 실측
퍼셉트론은 AND·OR 는 배우지만 XOR 은 끝내 못 푼다. 손으로 짠 역전파 신경망은 풀지만, tanh 은닉 2개는 시드 100개 중 59번 실패했다. 실패의 정체, 기울기 검사, 두 개의 달 정확도까지 실측했다.
머신러닝 수업 과제나 부트캠프에서 "단층 퍼셉트론은 XOR 을 못 푼다, 은닉층을 넣고 역전파로 학습하면 푼다"를 외우고 넘어간 분이 많을 겁니다. 이 글은 그 두 문장을 실제 프로그램 출력으로 확인합니다. 아무것도 설치하지 않고 돌릴 수 있는 파이썬 약 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, 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
눈에 띄는 점은 세 가지입니다.
- 은닉 유닛을 늘리면 크게 좋아진다. tanh·±1.0 에서 성공은 41 → 73 → 97번, 풀 때까지 걸린 에포크 중앙값은 68 → 54 → 37 로 줄어듭니다.
- 성공할 때는 빨리 성공한다. tanh·±1.0 에서 101 에포크를 넘긴 성공 사례는 없습니다. 상한을 2,000 에포크로 낮춰 다시 돌려도 성공은 똑같이 41·73·97번이었습니다. 추가된 3,000 에포크가 살려 낸 시도는 하나도 없다는 뜻입니다. tanh 가 수백 에포크 안에 못 풀면 더 기다려도 소용없습니다.
- 초기값 범위가 구조만큼 중요하다. ±0.5 의 tanh 는 거의 전멸입니다(100번 중 0·1·5번). 같은 ±0.5 라도 sigmoid 은닉층은 77·100·100번 성공했습니다. 느리지만(H=2 중앙값 488 에포크) 훨씬 잘 안 깨집니다.
시간 열은 대부분 막힌 시도의 몫입니다. 막힌 시도는 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 은닉층은 저희 실험에서 이런 붕괴를 한 번도 일으키지 않았습니다. 실패는 모두 진짜 지역 최솟값으로, 입력 한두 개는 완벽하게 맞히고 나머지는 같은 어중간한 값(위 예에서는 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), 너무 작으면 거의 같은 두 손실을 빼면서 자릿수를 잃습니다(1e-8 에서 1.8e-8). float64 에서는 1e-4~1e-6 근처가 적당합니다. (1 − h²) 를 빠뜨린 버그 버전은 0.33 으로 뛰어, 올바른 코드보다 8자릿수 큽니다. 놓칠 수가 없습니다.
작은 실전풍 과제: 두 개의 달과 동심원
XOR 은 점이 4개뿐이라 테스트 정확도가 의미가 없습니다. 그래서 테스트용으로 나눠도 의미 있는 대표적인 합성 데이터 두 가지를 씁니다. 서로 맞물린 반원 두 개(two moons)와 같은 중심의 고리 두 개(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%), 4개에서 99.0% 로 뜁니다. 그 이상 늘려도 정확도는 그대로이고 시간만 비례해서 늘었습니다. 은닉 16개는 1회 4.43초, 4개는 1.45초입니다.
동심원에서는 선형 모델이 50.7%, 찍기와 같습니다. 안쪽 원판과 바깥 고리를 가르는 직선은 없습니다. 은닉 2개는 74.6%(직선 두 개로 띠는 잘라내도 고리는 못 만듭니다), 8개는 94.4% 입니다. 노이즈 0.15 에서는 고리끼리 겹치기 때문에 100% 는 불가능합니다. 은닉 4개의 편차도 보세요. 시드 5개에서 87.7%~94.8% 입니다. 시드 하나로만 보고하면 실력이 아니라 운을 보고하게 됩니다.
순수 파이썬 학습 시간
위의 숫자는 전부 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회 0.7 µs(0.13초)입니다.
공부용이나, 특성이 몇 개뿐인 수천 개 규모 데이터라면 충분합니다. 하지만 이미지나 문장 임베딩에는 맞지 않습니다. 입력 784·은닉 128 층은 순전파만으로 샘플당 곱셈·덧셈이 약 10만 번 필요한데, 여기 2-8-1 은 24번입니다. 2-8-1 은 파라미터 33개에 갱신 1회 13 µs, 즉 파라미터당 약 0.4 µs 입니다. 이 비율이면 가중치 10만 개짜리 층은 샘플 하나 갱신에 40 ms 정도 걸린다는 계산이 나옵니다. 프레임워크가 존재하는 이유가 바로 이 차이입니다.
ReLU, softmax, 프레임워크를 쓰면 무엇이 바뀌나
여기서는 아무것도 설치하거나 실행하지 않았습니다. 말로만 설명합니다.
- ReLU(
max(0, z))는 요즘 은닉층에서 tanh 대신 쓰는 게 보통입니다. 미분이 1 아니면 0 이라 계산이 가볍고, 양수 입력에서 포화하지 않습니다. tanh 의 미분이 계속 곱해져 아주 작아지는 깊은 층에서 이 점이 중요합니다. ReLU 만의 실패는 어떤 입력에도 0 을 내서 기울기를 못 받는 "죽은 유닛"입니다. 이번에 잰 붕괴의 사촌쯤 됩니다. - softmax 와 교차 엔트로피는 sigmoid 출력을 K개 클래스로 일반화한 것입니다. 편리한 성질은 그대로라서, t 를 원-핫 벡터로 바꾼
y − t가 여전히 출력층 오차입니다.dz2 = y - t한 줄이 벡터 뺄셈이 될 뿐입니다. - 초기화 방식(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% |
| 가중치 하나하나를 설명해야 함 | 선형 | 특성별 가중치에 의미가 그대로 있음 |
| 순수 파이썬으로 싸게 학습해야 함 | 선형 | 점 600개에 1회 0.13초 vs 2.42초 |
| 작은 신경망이 자주 막힘 | 은닉 유닛 늘리기, 초기값 넓히기, 활성화 함수 바꾸기 | tanh ±1.0: 은닉 2→8개에서 41%→97% |
특성 하나만 만들면 선형이 되는 경우(XOR 은 x1·x2, 동심원은 반지름)라면 대개 그쪽이 더 나은 답입니다. 빠르고, 설명할 수 있고, 막히지 않습니다. 은닉층이 비용값을 하는 건 어떤 특성을 만들어야 할지 모를 때입니다.
솔직한 한계
- XOR 은 점 4개다. 여기서 말하는 "성공률"은 학습이 어떻게 움직이는지를 잰 것이지 일반화 성능이 아닙니다. 성공률은 학습률 0.5, 기본 SGD, 편향 초기값 0, 허용 오차 0.1, 상한 5,000 에포크라는 선택에 달려 있습니다. 하나만 바꿔도 숫자가 움직입니다.
- 붕괴에 대한 설명은 잰 가중치(전부 1e-49, 출력 0.5)를 보고 한 해석입니다. 증명하지 않았습니다. sigmoid 은닉층이 왜 붕괴하지 않는지도 밝히지 못했습니다. sigmoid 600번 시도에서 붕괴가 한 번도 없었고, 실패 60번(모두 은닉 2개)이 전부 지역 최솟값이었다는 관찰뿐입니다.
- 데이터셋 시드는 하나다. 두 개의 달과 동심원은 seed 7 로 한 번만 만들고, 학습 시드를 5개 돌렸습니다. 노이즈 크기가 바뀌면 은닉 몇 개면 "충분한지"도 바뀝니다.
- 정규화·검증 세트·조기 종료 없음. 점 600개에 은닉 16개에서는 과적합이 보이지 않았지만, 실제 데이터에서는 나타납니다.
- 측정은 Python 3.9.6 에서만 했다.
math·random·time만 쓰고 폐기 예정 API 도 없으니 3.12 이상에서도 그대로 돌아갈 겁니다. 시간은 달라집니다. 최신 CPython 은 이런 루프가 더 빠릅니다. - ReLU·softmax·프레임워크는 설명만 했고 측정하지 않았다. 이 글을 위해 아무것도 설치하지 않았습니다.
다음 강에서는 신경망을 떠나 다시 글로 돌아갑니다. TF-IDF 와 BM25 로 우리 블로그 글을 검색하는 강의로, 학습에 쓸 라벨이 하나도 없는 문제입니다.
자주 묻는 질문
작은 신경망은 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% 였습니다.