Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

데이터 증식

소규모 이미지 데이터로 합성곱 신경망을 훈련하면, 모델은 빠르게 훈련 데이터에 과적합됩니다. 훈련 손실은 계속 내려가지만 검증 손실은 어느 시점부터 오히려 올라가는 전형적인 패턴이 나타납니다. 근본 원인은 데이터의 다양성 부족입니다. 모델이 고양이와 개를 구분하는 데 필요한 일반적인 특징 대신, 특정 이미지에만 나타나는 우연한 패턴까지 외워 버리기 때문입니다.

데이터 증식(Data Augmentation)은 기존 훈련 이미지에 무작위 변형을 가해, 매 에폭마다 조금씩 다른 버전의 이미지를 모델에게 보여 주는 기법입니다. 좌우를 뒤집거나, 약간 회전시키거나, 일부를 잘라 확대하는 식입니다. 사람의 눈에는 같은 고양이지만 픽셀 값은 모두 다르므로, 모델은 더 다양한 변형을 두루 보면서 우연한 패턴 대신 본질적인 특징에 의존하게 됩니다. 결과적으로 새 데이터의 양을 늘리지 않고도 과적합을 늦추고 일반화 성능을 끌어올릴 수 있습니다.

이 장에서는 고양이/개 이진 분류 문제를 그대로 가져와, 데이터 파이프라인에 증식을 끼워 넣었을 때 학습 곡선이 어떻게 달라지는지 확인합니다.

1데이터셋과 파이프라인 준비

먼저 이미지 폴더를 읽어 들이는 데이터셋 클래스와 학습 보조 함수들을 불러옵니다. CatDogDataset은 폴더 안의 *.jpg 파일 목록을 만들고, 파일 이름에 cat이 들어 있으면 0, 아니면 1로 레이블을 붙입니다. 핵심은 __getitem__이 호출될 때마다 self.transform을 거쳐 샘플을 반환한다는 점입니다. 바로 이 transform 자리에 데이터 증식 변형을 꽂아 넣으면, 같은 색인을 두 번 조회해도 매번 다르게 변형된 이미지가 나오게 됩니다.

get_dataloaders는 분할별로 DataLoader를 만들되 훈련 분할만 shuffle=True로 섞고, get_train_results는 손실과 정확도 곡선을 나란히 그려 줍니다.

# %load catdog.py
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import PIL.Image as Image
import torch

class CatDogDataset:
    def __init__(self, 폴더, transform=None):
        self.폴더 = Path(폴더)
        self.파일들 = list(self.폴더.glob('**/*.jpg'))
        self.labels = {'cat': 0, 'dog': 1}
        self.transform = transform
    
    def __len__(self):
        return len(self.파일들)
    
    def __getitem__(self, 색인):
        파일 = self.파일들[색인]
        target = self.labels['cat' if 'cat' in 파일.stem else 'dog']
        with Image.open(파일) as img:
            sample = img.copy()
        if self.transform:
            sample = self.transform(sample)
        return sample, target
    

def get_dataloaders(datasets, **설정):
    print('# 데이터로더')
    dataloaders = {}
    for split in datasets.keys():
        dataloaders[split] = torch.utils.data.DataLoader(
            datasets[split], shuffle=(split=='train'),
            **설정
        )
        print(f'{split:<10}: {len(dataloaders[split]):,} batches')
    return dataloaders

def get_train_results(history):
    train_results = pd.DataFrame(history.history)
    plt.figure(figsize=(10, 4))
    왼쪽 = plt.subplot(1, 2, 1) # 1행 2열의 1번째 위치
    train_results.plot(y=['loss', 'val_loss'], ax=왼쪽)
    오른쪽 = plt.subplot(1, 2, 2) # 1행 2열의 2번째 위치
    train_results.plot(y=['accuracy', 'val_accuracy'], ax=오른쪽)
    plt.tight_layout()
    plt.show()
    return train_results

2훈련 데이터에만 증식 적용

증식에서 가장 중요한 원칙은 변형을 훈련 데이터에만 적용한다는 것입니다. 검증과 시험 데이터까지 무작위로 변형하면 평가 결과가 매번 흔들려 모델의 실제 성능을 가늠할 수 없게 됩니다. 따라서 전처리를 두 갈래로 나눕니다.

검증·시험용 전처리는 크기만 180×180180 \times 180으로 맞추고 텐서로 바꿉니다. 반면 훈련용 훈련전처리에는 세 가지 무작위 변형을 추가합니다.

변형역할
RandomHorizontalFlip이미지를 좌우로 뒤집어 좌우 방향에 대한 불변성을 학습
RandomRotation(20)±20\pm 20^\circ 범위에서 무작위 회전, 기울어진 피사체에 대응
RandomResizedCrop(scale=(0.8, 1.0))원본의 80~100% 영역을 무작위로 잘라 확대, 위치·크기 변화에 대응

이렇게 하면 분할마다 알맞은 전처리가 붙은 CatDogDataset이 만들어집니다.

# 검증/시험 데이터는 원래 전처리
전처리 = transforms.Compose([
    transforms.Resize((180, 180)),
    transforms.ToTensor()
])
# 훈련 데이터에만 적용
훈련전처리 = transforms.Compose([
    transforms.RandomHorizontalFlip(), # 좌우 반전
    transforms.RandomRotation(20), # 20도 범위 내에서 랜덤 회전
    transforms.RandomResizedCrop((180, 180), scale=(0.8, 1.0)), # 랜덤 크롭 및 리사이즈
    transforms.ToTensor()
])

for split in ['train', 'validation', 'test']:
    datasets[split] = CatDogDataset(
        폴더 / split, transform=훈련전처리 if split == 'train' else 전처리)

2.1같은 이미지, 매번 다른 모습

증식이 실제로 작동하는지는 같은 샘플을 여러 번 꺼내 보면 한눈에 드러납니다. 아래 코드는 훈련 데이터의 0번 이미지 하나를 열 번 반복해서 조회합니다. 인덱스가 모두 같은데도 출력되는 그림은 매번 다릅니다. 어떤 것은 뒤집혀 있고, 어떤 것은 살짝 기울었으며, 어떤 것은 다른 부분이 확대돼 있습니다. 텐서는 (C, H, W) 순서이므로 permute(1, 2, 0)(H, W, C)로 바꿔 imshow에 넘깁니다.

plt.figure(figsize=(10, 4))
for i in range(10):
    sample, label = datasets['train'][0]
    plt.subplot(2, 5, i + 1) # 2행 5열의 i+1번째 위치
    plt.imshow(sample.permute(1, 2, 0)) # (C, H, W) -> (H, W, C)
    plt.axis('off')

plt.tight_layout()
plt.show()

3모델 구성과 학습

모델은 합성곱과 풀링을 번갈아 쌓은 표준적인 CNN입니다. 입력은 채널 우선(channels first) 형식의 (3, 180, 180)으로 받고, 케라스 합성곱이 기대하는 채널 마지막(channels last) 형식으로 맞추기 위해 첫 레이어에서 Permute((2, 3, 1))로 축을 재배열합니다. 이후 채널 수를 32에서 256까지 키우며 특징을 추출하고, 마지막은 sigmoid 한 노드로 고양이/개 확률을 출력합니다. 이진 분류이므로 손실은 BinaryCrossentropy, 최적화기는 학습률을 자동으로 조정해 주는 RMSprop을 씁니다.

ModelCheckpoint 콜백은 검증 손실(val_loss)이 가장 낮은 순간의 가중치만 catdog.keras로 저장합니다. 증식 덕분에 과적합이 늦춰지므로 100 에폭처럼 긴 학습에서도 검증 손실이 꾸준히 개선될 여지가 생기고, 그 최적 시점을 콜백이 자동으로 붙잡아 둡니다.

from keras import layers

keras.backend.clear_session()
# keras.backend.set_image_data_format('channels_last')

model = keras.Sequential()
model.add(keras.Input(shape=(3, 180, 180))) # channels first 형식
# (C, H, W) -> (H, W, C)로 변환하는 레이어
model.add(layers.Permute((2, 3, 1))) # (B, C, H, W) -> (B, H, W, C)
# 은닉층
for units in [32, 64, 128, 256]:
    model.add(layers.Conv2D(
        units, kernel_size=3, activation='relu'))
    model.add(layers.MaxPooling2D(pool_size=2))

model.add(layers.Conv2D(256, kernel_size=3, activation='relu'))
# 출력층
model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid')) # 이진 분류 출력

model.summary()

model.compile(
    # 이진 분류 문제이므로, BinaryCrossentropy 손실 함수를 사용
    loss=keras.losses.BinaryCrossentropy(),
    # 학습률 튜닝을 줄이기 위해 자동 학습률 조정 가능한 RMSprop 사용
    optimizer=keras.optimizers.RMSprop(),
    metrics=['accuracy']
)

%run catdog.py
dataloaders = get_dataloaders(
    datasets, batch_size=32, num_workers=8)

history = model.fit(
    dataloaders['train'],
    validation_data=dataloaders['validation'],
    epochs=100,
    callbacks=[
        keras.callbacks.ModelCheckpoint(
            'catdog.keras', save_best_only=True, monitor='val_loss'),
    ]
)

4평가와 학습 곡선

학습이 끝나면 마지막 에폭의 가중치가 아니라 저장해 둔 최적 모델을 다시 불러와 시험 데이터로 평가합니다. 마지막 에폭이 곧 최선은 아니기 때문입니다. 이어서 get_train_results로 손실과 정확도 곡선을 그립니다.

증식을 적용한 학습 곡선의 특징은 훈련 곡선과 검증 곡선이 오래도록 함께 따라간다는 점입니다. 증식이 없을 때처럼 두 곡선이 일찍부터 벌어지지 않으므로, 검증 성능이 정점을 찍는 시점이 훨씬 뒤로 밀립니다. 데이터를 한 장도 더 모으지 않고 변형만으로 일반화 성능을 끌어올린 것이 데이터 증식의 핵심 효과입니다.

model = keras.models.load_model('catdog.keras')
test_results = model.evaluate(dataloaders['test'], return_dict=True)
print(pd.Series(test_results).round(4))

train_results = get_train_results(history)