[프레임워크 기초] 제2고지. 자연스러운 코드(후편)
18. 메모리 절약 모드
DeZero의 메모리 사용을 개선할 수 있는 구조 두 가지를 도입하자!
- 역전파 시 사용하는 메모리양을 줄이는 방법 : 불필요한 미분 결과 보관하지 않고 즉시 삭제
- '역전파가 필요 없는 경우용 모드'를 제공하는 것 : 불필요한 계산을 생략
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'
오류가 발생하는 과정
- 연산자 왼쪽에 있는 2.0의 __mul_ 메서드를 호출하려 시도
- 하지만 2.0은 float 타입이므로 __mul__ 메서드는 구현되어 있지 않음
- 다음은 * 연산자 오른쪽에 있는 x의 특수 메서드를 호출하려 시도
- x가 오른쪽에 있기 때문에 (__mul__ 대신) __rmul__메서드를 호출하려 시도
- 하지만 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)인 경우만 고려
새로운 연산자를 추가하는 순서
- Function 클래스를 상속하여 원하는 함수 클래스를 구현(ex: Mul 클래스)
- 파이썬 함수로 사용할 수 있도록 함(ex: mul 함수)
- 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