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.

합성곱

1완전연결 계층의 한계

완전연결 계층은 입력 특성마다 가중치를 할당해 학습하는 퍼셉트론으로 구성됩니다. 이미지를 입력으로 가정하면 개별 픽셀 하나하나가 특성이 되고, 퍼셉트론은 모든 픽셀에 가중치를 대응시킵니다. 그런데 이러한 방식이 이미지 처리에 바람직한지는 따져 볼 문제입니다.

왼쪽 위 첫 번째 픽셀을 x1x_1, 오른쪽 아래 마지막 픽셀을 xnx_n이라 하면, 완전연결 계층은 서로 멀리 떨어져 사실상 아무 상관이 없는 두 픽셀의 관계까지 표현하려고 합니다. 반면 한 사물 주변의 픽셀들은 대체로 비슷한 색과 일정한 패턴을 보이므로 서로 연관되어 있다고 보는 것이 자연스럽습니다. 멀리 떨어진 픽셀과의 관계를 굳이 표현하는 것이 문제가 되는 이유는, 그렇게 학습한 표현이 다른 이미지로 일반화되기 어렵기 때문입니다. 픽셀 값이 조금만 달라져도 학습된 가중치가 곧바로 무효해집니다.

또 다른 한계는 입력의 공간적 분포를 정보로 반영하지 못한다는 점입니다. 완전연결 계층은 가중치가 각 픽셀에 일대일로 대응하므로, 입력 픽셀의 위치를 바꾸어 무작위로 섞어도 가중치의 합산 결과가 동일합니다. 즉 픽셀을 뒤섞은 이미지와 원본 이미지가 같은 것으로 학습됩니다. 그러나 이미지가 담은 시각 정보에서 공간적 분포는 본질적으로 중요하며, 픽셀을 섞으면 그 정보는 사라집니다. 이러한 이유로 이미지와 같은 데이터를 완전연결 계층만으로 처리하는 것은 적절하지 않습니다.

2희소 연결과 계층적 구성

합성곱은 입력의 일부 영역에만 연결되는 희소 연결을 사용합니다. 한 출력 뉴런이 바라보는 입력의 범위, 즉 수용 영역(receptive field)은 전체 입력보다 작습니다. 그런데 이러한 희소 연결을 여러 계층으로 쌓으면, 한 계층에서는 좁은 영역에만 직접 연결되더라도 계층을 거치면서 간접적으로는 전체 또는 대부분의 입력으로부터 영향을 받게 됩니다. 예를 들어 수용 영역의 크기가 3인 연결을 두 단계로 쌓으면, 최상위 뉴런 하나가 결국 더 넓은 범위의 입력을 간접적으로 반영합니다. 적은 연결로도 깊이를 더하면 넓은 범위의 정보를 포착할 수 있다는 것이 합성곱 계층의 장점입니다.

3합성곱 계층과 완전연결 계층

완전연결 계층이 각 퍼셉트론의 가중치와 편향을 학습한다면, 합성곱 계층은 커널(kernel)이 학습 대상입니다. 합성곱 계층은 지정된 수의 커널을 입력에 대해 합성곱 연산으로 적용합니다. 커널 값도 처음에는 백색 노이즈에 가까운 무작위 초기값에서 출발해, 최적화 과정에서 유의미한 특징을 추출하는 표현으로 형성되기를 기대합니다. 커널은 입력 크기에 비해 대체로 매우 작으므로, 입력 특성마다 가중치를 두는 완전연결에 비해 훨씬 적은 수의 학습 매개변수로 구성됩니다.

완전연결 계층이 1차원 특징 벡터를 입력으로 받는 것과 달리, 합성곱 계층은 공간적 분포를 유지한 형태의 입력을 받습니다. 커널이 공간적 분포를 반영하며 적용되기 때문입니다. 합성곱 연산의 결과는 커널 개수만큼 생성되는 특징맵(feature map)이며, 이는 입력에서 추출한 서로 다른 표현들의 집합입니다.

편향은 완전연결에서와 같은 의미와 방식으로 쓰입니다. 입력과 커널의 합성곱 결과인 특징맵에 스칼라 값의 편향이 더해져 최종 출력이 됩니다. 편향은 각 커널마다 하나씩 있으며, 가중치와 마찬가지로 학습 매개변수입니다.

import numpy as np
import matplotlib.pyplot as plt
import torch

X = torch.tensor([
    [1, 2, 3, 0],
    [0, 1, 2, 3],
    [3, 0, 1, 2],
    [2, 3, 0, 1]
]).float()

kernel = torch.tensor([
    [2, 0, 1],
    [0, 1, 2],
    [1, 0, 2]
]).float()

print(X.shape, X.unsqueeze(0).unsqueeze(0).shape)

conv2d = torch.nn.Conv2d(
    in_channels=1, out_channels=1, kernel_size=3, bias=False)
with torch.no_grad():
    conv2d.weight.copy_(kernel.unsqueeze(0).unsqueeze(0))
outputs = conv2d(X.unsqueeze(0).unsqueeze(0))
outputs = outputs.detach().numpy()
print(outputs.shape, outputs.squeeze().shape)
print(outputs.squeeze())
torch.Size([4, 4]) torch.Size([1, 1, 4, 4])
(1, 1, 2, 2) (2, 2)
[[15. 16.]
 [ 6. 15.]]
import keras

X = np.array([
    [1, 2, 3, 0],
    [0, 1, 2, 3],
    [3, 0, 1, 2],
    [2, 3, 0, 1]
])
kernel = np.array([
    [2, 0, 1],
    [0, 1, 2],
    [1, 0, 2]
])

inputs = keras.Input(shape=(4, 4, 1))
x = keras.layers.Conv2D(
    1, kernel_size=(3, 3), use_bias=False, name='conv')(inputs)
model = keras.Model(inputs, x)
# 가중치 초기화
conv_layer = model.get_layer('conv')
kernel_shape = conv_layer.get_weights()[0].shape
 # (H, W) -> (H, W, 1, 1)
print('Kernel (H, W, C_IN, C_OUT):', kernel_shape)
conv_layer.set_weights([np.expand_dims(kernel, axis=(2, 3))])
 # (H, W) -> (B, H, W, C)
outputs = model.predict(np.expand_dims(X, axis=(0, -1)), verbose=0)
print(outputs.squeeze())
Kernel (H, W, C_IN, C_OUT): (3, 3, 1, 1)
[[15. 16.]
 [ 6. 15.]]

합성곱 연산을 활용한 수직 방향 모서리 탐지

from keras.utils import load_img, img_to_array

모찌 = load_img('../data/mozzi.jpg', target_size=(200, 200))
모찌
kernel = np.array([[1, -1]] * 3)
print(kernel.shape, end=' -> ')
# 1) 차원 추가: (C, H) -> (C, W, H), 2) (C, W, H) -> (H, W, C)
kernel = kernel[:, np.newaxis, :].T
print(kernel.shape)
from keras.utils import load_img, img_to_array

모찌 = load_img('../data/mozzi.jpg', target_size=(200, 200))

inputs = keras.Input(shape=모찌.size + (3,))
outputs = layers.Conv2D(1, kernel_size=(2, 1), use_bias=False, padding='same')(inputs)
print(inputs.shape, outputs.shape)
model = keras.Model(inputs, outputs)
kernel = np.array([[1, -1]] * 3)[:, np.newaxis, :].T
kernel_shape = model.layers[1].get_weights()[0].shape
model.layers[1].set_weights([kernel.reshape(*kernel_shape)])
outputs = model(img_to_array(모찌).reshape(1, 200, 200, 3))
# 불필요한 차원 제거 (1, 200, 200, 1) -> (200, 200)
edge_image = tf.squeeze(outputs)
print(edge_image.shape)
# 이미지: 1) 음수값을 절대값으로 변환, 2) uint8로 변환
edge_image = np.abs(edge_image.numpy()).astype(np.uint8)

plt.subplot(1, 2, 1)
plt.imshow(모찌)
plt.axis('off')
plt.subplot(1, 2, 2)
plt.imshow(edge_image, cmap='gray')
plt.axis('off')
plt.show()

4합성곱 계층의 출력 살펴보기

합성곱 계층은 특징맵을 출력으로 내보냅니다. 이미지를 입력으로 받아 첫 번째 합성곱 계층을 통과한 출력을 보면, 어떤 특징맵은 특정 방향의 윤곽선을 추출하고, 어떤 것은 빛의 세기와 같은 정보를, 또 어떤 것은 색상을 중심으로 표현을 형성합니다. 학습된 커널들이 입력 이미지에서 정보적 가치가 있는 효과적인 표현을 추출한다는 것을 이러한 출력을 통해 확인할 수 있습니다.

5MNIST

필기체 이미지 데이터

from tensorflow.keras.datasets import mnist

(X_train, y_train), (X_test, y_test) = mnist.load_data()

X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# 단위 정규화
X_train /= 255
X_test /= 255
#채널 추가
X_train = X_train.reshape(-1, 28, 28, 1)
X_test = X_test.reshape(-1, 28, 28, 1)
#라벨 인코딩
y_train = tf.keras.utils.to_categorical(y_train)
y_test = tf.keras.utils.to_categorical(y_test)

모델 생성

6합성곱 신경망의 계층 구성

합성곱 계층은 합성곱 연산과 비선형 활성화를 거치는 것을 기본으로 하고, 종종 그 출력에 풀링(pooling)을 적용해 다음 계층으로 전달할 출력을 만듭니다. 1998년 LeNet에서 이미 두 개의 합성곱 계층이 사용되었고 각 출력에 서브샘플링이라는 일종의 풀링 기법이 적용되었습니다. 즉 합성곱 신경망의 최초 형태에서부터 풀링의 필요성이 제시된 것입니다. 다만 풀링이 합성곱 계층에 일대일로 대응할 필요는 없습니다. 풀링을 거치면 출력이 상당히 줄어들기 때문에, 깊은 신경망을 구성할 때는 출력이 지나치게 작아지지 않도록 적절한 간격으로 적용합니다.

6.1풀링

풀링은 특정 구역의 값들을 통계적으로 요약하는 기법입니다. 널리 쓰이는 최대 풀링(max pooling)은 각 구역의 최댓값을 그 구역의 대표값으로 선택하며, 구역의 평균값을 쓰는 방법 등도 있습니다. 풀링을 사용하는 주된 목적은 입력의 작은 변화에 대한 강건함을 부여하는 것입니다. 예를 들어 손글씨 숫자 이미지에서 같은 숫자라도 형태가 조금씩 다른데, 풀링을 쓰지 않으면 모델이 이러한 작은 변화 하나하나에 민감하게 반응해 일반화 능력이 떨어집니다. 풀링은 구역별로 주요한 값을 선택하고 생략 가능한 세부를 제외하는 요약 과정이므로, 일종의 압축으로도 볼 수 있습니다. 구역 크기를 2×22 \times 2로 두고 갱신 간격을 구역 크기만큼으로 하면 출력은 원래의 절반으로 줄어듭니다.

6.2패딩

합성곱 연산을 거치면 대체로 출력이 입력보다 작아집니다. 합성곱 계층을 여러 단계로 쌓는 딥러닝에서는 출력 크기를 조정하기 위해 패딩(padding)을 사용하기도 합니다. 패딩은 입력 주변을 정해진 크기만큼 0으로 채우는 기법으로, 필요한 패딩 크기는 커널 크기와 갱신 간격에 따라 달라집니다. 예를 들어 갱신 간격이 1일 때 입력 주변을 폭 1만큼 0으로 채우면 입력과 동일한 크기의 출력을 얻을 수 있습니다.

7CNN 모델 구현 (MNIST / Fashion-MNIST)

동일한 합성곱 분류 모델을 두 프레임워크로 제시합니다. 정적 코드 탭으로 실행되지 않으며, TensorFlow 탭은 MNIST, PyTorch 탭은 Fashion-MNIST를 데이터 매개체로 사용합니다.

TensorFlow
PyTorch
from tensorflow.keras import Sequential, layers

def create_model():
    model = Sequential([
        layers.Conv2D(20, activation='relu', kernel_size=5, padding='same', input_shape=(28, 28, 1)),
        layers.MaxPooling2D(pool_size=(2,2), strides=(2,2)),
        layers.Conv2D(50, activation='relu', kernel_size=5, padding='same'),
        layers.MaxPooling2D(pool_size=(2,2), strides=(2,2)),
        layers.Flatten(),
        layers.Dense(500, activation='relu'),
        layers.Dense(10, activation='softmax')
    ])
    return model

model = create_model()
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
history = model.fit(X_train, y_train, batch_size=128, epochs=20, validation_split=0.2)

score = model.evaluate(X_test, y_test)
print('Score: {:.2f}, Acc.: {:.2%}'.format(*score))

컴파일

평가

8CIFAR-10 images

from keras.datasets import cifar10

(X_train, y_train), (X_test, y_test) = cifar10.load_data()

num_classes = len(np.unique(y_train))

X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255

모델 설정

from tensorflow.keras import Sequential, layers

def create_model():
    model = Sequential([
        layers.Conv2D(32, activation='relu', kernel_size=3, padding='same', input_shape=(32, 32, 3)),
        layers.MaxPooling2D(pool_size=(2,2), strides=(2,2)),
        layers.Dropout(0.25),

        layers.Conv2D(50, activation='relu', kernel_size=5, padding='same'),
        layers.MaxPooling2D(pool_size=(2,2), strides=(2,2)),
        layers.Dropout(0.25),

        layers.Flatten(),
        layers.Dense(512, activation='relu'),
        layers.Dense(num_classes, activation='softmax')
    ])
    return model

create_model().summary()

컴파일

from tensorflow.keras.losses import sparse_categorical_crossentropy

model = create_model()
model.compile(loss=sparse_categorical_crossentropy, optimizer='adam', metrics=['accuracy'])
model.fit(X_train, y_train, batch_size=128, epochs=10, validation_split=0.2)

평가

score = model.evaluate(X_test, y_test)
print('Score: {:.2f}, Acc.: {:.2%}'.format(*score))

모델 저장

model.save('cifar10_conv2d_level_1.h5')

모델을 활용한 예측

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

이미지 로드

import scipy.misc
img = scipy.misc.imread('data/mozzi2.png')
img.shape
img = scipy.misc.imresize(img, (32, 32))
img.shape

어떻게 바뀌었을까요?

scipy.misc.imsave('mozzi_32x32.png', img)
img = img.astype('float32')
img /= 255
imgs = np.array([img])

예측

model.predict_classes(imgs)
cifar10_labels[5]

보다 깊은 층을 구성해서 성능 개선하기

conv+conv+maxpool+dropout+conv+conv+maxpool+dropout
dense+dropout+dense

model = Sequential()

model.add(Conv2D(32, (3,3), padding='same', activation='relu', input_shape=(32, 32, 3)))
model.add(Conv2D(32, (3,3), padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))
model.add(Conv2D(64, (3,3), padding='same', activation='relu'))
model.add(Conv2D(64, (3,3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(512, activation='relu'))
model.add(Dropout(0.5))

model.add(Dense(num_classes))
model.add(Activation('softmax'))

컴파일

model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])

훈련

model.fit(X_train, Y_train, batch_size=128, epochs=40, validation_split=0.2, verbose=1)
model.save('cifar-10_cnn_deep.h5')
img = scipy.misc.imread('data/mozzi.jpg')
img.shape
img = scipy.misc.imresize(img, (32, 32))
img = img.astype('float32')
img /= 255
img = np.array([img])
y_pred = model.predict_classes([img])
cifar10_labels[y_pred[0]]

8.1Data Augumentation

import os
from keras.preprocessing.image import ImageDataGenerator
from scipy.misc import imread, imresize

datagen = ImageDataGenerator(
        rotation_range=40,
        width_shift_range=0.2,
        height_shift_range=0.2,
        shear_range=0.2,
        zoom_range=0.2,
        horizontal_flip=True,
        fill_mode='nearest')

img = imread('data/mozzi.png')  
x = imresize(img, (150, 150))
x = x.reshape((1,) + x.shape)  # this is a Numpy array with shape (1, 3, 150, 150)

if not os.path.exists('preview'):
    os.makedirs('preview')

i = 0
for batch in datagen.flow(x, batch_size=1,
                          save_to_dir='preview', save_prefix='mozzi', save_format='jpeg'):
    i += 1
    if i > 20:
        break  # otherwise the generator would loop indefinitely

새로 생성한 이미지를 X_train에 추가

datagen.fit(X_train)

10ImageNet 경진대회와 합성곱 신경망의 발전

10.12014년 GoogLeNet

2014년 ILSVRC에서 VGG-16은 준우승을, 우승은 GoogLeNet이 차지했습니다. 이름에서 드러나듯 GoogLeNet은 LeNet을 계승해 합성곱 신경망을 적극 활용합니다. 다만 VGG-16까지의 모델과 달리, 계층이 순차적으로만 신호를 전달하는 형태에서 벗어나 신호를 여러 합성곱 계층에 병렬로 제공하고 그 결과를 다시 취합하는 인셉션(Inception) 구조를 도입했습니다. 합성곱과 풀링을 순차적으로만 거듭하면 신호의 크기가 점차 줄어들어 결국 합성곱의 이점을 살리기 어려울 만큼 작아지므로 신경망의 깊이에 제약이 생깁니다. GoogLeNet은 신호를 병렬로 전달하는 방식으로 이 문제를 해결했습니다.

흥미로운 점은 VGG-16과 GoogLeNet의 오류율이 각각 7.3%와 6.7%로 그 차이가 1% 미만이라는 것입니다. GoogLeNet은 훨씬 깊고 복잡해 고도의 하드웨어 인프라가 없으면 훈련이 매우 어려운 반면, VGG-16은 순차적이고 상대적으로 얕아 준수한 워크스테이션에서도 훈련할 수 있습니다. 그래서 VGG-16은 준우승에 그쳤지만 간결한 구성 덕분에 여러 응용에서 널리 활용됩니다.

10.22015년 ResNet

ResNet은 2015년 마이크로소프트가 개발해 ILSVRC에서 우승한 모델로, Top-5 오류율 3.5%를 기록하며 인간의 영상 인식 수준을 넘어서는 성능에 도달했습니다. ResNet은 입력 데이터를 합성곱 계층을 건너뛰어 전달하는 스킵 연결(skip connection)을 도입했습니다. 그 결과 34개 계층의 깊이를 가지면서도 대체로 순차적인 구조를 유지합니다. 스킵 연결은 층이 깊어질 때 역전파 과정에서 신호가 감쇠하는 것을 막아 기울기 소실 문제를 완화하므로 깊은 신경망의 학습을 효율적으로 만듭니다. 또한 네트워크에 필요한 계층 수 자체를 상대적으로 적게 구성할 수 있어 더 빠른 훈련을 가능하게 합니다.

2012년 AlexNet이 LeNet의 합성곱 계층을 재발견한 이후 등장한 우승권 모델들의 공통점은 모두 합성곱 계층으로 구성되었다는 것입니다. ResNet 이후로 영상 데이터에 대해서는 기본적으로 합성곱 신경망으로 접근하게 되었으며, 사전훈련된 모델의 가중치를 다른 목표에 활용하는 전이학습의 형태로도 폭넓게 쓰입니다.

from keras.applications.vgg16 import VGG16
from keras.applications.vgg16 import preprocess_input
from keras.models import Model
from keras.preprocessing import image

Model의 특정 레이어 값 접근

base_model = VGG16(weights='imagenet')
base_model.summary()
base_model.layers
img = image.load_img('data/mozzi2.png', target_size=(224, 224))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
model = Model(inputs=base_model.input, outputs=base_model.get_layer('block4_pool').output)
features = model.predict(x)
features.shape