페이지

2022년 8월 19일 금요일

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를 꺼낸 것이죠.


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

15.1 역전파의 올바른 순서

 DeZero의 어디에 문제가 있는 걸까요? 원인을 파악하기 위해 [그림 15-3]과 같은 비교적 간단한 계산 그래프를 생각해보겠습니다. 이 계산 그래프도 현재의 DeZero로는 제대로 미분하지 못합니다.



[그림 15-3]에서 주목할 부분은 계산 중간에 등장하는 변수 a 입니다. 이전 단계에서 설명한 것처럼 같은 변수를 반복해서 사용하면 역전파 때는 출력 쪽에서 전파되는 미분값을 더해야 합니다. 따라서 a의 미분(수식으로는 dy/da)을 계산하려면 a의 출력 쪽에서 전파하는 2개의 미분값이 필요합니다. 그 2개의 미분값이 전해진 '후'에야 a에서 x로 미분값을 전파할 수 있습니다. 이점을 감안한 역전파의 흐름은 [그림 15-4]와 같습니다.

[그림 15-4]는 변수 y로 부터 x로 미분값이 전파되는 흐름을 보여줍니다. 다시 한번 이야기하자면, 여기서 주목할 것은 변수 a에 2개의 미분값이 모두 전파된 '후'에야 a 에서 x로 미분값을 전파한다는 점입니다. 함수의 관점에서 보면 역전파가 D, B, C, A의 순서로 진행되는 것이죠. 단, B와 C의 순서는 상관없으므로 D,C,B,A도 올바른 순서입니다. 받느시 지켜야 할 규칙은 함수 B와 C의 역전파를 모두 끝내고 나서 함수 A를 역전파한다는 것입니다.


STEP 15 복잡한 계산 그래프(이론편)

 지금까지 우리는 [그림 15-1]처럼 한 줄로 늘어선 계산 그래프를 다뤘습니다.


그러나 변수와 함수가 꼭 이렇게 한 줄로 연결되리라는 법은 없습니다. 우리 DeZero는 꾸준히 발전을 거듭하고 있습니다. 그리고 지금의 DeZero는, 예를 들어[그림 15-2]와 같은 계산 그래프도 만들 수 있습니다.


[그림 15-2]처럼 같은 변수를 반복해서 사용하거나 여러 변수를 입력받는 함수를 사용하는 계산을 할 수 있습니다. 이를 통해 더 복잡한 연결을 만들 수 있는 겂이죠. 그러나 불행히도 지금의 DeZero는 이런 계산의 미분은 제대로 계산하지 못합니다. 더 정확하게는 이런 복잡한 연결의 역전파를 제대로 할 수 없습니다.


그래프의 '연결된 형태'를 위상(topology)이라고 합니다. 이번 단계의 목표는 다양한 위상의 계산 그래프에 대응하는 것입니다. 어떤 모양으로 연결된 계산 그래프라도 제대로 미분할 수 있도록 지금부터 새로운 아이디어를 도입하겠습니다.



14.3 미분값 재설정

 방금 역전파 시 미분값을 더해주도록 코드를 수정했습니다. 그런데 이 변경으로 인해 새로운 주의사항이 뛰어나옵니다. 바로 같은 변수를 사용하여 '다른' 계산을 할 경우 계산이 꼬이는 문제입니다. 다음 코드르 예로 살펴봅시다.


# 첫 번째 계산
x = Variable(np.array(3.0))
y = add(x, x)
y.backward()
print(x.grad)

# 두 번째 계산(같은 x를 사용하여 다른 계산을 수행)
y = add(add(x, x), x)
y.backward()
print(x.grad)


2.0 5.0

앞의 코드는 서로 다른 두 가지 미분 계산을 수행했습니다. 그러면서 메모리를 절약하고자 Variable 인스턴스인 x를 재사용했다고 해봅시다. 그 결과 두 번째 x의 미분값에 첫 번째 미분값이 더해지고, 5.0이라는 잘못된 값을 돌려줍니다(3.0이 되어야 합니다).

이문제를 해결하기 위해 Variabvle 클래스에 미분값을 초기화하는 cleargrad메서드를 추가하겠습니다.


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

  def set_creator(selffunc):
    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):  
        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


cleargrad는 미분값을 초기화하는 메서드로, 단순히 self.grad에 None을 대입합니다. 이 메서드를 사용하면 여러가지 미분을 연달아 계산할 때 똑같은 변수를 재사용할 수 있습니다. 앞의 예는 다음과 같이 수정하면 됩니다.

# 첫 번째 계산
x = Variable(np.array(3.0))
y = add(x, x)
y.backward()
print(x.grad)

# 두 번째 계산(같은 x를 사용하여 다른 계산을 수행)
x.cleargrad() #미분값 초기화
y = add(add(x, x), x)
y.backward()
print(x.grad)

2.0 3.0

이번에는 두 번째 미분값도 재대로 구했습니다(두 번째 미분의 올바른 값은 3.0입니다). 예시처럼 두 번째 x.backward()를 호출하기 전에 x.cleargrad()를 호출하면 변수에 누적된 미분값이 초기화 됩니다. 이것으로 다른 계산에 똑같은 변수를 재사용할 때 생기던 문제가 사라졌습니다.


DeZero의 cleargrad메서드는 최적화 문제를 풀 때 유용하게 사용할 수 있습니다. 최적화 문제란 함수의 최솟값과 최댓값을 찾는 문제를 말합니다. 실제로 28단계에서 로젠브록 함수(Rosenbrock function)를 최적화할 때 cleargrad메서드를 사용합니다.


이상으로 이번 단계를 마무리 합니다. 이번 단곌르 거치며  Variable 클래스는 한층 성장했습니다. 그러나 아직도 중요한 문제가 하나 남아 있습니다. 그 문제는 이어지는 15, 16단계에서 해결하겠슷ㅂ니다. 16단계에서 마치면 마침내 Variable클래스가 완성됩니다.

14.2 해결책

 해결책은 간단합니다. 위 설명을 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

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

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


이와 같이 미분값(grad)을 처음 설정하는 경우에는 지금까지와 똑같이 출력 쪽에서 전해지는 미분값을 그대로 대입합니다. 그리고 그 다음번부터는 전달된 미분값을 '더해' 주도록 수정합니다.


앞의 코드에서 미분값을 더할 때 코드를 x.grad = x.gard + gx라고 썼습니다. 그 대신 복잡한 대입 연산자 +=을 사용하여 x.grad += gx 처럼 써도 좋을 것입니다. 그러나 이렇게 하면 문제가 되는 경우가 있습니다. 그 이유와 배경은 다소 복잡하고 딥러닝의 본질적인 문제에서도 벗어나기 때문에 부록 A에서 따로 설명했습니다. 관심 있는 분은 참고하세요.


이제 같은 변수를 반복해서 사용할 수 있습니다. 시험 삼아 앞에서 실패했던 계산에 다시 도전해보겠습니다.

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

2.0

이번에는 2.0이라는 올바른 결과를 얻었습니다. 그렇다면 x를 세 번 사용해도 여전히 잘 작동하느지 봅시다.

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

3.0

결과로 3.0을 얻었습니다. 수식으로 확인하면 y = x + x + x = 3x 이므로 미분하면 3이 나옵니다. 실행 결과와 일치하는군요. 이상으로 같은 변수를 반복 사용하기 위한 구현을 완성했습니다.

14.1 문제의 원인

 왜 x에 대한 미분값(x.grad)이 틀리게 나왔을까요? 원인은 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

  def set_creator(selffunc):
    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)

이 코드에서 알 수 있듯이 현재 구현에서 출력 쪽에서 전해지는 미분값을 그대로 대입합니다. 따라서 같은 변수를 반복해서 사용하면 전달되는 미분값이 덮어 써지는 것입니다. 예를 들어 앞의 덧셈 예에서는 미분값이 [그림 14-2]처럼 전파됩니다.



[그림 14-2]에서 전파되는 미분값도 표시했습니다. 이때 x의 미분은 1 + 1 = 2가 되어야 올바른 결과입니다. 즉, 전파되는 미분값의 '합'을 구해야 합니다. 그러나 지금의 구현에서는 그냥 덮어 쓰고있습니다.