실습 5-2 · 콘볼루션 층으로 옷 사진을 분류하기

Open In Colab

Open In Colab

⏱ 60분 · 5주차 실습 2/2 — (5-1) 사진을 (N, C, H, W) 텐서로 만들고 콘볼루션을 직접 계산하기 → 콘볼루션 층으로 옷 사진을 분류하기

🎯 이번 시간의 질문

5-1에서 콘볼루션이 어떤 계산인지 확인했습니다. 이제 그 계산을 층으로 써서 학습시킵니다.

콘볼루션 층은 파라미터가 몇 개이고, 그 층 하나로 옷 사진을 얼마나 맞힐까요?

출력 크기 공식과 파라미터 수를 nn.Conv2d로 검산하고, 콘볼루션 층 하나짜리 신경망으로 옷 사진 열 종류를 분류합니다. 학습 코드는 실습 4와 같고, 손실 함수만 분류용으로 바뀝니다.

이번 시간에 반복할 패턴: (N - K + 2P) // S + 1 · nn.Conv2d의 weight.shape로 파라미터 세기 · 층을 하나씩 통과시키며 모양 찍기 · CrossEntropyLoss와 argmax(1) · 실습 4의 train_one_epoch·evaluate를 분류에 맞게 다시 쓰기.

대응 이론: Ch06 콘볼루션 연산

표시 뜻
▶ 그대로 실행합니다
✍️ 보여 준 코드를 손으로 타이핑해서 실행합니다. 빈칸 ___은 채웁니다
💬 결과를 함께 읽습니다
✅ 이 숫자가 나오면 맞습니다
📝 과제. 힌트를 보고 스스로 풉니다

교수자 메모 (실습본에는 없음). 60분 운영: 준비 3분 → 1절 출력 크기 8분 → 2절 파라미터 수 7분 → 3절 데이터 7분 → 4절 CNN 모양 추적 10분 → 5절 분류 손실·학습·평가 15분 → 📝 과제 10분. ✅ 숫자: (32, 5, 2, 2) → 16 · Conv2d(3→32, k=3) 896 vs Linear(150,528→256) 38,535,424 · 9,600/2,400/10,000 · 배치 (128, 1, 28, 28) · Conv 출력 (1, 8, 24, 24), 펼친 길이 4,608 · 파라미터 208 + 46,090 = 46,298 · 학습 전 정확도 0.15 · 테스트 정확도 약 0.85 · 과제 1 커널 2개 11,582개 약 0.81 · 과제 2 출력 220, Linear 3,872,010.

# ▶ 준비 셀 — 그대로 실행하세요. 옷 사진 데이터를 ToTensor를 붙여 내려받습니다.
import numpy as np
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
from torchvision import datasets, transforms
from torch.utils.data import Subset, DataLoader
from sklearn.model_selection import train_test_split

np.set_printoptions(linewidth=140)
torch.manual_seed(42)

tr_full = datasets.FashionMNIST(root='../data', train=True, download=True, transform=transforms.ToTensor())
te_full = datasets.FashionMNIST(root='../data', train=False, download=True, transform=transforms.ToTensor())
print("준비 완료!")
준비 완료!

✅ 준비 완료!가 나오면 됩니다.


1. 출력 크기는 공식으로 먼저 센다

\[\text{출력 크기} = \left\lfloor \frac{N - K + 2P}{S} \right\rfloor + 1\]

이론 6장의 공식입니다. \(N\)은 입력 한 변, \(K\)는 커널 크기, \(P\)는 패딩, \(S\)는 스트라이드입니다. 파이썬의 //가 곧 버림입니다. 6장 연습의 32×32 입력, 5×5 커널, 패딩 2, 스트라이드 2를 한 줄로 계산합니다.

# ✍️ 직접 입력
print((32 - 5 + 2 * 2) // 2 + 1)
16

✅ 체크포인트 — 16이면 맞습니다. \((32-5+4)/2 = 15.5\)를 버려서 15, 거기에 1을 더해 16입니다.

공식을 함수로 조립하고, 같은 설정의 nn.Conv2d에 실제로 입력을 통과시켜 마지막 축의 길이와 대조합니다. torch.zeros(1, 1, N, N)은 값이 모두 0인 (N, C, H, W) 입력입니다. 빈칸을 채웁니다.

# ✍️ 빈칸(___)을 채운 뒤 실행하세요
#* def out_size(N, K, P, S):
#*     return (N - ___ + 2 * ___) // ___ + 1
#*
#* for N, K, P, S in [(7, 3, 0, 1), (32, 5, 2, 2), (28, 5, 0, 1)]:
#*     conv = nn.Conv2d(1, 1, kernel_size=K, stride=S, padding=P)
#*     real = conv(torch.zeros(1, 1, N, N)).shape[-1]
#*     print(N, K, P, S, '->', out_size(N, K, P, S), real)
def out_size(N, K, P, S):
    return (N - K + 2 * P) // S + 1

for N, K, P, S in [(7, 3, 0, 1), (32, 5, 2, 2), (28, 5, 0, 1)]:
    conv = nn.Conv2d(1, 1, kernel_size=K, stride=S, padding=P)
    real = conv(torch.zeros(1, 1, N, N)).shape[-1]
    print(N, K, P, S, '->', out_size(N, K, P, S), real)
7 3 0 1 -> 5 5
32 5 2 2 -> 16 16
28 5 0 1 -> 24 24

✅ 체크포인트 — 세 줄 모두 두 숫자가 같고, 마지막 줄이 24 24면 맞습니다.

💬 결과 해설 — 마지막 줄 28×28 입력에 5×5 커널, 패딩 0, 스트라이드 1은 24입니다. 4절의 CNN이 이 설정입니다. nn.Conv2d(1, 1, ...)의 두 숫자는 입력 채널 수와 커널 수입니다.


2. 콘볼루션 층의 파라미터 수

커널 하나의 파라미터는 \(K \times K \times C_{\text{in}}\)개에 편향 1개이고, 커널이 \(M\)개면 \(M\)배입니다. 이론 6장의 비교표 그대로 3×3×3 커널 32개짜리 층과, 5장의 완전연결 첫 층(150,528 → 256)을 만들어 weight.shape를 봅니다.

# ✍️ 직접 입력
conv = nn.Conv2d(3, 32, kernel_size=3)
fc = nn.Linear(3 * 224 * 224, 256)

print(conv.weight.shape, conv.bias.shape)
print(3 * 3 * 3 * 32 + 32)
print(fc.weight.shape, 3 * 224 * 224 * 256 + 256)
torch.Size([32, 3, 3, 3]) torch.Size([32])
896
torch.Size([256, 150528]) 38535424

✅ 체크포인트 — 커널 (32, 3, 3, 3)과 편향 32, 합쳐서 896개. 완전연결은 (256, 150528)에 38,535,424개면 맞습니다.

💬 결과 해설 — 6장 비교표의 두 숫자입니다. 약 4만 배 차이입니다. - weight.shape의 네 축은 5-1의 F.conv2d 커널과 같은 (커널 수, 입력 채널 수, K, K)입니다. - 콘볼루션의 파라미터 수에는 이미지 크기가 들어가지 않습니다. 커널 하나가 모든 위치를 돌아다니며 같은 가중치를 쓰기 때문입니다. 사진이 224×224든 32×32든 896개입니다.


3. 옷 사진 데이터

FashionMNIST 6만 장 중 앞 12,000장만 써서 실습 4처럼 훈련·검증으로 나눕니다. Subset은 데이터셋에서 번호 목록에 해당하는 것만 골라 낸 데이터셋입니다. 테스트는 FashionMNIST가 따로 두는 10,000장을 그대로 씁니다.

# ✍️ 직접 입력
tr_idx, va_idx = train_test_split(range(12000), test_size=0.2, random_state=42)

torch.manual_seed(42)
train_loader = DataLoader(Subset(tr_full, tr_idx), batch_size=128, shuffle=True)
val_loader = DataLoader(Subset(tr_full, va_idx), batch_size=256)
test_loader = DataLoader(te_full, batch_size=256)

print(len(tr_idx), len(va_idx), len(te_full))
print(len(train_loader), len(val_loader), len(test_loader))
9600 2400 10000
75 10 40

✅ 체크포인트 — 9600 2400 10000과 75 10 40이면 맞습니다. 훈련 9,600장을 128장씩 나누면 75배치입니다.

배치 하나를 꺼내 모양을 봅니다.

# ✍️ 직접 입력
xb, yb = next(iter(train_loader))
print(tuple(xb.shape), tuple(yb.shape))
print(yb[:8])
(128, 1, 28, 28) (128,)
tensor([2, 0, 8, 4, 1, 6, 5, 3])

✅ 체크포인트 — (128, 1, 28, 28) (128,)이면 맞습니다.

💬 결과 해설 — 흑백이므로 채널이 1입니다. 정답은 0~9의 클래스 번호이고, 실습 4의 연비처럼 (128, 1)이 아니라 (128,)입니다. 분류의 정답은 번호 하나이기 때문입니다.


4. 콘볼루션 층 하나짜리 신경망

5×5 커널 8개짜리 콘볼루션 층 뒤에 ReLU를 붙이고, 그 결과를 펼쳐 Linear로 열 클래스의 점수를 냅니다. 1절에서 센 대로 28×28이 콘볼루션을 지나면 24×24이므로, 펼친 길이는 \(8 \times 24 \times 24\)입니다. nn.Flatten은 5-1의 flatten(start_dim=1)을 층으로 만든 것입니다. 한 층씩 통과시키며 모양을 찍습니다.

# ✍️ 직접 입력
torch.manual_seed(42)
cnn = nn.Sequential(
    nn.Conv2d(1, 8, kernel_size=5),
    nn.ReLU(),
    nn.Flatten(),
    nn.Linear(8 * 24 * 24, 10),
)

h = torch.zeros(1, 1, 28, 28)
for layer in cnn:
    h = layer(h)
    print(layer.__class__.__name__, tuple(h.shape))
Conv2d (1, 8, 24, 24)
ReLU (1, 8, 24, 24)
Flatten (1, 4608)
Linear (1, 10)

✅ 체크포인트 — Conv2d (1, 8, 24, 24) → ReLU (1, 8, 24, 24) → Flatten (1, 4608) → Linear (1, 10)이면 맞습니다.

💬 결과 해설 — 커널 8개가 특성 맵 8장을 만들어 채널이 1에서 8이 되고, 패딩이 없어 변이 28에서 24로 줍니다. 펼친 4,608개가 Linear로 들어가 열 클래스의 점수 10개가 나옵니다.

파라미터 수를 셉니다. weight와 bias가 있는 층은 콘볼루션과 Linear 둘입니다.

# ✍️ 직접 입력
print(cnn[0].weight.shape, cnn[0].bias.shape)
print(cnn[3].weight.shape, cnn[3].bias.shape)
print(5 * 5 * 1 * 8 + 8, 8 * 24 * 24 * 10 + 10)
torch.Size([8, 1, 5, 5]) torch.Size([8])
torch.Size([10, 4608]) torch.Size([10])
208 46090

✅ 체크포인트 — 콘볼루션 208개, Linear 46,090개면 맞습니다.

💬 결과 해설 — 합쳐서 46,298개인데 그중 콘볼루션은 208개뿐이고 나머지가 뒤의 Linear입니다. 콘볼루션이 줄인 파라미터를 펼쳐서 넣는 Linear가 다시 키웁니다. 이 부분을 줄이는 방법은 다음 실습에서 다룹니다.


5. 분류를 학습시킨다

출력이 클래스별 점수 10개이고 정답이 번호 하나이므로 손실은 CrossEntropyLoss입니다. 점수 10개를 이론 3장의 소프트맥스로 확률로 바꾼 뒤, 정답 클래스의 확률이 낮을수록 큰 값을 냅니다. 배치 하나를 통과시켜 출력 모양과 손실을 봅니다.

# ✍️ 직접 입력
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(cnn.parameters(), lr=0.001)

out = cnn(xb)
print(tuple(out.shape))
print(out[0])
print(criterion(out, yb).item())
(128, 10)
tensor([-0.0387,  0.0005, -0.0229, -0.0291, -0.0512,  0.0490,  0.0066,  0.0442,
         0.0487, -0.0224], grad_fn=<SelectBackward0>)
2.280423164367676

✅ 체크포인트 — 출력 (128, 10), 손실 약 2.28이면 맞습니다.

💬 결과 해설 — out[0]은 첫 사진의 클래스별 점수 10개입니다. 학습 전이라 모두 0 근처로 비슷합니다. 열 클래스를 똑같은 확률로 찍으면 손실은 \(-\ln(1/10) = 2.30\)이고, 지금이 그 상태입니다.

out.argmax(1)은 행마다 점수 10개 중 가장 큰 자리의 번호, 즉 예측 클래스입니다. 정답과 같은지 비교해 맞힌 수를 셉니다.

# ✍️ 직접 입력
print(out.argmax(1)[:8])
print(yb[:8])
print((out.argmax(1) == yb).sum().item())
tensor([5, 1, 8, 8, 8, 7, 7, 2])
tensor([2, 0, 8, 4, 1, 6, 5, 3])
16

💬 결과 해설 — 128장 중 맞힌 수가 16장입니다. 이 “맞힌 수 ÷ 전체”가 정확도입니다. 찍어서 맞는 10%와 비슷합니다.

학습 함수는 실습 4의 것과 같습니다. 다시 씁니다.

# ✍️ 직접 입력
def train_one_epoch(model, loader, criterion, optimizer):
    model.train()
    total = 0.0
    n = 0
    for xb, yb in loader:
        optimizer.zero_grad()
        pred = model(xb)
        loss = criterion(pred, yb)
        loss.backward()
        optimizer.step()
        total += loss.item() * len(yb)
        n += len(yb)
    return total / n

평가 함수는 실습 4의 것에 맞힌 수를 세는 줄이 더해집니다. 손실과 정확도 두 값을 돌려줍니다. 빈칸을 채웁니다.

# ✍️ 빈칸(___)을 채운 뒤 실행하세요
#* def evaluate(model, loader, criterion):
#*     model.eval()
#*     total = 0.0
#*     correct = 0
#*     n = 0
#*     with torch.no_grad():
#*         for xb, yb in loader:
#*             out = model(xb)
#*             total += criterion(out, yb).item() * len(yb)
#*             correct += (___ == yb).sum().item()      # 예측 클래스가 정답과 같은 수
#*             n += len(yb)
#*     return total / n, correct / n
#*
#* print(evaluate(cnn, val_loader, criterion))
def evaluate(model, loader, criterion):
    model.eval()
    total = 0.0
    correct = 0
    n = 0
    with torch.no_grad():
        for xb, yb in loader:
            out = model(xb)
            total += criterion(out, yb).item() * len(yb)
            correct += (out.argmax(1) == yb).sum().item()
            n += len(yb)
    return total / n, correct / n

print(evaluate(cnn, val_loader, criterion))
(2.2810735861460367, 0.15)

✅ 체크포인트 — 학습 전 검증 손실 약 2.28, 정확도 0.15면 맞습니다.

8 에폭을 돌리며 에폭마다 검증 손실과 정확도를 찍습니다.

# ✍️ 직접 입력
for epoch in range(8):
    train_one_epoch(cnn, train_loader, criterion, optimizer)
    val_loss, val_acc = evaluate(cnn, val_loader, criterion)
    print(epoch, round(val_loss, 4), round(val_acc, 4))
0 0.593 0.7767
1 0.5118 0.815
2 0.4461 0.8475
3 0.4733 0.8246
4 0.4182 0.8521
5 0.4119 0.8567
6 0.4126 0.8583
7 0.4012 0.8567

💬 결과 해설 — 검증 손실이 약 0.59에서 0.40으로 내려가고, 정확도는 약 0.78에서 0.86으로 올라갑니다. 학습 코드는 실습 3·4의 다섯 줄 그대로이고, 입력의 모양만 표에서 이미지로 바뀌었습니다.

테스트 세트로 한 번 평가합니다.

# ✍️ 직접 입력
test_loss, test_acc = evaluate(cnn, test_loader, criterion)
print(test_acc)
0.8515

✅ 체크포인트 — 약 0.85면 맞습니다. 콘볼루션 층 하나로 열 종류의 옷을 열에 여덟 이상 맞힙니다.

커널 8개의 값은 사람이 정한 것이 아니라 학습으로 얻은 것입니다. 첫 층의 가중치를 꺼내 그림으로 보고, 사진 한 장을 통과시켜 각 커널이 어디에 반응하는지 봅니다.

# ▶ 그대로 실행하세요 — 학습된 커널 8개와 특성 맵 8장
kernels = cnn[0].weight.detach().numpy()
sample, sample_label = te_full[0]
with torch.no_grad():
    fmaps = torch.relu(cnn[0](sample.unsqueeze(0)))[0].numpy()

fig, axes = plt.subplots(2, 9, figsize=(13, 3.4))
axes[0, 0].axis('off')
axes[1, 0].imshow(sample[0], cmap='gray')
axes[1, 0].set_title(te_full.classes[sample_label], fontsize=8)
for i in range(8):
    axes[0, i + 1].imshow(kernels[i, 0], cmap='gray')
    axes[0, i + 1].set_title(f'kernel {i}', fontsize=8)
    axes[1, i + 1].imshow(fmaps[i], cmap='gray')
    axes[1, i + 1].set_title(f'map {i}', fontsize=8)
for ax in axes.flat:
    ax.axis('off')
plt.show()

💬 윗줄이 커널, 아랫줄이 그 커널을 통과한 특성 맵입니다. 커널마다 밝은 곳이 다릅니다. 어떤 커널은 윤곽에, 어떤 커널은 넓은 면에 반응합니다. 5-1 과제 3의 세로 에지 커널처럼 경계를 잡아내는 값이 데이터만 보고 정해졌습니다.


6. 실습 과제 (10분)

📝 과제 1 · 커널이 2개뿐이면

Conv2d(1, 8, ...)의 커널 수를 2로 바꾼 신경망을 같은 방식으로 8 에폭 학습시켜 테스트 정확도를 봅니다. 파라미터 수도 셉니다.

# 📝
#> 힌트: 4절의 nn.Sequential을 그대로 쓰되 Conv2d(1, 2, ...)와 Linear(2 * 24 * 24, 10)으로 바꿉니다. optimizer도 새 모델로 다시 만들고, 5절의 8 에폭 반복문과 evaluate(…, test_loader, …)를 그대로 씁니다. 파라미터는 5*5*1*2+2 + 2*24*24*10+10입니다.
torch.manual_seed(42)
cnn2 = nn.Sequential(
    nn.Conv2d(1, 2, kernel_size=5), nn.ReLU(),
    nn.Flatten(), nn.Linear(2 * 24 * 24, 10),
)
optimizer = torch.optim.Adam(cnn2.parameters(), lr=0.001)
for epoch in range(8):
    train_one_epoch(cnn2, train_loader, criterion, optimizer)
print(5 * 5 * 1 * 2 + 2 + 2 * 24 * 24 * 10 + 10, evaluate(cnn2, test_loader, criterion)[1])
11582 0.806
커널 수 파라미터 수 테스트 정확도
2 ✏️ ✏️
8 46,298 0.85

예시 답: 파라미터 11,582개, 테스트 정확도 약 0.81. 커널이 2개면 찾을 수 있는 특성이 두 가지뿐이라 정확도가 내려갑니다. 커널 수는 “몇 가지 특성을 찾을 것인가”를 정하는 하이퍼파라미터입니다.

📝 과제 2 · 사진이 224×224가 되면

4절의 신경망에 224×224 흑백 사진을 넣는다고 합시다. 콘볼루션 출력의 한 변, 펼친 길이, Linear(→10)의 파라미터 수를 1절의 out_size와 공식으로 계산합니다. 콘볼루션 층의 파라미터는 몇 개인지도 적습니다.

# 📝
#> 힌트: 한 변은 out_size(224, 5, 0, 1)입니다. 펼친 길이는 8 * 한 변 * 한 변, Linear는 펼친 길이 * 10 + 10입니다. 콘볼루션 층은 4절과 같은 208개입니다.
side = out_size(224, 5, 0, 1)
print(side, 8 * side * side, 8 * side * side * 10 + 10)
220 387200 3872010
입력 콘볼루션 파라미터 콘볼루션 출력 한 변 펼친 길이 Linear 파라미터
28×28 208 24 4,608 46,090
224×224 ✏️ ✏️ ✏️ ✏️

예시 답: 콘볼루션 208개(그대로), 한 변 220, 펼친 길이 387,200, Linear 3,872,010개. 콘볼루션 층은 사진 크기와 무관하지만 펼쳐서 넣는 Linear는 사진과 함께 커집니다.

📝 과제 3 · 생각해 보기

과제 2에서 콘볼루션 층은 208개 그대로인데 신경망 전체는 400만 개 가까이 커졌습니다. 어느 층 때문이고, 그 층에 들어가는 것을 줄이려면 콘볼루션 출력에 무엇을 해야 할까요? 한두 문장으로 적습니다.

✏️ 나의 답:

예시 답: 펼친 특성 맵을 받는 Linear 때문입니다. Linear에 들어가는 길이를 줄이려면 콘볼루션 출력의 높이와 너비를 줄여야 하고, 그것이 다음 실습의 풀링입니다.


✅ 정리

배운 것 핵심
출력 크기 (N - K + 2P) // S + 1. (32, 5, 2, 2) → 16, (28, 5, 0, 1) → 24
파라미터 수 커널 하나 K·K·C_in + 1. Conv2d(3→32, k=3) 896개 vs Linear(150,528→256) 38,535,424개. 이미지 크기와 무관
모양 추적 for layer in model: h = layer(h). (1, 1, 28, 28) → (1, 8, 24, 24) → (1, 4608) → (1, 10)
분류 손실 CrossEntropyLoss. 학습 전 약 2.30 = −ln(1/10). argmax(1)이 예측 클래스, 맞힌 수 ÷ 전체가 정확도
학습 실습 4의 train_one_epoch 그대로, evaluate에 맞힌 수 한 줄 추가. 테스트 정확도 약 0.85
남은 문제 콘볼루션 208개 뒤의 Linear가 46,090개. 사진이 커지면 Linear만 커진다

다음: 실습 6에서는 풀링으로 특성 맵을 줄이고, 콘볼루션 층을 여러 개 쌓아 컬러 사진을 분류합니다.