LLM 엔지니어링2장 · 신경망과 딥러닝의 기본 원리21 / 23← 이전목차다음 →MetaContents

2장. 신경망과 딥러닝의 기본 원리

프로젝트 1 — 손글씨 숫자 분류하기

우편물의 손글씨 우편번호를 자동으로 읽는다고 해 봅시다. 8×8 칸짜리 흑백 손글씨 숫자 그림 1,797장을 0부터 9까지의 숫자로 분류하는 신경망을 만들어 보세요. 데이터는 scikit-learn 에 들어 있는 load_digits 로, 실제 사람이 쓴 손글씨를 모은 공개 자료입니다. 인터넷 없이 바로 불러올 수 있습니다.

항목내용
그림8×8 칸. 칸마다 0(흰색) ~ 16(검은색)의 진하기
특성칸 64개를 한 줄로 편 값
레이블0 ~ 9 의 숫자
건수1,797장

요구사항은 다섯 가지입니다.

  1. 데이터를 불러와 그림 몇 장을 그려 봅니다.
  2. 진하기를 0~1 로 맞추고, 학습용 80%, 시험용 20% 로 나눕니다. 숫자마다의 비율을 맞춰 나눕니다.
  3. 은닉층 하나(뉴런 64개)짜리 신경망을 만들어 학습합니다. 출력은 숫자 10개입니다.
  4. 시험용 정확도와 혼동행렬을 구합니다.
  5. 틀린 그림을 몇 장 그려 보고, 왜 헷갈렸을지 한두 문장으로 적습니다.

단계별 힌트입니다.

  1. from sklearn.datasets import load_digits 로 불러오고, digits.data 가 특성(1797, 64), digits.target 이 레이블, digits.images 가 그림용 (1797, 8, 8) 입니다.
  2. 진하기의 최댓값이 16 이므로 16 으로 나누면 0~1 이 됩니다. 나눌 때 stratify=y 를 줍니다.
  3. 분류이므로 손실은 nn.CrossEntropyLoss() 이고, 출력층은 nn.Linear(64, 10) 에 활성화 함수 없이 둡니다. 레이블은 dtype=torch.long(정수) 텐서로 만듭니다.
  4. 예측한 숫자는 출력 10개 가운데 가장 큰 칸의 번호입니다. model(X).argmax(dim=1) 로 구합니다.
  5. 혼동행렬은 1장의 ConfusionMatrixDisplay.from_predictions 를 그대로 씁니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

같은 방법을 더 큰 손글씨 자료로 넓혀 볼 수 있습니다. 내려받기 전에 자료의 이용 조건을 확인하세요.

자료구하는 곳내용
MNISTtorchvision.datasets.MNIST 로 내려받기28×28 손글씨 숫자 7만 장. 특성이 784개가 된다
Fashion-MNISTtorchvision.datasets.FashionMNIST옷, 신발, 가방 같은 상품 그림 7만 장. 10가지로 분류
AI Hub 의 한글 손글씨 데이터AI Hub 누리집(회원 가입 후 신청)한글 손글씨 그림. 신청과 승인이 필요하다

MNIST 를 내려받는 모양은 아래와 같습니다. 이 PC 에서 실행하지 않은 안내 코드이고, torchvision 을 따로 설치해야 합니다. 그림이 28×28 이므로 입력층을 nn.Linear(784, 64) 로 바꾸면 아래 풀이를 거의 그대로 쓸 수 있습니다.

출력
from torchvision import datasets, transforms
train = datasets.MNIST("mnist", train=True, download=True, transform=transforms.ToTensor())

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

불러오기, 나누기, 학습, 채점, 틀린 그림의 다섯 상자를 화살표로 이은 손글씨 숫자 분류 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 다만 마지막 상자는 5~6단계를 묶었습니다. 1단계(청록)에서 load_digits() 로 그림을 불러와 10장을 그려 보고, 2단계(마젠타)에서 진하기를 16 으로 나눠 0~1 로 맞춘 뒤 stratify=y 로 나눕니다. 3단계(다홍)는 nn.Sequential 로 만든 신경망을 CrossEntropyLoss 로 30 에포크 학습하고, 4단계(빨강)는 argmax 로 고른 숫자로 정확도와 혼동행렬을 구합니다. 5~6단계(짙은 청록)는 np.where 로 틀린 그림을 골라 그리고, 숫자별 정확도와 확신 정도를 봅니다.

결과 보기 (예시 풀이)

1단계. 불러와서 그려 보기

Python
import numpy as np
import torch
from torch import nn
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits

plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False

digits = load_digits()
print(digits.data.shape, digits.target.shape)

# 앞의 그림 10장을 2줄 5칸으로 그린다. cmap="gray_r" 은 0 을 흰색, 큰 값을 검은색으로
fig, axes = plt.subplots(2, 5, figsize=(8, 3.6))
for ax, img, label in zip(axes.ravel(), digits.images, digits.target):
    ax.imshow(img, cmap="gray_r")
    ax.set_title(f"정답 {label}")
    ax.axis("off")
plt.tight_layout()
plt.show()
출력
(1797, 64) (1797,)
load_digits 의 손글씨 숫자 그림 열 장

이 단계의 함수입니다.

코드쉬운 뜻
load_digits()scikit-learn 에 들어 있는 손글씨 숫자 자료를 불러온다. 인터넷 없이 바로 된다
digits.data그림마다 8×8 칸 64개를 한 줄로 편 표. 모양은 (1797, 64) 다
digits.target그림마다의 정답 숫자(0~9). 모양은 (1797,) 다
digits.images같은 그림을 8×8 모양 그대로 둔 것. 그림을 그릴 때 쓴다
plt.subplots(2, 5, figsize=(8, 3.6))2줄 5칸, 그래프 칸 10개를 만든다
axes.ravel()2줄 5칸의 칸 묶음을 한 줄 10개로 편다. zip 으로 그림과 하나씩 짝짓기 위해서다
ax.imshow(img, cmap="gray_r")숫자 표를 그림으로 그린다. gray_r 은 회색조를 뒤집어 0 은 흰색, 큰 값은 검은색이다
ax.axis("off")칸의 눈금과 테두리를 없앤다

2단계. 맞추고 나누기

Python
from sklearn.model_selection import train_test_split

X = digits.data / 16.0          # 진하기 0~16 을 0~1 로
y = digits.target

# 숫자마다의 비율을 맞춰 학습용 80%, 시험용 20% 로 나눈다
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

# 특성은 float32, 분류 레이블은 정수(long) 텐서로
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_test_t = torch.tensor(X_test, dtype=torch.float32)
print(X_train_t.shape, y_train_t.shape, X_test_t.shape)
출력
torch.Size([1437, 64]) torch.Size([1437]) torch.Size([360, 64])

이 단계의 함수입니다.

코드쉬운 뜻
digits.data / 16.0칸의 진하기 0~16 을 0~1 로 줄인다. 2장 실습 3의 StandardScaler 처럼 숫자의 크기를 고르게 맞추는 일이다
train_test_split(..., stratify=y)학습용과 시험용에 0~9 가 같은 비율로 들어가게 나눈다
torch.tensor(X, dtype=torch.float32)그림의 칸 값을 소수 텐서로 바꾼다
torch.tensor(y, dtype=torch.long)정답을 정수 텐서로 바꾼다. 분류 손실 함수는 정답을 "몇 번 칸이 정답인가"라는 정수로 받는다

3단계. 분류 신경망 학습하기

Python
from torch.utils.data import TensorDataset, DataLoader

torch.manual_seed(42)
model = nn.Sequential(
    nn.Linear(64, 64),   # 칸 64개 → 은닉층 뉴런 64개
    nn.ReLU(),
    nn.Linear(64, 10),   # → 숫자 10개의 점수. 활성화 없음(손실 함수가 확률로 바꿔 준다)
)
loss_fn = nn.CrossEntropyLoss()     # 분류 손실
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loader = DataLoader(TensorDataset(X_train_t, y_train_t), batch_size=64, shuffle=True,
                    generator=torch.Generator().manual_seed(42))

for epoch in range(1, 31):
    for xb, yb in loader:
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        opt.step()
    if epoch in (1, 10, 30):
        print(f"에포크 {epoch:2d}  마지막 배치 손실 {loss.item():.4f}")
출력
에포크  1  마지막 배치 손실 0.5397
에포크 10  마지막 배치 손실 0.0274
에포크 30  마지막 배치 손실 0.0050

이 단계의 함수입니다.

코드쉬운 뜻
nn.Linear(64, 64)칸 64개를 받아 은닉층 뉴런 64개의 가중합을 낸다
nn.Linear(64, 10)숫자 0~9 의 점수 10개를 낸다. 점수가 클수록 그 숫자일 가능성이 크다
nn.CrossEntropyLoss()분류용 손실 함수(교차 엔트로피). 점수 10개를 안에서 확률로 바꾼 뒤, 정답 칸의 확률이 낮을수록 손실을 크게 낸다
loss_fn(model(xb), yb)점수 (64, 10) 와 정답 번호 (64,) 를 넣는다. 정답은 확률 표가 아니라 번호 하나로 준다
batch_size=6464장씩 묶어 고친다. 1437장이면 에포크마다 23번 고친다
loss.item()그 에포크의 마지막 배치 손실을 숫자로 꺼낸다

나머지 줄(manual_seed, Adam, DataLoader, 다섯 줄 학습 루프)은 2장 실습 4와 같습니다.

4단계. 정확도와 혼동행렬

Python
from sklearn.metrics import accuracy_score, ConfusionMatrixDisplay

with torch.no_grad():
    # 출력 10칸 가운데 가장 큰 칸의 번호가 예측한 숫자다
    pred = model(X_test_t).argmax(dim=1).numpy()
print("시험용 정확도:", round(accuracy_score(y_test, pred), 3))

ConfusionMatrixDisplay.from_predictions(y_test, pred)
plt.title("손글씨 숫자 혼동행렬")
plt.show()
출력
시험용 정확도: 0.969
시험용 손글씨 숫자 분류의 혼동행렬

이 단계의 함수입니다.

코드쉬운 뜻
with torch.no_grad():평가할 때는 기울기를 기록하지 않는다
model(X_test_t)시험용 360장의 점수를 낸다. 모양은 (360, 10) 이다
.argmax(dim=1)줄마다 가장 큰 점수가 있는 칸의 번호를 고른다. 그 번호가 예측한 숫자다. dim=1 은 "가로(칸 10개) 방향으로 견준다"는 뜻이다
.numpy()예측 텐서를 NumPy 배열로 바꿔 scikit-learn 함수에 넣는다
accuracy_score(실제, 예측)맞힌 비율(정확도)
ConfusionMatrixDisplay.from_predictions(실제, 예측)혼동행렬을 그림으로 그린다. 1장에서 본 것과 같고, 칸이 10×10 으로 커졌을 뿐이다

혼동행렬의 대각선(왼쪽 위에서 오른쪽 아래)이 맞힌 수이고, 대각선 밖의 칸이 "정답 몇을 몇으로 읽었나"입니다.

5단계. 틀린 그림 보기

Python
wrong = np.where(pred != y_test)[0]      # 틀린 그림의 위치(번호)
print("틀린 그림:", len(wrong), "장")

# 틀린 그림을 최대 8장 그린다. 제목에 정답과 예측을 적는다
fig, axes = plt.subplots(1, 8, figsize=(10, 1.9))
for ax, i in zip(axes, wrong[:8]):
    ax.imshow(X_test[i].reshape(8, 8), cmap="gray_r")
    ax.set_title(f"정답{y_test[i]}→{pred[i]}", fontsize=10)
    ax.axis("off")
for ax in axes[len(wrong):]:
    ax.axis("off")
plt.tight_layout()
plt.show()
출력
틀린 그림: 11 장
신경망이 틀린 손글씨 숫자 그림들과 정답, 예측

이 단계의 함수입니다.

코드쉬운 뜻
pred != y_test예측과 정답이 다른 자리만 True 인 배열을 만든다
np.where(...)[0]True 인 자리의 번호들을 꺼낸다. 틀린 그림의 위치 목록이다
wrong[:8]앞에서부터 최대 8개만 쓴다
X_test[i].reshape(8, 8)한 줄로 펴 둔 칸 64개를 다시 8×8 그림 모양으로 접는다
ax.set_title(..., fontsize=10)칸 위에 "정답→예측"을 작은 글씨로 적는다
for ax in axes[len(wrong):]: ax.axis("off")틀린 그림이 8장보다 적을 때 남는 빈 칸의 테두리를 지운다

6단계. 숫자별 정확도와 확신 정도 보기

Python
from sklearn.metrics import confusion_matrix

with torch.no_grad():
    # 학습용도 예측해 본다. 학습용과 시험용의 차이로 과적합을 가늠한다
    train_pred = model(X_train_t).argmax(dim=1).numpy()
    # softmax 로 점수 10개를 확률로 바꾸고, 고른 숫자의 확률(가장 큰 값)만 남긴다
    prob = torch.softmax(model(X_test_t), dim=1).max(dim=1).values.numpy()
print("학습용 정확도:", round(accuracy_score(y_train, train_pred), 3))

# 혼동행렬의 대각선(맞힌 수) ÷ 줄의 합(그 숫자의 그림 수) = 숫자별 맞힌 비율
cm = confusion_matrix(y_test, pred)
print("숫자별 맞힌 비율(0~9):", (cm.diagonal() / cm.sum(axis=1)).round(3))

right = pred == y_test
print("고른 숫자의 확률 평균  맞힌 그림:", round(prob[right].mean(), 3), " 틀린 그림:", round(prob[~right].mean(), 3))
print("0.9 넘게 확신하고 틀린 그림:", (prob[~right] > 0.9).sum(), "장")
출력
학습용 정확도: 1.0
숫자별 맞힌 비율(0~9): [0.972 0.944 1.    1.    0.972 1.    0.972 1.    0.886 0.944]
고른 숫자의 확률 평균  맞힌 그림: 0.986  틀린 그림: 0.747
0.9 넘게 확신하고 틀린 그림: 4 장

이 단계의 함수입니다.

코드쉬운 뜻
torch.softmax(점수, dim=1)줄마다 점수 10개를 더해서 1 이 되는 확률로 바꾼다
.max(dim=1).values줄마다 가장 큰 확률, 곧 모델이 고른 숫자에 준 확률만 꺼낸다
confusion_matrix(실제, 예측)혼동행렬을 그림이 아닌 숫자 표(10×10 배열)로 돌려준다
cm.diagonal()대각선 칸, 곧 숫자마다 맞힌 그림 수
cm.sum(axis=1)줄마다의 합, 곧 숫자마다 시험용 그림 수
prob[right], prob[~right]맞힌 그림과 틀린 그림의 확률을 나눠 꺼낸다. ~ 는 True 와 False 를 뒤집는다

30 에포크 학습 뒤 시험용 정확도는 0.969 로, 360장 가운데 11장을 틀렸습니다. 혼동행렬에서 가장 많은 실수는 8 을 1 로 읽은 4장입니다. 틀린 그림을 보면 8 의 가운데가 좁고 길게 쓰여 세로 막대처럼 보이고, 9 는 위쪽 고리가 열려 4 처럼, 1 은 윗부분이 꺾여 9 처럼 보입니다. 사람도 8×8 칸만 보고는 헷갈릴 만한 그림들입니다. 이 신경망은 칸 64개를 한 줄로 펴서 보기 때문에 "위쪽에 고리가 있다" 같은 모양의 위치 관계를 직접 쓰지 못합니다. 그림의 모양을 잘 보는 신경망(합성곱 신경망)이나, 더 큰 MNIST 로 넓혀 보면 이런 실수가 줄어듭니다.

결과를 어떻게 읽나

찍기와 견줍니다. 숫자가 10종류이고 시험용 360장에 숫자마다 35~37장이 고르게 들어 있어서, 아무렇게나 찍거나 가장 흔한 숫자 하나만 말해도 10%쯤 맞습니다. 신경망은 96.9% 로, 시험지 33장마다 1장꼴로 틀립니다.

숫자마다 실력이 다릅니다. 6단계의 숫자별 맞힌 비율을 보면 2, 3, 5, 7 은 한 장도 틀리지 않았고, 8 이 88.6%(35장 가운데 31장)로 가장 약합니다. 틀린 8 네 장은 모두 1 로 읽었고, 거꾸로 1 을 8 로 읽은 것은 한 장뿐입니다. 전체 정확도 하나만 보면 이런 약점이 숨으므로 혼동행렬과 숫자별 비율을 함께 봅니다.

학습용은 다 외웠고, 시험용과의 차이는 작습니다. 학습용 정확도는 1.0, 시험용은 0.969 입니다. 차이가 3%p 남짓이라 과적합이 심하지는 않지만, 학습용을 이미 다 맞히고 있어 에포크를 늘려도 시험 점수가 오를 여지는 적습니다.

틀릴 때는 덜 확신합니다. 모델이 고른 숫자에 준 확률은 맞힌 그림에서 평균 0.986, 틀린 그림에서 0.747 입니다. 확률이 낮은 그림만 사람에게 넘겨 확인하게 하면 실수를 꽤 거를 수 있습니다. 다만 틀린 11장 가운데 4장은 0.9 넘게 확신하고 틀렸으므로 확률만 믿을 수는 없습니다.

조심할 점까닭
시험지가 작다360장이라 한 장만 더 틀려도 정확도가 0.3%p 쯤 바뀐다. 나누는 씨앗을 바꾸면 숫자가 달라진다
그림이 거칠다8×8 칸, 점 64개뿐이라 사람에게도 헷갈리는 그림이 섞여 있다
모양의 위치를 직접 보지 않는다칸 64개를 한 줄로 펴서 보므로 "위에 고리가 있다" 같은 위치 관계를 바로 쓰지 못한다

정리하면, 층 두 개짜리 작은 신경망도 손글씨 숫자를 97% 맞힐 만큼 쓸모가 있고, 남은 실수는 거친 그림과 모양을 보지 못하는 구조에서 옵니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 우체국 신입 직원에게 손글씨 숫자 읽기를 가르치는 일과 같습니다. 연습 카드를 꺼내 몇 장 보고(1단계), 진하기를 고르게 맞춰 연습용과 시험용으로 나누고(2단계), 카드를 보고 0~9 가운데 하나를 고르도록 가르치고(3단계), 시험을 본 뒤 무엇을 무엇으로 읽었는지 표로 세고(4단계), 틀린 카드를 직접 보고(5단계), 숫자마다의 실력과 얼마나 확신했는지를 봅니다(6단계). 여기서 연습 카드가 load_digits 의 그림, 직원이 분류 신경망, "무엇을 무엇으로 읽었나" 표가 혼동행렬입니다. 숫자를 읽는 자세한 방법은 바로 위 "결과를 어떻게 읽나"에 있으므로 여기서는 출력의 뜻만 짧게 짚습니다.

단계출력뜻
1. 불러오기(1797, 64) (1797,)그림 1,797장이 칸 64개씩의 숫자로 들어 있고, 정답도 1,797개입니다
2. 나누기[1437, 64], [360, 64]학습용 1,437장, 시험용 360장으로 나눴습니다
3. 학습손실 0.5397 → 0.0274 → 0.005030 에포크 동안 마지막 배치의 손실이 꾸준히 줄었습니다
4. 정확도0.969시험용 360장 가운데 349장을 맞혔습니다
5. 틀린 그림11 장틀린 그림은 가운데가 좁은 8, 고리가 열린 9 처럼 사람도 헷갈릴 만한 그림이었습니다
6. 학습용 정확도1.0연습 카드는 모두 맞힙니다. 시험과의 차이는 3%p 남짓입니다
6. 숫자별8 이 0.8862, 3, 5, 7 은 다 맞혔고 8 이 가장 약합니다
6. 확신 정도맞힘 0.986, 틀림 0.747, 0.9 넘게 틀린 4장틀릴 때는 대체로 덜 확신하지만, 크게 확신하고 틀린 그림도 있습니다

한 줄로 줄이면, 실습에서 배운 학습 루프를 분류 문제로 바꿔 손글씨 숫자를 97%쯤 맞힌 프로젝트입니다. 360장으로 한 번 나눈 결과라 한 장만 달라져도 정확도가 0.3%p 쯤 바뀌고, 8×8 칸의 거친 그림이라 더 큰 실제 손글씨에서 같은 점수가 나온다는 뜻은 아닙니다.

← 이전 절2장 · 실습 6 — 기준선, 직선 모델과 견주기다음 절 →2장 · 정리