전이 학습
딥러닝 모델은 일반적으로 대량의 데이터를 필요로 합니다. 그러나 현실의 많은 문제에서는 학습에 쓸 수 있는 데이터가 제한적이고, 처음부터(from scratch) 충분히 큰 신경망을 학습시킬 만한 계산 자원도 부족합니다. 전이 학습(transfer learning) 은 이 간극을 메우는 핵심 기법입니다.
전이 학습은 기본적으로 하나의 문제에 대해 학습된 모델을 가져와 이를 다른 관련 문제에 적용하는 방법입니다. 이미 어느 정도 학습된 특성과 패턴을 다른 작업에 재사용함으로써, 적은 양의 데이터로도 효과적인 성능을 달성하고 학습 시간을 단축할 수 있다는 것이 핵심 아이디어입니다. 이는 특히 데이터가 제한적이거나 학습에 필요한 계산 자원이 제한적인 경우에 유용합니다.
전이 학습에는 두 가지 주요 단계가 있습니다.
사전 훈련(pre-training): 모델을 보통 대규모의 일반적인 데이터셋에서 훈련시킵니다. 이미지 인식의 경우 대표적으로 ImageNet이 쓰입니다. 이 단계에서 모델은 가장자리, 질감, 형태와 같은 일반적인 특성을 학습합니다.
미세 조정(fine-tuning): 사전 훈련된 모델을 새로운, 종종 더 작고 특화된 데이터셋에 적용하고, 이 모델의 가중치를 미세 조정하여 특정 작업(예: 특정 종류의 이미지 분류)에 최적화합니다.
이 장에서는 작은 강아지/고양이 이미지 데이터셋을 가지고, (1) 처음부터 학습한 합성곱 신경망의 한계를 확인하고, (2) ImageNet으로 사전 훈련된 VGG16을 가져와, (3) 그 특성 추출 능력을 재사용하는 전이 학습으로 성능을 끌어올리는 과정을 차례로 살펴봅니다.
전이 학습은 대규모 데이터로 일반 특성을 익히는 사전 훈련 단계와, 작고 특화된 데이터로 가중치를 조정하는 미세 조정 단계로 이어집니다.
1전이 학습 전략¶
미세 조정은 전이 학습의 한 예일 뿐이며, 전이 학습은 매우 다양한 형태와 전략으로 구현될 수 있습니다. 대표적인 방법들을 정리하면 다음과 같습니다.
| 전략 | 개념 |
|---|---|
| 특성 추출(feature extraction) | 사전 훈련된 모델의 마지막 몇 개 층을 제외하고 나머지 층의 가중치를 고정한다. 고정된 층들은 입력에서 특성을 추출하는 데 사용되고, 마지막 층만 새로운 작업에 맞게 새롭게 학습한다. |
| 멀티태스크 학습(multi-task learning) | 여러 관련 작업을 동시에 학습하여 작업 간에 유용한 지식을 공유한다. 예를 들어 얼굴 인식과 감정 분석을 함께 학습시킨다. |
| 도메인 적응(domain adaptation) | 소스 도메인에서 학습한 지식을 다른 대상 도메인에 적용한다. 예를 들어 사진 이미지에서 학습한 모델을 그림 스타일의 이미지 분류로 전이한다. |
| 제로샷/퓨샷 학습(zero/few-shot) | 제로샷은 학습 중에 보지 못한 카테고리를 인식하게 하고, 퓨샷은 한두 개의 예시만으로 새로운 카테고리를 학습한다. |
| 프로그레시브 네트워크(progressive networks) | 새로운 작업을 학습할 때마다 새 네트워크 컬럼을 추가하고, 기존 컬럼에서 학습된 지식을 이용해 더 빠르게 학습한다. |
| MAML(model-agnostic meta-learning) | 모델이 여러 작업에 걸쳐 빠르게 최적화될 수 있도록 하여, 새로운 작업에 대한 학습을 빠르게 수행한다. |
이처럼 전이 학습은 다양한 방식으로 구현될 수 있으며 그 방법과 범위는 계속 발전하고 있습니다. 이 장에서 실습하는 것은 그중 가장 직관적이고 널리 쓰이는 특성 추출 방식입니다.
미세 조정은 전이 학습의 한 갈래일 뿐이며, 다양한 전략이 존재합니다.
2댕냥이 데이터¶
실습에 사용할 데이터는 강아지와 고양이 사진을 모은 작은 데이터셋입니다. ImageNet과 같은 대규모 데이터셋에 비하면 훈련 표본 수가 매우 적은데, 바로 이런 “데이터가 부족한” 상황이 전이 학습의 효용을 보여주기에 적합합니다.
먼저 train / validation / test 폴더 구조를 확인하고 각 분할에 들어 있는 .jpg 파일 수를 셉니다.
from pathlib import Path
폴더 = Path('data/cats_dogs_small')
assert 폴더.is_dir(), f'{폴더} 폴더가 없습니다.'
# 하위 폴더 확인
for 하위폴더 in 폴더.iterdir():
print(하위폴더)
# 파일 획득
files = {}
for split in ['train', 'validation', 'test']:
files[split] = list((폴더 / split).glob('**/*.jpg'))
print(f'{split:<10}: {len(files[split]):,} files')이미지 파일은 텐서가 아니므로 곧바로 신경망에 넣을 수 없습니다. 파이썬 이미징 라이브러리(PIL, Python Imaging Library)로 한 장을 열어 크기(size)와 색상 모드(mode)를 확인해 봅니다. with 블록을 벗어나면 이미지 객체가 자동으로 닫히므로, 복사본(copy)을 만들어 사용합니다.
# PIL: Python Imaging Library
import PIL.Image as Image
sample = files['train'][0]
print(sample)
print(sample.name)
print(sample.stem)
# PIL 라이브러리를 사용하여 이미지 열기
with Image.open(sample) as img:
print(type(img), img.size, img.mode)
# with 블럭 외부에서 img 객체는 자동으로 닫히므로, 복사본을 만들어서 사용
sample = img.copy()
display(sample)데이터 적재를 체계화하기 위해 PyTorch 스타일의 데이터셋 클래스를 정의합니다. __len__ 은 표본 수를, __getitem__ 은 색인에 해당하는 (이미지, 레이블) 쌍을 돌려줍니다. 파일명에 cat 이 들어 있으면 0, 아니면 1(dog)로 레이블을 매기고, 주어진 transform 이 있으면 이미지에 적용합니다.
import PIL.Image as Image
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이미지마다 크기가 제각각이므로 모두 동일한 해상도로 맞추고 텐서로 변환해야 합니다. transforms.Compose 로 전처리 파이프라인을 구성합니다. 여기서는 (180, 180) 으로 크기를 통일(Resize)하고 텐서로 변환(ToTensor)합니다. 변환된 표본은 더 이상 PIL 이미지가 아니라 수치 연산이 가능한 텐서가 됩니다.
import torchvision.transforms as transforms
%run catdog.py
전처리 = transforms.Compose([
transforms.Resize((180, 180)),
transforms.ToTensor()
])
datasets = {}
for 하위폴더 in 폴더.iterdir():
split = 하위폴더.name
datasets[split] = CatDogDataset(하위폴더, transform=전처리)
print(f'{split:<10}: {len(datasets[split]):,} samples')
sample, label = datasets['train'][1000]
print(type(sample))
print(f"label: {'cat' if label == 0 else 'dog'}")
try:
sample - sample
except Exception as e:
# 이미지 객체는 연산 불가
print(e)
display(transforms.ToPILImage()(sample))마지막으로 데이터셋을 미니배치 단위로 공급하는 DataLoader 를 만듭니다. 훈련 분할만 shuffle=True 로 두어 에폭마다 표본 순서를 섞습니다. 한 배치를 꺼내 텐서의 형태를 확인하면 (배치 크기, 채널, 높이, 너비) 구조를 볼 수 있습니다.
import torch
dataloaders = {}
for split in datasets.keys():
dataloaders[split] = torch.utils.data.DataLoader(
datasets[split], batch_size=32, shuffle=(split=='train'))
print(f'{split:<10}: {len(dataloaders[split]):,} batches')
for X_batch, y_batch in dataloaders['train']:
print(X_batch.shape, y_batch.shape)
break3처음부터 학습한 합성곱 신경망¶
먼저 전이 학습 없이, 합성곱 신경망을 처음부터 학습시켜 기준선(baseline) 성능을 확인합니다. 입력은 (3, 180, 180) 의 컬러 이미지이고, Conv2D 와 MaxPooling2D 를 32→64→128→256 채널로 쌓아 특성 맵을 점점 추상화합니다. 출력층은 강아지/고양이의 이진 분류이므로 시그모이드 활성화를 갖는 단일 뉴런이며, 손실 함수로 이진 교차 엔트로피(BinaryCrossentropy)를 사용합니다.
학습 중 검증 손실이 가장 좋은 시점의 가중치를 ModelCheckpoint 콜백으로 저장해 둡니다.
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
history = model.fit(
dataloaders['train'],
validation_data=dataloaders['validation'],
epochs=30,
callbacks=[
keras.callbacks.ModelCheckpoint(
'catdog.keras', save_best_only=True, monitor='val_loss'),
# # 조기 종료
# keras.callbacks.EarlyStopping(
# monitor='val_loss', patience=5, restore_best_weights=True)
]
)학습 곡선을 그려 손실과 정확도가 에폭에 따라 어떻게 변하는지 살펴봅니다. 훈련 데이터가 적기 때문에, 훈련 손실은 계속 내려가지만 검증 손실은 어느 순간부터 정체하거나 다시 올라가는 과대적합(overfitting) 경향이 나타나기 쉽습니다.
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()
train_results.round(2)검증 손실 기준으로 저장해 둔 최적 모델을 다시 불러와 시험 데이터로 최종 성능을 평가합니다. 이 점수가 바로 "전이 학습 없이 처음부터 학습했을 때"의 기준선입니다.
# best 모델 로드
model = keras.models.load_model('catdog.keras')
test_results = model.evaluate(dataloaders['test'], return_dict=True)
print(pd.Series(test_results).round(4))4사전 훈련 모델¶
이제 ImageNet으로 사전 훈련된 모델을 가져옵니다. Keras의 applications 모듈에는 검증된 합성곱 신경망 구조들이 사전 훈련된 가중치와 함께 제공됩니다. 그중 고전적인 VGG16을 weights='imagenet' 으로 불러옵니다. summary() 로 보면 수많은 합성곱 층과, ImageNet의 1000개 분류를 위한 최상단 분류기(top)까지 포함된 깊은 구조임을 확인할 수 있습니다.
from keras.applications import vgg16
model = vgg16.VGG16(weights='imagenet')
model.summary()사전 훈련 모델이 실제로 동작하는지 임의의 사진 한 장으로 확인해 봅니다. VGG16은 (224, 224) 크기의 RGB 입력을 받으므로 이미지를 그 크기로 맞추고, 모델이 학습 당시 사용한 것과 동일한 방식으로 preprocess_input 전처리를 적용합니다. 입력 분포를 학습 때와 똑같이 맞춰 주는 것이 사전 훈련 가중치를 제대로 활용하는 전제 조건입니다.
from PIL import Image
# 모찌 이미지 열기
with Image.open('data/mozzi.png') as img:
img = img.resize((224, 224)).convert('RGB')
print(type(img), img.size, img.mode)
sample = img.copy()
display(sample)
x1 = np.array(sample)
x1 = vgg16.preprocess_input(x1) # VGG16 모델에 맞게 전처리
print(type(x1), x1.shape, x1.dtype, x1.min(), x1.max())전처리된 입력을 모델에 통과시키면 1000개 클래스에 대한 확률 분포가 나옵니다. decode_predictions 로 가장 확률이 높은 상위 후보들을 사람이 읽을 수 있는 레이블로 변환합니다. 우리가 강아지/고양이 데이터로 따로 학습시킨 적이 없는데도, ImageNet으로 사전 훈련된 이 모델은 사진 속 대상을 그럴듯하게 알아맞힙니다. 이렇게 일반적인 시각 특성을 이미 학습해 둔 모델을, 우리의 특화된 문제에 재사용하려는 것이 전이 학습의 출발점입니다.
outputs = model.predict(np.array([x1]))
print(type(outputs), outputs.shape, outputs.dtype)
예측, 확률 = np.argmax(outputs, axis=1), np.max(outputs, axis=1)
print(f'예측: {예측[0]}, 확률: {확률[0]:.2%}')
pd.DataFrame(vgg16.decode_predictions(outputs, top=3)[0]).round(2)5특성 추출을 통한 전이 학습¶
앞 절의 VGG16은 ImageNet의 1000개 클래스를 분류하도록 만들어졌으므로, 그 최상단 분류기는 우리의 이진 분류 문제에 그대로 쓸 수 없습니다. 대신 특성 추출 전략을 적용합니다. 즉, 분류기를 제외한 합성곱 기반(convolutional base)만 떼어 와 그 가중치를 고정하고, 그 위에 우리 문제에 맞는 새 분류기를 얹어 그 부분만 학습합니다.
먼저 이전 모델의 계산 그래프를 정리한 뒤, include_top=False 로 분류기를 제외한 기반 모델을 불러옵니다. input_shape 는 우리 데이터에 맞춰 (180, 180, 3) 으로 지정합니다.
keras.backend.clear_session()기반모델 = vgg16.VGG16(weights='imagenet', include_top=False, input_shape=(180, 180, 3))
# 기반모델.summary()핵심은 기반모델.trainable = False 입니다. 이 한 줄로 사전 훈련된 합성곱 기반의 가중치를 고정하면, 학습 과정에서 그 값들은 갱신되지 않고 오직 특성 추출기 역할만 합니다. 그 위에 Flatten, 과대적합을 억제하는 Dropout(0.5), 그리고 이진 분류를 위한 시그모이드 출력 뉴런을 새로 쌓습니다. 학습되는 파라미터는 이 새 분류기 부분뿐이므로, 적은 데이터로도 빠르고 안정적으로 학습됩니다.
from keras import layers
기반모델.trainable = False
model = keras.Sequential([
기반모델,
layers.Flatten(),
layers.Dropout(0.5),
layers.Dense(1, activation='sigmoid')
])
# model.summary()
model.compile(
loss=keras.losses.binary_crossentropy,
optimizer='rmsprop',
metrics=['acc']
)구성한 전이 학습 모델을 학습시키고 그 기록을 저장합니다. 학습 자체는 별도 스크립트로 실행하며, 그 결과(에폭별 손실과 정확도)를 CSV로 남겨 둡니다.
!conda run --no-capture-output --name pytorch python catdog_transfer_vgg16.pyimport pandas as pd
results = pd.read_csv('catdog_transfer.csv')
results.tail().round(3)6미세 조정과 임베딩¶
특성 추출은 사전 훈련된 가중치를 고정한 채 새 분류기만 학습하는 방법이었습니다. 한 걸음 더 나아가, 고정했던 기반 모델의 일부 층까지 풀어 새 데이터에 맞게 함께 갱신하는 것이 미세 조정(fine-tuning) 입니다. 사전 훈련된 모델이 미세 조정될 때 모델의 가중치는 새로운 데이터셋에 맞춰 조정되며, 이를 통해 전반적인 특성을 새 작업에 더 잘 맞도록 수정합니다.
참고로 "임베딩(embedding)"이라는 용어는 데이터를 저차원의 밀집 벡터로 변환하는 과정을 가리킬 때 사용됩니다. 예를 들어 자연어 처리에서 단어나 문장을 연속적인 벡터 공간에 매핑하는 것을 임베딩이라고 합니다. 이러한 임베딩은 모델이 입력 데이터의 의미적·문맥적 특성을 잡아내는 데 중요한 역할을 합니다. 임베딩 레이어는 사전 훈련에서 학습된 지식(예: 단어 간의 관계나 문맥적 정보)을 담고 있으며, 미세 조정을 통해 이 지식을 더욱 특화된 문제에 맞게 조정할 수 있습니다.
정리하면, 전이 학습은 "이미 학습된 표현을 재사용한다"는 하나의 큰 원리 아래 특성 추출과 미세 조정이라는 두 축으로 구현됩니다. 데이터가 적을수록, 그리고 새 문제가 사전 훈련 도메인과 가까울수록, 처음부터 학습하는 것보다 전이 학습이 더 적은 비용으로 더 좋은 성능을 안겨 줍니다.