들어가며
웹 개발을 하면서 이미지를 다룰 때는 그냥 <img> 태그로 보여주는 게 전부였다.
딥러닝에서 이미지를 다루기 시작하면서 CNN이 왜 이미지에 특화된 구조인지,
어떻게 동작하는지 처음엔 전혀 감이 없었다. 기초 개념부터 실제 코드까지 정리해둔다.
왜 CNN인가?
일반 신경망(MLP)으로 이미지를 처리하면 어떤 문제가 있을까?
import torch
import torch.nn as nn
# 224x224 컬러 이미지를 MLP로 처리하면
image_size = 224 * 224 * 3 # 150,528개 입력
mlp = nn.Sequential(
nn.Linear(150528, 1024), # 가중치: 약 1.5억개
nn.ReLU(),
nn.Linear(1024, 10)
)
total_params = sum(p.numel() for p in mlp.parameters())
print(f"MLP 파라미터 수: {total_params:,}") # 약 154,150,922개
문제점:
1. 파라미터가 너무 많음 → 과적합, 메모리 부족
2. 공간 정보 무시: 픽셀을 1차원으로 펼치면 위치 관계가 사라짐
3. 이동 불변성 없음: 고양이가 왼쪽에 있든 오른쪽에 있든 같은 것임
CNN은 이 문제를 세 가지 핵심 아이디어로 해결한다.
1. 지역적 연결 (Local Connectivity): 인접한 픽셀끼리만 연결
2. 가중치 공유 (Weight Sharing): 같은 필터를 이미지 전체에 적용
3. 풀링 (Pooling): 공간 크기 줄이기
합성곱 연산 (Convolution)
필터(커널)를 이미지 위에서 슬라이딩하면서 특징을 추출하는 연산이다.
import numpy as np
def convolution2d(image, kernel, stride=1, padding=0):
if padding > 0:
image = np.pad(image, padding, mode='constant')
H, W = image.shape
kH, kW = kernel.shape
out_H = (H - kH) // stride + 1
out_W = (W - kW) // stride + 1
output = np.zeros((out_H, out_W))
for i in range(out_H):
for j in range(out_W):
patch = image[i*stride:i*stride+kH, j*stride:j*stride+kW]
output[i, j] = np.sum(patch * kernel)
return output
# 엣지 검출 필터 (Sobel filter)
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=float)
image = np.random.randn(5, 5)
result = convolution2d(image, sobel_x)
print(f"입력 크기: {image.shape}") # (5, 5)
print(f"출력 크기: {result.shape}") # (3, 3)
출력 크기 계산
출력 크기 = (입력 크기 - 커널 크기 + 2 * 패딩) / 스트라이드 + 1
예시:
입력: 32x32, 커널: 3x3, 패딩: 0, 스트라이드: 1
출력: (32 - 3 + 0) / 1 + 1 = 30x30
입력: 32x32, 커널: 3x3, 패딩: 1, 스트라이드: 1
출력: (32 - 3 + 2) / 1 + 1 = 32x32 → 크기 유지 (same padding)
def calc_output_size(input_size, kernel_size, padding=0, stride=1):
return (input_size - kernel_size + 2 * padding) // stride + 1
print(calc_output_size(32, 3, padding=0)) # 30
print(calc_output_size(32, 3, padding=1)) # 32 (same padding)
print(calc_output_size(32, 3, padding=0, stride=2)) # 15
CNN의 핵심 레이어
1. Conv2d (합성곱 레이어)
# nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding)
conv = nn.Conv2d(
in_channels=3, # 입력 채널 수 (RGB=3, 흑백=1)
out_channels=64, # 출력 채널 수 (필터 개수)
kernel_size=3,
stride=1,
padding=1 # same padding: 출력 크기 = 입력 크기
)
# 파라미터 수: 3 * 64 * 3 * 3 + 64 (bias) = 1,792
x = torch.randn(4, 3, 32, 32) # (배치, 채널, 높이, 너비)
out = conv(x)
print(f"입력 shape: {x.shape}") # (4, 3, 32, 32)
print(f"출력 shape: {out.shape}") # (4, 64, 32, 32)
2. Pooling (풀링 레이어)
max_pool = nn.MaxPool2d(kernel_size=2, stride=2) # 크기 절반
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
gap = nn.AdaptiveAvgPool2d((1, 1)) # 채널별 전체 평균
x = torch.randn(4, 64, 32, 32)
print(f"Max Pool: {max_pool(x).shape}") # (4, 64, 16, 16)
print(f"Avg Pool: {avg_pool(x).shape}") # (4, 64, 16, 16)
print(f"GAP: {gap(x).shape}") # (4, 64, 1, 1)
3. 필터가 학습하는 것
# 깊이에 따라 학습하는 패턴이 달라짐
# 1층: 엣지, 색상, 텍스처
# 2층: 패턴, 모서리
# 3층: 부품 (눈, 코, 귀)
# 4층: 얼굴, 물체 전체
완전한 CNN 아키텍처
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# 블록 1: (3, 32, 32) → (32, 16, 16)
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2, 2),
# 블록 2: → (64, 8, 8)
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2, 2),
# 블록 3: → (128, 4, 4)
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(),
nn.MaxPool2d(2, 2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(128 * 4 * 4, 256),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(256, num_classes)
)
def forward(self, x):
return self.classifier(self.features(x))
model = SimpleCNN(num_classes=10)
x = torch.randn(4, 3, 32, 32)
print(f"출력: {model(x).shape}") # (4, 10)
유명한 CNN 아키텍처
LeNet-5 (1998)
최초의 실용적인 CNN이다. 손글씨 숫자 인식에 사용됐다.
class LeNet5(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5),
nn.Tanh(),
nn.AvgPool2d(2, 2),
nn.Conv2d(6, 16, kernel_size=5),
nn.Tanh(),
nn.AvgPool2d(2, 2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(16 * 4 * 4, 120),
nn.Tanh(),
nn.Linear(120, 84),
nn.Tanh(),
nn.Linear(84, num_classes),
)
ResNet의 핵심: Skip Connection (2015)
깊은 신경망의 기울기 소실 문제를 해결하는 아이디어다.
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(channels)
self.relu = nn.ReLU()
def forward(self, x):
residual = x # 입력 저장
out = self.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out = out + residual # Skip Connection: 입력을 더함
return self.relu(out)
# F(x) + x = H(x)
# 레이어는 F(x) = H(x) - x 만 학습 (잔차, Residual 학습)
# F(x) = 0이 되면 항등 함수 → 깊이가 깊어도 안전
사전학습 모델 활용 (Transfer Learning 맛보기)
import torchvision.models as models
# 사전학습된 ResNet18 로드
resnet = models.resnet18(pretrained=True)
# 마지막 FC 레이어만 교체 (10 클래스 분류)
num_features = resnet.fc.in_features
resnet.fc = nn.Linear(num_features, 10)
# 특징 추출 레이어는 고정
for param in resnet.parameters():
param.requires_grad = False
# 마지막 레이어만 학습
for param in resnet.fc.parameters():
param.requires_grad = True
trainable = sum(p.numel() for p in resnet.parameters() if p.requires_grad)
total = sum(p.numel() for p in resnet.parameters())
print(f"학습 파라미터: {trainable:,} / {total:,}")
실습: CIFAR-10 분류
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform_train = transforms.Compose([
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32, padding=4),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
train_dataset = datasets.CIFAR10(root='./data', train=True,
download=True, transform=transform_train)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleCNN(num_classes=10).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
def train_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss, correct, total = 0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
_, predicted = outputs.max(1)
total += labels.size(0)
correct += predicted.eq(labels).sum().item()
return total_loss / len(loader), 100. * correct / total
정리
| 개념 | 설명 | 핵심 포인트 |
|---|---|---|
| Convolution | 필터로 특징 추출 | 가중치 공유, 파라미터 효율적 |
| Pooling | 공간 크기 축소 | Max Pooling이 가장 일반적 |
| Feature Map | Conv 출력 | 채널 수 = 필터 개수 |
| Receptive Field | 뉴런이 보는 영역 | 깊을수록 넓어짐 |
| Skip Connection | 입력을 출력에 더함 | 기울기 소실 해결 (ResNet) |
| Transfer Learning | 사전학습 모델 활용 | 적은 데이터로도 좋은 성능 |
'딥러닝 > 딥러닝 아키텍쳐' 카테고리의 다른 글
| 사전학습 모델 활용 (Transfer Learning) (0) | 2026.05.16 |
|---|---|
| Transformer / Attention (0) | 2026.05.16 |
| RNN / LSTM (시퀀스, 텍스트) (0) | 2026.05.16 |