본 포스팅은 밑바닥부터 시작하는 딥러닝 3 책의 내용을 공부한 후 개인적으로 중요하다고 생각되는 내용들을 정리하는 것이 목적입니다.
해당 책은 큰 맥락을 '고지'로 나누고 있으며 총 5고지가 있으며 각 고지의 세부 내용을 'step'으로 분리해서 학습합니다.
1개의 고지를 학습하고 포스팅 할 경우 내용이 너무 길어져 가독성 측면을 고려해 각 고지의 절반에 해당하는 내용들을 하나의 포스팅에서 다룰 예정입니다. 혹시 틀린 내용이 있다면 피드백은 항상 환영입니다🤗
저번 포스팅까지의 다룬 방법에서는 약간의 제약성이 존재했는데 바로 입력 또는 출력 변수가 여러 개가 되는 경우에는 대응하지 못한다는 점이다. 이번 포스팅에서는 가변적인 길이의 입력, 출력 변수에도 대응이 가능하고 더 나아가 일직선 계산 그래프가 아닌 분기, 결합이 추가된 복잡한 계산 그래프 형태에 대해서도 미분을 자동적으로 계산할 수 있도록 기존에 배운 코드를 확장해 보도록 하자.
Step11. 가변 길이 인수(순전파 편)
먼저 순전파를 수행할 때, 입력 개수가 여러개일 경우에도 대응할 수 있도록 코드를 확장시켜 보자.
함수(연산) 종류에 따라 필요한 인수 개수가 달라지는데 인수가 1개만 필요한 대표적인 함수는 제곱 함수이다.
그리고 인수가 2개가 필요한 대표적인 함수로는 덧셈, 뺄셈, 곱셉, 나눗셈 등이 있고 아래와 같은 경우이다.
반대로 출력 개수가 여러개일 경우도 있는데, 아래의 사진처럼 분기되는 경우가 있을 수 있다.
위와 같은 경우들에 대응하기 위해서 우리는 기존의 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 클래스를 상속받아 덧셈 기능을 하는 Add 클래스를 구현해보자.
class Add(Function):
def forward(self, xs):
x0, x1 = xs
y = x0 + x1
return (y,)
def add(xs):
return Add()(xs)
xs = [Variable(np.array(2)), Variable(np.array(3))]
ys = add(xs)
y = ys[0]
print(y.data)
# 출력결과
5
Step13. 가변 길이 인수(역전파 편)
다음은 역전파를 수행할 때의 가변 길이 인수를 처리하는 방법이다. 역전파를 처리할 때는 우리가 만들어왔던 Variable 클래스를 수정해야 한다. 아래의 코드를 통해 살펴보도록 하자.
class Variable():
def __init__(self, data):
if data is not None:
if not isinstance(data, np.ndarray):
raise TypeError(f"{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()
# 추가된 부분 (시작) #
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):
x.grad = gx
if x.creator is not None:
funcs.append(x.creator)
if x.creator is not None:
수정된 부분은 크게 세 가지인데, 첫 번째는 출력이 여러 개이기 때문에 list comprehension을 통해서 각 출력의 기울기 값을 리스트에 모은다. 두 번째는 가져온 창조자 함수의 역전파를 계산하는 backward 메서드를 수행한다.
이때, 순전파 시 했던 것과 똑같이 언패킹(*)을 활용해 가변 길이 인수를 처리할 수 있도록 만든다.
그리고 역전파 결과값이 단일 값일 경우가 있을 수도 있기 때문에 그럴 경우 튜플 형태로 바꾸어주는 로직도 추가하였다.
마지막 세 번째는 두 번째 단계로 얻었던 역전파로 계산된 기울기 값을 각 입력 변수에 맞게 세팅을 해주어야 한다.
그래서 zip 함수를 활용해서 (입력변수 <-> 그 입력변수의 기울기값)에 대응되도록 기울기 값을 갱신해준다.
Step14. 같은 변수 반복 사용
여태껏 구현한 코드의 문제점을 살펴보면 다음과 같다.
동일한 변수를 사용해서 덧셈을 수행하면 제대로 미분을 하지 못한다는 점이다.
개선된 Variable 클래스에서 역잔파 후 입력 변수의 기울기 값을 업데이트해줄 때 작성한 코드 때문에 발생하는 문제이다.
해당 문제를 해결하는 코드를 구현해 보자.
class Variable():
def backward(self):
if self.grad is None:
self.grad = np.ones_like(self.data)
funcs = [self.creator]
while funcs:
... (생략)
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:
funcs.append(x.creator)
최초에 해당 입력변수의 기울기 값을 먼저 확인한 뒤 그 기울기 값이 None이라는 것은 아직 기울기가 한 번도 갱신되지 않았다는 것을 의미한다. 따라서 이런 경우에는 기울기 값으로 바로 설정해 준다. 하지만 그렇지 않다면 이미 갱신되어 있는 기울기 값에다가 더해주는 방식으로 구현한다.
Step15. 복잡한 계산 그래프 (이론 편)
지금까지 우리는 일직선 형태로 되어 있는 계산 그래프에 대해서만 미분 계산을 자동화시켜왔다.
하지만 대부분의 딥러닝 모델 계산 그래프는 위처럼 간단하지 않다. 적어도 아래처럼 분기, 결합과 같은 요소들이 여러 개가 추가된 계산 그래프의 미분 계산을 자동화시켜야 한다.
그러면 이제 본격적으로 복잡한 계산 그래프의 미분 계산을 자동화하는 핵심에 대해 알아보도록 하자.
먼저 아래와 같은 계산 그래프의 역전파를 구현하려면 어떤 순서로 미분을 계산해야 하는지 살펴보자.
가장 주목해야 할 부분은 변수 a에 대한 미분을 계산할 때이다.
순전 파시에 a 변수의 출력으로 두 갈래로 분기되어 나오는 것을 볼 수 있다. 따라서 역전파 시에도 두 갈래로 분기된 곳으로부터 미분값 두 개를 받아와서 더해주어야 한다. 결국 역전파시 미분을 계산하는 순서를 나타내면 아래와 같다.
위 순서를 봤을 때, a 변수에 대한 미분값을 구하기 위해서는 함수 B, C에 대한 미분이 모두 수행된 후에 가능하다.
그런데 우리가 지금까지 구현했던 미분 계산 코드는 이런 순서를 구현해내지 못한다. 왜냐하면 우리의 미분 계산 코드를 적용하게 되면 역전파 시 미분 계산 순서가 아래처럼 바뀌기 때문이다.
마지막 그림을 보면 미분 계산이 2번으로 중복되어 이루어지기 때문에 결국 기울기 값에서 오차가 발생하게 된다.
그러면 우리는 어떤 방법으로 위와 같은 문제를 해결할 수 있을까?
우리가 사용할 방법으로는 이전 포스팅의 내용을 잘 떠올려 본다면 유추할 수 있을 것이다.
우리는 변수와 함수 간의 관계를 살펴보았고 함수(부모) <-> 변수(자식)이라는 관계가 형성된다는 것을 살펴보았다.
바로 이를 통해 함수와 변수의 '세대(generation)'을 기록해 함수와 변수에 순위를 부여할 수 있다는 것이다.
위 그림에서 기억해야 할 부분은 바로 입력변수와 그 입력변수를 넣는 함수에 같은 세대를 부여한다는 점이다.
이렇게 세대를 부여하게 되면 역전파 시 세대수가 큰 쪽부터 미분 계산을 처리할 수 있게 되고 결국 복잡한 계산 그래프의 역전파 미분 계산도 처리가 가능해진다. 그러면 이제 코드로 살펴보도록 하자!
Step16. 복잡한 계산 그래프 (구현 편)
가장 먼저 해야 할 부분은 함수와 변수에 일종의 순위로 '세대' 값을 부여해야 한다.
따라서 Variable 클래스에 인스턴스 변수로서 세대를 의미하는 generation 값을 0으로 초기화시키도록 하자.
class Variable():
... (생략)
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 # 부모의 세대에 + 1
위에서 입력 변수와 그 입력 변수가 들어가는 함수는 같은 세대여야 한다고 했다.
이 말은 곧 입력변수가 들어가는 함수와 함수가 만들어낸 출력 변수의 세대값은 1세대 차이라는 것을 알 수 있다.
따라서 set_creator 메서드에 해당 함수의 세대값에 + 1을 해주도록 하고, 이 set_creator 메서드는 아래의 Function 클래스의 __call__ 매직 메서드 안에서 각 출력변수마다 적용해 준다.
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.inputs = inputs
self.outputs = outputs
# 특정 함수의 세대값은 그 함수의 입력 변수들의 가장 큰 세대수로 설정
self.generation = max([x.generation for x in inputs])
# 특정 함수가 내뱉은 출력변수의 세대값은 출력 변수의 창조자 함수의 세대수 + 1로 설정
for output in outputs:
output.set_creator(self)
return outputs if len(outputs) > 1 else outputs[0]
이제 순전파를 수행할 때, 변수와 함수 간에 세대를 부여하도록 했다. 그러면 이제 역전파를 수행할 때, 미리 기록해 놓았던 세대를 기준으로 큰 세대부터 꺼내서 역전파를 수행시켜 보도록 하자. 일단 지금까지의 변수와 함수에 세대를 부여한 계산 그래프를 그림으로 확인하면 다음과 같다.
그러면 큰 세대의 변수와 함수부터 꺼내도록 하기 위해서는 어떤 자료구조를 활용할 수 있을까?
가장 먼저 파이썬의 sort 메서드를 활용하는데, 그때 key 인자에 세대값을 부여하여 세대값 기준으로 매번 정렬되도록 한다.
다음의 코드를 통해 알아보도록 하자.
class Variable():
...(생략)
def backward(self):
if self.grad is None:
self.grad = np.ones_like(self.data)
funcs = []
seen_sets = set()
def add_func(f):
if f not in seen_sets:
funcs.append(f)
seen_sets.add(f)
funcs.sort(key=lambda x : x.generation) # 해당 함수의 세대가 큰 순서대로 정렬
add_func(self.creator)
위 코드에서 크게 수정된 부분은 add_func()이라는 중첩함수를 추가한 부분이다.
해당 함수는 창조자 함수 리스트를 세대 순으로 정렬하는 역할을 한다. 단 seen_sets이라는 집합 자료구조를 활용해서 동일한 함수가 복수로 추가되는 일을 막도록 했다.
📍 중첩함수를 정의하기 위한 2가지 상황으로는 첫 째, 감싸는 메서드 안에서만 이용한다는 점과 둘째, 감싸는 메서드에 정의된 변수를 사용해야만 한다는 점이다.
이번 포스팅까지의 내용을 통해서 우리는 복잡한 미분 계산도 자동으로 할 수 있도록 만들어 주었다. 다음 포스팅은 메모리 사용량에 대해 개선해 보고 파이썬 패키지로 묶는 것까지 포스팅에서 다뤄보도록 하겠다!
'AI Development > PyTorch' 카테고리의 다른 글
밑바닥부터 시작하는 딥러닝3 - Dezero의 도전(3) (0) | 2024.02.23 |
---|---|
밑바닥부터 시작하는 딥러닝3 - Dezero의 도전(2) (1) | 2024.02.23 |
밑바닥부터 시작하는 딥러닝3 - Dezero의 도전(1) (0) | 2024.02.22 |
밑바닥부터 시작하는 딥러닝 3 - 미분 자동 계산(2) (0) | 2024.02.01 |
밑바닥부터 시작하는 딥러닝3 - 미분 자동 계산(1) (0) | 2024.01.30 |