과적합 진단과 대응
이 챕터에서 배우는 것
Ch05에서fit()을 돌리고history로 학습 곡선을 그리는 법까지 익혔습니다. 이 챕터는 그 곡선을 읽고 판단하는 법, 그리고 판단 결과에 따라 손을 쓰는 법을 다룹니다.
시험 연관성: 실기 딥러닝 문항은 대부분 "EarlyStopping을 적용해 학습시키시오"라는 지시를 포함합니다. 콜백(callback) 세 줄을 정확히 쓰는 것이 이 챕터의 실전 목표입니다.
6.1 과적합은 "외운 것"과 "이해한 것"의 차이입니다
과적합(overfitting)은 모델이 학습 데이터의 정답을 통째로 외워 버려서, 처음 보는 데이터에서는 성능이 떨어지는 상태입니다.
도해의 세 패널은 같은 데이터에 대해 모델이 그은 경계선입니다.
| 상태 | 경계선 모습 | 학습 데이터 성능 | 새 데이터 성능 |
|---|---|---|---|
| 과소적합(underfitting) | 너무 단순한 직선 | 낮음 | 낮음 |
| 적정 | 완만한 곡선 | 높음 | 높음 |
| 과적합 | 점 하나하나를 감싸는 구불구불한 선 | 매우 높음 | 낮음 |
오른쪽 패널의 구불구불한 경계는 학습 데이터에 섞인 잡음(noise)까지 정답으로 받아들인 결과입니다. 그 잡음은 새 데이터에는 없으므로, 외운 만큼 손해를 봅니다. Ch03의 ch03_width-depth에서 "층과 노드를 늘리면 표현력은 커지지만 과적합 위험도 커진다"고 한 것이 바로 이 그림입니다.
문제는 학습 중에는 이 그림을 볼 수 없다는 점입니다. 우리가 볼 수 있는 것은 숫자뿐입니다. 그래서 학습 곡선이 필요합니다.
6.2 학습 곡선 — 두 선이 벌어지는 지점을 찾습니다
판별 기준은 한 문장입니다. 학습 손실은 계속 내려가는데 검증 손실이 어느 지점부터 올라가면 과적합입니다.
Ch05의 ch05_history-curve에서 곡선의 구성 요소(x축 epoch, 실선 학습 손실, 점선 검증 손실)를 확인했습니다. 여기서는 그 두 선의 관계만 봅니다.
| 곡선 모양 | 진단 | 조치 |
|---|---|---|
| 두 선이 함께 내려가 나란히 수렴 | 정상 | 그대로 두거나 epoch을 조금 더 |
| 학습 손실은 하강, 검증 손실은 상승(벌어짐) | 과적합 | Dropout·EarlyStopping 적용 |
| 두 선 모두 높은 값에서 정체 | 과소적합 | 층·노드 늘리기, epoch 늘리기, Dropout 줄이기 |
| 검증 손실이 위아래로 심하게 튐 | 학습 불안정 | batch_size 늘리기, 학습률 낮추기(Ch05 ch05_optimizer-lr) |
과적합을 일부러 만들어 곡선으로 확인하는 코드입니다. 샘플 수는 적고(400개) 모델은 큰(노드 256짜리 은닉층 2개) 상황을 만들면 과적합이 잘 재현됩니다.
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input, Dense
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# python·numpy·TensorFlow의 난수를 한 줄로 함께 고정합니다
tf.keras.utils.set_random_seed(42)
# 잡음(flip_y)이 섞인 작은 합성 데이터
X, y = make_classification(n_samples=400, n_features=30, n_informative=5,
n_redundant=0, flip_y=0.10, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
tf.keras.utils.set_random_seed(42) # 초기 가중치까지 고정하려면 모델을 만들기 직전에 한 번 더
model = Sequential()
model.add(Input(shape=(X_train_scaled.shape[1],)))
model.add(Dense(256, activation='relu'))
model.add(Dense(256, activation='relu'))
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
history = model.fit(X_train_scaled, y_train, epochs=50, batch_size=16,
validation_split=0.2, verbose=0)
h = history.history
print('마지막 epoch 학습 손실:', round(h['loss'][-1], 4))
print('마지막 epoch 검증 손실:', round(h['val_loss'][-1], 4))
print('검증 손실 최저 epoch:', int(np.argmin(h['val_loss'])) + 1,
'/ 최저값:', round(min(h['val_loss']), 4))
print('마지막 학습 정확도:', round(h['accuracy'][-1], 4),
'/ 마지막 검증 정확도:', round(h['val_accuracy'][-1], 4))
출력입니다.
마지막 epoch 학습 손실: 0.0005
마지막 epoch 검증 손실: 1.4136
검증 손실 최저 epoch: 3 / 최저값: 0.5857
마지막 학습 정확도: 1.0 / 마지막 검증 정확도: 0.7321
네 줄이 과적합의 정의를 그대로 보여 줍니다. 학습 손실은 0.0005까지 내려갔고 학습 정확도는 1.0, 즉 학습 데이터는 한 문제도 틀리지 않습니다. 그런데 검증 손실은 최저값 0.5857의 두 배가 넘는 1.4136까지 올라갔고, 검증 정확도는 0.7321에 머물렀습니다.
50번을 돌렸지만 실제로 쓸 만한 모델은 3번째 epoch에 있었고, 나머지 47번은 외우는 데 쓴 것입니다.
tf.keras.utils.set_random_seed(42)는 파이썬·numpy·TensorFlow의 난수를 한 줄로 고정하는 함수입니다. 이 줄이 없으면 같은 코드를 돌려도 위 숫자가 매번 달라집니다. 실기에서 재현성이 필요할 때 이 한 줄을 쓰십시오.
Ch05에서 만든 학습 곡선 플롯 함수를 그대로 씁니다.
def plot_history(history, title=''):
h = history.history
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(h['loss'], label='train loss')
plt.plot(h['val_loss'], label='val loss')
plt.xlabel('epoch'); plt.ylabel('loss'); plt.legend(); plt.title(title + ' loss')
plt.subplot(1, 2, 2)
plt.plot(h['accuracy'], label='train acc')
plt.plot(h['val_accuracy'], label='val acc')
plt.xlabel('epoch'); plt.ylabel('accuracy'); plt.legend(); plt.title(title + ' accuracy')
plt.tight_layout()
plt.show()
plot_history(history, 'no dropout')
그림에서는 두 선이 3번째 epoch 부근에서 갈라진 뒤 영영 만나지 않습니다. 학습 손실은 계속 바닥으로 내려가고 검증 손실은 방향을 틀어 올라갑니다. 그 갈라지는 지점이 도해 왼쪽 패널의 "과적합 시작" 세로 점선입니다.
⚠️ 시험 포인트
과적합 판단의 기준은 검증 손실(val_loss)입니다. 위 실험처럼 학습 정확도(accuracy)가 1.0이라는 사실만으로 "잘 학습됐다"고 답하면 안 됩니다. 진짜 성적은 검증 정확도 0.7321 쪽입니다. 답안에 학습 곡선을 그리라는 지시가 있으면loss와val_loss를 한 그래프에 함께 그려야 비교가 됩니다.
6.3 Dropout — 학습할 때만 끄고, 예측할 때는 켭니다
Ch04에서는 Dropout(0.3)을 어디에 넣는지(은닉층 Dense 뒤)만 다뤘습니다. 여기서는 그 층이 실제로 무슨 일을 하는지 봅니다.
Dropout(0.3)은 학습 중 매 step마다 그 층 노드의 30%를 무작위로 골라 임시로 끄는 층입니다. 도해 왼쪽처럼 꺼진 노드는 그 step 동안 출력이 0이 되고, 연결된 가중치도 갱신되지 않습니다. 중요한 것은 "매 step마다 꺼지는 노드가 달라진다"는 점입니다. 특정 노드가 영구히 삭제되는 것이 아닙니다.
이렇게 하면 왜 과적합이 줄어들까요. 노드 몇 개가 수시로 사라지므로, 모델은 어느 한 노드에만 의존하는 지름길을 만들 수 없습니다. 여러 노드가 조금씩 나눠서 판단하도록 강제되고, 그 결과 경계선이 6.1 도해의 오른쪽처럼 구불구불해지기 어려워집니다.
그리고 도해 오른쪽이 핵심입니다. 예측할 때는 아무 노드도 끄지 않습니다. predict()나 evaluate()를 부르면 Dropout 층은 저절로 통과 모드가 되어 모든 노드를 그대로 흘려보냅니다. 예측값이 호출할 때마다 달라지면 곤란하기 때문입니다.
같은 입력을 반복해 넣어 두 모드의 차이를 직접 확인하는 코드입니다.
from tensorflow.keras.layers import Dropout
tf.keras.utils.set_random_seed(42)
d_model = Sequential()
d_model.add(Input(shape=(X_train_scaled.shape[1],)))
d_model.add(Dense(64, activation='relu'))
d_model.add(Dropout(0.3))
d_model.add(Dense(1, activation='sigmoid'))
d_model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
sample = X_train_scaled[:1]
# (1) 예측 모드 — Dropout이 꺼져 있어 값이 항상 같습니다
print('predict 1회차:', np.round(d_model.predict(sample, verbose=0).ravel(), 4))
print('predict 2회차:', np.round(d_model.predict(sample, verbose=0).ravel(), 4))
# (2) 학습 모드 — 꺼지는 노드가 매번 달라 값이 흔들립니다 (원리 확인용 호출)
print('학습 모드 1회차:', np.round(d_model(sample, training=True).numpy().ravel(), 4))
print('학습 모드 2회차:', np.round(d_model(sample, training=True).numpy().ravel(), 4))
출력입니다.
predict 1회차: [0.5059]
predict 2회차: [0.5059] ← 동일
학습 모드 1회차: [0.3552]
학습 모드 2회차: [0.5609] ← 매번 다름
학습을 시키지 않은 모델이라 예측값이 0.5 근처에 머물러 있지만, 볼 것은 값의 크기가 아니라 같은 입력에 대해 값이 고정되는지 흔들리는지입니다. predict()는 두 번 다 0.5059로 같고, 학습 모드는 0.3552와 0.5609로 다릅니다. 매번 다른 노드가 꺼진다는 증거입니다.
training=True 호출은 원리 확인용이며, 시험 답안에 쓸 일은 없습니다. 답안에서는 언제나 predict()를 씁니다.
Dropout 비율을 바꿔 가며 학습 곡선과 검증 지표가 어떻게 달라지는지 비교합니다.
def build_model(dropout_rate, units=256):
m = Sequential()
m.add(Input(shape=(X_train_scaled.shape[1],)))
m.add(Dense(units, activation='relu'))
if dropout_rate > 0:
m.add(Dropout(dropout_rate))
m.add(Dense(units, activation='relu'))
if dropout_rate > 0:
m.add(Dropout(dropout_rate))
m.add(Dense(1, activation='sigmoid'))
m.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
return m
hist_by_rate = {}
for rate in [0.0, 0.3, 0.5, 0.8]:
tf.keras.utils.set_random_seed(42)
m = build_model(rate)
hist_by_rate[rate] = m.fit(X_train_scaled, y_train, epochs=60, batch_size=16,
validation_split=0.2, verbose=0)
hh = hist_by_rate[rate].history
print(f'dropout={rate} 최저 val_loss={min(hh["val_loss"]):.4f} '
f'마지막 val_loss={hh["val_loss"][-1]:.4f} '
f'최고 val_acc={max(hh["val_accuracy"]):.4f}')
# 곡선으로도 비교합니다 — Dropout 없음 vs 0.5
plot_history(hist_by_rate[0.0], 'dropout=0.0')
plot_history(hist_by_rate[0.5], 'dropout=0.5')
출력입니다.
dropout=0.0 최저 val_loss=0.5857 마지막 val_loss=1.4784 최고 val_acc=0.7321
dropout=0.3 최저 val_loss=0.5776 마지막 val_loss=1.6485 최고 val_acc=0.7857
dropout=0.5 최저 val_loss=0.5722 마지막 val_loss=1.2115 최고 val_acc=0.7857
dropout=0.8 최저 val_loss=0.5633 마지막 val_loss=0.5864 최고 val_acc=0.7143
| Dropout 비율 | 최저 val_loss | 마지막 val_loss | 최저 대비 상승폭 | 최고 val_acc |
|---|---|---|---|---|
| 0.0 | 0.5857 | 1.4784 | 약 2.5배 | 0.7321 |
| 0.3 | 0.5776 | 1.6485 | 약 2.9배 | 0.7857 |
| 0.5 | 0.5722 | 1.2115 | 약 2.1배 | 0.7857 |
| 0.8 | 0.5633 | 0.5864 | 약 1.04배 | 0.7143 |
이 결과는 "비율을 올릴수록 좋아진다"는 기대와 맞지 않습니다. dropout=0.3의 마지막 검증 손실(1.6485)은 Dropout이 아예 없는 경우(1.4784)보다 오히려 나쁩니다. 교재가 이 수치를 그대로 싣는 이유는, 여러분이 코드를 돌렸을 때 보게 될 것이 바로 이 모습이기 때문입니다.
읽는 법은 이렇습니다.
- Dropout은 과적합을 없애는 스위치가 아니라 완화 장치입니다. 400행짜리 잡음 섞인 데이터에서 0.3 정도로는 후반 붕괴를 막지 못합니다.
- 비율을 충분히 키우면 붕괴 폭이 줄어듭니다. 0.5는 2.1배, 0.8은 1.04배로, 특히 0.8은 검증 손실이 거의 무너지지 않습니다. 학습 곡선에서 두 선이 벌어지는 정도가 줄었다는 뜻입니다.
- 그렇다고 많이 끌수록 좋은 것도 아닙니다.
dropout=0.8의 최고 검증 정확도는 0.7143으로 네 조건 중 가장 낮습니다. 노드 대부분이 꺼져 모델이 학습할 기회 자체를 잃었습니다. - Dropout의 실제 이득은 여기서 드러납니다. 0.3과 0.5의 최고 검증 정확도는 0.7857로, Dropout이 없을 때의 0.7321보다 확실히 높습니다.
정리하면, Dropout을 평가할 때 볼 것은 "마지막 손실이 낮아졌는가"가 아니라 학습 곡선의 두 선이 덜 벌어졌는가입니다. 그리고 결정적으로, Dropout은 가장 좋은 지점에서 학습을 멈춰 주지 않습니다. 네 조건 모두 최저값은 초반에 찍고 그 뒤로 나빠졌습니다. 그래서 다음 절의 EarlyStopping이 반드시 함께 필요합니다.
⚠️ 시험 포인트
Dropout(0.3)의 0.3은 끄는 비율입니다. 남기는 비율이 아닙니다. 0.8처럼 큰 값을 넣으면 노드 대부분이 꺼져, 위 실험처럼 검증 정확도가 오히려 떨어집니다. 실기에서는 0.2~0.5 사이를 쓰고, 지시가 없으면 0.3을 기본값으로 삼으십시오.
6.4 EarlyStopping — 가장 좋았던 지점에서 멈추고 되돌립니다
6.2의 실험에서 우리는 "쓸 만한 모델은 3번째 epoch에 있었다"는 사실을 50번을 다 돌린 뒤에야 알았습니다. EarlyStopping은 그 판단을 학습 중에 자동으로 해 주는 콜백입니다.
콜백은 학습 도중 정해진 시점에 끼어들어 무언가를 하는 도구입니다. fit()에 callbacks=[...] 리스트로 넘기면 Keras가 매 epoch이 끝날 때마다 불러 줍니다.
EarlyStopping의 인자는 세 개만 외우면 됩니다.
| 인자 | 의미 | 시험 표준값 |
|---|---|---|
monitor |
무엇을 기준으로 판단할지 | 'val_loss' |
patience |
개선이 없는 epoch을 몇 번까지 참을지 | 5 (또는 문제 지시값) |
restore_best_weights |
멈춘 뒤 가장 좋았던 시점의 가중치로 되돌릴지 | True |
도해가 이 세 인자를 그대로 그린 것입니다(도해에 적힌 숫자는 관계를 보여 주기 위한 예시이며, 아래 실행 결과의 값과는 다릅니다). 검증 손실 최저점이 best epoch, 그 뒤로 개선 없이 흘러간 구간이 patience, 브래킷 끝의 세로선이 중단 지점, 그리고 best로 돌아가는 곡선 화살표가 restore_best_weights=True입니다.
restore_best_weights를 빠뜨리면 어떻게 될까요. 학습은 멈추지만 모델에 남는 가중치는 최저점이 아니라 patience만큼 더 나빠진 마지막 epoch의 것입니다. 기본값이 False이므로 반드시 직접 True로 적어야 합니다.
EarlyStopping을 붙여 같은 실험을 다시 돌립니다.
from tensorflow.keras.callbacks import EarlyStopping
tf.keras.utils.set_random_seed(42)
model = build_model(0.3)
es = EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True)
history = model.fit(X_train_scaled, y_train, epochs=100, batch_size=32,
validation_split=0.2, callbacks=[es], verbose=0)
print('지시한 epochs: 100')
print('실제로 학습한 epoch 수:', len(history.history['loss']))
print('검증 손실 최저 epoch:', int(np.argmin(history.history['val_loss'])) + 1)
출력입니다.
지시한 epochs: 100
실제로 학습한 epoch 수: 11
검증 손실 최저 epoch: 6
11번째에서 멈췄고, 최저점은 6번째였습니다. 11 = 6 + 5, 즉 최저점 이후 patience=5만큼 기다렸다가 중단한 것입니다. restore_best_weights=True 덕분에 지금 model이 들고 있는 가중치는 11번째가 아니라 6번째 epoch의 것입니다.
이 산술 관계는 데이터가 바뀌어도 그대로 성립합니다.
중단 epoch = 검증 지표가 가장 좋았던 epoch + patience
답안이 의도대로 동작했는지 스스로 검산하는 도구로 쓰십시오. 두 숫자를 출력해 보고 차이가 patience와 다르면 무언가 잘못된 것입니다. 특히 len(history.history['loss'])가 지시한 epochs와 똑같이 나왔다면 콜백이 동작하지 않았다는 신호입니다.
그래서 EarlyStopping을 쓸 때는 epochs를 넉넉하게(100~200) 주는 것이 정석입니다. 어차피 콜백이 알아서 멈추므로, 작게 주면 오히려 최적점에 닿기 전에 끝나 버립니다.
⚠️ 시험 포인트
monitor='val_loss'인데fit()에validation_split이나validation_data가 없으면 검증 손실이 계산되지 않아 콜백이 동작하지 않거나 경고가 납니다. 검증 데이터 지정과 EarlyStopping은 항상 한 쌍입니다.
참고로monitor='val_accuracy'처럼 클수록 좋은 지표를 감시할 때는mode='max'를 함께 적어 두면 안전합니다.
6.5 ModelCheckpoint — 좋았던 순간을 파일로 남깁니다
도해의 흐름이 두 콜백이 하는 일의 전부입니다. 매 epoch이 끝날 때마다 검증 지표를 보고, 개선됐으면 저장하고 카운터를 0으로, 아니면 카운터를 1 올린다. 그리고 카운터가 patience에 도달하면 학습을 멈춥니다. 넘어설 때까지 기다리는 것이 아니라 같아지는 순간 멈추기 때문에, 6.4에서 본 중단 epoch = 최저 epoch + patience 관계가 딱 맞아떨어집니다.
이 흐름에서 "개선됐으면 저장한다"를 담당하는 것이 ModelCheckpoint입니다.
| 인자 | 의미 | 시험 표준값 |
|---|---|---|
| 첫 번째 위치 인자 | 저장할 파일 경로 | 'best_model.keras' |
monitor |
무엇을 기준으로 개선을 판단할지 | 'val_loss' |
save_best_only |
개선된 경우에만 덮어쓸지 | True |
save_best_only=True가 빠지면 매 epoch마다 파일을 덮어써서, 결국 마지막 epoch 모델이 남습니다. 그러면 파일을 만든 의미가 없습니다.
두 콜백을 함께 적용하고, 저장된 파일에서 모델을 복원합니다. 여기서는 저장·복원의 효과가 성능 숫자로 분명히 보이도록 잡음이 적은 load_breast_cancer 데이터를 씁니다.
import os
from tensorflow.keras.models import load_model
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
from sklearn.datasets import load_breast_cancer
# 데이터 준비 (Ch02)
Xb, yb = load_breast_cancer(return_X_y=True)
Xb_train, Xb_test, yb_train, yb_test = train_test_split(
Xb, yb, test_size=0.3, random_state=42, stratify=yb
)
sc = StandardScaler()
Xb_train_scaled = sc.fit_transform(Xb_train)
Xb_test_scaled = sc.transform(Xb_test)
tf.keras.utils.set_random_seed(42)
bc_model = Sequential()
bc_model.add(Input(shape=(Xb_train_scaled.shape[1],)))
bc_model.add(Dense(64, activation='relu'))
bc_model.add(Dropout(0.3))
bc_model.add(Dense(64, activation='relu'))
bc_model.add(Dropout(0.3))
bc_model.add(Dense(1, activation='sigmoid'))
bc_model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
es = EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True)
mc = ModelCheckpoint('best_model.keras', monitor='val_loss', save_best_only=True)
history = bc_model.fit(Xb_train_scaled, yb_train, epochs=100, batch_size=32,
validation_split=0.2, callbacks=[es, mc], verbose=0)
print('실제로 학습한 epoch 수:', len(history.history['loss']))
print('검증 손실 최저 epoch:', int(np.argmin(history.history['val_loss'])) + 1)
print('파일 생성 확인:', os.path.exists('best_model.keras'))
best_model = load_model('best_model.keras')
loss, acc = best_model.evaluate(Xb_test_scaled, yb_test, verbose=0)
print(f'복원 모델 평가 성능 — loss: {loss:.4f}, accuracy: {acc:.4f}')
출력입니다.
실제로 학습한 epoch 수: 40
검증 손실 최저 epoch: 35
파일 생성 확인: True
복원 모델 평가 성능 — loss: 0.0629, accuracy: 0.9708
데이터가 바뀌었는데도 35 + 5 = 40, 6.4에서 확인한 관계가 그대로 성립합니다. 외울 것은 개별 숫자가 아니라 이 관계입니다.
📎 은닉층 구성이 조금씩 다르면 멈추는 epoch도 달라집니다. 이 예제는 노드 64-64이고, 뒤에 나오는 시험장 표준 코드는 64-32라 중단 지점이 다르게 나옵니다. 값이 다르다고 틀린 것이 아닙니다.
restore_best_weights=True를 이미 썼다면 메모리 위의 bc_model도 최적 상태이므로, 파일 복원은 안전장치에 가깝습니다. 다만 문제가 "가장 성능이 좋은 모델을 저장하시오"라고 명시하면 ModelCheckpoint가 정답입니다.
⚠️ 시험 포인트
콜백은 만들기만 해서는 아무 일도 하지 않습니다.fit()에callbacks=[es, mc]로 넘겨야 동작합니다. 이 리스트 전달을 빠뜨리는 것이 가장 흔한 감점 지점입니다.
저장 파일명은.keras확장자를 쓰십시오. Keras 3에서는.h5나 확장자 없는 경로가 오류를 낼 수 있습니다.
6.6 과적합 대응 수단을 고르는 순서
과적합에 쓸 수 있는 수단은 여러 가지지만, 실기에서 손대는 것은 사실상 두 가지입니다.
| 수단 | 효과 | 시험에서 |
|---|---|---|
Dropout |
특정 노드 의존을 막아 경계선을 부드럽게 함 | 빈출 — 모델 구성 지시에 자주 포함 |
EarlyStopping |
최적 지점에서 멈추고 되돌림 | 빈출 — 학습 지시에 거의 항상 포함 |
| 데이터 늘리기 | 가장 근본적이지만 실기에서는 불가 | 주어진 데이터가 고정 |
| 모델 크기 줄이기 | 층·노드를 줄여 표현력을 낮춤 | 지시가 있을 때만 |
순서는 이렇게 잡으십시오. ① 곡선을 그려 과적합인지 확인 → ② Dropout이 이미 있는지 보고 없으면 추가 → ③ EarlyStopping으로 멈추는 지점을 맡김. 그래도 검증 손실이 높다면 은닉층 노드 수를 절반으로 줄여 보는 것이 다음 카드입니다.
반대로 두 곡선이 모두 높은 값에서 정체한다면 과소적합이므로, 대응이 정반대가 됩니다. Dropout 비율을 낮추거나 빼고, 노드 수와 epoch을 늘려야 합니다. 과적합 대응 수단을 과소적합 상황에 쓰면 성능이 더 나빠집니다.
시험장 표준 코드
전처리를 마친 데이터에서 콜백을 붙인 학습까지, 그대로 옮겨 쓸 수 있는 완결 코드입니다. 이진분류 기준이며 출력층과 손실 함수만 유형에 맞게 교체하면 됩니다(Ch05 ch05_loss-decision 참조).
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.layers import Input, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
tf.keras.utils.set_random_seed(42)
# 1) 데이터 준비 (Ch02)
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# 2) 모델 구성 (Ch04) — Dropout 포함
tf.keras.utils.set_random_seed(42) # 초기 가중치까지 고정하려면 모델을 만들기 직전에 한 번 더
model = Sequential()
model.add(Input(shape=(X_train_scaled.shape[1],)))
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(32, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(1, activation='sigmoid')) # ← 유형별 교체 지점
# 3) 컴파일 (Ch05)
model.compile(optimizer='adam',
loss='binary_crossentropy', # ← 유형별 교체 지점
metrics=['accuracy'])
# 4) 콜백 정의 (이 챕터)
es = EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True)
mc = ModelCheckpoint('best_model.keras', monitor='val_loss', save_best_only=True)
# 5) 학습 — epochs는 넉넉히, 멈추는 것은 콜백에 맡깁니다
history = model.fit(X_train_scaled, y_train,
epochs=100, batch_size=32,
validation_split=0.2,
callbacks=[es, mc],
verbose=1)
print('실제로 학습한 epoch 수:', len(history.history['loss']))
print('검증 손실 최저 epoch:', int(np.argmin(history.history['val_loss'])) + 1)
# 6) 학습 곡선 확인
h = history.history
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(h['loss'], label='train loss')
plt.plot(h['val_loss'], label='val loss')
plt.xlabel('epoch'); plt.ylabel('loss'); plt.legend(); plt.title('loss')
plt.subplot(1, 2, 2)
plt.plot(h['accuracy'], label='train acc')
plt.plot(h['val_accuracy'], label='val acc')
plt.xlabel('epoch'); plt.ylabel('accuracy'); plt.legend(); plt.title('accuracy')
plt.tight_layout()
plt.show()
# 7) 저장된 최적 모델 복원 (선택)
best_model = load_model('best_model.keras')
loss, acc = best_model.evaluate(X_test_scaled, y_test, verbose=0)
print(f'평가 데이터 — loss: {loss:.4f}, accuracy: {acc:.4f}')
출력의 요지입니다(진행 막대는 생략했습니다).
실제로 학습한 epoch 수: 47
검증 손실 최저 epoch: 42
평가 데이터 — loss: 0.0689, accuracy: 0.9708
여기서도 47 = 42 + 5입니다. 100을 지시했지만 47에서 멈췄고, 남은 가중치는 42번째 epoch의 것입니다. 은닉층 구성이나 실행 환경이 달라지면 47·42 같은 숫자는 달라질 수 있지만, 두 숫자의 차이가 patience와 같다는 관계는 그대로입니다.
여기까지가 학습을 "잘" 끝내는 방법입니다. 이제 남은 것은 완성된 모델의 성능을 제대로 재고 해석하는 일이며, 그것이 Ch07의 내용입니다.
핵심 요약
| 항목 | 기억할 것 |
|---|---|
| 과적합 판별 | 학습 손실은 하강, 검증 손실이 상승하며 두 선이 벌어짐 |
| 과소적합 판별 | 두 손실 모두 높은 값에서 정체 → 대응은 정반대(노드·epoch 늘리기) |
| Dropout | 학습 중 매 step마다 지정 비율만큼 노드를 끔, 예측 시에는 끄지 않음 |
| Dropout 효과 확인 | "마지막 손실이 낮아졌는가"가 아니라 두 곡선이 덜 벌어졌는가. 비율을 올린다고 항상 좋아지지는 않음(0.2~0.5 권장) |
| EarlyStopping | EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True) |
| ModelCheckpoint | ModelCheckpoint('best_model.keras', monitor='val_loss', save_best_only=True) |
| 전달 | fit(..., validation_split=0.2, callbacks=[es, mc]) — 리스트 전달을 빠뜨리지 않기 |
| epochs | 콜백을 쓸 때는 넉넉히(100 이상) 주고 중단은 콜백에 맡김 |
| 자기 검산 | 중단 epoch = 검증 지표 최적 epoch + patience — 차이가 patience와 다르거나 지시한 epochs를 다 쓰면 콜백을 의심 |
Ch06 확인 문제 — 과적합 진단과 대응
본 문제는 공개된 출제 기준에 따른 유형 연습 문제입니다. 기출 원문이 아닙니다.
이 챕터의 범위는 학습 곡선 판독 / Dropout /
EarlyStopping/ModelCheckpoint입니다.
손실 함수 선택은 Ch05,evaluate·predict와 지표 해석은 Ch07에서 다룹니다.실기 딥러닝 문항은 "EarlyStopping을 적용해 학습시키시오"라는 지시를 거의 항상 포함합니다.
Q3의 세 줄을 손이 기억할 때까지 반복하십시오.총 5문항 (객관식 2 / 코드 빈칸 2 / 오류 찾기 1), 권장 소요 시간 22분.
공통 import는 다음과 같다고 가정합니다.
python import numpy as np import tensorflow as tf from tensorflow.keras.models import Sequential, load_model from tensorflow.keras.layers import Input, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
문제
Q1. (객관식 · 기본) 학습 곡선 판독
은닉층 2개(노드 256)짜리 모델을 epochs=50으로 학습시킨 뒤 history.history에서 일부 epoch의 값을 뽑았습니다.
| epoch | loss |
val_loss |
|---|---|---|
| 1 | 0.6777 | 0.6341 |
| 5 | 0.2606 | 0.5973 |
| 10 | 0.0601 | 0.7510 |
| 20 | 0.0057 | 1.0657 |
| 35 | 0.0013 | 1.2844 |
| 50 | 0.0005 | 1.4136 |
마지막 epoch의 accuracy는 1.0, val_accuracy는 0.7321이었습니다.
이 학습 상태에 대한 진단과 조치를 옳게 짝지은 것은?
- 정상 학습이다. 두 손실이 모두 낮으므로
epochs를 100으로 늘려 성능을 더 끌어올린다. - 과소적합이다. 은닉층 뒤에
Dropout(0.5)를 추가해 표현력을 낮춘다. - 과적합이다.
Dropout을 추가하고EarlyStopping을 적용해 검증 손실이 최저인 지점에서 멈추게 한다. - 과적합이다. 은닉층 노드 수를 512로 늘리고
epochs를 200으로 늘린다. - 학습이 불안정하다.
batch_size를 1로 줄여 가중치를 더 촘촘히 갱신한다.
Q2. (객관식 · 기본) Dropout의 동작
model.add(Dropout(0.3))에 대한 설명 중 옳은 것은?
- 은닉층 노드의 30%를 남기고 70%를 영구히 삭제한다.
- 학습 중에는 매 step마다 그 층 노드의 30%를 무작위로 골라 끄고,
predict()로 예측할 때는 아무 노드도 끄지 않는다. - Dropout 층은 자체 학습 파라미터를 가지므로
model.summary()의 Param #가 0이 아니다. - 비율을 0.8로 높일수록 과적합 방지 효과가 커지므로 검증 성능이 항상 좋아진다.
- 두 손실이 모두 높은 값에서 정체한 상태(과소적합)에서도 비율을 높이는 것이 표준 대응이다.
Q3. (코드 빈칸 · 기본) 콜백 표준 3줄
전처리를 마친 X_train_scaled(shape (398, 30)), y_train(0/1)으로 이진분류 모델을 학습시킵니다.
다음 지시를 코드로 옮기십시오.
- 검증 손실을 기준으로 5 epoch 동안 개선이 없으면 학습을 중단하고, 가장 좋았던 시점의 가중치로 되돌린다.
- 검증 손실이 개선될 때만
'best_model.keras'파일에 저장한다. epochs=100,batch_size=32, 학습 데이터의 20%를 검증 데이터로 사용하며, 위 두 콜백을 학습에 적용한다.
import numpy as np
import tensorflow as tf
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.layers import Input, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
# X_train_scaled, y_train, X_test_scaled, y_test 는 전처리가 끝난 상태라고 가정합니다.
model = Sequential()
model.add(Input(shape=(X_train_scaled.shape[1],)))
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
es = ______(가)______
mc = ______(나)______
history = model.fit(X_train_scaled, y_train,
epochs=100, batch_size=32,
validation_split=0.2,
______(다)______,
verbose=1)
print('실제로 학습한 epoch 수:', len(history.history['loss']))
추가로, epochs=100을 지시했는데도 len(history.history['loss'])가 100보다 작게 나오는 이유를 한 줄로 쓰십시오.
Q4. (코드 빈칸 · 응용) 중단 지점 확인과 val_accuracy 감시
이번에는 검증 정확도를 기준으로 감시하려 합니다.
patience=8, 최적 시점 가중치 복원을 적용해 epochs=200으로 학습시키고, 실제로 몇 epoch에서 멈췄는지와 최고 시점이 몇 번째였는지를 출력하십시오.
(가)에는 실제 학습 epoch 수를, (나)에는 val_accuracy가 가장 높았던 epoch 번호를, (다)에는 콜백 정의 한 줄을 채우십시오.
import numpy as np
from tensorflow.keras.callbacks import EarlyStopping
es = ______(다)______
history = model.fit(X_train_scaled, y_train,
epochs=200, batch_size=16,
validation_split=0.2,
callbacks=[es],
verbose=0)
print('실제로 학습한 epoch 수:', ______(가)______)
print('val_accuracy 최고 epoch:', ______(나)______)
추가로 다음에 답하십시오.
- 위 코드의 실행 결과 "val_accuracy 최고 epoch"가 17로 나왔다면, "실제로 학습한 epoch 수"는 얼마입니까? 근거와 함께 쓰십시오.
monitor='val_loss'일 때와 달리monitor='val_accuracy'일 때 추가로 적어 두는 것이 안전한 인자는 무엇이며 그 이유는 무엇입니까?
Q5. (오류 찾기 · 응용) 동작하지 않는 EarlyStopping
아래 코드는 오류 없이 끝까지 실행되지만, EarlyStopping이 전혀 제 역할을 하지 못합니다.
잘못된 곳 세 군데를 지적하고 수정한 코드를 쓰십시오.
from tensorflow.keras.callbacks import EarlyStopping
es = EarlyStopping(monitor='loss', patience=5)
history = model.fit(X_train_scaled, y_train,
epochs=100, batch_size=32,
validation_split=0.2,
verbose=0)
print('실제로 학습한 epoch 수:', len(history.history['loss']))
해답 및 해설 보기
Q1 해답
정답: 3번
정답 코드 (문제의 표를 만든 실험과 조치 후 비교)
import numpy as np
import tensorflow as tf
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping
np.random.seed(42); tf.random.set_seed(42)
X, y = make_classification(n_samples=400, n_features=30, n_informative=5,
n_redundant=0, flip_y=0.10, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
def build(dropout_rate=0.0, units=256):
m = Sequential()
m.add(Input(shape=(X_train_scaled.shape[1],)))
m.add(Dense(units, activation='relu'))
if dropout_rate > 0:
m.add(Dropout(dropout_rate))
m.add(Dense(units, activation='relu'))
if dropout_rate > 0:
m.add(Dropout(dropout_rate))
m.add(Dense(1, activation='sigmoid'))
m.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
return m
# 문제의 표를 만든 학습 (대책 없음)
tf.keras.utils.set_random_seed(42)
h = build(0.0).fit(X_train_scaled, y_train, epochs=50, batch_size=16,
validation_split=0.2, verbose=0).history
print('epoch | loss | val_loss')
for e in [1, 5, 10, 20, 35, 50]:
print(f'{e:5d} | {h["loss"][e-1]:8.4f} | {h["val_loss"][e-1]:8.4f}')
print('검증 손실 최저 epoch:', int(np.argmin(h['val_loss'])) + 1,
'/ 최저값:', round(min(h['val_loss']), 4))
print('마지막 train accuracy:', round(h['accuracy'][-1], 4),
'/ 마지막 val accuracy:', round(h['val_accuracy'][-1], 4))
epoch | loss | val_loss
1 | 0.6777 | 0.6341
5 | 0.2606 | 0.5973
10 | 0.0601 | 0.7510
20 | 0.0057 | 1.0657
35 | 0.0013 | 1.2844
50 | 0.0005 | 1.4136
검증 손실 최저 epoch: 3 / 최저값: 0.5857
마지막 train accuracy: 1.0 / 마지막 val accuracy: 0.7321
왜 이렇게 푸는가
판별 기준은 한 문장입니다. loss는 계속 내려가는데 val_loss가 어느 지점부터 올라가면 과적합입니다.
표를 그 기준으로 읽으면 이렇습니다.
loss는 0.6777 → 0.0005로 끝없이 내려갑니다. 학습 데이터는 사실상 통째로 외웠습니다(마지막accuracy1.0).val_loss는 5 epoch 부근(0.5973)에서 바닥을 찍고, 그 뒤로 0.7510 → 1.0657 → 1.4136으로 계속 올라갑니다.- 두 선이 갈라지는 그 지점이 학습 곡선 도해의 "과적합 시작" 세로 점선입니다.
조치는 챕터 6.6의 순서를 그대로 따릅니다. ① 곡선으로 과적합 확인 → ② Dropout이 없으면 추가 → ③ EarlyStopping으로 멈추는 지점을 맡김. 3번이 정확히 이 순서입니다.
자주 틀리는 지점
- 1번처럼 학습 손실(0.0005)만 보고 "잘 됐다"고 판단하는 것 — 이 챕터에서 가장 위험한 오독입니다. 정답의 근거는 언제나 검증 손실(
val_loss) 입니다. 마찬가지로accuracy가 1.0이라는 사실도 판단 근거가 되지 못합니다.val_accuracy가 0.7321이라는 것이 진짜 성적입니다. - 2번처럼 진단은 틀렸는데 조치만 그럴듯한 것 —
Dropout(0.5)추가는 과적합에 맞는 조치이지만, 이 상태를 "과소적합"으로 진단한 순간 답이 아닙니다. 과소적합은 두 손실이 모두 높은 값에서 정체하는 모양입니다. - 4번처럼 과적합에 모델을 더 키우는 것 — 진단은 맞았지만 조치가 정반대입니다. 노드를 늘리면 표현력이 커져 외우기가 더 쉬워집니다. 과적합 상황에서는 오히려 노드 수를 줄이는 것이 다음 카드입니다.
- 5번처럼
batch_size를 1로 줄이는 것 — 검증 손실이 위아래로 심하게 튀는 "학습 불안정"과 혼동한 것입니다. 이 표의val_loss는 튀지 않고 일관되게 상승하고 있습니다. 게다가batch_size를 줄이면 갱신이 더 잦아져 학습이 더 불안정해집니다.
Q2 해답
정답: 2번
정답 코드 (2·3번 실증과 4·5번 반증)
import numpy as np
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input, Dense, Dropout
tf.keras.utils.set_random_seed(42)
d = Sequential()
d.add(Input(shape=(X_train_scaled.shape[1],)))
d.add(Dense(64, activation='relu'))
d.add(Dropout(0.3))
d.add(Dense(1, activation='sigmoid'))
d.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
sample = X_train_scaled[:1]
print('predict 1회차:', np.round(d.predict(sample, verbose=0).ravel(), 4))
print('predict 2회차:', np.round(d.predict(sample, verbose=0).ravel(), 4), '<- 동일')
print('학습 모드 1회차:', np.round(d(sample, training=True).numpy().ravel(), 4))
print('학습 모드 2회차:', np.round(d(sample, training=True).numpy().ravel(), 4), '<- 매번 다름')
dl = [l for l in d.layers if isinstance(l, Dropout)][0]
print('Dropout 층의 학습 파라미터 수:', dl.count_params())
print('\n[비율별 비교]')
for rate in [0.0, 0.3, 0.5, 0.8]:
tf.keras.utils.set_random_seed(42)
hh = build(rate).fit(X_train_scaled, y_train, epochs=60, batch_size=16,
validation_split=0.2, verbose=0).history
print(f' dropout={rate} 최저 val_loss={min(hh["val_loss"]):.4f} '
f'마지막 val_loss={hh["val_loss"][-1]:.4f} '
f'최고 val_acc={max(hh["val_accuracy"]):.4f}')
predict 1회차: [0.5059]
predict 2회차: [0.5059] <- 동일
학습 모드 1회차: [0.3552]
학습 모드 2회차: [0.5609] <- 매번 다름
Dropout 층의 학습 파라미터 수: 0
[비율별 비교]
dropout=0.0 최저 val_loss=0.5857 마지막 val_loss=1.4784 최고 val_acc=0.7321
dropout=0.3 최저 val_loss=0.5776 마지막 val_loss=1.6485 최고 val_acc=0.7857
dropout=0.5 최저 val_loss=0.5722 마지막 val_loss=1.2115 최고 val_acc=0.7857
dropout=0.8 최저 val_loss=0.5633 마지막 val_loss=0.5864 최고 val_acc=0.7143
위 수치는
n_samples=400,flip_y=0.10의 잡음 섞인 합성 데이터에epochs=60,batch_size=16으로 학습한 조건에서의 값입니다. 데이터와 seed가 바뀌면 숫자는 달라지지만 경향은 동일합니다.
왜 이렇게 푸는가
2번이 정답인 근거는 실행 결과의 앞 네 줄입니다.
predict()를 두 번 불러도 값이0.5059로 같습니다. 예측할 때는 Dropout이 통과 모드가 되어 아무 노드도 끄지 않기 때문입니다. 예측값이 호출할 때마다 달라지면 평가 자체가 불가능해집니다.training=True로 부르면0.3552,0.5609로 매번 다릅니다. 학습 중에는 step마다 꺼지는 노드가 바뀐다는 증거입니다.
Dropout(0.3)의 0.3은 끄는 비율입니다. 노드 몇 개가 수시로 사라지면 모델은 특정 노드에만 의존하는 지름길을 만들 수 없고, 여러 노드가 나눠서 판단하도록 강제됩니다. 이것이 경계선이 덜 구불구불해지는 이유입니다.
자주 틀리는 지점
- 1번처럼 비율의 방향을 뒤집는 것 — "0.3이면 30%를 남긴다"고 외우면
Dropout(0.8)을 "80%를 살린다"로 오해하게 됩니다. 실제로는 80%를 끄는 것이고, 위 비교표에서dropout=0.8의 최고val_acc가 0.7143으로 0.3·0.5보다 오히려 낮습니다. "영구히 삭제한다"는 표현도 틀렸습니다. 매 step마다 다른 노드가 임시로 꺼질 뿐입니다. - 3번처럼 Param #를 오해하는 것 — Dropout 층의 학습 파라미터는 0개입니다. 노드를 끄기만 할 뿐 학습하는 값이 없습니다.
summary()에서 0으로 찍히는 것을 보고 "층이 잘못 들어갔다"고 판단해 지우는 일이 없어야 합니다. - 4번처럼 "높을수록 좋다"고 생각하는 것 — 위 결과에서
dropout=0.8의 최저val_loss는 낮지만val_acc는 가장 낮습니다. 노드 대부분이 꺼져 모델이 제대로 학습할 기회를 잃은 것입니다. 실기에서는 0.2~0.5 사이를 쓰고, 지시가 없으면 0.3을 기본값으로 삼으십시오. - 5번처럼 과소적합에 Dropout을 더 넣는 것 — 대응이 정반대입니다. 과소적합에서는 Dropout 비율을 낮추거나 빼고, 노드 수와
epochs를 늘려야 합니다.
Q3 해답
정답 코드
import os
import numpy as np
import tensorflow as tf
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.layers import Input, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
# 데이터 준비 (Ch02)
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
print('X_train_scaled:', X_train_scaled.shape, '/ X_test_scaled:', X_test_scaled.shape)
tf.keras.utils.set_random_seed(42)
model = Sequential()
model.add(Input(shape=(X_train_scaled.shape[1],)))
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.3))
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
es = EarlyStopping(monitor='val_loss', patience=5,
restore_best_weights=True) # (가)
mc = ModelCheckpoint('best_model.keras', monitor='val_loss',
save_best_only=True) # (나)
history = model.fit(X_train_scaled, y_train,
epochs=100, batch_size=32,
validation_split=0.2,
callbacks=[es, mc], # (다)
verbose=1)
print('지시한 epochs: 100')
print('실제로 학습한 epoch 수:', len(history.history['loss']))
print('검증 손실 최저 epoch:', int(np.argmin(history.history['val_loss'])) + 1)
print('파일 생성 확인:', os.path.exists('best_model.keras'))
best_model = load_model('best_model.keras')
loss, acc = best_model.evaluate(X_test_scaled, y_test, verbose=0)
print(f'복원 모델 평가 성능 — loss: {loss:.4f}, accuracy: {acc:.4f}')
X_train_scaled: (398, 30) / X_test_scaled: (171, 30)
지시한 epochs: 100
실제로 학습한 epoch 수: 40
검증 손실 최저 epoch: 35
파일 생성 확인: True
복원 모델 평가 성능 — loss: 0.0629, accuracy: 0.9708
추가 답: EarlyStopping이 val_loss 개선이 5 epoch 동안 없자 100번을 채우기 전에 학습을 중단시켰기 때문입니다. (35 + 5 = 40)
왜 이렇게 푸는가
- (가)
EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True) monitor='val_loss'— "검증 손실을 기준으로"라는 지시입니다. 학습 손실(loss)이 아니라 검증 손실을 봐야 과적합 시점을 잡을 수 있습니다.patience=5— "5 epoch 동안 개선이 없으면"을 그대로 옮긴 값입니다.restore_best_weights=True— "가장 좋았던 시점의 가중치로 되돌린다"에 해당합니다. 기본값이False이므로 직접 적어야 합니다.- (나)
ModelCheckpoint('best_model.keras', monitor='val_loss', save_best_only=True) - 파일 경로는 첫 번째 위치 인자입니다. 확장자는
.keras를 씁니다. save_best_only=True가 빠지면 매 epoch마다 덮어써서 결국 마지막 epoch 모델이 남습니다. 그러면 파일을 만든 의미가 사라집니다.- (다)
callbacks=[es, mc]— 콜백은 만들기만 해서는 아무 일도 하지 않습니다.fit()에 리스트로 넘겨야 Keras가 매 epoch 끝에 불러 줍니다.
실행 결과가 이 세 줄의 효과를 그대로 보여 줍니다. 35번째에서 검증 손실이 바닥을 찍었고, patience=5만큼 더 기다린 40번째에서 멈췄습니다. restore_best_weights=True 덕분에 메모리 위의 model이 들고 있는 가중치는 40번째가 아니라 35번째의 것입니다.
EarlyStopping을 쓸 때 epochs를 넉넉히(100 이상) 주는 것이 정석인 이유도 여기 있습니다. 어차피 콜백이 알아서 멈추므로, 작게 주면 오히려 최적점에 닿기 전에 끝나 버립니다.
자주 틀리는 지점
restore_best_weights=True를 빠뜨리는 것 — 오류가 나지 않아 가장 잡기 어려운 감점입니다. 학습은 멈추지만 모델에 남는 것은 최저점이 아니라patience만큼 더 나빠진 마지막 epoch의 가중치입니다.callbacks=[es, mc]를fit()에 넘기지 않는 것 — 챕터가 "가장 흔한 감점 지점"으로 지목한 실수입니다. Q5에서 그 결과를 숫자로 확인합니다.validation_split을 빠뜨린 채monitor='val_loss'를 쓰는 것 — 검증 손실이 계산되지 않아Early stopping conditioned on metric 'val_loss' is not available라는 경고만 뜨고 콜백은 아무 일도 하지 않습니다. 검증 데이터 지정과EarlyStopping은 항상 한 쌍입니다.- 파일명을
'best_model.h5'나 확장자 없이 쓰는 것 — Keras 3에서는 오류가 날 수 있습니다..keras를 표준으로 쓰십시오.
Q4 해답
정답 코드
import numpy as np
import tensorflow as tf
from tensorflow.keras.callbacks import EarlyStopping
es = EarlyStopping(monitor='val_accuracy', patience=8,
mode='max', restore_best_weights=True) # (다)
tf.keras.utils.set_random_seed(42)
model = build(0.3) # Q1에서 정의한 은닉층 2개 + Dropout 모델
history = model.fit(X_train_scaled, y_train,
epochs=200, batch_size=16,
validation_split=0.2,
callbacks=[es],
verbose=0)
print('실제로 학습한 epoch 수:', len(history.history['loss'])) # (가)
print('val_accuracy 최고 epoch:', int(np.argmax(history.history['val_accuracy'])) + 1) # (나)
print('감시 지표:', es.monitor, '/ mode:', es.mode,
'/ restore_best_weights:', es.restore_best_weights)
실제로 학습한 epoch 수: 25
val_accuracy 최고 epoch: 17
감시 지표: val_accuracy / mode: max / restore_best_weights: True
추가 1 — 답: 25 epoch
EarlyStopping은 최고 시점 이후 patience 만큼 더 기다렸다가 중단합니다.
중단 epoch = 최고 epoch + patience = 17 + 8 = 25
추가 2 — 답: mode='max'
monitor='val_accuracy'는 클수록 좋은 지표입니다. Keras가 지표 이름으로 방향을 추론해 주기는 하지만, 이름이 낯선 사용자 지정 지표에서는 방향을 잘못 잡아 개선을 "악화"로 판단해 곧바로 멈춰 버릴 수 있습니다. mode='max'를 명시하면 이 위험이 사라집니다. (val_loss는 작을수록 좋으므로 mode='min'이며, 이것이 기본 동작입니다.)
왜 이렇게 푸는가
- (가)
len(history.history['loss'])—history.history의 각 값은 실제로 수행된 epoch 수만큼의 길이를 가진 리스트입니다. 그래서 리스트 길이가 곧 실제 학습 epoch 수가 됩니다.EarlyStopping이 정말 동작했는지 확인하는 가장 간단한 방법입니다. - (나)
int(np.argmax(history.history['val_accuracy'])) + 1—argmax는 리스트에서 가장 큰 값의 인덱스를 돌려주는데, 인덱스는 0부터 시작하고 epoch 번호는 1부터 세므로+ 1이 필요합니다.val_loss를 볼 때는 작을수록 좋으므로np.argmin을 씁니다. - (다) 지시문의 세 조건("검증 정확도 기준", "patience=8", "최적 시점 가중치 복원")을 그대로 인자로 옮기고, 클수록 좋은 지표이므로
mode='max'를 덧붙입니다.
17 + 8 = 25가 정확히 맞아떨어지는 것을 확인하십시오. 이 산술 관계를 알고 있으면 답안이 의도대로 동작했는지 스스로 검산할 수 있습니다. 두 숫자의 차이가 patience와 다르면 무언가 잘못된 것입니다.
자주 틀리는 지점
argmax/argmin을 반대로 쓰는 것 —val_loss는 최소(argmin),val_accuracy는 최대(argmax)입니다. 감시 지표가 바뀌면 함께 바뀝니다.+ 1을 빠뜨리는 것 — 인덱스 16을 그대로 "16번째 epoch"라고 답하면patience와의 산술이 한 칸씩 어긋납니다.epochs=200을 지시받고 그대로 200으로 두는 것을 낭비라고 생각해 줄이는 것 — 콜백을 쓸 때는epochs를 넉넉히 주는 것이 정석입니다. 작게 주면 콜백이 멈추기 전에 학습이 끝나EarlyStopping을 쓴 의미가 사라집니다.monitor이름을'accuracy'로 적는 것 — 그러면 학습 정확도를 감시하게 되어 과적합을 전혀 잡지 못합니다. 검증 지표에는 반드시val_접두어를 붙이십시오.
Q5 해답
잘못된 곳 3군데
monitor='loss'— 학습 손실을 감시하고 있습니다. 학습 손실은 과적합이 진행돼도 계속 내려가므로 콜백이 멈출 이유를 찾지 못합니다.monitor='val_loss'로 고쳐야 과적합 시점을 잡습니다.restore_best_weights누락 — 기본값이False라, 멈추더라도 모델에는 최저점이 아닌 마지막 epoch의 가중치가 남습니다.restore_best_weights=True를 적어야 합니다.fit()에callbacks미전달 —es를 만들어 놓고 넘기지 않았습니다. 콜백은fit(..., callbacks=[es])로 전달해야만 동작합니다. 이 상태에서는 위 두 인자를 어떻게 고쳐도 아무 효과가 없습니다.
정답 코드
import numpy as np
import tensorflow as tf
from tensorflow.keras.callbacks import EarlyStopping
# ---------- (1) 잘못된 코드: 콜백을 만들었지만 넘기지 않음 ----------
tf.keras.utils.set_random_seed(42)
bad = build(0.3)
es_bad = EarlyStopping(monitor='loss', patience=5) # 만들어만 둠
h_bad = bad.fit(X_train_scaled, y_train, epochs=100, batch_size=32,
validation_split=0.2, verbose=0) # callbacks 없음
print('[잘못] 학습한 epoch 수:', len(h_bad.history['loss']), '(100 전량 소진)')
print('[잘못] 검증 손실 최저 epoch:', int(np.argmin(h_bad.history['val_loss'])) + 1)
print('[잘못] 최저 val_loss:', round(min(h_bad.history['val_loss']), 4),
'/ 마지막 val_loss:', round(h_bad.history['val_loss'][-1], 4))
# ---------- (2) 콜백은 넘겼지만 monitor='loss' 인 경우 ----------
tf.keras.utils.set_random_seed(42)
mid = build(0.3)
h_mid = mid.fit(X_train_scaled, y_train, epochs=100, batch_size=32, validation_split=0.2,
callbacks=[EarlyStopping(monitor='loss', patience=5)], verbose=0)
print("[monitor='loss'] 학습한 epoch 수:", len(h_mid.history['loss']),
'/ 검증 손실 최저 epoch:', int(np.argmin(h_mid.history['val_loss'])) + 1)
# ---------- (3) 수정된 코드 ----------
tf.keras.utils.set_random_seed(42)
model = build(0.3)
es = EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True)
history = model.fit(X_train_scaled, y_train,
epochs=100, batch_size=32,
validation_split=0.2,
callbacks=[es],
verbose=0)
print('[수정] 학습한 epoch 수:', len(history.history['loss']))
print('[수정] 검증 손실 최저 epoch:', int(np.argmin(history.history['val_loss'])) + 1)
[잘못] 학습한 epoch 수: 100 (100 전량 소진)
[잘못] 검증 손실 최저 epoch: 6
[잘못] 최저 val_loss: 0.5685 / 마지막 val_loss: 1.645
[monitor='loss'] 학습한 epoch 수: 47 / 검증 손실 최저 epoch: 6
[수정] 학습한 epoch 수: 11
[수정] 검증 손실 최저 epoch: 6
restore_best_weights만 따로 떼어 본 결과
from sklearn.model_selection import train_test_split
X_a, X_v, y_a, y_v = train_test_split(X_train_scaled, y_train, test_size=0.2,
random_state=42, stratify=y_train)
for rbw in [False, True]:
tf.keras.utils.set_random_seed(42)
m = build(0.3)
h = m.fit(X_a, y_a, epochs=100, batch_size=32, validation_data=(X_v, y_v),
callbacks=[EarlyStopping(monitor='val_loss', patience=5,
restore_best_weights=rbw)], verbose=0)
vl, va = m.evaluate(X_v, y_v, verbose=0)
print(f'restore_best_weights={rbw!s:<5} 중단={len(h.history["loss"])} '
f'best시점 val_loss={min(h.history["val_loss"]):.4f} '
f'마지막 val_loss={h.history["val_loss"][-1]:.4f} '
f'-> 남은 가중치의 val_loss={vl:.4f}')
restore_best_weights=False 중단= 11 best시점 val_loss=0.5587 마지막 val_loss=0.5671 -> 남은 가중치의 val_loss=0.5671
restore_best_weights=True 중단= 11 best시점 val_loss=0.5587 마지막 val_loss=0.5671 -> 남은 가중치의 val_loss=0.5587
위 수치는
n_samples=400,flip_y=0.10의 잡음 섞인 합성 데이터에 은닉층 2개(노드 256) +Dropout(0.3),epochs=100,batch_size=32조건에서 실행한 결과입니다. 데이터·seed가 바뀌면 숫자는 달라지지만 세 경우의 대소 관계는 동일합니다.
왜 이렇게 푸는가
세 오류가 어떻게 누적되는지 실행 결과가 단계별로 보여 줍니다.
- 콜백을 넘기지 않으면 100 epoch를 전부 돕니다. 검증 손실은 6번째에서 0.5685로 바닥을 찍었지만 마지막에는 1.645까지 올라갔습니다. 즉 남은 모델은 최적 지점보다 약 3배 나쁜 상태입니다.
epochs=100이 그대로 소진됐다는 사실 자체가 "콜백이 동작하지 않았다"는 신호입니다. - 콜백은 넘겼지만
monitor='loss'이면 47 epoch까지 갑니다. 학습 손실은 과적합 구간에서도 계속 내려가므로 "개선 없음"이 좀처럼 성립하지 않기 때문입니다. 검증 손실 최저점은 여전히 6번째인데 41 epoch를 더 외우는 데 쓴 셈입니다. monitor='val_loss'로 고치면 11 epoch에서 멈춥니다.6 + 5 = 11, 최저점 이후patience만큼 기다린 결과입니다.restore_best_weights만 따로 본 실험에서, 둘 다 11 epoch에서 멈추지만 모델에 남는 가중치가 다릅니다.False면 마지막 epoch의 값(val_loss0.5671),True면 최저점의 값(val_loss0.5587)입니다. 멈추는 시점은 같고 되돌리는지만 다르다는 것이 이 인자의 정체입니다.
자주 틀리는 지점
monitor에val_접두어를 빠뜨리는 것 —'loss'와'val_loss'는 한 글자 차이지만 감시 대상이 학습 데이터냐 검증 데이터냐로 완전히 갈립니다. 과적합을 잡는 것이 목적이므로 언제나 검증 지표를 봅니다.- 오류가 안 나니까 맞다고 판단하는 것 — 세 오류 모두 예외를 던지지 않습니다.
print(len(history.history['loss']))한 줄로 실제 학습 epoch 수를 찍어 보는 것이 유일한 자기 검산 수단입니다. 지시한epochs와 같은 숫자가 나왔다면 콜백을 의심하십시오. - 정확도만으로 개선 여부를 판단하는 것 — 이 데이터에서 세 경우의 평가 정확도 차이는 크지 않지만 손실은 3배 가까이 벌어집니다. 과적합의 손해는 손실에서 먼저, 훨씬 크게 드러납니다.
EarlyStopping이val_loss를 기본 감시 대상으로 삼는 이유가 이것입니다. - 콜백 객체를 만든 줄만 보고 넘어가는 것 — 답안을 검토할 때는
es = EarlyStopping(...)줄과model.fit(...)줄을 반드시 짝지어 확인하십시오. 만든 콜백이callbacks=[...]안에 모두 들어 있는지가 채점 포인트입니다.