본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(3)

[밑시딥] 나만의 딥러닝 프레임워크 만들기, 신경망 만들기(1)

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 3권을 개인적으로 공부하면서 배운 내용을 기록하고 해당 책을 공부하시는 다른 분들에게 조금이나마 도움이 되고자 하는 목적 하에 작성된 포스팅입니다. 포스팅 내용의 모든 근거는 책의 내용에 기반하였음을 알립니다.

출처: Yes24


이번 포스팅에서는 드디어 Dezero 프레임워크를 활용해서 신경망을 만들 수 있도록 기능을 추가할 예정이다. 신경망을 만들기 위해서는 추가적으로 다양한 함수의 기능들이 추가되어야 하는데 이번에는 이러한 함수들을 추가하는 포스팅이 주요 내용이 될 예정이다. 또한 이전까지는 우리가 만들어온 Dezero는 주로 입력이 스칼라인 형태를 다루어왔지만, 현실에서 가장 빈번하게 다루어지는 텐서의 입력 형태도 처리될 수 있도록 확장할 예정이다.

1. 우리가 만들어온 Dezero는 텐서도 잘 다룰까?

제목 그대로 우리가 지난 포스팅까지 해서 만들어온 Dezero 프레임워크가 스칼라가 아닌 다차원 배열인 텐서에도 잘 동작을 할까? 결론은 '그렇다' 이다. 순전파 뿐만 아니라 역전파도 우린 텐서를 처리할 수 있다. 따라서 그동안 추가해온 여러가지 함수들에 대해서 개선할 로직은 없다. 단지, 텐서의 행렬 원소를 모두 더한(sum)다거나 분기하는 등과 같은 형상 변환 함수에 대한 기능을 추가해주면 된다. 이 형상 변환 함수에 대해서는 해당 책 시리즈 2권을 공부하면서 배웠던 적이 있다. 추후에 또 배울 예정이지만, 이미 알고 계신 분들 중 회고하고 싶으시거나 맛보기를 원하시는 분들은 여기를 참조하도록 하자.

그렇다면 우리 Dezero가 역전파 시에도 텐서를 잘 다룰 수 있는 이유는 뭘까? 바로 지금까지 구현한 여러 함수들의 순전파 시, 입력이 텐서더라도 텐서의 원소마다 '스칼라'로 간주해 원소별 계산을 수행하기 때문이다. 이는 역전파 시에도 동일하게 원소별로 계산되기 때문에 현재 로직상으로도 텐서의 입력을 받으면 역전파가 가능하다.

 

import numpy as np
from dezero import Variable

x = Variable(np.array([[1,2,3],
                       [4,5,6]]))
c = Variable(np.array([[10,20,30],
                       [40,50,60]]))
y = x * c / 10
y.backward(use_heap=True, retain_grad=True)
print(x.grad)
print(c.grad)


이번에는 텐서가 들어왔을 때 역전파를 수식으로 살펴보는 내용도 이해해보도록 하자.(개인적으로 수식엔 약한 편이라서 수식에 대한 강건함(?)을 높이기 위해 일부러라도 포스팅하려는 점 이해해주세요🥲)

먼저 $Y = F(X)$ 라는 함수가 있다고 가정하자. 이 때 $Y$는 $y_1, y_2, ..., y_n$의 모음인 벡터 $Y$를 의미하고, $X$ 또한 마찬가지로 $x_1, x_2, ..., x_n$의 모음인 벡터를 의미한다. 이 때, $Y = F(X)$의 미분 즉, $Y$의 $X$에 대한 미분은 다음과 같은 식으로 정의할 수 있다.
$$\begin{pmatrix} { {\partial y_1} \over {\partial x_1} } & { {\partial y_1} \over {\partial x_2} } & \cdots & { {\partial y_1} \over {\partial x_n} } \\ { {\partial y_2} \over {\partial x_1} } & { {\partial y_2} \over {\partial x_2} } & \cdots & { {\partial y_2} \over {\partial x_n} } \\ \vdots & \vdots & \ddots & \vdots \\ { {\partial y_n} \over {\partial x_1} } & { {\partial y_n} \over {\partial x_2} } & \cdots & { {\partial y_n} \over {\partial x_n} } \end{pmatrix}$$
위 미분식은 $n \times n$ 형상의 행렬 형태가 된다. 이 행렬을 야코비(Jacobian) 행렬이라고 한다. 그런데 이 때, 만약에 $Y$가 벡터가 아닌 스칼라 $y$라고 한다면, $y$의 $X$에 대한 미분은 아래처럼 $1 \times n$ 행 벡터 형태의 야코비 행렬로 바뀐다.
$${{\partial y} \over {\partial X}} = \begin{pmatrix} { {\partial y} \over {\partial x_1} } & { {\partial y} \over {\partial x_2} } & \cdots & { {\partial y} \over {\partial x_n} } \end{pmatrix}$$
다음과 같은 합성함수가 있을 때, Chain Rule을 통해서 미분하는 방법을 알아보자. 이에 대해서는 그동안 여러번 다루었다. $Y = F(X), a = A(X), b = B(a), y = C(b)$가 있다. 이 때, $Y, X, a, b$는 벡터를 의미하며 $y$는 스칼라이자 $Y$ 라는 벡터의 원소 중 하나이다. 이 때, $y$의 $X$에 대한 미분을 Chain Rule을 사용해서 풀어보면 아래처럼 된다.
$${\partial y \over \partial X} = {\partial y \over \partial b} {\partial b \over \partial a} {\partial a \over \partial X}$$
그러면 위 Chain Rule을 두 가지 방법으로 계산해보자. 첫 번째 방법은 입력에서 출력 순인 Forward 방식(위 예시에서는 $X$ ➜ $y$ 순서로 계산한다는 의미), 두 번째 방법은 출력에서 입력순인 Reverse 방식이다. 이 두 가지 방법에 대해서는 예전 포스팅에서 배웠던 적이 있으니 각 방법이 의미하는 바는 해당 포스팅을 참조하도록 하자.

두 가지 방식의 계산식 전개를 아래에서 비교해보자.

 

Forward VS Reverse 모드 계산 차이


우리가 주목해야 할 부분은 각 방법이 계산을 수행할 때의 행렬 형상이다. 먼저 Forward 방식을 보면 처음에 $n \times n$ 크기인 2개의 야코비 행렬(노란색과 초록색)끼리를 연산한 후 $1 \times n$ 크기의 행 벡터형태의 야코비 행렬과 연산을 취한다. 반면 Reverse 방식은 $n \times n$ 크기인 2개의 야코비 행렬끼리의 연산을 한번도 수행하지 않고 모두 $1 \times n$ 크기의 행 벡터형태의 야코비 행렬과 $n \times n$ 크기의 야코비 행렬 연산만 수행해주면 된다. 따라서 Reverse 모드가 보다 연산의 계산 효율이 더 좋은 것을 알 수 있다.

여기서 더 나아가 중요한 포인트가 있다. 바로 명시적으로 야코비 행렬을 구하여 '행렬의 곱'을 계산할 필요가 없다는 것! 즉, 결과만 필요한 상황이라면 역전파 수행에 아무런 문제가 없다는 것이다. 단, 어떤 함수에서의 야코비 행렬이 대각행렬(대각 원소를 제외한 모든 값들은 0인 행렬)인 경우에만 해당한다. 위 예시에서 $a = A(X)$가 $a = sin(x)$라고 가정했을 경우, 해당 함수의 야코비 행렬인 ${\partial a \over \partial X}$를 구하면 아래와 같은 식이 된다.
$$\begin{pmatrix} { {\partial a_1} \over {\partial x_1} } & 0 & \cdots & 0 \\ 0 & { {\partial a_2} \over {\partial x_2} } & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & { {\partial a_n} \over {\partial x_n} } \end{pmatrix}$$
그렇다면 위 야코비 행렬을 Reverse 모드의 ${\partial y \over \partial a}$(파란색 네모칸) 과 ${\partial a \over \partial X}$(노란색 네모칸)를 곱하는 연산에서 노란색 네모칸에 해당하는 야코비 행렬에 대입시키면 아래처럼 될 것이다.

 

대각행렬일 경우 야코비 행렬을 만들 필요가 없다!


위처럼 만약 야코비 행렬이 대각행렬이라면 $1 \times n$ 크기의 행 벡터(파란색 네모칸)와 $n \times n$ 크기의 야코비 행렬(노란색 네모칸)의 연산을 굳이 수행하지 않고 행 벡터의 첫 번째 원소와 야코비 행렬의 첫 번째 대각 원소를 곱한 값, 행 벡터의 두 번째 원소와 야코비 행렬의 두 번째 대각 원소를 곱한 값, ... 이런 식으로 동일한 번째에 있는 원소들 끼리 곱하게 되면 '결과'는 동일하다는 것이다. 다시 말해, 최종 결과를 원소별 미분을 계산한 다음, 그 결과값을 원소별로 곱하면 얻을 수 있게 된다.

2. 형상 변환 함수 만들기

이제 그러면 본격적으로 텐서의 형상을 변환하는 2가지 함수를 Dezero에 추가해보려고 한다. 첫 번째는 형상(shape)을 변환하는 reshape 기능, 행렬을 전치(더 나아가 텐서의 축을 변경하는)하는 transpose 기능을 추가해보자.

먼저, reshape 기능을 추가해볼텐데, 그 전에 넘파이에서의 reshape 함수를 한 번이라도 사용해본 적이 있을 것이다. 간단히 아래처럼 사용이 가능하다.

 

import numpy as np

arr = np.array([[1,2,3],
                [4,5,6]])
re_arr = arr.reshape(6,)
print(arr.shape)
print(re_arr.shape)
print('re_arr:\n', re_arr)


이제 위와 같은 기능을 하는 함수를 Dezero에 맞게 구현해주어야 한다. Dezero에서는 순전파 시에 넘파이 객체를 사용하기 때문에 넘파이의 reshape 함수를 갖다 사용해도 무방하지만, 역전파 시에는 말이 달라진다. 1번 목차에서 알아본 것처럼 원소별 연산을 할 때에는 상관이 없었지만 지금 우리가 구현하려고 하는 형상 변환이나 행렬 전치시키는 함수는 '원소별 연산'을 하지 않는 것들이다. 즉, 어떠한 계산도 하지는 않지만 단순히 원소의 위치를 바꾸어준다. 따라서 이럴 때에는 텐서의 형상을 고려해야 하기 때문에 어떤 특정 변수 $x$라는 텐서의 형상과 $x$의 기울기 텐서의 형상을 항상 일치시켜주어야 한다는 것을 기억해야 한다. 참고로 방금 말한 것처럼 형상 변환은 원소에 '어떠한 계산'도 취해주지 않기 때문에 이전 단계에서 흘러들어오고 있는 국소적인 미분값을 단순히 전달만 해주면 된다.

구현할 reshape 기능에 맞는 순전파, 역전파 시 텐서의 형상을 아래처럼 도식화해볼 수 있다.

 

각 변수와 기울기의 형상은 일치해야 한다


위 상황에 맞게 Dezero 만의 reshape 함수를 구현해보자. 먼저 functions.py 파일에 Reshape이라는 클래스를 만들고, 이 클래스를 return 하는 reshape 라는 함수를 아래처럼 만들자.

 

class Reshape(Function):
    def __init__(self, shape):
        self.shape = shape
        
    def forward(self, x):
        self.x_shape = x.shape
        y = x.reshape(self.shape)
        return y
    
    def backward(self, gy):
        return reshape(gy, self.x_shape)

def reshape(x, shape):
    if x.shape == shape:
        return as_variable(x)
    return Reshape(shape)(x)


그리고 하나 더 추가해주어야 할 부분이 있다. 위에서 구현한 기능을 Variable 클래스에도 넣어줄 수 있다. Variable 클래스에 왜 넣어주냐 하는지를 이해하기 위해서 넘파이로 예시를 다시 들어보자. 넘파이의 reshape 기능은 크게 2가지 방법으로 호출할 수 있다. 첫 번째로 넘파이의 함수를 직접적으로 호출하는 방법, 두 번째는 넘파이 객체에 설정되어 있는 메소드(함수)를 호출하는 방법이다. 넘파이 코드로 살펴보면 아래와 같다.

 

import numpy as np

arr = np.array([[1,2,3],
                [4,5,6]])

# 1. 넘파이의 메소드를 직접적으로 호출
temp1 = np.reshape(arr, (6,))
# 2.넘파이 객체에 설정되어 있는 메소드를 호출
temp2 = arr.reshape((6,))

print('temp1:', temp1.shape)
print('temp2:', temp2.shape)


우리는 위 예시에서 2번째 방법도 Dezero에서 가능하도록 만들기 위해 Dezero 변수를 의미하는 Variable 클래스에다가도 추가해준다는 것이다.

 

class Variable:
    ...(생략)...
    
    def reshape(self, shape):
    if len(shape) == 1 and isinstance(shape[0], (tuple, list)):
        shape = shape[0]
    return dezero.functions.reshape(self, shape)
    
    ...(생략)...


다음은 행렬을 전치하는 기능인 transpose에 대해서 구현해보자. 전치는 알다시피 행렬의 행과 열을 서로 바꾸는 것을 의미한다. 물론 2차원 행렬에선 그렇지만, 다차원 행렬인 텐서로 넘어가면 축 여러개를 각각 다른 위치로 지정해서 전치시켜줄 수도 있다. 이것도 넘파이로 먼저 사용법을 살펴보자.

 

import numpy as np

arr1 = np.random.rand(3, 2)        # shape(3,2)
arr2 = np.random.rand(3, 1, 2, 4)  # shape(3,1,2,4)

temp1 = np.transpose(arr1)
temp2 = np.transpose(arr2, (3, 2, 1, 0))  # 의미: arr2의 3번째 축을 0번째 축 자리로, 2번째 축을 1번째 축 자리로 ...

print(arr1.shape, temp1.shape)
print(temp1.shape, temp2.shape)


위 예시에서 주의할 점은 다차원 행렬인 텐서의 축을 바꿀 때, transpose 함수의 인자로 들어가는 것은 축의 인덱싱 숫자이다. 즉, arr2의 shape가 (3,1,2,4) 라는 것은 축이 4개이며, 0번째 축에는 3개의 원소가, 1번째 축에는 1개의 원소가, 2번째 축에는 2개의 원소가, 3번째(마지막)축 에는 4개의 원소가 있다는 것을 의미한다. 반면에, transpose 인자에 들어가는 (3,2,1,0) 의 의미는 3번째 축을 0번째 축 자리로, 2번째 축을 1번째 축 자리로, 1번째 축을 2번째 축 자리로, 0번째 축을 3번째 축 자리로 옮기는 것을 의미한다.

책에서는 단순히 2차원 행렬을 전치하는 transpose 함수를 소개하고, 원본 소스코드에서 다차원 행렬 축도 처리하는 transpose 함수를 구현해놓았다. 필자는 다차원 행렬 축도 처리할 수 있도록 하는 transpose 함수를 적어놓도록 하겠다.

 

class Transpose(Function):
    def __init__(self, axes=None):
        self.axes = axes

    def forward(self, x):
        y = x.transpose(self.axes)
        return y

    def backward(self, gy):
        if self.axes is None:
            return transpose(gy, axes=None)

        axes_len = len(self.axes)
        inv_axes = tuple(np.argsort([ax % axes_len for ax in self.axes]))
        return transpose(gy, inv_axes)

def transpose(x, axes=None):
    return Transpose(axes)(x)


마찬가지로 이것 또한 Dezero 변수 객체에서도 사용가능하도록 하기 위해 Variable 클래스에다가도 구현시켜놓도록 하자.

 

class Variable:
    ...(생략)...
    
    def transpose(self, *axes):
    if len(axes) == 0:
        axes = None
    elif len(axes) == 1:
        if isinstance(axes[0], (tuple, list)) or axes[0] is None:
            axes = axes[0]
    return dezero.functions.transpose(self, axes)
    
    ...(생략)...

3. Broadcast 함수 만들기

이번에는 Dezero에서도 브로드캐스트 기능이 지원되도록 만들어보려고 한다. 브로드캐스트란, 형상이 다른 행렬끼리 연산을 취할 때, 작은 형상에 대해서 큰 형상을 기준으로 동일한 원소를 복제해서 확장해주는 기능을 의미한다. 아래처럼 말이다.

 

브로드캐스팅이란, 원소를 확장해주는 것을 의미


넘파이는 기본적으로 브로드캐스팅이 지원이 된다. 우리는 이제 Dezero에서도 이처럼 브로드캐스팅이 지원이 되도록 구현해볼 것 이다. 왜냐하면 현재 Dezero에서는 순전파 시에 브로드캐스팅이 발생하면 역전파가 제대로 이루어지지 않도록 설계되어 있기 때문이다. 따라서 역전파에서도 브로드캐스팅에 맞춰 잘 진행되도록 개선시켜보자. 먼저 넘파이의 broadcast_to 함수와 sum_to 함수에 대해 알아보고 이를 기반으로 Dezero에서도 비슷하게 구현해보도록 하자.

 

import numpy as np

arr = np.array([1,2,3])
temp = np.broadcast_to(arr, (3, 3))
print('arr:', arr)     # shape(3,)
print('temp:\n', temp) # shape(3,3)


위처럼 원본 행렬의 형상이 (3,) 이었던 것을 broadcast_to 함수로 (3,3)으로 브로드캐스팅 시켰다. 이 때, 원소는 원본 행렬의 형상을 그대로 복제시켰다. 이럴 경우, 순전파와 역전파가 이루어지는 과정에서 형상이 어떤 식으로 이루어지는지 도식화해보자.

 

broadcast_to 연산의 순전파, 역전파 과정


위 그림에서 sum_to 라는 함수가 갑자기 튀어나왔는데, 이 함수는 broadcast_to 함수의 역전파 기능을 담당하는 함수를 의미한다. 우선 broadcast_to 함수의 순전파부터 살펴보면 원소가 그대로 복제되어 (3,) ➜ (2,3) 으로 형상 변환이 이루어졌다. 그리고 브로드캐스팅된 변수(y)의 기울기 형상은 동일하게 (2,3)이면서 원소값은 모두 1이다.(원소값이 모두 1인 이유는 따로 언급하지 않아도 알 것이라고 예상한다. 최종 결과값의 Loss 값은 자기 자신과의 비교이기 때문에 1이다.) 그리고 이에 대해서 sum_to 즉, broadcast_to의 역전파를 수행하면 (2, 3) ➜ (3,) 형상 변환이 이루어지면서 각 원소값이 합(sum)으로 계산된다.

그리고 당연히 이 두 함수의 관계는 반대로도 적용된다. 다시 말해, sum_to가 순전파일 경우, sum_to의 역전파는 broadcast_to 함수가 기능을 담당한다.

 

sum_to의 순전파, 역전파 과정


참고로 sum_to 라는 함수는 해당 책의 저자가 딥러닝 프레임워크 중 하나인 체이너 코드를 참조해 구현해놓은 함수이다.

 

def sum_to(x, shape):
    """Sum elements along axes to output an array of a given shape.
    Args:
        x (ndarray): Input array.
        shape:
    Returns:
        ndarray: Output array of the shape.
    """
    ndim = len(shape)
    lead = x.ndim - ndim
    lead_axis = tuple(range(lead))

    axis = tuple([i + lead for i, sx in enumerate(shape) if sx == 1])
    y = x.sum(lead_axis + axis, keepdims=True)
    if lead > 0:
        y = y.squeeze(lead_axis)
    return y


그러면 이제 Dezero에 맞게 braodcast_to 와 sum_to 함수를 구현해보도록 하자.

 

class BroadcastTo(Function):
    def __init__(self, shape):
        self.shape = shape

    def forward(self, x):
        self.x_shape = x.shape
        y = np.broadcast_to(x, self.shape)
        return y

    def backward(self, gy):
        gx = utils.sum_to(gy, self.x_shape)
        return gx
        
 class SumTo(Function):
    def __init__(self, shape):
        self.shape = shape
        
    def forward(self, x):
        self.x_shape = x.shape
        y = utils.sum_to(x, self.shape)
        return y
    
    def backward(self, gy):
        gx = broadcast_to(gy, self.x_shape)
        return gx
    
def broadcast_to(x, shape):
    return BroadcastTo(shape)(x)
    
def sum_to(x, shape):
    if x.shape == shape:
        return as_variable(x)
    return SumTo(shape)(x)


그런데 위 2가지 함수를 추가해주면서 수정해야 할 부분이 있다. 바로 저번 포스팅에서 오버로딩시킨 연산자 클래스에다가 특정 로직을 추가해주어야 한다. 특정 로직이라 함은, 순전파 시에 두 텐서의 형상이 다를 때, 역전파 시 브로드캐스팅용 역전파를 수행해주도록 해주는 것이다. 적용할 클래스들은 Add, Sub, Mul, Div 총 4개의 클래스에 적용해주면 되며, 여기에서는 Mul 클래스에 적용할 예시만 살펴보자.

 

class Mul(Function):
    def forward(self, x0, x1):
        self.x0_shape, self.x1_shape = x0.shape, x1.shape  # 입력 형상을 기록
        return x0 * x1

    def backward(self, gy):
        x0, x1 = self.inputs
        gx0 = x1 * gy
        gx1 = x0 * gy
        # 순전파 시 입력 형상이 다르다면 -> 브로드캐스팅용 역전파 수행
        if self.x0_shape != self.x1_shape: 
            gx0 = dezero.functions.sum_to(gx0, self.x0_shape)
            gx1 = dezero.functions.sum_to(gx1, self.x1_shape)
        return gx0, gx1

 

4. Sum 함수 만들기

다음은 행렬 원소의 합을 계산하는 Sum 함수를 추가해보자. Sum 함수는 어떻게 보면 이전에 배운 덧셈(Add) 연산을 이어붙인(?) 것이라고 할 수있다. Sum 함수의 순전파, 역전파 과정을 도식화해보면 아래처럼 된다.

 

Sum 함수의 순전파, 역전파 과정


Sum 함수의 역전파는 이전으로부터 흘러들어오는 국소적인 미분값을 그대로 복제하면 된다. 복제할 개수는 역전파 방향을 가리키고 있는 변수(위 예시에서는 x 변수) 형상 개수만큼 복제하면 된다. 그런데 이렇게 값을 복제하는 게 익숙하지 않은가? 바로 브로드캐스팅이다! 따라서 우리는 Sum 함수의 역전파를 구현할 때, 직전 목차에서 구현한 broadcast_to 함수를 사용하면 된다.

 

class Sum(Function):
    def forward(self, x):
        self.x_shape = x.shape
        y = x.sum()
        return y
    
    def backward(self, gy):
        gx = broadcast_to(gy, self.x_shape)
        return gx
    
def sum(x):
    return Sum()(x)


그런데 한 가지 추가로 작업해줄 것이 있다. 물론 형상이 1차원이라면 위와 같이 구현해도 무방하겠지만, 알다시피 대부분은 2차원 행렬 또는 다차원 행렬인 텐서를 취급한다. 차원이 늘어남에 따라 축(axis)과 Sum 연산을 취해준 후 차원 수를 유지할지 감소시킬지 결정하는(넘파이에서는 이를 keepdims 라는 인자로 넣어준다) 옵션도 추가적으로 있다. 따라서 해당 2가지 요소를 고려해서 Sum 클래스를 다시 정의해볼 필요가 있다.

 

다차원 행렬일 경우, axis에 따라 Sum 하는 방식이 달라진다

 

class Sum(Function):
    def __init__(self, axis, keepdims):
        self.axis = axis
        self.keepdims = keepdims

    def forward(self, x):
        self.x_shape = x.shape
        y = x.sum(axis=self.axis, keepdims=self.keepdims)
        return y

    def backward(self, gy):
        gy = utils.reshape_sum_backward(gy, self.x_shape, self.axis, self.keepdims)
        gx = broadcast_to(gy, self.x_shape)
        return gx


def sum(x, axis=None, keepdims=False):
    return Sum(axis, keepdims)(x)


참고로 reshape_sum_backward 라는 함수는 저자가 구현한 역전파 시 기울기인 gy 변수의 형상을 미세하게 조정하는 역할을 하는 함수이다. 구체적으로는 axis와 keepdims 옵션이 지원되면서 기울기의 형상이 변환되는 경우가 생기기 때문에 이에 대응하여 만들어낸 함수라고 한다. 해당 함수의 구체적인 로직에 대해서 궁금하다면 여기를 참고하자.

5. 행렬의 곱

4번 목차까지 해서 텐서의 형상을 변환하는 역할을 하는 함수와 Dezero로 넘파이처럼 브로드캐스팅을 하는 함수, 원소별 합을 계산하는 함수도 구현해보았다. 이번에는 행렬의 곱 연산을 수행하는 기능을 추가해보도록 하자. 행렬의 곱은 1차원 행렬 즉, 벡터의 내적을 2차원으로 확장한 셈이다. 행렬의 곱의 순전파, 역전파에 대해서는 해당 책 1권 시리즈 포스팅에서 Affine 계층으로 다루었던 적이 있다. 그 때는 행렬 곱의 역전파에 대해 배울 때, 수식적인 부분까지 보면서 배우진 않았는데, 이번 책에서는 수식적인 부분까지 다루는데 해당 내용을 소개해보려고 한다. 우선, 아래와 같은 계산 그래프가 있다고 해보자.

 

행렬 곱 계산 그래프 예시


여기에서 벡터 x의 $i$번째 원소에 대한 미분 ${{\partial L} \over {\partial x_i}}$은 다음과 같이 구할 수 있게 된다.
$${{\partial L} \over {\partial x_i}} = \sum_{j} { {{\partial L} \over {\partial y_j}} {{\partial y_j} \over {\partial x_i}} }$$
${{\partial L} \over {\partial x_i}}$는 $x_i$를 미세하게 변화시켰을 때, Loss값인 $L$이 얼마나 변화하느냐인 변화율을 의미한다. 이 때, $x_i$ 하나를 변화시킨다면 벡터 y의 모든 원소($y_1, y_2, ..., y_j$)가 변화하고, 벡터 y의 모든 원소가 변화함에 따라 최종적으로 스칼라 값인 $L$이 변화하게 된다.(이와 같은 과정을 모든 $x_i$에다가 동일하게 적용해야 한다.) 그런데 여기서 ${{\partial y_j} \over {\partial x_i}} = W_{ij}$이게 된다. 왜 이렇게 되는지는 아래 그림에서 살펴볼 수 있다.

 

${{\partial y_j} \over {\partial x_i}} = W_{ij}$ 되는 과정


따라서, $W_{ij}$를 대입해보면 식은 아래와 같이 전개가 된다.
$${{\partial L} \over {\partial x_i}} = \sum_{j} { {{\partial L} \over {\partial y_j}} {{\partial y_j} \over {\partial x_i}} } = \sum_{j} { {{\partial L} \over {\partial y_j}} W_{ij} }$$
그리고 현재 $W_{ij}$가 행벡터이기 때문에 내적을 가능하게 하기 위해 행과열을 바꾸어주는 전치행렬($T$)을 취해준다. 따라서 모든 $L$의 모든 $x$에 대한 계산 식은 아래와 같이 된다.
$$\sum_{i=1}^{D} {{\partial L} \over {\partial x_i}} = \sum_{j=1}^{H} {{\partial L} \over {\partial y_j}}{W^T}$$
이렇게 계산함으로써 행렬 곱의 역전파를 수행할 때 변수 x와 W의 역전파 행렬 형상은 아래와 같다.

 

행렬 곱의 역전파 수행 시 행렬 형상 상태


위 그림을 기반으로 행렬 곱을 수행하는 클래스 MatMul을 funcions.py에 아래처럼 구현하면 된다.

 

class MatMul(Function):
    def forward(self, x, W):
        y = x.dot(W)
        return y

    def backward(self, gy):
        x, W = self.inputs
        gx = matmul(gy, W.T)
        gW = matmul(x.T, gy)
        return gx, gW
        
def matmul(x, W):
    return MatMul()(x, W)

6. 간단한 선형회귀 모델 구현하기

지금까지 구현해낸 클래스들을 활용해서 간단한 머신러닝 모델을 만들어보자. 그 중에서 만들어볼 모델은 선형회귀 모델이다. 선형회귀는 회귀모델 중에서 예측값이 선형(직전)을 이루는 것을 말한다. 선형회귀 모델의 이론은 여기를 참조하도록 하고 바로 코드로 구현해보는 내용으로 들어가보자. 이번에 구현할 때 사용할 손실함수는 MSE(Mean Squared Error, 평균 제곱 오차)를 사용하려고 한다. 참고로 손실함수란, 모델의 예측 성능이 얼마나 '나쁜가'를 평가하는 함수를 의미한다.

먼저 손실함수인 MSE 클래스를 만들어보자. 손실함수까지 클래스로 만드는 이유는 책에서 자세히 소개하긴 하지만, 결론부터 말하면 Variable 인스턴스가 손실함수로 들어가 계산이 될 때(순전파가 될 때) 계산 그래프가 생성이 되는데, 이 때 계산 그래프 중간에 기록되는 변수들이 다른 데서 사용되지 않고 계속 메모리에 머물게 된다. 따라서 메모리를 효율적으로 아끼기 위해 손실함수를 아래처럼 클래스 형태로 만들어 사용하도록 하자.

 

class MeanSquaredError(Function):
    def forward(self, x0, x1):
        diff = x0 - x1
        y = (diff ** 2).sum() / len(diff)
        return y

    def backward(self, gy):
        x0, x1 = self.inputs
        diff = x0 - x1
        gx0 = 2 * diff * (gy / len(diff))
        gx1 = -gx0
        return gx0, gx1
        
def mean_squared_error(x0, x1):
    return MeanSquaredError()(x0, x1)


그리고 이제 선형회귀 모델을 만들어 작동시켜보자.

 

import numpy as np
from dezero import Variable
from dezero import functions as F

# example dataset for Linear Regression
np.random.seed(0)
x = np.random.rand(100, 1)
y = 5 + 2 * x * np.random.rand(100, 1)
W = Variable(np.zeros((1, 1)))
b = Variable(np.zeros(1))


def predict(x):
    y = F.matmul(x, W) + b
    return y


lr = 0.1
iters = int(1e4)

for i in range(iters):
    # predict and get Loss
    y_pred = predict(x)
    loss = F.mean_squared_error(y, y_pred)

    # initialize gradients in parameters
    W.clear_grad()
    b.clear_grad()

    # backpropagation
    loss.backward()

    # update parameters
    W.data -= lr * W.grad.data
    b.data -= lr * b.grad.data

    if i % 1000 == 0:
        print(f"# Epoch:{i+1} -> Loss: {loss}")
        print('W:', W, 'b:', b)
        print()


출력 결과를 보면, 첫 번째 Epoch 만 돌고 난 후에만 손실함수 값이 크게 감소하고 그 이후로는 변화가 미미한 것을 볼 수 있다. 이유는 당연히 현재 사용하는 선형회귀 모델이 매우 간단한 모델이기 때문이다. 그러면 이제 선형회귀 보다는 복잡한 신경망 모델을 한 번 만들어보도록 하자.

7. 간단한 신경망 모델 만들기

신경망 모델을 만들기 전에 한 가지 해야할 작업이 있다. 바로 직전 6번 목차에서 배운 행렬 곱(MatMul) 하는 연산을 Linear 라는 클래스로 만들 것이다. 클래스로 만드는 이유는 크게 2가지 이다. 첫 번째는 신경망 모델은 행렬 곱의 연속을 계속 반복하는 것이라고 할 수 있기 때문에(중간에 활성함수로 비선형 함수가 들어가긴 하지만) 행렬 곱 연산을 계속 반복적으로 사용해야 하기 때문이다. 두 번째는 메모리를 효율적으로 쓰기 위함이다. 신경망에서 메모리의 대부분을 차지하는 것은 신경망의 중간 층의 계산 결과들이다. 따라서 신경망의 역전파에 사용되는 중간 층 계산 결과들이라면 필수적으로 남겨놓아야 하겠지만 어디에도 사용되지 않는 중간 층 계산 결과들이라면 과감히 삭제해주는 것이 메모리 측면에서 효율적이다.

크게 2가지 방법이 있는데, 첫 번째는 클래스화를 시키는 방법, 두 번째는 클래스화시키는 것은 아니지만 이전에 파이썬의 메모리를 관리하는 방법에서 배운 내용을 활용하는 일종의 묘수(?) 방법이 있다. 클래스화시키는 코드부터 살펴보자.

 

class Linear(Function):
    def forward(self, x, W, b):
        y = x.dot(W)
        if b is not None:
            y += b
        return y

    def backward(self, gy):
        x, W, b = self.inputs
        gb = None if b is None else sum_to(gy, b.shape)
        gx = matmul(gy, W.T)
        gW = matmul(x.T, gy)
        return gx, gW, gb
        
 def linear(x, W, b=None):
    return Linear()(x, W, b)


다음은 두 번째 방법인데, 구체적인 방법을 소개하기 전에 위처럼 클래스를 사용하지 않았을 때 선형 변환을 함수로 구현하면 아래처럼 계산 그래프가 만들어질 것이다.

 

클래스를 사용하지 않고 선형 변환을 개별 함수로 구현할 때 만들어지는 계산 그래프


6번 목차에서 MSE 함수를 클래스화시켜야 하는 이유를 설명했다시피 선형 변환에서도 위 계산 그래프로 보면 중간 변수인 t가 역전파 시에 어디에서도 사용되지 않는 것을 알 수 있다. +연산은 단순히 이전으로부터 흘러들어오는 국소적인 미분값을 그냥 흘려보내고 행렬 곱(matmul) 연산의 역전파는(6번 목차 내용을 다시 올라가보면 알겠지만) 전혀 t(행렬 곱의 결과값)라는 값이 사용되지 않는 것을 알 수 있다. 따라서, 중간에 t라는 값을 메모리에서 없애주면 메모리를 절약할 수 있는데, 메모리에서 없애는 방법이 바로 t라는 참조 카운트를 0으로 만들어버리면 되는 것이다. 아래처럼 말이다.

 

def linear_simple(x, W, b=None):
    t = matmul(x, W)
    if b is None:
        return t
    y = t + b
    t.data = None  # t 변수의 참조카운트를 0으로 만들어서 메모리에서 삭제
    return y


이렇게 선형 변환(Linear) 기능을 하는 연산을 만드는 2가지 방법에 대해 알아보았다. 마지막으로 신경망을 만들기 위해 대표적인 비선형 활성함수인 시그모이드 함수를 클래스화시켜서 만들어보고 시그모이드 함수가 추가된 간단한 신경망 함수를 만들고 학습시켜보도록 하자. 참고로 아래 시그모이드 활성화 함수 순전파, 역전파에 대한 수식 과정은 해당 책 1권 포스팅 2번 목차에서 배운적이 있었으니 여기서는 생략하도록 하겠다.

 

class Exp(Function):
    def forward(self, x):
        y = np.exp(x)
        return y

    def backward(self, gy):
        y = self.outputs[0]()
        gx = gy * y
        return gx

class Sigmoid(Function):
    def forward(self, x):
        y = 1 / (1 + np.exp(-x))
        return y

    def backward(self, gy):
        y = self.outputs[0]()
        gx = gy * y * (1 - y)
        return gx


def sigmoid(x):
    return Sigmoid()(x)

def exp(x):
    return Exp()(x)


데이터와 파라미터를 초기화 시키고 모델을 만들고 학습시키는 코드는 아래와 같다.

 

import numpy as np
from dezero import Variable
from dezero import functions as F

# dataset
np.random.seed(42)
x = np.random.rand(100, 1)
y = np.sin(2 * np.pi * x) + np.random.rand(100, 1)

# initialize parameters
std = 0.01
I, H, O = 1, 20, 1
W1 = Variable(np.random.rand(I, H) * std)
b1 = Variable(np.zeros(H))
W2 = Variable(np.random.rand(H, O) * std)
b2 = Variable(np.zeros(O))


# predict
def predict(x):
    y = F.linear(x, W1, b1)
    y = F.sigmoid(y)
    y = F.linear(y, W2, b2)
    return y


lr = 0.2
iters = 1000

for i in range(iters):
    # predict and get Loss
    y_pred = predict(x)
    loss = F.mean_squared_error(y, y_pred)

    # clear gradients in parameters
    for param in [W1, b1, W2, b2]:
        param.clear_grad()

    # backpropagation
    loss.backward()

    # update parameters
    W1.data -= lr * W1.grad.data
    b1.data -= lr * b1.grad.data
    W2.data -= lr * W2.grad.data
    b2.data -= lr * b2.grad.data

    # verbose
    if (i+1) % 50 == 0:
        print(f"# Epoch:{i+1} -> Loss:{loss}")

드디어 이제 Dezero를 활용해서 간단한 신경망 모델도 만들고 학습도 시켜보았다. 출력을 보니 손실함수 값이 학습이 진행됨에 따라 잘 감소하는 것으로 보인다. 그런데 아쉽게도 지금 상태에서도 개선해야 할 점이 있다. 바로 파라미터를 갱신할 때 파라미터의 기울기(gradient) 값을 Epoch를 돌 때마다 수기로 매번 초기화시켜 주어야 하고 또 갱신해주어야 한다. 지금이야 파라미터가 4개라서 코드량이 길지 않지만 조금만 신경망 모델이 복잡해지면 파라미터 개수가 백만 개, 천만 개, 심지어 억단위까지 증가하게 된다.

다음 포스팅에서는 이렇게 파라미터(매개변수)를 어떻게 한 데 모아서 관리해줄 수 있는지, 또 자주 사용하는 계층을 한 데 모아서 어떻게 관리할 수 있는지, 그리고 데이터를 로드하고 전처리 하는 역할을 수행하는 클래스까지도 만들어보는 내용을 배워보도록 하자!

반응형