In [1]:
import numpy as np
In [2]:
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
- 위와 같이 새로 grad라는 인스턴스 변수를 추가함
- 인스턴스 변수인 data와 grad는 모두 numpy의 다차원배열(ndarray)이라고 가정
- 또한 grad는 None으로 초기화해둔 다음, 나중에 실제로 역전파를 하면 미분값을 계산하여 대입
6.2 Function 클래스 추가 구현¶
- 이전 단계까지의 Function 클래스는 일반적인 계산을 하는 순전파(
forward
메서드) 기능만 지원하는 상태 - 이외에 다음 두 기능일 추가함
- 미분을 계산하는 역전파(
backward
메서드) forward
메서드 호출 시 건네받은 Variable 인스턴스 유지
- 미분을 계산하는 역전파(
In [3]:
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(y)
self.input = input # 입력 변수를 기억(보관)함
return output
def forward(self, x):
raise NotImplementedError()
def backward(self, x):
raise NotImplementedError()
- 코드에서 보듯
__call__
메서드에서 입력된 input을 인스턴스 변수인self.input
에 저장함 - 이렇게하여 나중에
backward
메서드에서 함수 (Function)에 입력한 변수(Variable 인스턴스)가 필요할때self.input
에서 가져와 사용가능
6.3 Square와 Exp 클래스 추가 구현¶
- Function을 상속한 구체적인 함수에서 역전파(backward)를 구현
- 첫 번째 대상은 제곱을 계산하는 Square 클래스
- $y=x^2$의 미분은 $\frac{dy}{dx}=2x$ 가 되기 때문에 다음처럼 구현 가능
In [4]:
class Square(Function):
def forward(self, x):
y = x ** 2
return y
def backward(self, dy):
x = self.input.data
dx = 2 * x * dy
return dx
- 이와 같이 역전파를 담당하는
backward
메서드를 추가함 - 이 메서드의 인수 gy는 ndarray 인스턴스이며, 출력 쪽에서 전해지는 미분값을 전달하는 역할을 함
- 인수로 전달된 미분에 $y=x^2$의 미분을 곱한 값이
backwrad
의 결과가 됨. - 역전파에서는 이 결괏값을 입력 쪽에서 더 가까운 다음 함수로 전파해나감
- 이어서 $y=e^x$ 계산을 할 Exp 클래스
- 이 계산의 미분은 $\frac{dy}{dx} = e^x$ 이기 때문에 다음과 같이 구현 가능
In [5]:
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
6.4 역전파 구현¶
In [6]:
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)
- 이어서 역전파로 y를 미분. 순전파와는 반대 순서로 각 함수의
backward
메서드를 호출
In [7]:
y.grad = np.array(1.0)
b.grad = C.backward(y.grad)
a.grad = B.backward(b.grad)
x.grad = A.backward(a.grad)
print(x.grad)
- 역전파는 $\frac{dy}{dy} = 1$ 에서 시작함. 따라서 출력 y의 미분값을 np.array(1.0)으로 설정.
- 그 후, C -> B -> A 순으로
backward
메서드를 호출하기만 하면 됨. 이를 통해 각 변수의 미분값이 구해짐 - 앞의 코드를 실행하면 x.grad의 값이 3.2974~~ 라고 나옴
- 이 값이 y의 x에 대한 미분 결과
- 4단계에서 수치 미분으로 구한 값이 3.2974~~ 였으므로 두 결과가 거의 같음을 알 수 있음. 역전파를 제대로 구현한 것(더 정확하게는 올바르게 구현한 것)
- 이제 역전파 순서에 맞춰 코드를 작성하는 것을 자동화시키는 작업이 필요함
7. 역전파 자동화¶
- 이전 단계에서 역전파를 동작시키는데 성공함. 이를 자동화함
- 더 정확히 표현하면, 일반적인 계산(순전파)을 한 번만 해주면 어떤 계산이라도 사오간없이 역전파가 작동으로 이루어지는 구조를 만듦
- 위 내용이 Define-by-Run의 핵심을 건드리는 내용
- Define-by-Run: 딥러닝에서 수행하는 계산들을 계산 시점에 '연결'하는 방식.
- 동적 계산 그래프라고도 부름
- 계산 그래프들이 모두 일직선으로 늘어선 계산인 경우, 함수의 순서를 리스트형태로 저장해두면 나중에 거꾸로 추적하는 방식으로 역전파를 자동화할 수 있음.
- 그러나 분기가 있는 계산 그래프나 같은 변수가 여러번 사용되는 복잡한 계산 그래프는 단순히 리스트로 저장하는 식으로는 풀 수 없음
- 리스트 데이터 구조를 응용하면 수행한 계산을 리스트에 추가해나가는 것만으로 어떠한 계산 그래프의 역전파도 수행 가능함.
- 이 데이터 구조를 웬거트 리스트(Wengert list) 혹은 테이프(Tape)라고 함
7.1 역전파 자동화의 시작¶
- 역전파 자동화로 가는 길은 변수와 함수의 '관계'를 이해하는 데서 출발함
- 함수 입장에서, 변수는 '입력'과 '출력'에 쓰임. 즉, 함수에게 변수는 '입력 변수(input)'와 '출력 변수(output)'로서 존재
- 변수 관점에서, 눈여겨볼 점은 변수는 함수에 의해 '만들어진다'(창조자)
- 창조자인 함수가 존재하지 않는 변수는 함수 이외에 존재, 예컨대 사용자에 의해 만들어진 변수로 간주됨
- 이를 코드에 녹이면, 일반적인 계산(순전파)이 이루어지는 시점에 '관계'를 맺어주도록(함수와 변수를 연결짓도록) 만듦
- 이를 위해 우선 Variable 클래스에 다음 코드를 추가함
In [8]:
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
- creator라는 인스턴스 변수를 추가함
- 그리고 creator를 설정할 수 있도록
set_creator
메서드도 추가함, 이어서 Function 클래스에 다음 코드를 추가함
In [9]:
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(y)
output.set_creator(self) # 출력 변수에 창조자를 설정
self.input = input
self.output = output # 출력도 저장
return output
def forward(self, x):
raise NotImplementedError()
def backward(self, x):
raise NotImplementedError()
- 순전파를 계산하면 그 결과로 output이라는 Variable 인스턴스가 생성됨
- 이때 생성된 output에 '내가 너의 창조자임'을 기억시킴. 이 부분이 '연결'을 동적으로 만드는 기법의 핵심
- 그런 다음, 앞으로를 위해 output을 인스턴스 변수에 저장함
- DeZero의 동적 계산 그래프는 실제 계산이 이루어질 때 변수(상자)에 관련 '연결'을 기록하는 방식으로 만들어짐
- 체이너와 파이토치의 방식도 위와 비슷함
- 이와 같이 '연결'된 Variable과 Function이 있다면 계산 그래프를 거꾸로 거슬러 올라갈 수 있음. 이를 코드로 나타내면 다음과 같음
In [10]:
'''###########################################################################################
해당 셀은 주피터에서 다시 선언하지 않으면 오류가 생겨서 다시 입력한 코드로, 책의 진행흐름과는 상관없음
###########################################################################################'''
class Square(Function):
def forward(self, x):
y = x ** 2
return y
def backward(self, dy):
x = self.input.data
dx = 2 * x * dy
return dx
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
In [11]:
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)
##
assert y.creator == C
assert y.creator.input == b
assert y.creator.input.creator == B
assert y.creator.input.creator.input == a
assert y.creator.input.creator.input.creator == A
assert y.creator.input.creator.input.creator.input == x
- 노드들의 연결로 이루어진 데이터 구조를 링크드 리스트(linked list)라고 함. 노드는 그래프를 구성하는 요소이며, 링크는 다른 노드를 가리키는 참조를 뜻함
7.2 역전파 도전¶
- 변수와 함수의 관계를 이용하여 역전파를 시도함.
- 순서
- 함수를 가져옴
- 함수의 입력을 가져옴
- 함수의 backward 메서드를 호출
In [12]:
y.grad = np.array(1.0)
C = y.creator
b = C.input
b.grad = C.backward(y.grad)
B = b.creator
a = B.input
a.grad = B.backward(b.grad)
A = a.creator
x = A.input
x.grad = A.backward(a.grad)
print(x.grad)
- 이와 같이 변수 y의 backward 메서드를 호출하면 역전파가 자동으로 진행됨. 실행결과도 지금까지와 동일함
- 이 것이 자동미분의 기초
8. 재귀에서 반복문으로¶
- 앞 단계에서는 Variable 클래스에
backward
메서드를 추가함. 이번에는 처리 효율을 개선하고 아픙로의 확장을 대비해backward
메서드의 구현 방식을 변경함
8.1 현재의 Variable 클래스¶
- 이전 장에서 우리는 Variable 클래스의 backward 메서드를 다음처럼 구현함
In [13]:
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
def backward(self):
f = self.creator
if f is not None:
x = f.input
x.grad = f.backward(self.grad)
x.backward()
- 이
backward
메서드에서 눈에 밟히는 부분은 (입력방향으로) 하나 앞 변수의 backward 메서드를 호출하는 코드 backward
메서드에서backward
메서드를 호출하고, 호출된backwward
메서드에서 또 다른backward
메서드를 호출하는 과정이 계속됨- 창조자 함수가 없는 변수, 즉
self.creator
가 None인 변수를 찾을 때 까지 계속됨. 이를 재귀구조라고 함
8.2 반복문을 이용한 구현¶
- 재귀를 이용한 구현을 반복문을 이용한 구현으로 고쳐보자
In [14]:
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
def backward(self):
funcs = [self.creator]
while funcs:
f = funcs.pop() # 함수를 가져옴
x, y = f.input, f.output # 함수의 입력과 출력을 가져옴
x.grad = f.backward(y.grad) # backward 메서드를 호출함
if x.creator is not None:
funcs.append(x.creator)
- 위와 같은 형태가 반복문을 이용한 구현.
- 주목해야 할 점은 처리해야 할 함수들을 funcs 라는 리스트에 차례로 집어 넣는다는 것
- while 블록 안에서
funcs.pop()
을 호출하여 처리할 함수 f를 꺼내고, f의backward
메서드를 호출함 - 이 때,
f.input
과f.output
에서 함수 f의 입력과 출력 변수를 얻음으로써 f.backward()의 인수와 반환값을 올바르게 설정할 수 있음
8.3 동작 확인¶
- 개선된 Variable 클래스를 사용하여 실제로 미분을 진행해봄
In [15]:
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)
# 역전파
y.grad = np.array(1.0)
y.backward()
print(x.grad)
- 앞으로 더욱 복잡한 계산도 가능하도록 현재의 DeZero를 확장해나감
- 그 전에 사용자 편의성을 개선
9. 함수를 더 편리하게¶
- Define-by-Run이라고 하는 전체 계산의 각 조각들을 런타임에 연결하는 능력을 할당
- 하지만 사용하기 불편한 부분이 있기때문에 DeZero의 함수에 세 가지 개선을 추가함
9.1 파이썬 함수로 이용¶
- 지금까지 DeZero는 함수를 '파이썬 클래스'로 정의하여 사용함
- 이 때문에 가령 Square 클래스를 사용하는 계산을 하려면 코드를 다음처럼 작성해야 했음
In [16]:
x = Variable(np.array(0.5))
f = Square()
y = f(x)
- 이와 같이 클래스의 인스턴스를 생성한 다음, 이어서 그 인스턴스를 호출하는 두 단계로 구분해야 진행해야 함
- 사용자 입장에서 번거로움. 이를 개선하는 해법은 '파이썬 함수'를 지원하는 것 그래서 다음 코드를 추가함
In [17]:
'''
def square(x):
f = Square()
return f(x)
def exp(x):
f = Exp()
return f(x)
'''
# 한 줄로 작성
def square(x):
return Square()(x)
def exp(x):
return Exp()(x)
- 이전의 f = Square() 형태에서는 DeZero 함수를 f라는 변수 이름으로 참조한 데 반해, 간소화한 코드에서는 직접 Square()(x)라고 쓴 것.
- 방금 구현한 두 함수를 사용해보자
In [18]:
x = Variable(np.array(0.5))
a = square(x)
b = exp(a)
y = square(b)
y.grad = np.array(1.0)
y.backward()
print(x.grad)
- 보다시피 최초의 np.array(0.5)를 Variable로 감싸면 일반적인 수치 계산을 하듯, 넘파이를 사용해 계산하도록 코딩이 가능함
- 또한 다음과 같이 함수를 연속으로 적용할 수도 있음
In [19]:
x = Variable(np.array(0.5))
y = square(exp(square(x))) # 연속하여 적용
y.grad = np.array(1.0)
y.backward()
print(x.grad)
- 이제 계산을 더 자연스로운 코드로 표현할 수 있게 됨
9.2 Backward 메서드 간소화¶
- 두 번째 계산은 역전파 시 사용자의 번거로움을 줄이기 위한 것
- 구체적으로 방금 작성한 코드에서
y.grad = np.array(1.0)
부분을 생략함 - 앞으로 위 코드를 생략할 수 있도록 Variable의
backward
메서드에 다음 두 줄을 추가함
In [20]:
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
def backward(self):
if self.grad is None: # 해당 코드 추가
self.grad = np.ones_like(self.data)
funcs = [self.creator]
while funcs:
f = funcs.pop()
x, y = f.input, f.output
x.grad = f.backward(y.grad)
if x.creator is not None:
funcs.append(x.creator)
- 이와 같이 만약 변수의 grad가 None이면 자동으로 미분값을 생성함.
np.ones_like(self.data)
코드는self.data
의 형상과 데이터 타입이 같은 ndarray 인스턴스를 생성하는데, 모든 요소를 1로 채워서 돌려줌- self.data가 스칼라이면 self.grad도 스칼라가 됨
- 이전까지는 출력의 미분값을
np.array(1.0)
으로 사용했지만, 방금 코드에서는np.ones_like()
를 씀 - 이 이유는 Variable data와 grad의 데이터 타입을 같게 만들기 위함
- 예를 들어, data의 타입이 32비트 부동소수점 숫자면 grad의 타입도 32비트 부동소수점 숫자가 됨
- np.array(1.0)은 64비트 부동소수점 숫자 타입으로 만들어 줌
- 이전까지는 출력의 미분값을
- 이제 어떤 계산을 하고 난 뒤 최종 출력 변수에서 backward 메서드를 호출하는 것만으로 미분값이 구해짐
In [21]:
x = Variable(np.array(0.5))
y = square(exp(square(x)))
y.backward()
print(x.grad)
9.3 ndarray만 취급하기¶
- DeZero의 Variable은 데이터로 ndarray 인스턴스만 취급하게끔 의도함
- 하지만 사용하는 사람이 모르고 float나 int 같은 의도치 않은 데이터 타입을 사용하는 일도 충분히 일어날 수 있음
- 예컨대 Variable(1.0)혹은 Variable(3)처럼 사용할 수도 있음
- 이런 사태를 막기 위해 Variable이 ndarray 인스턴스 만을 담는 '상자'가 되도록 함
- 따라서 Variable에 ndarray 인스턴스 외에 데이터를 넣을 경우 즉시 오류를 발생시킴(미분값은 None으로 유지)
In [22]:
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
def set_creator(self, func):
self.creator = func
def backward(self):
if self.grad is None:
self.grad = np.ones_like(self.data)
funcs = [self.creator]
while funcs:
f = funcs.pop()
x, y = f.input, f.output
x.grad = f.backward(y.grad)
if x.creator is not None:
funcs.append(x.creator)
- 이와 같이 인수로 주어진 data가 None이 아니고 ndarray 인스턴스도 아니라면 TypeError라는 예외를 발생시킴
In [23]:
x = Variable(np.array(1.0)) # ok
x = Variable(None) # ok
x = Variable(1.0)
- 그런데 이처럼 변경하고 나면 주의할게 하나 생김. 넘파이의 독특한 관례 때문
- 아래 코드 확인
In [24]:
x = np.array([1.0])
y = x ** 2
print(type(x), x.ndim)
print(type(y))
- x는 1차원 ndarray. 여기에 제곱을 하면 y의 데이터 타입도 ndarray가 됨.
In [25]:
x = np.array(1.0)
y = x ** 2
print(type(x), x.ndim)
print(type(y))
- 그러나 위의 경우에는 달라짐
- 0차원 ndarray 인스턴스를 사용하여 계산하면 결과의 데이터 타입이 numpy.float64나 numpy.float32 등으로 달라짐.
- 다시 말해, DeZero 함수의 계산 결과(출력)도 numpy.float64나 numpy.float32가 되는 경우가 나옴
- 그러나 우리 Variable은 데이터가 항상 ndarray 인스턴스라고 가정하고 있으니 대처해야함
- 이를 위해 다음과 같은 편의 함수를 준비함
In [26]:
def as_array(x):
if np.isscalar(x):
return np.array(x)
return x
- 여기에 쓰인
np.isscalar
는 입력 데이터가 numpy.float64 같은 스칼라 타입인지 확인해주는 함수(파이썬의 int나 float 타입도 스칼라로 판단 - 이처럼 x가 스칼라 타입인지 쉽게 확인할 수 있으며, as_array 함수는 이를 이용하여 입력이 스칼라인 경우 ndarray 인스턴스로 변환해줌
- 이를 활용하여 Function 클래스에 다음 코드를 추가함
In [27]:
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x)
output = Variable(as_array(y))
output.set_creator(self)
self.input = input
self.output = output
return output
def forward(self, x):
raise NotImplementedError()
def backward(self, x):
raise NotImplementedError()
- 이와 같이 순전파의 결과인 y를 Variable로 감쌀 때
as_array()
를 이용함. - 이렇게 하여 출력 결과인
output
은 항상 ndarray 인스턴스가 되도록 보장함 - 이제 0차원 ndarray 인스턴스를 사용한 계산에서도 모든 데이터는 ndarray 인스턴스가 됨
In [ ]:
'기계학습 > 밑바닥딥러닝3 오독오독 씹기' 카테고리의 다른 글
Chapter 2. 자연스러운 코드로(step 17~19)/ 연산자 오버로드 (0) | 2021.01.04 |
---|---|
Chapter 2. 자연스러운 코드로(step 17~19) (0) | 2020.12.29 |
Chapter 2. 자연스로운 코드로(step 16) (0) | 2020.12.16 |
Chapter 2. 자연스러운 코드로(step 11~14) (0) | 2020.12.07 |
1. Auto Gradient(자동미분): 변수/ 함수/ 수치미분/ 역전파 (0) | 2020.12.01 |