2편의 정규방정식은 손실이 이차식일 때만 쓸 수 있고, 로지스틱 회귀나 신경망에는 그런 닫힌 해가 없다. 그래서 대부분의 학습은 기울기를 따라 조금씩 내려가는 경사하강법(gradient descent)으로 한다. 이 글에서는 같은 데이터로 경사하강을 구현해 닫힌 해에 도달시키고, 학습률의 발산 경계를 이론값과 실측으로 맞춰본 뒤, 특성 스케일과 배치 크기가 수렴에 얼마나 개입하는지 확인한다.
기울기를 따라 내려가기
손실 L(θ) = ‖Xθ - y‖² / n의 기울기는 ∇L = (2/n)·Xᵀ(Xθ - y)다. 이 방향의 반대로 학습률만큼 움직이는 것을 반복한다.
#!/usr/bin/env python3
"""경사하강법으로 최소제곱과 같은 해에 도달하는지 확인한다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
X = np.column_stack([entropy[tr], np.ones(len(tr))])
y = ratio[tr]
n = len(y)
def mse(t):
return float(np.mean((X @ t - y) ** 2))
def grad(t): # dL/dtheta = 2/n * X^T (X theta - y)
return 2.0 / n * (X.T @ (X @ t - y))
theta = np.zeros(2)
lr = 0.02
print(f"{'step':>6}{'MSE':>12}{'w':>10}{'b':>10}{'|grad|':>12}")
for step in range(4001):
if step in (0, 10, 100, 500, 1000, 2000, 4000):
g = np.linalg.norm(grad(theta))
print(f"{step:>6}{mse(theta):>12.6f}{theta[0]:>10.4f}{theta[1]:>10.4f}{g:>12.2e}")
theta -= lr * grad(theta)
closed = np.linalg.lstsq(X, y, rcond=None)[0]
print(f"\n닫힌 해 : w={closed[0]:+.6f} b={closed[1]:+.6f} MSE={mse(closed):.8f}")
print(f"경사하강 4000: w={theta[0]:+.6f} b={theta[1]:+.6f} MSE={mse(theta):.8f}")$ python3 gd.py
step MSE w b |grad|
0 0.329871 0.0000 0.0000 6.39e+00
10 0.039635 0.0917 -0.0004 7.59e-02
100 0.031221 0.1118 -0.1217 6.10e-02
500 0.018110 0.1627 -0.4296 2.30e-02
1000 0.016123 0.1844 -0.5611 6.81e-03
2000 0.015934 0.1928 -0.6115 5.96e-04
4000 0.015932 0.1936 -0.6163 4.57e-06
닫힌 해 : w=+0.193583 b=-0.616326 MSE=0.01593235
경사하강 4000: w=+0.193577 b=-0.616289 MSE=0.01593235
4000스텝에서 소수점 다섯째 자리까지 닫힌 해와 일치한다. 기울기 크기가 6.39에서 4.57e-06까지 줄어드는 것이 “더 내려갈 곳이 없다”는 신호다.
학습률에는 이론적 상한이 있다
최소제곱에서는 헤세 행렬이 (2/n)·XᵀX로 상수라 발산하지 않는 학습률의 경계를 계산할 수 있다: 학습률 < 2 / λ_max.
#!/usr/bin/env python3
"""학습률만 바꿔 가며 1000스텝 뒤 상태를 비교하고, 이론적 상한과 맞춰본다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
X = np.column_stack([entropy[tr], np.ones(len(tr))])
y = ratio[tr]
n = len(y)
best = np.linalg.lstsq(X, y, rcond=None)[0]
best_mse = float(np.mean((X @ best - y) ** 2))
# 손실 L(t)=|Xt-y|^2/n 의 헤세 행렬은 (2/n) X^T X 로 상수다.
# 경사하강이 발산하지 않을 조건은 lr < 2 / (헤세의 최대 고윳값).
H = 2.0 / n * (X.T @ X)
eig = np.linalg.eigvalsh(H)
lr_max = 2.0 / eig.max()
print(f"헤세 고윳값 : {eig.min():.4f} ~ {eig.max():.4f}")
print(f"이론적 학습률 상한: {lr_max:.6f}")
print(f"최소 MSE(닫힌 해) : {best_mse:.8f}\n")
def train(lr, steps=1000):
t = np.zeros(2)
with np.errstate(over="ignore", invalid="ignore"):
for s in range(steps):
t = t - lr * (2.0 / n * (X.T @ (X @ t - y)))
if not np.all(np.isfinite(t)) or np.abs(t).max() > 1e12:
return None, s
return t, steps
print(f"{'학습률':>9}{'1000스텝 MSE':>15}{'w':>9}{'b':>9} 상태")
print("-" * 55)
for lr in (0.0001, 0.001, 0.01, 0.02, 0.028, 0.030, 0.04):
t, s = train(lr)
if t is None:
print(f"{lr:>9}{'발산':>15}{'-':>9}{'-':>9} {s}스텝 만에 폭발")
continue
m = float(np.mean((X @ t - y) ** 2))
gap = m - best_mse
state = "수렴" if gap < 1e-7 else ("거의 수렴" if gap < 1e-4 else "느림")
print(f"{lr:>9}{m:>15.8f}{t[0]:>9.4f}{t[1]:>9.4f} {state}")$ python3 lr_scan.py
헤세 고윳값 : 0.1216 ~ 70.6874
이론적 학습률 상한: 0.028294
최소 MSE(닫힌 해) : 0.01593235
학습률 1000스텝 MSE w b 상태
-------------------------------------------------------
0.0001 0.04022022 0.0904 0.0071 느림
0.001 0.03544363 0.1012 -0.0575 느림
0.01 0.01811362 0.1627 -0.4295 느림
0.02 0.01612297 0.1844 -0.5611 느림
0.028 0.01595941 0.1901 -0.5955 거의 수렴
0.03 발산 - - 263스텝 만에 폭발
0.04 발산 - - 49스텝 만에 폭발
이론값 0.028294를 사이에 두고 0.028은 거의 수렴, 0.030은 263스텝 만에 폭발한다. 발산은 서서히 나빠지지 않고 경계를 넘는 순간 갑자기 터진다.
고윳값 비율 70.6874 / 0.1216 ≈ 581은 2편의 cond(XᵀX)와 같은 값이다. 학습률은 가장 가파른 방향에 맞춰야 하므로, 조건수가 클수록 가장 완만한 방향은 그만큼 느리게 내려간다.
스케일을 안 맞추면 학습이 불가능해진다
앞의 문제는 특성이 entropy 하나였다. 여기에 size(수만~수백만 범위)를 그대로 더하면 어떻게 되는지 본다.
#!/usr/bin/env python3
"""특성 스케일이 경사하강에 미치는 영향을 조건수와 함께 본다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
y = ratio[tr]
n = len(y)
raw = np.column_stack([entropy[tr], size[tr], np.ones(n)])
mu, sd = raw[:, :2].mean(0), raw[:, :2].std(0)
std = np.column_stack([(raw[:, :2] - mu) / sd, np.ones(n)])
def analyze(name, X):
H = 2.0 / n * (X.T @ X)
eig = np.linalg.eigvalsh(H)
lr = 0.9 * 2.0 / eig.max() # 상한의 90%로 고정
best = float(np.mean((X @ np.linalg.lstsq(X, y, rcond=None)[0] - y) ** 2))
t = np.zeros(X.shape[1])
hit = None
with np.errstate(over="ignore", invalid="ignore"):
for s in range(1, 200001):
t = t - lr * (2.0 / n * (X.T @ (X @ t - y)))
if hit is None and np.isfinite(t).all():
if float(np.mean((X @ t - y) ** 2)) < best * 1.001:
hit = s
break
print(f"[{name}]")
print(f" 조건수 cond(X^T X) = {np.linalg.cond(X.T @ X):.3e} 쓸 수 있는 학습률 = {lr:.3e}")
print(f" 최소 MSE의 100.1%에 도달한 스텝: {hit if hit else '200000 안에 도달 못함'}")
analyze("원본 스케일 (entropy, size)", raw)
analyze("표준화 (평균 0 / 표준편차 1)", std)$ python3 scaling.py
[원본 스케일 (entropy, size)]
조건수 cond(X^T X) = 4.502e+11 쓸 수 있는 학습률 = 3.295e-11
최소 MSE의 100.1%에 도달한 스텝: 200000 안에 도달 못함
[표준화 (평균 0 / 표준편차 1)]
조건수 cond(X^T X) = 1.056e+00 쓸 수 있는 학습률 = 8.760e-01
최소 MSE의 100.1%에 도달한 스텝: 19
원본 스케일에서는 조건수 4.5e+11 때문에 쓸 수 있는 학습률이 3.3e-11로 떨어져 20만 스텝으로도 목표에 못 갔다. 표준화하자 조건수가 1.06이 되고 19스텝이면 끝난다.
| 원본 스케일 | 표준화 | |
|---|---|---|
| cond(XᵀX) | 4.502e+11 | 1.056 |
| 쓸 수 있는 학습률 | 3.295e-11 | 0.876 |
| 목표 도달 | 200,000스텝에도 실패 | 19스텝 |
배치 크기: 한 번에 몇 개를 볼 것인가
지금까지는 매 스텝마다 학습 표본 전부로 기울기를 계산했다(full-batch). 일부만 뽑아 쓰면 한 번의 데이터 통과(에폭)당 파라미터를 여러 번 갱신할 수 있다.
#!/usr/bin/env python3
"""같은 에폭 예산에서 배치 크기가 수렴 속도와 흔들림에 미치는 영향을 본다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
y = ratio[tr]
n = len(y)
raw = np.column_stack([entropy[tr], size[tr]])
X = np.column_stack([(raw - raw.mean(0)) / raw.std(0), np.ones(n)])
best = float(np.mean((X @ np.linalg.lstsq(X, y, rcond=None)[0] - y) ** 2))
def mse(t):
return float(np.mean((X @ t - y) ** 2))
def run(batch, lr, epochs=30, seed=0):
rng = np.random.default_rng(seed)
t = np.zeros(X.shape[1])
hist = []
for _ in range(epochs):
order = rng.permutation(n)
for i in range(0, n, batch):
j = order[i:i + batch]
t = t - lr * (2.0 / len(j) * (X[j].T @ (X[j] @ t - y[j])))
hist.append(mse(t))
return t, np.array(hist)
print(f"최소 MSE = {best:.8f}, 에폭 예산 30회 (학습률은 모두 0.05로 고정)\n")
print(f"{'배치':>8}{'에폭당 갱신':>11}{'최종 MSE':>13}{'최소값 대비':>12}{'마지막 10회 표준편차':>20}")
print("-" * 66)
for batch in (n, 64, 16, 4, 1):
t, hist = run(batch, 0.05)
label = f"{batch}(전체)" if batch == n else str(batch)
print(f"{label:>8}{int(np.ceil(n / batch)):>11}{hist[-1]:>13.8f}"
f"{hist[-1] / best:>12.4f}{hist[-10:].std():>20.2e}")$ python3 sgd.py
최소 MSE = 0.01506606, 에폭 예산 30회 (학습률은 모두 0.05로 고정)
배치 에폭당 갱신 최종 MSE 최소값 대비 마지막 10회 표준편차
------------------------------------------------------------------
251(전체) 1 0.01563897 1.0380 1.02e-03
64 4 0.01506623 1.0000 5.73e-06
16 16 0.01509069 1.0016 5.62e-05
4 63 0.01541132 1.0229 1.85e-04
1 251 0.01765421 1.1718 3.88e-04
전체 배치는 30에폭 동안 갱신이 30번뿐이라 최소값의 3.8% 위에 머물렀고, 배치 64는 최소에 도달했다. 배치 1(순수 SGD)은 갱신을 251배 더 하고도 표본 하나로 잰 잡음 섞인 기울기 때문에 17% 위에서 흔들린다.
주의사항
- 학습률은 데이터에 딸린 값이다. 위에서 0.03이 터진 이유는 이 설계행렬의 고윳값이 70.7이기 때문이다. 특성을 하나 추가하거나 스케일을 바꾸면 안전한 학습률도 같이 바뀐다.
- 표준화 통계는 학습 데이터에서만 계산한다. 평가 데이터까지 포함해 평균·표준편차를 구하면 평가 정보가 학습에 새어 들어간다(데이터 누수). 이 주제는 2단계에서 따로 다룬다.
- 손실이
nan이나inf가 되면 발산을 의심한다. 위lr_scan.py도 파라미터가 유한하지 않거나1e12를 넘으면 발산으로 판정했다. 학습률을 10배 낮춰 다시 돌리는 것이 가장 빠른 확인이다. - 배치를 줄이면 학습률도 줄여야 한다. 기울기 추정의 잡음이 커지므로, 같은 학습률로는 최소 근처에서 더 크게 튄다.
- 수렴 판정을 스텝 수로 하지 말 것. 기울기 크기나 손실 변화량으로 멈추는 게 맞다. 위 예제는 비교를 위해 스텝 수를 고정했을 뿐이다.
마무리
경사하강법 자체는 θ -= lr * grad 한 줄이고, 학습률 상한은 데이터와 특성 스케일이, 배치 크기는 속도와 잡음의 균형이 정한다. 다음 편에서는 닫힌 해가 아예 없는 분류 문제로 넘어가 이 루프를 그대로 쓴다.
이 시리즈의 다른 글
- 머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이
- 머신러닝 입문 (2) — 선형회귀를 밑바닥부터 구현하기
- 머신러닝 입문 (4) — 로지스틱 회귀와 이진 분류
- 머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기
- 머신러닝 입문 (6) — 자동미분(autograd) 직접 만들기