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| 최대 |
|---|---|---|---|---|
| 5 | 6 | 0.2081 | 0.3080 | 5.218e+00 |
| 9 | 10 | 0.1202 | 2.0056 | 5.460e+01 |
| 13 | 14 | 0.1013 | 244.80 | 1.362e+04 |
| 17 | 18 | 0.0000 | 174523.69 | 1.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으로 만들어 특성을 선택한다.
- 조기 종료는 벌점 없이도 같은 효과를 내지만 믿을 만한 검증 세트가 필요하다.
- 정규화 세기는 교차검증으로 고르고, 전처리 통계는 폴드 안에서만 학습한다.
다음 편에서는 특성 스케일링 자체를 다룬다. 이번 글에서 표준화를 전제로 깔고 지나갔는데, 스케일에 따라 경사하강의 수렴 속도가 어떻게 달라지는지 실측한다.
이 시리즈의 다른 글
- 머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이
- 머신러닝 입문 (2) — 선형회귀를 밑바닥부터 구현하기
- 머신러닝 입문 (3) — 경사하강법과 학습률
- 머신러닝 입문 (4) — 로지스틱 회귀와 이진 분류
- 머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기
- 머신러닝 입문 (6) — 자동미분(autograd) 직접 만들기