본문 바로가기
ML.DL

[DL 경진대회] 항공 사진 내 선인장 식별(1)

by 권미정 2023. 1. 30.

<Musthave 머신러닝딥러닝 문제해결 전략> 11장을 실습한 내용입니다.


1. 경진대회 이해

드디어 딥러닝 경진대회에 참가합니다! 첫 번째는 데이터 크기가 작고 난이도도 낮은 플레이그라운드 대회로, 항공 사진에서 선인장을 찾아내는 것이 목표입니다. '자연보호 구역 자율 감시'를 위해 보호 구역 내에 초목이 잘 자라는지 확인하는 작업을 하는 것인데, 딥러닝 기술을 활용하면 사람이 일일이 확인하지 않아도 선인장이 얼마나 있는지 파악할 수 있겠죠?

 

물체 식별 대회이기 때문에 csv 파일에 더해 '이미지 파일'도 제공합니다. 훈련 이미지, 테스트 이미지 데이터(jpg 형식)은 압축 파일로 제공합니다. train.csv 파일의 타깃값은 0 또는 1인데, 0이면 해당 항공 사진에 선인장이 없다는 뜻이고, 1이면 있다는 뜻입니다. 우리는 타깃값이 1인, 테스트 이미지 데이터에 선인장이 있을 확률을 예측해야 합니다.

 

2. 탐색적 데이터 분석

먼저 데이터를 둘러보고, 타깃값 분포를 알아본 뒤, 이미지 몇 개를 샘플로 출력해 봅시다.

 

① 데이터 둘러보기

csv 데이터를 불러와서 살펴보겠습니다.

import pandas as pd

# 데이터 경로
data_path = '/kaggle/input/aerial-cactus-identification/'

labels = pd.read_csv(data_path + 'train.csv')
submission = pd.read_csv(data_path + 'sample_submission.csv')
labels.head()

id 피처는 훈련 데이터의 이미지 파일명이고, 확장자인 jpg까지 표시되어 있는 것을 확인했습니다.

 

submission.head()

id는 테스트 데이터의 이미지 파일이고, 제출용 샘플이라 현재의 타깃값(has_cactus)은 의미가 없습니다. 나중에 테스트 이미지 데이터에 선인장이 있을 확률을 예측해서 타깃값을 갱신해 주면 됩니다.

 

② 데이터 시각화

csv 파일에는 이미지 파일명과 타깃값밖에 없어서 타깃값만 활용해 그래프를 그려 볼 것입니다.

 

타깃값 분포

import matplotlib as mpl
import matplotlib.pyplot as plt
%matplotlib inline

mpl.rc('font', size=15)
plt.figure(figsize=(7, 7))

label = ['Has cactus', 'Hasn\'t cactus'] # 타깃값 레이블
# 타깃값 분포 파이 그래프
plt.pie(labels['has_cactus'].value_counts(), labels=label, autopct='%.1f%%');

타깃값 0과 1의 비율이 약 1:3이네요.

 

이미지 출력

어떤 이미지가 사용되는지 볼까요? 먼저 압축 파일을 풀기 위해, zipfile 모듈의 ZipFile 클래스를 사용해서 train.zip과 test.zip을 풉니다. ZipFile()의 파라미터로 zip 파일 경로를 전달하고, with문으로 할당한 zipper 객체에 extractall() 메서드를 호출하면 됩니다.

from zipfile import ZipFile

# 훈련 이미지 데이터 압출 풀기
with ZipFile(data_path + 'train.zip') as zipper:
    zipper.extractall()
    
# 테스트 이미지 데이터 압출 풀기
with ZipFile(data_path + 'test.zip') as zipper:
    zipper.extractall()

 

os.listdir()을 호출해서 train 디렉터리와 test 디렉터리에 각각 이미지 파일이 몇 개 있는지도 알아볼 수 있습니다.

import os

num_train = len(os.listdir('train/'))
num_test = len(os.listdir('test/'))

print(f'훈련 데이터 개수: {num_train}')
print(f'테스트 데이터 개수: {num_test}')

 

이제 훈련 이미지 데이터를 몇 개 출력해 볼 건데, 이미지 파일을 읽기 위해서는 영상 처리에 자주 사용되는 OpenCV 라이브러리를 사용합니다. 선인장을 포함하는 이미지 12장을 출력해 보겠습니다.

import matplotlib.gridspec as gridspec
import cv2 # OpenCV 라이브러리 임포트

mpl.rc('font', size=7)
plt.figure(figsize=(15, 6))    # 전체 Figure 크기 설정
grid = gridspec.GridSpec(2, 6) # 서브플롯 배치(2행 6열로 출력)
    
# 선인장을 포함하는 이미지 파일명(마지막 12개)**
last_has_cactus_img_name = labels[labels['has_cactus']==1]['id'][-12:]

# 이미지 출력 
for idx, img_name in enumerate(last_has_cactus_img_name):
    img_path = 'train/' + img_name                 # 이미지 파일 경로 
    image = cv2.imread(img_path)                   # 이미지 파일 읽기 
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 이미지 색상 보정 
    ax = plt.subplot(grid[idx])
    ax.imshow(image)                               # 이미지 출력

코드에서 타깃값이 1(이미지가 선인장을 포함한다)인 labels 데이터의 마지막 12개(-12) id 값을 last_has_cactus_img_name 변수에 할당했습니다.

 

해상도가 많이 떨어지긴 하지만, 선인장으로 보이는 물체들이 잘 출력되었습니다.

 

이번에는 선인장을 포함하지 않는 이미지도 출력해 볼까요? 코드는 앞전 코드와 같은데 has_cactus 피처만 0으로 바꾸면 됩니다.

plt.figure(figsize=(15, 6))    # 전체 Figure 크기 설정
grid = gridspec.GridSpec(2, 6) # 서브플롯 배치
    
# 선인장을 포함하지 않는 이미지 파일명(마지막 12개) 
last_hasnt_cactus_img_name = labels[labels['has_cactus']==0]['id'][-12:]

# 이미지 출력 
for idx, img_name in enumerate(last_hasnt_cactus_img_name):
    img_path = 'train/' + img_name                 # 이미지 파일 경로
    image = cv2.imread(img_path)                   # 이미지 파일 읽기
    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 이미지 색상 보정
    ax = plt.subplot(grid[idx])
    ax.imshow(image)                               # 이미지 출력

땅만 보이는 사진들이 출력되었네요.

 

마지막으로 이미지 형상을 출력해 보겠습니다.

image.shape

 가로 픽셀과 세로 픽셀이 32개씩이고, 채널은 3개(빨강, 초록, 파랑으로 이루어진 컬러 이미지기 때문)임을 알 수 있습니다.

 

3. 베이스라인 모델

이제 파이토치를 활용해 딥러닝 모델을 만들어 보겠습니다. 베이스라인은 간단한 CNN(합성곱 신경망) 모델을 활용하겠습니다.

 

① 시드값 고정 및 GPU 장비 설정

머신러닝 경진대회에는 없던 단계로, 딥러닝과 파이토치의 특성 때문에 추가된 단계입니다.

 

시드값 고정

파이토치를 임포트하고 시드값을 고정하겠습니다. 시드값을 고정하는 이유는 다시 실행해도 같은 결과를 얻기 위해서입니다.

import torch # 파이토치 
import random
import numpy as np
import os

# 시드값 고정
seed = 50
os.environ['PYTHONHASHSEED'] = str(seed)
random.seed(seed)                # 파이썬 난수 생성기 시드 고정
np.random.seed(seed)             # 넘파이 난수 생성기 시드 고정
torch.manual_seed(seed)          # 파이토치 난수 생성기 시드 고정 (CPU 사용 시)
torch.cuda.manual_seed(seed)     # 파이토치 난수 생성기 시드 고정 (GPU 사용 시)
torch.cuda.manual_seed_all(seed) # 파이토치 난수 생성기 시드 고정 (멀티GPU 사용 시)
torch.backends.cudnn.deterministic = True # 확정적 연산 사용
torch.backends.cudnn.benchmark = False    # 벤치마크 기능 해제
torch.backends.cudnn.enabled = False      # cudnn 사용 해제

 

GPU 장비 설정

머신러닝 경진대회와 달리, 딥러닝 경진대회는 주로 비정형 데이터(이미지, 음성, 텍스트 등)을 다루는데 이것들을 모델링하려면 연산량이 많아집니다. 그래서 CPU보다 훨씬 빠르게 훈련시켜 주는 GPU를 사용해야 하는데, 캐글에서 GPU 환경까지 제공하고 있습니다.

 

아래 코드로 연산에 이용할 장비를 할당합니다. 병렬 처리 플랫폼인 CUDA에서 GPU를 사용할 수 있다면 장비를 'cuda'로, 아니면 'cpu'로 설정하도록 합니다.

if torch.cuda.is_available():
    device = torch.device('cuda')
else:
    device = torch.device('cpu')

그런데 캐글 환경에서 아무 설정도 하지 않으면 기본적으로 CPU를 제공합니다.

device

 

GPU를 사용하기 위해, 오른쪽 [Settings] 탭에서 Accelerator를 GPU로 바꿔 줍니다. (GPU 사용 시간은 주 단위로 제한되어 있으니 사용하지 않을 땐 다시 None으로 바꾸는 게 좋습니다.)

 

Accelerator를 변경하면 코드 환경 전체가 초기화되기 때문에 코드 전체를 재실행해야 합니다. 장비가 CUDA로 바뀐 것을 확인할 수 있습니다.

 

② 데이터 준비

딥러닝 경진대회는 머신러닝 경진대회 때보다 데이터 준비 과정이 복잡합니다.

  • 훈련/검증 데이터 분리
  • 데이터셋 클래스 정의
  • 데이터셋 생성
  • 데이터 로더 생성

먼저 모델링을 위한 데이터를 불러 오고, 압축을 풉니다.

import pandas as pd

# 데이터 경로
data_path = '/kaggle/input/aerial-cactus-identification/'

labels = pd.read_csv(data_path + 'train.csv')
submission = pd.read_csv(data_path + 'sample_submission.csv')
from zipfile import ZipFile

# 훈련 이미지 데이터 압축 풀기
with ZipFile(data_path + 'train.zip') as zipper:
    zipper.extractall()
    
# 테스트 이미지 데이터 압 풀기
with ZipFile(data_path + 'test.zip') as zipper:
    zipper.extractall()

 

훈련 데이터, 검증 데이터 분리

trian_test_split() 함수를 사용해, labels에 담긴 데이터를 훈련 데이터와 검증 데이터로 나눕니다.

from sklearn.model_selection import train_test_split

# 훈련 데이터, 검증 데이터 분리
train, valid = train_test_split(labels, 
                                test_size=0.1,
                                stratify=labels['has_cactus'],
                                random_state=50)

test_size는 검증 데이터의 비율을 뜻하는데, 0.1을 전달했으니 훈련 데이터와 검증 데이터의 비율은 9:1이 되었을 겁니다.

타깃값 분포를 파이 차트로 그려봤을 때 타깃값 비율은 약 3:1이었으므로, stratify 파라미터에 타깃값의 열 이름을 전달하면 훈련 데이터와 검증 데이터 내 타깃값 비율도 약 3:1이 됩니다.

print('훈련 데이터 개수:', len(train))
print('검증 데이터 개수:', len(valid))

비율이 정확히 9:1 이네요!

 

데이터셋 클래스 정의

먼저 데이터셋 생성에 필요한 라이브러리를 임포트하고, 파이토치에서 제공하는 Dataset 클래스를 활용해 데이터셋 객체를 만들 수 있습니다.

import cv2 # OpenCV 라이브러리
from torch.utils.data import Dataset # 데이터 생성을 위한 클래스

class ImageDataset(Dataset):
    # 초기화 메서드(생성자)
    def __init__(self, df, img_dir='./', transform=None):
        super().__init__() # 상속받은 Dataset의 생성자 호출
        # 전달받은 인수들 저장
        self.df = df #Dataframe 객체
        self.img_dir = img_dir #이미지 데이터를 포함하는 경로
        self.transform = transform #이미지 변환기
    
    # 데이터셋 크기 반환 메서드 
    def __len__(self):
        return len(self.df)
    
    # 인덱스(idx)에 해당하는 데이터 반환 메서드 
    def __getitem__(self, idx):
        img_id = self.df.iloc[idx, 0]    # 이미지 ID
        img_path = self.img_dir + img_id # 이미지 파일 경로 
        image = cv2.imread(img_path)     # 이미지 파일 읽기 
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 이미지 색상 보정
        label = self.df.iloc[idx, 1]     # 이미지 레이블(타깃값)

        if self.transform is not None:
            image = self.transform(image) # 변환기가 있다면 이미지 변환
        return image, label

위 코드로 데이터셋을 생성해 줄 ImageDataset 클래스를 정의했습니다.

 

데이터셋 생성

방금 정의한 ImageDataset 클래스를 이용해서 데이터셋을 만들어 보겠습니다.

파이토치 모델로 이미지를 다루려면 ToTensor() 메서드를 사용해 이미지 데이터를 텐서 타입으로 바꿔야 합니다.

from torchvision import transforms # 이미지 변환을 위한 모듈

transform = transforms.ToTensor()

이어서 훈련 데이터셋과 검증 데이터셋을 만듭니다. img_dir에는 이미지 데이터가 저장되어 있는 경로를, transform에는 방금 만든 transform 변환기를 전달합니다.

dataset_train = ImageDataset(df=train, img_dir='train/', transform=transform)
dataset_valid = ImageDataset(df=valid, img_dir='train/', transform=transform)

 

데이터 로더 생성

데이터 로더는 지정한 배치 크기만큼씩 데이터를 불러오는 객체입니다. 딥러닝 모델을 훈련할 때는 주로 배치 단위로 데이터를 가져오는데, 묶음 단위로 훈련하는 게 빠르기 때문입니다.

 

torch.utils.data의 DataLoader 클래스로 데이터 로더를 만듭니다. 배치 크기는 32개씩 불러오게 설정했고, 특정 데이터가 몰려 있을 경우를 대비해 훈련 데이터에서는 shuffle에 True를 전달해 데이터를 섞었습니다.

from torch.utils.data import DataLoader # 데이터 로더 클래스

loader_train = DataLoader(dataset=dataset_train, batch_size=32, shuffle=True)
loader_valid = DataLoader(dataset=dataset_valid, batch_size=32, shuffle=False)

 

③ 모델 생성

먼저 CNN 모델을 만드는 데 필요한 두 모듈을 불러옵니다.

import torch.nn as nn # 신경망 모듈
import torch.nn.functional as F # 신경망 모듈에서 자주 사용되는 함수

CNN 모델은 모든 신경망 모듈의 기반 클래스인 nn.Module을 상속해 정의합니다. (3, 32, 32) 형상의 이미지 데이터를 두 번의 합성곱과 풀링, 평탄화, 전결합 등을 거쳐 최종적으로 값이 0일 확률과 1일 확률을 구할 것입니다. 그리고 순전파 후 결과를 반환하는 메서드인 forward()를 재정의합니다.

nn.Conv2d() 파라미터는 아래와 같습니다.

  • in_channels: 입력 데이터의 채널 수
  • out_channels: 출력 데이터의 채널 수
  • kernel_size: 필터(커널) 크기
  • stride: 스트라이드 크기
  • padding: 패딩 크기
class Model(nn.Module):
    # 신경망 계층 정의 
    def __init__(self):
        super().__init__() # 상속받은 nn.Module의 __init__() 메서드 호출
        
        # 첫 번째 합성곱 계층 
        self.conv1 = nn.Conv2d(in_channels=3, out_channels=32, 
                               kernel_size=3, padding=2) 
        # 두 번째 합성곱 계층 
        self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, 
                               kernel_size=3, padding=2) 
        # 최대 풀링 계층 
        self.max_pool = nn.MaxPool2d(kernel_size=2) 
        # 평균 풀링 계층 
        self.avg_pool = nn.AvgPool2d(kernel_size=2) 
        # 전결합 계층 
        self.fc = nn.Linear(in_features=64 * 4 * 4, out_features=2)
        
    # 순전파 출력 정의 
    def forward(self, x):
        x = self.max_pool(F.relu(self.conv1(x)))
        x = self.max_pool(F.relu(self.conv2(x)))
        x = self.avg_pool(x)
        x = x.view(-1, 64 * 4 * 4) # 평탄화
        x = self.fc(x)
        return x

마지막으로, 이렇게 정의한 Model 클래스로 CNN 모델을 생성하여 device 장비에 할당하겠습니다. 현재 device는 GPU를 사용하도록 설정되어 있습니다.

model = Model().to(device)

model

model을 출력하면 모델의 전체 구조를 보여 줍니다.

 

④ 모델 훈련

훈련하기 전에 손실 함수와 옵티마이저를 정의해야 합니다.

 

손실 함수 설정

신경망 모델 훈련은 가중치를 갱신하는 작업인데, 가중치 갱신은 예측값과 실젯값의 손실이 작아지는 방향으로 이루어집니다. 이때 손실값을 구하는 함수가 손실 함수인데, 여기서는 교차 엔트로피를 사용하겠습니다. 교차 엔트로피는 딥러닝 분류 문제에서 자주 사용하는 손실 함수입니다.

# 손실함수
criterion = nn.CrossEntropyLoss()

 

옵티마이저 설정

옵티마이저는 최적 가중치를 찾아주는 알고리즘입니다. 베이스라인이므로 기본 옵티마이저인 SGD로 설정하겠습니다.

첫 번째 파라미터로 model.parameters()를 전달해 모델의 파라미터들을 최적화하고, lr은 학습률을 의미합니다.

# 옵티마이저
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

 

모델 훈련

본격적으로 모델을 훈련해 볼 건데, 그전에 헷갈리기 쉬운 개념 세 가지를 간단히 정의해 보겠습니다.

  • 에폭 : '훈련 데이터 전체'를 '한 번' 훑었음을 뜻합니다. 보통 수십~수백 에폭만큼 반복 훈련합니다.
  • 배치 크기 : 매 훈련 이터레이션에서 한 번에 훈련할 데이터 개수입니다. 아까 배치 크기를 32로 설정했는데, 훈련 데이터 32개를 한 묶음으로 보고 가중치를 함께 갱신하겠다는 뜻입니다.
  • 반복 횟수 : 1 에폭의 훈련을 완료하는 데 필요한 훈련 이터레이션입니다. 훈련 데이터 개수 % 배치 크기 값입니다. 본 경진대회의 훈련 데이터는 15,750개이고 배치 크기는 32개로 설정했으므로, 반복 횟수는 493번입니다.

이제 모델을 훈련시켜 볼까요? 에폭은 10으로 지정하고, 하나의 에폭이 끝날 때마다 손실값을 출력하도록 합니다.

epochs = 10 # 총 에폭
# 총 에폭만큼 반복
for epoch in range(epochs):
    epoch_loss = 0 # 에폭별 손실값 초기화
    
    # '반복 횟수'만큼 반복 
    for images, labels in loader_train:
        # 이미지, 레이블 데이터 미니배치를 장비에 할당 
        images = images.to(device)
        labels = labels.to(device)
        
        # 옵티마이저 내 기울기 초기화
        optimizer.zero_grad()
        # 순전파 : 이미지 데이터를 신경망 모델의 입력값으로 사용해 출력값 계산
        outputs = model(images)
        # 손실 함수를 활용해 outputs와 labels의 손실값 계산
        loss = criterion(outputs, labels)
        # 현재 배치에서의 손실 추가
        epoch_loss += loss.item() 
        # 역전파 수행
        loss.backward()
        # 가중치 갱신
        optimizer.step()
        
    # 훈련 데이터 손실값 출력
    print(f'에폭 [{epoch+1}/{epochs}] - 손실값: {epoch_loss/len(loader_train):.4f}')

에폭을 거듭하면서 손실값이 점점 줄어드는 것을 보면 훈련이 제대로 이루어지고 있음을 알 수 있습니다!

 

⑤ 성능 검증

훈련이 끝났으니 검증 데이터를 이용해 평가지표인 ROC AUC 값을 구해 보겠습니다.

from sklearn.metrics import roc_auc_score # ROC AUC 점수 계산 함수 임포트

# 실제값과 예측 확률값을 담을 리스트 초기화
true_list = []
preds_list = []

이어서 검증 데이터로 모델 성능을 평가해 봅시다.

model.eval() # 모델을 평가 상태로 설정 

with torch.no_grad(): # 기울기 계산 비활성화
    for images, labels in loader_valid:
        # 이미지, 레이블 데이터 미니배치를 장비에 할당 
        images = images.to(device)
        labels = labels.to(device) 
        
        # 순전파 : 이미지 데이터를 신경망 모델의 입력값으로 사용해 출력값 계산
        outputs = model(images)
        preds = torch.softmax(outputs.cpu(), dim=1)[:, 1] # 예측 확률  
        true = labels.cpu() # 실제값 
        # 예측 확률과 실제값을 리스트에 추가
        preds_list.extend(preds)
        true_list.extend(true)
        
# 검증 데이터 ROC AUC 점수 계산
print(f'검증 데이터 ROC AUC : {roc_auc_score(true_list, preds_list):.4f}')

ROC AUC의 최솟값은 0, 최댓값은 1입니다. 이 모델의 결과는 0.9902로, 쉬운 대회라 베이스라인인데도 점수가 꽤 높게 나왔습니다.

 

예측 확률을 구하는 코드를 조금 자세히 살펴보겠습니다.

preds = torch.softmax(outputs.cpu(), dim=1)[:, 1] # 예측 확률

ROC AUC를 구하려면 각 타깃값의 확률을 먼저 구해야 합니다. 그래서 순전파 출력값인 outputs를 torch.softmax()에 넘겨 타깃값이 0일 확률과 1일 확률을 얻었습니다. 우리가 원하는 값은 타깃값이 1일 확률, 즉 이미지가 선인장을 포함할 확률입니다. 그래서 마지막 코드 [:, 1] 로 호출 결과 중 (0열이 아닌) 1열의 값을 불러왔습니다.

 

⑥ 예측 및 결과 제출

먼저 테스트 데이터를 담은 데이터셋과 데이터 로더를 만들겠습니다.

dataset_test = ImageDataset(df=submission, img_dir='test/', transform=transform)
loader_test = DataLoader(dataset=dataset_test, batch_size=32, shuffle=False)

 

예측

테스트 데이터에서 타깃값이 1일 확률을 예측해 보겠습니다. 검증 데이터로 모델 성능을 평가하는 코드와 비슷한데, 테스트 데이터에는 타깃값이 없어서 for문에 labels 변수는 할당하지 않았습니다.

model.eval() # 모델을 평가 상태로 설정

preds = [] # 타깃 예측값 저장용 리스트 초기화

with torch.no_grad(): # 기울기 계산 비활성화
    for images, _ in loader_test:
        # 이미지 데이터 미니배치를 장비에 할당
        images = images.to(device)
        
        # 순전파 : 이미지 데이터를 신경망 모델의 입력값으로 사용해 출력값 계산
        outputs = model(images)
        # 타깃값이 1일 확률(예측값)
        preds_part = torch.softmax(outputs.cpu(), dim=1)[:, 1].tolist()
        # preds에 preds_part 이어붙이기
        preds.extend(preds_part)

이렇게 해서 for문이 끝나면 preds 변수에 최종 예측 확률이 모두 저장되어 있습니다.

 

결과 제출

방금 구한 최종 예측 확률로 제출 파일을 만들겠습니다.

submission['has_cactus'] = preds
submission.to_csv('submission.csv', index=False)

제출 전에, 훈련 이미지 데이터와 테스트 이미지 데이터는 더 필요가 없으니 모두 삭제하겠습니다.

import shutil

shutil.rmtree('./train')
shutil.rmtree('./test')

 

이제 커밋 후 제출해 보겠습니다.

0.9811점이면 낮은 등수로 만족스럽지 못한 결과입니다.

 

다음 글에서는 모델의 성능을 높여 등수도 올려 보는 실습을 블로깅하겠습니다!

댓글