직접 해 보기: PyTorch로 합성곱 신경망 학습하기
sklearn 8×8 숫자로 PyTorch CNN을 학습 · 평가 · 증강 · 과적합 관찰 · 저장까지 실제 실행
먼저 알아야 할 것
다루는 내용
내려받기 없이 scikit-learn의 손글씨 숫자 1,797장으로 파라미터 38,282개짜리 작은 CNN을 PyTorch로 학습하는 프로젝트입니다. Dataset · DataLoader · 학습 루프 · 검증 기반 가중치 선택 · 테스트(97.22%) · 혼동 행렬 · 증강 비교 · 100장 과적합 관찰 · state_dict 저장을 실제 실행 출력과 함께 단계별로 따라갑니다. CIFAR-10 판 코드는 실행하지 않은 채 함께 둡니다.
해설
직접 해 보기: PyTorch로 합성곱 신경망 학습하기
지금까지 배운 합성곱 · 손실 · 옵티마이저 · 과적합 · 증강을 실제 코드 한 벌로 묶어 돌려 봅니다. 인터넷 다운로드 없이 scikit-learn에 들어 있는 8×8 손글씨 숫자 1,797장으로 작은 CNN을 만들고, Dataset · DataLoader · 학습 루프 · 검증 기반 모델 고르기 · 테스트 평가 · 혼동 행렬 · 데이터 증강 · 과적합 관찰 · 저장과 불러오기까지 한 번에 해 봅니다. 본문의 출력은 모두 실제로 실행한 결과입니다.
| 항목 | 내용 |
|---|---|
| 참고 자료 | PyTorch 튜토리얼 '분류기 학습하기(CIFAR-10)' · docs.pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html, scikit-learn load_digits 문서 |
| 트랙 · 수준 | 딥러닝 기초 · 중급 (프로젝트) |
| 만드는 것 | 8×8 손글씨 숫자 10클래스 분류 CNN (파라미터 38,282개) |
| 실행 환경 | Python 3.14, torch 2.14.0+cpu, scikit-learn 1.9.0, numpy 2.5.1, CPU만 사용 · 전체 실행 약 40~80초 |
| 먼저 알면 좋은 것 | 합성곱 · 풀링, 교차 엔트로피, Adam, 드롭아웃, 학습 · 검증 · 테스트 나누기 |
들어가기 전에
- 합성곱 신경망 기초: 합성곱 · 패딩 · 스트라이드 · 풀링 — 아래 모델의 각 층 출력 크기를 직접 계산할 수 있어야 합니다.
- 직접 해 보기: 넘파이로 신경망을 처음부터 만들기 — 같은 데이터로 손으로 만든 신경망과 PyTorch 버전을 비교하면 자동 미분이 무엇을 대신하는지 보입니다.
한눈에 보기
- PyTorch 학습 코드의 다섯 부품(데이터 · 모델 · 손실 · 옵티마이저 · 루프)을 직접 조립합니다.
- 검증 정확도가 가장 높았던 시점의 가중치를 골라 테스트에서 한 번만 평가합니다.
- 혼동 행렬로 어떤 숫자를 헷갈리는지 봅니다.
- 데이터 증강이 언제나 이득은 아니라는 것을 실제 숫자로 확인합니다.
- 학습 데이터를 100장으로 줄여 과적합이 생기는 모습을 관찰합니다.
0. 준비
pip install torch scikit-learn numpy데이터는 scikit-learn에 들어 있어 내려받을 필요가 없습니다. load_digits()는 8×8 흑백 숫자 이미지 1,797장을 주며, 픽셀 값은 0~16의 정수입니다.
flowchart LR
A[load_digits 1,797장] --> B[학습 · 검증 · 테스트 나누기]
B --> C[Dataset · DataLoader]
C --> D[SmallCNN]
D --> E[교차 엔트로피 + Adam 학습 루프]
E --> F[에포크마다 검증 정확도 → 최고 가중치 보관]
F --> G[테스트 한 번 평가 · 혼동 행렬]
G --> H[state_dict 저장 · 불러와 예측]1. 데이터 나누기
import time
import numpy as np
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(0); np.random.seed(0)
print("torch", torch.__version__)
digits = load_digits()
X = digits.images.astype(np.float32) / 16.0 # 픽셀 0~16 -> 0~1
y = digits.target.astype(np.int64)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y)
X_tr, X_va, y_tr, y_va = train_test_split(X_tr, y_tr, test_size=0.2, random_state=0, stratify=y_tr)
print("train", X_tr.shape, "val", X_va.shape, "test", X_te.shape)torch 2.14.0+cpu
train (1149, 8, 8) val (288, 8, 8) test (360, 8, 8)왜 이렇게 나누나: 먼저 20%를 테스트로 떼어 끝까지 건드리지 않고, 남은 것의 20%를 검증으로 씁니다. stratify=y는 세 묶음 모두 숫자 0~9 비율을 같게 맞춥니다. 픽셀을 16으로 나눠 0~1로 맞추는 것은 입력 크기를 비슷하게 해 학습을 안정시키려는 것입니다.
2. Dataset과 DataLoader
class DigitsDataset(Dataset):
def __init__(self, X, y, augment=False):
self.X = torch.from_numpy(X).unsqueeze(1) # (N, 1, 8, 8): 채널 차원 추가
self.y = torch.from_numpy(y)
self.augment = augment
def __len__(self):
return len(self.y)
def __getitem__(self, i):
x = self.X[i]
if self.augment: # 위아래 · 좌우로 최대 1픽셀 밀기
dx, dy = np.random.randint(-1, 2, size=2)
x = torch.roll(x, shifts=(int(dy), int(dx)), dims=(1, 2))
return x, self.y[i]Dataset은 "몇 개인가(__len__)"와 "i번째는 무엇인가(__getitem__)" 두 가지만 알려 주면 됩니다.DataLoader가 섞기(shuffle=True) · 미니배치 묶기를 대신합니다.- PyTorch 합성곱은 입력을 (배치, 채널, 높이, 너비) 순서로 받으므로
unsqueeze(1)로 채널 1을 붙입니다. - 증강은 학습 데이터에만 켭니다. 검증 · 테스트는 늘 원본으로 평가해야 비교가 공정합니다.
3. 모델
class SmallCNN(nn.Module):
def __init__(self, n_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding=1), # 16x8x8
nn.ReLU(),
nn.Conv2d(16, 32, kernel_size=3, padding=1), # 32x8x8
nn.ReLU(),
nn.MaxPool2d(2), # 32x4x4
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(32 * 4 * 4, 64),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, n_classes),
)
def forward(self, x):
return self.classifier(self.features(x))
def n_params(m):
return sum(p.numel() for p in m.parameters())파라미터 수 손계산(실행 결과 38,282와 일치):
| 층 | 계산 | 개수 |
|---|---|---|
| Conv 1→16, 3×3 | 160 | |
| Conv 16→32, 3×3 | 4,640 | |
| Linear 512→64 | 32,832 | |
| Linear 64→10 | 650 | |
| 합계 | 38,282 |
패딩 1인 3×3 합성곱은 크기를 8×8로 유지하고, 2×2 최대 풀링이 4×4로 줄입니다. 그래서 펼친 길이가 입니다. 파라미터의 86%가 첫 완전 연결층에 몰려 있다는 점도 눈여겨볼 만합니다. 마지막 층은 소프트맥스 없이 로짓을 냅니다. nn.CrossEntropyLoss가 안에서 로그 소프트맥스를 계산하기 때문입니다.
4. 학습 루프와 검증으로 모델 고르기
def run_epoch(model, loader, loss_fn, opt=None):
train = opt is not None
model.train(train) # 드롭아웃 켜기/끄기
total, correct, loss_sum = 0, 0, 0.0
with torch.set_grad_enabled(train): # 평가 때는 기울기 계산 안 함
for xb, yb in loader:
logits = model(xb)
loss = loss_fn(logits, yb)
if train:
opt.zero_grad() # 1. 지난 기울기 지우기
loss.backward() # 2. 역전파
opt.step() # 3. 가중치 갱신
loss_sum += loss.item() * len(yb)
correct += (logits.argmax(1) == yb).sum().item()
total += len(yb)
return loss_sum / total, correct / total
def train_model(augment, epochs=30, lr=1e-3, label=""):
torch.manual_seed(0); np.random.seed(0)
tr = DataLoader(DigitsDataset(X_tr, y_tr, augment), batch_size=64, shuffle=True)
va = DataLoader(DigitsDataset(X_va, y_va), batch_size=256)
model = SmallCNN()
opt = torch.optim.Adam(model.parameters(), lr=lr)
loss_fn = nn.CrossEntropyLoss()
best_va, best_state = 0.0, None
for ep in range(1, epochs + 1):
tl, ta = run_epoch(model, tr, loss_fn, opt)
vl, vacc = run_epoch(model, va, loss_fn)
if vacc > best_va: # 검증 최고 시점 가중치 보관
best_va, best_state = vacc, {k: v.clone() for k, v in model.state_dict().items()}
if ep in (1, 5, 10, 20, 30):
print(f"[{label}] epoch {ep:2d} | train loss {tl:.4f} acc {ta:.4f} | val loss {vl:.4f} acc {vacc:.4f}")
model.load_state_dict(best_state)
return model, best_va
t0 = time.time()
model, best_va = train_model(augment=False, label="기본")
print("params", n_params(model), "best val acc", round(best_va, 4))[기본] epoch 1 | train loss 2.2768 acc 0.1897 | val loss 2.2183 acc 0.4097
[기본] epoch 5 | train loss 0.7191 acc 0.7885 | val loss 0.4526 acc 0.9097
[기본] epoch 10 | train loss 0.3011 acc 0.9043 | val loss 0.1876 acc 0.9549
[기본] epoch 20 | train loss 0.0982 acc 0.9687 | val loss 0.1192 acc 0.9722
[기본] epoch 30 | train loss 0.0651 acc 0.9861 | val loss 0.1088 acc 0.9722
params 38282 best val acc 0.9757읽는 법: 첫 에포크 손실 2.28은 10클래스를 고르게 찍을 때의 에 가깝습니다. "아직 아무것도 모름"에서 출발했다는 확인입니다. 초반에 학습 정확도가 검증보다 낮은 것은 학습 때만 드롭아웃이 켜져 있고, 학습 지표는 에포크 동안 변하는 모델의 평균이기 때문입니다.
왜 model.train() · model.eval()을 바꾸나: 드롭아웃과 배치 정규화는 학습 때와 평가 때 동작이 다릅니다. 이를 잊고 평가하면 점수가 들쭉날쭉해지는 흔한 실수가 생깁니다.
5. 테스트 평가와 혼동 행렬
te = DataLoader(DigitsDataset(X_te, y_te), batch_size=256)
_, test_acc = run_epoch(model, te, nn.CrossEntropyLoss())
print("test acc", round(test_acc, 4))
model.eval()
with torch.no_grad():
pred = model(torch.from_numpy(X_te).unsqueeze(1)).argmax(1).numpy()
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_te, pred)
wrong = [(i, j, cm[i, j]) for i in range(10) for j in range(10) if i != j and cm[i, j] > 0]
print("오분류(정답, 예측, 개수):", wrong)test acc 0.9722
오분류(정답, 예측, 개수): [(0, 4, np.int64(1)), (2, 1, np.int64(1)), (3, 7, np.int64(1)), (4, 9, np.int64(2)), (5, 3, np.int64(1)), (6, 1, np.int64(1)), (8, 1, np.int64(2)), (8, 7, np.int64(1))]테스트 360장 중 350장을 맞혀 97.22%입니다(틀린 10장). 4를 9로, 8을 1로 본 경우가 각 2장으로 가장 많습니다. 8×8 해상도에서는 획 하나가 1~2픽셀이라 비슷한 모양이 쉽게 섞입니다. 이렇게 "어디서 틀리나"를 보면 다음에 무엇을 고칠지(더 높은 해상도, 특정 숫자 데이터 보강) 정할 수 있습니다.
6. 데이터 증강: 언제나 이득은 아니다
model_aug, best_va_aug = train_model(augment=True, label="증강")
_, test_acc_aug = run_epoch(model_aug, te, nn.CrossEntropyLoss())
print("aug best val acc", round(best_va_aug, 4), "aug test acc", round(test_acc_aug, 4))[증강] epoch 1 | train loss 2.2987 acc 0.1192 | val loss 2.2706 acc 0.2049
[증강] epoch 5 | train loss 1.6401 acc 0.4291 | val loss 1.0626 acc 0.7569
[증강] epoch 10 | train loss 0.9945 acc 0.6806 | val loss 0.4562 acc 0.9167
[증강] epoch 20 | train loss 0.4895 acc 0.8538 | val loss 0.2043 acc 0.9306
[증강] epoch 30 | train loss 0.3277 acc 0.8938 | val loss 0.1221 acc 0.9653
aug best val acc 0.9688 aug test acc 0.9639결과 해석: 이번 설정에서는 증강을 켠 모델이 오히려 조금 낮았습니다(테스트 0.9639 대 0.9722). 이유를 생각해 봅시다.
- 8×8 이미지에서 1픽셀은 전체 너비의 12.5%입니다. 사진에서 "조금 밀기"와 달리 꽤 큰 변형입니다.
torch.roll은 밀려 나간 픽셀을 반대편으로 되감아 붙여, 원래 없던 획을 만들 수 있습니다.- 과제가 어려워진 만큼 30 에포크로는 덜 수렴했습니다(학습 정확도 0.89로 아직 오르는 중).
배울 점: 증강은 "데이터에 실제로 있을 법한 변화"를 흉내 낼 때 효과가 있습니다. 효과는 늘 검증 점수로 확인해야 하며, 증강을 켜면 보통 더 오래 학습해야 합니다.
7. 과적합 관찰: 학습 데이터 100장
X_small, y_small = X_tr[:100], y_tr[:100]
torch.manual_seed(0)
m = SmallCNN(); opt = torch.optim.Adam(m.parameters(), lr=1e-3)
tr_small = DataLoader(DigitsDataset(X_small, y_small), batch_size=32, shuffle=True)
va = DataLoader(DigitsDataset(X_va, y_va), batch_size=256)
for ep in range(1, 101):
tl, ta = run_epoch(m, tr_small, nn.CrossEntropyLoss(), opt)
if ep in (10, 25, 50, 100):
vl, vacc = run_epoch(m, va, nn.CrossEntropyLoss())
print(f"[100장] epoch {ep:3d} | train loss {tl:.4f} acc {ta:.4f} | val loss {vl:.4f} acc {vacc:.4f}")[100장] epoch 10 | train loss 2.1494 acc 0.3200 | val loss 2.1869 acc 0.4028
[100장] epoch 25 | train loss 1.0237 acc 0.6000 | val loss 1.0713 acc 0.6458
[100장] epoch 50 | train loss 0.3425 acc 0.8800 | val loss 0.6041 acc 0.8229
[100장] epoch 100 | train loss 0.0755 acc 0.9700 | val loss 0.5354 acc 0.8681100 에포크에서 학습 손실은 0.0755인데 검증 손실은 0.5354로 7배쯤 큽니다. 학습 정확도 0.97과 검증 0.868 사이의 약 10%p 차이가 과적합 신호입니다. 1,149장을 쓴 기본 모델은 같은 차이가 1~2%p였습니다. 데이터가 적을수록 파라미터 38,282개가 학습 예를 "외우기" 쉽다는 것을 숫자로 확인할 수 있습니다.
8. 저장과 불러오기
torch.save(model.state_dict(), "digits_cnn.pt")
loaded = SmallCNN(); loaded.load_state_dict(torch.load("digits_cnn.pt", weights_only=True)); loaded.eval()
with torch.no_grad():
x0 = torch.from_numpy(X_te[:5]).unsqueeze(1)
print("불러온 모델 예측", loaded(x0).argmax(1).tolist(), "정답", y_te[:5].tolist())
print("elapsed sec", round(time.time() - t0, 1))불러온 모델 예측 [7, 6, 3, 7, 7] 정답 [7, 6, 3, 7, 7]
elapsed sec 39.0state_dict(가중치 사전)만 저장하고, 불러올 때는 같은 클래스로 모델을 만든 뒤 가중치를 채웁니다. weights_only=True는 가중치 외의 임의 파이썬 객체를 읽지 않게 해 믿을 수 없는 파일을 열 때 더 안전합니다. 걸린 시간은 컴퓨터마다 다릅니다(같은 코드를 두 번 돌렸을 때 39초와 78초).
9. 더 큰 데이터로: CIFAR-10 판 (실행하지 않음)
같은 뼈대를 32×32 컬러 사진 10클래스(CIFAR-10)로 옮기려면 아래만 바꾸면 됩니다. 이 코드는 torchvision 설치와 데이터 내려받기가 필요해 이 수업에서는 실행하지 않았습니다.
# 실행하지 않음 — pip install torchvision 필요, 첫 실행 때 데이터 내려받음
import torchvision, torchvision.transforms as T
tf_train = T.Compose([T.RandomCrop(32, padding=4), T.RandomHorizontalFlip(), T.ToTensor(),
T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
tf_test = T.Compose([T.ToTensor(), T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
train_set = torchvision.datasets.CIFAR10("./data", train=True, download=True, transform=tf_train)
test_set = torchvision.datasets.CIFAR10("./data", train=False, download=True, transform=tf_test)
# 모델: 첫 Conv2d 입력 채널을 3으로, 풀링을 한 번 더 해 32→16→8, Linear 입력을 채널×8×8로사진에서는 좌우 뒤집기 · 여백 넣고 자르기가 "실제로 있을 법한 변화"라 증강이 대체로 도움이 됩니다. 6절 결과와 비교해 보면 증강은 데이터의 성격에 맞춰 골라야 한다는 점이 분명해집니다.
바꿔 보기 과제
- 증강을 켠 모델을 60 에포크 학습해 기본 모델을 따라잡는지 보세요.
torch.roll대신 0으로 채우며 미는 방식으로도 바꿔 보세요. nn.Dropout(0.3)을 0.0과 0.5로 바꿔 7절의 학습-검증 차이가 어떻게 변하는지 기록하세요.- 두 번째 합성곱 뒤에
nn.BatchNorm2d(32)를 넣고 수렴 속도를 비교하세요. torch.optim.Adam대신torch.optim.SGD(lr=0.1, momentum=0.9)로 바꿔 보세요.
흔한 오류
| 증상 | 원인 | 고치기 |
|---|---|---|
Expected 4D input | 채널 차원 누락 | unsqueeze(1)로 (N, 1, 8, 8) 만들기 |
mat1 and mat2 shapes cannot be multiplied | Linear 입력 크기 계산 착오 | 풀링 후 크기 × 채널 다시 계산 |
| 손실이 2.30에서 안 내려감 | 학습률 과대 · 입력 정규화 누락 · zero_grad 누락 | 0~1 정규화, 학습률 1e-3, 루프 순서 확인 |
| 평가 점수가 실행마다 출렁임 | model.eval() 안 함 | 평가 전 eval(), 학습 전 train() |
| 소프트맥스를 두 번 적용 | 모델 끝에 Softmax + CrossEntropyLoss | 모델은 로짓만 출력 |
스스로 점검
- 첫 에포크 손실이 약 2.3인 것이 왜 "정상"인가요?
- 답: 10클래스를 똑같이 찍는 모델의 교차 엔트로피가 이기 때문입니다. 학습 시작점이 무작위 추측이라는 확인입니다.
- 테스트 데이터로 최고 에포크를 고르면 무엇이 문제인가요?
- 답: 테스트에 맞춘 선택이 되어 테스트 점수가 실력보다 좋게 나옵니다. 고르기는 검증으로, 테스트는 마지막에 한 번만 씁니다.
- 이 실험에서 증강이 도움이 되지 않은 이유 두 가지는?
- 답: 8×8에서 1픽셀 이동이 너무 큰 변형이었고(되감기 부작용 포함), 과제가 어려워진 만큼 30 에포크로는 덜 학습됐기 때문입니다.
용어 정리
| 용어 | 쉬운 뜻 |
|---|---|
| Dataset · DataLoader | 데이터 한 개를 꺼내는 규칙 · 그것을 섞고 묶어 배치로 주는 도구 |
| 미니배치(minibatch) | 한 번 갱신에 쓰는 예제 묶음 |
| 에포크(epoch) | 학습 데이터를 한 바퀴 다 보는 것 |
| 로짓(logit) | 소프트맥스 전의 점수 |
| state_dict | 모델 가중치를 이름별로 담은 사전 |
| 혼동 행렬(confusion matrix) | 정답 × 예측 개수 표 |
| 데이터 증강(data augmentation) | 학습 예를 조금씩 바꿔 늘리는 기법 |
train() · eval() 모드 | 드롭아웃 · 배치 정규화의 학습용 · 평가용 동작 전환 |
다음에 볼 것
- 딥러닝 문서로 배우기 (D2L) — 같은 PyTorch 코드 스타일로 CNN · RNN · 트랜스포머를 이어서 실습할 수 있는 교재입니다.
- 중간 점검: 딥러닝 기초 트랙 — 이 트랙 전체를 문제 15개로 확인합니다.
라이선스
이 글과 코드는 KAITABASE가 직접 작성했으며, 출력은 위 실행 환경에서 실제로 돌린 결과입니다(난수 시드 0 고정, 환경에 따라 소수점 아래가 조금 다를 수 있음). 참고 자료 PyTorch 튜토리얼은 BSD-3 라이선스, scikit-learn은 BSD-3 라이선스입니다. 9절 CIFAR-10 코드는 실행하지 않았습니다.
출처
- 참고: https://docs.pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html (PyTorch CIFAR-10 분류기 튜토리얼)
- 참고: https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_digits.html (load_digits 데이터)
- 참고: https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html (교차 엔트로피 손실)
AI 원리부터 이해하기 · 딥러닝 기초 모듈
딥러닝 기초 모듈 학습
이 글은 딥러닝 기초 모듈의 학습 자료입니다. 전체 29개 자료 중 24번째입니다.
출처·원문
원문 보기요약만