고양이와 강아지를 구분하는 모델을 만들었다고 하자. 입력을 넣고 정답을 알려 주는 것만으로 가중치가 저절로 좋아지지는 않는다. 모델은 예측과 정답의 차이를 손실로 바꾸고, 그 손실이 각 가중치에 얼마나 책임이 있는지 역전파로 계산한 뒤 값을 조금 수정한다. 이 순환을 이해하면 학습률·옵티마이저·L1/L2 규제가 왜 필요한지도 한 흐름으로 연결된다.
훈련 손실 하나만 낮추는 것이 목표가 아니다. 보지 않은 검증 데이터에서도 오류가 줄어드는지 확인하고, 학습률과 모델 크기를 기준선부터 비교한 뒤 필요한 만큼만 규제를 추가한다.

훈련 손실과 검증 손실이 갈라지는 순간부터 모델은 학습 데이터의 세부 패턴을 외우기 시작할 수 있다.
1. 모델이 배우는 것은 가중치와 편향이다
신경망의 층은 입력에 가중치를 곱하고 편향을 더한 뒤 활성화 함수를 통과시킨다. 순전파는 현재 가중치로 출력을 만드는 과정이다. 손실 함수는 출력과 정답의 차이를 하나의 값으로 계산한다. 역전파는 연쇄법칙을 이용해 손실을 각 매개변수로 미분하고, 옵티마이저가 그 기울기를 참고해 매개변수를 갱신한다.
에포크는 전체 훈련 데이터를 한 번 훑는 단위이고, 스텝은 미니배치 하나로 가중치를 한 번 갱신하는 단위다. 데이터 1만 장을 배치 크기 100으로 학습하면 한 에포크에 약 100스텝이 생긴다. 에포크와 스텝을 섞어 기록하면 학습률 스케줄과 실험 시간을 비교하기 어렵다.

학습 루프는 같은 구조를 반복한다. L1은 일부 가중치를 0으로, L2는 큰 가중치에 더 큰 부담을 주는 방향으로 작동한다.
2. 순전파·손실·역전파를 코드로 연결한다
model.train()
for images, labels in train_loader:
images = images.to(device)
labels = labels.to(device)
optimizer.zero_grad()
logits = model(images) # 순전파
loss = loss_fn(logits, labels) # 손실
loss.backward() # 기울기 계산
optimizer.step() # 가중치 갱신PyTorch의 autograd는 순전파 때 만들어진 계산 그래프를 따라 각 매개변수의 기울기를 계산한다. loss.backward()가 모델을 직접 고치는 것은 아니다. 기울기를 .grad에 저장하고, 실제 갱신은 optimizer.step()이 수행한다.
zero_grad()를 빼면 이전 기울기가 더해진다.
PyTorch 기울기는 기본적으로 누적된다. 의도적인 gradient accumulation이 아니라면 배치마다 초기화한다. 초기화 위치는 backward() 전에 두거나 공식 튜토리얼처럼 갱신 뒤 다음 반복 전에 둘 수 있지만, 한 스텝의 경계를 분명하게 유지해야 한다.
손실 함수는 출력 문제와 맞아야 한다
| 문제 | 모델 출력 | 대표 손실 | 주의점 |
|---|---|---|---|
| 다중 클래스 분류 | 클래스별 logit | CrossEntropyLoss | 모델에서 softmax를 먼저 적용하지 않는다. |
| 이진 분류 | logit 1개 | BCEWithLogitsLoss | sigmoid와 BCE를 따로 쓰는 것보다 수치적으로 안정적이다. |
| 회귀 | 연속값 | MSE·L1·Huber | 이상치 비용과 단위에 맞춰 고른다. |
| 픽셀 분할 | N×C×H×W | Cross Entropy·Dice 계열 | 클래스 불균형과 경계 품질을 함께 본다. |
3. 옵티마이저보다 먼저 학습률을 비교한다
SGD는 현재 기울기를 중심으로 움직여 기준선과 학습 동작을 관찰하기 좋다. Momentum은 이전 이동 방향을 누적해 진동을 줄인다. Adam은 기울기의 1차·2차 모멘트를 이용해 매개변수마다 보폭을 조절한다. AdamW는 Adam의 적응적 갱신과 weight decay를 분리한다.
| 옵티마이저 | 첫 실험에서 볼 것 | 자주 생기는 오해 |
|---|---|---|
| SGD + Momentum | 학습률과 momentum, 수렴 속도 | 초반 손실이 천천히 내려간다고 항상 나쁜 모델은 아니다. |
| Adam | 빠른 기준선, 검증 성능, 학습률 | 기본값이 모든 데이터에 최적이라는 보장은 없다. |
| AdamW | 학습률과 weight decay를 따로 탐색 | Adam의 손실에 L2 항을 더한 것과 완전히 같지 않다. |
optimizer = torch.optim.AdamW(
model.parameters(),
lr=3e-4,
weight_decay=1e-2
)학습률이 너무 크면 최솟값 주변을 건너뛰며 손실이 진동하거나 발산한다. 너무 작으면 제한된 에포크 안에 충분히 이동하지 못한다. 옵티마이저 이름만 바꾸기 전에 동일한 데이터 분할과 시드에서 학습률 후보를 비교하고, 훈련·검증 곡선과 소요 시간을 함께 저장한다.
4. 과적합은 검증 곡선이 알려 준다
훈련 손실은 계속 감소하는데 검증 손실이 다시 증가하면 모델이 훈련 데이터의 세부 패턴을 외우기 시작했을 가능성이 있다. 하지만 한 번의 출렁임만 보고 과적합으로 단정하지 않는다. 여러 에포크의 추세, 검증 표본 수, 클래스별 지표를 함께 본다.
- 분할부터 고정한다. 같은 사람·제품·연속 프레임이 훈련과 검증에 동시에 들어가지 않게 그룹 단위로 나눈다.
- 작은 기준선을 만든다. 층과 채널 수를 줄인 모델이 충분하면 더 큰 모델을 쓸 이유가 적다.
- 데이터 증강은 라벨 의미를 보존한다. 숫자 6·9처럼 뒤집으면 의미가 바뀌는 데이터에는 무작정 회전을 적용하지 않는다.
- 최고 검증 체크포인트를 저장한다. 마지막 에포크가 가장 좋은 모델이라는 보장은 없다.
- 테스트 세트는 마지막에 한 번 사용한다. 반복 선택에 쓰면 사실상 검증 데이터가 된다.
5. L1·L2·weight decay는 목적과 구현을 구분한다
L1 규제는 손실에 가중치 절댓값의 합을 더한다. 작은 가중치를 정확히 0으로 밀어 희소한 표현을 만들 가능성이 있다. L2 규제는 가중치 제곱합을 더해 큰 값에 더 큰 부담을 준다. 다만 적응적 옵티마이저에서는 손실에 L2 항을 더하는 방식과 매 스텝 가중치를 직접 줄이는 weight decay가 같은 동작이 아니다. AdamW가 둘을 분리한 이유다.
# 명시적인 L1 패널티 예시
l1_lambda = 1e-5
l1_penalty = sum(
parameter.abs().sum()
for name, parameter in model.named_parameters()
if parameter.requires_grad and not name.endswith("bias")
)
loss = task_loss + l1_lambda * l1_penalty| 방법 | 기대 효과 | 검증할 것 |
|---|---|---|
| L1 패널티 | 일부 가중치가 0에 가까워지는 희소성 | 실제로 0이 된 비율과 성능 손실 |
| L2 패널티 | 큰 가중치 억제 | 옵티마이저와 결합 방식, 규제 계수 |
| AdamW weight decay | 적응적 갱신과 분리된 가중치 감쇠 | 학습률과 decay를 독립적으로 탐색 |
| Dropout | 훈련 중 일부 활성값을 무작위로 끔 | train()·eval() 모드 전환 |
| Early stopping | 검증 성능이 나빠지기 전 학습 중단 | patience와 최고 체크포인트 저장 |
6. 재현 가능한 최소 학습 루프
best_val_loss = float("inf")
for epoch in range(epochs):
model.train()
train_loss = 0.0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
loss = loss_fn(model(images), labels)
loss.backward()
optimizer.step()
train_loss += loss.item() * images.size(0)
model.eval()
val_loss = 0.0
with torch.inference_mode():
for images, labels in val_loader:
images, labels = images.to(device), labels.to(device)
val_loss += loss_fn(model(images), labels).item() * images.size(0)
train_loss /= len(train_loader.dataset)
val_loss /= len(val_loader.dataset)
print(epoch, train_loss, val_loss)
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), "best.pt")여기에 시드, 데이터 버전, 분할 ID, 전처리, 모델 설정, 라이브러리 버전, 장치, 에포크별 지표를 함께 남긴다. 한 번 잘 나온 정확도보다 같은 조건에서 결과를 다시 만들 수 있는지가 실험의 출발점이다.
7. 실수하기 쉬운 부분
- 훈련 정확도만 높으면 일반화도 잘된다고 판단한다.
model.eval()을 빼서 Dropout과 BatchNorm이 훈련 모드로 동작한다.- 검증 단계에서도 기울기를 계산해 메모리와 시간이 불필요하게 늘어난다.
CrossEntropyLoss앞에서 softmax를 적용해 수치 안정성을 낮춘다.- 학습률과 weight decay를 동시에 크게 바꾸고 무엇이 영향을 줬는지 기록하지 않는다.
- L2 패널티와 AdamW의 weight decay를 완전히 같은 구현으로 설명한다.
- 여러 시드를 비교하지 않고 좋은 실행 한 번만 대표 결과로 사용한다.
- 순전파는 예측, 손실은 오차의 수치화, 역전파는 매개변수별 기울기 계산이다.
- 옵티마이저는 기울기로 가중치를 갱신하며 학습률이 전체 보폭을 좌우한다.
- 과적합은 훈련값 하나가 아니라 훈련·검증 곡선의 차이로 찾는다.
- L1·L2·AdamW weight decay는 효과와 구현이 다르므로 같은 말처럼 쓰지 않는다.
- 데이터 분할, 시드, 체크포인트와 실패 조건을 함께 기록해야 결과를 재현할 수 있다.
참고한 1차 자료
'실전 개발 노트 > 개발 가이드' 카테고리의 다른 글
| PostgreSQL EXPLAIN ANALYZE로 느린 쿼리 해결하기: 인덱스와 통계 읽는 법 (0) | 2026.08.05 |
|---|---|
| PyTorch U-Net 이미지 세그멘테이션: 구조·Dice Loss·반려동물 실습 (0) | 2026.08.01 |
| VAE·GAN·Diffusion 차이: 이미지 생성 원리와 선택 기준 (0) | 2026.07.31 |
| TypeScript 7 마이그레이션 가이드: Go 네이티브 컴파일러 전환 전 확인할 것 (0) | 2026.07.31 |
| 개발자 이력서·포트폴리오 작성법: 프로젝트 경험을 증거로 바꾸는 방법 (1) | 2026.07.31 |