본문 바로가기
딥러닝 with Python

[딥러닝 with 파이썬] ResNet(잔차신경망)의 개념 (2/2) / CIFAR-10 활용해서 이미지 분류모델 구현

by CodeCrafter 2023. 10. 1.
반응형

 

 이번에는 저번 시간에 알아본 Residual Network의 개념을 바탕으로, Residual Block을 만들어보고 이를 쌓아간 Residual Network를 파이썬 코드로 구현해보겠습니다.

 

 만들어진 모델은 CIFAR-10 데이터 셋을 분류하면서 그 효과를 알아보도록 하겠습니다.

 

 

1. CIFAR-10 데이터 셋이란?

- CIFAR-10 이란, 32x32 크기의 컬러 이미지 60,000개로 구성된 이미지 분류 데이터셋을 말합니다. 

 * 이때 뒤에 붙은 10은, 각 이미지의 종류(클래스)가 10개라는 것을 의미합니다.

 * 또한, 각 클래스의 분포는 균등한데요. 즉, 60,000개 중 1/10인 6,000개씩 균등하게 클래스 별 이미지가 데이터셋을 구축한다는 것을 의미합니다.

 

- CIFAR-10 데이터 셋의 예시 및 클래스는 아래와 같습니다.

 

- 데이터를 다운로드 하기 위해서는

1) 아래 사이트를 이용해서 직접 다운로드를 받으실 수도 있으며

https://www.cs.toronto.edu/~kriz/cifar.html

 

CIFAR-10 and CIFAR-100 datasets

< Back to Alex Krizhevsky's home page The CIFAR-10 and CIFAR-100 are labeled subsets of the 80 million tiny images dataset. They were collected by Alex Krizhevsky, Vinod Nair, and Geoffrey Hinton. The CIFAR-10 dataset The CIFAR-10 dataset consists of 60000

www.cs.toronto.edu

2) torchvision 내에 내장된 데이터셋을 불러올 수도 있습니다.

 * 이번 실습 간에는 내장된 데이터셋을 불러와서 실습해보겠습니다.

 

 

2.  잔차신경망(ResNet) 구성 / CIFAR-10으로 실습해보기

- 이번에는 잔차신경망을 파이썬 코드를 통해 만들어보고, CIFAR-10 데이터셋으로 분류 실습을 해보겠습니다.

 * 이번에 구성하는 ResNet은 논문에서 제시된 만큼의 깊이가 있는 층이 아닌, 학습의 목적으로 몇개의 층만 만들어서 딥러닝 네트워크를 구성해보겠습니다.

 

 

- 먼저 기본 블록을 만들어보겠습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import torch
import torch.nn as nn
 
class BasicBlock(nn.Module):
   def __init__(self, in_channels, out_channels, kernel_size=3):
       super(BasicBlock, self).__init__()
 
 
       # ❶ 합성곱층 정의
       self.c1 = nn.Conv2d(in_channels, out_channels,
                           kernel_size=kernel_size, padding=1)
       self.c2 = nn.Conv2d(out_channels, out_channels,
                           kernel_size=kernel_size, padding=1)
 
       self.downsample = nn.Conv2d(in_channels, out_channels,
                                   kernel_size=1)
 
       # ❷ 배치 정규화층 정의
       self.bn1 = nn.BatchNorm2d(num_features=out_channels)
       self.bn2 = nn.BatchNorm2d(num_features=out_channels)
 
       self.relu = nn.ReLU()
   def forward(self, x):
       # ❸스킵 커넥션을 위해 초기 입력을 저장
       x_ = x
 
       x = self.c1(x)
       x = self.bn1(x)
       x = self.relu(x)
       x = self.c2(x)
       x = self.bn2(x)
 
       # ➍합성곱의 결과와 입력의 채널 수를 맞춤
       x_ = self.downsample(x_)
 
       # ➎합성곱층의 결과와 저장해놨던 입력값을 더해줌
       x += x_
       x = self.relu(x)
 
       return x
cs

* 각 줄별로 코딩을 해석해보면 아래와 같습니다. 

1) 파이토치 사용을 위해 torch를 임포트합니다.

 

2) 파이토치에서 신경망(Neural Network) 구성을 위해 torch.nn을 임포트하고 이를 nn으로 명명합니다.

 

4) 파이토치에서 nn.Module을 통해 'BasickBlock'이라는 이름의 모델 아키텍처를 정의하고 모델 파라미터를 관리할 수 있는 틀을 만듭니다.

 

5~6) 파이썬 클래스 생성자인 '__init__'를 정의한 뒤, self로 정의할 현재 클래스 인스턴스를 선택합니다.

* in_channels : 입력 데이터의 채널 수 (입력 데이터가 RGB 데이터인 경우 각 1개의 채널씩, 즉 3개의 채널을 사용)

* out_channels : 출력 데이터의 채널수

* kernel_size : 컨볼루션(합성곱) 연산에서 사용할 커널(필터)의 크기

* super(BasicBlock, slef).__init__() : 부모 클래스인 BasickBlock의 생성자를 호출하는 코드

 

10~20) 합성곱(컨볼루션 / Convolution) 층을 정의하고 있습니다. 

* self.c1 / self.c2 이라는 이름으로 첫번째 합성곱층을 정의하고 있습니다. 이때, 입력 데이터가 2차원인 그림이므로 nn.conv2d 함수를 활용해 정의하며, in_channels에서 out_channels로 변환됨을 의미하고 있으며, kernel_size는 위에서 정의한 3을 사용한다는 것을 의미하고 있으며, padding 사이즈는 1로 함으로써 제로 패딩을 정의하고 있습니다.

* self.downsample 층은, 1x1 컨볼루션 레이어로, 잔차 연결을 위해 사용되며 합성곱을 거쳐 입력 데이터가 채널의 수가 바뀌는 것에 맞추기 위해 사용됩니다.

* self.bn1과 self.bn2는 배치 정규화(Batch Normalization)를 위해 정의되었으며, 2차원인 그림 데이터이므로 nn.BatchNorm2d 함수를 사용해서 정의하고 있습니다. num_features는 정규화할 feature map의 채널수를 나타내며, out_channels와 동일하게 맞추어 출력 결과물의 구조를 유지하고 있습니다. 

 

22) 활성화함수를 정의하고 있으며, 여기서는 ReLU(Recified Linear Unit)을 활용하고 있습니다.

 * ReLU는 연산 간 비선형성을 추가하기 위해 사용됩니다.

 

23 ~ 40) 이 부분은 순방향(pass forward) 연산을 위해 함수를 정의하고 있는 부분입니다.

* x_ = x : 스킵 커넥션(skip connection /  잔차 연결이라고도 함)을 위해 초기 입력 x를 x_에 저장합니다. 

* self.c1(x) -> self.bn(x) -> self.relu(x) -> self.c2(x) -> self.bn2(x)  : 레이어 내의 전파 순서를 정의하고 있습니다. 

 먼저 x=self.c1(x) 는 초기 입력 x값을 self.c1으로 정의된 함수에 입력값으로 넣어 출력된 값을 다시 x로 저장하고 있습니다.

 다음 x=self.bn1(x)는 앞선 층에서 도출된 출력값을 bn1층에 넣어서 배치정규화를 하고 이를 다시 x로 저장하고 있습니다. 

 이후 x=self.relu(x)는 배치 정규화까지 된 값을 활성화함수인 relu를 통해 값을 다시 계산하고, 이를 다시 x로 저장합니다.

 self.c2 와 self.bn2 의 과정은 위의 과정과 유사합니다.

* x_ =self.downsample(x_) : 스킵 커넥션을 위해 x_에 저장된 입력값 x를 1x1 컨볼류션 연산을 통해 채널수를 맞추어 줍니다.

 * x += x_ : 입력데이터 x와 스킵 커넥션에서 나온 x_을 합산합니다. 이를 통해 스킵커넥션을 구현합니다.

 * x=self.relu(x) : 최종 출력에 ReLU 함수를 다시 적용합니다.

 

 

- 위의 과정을 거쳐 잔차 블록 1개가 만들어집니다. 이렇게 정의된 기본 잔차 블록을 가지고 ResNet 모델을 만들어 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ResNet(nn.Module):
   def __init__(self, num_classes=10):
       super(ResNet, self).__init__()
 
 
       # ❶ 기본 블록
       self.b1 = BasicBlock(in_channels=3, out_channels=64)
       self.b2 = BasicBlock(in_channels=64, out_channels=128)
       self.b3 = BasicBlock(in_channels=128, out_channels=256)
 
 
       # ❷ 풀링을 최댓값이 아닌 평균값으로
       self.pool = nn.AvgPool2d(kernel_size=2, stride=2)
 
       # ❸ 분류기
       self.fc1 = nn.Linear(in_features=4096, out_features=2048)
       self.fc2 = nn.Linear(in_features=2048, out_features=512)
       self.fc3 = nn.Linear(in_features=512, out_features=num_classes)
 
       self.relu = nn.ReLU()
   def forward(self, x):
       # ❶ 기본 블록과 풀링층을 통과
       x = self.b1(x)
       x = self.pool(x)
       x = self.b2(x)
       x = self.pool(x)
       x = self.b3(x)
       x = self.pool(x)
 
 
       # ❷ 분류기의 입력으로 사용하기 위해 flatten
       x = torch.flatten(x, start_dim=1)
 
       # ❸ 분류기로 예측값 출력
       x = self.fc1(x)
       x = self.relu(x)
       x = self.fc2(x)
       x = self.relu(x)
       x = self.fc3(x)
 
       return x
cs

 

1 ~ 3) ResNet이라는 클래스를 정의하고, nn.Module을 통해 Neural Network를 구성한다는 커맨드를 주고, 입력 데이터의 클래스를 num_classes=10으로 정의합니다. 이렇게 정의된 클래스는 부모 클래스인 ResNet을 받아옵니다.

 

6~9) 이제 모델의 구조에 사용될 함수들을 만들어줍니다. 위에서 정의한 기본블록을 바탕으로 만들어 줍니다.  

* 위에서 정의한 BasickBlock이라는 잔차 블록 함수를 활용하였습니다.

* self.b1은 입력 채널이 3개(R, G, B)로, 이후 출력 채널수는 64개로 정의하였으며

* self.b2는 self.b1의 출력채널인 64개를 받아오고 128개의 채널을 출력하게 하였으며

* self.b3는 self.b2의 출력채널인 128개를 받아오고 256개의 채널을 출력하게 정의하였습니다.

 

13) 256개나 되는 채널의 컨볼루션층을 모두 사용할 수 없으니, 1개의 채널로 만들어주기위해 풀링을 사용했고, 이때 각 채널을 대표하는 값을 만드는 방법은 평균을 사용하는 평균 풀링(Average Pooling)을 사용했습니다.

*이때 kernel의 크기는 2, stride는 2로 정의하여 데이터를 다운 샘플링하였습니다.

 

16~18) nn.Linear 함수로, 분류기의 전연결층(fully connected layer)을 정의하고 있습니다. 합성곱층은 이미지 데이터의 특성을 추출하는데 사용된다면, 전연결층은 최종적으로 분류를 위해 사용되는 층입니다. 

* 첫번째 전연결층의 입력 특성수는 4096, 출력 특성수는 2048 / 두번째 전연결층의 입력 특성수는 2048, 출력 특성수는 512 / 세번째 전 연결층의 입력 특성수는 512, 출력 특성수는 num_classes로 10개를 의미합니다. 

* 즉 첫번째 -> 두번째 -> 세번째 전연결층으로 연결되면서 특성수는 intercept와 weight의 선형결합을 통해 특성수가 점차 집약되고, 최종 세번째 전 연결층은 10개의 특성수로 결과들을 집약합니다. 

 

20) 비선형성을 위해 ReLU 층을 정의합니다.

 

21 ~ 41) 이제 forward pass를 정의해줍니다. 즉, 모델의 전체 구조를 정의하는 것인데요

 a) 입력값인 x가 첫번째 잔차 블록인 b1을 통과하고

 b) 풀링층을 통과

 c) b2층을 통과

 d) 풀링층을 통과

 e) b3층을 통과

 f) 풀링층을 통과 

 

* 위 과정을 통해 거쳐 만들어진 결과는 전연결층으로 입력되기 위해 flatten 과정을 거칩니다. 

 

* 이후 전연결층과 relu 활성화 함수를 거치는 과정을 반복하여 최종 결과를 만듭니다. 

 

 

- 다음은 CIFAR10 데이터 전처리를 위한 코드입니다. Compose 함수를 활용해 데이터를 일부 변형시켜주며, 랜덤으로 그림을 잘라주는 랜덤크롭핑, y축의 평행 대칭이동 이후 해당 숫자 배열의 이미지 데이터를 tensor의 형태로 바꿔주고, 정규화를 해줍니다. 이때 정규화에 사용된 평균과 표준편차는 CIFAR-10 데이터 전체에서 각 R(빨간색) ,G(초록색), B(파란색) 값의 평균과 표준편차입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import tqdm
 
from torchvision.datasets.cifar import CIFAR10
from torchvision.transforms import Compose, ToTensor
from torchvision.transforms import RandomHorizontalFlip, RandomCrop
from torchvision.transforms import Normalize
from torch.utils.data.dataloader import DataLoader
 
from torch.optim.adam import Adam
 
transforms = Compose([
   RandomCrop((3232), padding=4), #❶ 랜덤 크롭핑
   RandomHorizontalFlip(p=0.5), #❷ 랜덤 y축 대칭
   ToTensor(),
   Normalize(mean=(0.49140.48220.4465), std=(0.2470.2430.261))
])
 
cs

 

 

- 이제 데이터를 학습 및 테스트 데이터로 각각 불러오고 이를 DataLoader를 통해 모델에 입력가능한 형태 및 32개의 배치로 나누어 줍니다. 또한, 위에서 정의된 transforms 함수로 데이터를 전처리합니다.

 

1
2
3
4
5
training_data = CIFAR10(root="./", train=True, download=True, transform=transforms)
test_data = CIFAR10(root="./", train=False, download=True, transform=transforms)
 
train_loader = DataLoader(training_data, batch_size=32, shuffle=True)
test_loader = DataLoader(test_data, batch_size=32, shuffle=False)
cs

 

 

- 이제 모델을 정의(GPU 사용여부, 입력 클래스 크기)하겠습니다. 실행 결과는 아래와 같습니다.

1
2
3
4
device = "cuda" if torch.cuda.is_available() else "cpu"
 
model = ResNet(num_classes=10)
model.to(device)
cs

* 3개의 잔차 블록의 층을 거치고 이후 평균풀링된 뒤 해당 값들이 3개의 전연결층으로 거치는 구조가 보입니다.

 

 

 

- 이제 학습 루프를 정의하고, 학습 데이터(train_loader)로 모델을 학습시켜줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lr = 1e-4
optim = Adam(model.parameters(), lr=lr)
 
for epoch in range(30):
   iterator = tqdm.tqdm(train_loader)
   for data, label in iterator:
       # 최적화를 위해 기울기를 초기화
       optim.zero_grad()
 
       # 모델의 예측값
       preds = model(data.to(device))
 
       # 손실 계산 및 역전파
       loss = nn.CrossEntropyLoss()(preds, label.to(device))
       loss.backward()
       optim.step()
 
       iterator.set_description(f"epoch:{epoch+1} loss:{loss.item()}")
 
torch.save(model.state_dict(), "ResNet.pth")
cs

* 학습률은 0.0001로

* 옵티마이저는 Adam으로

* 반복실행 횟수인 epoch는 30회로 한정합니다.

* 분류문제이므로 CrossEntropyLoss로 손실함수를 정의하고

* 학습마다 결과를 출력하게 만들고 

* 최종 모델 결과는 ResNet.pth라는 파일로 저장합니다

* 학습 과정 및 결과는 아래와 같습니다. (점차 train loss가 줄어들고 있음을 볼수 있으며, 30번째 학습에서 가장 작은 train loss가 나왔음을 알 수 있습니다)

 

 

.- 이제 저장된 최종모델데이터를 활용해 테스트 데이터를 분류해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
model.load_state_dict(torch.load("ResNet.pth", map_location=device))
 
num_corr = 0
 
with torch.no_grad():
   for data, label in test_loader:
 
       output = model(data.to(device))
       preds = output.data.max(1)[1]
       corr = preds.eq(label.to(device).data).sum().item()
       num_corr += corr
 
   print(f"Accuracy:{num_corr/len(test_data)}")
cs

 

테스트 데이터에 대한 분류 성능은 총 88.39%의 정확도가 나오게 됨을 알 수 있습니다.

반응형

댓글