본문 바로가기
AI/Deep Learning

[Deep Learning for Scratch] 신경망 학습

by 유니야 2021. 5. 25.

학습: 훈련 데이터로부터 가중치 매개변수의 최적값을 자동으로 획득하는 것

 

신경망은 데이터를 보고 학습할 수 있다는 특징이 있으며, 데이터에서 학습한다 = 가중치 매개변수의 값을 데이터를 보고 자동으로 결정한다

 

* 퍼셉트론도 선형 분리 가능한 문제라면 데이터로부터 자동학습이 가능하지만, 비선형 분리 문제는 자동으로 학습이 불가능하다.

 

 

데이터 주도 학습

기계학습은 데이터가 생명으로, 데이터가 이끄는 접근 방식 덕에 사람 중심 접근에서 벗어날 수 있다. 기계학습에서는 사람의 개입을 최소화하고 수집한 데이터로부터 패턴을 찾으려 시도한다.

 

주어진 데이터를 잘 활용해 문제를 해결하는 방법의 하나로, 이미지에서 특징을 추출하고 그 특징의 패턴을 기계학습 기술로 학습하는 방법이 있다.  여기서 특징은 입력 데이터에서 본질적인 데이터를 정확히 추출할 수 있게 설계된 변환기를 가리킨다.

 

이미지 특징은 보통 벡터로 기술하고, 컴퓨터 비전 분야에서는 SIFT, SURF, HOG 등의 특징을 많이 사용한다. 이런 특징으로 이미지 데이터를 벡터로 변환하고, 변환된 벡터를 가지고 지도 학습 방식의 대표 분류 기법인 SVM, KNN 등으로 학습이 가능하다.

 

기계학습에서는 모아진 데이터로부터 규칙을 찾아내는 역할을 기계가 담당하긴 하지만, 이미지를 벡터로 변환할 때 사용하는 특징은 여전히 사람이 설계한다. 그렇기에 문제에 적합한 특징을 쓰지 않으면 좋은 결과를 얻기 어렵다. 즉, 특징과 기계학습을 활용한 접근에도 문제에 따라서는 '사람'이 적절한 특징을 생각해야한다는 사실을 기억하자!

 

이처럼 신경망은 이미지를 있는 그대로 학습하며, 두 번째 접근 방식에서는 특징을 사람이 설계했지만, 신경망은 이미지에 포함된 중요한 특징까지도 기계가 스스로 학습할 것이다.

 

신경망의 이점은 모든 문제를 같은 맥락에서 풀 수 있다는 것이고, 세부사항과 관계없이 주어진 데이터를 온전히 학습하고, 주어진 문제의 패턴을 발견하려 시도한다는 것이다. 즉, 신경망은 모든 문제를 주어진 데이터 그대로를 입력 데이터로 활용해 'end-to-end'로 학습할 수 있다.

 

 

훈련 데이터와 테스트 데이터

기계학습 문제는 데이터를 훈련 데이터테스트 데이터로 나눠 학습과 실험을 수행하는 것이 일반적이다. 먼저 훈련 데이터만 사용해 학습하면서 최적의 매개변수를 찾고, 그 다음 테스트 데이터를 사용해 앞서 훈련한 모델의 실력을 평가한다.

 

그렇다면 왜 이렇게 데이터를 두 가지로 나눠야 할까?

그 이유는 우리가 원하는 것이 범용적으로 사용이 가능한 모델이기 때문이다. 범용 능력은 아직 보지 못한 데이터로도 문제를 올바르게 풀어내는 능력이며, 이 능력을 획득하는 것이 기계학습의 최종 목표이다.

 

과적합(overfitting): 한 데이터셋에만 지나치게 최적화된 상태로, 올바를 평가를 수행할 수 없다.

 

 

손실 함수

신경망 학습에서는 현재의 상태를 하나의 지표로 표현하며, 그 지표를 가장 좋게 만들어주는 가중치 매개변수의 값을 탐색한다.

신경망 학습에서 사용하는 지표는 손실 함수(loss function)이며, 이는 일반적으로 오차제곱합과 교차 엔트로피 오차를 사용한다.

 

1) 오차제곱합(Sum of Squares for Error, SSE)

가장 많이 쓰이는 손실 함수로, 수식은 아래와 같다.

여기서 신경망의 출력 y는 소프트맥스 함수의 출력으로, 예측값을 확률로 해석 가능하다. 정답 레이블인 t는 정답을 가리키는 위치의 원소는 1, 그 외에는 0으로 표기(= 원-핫 인코딩)한다.

 

오차 제곱합은 각 원소의 출력과 정답 레이블의 차를 제곱한 후, 그 총합을 구하는 것으로, 파이썬으로 구현해보자.

def sum_squares_error(y, t):
	return 0.5 * np.sum((y-t)**2)

 

2) 교차 엔트로피 오차(Cross Entropy Error, CEE)

교차 엔트로피 오차의 수식은 다음과 같다.

 

교차 엔트로피도 정답 레이블 t가 정답에 해당하는 인덱스의 원소만 1이고 나머지는 0(원-핫 인코딩)이다. 그래서 실질적으로 정답일 때의 추정의 자연로그를 계산하는 식이 된다. 교차 엔트로피 오차는 정답일 때의 출력이 전체 값을 정하게 된다.

위에서 보듯이 자연로그의 그래프에서 x가 1일 때 y는 0이 되고 x가 0에 가까워질 수록 y의 값은 점점 작아진다. 따라서 위의 수식에서도 정답에 해당하는 출력이 커질 수록 0에 가까워지다가 그 출력이 1일 때 0이 되고, 반대로 정답일 때의 출력이 작아지면 오차는 커진다.

 

교차 엔트로피 오차를 구현해보자

def cross_entropy_error(y, t):
	delta = 1e - 7
    return -np.sum(t * np.log(y + delta))

여기서 y와 t는 넘파이 배열이다. 코드를 보면 np.log를 계산할 때 아주 작은 값인 delta를 더했는데 그 이유는, np.log() 함수에 0을 입력하면 마이너스 무한대를 뜻하는 -inf가 되어 계산을 더 이상 진행할 수 없기 때문이다. 그렇기에 아주 작은 값을 더해 절대 0이 되지 않게 하는 용도로 delta를 사용했다.

 

3) 미니 배치 학습

훈련 데이터 모두에 대한 손실 함수의 합을 구하는 방법을 생각해보자.

예를 들어서 교차 엔트로피 오차는 아래 식처럼 된다.

이때 데이터가 N개이면 t는 n번째 데이터의 k번째 값을 의미하게 된다. 이것은 데이터 하나에 대한 손실함수인 교차 엔트로피 식을 단순히 N개의 데이터로 확장하고, 마지막에 N으로 나누어 정규화한 것이다.

n으로 나눔으로써 평균 손실 함수를 구하는 것이다. 이렇게 평균을 구해 사용하면 훈련 데이터의 개수와 관계없이 통일된 지표를 얻을 수 있다.

 

하지만 데이터의 수가 많아지면 시간이 많이 걸리게 되는데, 이런 경우 데이터 일부를 추려 전체의 근사치로 이용할 수 있다. 이 일부를 미니배치라고 하며 이러한 학습 방법을 미니배치 학습이라 한다.

 

미니배치 학습을 구현하는 코드를 구현해보자.

# MNIST 데이터셋을 읽어오는 코드

import sys, os
sys.path.append(os.pardir)
import numpy as np
from dataset.mnist import load_mnist	# load_mnist 함수: MNIST 데이터셋을 읽어오는 함수

(x_train, t_train), (x_test, t_test) = \
	load_mnist(normalize=True, one_hot_label=True)	# 원-핫 인코딩
    
print(x_train.shape)	# (60000, 784)
print(t_train.shape)	# (60000, 10)

이 훈련 데이터에서 무작위로 10장만 빼내려면 넘파이의 np.random.choice() 함수를 사용하면 된다. 이 함수는 지정한 범위의 수 중에서 무작위로 원하는 개수만 꺼낼 수 있다. 이 함수가 출력한 배열을 미니배치로 뽑아낼 데이터의 인덱스로 사용하면 된다.

train_size = x_train.shape[0]
batch_size = 10
batch_mask = np.random_choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]

 

4) (배치용) 교차 엔트로피 오차 구현

미니배치 같은 배치 데이터를 지원하는 교차 엔트로피 오차를 구현하려면 교차 엔트로피 오차를 조금만 바꿔주면 된다. 데이터가 하나인 경우와 데이터가 배치로 묶여 입력될 경우 모두를 처리할 수 있도록 구현해보자.

def cross_entropy_error(y, t):	# y: 신경망의 출력 / t: 정답 레이블
	if y.ndim == 1:
    	t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)
    
    batch_size = y.shape[0]
    return -np.sum(t * np.log(y + 1e-7)) / batch_size

y가 1차원, 즉 데이터 하나당 교차 엔트로피 오차를 구하는 경우는 reshape 함수로 데이터의 형상을 바꿔준다. 이후 배치의 크기로 나눠 정규화하고 이미지 1장당 평균의 교차 엔트로피 오차를 계산한다.

 

정답 레이블이 숫자 레이블로 주어졌을 때의 교차 엔트로피 오차를 구현해보자.

def cross_entropy_error(y, t):
	if y.ndim == 1:
    	t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)
        
    batch_size = y.shape[0]
    return -np.sum(np.log(y[np.arrange(batch_size), t] + 1e-7)) / batch_size

원-핫 인코딩일 때 t가 0인 원소는 교차 엔트로피 오차도 0이므로, 그 계산은 무시해도 좋다는 것이 핵심!

즉, 정답에 해당하는 신경망의 출력만으로 교차 엔트로피 오차 계산이 가능하다! 

 

따라서, 원-핫 인코딩 시 t * np.log(y)였던 부분을 레이블 표현일 때는 np.log(y[np.arrange(batch_size), t]로 구현한다.

np.log(y[np.arrange(batch_size), t]는 0부터 batch_size -1까지 배열을 생성하며, 각 데이터의 정답 레이블에 해당하는 신경망의 출력을 추출한다.

 

5) 손실 함수 설정의 이유

정확도라는 지표를 놔두고 굳이 손실 함수의 값이라는 우회적인 방법을 택하는 이유는 무엇일까?

 

이 답은 신경망 학습에서의 미분의 역할에 주목하면 된다. 신경망 학습에서는 최적의 매개변수를 탐색할 때 손실 함수의 값을 가능한 한 작게 하는 매개변수 값을 찾는다. 이때 매개변수의 미분을 계산하고, 그 미분 값을 단서로 매개변수의 값을 서서히 갱신하는 과정을 반복한다.

 

가중치 매개변수의 손실 함수의 미분이란 '가중치 매개변수의 값을 아주 조금 변화시켰을 때, 손실 함수가 어떻게 변하는 지'를 의미하며, 만약 이 미분 값이 음수면 그 가중치 매개변수를 양의 방향으로 변화시켜 손실 함수의 값을 줄일 수 있다.

미분 값이 양수면 가중치 매개변수를 음의 방향으로 변화시켜 손실 함수의 값을 줄일 수 있고, 미분 값이 0이면 가중치 매개변수를 어느 쪽으로 움직여도 손실 함수의 값은 줄어들지 않아 가중치 매개변수의 갱신은 거기서 멈춘다.

 

즉, 정확도를 지표로 삼지 않는 이유는 미분값이 대부분의 장소에서 0이 되어 매개변수를 갱신할 수 없기 때문이다.

 

정확도는 매개변수의 미소한 변화에는 거의 반응을 보이지 않거나, 값이 불연속적으로 변화하는 반응을 보인다. 계단 함수를 활성화 함수로 사용하지 않는 이유와도 같다. 계단 함수를 이용하면 미분 값이 대부분 0이 되어 손실 함수를 지표로 삼는 게 아무 의미가 없게 된다.

 

 

수치 미분

1) 미분

미분: '특정 순간'의 변화량을 뜻함

수식은 다음과 같다

시간을 뜻하는 h를 한없이 0에 가깝게 한다는 의미를 lim으로 나타낸다.

 

수치 미분(numerical differentiation): 아주 작은 차분으로 미분하는 것

 

수치 미분을 구현할 때 주의해야 하는 문제가 있다. 그것은 h에 가급적 작은 값을 대입하려고 하다 보면 반올림 오차 문제를 일으켜 최종 계산 결과에 오차를 만들게 될 수 있다는 점이다.

 

또 다른 문제점은 f의 차분에 관한 것인데, 진정한 미분은 x 위치의 함수의 기울기에 해당하지만, 위의 식에서 미분은 (x+h)와 x 사이의 기울기에 해당한다. 이것은 h를 무한히 0으로 좁히는 것이 불가능해 생기는 한계이다.

 

위의 그래프와 같이 수치 미분에는 오차가 포함되며 이 오차를 줄이기 위해 (x+h)와 (x-h)일 때의 함수 f의 차분을 계산하는 방법을 쓰기도 한다. 여기서 이 차분은 x를 중심으로 그 전후의 차분을 계산한다는 의미로 중심 차분(= 중앙 차분)이라 한다. (x+h)와 x의 차분은 전방 차분

 

2) 편미분

이 식은 인수들의 제곱 합을 계산하는 단순한 식이지만, 변수가 2개라는 점을 주의해야 한다. 파이썬으로는 다음과 같이 구현이 가능하다.

def function_2(x):
	return x[0]**2 + x[1]**2	# 각 원소를 제곱하고 그 합을 구함
    # 또는 return np.sum(x**2)

 이 함수를 그래프로 그려보면 아래와 같이 3차원으로 그려진다.

 

위의 식을 미분할 때 주의할 점은 변수가 2개라는 것이다. 그렇기에 x0와 x1 중 어느 변수에 대한 미분인지를 구별해야 한다.

 

편미분: 변수가 여럿인 함수에대한 미분으로 변수가 하나인 미분과 마찬가지로 특정 장소의 기울기를 구한다. 단 여러 변수 중 목표 변수 하나에 초점을 맞추고 다른 변수는 값을 고정한다.

 

 

기울기

1) 경사법

최적: 손실 함수가 최솟값이 될 때의 매개변수 값

사법: 기울기를 이용해 함수의 최솟값을 찾으려는 방법

 

기울기는 각 지점에서 함수의 값을 낮추는 방안을 제시하는 지표이지만, 기울기가 가리키는 방향에 정말 함수의 최솟값이 있는지는 보장할 수가 없다. 하지만 그 방향으로 가야 함수의 값을 줄일 수 있다. 그래서 최솟값이 되는 장소를 찾는 문제에서는 기울기 정보를 단서로 나아갈 방향을 정해야한다.

 

경사법은 현 위치에서 기울어진 방향으로 일정 거리만큼 이동하며, 그 다음 이동한 곳에서도 또 똑같이 반복해 함수의 값을 점차 줄여나간다. 이는 기계학습을 최적화하는데 많이 쓰인다. 경사법은 최솟값을 찾느냐, 최댓값을 찾느냐에 따라 각각 경사 하강법, 경사 상승법이라고 한다.

 

경사법을 수식으로 나타내면 아래와 같다.

 

학습률(learning rate): 한 번의 학습으로 얼마만큼 학습해야 할지(= 매개변수 값을 얼마나 갱신할지)를 정하는 것

 

경사법은 변수의 값을 갱신하는 단계를 여러번 반복하며 점차 함수의 값을 줄이는 방법이며, 변수의 수가 늘어도 같은 식으로 갱신하게 된다. 여기서 학습률 값은 0.01이나 0.001 등 미리 특정값으로 정해둬야 한다.

 

경사 하강법을 구현해보자!

def gradient_descent(f, init_x, lr=0.01, step_num=100):
# f: 최적화하려는 함수, init_x: 초깃값, lr: 학습률, step_num: 경사법에 따른 반복 횟수

	x = init_x
    
    for i in range(step_num):
    	grad = numerical_gradient(f, x)		# 함수의 기울기 구하기
        x -= lr * grad						
    return x

 

경사법은 학습률이 너무 크면 큰 값으로 발산해버리고, 너무 작으면 거의 갱신되지 않은 채 끝나버리므로 학습률을 적절히 설정하는 일이 중요하다!

 

2) 신경망에서의 기울기

신경망에서의 기울기: 가중치 매개변수에 대한 손실 함수의 기울기

경사의 각 원소는 각각의 원소에 관한 편미분이며, 여기서 중요한 점은 경사의 형상이 W와 같다는 것이다.

손실 함수를 줄인다는 관점에서 w23은 양의 방향으로, w11은 음의 방향으로 갱신해야한다. 한 번에 갱신되는 양에는 w23이 w11보다 크게 기여한다.

 

 

학습 알고리즘 구현하기

신경망 학습의 절차

전제)

신경망에는 적응 가능한 가중치와 편향이 있고, 이 가중치와 편향을 훈련 데이터에 적응하도록 조정하는 과정을 학습이라함

1단계) - 미니배치

훈련 데이터 중 일부를 무작위로 가져온다. 이렇게 선멸한 데이터를 미니배치라 하며, 그 미니배치의 손실 함수 값을 줄이는 것이 목표이다.

2단계) - 기울기 산출

미니배치의 손실 함수 값을 줄이기 위해 각 가중치 매개변수의 기울기를 구한다. 기울기는 손실 함수의 값을 가장 작게 하는 방향을 제시한다.

3단계) - 매개변수 갱신

가중치 매개변수를 기울기 방향으로 아주 조금 갱신한다.

4단계) - 반복

1~3단계를 반복한다.

 

이는 경사하강법으로 매개변수를 갱신하는 방법으로 데이터를 미니배치로 무작위로 선정하기에 확률적 경사 하강법(Stochastic Gradient Descent, SGD)이라고 부른다. 

 

예시로 손글씨 숫자를 학습하는 신경망을 구현해보자!

 

1) 2층 신경망 클래스

# 2층 신경망을 하나의 클래스(TwoLayerNet)로 구현

import sys, os
sys.path.append(os.pardir)
from common.functions import *
from common.gradient import numerical_gradient

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 = softmax(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
    
    # x: 입력 데이터, t: 정답 레이블
    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

TwoLayerNet 클래스가 사용하는 중요 변수는 다음과 같다

 

아래는 TwoLayerNet 클래스의 메서드를 정리한 표이다.

 

2) 미니 배치 학습

TwoLayerNet 클래스와 MNIST 데이터셋을 사용해 학습을 수행해보자

import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet

(x_train, t_train), (x_test, t_test) = \
   load_mnist(normalize = True, one_hot_label = True)
   
train_loss_list = []

# 하이퍼파라미터
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):
	# 미니배치 획득
    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)
    # grad = network.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)

위의 코드에서 경사법에 의한 갱신 횟수를 10,000번으로 설정하고, 갱신할 때마다 훈련 데이터에 대한 손실 함수를 계산하고, 그 값을 배열에 추가한다. 이 손실 함수 값의 변화 추이를 그래프로 나타내면 아래와 같다.

 

여기서 학습 횟수가 늘어가면서 손실 함수의 값이 줄어드는 것을 볼 수 있는데, 이는 학습이 잘 되고 있다는 뜻이다. 

 

3) 테스트 데이터로 평가

신경망 학습에서는 훈련 데이터 외의 데이터를 올바르게 인식하는지, 즉 오버피팅되지 않는 지 확인해야 한다. 신경망 학습의원래 목표는 범용적인 능력을 익히는 것이기에, 이를 평가하려면 훈련 데이터에 포함되지 않은 데이터를 사용해서 평가를 진행해봐랴 한다.

 

평가가 제대로 이뤄지도록 앞에서 구현한 것을 조금만 수정해보았다.

import numpy as np
from dataset.mnist import load_mnist
from two_layer_net import TwoLayerNet

(x_train, t_train), (x_test, t_test) = \
   load_mnist(normalize=True, one_hot_label=True)
   
network = TwoLayerNet(input_size=784, hidden_size=50, ouput_size=10)

# 하이퍼파라미터
iters_num = 10000	# 반복 횟수를 적절히 설정한다
train_size = x_train.shape[0]
batch_size = 100	# 미니배치 크기
learning_rate = 0.1

train_loss_list = []
train_acc_list = []
test_acc_list = []

# 1에폭당 반복 수
iter_per_epoch = max(train_size / batch_size, 1)

for i in range(iters_num):
	# 미니배치 획득
    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)
    # grad = network.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에폭당 정확도 계산
    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))

여기서는 1에폭마다 모든 훈련 데이터와 시험 데이터에 대한 정확도를 계산하고, 그 결과를 기록한다. 위의 코드의 결과를 그래프로 나타내면 다음과 같다.

그래프를 보면 학습이 진행될수록 훈련 데이터와 테스트 데이터를 사용하고 평가한 정확도가 모두 좋아지고, 두 정확도에 차이가 거의 없음을 알 수 있다.