본문 바로가기

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

[밑시딥] 나만의 딥러닝 프레임워크 만들기, 미분 자동 계산(1)

반응형

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

 

출처: Yes24


최근에 밑바닥부터 시작하는 딥러닝 1,2권을 깊숙하게 공부해보면서 부끄럽지만 나의 부족한 실력을 많이 채워나갈 수 있었다. 그리고 이번에는 실력을 좀 더 발전시키기 위해 해당 책 시리즈 3권을 천천히, 최대한 깊숙하게 공부해 보기로 결심했다. 참고로 3권도 마찬가지로 시중에 공개된 딥러닝 프레임워크인 Tensorflow, Pytorch 등과 같은 라이브러리를 사용해서 공부하는 내용은 아니다. 1,2권처럼 순수하게 파이썬과 넘파이만을 활용하게 되는데, 이 책의 핵심은 공개된 딥러닝 프레임워크들의 기반이 되는 공통점을 공부하면서 나만의 Tensorflow, Pytorch 같은 딥러닝 프레임워크를 만들 수 있게 해주는 기초 실력을 만들어주는 것에 있다. 필자는 개인적으로 이 핵심에 매우 흥미가 끌렸고 책을 읽기도 전에 벌써 실력을 얻게 된 것처럼 설레(?)기도 하였다(하하..갈 길이 멀지만..) 

 

책에서 소개하는 DeZero라는 프레임워크는 이 책의 저자 사이토 고키라는 분께서 딥러닝 프레임워크들의 공통점을 이끌어내어 교육 측면을 강화해 만든 '사이토 고키'님 만의 딥러닝 프레임워크이다. 즉, 우리는 이 DeZero라는 프레임워크를 따라 만들어보면서 공개된 딥러닝 프레임워크들의 기반이 되는 코드를 작성해보고 이를 활용해 더 나아가서는 '나만의 딥러닝 프레임워크'를 만들 수 있도록 하는 데 목표가 있다고 할 수 있다.

 

그리고 이 기반 실력을 쌓는데 가장 먼저 넘어야 할 고지는 '미분 자동 계산'이다. 필자도 그랬지만 1,2권 시리즈를 공부해본 분들은 해당 챕터를 읽어나가면서 익숙한 느낌을 많이 받았을 것이다. 어쨌건, 이제 첫 번째 고지를 달성하기 위한 걸음을 시작해보자!


 

미분 자동 계산의 '자동'이라 함은 사람이 아닌 컴퓨터가 자동으로 미분을 계산해주는 것을 의미한다. 우리는 해당 챕터에서는 미분을 컴퓨터가 자동으로 계산하도록 하는 틀(프레임)을 만들어낼 것이다. 이제 하나씩 스텝을 밟아나가보자.

Step1. 상자로서의 변수

파이썬에서는 변수에다가 데이터를 할당하는 방식으로 정의한다. 우리는 여기서 변수의 역할을 하는 클래스 Variable를 정의할 것이다. 아래 코드를 보면 쉽게 이해할 수 있다.

 

import numpy as np


class Variable:
    def __init__(self, data):
        self.data = data

        
x = Variable(np.array(12.0))
print(x.data)

 

 

데이터를 Variable 이라는 클래스의 인스턴스 변수에 '할당' 하면 된다. 이것이 변수 기능을 하는 클래스를 만드는 것은 끝이다. 생각 외로 매우 간단하다. 물론 현재는 데이터를 단순한 하나의 스칼라 값으로 정의했지만 추후에는 다차원 배열을 다루는 Variable 클래스로 약간의 응용을 취할 예정이다.

 

그리고 책 내용에서 필자가 몰랐던 내용에 대해 소개하려고 한다. 바로 '벡터'와 '배열'의 각 개념에서 '차원'이라는 개념의 의미이다. 우선 벡터라는 관점에서 '차원'이라고 정의한다면 이는 원소의 개수를 의미한다. 예를 들어, $x = [1, 2, 3]$ 벡터가 존재할 때, $x$ 벡터의 차원은 3이 된다. 반면에 배열의 관점에서는 '차원'은 축(axis)의 개수를 의미한다. 그래서 위 $x$라는 배열의 차원은 몇이냐? 라고 묻는다면 정답은 1(개)이 된다.

Step2. 변수를 낳는 함수

위에서 변수에 대해 배웠다. 그렇다면 이제 변수를 넣어서 또 다른 변수를 낳는 함수(Function)에 대해 구현해보자. 함수와 변수 간의 관계는 아래와 같은 계산 그래프 형태로 나타낼 수 있다.

 

출처: 밑시딥3권 책

 

 

이 책에서는 변수를 동그라미로, 함수를 네모로 표시한다. 위와 같이 표현하는 방법을 계산 그래프 형태라고 부른다. 계산 그래프에 대한 개념은 1,2권을 공부하신 분들에게는 매우 익숙할 것이다.

 

그러면 이제 함수의 기능을 하는 Function 클래스를 간단하게 만들어보자. 이 때, 위에서 만든 변수 클래스 Variable을 이용한다는 점을 인지하고 코드를 보자.

 

import numpy as np


class Variable:
    def __init__(self, data):
        self.data = data
        
        
class Function:
    def __call__(self, input):
        """ 
        [args] input: Variable 클래스에 저장되어 있는 데이터
        """
        x = input.data
        y = x ** 2
        output = Variable(y)
        return output
    

x = Variable(np.array(10))
f = Function()
output = f(x)
print(type(output), output.data)

 

함수 기능을 구현하기 위해서  __call__ 이라는 매직 메소드를 활용할 수 있다. 매직 메소드에 대해서는 예전 포스팅에 소개한 적이 있으므로 모른다면 해당 글을 참고하자. 간단하게 말해서 애초부터 내장된 메소드를 의미한다.

 

위에서 특이한 점은 함수의 출력(output) 값을 또 하나의 Variable 변수 클래스에 담아내는 점이다. 이렇게 하는 이유는 계속적으로 변수 클래스의 인스턴스 변수에 저장해둠으로써 나중에 함수의 함수인 합성함수 같은 결과값을 구현할 때 유용하게 작용하기 때문이다.

 

그런데 위 코드에서 한 가지 수정할 사안이 있다. 위에서는 우리가 제곱 함수를 정의하긴 했지만 함수의 종류에는 제곱, Exp, Sigmoid, Softmax 등등 종류가 매우 많다. 그러면 이 모든 종류의 함수에 대해서 개별적으로 Function 클래스를 정의해주면 낭비되는 코드량이 많을 것이다. 그래서 모든 종류의 함수들에서 공통적으로 사용되는 기능들을 Function 클래스로 별도로 빼고 각 함수의 기능에 맞게 Function 클래스를 상속하게 함으로써 표현해줄 수 있다. 아래 코드는 제곱 함수를 예시이다.

 

import numpy as np


class Variable:
    def __init__(self, data):
        self.data = data
        
        
# 함수의 공통 클래스
class Function:
    def __call__(self, input):
        """ 
        [args] input: Variable 클래스에 저장되어 있는 데이터
        """
        x = input.data
        y = self.forward(x)
        output = Variable(y)
        return output
    
    def forward(self, x):
        raise NotImplementedError("This method should be called in other function class")
    

# 제곱 함수
class Square(Function):
    def forward(self, x):
        y = x ** 2
        return y
    

data = Variable(np.array(12.0))
square_f = Square()
output_var = square_f(data)
print(type(output_var), output_var.data)

Step3. 여러 함수를 연결하자(합성함수)

이번엔 좀 더 복잡하게 여러가지 함수를 결합하는 합성함수(Composite Function) 연산을 수행하는 함수 클래스를 만들어보자. 합성함수란, 계산 그래프로 나타내면 아래와 같다.

 

함성함수의 계산 그래프

 

X 라는 최초 입력을 넣어서 중간에 A, B, C 라는 3가지 함수를 연속적으로 거쳐 최종 출력 y 를 내뱉게 한다. 그러면 여기서 A, C를 제곱함수, B를 지수함수라고 가정하고 위 계산 그래프의 합성함수를 클래스로 구현해보도록 하자.

 

import numpy as np


class Variable:
    def __init__(self, data):
        self.data = data
        
        
# 함수의 공통 클래스
class Function:
    def __call__(self, input):
        """ 
        [args] input: Variable 클래스에 저장되어 있는 데이터
        """
        x = input.data
        y = self.forward(x)
        output = Variable(y)
        return output
    
    def forward(self, x):
        raise NotImplementedError("This method should be called in other function class")
    

# 제곱 함수
class Square(Function):
    def forward(self, x):
        return x ** 2
    
# 지수 함수
class Exp(Function):
    def forward(self, x):
        return np.exp(x)
    

data = Variable(np.array(12.0))
A = Square()
B = Exp()
C = Square()

output = C(B(A(data)))
print(type(output), output.data)

Step4. 수치 미분

미분을 계산하는 방법에는 크게 간단하지만 오래 걸리는 수치 미분, 복잡하지만 빠른 속도를 자랑하는 역전파 방법이 존재한다. 먼저 수치 미분을 클래스로 구현하는 방법에 대해 알아보자. 수치 미분에 대한 개념은 지난 1권 포스팅에서 다루었으므로 모른다면 해당 포스팅을 참고하도록 하자. 먼저 간단하게 하나의 함수를 수치 미분해보자.

 

def numerical_diff(func, x, eps=1e-4):
    """
    [args] func: 수치 미분을 적용할 함수
    [args] x : 미분을 적용할 기준이 되는 변수가 담긴 Variable 클래스
    [args] eps : 수치 미분 수행할 때 사용하는 미세한 차이(epsilon)값
    """
    x0 = Variable(x.data - eps)
    x1 = Variable(x.data + eps)
    y0 = func(x0)
    y1 = func(x1)
    return (y1.data - y0.data) / (2*eps)


f = Square()
x = Variable(np.array(2.0))
dy = numerical_diff(f, x)
print(dy)

 

그러면 이제 합성함수를 미분해보자. 합성함수로 바꾸는 부분은 위에서 함수만 합성함수로 변경해주면 된다.

 

def numerical_diff(func, x, eps=1e-4):
    """
    [args] func: 수치 미분을 적용할 함수
    [args] x : 미분을 적용할 기준이 되는 변수가 담긴 Variable 클래스
    [args] eps : 수치 미분 수행할 때 사용하는 미세한 차이(epsilon)값
    """
    x0 = Variable(x.data - eps)
    x1 = Variable(x.data + eps)
    y0 = func(x0)
    y1 = func(x1)
    return (y1.data - y0.data) / (2*eps)


def composite_f(x):
    A = Square()
    B = Exp()
    C = Square()
    
    return C(B(A(x)))

x = Variable(np.array(2.0))
dy = numerical_diff(composite_f, x)
print(dy)

 

위 결과값을 출력하면 대략 아래의 값이 나오게 된다.

 

23847.666917060906

 

위 값이 그러면 의미하는 게 무엇일까? 합성함수에서 입력 값($x$)인 2.0에서 매우 미세하게(eps 만큼) 변화시켰을 때, 합성함수의 출력값($y$)은 $x =2.0$을 넣었을 때의 $y$ 보다 미세한 $eps$ 만큼의 23,847 배만큼 변한다는 것을 의미한다.

 

하지만 이러한 수치 미분은 단점이 존재한다. 첫 번째로는 변수가 많아짐에 따라 계산량이 기하급수적으로 늘어난다는 문제점이다. 이는 변수가 적게는 몇 십만 개 많게는 억 단위 개수를 갖는 딥러닝 신경망에서는 치명적인 시간 문제가 발생한다. 두 번째로는 자릿수 누락 때문에 수치 미분의 결과에 오차가 포함되기 쉽다는 문제다. 

 

이러한 단점이 존재하는 수치 미분도 유용하게 사용될 때가 있다. 바로 다른 미분 계산 방법인 오차역전파 방법의 결과로 나온 미분값이 정확한지 아닌지 판단하기 위한 수단으로 사용된다. 이를 기울기 확인(Gradient Checking)이라고도 한다.


이번 포스팅에서는 변수의 정의부터 수치 미분을 계산하는 클래스까지 구현해보았다. 아직 첫 번째 고지의 절반 정도의 내용이 남았다. 역전파 방법에 대해서는 다음 포스팅에 연이어서 다루도록 한다.

 

한 번에 모든 챕터를 다 작성하면 좋겠지만 책을 읽으면서 느낀 점은 책 앞의 내용이 연쇄적으로 후반챕터에 계속 사용되는 구조인 것 같아 한 챕터에 절반정도씩 나누어서 포스팅하려고 한다. 그러면 다음 챕터에서 '미분 자동 계산'의 나머지 내용을 배우도록 해보자!

 

반응형