while (1): study();

Ch5. 오차역전파법 본문

독서

Ch5. 오차역전파법

전국민실업화 2021. 7. 19. 21:49
728x90

* 책 내용 요약이 아니니 유의하시기 바랍니다.

 

 오차역전파에 대해서 이전에 해석 강의를 들은 적이 있긴 했는데, 솔직히 감이 잘 안 왔습니다. 이 책을 보고 나서야 비로소 '아! 이런거구나!'라고 제대로 와닿는 느낌이었습니다. 여러모로 읽으면 읽을수록 '읽기를 잘했다'라고 생각이 드는 책이네요. 이 책에서는 계산 그래프를 통해서 오차역전파에 대해 설명하고 있습니다. CS231n에서 설명한 방식을 차용했다고 하는데, 역시 인공지능 배우는 사람이라면 다 한번 거쳐야 하는 관문인걸까.. 싶습니다. 시간 날 때 꼭 봐야겠네요.

 계산 그래프로 푸는 방법의 이점은 2가지입니다.


1. 국소적 계산: 단순한 계산에 집중하여 문제를 단순화

2. 중간 계산 결과 보관

3. 미분을 효율적으로 계산


기존 수치 미분 방법을 이용한 경사 하강법은 시간이 정말 오래 걸렸습니다. 음, 넘파이의 nditer 메서드를 사용했을 때 단순히 모든 원소에 대해 반복문을 돌리는 것보다 빠른 것 같기는 하지만, 그래도 역시 역전파 학습법에 비하면 느린 것은 사실이죠.

 오차역전파의 핵심은 '중간 계산 결과를 보관한다는 것'입니다. 즉 일종의 DP문제로 학습에 접근한다고 볼 수 있죠. 예를 들어 곱셈의 역전파는 다음과 같이 계산됩니다.

곱셈의 역전파

이러한 결과는 그냥 나온 것이 아니라 $z = xy$라고 두었을 때 $dy = x$이고 $dx = y$인 것을 바탕으로 연쇄법칙에 의해 각 가중치의 미분 연산을 단순화한 것이라고 볼 수 있습니다. 즉, 곱셈노드는 입력으로 받은 x와 y에 대해 저장하고 있다가, 역전파 시 서로 위치를 바꿔 입력으로 들어오는 미분에 곱해주면 됩니다.

 덧셈은 더 간단합니다.

덧셈의 역전파

$z = x + y$일 때 x, y 각각에 대한 편미분이 1이므로, 역전파 시 입력을 그대로 뒤로 보내주면 됩니다. 이런식으로 역전파를 사용하면 복잡한 계산을 단순화하여 학습을 효율적으로 진행할 수 있습니다.

 실제 신경망에 사용되는 계층이 역전파는 사진을 올려놓겠습니다만, 자세한 도출 과정은 생략하겠습니다.

1) 시그모이드

시그모이드의 역전파

2) Affine 계층

Affine 계층의 역전파

 

3) 소프트맥스 (with Cross Entropy)

소프트맥스의 역전파

 신기한 것은 교차 엔트로피 손실을 사용했을 때 소프트맥스 층의 역전파가 간결하게 $y-t$가 되어버린다는 점입니다. 이는 의도적으로 이렇게 설계된 것이라고 합니다. 또한 MSE를 손실함수로 사용하고 항등함수를 출력층으로 놓아도 똑같이 역전파가 $y-t$가 된다고 합니다. 정말 신기합니다..

 역전파를 바탕으로 구현한 계층들을 이용하여 신경망을 학습하면, 수치 미분을 사용할 때보다 월등히 빨라지는 것을 알 수 있습니다. 또한 우리는 수치 미분을 사용하여 역전파 알고리즘이 얼마나 잘 구축되었나 확인할 수 있습니다. 이를 기울기 확인(gradient check)라고 한다고 합니다.

 다만 저 같은 경우 책에서 나온 오차보다 실제 오차가 매우 크게 나왔습니다. 이것 때문에 뭐가 잘못되었는지 밤새 살펴보았는데... 도저히 오차가 좁혀지지를 않네요. 실제 오차역전파를 이용하여 학습은 잘 진행됩니다.

 역전파 알고리즘을 이용하여 MNIST를 학습한 모습

 다만 수치 미분 기울기와 역전파 기울기가 다음과 같이 차이가 납니다.

수치 미분 기울기와 역전파 기울기의 절대값 오차

두 번째 레이어의 편향은 거의 0.1 정도 차이가 나고 있는데 이 정도면 충분히 학습에 큰 영향을 줄 수 있는 수치라고 생각합니다.. 원인은 두 가지 정도 생각하고 있습니다. 


1. 수치 미분 알고리즘 구축에서 문제가 있었다.

2. 정밀도의 차이이다.


 첫 번째 케이스의 경우 소스코드까지 살펴보았는데 문제가 없었고.. (단일 샘플에 대한 수치 미분도 책의 샘플과 동일하게 반환됩니다) 정밀도 같은 경우 넘파이의 부동소수점 배열 기본 정밀도가 64비트인 것 같은 데다가, 문제가 될 것 같으면 이전 시그모이드 구현 때처럼 애초에 float64로 형변환을 시키고 들어가기 때문에.. 어디서 문제가 발생했는지 찾으려면 좀 걸릴 것 같습니다.


 해법을 찾았습니다. 힘들었네요.. 우선 수치미분으로 구했을 경우와 역전파로 구했을 때의 기울기를 실제로 살펴보았습니다.

공포의 0

 보니까 수치 미분으로 구한 기울기가 아예 전부 0입니다. 즉, 훈련이 전혀 안 됐다고 판단했습니다. 그럼 어디가 문제일까요? 우선 알고리즘부터 확인해봤습니다. 다음은 기존의 모델에서 그라디언트를 구하는 함수입니다.

def get_grad(self, x, y)
    loss_W = lambda W: self.get_loss(x, y)

    grads = {}
    grads['W1'] = numerical_gradient(loss_W, (self.params['W1']))
    grads['b1'] = numerical_gradient(loss_W, (self.params['b1']))
    grads['W2'] = numerical_gradient(loss_W, (self.params['W2']))
    grads['b2'] = numerical_gradient(loss_W, (self.params['b2']))
    
    return grads

 

 numerical gradient 함수는 함수와 입력을 받아, 중앙 차분하여 기울기를 구합니다. 다만 인자로 준 함수의 인자(말이 좀 복잡하네요)가 더미 인자라는 점에서 조금 의구심이 들었습니다. 가중치가 매번 변하면서 $f(x + h)$와 $f(x - h)$의 차이를 구해야 하는데 매번 가중치 업데이트가 안 되고 있는 것은 아닐까 싶어서요. 혹시 책이 틀릴 가능성도 있으니까, 조금 코드를 직관적으로 바꿔봤습니다.

 다음 함수는 모델의 가중치를 업데이트해서 미분한 뒤, 다시 원래 가중치로 되돌리는 작업을 명시적으로 보여줍니다.

def get_grad(self, x, y):
    def loss_W(model, weight, key) :
        origin_params = self.params[key]
        model.params[key] = weight
        loss = model.get_loss(x, y)
        model.params[key] = origin_params

        return loss

    grads = {}
    grads['W1'] = numerical_gradient(loss_W, (self, self.params['W1'], 'W1'))
    grads['b1'] = numerical_gradient(loss_W, (self, self.params['b1'], 'b1'))
    grads['W2'] = numerical_gradient(loss_W, (self, self.params['W2'], 'W2'))
    grads['b2'] = numerical_gradient(loss_W, (self, self.params['b2'], 'b2'))

    return grads

 물론 결과는 같았습니다.. 삽질이었네요. 생각해보니 loss_W 함수 안에서 호출하는 get_loss 메서드는 추론 과정에서 알아서 가중치를 클래스 프로퍼티로 업데이트합니다.

 알고리즘이 문제가 아니라면 정밀도가 문제일까요? 기존 수치 미분 함수를 살펴보았습니다.

def numerical_gradient(f, x, h=1e-4):
    x = x.astype('float64')
    grad = np.zeros_like(x)

    # generate iterator
    iter = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
    while not iter.finished:
        idx = iter.multi_index
        tmp_val = x[idx]

        # central diff
        x[idx] = tmp_val + h
        fx_h1 = f(x)

        x[idx] = tmp_val - h
        fx_h2 = f(x)

        grad[idx] = (fx_h1 - fx_h2) / (2. * h)

        x[idx] = tmp_val
        iter.iternext()

    return grad

넘파이 nditer 메서드를 사용하면 효과적으로 배열을 순회할 수 있습니다. 제가 알기로 넘파이 소수의 기본 정밀도는 float64입니다. 따라서 처음부터 입력으로 받았던 x를 float64로 형변환을 시켜서 더 정밀한 결과를 내놓고 싶었습니다. 코드를 가만히 살펴보다가 문득 드는 생각, '형변환을 하지 말아볼까?'이었습니다.

 아래 코드는 단순히 float64로 바꾸는 라인만 지웠습니다.

def numerical_gradient(f, x, h=1e-4):
    grad = np.zeros_like(x)

    # generate iterator
    iter = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
    while not iter.finished:
        idx = iter.multi_index
        tmp_val = x[idx]

        # central diff
        x[idx] = tmp_val + h
        fx_h1 = f(x)

        x[idx] = tmp_val - h
        fx_h2 = f(x)

        grad[idx] = (fx_h1 - fx_h2) / (2. * h)

        x[idx] = tmp_val
        iter.iternext()

    return grad

  결과는 성공적이었습니다. 다시 한번 기울기를 직접 보겠습니다.

 밑의 두 줄이 두 번째 레이어의 편향의 기울기입니다만, 두 그래디언트가 상당히 유사하게 도출되었음을 알 수 있습니다. 가중치 간 절대값 오차도 매우 작아졌음이 한 눈에 보입니다.

 중요한 것은 '왜 형변환을 안했더니 제대로 된 결과가 나오는가?'입니다. 저번에 수치 미분을 구현할 때 정수 배열에 인덱싱을 하면 소수점 뒷자리가 날라가버리는 현상이 발생했었습니다. 이번에는 그것과는 또 다른 문제인 듯 합니다. 사실 형변환을 하던말던 이전의 값과 이후의 값, 그리고 이전의 타입과 이후의 타입이 완전히 같은 것을 확인했습니다. 뭔가 이론 이상의 내용이 있는 것 같긴한데..

  더 중요한 것은 '함부로 형변환 막해서 끼워맞추기 식으로 코딩하면 안된다는 것'이겠죠... 생각해보니 Weight tying을 이용한 컴팩트한 번역기를 만들어보겠다고 덤벼놓고 추론 과정에서 막혔었는데, 이게 원인인 것 같네요 아무래도..

 왜 이런 현상이 발생하는지 아시는 분은 댓글 부탁드립니다..

728x90

'독서' 카테고리의 다른 글

Ch7. 합성곱 신경망  (0) 2021.07.22
Ch6. 학습 관련 기술들  (0) 2021.07.21
Ch4. 신경망 학습  (0) 2021.07.18
Ch3. 신경망  (0) 2021.07.18
Ch2. 퍼셉트론  (0) 2021.07.18
Comments