파이톨치

[밑바닥부터 시작하는 딥러닝] 오차역전파법 본문

AI&ML/밑바닥부터 시작하는 딥러닝

[밑바닥부터 시작하는 딥러닝] 오차역전파법

파이톨치 2022. 12. 31. 17:06
728x90

솔직히 어렵긴 합니다

저도 처음 이 내용을 배울 때 하나도 이해가 가지 않았습니다. 그래서 누군가에게 이를 이해하라고 말할 자신이 없습니다. 오차역전파법은 고등학생 때, 처음 미분을 배웠을 때와 비슷한 느낌이었습니다. 하지만, 미분과 마찬가지로 보다보니 결국은 어떤 내용인지 이해가 갔습니다. 이것도 마찬가지입니다. 처음에는 이해가 가지 않을 수도 있습니다. 하지만 천천히 반복적으로 보다보면 결국에는 이해가 갈 것입니다. 때문에 당장에 이해가 안되도 괜찮다는 말을 하고 시작하겠습니다. 

 

오차역전파법이 어렵다고 생각하는 이유는 우리가 지금까지 사용해보지 않는 계산체계를 사용하기 때문이라고 생각합니다. 여기서는 주로 계산 그래프를 사용하고, 이를 미분하는 형태로 내용을 전개합니다. 계산 그래프를 처음 접하는 상태에서 미분까지 하려고 하니 부담이 되기에 어렵다고 생각합니다. 하지만 말했다싶이 천천히 반복적으로 보다보면 이해가 갑니다. 쉬운 내용은 아니라 제대로 설명할 수 있도록 노력해보겠습니다. 

 

시작은 계산 그래프

계산 그래프는 노드와 엣지로 구성됩니다. 이산수학에서 다루는 그래프라고 하는 것은 노드와 엣지로 구성된 것을 말합니다. 우리가 생각하는 그 그래프와 컴퓨터 과학에서 다루는 그래프는 차이가 있을 수 있습니다. 그림을 보면 동그란 것을 노드라고 하고 선분을 엣지라고 부릅니다. 

 

그림 1. 계산 그래프 예시

우선 이 그래프를 이해해봅시다. 저자가 일본인이라 소비세를 곱해서 계산하는 것 같습니다. 여기에서 노드를 찾아보면 더하기 노드와 곱하기 노드가 있습니다. 두 노드는 입력 2개를 받아서 연산을 진행하고 결과를 다음 노드로 보내줍니다. 예를 들어서 처음에 나오는 곱하기 노드의 경우에는 "사과의 개수"와 "사과의 가격"을 입력으로 받아서 곱한 결과인 200을 다음 노드에 전해줍니다. 더하기 노드의 경우에는 뒷 노드의 출력을 입력으로 받아서 연산을 진행합니다. 화살표의 방향을 보면 알 수 있겠지만, 이는 왼쪽에서 오른쪽으로 진행합니다. 이를 순전파라고 부릅니다. 그렇다면 우리가 배울 내용인 역전파는 무엇일까요? 순전파와 반대 방향인 것은 추측할 수 있을 것입니다. 하지만 반대 방향으로 무엇이 전해지는 것인지는 잘 이해가 가지 않을 것입니다. 이는 뒤에서 자세하게 다루어보겠습니다. 

 

계산 그래프는 왜 쓰는 것일까?

내용은 알겠지만, 왜 사용하는 것인지에 대해 설득해보겠습니다. 우리가 앞에서 보았던 계산들은 매번 처음부터 계산해야 한다는 특징이 있었습니다. 하지만 이것을 사용하면 국소적인 계산만 하면 됩니다. 노드는 2개의 입력을 받았습니다. 이 때 우리가 관심있는 것은 그 2개의 입력이지 앞에서 어떤 연산이 일어났는지는 우리의 알바가 아닙니다.

 

그림 2. 국소적인 계산

국소적인 계산이 강력해지는 것은 역전파를 진행할 때 입니다. 궁금한 미분 값이 있을 때, 전체의 미분이 아닌 국소적인 미분만 하면 되기 때문에 수치미분보다 훨씬 빠른 속도로 이를 해결할 수 있습니다. 간단한 예시를 한번 보면 다음과 같습니다. 

만약 알고리즘을 배운 학생이라면, 처음부터 계산하는 것을 타파하기 위해 나온 알고리즘이 무엇인지 알 수 있을 것입니다. 바로 동적 프로그래밍입니다. 이 알고리즘의 특징은 이 전 계산을 기억해두었다가 현재 연산에서 필요한 값만 뽑아서 계산하는 것입니다. 만약 동적 프로그래밍을 안다면 그것과 사용하는 이유가 비슷하다고 생각하면 편합니다. 

 

그림 3. 역전파 예시

여기서 2.2가 의미하는 것은 사과 가격에 따른 지불 금액의 변화입니다. 여기서 가장 눈에 보이는 특징은 미분 결과를 공유한다는 것입니다. 사과 개수에 대한 지불 금액의 미분의 경우 1.1이라는 결과를 이용해 구할 수 있습니다. 때문에 많은 양의 미분을 효율적으로 할 수 있다는 이야기입니다. 

 

곱하기 노드와 더하기 노드의 역전파

여기부터가 고비입니다. 이는 편미분의 개념을 안다면 편미분으로 이해하면 편합니다. 순전파와 마찬가지로 역전파도 국소적인 계산을 진행합니다. 때문에 앞에서 어떤 미분이 일어났는지는 별로 관심이 없고 바로 앞에서 일어난 미분 값만 활용해서 여기에 현재 미분 값을 곱해서 넘겨주면 됩니다. 

 

그림 4. 곱하기 노드의 역전파

결국은 이해하기 쉽게 쓰면 z = x * y의 형태입니다. 이를 x에 대해서 미분하면 dz / dx = y 입니다. 때문에 이를 이용해서 생각하면 앞의 결과에서 y를 곱한 것이 x의 미분값이 됩니다. 예시를 한번 볼까요?

 

그림 5. 곱하기 노드 역전파 예시

왼쪽은 순전파이고 오른쪽은 역전파입니다. 모두 국소적인 계산만 이루어 지고 있습니다. 1.3이라는 값이 앞에서 주어진 미분 결과이고 이를 각각 5와 10을 곱해서 넘겨주고 있습니다. 이때 순방향 입력 신호의 값이 필요하기 때문에 곱셈 노드를 구현할 때는 이를 변수에 저장해주어야 합니다. 

 

더하기 노드의 경우 더 간단합니다. 왜냐하면 더하기는 미분하면 상관 없는 변수는 없어지기 때문에 값을 그대로 넘겨주면 됩니다. 

 

그림 6. 더하기 노드 역전파

곰셈 노드보다 덧셈 노드가 구현하기도 훨씬 쉽습니다. 한번 둘 다 구현을 해봅시다. 우선은 곱셈 계층을 봅시다. 다음과 같은 코드가 됩니다. 

class MulLayer:
  def __init__(self):
    self.x = None
    self.y = None
  
  def forward(self, x, y):
    self.x = x
    self.y = y
    out = x * y
    return out 
  
  def backward(self, dout):
    dx = dout * self.y
    dy = dout * self.x
    return dx, dy

예제를 테스트 하는 코드는 다음과 같습니다. 주목해야할 점은 미분 값의 결과를 저장해서 사용한다는 점 입니다. 여기부터 클래스를 만들어서 층을 쌓아서 진행하기에 더욱 재밌습니다.

# 순전파
apple = 100
apple_num = 2
tax = 1.1

mul_apple_layer = MulLayer()
mul_tax_layer = MulLayer()

apple_price = mul_apple_layer.forward(apple, apple_num)
price = mul_tax_layer.forward(apple_price, tax)
print(price)

# 역전파
dprice = 1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)
print(apple, dapple_num, dtax)

덧셈노드는 다음과 같습니다. 

class AddLayer:
  def __init__(self):
    pass
  
  def forward(self, x, y):
    out = x * y
    return out
  
  def backward(self, dout):
    dx = dout * 1 
    dy = dout * 1 
    return dx, dy

역전파를 할 때, 덧셈노드는 값을 기억할 필요가 없습니다. 받아온 dout을 그대로 넘겨주기만 하면 됩니다. 

 

Affine 계층

퍼셉트론을 할 때, 우리는 입력에 가중치를 곱하고 편향을 더했습니다. Affine계층은 이 과정을 하는 계층입니다. 이 때, 곱하고 더하는 과정이 행렬 단위로 일어난다는 점이 특징입니다. 여기부터 조금 어렵지만 정신을 붙잡고 이해해야 합니다.

그림 7. Affine 계층

우리가 주목해서 봐야하는 것은 행렬의 차원입니다. 입력으로 들어간 X의 경우 (1, 2)의 차원이지만 여기서는 이를 (2,) 으로 표현했습니다. 이게 헷갈리기 때문에 조심해야 합니다. (아마 벡터라서 저렇게 특별하게 표현한 것이라 추측합니다.) 행렬의 곱은 (A, B) * (B, C) = (A, C) 로 이루어지는 것을 알아야 합니다. 여기서 (1, 2) * (2, 3) 을 곱해서 (1, 3)이라는 결과가 나온 것입니다. 여기에 같은 차원을 가진 벡터 편향을 더해서 Affine 계층의 순전파가 완성됩니다. 그렇다면 역전파는 어떨까요?

그림 8. Affine 계층의 역전파

 

곱하기 노드의 역전파를 생각하면 그리 어렵지 않습니다. 곱하기 노드는 다른쪽 입력을 곱해서 넘겨주었습니다. 이도 비슷하지만 여기서는 차원을 맞추어 주어야 하기 때문에 전치행렬을 곱합니다. 행렬의 미분은 메인 내용이 아니기 때문에 이 정도만 언급하고 넘어가겠습니다. 역전파는 위와 같은 수식으로 진행이 됩니다. 이해가 가지 않으면 아래 코드를 보면 이해하실 수 있습니다. 

 

class Affine:
  def __init__(self, W, b):
    self.W = W
    self.b = b
    self.x = None
    self.dW = None
    self.db = None
  
  def forward(self, x):
    self.x = x
    out = np.dot(x, self.W) + self.b

    return out
  
  def backward(self, dout):
    dx = np.dot(dout, self.W.T)
    self.dW = np.dot(self.x.T, dout)
    self.db = np.sum(dout, axis=0)
    
    return dx

init 함수 안에 있는 변수들은 활용해야 되는 값이기 때문에 변수로 만들어준 것입니다. 

 

Sigmoid 계층과 ReLU 계층

지난 번에 Sigmoid 함수가 기울기 소실을 일으킬 수 있다고 하였는데 여기서 그 이유를 알 수 있습니다. 시그모이드 계층의 역전파를 보면 이를 알 수 있습니다. 하지만 과정이 어려우니 잘 따라와야 합니다. 사실 과정을 몰라도 괜찮습니다만, 알아두면 좋으니까요.

 

그림 9. Sigmoid 역전파

고등학생 때 배운 미분과 비슷한데 특징은 속미분 같은 것은 고려하지 않습니다. exp 노드의 역전파를 보면 exp(-x)를 곱해서 넘겨주는데 속미분까지 생각하면 원래 -가 붙어야 합니다만 붙지 않습니다.

 

이를 고려하지 않는 이유는 뒤에 있는 곱하기 노드에서 이 역할을 하기 때문에 단순히 출력을 곱해서 넘겨준니다. 또 특징적인 노드는 /인데 이는 고등학생 때 배운 것처럼 -1 / x**2 입니다. 노드들을 모두 지나면 결국 y**2 * exp(-x)가 됩니다. 이를 정리하면, y * (1-y)가 됩니다. (y = 1/(1+exp(-x))니까요.)

 

결국에는 앞의 역전파의 출력에 y * (1-y)를 해주는 꼴이 됩니다. y가 1보다 작은 경우에, 이는 원래 값보다 작은 값이 되어 값이 점점 작아집니다. 때문에 기울기 소실이라는 문제가 생기는 것입니다. 코드로 보면 다음과 같습니다.

class Sigmoid:
  def __init__(self):
    self.out = None
  
  def forward(self, x):
    out = 1 / (1+np.exp(-x))
    self.out = out
    return out
  
  def backward(self, dout):
    dx = dout * (1.0 - self.out) * self.out
    return dx

 

ReLU의 경우는 어떨까요?

 

그림 10. ReLU의 역전파

이 그림을 보면 0보다 큰 경우에는 그대로 값을 넘겨주고 0보다 작은 경우에는 0을 넘겨줍니다. 이렇게 설계하면 1보다 작은 값이 들어와도 값이 작아지지 않고 그대로 넘겨줄 수 있게 됩니다. 구현 또한 간단합니다.

 

class Relu:
  def __init__(self):
    self.mask = None
  
  def forward(self, x):
    self.mask = (x<=0)
    out = x.copy()
    out[self.mask] = 0
    return out
  
  def backward(self, dout):
    dout[self.mask] = 0
    dx = dout
    return dx

마지막으로 소프트맥스도 있는데 이는 복잡해서 구현만 봅시다 😋

class SoftmaxWithLoss:
  def __init__(self):
    self.loss = None
    self.y = None
    self.t = None
  
  def forward(self, x, t):
    self.t = t 
    self.y = softmax(x)
    self.loss = cross_entropy_error(self.y, self.t)
    return self.loss
  
  def backward(self, dout=1):
    batch_size = self.t.shape[0]
    dx = (self.y - self.t) / batch_size
    return dx
728x90