본문 바로가기

TensorFlow

CNN을 활용한 MNIST 숫자인식 코드 야매 해석

Convolutional Neural Network(CNN) 기반의 MNIST 숫자 인식 모델에 대한 코드를 느낌 가는대로 해석해보았다

 

원본 코드는 다음과 같다

# -*- coding: utf-8 -*-
"""
Created on Wed Jul 29 14:27:42 2020

@author: Droomii
"""

import tensorflow.compat.v1 as tf
from tensorflow.examples.tutorials.mnist import input_data

mnist = input_data.read_data_sets('MNIST_data/', one_hot=True)

# Using Interactive session makes it the default sessions so we do not need to pass sess
sess = tf.InteractiveSession()

# Define placeholders for MNIST input data
x = tf.placeholder(tf.float32, shape=[None, 784])
y_ = tf.placeholder(tf.float32, [None, 10])

# change the MNIST input data from a list of values to a 28 x 28 x 1 grascale value cube
# which the Convolution NN can use
#                       -1 : make this a list of the other dimensions
x_image = tf.reshape(x, [-1,28,28,1], name="x_image")

# Define helper functions to created weights and biases variables, and convolution, and pooling layers
# Relu is used as activation function.  Must be initialized to a small positive number
#  and with some noise so it doesn't end up going to zero when comparing diffs
def weight_variable(shape):
    initial = tf.truncated_normal(shape, stddev=0.1)
    return tf.Variable(initial)

def bias_variable(shape):
    initial = tf.constant(0.1, shape=shape)
    return tf.Variable(initial)

# Convolution and Pooling - we do Convolution, and then pooling to control overfitting
def conv2d(x, W):
    return tf.nn.conv2d(x, W, strides=[1,1,1,1], padding='SAME')

def max_pool_2x2(x):
    return tf.nn.max_pool(x, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME')


# Define layers in the NN
    
# ------------ 1st convolution layer ------------------
# 32 features for each 5x5 patch of the image
W_conv1 = weight_variable([5,5,1,32]) # width, height, channel(grayscale, so 1), num. of features
b_conv1 = bias_variable([32])

# Do convolution on images, add bias and push through ReLU activation
h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)

# take results and run through max_pool
h_pool1 = max_pool_2x2(h_conv1)


# ------------- 2nd convolution layer --------------
# process the 32 features from conv. layer 1, in 5x5 patch. Return 64 features weights and biases
W_conv2 = weight_variable([5,5,32,64]) # width, height, channel(32 features), num. of features
b_conv2 = bias_variable([64])

# Do convolution on images, add bias and push through ReLU activation
h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)

# take results and run through max_pool
h_pool2 = max_pool_2x2(h_conv2)

# Fully Connected Layer
W_fc1 = weight_variable([7*7*64, 1024])
b_fc1 = bias_variable([1024])


# Connect output of pooling layer 2 as input to full connected layer
h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

# dropout some neurons to reduce overfitting
keep_prob = tf.placeholder(tf.float32)
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

# Readout layer
W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])

# Define model
y_conv = tf.matmul(h_fc1_drop, W_fc2)  + b_fc2

# Loss measurement
cross_entropy = tf.reduce_mean(
        tf.nn.softmax_cross_entropy_with_logits(labels=y_, logits=y_conv))


# Loss optimization
train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)

# correct prediction
correct_prediction = tf.equal(tf.argmax(y_conv, 1), tf.argmax(y_,1))

# get accuracy
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

sess.run(tf.global_variables_initializer())

# ------------------------train model-------------------------
import time

# define number of steps and how often we display progress
num_steps = 3000
display_every = 100

# Start timer
start_time = time.time()
end_time = time.time()

for i in range(num_steps):
    batch = mnist.train.next_batch(50)
    train_step.run(feed_dict={x:batch[0], y_:batch[1], keep_prob:0.5})
    
    # Periodic status display
    if i % display_every == 0:
        train_accuracy = accuracy.eval(feed_dict={
                x: batch[0],
                y_ : batch[1],
                keep_prob : 1.0})
        end_time = time.time()
        print("step {0}, elapsed time {1:.2f} sec, training accuracy {2:.3f}%".format(i, end_time-start_time, train_accuracy*100))
        
# display summary
# Time to train
end_time = time.time()
print("total training time for {0} batches : {1:.2f} sec.".format(i+1, end_time-start_time))

# Accuracy on test data
print("Test accuracy {0:.3f}%".format(accuracy.eval(feed_dict={
        x : mnist.test.images,
        y_ : mnist.test.labels,
        keep_prob : 1.0}) *100.0))
    
sess.close()

 

# Define placeholders for MNIST input data
x = tf.placeholder(tf.float32, shape=[None, 784])
y_ = tf.placeholder(tf.float32, [None, 10])

MNIST 숫자 데이터를 담을 x 변수와, 모델이 추측한 y_ 변수를 담을 placeholder이다.

 

shape의 가로가 None인 이유는 몇 개의 데이터를 넣을지 정하지 않았기 때문이다.

 

MNIST 숫자 데이터는 28x28의 픽셀 이미지이며, 총 784개의 픽셀을 가지고 있기 때문에 높이가 784이고,

 

0부터 9까지 총 10개의 결과값이 나올 수 있기 때문에 y_는 높이가 10이 된다.


# change the MNIST input data from a list of values to a 28 x 28 x 1 grascale value cube
# which the Convolution NN can use
#                       -1 : make this a list of the other dimensions
x_image = tf.reshape(x, shape=[-1,28,28,1], name="x_image")

CNN 모델의 특징은 2차원 데이터를 여러 개의 2차원 데이터로 쪼개어 학습을 하기 때문에,

 

2차원 데이터가 입력되어야 한다.

 

MNIST의 숫자 데이터는 2차원 배열이 아닌 1차원 배열로 제공되므로, 2차원 배열로 변환해줄 필요가 있다.

 

tensorflow의 reshape 메서드는 배열의 shape를 변환해준다.

 

shape 파라미터 리스트 안에 -1이 들어가있다면, 모델 내에서 리스트 내 다른 숫자를 참고하여 알맞은 값을 추측한다.

 

여기 -1에 들어가야 할 숫자는 학습 데이터의 갯수이다.

 

* 아래부터는 하나의 학습 데이터만 들어갔을 때를 가정해서 해석한다.

 

맨 뒤 숫자인 1은 숫자 샘플이 RGB 채널을 가지지 않은 흑백(grayscale)이기 때문에 1로 지정하였다.

 

만약 흑백이 아니라 색깔이 들어있었다면 맨 마지막은 3이 되었을 것이다.

 

위 코드를 실행하면 x는 28x28x1인 3차원 배열 여러개가 배열 형태로 x_image 변수에 저장된다.

 

즉 x_image는 4차원 배열이 된다.

 


# Define helper functions to created weights and biases variables, and convolution, and pooling layers
# Relu is used as activation function.  Must be initialized to a small positive number
#  and with some noise so it doesn't end up going to zero when comparing diffs
def weight_variable(shape):
    initial = tf.truncated_normal(shape, stddev=0.1)
    return tf.Variable(initial)

평균이 0이면서 표준편차가 0.1인 무작위값을 가진 shape 모양의 행렬을 생성하는 함수이다.

 

tf.truncated_normal은 평균으로부터 표준편차의 2배를 넘어간 랜덤값은 반영하지 않고 다시 랜덤을 굴린다.

 

즉 평균이 0이고 stddev가 0.1이면 생성되는 무작위값은 -0.2~0.2이다.

 


def bias_variable(shape):
    initial = tf.constant(0.1, shape=shape)
    return tf.Variable(initial)

 

모든 요소의 값이 0.1인 shape 모양의 행렬을 만드는 함수다.


# Convolution and Pooling - we do Convolution, and then pooling to control overfitting
def conv2d(x, W):
    return tf.nn.conv2d(x, W, strides=[1,1,1,1], padding='SAME')

 

conv2d 연산의 파라미터값을 통일하기 위해 새로운 함수로 포장하였다.

 

x는 입력되는 데이터로, 4차원 배열(3차원이 들어간다.

 

여기엔 이미지 데이터가 전달되므로 [이미지_데이터_갯수, 너비, 높이, 채널(흑백이면 1, RGB면 3)]의 모양이 될 것이다. 

 

 

W는 x를 어느 정도의 크기로 쪼개서 연산할지 결정하는 파라미터로, 4차원의 배열이 전달된다.

 

W의 shape는 [너비, 높이, 채널, 필터_갯수]이다.

 

strides 파라미터는 가로/세로로 몇 칸씩 움직이면서 연산을 할지 결정한다.

 

위 코드의 파라미터는 [1,1,1,1] 이므로 가로 한 칸, 세로 한 칸씩 움직이며 연산을 하게 된다.

 

padding 파라미터는 가장자리의 값들을 연산할 때 행렬에서 벗어난 부분을 어떻게 처리할 지에 대한 파라미터이다.

 

'SAME'일 경우 벗어난 부분에 0을 넣어 연산하고, 'VALID'일 경우 벗어난 부분이 존재하는 구간은 연산하지 않는다.

 

예를 들어, W의 너비와 높이가 [5,5]이고, padding이 'SAME'인 경우,

 

벗어난 부분에 0을 넣어 연산하므로 첫 연산 구간은 다음 그림과 같다.

 

padding='SAME'

 

반면 padding='VALID'인 경우, 

 

벗어난 부분이 포함된 구간은 연산하지 않기 때문에 첫 연산 구간은 다음과 같다.

 

padding='VALID'

 

이와 같이 padding의 설정에 따라 연산 개수가 달라지기 때문에,

 

출력되는 결과의 shape 또한 변한다.

 

위 그림은 10x10의 데이터를 5x5의 필터로 연산하는데,

 

padding이 SAME일 경우 연산 횟수가 동일하기 때문에 출력 결과의 shape 또한 10x10이다.

 

반면 padding이 VALID일 경우 가로 6칸, 세로 6칸 움직이며 연산하기 때문에,

 

출력 결과의 shape는 입력 데이터보다 작은 6x6이 된다.

 


def max_pool_2x2(x):
    return tf.nn.max_pool(x, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME')

 

max_pool_2x2 함수 : 입력된 데이터를 2x2로 쪼갠 후, 4개의 값 중 가장 큰 값만 남기고 나머지는 버린다.

 

그림으로 나타낸 max_pool_2x2

 

결과적으로 데이터가 이 함수를 거치게 되면, 출력되는 데이터의 양은 1/4로 줄어든다.


# ------------ 1st convolution layer ------------------
# 32 features for each 5x5 patch of the image
W_conv1 = weight_variable([5,5,1,32]) # width, height, channel(grayscale, so 1), num. of features
b_conv1 = bias_variable([32])

첫 컨볼루션 레이어를 생성한다.

 

5x5 크기로 데이터를 나누어 볼 것이며, 총 32개의 feature를 출력할 것이다.

 

대략 이런 느낌?

 

32개의 feature를 출력할 것이므로 이에 대한 bias의 크기도 32가 된다.

 


# Do convolution on images, add bias and push through ReLU activation
h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)

본격적으로 연산이 시작된다.

 

앞서 28x28의 모양으로 변환한 MNIST의 이미지 데이터와, 컨볼루션 레이어에 대해 컨볼루션 연산을 수행한 후,

 

거기에 bias를 더한 값 중 0 미만인 값은 모두 0으로 설정한다.(ReLU 함수)

 

 

 

연산 과정을 이해한대로 표현해보았다.

 

1. 곱할 weight값을 가져온다

 

2. 숫자 데이터의 일부(5x5)를 따온다

 

정확한 수치는 아님(모두 랜덤값)

3. 따온 숫자 데이터의 일부를 (1,25)의 모양으로,

 

가져온 weight값의 행렬을 (25,1)의 모양으로 재배열한 후 행렬곱을 수행한다

 

숫자 데이터 일부를 1행 25열로 재배열
weight값의 행렬을 25행 1열로 재배열
행렬곱 수행

행렬곱을 수행한 결과는 단 하나의 숫자(n)이 된다.

 

 

4. 위 과정을 행, 열마다 반복해서 나온 값들로 행렬을 만든다.

 

예시에서는 28x28의 이미지를 다루면서 padding이 SAME이므로, 만들어지는 행렬 또한 28x28이 된다.

 

5. 마지막으로 결과에 bias를 더한다.

 

 

예시 코드에서 이 과정을 각 feature마다 진행해주면, 28x28의 행렬이 32개 생성된다.

 


# take results and run through max_pool
h_pool1 = max_pool_2x2(h_conv1)

앞의 과정에서 생성된 28x28의 행렬을 14x14로 축소해준다.

 


# ------------- 2nd convolution layer --------------
# process the 32 features from conv. layer 1, in 5x5 patch. Return 64 features weights and biases
W_conv2 = weight_variable([5,5,32,64]) # width, height, channel(32 features), num. of features
b_conv2 = bias_variable([64])

# Do convolution on images, add bias and push through ReLU activation
h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)

# take results and run through max_pool
h_pool2 = max_pool_2x2(h_conv2)

두 번째 convolution layer이다.

 

W_conv2의 channel이 32인 이유는 첫 번째 conv. 연산에서 32개의 feature를 출력했기 때문이다.

 

앞선 과정과 같이 동일하게 conv. 연산을 해주고, 64개의 feature를 출력한다.

 

결과에 max_pool_2x2 함수를 적용시켜주었으므로 각 feature의 모양은 이전보다 더 축소된 7x7이 된다.


# ------------- Fully Connected Layer ---------------
W_fc1 = weight_variable([7*7*64, 1024])
b_fc1 = bias_variable([1024])


# Connect output of pooling layer 2 as input to full connected layer
h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

 

다음 layer는 fully connected layer인데,

 

이는 흔히 다음 그림과 같이 이전 layer의 노드와 다음 layer의 노드를 모두 연결해준다고 한다

Fully Connected Layer

이 그림은 행렬곱(matmul)을 쉽게 표현하기 위해 이런 모양으로 그린 것이다.

 

코드를 보면 fully connected layer의 weight 변수(W_fc1)의 모양은 [7*7*64, 1024]이다.

 

즉 7*7*64개의 입력을 받고 1024개의 출력을 내겠다는 뜻이다.

 

이전 layer(h_pool2)의 열을 7*7*64로 맞추는 것을 볼 수 있는데,

 

이는 새로 생성한 layer의 weight값을 이전 layer의 출력값에 행렬곱을 하기 위해 행과 열의 크기를 맞춘 것이다.

 

* 앞서 말했듯이 하나의 학습 데이터만 들어갔을 때를 가정하므로 예시에서는 행의 갯수가 1이 된다.

 

행렬곱 수행


# dropout some neurons to reduce overfitting
keep_prob = tf.placeholder(tf.float32)
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

모델의 Overfitting을 줄이기 위한 코드이다.

 

이는 몇몇 뉴런(출력 결과)의 값을 0으로 바꿈으로써 다음 레이어의 값에 영향을 끼치지 않게 한다.

 


# Readout layer
W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])

# Define model
y_conv = tf.matmul(h_fc1_drop, W_fc2)  + b_fc2

 최종적으로 인식 결과가 출력되는 layer이다.

 

앞선 layer에서 출력된 1024개의 결과에 새로 생성한 (1024, 10) 모양의 레이어를 곱연산하여,

 

0부터 9까지, 총 10개의 값을 출력하는데,

 

각 값은 해당 숫자일 확률에 대한 값을 가진다.


# Loss measurement
cross_entropy = tf.reduce_mean(
        tf.nn.softmax_cross_entropy_with_logits(labels=y_, logits=y_conv))


# Loss optimization
train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)

모델의 예측값과 실제 정답의 loss(오차? 손실?)를 계산하기 위해 교차 엔트로피를 사용한다.

 

y_ 는 실제 정답이고, y_conv는 모델이 예측한 답이다.

 

loss를 최소화, 즉 모델을 최적화 시키기 위해서는 Adam 옵티마이저를 사용한다.

 

Adam 옵티마이저 Gradient Descent의 종류 중 하나인데,

 

step size를 다양하게 변형시킴으로써 모델을 더 안정적으로 만들어준다고 한다.

 


# correct prediction
correct_prediction = tf.equal(tf.argmax(y_conv, 1), tf.argmax(y_,1))

# get accuracy
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

모델의 정확도를 측정하는 코드이다.

 

예측값과 실제 값이 동일한지 확인하여 True 혹은 False로 이루어진 배열을 반환한다.

 

그 배열의 True를 1, False를 0으로 변환하여 평균을 구하면 정확도가 나온다.

 


sess.run(tf.global_variables_initializer())

# ------------------------train model-------------------------
import time

# define number of steps and how often we display progress
num_steps = 3000
display_every = 100

# Start timer
start_time = time.time()
end_time = time.time()

for i in range(num_steps):
    batch = mnist.train.next_batch(50)
    train_step.run(feed_dict={x:batch[0], y_:batch[1], keep_prob:0.5})
    
    # Periodic status display
    if i % display_every == 0:
        train_accuracy = accuracy.eval(feed_dict={
                x: batch[0],
                y_ : batch[1],
                keep_prob : 1.0})
        end_time = time.time()
        print("step {0}, elapsed time {1:.2f} sec, training accuracy {2:.3f}%".format(i, end_time-start_time, train_accuracy*100))
        
# display summary
# Time to train
end_time = time.time()
print("total training time for {0} batches : {1:.2f} sec.".format(i+1, end_time-start_time))

# Accuracy on test data
print("Test accuracy {0:.3f}%".format(accuracy.eval(feed_dict={
        x : mnist.test.images,
        y_ : mnist.test.labels,
        keep_prob : 1.0}) *100.0))
    
sess.close()

이 부분은 실제로 모델을 학습시키는 코드다.

 

3천 번의 학습을 돌리며, 매 100회마다 정확도를 출력하게 코딩하였다.

 

학습이 끝났으면 최종 정확도를 출력하고 마친다.

 


어렵다.....ㅋ