본문 바로가기
IT/머신러닝&딥러닝

[머신러닝/딥러닝] 항공 사진 내 선인장 식별 경진대회 (3)_모델 성능 개선

by vulter3653 2023. 1. 30.

<머신러닝. 딥러닝 문제해결 전략> 3 11장 실습하고 해당 내용 정리한 내용입니다.

 

https://www.kaggle.com/c/aerial-cactus-identification

 

Aerial Cactus Identification | Kaggle

 

www.kaggle.com

1. 성능 개선

베이스라인에서는 간단한 CNN모델을 사용했었습니다. 이번 절에서는 다음 네 가지를 개선해 성능을 높여보겠습니다.

 

1. 다양한 이미지 변환을 수행합니다.

2. 더 깊은 CNN 모델을 만듭니다.

3. 더 뛰어난 옵티마이저를 사용합니다.

4. 훈련 시 에폭 수를 늘립니다.

 

이상의 네 가지를 제외하고는 베이스라인과 코드가 비슷합니다. 절차는 다음과 같습니다.

시드값 고정 및 GPU 장비 설정
데이터 준비 1. 훈련/검증 데이터 분리
2. 데이터셋 클래스 정의
3. 이미지 변환기 정의
4. 데이터셋 생성
5. 데이터 로더 생성
모델 생성 (깊은 CNN) 배치 정규화
Leaky ReLU
모델 훈련 1. 손실 함수와 (더 나은) 옵티마이저 설정
2. 모델 훈련 (에폭 수 증가)
성능 검증
예측 및 제출

2. 데이터 준비

시드값 고정부터 '데이터 준비'의 '2. 데이터셋 클래스 정의'까지는 베이스라인과 동일합니다.

- 이미지 변환과 데이터 증강

앞서 ImageDataset 클래스로 데이터셋을 만들 때 이미지 변환기를 적용할 수  있다고 했습니다. 다음은 많이 쓰이는 이미지 변환의 예입니다.

이렇게 원본 이미지를 다양한 방법으로 하나의 데이터로 더 많은 데이터를 생성이 가능해집니다. 딥러닝 모델을 대체로 훈련 데이터가 많을수록 정확해지므로 확보한 데이터가 부족할 때 특히 유용합니다. 이렇게 이미지를 변환하여 데이터 수를 늘리는 방식을 데이터 증강이라고 합니다.

 

다음은 파이토치용 컴퓨터 비전 라이브러리인 torchvision의 transforms 모듈이 제공하는 주요 변환기들입니다.

 

● Compose() : 여러 변환기를 묶어줌

● ToTensor() : PIL이미지나 ndarray를 텐서로 변환

● Pad() : 이미지 주변에 패딩 추가

● RandomHorizontalFlip() : 이미지를 무작위로 좌우 대칭 변화

● RandomVerticalFlip() : 이미지를 무작위로 상하 대칭 변화

● RandomRotation() : 이미지를 무작위로 회전

● Normalize() : 텐서 형태의 이미지 데이터를 정규화

 

이처럼 다양한 변환기들을 Compose()로 묶어 하나의 변환기처럼 사용할 수 있습니다.

- 이미지 변환기 정의

데이터를 증강해줄 이미지 변환기를 직접 정의해보죠. 성능을 개선하기 위해 다양한 이미지 변환기를 활용할 텐데, 훈련 데이터용과 검증 및 테스트 데이터용을 따로 만듭니다. 훈련 시에는 모델을 다양한 상황에 적응시키는 게 좋지만, 평가 및 테스트 시에는 원본 이미지와 너무 달라지면 예측하기 어려워질 수 있기 때문입니다.

transforms.Compose()로 여러 변환기를 하나로 묶었습니다. 사용된 변환기들을 하나씩 살펴보죠.

 

ⓐ transforms.ToTensor() : 이미지를 텐서 객체 만듭니다. 이어서 수행되는 다른 transforms 변환기들이 텐서 객체를 입력받기 때문에 가장 앞단에 추가했습니다.

 

ⓑ transforms.Pad() : 이미지 주변에 패딩을 추가합니다. 여기서는 32를 전달했으므로 32 x 32 크기인 원본 이미지 주변에 32 두께의 패딩을 두른 것입니다. 이미지의 가로, 세로 크기가 각각 세 배가 되겠네요. 한편, padding_mode='symmetric'은 패딩 추가 시  원본 데이터를 상하, 좌우 대칭이 되는 모양으로 만들어줍니다. 상하, 좌우 대칭한 선인장 이미지가 8개나 더 추가된 셈이니 원본 이미지 하나일 때보다 선인장을 더 잘 식별할 거라 기대해볼 수 있습니다.

 

ⓒ transforms.RandomHorizontalFlip(), ⓓ transforms.RandomVerticalFlip() : 각각 무작위로 이미지를 좌우, 상하 대칭 변환합니다. 파라미터에는 변환할 이미지의 비율을 설정할 수 있으며, 기본값은 0.5입니다. 기본값이면 전체 이미 중 50%를 무작위로 뽑아 대칭을 변환합니다.

 

ⓔ tranforms.RandomRotation() : 이미지를 회전시킵니다. 파라미터로 10을 전달하면 -10도 ~ 10도 사이의 값만큼 무작위 회전합니다.

 

ⓕ transforms.Normalize() : 데이터를 지정한 평균과 분산에 맞게 정규화해줍니다. 0 ~ 1 사이 값으로 설정해주면 되는데, 여기서는 평균을 (0.485, 0.456, 0.406)으로, 분산을 (0.229, 0.224, 0.225)로 정규화했습니다.

 

평균과 분산이 세 개씩 있는 이유와 값이 다음과 같은 이유는 아래와 같습니다.

 

첫째, 평균과 분산이 각각 세 개씩 있는 이유이미지 데이터의 색상을 빨강(R), 초록(G), 파랑(B)으로 구성돼 있기 때문입니다. 따라 빨강, 초록, 파랑을 각각 정규화해야 해서 평균과 분산에 값을 세 개씩 전달한 겁니다.

 

둘째, 평균은 (0.485, 0.456, 0.406)이고 분산은 (0.229, 0.224, 0.225)인 이유는 다른 값으로 해도 상관없지만 이미지를 다룰 때는 보통 이 값들로 정규화합니다. 이 값들은 백만 개 이상의 이미지를 보유한 이미지넷의 데이터로부터 얻은 값이기에  내가 사용할 이미지들로부터 평균과 분산을 직접 구해도 되지만 번거롭기 때문에 대게 이 값을 그대로 사용합니다. 일반적으로 성능도 잘 나옵니다.

- 데이터셋 및 데이터 로더 생성

ImageDataset 클래스로 훈련 및 검증 데이터셋을 만듭니다. 전달하는 변환기를 제외하면 베이스라인 코드와 똑같습니다. 훈련 데이터셋을 만들 때는 훈련용 변환기를, 검증 데이터셋을 만들 때는 검증/테스트용 변환기를 전달합니다.

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

데이터 로더도 만들겠습니다.

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)

앞서 데이터셋을 만들 때 이미지 변환기를 전달했습니다. 그러면 데이터 로더로 데이터를 불러올 때마다 이미지 변환을 수행합니다. 이때 변환기 중 RandomHorizontalFlip(),RandomVerticalFlip(),RandomRotation()은 변환을 무작위로 가하기 때문에 매번 다르게 변환합니다. 즉, 원본 이미지는 같지만 첫 번째 에폭과 두 번째 에폭에서 서로 다른 이미지로 훈련하는 효과를 얻을 수 있는 거죠. 이것이 바로 '데이터 증강' 기법입니다.

3. 모델 생성

데이터가 준비되었으니 이제 CNN모델을 설계해봅시다. 베이스라인에는 합성곱과 최대 풀링 계층이 두 개씩이고, 이어서 평균 풀링 계층과 전결합 계층이 하나씩 있었습니다.

 

이번에는 더 깊은 CNN을 만들겠습니다. 신경망 계층이 깊어지면 대체로 예측력이 좋아집니다. 다만 지나치게 깊으면 과대적합될 우려가 있으니 유의해야 합니다. 배치 정규화를 적용하고 활성화 함수를 Leaky ReLU로 바꿔서 성능을 더 높이겠습니다. 또한 {합성곱, 배치 정규화, 최대 풀링 계층} 계층이 총 다섯 개에, 전결합 계층도 두 개로 늘리겠습니다.

 

nn.Sequential()을 활용해 신경망 계층을 설계하겠습니다.

 

베이스라인보다 복잡하지만, nn.Sequential()을 이용한 점과 계층이 더 많아진 점, 그리고 배치 정규화와 LeakyReLU()를 추가한 점 빼고 전체적인 구조는 비슷합니다. 달라진 부분만 간단히 설명하겠습니다.

ⓐ 의 nn.BatchNorm2d(32)가 배치 정규화를 적용하는 코드입니다. 파라미터로 채널 수를 전달하면 되는데, 바로 앞의 합성곱 연산(nn.Conv2d)을 거치면 채널이 32개가 됩니다.

 

ⓑ 에서는 활성화 함수를 Leaky ReLU로 지정합니다. ReLU를 적용할 때보다 성능이 조금 더 좋아질 수 있습니다.

ⓒ 에서 전결합 계층 두 개를 정의합니다. nn.Linear()의 파라미터 in_features에 512 * 1* 1 을 전달했네요. 평균 풀링 계층을 거친 후 데이터 개수가 512개여서 그렇습니다.  공식을 통해 하나하나 계산하면 구할 수 있습니다. 또한 크기가 32인 패딩을 추가했기 때문에 초기 이미지의 형상이 (32, 3, 32, 32)가 아니라 (32, 3, 96, 96)입니다.

 

마지막으로 방금 정의한 Model 클래스를 활용해 CNN 모델을 만든 뒤, 장비에 할당하겠습니다.

model = Model().to(device)

4. 모델 훈련

베이스라인 때와 마찬가지로 손실 함수와 옵티마이저를 설정한 후 훈련에 돌입하겠습니다.

- 손실 함수와 옵티마이저 설정

손실 함수는 베이스라인과 동일하게 CrossEntropyLoss()로 하겠습니다.

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

옵티마이저는 Adamax로 바꿔보겠니다. Adamax가 SGD나 Adam보다 항상 나은 결과를 보장하는 건 아니며, 실제로 테스트해보기 전까진 어떤 옵티마이저가 더 좋은지 판단하기 쉽지 않으니 여러 차례 실험을 해보는 게 좋습니다.

 

학습률을 0.00006으로 낮게  설정해줬습니다.

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

- 모델 훈련

데이터를 증강시켜 훈련할 데이터가 더 많이졌기에 훈련을 더 많이 하기 위해 에폭 수를 10에서 70으로 늘리겠습니다. 나머지 코드는 베이스라인과 동일합니다.

epochs = 70 # 총 에폭

# 총 에폭만큼 반복
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}') 

총 70 에폭만큼 훈련하며 손실값을 출력했습니다.  출력 로그는 아래와 같이 정상적으로 나오는 것을 확인할 수 있습니다.

5. 성능 검증

검증 데이터로 모델 성능을 평가해보겠습니다. 역시 코드는 베이스라인과 다를 바 없습니다.

베이스라인의 ROC AUC는 0.9900이었는데, 개선 작업이 효과를 발휘해 0.9998이 되었습니다. ROC AUC의 최댓값이 1이니 거의 완벽에 가까운 점수입니다. 검증 데이터를 거의 완벽히 분류해냈다는 뜻입니다.

6. 예측 및 결과 제출

이제 테스트 데이터로 예측해보겠습니다.이번에도 transform_test 변환기를 이용해 데이터셋을 만들었습니다.

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

# 예측 수행
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)

제출 파일을 만듭니다.

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

이미지 파일은 더 이상 필요 없으니 디렉터리째로 삭제합니다.

import shutil

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

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

최종 점수는 0.9998입니다. 0.9811을 기록한 베이스라인보다 0.0187 오른 것을 확인할 수 있습니다.

- 한 걸음 더

점수를 조금 더 높일 수 있는 간단한 방법이 있습니다. 전체 모델링 절차는 그대로 두고 '훈련 데이터 전체'로 모델을 훈련하는 것입니다.

 최종 점수가 0.9999로 0.9998보다도 0.0001 오른 것을 확인할 수 있습니다.