본문 바로가기

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

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

반응형

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

 

출처: Yes24


저번 포스팅까지 걸쳐서 순수하게 Python, Numpy 만을 활용해서 미분을 자동으로 계산하는 방법에 대해 알아보고 코드로 구현까지 했다. 하지만 지금까지 배운 방법은 약간의 제약성이 존재한다. 바로 입력 또는 출력 변수가 여러개가 되는 경우에는 대응하지를 못한다.

 

이번 포스팅에서는 가변적인 길이의 입력, 출력 변수에도 대응이 가능하고 더 나아가 일직선 계산 그래프 형태가 아닌 분기, 결합이 추가된 복잡한 계산 그래프 형태에 대해서도 미분을 자동적으로 계산할 수 있도록 기존에 배운 DeZero 코드를 확장해나가 볼 것이다. 그리고 더 나아가 이렇게 직접 만든 코드를 다른 사람들도 쓸 수 있도록 파이썬 패키지로 묶어보는 방법에 대해서도 배울 것이다. 앞으로 2개의 포스팅에 걸쳐 배울 내용은 책 목차의 '제 2고지'이다.

1. 순전파 시, 가변 길이 처리하기

먼저 순전파를 수행할 때, 입력 개수가 여러개일 경우에도 대응할 수 있도록 코드를 확장시켜보자. 함수(연산) 종류에 따라 필요한 인수(피연산자) 개수가 달라지는데, 인수가 1개만 필요한 대표적인 함수는 제곱 함수이다. 그리고 인수가 2개가 필요한 대표적인 함수로는 덧셈, 뺄셈, 곱셈, 나눗셈 등이 있다. 아래와 같은 경우다.

 

입력 인수가 무조건 2개 필요한 연산인 덧셈, 곱셈

 

반대로 출력 개수가 여러개일 경우도 있다. 바로 아래처럼 분기되는 경우가 있을 수 있다.

 

출력 인수가 2개일 경우도 존재하는 분기 함수

 

위와 같은 경우들에 대응하기 위해서 우리는 기존에 배웠던 Function 클래스를 수정해야 한다. 먼저 기존에 배운 Function 클래스 코드부터 살펴보자. 참고로 아래 코드의 Variable 클래스도 저번에 배웠던 Variable 클래스를 의미한다.

 

# 기존 Function 클래스
class Function:
    def __call__(self, input):
        x = input.data
        y = self.forward(x)
        output = Variable(as_array(y))
        output.set_creator(self)
        self.input = input
        self.output = output
        
        return output
    
    def forward(self, x):
        raise NotImplementedError("This method should be called in other function class")
        
    def backward(self, gy):
        raise NotImplementedError("This method should be called in other function class")
        
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x

 

그리고 2개 이상의 입력변수들을 리스트로 받는다고 가정하고 아래처럼 코드를 확장시킬 수 있다.

 

# 개선된 Function 클래스
class Function:
    def __call__(self, inputs: list):
        xs = [x.data for x in inputs]
        ys = self.forward(xs)
        #====== 추가된 부분 ======#
        outputs = [Variable(as_array(y)) for y in ys]
        for output in outputs:
            output.set_creator(self)
        #======================#
        self.inputs = inputs
        self.outputs = outputs
        return outputs
        
    def forward(self, xs):
        raise NotImplementedError("This method should be called in other function class")
        
    def backward(self, gys):
        raise NotImplementedError("This method should be called in other function class")
        
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x

 

위의 추가된 부분 주석을 보면 리스트로 받은 입력변수들을 처리하는 것을 볼 수 있다. 그리고 생성된 출력변수들도 리스트로 바꾸게 되는데, 이 때 해주어야 할 부분은 출력변수가 여러개가 되었을 수 있으므로 해당 출력변수들 하나하나 마다 창조자 함수를 설정해주도록 해야 한다. 이렇게 개선된 Function 클래스를 상속받아 덧셈 기능을 하는 Add 클래스를 만들어보자.

 

# Add 함수 클래스 만들어보기
class Add(Function):
    def forward(self, xs):
        x0, x1 = xs
        y = x0 + x1
        return (y,)  # 출력은 튜플 형태로!
    
def add(xs):
    return Add()(xs)
    
xs = [Variable(np.array(2)), Variable(np.array(5))]
ys = add(xs)
y = ys[0]
print(y.data)

 

그런데 약간 불편한 점이 있다. 우리는 앞으로 입력변수를 정의해줄 때면 위처럼 2개 이상의 입력 변수들을 쭉 나열하고 또 이를 리스트([])로 감싸주고 해야 한다. 그래서 우리는 사용자 편의적 측면에서 아래처럼 개선할 필요가 있다.

 

왼쪽은 사용자 입장에서 불편하다!

 

오른쪽 코드를 보니 Tensorflow 1.x 버전 때의 API 모습이 문득 생각난다. 아마 Tensorflow 1.x를 만든 개발자들도 위와 같은 불편한 느낌(?)을 느꼈을 것이다.

 

아득한 Tensorflow 1.x 버전 API 모습..

 

이제 다시 한 번 Function 클래스를 개선시켜보도록 하자. 이 때 우리가 사용할 방법은 함수 인수 앞에 별표(*)를 붙여주는 것이다. 이는 임의 개수의 인수 즉, 가변 길이 인수를 건네서 함수를 호출시킬 수 있다는 것을 의미한다. 예를 들어 아래처럼 말이다.

 

def func(*inputs):
    res = inputs
    print(res)
    
func(1,2,3)  # (1, 2, 3) 출력됨

 

그래서 한번 더 개선한 Function 클래스 코드는 아래와 같다.

 

class Function:
    def __call__(self, *inputs):
        """
        Args:
            inputs: [Variable(..), Variable(..), ...]
        """
        xs = [x.data for x in inputs]
        #==== 추가된 부분 ====#
        ys = self.forward(*xs)
        if not isinstance(ys, tuple):
            ys = (ys,)
        #==================#
        outputs = [Variable(as_array(y)) for y in ys]
        
        for output in outputs:
            output.set_creator(self)
        
        self.inputs = inputs
        self.outputs = outputs
        return outputs if len(outputs) > 1 else outputs[0] # 변경된 부분

    def forward(self, xs):
        raise NotImplementedError("This method should be called in other function class")
        
    def backward(self, gys):
        raise NotImplementedError("This method should be called in other function class")

class Add(Function):
    def forward(self, x0, x1): # 변경된 부분
        y = x0 + x1
        return y
    
def add(x0, x1):
    return Add()(x0, x1)

 

위에서 개선된 코드는 크게 2가지다. 먼저 Function 클래스의 __call__ 이라는 매직 메서드를 호출할 때, *를 활용해서 가변 길이의 인자를 넣어줄 수 있도록 했다. 이렇게 함으로써 사용자는 2개 이상의 입력 변수들을 리스트같은 자료구조로 감싸줄 필요가 없게 되었다. 그리고 혹시라도 출력변수가 1개일 경우 반환할 때는 단일 값만 반환하도록 수정했다.

 

또 추가적으로 개선된 부분은 주석의 '추가된 부분'이라고 적혀있는 부분이다. 바로 특정 함수의 순전파 forward 메소드를 호출할 때도 *를 활용해 언패킹을 수행해주는 것이다. 이렇게 하게 되면 예를 들어, 덧셈 기능을 하는 Add 클래스의 forward 메소드를 호출할 때 편의성이 개선된다. 그래서 위 2가지 사항을 개선한 후 덧셈 연산 기능을 하는 API는 다음과 같이 변경된다.

 

# 개선하기 전 Function 클래스를 사용할 경우 API
xs = [Variable(np.array(1)), Variable(np.array(10))]
ys = add(xs)
y = ys[0]
print(y.data)

# 개선한 후 Function 클래스를 사용할 경우 API
x0 = Variable(np.array(1))
x1 = Variable(np.array(10))
y = add(x0, x1)
print(y.data)

2. 역전파 시, 가변 길이 처리하기

다음은 역전파를 수행할 때의 가변 길이 인수를 처리하는 방법이다. 역전파를 처리할 때는 우리가 만들어왔던 클래스들 중 Variable 클래스를 수정해야 한다. 먼저 기존의 Variable 클래스를 살펴보자.

 

# 기존 클래스
class Variable:
    def __init__(self, data):
        if self.data is not None:
            if not isinstance(self.data, np.ndarray):
                raise TypeError(f"{type(self.data)} dtype is not supported!")
                
        self.data = data
        self.grad = None
        self.creator = None
        
    def set_creator(self, func):
        self.creator = func
        
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
        
        funcs = [self.creator]
        while funcs:
            f = funcs.pop()
            x, y = f.input, f.output
            x.grad = f.backward(y.grad)
            
            if x.creator is not None:
                funcs.append(x.creator)

 

그러면 아래의 개선된 Variable 클래스 코드를 살펴보자.

 

# 변경된 클래스
class Variable:
    def __init__(self, data):
        if data is not None:
            if not isinstance(data, np.ndarray):
                raise TypeError(f"{type(data)} dtype is not supported!")
                
        self.data = data
        self.grad = None
        self.creator = None
        
    def set_creator(self, func):
        self.creator = func
        
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
            
        funcs = [self.creator]
        while funcs:
            f = funcs.pop()
            #======= 아래가 변경 및 추가된 부분 =======#
            # 여러개 출력의 기울기값을 리스트에 담기
            gys = [output.grad for output in f.outputs]
            gxs = f.backward(*gys) # 역전파 기울기 계산
            # 계산되어 반환된 기울기값을 튜플로 변환
            if not isinstance(gxs, tuple):
                gxs = (gxs,)
            
            # 각 변수에 대응하는 미분값 갱신
            for x, gx in zip(f.inputs, gxs):
                x.grad = gx
                
                if x.creator is not None:
                    funcs.append(x.creator)

 

수정도니 부분은 크게 세가지이다. 첫 번째는 출력이 여러개이기 때문에 list comprehension을 통해서 각 출력의 기울기 값(gradient)을 리스트에 모아놓는다.

 

두 번재는 가져온 창조자 함수의 실질적으로 역전파를 계산하는 backward 메소드를 수행한다. 이 때, 순전파 시 했던 것과 똑같이 언패킹(*)을 활용해 가변 길이 인수를 처리할 수 있도록 만든다. 그리고 역전파  결과값이 단일 값일 경우가 있을 수도 있기 때문에 그럴 경우 튜플 형태로 바꾸어주는 로직도 추가해주었다. 

 

마지막 세번째는 두번째 단계로 얻었던 역전파로 계산된 기울기 값을 이제 각 입력 변수에 맞게 세팅을 해주어야 한다. 그래서 zip 함수를 활용해서 [입력변수 - 그 입력변수의 기울기값] 대응되도록 기울기 값을 갱신해주도록 한다.

 

위 개선된 Variable 클래스를 활용했을 때의 Add 클래스와 Square 클래스를 다시 정의해보자. 참고로 덧셈 함수, 제곱 함수에 대한 역전파 기울기 값 계산은 저번 포스팅에서 알아보았으니 설명은 생략하겠다.

 

# 함수 클래스
class Function:
    def __call__(self, *inputs):
        xs = [x.data for x in inputs]
        ys = self.forward(*xs)
        if not isinstance(ys, tuple):
            ys = (ys,)
        outputs = [Variable(as_array(y)) for y in ys]
        
        # 출력변수를 만든 창조자 함수 기록
        for output in outputs:
            output.set_creator(self)
        
        self.inputs = inputs
        self.outputs = outputs
        return outputs if len(outputs) > 1 else outputs[0]
    
    def forward(self, xs):
        raise NotImplementedError("This method should be called in other function class")
        
    def backward(self, gys):
        raise NotImplementedError("This method should be called in other function class")
        
# 제곱함수 클래스
class Square(Function):
    def forward(self, x):
        return x ** 2
    
    def backward(self, gy):
        x = self.inputs[0].data
        gx = 2 * x * gy
        return gx
    
# 덧셈 클래스
class Add(Function):
    def forward(self, x0, x1):
        y = x0 + x1
        return y
    
    def backward(self, gy):
        return gy, gy
    
# PythonAPI로 변경
def add(x0, x1):
    return Add()(x0, x1)

def square(x):
    return Square()(x)

x = Variable(np.array(2.0))
y = Variable(np.array(3.0))
# 순전파 수행
z = add(square(x), square(y))
# 역전파 수행
z.backward()
# 각 변수의 기울기값 출력
print(z.grad)
print(z.data)
print(x.grad)
print(y.grad)

3. 같은 변수의 반복 사용 문제

2번 목차까지 해서 순전파, 역전파 시 가변 길이 인수를 처리하도록 확장하는 방법을 배워보았다. 하지만 위 코드는 아직 2개의 문제점이 존재한다. 먼저 동일한 변수를 사용해서 덧셈을 수행하면 제대로 미분을 하지 못한다는 것이다. 아래 계산 그래프를 위 코드로 구현했을 때 $x$ 변수의 기울기 값이 어떻게 나오는지 살펴보자.

 

동일한 변수를 사용해서 덧셈을 수행

 

import numpy as np

x = Variable(np.array(3.0))
y = add(x, x)
print('y:', y.data)

y.backward()
print('x 변수의 gradient:', x.grad)

 

위 코드는 우리가 $y = x + x$ 가 있을 때, $y$의 미분값을 구하고자 하는 것이다. 고등학교 시절 배운 수학 지식을 적용해서 $y'$ 을 계산하게 되면 $2$라는 값이 나온다는 걸 알 수 있다.(이렇게 미분을 계산하는 것을 해석적인 미분방법이라고도 한다)

 

그런데 위 코드를 수행해보면 결과값이 $1$로 나오게 된다. 대체 왜그럴까? 이유는 바로 개선된 Variable 클래스에서 역전파 후 입력변수의 기울기 값을 업데이트해줄 때 작성한 코드 때문이다. 

 

class Variable:
	...(생략)...
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
            
        funcs = [self.creator]
        while funcs:
            ...(생략)...
            # 각 변수에 대응하는 미분값 갱신
            for x, gx in zip(f.inputs, gxs):
                x.grad = gx   #====> 바로 이부분 때문에 오류 발생!
                
                if x.creator is not None:
                    funcs.append(x.creator)

 

위 코드를 개선하려면 아래 처럼 코드를 수정하면 된다.

 

# 동일한 변수 관련 오류를 제거한 개선된 Variable 클래스
class Variable:
	...(생략)...
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
            
        funcs = [self.creator]
        while funcs:
            ...(생략)...
            # 각 변수에 대응하는 미분값 갱신
            for x, gx in zip(f.inputs, gxs):
                if x.grad is None:
                    x.grad = gx
                else:
                    x.grad = x.grad + gx
                
                if x.creator is not None:
                    funcs.append(x.creator)

 

즉, 최초에 해당 입력변수의 기울기 값을 먼저 확인한다. 그리고 그 기을기 값이 None 이라는 것은 아직 기울기가 한번도 갱신되지 않았다는 것을 의미한다. 따라서 이럴 때는 그냥 바로 기울기 값으로 설정해준다. 하지만 만약 그렇지 않다면 이미 갱신되어 있는 기울기 값에다가 더해주는 로직을 추가한다.

 

그런데 여기서 Python에 능숙하신 분들이 약간의 응용을 해보시고자 else 구문에서 아래와 같은 축약한 인플레이스 연산 코드로 바꿔줄 수도 있다.

 

# 동일한 변수 관련 오류를 제거한 개선된 Variable 클래스
class Variable:
	...(생략)...
    def backward(self):
        ...(생략)...
            for x, gx in zip(f.inputs, gxs):
                if x.grad is None:
                    x.grad = gx
                else:
                    x.grad += gx  # 덧셈 축약형 코드로 변환하면 되지 않을까!?
                
                if x.creator is not None:
                    funcs.append(x.creator)

 

그런데 이렇게 하면 연산 오류가 발생한다. 이 이유를 이해하기 위해서는 바로 복사(copy)와 덮어쓰기(overwrite) 차이를 알아야한다. 이는 메모리와 관련되어 있는데, 아래 간단한 코드로 메모리 결과값을 살펴보자.

 

import numpy as np

a = np.array(5)
print(id(a))  # 140457002131792

a += a
print(id(a))  # 140457002131792(덮어쓰기)

a = a + a
print(id(a))  # 140456948425456(복사)

 

축약형 연산인 += 를 인플레이스 연산이라고 하는데, 이렇게 할 때는 메모리 주소가 동일한 것을 볼 수 있다. 즉, 기존 변수 a에 덮어쓰기가 된 것이며 메모리의 값을 직접 덮어쓰게 된다. 물론 이런 인플레이스 연산이 그렇지 않은 연산보다 메모리 효율적인 측면에서는 장점이 있지만 우리가 원하는 연산의 결과가 인플레이스 연산으로 달라진다면 그 때는 인플레이스 연산을 사용해서는 안된다.

 

간단하게 예시를 들어보자, $y = x + x$ 를 미분한다고 해보자. 역전파를 수행할 때 $y$의 기울기값은 1일 것이다. 그리고 이제 $x$의 기울기 값을 2로 구했고 이를 업데이트해주어야 하는데, 인플레이스 연산을 사용하게 되면 1인 $y$의 기울기값까지 2로 변경된다. 따라서 이렇게 되면 역전파 시 각 변수에 대응되는 기울기 값을 저장할 수 없게 된다.

 

두 번째 문제는 같은 변수를 사용해서 서로 다른 미분 계산을 수행할 때이다. 예를 들어, 아래와 같은 두 번의 서로 다른 미분 계산을 수행한다고 해보자.

 

import numpy as np

x = Variable(np.array(3.0))
y = add(x, x)
y.backward()
print(x.grad)  # 2.0

print('-'*50)

y = add(add(x, x), x)
y.backward()
print(x.grad)  # 3.0이 나와야 하는데 5.0이 나옴

 

즉, 두 번째에 수행해야 하는 미분 계산에서 첫 번째에서 얻어진 기울기값을 가져가 사용한다는 것이다. 그래서 우리는 이를 해결하기 위해 중간에 기울기 값을 초기화시키는 cleargrad 라는 메소드를 추가해주면 간단하다.

 

# 미분 초기화 메소드가 추가된 Variable 클래스
class Variable:
    def __init__(self, data):
        ...(생략)...
        
    def set_creator(self, func):
        self.creator = func
        
    def cleargrad(self):
        self.grad = None
        
    def backward(self):
        ...(생략)...

 

위 cleargrad 라는 메소드를 활용하면 이제 여러가지 미분을 연달아 계산할 때 공통으로 사용하는 변수를 계속 재사용할 수 있게 된다. 뿐만 아니라 추후에 배울 예정인 손실함수의 최대/최소값을 찾는 최적화 문제에 유용하게 이용된다고 한다.

4. 복잡한 계산 그래프미분을 자동 계산해보자!(이론)

4-1. 복잡한 계산 그래프의 역전파 시 미분 순서

지금까지 우리는 아래처럼 일직선의 형태로 되어 있는 계산 그래프에 대해서만 미분 계산을 자동화시켜왔다.

 

일직선 형태의 계산 그래프

 

하지만 우리가 실제 상황에서 맞닥뜨리는 딥러닝 모델의 계산 그래프는 위처럼 그리 간단하지 않다. 적어도 아래처럼 분기, 결합과 같은 요소들이 여러개가 추가된 계산 그래프의 미분 계산을 자동화시켜야 한다.

 

복잡한 형태의 계산 그래프

 

단적인 예로, $tanh(x)$의 4차 미분 계산 그래프 형태는 아래와 같다고 한다..

 

$tanh(x)$의 4차 미분 계산 그래프

 

그러면 이제 본격적으로 복잡한 계산 그래프의 미분 계산을 자동화하는 핵심에 대해 알아보자. 먼저 아래와 같은 계산 그래프의 역전파를 구현하려면 어떤 순서로 미분을 계산해야 하는지 살펴보자.

 

$a$ 변수에 대한 미분을 수행하려면 두 개의 미분값이 모두 전파받아야 함

 

가장 주목해야 할 부분은 변수 $a$에 대한 미분을 계산해야 할 때이다. 보다시피 순전파 시에 $a$ 변수 이후에 두 갈래로 분기되었다. 따라서 역전파 시에도 두 갈래 분기된 곳으로부터 미분값 두 개를 받아와서 더해주어야 한다. 결국 역전파 시 미분을 계산하는 순서를 나타내면 아래와 같다.

 

계산 그래프에서의 역전파 시 미분 계산 순서

 

위 순서를 보면 알다시피 $a$ 변수에 대한 미분값을 구하기 위해서는 함수 B, C에 대한 미분이 모두 수행된 후에야 가능하다. 그런데 우리가 지금까지 구현했던 DeZero의 미분 계산 코드는 이런 순서를 구현해내지 못한다. 왜냐하면 현재 DeZero의 미분 계산 코드를 적용하게 되면 역전파 시 미분 계산 순서가 아래처럼 바뀌기 때문이다.

 

현재 DeZero 코드로 적용하면 발생하는 미분 계산 순서

 

빨간색 네모칸을 보다시피 미분 계산이 2번으로 중복으로 이루어지기 때문에 결국 기울기 값에서의 오차가 발생하게 된다.

4-2. 함수와 변수에 우선순위를 부여하기

그러면 우리는 어떤 방법으로 위와 같은 문제를 해결할 수 있을까? 가장 먼저 떠올릴 수 있는 방법은 그래프 알고리즘 종류 중 하나이면서 노드 간의 선후관계를 고려하며 정렬할 수 있는 위상 정렬 알고리즘을 활용할 수 있다.(위상 정렬에 대해서는 알고리즘 카테고리에 자세히 설명된 포스팅이 있으니 궁금하다면 참조하도록 하자)

 

두 번째 방법도 존재하는데, 이 방법이 우리가 사용할 방법이다. 이전 포스팅에서 우리는 DeZero의 핵심 철학이라고 하면서 변수와 함수 간의 관계를 파악해보았고, [함수(부모) ↔️ 변수(자식] 이라는 관계가 형성된다는 것을 배웠다. 바로 이를 이용해서 함수와 변수의 '세대(generation)'를 기록해 함수와 변수에 순위를 부여할 수 있는 것이다! 먼저 아래 그림을 보자.

 

오른쪽으로 갈수록 높은 세대 즉, 자식세대를 의미

 

위 그림에서 기억해야 할 부분은 바로 입력변수와 그 입력변수를 넣는 함수에 같은 세대를 부여한다는 점이다. 이렇게 세대를 부여하게 되면 역전파 시 세대 수가 큰 쪽부터 미분 계산을 처리할 수 있게 되고 결국 복잡한 계산 그래프의 역전파 미분 계산도 처리가 가능해진다. 그러면이제 이를 코드로 구현해 확장해보도록 하자.

5. 복잡한 계산 그래프미분을 자동 계산해보자!(구현)

5-1. 함수와 변수에 세대를 부여하자

가장 먼저해야 할 부분은 함수와 변수에 일종의 순위로 '세대' 값을 부여해야 한다. 따라서 Variable 클래스에 인스턴스 변수로서 세대를 의미하는 generation 값을 0으로 초기화시키도록 하자.

 

class Variable:
    def __init__(self, data):
        if data is not None:
            if not isinstance(data, np.ndarray):
                raise TypeError(f"{type(data)} dtype is not supported")
        
        self.data = data
        self.grad = None
        self.creator = None
        self.generation = 0   # 변수에 세대 값을 추가!
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1   # 함수에 세대값을 추가
        
    def cleargrad(self):
        self.grad = None
        
    def backward(self):
        ...(생략)...

def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x

 

수정된 부분은 위 코드에서 주석 부분을 보면 알 수 있다. 방금 위에서 입력 변수와 그 입력변수가 들어가는 함수가 같은 세대여야 한다고 했다. 이 말은 곧 입력변수가 들어가는 함수와 함수가 만들어낸 출력변수의 세대값은 1세대 차이라는 것을 알 수 있다. 따라서 set_creator 메소드에 해당 함수의 세대값에 +1 해주도록 한다. 그리고 이 set_creator 메소드는 아래의 Function 클래스의 __call__ 매직 메소드 안에서 각 출력변수마다 적용해준다.

 

class Function:
    def __call__(self, *inputs):
        # 순전파 수행
        xs = [x.data for x in inputs]
        ys = self.forward(*xs)
        if not isinstance(ys, tuple):
            ys = (ys,)
        outputs = [Variable(as_array(y)) for y in ys]
        self.inputs = inputs
        self.outputs = outputs
        
        #ex) A함수 세대값은 그 함수의 입력 변수들의 가장 큰 세대수로 설정
        self.generation = max([x.generation for x in inputs])
        
        #ex) A함수가 내뱉은 출력변수의 세대값은 출력변수를 만든 A함수의 세대수 + 1로 설정!
        for output in outputs:
            output.set_creator(self)
            
        return outputs if len(outputs) > 1 else outputs[0]
    
    def forward(self, xs):
        raise NotImplementedError("This method should be called in other function class")
        
    def backward(self, gy):
        raise NotImplementedError("This method should be called in other function class")

5-2. 역전파 시, 큰 세대 부터 꺼내어 미분을 수행하자

이제 순전파를 수행할 때, 변수와 함수간에 세대를 부여하도록 했다. 그러면 이제 역전파를 수행할 때, 미리 기록해놓았던 세대를 기준으로 큰 세대부터 꺼내서 역전파를 수행시켜보도록 하자. 일단 지금까지 변수와 함수에 세대를 부여한 계산 그래프를 그림으로 표시하면 아래와 같다.

 

계산 그래프에 세대를 반영했다

 

그러면 큰 세대의 변수와 함수부터 꺼내도록 하기 위해서는 어떤 자료구조를 활용할 수 있을까? 가장 먼저 Python의 sort 메소드를 활용하는데, 그 때 key 인자에 세대값을 부여하여 세대값 기준으로 매번 정렬되도록 할 수 있다. 두 번째로는 정렬 알고리즘 종류 중 하나인 우선순위 큐를 활용할 수 있다. 먼저 sort 메소드를 활용한 방법부터 알아보도록하자. 책에서는 우선순위 큐 활용한 코드는 제공하지 않지만 필자가 직접 작성해 구현해보았다.

5-2-1. Python sort() 사용하기

가장 먼저 수정해야 할 부분은 Variable 클래스의 backward 메소드 부분이다. 

 

class Variable:
    ...(생략)...
        
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
        
        funcs = []
        seen_sets = set()
        
        def add_func(f):
            if f not in seen_sets:
                funcs.append(f)
                seen_sets.add(f)
                funcs.sort(key=lambda x: x.generation) # 해당 함수의 세대가 큰 순서대로 정렬
        
        add_func(self.creator)
        
        while funcs:
            f = funcs.pop()
            # 각 출력변수의 기울기 가져와서 역전파 미분 수행
            gys = [output.grad for output in f.outputs]
            gxs = f.backward(*gys)
            if not isinstance(gxs, tuple):
                gxs = (gxs,)
            
            # 각 입력변수에다가 기울기 설정
            for x, gx in zip(f.inputs, gxs):
                if x.grad is None:
                    x.grad = gx
                else:
                    x.grad = x.grad + gx
                    
                # 각 입력변수의 창조자 함수 append
                if x.creator is not None:
                    add_func(x.creator)

 

위 코드에서 크게 수정된 부분은 add_func() 이라는 중헙참수를 추가한 부분이다. 해당 함수는 창조자 함수 리스트를 세대 순으로 정렬하는 역할을 한다. 단, seen_sets 라는 집합 자료구조를 활용해서 동일한 함수(여기서 동일한 함수같은 연산을 하는 것 뿐만 아니라 그 함수의 메모리 id 값까지 모두 동일해야 '동일하다'라고 취급함을 의미)가 복수로 추가되는 일을 막도록 했다.

 

참고로 특정 메서드 안에 중첩함수를 정의해야 하기 위해서는 2가지 상황이 있다고 하는데, 첫째는 감싸는 메서드 안에서만 이용한다는 점, 둘째는 감싸는 메서드에 정의된 변수를 사용해야만 하는 점 이다.

5-2-2. 우선순위 큐(heapq) 사용하기

다음은 우선순위큐를 사용한 예제 코드이다. 예전 알고리즘 공부를 하면서 우선순위큐 즉, heapq 라이브러리를 활용해 정렬을 하는 방법에 대해 배웠었다. 그래서 그 때 배웠던 지식을 활용해서 필자가 직접 구현해보고 Python sort()를 활용했을 때와 결과가 동일한지도 확인했다.(만약 우선순위 큐 개념에 대해 모른다면 해당 포스팅을 참조하도록 하자)

 

우선순위 큐는 힙 자료구조를 활용해서 최소힙(오름차순 정렬), 최대힙(내림차순 정렬) 두 가지 유형으로 구현할 수 있는데, 지금 여기서 우리는 '큰 세대'부터 꺼내야 하기 때문에 최대힙으로 구현해야 한다. 왜냐하면 일반 리스트의 같은 경우 pop() 메소드는 가장 마지막 인덱스에 있는 값을 꺼내지만, 우선순위 큐의 경우 heappop() 메소드는 가장 첫번재 인덱스에 있는 값을 꺼내도록 되어 있기 때문에 '최대 힙'으로 정렬시키도록 구현해야 한다.

 

import numpy as np
import heapq

# 변수 클래스
class Variable:
    ...(생략)...
    def backward(self):
        if self.grad is None:
            self.grad = np.ones_like(self.data)
            
        funcs = []
        seen_sets = set()
        flag = 0
        
        def add_func(f):
            if f not in seen_sets:
                heapq.heappush(funcs, (-f.generation, flag, f))
                seen_sets.add(f)
        
        add_func(self.creator)

        while funcs:
            _, _, f = heapq.heappop(funcs)
            gys = [output.grad for output in f.outputs]
            gxs = f.backward(*gys)
            if not isinstance(gxs, tuple):
                gxs = (gxs,)
            
            for x, gx in zip(f.inputs, gxs):
                if x.grad is None:
                    x.grad = gx
                else:
                    x.grad = x.grad + gx
                    
                if x.creator is not None:
                    add_func(x.creator)
                    flag += 1
            flag = 0

 

필자는 최대힙을 적용할 때, 한 가지 난관(?)에 봉착한 것이 있었다. 처음에는 힙 자료구조에다가 (함수의 세대값, 함수 클래스) 튜플 형태로 넣어주고 있었다. 우선순위큐는 첫 번째 튜플의 값 기준으로 정렬을 하게 된다. 그런데 이렇게 구현하면 같은 세대에 2개 이상 입력이 있게 되면 함수의 세대값은 같기 때문에 정렬할 다음 기준으로 '함수 클래스'를 사용하게 됨에 따라 TypeError가 발생하게 된다.

 

따라서 추가적으로 flag 라는 변수를 두어 이 변수가 같은 세대에 있는 2개 이상 입력에 대해서 일종의 추가 순위를 두게 했다. 물론 같은 세대에서는 어떤 순서대로 정렬되도 상관없기 때문에 일부러 TypeError을 막게 하기 위한 일종의 트릭이라고 할 수 있겠다.


저번 포스팅과 이번 포스팅을 거치면서 개선된 최종 Variable, Function, Add, Square 클래스를 개인 Github 계정에다가 py 파일로 한번에 정리를 해두었다. 정리된 최종 클래스들을 참고하려면 여기를 살펴보도록 하자.

 

이렇게까지 해서 우리는 DeZero 프레임워크를 복잡한 계산 그래프에도 미분을 자동 계산할 수 있도록 만들어주게 되었다. 다음 포스팅은 제 2고지 나머지 부분에서는 지금 우리가 확장시킨 DeZero 프레임워크의 성능 특히, 메모리 사용량에 대해 개선해보고 파이선 패키지로 묶는 것 까지 발전시켜보도록 하자!

반응형