Open In Colab

기본 세팅

import numpy as np
from numpy import ndarray
def assert_same_shape(output, output_grad):
    assert output.shape == output_grad.shape, \
    '''
    두 ndarray의 모양이 같아야 하는데,
    첫 번째 ndarray의 모양은 {0}이고
    두 번째 ndarray의 모양은 {1}이다.
    '''.format(tuple(output_grad.shape), tuple(output.shape))
    return None

def assert_dim(t, dim):
    assert len(t.shape) == dim, \
    '''
    이 텐서는 {0}차원이어야 하는데, {1}차원이다.
    '''.format(dim, len(t.shape))
    return None

필요한 차원을 잘 입력했는지 확인하는 함수를 선언합니다.

1차원 합성 곱

input_1d = np.array([1,2,3,4,5])
param_1d = np.array([1,1,1])
def _pad_1d(inp, num): # 원래 데이터와 패딩 길이 입력
    z = np.array([0])
    z = np.repeat(z, num)
    return np.concatenate([z, inp, z])

_pad_1d(input_1d, 1)
array([0, 1, 2, 3, 4, 5, 0])

간단한 함수로 1차원 데이터를 패딩한 모습입니다.

입력 데이터와 합성곱 연산을 한 출력 데이터의 크기를 같게하기 위해선 벗어나는 범위에 대해서 0 값을 채워주는 패딩을 하게 됩니다.

패딩 크기는 필터 크기를 2로 나눈 값에 정수부분이 입력과 출력을 같게하는 패딩의 크기가 됩니다.

def conv_1d(inp, param): # 입력 값과 필터 값 입력
    # 1차원 입력인지 확인합니다
    assert_dim(inp, 1)
    assert_dim(param, 1)

    # 입력 값에 패딩을 덧붙입니다.
    param_len = param.shape[0]
    param_mid = param_len // 2
    input_pad = _pad_1d(inp, param_mid)

    # 초기값 부여
    out = np.zeros(inp.shape)

    # 1차원 합성곱 연산 수행
    for o in range(out.shape[0]):
        for p in range(param_len):
            out[o] += param[p] * input_pad[o+p]

    # 출력 모양이 입력과 동일한지 확인
    assert_same_shape(inp, out)

    return out

conv_1d(input_1d, param_1d)
array([ 3.,  6.,  9., 12.,  9.])

가중치가 [1,1,1] 인 간단한 합성곱 연산을 진행했습니다.

def conv_1d_sum(inp, param): 
    out = conv_1d(inp, param)
    return np.sum(out)

input_1d = np.array([1,2,3,4,5])
input_1d_2 = np.array([1,2,3,4,6])
input_1d_3 = np.array([1,2,3,5,5])

param_1d = np.array([1,1,1])
param_1d_2 = np.array([2,1,1])

print(conv_1d_sum(input_1d, param_1d))
print(conv_1d_sum(input_1d_2, param_1d))
print(conv_1d_sum(input_1d_3, param_1d))
print(conv_1d_sum(input_1d, param_1d_2))
39.0
41.0
42.0
49.0

입력값과 필터값이 달라짐에 따라 출력값의 합이 어떻게 바뀌는지 비교했습니다.

끝 쪽 입력값이 1 증가할때는 출력값의 합이 2증가, 가운데 쪽 입력값(패딩 영향 안받는)이 1 증가할때는 출력값의 합이 3증가합니다.

또 필터값이 1 증가할때 출력값의 합이 10 증가합니다.

def _param_grad_1d(inp, param, output_grad = None):
    # 입력값 패딩 추가
    param_len = param.shape[0]
    param_mid = param_len // 2
    input_pad = _pad_1d(inp, param_mid)

    if output_grad is None:
        # 출력값의 기울기를 입력하지 않으면 1로 초기화.
        # 왜냐하면 출력값의 합의 기울기이기 때문에 기울기를 유지하는 1을 쓰면됨.
        output_grad = np.ones_like(inp)

    else:
        assert_same_shape(inp, output_grad)

    # 모든 기울기의 초기값을 0으로 줍니다.
    param_grad = np.zeros_like(param)
    input_grad = np.zeros_like(inp)

    for o in range(inp.shape[0]): # 0~4
        for p in range(param.shape[0]): # 0~2
            # 필터값의 기울기는 실제 영향을 받는 입력값의 합으로 됨
            param_grad[p] += input_pad[o+p] * output_grad[o]
    
    assert_same_shape(param_grad, param)

    return param_grad

_param_grad_1d(input_1d, param_1d)
array([10, 15, 14])

1차원 합성 곱의 역방향 함수중 먼저 필터(파라미터) 기울기를 구하는 함수 입니다.

조금 어려운데 결과값을 간단히 해석하면 파라미터가 1 증가했을때 출력값의 합이 각각 10, 15, 14 증가한다는 것 입니다.

def _input_grad_1d(inp, param, output_grad = None):
    # 입력값 패딩 추가
    param_len = param.shape[0]
    param_mid = param_len // 2
    input_pad = _pad_1d(inp, param_mid)

    if output_grad is None:
        # 출력값의 기울기를 입력하지 않으면 1로 초기화.
        # 왜냐하면 출력값의 합의 기울기이기 때문에 기울기를 유지하는 1을 쓰면됨.
        output_grad = np.ones_like(inp)

    else:
        assert_same_shape(inp, output_grad)

    # 원할한 연산을 위해 범위 내 값은 1을, 범위를 벗어나는 것들은 0으로함.
    # 패딩도 같은 효과를 냄.
    output_pad = _pad_1d(output_grad, param_mid)

    # 모든 기울기의 초기값을 0으로 줍니다.
    param_grad = np.zeros_like(param)
    input_grad = np.zeros_like(inp)

    for o in range(inp.shape[0]): # 0~4
        for f in range(param.shape[0]): # 0~2
            # 입력값의 기울기는 실제 영향을 받는 필터값의 합으로 됨
            input_grad[o] += output_pad[o + param_len - f - 1] * param[f]
    
    assert_same_shape(param_grad, param)

    return input_grad

_input_grad_1d(input_1d, param_1d)
array([2, 3, 3, 3, 2])

입력값에 따른 출력값의 변동이 얼마나 되는지를 나타내는 함수 입니다.

첫번째와 마지막은 입력값이 1 증가할때 크기가 1 작은 2만큼 증가하고 나머지 값들은 3만큼 증가합니다.

패딩한 것에 영향받는 값을 제외하고 필터의 개수(3)만큼 영향력이 있다고 생각하면 됩니다.

배치 입력 적용하기

input_1d_batch = np.array([[0,1,2,3,4,5,6],
                           [1,2,3,4,5,6,7]])

def _pad_1d_batch(inp, num):
    outs = [_pad_1d(obs, num) for obs in inp]
    return np.stack(outs)

_pad_1d_batch(input_1d_batch, 1)
array([[0, 0, 1, 2, 3, 4, 5, 6, 0],
       [0, 1, 2, 3, 4, 5, 6, 7, 0]])

입력값이 2개 이상인 배치에도 적용하기 위해 기존 구현한 함수를 확장하겠습니다.

패딩의 경우 기존함수를 반복문을 이용해서 여러번 호출하면 됩니다.

def conv_1d_batch(inp, param):
    outs = [conv_1d(obs, param) for obs in inp]
    return np.stack(outs)

conv_1d_batch(input_1d_batch, param_1d)
array([[ 1.,  3.,  6.,  9., 12., 15., 11.],
       [ 3.,  6.,  9., 12., 15., 18., 13.]])

순방향 계산에 경우에도 같은 방식으로 확장했습니다.

def input_grad_1d_batch(inp, param):
    out = conv_1d_batch(inp, param)
    out_grad = np.ones_like(out) # 출력기울기 값의 형태가 배치이므로 이에 맞게 조정
    batch_size = out_grad.shape[0] # 배치 크기가 나옴
    grads = [_input_grad_1d(inp[i], param, out_grad[i]) for i in range(batch_size)]
    return np.stack(grads)

input_grad_1d_batch(input_1d_batch, param_1d)
array([[2, 3, 3, 3, 3, 3, 2],
       [2, 3, 3, 3, 3, 3, 2]])

입력값에 따른 출력값이 얼마나 되는지 구하는 함수를 배치로 확장했습니다.

기울기는 입력값에 영향이 있지 않기 때문에 어느 입력값이 입력되던 그대로 출력됩니다.

def param_grad_1d_batch(inp, param):
    output_grad = np.ones_like(inp)
    # 단순 합의 기울기이기 때문에 모든 값을 1로 둡니다.

    inp_pad = _pad_1d_batch(inp, 1)
    out_pad = _pad_1d_batch(inp, 1)
    
    param_grad = np.zeros_like(param)
    
    for i in range(inp.shape[0]): # 배치 크기만큼
        for o in range(inp.shape[1]): # 인풋 길이만큼
            for p in range(param.shape[0]): # 필터 길이만큼
                # 전부 합해줍니다.
                param_grad[p] += inp_pad[i][o+p] * output_grad[i][o]

    return param_grad

param_grad_1d_batch(input_1d_batch, param_1d)
array([36, 49, 48])

필터값에 따른 출력값이 얼마나 변하는지를 구하는 함수를 배치로 확장했습니다.

이때 필터에 대한 기울기는 배치 단위인데 필터는 모든 관찰과 합성곱 연선이 이뤄지므로 모든 값을 다 더해야합니다.

즉 모든 요소의 합이 필터 값이 바뀜에 따라서 얼마나 바뀌는지를 구하는 것 입니다.

2차원 합성곱

imgs_2d_batch = np.random.randn(3, 28, 28)
param_2d = np.random.randn(3,3)

def _pad_2d_obs(inp, num):
    # 가로 단위로 앞 뒷 값 각각 패딩
    inp_pad = _pad_1d_batch(inp, num)
    # 가로로 윗 2줄, 아래 2줄 패딩
    other = np.zeros((num, inp.shape[0] + num * 2))

    return np.concatenate([other, inp_pad, other])

def _pad_2d(inp, num):
    # 첫번째 차원은 배치 크기에 해당함.
    outs = [_pad_2d_obs(obs, num) for obs in inp]
    return np.stack(outs)

_pad_2d(imgs_2d_batch, 1).shape
(3, 30, 30)

2차원 단위에 입력값을 가지고 패딩을 진행했습니다.

_pad_2d_obs 함수는 패딩을 실질적으로 진행하는 함수이고, _pad_2d 함수는 배치 단위로 확장하는 함수 입니다.

def _compute_output_obs_2d(obs, param):
    param_mid = param.shape[0] // 2
    
    obs_pad = _pad_2d_obs(obs, param_mid)

    out = np.zeros_like(obs)

    # 2차원 필터를 거처 출력값을 만듭니다.
    for o_w in range(out.shape[0]): # 출력값 가로길이
        for o_h in range(out.shape[1]): # 출력값 세로길이
            for p_w in range(param.shape[0]): # 필터 가로길이
                for p_h in range(param.shape[1]): # 필터 세로길이
                    out[o_w][o_h] += param[p_w][p_h] * obs_pad[o_w+p_w][o_h+p_h]
    
    return out

def _compute_output_2d(img_batch, param):
    assert_dim(img_batch, 3)

    outs = [_compute_output_obs_2d(obs, param) for obs in img_batch]

    return np.stack(outs)

_compute_output_2d(imgs_2d_batch, param_2d).shape
(3, 28, 28)

2차원 단위에 순방향 계산입니다. 패딩을 먼저 시킨 뒤 2차원 필터를 통과시켜 모든 값을 합친 값을 출력해줍니다.

def _compute_grads_obs_2d(input_obs, output_grad_obs, param):
    # 입력을 나타내는 2차원값, 출력 기울기를 나타내는 2차원값(여기선 모두 1을 사용), 2차원 필터

    param_size = param.shape[0] # 2차원 필터의 가로 세로가 같다고 가정합니다.

    # 출력 기울기에 패딩을 먼저 덧붙입니다. 원본값은 1로, 나머지 값은 0으로하여 원본값만 유지하게 합니다.
    output_obs_pad = _pad_2d_obs(output_grad_obs, param_size // 2)

    input_grad = np.zeros_like(input_obs) # 초기 기울기는 0으로 합니다.

    for i_w in range(input_obs.shape[0]): # 입력값 가로길이
        for i_h in range(input_obs.shape[1]): # 입력값 세로길이
            for p_w in range(param_size): # 필터 가로길이
                for p_h in range(param_size): # 필터 세로길이
                    input_grad[i_w][i_h] += output_obs_pad[i_w + param_size - p_w -1][i_h + param_size - p_h -1] * param[p_w][p_h]

    return input_grad

def _compute_grads_2d(inp, output_grad, param):
    
    grads = [_compute_grads_obs_2d(inp[i], output_grad[i], param) for i in range(output_grad.shape[0])]

    return np.stack(grads)

img_grads = _compute_grads_2d(imgs_2d_batch, np.ones_like(imgs_2d_batch), param_2d)
img_grads.shape
(3, 28, 28)

역방향 계산을 2차원으로 구현했습니다. 그 중 입력 기울기를 계산하는 절차인데요.

출력 기울기에 패딩을 덧붙이고 해당하는 가중치와의 합 연산을 하면 입력 기울기를 계산할 수 있습니다.

def _param_grad_2d(inp, output_grad, param):
    # 입력을 나타내는 3차원값, 출력 기울기를 나타내는 3차원값(여기선 모두 1을 사용), 2차원 필터

    param_size = param.shape[0] # 2차원 필터의 가로 세로가 같다고 가정합니다.
    inp_pad = _pad_2d(inp, param_size // 2) # 입력 값을 패딩합니다.

    param_grad = np.zeros_like(param) # 초기 가중치 기울기를 0으로 합니다.
    img_shape = output_grad.shape[1:] # 첫 값은 배치크기이므로 빼고 실행하기 위해.

    for i in range(inp.shape[0]): # 배치 크기
        for o_w in range(img_shape[0]): # 입력값 가로길이
            for o_h in range(img_shape[1]): # 입력값 세로길이
                for p_w in range(param_size): # 필터 가로길이
                    for p_h in range(param_size): # 필터 세로길이
                        param_grad[p_w][p_h] += inp_pad[i][o_w+p_w][o_h+p_h] * output_grad[i][o_w][o_h]

    return param_grad

param_grad = _param_grad_2d(imgs_2d_batch, np.ones_like(imgs_2d_batch), param_2d)
param_grad
array([[108.03493534, 116.92134058, 112.40044695],
       [112.79682302, 122.16317892, 122.1838074 ],
       [106.24163816, 116.07072373, 116.86259153]])

역방향 계산 중 필터 기울기를 구하는 부분을 2차원으로 구현했습니다.

여기서는 입력값에 패딩을 덧붙이고 합 연산을 했는데, 1차원하고 크게 다를게 없습니다.

배치 연산까지 한번에 진행하는 함수를 구현했는데 모든 배치의 입력값을 순회합니다.

채널 추가하기

def _compute_output_obs(obs, param):
    assert_dim(obs, 3)
    assert_dim(param, 4)
    
    param_size = param.shape[2]
    param_mid = param_size // 2
    obs_pad = _pad_2d_channel(obs, param_mid)
    
    in_channels = param.shape[0]
    out_channels = param.shape[1]
    img_size = obs.shape[1]
    
    out = np.zeros((out_channels,) + obs.shape[1:])
    for c_in in range(in_channels):
        for c_out in range(out_channels):
            for o_w in range(img_size):
                for o_h in range(img_size):
                    for p_w in range(param_size):
                        for p_h in range(param_size):
                            out[c_out][o_w][o_h] += \
                            param[c_in][c_out][p_w][p_h] * obs_pad[c_in][o_w+p_w][o_h+p_h]
    return out
 

def _output(inp, param):
    outs = [_compute_output_obs(obs, param) for obs in inp]    

    return np.stack(outs)

합성곱층은 2차원으로 서로 엮인 뉴런 외에도 특징 맵과 같은 수의 채널을 갖습니다.

이런식으로 입력된 데이터를 다루기 위해 채널이 있는 순방향 연산을 구현했습니다. 역방향 연산은 생략합니다.

기타 가벼운 이론

이미지 데이터에서는 서로 가까운 픽셀 간에 유의미한 의미가 있는 조합이 나올 가능성이 높습니다.

즉 픽셀간 얼마나 공간적으로 가까운지를 나타내기 위해 합성곱 연산을 수행합니다.

풀링이란 각 특징 맵을 다운샘플링하여 데이터의 크기를 줄이는 방법입니다. 예시로 각 영역 픽셀 값의 최대값을 사용할 수 있습니다.

계산양 감소라는 이점이 있으나, 정보 손실또한 크기 때문에 이 방법을 쓰는데 다양한 의견이 있습니다.

스트라이드란 필터가 움직이는 간격이 지금까지는 1이였는데 이 값을 의미하는 용어로 커질수록 다운샘플링 효과가 커집니다.

최근 제안되는 고급 합성곱 신경망 구조에서는 풀링 대신 스트라이드를 2이상으로 설정해 다운샘플링 효과를 얻습니다.