부록 D – 자동 미분

이 노트북은 간단한 예제를 통해 여러 가지 자동 미분 기법의 작동 원리를 설명합니다.

설정

소개

파라미터 x와 y에 대한 함수 $f(x,y)=x^2y + y + 2$의 그래디언트를 계산한다고 가정합시다:

In [1]:
def f(x,y):
    return x*x*y + y + 2

해석적으로 푸는 방법이 하나 있습니다:

$\dfrac{\partial f}{\partial x} = 2xy$

$\dfrac{\partial f}{\partial y} = x^2 + 1$

In [2]:
def df(x,y):
    return 2*x*y, x*x + 1

예를 들어 $\dfrac{\partial f}{\partial x}(3,4) = 24$ 이고, $\dfrac{\partial f}{\partial y}(3,4) = 10$ 입니다.

In [3]:
df(3, 4)
Out[3]:
(24, 10)

완벽합니다! 2차 도함수(헤시안이라고도 부릅니다)를 위한 식도 구할 수 있습니다:

$\dfrac{\partial^2 f}{\partial x \partial x} = \dfrac{\partial (2xy)}{\partial x} = 2y$

$\dfrac{\partial^2 f}{\partial x \partial y} = \dfrac{\partial (2xy)}{\partial y} = 2x$

$\dfrac{\partial^2 f}{\partial y \partial x} = \dfrac{\partial (x^2 + 1)}{\partial x} = 2x$

$\dfrac{\partial^2 f}{\partial y \partial y} = \dfrac{\partial (x^2 + 1)}{\partial y} = 0$

x=3이고 y=4일 때, 헤시안은 각각 8, 6, 6, 0입니다. 위 식을 사용해 이를 계산해 보죠:

In [4]:
def d2f(x, y):
    return [2*y, 2*x], [2*x, 0]
In [5]:
d2f(3, 4)
Out[5]:
([8, 6], [6, 0])

좋습니다. 하지만 이렇게 하려면 수학 지식이 필요합니다. 이 경우에는 아주 어렵지 않지만 심층 신경망일 때 이런 식으로 도함수를 계산하는 것은 현실적으로 불가능합니다. 자동화해서 계산할 수 있는 여러 방법을 살펴 보겠습니다!

수치 미분

여기서는 다음 식을 사용하여 그래디언트 근사값을 계산합니다. $\dfrac{\partial f}{\partial x} = \displaystyle{\lim_{\epsilon \to 0}}\dfrac{f(x+\epsilon, y) - f(x, y)}{\epsilon}$ (그리고 $\dfrac{\partial f}{\partial y}$에 대해서도 비슷합니다).

In [6]:
def gradients(func, vars_list, eps=0.0001):
    partial_derivatives = []
    base_func_eval = func(*vars_list)
    for idx in range(len(vars_list)):
        tweaked_vars = vars_list[:]
        tweaked_vars[idx] += eps
        tweaked_func_eval = func(*tweaked_vars)
        derivative = (tweaked_func_eval - base_func_eval) / eps
        partial_derivatives.append(derivative)
    return partial_derivatives
In [7]:
def df(x, y):
    return gradients(f, [x, y])
In [8]:
df(3, 4)
Out[8]:
[24.000400000048216, 10.000000000047748]

잘 작동하네요!

이 방식의 장점은 헤시안 계산이 쉽다는 것입니다. 먼저 1차 편도함수(야코비안이라고도 부릅니다)를 계산하는 함수를 만듭니다:

In [9]:
def dfdx(x, y):
    return gradients(f, [x,y])[0]

def dfdy(x, y):
    return gradients(f, [x,y])[1]

dfdx(3., 4.), dfdy(3., 4.)
Out[9]:
(24.000400000048216, 10.000000000047748)

이제 간단하게 이 함수에 grandients() 함수를 적용하면 됩니다:

In [10]:
def d2f(x, y):
    return [gradients(dfdx, [3., 4.]), gradients(dfdy, [3., 4.])]
In [11]:
d2f(3, 4)
Out[11]:
[[7.999999951380232, 6.000099261882497],
 [6.000099261882497, -1.4210854715202004e-06]]

모두 잘 계산되었지만 이 결과는 근사값입니다. $n$개의 변수에 대한 함수의 그래디언트를 계산하러면 이 함수를 $n$번 호출해야 합니다. 심층 신경망에서는 경사 하강법을 사용해 수정할 파라미터가 수천 개가 있기 때문에 이런 방법은 매우 느릴 수 있습니다(경사 하강법은 각 파라미터에 대한 손실 함수의 그래디언트를 계산해야 합니다).

간단한 계산 그래프 구현하기

수치적인 방법 대신에 기호 미분 기법을 구현해 보죠. 이를 위해 상수, 변수, 연산을 표현할 클래스를 정의하겠습니다.

In [12]:
class Const(object):
    def __init__(self, value):
        self.value = value
    def evaluate(self):
        return self.value
    def __str__(self):
        return str(self.value)

class Var(object):
    def __init__(self, name, init_value=0):
        self.value = init_value
        self.name = name
    def evaluate(self):
        return self.value
    def __str__(self):
        return self.name

class BinaryOperator(object):
    def __init__(self, a, b):
        self.a = a
        self.b = b

class Add(BinaryOperator):
    def evaluate(self):
        return self.a.evaluate() + self.b.evaluate()
    def __str__(self):
        return "{} + {}".format(self.a, self.b)

class Mul(BinaryOperator):
    def evaluate(self):
        return self.a.evaluate() * self.b.evaluate()
    def __str__(self):
        return "({}) * ({})".format(self.a, self.b)

좋습니다. 이제 함수 $f$를 나타내는 계산 그래프를 만들 수 있습니다:

In [13]:
x = Var("x")
y = Var("y")
f = Add(Mul(Mul(x, x), y), Add(y, Const(2))) # f(x,y) = x²y + y + 2

이 그래프를 실행하여 어떤 포인트에서도 $f$를 계산할 수 있습니다. 예를 들면 $f(3, 4)$는 다음과 같습니다.

In [14]:
x.value = 3
y.value = 4
f.evaluate()
Out[14]:
42

완벽한 정답을 찾았네요.

그래디언트 계산하기

여기서 제시할 자동 미분 방법은 모두 연쇄 법칙(chain rule)을 기반으로 합니다.

두 개의 함수 $u$와 $v$가 있고 어떤 입력 $x$에 연속적으로 적용하여 결과 $v$를 얻었다고 가정합시다. 즉, $z = v(u(x))$이고, $z = v(s)$와 $s = u(x)$로 나누어 쓸 수 있습니다. 연쇄 법칙을 적용하면 입력 $x$에 대한 출력 $z$의 편도 함수를 계산할 수 있습니다:

$ \dfrac{\partial z}{\partial x} = \dfrac{\partial s}{\partial x} \cdot \dfrac{\partial z}{\partial s}$

$z$가 중간 출력이 $s_1, s_2, ..., s_n$인 연속 함수의 출력이라면, 연쇄 법칙이 다음과 같이 적용됩니다:

$ \dfrac{\partial z}{\partial x} = \dfrac{\partial s_1}{\partial x} \cdot \dfrac{\partial s_2}{\partial s_1} \cdot \dfrac{\partial s_3}{\partial s_2} \cdot \dots \cdot \dfrac{\partial s_{n-1}}{\partial s_{n-2}} \cdot \dfrac{\partial s_n}{\partial s_{n-1}} \cdot \dfrac{\partial z}{\partial s_n}$

전진 모드 자동 미분에서는 알고리즘이 이 항들을 "진행 순서대로"(즉, 출력 $z$을 계산하기 위해 필요한 계산 순서와 동일하게), 즉 왼쪽에서 오른쪽으로 계산합니다. 먼저 $\dfrac{\partial s_1}{\partial x}$를 계산하고, 그다음 $\dfrac{\partial s_2}{\partial s_1}$을 계산하는 식입니다. 후진 모드 자동 미분에서는 알고리즘이 이 항들을 "진행 반대 순서로", 즉 오른쪽에서 왼쪽으로 계산합니다. 먼저 $\dfrac{\partial z}{\partial s_n}$을 계산하고, 그다음 $\dfrac{\partial s_n}{\partial s_{n-1}}$을 계산하는 식입니다.

예를 들어, x=3에서 함수 $z(x)=\sin(x^2)$의 도함수를 전진 모드 자동 미분을 사용하여 계산한다고 가정합시다. 알고리즘은 먼저 편도함수 $\dfrac{\partial s_1}{\partial x}=\dfrac{\partial x^2}{\partial x}=2x=6$을 계산합니다. 다음, $\dfrac{\partial z}{\partial x}=\dfrac{\partial s_1}{\partial x}\cdot\dfrac{\partial z}{\partial s_1}= 6 \cdot \dfrac{\partial \sin(s_1)}{\partial s_1}=6 \cdot \cos(s_1)=6 \cdot \cos(3^2)\approx-5.46$을 계산합니다.

앞서 정의한 gradients() 함수를 사용해 결과를 검증해 보겠습니다:

In [15]:
from math import sin

def z(x):
    return sin(x**2)

gradients(z, [3])
Out[15]:
[-5.46761419430053]

훌륭하네요. 이제 후진 모드 자동 미분을 사용해 동일한 계산을 해보겠습니다. 이번에는 알고리즘이 오른쪽부터 시작하므로 $\dfrac{\partial z}{\partial s_1} = \dfrac{\partial \sin(s_1)}{\partial s_1}=\cos(s_1)=\cos(3^2)\approx -0.91$을 계산합니다. 다음 $\dfrac{\partial z}{\partial x}=\dfrac{\partial s_1}{\partial x}\cdot\dfrac{\partial z}{\partial s_1} \approx \dfrac{\partial s_1}{\partial x} \cdot -0.91 = \dfrac{\partial x^2}{\partial x} \cdot -0.91=2x \cdot -0.91 = 6\cdot-0.91=-5.46$을 계산합니다.

당연히 두 방법 모두 같은 결과를 냅니다(반올림 오차는 제외하고). 하나의 입력과 하나의 출력이 있는 경우에는 둘 다 동일한 횟수의 계산이 필요합니다. 하지만 입력과 출력의 개수가 여러 개이면 두 방법의 성능이 매우 달라집니다. 입력이 많다면 가장 오른쪽에 있는 항은 각 입력마다 편도 함수를 계산하기 위해 필요할 것입니다. 그러므로 가장 오른쪽에 있는 항을 먼저 계산하는 것이 좋습니다. 이것은 후진 모드 자동 미분을 의미합니다. 가장 오른쪽의 항을 한번 계산해서 모든 편도 함수를 계산하는데 사용할 수 있습니다. 반대로 출력이 많을 경우에는 가장 왼쪽의 항을 한번 계산해서 여러 출력의 편도 함수를 계산할 수 있는 전진 모드가 더 좋습니다. 딥러닝에서는 전형적으로 수천 개의 모델 파라미터가 있고 입력은 많지만 출력은 적습니다. 사실 훈련하는 동안 일반적으로 출력은 손실 단 하나입니다. 그래서 텐서플로와 주요 딥러닝 라이브러리들은 후진 모드 자동 미분을 사용합니다.

후진 모드 자동 미분에는 복잡도가 한가지 추가됩니다. $s_i$의 값은 일반적으로 $\dfrac{\partial s_{i+1}}{\partial s_i}$를 계산할 때 필요하고, $s_i$는 먼저 $s_{i-1}$를 계산해야 합니다. 이는 또 $s_{i-2}$를 계산해야 하는 식입니다. 그래서 $s_1$, $s_2$, $s_3$, $\dots$, $s_{n-1}$ 그리고 $s_n$를 계산하기 위해 기본적으로 전진 방향으로 한번 네트워크를 실행해야 합니다. 그다음에 알고리즘이 오른쪽에서 왼쪽으로 편도 함수를 계산할 수 있습니다. RAM에 모든 $s_i$의 중간값을 저장하는 것은 가끔 문제가 됩니다. 특히 이미지를 다룰 때와 RAM이 부족한 GPU를 사용할 때 입니다. 이 문제를 완화하기 위해 신경망의 층 개수를 줄이거나, 텐서플로가 GPU RAM에서 CPU RAM으로 중간값들을 스왑(swap)하도록 설정할 수 있습니다. 다른 방법은 홀수 번째 중간값인 $s_1$, $s_3$, $s_5$, $\dots$, $s_{n-4}$, $s_{n-2}$ 그리고 $s_n$만 캐싱하는 것입니다. 알고리즘이 편도 함수를 계산할 때 중간값 $s_i$가 없으면, 이전 중간값 $s_{i-1}$를 사용하여 다시 계산해야 합니다. 이는 CPU와 RAM 사이의 트레이드오프입니다(관심이 있다면 이 논문을 확인해 보세요).

전진 모드 자동 미분

In [16]:
Const.gradient = lambda self, var: Const(0)
Var.gradient = lambda self, var: Const(1) if self is var else Const(0)
Add.gradient = lambda self, var: Add(self.a.gradient(var), self.b.gradient(var))
Mul.gradient = lambda self, var: Add(Mul(self.a, self.b.gradient(var)), Mul(self.a.gradient(var), self.b))

x = Var(name="x", init_value=3.)
y = Var(name="y", init_value=4.)
f = Add(Mul(Mul(x, x), y), Add(y, Const(2))) # f(x,y) = x²y + y + 2

dfdx = f.gradient(x)  # 2xy
dfdy = f.gradient(y)  # x² + 1
In [17]:
dfdx.evaluate(), dfdy.evaluate()
Out[17]:
(24.0, 10.0)

gradient() 메서드의 출력은 완전한 기호 미분이므로 1차 도함수에 국한되지 않고 2차 도함수도 계산할 수 있습니다:

In [18]:
d2fdxdx = dfdx.gradient(x) # 2y
d2fdxdy = dfdx.gradient(y) # 2x
d2fdydx = dfdy.gradient(x) # 2x
d2fdydy = dfdy.gradient(y) # 0
In [19]:
[[d2fdxdx.evaluate(), d2fdxdy.evaluate()],
 [d2fdydx.evaluate(), d2fdydy.evaluate()]]
Out[19]:
[[8.0, 6.0], [6.0, 0.0]]

결과는 근사값이 아니고 완벽하게 맞습니다(물론 컴퓨터의 부동 소수 정밀도 한계까지만).

이원수(dual number)를 사용한 전진 모드 자동 미분

전진 모드 자동 미분을 적용하는 좋은 한가지 방법은 이원수)를 사용하는 것입니다. 간단하게 말하면 이원수 $z$는 $z = a + b\epsilon$의 형태를 가집니다. 여기에서 $a$와 $b$는 실수입니다. $\epsilon$은 아주 작은 양수 이지만 모든 실수보다 작기 때문에 $\epsilon^2=0$입니다. $f(x + \epsilon) = f(x) + \dfrac{\partial f}{\partial x}\epsilon$로 쓸 수 있으므로, $f(x + \epsilon)$를 계산하여 $f(x)$와 $x$에 대한 $f$의 편도 함수를 구할 수 있습니다.

이원수는 자체적인 산술 규칙을 가집니다. 일반적으로 매우 직관적입니다. 예를 들면:

덧셈

$(a_1 + b_1\epsilon) + (a_2 + b_2\epsilon) = (a_1 + a_2) + (b_1 + b_2)\epsilon$

뺄셈

$(a_1 + b_1\epsilon) - (a_2 + b_2\epsilon) = (a_1 - a_2) + (b_1 - b_2)\epsilon$

곱셈

$(a_1 + b_1\epsilon) \times (a_2 + b_2\epsilon) = (a_1 a_2) + (a_1 b_2 + a_2 b_1)\epsilon + b_1 b_2\epsilon^2 = (a_1 a_2) + (a_1b_2 + a_2b_1)\epsilon$

나눗셈

$\dfrac{a_1 + b_1\epsilon}{a_2 + b_2\epsilon} = \dfrac{a_1 + b_1\epsilon}{a_2 + b_2\epsilon} \cdot \dfrac{a_2 - b_2\epsilon}{a_2 - b_2\epsilon} = \dfrac{a_1 a_2 + (b_1 a_2 - a_1 b_2)\epsilon - b_1 b_2\epsilon^2}{{a_2}^2 + (a_2 b_2 - a_2 b_2)\epsilon - {b_2}^2\epsilon} = \dfrac{a_1}{a_2} + \dfrac{a_1 b_2 - b_1 a_2}{{a_2}^2}\epsilon$

거듭제곱

$(a + b\epsilon)^n = a^n + (n a^{n-1}b)\epsilon$

등.

이원수를 표현할 클래스를 만들고 몇 개의 연산(덧셈과 곱셈)을 구현해 보죠. 필요하면 다른 연산을 더 추가해도 됩니다.

In [20]:
class DualNumber(object):
    def __init__(self, value=0.0, eps=0.0):
        self.value = value
        self.eps = eps
    def __add__(self, b):
        return DualNumber(self.value + self.to_dual(b).value,
                          self.eps + self.to_dual(b).eps)
    def __radd__(self, a):
        return self.to_dual(a).__add__(self)
    def __mul__(self, b):
        return DualNumber(self.value * self.to_dual(b).value,
                          self.eps * self.to_dual(b).value + self.value * self.to_dual(b).eps)
    def __rmul__(self, a):
        return self.to_dual(a).__mul__(self)
    def __str__(self):
        if self.eps:
            return "{:.1f} + {:.1f}ε".format(self.value, self.eps)
        else:
            return "{:.1f}".format(self.value)
    def __repr__(self):
        return str(self)
    @classmethod
    def to_dual(cls, n):
        if hasattr(n, "value"):
            return n
        else:
            return cls(n)

$3 + (3 + 4 \epsilon) = 6 + 4\epsilon$

In [21]:
3 + DualNumber(3, 4)
Out[21]:
6.0 + 4.0ε

$(3 + 4ε)\times(5 + 7ε)$ = $3 \times 5 + 3 \times 7ε + 4ε \times 5 + 4ε \times 7ε$ = $15 + 21ε + 20ε + 28ε^2$ = $15 + 41ε + 28 \times 0$ = $15 + 41ε$

In [22]:
DualNumber(3, 4) * DualNumber(5, 7)
Out[22]:
15.0 + 41.0ε

이제 이원수가 우리가 만든 계산 프레임워크와 함께 쓸 수 있는지 확인해 보죠:

In [23]:
x.value = DualNumber(3.0)
y.value = DualNumber(4.0)

f.evaluate()
Out[23]:
42.0

오, 잘 되네요. 이를 사용해 x=3이고 y=4에서 $x$와 $y$에 대한 $f$의 편도 함수를 계산해 보겠습니다:

In [24]:
x.value = DualNumber(3.0, 1.0)  # 3 + ε
y.value = DualNumber(4.0)       # 4

dfdx = f.evaluate().eps

x.value = DualNumber(3.0)       # 3
y.value = DualNumber(4.0, 1.0)  # 4 + ε

dfdy = f.evaluate().eps
In [25]:
dfdx
Out[25]:
24.0
In [26]:
dfdy
Out[26]:
10.0

훌륭합니다! 하지만 이 구현에서는 1차 도함수만 가능합니다. 이제 후진 모드를 살펴 보죠.

후진 모드 자동 미분

우리가 만든 간단한 프레임워크를 수정해서 후진 모드 자동 미분을 추가하겠습니다:

In [27]:
class Const(object):
    def __init__(self, value):
        self.value = value
    def evaluate(self):
        return self.value
    def backpropagate(self, gradient):
        pass
    def __str__(self):
        return str(self.value)

class Var(object):
    def __init__(self, name, init_value=0):
        self.value = init_value
        self.name = name
        self.gradient = 0
    def evaluate(self):
        return self.value
    def backpropagate(self, gradient):
        self.gradient += gradient
    def __str__(self):
        return self.name

class BinaryOperator(object):
    def __init__(self, a, b):
        self.a = a
        self.b = b

class Add(BinaryOperator):
    def evaluate(self):
        self.value = self.a.evaluate() + self.b.evaluate()
        return self.value
    def backpropagate(self, gradient):
        self.a.backpropagate(gradient)
        self.b.backpropagate(gradient)
    def __str__(self):
        return "{} + {}".format(self.a, self.b)

class Mul(BinaryOperator):
    def evaluate(self):
        self.value = self.a.evaluate() * self.b.evaluate()
        return self.value
    def backpropagate(self, gradient):
        self.a.backpropagate(gradient * self.b.value)
        self.b.backpropagate(gradient * self.a.value)
    def __str__(self):
        return "({}) * ({})".format(self.a, self.b)
In [28]:
x = Var("x", init_value=3)
y = Var("y", init_value=4)
f = Add(Mul(Mul(x, x), y), Add(y, Const(2))) # f(x,y) = x²y + y + 2

result = f.evaluate()
f.backpropagate(1.0)
In [29]:
print(f)
((x) * (x)) * (y) + y + 2
In [30]:
result
Out[30]:
42
In [31]:
x.gradient
Out[31]:
24.0
In [32]:
y.gradient
Out[32]:
10.0

여기에서도 이 구현의 출력이 숫자이고 기호 표현(symbolic expressions)이 아니므로 1차 도함수로 제한이 됩니다. 그러나 값 대신 기호 표현을 반환하는 backpropagate() 메서드를 만들 수 있습니다. 이렇게 하면 2차 도함수(또 그 이상)를 계산할 수 있습니다. 이것이 텐서플로와 자동 미분을 구현한 모든 주요 딥러닝 라이브러리들의 방식입니다.

텐서플로를 사용한 후진 모드 자동 미분

In [33]:
import tensorflow as tf
In [34]:
x = tf.Variable(3.)
y = tf.Variable(4.)

with tf.GradientTape() as tape:
    f = x*x*y + y + 2

jacobians = tape.gradient(f, [x, y])
jacobians
Out[34]:
[<tf.Tensor: shape=(), dtype=float32, numpy=24.0>,
 <tf.Tensor: shape=(), dtype=float32, numpy=10.0>]

전부 기호이기 때문에 2차 도함수와 그 이상도 계산할 수 있습니다.

In [35]:
x = tf.Variable(3.)
y = tf.Variable(4.)

with tf.GradientTape(persistent=True) as tape:
    f = x*x*y + y + 2
    df_dx, df_dy = tape.gradient(f, [x, y])

d2f_d2x, d2f_dydx = tape.gradient(df_dx, [x, y])
d2f_dxdy, d2f_d2y = tape.gradient(df_dy, [x, y])
del tape

hessians = [[d2f_d2x, d2f_dydx], [d2f_dxdy, d2f_d2y]]
hessians
WARNING:tensorflow:Calling GradientTape.gradient on a persistent tape inside its context is significantly less efficient than calling it outside the context (it causes the gradient ops to be recorded on the tape, leading to increased CPU and memory usage). Only call GradientTape.gradient inside the context if you actually want to trace the gradient in order to compute higher order derivatives.
Out[35]:
[[<tf.Tensor: shape=(), dtype=float32, numpy=8.0>,
  <tf.Tensor: shape=(), dtype=float32, numpy=6.0>],
 [<tf.Tensor: shape=(), dtype=float32, numpy=6.0>, None]]

그러나 의존하지 않는 변수에 대한 텐서의 도함수를 계산할 때, grandients() 함수가 0.0 대신 None을 반환합니다.

여기까지 해서 마치도록 하겠습니다! 이 노트북이 맘에 드시길 바랄께요.