페이지

2022년 8월 31일 수요일

23.1 파일 구성

 파일 구성부터 확인하겠습니다. 지금까지는 step01.py, step02.py,.. 처럼 각 step파일 코드를 작성했습니다. 이제부터는 이 step파일 모두에서 DeZero를 이용할 수 있도록 dezero라는 공통의 디렉터리를 하나 만들겠습니다. 그래서 최종 파일 구성은 다음과 같습니다.


이와 같이 구성한 뒤 dezero 디렉터리에 모듈을 추가하는 것입니다. 그리하여 dezero라는 페키지가 만들어지는데, 이 패키지가 바로 우리가 만드는 프레임워크입니다. 앞으로 주로 이 dezero디렉터리에 있는 파일들에 코드를 추가할 것입니다.

STEP 23 패키지로 정리

 지금까지는 단계마다 내용전체를 파일 하나에 담았습니다. step01.py에서 시작하여 step22.py까지 도달했죠, 그런데 어느덧 우리의 DeZero는 크게 '성장'했습니다. 그래서 이번 단계에서는 지금까지의 성과를 재상요할 수 있도록 패키지로 정리할 생각입니다.

참고로 파이썬에서는 '도듈', '패키지', '라이브러리'라는 용어를 사용하는데, 보통 다음의 의미로 통용됩니다.


1) 모듈: 

모듈은 파이썬 파일입니다. 특히 다른 파이썬 프로그램에서 임포트(import)하여 사용하는 것을 가정하고 만들어진 파이썬 파일을 '모듈'이라고 합니다.

2) 패키지:

패키지는 여러 모듈을 묶은 것입니다. 패키지를 만들려면 먼저 디렉터리를 만들고 그 안에 모듈(파이썬 파일)을 추가합니다.

3) 라이브러리

라이브러리는 여러 패키지를 묶은 것입니다. 그래서 하나 이상의 디렉터리로 구성되죠. 때로는 패키지를 가리켜 '라이브러리'라고 부르기도 합니다.

22.4 거듭제곱

 거듭제곱은 y = x **  c 형태로 표현됩니다. 이때 x를 밑이라고 하고 c를 지수라 합니다. 거듭제곱의 미분은 미분 공식으로부터 dy/dx = cx ** ( c - 1)이 됩니다. dy/dc의 값도 구할 수는 있지만 실정에서는 거의 사용되지 않으니 이 책에서는 밑이 x인 경우만 미분해보겠습니다. 즉, 지수 c는 상수로 취급하여 따로 미분을 계산하지 않기로 합니다. 다음은 이를 구현한 코드입니다.

class Pow(Function):
  def __init__(selfc):
    self.c = c

  def forward(selfx):
    y = y ** self.c
    return y

  
  def backward(Selfgy):
    x = self.inputs[0].data
    c = self.c
    gx = c * x ** ( c - 1 )  * gy
    return gx


def pow(xc):
  return Pow(c)(x)

Variable.__pow__ = pow

코들르 보면 Pow클래스를 초기화할 때 지수 c를 제공할 수 있습니다. 그리고 순전파 메서드인 forward(x)는 밑에 해당하는 x만(즉, 하나의 항만)받게 합니다. 그런 다음 특수 메서드인 __pow__에 함수 pow를 할당합니다. 이제 ** 연산자를 사용하여 거듭제곱을 계산할 수 있습니다.

x = Variable(np.array(2.0))
y = x ** 3

print(y)

이상으로 목표한 연산자를 모두 추가했습니다. 이번 단곈느 다소 단조로운 작업의 연속이었지만 그 덕분에 DeZero의 유용성은 크게 향상됐습니다. 사칙연산 연산자들을 자유롭게 계산에 활용할 수 있게 된거죠. 거듭제ㅐ곱도 가능하기 때문에 제법 고급 계산까지 표현할 수 있답니다.

22.3 나눗셈

 나눗셈의 미분은 y = x0/x1 일때 dy/dx0 = 1/x1, dy/dx1 = -x0/(x1) ** 2 입니다. 코드로는 다음처럼 구현할 수 있습니다.

from os.path import getsize
class Div(Function):
  def forward(selfx0x1):
    y = x0 / x1
    return y

  def backward(selfgy):
    x0, x1 = self.inputs[0].data, self.inputs[1].data
    gx0 = gy / x1
    gx1 = gy * (-x0 / x1 ** 2)
    return gx0, gx1

def div(x0x1):
  x1 = as_array(x1)
  return Div()(x0, x1)

def rdiv(x0x1):
  x1 = as_array(x1)
  return Div()(x1, x0)    # x0와 x1의 순서를 바꾼다.

Variable.__truediv__ = div
Variable.__rtruediv__ = rdiv

나눗셈도 뺄셈과 마찬가지로 좌/우항 중 어느, 것에 적용할지에 따라 적용되는 함수가 다릅니다. 그되에는 특별히 어려운 점은 없을 것입니다.

그럼 마지막으로 '거듭제곱'을 살펴보겠습니다.

22.2 뺄셈

 뺄셈의 미분은 y = x0 - x1일 때 dy/dx0 = 1, dy/dx1 = -1 입니다. 따라서 역전파는 상류에서 전해지는 미분값에 1을 곱한 값이 x0의 미분 결과가 되며, -1을 곱한 값이 x1의 미분 결과가  됩ㄴㅣ다. 코드로는 다음처럼 구현할 수 있습니다.

class Sub(Function):
  def forward(selfx0x1):
    y = x0 - x1
    return y

  def backward(selfgy):
    return gy, -gy

def sub(x0x1):
  x1 = as_array(x1)
  return Sub()(x0, x1)

Variable.__sub__ = sub

이제 x0와 x1이 Variable 인스턴스라면 y0 = x0 - x1 계산을 수행할 수 있습니다. 그러나 x0가 Variable 인스턴스가 아닌 경우, 예컨데 y = 2.0 - x 같은 코드는 제대로 처리할 수 없습니다. x의 __rsub__ 메서드가 호출되어 인수가 [그림 22-1]형태로 전달되기 때문이죠.


[그림 22-1]과 같이 __rsub__(self, other)가 호출될 땐느 우항인 x가 인수 self에 전달됩니다. 따라서 다음처럼 구현해야 합니다.

def rsub(x0x1):
  x1 = as_array(x1)
  return Sub()(x1, x0)    # x0 와 x1의 순서를 바꾼다.

Variable.__rsub__ = rsub

보다시피 함수 rsub(x0, x1)을 정의하고 인수의 순서를 바꿔서 Sub()(x1, x0)를 호출하게 합니다. 그런 다음 특수 메서드인 __rsub__에 함수 rsub를 할당합니다.


덧셈과 곰셈은 좌항과 우항의 순석를 바꿔도 결과가 같기 때문에 둘을 구별할 필요가 없었ㅅ브니다. 하지만 뺄셈에서는 좌우를 구별해야 합니다. (x0 - x1과 x1 - x0의 값은 다릅니다). 따라서 우항을 대상으로 했을 때 적용할 함수인 rsub(x0, x1)을 별도로 준비해야 합니다.


이상으로 뺄셈도 할 수 있게 되었습니다. 이제 다음 코드가 잘 작동합니다.

x = Variable(np.array(2.0))
y1 = 2.0 - x
y2 = x - 1.0
print(y1)
print(y2)

2022년 8월 28일 일요일

22.1 음수(부호 변환)

 음수의 미분은 y = -x 일때 dy/dx = -1입니다. 따라서 역전파는 상류(출력 쪽)에서 전해지는 미분에 -1을 곱하여 하류로 흘려보내주면 됩니다. 그러면 다음과 같이 구현할 수 있습니다.


이와 같이 Neg 클래스를 구현한 다음, 파이썬 함수로 사용할 수 있도록 neg 함수도 구현합니다. 그리고 특수 메서드인 __neg__에 neg를 대입하면 완성입니다. 이제 다음 코드를 실행할 수 있습니다.



다음은 '뺄셈' 차례입니다.



2022년 8월 27일 토요일

STEP 22 연산자 오버로드(3)

 이전 단계에서는 DeZero가 *와 +연산자를 지원하도록 확장했는데, 연산자는 이밖에도 많습니다. 그래서 이번 단계에서는 [표 22-1]의 연산자들을 추가하겠습니다.


[표 22-1]의 첫 번째 메서드인 __neg__(self)는 양수를 음수로, 혹은 음수를 양수로 바꿔주는 부호 변환 연산자입니다. 또한 다른 연산자들과 달리 항이 하나뿐인 '단항 연산자'입니다. 그래서 특수 메서드인 인수도 하나뿐이죠, 나머지 연산자들은 차례로 뺄셈, 나눗셈, 거듭제곱으로, 모두 이항 연산자입니다( a - b, a / b등), 따라서 적용 대상이 우항이냐 좌항이냐에 따라 2개의 특수 메서드 중 하나가 선별되어 호출됩니다.단, 거듭제곱은 x ** 3 처럼 좌항이 Variable 인스턴스이고 우항이 상수(2, 3 등의 int)인 경우만을 고려하겠습니다.

파이썬 연산자는 [표22-1]외에도 몇 가지 더 있습니다. 옐르 들어 a // b 와 a % b 같은 연산자도 있고, a +=1 과 a -= 2같은 연산자도 있습니다. 이 책에서는 자주 사용하는 연산자만 선별하여 구현할 테니, 다른 연산자는 독자 여러분이 필요하면 추가하기 바랍니다. 한 가지 더, 이번 단계는 단조로운 작업이기 때문에 생략해도 무방합니다.


그러면 작업을 시작해 볼까요? 우선 복습도 할 겸 새로운 연산자를 추가하는 순서를 살펴보죠,

1. Function 클래스를 상속하여 원하는 함수 클래스를 구현합니다(예: Mul클래스).

2. 파이썬 함수로 사용할 수 있도록 합니다(예: mul 함수).

3. Variable 클래스의 연산자를 오버로드합니다(예: Variable.__mul__ = mul).

이번 단계에서도 똑같은 과정을 거쳐 새로운 연ㅅ간자들을 추가할 것입니다. '부호 변환'부터 시작해 보죠.

21.4 문제점 2: 좌항이 ndarray인스턴스인 경우

 남은 문제는 ndarray 인스턴스가 좌항이고 Variable 인스턴스가 우항인 경우입니다. 예를 들어 다음과 같은 코드입니다.

x = Variable(np.array([1.0]))
y = np.array([2.0] * x)

이 예에서 좌항은 ndarray 인스턴스이고 우항은 Variable 인스턴스입니다. 이렇게 되면 좌항인  ndarray 인스턴스의 __add__ 메서드가 호출됩니다. 하지만 우리는 우항인 Variable 인스턴스의 __radd__ 메서드가 호출되길 원합니다. 그러면서 '연산자 우선순위'를 지정해야 합니다. 구체적으로 Variable 인스턴스의 속성에 __array_priority__를 추가하고 그 값을 큰 정수로 설정해야 합니다. 다음처럼 마리죠.

class Variable:
  __array_priority__ = 200

이렇게 하면 Variable 인스턴스의 연산자 우선순위를 ndarray 인스턴스의 연산자 우선순위보다 높일 수 있습니다. 그 결과 좌항이 ndarray 인스턴스라 해도 우한인 Variable 인스턴스의 연산자 메서드가 우선적으로 호출됩니다.

이상이 연산자 오버로드 시 조심해야 할 핵심입니다. 마침내 DeZero는 *와 + 연산자를 서로 다른 타입과 섞어 사용할 수 있게 되었습니다. 이어서 다음 단계에서는 /와 - 같은 다른 연사자를 추가하겠습니다.

21.3 문제점1: 첫 번째 인수가 flaot나 int인 경우

 현재의 DeZero는 x * 2.0이라는 코드를 제대로 실행할 수 있습니다(x는 Variable 인스턴스). 하지만 2.0 * x를 실행하면 오류가 납니다. 어떤 오류가 나는지 볼까요?

y = 2.0 * x

원인은 2.0 * x를 실행했을 때 오류가 발생하는 과정을 보면 알수 있습니다. 2.0 * x 는 다음 순서로 처리됩니다.

1. 연산자 왼쪽에 있는 2.0의 __mul__메서드를 호출하려 시도한다.

2. 하지만 2.0은  float 타입이므로 __mul__ 메서드는 구현되어 있지 않다.

3. 다음은 * 연산자 오른쪽에 있는 x의 특수 메서드를 호출하려 시도한다.

4. x가 오른쪽에 있기 때문에 (__mul__대신) __mul__ 메서드를 호출하려 시도한다.

5. 하지만 Variable 인스턴스에는 __mul__메서드가 구현되어 있지 않다.

이상이 오류 발생 과정입니다. 핵심은 * 같은 이항 연산자의 경우 피연산자(항)의 위치에 따라 호출되는 특수 메서드가 다르다는 것입니다. 곱셈의 경우 피연사자가 촤항이면 __mul__메서드가 호출되고, 우항이면 __rmul__메서드가 호출됩니다.

따라서 이번 문제는 __rmul__메서드를 구현하면 해결됩니다. 이때 __rmul__메서드의 인수는 [그림 21-1]처럼 전달됩니다.

Variable.__add__ = add
Variable._radd__ =  add
Variable.__mul__ = mul
Variable.__rmul__ = mul

[그림 21-1]과 같이 __rmul__(self, other)의 인수 중 self는 자신인 x에 대응하고, other는  다른 쪽 항인 2.0에 대응합니다. 그런데 곱셈에서는 좌항과 우항을 바꿔도 결과가 같기 때문에 둘을 구별할 필요가 없습니다(2.0 * x 와 x * 2.0의 결과가 똑같죠). 덧셈도 마찬가지이므로 + 와 * 의 특수 메서드는 다음처럼 설정하면 됩니다.


이제 float와 int를 자류롭게 계산할 수 있습니다. 연습삼아 실제로 해보죠

x = Variable(np.array(2.0))
y = 3.0 * x + 1.0
print(y)

이제 Variable 인스턴스와 float, int 를 함께 사용할 수 있습니다. 이어서 나머지 문제 하나를 마저 해결해보죠.




21.2 float, int와 함께 사용하기

 이어서 파이썬의 float와 int, 그리고 np.float64와 같은 타입과도 함께 사용할 수 있도록 하겠습니다. x가 Variable 인스턴스일 때 x + 3.0 같은 코드를 실행할 수 있도록 하러면 어떻게 해야 할까요? 한가지 방법으로 add 함수에 다음의 음영부분의 코드를 추가하는 방법이 떠오르는군요.

def add(x0x1):
  x1 = as_array(x1)
  return Add()(x0, x1)

여기에서는 as_array 함술르 사용했씁니다. 9단계에서 구현한 함수죠. as_array를 사용하면 x1이 float나 int인 경우 ndarray인스턴스로 변환됩니다. 그리고 ndarray 인스턴스는 (이후에)Function 클래스에서 Variable 인스턴스로 변환됩니다. 이것으로 다음과 같은 코드르 작성할 수 있게 됐습니다.

x = Variable(np.array(2.0))
y = x + 3.0
#y = add(x, 3.0)
print(y)

이와같이 float와 Variable인스턴스를 조합한 계산이 가능해졌습니다. 여기서는 add함수만 대표로 수정해봤지만, mul과 같은 다른 함수들도 같은 방식으로 수정할 수 있습니다. 다 수정하고 나면 +나 *로 Variable 인스턴스, float, int를 조합하여 계산할 수 있습니다. 그런데 실은 지금의 방식에는 두가지 문제가 남아 있습니다.

21.1 ndarray와 함께 사용하기

 우선 Variable응 ndarray 인스턴스와 함께 사용할 수 있게 하겠습니다. 전략은 간단합니다. 예를 들어 a가 Variable 인스턴스일 때 a *  np.array(2.0)이라는 코드를 만나면 ndarray인스턴스를 자동으로 Variable 인스턴스로 변환하는 것입니다. 즉, Variable(np.array(2.0))으로 변환해버리면 그다음 계산은 지금까지와 같습니다.

이를 위한 사전 준비로 as_variable이라는 편의 함수를 준비합니다. 인수로 주어진 객체를 Variable 인스턴스로 변환해부는 함수입니다. 구현은 다음과 같습니다.

def as_variable(obj):
  if isinstance(obj, Variable):
    return obj
  return Variable(obj)

이 함수는 인수 obj가 Variable 인스턴스 또는 ndarray 인스턴스라고 가정합니다.  obj가 Variable 인스턴스면 아무것도 손보지 않고 그대로 반환하고, 그렇지 않으면 Variable인스턴스로 변환하여 반환합니다.

그럼 Function클래스의 __call__ 메서드가 as_variable 함수를 이용하도록 다음 음영부분의 코드를 추가합니다.

import weakref    
class Function(object):
  def __call__(self, *inputs):
    inputs = [as_variable(x) for x in 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]) 

    for output in outputs:
      output.set_creator(self)  
    self.inputs = inputs  #
    self.outputs = [weakref.ref(output) for output in outputs]  
   
    return outputs if len(outputs) > 1 else outputs[0]

이와 같이 인수 inputs에 담긴 각각의 원소 x를 Variable 인스턴스로 변환합니다. 따라서 ndarray 인스턴스 주어지면 Variable 인스턴스로 변환됩니다. 그러면 이후의 처리는 모든 변수가 Variable 인스턴스인 상태로 진행됩니다.

DeZero에서 사용하는 모든 함수(연산)는 Function클래스를 상속하므로 실제 연산은 Function클래스의 __call__ 메서드에서 이루어집니다. 따라서 이 __call__ 메서드에 가한 수정은 DeZero에서 사용하는 모든 함수에 적용됩니다.


그러면 새로운 DeZeror를 사용하여 계산을 해봅시다.

x = Variable(np.array(2.0))
y = add(x, np.array(3.0))
#y = x + np.array(3.0)
print(y)
variable(5.0)

y = x + np.array(3.0)이라는 코드를 실행했고, 출력을 보면 제대로 작동함을 알 수 있습니다. ndarray 인스턴스가 Variable 인스턴스로 자동 변환된 결과죠, 이렇게 ndarray와 Variable을 함께 사용할 수 있게 되었습니다.


2022년 8월 26일 금요일

STEP 21 연산자 오버로드(2)

 DeZero가 점점 편리해지고 있습니다. 이제 우리는 Variable 인스턴스 a 와 b가 있을 때 a * b 혹은  a + b 같은 코드도 작성할 수 있지요. 하지만 안타깝게도 a * np.array(2.0) 처럼 ndarray 인스턴스와 수치 데이터와도 함께 사용할 수 있게 되면 DeZero가 더욱 편리할 텐데 말이죠. 그래서 이번 단계에서는 Variable 인스턴스와 ndarray인스턴스, 심지어 int나 float등도 함께 사용할 수 있도록 해보겠습니다.

20.2 연산자 오버로드

먼저 곱셈 연산자 *를 오버로드하겠습니다. 곱셈의 특수 메서드는 __mul__(self, other) 입니다(인수 self와 other에 대해서는 조금 뒤에 설명합니다). __mul__메서드를 정의(구현)하면 * 연산자를 사용할 때 __mul__ 메서드가 호출됩니다. 시험 삼아 Variable 클래스의 __mul__ 메서드를 다음과 같이 구현해보겠습니다.


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 + ')'

  def __mul__(selfother):
    return mul(self, other)


지금까지 구현한 Variable 클래스에 이 __mul__ 메서드를 추가합니다. 이제부터 *를 사용하면 __ㅡmul__ 메서드 대신 불리고, 다시 그 안의 mul 함수가 불리게 됩니다. 시험해볼까요?


a = Variable(np.array(3.0))
b = Variable(np.array(2.0))
y = a * b
print(y)

variable(6.0)



보다시피 y = a * b라는 코드를 문제없이 실행할 수 있습니다. a * b가 실행될 때 인스턴스 a의 __mul__(self, other) 메서드가 호출됩니다. 이때 [그림 20-2]와 같이 연산자 * 왼쪽의 a가 인수 self에 전달되고, 오른쪽 b가 other에 전달됩니다.


앞의 예에서 a * b가 실행되면 먼저 인스턴스 a의 특수 메서드인 __mul__ 호출됩니다. 그런데 만약 a에 __mul__ 메서드가 구현되어 있지 않으면 인스턴스 b의 * 연산자 특수 메서드가 호출됩니다. 이 경우 b는 * 연산자의 오른쪽에 위치하기 때문에 __mul__이 아닌 __mul__이라는 특수 메서드가 호출됩니다(메서드 이름 앞에 오른쪽(right)을 뜻하는 'r'이 붙어 있습니다).


이상으로 * 연산자를 오버로드해봤습니다. 정확히는 Variable 클래스의 __mul__ 메서드를 구현했습니다. 그런데 이와 똑같은 작업을 다음 코드처럼 간단히 처리하는 방법도 있습니다.

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 + ')'


  Variable.__mul__ = mul
  Variable.__add__ = add


Variable 클래스를 정의한 후 Variable.__mul__ = mul이라고 작성하면 끝! 파이썬에서는 함수도 객체이므로 이와 같이 함수 자체를 할당할 수 있습니다. 이렇게 하면 Variagble 인스턴스의 __mul__ 메서드를 호출할 때 mul 함수가 불립니다.

앞의 코드에서 + 연산자의 특수 메서드인 __add__도 설정했습니다. + 연샂자도 함께 오버로드한 것이죠. 그럼 + 와 * 를 모두 사용하여 계산을 해 보겠습니다.


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 = a * b + c
y.backward()

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

variable(7.0) 2.0 3.0


보다시피 y = a * b + c 형태로 코등하는게 가능해졌습니다. 계산 시 + 와  * 를 자유롭게 사용할 수 있게 된 것이죠, / 와 - 같은 다른 연산자도 같은 방식으로 구현할 수 있습니다. 그럼 다음 단계에서도 계속 연산자 오버로드를 살펴보겠습니다.

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 클래스를 더욱 쉽게 사용할 수 있게 해보겠습니다.