[내 신경망 만들기/2부] 3. 손글씨 인식 신경망(MNIST 데이터 셋)
1. 개요
2. MNIST 데이터 시현
2-1. 파이썬
2-2. C++
3. MNIST 손글씨 인식 신경망
----------------------------------------------------------------------------------------------
[참고서] Make Your Own Neural Networks, Tariq Rashid [
book][
검색링크]
----------------------------------------------------------------------------------------------
1. 개요
MNIST(Modified National Institute of Standards and Technology) 데이터 셋은 손글씨 이미지를 모아놓은 자료로서 다양하게 구현한 신경망의 성능을 평가할 때 널리 활용된다. 뿐만 아니라 신경망을 처음 공부하면서 만든 "내 신경망"의 시험 대상으로 활용되어 딥러닝 분야의 'Hello World'라고 할 수 있다[링크]. 앞서 만들어 놓은 단순 신경망[링크]을 그대로 사용하여 손글씨 인식 "내 신경망"을 구성해 보기로 한다. 다만 신경망의 능력을 높이기 위해 각 층을 구성하는 노드(또는 신경 세포)의 갯수만 늘렸다. 개별 신경세포는 아주 단순하지만 수없이 많은 세포들이 모여 구성한 망은 놀라운 결과를 보여준다. 복잡계를 단순화시켜 그로부터 규칙을 찾아내오던 전통적인 지적 활동에 비하면 신경망은 '단순'하게 보인다. 그래서 일까? 프로그램 코딩을 몰라도, 알고리즘을 몰라도 컴퓨터를 내 구미에 맞추는데 전문가 초보자 가릴것 없이 인공지능에 메달린다. 게으름 이라니!
2. 이미지 시현
MNIST 데이터 셋의 규모는 학습용으로 6만개의 손글씨 이미지, 시험용으로 1만개의 손글씨 이미지로 구성되었다. 각 이미지는 256단계의 단색 화소가 가로 28 세로 28개로 구성된다. MNIST 데이터 셋은 LeCunn의 웹사이트[링크]에서 배포되었으나 워낙 다양한 형식으로 널리 퍼져 있어서 지금은 해당 자료가 보이지 않는다. CSV 형식 MNIST 데이터 셋은 Joseph Redmon의 깃허브 저장소[링크]에서 구할 수 있다.

28x28 화소 손글씨 이미지
출처: https://youtu.be/gh8UR3nw2uk?si=-V-Jf28tN_1cEjQe
내 신경망에서 사용할 MNIST 데이터 셋의 형식은 CSV(Comma Separated Values)로서 다음과 같다.
2,0,0,0,0,,......,0,116,125,171,255,255,150,93,0,0,.....,0,0,0,0
첫번째 값은 해당 이미지의 숫자이며 이어 784(=28x28)개의 화소값이다. 숫자들의 나열로부터 손글씨 화상(이미지)를 떠올리려면 상당한 상상력이 필요하다. 처리할 대상의 가시화는 개발 생산성을 높이는 중요 요소다. 손글씨 이미지를 컴퓨터 그래픽장치에 시현해보자.
2-1. 파이썬
손글씨 이미지를 시현하는 파이썬 코드 plot_data.py 의 내용을 따라가 보기로 한다.
#
# Plot hand written image with CSV(Comma Separated Value)
#
data_file = open("mnist_dataset/mnist_train_100.csv", 'r')
data_list = data_file.readlines()
data_file.close()
print(F"There's {len(data_list)} Data lists.")
print(F"0-th Data List: \n{data_list[0]}")
읽을 CSV 파일을 열어 한줄을 읽어 .readlines() 변수 data_list에 저장했다.
import numpy
import matplotlib.pyplot
배열형 자료를 쉽게 다루기 위한 numpy 모듈과 데이터 가시화 모듈 matplotlib를 들여온다(import).
all_values = data_list[1].split(',')
긴 문자열에서 콤마(,) 기호로 분리된 값들을 1차원 리스트(list)로 변환한다.
image_array = numpy.asfarray(all_values[1:]).reshape((28,28))
리스트에서 첫번째 값(인식 숫자)을 제외한 나머지 all_values[1:] 를 28x28의 행렬로 재구성하여 부동 소수점 2차원 배열로 변환한다.
matplotlib.pyplot.imshow(image_array,cmap='Greys',interpolation='None')
matplotlib.pyplot.show()
파이썬 모듈 matplotlib에 행렬 데이터를 시각화 해주는 방법(크래스 소속함수)들을 갖추고 있다. 이를 이용하면 단숨에 데이터를 시각화 할 수 있다. 소속함수 .imshow()로 그림판을 채우고 실제로 그래픽 화면에 띄우는 .show()가 뒤따른다. 컴퓨터 그래픽 체계는 그리기(렌더링, rendering)와 시현(show)을 분리한다. 컴퓨팅 시스템에 장착된 그래픽 장치는 메모리에 비해 매우 느리다. 따라서 그림판에 화소를 일일이 채울 때마다 그래픽 장치를 동작시키기 보다 그리는 행동(렌더링)은 메모리에서 수행하고 완성된 그림을 한번에 주변장치에 뿌리는 것이 효과적이다.
$ python3 plot_data.py

파이썬에서 객체의 자료형은 선언할 필요 없이 할당되는 값(리터럴)에 의해 결정된다. 문자열로부터 숫자 배열로 변환하는 소속함수를 갖추고 있다. 매우 높은 추상성을 추구하는 파이썬의 자료형은 자료 처리를 위해 많은 사람들이 요구하는 기능들은 갖추고 있어서 코딩의 수고를 덜어준다. 이는 프로그래밍 입문자들이 파이썬에 열광하는 이유이기도 하다.
2-2. C++
C++ 역시 상당히 높은(그리고 넓은!) 추상화 수준의 프로그래밍 언어다. 각종 자료처리와 시각화를 위해 헤아릴 수 없을 만큼 다양한 그래픽 API와 라이브러리들이 제공되고 있지만 C++ 언어 학습을 위해 고도의 라이브러리를 배제하고 MNIST 데이터 시현 프로그램을 작성하해보기로 한다. C++ 코드는 plot_data.cpp 다. 주요 내용을 따라가 보자.
//
// Plot hand written image with CSV(Comma Separated Value)
// Filename: plot_data.cpp
//
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h> // sleep()
#include <string.h>
C 언어의 표준 라이브러리는 파일 입출력을 다루고(stdio.h, stdlib.h) 문자열 변수를 처리하는 함수들(string.h)이 기본적으로 제공 한다. 그래픽 프로그래밍은 게임 및 멀티미디어 응용 프로그램 개발용 오픈-소스 라이브러리 SDL을 활용하기로 한다.
#include <SDL2/SDL.h>
#define CSV_FILENAME "mnist_dataset/mnist_test_10.csv"
#define SIZE_OF_BOX 10
CSV 형식을 다루는 공개 라이브러리가 있지만 그리 복잡하지 않으므로 직접 작성하기로 한다(공부도 할겸...). CSV 형식 파일에서 손글씨 이미지 데이터를 구하는 함수는 다음과 같다.
bool ParseCSV_MNIST(
char* FileName, // CSV 파일명
int nLine, // 번째 줄
uint8_t* image) // 이미지 데이터(1차원 배열, 길이=28x28+1)
{
FILE *fp;
char *line = NULL;
size_t len = 0;
ssize_t read;
fp = fopen(FileName, "r");
if (fp == NULL) {
printf("Error Opening CSV file\n");
return false;
}
do {
nLine--;
} while (((read=getline(&line, &len, fp))!=-1) && (nLine>0));
if (read==EOF) // -1
{
printf("End of File\n");
return false;
}
CSV 파일을 열어 nLine 번째 한 줄을 읽어 문자열 변수에 저장했다. getline()은 파일에서 한줄(문자 '\n'으로 끝난다)을 읽어오는 C/C++의 표준 입출력 함수다. 콤마(,)기호로 분리된 문자열을 다루는 표준 함수는 없으므로 응용 목적에 맞게 처리한다.
char buff[8];
int k=0;
for (int j=0, i=0; i<strlen(line); i++)
{
if (line[i]>=0x30 && line[i]<= 0x39)
buff[j++] = line[i];
else if (line[i]==',' || line[i]=='\n')
{
buff[j] = 0x00;
image[k++] = (uint8_t)atoi(buff);
j = 0;
if (k>(28*28+1)) break;
}
}
앞서 열었던 파일을 닫고,
free(line);
fclose(fp);
읽은 데이터가 응용 목적에 맞는지 최소한 갯수 만이라도 확인한다.
if (k!=(28*28+1)) return false;
else return true;
}
CSV 파일에서 읽은 이미지를 시현 해보자. C++ 프로그래밍에서 그래픽을 다루는 다양한 방법과 라이브러리가 있지만 SDL이 그중 사용이 쉽다. SDL(https://www.libsdl.org/)은 게임 제작용으로 배포되는 오픈-소스 라이브러리다. 그래픽 뿐만 아니라 키보드, 마우스 등 주변장치의 제어와 쓰레드 프로그래밍을 포함하여 다양한 API들을 구비하고 있어서 시스템 모델링에 매우 유용하다. SDL을 사용하여 각종 게임기 에뮬레이터를 제작한 예를 봐도 알만하다. 손글씨 문자 이미지를 시현하는 함수를 작성하면 다음과 같다.
bool Draw_Charater(
const char* Filename, // CSV 파일명
SDL_Renderer* renderer, // 그림을 그릴 객체의 속성
SDL_Window* window, // 그림창 객체의 고유번호
int nImage)
{
CSV 파일을 열어 해당 nImage 번째 이미지 자료 읽어온다. CSV에서 읽은 이미지를 저장할 변수는 용도에 맞게 미리 선언되어 있어야 한다. 손글씨 이미지의 각 화소는 256단계 값이므로 부호없는 8비트 uint8_t 형으로 선언되었다(stdint.h).
uint8_t image[28*28+1];
// CSV to Image
if(!ParseCSV_MNIST((char*)CSV_FILENAME, nImage, image))
return false;
SDL 창에 모양을 내봤다. 몇번째 이미지 인지 창의 타이틀에 표시하였다.
char szTitle[32];
sprintf(szTitle, "%d-th Data [%d]", nImage, image[0]);
SDL_SetWindowTitle(window, szTitle);
화소를 확대하여 시현하기 위해 상자를 그린 후 밝기 단계에 맞춰 채운다. SDL_Renderer는 그림을 그릴 공간과 도구를 모아놓은 객체다. 그리기 전 붓의 RGB 색상, 상자가 그려질 그림판 영역등이 모두 포함된다.
SDL_Rect rect;
rect.x = 0; // X position
rect.y = 0; // Y position
rect.w = SIZE_OF_BOX; // Width
rect.h = SIZE_OF_BOX; // Height
for (int n=1, row=0; row<28; row++)
{
for (int col=0; col<28; col++)
{
SDL_SetRenderDrawColor(renderer,
image[n], image[n], image[n],SDL_ALPHA_OPAQUE);
SDL_RenderFillRect(renderer, &rect);
rect.x += SIZE_OF_BOX; // X position
n++;
}
rect.x = 0;
rect.y = SIZE_OF_BOX*(n/28); // Y position
}
렌더러(renderer) 객체에 그림을 채운 후 이를 그래픽 장치에 시현되도록 한다. 파이썬의 경우와 마찬가지로 그리기(rendering)와 시현(presenting)이 분리되어있다.
SDL_RenderPresent(renderer);
return true;
}
위에서 작성한 함수들을 활용하여 손글씨 이미지를 시현해보자.
int main()
{
// SDL2-------------------------------------------------------
SDL_Window* window = NULL;
SDL_Renderer* renderer = NULL;
SDL_Event event;
fprintf(stderr, "Plottig MNIST dataset. Use Arrow keys.....");
먼저 컴퓨터의 그래픽 시스템 초기화가 필요하다. SDL에서 제공하는 각종 API들과 멀티미디어를 다룰 객체들은 헤더파일 SDL2.h 에 정의되어 있다.
if (SDL_Init(SDL_INIT_VIDEO) < 0)
{
fprintf(stderr, "SDL Initialization Fail: %s\n",
SDL_GetError());
return -1;
}
그림을 그릴 창을 띄운다.
window = SDL_CreateWindow("Plotting Data",
SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED,
28*SIZE_OF_BOX, 28*SIZE_OF_BOX,
SDL_WINDOW_SHOWN);
if (!window)
{
fprintf(stderr,
"SDL Initialization Fail: %s\n",
SDL_GetError());
SDL_Quit();
return -1;
}
손글씨 이미지는 항상 28x28 이므로 창의 크기는 고정시켰다.
SDL_SetWindowResizable(window, SDL_FALSE);
그리기 객체(렌더러)를 생성하여 창에 부여 해준다.
renderer = SDL_CreateRenderer(window,-1, SDL_RENDERER_ACCELERATED);
게임 제작 라이브러리 SDL의 주 쓰레드는 무한 반복문 내에서 입출력(키보드, 마우스 그외 게임장치 등)의 사건(event)에 반응한다.
// Event Loop -----------------------------------------------
uint8_t image[28*28+1];
int nImage = 1;
bool quit = false;
while(!quit)
{
SDL 커널은 입력장치를 조사(event polling)하여 상태변화가 발생하면 이를 사건으로 알려준다.
if(SDL_PollEvent(&event))
{
사건을 일으킨 장치를 판별,
switch (event.type)
{
프로그램 종료(마우스로 창 닫음 버튼을 누른 경우),
case SDL_QUIT:
quit = true;
break;
키보드에서 누름(key-down) 사건,
case SDL_KEYDOWN:
switch( event.key.keysym.sym )
{
case SDLK_ESCAPE:
quit = true;
break;
default:
break;
}
break;
키 뗌(key-up) 사건인 경우,
case SDL_KEYUP: switch( event.key.keysym.sym ) { 키보드 윗 화살, 왼쪽 화살 인경우,
case SDLK_UP: case SDLK_LEFT:이전 이미지 시현 함수 Draw_Character() 호출,
if (Draw_Charater(CSV_FILENAME,renderer, window, nImage)) nImage--; else quit = true; break; case SDLK_DOWN: case SDLK_RIGHT:다음 이미지 시현 함수 Draw_Character() 호출,
if (Draw_Charater(CSV_FILENAME,renderer, window, nImage)) nImage++; else quit = true; break; default: break; } break; default: break; } }아무 사건도 없다면 잠시 쉰 후(컴퓨팅 자원의 독점 방지) 무한 반복,
else
usleep(100);
}
EXIT:
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
} 빌드 할 때 SDL2는 표준 라이브러리가 아니므로 링크 시 추가한다.
$ g++ -o plot_data plot_data.cpp -lSDL2
실행하면 다음과 같은 손글씨를 볼 수 있다. 파이썬에 비하면 그림을 띄우는데 상당한 공이 들어간다. 추상성이 넓은 C++를 활용하면 손쉽게 시스템 제어를 할 수 있다.
$ plot_data

3. MNIST 인식 신경망
두뇌의 신경세포에 꼬리표가 없다. 방대하게 구성된 신경망이 훈련을 통해 특별한 능력을 발휘할 뿐이다. 인공 신경망 역시 이와 다르지 않다. MNIST 손글씨를 인식하기 위해 별도의 인공 신경 세포를 작성하지 않고 망의 구성을 확대하고 훈련 할 뿐이다.
손글씨 숫자 인식 신경망은 다양하게 구현되고 교육용으로 제작된 컨텐츠들이 많다. "내 신경망"을 만들어보기 전에 다른 이들은 어떻게 설명하고 있는지 살펴보기 바란다.
# python notebook for Make Your Own Neural Network
# code for a 3-layer neural network,and learning the MNIST dataset
# (c) Tariq Rashid, 2016
# license is GPLv2
#--------------------------------------------------------------------
# Fiename: part2_neural_network_mnist_data.py
#--------------------------------------------------------------------
import numpy
import scipy.special
import matplotlib.pyplot
신경망 크래스는 앞서 작성해둔 것 part2_neural_network.py 과 동일하다. 초기화 __init()__, 학습 train() 그리고 조회 query() 를 소속함수로 두고 있다.
# neural network class definition
class neuralNetwork:
def __init__(self, inputnodes, hiddennodes, outputnodes, learningrate):
# set number of nodes in each input, hidden, output layer
self.inodes = inputnodes
self.hnodes = hiddennodes
self.onodes = outputnodes
........
pass
# train the neural network
def train(self, inputs_list, targets_list):
.........
pass
# query the neural network
def query(self, inputs_list):
.........
return final_outputs
좀더 복잡한 '지능(?!)'을 부여하기 위해 신경망의 규모를 늘린다. 이미지를 구성하는 784개의 화소가 모두 일렬로 입력이다. 은닉층의 노드수는 200개로 정했다. 노드수의 갯수가 많을 수록 정교한 신경망이 될 수 있으나 그렇다고 항상 효과가 있지는 않다. 각 층 사이에 모든 노드가 연결되므로 총 연결 수는 156,800(=784x200)나 된다. 이어 은닉층과 출력층의 연결은 2,000(=200x10)개다. 이 연결마다 가중치 곱셈을 고려하면 신경망이 요구하는 계산량은 엄청나다. 오늘날의 고도로 발전한 반도체 계산기가 없었더라면 구현 불가능하다. 전통적인 과학 기술 탐구에서 수학적 사고의 위대함 엿볼 수 있다.
if __name__=="__main__":
# number of input, hidden and output nodes
input_nodes = 784
hidden_nodes = 200
output_nodes = 10
# learning rate
learning_rate = 0.1
입력층, 은닉층 그리고 출력 층을 지정하여 신경망을 구성한다.
# create instance of neural network
n = neuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)
CSV형식 MNIST 파일을 읽어 신경방을 훈련한다.
# load the mnist training data CSV file into a list
training_data_file = open("mnist_dataset/mnist_train_100.csv", 'r')
training_data_list = training_data_file.readlines()
training_data_file.close()
동일한 훈련 데이터 셋을 가지고 여러번에 걸쳐 훈련을 실시한다. 훈련을 반복함에 따라 경사 하강하여 최소 오류에 도달 할 것으로 기대 하지만 수많은 연결이 최적화(오류 최소화) 될 것이라고 기대하기 어렵다. 훈련의 반복 횟수는 적당히 한다. 반복이 많다고 훈련이 잘된다고 할 수 없다.
# train the neural network
# epochs is the number of times the training data set is
# used for training
epochs = 5
for e in range(epochs):
# go through all records in the training data set
for record in training_data_list:
# split the record by the ',' commas
all_values = record.split(',')
# scale and shift the inputs
inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01
# create the target output values (all 0.01, except the desired label which is 0.99)
targets = numpy.zeros(output_nodes) + 0.01
# all_values[0] is the target label for this record
targets[int(all_values[0])] = 0.99
n.train(inputs, targets)
pass
pass
훈련을 마친 신경망에 시험 이미지를 제공하여 인식이 되는지 알아보자.
# load the mnist test data CSV file into a list
test_data_file = open("mnist_dataset/mnist_test_10.csv", 'r')
test_data_list = test_data_file.readlines()
test_data_file.close()
# test the neural network
# scorecard for how well the network performs, initially empty
scorecard = []
# go through all the records in the test data set
for record in test_data_list:
# split the record by the ',' commas
all_values = record.split(',')
# correct answer is first value
correct_label = int(all_values[0])
# scale and shift the inputs
inputs = (numpy.asfarray(all_values[1:])/255.0*0.99)+0.01
# query the network
outputs = n.query(inputs)
# the index of the highest value corresponds to the label
label = numpy.argmax(outputs)
print(F'correct label : {correct_label} -> ', end="")
print(F'network answer: {label}')
# append correct or incorrect to list
if (label == correct_label):
# network's answer matches correct answer,
# add 1 to scorecard
scorecard.append(1)
else:
# network's answer doesn't match correct answer,
# add 0 to scorecard
scorecard.append(0)
pass
pass
# calculate the performance score
scorecard_array = numpy.asarray(scorecard)
print ("performance = ", scorecard_array.sum() / scorecard_array.size)
실행,
$ python3 part2_neural_network.py
correct label : 7 -> network answer: 7
correct label : 2 -> network answer: 0
correct label : 1 -> network answer: 1
correct label : 0 -> network answer: 0
correct label : 4 -> network answer: 4
correct label : 1 -> network answer: 1
correct label : 4 -> network answer: 4
correct label : 9 -> network answer: 4
correct label : 5 -> network answer: 4
correct label : 9 -> network answer: 7
performance = 0.6
MNIST 데이터 셋을 사용하지 못하고 100개의 훈련 데이터셋을 골라 10개의 데이터를 시험해봤다. 잘못된 인식을 한 숫자 이미지는 다음과 같다. 단순 구조(한개의 은닉층, 바이어스 값이 없는 단순 발화 함수)의 "내 신경망"은 시험결과 성능이 우수하다고 볼 수 없다. 어쨌든 신경망의 작동과 훈련 개념을 배웠다는 점이다. 여기에 더하여 파이썬과 C++가 고도의 추상적 언어라고 불릴 수 있는 핵심 요인인 '크래스'의 개념도 학습할 수 있었다. C++ 로 MNIST 손글씨 인식 신경망을 작성해보자.

신경망의 성능은 신경 세포 사이의 적절한 연결 강도의 결정에 달렸다. 훈련 과정에서 역전파된 오류로부터 은닉층의 오류 추정의 불명확성, 지역 최소 오류, 불균일한 훈련, 은닉층의 노드 수, 훈련 반복 횟수등의 정량적 평가와 결정이 매우 어렵다. 재훈련의 비용부담이 큰 인공 신경망은 구조의 유연성을 어렵다.
--------------------------------------------------------------------------------------------------
참고:
[1] Python 및 C++로 "내 신경망 만들기" 깃허브 저장소, https://github.com/GoodKook/ETRI-0.5um-CMOS-MPW-Std-Cell-DK/tree/main/Projects/MYONN
[2] 딥러닝의 핵심 활성화 함수(1): Sigmoid의 특징과 한계, https://youtu.be/KxUhMN5oJyc
[3] 딥러닝의 핵심 활성화 함수(2):Tanh, ReLU, Leaky ReLU, https://youtu.be/OtaUrw2ArWQ
[4] 예제로 배우는 역전파(backpropagation), https://youtu.be/Ku1xUFK9I3Y
[5] 합성곱 신경망(CNN) (기초이론), https://youtu.be/h1Io450Igrg
[6] 합성곱 신경망(CNN) (MNIST 실습), https://youtu.be/IHbsSmRbcrw
[7] Canny Edge Detection, https://en.wikipedia.org/wiki/Canny_edge_detector