[밑바닥부터 시작하는 딥러닝] 5. 오차역전파법 part2 - 계층 구현

Yejin Kim·2022년 3월 10일
0

🌿 계층 (Layer)

계층이란 신경망의 기능 단위를 의미한다
이번 포스팅에서는 신경망을 구성하는 '계층'을 각각의 하나의 클래스로 구현해보고자 한다 !

ex ) 시그모이드 함수를 위한 Sigmoid, 행렬 곱을 위한 Affine등의 기능을 계층 단위로 구현


🔨 단순한 계층의 구현

모든 계층은 forward()와 backward()라는 공통의 메서드(인터페이스)를 갖도록 구현

forward() : 순전파
backward() : 역전파

곱셈 계층

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

        return out

    def backward(self, dout):
        dx = dout * self.y  # x와 y를 바꾼다.
        dy = dout * self.x

        return dx, dy

x와 y, 두 변수는 순전파 시의 입력 값을 유지하기 위해 사용

순전파는 x와 y를 인수로 받고 두 값을 곱해 반환한다.
역전파는 상류에서 넘어온 미분(dout)에 순전파 때의 값을 '서로 바꿔' 곱한 후 하류로 흘린다.

MulLayer를 사용해 아래의 '사과 쇼핑'을 구현하면 다음과 같다.

from layer_naive import *


apple = 100
apple_num = 2 # 사과를 2개 구입하는 로직 구현
tax = 1.1

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

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

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

print("price:", int(price)) # 220
print("dApple:", dapple) # 2.2
print("dApple_num:", int(dapple_num)) # 110
print("dTax:", dtax) # 200
  • backward()의 호출 순서는 forward()와 반대
  • backward()가 받는 인수는 '순전파 출력에 대한 미분'임에 주의

덧셈 계층

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

덧셈 계층의 경우 순전파 시의 입력 값을 유지할 필요가 없으므로 별도의 인스턴스 변수 초기화를 진행하지 않는다. (pass가 '아무것도 하지 말라'는 명령)

AddLayer를 사용해 '사과 2개와 귤 3개를 사는 상황'을 구현하면 다음과 같다.

from layer_naive import *

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와 Sigmoid 계층을 구현

ReLU 계층


ReLU의 수식에서 x에 대한 y의 미분을 구하면 다음과 같다.

순전파의 입력인 x가 0보다 크면 역전파는 상류의 값을 그대로 하류로 흘리고, x가 0 이하이면 역전파 때는 하류로 신호를 보내지 않는다(0을 보냄).

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, 그 외는 False로 유지

역전파 때는 순전파 때 만들어둔 mask를 활용하여
mask의 원소가 True인 곳에는 상류에서 전파된 dout을 0으로 설정

Sigmoid 계층


sigmoid의 식을 계산 그래프로 나타내면 다음과 같다.

이 그래프의 역전파 흐름을 한단계식 따라가보자 !

1단계

'/' 노드(y = 1/x)를 미분하면 다음과 같다.

따라서 역전파는 다음과 같이 구할 수 있다.

2단계

'+' 노드는 상류의 값을 여과 없이 하류로 보내므로 다음과 같다.

3단계

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

이 예시에서는 합성함수의 미분을 고려하여 다음과 같이 역전파를 계산할 수 있다.

4단계

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

Sigmoid 계층의 역전파를 계산 그래프로 위와 같이 완성할 수 있다.
역전파의 가장 하류의 값을 순전파의 입력 x와 출력 y로 계산할 수 있음을 확인할 수 있는데, 이를 통해 계산 그래프의 중간 과정을 모두 묶어 단순한 'sigmoid' 노드 하나로 대체할 수 있다.

또한 y가 아래와 같은 값을 가짐을 활용하여 역전파를 더 단순화할 수 있다.

따라서 Sigmoid 계층의 역전파는 순전파의 출력(y)만으로 계산이 가능하다.

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 계층

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

Affine 계층의 계산(행렬의 곱과 편항의 합) 그래프는 다음과 같다.

이 계산 그래프는 노드 사이에 '스칼라 값'이 아닌 '행렬'이 흐르고 있다.

위의 계산 그래프에서의 역전파를 생각해보자.
행렬을 사용한 역전파도 행렬의 원소마다 전개하면 스칼라 값을 사용한 것과 같은 순서로 생각할 수 있다.

이를 실제로 전개하면 다음과 같은 식과 이에 따른 계산 그래프를 얻을 수 있다.

배치용 Affine 계층

이전까지 설명한 Affine 계층은 입력 데이터로 X 하나만을 고려한 것
데이터를 N개 묶어 순전파 하는 경우에 대한 배치(Batch)용 Affine을 생각해보자 !

기존과 다른 부분은 X의 형상이 (N, 2)가 된 것뿐이다.

순전파에서 편향 덧셈은 X·W에 대한 편향이 각 데이터에 더해진다.
ex ) N = 2인 경우(데이터가 2개), 편향은 그 두 데이터에 각각 더해짐

따라서 역전파에서는 각 데이터의 역전파 값이 편향의 원소에 모여야 한다.

예시 코드는 다음과 같다.

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

편향의 역전파는 N = 2일 때, 두 데이터에 대한 미분을 데이터마다 더해서 구한다.
axis=0은 0번째 축, 즉 데이터를 단위로 한 축에 대해서 총합을 구할 수 있도록 한다.

Affine 최종 구현은 다음과 같다.

class Affine:
    def __init__(self, W, b):
        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.original_x_shape = x.shape
        x = x.reshape(x.shape[0], -1)
        self.x = x

        out = np.dot(self.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)
        
        dx = dx.reshape(*self.original_x_shape)  # 입력 데이터 모양 변경(텐서 대응)
        return dx

위의 코드는 입력 데이터가 텐서(4차원 데이터)인 경우도 고려한 코드임 !

Softmax-with-Loss 계층

소프트맥스 함수는 입력 값을 정규화하여 출력한다.

예를 들어 손글씨 숫자 인식에서의 Softmax 계층의 출력은 다음과 같다.

입력 이미지가 Affine 계층과 ReLU 계층을 통과하면서 여러 차례 변환되고, 마지막으로 Softmax 계층에 의해 10개의 입력이 정규화된다.

이 그림에서는 숫자 '0'의 점수가 5.3이며 이것이 Softmax에 의해 0.008(0.8%)로 변환되고, 숫자 '2'의 점수가 10.1에서 0.991(99.1%)로 변환된다.

위의 예시에서는 손글씨 숫자의 가짓수가 10개(10 클래스로의 분류)이므로 Softmax 계층의 입력이 10개가 된다.

신경망에서 수행하는 작업 → 학습, 추론
Softmax 앞의 Affine 계층의 출력을 점수(score) 라고 하는데, 신경망 추론 에서는 가장 높은 점수만 알면 되기 때문에 정규화 과정인 Softmax 계층은 필요하지 않음.
반면 신경망 학습 에서는 오차율이 다음 학습에 영향을 주기 때문에 정규화 과정이 필요하고 이 때문에 Softmax 계층이 필요하다.


softmax함수에 교차 엔트로피 오차도 포함 하여 계산을 진행한다.

계산해보면 역전파로 (y1-t1, y2-t2, y3-t3), 즉 출력과 정답 레이블의 차로 결과값을 가짐
→ 역전파는 효율적으로 오차를 앞 계층으로 전달

Softmax-with-Loss 계층의 코드 구현은 다음과 같다.

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 = cross_entropy_error(self.y, self.t)
        
        return self.loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]
        if self.t.size == self.y.size: # 정답 레이블이 원-핫 인코딩 형태일 때
            dx = (self.y - self.t) / batch_size
        else:
            dx = self.y.copy()
            dx[np.arange(batch_size), self.t] -= 1
            dx = dx / batch_size
        
        return dx

역전파에서 전파하는 값을 배치의 수로 나눠 데이터 1개당 오차를 앞 계층으로 전파할 수 있도록 함 !


🛠 오차역전파법 구현

앞에서 구현한 계층들의 조합으로 신경망을 구축해보자

신경망 학습의 전체적인 그림

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

  • 1단계 - 미니배치

    훈련 데이터 중 일부를 무작위로 가져옴
    선별한 데이터를 미니배치라 하고, 그 미니배치의 손실 함수를 줄이는 것이 목표

  • 2단계 - 기울기 산출

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

  • 3단계 - 매개변수 갱신

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

  • 4단계 - 반복

    1~3단계를 반복

오차역전파법이 등장하는 단계는 두 번째인 기울기 산출
수치 미분은 구현하기 쉽지만 계산하는데 오래 걸리는게 단점
오차역전파법을 이용하여 기울기를 효율적이고 빠르게 계산

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

2층 신경망을 TwoLayerNet 클래스로 구현
이 클래스의 인스턴스 변수와 메서드는 다음과 같다.

코드 구현은 다음과 같다.

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에 보관한고 있다는 점이다.
순서가 있는 딕셔너리로 순전파 때는 추가한 순서대로 각 계층의 forward() 메서드를 호출하고, 역전파 때에는 반대 순서로 backward() 메서드를 호출한다.

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

매개변수가 많은 경우에 오차역전파법을 이용해야 효율적으로 계산이 가능하므로, 이제부터 느린 수치 미분 대신 오차역전파법을 사용한다 !

하지만 수치 미분은 느린 대신 구현하기가 쉽다는 이점이 있다.
따라서 구현이 까다로운 오차역전파법에서 생길 수 있는 실수를 방지하기 위해 수치 미분과의 기울기 오차를 확인·검증한다.

profile
The World Is My Oyster 🌏

0개의 댓글