합성곱(Convolutions) 직접 구현하기

합성곱(Convolution)은 이미지 위를 미끄러지듯 이동하는 작은 밀집 계층(dense layer)과 같습니다. 모든 위치에서 같은 가중치(weight)를 공유합니다.

유형: Build 언어: Python 선수 지식: Phase 3 Deep Learning Core, Phase 4 Lesson 01 Image Fundamentals 예상 시간: 약 75분

학습 목표

  • NumPy만 사용해 2차원 합성곱(2D convolution)을 직접 구현합니다. 중첩 반복문(nested-loop) 버전과 벡터화된(vectorized) im2col 버전을 모두 만듭니다.
  • 입력 크기(input size), 커널 크기(kernel size), 패딩(padding), 보폭(stride) 조합에 대해 출력 공간 크기(output spatial size)를 계산하고 (H - K + 2P) / S + 1 공식을 설명합니다.
  • 경계(edge), 흐림(blur), 선명화(sharpen), 소벨(Sobel) 같은 커널을 손으로 설계하고, 활성값 패턴(activation pattern)이 왜 그렇게 나오는지 설명합니다.
  • 합성곱을 쌓아 특징 추출기(feature extractor)를 만들고, 계층을 쌓는 깊이가 수용 영역(receptive field) 크기와 어떻게 연결되는지 이해합니다.

문제

224x224 크기의 RGB 이미지에 완전 연결 계층(fully connected layer)을 바로 적용하면, 뉴런(neuron) 하나당 224 * 224 * 3 = 150,528개의 입력 가중치가 필요합니다. 은닉 유닛(hidden unit) 1,000개만 있어도 1억 5천만 개의 매개변수(parameter)가 됩니다. 게다가 밀집 계층은 왼쪽 위에 있는 강아지와 오른쪽 아래에 있는 강아지가 같은 패턴이라는 사실을 모릅니다. 픽셀 위치를 서로 독립적으로 다루기 때문에, 이미지에 대해서는 이것이 잘못된 사전 가정(prior)입니다.

이미지 모델에는 두 가지 속성이 필요합니다.

  • 이동 등변성(Translation Equivariance): 입력이 이동하면 출력도 같은 방식으로 이동합니다.
  • 매개변수 공유(Parameter Sharing): 같은 특징 검출기(feature detector)가 모든 위치에서 동작합니다.

밀집 계층은 두 속성을 모두 제공하지 못하지만, 합성곱은 둘 다 자연스럽게 제공합니다.

합성곱은 딥러닝을 위해 발명된 것이 아닙니다. JPEG 압축, 포토샵의 가우시안 블러(Gaussian blur), 산업용 비전(vision)의 경계 검출(edge detection), 음성 필터(audio filter)에 모두 쓰이던 연산입니다. CNN이 한동안 이미지넷(ImageNet)을 지배했던 이유는, "가까운 값은 서로 관련이 있고, 같은 패턴은 어디에나 나타날 수 있다"는 이미지 데이터의 사전 가정에 합성곱이 잘 들어맞기 때문입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.224x224 크기의 RGB 이미지를 kernel_size=3, stride=1, padding=0인 합성곱(convolution)에 넣었습니다. 출력 공간 크기(output spatial size)는 얼마입니까?

2.in_channels=3, out_channels=64, kernel_size=3이고 편향(bias)이 있는 합성곱 계층(conv layer)의 학습 가능한 매개변수(learnable parameter) 수는 얼마입니까?

0/2 답변 완료

개념

하나의 커널을 미끄러뜨리기

2차원 합성곱은 작은 가중치 행렬인 커널(kernel, 필터(filter))을 입력 위로 움직이며, 각 위치에서 원소별 곱(element-wise product)의 합을 계산합니다. 그 합이 출력 픽셀 하나가 됩니다.

flowchart LR
    subgraph IN["Input (H x W)"]
        direction LR
        I1["5 x 5 image"]
    end
    subgraph K["Kernel (3 x 3)"]
        K1["learned<br/>weights"]
    end
    subgraph OUT["Output (H-2 x W-2)"]
        O1["3 x 3 map"]
    end
    I1 --> |"slide kernel<br/>compute dot product<br/>at each position"| O1
    K1 --> O1

    style IN fill:#dbeafe,stroke:#2563eb
    style K fill:#fef3c7,stroke:#d97706
    style OUT fill:#dcfce7,stroke:#16a34a

5x5 입력에 3x3 커널을 적용하고 패딩이 없으며 보폭이 1일 때, 출력은 3x3이 됩니다.

Input X (5 x 5):                Kernel W (3 x 3):

  1  2  0  1  2                   1  0 -1
  0  1  3  1  0                   2  0 -2
  2  1  0  2  1                   1  0 -1
  1  0  2  1  3
  2  1  1  0  1

The kernel slides across every valid 3 x 3 window. Output Y is 3 x 3:

Y[0,0] = sum(W * X[0:3, 0:3])
Y[0,1] = sum(W * X[0:3, 1:4])
Y[0,2] = sum(W * X[0:3, 2:5])
Y[1,0] = sum(W * X[1:4, 0:3])
... and so on

가중치 공유(weight sharing), 지역성(locality), 미끄러지는 창(sliding window)이 합성곱의 핵심입니다.

출력 크기 공식

입력의 공간 크기 H, 커널 크기 K, 패딩 P, 보폭 S가 주어지면 다음과 같이 계산합니다.

H_out = floor((H - K + 2P) / S) + 1
시나리오HKPSH_out
패딩이 없는 유효 합성곱(valid conv)3230130
크기를 유지하는 동일 합성곱(same conv)3231132
2배 다운샘플(downsample)3231216
2x2 풀링(pool)3220216
큰 수용 영역3273216

"동일 패딩(same padding)"은 S == 1일 때 H_out == H가 되도록 P를 고르는 것입니다. 홀수 K에서는 P = (K - 1) / 2이며, 그래서 3x3 커널이 널리 쓰입니다. 중심(center)을 갖는 가장 작은 홀수 크기 커널이기 때문입니다.

패딩

패딩이 없으면 합성곱을 거칠 때마다 특징 지도(feature map)가 줄어듭니다. 여러 계층을 쌓으면 경계 부근의 계산을 버리게 되고, 잔차 연결(residual connection)처럼 형상(shape)이 맞아야 하는 구조도 복잡해집니다.

영 패딩(zero padding)은 입력 주변에 0을 덧붙여, 경계 픽셀에도 커널의 중심을 놓을 수 있게 합니다.

Zero padding (P = 1) on a 5 x 5 input:

  0  0  0  0  0  0  0
  0  1  2  0  1  2  0
  0  0  1  3  1  0  0
  0  2  1  0  2  1  0       Now the kernel can centre on pixel
  0  1  0  2  1  3  0       (0, 0) and still have three rows and
  0  2  1  1  0  1  0       three columns of values to multiply.
  0  0  0  0  0  0  0

실무에서는 zero가 가장 흔하게 쓰이고, 생성 모델(generative model)에서는 딱딱한 경계를 피하기 위해 reflect, 경계 값을 그대로 복사하는 데는 replicate, 원환(toroidal) 문제에는 circular를 사용하기도 합니다.

보폭(Stride)

보폭은 커널이 한 번에 이동하는 걸음의 크기입니다. stride=1이 기본이고, stride=2는 공간 차원(spatial dimension)을 절반으로 줄입니다. ResNet, ConvNeXt, MobileNet 같은 최신 구조(architecture)는 최대 풀링(max-pool) 대신 보폭이 큰 합성곱(strided convolution)을 다운샘플 용도로 자주 사용합니다.

Stride 1 on a 5 x 5 input, 3 x 3 kernel:

  starts: (0,0) (0,1) (0,2)        -> output row 0
          (1,0) (1,1) (1,2)        -> output row 1
          (2,0) (2,1) (2,2)        -> output row 2

  Output: 3 x 3

Stride 2 on the same input:

  starts: (0,0) (0,2)              -> output row 0
          (2,0) (2,2)              -> output row 1

  Output: 2 x 2

여러 개의 입력 채널

RGB 입력에 대한 3x3 합성곱은 사실 3x3x3 부피(volume)입니다. 입력 채널마다 3x3 조각(slice)이 하나씩 있고, 각 공간 위치에서 세 조각을 모두 곱한 뒤 더하고 마지막에 편향(bias)을 더합니다.

Input:   (C_in, H, W)
Weight:  (C_out, C_in, K, K)
Output:  (C_out, H', W')

Parameter count = C_out * C_in * K * K + C_out

예를 들어 3채널 입력에 64채널 3x3 합성곱을 적용하면 매개변수 수는 64 * 3 * 3 * 3 + 64 = 1,792개입니다. 밀집 계층에 비해 훨씬 적은 비용입니다.

im2col 기법

중첩 반복문은 읽기 쉽지만 느립니다. GPU는 큰 행렬 곱(matrix multiplication)에 강합니다. im2col은 입력의 모든 수용 영역 창(receptive-field window)을 하나의 열(column)로 펼치고, 커널을 행(row)으로 펼쳐 합성곱을 행렬 곱으로 바꿉니다.

flowchart LR
    X["Input<br/>(C_in, H, W)"] --> C["im2col<br/>(C_in*K*K, H_out*W_out)"]
    W["Weight<br/>(C_out, C_in, K, K)"] --> F["Flatten<br/>(C_out, C_in*K*K)"]
    F --> M["matmul"]
    C --> M
    M --> O["Output reshape<br/>(C_out, H_out, W_out)"]

실제 운영 환경의 합성곱 구현은 직접 합성곱(direct conv), 위노그라드(Winograd), FFT 기반 합성곱 등 여러 변형(variant)을 사용하지만, 그 핵심 사고방식은 모두 im2col과 일반화 행렬곱(GEMM)에 있습니다.

수용 영역(Receptive Field)

3x3 합성곱 하나는 9개의 입력 픽셀을 봅니다. 3x3 합성곱 두 개를 쌓으면 두 번째 계층의 뉴런은 5x5 크기의 입력 영역을 봅니다. 세 개를 쌓으면 7x7이 됩니다.

RF after L stacked K x K convs(stride 1) = 1 + L * (K - 1)

3x3 합성곱 두 개는 5x5 합성곱 하나와 같은 입력 영역을 보지만 매개변수가 더 적고, 그 사이에 비선형성(non-linearity)을 한 번 더 끼워 넣을 수 있습니다. VGG, ResNet, ConvNeXt가 3x3을 선호하는 이유입니다.

만들어 보기

단계 1: 배열 패딩

import numpy as np

def pad2d(x, p):
    if p == 0:
        return x
    h, w = x.shape[-2:]
    out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype)
    out[..., p:p + h, p:p + w] = x
    return out

x = np.arange(9).reshape(3, 3)
print(x)
print()
print(pad2d(x, 1))

x.shape[:-2]를 사용하면 (H, W), (C, H, W), (N, C, H, W)에 대해 같은 함수가 동작합니다.

단계 2: 중첩 반복문으로 만드는 2차원 합성곱

def conv2d_naive(x, w, b=None, stride=1, padding=0):
    c_in, h, w_in = x.shape
    c_out, c_in_w, kh, kw = w.shape
    assert c_in == c_in_w

    x_pad = pad2d(x, padding)
    h_out = (h + 2 * padding - kh) // stride + 1
    w_out = (w_in + 2 * padding - kw) // stride + 1

    out = np.zeros((c_out, h_out, w_out), dtype=np.float32)
    for oc in range(c_out):
        for i in range(h_out):
            for j in range(w_out):
                hs = i * stride
                ws = j * stride
                patch = x_pad[:, hs:hs + kh, ws:ws + kw]
                out[oc, i, j] = np.sum(patch * w[oc])
        if b is not None:
            out[oc] += b[oc]
    return out

느리지만 의미가 명확한 기준 구현(reference implementation)입니다. 더 빠른 구현은 이 결과와 비교해 정확성을 검증합니다.

단계 3: 손으로 만든 커널로 검증하기

세로 방향의 소벨 커널(vertical Sobel kernel)을 인공적으로 만든 계단 모양 이미지(synthetic step image)에 적용하면 세로 경계가 강하게 드러나야 합니다.

def synthetic_step_image():
    img = np.zeros((1, 16, 16), dtype=np.float32)
    img[:, :, 8:] = 1.0
    return img

sobel_x = np.array([
    [[-1, 0, 1],
     [-2, 0, 2],
     [-1, 0, 1]]
], dtype=np.float32)[None]

x = synthetic_step_image()
y = conv2d_naive(x, sobel_x, padding=1)
print(y[0].round(1))

7번 열(column) 근처에서 큰 양수가 나오고, 나머지 위치는 0에 가까워야 합니다.

단계 4: im2col 구현

def im2col(x, kh, kw, stride=1, padding=0):
    c_in, h, w = x.shape
    x_pad = pad2d(x, padding)
    h_out = (h + 2 * padding - kh) // stride + 1
    w_out = (w + 2 * padding - kw) // stride + 1

    cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype)
    col = 0
    for i in range(h_out):
        for j in range(w_out):
            hs = i * stride
            ws = j * stride
            patch = x_pad[:, hs:hs + kh, ws:ws + kw]
            cols[:, col] = patch.reshape(-1)
            col += 1
    return cols, h_out, w_out

파이썬 반복문은 남아 있지만, 가장 무거운 연산은 하나의 벡터화된 행렬 곱(matmul)으로 옮길 수 있습니다.

단계 5: im2col과 행렬 곱으로 만드는 합성곱

def conv2d_im2col(x, w, b=None, stride=1, padding=0):
    c_out, c_in, kh, kw = w.shape
    cols, h_out, w_out = im2col(x, kh, kw, stride, padding)
    w_flat = w.reshape(c_out, -1)
    out = w_flat @ cols
    if b is not None:
        out += b[:, None]
    return out.reshape(c_out, h_out, w_out)

정확성 확인을 위해 두 구현을 같은 입력과 가중치로 실행해 비교합니다.

rng = np.random.default_rng(0)
x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32)
w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32)
b = rng.normal(0, 1, (8,)).astype(np.float32)

y_naive = conv2d_naive(x, w, b, padding=1)
y_im2col = conv2d_im2col(x, w, b, padding=1)

print(f"max abs diff: {np.max(np.abs(y_naive - y_im2col)):.2e}")

max abs diff는 대략 1e-5 수준이어야 합니다. 이는 부동소수점 누산 순서(floating-point accumulation order) 차이일 뿐이며 버그가 아닙니다.

단계 6: 손으로 설계한 커널 모음

identity, blur_3x3, sharpen, sobel_x, sobel_y는 학습(training) 없이도 합성곱 계층 하나가 무엇을 표현할 수 있는지 보여 줍니다.

KERNELS = {
    "identity": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32),
    "blur_3x3": np.ones((3, 3), dtype=np.float32) / 9.0,
    "sharpen": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32),
    "sobel_x": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32),
    "sobel_y": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32),
}

def apply_kernel(img2d, kernel):
    x = img2d[None].astype(np.float32)
    w = kernel[None, None]
    return conv2d_im2col(x, w, padding=1)[0]

회색조 이미지(grayscale image)에 적용하면, 흐림 커널은 이미지를 부드럽게 만들고 선명화 커널은 경계를 강조하며, 소벨-x는 세로 경계를, 소벨-y는 가로 경계를 밝힙니다. 이것들은 AlexNet과 VGG의 첫 합성곱 계층이 학습 끝에 얻어 낸 패턴과 사실상 같습니다. 어떤 후속 과제를 풀든, 좋은 이미지 모델에는 경계 검출기와 덩어리 검출기(blob detector)가 필요하기 때문입니다.

사용하기

파이토치의 nn.Conv2d는 같은 연산에 자동 미분(autograd), CUDA 커널, cuDNN 최적화를 덧붙인 것입니다. 형상(shape)의 의미는 동일합니다.

import torch
import torch.nn as nn

conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)
print(conv)
print(f"weight shape: {tuple(conv.weight.shape)}   # (C_out, C_in, K, K)")
print(f"bias shape:   {tuple(conv.bias.shape)}")
print(f"param count:  {sum(p.numel() for p in conv.parameters())}")

x = torch.randn(8, 3, 224, 224)
y = conv(x)
print(f"\ninput  shape: {tuple(x.shape)}")
print(f"output shape: {tuple(y.shape)}")

padding=1padding=0으로 바꾸면 출력은 222x222가 됩니다. stride=2로 바꾸면 112x112가 됩니다. 위에서 외운 공식 그대로입니다.

산출물 만들기

이 강의의 최종 산출물은 다음 두 가지입니다.

  • outputs/prompt-cnn-architect.md: 입력 크기, 매개변수 예산(parameter budget), 목표 수용 영역으로 Conv2d 스택(stack)을 설계하는 프롬프트(prompt)
  • outputs/skill-conv-shape-calculator.md: 네트워크 사양(spec)을 계층마다 따라가며 출력 형상, 수용 영역, 매개변수 수를 계산해 주는 스킬(skill)

연습문제

  1. 쉬움: 128x128 회색조 입력과 [Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)] 스택이 주어졌을 때 각 계층의 출력 공간 크기와 수용 영역을 손으로 계산합니다. 파이토치의 nn.Sequential로 더미(dummy) 합성곱을 만들어 검증합니다.
  2. 중간: conv2d_naiveconv2d_im2colgroups 인자(argument)를 추가합니다. groups=C_in=C_out이 깊이별 합성곱(depthwise convolution)을 재현하고, 매개변수 수가 C * K * K가 되는지 보입니다.
  3. 어려움: conv2d_im2col의 역전파(backward pass)를 직접 구현합니다. 출력 기울기(output gradient)에서 xw의 기울기를 계산하고, 같은 입력과 가중치에서 torch.autograd.grad와 비교합니다. 핵심은 im2col의 기울기가 col2im이며, 겹치는 창(overlapping window)을 누적해야 한다는 점입니다.

핵심 용어

용어흔한 설명실제 의미
합성곱(Convolution)"필터를 미끄러뜨리는 것"가중치를 공유하는 학습 가능한 내적(dot product)을 모든 공간 위치에 적용하는 연산
커널/필터(Kernel / filter)"특징 검출기"입력 창과 내적을 계산해 출력 픽셀 하나를 만드는 (C_in, K, K) 형태의 작은 가중치 텐서
보폭(Stride)"얼마나 건너뛰는지"연속된 커널 적용 위치 사이의 걸음 크기
패딩(Padding)"경계에 두는 0"경계 픽셀에도 커널의 중심을 놓을 수 있도록 입력 주변에 추가하는 값
수용 영역(Receptive field)"뉴런이 보는 범위"하나의 출력 활성값이 의존하는 원본 입력의 영역
im2col"GEMM 트릭"수용 영역 창을 열로 재배열해 합성곱을 행렬 곱으로 바꾸는 방법
깊이별 합성곱(Depthwise conv)"채널마다 커널 하나"groups == C_in인 합성곱. MobileNet과 ConvNeXt에서 중요한 구성 요소
이동 등변성(Translation equivariance)"들어가서 이동하면, 나와서도 이동"입력이 이동하면 출력도 같은 만큼 이동하는 성질

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 2개

skill-conv-shape-calculator

Walk a CNN spec layer by layer and report output shape, receptive field, and parameter count for every block

Skill
prompt-cnn-architect

Design a stack of Conv2d layers from input size, parameter budget, and target receptive field

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.최신 CNN이 하나의 5x5 또는 7x7 합성곱 대신 3x3 합성곱을 쌓은 스택(stack)을 선호하는 이유는 무엇입니까?

2.`im2col` 변환(transformation)은 실제로 무엇을 합니까?

3.네 개의 3x3 합성곱을 모두 보폭(stride) 1로 두고 풀링(pooling) 없이 쌓았습니다. 마지막 계층 뉴런의 수용 영역은 얼마입니까?

0/3 답변 완료