페이지

2022년 8월 26일 금요일

20.1 Mul 클래스 구현

 곰셈의 미분은 y = x0 * x1 일때 dy/dx0 = x1, dy/dx1 = x0가 됩니다. 따라서 역전파는 [그림 20-1]처럼 이루어집니다.


[그림 20-1]에서 보듯 역전파는 최종 출력인 l의 미분을, 정확하게는 l의 각 변수에 대한 미분을 전파합니다. 이때 변수 x0와 x1에 대한 미분은 각각 dl/dx0 = x1*dl/dy 과 dl/dx1 = x0*dl/dy입니다.


우리는 스칼라를 출력하는 합성 함수에 관임이 있습니다. 그래서 [그림 20-1]에서는 마지막에 L이라는 스칼라를 출려하는 합성 함수를 가정했습니다. 여기서 L은 오차, 다른 말로 손실(loss)을 뜻합니다.

그럼 Mul클래스의 코드부터 보죠, [그림 20-1]를 참고하여 다음과 같이 구현할 수 있습니다.

class Mul(Function):
  def forward(selfx0x1):
    y = x0 * x1

    return y
  
  def backward(selfgy):
    x0, x1 = self.inputs[0].data, self.inputs[1].data
    return gy * x1, gy * x0

이어서 Mul클래스를 파이썬 함수로 사용할 수 있도록 해줍니다. 코드는 다음과 같습니다.

def mul(x0x1):
  return Mul()(x0, x1)

이제 mul함수를 사용하여 '곱셈'을 할 수 있습니다. 옐르 들어 다음과 같은 코드를 작성할 수 있습니다.

a = Variable(np.array(3.0))
b = Variable(np.array(2.0))
c = Variable(np.array(1.0))

y = add(mul(a, b), c)
y.backward()

print(y)
print(a.grad)
print(b.grad)



variable(7.0) 2.0 3.0

이와 같이 add 함수와 mul 함수를 함께 사용할 수 있게 됐습니다. 이때 미분도 자동으로 이루어집니다. 다만 매번 y = add(mul(a, b), c)처럼 코딩하기는 번거로울 것 같군요, 지금 보다는 y = a * b + c 형태가 훨씬 깔끔하겠죠? 그래서 + 와 * 연산자를 사용할 수 있도록 Variable을 확장하려 합니다. 이를 위해 연사자 오버로드operator.overload를 이용할 것입니다.


연산자를 오버로드하면 + 와 * 같은 연산자 사용 시 사용자가 설정한 함수가 호출됩니다. 파이썬에서는 __add__와 __mul__ 같은 특수 메서드를 정의함으로써 사용자 지정함수가 호출되도록 합니다.






STEP 20 연산자 오버로드(1)

 이전 단계부터 Variable을 '투명한 상자'로 만드는 작업을 시작했지만 아직 + 와 * 같은 연산자에 대응하는 작업이 남아 있습니다. 예컨대  Variable 인스턴스 a와 b가 있을 때  y = a * b처럼 코딩할 수 있으면 아주 유용한데, 이렇게 확장하는 것이 이번 단계의 목표입니다.


궁극적인 목표는 Variable 인스턴스를 ndarray인스턴스처럼 '보이게 만드는 것입니다. 이렇게 하면 DeZero를 평범한 넘파이 코드를 작성하듯 사용할 수 있어서 넘파이를 사용해본 사람들이 아주 쉽게 배울 수 있습니다.


이제부터 + 와 *연산잘르 지원하도록 Variable을 확장할 것입니다. 그 첫 번째로 곱셈을 수행하는 함수를 구현하려 합니다(덧셈은 11단계에서 구현했습니다). 자, 곰셈을 후행하는 클래스 Mul을 구현해봅시다(Mul은 Multiply의 약자입니다).

19.3 len 함수와 print함수

 이어서 Variable 클래스를 더 확장하여 파이썬의 len함수와도 함께 사용할 수 있도록 하겠습니다. len은 객체 수를 알려주는 파이썬의 표준 함수입니다. 다음과 같이 사용할 수 있지요.

x = [ 1234]
len(x)
4

x = np.array([1234])
len(x)
4

x = np.array([[123], [456]])
len(x)
2

이와 같이 리스트 등에 len함수를 사용하면 그 안에 포함된 원소 수를 반환합니다. ndarray인스턴스라면 첫 번째 차원의 원소 수를 반환합니다. 이제 이 len 함수가 Variable안의 원소수도 인식하도록 해보겠습니다.

class Variable:
  def __init__(selfdataname=None):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{} is not supported'format(type(data)))
    
    self.data = data
    self.name = name
    self.grad = None
    self.creator = None
    self.generation = 0
    
  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     


  def backward(selfretain_grad=False):
    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]  
      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)
      
      if not retain_grad:
        for y in f.outputs:
          y().grad = None   # y는 약한 참조(weakref)

  def cleargrad(self):
    self.grad = None

  
  @property
  def shape(self):
    return self.data.shape

  @property
  def ndim(self):
    return self.data.ndim
  
  @property
  def size(self):
    return self.data.size

  @property
  def dtype(self):
    return self.data.dtype


  def __len__(self):
    return len(self.data)

이와 같이 __len__이라는 특수 메서드를 구현하면 Variable 인스턴스에 대해서도 len 함수를 사용할 수 있게 됩니다. 이제 다음과 같은 코드를 작성할 수 있습니다.


파이썬에서 __init__ 와 __len__등 특별한 의미를 지닌 메서드는 밑줄 두개로 감싼 이름을 사용합니다.

x = Variable(np.array([[123], [456]]))
print(len(x))
2

마지막으로 Variable의 내용을 쉽게 확인할 수 있는 기능을 추가합니다. 바로 print 함수를 사용하여 Variable의 안의 데이터 내용을 출력하는 기능입니다. 즉, 다음 예처럼 사용하고자 합니다.

x = Variable(np.array([123]))
print(x)
variable([1 2 3])

x = Variable(None)
print(x)
variable(None)

x = Variable(np.array([[123], [456]]))
print(x)
variable([[1 2 3] [4 5 6]])

이와 같이 Variable 인스턴스를 print 함수에 건네면 안에 담긴 ndarray인스턴스의 내용을 출력하도록 하겠습니다. 이때 출력 결과는 variable(...) 형태로 통일하여 사용자에게 Variable 인스턴스임을 알려줍니다. 값이 None이거나 내용을 여러 줄로 출력해야 하는 경우도 지원합니다. 여러 줄일때는 공백 문자로 시작 위치를 조정하여 보기 좋게 출력합니다. 다음은 이상의 조건을 만족하는 Variable의 __repr__메서드 모습입니다.

class Variable:
  def __init__(selfdataname=None):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{} is not supported'format(type(data)))
    
    self.data = data
    self.name = name
    self.grad = None
    self.creator = None
    self.generation = 0
    
  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     


  def backward(selfretain_grad=False):
    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]  
      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)
      
      if not retain_grad:
        for y in f.outputs:
          y().grad = None   # y는 약한 참조(weakref)

  def cleargrad(self):
    self.grad = None

  
  @property
  def shape(self):
    return self.data.shape

  @property
  def ndim(self):
    return self.data.ndim
  
  @property
  def size(self):
    return self.data.size

  @property
  def dtype(self):
    return self.data.dtype


  def __len__(self):
    return len(self.data)

  def __repr__(self):
    if self.data is None:
      return 'variable(None)'
    
    p = str(self.data).replace('\n''\n' + ' ' * 9)
    return 'variable('+ p + ')'

이처럼 print 한수가 출력해주는 문자열을 입맛에 맞게 정의하려면 __repr__메서드를 재정의 하면 됩니다. 반환값을 출력하고자 하는 문자열입니다. 앞의 코드에서는 str(self.data)를 이용하여 ndarray 인스턴스를 문자열로 변환했습니다. str 할수 안에서는 ndarray 인스턴스의 __str__ 함수가 호출되고 숫자가 문자열로 반환됩니다. 줄바꿈(\n)이 있으면 줄을 바꾼 후 새로운 줄 앞에 공백9개를 삽입하여 여러 줄에 걸친 출력도 숫자의 시작 위치가 가지런하게 표시되게 했습니다. 마지막으로 변환된 문자열을 'variable(...)' 현태로 감쌉니다.

이상으로 Variable 클래스를 '투명한 상자'로 만드는 작업을 일부 끝마쳤습니다. 다음 단계에서도 이 작업을 계속 이어갈 것입니다.


19.2 ndarray 인스턴스 변수

 Variable은 데이터를 담는 '상자'역할을 합니다. 그러나 사용하는 사람 입장에서 중요한 것은 상자가 아니라 그 안의 '데이터'입니다. 그래서 Variable이 데이터인 것처럼 보이게 하는 장치, 즉 상자를 투명하게 해주는 장치를 만들겠습니다. 

1단계에서 언급했듯이 수치 계산과 머신러닝 시스템은 다차원 배열(텐서)을 기본 데이터 구조로 사용합니다. 따라서 Variable클래스는 (스칼라는 무시하고)  ndarray만을 취급하기로 했습니다. 그래서 이번 절의 목표는 Variable 인스턴스를 ndarray 인스턴스처럼 보이게 하는 것입니다.


Variable 안에 ndarray 인스턴스가 있습니다. 넘파이의 ndarray 인스턴스에는 다차원 배열용 인스턴스 변수가 몇 가지 제공됩니다. 다음은 그중 하나인 shape 인스턴스 변수를 사용하는 모습입니다.

import numpy as np
x = np.array([[123], [456]])
x.shape

인스턴스 변수 shape는 다차원 배열의 형상을 알려줍니다. 참고로 앞의 결과에서 (2, 3)은 수학에서 말하는 2 * 3 행렬을 뜻합니다. 이제 똑같은 작업을 Variable 인스턴스에서도 할 수 있도록 확장하겠습니다.

class Variable:
  def __init__(selfdataname=None):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{} is not supported'format(type(data)))
    
    self.data = data
    self.name = name
    self.grad = None
    self.creator = None
    self.generation = 0
    
  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     


  def backward(selfretain_grad=False):
    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]  
      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)
      
      if not retain_grad:
        for y in f.outputs:
          y().grad = None   # y는 약한 참조(weakref)

  def cleargrad(self):
    self.grad = None

  
  @property
  def shape(self):
    return self.data.shape

shape라는 메서드를 추가한 후 실제 데이터의 shape를 반환하도록 했습니다. 여기서 중요한 부분은 def shape(self): dkvdp cnrkehls @property라는 한줄입니다. 이 한 줄 덕분에 shape 메서드를 인스턴스 변수처럼 사용할 수 있게 됩니다. 확인해 보겠습니다.

x = Variable(np.array([[123], [456]]))
print(x.shape)    # x.shape() 대신 x.shape로 호출할 수 있다.
(2, 3)

이와 같이 메서드 호출이 아닌 인스턴스 변수로 데이터의 형상을 얻을 수 있습니다. 같은 방법으로 ndarray의 다른 인스턴스 변수들을 Variable에 추가할 수 있습니다. 여기에서는 다음 세 인스턴스 변수를 더 추가하겠습니다.


class Variable:
  def __init__(selfdataname=None):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{} is not supported'format(type(data)))
    
    self.data = data
    self.name = name
    self.grad = None
    self.creator = None
    self.generation = 0
    
  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     


  def backward(selfretain_grad=False):
    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]  
      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)
      
      if not retain_grad:
        for y in f.outputs:
          y().grad = None   # y는 약한 참조(weakref)

  def cleargrad(self):
    self.grad = None

  
  @property
  def shape(self):
    return self.data.shape

  @property
  def ndim(self):
    return self.data.ndim
  
  @property
  def size(self):
    return self.data.size

  @property
  def dtype(self):
    return self.data.dtype


보다시피 ndim, size, dtype이라는 3개의 인스턴스 변수를 추가했습니다. ndim은 차원 수, size는 원소 수, dtype은 데이터 타입을 나타냅니다. 이상으로 Variable에 필요한 인스턴스 변수를 모두 추가했습니다. 이외에도 ndarray에는 많은 인스턴스 변수가 존재하며, 그 모두를 추가할 수도 있습니다. 하지만 단순한 작업이라서 지면으로 설명하지 않겠습니다. 더 필요한 독자는 직접 추가햅조기 바랍니다.


이 책은 지금까지 ndarray 인스턴스의 데이터 타입의 dtype은 특별히 의식하지 않고 이야기를 진행했습니다. dtype을 지정하지 않으면 ndarray인스턴스는 (환경에 따라) float64 또는 int64로 초기화됩니다. 한편 신경망에서는 float32를 사용하는 경우가 많습니다.

2022년 8월 25일 목요일

19.1 변수 이름 지정

 앞으로 우리는 수많은 변수를 처리할 것이라서 변수들을 서로 구분할 필요가 없습니다. 변수에 '이름'을 붙여줄 수 있도록 설정하면 해결되겠군요. 그래서 다음과 같이 Variable클래스에 name이라는 인스턴스 변수를 추가했습니다.

class Variable:
  def __init__(selfdataname=None):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{} is not supported'format(type(data)))
    
    self.data = data
    self.name = name
    self.grad = None
    self.creator = None
    self.generation = 0
    
  def set_creator(selffunc):
    self.creator = func
    self.generation = func.generation + 1     


  def backward(selfretain_grad=False):
    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]  
      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)
      
      if not retain_grad:
        for y in f.outputs:
          y().grad = None   # y는 약한 참조(weakref)

  def cleargrad(self):
    self.grad = None

이와 같이 초기화 인수 name=None을 추가하고 그 값을 인스턴스 변수 name에 설정합니다. 이제 예컨대 x = Variable(np.array(1.0)), 'input_x')라고 작성하면 변수 x의 이름은 input_x가 됩니다. 아무런 이름도 주지 않으면 변수명으로 None이 할당됩니다.


변수에 이름을 붙일 수 있다면, 예컨대 계산 그래프를 시작화할 때 변수 이름을 그래프에 표시할 수 있습니다. 계산 그래프 시각화는 25단계와 26단계를 참고하세요.

STEP 19 변수 사용성 개선

DeZero의 기초는 이미 완성했습니다. 지금 상태로도 계산 그래프를 만들고 자동으로 미분을 계산할 수 있습니다. 그래서 앞으로 할 일은 DeZero를 더 쉽게 사용하도록 개선하는 작업입니다.

그 첫걸음으로, 이번 단계에서는 Variable 클래스를 더욱 쉽게 사용할 수 있게 해보겠습니다.

2022년 8월 24일 수요일

18.5 with 문을 활용한 모드 전환

 파이썬에는 with라고 하는, 후처리를 자동으로 수행하고자 할 때 사용할 수 있는 구문이 있습니다. 대표적인 예는 파일의 open과 close입니다. 예를 들어 with문 없이 파일에 무언가를 쓰려면 다음처럼 작성해야 합니다.

f = open('sample.txt''w')
f.write('hello world!')
f.close()

보다시피 open()으로 파일을 열고, 무언가를 쓰고, close()로 파일을 닫습니다. 이때 매번 close()하기란 귀찮기도 하거니와 깜빡하고 잊어버릴 때도 있습니다. with문은 이런 실수를 막아줍니다.

with open('sample.txt''w'as f:
  f.write('hello world!')

이 코드에서는 with블록에 들어갈 때 파일이 열립니다. with블록 안에서 파일은 계속 열린 상태고 블록을 빠져나올때(사용자에게 보이지 않는 곳에서) 자동으로 닫힙니다. 이와 같이 with문을 사용하는 것으로 'with 블록에 들어갈 때의 처리(후처리)'를 자동으로 할 수 있습니다.

이런한 with문의 원리를 이용하여 '역전파 비활성 모드'로 전화하려 합니다. 구체적으로 다음과 같은 식으로 사용하려 합니다(using_config 메서드의 구현은 조금 뒤에 설명합니다.).


with using_config('enable_backprop'False):
  x = Variable(np.array(2.0)
  y = square(x)

이와 같이 with using_config('enable_backprop', False): 안에서만 '역전파 비활성 모드'가 됩니다. 그리고 with블록을 벗어나면 일반 모드, 즉 '역전파 활성 모드'로 돌아갑니다.


'역전파 비활성 모드'로 일시적으로 전환하는 방법은 실전에서 자주 사용됩니다. 예컨대 신경망 학습에서는 모델 평가를 (학습 도중에)하기 위해 기울기가 필요 없는 모드를 사용하는 일이 자주 발생합니다.


그럼 with문을 사용한 모드 전환을 구현해볼까요? contextlib모듈을 사용하면 가장 쉽게 구현할 수 있습니다. 우선 contextlib모듈 사용법을 설명하겠습니다.

import contextlib

@contextlib.contextmanager
def config_test():
  print('start')      # 전처리
  try:
    yield
  finally:
    print('done')     # 후처리

with config_test():
  print('process...')


start process... done


앞의 코드처럼 @contextlib.contextmanager 데코레이터를 달면 문맥(context)을 판단하는 함수가 만들어집니다. 그리고 이 함수 안에서 yield전에는 전처리 로직은, yield 다음에는 후처리 로직을 작성합니다. 그러면 with config_test(): 형태의 구문을 사용할 수 있습니다. 이 구문을 사용하면 with블록 안으로 들어갈 때 전처리가 실행되고 블록 범위를 빠져나올 때 후처리가 실행됩니다.


with 블록 안에서예외가 발생할 수 있고, 발생한 예외는 yield를 실행하는 코드로 전달 됩니다. 따라서 yield는 try/finaliy로 감싸야 합니다.


이상을 바탕으로 using_config 함수를 다음과 같이 구현할 수 있습니다.

import contextlib

@contextlib.contextmanager
def using_config(namevalue):
  old_value = getattr(Config, name)
  setattr(Config, name, value)
  try:
    yield
  finally:
    setattr(Config, name, old_value)

using_config(name, value)의 인수 중 name은 타입이 str이며, 사용할 Config속성의 이름(클래스 속성 이름)을 가리킵니다. 그리고 nmae을 getattr 함수에 넘겨 Config클래스에서 커내옵니다. 그런 다음 setattr함수를 사용하여 새로운 값을 설정합니다.

이제 with 블록에 들어갈 때 name으로 지정한 Config 크래스 속성이 value로 설정됩니다. 그리고 with블록에 빠져나오면서 원래 값(old_value)으로 복원됩니다. 그럼 using_config 함수를 실제로 사용해보죠.

with using_config('enable_backprop'False):
  x = Variable(np.array(2.0))
  y = square(x)

이와 같이 역전파가 필요 없는 경우에는 with 블록에서 순전파 코드만 실행합니다. 이제 불플요한 계산을 생략하고 메모리를 절약할 수 있습니다. 그러나 with using_config('enable_backprop', False): 라는 긴 코드를 매번 적어주기는 귀찮은 일이니 다음과 같이 no_grad라는 편의 함수를 준비했습니다.

def no_grad():
  return using_config('enable_backprop'False)

with no_grad():
  x = Variable(np.array(2.0))
  y = square(x)

no_grad 함수는 단순히 using_conig('enable_backprop', False)를 호출하는 코드를 return으로 돌려줄 뿐입니다. 이제 기울기가 필요 없을 때는 no_grad함수를 호출하면 됩니다.

이상으로 이번 단계를 마칩니다. 앞으로는 기울기 계산이 필요 없을 때, 즉 단순히 순전파 계산만 필요할 때는 방금 구현한 '모드 전환'을 사용하겠 습니다.