데이터에서 학습한다!
`학습`이란, 훈련 데이터로부터 가중치 매개변수의 최적값을 자동으로 획득하는 것을 뜻한다. 학습의 목표는 `손실 함수`의 결괏값을 가장 작게 만드는 가중치 매개변수를 찾는 것이다. 신경망의 특징은 데이터를 보고 학습할 수 있다는 점이다. 데이터에서 학습한다는 것은 가중치 매개변수의 값을 데이터를 보고 자동으로 결정한다는 뜻이다.
데이터 주도 학습
`기계 학습`은 데이터에서 답을 찾고, 데이터에서 패턴을 발견하고, 데이터로 이야기를 만든다. 이러한 기계학습에서는 사람의 개입을 최소화하고 수집한 데이터로부터 패턴을 찾으려 시도한다. 게다가 신경망은 기존 기계학습에서 사용하던 방법보다 사람의 개입을 더욱 배제할 수 있게 해주는 중요한 특징을 가지고 있다.
신경망은 이미지를 '있는 그대로' 학습한다. 이미지를 사람이 설계한 대로 벡터로 변환하고 추출한 `특징(feature)`을 기계학습 기술로 학습하는 방법에서 벗어나 신경망은 이미지에 포함된 주요한 특징까지도 기계가 스스로 학습한다. 따라서 딥러닝을 `end-to-end machine learning`이라고 한다. 즉, 데이터(입력)에서 목표한 결과(출력)를 사람의 개입 없이 얻으며 모든 문제를 주어진 데이터 그대로를 입력 데이터로 활용해 학습할 수 있다.
훈련 데이터와 시험 데이터
기계학습 문제에서는 `training data`와 `test data`로 나누어 학습과 실험을 수행하는 것이 일반적이다. 이는 `범용 능력`을 제대로 평가하기 위함이다. 범용 능력이란, 훈련 데이터에 포함되지 않는 데이터로도 문제를 올바르게 풀어내는 능력이다. 이 범용 능력을 획득하는 것이 기계학습의 최종 목표이다. 추가적으로 한 데이터셋에만 지나치게 최적화된 상태를 `overfitting`이라고 한다. 이를 피하는 것이 기계학습의 중요한 과제이다.
손실 함수(Loss Function)
신경망은 하나의 지표를 기준으로 최적의 매개변수 값을 탐색하는데, 이를 `loss function`이라고 한다. 이 손실 함수는 신경망 성능의 '나쁨'을 나타내는 지표로, 현재의 신경망이 훈련 데이터를 얼마나 잘 처리하지 못하느냐를 나타낸다. 임의의 함수를 사용하기도 하며, 일반적으로는 오차제곱합과 교차 엔트로피 오차를 사용한다.
오차제곱합(Sum of Squares for Error, SSE)
가장 많이 쓰이는 손실 함수인 오차제곱합의 수식은 다음과 같다.
여기서 y는 신경망이 추정한 값, t는 정답 레이블, k는 데이터의 차원 수를 나타낸다. 이를 파이썬 코드로 구현하면 다음과 같다.
# SSE
def sum_sqaures_error(y, t):
return 0.5 * np.sum((y-t)**2)
여기서 인수 y와 t는 넘파이 배열이다. 실제 함수를 사용해보면 다음과 같다.
t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0] # 정답은 2
y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0] # 신경망의 출력으로, 소프트맥스 함수
print(sum_squares_error(np.array(y), np.array(t)))
y = [0.1, 0.05, 0.1, 0.0, 0.05, 0.1, 0.0, 0.6, 0.0, 0.0]
print(sum_squares_error(np.array(y), np.array(t)))
첫 번째 예시에선 정답이 2이고, 신경망의 출력도 2 인덱스의 확률이 0.6으로 가장 높게 나와 오차제곱합은 약 0.095가 나왔다. 그러나 두 번째 예시에선 신경망의 출력에서 7 인덱스의 확률이 가장 높아 오차제곱합이 약 0.5975가 나왔다. 이를 통해 첫 번째 추정 결과가 오차가 더 작아 정답에 더 가까움을 확인할 수 있다.
크로스 엔트로피 오차
또다른 손실 함수로는 `크로스 엔트로피 오차(cross entropy error)`를 많이 이용한다. 크로스 엔트로피 오차의 수식은 다음과 같다.
- y는 신경망의 출력
- t는 정답 레이블로 정답에 해당하는 인덱스의 원소만 1이고 나머지는 0(원-핫 인코딩)
- 실질적으로 해당 수식은 정답일 때의 추정(t가 1일 때의 y)의 자연로그를 계산하는 식
- 정답이 아닌 나머지는 모두 t값이 0이 되므로 추정값을 곱해도 결괏값에 영향을 미치지 않음
이를 파이썬 코드로 구현하면 다음과 같다.
def cross_entropy_error(y, t):
delta = 1e-7
return -np.sum(t * np.log(y + delta))
여기서 log 값을 취할 때 아주 작은 값인 delta를 더한다. 이는 `np.log()` 함수에 0을 입력하면 마이너스 무한대를 뜻하는 -inf가 되어 더 이상 계산을 진행할 수 없기 때문이다. 이 함수를 적용해보면 다음과 같다.
t = [0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
y = [0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0]
cross_entropy_error(np.array(y), np.array(t))
y = [0.1, 0.05, 0.1, 0.0, 0.05, 0.1, 0.0, 0.6, 0.0, 0.0]
cross_entropy_error(np.array(y), np.array(t))
첫 번째 예의 경우 정답 레이블인 2번째 인덱스에 대하여 소프트맥스 값이 0.6이 나온 것을 확인할 수 있다. 따라서 크로스 엔트로피 오차는 약 0.51이 나온다. 그러나 두 번째 예의 경우 2번째 인덱스의 소프트맥스 값이 0.1으로 낮은 값이 나왔으므로 크로스 엔트로피 오차는 2.3으로 커진다. 결론적으로 첫 번째 추정이 정답일 가능성이 높다고 판단한 것이다.
미니배치 학습
기계학습 문제는 훈련 데이터에 대한 손실 함수의 값을 구하고, 그 값을 최대한 줄여주는 매개변수를 찾는다. 이를 위해선 모든 훈련 데이터를 대상으로 손실 함수 값을 구해야 한다. 이는 하나의 데이터에 대해 손실 함수를 구했던 것에서 전체 데이터로 확장한 후, N으로 나눠 정규화를 수행해 평균 손실 함수를 통해 계산할 수 있다. 그러나 데이터의 개수가 많아질 경우 이 또한 어려운 일이다. 따라서 데이터 일부를 추려 전체의 '근사치'로 이용할 수 있다. 또한 신경망 학습에서도 훈련 데이터로부터 일부만 골라 학습을 수행할 수 있다. 해당 일부를 `미니 배치(mini-batch)`라고 하며 이러한 학습 방법을 `미니배치 학습`이라고 한다.
왜 손실 함수를 설정하는가?
신경망 학습에서는 최적의 매개변수(가중치와 편향)를 탐색할 때 손실 함수의 값을 가능한 한 작게 하는 매개변수 값을 찾는다. 이때 매개변수의 손실 함수의 미분(기울기)을 계산하고, 그 미분 값을 단서로 매개변수의 값을 서서히 갱신하는 과정을 반복한다. 여기서 가중치 매개변수의 손실 함수의 미분이란 '가중치 매개변수의 값을 아주 조금 변화시켰을 때, 손실 함수가 어떻게 변하나'라는 의미를 가지고 있다. 예를 들어 이 값이 음수일 경우 가중치 매개변수를 양의 방향으로 변화시켜 손실 함수의 값을 줄일 수 있으며, 이 값이 양수일 경우 반대이다. 그러나 문제는 이 기울기 값이 0인 경우이다. 미분 값, 즉 기울기가 0일 경우 가중치 매개변수를 어느 쪽으로 움직여도 손실 함수의 값은 변화하지 않는다. 가중치 매개변수의 갱신이 멈춘다.
따라서, 신경망을 학습할 때 정확도를 지표로 삼아서는 안된다. 정확도를 지표로 하면 매개변수의 미분이 대부분의 장소에서 0이 되기 때문이다. 정확도는 매개변수의 미세한 변화에는 거의 반응을 하지 않으며, 반응이 있더라도 그 값이 32%, 33%, 34% 등 불연속적으로 갑자기 변화한다. 이는 계단 함수를 신경망에서 활성화 함수로 사용하지 않는 것과도 연관이 있다. 계단 함수의 미분은 대부분의 장소에서 0이다. 이에 비해 시그모이드 함수의 미분은 그 출력이 연속적으로 변하며, 기울기 또한 연속적으로 변한다. 이는 신경망 학습에서 중요한 성질로, 기울기가 0이 되지 않는 덕분에 신경망이 올바르게 학습할 수 있다.
수치 미분
미분
미분은 한순간의 변화량을 표시한 것이다. 즉, x의 '작은 변화'가 함수 f(x)를 얼마나 변화시키느냐를 의미한다. 이를 수식으로 나타내면 다음과 같다.
이를 파이썬 그대로 구현하면 다음과 같은데, 이는 나쁜 구현이며 그 이유는 두가지이다.
# 나쁜 구현의 예
def numerical_diff(f,x):
h = 1e-50
return (f(x+h)-f(x)) / h
- `반올림 오차(rounding error)` : 작은 값(소수점 8자리 이하)이 생략되어 최종 계산 결과에 오차가 발생
- 미세한 값을 1e-4로 조정
- `차분` : (x+h)와 x 사이의 함수 f의 차분을 계산하는 것이 실제 x 위치의 함수의 기울기를 계산하는데 오차 발생
- (x+h)와 (x-h)일 때의 함수 f의 차분을 계산하는 방법 사용 → `중심 차분`, `중앙 차분`
이렇듯 아주 작은 차분으로 미분하는 것을 `수치 미분`이라고 한다. 수치 미분을 다시 코드로 구현하면 다음과 같다.
# 개선한 수치 미분 함수
def numerical_diff(f, x):
h = 1e-4
return (f(x+h)-f(x-h)) / (2*h)
편미분
다음과 같은 함수가 있다고 가정하자. 여기서 `편미분`이란, x0와 x1 중 어느 변수에 대한 미분이냐를 구별해야 하는 것으로, 변수가 여럿인 함수에 대한 미분을 말한다.
편미분은 변수가 하나인 미분과 마찬가지로 특정 장소의 기울기를 구한다. 단, 여러 변수 중 목표 변수 하나에 초점을 맞추고 다른 변수는 값을 고정한다.
# x0 = 3, x1 = 4일 때 x0에 대한 편미분
def function_tmp1(x0):
return x0*x0 + 4.0**2.0
print(numerical_diff(function_tmp1, 3.0))
기울기
편미분의 경우 x0와 x1을 따로 계산한다. 그러나 이와 다르게 모든 변수의 편미분을 벡터로 정리한 것을 `기울기(gradient)`라고 한다. 기울기의 예는 다음과 같이 구현이 가능하다.
def numerical_gradient(f, x):
h = 1e-4
grad = np.zeros_like(x)
for idx in range(x.size):
tmp_val = x[idx]
# f(x+h) 계산
x[idx] = tmp_val + h
fxh1 = f(x)
# f(x-h) 계산
x[idx] = tmp_val - h
fxh2 = f(x)
grad[idx] = (fxh1 - fxh2) / (2*h)
x[idx] = tmp_val
return grad
기울기의 결과에 마이너스를 붙인 벡터를 그려보면 다음과 같다.
해당 그림을 보면, 기울기는 함수의 '가장 낮은 장소(최솟값)'을 가리키는 것 처럼 보인다. 또한 최솟값에서 멀어질수록 화살표의 크기는 커짐을 확인할 수 있다. 기울기는 가장 낮은 장소를 가리키지만, 실제로 반드시 그렇다고는 할 수 없다. 기울기는 각 지점에서 낮아지는 방향을 가리킨다. 더 정확히 말하면 기울기가 가리키는 쪽은 각 장소에서 함수의 출력 값을 가장 크게 줄이는 방향이다.
경사법(경사 하강법)
신경망은 학습 시에 최적의 매개변수(가중치와 편향), 즉 손실 함수가 최솟값이 될 때의 매개변수 값을 찾아야 한다. 그러나 일반적인 문제의 손실 함수는 매우 복잡하기 떄문에 어디가 최솟값이 되는 곳인지 짐작할 수 없다. 이런 상황에서 기울기를 잘 이용해 함수의 최솟값(또는 가능한 가장 작은 값)을 찾으려는 것을 경사법이라고 한다. 여기서 각 지점에서 함수의 값을 낮추는 방안을 제시하는 지표가 기울기이다.
기울기가 가리키는 곳에 정말 함수의 최솟값이 있는지, 그쪽이 정말 나아갈 방향인지는 보장할 수 없다. 함수의 기울기가 0인 지점은 극솟값, 최솟값, 안장점 3가지이다. `극솟값`은 국소적인 최솟값, 즉 한정된 범위에서 최솟값인 점을 말하며, `안장점`은 어느 방향에서 보면 극댓값이고 다른 방향에서 보면 극솟값인 점이다. 따라서 기울기가 0인 지점이 최솟값일 수 있지만, 반대로 극솟값, 혹은 안장점일 가능성도 존재한다. 또한 복잡하고 찌그러진 모양의 함수라면 대부분 평평한 곳으로 파고들면서 `고원(plateau)`이라 불리는 학습이 진행되지 않는 정체기에 빠질 수도 있다. 하지만 그 기울기가 가리키는 방향으로 가야 함수의 값을 줄일 수 있기 때문에 최솟값이 되는 장소를 찾는 문제에서는 기울기 정보를 단서로 나아갈 방향을 정해야 한다.
`경사법(gradient method)`이란, 현 위치에서 기울어진 방향으로 일정 거리만큼 이동한 후 이동한 곳에서도 마찬가지로 기울기를 구하고, 또 그 기울어진 방향으로 나아가기를 반복하여 함수의 값을 점차 줄이는 것이다. 경사법을 수식으로 나타내면 다음과 같다.
여기서 에타 기호는 한 번의 학습으로 얼마만큼 학습해야 할지, 즉 매개변수 값을 얼마나 갱신하느냐를 정하는 `학습률(learning rate)`이다. 위의 수식은 1회에 해당하는 갱신이고, 이 단계를 반복한다. 또한 변수의 수가 늘어도 같은 식(각 변수의 편미분 값)으로 갱신한다. 학습률은 미리 특정 값으로 정해둬야 하는데, 일반적으로 이 값이 너무 크거나 작으면 최솟값을 찾아갈 수 없다. 경사 하강법을 구현하면 다음과 같다.
def gradient_descent(f, init_x, lr=0.01, step_num=100):
x = init_x
for i in range(step_num):
grad = numerical_gradient(f,x)
x -= lr * grad
return x
- f : 최적화하려는 함수
- init_x : 초깃값
- lr : learning rate, 학습률
- step_num : 경사법에 따른 반복 횟수
학습률과 같은 매개변수를 `하이퍼 파라미터(hyper parameter)`라고 한다. 이는 가중치와 편향 같은 신경망의 매개변수와는 성질이 다른 매개변수이다. 신경망의 가중치 매개변수는 훈련 데이터와 학습 알고리즘에 의해서 자동으로 획득되지만 하이퍼 파라미터의 경우 사람이 직접 설정해야 하며 여러 후보 값 중 시험을 통해 가장 잘 학습하는 값을 찾는 과정이 필요하다.
신경망에서의 기울기
신경망 학습에서도 기울기를 계산해야 한다. 신경망 학습에서의 기울기는 가중치 매개변수에 대한 손실 함수의 기울기이다. 예를 들어 가중치가 W, 손실 함수가 L인 신경망을 고려했을 때, 경사는 다음과 같이 나타낼 수 있다.
경사의 각 원소의 경우 각 원소에 관한 편미분이다. 1행 1번째 원소의 경우 w11을 조금 변경 했을 때 손실 함수 L이 얼마나 변화하느냐를 나타낸다. 여기서 중요한 점은 가중치 W와 경사의 형상이 같다는 것이다.(2x3)
만약 위의 그림처럼 신경망의 기울기가 계산되었다고 하면, w11에 대한 편미분 값이 0.2이기 때문에 w11을 h만큼 늘리면 손실 함수의 값은 0.2h만큼 증가한다는 것을 의미한다. 따라서 손실 함수를 줄인다는 관점에서는 w23은 양의 방향으로 갱신하고, w11은 음의 방향으로 갱신해야 한다. 또한 한번에 갱신되는 양에는 w23이 w11보다 크게 기여한다는 사실도 확인할 수 있다.
학습 알고리즘 구현하기
신경망에는 적응 가능한 가중치와 편향이 있고, 이 가중치와 편향을 훈련 데이터에 적응하도록 조정하는 과정을 학습이라고 한다. 신경망 학습의 절차는 다음과 같다.
- 미니배치 - 훈련 데이터 중 일부를 무작위로 가져와(미니배치) 해당 미니배치의 손실 함수 값을 줄이는 것이 목표
- 기울기 산출 - 미니배치의 손실 함수 값을 줄이기 위해 각 가중치 매개변수의 기울기를 계산하며, 이 기울기는 손실 함수의 값을 가장 작게하는 방향으로 제시
- 매개변수 갱신 - 가중치 매개변수를 기울기 방향으로 아주 조금 갱신
- 반복 - 1~3단계를 반복
이는 경사 하강법으로 매개변수를 갱신하는 방법이며, 데이터를 미니배치로 무작위로 선정하기 때문에 `확률적 경사 하강법(stochastic gradient descent)`라고 부른다. 확률적으로 무작위로 골라낸 데이터에 대해 수행하는 경사 하강법이라는 의미이다.
class TwoLayerNet:
def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01):
# 가중치 초기화
self.params = {}
self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size)
self.params['b1'] = np.zeros(hidden_size)
self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size)
self.params['b2'] = np.zeros(output_size)
def predict(self, x):
W1, W2 = self.params['W1'], self.params['W2']
b1, b2 = self.params['b1'], self.params['b2']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
y = sigmoid(a2)
return y
# x : 정답 데이터, t : 정답 레이블
def loss(self, x, t):
y = self.predict(x)
return cross_entropy_error(y, t)
def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy
def numerical_gradient(self, x, t):
loss_W = lambda W : self.loss(x, t)
grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])
return grads
- params : 신경망의 매개변수(가중치, 편향)
- grads : 기울기
미니배치 학습 구현하기
미니배치 학습이란 훈련 데이터 중 일부를 무작위로 꺼내고(미니배치), 그 미니배치에 대해서 경사법으로 매개변수를 갱신한다. 앞서 정의한 TwoLayerNet 클래스와 MNIST 데이터셋을 통해 학습을 수행하면 다음과 같다.
import numpy as np
from mnist import load_mnist
from twolayer_net import TwoLayerNet
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)
train_loss_list = []
# hyper parameter
iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
for i in range(iters_num):
# mini-batch
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# 기울기
grad = network.numerical_gradient(x_batch, t_batch)
# 매개변수
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
# 학습 경과 기록
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
여기에서는 미니배치의 크기를 100으로 지정했다. 즉, 매번 60,000개의 데이터 중 임의로 100개의 이미지 데이터 및 정답 레이블 데이터를 추출한다. 이후 100개의 미니배치를 통해 확률적 경사 하강법을 수행해 매개변수를 갱신한다. 또한 경사법에 의한 갱신 횟수(반복 횟수)를 나타내는 iters_num은 10,000으로 지정해 갱신할 때마다 훈련 데이터에 대한 손실 함수를 계산하고, 그 값을 리스트에 추가한다. 여기서 batch_size * iters_num = 100 * 10,000 = 1,000,000으로 데이터의 개수인 60,000보다 큰데 이는 데이터가 중복으로 학습될 수 있지만 경사법과 같은 최적화 알고리즘을 통해 이를 보완해 적절한 가중치를 찾을 수 있고, 중복 학습이 발생해도 모델은 데이터셋의 다양한 샘플에 대한 정보를 학습할 수 있다.
시험 데이터로 평가하기
앞서 본 손실 함수는 훈련 데이터의 미니배치에 대한 손실 함수이다. 훈련 데이터의 손실 함수 값이 작아지는 것은 신경망이 잘 학습하고 있다는 방증이지만, 이 결과만으로는 범용적인 능력을 증명할 수 없다. 신경망 학습에서는 훈련 데이터 외의 데이터를 올바르게 인식하는지를 확인해야 한다. 즉, `오버피팅(overfitting)`을 일으키지 않는지 확인해야 한다. 오버피팅되었다는 것은, 예를 들어 훈련 데이터에 포함된 이미지만 제대로 구분하고, 그렇지 않은 이미지는 식별할 수 없다는 것이다. 따라서 위에서 확인한 코드에서 수정이 필요하다.
import numpy as np
import matplotlib.pyplot as plt
from mnist import load_mnist
from twolayer_net import TwoLayerNet
(x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True)
train_loss_list = []
train_acc_list = []
test_acc_list = []
# hyper parameter
iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)
# 1epoch당 반복의 수
iter_per_epoch = max(train_size / batch_size, 1)
for i in range(iters_num):
# mini-batch
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]
# 기울기
grad = network.numerical_gradient(x_batch, t_batch)
# 매개변수
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]
# 학습 경과 기록
loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)
# 1 epoch당 정확도 계산
if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print("train acc, test acc : " + str(train_acc), ", " + str(test_acc))
# 그래프
markers = {'train': 'o', 'test': 's'}
x = np.arange(len(train_acc_list))
plt.plot(x, train_acc_list, label='train acc')
plt.plot(x, test_acc_list, label='test acc', linestyle='--')
plt.xlabel("epochs")
plt.ylabel("accuracy")
plt.ylim(0, 1.0)
plt.legend(loc='lower right')
plt.show()
위 코드에선 1 epoch마다 훈련 데이터와 시험 데이터에 대한 정확도를 계산하고, 그 결과를 기록한다.
이 포스팅은 '밑바닥부터 시작하는 딥러닝' 교재를 공부하며 작성한 글입니다. 해당 포스팅에 포함된 코드는 깃허브에서 확인가능합니다.