페이지

2022년 8월 19일 금요일

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에는 몇 가지 문제가 숨어 있습니다. 다음 단계에서는 이 문제들을 먼저 해결하겠습니다.

13.2 Variable 클래스 수정

 그럼 Variable 클래스의 backward메서드를 살펴보겠습니다. 복습할 겸 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()
      x, y = f.input, f.output  # 1 함수의 입출력을 얻는다.
      x.grad = f.backward(y.grad)   # 2 backward 메서드를 호출한다.

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

여기서 주목할 곳은 음영 부분입니다. 우선 while 블럭 안의 1)에서 함수의 입출력 변수를 꺼냅니다. 그리고 2)에서 함수의 backward메서드를 호출 합니다. 지금까지 우리는 1)에서 함수의 입출력이 하나씩이라고 한정했습니다. 이부분을 여러 개의 변수에 대응할 수 있도록 수정하겠습니다.

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]   # 1)
      gxs = f.backward(*gys)    # 2)
      if not isinstance(gxs, tuple):  # 3)
        gxs = (gxs,)
      
      for x, gx in zip(f.inputs, gxs):  # 4)
        x.grad = gx

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


총 네 군데를 수정했습니다. 우선 1)에서 출력 변수인 outputs에 담계 잇는 미분값들을 리스트에 담습니다. 그리고 2)에서 함수 f의 역전파를 호출합니다. 이때 f.backward(*gys)처럼 인수에 별표를 붙여 호출하여 리스트를 풀어줍니다(리스트 언팩). 3) 에서 gxs가 튜플이 아니라면 튜플로 변환합니다.


2)와 3)은 이전 단계에서 순전파 개선 시 활용한 관례와 같습니다. 2)에서 Add클래스의 backward메서드를 호출할 때  인술르 플어서 전달합니다. 3)에서는 Add 클래스의 backward 메서드가 튜플이 아닌 해당 원소를 직접 반환할 수 있게 합니다.


4)에서는  역전파로 전파되는 미분값을 Variable의 인스턴스 변수 grad에 저장해둡니다. 여리게서 gxs와 f.inputs의 각 원소는 서로 대응 관계에 있습니다. 더 정확히 말하면 i번째 원소에 대해 f.inputs[i]의 미분값은 gxs[i]에 대응합니다. zip 함수와 for 문을 이용해서 모든 Variable  인스턴스 각각에 알맞은 미분값을 설정한 것입니다. 이상이 Variable 클래스의 새로운 backward 메서드입니다.

2022년 8월 17일 수요일

13.1 가변 길이 인수에 대응한 Add클래스의 역전파

 역전파를 구현하기에 앞서 [그림 13-1]의 덧셈 계산 그래프를 살펴봅시다.



[그림 13-1]에서 덧셈의 순전파는 입력이 2개, 출력이 1개입니다. 역전파는 그 반대가 되어 입력이 1개, 출력이 2개입니다. 수식으로 확인하면 y = x0 + x1일때 미분하면 dy/dx0 = 1, dy/dx1 = 1이 구해집니다.

y = x0 + x1계산(함수)와 입력 변수는 2개입니다. 이처럼 압력 변수가 여러 개인 함수를 다변수 함수라고 합니다. 다변수 함수에서 하나의 입력 변수에만 주목하여(다른 변수는 상수로 취급) 미분하는 것을 편미분이라고 합니다. 편미분에서는 미분 기호로 를 사용합니다. 예를 들어 dy/dx0  는 x0 이외의 변수를 상수로 생각하고 x0에만 주목하여 미분한다는 뜻입니다. 이 책에서는 앞으로 편미분을 따로 구분하지 않고 '미분'으로 통일해 부르겠습니다. 또한 변수가 하나인 경우에도 수식에서  d 기호를 사용하겠습니다.


덧셈의 역전파는 출력쪽에서 전해지는 미분값에 1을 곱한 값이 입력 변수(x0, x1)의 미분입니다. 즉, 상유에서 흘러오는 미분값을 '그대로 흘려보내는 것'이 덧셈의 역전파입니다. 이상을 반영하여 Add클래스를 다음과 같이 구현합니다.

class Add(Function):
  def forward(selfx0x1):
    y = x0 + x1
    return y

  def backward(selfgy):
    return gy, gy

이와 같이 backward메서드는 입력이 1개, 출력이 2개입니다. 물론 이 코드처럼 여러 개의 값을 반환할 수 있게 하려면 역전파의 핵심 구현을 변경해야 합니다. DeZero에서는 Variable클래스의 backward메서드를 수정하기로 했습니다.