본문 바로가기

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

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

반응형

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

 

출처: Yes24


저번 포스팅까지 해서 일직선의 계산 그래프 형태만이 아닌 더 복잡한 계산 그래프 형태도 자동미분할 수 있도록 여러가지 클래스들을 개선했었다. 하지만 직전까지 만든 코드들에 대해서도 개선사항들이 존재한다. 먼저 메모리를 절약할 수 있다는 점과 변수 사용성 개선, 연산자 오버로드 등이 있다. 그러면 이제 하나씩 개선시켜보도록 하자. 그 첫번째 내용으로는 메모리를 관리하는 것이다.

1. Python은 메모리를 어떻게 관리하고 있을까?

먼저 해당 목차에서 소개할 메모리 관리의 설명 기준은 파이썬의 표준 인터프리터인 CPython 인터프리터를 기준으로 설명한다. 참고로 파이썬을 실행하는 프로그램을 파이썬 인터프리터라고 한다.

 

파이썬은 필요가 없어진 객체를 메모리에서 자동으로 삭제해준다. 그래서 파이썬 유저들은 알다시피 프로그래밍을 하면서 메모리 관리를 했다는 경험을 자주 해본 사람은 없을 것이다. 하지만 데이터 개수가 커지고 연산 복잡도가 많아지는 딥러닝 모델을 다룰 때면 아무리 자동으로 메모리를 관리해준다고 하는 파이썬일지라도 메모리 관리를 제대로 해주지 않으면 시간이 매우 오래걸리거나 심지어 GPU에서는 실행조차 되지 않는 문제를 야기시킬 수 있다.

 

그러면 파이썬은 내부적으로 메모리를 어떤 방식으로 관리할까? 두 가지 방식이 있는데, 참조(Reference) 수를 세는 '참조 카운트' 방식이 있고, 세대(Generation)를 기준으로 쓸모 없어진 객체(Garbage)를 회수(Collection)하는 Garbage Collection 일명, GC라고 불리는 방식이 있다. 그러면 하나씩 어떤 방법인지에 대해 세부적으로 살펴보자.

1-1. 참조 카운트 방식

참조 카운트 방식의 메모리 관리 기법은 구조가 간단하고 속도가 빠르다. 모든 객체들은 참조 카운트가 0인 상태로 생성되고, 다른 객체가 잠조할 때마다 1씩 증가한다. 이 말이 무슨 말인지 잘 이해가 안갈 수 있다.

 

a = 10 
b = a

 

위와 같이 '10' 이라는 정수 객체를 생성하면서 a 라는 또 다른 객체에 할당했다. 이 때, 10이라는 객체는 최초에 생성되면서 참조 카운트가 0이 되었을 것이다. 그리고 a = 10 이라는 대입 연산자를 사용함에 따라 10이라는 객체는 참조 카운트 1이 증가한다. 즉, a 라는 객체가 10이라는 객체를 참조하고 있다는 뜻이다. 그래서 a = 10 이라는 라인을 실행했을 때의 각 객체의 참조 카운트는 아래와 같다.

 

a = 10  
# a  라는 객체의 참조 카운트 : 1

 

그리고 이제 다음 줄인 b = a 까지 실행되면 참조 카운트가 어떻게 변할까? b에다가 a를 대입했다. 이 의미는 b가 a를 참조하고 있음을 의미한다. 따라서 b = a 까지 실행되었을 때, 각 객체의 참조 카운트는 아래와 같다.

 

a = 10
b = a
# a  라는 객체의 참조 카운트 : 2
# b  라는 객체의 참조 카운트 : 1

 

이렇게 참조할 때마다 참조 카운트가 1씩 늘어난다. 반대로 객체에 대한 참조가 끊길 때마다 1씩 감소하게 된다. 그리고 계속 감소하다가 해당 객체의 참조 카운트가 0이 되면 파이썬 인터프리터가 회수하게 된다. 회수해갔다는 것은 해당 객체가 더 이상 필요 없어져서 메모리에서 삭제되는 것을 의미한다. 그러면 아래 수도코드 예시를 보면서 참조 카운트 방식이 어떻게 동작하는지 다시 이해해보도록 하자. 아래 코드는 수도코드이다.

 

a = Object1()   # a의 참조카운트: 1
b = Object2()   # b의 참조카운트: 1
c = Object3()   # c의 참조카운트: 1

a.b = b        # a가 b를 참조  -> a의 참조카운트: 1 | b의 참조카운트: 2
b.c = c        # b가 c를 참조  -> b의 참조카운트: 2 | c의 참조카운트 : 2

#===== 지금까지 객체 3가지의 참조 카운트 중간 정리 =====#
#  a의 참조카운트: 1 | b의 참조카운트: 2 | c의 참조카운트: 2

a = b = c = None  # 참조 해제 -> a,b,c의 참조카운트 모두 하나씩 감소
                  # a의 참조카운트: 0 | b의 참조카운트: 1 | c의 참조카운트: 1

 

a = b = c = None으로 참조를 끊은 후, 참조 카운트의 변화

 

위 그림은 각 객체의 참조 카운트 변화를 의미한다. 수도코드에 붙어있는 주석을 천천히 읽어보면서 참조 카운트가 어떻게 변하는지 이해해보자.

 

마지막 줄 주석에 보면 a,b,c 객체의 참조 카운트가 최종적으로 각각 0, 1, 1이 되었다. a의 참조 카운트가 0이 되었으니 이제 파이썬 인터프리터가 회수해가서 메모리에서 삭제된다. 그런데 삭제될 예정인 a가 b를 참고하고 있었다. b 입장에서는 자기를 참조하는 객체 a가 삭제되기 때문에 참조 카운트가 감소하게 된다. 그러면 다시 b의 참조 카운트가 0이 되어 곧 메모리에서 사라진다. 그런데 또 이 때, b가 c를 참조하고 있다. c 입장에서도 마찬가지로 자기를 참조하는 객체 b가 메모리에서 사라지기 때문에 c의 참조 카운트도 1이 감소한다. 결국 최종적으로, 연쇄적으로 작용해 3개의 객체 모두 메모리에서 사라지게 된다.

 

이러한 파이썬의 참조 카운트 방식의 메모리 관리 방법은 수많은 메모리 관리 문제를 해결해준다. 하지만 이 방법으로 해결할 수 없는 문제가 있으니 그것이 바로 순환 참조이다.

1-2. 순환참조를 해결하는 GC

위 목차에서 살펴본 코드에서 한 줄이 추가된 아래 코드이다. 아래 객체 간의 참조 관계를 그림으로 나타내면 아래와 같다.

 

a = Object1()   # a의 참조카운트: 1
b = Object2()   # b의 참조카운트: 1
c = Object3()   # c의 참조카운트: 1

a.b = b        # a가 b를 참조  -> a의 참조카운트: 1 | b의 참조카운트: 2
b.c = c        # b가 c를 참조  -> b의 참조카운트: 2 | c의 참조카운트 : 2
# -> 새로 추가!
c.a = a        # c가 a를 참조  -> c의 참조카운트: 2 | a의 참조카운트: 2

#===== 지금까지 객체 3가지의 참조 카운트 중간 정리 =====#
#  a의 참조카운트: 2 | b의 참조카운트: 2 | c의 참조카운트: 2

a = b = c = None  # 참조 해제 -> a,b,c의 참조카운트 모두 하나씩 감소
                  # a의 참조카운트: 1 | b의 참조카운트: 1 | c의 참조카운트: 1

 

a = b = c = None으로 참조를 끊은 후, 참조 카운트의 변화

 

위 수도코드를 모두 실행하면 3개 객체의 참조 카운트가 모두 1이된다. 즉, 여전히 메모리에 남아 있음을 의미한다. 하지만 우리는 수도코드 마지막에 3개 객체에 None을 할당했기 때문에 a,b,c에 할당되어 있던 Object1,2,3에 접근을 할 수 없다. 그러면 결국 해당 객체에는 접근할 수 없지만 메모리에는 여전히 남아있는 비효율적인 상황이 발생한다. 이것을 바로 순환 참조 문제라고 한다.

 

이러한 문제를 해결할 수 있는 방법이 바로 GC 즉, 세대별 가비지 컬렉션이 등장한다. GC의 구조는 너무 복잡해서 책에서는 따로 언급하지 않지만 똑똑한 방법으로 불필요한 객체를 찾아낸다. 이러한 기능을 갖는 GC 방식은 참조 카운트 방식과 달리 메모리가 부족해지는 시점에 파이썬 인터프리터에 의해 자동으로 호출되어서 메모리를 관리한다. 물론 명시적으로 GC 라이브러리를 임포트해서 호출해줄 수도 있다. 

 

이렇게 파이썬이 내부적으로 잘 메모리 관리를 해주지만 아무생각 없이 메모리 관리를 계속 GC에 위탁(?)하다 보면 프로그램 전체의 메모리 사용량이 특히, 순환참조가 없을 때 보다 엄청나게 커지게 된다. 그런데 딥러닝은 알다시피 메모리를 엄청나게 사용한다는 것을 알고 있다. 결국, 파이썬이 자동으로 처리해주는 메모리 기능이 있음에도 불구하고 우리는 이제 앞으로 딥러닝 구현 코드를 작성할 때 순환 참조 상태를 만들어주지 않도록 코드를 작성해나가야 한다.

 

그러면 우리가 지금까지 구현한 코드에서 순환 참조가 발생하는 곳은 어디일까? 바로 Function 와 Variable 클래스 즉, 함수와 변수를 연결하는 방식에서 순환참조가 발생한다.

 

함수와 변수 사이에서 순환 참조 발생

 

위에서 발생하는 순환 참조 문제를 파이썬에서는 weakref 라는 표준 파이썬 모듈로 해결할 수 있다. weakref 모듈의 ref() 라는 함수를 사용해서 약한 참조(Weak Reference)를 만들 수 있다. 약한 참조란, 다른 객체를 참조하되 참조 카운트를 증가시키지 않는 기능이다. 우리가 위에서 순환 참조라는 문제의 근본적인 원인은 '객체 접근할 수는 없는데 참조 카운트가 여전히 0이 아니여서 메모리에 올라가 있는 상태'라고 배웠다. 약한 참조는 이러한 원인을 해결하기 위한 방법이라고 할 수 있다. weakref 모듈을 사용하는 방법에 대해 간단히 알아보자.

 

import numpy as np
import weakref

a = np.array([1,2,3])
b = weakref.ref(a)
print(b)   # <weakref at 0x7f871c045ef0; to 'numpy.ndarray' at 0x7f871ae4dcf0>
print(b()) # [1 2 3]

 

특이한 부분은 weakref의 ref() 함수를 사용해 약한 참조를 했을 경우, 해당 객체 데이터를 출력하려면 call 하는 소괄호() 를 붙여주어야 한다. 자, 이제 그러면 위 상태에서 원본 객체인 a에 대한 참조를 해제해준다면 a를 약한 참조한 b 객체의 메모리는 어떻게 될까?

 

import numpy as np
import weakref

a = np.array([1,2,3])
b = weakref.ref(a)
# 참조 끊기!
a = None
print(b)   # <weakref at 0x7f87dfb25ef0; dead>
print(b()) # None

 

위 코드 결과를 보면 약한 참조를 한 b 객체를 출력해보면 메모리에서도 삭제(dead라고 표시되었음)가 되어있고 b 객체의 데이터도 None이라고 출력되는 것을 볼 수 있다. 즉, 순환참조 문제를 발생시키지 않은 것!

2. 메모리 절약을 Dezero에 적용시켜보자!

그러면 이제 우리가 지금까지 만든 코드(앞으로 이름을 Dezero라고 부른다) Dezero에 약한 참조를 넣어서 순환 참조 문제를 해결해보자. 수정할 코드는 위에서 배운 메모리 개념들에 비해 간단하다.

 

import numpy as np
import heapq
import weakref


class Variable:
    ...(생략)...

    def backward(self, use_heap=False):
        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:
                if not use_heap:
                    funcs.append(f)
                    seen_sets.add(f)
                    funcs.sort(key=lambda x: x.generation)
                else:
                    heapq.heappush(funcs, (-f.generation, flag, f))
                    seen_sets.add(f)

        add_func(self.creator)

        while funcs:
            if not use_heap:
                f = funcs.pop()
            else:
                f = heapq.heappop(funcs)[2]
            gys = [y().grad for y in f.outputs]   # 약한 참조로 변경한 부분
            gxs = f.backward(*gys)
            if not isinstance(gxs, tuple):
                gxs = (gxs,)
            
            ...(생략)...


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.generation = max([x.generation for x in inputs])
        for output in outputs:
            output.set_creator(self)
        self.inputs = inputs
        self.outputs = [weakref.ref(y) for y in outputs]  # 약한 참조로 변경한 부분

        return outputs if len(outputs) > 1 else outputs[0]

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

 

다음은 추가적으로 기존 Dezero 코드에서 메모리를 절약할 수 있는 부분에 대해 개선시켜보자. 첫 번째는 역전파 시 불필요한 미분 결과를 보관하지 않고 즉시 삭제하는 부분이다. 두 번째는 학습이 아닌 추론의 경우 역전파를 수행하지 않는 모드를 키고 끌 수 있도록 만드는 것이다.

 

보통 딥러닝에서 미분을 알고싶은 변수들은 가장 말단에 있는 입력 변수들이다. 이러한 점에 착안해서 역전파를 1번 수행할 때 모든 변수들의 미분값을 계산한 뒤, 중간에 존재하는 변수들의 미분값들을 모두 삭제시키는 것이다. 이 부분은 Variable 클래스의 backward() 메소드에서 아래처럼 개선시킬 수 있다.

 

class Variable:
    ...(생략)...

    def backward(self, retain_grad=False, use_heap=False):
        ...(생략)...

        while funcs:
            if not use_heap:
                f = funcs.pop()
            else:
                f = heapq.heappop(funcs)[2]
            gys = [y().grad for y 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
            
            # 중간 변수들의 미분(기울기)값들을 모두 삭제!
            if not retain_grad:
                for y in f.outputs:
                    y().grad = None

 

다음 개선할 부분은 역전파 활성화 모드를 추가하는 것이다. 위 Function 클래스를 보면 인스턴스 변수에다가 입력값들(inputs)을 저장해놓는 것을 볼 수 있다. 이를 하는 이유는 역전파 시 입력변수들을 사용하기 때문에 저장해놓는 것이라고 예전 포스팅에서 배웠다. 그런데 딥러닝 모델은 학습할 때는 역전파를 필수적으로 사용해야 되지만, 추론할 시에는 역전파를 사용할 필요가 없다. 즉, 학습 시에는 역전파 모드를 활성화시키고 추론 시에는 역전파 모드를 비활성화시켜야 한다. 그래서 아래의 Config 라는 새로운 클래스를 추가해서 역전파 모드를 끄고 킬수 있도록 하자

 

class Config:
    enable_backprop = True

 

그리고 이 Config 클래스를 활용해서 Function 클래스 내부에서 역전파 모드를 끄고 킬 수 있도록 아래처럼 수정하자.

 

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]
        
        # 역전파 모드를 끄고 킬수있도록 하기
        if Config.enable_backprop:
            self.generation = max([x.generation for x in inputs])
            for output in outputs:
                output.set_creator(self)
            self.inputs = inputs
            self.outputs = [weakref.ref(y) for y in outputs]  # 약한 참조로 변경한 부분

        return outputs if len(outputs) > 1 else outputs[0]

 

그런데 역전파 모드를 끄고 킬 수 있도록 하기 위해서는 위에서 새롭게 정의한 Config 클래스의 속성값을 바꾸어주어야 한다. 매번 바꿀 때마다 저 Config 클래스의 속성을 [True ➜ False] 로 직접 수정해줘야 할까? 이를 편하게 하기 위해서 with 구문을 만들 수 있는 파이썬의 contextlib 라이브러리를 활용할 수 있다. 한 번이라도 사용해봤을 법한 with open 구문도 이러한 contextlib 라이브러리를 활용한 것이다. with 구문은 후처리를 자동으로 수행하고자 할 때 사용하는 구문이다. 일단 예시부터 살펴보자.

 

import contextlib

@contextlib.contextmanager
def using_config(name: str, value: bool):
    old_value = getattr(Config, name)
    setattr(Config, name, value)
    try:
        yield 
    finally:
        setattr(Config, name, old_value)

 

위에서 Config은 방금 위에서 우리가 정의한 Config 클래스를 의미한다. 그리고 yield 구문 전에는 전처리 로직을, yield 다음에는 후처리 로직을 작성한다. 이렇게 정의하면 with using_config() 형태 구문으로 사용이 가능하다.

 

with using_config('enable_backprop', False):
    ...(생략)...

 

위 구문을 활용하면 Config 클래스의 enable_backprop 속성값을 False로 바꾸었다가 with 구문을 빠져나오면서 다시 True 값으로 되돌려놓게 된다. 이 with 구문을 사용하면 좀 더 편하게 역전파 모드를 키고 끌 수 있게 된다.

3. 변수 사용성 개선과 연산자 오버로드

다음은 지금까지 만든 Dezero 코드의 변수 사용성을 좀 더 개선해보고 일반 파이썬에서 사용하는 연산자들을 오버로드해보려고 한다. 여기서 연산자란, 우리가 파이썬에서 a = 3 + 5 를 하게 되면 8이 출력되는 것처럼 이 연산자들(+, -, *, /, 음수변환, 거듭제곱)을 의미한다. 그리고 이러한 연산자들을 우리 Dezero 클래스에 맞게 오버로딩 시킬 것이다.

 

먼저 변수의 사용성을 개선시켜보자. 이 부분은 간단하다. 먼저 변수의 이름을 설정할 수 있도록 해서 우리가 print 하면 변수의 이름이 출력될 수 있도록 __repr__ 매직 메서드를 오버로딩 시켜보도록 하자. 그리고 __len__ 이라는 매직 메서드도 오버로딩 시켜보자. 그리고 판다스 데이터프레임에서 df.shape를 치면 (행, 열) 튜플 형태가 나오도록 하는 것처럼 여러가지 property도 정의해보자.

 

class Variable:
    def __init__(self, data, name=None):
        if data is not None:
            if not isinstance(data, np.ndarray):
                raise TypeError(f"{type(data)} dtype is not supported.")

        self.data = data
        self.name = name  # 변수 이름 설정
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def __repr__(self):
        if self.data is None:
            return 'variable(None)'
        p = str(self.data).replace('\n', '\n' + ' ' * 9)
        return 'variable(' + p + ')'
    
    def __len__(self):
        return len(self.data)
    
    @property
    def shape(self):
        return self.data.shape
    
    @property
    def ndim(self):
        return self.data.ndim
    
    @property
    def size(self):
        return self.data.size
    
    @property
    def dtype(self):
        return self.data.dtype
        
    ...(생략)...

 

다음은 연산자 오버로드를 시켜보자. 현재까지 우리가 만든 Dezero 코드에서 정의하는 Variable 변수들끼리 연산자를 수행하려면 예를 들어, 더하기(+) 연산을 하려하면 Add 라는 클래스를 불러서 add() 함수를 사용해서 정의해주어야 한다. 아래처럼 말이다.

 

import numpy as np
from dezero.core_simple import Variable, add

a = Variable(np.array([1]))
b = Variable(np.array([3]))
y = add(a, b)
print(y)

 

그런데 우리는 위와 같이 add() 함수말고 아래처럼 +라는 연산자를 사용해도 계산이 될 수 있도록 확장시켜보고 싶다.

 

import numpy as np
from dezero.core_simple import Variable, add

a = Variable(np.array([1]))
b = Variable(np.array([3]))
y = a + b  # 이렇게 표현하기 위해 연산자 오버로드를 수행하자!
print(y)

 

위 코드가 동작하도록 하기 위해서 연산자 오버로드를 수행시켜보자. 연산자 오버로드를 하기 위해서는 기본적으로 내재되어 있는 연산자 매직 메서드를 오버로딩 즉, 우리 입맛에 맞게 해당 메서드를 덮어씌우면 된다. 앞으로 만들어볼 연산자는 아래와 같다.

 

연산자 매직 메서드 이름 오버로드할 메서드 이름 연산자 기능
__add__ add (왼쪽 기준) 덧셈 연산
__radd__ add (오른쪽 기준) 덧셈 연산
__mul__ mul (왼쪽 기준) 곱셈 연산
__rmul__ mul (오른쪽 기준) 곱셈 연산
__neg__ neg 음수 변환
__sub__ sub (왼쪽 기준) 뺄셈 연산
__rsub__ rsub (오른쪽 기준) 뺄셈 연산
__truediv__ div (왼쪽 기준) 나눗셈 연산
__rtruediv__ rdiv (오른쪽 기준) 나눗셈 연산
__pow__ pow 거듭 제곱

 

위 표에서 한 가지 알아두어야 할 매직 메서드는 앞에 'r'(right)이 붙는 오른쪽 기준 연산자이다. 이게 무슨 의미냐면, 예를 들어, a * b 라는 연산자가 실행된다고 해보자. 그랬을 때 가장 먼저 실행되는 매직 메서드는 a의 매직 메서드 중 __mul__ 이 실행된다. 그런데 만약 이 때 a에 __mul__ 메서드가 구현되어 있지 않다면 그 다음에 b에서 곱셈 관련 매직 메서드가 호출되는데 이 때 호출되는 것이 __rmul__ 이다. 즉, * 연산자의 오른쪽에 있기 때문에 __rmul__이 실행되는 것이다. 이렇게 앞에 'r'이 붙는 매직 메서드는 2개의 피연산자를 필요로 하는 연산자들에는 무조건 존재한다.

 

위 개념을 머릿속에 넣고 표에서 살펴보아야 할 점은 2개의 피연사자를 필요로 하는 덧셈, 곱셈, 뺄셈, 나눗셈 중 덧셈, 곱셈은 순서가 바뀌어도 결과가 동일하기 때문에 오른쪽 기준 매직 메서드 즉, __radd__ 와 __rmul__ 은 각각 __add__, __mul__ 과 동일한 메서드로 대신할 수 있다. 하지만 순서에 따라 결과가 뒤바뀌는 뺄셈과 나눗셈은 덧셈과 곱셈처럼 오른쪽 기준 매직 메서드를 왼쪽 기준 매직 메서드로 대신해서는 안 된다.

 

자, 이제 그러면 각 연산자의 기능을 하는 클래스를 정의한 후, 연산자 오버로드를 수행해보자. 먼저 각 연산자 기능을 하는 클래스를 정의해보자. 아래 그림을 통해 각 연산자의 역전파 계산 방법을 이해하고 각 연산자 클래스 코드를 보면 이해가 될 것이다.

 

각 연산자의 역전파 계산 공식

 

class Add(Function):
    def forward(self, x0, x1):
        return x0 + x1
    
    def backward(self, gy):
        return gy, gy
    

class Mul(Function):
    def forward(self, x0, x1):
        return x0 * x1
    
    def backward(self, gy):
        x0, x1 = self.inputs[0].data, self.inputs[1].data
        return gy * x1, gy * x0
    

class Neg(Function):
    def forward(self, x):
        return -x
    
    def backward(self, gy):
        return -gy
    

class Sub(Function):
    def forward(self, x0, x1):
        return x0 - x1
    
    def backward(self, gy):
        return gy, -gy
    

class Div(Function):
    def forward(self, x0, x1):
        return x0 / x1
    
    def backward(self, gy):
        x0, x1 = self.inputs[0].data, self.inputs[1].data
        gx0 = gy / x1
        gx1 = gy * (-x0 / x1 ** 2)
        return gx0, gx1
    

class Pow(Function):
    def __init__(self, power):
        self.c = power
        
    def forward(self, x):
        return x ** self.c
    
    def backward(self, gy):
        x, c = self.inputs[0].data, self.c
        gx = c * (x ** (c - 1)) * gy
        return gx

 

그리고 위에서 정의한 각 클래스를 기반으로 매직 메서드를 대체할 오버로딩 함수(메서드)를 아래처럼 만들어보자.

 

def add(x0, x1):
    return Add()(x0, x1)


def mul(x0, x1):
    return Mul()(x0, x1)


def neg(x):
    return Neg()(x)


def sub(x0, x1):
    return Sub()(x0, x1)


def rsub(x0, x1):
    return Sub()(x1, x0)


def div(x0, x1):
    return Div()(x0, x1)


def rdiv(x0, x1):
    return Div()(x1, x0)


def pow(x, power):
    return Pow(power)(x)


# Variable 클래스의 매직 메서드에 오버로딩!
def setup_variables():
    Variable.__add__ = add
    Variable.__radd__ = add
    Variable.__mul__ = mul
    Variable.__rmul__ = mul
    Variable.__neg__ = neg
    Variable.__sub__ = sub
    Variable.__rsub__ = rsub
    Variable.__truediv__ = div
    Variable.__rtruediv__ = rdiv
    Variable.__pow__ = pow

 

위처럼 각 클래스마다 하나의 메서드로 만들어준다. 단, 이 때 피연산자 순서를 고려해야 하는 뺄셈, 나눗셈에 대해서 오른쪽 기준 메서드(rsub, rdiv 이름의 함수들)를 추가로 만들어주자. 이들을 만드는 방법은 간단히 sub, div 메서드 만들 때 인자 순서만 뒤집어서 구현해주면 된다. 그리고 마지막에 Variable 클래스의 매직 메서드로 오버로드를 시켜주자. 물론 Variable 클래스 내부에서 __len__ 매직 메서드를 오버로드한 것처럼 동일한 형태로 구현해도 무방하다.

4. 스칼라, numpy nd-array 변수로도 확장하기

위 목차까지 해서 변수 사용성 개선과 연산자마다 오버로드를 수행해서 이제 Dezero에서도 아래와 같은 형태를 구현할 수 있게 되었다.

 

import numpy as np
from dezero.core_simple import Variable, add

a = Variable(np.array([1]))
b = Variable(np.array([3]))
y = a + b  # 이렇게 표현하기 위해 연산자 오버로드를 수행하자!
print(y)

 

그런데 좀 더 확장해야 할 사항들이 있다. 만약 두 피연산자 중에 하나는 파이썬 또는 넘파이 스칼라로 정의해주거나 넘파이의 nd-array 형태로 정의해줄 수 있는 경우가 있다. 아래처럼 말이다.

 

import numpy as np
from dezero.core_simple import Variable, add

# 첫 번째 케이스: 스칼라로 정의
a = Variable(np.array(1))
b = 3   # 또는 np.array(3)
y = a + b  # 에러 발생

# 두 번째 케이스: numpy nd-array로 정의
a = Variable(np.array([1,2,3]))
b = np.array([4,5,6])
y = a + b  # 에러 발생

4-1. Numpy nd-array 등장하는 경우를 대비하자!

먼저 우항에 nd-array로 변수를 정의했을 경우를 대비하기 위해 Variable 클래스로 변환해주는 as_variable() 함수를 하나 새롭게 정의하자. 그리고 이 함수를 Function 클래스 내부에서 입력 변수를 받아올 때 적용되도록 해주자.

 

def as_variable(obj):
    if isinstance(obj, Variable):
        return obj
    return Variable(obj)
    

# Function 클래스에 넣어주기
class Function:
    def __call__(self, *inputs):
        inputs = [as_variable(x) for x in inputs]   # as_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]

        if Config.enable_backprop:
            self.generation = max([x.generation for x in inputs])
            for output in outputs:
                output.set_creator(self)
            self.inputs = inputs
            self.outputs = [weakref.ref(y) for y in outputs]

        return outputs if len(outputs) > 1 else outputs[0]
        
    ...(생략)...

 

그런데 위 코드로 여전히 해결할 수 없는 문제점이 하나 있다. 바로 좌항에 nd-array가 올 경우이다. 아래처럼 말이다.

 

import numpy as np
from dezero.core_simple import Variable, add

a = np.array([3])
b = Variable(np.array([1]))
y = a + b  
print(y)  # TypeError 발생

 

이러한 문제가 발생하는 이유는 아까 위해서 우리가 배웠던 연산자 실행 순서와 관련이 있다. 위 코드로 예시를 들어보자. a + b 를 수행하게 되면 가장 처음에 넘파의 nd-array 형태인 변수 a의 매직 메서드 __add__ 가 호출된다. 하지만 우리는 해당 메서드가 먼저 호출되면 에러가 발생한다는 것을 알고 있다. 그렇다면 우항에 있는 우리가 만든 Variable 클래스로 정의한 변수 b의 매직 메서드 __radd__가 좌항 변수 a의 __add__ 보다 먼저 호출되도록 시켜야 한다. 그래야 에러가 발생하지 않고 연산이 수행된다. 이렇게 하기 위해선 어떻게 해야 할까?

 

연산자 우선순위를 지정해주면 된다. 넘파이 배열(array)의 서브클래스로서 __array_priority__ 라는 속성이 존재한다. 해당 속성은 공식 문서에서 찾아볼 수 있다. 해당 속성 값을 크게 하여 Variable 클래스의 클래스 변수로 설정해주면 nd-array가 좌항에 있더라도 우항에 있는 Variable 클래스의 연산자 우선순위가 높게되어 Variable 클래스의 연산 매직 메서드가 먼저 호출된다. 이렇게 하면 우리가 원하는 대로 좌항에 nd-array가 있더라도 Variable 클래스와의 연산을 할 수 있도록 확장이 가능해진다. __array_priority__라는 속성은 아래의 위치에 넣어주면 된다.

 

class Variable:
    __array_priority__ = 200   # 연산자 우선순위를 높게 설정!

    def __init__(self, data, name=None):
        if data is not None:
            if not isinstance(data, np.ndarray):
                raise TypeError(f"{type(data)} dtype is not supported.")

        self.data = data
        self.name = name
        self.grad = None
        self.creator = None
        self.generation = 0
        
    ...(생략)...

4-2. 스칼라 형태가 오는 경우를 대비하자!

다음은 파이썬의 int, float 형태나 넘파이의 차원이 0인 형태(ex. np.array(3))로 정의해줄 경우를 대비해보자. 참고로 넘파이의 전통 관습상 넘파이의 차원이 0인 형태 즉, np.array(3) 의 타입을 출력하면 nd-array 형태로 출력되지만, 특정 연산을 취할 경우 넘파이 스칼라 형태로 자동으로 바뀐다는 것을 예전 포스팅에서 알아보았었다.

 

먼저 우항에 스칼라 형태가 오는 것을 대비해 위에서 정의한 오버로딩 시킬 각 연산자의 메서드들 중 2개 피연산자를 필요로 하는 연산자(덧셈, 뺄셈, 곱셈, 나눗셈)에 대해 우항을 nd-array 형태로 변환하는 코드를 넣어주도록 하자.(참고로 nd-array 형태로 변환하는 함수는 우리가 예전에 만든 적이 있다)

 

def add(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Add()(x0, x1)


def mul(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Mul()(x0, x1)


def sub(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Sub()(x0, x1)


def rsub(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Sub()(x1, x0)


def div(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Div()(x0, x1)


def rdiv(x0, x1):
    x1 = as_array(x1)      # 우항을 nd-array 형태로 변환!
    return Div()(x1, x0)

 

다음은 좌항에 스칼라 형태가 오는 경우를 대비해야 한다. 사실 이 경우에 대한 대비는 이미 우리가 구현해놓았다. 위에서 오른쪽 기준 연산자 즉, 앞에 'r'이 들어가는 매직 메서드를 구현할 때이다. 이를 이해하기 위해서는 하나 예시를 들어 매직 메서드 인자의 의미를 이해하면 된다.

 

매직 메서드의 인자가 가리키는 피연산자들은?

 

즉, 두 인자가 서로 반대 인자를 가리키는 것을 알 수 있다. 따라서 두 인자의 순서를 고려하지 않아도 되는 덧셈, 곱셈에 대해서는 왼쪽 기준, 오른쪽 기준 연산자에 할당하는 메서드는 동일하게 해도 되지만, 뺄셈과 나눗셈에 대해서는 연산자 순서를 바꾸는 코드만 추가 해준 rsub, rdiv 라는 새로운 함수를 정의해준 것이다. 그리고 정의한 그 rsub, rdiv 코드를 보면 단순히 인자 순서만 바꾸어서 각각 sub, div를 호출해준 것을 볼 수 있다.

5. 하나의 패키지로 정리

그러면 지금까지 개선 및 확장시켜온 Dezero 코드들을 하나의 패키지로 정리시켜보자. 그에 들어가기에 앞서 모듈, 패키지, 라이브러리에 대한 개념을 구분하고 가자. 텍스트 설명 보다는 아래의 도식화를 통해서 3가지 개념을 구분할 수 있다.

 

모듈, 패키지, 라이브러리의 차이점

 

Scikit-learn 라이브러리에서 가장 자주 사용하는 임포트 구문을 통해 개념 이해를 테스트해보자. 

 

from sklearn.preprocessing import StandardScaler

 

3개의 개념에 대해 알아보았으니, 이제 Dezero를 하나의 패키지로 만들어보도록 하자. 이미 개인 깃헙에 만들어놓은 것은 여기를 참조하자.

 

다음으로 우리가 중점적으로 배울 부분은 바로 Dezero 라는 패키지 즉, Dezero 라는 디렉토리 안에 들어가 있는 __init__.py 이름의 파일이다. 해당 이름의 파일은 우리가 모듈을 임포트할 때 가장 먼저 실행되는 파일이다. 예를 들어, Dezero 안에 __init__.py 파일과 core_simple.py 파일 2개가 있는데, Dezero 패키지 안에 있는 어떤 모듈을 실행하던 가장 먼저 호출되는 실행 파일이 바로 __init__.py 모듈이라는 것이다. 따라서 우리는 __init__.py 파일 안에 아래의 코드를 넣어줌으로써 임포트 구문을 간결화 시킬 수 있다.

 

from dezero.core_simple import Variable, Function
from dezero.core_simple import as_array, as_variable, setup_variables

setup_variables()

 

위와 같은 구문을 __init__.py 파일 안에 넣어두면 다른 파이썬 스크립트에서 아래와 같이 호출 구문을 간결화 시킬 수 있다.

 

import numpy as np
from dezero import Variable  # 원래대로라면 from dezero.core_simple import Variable

a = Variable(np.array([1]))
b = 3
y = a + b
print(y)

 

이제 마지막으로 Dezero 패키지를 임포트 하는 방법을 살펴보자. Dezero 내부 디렉토리에서 test.py 파일을 만들어서 아래의 구문을 추가해서 실행시켜보자.

 

if '__file__' in globals():
    import os, sys
    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))

import numpy as np
from dezero import Variable

a = Variable(np.array([1]))
b = 3
y = a + b
print(y)

 

맨 처음의 구문에 익숙하지 않을 수 있다. 하나씩 살펴보자. globals() 라는 것을 출력하면 딕셔너리 향태로 각 전역 변수에 어떤 데이터 값이 저장되어 있는지 보여준다. 그 딕셔너리에서 __file__ 이라는 key 값이 존재하는지 여부를 체크한다. 이 때, __file__ 이라는 key에 매핑되는 value 값은 현재 우리가 실행하려는 파일의 경로가 들어 있다. 그래서 해당 경로의 부모 디렉터리를 모듈 탐색 경로에 추가해서 Dezero 패키지를 임포트할 수 있도록 만들어주는 것이다. 

 

단, 위와 같은 모듈 탐색 경로 추가하는 구문은 아직 pip install dezero 처럼 설치가능한 상태로 만들지 않았기 때문에 추가해준 것이다. 만약에 설치 가능한 패키지 형태로 만들어준 후 pip 명령어로 설치해준다면 자동으로 모듈 탐색 경로에 Dezero 패키지 경로를 추가해주기 때문에 그 때는 위와 같은 모듈 탐색 추가 경로 구문을 작성해줄 필요는 없다. 

 

또한 __file__ 이라는 변수는 파이썬 인터프리터의 인터렉티브 모드(주피터 노트북 같은)와 구글 콜랩의 환경에서 실행되는 경우에는 정의되어 있지 않음을 참고해두자.

6. 정적 계산 그래프와 동적 계산 그래프

우리가 지금까지 Dezero 코드를 구현해오면서 초점을 맞추었던 철학은 바로 동적 계산 그래프 즉, Define-by-Run이었다. 사실 이것 말고도 Define-and-Run 이라는 정적 계산 그래프 방식이 있는데 이번 목차에서는 이 2개의 차이점이 무엇이고 각 장,단점이 무엇인지에 대해서 살펴보도록 하자.

6-1. 계산 그래프를 모두 정의하고 데이터를 흘려보내기, Define-and-Run

말 그대로 '정의' 하고 '실행' 시킨다 의미처럼 계산 그래프를 미리 다 정의해놓고 데이터를 흘려보냄으로써 모델을 실질적으로 실행시킴을 의미한다. 먼저 Define-and-Run 방식의 프레임워크 처리 흐름을 보자.

 

Define-and-Run 방식의 프레임워크 처리 흐름

 

우리가 알고 있는 정적 계산 그래프 방식의 프레임워크는 계산 그래프의 정의를 반환하는 일종의 컴파일 역할을 한다. 이 컴파일 역할에 의해 사용자가 정의한 계산 그래프가 컴퓨터 메모리 상에 펼쳐지면서 실제 데이터를 받을 준비가 완료된다. 여기서 주목해야 할 부분은 계산 그래프를 정의하는 부분과 데이터를 흘려보내는 부분이 분리되어 있다는 점이다.

 

아래는 책에서 제시하는 Define-and-Run 방식의 프레임워크 수도 코드 예시이다.

 

# 계산 그래프 정의
a = Variable('a')
b = Variable('b')
c = a * b
d = c + Constant(1)

# 계산 그래프 컴파일
f = compile(d)

# 데이터 흘려보내기
d = f(a=np.array(2), b=np.array(3))

 

위 코드에서 계산 그래프를 컴파일 하기 전의 계산 그래프 정의하는 부분에서는 실제 계산이 이루어지지 않는다. 왜냐하면 실제 수치가 아닌 기호(symbol)를 대상으로 프로그래밍 되었기 때문이다. 위 코드를 잘보면 Variable 에다가 넘파이나 숫자 같은 실제 수치값을 기입한 것이 아니라 '기호'를 할당한 것을 볼 수 있다. 참고로 이러한 방식을 기호 프로그래밍이라고도 부른다.

 

이렇게 기호 프로그래밍을 사용하다보니 Define-and-Run 방식은 추상적인 계산 절차를 코딩해야 한다. 뿐만 아니라 도메인 특화 언어(DSL, Domain-Specific Languate)를 사용해야 한다. DSL 이란, 특정 프레임워크 자체의 규칙들로 이루어진 언어를 의미한다. 예를 들어, Tensorflow 1.x 버전 대의 문법을 잠시 살펴보자.

 

Tensorflow 1.x 버전의 API

 

위 문법을 보면 $x, y, w, b$ 변수를 정의할 때, 실제 수치를 기입하는 것이 아닌 일종의 규칙으로 정의를 하는 것을 볼 수 있다. 이렇게 Tensorflow 1.x 버전 자체에서 정한 일종의 규칙을 기반으로 코드를 작성해야 한다.

 

그렇다면 이 Define-and-Run 방식의 장,단점은 무엇일까? 먼저 장점으로는 성능이 있다. 계산 그래프를 최적화하면 자연스레 성능도 최적화된다. 아까 Define-and-Run 방식은 계산 그래프를 미리 다 정의해놓는다고 했다. 따라서 정의하는 부분에서 계산 그래프 형태의 최적화가 가능해지고 이 때 가장 효율적인 계산 그래프 형태로 만들 수 있다. 따라서 계산 시간이 단축됨에 따라 높은 성능이 발휘된다.

 

또 다른 장점으로는 어떻게 컴파일하느냐에 따라 다른 실행 파일로 변환이 가능하다. 즉, 파이썬이 아닌 다른 환경에서도 데이터를 흘려보내는게 가능하다. 파이썬이라는 환경을 벗어난다는 것은 곧 파이썬 자체가 주는 성능 오버헤드가 사라진다는 것이고, 멀리 더 나아가 Edge-AI 라고 불리는 스마트폰이나 IoT와 같은 작은 모바일 기기같이 자원이 부족한 에지 전용 환경에서 장점이 발휘된다.

 

마지막 장점으로는 학습을 여러 대의 컴퓨터에 분산학습시킬 때 유리해진다. 계산 그래프 자체를 분할해서 여러 컴퓨터로 분배할 수 있는데 이것이 가능한 이유는 전체 계산 그래프가 이미 구축(정의)되어 있어야 가능하기 때문이다.

 

반면에 단점으로는 도메인 특화 언어 즉, 해당 프레임워크만의 규칙 문법을 지키면서 코드를 작성해야 하기 때문에 프로그래머들의 진입 장벽이 상대적으로 높다. 그래서 디버깅하는 데 매우 어려운 문제가 발생한다.

6-2. 데이터를 흘려보내면서 계산 그래프가 정의된다, Define-by-Run

다음은 동적 계산 그래프인 Define-by-Run에 대해 알아보자. 사실 지금까지 우리가 배우고 구현해왔던 Dezero의 철학이 Define-by-Run 방식이다. 이 방식의 포인트는 데이터를 흘려보내는 부분과 계산 그래프가 정의되는 부분이 동시에 이루어진다는 것이다. 이 방식의 가장 큰 장점은 넘파이를 사용하는 일반적인 프로그래밍과 똑같은 형태로 코딩할 수 있다는 것이다. 우리가 위에서 넘파이와 파이썬 스칼라가 들어왔을 때도 대응할 수 있도록 확장시킨 것처럼 말이다.

(참고로 우리가 사용하는 Tensorflow 2.x 버전부터 Eager Execution 이라는 Define-by-Run 방식을 표준으로 채택했다. 그 이전 1.x 버전에서는 Define-and-Run 방식을 채택했었다)

 

그러면 Define-by-Run의 장,단점은 무엇일까? 먼저 도메인에 특화된 언어를 사용하지 않아도 되기 때문에 프로그래머들이 프레임워크를 배울 때 진입 장벽이 다소 낮다. 그렇기 때문에 우리가 파이썬에서 일반적으로 사용하는 if ~ else, while 구문 같은 것도 자연스레 딥러닝 프레임워크에도 호환이 된다. 이렇게 호환이 좋다보니 에러가 발생했을 때도 디버깅하기가 쉬워진다. 심지어 파이썬 디버거 도구인 pdb를 사용해서 디버깅도 가능하다.

 

이렇게 디버깅이 쉬운 또 다른 이유는 Define-by-Run 동작 방식에서도 찾을 수 있다. 보통 버그의 원인은 계산 그래프를 정의하는 부분에서 근원적으로 발생하지만, 버그가 발견되는 시점은 데이터를 흘려보낼 때이기 때문이다. 이러한 이유로 데이터를 흘려보내고 계산 그래프를 정의하는 부분이 동시에 이루어지는 Define-by-Run이 디버깅하기가 쉽다.

 

반면에 Define-by-Run의 가장 큰 단점은 바로 성능이다. 이유는 계산 그래프를 최적화할 수 없기 때문이다. 이 이유도 Define-by-Run의 동작 방식과 연관이 있다. 정적 계산 그래프인 Define-and-Run의 경우, 미리 계산 그래프를 다 구축해놓기 때문에 계산 그래프를 미리 효율적으로 만든 후 정의하면 되지만 동적 계산 그래프인 Define-by-Run은 미리 계산 그래프를 구축해놓을 수 없기 때문이다.

 

지금까지 딥러닝 프레임워크의 두 가지 철학인 Define-and-Run(정적 계산 그래프)와 Define-by-Run(동적 계산 그래프)를 살펴보았다. 간단하게 요약하면 성능 면에서 유리한 것은 Define-and-Run, 사용성 측면에서 유리한 것은 Define-by-Run이라고 할 수 있다. 어떤 방식이 모두 유리하다라고 할 수 없기에 프레임워크 사용자가 어떤 상황에 처해있느냐에 따라 두 철학 중 하나를 사용하는 프레임워크를 선정하면 된다.

 

참고로 위 두 마리 토끼(성능과 사용성)를 모두 잡으려고 하는 시도가 많이 연구되고 있다고 한다. 프로그래밍 언어 자체에서 자동 미분을 지원하는 것인데, 대표적인 예로 IOS 앱을 만드는 데 주로 사용되는 Swift 라는 언어와 Tensorflow를 결부시키는 것이다. Swift 라는 범용 프로그래밍 언어를 확장(Swift 컴파일러는 손질해서)해 자동 미분 구조를 도입하려고 하고 있다. 이런 여러가지 시도를 보면 향후에는 위 2가지 철학을 동시에 조화시킨 이상적인 프레임워크 철학이 나올 수도 있지 않을까 하는 기대가 들기도 한다.


지금까지 긴 포스팅으로 제 2고지까지의 학습을 마쳤다. 복잡한 계산 그래프의 역전파를 가능하게 했으며 메모리 절약, 사용성 개선, 변수에 할당하는 데이터 유형(스칼라, 넘파이 nd-array)에 상관없이 확장시키는 등 여러가지 개선사항을 적용해왔다. 하지만 아직 좀 더 발전시킬 단계가 있다. 바로 고차 미분을 계산하는 방법이다. 다음 포스팅에서는 고차 미분을 계산하는 나만의 프레임워크를 만들 수 있도록 실력을 키워보자!

반응형