KoreanFoodie's Study

딥러닝 튜토리얼 5강 2부, 활성화 함수 계층 구현, Affine/Softmax 계층 구현, 오차역전파법 구현 - 밑바닥부터 시작하는 딥러닝 본문

Deep Learning/밑바닥부터 시작하는 딥러닝 1

딥러닝 튜토리얼 5강 2부, 활성화 함수 계층 구현, Affine/Softmax 계층 구현, 오차역전파법 구현 - 밑바닥부터 시작하는 딥러닝

GoldGiver 2019. 12. 26. 16:46

해당 포스팅은 한빛 미디어에서 출판한 '밑바닥부터 시작하는 딥러닝'이라는 교재의 내용을 따라가며 딥러닝 튜토리얼을 진행하고 있습니다. 관련 자료는 여기에서 찾거나 다운로드 받으실 수 있습니다.


활성화 함수 계층 구현하기

이제 계산 그래프를 신경망에 적용해 보자. 우선은 활성화 함수은 ReLU와 Sigmoid 계층을 구현해 보자.

  • ReLU 계층

활성화 함수로 상요되는 ReLU의 수식은 다음과 같다.

위 식에서 x에 대한 y의 미분은 다음과 같다.

위 식과 같이 순전파 때의 입력인 x가 0보다 크면 역전파는 상류의 값을 그대로 하류로 흘린다. 반면, 순전파 때 x가 0 이하면 역전파 때는 하류로 신호를 보내지 않는다(0을 보낸다). 계산 그래프로는 다음과 같이 그릴 수 있다.

이제 이 ReLU 계층을 구현해 보자! 신경망 계층의 forward()와 backward() 함수는 넘파이 배열을 인수로 받는다고 가정한다. ReLU 계층을 구현한 코드는 common/layers.py에 있다.

class Relu:
	def __init__(self):
    	self.mask = None
    
    def forward(self, x):
    	self.mask = (x <= 0)
        out = x.copy()
        out[self.mask] = 0
        
        return out
    
    def backward(self, dout):
    	dout[self.mask] = 0
        dx = dout
        
        return dx

Relu 클래스는 mask라는 인스턴스 변수를 가진다. mask는 True/False로 구성된 넘파이 배열로, 순전파의 입력인 x의 원소 값이 0 이하인 인덱스는 True, 그 외(0보다 큰 원소)는 False로 유지한다. 예컨대 mask 변수는 다음 예와 같이 True/False로 구성된 넘파이 배열을 유지한다.

>>> x = np.array([[1.0, -0.5], [-2.0, 3.0]])
>>> print(x)
[[ 1.  -0.5]
 [-2.   3. ]]
>>> mask = (x <= 0)
>>> print(mask)
[[False  True]
 [ True False]]

위의 그림과 같이, 순전파 때의 입력 값이 0 이하면 역전파 때의 값은 0이 되어야 한다. 그래서 역전파 때는 순전파 때 만들어둔 mask를 써서 mask의 원소가 True인 곳에서는 상류에서 전파된 dout을 0으로 설정한다!

ReLU 계층은 전기 회로의 '스위치'에 비유할 수 있다. 순전파 때 전류가 흐르고 있으면 스위치를 ON으로 하고, 흐르지 않으면 OFF로 한다. 역전파 때는 스위치가 ON이면 전류가 그대로 흐르고, OFF면 더 이상 흐르지 않는다.

 

  • Sigmoid 계층

다음은 시그모이드 함수를 구현해보자. 시그모이드 함수는 다음 식을 의미한다.

위의 식을 계산 그래프로 그리면 다음 그림과 같이 표현할 수 있다.

'x'와 '+' 노드 말고도 'exp'와 '/' 노드가 새롭게 등장했다. 위 그림과 같이 시그모이드 함수 식의 계산은 국소적 계산의 전파로 이뤄진다. 이제 위 그림의 역전파를 하나씩 알아보자!

1 단계

'/' 노드, 즉 y = 1/x를 미분하면 다음 식이 된다.

위의 식에 따르면 역전파 때는 상류에서 흘러온 값에 -y^2을 곱해서 하류로 전달한다!

 

2단계

'+' 노드는 상류의 값을 여과 없이 하류로 내보내는 게 다다!

 

3 단계

'exp'노드는 y = exp(x) 연산을 수행하며, 그 미분은 다음과 같다.

4 단계

'x' 노드는 순전파 때의 값을 '서로 바꿔' 곱한다. 이 예에서는 -1을 곱하면 된다!

이상으로 계산 그래프를 완성했다. 이때, 시그모이드 함수의 역전파는 최종 결과물 dL/dy * y^2 * exp(-x)로 묶을 수 있으므로, 다음과 같이 간단하게 표현할 수 있다!

결과는 같으나, 노드의 그룹화를 통해 입력과 출력에 집중할 수 있다!

또한, dL/dy * y^2 * exp(-x)은 다음처럼 정리해서 쓸 수 있다.

이처럼, Sigmoid 계층의 역전파는 순전파의 출력 (y) 만으로 계산할 수 있다!

그럼 이제 Sigmoid 계층을 파이썬으로 구현해보자(소스 코드는 common/layers.py에 있습니다).

class Sigmoid:
    def __init__(self):
        self.out = None

    def forward(self, x):
        out = sigmoid(x)
        self.out = out
        return out

    def backward(self, dout):
        dx = dout * (1.0 - self.out) * self.out

        return dx

이 구현에서는 순전파의 출력을 인스턴스 변수 out에 보관했다가, 역전파 계산 때 그 값을 사용한다.


Affine/Softmax 계층 구현하기

 

  • Affine 계층

신경망의 순전파에서는 가중치 신호의 총합을 계산하기 때문에 행렬의 곱(넘파이에서는 np.dot())을 사용했다.

예시 코드를 보자.

X = np.random.rand(2)
W = np.random.rand(2,3)
B = np.random.rand(3)

X.shape # (2,)
W.shape # (2,3)
B.shape # (3,)

Y = np.dot(X.W) + B

예를 들어, 다음과 같은 코드를 계산 그래프로 나타내면 다음과 같다.

신경망의 순전파 때 수행하는 행렬의 곱은 기하학에서는 어파인 변환(affine transformation)이라고 한다. 그래서 어파인 변환을 수행하는 처리를 'Affine 계층'이라고 정의했다.

이제 위 그림에 대한 역전파를 구해보자. 행렬을 이용한 역전파도 행렬의 원소마다 전개해보면 스칼라값을 사용한 지금까지의 계산 그래프와 같은 순서로 생각할 수 있다.

W^T의 T는 전치행렬을 뜻한다. 전치행렬은 W의 (i, j) 위치의 원소를 (j, i) 위치로 바꾼 것을 말한다. 수식으로는 다음과 같다.

위 식을 바탕으로 한 역전파는 다음과 같이 구할 수 있다.

이때, 행렬 곱의 역전파는 행렬의 대응하는 차원의 원소 수가 일치하도록 잘 조립해주어야 한다.

 

  • 배치용 Affine 계층

지금까지의 Affine 계층은 입력 데이터로 X 하나만을 고려한 것이었다. 이번 절에서는 데이터 N개를 묶어 순전파하는 경우, 즉 배치용(데이터 묶음) Affine 계층을 생각해 보자.

기존과 다른 부분은 입력인 X의 형상이 (N, 2)가 된 것뿐이다. 그 뒤로는 지금까지와 같이 계산 그래프의 순서를 따라 순순히 행렬 계산을 하게 된다.

편향을 더할 때도 주의해야 한다. 순전파 때의 편향 덧셈은 X*W에 대한 편향이 각 데이터에 더해진다. 예를 들어 N = 2(묶음이 2개짜리)로 한 경우, 편향은 그 두 데이터 각각에(각각의 계산 결과에) 더해진다. 코드를 한번 보자.

X_dot_W = np.array([[0, 0, 0], [10, 10, 10]])
B = np.array([1, 2, 3])

# X_dot_W + B
# [[1, 2, 3], [11, 12, 13]])

순전파의 편향 덧셈은 각각의 데이터(1번째 데이터, 2번째 데이터, ...)에 더해진다. 그래서 역전파 때는 각 데이터의 역전파 값이 편향의 원소에 모여야 한다.

dY = np.array([[1, 2, 3], [4, 5, 6]])
dB = np.sum(dY, axis=0)
# dB -> [5, 7, 9]

편향의 역전파는 그 두 데이터에 대한 미분을 데이터마다 더해서 구한다. 그래서 np.sum()에서 0번째 축에 대해(axit=0) 총합을 구한다.

class Affine:
	def __init__(self):
    	self.W = W
        self.b = b
        self.x = None
        self.original_x_shape = None
        # 가중치와 편향 매개변수의 미분
        self.dW = None
        self.db = None
        
    def forward(self, x):
    	self.x = x
        out = np.dot(x, self.W) + self.b
        
        return out
    
    def backward(self, dout):
    	dx = np.dot(dout, self.W.T)
        self.dW = np.dot(self.x.T, dout)
        self.db = np.sum(dout, axit=0)
    	
        return dx

 

  • Softmax-with-Loss 계층

마지막에서 출력층에서 사용하는 소프트맥스 함수에 대해 알아보자. 소프트맥스 함수는 입력 값을 경규화아혀 출력한다. 예를 들어 손글씨 숫자 인식에서의 출력은 다음과 같다.

위와 같이 Softmax 계층은 입력 값을 정규화(출력의 합이 1이 되도록 변형)하여 출력한다. 손글씨 숫자는 가짓수가 10개(10 클래스 분류)이므로 Softmax 계층의 입력은 10개가 된다.

신경망에서 수행하는 작업은 학습추론 두 가지이다. 추론할 때는 일반적으로 Softmax 계층을 사용하지 않는다. 예를 들어, 위에서 신경망은 추론할 때 마지막 Affine 계층의 출력을 인식 결과로 이용한다. 또한, 신경망에서 정규화하지 않는 출력 결과(Affine 계층의 출력)를 점수(Score)라고 한다. 즉, 신경망 추론에서 답을 하나만 내는 경우에는 가장 높은 점수만 알면 되니 Softmax 계층은 필요 없다는 것이다. 반면, 신경망을 학습할 때는 Softmax 계층이 필요하다.

이제 손실 함수인 교차 엔트로피 오차도 포함하여 'Softmax-with-Loss 계층'이라는 이름으로 구현해보자. 계산 그래프는 다음과 같다.

위의 계산 그래프는 다음과 같이 간소화할 수 있다 :

위의 계산 그래프에서 소프트맥스 함수는 'Softmax' 계층으로, 교차 엔트로피 오차는 'Cross Entropy Error' 계층으로 표시했다. 여기에서는 3클래스 분류를 가정하고 이전 계층에서 3개의 입력(점수)을 받는다. 그림과 같이 Softmax 계층은 입력 (a1, a2, a3)를 정규화하여 (y1, y2, y3)를 출력한다. Cross Entropy Error 계층은 Softmax의 출력 (y1, y2, y3)와 정답 레이블 (t1, t2, t3)를 받고, 이 데이터들로부터 손실 L을 출력한다.

여기서 주목할 것은 역전파의 결과이다. Softmax 계층의 역전파는 (y1-t1, y2-t2, y3-t3)라는 말끔한 결과를 내놓고 있다. (y1, y2, y3)는 Softmax 계층의 출력이고 (t1, t2, t3)는 정답 레이블이므로 (y1-t1, y2-t2, y3-t3)는 Softmax 계층의 출력과 정답 레이블의 차분인 것이다. 신경망의 역전파에서는 이 차이인 오차가 앞 계층에 전해지는 것이다. 이것은 신경망 학습의 중요한 성질이다!

그런데 신경망 학습의 목적은 신경망의 출력(Softmax의 출력)이 정답 레이블과 가까워지도록 가중치 매개변수의 값을 조정하는 것이었다. 그래서 신경망의 출력과 정답 레이블의 오차를 효율적으로 앞 계층에 전달해야 한다. 앞의 (y1-t1, y2-t2, y3-t3)라는 결과는 바로 Softmax 계층의 출력과 정답 레이블의 차이로, 신경망의 현재 출력과 정답 레이블 오차를 있는 그대로 드러내고 있다.

소프트맥스 함수의 손실 함수로 교차 엔트로피 오차를 사용하니 역전파가 (y1-t1, y2-t2, y3-t3)로 말끔히 떨어진다. 

예를 들어, 정답 레이블이 (0, 1, 0)일 때 Softmax 계층이 (0.3, 0.2, 0.5)를 출력했다고 해보자. 정답 레이블을 보면 정답의 인덱스는 1이다. 그런데 출력에서는 이때의 확률이 겨우 0.2(20%)라서, 이 시점의 신경망은 제대로 인식하지 못하고 있다. 이 경우 Softmax 계층의 역전파는 (0.3, -0.8, 0.5)라는 커다란 오차를 전파한다. 결과적으로 Softmax 계층의 앞 계층들은 그 큰 오차로부터 큰 깨달음을 얻게 된다.

만약 정답 레이블은 (0, 1, 0)이고 Softmax 계층이 (0.01, 0.99, 0)을 출력한 경우가 있다고 해보자. 이 경우, Softmax 계층의 역전파가 보내는 오차는 비교적 작은 (0.01, -0.01, 0)이다. 

그럼 이제 코드를 살펴보자.

class SoftmaxWithLoss:
	def __init__(self):
    	self.loss = None # 손실
        self.y = None	 # softmax의 출력
        self.t = None	 # 정답 레이블(원-핫 벡터)
    
    def forward(self, x, t):
    	self.t = t
        self.y = softmax(x)
        self.loss = corss_entropy_error(self.y, self.t)
        return self.loss
    
    def backward(self, dout=1):
    	batch_size = self.t.shape[0]
        dx = (self.y - self.t) / batch_Size
        
        return dx

 

오차역전파법 구현하기

  • 신경망 학습의 전체 그림

구체적인 구현에 들어가기 전에 신경망 학습의 전체 그림을 복습해 보자. 

전제

신경망에는 적응 가능한 가중치와 편향이 있고, 이 가중치와 편향을 훈련 데이터에 적응하도록 조정하는 과정을 '학습'이라 한다. 신경망 학습은 다음과 같이 4단계로 수행한다.

1단계 - 미니배치

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

2단계 - 기울기 산출

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

3단계 - 매개변수 갱신

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

4단계 - 반복

1~3단계를 반복한다.

지금까지 설명한 오차역전파법이 등장하는 단계는 두 번째인 '기울기 산출'이다. 오차역전파법을 이용하면 느린 수치 미분과 달리 기울기를 효율적이고 빠르게 구할 수 있다.

 

  • 오차역전파법을 적용한 신경망 구현하기

2층 신경망을 TwoLayerNet 클래스로 구현하겠다. 다음은 인스턴스 변수와 메서드를 정리한 표이다.

인스턴스 변수 설명
params

딕셔너리 변수로, 신경망의 매개변수를 보관.

params['W1']은 1번째 층의 가중치, params['b1']은 1번째 층의 편향

layers

순서가 있는 딕셔너리 변수로, 신경망의 계층을 보관

layers['Affine1']과 같이 각 계층을 순서대로 유지

lastLayer 신경망의 마지막 계층. 이 예에서는 SoftmaxWithLoss 계층
메서드 설명
__init__(self, input_size, hidden_size, output_size, weight_init_std) 초기화를 수행한다. 인수는 앞에서부터 입력층 뉴런 수, 은닉층 뉴런 수, 출력층 뉴런 수, 가중치 초기화 시 정규분포의 스케일
predict(self, x) 예측(추론)을 수행한다. 인수 x는 이미지 데이터
loss(self, x, t) 손실 함수의 값을 구한다. t는 정답 레이블
accuracy(self, x, t) 정확도를 구한다
numerical_gradient(self, x, t) 가중치 매개변수의 기울기를 수치 미분 방식으로 구한다(앞 장과 같음)
gradient(self, x ,t) 가중치 매개변수의 기울기를 오차역전파법으로 구한다

앞과 다른 부분은 계층을 사용한다는 것이다. 계층을 사용함으로써 인식 결과를 얻는 처리(predict())와 기울기를 구하는 처리(gradient()) 계층의 전파만으로 동작이 이루어지는 것이다. 이제 코드를 보자!

# coding: utf-8
import sys, os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
import numpy as np
from common.layers import *
from common.gradient import numerical_gradient
from collections import OrderedDict


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)

        # 계층 생성
        self.layers = OrderedDict()
        self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1'])
        self.layers['Relu1'] = Relu()
        self.layers['Affine2'] = Affine(self.params['W2'], self.params['b2'])

        self.lastLayer = SoftmaxWithLoss()
        
    def predict(self, x):
        for layer in self.layers.values():
            x = layer.forward(x)
        
        return x
        
    # x : 입력 데이터, t : 정답 레이블
    def loss(self, x, t):
        y = self.predict(x)
        return self.lastLayer.forward(y, t)
    
    def accuracy(self, x, t):
        y = self.predict(x)
        y = np.argmax(y, axis=1)
        if t.ndim != 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
        
    def gradient(self, x, t):
        # forward
        self.loss(x, t)

        # backward
        dout = 1
        dout = self.lastLayer.backward(dout)
        
        layers = list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout = layer.backward(dout)

        # 결과 저장
        grads = {}
        grads['W1'], grads['b1'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
        grads['W2'], grads['b2'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

        return grads

#계층 생성 부분을 잘 보자. 신경망의 계층을 OrderedDict()에 보관하는 점이 중요하다! OrderedDIct는 순서가 있는 딕셔너리다. 그래서 순전파 때는 추가한 순서대로 각 계층의 forward() 메서드를 호출하기만 하면 처리가 완료된다. 마찬가지로 역전파 때는 계층을 반대 순서로 호출하기만 하면 된다!

이처럼 신경망을 '계층'으로 모듈화해서 구현한 효과는 아주 크다. 예컨대 5층, 10층, 20층, ...과 같이 깊은 신경망을 만들고 싶다면, 단순히 필요한 만큼 계층을 더 추가하면 된다!

 

  • 오차역전파법으로 구한 기울기 검증하기

수치 미분은 느리지만, 구현이 간단하다. 그래서 오차역전파법으로 구한 기울기가 일치함을 체크함으로써 계산의 정확성을 확인할 수 있다! (코드는 ch05/gradient_check.py에 있다)

# coding: utf-8
import sys, os
sys.path.append(os.pardir)  # 부모 디렉터리의 파일을 가져올 수 있도록 설정
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, output_size=10)

x_batch = x_train[:3]
t_batch = t_train[:3]

grad_numerical = network.numerical_gradient(x_batch, t_batch)
grad_backprop = network.gradient(x_batch, t_batch)

# 각 가중치의 절대 오차의 평균을 구한다.
for key in grad_numerical.keys():
    diff = np.average( np.abs(grad_backprop[key] - grad_numerical[key]) )
    print(key + ":" + str(diff))

결과는 다음과 같다.

W1:4.992444822296182e-10
b1:2.7775308568029376e-09
W2:6.1427267257827926e-09
b2:1.4103044333468872e-07

각 가중치 매개변수의 차이의 절댓값을 구하고, 이를 평균한 값을 구한 것이다!

 

  • 오차역전파법을 사용한 학습 구현하기

마지막으로 오차역전파법을 사용한 신경망 학습을 구현해 보자. 지금까지와 다른 부분은 기울기를 오차역전파법으로 구현한다는 것 뿐이다. (소스 코드는 ch05/train_neuralnet.py에 있다)

# coding: utf-8
import sys, os
sys.path.append(os.pardir)

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, output_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 = []

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)
    
    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)
Comments