페이지

2022년 8월 19일 금요일

16.4 동작 확인

 이상으로 세대가 큰 함수부터 꺼낼 수 있게 되었습니다. 아무리 복잡한 계산 그래프의 역전파도 올바른 순서로 진행할 수 있게 된 것이죠. 그럼 시험 삼아 [그림 16-4]의 계산을 미분해 봅시다.


코드로는 다음과 같습니다.


결과를 보면 x의 미분은 64.0입니다. 수식으로 확인하면 [그림 16-4]의 계산 그래프는 y = (x **2)**2 + (x**2)**2이므로 간단히 y=2x**4을 미분하는 문제입니다. 이때 y**t = 8x**3 이므로 x = 2.0일때 미분은 64.0입니다. 물론 코드를 실행한 결과와 일치합니다.

축하합니다! 여러분은 드리어 복잡한 계산 그래프도 다룰 수 있게 되었습니다. [그림 16-4]는 여전히 간단한 편이지만, 사실 지금의 DeZero는 아무리 복잡한 '연결'도 제대로 미분할 수 있습니다. 가렬 다음 페이지의 [그림 16-5]와 같은 꼐산 그래프도 문제없습니다!

이상으로 또하나의 단계를 끝마쳤습니다. 이번 단계는 이 책에서 특별히 어려운 부분에 속합니다. 여기까지 잘 쫓아왔다면 곧 DeZero의 제대로 된 실력을 확인할 수 있으니 조금만 더 견뎌 주세요. 다음 단계에서는 DeZero 성능, 특히 메모리 사용량에 대해 살펴보겠습니다.

16.3 Variable 클래스의 backward

 본론으로 돌아와서 Variable 클래스의 backward 메서드를 구현하겠습니다. 이전과 달라진 부분(음영)에 주목해서 살펴보죠.

class Variable:

  def __init__(selfdata):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
    self.data = data
    self.grad = None
    self.creator = None
    self.generation = 0     # 세대 수를 기록하는 변수

  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     #  세대를 기록한다(부모 세대 + 1).


  def backward(self):
    if self.grad is None:
      self.grad = np.ones_like(self.data)


    funcs = []
    seen_set = set()

    def add_func(f):
      if f not in seen_set:
        funcs.append(f)
        seen_set.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

        if x.creator is not None:
          add_func(x.creator) #수정 전 funcs.append(x.creator)   

  def cleargrad(self):
    self.grad = None

가장 큰 변화는 새로 추가된 add_func 함수입니다. 그 동안 'DeZero 함수'를 리스트에 추가할 때 funcs.append(f)를 호출했는데, 대신 add_func 함수를 호출하도록 변경했습니다. 이 add_func함수가 DeZero함수 리스트를 세대 순으로 정렬하는 역할을 합니다. 그 결과 funcs.pop()은 자동으로 세대가 가장 큰 DeZero함수를 꺼내게 됩니다.

참고로 add_func 함수를 backward메서드 안에 중첩 함수로 정의했습니다. 중첩 함수는 주로 다음 두 조건을 충족할 때 적합합니다.

1) 감싸는 메서드(backward 메서드)안에서만 이용한다.

2) 감싸는 메서드(backward 메서드)에 정의된 변수(funcs과 seen_set)를 사용해야 한다.


add_func 함수는 이 조건들을 모두 충족하기 때문에 메서드 안에 정의했습니다.


앞의 구현에서는 seen_set이라는 '집합(set)'을 이용하고 있습니다. funcs리스트에 같은 함수를 중복 추가하는 일을 막기위해서 입니다. 덕분에 함수의 backward메서드가 잘못되어 여러번 불리는 일은 발생하지 않습니다.

16.2 세대 순으로 꺼내기

 지금까지의 수정을 반영하여 일반적인 계산(순전파)을 하면 모든 변수와 함수에 세대가 설정됩니다. 구체적인 예로 [그림 16-3]과 같은 계산 그래프를 살펴보죠.


[그림 16-3]을 보면 함수 A, B, C, D의 세대는 차례로 0,1,1,2입니다. 이렇게 세대가 설정되어 있으면 역전파 때 함수를 올바른 순서로 꺼낼 수 있습니다. 예를 들어 함수 A보다 세대가 큰 B와 C를 먼저 꺼내게 됩니다.


이전 단계에서 이야기한 것처럼 Variable클래스의 backward메서드 안에서는 처리할 함수의 후보들을 funcs리스트에 보관합니다. 따라서 funcs에서 세대가 큰 함수부터 꺼내게 하면 올바른 순서로 역전파를 할 수 있습니다.


이어서 함수를 새대 순으로 꺼낼 차례입니다. 그 준비 작업으로 더미(dummy) DeZero함수를 사용하여 간단한 실험을 해보겠습니다.

generations = [2014,2]
funcs = []

for g in generations:
  f = Function()   # 더미 함수 클래스
  f.generation = g
  funcs.append(f)

[f.generation for f in funcs]

[2, 0, 1, 4, 2]

이와 같이 더미 함수를 준비하고 funcs리스트에 추가합니다. 그런 다음 이 리스트에서 세대가 가장 큰 함수를 꺼내보겠습니다.

funcs.sort(key=lambda x: x.generation)  # 리스트 정렬
[f.generation for f in funcs]

[0, 1, 2, 2, 4]

f = funcs.pop()   # 가장 큰 값을 꺼낸다.
f.generation

4

코드에서 보듯 리스트의 sort 메서드를 이용하여 generation을 오름차순으로 정렬합니다. 이 메서드의 인수인 key=lambda x: x.generation은 '리스트의 원소를 x라고 했을 때 x.generation을 키로 사용해 정렬하라'라는 뜻입니다. 정렬 후 pop메서드를 써서 리스트의 끝 원소를 꺼내면 자연스럽게 세대가 가장 큰 함수를 얻을 수 있습니다.


지금 우리가 원하는 것은 세대가 가장 큰 함수를 꺼내는 것뿐입니다. 따라서 모든 원소를 정렬할 필요 없이 '우선순위 큐'를 이용하면 더 효율적입니다. 이 책에서는 우선순위 쿠까지는 구현하지 않습니다. 관심 있는 분은 직접 구현해보기 바랍니다.(힌트: 파이썬에는 heapq 라는 모듈이 있습니다).


16.1 세대 추가

 먼저 Variable 클래스와 Function 클래스에 인스턴스 변수 generation을 추가하겠습니다. 몇 번째 '세대'의 함수(혹은 변수)인지 나타내는 변수죠. Variable 클래스부터 시작하겠습니다.


class Variable:

  def __init__(selfdata):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
    self.data = data
    self.grad = None
    self.creator = None
    self.generation = 0     # 세대 수를 기록하는 변수

  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     #  세대를 기록한다(부모 세대 + 1).


  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):  
        if x.grad is None:
          x.grad = gx
        else:
          x.grad = x.grad + gx

        if x.creator is not None:
          funcs.append(x.creator)   #

  def cleargrad(self):
    self.grad = None


Variable 클래스는 generation을 0으로 초기화합니다. 그리고 set_creator 메서드가 호출될 때 부모 함수의 세대보다 1만큼 큰 값을 설정합니다. 예를 들어 [그림 16-1]처럼 f.generation이 2인 함수에서 만들어진 변수인 y의 generation 은 3이 됩니다. 이상이 Variable클래스에 추가되는 구현입니다.


다음 차례는 Function 클래스입니다.  Function클래스의 generation은 입력 변수와 같은 값으로 설정합니다. 예를 들어 [그림 16-2]의 왼쪽처럼 입력 변수의 generation 이 4라면 함수의 generation도 4가 됩니다.


입력 변수가 둘 이사이라면 가장 큰 generation의 수를 선택합니다. 예를 들어 [그림 16-2]의 오른쪽처럼 입력 변수가 2개고 각각의 generation이 3과 4라면 함수 D의 generation은 4로 설정합니다. 다음은 이상의 설계를 반영한 Function 클래스의 코드입니다.

class Function(object):
  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])  # generation 설정

    for output in outputs:
      output.set_creator(self)
    self.inputs = inputs
    self.outputs = outputs

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

이 코드의 음영 부분에서 Function의 generation을 설정했습니다.

STEP16 복잡한 계산 그래프(구현편)

 이번 단계에서는 15단계에서 설명한 이론을 코드로 구현합니다. 가장 먼저 순전파 시 '세대'를 설정하는 부분부터 시작하겠습니다. 그런 다음 역전파 시 최근 세대의 함수부터 꺼내도록 합니다. 이렇게 하면 아무리 복잡한 계산 그래프라도 올바른 순서로 역전파가 이루어집니다.

15.3 함수 우선순위

 funcs리스트에는 다음에 처리할 함수의 '후보'들이 들어 있습니다. 그러나 지금까지는 (아무생각없이)'마지막' 원소만 꺼냈습니다. 물론 funcs리스트에서 적절한 함수를 꺼낼 수 있어야 합니다. 앞의 예로 말하면 [B, A]상태의 리스트에서 출력 쪽에 더 가까운 B를 꺼낼 수 잇어야 합니다. 이 문제를 해결하기 위해서는 함수에 '우선순위'를 줄 수 있어야 합니다. 만약 A보다 B의 우선쉬위가 높다면 B를 먼저 꺼내는 식이죠.

그럼 우선순위는 어떻게 설정하면 좋을까요? 첫 번째로, 주어진 계산 그래프를 '분석'하여 알아내는 방법이 있습니다. 가령 위상 정렬(Topological Sort)알고리즘을 사용하면 노드의 연결 방법을 기초로 노드들을 정렬할 수 있습니다. 그 정렬 순서가 바로 우선순위가 됩니다. 그러나 더 쉬운 방법도 있습니다. 사실 우리는 그 답을 이미 목격했습니다.

우리는 일반적인 계산(순전파) 때 '함수'가 '변수'를 만들어내는 과정을 '목격'하고 있습니다. 즉, 어떤 함수가 어떤 변수를 만들어내는가 하는 '창조자-피조물 관계' 혹은 '부모-자식 관계'를 이미 목격하고 있습니다. 이 관계를 기준으로 [그림 15-8]처럼 함수와 변수의 '세대(generations)를 기록할 수 있습니다.


[그림 15-8]에서 말하는 '세대'가 바로 우선순위에 해당합니다. 역전파 시 세대의 수가 큰 쪽부터 처리하면 '부모'보다 '자식'이 먼저 처리됨을 보장할 수 있습니다. [그림 15-8]의 예에서는 함수 B와 A중 하나를 선택해야 할 때 세대수가 큰 B를 먼저 꺼내면 됩니다. 이상이 역전파를 올바른 순서로 진행하는 요령입니다. 다음 단계에서는 이 이론을 코드로 구현하겠습니다.


15.2 현재의 DeZero

 우리 DeZero는 어떻게 구현되어 있는지 살펴보겠습니다. [그림 15-4]의 순서로 연전파하고 있을 까요? 다음 Variable 클래스의 현재 모습입니다. 음영 부분만 주목해보죠.


눈여겨 볼 대상은 funcs리스트입니다. while 블럭의 마지막 줄을 보면 처리할 함수의 후보를 funcs 리스트의 끝에 추가하고 있습니다(funcs.apppend(x.creator)). 그리고 다음에 처리할 함수를 그 리스트의 끝에서 꺼냅니다(funcs.pop()). 이코드대로 진행하면 역전파의 흐름이 [그림 15-5]처럼 됩니다.


[그림 15-5]와 같이 함수의 처리 순서는 D, C, A, B, A가 됩니다. C다음에 A로 바로 이어지는 게 문제군요. 그리고 함수 A의 역전파가 두 번일어나는 것도 문제입니다. 왜 이런 문제가 일어나는지 앞의 코드와 비교하면서 살펴보겠습니다.


가장 먼저 funcs리스트에 D가 추가되어 [D]상태로 시작됩니다. 여기에서 함수 D가 꺼내지고, 그런 다음 D의 입력 변수(D.input)의 창조자인 B와 C가 functions리스트에 추가됩니다(그림 15-6).


이 시전에서는 funcs리스트는 [B, C]입니다. 그런 다음 리스트의 마지막 원소인 C가 꺼내집니다. 그리고 C의 입력 변수의 창조자인 A가 리스트에 추가됩니다. 이 시점의 funcs리스트는 [B, A] 입니다(그림 15-7).


이어서 다시 마지막 원소인 A가 꺼내집니다. 여기서 바로 문제를 일으키는 부분입니다. 원래는 B를 꺼내야 하는데 A를 꺼낸 것이죠.


지금까지 우리는 한줄로 나열된 계산 그래프를 다뤘습니다. 그래서 리스트에서 원소(함수)를 꺼내 처리하는 순서를 고려하지 않아도 괜찮습니다. 리스트에서 원소를 꺼낼 때는 항상 원소가 하나뿐이었기 때문입니다.