본문 바로가기

케라스 창시자에게 배우는 딥러닝(DACOS)

9. 컴퓨터 비전을 위한 고급 딥러닝

 

 

[목차]

9-1 ) 세가지 주요 컴퓨터 비전 작업

9-2 ) 이미지 분할 예제

9-3 ) 최신 컨브넷 아키텍쳐

9-4 ) 컨브넷이 학습한 것 해석하기

 

 

 

 

 

9-1 ) 세가지 주요 컴퓨터 비전 작업

 

1) 이미지 분류

: 이미지에 하나 이상의 레이블을 할당하는 작업 (단일 레이블 분류 / 다중 레이블 분류)

ex) 다중 레이블 분류: 구글 포토 앱 키워드 검색

 

 

2) 이미지 분할

: 이미지를 서로 다른 영역으로 나누거나 분할하는 작업, 각 영역은 일반적으로 하나의 범주를 나타냄

ex) 구글 미트 속 지정 배경을 출력하는 기능 (인물과 배경 분리)

 

 

3) 객체 탐지

: 이미지에 있는 관심 객체 주변으로 바운딩 박스를 그리는 작업 (각 사각형은 하나의 클래스와 연결)

ex) 자율주행 자동차의 보행자/ 자동차/ 표지판 등을 감지하는 기능

 

* 이 외의 작업

유사도 평가: 두 이미지가 얼마나 시각적으로 유사한지 평가

키포인트 감지: 이미지에서 관심 속성을 정확하게 짚어내기

포즈 추정, 3D 메시 추정 등...

 

 

 

 

9-2 ) 이미지 분할 예제

 

이미지 분할: 이미지를 서로 다른 영역으로 나누거나 분할하는 작업

>> 모델을 사용하여 이미지 안의 각 픽셀에 클래스를 할당하는 것

 

종류

1) 시맨틱 분할 : 각 픽셀이 독립적으로 하나의 의미를 가진 범주로 분류됨

즉, 의미가 같을 시 같은 클래스로 분류함

 

2) 인스턴스 분할 : 이미지 픽셀을 범주로 분류하는 것 뿐만 아니라 개별 객체 인스턴스 구분지음

즉, 각 객체 단위의 분류를 의미함

시맨틱 분할/ 인스턴스 분할

 

* 분할 마스크: 이미지 분할에서 레이블에 해당 (입력 이미지와 동일한 크기 이미지, 컬러 채널 1개)

 

 

*시맨틱 분할 예제

 

데이터: 다양한 품종의 강아지/고양이 데이터(각 사진의 전경-배경 분할 마스크 포함)

 

타깃(분할 마스크): 1(전경), 2(배경), 3(윤곽)

 

import numpy as np
import random

img_size = (200, 200)              // 크기 변환
num_imgs = len(input_img_paths)

random.Random(1337).shuffle(input_img_paths)
random.Random(1337).shuffle(target_paths)                 // 정렬 섞음

def path_to_input_image(path):
    return img_to_array(load_img(path, target_size=img_size))

def path_to_target(path):
    img = img_to_array(
        load_img(path, target_size=img_size, color_mode="grayscale"))
    img = img.astype("uint8") - 1                                                                     // 레이블 0, 1, 2로 변환
    return img

input_imgs = np.zeros((num_imgs,) + img_size + (3,), dtype="float32")      // 전체 이미지 각각 입력과 타깃 배열에 로딩함
targets = np.zeros((num_imgs,) + img_size + (1,), dtype="uint8")
for i in range(num_imgs):
    input_imgs[i] = path_to_input_image(input_img_paths[i])
    targets[i] = path_to_target(target_paths[i])

num_val_samples = 1000
train_input_imgs = input_imgs[:-num_val_samples]                // 훈련 셋, 검증 셋 쪼개기
train_targets = targets[:-num_val_samples]
val_input_imgs = input_imgs[-num_val_samples:]
val_targets = targets[-num_val_samples:]

 

from tensorflow import keras
from tensorflow.keras import layers

def get_model(img_size, num_classes):
    inputs = keras.Input(shape=img_size + (3,))    
    x = layers.Rescaling(1./255)(inputs)              // 입력을 0/1 범위로 조정

    x = layers.Conv2D(64, 3, strides=2, activation="relu", padding="same")(x)    // strides=2로 다운샘플링
    x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)
    x = layers.Conv2D(128, 3, strides=2, activation="relu", padding="same")(x)
    x = layers.Conv2D(128, 3, activation="relu", padding="same")(x)
    x = layers.Conv2D(256, 3, strides=2, padding="same", activation="relu")(x)
    x = layers.Conv2D(256, 3, activation="relu", padding="same")(x)

    x = layers.Conv2DTranspose(256, 3, activation="relu", padding="same")(x)
    x = layers.Conv2DTranspose(256, 3, activation="relu", padding="same", strides=2)(x)  // strides=2 사용하여 업샘플링
    x = layers.Conv2DTranspose(128, 3, activation="relu", padding="same")(x)
    x = layers.Conv2DTranspose(128, 3, activation="relu", padding="same", strides=2)(x)
    x = layers.Conv2DTranspose(64, 3, activation="relu", padding="same")(x)
    x = layers.Conv2DTranspose(64, 3, activation="relu", padding="same", strides=2)(x) 
  // padding="same" : 패딩이 특성 맵 크기에 영향 미치지 않도록 함

 

합성곱 층마다 스트라이드를 추가하여 다운샘플링

 

** 정보 공간상 위치 중요한 이미지 분류의 경우

최대풀링 사용 시 풀링 윈도우 내 정보 삭제됨

>> 위치 정보를 유지하면서 특성 맵을 다운샘플링하는 스트라이드 사용

 

마지막 합성곱 층의 활성화 출력: (25, 25, 256)

 

최종 출력은 입력 = 타깃 마스크와 동일한 크기의 (200, 200, 3) 이어야 함

>> Conv2DTranspose() 층 사용하여 업샘플링

 
    outputs = layers.Conv2D(num_classes, 3, activation="softmax", padding="same")(x)  // 3개의 범주로 분류

    model = keras.Model(inputs, outputs)
    return model

model = get_model(img_size=img_size, num_classes=3)
model.summary()

 

model.compile(optimizer="rmsprop", loss="sparse_categorical_crossentropy")

callbacks = [
    keras.callbacks.ModelCheckpoint("oxford_segmentation.keras",    // 훈련 중 손실 변화에 대한 체크포인트 설정함
                                    save_best_only=True)
]

history = model.fit(train_input_imgs, train_targets,
                    epochs=50,
                    callbacks=callbacks,
                    batch_size=64,
                    validation_data=(val_input_imgs, val_targets))    // 모델 컴파일, 훈련 후 손실 정보 저장
 

 

epochs = range(1, len(history.history["loss"]) + 1)
loss = history.history["loss"]
val_loss = history.history["val_loss"]
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()                                                                                // 저장한 손실 정보 시각화

 

최종 분류 결과 중 한 예시 시각화

 

>> 어느 정도의 잡음으로 인한 오류를 제외하고 대체로 잘 작동하는 것 확인 가능

 

 

 

 

9-3 ) 최신 컨브넷 아키텍쳐

 

 

모델의 '아키텍쳐': 모델을 만드는 데에 사용된 일련의 선택 (사용할 층, 층의 설정, 층을 연결하는 방법 등)

>> 이러한 선택이 모델의 가설 공간 정의함

 

*가설 공간: 경사 하강법이 검색할 수 있는 가능한 함수의 공간 (파라미터: 모델의 가중치)

> 좋은 가설 공간은 현재 문제와 솔루션에 대한 사전 지식을 인코딩함

 

*아키텍쳐 선택의 중요성

아키텍쳐 적절하지 않을 시 훈련시켜도 성능이 개선되지 않을 수 있음

좋은 아키텍쳐 선택은 학습을 가속화하고 훈련 데이터를 효율적으로 사용 가능케 함 > 대규모 데이터셋 필요성 줄임

 

>> 좋은 모델 아키텍쳐란? : 탐색 공간의 크기를 줄이거나 탐색 공간의 좋은 위치에 쉽게 수렴할 수 있도록 하는 구조

 

* 컨브넷 아키텍쳐의 모법 사례

1) 잔차 연결 (residual connection)

2) 배치 정규화 (batch normalization)

3) 분리 합성곱 (separable convolution)

 

 

 

1) 모듈화, 계층화, 그리고 재사용

 

'아키텍쳐' / 케라스 등 모든 시스템 구조의 기초

복잡한 구조를 모듈화 > 모듈을 계층화 > 같은 모듈을 적절히 재사용

 

모듈화: 동일한 것을 두번 이상 구현할 필요성 없앰

 

>> 딥러닝: 경사 하강법을 통한 연속적인 최적화에 이러한 방법 적용

 

 

딥러닝 모델 아키텍쳐: 반복되는 층 그룹(모듈)들이 피라미드 구조(계층적 구조)를 이룸

특정 구조 새로 만들지 않고 재사용할 수 있음

 

계층 구조 > 깊을수록 특성 재사용과 이로 인한 추상화 장려함

> 일반적으로 작은 층을 깊게 쌓은 모델이 큰 층 얇게 쌓은것보다 성능 좋음

 

* 문제: 그레이디언트 소실(vanashing gradient) > 층을 쌓을 수 있는 것에는 한계 있음

 

 

 

2) 잔차 연결 (residual connection)

 

층 연결할수록 그레이트언트 소실 문제

원인: 정보가 잡음이 있는 채널을 통해 순차적으로 전달될 때 에러 누적 일어남

 

예시: y= f4(f3(f2(f1(x))))

 

> f1을 조정하려면 오차 정보가 f2, f3, f4를 통과해야 함

> 그러나, 연속적으로 놓인 각 함수에는 일정량의 잡음이 존재

> 그레이디언트 소실: 함수 연결이 너무 기프면 이 잡음이 그레이디언트 정보를 압도하여 역전파가 잘 동작하지 않게 됨

 

>> 해결 방법: 이전 입력에 담긴 잡음 없는 정보를 유지시킴

 

 

잔차 연결: 층이나 블록의 입력을 출력에 더해줌 

>> 이전 층의 오차 그레이디언트 정보가 잡음 없이 네트워크 깊숙히 전파하도록 해줌

 

잔차 연결 구현

 

[필터 개수가 변경되는 잔차 블록]

from tensorflow import keras
from tensorflow.keras import layers

inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
residual = x                                                                            // 잔차를 따로 저장
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)   // 잔차 블록에 해당하는 층 
residual = layers.Conv2D(64, 1)(residual)      // 투영
x = layers.add([x, residual])     // 블록 출력과 잔차의 크기가 같으므로 이제 더해줌 

 

출력 필터를 32 > 64로 증가시킴, 패딩으로 인한 다운샘플링이 일어나지 않도록 padding="same" 사용

 

[최대 풀링 층을 가지는 잔차 블록]

inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
residual = x
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)
x = layers.MaxPooling2D(2, padding="same")(x)
residual = layers.Conv2D(64, 1, strides=2)(residual)           
x = layers.add([x, residual])

 

최대 풀링 층으로 인한 다운샘플링에 맞추기 위해 잔차 투영에 strides=2 사용

 

 

inputs = keras.Input(shape=(32, 32, 3))
x = layers.Rescaling(1./255)(inputs)

def residual_block(x, filters, pooling=False):
    residual = x
    x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
    x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
    if pooling:
        x = layers.MaxPooling2D(2, padding="same")(x)
        residual = layers.Conv2D(filters, 1, strides=2)(residual)        // 잔차를 원하는 크기로 투영 위해 스트라이드 합성곱 사용
    elif filters != residual.shape[-1]:
        residual = layers.Conv2D(filters, 1)(residual)
    x = layers.add([x, residual])
    return x

x = residual_block(x, filters=32, pooling=True)         // 첫 번째 블록 
x = residual_block(x, filters=64, pooling=True)         // 두 번째 블록, 블록마다 필터 개수 증가
x = residual_block(x, filters=128, pooling=False)       // 마지막 블록은 바로 다음 전역 풀링 사용하기 때문에 최대 풀링 필요하지 않음

x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
model.summary()

 

 

 

3) 배치 정규화 (batch normalization)

 

배치 정규화: 훈련하는 동안 평균과 분산이 바뀌더라도 현재 배치 데이터의 평균과 분산을 이용하여 이를 정규화하는 방법

 

> 내부 공변량 변화로 인한 효과 (정확히 왜 도움이 되는지는 모름)

> 잔차 연결과 매우 흡사하게 그레이디언트의 전파를 도와줌

>> 매우 깊은 네트워크라면 여러 개의 배치 정규화 층을 포함해야 훈련할 수 있음

 

ex)

x= layers.Conv2D(32, 3, use_bias=False)(x)

x= layers.BatchNormalization()(x)

x=layers.Activation("relu")(x)

 

>> 올바른 사용법: 활성화 층 이전에 배치 정규화 층을 두어야 함

 

 

 

4) 깊이별 분리 합성곱 (depthwise separable convolution)

 

깊이별 분리 합성곱: 입력 채널별로 따로따로 공간 방향의 합성곱을 수행

 

* 깊이별 분리 합성곱은 중간 활성화에 있는 공간상의 위치가 높은 상관관계를 가지지만 채널 간에는 매우 독립적이라는 가정에 의존

 

장점: 일반 합성곱 (Conv2D 층)보다 훨씬 적은 개수의 파라미터를 사용하고 적은 수의 연산을 수행하는데 유사한 표현 능력을 가짐

 

단점: 일반적인 합성곱과 동일한 수준의 소프트웨어와 하드웨어 최적화 혜택을 받고 있지 못함

 

 

** 컨브넷 아키텍쳐 원칙 정리

1) 모델은 반복되는 블록으로 조직되어야 함

2) 특성 맵의 공간 방향이 줄어둚에 따라 층의 필터 개수는 증가해야 함

3) 깊고 좁은 아키텍쳐가 넓고 얕은 것보다 나음

4) 층 블록에 잔차 연결 추가 시 깊은 네트워크 훈련에 도움

5) 배치 정규화 층 추가

6) 일반 층 깊이별 분리 층으로 바꾸기

 

 

>> 이를 Xception 유사 모델에 모두 적용하기

 

inputs = keras.Input(shape=(180, 180, 3))
x = data_augmentation(inputs)

x = layers.Rescaling(1./255)(x)
x = layers.Conv2D(filters=32, kernel_size=5, use_bias=False)(x)

for size in [32, 64, 128, 256, 512]:
    residual = x                                                  // 잔차 연결

    x = layers.BatchNormalization()(x)              // 활성화 층 이전에 배치 정규화 층 추가
    x = layers.Activation("relu")(x)
    x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)

    x = layers.BatchNormalization()(x)
    x = layers.Activation("relu")(x)
    x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)  // 깊이별 분리 합성곱 층으로 일반 Conv2D 층 대체함

    x = layers.MaxPooling2D(3, strides=2, padding="same")(x)

    residual = layers.Conv2D(
        size, 1, strides=2, padding="same", use_bias=False)(residual)   // 잔차 크기 조정하기
    x = layers.add([x, residual])                                    // 잔차 더하기

x = layers.GlobalAveragePooling2D()(x)              // 전역 풀링으로 출력 크기와 맞추기
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)

 

 

파라미터 수 줄어듦, 90.8% 정확도 달성

 

 

 

 

9-4 ) 컨브넷이 학습한 것 해석하기

 

> 컨브넷이 학습한 것을 시각화하고 컨브넷이 내린 다양한 결정 이해하는 과정 필요

 

 

1) 컨브넷 중간층의 출력(중간층에 있는 활성화) 시각화하기

 

from tensorflow.keras import layers

layer_outputs = []
layer_names = []
for layer in model.layers:
    if isinstance(layer, (layers.Conv2D, layers.MaxPooling2D)):
        layer_outputs.append(layer.output)
        layer_names.append(layer.name)
activation_model = keras.Model(inputs=model.input, outputs=layer_outputs)

모델 입력이 주어졌을 때 층의 출력을 반환하는 모델 만듬

(입력 이미지 1개 > 9개 층마다 원본 모델의 활성화 값 반환)

 

activations = activation_model.predict(img_tensor)
first_layer_activation = activations[0]
print(first_layer_activation.shape)
 
images_per_row = 16
for layer_name, layer_activation in zip(layer_names, activations):   // 활성화(+ 해당 층 이름) 에 대한 루프 순회
    n_features = layer_activation.shape[-1]
    size = layer_activation.shape[1]                     // 총 활성화 크기 (1, size, n_features)
    n_cols = n_features // images_per_row
    display_grid = np.zeros(((size + 1) * n_cols - 1,
                             images_per_row * (size + 1) - 1))            //빈 그리드 생성
    for col in range(n_cols):
        for row in range(images_per_row):
            channel_index = col * images_per_row + row
            channel_image = layer_activation[0, :, :, channel_index].copy()
            if channel_image.sum() != 0:
                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 + 1): (col + 1) * size + col,
                row * (size + 1) : (row + 1) * size + row] = 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.axis("off")
    plt.imshow(display_grid, aspect="auto", cmap="viridis")            // 그리드 출력

 

 

>> 첫 층에서는 초기 이미지에 있는 거의 모든 정보가 유지

>> 층이 깊어질수록 추상적 (고수준 개념 인코딩하기 때문)

>> 비어 있는 활성화 증가 (층 올라갈수록 활성화되지 않는 필터 생기기 때문)

 

 

 

2) 컨브넷 필터 시각화

 

각 필터가 반응하는 시각적 패턴 그려 봄 > 경사 상승법 적용

 

1) 특정 합성곱 층의 한 필터를 최대화하는 손실 함수를 정의

2) 이 활성화 값을 최대화하기 위해 입력 이미지를 변경하도록 확률적 경사 상승법 사용

 

 

확률적 경사 상승법을 사용한 손실 최대화

@tf.function
def gradient_ascent_step(image, filter_index, learning_rate):
    with tf.GradientTape() as tape:
        tape.watch(image)
        loss = compute_loss(image, filter_index)
    grads = tape.gradient(loss, image)
    grads = tf.math.l2_normalize(grads)
    image += learning_rate * grads
    return image

 

필터 시각화 생성 함수

img_width = 200
img_height = 200

def generate_filter_pattern(filter_index):
    iterations = 30
    learning_rate = 10.
    image = tf.random.uniform(
        minval=0.4,
        maxval=0.6,
        shape=(1, img_width, img_height, 3))
    for i in range(iterations):
        image = gradient_ascent_step(image, filter_index, learning_rate)
    return image[0].numpy()

 

텐서를 이미지로 변환하기 위한 유틸리티 함수

def deprocess_image(image):
    image -= image.mean()
    image /= image.std()
    image *= 64
    image += 128
    image = np.clip(image, 0, 255).astype("uint8")
    image = image[25:-25, 25:-25, :]
    return image

 

 

 

3) 클래스 활성화에 대한 히트맵을 이미지에 시각화

 

클래스 활성화 맵: 어느 부분이 컨브넷의 최종 분류 결정에 기여하는지 이해하는 데에 유용

>> 분류에 실수 있을 경우 컨브넷의 결정 과정을 디버깅하는 데에 도움

 

 

마지막 합성곱 층을 반환하는 모델

last_conv_layer_name = "block14_sepconv2_act"
classifier_layer_names = [
    "avg_pool",
    "predictions",
]
last_conv_layer = model.get_layer(last_conv_layer_name)
last_conv_layer_model = keras.Model(model.inputs, last_conv_layer.output)

 

마지막 합성곱 출력 위에 있는 분류기에 적용하기 위한 모델 만들기

classifier_input = keras.Input(shape=last_conv_layer.output.shape[1:])
x = classifier_input
for layer_name in classifier_layer_names:
    x = model.get_layer(layer_name)(x)
classifier_model = keras.Model(classifier_input, x)

 

최상위 예측 클래스의 그레이디언트 계산하기

import tensorflow as tf

with tf.GradientTape() as tape:
    last_conv_layer_output = last_conv_layer_model(img_array)
    tape.watch(last_conv_layer_output)
    preds = classifier_model(last_conv_layer_output)
    top_pred_index = tf.argmax(preds[0])
    top_class_channel = preds[:, top_pred_index]

grads = tape.gradient(top_class_channel, last_conv_layer_output)

 

그레이디언트를 평균하고 채널 중요도 가중치 적용하기

pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2)).numpy()
last_conv_layer_output = last_conv_layer_output.numpy()[0]
for i in range(pooled_grads.shape[-1]):
    last_conv_layer_output[:, :, i] *= pooled_grads[i]
heatmap = np.mean(last_conv_layer_output, axis=-1)
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)
plt.show()

 

원본 이미지

 

클래스 활성화 히트맵

 

이 결과를 기반으로 왜 네트워크가 어떤 객체가 이미지에 존재한다고 판단했는지,

그리고 해당 객체가 이미지 어디에 존재하는지를 확인해 학습 결과를 해석할 수 있음.