실전 딥러닝! 딥러닝을 이용한 예제를 풀어봅시다.

본 문서는 [케라스 창시자에게 배우는 딥러닝] 책을 기반으로 하고 있으며, subinium(본인)이 정리하고 추가한 내용입니다. 생략된 부분과 추가된 부분이 있으니 추가/수정하면 좋을 것 같은 부분은 댓글로 이야기해주시면 감사하겠습니다.

5.3 사전 훈련된 컨브넷 사용하기

작은 이미지 데이터 셋에서 딥러닝을 적용하는 일반적이고 매우 효과적인 방법은 사전 훈련된 네트워크를 사용하는 것입니다. 사전 훈련된 네트워크(pretrained network) 는 일반적으로 대규모 이미지 분류 문제를 위해 대량의 데이터셋에서 미리 훈련되어 저장된 네트워크입니다.

즉 ImageNet와 같이 큰 데이터셋에서 훈련된 대규모 컨브넷을 사용하면 지금 문제에도 좋은 성능을 낼 것입니다. 여러 모델이 있지만 여기서는 VGG16 모델을 사용합니다.

사전 훈련된 네트워크는 2가지 방법으로 사용할 수 있습니다.

  1. 특성 추출(feature extraction)
  2. 미세 조정(fine tuning)

5.3.1 특성 추출

특성 추출 은 사전에 학습된 네트워크의 표현을 사용하여 새로운 샘플에서 흥미로운 특성을 뽑아 내는 것입니다. 이런 특성을 사용하여 새로운 분류기를 처음부터 훈련합니다.

컨브넷은 이미지 분류를 위해 두 부분으로 구성됩니다.

  • 합성곱 기반 층 : 합성곱과 풀링 층으로 구성
  • 완전 연결 분류기

컨브넷의 경우 특성 추출은 사전에 훈련된 네트워크의 합성곱 기반 층을 선택하여 새로운 데이터를 통과시키고, 그 출력으로 새로운 분류기를 훈련합니다.

완전 연결 분류기 재사용은 권장하지 않고 있습니다. 합성곱 층에 학습된 표현이 더 일반적이어서 재사용이 가능하기 때문입니다. 그 이유는 다음과 같습니다.

  • 컨브넷의 특성 맵은 사진에 대한 일반적인 콘셉트의 존재 여부를 기록한 맵입니다.
  • 분류기는 전체 사진에 어떤 클래스가 존재할 확률 정보 -> 훈련된 클래스 집합에 특화
  • 완전 연결 층에서 찾은 표현은 더 이상 입력 이미지에 있는 위치 정보를 가지고 있지 않음 -> 객체 위치가 중요한 문제라면 더욱 필요없음

특정 합성곱 층에서 추출한 표현의 일반성 및 재사용성 수준은 모델에 있는 층의 깊이에 따라 달려있습니다.

  • 하위 층 : 에지, 색깔, 질감 등 지역적이고 매우 일반적인 특성 맵
  • 상위 층 : 강아지 눈, 고양이 귀와 같은 좀 더 추상적인 개념

새로운 데이터셋과 훈련된 데이터셋이 많이 다르다면 전체 합성곱 기반 층이 아닌 모델의 하위 층 몇 개만 특성 추출에 사용하는 것이 좋습니다.

ImageNet 클래스에서는 강아지와 고양이가 있기 때문에 완전 연결 층 정보를 재사용해도 좋지만 일반적인 케이스를 위해 여기서는 사용하지 않습니다. ImageNet 데이터셋에서 훈련된 VCG16 네트워크에서 유용한 특성을 추출합니다. 그 후 특성으로 훈련을 진행합니다.

VGG16 모델은 케라스 패키지로 존재하며 keras.applications 모듈에서 import 할 수 있습니다. 이 모듈에서 사용가는한 이미지 분류 모델은 다음과 같습니다.

  • Xception
  • Inception V3
  • ResNet50
  • VGG16
  • VGG19
  • MobileNet

모델은 다음과 같이 만들 수 있습니다.

# 코드 5-16 VGG16 합성곱 기반 층 만들기
from keras.applications import VGG16

conv_base = VGG16(weights='imagenet',
                  include_top=False,
                  input_shape=(150, 150, 3))

VGG16 함수에서는 3개의 매개변수를 전달합니다.

  • weights는 모델을 초기화할 가중치 체크포인트를 지정합니다.
  • include_top은 네트워크의 최상위 완전 연결 분류기를 포함할지 안할지를 지정합니다. 기본값은 ImageNet의 1,000개의 클래스에 대응되는 완전 연결 분류기를 포함합니다. 별도의 (강아지와 고양이 두 개의 클래스를 구분하는) 완전 연결 층을 추가하려고 하므로 이를 포함시키지 않습니다.
  • input_shape은 네트워크에 주입할 이미지 텐서의 크기입니다. 이 매개변수는 선택사항입니다. 이 값을 지정하지 않으면 네트워크가 어떤 크기의 입력도 처리할 수 있습니다.

최종 특성 맵의 크기는 (4, 4, 512) 입니다. 여기에서 완전 연결 층은 2가지 방식으로 놓을 수 있습니다.

  • 새로운 데이터셋에서 합성곱 기반 층을 실행하고 출력을 넘파이 배열로 디스크에 저장합니다. 그다음 이 데이터를 독립된 완전 연결 분류기에 입력으로 사용합니다. 이 방식에서는 합성곱 기반 층을 한 번만 실행하기 때문에 빠르고 비용이 적게 듭니다. 하지만 데이터 증식을 사용할 수 없습니다.
  • 준비한 모델(conv_base)에 Dense 층을 쌓아 확장합니다. 그리고 입력 데이터에서 엔드-투-엔드로 전체 모델을 실행합니다. 모델에 노출된 모든 입력 이미지가 매번 합성곱 기반 층을 통과하므로 데이터 증식을 사용할 수 있습니다. 하지만 첫 번째 방식보다 훨씬 비용이 많이 듭니다.

각각의 방법으로 구현은 다음과 같이 할 수 있습니다.

데이터 증식을 사용하지 않는 빠른 특성 추출

ImageDatagenerator를 사용하여 이미지와 레이블을 넘파이 배열로 추출합니다. conv_base 모델의 predict 메서드를 호출하여 이 이미지에서 특성을 추출합니다.

추출된 특성의 크기는 (samples, 4, 4, 512)이기에 완전 연결 분류기에 넣기 위해 (samples, 8192)크기로 펼칩니다.

#코드 5-17 사전 훈련된 합성곱 기반 층을 사용한 특성 추출하기 + 특성맵 펼치기
import os
import numpy as np
from keras.preprocessing.image import ImageDataGenerator

base_dir = './datasets/cats_and_dogs_small'

train_dir = os.path.join(base_dir, 'train')
validation_dir = os.path.join(base_dir, 'validation')
test_dir = os.path.join(base_dir, 'test')

datagen = ImageDataGenerator(rescale=1./255)
batch_size = 20

def extract_features(directory, sample_count):
    features = np.zeros(shape=(sample_count, 4, 4, 512))
    labels = np.zeros(shape=(sample_count))
    generator = datagen.flow_from_directory(
        directory,
        target_size=(150, 150),
        batch_size=batch_size,
        class_mode='binary')
    i = 0
    for inputs_batch, labels_batch in generator:
        features_batch = conv_base.predict(inputs_batch)
        features[i * batch_size : (i + 1) * batch_size] = features_batch
        labels[i * batch_size : (i + 1) * batch_size] = labels_batch
        i += 1
        if i * batch_size >= sample_count:
            # 제너레이터는 루프 안에서 무한하게 데이터를 만들어내므로 모든 이미지를 한 번씩 처리하고 나면 중지합니다
            break
    return features, labels

train_features, train_labels = extract_features(train_dir, 2000)
validation_features, validation_labels = extract_features(validation_dir, 1000)
test_features, test_labels = extract_features(test_dir, 1000)

train_features = np.reshape(train_features, (2000, 4 * 4 * 512))
validation_features = np.reshape(validation_features, (1000, 4 * 4 * 512))
test_features = np.reshape(test_features, (1000, 4 * 4 * 512))

이제 여기서 완전 연결 분류기를 정의하고 저장된 데이터와 레이블을 사용하여 훈련합니다. 규제를 위해 드롭아웃을 사용합니다.

# 코드 5-18 완전 연결 분류기를 정의하고 훈련하기
from keras import models
from keras import layers
from keras import optimizers

model = models.Sequential()
model.add(layers.Dense(256, activation='relu', input_dim=4 * 4 * 512))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(1, activation='sigmoid'))

model.compile(optimizer=optimizers.RMSprop(lr=2e-5),
              loss='binary_crossentropy',
              metrics=['acc'])

history = model.fit(train_features, train_labels,
                    epochs=30,
                    batch_size=20,
                    validation_data=(validation_features, validation_labels))

이 코드에선 각 epoch당 0.4초 정도 소모되었습니다. 2개의 Dense 층만 처리하면 되기 때문에 훈련이 매우 빠릅니다. 이 코드는 CPU에서도 충분히 진행할 수 있을 것 같습니다.

훈련 손실과 정확도 곡선을 matplotlib으로 다시 그려봅시다.

# 코드 5-19 결과 그래프 그리기
import matplotlib.pyplot as plt
acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(len(acc))

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()

이렇게 빠르고 편한 방법에서 다음 정도의 그래프를 그릴 수 있었습니다.

단순한 특성 추출 방식의 훈련 정확도와 검증 정확도
<img src= "https://i.imgur.com/scJ8m0N.png"alt>
단순한 특성 추출 방식의 훈련 손실과 검증 손실

약 90%의 검증 정확도에 도달하였고, 이는 처음부터 훈련시킨 작은 모델에서 얻은 것보다 훨씬 좋습니다. 하지만 이 그래프도 역시 드롭아웃을 사용했음에도 과대적합이 생깁니다. 여기서는 이제 작은 이미지 데이터셋에서 과대적합을 막는 방법, 즉 데이터 증식을 사용하여 해결할 수 있습니다.

데이터 증식을 사용한 특성 추출

훈련하는 동안 데이터 증식을 통해 과대적합을 막는 방법입니다.

conv_base로 모델을 확장하고 입력 데이터를 사용하여 엔드-투-엔드로 실행합니다. CPU에서 하면 컴퓨터가 고생하니 GPU에서 해야합니다.

모델은 층과 동일하게 작동하므로 층을 추가하듯이 Sequential모델에 다른 모델을 추가할 수 있습니다. 코드는 다음과 같습니다. conv_base가 추가된 층입니다.

# 코드 5-20 합성곱 기반 층 위에 완전 연결 분류기 추가하기
from keras import models
from keras import layers

model = models.Sequential()
model.add(conv_base)
model.add(layers.Flatten())
model.add(layers.Dense(256, activation='relu'))
model.add(layers.Dense(1, activation='sigmoid'))

모델의 구조는 다음과 같습니다.

>> model.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
vgg16 (Model)                (None, 4, 4, 512)         14714688  
_________________________________________________________________
flatten_1 (Flatten)          (None, 8192)              0         
_________________________________________________________________
dense_3 (Dense)              (None, 256)               2097408   
_________________________________________________________________
dense_4 (Dense)              (None, 1)                 257       
=================================================================
Total params: 16,812,353
Trainable params: 16,812,353
Non-trainable params: 0
_________________________________________________________________

VCG16의 합성곱 기반 층은 1400만개 정도의 파라미터를 가지고, 그 위의 분류기는 200만개 정도의 파라미터를 가집니다.

모델을 컴파일하고 훈련하기 전에 합성곱 기반 층을 동결하는 것이 매우 중요합니다. 동결(freezing) 은 훈련하는 동안 가중치가 업데이트되지 않도록 막는 것을 의미합니다. 여기서는 동결에 대한 설정이 없다면 맨 위의 Dense층이 랜덤하게 초기화되었기 때문에 매우 큰 가중치 값이 학습된 값에 영향을 미치게 됩니다. 케라스에서는 trainable 속성으로 동결을 할 수 있습니다.

conv_base.trainable = False

이렇게 설정하면 따로 추가한 Dense층 가중치만 훈련됩니다. 층마다 2개씩(가중치 + 편향) 4개의 텐서가 훈련됩니다. 이제 동결을 했으니 훈련을 시작합니다.

# 코드 5-21 동결된 합성곱 기반 층과 함께 모델을 엔드-투-엔드로 훈련하기
from keras.preprocessing.image import ImageDataGenerator

train_datagen = ImageDataGenerator(
      rescale=1./255,
      rotation_range=20,
      width_shift_range=0.1,
      height_shift_range=0.1,
      shear_range=0.1,
      zoom_range=0.1,
      horizontal_flip=True,
      fill_mode='nearest')

# 검증 데이터는 증식되어서는 안 됩니다!
test_datagen = ImageDataGenerator(rescale=1./255)

train_generator = train_datagen.flow_from_directory(
        # 타깃 디렉터리
        train_dir,
        # 모든 이미지의 크기를 150 × 150로 변경합니다
        target_size=(150, 150),
        batch_size=20,
        # binary_crossentropy 손실을 사용하므로 이진 레이블이 필요합니다
        class_mode='binary')

validation_generator = test_datagen.flow_from_directory(
        validation_dir,
        target_size=(150, 150),
        batch_size=20,
        class_mode='binary')

model.compile(loss='binary_crossentropy',
              optimizer=optimizers.RMSprop(lr=2e-5),
              metrics=['acc'])

history = model.fit_generator(
      train_generator,
      steps_per_epoch=100,
      epochs=30,
      validation_data=validation_generator,
      validation_steps=50,
      verbose=2)

여기서 훈련은 각 에포크 당 25초정도 소모되었습니다. 그래프를 똑같은 코드로 그려봅시다.

데이터 증식을 사용한 특성 추출 방식의 훈련 정확도와 검증 정확도
데이터 증식을 사용한 특성 추출 방식의 훈련 손실과 검증 손실

검증 정확도가 이전과 비슷하지만 전보다 과대적합이 줄어든 것을 알 수 있습니다.

5.3.2 미세 조정

모델을 재사용하는 데 널리 사용되는 하나의 기법은 특성 추출을 보완하는 미세 조정(fine-tuning) 입니다. 미세 조정은 특성 추출에 사용했던 동결 모델의 상위 층 몇 개를 동결에서 해제하고 모델에 새로 추가한 층(여기서는 완전 연결 분류기)과 함께 훈련하는 것입니다. 재사용 모델의 표현을 일부 조정하기에 미세 조정이라고 부릅니다. 네트워크를 미세 조정하는 단계는 다음과 같습니다.

  1. 사전에 훈련된 기반 네트워크 위에 새로운 네트워크를 추가
  2. 기반 네트워크 동결
  3. 새로 추가한 네트워크 훈련
  4. 기반 네트워크에서 일부 층 동결 해제
  5. 동결 해제 층과 새로 추가한 층을 함께 훈련

완전 연결 분류기의 값이 지나치게 조정하는 것을 방지하기 위해 새로 추가한 네트워크를 먼저 훈련합니다. 즉 위에서 이미 3단계까지는 한 것입니다. 여기서는 구조가 너무 길어 생략하지만 본 사전 훈련된 네트워크의 구조는 5 블록으로 이루어져있고, 각 단계는 합성곱 층 3개와 풀링 층 1개로 이루어져있습니다. 미세 조정은 block_5에서만 진행합니다. 더 많은 층을 미세 조정하지 않는 이유는 다음과 같습니다.

  • 합성곱 기반 층에 있는 하위 층들은 좀 더 일반적이고 재사용 가능한 특성들을 인코딩
  • 반면 상위 층은 좀 더 특화된 특성을 인코딩 -> 새로운 문제에는 구체적 특성이 필요 -> 상위층만
  • 훈련해야 할 파라미터가 많으면 과대적합의 위험이 커짐. 작은 데이터 셋에 1500만개의 파라미터는 위험.

이제 미세조정은 다음과 같이 하드코딩합니다.

# 코드 5-22 특정 층까지 모든 층 동결하기
conv_base.trainable = True

set_trainable = False
for layer in conv_base.layers:
    if layer.name == 'block5_conv1':
        set_trainable = True
    if set_trainable:
        layer.trainable = True
    else:
        layer.trainable = False

바로 네트워크 미세 조정을 시작합니다. 학습률을 낮춘 RMSprop 옵티마이저를 사용합니다. 학습률이 높으면 미세 조정이 안될 수 있기 때문입니다.

# 코드 5-23 모델 미세 조정하기

model.compile(loss='binary_crossentropy',
              optimizer=optimizers.RMSprop(lr=1e-5),
              metrics=['acc'])

history = model.fit_generator(
      train_generator,
      steps_per_epoch=100,
      epochs=100,
      validation_data=validation_generator,
      validation_steps=50)

그리고 훈련 데이터와 검증 데이터의 정확도, 손실 그래프를 그리면 다음과 같습니다.

미세 조정을 사용한 훈련 정확도와 검증 정확도
미세 조정을 사용한 훈련 손실과 검증 손실

그래프가 불규칙해보이는데 이는 전에 사용한 지수 이동 평균 을 사용하여 부드럽게 그래프를 그릴 수 있습니다. 부드러운 그래프는 다음과 같이 그릴 수 있습니다.

# 코드 5-24 부드러운 그래프 그리기
def smooth_curve(points, factor=0.8):
  smoothed_points = []
  for point in points:
    if smoothed_points:
      previous = smoothed_points[-1]
      smoothed_points.append(previous * factor + point * (1 - factor))
    else:
      smoothed_points.append(point)
  return smoothed_points

plt.plot(epochs, smooth_curve(acc), 'bo', label='Smoothed training acc')
plt.plot(epochs, smooth_curve(val_acc), 'b', label='Smoothed validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()
plt.plot(epochs, smooth_curve(loss), 'bo', label='Smoothed training loss')
plt.plot(epochs, smooth_curve(val_loss), 'b', label='Smoothed validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()
미세 조정을 사용한 훈련 정확도와 검증 정확도의 부드러운 곡선
미세 조정을 사용한 훈련 손실과 검증 손실의 부드러운 곡선

정확도가 92-94%를 왔다 갔다 합니다. 손실 곡선은 실제 어떤 향상을 얻지 못했습니다. 이는 그래프는 개별적인 손실 값의 평균을 구한 것이고, 정확도의 영향을 미치는 것은 손실 값의 분포이지 평균이 아닙니다. 정확도는 모델이 예측한 클래스 확률이 어떤 임계값을 넘었는지에 대한 결과이기에 모델이 향상되어도 평균 손실에 반영되지 않을 수 있습니다.

마지막으로 테스트 데이터에서 이 모델을 평가하면 약 93%의 정확도를 얻는 것을 알 수 있습니다.

# 테스트로 모델 평가하기
test_generator = test_datagen.flow_from_directory(
        test_dir,
        target_size=(150, 150),
        batch_size=20,
        class_mode='binary')

test_loss, test_acc = model.evaluate_generator(test_generator, steps=50)
print('test acc:', test_acc)
Found 1000 images belonging to 2 classes.
test acc: 0.9349999976158142

5.3.3 정리

다음은 앞의 두 절에 있는 예제로부터 배운 것들입니다.

  • 컨브넷은 컴퓨터 비전 작업에 가장 뛰어난 머신 러닝 모델입니다. 아주 작은 데이터셋에서도 처음부터 훈련해서 괜찮은 성능을 낼 수 있습니다.
  • 작은 데이터셋에서는 과대적합이 큰 문제입니다. 데이터 증식은 이미지 데이터를 다룰 때 과대적합을 막을 수 있는 강력한 방법입니다.
  • 특성 추출 방식으로 새로운 데이터셋에 기존의 컨브넷을 쉽게 재사용할 수 있습니다. 작은 이미지 데이터셋으로 작업할 때 효과적인 기법입니다.
  • 특성 추출을 보완하기 위해 미세 조정을 사용할 수 있습니다. 미세 조정은 기존 모델에서 사전에 학습한 표현의 일부를 새로운 문제에 적응시킵니다. 이 기법은 조금 더 성능을 끌어올립니다.

지금까지 이미지 분류 문제에서 특히 작은 데이터셋을 다루기 위한 좋은 도구들을 배웠습니다.

5.4 컨브넷 학습 시각화

컨브넷의 표현은 시각적인 개념을 학습한 것이기 때문에 시각화하기 좋습니다. 2013년부터 이런 표현들을 시각화하고 해석하는 다양한 기법들이 개발되었고, 그 중 가장 편하고 유용한 3가지를 알아봅시다.

  • 컨브넷 중간층을 출력(중간층에 있는 활성화)을 시각화하기
  • 컨브넷 필터를 시각화하기
  • 클래스 활성화에 대한 히트맵을 이미지에 시각화하기

여기서 첫 번째는 처음부터 훈련시킨 작은 컨브넷을 두 세번째는 5.3에서 소개된 VGG16 모델을 사용합니다. 저는 처음부터 훈련시킨 작은 컨브넷을 코랩이 12시간 지나면서 사라져서 코드 예제의 자료를 사용하겠습니다.

5.4.1 중간층의 활성화 시각화하기

중간층의 활성화 시각화는 어떤 입력이 주어졌을 때 네트워크에 있는 여러 합성곱과 풀링 층이 출력하는 특성 맵을 그리는 것입니다. 이 방법은 네트워크에 의해 학습된 필터들이 어떻게 입력을 분해하는지 보여 줍니다.

너비, 높이, 깊이(채널) 3개의 차원에 대해 특성 맵을 시각화하는 것이 좋습니다. 각 채널은 비교적 독립적인 특성을 인코딩하므로 특성 맵의 각 채널 내용을 독립적인 2D 이미지로 그리는 것이 괜찮은 방법입니다.

우선 모델을 로드합니다.

from keras.models import load_model
model = load_model('cats_and_dogs_small_2.h5')

네트워크를 훈련할 때 사용했던 이미지에 포함되지 않은 고양이 사진 하나를 입력 이미지로 선택합니다.

# 코드 5-25 개별 이미지 전처리하기
img_path = './datasets/cats_and_dogs_small/test/cats/cat.1700.jpg'

from keras.preprocessing import image
import numpy as np

img = image.load_img(img_path, target_size=(150, 150))
img_tensor = image.img_to_array(img)

# 이미지를 4D 텐서로 변경합니다
img_tensor = np.expand_dims(img_tensor, axis=0)
# 모델이 훈련될 때 입력에 적용한 전처리 방식을 동일하게 사용합니다
img_tensor /= 255.

# 이미지 텐서의 크기는 (1, 150, 150, 3)입니다
print(img_tensor.shape)

이미지를 matplotlib로 출력하면 다음과 같습니다.

import matplotlib.pyplot as plt
plt.imshow(img_tensor[0])
plt.show()

고양이

확인하고 싶은 특성 맵을 추출하기 위해 이미지 배치를 입력으로 받아 모든 합성곱과 풀링 층의 활성화를 출력하는 케라스 모델을 만들 것입니다. 이를 위해 케라스의 Model 클래스를 사용합니다. 모델 인트턴스를 만들 때는 2개의 매개변수가 필요합니다. 입력 텐서와 출력 텐서입니다.

반환되는 객체는 Sequential과 같은 케라스 모델이지만 특정 입력과 특정 출력을 매핑합니다.

# 코드 5-27 입력 텐서와 출력 텐서의 리스트로 모델 인스턴스 만들기
from keras import models

# 상위 8개 층의 출력을 추출합니다:
layer_outputs = [layer.output for layer in model.layers[:8]]
# 입력에 대해 8개 층의 출력을 반환하는 모델을 만듭니다:
activation_model = models.Model(inputs=model.input, outputs=layer_outputs)

입력 이미지가 주입될 때 이 모델은 원본 모델의 활성화 값을 반환합니다. 이 모델이 다중 출력 모델입니다. 일반적으로 모델은 몇 개의 입력과 출력이라도 가질 수 있습니다. 이 모델은 하나의 입력과 층의 활성화마다 하나씩 총 8개의 출력을 가집니다.

# 코드 5-28 예측 모드로 모델 실행하기
# 층의 활성화마다 하나씩 8개의 넘파이 배열로 이루어진 리스트를 반환합니다:
activations = activation_model.predict(img_tensor)

이렇게 하면 첫번째 층(activations[0])은 conv2d_1 층임을 알 수 있습니다. 이 층의 shape은 (1, 148, 148, 32)이므로 32개의 채널에 각각 이미지를 뽑을 수 있습니다. 20번째 채널에 이미지는 다음과 같이 그릴 수 있습니다.

# 코드 5-29 20번째 채널 시각화하기
first_layer_activation = activations[0]
plt.matshow(first_layer_activation[0, :, :, 19], cmap='viridis')
plt.show()

고양이20

학습한 필터는 결정적이지 않기 때문에 모두 다른 값을 가집니다. 여기는 가로 에지를 감지하도록 인코딩된 것 같습니다. 이제 8개의 활성화 맵에서 추출한 모든 채널을 그리기 위해 하나의 큰 이미지 텐서에 결과를 출력합니다.

# 코드 5-31 중간층의 모든 활성화에 있는 채널 시각화하기

# 층의 이름을 그래프 제목으로 사용합니다
layer_names = []
for layer in model.layers[:8]:
    layer_names.append(layer.name)

images_per_row = 16

# 특성 맵을 그립니다
for layer_name, layer_activation in zip(layer_names, activations):
    # 특성 맵에 있는 특성의 수
    n_features = layer_activation.shape[-1]

    # 특성 맵의 크기는 (1, size, size, n_features)입니다
    size = layer_activation.shape[1]

    # 활성화 채널을 위한 그리드 크기를 구합니다
    n_cols = n_features // images_per_row
    display_grid = np.zeros((size * n_cols, images_per_row * size))

    # 각 활성화를 하나의 큰 그리드에 채웁니다
    for col in range(n_cols):
        for row in range(images_per_row):
            channel_image = layer_activation[0,
                                             :, :,
                                             col * images_per_row + row]
            # 그래프로 나타내기 좋게 특성을 처리합니다
            channel_image -= channel_image.mean()
            channel_image /= channel_image.std()
            channel_image *= 64
            channel_image += 128
            channel_image = np.clip(channel_image, 0, 255).astype('uint8')
            display_grid[col * size : (col + 1) * size,
                         row * size : (row + 1) * size] = channel_image

    # 그리드를 출력합니다
    scale = 1. / size
    plt.figure(figsize=(scale * display_grid.shape[1],
                        scale * display_grid.shape[0]))
    plt.title(layer_name)
    plt.grid(False)
    plt.imshow(display_grid, aspect='auto', cmap='viridis')

plt.show()
고양이 테스트 사진에서 각 층의 활성화 채널

이 사진에서 다음과 같은 사실을 생각해볼 수 있습니다.

  • 첫 번째 층은 여러 종류의 에지 감지기를 모아 놓은 것 같습니다. 이 단계의 활성화에는 초기 사진에 있는 거의 모든 정보가 유지됩니다.
  • 상위 층으로 갈수록 활성화는 점점 더 추상적으로 되고 시각적으로 이해하기 어려워집니다. ‘고양이 귀’와 ‘고양이 눈’과 같이 고수준의 개념을 인코딩하기 시작합니다. 상위 층의 표현은 이미지의 시각적 콘텐츠에 관한 정보가 점점 줄어들고 이미지의 클래스에 관한 정보가 점점 증가합니다.
  • 비어 있는 활성화가 층이 깊어짐에 따라 늘어납니다. 첫 번째 층에서는 모든 필터가 입력 이미지에 활성화되었지만 층을 올라가면서 활성화되지 않는 필터들이 생깁니다. 필터에 인코딩된 패턴이 입력 이미지에 나타나지 않았다는 것을 의미입니다.
  • 심층 신경망이 학습한 표현에서 일반적으로 나타나는 중요한 특징을 조금 전 확인했습니다. 층에서 추출한 특성은 층의 깊이를 따라 점점 더 추상적이 됩니다. 높은 층의 활성화는 특정 입력에 관한 시각적 정보가 점점 줄어들고 타깃에 관한 정보(이 경우에는 강아지 또는 고양이 이미지의 클래스)가 점점 더 증가합니다. 심층 신경망은 입력되는 원본 데이터(여기서는 RGB 포맷의 사진)에 대한 정보 정제 파이프라인처럼 작동합니다. 반복적인 변환을 통해 관계없는 정보(예를 들어 이미지에 있는 특정 요소)를 걸러내고 유용한 정보는 강조되고 개선됩니다(여기에서는 이미지의 클래스).

사람과 동물이 세상을 인지하는 방식이 이와 비슷합니다. 사람은 몇 초동안 한 장면을 보고 난 후에 그 안에 있었던 추상적인 물체(자전거, 나무)를 기억할 수 있습니다. 하지만 이 물체의 구체적인 모양을 기억하지 못합니다. 사실 기억을 더듬어 일반적인 자전거를 그려보면 평생 수천 개의 자전거를 보았더라도 조금이라도 비슷하게 그릴 수 없습니다.

실제로 한 번 해보면 진짜 그런지 알 수 있습니다. 우리의 뇌는 시각적 입력에서 관련성이 적은 요소를 필터링하여 고수준의 개념으로 변환합니다. 이렇게 완전히 추상적으로 학습하기 때문에 눈으로 본 것을 자세히 기억하기는 매우 어렵습니다.

5.4.2 컨브넷 필터 시각화하기

컨브넷이 학습한 필터를 조사하는 방법 중 하나는 각 필터가 반응하는 시각적 패턴을 그려보는 것입니다. 빈 입력 이미지에서 시작해서 특정 필터의 응답을 최대화하기 위해 컨브넷 입력 이미지에 경사 상승법을 적용합니다. 결과적으로 입력 이미지는 선택된 필터가 최대로 응답하는 이미지가 될 것입니다.

역주 : 경사 상승법은 손실 함수의 값이 커지는 방향으로 그래디언트를 업데이트하기 때문에 경사 하강법과 반대이지만, 학습과정은 동일합니다. 이 절에서는 두 용어를 섞어 사양하는데 번역서에서는 혼동을 피하기 위해 경사 상승법으로 통일했습니다.

전체 과정은 다음과 같습니다.

  • 특정 합성곱 층의 한 필터 값을 최대화하는 손실 함수를 정의합니다.
  • 이 활성화 값을 최대화하기 위해 입력 이미지를 변경하도록 확률적 경사 상승법을 사용합니다.

손실 텐서를 정해야합니다. 책의 예시는 ImageNet에 사전 훈련된 VGG16 네트워크에서 block3_conv1 층 필터 0번의 활성화를 손실로 정의합니다.

# 코드 5-32 필터 시각화를 위한 손실 텐서 정의하기
from keras.applications import VGG16
from keras import backend as K

model = VGG16(weights='imagenet',
              include_top=False)

layer_name = 'block3_conv1'
filter_index = 0

layer_output = model.get_layer(layer_name).output
loss = K.mean(layer_output[:, :, :, filter_index])

경사 상승법을 구현하기 위해 모델의 입력에 대한 손실의 그래디언트가 필요합니다. 이를 위해 케라스의 backend 모듈에 있는 gradients 함수를 사용하겠습니다.

# 코드 5-33 입력에 대한 소실의 그래디언트 구하기

# gradients 함수가 반환하는 텐서 리스트(여기에서는 크기가 1인 리스트)에서 첫 번째 텐서를 추출합니다
grads = K.gradients(loss, model.input)[0]

경사 상승법 과정을 부드럽게 하기 위해 그래디언트 텐서를 L2 norm으로 나누어 정규화합니다. 이렇게 하면 입력 이미지에 적용할 수정량의 크기를 항상 일정 범위 안에 넣을 수 있습니다. (그래디언트 클리핑)

# 코드 5-34 그래디언트 정규화하기

# 0 나눗셈을 방지하기 위해 1e–5을 더합니다
grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)

이제 주어진 입력 이미지에 대해 손실 텐서와 그래디언트 텐서를 계산해야합니다. 경사 상승법이기에 옵티마이저를 따로 사용할 수 없고, keras.backend.function() 함수로 만듭니다. iterate는 넘파이 텐서를 입력으로 받아 손실과 그래디언트 2개의 넘파이 텐서를 반환합니다.

# 코드 5-35 입력 값에 대한 넘파이 출력 값 추출하기

iterate = K.function([model.input], [loss, grads])

# 테스트:
import numpy as np
loss_value, grads_value = iterate([np.zeros((1, 150, 150, 3))])

여기서 파이썬 루프를 만들어 확률적 경사 상승법을 구성합니다.

# 코드 5-36 확률적 경사 상승법을 사용한 손실 최대화하기

# 잡음이 섞인 회색 이미지로 시작합니다
input_img_data = np.random.random((1, 150, 150, 3)) * 20 + 128.

# 업데이트할 그래디언트의 크기
step = 1.
for i in range(40):   # 경사 상승법을 40회 실행합니다
    # 손실과 그래디언트를 계산합니다
    loss_value, grads_value = iterate([input_img_data])
    # 손실을 최대화하는 방향으로 입력 이미지를 수정합니다
    input_img_data += grads_value * step

결과 이미지 텐서는 (1. 150, 150, 3) 크기의 부동 소수 텐서입니다. 이 텐서 값은 [0, 255] 사이의 정수가 아니므로 후처리가 필요합니다.

# 코드 5-37 텐서를 이미지 형태로 변환하기 위한 유틸리티 함수

def deprocess_image(x):
    # 텐서의 평균이 0, 표준 편차가 0.1이 되도록 정규화합니다
    x -= x.mean()
    x /= (x.std() + 1e-5)
    x *= 0.1

    # [0, 1]로 클리핑합니다
    x += 0.5
    x = np.clip(x, 0, 1)

    # RGB 배열로 변환합니다
    x *= 255
    x = np.clip(x, 0, 255).astype('uint8')
    return x

이 코드를 모아서 층의 이름과 필터 번호를 입력으로 받는 함수를 만듭니다. 이 함수는 필터 활성화를 최대화하는 패턴을 이미지 텐서로 출력합니다.

# 코드 5-37 필터 시각화 이미지를 만드는 함수

def generate_pattern(layer_name, filter_index, size=150):
    # 주어진 층과 필터의 활성화를 최대화하기 위한 손실 함수를 정의합니다
    layer_output = model.get_layer(layer_name).output
    loss = K.mean(layer_output[:, :, :, filter_index])

    # 손실에 대한 입력 이미지의 그래디언트를 계산합니다
    grads = K.gradients(loss, model.input)[0]

    # 그래디언트 정규화
    grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)

    # 입력 이미지에 대한 손실과 그래디언트를 반환합니다
    iterate = K.function([model.input], [loss, grads])

    # 잡음이 섞인 회색 이미지로 시작합니다
    input_img_data = np.random.random((1, size, size, 3)) * 20 + 128.

    # 경사 상승법을 40 단계 실행합니다
    step = 1.
    for i in range(40):
        loss_value, grads_value = iterate([input_img_data])
        input_img_data += grads_value * step

    img = input_img_data[0]
    return deprocess_image(img)

이제 이 코드로 block3_conv1 층의 필터 0번째 채널이 최대로 반응하는 패턴을 그려봅시다.

plt.imshow(generate_pattern('block3_conv1', 0))
plt.show()

패턴테스트

이는 마치 물방울 패턴에 반응하는 것 같습니다. 이렇게 모든 층 필터를 시각화합니다. 간단하게 만들기 위해 각 층에서 처음 64개의 필터만 사용합니다. 또한 각 합성곱 블록의 첫번째 층만 살펴봅니다. 8 * 8 그리드로 정렬하여 출력합니다.

# 코드 5-39 층에 있는 각 필터에 반응하는 패턴 생성하기

for layer_name in ['block1_conv1', 'block2_conv1', 'block3_conv1', 'block4_conv1']:
    size = 64
    margin = 5

    # 결과를 담을 빈 (검은) 이미지
    results = np.zeros((8 * size + 7 * margin, 8 * size + 7 * margin, 3), dtype='uint8')

    for i in range(8):  # results 그리드의 행을 반복합니다
        for j in range(8):  # results 그리드의 열을 반복합니다
            # layer_name에 있는 i + (j * 8)번째 필터에 대한 패턴 생성합니다
            filter_img = generate_pattern(layer_name, i + (j * 8), size=size)

            # results 그리드의 (i, j) 번째 위치에 저장합니다
            horizontal_start = i * size + i * margin
            horizontal_end = horizontal_start + size
            vertical_start = j * size + j * margin
            vertical_end = vertical_start + size
            results[horizontal_start: horizontal_end, vertical_start: vertical_end, :] = filter_img

    # results 그리드를 그립니다
    plt.figure(figsize=(20, 20))
    plt.imshow(results)
    plt.show()
각 층의 필터 패턴

이런 필터 시각화를 통해 컨브넷 층이 바라보는 방식을 이해할 수 있습니다. 이 컨브넷 필터들은 모델의 상위 층으로 갈수록 점점 더 복잡해지고 개선됩니다.

  • 모델에 있는 첫 번째 층의 필터는 간단한 대각선 방향의 에지와 색깔을 인코딩합니다.
  • 에지나 색깔의 조합으로 만들어진 간단한 질감을 인코딩합니다.
  • 더 상위 층 필터는 자연적인 이미지에서 찾을 수 있는 질감을 닮아 가기 시작합니다.

5.4.3 클래스 활성화의 히트맵 시각화하기

이 방법은 이미지의 어느 부분이 컨브넷 컨브넷의 최종 분류 결정에 기여하는지 이해하는 데 유용합니다. 분류에 실수가 있는 경우 컨브넷의 결정 과정을 디버깅 하는 데 도움이 됩니다. 또 특정 물체가 있는 위치를 파악하는 데 사용할 수도 있습니다.

이 기법의 종류를 일반적으로 클래스 활성화 맵(Class Activation Map, CAM) 시각화라고 부릅니다. 입력 이미지에 대한 클래스 활성화의 히트맵을 만듭니다. 클래스 활성화 히트맵은 특정 출력 클래스에 대해 입력 이미지의 모든 위치를 계산한 2D 점수 그리드입니다.

여기서 사용할 구체적인 구현은 “Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization”에 기술되어 있습니다.

  • 입력 이미지가 주어지면 합성곱 층에 있는 특성 맵의 출력 추출
  • 특성 맵의 모든 채널 출력에 채널에 대한 클래스의 그래디언트 평균을 곱함

직관적으로 이해하는 방법은 다음과 같습니다.

입력 이미지가 각 채널을 활성화하는 정도 에 대한 공간적인 맵을 클래스에 대한 각 채널의 중요도 로 가중치를 부여하여 입력 이미지가 클래스를 활성화하는 정도 에 대한 공간적인 맵을 만는 것.

사전 훈련된 VGG16 네트워크로 이 기법을 시연합니다. 책에서는 아프리카 코끼리로 진행합니다. 사진은 다음과 같습니다.

elephant

우선 이미지를 코랩에 업로드합니다. 업로드는 kaggle.json을 업로드할때 사용하듯 하면 됩니다.

from google.colab import files
files.upload()

그리고 이제 작업을 시작해봅시다.

# 코드 5-40 사전 훈련된 가중치로 VGG16 네트워크 로드하기

from keras.applications.vgg16 import VGG16

K.clear_session()

# 이전 모든 예제에서는 최상단의 완전 연결 분류기를 제외했지만 여기서는 포함합니다
model = VGG16(weights='imagenet')

모델에 따라 이미지를 224 * 224로 변환하고, float32 텐서로 바꾼 후 이 전처리 함수를 적용해야합니다.

# 코드 5-41 VGG16을 위해 입력 이미지 전처리하기

from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input, decode_predictions
import numpy as np

# 이미지 경로
img_path = './elephant.jpg'

# 224 × 224 크기의 파이썬 이미징 라이브러리(PIL) 객체로 반환됩니다
img = image.load_img(img_path, target_size=(224, 224))

# (224, 224, 3) 크기의 넘파이 float32 배열
x = image.img_to_array(img)

# 차원을 추가하여 (1, 224, 224, 3) 크기의 배치로 배열을 변환합니다
x = np.expand_dims(x, axis=0)

# 데이터를 전처리합니다(채널별 컬러 정규화를 수행합니다)
x = preprocess_input(x)

이제 이 이미지에서 사전훈련된 네트워크를 실행하고 예측 백터를 이해하기 쉽게 디코딩합니다.

>> preds = model.predict(x)
>> print('Predicted:', decode_predictions(preds, top=3)[0])

Downloading data from https://s3.amazonaws.com/deep-learning-models/image-models/imagenet_class_index.json
40960/35363 [==================================] - 0s 2us/step
Predicted: [('n02504458', 'African_elephant', 0.9094213), ('n01871265', 'tusker', 0.08618258), ('n02504013', 'Indian_elephant', 0.004354576)]

상위 3개의 예측 클래스는 다음과 같습니다.

  • 아프리카 코끼리 (90.9%)
  • 코끼리(9%)
  • 인도 코끼리

이미지에서 가장 아프리카 코끼리 같은 부분을 시각화하기 위해 Grad-CAM 처리 과정을 구현합니다.

# 코드 5-42 Grad-CAM 알고리즘 설명하기

idx_ele = np.argmax(preds[0])

# 예측 벡터의 '아프리카 코끼리' 항목
african_elephant_output = model.output[:, idx_ele]

# VGG16의 마지막 합성곱 층인 block5_conv3 층의 특성 맵
last_conv_layer = model.get_layer('block5_conv3')

# block5_conv3의 특성 맵 출력에 대한 '아프리카 코끼리' 클래스의 그래디언트
grads = K.gradients(african_elephant_output, last_conv_layer.output)[0]

# 특성 맵 채널별 그래디언트 평균 값이 담긴 (512,) 크기의 벡터
pooled_grads = K.mean(grads, axis=(0, 1, 2))

# 샘플 이미지가 주어졌을 때 방금 전 정의한 pooled_grads와 block5_conv3의 특성 맵 출력을 구합니다
iterate = K.function([model.input], [pooled_grads, last_conv_layer.output[0]])

# 두 마리 코끼리가 있는 샘플 이미지를 주입하고 두 개의 넘파이 배열을 얻습니다
pooled_grads_value, conv_layer_output_value = iterate([x])

# "아프리카 코끼리" 클래스에 대한 "채널의 중요도"를 특성 맵 배열의 채널에 곱합니다
for i in range(512):
    conv_layer_output_value[:, :, i] *= pooled_grads_value[i]

# 만들어진 특성 맵에서 채널 축을 따라 평균한 값이 클래스 활성화의 히트맵입니다
heatmap = np.mean(conv_layer_output_value, axis=-1)

시각화를 위해 히트맵을 0과 1사이로 정규화하고 최종 결과를 출력합니다.

# 코드 5-43 히트맵 후처리하기
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
plt.show()

코끼리특성

원래는 파랑-노랑으로 표현되야하는데, 흑백으로 나옵니다. matplotlib에서 기본 컬러맵은 viridis로 노란이 가장 큰 값인데 제가 출력한 값은 검정이 가장 큰 값입니다.

그리고 OpenCV를 사용하여 앞에서 얻은 히트맵에 원본 이미지를 겹친 이미지를 만듭니다.

# 코드 5-44 원본 이미지에 히트맵 덧붙이기

import cv2

# cv2 모듈을 사용해 원본 이미지를 로드합니다
img = cv2.imread(img_path)

# heatmap을 원본 이미지 크기에 맞게 변경합니다
heatmap = cv2.resize(heatmap, (img.shape[1], img.shape[0]))

# heatmap을 RGB 포맷으로 변환합니다
heatmap = np.uint8(255 * heatmap)

# 히트맵으로 변환합니다
heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET)

# 0.4는 히트맵의 강도입니다
superimposed_img = heatmap * 0.4 + img

# 디스크에 이미지를 저장합니다
cv2.imwrite('./datasets/elephant_cam.jpg', superimposed_img)

그리고 그 결과를 코랩에서는 다음과 같은 코드로 볼 수 있습니다.

# 코랩 출력
from IPython.display import Image
Image('./datasets/elephant_cam.jpg')

코끼리다

이 시각화 기법은 2개의 중요한 질문에 답을 줍니다.

  • 왜 네트워크가 이미지에 아프리카 코끼리가 있다고 생각하는가?
  • 아프리카 코끼리가 사진 어디에 있는가?

5.5 요약

  • 컨브넷은 시각적인 분류 문제에서 좋은 도구
  • 컨브넷은 우리가 보는 세상을 표현하기 위해 패턴의 계층 구조와 개념을 학습합니다.
  • 학습된 표현은 분석할 수 있음
  • 이미지 분류 문제는 다양한 방법을 사용할 수 있음
  • 시각화

Leave a Comment