해커에게 전해들은 머신러닝 #4

이 글에서 사용한 코드는 깃허브에서 확인할 수 있습니다.

 

콘볼루션

이미지 분류에 관해서 특히 많이 사용하는 방법 중 하나가 콘볼루션 뉴럴 네트워크(Convolution Neural Networks)입니다. 콘볼루션 뉴럴 네트워크는 뉴욕 대학교의 교수이자 페이스북의 인공지능 연구소의 리더인 얀 리쿤(Yann LeCun) 박사가 고안한 것으로 유명합니다. 콘볼루션 뉴럴 네트워크를 줄여서 ConvNet 혹은 CNN 이라고 즐겨 표기하고 있습니다.

이전 챕터에서 뉴럴 네트워크를 이용해서 손글씨 숫자를 분류해 보았는데요. 우리가 사용한 뉴럴 네트워크는 레이어간의 뉴런들이 모두 연결되어 있었습니다. 즉 완전 연결 뉴럴 네트워크였습니다. 이에 비해 콘볼루션 뉴럴 네트워크는 한 레이어와 그 다음 레이어 간의 뉴런이 모두 연결되어 있지 않습니다. 예를 들어 입력 레이어의 9 개만 다음 레이어의 하나의 뉴런에 연결되거나 하는 식입니다.

그런데 입력 레이어에서 선택한 9 개가 입력 이미지의 픽셀을 일렬로 늘여 놓은 상태에서 순서대로 9 개를 선택하지 않습니다. 이미지라는 2D 데이터의 특성을 고려해 이미지의 왼쪽 모서리 영역에서 가로 세로 각 3 픽셀의 크기의 사각형 영역 픽셀 9 개를 선택합니다. 즉 이미지가 화면에 위치한 모습대로 데이터를 이용하는 것이죠. 그러다 보니 입력을 1 차원 배열로 나타내는 것보다 원래 이미지의 높이와 넓이를 행과 열로 표현한 2 차원 행렬로 관리하는 것이 연산할 때 훨씬 편리합니다.

hackers_4_1

그림 1. 콘볼루션 되는 2D 입력 이미지

이렇게 레이어는 하나의 행렬로 관리되다 보니 뉴런이란 말보다 그냥 레이어 유닛(unit)이란 말도 자주 사용하는 것 같습니다.

입력 데이터의 좌상단 3 x 3 픽셀에 가중치를 곱해서 히든 레이어의 좌상단 한 픽셀 혹은 한 뉴런에 매핑시킵니다. 곱해지는 가중치도 3 x 3 가 될 것이고 이렇게 입력 데이터 전체를 왼쪽에서 오른쪽으로, 위에서 아래로 한칸씩 이동하면서 계속 가중치를 곱해서 히든레이어의 뉴런에 매핑합니다.

no_padding_no_strides1

그림 2. 3×3 콘볼루션 애니메이션.  출처: conv_arithmetic

애니메이션으로 보면 더 이해하기 쉽습니다. 또 이러다 보니 콘볼루션 뉴럴 네트워크를 아래 그림처럼 레이어의 뉴런이 일렬로 늘어선 모습으로는 제대로 설명하기가 어렵습니다.

tikz41

그림 3. 전형적인 완전 연결 뉴럴 네트워크

그래서 레이어의 뉴런을 아래 그림처럼 입력 이미지의 정사각형의 모습 그대로 표현하는 경우가 일반적입니다. 아래 그림에서 서브샘플링(subsampling)은 다음 섹션에서 자세히 살펴 보겠습니다.

typical_cnn

그림 4. 전형적인 콘볼루션 뉴럴 네트워크

콘볼루션에 사용한 3 x 3 가중치를 필터(Filter) 혹은 커널(Kernel)이라고 종종 부릅니다. 그리고 필터를 통해 만들어진 히든 레이어의 행렬을 특성 맵(Feature Map)이라고 합니다. 위 그림에서 로봇 이미지를 콘볼루션한 첫번째 히든 레이어의 특성 맵이 여러개인 것을 볼 수 있습니다. 대부분의 콘볼루션 뉴럴 네트워크에서는 필터를 한개 이상 두어 히든 레이어의 특성 맵이 여러개 쌓이도록 하고 있습니다. 조금 다르게 표현하면 아래와 같이 2 차원의 특성 맵이 큐브 모양으로 쌓이는 것처럼 나타낼 수 있습니다.

depthcol

그림 5. 여러개의 커널이 콘볼루션 되는 그림. 출처: 스탠포드 cs231n 강의노트

위 그림은 32 x 32 컬러 이미지를 다섯개의 필터를 적용해 만든 특성 맵을 볼륨감 있게 도식화한 그림입니다. 컬러 이미지라서 입력 데이터가 RGB 컬러를 반영하여 3 만큼의 두께를 가진 배열로 묘사되었습니다. 특성 맵이 많다는 것은 그 만큼 많은 필터가 사용되었다는 의미입니다. 하지만 특성 맵 하나를 위해 콘볼루션에 사용된 필터는 하나이고 이 필터로 전체 이미지를 스캔하므로 완전 뉴럴 네트워크보다 전체적인 가중치 파라메타의 수는 대폭 줄어듭니다.

필터와 특성 맵의 역할은 이미지에서 어떤 특징을 찾아 학습하기 위해 이미지를 스캔하는 도구로 볼 수 있습니다. 다만 어떤 특징을 배울지는 사전에 정의되어 있지 않겠죠. 콘볼루션 뉴럴 네트워크는 완전 연결 뉴럴 네트워크에 비해 새로운 용어, 개념들을 많이 포함하고 있습니다. 하지만 입력에 가중치를 곱해서 출력 값을 만드는 기본 원리는 동일합니다. 그리고 마지막 레이어에는 완전 연결 레이어를 두고 소프트맥스 함수를 통해 클래스를 분류하는 과정을 거치는 것이 일반적입니다.

콘볼루션 뉴럴 네트워크를 직접 코드로 구현하면 좋겠지만 간단함을 유지하는 이 글의 범위를 벗어날 것 같습니다. 그리고 아쉽게도 사이킷런에는 콘볼루션 뉴럴 네트워크에 대한 기능은 아직 지원하지 않고 있습니다. 그래서 현재 가장 인기 있는 딥 러닝 라이브러리인 구글의 텐서플로우를 사용해서 예제를 만들어 보겠습니다.

 

스트라이드, 패딩

입력 데이터 위를 필터가 스캔할 때 한칸씩 이동하지 않고 두어칸씩 건너 뛰면서 이동할 수도 있습니다. 이렇게 건너뛰는 정도를 스트라이드(Stride)라고 합니다. 스트라이드가 크면 스캔하는 횟수가 줄어드니 만들어지는 특성맵의 크기도 당연히 작아집니다.

입력 이미지(i)가 4 x 4 이고 콘볼루션 필터(f)가 3 x 3 일 때 스트라이드(s)가 1 이면 출력되는 특성 맵(o)은 2 x 2 크기가 됩니다. 이를 간단한 공식으로 나타내면 아래와 같습니다. 아래 식에서 \lfloor \; \rfloor 기호는 소수 이하를 버린다는 뜻입니다.

o = \lfloor \dfrac{i - f}{s} \rfloor + 1

이 공식은 가로, 세로의 크기가 서로 다르더라도 상관없이 동일하게 적용할 수 있습니다. 즉 아래처럼 가로, 세로의 출력을 각각 구할 수 있는 거죠.

o_{width} = \lfloor \dfrac{i_{width} - f_{width}}{s_{width}} \rfloor + 1

o_{height} = \lfloor \dfrac{i_{height} - f_{height}}{s_{height}} \rfloor + 1

이 글에서는 간단하게 표기하기 위해서 가로, 세로가 동일하다고 가정하고 하나의 식으로 나타내도록 하겠습니다. 그리고 출력 특성 맵을 간단하게 출력 맵, 입력 이미지나 데이터를 입력 맵 이라고도 부릅니다.

스트라이드가 1 이더라도 필터 사이즈가 크면 출력 맵의 사이즈가 작아지게 됩니다. 이럴때 출력 사이즈를 키우고 입력 맵의 픽셀들을 좀 더 많이 스캔하기 위해서 입력 맵 주위에 값이 0 인 픽셀을 채우는 것을 제로 패딩(zero padding)이라고 합니다. 추가된 0 픽셀은 가중치가 학습하는데 영향을 미치지 않습니다.

아래 그림은 5 x 5 입력에 대해 4 x 4 필터를 스트라이드 1 로 콘볼루션하는 애니메이션입니다. 이 경우 위 공식에 의하면 출력의 크기는 2 가 됩니다. 이렇게 되면 출력의 크기가 너무 작으므로 입력 맵의 특성을 좀 더 풍부하게 스캔하기 위해서 입력 맵의 좌우 상하에 제로 패딩(p) 2 개를 추가했습니다. 패딩을 추가한 결과 출력 맵은 6 x 6 의 크기가 되었습니다.

arbitrary_padding_no_strides

그림 6. 2×2 패딩 콘볼루션 애니메이션. 출처: conv_arithmetic

제로 패딩이 추가된 콘볼루션의 공식은 아래와 같습니다. 처음 식에서 + 2p 항이 추가되었습니다.

o = \lfloor \dfrac{i - f + 2p}{s} \rfloor + 1 = \lfloor \dfrac{5 - 4 + 2 \times 2}{1} \rfloor + 1 = 6

스트라이드와 패딩을 사용하는 전형적인 예가 있는데요. 필터 사이즈의 절반을 패딩으로 넣는다고 하여 하프(haf) 패딩이라고 부르기도 하고 스트라이드가 1 일 때 출력과 입력이 동일한 사이즈가 된다고 해서 동일(same) 패딩이라고도 부르는 것입니다. 필터 사이즈의 절반이 패딩으로 들어가는 것을 위 식에 적용하면 아래와 같습니다.

o = \lfloor \dfrac{i - f + 2p}{1} \rfloor + 1 = i - f + 2\lfloor \dfrac{f}{2} \rfloor + 1 = i +2\lfloor \dfrac{f}{2} \rfloor - (f - 1)

필터(f)의 크기가 홀수이면 2\lfloor \dfrac{f}{2} \rfloor 는 (f - 1) 와 같게되어 출력과 입력의 크기가 같아집니다. 하지만 필터의 사이즈가 짝수이면 출력의 사이즈가 하나 더 커지게 되고 왼쪽이나 오른쪽에 패딩이 하나 더 들어가게 됩니다. 텐서플로우에서는 콘볼루션 연산 함수인 tf.nn.conv2d 의 padding 옵션에 'SAME' 으로 지정하는 것이 이와 같은 역할을 담당합니다.

텐서플로우의 tf.nn.con2d 의 padding 옵션에는 'VALID' 도 있습니다. 이 설정은 패딩을 전혀 넣지 않도록 합니다. padding 옵션외에 stride 옵션에서 필터가 좌우 방향으로 얼만큼 간격으로 움직일지 지정할 수 있습니다.

 

렐루

이제까지 활성화 함수로 시그모이드 함수와 소프트맥스 함수를 살펴보았었습니다. 최근에는 특히 콘볼루션 뉴럴 네트워크에서 렐루(ReLU, Rectifier Linear Unit) 함수가 좋은 성능을 내고 있다고 알려져 있습니다. 렐루 함수는 0 보다 작은 값은 0 으로 바꾸어 출력하고 0 보다 큰 값은 그냥 바이패스(bypass) 합니다. 그래프로 보면 훨씬 이해가 쉽습니다.

relu

그림 7. 렐루 함수 그래프.  출처: 스탠포드 cs231n 강의노트

입력(x 축)이 0 보다 클 경우에는 출력(y 축)은 입력과 동일합니다. 완전 선형(linear)적인 거죠. 입력이 0 보다 작을 경우엔 출력은 무조건 0 이 됩니다.

텐서플로우에서는 시그모이드 함수(tf.nn.sigmoid), 소프트맥스 함수(tf.nn.softmax) 뿐만 아니라 렐루 함수(tf.nn.relu) 등 여러 활성화 함수를 제공하고 있습니다.

 

서브샘플링

서브샘플링(subsampling)은 말그대로 출력 값에서 일부분만을 취하는 기능입니다. 콘볼루션 뉴럴 네트워크에서는 서브샘플링이란 말보다 풀링(pooling)이란 용어를 더 즐겨 사용합니다. 풀링은 활성화 함수를 거쳐 나온 출력 맵을 압축하는 역할을 한다고 볼 수도 있습니다.

풀링은 콘볼루션 처럼 가로, 세로 일정 간격으로 특성 맵을 스캔합니다. 하지만 콘볼루션처럼 필터를 곱하는 것이 아니고 특성 맵의 값을 평균 낸다거나 가장 큰 값을 뽑아서 사용합니다. 만약에 224 x 224 인 특성 맵에 2 x 2 풀링을 적용한다면 아래 그림처럼 결과가 112 x 112 로 줄어들게 됩니다.

hackers_4_2

그림 8. 2×2 풀링의 예.  출처: 스탠포드 cs231n 강의노트

보통 풀링은 콘볼루션과는 달리 겹치는 부분이 없이 스캔하기 때문에 스트라이드를 풀링 사이즈와 동일하게 지정합니다. 콘볼루션 처럼 간단한 식으로 풀링의 입력, 출력 관계를 나타내면 아래와 같습니다.

o = \lfloor \dfrac{i-k}{s} \rfloor + 1 = \lfloor \dfrac{10-2}{2} \rfloor + 1 = 5

많이 사용하는 풀링 방식은 평균 값을 계산하는 평균 풀링(average pooling)과 최대 값을 선택하는 맥스 풀링(max pooing)이 있습니다. 텐서플로우에서는 이에 해당하는 tf.nn.avg_pool 과 tf.nn.max_pool 함수를 제공하고 있습니다.

 

텐서 크기

콘볼루션 뉴럴 네트워크로 들어오면서 새로운 용어가 많이 생겨서 조금 어려울 수 있을 것 같습니다. 콘볼루션 뉴럴 네트워크도 입력 값과 가중치 행렬(필터)을 곱해서 출력을 만들지만 그 방식이 완전 연결 뉴럴 네트워크와는 조금 다른 것 뿐입니다. 대부분 콘볼루션 뉴럴 네트워크를 사용하는 분야가 분류(Classification) 문제이기 때문에 콘볼루션 레이어 마지막에는 완전 연결 레이어와 소프트맥스 레이어를 둔다는 점은 동일하다는 것을 기억해 주세요. 콘볼루션 레이어에서는 통상 필터를 여러개 사용하게 되지만 하나의 필터, 즉 하나의 가중치 행렬로 전체 입력을 스캔한다는 점도 중요합니다.

텐서플로우를 사용해서 이전 챕터에서 보았던 손글씨 숫자를 분류하는 콘볼루션 뉴럴 네트워크를 만들어 보겠습니다. 이전 챕터에서는 훈련 데이터 하나가 하나의 행으로 일렬로 펼쳐진 digits.data 를 그대로 사용했습니다. 즉 digits.data 는 행이 훈련 데이터 개개를 나타내는 2 차원 행렬이었죠.

콘볼루션 뉴럴 네트워크는 위에서 말한 대로 입력 값을 2 차원 배열 형태로 만듭니다. 아래 코드가 사이킷런에서 데이터가 입력된다고 가정하고 텐서플로우의 텐서(Tensor)를 2 차원 배열 형태로 만드는 코드입니다.

import tensorflow as tf

X = tf.placeholder(tf.float32, [None, 64])
X_input = tf.reshape(X, [-1, 8, 8, 1])
Y = tf.placeholder(tf.float32, [None, 10])

텐서플로우의 플레이스 홀더(tf.placeholder)는 함수의 입력 파라메타와 같은 역할을 한다고 생각하면 좋습니다. 텐서플로우의 API 로 뉴럴 네트워크의 구조를 구성하는 동안에는 실제 연산이 일어나지 않고 run 명령을 줄 때 만들어진 뉴럴 네트워크가 작동하게 됩니다. run 명령을 실행할 때 플레이스 홀더로 지정한 이름(X, Y)으로 데이터를 넘겨 줍니다.

이 방식이 조금 어색하게 느낄 수도 있는데요. 사실 우리가 사이킷런의 MLPClassifier 를 사용했던 것과 유사합니다. MLPClassifier 의 객체를 생성할 때 다양한 설정 값을 부여했고 실제 입력, 출력 데이터를 넣어준 것은 fit 메소드였으니까요. 텐서플로우도 같은 맥락에서 이해하면 편리합니다.

플레이스 홀더로 만들어진 것을 텐서(Tensor)라고 합니다. 텐서를 넘파이 배열과 비슷한 것이라고 생각하면 이해가 빠를 것 같습니다. 위 코드에서 첫번째 플레이스홀더 함수를 사용해서 X 텐서를 만들었습니다. 그런데 이 텐서는 2 차원이지만 첫번째 차원이 None 으로 정확한 값이 지정되어 있지 않네요. 우리가 이전에 FCNeuron 을 만들었던 기억을 되살려 보면 입력 값은 행이 훈련 데이터 개개를 나타내고 열은 각 픽셀을 나타내었습니다. 그런데 텐서플로우에서는 아직 입력 값이 준비되기 전에 뉴럴 네트워크 구조를 만들다 보니 몇개의 훈련 데이터가 입력될지 알수 없는 셈입니다. 따라서 텐서 X 의 첫번째 차원을 None 으로 지정하였습니다. Y 도 마찬가지 이유로 첫번째 차원을 None 으로 지정하였습니다.

tf.reshape 함수는 이전 예제에서 여러번 사용했던 넘파이의 reshape 메소드와 아주 유사합니다. 텐서의 배열 차원을 변경시켜 주는 것이죠. 콘볼루션에 사용할 수 있도록 두번째, 세번째 차원을 8 x 8 로 변경하였습니다. 텐서플로우의 콘볼루션 함수 tf.nn.conv2d 는 입력 값이 4 차원일 것으로 기대하고 있습니다. 이런 이유는 마지막 차원이 이미지의 컬러 채널에 해당하기 때문입니다. 우리가 다룰 예제는 흑백 이미지이므로 마지막 차원을 단순히 1 로 지정하면 될 것 같습니다.

완전 연결 뉴럴 네트워크에서 처럼 훈련 데이터로 1617 개를 입력 데이터로 넣고 각각의 데이터는 8 x 8 x 1 의 크기를 가집니다. 결국 입력 데이터는 1617 x 8 x 8 x 1 의 4 차원 배열이 되고 이를 3 x 3 x 1 필터로 콘볼루션 합니다. 스트라이드는 1 로하고 패딩은 텐서플로우의 SAME 패딩을 사용합니다. 스트라이드를 1 로 하였으므로 SAME 패딩은 출력과 입력의 크기를 동일하게 만들어 줍니다. 이런 필터를 총 32 개 만들어 입력 데이터를 스캔합니다.

만약 컬러 이미지일 경우 필터가 콘볼루션 되는 방향을 가로, 세로로 국한하지 않고 컬러 채널 방향으로도 지정할 수 있겠지만 이는 바람직하지 않으며 항상 입력의 컬러 채널, 즉 네번째 차원과 동일한 사이즈로 필터를 만들도록 합니다. 이렇게 생각하면 필터는 2 차원 배열이 아니고 3 차원 배열처럼 이해하는 게 더 직관적일 수 있습니다. 다시 말하면 입력 데이터의 3 차원 볼륨을 콘볼루션하는 것입니다.

출력의 크기가 같으므로 1617 x 8 x 8 크기의 특성 맵이 32 개가 만들어지게 됩니다. 결국 이 콘볼루션으로 1617 x 8 x 8 x 32 크기의 특성 맵이 만들어 집니다. 그런 후에 2 x 2 맥스 풀링을 적용하여 특성 맵의 사이즈를 절반으로 줄이도록 하겠습니다. 그럼 1617 x 4 x 4 x 32 사이즈로 특성 맵의 크기가 줄어들게 됩니다.

콘볼루션한 후에 특성 맵을 완전 연결 레이어에 펼쳐 놓게 됩니다. 훈련 데이터 한개당 4 x 4 x 32 개의 픽셀 정보 즉 뉴런을 절반 정도인 300 개의 뉴런에 완전 연결 시키도록 하겠습니다. 이렇게 하기 위해서 4 x 4 x 32 차원을 1 차원으로 다시 변경하여 완전 연결 레이어에 행렬 연산을 합니다. 여기서 부터는 이전에 했던 예제와 완전히 동일합니다. 그 다음에 300 개의 완전 연결 레이어에서 최종 출력 레이어 10 개의 뉴런에 완전 연결하고 소프트맥스 함수를 거쳐서 출력 값을 계산하면 됩니다.

이렇게 콘볼루션 레이어의 단계에서는 콘볼루션과 풀링으로 변하는 입력 맵과 출력 맵의 차원과 메모리 공간을 쫓아가는 습관을 들이는 것이 좋습니다. 그럼 텐서플로우로 콘볼루션 레이어를 구현해 보도록 하겠습니다.

 

콘볼루션 뉴럴 네트워크

텐서플로우에서 가중치와 바이어스를 만들 때는 tf.Variable 클래스를 사용합니다. 이 클래스는 이름을 보면 일반 프로그램의 변수와 같이 생각할 수 하지만 텐서를 저장하는 일종의 저장 공간이라고 볼 수도 있습니다. 가중치와 바이어스 텐서를 만들어 담아 놓으면 모델이 훈련 데이터로 학습을 진행하면서 tf.Variable 에 담긴 텐서들을 조정해 나가게 됩니다.

이전처럼 가중치와 바이어스 행렬를 일일이 값을 넣어서 초기화할 수는 없습니다. 여기서는 텐서플로우에서 제공해주는 랜덤 함수 tf.random_normal 을 사용해서 가중치와 바이어스를 초기화합니다. 콘볼루션 뉴럴 네트워크에서 초기값을 어떻게 지정하는지에 대한 좋은 가이드가 있습니다만 이 글에서 깊게 들어가지는 않겠습니다. 간단하게 여기서는 앞 레이어의 뉴런의 갯수를 제곱근의 역수로 하여 사용하도록 하겠습니다.

앞 섹션에서 언급한대로 3 x 3 x 1 필터를 32 개 만들 것이므로 가중치는 파라메타(W_1)는 3 x 3 x 1 x 32 차원으로 생성합니다. 바이어스(b_1)는 완전 연결 뉴럴 네트워크에서 뉴런 마다 한개씩 대응했던 것처럼 여기서는 콘볼루션 된 결과인 특성 맵 마다 한개씩 대응합니다. 따라서 32 개의 원소를 갖는 바이어스 텐서를 생성합니다. 단순한 행렬곱 연산이 아니고 콘볼루션이므로 입력 값과 가중치를 tf.nn.conv2d 에 입력하여 콘볼루션 계산을 수행합니다. 결과는 바이어스와 더해져서 출력(z_1)으로 저장합니다. 아래 conv2d 함수의 파라메타에서 볼 수 있듯이 스트라이드는 4 차원 배열 모든 방향으로 1 이고 패딩은 SAME 이므로 출력은 입력과 크기가 동일하게 됩니다. 마지막으로 활성화 함수로 렐루 함수 tf.nn.relu 를 사용하여 h_1 에 저장합니다.

W_1 = tf.Variable(tf.random_normal([3, 3, 1, 32], mean=0, stddev=1/np.sqrt(64)))
b_1 = tf.Variable(tf.random_normal([32], mean=0, stddev=1/np.sqrt(64)))
z_1 = tf.nn.conv2d(X_input, W_1, strides=[1, 1, 1, 1], padding='SAME') + b_1
h_1 = tf.nn.relu(z_1)
hackers_4_3

그림 9. 히든 레이어의 콘볼루션

표현상 계산, 저장이란 말을 쓰고 있지만 사실 우리는 뉴럴 네트워크 구조를 만들고 있을 뿐 진짜 계산을 하거나 값을 저장하고 있는 것이 아님을 기억해야 합니다. 텐서플로우는 내부적으로 이런 연산의 구조를 그래프 구조로 저장하고 있습니다. 뉴럴 네트워크 구조를 다 만들고 나서 실제 실행은 나중에 입력 데이터를 주입하면서 시작될 것입니다.

활성화 함수까지 거치고 나면 콘볼루션 레이어 하나를 만든 것이며 다음은 2 x 2 맥스 풀링 레이어를 만들 차례입니다. 콘볼루션 레이어에서 전달되는 특성 맵의 크기가 4 차원이고 흑백 데이터라 채널이 하나이므로  풀링의 크기(ksize)는 1 x 2 x 2 x 1 입니다. 그리고 스트라이드(strides)도 풀링의 크기와 동일하게 줍니다. 패딩 옵션이 SAME 으로 되어 있으므로 만약 풀링 크기보다 스트라이드가 클 경우 출력 맵 크기를 입력 맵과 맞추기 위해 패딩이 자동으로 추가될 것 입니다. 그리고 풀링은  앞서 설명한 것처럼 서브샘플링입니다. 무슨 가중치를 곱하거나 바이어스를 더하는 게 없는 거죠.

p_1 = tf.nn.max_pool(h_1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], 
                     padding='SAME')
hackers_4_4

그림 10. 풀링 레이어

풀링 사이즈가 2 x 2 이므로 훈련 데이터 마다 특성 맵의 크기가 8 x 8 x 32 에서 4 x 4 x 32 로 줄어 들었습니다. 풀링의 크기가 2, 3 차원만 2 로 주었고 4 차원은 1 이므로 특성 맵의 갯수는 줄어들지 않습니다. 그리고 나서 풀링 레이어를 거치고 난 특성 맵을 완전 연결 레이어에 연결하겠습니다. 완전 연결 레이어에 연결하려면 풀링 레이어를 거친 특성 맵을 일렬로 펼쳐야 합니다.

p_1_flat = tf.reshape(p_1, [-1, 4*4*32])

펼친 특성 맵 p_1_flat 을 300 개의 완전 연결 레이어에 연결합니다. 완전 연결 레이어에서 가중치를 곱하고 바이어스를 더하는 것은 우리가 만들었던 FCNeuron 과 동일합니다. 사용하는 함수만 바뀌었을 뿐이죠. 텐서플로우에서 행렬 곱은 tf.matmul 함수를 사용합니다. 그리고 활성화 함수로는 역시 렐루 함수를 사용합니다.

W_2 = tf.Variable(tf.random_normal([4*4*32, 300], mean=0, 
                  stddev=1/np.sqrt(4*4*32)))
b_2 = tf.Variable(tf.random_normal([300], mean=0, stddev=1/np.sqrt(4*4*32)))
z_2 = tf.matmul(p_1_flat, W_2) + b_2
h_2 = tf.nn.relu(z_2)
hackers_4_5

그림 11. 300개의 히든 유닛 레이어로 완전 연결

300 개의 뉴런을 마지막 출력 레이어의 뉴런 10 개에 연결하겠습니다. 위와 동일한 방식으로 tf.matmul 함수를 사용하여 이전 레이어의 출력 값과 가중치를 곱하고 바이어스를 더하도록 하겠습니다. 활성화 함수는 당연히 소프트맥스 함수이고 최종 출력 값은 y_ 에 저장합니다. 최종적으로 y_ 는 1617 x 10 크기의 텐서가 됩니다.

W_3 = tf.Variable(tf.random_normal([300, 10], mean=0, stddev=1/np.sqrt(300)))
b_3 = tf.Variable(tf.random_normal([10], mean=0, stddev=1/np.sqrt(300)))
z_3 = tf.matmul(h_2, W_3) + b_3
y_ = tf.nn.softmax(z_3)
hackers_4_6

그림 12. 출력 레이어

텐서플로우를 실행하면 자동으로 가중치나 바이어스 값을 채워주는 것은 아니고 변수 초기화를 명시적으로 실행해 주어야 합니다. 변수 초기화를 해주는 함수가 tf.global_variables_initializer 입니다(initialize_all_variables 함수가 텐서플로우 0.12 버전부터 global_variables_initializer 로 변경되었습니다). 이 함수를 실행하면 run 명령으로 실행시킬 수 있는 텐서플로우 연산자를 리턴해 줍니다.

init = tf.global_variables_initializer()

마지막으로 우리가 만들 것은 훈련 데이터의 타겟값 Y 와 모델이 계산한 y_ 의 로그 값을 곱한 크로스 엔트로피 비용 함수와 이 비용 함수를 경사 하강법을 사용해서 최적화 하라고 선언하는 것 입니다. 학습 속도는 0.0001 로 앞서 만든 FCNeuron 보다 훨씬 작게하였습니다. 여기서 만든 뉴럴 네트워크는 콘볼루션, 풀링 등의 여러 기법을 사용하여 모델이 한층 복잡해 졌습니다. 따라서 비용 함수가 만드는 공간도 매우 변화무쌍하게 되므로 이전보다 더 조심스럽게 가중치와 바이어스를 변경하려고 합니다.

cost_function = -tf.reduce_sum(Y * tf.log(y_))
optimizer = tf.train.GradientDescentOptimizer(0.0001).minimize(cost_function)

그리고 넘파이에 잇는 np.argmax 와 같은 tf.argmax 함수를 사용하여 가장 높은 값을 가진 클래스를 선택해서 소프트맥스 함수를 통과한 y_ 가 타겟값 Y 와 같은 값인지를 확인합니다. tf.reduce_mean 함수가 평균을 낼 때 정수 값이 입력되면 단순하게 소수점 이하를 절삭하기 때문에  tf.cast 로 입력 값을 부동소수점 값으로 변경합니다. 또 tf.cast 는 True, False 로 채워진 correct_prediction 텐서의 값을 숫자 값으로 변경하는 효과도 발휘합니다.

correct_prediction = tf.equal(tf.argmax(y_, 1), tf.argmax(Y, 1))
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

 

학습 결과

텐서플로우는 세션이라는 객체를 만들어 연산과 관련된 다양한 명령을 내릴 수 있고 그 결과 값을 돌려 받을 수 있습니다. 세션을 만들 때 리소스 관리를 편하게 하기 위해 보통 파이썬의 with 컨택스트 매니저를 많이 사용합니다.

세션을 만들면 맨 먼저 할 것은 위에서 만든 변수를 초기화하는 일입니다. 변수 초기와와 관련된 연산자를 init 에 만들어 두었으니 간단하게 sess.run() 함수를 호출하면 됩니다. 그리고 나서 2000 번 반복 학습을 시작합니다. 반복 학습에서 입력 값은 훈련 데이터 X_train 과 y_train_enc 을 각각 X 와 Y 플레이스 홀더에 주입하면서 optimizer 와 cost_function 을 계산하도록 sess.run 함수를 실행합니다. 실행된 결과는 optimizer 와 cost_function 에 각각 맞춰 두개가 리턴됩니다. 하지만 optimizer 는 결과 값을 돌려주는 계산이 아니므로 그냥 버립니다.

100 번의 반복마다 cost_function 의 리턴값 cost 와 accuracy 를 계산하여 프린트합니다. 그리고 마지막으로 모든 훈련이 끝나면 X_test 와 y_test_enc 테이터를 사용하여 테스트 정확도를 프린트합니다.

with tf.Session() as sess:
    sess.run(init)
    for i in range(2000):
        _, cost = sess.run([optimizer, cost_function], 
                           feed_dict={X: X_train, Y: y_train_enc})
        if i % 100 == 0:
            train_accuracy = sess.run(accuracy, 
                                      feed_dict={X: X_train, Y: y_train_enc})
            print("step %d, training accuracy %g, %g" %
                  (i, cost/X_train.shape[0], train_accuracy))

    test_accuracy = sess.run(accuracy, feed_dict={X: X_test, Y: y_test_enc})
    print("test accuracy %g" % (test_accuracy))
step 0, training accuracy 2.30805, 0.111317
step 100, training accuracy 0.258954, 0.931973
step 200, training accuracy 0.106875, 0.973408
step 300, training accuracy 0.06407, 0.98825
step 400, training accuracy 0.0429992, 0.992579
step 500, training accuracy 0.0310618, 0.996908
step 600, training accuracy 0.0234538, 0.997526
step 700, training accuracy 0.0183058, 0.997526
step 800, training accuracy 0.0146601, 0.998763
step 900, training accuracy 0.011993, 0.999382
step 1000, training accuracy 0.0099968, 1
step 1100, training accuracy 0.00846826, 1
step 1200, training accuracy 0.0072722, 1
step 1300, training accuracy 0.00632992, 1
step 1400, training accuracy 0.00557247, 1
step 1500, training accuracy 0.0049536, 1
step 1600, training accuracy 0.00444069, 1
step 1700, training accuracy 0.00401012, 1
step 1800, training accuracy 0.00364572, 1
step 1900, training accuracy 0.00333434, 1
test accuracy 0.983333

결과를 보면 훈련은 1000 번이 넘으면서 100% 정확도를 내었고 비용 값은 계속 조금씩 더 감소하는 것을 볼 수 있습니다. 하지만 테스트 결과는 100% 를 내진 못하고 98.3% 정도를 내었습니다. 아마도 훈련 데이터를 크로스 밸리데이션으로 나누어서 콘볼루션 뉴럴 네트워크에서 사용한 다양한 하이퍼 파라메타를 튜닝하는 것이 테스트 정확도를 높일 수 있는 한 방법일 것 같습니다.

아래 참고를 위해 전체 코드를 싣습니다.

from sklearn.datasets import load_digits
digits = load_digits()
digits_data = digits.data / 16

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(digits_data, digits.target, test_size=0.1)

from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(n_values=10)
y_train_enc = ohe.fit_transform(y_train.reshape(-1, 1)).toarray()
y_test_enc = ohe.fit_transform(y_test.reshape(-1, 1)).toarray()
y_train_enc.shape, y_test_enc.shape

import tensorflow as tf

X = tf.placeholder(tf.float32, [None, 64])
X_input = tf.reshape(X, [-1, 8, 8, 1])
Y = tf.placeholder(tf.float32, [None, 10])

W_1 = tf.Variable(tf.random_normal([3, 3, 1, 32], mean=0, stddev=1/np.sqrt(64)))
b_1 = tf.Variable(tf.random_normal([32], mean=0, stddev=1/np.sqrt(64)))
z_1 = tf.nn.conv2d(X_input, W_1, strides=[1, 1, 1, 1], padding='SAME') + b_1
h_1 = tf.nn.relu(z_1)

p_1 = tf.nn.max_pool(h_1, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')
p_1_flat = tf.reshape(p_1, [-1, 4*4*32])

W_2 = tf.Variable(tf.random_normal([4*4*32, 300], mean=0, stddev=1/np.sqrt(4*4*32)))
b_2 = tf.Variable(tf.random_normal([300], mean=0, stddev=1/np.sqrt(4*4*32)))
z_2 = tf.matmul(p_1_flat, W_2) + b_2
h_2 = tf.nn.relu(z_2)

W_3 = tf.Variable(tf.random_normal([300, 10], mean=0, stddev=1/np.sqrt(300)))
b_3 = tf.Variable(tf.random_normal([10], mean=0, stddev=1/np.sqrt(300)))
z_3 = tf.matmul(h_2, W_3) + b_3
y_ = tf.nn.softmax(z_3)

init = tf.global_variables_initializer()

cost_function = -tf.reduce_sum(Y * tf.log(y_))
optimizer = tf.train.GradientDescentOptimizer(0.0001).minimize(cost_function)

correct_prediction = tf.equal(tf.argmax(y_, 1), tf.argmax(Y, 1))
accuracy = tf.reduce_mean(tf.cast(correct_prediction, tf.float32))

with tf.Session() as sess:
    sess.run(init)
    for i in range(2000):
        _, cost = sess.run([optimizer, cost_function], feed_dict={X: X_train, Y: y_train_enc})
        if i % 100 == 0:
        train_accuracy = sess.run(accuracy, feed_dict={X: X_train, Y: y_train_enc})
        print("step %d, training accuracy %g, %g"%(i, cost/X_train.shape[0], train_accuracy))

    test_accuracy = sess.run(accuracy, feed_dict={X: X_test, Y: y_test_enc})
    print("test accuracy %g" % (test_accuracy))

 

여기가 이 연재의 끝입니다. 재미있게 읽으셨는지 모르겠네요. 앞으로 더 익히면서 조금씩 보완해 나가도록 하겠습니다. 감사합니다.

 

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중