Overfitting과 정규화: 모델이 답을 외우는 순간을 잡아내는 법
데이터를 train/validation/test로 나누는 이유와, 모델이 학습 데이터를 "외워버리는" 과적합(overfitting) 현상을 직접 눈으로 확인한다. 다항 회귀의 차수를 늘려가며 train 손실과 val 손실이 어떻게 갈라지는지 실험하고, L2 정규화와 early stopping으로 이를 막는 방법을 배운다. 이 과정은 이후 LLM을 파인튜닝할
By 송민성
오늘의 목표
모델이 "잘 배운 것"과 "그냥 외운 것"을 구분할 수 있어야 한다. 오늘은 학습 데이터에서는 점수가 좋은데 새 데이터에서는 형편없는 상황(과적합)을 직접 만들어보고, 이를 진단하고 완화하는 방법을 익힌다.
개념과 직관
Train / Validation / Test 분할
시험 공부에 비유하면 이해가 쉽다.
- Train set: 교과서와 연습문제 — 여기서 직접 공부(학습)한다.
- Validation set: 모의고사 — 공부 방법(하이퍼파라미터, 모델 구조)이 맞는지 중간 점검한다. 모의고사 점수를 보고 공부 전략을 계속 수정한다.
- Test set: 진짜 수능 — 마지막에 딱 한 번만 본다. 이걸 보고 다시 공부 방법을 고치면 안 된다.
데이터 누수(data leakage): 모의고사 문제를 미리 보고 공부한 것과 같다. 예를 들어 전처리(정규화 스케일 계산 등)를 전체 데이터에 대해 한 다음 train/test로 나누면, test 정보가 train 단계에 스며든다. 이러면 성능이 실제보다 좋아 보이는 착시가 생긴다.
Overfitting vs Underfitting
- Overfitting(과적합): 연습문제 답을 통째로 외워서, 문제 형식이 조금만 바뀌어도 못 푸는 상태. Train 손실은 매우 낮은데 Validation 손실은 높다.
- Underfitting(과소적합): 아예 공부를 제대로 안 한 상태. Train 손실부터 높다.
그림으로 그리면: x축이 "모델 복잡도(차수)", y축이 "손실"일 때, train 손실 곡선은 계속 내려가지만 val 손실 곡선은 어느 지점부터 다시 올라간다. 그 갈라지는 지점이 바로 "외우기 시작하는 순간"이다.
Bias-Variance (편향-분산 트레이드오프)
- Bias(편향): 모델이 너무 단순해서 패턴 자체를 못 잡는 정도. 편향이 크면 underfitting.
- Variance(분산): 데이터가 조금만 바뀌어도 모델이 크게 흔들리는 정도. 분산이 크면 overfitting.
- 비유: 편향은 "과녁 중심에서 계속 같은 방향으로 빗나가는 것"(못 맞추는 습관), 분산은 "쏠 때마다 완전히 다른 곳에 맞는 것"(일관성 없음). 좋은 모델은 둘 다 낮아야 하는데, 보통 하나를 줄이면 다른 하나가 늘어나는 경향이 있다.
정규화(regularization)
모델이 너무 복잡해지지(파라미터가 너무 커지지) 않도록 손실 함수에 페널티를 추가하는 방법이다.
- L2 정규화(릿지, Ridge): 손실 = 원래 손실 + 람다 × (파라미터들의 제곱합). 파라미터가 커질수록 벌점이 커지므로 모델이 완만해진다.
- L1 정규화(라쏘, Lasso): 손실 = 원래 손실 + 람다 × (파라미터들의 절댓값 합). 일부 파라미터를 아예 0으로 만들어버리는 경향이 있어서 "필요없는 특징 제거"에 가깝다.
- 람다(lambda)는 정규화 강도를 조절하는 하이퍼파라미터. 너무 크면 underfitting, 0이면 정규화 없음.
Early stopping: 매 epoch(학습 데이터를 한 바퀴 도는 단위)마다 validation 손실을 확인하다가, 그게 더 이상 내려가지 않고 올라가기 시작하면 학습을 멈추는 것. "모의고사 점수가 떨어지기 시작하면 그만 외우고 그만두는" 전략이다.
코드로 직접 해보기
원리를 보기 위해 먼저 numpy로 다항 회귀를 직접 만든다. 데이터는 약간의 곡선(sin 함수) + 노이즈로 생성한다.
import numpy as np
np.random.seed(0)
# 진짜 패턴: sin 곡선 + 잡음
def make_data(n):
x = np.random.uniform(-3, 3, n)
y = np.sin(x) + np.random.normal(0, 0.2, n)
return x, y
x_train, y_train = make_data(20) # 일부러 데이터를 적게
x_val, y_val = make_data(20)
def fit_poly(x, y, degree, l2=0.0):
# x를 [1, x, x^2, ..., x^degree] 형태로 확장
X = np.vstack([x**d for d in range(degree + 1)]).T
# L2 정규화가 들어간 정규방정식(normal equation)
I = np.eye(X.shape[1])
I[0, 0] = 0 # 절편(bias)은 정규화하지 않는다
w = np.linalg.solve(X.T @ X + l2 * I, X.T @ y)
return w
def predict(x, w):
degree = len(w) - 1
X = np.vstack([x**d for d in range(degree + 1)]).T
return X @ w
def mse(y_true, y_pred):
return np.mean((y_true - y_pred) ** 2)이제 차수를 1, 3, 9로 바꿔가며 train/val 손실을 비교한다.
for degree in [1, 3, 9]:
w = fit_poly(x_train, y_train, degree, l2=0.0)
train_loss = mse(y_train, predict(x_train, w))
val_loss = mse(y_val, predict(x_val, w))
print(f"degree={degree} train_loss={train_loss:.4f} val_loss={val_loss:.4f}")여기서 L2 정규화를 추가해서 9차 다항식이 어떻게 안정되는지 본다.
for l2 in [0.0, 0.01, 1.0]:
w = fit_poly(x_train, y_train, degree=9, l2=l2)
train_loss = mse(y_train, predict(x_train, w))
val_loss = mse(y_val, predict(x_val, w))
print(f"l2={l2} train_loss={train_loss:.4f} val_loss={val_loss:.4f}")오늘의 실험
- 차수를 1, 3, 5, 7, 9로 세분화해서 train_loss와 val_loss를 표로 기록한다. (차수, train_loss, val_loss) 형태로 리스트나 CSV에 남겨두면 나중에 비교하기 좋다.
- 몇 차부터 val_loss가 train_loss보다 눈에 띄게 커지는지 찾는다. 그 지점이 "과적합이 시작되는 복잡도"다.
- 9차 다항식에 l2 값을 0, 0.001, 0.01, 0.1, 1.0으로 바꿔가며 val_loss가 가장 낮아지는 지점을 찾는다. 이게 "람다를 튜닝한다"는 것의 실제 의미다.
- 가능하면 x_train 데이터를 20개에서 100개로 늘려서 같은 실험을 반복해본다. 데이터가 많아지면 9차 다항식도 과적합이 덜해지는지 관찰한다. (데이터 양과 과적합의 관계를 몸으로 느끼는 게 목적)
- matplotlib으로 x축을 -3~3 범위로 촘촘히 잡아 예측 곡선을 그려보면, 9차 다항식이 학습 데이터 점들 사이에서 얼마나 요동치는지(진짜 sin 곡선에서 벗어나는지) 시각적으로 확인할 수 있다.
왜 이걸 배우나 (LLM / Post-training 연결)
LLM을 지도 파인튜닝(SFT)할 때도 똑같은 문제가 생긴다. 학습 데이터에 있는 문장을 거의 그대로 외워서 답하는 모델은 train loss는 낮아도 실제 사용자 질문(분포가 조금 다른 입력)에는 약하다. Validation set으로 체크포인트를 고르고, early stopping으로 적절한 시점에 멈추고, 정규화(weight decay라는 이름으로 L2와 거의 같은 역할을 한다)를 쓰는 관행은 오늘 배운 원리가 그대로 확장된 것이다. Upstage 같은 곳에서 post-training 실험을 설계할 때도 "이 체크포인트가 학습 데이터를 외운 건지, 실제로 일반화된 건지"를 구분하는 게 핵심 평가 기준 중 하나다.
초보자가 막히는 지점
- Validation과 Test를 섞어 쓰는 실수: validation으로 하이퍼파라미터를 고르고 나서 그 test set으로 또 튜닝을 반복하면, test도 사실상 validation이 되어버려 최종 평가가 낙관적으로 부풀려진다. Test는 정말 마지막에 한 번만 봐야 한다.
- 전처리 순서로 인한 데이터 누수: 정규화(스케일링)나 결측치 채우기를 train+val+test 전체에 대해 한 번에 계산하면 안 된다. train에서만 통계(평균, 표준편차 등)를 계산하고 그 값을 val/test에도 그대로 적용해야 한다.
- "손실이 낮으면 좋은 모델"이라는 오해: train 손실만 보고 판단하면 안 된다. 반드시 val 손실과 같이 봐야 과적합 여부를 알 수 있다.
- 정규화 항에 bias(절편)까지 포함시키는 실수: 코드에서
I[0, 0] = 0으로 절편은 정규화 대상에서 뺀 것처럼, 보통 편향 파라미터는 정규화하지 않는 게 관례다. 이걸 놓치면 결과가 미묘하게 이상해진다. - 차수가 낮은데 왜 val_loss가 더 낮게 나오나 헷갈림: 데이터에 노이즈가 섞여 있으면 특정 시드에서는 우연히 그런 결과가 나올 수 있다. 여러 시드로 반복해서 평균적인 경향을 보는 습관을 들이자.
알고리즘 (병행 1시간)
- LeetCode Easy 1문제 + Medium 1문제(이분 탐색, binary search) 풀이.
- 이분 탐색 문제를 고를 때는 "정렬된 배열에서 값 찾기"류의 기본형으로 시작하는 걸 추천한다.
- 풀이 후 반드시 기록할 것:
- 접근 방식: 어떤 아이디어로 풀었는지 한두 문장. - 시간복잡도: Big-O로 표현 (예: 이분 탐색은 O(log n)). - 틀렸다면 이유: 경계 조건(low, high, mid 갱신 시점) 실수인지, 무한 루프인지, 인덱스 오프바이원(off-by-one) 오류인지 구체적으로 남긴다. 이분 탐색은 mid 계산과 while 조건, low/high 갱신에서 오프바이원 실수가 유독 잦으니 오늘 틀렸다면 그 지점을 정확히 짚어두자.