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

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

H_erb Salt 2020. 12. 29. 16:52
step_17-19

17. 메모리 관리와 순환 참조

  • 성능을 개선할 수 있는 대책(기술)을 DeZero에 도입함
  • 본격적인 시작에 앞서, 파이썬에서의 메모리 관리에 대해 알아봄
  • 이번 단계에서 설명하는 파이썬 메모리 관리 설정은 CPython을 기준으로 함

17.0 지금까지 코드 정리

In [1]:
import weakref
import numpy as np
import contextlib
In [2]:
import numpy as np
In [3]:
def as_array(x):
    if np.isscalar(x):
        return np.array(x)
    return x
In [4]:
class Variable:
    def __init__(self, data):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))
                
        self.data = data
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
        
    def backward(self):
        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)
                    
    def cleargrad(self):
        self.grad = None
In [5]:
class Function(object):
    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 = outputs
        
        return outputs if len(outputs) > 1 else outputs[0]
    
    def forward(self, xs):
        raise NotImplementedError()
        
    def backward(self, gys):
        raise NotImplementedError()
In [6]:
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
    
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
    
class Add(Function):
    def forward(self, x0, x1):
        y = x0 + x1
        return y
    
    def backward(self, gy):
        return gy, gy
In [7]:
def square(x):
    return Square()(x) 

def exp(x):
    return Exp()(x)

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

17.1 메모리 관리

  • 파이썬은 필요 없어진 객체들을 메모리에서 자동으로 삭제함. 해당 기능 덕에 메모리 관리를 의식할 일이 크게 줄어듦. 불필요한 객체는 파이썬 인터프리터가 제거해주므로 더 중요한 작업에 집중가능
  • 그렇더라도, 코드를 제대로 작성하지 않으면 때때로 메모리 누수(memory leak) 또는 메모리 부족(out of memory) 등의 문제가 발생함
  • 특히 신경망에서는 큰 데이터를 다루는 경우가 많아서 메모리 관리를 제대로 하지 않으면 실행 시간이 오래걸리는(GPU의 경우 실행할 수 조차 없는) 일이 자주 발생
  • 파이썬은 메모리를 어떤식으로 관리할까? 크게 두 가지 방식으로 진행됨
    1. 참조(reference) 수를 세는 방식 -> 참조 카운트
    2. 세대(generation)을 기준으로 쓸모 없어진 객체(garbage)를 회수(collection)하는 방식 -> GC(Garbage Collection)

17.2 참조 카운트 방식의 메모리 관리

  • 파이썬 메모리 관리의 기본은 참조 카운트. 참조 카운트는 구조가 간단하고 속도도 빠름.
  • 모든 객체는 참조 카운트가 0인 상태로 생성되고, 다른 객체가 참조할 때마다 1씩 증가함. 반대로 객체에 대한 참조가 끊길 때마다 1만큼 감소하다가 0이 되면 파이썬 인터프리터가 회수해감
  • 이런 방식으로 객체가 더 이상 필요 없어지면 즉시 메모리에서 삭제됨. 해당 방식이 참조 카운트 메모리 관리
  • 참고로, 다음과 같은 경우에 참조 카운트가 증가함
    • 대입 연산자를 사용할 때
    • 함수에 인수로 전달할 때
    • 컨테이너 타입 객체(리스트, 튜플, 클래스 등)에 추가할 때
  • 예제(개념을 설명하기 위한 의사코드라서 동작하지는 않음)
In [8]:
class obj:
    pass

def f(x):
    print(x)
    
a = obj() # 변수에 대입: 참조 카운트 1
f(a) # 함수에 전달: 함수 안에서는 참조 카운트 2
# 함수 완료: 빠져나오면 참조 카운트 1
a = None # 대입 해제: 참조 카운트 0
<__main__.obj object at 0x0000017A97DEE5C8>
  • 먼저 obj()에 의해 생성된 객체를 a에 대입함. 그러면 이 객체의 참조 카운트는 1이 됨
  • 다음 줄에서 f(a)를 호출하는데, 이 때 a가 인수로 전달되기 때문에 함수 f의 범위 안에서는 참조 카운트가 1 증가함(총 2)
  • 그리고 함수의 범위를 벗어나면 참조 카운트가 다시 1 감소
  • 마지막으로 a = None에서 참조를 끊으면 다시 0이 됨 (아무도 참조하지 않는 상태)
  • 이렇게 0이 되는 즉시 해당 객체는 메모리에서 삭제됨
  • 이러한 간단한 방식을 통해 수많은 메모리문제를 해결할 수 있음
In [9]:
a = obj()
b = obj()
c = obj()

a.b = b
b.c = c

a = b = c = None
  • a, b, c라는 세 개의 객체를 생성함. a가 b를 참조하고, b가 c를 참조함.
  • 그런 다음, a=b=c=None 줄을 실행하면 객체의 관계에 따라 a가 즉시 삭제됨
  • 그 여파로 b의 참조 카운트가 0이 되서 b 역시 삭제되고, c도 삭제됨
  • 이렇게 사용하자로부터 참조되지 않는 객체들이 도미노처럼 한꺼번에 삭제되는 것
  • 이런 기능이 수많은 메모리 관리 문제를 해결해주지만, 참조 카운트로는 해결할 수 없는 문제가 있음. 순환 참조

17.3 순환 참조

  • 순환 참조를 설명하기 위한 코드
In [10]:
a = obj()
b = obj()
c = obj()

a.b = b
b.c = c
c.a = a

a = b = c = None
  • c에서 a로의 참조가 추가된 상태. 세 개의 객체가 원 모양을 이루며 서로가 서로를 참조하게 되는데, 이 상태가 순환 참조
  • 각 상태의 참조 카운트는 모두 1이므로 마지막 줄에서 None을 선언해도 삭제되지 않지만, 사용자는 이 객체중 어느 것에도 접근할 수 없음(즉, 모두 불필요한 상태)
  • 결과적으로 메모리에서 삭제되지 않으므로 메모리 관리 방식이 등장함. GC 방식
  • GC 방식은 참조 카운트보다 영리한 방법으로 불필요한 객체를 찾아냄
  • GC는 참조 카운트와 달리 메모리가 부족해지는 시점에 파이썬 인터프리터에 의해 자동으로 호출됨. 명시적으로 호출할 수도 있음(gc 모듈을 임포트하여 gc.collect()를 실행)
  • GC는 순환 참조를 올바르게 처리함. 따라서 일반적인 경우에서는 순환 참조를 의식할 필요가 없음
  • 하지만 메모리 해제를 GC에 미루다 보면 프로그램의 전체 메모리 사용량이 (순환 참조가 없을 때와 비교해) 커지는 원인이 됨
  • 머신러닝, 특히 신경망에서 메모리는 중요한 자원이므로, DeZero를 개발할 때는 순환 참조를 만들지 않은 것이 좋음
  • 현재의 DeZero에는 순환 참조가 존재함. '변수'와 '함수'를 연결하는 방식에 순환 참조가 숨어 있음
  • Function 인스턴스는 두 개의 Variable 인스턴스 (입력과 출력)를 참조함
  • 그런데 이 때 Function 인스턴스와 Variable 인스턴스가 순환 참조 관계를 만듦.
  • 다행히 해당 순환참조는 표준 파이썬 모듈인 weakref로 해결할 수 있음

17.4 weakref 모듈

  • 파이썬에서는 weakref.ref 함수를 사용하여 약한 참조(weak reference)를 만들 수 있음
  • 약한 참조란, 다른 객체를 참조하되 참조 카운트는 증가시키지 않는 함수.
  • 아래는 그 사용 예
In [11]:
import weakref
import numpy as np

a = np.array([1, 2, 3])
b = weakref.ref(a)

print(b)
print(b())
<weakref at 0x0000017A97E014F8; to 'numpy.ndarray' at 0x0000017A97E011C0>
[1 2 3]
  • ndarray 인스턴스를 대상으로 실험을 함. 먼저, a는 일반적인 방식으로 참조하고, 다음으로 b는 약한 참조를 갖게 함
  • 이 상태로 b를 출력해보면 ndarray를 가리키는 약한 참조(weakref)임을 확인할 수 있음.
  • 참고로, 참조된 데이터에 접근하려면 b()라고 쓰면 됨
  • 앞의 코드에 이어서 a = None이라고 실행하면 결과는 아래와 같음
In [12]:
a = None
b
Out[12]:
<weakref at 0x0000017A97E014F8; dead>
  • 이와 같이 ndarray 인스턴스는 참조 카운트 방식에 따라 메모리에서 삭제됨. b도 참조를 가지고 있지만 약한 참조이기 때문에 참조 카운트에 영향을 주지 못하는 것
  • 그래서 b를 출력하면 dead라는 문자가 나오고, 이것으로 ndarray 인스턴스가 삭제됐음을 알 수 있음
    • 지금까지의 약한 참조 실험은 파이썬 인터프리터에서 실행한다고 가정함. IPython과 주피터 등의 인터프리터는 인터프리터 자체가 사용자가 모르는 참조를 추가로 유지하기 때문에 앞의 코드에서 b가 여전히 유효한 참조를 유지함(dead가 되지 않음) -> 근데 됨 (띠용?)
  • 이 weakref 구조를 DeZero에도 도입하려 함. 먼저 Function에 다음 부분을 추가함
In [13]:
import weakref

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]
    
    def forward(self, xs):
        raise NotImplementedError()
        
    def backward(self, gys):
        raise NotImplementedError()
  • 이와 같이 인스턴스 변수 self.outputs가 대상을 약한 참조로 가리키게 변경함. 그 결과 함수는 출력 변수를 약하게 참조
  • 또한, 이 변경의 여파로 다른 클래스에서 Function 클래스의 outputs를 참조하는 코드도 수정해야 함. DeZero에서는 Variable 클래스의 backward 메서드를 다음처럼 수정하면 됨
In [14]:
class Variable:
    def __init__(self, data):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))
                
        self.data = data
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
        
    def backward(self):
        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.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)
                        
    def cleargrad(self):
        self.grad = None
  • 이와 같이 [output.grad ...] 부분을 [output().grad ...]로 수정함. 이상으로 DeZero의 순환참조 문제가 해결됨

17.5 동작확인

  • 순환 참조가 없어진 새로운 DeZero에서 다음 코드를 실행
In [15]:
'''###################################
흐름과는 상관없이 오류 때문에 재생한 코드
###################################'''

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
    
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
    
class Add(Function):
    def forward(self, x0, x1):
        y = x0 + x1
        return y
    
    def backward(self, gy):
        return gy, gy
In [16]:
for i in range(10):
    x = Variable(np.random.randn(10000)) # 거대한 데이터
    y = square(square(square(x))) # 복잡한 계산 수행
  • for 문을 사용하여 계산을 반복해 수행함. 이 반복문은 복잡한 참조 구조를 만들어냄
  • 그리고 for 문이 두 번째 반복될 대 x와 y가 덮어써짐. 그러면 사용자는 이전의 계산 그래프를 더이상 참조하지 않게 됨
  • 참조 카운트가 0이 되므로 이 시점에 계산 그래프에 사용된 메모리가 바로 삭제됨. 이런 방식으로 DeZero 순환 참조 문제가 해결됨
    • 파이썬으로 메모리 사용량을 측정하려면 외부 라이브러리인 memory profiler 등을 사용하면 편리함
    • 방금 전의 코드를 실제로 측정해보면 메모리 사용량이 전혀 증가하지 않음을 확인할 수 있음

18. 메모리 절약 모드

  • 이전 단계에서는 파이썬의 메모리 관리 방식에 대해 알아봄. 이번에는 DeZero의 메모리 사용을 개선할 수 있는 구조 두가지를 도입
    1. 역전파 시 사용하는 메모리양을 줄이는 방법으로, 불필요한 미분 결과를 보관하지 않고 즉시 삭제
    2. '역전파가 필요없는 경우'용 모드를 제공. 불필요한 계산을 생략함

18.1 필요 없는 미분값 삭제

  • DeZero의 역전파를 개선함. 현재의 DeZero에서는 모든 변수가 미분값을 변수에 저장하고 있음.
In [17]:
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)
print(x0.grad, x1.grad)
1.0 1.0
2.0 1.0
  • 여기에서 사용자가 제공한 변수는 x0와 x1이며, 다른 변수 t와 y는 계산 결과로 만들어짐. 그리고 y.backward()를 실행하여 미분하면 모든 변수가 미분 결과를 메모리에 유지함
  • 그러나 많은 경우, 머신러닝에서는 역전파로 구하고 싶은 미분값은 말단 변수(x0, x1)뿐 일때가 대부분. 앞의 예에서는 y와 t 같은 중간 변수의 미분값은 필요하지 않음
  • 그래서 중간 변수에 대해서는 미분값을 제거하는 모드를 추가함. 현재의 Variable 클래스의 backward 메서드에 다음 코드를 추가함
In [18]:
class Variable:
    def __init__(self, data):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))

        self.data = data
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
    def backward(self, retain_grad=False): # 해당 부분 추가
        if self.grad == 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 # y는 약한 참조(weakref)
  • 우선 메서드의 인수에 retain_grad를 추가함. 이 retain_grad가 True면 지금까지처럼 모든 변수가 미분 결과(기울기)를 유지함.
  • 반면 retain_grad가 False면(기본값) 중간 변수의 미분값을 모두 None으로 재설정함. 그 원리는 앞의 코드에서 보듯 backward 메서드의 마지막 for 문으로, 각 함수의 출력 변수의 미분값을 유지하지 않도록 y().grad = None으로 설정하는 것
    • y에 접근할 때 y()라고 한 이유는 y가 약한 참조이기 때문. y().grad = None 코드가 실행되면 참조 카운트가 0이 되어 미분값 데이터가 메모리에서 삭제됨
In [19]:
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)
print(x0.grad, x1.grad)
None None
2.0 1.0
  • 이와 같이 중간 변수인 y와 t의 미분값이 삭제되어 그만큼의 메모리를 다른 용도로 사용할 수 있게됨
  • 이렇게 DeZero의 메모리 사용에 관한 첫 번째 개선이 완성
  • 다음으로 Function 클래스를 복습해봄

18.2 Function 클래스 복습

  • DeZero에서 미분을 하려면 순전파를 수행한 뒤 역전파해주면 됨. 그리고 역전파 시에는 순전파의 계산 결과가 필요하기 때문에 순전파 때 결괏값을 기억해둠.
  • 결괏값을 기억하는 로직은 아래 Function 클래스에서 확인
In [20]:
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]
    
    def forward(self, xs):
        raise NotImplementedError()
        
    def backward(self, gys):
        raise NotImplementedError()
  • 이와 같이 함수는 입력을 inputs라는 '인스턴스 변수'로 참조함. 그 결과 inputs가 참조하는 변수의 참조 카운트가 1만큼 증가하고, __call__ 메서드에서 벗어난 뒤에도 메모리에 생존함. 만약 인스턴스 변수인 inputs로 참조하지 않았다면 참조 카운트가 0이 되어 메모리에서 삭제됐을 것
  • 인스턴스 변수 inputs는 역전파 계산 시 사용됨. 따라서 역전파하는 경우라면 참조할 변수들을 inputs에 미리 보관해둬야 함.
  • 하지만 때로는 미분값이 필요 없는 경우도 있음. 이런 경우라면 중간 계산 결과를 저장할 필요가 없고, 계산의 '연결' 또한 만들 이유가 없음
    • 신경망에서는 학습(training)과 추론(inference)라는 두 가지 단계가 있음. 학습 시에는 미분값을 구해야 하지만, 추론 시에는 단순히 순전파만 사용하기 때문에 중간 계산 결과를 곧바로 버리면 메모리 사용량을 크게 줄일 수 있음

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

  • 이제부터 순전파만 할 경우를 위한 개선을 DeZero에 추가함. 우선 두 가지 모드, 즉 '역전파 활성 모드'와 '역전파 비활성 모드'를 전환하는 구조가 필요함
  • 간단히 다음 클래스를 이용함
In [21]:
class Config:
    enable_backprop = True
  • 간단한 클래스. 이 클래스의 속성은 (현재) bool 타입인 enable_backprop만 존재함. 이는 역전파가 가능한지 여부를 뜻하고, 이 값이 True면 '역전파 활성 모드'
    • 설정 데이터는 한 군데에만 존재하는게 좋음. 그래서 Config 클래스는 인스턴스화 하지 않고 '클래스'상태로 이용함. 인스턴스는 여러 개 생성할 수 있지만 클래스는 항상 하나만 존재하기 때문. 따라서 앞 코드에서 Config 클래스가 '클래스 속성'을 갖도록 설정함
  • Config 클래스를 정의했으니 Function에서 참조하게 하여 모드를 전활할 수 있게 함.
In [22]:
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]

    def forward(self, xs):
        raise NotImplementedError()
        
    def backward(self, gys):
        raise NotImplementedError()
  • 이와 같이 Config.enable_backprop이 True일 때만 역전파 코드가 실행됨.
    • #1 에서 정하는 '세대'는 역전파 시 노드를 따라가는 순서를 정하는 데 사용됨. 따라서 '역전파 비활성 모드'에서는 필요하지 않음
    • #2의 output_set_creator(self)는 게산들의 '연결'을 만드는데, 마찬가지로 '역전파 비활성 모드'에서는 필요없음

18.4 모드 전환

  • 이상으로 활성/비활성을 구분 짓는 구조가 만들어짐. 이 구조를 활용하면 다음과 같이 모드를 전활할 수 있음
In [23]:
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 함수를 세 번 적용(원소별 제곱이 이루어짐)
  • 이 때, Config.enable_backprop이 True면 중간 계산 결과가 (적어도 역전파가 완료되기 전까지는) 계속 유지되어 그만큼 메모리를 차지함. 그러나 False면 중간 계산 결과는 사용 후 바로 삭제됨(정확히는 다른 객체에서의 참조가 없어지는 시점에 메모리에서 삭제됨)
  • 이상으로 역전파 모드를 전환하는 구조를 완성함. 이어서 모드 전환을 더 쉽게 해주는 구조를 만듦

18.5 with 문을 활용한 모드 전환

  • 파이썬에서는 with라고 하는, 후처리를 자동으로 수행하고자 할 때 사용할 수 있는 구문이 있음. 대표적인 예는 파일의 open과 close. 예를 들어 with문 없이 파일에 무언가를 쓰려면 다음처럼 작성해야 함
In [24]:
f = open('sample.txt', 'w')
f.write('hello world')
f.close()
  • 보다시피 open()으로 파일을 열고, 무언가를 쓰고, close()로 파일을 닫음. 이때 매번 close()를 하기 귀찮거나 잊을 때도 있음. with는 이런 실수를 막아줌
In [25]:
with open('sample.txt', 'w') as f:
    f.write('hello world')
  • 이 코드에는 with 블록에 들어갈 때 파일이 열림. with 블록 안에서 파일은 계속 열린 상태고 블록을 빠져나올 때 (사용자에게 보이지 않는 곳에서) 자동으로 닫힘.
  • 이와 같이 with 문을 사용하는 것으로 'with 블록에 들어갈 때의 처리(전처리)'와 'with 블록을 빠져나올 때의 처리(후처리)'를 자동으로 할 수 있음
  • 이러한 with 문의 원리를 이용하여 '역전파 비활성 모드'로 전환하려 함. 구체적으로는 다음과 같은 식으로 사용함
    • (using_config 메서드의 구현은 조금 뒤에 설명)
In [26]:
try:
    
    ############# 아래 부분만 확인 #############
    with using_config('enable_backprop', False):
        x = Variable(np.array(2.0))
        y = square(x)
    ###########################################
    
except:
    pass
  • 이와 같이 with using_config('enable_backprop', False): 안에서만 '역전파 비활성 모드'가 됨. 그리고 with 블록을 벗어나면 일반 모드, 즉 '역전파 활성 모드'로 돌아감
    • '역전파 비활성 모드'로 일시적으로 전환하는 방법은 실전에서 자주 사용됨. 예컨대 신경망 학습에서는 모델 평가를 학습 도중에 하기 위해 기울기가 필요 없는 모드를 사용하는 일이 자주 발생함
  • 그럼 with문을 사용한 모드 전환을 구현함. contextlib 모듈을 사용하면 가장 쉽게 구현 가능. contextlib 모듈 사용법 설명
In [27]:
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/finally로 감싸야 함
  • 이상을 바탕으로 using_config 함수를 다음과 같이 구현할 수 있음
In [28]:
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 블록에 들어갈 때 name으로 지정한 Config 클래스 속성이 value로 설정됨. 그리고 with 블록을 빠져나오면서 원래 값(old_value)로 복원 됨.
  • 실제 사용
In [29]:
with using_config('enable_backprop', False):
    x = Variable(np.array(2.0))
    y = square(x)
  • 이와 같이 역전파가 필요 없는 경우에는 with 블록에서 순전파 코드만 실행됨. 이제 불필요한 계산을 생략하고 메모리를 절약할 수 있음.
  • 그러나 with_using_config('enable_backprop', 'False'): 라는 긴 코드를 매번 적는 것이 귀찮은 일이니 다음과 같이 no_grad라는 편의 함수를 준비
In [30]:
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를 호출하는 코드를 return으로 돌려줌. 이제 기울기가 필요없을때는 no_grad 함수를 호출하면 됨
  • 앞으로는 기울기 계산이 필요 없을 때, 즉 단순히 순전파 계산만 필요할 때는 지금까지 구현한 '모드 전환'을 사용함

19. 변수 사용성 개선

  • DeZero의 기초는 이미 완성됨. 지금 상태로도 계산 그래프를 만들고 자동으로 미분을 계산할 수 있음. 그래서 앞으로 할 일은 DeZero를 더 쉽게 사용하도록 개선하는 작업.
  • 그 첫걸음으로, 이번에는 Variable 클래스를 더욱 쉽게 사용할 수 있게 함

19.1 변수 이름 지정

  • 앞으로 우리는 수많은 변수를 처리할 것이라서 변수들을 서로 구분할 필요가 있음. 변수에 '이름'을 붙여줄 수 있도록 설정
  • 다음과 같이 Variable 클래스에 name이라는 인스턴스 변수를 추가함
In [31]:
class Variable:
    def __init__(self, data, name=None): # 여기 추가!
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))

        self.data = data
        self.name = name # 여기 추가!
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
    def backward(self, retain_grad=False):
        if self.grad == 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
  • 이와 같이 초기화 인수 name=None을 추가하고 그 값을 인스턴스 name에 설정함. 이제 예컨대 x = Variable(np.input(1.0), 'input_x')라고 작성하면 변수 x의 이름은 input_x가 됨. 아무런 이름도 주지않으면 변수명으로 None이 할당됨
    • 변수에 이름을 붙일 수 있다면 예컨대 계산 그래프를 시각화 할 때 변수 이름을 그래프에 표시할 수 있음.

19.2 ndarray 인스턴스 변수

  • Variable은 데이터를 담는 '상자' 역할을 함. 그러나 사용하는 사람 입장에서 중요한 것은 상자가 아니라 그 안의 '데이터'임. 그래서 Variable이 데이터인 것처럼 보이게하는 장치, 즉 상자를 투명하게 해주는 장치를 만듦.
    • 1 단계에서 언급했듯이 수치 계산과 머신러닝 시스템은 다차원 배열(텐서)을 기본 데이터 구조로 사용함. 따라서 Variable 클래스는 (스칼라는 무시하고) ndarray만을 취급함. 그래서 이번 절의 목표는 Variable 인스턴스를 ndarray 인스턴스처럼 보이게 하는 것
  • Variable 안에는 ndarray 인스턴스가 있음. 넘파이의 ndarray 인스턴스에는 다차원 배열용 인스턴스 변수가 몇 가지 제공됨. 다음은 그 중 하나인 shape 인스턴스 변수를 사용하는 모습
In [32]:
import numpy as np
x = np.array([[1, 2, 3], [4, 5, 6]])
x.shape
Out[32]:
(2, 3)
  • 인스턴스 변수 shape는 다차원 배열의 형상을 알려줌. 참고로 앞의 결과에서 (2, 3)은 수학에서 말하는 2 x 3 행렬을 뜻함. 이제 똑같은 작업을 Variable 인스턴스에서도 할 수 있도록 확장함
In [33]:
class Variable:
    def __init__(self, data, name=None):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))

        self.data = data
        self.name = name
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
    def backward(self, retain_grad=False):
        if self.grad == 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
                    
    @property # 해당 부분 추가!
    def shape(self):
        return self.data.shape
  • shape라는 메서드를 추가한 후 실제 데이터의 shape를 반환하도록 함. 여기서 중요한 부분은 @property라는 한 줄. 이 한 줄 덕분에 shape 메서드를 인스턴스 변수처럼 사용할 수 있게 됨.
In [34]:
x = Variable(np.array([[1, 2, 3], [4, 5,6]]))
print(x.shape)
(2, 3)
  • 이와 같이 메서드 호출이 아닌 인스턴스 변수로 데이터의 형상을 얻을 수 있음. 같은 방법으로 ndarray의 다른 인스턴스 변수들을 Variable에 추가할 수 있음. 여기에서는 다음 세 인스턴스 변수를 더 추가함.
In [35]:
class Variable:
    def __init__(self, data, name=None):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))

        self.data = data
        self.name = name
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
    def backward(self, retain_grad=False):
        if self.grad == 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
                    
    @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를 사용하는 경우가 많음

19.3 len 함수와 print 함수

  • Variable 클래스를 더 확장하여 파이썬의 len 함수와도 함께 사용할 수 있도록 함. len은 객체 수를 알려주는 파이썬 표준 함수.
  • 리스트 등에 len 함수를 사용하면 그 안에 포함된 원소 수를 반환함. ndarray 인스턴스라면 첫 번째 차원의 원소 수를 반환함. 이 len 함수가 Variable 안의 원소 수도 인식하도록 함
In [36]:
class Variable:
    def __init__(self, data, name=None):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.format(type(data)))

        self.data = data
        self.name = name
        self.grad = None
        self.creator = None
        self.generation = 0
        
    def set_creator(self, func):
        self.creator = func
        self.generation = func.generation + 1
        
    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
                    
    @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 함수를 사용할 수 있게 됨. 다음과 같은 코드 작성 가능
In [37]:
x = Variable(np.array([[1, 2, 3], [4, 5, 6]]))
print(len(x))
2
  • 파이썬에서 특별한 의미를 지닌 메서드는 밑줄 두개로 감싼 이름을 씀
  • 마지막으로 Variable의 내용을 쉽게 확인할 수 있는 기능을 추가함. 바로 print 함수를 사용하여 Variable 안의 데이터 내용을 출력하는 기능.
  • 아래 예처럼 사용하고자 함
In [38]:
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)
<__main__.Variable object at 0x0000017A97E165C8>
<__main__.Variable object at 0x0000017A97E3DB08>
<__main__.Variable object at 0x0000017A97E165C8>
In [39]:
class Variable:
    def __init__(self, data, name=None):
        if data is not None: 
            if not isinstance(data, np.ndarray):
                raise TypeError('{}은(는) 지원하지 않아요. ndarray로 입력하세요.'.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 backward(self, retain_grad=False):
        if self.grad == 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 [40]:
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]])
  • 이처럼 print 함수가 출력해주는 문자열을 입맛에 맞게 정의하려면 __repr__ 메서드를 재정의하면 됨. 반환값은 출력하고자 하는 문자열.
  • 앞의 코드에서는 str(self.data)를 이용하여 ndarray 인스턴스를 문자열로 반환함. str 함수 안에서는 ndarray 인스턴스의 str 함수가 호출되고 숫자가 문자열로 반환됨.
  • 줄바꿈이 있으면 줄을 바꾼 후 새로운 줄 앞에 공백 9개를 사용하여 여러 줄에 걸친 출력도 숫자의 시작 위치가 가지런하게 표시되게 함.
  • 마지막으로 변환된 문자열을 'variable(...)' 형태로 감쌈
  • 이상으로 Variable 클래스를 '투명한 상자'로 만드는 작업을 일부 끝마침. 다음 단계에서도 이 작업을 계속 이어나감
In [ ]: