이미지 로딩 중...

CNN 아키텍처 완벽 가이드 LeNet AlexNet VGGNet - 슬라이드 1/9
A

AI Generated

2025. 11. 23. · 0 Views

CNN 아키텍처 완벽 가이드 LeNet AlexNet VGGNet

컴퓨터 비전의 기초가 되는 세 가지 핵심 CNN 아키텍처를 배웁니다. 손글씨 인식부터 이미지 분류까지, 딥러닝의 발전 과정을 따라가며 각 모델의 구조와 특징을 실습 코드와 함께 이해합니다.


목차

  1. LeNet-5 - 손글씨 숫자 인식의 시작
  2. AlexNet - ImageNet 혁명의 시작
  3. VGGNet-16 - 단순함의 힘
  4. 합성곱 계층의 원리 - 이미지의 패턴 찾기
  5. 풀링 계층 - 중요한 정보만 남기기
  6. ReLU 활성화 함수 - 신경망의 비선형성
  7. 드롭아웃 - 과적합의 강력한 해결사
  8. 배치 정규화 - 학습 안정화의 게임 체인저

1. LeNet-5 - 손글씨 숫자 인식의 시작

시작하며

여러분이 은행에서 수표를 처리하거나 우편번호를 자동으로 읽는 시스템을 만든다고 상상해보세요. 수천 장의 손글씨 숫자를 일일이 사람이 확인한다면 얼마나 오래 걸릴까요?

1990년대 후반, 바로 이런 문제를 해결하기 위해 LeNet-5가 탄생했습니다. Yann LeCun이 개발한 이 모델은 손글씨 숫자를 자동으로 인식하는 최초의 실용적인 CNN 아키텍처입니다.

마치 우리 눈이 글씨를 볼 때 전체 모양을 한 번에 보는 게 아니라, 선분과 곡선, 그리고 그것들의 조합을 차례로 인식하는 것처럼, LeNet-5도 단계적으로 이미지의 특징을 파악합니다.

개요

간단히 말해서, LeNet-5는 32x32 크기의 흑백 이미지에서 0부터 9까지의 숫자를 인식하는 신경망 모델입니다. 이 모델이 왜 혁명적이었을까요?

과거에는 이미지를 인식하기 위해 사람이 직접 "가로선이 있나요?", "둥근 모양이 있나요?" 같은 규칙을 일일이 만들어야 했습니다. 하지만 LeNet-5는 데이터만 주면 스스로 이런 규칙을 학습합니다.

예를 들어, 우편번호 자동 인식 시스템에서 매일 수백만 개의 우편물을 자동으로 분류할 수 있게 되었죠. 기존에는 픽셀 하나하나를 개별적으로 처리했다면, 이제는 주변 픽셀들과의 관계를 함께 고려합니다.

마치 단어를 읽을 때 글자 하나하나가 아니라 단어 전체의 모양을 보는 것과 비슷합니다. LeNet-5의 핵심은 크게 세 가지입니다.

첫째, 합성곱(Convolution) 계층으로 이미지의 지역적 특징을 추출합니다. 둘째, 풀링(Pooling) 계층으로 중요한 정보만 남기고 크기를 줄입니다.

셋째, 완전연결(Fully Connected) 계층으로 최종 분류를 수행합니다. 이 세 가지가 조합되어 높은 정확도로 숫자를 인식할 수 있게 됩니다.

코드 예제

import torch
import torch.nn as nn

class LeNet5(nn.Module):
    def __init__(self):
        super(LeNet5, self).__init__()
        # 첫 번째 합성곱 계층: 1채널 입력 -> 6개 필터, 5x5 크기
        self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2)
        # 두 번째 합성곱 계층: 6채널 -> 16개 필터
        self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
        # 완전연결 계층들: 특징을 숫자로 변환
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)  # 10개 숫자 분류
        # 활성화 함수와 풀링
        self.pool = nn.AvgPool2d(2, 2)
        self.relu = nn.ReLU()

    def forward(self, x):
        # 첫 번째 블록: 합성곱 -> 활성화 -> 풀링
        x = self.pool(self.relu(self.conv1(x)))
        # 두 번째 블록: 합성곱 -> 활성화 -> 풀링
        x = self.pool(self.relu(self.conv2(x)))
        # 평탄화: 2D -> 1D 벡터로 변환
        x = x.view(-1, 16 * 5 * 5)
        # 완전연결 계층들로 최종 분류
        x = self.relu(self.fc1(x))
        x = self.relu(self.fc2(x))
        x = self.fc3(x)
        return x

설명

이것이 하는 일: LeNet-5는 32x32 픽셀의 손글씨 숫자 이미지를 받아서, 그것이 0부터 9 중 어떤 숫자인지 판별합니다. 마치 우리가 숫자를 볼 때 "이건 8이구나" 하고 인식하는 것처럼요.

첫 번째로, conv1과 첫 번째 풀링 계층이 실행됩니다. 여기서는 입력 이미지에서 기본적인 패턴들을 찾아냅니다.

예를 들어 "가로선", "세로선", "대각선" 같은 간단한 모양들이죠. 5x5 크기의 필터 6개가 이미지를 훑으면서 이런 패턴들을 감지합니다.

그리고 풀링 계층에서 이미지 크기를 절반으로 줄여서, 중요한 정보는 유지하면서도 계산량을 줄입니다. 두 번째로, conv2와 두 번째 풀링 계층이 동작합니다.

이번에는 첫 번째 계층에서 찾은 간단한 패턴들을 조합해서 더 복잡한 모양을 인식합니다. 예를 들어 "둥근 곡선", "모서리", "교차점" 같은 것들이죠.

16개의 필터가 각각 다른 특징을 학습합니다. 다시 풀링으로 크기를 줄이면 5x5 크기의 특징 맵 16개가 남습니다.

세 번째로, view 함수로 2차원 이미지를 1차원 벡터로 펼칩니다. 그리고 완전연결 계층 3개(fc1, fc2, fc3)를 거치면서 점진적으로 정보를 압축합니다.

fc1은 400개의 특징을 120개로, fc2는 120개를 84개로, fc3는 84개를 최종 10개(0~9 숫자)로 변환합니다. 마지막으로, 10개의 출력값 중 가장 큰 값에 해당하는 숫자가 모델의 예측 결과가 됩니다.

예를 들어 8번째 값이 가장 크면 "이 이미지는 숫자 8입니다"라고 판단하는 거죠. 여러분이 이 코드를 사용하면 MNIST 데이터셋에서 약 98-99%의 정확도로 손글씨 숫자를 인식할 수 있습니다.

또한 이 구조를 이해하면 더 복잡한 CNN 모델을 배우는 데 탄탄한 기초가 됩니다. 파라미터 수가 약 6만 개로 적어서 학습도 빠르고, CPU에서도 충분히 돌릴 수 있다는 장점도 있습니다.

실전 팁

💡 입력 이미지는 반드시 정규화(0~1 범위)해야 합니다. 그렇지 않으면 학습이 매우 불안정해지고 정확도가 크게 떨어집니다.

💡 원본 LeNet-5는 tanh 활성화 함수를 사용했지만, ReLU를 사용하면 학습 속도가 훨씬 빨라집니다. 위 코드에서 ReLU를 사용한 이유입니다.

💡 AvgPool2d 대신 MaxPool2d를 사용해도 됩니다. 실험 결과 MaxPool이 약간 더 나은 성능을 보이는 경우가 많습니다.

💡 배치 정규화(Batch Normalization)를 각 합성곱 계층 뒤에 추가하면 학습이 더 안정적이고 빨라집니다.

💡 데이터 증강(회전, 이동, 확대/축소)을 적용하면 과적합을 방지하고 실제 환경에서의 성능을 크게 향상시킬 수 있습니다.


2. AlexNet - ImageNet 혁명의 시작

시작하며

여러분이 100만 장이 넘는 고해상도 컬러 사진에서 1000가지 종류의 물체를 구분해야 한다면 어떻게 하시겠어요? 고양이, 개, 비행기, 자동차...

각각의 특징을 일일이 정의하는 것은 불가능에 가깝습니다. 2012년, AlexNet이 등장하기 전까지 ImageNet 대회에서는 전통적인 컴퓨터 비전 기법들이 주류였습니다.

하지만 AlexNet이 2위와 10% 이상의 압도적인 차이로 우승하면서, 딥러닝의 시대가 본격적으로 시작되었습니다. 이 모델은 단순히 성능이 좋은 것을 넘어서, GPU를 활용한 대규모 신경망 학습이 가능하다는 것을 세상에 증명했습니다.

그리고 그 이후 모든 컴퓨터 비전 연구의 방향을 바꾸어 놓았죠.

개요

간단히 말해서, AlexNet은 227x227 크기의 컬러 이미지를 1000개의 카테고리로 분류하는 깊은 CNN 모델입니다. 왜 이 모델이 혁명적이었을까요?

LeNet-5가 6만 개의 파라미터를 가진 반면, AlexNet은 6천만 개가 넘는 파라미터를 가지고 있습니다. 1000배나 큰 모델이죠.

이렇게 큰 모델을 학습시키기 위해 Alex Krizhevsky는 여러 혁신적인 기법을 도입했습니다. 예를 들어, 의료 영상 진단 시스템이나 자율주행 자동차의 객체 인식 같은 실제 산업 분야에서 딥러닝을 적용할 수 있는 가능성을 열어주었습니다.

기존 LeNet이 작은 흑백 이미지에 한정되었다면, 이제는 실제 세상의 복잡한 컬러 이미지를 처리할 수 있게 되었습니다. 또한 과적합을 방지하기 위한 드롭아웃, 학습 속도를 높이는 ReLU 활성화 함수, GPU 병렬 처리 등 현대 딥러닝의 필수 요소들이 모두 AlexNet에서 시작되었습니다.

AlexNet의 핵심 특징은 다섯 가지입니다. 첫째, 5개의 합성곱 계층으로 깊은 특징 추출이 가능합니다.

둘째, ReLU 활성화 함수로 학습 속도가 6배 이상 빨라졌습니다. 셋째, 드롭아웃으로 과적합을 효과적으로 방지합니다.

넷째, 지역 반응 정규화(LRN)로 일반화 성능을 향상시킵니다. 다섯째, 데이터 증강으로 학습 데이터를 효과적으로 확장합니다.

이 모든 요소가 결합되어 당시로서는 상상할 수 없었던 높은 정확도를 달성했습니다.

코드 예제

import torch
import torch.nn as nn

class AlexNet(nn.Module):
    def __init__(self, num_classes=1000):
        super(AlexNet, self).__init__()
        # 특징 추출 계층들 (5개 합성곱)
        self.features = nn.Sequential(
            # Conv1: 3채널 RGB -> 96개 필터, 11x11 크기, 보폭 4
            nn.Conv2d(3, 96, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),  # 크기 축소
            # Conv2: 96 -> 256 필터, 5x5 크기
            nn.Conv2d(96, 256, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            # Conv3, 4, 5: 더 깊은 특징 추출
            nn.Conv2d(256, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        # 분류기 계층들 (3개 완전연결)
        self.classifier = nn.Sequential(
            nn.Dropout(p=0.5),  # 50% 드롭아웃으로 과적합 방지
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),  # 1000개 클래스 분류
        )

    def forward(self, x):
        x = self.features(x)  # 특징 추출
        x = x.view(x.size(0), -1)  # 평탄화
        x = self.classifier(x)  # 분류
        return x

설명

이것이 하는 일: AlexNet은 고해상도 컬러 사진을 받아서 그것이 무엇인지(개, 고양이, 자동차, 비행기 등 1000가지 중 하나)를 판별합니다. 실제 세상의 복잡한 이미지를 이해하는 첫 번째 실용적인 딥러닝 모델이라고 할 수 있습니다.

첫 번째로, features 부분의 첫 번째 합성곱 계층(Conv1)이 실행됩니다. 11x11이라는 큰 필터 96개가 이미지를 훑으면서 가장 기본적인 패턴들을 찾습니다.

stride=4 때문에 4픽셀씩 건너뛰면서 빠르게 처리합니다. 이 계층에서 "색상의 변화", "밝기 변화", "간단한 선과 곡선" 같은 저수준 특징들을 감지합니다.

ReLU 활성화 함수가 음수 값을 0으로 만들어서 학습 속도를 크게 향상시키고, MaxPool로 중요한 특징만 남깁니다. 두 번째로, Conv2와 Conv3, 4, 5가 순차적으로 동작합니다.

Conv2는 256개 필터로 "모서리", "질감", "간단한 모양" 같은 중간 수준 특징을 학습합니다. Conv3, 4, 5는 각각 384, 384, 256개 필터로 더 복잡한 패턴을 인식합니다.

예를 들어 "눈", "귀", "바퀴", "날개" 같은 물체의 부분들을 감지하게 됩니다. 계층이 깊어질수록 더 추상적이고 의미 있는 특징을 학습한다는 것이 핵심입니다.

세 번째로, view 함수로 6x6x256 크기의 3차원 특징 맵을 9216 크기의 1차원 벡터로 펼칩니다. 그리고 classifier의 첫 번째 완전연결 계층에서 9216개를 4096개로 압축합니다.

여기서 중요한 것이 드롭아웃입니다. 학습 시 50%의 뉴런을 무작위로 끄기 때문에, 모델이 특정 뉴런에 과도하게 의존하지 않고 더 일반적인 패턴을 학습하게 됩니다.

마지막으로, 두 번째 완전연결 계층(4096 -> 4096)을 거쳐, 세 번째 계층에서 최종적으로 1000개의 클래스 확률을 출력합니다. 소프트맥스를 적용하면 "이 이미지가 골든 리트리버일 확률 85%, 래브라도일 확률 10%..." 이런 식으로 해석할 수 있습니다.

여러분이 이 코드를 사용하면 ImageNet 데이터셋에서 top-5 정확도 약 80%를 달성할 수 있습니다(즉, 상위 5개 예측 안에 정답이 들어갈 확률). GPU 없이는 학습이 매우 어렵지만, 사전 학습된 가중치를 사용하면 전이 학습으로 자신만의 이미지 분류 모델을 빠르게 만들 수 있습니다.

또한 AlexNet의 구조를 이해하면 VGGNet, ResNet 같은 더 현대적인 모델로 발전시킬 수 있는 기반이 됩니다.

실전 팁

💡 학습 시 배치 크기를 256으로 설정하고, SGD 옵티마이저에 모멘텀 0.9를 사용하는 것이 원 논문의 세팅입니다. 학습률은 0.01에서 시작해 검증 정확도가 정체되면 10으로 나눕니다.

💡 데이터 증강이 필수입니다. 256x256 이미지에서 무작위로 227x227를 잘라내고, 좌우 반전을 적용하세요. 이것만으로도 과적합을 크게 줄일 수 있습니다.

💡 드롭아웃 비율 0.5는 학습 시에만 적용되고, 추론(테스트) 시에는 모든 뉴런을 사용합니다. model.eval()을 호출하면 자동으로 드롭아웃이 비활성화됩니다.

💡 GPU 메모리가 부족하면 배치 크기를 줄이거나, mixed precision training(torch.cuda.amp)을 사용하세요. 메모리를 절반으로 줄이면서도 성능은 거의 유지됩니다.

💡 사전 학습된 AlexNet을 전이 학습에 사용할 때는 마지막 분류기 계층만 교체하고, features 부분은 동결(freeze)하거나 낮은 학습률로 미세 조정하세요. 데이터가 적을 때 매우 효과적입니다.


3. VGGNet-16 - 단순함의 힘

시작하며

여러분이 레고 블록으로 집을 짓는다고 생각해보세요. 여러 가지 모양의 특수 블록보다, 같은 크기의 단순한 블록을 반복해서 쌓는 것이 때로는 더 견고하고 아름다운 구조를 만들 수 있습니다.

AlexNet 이후 많은 연구자들이 더 복잡한 구조를 시도했지만, 2014년 옥스퍼드 대학의 VGG 팀은 정반대의 접근을 했습니다. "3x3 합성곱만 사용하면 어떨까?" 이 단순한 아이디어가 ImageNet 대회에서 2위를 차지하며, 깊이가 중요하다는 것을 증명했습니다.

VGGNet의 가장 큰 장점은 이해하기 쉽고 구현하기 간단하다는 것입니다. 복잡한 트릭 없이도 우수한 성능을 내기 때문에, 지금도 많은 실무 프로젝트에서 백본 네트워크로 사용됩니다.

개요

간단히 말해서, VGGNet은 3x3 합성곱 계층을 16개(또는 19개) 깊게 쌓아서 매우 세밀한 특징을 추출하는 CNN 모델입니다. 왜 이렇게 단순한 구조가 효과적일까요?

비밀은 "깊이"에 있습니다. 3x3 합성곱 두 개를 연속으로 사용하면 5x5 필터 하나와 비슷한 시야를 가지지만, 파라미터 수는 더 적고 비선형성은 두 배가 됩니다.

세 개를 쌓으면 7x7 필터와 같은 효과를 내죠. 예를 들어, 얼굴 인식 시스템이나 스타일 전이(Neural Style Transfer) 같은 응용 분야에서 VGGNet의 중간 계층 특징들이 매우 유용하게 사용됩니다.

기존 AlexNet이 다양한 크기의 필터(11x11, 5x5, 3x3)를 혼합했다면, VGGNet은 오직 3x3만 사용합니다. 이렇게 하면 구조가 매우 규칙적이 되어 이해하기 쉽고, GPU 최적화도 쉬워집니다.

VGGNet의 핵심 특징은 네 가지입니다. 첫째, 모든 합성곱이 3x3 크기로 통일되어 있습니다.

둘째, 합성곱 계층 뒤에 MaxPool을 배치하는 규칙적인 블록 구조를 가집니다. 셋째, 깊이가 깊어질수록(풀링을 거칠수록) 채널 수가 2배씩 증가합니다(64 -> 128 -> 256 -> 512).

넷째, 총 파라미터 수가 1억 3천만 개로 매우 크지만, 구조가 단순해서 디버깅과 수정이 쉽습니다. 이런 특징들이 모여 높은 정확도와 범용성을 동시에 달성합니다.

코드 예제

import torch
import torch.nn as nn

class VGG16(nn.Module):
    def __init__(self, num_classes=1000):
        super(VGG16, self).__init__()
        # VGG 블록 1: 64 채널, 2개 합성곱
        self.block1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 64, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)  # 크기 절반
        )
        # VGG 블록 2: 128 채널, 2개 합성곱
        self.block2 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(128, 128, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        # VGG 블록 3: 256 채널, 3개 합성곱
        self.block3 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        # VGG 블록 4: 512 채널, 3개 합성곱
        self.block4 = nn.Sequential(
            nn.Conv2d(256, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(512, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(512, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        # VGG 블록 5: 512 채널, 3개 합성곱
        self.block5 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(512, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(512, 512, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        # 분류기: 3개 완전연결 계층
        self.classifier = nn.Sequential(
            nn.Linear(512 * 7 * 7, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(4096, num_classes)
        )

    def forward(self, x):
        # 5개 블록을 순차적으로 통과
        x = self.block1(x)  # 224x224 -> 112x112
        x = self.block2(x)  # 112x112 -> 56x56
        x = self.block3(x)  # 56x56 -> 28x28
        x = self.block4(x)  # 28x28 -> 14x14
        x = self.block5(x)  # 14x14 -> 7x7
        x = x.view(x.size(0), -1)  # 평탄화
        x = self.classifier(x)  # 분류
        return x

설명

이것이 하는 일: VGGNet-16은 224x224 크기의 컬러 이미지를 받아서 1000개 카테고리 중 하나로 분류합니다. AlexNet보다 두 배 이상 깊지만, 훨씬 규칙적이고 이해하기 쉬운 구조를 가지고 있습니다.

첫 번째로, block1이 실행됩니다. 3채널 RGB 이미지가 64채널로 확장되면서, 3x3 합성곱이 두 번 적용됩니다.

첫 번째 합성곱에서 "빨간색 가로선", "파란색 세로선" 같은 색상과 방향이 결합된 기본 패턴을 감지합니다. 두 번째 합성곱에서 이런 패턴들의 조합을 학습합니다.

padding=1 덕분에 이미지 크기가 유지되다가, MaxPool로 절반(112x112)이 됩니다. 이렇게 크기를 줄이면서 점점 더 넓은 영역을 한 번에 볼 수 있게 됩니다.

두 번째로, block2와 block3가 동작합니다. block2에서는 64채널이 128채널로 늘어나면서, 더 다양한 중간 수준 특징("곡선 모양", "각진 모서리", "반복 패턴" 등)을 학습합니다.

block3에서는 256채널로 확장되고 합성곱이 3번 적용됩니다. 여기서부터 "질감", "물체의 일부분" 같은 의미 있는 특징들이 나타나기 시작합니다.

예를 들어 개의 귀, 고양이 눈, 자동차 바퀴 같은 것들이죠. 세 번째로, block4와 block5가 실행됩니다.

둘 다 512채널로 가장 많은 특징 맵을 생성합니다. block4에서는 "개 얼굴", "새의 날개", "건물의 창문" 같은 복잡한 부분들을 인식합니다.

block5는 가장 추상적인 특징을 학습하는데, 거의 완전한 물체에 가까운 수준입니다. "골든 리트리버의 특징", "보잉 747의 특징" 같은 것들이죠.

최종적으로 7x7x512 크기의 특징 맵이 생성됩니다. 마지막으로, 25088개(7x7x512)의 특징을 classifier가 처리합니다.

첫 번째와 두 번째 완전연결 계층에서 각각 4096개로 압축하면서, 드롭아웃으로 과적합을 방지합니다. 세 번째 계층에서 최종적으로 1000개 클래스 확률을 출력합니다.

여러분이 이 코드를 사용하면 ImageNet에서 top-5 정확도 약 90%를 달성할 수 있습니다. 구조가 단순하고 규칙적이어서 직관적으로 이해하기 쉽고, 다른 용도로 수정하기도 간편합니다.

특히 중간 계층(block3, block4)의 특징들이 매우 범용적이어서, 전이 학습으로 다양한 컴퓨터 비전 작업(객체 검출, 세그멘테이션, 스타일 전이 등)에 활용할 수 있습니다. 다만 파라미터가 1억 3천만 개로 매우 많아서 메모리 사용량이 크다는 점은 주의해야 합니다.

실전 팁

💡 VGG16보다 VGG19(19계층)가 약간 더 정확하지만, 학습 시간과 메모리 사용량이 크게 증가합니다. 실무에서는 VGG16이 성능과 효율의 균형이 좋아 더 많이 사용됩니다.

💡 입력 이미지는 ImageNet 평균값(RGB: [0.485, 0.456, 0.406])으로 정규화하고 표준편차([0.229, 0.224, 0.225])로 나누세요. 이것이 사전 학습된 가중치와 호환되는 방법입니다.

💡 전이 학습 시 block5만 미세 조정하고 나머지는 동결하면, 적은 데이터로도 좋은 결과를 얻을 수 있습니다. 데이터가 많으면 block4부터, 아주 많으면 전체를 미세 조정하세요.

💡 배치 정규화가 없어서 학습이 불안정할 수 있습니다. 학습률을 작게(0.001 이하) 시작하고, 웜업(warm-up) 전략을 사용하면 안정적으로 수렴합니다.

💡 메모리 절약이 필요하면 완전연결 계층(4096 -> 4096)을 더 작은 크기(2048 -> 2048)로 줄여도 됩니다. 정확도는 1-2% 정도만 떨어지고 메모리는 크게 절약됩니다.


4. 합성곱 계층의 원리 - 이미지의 패턴 찾기

시작하며

여러분이 큰 그림에서 특정 모양을 찾는다고 상상해보세요. 예를 들어 월리를 찾아라 게임처럼요.

그림 전체를 한 번에 보는 것보다, 작은 창문을 들고 조금씩 옮겨가며 확인하는 게 효율적입니다. 합성곱(Convolution)이 바로 이런 방식으로 동작합니다.

작은 필터(창문)가 이미지를 좌에서 우로, 위에서 아래로 훑으면서 특정 패턴이 있는지 확인하죠. 이것이 CNN의 가장 핵심적인 연산입니다.

이 개념을 이해하지 못하면 CNN을 제대로 다룰 수 없습니다. 반대로 이것을 완벽히 이해하면, 왜 CNN이 이미지에 그토록 효과적인지, 어떻게 계층을 설계해야 하는지 자연스럽게 알게 됩니다.

개요

간단히 말해서, 합성곱은 작은 필터를 이미지 위에 슬라이딩하며 각 위치에서 내적을 계산하는 연산입니다. 왜 이 연산이 중요할까요?

일반 신경망이 이미지의 모든 픽셀을 개별적으로 본다면, 합성곱은 "이웃 픽셀들의 관계"를 봅니다. 예를 들어, 3x3 필터는 9개 픽셀이 어떻게 배열되어 있는지 한 번에 파악합니다.

이렇게 하면 "가로선", "세로선", "모서리" 같은 지역적 패턴을 효과적으로 감지할 수 있습니다. 엣지 검출, 블러 효과, 샤프닝 같은 전통적인 이미지 처리 기법도 모두 합성곱의 특수한 경우입니다.

기존에는 이미지를 1차원 벡터로 펼쳐서 처리했다면, 합성곱은 이미지의 2차원 구조를 그대로 유지합니다. 덕분에 공간적 정보가 보존되고, 같은 패턴이 이미지 어디에 나타나든 감지할 수 있습니다(이를 위치 불변성이라고 합니다).

합성곱의 핵심 특징은 세 가지입니다. 첫째, 파라미터 공유로 같은 필터를 이미지 전체에 적용하여 파라미터 수를 크게 줄입니다.

둘째, 지역적 연결성으로 각 뉴런이 이미지의 일부분만 보기 때문에 계산 효율이 높습니다. 셋째, 계층적 특징 학습으로 낮은 계층에서는 간단한 패턴, 높은 계층에서는 복잡한 패턴을 학습합니다.

코드 예제

import torch
import torch.nn as nn
import torch.nn.functional as F

# 수동으로 합성곱 연산 이해하기
def manual_conv2d(input_img, kernel):
    """
    간단한 2D 합성곱 연산 구현
    input_img: (H, W) 크기의 입력 이미지
    kernel: (K, K) 크기의 필터
    """
    H, W = input_img.shape
    K = kernel.shape[0]
    # 출력 크기 계산 (패딩 없음)
    out_H = H - K + 1
    out_W = W - K + 1
    output = torch.zeros(out_H, out_W)

    # 필터를 슬라이딩하며 내적 계산
    for i in range(out_H):
        for j in range(out_W):
            # 현재 위치의 패치 추출
            patch = input_img[i:i+K, j:j+K]
            # 필터와 내적 (element-wise 곱셈 후 합)
            output[i, j] = (patch * kernel).sum()

    return output

# 실제 사용 예제: 수직 엣지 검출 필터
vertical_edge_kernel = torch.tensor([
    [-1.0, 0.0, 1.0],   # 왼쪽이 어두우면 음수
    [-1.0, 0.0, 1.0],   # 가운데는 무시
    [-1.0, 0.0, 1.0]    # 오른쪽이 밝으면 양수
])

# 간단한 테스트 이미지 (왼쪽 어둡고 오른쪽 밝음)
test_image = torch.tensor([
    [0.0, 0.0, 1.0, 1.0],
    [0.0, 0.0, 1.0, 1.0],
    [0.0, 0.0, 1.0, 1.0],
    [0.0, 0.0, 1.0, 1.0]
])

# 합성곱 적용
edge_map = manual_conv2d(test_image, vertical_edge_kernel)
print("수직 엣지 검출 결과:", edge_map)
# 가운데 열에서 큰 값이 나옴 (경계 검출)

설명

이것이 하는 일: 합성곱 연산은 이미지에서 특정 패턴(선, 모서리, 질감 등)이 어디에 있는지 찾아내는 작업입니다. 마치 도장을 찍듯이, 필터라는 작은 틀을 이미지 전체에 대보면서 얼마나 잘 맞는지 확인하는 것이죠.

첫 번째로, 함수 시작 부분에서 출력 크기를 계산합니다. 4x4 이미지에 3x3 필터를 적용하면, 필터가 움직일 수 있는 공간이 2x2밖에 안 됩니다.

그래서 출력도 2x2가 되는 거죠. 이것이 "크기가 줄어드는" 이유입니다.

만약 원본 크기를 유지하고 싶다면 패딩(padding)을 추가해야 합니다. 두 번째로, 이중 for 루프가 핵심입니다.

i와 j가 필터의 위치를 나타냅니다. 예를 들어 i=0, j=0일 때, 이미지의 왼쪽 위 3x3 영역을 잘라냅니다(patch).

그리고 이 patch와 kernel을 element-wise로 곱한 후 모두 더합니다. 이게 바로 내적(dot product) 연산입니다.

patch와 kernel이 비슷하면 큰 양수가, 반대면 큰 음수가, 관계없으면 0에 가까운 값이 나옵니다. 세 번째로, vertical_edge_kernel을 봅시다.

왼쪽 열이 -1, 가운데가 0, 오른쪽이 1입니다. 이 필터는 "왼쪽이 어둡고 오른쪽이 밝은" 패턴을 찾습니다.

test_image의 가운데 부분(0과 1이 만나는 곳)에 이 필터를 적용하면, 왼쪽 픽셀(0) × (-1) + 오른쪽 픽셀(1) × 1 = 0 + 1 = 강한 반응이 나옵니다. 반대로 모두 0이거나 모두 1인 영역에서는 반응이 약합니다.

마지막으로, edge_map 출력을 보면 수직 경계 부분에서만 큰 값이 나타납니다. 이것이 바로 엣지 검출입니다.

CNN은 이런 수작업 필터 대신, 데이터로부터 자동으로 최적의 필터 값들을 학습합니다. 첫 번째 계층에서는 엣지, 두 번째에서는 질감, 세 번째에서는 물체 부분...

이런 식으로 계층적으로 복잡해지는 거죠. 여러분이 이 코드를 실행하면 합성곱이 실제로 어떻게 동작하는지 직접 확인할 수 있습니다.

다른 필터(수평 엣지, 대각선, 블러 등)를 만들어 실험해보세요. 이해가 깊어질수록 CNN의 각 계층이 무엇을 학습하는지 해석할 수 있게 됩니다.

또한 stride(보폭)와 padding(테두리 채우기) 개념도 이 코드를 확장하면 쉽게 이해할 수 있습니다.

실전 팁

💡 padding=1을 추가하면 입력과 출력 크기를 같게 유지할 수 있습니다. 보통 "same" 패딩이라고 부르며, 깊은 네트워크에서 크기가 너무 빨리 줄어드는 것을 방지합니다.

💡 stride=2로 설정하면 필터가 2칸씩 건너뛰면서 이동하여, 출력 크기가 절반이 됩니다. 풀링 대신 stride 큰 합성곱을 사용하는 방법도 있습니다.

💡 실제 PyTorch의 nn.Conv2d는 위 코드보다 수천 배 빠릅니다. GPU에 최적화된 행렬 연산으로 구현되어 있기 때문입니다. 위 코드는 이해용이고, 실무에서는 항상 nn.Conv2d를 사용하세요.

💡 여러 입력 채널(RGB)과 여러 출력 채널을 처리하려면, 필터가 4차원 텐서(out_channels, in_channels, height, width)가 됩니다. 각 출력 채널마다 별도의 필터 세트를 학습합니다.

💡 합성곱 계층 뒤에는 거의 항상 활성화 함수(ReLU)를 붙입니다. 그래야 비선형성이 생겨서 복잡한 패턴을 학습할 수 있습니다.


5. 풀링 계층 - 중요한 정보만 남기기

시작하며

여러분이 고해상도 사진을 SNS에 올릴 때 자동으로 크기가 줄어드는 것을 경험해보셨죠? 하지만 사진의 내용은 여전히 알아볼 수 있습니다.

이처럼 크기는 줄이되 중요한 정보는 유지하는 것이 풀링의 목표입니다. CNN에서 합성곱을 계속하다 보면 특징 맵의 크기가 너무 커서 계산량이 폭발적으로 증가합니다.

또한 작은 위치 변화(1-2픽셀 이동)에도 민감하게 반응하면 일반화 성능이 떨어집니다. 풀링(Pooling)은 이 두 문제를 동시에 해결합니다.

크기를 줄여서 계산 효율을 높이고, 위치 변화에 둔감하게 만들어 일반화를 향상시키죠. 간단하지만 CNN에서 빠질 수 없는 핵심 요소입니다.

개요

간단히 말해서, 풀링은 특징 맵을 작은 영역으로 나눈 후, 각 영역에서 대표값 하나를 선택하여 크기를 줄이는 연산입니다. 왜 이것이 필요할까요?

합성곱 계층이 "엣지가 여기 있어!"라고 정확한 위치를 알려준다면, 풀링은 "엣지가 이 근처 어딘가에 있어"라고 대략적인 위치만 기억합니다. 이렇게 하면 물체가 1-2픽셀 이동해도 같은 특징으로 인식되어, 모델이 더 강건해집니다.

예를 들어, 얼굴 인식 시스템에서 얼굴이 약간 옆으로 치우쳐도 같은 사람으로 인식할 수 있게 됩니다. 기존에는 모든 위치 정보를 보존했다면, 풀링은 "어느 정도 근처에 있다"는 정보만 남깁니다.

이것이 오히려 일반화에 도움이 됩니다. 또한 파라미터가 전혀 없어서 학습할 것도 없고, 계산도 매우 빠릅니다.

풀링의 핵심 특징은 네 가지입니다. 첫째, 크기 축소로 계산량과 메모리 사용량을 크게 줄입니다.

둘째, 위치 불변성으로 작은 이동이나 변형에 강건합니다. 셋째, 파라미터가 없어서 과적합 위험이 없습니다.

넷째, 각 채널에 독립적으로 적용되어 채널 수는 유지됩니다.

코드 예제

import torch
import torch.nn as nn

# MaxPooling vs AveragePooling 비교
def compare_pooling():
    # 테스트 입력: 4x4 특징 맵
    feature_map = torch.tensor([
        [1.0, 3.0, 2.0, 4.0],
        [5.0, 6.0, 1.0, 2.0],
        [7.0, 8.0, 3.0, 1.0],
        [2.0, 1.0, 4.0, 5.0]
    ]).unsqueeze(0).unsqueeze(0)  # (1, 1, 4, 4) 형태로 변환

    # MaxPooling: 각 영역에서 최댓값 선택
    max_pool = nn.MaxPool2d(kernel_size=2, stride=2)
    max_result = max_pool(feature_map)
    print("MaxPooling 결과 (2x2):")
    print(max_result.squeeze())
    # [[6, 4],
    #  [8, 5]]

    # AveragePooling: 각 영역의 평균값 계산
    avg_pool = nn.AvgPool2d(kernel_size=2, stride=2)
    avg_result = avg_pool(feature_map)
    print("\nAveragePooling 결과 (2x2):")
    print(avg_result.squeeze())
    # [[3.75, 2.25],
    #  [4.5, 3.25]]

    return max_result, avg_result

# 풀링의 위치 불변성 테스트
def test_translation_invariance():
    # 원본 이미지: 가운데 밝은 점
    original = torch.zeros(1, 1, 8, 8)
    original[0, 0, 3:5, 3:5] = 1.0

    # 약간 이동한 이미지
    shifted = torch.zeros(1, 1, 8, 8)
    shifted[0, 0, 4:6, 4:6] = 1.0

    # 풀링 적용
    pool = nn.MaxPool2d(2, 2)
    original_pooled = pool(original)
    shifted_pooled = pool(shifted)

    print("원본 풀링 결과:", original_pooled.squeeze())
    print("이동 후 풀링 결과:", shifted_pooled.squeeze())
    # 두 결과가 매우 유사함 - 위치 불변성

설명

이것이 하는 일: 풀링은 큰 특징 맵을 작게 요약합니다. 마치 고해상도 사진을 썸네일로 만드는 것처럼, 전체적인 모양은 유지하면서 크기만 줄이는 작업이죠.

첫 번째로, MaxPooling을 봅시다. kernel_size=2, stride=2는 2x2 영역을 하나의 칸으로 압축한다는 뜻입니다.

4x4 입력의 왼쪽 위 2x2 영역은 [1, 3, 5, 6]입니다. 이 중 최댓값인 6을 선택합니다.

오른쪽 위는 [2, 4, 1, 2]에서 4를, 왼쪽 아래는 [7, 8, 2, 1]에서 8을, 오른쪽 아래는 [3, 1, 4, 5]에서 5를 선택합니다. 결과적으로 4x4가 2x2로 줄어들었습니다.

MaxPooling은 "가장 강하게 활성화된 지점"을 찾는 것과 같아서, 엣지나 특정 패턴의 존재 여부를 파악하는 데 유리합니다. 두 번째로, AveragePooling을 보면 같은 영역들의 평균을 계산합니다.

왼쪽 위 (1+3+5+6)/4 = 3.75가 됩니다. MaxPooling이 "가장 중요한 특징"에 집중한다면, AveragePooling은 "전체적인 분위기"를 파악합니다.

그래서 배경 정보가 중요하거나, 부드러운 특징 변화를 유지하고 싶을 때 사용합니다. LeNet-5는 AvgPooling을, AlexNet과 VGGNet은 MaxPooling을 사용합니다.

세 번째로, test_translation_invariance 함수를 실행해보면 흥미로운 결과를 볼 수 있습니다. 밝은 점이 (3,3) 위치에 있든 (4,4) 위치에 있든, 풀링 후 결과는 거의 비슷합니다.

왜냐하면 풀링이 2x2 영역 단위로 처리하기 때문에, 1-2픽셀 이동은 같은 영역 안에 머물거나 비슷한 패턴을 만들기 때문입니다. 이것이 바로 "위치 불변성"입니다.

마지막으로, 실제 CNN에서 풀링의 역할을 생각해봅시다. VGGNet의 경우 224x224 입력이 5번의 풀링을 거쳐 7x7이 됩니다.

크기가 1/32로 줄어들어, 최종 완전연결 계층의 입력 크기가 25088(7x7x512)이 됩니다. 만약 풀링이 없었다면 1606만(224x224x512)이 되어 계산이 불가능했을 겁니다.

여러분이 이 코드를 실행하면 풀링의 동작과 효과를 직접 확인할 수 있습니다. MaxPooling과 AvgPooling 중 무엇을 선택할지는 작업에 따라 다르지만, 일반적으로 이미지 분류에서는 MaxPooling이 더 나은 성능을 보입니다.

최근에는 풀링 대신 stride가 큰 합성곱을 사용하는 추세도 있지만, 풀링의 간결함과 효과는 여전히 매력적입니다.

실전 팁

💡 kernel_size=2, stride=2가 가장 일반적인 설정으로, 크기를 정확히 절반으로 줄입니다. 다른 값을 사용하면 출력 크기 계산이 복잡해집니다.

💡 Global Average Pooling(GAP)은 전체 특징 맵을 하나의 값으로 압축합니다. 완전연결 계층 대신 사용하면 파라미터를 크게 줄이고 과적합을 방지할 수 있습니다.

💡 풀링 계층 앞에는 보통 활성화 함수(ReLU)를 먼저 적용합니다. 순서는 Conv -> ReLU -> Pool이 일반적입니다.

💡 최근 연구(ResNet 등)에서는 풀링을 줄이고 stride=2 합성곱으로 크기를 조절하는 경향이 있습니다. 학습 가능한 파라미터로 다운샘플링하는 것이 더 유연하기 때문입니다.

💡 풀링 후에는 채널 수를 늘리는 것이 일반적입니다(VGGNet처럼). 공간 해상도는 줄지만 채널 수를 늘려서 표현력을 유지하는 전략입니다.


6. ReLU 활성화 함수 - 신경망의 비선형성

시작하며

여러분이 "이 숫자가 0보다 크면 그대로 쓰고, 아니면 0으로 만들어"라는 간단한 규칙을 들으면 "이게 뭐가 대단해?"라고 생각할 수 있습니다. 하지만 이 단순한 아이디어가 딥러닝 혁명의 핵심 중 하나입니다.

2000년대까지 신경망에서는 주로 sigmoid나 tanh 함수를 사용했습니다. 하지만 이들은 깊은 네트워크에서 "기울기 소실(gradient vanishing)" 문제를 일으켜 학습이 매우 어려웠죠.

2010년대 초반 ReLU(Rectified Linear Unit)가 주목받으면서 이 문제가 극적으로 개선되었습니다. AlexNet이 ReLU를 사용해서 학습 속도를 6배나 향상시켰고, 그 이후 거의 모든 CNN이 ReLU를 기본으로 사용합니다.

간단하지만 강력한, 딥러닝의 필수 요소입니다.

개요

간단히 말해서, ReLU는 입력이 양수면 그대로 출력하고, 음수면 0으로 만드는 활성화 함수입니다. 수식으로는 f(x) = max(0, x)입니다.

왜 이렇게 단순한 함수가 효과적일까요? 첫째, 계산이 매우 빠릅니다.

sigmoid는 지수 연산이 필요하지만, ReLU는 단순 비교와 선택만 하면 됩니다. 둘째, 양수 영역에서 기울기가 항상 1이어서 깊은 네트워크에서도 기울기가 잘 전달됩니다.

셋째, 생물학적 뉴런의 동작(임계값 이상일 때만 활성화)과 유사합니다. 예를 들어, 실시간 비디오 처리나 모바일 기기에서 실행되는 모델에서 ReLU의 빠른 계산 속도는 큰 장점입니다.

기존 sigmoid가 0과 1 사이로, tanh가 -1과 1 사이로 출력을 압축했다면, ReLU는 상한이 없습니다. 이것이 오히려 강한 신호를 그대로 전달할 수 있게 해서 표현력이 향상됩니다.

ReLU의 핵심 특징은 네 가지입니다. 첫째, 계산 복잡도가 O(1)로 매우 빠릅니다.

둘째, 양수 영역에서 선형이어서 기울기 소실 문제가 없습니다. 셋째, 희소성(sparsity)을 유도하여 음수 입력을 모두 0으로 만듭니다.

넷째, 학습 속도가 sigmoid보다 수배 빠릅니다. 다만 "dying ReLU" 문제(한 번 음수가 되면 영원히 0 출력)가 있지만, Leaky ReLU 등 변형으로 해결할 수 있습니다.

코드 예제

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np

# 다양한 활성화 함수 비교
def compare_activations():
    x = torch.linspace(-5, 5, 100)

    # ReLU: max(0, x)
    relu = nn.ReLU()
    y_relu = relu(x)

    # Leaky ReLU: max(0.01*x, x) - dying ReLU 문제 해결
    leaky_relu = nn.LeakyReLU(negative_slope=0.01)
    y_leaky = leaky_relu(x)

    # Sigmoid: 1 / (1 + e^(-x))
    sigmoid = nn.Sigmoid()
    y_sigmoid = sigmoid(x)

    # Tanh: (e^x - e^(-x)) / (e^x + e^(-x))
    tanh = nn.Tanh()
    y_tanh = tanh(x)

    print(f"ReLU(-2) = {relu(torch.tensor(-2.0))}")  # 0
    print(f"ReLU(3) = {relu(torch.tensor(3.0))}")    # 3
    print(f"Leaky ReLU(-2) = {leaky_relu(torch.tensor(-2.0))}")  # -0.02

    return x, y_relu, y_leaky, y_sigmoid, y_tanh

# Dying ReLU 문제 시연
def demonstrate_dying_relu():
    # 간단한 신경망
    layer = nn.Linear(10, 1)
    relu = nn.ReLU()

    # 크게 음수인 입력
    x = torch.randn(100, 10) - 5  # 평균 -5

    # 순전파
    output = relu(layer(x))

    # 0이 아닌 출력 비율 확인
    active_ratio = (output > 0).float().mean()
    print(f"활성화된 뉴런 비율: {active_ratio:.2%}")
    # 매우 낮을 수 있음 - dying ReLU 현상

설명

이것이 하는 일: ReLU는 신경망에 비선형성을 부여합니다. 만약 활성화 함수 없이 합성곱만 여러 개 쌓으면, 결국 하나의 선형 변환과 같아져서 복잡한 패턴을 학습할 수 없습니다.

ReLU가 중간중간 비선형 변환을 추가해서 신경망이 진짜 "깊어질" 수 있게 만듭니다. 첫 번째로, ReLU의 동작을 봅시다.

x가 -2일 때는 0을 출력하고, 3일 때는 3을 그대로 출력합니다. 이 간단한 규칙이 왜 중요할까요?

합성곱 계층에서 "강한 특징"(큰 양수 값)은 그대로 전달하고, "관련 없는 특징"(음수 값)은 버리는 효과를 냅니다. 마치 신호와 잡음을 분리하는 필터 같은 역할이죠.

두 번째로, sigmoid와 tanh와 비교해봅시다. sigmoid의 기울기는 최대 0.25이고, 입력이 크면 거의 0에 가까워집니다(기울기 소실).

10개 계층을 거치면 기울기가 0.25^10 = 0.0000001 수준으로 사라져서 학습이 안 됩니다. 반면 ReLU는 양수 영역에서 기울기가 항상 1이어서, 100개 계층을 쌓아도 기울기가 그대로 전달됩니다.

이것이 ResNet 같은 초깊은 네트워크를 가능하게 만든 핵심입니다. 세 번째로, Leaky ReLU를 봅시다.

ReLU(-2) = 0이지만, Leaky ReLU(-2) = -0.02입니다. 음수 영역에서도 아주 작은 기울기(0.01)를 가지기 때문에, "dying ReLU" 문제를 해결합니다.

dying ReLU란 한 뉴런의 가중치가 불운하게 업데이트되어 항상 음수를 출력하게 되면, 그때부터 기울기가 0이 되어 영원히 학습이 안 되는 현상입니다. 전체 뉴런의 10-20%가 "죽을" 수 있습니다.

마지막으로, demonstrate_dying_relu 함수를 보면 실제로 일부 뉴런이 거의 활성화되지 않는 것을 확인할 수 있습니다. 입력이 대부분 음수라서 ReLU 출력이 0이 되기 때문입니다.

이런 경우 Leaky ReLU나 ELU, GELU 같은 변형을 사용하면 더 안정적인 학습이 가능합니다. 여러분이 이 코드를 실행하면 각 활성화 함수의 특징을 시각적으로 비교할 수 있습니다.

대부분의 경우 ReLU가 기본 선택이지만, 특정 문제(예: GAN, 강화학습)에서는 다른 함수가 더 나을 수 있습니다. 최근에는 Swish나 GELU 같은 더 부드러운 활성화 함수도 인기를 얻고 있지만, ReLU의 간결함과 효과는 여전히 강력합니다.

실전 팁

💡 활성화 함수는 항상 합성곱이나 완전연결 계층 바로 뒤에 배치합니다. 순서는 Conv -> ReLU -> Pool이 표준입니다.

💡 배치 정규화를 사용할 때는 Conv -> BatchNorm -> ReLU 순서가 일반적입니다. ReLU를 먼저 하면 배치 정규화의 효과가 줄어듭니다.

💡 가중치 초기화를 He 초기화(nn.init.kaiming_normal_)로 하면 ReLU와 궁합이 좋습니다. Xavier 초기화는 sigmoid/tanh용입니다.

💡 Leaky ReLU의 negative_slope는 보통 0.01 또는 0.1을 사용합니다. 너무 크면 ReLU의 장점이 사라지고, 너무 작으면 효과가 없습니다.

💡 출력 계층(마지막 분류 계층)에는 활성화 함수를 사용하지 않거나, 분류 문제에서는 Softmax를 사용합니다. ReLU는 중간 계층 전용입니다.


7. 드롭아웃 - 과적합의 강력한 해결사

시작하며

여러분이 시험을 준비할 때 문제집의 정답을 통째로 외우면 어떻게 될까요? 문제집은 100점이지만, 실전 시험에서는 망하겠죠.

이것이 바로 "과적합(overfitting)"입니다. 신경망도 마찬가지입니다.

학습 데이터는 완벽하게 맞추지만, 새로운 데이터에서는 성능이 떨어지는 문제가 자주 발생합니다. 특히 AlexNet처럼 파라미터가 6천만 개나 되는 거대한 모델에서는 과적합이 심각한 문제였습니다.

2012년, Hinton 교수 연구팀이 제안한 드롭아웃(Dropout)은 이 문제를 놀랍도록 간단하고 효과적으로 해결했습니다. "학습할 때마다 무작위로 일부 뉴런을 꺼버리자"는 아이디어죠.

단순하지만, 정규화 기법 중 가장 강력한 방법 중 하나로 자리 잡았습니다.

개요

간단히 말해서, 드롭아웃은 학습 시 각 뉴런을 일정 확률(보통 50%)로 무작위로 비활성화시키는 정규화 기법입니다. 왜 이것이 과적합을 방지할까요?

매번 다른 뉴런들을 끄기 때문에, 네트워크는 특정 뉴런에 과도하게 의존할 수 없게 됩니다. 마치 팀 프로젝트에서 누가 빠져도 돌아가도록 준비하는 것과 비슷합니다.

결과적으로 모든 뉴런이 더 강건하고 일반적인 특징을 학습하게 됩니다. 예를 들어, 의료 영상 진단 모델에서 드롭아웃을 사용하면 학습 병원의 특정 촬영 장비에만 최적화되지 않고, 다양한 환경에서도 잘 작동합니다.

기존 L2 정규화가 가중치의 크기를 제한했다면, 드롭아웃은 네트워크의 구조 자체를 매번 변경합니다. 또한 앙상블(ensemble) 효과를 냅니다.

매번 다른 하위 네트워크를 학습시키는 것과 같아서, 테스트 시 이들을 평균하는 효과를 얻습니다. 드롭아웃의 핵심 특징은 네 가지입니다.

첫째, 학습 시에만 적용되고 테스트 시에는 모든 뉴런을 사용합니다(다만 출력에 확률을 곱해 조정). 둘째, 완전연결 계층에는 p=0.5, 합성곱 계층에는 p=0.1~0.2를 주로 사용합니다.

셋째, 추가 계산 비용이 거의 없습니다. 넷째, 데이터가 적을수록 효과가 큽니다.

코드 예제

import torch
import torch.nn as nn

# 드롭아웃 동작 원리 시연
def demonstrate_dropout():
    # 간단한 완전연결 계층
    fc = nn.Linear(10, 10)
    dropout = nn.Dropout(p=0.5)  # 50% 확률로 끄기

    # 입력 데이터
    x = torch.ones(1, 10)  # 모두 1인 입력

    # 학습 모드: 드롭아웃 활성화
    fc.train()
    dropout.train()

    output1 = dropout(fc(x))
    output2 = dropout(fc(x))
    output3 = dropout(fc(x))

    print("학습 모드 - 매번 다른 출력:")
    print("출력 1:", output1)
    print("출력 2:", output2)
    print("출력 3:", output3)
    # 같은 입력이지만 매번 다른 뉴런이 꺼져서 출력이 다름

    # 평가 모드: 드롭아웃 비활성화
    fc.eval()
    dropout.eval()

    output_eval1 = dropout(fc(x))
    output_eval2 = dropout(fc(x))

    print("\n평가 모드 - 항상 같은 출력:")
    print("출력 1:", output_eval1)
    print("출력 2:", output_eval2)
    # 드롭아웃이 꺼져서 항상 같은 출력

# 드롭아웃의 과적합 방지 효과
class SimpleNetWithDropout(nn.Module):
    def __init__(self, use_dropout=True):
        super().__init__()
        self.fc1 = nn.Linear(784, 256)
        self.fc2 = nn.Linear(256, 128)
        self.fc3 = nn.Linear(128, 10)
        self.relu = nn.ReLU()
        self.use_dropout = use_dropout
        if use_dropout:
            # 각 완전연결 계층 사이에 드롭아웃 배치
            self.dropout1 = nn.Dropout(0.5)
            self.dropout2 = nn.Dropout(0.5)

    def forward(self, x):
        x = self.relu(self.fc1(x))
        if self.use_dropout:
            x = self.dropout1(x)  # 학습 시 50% 뉴런 끄기

        x = self.relu(self.fc2(x))
        if self.use_dropout:
            x = self.dropout2(x)

        x = self.fc3(x)  # 출력 계층에는 드롭아웃 X
        return x

설명

이것이 하는 일: 드롭아웃은 신경망이 학습 데이터만 외우지 않고, 진짜 일반적인 패턴을 배우도록 강제합니다. 마치 연습할 때는 한 손을 뒤로 묶고 하다가, 실전에서는 두 손을 다 쓰는 것처럼요.

첫 번째로, demonstrate_dropout 함수의 학습 모드 부분을 봅시다. 같은 입력 x를 세 번 넣었는데 출력이 매번 다릅니다.

왜 그럴까요? dropout.train() 모드에서는 매번 50%의 뉴런을 무작위로 선택해서 출력을 0으로 만들기 때문입니다.

예를 들어 첫 번째는 1, 3, 5번 뉴런을, 두 번째는 2, 4, 7번 뉴런을 끄는 식이죠. 그리고 살아남은 뉴런의 출력에는 2를 곱합니다(1/0.5 = 2).

왜냐하면 절반만 쓰니까 신호를 2배로 증폭해서 원래 크기를 유지하는 겁니다. 두 번째로, 평가 모드를 보면 완전히 다릅니다.

dropout.eval() 모드에서는 드롭아웃이 완전히 비활성화되어, 모든 뉴런이 항상 작동합니다. 따라서 같은 입력에 항상 같은 출력이 나옵니다.

이것이 매우 중요한 이유는, 테스트나 실제 사용 시에는 예측이 일관되어야 하기 때문입니다. "같은 이미지를 넣었는데 매번 다른 결과가 나온다"면 시스템을 신뢰할 수 없겠죠.

세 번째로, SimpleNetWithDropout 클래스를 봅시다. 드롭아웃은 활성화 함수(ReLU) 바로 뒤, 다음 계층 앞에 배치됩니다.

fc1 -> ReLU -> Dropout -> fc2 -> ReLU -> Dropout -> fc3 순서입니다. 중요한 것은 마지막 출력 계층(fc3) 뒤에는 드롭아웃을 쓰지 않는다는 점입니다.

최종 예측을 무작위로 방해하면 안 되니까요. 마지막으로, 왜 과적합이 줄어드는지 직관을 생각해봅시다.

만약 드롭아웃이 없으면, 모델이 "뉴런 A와 B가 동시에 활성화되면 고양이다"라고 외울 수 있습니다. 하지만 드롭아웃으로 A나 B 중 하나가 자주 꺼지면, "A만 봐도 고양이를 알 수 있어야 하고, B만 봐도 알 수 있어야 해"라고 학습하게 됩니다.

결과적으로 각 뉴런이 더 독립적이고 강건한 특징을 학습하게 되는 거죠. 여러분이 이 코드를 사용하면 작은 데이터셋에서도 과적합을 크게 줄일 수 있습니다.

AlexNet 논문에서는 드롭아웃이 없으면 과적합이 심했지만, p=0.5 드롭아웃만 추가해도 테스트 정확도가 크게 향상되었다고 보고합니다. 다만 학습 시간은 약 2배 더 걸릴 수 있습니다(매번 절반의 뉴런만 사용하니까).

실전 팁

💡 model.train()과 model.eval()을 올바르게 호출하는 것이 핵심입니다. 학습 루프에서는 train(), 검증/테스트에서는 eval()을 반드시 호출하세요. 이것을 빠뜨리면 성능이 크게 떨어집니다.

💡 완전연결 계층에는 p=0.5, 합성곱 계층에는 p=0.2 정도가 일반적입니다. 합성곱은 이미 파라미터 공유로 정규화 효과가 있어서 드롭아웃을 덜 사용합니다.

💡 데이터 증강, 배치 정규화와 함께 사용하면 과적합 방지 효과가 더욱 강력해집니다. 세 가지를 조합하면 데이터가 적어도 좋은 성능을 낼 수 있습니다.

💡 드롭아웃 비율이 너무 높으면(0.7 이상) 언더피팅이 발생할 수 있습니다. 학습 정확도도 낮아지면 드롭아웃을 줄이세요.

💡 최근에는 드롭아웃 대신 배치 정규화만 사용하는 경우도 많습니다(ResNet 등). 하지만 둘을 함께 쓸 때는 BatchNorm -> ReLU -> Dropout 순서가 일반적입니다.


8. 배치 정규화 - 학습 안정화의 게임 체인저

시작하며

여러분이 여러 팀과 협업하는데, 각 팀이 완전히 다른 단위(cm, inch, meter)를 사용한다면 혼란스럽겠죠? 모두가 같은 기준을 사용하면 협업이 훨씬 수월해집니다.

신경망도 마찬가지입니다. 각 계층의 입력 분포가 학습 중에 계속 변한다면, 다음 계층은 매번 새로운 분포에 적응해야 해서 학습이 매우 느리고 불안정합니다.

이를 "내부 공변량 변화(Internal Covariate Shift)"라고 부릅니다. 2015년 구글 연구팀이 발표한 배치 정규화(Batch Normalization)는 이 문제를 해결하여, 학습 속도를 크게 높이고 더 깊은 네트워크를 안정적으로 학습할 수 있게 만들었습니다.

지금은 거의 모든 현대 CNN의 표준 구성 요소가 되었습니다.

개요

간단히 말해서, 배치 정규화는 각 계층의 입력을 미니배치 단위로 정규화(평균 0, 분산 1)하여 학습을 안정화시키는 기법입니다. 왜 이것이 효과적일까요?

첫째, 각 계층이 항상 비슷한 범위의 입력을 받기 때문에 학습이 안정적입니다. 둘째, 더 큰 학습률을 사용할 수 있어서 학습 속도가 빨라집니다.

셋째, 가중치 초기화에 덜 민감해져서 초기값 선택이 쉬워집니다. 넷째, 드롭아웃과 비슷한 정규화 효과가 있어 과적합을 줄입니다.

예를 들어, ResNet-50을 배치 정규화 없이 학습시키면 수렴이 매우 어렵지만, 배치 정규화를 사용하면 안정적으로 학습됩니다. 기존에는 가중치 초기화와 작은 학습률에 의존했다면, 이제는 배치 정규화가 자동으로 입력을 조절해줍니다.

또한 학습 시와 테스트 시 동작이 약간 다릅니다. 학습 시는 현재 배치의 통계를, 테스트 시는 학습 중 계산한 전체 평균을 사용합니다.

배치 정규화의 핵심 특징은 네 가지입니다. 첫째, 각 미니배치의 평균과 분산으로 정규화합니다.

둘째, 학습 가능한 스케일(γ)과 이동(β) 파라미터로 표현력을 유지합니다. 셋째, 합성곱 계층과 활성화 함수 사이에 배치합니다(Conv -> BN -> ReLU).

넷째, 학습 속도를 5-10배 향상시키고 정확도도 개선합니다.

코드 예제

import torch
import torch.nn as nn

# 배치 정규화 동작 원리
def demonstrate_batch_norm():
    # 2D 합성곱용 배치 정규화 (채널마다 정규화)
    bn = nn.BatchNorm2d(num_features=3)  # 3채널(RGB)

    # 입력: (배치크기=4, 채널=3, 높이=2, 너비=2)
    x = torch.randn(4, 3, 2, 2) * 10 + 50  # 평균 50, 큰 분산

    print("입력 통계 (채널별):")
    for i in range(3):
        print(f"채널 {i}: 평균={x[:, i].mean():.2f}, 표준편차={x[:, i].std():.2f}")

    # 학습 모드: 현재 배치로 정규화
    bn.train()
    output = bn(x)

    print("\n배치 정규화 후 (학습 모드):")
    for i in range(3):
        print(f"채널 {i}: 평균={output[:, i].mean():.4f}, 표준편차={output[:, i].std():.4f}")
    # 평균이 거의 0, 표준편차가 거의 1로 정규화됨

# 배치 정규화가 포함된 CNN 블록
class ConvBNReLU(nn.Module):
    """표준 CNN 블록: Conv -> BatchNorm -> ReLU"""
    def __init__(self, in_channels, out_channels):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, out_channels,
                             kernel_size=3, padding=1, bias=False)  # BN 사용 시 bias 불필요
        self.bn = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        x = self.conv(x)      # 합성곱 적용
        x = self.bn(x)        # 배치 정규화로 안정화
        x = self.relu(x)      # 비선형성 추가
        return x

# 배치 정규화의 학습 효과 비교
class SimpleNet(nn.Module):
    def __init__(self, use_bn=True):
        super().__init__()
        self.use_bn = use_bn

        self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
        self.conv2 = nn.Conv2d(64, 128, 3, padding=1)

        if use_bn:
            self.bn1 = nn.BatchNorm2d(64)
            self.bn2 = nn.BatchNorm2d(128)

        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.conv1(x)
        if self.use_bn:
            x = self.bn1(x)  # 정규화로 안정화
        x = self.relu(x)

        x = self.conv2(x)
        if self.use_bn:
            x = self.bn2(x)
        x = self.relu(x)

        return x

설명

이것이 하는 일: 배치 정규화는 각 계층이 일관된 입력 분포를 받도록 자동으로 조절합니다. 마치 여러 공장에서 들어오는 부품들의 크기를 표준화하는 것처럼, 신경망의 각 계층도 표준화된 입력을 받게 되어 학습이 훨씬 수월해집니다.

첫 번째로, demonstrate_batch_norm 함수를 봅시다. 입력 x는 평균 50, 큰 분산을 가진 불규칙한 데이터입니다.

하지만 배치 정규화를 통과하면 각 채널의 평균이 거의 0, 표준편차가 거의 1로 바뀝니다. 어떻게 이렇게 될까요?

BN은 각 채널마다 독립적으로 (x - mean) / sqrt(variance)를 계산합니다. 예를 들어 채널 0의 모든 값(배치 4개 × 2×2 픽셀 = 16개 값)의 평균과 분산을 구해서 정규화하는 거죠.

두 번째로, ConvBNReLU 클래스의 구조를 주목하세요. 합성곱에서 bias=False로 설정했습니다.

왜냐하면 배치 정규화가 β(학습 가능한 bias) 파라미터를 가지고 있어서, 합성곱의 bias는 불필요하기 때문입니다. 이렇게 하면 파라미터를 절약할 수 있습니다.

순서는 Conv -> BN -> ReLU가 표준입니다. BN을 ReLU 앞에 두는 이유는, 정규화된 값이 ReLU를 통과하면서 적절한 비선형성을 얻기 때문입니다.

세 번째로, 배치 정규화의 학습 가능한 파라미터를 이해해봅시다. BN은 단순히 평균 0, 분산 1로 만들고 끝나는 게 아닙니다.

그 후 y = γ * x_normalized + β를 계산합니다. γ(스케일)와 β(이동)는 학습 중 최적값을 찾습니다.

왜 이렇게 할까요? 만약 정규화만 하면 네트워크의 표현력이 제한될 수 있습니다.

γ와 β를 학습하면 필요하다면 정규화를 "되돌릴" 수도 있어서, 표현력을 유지하면서도 학습 안정성을 얻습니다. 마지막으로, 학습 모드와 평가 모드의 차이를 알아야 합니다.

학습 시에는 현재 미니배치의 평균/분산을 사용하지만, 이 값들의 이동 평균(running mean/variance)을 계속 추적합니다. 평가 시(model.eval())에는 학습 중 축적한 이 이동 평균을 사용합니다.

왜냐하면 테스트 시에는 배치 크기가 1일 수도 있고, 매번 다른 통계를 쓰면 예측이 불안정하기 때문입니다. 여러분이 이 코드를 사용하면 깊은 네트워크도 안정적으로 학습시킬 수 있습니다.

실제로 ResNet, Inception, DenseNet 같은 현대 아키텍처는 모두 배치 정규화를 사용합니다. 학습률을 10배 크게 설정해도 발산하지 않고, 학습 에포크 수를 절반으로 줄일 수 있습니다.

다만 배치 크기가 너무 작으면(2-4) 통계가 불안정해서 효과가 떨어지니, 최소 16 이상을 권장합니다.

실전 팁

💡 배치 정규화를 사용하면 학습률을 10배 정도 크게 설정할 수 있습니다. 0.001 대신 0.01로 시작해보세요. 학습 속도가 크게 빨라집니다.

💡 Conv -> BN -> ReLU 순서가 원 논문의 방식이지만, Conv -> ReLU -> BN도 가능합니다. 최근 연구에서는 두 방식의 성능 차이가 크지 않다고 보고됩니다.

💡 전이 학습 시 사전 학습된 BN 계층을 동결(freeze)하면 안 됩니다. BN의 running stats는 새 데이터 분포에 맞춰 업데이트되어야 합니다.

💡 배치 크기가 작으면 Layer Normalization이나 Group Normalization을 대신 사용하세요. 이들은 배치 크기에 독립적입니다.

💡 배치 정규화와 드롭아웃을 함께 쓸 때는 BN -> ReLU -> Dropout 순서가 일반적입니다. 하지만 BN만으로도 충분한 정규화 효과가 있어서, 드롭아웃을 생략하는 경우도 많습니다.


#CNN#LeNet#AlexNet#VGGNet#DeepLearning#Data Science

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

데이터 증강과 정규화 완벽 가이드

머신러닝 모델의 성능을 극대화하는 핵심 기법인 데이터 증강과 정규화에 대해 알아봅니다. 실무에서 바로 활용할 수 있는 다양한 기법과 실전 예제를 통해 과적합을 방지하고 모델 성능을 향상시키는 방법을 배웁니다.

ResNet과 Skip Connection 완벽 가이드

딥러닝 모델이 깊어질수록 성능이 떨어지는 문제를 해결한 혁신적인 기법, ResNet과 Skip Connection을 초급자도 이해할 수 있도록 쉽게 설명합니다. 실제 구현 코드와 함께 배워보세요.

CNN 기초 Convolution과 Pooling 완벽 가이드

CNN의 핵심인 Convolution과 Pooling을 초급자도 쉽게 이해할 수 있도록 설명합니다. 이미지 인식의 원리부터 실제 코드 구현까지, 실무에서 바로 활용 가능한 내용을 담았습니다.

TensorFlow와 Keras 완벽 입문 가이드

머신러닝과 딥러닝의 세계로 들어가는 첫걸음! TensorFlow와 Keras 프레임워크를 처음 접하는 분들을 위한 친절한 가이드입니다. 실무에서 바로 활용할 수 있는 핵심 개념과 예제를 통해 AI 모델 개발의 기초를 탄탄히 다져보세요.

PyTorch Dataset과 DataLoader 완벽 가이드

딥러닝 모델을 학습시킬 때 데이터를 효율적으로 다루는 방법을 배웁니다. PyTorch의 Dataset과 DataLoader를 사용하여 대용량 데이터를 메모리 효율적으로 처리하고, 배치 처리와 셔플링을 자동화하는 방법을 실무 예제와 함께 알아봅니다.