Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

3 P1 실습 — MNIST 첫 신경망 프로젝트

Updated: 23 aug 2026

이번 프로젝트의 질문은 단순하다.

손글씨 숫자를 신경망으로 얼마나 잘 구분할 수 있을까?

하지만 목표는 정확도 경쟁이 아니다.

세 번의 실습을 통해

데이터 이해 → 첫 모델 → 훈련과 비교 → 오류 분석 → 최종 판단

의 전체 흐름을 경험한다.

Keras와 PyTorch로 같은 문제를 풀고, 두 프레임워크의 차이보다 공통된 딥러닝 원리를 확인한다.

3.1프로젝트 목표

프로젝트가 끝나면 다음을 할 수 있어야 한다.

  • MNIST 데이터의 입력과 타깃을 설명한다.

  • 이미지와 배치의 shape을 읽는다.

  • 간단한 MLP를 Keras와 PyTorch로 구성한다.

  • 훈련 손실과 정확도의 변화를 해석한다.

  • 테스트셋에서 최종 성능을 확인한다.

  • 잘못 분류된 이미지를 찾아 오류를 분석한다.

  • 두 구현의 공통점과 차이점을 설명한다.

코드 작성 자체보다 코드가 무엇을 하는지 설명하고 결과를 판단하는 것이 중요하다.

3.2준비

아래 셀을 실행해 필요한 라이브러리와 난수 시드를 준비한다.

import random
import numpy as np
import matplotlib.pyplot as plt

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader

SEED = 42

random.seed(SEED)
np.random.seed(SEED)
tf.random.set_seed(SEED)
torch.manual_seed(SEED)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("PyTorch device:", device)

3.3실습 1 — 데이터 이해와 첫 모델

3.3.11. MNIST 데이터 불러오기

먼저 Keras가 제공하는 MNIST 데이터를 불러온다.

from tensorflow.keras.datasets import mnist

(train_images_raw, train_labels), (test_images_raw, test_labels) = mnist.load_data()

3.3.22. 데이터 구조 확인

다음 값을 직접 확인하고 아래 질문에 답하라.

  • 훈련 이미지 수

  • 테스트 이미지 수

  • 이미지 한 장의 shape

  • 픽셀값의 최솟값과 최댓값

  • 타깃에 포함된 값

print("train_images:", train_images_raw.shape, train_images_raw.dtype)
print("train_labels:", train_labels.shape, train_labels.dtype)
print("test_images :", test_images_raw.shape, test_images_raw.dtype)

print("pixel range:", train_images_raw.min(), "~", train_images_raw.max())
print("labels:", np.unique(train_labels))

판단 1

  1. 이 문제의 입력은 무엇인가?

  2. 타깃은 수치형 값인가, 범주형 값인가?

  3. 회귀와 분류 중 어떤 문제인가?

  4. 마지막 출력층에는 몇 개의 출력이 필요할까?

아래에 자신의 답을 작성한다.

답:

3.3.33. 이미지 직접 보기

무작위로 몇 장을 확인한다.

fig, axes = plt.subplots(2, 5, figsize=(10, 4))

indices = np.random.choice(len(train_images_raw), size=10, replace=False)

for ax, idx in zip(axes.ravel(), indices):
    ax.imshow(train_images_raw[idx], cmap="gray")
    ax.set_title(f"label={train_labels[idx]}")
    ax.axis("off")

plt.tight_layout()
plt.show()

관찰 1

숫자마다 글씨체가 얼마나 다른가? 사람이 보아도 애매한 이미지가 있는가?

관찰:

3.3.44. 전처리

완전연결 신경망에 넣기 위해 각 이미지를 784개의 값으로 펼치고 픽셀값을 0~1 범위로 바꾼다.

train_images = train_images_raw.reshape((60000, 28 * 28)).astype("float32") / 255
test_images = test_images_raw.reshape((10000, 28 * 28)).astype("float32") / 255

print(train_images.shape)
print(test_images.shape)
print(train_images.min(), train_images.max())

판단 2

(60000, 784)에서 두 숫자는 각각 무엇을 의미하는가?

답:

3.3.55. Keras 기본 모델

기존 dlp2의 기본 구조를 사용한다.

keras_model = keras.Sequential([
    layers.Dense(512, activation="relu"),
    layers.Dense(10, activation="softmax")
])

keras_model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"]
)

keras_model.summary()

3.3.66. Keras 모델 훈련

실습에서는 훈련 과정의 변화를 보기 위해 검증 데이터를 함께 사용한다.

테스트셋은 최종 평가까지 사용하지 않는다.

history = keras_model.fit(
    train_images,
    train_labels,
    epochs=5,
    batch_size=128,
    validation_split=0.1,
    verbose=1
)

3.3.77. 학습 곡선 확인

history_dict = history.history

epochs = range(1, len(history_dict["loss"]) + 1)

plt.figure(figsize=(7, 4))
plt.plot(epochs, history_dict["loss"], marker="o", label="train loss")
plt.plot(epochs, history_dict["val_loss"], marker="o", label="validation loss")
plt.xlabel("epoch")
plt.ylabel("loss")
plt.legend()
plt.show()

plt.figure(figsize=(7, 4))
plt.plot(epochs, history_dict["accuracy"], marker="o", label="train accuracy")
plt.plot(epochs, history_dict["val_accuracy"], marker="o", label="validation accuracy")
plt.xlabel("epoch")
plt.ylabel("accuracy")
plt.legend()
plt.show()

판단 3

  • 에포크가 증가하면서 훈련 손실은 어떻게 변하는가?

  • 검증 손실도 같은 방식으로 변하는가?

  • 현재 결과만으로 과대적합이 나타난다고 볼 수 있는가?

답:

3.4실습 2 — PyTorch로 같은 훈련 과정 확인

Keras에서는 fit()이 훈련 루프를 처리했다.

이번에는 같은 데이터를 PyTorch 텐서와 DataLoader로 만들고, 훈련 루프를 직접 확인한다.

x_train_t = torch.tensor(train_images, dtype=torch.float32)
y_train_t = torch.tensor(train_labels, dtype=torch.long)

x_test_t = torch.tensor(test_images, dtype=torch.float32)
y_test_t = torch.tensor(test_labels, dtype=torch.long)

# Keras의 validation_split=0.1과 비슷하게 뒤 6,000개를 검증셋으로 사용
x_val_t = x_train_t[-6000:]
y_val_t = y_train_t[-6000:]
x_train_sub_t = x_train_t[:-6000]
y_train_sub_t = y_train_t[:-6000]

train_ds = TensorDataset(x_train_sub_t, y_train_sub_t)
val_ds = TensorDataset(x_val_t, y_val_t)
test_ds = TensorDataset(x_test_t, y_test_t)

train_loader = DataLoader(train_ds, batch_size=128, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=256, shuffle=False)

xb, yb = next(iter(train_loader))
print("input batch :", xb.shape)
print("target batch:", yb.shape)

판단 4

배치 하나의 입력 shape이 (128, 784)라면 128784는 각각 무엇을 의미하는가?

답:

3.4.1PyTorch 모델 구성

Keras와 동일하게 784 → 512 → 10 구조를 사용한다.

마지막에 Softmax를 넣지 않는다. CrossEntropyLoss가 모델의 원시 출력인 logits를 직접 사용하기 때문이다.

torch_model = nn.Sequential(
    nn.Linear(28 * 28, 512),
    nn.ReLU(),
    nn.Linear(512, 10)
).to(device)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.RMSprop(torch_model.parameters())

print(torch_model)

3.4.2훈련 루프

각 코드가 어떤 단계인지 확인하면서 실행한다.

순전파 → 손실 → 역전파 → 파라미터 갱신

def evaluate_torch(model, loader, loss_fn, device):
    model.eval()
    total_loss = 0.0
    total_correct = 0
    total_count = 0

    with torch.no_grad():
        for x_batch, y_batch in loader:
            x_batch = x_batch.to(device)
            y_batch = y_batch.to(device)

            logits = model(x_batch)
            loss = loss_fn(logits, y_batch)

            total_loss += loss.item() * len(x_batch)
            total_correct += (logits.argmax(dim=1) == y_batch).sum().item()
            total_count += len(x_batch)

    return total_loss / total_count, total_correct / total_count


torch_history = {
    "loss": [],
    "accuracy": [],
    "val_loss": [],
    "val_accuracy": []
}

for epoch in range(5):
    torch_model.train()

    running_loss = 0.0
    running_correct = 0
    running_count = 0

    for x_batch, y_batch in train_loader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)

        # 1. 이전 그레이디언트 초기화
        optimizer.zero_grad()

        # 2. 순전파
        logits = torch_model(x_batch)

        # 3. 손실 계산
        loss = loss_fn(logits, y_batch)

        # 4. 역전파: 그레이디언트 계산
        loss.backward()

        # 5. 파라미터 갱신
        optimizer.step()

        running_loss += loss.item() * len(x_batch)
        running_correct += (logits.argmax(dim=1) == y_batch).sum().item()
        running_count += len(x_batch)

    train_loss = running_loss / running_count
    train_acc = running_correct / running_count
    val_loss, val_acc = evaluate_torch(torch_model, val_loader, loss_fn, device)

    torch_history["loss"].append(train_loss)
    torch_history["accuracy"].append(train_acc)
    torch_history["val_loss"].append(val_loss)
    torch_history["val_accuracy"].append(val_acc)

    print(
        f"epoch {epoch+1}: "
        f"loss={train_loss:.4f}, acc={train_acc:.4f}, "
        f"val_loss={val_loss:.4f}, val_acc={val_acc:.4f}"
    )

3.4.3PyTorch 학습 곡선

epochs = range(1, len(torch_history["loss"]) + 1)

plt.figure(figsize=(7, 4))
plt.plot(epochs, torch_history["loss"], marker="o", label="train loss")
plt.plot(epochs, torch_history["val_loss"], marker="o", label="validation loss")
plt.xlabel("epoch")
plt.ylabel("loss")
plt.legend()
plt.show()

plt.figure(figsize=(7, 4))
plt.plot(epochs, torch_history["accuracy"], marker="o", label="train accuracy")
plt.plot(epochs, torch_history["val_accuracy"], marker="o", label="validation accuracy")
plt.xlabel("epoch")
plt.ylabel("accuracy")
plt.legend()
plt.show()

판단 5

PyTorch 훈련 루프에서 다음 코드의 역할을 한 문장씩 설명하라.

  • optimizer.zero_grad()

  • logits = torch_model(x_batch)

  • loss = loss_fn(logits, y_batch)

  • loss.backward()

  • optimizer.step()

답:

3.4.4Keras와 PyTorch 결과 비교

두 프레임워크의 결과가 완전히 같을 필요는 없다.

초기 파라미터, 데이터가 섞이는 순서, 구현 세부사항 등이 다르기 때문이다.

비교할 것은 다음이다.

  • 훈련이 진행되면서 손실이 감소하는가?

  • 정확도가 증가하는가?

  • 훈련과 검증 성능의 관계는 비슷한가?

  • 두 코드에서 순전파·손실·파라미터 갱신이라는 공통 구조를 찾을 수 있는가?

비교 결과:

3.5실습 3 — 최종 평가와 오류 분석

이제 마지막으로 테스트셋을 사용한다.

테스트셋은 모델을 선택하거나 반복적으로 수정하기 위한 데이터가 아니라 최종 모델의 일반화 성능을 확인하기 위한 데이터로 사용한다.

3.5.1Keras 테스트 성능

keras_test_loss, keras_test_acc = keras_model.evaluate(
    test_images, test_labels, verbose=0
)

print("Keras test loss    :", keras_test_loss)
print("Keras test accuracy:", keras_test_acc)

3.5.2PyTorch 테스트 성능

torch_test_loss, torch_test_acc = evaluate_torch(
    torch_model, test_loader, loss_fn, device
)

print("PyTorch test loss    :", torch_test_loss)
print("PyTorch test accuracy:", torch_test_acc)

3.5.3잘못 분류된 이미지 찾기

먼저 Keras 모델의 예측을 이용해 오류를 확인한다.

keras_probs = keras_model.predict(test_images, verbose=0)
keras_pred = keras_probs.argmax(axis=1)

wrong_idx = np.where(keras_pred != test_labels)[0]

print("오분류 수:", len(wrong_idx))
print("전체 테스트 이미지 수:", len(test_labels))
n_show = min(12, len(wrong_idx))
chosen = wrong_idx[:n_show]

fig, axes = plt.subplots(3, 4, figsize=(9, 7))

for ax, idx in zip(axes.ravel(), chosen):
    ax.imshow(test_images_raw[idx], cmap="gray")
    ax.set_title(
        f"true={test_labels[idx]}, pred={keras_pred[idx]}"
    )
    ax.axis("off")

plt.tight_layout()
plt.show()

오류 분석

오분류 이미지들을 보고 다음을 작성하라.

  1. 사람이 보아도 애매한 이미지가 있는가?

  2. 특정 숫자 쌍에서 혼동이 자주 보이는가?

  3. 글씨가 기울었거나 일부 획이 약한 경우가 있는가?

  4. 단순히 테스트 정확도만 볼 때는 알 수 없었던 사실은 무엇인가?

분석:

3.5.4선택 과제 — 어떤 숫자를 어떤 숫자로 혼동했는가?

아래 코드를 완성하거나 생성형 AI의 도움을 받아 오분류 빈도표를 만들어보자.

예를 들어 실제 숫자 49로 잘못 예측한 횟수를 확인할 수 있어야 한다.

AI의 도움을 받았다면 생성된 코드가 무엇을 세는지 직접 설명할 수 있어야 한다.

# TODO:
# 1. 실제 라벨과 예측 라벨을 이용한다.
# 2. 같은 조합이 몇 번 나타나는지 센다.
# 3. 가장 자주 발생한 오분류 조합을 찾아본다.

# 여기에 코드를 작성하세요.

3.6작은 실험 — 은닉층 크기를 바꾸면?

기본 모델은 은닉층에 512개의 유닛을 사용했다.

다음 중 하나를 선택하여 다시 훈련해보자.

  • 32

  • 128

  • 256

  • 1024

비교할 것은 단순히 테스트 정확도 하나가 아니다.

  • 파라미터 수

  • 훈련 시간

  • 훈련 정확도

  • 검증 정확도

  • 테스트 정확도

를 함께 본다.

# TODO: units 값을 바꾸어 실험한다.
units = 128

experiment_model = keras.Sequential([
    layers.Dense(units, activation="relu"),
    layers.Dense(10, activation="softmax")
])

experiment_model.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"]
)

experiment_model.summary()

experiment_history = experiment_model.fit(
    train_images,
    train_labels,
    epochs=5,
    batch_size=128,
    validation_split=0.1,
    verbose=1
)

판단 6

은닉층을 크게 만들면 항상 더 좋은 모델이 되는가?

실험 결과를 근거로 답한다.

답:

3.7생성형 AI 활용 점검

이번 프로젝트에서 생성형 AI를 사용했다면 아래를 기록한다.

3.7.1AI를 사용한 작업

  • 코드 설명

  • 오류 수정

  • PyTorch/Keras 코드 변환

  • 그래프 작성

  • 오류 분석 아이디어

  • 기타

3.7.2AI 결과를 어떻게 검증했는가?

다음 중 최소 두 가지를 구체적으로 작성한다.

  • 데이터 shape을 확인했다.

  • 입력과 타깃이 맞게 연결되었는지 확인했다.

  • 손실함수가 문제 유형에 적절한지 확인했다.

  • 테스트셋을 모델 선택에 반복 사용하지 않았는지 확인했다.

  • 생성된 코드의 각 단계가 무엇을 하는지 설명해 보았다.

  • 결과가 비정상적으로 높거나 낮지 않은지 확인했다.

검증 기록:

3.8최종 제출 내용

길게 작성할 필요는 없다. 다음 내용을 중심으로 정리한다.

3.8.11. 문제

MNIST 프로젝트에서 무엇을 예측했는가?

3.8.22. 데이터

입력과 타깃, 한 샘플의 shape을 설명한다.

3.8.33. 모델

사용한 MLP 구조를 설명한다.

3.8.44. 훈련

손실함수와 옵티마이저의 역할을 설명한다.

3.8.55. 결과

Keras 또는 PyTorch 모델의 최종 테스트 성능을 제시한다.

3.8.66. 오류 분석

대표적인 오분류 사례 2~3개를 설명한다.

3.8.77. 최종 판단

모델이 잘 작동한다고 판단하는가? 어떤 한계가 있는가?

3.8.88. Keras와 PyTorch

두 구현에서 가장 중요한 공통점 하나와 차이점 하나를 설명한다.

3.8.99. AI 활용

AI를 어디에 사용했고 무엇을 검증했는지 기록한다.

3.9P1에서 남겨야 할 한 문장

프로젝트를 마친 뒤 다음 문장을 자신의 말로 설명할 수 있어야 한다.

모델 훈련은 훈련 데이터를 이용하여 손실이 작아지도록 모델의 파라미터를 반복적으로 결정하는 과정이다.

그리고 Keras의 fit()이나 PyTorch의 training loop를 보았을 때,

순전파 → 손실 계산 → 역전파 → 파라미터 갱신

의 흐름을 찾을 수 있어야 한다.