⊢ DeepLearning

ResNet(Residual Network)

최 수빈 2025. 3. 21. 01:35

 

ResNet(Residual Network)

 

깊은 신경망을 효과적으로 학습하기 위해 개발된 모델

 

잔차 학습(Residual Learning) 개념을 도입하여 기울기 소실(Vanishing Gradient)문제를 해결

2015년 Microsoft Research에서 개발었으며, ImageNet 챌린지(ILSVRC) 2015에서 우승 

 

 

 

신경망의 깊이가 깊어질수록 더 복잡한 패턴을 학습 할 수 있지만, 오차 역전파 시 기울기가 매우 작아지거나 커져 가중치 업데이트가 제대로 이루어지지 않는 기울기 소실(Vanishing Gradient) 또는 기울기 폭발(Exploding Gradient) 문제로 인해 학습이 어려워 짐

→ 네트워크를 깊게 쌓을수록 성능이 오히려 저하되는 문제 발생

 

잔차 학습(Residual Learning)

 

ResNet은 잔차(Residual)를 학습하는 구조를 사용

 

일반적인 신경망은 각 레이어의 출력을 바로 다음 레이어로 전달하지만, ResNet에서는 입력을 출력에 더하는(Shorcut Connection) 방식을 사용

→ 기울기가 원활하게 전달되면서 깊은 네트워크에서도 안정적인 학습이 가능

 

잔차 블록(Residual Block)의 수식 표현

일반적인 네트워크의 출력

Y = F(X)

 

ResNet의 출력 (잔차 학습 적용)

Y = F(X) + X

여기서 F(X)는 컨볼루션 연산을 의미하며, X를 직접 더해주는 것이 핵심

 

 

ResNet의 주요 특징

 

기울기 소실 문제 해결

 

Shortcut Connection을 통해 원래 입력을 출력과 더해주어 신호가 손실되지 않고 전달

→ 깊은 네트워크에서도 학습이 원활하게 이루어짐

 

 

Residual Block을 통한 네트워크 확장

 

Residual Block을 사용하여 쉽게 네트워크를 깊게 확장 가능

ResNet-18, ResNet-34, ResNet-50, ResNet-101, ResNet-152 등 다양한 버전 존재

 

*ResNet-50 / ResNet-101 / ResNet-152는 ImageNet 데이터셋에서 높은 정확도를 기록함

→ 더 깊은 모델이더라도 성능이 하락하지 않고 안정적으로 유지됨

 

 

높은 성능과 다양한 응용 분야

 

이미지 분류(Image Classification), 객체 탐지(Objet Detection), 시맨틱 세그멘테이션(Semantic Segmentation), GAN(Generative Adversarial Network, 생성적 적대 신경망) 등 컴퓨터 비전 분야에서 우수한 성능을 발휘

 

ResNet 모델 구조

 

Residual Block (기본 블록)

ResNet의 핵심 단위

입력을 직접 출력과 더하는 Shorcut Connection을 포함하고 있음

 

  1. Conv → BatchNorm → ReLU
  2. Conv → BatchNorm
  3. 입력값과 합산 후 활성화 함수 적용

 

ResNet 전체 구조

  1. 초기 계층 : ConV + BatchNorm + ReLU
  2. Residual Blocks : 여러 개의 Residual Block이 포함됨 (ResNet-18 기준: [2, 2, 2, 2])
  3. Global Average Pooling
  4. Fully Connected Layer (출력층)

 

 

ResNet 구현 (PyTorch)

 

PyTorch를 사용한 ResNet-18 구현

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

# Residual Block 정의
class Block(nn.Module):
    def __init__(self, in_ch, out_ch, stride=1):
        """
        Residual Block (잔차 블록)
        - 두 개의 3x3 컨볼루션 레이어(conv1, conv2)와 Batch Normalization을 포함
        - 입력이 shortcut을 통해 출력에 더해지는 구조

        Args:
        - in_ch (int): 입력 채널 수
        - out_ch (int): 출력 채널 수
        - stride (int, optional): 첫 번째 컨볼루션의 stride (기본값: 1)
        """
        super(Block, self).__init__()
        # 첫 번째 3x3 컨볼루션 레이어 (stride 적용 가능)
        self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_ch) # 배치 정규화
        # 두 번째 3x3 컨볼루션 레이어 (채널 수 유지, stride=1)
        self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_ch) # 배치 정규화

        # 입력과 출력 채녈이 다를 경우 1x1 Conv를 사용하여 차원 맞추기
        self.skip_connection = nn.Sequential()
        if stride != 1 or in_ch != out_ch:
            self.skip_connection = nn.Sequential(
                nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_ch) # 배치 정규화
            )

    def forward(self, x):
        """
        Forward 함수 (잔차 학습 적용)
        - 입력 데이터 (x)를 컨볼루션과 배치 정규화를 거쳐 변환
        - Shortcut Connection을 통해 원래 입력(x)을 출력에 더함
        """
        # 첫 번째 컨볼루션 + ReLU 활성화 함수
        output = F.relu(self.bn1(self.conv1(x)))
        # 두 번째 컨볼루션 후 배치 정규화
        output = self.bn2(self.conv2(output))
        # shortcut 경로 출력과 현재 블록의 출력 더하기
        output += self.skip_connection(x) # Residual 연결
        # 최종 ReLU 활성화 함수 적용
        output = F.relu(output)
        return output

# ResNet 모델 정의
class CustomResNet(nn.Module):
    def __init__(self, block, layers, num_classes=10):
        """
        Custom ResNet 모델
        - 첫 번째 컨볼루션 레이어와 배치 정규화 적용
        - Residual Block을 쌓아 네트워크 구성
        - 최종적으로 Fully Connected Layer를 통해 분류 수행

        Args:
        - block (nn.Module): Residual Block 클래스
        - layers (list): 각 단계에서 사용할 Residual Block의 개수
        - num_classes (int, optional): 최종 분류할 클래스 개수 (기본값: 10)
        """
        super(CustomResNet, self).__init__()
        
        self.initial_channels = 64 # 첫 번째 레이어의 입력 채널 수 정의
        
        # 첫 번째 컨볼루션 레이어 (입력: 3채널 이미지)
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64) # 배치 정규화
        
        # ResNet의 각 레이어 생성 (Residual Block을 여러 개 쌓음)
        self.layer1 = self._create_layer(block, 64, layers[0], stride=1)
        self.layer2 = self._create_layer(block, 128, layers[1], stride=2)
        self.layer3 = self._create_layer(block, 256, layers[2], stride=2)
        self.layer4 = self._create_layer(block, 512, layers[3], stride=2)
        
        # 평균 풀링 레이어 (AdaptiveAvgPool2d: 입력 크기에 관계없이 1x1로 변환)
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        
        # 최종 완전 연결 레이어 (출력: num_classes)
        self.fc = nn.Linear(512, num_classes)

    # ResNet의 각 레이어를 생성하는 함수
    def _create_layer(self, block, out_ch, num_layers, stride):
        """
        Residual Block을 여러 개 쌓아 하나의 레이어를 구성하는 함수

        Args:
        - block (nn.Module): Residual Block 클래스
        - out_ch (int): 출력 채널 수
        - num_layers (int): 해당 레이어에서 사용할 Residual Block 개수
        - stride (int): 첫 번째 블록에서 적용할 stride 값

        Returns:
        - nn.Sequential: 구성된 Residual Layer
        """
        layers= []
        # 첫 번째 블록은 stride를 받을 수 있음
        layers.append(block(self.initial_channels, out_ch, stride))
        self.initial_channels = out_ch # 다음 불록을 위해 채널 수 업데이트
        
        # 나머지 블록들은 기본 stride를 사용
        for _ in range(1, num_layers):
            layers.append(block(out_ch, out_ch))
        
        return nn.Sequential(*layers)

    def forward(self, x):
        """
        Forward 함수 (ResNet 모델의 순전파 과정)
        - 첫 번째 컨볼루션을 거친 후, 여러 개의 Residual Block을 순차적으로 통과
        - 마지막으로 Fully Connected Layer를 거쳐 최종 클래스 확률을 출력
        """
        # 첫 번째 컨볼루션 + ReLU 활성화 함수
        x = F.relu(self.bn1(self.conv1(x)))
        # 각 레이어를 순차적으로 통과
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        # 평균 풀링 및 텐서의 차원 축소
        x = self.avgpool(x)
        x = torch.flatten(x, 1) # 1차원 벡터로 변환
        # 최종 완전 연결 레이어를 통해 클래스별 예측값 출력
        x = self.fc(x)
        return x

# ResNet-18 모델 생성 (각 레이어의 블록 수 : [2, 2, 2, 2])
model = CustomResNet(Block, [2, 2, 2, 2], num_classes=10)

 

import torch
import torchvision
import torchvision.transforms as transforms

# 데이터 변환 (Normalization 포함)
transform = transforms.Compose([
    transforms.ToTensor(),  # 이미지를 Tensor로 변환
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))  # 정규화
])

# CIFAR-10 데이터셋 다운로드 및 로드
batch_size = 128

trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=True, num_workers=2)

testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size, shuffle=False, num_workers=2)

# CIFAR-10 클래스 목록
classes = ('airplane', 'automobile', 'bird', 'cat', 'deer', 
           'dog', 'frog', 'horse', 'ship', 'truck')
import torch.optim as optim

# GPU 사용 여부 확인
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

# 모델 생성 및 GPU로 이동
model = CustomResNet(Block, [2, 2, 2, 2], num_classes=10).to(device)

# 손실 함수 (CrossEntropy Loss) 및 최적화 함수 (Adam 사용)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 학습 루프
num_epochs = 10  # 학습 횟수
for epoch in range(num_epochs):
    running_loss = 0.0
    correct = 0
    total = 0

    for i, (inputs, labels) in enumerate(trainloader):
        inputs, labels = inputs.to(device), labels.to(device)  # GPU 이동

        optimizer.zero_grad()  # 기존 기울기 초기화
        outputs = model(inputs)  # 모델 예측
        loss = criterion(outputs, labels)  # 손실 계산
        loss.backward()  # 역전파 (Backpropagation)
        optimizer.step()  # 가중치 업데이트

        # 손실 및 정확도 기록
        running_loss += loss.item()
        _, predicted = torch.max(outputs, 1)
        correct += (predicted == labels).sum().item()
        total += labels.size(0)

        if i % 100 == 99:  # 100배치마다 진행 상황 출력
            print(f"[Epoch {epoch+1}, Batch {i+1}] Loss: {running_loss / 100:.4f}, Accuracy: {100 * correct / total:.2f}%")
            running_loss = 0.0
    
"""
Using device: cpu
[Epoch 1, Batch 100] Loss: 1.7272, Accuracy: 36.34%
[Epoch 1, Batch 200] Loss: 1.3554, Accuracy: 43.12%
[Epoch 1, Batch 300] Loss: 1.1655, Accuracy: 47.93%
[Epoch 2, Batch 100] Loss: 0.8738, Accuracy: 68.91%
[Epoch 2, Batch 200] Loss: 0.8252, Accuracy: 69.99%
[Epoch 2, Batch 300] Loss: 0.7599, Accuracy: 71.09%
[Epoch 3, Batch 100] Loss: 0.6132, Accuracy: 79.12%
[Epoch 3, Batch 200] Loss: 0.5797, Accuracy: 79.45%
[Epoch 3, Batch 300] Loss: 0.5677, Accuracy: 79.65%
[Epoch 4, Batch 100] Loss: 0.4474, Accuracy: 84.36%
[Epoch 4, Batch 200] Loss: 0.4410, Accuracy: 84.59%
[Epoch 4, Batch 300] Loss: 0.4530, Accuracy: 84.48%
[Epoch 5, Batch 100] Loss: 0.3366, Accuracy: 88.33%
[Epoch 5, Batch 200] Loss: 0.3562, Accuracy: 88.13%
[Epoch 5, Batch 300] Loss: 0.3547, Accuracy: 87.97%
[Epoch 6, Batch 100] Loss: 0.2294, Accuracy: 92.12%
[Epoch 6, Batch 200] Loss: 0.2708, Accuracy: 91.31%
[Epoch 6, Batch 300] Loss: 0.2757, Accuracy: 90.92%
[Epoch 7, Batch 100] Loss: 0.1596, Accuracy: 94.44%
[Epoch 7, Batch 200] Loss: 0.1982, Accuracy: 93.71%
[Epoch 7, Batch 300] Loss: 0.2070, Accuracy: 93.44%
[Epoch 8, Batch 100] Loss: 0.1244, Accuracy: 95.66%
[Epoch 8, Batch 200] Loss: 0.1199, Accuracy: 95.75%
[Epoch 8, Batch 300] Loss: 0.1443, Accuracy: 95.51%
[Epoch 9, Batch 100] Loss: 0.0861, Accuracy: 97.11%
[Epoch 9, Batch 200] Loss: 0.0983, Accuracy: 96.79%
[Epoch 9, Batch 300] Loss: 0.1032, Accuracy: 96.65%
[Epoch 10, Batch 100] Loss: 0.0829, Accuracy: 96.96%
[Epoch 10, Batch 200] Loss: 0.0678, Accuracy: 97.30%
[Epoch 10, Batch 300] Loss: 0.0866, Accuracy: 97.20%
"""
# 모델 평가 모드 설정
model.eval()

correct = 0
total = 0

with torch.no_grad():  # 테스트 과정에서는 기울기 계산 X (속도 향상)
    for images, labels in testloader:
        images, labels = images.to(device), labels.to(device)
        outputs = model(images)
        _, predicted = torch.max(outputs, 1)  # 가장 높은 확률의 클래스 선택
        correct += (predicted == labels).sum().item()
        total += labels.size(0)

# 최종 정확도 출력
print(f"Test Accuracy: {100 * correct / total:.2f}%")

"""
Test Accuracy: 83.19%
"""

 

Custom ResNet-10이 예측한 이미지

 


CPU로 돌렸더니 약 7-8시간 걸렸다.. MPS로 재시도 하는 중

CNN 성능은 약 75%정도 나왔던 것 같은데.. 나름 만족