검색
← 모듈 로드맵

직접 해 보기: PyTorch로 합성곱 신경망 학습하기

강의 · 준전문가 · KAITABASE 정리

sklearn 8×8 숫자로 PyTorch CNN을 학습 · 평가 · 증강 · 과적합 관찰 · 저장까지 실제 실행

먼저 알아야 할 것

합성곱 · 풀링교차 엔트로피 · Adam학습 · 검증 · 테스트 나누기파이썬 기초

다루는 내용

내려받기 없이 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 학습 코드의 다섯 부품(데이터 · 모델 · 손실 · 옵티마이저 · 루프)을 직접 조립합니다.
  • 검증 정확도가 가장 높았던 시점의 가중치를 골라 테스트에서 한 번만 평가합니다.
  • 혼동 행렬로 어떤 숫자를 헷갈리는지 봅니다.
  • 데이터 증강이 언제나 이득은 아니라는 것을 실제 숫자로 확인합니다.
  • 학습 데이터를 100장으로 줄여 과적합이 생기는 모습을 관찰합니다.

0. 준비

bash
pip install torch scikit-learn numpy

데이터는 scikit-learn에 들어 있어 내려받을 필요가 없습니다. load_digits()는 8×8 흑백 숫자 이미지 1,797장을 주며, 픽셀 값은 0~16의 정수입니다.

mermaid
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. 데이터 나누기

python
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

python
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. 모델

python
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×31×16×9+161 \times 16 \times 9 + 16160
Conv 16→32, 3×316×32×9+3216 \times 32 \times 9 + 324,640
Linear 512→64512×64+64512 \times 64 + 6432,832
Linear 64→1064×10+1064 \times 10 + 10650
합계38,282

패딩 1인 3×3 합성곱은 크기를 8×8로 유지하고, 2×2 최대 풀링이 4×4로 줄입니다. 그래서 펼친 길이가 32×4×4=51232 \times 4 \times 4 = 512입니다. 파라미터의 86%가 첫 완전 연결층에 몰려 있다는 점도 눈여겨볼 만합니다. 마지막 층은 소프트맥스 없이 로짓을 냅니다. nn.CrossEntropyLoss가 안에서 로그 소프트맥스를 계산하기 때문입니다.

4. 학습 루프와 검증으로 모델 고르기

python
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클래스를 고르게 찍을 때의 ln⁡10≈2.303\ln 10 \approx 2.303에 가깝습니다. "아직 아무것도 모름"에서 출발했다는 확인입니다. 초반에 학습 정확도가 검증보다 낮은 것은 학습 때만 드롭아웃이 켜져 있고, 학습 지표는 에포크 동안 변하는 모델의 평균이기 때문입니다.

왜 model.train() · model.eval()을 바꾸나: 드롭아웃과 배치 정규화는 학습 때와 평가 때 동작이 다릅니다. 이를 잊고 평가하면 점수가 들쭉날쭉해지는 흔한 실수가 생깁니다.

5. 테스트 평가와 혼동 행렬

python
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. 데이터 증강: 언제나 이득은 아니다

python
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장

python
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.8681

100 에포크에서 학습 손실은 0.0755인데 검증 손실은 0.5354로 7배쯤 큽니다. 학습 정확도 0.97과 검증 0.868 사이의 약 10%p 차이가 과적합 신호입니다. 1,149장을 쓴 기본 모델은 같은 차이가 1~2%p였습니다. 데이터가 적을수록 파라미터 38,282개가 학습 예를 "외우기" 쉽다는 것을 숫자로 확인할 수 있습니다.

8. 저장과 불러오기

python
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.0

state_dict(가중치 사전)만 저장하고, 불러올 때는 같은 클래스로 모델을 만든 뒤 가중치를 채웁니다. weights_only=True는 가중치 외의 임의 파이썬 객체를 읽지 않게 해 믿을 수 없는 파일을 열 때 더 안전합니다. 걸린 시간은 컴퓨터마다 다릅니다(같은 코드를 두 번 돌렸을 때 39초와 78초).

9. 더 큰 데이터로: CIFAR-10 판 (실행하지 않음)

같은 뼈대를 32×32 컬러 사진 10클래스(CIFAR-10)로 옮기려면 아래만 바꾸면 됩니다. 이 코드는 torchvision 설치와 데이터 내려받기가 필요해 이 수업에서는 실행하지 않았습니다.

python
# 실행하지 않음 — 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절 결과와 비교해 보면 증강은 데이터의 성격에 맞춰 골라야 한다는 점이 분명해집니다.

바꿔 보기 과제

  1. 증강을 켠 모델을 60 에포크 학습해 기본 모델을 따라잡는지 보세요. torch.roll 대신 0으로 채우며 미는 방식으로도 바꿔 보세요.
  2. nn.Dropout(0.3)을 0.0과 0.5로 바꿔 7절의 학습-검증 차이가 어떻게 변하는지 기록하세요.
  3. 두 번째 합성곱 뒤에 nn.BatchNorm2d(32)를 넣고 수렴 속도를 비교하세요.
  4. 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 multipliedLinear 입력 크기 계산 착오풀링 후 크기 × 채널 다시 계산
손실이 2.30에서 안 내려감학습률 과대 · 입력 정규화 누락 · zero_grad 누락0~1 정규화, 학습률 1e-3, 루프 순서 확인
평가 점수가 실행마다 출렁임model.eval() 안 함평가 전 eval(), 학습 전 train()
소프트맥스를 두 번 적용모델 끝에 Softmax + CrossEntropyLoss모델은 로짓만 출력

스스로 점검

  1. 첫 에포크 손실이 약 2.3인 것이 왜 "정상"인가요?
    • 답: 10클래스를 똑같이 찍는 모델의 교차 엔트로피가 ln⁡10≈2.303\ln 10 \approx 2.303이기 때문입니다. 학습 시작점이 무작위 추측이라는 확인입니다.
  2. 테스트 데이터로 최고 에포크를 고르면 무엇이 문제인가요?
    • 답: 테스트에 맞춘 선택이 되어 테스트 점수가 실력보다 좋게 나옵니다. 고르기는 검증으로, 테스트는 마지막에 한 번만 씁니다.
  3. 이 실험에서 증강이 도움이 되지 않은 이유 두 가지는?
    • 답: 8×8에서 1픽셀 이동이 너무 큰 변형이었고(되감기 부작용 포함), 과제가 어려워진 만큼 30 에포크로는 덜 학습됐기 때문입니다.

용어 정리

용어쉬운 뜻
Dataset · DataLoader데이터 한 개를 꺼내는 규칙 · 그것을 섞고 묶어 배치로 주는 도구
미니배치(minibatch)한 번 갱신에 쓰는 예제 묶음
에포크(epoch)학습 데이터를 한 바퀴 다 보는 것
로짓(logit)소프트맥스 전의 점수
state_dict모델 가중치를 이름별로 담은 사전
혼동 행렬(confusion matrix)정답 × 예측 개수 표
데이터 증강(data augmentation)학습 예를 조금씩 바꿔 늘리는 기법
train() · eval() 모드드롭아웃 · 배치 정규화의 학습용 · 평가용 동작 전환

다음에 볼 것

라이선스

이 글과 코드는 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번째입니다.

AI 기초 과정은 현재 진도 저장을 지원하지 않습니다.

출처·원문

원문 보기

요약만

본문 · 코드 자체 작성 · 참고 자료 PyTorch 튜토리얼(BSD-3)