소프트웨어 개발/Scala - Functional

스칼라로 인공신경망 맛보기 구현

늘근이 2017. 11. 25. 13:15

대부분의 딥러닝 참고자료는 파이썬이 제일 많을수밖에 없고, 그다음이 간단한것들은 R로 래핑한 라이브러리를 써서 실제적으로는 파이썬과 큰 차이가 없어보인다. 사실 파이썬래핑이 되어있는 라이브러리들도 충분히 빠르기 때문에 JVM에서 오는 이점이 크지는 않아 보이지만 자바 생태계는 기존에 존재하는 기업 소프트웨어등에서 큰 힘을 발휘하는데 실제적으로 소프트웨어를 만들어 팔아먹을때 큰 힘이 되지 않나 싶다.

스칼라는 개발에서는 완전히 대세는 아니지만 실무형 프로그래밍에서는 다음 세대의 언어로까지 최근까지도 언급이 되었으며, 안드로이드 개발 등에서는 조금더 실용적인 코틀린등이 언급되기는 하지만 그래도 어느정도 소프트웨어 개발로써는 생각해볼만한 언어정도는 되는 듯하다.

결국 박사님들이 창출해내신 기가막힌 딥러닝 알고리즘등은 기업환경에서 어떻게든 써먹어져야 하는데, 기존의 기업 시스템 생태계에 엄청나게 녹아든 JVM계열의 언어가 언급이 안될래야 안될수가 없는듯하다.

어쨌든, 인공신경망을 하나하나 구현하기 전 개념적으로만 깨달은 상태에서 여기저기 말하고 다니는건 조금 그렇기 때문에 일단 구현을 한다.


일단 인공신경망의 구조를 살펴본다.



이미지 픽셀이 3 * 3 이 있다고 하면, 이를 일렬로 주욱 풀은것이 input layer 와 같다고 생각한다.

배열로 따지면 위의 픽셀은

과 같은데, 이는 2차원 배열이다. 차원이 높을수록 대부분 계산이 힘들기 때문에 인공신경망에서는 이를 일렬로 만들어서 입력층에 그대로 넣는다. 위의 행렬을 일렬로 만들면 1, 1, 0, 0, 1, 0, 0, 1, 1 이 나란히 나란히 줄을 서서 있을 것이다. 

물론 사실 이미지에서 픽셀을 함부로 일렬로 늘어뜨리는것은 상식적으로 이미지 자체가 가지고 있는 상대적인 공간성을 희미하게 만들수 있다는 점이 있기 때문에 인공신경망만 써서 검출하는건 풀링을 통해 이미지구조를 여러 피처맵을 통해 그대로 살아서 가지고 있는 CNN계열보다 물론 제약적이다.

순전파 과정은 사실 프로그래밍적으로 고려할필요가 없이 프로토타입만 만들어본다면 아래와같이 값을 박아넣은 파이썬코드도 동작이 가능하다.


파이썬

def propagate(network, x) :

x = np.array([1.0, 0.5])
W = np.array([[1.0, 0.3, 0.5],[0.2,0.4,0.6]])
b = np.array([0.1, 0.2, 0.3])

z = sigmoid( np.dot(x, W) + b )


파이썬에서는 numpy가 존재하기 때문에 행렬을 가지고 더 쉽게 계산이 가능하다. 첫번째 출력층의 경우, 파랑색에서 온 값에 가중치를 곱해준 1 * 1 빨간색에서 넘어온 0.5 * 0.2 그리고 마지막으로 편향 0.1 을 모두 더한값이 출력값으로 튀어나온다. 마지막 시그모이드는 1 / 1 + exp(-x) 로 로직이 되어있으면 된다.

은닉층이면 sigmoid를 쓰거나 ReLU를 쓰면 되고, 마지막 출력층은 소프트맥스 함수를 통해 합해서 1이되게 맞춰주면 된다.

이렇게 간단한 코드는 스칼라로 가면 약간은 불편해 지는데, 사실은 하드코딩이 아니고 프로덕션 코드여야하기때문에 대부분의 값들을 변수로 바꿔야된다. 이건 당연하지만, 스칼라에서는 행렬을 바로 계산하기에는 Numpy의 완벽한 대체품은 잘 보이지 않고, ND4J등에서 제공하는 듯 싶다.


스칼라코드

def propagate(): Array[Double] = {

//이중배열에서 적용되어 있는 map형태의 한 컬럼이
//모두 sum으로 나옴. 즉, Vector -> Scalar 환산.
val out = synapseMap.drop(1).map(synapses => {
// 각 시냅스당, src.outputs로 저장되어있는
//x과 값과, W값을 곱해준 값을 모두 합해야함.
val sum = synapses.zip(src.output).foldLeft(0.0){(s, xW) =>
s + xW._1._1 * xW._2 }
// 출력층이면 그대로 출력하고,
// 그렇지 않으면 활성화 함수를 차용
if(!isOutLayer) activationFunction (sum) else sum
})
out
}

sysnapses가 가지고 있는 값을 W즉, 가중치의 리스트로 보고, src.output이 전 출력의 x집합이라고 보면 된다. 

xW 들을 잔뜩 계산한다고 보면 된다.


def backpropagate(): Unit = {
Range(1, src.length).foreach{ i =>
// 오류 미분값을 현재의 가중치W 로 내적
val err = Range(1, dest.length).foldLeft(0.0){(e1, e2) =>
e1 + synapseMap(e2)(i)._1 * dest.delta(e2) }

// 은닉계층의 가중치에 대해 오차를 미분한 값은, e * z * (1 - z)
src.delta(i) = src.outputs(i) *
(1.0 - src.outputs(i)) * err
}
}

역전파하는 로직도 은근히 간단한 편인데, 사실 눈에 확 잘 들어오는 편이 아니다. 역전파는 최종 출력에서의 오차에 기여한 만큼 이를 다시 가중치에 먹여줘서 업데이트를 해야하는데 이는 사실 이렇게 생각하면 쉽다.

예를들어 한 대기업에서 옥수수를 대홍단농장에서 100원에 팔고있고  마진을 10원 붙였다. 그리고 김일성농장에서 파는건 120원을 쳐줬는데 그리고 이에대가 정찰제로 50원을 붙여서 160원, 170원에 최종적으로 소비자에게 100개씩 팔아서 33000원을 벌었다. 하지만 우리가 목표로 하던 값은 4만원 매출이였다. 여기서 오차는 7천원이다. 이를 맞추기 위해 얼마나 농장하청들을 후려쳐서 더 값을 깎아야 하는가? 이를 그냥 그래프로 따라가면서 미분을 하게 되면 각각의 가중치에 대한 변화정도를 유추할수 있게 된다.

따라서 위의 로직을 보면, 계속해서 델타값을 적용해 오차를 구하고 있고 이를 미분 방정식에 넣어서 푸는것을 볼수있다.


def update: Unit = {
Range(1, dest.length).foreach { i =>
val delta = dest.delta(i)
Range(0, src.length).foreach { j =>
val grad = hyperparams.eta * delta * src.outputs(j)
synapseMap(i)(j) =
(synapseMap(i)(j)._1 + grad +
config.alpha * synapseMap(i)(j)._2, grad)
}
}
}


마지막으로 가중치를 업데이트만 해주면 되는데, 앞에서 구한 델타값과 하이퍼파라미터인 eta를 가지고 기울기 하강량을 구하고 이에 추가적으로 모멘텀 계수까지 고려해줘서 가중치값을 업데이트 하는 과정이다.


참고할만한 책은


1) 메인 - 스칼라와 기계학습 PACKT, 에이콘, 패트릭 니콜라스 지음

2) 부 - 밑바닥부터 시작하는 딥러닝, 한빛미디어, 사이토 고키 지음

첫번째 책은 그 판매량에 비해 내공이 보이는 책이며, 스칼라와 기계학습분야를 동시에 잡는 책이다.  두번째는 파이썬이 기반인 책이지만, 첫번째 책보다 훨씬 인공신경망 관련한 로직들이 자세하게 쓰여 있어서 2 -> 1순으로 보는 편이 낫다. 첫번째 책의 구현 수준은 굉장히 높은것으로 느껴진다.