머신러닝 수난기 (프로젝트)

Dogs vs. Cats (6) [PyTorch]

환동씨 2025. 2. 25. 01:43

Dogs vs. Cats (1) : https://one-plus-one-is-two.tistory.com/3

Dogs vs. Cats (2) : https://one-plus-one-is-two.tistory.com/4

Dogs vs. Cats (3) : https://one-plus-one-is-two.tistory.com/5

Dogs vs. Cats (4) : https://one-plus-one-is-two.tistory.com/6

Dogs vs. Cats (5) : https://one-plus-one-is-two.tistory.com/7

 

지난 편에서 내가 만든 모델을 이용하여 테스트 데이터를 분류하고, 그 결과를 경진대회에 제출하는 작업까지 했다.

제출까지 했는데 다른 경진대회에 도전해볼까 아니면 심화학습을 해볼까 고민을 좀 해봤다. 심화학습을 한다고 하면 아마 Dogs vs. Cats만 너무 우려먹는것 아닌가 싶기도 하고...  하지만 코드를 직접 짜보니 이것저것 심층적으로 탐구해보고 싶은게 생겼다. 그래서 바로 다른 경진대회로 넘어가기 보다는 이 경진대회에서 좀 더 심화학습을 해보기로 했다.

 

참고로 '머신러닝 수난기'에 올라오는 글은 내가 맨땅에서부터 공부하는 과정을 기록한 것이라 틀린 설명도 많고 내가 착각하고 지나간 것도 몇 개 있을 수 있다. 혹시 이 글 꼼꼼히 정독하는 사람이 있는지 모르겠지만 있다면 양해 바란다. (너무 중구난방으로 써서 이걸 정독이나 할 수 있을지 모르겠다. 정독하시는 분이 있다면 매우 감사...)

 

내가 해보고 싶은 심층 탐구는 다음과 같았다.

1. 텐서플로우로 작성한 코드를 파이토치로 바꿔보기

2. 데이터 증강, EarlyStopping 등 성능 향상에 도움이 되는 다양한 기법

3. 유명한 딥러닝 모델 논문 리뷰하고 사용해보기

4. 다른 사람이 작성한 코드 리뷰해보기

 

6편에서는 파이토치 사용법을 공부하고, 개/고양이 분류 모델을 파이토치로 재작성해 볼 것이다.

 

파이토치 개념

① 데이터셋과 데이터로더

데이터셋(Dataset) : 학습에 필요한 데이터 샘플을 정제하고 정답을 저장하는 기능을 제공한다. 데이터셋은 클래스 형태로 제공되며, 초기화 메서드(__init__), 호출 메서드(__getitem__), 길이 반환 메서드(__len__)를 재정의하여 활용한다. 데이터세트 클래스의 기본형은 다음과 같다.

class Dataset:

  def __init__(self, data, *arg, **kwargs):
    self.data = data

  def __getitem__(self, index):
    return tuple(data[index] for data in data.tensors)

  def __len__(self):
    return self.data[0].size(0)
  • 초기화 메서드(__init__) : 입력된 데이터의 전처리 과정을 수행하는 메서드
  • 호출 메서드(__getitem__) : 학습을 진행할 때 사용되는 하나의 행을 불러오는 과정
  • 길이 메서드(__len__) : 학습에 사용된 전체 데이터세트의 개수 반환

모델 학습을 위해 임의의 데이터세트를 구성할 때 파이토치에서 지원하는 데이터세트 클래스를 상속받아 사용한다. 새로 정의한 데이터세트 클래스는 현재 시스템에 적합한 구조로 데이터를 전처리해 사용한다.

 

데이터로더(DataLoader) : 데이터로더는 데이터세트에 저장된 데이터를 어떠한 방식으로 불러와 활용할지 정의한다. 학습을 조금 더 원활하게 진행할 수 있도록 배치 크기(batch_size), 데이터 순서 변경(shuffle), 데이터 로드 프로세스 수(num_workers) 등의 기능을 제공한다.

  • 배치 크기(batch_size) : 학습에 사용되는 데이터의 개수가 매우 많아 한 번의 에폭에서 모든 데이터를 메모리에 올릴 수 없을 때 데이터를 나누는 역할을 한다. 전체 데이터세트에서 배치 크기만큼 데이터 샘플을 나누고, 모든 배치를 대상으로 학습을 완료하면 한 번의 에폭이 완료되는 구조로 볼 수 있다.
  • 데이터 순서 변경(shuffle) : 모델이 데이터 간의 관계가 아닌, 데이터의 순서로 학습되는 것을 방지하고자 수행하는 기능이다. 데이터 샘플과 정답의 매핑 관계는 변경되지 않으며, 행의 순서를 변경하는 개념이다.
  • 데이터 로드 프로세스 수(num_workers) : 데이터를 불러올 때 사용할 프로세스의 개수를 의미한다. 학습을 제외한 코드에서는 데이터를 불러오는 데 시간이 가장 오래 소요된다. 이를 최소화하고자 데이터 로드에 필요한 프로세스의 수를 늘릴 수 있다.

파이토치는 데이터세트와 데이터로더를 통해 학습에 필요한 데이터 구조를 생성한다. 일반적으로 데이터세트를 재정의해 가장 많이 사용하며, 데이터로더에서는 주로 배치 크기를 조절해 가며 현재 학습 환경에 맞는 구조로 할당한다.

 

② 신경망 패키지와 모듈

신경망 패키지 : 파이토치의 신경망(Neural Networks) 패키지에는 신경망을 생성하고 학습시키는 과정을 빠르고 간편하게 구현할 수 있는 기능이 제공된다. 신경망 패키지는 torch.nn을 import 하여 사용할 수 있다. 신경망 패키지에는 네트워크(Net)를 정의하거나 자동 미분, 계층 등을 정의할 수 있는 모델이 포함돼 있다.

 

모듈 : 신경망 패키지 안에 있는 모듈(nn.Module)을 이용하여 사용자 정의 모델을 정의할 수 있다. 모듈 클래스는 초기화 메서드(__init__)와 순방향 메서드(forward)를 재정의하여 활용한다. 다음은 모듈 클래스의 기본형이다.

class Model(nn.Module):
  def __init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(1, 20, 5)
    self.conv2 = nn.Conv2d(20, 20, 5)

  def forward(self, x):
    x = F.relu(self.conv1(x))
    x = F.relu(self.conv2(x))
    return x

 

  • 초기화 메서드(__init__) : 신경망에 사용될 계층을 정의하기 전에 super 함수로 모듈 클래스의 속성을 초기화한다. super 함수로 부모 클래스를 초기화하면 서브 클래스인 모델에서 부모 클래스의 속성을 사용할 수 있다. 모델 클래스 초기화 이후, 학습에 사용되는 계층을 초기화 메서드에 선언한다. self.conv1이나 self.conv2와 같은 인스턴스가 모델의 매개변수가 된다.
  • 순방향 메서드(forward) : 초기화 메서드에서 선언한 모델 매개변수를 활용해 신경망 구조를 설계한다. 모델이 데이터(x)를 입력받아 학습을 진행하는 일련의 과정을 정의하는 영역이다. 모듈 클래스는 호출 가능한 형식(Callable Type)으로 모델의 인스턴스를 호출하는 순간 호출 메서드(__call__)가 순방향 메서드를 실행한다. 그러므로 모델 객체를 호출하는 순간 순방향 메서드가 실행돼 정의한 순서대로 학습이 진행된다.

초기화 메서드에서 super 함수로 부모 클래스를 초기화했으므로 역방향(backward) 연산은 정의하지 않아도 된다. 파이토치의 자동 미분 기능인 Autograd에서 모델의 매개변수를 역으로 전파해 자동으로 기울기 또는 변화도를 계산해 준다. 그러므로 별도의 메서드로 역전파 기능을 구성하지 않아도 된다.

 

파이토치 활용

① 데이터셋과 데이터로더

이제 파이토치 개념을 이용하여 Dogs vs. Cats 경진대회 데이터에 대한 데이터셋과 데이터로더를 정의해볼 것이다. 이전에 Keras로 코드를 작성할 때는 모델 설계를 먼저하고 데이터셋 정의 및 라벨링을 나중에 했는데, 이번에는 순서를 바꿔서 데이터셋 정의 및 라벨링을 먼저 하고 모델 설계를 나중에 한다.

from tensorflow.keras.utils import image_dataset_from_directory

training_data, validation_data = image_dataset_from_directory(
    directory,
    labels=labels,
    label_mode="binary",
    image_size=IMAGE_SIZE,
    seed=42,
    validation_split=0.15,
    subset="both"
)

training_data = training_data.map(lambda x, y : (x/255.0, y))
validation_data = validation_data.map(lambda x, y : (x/255.0, y))

 

keras에서 데이터로더를 어떻게 정의했는지 복습해보자. keras에서는 위 코드처럼 image_dataset_from_directory() 함수를 이용하여 데이터셋을 정의했다.

 

# 배치에서 이미지와 라벨 가져오기
iterator = iter(validation_data)
first_image_batch, first_label_batch = next(validation_data)
second_image_batch, second_label_batch = next(validation_data)
...

 

그리고 데이터로더에서 데이터를 로드할 때는 반복자(iterator)를 사용하여 배치(batch)를 하나씩 가져오는 방법으로 데이터를 로드했다. 그렇다면 이를 파이토치로 구현한다면 코드를 어떻게 짜야 할까? 

import os
from PIL import Image
from torch.utils.data import Dataset, DataLoader

class ImageDataset(Dataset):
    def __init__(self, filepath):
        self.filepath = filepath
        self.filenames = os.listdir(filepath)

    def __getitem__(self, index):
        filepath = self.filepath
        filename = self.filenames[index]
        x = Image.open(filepath + filename)
        y = 0 if filename.split('.')[0] == "cat" else 1
        return x, y

    def __len__(self):
        return len(self.filenames)

 

먼저 나는 위와 같은 방식으로 정의해봤다.

  • 초기화 메서드(__init__) : filepath와 filenames라는 인스턴스 변수들을 정의해서, filepath에는 인자로 전달받은 파일의 경로를, filenames에는 해당 경로에 존재하는 파일명의 목록을 리스트로 저장하도록 했다.
  • 호출 메서드(__getitem__) : x에는 인덱스가 index인 이미지 파일을, y에는 해당 이미지에 붙는 레이블(고양이면 0, 강아지면 1)을 저장한다. 그리고 x와 y를 같이 반환하도록 했다. 이미지 파일을 읽을 때는 PIL 라이브러리의 Image.open() 함수를 사용하였다.
  • 길이 메서드(__len__) : 경로 내 파일 이름의 개수가 학습에 사용된 전체 데이터의 개수가 될 것이다.

하지만 이를 사용하려고 보니 다음과 같은 에러가 발생했다.

 

배치는 텐서, 넘파이 배열, 숫자, 딕셔너리 또는 리스트를 포함해야 한다고 되어있다.

뒤에 PIL 어쩌구 저쩌구 하는거 보면 문제는 x에 저장한 이미지에서 발생한 것 같고 이거를 텐서로 바꿔야 하는 것 같은데, 어떻게 하는건지 잘 모르겠어서 GPT의 도움을 받았다.

import os
from PIL import Image
from torchvision import transforms
from torch.utils.data import Dataset, DataLoader

class ImageDataset(Dataset):
    def __init__(self, filepath, transform=None):
        self.filepath = filepath
        self.filenames = os.listdir(filepath)
        self.transform = transform

    def __getitem__(self, index):
        filepath = self.filepath
        filename = self.filenames[index]
        x = Image.open(os.path.join(filepath, filename)).convert("RGB")

        if transform:
            x = self.transform(x)
            
        y = 0 if filename.split('.')[0] == "cat" else 1
        return x, y

    def __len__(self):
        return len(self.filenames)

 

__init__ 메서드를 보면 ImageDataset 클래스의 인스턴스 변수로 이미지 변환기(transform)가 추가되었다. PIL.Image 객체를 Tensor로 바꾸는 함수를 변환기 내에 포함해서 이미지를 텐서로 바꿔주면 된다.

 

__getitem__ 메서드를 보면 변환기(transform)이 적용되어 있으면 이미지 파일에 변환기를 적용하여 변환시키는 코드가 추가되었다. 그 외에 자잘하게 수정된 부분도 있는데, 원래 Image.open() 안에 경로를 문자열로 직접 전달했지만, 이 코드에서는 os.path.join(파일 경로, 파일 이름)을 호출하는 방식으로 경로를 명시하였다. 그리고 뒤에 .convert("RGB")가 붙었는데, 이는 만약에 데이터셋 내에 흑백 이미지가 섞여있어도 오류가 발생하지 않도록 하기 위해 모든 사진을 확실하게 컬러 사진으로 변환시켜주는 기능을 한다.

 

위와 같이 수정했더니 데이터셋 클래스 활용하려고 할 때 오류가 발생하지 않았다.

 

transform = transforms.Compose([
    transforms.Resize((112, 112)),
    transforms.ToTensor()
])

train_dataset = ImageDataset("train/", transform=transform)
train_dataloader = DataLoader(train_dataset, batch_size=32, shuffle=True, drop_last=True)

 

다음으로 train 폴더 내 사진들에 대한 데이터셋을 만들고, 이 데이터셋에 대한 데이터로더를 정의해봤다.

데이터셋을 만들때는 파일의 경로와 변환기를 전달하는데, 변환기에는 사진을 (112, 112)로 바꾸는 함수와 이미지를 텐서로 바꿔주는 함수를 포함시켰다. 참고로 이미지를 텐서로 바꿔주는 함수인 ToTensor() 함수는 PIL 이미지를 [0, 1] 범위의 텐서(float32)로 변환해준다고 한다. 즉, 케라스로 구현했을 때와 달리 x에 255를 나눠주거나 하는 작업을 할 필요는 없다.

데이터로더의 경우, 데이터셋은 train_dataset을 사용하며, 배치 크기는 케라스로 구현한 것과 똑같이 32로 설정했다. 더 효과적인 학습을 위해 데이터가 섞일 수 있도록 했으며(shuffle=True), 배치마다 이미지를 32개씩 넣으면 마지막 배치에는 이미지가 8개밖에 들어가지 못하는데(32×781 + 8 = 25,000), 이 배치는 버리도록 했다(drop_last=True). 따라서 원래 배치의 수는 782개이나, 마지막 배치는 버리므로 데이터로더에 저장된 배치는 781개가 된다. 이는 다음과 같이 len(train_dataloader)를 출력하면 확인할 수 있다.

 

참고로, 공식 문서를 읽어보면 DataLoader 클래스에 전달할 수 있는 파라미터의 종류가 모두 나와있다. 그래서 공식 문서 내에서 해당 내용을 가져와 봤다.

torch.utils.data : https://pytorch.org/docs/stable/data.html

DataLoader(dataset, batch_size=1, shuffle=False, sampler=None,
           batch_sampler=None, num_workers=0, collate_fn=None,
           pin_memory=False, drop_last=False, timeout=0,
           worker_init_fn=None, *, prefetch_factor=2,
           persistent_workers=False)
  • dataset : 데이터로더를 이용해 로드할 데이터셋
  • batch_size : 배치 크기
  • shuffle : 데이터 섞을지 여부
  • sampler : 파이토치에서는 다양한 샘플러들을 제공하는데, 보니까 SequentialSampler, RandomSampler, SubsetRandomSampler, WeightedRandomSampler, BatchSampler, DistributedSampler가 있는 것 같다. 이거에 대해서는 나중에 알아봐야겠다. 머리 아픔...
  • batch_sampler : sampler랑 마찬가지 맥락
  • num_workers : 데이터셋의 데이터를 gpu로 로딩할 때 사용할 서브 프로세스의 개수. 서브 프로세스의 개수를 늘리면 gpu에 더 빠르게 데이터를 전송할 수 있지만 너무 많이 설정하면 다른 작업을 할 때 오히려 느려질 수 있다고 한다. 컴퓨팅 자원은 한정되어 있다는 점을 항상 유의해야 한다. 디폴트값(0)으로 두면 서브 프로세스 없이 메인 프로세스에 데이터를 로딩한다고 한다.
  • drop_last : 마지막 배치 버릴지 여부 결정

대충 이렇게만 정리해봤다. 나머지 파라미터들은 일단 당장에 쓸 일은 없을 것 같아서 나중에 필요하면 다시 정리해야지... 파라미터 개념 정리하는 것도 복잡한 일인 것 같다.

 

자 그래서 데이터셋 만들고 데이터로더 정의하는 것까지 해봤는데, 만들었으면 당연히 써봐야겠지?

파이토치의 데이터로더는 케라스에서 image_dataset_from_directory로 구현한 데이터로더처럼 반복자(iterator)의 개념이다. 따라서 train_dataloader에 대한 반복자 iterator를 정의하고 next() 함수에 이 반복자를 전달함으로써 하나의 배치를 로딩할 수 있다.

 

만약에 100번째 배치의 데이터를 가져오고 싶다면 다음 코드와 같이 next(iterator)를 99번 호출하고, 100번째 호출에서 데이터를 변수에 저장해주면 된다.

iterator = iter(train_dataloader)

n = 100   # 100번째 배치를 가져오고 싶다.

for i in range(n-1):
    next(iterator)

x, y = next(iterator)

 

하지만 데이터가 안 섞여 있으면 몰라도, 내가 정의한 데이터로더처럼 shuffle=True로 되어있다면 굳이 몇 번째 배치를 특정해서 가져올 필요가 없다. 어차피 코드를 실행할 때마다 데이터가 섞이기 때문이다. 아무 배치나 가져와도 된다면 첫 번째 배치를 가져오는 것이 가장 편할 것이다.

x, y = next(iter(train_dataloader))

 

위 코드는 train_dataloader에 대한 이터레이터에 대해 next() 함수를 한 번만 호출했으니 x, y에는 첫 번째 배치에 들어있는 데이터들과 레이블들이 각각 저장된다. 이렇게 첫 번째 배치를 로드하는 것은 한 줄의 코드만을 필요로 한다.

 

원하는대로 잘 저장됐는지 확인해보자. 하나의 배치에는 32개의 데이터가 들어가있기 때문에 x와 y에 각각 들어있는 데이터와 레이블의 개수는 각각 32개씩일 것이다. 따라서 서브플롯 그리드를 8×4로 구성하여 데이터를 출력해보자.

 

import matplotlib.pyplot as plt

plt.figure(figsize=(10, 20))
for i in range(32):
    plt.subplot(8, 4, i + 1)
    plt.imshow(x[i])  # 이미지 변환
    plt.title(f"Label: {y[i]}")  # 라벨 표시
    plt.axis("off")
plt.show()

 

이렇게 코드를 짜고 실행시켜보려고 하니..!

 

뚀잉.. 또 에러가 발생했다. 근데 이번에는 뭐가 문제인지 오류 내용만 읽고 알 수 있었다.

채널 수 3이 뒷쪽으로 가야하는데, 파이토치의 데이터로더에서 로딩한 데이터는 채널 수가 앞 차원에 와있다. 따라서 채널 수를 뒤쪽으로 보내줘야 한다.

x, y = next(iter(train_dataloader))
x = x.permute(0, 2, 3, 1)

 

print(x.shape)를 실행하면 결과는 torch.Size([32, 3, 112, 112])로 나온다. 배치 내 데이터 개수는 32개, 채널 수 3개, 너비 112, 높이 112라는 뜻이다. 그런데 여기서 채널 수를 맨 뒤로 보내고 싶다. 그러려면 데이터 개수(인덱스 0)는 그 위치로 유지하고, 너비(인덱스 2)와 높이(인덱스 3)는 한 칸씩 앞으로 땡긴다. 그리고 채널 수(인덱스 1)를 맨 뒤인 인덱스 3으로 보내주면 된다.

 

따라서 인덱스 (0, 1, 2, 3)이 (0, 2, 3, 1)로 바뀌게 되므로 x.permute(0, 2, 3, 1)을 호출하면 채널 수가 맨 뒤로 보내지게 된다.

다음 사진은 확인 출력 코드를 넣어서 x의 차원이 정상적으로 바뀌었는지 확인한 것이다.

 

자 어쨌든 이렇게 차원 변환을 시키고 아까 그 서브플롯 출력 코드를 다시 실행시켜보면 다음과 같이 사진이 정상적으로 나온다.

 

기존에 작성한 코드를 파이토치로 다시 짜보는 것인데도 뭔가 힘들고 오래 걸리는 것 같다.

다음으로는 파이토치 방식으로 모델을 설계해보자.

 

② 모델 설계

케라스로 만든 것과 똑같은 구조로 모델을 설계할 것이다. 케라스로 설계한 모델이 어떤 구조를 가졌는지 리뷰해보면서 하나하나씩 파이토치 코드로 바꿔보자.

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D, Dropout, Flatten, Dense, Rescaling

IMAGE_WIDTH = 112
IMAGE_HEIGHT = 112
IMAGE_SIZE = (IMAGE_WIDTH, IMAGE_HEIGHT)
IMAGE_CHANNELS = 3

model = Sequential()

model.add(Conv2D(32, (3, 3), activation='relu', input_shape=(IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS)))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

model.add(Conv2D(128, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(256, activation='relu'))
model.add(Dropout(0.50))
model.add(Dense(1, activation='sigmoid'))

model.compile(loss="binary_crossentropy", optimizer="adam", metrics=['accuracy'])

model.summary()

 

1. 먼저 차원이 (112, 112, 3)인 이미지 데이터를 입력으로 받는다. 그런데 파이토치를 이용해 이미지 차원을 (112, 112, 3)으로 만드는건 앞에서 다 했기 때문에 추가적인 언급은 안 해도 될 것 같다.

 

2. 이후에는 합성곱 계층 -> 맥스 풀링 계층 -> 드롭아웃 계층으로 구성된 일련의 단계가 계속 반복되고 있다. 합성곱 계층의 입력 차원과 출력 차원만 달라질 뿐, 그 외에 활성화 함수를 ReLU로 사용한다거나 드롭아웃 계층에서 데이터를 끄는 비율이 25%인 것 등등은 똑같이 유지된다. 먼저 첫 번째 단계부터 살펴보자.

첫 번째 단계에서

- 합성곱 계층의 입력 채널 수는 3이고(입력 이미지 채널 수가 3이라서), 출력 채널 수는 32이다. 파이토치에서는 케라스에서와 달리 입력 채널 수(in_channels)를 명시해줘야 한다. 이 파라미터들 외에도 stride, padding, dilation 등 다양한 파라미터들이 있지만 디폴트값이 없어서 사용자가 필수로 값을 전달해야하는 파라미터는 in_channels, out_channels, kernel_size 세 가지 뿐이다. 활성화 함수는 케라스에서는 파라미터로 'relu'라고 전달했던 것과 달리 파이토치에서는 함수를 사용한다. torch.nn.functional 모듈에서는 신경망을 구성할 때 사용되는 유용한 함수들을 포함하고 있는데, 이 모듈은 주로 간단하게 F로 줄여서 사용된다고 한다. F.relu() 함수에 데이터를 전달하여 렐루 연산을 적용할 수 있다.

- 맥스 풀링 계층에서도 kernel_size를 필수로 명시해야 한다. stride의 경우, 명시하지 않으면 None(kernel_size와 똑같음)이라서 커널 크기와 똑같이 맞출거면 굳이 명시해주지는 않아도 되는데 여기에서는 명시해줬다.

- 드롭아웃 계층에서 뉴런을 끄는 비율은 0.25로 한다.

 

이처럼 모델 클래스 구성 방식은 (1) __init__ 함수에서 사용할 계층들을 정의하고 (2) forward 함수에서 데이터가 계층을 통과하는 과정을 명시하면 된다.

 

다음은 두 번째 단계까지 구현한 것이다.

 

두 번째 단계에서

- 합성곱 계층의 kernel_size는 첫 번째 단계와 똑같이 3을 사용하지만, 입력 채널 수는 32, 출력 채널 수는 64를 사용한다. 입력 채널 수는 반드시 앞 계층에서의 출력 채널 수와 똑같이 맞춰줘야 한다.

- 풀링 계층은 첫 번째 단계에서 사용한 풀링 계층과 기능이 완전히 똑같기 때문에 __init__ 함수에서 새로 정의할 필요가 없다. forward 함수에서만 x가 풀링 계층을 통과한다고 명시하면 된다.

- 그러면 '같은 이유로 드롭아웃 계층도 따로 명시 안해줘도 되겠네?' 했는데 그건 아니라고 한다. 드롭아웃 계층을 같은 것으로 사용하면 계속해서 같은 뉴런들이 꺼진다고 한다. 그러면  과적합 방지의 효과를 보는게 아니라 그냥 25%의 데이터는 없는셈 치고 학습시키는 것이나 마찬가지라서 학습 효과가 오히려 더 떨어진다고 한다. 따라서 꺼지는 25%의 뉴런을 매번 다르게 하기 위해 드롭아웃 계층은 새로 정의해줘야 한다.

 

세 번째 단계도 같은 패턴이니 패스하고, 마무리 작업(평탄화 -> 전결합 -> 드롭아웃 -> 전결합)을 파이토치로 구현해보자.

from torch.nn import functional as F

class CNNModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3)
        self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
        self.dropout1 = nn.Dropout(0.25)

        self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3)
        self.dropout2 = nn.Dropout(0.25)

        self.conv3 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3)
        self.dropout3 = nn.Dropout(0.25)

        self.flatten = nn.Flatten()
        self.fc1 = nn.Linear(128 * 12 * 12, 256)
        self.dropout4 = nn.Dropout(0.5)
        self.fc2 = nn.Linear(256, 1)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        x = self.pool(x)
        x = self.dropout1(x)

        x = F.relu(self.conv2(x))
        x = self.pool(x)
        x = self.dropout2(x)

        x = F.relu(self.conv3(x))
        x = self.pool(x)
        x = self.dropout3(x)

        x = self.flatten(x)
        x = self.fc1(x)
        x = self.dropout4(x)
        x = self.fc2(x)

        return x

 

먼저 평탄화는 쉽다. self.flatten = nn.Flatten()으로 평탄화 계층을 만들고, forward 함수에서 x를 평탄화 계층에 통과시키는 코드(x = self.flatten(x))를 작성하면 된다.

하지만 첫 번째 전결합 계층이 좀 어렵다. 첫 번째 인자로 전달해야 하는 것은 평탄화 시킨 후 입력 뉴런의 개수를 입력해야 하는데, 평탄화 시키기 직전 데이터의 width * height * channels를 계산한 값을 첫 번째 인자로 전달하면 된다. 채널의 개수는 self.conv3을 통과한 후 128개가 되기 때문에 channels=128인 것을 알고 있는데, width와 height는 내가 직접 모델 계층을 살펴보면서 계산해야 한다고 한다. 그러니까 112×112였던 이미지가 합성곱 계층을 거치면서 110×110이 되고, 맥스 풀링을 거치면서 55×55가 되고 하는걸 내가 다 따져줘야 한다는 것이다. 헉 뭐 이런 비효율이 다 있지? 싶었는데, 다행히 파이토치에서도 케라스에서처럼 모델 구조를 요약하는 기능이 있다고 한다.

 

import torchsummary

torchsummary.summary(CNNModel(), (3, 112, 112))

 

이렇게 torchsummary.summary()에 모델 객체와 입력 이미지 차원을 각각 전달해주면 다음과 같이 계층을 통과하면서 Output Shape가 변화되는 과정이 출력된다. 단, 이미지 차원은 채널이 앞쪽에 가도록 입력해야 한다.

평탄화 시키기 전에 데이터의 width와 height는 12, 12였으며, 이를 평탄화시켰더니 128×12×12=18,432개의 유닛이 나온다. 따라서 self.fc1()의 첫 번째 인자로 18,432를 전달하면 된다. 아니면 18,432인 이유를 코드에 명시하고 싶다면 128*12*12로 전달해도 되겠지. 내 코드를 보면 128*12*12로 전달해놨다. 출력 뉴런의 계수는 케라스로 구현한 것과 똑같이 256으로 했다.

 

다음으로 드롭아웃 계층과, 두 번째 전결합 계층을 추가해줬다. 결국 이렇게 모델이 완성되었다. (에고 힘들어..)

 

데이터셋과 데이터로더도 만들었고 모델 구조도 다 짰겠다, 이제 모델을 학습시키고 평가하는 일만 남았다. 미리 말하자면 모델을 학습시키는것도 너무 어려웠다. 케라스로 짠 코드를 파이토치로 바꾸기만 하는건데도 생각보다 쉬운게 없다... 

 

import torch
import torch.optim as optim

device = "cuda" if torch.cuda.is_available() else "cpu"

model = CNNModel().to(device)
criterion = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

 

먼저 device를 설정한다. torch.cuda.is_available()은 cuda를 사용할 수 있는지 여부를 반환한다. cuda는 NVIDIA에서 개발한 GPU 개발 장치인데, 이를 사용할 수 있으면 "cuda"라는 문자열을, 사용할 수 없으면 "cpu"라는 문자열을 device에 저장한다. 이 코드는 책에서도 밥 먹듯이 나오는 코드라 아예 외워버렸다. 

 

model은 아까 정의했던 CNNModel 클래스의 객체를 사용한다. 단, 이를 device에 할당해줘야 한다. 

criterion은 오차 함수를 의미하는데, 이진 교차 엔트로피(Binary Cross Entropy, BCE)를 사용한다. nn 모듈의 BCELoss 클래스 객체를 생성하여 criterion에 저장한다.

optimizer은 최적화 함수이다. 케라스로 구현할 때 Adam을 사용했었고, 학습률(learning rate, lr)은 따로 지정해주지 않았던 것 같은데 여기서는 0.001로 지정해줬다. 첫 번째 인자로는 모델에 존재하는 파라미터를 전달해주면 되는데, 나는 이 모델에 어떤 파라미터가 있는지 모른다. 아니, 알아도 전달해줄 수 없다.

요약표 출력하면 알겠지만 파라미터의 수가 481만개나 된다. 이걸 나보고 하나하나 전달하라고 하면 못한다. 다행히 파이토치에서는 model.parameters()라고 써놓으면 파라미터들을 편리하게 전달할 수 있다. 따라서 optim.Adam()의 첫 번째 인자에 model.parameters()라고 입력하면 된다.

 

다음은 모델을 학습시키는 코드이다.

from tqdm import tqdm

epochs = 10

for epoch in range(epochs):
    model.train() 
    running_loss = 0.0

    for images, labels in tqdm(train_dataloader):
        images, labels = images.to(device), labels.to(device, dtype=torch.float32)

        optimizer.zero_grad()  
        
        outputs = model(images)  
        outputs = torch.sigmoid(outputs)  
        loss = criterion(outputs.squeeze(), labels) 

        loss.backward()
        optimizer.step()

        running_loss += loss.item()

    print(f"Epoch {epoch+1}/{epochs}, Loss: {running_loss/len(train_dataloader):.4f}")

print("Training complete!")

 

케라스에서는 model.fit() 한 줄로 끝났던 것 같은데, 이건 도대체 뭐지 ㅋㅋㅋ 케라스가 걍 엄청 편한거였구나...

 

  • 먼저 학습을 진행할 때 반복 횟수는 10회로 한다(epochs = 10).
  • model.train()은 모델을 학습 모드로 하겠다는 뜻이다.
  • train_dataloader에서 배치를 하나씩 꺼내서 학습을 진행한다. 이때, 케라스에서와 달리 진행률 바는 뜨지 않으므로 tqdm을 이용하여 진행률 바를 띄운다.
  • 이미지와 레이블을 device에 할당한다. 참고로 모델과 데이터가 같은 장치에 할당되어 있지 않으면 에러가 뜨므로 model을 cuda에 할당했으면 images와 labels도 cuda에 필수적으로 할당해야 한다.
  • optimizer.zero_grad()와 loss.backward()와 optimizer.step()은 거의 하나의 세트처럼 항상 같이 나오는 코드이기 때문에 셋이 묶어서 외워두는게 좋다. 모델이 예측을 수행한 뒤 손실 함수에 대한 역전파(loss.backward())를 수행하면 각 파라미터에 대한 손실 함수의 기울기(gradient)값이 계산되는데, 이 기울기값을 이용해서 손실값을 줄이는 방향으로 파라미터를 조정할 수 있다. 손실 함수의 기울기가 양수라면 파라미터가 커질수록 손실값이 증가한다는 의미이므로 파라미터 값을 줄이고, 기울기가 음수라면 파라미터가 커질수록 손실값이 감소한다는 의미이므로 파라미터 값을 늘리는 방식으로 손실값을 줄일 수 있다. 이렇게 계산된 기울기 값을 이용해서 최적화 함수의 파라미터 값을 변화시키는 함수가 optimizer.step()이 된다. 그리고 다음 에포크로 넘어가면 기울기값을 0으로 초기화해야 한다. 만약에 이전 에포크에 -0.8이라는 기울기값이 있고, 현재 에포크에서 기울기값이 -0.5로 계산되었다고 하자. 만약에 이전 에포크 -0.8을 0으로 초기화시키지 않으면 기울기값이 누적되어 optimizer.step()을 수행할 때 기울기값이 -1.3인 것으로 인식된다. 따라서 원래 의도했던 변화량보다 더 많이 파라미터를 변화시키게 된다. 따라서 모델을 학습시키기 전, optimizer.zero_grad() 함수를 이용해서 반드시 기울기를 초기화시켜줘야 한다.
  • 기울기를 초기화했으면 모델을 학습시킨다. outputs = model(images) 코드는 이미지 배치를 모델에 입력으로 넣어서 출력값을 받는 코드이다.
  • 그 다음 모델에서 받은 출력값에 sigmoid 함수를 적용해야 하므로 outputs = torch.sigmoid(outputs) 코드를 작성해준다. 근데 나는 여기서 이 생각이 들었다. '시그모이드 함수를 모델 안에서 적용시켜도 되지 않나? 왜 모델에서 시그모이드 계산을 안하고 모델 바깥에서 시그모이드 함수를 적용했을까?' 그래서 그렇게 outputs = torch.sigmoid(outputs)를 지우는 대신에 모델의 x = self.fc2(x) 코드를 x = torch.sigmoid(self.fc2(x))로 바꿔보았다. 결론부터 말하면 이렇게 하면 안된다. 이렇게 만들어놓고 학습을 진행시켜보니 loss가 0.69 ~ 0.7에서 거의 진전이 없었다. ChatGPT를 붙잡고 물어보니 그렇게 짜면 기울기 손실(Gradient Loss)이 발생하기 때문이라고 한다. 역전파를 계산할 때 모델 안에 시그모이드가 있으면 역전파를 계산할 때도 시그모이드가 고려되는데, 이렇게 되면 기울기가 거의 0에 가까워진다고 한다. 시그모이드 함수는 실수값을 (0, 1)이라는 매우 제한된 범위의 값으로 만드는 함수이기 때문이다. 하지만 시그모이드 함수를 밖으로 빼면 역전파를 계산할 때 시그모이드가 고려되지 않아 유의미한 기울기 값을 얻을 수 있다고 한다. 기울기 소실에 대해서도 다시 공부해야겠다(AI 너무 어려워...).

  • 다음으로 출력값(outputs)과 정답값(labels)를 손실 함수(criterion)에 집어넣어서 손실값을 계산한다. criterion에는 아까 BCELoss객체를 저장했으므로 두 값 사이의 이진 교차 엔트로피가 계산될 것이다. 이때, outputs은 (32, 1) 크기를 갖는 2차원 데이터인데 BCELoss는 1차원 데이터를 받는 함수이므로 outputs을 (32,) 크기를 갖는 1차원 데이터로 만들어야 한다. 이렇게 차원을 줄여주기 위해 사용하는 함수가 squeeze() 함수이다. 
  • 계산된 손실 값은 loss.items()를 호출하여 뽑아낼 수 있는데, 이를 running_loss에 누적시킨다. 반복문을 모두 돌게 되면 781개 배치에 대한 손실값이 모두 누적된 것이 running_loss에 저장된다. 따라서 781개의 배치를 모두 학습한 후 손실값을 출력할 때는 running_loss의 값을 배치 개수인 781로 나눠줘야 한다.

와 써놓고 보니 주절주절 말이 많았는데... 어쨌든 학습 코드를 실행시킨 결과는 다음과 같았다.

 

loss 값을 보니 학습은 성공적으로 된 것 같다. Loss 값이 계속해서 안정적으로 줄어들고 있고, 케라스로 만든 모델의 Loss 값이 0.37쯤이었다는걸 고려하면 그것보다는 성능이 조금 나아졌다고 할 수 있다.

 

아이고 그래서 이번 포스팅은 여기까지... 너무 힘들다.

뭔가 이번 포스팅하면서 머신러닝/딥러닝 개념을 다시 정리해야 할 것 같아서, 조만간 개념 정리 글도 써볼까 한다.