본문 바로가기

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

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

반응형

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

 

출처: Yes24


저번 포스팅까지 우리가 만들어온 Dezero라는 프레임워크가 미분 계산을 자동화할 수 있어 역전파 시 미분 계산을 자연스레 할 수 있도록 만들었다. 하지만 더 어려운 작업을 자동화시켜야 해야 하는데, 그 작업이 바로 고차 미분 계산이다.

 

고차 미분이란, 1차 미분에서 미분을 $N$번 더 수행하는 2차 미분, 3차 미분, ... 을 의미한다. 이렇게 고차 미분을 수행해야 하는 이유는 함수를 최적화하는 것과 관련이 있다. 함수 최적화가 필요한 이유는 뭘까? 바로 딥러닝이 학습하는 데 가장 핵심인 목적함수(손실함수) 값($y$)의 최솟값(또는 최댓값)을 갖는 파라미터($x$)를 찾아야 하기 때문이다.

 

고차 미분을 구현하는 것에 앞서서 고차 미분의 개념이 무엇인지부터 알아야 한다. 그리고 고차 미분을 배우기 위한 기초 개념으로 테일러 급수라는 것부터 배워야 한다. 하나씩 차근차근 단계를 밟아나가 보자.

1. 함수를 다항식으로 근사시키자, 테일러 급수(Taylor Series)

테일러 급수 이론을 알아보기 위해 하나의 예시 함수를 만들어보자. 바로 주기 함수인 $sin(x)$ 함수이다. 우리는 $sin(x)$ 함수를 넘파이의 np.sin() 함수를 사용해 계산하는 방법과 테일러 급수를 활용해서 근사하여 계산하는 방법이 동일한 결과를 만들어내는지 살펴볼 예정이다. 그리고 참고로 $sin(x)$의 미분 값은 $cos(x)$이라는 점을 미리 알아두자.(밑에서 사용할 예정)

$$y = sin(x)$$

$${\partial y \over \partial x} = cos(x)$$

위 식을 참고해서 Dezero 프레임워크의 Function 클래스를 상속받아 $sin(x)$ 함수를 아래처럼 만들어보자. 출력결과를 확인해보면 값이 동일한 것을 알 수 있다. 

 

import numpy as np
from dezero import Function
from dezero import Variable


class Sin(Function):
    def forward(self, x):
        y = np.sin(x)
        return y
    
    def backward(self, gy):
        x = self.inputs[0].data
        gx = np.cos(x) * gy
        return gx
    
def sin(x):
    return Sin()(x)
    
# Test
x = Variable(np.array(np.pi/4))
y = sin(x)
y.backward()

flag = np.cos(np.pi/4)

print(y.data) # sin(pi/4)
print(x.grad) # sin(pi/4)의 미분값
print(flag)   # cos(pi/4)

 

우리는 위에서 $sin(x)$ 값을 구하기 위해 단순히 Numpy 클래스의 np.sin(x)를 사용했다. 이번에는 테일러 급수를 사용해서 $sin(x)$ 값을 계산해보도록 하자. 테일러 급수란 어떤 함수를 다항식으로 근사하는 방법인데 수식은 아래와 같다.

 

 

테일러 급수 수식

 

위 식은 점 $a$에서의 테일러 급수이다. $f(a)$는 점 $a$에서의 $f(x)$ 값을 의미한다. $f'$은 1차 미분을, $f''$은 2차 미분을, $f'''$은 3차 미분을 의미한다. 그리고 $!$은 계승(팩토리얼)을 의미한다. 그리고 $\cdots$ 표시를 보면 알 수 있듯이 테일러 급수는 무한정으로 $n$차 미분과 계승을 계속적으로 수행하게 된다. 물론 항상 무한대로 항이 계속되지는 않고 함수 사용자가 어느 시점에 중단했을 때 $f(x)$의 값을 그 중단한 시점의 $n$차 다항식으로 근사를 하게 되는 것이다. 당연히 $n$값이 클수록(차수가 높은 다항식일수록) 근사의 정확도는 높아진다.

 

그리고 위 테일러 급수 수식에서 $a=0$일 때를 매클로린 전개라고도 한다. $a=0$을 대입하게 되면 테일러 급수 수식은 아래처럼 간단해진다.

 

매클로린 전개

 

그러면 $a=0$일 때, $f(x) = sin(x)$로 대입해서 테일러 급수 수식을 재정의해보자. 위에서 알아봤듯이 $sin(x)$의 미분은 $cos(x)$이며, $cos(x)$의 미분은 $-sin(x)$가 된다. 그리고 $sin(0) = 0$, $cos(0) = 1$ 이기 때문에 수식은 최종적으로 아래처럼 변한다.

 

테일러 급수에 $sin(x)$, $a=0$일 때 전개시켜보자

 

최종 전개된 수식을 보면 $sin(x)$의 테일러 급수는 $x$의 거듭제곱으로 이루어진 항들이 계속 무한대로 반복되는 형태임을 알 수 있다. 여기서 중요한 점은 시그마의 $i$가 커질수록 근사 정밀도가 좋아진다는 것이다. 그리고 분모에 $!$이라는 계승 항 때문에 수식 결과의 절댓값이 계속 작아지므로 이 절댓값을 참고하면서 $i$의 값 즉, 사용자가 무한히 반복되는 다항식을 언제 적절히 중단시킬지 결정할 수 있다.

 

그러면 위 [식 27.3]을 기반으로 해서 $sin(x)$의 값을 테일러 급수로 계산해보자. 그리고 그렇게 계산해낸 $sin(x)$ 값을 미분 했을 때 $cos(x)$ 값과 동일한지도 비교 검증해보자.

 

# 테일러 급수
import math
from dezero import Variable

def taylor_sin(x, threshold=0.0001):
    # y(sin(x)의 값)의 초기값
    y = 0 
    for i in range(int(1e5)):
        c = (-1) ** i / math.factorial(2*i + 1)
        t = c * x ** (2*i + 1)
        y = y + t
        if abs(t.data) < threshold:
            break
    return y

# Test
x = Variable(np.array(np.pi/4))
y = taylor_sin(x)
y.backward()

print(y.data)
print(x.grad)
print(np.cos(np.pi/4))

 

출력 결과를 보면 오차가 대략 소수 여섯째자리 밖에 되지 않으므로 거의 값이 동일한 결과를 얻었다고 할 수 있겠다. 위에서 만든 테일러 급수를 활용한 $sin(x)$ 함수의 계산 그래프를 만들어볼 수 있는데, 이에 대해서는 계산 그래프 만들어보기 포스팅을 참고하도록 하자.

2. 1차 미분만을 사용해 함수를 최적화시키자, 경사하강법(Gradient Descent)

경사하강법은 어떤 함수 또는 딥러닝에서는 목적함수(손실함수)의 최솟값, 최댓값을 갖는 파라미터를 찾는 일종의 함수 최적화 기법 중 하나이다. 그런데 이 경사하강법은 단순히 1차 미분만을 사용해서 함수를 최적화시킨다. 그동안 다양한 글을 포스팅해오면서 경사하강법에 대해서 많이 다루어온 것 같은데, 여기서 소개하는 이유는 경사하강법이 1차 미분만을 사용해서 함수를 최적화시키기 때문이고 이는 밑에서 살펴볼 또 다른 최적화 기법인 뉴턴 메소드와 큰 차이점이기 때문이다.

 

함수를 최적화하는 여러가지 기법이 등장하면서, 이 최적화 기법이 '효율적인가/비효율적인가'를 구분짓는 용도로 사용되는 대표적인 벤치마크 함수가 로젠브록(Rosenbrock) 함수이다. 로젠브록 함수 수식은 아래와 같으며 3차원 그래프로 함수 모양을 나타내면 아래와 같다.

 

로젠브록 함수의 수식과 그래프 생김새

 

위 로젠브록 함수의 수식도 주어졌으니, 우린 Dezero를 활용해서 로젠브록 함수를 정의하고 한번의 역전파(미분)을 수행할 수 있다. 그 결과의 2개 변수($x_0, x_1$)의 미분값은 아래와 같다.

 

from dezero import Variable
import numpy as np


def rosenbrock(x0, x1):
    y = 100 * ((x1 - x0**2) ** 2) + (1 - x0) ** 2
    return y

x0 = Variable(np.array(0.0))
x1 = Variable(np.array(2.0))
y = rosenbrock(x0, x1)
y.backward()

print(x0.grad, x1.grad) # (-2.0, 400.0)

 

로젠브록 함수에 들어간 인자 $x_0, x_1$의 각 기울기는 (-2.0, 400.0)이 나오게 된다. 이러한 벡터를 기울기 또는 기울기 벡터라고 정의한다. 기울기는 각 지점에서 함수의 출력을 가장 크게 하는 방향을 가리킨다. 즉, $x_0$ 지점에서 로젠브록 함수의 출력($y$)를 가장 크게 하는 방향이 음(-)의 방향의 크기가 2인 벡터이고 $x_1$ 지점에서 로젠브록 함수의 출력($y$)를 가장 크게 하는 방향이 양(+)의 방향의 크기가 400인 벡터를 의미하는 것이다. 만약 여기서 함수의 출력을 가장 작게하려 한다면 각 벡터에서 방향만 바꾸어주면 된다. 다시 말해, 로젠브록 함수의 출력을 가장 작게 하려면 (2.0, -400.0)이 기울기 벡터가 된다.

 

물론 방금 실행한 실습 코드 결과로 얻은 기울기 벡터가 가리키는 방향에 어떤 목적 함수(여기에선 로젠브록 함수)의 최댓값(또는 최솟값)이 항상 존재한다고 단언할 수 없다. 특히, 목적 함수가 복잡한 형태의 함수라면 더더욱 보장할 수 없다. 하지만 국소적으로 본다면 기울기 벡터는 함수의 출력을 가장 크게(또는 작게)하는 방향을 나타내긴 한다. 그래서 이 '국소적인 기울기 방향'을  일정 거리만큼 계속 반복적으로 수행하면서 기울기를 갱신해나간다면 우리가 원하는 지점에 도달할 것이라고 기대하는 것이 바로 경사하강법(Gradient Descent)의 의미이다.

(필자도 경사하강법을 반복해서 배울수록 새롭다는 느낌이 든다.. 내가 아는 것이 아는 것이 아닌 느낌..)

 

경사하강법의 수식은 간단하다. 기존의 파라미터($x$값들)에 1차 미분을 수행한 기울기 값에 사용자가 설정하는 학습률(learning_rate)을 곱해준 양만큼 빼주어 파라미터 값들을 업데이트 해주면 된다.

$$x_0 = x_0 - \alpha{\operatorname{d}\!f(x)\over\operatorname{d}\!x_0}$$
$$x_1 = x_1 - \alpha{\operatorname{d}\!f(x)\over\operatorname{d}\!x_1}$$

 

from dezero import Variable
import numpy as np


def rosenbrock(x0, x1):
    y = 100 * ((x1 - x0**2) ** 2) + (1 - x0) ** 2
    return y

x0 = Variable(np.array(0.0))
x1 = Variable(np.array(2.0))
lr = 0.001
iters = 1000

for i in range(iters):
    print(x0, x1)
    
    y = rosenbrock(x0, x1)
    
    x0.clear_grad()
    x1.clear_grad()
    
    y.backward()
    
    x0.data -= lr * x0.grad
    x1.data -= lr * x1.grad

 

위 경사하강법 코드를 수행함으로써 목적지에 도달해가는 루트를 시각화하면 아래와 같다.

 

경사하강법으로 로젠브록 함수를 최적화시키기

 

목적지는 파란색 별표인데, 경사하강법은 아쉽게도 1,000번의 반복 횟수로도 목적지에 도달하지 못한 것을 볼 수 있다. 그래도 반복 횟수를 10,000번으로 늘리게 되면 목적지에 도달한다. 이렇게 해서 경사하강법을 통해서 우리가 찾고자 하는 목적지에 도달(수렴)할 수 있는 것을 배웠다.

 

하지만 경사하강법은 목적지에 도달하긴 했지만 그 과정에서 10,000번 이상이라는 연산 리소스가 수행되었다. 이에 대응하듯이 $n$차 미분을 사용하는 테일러 급수를 활용하는 최적화 기법인 뉴턴 메소드는 10번 이하의 연산으로 로젠브록 함수의 목적지에 도달할 수 있게 된다. 이제 뉴턴 메소드에 대해 알아보자.

3. 고차미분을 사용해 함수를 최적화시키자, 뉴턴 방법(Newton's Method)

뉴턴 방법은 대체 어떤 원리로 함수를 최적화시킬까? 바로 처음에 배운 테일러 급수를 활용한다. 먼저 $y = f(x)$라는 것을 테일러 급수로 나타내면 아래와 같다고 했다.

 

테일러 급수로 $y = f(x)$를 정의하는 수식

 

위처럼 테일러 급수는 무한하게 항이 반복되는데, 어느 시점에 중단하면 $f(x)$를 근사적으로 구할 수 있다고 했다. 책에서는 2차 미분에서 중단하기로 가정했다. 그러면 우리는 $y = f(x)$를 2차 미분까지만 사용해서 $y$값을 근사시킬 수 있게 된다. 즉, $f(x)$는 $x$의 2차 함수로 근사해 정의가 된다. 다시 말해, $f(x)$를 무한한 다항식인 '어떤 함수'의 2차 함수로 근사 정의시켰다는 것!

 

방금 2차 함수로 근사시킨 함수와 무한 다항식의 그림을 2차원 그래프로 나타내면 아래와 같다.

 

무한한 다항식(테일러 급수) vs 2차항으로 근사한 함수 그래프 비교

 

2차 함수로 근사시킨 함수는 점 $a$에서 $y = f(x)$에 접하는 곡선이 된다. 현재 2차 함수의 최솟값은 우리가 수학시간에 풀어본 것처럼 최솟값을 해석적으로 구할 수 있다. 즉, 위 2차 함수로 근사시킨 함수를 미분을 해서 0이 되는 $x$를 구하면 된다.(미분하는 방법은 고등학교 수학시간 때 도함수 $f'(x)$를 구할 때 사용하던 방법을 적용하면 된다!) 

 

2차 함수로 근사시킨 함수의 미분값이 0이 되는 $x$를 찾자

 

위 수식대로 전개하면 $x$는 $a - {f'(a) \over f''(a)}$가 된다. 이 말은 곧 $x = a - {f'(a) \over f''(a)}$ 일 때, 2차로 근사시킨 함수의 값이 최솟값이 된다는 의미다.  따라서 우리는 현재 위치인 점 $a$에서 $- {f'(a) \over f''(a)}$ 만큼 이동시키면 2차로 근사시킨 함수의 최솟값을 구할 수 있다는 것! 그래프 그림으로 나타내면 아래와 같다.

 

최솟값을 찾기 위해 점 $a$로부터 이동시키자!

 

이렇게 갱신시키는 것이 뉴턴 방법을 활용해서 최적화하는 작업 한 번을 수행한 것이다. 여기서는 한 번 갱신한 후 바로 최솟값을 찾았지만, 더 복잡한 형상의 함수일 때는 위와 같은 한 번의 갱신 과정을 계속 반복적으로 수행해 최적화를 시키면 된다. 즉, 위 예시에서는 이제 점 $a_{new}$로 이동한 2차 근사 함수에서 미분한 값이 0이 되는 지점을 또 따라 점 위치를 갱신시켜준다. 

 

이렇게 뉴턴 방법을 활용해 파라미터($x$)를 갱신시켜주는 과정과 경사하강법을 활용해 갱신시켜주는 과정을 수식적으로 나타내면 아래와 같은 차이가 있다.

 

뉴턴 메소드 vs 경사 하강법 파라미터 갱신 수식 차이점

 

경사 하강법 수식에서 $\alpha$ 값은 학습률(learning rate) 즉, 사람이 직접 정의하는 하이퍼파라미터이다. 결국, 사람이 수동적으로 이 값을 설정해줌으로써 경사 하강법으로 함수를 최적화시킨다. 반면, 뉴턴 방법은 2차 미분(고차 미분)을 사용하는 것 덕분에 사람이 직접 조정할 파라미터가 없이 자동으로 조정한다. 결국 경사 하강법의 $\alpha$의 역할을 뉴턴 방법에서는 ${1 \over f''(x)}$가 대신한다고 할 수 있다.

 

지금까지 뉴턴 방법을 설명할 때, 입력을 단순한 상수인 스칼라 값이라고 가정하고 설명했다. 하지만 실제 딥러닝에서는 다차원 행렬인 벡터가 들어오는데, 벡터인 경우에도 자연스레 확장이 가능하다. 벡터일 경우에는 1차 미분 시 스칼라처럼 똑같이 기울기를 사용하되 2차 미분부터는 헤세안(Hessian) 행렬을 사용한다는 점이 차이가 있다. 이에 대한 자세한 내용은 다음 포스팅에서 더 배울 예정이므로 잠시 차치해두자.

 

이렇게 고차 미분을 사용하는 뉴턴 방법은 경사 하강법보다 훨씬더 목적지에 더 빨리 도달할 확률이 커진다. 물론 항상 뉴턴 방법이 더 빨리 도달하는 것은 아니고 파라미터 초깃값이나 학습률 등에 따라 결과가 달라질 수 있다. 일반적으로는 초깃값 설정 시 최적의 값에 가까운 값으로 설정할 수록 뉴턴 방법이 더 빨리 수렴하게 된다고 한다. 하지만 뉴턴 메소드의 단점도 존재한다. 고차 미분을 사용한다는 것은 곧 연산량이 많아진다는 것을 의미하고 이는 결국 한편으로는 수많은 연산을 하는 데 시간이 오래걸릴 수도 있다는 점을 시사한다.

 

그러면 이제 뉴턴 방법을 활용하되, 여기서는 2차 미분까지만 사용한다고 가정하고, 사람이 직접 하드코딩해 수동적으로 2차 미분을 수행해준 후 아래의 함수를 최적화시켜보도록 하자. 최적화 시킬 함수의 생김새는 아래와 같다.

 

$y = x^4 - 2x^2$ 4차항 그래프

 

우리는 $y = x^4 - 2x^2$이라는 4차항 식은 해석적으로 1차 미분, 2차미분 값을 아래와 같이 계산할 수 있다.

$$ y = x^4 - 2x^2$$

$${\partial y \over \partial x} = 4x^3 - 4x$$

$${\partial^2 y \over \partial x^2} = 12x^2 - 4$$

이제 위 1,2차 미분 결과 식을 토대로 해서 뉴턴 방법을 활용한 최적화를 아래처럼 구할 수 있다.

 

import numpy as np
from dezero import Variable

# 4차항 함수
def f(x):
    y = x ** 4 - 2 * x ** 2
    return y

# 4차항 함수를 2차 미분한 도함수
def gx2(x):
    return 12 * x ** 2 - 4

x = Variable(np.array(2.0))
iters = 10

# 뉴턴 메소드로 x 갱신
for i in range(iters):
    print(i, x)
    
    y = f(x)
    x.clear_grad()
    y.backward()
    
    x.data -= x.grad / gx2(x.data)



4차항 함수의 목적지 즉, 함수의 최솟값을 갖는 파라미터($x$)는 1이다. 위 코드 결과를 보면 단 7번 만에 목적지에 도달한 것을 볼 수 있다. 위 4차항 함수를 경사하강법과 뉴턴 방법 각각을 사용해서 목적지에 도달하는 루트를 비교 및 시각화해보면 아래와 같다.

 

경사하강법 vs 뉴턴 메소드

 

그림을 보면 왼쪽 경사하강법은 124번의 횟수(동그란 점의 개수)로 목적지에 도달한 반면, 오른쪽인 뉴턴 방법은 단 7번 만에 목적지에 도달한 것을 볼 수 있다. 물론 파라미터 초깃값, 학습률의 상황에 따라 다르겠지만 살펴본 것처럼 뉴턴 방법이 경사하강법에 비해 빠르게 수렴하는 경우도 있음을 알게 되었다.


하지만 이번 포스팅에서는 우리가 뉴턴 방법을 활용할 때 단순히 '2차 미분'까지만 사용한다고 가정했고, 심지어 2차 미분을 해줄 때 해석적으로 계산 즉, 사람의 손으로 직접 하드코딩을 해주었다. 하지만 우리는 이것도 자동화시킬 필요가 있다. 다음 포스팅에서는 이 수작업을 자동화하는 고차 미분 및 뉴턴 방법 자동화 작업에 대해 배워보도록 하자.

 

 

반응형