AI/딥러닝 프레임워크 기초

[프레임워크 기초] 제2고지. 자연스러운 코드(후편)

kk_______yy 2024. 2. 8. 14:44

18. 메모리 절약 모드

DeZero의 메모리 사용을 개선할 수 있는 구조 두 가지를 도입하자!

  1. 역전파 시 사용하는 메모리양을 줄이는 방법 : 불필요한 미분 결과 보관하지 않고 즉시 삭제
  2. '역전파가 필요 없는 경우용 모드'를 제공하는 것 : 불필요한 계산을 생략

 

18.1 필요 없는 미분값 삭제

현재의 DeZero : 모든 변수가 미분값을 변수에 저장

x0 = Variable(np.array(1.0))
x1 = Variable(np.array(1.0))
t = add(x0, x1)
y = add(x0, t)
y.backward()

print(y.grad, t.grad)    # 1.0 1.0
print(x0.grad, x1.grad)  # 2.0 1.0
  • y.backward()를 실행하여 미분하면 모든 변수가 미분 결과를 메모리에 유지
  • 머신러닝에서는 말단 변수(x0, x1) 미분값만 필요할 때가 많음
  • 중간 변수에 대해 미분값을 제거하는 모드 추가

 

수정한 DeZero

class Variable:
    ...
    
    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]  # output is weakref
            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는 약한 참조     *
  • retain_grad를 추가
    True : 지금까지처럼 모든 변수가 미분 결과(기울기)를 유지
    False(기본값) : 중간 변수의 미분값을 모두 None으로 재설정
  • 원리 : backward 메서드 마지막 for문으로 각 함수의 출력 변수의 미분값을 유지하지 않도록 y().grad = None으로 설정
    → 말단 변수 외에는 미분값 유지하지 않음

 

* NOTE

  • 마지막 y().grad = None에서 y에 접근할 때 y()라고 한 이유는 y가 약한 참조이기 때문
  • 이 코드가 실행되면 참조 카운트가 0이 되어 미분값 데이터가 메모리에서 사라짐

 

x0 = Variable(np.array(1.0))
x1 = Variable(np.array(1.0))
t = add(x0, x1)
y = add(x0, t)
y.backward()

print(y.grad, t.grad)    # None None
print(x0.grad, x1.grad)  # 2.0 1.0
  • 중간 변수인 y와 t의 미분값이 삭제되어 그만큼의 메모리를 다른 용도로 사용할 수 있게 됨

 

18.2 Function 클래스 복습

기존 코드

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]

        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]
  • DeZero에서 미분 하려면 순전파를 수행한 뒤 역전파
  • 역전파 시에는 순전파의 계산 결과가 필요하므로 순전파 때 결괏값 기억

 

  • 함수는 입력을 inputs라는 '인스턴스 변수'로 참조
    * 인스턴스 변수 : 클래스에 정의된 변수, 클래스 변수
  • inputs가 참조하는 변수의 참조 카운트가 1만큼 증가, __call__을 벗어나도 메모리에 생존
  • inputs는 역전파 계산 시 사용 → 필요없는 경우도 있음

 

* CAUTION

학습(훈련) : 미분값 구해야 함

추론 : 단순히 순전파만 하기 때문에 중간 계산 결과를 곧바로 버리면 메모리 사용량 크게 줄임

 

18.3 Config 클래스를 활용한 모드 전환

순전파만 할 경우를 위한 개선 추가

  • 역전파 활성 모드
  • 역전파 비활성 모드
class Config:
    enable_backprop = True

enable_backprop은 역전파가 가능한지 여부 의미

True : 역전파 활성 모드

 

* CAUTION

  • 설정 데이터는 단 한 군데에만 존재하는 것이 좋음
    → 인스턴스화하지 않고 '클래스' 상태로 이용
  • 인스턴스는 여러 개 생성할 수 있지만, 클래스는 항상 하나만 존재하기 때문
  • 따라서 Config 크래스가 '클래스 속성'을 갖도록 설정

 

Function에서 참조하게 하여 모드 전환

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])    # 1. 세대 설정
            for output in outputs:
                output.set_creator(self)    # 2. 연결 설정
            self.inputs = inputs
            self.outputs = [weakref.ref(output) for output in outputs]

        return outputs if len(outputs) > 1 else outputs[0]
  • Config.enable_backprop이 True일 때만 역전파 코드 실행
  • 역전파 비활성 모드에서 필요하지 않은 것
    → 1에서 정하는 '세대'는 역전파 시 노드를 따라가는 순서를 정하는데 사용하므로 x
    → 2는 계산들의 '연결'을 만드므로 x

 

18.4 모드 전환

모드 전환 예시 코드

Config.enable_backprop = True
x = Variable(np.ones((100, 100, 100)))
y = square(square(square(x)))
y.backward()

Config.enable_backprop = False
x = Variable(np.ones((100, 100, 100)))
y = square(square(square(x)))

형상이 (100, 100, 100)인 텐서에 square 함수 세 번 적용

True : 중간 계산 결과가 계속 유지되어 그만크 메모리 차지

False : 중간 계산 결과는 사용 후 곧바로 삭제됨

 

 

18.5 with문을 활용한 모드 전환

with문을 사용한 예시

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

# 후
with open('sample.txt', 'w') as f:
    f.write('hello world!')
  • open()으로 파일을 열고, 무언가 쓰고, close()로 파일 닫음 → close() 귀찮고 까먹기 쉬움
  • with는 이런 실수 막아줌
    → with 블록에 들어갈 때 파일이 열림, with 블록 안에서 파일은 계속 열린 상태
    → 블록을 빠져나올 때 자동으로 닫힘

 

with 문의 원리 이용하여 '역전파 비활성 모드'로 전환하는 코드

with using_config('enable_backprop', False):
    x = Variable(np.array(2.0))
    y = square(x)
  • with 블록 안에서만 '역전파 비활성 모드'
  • with 블록 벗어나면 일반 모드, '역전파 활성 모드'

* NOTE

'역전파 비활성 모드'로 일시적으로 전환하는 방법 실전에서 자주 사용

→ 모델 평가를 위해 기울기가 필요없는 모드 사용할 때

 

import contextlib

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

with config_test():
    print('process...')
    
# 결과
start
process...
done
  • @contextlib.contextmanager 데코레이터를 달면 문맥을 판단하는 함수가 만들어짐
  • 함수 안에서 yield 전에는 전처리 로직, yield 다음에는 후처리 로직을 작성
  • with config_test(): 형태의 구문을 사용할 수 있음
  • with 블록 안으로 들어갈 때 전처리 실행, 블록 범위 빠져나올 때 후처리 실행

 

* NOTE

  • yield는 try/finally로 감싸야 함
  • with 블록 안에서 예외가 발생할 수 있고, 발생한 예외는 yield를 실행하는 코드로도 전달

 

Using_config 함수 구현

import contextlib

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

using_config(name, value)

  • 인수 name : str 타입, 사용할 Config 속성의 이름(클래스 속성 이름)을 가리킴
  • name을 getattr 함수에 넘겨 Config 클래스에서 꺼내옴
  • setattr 함수를 사용하여 새로운 값을 설정
with using_config('enable_backprop', False):
    x = Variable(np.array(2.0))
    y = square(x)

with 블록

  • with 블록에 들어갈 때 name으로 지정한 Config 클래스 속성이 value로 설정
  • with 블록을 빠져나오면서 원래 값(old_value)로 복원
  • 역전파가 필요 없는 경우에는 with 블록에서 순전파 코드만 실행 → 불필요한 계산 줄이고, 메모리 절약
def no_grad():
    return using_config('enable_backprop', False)
    
with no_grad():
    x = Variable(np.array(2.0))
    y = square(x)

no_grad()

  • 단순히 using_config('enable_backprop', False)를 호출하는 코드를 return
  • 기울기가 필요없을 때는 no_grad 함수 호출하면 됨 = '모드 전환'

 

 

19. 변수 사용성 개선

DeZero를 더 쉽게 사용하도록 개선

 

19.1 변수 이름 지정

수많은 변수를 처리할 예정 → 변수에 '이름'을 붙여줄 수 있도록 설정

 

Variable 클래스에 name 인스턴스 변수 추가

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
        
    ...
  • 초기화 변수 name=None 추가, 그 값을 인스턴스 변수 name에 설정
  • x = Variable(np.array(1.0), 'input_x')라고 작성하면 변수 x의 이름은 input_x 가 됨
  • 아무런 이름도 주지 않으면 변수명 None 할당

 

* NOTE

변수에 이름 붙일 수 있다면, 계산 그래프를 시각화할 때 변수 이름을 그래프에 표시할 수 있음

 

 

19.2 ndarray 인스턴스 변수

Variable은 데이터를 담는 '상자' 역할, 중요한 것은 그 안의 '데이터'

Variable이 데이터인 것처럼 보이게 하는 장치를 만들자

 

* NOTE 

이번 절의 목표 Variable 인스턴스를 ndarray 인스턴스처럼 보이게 하는 것

 

ndarray 인스턴스의 다차원 배열용 인스턴스 변수

import numpy as np
x = np.array([[1, 2, 3], [4, 5, 6]])
x.shape

>>> (2, 3)
  • 넘파이의 ndarray 인스턴스에는 다차원 배열용 인스턴스 변수 몇 가지 제공
    → 똑같은 작업을 Variable 인스턴스에서도 할 수 있도록 확장

 

Variable 인스턴스 안에서 ndarray 인스턴스 변수

class Variable:
    ...
    
    @property
    def shape(self):
        return self.data.shape
        
# 테스트
x = Variable(np.array([[1, 2, 3], [4, 5, 6]]))
print(x.shape)

# 실행 결과
(2, 3)
  • shape라는 메서드를 추가할 후 셀제 데이터의 shape를 반환하도록 함
  • @property : shape 메서드를 인스턴스 변수처럼 사용할 수 있게 함
  • 메서드 호출이 아닌 인스턴스 변수로 데이터의 형상을 얻음

 

인스턴스 변수 추가 : ndim, size, dtype

class Variable:
    ...

    @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 : 데이터 타입

 

19.3 len 함수와 print 함수

len : 객체 수를 알려주는 파이썬의 표준 함수

 

len 함수 예시

x = [1, 2, 3, 4]
len(x)
>>> 4

x = np.array([1, 2, 3, 4])
len(x)
>>> 4

x.np.array([[1, 2, 3], [4, 5, 6]])
len(x)
>>> 2
  • ndarray 인스턴스라면 첫 번째 차원의 원소 수 반환

 

Variable 인스턴스에서 len 함수 구현

class Variable:
    ...
    
    def __len__(self):
        return len(self.data)
        
# 테스트 코드
x = Variable(np.array([[1, 2, 3], [4, 5, 6]]))
print(len(x))

# 실행 결과
2
  • __len__이라는 특수 메서드를 구현하면 Variable 인스턴스에 대해서도 len 함수 사용할 수 있게 됨

* NOTE

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

 

print 함수 사용하여 Variable의 안의 데이터 내용 출력 기능 구현

class Variable:
    ...

    def __repr__(self):
        if self.data is None:
            return 'variable(None)'
        p = str(self.data).replace('\n', '\n' + ' ' * 9)
        return 'variable(' + p + ')'
# 테스트 코드
x = Variable(np.array([1, 2, 3]))
print(x)

x = Variable(None)
print(x)

x = Variable(np.array([[1, 2, 3], [4, 5, 6]]))
print(x)
# 실행 결과
variable([1 2 3])
variable(None)
variable([[1 2 3]
          [4 5 6]])
  • Variable 인스턴스를 print 함수에 건네면 내부 ndarray 인스턴스의 내용 출력
  • variable(...) 형태로 통일하여 Variable 인스턴스임을 알림
  • None이거나 여러 줄 출력도 지원
  • __repr__ 메서드 재정의 : print 함수가 출력해주는 문자열을 입맛에 맞게 정의
  • str(self.data)를 이용하여 문자열로 변환 (__str__ 함수 호출, 숫자가 문자열로 변환)
  • 줄바꿈(\n)이 있으면 줄을 바꾼 후 새로운 줄 앞에 공백 9개 삽입하여 여러 줄에 걸친 출력 가지런하게 표시

 

 

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

+, * 와 같은 연산자에 대응하는 작업

 

* NOTE

궁극적인 목표는 Variable 인스턴스를 ndarray 인스턴스처럼 '보이게' 만드는 것

 

20.1 Mul 클래스 구현

곱셈의 미분은 y = x0 × x1 일 때,

∂y / ∂x0 = x1,

∂y / ∂x1 = x0

 

[그림 20-1]에서 역전파는 최종 출력인 L의 미분을, 정확하게는 L의 각 변수에 대한 미분을 전파

x0에 대한 미분 : ∂L / ∂x0 = x1 * ∂L / ∂y,

x1에 대한 미분 : ∂L / ∂x1 = x0 * ∂L / ∂y

 

Mul 클래스 구현

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

 

Mul 클래스를 파이썬 함수로 사용할 수 있게 함

def mul(x0, x1):
    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

 

mul 형태는 번거로우니 +, * 연산자를 사용할 수 있도록 Varuiable 확장

연산자 오버로드(operator overload) 이용

 

* NOTE : 연산자 오버로드

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

 

20.2 연산자 오버로드

곱셈 연산자 *를 오버로드

곱셈의 특수 메서드 __mul__ : __mul__를 정의하면 * 연산자 사용시 __mul__메서드 호출

 

* 오버로드 구현

Variable:
    ...
    
    def __mul(self, other):
        rturn mul(self, other)
# 테스트 코드
a = Variable(np.array(3.0))
b = Variable(np.array(2.0))
y = a * b
print(y)

# 실행 결과
variable(6.0)
  • a * b가 실행될 때 인스턴스 a의 __mul__(self, other) 메서드 호출
  • 연산자 * 왼쪽의 a가 인수 self에 전달, 오른쪽의 b가 other에 전달

* CAUTION

  • a * b가 실행되면 인스턴스 a의 특수 메서드인 __mul__이 호출
  • 만약 a에 __mul__ 메서드가 구현되어 있지 않으면 인스턴스 b의 * 연산자 특수 메서드가 호출 (__rmul__)

 

더 간단하게 오버로드한 코드

class Variable:
    ...
    
Variable.__mul__ = mul
Variable.__add__ = 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

 

 

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

a * np.array(2.0)과 같은 ndarray 인스턴스와 함께 사용할 수는 없음

Variable 인스턴스, ndarray 인스턴스, int나 float 등도 함께 사용할 수 있도록 구현

 

21.1 ndarray와 함께 사용하기

전략 ex) a가 Variable 인스턴스일 때 a * np.array(2.0)이라는 코드를 만나면?

ndarray 인스턴스를 자동으로 Variable 인스턴스로 변환

→ np.array(2.0)을 Variable(np.array(2.0))으로 변환

def as_variable(obj):
    if isinstance(obj, Variable):
        return obj
    return Variable(obj)
  • as_variable : 인수로 주어진 객체를 Variable 인스턴스로 변환
  • obj : Variable 인스턴스 또는 ndarray 인스턴스라고 가정
            Variable 인스턴스가 아니면 Variable 인스턴스로 변환하여 반환

 

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)
        ...

inputs에 담긴 각각의 원소 x를 Variable 인스턴스로 변환 

 

* NOTE

  • DeZero에서 사용하는 모드 함수(연산)는 Function 클래스를 상속
  • 실제 연산은 Function 클래스의 __call_ 메서드에서 이루어짐
  • __call__ 메서드에 가한 수정은 DeZero에서 사용하는 모든 함수에 적용

 

계산 예시

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

# 실행 결과
VARIABLE(5.0)

 

 

 

21.2 float, int와 함께 사용하기

방법 : add 함수에 as_array(x) 코드를 추가하는 것

def add(x0, x1):
    x1 = as_array(x1)    *
    return Add()(x0, x1)
  • as_array : float나 int인 경우 ndarray 인스턴스로 변환
  • ndarray 인스턴스는 이후 Function 클래스에서 Variable 인스턴스로 변환
# 테스트 코드
x = Variable(np.array(2.0))
y = x + np.array(3.0)
print(y)

# 실행 결과
variable(5.0)

mul과 같은 다른 함수도 같은 방식으로 수정
→ 다 수정하고 나면 +나 *로 Variable 인스턴스, float, int를 조합하여 계산 할 수 있음

 

그러나... 문제점

 

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

오류 코드

y = 2.0 * x

>>> TypeError: unsupported operand type(s) for *: 'float' and 'Variable'

 

오류가 발생하는 과정

  1. 연산자 왼쪽에 있는 2.0의 __mul_ 메서드를 호출하려 시도
  2. 하지만 2.0은 float 타입이므로 __mul__ 메서드는 구현되어 있지 않음
  3. 다음은 * 연산자 오른쪽에 있는 x의 특수 메서드를 호출하려 시도
  4. x가 오른쪽에 있기 때문에 (__mul__ 대신) __rmul__메서드를 호출하려 시도
  5. 하지만 Variable 인스턴스에는 __rmul_메서드가 구현되어 있지 않음

* 핵심

  • * 같은 이항 연산자의 경우 피연산자(항)의 위치에 따라 호출되는 특수 메서드 다름
  • 곱셈의 경우 피연산자 좌항이면 __mul__ 메서드 호출, 우항이면 __rmul__메서드 호출

==> __rmul__ 메서드 구현하면 해결!

 

 

  • __rmul__(self, other)의 인수 중 self는 자신인 x에 대응, other는 다른 쪽 항인 2.0에 대응
  • 곱셈은 좌항과 우항 바꿔도 결과 같으므로 둘 구분할 필요 없음

 

다음과 같이 설정

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

 

테스트 코드

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

>>> variable(7.0)
  • Variable 인스턴스와 float, int를 함께 사용할 수 있음

 

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

오류 코드 : 좌항인 ndarray 인스턴스의 __add__ 메서드가 호출

x = Variable(np.array([1.0]))
y = np.array([2.0]) + x
  • 좌항은 ndarray 인스턴스, 우항은 Variable 인스턴스
    → 우리는 우항인 Variable 인스턴스의 __radd__메서드가 호출되기 원함

 

'연산자 우선순위' 지정 : Variable 인스턴스의 속성에 __array__priority__를 추가하고 그 값을 큰 정수로 설정

class Variable:
    __array_priority__ = 200
    ...
  • Variority 인스턴스의 연산자 우선순위를 ndarrary 인스턴스의 연산자 우선순위보다 높일 수 있음
  • 좌항이 ndarray 인스턴스라 해도 우항인 Variable 인스턴스의 연산자 메서드가 우선적으로 호출

 

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

DeZero가 더 많은 연산자들을 계산하도록 추가

 

  • __neg__(self) : 양수를 음수로 & 음수를 양수로, 다른 연산자들과 달리 항이 하나뿐인 '단항 연산자'
  • 나머지는 이항연산자 : 우항, 좌항이냐에 따라 2개의 특수 메서드 중 하나가 선별되어 호출
  • 거듭제곱은 좌항이 Variable 인스턴스이고 우항이 상수(2, 3 등의 int)인 경우만 고려

 

새로운 연산자를 추가하는 순서

  1. Function 클래스를 상속하여 원하는 함수 클래스를 구현(ex: Mul 클래스)
  2. 파이썬 함수로 사용할 수 있도록 함(ex: mul 함수)
  3. Variable 클래스의 연산자를 오버로드(ex: Variable.__mul__=mul)

 

22.1 음수(부호 변환)

구현 : 음수의 미분은 y = -x일 때 ∂y / ∂x = -1

class Neg(Function):
    def forward(self, x):
        return -x

    def backward(self, gy):
        return -gy

def neg(x):
    return Neg()(x)
    
Variable.__neg__ = neg

 

테스트 코드

x = Variable(np.array(2.0))
y = -x    # 부호를 바꾼다.
print(y)  

# 실행 결과
variable(-2.0)

 

 

22.2 뺄셈

구현 : 뺄셈의 미분은 y = x0 - x1일 때 ∂y / ∂x0 = 1, ∂y / ∂x1 = -1

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

*를 -로 바꿔서 생각해보면...

 

  • x0이 Variable 인스턴스가 아닌 경우 y = 2.0 - x와 같은 계산 수행을 할 수 있도록 __rsub__ 메서드 구현
  • rsub(x0, x1) : 인수의 순서를 바꿔서 Sub()(x1, x0)를 호출
  • 특수 메서드인 __rsub__에 함수 rsub를 할당

* CAUTION : 우항 좌항의 계산 순서

  • 덧셈, 곱셈은 순서 바꿔도 계산 결과가 같으므로 구분할 필요 x
  • 뺄셉은 구별해주어야 함 → 우항을 대상으로 적용할 함수인 rsub(x0, x1)을 별도로 준비

 

테스트 코드

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

 

 

22.3 나눗셈

구현 : y = x0/x1 일 때, ∂y / ∂x0 = 1/x1, ∂y / ∂x1 = -x0/(x1)**2

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__ = rdiv
  • 뺄셈과 마찬가지로 좌/우항 중 어느 것에 적용할지에 따라 적용되는 함수 다름

 

22.4 거듭제곱

구현 : y = x**c 일 때(이때 x는 상수), ∂y / ∂x = c(x**c-1)

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만(즉, 하나의 항만) 받게 함
  • 특수 메서드 __pow__에 함수 pow를 할당

 

테스트 코드

x = Variable(np.array(2.0))
y = x ** 3
print(y)  # variable(8.0)

 

 

23. 패키지로 정리

지금까지의 성과를 재사용할 수 있도록 패키지로 정리

 

모듈

  • 모듈은 파이썬 파일
  • 다른 파이썬 프로그램에서 import하여 사용하는 것을 가정하고 만들어진 파이썬 파일

패키지

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

라이브러리

  • 여러 패키지를 묶은 것
  • 하나 이상의 디렉터리로 구성
  • 때로는 패키지를 '라이브러리'라고 부르기도 함

 

23.1 파일 구성

현재까지는 각 step 파일에 코드 작성

이것을 모아 dezero라는 공통의 디렉터리 생성

 

최종 파일 구성

|
|-------- dezero
|    |-------- __init__.py
|    |-------- core_simple.py
|    |-------- ...
|    |-------- utils.py
|
|-------- steps
|    |-------- step01.py
|    |-------- ...
|    |-------- step60.py
|
  • 이와 같이 구성하여 dezero 디렉터리에 모듈을 추가
  • dezero 패키지 만들어짐 → 우리가 만드는 프레임워크

 

23.2 코어 클래스로 옮기기

dezero 디렉터리에 파일을 추가

 

목표 : step22.py 코드를 dezero/core_simple.py라는 코어 파일로 옮기기

 

클래스

  • Config
  • Variable
  • Function
  • Add (Function)
  • Mul (Function)
  • Neg (Function)
  • Sub (Function)
  • Div (Function)
  • Pow (Function)
    * (Function)은 Function 클래스 상속했음을 의미

함수

역전파의 활성/비활성을 전환

  • using_config
  • no_grad

인수로 주어진 객체를 ndarray 또는 Variable로 변환 

  • as_array
  • as_variable

DeZero에서 사용하는 함수

  • add
  • mul
  • neg
  • sub
  • rsub
  • div
  • rdiv
  • pow

* CAUTION

Exp 클래스, Square 클래스, exp 함수, square 함수 등은 코어 파일에 넣지 않음

이후 dezer/functions.py에 추가

 

코드 : 외부 파이썬 파일에서 dezero를 import

import numpy as np
from dezero.core_simple import Variable    *

x = Variable(np.array(1.0))
print(x)

>>> variable(1.0)
  • Variable 클래스 import
  • 주의 : dezero.core_simple처럼 파일 이름까지 명시
  • → 뒤에서 core_simple을 생략하는 방법 도입할 것

* CAUTION

  • from ... import ... 구문을 사용하면 모듈 내의 클래스나 함수 등을 직접 import 할 수 있음
  • import XXX as A 라고 쓰면 XXX 모듈을 A라는 이름으로 import 할 수 있음
  • ex) import dezero.core_simple as dz
    dezero.core_simple 모듈을 dz라는 이름으로 임포트
    Variable 클래스를 사용하려면 dz.Variable이라고 쓰면 됨

 

23.3 연산자 오버로드

오버로드한 연산자들을 dezero로 옮김

 

dezero/core_simple.py 에 다음 함수 추가

def setup_variable():
    Variable.__add__ = add
    Variable.__radd__ = add
    Variable.__mul__ = mul
    Variable.__rmul__ = mul
    Variable.__neg__ = neg
    Variable.__sub__ = sub
    Variable.__rsub__ = rsub
    Variable.__truediv__ = div
    Variable.__rtruediv__ = rdiv
    Variable.__pow__ = pow
  • setup_variable : Variable의 연산자들을 오버로드하는 함수
  • 이 함수는 dezero/__init__.py 에서 호출
  • __init__.py : 모듈을 import 할 때 가장 먼저 실행되는 파일

 

dezero/__init__.py 에 다음 코드 작성

from dezero.core_simple import Variable
from dezero.core_simple import Function
from dezero.core_simple import using_config
from dezero.core_simple import no_grad
from dezero.core_simple import as_array
from dezero.core_simple import as_variable
from dezero.core_simple import setup_variable

setup_variable()
  • setup_variable 함수를 import 해서 호출
  • 이를 통해 dezero 패키지를 이용하는 사용자는 반드시 연산자 오버로드가 이루어진 상태에서 Variable 사용할 수 있음
  • __init__.py의 시작이 from dezero.core_simple import Variable
    → dezero 패키지에서 Variable 클래스를 곧바로 임포트할 수 있음

 

dezero를 이용하는 사용자의 코드

# from dezero.core_simple import Variable
from dezero import Variable

 

 

23.4 실제 __init__.py 파일

# =============================================================================
# step23.py부터 step32.py까지는 simple_core를 이용해야 합니다.
is_simple_core = False  # True
# =============================================================================

if is_simple_core:
    from dezero.core_simple import Variable
    from dezero.core_simple import Function
    from dezero.core_simple import using_config
    from dezero.core_simple import no_grad
    from dezero.core_simple import as_array
    from dezero.core_simple import as_variable
    from dezero.core_simple import setup_variable

else:
    from dezero.core import Variable
    from dezero.core import Parameter
    ...
    ...
    
setup_variable()
  • 23단계 ~ 32단계 : True  →  if문
  • 33단계 ~             : False  →  else문

 

23.5 dezero 임포트하기

steps/step23.py

# Add import path for the dezero directory.
if '__file__' in globals():
    import os, sys
    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))

import numpy as np
from dezero import Variable


x = Variable(np.array(1.0))
y = (x + 3) ** 2
y.backward()

print(y)
print(x.grad)

# 실행 결과
variable(16.0)
8.0
  • if '__file__' in globals(): 문장에서 __file__이라는 전역 변수가 정의되어 있는지 확인
  • 터미널에서 python 명령으로 실행한다면 __file__ 변수가 정의되어 있음
  • 현재 파일이 위치한 디렉터리의 부모 디렉트리를 모듈 검색 경로에 추가
    → 파이썬 명령어 어디에서 실행하던 dezero 디렉터리의 파일들은 제대로 import 할 수 있음

 

* NOTE

검색 경로 추가 코드는 dezero 디렉터리를 import하기 위해 일시적으로 사용

DeZero가 pip 등 패키지로 설치된 경우라면 DeZero 패키지가 파이썬 검색 경로에 추가됨
→ 경로 수동 추가하지 않아도 됨

 

24. 복잡한 함수의 미분

최적화 문제에서 자주 사용되는 테스트 함수를 돌려보자

* 테스트 함수 : 최적화 기법이 '얼마나 좋은가'를 평가하는 데 사용하는 함수

 

 

몇 가지 함수를 (1.0, 1.0)에서 미분해보자

 

24.1 Sphere 함수

z = x**2 + y**2

if '__file__' in globals():
    import os, sys
    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
import numpy as np
from dezero import Variable

def sphere(x, y):
    z = x ** 2 + y ** 2
    return z

x = Variable(np.array(1.0))
y = Variable(np.array(1.0))
z = goldstein(x, y)  # sphere(x, y) / matyas(x, y)
z.backward()
print(x.grad, y.grad)

# 실행 결과
2.0 2.0
  • 원하는 계산을 z = x**2 + y**2 로 표현할 수 있음
  • 미분 결과는 f'(x) = (2x, 2y) 이므로 (2.0, 2.0) 정답과 일치

 

24.2 matyas (마차시)

z = 0.26(x**2 + y**2) - 0.48xy

if '__file__' in globals():
    import os, sys
    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
import numpy as np
from dezero import Variable

def matyas(x, y):
    z = 0.26 * (x ** 2 + y ** 2) - 0.48 * x * y
    return z

x = Variable(np.array(1.0))
y = Variable(np.array(1.0))
z = goldstein(x, y)  # sphere(x, y) / matyas(x, y)
z.backward()
print(x.grad, y.grad)

# 실행 결과
0.04000000000000036 0.04000000000000036
  • sub, add, mul 등의 어려운 코드를 사용하지 않아 보기에 편리함

 

24.3 Goldstein-Price 함수

복잡하여 적진 않겠음

if '__file__' in globals():
    import os, sys
    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
import numpy as np
from dezero import Variable

def goldstein(x, y):
    z = (1 + (x + y + 1)**2 * (19 - 14*x + 3*x**2 - 14*y + 6*x*y + 3*y**2)) * \
        (30 + (2*x - 3*y)**2 * (18 - 32*x + 12*x**2 + 48*y - 36*x*y + 27*y**2))
    return z

x = Variable(np.array(1.0))
y = Variable(np.array(1.0))
z = goldstein(x, y)  # sphere(x, y) / matyas(x, y)
z.backward()
print(x.grad, y.grad)

# 실행 결과
-5376.0 8064.0

 

 

칼럼: Define-by-Run

딥러닝 프레임워크 동작 방식 두 가지

  • 정적 계산 그래프 (Define-and-Run)
  • 동적 계산 그래프 (Define-by-Run)

 

Define-and-Run (정적 계산 그래프 방식)

  • 계산 그래프를 정의한 다음 데이터를 흘려보냄
  • 계산 그래프 정의는 사용자가 제공
  • 프레임워크는 주어진 그래프를 컴퓨터가 처리할 수 있는 형태로 변환하여 데이터 흘려보냄
  • 프레임 워크는 계산 그래프의 정의를 변환 → 책에서는 '컴파일'
  • '계산 그래프 정의'와 '데이터 흘려보내기' 처리가 분리되어 있음

 

# 가상의 Define-and-Run 방식 프레임워크용 코드 예

# 계산 그래프 정의
a = Variable('a')
b = Variable('b')
c = a * b
d = c + Constant(1)

# 계산 그래프 컴파일
f = compile(d)

# 데이터 흘려보내기
d = f(a=np.array(2), b=np.array(3))
  • 이 네 줄의 코드에서는 실제 계산이 이루어지지 않음
    → 실제 '수치'가 아닌 '기호'를 대상으로 프로그래밍했디 때문 = 기호 프로그래밍

 

Define-and-Run 방식의 프레임워크

  • 실제 데이터가 아닌 기호를 사용한 추상적인 계산 절차를 코딩해야 함
  • 도메인 특화 언어(DSL)를 사용해야 함
    * 도메인 특화 언어 : 프레임워크 자체의 규칙들로 이루어진 언어, ex) 텐서플로

실제 텐스플로 코드 예시

import tensorflow as tf

flg = tf.placeholder(dtype=tf.bool)
x0 = tf.placeholder(dtype=tf.float32)
x1 = tf.placeholder(dtype=tf.float32)
y = tf.cond(flg, lambda: x0+x1, lambda: x0*x1)
  • 데이터를 저장하는 플레이스홀더(tf.placeholder)로 이루어진 계산 그래프를 만듦
  • 마지막 줄에서 tf.cond 연산을 사용하여 실행 시 flg 값에 따라 처리 방식 달리함
  • tf.cond 연산이 파이썬의 if 문 역할

* NOTE

  • Define-and-Run 방식 프레임워크의 대부분은 도메인 특화 언어 사용하여 계산 정의
  • 도메인 특화 언어 : 파이썬 위에서 동작하는 새로운 프로그래밍 언어
    (새로운 프로그래밍 언어라고 불러도 이상하지 않음)
  • 미분을 하기 위해 설계된 언어 → 미분 가능 프로그래밍(differentiable programming)이라고도 함

다음 세대로 등장한 Define-by-Run

 

Define-by-Run(동적 계산 그래프 방식)

'데이터를 흘려보냄으로써 계산 그래프가 정의된다'

'데이터 흘려보내기'와 '계산 그래프 구축'이 동시에 이루어짐

 

* NOTE

  • DeZero의 경우 사용자가 데이터를 흘려보낼 때 자동으로 계산 그래프를 구성하는 '연결(참조)' 만듦
    → DeZero의 계산 그래프
  • 구현 수준에서는 연결 리스트로 표현 → 계산이 끝난 후 해당 연결을 역방향으로 추적할 수 있음

Define-by-Run 방식 프레임워크 예시 : DeZero

import numpy as np
from dezero import Variable

a = Variable(np.ones(10))
b = Variable(np.ones(10) * 2)
c = b * a
d = c + 1
print(d)
  • 넘파이를 사용한 일반적인 프로그래밍과 흡사
  • 결괏값도 코드가 실행되면 즉시 구해짐
  • 백그라운드에서는 계산 그래프를 위한 연결이 자동으로 만들어짐
  • ex) 파이토치, MXNet, DyNet, 텐서플로

동적 계산 그래프 방식의 장점

  • 일반 넘파이와 같은 방식으로 수치 계산 가능
  • 프레임워크 고유의 '도메인 특화 언어'를 배우지 않아도 됨
  • 계산 그래프를 '컴파일'하여 독자적인 데이터 구조로 변환할 필요 없음

DeZero 코딩

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

while True:
    y = y + x
    if y.data > 100:
        breadk
        
y.backward()
  • while문이나 if문을 사용할 수 있음 → 계산 그래프가 자동으로 만들어짐
  • 디버깅에도 유리 → 계산 그래프가 파이썬 프로그램 형태로 실행되기 때문에 디버깅도 파이썬 프로그램 가능
    ↔ 정적 계산 그래프에서 디버깅이 어려운 본질적인 이유
    - 정적 방식에서느 컴파일을 거쳐 프레임워크만 이해하고 실행할 수 있는 표현 형식으로 변환, 파이썬 디버깅 X
    - '계산 그래프 정의'와 '데이터 흘려보내기' 작업 분리, 문제 발생 시점과 원인 시점 떨어져 문제 특정 어려움

* NOTE : 정적 계산 그래프(Define -and-Run 방식) 프레임워크

  • 데이터를 흘려보내기에 앞서 계산 그래프 정의해야 함
  • 데이터 흘려보내는 동안은 계산 그래프의 구조 바꿀 수 없음
  • if문에 대응하는 tr.cond 같은 전용 연산 사용법 익혀야 하는 부담 생김

 

정적 계산 그래프 방식의 장점

성능

  • 계산 그래프를 최적화하면 성능도 최적화됨
  • 데이터를 흘려보내기 전에 전체 계산 그래프가 손에 들어오므로, 계산 그래프 전체를 고려한 최적화 가능

  • a * b + 1 이라는 계산 그래프
  • 최적화 버전에서는 곱셈과 덧셈을 한 번에 수행하는 연산 사용
  • '두 개의 연산'을 '하나의 연산'으로 '축약'하여 계산 시간 단축됨

어떻게 컴파일하느냐에 따라 다른 실행 파일로 변환할 수 있음

  • 파이썬이 아닌 다른 환경에서도 데이터를 흘려보내는 것이 가능
  • 파이썬 자체가 주는 오버헤드 사라짐 (IoT 기기처럼 자원이 부족한 에지 전용 환경에서 특히 중요)
  • 학습을 여러 대의 컴퓨터에 분산해 수행하는 경우

 

* NOTE

신경망 학습은 주로 '신경망을 한 번만 정의하고, 정의된 신경망에 데이터를 여러 번 흘려 보내는' 형태로 활용

 

정리

  • 성능이 중요 → Define-and-Run
  • 사용성이 중요 → Define-by-Run