1장: 실수치 회로 – 해커가 알려주는 뉴럴 네트워크

이 글은 OpenAI 안드레이 카패시(Andrej Karpathy)가 쓴 ‘Hacker’s guide to Neural Networks‘를 원저자의 동의하에 번역한 것입니다. 이 글에 포함된 코드의 파이썬 버전은 여기에서 보실 수 있습니다.

안녕하세요. 저는 스탠포드 대학 컴퓨터 사이언스 학과의 PhD 학생입니다. 저는 지난 몇년간 딥 러닝 분야를 연구해왔으며 개인적인 프로젝트 중에는 자바스크립트로 뉴럴 네트워크를 구현한  ConvNetJS가 있습니다. 자바스크립트는 무슨 일이 일어나고 있는지 시각적으로 보여주기 좋으며 하이퍼파라메타를 바꿔가며 이리저리 가지고 놀기에 좋습니다. 하지만 종종 사람들로부터 이 분야에 대해 좀 더 자세한 내용을 다뤄 달라고 요청을 받습니다. 그래서 부족하지만 이 글을 쓰기 시작합니다(아마도 몇개의 장으로 천천히 늘려 나갈 예정입니다). 다른 책들도 모두 그랬으면 좋겠지만 가끔 애니메이션이나 데모가 들어가야 하기 때문에 이 글을 PDF 대신에 웹에 올려 놓습니다.

뉴럴 네트워크에 대한 개인적인 경험을 비추어 볼 때 페이지 가득한 복잡한 역전파 알고리즘 방정식을 무시하고 직접 코드를 작성해 보았을 때 모든 것이 훨씬 명확하게 이해가 되었습니다. 그래서 이 튜토리얼에도 수학은 매우 조금 포함시킬 것 입니다(수학이 필수적이라고 생각하지 않으며 가끔은 간단한 컨셉을 어렵게 만들기도 합니다). 제 이력은 컴퓨터 과학과 물리학이기 때문에 소위 해커의 관점으로 뉴럴 네트워크에 대해 설명해 보겠습니다. 제 설명은 수학 방정식 보다는 프로그램 코드와 물리적인 직관에 중점을 둘 것입니다. 요컨대 제가 뉴럴 네트워크를 처음 배웠을 때 알았더라면 좋았을 방식으로 알고리즘을 풀어가 보겠습니다.

“..직접 코드를 작성해 보았을 때 모든 것이 훨씬 명확하게 이해가 되었습니다.”

어쩌면 여러분은 바로 뉴럴 네트워크와 역전파 알고리즘에 대해 배우고 싶거나 실제로 어떻게 데이터셋에 적용하는지 알고 싶을지 모르겠습니다. 하지만 그 전에 모든 것을 잠시 잊었으면 좋겠습니다. 그리고 한걸음 뒤로 물러나서 알고리즘의 핵심에서 무슨 일이 일어나는 지를 이해하려고 합니다. 먼저 실수치 회로(real-valued circuit. 역주: 불리언 값이 들어가는 논리회로가 아닌 실제 수치 값이 들어가는 회로)에 대한 이야기를 시작해 보죠.

추가 메모: 이 가이드의 작업을 잠시 미루고 많은 시간을 스탠포드 CS231n 강의(콘볼루션 뉴럴 네트워크)에 투여하고 있습니다. 이 강좌의 강의 노트는 cs231n.github.io에 있고 슬라이드는 여기에서 받을 수 있습니다. 이 자료들은 이 글의 내용과 밀접하게 연관되어 있지만 좀 더 광범위고 전문적인 내용을 담고 있습니다.

1장: 실수치 회로

개인적인 생각엔 뉴럴 네트워크를 이해하는 가장 좋은 방법은 실수치({0,1}의 불리언 값이 아닌)가 엣지(edge. 역주: 게이트 사이를 연결하는 선)를 타고 흘러 들어가 게이트(gate)에서 상호 작용을 하는 실수치 회로로 생각하는 것입니다.  하지만 AND, OR, NOT 같은 게이트 대신 *(곱셈), +(덧셈), max 또는 exp 같은 단항(unary. 역주: 하나의 피연산자를 가지는) 게이트를 다룰 것입니다. 일반적인 불리언 회로와는 달리 기울기(gradient)란 값이 회로의 엣지를 따라 반대 방향으로 이동할 수 있습니다. 너무 앞서 나가지 말고 간단한 예제로 시작해 보겠습니다.

기본적인 구조: 하나의 게이트로 된 회로

먼저 한개의 게이트가 있는 간단한 회로를 생각해 봅시다. 아래 그림과 같습니다.

hackers_nn_1

이 회로는 두개의 실수 x 와 y 를 입력받아 * 게이트에서 x * y 를 계산합니다. 이 회로의 자바스크립트 버전은 아래처럼 매우 간단합니다.

var forwardMultiplyGate = function(x, y) {
  return x * y;
};
forwardMultiplyGate(-2, 3); // -6 이 리턴됩니다.

수식으로 표현하면 이 게이트를 실수치를 받는 어떤 함수로 생각할 수 있습니다.

f(x, y) = xy

이 예에서처럼 우리가 사용할 게이트들은 하나 또는 두개의 입력을 받아 하나의 출력 값을 만듭니다.

목표

우리가 다루려고 하는 문제는 아래와 같습니다.

  1. 주어진 회로에 특정 입력 값을 넣습니다(예, x = -2, y = 3)
  2. 회로는 출력 값을 계산합니다(예, -6)
  3. 여기서 핵심적인 질문이 발생합니다. 입력 값을 어떻게 변경시켜야 출력이 증가 될까요?

이 경우라면 어떤 방향으로 x, y 를 수정해야 -6 보다 큰 숫자를 얻을 수 있을까요? 예를 들면, x = -1.99 와 y = 2.99 라면 x * y = -5.95 가 되어 -6.0 보다 커집니다. 혹시 혼돈하지 마세요. -5.95 는 -6.0 보다 큽니다. 절대값(0 에서부터의 거리)은 작지만 -5.95 는 0.05 만큼 향상된 것입니다.

전략 #1: 무작위 지역 탐색(Random Local Search)

좋습니다. 어떤 회로와 입력이 주어 졌을 때 입력 값을 조금 변경시켜 출력 값을 증가시키려고 합니다. 어려운 일인가요? 우리는 어떤 x 와 y 라도 회로에 정방향(역주: 입력에서 출력 방향으로)으로 통과시켜  출력을 계산할 수 있습니다. 그럼 간단한거 아닌가요? x 와 y 를 무작위로 바꾸면서 어떤 값이 가장 좋은 출력을 내는지 확인하면 됩니다.

// 하나의 게이트가 있는 회로
var forwardMultiplyGate = function(x, y) { return x * y; };
var x = -2, y = 3; // 입력 값

// x, y 를 랜덤하게 조금씩 변경하면서 가장 좋은 출력을 내는 값을 추적합니다
var tweak_amount = 0.01;
var best_out = -Infinity;
var best_x = x, best_y = y;
for(var k = 0; k < 100; k++) {
  var x_try = x + tweak_amount * (Math.random() * 2 - 1); // x 를 조금 변경
  var y_try = y + tweak_amount * (Math.random() * 2 - 1); // y 를 조금 변경
  var out = forwardMultiplyGate(x_try, y_try);
  if(out > best_out) {
    // 현재까지 최고 값 보다 좋은 경우 이를 새로운 최고 값으로 저장합니다
    best_out = out; 
    best_x = x_try, best_y = y_try;
  }
}

제가 이 코드를 실행 했을 때 best_x = -1.9928, best_y = 2.9901 이고 best_out = -5.9588 이었습니다. -5.9588 도 -6.0 보다 높은 값입니다. 성공했네요. 그렇죠? 하지만 최선은 아닌 것 같습니다. 이 방법은 게이트가 몇개 안되는 작은 문제에서 어느 정도 계산 시간이 걸려도 좋다면 꽤 좋은 전략입니다. 하지만 수백만개의 입력이 있는 거대한 회로를 생각한다면 불가능한 방법입니다. 우리에겐 더 효율적인 방법이 필요합니다.

전략 #2: 계산 기울기(Numerical Gradient)

다행히 더 좋은 방법이 있습니다. 우리에게 주어진 것은 회로(예를 들면, 우리 회로는 하나의 * 게이트입니다)와 특정 입력 값(예를 들면, x = -2, y = 3)이라는 것을 기억해 주세요. 이 게이트는 출력 값(-6)을 만들며 우리는 x 와 y 를 바꾸어 출력을 더 높이길 원합니다.

여기서 하려는 방식을 다음과 같이 이야기해 보죠. 회로에서 출력되는 값을 플러스 방향으로 잡아 당긴다고 생각해 보는 것입니다. 플러스 방향으로 당기는 이 장력은 게이트를 통과해서 입력 x 와 y 에게 힘을 가하게 될 것입니다. 이 힘은 출력 값을 증가시키기 위해 어떻게 x 와 y 가 변경되어야 하는지를 알려 줍니다.

이 예에서 그 힘은 어떤 형태일까요? 세심히 살펴보면 x 의 값을 조금 더 크게 하면 회로의 출력이 증가하므로 x 에 미치는 힘은 플러스 방향임을 알 수 있습니다. 예를 들어, x 를 x = -2 에서 x = -1 로 증가시키면 출력 값은 -6 보다 큰 -3 이 됩니다. 반대로 y 에 대한 힘은 출력을 더 작게 만드려는(왜냐하면 y 를 작게하면, 즉 y = 3 에서 y = 2 로 변경하면 출력 값은 2 x -2 = -4 로 -6 보다 증가하기 때문에) 마이너스 방향임을 알 수 있습니다. 이를 잘 기억해 두세요. 앞으로 보게 되겠지만 여기서 말한 힘은 사실 입력(x 와 y)에 대한 출력 값의 변화율(derivative. 역주: 가능하면 수학을 배제하고자 하는 원문의 의도를 살려 도함수 보다 변화율이라고 번역합니다)이 됩니다. 혹 아래와 같은 말을 들어본 적이 있을지 모르겠습니다.

변화율은 출력을 증가시키려고 입력에 가하는 힘으로 생각할 수 있다.

그럼 어떻게 이 힘(변화율)을 계산할 수 있을까요? 사실 매우 간단한 방법이 있습니다. 거꾸로 생각하는 것이죠. 회로의 출력을 끌어 올리려고 입력 값의 변화를 보는 것이 아니고 입력 값을 하나씩 차례대로 아주 조금씩 증가시켜서 출력 값에 어떤 변화가 있는지를 살펴 보는 것입니다. 이 출력 값의 변화가 변화율입니다. 개념이 충분히 이해되었으면 수학적 정의를 살펴보겠습니다. 입력에 대한 어떤 함수의 변화율을 정의할 수 있습니다. 예를 들어 x 에 대한 변화율은 아래의 식으로 계산할 수 있습니다.

\dfrac{\partial f(x, y)}{\partial x} = \dfrac{f(x + h, y) - f(x, y)}{h}

여기서 hx 를 조금 변경시키기 위한 값으로 아주 작습니다. 혹시 미적분에 대해 익숙하지 않다면 유념해야 할 것은 위 식의 왼쪽 항에 있는 식의 수평선이 나눗셈을 의미하지 않는다는 것입니다. 전체 식 \dfrac{\partial f(x, y)}{\partial x} 이 하나의 기호, 즉 x 에 대한 f(x, y) 의 변화율입니다. 오른쪽 항의 수평선은 나눗셈입니다. 좀 혼란스럽긴 하지만 이게 표준이랍니다. 어쨋든 이걸 너무 겁내지 않기를 바랍니다. 사실 별거 아니거든요. 회로의 초기 출력을 f(x, y) 이라 할 때 미세한 양 h 만큼 입력 값 중 하나가 변경되면 새로운 출력은 f(x+h, y) 가 됩니다. 이 두 값을 뺀 것이 변화된 출력 값이며 h 로 나누면 (임의의) 입력 변화에 따른 출력의 변화량을 정규화(normalize)하게 됩니다. 덧붙이자면 이것은 위에서 제가 말한 것과 정확히 같으며 아래와 같은 코드로 나타낼 수 있습니다.

var x = -2, y = 3;
var out = forwardMultiplyGate(x, y); // -6
var h = 0.0001;

// x 에 대한 변화율을 계산
var xph = x + h; // -1.9999
var out2 = forwardMultiplyGate(xph, y); // -5.9997
var x_derivative = (out2 - out) / h; // 3.0

// y 에 대한 변화율을 계산
var yph = y + h; // 3.0001
var out3 = forwardMultiplyGate(x, yph); // -6.0002
var y_derivative = (out3 - out) / h; // -2.0

예시를 위해 x 를 따라가 보겠습니다. 입력을 x 에서 x + h 로 바꾸면 회로는 더 큰 값을 만들게 됩니다(다시 말하지만 -5.9997 은 -6 보다 큽니다: -5.9997 > -6). h 로 나누는 것은 임의로 선택한 h 값으로 출력된 회로의 값을 정규화시키는 역할을 합니다. 기술적으로 보면 h 값을 무한히 작은 값으로 하면 좋습니다(정확한 기울기의 수학적 정의는 h 가 0에 가까운 극한 값으로 표현됩니다). 하지만 현실적으로는 h = 0.00001 정도가 대부분의 경우에 잘 맞는 충분한 근사 값입니다. 여기서 x 에 대한 변화율은 +3 이라는 걸 알았습니다. 제가 일부러 플러스 기호를 표시했습니다. 왜냐하면 회로가 x 를 커지는 방향으로 끌어 당겼기 때문입니다. 3 이란 값은 잡아 당기는 힘의 정도를 나타냅니다.

입력에 대한 변화율은 입력을 조금 변경시켜 나오는 출력 값의 차이를 관찰하여 계산할 수 있습니다.

우리는 보통 하나의 입력에 대해서는 변화율이라 말하고 모든 입력에 대해서는 기울기(gradient)라고 이야기 합니다. 기울기는 모든 입력의 변화율을 하나의 벡터(즉, 리스트)로 연결해 놓은 것입니다. 결론적으로 기울기가 향하는 방향으로 입력 값을 조금 변경시키면(즉, 모든 입력에 각각의 변화율을 더한 것) 아래 코드와 같이 출력 값이 증가할 것입니다.

var step_size = 0.01;
var out = forwardMultiplyGate(x, y); // 변경전: -6
x = x + step_size * x_derivative; // x 는 -1.97 로 변경
y = y + step_size * y_derivative; // y 는 2.98 로 변경
var out_new = forwardMultiplyGate(x, y); // -5.87! 만세.

예상한 대로 기울기에 따라 입력을 변경하니 회로는 조금 더 큰 값을 출력합니다(-5.87 > -6.0). 이 방식이 x 와 y 를 무작위로 시험해 보는 것 보다 간단합니다. 그렇죠? 다행인 것은 미분을 하면 그 기울기가 함수의 가장 가파른 증가를 이끄는 방향임을 확신할 수 있습니다. 더 이상 전략 #1 처럼 무작위 근사 방식을 찾으려 헤맬 필요가 없습니다. 수백번이 아니라 단지 세번의 정방향 계산만 하면 기울기를 얻을 수 있고 출력 값을 증가시키기 위해 (국부적인 영역에서)최선의 방향을 제시해 줍니다.(역주: x_derivative, y_derivative를 계산하기 위해 forwardMuliplyGate를 세번 계산했습니다)

xy_surface

역주: 이 그래프는 f(x, y) = xy 를 3차원 공간에 표시하고 있습니다. [-2, 3] 위치에서 x 는 플러스 방향으로(왼쪽 붉은 화살표), 그리고 y 는 마이너스 방향으로(오른쪽 붉은 하살표) 향하는 것이 출력 값 x * y 를 크게 만듭니다.

큰 스텝이 항상 좋은 것은 아닙니다. 이 부분을 조금 자세히 짚어 보겠습니다. 이 예에서는 0.01 보다 아무리 큰 step_size 를 사용해도 괜찮습니다. 예를 들면 step_size = 1.0 은 -1 을 출력합니다(더 크니 더 좋죠!). 실제로 무한대의 스텝 크기는 무한대로 좋은 결과를 만듭니다. 하지만 우리의 회로가 많이 복잡해지면(예를 들어 뉴럴 네트워크 전체) 입력에서 출력까지의 함수는 매우 복잡하고 뒤엉켜 있습니다. 우리가 매우 작은 스텝(사실 극한으로 작아지는 값) 크기를 사용하면 기울기의 방향을 따라 갈 때 항상 더 높은 값을 보장 받을 수 있습니다. 극한으로 작은 스텝 크기에서는 더 나은 다른 방향이 없는 것입니다. 하지만 큰 스텝 크기(예를 들면 step_size = 0.01)에서는 모든 예상이 빗나갑니다. 극한의 아주 작은 값 보다 큰 스텝 크기를 사용할 수 있는 경우는 함수가 비교적 완만한 곡선으로 이루어져 있을 때에나 가능합니다. 하지만 기도하는 마음으로 행운을 바라는 것이나 다를 바가 없습니다.

언덕 오르기 비유. 전에 들었던 한 비유는 회로의 출력 값을 언덕의 높이로 보고 눈을 가리고 언덕을 오른다고 생각하는 것입니다. 우리는 발바닥으로 언덕의 경사를 느낄 수 있으므로(기울기) 조금씩 바닥에 발을 붙여 걸어나가면 높은 곳으로 올라갈 수 있을 것입니다. 하지만 대담하게 성큼성큼 걷는다면 도랑으로 빠져버릴 수 있습니다.

좋네요. 계산 기울기가 실제로 구하기도 손쉽고 매우 유용하다는 것을 이해했습니다. 하지만 더 좋은 방법이 있습니다.

전략 #3: 공식 기울기(Analytic Gradient)

이전 섹션에서 우리는 각 입력에 대한 회로의 출력을 검사해서 기울기를 계산했습니다. 이런 방식을 계산 기울기라고 불렀습니다. 하지만 각각의 입력을 조금씩 변경하여 회로의 출력을 계산해야 하기 때문에 여전히 비용이 많이 듭니다. 그래서 기울기를 계산하는 복잡도는 입력의 갯수에 선형적으로 비례하여 증가됩니다. 그런데 실제 환경에서 수백, 수천 또는 (뉴럴 네트워크에서는)수천만개나 수억개의 입력이 있고 회로가 하나의 곱셈 게이트가 아니고 수 많은 수식으로 되어 있어 계산에 많은 비용이 듭니다. 그래서 더 좋은 방법이 필요하게 됩니다.

다행히도 더 쉽고 훨씬 빠르게 기울기를 계산하는 방법이 있습니다. 회로의 출력을 간단하게 계산할 수 있도록 미분을 사용하여 직접 공식을 유도할 수 있습니다. 이것을 공식 기울기(analytic gradient)라고 부르겠습니다. 이제 더 이상 입력 값을 이리 저리 변경할 필요가 없습니다. 혹시 다른 사람들이 뉴럴 네트워크를 가르칠 때 엄청난, 솔직히 말해 (수학에 능통하지 않다면)무시무시하고 복잡한 수학 방정식으로 기울기 공식을 유도하는 걸 본적이 있을지 모르겠습니다. 하지만 그런 것이 굳이 필요하지 않습니다. 저는 뉴럴 네트워크 코드를 많이 만들어 봤지만 두줄 이상 수학 공식을 써야할 경우가 드물었습니다. 95%의 경우 수학 공식을 전혀 사용하지 않고 작업했습니다. 그러므로 아주 간단하고 짧은 식으로(기본적인 구조일 경우) 기울기 공식을 유도하고 체인 룰(chain rule)을 사용해 전체 기울기(중첩된 구조의 경우)가 어떻게 구성되는지 설명하겠습니다.

공식 기울기는 입력 값을 조작할 필요가 없습니다. 수학(미분)으로 공식을 유도할 수 있습니다.

곱셈과 다항식, 몫의 규칙 등(미분 규칙, 위키페이지)을 기억하고 있다면 x * y 같은 간단한 식의 x 와 y 에 대한 미분 방정식은 쉽게 쓸 수 있습니다. 하지만 미분 규칙을 잊어버렸다고 가정하고 정의부터 시작해 보겠습니다. x 에 대한 미분 함수 표현은 아래와 같습니다.

\dfrac{\partial f(x, y)}{\partial x} = \dfrac{f(x + h, y) - f(x, y)}{h}

(엄밀히 말하면 h 를 0에 가깝도록 극한(limit)을 취해야 합니다. 수학 매니아들에겐 양해를 구합니다.) 이제 우리가 가진 함수(f(x, y) = xy )를 식에 대입해 보겠습니다. 이 글에서 가장 어려운 수학이 나오게 되는데 준비 되셨죠? 바로 이겁니다.

\dfrac{\partial f(x, y)}{\partial x} = \dfrac{f(x + h, y) - f(x, y)}{h} = \dfrac{(x + h)y - xy}{h}

= \dfrac{xy + hy - xy}{h} = \dfrac{hy}{h} = y

아주 신기하네요. x 에 대한 변화율은 그냥 y 입니다. 이전 섹션에서 보았던 것과 같은데 눈치채셨나요? x 에서 x + h 로 변경하고 계산했을 때 x_derivative = 3.0 즉 여기서 구한 y 값과 정확히 일치하는 값이 나왔습니다. 이것은 우연이 아니며 공식 기울기가 f(x, y) = x * y 함수의 x 변화율과 같다는 것을 알려 줍니다. 그리고 y 에 대한 변화율은 자연스럽게 반대로 생각하면 x 가 됨을 알 수 있습니다. 이제 입력 값을 변경해서 출력을 확인할 필요가 없습니다. 우리는 수학의 힘을 빌려 변화율 계산을 아래와 같이 바꿀 수 있습니다.

var x = -2, y = 3;
var out = forwardMultiplyGate(x, y); // 변경전: -6
var x_gradient = y; // 수학 공식에 의해
var y_gradient = x;

var step_size = 0.01;
x += step_size * x_gradient; // -2.03
y += step_size * y_gradient; // 2.98
var out_new = forwardMultiplyGate(x, y); // -5.87. 출력 값 증가! 나이스.

기울기 구하기 위해 수백번 회로를 계산하는 것(전략 #1)에서 시작해서 입력 값 마다 두번씩 계산하는 방법(전략 #2)을 사용했고 마침내 단 한번 계산으로 구해냈습니다! 계산이 많은 전략(#1, #2)을 써도 기울기의 근사 값만 얻을 수 있는 반면에 #3은(가장 빠르고) 정확한 기울기를 얻을 수 있으므로 훨씬 좋은 방법입니다. 더 이상 근사 값을 쓸 필요가 없죠. 유일한 단점은 미분에 대해 알고 있어야 한다는 것 뿐입니다.

앞서 배운 것을 정리해 보겠습니다.

  • 입력: 어떤 회로와 입력이 주어지면 출력 값을 계산할 수 있습니다.
  • 출력: 출력 값을 증가시킬 수 있는 각 입력의 작은 변경 값을 찾으려고 합니다(독립적으로).
  • 전략 #1: 무식한 방법으로서 입력 값을 조금씩 변경하면서 출력을 가장 크게 증가시키는 값을 찾아가는 무작위 탐색 방법입니다.
  • 전략 #2: 기울기를 계산해서 좀 더 나은 방법을 찾을 수 있습니다. 회로가 아무리 복잡하더라도 계산 기울기로 매우 간단하게(하지만 #3 보다는 비용이 더 드는 방법으로) 계산할 수 있습니다. 기울기를 계산하기 위해 한번에 하나씩 입력 값을 변경하여 나오는 출력을 검사합니다.
  • 전략 #3: 결국 훨씬 스마트하게 수식을 유도하여 공식 기울기를 구할 수 있습니다. 이 결과는 계산 기울기와 동일하지만 훨씬 빠르고 입력 값을 조작할 필요가 없습니다.

그런데 사실(나중에 다시 한번 보겠지만) 뉴럴 네트워크 라이브러리는 모두 공식 기울기를 계산합니다. 하지만 구현된 것이 정확한지는 계산 기울기와 비교하여 검증합니다. 공식 기울기는 계산이 매우 효율적이지만 때로는 버그를 포함할 수 있는 반면 계산 기울기는 매우 간단하게 평가할 수 있기 때문입니다(계산에 드는 비용은 더 크지만). 앞으로 배울텐데 기울기를 계산하는 것은(즉, 역전파하는 동안 혹은 역방향일 때) 회로의 정방향 계산 만큼의 동일한 비용이 듭니다.

중첩된 구조: 여러개의 게이트로 된 회로

잠깐만요. “공식으로 계산한 기울기는 간단한 공식을 유도할 때나 쉽지요. 그런건 유용하지 않아요. 훨씬 더 큰 식이 있을땐 어떡하나요? 거대하고 복잡한 식은 그렇게 빠르지 않을 것 같은데요?” 라고 의문이 생길 수 있습니다. 좋은 질문입니다. 공식은 훨씬 더 복잡해집니다. 하지만 더 어려워지진 않습니다. 이제 곧 볼텐데 각각의 게이트는 크고 복잡한 전체 회로와 완전히 상관없이 스스로의 문제에만 집중합니다. 추가적인 곱셈 하나가 추가되는 것만 빼면 자신의 입력만 신경쓰며 이전 섹션에서 보았던 자신의 변화율만을 계산합니다.

곱셈 하나가 더 추가됨으로써 (그리 유용하지 않은)하나의 게이트가 뉴럴 네트워크 같은 복잡한 알고리즘을 만들어 냅니다.

선전은 그만하겠습니다. 관심을 끌었다면 다행이구요! 자세히 들어가 보기 위해 두개의 게이트가 있는 다음 예를 보겠습니다.

hackers-nn-2

우리가 계산할 식은 f(x, y, z) = (x + y)z 입니다. 아래 코드와 같이 게이트를 함수로 구성해 보겠습니다.

var forwardMultiplyGate = function(a, b) { 
  return a * b;
};
var forwardAddGate = function(a, b) { 
  return a + b;
};
var forwardCircuit = function(x,y,z) { 
  var q = forwardAddGate(x, y);
  var f = forwardMultiplyGate(q, z);
  return f;
};

var x = -2, y = 5, z = -4;
var f = forwardCircuit(x, y, z); // -12 가 출력됩니다

위 코드에서 회로의 입력 x, y, z 와 혼돈되지 않게 하려고 게이트 함수에서 a 와 b 지역 변수를 사용했습니다. 이전과 마찬가지로 x, y, z 세개 입력에 대한 변화율을 찾으려고 합니다. 그런데 여러개의 게이트가 있는 이런 경우 어떻게 해야 할까요? 먼저 + 게이트가 없이 회로에 두개의 변수만 있다고 생각합니다. 즉, q, z 와 * 게이트 하나만 있습니다. q 는 + 게이트의 출력입니다. x, y 를 고려하지 않고 q, z 만 본다면 이전처럼 * 게이트 하나만 가진 문제가 되는 거죠. 이전 섹션에서 이 게이트의 기울기 공식이 어떤 건지 알고 있으므로 아래와 같이 쓸 수 있습니다(이전 섹션의 공식에서 x, y 를 q, z 로 바꿨습니다).

f(q, z) = qz \qquad \Longrightarrow \qquad \dfrac{\partial f(q, z)}{\partial q} = z, \qquad \dfrac{\partial f(q, z)}{\partial z} = q

엄청 간단하죠. 이 식들은 q 와 z 에 대한 기울기 식입니다. 잠깐만요. 그런데 q 에 대한 기울기를 원하는 것이 아니고 입력 x, y 에 대한 기울기를 원하는 것이죠. 다행히도 q 는 x 와 y 의 함수로 구할 수 있습니다(이 예에서는 덧셈으로). 덧셈 게이트에 대한 기울기는 아래처럼 쓸 수 있습니다. 더 간단하네요.

q(x, y) = x + y \qquad \Longrightarrow \qquad \dfrac{\partial q(x, y)}{\partial x} = 1, \qquad \dfrac{\partial q(x, y)}{\partial y} = 1

맞습니다. x 와 y 의 값에 상관없이 기울기는 그냥 1입니다. 잘 생각해 보면 이해가 되는데요. 덧셈 게이트가 더 큰 값을 내려면 x, y 값이 어떻든 간에 상관없이 플러스 방향으로 끌어 당겨야 하기 때문입니다.

역전파(Backpropagation)

마침내 체인 룰(chain rule)을 적용할 때가 왔네요. x 와 y 에 대한 q 의 기울기를 어떻게 계산하는 지를 알았습니다(하나의 + 게이트가 있는 경우). 그리고 q 에 대한 최종 출력의 기울기를 어떻게 계산할 지를 알고 있습니다.(역주: * 게이트에서) 체인 룰은 이 두개를 연결하여 우리가 원하는 x 와 y 에 대한 최종 출력의 기울기를 구하는 방법입니다. 무엇보다도 체인 룰은 아주 간단합니다. 단지 기울기를 곱셈으로 연결시키는 게 전부거든요. 예를 들면 x 에 대한 최종 기울기 공식은 아래와 같습니다.

\dfrac{\partial f(q, z)}{\partial x} = \dfrac{\partial q(x, y)}{\partial x} \dfrac{\partial f(q, z)}{\partial q}

기호가 많아 어려워 보이지만 사실 이건 그냥 두 수를 곱하는 것 뿐입니다. 아래 코드를 보세요.

// 초기 조건
var x = -2, y = 5, z = -4;
var q = forwardAddGate(x, y); // q 는 3
var f = forwardMultiplyGate(q, z); // 출력은 -12

// 입력에 대한 곱셈 게이트의 기울기.
// wrt는 '에 대해서'의 줄임말 입니다.
var derivative_f_wrt_z = q; // 3
var derivative_f_wrt_q = z; // -4

// 입력에 대한 덧셈 게이트의 기울기
var derivative_q_wrt_x = 1.0;
var derivative_q_wrt_y = 1.0;

// 체인 룰
var derivative_f_wrt_x = derivative_q_wrt_x * derivative_f_wrt_q; // -4
var derivative_f_wrt_y = derivative_q_wrt_y * derivative_f_wrt_q; // -4

이게 다입니다. 우리는 기울기(힘)를 계산하여 이제 입력이 기울기를 따라 조금씩 반응하도록 만들 수 있습니다. 입력에 기울기를 더하니 회로의 출력은 -12 보다 커졌습니다!

// 위에서 구해진 최종 기울기: [-4, -4, 3]
var gradient_f_wrt_xyz = [derivative_f_wrt_x, derivative_f_wrt_y, derivative_f_wrt_z]

// 포스에 맞춰 입력을 조정합니다.
var step_size = 0.01;
x = x + step_size * derivative_f_wrt_x; // -2.04
y = y + step_size * derivative_f_wrt_y; // 4.96
z = z + step_size * derivative_f_wrt_z; // -3.97

// 회로가 더 높은 값을 출력합니다.
var q = forwardAddGate(x, y); // q 는 2.92
var f = forwardMultiplyGate(q, z); // 출력은 -11.59 로 -12 보다 높습니다! 좋아요!

제대로 작동합니다! 어떻게 된건지 이해할 수 있게 설명해 보겠습니다. 회로는 더 큰 값을 만들어야 합니다. 마지막 게이트(역주: 곱셈 게이트)의 입력은 q = 3, z = -4 이고 -12 출력을 냅니다. 출력 값을 올리기 위해 작용하는 힘은 q 와 z 에 미치게 됩니다. 즉 출력 값을 높이기 위해서는 z 의 기울기 값이 양수인 것에서(derivative_f_wrt_z = +3) 알수 있듯이 회로는 z 를 증가시키려고 합니다. 이 기울기 값의 크기는 힘의 크기를 나타냅니다. 반대로 q 는 derivative_f_wrt_q = -4 이므로 4 의 힘으로 감소하는 방향으로 움직이길 원합니다.

다음은 q 를 출력하는 + 게이트입니다. 기본적으로 + 게이트도 q 가 커지도록 x 와 y 를 어떻게 변경해야 하는지 기울기를 계산합니다. 하지만! 중요한 포인트가 있습니다. q 의 기울기는 음수이므로(derivative_f_wrt_q = -4) 회로는 q 가 4 의 크기로 감소되길 원합니다! 그러므로 + 게이트가 최종 출력을 크게 만들도록 하기 위해서는 앞선 기울기의 시그널을 반영할 필요가 있습니다. 이 경우엔 4 의 힘을 반대 방향으로 x, y 에 적용해야 합니다. 체인 룰에서 -4 로 곱하는 것이 바로 이 역할입니다. 그래서 x 와 y 에 +1 의 양수의 힘(지역 기울기)이 적용되지 않고 따라서 x 와 y 미치는 전체 회로의 기울기는 1 x -4 = -4 가 됩니다. 즉 회로는 f 를 커지게 하기 위해 q 를 작게 만들고 싶고 따라서 x 와 y 가 작아지게 되길 원합니다.

이것이 이해되면 역전파를 이해한 것입니다.

우리가 배운 것을 다시 정리해 봅시다.

  • 이전 챕터에서 하나의 게이트(또는 하나의 수식)에 대해 간단한 미분을 사용하여 기울기 공식을 유도했습니다. 우리는 기울기를 게이트의 출력을 높이기 위한 방향으로 입력 값에 미치는 힘 또는 끌어당김으로 생각합니다.
  • 여러개의 게이트가 있는 경우에도 같은 방식으로 작동합니다. 각 게이트는 전체 회로에 대해선 전혀 알지 못하고 자기 자신만을 고려합니다. 입력이 들어오고 게이트는 출력과 입력에 대한 기울기를 계산합니다. 다른 점 하나는 앞선 게이트로 부터 이 게이트에 미치는 어떤 힘이 있다는 거죠. 바로 현재 게이트의 출력에 대해 최종 회로의 출력의 기울기 입니다. 회로가 게이트에게 높은 혹은 낮은 값을 출력하라고 가하는 힘입니다. 게이트는 이 힘을 받아서 입력 값으로 계산한 게이트의 힘(역주: 기울기)과 곱하기만 하면 됩니다(체인 룰). 아래가 이로인해 일어나는 효과입니다.
  1. 게이트가 앞으로부터 강한 플러스 방향의 힘을 받으면 전달 받은 힘의 크기에 비례하여 자기 자신의 입력 값을 더 크게 만듭니다.
  2. 만약 마이너스 방향의 힘을 받으면 회로가 원하는 것은 증가가 아니고 값의 감소입니다. 따라서 게이트의 출력 값이 작아지도록 입력 값에 미치는 힘을 반대로 변경시킵니다.

우리가 회로의 끝에서 출력 값에 힘을 가하면 이것은 전체 회로를 통과하면서 입력까지 그 힘이 미치게 됩니다.

멋지지 않은가요? 하나의 게이트만 있는 경우와 여러개의 게이트가 상호작용하여 복잡한 수식을 계산해야 하는 경우의 차이는 오직 각 게이트에서 곱셈 연산이 하나 추가되는 것 뿐입니다.(역주: 체인 룰에서 앞 게이트에서 전달된 기울기를 곱하는 연산)

역방향 계산의 패턴

숫자를 채워놓고 예제 회로를 다시 보겠습니다. 첫번째 회로는 원래 값을 보여주고 있고 두번째 회로는 앞서 말한 입력으로 돌아가는 기울기를 보여줍니다. 기울기는 항상 체인 룰이 시작되는 맨 끝에서 +1 로 시작합니다. 이것이 회로의 값을 증가시키는 (기본)힘입니다.

hackers-nn-3

기울기가 어떻게 회로에서 역방향으로 전파되는지 패턴을 볼 수 있습니다. 예를 들어 + 게이트는 항상 앞의 기울기를 받아 입력쪽으로 그냥 통과시킵니다(이 예에서 -4 는 + 게이트를 그냥 통과하여 두개의 입력쪽으로 전달되었습니다). 왜냐하면 이 두 입력들의 기울기 공식이 입력 값에 상관없이 그냥 +1 이기 때문입니다. 그래서 체인 룰을 적용하면 전달받은 기울기에 1을 곱하므로 전달 받은 값 그대로가 됩니다. 같은 개념이 max(x ,y) 같은 게이트에도 적용됩니다. 입력에 대한 max(x,y) 의 기울기는 x, y 중 더 큰 쪽은 +1 이고 나머지 하나는 0 입니다. 이 게이트는 역전파되는 동안 기울기를 선택적으로 보내는 역할을 합니다. 즉 앞에서 받은 기울기를 정방향일 때 큰 값을 가진 입력으로 보내게 됩니다.

계산 기울기로 확인. 이 섹션을 마치기 전에 역전파로 계산한 (공식)기울기가 정확한 지 확인해 보도록 하겠습니다. x, y, z 에 대해 계산 기울기를 계산해서 [-4, -4, 3] 와 같은지 확인하면 됩니다. 코드는 아래와 같습니다.

// 초기 조건
var x = -2, y = 5, z = -4;

// 계산 기울기 확인
var h = 0.0001;
var x_derivative = (forwardCircuit(x+h,y,z) - forwardCircuit(x,y,z)) / h; // -4
var y_derivative = (forwardCircuit(x,y+h,z) - forwardCircuit(x,y,z)) / h; // -4
var z_derivative = (forwardCircuit(x,y,z+h) - forwardCircuit(x,y,z)) / h; // 3

역전파를 계산한 것과 같은 [-4, -4, 3] 을 얻었습니다. 다행이네요!🙂

예제: 단일 뉴런

이전 섹션에서 역전파에 대한 기본적인 개념이 이해됐을거라 생각합니다. 이제 좀 더 복잡하고 실제에 가까운 예를 보겠습니다. 아래 함수를 계산하는 2차원(역주: 2개의 변수를 가진) 뉴런을 가정해 봅시다.

f(x, y, a, b, c) = \sigma(ax + by +c)

이 수식에서 \sigma는 시그모이드(sigmoid) 함수를 나타냅니다. 이 함수는 입력 값을 0에서 1 사이의 값으로 구겨 넣기 때문에 일종의 압축(squashing) 함수로 볼 수 있습니다. 즉 아주 큰 음수는 0에 가까운 값이 되고 큰 양수는 1에 가까운 값이 됩니다. 예를 들면 sig(-5) = 0.006, sig(0) = 0.5, sig(5) = 0.993 이 됩니다. 시그모이드 함수는 아래와 같이 정의됩니다.

\sigma (x) = \dfrac{1}{1 + e^{-x}}

sigmoid

역주: 시그모이드 함수 그래프

하나의 입력에 대한 이 함수의 기울기는 위키피디아에서 찾아볼 수 있고 미분을 안다면 직접 구할 수도 있는데 바로 아래와 같습니다.

\dfrac{\partial \sigma (x)}{\partial x} = \sigma (x)(1 - \sigma (x))

sigmoid-gradient

역주: 시그모이드 함수의 기울기(gradient) 그래프

예를 들어, 시그모이드 게이트로 x = 3 입력이 들어오면 출력은 f = 1.0 / (1.0 + Math.exp(-x)) = 0.95 가 됩니다. 그리고 입력에 대한 (이 게이트의)기울기는 dx = (0.95) * (1 - 0.95) = 0.0475 입니다.

이 게이트를 사용하기 위해서 알아야 할 것은 어떻게 입력이 시그모이드 게이트를 통과하면서 정방향으로 계산되는 지와 역전파에 사용하기 위해 입력에 대한 기울기를 나타내는 식을 얻는 것이 전부입니다. 조금 기술적으로 말하면 시그모이드 함수는 지수 게이트, 덧셈 게이트, 나눗셈 게이트를 묶어 놓은 일련의 시리즈 게이트로 구성된 것입니다. 각각을 나누어 계산해도 무방하지만 이 예에서는 기울기 수식을 간단하게 표현하기 위해서 이 게이트들을 하나의 시그모이드 게이트로 압축하여 한번에 나타내도록 했습니다.

그럼 관련된 코드를 깔끔하게 모듈화해서 작성해 보겠습니다. 먼저 알아둬야 할 점은 회로 그림에서 하나의 에는 두개의 숫자가 관련되어 있다는 것입니다.

  1. 정방향 계산일 때 입력, 출력을 나릅니다.
  2. 역방향으로 기울기(즉 당기는 힘)가 흐릅니다.

선에 실릴 이 두개의 힘을 저장할 Unit 구조체를 만듭니다. 각 게이트는 Unit 을 대상으로 연산할 것입니다. 즉 Unit 을 입력으로 받아들이거나 출력을 위해 Unit 을 생성할 것입니다.

// Unit은 회로 그림의 선에 대응합니다
var Unit = function(value, grad) {
  // 정방향에서 계산되는 값
  this.value = value; 
  // 역방향일 때 계산되는 이 유닛에 대한 회로 출력의 변화율
  this.grad = grad; 
}

Unit 외에 3개의 게이트 +, *, sig(시그모이드)도 필요합니다. 곱셈 게이트를 먼저 만들어 보겠습니다. 저는 자바스크립트를 사용하여 만들텐데 자바스크립트는 함수를 사용하여 클래스와 비슷한 효과를 낼 수 있습니다. 만약 자바스크립트를 잘 모른다면 그냥 어떤 속성(this 키워드로 참조할 수 있는)과 메소드(자바스크립트 함수의 prototype 안에 정의되어 있는)를 가진 클래스를 정의하는 것으로 이해하면 됩니다. 모든 게이트에 대해 하나씩 forward 메소드를 실행하고 그리고 나서 반대 방향으로 모든 게이트의 backward 메소드를 실행할 것입니다. 아래가 구현된 코드입니다.

var multiplyGate = function(){ };
multiplyGate.prototype = {
  forward: function(u0, u1) {
    // 입력 유닛 u0, u1 과 출력 유닛 utop 의 포인터를 저장합니다.
    this.u0 = u0; 
    this.u1 = u1; 
    this.utop = new Unit(u0.value * u1.value, 0.0);
    return this.utop;
  },
  backward: function() {
    // 출력 유닛의 기울기를 받아 곱셉 게이트의 자체 기울기와 곱하여(체인 룰)
    // 입력 유닛의 기울기로 저장합니다.
    this.u0.grad += this.u1.value * this.utop.grad;
    this.u1.grad += this.u0.value * this.utop.grad;
  }
}

곱셈 게이트는 입력이 담긴 두개의 유닛을 받아 출력이 저장되어 있는 하나의 유닛을 만듭니다. 기울기는 0으로 초기화 합니다. backward 함수에서는 forward 함수에서 만든 출력 유닛으로 부터 기울기를 얻어(채워져 있을 거라 믿고서) 이 게이트의 자체 기울기와 곱합니다(체인 룰). forward 함수에서 u0.value * u1.value 곱셈을 계산하므로 u0 에 대한 기울기는 u1.value 가 되고 u1 에 대한 기울기는 u0.value 가 됩니다. 또 backward 함수에서 += 연산자를 사용해 기울기를 업데이트 하고 있는 것을 눈여겨 보아 주세요. 이는 하나의 게이트의 출력이 여러번 입력으로 사용될 수 있다는 것을 의미합니다(마치 출력 선 하나가 두 가닥으로 나뉘어져 입력으로 들어가는 것 처럼. 역주: 역방향일 때 하나의 유닛에 여러번 기울기가 업데이트 될 수 있으므로 += 연산자를 사용합니다). 따라서 분기된 유닛으로 부터 전달되는 기울기들은 회로의 출력 값 입장에서 볼 때 최종 기울기를 계산하기 위해 더해져야 합니다. 다른 게이트 두개도 동일하게 만듭니다.

var addGate = function(){ };
addGate.prototype = {
  forward: function(u0, u1) {
    this.u0 = u0; 
    this.u1 = u1; // 입력 유닛의 포인터를 저장합니다
    this.utop = new Unit(u0.value + u1.value, 0.0);
    return this.utop;
  },
  backward: function() {
    // 입력에 대한 덧셈 게이트의 기울기는 1 입니다
    this.u0.grad += 1 * this.utop.grad;
    this.u1.grad += 1 * this.utop.grad;
  }
}
var sigmoidGate = function() { 
  // 헬퍼 함수
  this.sig = function(x) { return 1 / (1 + Math.exp(-x)); };
};
sigmoidGate.prototype = {
  forward: function(u0) {
    this.u0 = u0;
    this.utop = new Unit(this.sig(this.u0.value), 0.0);
    return this.utop;
  },
  backward: function() {
    var s = this.sig(this.u0.value);
    this.u0.grad += (s * (1 - s)) * this.utop.grad;
  }
}

앞에서와 동일하게 backward 함수는 입력에 대한 기울기와 앞 유닛에서 받은 기울기를 곱합니다(즉 체인 룰). 전체 예제를 완성하기 위해 샘플 데이터를 이용해 두개의 차원을 가진 뉴런의 정방향과 역방향 계산을 아래와 같이 작성합니다.

// 입력 유닛 생성
var a = new Unit(1.0, 0.0);
var b = new Unit(2.0, 0.0);
var c = new Unit(-3.0, 0.0);
var x = new Unit(-1.0, 0.0);
var y = new Unit(3.0, 0.0);

// 게이트 생성
var mulg0 = new multiplyGate();
var mulg1 = new multiplyGate();
var addg0 = new addGate();
var addg1 = new addGate();
var sg0 = new sigmoidGate();

// 정방향 계산
var forwardNeuron = function() {
  ax = mulg0.forward(a, x); // a*x = -1
  by = mulg1.forward(b, y); // b*y = 6
  axpby = addg0.forward(ax, by); // a*x + b*y = 5
  axpbypc = addg1.forward(axpby, c); // a*x + b*y + c = 2
  s = sg0.forward(axpbypc); // sig(a*x + b*y + c) = 0.8808
};
forwardNeuron();

console.log('회로 출력: ' + s.value); // 0.8808 출력

이제 기울기를 계산해 보겠습니다. 간단하게 backward 함수를 반대 순서로 계속 호출하면 됩니다! 정방향 계산할 때 유닛에 대한 포인터를 저장했으므로 각 게이트에서 입력과 출력 유닛에 접근할 수 있습니다.

s.grad = 1.0;
sg0.backward(); // axpbypc 에 기울기 저장
addg1.backward(); // axpby 와 c 에 기울기 저장
addg0.backward(); // ax 와 by 에 기울기 저장
mulg1.backward(); // b 와 y 에 기울기 저장
mulg0.backward(); // a 와 x 에 기울기 저장

첫번째 라인은 체인 룰을 시작하기 위해 출력(맨 마지막 유닛)의 기울기를 1.0 으로 셋팅합니다. 이는 마지막 게이트를 +1 의 힘으로 잡아 당기는 것처럼 보면 됩니다. 다른 말로 하자면 출력을 증가시키기 위해 전체 회로를 일정 힘으로 잡아 당기는 것입니다. 만약 1로 셋팅하지 않는다면(역주: 즉 0이면) 체인 룰의 곱셈 방식 때문에 모든 기울기는 0이 될 것입니다. 마지막으로 계산된 기울기로 입력을 변경하고 값이 증가하는지 확인합니다.

var step_size = 0.01;
a.value += step_size * a.grad; // a.grad 는 -0.105
b.value += step_size * b.grad; // b.grad 는 0.315
c.value += step_size * c.grad; // c.grad 는 0.105
x.value += step_size * x.grad; // x.grad 는 0.105
y.value += step_size * y.grad; // y.grad 는 0.210

forwardNeuron();
console.log('역전파 적용한 후 회로의 출력: ' + s.value); // 0.8825 출력

성공입니다! 0.8825 는 이전 값 0.8808 보다 높습니다. 마지막으로 계산 기울기로 역전파 알고리즘이 정확하게 계산된 것인지 확인하겠습니다.

var forwardCircuitFast = function(a,b,c,x,y) { 
  return 1/(1 + Math.exp( - (a*x + b*y + c))); 
};
var a = 1, b = 2, c = -3, x = -1, y = 3;
var h = 0.0001;
var a_grad = (forwardCircuitFast(a+h,b,c,x,y) - forwardCircuitFast(a,b,c,x,y))/h;
var b_grad = (forwardCircuitFast(a,b+h,c,x,y) - forwardCircuitFast(a,b,c,x,y))/h;
var c_grad = (forwardCircuitFast(a,b,c+h,x,y) - forwardCircuitFast(a,b,c,x,y))/h;
var x_grad = (forwardCircuitFast(a,b,c,x+h,y) - forwardCircuitFast(a,b,c,x,y))/h;
var y_grad = (forwardCircuitFast(a,b,c,x,y+h) - forwardCircuitFast(a,b,c,x,y))/h;

역전파된 기울기 값과 동일한 [-0.105, 0.315, 0.105, 0.105, 0.210] 이 나왔습니다. 훌륭하네요!

비록 이 예제는 단일 뉴런에 대한 것이지만 이 코드는 어떤 수식(매우 복잡한 수식도)의 기울기도 계산할 수 있도록 일반화 시킬 수 있습니다. 입력 값에 대한 자체 변화율을 계산하는 간단한 게이트를 만들어서 그래프로 구성하고 정방향으로 계산하여 출력 값을 계산합니다. 그리고 나서 입력이 들어오는 모든 방향으로 기울기에 체인 룰을 적용하여 역방향 계산을 진행하면 됩니다.

역전파 고수 되기

계속 연습하면 복잡한 회로라도 매우 쉽게 역방향 계산을 단번에 할 수 있게 될 것입니다. 역전파에 대한 몇가지 사례를 연습해 보겠습니다. 다음 코드에서는 간단하게 표현하기 위해 Unit, Circuit 클래스 등은 사용하지 않고 그냥 a, b, c, x 변수를 사용하고 이 변수들의 기울기는 da, db, dc, dx 라고 하겠습니다. 우리는 선을 따라서 변수들은 정방향으로 흐르고 기울기는 역방향으로 흐른다고 생각하겠습니다. * 게이트에 대한 코드는 아래와 같습니다.

var x = a * b;
// x 의 기울기(dx)가 주어지면 다른 변수는 역전파 계산으로 구해집니다
var da = b * dx;
var db = a * dx;

위 코드에서 dx 변수가 주어진 것으로 가정했습니다. 즉 역전파 되는 동안 회로의 앞쪽에서 전달될 것입니다(아니면 기본 값인 +1 이 됩니다). 이 코드는 기울기가 어떻게 체인 룰로 전파되는지 보여 줍니다. 여기서 * 게이트는 역방향에서 스위치처럼 작동한다고 표현하면 가장 좋을 것 같습니다. 즉 두 입력 값에 대한 기울기(역주: da, db)는 상대방의 정방향 값(역주: b, a)이 됩니다. 물론 체인 룰이므로 앞에서 전달된 기울기를 곱해야 합니다. 아래는 압축해서 표현한 + 게이트의 코드 입니다.

var x = a + b;
var da = 1.0 * dx;
var db = 1.0 * dx;

이 게이트의 기울기는 1.0 이고 체인 룰에 의해 곱셈을 합니다. 세 수를 더하는 경우는 어떻게 할까요?

// 두 단계로 x = a + b + c 를 계산합니다
var q = a + b; // 게이트 1
var x = q + c; // 게이트 2

// 역방향 계산
dc = 1.0 * dx; // 게이트 2 역전파
dq = 1.0 * dx; 
da = 1.0 * dq; // 게이트 1 역전파
db = 1.0 * dq;

어떻게 된 건지 이해되나요? 역방향일 때 그림을 떠올려 보면 + 게이트는 전달된 기울기를 받아서 그냥 모든 입력에 똑같이 전달합니다(왜냐하면 실제 값에 상관없이 모든 입력에 대한 게이트 자체 기울기가 1.0 이므로). 그러므로 훨씬 간단하게 쓸 수 있습니다.

var x = a + b + c;
var da = 1.0 * dx;
var db = 1.0 * dx;
var dc = 1.0 * dx;

좋네요. 여러 종류의 게이트가 섞여 있을 땐 어떨까요?

var x = a * b + c;
// dx 가 주어지면 역전파 계산은 한번에 됩니다
da = b * dx;
db = a * dx;
dc = 1.0 * dx;

위 코드가 잘 이해가 안되면 임시 변수 q = a * b 를 만들고 x = q + c 를 계산해서 확인해 보세요. 앞서 보았던 뉴런을 가지고 두 단계로 나누어 계산해 보겠습니다.

// 두 단계로 우리 뉴런을 계산합니다
var q = a*x + b*y + c;
var f = sig(q); // sig 는 시그모이드 함수입니다
// 역방향 계산에서 df 가 주어집니다
var df = 1;
var dq = (f * (1 - f)) * df;
// 체인 룰을 적용해 입력에 반영합니다
var da = x * dq;
var dx = a * dq;
var dy = b * dq;
var db = y * dq;
var dc = 1.0 * dq;

이해하는 데 좀 더 도움이 되었나요? 아래는 어떨까요?

var x = a * a;
var da = //???

제곱은 a* 게이트로 들어갈 때 입력 선이 두개로 나누어져 두개의 입력이 되는 것으로 바꾸어 생각할 수 있습니다. 역방향에서는 기울기가 항상 더해지므로 간단하게 계산할 수 있습니다. 결국 이전과 방식은 동일합니다.

var da = a * dx; // 첫번째 입력에 대해 기울기 저장
da += a * dx; // 두번째 입력에 대해 기울기 저장

// 간단하게 쓰면
var da = 2 * a * dx;

사실 제곱 미분 규칙을 알고 있으면 f(a) = a^2\dfrac{\partial f(a)}{\partial a} = 2a 임을 알수 있습니다. 이건 입력 선이 나뉘어져 두개의 입력이 게이트로 들어간다는 생각과 완전히 일치합니다.

또 다른 예를 보겠습니다.

var x = a*a + b*b + c*c;
// 이렇게 계산 됩니다
var da = 2*a*dx;
var db = 2*b*dx;
var dc = 2*c*dx;

좋습니다. 더 복잡한 것을 보겠습니다.

var x = Math.pow(((a * b + c) * d), 2); // pow(x,2) 은 입력을 제곱합니다

실제 이렇게 복잡한 경우가 생기면 수식을 좀 더 간단한 형태로 분리하고 체인 룰을 사용하여 연결합니다.

var x1 = a * b + c;
var x2 = x1 * d;
var x = x2 * x2; // x 는 위와 동일한 식을 표현하고 있습니다
// 역전파 계산을 합니다
var dx2 = 2 * x2 * dx; // x2 로 역전파
var dd = x1 * dx2; // d 로 역전파
var dx1 = d * dx2; // x1 로 역전파
var da = b * dx1;
var db = a * dx1;
var dc = 1.0 * dx1; // 완료!

그리 어렵지 않습니다! 전체 수식을 역전파 방정식들로 표현하고 그것들을 하나씩 수행하여 모든 변수에 대해 역전파 되도록 합니다. 정방향일 때 나온 모든 변수에 상응하는 기울기 변수를 만들고 어떻게 회로의 최종 출력에 대한 기울기를 계산하는 지 주목해 주세요. 실제 잘 사용되는 함수 몇 개와 그 기울기입니다.

var x = 1.0/a; // 나눗셈
var da = -1.0/(a*a);

나눗셈이 활용되는 예입니다.

var x = (a + b)/(c + d);
// 단계별로 나눕니다
var x1 = a + b;
var x2 = c + d;
var x3 = 1.0 / x2;
var x = x1 * x3; // 위 식과 동일합니다.
// 역방향 계산입니다
var dx1 = x3 * dx;
var dx3 = x1 * dx;
var dx2 = (-1.0/(x2*x2)) * dx3; // 앞 게이트의 기울기를 체인 룰에 의해 곱합니다
var da = 1.0 * dx1; // 최종적으로 원래 변수에 적용합니다
var db = 1.0 * dx1;
var dc = 1.0 * dx2;
var dd = 1.0 * dx2;

정방향에서는 나눗셈 수식을 나누어 각각 계산하고 역방향으로 진행할 때는 모든 변수(a 와 같은)의 기울기(da)를 사용하여 하나씩 자신의 기울기와 앞에서 전달된 기울기를 곱했습니다. 아래는 또 다른 예입니다.

var x = Math.max(a, b);
var da = a === x ? 1.0 * dx : 0.0;
var db = b === x ? 1.0 * dx : 0.0;

이걸 이해하기 쉽게 설명해 보겠습니다. max 함수는 입력 값 중에 큰 것을 리턴하고 다른 하나는 무시합니다. 역방향 계산에서는 max 게이트는 단순히 앞에서 전달받은 기울기를 정방향 때 선택했던 입력으로 전달합니다. 이 게이트는 정방향 때 어떤 입력 값이 높았는 지에 따라 결정되는 스위치 같은 역할을 합니다. 다른 입력 쪽으로는 0의 기울기가 전달됩니다. === 연산자는 max 에서 리턴된 값이 어떤 입력인지 테스트하고 그 입력으로 기울기를 전달하는데 사용됩니다.

마지막으로 비선형 함수인 렐루(ReLU, Rectified Linear Unit) 함수를 보겠습니다. 혹시 들어봤을지 모르겠네요. 이 함수는 뉴럴 네트워크에서 시그모이드 함수 대용으로 종종 사용됩니다. 이 함수는 단순히 0이하를 버리는 기능을 합니다.

var x = Math.max(a, 0)
// 역전파 될 때
var da = a > 0 ? 1.0 * dx : 0.0;

다른 말로 하면 이 게이트는 0보다 큰 값은 그냥 통과시키고 0보다 작은 값은 모두 0으로 만듭니다. 역방향 계산에서 이 게이트는 정방향에서 0보다 컸을 때에는 앞에서 온 기울기를 그냥 뒤로 전달하고 정방향에서 0보다 작았다면 기울기를 전달하지 않습니다.

이 정도까지 하도록 하겠습니다. 이것으로 여러분이 (많은 게이트로 이루어진)전체 수식의 계산과 각각의 역방향 계산을 어떻게 하는지 이해했기를 바랍니다.

이 챕터에서 배운 것을 다음처럼 요약합니다. 어떤 값을 복잡한 실수치 게이트에 입력으로 넣을 수 있고 회로의 끝에 일정 힘을 가하면 입력까지 전체 회로를 통화하면서 그 힘이 역전파 됩니다. 입력이 역전파 된 힘의 최종 방향으로 조금 움직이면 전체 회로는 원래 힘의 방향(역주: 회로 끝에 가한 힘의 방향)으로 조금 움직이게 됩니다.

당장 이해가 안될지 모르겠지만 이 메카니즘이 머신러닝의 핵심 요소입니다.

그럼 이 메카니즘을 제대로 한번 사용해 보겠습니다.