본문 바로가기
Drawing (AI)/DeepLearning

딥러닝 직접 구현하기 - (CNN)

by 생각하는 이상훈 2024. 1. 19.
728x90

구조

CNN(Convolutional Neural Network)은 이전에도 블로그에서 자주 다뤘으니 기본 개념은 간단히 다루고 넘어가고 구현하는 부분에 집중하도록 한다.

CNN은 컴퓨터 비전과 이미지 인식에서 널리 사용되는 심층 학습 모델이다. Fully Connected Layer와는 다르게 전체 데이터를 섞어버리지 않고 각 부분의 특징을 보존하기 때문에 CNN은 이미지에서 패턴을 인식하고 이를 분류하는 데 탁월한 능력을 지닌다. CNN에서 쓰이는 개념들을 간단히 살펴보자.

커널 (Kernel) / 필터 (Filter)
커널 또는 필터로 불리는 행렬은 이미지의 특정 특성을 추출하는 데 사용되는 작은 행렬이다. 커널은 이미지의 윈도우와 요소별로 곱해지며, 그 결과를 합하여 피처맵의 한 요소를 형성한다. 다양한 커널을 사용하여 이미지의 다양한 특성을 추출할 수 있다.

 

윈도우 (Window)
윈도우는 입력 이미지에서 커널이 적용되는 영역을 의미한다. 윈도우의 크기는 일반적으로 커널의 크기와 동일하며, 이미지 위를 이동하며 각 위치에서 커널과의 컨볼루션 연산을 수행한다.


스트라이드 (Stride)
스트라이드는 필터를 적용할 때 필터가 이미지 위를 이동하는 거리를 의미한다. 스트라이드가 크면 출력 피처맵의 크기는 작아지고, 스트라이드가 작으면 출력 피처맵의 크기는 커진다. 일반적으로 스트라이드는 1 또는 2를 사용한다.

피처맵 (Feature Maps)
피처맵은 커널을 이미지에 적용하여 얻은 결과이다. 이는 이미지의 특정 특성(예: 모서리, 색상, 질감 등)을 강조하여, 네트워크가 이미지를 더 잘 이해하도록 돕는다. 각 커널은 이미지의 다른 특성을 강조하기 때문에, 여러 커널을 사용하면 이미지에 대한 더 풍부한 정보를 얻을 수 있다.

풀링 (Pooling) 

풀링은 피처맵의 크기를 축소하여 네트워크의 계산 부하를 줄이고, 과적합을 방지하는 동시에 공간적인 특성을 유지하는 방법이다. 풀링은 일반적으로 Max Pooling과 Average Pooling등과 같은 유형이 있다.


im2col

for문을 이용하여 convolution연산을 진행하려면 중첩된 for문을 이용해야하여 성능이 매우 떨어지기 때문에 im2col이라는 데이터를 전개하는 함수를 이용한다. im2col은 'image to column' 즉 '이미지에서 행렬로'라는 뜻으로 CNN에 이용하기에 매우 적합한 함수임을 알 수 있다. 이 함수를 이용하면 아래 그림과 같이 3차원 입력 데이터에 im2col을 적용하면 2차원 행렬로 바뀐다.

im2col로 입력 데이터를 전개한 다음에는 conv layer의 필터를 1열로 전개하고, 두행렬의 곱을 계산하면 된다. 이는 FCL의 Affine 계층에서 한 것과 거의 같다.

im2col 구현

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

Conv Layer 구현

im2col을 이용하여 convolution layer를 구현해보자.

class Convolution:
	def __init__(self, W, b, stride=1, pad=0):
    	self.W = W
        self.b = b
        self.stride = stride
        self.pad = pad
        
    def forward(self, x):
    	FN, C, FH, FW = self.W.shape
        N, C, H, W = x.shape # 입력
        out_h = int(1 + (H + 2*self.pad - FH) / self.stride)
        out_w = int(1 + (W + 2*self.pad - FW) / self.stride)
        
        col = im2col(x, FH, FW, self.stride, self.pad)
        col_W = self.W.reshape(FN, -1).T # 필터의 전개 (세로)
        out = np.dot(col, col_W) + self.b
        
        out = out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
        
        return out

Convolution Layer는 필터, 편향, 스트라이드, 패딩을 인수로 받아 초기화한다. 필터는 (FN, C, FH, FW)의 4차원 형상이다. FN은 필터 개수, C는 채널, FH는 필터 높이, FW는 필터 너비이다. 

우선 im2col과 reshape을 이용하여 2차원 배열로 전개하고 두 행렬의 곱을 구한다. 그 다음으로 reshape에 -1을 지정하여 세로로 세운다. forward 구현의 마지막에서는 출력 데이터를 transpose 함수를 이용하여 적절한 형상으로 바꿔준다.

역전파 과정은 col2im 함수를 이용한다는 특징만 살려서 일반적인 역전파와 똑같이 구현하면된다.


Pooling Layer 구현

Pooling Layer도 im2col을 이용하여 입력 데이터를 전개하는데 다만 채널 쪽이 독립적이라는 특징이 있다.

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)
 
    # 전개 (1)
    col = im2col(x, self.pool_h, self.pool_w, self.stride, self.pad)
    col = col.reshape(-1, self.pool_h*self.pool_w)
 
    # 최댓값 (2)
    out = np.max(col, axis=1) # 각 행마다 최댓값 도출
 
    # 성형 (3)
    out = out.reshape(N, out_h, out_w, C).transpose(0, 3, 1, 2)
 
    return out

우선 입력 데이터를 전개하고 행별 최댓값을 구하고 적절한 모양으로 성형하는 세단계를 거친다.


CNN 구현

# coding: utf-8
import sys, os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
import pickle
import numpy as np
from collections import OrderedDict
from common.layers import *
from common.gradient import numerical_gradient


class SimpleConvNet:
    """단순한 합성곱 신경망
    
    conv - relu - pool - affine - relu - affine - softmax
    
    Parameters
    ----------
    input_size : 입력 크기(MNIST의 경우엔 784)
    hidden_size_list : 각 은닉층의 뉴런 수를 담은 리스트(e.g. [100, 100, 100])
    output_size : 출력 크기(MNIST의 경우엔 10)
    activation : 활성화 함수 - 'relu' 혹은 'sigmoid'
    weight_init_std : 가중치의 표준편차 지정(e.g. 0.01)
        'relu'나 'he'로 지정하면 'He 초깃값'으로 설정
        'sigmoid'나 'xavier'로 지정하면 'Xavier 초깃값'으로 설정
    """
    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))

        # 가중치 초기화
        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)

        # 계층 생성
        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):
        """손실 함수를 구한다.

        Parameters
        ----------
        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 numerical_gradient(self, x, t):
        """기울기를 구한다(수치미분).

        Parameters
        ----------
        x : 입력 데이터
        t : 정답 레이블

        Returns
        -------
        각 층의 기울기를 담은 사전(dictionary) 변수
            grads['W1']、grads['W2']、... 각 층의 가중치
            grads['b1']、grads['b2']、... 각 층의 편향
        """
        loss_w = lambda w: self.loss(x, t)

        grads = {}
        for idx in (1, 2, 3):
            grads['W' + str(idx)] = numerical_gradient(loss_w, self.params['W' + str(idx)])
            grads['b' + str(idx)] = numerical_gradient(loss_w, self.params['b' + str(idx)])

        return grads

    def gradient(self, x, t):
        """기울기를 구한다(오차역전파법).

        Parameters
        ----------
        x : 입력 데이터
        t : 정답 레이블

        Returns
        -------
        각 층의 기울기를 담은 사전(dictionary) 변수
            grads['W1']、grads['W2']、... 각 층의 가중치
            grads['b1']、grads['b2']、... 각 층의 편향
        """
        # forward
        self.loss(x, t)

        # backward
        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'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
        grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
        grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

        return grads
        
    def save_params(self, file_name="params.pkl"):
        params = {}
        for key, val in self.params.items():
            params[key] = val
        with open(file_name, 'wb') as f:
            pickle.dump(params, f)

    def load_params(self, file_name="params.pkl"):
        with open(file_name, 'rb') as f:
            params = pickle.load(f)
        for key, val in params.items():
            self.params[key] = val

        for i, key in enumerate(['Conv1', 'Affine1', 'Affine2']):
            self.layers[key].W = self.params['W' + str(i+1)]
            self.layers[key].b = self.params['b' + str(i+1)]

시각화

MNIST 데이터 셋으로 CNN 학습알 해보면 1번째 층의 합성곱 계층의 가중치는 그 형상이 (30, 1, 5, 5)이다. 필터의 크기가 5x5이고 채널이 1개라는 것은 이 필터를 1채널의 회색조 이미지로 시각화할 수 있다는 뜻이다. 합성곱 계층 필터를 이미지로 나타내면 다음과 같다.

학습 전 필터는 무작위로 초기화되고 있기 때문에 흑백의 정도에 규칙성이 없지만 학습 후에는 규칙성 있는 이미지가 되었다. 이렇게 규칙성 있는 필터는 에지(색상이 바뀐 경계선)과 블롭(국소적으로 덩어리진 영역)을 보고 있다.

각 필터는 가로 에지와 세로 에지에 반응한다.

 

층 깊이에 따라서 추출 정보가 어떻게 변하는지 살펴보자. 계층이 깊어질 수록 추출되는 정보는 더 추상화 된다. 딥러닝의 흥미로운 점은 합성곱 계층을 여러 겹 쌓으면 층이 깊어지면서 더 복잡하고 추상화된 정보가 추출된다는 점이다. 처음 층은 단순한 에지에 반응하고, 이어서 텍스처에 반응하고, 더 복잡한 사물의 일부에 반응하도록 변화한다. 층이 깊어지면서 뉴런이 반응하는 대상이 단순한 모양에서 고급 정보로 변화해간다. 다시말해 사물의 의미를 이해하도록 변화하는 것이다.


 

728x90