
MNIST 손글씨 숫자 분류는 PyTorch를 처음 배울 때 누구나 거치는 실습이지만, 막상 “MLP와 CNN 중 뭐가 더 나은가”를 숫자로 비교한 자료는 의외로 드뭅니다. 이 글에서는 PyTorch로 MNIST CNN 만들기 과정을 단계별 코드로 보여드리면서, 같은 데이터셋에 대해 MLP와 CNN의 파라미터 수를 직접 계산하고 구조적 차이가 왜 정확도 차이로 이어지는지 정리했습니다. 코드를 그대로 복사해서 실행하면 model.parameters()로 직접 파라미터 수를 확인할 수 있고, 두 모델의 학습 곡선이 왜 다르게 나오는지도 이해할 수 있습니다.
MLP와 CNN 구조 설계 — 비교의 기준 잡기
공정하게 비교하려면 두 모델의 “크기”를 비슷한 수준으로 맞추는 게 먼저입니다. 이 글에서는 다음 구조를 사용합니다.
- MLP: 입력 784(28×28 픽셀을 1차원으로 펼침) → 은닉층 256 → 은닉층 128 → 출력 10
- CNN: Conv(1→32, 3×3) → MaxPool(2×2) → Conv(32→64, 3×3) → MaxPool(2×2) → FC(3136→64) → FC(64→10)
MLP는 이미지를 784차원 벡터로 펴서 넣기 때문에 픽셀 간의 공간적 위치 정보(위아래·좌우 인접 관계)를 전혀 활용하지 못합니다. 반면 CNN은 3×3 커널이 이미지 전체를 슬라이딩하며 지역적인 패턴(모서리, 곡선)을 추출하고, 이 커널의 가중치를 이미지 전체 위치에서 공유합니다. 이 “가중치 공유” 덕분에 CNN은 더 넓은 영역을 처리하면서도 파라미터 수를 MLP와 비슷하거나 더 적게 유지할 수 있습니다. nn.Conv2d의 정확한 파라미터 규칙은 PyTorch 공식 문서에 나와 있는데, 커널 크기·입력 채널·출력 채널 수로 가중치 개수가 결정되고 편향(bias)이 출력 채널 수만큼 추가됩니다.
PyTorch 코드로 두 모델 직접 구현하기
먼저 데이터를 불러오는 부분입니다. torchvision.datasets.MNIST를 쓰면 자동으로 다운로드되고, 정규화 값 (0.1307, 0.3081)은 MNIST 학습 데이터 전체의 평균·표준편차로 PyTorch 공식 예제에서도 사용하는 값입니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_set = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
test_set = datasets.MNIST(root="./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
test_loader = DataLoader(test_set, batch_size=1000, shuffle=False)
MLP 모델 정의입니다. view로 이미지를 1차원으로 펼친 뒤 완전연결층 3개를 통과시킵니다.

class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(28 * 28, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
def forward(self, x):
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
return self.fc3(x)
CNN 모델 정의입니다. padding=1을 줘서 3×3 컨볼루션 후에도 특성맵 크기가 그대로 유지되도록 하고, MaxPool로 두 번 절반씩 줄여 28→14→7이 되도록 설계했습니다.
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.fc1 = nn.Linear(64 * 7 * 7, 64)
self.fc2 = nn.Linear(64, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x))) # 28x28 -> 14x14
x = self.pool(F.relu(self.conv2(x))) # 14x14 -> 7x7
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
return self.fc2(x)
파라미터 수 직접 계산해보기
sum(p.numel() for p in model.parameters())로 모델 전체 파라미터 수를 바로 셀 수 있습니다.
def count_params(model):
return sum(p.numel() for p in model.parameters())
mlp = MLP()
cnn = CNN()
print("MLP params:", count_params(mlp))
print("CNN params:", count_params(cnn))
# 출력 결과
# MLP params: 235146
# CNN params: 220234
이 숫자는 각 층의 가중치·편향을 직접 계산한 값과 정확히 일치합니다. 층별로 나눠보면 다음과 같습니다.
| 모델 | 레이어 | 계산식 | 파라미터 수 |
|---|---|---|---|
| MLP | fc1 (784→256) | 784×256 + 256 | 200,960 |
| MLP | fc2 (256→128) | 256×128 + 128 | 32,896 |
| MLP | fc3 (128→10) | 128×10 + 10 | 1,290 |
| MLP | 합계 | 235,146 | |
| CNN | conv1 (1→32, 3×3) | 32×(1×3×3+1) | 320 |
| CNN | conv2 (32→64, 3×3) | 64×(32×3×3+1) | 18,496 |
| CNN | fc1 (3136→64) | 3136×64 + 64 | 200,768 |
| CNN | fc2 (64→10) | 64×10 + 10 | 650 |
| CNN | 합계 | 220,234 |
흥미로운 점은 이 설계에서 CNN이 MLP보다 파라미터 수가 오히려 적다는 것입니다. 컨볼루션 층(conv1, conv2) 자체는 18,816개로 미미하고, 대부분의 파라미터는 여전히 마지막 완전연결층(fc1)에 몰려 있습니다. 즉 “CNN이 무조건 파라미터가 적다”가 아니라 컨볼루션 부분의 가중치 공유 덕분에 같은 표현력을 훨씬 적은 파라미터로 얻고, 나머지 용량은 분류기(FC)에 배분한 구조라는 점이 핵심입니다.
학습 코드와 정확도 차이가 나는 이유

학습 루프는 두 모델 모두 동일하게 씁니다. 옵티마이저는 Adam, 손실 함수는 다중 분류에 적합한 CrossEntropyLoss를 사용합니다.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = CNN().to(device) # 또는 MLP().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
for epoch in range(5):
model.train()
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
output = model(images)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
model.eval()
correct, total = 0, 0
with torch.no_grad():
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
preds = model(images).argmax(dim=1)
correct += (preds == labels).sum().item()
total += labels.size(0)
print(f"Epoch {epoch+1}, Test Accuracy: {correct/total:.4f}")
파라미터 수가 비슷한데도 두 모델의 학습 곡선은 다르게 나타납니다. MLP는 784개 픽셀을 독립적인 입력처럼 취급하기 때문에, 숫자가 이미지 안에서 몇 픽셀만 옆으로 이동해도 완전히 다른 입력 패턴으로 인식합니다. 이런 특성 때문에 학습 데이터에서는 높은 정확도를 보여도 테스트셋의 사소한 위치 변화에 취약한 경향이 있습니다. CNN은 컨볼루션 커널이 이미지 전 영역에서 같은 가중치로 특징을 추출하므로, 숫자가 살짝 이동하거나 형태가 조금 달라져도 비슷한 특징 맵을 만들어냅니다(이동 불변성). 이 글의 검색 참고 자료 중 하나(6번 자료)에서도 CNN 모델이 학습 후 약 98.69%의 테스트 정확도를 기록했다고 보고하고 있는데, 이는 MLP 단독 구조에서 흔히 보고되는 97%대보다 확실히 높은 수준입니다. 다만 정확한 수치는 배치 크기, 학습률, 에폭 수, 랜덤 시드에 따라 실행할 때마다 달라지므로, 위 코드를 그대로 돌려 본인 환경에서 나온 값을 기준으로 삼는 것이 정확합니다.
튜닝 시 주의할 점
nn.MaxPool2d(2, 2)를 두 번 거치면 28×28 이미지가 7×7로 줄어드는데, 만약 padding 설정을 틀리면 fc1 = nn.Linear(64*7*7, 64)의 입력 크기가 맞지 않아 RuntimeError: mat1 and mat2 shapes cannot be multiplied 에러가 발생합니다. 이 에러를 만나면 print(x.shape)를 forward 안에 임시로 추가해 실제 텐서 크기를 확인하는 것이 가장 빠른 해결법입니다. 배치 크기는 GPU 메모리에 여유가 있다면 64에서 128로 늘려도 되고, 학습률은 Adam 기준 1e-3이 기본값으로 무난하지만 손실이 진동하면 5e-4로 낮춰볼 수 있습니다. GPU가 없는 환경(torch.cuda.is_available()이 False)이라면 CNN은 conv 연산 특성상 CPU에서 MLP보다 느리게 학습되므로, 실습 단계에서는 에폭 수를 3~5로 제한해 먼저 구조와 파라미터 계산이 맞는지 확인한 뒤 에폭을 늘리는 순서를 추천합니다.
마무리 — 다음 실습 단계
이 글에서는 MLP(784-256-128-10)와 CNN(Conv 32→64 + FC 64) 구조를 코드로 직접 구현하고, model.parameters()로 파라미터 수를 계산해 MLP 235,146개, CNN 220,234개라는 결과를 확인했습니다. 파라미터 수가 비슷하거나 CNN이 더 적음에도 컨볼루션의 가중치 공유와 이동 불변성 덕분에 CNN 쪽 정확도가 더 높게 나오는 경향을 구조적으로 살펴봤습니다. 다음 단계로는 torchvision.transforms.RandomRotation이나 RandomAffine으로 데이터 증강을 추가해 두 모델의 정확도 변화를 비교해보거나, CNN의 conv 채널 수를 32→16, 64→32로 줄여 파라미터 수 대비 정확도 손실이 얼마나 되는지 직접 실험해보는 것을 추천합니다. 데이터셋 상세 스펙은 torchvision MNIST 공식 문서에서 확인할 수 있습니다.
자주 묻는 질문(FAQ)
Q1. MLP와 CNN 중 MNIST에는 무조건 CNN을 써야 하나요?
MNIST처럼 이미지가 작고 배경이 단순한 데이터셋에서는 MLP도 97% 안팎의 정확도를 낼 수 있어 실습·프로토타입 용도로는 충분합니다. 다만 실제 서비스나 더 복잡한 이미지(배경 잡음, 회전, 크기 변화가 있는 데이터)에는 이동 불변성을 가진 CNN 구조가 안정적입니다.
Q2. 파라미터 수가 적으면 항상 학습이 빠른가요?
아닙니다. 파라미터 수는 모델 크기의 지표일 뿐이고, 실제 학습 속도는 연산량(FLOPs)에 더 크게 좌우됩니다. CNN은 파라미터 수가 적어도 커널이 이미지 전체를 슬라이딩하며 반복 연산을 하기 때문에, 같은 파라미터 수의 MLP보다 CPU 환경에서는 오히려 느릴 수 있습니다.
Q3. fc1 = nn.Linear(64*7*7, 64)의 7×7은 어떻게 계산하나요?
입력 28×28에 padding=1, kernel_size=3 컨볼루션은 출력 크기를 그대로 유지하고, MaxPool2d(2,2)가 절반으로 줄입니다. 28 → (conv, 크기 유지) → (pool) 14 → (conv, 크기 유지) → (pool) 7이 되어 최종 특성맵 크기가 7×7이 됩니다. 이 계산이 코드의 padding·stride 설정과 어긋나면 Linear 계층 입력 크기 에러가 발생하므로 반드시 직접 계산해서 맞춰야 합니다.