들어가며

이 포스팅에서는 딥러닝의 오차역전파법(backpropergation)을 다룹니다. 해당 포스팅의 내용은 <<밑바닥부터 시작하는 딥러닝>> 5장의 ‘오차역전파법’의 내용을 요약한 것입니다.

오차역전파법

신경망 학습에서 weight는 다음과 같은 프로세스를 통해 변화합니다.

  1. 결과값을 손실함수로 변환한다.
  2. 손실함수의 기울기를 수치 미분을 통해 구한다.
  3. 기울기가 0이 되는 지점까지 weight를 변화시킨다.

이 방법은 수치 미분을 통하여 기울기를 구하는데, 이는 단순하고 구현하기는 쉽지만 계산 시간이 오래 걸린다는 단점이 있습니다. 따라서, 가중치 매개변수의 기울기를 가장 효율적으로 계산할 수 있는 오차역전파법(backpropagation) 을 정리할 예정입니다.

계산 그레프

오차역전파법을 수식 말고 그래프를 통해서 표현할 생각인데, 그걸 나타내기 위해서 계산 그래프에 대해 간단하게 설명할 것입니다. 계산 그래프는 계산 과정을 그래프로 나타낸 자료구조로, 복수의 노드와 에지로 표현됩니다.

계산 그래프 테스트

간단한 문제가 주어졌습니다. 그래프를 통해 풀어보겠습니다.

현빈 군은 슈퍼에서 1개에 100원인 사과를 2개 샀습니다. 이때 지불금액을 구하시오, 단 소비세가 10% 부과됩니다.

graph LR;
    사과-->|100|x2;
    x2-->|200|x1.1;
    x1.1-->|220|금액

최초에 사과를 두 개 사고, 소비세를 곱한 값이 답이 됩니다.(220원). 위의 그래프에서는 x2와 x1.1을 각각 하나의 연산으로 취급했지만, 곱하기만을 연산으로 취급할수도 있습니다. 이렇게 하면 곱한 값 또한 변수가 되어 원 밖에 위치하게 됩니다.

graph LR;
    사과-->|100|x;
    개수-->|2|x
    x-->y
    소비세-->|1.1|y;
    y-->|220|결과;

위와 같이 식을 구성할 수 있습니다.

위의 예제를 이용한 문제풀이는 다음 흐름으로 전개됩니다.

  1. 계산 그래프를 구성한다.
  2. 그래프에서 계산을 왼쪽에서 오른쪽으로 진행한다.

여기서 2번, ‘계산을 왼쪽에서 오른쪽으로 진행하는 것’을 순전파라고 합니다. 이 포스팅에서 다룰 역전파는, 반대로(오른쪽에서 왼쪽으로) 계산을 진행합니다.

국소적 계산

계산 그래프에서는 모든 계산은 국소적입니다. 즉, 위의 그래프에서 한 노드가 해야 할 일은 입력값을 받아, 해당 연산(곱하기)를 수행하고 다음 노드로 계산 결과를 넘겨주는 일 밖에 없습니다. 노드는 자신이 관련한 계산 외에는 아무것도 신경 쓸 게 없습니다.

왜 계산 그래프로 푸는가?

계산 그래프는 다음과 같은 이점이 있습니다.

  1. 국소적 계산
  • 전체가 아무리 복잡해도, 각 노드에서는 단순한 계산에 집중하여 문제를 풀 수 있습니다.
  1. 중간 계산 결과를 모두 보관할 수 있습니다.
  • 중간에 계산한 값을 저장할 수 있습니다.
  1. 계산 그래프를 사용하면, 역전파를 통해 ‘미분’을 효율적으로 계산할 수 있습니다.

위에서 계산 그래프로 해결한 문제는, 사과 두 개를 사서 소비세를 포함한 최종 금액을 구하는 것입니다. 여기서 사과 가격이 오르면 최종 금액에 어떤 영향을 미치는지 알고 싶다고 합시다. 이는 ‘사과 가격에 대한 지불 금액의 미분’ 이고, 사과 가격이 아주 조금 올랐을 때 지불 금액에 얼마나 증가하는지 알려는 것입니다.

그래프로 표현하면 다음과 같은 방식으로 구할 수 있습니다.

graph LR;
    사과-->|100|x;
    개수-->|2|x
    x-->y;
    소비세-->|1.1|y;
    y-->|220|결과;
    결과-->|1|y;
    y-->|1.1|x
    x-->|2.2|사과;

이 그래프에서 결과->y->x->사과로 흘러가는 배수가, 역전파를 통해 구한 미분 값입니다. 즉, 1 -> 1.1 -> 2.2 순으로 미분 값을 전달하였고, 최종적으로 사과 가격에 대한 지불 금액의 미분은 2.2 라고 할 수 있습니다. 이 말은 사과 값이 아주 조금 오르면, 최종 금액은 그 값의 2.2 배만큼 오른다는 뜻입니다.

연쇄법칙

순전파는 계산 결과를 왼쪽에서 오른쪽으로 전달했지만, 역전파는 반대로 오른쪽에서 왼쪽으로 결과값을 전달합니다. 이 미분값을 전달하는 원리는 연쇄법칙에 따른 것입니다.

계산 그래프의 역전파

graph LR;
    인풋 -->|x|B["F(x)"]
    B["F(x)"] --> |y|결과
    결과 --> |E|B["F(x)"]
    B["F(x)"] --> |Ey/x|인풋

y = f(x)라는 함수의 역전파를 그래프로 그려보았습니다. 역전파의 계산 절차는 다음과 같습니다.

  1. 국소적 미분 (y/x)을 구한다. (y=X^2이라면, 2x를 구한다.)
  2. 입력받은 값 E를 곱한다. (2x * E)
  3. 이 값을 상류로 전달한다.

연쇄법칙이란

연쇄법칙을 이야기하려면 먼저 합성 함수를 설명해야 합니다. 합성 함수란 여러 함수로 구성된 함수입니다. 예를 들면, z = (x + y)^2은 다음과 같은 두 개의 식으로 분리할 수 있습니다.

z = t^2
t = x + y

연쇄법칙은 합성 함수의 미분에 대한 성질로, 다음과 같이 정의할 수 있습니다.

합성 함수의 미분은 합성 함수를 구성하는 각 함수의 미분의 곱으로 나타낼 수 있습니다.

자세한 증명은 넘어가도록 하겠습니다. 그럼 이 법칙을 써서 위의 식의 미분값을 구해보도록 하겠습니다.

t^2을 미분하면 2t이고, t를 미분하면 1입니다. 그래서, 최종적으로 z를 미분한 값은 2t * 1 = 2t입니다.

연쇄법칙과 계산 그래프

연쇄법칙 계산을 계산 그래프로 나타내 보겠습니다. 2제곱 계산을 **2 노드로 나타내면, 다음과 같이 표현할 수 있습니다.

graph LR;
    input1 -->|x|+
    input2 -->|y|+
    + --> |t|**2

이렇게 생긴 그래프에서, 반대 방향으로 그 노드의 편미분을 수행한 값 * 위에서 흘러 내려온 값을 곱하면 됩니다. 즉, 역전파를 수행한 결과는 다음과 같습니다.

graph LR;
    input1 -->|x|+
    input2 -->|y|+
    + -->|t|**2
    **2 -->|1|+
    + -->|2xy*1|input1

역전파

그러면, +와 *의 예를 들어 역전파의 구조를 설명해 보겠습니다.

덧셈 노드의 역전파

z = x + y 라는 식을 대상으로, 역전파를 살펴보겠습니다. 그래프를 그리면 다음과 같은 모양이 될 것입니다.

graph LR;
    input1 -->|x|+
    input2 -->|y|+
    + -->|z|output

x + y 의 식에 대해, input1로 가는 노드는 x를 기준으로 편미분을 하므로, y가 사라지면서 1이 남게 됩니다. input2로 가는 노드는 y 를 기준으로 편미분을 하므로, x가 사라지면서 1이 남게 된다. 따라서, + 노드를 대상으로 하는 역전파는 결과적으로 입력받은 데이터를 그대로 흘려주게 됩니다.

곱셈 노드의 역전파

z = xy라는 식을 생각해보자. 이 식의 미분은 다음과 같습니다.

  • x로 편미분 : y
  • y로 편미분 : x

해석학적으로 다음과 같이 미분이 되므로, 그래프로 보았을 때는 이웃 노드의 데이터 * 위에서 흘러내려온 데이터가 내려감을 알 수 있습니다. 간단한 예제로 그래프를 그려보면 다음과 같습니다.

graph LR;
    input1 -->|10|*
    input2 -->|5|*
    * -->|50|output

이런 그래프가 있고, 역전파 시 상류에서 1.3이라는 값이 흘러내려온다고 가정하면, 역전파 그래프는 다음과 같이 나오게 됩니다.

graph LR;
    input1 -->|10|*
    input2 -->|5|*
    * -->|50|output
    * -->|6.5|input1
    output -->|1.3|*

즉, 곱하기 노드에서는 순전파 때의 입력 신호들에 ‘서로 바꾼 값’을 곱하여 하류로 보내게 됩니다.

단순한 계층 구현하기

이번 장에서는 ‘사과 쇼핑’예를 파이썬으로 구현합니다. 계산 그래프의 곱셈 노드는 ‘MulLayer’, 덧셈 노드는 ‘AddLayer’라는 이름으로 구현합니다.

모든 계층은 forward()와 backward()라는 공통의 메서드를 갖도록 구현할 것입니다. 해당 메서드는 인터페이스 형태로 구현할 것입니다. forward는 순전파, backward는 역전파를 처리합니다. 그럼 먼저 곱셈 계층을 클래스로 구현해 보겠습니다.

class MulLayer:
    def __init__(self):
        self.x = None
        self.y = None
 
    def forward(self, x, y):
        self.x = x
        self.y = y
        return x * y
    def backward(self, dout):
        dx = dout * self.y # x와 y를 변경한다.
        dy = dout * self.x
 
        return dx, dy
  1. init 에서는 인스턴스 변수인 x,y를 초기화한다.
  2. forward에서는 x,y를 인수로 받아서 두 값을 곱해서 반환한다.
  3. backward에서는 입력받은 미분값(dout) 에 순전파의 값을 바꿔서 곱한다.

이상의 MulLayer의 구현이고, 이를 이용하여 앞에서 본 사과 쇼핑을 구현할 수 있습니다. 그래프를 다시 한번 확인해 보겠습니다.

graph LR;
    사과-->|100|x;
    개수-->|2|x
    x-->y;
    소비세-->|1.1|y;
    y-->|220|결과;

우선 순전파를 구현해 보겠습니다.

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) # 220

각 변수에 대한 미분은 backward() 에서 구할 수 있습니다.

# 역전파
dprice = 1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_tax_layer.backward(dapple_price)
 
print(dapple, dapple_num, dtax) # 2.2, 110, 200

활성화 함수 계층 구현하기

여기서는 신경망을 구성하는 층(계층)을 각각의 클래스 하나로 구현하게 됩니다. 우선, 활성화 함수인 ReLU와 Sigmoid 계층을 구현하겠습니다.

ReLU 계층

활성화 함수로 사용하는 ReLU계층은 다음과 같이 나타낼 수 있습니다.

y = x (x > 0)
   = 0 ( x<= 0)

위의 식을 x에 대해 미분하면

  1. x > 0 : 1
  2. x<= 0 : 0

순전파 때의 값이 0보다 크면 1 * 상류에서 흘러온 값을 내려주기 때문에, 상류에서 준 값을 그대로 흘러내려주게 됩니다. 0보다 작으면 0을 하류로 보내므로, 하류에 신호를 보내지 않게 됩니다.

Sigmoid 계층

시그모이드 계층은 다음 식을 의미합니다.

y = 1 / 1 + exp(-x)

이 식을 그래프로 그린 후에, 미분 연산을 수행하면, 결국 L/y _ y^2 _ exp(-x) 를 아래 노드로 흘려주는 방식이 됩니다. 위의 식에서 L/y는 입력값을 y에 대해서 편미분한 값입니다. 즉, 입력값 x와 출력값 y만 있다면 시그모이드 계층의 역전파는 계산할 수 있습니다.

또한 위 식을 정리하면, L/y _ y _ (1-y)로 변경할 수 있어서, 역전파값은 순전파의 출력 y만으로도 계산할 수 있습니다.

Affine/Softmax 계층 구현하기

신경망의 순전파에는 가중치 신호의 총합을 계산하기 때문에, 행렬의 내적(넘파이에서는 np.dot())을 사용합니다. 이 연산을 수행하려면, 행렬의 내적 계산을 수행하려면 두 행렬의 차원이 같아야 합니다. 이 때, 행렬의 내적을 어파인 변환이라고 부릅니다.

신경망에서 하나의 레이어를 통과할 때는 입력값과 가중치의 내적 + 편향값을 계산하게 되는데, 이 방식을 역전파로 구현해 볼 것입니다. 먼저, 순전파 구현은 다음과 같습니다.

X = np.random.rand(2) # 입력
W = np.random.rand(2,3) # 가중치
B = np.random.rand(3) # 편향
 
Y = np.dot(X, W) + B

이 때, X, W를 어파인 연산을 하려면 두 행렬의 차원이 같아야 합니다. X의 차원은 2고, W의 차원은 2이기 때문에 어파인 연산이 가능합니다. 위의 연산을 계산 그래프로 그리면 다음과 같이 나타낼 수 있습니다.

graph LR;
    input1 -->|X|dot
    input2 -->|W|dot
    dot -->|X*W|+
    + -->|Y|output

위에서 본 식과 다른 것은, 계산 그래프에서 흐르는 값이 행렬이라는 점입니다. 행렬의 역전파를 전계해보면 다음과 같이 나타낼 수 있습니다.

dL/dX = dL/dY * W

dL/dW = X^t * dL/dY

위 식에서 W^t는 W의 원소 위치를 (i,j) -> (j, i)로 변경한 전치행렬을 의미합니다.

Softmax-with-Loss 계층

마지막으로, 출력층에서 사용하는 소프트맥스 함수에 관해 이야기하겠습니다. 소프트맥스 함수는 입력 값을 정규화하여 출력합니다. 소프트맥스 계층은 출력값을 확률로 표현해 주는데, 그 총합은 1이 됩니다. 또한 클래스 분류 만큼의 입력/출력 개수를 갖게 됩니다. 예를 들면, MNIST에서는 0-9까지의 숫자를 분류하기 때문에 소프트맥스 함수는 10개의 입력값과 10개의 출력값을 갖게 됩니다.

손실 함수로는 교차 엔트로피 함수를 사용하는데, 교차 엔트로피 함수를 사용하면 역전파가 깔끔하게 떨어지게 됩니다. 교차 엔트로피 함수는 이렇게 말끔한 결과를 출력하게 설계되었습니다. 구현 코드는 다음과 같습니다.

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

결론

위에서 나온 내용을 모두 조합하면, 오차역전파법을 이용한 신경망 구축을 진행할 수 있습니다. 신경망 프로세스는 다음과 같습니다.

  1. 미니배치
  • 훈련 데이터 중 일부를 무작위로 가져옵니다.
  1. 기울기 산출
  • 가중치 매개변수의 기울기를 구합니다. 기울기는 손실 함수의 값을 가장 적게 하는 방향을 제시합니다.
  1. 매개변수 갱신
  • 가중치 매개변수를 기울기 방향으로 아주 조금 갱신합니다.
  1. 반복
  • 1~3단계를 반복합니다.

이 과정에서, 역전파법은 기울기 산출에 사용할 수 있습니다. 원래는 수치 미분을 사용하여 기울기를 구하지만, 수치 미분은 구현하기는 쉽지만 계산이 오래 걸립니다. 따라서 역전파법을 사용하면 구현은 복잡해지지만, 기울기를 더 빠르게 구할 수 있습니다.