본문 바로가기
Deep Learning/밑바닥부터 시작하는 딥러닝

Backpropagation

by 대소기 2022. 1. 27.

 

 

* 이번 포스팅에서는 Backpropagation을 계산 그래프를 위주로 알아보고자 합니다.

* 오차 역전파법에 대해 수식을 통해 설명해 놓은 이전 포스팅이 있으니 궁금하신 분들은 참고하시길 바랍니다.

https://soki.tistory.com/7?category=1050549

 

Backpropagation

본 포스팅은 유튜버 혁펜하인님의 Backpropagation 강의를 정리한 포스팅입니다. 더 정확한 설명을 들으실 분들은 아래 동영상을 참고해주세요. 먼저 위 그림과 같은 hidden layer가 2개인 DNN model이 있

soki.tistory.com

 

계산그래프

 

문제 : 슈퍼에서 사과 2개, 귤 3개를 구매했고, 사과는 개당 100원, 귤은 개당 150원일 때 소비세가 10%일 때의 지불 금액을 구하시오.

* 구매한 사과와 귤의 총 금액을 계산하는 그래프는 위와 같이 구성할 수 있다.

* 계산 그래프에서 동그라미로 표시된 node에는 연산 종류가 edge에는 금액이 적혀있다.

* 계산 그래프의 계산 방향은 왼쪽에서 오른쪽으로 진행된다. 그리고 이 과정을 순전파(forward propagation) 라고 한다. 역방향인 오른쪽에서 왼쪽으로 계산되는 과정은 추후에 배우겠지만 역전파(Backpropagation)이라고 한다.

 

 

 

계산 그래프의 장점

 

1) 국소적 계산

* 계산그래프에서 국소적 계산이란 계산의 각 단계에서 자신과 관련된 부분의 계산에만 집중하는 특성을 뜻한다.

* 예를 들어 위와 같은 계산 그래프가 있다고 해보자. 사과의 금액을 계산하는 단계에서는 '여러 식품 구입' 이라는 복잡한 계산 과정을 신경쓸 필요 없이 2 x 100의 계산만 하면 된다. 이렇게 계산 그래프에서 아무리 다른 계산이 복잡해도 각 단계에서는 간단한 계산만을 해서 계산 결과를 전달하기 때문에 전체적으로 복잡한 계산이라도 단순한 계산들의 단계로서 구현이 가능하다.

* 이러한 계산 과정은 마치 자동차를 만드는 것은 복잡한 과정들로 이뤄져있지만, 조립 라인의 각 단계들은 매우 단순한 작업으로 구성되어 있는 것과 같다. 단순한 작업의 단계들을 거치며 복잡한 결과물을 도출할 수 있다는게 계산 그래프의 특징이다.

 

 

2) 보관 가능한 중간계산 결과

 

* 위 그림은 포스팅 초반에 보았던 예시이다. 계산은 단계별로 이뤄지기 때문에 단계별 계산 결과를 저장하는 것이 가능하다. 예를 들어 '사과 가격은 200원이었고, 사과와 귤의 가격을 더한 것은 600원이었다.' 와 같이 말이다.

 

3) 미분 측면의 효율성

 

* 우리는 결국 이 계산 그래프를 신경망의 가중치 갱신에 사용하게 될 것이다. 가중치를 갱신하기 위해서는 앞서 살펴봤듯이 가중치의 기울기를 구해 손실함수가 최소가 되는 방향으로 가중치를 갱신하게 된다.

* 계산그래프가 미분 측면에서 효율적이라는 것은 계산그래프의 국소적 계산이라는 특성과 연결된다. 위 그림에서 사과의 가격이 h만큼 변화였을 때 총 금액에 미치는 영향을 계산하기 위해 미분을 사용하고 있다. 이 때, 국소적으로 미분값을 전달(1 -> 1.1 -> 2.2) 하고 있는 것을 확인할 수 있다. 이렇게 국소적 계산이라는 특성 때문에 미분 측면에서 효율적이라고 할 수 있다. 

* 또한 보관 가능한 중간결과 라는 특성 덕분에 각 미분값을 저장할 수 있다는 측면에서도 효율적이다.

 

 

 

 

 

 

연쇄법칙(Chain Rule)

 

 

연쇄법칙 해석적으로 살펴보기

 

* backpropagation에서 국소적 미분 값의 전달은 연쇄법칙에 의해 이뤄진다. 연쇄법칙을 살펴보기 앞서 합성함수를 살펴보자.

 

$z=(x+y)^2$ 의 식은 아래와 같이 2 가지 식으로 구성되어 있다.

$z = t^2$

$t = x + y$

 

* 함성함수의 미분은 합성함수를 구성하는 각 함수의 미분의 곱으로 나타낼 수 있다. 이를 연쇄법칙이라고 한다.

* 이 연쇄법칙을 z를 x에 대하여 미분하는 상황에 대입해보자.

 

합성함수의 미분 ${\partial z \over \partial x}$ 는 각 함수의 미분 ${\partial z \over \partial t},  {\partial t \over \partial x}$ 의 곱으로 나타낼 수 있다.

 

* 위 명제가 성립하는가? 각 함수의 미분 값을 곱하면 $\partial t$가 지워지기 때문에 양 변이 같아진다. 즉 위 명제는 성립한다.

 

 

 

연쇄법칙과 계산그래프

 

* 역전파 과정에서 국소적 미분 값을 전달하는 그래프를 시각화 한 자료이다. 국소적 미분값을 전달하는 것은 결국 연쇄법칙을 사용하는 것과 같다는 것을 볼 수 있다.

 

 

역전파(Backpropagation)

 

덧셈 노드의 역전파

 

* $z = x + y$라는 식이 있다고 해보자. 각 변수별 편미분 값은 다음과 같다.

 

${\partial z \over \partial x} = 1$

${\partial z \over \partial y} = 1$

 

 

 

* 덧셈 노드에서의 역전파 과정을 살펴보면 상류에서 흘러온 미분 값 ${\partial L \over \partial z}$ 에 z를 입력신호로 미분한 값 ${\partial z \over \partial x}$, ${\partial z \over \partial y}$ 를 곱해서 다음 노드에 전달하게 된다. 이 때 x, y의 각 편미분 값은 1이기 때문에(${\partial z \over \partial x} = {\partial(x+y) \over \partial(x}$ = 1, ${\partial z \over \partial y} = {\partial(x+y) \over \partial(y}$ = 1) 결국 역전파 과정에서 덧셈 노드는 전달된 미분값을 그대로 다음 노드에게 전달하는 역할을 하게 된다.

 

 

곱셈 노드의 역전파

 

* z = xy라는 식이 있다고 가정해보자. 이 식의 미분은 다음과 같다.

 

${\partial z \over \partial x} = y$

${\partial z \over \partial y} = x$

 

* 뭔가 좀 특이한 것을 발견했는가? 입력 값 x로 편미분한 값은 y가 나오고, y로 편미분한 값은 x가 나왔다. 그렇다면 곱셈 노드의 역전파는 결과적으로 어떻게 진행될까? 다음 그림을 보자.

 

* 상류로 부터 전달된 미분값 ${\partial L \over \partial z}$ 에 각 입력 값을 바꾼 값(x가 입력되었을 때는 y, y가 입력되었을 때는 x를)을 곱하여 다음 노드로 전달하고 있는 것을 볼 수 있다. 

* 이렇게 전달된 미분 값에 뒤바뀐 입력신호를 곱하게 되기 때문에 곱셈 노드의 순전파에서는 추후 역전파시의 계산의 용이성을 위해 입력 값을 저장해 놓는다. 반대로 덧셈에서는 이러한 현상이 나타나지 않기 때문에 입력 값을 따로 저장하지는 않게 된다.

 

단순한 layer 구현하기

 

1) 곱셈 layer 

* 곱셈 layer을 python code로 구현해보자.

# MulLayer 곱셈노드
class MulLayer: 
	def __init__(self): 
    	self.x = None 
    	self.y = None 
        
    def forward(self, x, y): 
    	# 곱셈 노드이므로 입력값 저장
        self.x = x 
        self.y = y 
        out = x * y 
    
    def backward(self, dout): 
    	dx = dout * self.y # x와 y를 바꾼다.
        dy = dout * self.x 
        
        return dx, dy

* 순전파, 역전파를 포함한 곱셈 layer을 구하였으니 다음으로 곱셈 layer을 통해 사과 쇼핑 계산을 시행해보자.

 

apple = 100 
apple_num = 2 
tax = 1.1 

#계층들 
mul_apple_layer = MulLayer() 
mul_tax_layer = MulLayer() 

#순전파 
apple_price = mul_apple_layer.forward(apple, apple_num) 
price = mul_tax_layer.forward(apple_price, tax) 

print(price) # 220

* 하위 multiplication layer를 사용해 apple price를, multiplication layer 두 개를 사용해서 소비세를 포함한 price를 순전파 과정을 사용해 구하였다.

* 순전파를 시행해 보았으니 역전파도 시행해보자.

 

#역전파 
dprice = 1 
dapple_price, dtax = mul_tax_layer.backward(dprice) 
dapple, dapple_num = mul_apple_layer.backward(dapple_price)

print(dapple, dapple_num, dtax) # 2.2 110 200

* backward가 받는 인수는 순전파 출력의 미분값인 dprice라는 것을 주의

 

 

 

2) 덧셈 layer

 

* 덧셈 layer도 구현해보자.

 

# AddLayer 덧셈노드
class AddLayer: 
	def __init__(self): 
    	pass 
    
    def forward(self, x, y): 
    	out = x + y 
        return out 
        
    def backward(self, dout): 
    	dx = dout * 1 
        dy = dout * 1 
        
        return dx, dy

* 덧셈 layer에서는 입력값을 __init__에서 정의해줄 필요가 없기 때문에(곱셈 layer와 달리 입력값을 저장할 필요가 없음) pass를 사용한다. 또한 전달받은 미분 값을 그대로 다음 노드에게 흘려보내주는 것을 볼 수 있다.

 

* 이렇게 구현한 add, multiplication layer들을 사용하여 위 계산 그래프를 구현해보자.

 

apple = 100 
apple_num = 2 
orange = 150 
orange_num = 3 
tax = 1.1 

# layer 계층들
mul_apple_layer = MulLayer() 
mul_orange_layer = MulLayer() 
add_apple_orange_layer = AddLayer() 
mul_tax_layer = MulLayer() 

# forward 순전파
apple_price = mul_apple_layer.forward(apple, apple_num) # (1) 
orange_price = mul_orange_layer.forward(orange, orange_num) # (2) 
all_price = add_apple_orange_layer.forward(apple_price, orange_price) # (3) 
price = mul_tax_layer.forward(all_price, tax) # (4) 

# backward 역전파
dprice = 1 
dall_price, dtax = mul_tax_layer.backward(dprice) # (4) 
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price) # (3) 
dorange, dorange_num = mul_orange_layer.backward(dorange_price) # (2) 
dapple, dapple_num = mul_apple_layer.backward(dapple_price) # (1) 

print("price:", int(price)) print("dApple:", dapple) 
print("dApple_num:", int(dapple_num)) print("dOrange:", dorange) 
print("dOrange_num:", int(dorange_num)) print("dTax:", dtax)

 

 

 

활성화 함수 계층 구현하기

 

ReLU layer

 

 

* ReLU 식은 위와 같다. 그리고 ReLU를 x에 대해 미분한 값은 아래와 같다.

* 미분 결과를 보면 x가 0보다 클 때는 1이 돼서 전달된 미분 값을 다음 노드에 그대로 전달해주는 역할을 하고, x가 0보다 작을 때는 0이 곱해져서 다음 노드에 전달되는 것을 알 수 있다.

* Relu를 python code로 구현해보자.

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

* 역전파 과정에서 0보다 작을 때는 미분 값으로 0을 전달하기 위해 self.mask를 사용한다. 만약 0보다 큰 값이 있다면 그대로 전달한다.

 

 

Sigmoid layer

 

$y = {1 \over 1+exp(-x)}$

 

* sigmoid 함수를 계산 그래프로 표현하면 다음과 같다.

 

1 단계)

 

* ' / ' 노드, 즉 $y = {1 \over x}$ 를 미분하면 다음 식이 된다.

 

${\partial y \over \partial x} = -{1 \over x^2} = - y^2$

 

* 때문에 ' / ' 노드는 전달받은 미분 값에 순전파의 출력값 y를 사용한 $-y^2$를 곱해서 다음 노드로 전달한다.

 

 

2 단계)

 

* + 노드는 앞에서 봤듯이 전달받은 미분 값을 그대로 다음 노드로 전달한다.

 

3 단계)

 

* 'exp' 노드는 y=exp(x) 연산을 수행한다. exponential은 미분해도 값이 동일하기 때문에 아래와 같다.

 

${\partial y \over \partial x} = exp(x)$

 

* 계산 그래프에서는 상류의 값에 순전파 때의 출력을 곱해 하류로 전달한다. 

 

4 단계)

 

* 'x' 노드는 순전파 때의 값을 서로 바꿔서서 곱한다. 

 

 

* 이렇게 sigmoid 계산 그래프의 역전파 과정을 살펴보았다. x노드에서 전달되는 미분 값인 ${\partial L \over \partial y y^2 exp(-x)}$ 는 x 노드의 입력값인 x와 순전파 과정의 마지막 출력값인 y만 사용해서 사용해서 계산할 수 있다는 것을 확인하였는가? 이 덕분에 우리는sigmoid의 중간 과정을 생략하고 다음과 같이 그래프를 바꿀 수 있다.

 

* 이렇게 계산 그래프를 간소화 하면 입력과 출력에만 집중할 수 있고, 번거로운 계산 과정이 줄어든다는 장점이 있다.

* 여기서 더 간소화 할 수 있는데, sigmoid 노드의 미분값은 다음과 같이 출력 y로만 표현할 수 있기 때문이다.

* 그래서 최종적으로는 아래와 같이 순전파의 출력만으로 미분 값을 계산하는 것이 가능하다.

 

* sigmoid layer를 python code로 구현해보자.

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

 

 

Affine layer

* 앞서 신경망의 순전파에서는 X * W + b 와 같이 가중치행렬 W와 입력 벡터 X가 곱해지고 편향 b가 더해지는 계산이 이뤄졌었다. 순전파 때 수행하는 행렬의 곱은 기하학에서 Affine Transformation이라고 하기 때문에 이 연산이 이뤄지는 layer를 Affine layer라고 칭할 수 있다.

 

* 계산 그래프로 표현해보면 위와 같다. X와 ${\partial L \over \partial X}$의 shape은 같다. 다음 식을 보면 확실히 이해할 수 있을 것이다. 마찬가지로 W와 ${\partial L \over \partial W}$ 또한 같다.

 

$X = (x_0, x_1, ... , x_n)$

${\partial L \over \partial X} = ({\partial L \over \partial x_0}, {\partial L \over \partial x_1}, ... , {\partial L \over \partial x_n})$

 

 

 

 

배치용 Affine layer

 

* 배치용 Affine layer는 Affine layer에서 크게 달라진 점은 없고, 배치 차원이 추가되었다는 점만 다르다.

* python code로 배치용 Affine layer를 구현해보겠다.

class Affine:
	def __init __(self, W, b):
		self.W = W 
		self.b = b 
		self.x = 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, axis = 0)
		return dx

 

 

 

Softmax-with-Loss layer

* softmax는 Affine layer를 통과한 값을 0~1 사이의 값으로 정규화시켜준다.

* softmax layer는 학습에서만 사용되고 추론시에는 Affine 계층의 출력값인 score를 그대로 이용한다.

 

* softmax layer를 cross entropy loss와 함께 구현한 계층의 구조는 위와 같다.

* 위 그래프는 아래와 같이 간소화할 수 있다.

 

 

* 한 가지 주목할 점은 역전파시 softmax층에서 앞 층으로 전달되는 값이 $y_n - t_n$라는 점이다. 이는 단순히 예측과 정답 레이블의 차이를 뜻하는데, 이상하리만큼 깔끔하게 떨어지고 있다. 그 이유는 cross entropy가 softmax의 loss를 위해 고안된 함수이기 때문이다. 역전파시 $y_t - t_t$가 전달되는 것은 우연이 아니다.

 

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

* cross entropy loss를 적용한 softmax 층은 위와 같이 구현할 수 있다.

 

 

 

Backpropagation 구현하기

import sys
import os
import numpy as np
from collections import OrderedDict
sys.path.append(os.pardir)
from common.layers import *
from common.gradient import numerical_gradient
from dataset.mnist import load_mnist


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

    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):
        # 순전파
        self.loss(x, t)

        # 역전파
        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'] = self.layers['Affine1'].dW
        grads['b1'] = self.layers['Affine1'].db
        grads['W2'] = self.layers['Affine2'].dW
        grads['b2'] = self.layers['Affine2'].db

        return grads

 

 

 

 

 

* 오차 역전파법이 복잡한 구조를 띠기 때문에 역전파시 전달되는 기울기를 검증하기 위해 수치미분ㅇ르 사용해보자.

   
import sys
import os
import numpy as np
sys.path.append(os.pardir)
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 = []

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

for i in range(iters_num):
    # print(i)
    # 미니배치 획득
    batch_mask = np.random.choice(train_size, batch_size)
    x_batch = x_train[batch_mask]
    t_batch = t_train[batch_mask]

    # 오차역전파법으로 기울기 계산
    grad = network.gradient(x_batch, t_batch)

    # 매개변수 갱신
    for key in ('W1', 'b1', 'W2', 'b2'):
        network.params[key] -= learning_rate * grad[key]

'Deep Learning > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글

합성곱 신경망(CNN)  (0) 2022.01.30
Optimizer  (0) 2022.01.27
경사하강법(Gradient Descent method)  (0) 2022.01.25
수치 미분  (0) 2022.01.25
손실 함수  (0) 2022.01.25