머신러닝 입문 (7) — 과적합과 정규화

6편까지 1단계를 끝내면서 “파라미터를 더 주면 학습 손실은 계속 내려간다”는 것을 확인했다. 문제는 그렇게 줄인 손실이 새 데이터에서는 더 나빠질 수 있다는 점이다. 5편에서 은닉 뉴런을 늘렸을 때 평가 손실이 도로 올라간 것이 그 현상이다. 이 글에서는 다항회귀로 과적합을 숫자로 재현하고, L2·L1 벌점과 조기 종료로 그것을 억제한 뒤, 평가 세트를 보지 않고 벌점 세기를 고르는 교차검증까지 numpy만으로 돌려본다.

실험용 데이터

정답 곡선에 표준편차 0.22의 잡음을 섞어 학습 18개, 평가 400개를 만들고 시드를 고정했다. 다항 특성은 학습 데이터 기준으로 표준화한다 — x^15는 열마다 크기가 수십만 배 차이 나서 그냥 두면 해가 불안정하고 L2 벌점이 열마다 다른 세기로 걸린다.

# -*- coding: utf-8 -*-
"""과적합 실험용 합성 데이터와 다항 특성. 시드를 고정해 매 실행 같은 값이 나온다."""
import numpy as np

TRUE_F = lambda x: np.sin(2.2 * x) + 0.35 * x      # 정답 곡선(모델은 모른다)
NOISE = 0.22

def make(n_train=18, n_test=400, seed=7):
    rng = np.random.default_rng(seed)
    xtr = np.sort(rng.uniform(-2.5, 2.5, n_train))
    ytr = TRUE_F(xtr) + rng.normal(0, NOISE, n_train)
    xte = np.linspace(-2.5, 2.5, n_test)
    yte = TRUE_F(xte) + rng.normal(0, NOISE, n_test)
    return xtr, ytr, xte, yte

class Poly:
    """[1, x, x^2, ...]를 만들고 1열을 뺀 나머지를 학습 데이터 기준으로 표준화한다.

    x^15처럼 열마다 크기가 수십만 배 차이 나면 조건수가 커져 해가 불안정하고,
    L2 벌점이 열마다 다른 세기로 걸린다. 그래서 스케일을 먼저 맞춘다.
    """
    def __init__(self, degree): self.d = degree
    def fit(self, x):
        P = np.vander(x, self.d + 1, increasing=True)[:, 1:]
        self.mu, self.sd = P.mean(0), P.std(0)
        self.sd[self.sd == 0] = 1.0
        return self
    def transform(self, x):
        P = np.vander(x, self.d + 1, increasing=True)[:, 1:]
        return np.column_stack([np.ones(len(x)), (P - self.mu) / self.sd])
    def fit_transform(self, x): return self.fit(x).transform(x)

def rmse(a, b): return float(np.sqrt(np.mean((a - b) ** 2)))

차수를 올리면 어떻게 되는가

파라미터 개수만 늘려가며 정규화 없는 최소제곱으로 푼다.

# -*- coding: utf-8 -*-
"""차수를 올리면 학습 오차는 계속 내려가지만 평가 오차는 어느 지점부터 올라간다."""
import numpy as np
from over_data import make, Poly, rmse

xtr, ytr, xte, yte = make()
print(f"학습 {len(xtr)}개 / 평가 {len(xte)}개 / 잡음 표준편차 0.22")
print(f"{'차수':>4} {'파라미터':>8} {'학습RMSE':>10} {'평가RMSE':>10} {'|w|최대':>11}")
for d in [1, 2, 3, 5, 7, 9, 11, 13, 15, 17]:
    pf = Poly(d).fit(xtr)
    Xtr, Xte = pf.transform(xtr), pf.transform(xte)
    w, *_ = np.linalg.lstsq(Xtr, ytr, rcond=None)      # 정규화 없는 최소제곱
    print(f"{d:>4} {d+1:>8} {rmse(Xtr@w,ytr):>10.4f} {rmse(Xte@w,yte):>10.4f} {np.abs(w).max():>11.3e}")
$ python3 poly_fit.py
학습 18개 / 평가 400개 / 잡음 표준편차 0.22
  차수     파라미터     학습RMSE     평가RMSE       |w|최대
   1        2     0.5304     0.8490   4.392e-01
   2        3     0.5257     0.8606   4.566e-01
   3        4     0.3824     0.7029   1.141e+00
   5        6     0.2081     0.3080   5.218e+00
   7        8     0.1563     0.7245   1.663e+01
   9       10     0.1202     2.0056   5.460e+01
  11       12     0.1157     9.9155   4.914e+02
  13       14     0.1013   244.8006   1.362e+04
  15       16     0.0804  2613.4552   2.569e+05
  17       18     0.0000 174523.6924   1.895e+07

학습 RMSE는 단조 감소해 17차에서 0이 된다. 파라미터 18개로 점 18개를 정확히 통과했기 때문인데, 평가 RMSE는 5차에서 0.3080으로 최소가 된 뒤 폭발해 17차에서 174523까지 간다.

계수 크기가 같이 폭발한 것이 단서로, |w| 최대값이 5차 5.2에서 17차 1.9e+07까지 커졌다. 점들을 정확히 지나려고 서로 상쇄하는 거대한 계수를 쓰게 된 것이다.

차수파라미터학습 RMSE평가 RMSE|w| 최대
560.20810.30805.218e+00
9100.12022.00565.460e+01
13140.1013244.801.362e+04
17180.0000174523.691.895e+07

L2 벌점: 계수 크기에 비용을 붙인다

손실에 lambda * ||w||²를 더하면 정규방정식이 (XᵀX + lambda·I)w = Xᵀy가 된다. 절편은 벌하지 않으므로 단위행렬의 (0,0)만 0으로 둔다.

# -*- coding: utf-8 -*-
"""같은 15차 모델에 L2 벌점만 붙여 lambda를 훑는다. 절편(w0)은 벌하지 않는다."""
import numpy as np
from over_data import make, Poly, rmse

xtr, ytr, xte, yte = make()
pf = Poly(15).fit(xtr)
Xtr, Xte = pf.transform(xtr), pf.transform(xte)

def ridge(X, y, lam):
    I = np.eye(X.shape[1]); I[0, 0] = 0.0          # 절편 제외
    return np.linalg.solve(X.T @ X + lam * I, X.T @ y)

print(f"15차 다항(파라미터 16개) / 학습 {len(xtr)}개")
print(f"{'lambda':>9} {'학습RMSE':>10} {'평가RMSE':>10} {'|w|최대':>11} {'||w||2':>11}")
best = (1e18, None)
for lam in [0.0, 1e-6, 1e-4, 1e-3, 1e-2, 1e-1, 1.0, 3.0, 10.0, 100.0]:
    w = ridge(Xtr, ytr, lam)
    te = rmse(Xte @ w, yte)
    print(f"{lam:>9.0e} {rmse(Xtr@w,ytr):>10.4f} {te:>10.4f} {np.abs(w).max():>11.3e} {np.linalg.norm(w):>11.3f}")
    if te < best[0]: best = (te, lam)
print(f"\n평가 RMSE 최소: lambda={best[1]:.0e} 에서 {best[0]:.4f}")
print(f"(정규화 없는 5차 모델의 평가 RMSE는 0.3080 이었다)")
$ python3 ridge_scan.py
15차 다항(파라미터 16개) / 학습 18개
   lambda     학습RMSE     평가RMSE       |w|최대      ||w||2
    0e+00     0.0804  2657.0719   2.606e+05  390327.725
    1e-06     0.1197     2.2723   5.018e+01      65.541
    1e-04     0.1273     1.1018   9.061e+00      15.290
    1e-03     0.1351     0.4488   5.809e+00       9.949
    1e-02     0.1783     0.3757   4.233e+00       5.820
    1e-01     0.2818     0.5394   1.506e+00       2.209
    1e+00     0.3678     0.8585   8.404e-01       1.004
    3e+00     0.4291     0.8423   5.815e-01       0.711
    1e+01     0.5192     0.8111   3.258e-01       0.464
    1e+02     0.6442     0.8169   3.120e-01       0.319

평가 RMSE 최소: lambda=1e-02 에서 0.3757
(정규화 없는 5차 모델의 평가 RMSE는 0.3080 이었다)

같은 15차 모델인데 평가 RMSE가 2657에서 0.3757까지 내려갔다. 정규화 없는 5차 모델(0.3080)에 근접한 값을 모델을 줄이지 않고 얻은 것이다.

||w||₂가 390327에서 5.8로 줄어드는 동안 학습 RMSE는 0.0804에서 0.1783으로 올라갔다. 학습 손실을 일부러 포기해 평가 손실을 사는 거래다.

L1 벌점: 계수를 0으로 만든다

L1은 절댓값이라 0에서 미분이 안 된다. 경사 한 걸음 뒤에 soft-threshold를 적용하는 근접 경사(ISTA)로 푼다.

# -*- coding: utf-8 -*-
"""같은 lambda에서 L2는 계수를 줄이고 L1은 0으로 만든다. L1은 근접 경사(ISTA)로 푼다."""
import numpy as np
from over_data import make, Poly, rmse

xtr, ytr, xte, yte = make()
pf = Poly(15).fit(xtr)
Xtr, Xte = pf.transform(xtr), pf.transform(xte)
n, k = Xtr.shape

def ridge(lam):
    I = np.eye(k); I[0, 0] = 0.0
    return np.linalg.solve(Xtr.T @ Xtr + lam * I, Xtr.T @ ytr)

def lasso(lam, steps=200000, lr=None):
    """L1은 미분 불가라 soft-threshold를 쓴다: w <- sign(w) * max(|w|-lr*lam/n, 0)"""
    L = np.linalg.eigvalsh(Xtr.T @ Xtr).max() / n
    lr = lr or 1.0 / L
    w = np.zeros(k)
    for _ in range(steps):
        g = Xtr.T @ (Xtr @ w - ytr) / n
        w = w - lr * g
        thr = lr * lam / n
        w[1:] = np.sign(w[1:]) * np.maximum(np.abs(w[1:]) - thr, 0.0)   # 절편 제외
    return w

for lam in [1e-2, 1e-1, 1.0]:
    w2, w1 = ridge(lam), lasso(lam)
    print(f"--- lambda = {lam:g} ---")
    print(f"  L2: 평가RMSE={rmse(Xte@w2,yte):7.4f}  0인 계수={int((np.abs(w2[1:])<1e-8).sum()):2d}/15  ||w||1={np.abs(w2[1:]).sum():8.3f}")
    print(f"  L1: 평가RMSE={rmse(Xte@w1,yte):7.4f}  0인 계수={int((np.abs(w1[1:])<1e-8).sum()):2d}/15  ||w||1={np.abs(w1[1:]).sum():8.3f}")

w1 = lasso(1e-1)
nz = [i for i in range(1, k) if abs(w1[i]) >= 1e-8]
print(f"\nlambda=0.1 L1이 남긴 항의 차수: {nz}")
print("계수:", np.array2string(w1[nz], precision=3, suppress_small=True))
$ python3 l1_vs_l2.py
--- lambda = 0.01 ---
  L2: 평가RMSE= 0.3757  0인 계수= 0/15  ||w||1=  16.237
  L1: 평가RMSE= 0.4926  0인 계수= 7/15  ||w||1=  17.325
--- lambda = 0.1 ---
  L2: 평가RMSE= 0.5394  0인 계수= 0/15  ||w||1=   5.041
  L1: 평가RMSE= 0.5216  0인 계수=10/15  ||w||1=   4.496
--- lambda = 1 ---
  L2: 평가RMSE= 0.8585  0인 계수= 0/15  ||w||1=   2.133
  L1: 평가RMSE= 0.7826  0인 계수=12/15  ||w||1=   0.812

lambda=0.1 L1이 남긴 항의 차수: [1, 2, 3, 8, 9]
계수: [ 1.629 -0.196 -1.816  0.153  0.702]

같은 lambda에서 L2는 0인 계수가 하나도 없고 L1은 7~12개를 0으로 만든다. 이 데이터에서 평가 RMSE는 L2가 조금 낫지만, L1은 “어떤 차수가 필요한지”를 골라준다.

벌점식에 더하는 항효과해법
L2 (ridge)lambda · Σwᵢ²계수를 전체적으로 줄인다닫힌 해 (XᵀX+lambda·I)⁻¹Xᵀy
L1 (lasso)lambda · Σ|wᵢ|일부 계수를 정확히 0으로 만든다(특성 선택)근접 경사, 좌표하강 등 반복법

데이터를 늘리는 것도 정규화다

모델을 그대로 두고 학습 개수만 늘려본다.

# -*- coding: utf-8 -*-
"""정규화 없이도 데이터가 늘어나면 같은 모델의 과적합이 줄어든다."""
import numpy as np
from over_data import make, Poly, rmse

print(f"{'학습개수':>8} {'15차 학습':>10} {'15차 평가':>12} {'5차 평가':>10}")
for n in [18, 30, 60, 120, 400, 2000]:
    xtr, ytr, xte, yte = make(n_train=n)
    row = []
    for d in (15, 5):
        pf = Poly(d).fit(xtr)
        Xtr, Xte = pf.transform(xtr), pf.transform(xte)
        w, *_ = np.linalg.lstsq(Xtr, ytr, rcond=None)
        row.append((rmse(Xtr@w, ytr), rmse(Xte@w, yte)))
    print(f"{n:>8} {row[0][0]:>10.4f} {row[0][1]:>12.4f} {row[1][1]:>10.4f}")
print("\n(평가 데이터의 잡음 표준편차 0.22가 도달 가능한 하한이다)")
$ python3 datasize.py
    학습개수     15차 학습       15차 평가      5차 평가
      18     0.0804    2613.4552     0.3080
      30     0.1495       0.3197     0.2441
      60     0.1613       0.2424     0.2399
     120     0.1855       0.2222     0.2448
     400     0.2076       0.2202     0.2441
    2000     0.2196       0.2102     0.2374

(평가 데이터의 잡음 표준편차 0.22가 도달 가능한 하한이다)

학습 18개에서 2613이던 15차 모델의 평가 RMSE가 30개에서 0.3197로 떨어지고, 2000개에서는 0.2102로 잡음 하한(0.22)에 닿는다. 데이터가 충분하면 15차가 5차보다 오히려 낫다.

조기 종료

경사하강으로 학습할 때는 벌점을 붙이지 않고도 “언제 멈추는지”로 과적합을 조절할 수 있다. 학습 30개를 20/10으로 쪼개 검증 손실을 지켜본다.

# -*- coding: utf-8 -*-
"""경사하강으로 15차 모델을 학습하며 검증 손실이 다시 올라가는 지점을 찾는다."""
import numpy as np
from over_data import make, Poly, rmse

N = 30
xtr, ytr, xte, yte = make(n_train=N)
rng = np.random.default_rng(1)
idx = rng.permutation(N); tr, va = idx[:20], idx[20:]      # 학습 20 / 검증 10
pf = Poly(15).fit(xtr[tr])
Xtr, Xva, Xte = pf.transform(xtr[tr]), pf.transform(xtr[va]), pf.transform(xte)
ytr_, yva = ytr[tr], ytr[va]

w = np.zeros(Xtr.shape[1]); lr = 0.05
best = (1e18, 0, None)
print(f"15차 / 학습 {len(tr)}개 / 검증 {len(va)}개 / lr={lr}")
print(f"{'step':>8} {'학습RMSE':>10} {'검증RMSE':>10} {'평가RMSE':>10}")
for step in range(1, 100001):
    w -= lr * (Xtr.T @ (Xtr @ w - ytr_) / len(ytr_))
    v = rmse(Xva @ w, yva)
    if v < best[0]: best = (v, step, w.copy())
    if step in (100, 1000, 5000, 20000, 50000, 100000):
        print(f"{step:>8} {rmse(Xtr@w,ytr_):>10.4f} {v:>10.4f} {rmse(Xte@w,yte):>10.4f}")
print(f"\n검증 최소: step={best[1]} (검증RMSE={best[0]:.4f}) -> 그때 평가RMSE={rmse(Xte@best[2],yte):.4f}")
print(f"끝까지(100000 step): 검증RMSE={rmse(Xva@w,yva):.4f}, 평가RMSE={rmse(Xte@w,yte):.4f}")
print(f"즉 조기 종료로 평가 RMSE가 {rmse(Xte@w,yte)/rmse(Xte@best[2],yte):.2f}배 나빠지는 것을 막았다")
$ python3 early_stop.py
15차 / 학습 20개 / 검증 10개 / lr=0.05
    step     학습RMSE     검증RMSE     평가RMSE
     100     0.4917     0.7995     0.6785
    1000     0.3907     0.5343     0.4969
    5000     0.3040     0.6047     0.4125
   20000     0.2131     0.2559     0.2795
   50000     0.1785     0.7530     0.4195
  100000     0.1702     0.8674     0.4635

검증 최소: step=19283 (검증RMSE=0.2541) -> 그때 평가RMSE=0.2800
끝까지(100000 step): 검증RMSE=0.8674, 평가RMSE=0.4635
즉 조기 종료로 평가 RMSE가 1.66배 나빠지는 것을 막았다

검증 RMSE는 19283 스텝에서 0.2541로 최소가 된 뒤 다시 올라 100000 스텝에서 0.8674가 된다. 그 지점의 가중치를 쓰면 평가 RMSE가 0.2800이고, 끝까지 돌리면 0.4635다.

벌점 세기는 평가 세트를 보지 않고 고른다

위에서 lambda를 고를 때 평가 RMSE 표를 훑어봤는데 실전에서는 그 표를 볼 수 없고, 학습 데이터만 K개로 쪼개 돌려가며 고르는 것이 교차검증이다. 스케일링도 폴드 안에서만 학습해야 한다 — 전체 데이터로 평균·표준편차를 구하면 검증 폴드 정보가 새어 들어간다.

# -*- coding: utf-8 -*-
"""평가 세트를 보지 않고 lambda를 고르는 방법: K-겹 교차검증. 데이터 양에 따라 신뢰도가 다르다."""
import numpy as np
from over_data import make, Poly, rmse

D, K = 15, 6
LAMS = [1e-6, 1e-4, 1e-3, 1e-2, 1e-1, 1.0, 10.0]

def ridge(X, y, lam):
    I = np.eye(X.shape[1]); I[0, 0] = 0.0
    return np.linalg.solve(X.T @ X + lam * I, X.T @ y)

def run(n_train):
    xtr, ytr, xte, yte = make(n_train=n_train)
    rng = np.random.default_rng(0)
    folds = np.array_split(rng.permutation(len(xtr)), K)
    print(f"\n=== 학습 {n_train}개 / {K}-겹 교차검증 / {D}차 ===")
    print(f"{'lambda':>9} {'CV RMSE':>10} {'평가RMSE':>10}")
    cv, te = {}, {}
    for lam in LAMS:
        errs = []
        for i in range(K):
            va = folds[i]; tr = np.concatenate([folds[j] for j in range(K) if j != i])
            pf = Poly(D).fit(xtr[tr])                 # 스케일링도 폴드 안에서만 학습
            w = ridge(pf.transform(xtr[tr]), ytr[tr], lam)
            errs.append(rmse(pf.transform(xtr[va]) @ w, ytr[va]))
        cv[lam] = float(np.mean(errs))
        pf = Poly(D).fit(xtr); w = ridge(pf.transform(xtr), ytr, lam)
        te[lam] = rmse(pf.transform(xte) @ w, yte)
        print(f"{lam:>9.0e} {cv[lam]:>10.4f} {te[lam]:>10.4f}")
    pick, oracle = min(cv, key=cv.get), min(te, key=te.get)
    print(f"CV 선택: lambda={pick:.0e} -> 평가RMSE {te[pick]:.4f}")
    print(f"평가 세트를 훔쳐본 최적: lambda={oracle:.0e} -> 평가RMSE {te[oracle]:.4f}")

run(18)
run(60)
$ python3 kfold.py

=== 학습 18개 / 6-겹 교차검증 / 15차 ===
   lambda    CV RMSE     평가RMSE
    1e-06    27.4519     2.2723
    1e-04     7.7323     1.1018
    1e-03     2.3136     0.4488
    1e-02     2.4783     0.3757
    1e-01     1.1319     0.5394
    1e+00     0.6285     0.8585
    1e+01     0.5823     0.8111
CV 선택: lambda=1e+01 -> 평가RMSE 0.8111
평가 세트를 훔쳐본 최적: lambda=1e-02 -> 평가RMSE 0.3757

=== 학습 60개 / 6-겹 교차검증 / 15차 ===
   lambda    CV RMSE     평가RMSE
    1e-06     0.2999     0.2179
    1e-04     0.2451     0.2144
    1e-03     0.2330     0.2162
    1e-02     0.2391     0.2308
    1e-01     0.2703     0.2848
    1e+00     0.4075     0.4385
    1e+01     0.5814     0.6331
CV 선택: lambda=1e-03 -> 평가RMSE 0.2162
평가 세트를 훔쳐본 최적: lambda=1e-04 -> 평가RMSE 0.2144

학습 18개에서는 교차검증이 lambda=1e+01을 골라 평가 RMSE 0.8111이 됐고, 평가 세트를 훔쳐본 최적값(1e-02, 0.3757)과 두 배 이상 벌어진다. 폴드마다 학습에 쓰는 데이터가 15개뿐이라 CV 추정 자체가 불안정하기 때문이다.

학습 60개에서는 CV가 1e-03을 골라 0.2162가 됐고, 최적값(1e-04, 0.2144)과 거의 같다. 교차검증은 데이터가 적을 때 가장 필요하지만 그때 가장 못 믿을 만하다.

주의사항

  • 절편은 벌하지 않는다. I[0,0]=0을 빼먹으면 모델이 y의 평균조차 맞추지 못한다.
  • 스케일링 없이 L2를 걸면 벌점이 열마다 다른 세기로 걸린다. x와 x¹⁵의 크기 차이가 수십만 배라 같은 lambda가 전혀 다른 제약이 된다.
  • 전처리 통계는 학습 폴드 안에서만 구한다. 전체 데이터로 표준화한 뒤 교차검증하면 데이터 누수(data leakage)로 CV 점수가 낙관적으로 나온다.
  • 학습 오차로는 과적합을 알 수 없다. 17차 모델의 학습 RMSE는 0이었다. 반드시 분리된 데이터로 재야 한다.
  • 검증 세트가 작으면 조기 종료 지점도 믿을 수 없다. 위 실험을 학습 18개(검증 6개)로 돌렸을 때는 검증 최소가 15 스텝에서 나와 과소학습 지점을 골랐다.
  • L1 해는 반복법이라 수렴을 확인해야 한다. 스텝 수를 줄이면 0이 되어야 할 계수가 작은 값으로 남는다.

마무리

  • 과적합은 “파라미터가 많다”가 아니라 데이터 양에 비해 많다는 문제다. 15차 모델도 학습 2000개에서는 가장 좋았다.
  • L2는 계수를 전체적으로 줄이고, L1은 일부를 0으로 만들어 특성을 선택한다.
  • 조기 종료는 벌점 없이도 같은 효과를 내지만 믿을 만한 검증 세트가 필요하다.
  • 정규화 세기는 교차검증으로 고르고, 전처리 통계는 폴드 안에서만 학습한다.

다음 편에서는 특성 스케일링 자체를 다룬다. 이번 글에서 표준화를 전제로 깔고 지나갔는데, 스케일에 따라 경사하강의 수렴 속도가 어떻게 달라지는지 실측한다.

이 시리즈의 다른 글

참고

답글 남기기