Adventure Time - Finn 3
본문 바로가기
AI/ML

CNN으로 MNIST 손글씨 분류하기 (TensorFlow)

by hyun9_9 2026. 3. 29.

이번엔 Convolutional Neural Network(CNN)를 사용해 MNIST 손글씨 숫자를 분류하는 모델을 구현해보겠습니다.
CNN 구현의 전체 흐름은 아래 9단계로 정리할 수 있습니다.

1. 하이퍼파라미터 설정
2. 데이터 파이프라인 구성
3. 모델 구성
4. Loss 함수 정의
5. Gradient 계산
6. Optimizer 선택
7. 성능 지표(정확도) 정의
8. Checkpoint 저장 (선택)
9. 학습 및 검증

전체 코드

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.utils import to_categorical
import numpy as np
import os

# 1. 하이퍼파라미터 설정
learning_rate = 0.001
training_epochs = 15
batch_size = 100

# 2. 데이터 파이프라인
mnist = keras.datasets.mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_images = train_images.astype(np.float32) / 255.
test_images = test_images.astype(np.float32) / 255.

train_images = np.expand_dims(train_images, axis=-1)
test_images = np.expand_dims(test_images, axis=-1)

train_labels = to_categorical(train_labels, 10)
test_labels = to_categorical(test_labels, 10)

train_dataset = tf.data.Dataset.from_tensor_slices((train_images, train_labels)) \
    .shuffle(buffer_size=100000).batch(batch_size).prefetch(buffer_size=batch_size)
test_dataset = tf.data.Dataset.from_tensor_slices((test_images, test_labels)).batch(batch_size)

# 3. 모델 구성
def create_model():
    model = keras.Sequential()
    model.add(keras.layers.Conv2D(filters=32, kernel_size=3, activation=tf.nn.relu, padding='SAME', input_shape=(28, 28, 1)))
    model.add(keras.layers.MaxPool2D(padding='same'))
    model.add(keras.layers.Conv2D(filters=64, kernel_size=3, activation=tf.nn.relu, padding='SAME'))
    model.add(keras.layers.MaxPool2D(padding='same'))
    model.add(keras.layers.Conv2D(filters=128, kernel_size=3, activation=tf.nn.relu, padding='SAME'))
    model.add(keras.layers.MaxPool2D(padding='same'))
    model.add(keras.layers.Flatten())
    model.add(keras.layers.Dense(256, activation=tf.nn.relu))
    model.add(keras.layers.Dropout(0.4))
    model.add(keras.layers.Dense(10))
    return model

model = create_model()
model.summary()

# 4. Loss 함수
def loss_fn(model, images, labels):
    logits = model(images, training=True)
    loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=labels))
    return loss

# 5. Gradient 계산
def grad(model, images, labels):
    with tf.GradientTape() as tape:
        loss = loss_fn(model, images, labels)
    return tape.gradient(loss, model.trainable_variables)

# 6. Optimizer / 7. 정확도 정의
optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)

def evaluate(model, images, labels):
    logits = model(images, training=False)
    correct_prediction = tf.equal(tf.argmax(logits, 1), tf.argmax(labels, 1))
    accuracy = tf.reduce_mean(tf.cast(correct_prediction, dtype=tf.float32))
    return accuracy

# 9. 학습
for epoch in range(training_epochs):
    avg_loss = 0.
    avg_train_acc = 0.
    avg_test_acc = 0.
    train_step = 0
    test_step = 0

    for images, labels in train_dataset:
        grads = grad(model, images, labels)
        optimizer.apply_gradients(zip(grads, model.trainable_variables))
        loss = loss_fn(model, images, labels)
        acc = evaluate(model, images, labels)
        avg_loss += loss.numpy()
        avg_train_acc += acc.numpy()
        train_step += 1

    avg_loss /= train_step
    avg_train_acc /= train_step

    for images, labels in test_dataset:
        acc = evaluate(model, images, labels)
        avg_test_acc += acc
        test_step += 1

    avg_test_acc /= test_step
    print(f"Epoch: {epoch+1}, Loss: {avg_loss:.4f}, Train Acc: {avg_train_acc:.4f}, Test Acc: {avg_test_acc:.4f}")

1. 하이퍼파라미터 설정

learning_rate = 0.001
training_epochs = 15
batch_size = 100

모델 학습을 위한 설정값들입니다.

  • learning_rate : Gradient를 W에 얼마나 반영할지 결정하는 값
  • training_epochs : 전체 데이터를 몇 번 반복 학습할지
  • batch_size : 한 번에 네트워크에 넣는 데이터 수

2. 데이터 파이프라인

train_images = train_images.astype(np.float32) / 255.
train_images = np.expand_dims(train_images, axis=-1)  # [N,28,28] → [N,28,28,1]
train_labels = to_categorical(train_labels, 10)        # 7 → [0,0,0,0,0,0,0,1,0,0]
  • / 255. : 픽셀값 0~255를 0~1 사이로 정규화
  • expand_dims : CNN이 받는 shape [N, H, W, C] 에 맞게 채널 차원 추가 (흑백이라 채널=1)
  • to_categorical : 정답 레이블을 One-Hot Encoding으로 변환

3. 모델 구성 : CNN 레이어 쌓기

model.add(keras.layers.Conv2D(filters=32, kernel_size=3, activation=tf.nn.relu, padding='SAME', input_shape=(28,28,1)))
model.add(keras.layers.MaxPool2D(padding='same'))

각 레이어를 통과하면서 shape가 어떻게 변하는지 확인해보면:

입력          [N, 28, 28,  1]
Conv2D(32)  → [N, 28, 28, 32]   파라미터: (3×3×1 + 1) × 32 = 320
MaxPool2D   → [N, 14, 14, 32]   크기 절반으로 감소

Conv2D(64)  → [N, 14, 14, 64]   파라미터: (3×3×32 + 1) × 64 = 18,496
MaxPool2D   → [N,  7,  7, 64]   크기 절반으로 감소

Conv2D(128) → [N,  7,  7, 128]
MaxPool2D   → [N,  4,  4, 128]  (SAME 패딩으로 올림)

Flatten     → [N, 2048]
Dense(256)  → [N, 256]
Dropout(0.4)→ [N, 256]   40% 뉴런 OFF
Dense(10)   → [N, 10]    최종 출력 (숫자 0~9)

Conv2D 파라미터 계산:

(kernel_size × kernel_size × 입력채널 + 1(bias)) × filters
(3 × 3 × 1 + 1) × 32 = 320

필터 개수가 늘어날수록(32→64→128) 더 복잡한 특징을 추출합니다.
Max Pooling을 거칠 때마다 크기가 절반으로 줄어들며 중요한 특징만 남습니다.


4 & 5. Loss 함수와 Gradient

def loss_fn(model, images, labels):
    logits = model(images, training=True)   # training=True → Dropout 적용
    loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=labels))
    return loss

def grad(model, images, labels):
    with tf.GradientTape() as tape:
        loss = loss_fn(model, images, labels)
    return tape.gradient(loss, model.trainable_variables)
  • training=True : 학습 중이므로 Dropout 적용
  • softmax_cross_entropy_with_logits : Softmax + Cross-Entropy를 한 번에 계산
  • tape.gradient : Backpropagation으로 모든 W의 Gradient 계산

6 & 7. Optimizer와 정확도

optimizer = tf.keras.optimizers.Adam(learning_rate=0.001)

def evaluate(model, images, labels):
    logits = model(images, training=False)   # training=False → Dropout 미적용
    correct_prediction = tf.equal(tf.argmax(logits, 1), tf.argmax(labels, 1))
    accuracy = tf.reduce_mean(tf.cast(correct_prediction, dtype=tf.float32))
    return accuracy
  • Adam : 학습률을 자동으로 조절하는 옵티마이저로 실무에서 가장 많이 사용
  • training=False : 테스트 시에는 Dropout을 끄고 모든 뉴런 사용
  • tf.argmax(logits, 1) : 가장 높은 확률의 클래스 인덱스 반환

9. 학습 루프

for epoch in range(training_epochs):
    avg_loss = 0.
    avg_train_acc = 0.
    train_step = 0

    for images, labels in train_dataset:
        grads = grad(model, images, labels)              # Gradient 계산 (Backpropagation)
        optimizer.apply_gradients(zip(grads, model.trainable_variables))  # W 업데이트 (Gradient Descent)
        avg_loss += loss_fn(model, images, labels).numpy()
        avg_train_acc += evaluate(model, images, labels).numpy()
        train_step += 1

    avg_loss /= train_step          # 전체 스텝의 평균 Loss
    avg_train_acc /= train_step     # 전체 스텝의 평균 정확도

매 Epoch마다 Loss와 정확도를 누적해서 평균을 냅니다.
train_dataset을 한 번 다 돌면 1 Epoch가 완료됩니다.


전체 흐름 정리

MNIST 이미지 로드 (28×28, 흑백)
    ↓
정규화 (0~255 → 0~1) + One-Hot Encoding
    ↓
Conv2D(32) → ReLU → MaxPool  [28×28×32 → 14×14×32]
    ↓
Conv2D(64) → ReLU → MaxPool  [14×14×64 → 7×7×64]
    ↓
Conv2D(128) → ReLU → MaxPool [7×7×128 → 4×4×128]
    ↓
Flatten → Dense(256) → Dropout(0.4) → Dense(10)
    ↓
Softmax Cross-Entropy Loss
    ↓
GradientTape → Backpropagation
    ↓
Adam → Gradient Descent → W 업데이트
    ↓
15 Epoch 반복 → 높은 정확도 달성 ✅

정리

단계 개념 코드

데이터 정규화 + One-Hot + 채널 추가 / 255., expand_dims, to_categorical
Conv2D 필터로 특징 추출 keras.layers.Conv2D(filters, kernel_size)
MaxPool2D 크기 절반 축소 keras.layers.MaxPool2D(padding='same')
Dropout 40% 뉴런 OFF → 과적합 방지 keras.layers.Dropout(0.4)
training=True/False 학습/테스트 시 Dropout 제어 model(images, training=True/False)
Backpropagation Gradient 계산 tape.gradient(loss, model.trainable_variables)
Gradient Descent W 업데이트 optimizer.apply_gradients(...)

CNN은 Fully Connected Network보다 이미지 분류에서 훨씬 높은 성능을 보여줍니다. 🚀