페이지

2022년 8월 12일 금요일

7.3 backward 메서드 추가

 방금 보여드린 역전파 코드에는 똑같은 처리 흐름이 반복해서 나타났습니다. 변수에서 하나 앞의 변수로 거슬러 올라가는 로직이 그러했습니다. 그러므로 이 반복 작업을 자동화할 수 있도록 Variable 클래스에 badckward라는 새로운 메서드를 추가하겠습니다.


class Variable:
  def __init__(selfdata):
    self.data = data
    self.grad = None
    self.creator = None

  def set_creator(selffunc):
    self.creator = func

  def backward(self):
    f = self.creator    # 1. 함수를 가져온다.
    if f is not None:
      x = f.input       # 2. 함수의 입력을 가져온다.
      x.grad = f.backward(self.grad)    # 3. 함수의 backward 메서드를 호출한다.
      x.backward()      # 하나 앞 변수의 backward 메서드를 호출한다(재귀)


backward메서드는 지금까지 반복한 처리 흐름과 거의 동일합니다. Variable의 creator에서 함수를 얻어오고, 그 함수의 입력 변수를 가져옵니다. 그런 다음 함수의 backward메서드를 호출합니다. 마지막으로 자신보다 하나 앞에 놓인 변수의 backward 메서드를 호출합니다. 이런 식으로 각 변수의 backward 메서드가 재귀적으로 불리게 됩니다.

Variable 인스턴스의 creator 가 None이면 역전파가 중단됩니다. 창조자가 없으므로 이 Variable 인스턴스는 함수 바깥에서 생성했음을 뜻합니다(높은 확률로 사용자가 만들어 건네 변수일 것입니다).

이제 새로워진 Variable을 이용하여 역전파가 자동으로 실행되는 모습을 보겠습니다.

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

이와 같이 변수 y의 backward 메서드를 호출하면 역전파가 자동으로 진행됩니다. 실행 결과도 지금까지와 동일합니다. 축하합니다! 여러분은 방금  DeZero에서 가장 중요한 개념인 자동 미분의 기초를 완성했습니다.

7.2 역전파 도전!

 변수와 함수의 관곌르 이용하여 역전파를 시도해보겠습니다. 우선 y에서 b까지의 역전파를 시도해보죠.


이 부분은 다음과 같이 구현할 수 있습니다.

y.grad = np.array(1.0)

C = y.creator # 1. 함수를 가져온다.
b = C.input # 2. 함수의 입력을 가져온다.
b.grad = C.backward(y.grad) # 3. 함수의 backward 메서드를 호출한다.

y의 인스턴스 변수 creator 에서 함수를 얻어오고, 그 함수의 input에서 입력 변수를 가져왔습니다. 그런 다음 함수의 backward메서드를 호출합니다. 이어서 변수 b에서 a로의 역전파를 보겠습니다.

B = b.creator # 1. 함수를 가져온다.
a = B.input   # 2. 함수의 입력을 가져온다.
a.grad = B.backward(b.grad) # 3. 함수의 backward메서드를 호출한다.

똑같은 흐름입니다. 구체적으로 다음과 같은 순서로 진행됩니다.

1. 함수를 가져온다.

2. 함수의 입력을 가져온다.

3. 함수의 backward메서드를 호출한다


마지막으로 변수 a에서 x로의 역전파까지 진행합니다.\

 A = a.creator  #1. 함수를 가져온다.
 x = A.input    # 함수의 입력을 가져온다.
 x.grad = A.backward(a.grad)    # 3. 함수의 backward메서드를 호출한다.
 print(x.grad)

3.297442541400256

이상으로 모든 역전파가 끝났습니다.


7.1 역전파 자동화의 시작

 역전파 자동화로 가는 길은 변수와 함수의 '관계'를 이해하는 데서 출발합니다. 우선 함수 관점에서 '함수는 변수를 어떻게 바라볼까'를 생각해봅시다. 함수 입장에서 변수는 '입력'과 '출력'에 쓰입니다. 즉, [그림 7-2]의 왼쪽과 같이 함수에서 변수는 '입력 변수 (input)'와 '출력 변수(output)'로서 존재합니다(그림의 점선은 참조(reference)를 뜻합니다).


변수 관점에서 함수는 어떤 존재일까요? 여기서 눈여겨볼 점은 변수는 함수에 의해 '만들어진다'라는 것입니다. 즉, 변수에게 있어 함수는 '창조자(creator)'혹은 '부모'입니다. 창조자인 함수가 존재하지 않는 변수는 함수 이외의 존재, 예컨대 사용자에 의해 만들어진 변수로 간주됩니다.

일단 [그림 7-2]와 같은 함수와 변수의 관계를 DeZero코드에 녹여볼까요? 여기에서는 일반적인 계산(순전파)이 이루어지는 시점에 '관계'를 맺어주도록(즉, 함수와 변수를 연결 짓도록) 만들겠습니다. 이를 위해 우선 Variable 클래스에 다음 코드를 추가합니다.

class Variable:
  def __init__(selfdata):
    self.data = data
    self.grad = None
    self.creator = None
  
  def set_creator(selffunc):
    self.creator = func
    

creator 라는 인스턴스 변수를 추가했습니다. 그리고 creator 를 설정할 수 있도록 set_creator 메서드로 추가합니다. 이어서 Function클래스에 다음 코드를 추가합니다.

class Function:
  def __call__(selfinput):
    x = input.data
    y = self.forward(x)
    output = Variable(y)
    output.set_creator(self)  # 출력 변수에 창조자를 설정한다.
    self.input = input
    self.output = output # 출력도 저장한다.
    return output
  

순전파를 계산하면 그 결과로 output이라는 Variable 인스턴스가 생성됩니다. 이때 생성된 output에 '내가 너의 창조자임'을 기억시킵니다. 이 부분이 '연결'을 동적으로 만드는 기법의 핵심입니다. 그런 다음 앞으로를 위해 output을 인스턴스 변수에 저장했습니다.


DeZero의 동적 계산 그래프(Dynamic Computational Graph)는 실제 계산이 이루어질 때 변수(상자)에 관련 '연결'을 기록하는 방식으로 만들어집니다. 체이너와 파이토치의 방식도 이와 비슷합니다.


이와 같이'연결' 된 Variable과 Function이 있다면 계산 그래프를 거꾸로 거슬러 올라갈 수 있습니다. 구체적인 코드로 나타내면 다음과 같습니다.

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

우선 assert문이 무엇이지 설명해야겠네요. 먼저 'assert'는 우리말로 '단호하게 주장하다', '단언하다'라는 뜻입니다. assert 문은 assert ... 형태로 사용합니다. 여기서 ... 부분이 '주장'에 해당하는 내용으로, 그 평가 결과가 True가 아니면 예외가 발생합니다. 따라서 assert문은 조건을 충족하는지 여부를 확인하는데 사용할 수 있습니다. 참고로 앞의 코드는 문제없이(예외가 발생하지 않고) 실행되므로 assert문의 조건을 모두 충족함을 알 수 있습니다.

앞의 코드가 보여주듯 Variable의 인스턴스 변수 creator 에서 바로 앞의 Function으로 건너갑니다. 그리고 그 Function의 인스턴스 변수 input에서 다시 하나 더 앞의 Variable로 건너가죠. [그림 7-3]은 이 관계를 잘 보여줍니다.


[그림 7-3]과 같이 우리 계산 그래프는 함수와 변수 사이의 연결로 구성됩니다. 그리고 중요한 점은 이 '연결'이 실제로 계산을 수행하는 싲넘에(순전파로 데이터를 흘려보낸 때) 만들어진다는 것입니다. 이러한 특성에 이름을 붙인 것이 Define-by-Run입니다. 데이터를 흘려보냄으로써(Run함으로써)연결이 규정된다는 (Define된다는) 뜻입니다.

또한 [그림 7-3]과 같이 노드들의 연결로 이루어진 데이터 구조를 '링크드 리스트(linked list)라고 합니다. 노드는 그래프를 구성하는 요소이며, 링크(link)는 다른 노드를 가리키는 참조를 뜻합니다. 결국 우리는 '링크드 리스트'라는 데이터 구조를 이용해 계산 그래프를 표현하고 있는 것입니다.

2022년 8월 11일 목요일

STEP7 역전파 자동화

 이전 단계에서 역전파를 동작신키는 데 성공했습니다. 그러나 역전파 계산 코드를 수동으로 조합해야 했습니다. 새로운 계산을 할 때마다 역전파 코드를 직접 작성해야 한다는 뜻이죠. [그림 7-1]처럼 계산 그래프가 여러 개라면 각각의 계산에 맞게 역전파 코드를 수동으로 따로따로 작성해야 합니다. 그러다 보면  실수가 생길 수도 있고, 무엇보다도 지루할 것입니다. 지루한 일은 파이썬에게 시키자고요!


그래서 이제부터 역전파를 자동화하려 합니다. 더 정확히 말하면, 일반적인 계산(순전파)을 한 번만 해주면 어떤 계산이라도 상관없이 역전파가 자동으로 이루어지는 구조를 만들 것입니다. 두둥! 지금부터가 바로 Define-by-Run의 핵심을 건드리는 내용입니다!

Define-by-Run이란 딥러닝에서 수행하는 계산들을 계산 시점에 '연결'하는 방식으로, '동적 계산 그래프'라고도 합니다. Define-by-Run의 개념과 장점은 제2고지 마지막의 '칼럼:Define-by-Run'에서 자세히 설명합니다.

그런데 [그림7-1]의 계산 그래프들은 모두 일직선으로 늘어선 계산입니다. 따라서 함수의 순서를 리스트 형태로 저장해두면 자중에 거꾸로 추적하는 식으로 역전파를 자동화할 수 있습니다. 그러나 분기가 있는 계산 그래프나 같은 변수가 여러 번 사용되는 복잡한 계산 그래프는 단순히 리스트로 저장하는 식으로 풀 수 없습니다. 우리 목표는 아무리 복잡한 계산 그래프라 하더라도 역전파를 자동으로 할 수 있는 구조를 만련하는 것입니다.


사실 리스트 데이터 구조를 응용하면 수행한 계산을 리스트에 추가해 나가는 것만으로 어떠한 계산 그래프의 역전파도 제대로 해낼 수 있습니다. 이 데이터 구조를 웬거트 리스트(Wergert List)(혹은 테이프(tape))라고 합니다. 이 책에서는 웬거트 리스트에 대한 설명은 하지 않으니 관심 있는 분은 참고 문헌 [2]와 [3]을 참고하시고, 웬거튼 리스트를 활용하는 Define-by-Run의 장점은 참고문헌[4]를 참고해 주세요


6.4 역전파 구현

 이상으로 준비 작업이 끝났습니다. 이번 절에서는 [그림 6-1]에 해당하는 계산의 미분을 역전파로 계산해보겠습니다.


먼저[그림 6-1]을 순전파하는 코드부터 보겠습니다.


A = Square()
B = Exp()
C = Square()

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


이어서 역전파로 y를 미분해보죠. 순전파 때와는 반대 순서로 각 함수의 backward메서드를 호출하면 됩니다. [그림 6-2]는 이때 이루어지는 역전파를 계산 그래프로 그린 모습입니다.


[그림 6-2]를 보면 어떤 순서로 어느 함수의 backward메서드를 호출하면 되는지 알 수 있습니다. 또한 backward메서드의 결과를 어느 변수의 grad로 설정하면 되는지도 알 수 있습니다. 다음은 [그림 6-2]의 계산 그래프를 코드로 옮긴 모습입니다.

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

역전파는 dy/dy = 1에서 시작합니다. 따라서 출력 y의 미분값을 np.array(1.0)로 설정합니다. 그런 다음 C->B->A순으로 backward 메서드를 호출하기만 하면 됩니다. 이것으로 각 변수의 미분값이 구해집니다.

앞의 코드를 실행하면 x.grad의 값이 3.297442541400256 이라고 나옵니다. 이 값이 y의 x에 대한 미분 결과입니다. 4단계에서 수치 미분으로 구한 값이 3.2974426293330694 였으니 두 결과가 거의 같음을 알 수 있습니다. 역전파를 제대로 구현한 것입니다(더 정확하게는, 올바르게 구현했을 가능성이 큽니다).

이상이 역전파 구현입니다. 제대로 동작하지만 역전파 순서(C->B->A)에 맞춰 호출하는 코드를 우리가 일일이 작성해 넣은 건 영 불편할 것 같습니다. 그래서 다음 단계에서는 이 작업을 자동화하겠습니다.



2022년 8월 7일 일요일

6.3 Square 와 Exp클래스 추가 구현

 이어서 Function을 상속한 구체적인 함수에서 역전파(backward)를 구현해보겠습니다. 첫번째 대상은 제곱을 계산하는 Square 클래스입니다. y = x **2 의 미분은 dy/dx = 2x가 되기 때문에 다음처럼 구현할 수 있습니다.

class Square(Function):
  def forward(selfx):
    y = x ** 2
    return y

  def backward(selfgy):
    x = self.input.data
    gx = 2 * x * gy
    return gx

이와 같이 역전파를 담당하는 backward 메서드를 추가했습니다. 이 메서드의 인수 gy는 ndarray 인스턴스이며, 출력 쪽에서 전해지는 미분값을 전달하는 역할을 합니다. 그리고 인수로 전달된 미분에 'y= x ** 2의 미분'을 곱한 값이 backward의 결과가 됩니다. 역전파에서는 이 결괏값을 입력 쪽에 더 가까운 다음 함수로 전파해나갈 것입니다.

이어서 y =  e ** x계산을 할 Exp 클래스입니다. 이 계산의 미분은 dy/dx = e ** 2이기 때문에 다음과 같이 구현할 수 있습니다.

class Exp(Function):
  def forward(selfx):
    y = np.exp(x)
    return y
  
  def backward(selfgy):
    x = self.input.data
    gx = np.exp(x) * gy
    return gx