03.6 손글씨 숫자 인식
신경망의 구조를 배웠으니 실전 예에 적용해보죠. 바로 손글씨 숫자 분류입니다. 이번 절에서는 이미 학습된 매개변수를 사용하여 학습 과정은 생략하고, 추론 과정만 구현할 겁니다. 이 추론 과정을 신경망의 순전파 forward propagation라고도 합니다.
NOTE_ 기계학습과 마찬가지로 신경망도 두 단계를 거쳐 문제를 해결합니다. 먼저 훈련 데이터(학습 데이터)를 사용해 가중치 매개변수를 학습하고, 추론 단계에서는 앞서 학습한 매개변수를 사용하여 입력 데이터를 분류합니다.
3.6.1 MNIST 데이터셋
이번 예에서 사용하는 데이터셋은 MNIST라는 손글씨 숫자 이미지 집합입니다. MNIST는 기계학습 분야에서 아주 유명한 데이터셋으로, 간단한 실험부터 논문으로 발표되는 연구까지 다양한 곳에서 이용하고 있습니다. 이미지 인식이나 기계학습 논문들을 읽다 보면 실험용 데이터로 자주 등장하는 것을 확인할 수 있을 겁니다.
MNIST 데이터셋은 0부터 9까지의 숫자 이미지로 구성됩니다(그림 3-24). 훈련 이미지가 60,000장, 시험 이미지가 10,000장 준비되어 있습니다. 일반적으로 이들 훈련 이미지를 사용하여 모델을 학습하고, 학습한 모델로 시험 이미지들을 얼마나 정확하게 분류하는지를 평가합니다.
그림 3-24 MNIST 이미지 데이터셋의 예

지니의 데이터셋 안내
- 지니: “도로시, 토토! 딥러닝 계에 널리 알려진 역사적인 연습 문제 데이터셋인 MNIST(엠니스트)에 도착했어! 0부터 9까지 손으로 직접 쓴 작은 이미지들이 무려 6만 장(학습 데이터)이나 가득 들어 있는 방이란다.”
- 도로시: “와, 이미지가 정말 많고 다양해! 악필로 쓴 글자들도 꽤 있는 것 같은데, 우리가 만든 신경망이 이걸 전부 정확히 읽어낼 수 있을지 긴장되는걸!”
- 토토: “왈왈! (칠판을 보며) 나도 7이랑 1은 구별할 수 있다 멍!”
도로시와 지니가 칠판 가득 적힌 0~9 손글씨 이미지들을 살펴보고 있고, 토토가 ‘7’이라고 그린 손글씨 카드를 자랑스럽게 물고 있네요.
MNIST의 이미지 데이터는 $28 \times 28$ 크기의 회색조 이미지(1채널)이며, 각 픽셀은 0에서 255까지의 값을 취합니다. 각 이미지에는 또한 ‘7’, ‘2’, ‘1’과 같이 그 이미지가 실제 의미하는 숫자가 레이블로 붙어 있습니다.
이 책에서는 MNIST 데이터셋을 내려받아 이미지를 넘파이 배열로 변환해주는 파이썬 스크립트를 제공합니다(깃허브 저장소의 dataset/mnist.py 파일). mnist.py를 실행하려면 작업 디렉터리를 ch01, ch02, ch03, …, ch08 중 하나로 옮겨주세요. load_mnist() 함수를 이용하면 MNIST 데이터를 다음과 같이 아주 쉽게 가져올 수 있답니다.
import sys, os
sys.path.append(os.pardir) # 부모 디렉터리의 파일을 가져올 수 있도록 설정
from dataset.mnist import load_mnist
# 처음 한 번은 몇 분 정도 걸립니다.
(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True, normalize=False)
# 각 데이터의 형상 출력
print(x_train.shape) # (60000, 784)
print(t_train.shape) # (60000,)
print(x_test.shape) # (10000, 784)
print(t_test.shape) # (10000,)
코드를 보면 가장 먼저 부모 디렉터리의 파일을 가져오도록 설정하고 dataset/mnist.py의 load_mnist 함수를 임포트합니다. 그런 다음 load_mnist 함수로 MNIST 데이터셋을 읽습니다. load_mnist가 MNIST 데이터를 받아와야 하니 최초 실행 시에는 인터넷에 연결된 상태여야 합니다. 두 번째부터는 로컬에 저장된 파일(pickle 파일)을 읽기 때문에 순식간에 끝납니다.
WARNING_
mnist.py파일은 이 책 예제 소스의dataset디렉터리에 있고, MNIST 이미지를ch01,ch02,ch03, …,ch08디렉터리에서만 이용한다고 가정합니다. 그래서 실행 시 부모 디렉터리(dataset디렉터리)에 있는 파일을 가져와야 해서sys.path.append(os.pardir)문장을 추가한 것입니다.
load_mnist 함수는 읽은 MNIST 데이터를 “(훈련 이미지, 훈련 레이블), (시험 이미지, 시험 레이블)” 형식으로 반환합니다. 인수로는 normalize, flatten, one_hot_label 세 가지를 설정할 수 있습니다. 세 인수 모두 bool 값입니다. 첫 번째 인수인 normalize는 입력 이미지의 픽셀 값을 0.0~1.0 사이의 값으로 정규화할지를 정합니다. False로 설정하면 입력 이미지의 픽셀은 원래 값 그대로 0~255 사이의 값을 유지합니다. 두 번째 인수인 flatten은 입력 이미지를 평탄하게, 즉 1차원 배열로 만들지를 정합니다. False로 설정하면 입력 이미지를 $1 \times 28 \times 28$의 3차원 배열로, True로 설정하면 784개의 원소로 이뤄진 1차원 배열로 저장합니다. 세 번째 인수인 one_hot_label은 레이블을 원-핫 인코딩 one-hot encoding 형태로 저장할지를 정합니다. 원-핫 인코딩이란, 예를 들어 [0,0,1,0,0,0,0,0,0,0]처럼 정답을 뜻하는 원소만 1이고(hot하고) 나머지는 모두 0인 배열입니다. one_hot_label이 False면 ‘7’이나 ‘2’와 같이 숫자 형태의 레이블을 저장하고, True일 때는 레이블을 원-핫 인코딩하여 저장합니다.
NOTE_ 파이썬에는 pickle 피클이라는 편리한 기능이 있습니다. 이는 프로그램 실행 중에 특정 객체를 파일로 저장하는 기능입니다. 저장해둔 pickle 파일을 로드하면 실행 당시의 객체를 즉시 복원할 수 있습니다. MNIST 데이터셋을 읽는
load_mnist()함수에서도 (2번째 이후의 읽기 시) pickle을 이용합니다. pickle 덕분에 MNIST 데이터를 순식간에 준비할 수 있습니다.
그럼 데이터도 확인할 겸 MNIST 이미지를 화면으로 불러보도록 하겠습니다. 이미지 표시에는 PIL Python Image Library 모듈을 사용합니다. 다음 코드(ch03/mnist_show.py)를 실행하면 첫 번째 훈련 이미지가 모니터 화면에 표시됩니다(그림 3-15).
import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist
from PIL import Image
def img_show(img):
pil_img = Image.fromarray(np.uint8(img))
pil_img.show()
(x_train, t_train), (x_test, t_test) = load_mnist(flatten=True, normalize=False)
img = x_train[0]
label = t_train[0]
print(label) # 5
print(img.shape) # (784,)
img = img.reshape(28, 28) # 원래 이미지의 모양으로 변형
print(img.shape) # (28, 28)
img_show(img)
그림 3-25 MNIST 이미지 중 하나

여기서 주의 사항으로, flatten=True로 설정해 읽어 들인 이미지는 1차원 넘파이 배열로 저장되어 있다는 것입니다. 그래서 이미지를 표시할 때는 원래 형상인 $28 \times 28$ 크기로 다시 변형해야 합니다. reshape() 메서드에 원하는 형상을 인수로 지정하면 넘파이 배열의 형상을 바꿀 수 있습니다. 또한, 넘파이로 저장된 이미지 데이터를 PIL용 데이터 객체로 변환해야 하며, 이 변환은 Image.fromarray()가 수행합니다.
3.6.2 신경망의 추론 처리
드디어 이 MNIST 데이터셋을 가지고 추론을 수행하는 신경망을 구현할 차례입니다. 이 신경망은 입력층 뉴런을 784개, 출력층 뉴런을 10개로 구성합니다. 입력층 뉴런이 784개인 이유는 이미지 크기가 $28 \times 28 = 784$이기 때문이고, 출력층 뉴런이 10개인 이유는 이 문제가 0에서 9까지의 숫자를 구분하는 문제이기 때문입니다. 한편, 은닉층은 총 두 개로, 첫 번째 은닉층에는 50개의 뉴런을, 두 번째 은닉층에는 100개의 뉴런을 배치할 것입니다. 여기서 50과 100은 임의로 정한 값입니다.
이제 순서대로 작업을 처리해줄 세 함수인 get_data(), init_network(), predict()를 정의하겠습니다(ch03/neuralnet_mnist.py).
def get_data():
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, flatten=True, one_hot_label=False)
return x_test, t_test
def init_network():
with open("sample_weight.pkl", 'rb') as f:
network = pickle.load(f)
return network
def predict(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)
a3 = np.dot(z2, W3) + b3
y = softmax(a3)
return y
init_network()에서는 pickle 파일인 sample_weight.pkl에 저장된 ‘학습된 가중치 매개변수’를 읽습니다. 이 파일에는 가중치와 편향 매개변수가 딕셔너리 변수로 저장되어 있습니다. 나머지 두 함수는 지금까지 보아온 구현과 거의 같으니 설명은 생략하겠습니다. 그럼 이들 세 함수를 사용해 신경망에 의한 추론을 수행해보고, 정확도 accuracy (분류가 얼마나 올바른가)도 평가해봅시다.
x, t = get_data()
network = init_network()
accuracy_cnt = 0
for i in range(len(x)):
y = predict(network, x[i])
p = np.argmax(y) # 확률이 가장 높은 원소의 인덱스를 얻는다.
if p == t[i]:
accuracy_cnt += 1
print("Accuracy:" + str(float(accuracy_cnt) / len(x)))
가장 먼저 MNIST 데이터셋을 얻고 네트워크를 생성합니다. 이어서 for 문을 돌며 x에 저장된 이미지 데이터를 1장씩 꺼내 predict() 함수로 분류합니다. predict() 함수는 각 레이블의 확률을 넘파이 배열로 반환합니다. 예를 들어 [0.1, 0.3, 0.2, ..., 0.04] 같은 배열이 반환되며, 이는 이미지가 숫자 ‘0’일 확률이 0.1, ‘1’일 확률이 0.3, … 식으로 해석합니다. 그런 다음 np.argmax() 함수로 이 배열에서 값이 가장 큰(확률이 가장 높은) 원소의 인덱스를 구합니다. 이것이 바로 예측 결과죠. 마지막으로, 신경망이 예측한 답변과 정답 레이블을 비교하여 맞힌 숫자(accuracy_cnt)를 세고, 이를 전체 이미지 숫자로 나눠 정확도를 구합니다.
이 코드를 실행하면 “Accuracy:0.9352”라고 출력합니다. 올바르게 분류한 비율이 93.52%라는 뜻이죠. 이번 장의 목표는 학습된 신경망을 돌려보는 것까지라 정확도에 대해서는 고민하지 않겠지만, 한 가지만 미리 말씀드립니다. 다음 장부터는 신경망 구조와 학습 방법을 궁리하여 이 정확도를 더 높여갈 것입니다. 마지막에는 99% 이상까지 도달할 예정입니다!
도로시의 하이파이브
- 도로시: “와! 지니, 우리가 만든 추론 프로그램이 처음 시도했는데도 벌써 정확도 93.5%를 달성했어! 정말 대단해!”
- 지니: “후후, 기뻐하긴 이르단다 도로시. 4장에서 학습 마법(역전파와 경사하강법)을 도입해서 신경망이 스스로 공부하게 만들면, 나중엔 99% 이상의 기적 같은 정확도(A등급!)에 도달할 수 있어!”
- 토토: “왈왈! (꼬리를 흔들며) 99% 마법 합격증을 향해 하이파이브다 멍!”
도로시와 지니가 손글씨 정확도 99%가 적힌 합격 시험지를 들고 하이파이브를 하고 있고, 토토가 신나게 뛰어놀고 있어요.
또한, 이 예에서는 load_mnist 함수의 인수인 normalize를 True로 설정했습니다. normalize를 True로 설정하면 0~255 범위인 각 픽셀의 값을 0.0~1.0 범위로 변환합니다(단순히 픽셀의 값을 255로 나눕니다). 이처럼 데이터를 특정 범위로 변환하는 처리를 정규화 normalization라 하고, 신경망의 입력 데이터에 특정 변환을 가하는 것을 전처리 pre-processing라 합니다. 여기에서는 입력 이미지 데이터에 대한 전처리 작업으로 정규화를 수행한 셈이 됩니다.
NOTE_ 현업에서도 신경망(딥러닝)에 전처리를 활발히 사용합니다. 전처리를 통해 식별 능력을 개선하고 학습 속도를 높이는 등의 사례가 많이 제시되고 있습니다. 앞의 예에서는 각 픽셀의 값을 255로 나누는 단순한 정규화를 수행했지만, 현업에서는 데이터 전체의 분포를 고려해 전처리하는 경우가 많습니다. 예를 들어 데이터 전체 평균과 표준편차를 이용하여 데이터들이 0을 중심으로 분포하도록 이동시키거나 데이터의 확산 범위를 제한하는 정규화를 수행합니다. 그 외에도 전체 데이터를 균일하게 분포시키는 데이터 백색화 whitening 등도 있습니다.
3.6.3 배치 처리
구현 진도를 더 나가기 전에, 이번 절에서는 입력 데이터와 가중치 매개변수의 ‘형상’에 주의해서 조금 전의 구현을 다시 살펴보겠습니다.
우선 파이썬 인터프리터에서 앞서 구현한 신경망 각 층의 가중치 형상을 출력해보죠.
>>> x, _ = get_data()
>>> network = init_network()
>>> W1, W2, W3 = network['W1'], network['W2'], network['W3']
>>>
>>> x.shape
(10000, 784)
>>> x[0].shape
(784,)
>>> W1.shape
(784, 50)
>>> W2.shape
(50, 100)
>>> W3.shape
(100, 10)
이 결과에서 다차원 배열의 대응하는 차원의 원소 수가 일치함을 확인할 수 있습니다(편향은 생략했습니다). 그림으로는 [그림 3-26]처럼 됩니다. 확실히, 다차원 배열의 대응하는 차원의 원소 수가 일치하고 있군요. 그리고 최종 결과로는 원소가 10개인 1차원 배열 y가 출력되는 점도 확인합시다.
그림 3-26 신경망 각 층의 배열 형상의 추이

[그림 3-26]을 전체적으로 보면 원소 784개로 구성된 1차원 배열(원래는 $28 \times 28$인 2차원 배열)이 입력되어 마지막에는 원소가 10개인 1차원 배열이 출력되는 흐름입니다. 이는 이미지 데이터를 1장만 입력했을 때의 처리 흐름입니다.
그렇다면 이미지 여러 장을 한꺼번에 입력하는 경우를 생각해봅시다. 가령 이미지 100개를 묶어 predict() 함수에 한 번에 넘기는 것이죠. x의 형상을 $100 \times 784$로 바꿔서 100장 분량의 데이터를 하나의 입력 데이터로 표현하면 될 겁니다. 그림으로는 [그림 3-27]처럼 됩니다.
그림 3-27 배치 처리를 위한 배열들의 형상 추이

[그림 3-27]과 같이 입력 데이터의 형상은 $100 \times 784$, 출력 데이터의 형상은 $100 \times 10$이 됩니다. 이는 100장 분량 입력 데이터의 결과가 한 번에 출력됨을 나타냅니다. 가령 x[0]와 y[0]에는 0번째 이미지와 그 추론 결과가, x[1]와 y[1]에는 1번째의 이미지와 그 결과가 저장되는 식입니다.
이처럼 하나로 묶은 입력 데이터를 배치 batch라 합니다. 배치가 곧 묶음이란 의미죠. 이미지가 지폐처럼 다발로 되어 있다고 생각하면 됩니다.
NOTE_ 배치 처리는 컴퓨터로 계산할 때 큰 이점을 줍니다. 이미지 1장당 처리 시간을 대폭 줄여주는 것이죠. 크게 두 가지 이유가 있는데, 하나는 수치 계산 라이브러리 대부분이 큰 배열을 효율적으로 처리할 수 있도록 고도로 최적화되어 있기 때문입니다. 그리고 커다란 신경망에서는 데이터 전송이 병목이 되는 경우가 자주 있는데, 배치 처리를 함으로써 버스에 주는 부하를 줄인다는 것이 두 번째 이유입니다(정확히는 느린 I/O를 통해 데이터를 읽는 횟수에 비해, 빠른 CPU나 GPU로 순수 계산을 수행하는 비율이 높아집니다). 즉, 배치 처리를 수행함으로써 큰 배열로 이뤄진 계산을 하게 되는데, 컴퓨터에서는 큰 배열을 한꺼번에 계산하는 것이 분할된 작은 배열을 여러 번 계산하는 것보다 빠릅니다.
이제 배치 처리를 구현해보죠. 앞의 구현에서 달라진 부분을 굵게 강조했습니다.
x, t = get_data()
network = init_network()
batch_size = 100 # 배치 크기
accuracy_cnt = 0
for i in range(0, len(x), batch_size):
x_batch = x[i:i+batch_size]
y_batch = predict(network, x_batch)
p = np.argmax(y_batch, axis=1)
accuracy_cnt += np.sum(p == t[i:i+batch_size])
print("Accuracy:" + str(float(accuracy_cnt) / len(x)))
굵은 부분을 하나씩 풀어봅시다. 우선 range() 함수입니다. range() 함수는 range(start, end)처럼 인수를 2개 지정해 호출하면 start에서 end-1까지의 정수로 이뤄진 리스트를 반환합니다. 또, range(start, end, step)처럼 인수를 3개 지정하면 start에서 end-1까지 step 간격으로 증가하는 리스트를 반환합니다. 뭔가 복잡하지만 다음의 예를 보면 바로 이해될 겁니다.
>>> list( range(0, 10) )
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list( range(0, 10, 3) )
[0, 3, 6, 9]
이 range() 함수가 반환하는 리스트를 바탕으로 x[i:i+batch_size]에서 입력 데이터를 묶습니다. x[i:i+batch_n]은 입력 데이터의 $i$번째부터 $i+\text{batch_n}$번째까지의 데이터를 묶는다는 의미죠. 이 예에서는 batch_size가 100이므로 x[0:100], x[100:200], … 와 같이 앞에서부터 100장씩 묶어 꺼내게 됩니다.
그리고 앞에서도 나온 argmax()는 최댓값의 인덱스를 가져옵니다. 다만 여기에서는 axis=1이라는 인수를 추가한 것에 주의합시다. 이는 $100 \times 10$의 배열 중 1번째 차원을 구성하는 각 원소에서(1번째 차원을 축으로) 최댓값의 인덱스를 찾도록 한 것입니다(인덱스가 0부터 시작하니 0번째 차원이 가장 처음 차원입니다). 이 역시 예를 보면 쉽게 이해될 겁니다.
>>> x = np.array([[0.1, 0.8, 0.1], [0.3, 0.1, 0.6],
... [0.2, 0.5, 0.3], [0.8, 0.1, 0.1]])
>>> y = np.argmax(x, axis=1)
>>> print(y)
[1 2 1 0]
마지막으로 배치 단위로 분류한 결과를 실제 답과 비교합니다. 이를 위해 == 연산자를 사용해 넘파이 배열끼리 비교하여 True/False로 구성된 bool 배열을 만들고, 이 결과 배열에서 True가 몇 개인지 셉니다. 이 처리 과정은 다음 예에서 확인해보죠.
>>> y = np.array([1, 2, 1, 0])
>>> t = np.array([1, 2, 0, 0])
>>> print(y==t)
[ True True False True]
>>> np.sum(y==t)
3
이상으로 배치 처리 구현에 대한 설명을 마칩니다. 데이터를 배치로 처리함으로써 효율적이고 빠르게 처리할 수 있었습니다. 다음 장에서 진행할 신경망 학습에서도 이미지 데이터를 적절히 묶어서 학습하는데, 그때도 이번 장에서 구현한 배치 처리와 같은 방식으로 구현하게 됩니다.
지니의 배치 마법
- 지니: “도로시, 채점할 때 시험지 1장씩 채점하고 넘기는 것보다, 100장씩 묶음(배치)으로 착착 모아서 한 번에 처리하는 게 훨씬 효율적이겠지? 컴퓨터 하드웨어도 대량의 묶음 연산을 할 때 병목 없이 훨씬 빠르게 일하도록 최적화되어 있단다!”
- 도로시: “아하! 매번 파일을 읽고 쓰는 느린 속도에 발목 잡히지 않고, CPU나 GPU가 행렬 묶음을 한 번에 시원시원하게 처리하니까 처리 시간이 대폭 줄어드는구나!”
- 토토: “멍멍! (박스에 가득 찬 100장의 시험지를 보며) 한 번에 꿀꺽 처리하니까 신난다 멍!”
지니가 100장의 이미지 페이퍼 묶음을 상자(배치 100)에 담아 한 번에 가져가 효율적으로 연산하는 방법을 도로시와 토토에게 설명하고 있어요.