본문 바로가기

딥러닝/딥러닝 아키텍쳐

CNN (합성곱 신경망)

들어가며

웹 개발을 하면서 이미지를 다룰 때는 그냥 <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