모델 해석과 활성화 분석
딥러닝 모델은 흔히 "블랙박스"라고 불립니다. 수백만 개의 가중치가 입력을 받아 예측을 내놓지만, 그 내부에서 어떤 일이 일어나는지는 가중치 숫자만 들여다봐서는 알기 어렵습니다. Part 1에서 합성곱 신경망(CNN)이 이미지에서 특징을 추출하는 원리를 다루었다면, 이 장에서는 한 걸음 더 나아가 이미 학습된 모델의 내부를 들여다보는 방법을 다룹니다.
핵심 도구는 두 가지입니다. 첫째, 전이 학습(transfer learning) 으로 ImageNet에서 미리 학습된 VGG16을 가져와 고양이/개 분류라는 새로운 과제에 재활용합니다. 둘째, 활성화 분석(activation analysis) 으로 그 모델의 각 계층이 입력 이미지를 처리하면서 만들어 내는 중간 출력을 직접 시각화합니다. 이렇게 하면 모델이 입력의 어떤 정보에 반응하는지를 눈으로 확인할 수 있습니다.
1기반 모델: 사전 학습된 특징 추출기¶
전이 학습의 출발점은 기반 모델(base model) 입니다. ImageNet 데이터(약 1,400만 장, 1,000개 범주)로 이미 학습된 VGG16의 가중치를 그대로 가져옵니다. 이 가중치에는 일반적인 시각 특징 — 가장자리(edge), 질감, 무늬, 부분 형태 — 을 추출하는 능력이 이미 담겨 있습니다.
여기서 include_top=False 옵션이 중요합니다. VGG16의 "top"은 1,000개 범주를 분류하는 완전연결 계층인데, 우리가 풀려는 문제는 고양이/개 2-범주 분류이므로 그 분류기 부분은 떼어 내고 합성곱 특징 추출부만 가져옵니다.
from keras.applications.vgg16 import VGG16
기반모델 = VGG16(weights='imagenet', include_top=False)
기반모델.summary()2응용 모델: 새로운 분류기 얹기¶
기반 모델 위에 우리 문제에 맞는 새로운 출력부(“New Top”) 를 얹어 응용 모델을 구성합니다. 합성곱 출력을 Flatten으로 펼친 뒤, Dense(256) → Dropout(0.5) → Dense(1, sigmoid) 를 연결합니다. 마지막 시그모이드 출력 하나는 "개일 확률"을 의미하며, 이진 분류에 알맞습니다.
전이 학습의 두 번째 핵심은 기반모델.trainable = False, 즉 가중치 동결(freezing) 입니다. 기반 모델이 ImageNet에서 이미 잘 학습한 특징 추출 능력을 그대로 유지하기 위해 해당 가중치는 더 이상 갱신하지 않고, 새로 얹은 출력부만 우리 데이터로 학습시킵니다. 이렇게 하면 적은 데이터로도 빠르고 안정적으로 학습할 수 있습니다.
def get_model(기반모델):
model = keras.Sequential()
model.add(keras.Input(shape=(180, 180, 3)))
model.add(기반모델)
# 응용 출력: "New Top"
model.add(keras.layers.Flatten())
model.add(keras.layers.Dense(256, activation='relu'))
model.add(keras.layers.Dropout(0.5))
model.add(keras.layers.Dense(1, activation='sigmoid'))
return model
기반모델.trainable = False # 가중치 고정; "동결"
model = get_model(기반모델)
model.summary()2.1데이터 준비 도우미¶
고양이/개 이미지를 다루기 위한 데이터셋·데이터로더와 학습 결과 처리 함수들을 모듈로 정리해 둡니다. CatDogDataset은 파일명에 cat이 들어 있으면 0, 아니면 1(dog)로 레이블을 부여하며, 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_datasets(폴더, transform=None):
print('# 데이터셋')
datasets = {}
for 하위폴더 in Path(폴더).iterdir():
split = 하위폴더.stem
datasets[split] = CatDogDataset(하위폴더, transform=transform)
print(f'{split:<10}: {len(datasets[split]):,} samples')
return datasets
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, plot=True):
train_results = pd.DataFrame(history.history)
if plot:
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
def evaluate_model(model, dataloader):
results = model.evaluate(dataloader, return_dict=True)
return pd.Series(results)3모델 훈련¶
이제 동결된 기반 모델 위에 새 출력부를 얹은 응용 모델을 컴파일하고 학습합니다. 손실 함수는 이진 교차 엔트로피, 최적화기는 RMSprop을 사용합니다. ModelCheckpoint 콜백으로 검증 성능이 가장 좋은 시점의 모델을 catdog.keras에 저장하여, 과적합이 진행되기 전의 최적 가중치를 확보합니다.
기반모델 = VGG16(weights='imagenet', include_top=False)
기반모델.trainable = False # 가중치 고정; "동결"
model = get_model(기반모델)
model.compile(
loss=keras.losses.BinaryCrossentropy(),
optimizer=keras.optimizers.RMSprop(),
metrics=['accuracy']
)
history = model.fit(
dataloaders['train'], validation_data=dataloaders['validation'],
epochs=10,
callbacks=[
keras.callbacks.ModelCheckpoint('catdog.keras', save_best_only=True)
]
)학습이 끝나면 저장해 둔 최적 모델을 다시 불러와 테스트 데이터로 최종 성능을 평가합니다. 학습·검증 데이터와 분리된 테스트 데이터에서의 성능이야말로 모델이 실제로 일반화되었는지를 보여 줍니다.
from catdog import get_train_results, evaluate_model
model = keras.models.load_model('catdog.keras')
evaluate_model(model, dataloaders['test'])학습 과정의 손실과 정확도 곡선을 그려 학습이 어떻게 진행되었는지 확인합니다. 학습 손실과 검증 손실이 벌어지기 시작하는 지점이 과적합의 신호이며, 체크포인트가 저장한 모델은 검증 손실이 최소인 지점에 해당합니다.
train_results = get_train_results(history)
train_results.round(3)4활성화 분석¶
신경망 계층의 출력을 종종 "활성화(activation)"라고 부릅니다. 모델이 입력을 처리할 때, 각 계층은 입력에서 특징을 추출하여 다음 계층으로 전달합니다. 이 과정에서 각 계층의 출력은 모델이 입력에서 어떤 정보를 추출했는지를 보여주는 중요한 단서가 됩니다. 활성화 분석은 이러한 출력을 시각화하여 모델이 입력을 어떻게 이해하고 있는지를 탐구하는 방법입니다.
구현의 아이디어는 단순합니다. 우리가 보고 싶은 것은 최종 예측값 하나가 아니라 중간 계층들의 출력입니다. 그래서 기반 모델의 입력은 그대로 두되, 출력을 여러 중간 계층(block1_pool부터 block5_pool까지)으로 "여러 갈래"로 뽑아내는 새로운 모델을 정의합니다. 이렇게 만든 활성화 모델에 이미지를 한 번 통과시키면, 다섯 단계의 풀링 출력을 한꺼번에 얻을 수 있습니다.
기반모델 = VGG16(weights='imagenet', include_top=False, input_shape=(180, 180, 3))
활성화모델 = keras.Model(
inputs=기반모델.input,
outputs=[
기반모델.get_layer('block1_pool').output,
기반모델.get_layer('block2_pool').output,
기반모델.get_layer('block3_pool').output,
기반모델.get_layer('block4_pool').output,
기반모델.get_layer('block5_pool').output,
]
)
활성화모델.summary()4.1계층별 활성화 시각화¶
샘플 이미지 한 장을 전처리한 뒤 활성화 모델에 통과시키면, 각 풀링 계층의 출력이 리스트로 반환됩니다. 각 출력은 (높이, 너비, 채널) 형태의 특징 맵이며, 채널 하나하나가 서로 다른 특징 검출기에 해당합니다. 출력시각화 함수는 이 채널들을 격자로 펼쳐 회색조 이미지로 그려 줍니다.
시각화 결과를 얕은 계층부터 깊은 계층 순으로 비교해 보면 다음과 같은 경향이 드러납니다.
| 계층 | 공간 해상도 | 채널 수 | 반응하는 특징 |
|---|---|---|---|
block1_pool | 높음 | 적음 | 가장자리·색·질감 같은 저수준·국소적 특징 |
block3_pool | 중간 | 중간 | 부분적인 무늬·형태 조합 |
block5_pool | 낮음 | 많음 | 추상적·의미적 특징 (원본을 알아보기 어려움) |
얕은 계층의 활성화는 원본 이미지의 윤곽이 비교적 잘 보이는 반면, 깊은 계층으로 갈수록 공간 정보는 압축되고 활성화는 점점 추상적이고 희소해집니다. 이는 CNN이 계층을 거치며 국소적인 픽셀 패턴에서 점차 의미 있는 고수준 개념으로 특징을 추상화한다는 것을 시각적으로 보여 줍니다. 모델 해석의 관점에서, 이런 활성화 지도는 모델이 입력의 어느 영역과 어떤 패턴에 주목하는지를 직접 확인하게 해 주는 가장 직관적인 단서입니다.
from PIL import Image
with Image.open('data/mozzi.png') as img:
sample = img.convert('RGB')
sample = 전처리(sample)
print(sample.shape)
outputs = 활성화모델.predict(sample.unsqueeze(0)) # 배치 차원 추가
# 출력이 여러 층의 활성화이므로 리스트로 반환됩니다.
print(type(outputs), len(outputs))
# 출력 시각화
def 출력시각화(출력, 레이아웃, figsize=(10, 10)):
그림틀, 영역 = plt.subplots(*레이아웃, figsize=figsize)
for i, 그래프 in enumerate(영역.flat):
그래프.imshow(출력[:, :, i], cmap='gray')
그래프.axis('off')
return 그림틀
for i, 출력 in enumerate(outputs):
출력 = 출력.squeeze(0) # 배치 차원 제거
print(출력.shape)
그림틀 = 출력시각화(
# 열을 고정하고, 행은 자동으로 계산
출력, 레이아웃=(출력.shape[-1] // 8, 8),
# 가로 길이를 고정하고, 세로 길이는 채널 수에 비례하여 조정
figsize=(10, 10 * 출력.shape[-1] // 64))
plt.savefig(f'activation_block{i+1}.png')
plt.close()