기계학습/밑바닥딥러닝3 오독오독 씹기

Chapter 2. 자연스러운 코드로(step 17~19)/ 연산자 오버로드

H_erb Salt 2021. 1. 4. 08:56
step_20-22

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

  • 이전 단계부터 Variable을 '투명한 상자'로 만드는 작업ㅇ르 시행했지만 아직 + 와 * 같은 연산자에 대응하는 작업이 남아 있음.
  • 예컨대 Variable 인스턴스 a와 b가 있을 때 y = a*b 처럼 코딩할 수 있으면 아주 유용한데, 이렇게 확장하는 것이 이번 장의 목표
    • 궁극적인 목표는 Variable 인스턴스를 ndarray 인스턴스처럼 '보이게' 만드는 것. 이렇게 하면 DeZero를 평범한 넘파이 코드를 작성하듯 사용할 수 있어 넘파이를 사용해본 사람들이 아주 쉽게 배울 수 있음
  • 이제부터 +와 * 연산자를 지원하도록 Variable을 확장함. 그 첫 번째로 곱셈을 수행하는 함수를 구현함 (덧셈은 11단계에서 구현함).

20.0 지금까지의 구현

In [1]:
import weakref
import numpy as np
import contextlib
In [2]:
class Config:
    enable_backprop = True
In [3]:
@contextlib.contextmanager
def using_config(name, value):
    old_value = getattr(Config, name)
    setattr(Config, name, value)
    try:
        yield
    finally:
        setattr(Config, name, old_value)
In [4]:
def no_grad():
    return using_config('enable_backprop', False)
In [5]:
class Variable:
    def __init__(self, data, name=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

    @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 set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1

    def cleargrad(self):
        self.grad = None

    def backward(self, retain_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]
            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
In [6]:
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x
In [7]:
class Function:
    def __call__(self, *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]

        if Config.enable_backprop:
            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]

    def forward(self, xs):
        raise NotImplementedError()

    def backward(self, gys):
        raise NotImplementedError()
In [8]:
class Exp(Function):
    def forward(self, x):
        y = np.exp(x)

        return y

    def backward(self, dy):
        x = self.input.data
        dx = np.exp(x) * dy

        return dx
    
def exp(x):
    return Exp()(x)
In [9]:
class Square(Function):
    def forward(self, x):
        y = x ** 2
        return y
    
    def backward(self, gy):
        x = self.inputs[0].data
        gx = 2 * x * gy
        return gx
    
def square(x):
    return Square()(x) 
In [10]:
class Add(Function):
    def forward(self, x0, x1):
        y = x0 + x1
        return y

    def backward(self, gy):
        return gy, gy


def add(x0, x1):
    return Add()(x0, x1)

20.1 Mul 클래스 구현

  • 곱셈의 미분은 $y=x_0 \times x_1$ 일 때 $\frac{\partial y}{\partial x_0} = x_1, \frac{\partial y}{\partial x_1} = x_0$가 됨.
  • 이에 대한 역전파는 최종 출력을 $L$ 이라고 할 경우 $L$의 미분을, 정확하게는 $L$의 각 변수에 대한 미분을 전파함.
  • 이 때 변수 $x_0$와 $x_1$에 대한 미분은 각각 $\frac{\partial L}{\partial x_0} = x_1 \frac{\partial L}{\partial y}$과 $\frac{\partial L}{\partial x_1} = x_0 \frac{\partial L}{\partial y}$
    • 우리는 스칼라를 출력하는 합성 함수에 관심이 있음. 그래서 마지막에 $L$이라는 스칼라를 출력하는 합성함수를 가정함. 여기서 $L$은 오차, 다른 말로 손실(loss)를 뜻함
  • 이를 참고하여 Mul 클래스를 구현
In [11]:
class Mul(Function):
    def forward(self, x0, x1):
        y = x0 * x1
        return y
    
    def backward(self, gy):
        x0, x1 = self.inputs[0].data, self.inputs[1].data
        return gy * x1, gy * x0
    

def mul(x0, x1):
    return Mul()(x0, x1)
  • 이제 Mul 함수를 사용하여 곱셈이 가능함. 예를 들어 다음과 같이 코드 작성 가능
In [12]:
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을 확장함. 이를 위해 연산자 오버로드를 이용함
    • 연산자를 오버로드하면 +와 *같은 연산자 사용 시 사용자사 설정한 함수가 호출됨. 파이썬에선 __add____mul__ 같은 특수 메서드를 정의함으로써 사용자 지정 함수가 호출되도록 함

20.2 연산자 오버로드

  • 먼저 곱셈 연산자 를 오버로드함. 곱셈의 특수 메서드는 __mul__(self, other)임. 해당 메서드를 정의(구현)하면 연산자를 사용할 때 메서드가 호출됨.
In [13]:
class Variable:
    def __init__(self, data, name=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
        
    @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 set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
    
    def cleargrad(self):
        self.grad = None
    
    def backward(self, retain_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]
            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
                    
                    
    def __mul__(self, other): # 해당 부분 추가
        return mul(self, other)
In [14]:
a = Variable(np.array(3.0))
b = Variable(np.array(2.0))
y = a*b
print(y)
variable(6.0)
  • 해당 코드를 더 간단하게 구현 가능함. 덧셈도 같이 구현
In [15]:
Variable.__mul__ = mul
Variable.__add__ = add
  • Variable 클래스를 정의한 후 Variable.__mul__ = mul이라고 작성하면 끝. 파이썬에서는 함수도 객체이므로 이와 같이 함수 자체를 할당할 수 있음.
In [16]:
a = Variable(np.array(3.0))
b = Variable(np.array(2.0))
c = Variable(np.array(1.0))

y = a*b+c
y.backward()

print(y)
print(a.grad)
print(b.grad)
print(c.grad)
variable(7.0)
2.0
3.0
1.0

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

  • Variable 인스턴스 a와 b가 있을 때 ab 혹은 a+b 같은 코드도 작성할 수 있음. 하지만 anp.numpy(2.0)처럼 ndarray 인스턴스와 함께 사용할 수는 없음. 3+b처럼 수치 데이터도 함께 사용할 수 없음.
  • ndarray 인스턴스와 수치 데이터와도 함께 사용할 수 있으면 Dezero가 더욱 편리해질 것. 따라서 이번 단계에서는 Variable 인스턴스와 ndarray 인스턴스, int나 float 등도 함께 사용할 수 있도록 작업

21.1 ndarray와 함께 사용하기

  • 우선 Variable을 ndarray 인스턴스와 함께 사용할 수 있게 함. 예를 들어, a가 Variable 인스턴스일 때 a * np.array(2.0)이라는 코드를 만나면 ndarray 인스턴스를 자동으로 Variable 인스턴스로 변환하는 것. 즉, Varaible(np.array(2.0))으로 변환하면 그 다음 계산은 지금까지와 같음
  • 이를 위한 사전 준비로 as_variable이라는 편의 함수를 준비함. 인수로 주어진 객체를 Variable 인스턴스로 변환해주는 함수.
In [17]:
def as_variable(obj):
    if isinstance(obj, Variable):
        return obj
    return Variable(obj)
  • 이 함수는 인수 obj가 Variable 인스턴스 또는 ndarray 인스턴스라고 가정함. obj가 Variable 인스턴스면 아무것도 손보지 않고 그대로 반환하고, 그렇지 않으면 Variable 인스턴스로 변환하여 반환
  • 그럼 Function 클래스의 __call__ 메서드가 as_variable 함수를 이용하도록 다음 코드를 추가함.
In [18]:
class Function:
    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]
        
        if Config.enable_backprop:
            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]
    
    def forward(self, xs):
        raise NotImplementedError()

    def backward(self, gys):
        raise NotImplementedError()
  • 이와 같이 인수 inputs에 담긴 각각의 원소 x를 Variable 인스턴스로 변환함. 따라서 ndarray 인스턴스가 주어지면 Variable 인스턴스로 변환됨. 그러면 이후의 처리는 모든 변수가 Variable 인스턴스인 상태로 진행됨.
    • DeZero에서 사용하는 함수(연산)는 Function 클래스를 상속하므로 실제 연산은 Function 클래스의 __call__ 메서드에서 이루어짐. 따라서 위 메서드에서 가한 수정은 DeZero에서 사용하는 모든 함수에 적용됨
  • 새로운 DeZero를 사용하여 계산 해봄
In [19]:
'''###################################
흐름과는 상관없이 오류 때문에 재생한 코드
###################################'''

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

    def backward(self, gy):
        return gy, gy


def add(x0, x1):
    return Add()(x0, x1)
In [20]:
x = Variable(np.array(2.0))
y = x + np.array(3.0)

print(y)
variable(5.0)

21.2 float, int와 함께 사용하기

  • 이어서 파이썬의 float와 int, 그리고 np.float64와 np.int64 같은 타입과도 함께 사용할 수 있도록 함.
  • x가 Variable 인스넌스 일 때 x + 3.0 같은 코드를 실행할 수 있도록 하려면 add 함수에 다음 코드를 추가하는 방법이 있음
In [21]:
class Add(Function):
    def forward(self, x0, x1):
        y = x0 + x1
        return y

    def backward(self, gy):
        return gy, gy


def add(x0, x1):
    x1 = as_array(x1) # 해당 부분 추가
    return Add()(x0, x1)
  • mul도 추가
In [22]:
class Mul(Function):
    def forward(self, x0, x1):
        y = x0 * x1
        return y
    
    def backward(self, gy):
        x0, x1 = self.inputs[0].data, self.inputs[1].data
        return gy * x1, gy * x0
    

def mul(x0, x1):
    x1 = as_array(x1) # 해당 부분 추가
    return Mul()(x0, x1)
In [23]:
'''###################################
흐름과는 상관없이 오류 때문에 재생한 코드
###################################'''

Variable.__mul__ = mul
Variable.__add__ = add
In [24]:
x = Variable(np.array(2.0))
y = x + 3.0

print(y)
variable(5.0)

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

  • x 2.0은 계산 가능하지만 2.0 x는 계산 불가능함
  • 이를 해결하기 위해 __rmul__, __radd__ 메서드 추가
In [25]:
Variable.__mul__ = mul
Variable.__add__ = add
Variable.__rmul__ = mul
Variable.__radd__ = add
In [26]:
x = Variable(np.array(2.0))
y = 3.0 * x + 1.0

print(y)
variable(7.0)

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

  • ndarray 인스턴스가 좌항이고 Variable 인스턴스가 우항인 경우에 문제 발생
  • 이렇게 되면 좌항인 ndarray 인스턴스의 __add__메서드가 호출됨. 하지만 우리는 우항인 Varaible 인스턴스의 __radd__ 메서드가 호출되길 원하므로, 연산자 우선순위를 지정해야 함.
  • 구체적으로는 Variable 인스턴스의 속성에 __array_priority__를 추가하고 그 값을 큰 수로 설정함.
In [27]:
class Variable:
    __array_priority__ = 200 # 해당 부분 추가
    
    def __init__(self, data, name=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
        
    @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 set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
    
    def cleargrad(self):
        self.grad = None
    
    def backward(self, retain_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]
            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
  • 이렇게 하면 Variable 인스턴스의 연산자 우선순위를 ndarray 인스턴스의 연산자 우선순위보다 높일 수 있음. 그 결과 좌항이 ndarray 인스턴스라 해도 우항인 Variable 인스턴스의 연산자 메서드가 우선적으로 호출됨.
  • 이상이 연산자 오버로드의 조심해야 할 핵심. 마침내 DeZero는 * 와 + 연산자를 서로 다른 타입과 섞어 사용할 수 있게 됨. 다음 단계에서는 / 와 - 와 같은 다른 연산자를 추가함

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

  • 이전 단계에서는 DeZero가 * 와 + 연산자를 지원하도록 확장했는데, 연산자는 이 밖에도 많이 존재함.
  • 연산자를 추가하는 순서
    1. Function 클래스를 상속하여 원하는 함수 클래스를 구현함(ex: Mul 클래스)
    2. 파이썬 함수로 사용할 수 있도록 함(ex: mul 함수)
    3. Variable 클래스의 연산자를 오버로드함(ex: Variable.mul = mul)

22.1 음수(부호변환)

  • 음수의 미분은 $y = -x$ 일때 $\frac{\partial y}{\partial x} = -1$ 임. 따라서 역전파는 상류(출력 쪽)에서 전해지는 미분에 -1을 곱하여 하류로 흘려보내 주면 됨. 그러면 다음과 같이 구현
In [28]:
class Neg(Function):
    def forward(self, x):
        return -x
    
    def backward(self, gy):
        return -gy
    

def neg(x):
    return Neg()(x)

Variable.__neg__ = neg
In [29]:
x = Variable(np.array(2.0))
y = -x # 부호 바꿈
print(y)
variable(-2.0)

22.2 뺄셈

  • 뺄셈의 미분은 $y=x_0 - x_1$ 일 때 $\frac{\partial y}{\partial x_0} = 1, \frac{\partial y}{\partial x_1} = -1$ 임. 따라서 역전파는 상류에서 전해지는 미분값에 1을 곱한 값이 $x_0$ 의 미분 결과가 되며, -1을 곱한 뒤 값이 $x_1$의 미분 결과가 됨.
In [30]:
class Sub(Function):
    def forward(self, x0, x1):
        y = x0 - x1
        return y
    
    def backward(self, gy):
        return gy, -gy
    
    
def sub(x0, x1):
    x1 = as_array(x1)
    return Sub()(x0, x1)


def rsub(x0, x1):
    x1 = as_array(x1)
    return Sub()(x1, x0)

Variable.__sub__ = sub
Variable.__rsub__ = rsub
  • 덧셈과 곱셈은 좌항과 우항의 순서를 바꿔도 결과가 같으므로 둘을 구별할 필요가 없지만, 뺄셈에서는 구별해야 함. 따라서 rsub 함수를 별도로 준비
In [31]:
x = Variable(np.array(2.0))
y1 = 2.0 - x
y2 = x - 1.0
print(y1)
print(y2)
variable(0.0)
variable(1.0)

22.3 나눗셈

  • 나눗셈의 미분은 $\frac{x_0}{x_1}$ 일 때 $\frac{\partial y}{\partial x_0} = \frac{1}{x_0}, \frac{\partial y}{\partial x_1} = -\frac{x_0}{(x_1)^2}$
In [32]:
class Div(Function):
    def forward(self, x0, x1):
        y = x0 / x1
        return y
    
    def backward(self, gy):
        x0, x1 = self.inputs[0].data, self.inputs[1].data
        gx0 = gy / x1
        gx1 = gy * (-x0 / x1 ** 2)
        return gx0, gx1
    

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

def rdiv(x0, x1):
    x1 = as_array(x1)
    return Div()(x1, x0)

Variable.__truediv__ = div
Variable.__rtruediv__ = div
  • 나눗셈도 뺄셈과 마찬가지로 좌/우항 중 어느 것에 적용할지에 따라 적용되는 함수가 다름.

22.4 거듭제곱

  • 거듭제곱은 $y=x^c$ 형태로 표현됨. 이 때 x를 밑이라 하고 c를 지수라 함. 거듭제곱의 미분은 $\frac{\partial y}{\partial x} = cx^{c-1}$이 됨. 지수 c는 상수로 취급하여 따로 미분을 계산하지 않기로 하고 구현
In [33]:
class Pow(Function):
    def __init__(self, c):
        self.c = c
        
    def forward(self, x):
        y = x ** self.c
        return y
    
    def backward(self, gy):
        x = self.inputs[0].data
        c = self.c
        
        gx = c * x ** (c - 1) * gy
        return gx
    

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

Variable.__pow__ = pow
  • 코드를 보면 Pow 클래스를 초기화 할 때 지수 c를 제공할 수 있음. 그리고 순전파 메서드인 forward(x)는 밑에 해당하는 x만(즉, 하나의 항만) 받게 함.
In [34]:
x = Variable(np.array(2.0))
y = x ** 3
print(y)
variable(8.0)
  • 이상으로 목표한 연산자를 모두 추가함. 이번 단계를 통해 DeZero의 유용성을 크게 향상함.
In [ ]: