AI

210523 프로그래머스 머신러닝 데브매칭

완달프 2021. 5. 23. 19:13

개요

프로그래머스 데브매칭에서 진행된 미니대회

아침 8시부터 저녁 6시까지 진행되었다.

이미지를 주고 7가지 클래스로 분류하는 내용이었다.

 

데이터

학습데이터로 1698개가 주어졌고,

테스트데이터로 350개가 주어졌다.

퍼블릭 리더보드에서는 20%만 사용하여 클래스 분류의 정확도를 보여주었고,

종료 이후 프라이빗 리더보드에서 전체 데이터셋에 대한 클래스 분류의 정확도로 랭킹을 매겼다.

사진의 크기는 가로세로 227 픽셀이었고,

분류해야 하는 클래스는 'dog','elephant','giraffe','guitar','horse','house','person' 이었다.

 

사용한 라이브러리

import torch
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import cv2
import random
import os
import glob
import albumentations as A
# !pip install --upgrade --force-reinstall --no-deps albumentations
from albumentations.pytorch.transforms import ToTensorV2
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from torchvision.transforms import Resize, ToTensor, Normalize
# !pip install efficientnet_pytorch
from efficientnet_pytorch import EfficientNet

 

데이터 다운로드

# !wget train.zip
# !wget test.zip
# !unzip test.zip
# !unzip train.zip

 

시드 고정하기

나는 언제나 777을 사용한다.

def fix_seed(seed):
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)  # if use multi-GPU
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False
    np.random.seed(seed)
    random.seed(seed)
fix_seed(777)

 

경로 설정 및 간단한 확인

clas = ['dog','elephant','giraffe','guitar','horse','house','person']
clas2id = {
    'dog': 0,
    'elephant': 1,
    'giraffe': 2,
    'guitar': 3,
    'horse': 4,
    'house': 5,
    'person': 6
}
id2clas = {v: k for k, v in clas2id.items()}
test_dir = "/content/test/0"
train_dir = "/content/train"
train_dirs = [os.path.join(train_dir, cla) for cla in clas]
train_dirs

 

EDA

8시간 안에 제출까지 해야했고, 사진데이터이기에 EDA에 시간투자를 많이 하기 어려웠다.

사진 픽셀 크기같은 경우는 몇개 열어보고, 모두가 가로세로 227 픽셀인것을 확인했기에 따로 확인하지 않았다.

for i, d in enumerate(train_dirs):
    file_list = os.listdir(d)
    print(f"{clas[i]} 클래스의 사진 갯수는 {len(file_list)}개 입니다.")

 

데이터셋 설정

새로 알게 된 점

여기서 조금 당황했었는데, 이번에 augmentation 툴로 albumentation을 사용해보려고 PIL로 사진을 불러왔다가,

엄청나게 많은 오류 데이터를 보게 되었다.

파이토치 transform을 사용하지않고, albumentation을 사용할때에는 cv2를 사용해야 한다.

 

테스트 데이터는 라벨링 데이터가 없고, 학습 데이터는 라벨링 데이터를 들도록 설정했다.

새로 알게 된 점

여기서 또 배운점은 glob 라이브러리는 폴더명을 반환할때 정렬된 상태로 반환하는것이 아니기때문에,

이번 대회처럼 test 데이터를 순차적으로 내뱉어야 되는 경우에는 파일명으로 정렬을 따로 해주어야 한다.

# 테스트 데이터 요청시 이미지를 반환합니다.
class TestDataset(Dataset):
    def __init__(self, test_dir, transform):
        self.transform = transform
        self.paths = [path for path in sorted(glob.iglob(test_dir+"/*", recursive=True))]
        
    def __getitem__(self, index):
        image = cv2.imread(self.paths[index])
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        if self.transform:
            image = self.transform(image=image)["image"]
        return image
    
    def __len__(self):
        return len(self.paths)
    
# 학습 데이터 요청시 이미지와 클래스 id를 반환합니다.
class TrainDataset(Dataset):
    def __init__(self, train_dir, transform):
        self.transform = transform
        self.paths = []
        self.labels = []
        for path in glob.iglob(train_dir+"/**/*"):
            self.paths.append(path)
            self.labels.append(clas2id[path.split("/")[-2]])

    def __getitem__(self, index):
        image = cv2.imread(self.paths[index])
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
        if self.transform:
            image = self.transform(image=image)["image"]
        label = self.labels[index]
        return image, label
    
    def __len__(self):
        return len(self.paths)

 

데이터로더 설정

배치사이즈, 워커사이즈, 셔플여부, 어그멘테이션을 설정한다.

최종적으로는 k fold를 포기하고 train all을 선택하면서,

검증데이터없이 학습데이터로만 20 epoch학습하고 최종 모델을 저장했다.

 

아쉬운 점

가장 아쉬운 점은 어그멘테이션을 애슐리 뷔페먹는식으로 맛있어보이는 어그멘테이션을 죄다 집어넣었다.

사진 분류 태스크에서 어떤 어그멘테이션이 유용하고 도움이 되는지 알아보고 싶다.

 

새로 알게 된 점

학습데이터와 검증데이터를 나누는 방법에 대해서 한번 더 짚고 넘어 갈 수 있었다.

문제는 학습데이터에 어그멘테이션을 넣어놓고 나눌경우에는 검증데이터도 강제로 어그멘테이션을 하게 된다는 점이었는데,

어그멘테이션을 넣지않고 나눈다음에 직접 객체에 접근하여 멤버변수(?)로 넣어줄 수 있었다.

test_transform = A.Compose([
    ToTensorV2(p=1.0)
])
train_transform = A.Compose([
    A.RandomCrop(200, 200, p=0.2),
    A.OneOf([
      A.HorizontalFlip(p=1),
      A.RandomRotate90(p=1),
      A.VerticalFlip(p=1),        
    ], p=0.25),
    A.RandomGamma(p=0.2),
    A.RandomBrightnessContrast(p=0.2),
    A.RGBShift(p=0.2),
    A.Blur(p=0.2),
    A.JpegCompression(p=0.2),
    A.MotionBlur(p=0.2),
    A.OpticalDistortion(p=0.2),
    A.GaussNoise(p=0.2),
    ToTensorV2(p=1.0)
])
test_dataset = TestDataset(test_dir, transform=test_transform)
train_valid_dataset = TrainDataset(train_dir, transform=None)
train_count = int(len(train_valid_dataset) * 1) # 1이면 train_all
valid_count = len(train_valid_dataset) - train_count
train_dataset, valid_dataset = torch.utils.data.random_split(
    train_valid_dataset,
    [train_count, valid_count]
)
train_dataset.dataset.transform = train_transform
valid_dataset.dataset.transform = test_transform

print(f"학습데이터: {len(train_dataset)}개")
print(f"검증데이터: {len(valid_dataset)}개")

batch_size = 8

test_loader = DataLoader(
    test_dataset,
    num_workers = 2,
    shuffle = False
)
train_loader = DataLoader(
    train_dataset,
    batch_size = batch_size,
    num_workers = 2,
    shuffle = False
)
valid_loader = DataLoader(
    valid_dataset,
    batch_size = batch_size,
    num_workers = 2,
    shuffle = False
)

 

학습하기

이전 CNN 관련 태스크에서 EfficientNet이 가장 빠르고 성능이 좋았기 때문에 pretrained 모델과 함께 가져왔다.

b4가 가성비(학습시간대비 성능)가 가장 좋아서 어그멘테이션이나 하이퍼파라미터를 테스트하고 최종에는 b5로 학습을 진행했다.

아쉬운 점

아무생각없이 무지성 EfficientNet을 사용했는데, 어떤 태스크에 좋고, 다른 어떤 모델이 더 좋을 수 있는지 알아보고  싶다.

 

처음에 lr을 아담 국민 학습률인 5e-4로 했다가, 최대 성능이 90프로 아래로 나와서 1e-4로 바꿨더니 성능이 90프로대 초반까지 나왔다.

epochs는 처음에는 10을 사용했다가 어그멘테이션이 추가되면서 20으로 늘려줬다.

 

새로 알게된 점

학습을 하다보니 어느 이상으로는 성능이 오르지 않았다.

그래서 극한의 성능을 얻기위해 스케줄러를 적용했다.

스케줄러는 써본적이 없었는데, 성능을 올리고 싶은 마음에 써보게 되었다.

파이토치에 내장된 ReduceLROnPlateau를 사용하였는데,

특정 횟수동안 특정 값이 오르거나 내리지 않는 경우 lr을 특정 비율만큼 변경할 수 있다.

처음에는 스케줄러를 적용해도 lr이 변경되지 않아서 낚인줄 알았는데,

특정 횟수가 10으로 기본값이 적용되어 있어서 변경되지 않은 것이었다.

최종으로는 사용하지 않았는데, optimizer.step()에 일반적으로 validation loss나 val을 넣어주는데,

train all을 하면서 validation 메트릭을 얻을 수 없었기 때문이다.

 

새로 알게된 점

파이토치가 lr을 optimizer에 넣어놓지만, 이를 가져오는 메소드는 따로 없다.

그래서 get_lr 메소드를 만들어서 끄집어와야한다.

# 학습 기본 환경 설정
lr = 1e-4
epochs = 20
log_interval = 30
device = torch.device("cuda")
save_dir = "/contents/models/efficientnet-b5"
os.makedirs(save_dir, exist_ok=True)

# 모델 준비
model = EfficientNet.from_pretrained("efficientnet-b5", num_classes=7).to(device)
model.train()

# loss 및 optimizer 설정
criterion = torch.nn.CrossEntropyLoss().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
# scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer, verbose=True, mode="min", patience=1, factor=0.8) # train_all에서는 사용하지 않음
print(f"learning rate: {lr}")
print(f"target epoch : {epochs}")
print(f"batch size   : {batch_size}")

def get_lr(optimizer):
    for param_group in optimizer.param_groups:
        return param_group['lr']

best_val_acc = 0
best_val_loss = np.inf
for epoch in range(1, epochs+1):
  # 학습모드
  model.train()
  loss_value = 0
  matches = 0
  print(f"Epoch[{epoch}] train started")
  for idx, batch in enumerate(train_loader):
    inputs, labels = batch
    inputs = inputs.float().to(device)
    labels = labels.to(device)

    optimizer.zero_grad()
    outs = model(inputs)
    preds = torch.argmax(outs, dim=-1)
    loss = criterion(outs, labels)

    loss.backward()
    optimizer.step()

    loss_value += loss.item()
    matches += (preds == labels).sum().item()
    if (idx + 1) % log_interval == 0:
      train_loss = loss_value / log_interval
      train_acc = matches / batch_size / log_interval
      current_lr = get_lr(optimizer)
      print(
        f"Epoch[{epoch}/{epochs}]({idx + 1}/{len(train_loader)}) || "
        f"training loss {train_loss:4.4} || training accuracy {train_acc:4.2%} || lr {current_lr}"
      )
      # logger.add_scalar("Train/loss", train_loss, epoch * len(train_loader) + idx)
      # logger.add_scalar("Train/accuracy", train_acc, epoch * len(train_loader) + idx)

      loss_value = 0
      matches = 0
  
  with torch.no_grad():
    # 예측모드
    model.eval()
    print(f"Epoch[{epoch}] validation started")
    val_loss_items = []
    val_acc_items = []
    for val_batch in valid_loader:
      inputs, labels = val_batch
      inputs = inputs.float().to(device)
      labels = labels.to(device)

      outs = model(inputs)
      preds = torch.argmax(outs, dim=-1)

      loss_item = criterion(outs, labels).item()
      acc_item = (labels == preds).sum().item()
      val_loss_items.append(loss_item)
      val_acc_items.append(acc_item)

    val_loss = np.sum(val_loss_items) / len(valid_loader)
    val_acc = np.sum(val_acc_items) / len(valid_dataset)
    best_val_loss = min(best_val_loss, val_loss)
    if val_acc > best_val_acc:
      print(f"New best model for val accuracy : {val_acc:4.2%}! saving the best model..")
      torch.save(model.state_dict(), f"{save_dir}/best.pth")
      best_val_acc = val_acc
    torch.save(model.state_dict(), f"{save_dir}/last.pth")
    print(
      f"[Val] acc : {val_acc:4.2%}, loss: {val_loss:4.2} || "
      f"best acc : {best_val_acc:4.2%}, best loss: {best_val_loss:4.2}"
    )
    # logger.add_scalar("Val/loss", val_loss, epoch)
    # logger.add_scalar("Val/accuracy", val_acc, epoch)
    # logger.add_figure("results", figure, epoch)
    print()

  # scheduler.step(val_loss) # train_all에서는 사용하지 않음

 

예측하기

처음엔 간단한 코드였는데, tta를 적용하면서 양이 조금 늘어났다.

ttach 라이브러리를 사용하면 tta에 사용할 어그멘테이션만 적용해주면 알아서 최종 결정을 해준다.

아쉬운 점

다른 분은 tta 할때 그냥 albumentation에 정의해두고 여러번 모델에 넣어보는 식으로 했다고 한다.

나도 그렇게 할걸!

ttach 라이브러리를 사용하면 세부적인 가중치나 투표 방법(?)을 설정하기 어렵다.

어찌되었든 tta를 적용하면서 퍼블릭 리더보드 성능을 90프로 후반대까지 끌어올렸다.

 

새로 알게 된 점

90 rotate를 tta에 적용하니 성능이 폭락했다.

여태 다른 태스크에서도 멀티 스케일을 tta에 적용할 때는 성능이 올랐는데,

이번에도 올랐다.

다음에는 간단한 horizontal flip과 멀티 스케일부터 적용하면서 실험해 봐야겠다.

# !pip install ttach
import ttach as tta

# 모델 불러오기
model_path = os.path.join(save_dir, 'last.pth') # train_all에서는 last, train에서는 best
model = EfficientNet.from_pretrained("efficientnet-b5", num_classes=7)
model.load_state_dict(torch.load(model_path))

# test time augmentation model wrapper
transforms = tta.Compose(
    [
        tta.HorizontalFlip(),
        # tta.Scale(scales=[1, 2, 4]),
        tta.Multiply(factors=[0.8, 0.9, 1, 1.1]),        
    ]
)
model = tta.ClassificationTTAWrapper(model, transforms)
model = model.to(device)

predictions = []
with torch.no_grad():
  model.eval()
  count = 0 
  for image in test_loader:
    image = image.float().to(device)
    pred = model(image)
    pred = pred.argmax(dim=-1)
    predictions.extend(pred.cpu().numpy())
    count += 1
    if count % 20 == 0:
      print(f"prediction {count}/{len(test_loader)}")
submission = pd.DataFrame()
submission["answer value"] = predictions
submission.to_csv("test_answer.csv")
print("prediction is done")

 

실험방법

순번 내용 결과
1 EfficientNet b4로 학습 -
2 SGD로 변경해봄 성능하락-기각
3 lr 변경(5e-4에서 1e-4로) 성능증가-채택
4 Augmentation 적용 성능증가-채택
5 EfficnentNet B5로 학습 성능증가-채택
6 TTA 적용 성능증가-채택

 

그래서 결과는?

퍼블릭 리더보드 : 98.571 / 12등 / 1문제 틀림

프라이빗 리더보드 : 94.857 / 49등 / 꽤 틀린듯... 현타..

 

최종적으로 아쉬운 점

  • 퍼블릭 리더보드와 프라이빗 리더보드가 차이날때는 어떻게 해야할지 모르겠다. validation으로 성능한 측정인데도 이렇게 차이날때는 무엇이 잘못된건지 좀 알아봐야겠다.
  • 어그멘테이션을 어떤 것을 적용해야 좋은지 모르겠다. 일반적으로 적용하는 목록 같은게 있을 것 같은데 알아봐야겠다.
  • k-fold를 적용 못했다. 대회시간이 이렇게 짧은데 어떻게 k-fold를 적용하는거지..
  • 앙상블도 적용 못했다. 다음에 할때는 모델을 다 버리지 않고 모아둔 다음에 짬뽕해봐야겠다.

'AI' 카테고리의 다른 글

Segmentation  (0) 2021.04.26
PORORO 자연어처리 라이브러리  (0) 2021.04.20