Ddanggle in Ml minutes

13줄의 파이썬 코드로 뉴럴 네트워크를 만들어보자. (파트2 - 경사하강법)

이 글은 머신러닝 블로그 iamtrask 저자 Trask의 허락을 받아 A Neural Network in 13 lines of Python (Part 2 - Gradient Descent)의 듀토리얼 글을 번역한 것입니다. 원문도 꼭 읽어보셨으면 합니다.

이 글의 jupyter notebook은 곧 제 깃헙에 올려두었습니다. 링크


요약: 저는 가지고 놀 수 있는 코드로 배우는 것이 가장 좋았습니다. 이 듀토리얼은 매우 단순한 toy 예시를 간단하게 파이썬으로 향상시켜보면서 역전파법(backpropagation)에 대해 알아봅니다.

후속으로 보실 글: state-of-the-art approaches을 바탕으로 인기있는 특징들을 추가해서 글을 쓸 생각입니다. @iamtrask 트윗을 통해서 완성되면 알려드리도록 하겠습니다. 게속되는 글을 읽고 싶으시다면 부담없이 팔로우 해 주시고, 피드백도 모두 감사드립니다.

코드부터 보면:

import numpy as np

X = np.array([ [0,0,1],[0,1,1],[1,0,1],[1,1,1] ])
y = np.array([[0,1,1,0]]).T

alpha,hidden_dim= (0.5,4)
synapse_0 = 2*np.random.random((3,hidden_dim))-1
synapse_1 = 2*np.random.random((hidden_dim,1))-1

for j in range(60000):
    layer_1 = 1/(1+np.exp(-(np.dot(X, synapse_0))))
    layer_2 = 1/(1+np.exp(-(np.dot(layer_1, synapse_1))))
    layer_2_delta = (layer_2-y)*(layer_2*(1-layer_2))
    layer_1_delta = layer_2_delta.dot(synapse_1.T)*(layer_1*(1-layer_1))
    synapse_1 -= (alpha * layer_1.T.dot(layer_2_delta))
    synapse_0 -= (alpha * X.T.dot(layer_1_delta))

단계 1: 최적화

파트1 (한글 번역본 파트1)에서 간단한 네트워크 내의 기본적인 역전파법을 설명하였습니다. 역전파법은 네트워크 안의 각각의 웨이트가 얼마나 전체 에러에 기여하는 지 측정할 수 있습니다. 경사하강법 (Gradient Descent) 이라는 알고리즘을 사용해서 근본적으로 이 웨이트들을 변경할 수 있도록 합니다.

여기서 해결해야할 문제는 역전파법은 최적화가 되지 않는다는 것입니다! 역전파법은 에러 정보를 네트워크의 끝에서 네트워크 안에 있는 모든 웨이트들로 옮겨줍니다. 그래서 다른 알고리즘이 웨이트들은 우리 데이터에 적절히 맞춰주어야 합니다. 사실 과할 정도로 많은 비선형 최적화 방법들이 존재하고 역전파법과 함께 사용할 수 있습니다.

몇 가지 최적화 기법들

시각적으로 다른 점을 확인 해보시려면:

대부분 이런 최적화기법들은 각각의 목적에 따라 장단점이 있고, 몇 가지들을 함께 사용하는 경우도 있습니다. 이번 듀토리얼에서는, 거의 확신컨대 뉴럴 네크워크 최적화 알고리즘에 있어서 가장 간단하고 많이 사용하는 Gradient Descent(경사 하강법)을 사용할 것 입니다. 경사하강법을 배우고 난 후에는, 튜닝과 파라미터화를 통해서 우리의 장난감 뉴럴네트워크를 향상시킬 수 있을 것이고, 궁극적으로는 정말 강력하게 만들어 줄 것입니다.

단계 2: 경사하강법 (Gradient Descent)

아래의 그림과 같은 동그란 바구니 안에 빨간 공이 있다고 생각 해봅시다. 그리고 빨간공은 바구니의 바닥지점을 찾으려고 시도한다고 해봅시다. 이것이 바로 최적화 과정입니다. 이 경우에는, 공은 공의 위치를 (왼쪽에서 오른쪽으로 움직이며) 바구니에서 가장 낮은 지점을 찾으려 최적화하려고 합니다.

(여기서 잠깐 정지하시고… 마지막 문장을 확실하게 이해하세요…. 되셨나요?)

자 이제 이것들을 게임처럼 생각해보죠. 공은 두 가지 옵션이 있습니다. 왼쪽 또는 오른쪽. 그리고 최대한 아래로 가고자하는 단 하나의 목표가 있습니다. 그래서, 가장 낮은 곳을 정확하게 찾을 수 있도록 왼쪽과 오른쪽 버튼을 누르는 것이 필요합니다.

경사하강법

그럼, 공은 가장 낮은 지점을 찾기 위해서 어떤 정보를 사용해서 공의 위치를 조정할까요? 현재 공이 있는 지점의 바구니 밖의 기울기가 유일한 정보이고, 아래 그림에서는 파란색 선이 이를 의미합니다. 만약 기울기가 음수라면(왼쪽에서 오른쪽으로 기울여 진 것), 공은 오른쪽으로 움직일 것입니다. 그러나 기울기가 양수라면, 공은 왼쪽으로 움직일 것입니다. 그림에서 볼 수 있듯, 몇 번의 반복을 통해 바구니의 바닥지점을 찾는 데는 충분히 많은 정보입니다. 이는 최적화의 부분으로 Gradient optimization라 일컫습니다. (기울기는 단순히 경사나 가파름의 좀 더 멋져보이는 용어일 뿐이다.)

완전 간단하게 경사 하강법을 설명하자면
  • 현재위치의 경사를 구한다.
  • 경사가 음수이면, 오른쪽으로 움직인다.
  • 경사가 양수이면, 왼쪽으로 움직인다.
  • (경사가 0이 될 때까지 반복한다)

하지만, 문제는 각 시간 단계마다 얼마나 공이 움직이냐는 것입니다. 다시 바구니를 봅시다. 경사가 가파를수록, 공은 바닥에서부터 멀리 떨어져있습니다. 유용한 정보입니다! 이 새로운 정보를 우리 알고리즘에 얹어서 좀 더 향상시켜봅시다. 또, 바구니가 (x,y) 좌표 위에 올려져있다고 생각해봅시다. 그러면, 위치는 x(바닥 면)입니다. 공의 “x”값을 증가시키면 오른쪽으로 움직이고. 공의 “x”값을 줄이면 왼쪽으로 움직입니다.

경사 하강법을 간단하게 보면,
  • 현재 “x” 위치의 “경사”를 계산한다. = 경사값
  • x 값을 경사의 -값으로 바꾼다. (x=x-경사값)
  • (경사가 0이 될 때까지 반복한다)

다음으로 넘어가기 전에 이 과정들을 머리 속에서 그릴 수 있는 지 생각 해 보시길 바랍니다. 이 방법이 우리 알고리즘을 상당히 향상시키는 방법입니다. 매우 가파른 양의 기울기라면, 왼쪽으로 더 많이 움직일 수 있습니다. 아주 작은 양의 기울기라면, 아주 조금만 움직일 수 있을 것입니다. 그리고 조금씩 조금씩 바닥에 가까워질 수록, 단계들도 조금씩 경사가 0이 될 때까지 작아집니다. 그리고 어느 순간 멈춥니다. 이 멈추는 지점을 수렴점 이라 부릅니다.

단계 3: 어떤 때는 무너진다

경사하강법은 완벽하지 않습니다. 여기에 관련된 문제들을 살펴보고, 사람들이 어떻게 처리하는지 살펴봅시다. 이는 우리 네트워크의 몇 가지 문제점들을 극복하고 향상시킬 수 있을 것입니다.

문제 1: 언제 경사가 커지는가

얼마나 큰 게 진짜 큰 것일까요? 각 단계의 크기는 경사의 기울기를 기준으로 만든다는 것을 명심합니다. 어쩔 때는 너무 가팔라서 지나치게 커지는 경우가 있을 수 있습니다. 조금 커진 경우는 괜찮지만 너무 커져서 벗어나면, 심지어 시작했던 지점보다 더 멀리갈 때도 있습니다!

경사 너무 멀리감

반대방향에서 심지어 더 가파른 기울기를 가진다면 경사가 지나치게 커져서 매우 치명적인 문제가 발생할 수 있습니다. 이는 다시 지나치게 커지게 되서 공을 더 멀리 보내버립니다. 이런 더 지나친 악성사이클은 공을 더 멀리 보내버리는 데, 이를 발산(divergence)이라고 한다.

해결책 1: 경사를 더 작게 만들기

우와! 진짜 해결책으로 보기는 너무 간단 해보이지만, 모든 뉴럴네트워크에서 꽤나 많이 사용하고 있습니다. 만약 경사가 너무 크면, 작게 만들면 됩니다! 모두 0과 1사이의 하나의 수(예를 들면 0.01)를 곱해서 작게 만들 수 있습니다. 이는 일반적으로 alpha라 불리는 하나의 유리수 값입니다. 이를 통해, 값이 지나치게 커져버리는 문제를 막고, 네르워크를 수렴시킵니다.

경사 너무 멀리갔을 때 해결책

경사하강법 향상시키기:
  • alpha = 0.1 (혹은 0과 1의 사이의 어떤 숫자)
  • 현재 “x”좌표의 “기울기” 구하기.
  • x = x-(alpha*slope)
  • (경사가 0이 될 때까지 반복한다)

문제 2: 극솟값들

어떤 경우에는 바구니가 우스꽝스러운 모양이어서, 경사를 따라가는 것이 절대적인 최저점에 데려다 주지 않을 수도 있습니다. 아래 그림을 보죠.

극솟값 그림

이것이 아마 경사하강법에 있어 가장 어려운 문제일 겁니다. 이를 극복키 위한 무수히 많은 방법들이 있습니다. 일반적으로 보면, 모든 방법들이 바구니의 많고 많은 부분들을 무작위로 요소들을 탐색하는 방법을 포함합니다.

해결책 2: 다양하고 무작위적인 시작상태값

극솟값 문제에 부닺히는 문제를 극복키위해 무작위를 활용하는 방법도 매우 많습니다. 문제의 시작점으로 돌아와서 만약 우리가 최소값(global minimum)을 찾기 위해서 무작위를 사용해야한다면, 왜 처음부터 최적화를 해야하는걸까요? 그냥 무작위로 시도하면 안되는 것일까요? 아래 그래프에 답이 나와있습니다.

극솟값 그림

무작위로 100개의 공들을 선 위에 놓아두고 모든 부분 최적화를 시작한다고 생각해봅시다. 그렇게 했다면, 위 그림에서 보듯 색깔이 다른 공 5개가 매핑된 위치에서 멈출 것입니다. 색칠된 각각의 지역은 각 지역의 극솟값의 영역을 대변합니다. 예를 들면, 만약 공이 파란색 영역에 떨어진다면 파란색 극솟값으로 수렴할 것입니다. 이 의미는 모든 위치들을 다 확인해본다고 하더라도, 오직 무작위적으로 5개의 영역을 발견할 것이라는 의미입니다! 이것은 무작위적으로 정말 모든 장소(각각의 입상에 따른 검은 선 안에 수백만 개의 장소들이 있다고 생각하면 쉽습니다)를 시도하는 순수한 무작위 검색보다는 훨씬 낫습니다.

뉴런 네트워크 안에서 : 뉴럴네크워크가 이 일을 가능케하는 한 방법은 매우 많은 히든 레이어들을 가지는 것입니다. 보셨듯, 레어어 안의 각각의 히든 노드를은 서로 다른 무작위적인 시작 상태에서 시작합니다. 이는 각각의 히든 노드들이 네트워크안의 서로다른 패턴으로 수렴하게 만듭니다. 이 크기로 파라미터화를 하면 뉴럴네크워크 유저에게 잠재적으로 수천개의(혹은 수십억개) 서로 다른 극솟점을 시도할 수 있게 만들어줍니다.

추가적으로 1:로 이것이 바로 뉴럴 네트워크가 매우 효과적인 이유입니다! 실제 계산 가능한 영역보다 훨씬 더 많은 공간을 탐색할 수 있는 능력이 있습니다. (이론적으로는) 5개의 공으로 위에 있는 검은 선의 모든 부분을 탐색할 수 있고 반복횟수도 몇 안 됩니다. 같은 공간을 무식하게 모두 하나하나 탐색하는 것은 엄청난 규모의 계산 횟수를 만듭니다.

추가적으로 2: 눈을 감고 생각해보면, “왜 수많은 노드가 같은 공간을 수렴하도록 놔둘까? 컴퓨터 자원을 낭비하는 것 아닌가?”라는 생각이 들 수 있습니다. 진심으로 좋은 포인트입니다. 최근 기술 동향을 보면, (같은 공간을 탐색함으로써) 같은 대답이 나오는 히든 노드를 피하는 Dropout, Drop-connect가 연구되고 있습니다. 추후 또 다른 글에서 다루어 보도록 하겠습니다.

문제3: 경사가 너무 작을 때

뉴럴 네트워크는 경사가 너무 작아서 문제가 될 때도 있습니다. 해결방법 또한 확실하지만, 이 현상에 대해서 좀 더 확장해서 이야기 해보도록 하겠습니다. 아래와 같은 그래프를 보죠

경사 작은 그림

우리의 작은 빨간 공이 꼼짝도 못하고 있습니다! 알파가 너무 작으면 이런 일이 발생할 수 있습니다. 공을 즉각적인 극솟점에 떨어드려주면 큰 그림에서는 무시할 수 있습니다. 구불구불한 곳에서 빠져나오기 위한 멋진 것이 없습니다.

경사 작은 그림2

그리고 확실히 델타가 너무 작아서 수렴점에 이르기끼자 너무 오랜시간이 걸리는 현상으로 보입니다.

해결책 3: 알파를 증가시키기.

답을 예상했듯, 문제가 있는 두 현상 모두 알파값을 키워줌으로써 해결할 수 있습니다. 1보다 큰 웨이트로 하여금, 델타와 곱할 수도 있습니다. 정말 드물지만 가끔씩 일어나는 일들입니다

단계 4: 뉴럴네트워크 내의 SGD

지금쯤 오면, 의문점이 좀 들 수도 있을 것입니다. 이것들이 어떻게 뉴럴네트워크와 역전파 법과 관계가 있어보이는 지 말입니다. 이 부분이 가장 어려운 부분이기에, 심호흡 하고 찬찬히 읽어보시길 바랍니다. 또한 꽤 중요한 부분이기도 합니다.

sgd

이 크고 형편없는 곡선은 뭘까요? 뉴럴네트워크 안에서, 웨이트를 참고한 에러를 최소화하려고 합니다. 그래서 이 곡선은 각 웨이트의 지점에서 상대적인 네트워크의 에러입니다. 그렇기에 우리가 각각의 웨이트에서 모든 가능한 변수를 위한 네트워크의 에러를 계산한다면, 위에서 보이는 곡선이 만들어집니다. 그러면 최소 에러((곡선에서 가장 낮은 지점))를 가지고 있는 웨이트 하나를 선택할 수 있을 것입니다. 웨이트 하나라고 말한 이유는 2차원 좌표이기 때문입니다. 그러므로 웨이트가 어떤 지점에 있다 했을 때, x차원은 웨이트의 변수이고 y차원은 뉴럴 네트워크 에러일 것입니다.

‘잠깐 멈춰서서 마지막 단락을 확실하게 이해하고 가세요. 이것이 바로 핵심입니다!’

그러면, 간단히 두 레이어로 구성된 뉴럴 네트워크에서는 어떻게 동작하는지 살펴봅시다.

2레이어 뉴럴네트워크

import numpy as np # 1번째 줄

# 시그모이드 비선형을 계산한다. 
def sigmoid(x): # 4번째 줄
    output = 1/(1+np.exp(-x)) # 5번째 줄
    return output # 6번째 줄

# 시그모이드 함수 결과값을 미분값으로 전환하기.
def sigmoid_output_to_derivative(output): # 9번째 줄
    return output*(1-output)

# 인풋 데이터 셋
X = np.array([ [0,1], # 13번째 줄
                [0,1],
                [1,0],
                [1,0] ])

# 결과 데이터 셋
y = np.array([[0,0,1,1]]).T # 19번째 줄 

# 실험의 편의를 위해 항상 같은 값이 나오게 함
np.random.seed(1) # 23번째 줄

# 웨이트들을 평균 0인 수들로 무작위로 초기화
synapse_0 = 2*np.random.random((2,1))-1 # 26번째 줄

for iter in range(10000): # 28번째 줄

    # 먼저 전파하기 # 30번째줄
    layer_0 = X 
    layer_1 = sigmoid(np.dot(layer_0, synapse_0))
                      
    # 얼마나 놓쳤을까? # 34번째줄
    layer_1_error = layer_1 - y # 35번째 줄

    # l1 안에서 값에서의 시그모이드 경사와 놓친 값들을 곱해주기 # 37번째 줄
    layer_1_delta = layer_1_error * sigmoid_output_to_derivative(layer_1) # 39번째 줄
    synapse_0_derivative = np.dot(layer_0.T, layer_1_delta)

    # 웨이트 업데이트
    synapse_0 -= synapse_0_derivative

print ('트레이닝 후 결과는')
print (layer_1)

이 경우에는, 결과(값 1개)값에서는 35번째 줄에서 계산된 에러 하나만 존재합니다. 만약 웨이트 2개를 가지면 결과 ‘에러 좌표(error plane)’는 3차원 공간에 존재합니다. 이것을 (x,y,z) 좌표에서 생각해보면, 수직좌표가 에러이고 x와 y값은 syn0에서의 두 웨이트 값입니다.

위 네트워크/데이터에서 에러 좌표의 형상이 어떤 것인지 그림으로 살펴봅시다. 그렇다면 주어진 웨이트 집합에서 에러를 어떻게 계산할까요? 31, 32, 35번째 줄이 이를 보여줍니다. 가능한 모든 (x,y가 -10에서 10까지) 총체적인 에러를 좌표화하고 로직을 취한다면(스칼라 하나가 모든 데이터 셋의 네트워크 에러를 대표한다.) 아래 그림과 같을 것입니다.

3d_error_plane

겁내지 마시라. 단순하게 가능한한 모든 웨이트의 집합들을 계산해서 각 집합에서 네트워크가 만드는 에러를 보여준 것일 뿐입니다. x는 첫번째 synapse_0 웨이트이고, y는 두 번째 synapse_0 웨이트입니다. z는 전반적인 에러들 의미합니다. 위에서 봤 듯, 결과 데이터는 첫번째 인풋 데이터 값과 양의 상관 관계에 있습니다. 그러므로, 에러는 x(첫번째 synapse_0 웨이트)가 높을 때 에러가 최소화됩니다. 그러면 두번째 synapse_0 웨이트는 무엇일까요? 어떻게 최적화할 수 있을까요?

2 레이어 뉴럴 네트워크를 최적화하는 방법

31, 32, 35번째 줄이 에러를 계산합니다. 그렇다면 39, 40, 43번째 줄이 자연스럽게 에러를 줄이고 최적화하는 데 활용되었다는 것을 알 수 있습니다. 이것이 바로 경사하강법이 하는 일입니다! 수도코드가 기억나시나요?

경사 하강법을 간단하게 보면,
  • 39, 40번째 줄: 현재 “x” 위치의 “경사”를 계산한다.
  • 43번째 줄: x 값을 경사의 -값으로 바꾼다. (x=x-slope)
  • 28번째 줄: (경사가 0이 될 때까지 반복한다)

완전히 같은 것입니다! 오직 다른 하나는 웨이트를 하나가 아니라 2개를 가지고 있다는 것입니다. 하지만 로직은 완전히 동일합니다.

단계 5: 뉴럴네트워크를 향상시키기.

경사하강법은 몇 가지 약점을 가지고 있습니다. 이제 우리는 뉴럴네트워크가 경사하강에 얼마나 의존하는 지 보았으므로, part3의 경사하강법과 (문제와 해결책 3개씩) 같은 방법으로 약점을 넘어 네트워크를 향상시켜보자.

향상법 1: 알파 파라미터를 추가하고 튜닝하기

알파란 무엇일까요? 위에 적었듯, 알파값은 각각의 반복 업데이트의 크기를 줄이는 가장 단순한 방법입니다. 웨이트를 업데이트 시키기 바로 직전의 매우 짧은 시간동안 알파와 곱합으로써 웨이트를 업데이트 시킬 수 있습니다.(보통은 0과 1사이로 하므로, 웨이트 업데이트시 크기는 작아집니다). 코드에 몇 줄 안되는 코드의 변화만으로 훈련 능력에 실로 엄청난 양향을 미칩니다.

그럼 첫 번째 글의 3개 레이어의 뉴럴네트워크로 돌아가서, 적절한 위치에 알파 파라미터를 추가 해봅시다. 그리고 직관적으로 만들어진 실험코드들을 실행시켜보며 알파값으로 라이브 코드를 발전시켜봅시다.

경사하강법 향상시키기:
  • 현재 “x” 위치의 “경사”를 계산한다.
  • 56-57번째 줄 x 값을 경사의 -값으로 바꾼다. (x=x-slope)
  • (경사가 0이 될 때까지 반복한다)
import numpy as np

alphas = [0.001, 0.01, 0.1, 1, 10, 100, 1000]

# 시그모이드 비선형성을 사용해서 계산한다. 

import numpy as np

alphas = [0.001, 0.01, 0.1, 1, 10, 100, 1000]

# 시그모이드 비선형성을 사용해서 계산한다. 

def sigmoid(x): 
    output = 1/(1+np.exp(-x))
    return output

# 시그모이드 함수의 결과값을 이용해 미분값으로 변환한다. 

def sigmoid_output_to_derivative(output):
    return output*(1-output)

X = np.array([[0,0,1],
             [0,1,1],
              [1,0,1],
              [1,1,1]
             ])

y = np.array([[0],
             [1],
             [1],
             [0]])

for alpha in alphas:
    print ("\n알파값을 이요한 트레이닝 "+ str(alpha))
    np.random.seed(1)

    # 랜덤적으로 웨이트값들을 평균 0으로 초기화시킨다.
    synapse_0 = 2*np.random.random((3,4)) - 1 
    synapse_1 = 2*np.random.random((4,1)) - 1
    
    for j in range(60000):
        # 레이어 0, 1, 2로 값을 준다.
        layer_0 = X
        layer_1 = sigmoid(np.dot(layer_0, synapse_0)) 
        layer_2 = sigmoid(np.dot(layer_1, synapse_1))
        
        # 얼마나 놓쳤을까?
        layer_2_error = layer_2 - y
        
        if (j%10000) == 0:
            print ("에러 후 " + str(j) + "반복 횟수" + str(np.mean(np.abs(layer_2_error))))
        
        # 타겟 내의 방향은 무엇인가요?
        # 정말 확신할 수 있나요? 그렇다면 많이 바꾸면 안된다. 
        
        layer_2_delta = layer_2_error*sigmoid_output_to_derivative(layer_2)
        
        
        # (웨이트에 따르면) 얼마나 각각의 l1 값들은 l2에러에 기여했을까? 
        layer_1_error = layer_2_delta.dot(synapse_1.T)
        
        # l1 타겟 내의 방향은 무엇인가요?
        # 정말 확신할 수 있나요?
        
        layer_1_delta = layer_1_error * sigmoid_output_to_derivative(layer_1)
        
        ##### 여기가 중요!!! 바뀐 부분
        synapse_1 -= alpha * (layer_1.T.dot(layer_2_delta)) # 56번째 줄
        synapse_0 -= alpha * (layer_0.T.dot(layer_1_delta)) # 57번째 줄

그러면 서로 다른 크기의 알파로 무엇을 알 수 있을까요?

알파 = 0.001 알파값이 너무 작은 네트워크는 거의 수렴할 수 없습니다. 너무 조금씩 업데이트되는 웨이트들을 만들기 때문에 아무것도 바뀌지 않습니다. 60,000번의 반복에도 불구하고 말입니다! 이 글의 문제 3: 경사가 너무 작을 때 로 연결됩니다.

알파 = 0.01 이 알파값은 좀 더 나은 수렴값을 보여줍니다. 기실, 60,000번의 반복끝에 꽤 부드러운 곡선을 보여주지만, 궁극적으로는 멀리 있는 몇몇 것들은 수렴하지 않습니다. 이 문제 또한 문제 3: 경사가 너무 작을 때 에서 확인할 수 있습니다.

알파 = 0.1 이 알파값는 빠른 속도로 꽤 나은 진전을 보여주지만 그 이후에는 조금 느려집니다. 아직까지 문제3에 해당하는 문제입니다. 알파값을 조금 더 증가시켜야 합니다.

알파 = 1 이미 똑똑한 분들은 눈치 채셨듯, 알파가 없을 때와 완전히 동일한 수렴된 결과를 보여줍니다! 웨이트에 1을 곱하는 것은 아무것도 변하지 않습니다 :)

알파 = 10 이 글을 읽고 있는 독자들께서는 단순히 10,000번의 반복만 넘어서도 알파값이 1일 때의 경우를 넘어서는 좋은 결과를 보이는 것에 놀랄 것입니다! 작은 알파와 함께한 웨이트일수록 매우 보수적으로 올라갑니다. 이 의미는, (10보다 작은) 작은 알파 파라미터값에서 네트웨크가 맞는 방향으로 놓여져있다면 단순히 기폭제만 필요할 뿐이라는 것입니다!

알파 = 100 이 단계를 보면, 알파 값이 너무 크면 오히려 역효과를 낳음을 볼 수 있습니다. 네트워크의 단계들이 너무 커지면 에러 평면에서 적절한 저점을 찾을 수 없습니다. 이 글의 문제1 과 연관된 것입니다. 알파가 너무 크면 에러 평면위를 뛰어넘기 때문에 적절한 극솟점에 “안착”할 수 없습니다.

알파 = 1000 너무 커진 알파값은 본 글의 여러 문제에 대해 보셨듯 에러가 줄어들지 않고 커져버립니다… 0.5를 훌쩍 넘겨버립니다. 이 문제는 전반적으로 과잉교정되고, 극솟점으부터 굉장히 멀어지는 문제3의 극단적인 버전인 듯합니다.

좀 더 자세히 살펴보기

import numpy as np

alphas = [0.001, 0.01, 0.1, 1, 10, 100, 1000]

# 시그모이드 비선형을 사용해서 계산한다. 
def sigmoid(x): 
    output = 1/(1+np.exp(-x))
    return output

# 시그모이드 함수의 결과값을 이용해 미분값으로 변환한다. 
def sigmoid_output_to_derivative(output):
    return output*(1-output)

X = np.array([[0,0,1],
              [0,1,1],
              [1,0,1],
              [1,1,1]
             ])

y = np.array([[0],
             [1],
             [1],
             [0]])

for alpha in alphas:
    print ("\n알파값을 이용한 트레이닝 "+ str(alpha))
    np.random.seed(1)
    
    # 랜덤적으로 웨이트값들을 평균 0으로 초기화시킨다.
    synapse_0 = 2*np.random.random((3,4)) - 1 
    synapse_1 = 2*np.random.random((4,1)) - 1
    
    prev_synapse_0_weight_update = np.zeros_like(synapse_0)
    prev_synapse_1_weight_update = np.zeros_like(synapse_1)
    
    synapse_0_direction_count = np.zeros_like(synapse_0)
    synapse_1_direction_count = np.zeros_like(synapse_1)
    
    for j in range(60000):
        # 레이어 0, 1, 2로 값을 준다.
        layer_0 = X
        layer_1 = sigmoid(np.dot(layer_0, synapse_0)) 
        layer_2 = sigmoid(np.dot(layer_1, synapse_1))
        
        # 얼마나 target 값들을 놓쳤을까?
        layer_2_error = y - layer_2
        
        if (j%10000) == 0:
            print ( "에러: " + str(np.mean(np.abs(layer_2_error))) ) 
        
    
        # 타겟 내의 방향은 무엇일까?
        # 우리가 정말 맞을까? 만약, 그렇다면 많이 바꾸지 않아야한다. 
        layer_2_delta = layer_2_error * sigmoid_output_to_derivative(layer_2)
    
        # (웨이트에 의하면) 각각의 l1 값들은 얼마나 l2에러에 영향을 미쳤을까?
        layer_1_error = layer_2_delta.dot(synapse_1.T)
        
        # 타겟 l1 내의 방향은 무엇일까?
        # 우리가 정말 맞을까? 만약, 그렇다면 많이 바꾸지 않아야한다. 
        layer_1_delta = layer_1_error * sigmoid_output_to_derivative(layer_1)
        
        synapse_1_weight_update = (layer_1.T.dot(layer_2_delta))
        synapse_0_weight_update = (layer_0.T.dot(layer_1_delta))
    

        if (j > 0):
            synapse_0_direction_count += np.abs(((synapse_0_weight_update > 0)+0) - ((prev_synapse_0_weight_update > 0) + 0))
            synapse_1_direction_count += np.abs(((synapse_1_weight_update > 0)+0) - ((prev_synapse_1_weight_update > 0) + 0))  
            
        synapse_1 += alpha * synapse_1_weight_update
        synapse_0 += alpha * synapse_0_weight_update
        
        prev_synapse_0_weight_update = synapse_0_weight_update
        prev_synapse_1_weight_update = synapse_1_weight_update
        
    print ("synaspe 0은 \n", synapse_0)
    
    print ("synapse 0 방향 전환을 업데이트하면, \n", synapse_0_direction_count)
    
    print ("Synapse 1은 \n ", synapse_1)
    
    print ("synapse 1 방향 전환을 업데이트하면, \n ", synapse_1_direction_count)

위 코드에서 방향을 바꾸는 미분 횟수를 헤아리는 일을 했습니다. “방향 전환 업데이트” 라고 트레이닝 맨 마지막에 출력시킨 부분이 그 부분입니다. 경사(미분값)가 바뀐다는 것은, 극솟값을 넘어서기 때문에 되돌아갈 필요가 있습니다. 방향이 계속 바뀌지 않는다면, 충분히 멀리 가지 않았기 때문일 것입니다.

몇 가지 짚고 넘어가야할 것들:
  • 알파값이 매우 작으면, 미분하더라도 방향은 거의 바뀌지 않습니다.
  • 알파값이 최적값이라면, 미분하면서 방향이 여러번 바뀝니다.
  • 알파값이 매우 커지면, 미분하면서 방향이 중간값으로 바뀝니다.
  • 알파값이 매우 작으면, 웨이트 또한 상당히 작아지면서 마무리 됩니다.
  • 알파값이 매우 커지면, 웨이트 또한 매우 커집니다!

향상법 2: 히든 레이어의 크기들을 파라미터화 하기

히든레이어의 크기를 키우는 것이 가능하다면, 각각의 반복 또한 수렴하는 검색범위를 증가시킬 수 있습니다. 아래의 네트워크와 그에 따른 결과로 생각 해보죠.

import numpy as np

alphas = [0.001, 0.01, 0.1, 1, 10, 100, 1000]
hiddenSize = 32

# 시그모이드 비선형성을 사용해서 계산한다. 
def sigmoid(x): 
    output = 1/(1+np.exp(-x))
    return output

# 시그모이드 함수의 결과값을 이용해 미분값으로 변환한다. 
def sigmoid_output_to_derivative(output):
    return output*(1-output)

X = np.array([[0,0,1],
             [0,1,1],
              [1,0,1],
              [1,1,1]
             ])

y = np.array([[0],
             [1],
             [1],
             [0]])

for alpha in alphas:
    print ("\n알파값을 이용한 트레이닝 "+ str(alpha))
    np.random.seed(1)
    
    # 랜덤적으로 웨이트값들을 평균 0으로 초기화시킨다.
    synapse_0 = 2*np.random.random((3, hiddenSize)) - 1
    synapse_1 = 2*np.random.random((hiddenSize, 1)) - 1
    
    for j in range(60000):
        # 레이어 0, 1, 2로 값을 준다.
        layer_0 = X
        layer_1 = sigmoid(np.dot(layer_0, synapse_0)) 
        layer_2 = sigmoid(np.dot(layer_1, synapse_1))
        
        # 얼마나 target 값들을 놓쳤을까?
        layer_2_error = layer_2 - y
        if (j % 10000) == 0:
            print("에러 이후 " + str(j) + " 반복 후: " + str(np.mean(np.abs(layer_2_error))))

        
        # 타겟 내의 방향은 무엇일까?
        # 우리가 정말 맞을까? 만약, 그렇다면 많이 바꾸지 않아야한다. 
        layer_2_delta = layer_2_error * sigmoid_output_to_derivative(layer_2)
        
        # (웨이트에 의하면) 각각의 l1 값들은 얼마나 l2에러에 영향을 미쳤을까?
        layer_1_error = layer_2_delta.dot(synapse_1.T)
        
        # 타겟 l1 내의 방향은 무엇일까?
        # 우리가 정말 맞을까? 만약, 그렇다면 많이 바꾸지 않아야한다. 
        layer_1_delta = layer_1_error * sigmoid_output_to_derivative(layer_1)
        
        synapse_1 -= alpha * (layer_1.T.dot(layer_2_delta))
        synapse_0 -= alpha * (layer_0.T.dot(layer_1_delta))

노드 32개 기준으로 에러 중 최고는 0.0009임을 감안하면, 히든 노드 4개 기준으로 에러 중 최고는 0.0013밖에 되지 않습니다. 이것이 많아 보이지는 않지만, 매우 중요한 배움입니다. 이 데이터 셋을 대표하기 위해서는 단순히 3개의 노드밖에 필요하지 않다는 말입니다. 그러나 시작 할 때보다는 더 많은 노드를 가지고 있기 때문에, 각각의 반복동안 더 많은 범위를 검색하고 실제로는 더 빨리 수렴할 수 있게 합니다. 비록, 이 장난감 문제에서는 매우 미비한 것 처럼 보이지만, 모델링된 정말 복잡한 데이터집합에서는 매우 커다란 역할을 하며 영향을 끼칠 것입니다.

단계 6: 결론 및 앞으로 해야할 일들

제 추천

뉴럴 네트워크에 대해 진지하게 관심이 있다면, 추천할 것이 있습니다. 기억으로만 네트워크를 다시 만들어보세요, 이 말이 약간 바보처럼 들릴 수도 있겠지만 정말 도움이 될 것입니다. 새로운 논문에 나온 임의적인 아키텍처들을 만들고 싶거나 서로 다른 아키텍쳐들로 만들어진 샘플코드를 읽고 이해하고 싶다면 필시 최고의 방법인 것 같습니다. Torch, Caffe, Theano 같은 프레임워크를 사용한다해도 유용할 것입니다. 이런 연습을 하기 전에도 몇 년동안 뉴럴네트워크를 사용해왔는데, 이 연습에 대한 시간 투자가 제가 이 분야에 들어오고 가장 잘했던 투자였다고 생각합니다. (그리고 오래 걸리지도 않습니다.)

다음에 해야할 일

여태까지 쉽게 만들어 본 예시는 state-of-the-art 아키텍처를 만들기에는 아직 몇몇 것들이 더 필요합니다. 여기 네트워크를 향상시키고 싶다면 봐야할 몇 가지 것들을 소개합니다. (아마 다음 포스트에서 다룰 게 될 것입니다. )


댓글