기계학습/밑바닥딥러닝3 오독오독 씹기

Chapter 1. Auto-grad(자동미분) step 6~9: 수동 역전파/ 역전파 자동화/ 재귀, 반복문/ 파이썬 함수 활용

H_erb Salt 2020. 12. 2. 16:56
ch_6-9
In [1]:
import numpy  as np

6. 수동 역전파

  • 이전 단계에서 역전파의 구동원리를 설명함
  • 이번 단계에서는 Variable과 Function 클래스를 확장하여 역전파를 이용한 미분을 구현함

6.1 Variable 클래스 추가 구현

  • 역전파에 대응하는 Variable 클래스를 구현함
  • 이를 위해 통상값(data)과 더불어 그에 대응하는 미분값(grad)도 저장하도록 확장함
  • 새로 추가된 코드에는 음영을 덧씌움
In [2]:
class Variable:
    def __init__(self, data):
        self.data = data
        self.grad = None
  • 위와 같이 새로 grad라는 인스턴스 변수를 추가함
  • 인스턴스 변수인 data와 grad는 모두 numpy의 다차원배열(ndarray)이라고 가정
  • 또한 grad는 None으로 초기화해둔 다음, 나중에 실제로 역전파를 하면 미분값을 계산하여 대입

6.2 Function 클래스 추가 구현

  • 이전 단계까지의 Function 클래스는 일반적인 계산을 하는 순전파(forward 메서드) 기능만 지원하는 상태
  • 이외에 다음 두 기능일 추가함
    • 미분을 계산하는 역전파(backward 메서드)
    • forward 메서드 호출 시 건네받은 Variable 인스턴스 유지
In [3]:
class Function:
    def __call__(self, input):
        x = input.data
        y = self.forward(x)
        output = Variable(y)
        self.input = input # 입력 변수를 기억(보관)함
        
        return output
    
    def forward(self, x):
        raise NotImplementedError()
        
    def backward(self, x):
        raise NotImplementedError()
  • 코드에서 보듯 __call__ 메서드에서 입력된 input을 인스턴스 변수인 self.input에 저장함
  • 이렇게하여 나중에 backward 메서드에서 함수 (Function)에 입력한 변수(Variable 인스턴스)가 필요할때 self.input에서 가져와 사용가능

6.3 Square와 Exp 클래스 추가 구현

  • Function을 상속한 구체적인 함수에서 역전파(backward)를 구현
  • 첫 번째 대상은 제곱을 계산하는 Square 클래스
  • $y=x^2$의 미분은 $\frac{dy}{dx}=2x$ 가 되기 때문에 다음처럼 구현 가능
In [4]:
class Square(Function):
    def forward(self, x):
        y = x ** 2

        return y

    def backward(self, dy):
        x = self.input.data
        dx = 2 * x * dy

        return dx
  • 이와 같이 역전파를 담당하는 backward 메서드를 추가함
  • 이 메서드의 인수 gy는 ndarray 인스턴스이며, 출력 쪽에서 전해지는 미분값을 전달하는 역할을 함
  • 인수로 전달된 미분에 $y=x^2$의 미분을 곱한 값이 backwrad의 결과가 됨.
  • 역전파에서는 이 결괏값을 입력 쪽에서 더 가까운 다음 함수로 전파해나감
  • 이어서 $y=e^x$ 계산을 할 Exp 클래스
  • 이 계산의 미분은 $\frac{dy}{dx} = e^x$ 이기 때문에 다음과 같이 구현 가능
In [5]:
class Exp(Function):
    def forward(self, x):
        y = np.exp(x)

        return y

    def backward(self, dy):
        x = self.input.data
        dx = np.exp(x) * dy

        return dx

6.4 역전파 구현

In [6]:
A = Square()
B = Exp()
C = Square()

x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)
  • 이어서 역전파로 y를 미분. 순전파와는 반대 순서로 각 함수의 backward 메서드를 호출
In [7]:
y.grad = np.array(1.0)
b.grad = C.backward(y.grad)
a.grad = B.backward(b.grad)
x.grad = A.backward(a.grad)
print(x.grad)
3.297442541400256
  • 역전파는 $\frac{dy}{dy} = 1$ 에서 시작함. 따라서 출력 y의 미분값을 np.array(1.0)으로 설정.
  • 그 후, C -> B -> A 순으로 backward 메서드를 호출하기만 하면 됨. 이를 통해 각 변수의 미분값이 구해짐
  • 앞의 코드를 실행하면 x.grad의 값이 3.2974~~ 라고 나옴
  • 이 값이 y의 x에 대한 미분 결과
  • 4단계에서 수치 미분으로 구한 값이 3.2974~~ 였으므로 두 결과가 거의 같음을 알 수 있음. 역전파를 제대로 구현한 것(더 정확하게는 올바르게 구현한 것)
  • 이제 역전파 순서에 맞춰 코드를 작성하는 것을 자동화시키는 작업이 필요함

7. 역전파 자동화

  • 이전 단계에서 역전파를 동작시키는데 성공함. 이를 자동화함
  • 더 정확히 표현하면, 일반적인 계산(순전파)을 한 번만 해주면 어떤 계산이라도 사오간없이 역전파가 작동으로 이루어지는 구조를 만듦
  • 위 내용이 Define-by-Run의 핵심을 건드리는 내용
    • Define-by-Run: 딥러닝에서 수행하는 계산들을 계산 시점에 '연결'하는 방식.
    • 동적 계산 그래프라고도 부름
  • 계산 그래프들이 모두 일직선으로 늘어선 계산인 경우, 함수의 순서를 리스트형태로 저장해두면 나중에 거꾸로 추적하는 방식으로 역전파를 자동화할 수 있음.
  • 그러나 분기가 있는 계산 그래프나 같은 변수가 여러번 사용되는 복잡한 계산 그래프는 단순히 리스트로 저장하는 식으로는 풀 수 없음
    • 리스트 데이터 구조를 응용하면 수행한 계산을 리스트에 추가해나가는 것만으로 어떠한 계산 그래프의 역전파도 수행 가능함.
    • 이 데이터 구조를 웬거트 리스트(Wengert list) 혹은 테이프(Tape)라고 함

7.1 역전파 자동화의 시작

  • 역전파 자동화로 가는 길은 변수와 함수의 '관계'를 이해하는 데서 출발함
  • 함수 입장에서, 변수는 '입력'과 '출력'에 쓰임. 즉, 함수에게 변수는 '입력 변수(input)'와 '출력 변수(output)'로서 존재
  • 변수 관점에서, 눈여겨볼 점은 변수는 함수에 의해 '만들어진다'(창조자)
  • 창조자인 함수가 존재하지 않는 변수는 함수 이외에 존재, 예컨대 사용자에 의해 만들어진 변수로 간주됨
  • 이를 코드에 녹이면, 일반적인 계산(순전파)이 이루어지는 시점에 '관계'를 맺어주도록(함수와 변수를 연결짓도록) 만듦
  • 이를 위해 우선 Variable 클래스에 다음 코드를 추가함
In [8]:
class Variable:
    def __init__(self, data):
        self.data = data
        self.grad = None
        self.creator = None

    def set_creator(self, func):
        self.creator = func
  • creator라는 인스턴스 변수를 추가함
  • 그리고 creator를 설정할 수 있도록 set_creator메서드도 추가함, 이어서 Function 클래스에 다음 코드를 추가함
In [9]:
class Function:
    def __call__(self, input):
        x = input.data
        y = self.forward(x)
        output = Variable(y)
        output.set_creator(self) # 출력 변수에 창조자를 설정
        self.input = input
        self.output = output # 출력도 저장

        return output
    
    def forward(self, x):
        raise NotImplementedError()
        
    def backward(self, x):
        raise NotImplementedError()
  • 순전파를 계산하면 그 결과로 output이라는 Variable 인스턴스가 생성됨
  • 이때 생성된 output에 '내가 너의 창조자임'을 기억시킴. 이 부분이 '연결'을 동적으로 만드는 기법의 핵심
  • 그런 다음, 앞으로를 위해 output을 인스턴스 변수에 저장함
    • DeZero의 동적 계산 그래프는 실제 계산이 이루어질 때 변수(상자)에 관련 '연결'을 기록하는 방식으로 만들어짐
    • 체이너와 파이토치의 방식도 위와 비슷함
  • 이와 같이 '연결'된 Variable과 Function이 있다면 계산 그래프를 거꾸로 거슬러 올라갈 수 있음. 이를 코드로 나타내면 다음과 같음
In [10]:
'''###########################################################################################
해당 셀은 주피터에서 다시 선언하지 않으면 오류가 생겨서 다시 입력한 코드로, 책의 진행흐름과는 상관없음
###########################################################################################'''

class Square(Function):
    def forward(self, x):
        y = x ** 2

        return y

    def backward(self, dy):
        x = self.input.data
        dx = 2 * x * dy

        return dx
    
class Exp(Function):
    def forward(self, x):
        y = np.exp(x)

        return y

    def backward(self, dy):
        x = self.input.data
        dx = np.exp(x) * dy

        return dx
In [11]:
A = Square()
B = Exp()
C = Square()

x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)

##
assert y.creator == C
assert y.creator.input == b
assert y.creator.input.creator == B
assert y.creator.input.creator.input == a
assert y.creator.input.creator.input.creator == A
assert y.creator.input.creator.input.creator.input == x
  • 노드들의 연결로 이루어진 데이터 구조를 링크드 리스트(linked list)라고 함. 노드는 그래프를 구성하는 요소이며, 링크는 다른 노드를 가리키는 참조를 뜻함

7.2 역전파 도전

  • 변수와 함수의 관계를 이용하여 역전파를 시도함.
  • 순서
    1. 함수를 가져옴
    2. 함수의 입력을 가져옴
    3. 함수의 backward 메서드를 호출
In [12]:
y.grad = np.array(1.0)

C = y.creator
b = C.input
b.grad = C.backward(y.grad)

B = b.creator
a = B.input
a.grad = B.backward(b.grad)

A = a.creator
x = A.input
x.grad = A.backward(a.grad)

print(x.grad)
3.297442541400256
  • 이와 같이 변수 y의 backward 메서드를 호출하면 역전파가 자동으로 진행됨. 실행결과도 지금까지와 동일함
  • 이 것이 자동미분의 기초

8. 재귀에서 반복문으로

  • 앞 단계에서는 Variable 클래스에 backward 메서드를 추가함. 이번에는 처리 효율을 개선하고 아픙로의 확장을 대비해 backward메서드의 구현 방식을 변경함

8.1 현재의 Variable 클래스

  • 이전 장에서 우리는 Variable 클래스의 backward 메서드를 다음처럼 구현함
In [13]:
class Variable:
    def __init__(self, data):
        self.data = data
        self.grad = None
        self.creator = None

    def set_creator(self, func):
        self.creator = func
    
    def backward(self):
        f = self.creator
        if f is not None:
            x = f.input
            x.grad = f.backward(self.grad)
            x.backward()
  • backward 메서드에서 눈에 밟히는 부분은 (입력방향으로) 하나 앞 변수의 backward 메서드를 호출하는 코드
  • backward메서드에서 backward 메서드를 호출하고, 호출된 backwward 메서드에서 또 다른 backward 메서드를 호출하는 과정이 계속됨
  • 창조자 함수가 없는 변수, 즉 self.creator가 None인 변수를 찾을 때 까지 계속됨. 이를 재귀구조라고 함

8.2 반복문을 이용한 구현

  • 재귀를 이용한 구현을 반복문을 이용한 구현으로 고쳐보자
In [14]:
class Variable:
    def __init__(self, data):
        self.data = data
        self.grad = None
        self.creator = None

    def set_creator(self, func):
        self.creator = func
        
    def backward(self):
        funcs = [self.creator]
        while funcs:
            f = funcs.pop() # 함수를 가져옴
            x, y = f.input, f.output # 함수의 입력과 출력을 가져옴
            x.grad = f.backward(y.grad) # backward 메서드를 호출함
            
            if x.creator is not None:
                funcs.append(x.creator)
  • 위와 같은 형태가 반복문을 이용한 구현.
  • 주목해야 할 점은 처리해야 할 함수들을 funcs 라는 리스트에 차례로 집어 넣는다는 것
  • while 블록 안에서 funcs.pop()을 호출하여 처리할 함수 f를 꺼내고, f의 backward 메서드를 호출함
  • 이 때, f.inputf.output에서 함수 f의 입력과 출력 변수를 얻음으로써 f.backward()의 인수와 반환값을 올바르게 설정할 수 있음

8.3 동작 확인

  • 개선된 Variable 클래스를 사용하여 실제로 미분을 진행해봄
In [15]:
A = Square()
B = Exp()
C = Square()

x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)

# 역전파
y.grad = np.array(1.0)
y.backward()
print(x.grad)
3.297442541400256
  • 앞으로 더욱 복잡한 계산도 가능하도록 현재의 DeZero를 확장해나감
  • 그 전에 사용자 편의성을 개선

9. 함수를 더 편리하게

  • Define-by-Run이라고 하는 전체 계산의 각 조각들을 런타임에 연결하는 능력을 할당
  • 하지만 사용하기 불편한 부분이 있기때문에 DeZero의 함수에 세 가지 개선을 추가함

9.1 파이썬 함수로 이용

  • 지금까지 DeZero는 함수를 '파이썬 클래스'로 정의하여 사용함
  • 이 때문에 가령 Square 클래스를 사용하는 계산을 하려면 코드를 다음처럼 작성해야 했음
In [16]:
x = Variable(np.array(0.5))
f = Square()
y = f(x)
  • 이와 같이 클래스의 인스턴스를 생성한 다음, 이어서 그 인스턴스를 호출하는 두 단계로 구분해야 진행해야 함
  • 사용자 입장에서 번거로움. 이를 개선하는 해법은 '파이썬 함수'를 지원하는 것 그래서 다음 코드를 추가함
In [17]:
'''
def square(x):
    f = Square()
    return f(x)

def exp(x):
    f = Exp()
    return f(x)
'''

# 한 줄로 작성

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

def exp(x):
    return Exp()(x)
  • 이전의 f = Square() 형태에서는 DeZero 함수를 f라는 변수 이름으로 참조한 데 반해, 간소화한 코드에서는 직접 Square()(x)라고 쓴 것.
  • 방금 구현한 두 함수를 사용해보자
In [18]:
x = Variable(np.array(0.5))
a = square(x)
b = exp(a)
y = square(b)

y.grad = np.array(1.0)
y.backward()

print(x.grad)
3.297442541400256
  • 보다시피 최초의 np.array(0.5)를 Variable로 감싸면 일반적인 수치 계산을 하듯, 넘파이를 사용해 계산하도록 코딩이 가능함
  • 또한 다음과 같이 함수를 연속으로 적용할 수도 있음
In [19]:
x = Variable(np.array(0.5))
y = square(exp(square(x))) # 연속하여 적용
y.grad = np.array(1.0)
y.backward()

print(x.grad)
3.297442541400256
  • 이제 계산을 더 자연스로운 코드로 표현할 수 있게 됨

9.2 Backward 메서드 간소화

  • 두 번째 계산은 역전파 시 사용자의 번거로움을 줄이기 위한 것
  • 구체적으로 방금 작성한 코드에서 y.grad = np.array(1.0) 부분을 생략함
  • 앞으로 위 코드를 생략할 수 있도록 Variable의 backward 메서드에 다음 두 줄을 추가함
In [20]:
class Variable:
    def __init__(self, data):
        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)
  • 이와 같이 만약 변수의 grad가 None이면 자동으로 미분값을 생성함.
  • np.ones_like(self.data)코드는 self.data의 형상과 데이터 타입이 같은 ndarray 인스턴스를 생성하는데, 모든 요소를 1로 채워서 돌려줌
  • self.data가 스칼라이면 self.grad도 스칼라가 됨
    • 이전까지는 출력의 미분값을 np.array(1.0)으로 사용했지만, 방금 코드에서는 np.ones_like()를 씀
    • 이 이유는 Variable data와 grad의 데이터 타입을 같게 만들기 위함
    • 예를 들어, data의 타입이 32비트 부동소수점 숫자면 grad의 타입도 32비트 부동소수점 숫자가 됨
    • np.array(1.0)은 64비트 부동소수점 숫자 타입으로 만들어 줌
  • 이제 어떤 계산을 하고 난 뒤 최종 출력 변수에서 backward 메서드를 호출하는 것만으로 미분값이 구해짐
In [21]:
x = Variable(np.array(0.5))
y = square(exp(square(x)))
y.backward()
print(x.grad)
3.297442541400256

9.3 ndarray만 취급하기

  • DeZero의 Variable은 데이터로 ndarray 인스턴스만 취급하게끔 의도함
  • 하지만 사용하는 사람이 모르고 float나 int 같은 의도치 않은 데이터 타입을 사용하는 일도 충분히 일어날 수 있음
  • 예컨대 Variable(1.0)혹은 Variable(3)처럼 사용할 수도 있음
  • 이런 사태를 막기 위해 Variable이 ndarray 인스턴스 만을 담는 '상자'가 되도록 함
  • 따라서 Variable에 ndarray 인스턴스 외에 데이터를 넣을 경우 즉시 오류를 발생시킴(미분값은 None으로 유지)
In [22]:
class Variable:
    def __init__(self, data):
        if data is not None: # 해당 코드 추가
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))
                
        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)
  • 이와 같이 인수로 주어진 data가 None이 아니고 ndarray 인스턴스도 아니라면 TypeError라는 예외를 발생시킴
In [23]:
x = Variable(np.array(1.0)) # ok
x = Variable(None) # ok

x = Variable(1.0)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-23-ed67f37ae2d7> in <module>
      2 x = Variable(None) # ok
      3 
----> 4 x = Variable(1.0)

<ipython-input-22-336387f7d9eb> in __init__(self, data)
      3         if data is not None: # 해당 코드 추가
      4             if not isinstance(data, np.ndarray):
----> 5                 raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))
      6 
      7         self.data = data

TypeError: <class 'float'>은(는) 지원하지 않아요. ndarray로 입력하세요.
  • 그런데 이처럼 변경하고 나면 주의할게 하나 생김. 넘파이의 독특한 관례 때문
  • 아래 코드 확인
In [24]:
x = np.array([1.0])
y = x ** 2
print(type(x), x.ndim)
print(type(y))
<class 'numpy.ndarray'> 1
<class 'numpy.ndarray'>
  • x는 1차원 ndarray. 여기에 제곱을 하면 y의 데이터 타입도 ndarray가 됨.
In [25]:
x = np.array(1.0)
y = x ** 2
print(type(x), x.ndim)
print(type(y))
<class 'numpy.ndarray'> 0
<class 'numpy.float64'>
  • 그러나 위의 경우에는 달라짐
  • 0차원 ndarray 인스턴스를 사용하여 계산하면 결과의 데이터 타입이 numpy.float64나 numpy.float32 등으로 달라짐.
  • 다시 말해, DeZero 함수의 계산 결과(출력)도 numpy.float64나 numpy.float32가 되는 경우가 나옴
  • 그러나 우리 Variable은 데이터가 항상 ndarray 인스턴스라고 가정하고 있으니 대처해야함
  • 이를 위해 다음과 같은 편의 함수를 준비함
In [26]:
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x
  • 여기에 쓰인 np.isscalar는 입력 데이터가 numpy.float64 같은 스칼라 타입인지 확인해주는 함수(파이썬의 int나 float 타입도 스칼라로 판단
  • 이처럼 x가 스칼라 타입인지 쉽게 확인할 수 있으며, as_array 함수는 이를 이용하여 입력이 스칼라인 경우 ndarray 인스턴스로 변환해줌
  • 이를 활용하여 Function 클래스에 다음 코드를 추가함
In [27]:
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()
        
    def backward(self, x):
        raise NotImplementedError()
  • 이와 같이 순전파의 결과인 y를 Variable로 감쌀 때 as_array()를 이용함.
  • 이렇게 하여 출력 결과인 output은 항상 ndarray 인스턴스가 되도록 보장함
  • 이제 0차원 ndarray 인스턴스를 사용한 계산에서도 모든 데이터는 ndarray 인스턴스가 됨
In [ ]: