2장: 머신러닝 – 해커가 알려주는 뉴럴 네트워크

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

2장: 머신러닝

이전 장에서 (정방향에서)입력을 받아 복잡한 수식을 계산하는 실수치 회로를 보았고 (역방향에서)입력 값에 대한 이 수식의 기울기를 계산했습니다. 이 장에서는 진짜 간단한 이 메카니즘이 어떻게 머신러닝에 유용하게 사용되는지 알아 보겠습니다.

이진 분류(Binary Classification)

이전처럼 간단한 것부터 시작하죠. 머신러닝에서 가장 간단하고 평범하지만 여전히 실용적인 알고리즘은 이진 분류입니다. 재미있고 중요한 많은 문제들이 이 방법으로 간소화될 수 있습니다. 방식은 다음과 같이 N 개의 벡터 데이터셋이 주어졌을 때 모든 데이터를 하나씩 +1 또는 -1 로 구분하는 것입니다. 예를 들면 2차원 데이터셋일 경우 아래와 같이 간단히 나타낼 수 있습니다.

vector -> label
---------------
[1.2, 0.7] -> +1
[-0.3, 0.5] -> -1
[-3, -1] -> +1
[0.1, 1.0] -> -1
[3.0, 1.1] -> -1
[2.1, -3] -> +1

여기서 N = 6 인 데이터 포인트(datapoint)가 있고 각 데이터 포인트는 두개의 특성(feature. D = 2)을 가지고 있습니다. 세개의 데이터 포인트는 +1 로 레이블(label) 되어 있고 나머지 세개는 -1 로 레이블 되었습니다. 이건 의미없는 단순한 예이지만 실제로 +1/-1 데이터셋은 여러 상황에서 매우 유용하게 사용됩니다. 예를 들면 스팸/노스팸 이메일 분류에서 벡터는 이메일 컨텐츠의 여러 특성을 대변할 수 있습니다. 마약이란 단어가 나타난 횟수 같은 거죠.

목표. 이진 분류에서 우리의 목표는 2차원 벡터를 입력 받아 레이블을 예측하는 함수를 학습시키는 것입니다. 이 함수는 보통 일련의 파라메타로 이루어져 있고 우리는 주어진 데이터의 레이블에 맞는 출력이 나오도록 함수의 파라메타를 조정해 나가게 됩니다. 그리고 결국 이 데이터셋을 제쳐두고 학습된 파라메타로 새로운 벡터에 대해 레이블을 예측할 수 있게 됩니다.

훈련 방법(Training Protocol)

우리는 언젠가 복잡한 수식으로 이루어진 완전한 뉴럴 네트워크를 만들겠지만 지금은 1장에서 보았던 단일 뉴런과 비슷한 선형 분류(linear classifier)를 훈련시키는 것으로 시작하겠습니다. 1장의 예제와 차이점은 괜시리 복잡한 시그모이드 함수를 뺀 것 뿐입니다(1장에서는 시그모이드 뉴런이 오랫동안 널리 사용되었기에 예로 사용했지만 요즘 뉴럴 네트워크에서는 거의 사용되지 않습니다). 어쨋든 간단한 선형 함수를 사용하도록 하겠습니다.

f(x, y) = ax + by +c

이 식에서 x, y 를 입력(2D 벡터)으로 생각하고 a, b, c 를 학습시킬 대상인 함수의 파라메타로 생각합니다. 예를 들면 a = 1, b = -2, c = -1 일 때 함수가 첫번째 데이터 포인트([1.2, 0.7])를 받으면 1 * 1.2 + (-2) * 0.7 + (-1) = -1.2 출력을 만들 것입니다. 모델을 훈련시키는 방법은 다음과 같습니다.

  1. 무작위로 데이터 포인트를 선택해 회로에 입력합니다.
  2. 회로 출력이 어느 정도 확신이 들면 그 데이터 포인트를 +1 로 해석합니다(즉 매우 높은 값은 데이터 포인트가 +1 이라고 생각할 수 있고 매우 낮은 값이면 데이터 포인트가 -1 이라고 확신할 수 있습니다)
  3. 예측 값이 주어진 레이블에 얼마나 잘 맞았는지를 측정합니다. 당연하지만 예를 들어 플러스 샘플이 매우 낮은 값이 나오면 회로를 플러스 방향으로 움직여 이 데이터 포인트에 대해 더 높은 값이 나오도록 해야 합니다. 첫번째 데이터 포인트의 경우 +1 의 레이블이지만 우리 예측 함수는 -1.2 를 출력했습니다. 그러므로 더 높은 값을 얻기위해 회로를 플러스 방향으로 잡아 당겨야 합니다.
  4. 회로에 힘을 가하고 입력 a, b, c, x, y 에 역전파시켜 입력을 변경시킵니다.
  5. x, y 는 (고정된)데이터 포인트이므로 x, y 값을 변경하지 않습니다. 회로와 힘의 비유에 걸맞게 말해 본다면 이 두 입력은 땅에 박혀있는 말뚝처럼 생각하면 좋습니다.
  6. 반대로 파라메타 a, b, c 에 대해서는 역전파되는 힘을 반영할 것입니다(즉, 파라메타 업데이트라는 것을 하는거죠). 당연하지만 나중에 이 데이터 포인트가 다시 입력됐을 때 조금 더 높은 출력을 내도록 만들것입니다.
  7. 반복합니다! 다시 1번 단계로 돌아가는 거죠.

위에서 설명한 훈련 방법은 확률적 경사 하강법(Stochastic Gradient Descent)이라고 부릅니다. 제가 강조하고 싶은 부분은 a, b, c, x, y 가 모두 회로 입장에서는 같은 구성 요소라는 것입니다. 따라서 회로에 입력이 들어가면 회로는 어느 방향으로든지 이 값들을 모두 잡아 당길 것입니다. 회로는 파라메타와 데이터 포인트의 차이를 알지 못하거든요. 그러나 역방향 계산이 끝나고 난 후에 우리는 데이터 포인트 (x, y)에 가해지는 모든 힘을 무시하고 데이터셋에 있는 다른 샘플 데이터로 바꾸어 놓고 다음 차례를 반복할 것입니다. 반대로 파라메타 (a, b, c)는 한벌이 그대로 유지되며 데이터 포인트 샘플이 적용될 때 마다 계속 변경됩니다. 시간이 지나면서 파라메타들은 점차 변경되어져서 주어진 함수가 +1 레이블인 샘플에서는 높은 값을 내고 -1 레이블에서는 낮은 값을 출력하게 될 것입니다.

서포트 벡터 머신(Support Vector Machine)

제대로 된 예제를 위해 서포트 벡터 머신을 배워 보겠습니다. SVM은 매우 인기있는 선형 분류 알고리즘입니다. 이 함수의 형태는 앞에서 보았던, f(x, y) = ax + by + c 와 똑같습니다. 만약 여러분이 SVM에 대해 들어 본 적이 있다면 이쯤에서 제가 SVM의 손실 함수(loss function)를 정의하거나 슬랙(slack) 변수나 최대 마진(margin)의 기하학적 개념, 커널, 쌍대(duality) 문제 등의 설명을 시작할 것이라 예상할지 모르겠습니다. 하지만 여기서 저는 다른 방식을 사용하려고 합니다. 손실 함수를 정의하는 것 대신 서포트 벡터 머신의 포스 명세서(이 용어는 제가 만들었습니다)에 대해 설명하려고 합니다. 개인적으로 이 방법이 이해하기 훨씬 쉽다고 생각합니다. 나중에 알게되겠지만 포스 명세서나 손실 함수 모두 한 문제를 바라보는 동일한 방법입니다. 어쨋든 포스 명세서는 아래와 같습니다.

서포트 벡터 머신의 포스 명세서(Force Specification):

  • 우리가 SVM 회로에 플러스 데이터 포인트를 입력하고 1보다 작은 출력을 얻게 되면 회로를 +1 의 힘으로 당깁니다. 이 데이터 포인트는 플러스 샘플이기 때문에 더 높은 스코어(역주: 출력 값)을 내야 하기 때문입니다.
  • 반대로 SVM 회로에 마이너스 데이터 포인트를 넣고 -1 보다 큰 출력을 얻었다면 회로가 이 데이터 포인트에 대해 높은 스코어를 부여한 것입니다. 그러므로 회로를 -1 의 힘으로 마이너스 방향으로 잡아 당겨야 합니다.
  • 위의 잡아 당기는 힘 이외에 a, b 파라메타(c 는 아닙니다!)에는 0의 방향으로 잡아 당기는 작은 힘을 추가합니다. 이건 마치 0에 고정되어 있는 스프링이 a, b 에 연결되어 있는 것처럼 생각하면 됩니다. 실제 스프링처럼 이 힘은 a, b 의 값에 비례합니다(훅의 법칙 아시죠?). 예를 들어 a 가 매우 큰 값이면 0의 방향으로 절대값 |a| 만큼의 강한 힘을 받습니다. 이 힘을 종종 정형화(regularization. 역주:regularization과 normalization을 모두 정규화로 번역하는 사례가 많지만 둘의 개념을 구분하기 위해 regularization을 정형화라고 번역합니다)라고 부르며 a, b 가 비정상적으로 커지는 것을 막아줍니다. a, b 는 입력 값 x, y 와 곱해지기 때문에(a*x + b*y + c 이므로) 둘 중 하나라도 너무 커지면 이 분류 모델은 해당 특성(x 또는 y)에 너무 민감해집니다. 입력 값들은 실제로는 종종 노이즈가 있기 때문에 이렇게 민감한 특성은 좋지 못합니다. 입력이 들쑥 날쑥할 때 우리는 분류 모델이 비교적 부드럽게 따라가길 바랍니다.

간단하지만 완전한 예제를 빠르게 훑어 보겠습니다. 처음 시작할 때 파라메타는 랜덤하게 설정합니다. 즉 a = 1, b = -2, c = -1 입니다. 그런다음에,

  • [1.2, 0.7] 포인트를 입력으로 넣으면 SVM 모델은 1 * 1.2 + (-2) * 0.7 - 1 = -1.2 를 계산합니다. 훈련 데이터에서 이 포인트는 +1 로 레이블 되어 있으므로 출력 값이 1보다 높아야 합니다. 따라서 회로의 맨 끝의 기울기는 양수 +1 이 되고 a, b, c 로 역전파될 것입니다. 거기에 정형화를 위해 0의 방향으로 a 에 (작게 만드는)-1 과 b 에 더 큰 값으로 만들려는 +2 의 힘이 작용합니다.
  • 이번에는 [-0.3, 0.5] 데이터 포인트가 SVM 모델에 입력된다고 생각해 봅시다. 출력은 1 * (-0.3) + (-2) * 0.5 - 1 = -2.3 이 됩니다. 이 데이터의 레이블은 -1 이고 -2.3 은 -1 보다 작으므로 우리의 포스 명세서에 따르면 SVM 모델은 올바르게 작동했습니다. 즉 마이너스 레이블인 이 샘플의 출력이 큰 음수로 계산되었습니다. 그러므로 회로의 출력을 바꿀 필요가 없고 회로 끝에 힘을 가할 필요도 없습니다(즉 힘은 0입니다). 그렇지만 여전히 정형화시키려는 힘은 a 에 -1, b 에 +2 가 작용합니다.

자 글이 너무 많았네요. 1장에서 만들었던 회로 구조를 이용해 SVM 코드를 작성해 보겠습니다.

// 회로: 다섯 개의 유닛 (x,y,a,b,c) 을 입력 받고 하나의 유닛을 출력합니다
// 그리고 입력에 대한 기울기를 계산합니다
var Circuit = function() {
  // 게이트 생성
  this.mulg0 = new multiplyGate();
  this.mulg1 = new multiplyGate();
  this.addg0 = new addGate();
  this.addg1 = new addGate();
};
Circuit.prototype = {
  forward: function(x,y,a,b,c) {
    this.ax = this.mulg0.forward(a, x); // a*x
    this.by = this.mulg1.forward(b, y); // b*y
    this.axpby = this.addg0.forward(this.ax, this.by); // a*x + b*y
    this.axpbypc = this.addg1.forward(this.axpby, c); // a*x + b*y + c
    return this.axpbypc;
  },
  backward: function(gradient_top) { // 상위 게이트로 부터 기울기를 전달 받음
    this.axpbypc.grad = gradient_top;
    this.addg1.backward(); // axpby 와 c 에 기울기 적용
    this.addg0.backward(); // ax 와 by 에 기울기 적용
    this.mulg1.backward(); // b 와 y 에 기울기 적용
    this.mulg0.backward(); // a 와 x 에 기울기 적용
  }
}

이 회로는 a*x + b*y + c 의 출력을 계산하고 그 기울기도 계산할 수 있습니다. 이 코드는 1장에서 만들었던 게이트 코드를 응용했습니다. 이제 회로와는 상관없는 SVM 코드를 만들겠습니다. 이 코드는 출력되는 값을 보고 회로를 움직이게 합니다.

// SVM 클래스
var SVM = function() {
  
  // 파라메타를 랜덤하게 초기화
  this.a = new Unit(1.0, 0.0); 
  this.b = new Unit(-2.0, 0.0);
  this.c = new Unit(-1.0, 0.0);

  this.circuit = new Circuit();
};
SVM.prototype = {
  forward: function(x, y) { // x 와 y 는 유닛 객체라 가정합니다
    this.unit_out = this.circuit.forward(x, y, this.a, this.b, this.c);
    return this.unit_out;
  },
  backward: function(label) { // 레이블은 +1 또는 -1

    // a,b,c 의 기울기 초기화
    this.a.grad = 0.0; 
    this.b.grad = 0.0; 
    this.c.grad = 0.0;

    // 회로의 출력에 따라 당겨야 할 힘(기울기)을 계산합니다
    var pull = 0.0;
    if(label === 1 && this.unit_out.value < 1) { 
      pull = 1; // 스코어가 너무 낮네요. 증가시켜야 합니다.
    }
    if(label === -1 && this.unit_out.value > -1) {
      pull = -1; // 스코어가 너무 높네요. 감소시켜야 합니다.
    }
    this.circuit.backward(pull); // x,y,a,b,c 에 기울기를 적용합니다
    
    // 0의 방향으로 각 파라메타에 비례해서 정형화 힘을 추가합니다
    this.a.grad += -this.a.value;
    this.b.grad += -this.b.value;
  },
  learnFrom: function(x, y, label) {
    this.forward(x, y); // 정방향 계산 (모든 유닛의 .value 속성을 채웁니다)
    this.backward(label); // 역방향 계산 (모든 유닛의 .grad 속성을 채웁니다)
    this.parameterUpdate(); // 파라메타를 업데이트합니다
  },
  parameterUpdate: function() {
    var step_size = 0.01;
    this.a.value += step_size * this.a.grad;
    this.b.value += step_size * this.b.grad;
    this.c.value += step_size * this.c.grad;
  }
};

이제 SVM 을 확률적 경사 하강법으로 훈련시켜 보겠습니다.

var data = []; var labels = [];
data.push([1.2, 0.7]); labels.push(1);
data.push([-0.3, -0.5]); labels.push(-1);
data.push([3.0, 0.1]); labels.push(1);
data.push([-0.1, -1.0]); labels.push(-1);
data.push([-1.0, 1.1]); labels.push(-1);
data.push([2.1, -3]); labels.push(1);
var svm = new SVM();

// 분류의 정확도를 계산하기 위한 함수
var evalTrainingAccuracy = function() {
  var num_correct = 0;
  for(var i = 0; i < data.length; i++) {
    var x = new Unit(data[i][0], 0.0);
    var y = new Unit(data[i][1], 0.0);
    var true_label = labels[i];

    // 예측과 레이블이 맞는지 검사
    var predicted_label = svm.forward(x, y).value > 0 ? 1 : -1;
    if(predicted_label === true_label) {
      num_correct++;
    }
  }
  return num_correct / data.length;
};

// 학습 루프
for(var iter = 0; iter < 400; iter++) {
  // 임의의 데이터 포인트 추출
  var i = Math.floor(Math.random() * data.length);
  var x = new Unit(data[i][0], 0.0);
  var y = new Unit(data[i][1], 0.0);
  var label = labels[i];
  svm.learnFrom(x, y, label);

  if(iter % 25 == 0) { // 매 25번 반복마다...
    console.log('training accuracy at iter ' + iter + ': ' + evalTrainingAccuracy());
  }
}

이 코드의 출력은 아래와 같습니다.

training accuracy at iteration 0: 0.3333333333333333
training accuracy at iteration 25: 0.3333333333333333
training accuracy at iteration 50: 0.5
training accuracy at iteration 75: 0.5
training accuracy at iteration 100: 0.3333333333333333
training accuracy at iteration 125: 0.5
training accuracy at iteration 150: 0.5
training accuracy at iteration 175: 0.5
training accuracy at iteration 200: 0.5
training accuracy at iteration 225: 0.6666666666666666
training accuracy at iteration 250: 0.6666666666666666
training accuracy at iteration 275: 0.8333333333333334
training accuracy at iteration 300: 1
training accuracy at iteration 325: 1
training accuracy at iteration 350: 1
training accuracy at iteration 375: 1

처음에는 분류기의 정확도가 33% 정도 였지만 훈련과정에서 a, b, c 파라메타들이 조정되었고마지막에는 모든 훈련 데이터들이 정확하게 분류되었습니다. 우리가 SVM 모델을 훈련시켰습니다! 그렇다고 이 코드를 운영 시스템에 사용하진 마세요🙂 핵심 부분을 이해한 뒤에 훨씬 효율적인 모델을 어떻게 만드는지 살펴 보겠습니다.

여러 차례 반복이 필요합니다. 이 예제 데이터와 초기 값, 스텝 사이즈로 SVM을 학습시키는데 300번의 반복이 필요했습니다. 사실 얼마나 복잡하고 대규모의 모델인지, 어떻게 초기화했는지, 어떻게 데이터를 정규화(normalization)했는지, 어떤 스텝 사이즈를 썼는지 등에 따라 반복이 더 걸릴 수도 있고 덜 걸릴 수도 있습니다. 이건 예시를 위한 작은 모델이지만 나중에 이런 분류기를 훈련시키는데 가장 좋은 방법에 대해 살펴볼 것입니다. 예를 들면 스템 사이즈 설정은 매우 중요하고 까다롭습니다. 스텝 사이즈가 작으면 모델이 느리게 학습합니다. 스텝사이즈가 크면 모델이 빠르게 학습될 수 있지만 너무 크면 분류 모델을 중구 난방으로 널뛰기 하게 만들고 올바른 최적 결과에 다다르지 못하게 합니다. 결국 훈련 데이터에 최적화되도록 스텝 사이즈를 튜닝하기 위해 일부 데이터를 따로 떼내어 검증(validation)에 이용합니다.

여기서 강조하고 싶은 것은 이 회로는 예제의 선형 함수 뿐만 아니라 어떤 수식도 될 수 있다는 점입니다. 예를 들면 완전한 뉴럴 네트워크도 가능합니다.

일부러 모듈로 구조화하여 코드를 작성했지만 사실 더 간단한 코드로 SVM 모델을 훈련시킬 수 있습니다. 아래 코드가 앞선 클래스나 계산들을 모두 압축한 것입니다.

var a = 1, b = -2, c = -1; // 파라메타 초기화
for(var iter = 0; iter < 400; iter++) {
  // 랜덤한 데이터 포인트 선택
  var i = Math.floor(Math.random() * data.length);
  var x = data[i][0];
  var y = data[i][1];
  var label = labels[i];

  // 힘 계산
  var score = a*x + b*y + c;
  var pull = 0.0;
  if(label === 1 && score < 1) pull = 1;
  if(label === -1 && score > -1) pull = -1;

  // 기울기 계산과 파라메타 업데이트
  var step_size = 0.01;
  a += step_size * (x * pull - a); // -a 는 정형화 값
  b += step_size * (y * pull - b); // -b 는 정형화 값
  c += step_size * (1 * pull);
}

이 코드는 동일한 결과를 냅니다. 아마 이제 여러분은 이 코드를 보고 수식들이 어떻게 만들어진 것인지 알 수 있을 것입니다.

가변적인 힘? 이 시점에 간단한 코멘트를 하자면 회로에 가하는 힘이 항상 +1, 0, 아니면 -1 인 것을 누군가 눈치챘을 것 같습니다. 이와는 좀 다르게 만들 수도 있습니다. 예를 들어 결과 오차가 큰 만큼 비례적으로 힘을 가할 수도 있습니다. 이는 나중에 자세히 보겠지만 제곱 힌지 로스(squared hinge loss) SVM 이라고 불리는 SVM 의 한 변종입니다. 이 모델은 훈련 데이터에 어떤 특징이 있느냐에 따라 좋을 수도 있고 나쁠 수도 있습니다. 예를 들면 데이터에 아주 심한 이상치(outlier)가 있을 때, 출력 스코어를 +100 을 만드는 마이너스 데이터, 얼마나 큰 오차가 있는지에 상관없이 -1 의 힘으로만 당긴다면 분류 모델에게 비교적 작은 영향만 끼칠 것입니다. 모델의 이런 성질을 이상치에 대해 잘 견딘다고(robustness) 말합니다.

정리해 보죠. N개의 D차원 벡터와 각각 +1/-1 로 레이블되어 있는 데이터를 가진 이진 분류 문제가 있습니다. 실수치 회로안에서(우리 예에서는 서포트 벡터 머신) 입력 특성들은 일련의 파라메타와 연결할 수 있습니다. 그리고 그 회로에 데이터를 반복하여 입력시키고 회로의 출력 값이 주어진 레이블과 같아지도록 파라메타를 매번 조절합니다. 특별히 회로에 역전파되는 기울기의 효과에 따라 조정됩니다. 결국 최종 회로는 새로운 데이터 포인트에 대해서도 예측을 할 수 있습니다.

SVM을 뉴럴 네트워크로 일반화하기

사실 SVM은 매우 간단한 회로의 한 종류일 뿐입니다(score = a*x + b*y + c 를 계산하는 회로. a, b, c 는 가중치이고 x, y 는 데이터 포인트). 이 회로는 더 복잡한 함수로 쉽게 확장될 수 있습니다. 예를 들면 이진 분류를 수행하는 2개의 레이어를 가진 뉴럴 네트워크를 만들 수도 있습니다. 정방향 계산은 이런 형태일 것 같습니다.

// x,y 는 입력이라 가정함
var n1 = Math.max(0, a1*x + b1*y + c1); // 첫번째 뉴런의 활성화 함수
var n2 = Math.max(0, a2*x + b2*y + c2); // 두번째 뉴런
var n3 = Math.max(0, a3*x + b3*y + c3); // 세번째 뉴런
var score = a4*n1 + b4*n2 + c4*n3 + d4; // 스코어

위 코드는 3개의 히든 뉴런(n1, n2, n3)과 각 히든 뉴런에 렐루(ReLU) 비선형 함수를 사용하는 2-레이어 뉴럴 네트워크입니다. 이 코드에서 볼 수 있듯이 여러개의 파라메타들이 관련되어 있어 분류 모델은 더 복합적이 됐으며 SVM 같은 선형적인 의사 결정 규칙보다 복잡한 경계를 나타낼 수 있게 됩니다. 다른 식으로 생각해 보면 3개의 히든 뉴런을 각각 선형 분류기로 볼 수 있고 그 위에 또 하나의 선형 분류기를 얹은 것이라 생각할 수 있습니다. 이제 딥(deep)한 모델을 만들기 시작한 것이죠🙂. 좋습니다. 2-레이어 뉴럴 네트워크를 훈련시켜 보죠. 코드는 위 SVM 예제 코드와 매우 비슷합니다. 정방향과 역방향 계산을 조금 수정하면 됩니다.

// 파라메타를 랜덤하게 초기화
var a1 = Math.random() - 0.5; // -0.5 ~ 0.5 사이의 랜덤한 수
// ... 비슷하게 모든 파라메타를 랜덤하게 초기화(생략)
for(var iter = 0; iter < 400; iter++) {
  // 임의의 데이터 포인트 선택
  var i = Math.floor(Math.random() * data.length);
  var x = data[i][0];
  var y = data[i][1];
  var label = labels[i];

  // 정방향 계산
  var n1 = Math.max(0, a1*x + b1*y + c1); // 첫번째 히든 뉴런 활성화 함수
  var n2 = Math.max(0, a2*x + b2*y + c2); // 두번째 뉴런
  var n3 = Math.max(0, a3*x + b3*y + c3); // 세번째 뉴런
  var score = a4*n1 + b4*n2 + c4*n3 + d4; // 스코어

  // 회로 끝에 가할 힘 계산
  var pull = 0.0;
  if(label === 1 && score < 1) pull = 1; // 더 높은 출력 필요! 플러스 방향으로.
  if(label === -1 && score > -1) pull = -1; // 더 낮은 출력 필요! 마이너스 방향으로.

  // 모델의 모든 파라메타에 대해 역방향 계산

  // 마지막 'score' 뉴런에서 부터 역전파
  var dscore = pull;
  var da4 = n1 * dscore;
  var dn1 = a4 * dscore;
  var db4 = n2 * dscore;
  var dn2 = b4 * dscore;
  var dc4 = n3 * dscore;
  var dn3 = c4 * dscore;
  var dd4 = 1.0 * dscore; // 휴~

  // 렐루 비선형 함수에 역전파 적용
  // 즉 뉴런이 정방향에 작동하지 않았으면 기울기는 0으로 셋팅
  var dn3 = n3 === 0 ? 0 : dn3;
  var dn2 = n2 === 0 ? 0 : dn2;
  var dn1 = n1 === 0 ? 0 : dn1;

  // 뉴런 1의 파라메타로 역전파
  var da1 = x * dn1;
  var db1 = y * dn1;
  var dc1 = 1.0 * dn1;
  
  // 뉴런 2의 파라메타로 역전파
  var da2 = x * dn2;
  var db2 = y * dn2;
  var dc2 = 1.0 * dn2;

  // 뉴런 3의 파라메타로 역전파
  var da3 = x * dn3;
  var db3 = y * dn3;
  var dc3 = 1.0 * dn3;

  // 와! 역전파 완료!
  // x,y 로도 역전파 시킬 수 있지만 이 기울기는 관심이 없으므로
  // x,y 는 제외하고 파라메타 업데이트를 합니다

  // 편향(bias)를 제외하고 모든 파라메타에 정형화 값을 추가하여
  // 파라메타 자체의 값에 비례하여 파라메타 값을 낮추도록 유도합니다
  da1 += -a1; da2 += -a2; da3 += -a3;
  db1 += -b1; db2 += -b2; db3 += -b3;
  da4 += -a4; db4 += -b4; dc4 += -c4;

  // 마지막으로 파라메타를 업데이트합니다
  var step_size = 0.01;
  a1 += step_size * da1; 
  b1 += step_size * db1; 
  c1 += step_size * dc1;
  a2 += step_size * da2; 
  b2 += step_size * db2;
  c2 += step_size * dc2;
  a3 += step_size * da3; 
  b3 += step_size * db3; 
  c3 += step_size * dc3;
  a4 += step_size * da4; 
  b4 += step_size * db4; 
  c4 += step_size * dc4; 
  d4 += step_size * dd4;
  // 좀 지저분하네요. 실제 쓸 땐 for 루프를 사용하세요
  // 완료했습니다
}

이게 뉴럴 네트워크를 훈련시키는 방법입니다. 분명히 여러분은 코드를 모듈화고 싶겠지만 여기서는 간단하고 이해하기 쉽도록 예제를 만드는데 집중했습니다. 나중에 이런 네트워크를 구현하는 모범 사례를 살펴보고 모듈로 깔끔하게 정리해서 실용적인 방식으로 구축해보겠습니다.

지금은 두개의 레이어 뉴럴 네트워크가 그렇게 무시무시한 것이 아니라는 걸 느꼈으면 좋겠습니다. 우리는 정방향 수식을 작성했고 마지막에서 출력 스코어를 계산하고 우리가 원하는 값인지에 따라 플러스 방향 혹은 마이너스 방향으로 힘을 가합니다. 역전파된 후 파라메타를 업데이트하면 그 데이터 샘플이 나중에 다시 입력됐을 때 파라메타 업데이트 전에 출력했던 값 보다 조금 더 우리가 원하는 값에 가까운 출력을 내도록 도와줄 것입니다.

전통적인 방법: 손실 함수(loss function)

우리는 이런 회로들이 데이터와 어떻게 작동하는지 기본 원리를 이해했습니다. 이제 인터넷이나 다른 튜토리얼, 혹은 책에서 볼 수 있는 좀 더 전통적인 방식을 적용해 보겠습니다. 아마 포스 명세서에 대해 이야기하는 사람은 만나지 못할 거에요. 대신 머신러닝 알고리즘은 손실 함수(또는 비용 함수-cost function, 목적함수-objectives)의 용어로 기술됩니다.

이 공식을 설명하면서 변수나 파라메타의 이름을 어떻게 부를지 조금 신중하게 선택하려고 했습니다. 여러분이 책이나 다른 튜토리얼에서 보는 것과 유사한 식을 만들기 위해 표준적인 용어를 따르겠습니다.

예: 2-D 서포트 벡터 머신

2차원 SVM을 예로 들겠습니다. N개의 데이터 샘플(x_{i0}, x_{i1})이 있고 이에 상응하는 레이블 y_i 는 플러스 샘플 또는 마이너스 샘플일 때 각각 +1/-1 중 하나의 값을 가집니다. 여기에는 세개의 파라메타 (w_o, w_1, w_2)가 있다는 걸 기억하는게 중요합니다. SVM 손실 함수는 아래와 같이 정의됩니다.

L = [\sum_{i =  1}^N max(0, -y_i(w_0 x_{i0} + w_1 x_{i1} + w_2) + 1)] - \alpha [w^2_o + w^2_1]

주목할 것은 첫번째 항에서 0 이하는 버리고 정형화 항에서 제곱을 하기 때문에 이 함수는 항상 양수를 리턴합니다. 우리는 이 함수의 값을 가능하면 작게 만들려고 합니다. 세부적인 내용을 파고들기 전에 먼저 코드로 작성해 보겠습니다.

var X = [ [1.2, 0.7], [-0.3, 0.5], [3, 2.5] ] // 2차원 데이터 배열
var y = [1, -1, 1] // 레이블 배열
var w = [0.1, 0.2, 0.3] // 임의의 숫자
var alpha = 0.1; // 정형화 강도

function cost(X, y, w) {
  
  var total_cost = 0.0; // SVM 손실 함수의 값
  N = X.length;
  for(var i=0;i<N;i++) {
    // 모든 데이터 포인트를 순회하면서 스코어를 계산
    var xi = X[i];
    var score = w[0] * xi[0] + w[1] * xi[1] + w[2];
    
    // 스코어와 레이블을 기반으로 비용을 계산하고 누적함
    var yi = y[i]; // label
    var costi = Math.max(0, - yi * score + 1);
    console.log('example ' + i + ': xi = (' + xi + ') and label = ' + yi);
    console.log('  score computed to be ' + score.toFixed(3));
    console.log('  => cost computed to be ' + costi.toFixed(3));
    total_cost += costi;
  }

  // 정형화 값: 가중치(weight) 파라메타를 작게 만듦
  reg_cost = alpha * (w[0]*w[0] + w[1]*w[1])
  console.log('regularization cost for current model is ' + reg_cost.toFixed(3));
  total_cost += reg_cost;

  console.log('total cost is ' + total_cost.toFixed(3));
  return total_cost;
}

결과는 아래와 같습니다.

cost for example 0 is 0.440
cost for example 1 is 1.370
cost for example 2 is 0.000
regularization cost for current model is 0.005
total cost is 1.815

이 식이 어떻게 작동하는 걸까요. 이 함수는 SVM 분류기가 얼마나 오차를 내는지를 계산합니다. 단계별로 자세히 짚어 보겠습니다.

  • 첫번째 데이터 포인트 xi = [1.2, 0.7] 은 레이블이 yi = 1 이고 스코어는 0.1*1.2 + 0.2*0.7 + 0.3 이어서 0.56 이 됩니다. 이 데이터는 플러스 샘플이므로 스코어가 +1 보다 커야 합니다. 0.56 은 충분하지 않네요. 이 데이터 포인트에 대한 코스트 함수는 costi = Math.max(0, -1*0.56 + 1) 이어서 0.44 를 출력합니다. 이 코스트 값이 SVM 모델에 맞지 않는 정도라고 생각할 수 있습니다.
  • 두번째 데이터 포인트 xi = [-0.3, 0.5] 은 레이블이 yi = -1 이고 스코어는 0.1*(-0.3) + 0.2*0.5 + 0.3 이어서 0.37 이 됩니다. 뭔가 잘 맞지 않는군요. 마이너스 샘플이라 스코어가 -1 보다 작아야 하는데 너무 높습니다. 실제로 코스트를 계산해 보면 costi = Math.max(0, 1*0.37 + 1) 이어서 1.37 이 나옵니다. 이 샘플의 비용이 매우 높네요. 잘 못 분류한 것이죠.
  • 마지막 샘플 xi = [3, 2.5] 는 레이블이 yi = 1 이고 스코어는 0.1*3 + 0.2*2.5 + 0.3 이어서 1.1 입니다. 이 경우 SVM 모델의 코스트는 costi = Math.max(0, -1*1.1 + 1) 이어서 0이 됩니다. 이 샘플은 비용이 0이므로 올바르게 분류되었습니다.

비용 함수는 분류 모델이 얼마나 오차를 내는지 측정하는 수식입니다. 훈련 데이터가 완벽하게 분류될 때 비용(정형화를 무시하면)은 0이 됩니다.

손실 함수의 마지막 항은 모델 파라메타가 가능하면 작아지도록 만드는 정형화 값입니다. 사실 이 항때문에 비용이 0이 되지는 않습니다(편향 값을 제외한 모델의 모든 파라메타가 완전히 0이 될 수는 없으므로). 하지만 비용이 0에 가까운 근사 값으로 갈수록 분류 모델은 더 좋은 성능을 낼 것입니다.

머신러닝에서 대부분의 비용 함수는 두 부분으로 구성됩니다. 1:모델이 얼마나 데이터에 잘 맞는지를 측정하는 부분. 그리고 2:모델이 얼마나 복잡한지(역주: 0보다 큰 파라메타가 얼마나 많은지)를 나타내는 정형화 부분.

좋은 SVM 모델을 만들기 위해서는 비용을 가능한 작게 만들어야 합니다. 왠지 익숙한 것 같지 않나요? 우리는 무얼해야 할지 잘 알고 있습니다. 즉 위에 있는 비용 함수가 우리의 회로입니다. 모든 샘플을 회로에 통과시키고 역방향을 계산하여 파라메타를 업데이트하면 회로는 앞으로 더 작은 비용을 출력할 것입니다. 정확히 말하면 우리는 기울기를 계산하여 기울기의 반대 방향으로 파라메타를 업데이트 할 것입니다(비용 값이 커지는 것이 아니고 작아지길 원하므로).

우리는 무얼해야 할지 잘 알고 있습니다. 즉 위에 있는 비용 함수가 우리의 회로입니다.