최적 가중치와 조기 종료¶
30 에폭을 돌리면 손에 남는 건 30번째 가중치다. 검증 손실이 8번째에서 최저였더라도 그 모델은 이미 사라진 뒤다.
두 기능이 그 문제를 나눠 맡는다. 따로 쓰면 각각 반쪽이다.
| 단독일 때 | 문제 |
|---|---|
| 조기 종료만 | 멈추긴 하는데 최저점을 지나 나빠진 가중치가 남는다 |
| best 스냅샷만 | 최적 가중치는 건지는데 쓸모없는 에폭을 끝까지 돈다 |
표준 절차¶
trainer = dt.Trainer(max_epochs=100, patience=5)
trainer.fit(model, data)
len(trainer.history["val_loss"]) # 24 — 100까지 안 감
trainer.best_epoch, trainer.best_val_loss # (18, 0.2913)
trainer.restore_best() # 18 을 반환
fit()은 가중치를 안 바꾼다¶
restore_best()를 부르기 전까지 모델은 마지막 에폭 상태다.
자동 복원하지 않는 것은 의도다 — 두 시점 성능을 비교할 수 있어야 한다.
trainer.predict(data).accuracy # 마지막 에폭 기준
trainer.restore_best()
trainer.predict(data).accuracy # 최저점 기준
restore_best()는 모델 가중치만 되돌린다. optimizer 상태는 그대로다.
목적이 "가장 좋은 모델로 평가·추론"이지 학습 재개가 아니기 때문이다.
인자 여섯 개¶
dt.Trainer(max_epochs, ...,
snapshot_best=True, best_path=None,
best_with_optim=False, patience=None,
monitor="val_loss", mode="min")
| 인자 | 기본 | 의미 |
|---|---|---|
snapshot_best |
True |
스냅샷을 만들 것인가 |
best_path |
None |
None이면 메모리, 경로면 파일 |
best_with_optim |
False |
파일에 optimizer 상태도 넣을 것인가 |
patience |
None |
몇 번의 monitor 확인 동안 개선이 없으면 멈출 것인가 |
monitor |
"val_loss" |
비교할 정확한 지표 이름 |
mode |
"min" |
작을수록 좋은지(min), 클수록 좋은지(max) |
best_score와 best_epoch는 snapshot_best=False여도 계속 추적된다.
best_val_loss는 custom monitor를 쓰더라도 실제 검증 손실 최저값을 별도로
유지한다. 끄면 비교는 계속하고 복사·쓰기만 건너뛴다.
step 모드에서는 같은 기준을 검증 경계마다 확인하고 최적 위치를 best_step에
저장한다. best_epoch는 None이며 restore_best()는 복원한 step을 반환한다.
이때 patience는 optimizer update가 아니라 monitor 확인 횟수를 센다.
IoU·accuracy로 최적 모델 고르기¶
검증 단계에서 자유형 지표를 기록하고 같은 이름을 monitor로 지정한다.
class SegmentationModel(dt.Module):
def validation_step(self, batch):
y_hat = self(*batch[:-1])
target = batch[-1]
loss = self.loss(y_hat, target)
prediction = y_hat.argmax(dim=1)
intersection = ((prediction == 1) & (target == 1)).sum()
union = ((prediction == 1) | (target == 1)).sum().clamp_min(1)
self.log("iou", intersection / union)
return loss
trainer = dt.Trainer(
max_epochs=100,
patience=5,
monitor="iou",
mode="max",
best_path="best.pt",
)
이 설정에서는 IoU 최고점 하나가 best.pt, best_score, best_epoch(또는
best_step),
restore_best(), 조기 종료를 모두 결정한다. 같은 값은 개선이 아니다.
monitor가 어떤 에폭에 빠지거나 NaN/무한대면 해당 에폭을 이름으로 표시하고 즉시
실패한다. ReduceLROnPlateau만은 이 설정과 독립적으로 계속 val_loss를 받는다.
손실이 계속 줄기만 할 때¶
단조 감소하면 매 에폭이 새 최저점이라 매번 스냅샷이 뜬다. 이게 최대 빈도다.
메모리는 누적되지 않는다. 새 deepcopy가 이전 사본을 대체하고 옛 것은
GC된다. 항상 모델 하나 분량이다.
| 모델 | 파라미터 | 에폭당 복사 |
|---|---|---|
| FashionMNIST MLP (784→256→10) | 20만 | 0.8MB — 무시 가능 |
| ResNet-50 | 2500만 | 100MB, 약 50ms |
에폭 하나가 수십 초인 걸 생각하면 메모리 모드 비용은 없는 것과 같다. 그래서 기본값이 메모리다.
끝까지 단조 감소하면 best_epoch가 마지막 에폭이고 restore_best()는
아무것도 안 바꾼다. patience도 발동하지 않는다.
그 자체가 "더 오래 학습해야 한다"는 신호다.
파일로 남기기 — optimizer 상태를 왜 빼는가¶
파일에는 모델 가중치만 들어간다. 이유는 산수다.
restore_best()는 optimizer 상태를 읽지 않는다. 그런데 Adam은 파라미터당
moment를 2개 들고 있어 optimizer 상태가 모델 크기의 2배다.
읽지 않을 200MB를 매 에폭 쓰는 건 낭비다. 최저점부터 학습을 재개할 계획이 있을 때만 켠다.
dt.Trainer(max_epochs=100, best_path="best.pt", best_with_optim=True)
# 나중에
dt.Trainer.load_checkpoint("best.pt", model, optim)
쓰다가 중단되면¶
best_path는 개선마다 같은 경로를 덮어쓰는 파일이다. 쓰는 도중 끊기면
그때까지 쌓은 최적 가중치를 통째로 잃는다.
<path>.tmp에 먼저 쓰고 os.replace로 교체한다. POSIX와 Windows 양쪽에서
원자적이고, 실패하면 이전 파일이 그대로 남는다.
조기 종료의 두 가지 방어¶
patience=3은 "개선 없는 에폭이 3번 연속이면 멈춘다"는 뜻이다.
best_epoch=8이고 현재가 11이면 11 - 8 = 3 >= 3이라 멈춘다.
잘못된 설정은 즉시 막는다.
patience=0은 거부된다. epoch - best_epoch >= 0이 최저점 에폭에서도
참이라 첫 에폭 직후 멈춘다. 의미가 없다.
검증 데이터 없이 patience도 거부된다. best_epoch가 영원히 None이라
조기 종료가 절대 발동하지 않는다. 조용히 무시하면 왜 안 멈추는지 알 방법이 없다.
restore_best()가 던지는 세 가지¶
| 상황 | 메시지 |
|---|---|
fit() 전 호출 |
fit() has not run yet. |
| 검증 데이터 없음 | No snapshot: there was no validation data. |
snapshot_best=False |
No snapshot: trained with snapshot_best=False. (best was epoch 8, val_loss 0.2913) |
세 번째가 특히 쓸모 있다. 껐다는 사실을 알리면서 최저점 정보는 그대로 준다.
max_epochs=8로 다시 돌리면 된다.
기본 val_loss 기준의 한계¶
기본 설정의 best_epoch는 검증 손실 최저점이다. 분류에서 이게 정확도
최고점과 같지 않다. 차이가 실제로 중요하면 위처럼 monitor를 바꿀 수 있다.
교차엔트로피는 -log p(정답)이라 확신도에 연속 반응하고, 정확도는 argmax의
0/1이다. 최저 CE 지점 이후:
- 이미 맞추는 샘플:
p0.90 → 0.99. CE 이득 미미 (0.105 → 0.010) - 틀리는 샘플:
p(정답)0.10 → 0.01. CE 페널티 폭발 (2.3 → 4.6)
평균 CE는 오르는데, 결정 경계는 계속 다듬어져 애매한 샘플 몇 개가 정답으로 넘어간다. 정확도 정점이 손실 최저점보다 뒤에 오는 게 보통이다.
먼저 재라¶
차이가 실제로 있는지부터 확인한다. 대개 노이즈다.
p_last = trainer.predict(data) # fit 직후 = 마지막 에폭
acc_last, n = p_last.accuracy, len(p_last)
trainer.restore_best() # 이 뒤로 모델이 바뀐다
acc_best = trainer.predict(data).accuracy
import math
sigma = math.sqrt(acc_best * (1 - acc_best) / n)
print(f"best {acc_best:.4f} last {acc_last:.4f}")
print(f"diff {(acc_last - acc_best) * 100:+.2f}%p 1σ = {sigma * 100:.2f}%p")
순서가 중요하다. restore_best()가 모델을 바꾸므로 last를 먼저 잰다.
n=10000, p≈0.93이면 1σ가 약 0.25%p다. 차이가 1σ 안이면 어느 쪽을 골라도
같다. 실제 FashionMNIST MLP로 재보면 0.01%p — 1만 장 중 한 장 차이가
나오기도 한다.
차이가 진짜라면¶
무엇을 고를지는 다운스트림에서 뭘 쓰느냐에 달렸다.
| 쓰는 것 | 맞는 체크포인트 |
|---|---|
| argmax 예측만 | 정확도 최고점 |
p.confidence, 확률, 임계값 컷, 앙상블 |
손실 최저점 |
정확도 최고점 모델은 calibration이 나쁘다. 정확히 위에서 설명한 이유로,
틀린 것을 더 확신하는 상태이기 때문이다. 그 모델의 p.confidence는 과신이라
"확신도 0.99인데 틀림" 케이스가 늘어난다.
deeptool이 손실 최저점을 기준으로 삼는 것은 이쪽이 보수적으로 안전해서다.