페이지

2022년 8월 19일 금요일

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가 되어야 올바른 결과입니다. 즉, 전파되는 미분값의 '합'을 구해야 합니다. 그러나 지금의 구현에서는 그냥 덮어 쓰고있습니다.

STEP 14 같은 변수 반복 사용

 현재 DeZeror에는 문제가 있습니다. 같은 변수를 반복해서 사용할 경우 의도대로 동작하지 않을 수 있다는 문제입니다. y = add(x, x) 계산을 예로 생각해봅시다.


DeZero는 [그림 14-1]처럼 동일한 변수를 사용하여 덧셈을 하면 제대로 미분하지 못합니다. 실제로 어떤 결과가 나오는지 직접 확인해 보겠습니다.

x = Variable(np.array(3.0))
y = add(x, x)
print('y', y.data)

y.backward()
print('x.grad', x.grad)

y 6.0 x.grad 1.0

x = 3.0으로 설정한 후 더 해봤습니다. y의 값은 6.0이라고 재대로 계산했군요. 그러나 x에 대한 미분값(x.grad)을 구하니 1.0이라는 잘못된 결과가 나왔습니다. 제대로 계산한다면 y = x + x일때 y = 2x이니 미분값은 dy/dx = 2 가 됩니다.


13.3 Square 클래스 구현

 지금까지 Variable과 Function클래스가 가변 길이 입출력을 지원하도록 개선했습니다. 그리고 구체적인 함수로서 Add 클래스를 구현했습니다. 마지막으로 Square클래스도 새로운 Variable과 Function클래스에 맞게 수정하겠습니다. 수정할 곳은 단 하나뿐입니다. 다음 코드에서 음영을 입힌 부분이죠.

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

  def backward(selfgy):
    x = self.inputs[0].data #수정전: x = self.input.data
    gx = 2 * x * gy
    return gx

Function 클래스의 인스턴스 변수 이름이 단수형인 input에서 복수형인 inputs로 변경되었으니 바뀐 변수에서 입력 변수 x 를 가져오도록 코드를 수정해주면 됩니다. 이것으로 새로운 Square 클래스도 완성입니다. 그럼 add 함수와 sequare 함수를 실제로 사용해봅시다. 다음은 z = x**2 + y**2를 계산하는 코드입니다.

x = Variable(np.array(2.0))
y = Variable(np.array(3.0))

z = add(square(x), square(y))
z.backward()
print(z.data)
print(x.grad)
print(y.grad)

13.0 4.0 6.0


보다시피 DeZero를 사용하여 z = x**2  + y**2이라는 계산을 z = add(square(x), square(y))라는 코드로 풀어냈습니다. 그런 다음 z.backward()를 호출하기만 하면 미분 계산이 자동으로 이루어집니다!

이상에서 복수의 입출력에 대응한 자동 미분 구조를 완성했습니다. 이제 다른 함수들도 적절히 구현해주면 더 복잡한 계산도 가능할 것입니다. 그러나 사실 지금의 DeZero에는 몇 가지 문제가 숨어 있습니다. 다음 단계에서는 이 문제들을 먼저 해결하겠습니다.