1. 수동 역전파
1.1 Variable 클래스 수정
■ 앞서, 구현한 Variable 클래스는 다음과 같이 단순히 상자와 같은 역할을 할 수 있도록 정의하였다.
class Variable:
def __init__(self, data):
self.data = data
- Variable 클래스의 인스턴스를 생성하여, 상자(변수)에 데이터를 넣을 수 있고, 상자 속을 보면 상자 속에 들어 있는 데이터가 무엇인지 확인할 수 있다.
■ 이때, data는 통상값이며, 역전파 구현을 위해서는 통상값에 대응되는 미분값(grad)도 저장할 수 있도록 다음과 같이 미분값을 저장할 인스턴스 변수를 Variable 클래스에 추가하면 된다.
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
■ 여기서 grad의 값을 None으로 초기화한 이유는, 역전파 과정에서 미분값을 계산하여 대입하기 위함이다.
1.2 Function 클래스 수정
■ 앞서, 구현한 Function 클래스는 순전파(forward) 메서드로 통상 계산만 수행하는 역할을 하도록 정의하였다.
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x) # 사용자가 원하는 연산은 forward 메서드에서 진행된다.
output = Variable(y)
return output
def forward(self, x):
raise NotImplementedError()
■ 변수를 의미하는 Variable 클래스에서 통상값과 통상값에 대응되는 미분값을 정의한 것처럼, 함수를 의미하는 Function 클래스도 통상 계산(forward)과 통상 계산에 대응되는, 즉 미분을 계산하는 역전파(backward)를 수행할 수 있도록 수정해야 한다.
■ 수정할 내용은 2가지이다.
- ① 역전파 메서드는 순전파 메서드처럼 사용자가 원하는 함수(=어떤 연산)가 매번 다를 때, 새롭게 정의한 Function 클래스를 상속받아서 수행하는 것과 동일하게 정의할 수 있다.
- ② 그리고 역전파 계산을 위해서는 미분 자동 계산 (2) 에서 본 것처럼 순전파 과정에서 순차적으로 나오는 변수의 값이 필요하다. 즉, 파이썬에서는 입력으로 받은 변수의 값을 저장하고 있어야 한다.
■ 이 두 가지 수정 내용을 반영한 Function 클래스는 다음과 같다.
class Function:
def __call__(self, input): # input은 Variable 클래스의 인스턴스
self.input = input # 입력 변수를 보관(저장)
x = input.data
y = self.forward(x)
output = Variable(y)
return output
def forward(self, x): # 순전파 메서드
raise NotImplementedError()
def backward(self, gy): # 역전파 메서드
raise NotImplementedError()
■ 이렇게 __call__ 메서드에 입력 변수를 인스턴스 변수에 저장한다면, 역전파 과정에서 입력 변수가 필요할 때, self.input을 통해 가져올 수 있다.
■ 예시에서 사용할 함수는 \( y = x^2 \)을 계산하는 Square 클래스와 \( y = e^x \)를 계산하는 Exp 클래스이다. 통상(일반적인) 계산만 수행했을 때는 다음과 같이 Function 클래스를 상속하여 forward 메서드만 정의하였는데,
class Square(Function):
def forward(self, x):
return x**2
class EXP(Function):
def forward(self, x):
return np.exp(x)
이제는 미분을 계산하는 역전파 메서드에 대한 정의가 필요하다.
■ \( y = x^2 \)의 미분은 \( \dfrac{dy}{dx} = y' = 2 \cdot x \)이고 \( y = e^x \)의 미분은 자기 자신. 즉, \( \dfrac{dy}{dx} = y' = e^x \)이므로 다음과 같이 Square, Exp 클래스에 역전파를 수행하는 backward 메서드를 추가할 수 있다.
class Square(Function):
def forward(self, x):
y = x ** 2
return y
def backward(self, gy):
x = self.input.data
gx = 2 * x * gy
return gx
class Exp(Function):
def forward(self, x):
y = np.exp(x)
return y
def backward(self, gy):
x = self.input.data
gx = np.exp(x) * gy
return gx
■ backward 메서드의 인수 gy는 출력 방향에서 전파되는 미분값을 전달하는 역할을 한다. 출력 쪽에서 전해지는 미분값 gy를 \( y' = 2x \)나 \( y' = e^x \)에 곱한 값이 역전파의 결과이기 때문이다.
1.3 역전파 구현 예시
■ 예를 들어, 다음과 같은 합성 함수의 계산 그래프가 있을 때, \( A \)와 \( C \) 함수는 Square 함수, \( B \)는 Exp 함수라고 했을 때, 이 합성 함수의 미분을 역전파로 계산하기 위해서는
■ 반드시 순전파가 먼저 수행되어야 한다.
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(2)) # 입력 변수
## 순전파 수행 순서
s = A(x)
t = B(s)
y = C(t)
# 순전파 계산 결과 확인
y.data
```#결과#```
2980.957987041728
````````````
- 순전파를 통해 계산되는 \( y \)는 \( y = ( e^4 )^2 \)이므로 2980.957987... \)의 값을 갖는다.
■ 순전파를 수행했으니, 이제 역전파를 수행할 수 있다. 미분 자동 계산 (2) 에서 본 다음 그림처럼, 역전파의 계산 순서는 순전파 계산 순서의 반대이므로,
■ 다음과 같이 순전파 때와는 반대 순서로 각 함수의 backward 메서드를 호출하면 된다. 이때, 위의 그림처럼 역전파 과정에서 처음 전달되는 값은 \( \dfrac{dy}{dy} = 1 \)이다.
## 역전파 수행 순서
y.grad = np.array(1.0)
t.grad = C.backward(y.grad)
s.grad = B.backward(t.grad)
x.grad = A.backward(s.grad)
# 역전파 계산 결과 확인
x.grad
```#결과#```
23847.663896333823
````````````
■ x.grad 값이 \( y \)의 \( x \)에 대한 미분 결과. 즉, \( \dfrac{dy}{dx} \)의 값이다.
2. 역전파 자동화
■ 1.3에서 역전파를 계산하기 위해서 수동으로 역전파 순서에 맞춰 backward 메서드를 호출하였다. 예시에서 사용한 함수의 개수는 3개이므로 간단하지만, 사용할 함수들이 100개, 1000개, 10000개, ... 일 때, 이렇게 수동으로 호출하여 조합하는 것은 비효율적이다. 그러므로 해당 작업을 자동화할 필요가 있다.
■ 여기서 말하는 '자동화'는 순전파를 한 번만 수행하면, 역전파가 자동으로 진행되는 구조를 만드는 것이다. 이것은 'Define-by-Run'이라는 동적 계산 그래프 생성 방법과 관련이 있다.
■ 'Define-by-Run' 방법은 동적 계산 그래프를 생성하는 방법으로, 딥러닝 프레임워크가 순방향 패스(forward pass)를 실행하는 동안 계산 그래프를 생성하게 되는데, 이때 그래프를 동적으로 변경할 수 있다.
■ 그 이유는 딥러닝에서 수행하는 계산들을 계산 시점에 연결(참조)하는 방식이기 때문이다. 예를 들어, forward pass 과정에서 수행한 계산과 그 결과물을 연결하여 계산 그래프를 만든다. 대표적인 프레임워크로 파이토치가 있다.
cf) 반대로 'Define-and-Run'이라는 정적 계산 그래프를 생성하는 방법이 있다. 대표적인 프레임워크로 텐서플로우가 있다.
2.1 역전파 자동화를 위한 변수와 함수의 관계
■ 2.에서 언급한 '자동화'를 구현하기 위해서는 다음 그림과 같은 변수와 함수의 관계를 이해해야 한다.
■ 함수 입장에서 변수는, 함수의 '입력'과 '출력'에 사용되는 것이다. 즉, 함수에게 변수는 다음과 같이 '입력 변수(input)'과 '출력 변수(output)'로서 존재한다.
- 점선은 참조를 의미한다.
■ 변수 입장에서 함수는, '변수는 함수에 의해 생성'된다. 즉, 다음과 같은 변수 \( y \)에게 있어 함수는 창조자(creator)이다.
- 변수 \( y \)같은 경우는 창조자인 함수 \( function \)으로 생성되는 변수이다.
- 그러나 변수 \( x \)는 창조자인 함수가 없다. 이렇게 창조자인 함수가 존재하지 않는 \( x \)와 같은 변수는 사용자에 의해 만들어진 변수로 간주된다.
■ 앞서 2.에서 제시한 '자동화'를 구현하기 위해서는 순전파가 이루어지는 시점에 이러한 '관계'를 연결(참조)하도록, 즉 함수와 변수를 연결(참조)하도록 Variable 클래스와 Function 클래스를 만들어야 한다.
class Variable:
def __init__(self, data):
self.data = data
self.grad = None
self.creator = None
def set_creator(self, func):
self.creator = func
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, gy):
raise NotImplementedError()
■ 먼저, Variable 클래스를 보면, self.creator이라는 인스턴스 변수를 추가하고 set_creator( )라는 메서드를 통해 변수의 creator 속성을 설정해서, self.creator로 변수(Variable)를 생성한 함수(Function)를 참조할 수 있도록 한다.
■ 그다음, Function 클래스를 보면
- 입력으로 받은 Variable 객체에서 데이터를 추출한 뒤, 이를 기반으로 순전파(forward) 계산을 수행한다.
- 순전파 계산 결과인 output을 새로운 Variable 객체로 감싸고, 이 새로운 객체의 creator를 현재 함수로 설정한다.
- 또한, 입력과 출력을 각각 self.input, self.output에 저장하여 추적이 가능하도록 설정한다.
■ 어떤 함수(Function)가 호출되면, 입력 변수(Variable)에서 data를 가져와 계산을 수행하여, 새로운 변수인 출력 변수(Variable)가 생성된다.
■ 이때, 새로운 변수인 output은 자신을 생성한 함수를 기억하기 위해, 자신의 creator 속성에 자신을 생성한 함수를 저장한다. 이렇게 하면 변수와 함수 간의 관계가 동적으로 '연결'되며, 이는 역전파 시, 계산 그래프를 따라가며 기울기를 계산하는 데 사용된다.
■ 위와 같이 Variable 클래스와 Function 클래스를 만들면 위의 '함수와 변수의 관계'가 포함된 것을 알 수 있다.
- 입력 변수 \( \rightarrow \) 함수 \( \rightarrow \) 출력 변수
-- 입력 변수는 함수(Function)의 입력으로 사용되고(input.data), 함수는 이를 처리하여 새로운 출력 변수(output = Variable(y))를 생성한다.
- 출력 변수 \( \rightarrow \) 창조자 함수
-- 출력 변수(output)는 자신을 생성한 함수를 기억하기 위해 creator 속성을 설정한다.(output.set_creator(self))
- 이렇게 설정하면, 출력 변수가 자신의 창조자(함수)를 기억하고 있으므로, 역전파 시 이를 통해 이전 단계로 돌아갈 수 있다.
■ 예를 들어, 다음과 같은 순전파 계산을 수행하는 계산 그래프가 있다고 했을 때,
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(2)) # 입력 변수
## 순전파 수행 순서
s = A(x)
t = B(s)
y = C(t)
- 변수 \( y \)를 생성한 함수는 \( C \)
- 함수 \( C \)의 입력은 변수 \( t \)
- 변수 \( t \)를 생성한 함수는 \( B \)
- 함수 \( B \)의 입력은 변수 \( s \)
- 변수 \( s \)를 생성한 함수는 \( A \)
- 함수 \( A \)의 입력은 변수 \( x \)
- 이런 식으로 순전파를 수행해서 생성한 계산 그래프를 다음과 같이 역추적할 수 있다.
- 즉, 계산 그래프의 노드들을 거꾸로 거슬러 올라갈 수 있다. 이를 코드로 확인하면 다음과 같다.
## 계산 그래프 역추적 - 변수 y에서 시작
print(y.creator == C) # y(Variable).creator == C(Function)
print(y.creator.input == t) # C(Function).input == t(Variable)
print(y.creator.input.creator == B) # t(Variable).creator == B(Function)
print(y.creator.input.creator.input == s) # B(Function).input == s(Variable)
print(y.creator.input.creator.input.creator == A) # s(Variable).creator == A(Function)
print(y.creator.input.creator.input.creator.input == x) # A(Function).input == x(Variable)
```#결과#```
True
True
True
True
True
True
````````````
■ 위의 코드와 위의 역추적을 진행하는 계산 그래프 그림을 보면, 계산 그래프는 '함수와 변수 사이의 연결'로 구성된다고 볼 수 있다. 그리고 이 '연결'은 순전파(forward pass) 과정에서 데이터를 다음 노드로 흘려보낼 때 만들어진다.
■ 이렇게 계산 그래프를 생성하는 방법을 'Define-by-Run'이라고 한다. 이 방법을 동적 계산 그래프 생성 방법이라고 하는 이유는, 프로그램이 시작할 때 객체를 미리 생성해두는 것이 아니라, 객체를 필요로 하는 순간에 바로 생성하기 때문이다.
■ 또한, 역추적 계산 그래프 그림에 있는 변수와 함수를 '노드'로 보면, 계산 그래프의 구조는 노드들의 연결로 이루어진 링크드 리스트(linked list)로 볼 수 있다. 여기서 노드는 그래프를 구성하는 요소이며, 링크(link)는 다른 노드를 가리키는 참조 역할을 한다. 리스트(list)
리스트(list)
1. 리스트■ 리스트(=선형 리스트(linear list))는 순서 또는 위치(position)를 가진 항목들이 차례대로 나열된 선형 자료구조이다. ■ 리스트를 기호로 표현하면 다음과 같다.list = [item0, item1, item2, ... ,
hyeon-jae.tistory.com
■ 이렇게 변수와 함수의 관계를 이용하여 역전파를 수행할 수 있다. 우선 다음과 같이 변수 \( y \)에서 변수 \( t \)까지의 역전파를 수행한다면,
y.grad = np.array(1.0)
Function_C = y.creator # y를 생성한 함수 C
Variable_t = Function_C.input # 함수 C의 입력 변수 t
# 변수 y에서 변수 b까지의 역전파 수행
Variable_t.grad = Function_C.backward(y.grad) # 함수 C의 backward 메서드 호출
# 역전파 계산 결과 값
print(Variable_t.grad)
```#결과#```
109.19630006628847
````````````
■ 그다음 단계는 변수 \( t \)에서 \( s \)로의 역전파이다. 이때, 변수 \( t \)에 전파된 미분값은 변수 \( y \)에서 변수 \( b \)까지의 역전파를 통해 얻은 값(Variable_t.grad)이다.
Function_B = t.creator
Variable_s = Function_B.input
Variable_s.grad = Function_B.backward(Variable_t.grad)
print(Variable_s.grad)
```#결과#```
5961.915974083456
````````````
■ 그다음 단계는 변수 \( s \)에서 \( x \)로의 역전파이다. 이때, 변수 \( s \)에 전파된 미분값은 변수 \( t \)에서 변수 \( s \)로의 역전파를 통해 얻은 값(Variable_s.grad)이다.
Function_A = s.creator
Variable_x = Function_A.input
Variable_x.grad = Function_A.backward(Variable_s.grad)
print(Variable_x.grad)
```#결과#```
23847.663896333823
````````````
■ 이렇게 역전파를 부분별로 나누어서 수행했을 때, 다음과 같은 동일한 흐름으로 진행되는 것을 알 수 있다.
- (1) 변수를 생성한 함수를 가져온다.
- (2) 그 함수의 입력을 가져온다.
- (3) 그 함수의 backward 메서드를 호출한다.
2.2 역전파 처리 흐름 자동화 - 재귀
■ 바로 위의 예시에서 역전파를 부분별로 나누어서 수행했을 때, 동일한 처리 흐름을 반복하였다. 이 반복 작업은 다음과 같이 Variable 클래스에 역전파를 수행하는 backward 메서드를 재귀 호출하는 기능을 추가하여 자동화할 수 있다.
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):
function = self.creator # 변수를 생성한 함수
if function is not None: # 창조자인 함수가 존재한다면 = 사용자에 의해 만들어진 변수가 아니라면,
x = function.input # 함수의 입력 변수를 가져와서
x.grad = function.backward(self.grad) # 함수(Function)의 backward 메서드 호출
x.backward() # 재귀 호출 - 하나 앞의 변수의 backward 메서드를 호출
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(2)) # 입력 변수
## 순전파 수행 순서
s = A(x)
t = B(s)
y = C(t)
# 역전파 자동화
y.grad = np.array(1.0)
y.backward() # Variable 클래스의 backward 메서드 호출
print(x.grad)
```#결과#```
23847.663896333823
````````````
- y.grad = 1.0은 x.grad = function.backward(self.grad)에 들어가서, function.backward(y.grad)를 통해 변수 t의 grad값(미분값 = 기울기)을 계산한다.
- 즉, t.grad = function.backward(y.grad)를 계산한다.
- 그다음, x.backward()를 호출. 즉, Variable 클래스의 backward() 메서드를 호출한다. 이 재귀 호출을 통해 변수 t 하나 앞의 변수인 s의 backward() 메서드를 호출하게 된다.
- 마찬가지로 변수 s의 grad 값 계산이 끝나면, 재귀 호출을 통해 변수 s 하나 앞의 변수인 x의 grad 값이 계산된다.
cf) 또한, 변수 x의 grad값 뿐만 아니라 중간 과정에 있는 변수 t, 변수 s의 gard 값도 저장된다.
print(t.grad); print(s.grad)
```#결과#```
109.19630006628847
5961.915974083456
````````````
■ 이렇게 반복 작업은 '재귀'를 통해 구현할 수도 있지만, 문제가 재귀적 구조가 아니라면, 일반적으로 반복문을 사용하는 것이 성능 면에서 더 효율적이다.
2.3 역전파 처리 흐름 자동화 - 반복문
■ 위의 재귀 호출을 통해 자동 미분(역전파 처리 흐름)을 자동화하는 코드는 창조자 함수가 없는 변수. 즉, self.creator is None인 변수를 찾을 때까지 계속해서 backward 메서드에서 backward 메서드를 호출한다.
■ 이러한 재귀 구조를 이용하면, 함수를 재귀적으로 호출할 때마다 중간 결과를 메모리에 유지하면서(스택에 쌓으면서) 처리를이어간다. 이 예시에서는 중간 결과를 저장할 객체의 수가 많지 않으므로 문제가 없지만, 중간 결과를 저장해야할 것이 1000개, 10000개, ... 가 된다면, 비효율적으로 메모리를 사용하는 것이므로 반복문을 사용하는 것이 효율적이다.
■ 이러한 반복 구조를 재귀 구조가 아닌 반복문을 이용하여 다음과 같이 구현할 수 있다.
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() # funcs 리스트에서 역전파 처리해야 할 함수를 하나 가져온다.
x, y = f.input, f.output # 해당 함수의 입력 변수와 출력 변수를 가져온다.
x.grad = f.backward(y.grad) # 함수(Function)의 backward() 메서드를 호출
# 창조자인 함수가 존재한다면 = 사용자에 의해 만들어진 변수가 아니라면,
# 현재 입력 변수 x의 하나 앞의 함수. 즉, 변수 x를 생성하는 함수를 리스트에 funcs 추가한다.
if x.creator is not None: funcs.append(x.creator)
- 출력 변수 y에 대해 dy/dy = 1 \( \Leftrightarrow \) y.grad = 1.0으로 설정한 다음, 역전파를 시작(y.backward( )) 하면,
y를 생성한 함수의 객체인 y의 creator(y.creator)가 계산 그래프의 첫 번째 역전파 대상이 된다.
- Variable 클래스의 backward( ) 메서드 내부에는 funcs라는 리스트를 생성하는데, 여기에는 역전파가 진행될 함수들이 저장된다. 처음에는 y.creator가 funcs 리스트에 들어갈 것이다.
- 그다음, funcs 리스트가 빌 때까지 while 루프를 실행한다. funcs는 리스트이므로, 리스트의 pop( ) 메서드를 사용하여 리스트에서 마지막 원소(self.creator)를 제거 & 반환한다.
-- 이 f는 현재 처리할 역전파 대상(이 예에서는 Square 또는 Exp 클래스의 인스턴스)이다.
- f.input과 f.output을 통해 함수의 입력 변수와 출력 변수를 받아와서 f.backward(y.grad)를 통해 역전파를 계산한다. 이 과정에서 chain rule에 따라 입력 x에 대한 grad(기울기)가 계산된다.
-- 정확하게는 f.backward(y.grad)를 통해 역전파를 계산하면, Square 클래스나 Exp 클래스의 인스턴스에 backward( ) 메서드를 호출하여 계산된 기울기(gx)를 f.input에 해당하는 입력 변수 x의 grad 속성에 저장한다.
- 만약 f의 입력 변수인 x가 creator를 가지고 있다면, 이는 입력 변수 x가 또 다른 함수의 출력 변수임을 의미하므로, 계산 그래프에서 역전파를 더 거슬러 올라가야 한다. 이 경우에 x의 creator(x.creator)를 funcs 리스트에 추가한다.
- 이 과정을 반복하면(funcs 리스트가 빌 때까지, 리스트의 pop( ) 메서드를 통해 계산 그래프의 모든 노드를 역방향으로 방문하면), 계산 그래프에 존재하는 모든 함수들이 역전파를 통해 처리되어 각 변수의 gard 속성에는 기울기(미분값)이 대입된다.
- 이 예에서는 처음에 funcs가 초기화되어 빈 리스트였다가. y.backward( )가 시작되면 funcs에는 C 함수가 들어가고, 이는 다시 f = funcs.pop( )을 통해 funcs 리스트에서 제거 & 반환된다.
그리고 if x.creator is not None이라는 조건(현재 x.creator는 t.creator)을 만족하므로 비어 있는 funcs 리스트에는 다시 t.creator인 함수 B가 funcs 리스트에 추가된다.
이 과정은 더 이상 funcs 리스트에서 pop( )을 할 수 없을 때까지(if 문이 Flae. 즉, x.creator가 없을 때까지) 반복된다.
-- 여기서 말한 '과정'들은 연쇄 법칙(chain rule) 계산을 위한 것들이다. 이렇게 반복문을 이용하여 연쇄 법칙을 구현할 수 있다.
- 최종적으로 입력 변수 x에 대해서도 x.grad에 출력 변수 y로부터 전달된 전체 기울기인 \( \dfrac{dy}{dx} \)가 계산되어 할당된다.
- 또한, 계산 그래프로 역전파 과정을 처리하기 위해 데이터 구조 중 후입선출 LIFO(Last In Frist Out) 원칙을 따르는 스택(stack)을 사용하는 것을 볼 수 있다.
A = Square()
B = Exp()
C = Square()
x = Variable(np.array(2)) # 입력 변수
## 순전파 수행 순서
s = A(x)
t = B(s)
y = C(t)
# 역전파
y.grad = np.array(1.0)
y.backward() # Variable 클래스의 backward 메서드 호출
print(x.grad)
```#결과#```
23847.663896333823
````````````
'딥러닝' 카테고리의 다른 글
가변 길이 인수 (0) | 2025.03.26 |
---|---|
미분 자동 계산 (4) (0) | 2025.03.14 |
미분 자동 계산 (2) (0) | 2025.03.11 |
미분 자동 계산 (1) (0) | 2025.03.07 |
트랜스포머 (Transformer) (3) (0) | 2025.01.10 |