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의 경우 실행할 수 조차 없는) 일이 자주 발생
- 파이썬은 메모리를 어떤식으로 관리할까? 크게 두 가지 방식으로 진행됨
- 참조(reference) 수를 세는 방식 -> 참조 카운트
- 세대(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
- 먼저 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())
- ndarray 인스턴스를 대상으로 실험을 함. 먼저, a는 일반적인 방식으로 참조하고, 다음으로 b는 약한 참조를 갖게 함
- 이 상태로 b를 출력해보면 ndarray를 가리키는 약한 참조(weakref)임을 확인할 수 있음.
- 참고로, 참조된 데이터에 접근하려면 b()라고 쓰면 됨
- 앞의 코드에 이어서 a = None이라고 실행하면 결과는 아래와 같음
In [12]:
a = None
b
Out[12]:
- 이와 같이 ndarray 인스턴스는 참조 카운트 방식에 따라 메모리에서 삭제됨. b도 참조를 가지고 있지만 약한 참조이기 때문에 참조 카운트에 영향을 주지 못하는 것
- 그래서 b를 출력하면 dead라는 문자가 나오고, 이것으로 ndarray 인스턴스가 삭제됐음을 알 수 있음
- 지금까지의 약한 참조 실험은 파이썬 인터프리터에서 실행한다고 가정함. IPython과 주피터 등의 인터프리터는 인터프리터 자체가 사용자가 모르는 참조를 추가로 유지하기 때문에 앞의 코드에서 b가 여전히 유효한 참조를 유지함(dead가 되지 않음) ->
근데 됨 (띠용?)
- 지금까지의 약한 참조 실험은 파이썬 인터프리터에서 실행한다고 가정함. 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의 메모리 사용을 개선할 수 있는 구조 두가지를 도입
- 역전파 시 사용하는 메모리양을 줄이는 방법으로, 불필요한 미분 결과를 보관하지 않고 즉시 삭제
- '역전파가 필요없는 경우'용 모드를 제공. 불필요한 계산을 생략함
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)
- 여기에서 사용자가 제공한 변수는 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이 되어 미분값 데이터가 메모리에서 삭제됨
- y에 접근할 때 y()라고 한 이유는 y가 약한 참조이기 때문.
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)
- 이와 같이 중간 변수인 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...')
- 앞의 코드처럼
@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]:
- 인스턴스 변수 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)
- 이와 같이 메서드 호출이 아닌 인스턴스 변수로 데이터의 형상을 얻을 수 있음. 같은 방법으로 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))
- 파이썬에서 특별한 의미를 지닌 메서드는 밑줄 두개로 감싼 이름을 씀
- 마지막으로 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)
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)
- 이처럼 print 함수가 출력해주는 문자열을 입맛에 맞게 정의하려면
__repr__
메서드를 재정의하면 됨. 반환값은 출력하고자 하는 문자열. - 앞의 코드에서는 str(self.data)를 이용하여 ndarray 인스턴스를 문자열로 반환함. str 함수 안에서는 ndarray 인스턴스의 str 함수가 호출되고 숫자가 문자열로 반환됨.
- 줄바꿈이 있으면 줄을 바꾼 후 새로운 줄 앞에 공백 9개를 사용하여 여러 줄에 걸친 출력도 숫자의 시작 위치가 가지런하게 표시되게 함.
- 마지막으로 변환된 문자열을 'variable(...)' 형태로 감쌈
- 이상으로 Variable 클래스를 '투명한 상자'로 만드는 작업을 일부 끝마침. 다음 단계에서도 이 작업을 계속 이어나감
In [ ]:
'기계학습 > 밑바닥딥러닝3 오독오독 씹기' 카테고리의 다른 글
Chapter 2. 자연스러운 코드로(step 23~24)/복잡한 함수 미분 (1) | 2021.01.21 |
---|---|
Chapter 2. 자연스러운 코드로(step 17~19)/ 연산자 오버로드 (0) | 2021.01.04 |
Chapter 2. 자연스로운 코드로(step 16) (0) | 2020.12.16 |
Chapter 2. 자연스러운 코드로(step 11~14) (0) | 2020.12.07 |
Chapter 1. Auto-grad(자동미분) step 6~9: 수동 역전파/ 역전파 자동화/ 재귀, 반복문/ 파이썬 함수 활용 (0) | 2020.12.02 |