케라스 창시자에게 배우는 딥러닝/ 시작하기 전에: 신경망의 수학적 구성 요소

신경망과의 첫 만남

  • 딥러닝의 “hello world”인 MNIST 데이터셋을 이용해서  신경망 예제를 살펴보겠다.
    • 실제 실습은 3장에서 할 것이고, 여기서는 개념만 살펴본다.
  • 머신 러닝에서 분류 문제의 범주(category)를 클래스(class)라고 한다. 데이터 포인트는 샘플(sample)이라고 한다. 특정 샘플의 클래스는 레이블(label)이라고 한다.

  • MNIST 데이터셋은 넘파이(NumPy) 배열 형태로 케라스에 이미 포함되어 있다.
from keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
  • train_images와 train_labels가 모델이 학습해야 할 훈련 세트(training set)를 구성한다. 모델은 test_images와 test_labels로 구성된 테스트 세트(test set)에서 테스트될 것이다.
  • 이미지는 넘파이 배열로 인코딩 되어 있고 레이블은 0-9까지의 숫자배열이다. 이미지와 레이블은 일대일 관계이다.
  • 훈련 데이터와 테스트 데이터를 살펴 보자.
train_images.shape
# (60000, 28, 28)

len(train_labels)
# 60000

train_labels
# array([5, 0, 4, ... , 5, 6, 8], dtype=unit8)

test_images.shape
# (10000, 28, 28)

len(test_labels)
# 10000

test_labels
# array([7, 2, 1, ... , 4, 5, 6], dtype=unit8)
  • 작업 순서는 다음과 같다.
    • 먼저 훈련 데이터 train_images와 train_labels를 네트워크에 주입한다.
    • 그러면 네트워크는 이미지와 레이블을 연관시킬 수 있도록 학습된다.
    • 마지막으로 test_images에 대한 예측을 네트워크에 요청한다.
    • 그리고 이 예측이 test_labels와 맞는지 확인할 것이다.
  • 신경망을 만들어 보자
from keras import models
from keras import layers

network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))
  • 신경망의 핵심 고숭요소는 일종의 데이터 처리 필터라고 생각할 수 있는 층(layer)이다. 어떤 데이터가 들어가면 더 유용한 형태로 출력된다.
    • 좀 더 구체적으로 층은 주어진 문제에 더 의미 있는 표현(representation)을 입력된 데이터로부터 추출한다.
    • 대부분의 딥러닝은 간단한 층을 연결하여 구성되어 있고, 점진적으로 데이터를 정제하는 형태를 띠고 있다.
    • 딥러닝 모델은 데이터 정제 필터(층)가 연속되어 있는 데이터 프로세싱을 위한 여과기와 같다.
  • 이 예에서는 조밀하게 연결된 (또는 완전 연결(fully connected)된) 신경망 층인 Dense 층 2개가 연속되어 있다.
    • 두 번째 (즉 마지막) 층은 10개의 확률 점수가 들어 있는 배열(모두 더하면 1)을 반환하는 소프트맥스(softmax) 층이다.
    • 각 점수는 현재 숫자 이미지가 10개의 숫자 클래스 중 하나에 속할 확률이 된다.
  • 신경망이 훈련 준비를 마치기 위해 컴파일 단계에 포함될 세 가지가 더 필요하다.
    • 손실 함수(loss function): 훈련 데이터에서 신경망의 성능을 측정하는 방법으로 네트워크가 옳은 방향으로 학습될 수 있도록 도와준다.
    • 옵티마이저(optimizer): 입력된 데이터와 손실 함수를 기반으로 네트워크를 업데이트하는 메커니즘
    • 훈련과 테스트 과정을 모니터링할 지표: 여기서는 정확도(정확히 분류된 이미지의 비율)만 고려하겠다.
  • 손실 함수와 옵티마이저의 정확한 목적은 이어지는 장에서 자세히 설명하겠다.
network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
  • 훈련을 시작하기 전에 데이터를 네트워크에 맞는 크기로 바꾸고 모든 값을 0과 1사이로 스케일 조정한다.
    • 예컨대 우리의 훈련 이미지는 [0, 255] 사이의 값인 uint8 타입의 (60000, 28 * 28) 크기를 가진 배열로 저장되어 있는데, 이 데이터를 0과 1사이의 값을 가지는 float32 타입의 (60000, 28 * 28) 크기인 배열로 바꾼다.
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255
  • 또 레이블을 범주형으로 인코딩해야 하는데, 이 단계는 다음 장에서 설명하겠다.
from keras.utils import to_categorical

train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)
  • 이제 신경망을 훈련시킬 준비가 되었다. 케라스에서는 fit 메서드를 호출하여 데이터에 모델을 학습 시킨다.
network.fit(train_images, train_labels, epochs=5, batch_size=128)

# Epoch 1/5
# 60000/60000 [===========================================] - 1s 22us/step - loss: 0.2571 - acc: 0.9257
# Epoch 2/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.1027 - acc: 0.9695
# Epoch 3/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0686 - acc: 0.9797
# Epoch 4/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0494 - acc: 0.9856
# Epoch 5/5
# 60000/60000 [===========================================] - 1s 12us/step - loss: 0.0368 - acc: 0.9894
  • 훈련 하는 동안 2개의 정보가 출력되는데, 훈련 데이터에 대한 네트워크의 손실과 정확도이다.
  • 훈련 데이터에 대해 0.989(98.9%)의 정확도를 금방 달성했다. 테스트 세트에서도 모델이 잘 작동하는지 확인해 보자.
test_loss, test_acc = network.evaluate(test_images, test_labels)
# 10000/10000 [===========================================] - 0s 16us/step

print('test_acc:', test_acc)
# test_acc: 0.9789
  • 테스트 세트의 정확도는 97.8%로 나왔다.
    • 훈련 세트 정확도 보다 약간 낮은데, 훈련 정확도와 테스트 정확도 사이의 차이는 과대적합(overfitting) 때문이다. 이는 머신 러닝 모델이 훈련 데이터보다 새로운 데이터에서 성능이 낮아지는 경향을 말한다.
    • 과대적합에 대해서는 3장에서 자세히 논의하겠다.

신경망을 위한 데이터 표현

  • 최근의 모든 머신 러닝 시스템은 일반적으로 텐서를 기본 데이터 구조로 사용한다. 텐서는 머신 러닝의 기본 구성 요소이다.
  • 텐서는 데이터를 위한 컨테이너(container)이다.
    • 거의 항상 수치형 데이터를 다루므로 숫자를 위한 컨테이너이다.
    • 텐서는 임의의 차원 개수를 가지는 행렬의 일반화된 모습이다. (텐서에서는 차원(dimension)을 종종 축(axis)라고 부른다.)

스칼라(0D 텐서)

  • 하나의 숫자만 담고 있는 텐서를 스칼라(scalar) (또는 스칼라 텐서, 0차원 텐서, 0D 텐서)라고 부른다.
    • 넘파이에서는 float32, float64 타입의 숫자가 스칼라 텐서(또는 배열 스칼라(array scalar)) 이다.
    • ndim 속성을 사용하면 넘파이 배열의 축 개수를 확인할 수 있다.
    • 스칼라 텐서의 축 개수는 0이다(ndim==0)
    • 텐서의 축 개수를 랭크(rank)라고 부른다.

벡터(1D 텐서)

  • 숫자의 배열을 벡터(vector) 또는 1D 텐서라고 부른다.
    • 1D 텐서는 딱 하나의 축을 가진다.
    • 5개의 원소를 가진 배열을 5차원 벡터라고 부르는데, 5D 벡터와 5D 텐서를 혼동하지 말 것

행렬(2D 텐서)

  • 벡터의 배열이 행렬(matrix) 또는 2D 텐서이다.
    • 행렬에는 2개의 축이 있다. (보통 행(row)과 열(column)이라 부른다)
    • 행렬은 숫자가 채워진 사각 격자라고 생각할 수 있다.
    • 첫 번째 축에 놓여 있는 원소를 행이라 부르고, 두 번째 축에 놓여 있는 원소를 열이라 부른다.

3D 텐서와 고차원 텐서

  • 이런 행렬들을 하나의 새로운 배열로 합치면 숫자가 채워진 직육면체 형태로 해석할 수 있는 3D 텐서가 만들어진다.
  • 3D 텐서들을 하나의 배열로 합치면 4D 텐서가 만들어진다.
  • 딥러닝에서는 보통 0D에서 4D까지 텐서를 다루는데, 동영상 데이터를 다룰 때에는 5D 텐서까지 가기도 한다.

핵심 속성

  • 텐서는 4개의 핵심 속성으로 정의된다.
    • 축의 개수(rank): 예컨대 3D 텐서에는 3개의 축이 있고, 행렬에는 2개의 축이 있다. 넘파이 라이브러리에서는 ndim 속성에 저장되어 있다.
    • 크기(shape): 텐서의 각 축을 따라 얼마나 많은 차원이 있는지를 나타낸 파이썬의 튜플(tuple)이다.
      • 예컨대 행렬의 크기는 (3, 5), 3D 텐서의 크기는 (3, 3, 5)와 같이 표현된다. 벡터의 크기는 (5,)처럼 1개의 원소로 이루어진 튜플이고 배열 스칼라는 ()처럼 크기가 없다.
    • 데이터 타입(넘파이에서는 dtype에 저장된다): 텐서에 포함된 데이터의 타입이다. 예컨대 텐서의 타입은 float32, uint8, float64 등이 될 수 있다.
  • (크기 살펴보는 예시 코드 생략)

넘파이로 텐서 조작하기

  • train_images[i] 같은 형식으로 첫 번째 축을 따라 특정 숫자를 선택했는데, 배열에 있는 특정 원소들을 선택하는 것을 슬라이싱(slicing)이라고 한다. 넘파이 배열에서 할 수 있는 슬라이싱 연산을 살펴보자.
  • 다음 예는 11번째에서 101번째까지 숫자를 선택하여 (90, 28, 28) 크기의 배열을 만든다.
my_slice = train_images[10:100]
print(my_slice.shape)
# (90, 28, 28)
  • 동일하지만 조금 더 자세한 표기법은 각 배열의 축을 따라 슬라이싱의 시작 인덱스와 마지막 인덱스를 지정하는 것이다. :(콜론)은 전체 인덱스를 선택한다.
my_slice = train_images[10:100, :, :]
print(my_slice.shape)
# (90, 28, 28)

my_slice = train_images[10:100, 0:28, 0:28]
print(my_slice.shape)
# (90, 28, 28)
  • 일반적으로 각 배열의 축을 따라 어떤 인덱스 사이도 선택할 수 있다. 예컨대 이미지의 오른쪽 아래 14×14 픽셀을 선택하려면 다음과 같이 한다.
my_slice = train_images[:, 14:, 14:]
  • 음수 인덱스도 사용할 수 있다. 파이썬 리스트의 음수 인덱스와 마찬가지로 현재 축의 끝에서 상대적인 위치를 나타낸다.
    • 정중앙에 위치한 14×14 픽셀 조각을 이미지에서 잘라 내려면 다음과 같이 하면 된다.
my_slice = train_images[:, 7:-7, 7:-7]

배치 데이터

  • 일반적으로 딥러닝에서 사용하는 모든 데이터의 텐서의 첫 번째 축(인덱스가 0부터 시작하므로 0번째 축)은 샘플 축(sample axis)이다. (이따금 샘플 차원이라고도 부른다)
    • MNIST 예제에서는 숫자 이미지가 샘플이다.
  • 딥러닝 모델은 한 번에 전체 데이터셋을 처리하지 않는다. 대신 데이터를 작은 배치(batch)로 나눈다. 구체적으로 말하면 MNIST 숫자 데이터에서 크기가 128인 배치 하나는 다음과 같다.
batch = train_images[:128]
  • 그 다음 배치는 다음과 같다.
batch = train_images[128:256]
  • n번째 배치는 다음과 같다.
batch = train_images[128 * n:128 * (n + 1)]
  • 이런 배치 데이터를 다룰 때는 첫 번째 축(0번 축)을 배치 축(batch axis) 또는 배치 차원(batch dimension)이라고 부른다.

텐서의 실제 사례

  • 우리가 사용할 데이터는 대부분 다음 중 하나에 속할 것이다.
    • 벡터 데이터: (samples, features) 크기의 2D 텐서
    • 시계열 데이터 또는 시퀀스(sequence) 데이터: (samples, timesteps, features) 크기의 3D 텐서
    • 이미지: (samples, height, width, channels) 크기의 4D 텐서
    • 동영상: (samples, frames, height, width, height, channels) 크기의 5D 텐서

벡터 데이터

  • 대부분의 경우에 해당한다. 이런 데이터셋에서는 하나의 데이터 포인트가 벡터로 인코딩될 수 있으므로 배치 데이터는 2D 텐서로 인코딩될 것이다.
    • 여기서 첫 번째 축은 샘플 축이고, 두 번째 축은 특성 축(feature axis)이다.

시계열 데이터 또는 시퀀스 데이터

  • 데이터에서 시간이 (또는 연속된 순서가) 중요할 떄는 시간 축을 포함하여 3D 텐서로 저장된다.
    • 각 샘플은 벡터(2D 텐서)의 시퀀스로 인코딩 되므로 배치 데이터는 3D 텐서로 인코딩 될 것이다.
    • 관례적으로 시간 축은 항상 두 번째 축(인덱스가 1인 축)이다.

이미지 데이터

  • 이미지는 전형적으로 높이, 너비, 컬러의 3차원으로 이루어진다.
    • 흑백 이미지는 컬러 채널만 갖고 있어 2D 텐서로 저장할 수 있지만 관례상 항상 3D로 저장한다. 흑백 이미지의 경우 컬러 채널 차원의 크기는 1이다.
  • 이미지 텐서의 크기를 지정하는 방식은 두 가지인데, 텐서플로에서 사용하는 채널 마지막(channel-last) 방식과 씨아노에서 사용하는 채널 우선(channel-first) 방식이다.
    • 케라스 프레임워크는 두 형식을 모두 지원한다.

비디오 데이터

  • 비디오 데이터는 5D 텐서가 필요한 몇 안되는 데이터 중 하나로 하나의 미비도는 프레임의 연속이고 각 프레임은 하나의 컬러 이미지이다.

신경망의 톱니바퀴: 텐서 연산

  • 심층 신경망이 학습한 모든 변환을 수치 데이터 텐서에 적용하는 몇 종류의 텐서 연산(tensor operation)으로 나타낼 수 있다.
  • 첫 번째 예제에서는 Dense 층을 쌓아서 신경망을 만들었다. 케라스의 층은 다음과 같이 생성한다.
keras.layers.Dense(512, activation='relu')
  • 이 층은 2D 텐서를 입력으로 받고 입력 텐서의 새로운 표현인 또 다른 2D 텐서를 반환하는 함수처럼 해석할 수 있다.
    • 구체적으로 보면 이 함수는 다음과 같다. (W는 2D 텐서고, b는 벡터이다. 둘 모두 층의 속성이다)
output= = relu(dot(W, input) + b)
  • 좀 더 자세히 알아보자. 여기에는 3개의 텐서 연산이 있다.
    • 입력 텐서와 텐서 W 사이의 점곱(dot), 점곱의 결과인 2D 텐서와 벡터 b 사이의 덧셈, 마지막으로 relu(렐루) 연산이다.
    • relu(x)는 max(x, 0)이다.

원소별 연산

  • relu 함수와 덧셈은 원소별 연산(element-wise operation)이다. 이 연산은 텐서에 있는 각 원소에 독립적으로 적용된다.
    • 이 말은 고도의 병렬 구현(1970-1990년대 슈퍼컴퓨터의 구조인 벡터 프로세서(vector processor)에서 온 용어인 벡터화된 구현을 말한다)이 가능한 연산이라는 의미이다.
    • 파이썬에서 단순한 원소별 연산을 구현한다면 for 반복문을 사용해야 할 것이다.
    • (예시 코드 생략)
  • 사실 넘파이 배열을 다룰 때는 최적화된 넘파이 내장 함수로 이런 연산들을 처리할 수 있다.
    • 넘파이는 시스템에 설치된 BLAS(Basic Linear Algebra Subprogram) 구현에 복잡한 일들을 위임한다.
    • BLAS는 고도로 병렬화되고 효율적인 저수준의 텐서 조작 루틴이며, 전형적으로 포트란(Fortran)이나 C 언어로 구현되어 있다.
    • 넘파이는 원소별 연산을 엄청난 속도로 처리한다.

브로드캐스팅

  • Dense 층에서는 2D 텐서와 벡터를 더했는데, 크기가 다른 두 텐서가 더해질 때 무슨 일이 일어날까?
  • 모호하지 않고 실행 가능하다면 작은 텐서가 큰 텐서의 크기에 맞추어 브로드캐스팅(broadcasting) 된다. 브로드캐스팅은 두 단계로 이루어진다.
    • 큰 텐서의 ndim에 맞도록 작은 텐서에 (브로드캐스팅 축이라고 부르는) 축이 추가된다.
    • 작은 텐서가 새 축을 따라서 큰 텐서의 크기에 맞도록 반복된다.
  • 구현 입장에서 새로운 텐서가 만들어지면 매우 비효율적이므로 어떤 2D 텐서도 만들어지지 않는다. 반복된 연산은 완전히 가상적이다. 이 과정은 메모리 수준이 아니라 알고리즘 수준에서 일어난다.
    • 하지만 새로운 축을 따라 벡터가 반복된다고 생각하는 것이 이해하기 쉽다.

텐서 점곱

  • 텐서 곱셈(tensor product)이라고도 부르는 점곱 연산(dot operation)은 가장 널리 사용되고 유용한 텐서 연산이다. 원소별 연산과 반대로 입력 텐서의 원소들을 결합시킨다.
    • 넘파이, 케라스, 씨아노, 텐서플로에서 원소별 곱셈은 * 연산자를 사용한다.
    • 텐서플로에서는 dot 연산자가 다르지만 넘파이와 케라스는 점곱 연산에 보편적인 dot 연산자를 사용한다.
  • (예시 코드 생략)
  • 두 벡터의 점곱은 스칼라가 되므로 원소 개수가 같은 벡터끼리 점곱이 가능하다.
    • 행렬 x와 벡터 y 사이에서도 점곱이 가능하다. y와 x의 행 사이에서 점곱이 일어나므로 벡터가 반환된다.
    • (사실 행렬의 곱셈은 행렬의 각 벡터간 점곱(내적)의 결과이다)
  • 점곱은 임의의 축 개수를 가진 텐서에 일반화 된다. 가장 일반적인 용도는 두 행렬 간의 점곱일 것이다.

  • x, y, z는 직사각형 모양으로 그려져 있다. x의 행 벡터와 y의 열 벡터가 같은 크기여야 하므로 자동으로 x의 너비는 y의 높이와 동일해야 한다.
  • 더 일반적으로 크기를 맞추는 동일한 규칙을 따르면 다음과 같이 고차원 텐서 간의 점곱을 할 수 있다.

(a, b, c, d) \cdot (d, ) \to (a, b, c)

(a, b, c, d) \cdot (d, e) \to (a, b, c, e)

텐서 크기 변환

  • 꼭 알아두어야 할 세 번째 텐서 연산은 텐서 크기 변환(tensor reshaping)이다.
  • 텐서의 크기를 변환한다는 것은 특정 크기에 맞게 열과 행을 재배열한다는 뜻이다. 당연히 크기가 변환된 텐서는 원래 텐서와 원소 개수가 동일하다.
    • (예시 코드 생략)
  • 자주 사용하는 특별한 크기 변환은 전치(transposition)이다. 전치는 행과 열을 바꾸는 것을 의미한다.
    • 즉 x[i, :]이 x[:, i]가 된다.

텐서 연산의 기하학적 해석

  • 텐서 연산이 조작하는 텐서의 내용은 어떤 기하학적 공간에 있는 좌표 포인트로 해석될 수 있기 때문에 모든 텐서 연산은 기하학적 해석이 가능하다.
  • 다음 벡터를 보자.
A = [0.5, 1]
  • 이 포인트는 2-6 이미지와 같은 2D 공간에 있다. 일반적으로 2-7이미지와 같이 원점에서 포인트를 연결하는 화살표로 벡터를 나타낸다.

  • 새로운 포인트 B = [1, 0.25]를 이전 벡터에 더해 보자. 기하학적으로는 벡터 화살표를 연결하여 계산할 수 있다. 최종 위치는 두 벡터의 덧셈을 나타내는 벡터가 된다.

  • 일반적으로 아핀 변환(affine transformation), 회전, 스케일링(scaling) 등처럼 기본적인 기하학적 연ㅅ나은 텐서 연산으로 표현될 수 있다.
    • 예컨대 \theta 각도로 2D 벡터를 회전하는 것은 2 x 2 행렬 R = [u, v] 를 접곱하여 구할 수 있다.
    • 여기에서 u, v 는 동일 평면상의 벡터이며, u = [cos(\theta), sin(\theta)], v = [-sin(\theta), cos(\theta)] 이다.

딥러닝의 기하학적 해석

  • 신경망은 전체적으로 텐서 연산의 연결로 구성된 것이고, 모든 텐서 연산은 입력 데이터의 기하학적 변환임을 배웠다.
    • 단순한 단계들이 길게 이어져 구현된 신경망을 고차원 공간에서 매우 복잡한 기하학적 변환을 하는 것으로 해석할 수 있다.
  • 3D라면 다음 비유가 이해하는데 도움이 될 것이다.
    • 하나는 빨간색이고 다른 하나는 파란색인 2개의 색종이가 있다고 가정하자.
    • 두 장을 겹친 다음 뭉쳐서 작은 공으로 만든다.
    • 이 종이 공이 입력 데이터이고 색종이는 분류 문제의 데이터 클래스이다.
    • 신경망(또는 다른 머신 러닝 알고리즘)이 해야 할 일은 종이 공을 펼쳐서 두 클래스가 다시 깔끔하게 분리되는 변환을 찾는 것이다.
    • 손가락으로 종이 공을 조금씩 펼치는 것처럼 딥러닝을 사용하여 3D 공간에서 간단한 변환들을 연결해서 이를 구현한다.

  • 종이 공을 펼치는 것이 머신 러닝이 하는 일이다. 복잡하고 심하게 꼬여 있는 데이터의 매니폴드에 대한 깔끔한 표현을 찾는 일이다.
    • 이쯤되면 딥러닝이 왜 이런 작업에 뛰어난지 알았을 것이다.
    • 기초적인 연산을 길게 연결하여 복잡한 기하학적 변환을 조금씩 분해하는 방식이 마치 사람이 종이 공을 펼치기 위한 전략과 매우 흡사하기 때문이다.
  • 심층 네트워크의 각 층은 데이터를 조금씩 풀어 주는 변환을 적용하므로, 이런 층을 깊게 쌓으면 아주 복잡한 분해 과정을 처리할 수 있다.

신경망의 엔진: 그래디언트 기반 최적화

  • 이전 절에서 보았듯이 첫 번째 신경망 예제에 있는 각 층은 입력 데이터를 다음과 같이 변환한다.
output = relu(dot(W, intput) + b)
  • 이 식에서 텐서 W와 b는 층의 속성처럼 볼 수 있다.
    • 가중치(weight) 또는 훈련되는 파라미터(trainable parameter)라고 부른다. (각각 커널(kernel)과 편향(bias)라고 부르기도 한다) 이런 가중치에는 훈련 데이터를 신경망에 노출시켜서 학습된 정보가 담겨 있다.
  • 초기에는 가중치 행렬이 작은 난수로 채워져 있다. (무작위 초기화(random initialization) 단계라고 부른다)
    • 물론 W와 b가 난수일 때 relu(dot(W, input) + b)가 유용한 어떤 표현을 만들 것이라고 기대할 수는 없다.
    • 즉 의미없는 표현이 만들어진다. 하지만 이는 시작 단계일 뿐이다.
    • 그 다음에는 피드백 신호에 기초하여 가중치가 점진적으로 조정될 것이다.
    • 이런 점진적인 조정 또는 훈련(training)이 머신 러닝 학습의 핵심이다.
  • 훈련은 다음과 같은 훈련 반복 루프(training loop) 안에서 일어난다. 필요한 만큼 반복 루프 안에서 이런 단계가 반복된다.
    1. 훈련 샘플 x와 이에 상응하는 타깃 y의 배치를 추출한다.
    2. x를 사용하여 네트워크를 실행하고(정방향 패스(forward pass) 단계), 예측 y_pred를 구한다.
    3. y_pred와 y의 차이를 측정하여 이 배치에 대한 네트워크의 손실을 계산한다.
    4. 배치에 대한 손실이 조금 감소되도록 네트워크의 모든 가중치를 업데이트한다.
  • 결국 훈련 데이터에서 네트워크의 손실, 즉 예측 y_pred와 타깃 y의 오차가 매우 작아질 것이다.
    • 이 네트워크는 입력에 정확한 타깃을 매핑하는 것을 학습했다.
    • 전체적으로 보면 마술처럼 보이지만 개별적인 단계로 쪼개어보면 단순하다.
  • 1단계는 입출력 코드이므로 매우 쉽다. 2-3단계는 몇 개의 텐서 연산을 적용할 뿐이므로 이전 절에 배웠던 연산을 사용하여 이 단계를 구현할 수 있다.
    • 어려운 부분은 네트워크의 가중치를 업데이트하는 4단계이다. 개별적인 가중치 값이 있을 때 값이 증가해야 할지 감소해야 할지 또 얼마만큼 업데이트해야 할지 어떻게 알 수 있을까?
  • 한 가지 간단한 방법은 네트워크 가중치 행렬의 원소를 모두 고정하고 관심 있는 하나만 다른 값을 적용해 보는 것이다.
    • 이 가중치의 초깃값이 0.3이고 배치 데이터를 정방향 패스에 통과시킨 후 네트워크 손실이 0.5가 나왔다고 하자.
    • 이 가중치를 0.35로 변경하고 다시 정방향 패스를 실행했더니 손실이 0.6으로 증가했다.
    • 반대로 0.25로 줄이면 손실이 0.4로 감소했다. 이 경우에 가중치를 -0.05만틈 업데이트한 것이 손실을 줄이는데 기여한 것으로 보인다.
    • 이런 식으로 네트워크의 모든 가중치에 반복한다.
  • 이런 접근 방식은 모든 가중치 행렬의 원소마다 두 번의 (비용이 큰) 정방향 패스를 계산해야 하므로 엄청나게 비효율적이다. (보통 수천에서 경우에 따라 수백만 개의 많은 가중치가 있다.)
    • 신경망에 사용된 모든 연산이 미분가능(differentiable) 하다는 장점을 사용하여 네트워크 가중치에 대한 손실의 그래디언트(gradient)를 계산하는 것이 훨씬 더 좋은 방법이다.
    • 그래디언트의 반대 방향으로 가중치를 이동하면 손실이 감소된다.

변화율이란?

  • 실수 x를 새로운 실수 y로 매핑하는 연속적이고 매끄러운 함수 f(x) = y를 생각해 보자.
    • 이 함수가 연속적이므로 x를 조금 바꾸면 y가 조금 변경될 것이다. 이것이 연속성의 개념이다.
    • x를 작은 값 epsilon_x 만큼 증가시켰을 때 y가 epsilon_y 만큼 바뀐다고 말할 수 있다.
f(x + eplsion_x) = y + epsion_y
  • 또 이 함수가 매끈하므로(곡선의 각도가 갑자기 바뀌지 않는다) epsilon_x가 충분히 작다면 어떤 포인트 p에서 기울기 a의 선형 함수로 f를 근사할 수 있다.
    • 따라서 epsilon_y는 a * epsilon_x 가 된다.
f(x + eplsion_x) = y + a * epsion_x
  • 이 선형적인 근사는 x가 p에 충분히 가까울 때 유효하다.
  • 이 기울기를 p에서 f의 변화율(derivative)라고 한다.
    • 이는 a가 음수일 때 p에서 양수 x만큼 조금 이동하면 f(x)가 감소한다는 것을 의미한다.
    • a가 양수일 때는 음수 x만큼 조금 이동하면 f(x)가 감소된다.
    • a의 절댓값(변화율의 크기)은 이런 증가나 감소가 얼마나 빠르게 일어날지 알려준다.

  • 모든 미분 가능한(미분 가능하다는 것은 변화율을 유도할 수 있다는 의미로, 예컨대 매끄럽고 연속적인 함수이다) 함수 f(x)에 대해 x의 값을 f의 국부적인 선형 근사인 그 지점의 기울기로 매핑하는 변화율 함수 f'(x)가 존재한다.
    • 예컨대 cos(x)의 변화율은 -sin(x)이고, f(x) = a * x의 변화율은 f'(x) = a이다.
  • f(x)를 최소화하기 위해 epsilon_x 만큼 x를 업데이트하고 싶을 때 f의 변화율을 알고 있으면 해결된다.
    • 변화율 함수는 x가 바뀜에 따라 f(x)가 어떻게 바뀔지 설명해 준다.
    • f(x)의 값을 감소 시키고 싶다면 x를 변화율의 방향과 반대로 조금 이동해야 한다.

텐서 연산의 변화율: 그래디언트

  • 그래디언트는 텐서 연산의 변화율이다. 이는 다차원 입력, 즉 텐서를 입력으로 받는 함수에 변화율 개념을 확장시킨 것이다.
  • 입력 벡터 x, 행렬 W, 타깃 y와 손실 함수 loss가 있다고 가정하자.
    • W를 사용하여 타깃의 예측 y_pred를 계산하고 손실, 즉 타깃 예측 y_pred와 타깃 y사이의 오차를 계산할 수 있다.
y_pred = dot(W, x)
loss_value = loss(y_pred, y)
  • 입력 데이터 x와 y가 고정되어 있다면 이 함수는 W를 손실 값에 매핑하는 함수로 볼 수 있다.
loss_value = f(W)
  • W의 현재 값을 W0라고 하자.
    • 포인트 W0에서 f의 변화율은 W와 같은 크기의 텐서인 gradient(f)(W0)이다.
    • 이 텐서의 각 원소 gradient(f)(W0)[i, j]는 W0[i, j]를 변경했을 때 loss_value가 바뀌는 방향과 크기를 나타낸다.
    • 다시 말해 gradient(f)(W0)가 W0)에서 함수 f(W) = loss_value의 그래디언트이다.
  • 앞서 함수 f(x)의 변화율 하나는 곡선 f의 기울기로 해석할 수 있다는 것을 보았다. 비슷하게 gradient(f)(W0)는 W0에서 f(W)의 기울기를 타나내는 텐서로 해석할 수 있다.
    • 그렇기 때문에 함수 f(x)에 대해서는 변화율의 반대 방향으로 x를 조금 움직이면 f(x)의 값을 감소시킬 수 있다.
    • 동일한 방식을 적용하면 함수 f(W)의 입장에서는 그래디언트의 반대 방향으로 W를 움직이면 f(W)의 값을 줄일 수 있다.
    • 예컨대 W1 = W0 – step * gradient(f)(W0)이다. (step은 스케일을 조정하기 위한 작은 값이다)
    • 이 말은 기울기가 작아지는 곡면의 낮은 위치로 이동된다는 의미이다.
    • gradient(f)(W0)는 W0)에 아주 가까이 있을 때 기울기를 근사한 것이므로 W0에서 너무 크게 벗어나지 않기 위해 스케일링 비율 step이 필요하다.

확률적 경사 하강법

  • 미분 가능한 함수가 주어지면 이론적으로 이 함수의 최솟값을 해석적으로 구할 수 있다.
    • 함수의 최솟값은 변화율이 0인 지점이다. 따라서 우리가 할 일은 변화율이 0이 되는 지점을 모두 찾고 이 중에서 어떤 포인트의 함수 값이 가장 작은지 확인하는 것이다.
  • 신경망에 적용하면 가장 작은 손실 함수의 값을 만드는 가중치의 조합을 해석적으로 찾는 것을 의미한다.
    • 이는 식 gradient(f)(W) = 0을 풀면 해결된다. 이 식은 N개의 변수로 이루어진 다항식이다.
    • 여기서 N은 네트워크 가중치 개수이다. N=2, N=3인 식을 푸는 것은 가능하지만 실제 신경망에서는 파라미터의 개수가 수천 개보다 적은 경우가 거의 없고 종종 수천만 개가 되기 때문에 해석적으로 해결하는 것이 어렵다.
  • 그 대신 앞서 설명한 알고리즘 네 단계를 사용할 수 있다. 랜덤한 배치 데이터에서 현재 손실 값을 토대로 하여 조금씩 파라미터를 수정하는 것이다.
    • 미분 가능한 함수를 가지고 있으므로 그래디언트를 계산하여 단계 4를 효율적으로 구현할 수 있다. 그래디언트 방향으로 가중치를 업데이트 하면 손실이 매번 조금씩 감소할 것이다.
  • 그래디언트 계산을 반영하여 업데이트
    1. 훈련 샘플 x와 이에 상응하는 타깃 y의 배치를 추출한다.
    2. x를 사용하여 네트워크를 실행하고 예측 y_pred를 구한다.
    3. y_pred와 y의 차이를 측정하여 이 배치에 대한 네트워크의 손실을 계산한다.
    4. 네트워크의 파라미터에 대한 손실 함수의 그래디언트를 계산한다(역방향 패스(backward pass))
    5. 그래디언트의 반대 방향으로 파라미터를 조금 이동시킨다. 예컨대 W -= step * gradient 처럼 하면 배치에 댛나 손실이 조금 감소할 것이다.
  • 이것이 바로 미니 배치 확률적 경사 하강법(mini-batch stochastic gradient descent)(미니 배치 SGD)이다.
    • 확률적(stochastic)이란 단어는 각 배치 데이터가 무작위로 선택된다는 의미이다. (확률적이란 것은 무작위(random) 하다는 것의 과학적 표현이다.)
    • 네트워크의 파라미터와 훈련 샘플이 하나일 때 이 과정을 아래 그림에 나타냈다.

  • 그림에서 볼 수 있듯이 step 값을 적절히 고르는 것이 중요하다.
    • 이 값이 너무 작으면 곡선을 따라 내려가는데 너무 많은 반복이 필요하고 지역 최솟값(local minimum)에 갇힐 수 있다.
    • step이 너무 크면 손실 함수 곡선에서 완전히 임의의 위치로 이동시킬 수 있다.
  • 미니 배치 SGD 알고리즘의 한 가지 변종은 반복마다 하나의 샘플과 하나의 타깃을 뽑는 것이다.
    • 이것이 (미니 배치 SGD와 반대로) 진정한(true) SGD이다.
    • 다른 한편으로 극단적인 반대의 경우를 생각해 보면 가용한 모든 데이터를 사용하여 반복을 실행할 수 있다.
    • 이를 배치 SGD (batch SGD)라고 한다. 더 정확하게 업데이트 되지만 더 많은 비용이 든다.
    • 극단적인 두 가지 방법의 효율적인 절충안은 적절한 크기의 미니 배치를 사용하는 것이다.
  • 그림 2-11은 1D 파라미터 공간에서 경사 하강법을 설명하고 있지만 실제로는 매우 고차원 공간에서 경사 하강법을 사용하게 된다.
    • 신경망에 있는 각각의 가중치 값은 이 공간에서 하나의 독립된 차원이고 수만 또는 수백만 개가 될 수도 있다.
    • 손실 함수의 표면을 좀 더 쉽게 이해하기 위해 아래 그림 2-12와 같이 2D 손실 함수의 표면을 따라 진행하는 경사 하강법을 시각화해 볼 수 있다.

  • 하지만 신경망이 훈련되는 실제 과정을 시각화하기는 어렵다. 4차원 이상의 공간을 사람이 이해할 수 있도록 표현하는 것이 불가능하기 때문이다.
    • 때문에 저차원 표현으로 얻은 직관이 실전과 항상 맞지 않는다는 것을 유념해야 한다. 이는 딥러닝 연구 분야에서 오랫동안 여러 이슈를 일으키는 근원이었다.
  • 또 업데이트할 다음 가중치를 계산할 때 현재 그래디언트 값만 보지 않고 이전에 업데이트도니 가중치를 여러 다른 방식으로 고려하는 SGD 변종이 많이 있다.
    • 예컨대 모멘텀을 사용한 SGD, Adagrad, RMSProp 등이 그것이다.
    • 이런 변종들은 모두 최적화 방법(optimization method) 또는 옵티마이저라고 부른다.
  • 특히 여러 변종들에서 사용하는 모멘텀(momentum) 개념은 아주 중요하다. 모멘텀은 SGD에 이는 2개의 문제점인 수렴 속도와 지역 최솟값을 해결한다.
    • 아래 그림 2-13은 네트워크의 파라미터 하나에 대한 손실 값의 곡선을 보여준다.

  • 어떤 파라미터 값에서는 지역 최솟값에 도달한다. 그 지점 근처에서는 왼쪽으로 이동해도 손실이 증가하고 오른쪽으로 이동해도 손실이 증가하기 때문이다.
    • 대상 파라미터가 작은 학습률을 가진 SGD로 최적화되었다면 최적화 과정이 전역 최솟값으로 향하지 못하고 이 지역 최솟값에서 갇히게 될 것이다.
  • 물리학에서 영감을 얻은 모멘텀을 사용하여 이 문제를 피할 수 있다. 여기에서 최적화 과정을 손실 곡선 위로 작은 공을 굴리는 것으로 생각하면 쉽게 이해할 수 있다.
    • 모멘텀이 충분함녀 공이 골짜기에 갇히지 않고 전역 최솟값에 도달할 것이다.
    • 모멘텀은 현재 기울기 값(현재 가속도) 뿐만 아니라 (과거의 가속도로 인한) 현재 속도를 함께 고려하여 각 단계에서 공을 움직인다.
    • 실전에 적용할 때는 현재 그래디언트 값 뿐만 아니라 이전에 업데이트한 파라미터에 기초하여 파라미터 w를 업데이트 한다.
  • 다음은 단순한 구현 예이다.
past_velocity = 0
momentum = 0.1 --모멘텀 상수
while loss > 0.01: --최적화 반복 루프
w, loss, gradient = get_current_parameters()
velocity = momentum * past_velocity - learning_rate * gradient
w = w + momentum * velocity - learning_rate * gradient
past_velocity = velocity
update_parameter(w)

변화율 연결: 역전파 알고리즘

  • 앞의 알고리즘에서 함수가 미분 가능하기 때문에 변화율을 직접 계산할 수 있다고 가정했다.
    • 실제로 신경망은 많은 텐서 연산으로 구성되어 있고 이 연산들의 변화율은 간단하며 이미 잘 알려져 있다.
    • 3개의 텐서 연산 a, b, c와 가중치 행렬 W1, W2, W3으로 구성된 네트워크 f를 예로 들어보자.
f(W1, W2, W3) = a(W1, b(W2, c(W3)))
  • 이벅분에서 이렇게 연결된 함수는 연쇄 법칙(chain rule)이라 부르는 다음 항등식 f(g(x))’ = f'(g(x)) * g'(x)를 사용하여 유도될 수 있다.
    • 연쇄 법칙을 신경망의 그래디언트를 계산에 적용하여 역전파(Backpropagation) 알고리즘(후진 모드 자동 미분(reverse-mode automatic differentiation)이라고도 부른다)이 탄생되었다.
    • 역전파는 최종 손실 값에서부터 시작한다.
    • 손실 값에 각 파라미터가 기여한 정도를 계산하기 위해 연쇄 법칙을 적용하여 최상위 층에서 하위층까지 거꾸로 진행된다.
  • 요즘에는 그리고 향후 몇 년 동안은 텐서플로처럼 기호 미분(symbolic differentiation)이 가능한 최신 프레임워크를 사용하여 신경망을 구현할 것이다.
    • 이 말은 변화율이 알려진 연산들로 연결되어 있으면 (연쇄 법칙을 적용하여) 네트워크 파라미터와 그래디언트 값을 매핑하는 그래디언트 함수를 계산할 수 있다는 의미이다.
    • 이런 함수를 사용하면 역방향 패스는 그래디언트 함수를 호출하는 것으로 단순화될 수 있다.
    • 기호 미분 덕택에 역전파 알고리즘을 직접 구현할 필요가 전혀 없고 정확한 역전파 공식을 유도하느라 시간과 노력을 소모하지 않아도 된다. 그래디언트 기반의 최적화가 어떻게 작동하는지 잘 이해하는 것만으로 충분하다.

첫 번째 예제 다시 살펴보기

  • 신경망의 이면에 어떤 원리가 있는지 기초적인 내용을 이해했으므로 첫 번째 예제로 돌아가서 이전 절에서 배웠던 내용을 이용하여 코드를 리뷰해 보자.
  • 먼저 입력 데이터이다.
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype('float32') / 255
  • 입력 이미지의 데이터 타입은 float32, 훈련 데이터는 (60000, 784) 크기, 테스트 데이터는 (10000, 784) 크기의 넘파이 배열로 저장된다.
  • 우리가 사용할 신경망이다.
network = models.Sequential()
network.add(layers.Dense(512, activation='relu', input_shape=(28 * 28,)))
network.add(layers.Dense(10, activation='softmax'))
  • 이 네트워크는 2개의 Dense 층이 연결되어 있고 각 층은 가중치 텐서를 포함하여 입력 데이터에 대한 몇 개의 간단한 텐서 연산을 적용한다.
    • 층의 속성인 가중치 텐서는 네트워크가 정보를 저장하는 곳이다.
  • 이제 네트워크를 컴파일 하는 단계이다.
network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
  • categorical_crossentropy는 손실 함수이다. 가중치 텐서를 학습하기 위한 피드백 신호로 사용되며 훈련하는 동안 최소화 된다.
    • 미니 배치 확률적 경사 하강법을 통해 손실이 감소된다.
    • 경사 하강법을 적용하는 구체적인 방식은 첫 번째 매개변수로 전달된 rmsprop 옵티마이저에 의해 결정된다.
  • 마지막으로 훈련 반복이다.
network.fit(train_images, train_labels, epochs=5, batch_size=128)
  • fit 메서드를 호출했을 때 다음과 같은 일이 일어난다.
    • 네크워크가 128개 샘플씩 미니 배치로 훈련 데이터를 다섯 번 반복한다(전체 훈련 데이터에 수행되는 각 반복을 에포크(epoch)라고 한다.)
    • 각 반복마다 네트워크가 배치에서 손실에 대한 가중치의 그래디언트를 계산하고 그에 맞추어 가중치를 업데이트 한다.
    • 다섯 번의 에포크 동안 네트워크는 2,345번의 그래디언트 업데이트를 수행할 것이다 (에포크마다 469번)

요약

  • 학습(Learning)은 훈련 데이터 샘플과 그에 상응하는 타깃이 주어졌을 때 손실 함수를 최소화 하는 모델 파라미터의 조합을 찾는 것을 의미한다.
  • 데이터 샘플과 타깃의 배치를 랜덤하게 뽑고 이 배치에서 손실에 대한 파라미터의 그래디언트를 계산함으로써 학습이 진행된다. 네트워크의 파라미터는 그래디언트의 반대 방향으로 조금씩(학습률에 의해 정의된 크기만큼) 움직인다.
  • 전체 학습 과정은 신경망이 미분 가능한 텐서 연산으로 연결되어 있기 때문에 간으하다. 현재 파라미터와 배치 데이터를 그래디언트 값에 매핑해주는 그래디언트 함수를 구성하기 위해 미분의 연쇄 법칙을 사용한다.
  • 이어지는 장에서 자주 보게 될 두 가지 핵심 개념은 손실과 옵티마이저이다. 이 두 가지는 네트워크에 데이터를 주입하기 전에 정의 되어야 한다.
  • 손실은 훈련하는 동안 최소화해야 할 양이므로 해결하는 문제의 성공을 측정하는데 사용한다.
  • 옵티마이저는 손실에 대한 그래디언트가 파라미터를 업데이트하는 정확한 방식을 정의한다. 예컨대 RMSProp 옵티마이저, 모멘텀을 사용한 SGD 등이다.
[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.