본문 바로가기
Deep Learning/밑바닥부터 시작하는 딥러닝

합성곱 신경망(CNN)

by 대소기 2022. 1. 30.

완전연결층

 

 

 

CNN이전의 구조 - 완전연결층

* 우리가 지금까지 살펴본 network는 완전연결층(Fully-connected layer)으로 이뤄져있었다. 완전 연결층은 말 그대로 이전 층의 뉴런들과 다음층의 뉴런들이 하나도 빠짐없이 서로 연결되어있는 구조를 띠는 층을 뜻한다. 조금 생소할수도 있으니 다음 자료를 통해 이해해보자.

* 일반적인 DNN구조이다. 잘 보면 이전 층의 뉴런들과 다음 층의 뉴런들이 모두 각각 연결되어있는 것을 확인할 수 있다.

* 그리고 이 완전연결층을 구현할 때 우리는 지금까지 Affine layer와 ReLU layer를 사용하였다. 이렇게 말이다.

* 앞서 mnist dataset을 통해 이미지를 분류하는 과정을 생각해보자. 이미지가 network로 입력될 때 flatten되어서 입력된다. 이 때문에 2차원 이미지(흑백) 혹은 3차원 이미지(컬러)가 network에 입력될 때 공간적인 정보들을 모두 잃게 된다. 이미지데이터는 데이터의 특성상 공간적인 정보가 많은 것을 담고 있는 경우가 많다. 가까운 픽셀끼리의 관계, RGB채널의 동일 위치 값들간의 관계 등 이미지 데이터의 원본 형식이 담고있는 정보가 적지 않다. 이 이미지 픽셀들을 1차원 벡터로 만드는 과정에서 공간적인 정보는 모두 소실되는 것이다.

* 이러한 이유 때문에 완전연결층을 사용하는 DNN은 이미지 처리에는 효율적이지 않다. 그렇다면 이미지는 어떤 network를 통해 처리 가능할까?

 

 

 

 

CNN의 구조

* CNN에서는 새롭게 Convolution layer, Pooling layer가 추가된다. CNN의 layer는 Conv-ReLU-Pooling의 흐름으로 연결된다. 이 때 Pooling은 생략되기도 한다.

* 이러한 구조는 완전연결층과는 달리 이미지를 원본 형식 그대로 처리하고 다음 layer에 넘겨주기 때문에 공간적인 정보가 소실될 위험이 없다.

 

 

 

합성곱 연산(Convolution operation)

* Convolution layer에서의 Convolution operation은 이미지 처리에서 말하는 필터 연산과 같다. 필터는 참고로 kernel이라고 부르기도 한다.

* 입력 데이터에 필터를 통한 연산을 시행하는 과정은 아래와 같다.

* 필터에 대응하는 원소와 필터의 원소끼리 곱하여 총합을 계산하는 방식을 단일 곱셈-누산(Fused Multiply-Add, FMA) 라고 한다.

* 완전연결신경망에서 매개변수는 가중치와 편향으로 이뤄져있었다. CNN에서의 가중치는 필터의 값들이 가중치가 되고, 편향은 1x1의 단일 값으로 FMA 이후 출력된 feature map의 모든 원소에 더해진다.

 

 

 

 

패딩(padding)

* 패딩은 데이터 주위에 특정 width의 특정 값(예를 들어 zero padding)을 채워 출력 feature map의 크기를 조절하는 방식이다. 만약 정말 deep한 CNN이라면, layer를 거칠수록 이미지의 크기가 작아져서 결국엔 1이 될 것이다. 이렇게 되면 더 이상 Convolution 연산을 진행할 수 없다. 패딩은 이를 방지하기 위해 사용한다. 만약 이미지에 width가 1인 패딩을 적용하면 출력 feature map의 크기는 입력 feature map의 크기와 동일해진다.

 

 

 

 

 

 

스트라이드(Stride)

* stride는 필터를 적용하는 위치의 간격을 뜻한다. 지금까지는 stride가 1인 경우만 살펴봤지만, stride는 사용자가 임의로 조정할 수 있는 parameter이다.

* stride가 커질수록 출력 feature map의 크기는 작아진다. stride에 따른 feature map의 크기를 수식으로 나타내면 아래와 같다.

* 입력 크기를 (H, W), 필터 크기를 (FH, FW), 출력 크기를 (OH, OW), 패딩을 P, 스트라이드를 S라고 하면,

 

 

$OH = {H + 2P - FH \over S } + 1$

$OW = {W + 2P - FW \over S } + 1$

 

 

* 주의할 점은 ${H + 2P - FH \over S }$, ${W + 2P - FW \over S }$ 가 정수로 나눠떨어지는 값이어야 한다는 점이다.

 

 

 

 

3차원 데이터의 합성곱 연산

* 3차원 데이터의 합성곱 연산은 2차원 데이터의 합성곱 연산과 크게 다르지 않다. 주의할 점은 필터의 개수와 입력 데이터의 마지막 차원, 즉 채널의 수가 같아야 한다는 점이다.

 

 

 

 

블록으로 생각하기

 

* 3차원 합성곱 연산은 데이터와 필터를 직육면체 블록이라고 생각하면 이해하기 쉬워진다.

* 블록의 크기는 (Channel, Height, Width) 순서로 나타낸다.

* 이 예에서 출력 데이터는 한 장의 feature map이다. 이는 channel이 1 개인 feature map이라는 것인데, 만약 여러 장의 feature map을 추출하고 싶다면 다음과 같은 과정을 거치면 된다.

 

* 필터를 FN개를 사용하면, 출력되는 feature map도 FN개가 된다.

* 또한 편향은 feature map 개수만큼의 단일값들로 이뤄져있기 때문에 크기가 (FN, 1, 1)이 된다.

 

 

 

배치 처리

 

 

* CNN에서도 배치 처리가 가능하다. 위와 같이 (C, H, W) 크기의 데이터 N개를 한 번에 계산하는 것이 가능하다.

 

 

 

 

풀링 계층(Pooling layer)

 

 

 

* pooling layer는 세로, 가로 방향의 공간을 줄이는 연산을 뜻한다. 

* pooling의 종류는 윈도우에 속한 원소 중 가장 큰 값만 추출하는 max pooling, 윈도우의 원소들의 평균을 추출하는 mean pooling 등이 있지만, 이미지 인식 분야에서는 주로 max pooling을 사용한다. 앞으로도 pooling이라 하면 max pooling으로 생각하면 될 것이다.

* pooling의 stride는 보통 window size와 동일하게 설정하는 것이 일반적이다(예를 들어 2x2 window면 stride 또한 2).

 

 

 

Pooling layer의 특징

 

1) 학습해야 할 매개변수가 없다.

* 대표적으로 max pooling을 예로 들면 가장 큰 값 만을 추출하면 되기 때문에 학습할 매개변수가 존재하지 않는다.

 

 

2) 채널 수가 변하지 않는다.

* pooilng layer의 output이 되는 feature map의 개수는 동일하다. channel마다 독립적으로 계산하기 때문이다.

 

 

3) 입력의 변화에 영향을 적게 받는다(강건하다)

* 위와 같이 입력 데이터가 1 칸씩 밀려도 결과는 달라지지 않는 것을 볼 수 있다. 물론 모든 경우에 출력이 동일하지는 않는다.

 

 

 

합성곱/풀링 계층 구현하기

 

 

4차원 배열

 

X = np.random.rand(10, 1, 28, 28)
X.shape

# (10, 1, 28, 28)

* 먼저 데이터로 사용할 4차원 이미지 데이터를 생성하였다. 배치처리를 위해 3차원 데이터가 아닌 4차원 데이터를 생성하였다.

 

 

 

 

 

im2col로 데이터 전개하기

 

 

* CNN의 연산 과정을 구현하기 위해서는 for 문을 사용해야 하는데, numpy에서 for문을 사용하는 것은 성능 면에서 좋지 못하다. 이를 대체하기 위해서 우리는 im2col 함수를 사용한다.

* 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀐다(정확히는 배치 안의 데이터수까지 포함한 4차원 데이터를 2차원 데이터로 변환한다). im2col은 필터링하기 좋게 입력 데이터를 전개한다.

* 위 예시 자료에서는 stride를 크게 잡아서 일부러 필터의 적용 영역이 겹치지 않게 했지만, 실제로는 겹치는 경우가 대부분이다. 만약 영역이 겹치면 출력한 후 원소 개수가 원래 블록의 원소 수보다 많아진다. 이는 메모리 소비라는 단점이 있지만, 행렬 계산이 for문 등의 계산을 사용하는 것 보다 효율적이기 때문에 이 방법을 사용한다.

* 조금 더 im2col함수를 살펴보면 만약 원본 데이터가 7x7xC 크기라고 할 때, 먼저 filter size (5,5,3) 대로 가로 3번, 세로 3번 총 9개로 나뉜다. 각 나뉜 구간의 데이터의 크기는 filter size와 같기 때문에 (5, 5, C)가 되고, 데이터를 모두 전개하면 (9, 5 x 5 x C) 크기의 2차원 행렬이 도출된다.

 

 

* im2col을 통해 데이터를 2차원으로 변환한 다음 filter 또한 각 필터를 1개의 열로 하는 2차원 행렬로 변환하여 계산을 진행한다.

* 마지막에 출력된 데이터를 reshape해주면 출력 데이터가 완성된다.

 

 

 

 

 

 

합성곱계층 구현하기

def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
    """다수의 이미지를 입력받아 2차원 배열로 변환한다(평탄화).
    
    Parameters
    ----------
    input_data : 4차원 배열 형태의 입력 데이터(이미지 수, 채널 수, 높이, 너비)
    filter_h : 필터의 높이
    filter_w : 필터의 너비
    stride : 스트라이드
    pad : 패딩
    
    Returns
    -------
    col : 2차원 배열
    """
    N, C, H, W = input_data.shape
    out_h = (H + 2*pad - filter_h)//stride + 1
    out_w = (W + 2*pad - filter_w)//stride + 1

    img = np.pad(input_data, [(0,0), (0,0), (pad, pad), (pad, pad)], 'constant')
    col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))

    for y in range(filter_h):
        y_max = y + stride*out_h
        for x in range(filter_w):
            x_max = x + stride*out_w
            col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]

    col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)
    return col


x1 = np.random.rand(1, 3, 7, 7)
col1 = im2col(x1, 5, 5, stride=1, pad=0)
print(col1.shape)

# (9, 75)

 

* im2col 함수를 정의한 후 x1에 적용을 해봤다. filter size를 (5, 5)로 설정하였기 때문에 2번째 차원 원소의 수가 3x5x5 = 75인 것을 확인할 수 있다.

* 이제 이 im2col을 사용하여 합성곱 층을 구현해보자.

 

 

class Convolution:
    def __init__(self, W, b, stride=1, pad=0):
        self.W = W # filter
        self.b = b 
        self.stride = stride 
        self.pad = pad
        
    def forward(self, x):
        FN, C, FH, FW = self.W.shape #filter shape
        N, C, H, W = x.shape
        out_h = int(1+(H+2*self.pad-FH)/self.stride) #output의 height
        out_w = int(1+(W+2*self.pad-FW)/self.stride) #output의 width
        
				# 전개
        col = im2col(x, FH, FW, self.stride, self.pad)
        col_W = self.W.reshape(FN, -1).T # filter to column

				# 계산
        out = np.dot(col, col_W) + self.b
        
				# reshape
        out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2) #축 순서 변경하기(N, H, W, C) to (N, C, H, W)
        
        return out

 

* 이렇게 im2col함수를 사용해 마치 이미지를 flatten해서 처리하는 DNN에서의 Affine 계층과 거의 똑같이 구현할 수 있었다. 

* 합성곱의 역전파는 결국 계산 과정은 Affine 계층과 유사하기 때문에 다루지 않지만, 하나 주의할 점은 아래 col2lim함수를 통해 im2col을 역으로 처리해야 한다는 점이다.

 

def col2im(col, input_shape, filter_h, filter_w, stride=1, pad=0):
    """(im2col과 반대) 2차원 배열을 입력받아 다수의 이미지 묶음으로 변환한다.
    
    Parameters
    ----------
    col : 2차원 배열(입력 데이터)
    input_shape : 원래 이미지 데이터의 형상(예:(10, 1, 28, 28))
    filter_h : 필터의 높이
    filter_w : 필터의 너비
    stride : 스트라이드
    pad : 패딩
    
    Returns
    -------
    img : 변환된 이미지들
    """
    N, C, H, W = input_shape
    out_h = (H + 2*pad - filter_h)//stride + 1
    out_w = (W + 2*pad - filter_w)//stride + 1
    col = col.reshape(N, out_h, out_w, C, filter_h, filter_w).transpose(0, 3, 4, 5, 1, 2)

    img = np.zeros((N, C, H + 2*pad + stride - 1, W + 2*pad + stride - 1))
    for y in range(filter_h):
        y_max = y + stride*out_h
        for x in range(filter_w):
            x_max = x + stride*out_w
            img[:, :, y:y_max:stride, x:x_max:stride] += col[:, :, y, x, :, :]

    return img[:, :, pad:H + pad, pad:W + pad]

 

 

 

 

Pooling계층 구현하기

 

 

 

 

* pooling계층 구현 또한 합성곱 계층과 마찬가지로 im2col을 사용해 입력 데이터를 전개하지만, 채널마다 독립적인 pooling을 해준다는 점이 이라는 점이 합성곱 계층 때와 다르다. 위 그림은 pooling계층의 forward 처리 과정이다. 

* pooling 계층의 forward과정을 python code로 구현하면 아래와 같다.

 

 

class Pooling:
    def __init__(self, pool_h, pool_w, stride=1, pad=0):
        self.pool_h=pool_h
        self.pool_w=pool_w
        self.stride=stride
        self.pad=pad
        
    def forward(self, x):
        n, c, h, w=x.shape
        out_h=int(1+(h-self.pool_h)/self.stride)
        out_w=int(1+(w-self.pool_w)/self.stride)
        
        col=im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)  #전개
        col=col.reshape(-1, self.pool_h*self.pool_w)
        
        out=np.max(col, axis=1) #행별 최댓값
        
        out=out.reshape(n, out_h, out_w, c).transpose(0, 3, 1, 2)
        
        return out

 

 

CNN 구현하기

 

* convolution 계층과 pooling 계층을 구현했으니, 이를 조합해 손글씨 숫자를 인식하는 CNN을 조립해보자.

* CNN 네트워크는 Convolution-ReLU-Pooling-Affine-ReLU-Affine-Softmax 순으로 흐른다.

 

import sys
import os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
import numpy as np
import matplotlib.pyplot as plt
from collections import OrderedDict
from common.layers import *
from common.gradient import numerical_gradient
from dataset.mnist import load_mnist
from common.trainer import Trainer


class SimpleConvNet:
    """
    다음과 같은 CNN을 구성한다.
    → Conv → ReLU → Pooling → Affine → ReLU → Affine → Softmax →
    전체 구현은 simple_convnet.py 참고
    """
    def __init__(self, input_dim=(1, 28, 28),
                 conv_param={'filter_num': 30, 'filter_size': 5,
                             'pad': 0, 'stride': 1},
                 hidden_size=100, output_size=10, weight_init_std=0.01):
        # 초기화 인수로 주어진 하이퍼파라미터를 딕셔너리에서 꺼내고 출력 크기를 계산한다.
        filter_num = conv_param['filter_num']
        filter_size = conv_param['filter_size']
        filter_pad = conv_param['pad']
        filter_stride = conv_param['stride']
        input_size = input_dim[1]
        conv_output_size = (input_size - filter_size + 2*filter_pad) / \
            filter_stride + 1
        pool_output_size = int(filter_num * (conv_output_size/2) *
                               (conv_output_size/2))

        # 1층의 합성곱 계층과 2, 3층의 완전연결 계층의 가중치와 편향 생성
        self.params = {}
        self.params['W1'] = weight_init_std * \
            np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
        self.params['b1'] = np.zeros(filter_num)
        self.params['W2'] = weight_init_std * \
            np.random.randn(pool_output_size, hidden_size)
        self.params['b2'] = np.zeros(hidden_size)
        self.params['W3'] = weight_init_std * \
            np.random.randn(hidden_size, output_size)
        self.params['b3'] = np.zeros(output_size)

        # CNN을 구성하는 계층을 생성
        self.layers = OrderedDict()
        self.layers['Conv1'] = Convolution(self.params['W1'],
                                           self.params['b1'],
                                           conv_param['stride'],
                                           conv_param['pad'])
        self.layers['Relu1'] = Relu()
        self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
        self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
        self.layers['Relu2'] = Relu()
        self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])
        self.last_layer = SoftmaxWithLoss()

    def predict(self, x):
        """추론을 수행"""
        for layer in self.layers.values():
            x = layer.forward(x)
        return x

    def loss(self, x, t):
        """손실함수 값 계산"""
        y = self.predict(x)
        return self.last_layer.forward(y, t)

    def accuracy(self, x, t, batch_size=100):
        if t.ndim != 1:
            t = np.argmax(t, axis=1)

        acc = 0.0

        for i in range(int(x.shape[0] / batch_size)):
            tx = x[i*batch_size:(i+1)*batch_size]
            tt = t[i*batch_size:(i+1)*batch_size]
            y = self.predict(tx)
            y = np.argmax(y, axis=1)
            acc += np.sum(y == tt)

        return acc / x.shape[0]

    def gradient(self, x, t):
        """오차역전파법으로 기울기를 구함"""
        # 순전파
        self.loss(x, t)

        # 역전파
        dout = 1
        dout = self.last_layer.backward(dout)

        layers = list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout = layer.backward(dout)

        # 결과 저장
        grads = {}
        grads['W1'] = self.layers['Conv1'].dW
        grads['b1'] = self.layers['Conv1'].db
        grads['W2'] = self.layers['Affine1'].dW
        grads['b2'] = self.layers['Affine1'].db
        grads['W3'] = self.layers['Affine2'].dW
        grads['b3'] = self.layers['Affine2'].db

        return grads

 

 

 

CNN 시각화하기

 

 

 

 

1번째 층의 가중치 시각화하기

 

 

* CNN의 가중치 갱신 과정을 이해하기 위해 가중치를 시각화 해보자.

* 방금 구현한 합성곱 계층의 필터(가중치)를 이미지로 시각화해보면 다음과 같다.

* 학습 전의 무작위적인 패턴에서 학습 후 어느정도의 규칙성있는 패턴으로 변화한 것을 확인할 수 있다.

* 흰색에서 검은색으로 점차 변화하는 필터와 덩어리가 진 필터 등, 규칙을 띄는 필터로 바뀌었다.

* 오른쪽에 규칙성 있는 필터는 edge와 blob을 보고있는 것이라고 할 수 있다. 

 

* filter의 모양에 따라 다르지만 대표적으로 세로 edge를 detect하는 filter 1과 가로 edge를 detect하는 filter2를 보자. filter1의 왼쪽 부분에 세로로 길게 이어진 흰색 픽셀이 보이는 것을 알 수 있는데, 이 때문에 filter1과 곱해진 원본 이미지는 세로 edge에 흰 픽셀이 많이 나타나게 된다. 반대로 filter 2의 출력본에서는 가로 edge에 흰 픽셀이 많이 나타나는 것을 확인할 수 있다.

* 첫 번째 convolution layer(저수준 layer)를 시각화한 결과이기 때문에 매우 원시적인 수준의 정보를 추출하고 있는 것을 볼 수 있다.

 

 

층 깊이에 따른 추출 정보 변화

 

 

 

* 앞서 저수준의 layer에서 어떤 정보를 추출하는지 시각화를 해 보았다. 그렇다면 깊이가 깊어질수록 어떤 정보를 추출하게 될까? 위 네트워크는 AlexNet이고 밑에 있는 그림들은 각 layer에서 어떤 feature map을 추출하는지를 시각화해본 결과이다. 

* 네트워크는 층이 깊어질수록 더 추상화되고 복잡한 feature map을 추출하면서 패턴을 파악하는 것으로 보인다.

 

 

 

 

대표적인 CNN

 

 

 

 

LeNet

 

* LeNet은 1998년에 등장한 손글씨 인식을 위한 네트워크이다.

* 최근 CNN에서는 activation function으로 ReLU를 많이 사용하는 반면, LeNet에서는 sigmoid를 사용한다.

* max pooling layer가 아닌 sub sampling layer를 사용한다.

 

 

 

 

AlexNet

* 2012년에 발표된 네트워크이다. 활성화 함수로 ReLU를 사용한다.

* LRN(Local Response Normalization)이라는 국소적 정규화를 실시하는 계층을 이용한다.

* 드롭아웃을 사용한다.

 

 

'Deep Learning > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글

Word2Vec  (0) 2022.02.03
딥러닝 등장 이전의 자연어와 단어의 분산 표현  (0) 2022.02.01
Optimizer  (0) 2022.01.27
Backpropagation  (0) 2022.01.27
경사하강법(Gradient Descent method)  (0) 2022.01.25