1. 가변 길이의 입출력 변수
■ 함수에 따라 여러 개의 변수를 입력으로 받거나, 여러 개의 변수를 출력할 수 있다. 이러한 가변 길이 입출력에 대응할 수 있어야 한다.
1.1 순전파 과정에서 가변 길이 인수 처리를 위한 Function 클래스 수정
1,1,1 반복 가능한(iterable) 객체 이용
■ 가변 길이의 입출력에 대응하기 위해서는 다음과 같이 하나의 인수만 입력으로 받아 하나의 값만 처리하는 Function 클래스를 수정해야 한다.
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, gy):
raise NotImplementedError()
- 기존의 Function 클래스는 'input'으로 전달받은 하나의 Variable 클래스의 인스턴스에서 data 속성을 저장한 다음,
- 저장한 데이터 값으로 순전파를 수행한다. (forward 메서드로 연산을 수행한다.)
- 순전파 계산 결과를 새로운 Variable 클래스의 인스턴스 'output'에 저장한다.
- 역전파 시 계산 그래프를 (역)추적하기 위해 출력 변수 'output'에 set_creator 메서드를 호출해서, 출력값이 현재 함수(self)에 의해 생성되었음을 기록한다.
■ 새로운 Function 클래스에서 가변 길이 입출력을 다루기 위해 여러 개의 데이터를 순서대로 한 줄로 저장하는 시퀀스 개체인 리스트(또는 튜플)를 사용한다면, 기존 Function 클래스에서 바꿔야 하는 부분들은 크게 다음과 같다.
- 'input'을 받아 data 속성을 추출하는 부분이다. 여러 개의 입력 변수에 대한 data 속성을 각각 추출해야 할 것이다.
- 'output'도 마찬가지이다. 각각의 순전파 결과에 대한 출력 값을 저장해야 한다.
- 'output'의 set_creator 메서드를 호출하는 부분도 마찬가지이다. forward 메서드를 통해 계산된 output들이 현재 함수(self)에 의해 생성되었음을 기록해야 한다.
- 이를 반영하여 Function 클래스를 다음과 같이 수정할 수 있다.
class Function:
def __call__(self, inputs):
xs = [x.data for x in inputs]
ys = self.forward(xs)
outputs = [Variable(as_array(y)) for y in ys]
for output in outputs:
output.set_creator(self)
self.inputs = inputs
self.outputs = outputs
return outputs
def forward(self, xs):
raise NotImplementedError()
def backward(self, gys):
raise NotImplementedError()
■ 위와 같이 가변 길이 입출력을 처리하기 위해 Function 클래스에서 리스트(또는 튜플)을 사용하면, Function 클래스를 상속받아 구현되는 어떤 연산을 하는 함수들도 forward 메서드의 결과를 리스트(또는 튜플)로 반환해야 한다.
- 왜냐하면, forward 메서드를 통해 계산된 ys는 다음 줄의 리스트 컴프리헨션에서 for 문의 in에 위치하고 있다.
- 이 위치는 반복 가능한 객체가 올 것으로 가정하고 있기 때문에, ys는 반복 가능한(iterable) 객체(리스트, 튜플같은 시퀀스 객체나 딕셔너리, 튜플같은 비시퀀스 객체 등)로 설정해야 한다.
■ 위와 같이 예를 들어, 두 변수의 곱셈을 계산하는 클래스를 만든다면
class Multiply(Function):
def forward(self, xs):
x_0, x_1 = xs # 입력 변수를 튜플로 받아서
y = x_0 * x_1 # 곱셈을 계산한 다음,
return [y] # 계산 결과를 리스트로 반환 # 튜플로 반환하고 싶다면 (y ,)로 콤마(,)를 사용해 튜플임을 명시
■ 이 Multiply 클래스를 이용하기 위해서는 다음과 같이 입력 변수를 묶어서 처리해야 한다.
xs = [Variable(np.array(2)), Variable(np.array(5))]
f = Multiply()
ys = f(xs)
■ Multiply의 경우 forward 메서드의 결과는 1개이다. 그러므로 계산 결과를 확인하기 위해서는 ys 리스트의 첫 번째 인덱스에 접근해야 한다.
ys[0].data
```#결과#```
array(10)
````````````
■ 참고로 ys[0] = 10의 입력 변수가 어떤 함수로부터 계산된 것이며, 입력 변수가 무엇인지 확인하고 싶다면, 다음과 같이 creator 속성을 이용하여 확인할 수 있다.
print(ys[0].creator.inputs[0].data)
print(ys[0].creator.inputs[1].data)
```#결과#```
2
5
````````````
print(ys[0].creator.__class__)
```#결과#```
<class '__main__.Multiply'>
````````````
1,1,2 패킹과 언패킹
■ 1.1.1에서는 인수를 리스트에 모아서 처리했다. 파이썬에서는 가변 인수(또는 가변 위치 인수) 기능을 제공한다.
■ 다음과 같이 함수 파라미터 *args처럼 args 앞에 *을 붙인 것을 가변 인수라고 한다 함수 호출부에서 임의의 개수의 인수(가변 길이 인수)를 전달하고자 할 때 사용한다.
def f(*args):
print(type(args))
print(args)
f(1, 2, 3)
f(1, 2, 3, 4)
f([1, 2, 3], [4, 5, 6])
```#결과#```
<class 'tuple'>
(1, 2, 3)
<class 'tuple'>
(1, 2, 3, 4)
<class 'tuple'>
([1, 2, 3], [4, 5, 6])
````````````
■ 이 문법을 사용하면 임의 개수의 인자를 받을 수 있으며, 함수 호출시 인자들이 튜플로 패킹되어 args에 들어가고, args라는 변수는 여러 개의 입력에 대해 튜플 형태로 저장하는 것을 볼 수 있다.
■ 즉, 이러한 가변(위치)인수는 함수가 임의의 개수의 인자(가변 길이 인수)를 받을 수 있게 해주는 기능이며, 임의 개수의 인수를 튜플 형태로 받는다. 이 가변 인수를 사용하면 1.1.1처럼 인수를 리스트에 모아서 처리하지 않아도 된다.
■ 그리고 1.1.1에서 순전파의 출력 결과로 output을 반환받았을 때, output의 값. 즉, 출력 변수의 값을 확인하기 위해서 리스트의 첫 번째 인덱스에 접근하여 .data 속성을 통해 결과값을 확인하였다.
■ 특히, 예시의 경우 두 개의 인수를 입력 받아 하나의 출력을 내는 곱셈 연산이다. 그러므로 차라리 함수 내에서 outputs 리스트의 길이를 확인하여 길이가 1이라면(=원소의 개수가 1개라면) 해당 변수를 직접 반환하는 것이 합리적이다.
- 혹은 Variable 클래스에 .data의 속성을 반환하는 def get_data(self): return self.data같은 메서드를 만들어서 사용해도 된다.
class Function:
def __call__(self, *inputs): # 가변(위치)인수 *inputs: 임의의 개수의 인수를 튜플로 받는다.
xs = [x.data for x in inputs]
ys = self.forward(xs)
outputs = [Variable(as_array(y)) for y in ys]
for output in outputs:
output.set_creator(self)
self.inputs = inputs
self.outputs = outputs
if len(outputs) == 1: return outputs[0] # 출력할 변수가 1개라면 해당 변수 직접 반환
else: outputs
def forward(self, xs):
raise NotImplementedError()
def backward(self, gys):
raise NotImplementedError()
■ 그리고 1.1.1에서 정의한 Function 클래스를 상속받아 곱셈 연산을 수행하는 Multiply 클래스의 경우 인수로 [xs] 리스트를 받아서 [y] 리스트 형태로 결과를 반환하고 있다. 이를 더 간결하게 나타내려면 언패킹(unpacking)을 하면 된다.
■ 파이썬에는 패킹(packing)과 언패킹(unpacking)이 있다. 패킹은 위의 가변 인수처럼 인수로 받은 여러 개의 값을 하나의 객체(가변 인수에는 튜플)로 합쳐서 받을 수 있도록 한다. 즉, 매개변수 앞에 *을 붙여 가변(위치)인수 패킹을 수행하는 것이다.
def f(*args):
print(type(args))
print(args)
f(1, 2, 3)
f(1, 2, 3, 4)
f([1, 2, 3], [4, 5, 6])
```#결과#```
<class 'tuple'>
(1, 2, 3)
<class 'tuple'>
(1, 2, 3, 4)
<class 'tuple'>
([1, 2, 3], [4, 5, 6])
````````````
- f(1, 2, 3)을 보면 각각의 인수 1, 2, 3은 가변 인수 *args를 통해 (1, 2, 3)이라는 하나의 객체(튜플)로 패킹된 것을 볼 수 있다.
■ 이러한 패킹(packing)과 반대되는 개념이 언패킹(unpacking)이다. 패킹이 여러 개의 객체(인수)를 하나의 객체로 합쳐주었다면, 언패킹은 여러 개의 객체(인수)를 포함하고 있는 하나의 객체를 풀어준다.
■ 가변(위치)인수에 대해 언패킹을 할 때는 패킹과 마찬가지로 *을 앞에 붙인다. 단, 패킹처럼 함수의 매개변수 앞에 *을 붙이는 것이 아니라 인수 앞에 *을 붙여 사용한다.
a = ([1, 2, 3], [4, 5, 6])
print(a) # 두 개의 리스트를 하나의 튜플로 패킹
print(*a) # 하나의 튜플에 있는 두 개의 리스트를 언패킹
```#결과#```
([1, 2, 3], [4, 5, 6])
[1, 2, 3] [4, 5, 6]
````````````
■ 이러한 언패킹 개념을 사용한다면, Function 클래스와 Function 클래스를 상속받아 곱셈 연산을 수행하는 Multiply 클래스를 다음과 같이 수정하면 된다.
class Function:
def __call__(self, *inputs): # 패킹
xs = [x.data for x in inputs]
ys = self.forward(*xs) # 언패킹
# forward 메서드 결고가 튜플이 아니면 튜플로 변환
if not isinstance(ys, tuple):
ys = (ys, )
outputs = [Variable(as_array(y)) for y in ys]
for output in outputs:
output.set_creator(self)
self.inputs = inputs
self.outputs = outputs
if len(outputs) == 1: return outputs[0]
else: outputs
def forward(self, xs):
raise NotImplementedError()
def backward(self, gys):
raise NotImplementedError()
- xs는 리스트이므로 self.forward(*xs)를 하면 리스트를 언패킹한다.
- 예를 들어 xs = [x_0, x_1]일 때, self.forward(*xs)를 하면 self.forward(x_0, x_1)로 호출하는 것과 동일하게 동작한다.
- self.forward()로 반환되는 결과는 int32, float64와 같은 데이터일 수 있다. 이를 방지하기 위해 반환되는 ys의 타입이 튜플이 아니라면 ys를 튜플로 만들어준다.
- 그러므로 Multiply 클래스는 다음과 같이 forward의 파라미터를 x_0, x_1으로 설정할 수 있다.
class Multiply(Function):
def forward(self, x_0, x_1):
y = x_0 * x_1
return y
■ 마지막으로 Multiply 클래스를 def 로 정의하여 파이썬 함수로 사용할 수 있는 코드를 추가하면
def multiply(x_0, x_1):
return Multiply()(x_0, x_1)
■ 다음과 같이 간단하게 순전파를 진행할 수 있다.
## 수정 전
xs = [Variable(np.array(1)), Variable(np.array(2))]
f = Add()
ys = f(xs)
ys[0].data
## 수정 후
x_0, x_1 = Variable(np.array(2)), Variable(np.array(5))
y = multiply(x_0, x_1)
print(y.data)
```#결과#```
10
````````````
1.2 역전파 과정에서 가변 길이 인수 처리를 위한 Function 클래스 수정
■ 예를 들어, 다음과 같이 입력이 2개, 출력이 1개인 덧셈 계산 그래프와 곱셈 계산 그래프가 있다고 하자.
■ 순전파 과정에서는 입력이 2개(\( x_0, x_1 \)), 출력이 1개(\( y \))이지만, 역전파 과정에서는 입력이 1개(\( y \)) , 출력이 2개(\( x_0, x_1 \))가 된다.
■ 각각에 대해 수식으로 나타내면 \( y = x_0 + x_1 \)과 \( y = x_0 x_1 \)이다. 즉, 순전파 과정에서는 \( y = x_0 + x_1 \)과 \( y = x_0 x_1 \)가 계산된다.
■ 반대로 역전파 과정에서는, 미분을 한다. 이 예시의 경우 입력 변수가 여러 개(2개 인) 다변수 함수이므로 각각의 수식에 대해 편미분을 진행하면
- \( y = x_0 + x_1 \)의 경우
- \( x_0 \)에 대해 편미분하면 \( x_1 \)을 상수 취급하여 \( y' = 1 \)이고, \( x_1 \)에 대해 편미분하면 \( x_0 \)을 상수 취급하여 \( y' = 1 \)가 된다.
- \( y = x_0x_1 \)의 경우
- \( x_0 \)에 대해 편미분하면 \( y' = x_1 \)이고, \( x_1 \)에 대해 편미분하면 \( y' = x_0 \)이 된다.
■ 즉, 덧셈에 대한 역전파 과정에서는 상류에서 흘러오는 기울기에 각각 1이 곱해져서 기울기가 흘러갈 것이고,
■ 곱셈에 대한 역전파 과정에서는 \( x_0 \)의 경우 상류에서 흘러오는 기울기 값과 \( x_1 \)이, \( x_1 \)의 경우 상류에서 흘러오는 기울기 값과 \( x_0 \)이 곱해져서 하류로 기울기를 전파할 것이다.
■ 이러한 덧셈과 곱셈에 대한 클래스는 다음과 같이 정의할 수 있다.
class Add(Function):
def forward(self, x_0, x_1):
y = x_0 + x_1
return y
def backward(self, gy): # gy는 상류에서 전파된 기울기값
return gy, gy
class Multiply(Function):
def forward(self, x_0, x_1):
y = x_0 * x_1
return y
def backward(self, gy): # gy는 상류에서 전파된 기울기값
# x_0 노드에는 상류의 기울기값과 x_1을 곱한 값
# x_1 노드에는 상류의 기울기값과 x_0을 곱한 값을 전달
return gy*self.inputs[1].data, gy*self.inputs[0].data
■ 그리고 기존에 제곱 연산을 수행했던 Square 클래스도 다음과 같이 변경해야 한다.
# 수정 전
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 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
- 기존의 Function 클래스는 input으로 한 개의 변수를 받았는데, 수정한 Function 클래스는 패킹을 통해 여러 개의 인수를 입력으로 받게 된다.
- 그러므로, 기존의 self.input.data를 통해 가져왔던 x값을 self.inputs[0].data를 통해 가져와야 한다.
■ 이렇게 역전파 과정에서 여러 개의 값을 반환할 수 있게 하려면 Variable 클래스를 수정해야 한다.
■ 현재 Variable 클래스는 다음과 같이 정의되어 있다.
class Variable:
def __init__(self, data):
if data is not None:
if not isinstance(data, np.ndarray):
raise TypeError(f'{type(data)} data must be a ndarray')
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 = as_array(f.backward(y.grad))
if x.creator is not None: funcs.append(x.creator)
■ 현재 Variable 클래스는 while 문 안에서 함수의 입출력 변수를 꺼낸 다음, ( x, y = f.input, f.output)
■ 함수 f의 backward 메서드를 호출해서 역전파를 진행한다.
■ 이때, 함수의 입출력 변수를 꺼내는 과정 x, y = f.input, f.output은 입출력 변수를 1개로 제한한 것이다.
■ 이 부분을 여러 개의 변수를 받을 수 있도록 수정하면 된다. 즉, 역전파 과정에서 가변 인수를 처리하도록 수정하면 된다.
class Variable:
def __init__(self, data):
if data is not None:
if not isinstance(data, np.ndarray):
raise TypeError(f'{type(data)} data must be a ndarray')
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()
gys = [output.grad for output in f.outputs] # output의 미분값들을 리스트에 저장
gxs = f.backward(*gys) # 리스트 언팩 # 함수 f의 역전파 메서드 호출 # gxs는 역전파로 전파되는 미분값
if not isinstance(gxs, tuple): # gxs가 튜플이 아니면
gxs = (gxs, ) # 튜플로 변환
for x, gx in zip(f.inputs, gxs): # f.inputs의 미분값이 gxs
x.grad = gx
if x.creator is not None: funcs.append(x.creator)
■ gys와 gxs 그리고 gxs가 튜플인지 확인하는 과정읜 위의 Function 클래스를 수정했을 때와 같다.
■ 이때, 함수의 입력값을 의미하는 f.inputs과 역전파로 전파되는 미분값인 gxs는 서로 관련이 있다. \( i \) 번째 f.inputs[i]의 미분값이 gxs[i]이기 때문이다.
■ 그래서 위와 같이 f.inputs의 원소와 gxs의 원소를 for 문과 zip 함수를 사용하여 대응시킨다. 그러면 각각에 알맞은 미분값이 설정될 것이다.
■ 예를 들어, \( z = x^2_0 + x_1 x_2 \)라는 함수에 대해 순전파와 역전파를 수행하면,
x_0 = Variable(np.array(1.0))
x_1 = Variable(np.array(2.0))
x_2 = Variable(np.array(3.0))
## 순전파
z = add(square(x_0), multiply(x_1, x_2))
print(z.data)
```#결과#```
7.0
````````````
## 역전파
z.backward() # 파이토치의 backward()처럼 backward()를 호출해서 기울기 계산
print(x_0.grad); print(x_1.grad); print(x_2.grad)
```#결과#```
2.0
3.0
2.0
````````````
- 역전파 결과를 보면, \( x_0 \)에 대한 (편)미분 결과는, \( 2x_0 \)이며, \( x_0 = 1 \)이므로 \( 2 \)가 된다.
- \( x_1 \)의 경우 \( x_2 \)만 남게 되며, \( x_2 = 3 \)이므로 \( 3 \)이 되며,
- \( x_2 \)의 경우 \( x_1 \)만 남게 되므로, \( x_1 = 2 \)이므로 \( 2 \)가 되는 것을 확인할 수 있다.
'딥러닝' 카테고리의 다른 글
미분 자동 계산 (4) (0) | 2025.03.14 |
---|---|
미분 자동 계산 (3) (0) | 2025.03.13 |
미분 자동 계산 (2) (0) | 2025.03.11 |
미분 자동 계산 (1) (0) | 2025.03.07 |
트랜스포머 (Transformer) (3) (0) | 2025.01.10 |