파이썬 손코딩으로 하는 딥러닝 - MNIST

Mar 11, 2018 15:43 · 1991 words · 10 minute read deep-learning python

딥러닝에서 가장 중요한 알고리즘은 오차역전파입니다. 오차역전파 알고리즘은 비용 함수의 값을 떨어뜨리면서 가중치 값을 학습합니다. 본 포스트에서는 이 오차역전파를 철저히 분해해보려고 합니다. 전 처음 딥러닝을 공부할 때 모두를 위한 딥러닝 - 김성훈 교수님 강의를 많이 참고했습니다.

김교수님 강의는 초보자인 제게 딥러닝 개념을 쉽게 설명해주는 훌륭한 강의 였지만 강의에서는 TensorFlow를 이용해 신경망을 구현하기 때문에 실제 오차역전파 동작은 많이 추상화 되어 있어서 이해하기 힘든 부분이 있었습니다. 그리고 개발자의 고집이랄까요? 순수하게 파이썬 코드 만으로 신경망을 구현해보고 싶은 욕심이 생겼습니다. 그래서 순수 파이썬 코드로 신경망을 구현하기로 했습니다. 그것도 단 69줄 의 코드로!

주의. 본 포스팅은 독자가 딥러닝 개념에 대해 어느정도 이해하고 있다는 가정하에 출발하고 있습니다.

딥러닝을 처음 공부하거나, 아직 Gradient Descent, Cost Function, Sigmoid 함수 등의 개념이 익숙하지 않으면 다음 링크에서 1번 영상부터 23번까지 시청하시고 이 글을 읽으시면 이해하는데 도움이 될 겁니다. 모두를 위한 딥러닝 - 1강 수업 개요

오차역전파(Back-Propagation)

사실 오차역전파 알고리즘의 기원은 경사하강법에서 출발합니다. 경사하강법부터 대충 설명하자면 경사하강 알고리즘은 신경망이 예측한 값과 실제 값의 차이(트레이닝 데이터) 즉 비용을 줄이는 것을 목표로 합니다. 이 비용이란 단어가 어려우면 오차, 에러, 오류 등의 단어로 바꿔서 이해해도 좋습니다. 아무튼 이 비용을 정의하는 함수를 비용함수라고 합니다.

비용 함수

다양한 비용함수 중에서 위와 같이 정의한 비용을 평균제곱오차(Mean Squared Error)라고 부르는데 이 오차 값을 줄이기 위해 다음과 같이 경사하강 알고리즘을 쓰는 것이죠

경사하강법

자! 몇가지 미분지식과 이정도만 알고있으면 *94%의 정확도를 자랑하는 손글씨 인식 인공지능*을 개발할 수 있습니다. 그것도 순수하게 파이썬 날코딩으로요! 지금부터 우리의 목표는 신경망을 실제로 만드는 것이기 때문에 우선 신경망의 기본골격부터 만든 다음에 본격적으로 오차 역전파에 대해 다뤄보도록 하겠습니다.

준비하기

저는 프로그래밍 언어로는 Python 3을 사용했고 행렬계산에 강력한 라이브러리인 numpy를 사용햇습니다. 파이썬이 있으시다면 다음 명령어로 numpy를 설치하실 수 있습니다.

$ pip install —upgrade pip

$ pip install numpy

우리가 만들 신경망은 유능하게 손글씨로 쓴 숫자들을 분류하는 프로그램입니다. 학습에 사용할 데이터로는 MNIST를 이용할 것입니다. MNIST는 인공지능 연구의 권위자 LeCun교수님께서 만드신 데이터 셋이고요 현재 딥러닝을 공부할 때 반드시 거쳐야할 Hello, World같은 존재입니다. MNIST는 60,000개의 트레이닝 셋과 10,000개의 테스트 셋으로 이루어져 있고 이중 트레이닝 셋을 학습데이터로 사용하고 테스트 셋을 신경망을 검증하는 데에 사용합니다.

MNIST 데이터 셋

다음 링크에서 csv파일로 된 MNIST 데이터 셋을 다운받을 수 있습니다. Make Your Own Neural Network: The MNIST Dataset of Handwitten Digits

우선 학습 데이터가 어떤 모습인지 부터 볼까요? 아래 파이썬 코드로 데이터를 불러올 수 있습니다.

data_file = open("data/mnist_train.csv", "r")
training_data = data_file.readlines()
data_file.close()

test_data_file = open("data/mnist_test.csv", "r")
test_data = test_data_file.readlines()
test_data_file.close()

readlines() 메서드는 파일의 내용을 한줄씩 불러와서 문자열 리스트로 반환하는 함수입니다. 데이터를 불러온 다음에 아래처럼 데이터를 확인해봤습니다.

학습데이터의 모습

MNIST 학습데이터는 28x28 사이즈에 총 784개의 픽셀로 이루어진 흑백이미지 입니다. 컴퓨터는 흑백이미지를 표현할 때 픽셀의 숫자가 0에 가까울수록 검정색으로 255에 가까울수록 하얀색으로 표현하는데 위에 나열한 숫자가 바로 이미지의 색을 나타내는 정보입니다. 데이터 맨 앞에는 라벨이 붙습니다 이 이미지가 원래 무엇인지 알려주는 정보입니다. 이 경우에는 이 이미지의 라벨은 5입니다.

다음 코드로 위 정보가 어떻게 이미지를 표현하는지 자세히 알 수 있습니다.

import matplotlib.pyplot as plt
import numpy as np

t = np.asfarray(training_data[0].split(","))

# 일렬로 늘어진 784개의 픽셀정보를 28x28 행렬로 바꾼다
n = t[1:].reshape(28,28)

plt.imshow(n, cmap='gray')
plt.show()

학습데이터 시각화 하기

DeepNeuralNetwork

자! 이제 데이터를 불러오는 것도 했고 학습 데이터가 어떤 모습인지 감도 잡았습니다. 지금부터 신경망 구현에 사용할 클래스를 만들건데요. 이 클래스의 최종목표는 아래 코드처럼 사용해서 손글씨를 분류하는 프로그램을 만드는 겁니다.

network = DeepNeuralNetwork(input_layers=784, hidden_layers=100, output_layers=10)
network.test_data = test_data
network.train(training_data, lr=0.01, epoch=1)

생성자에는 구성할 신경망의 뉴런 수를 입력받을거고, 학습 중간중간에 진척도를 표현하는데 사용 할 테스트 데이터도 따로 지정해서 사용할 겁니다. 아래 코드 처럼요

import numpy as np

class DeepNeuralNetwork:
    def __init__(self, input_layers, hidden_layers, output_layers):
        self.inputs = input_layers
        self.hiddens = hidden_layers
        self.outputs = output_layers
        self.test_data = None

    # feed-forward를 진행한다.
    def predict(self, x):
        pass

    # training_data로 학습을 진행한다.
    def train(self, training_data, lr=0.01, epoch=1):
        pass

    # 현재 신경망의 정확도를 출력한다.
    def print_accuracy(self):
        pass

pass명령어를 사용한 함수는 추후에 구현할 겁니다. 일단 이 클래스의 구조부터 봅시다. 주석이 있으니 각 함수가 어떤 역할을 가졌는지는 이해하시겠죠? 이 코드에서 실제로 학습에 사용할 뉴런의 가중치값을 초기화 해야합니다. 신경망에서 가중치를 정할때 보통 랜덤으로 정합니다 굳이 랜덤으로 정하는 이유가 따로 있는데 이번 자세히 다루진 않을거고요. 짧게 설명하자면 연구자들은 이 가중치가 0으로 초기화하면 학습이 잘 잘안되네? 라는 걸 깨달았습니다. 그럼 0으로 주지말고 랜덤으로 주자가 결론인데 더 자세히 알고 싶으시면 이 링크를 참조하면 됩니다. 우리가 만들 신경망에선 Xavier/He 방법으로 가중치를 초기화했습니다.

def __init__(...):
    ...
    self.wih = np.random.randn(inputs, hiddens) / np.sqrt(inputs/2)
    self.who = np.random.randn(hiddens, outputs) / np.sqrt(hiddens/2)
    ...

wih는 인풋레이어에서 히든 레이어로, who는 히든레이어에서 아웃풋 레이어로 향하는 가중치입니다. 제가 가중치라고 1인칭 지시자를 사용하긴 했지만, 사실 wih는 굉장히 많은 가중치가 모인 행렬입니다.

network = DeepNeuralNetwork(input_layers=3, hidden_layers=2, output_layers=2)

신경망을 초기화 할 때 파라미터 값을 이와 같이 주면 신경망의 구성은 아래 모형과 같이 됩니다

3-2-2 layer

Sigmoid 함수

Activation함수로 쓸 Sigmoid함수를 추가해보겠습니다. Sigmoid함수의 수식과 코드는 아래와 같습니다. Sigmoid 함수의 수식

def sigmoid(self, x):
    return 1.0/(1.0 + np.exp(-x))

Normalize 함수

현재 학습 데이터의 범위가 0부터 255입니다. 이 값을 계속 연산하다보면 가끔씩 오버플로우가 나는 경우가 있는데, 그래서 데이터의 범위를 0과 1사이로 좁히는 노멀라이징 작업을 해야합니다.

def normalize(self, x):
    return (x / 255.0) * 0.99 + 0.01

데이터를 노멀라이징을 하는 이유가 꼭 오버플로우 문제때문만은 아니에요. 경사하강법 알고리즘을 진행할 때 일정한 성능으로 학습이 진행되게끔 하는 장치이기도 합니다. 이 작업을 또다른 말로 Feature Scaling 으로도 부릅니다.

피드 포워딩

인풋레이어에서 학습데이터를 받고 히든 레이어를 타서 아웃풋레이어로 결과가 나오는 이 과정을 피드 포워딩이라 불러요. 피드 포워딩으로 신경망이 데이터를 어떻게 분류하는지 나타내는거죠. 아까 학습데이터 첫째 줄은 5로 분류되었었죠?

학습데이터 시각화 하기

첫째 줄의 데이터로 피드포워딩을 한다면 이상적인 결과는 아래와 같이 나와야해요.

network.predict(training_data[0])
# [0.01, 0.01, 0.01, 0.01, 0.01, 0.99, 0.01, 0.01, 0.01, 0.01]

배열 인덱스 5번째 값이 99%로 가장 높습니다. 만약 5번째 값이 다른 값들보다 낮다면 제대로 학습된 신경망이 아니란 뜻이 됩니다. 이 피드포워딩은 아래와 같이 구현해 줍니다.

def predict(self, x):
    # 문자열을 float array로 바꾸는 과정
    data = self.normalize(np.asfarray(x.split(",")))

    # 0번은 라벨이기 때문에 날렸다.
    data = data[1:]

    layer_1 = self.sigmoid(np.dot(data, self.wih))
    output = self.sigmoid(np.dot(layer_1, self.who))
    return output

아까 우리 DeepNeuralNetwork 클래스 변수중에 test_data가 있었죠? 이 테스트 데이터로 현재 신경망의 정확도를 출력하는 함수를 만들거에요. 다들 한가닥 하시는 개발자분들이시니 긴 말 필요없고 바로 코드부터 보여드리겠습니다.

def print_accuracy(self):
    matched = 0

    for x in self.test_data:
        label = int(x[0])
        predicted = np.argmax(self.predict(x))
        if label == predicted :
            matched = matched + 1

    print("현재 신경망의 정확도 : {0}%".format(matched/len(self.test_data)))

라벨과 피드포워딩한 결과가 같으면 matched 변수를 1씩 더합니다. 반복이 끝나면 matched 변수를 전체 학습데이터 수로 나눠서 퍼센트를 구합니다. 위 코드에서 np.argmax 함수는 파라미터로 받은 배열에서 가장 큰 값의 인덱스를 리턴해요. 5번째 값이 제일 크면 숫자 5를 리턴합니다.

DeepNeuralNetwork 클래스

벌써 꽤 많이 왔습니다. 지금까지 작성한 코드는 다음과 같아요.

import numpy as np

data_file = open("data/mnist_train.csv", "r")
training_data = data_file.readlines()
data_file.close()

test_data_file = open("data/mnist_test.csv", "r")
test_data = test_data_file.readlines()
test_data_file.close()

class DeepNeuralNetwork:
    def __init__(self, input_layers, hidden_layers, output_layers):
        self.inputs = input_layers
        self.hiddens = hidden_layers
        self.outputs = output_layers
        self.test_data = None

        self.w_ih = np.random.randn(inputs, hiddens) / np.sqrt(inputs/2)
        self.w_ho = np.random.randn(hiddens, outputs) / np.sqrt(hiddens/2)

    # feed-forward를 진행한다.
    def predict(self, x):
        # 문자열을 float array로 바꾸는 과정
        data = self.normalize(np.asfarray(x.split(',')))

        # 0번은 라벨이기 때문에 날렸다.
        data = data[1:]

        layer_1 = self.sigmoid(np.dot(data, self.wih))
        output = self.sigmoid(np.dot(sigmoid_h, self.who))
        return output

    # training_data로 학습을 진행한다.
    def train(self, training_data, lr=0.01, epoch=1):
        pass

    # 현재 신경망의 정확도를 출력한다.
    def print_accuracy(self):
        matched = 0

        for x in self.test_data:
            label = int(x[0])
            predicted = np.argmax(predict(x))
                if label == predicted :
                    matched = matched + 1
        print('현재 신경망의 정확도 : {0}%'.format(matched/count(self.test_data)))

    def sigmoid(self, x):
        return 1.0/(1.0 + np.exp(-x))

    def normalize(self, x):
        return (x / 255.0) * 0.99 + 0.01

network = DeepNeuralNetwork(input_layers=784, hidden_layers=100, output_layers=10)
network.test_data = test_data
network.train(training_data, lr=0.01, epoch=1)

마지막 train 함수만 구현하면 숫자 손글씨를 분류하는 인공지능이 완성됩니다. 드디어 딥러닝의 꽃이자 모든것인 역전파 (Back-Propagation) 알고리즘이 등장할 차례입니다.

딥러닝의 꽃 역전파 ( Back Propagation )

역전파는 경사 하강법 알고리즘이 핵심입니다. 경사하강법이 무엇이었죠? 뉴런 A의 입장에서 이해해보자면 뉴런 A인 나의 입장에서 내가 오차에 기여한 만큼을 구해서 내 값을 계속 조정하는 겁니다. 만약 내가 오차에 기여한 만큼이 0이라면 뉴런 A인 나는 값을 조정하는 것을 멈추고 그 값에 머무를 거에요. 우리말로 풀어쓰면 이렇게 되겠죠?

뉴런 A := 뉴런 A - 학습률 * 뉴런 A가 에러에 기여한 정도

경사하강법

특정 뉴런의 오차는 미분법을 통해 쉽게 구할 수 있어요. 아래 그림처럼 뉴런 1개짜리 슈퍼 심플한 신경망이 있다고 가정하고 역전파 과정을 표현할건데 기호들이 많으니 노트에 따로 그려보고 손으로 하나하나 따라가는걸 권합니다.

슈퍼 심플한 신경망

x는 초기 입력 데이터입니다. 이 값이 x의 가중치인 wih와 곱해져서 히든레이어로 전달되고 활성화 함수를 거쳐서 sp가 됩니다. 우리가 쓸 활성함수인 Sigmoid함수의 s를 따서 앞에 붙였습니다. 그다음 spq라는 이름으로 바꿔서 다음 아웃풋 레이어의 입력값으로 들어갑니다. 여기서 spq는 완벽하게 동일한 값입니다. 단순히 다음 레이어의 입력값이라는 의미에서 명칭만 바꿧을 뿐 별다른 의미는 없습니다

우리는 지금 각 레이어들의 뉴런 수가 정확히 1개씩만 있는 슈퍼 심플한 신경망에서 역전파를 다루고 있습니다. 이 신경망은 피드 포워딩을 거쳐서 y’ 이라는 결과를 내놓습니다. y는 입력데이터의 라벨입니다. 그럼 이 신경망은 정확히 어떻게 학습할까요? 아주 간단합니다. 입력데이터는 변하지 않습니다. 이 신경망이 학습하는 건 오로지 wih, who 변수 둘 뿐입니다.

우선 이 신경망에서 오차 J를 정의합니다. 이 Jwihwho로 미분한 뒤에경사하강법 알고리즘을 타면 됩니다. 어렵지 않죠?

MSE

이 정의한 비용 즉 오류를 가지고 신경망을 학습시킬 것 입니다. 역전파는 오류가 앞에서부터 거꾸로 인풋레이어까지 전달되는 형태이기에 가장먼저 who의 값을 조정해보도록 하겠습니다. who값을 조정하려면 Jwho로 미분한 값을 구해야 합니다.

경사하강법

이 미분식의 값을 구하려면 Chain Rule을 이용해야 합니다. 다시 말하면 위의 식은 아래와 같은 식입니다.

경사하강법

Chain Rule을 적용해서 미분식을 풀이하기 쉽게 쪼갠 모양입니다. 각각의 편미분식의 값을 구하고 곱하면 비용을 구할 수 있습니다. 하나씩 풀이해보도록 하겠습니다. 먼저 첫번째 식부터 미분하면 다음과 같이 됩니다.

dj

sq값이 y’ 과 같은 값이기 때문에 위의 식은 Jy’으로 편미분 한것과 같아요. 이때 2는 계산식에서 사라져도 크게 상관이 없습니다. 어차피 경사에서 어디를 타고 내려갈지 정해지기만 해도 되기 때문입니다.

이제 그 다음식을 미분하자면, sq는 사실 sigmoid(q)의 값 입니다. sq를 미분하려면 sigmoid함수를 미분하는 법도 알아야겠죠?

경사하강법 경사하강법

Sigmoid함수를 미분하면 다음모양이 되기때문에 두번째 미분식도 쉽게 구할 수 있습니다.

경사하강법 경사하강법

q는 뭐였죠? spwho를 곱한 값입니다. 피드 포워딩을 잊으신 건 아니시죠? qwho로 미분하면 단순히 sp값 하나만 나옵니다.

경사하강법

이제 다왔습니다! 이 모든 값을 곱하면 이렇게 정리할 수 있습니다. 경사하강법

한번 경사하강법 까지 표현해볼까요? 경사하강법

자 이제 처음으로 히든레이어의 가중치값을 학습 했습니다! 같은 원리를 이용해서 wih값도 학습할 수 있습니다. 한 번 해볼까요?

경사하강법

처음 두개는 우리가 이미 위에서 구했습니다. 나머지 세개의 미분값만 새로 구해보도록 합시다. 위에서 얘기했지만 q값은 spwho를 곱한 값입니다. qsp로 미분하면 who만 뚝 떨어지게 됩니다.

경사하강법 경사하강법

sp는 무엇일까요? 마찬가지로 spsigmoid(p)입니다. 따라서 미분식은 다음과 같습니다.

경사하강법

거의 다왔습니다. 마지막으로 p = wih * x 이므로 wih에 대해 미분하면 x 값만 남게 됩니다.

경사하강법

수고하셨습니다! 이제 거의 끝나갑니다. 이제 이 수식을 모두 합치고 경사하강법 까지 적용해보겠습니다.

경사하강법 경사하강법

드디어 끝났습니다. 이것으로 우리는 역전파 알고리즘을 통해서 1회 학습했습니다. 이제 이 모든 마법재료들을 가지고 비용값이 충분히 떨어질 때 까지 학습을 반복하면 됩니다. 지금까지는 수학세계에서 표현했던 역전파 알고리즘을 코딩세계에서는 이렇게 표현합니다.

def train(self, training_data, lr=0.01, epoch=1):
    for ech in range(0, epoch):
        for i, x in enumerate(trainig_data):
            target = np.array(np.zeros(self.outputs) + 0.01, ndmin=2)
            target[0][int(x[0])] = 0.99
            x = self.normalize(np.asfarray(x.split(",")))

            # feed forward
            l1 = self.sigmoid(np.dot(x[1:], self.wih))
            l2 = self.sigmoid(np.dot(l1, self.who))

            # back propagation alogrithm.
            l2_e = (target - l2) * (l2 * (1 - l2))
            l1_e = l2_e.dot(self.who.T) * (l1 * (1 - l1))

            # update
            self.who = self.who + lr * l2_e.T.dot(np.array(l1, ndmin=2)).T
            self.wih = self.wih + lr * l1_e.T.dot(np.array(data[1:], ndmin=2)).T

            if i % 2000 == 0 :
                self.print_accuracy()

우리는 이해를 돕기위해 뉴런의 개수를 극단적으로 줄였지만, 실제 신경망 안에서는 뉴런의 가중치들은 행렬값으로 표현됩니다. 위의 코드들은 전부 행렬을 다루지만 하나 하나 쪼개고 분해해서 이해해보면 위에서 우리가 다뤘던 수학세계의 수식들과 정확히 일치합니다.

자 이제 코딩시간도 전부 끝났습니다! 이제는 코드를 돌려보면서 결과를 확인해봅시다.

경사하강법

정리하며

지금까지 94% 정확도의 좀 쓸만한 신경망을 만들어 봤습니다. 그렇지만 아직도 많이 부족하죠. 학습에 실패했다면 왜 실패했을까요? 조금 더 분석하기 위해 학습에 실패한 데이터 10개를 무작위로 추려서 모아봤습니다.

학습에 실패한 데이터 10개

참고로 그림 왼쪽 위에 있는게 이미지의 라벨이고 오른쪽 위에 있는 숫자가 신경망이 내놓은 결과입니다. 어떤 데이터는 오해할만 하다고 수긍할만 한 반면, 신경망이 왜 틀렸는지 아리송한 데이터도 있습니다.

그렇다면 이 데이터들은 왜 분류에 실패했을까요? 그것은 우리의 신경망의 뉴런들이 모양을 학습한 것이 아니라 특정 위치에 있는 점의 색상정보로 숫자를 유추했기 때문일지도 모릅니다. 4의 모양을 학습한게 아니라. 학습데이터를 주면 픽셀 (6,10) 위치에선 조금 하얀색에 가까우면 데이터가 4나 9에 가깝더라 라는 식인거죠

그렇다면 모양을 학습하려면 어떻게 해야할까요? 수많은 인공지능 연구자들은 많은 고민을했고 현재 2017년, 내놓은 정답은 CNN입니다. CNN은 3부에서 다루도록 하겠습니다.

오늘 만든 신경망을 보면 많은 분들이 눈치 채셨겠지만 저희 신경망에는 Bias Unit이 없었습니다. 2부에서 다루겠지만 여러분들이 직접 신경망을 고쳐보시면 도움이 많이 될 것 같습니다. 2부에서는 94%의 학습률을 2%정도 더 끌어올릴 수 있는 트릭들에 대해 알아보고 오늘 작성한 신경망을 업그레이드 하도록 하겠습니다.

3부에서 부터는 CNN에대해 조금 더 시간을 가지고 길게 다룰 예정입니다. CNN의 개념에 대해 알아보고 오늘과같이 CNN의 역전파 알고리즘을 분해해보고 끝으로 학습률 99%!!! 의 신경망을 만들어보도록 하겠습니다.

감사합니다.

참고자료

tweet Share