Golang 으로 MNIST 신경망 구현하기

하설·2023년 8월 16일

참조

https://books.google.co.kr/books?id=C-u5DgAAQBAJ&printsec=frontcover&redir_esc=y#v=onepage&q&f=false

완성 코드

https://github.com/myeong01/neuralNet

들어가기에 앞서

위에 참조한 책은 Golang 과 전혀 무관하며 Python 을 사용하여 설명합니다.
하지만 Python 에서는 기본 패키지들조차 너무 편리한 기능을 제공해줘서 직접 하나하나 구현해보자 라는 생각은 무슨 사실 딱히 이유는 없고 재밌을 거 같아서 Golang 으로 구현합니다.

코드

function/sigmoid.go

Sigmoid 함수가 복잡하진 않아 라이브러리 검색해보고 코드가 맞는지 확인하는 것보다 직접 구현하는 것이 나을 것 같아서 직접 구현

package function

import "math"

func Sigmoid(x float64) float64 {
	return 1 / (1 + math.Exp(-x))
}

matrix/reverse.go

Transpose 라는 용어가 더 보편적으로 사용되나 해당 코드를 작성하는 시점에는 용어에 대해 잘 몰랐음

package matrix

func Reverse(a [][]float64) [][]float64 {
	b := make([][]float64, len(a[0]))
	for yIndex := range b {
		b[yIndex] = make([]float64, len(a))
		for xIndex := range b[yIndex] {
			b[yIndex][xIndex] = a[xIndex][yIndex]
		}
	}
	return b
}

matrix/multiply.go

와우! 행렬곱도 직접 구현

package matrix

import "fmt"

func Multiply(a [][]float64, b [][]float64) ([][]float64, error) {
	if len(a) == 0 || len(a[0]) == 0 || len(b) == 0 || len(b[0]) == 0 {
		return nil, fmt.Errorf("invalid array input")
	}
	if len(a[0]) != len(b) {
		return nil, fmt.Errorf("row length of first elem must be same as column length of second elem")
	}
	newMatrix := make([][]float64, len(a))
	for yIndex := range newMatrix {
		newMatrix[yIndex] = make([]float64, len(b[0]))
		for xIndex := range newMatrix[yIndex] {
			for iter := range b {
				newMatrix[yIndex][xIndex] += a[yIndex][iter] * b[iter][xIndex]
			}
		}
	}
	return newMatrix, nil
}

neuralnet/net.go

드디어 본격적으로 신경망을 구현합니다!!!
먼저 신경망 구조체를 정의해줍니다.
(확장성보다는 ML 학습의 차원에서 작성한거라 히든 레이어는 한 개로 하드코딩하여 정의하였습니다.)

// 신경망 구조체
type Net struct {
	// 입력 노드 갯수
	inputNodes int
	// 히든 노드 갯수
	hiddenNodes int
	// 결과 노드 갯수
	outputNodes    int
	learningRate   float64
	activeFunction func(float64) float64
	wih            [][]float64
	who            [][]float64
}

랜덤 Weight 초기 값 생성 함수

import "math/rand"

func newRandomWeightValue() float64 {
	var i float64
	for i = rand.NormFloat64() * 0.3; i >= 1; i = rand.NormFloat64() * 0.3 {
	}
	return i
}

신경망 구조체 초기화 함수

import "neuralNet/utils/function"

func New(inputNodes, hiddenNodes, outputNodes int, learningRate float64, activeFunction func(float64) float64) *Net {
	if activeFunction == nil {
		activeFunction = function.Sigmoid
	}
	return &Net{
		inputNodes:     inputNodes,
		hiddenNodes:    hiddenNodes,
		outputNodes:    outputNodes,
		learningRate:   learningRate,
		activeFunction: activeFunction,
		// 행렬곱의 경우 곱하기 연산자 기준 좌측의 행렬의 i 번째 행의 각 성분과 우측 행렬의 j 번째 열의 각 성분을 순서대로 곱하여 더한 것이 결과의 (i, j) 의 값이 되기 때문에 행렬의 길이는 아래와 같이 적용됨
		// (m x k) x (k x n) => (m x n)
		// 우리는 사용자의 input 값을 (inputNodes x 1) 로 정의한다면
		// <weight> x <input> => <result> 를
		// (m x n) x (inputNodes x 1) => (hiddenNodes x 1) 임을 알 수 있고
		// 곱하기 연산자 기준 좌측 행렬의 열의 수와 우측 행렬의 행의 수가 같아야되기 때문에 n은 inputNodes 임을 알 수 있다.
		// 또한 결과 값의 행의 수가 곱하기 연산자 좌측 행렬의 행의 수가 같아야되기 때문에 m은 hiddenNodes 임을 알 수 있다.
		// 따라서 위 수식은 (hiddenNodes x inputNode) x (inputNodes x 1) => (hiddenNodes x 1) 임을 알 수 있고
		// inputNodes -> hiddenNodes 사이의 Weight 행렬의 크기는 hiddenNodes x inputNodes 임을 알 수 있다.
		wih:            newWeight(hiddenNodes, inputNodes),
		who:            newWeight(outputNodes, hiddenNodes),
	}
}

신경망 학습시키기

import "neuralNet/utils/matrix"

func (n *Net) Train(rawData []float64, rawTargets []float64) error {
	data := [][]float64{rawData}
	// 처음 data 값이 (1 x inputNodes) 기 때문에 행렬을 뒤집어 (inputNodes x 1) 로 만들어 준다.
	data = matrix.Reverse(data)

	target := [][]float64{rawTargets}
	// 처음 target 값이 (1 x inputNodes) 기 때문에 행렬을 뒤집어 (inputNodes x 1) 로 만들어 준다.
	target = matrix.Reverse(target)

	// hiddenValue 를 계산하기 위해 weight x input 을 해줌 (행렬의 길이는 위에서 설명)
	hiddenValue, err := matrix.Multiply(n.wih, data)
	if err != nil {
		return err
	}
	// 값이 0과 1 사이에 들어와야되기 때문에 활성화 함수를 통하여 이를 맞춰줌
	for yIndex := range hiddenValue {
		for xIndex := range hiddenValue[yIndex] {
			hiddenValue[yIndex][xIndex] = n.activeFunction(hiddenValue[yIndex][xIndex])
		}
	}

	// finalValue 를 계산하기 위해 weight x hiddenValue 를 해줌
	finalValue, err := matrix.Multiply(n.who, hiddenValue)
	if err != nil {
		return err
	}
	// 값이 0과 1 사이에 들어와야되기 때문에 활성화 함수를 통하여 이를 맞춰줌
	for yIndex := range finalValue {
		for xIndex := range finalValue[yIndex] {
			finalValue[yIndex][xIndex] = n.activeFunction(finalValue[yIndex][xIndex])
		}
	}

	// target 값에서 우리가 추측한 값을 빼어 에러 값을 계산
	errors := make([][]float64, len(target))
	for yIndex := range errors {
		errors[yIndex] = make([]float64, len(target[yIndex]))
		for xIndex := range errors[yIndex] {
			errors[yIndex][xIndex] = target[yIndex][xIndex] - finalValue[yIndex][xIndex]
		}
	}
	// hiddenNode 의 에러의 경우 최종 output 값에 영향을 준만큼(weight) error 를 적용
	hiddenErrors, err := matrix.Multiply(matrix.Reverse(n.who), errors)
	if err != nil {
		return err
	}

	// error 값과 기대 값(finalValue), 영향을 미친 값(hiddenValue) 를 통하여 오차 수정 값을 계산
	temp := make([][]float64, len(errors))
	for yIndex := range temp {
		temp[yIndex] = make([]float64, len(errors[yIndex]))
		for xIndex := range temp[yIndex] {
			temp[yIndex][xIndex] = errors[yIndex][xIndex] * finalValue[yIndex][xIndex] * (1 - finalValue[yIndex][xIndex])
		}
	}
	delta, err := matrix.Multiply(temp, matrix.Reverse(hiddenValue))
	if err != nil {
		return err
	}
	// 계산된 오차 수정 값을 weight 에 적용
	for yIndex := range n.who {
		for xIndex := range n.who[yIndex] {
			n.who[yIndex][xIndex] += delta[yIndex][xIndex]
		}
	}

	// hiddenErrors 값과 기대 값(hiddenValue), 영향을 미친 값(input) 를 통하여 오차 수정 값을 계산
	temp = make([][]float64, len(hiddenErrors))
	for yIndex := range temp {
		temp[yIndex] = make([]float64, len(hiddenErrors[yIndex]))
		for xIndex := range temp[yIndex] {
			temp[yIndex][xIndex] = hiddenErrors[yIndex][xIndex] * hiddenValue[yIndex][xIndex] * (1 - hiddenValue[yIndex][xIndex])
		}
	}
	delta, err = matrix.Multiply(temp, matrix.Reverse(data))
	if err != nil {
		return err
	}
	// 계산된 오차 수정 값을 weight 에 적용
	for yIndex := range n.wih {
		for xIndex := range n.wih[yIndex] {
			n.wih[yIndex][xIndex] += delta[yIndex][xIndex]
		}
	}
	return nil
}

학습된 신경망으로 인퍼런스

import "neuralNet/utils/matrix"

func (n *Net) Query(rawData []float64) ([]float64, error) {
	data := [][]float64{rawData}
	// 처음 input 값이 (1 x inputNodes) 기 때문에 행렬을 뒤집어 (inputNodes x 1) 로 만들어 준다.
	data = matrix.Reverse(data)

	// hiddenValue 를 계산하기 위해 weight x input 을 해줌 (행렬의 길이는 위에서 설명)
	hiddenValue, err := matrix.Multiply(n.wih, data)
	if err != nil {
		return nil, err
	}
	// 값이 0과 1 사이에 들어와야되기 때문에 활성화 함수를 통하여 이를 맞춰줌
	for yIndex := range hiddenValue {
		for xIndex := range hiddenValue[yIndex] {
			hiddenValue[yIndex][xIndex] = n.activeFunction(hiddenValue[yIndex][xIndex])
		}
	}

	// finalValue 를 계산하기 위해 weight x hiddenValue 를 해줌
	finalValue, err := matrix.Multiply(n.who, hiddenValue)
	if err != nil {
		return nil, err
	}
	// 값이 0과 1 사이에 들어와야되기 때문에 활성화 함수를 통하여 이를 맞춰줌
	for yIndex := range finalValue {
		for xIndex := range finalValue[yIndex] {
			finalValue[yIndex][xIndex] = n.activeFunction(finalValue[yIndex][xIndex])
		}
	}
	// 최종 값이 (ouputNodes x 1) 기 때문에 행렬을 뒤집어 (1 x outputNodes) 로 만들어주고 일차원 배열로 반환해줌
	// 어차피 y 축의 길이가 1이기 때문에 손실되는 데이터는 없음
	result := matrix.Reverse(finalValue)
	return result[0], nil
}

main.go

길고 길었던 구현이 끝났으니 이제 사용해봅시다!
데이터는 mnist 를 사용했습니다.
추정 값들 중 가장 높은(값이 높은) 값(인덱스)가 추정하는 답이기 때문에 최대 값 Index 를 찾아야합니다.

func findMaxIndex(l []float64) int {
	maxValue := l[0]
	maxIndex := 0
	for index, value := range l {
		if maxValue < value {
			maxValue = value
			maxIndex = index
		}
	}
	return maxIndex
}

초기화, 학습과 성능 측정까지 하는 main 함수

import (
	"encoding/csv"
	"fmt"
	"neuralNet/neuralnet"
	"os"
	"strconv"
)

func main() {
	// mnist 데이터를 사용하여 inputNodes 는 784 이고 결과 값이 0 ~ 9 이기 때문에 outputNodes 는 10이며 hiddenNodes 는 임의로 100으로 설정
	n := neuralnet.New(784, 100, 10, 0.1, nil)

	// 학습 데이터셋 읽어오고 파싱하기
	trainFile, err := os.Open("./mnist_train.csv")
	if err != nil {
		panic(err)
	}
	defer trainFile.Close()
	trainSetReader := csv.NewReader(trainFile)
	rawTrainSet, err := trainSetReader.ReadAll()
	if err != nil {
		panic(err)
	}
	trainSet := make([][]float64, len(rawTrainSet))
	for yIndex := range trainSet {
		trainSet[yIndex] = make([]float64, len(rawTrainSet[yIndex]))
		for xIndex := range trainSet[yIndex] {
			trainSet[yIndex][xIndex], err = strconv.ParseFloat(rawTrainSet[yIndex][xIndex], 64)
			if err != nil {
				panic(err)
			}
			if xIndex != 0 {
				// 학습 값이 0으로 시작한다면 해당 학습에서 해당 Node는 어떠한 영향도 끼치지 않는 죽은 노드가 되기 때문에 이를 방지하고자 0과 가까운 값으로 설정
				// 0번째 인덱스는 학습 데이터가 아닌 정답 데이터기 때문에 해당 계산 부분에서 제외
				trainSet[yIndex][xIndex] = (trainSet[yIndex][xIndex] / 255 * 0.99) + 0.01
			}
		}
	}
	// 학습 시작
	fmt.Println("Start Train")
	// 에폭은 5로 주어 동일한 데이터셋으로 5회 반복 학습 진행
	epoch := 5
	for i := 0; i < epoch; i++ {
		fmt.Println("epoch", i, "start")
		for _, train := range trainSet {
			target := make([]float64, 10)
			// 활성화 함수 특성상 무한히 0으로 다가갈 뿐 0이 될 수 없음
			// 때문에 0으로 설정할 시 과도하게 피팅될 가능성이 있기 때문에 0이 아닌 0에 가까운 값으로 설정
			for index := range target {
				target[index] = 0.01
			}
			// 이 것도 위와 동일한 이유로 1에 가까운 값으로 설정
			target[int(train[0])] = 0.99
			// 첫 번째 인덱스는 정답(목표 값)이기 때문에 제거하고 학습 진행
			err := n.Train(train[1:], target)
			if err != nil {
				panic(err)
			}
		}
	}
	fmt.Println("Finish Train")

	// 테스트 시작
	// 테스트 데이터셋 읽어오고 파싱하기
	testFile, err := os.Open("./mnist_test.csv")
	if err != nil {
		panic(err)
	}
	defer testFile.Close()
	testSetReader := csv.NewReader(testFile)
	rawTestSet, err := testSetReader.ReadAll()
	if err != nil {
		panic(err)
	}
	testSet := make([][]float64, len(rawTestSet))
	for yIndex := range testSet {
		testSet[yIndex] = make([]float64, len(rawTestSet[yIndex]))
		for xIndex := range testSet[yIndex] {
			testSet[yIndex][xIndex], err = strconv.ParseFloat(rawTestSet[yIndex][xIndex], 64)
			if err != nil {
				panic(err)
			}
			if xIndex != 0 {
				// 테스트 값이 0으로 시작한다면 해당 테스트에서 해당 Node는 어떠한 영향도 끼치지 않는 죽은 노드가 되기 때문에 이를 방지하고자 0과 가까운 값으로 설정
				// 0번째 인덱스는 학습 데이터가 아닌 정답 데이터기 때문에 해당 계산 부분에서 제외
				testSet[yIndex][xIndex] = (testSet[yIndex][xIndex] / 255 * 0.99) + 0.01
			}
		}
	}
	var correctCnt int64 = 0
	for _, test := range testSet {
		answer, err := n.Query(test[1:])
		if err != nil {
			panic(err)
		}
		if int(test[0]) == findMaxIndex(answer) {
			correctCnt++
		}
	}
	fmt.Println("Accuracy :", float64(correctCnt)/float64(len(testSet)))
}

후기

실수로 테스트를 할 때 input 값을 0 ~ 1 사이로 계산하기 않고 기존 값을 바로 사용하여 성능이 0.1 정도 나왔어서 포기하고 싶었으나 이유를 발견하고 너무나도 허탈했습니다...ㅋㅋㅋㅋ 그래도 해당 부분 수정하니 성능이 0.8 중후반으로 올라서 기분이 좋았고 뿌듯했습니다 :)

profile
리자몽이 되고픈 파이리 데이터 엔지니어

1개의 댓글

comment-user-thumbnail
2023년 8월 16일

잘 읽었습니다. 좋은 정보 감사드립니다.

답글 달기