Step 5. MLP로 이미지 분류하기: MNIST 데이터셋 이해¶
딥러닝의 세계에 발을 들일 때 가장 먼저 만나게 되는 'Hello World', 바로 MNIST(Modified National Institute of Standards and Technology) 데이터셋입니다.
1. MNIST란 무엇인가?¶
MNIST는 0부터 9까지의 숫자들로 이루어진 손글씨 이미지 데이터셋입니다. 1990년대에 수집된 이 데이터는 오늘날까지도 새로운 알고리즘의 성능을 검증하는 표준 벤치마크로 널리 사용되고 있습니다.
- 데이터 구성:
- Training Set: 60,000개의 학습 데이터
- Test Set: 10,000개의 테스트 데이터
- 이미지 규격: 28 x 28 픽셀 (흑백 이미지)
- 레이블(Label): 0부터 9까지의 정수 (총 10개 클래스)
2. MLP(다층 퍼셉트론)의 이미지 처리 방식¶
우리는 지금까지 배운 MLP(Multi-Layer Perceptron)를 사용하여 이 숫자를 맞춰볼 것입니다. 하지만 여기서 아주 중요한 과정 하나가 필요합니다.
데이터의 평탄화 (Flattening)¶
MLP는 1차원 배열 형태의 입력만 받을 수 있습니다. 하지만 MNIST 이미지는 $28 \times 28$ 크기의 2차원 행렬입니다. 따라서 이 행렬을 한 줄로 길게 이어 붙이는 작업이 필요합니다.
- 입력 데이터 변환: $28 \times 28 = 784$
- 결과적으로 각 이미지는 784개의 특징(Feature)을 가진 하나의 벡터가 되어 신경망에 입력됩니다.
3. Step 5의 핵심 질문: "공간 정보의 손실"¶
이 방식은 단순하고 강력하지만 한 가지 치명적인 약점이 있습니다. 이미지를 1차원으로 펼치는 순간, 픽셀 간의 상하좌우 관계(공간 정보)가 완전히 파괴된다는 점입니다.
Note: "숫자 '8'의 위아래 동그라미가 붙어 있다"라는 공간적 특징을 MLP는 이해하지 못하고, 단지 784개의 독립된 픽셀 값이 얼마나 밝은지만을 보고 판단하게 됩니다. 이 한계를 극복하는 방법은 이후 Part 2 - Step 6(CNN)에서 다루게 됩니다.
import setup_env
✅ GPU 활성화: NVIDIA GeForce RTX 5070 Ti ✅ 전체 VRAM 용량: 15.92 GB ✅ CUDA 버전: 13.0 ✅ PyTorch 버전: 2.9.0a0+145a3a7bda.nv25.10 --------------------------------------------------
2. 데이터 로드 및 전처리 (Data Loading & Preprocessing)¶
모델이 학습을 시작하기 전, '문제지'인 데이터를 준비하는 과정입니다. 파이토치(PyTorch)의 torchvision 라이브러리를 사용하면 복잡한 데이터 로딩 과정을 아주 간단하게 처리할 수 있습니다.
🌐 1) 데이터는 어디서 오나요? (datasets.MNIST)¶
torchvision.datasets.MNIST 함수는 얀 르쿤(Yann LeCun) 교수가 제공하는 MNIST 원본 서버에 접속하여 데이터를 내려받습니다.
root='./data': 데이터를 저장할 폴더 위치입니다. 현재 디렉토리에data폴더가 생성됩니다.download=True: 지정된 경로에 데이터가 없으면 인터넷에서 자동으로 다운로드합니다.- 학습용 vs 테스트용:
train=True는 모델 학습을 위한 6만 장의 데이터를,False는 학습 결과 검증을 위한 1만 장의 데이터를 가져옵니다.
🛠️ 2) 모델이 먹기 좋게 가공하기 (Transforms)¶
컴퓨터는 날것의 이미지(0~255 픽셀 값)보다 정제된 수치를 더 잘 학습합니다.
ToTensor(): 0~255 사이의 정수 값을 가진 이미지를 0.0~1.0 사이의 실수 값을 가진 '파이토치 텐서'로 변환합니다.Normalize((0.1307,), (0.3081,)): MNIST 데이터 전체의 평균(0.1307)과 표준편차(0.3081)를 사용하여 데이터를 표준화합니다. 이는 모델이 특정 픽셀에 치우치지 않고 가중치를 고르게 학습하도록 돕습니다.
📦 3) 효율적인 배달 시스템 (DataLoader)¶
6만 장의 데이터를 한꺼번에 GPU에 넣으면 메모리가 버티지 못합니다. 그래서 DataLoader라는 배달 트럭이 필요합니다.
batch_size=64: 데이터를 64개씩 묶어서 전달합니다. RTX와 같은 GPU는 여러 작업을 동시에 처리(병렬 연산)하므로 이렇게 묶어서 처리하는 것이 훨씬 빠릅니다.shuffle=True: 학습할 때마다 데이터의 순서를 섞어줍니다. 모델이 문제의 순서를 외우는 '편향'을 방지하기 위해 필수적입니다.
# ==========================================
# 2. 데이터 로드 및 전처리 (Normalization)
# ==========================================
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
# MNIST 데이터셋 다운로드
train_dataset = torchvision.datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = torchvision.datasets.MNIST(root='./data', train=False, download=True, transform=transform)
# DataLoader 설정
train_loader = torch.utils.data.DataLoader(dataset=train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(dataset=test_dataset, batch_size=64, shuffle=False)
print(f"✅ 데이터 로드 완료 (학습: {len(train_dataset)}개, 테스트: {len(test_dataset)}개)")
✅ 데이터 로드 완료 (학습: 60000개, 테스트: 10000개)
import os
def list_files(startpath):
print(f"📁 대상 경로: {os.path.abspath(startpath)}")
for root, dirs, files in os.walk(startpath):
level = root.replace(startpath, '').count(os.sep)
indent = ' ' * 4 * (level)
print(f"{indent}📂 {os.path.basename(root)}/")
subindent = ' ' * 4 * (level + 1)
for f in files:
print(f"{subindent}📜 {f}")
# MNIST 데이터가 저장된 경로 확인
list_files('./data')
📁 대상 경로: /workspace/ai-deeplearning/tutorial/data
📂 data/
📂 MNIST/
📂 raw/
📜 t10k-images-idx3-ubyte
📜 t10k-images-idx3-ubyte.gz
📜 t10k-labels-idx1-ubyte
📜 t10k-labels-idx1-ubyte.gz
📜 train-images-idx3-ubyte
📜 train-images-idx3-ubyte.gz
📜 train-labels-idx1-ubyte
📜 train-labels-idx1-ubyte.gz
# 1번째(인덱스 0) 데이터 세트 가져오기
# train_dataset[0]은 (image_tensor, label_integer)를 반환합니다.
image, label = train_dataset[0]
print(f"✅ 데이터 타입: {type(image)}") # <class 'torch.Tensor'>
print(f"✅ 이미지 모양: {image.shape}") # torch.Size([1, 28, 28])
print(f"✅ 이 이미지의 정답(Label): {label}") # 5 (MNIST의 첫 번째 데이터는 5입니다)
# 시각화 (Matplotlib)
import matplotlib.pyplot as plt
plt.imshow(image.squeeze(), cmap='gray') # squeeze()로 [1, 28, 28] -> [28, 28] 변환
plt.title(f"Label (Ground Truth): {label}")
plt.show()
✅ 데이터 타입: <class 'torch.Tensor'> ✅ 이미지 모양: torch.Size([1, 28, 28]) ✅ 이 이미지의 정답(Label): 5
🔧 데이터 전처리: Normalization (정규화)¶
📊 왜 정규화가 필요한가?¶
원본 MNIST 이미지는 픽셀값이 0~255 범위를 가집니다. 이를 그대로 사용하면:
- 학습 불안정: 큰 값으로 인해 gradient가 폭발하거나 소실될 수 있음
- 느린 수렴: 최적화 알고리즘이 최적점을 찾는데 오래 걸림
- 활성화 함수 포화: ReLU, Sigmoid 등이 제대로 작동하지 않을 수 있음
🧮 정규화 수식¶
MNIST의 경우 다음과 같이 정규화합니다:
$$ X_{\text{normalized}} = \frac{X_{\text{raw}} - \mu}{\sigma} $$
여기서:
- $X_{\text{raw}}$: 원본 픽셀값 (0~255를 ToTensor()로 0~1로 변환한 값)
- $\mu = 0.1307$: MNIST 전체 데이터셋의 평균
- $\sigma = 0.3081$: MNIST 전체 데이터셋의 표준편차
📈 변환 과정¶
원본 픽셀 (0~255)
↓ ToTensor()
범위 (0~1)
↓ Normalize(mean=0.1307, std=0.3081)
정규화된 값 (평균≈0, 표준편차≈1)
🎯 정규화 효과¶
- ✅ 평균 0, 표준편차 1에 가까운 분포로 변환
- ✅ 학습 속도 향상 및 안정성 증가
- ✅ 더 나은 일반화 성능
- ✅ 활성화 함수의 효율적인 작동
💡 예시¶
픽셀값 128인 경우:
ToTensor(): 128/255 ≈ 0.502Normalize(): (0.502 - 0.1307) / 0.3081 ≈ 1.205
import torch
from torchvision import datasets, transforms
import numpy as np
import sys
# 터미널/노트북에서 배열 생략 없이 전체를 보여주도록 설정
np.set_printoptions(threshold=sys.maxsize, linewidth=150)
# 1. 원본 데이터 로드 (전처리 없음)
dataset_raw = datasets.MNIST(root='./data', train=True, download=True)
img_raw, label = dataset_raw[0]
pixels_raw = np.array(img_raw)
# 2. 정규화 데이터 로드 (ToTensor + Normalize 적용)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
dataset_norm = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
tensor_norm, _ = dataset_norm[0]
pixels_norm = tensor_norm.squeeze().numpy()
print(f"--- [첫 번째 이미지 (Label: {label}) - Raw 0~255] ---")
print(pixels_raw)
print("\n" + "="*50 + "\n")
print(f"--- [첫 번째 이미지 (Label: {label}) - Normalized Float] ---")
# 가독성을 위해 소수점 둘째자리까지 반올림
print(np.round(pixels_norm, 2))
--- [첫 번째 이미지 (Label: 5) - Raw 0~255] --- [[ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 3 18 18 18 126 136 175 26 166 255 247 127 0 0 0 0] [ 0 0 0 0 0 0 0 0 30 36 94 154 170 253 253 253 253 253 225 172 253 242 195 64 0 0 0 0] [ 0 0 0 0 0 0 0 49 238 253 253 253 253 253 253 253 253 251 93 82 82 56 39 0 0 0 0 0] [ 0 0 0 0 0 0 0 18 219 253 253 253 253 253 198 182 247 241 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 80 156 107 253 253 205 11 0 43 154 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 14 1 154 253 90 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 139 253 190 2 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 11 190 253 70 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 35 241 225 160 108 1 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 81 240 253 253 119 25 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 45 186 253 253 150 27 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 16 93 252 253 187 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 249 253 249 64 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 46 130 183 253 253 207 2 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 39 148 229 253 253 253 250 182 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 24 114 221 253 253 253 253 201 78 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 23 66 213 253 253 253 253 198 81 2 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 18 171 219 253 253 253 253 195 80 9 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 55 172 226 253 253 253 253 244 133 11 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 136 253 253 253 212 135 132 16 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]] ================================================== --- [첫 번째 이미지 (Label: 5) - Normalized Float] --- [[-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.39 -0.2 -0.2 -0.2 1.18 1.31 1.8 -0.09 1.69 2.82 2.72 1.19 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.04 0.03 0.77 1.54 1.74 2.8 2.8 2.8 2.8 2.8 2.44 1.77 2.8 2.66 2.06 0.39 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.2 2.61 2.8 2.8 2.8 2.8 2.8 2.8 2.8 2.8 2.77 0.76 0.62 0.62 0.29 0.07 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.2 2.36 2.8 2.8 2.8 2.8 2.8 2.1 1.89 2.72 2.64 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.59 1.56 0.94 2.8 2.8 2.19 -0.28 -0.42 0.12 1.54 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.25 -0.41 1.54 2.8 0.72 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 1.35 2.8 1.99 -0.4 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.28 1.99 2.8 0.47 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.02 2.64 2.44 1.61 0.95 -0.41 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.61 2.63 2.8 2.8 1.09 -0.11 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.15 1.94 2.8 2.8 1.49 -0.08 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.22 0.76 2.78 2.8 1.96 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 2.75 2.8 2.75 0.39 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.16 1.23 1.91 2.8 2.8 2.21 -0.4 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 0.07 1.46 2.49 2.8 2.8 2.8 2.76 1.89 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.12 1.03 2.39 2.8 2.8 2.8 2.8 2.13 0.57 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.13 0.42 2.29 2.8 2.8 2.8 2.8 2.1 0.61 -0.4 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.2 1.75 2.36 2.8 2.8 2.8 2.8 2.06 0.59 -0.31 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 0.28 1.77 2.45 2.8 2.8 2.8 2.8 2.68 1.27 -0.28 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 1.31 2.8 2.8 2.8 2.27 1.29 1.26 -0.22 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42] [-0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42 -0.42]]
🏗️ MLP 모델 정의¶
📐 MLP (Multi-Layer Perceptron)란?¶
다층 퍼셉트론은 가장 기본적인 신경망 구조로, 여러 층의 뉴런들이 완전히 연결된 구조입니다.
🧱 모델 구조¶
입력층 (784) → 은닉층1 (128) → 은닉층2 (64) → 출력층 (10)
↓ ↓ ↓ ↓
28×28 평탄화 ReLU 활성화 ReLU 활성화 10개 클래스
🔢 각 층의 역할¶
1️⃣ 입력층 (Input Layer)¶
- 크기: 784 (= 28 × 28 픽셀)
- 역할: 2D 이미지(28×28)를 1D 벡터(784)로 평탄화
- 수식:
x.view(batch_size, -1)→ (batch, 1, 28, 28) → (batch, 784)
2️⃣ 은닉층 1 (Hidden Layer 1)¶
- 크기: 784 → 128
- 활성화 함수: ReLU (Rectified Linear Unit)
- 수식: $$h_1 = \text{ReLU}(W_1 \cdot x + b_1)$$ $$\text{ReLU}(z) = \max(0, z)$$
- 역할: 입력의 차원을 축소하면서 중요한 특징 추출
3️⃣ 은닉층 2 (Hidden Layer 2)¶
- 크기: 128 → 64
- 활성화 함수: ReLU
- 수식: $$h_2 = \text{ReLU}(W_2 \cdot h_1 + b_2)$$
- 역할: 더 고차원적인 특징 학습 및 추상화
4️⃣ 출력층 (Output Layer)¶
- 크기: 64 → 10
- 활성화 함수: 없음 (CrossEntropyLoss가 내부적으로 Softmax 적용)
- 수식: $$\text{output} = W_3 \cdot h_2 + b_3$$
- 역할: 10개 클래스(0~9 숫자)에 대한 점수(logit) 출력
🎯 ReLU 활성화 함수¶
ReLU (Rectified Linear Unit)는 가장 널리 사용되는 활성화 함수입니다.
$$ \text{ReLU}(x) = \max(0, x) = \begin{cases} x & \text{if } x > 0 \\ 0 & \text{if } x \leq 0 \end{cases} $$
장점:¶
- ✅ 계산 효율성: 단순한 max 연산
- ✅ Gradient 소실 방지: 양수 영역에서 gradient가 1
- ✅ 희소 활성화: 일부 뉴런만 활성화되어 효율적
단점:¶
- ❌ Dying ReLU: 음수 입력에 대해 gradient가 0 (학습 중단 가능)
📊 파라미터 수 계산¶
- Layer 1: $(784 \times 128) + 128 = 100,480$ 개
- Layer 2: $(128 \times 64) + 64 = 8,256$ 개
- Layer 3: $(64 \times 10) + 10 = 650$ 개
- 총합: $109,386$ 개의 학습 가능한 파라미터
import torch.nn as nn
import torch.nn.functional as F
class MLP(nn.Module):
def __init__(self, input_size=784, hidden_size=128, num_classes=10):
super(MLP, self).__init__()
self.fc1 = nn.Linear(input_size, hidden_size) # 784 -> 128
self.fc2 = nn.Linear(hidden_size, 64) # 128 -> 64
self.fc3 = nn.Linear(64, num_classes) # 64 -> 10
def forward(self, x):
# 입력 이미지를 1차원 벡터로 변환 (batch_size, 28*28)
x = x.view(x.size(0), -1)
# Hidden Layer 1 + ReLU
x = F.relu(self.fc1(x))
# Hidden Layer 2 + ReLU
x = F.relu(self.fc2(x))
# Output Layer (활성화 함수 없음)
x = self.fc3(x)
return x
model = MLP()
print("✅ MLP 모델 생성 완료")
print(model)
✅ MLP 모델 생성 완료 MLP( (fc1): Linear(in_features=784, out_features=128, bias=True) (fc2): Linear(in_features=128, out_features=64, bias=True) (fc3): Linear(in_features=64, out_features=10, bias=True) )
🎯 손실 함수 및 옵티마이저¶
📉 손실 함수: CrossEntropyLoss¶
교차 엔트로피 손실은 다중 클래스 분류에서 가장 많이 사용되는 손실 함수입니다.
🧮 수식¶
$$ \text{CrossEntropyLoss} = -\sum_{i=1}^{C} y_i \log(\hat{y}_i) $$
여기서:
- $C$: 클래스 개수 (MNIST는 10)
- $y_i$: 실제 레이블 (one-hot encoding)
- $\hat{y}_i$: 예측 확률 (softmax 적용 후)
📊 내부 동작¶
CrossEntropyLoss는 내부적으로 다음 두 단계를 수행합니다:
Softmax: 로짓(logit)을 확률로 변환 $$\text{Softmax}(z_i) = \frac{e^{z_i}}{\sum_{j=1}^{C} e^{z_j}}$$
Negative Log-Likelihood: 음의 로그 가능도 계산 $$\text{NLL}(y, \hat{y}) = -\log(\hat{y}_{\text{true class}})$$
💡 예시¶
정답이 "5"이고, 모델의 출력이 다음과 같다면:
Logits: [0.1, 0.3, 0.2, 0.1, 0.5, 2.1, 0.4, 0.2, 0.3, 0.1]
[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
- Softmax 적용 → 확률값으로 변환
- 정답 클래스(5)의 확률에 -log 적용
- Loss = -log(0.65) ≈ 0.43
⚡ 옵티마이저: Adam¶
Adam (Adaptive Moment Estimation)은 가장 인기있는 최적화 알고리즘입니다.
🧮 핵심 아이디어¶
Adam은 다음 두 가지를 결합합니다:
Momentum: 과거 gradient의 이동 평균 사용 $$m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t$$
RMSprop: Gradient의 제곱에 대한 이동 평균 사용 $$v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2$$
파라미터 업데이트: $$\theta_{t+1} = \theta_t - \frac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$
여기서:
- $\alpha$: 학습률 (learning rate) = 0.001
- $\beta_1$: momentum 계수 = 0.9 (기본값)
- $\beta_2$: RMSprop 계수 = 0.999 (기본값)
- $\epsilon$: 0으로 나누기 방지 = 1e-8
✅ Adam의 장점¶
- 적응형 학습률: 각 파라미터마다 다른 학습률 적용
- 빠른 수렴: Momentum과 RMSprop의 장점 결합
- 하이퍼파라미터 튜닝 불필요: 기본값이 대부분의 경우 잘 작동
5.4 손실 함수, 최적화 및 학습 루프¶
모델이 설계되었으니, 이제 '어떻게 공부할지'를 정해야 합니다.
1. 손실 함수 (Loss Function)¶
CrossEntropyLoss: 다중 클래스 분류 문제(0~9 숫자 맞추기)에서 가장 표준적으로 사용되는 함수입니다. 모델의 예측값과 실제 정답 사이의 오차를 계산합니다.
2. 최적화 도구 (Optimizer)¶
Adam: 학습률을 자동으로 조절하며 빠르게 수렴하는 효율적인 최적화 알고리즘입니다.
3. 학습 루프 (Training Loop)¶
model.train(): 모델을 학습 모드로 설정합니다.zero_grad(): 이전 단계의 기울기를 초기화합니다.backward(): 오차 역전파를 통해 기울기를 계산합니다.step(): 계산된 기울기를 바탕으로 가중치($W$)를 업데이트합니다.
import torch.optim as optim
# 1. 히스토리를 저장할 리스트 초기화
train_loss_history = []
train_acc_history = []
# 2. 손실 함수 및 최적화 설정
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
epochs = 30
model.to(device)
print(f"🚀 학습 시작 (기록 기능 활성화)")
for epoch in range(epochs):
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
# 순전파 및 가중치 업데이트
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# 통계 기록
running_loss += loss.item()
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
# 에포크 종료 후 평균값 저장
epoch_loss = running_loss / len(train_loader)
epoch_acc = 100 * correct / total
train_loss_history.append(epoch_loss)
train_acc_history.append(epoch_acc)
print(f"Epoch [{epoch+1}/{epochs}] - Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.2f}%")
print("✨ 모든 학습 및 기록 완료!")
🚀 학습 시작 (기록 기능 활성화) Epoch [1/30] - Loss: 0.2709, Acc: 92.03% Epoch [2/30] - Loss: 0.1137, Acc: 96.52% Epoch [3/30] - Loss: 0.0792, Acc: 97.54% Epoch [4/30] - Loss: 0.0609, Acc: 98.08% Epoch [5/30] - Loss: 0.0492, Acc: 98.43% Epoch [6/30] - Loss: 0.0413, Acc: 98.67% Epoch [7/30] - Loss: 0.0374, Acc: 98.80% Epoch [8/30] - Loss: 0.0291, Acc: 99.02% Epoch [9/30] - Loss: 0.0239, Acc: 99.22% Epoch [10/30] - Loss: 0.0226, Acc: 99.24% Epoch [11/30] - Loss: 0.0205, Acc: 99.34% Epoch [12/30] - Loss: 0.0188, Acc: 99.35% Epoch [13/30] - Loss: 0.0210, Acc: 99.33% Epoch [14/30] - Loss: 0.0162, Acc: 99.44% Epoch [15/30] - Loss: 0.0128, Acc: 99.57% Epoch [16/30] - Loss: 0.0157, Acc: 99.48% Epoch [17/30] - Loss: 0.0154, Acc: 99.47% Epoch [18/30] - Loss: 0.0148, Acc: 99.47% Epoch [19/30] - Loss: 0.0120, Acc: 99.59% Epoch [20/30] - Loss: 0.0103, Acc: 99.67% Epoch [21/30] - Loss: 0.0128, Acc: 99.57% Epoch [22/30] - Loss: 0.0123, Acc: 99.61% Epoch [23/30] - Loss: 0.0112, Acc: 99.63% Epoch [24/30] - Loss: 0.0108, Acc: 99.68% Epoch [25/30] - Loss: 0.0127, Acc: 99.60% Epoch [26/30] - Loss: 0.0101, Acc: 99.64% Epoch [27/30] - Loss: 0.0065, Acc: 99.78% Epoch [28/30] - Loss: 0.0143, Acc: 99.59% Epoch [29/30] - Loss: 0.0103, Acc: 99.69% Epoch [30/30] - Loss: 0.0074, Acc: 99.77% ✨ 모든 학습 및 기록 완료!
5.5 모델 평가 (Model Evaluation)¶
학습에 사용하지 않은 테스트 데이터(10,000개)를 사용하여 모델의 실제 성능을 측정합니다.
1. 평가 모드 (model.eval())¶
- 모델을 평가 모드로 전환합니다. 드롭아웃(Dropout)이나 배치 정규화(Batch Normalization) 같은 기능이 학습 때와 다르게 동작하도록 설정합니다.
2. 기울기 계산 비활성화 (torch.no_grad())¶
- 평가 시에는 가중치를 업데이트할 필요가 없으므로 기울기를 계산하지 않습니다. 이는 메모리를 절약하고 연산 속도를 높여줍니다.
3. 정확도(Accuracy) 계산¶
- 모델이 출력한 10개의 숫자 점수 중 가장 높은 값의 인덱스를 예측값으로 선택하고, 실제 정답과 비교합니다.
$$\text{Accuracy} = \frac{\text{Correct Predictions}}{\text{Total Predictions}} \times 100$$
# 1. 모델을 평가 모드로 전환
model.eval()
correct = 0
total = 0
# 2. 기울기 계산 비활성화 (메모리 절약 및 속도 향상)
with torch.no_grad():
for images, labels in test_loader:
# 데이터를 GPU로 전송
images, labels = images.to(device), labels.to(device)
# 순전파 연산
outputs = model(images)
# 가장 높은 점수를 받은 인덱스 선택 (예측값)
_, predicted = torch.max(outputs.data, 1)
# 전체 개수 및 맞은 개수 카운트
total += labels.size(0)
correct += (predicted == labels).sum().item()
# 3. 최종 정확도 출력
accuracy = 100 * correct / total
print(f"🎯 테스트 데이터 최종 정확도: {accuracy:.2f}%")
🎯 테스트 데이터 최종 정확도: 98.00%
import matplotlib.pyplot as plt
# 1. 테스트 데이터에서 샘플 추출 및 예측
model.eval()
images, labels = next(iter(test_loader))
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, preds = torch.max(outputs, 1)
# CPU로 다시 옮겨서 시각화 준비
images = images.cpu().numpy()
labels = labels.cpu().numpy()
preds = preds.cpu().numpy()
# 2. 결과 시각화 (너비 100% 스타일)
plt.figure(figsize=(15, 4)) # 가로로 길게 설정
for idx in range(10): # 앞의 10개만 출력
plt.subplot(1, 10, idx + 1)
plt.imshow(images[idx].squeeze(), cmap='gray')
# 정답 여부에 따라 타이틀 색상 변경 (맞으면 파랑, 틀리면 빨강)
color = 'blue' if preds[idx] == labels[idx] else 'red'
plt.title(f"P: {preds[idx]}\n(A: {labels[idx]})", color=color)
plt.axis('off')
plt.tight_layout()
plt.show()
import matplotlib.pyplot as plt
# 1. 가로로 넓은 캔버스 설정 (18x5 인치)
plt.figure(figsize=(18, 5))
# --- [왼쪽: Loss 그래프] ---
plt.subplot(1, 2, 1)
plt.plot(range(1, epochs + 1), train_loss_history, marker='o', color='royalblue', linewidth=2, label='Train Loss')
plt.title('Training Loss Trend', fontsize=15, pad=15)
plt.xlabel('Epoch', fontsize=12)
plt.ylabel('Loss Value', fontsize=12)
plt.xticks(range(1, epochs + 1))
plt.grid(True, linestyle='--', alpha=0.7)
plt.legend(fontsize=12)
# --- [오른쪽: Accuracy 그래프] ---
plt.subplot(1, 2, 2)
plt.plot(range(1, epochs + 1), train_acc_history, marker='s', color='darkorange', linewidth=2, label='Train Accuracy')
plt.title('Training Accuracy Trend', fontsize=15, pad=15)
plt.xlabel('Epoch', fontsize=12)
plt.ylabel('Accuracy (%)', fontsize=12)
plt.xticks(range(1, epochs + 1))
plt.grid(True, linestyle='--', alpha=0.7)
plt.legend(fontsize=12)
# 레이아웃 정렬 및 출력
plt.tight_layout()
plt.show()