[밑딥] 5장. 오차역전파법 (backpropagation)

Speedwell🍀·2022년 5월 22일
0


앞 장에서는 신경망 학습에 대해 배웠다. 그때 신경망의 가중치 매개변수에 대한 손실 함수의 기울기는 수치 미분을 사용해 구했다.
수치 미분은 단순하고 구현하기 쉽지만, 계산 시간이 오래 걸린다는 단점!
➡ 이번 장에서는 가중치 매개변수의 기울기를 효율적으로 계산하는 오차역전파법(backpropagation)을 배운다.


오차역전파법을 제대로 이해하는 두 가지 방법은 '수식'과 '계산 그래프'이다.
이번 장에서는 계산 그래프를 통해 '시각적'으로 이해해보자!

1. 계산 그래프 (Computational Graph)

: 계산 과정을 그래프로 나타낸 것
여기서 그래프는 그래프 자료구조 (복수의 node와 edge로 표현)

계산 과정을 노드화살표로 표현

  • 노드 안에 연산 내용
  • 화살표 위에 계산 결과
    ➡ 계산 결과가 왼쪽에서 오른쪽으로 전해지게 함

    계산이 왼쪽에서 오른쪽으로 진행하는 것을 순전파(forward propagation)라고 한다.
    즉, 계산 그래프의 출발점부터 종착점으로의 전파

    반대로 오른쪽에서 왼쪽으로 진행하는 것을 역전파(backward propagation)라고 한다.
    📌역전파는 이후에 미분을 계산할 때 중요한 역할!


1-1) 국소적 계산

계산 그래프의 특징은 국소적 계산을 전파함으로써 최종 결과를 얻는다는 것!

국소적 계산: 자신과 직접 관계된 작은 범위
➡️ 결국 전체에서 어떤 일이 벌어지든 자신과 관계된 정보만으로 결과를 출력할 수 있다는 것

각 노드에서의 계산은 국소적 계산!
각 노드는 자신과 관련한 계산 외에는 신경쓰지 않는다.

📌 전체 계산이 아무리 복잡하더라도 각 단계에서 하는 일은 해당 노드의 '국소적 계산'
➡️ 국소적인 계산은 단순하지만, 그 결과를 전달함으로써 전체를 구성하는 복잡한 계산 가능!


1-2) 왜 계산 그래프로 푸는가?

계산 그래프의 이점

  1. 국소적 계산

    • 전체가 아무리 복잡해도 각 노드에서는 단순한 계산에 집중하여 문제를 단순화 가능
  2. 중간 계산 결과를 모두 보관 가능

⭐3. 역전파를 통해 미분을 효율적으로 계산 가능


예를 들어 사과 가격이 오르면 최종 금액에 어떤 영향을 끼치는지 알고 싶다면, '사과 가격에 대한 지불 금액의 미분'을 구하면 된다.
➡️ 계산 그래프에서 역전파를 하면 구할 수 있다!


그림과 같이 역전파는 순전파와 반대 방향의 화살표로 그린다.
➡️ 이 전파는 '국소적 미분'을 전달하고, 그 미분 값은 화살표의 아래에 적는다.

여기에서 '사과 가격에 대한 지불 금액의 미분' 값은 2.2이다.
그 외에도 '소비세에 대한 지불 금액의 미분'이나 '사과 개수에 대한 지불 금액의 미분'도 같은 순서로 구할 수 있다.
➡️ 그때는 중간까지 구한 미분 결과를 공유할 수 있어서 다수의 미분을 효율적으로 계산할 수 있다!

📌 정리하자면, 계산 그래프의 이점은 순전파와 역전파를 활용해서 각 변수의 미분을 효율적으로 구할 수 있다는 것!



2. 연쇄법칙 (chain rule)

앞서 배운 역전파는 '국소적인 미분'을 순방향과는 반대인 오른쪽→왼쪽으로 전달한다.
➡️ 이 '국소적 미분'을 전달하는 원리가 연쇄법칙


앞으로 연쇄법칙을 배우고, 연쇄법칙이 계산 그래프 상의 역전파와 같다는 걸 알아보자!


2-1) 계산 그래프의 역전파

역전파의 계산 절차

: 상류에서 전달된 값(신호 E)에 노드의 국소적 미분(∂y/∂x)을 곱한 후 다음 앞쪽 노드로 전달

여기서 말하는 국소적 미분은 순전파 때의 y=f(x) 계산의 미분; x에 대한 y의 미분(∂y/∂x)을 구한다는 것


이러한 역전파의 계산 절차를 따르면 목표로 하는 미분 값을 효율적으로 구할 수 있다는 것이 이 전파의 핵심!

🤔 왜 이런 일이 가능할까?
연쇄법칙!


2-2) 연쇄법칙이란?

연쇄법칙은 합성 함수의 미분에 대한 성질

연쇄법칙의 원리
: 합성 함수의 미분은 합성 함수를 구성하는 각 함수의 미분의 곱으로 나타낼 수 있다

합성 함수: 여러 함수로 구성된 함수


2-3) 연쇄법칙과 계산 그래프

복습)

  • 계산 그래프의 역전파는 오른쪽에서 왼쪽
  • 노드로 들어온 입력 신호에 그 노드의 국소적 미분(편미분)을 곱한 후 다음 노드로 전달

위의 그림에서 역전파를 봐보자.

입력은 ∂z/∂z이며, 이에 국소적 미분인 ∂z/∂t(순전파 시에는 입력이 t이고 출력이 z이므로 이 노드에서 국소적 미분은 ∂z/∂t)를 곱하고 다음 노드로 넘긴다.

맨 왼쪽 역전파는 연쇄법칙에 따르면 (∂z/∂z)(∂z/∂t)(∂t/∂x) = (∂z/∂t)(∂t/∂x) = (∂z/∂x)가 성립되어 'x에 대한 z의 미분'이 된다.

즉, 역전파가 하는 일은 연쇄법칙의 원리와 같다.


위의 그림에 값을 넣어보자.

z = t²
t = x + y



3. 역전파

3-1) 덧셈 노드의 역전파

z = x + y을 대상으로 역전파를 살펴보자.

∂z/∂x = 1, ∂z/∂y = 1
➡️ 상류에서 전해진 미분에 1을 곱하여 하류로 흘린다.

덧셈 노드의 역전파는 1을 곱하기만 할 뿐이므로 입력된 값을 그대로 다음 노드로 보내게 된다.

(구체적인 예)


3-2) 곱셈 노드의 역전파

z = xy을 대상으로 역전파를 살펴보자.

∂z/∂x = y, ∂z/∂y = x

곱셈 노드의 역전파는 상류의 값에 순전파 때의 입력 신호들을 서로 바꾼 값을 곱해서 하류로 보낸다.

(구체적인 예)


덧셈의 역전파에서는 상류의 값을 그대로 흘려보내서 순방향 입력 신호의 값은 필요하지 않았지만,
곱셈의 역전파는 순방향 입력 신호의 값이 필요하다.
➡️ 곱셈 노드를 구현할 때는 순전파의 입력 신호를 변수에 저장해둔다.


3-3) 사과 쇼핑의 예

1-2)에서 봤던 사과 쇼핑의 예를 다시 살펴보자.

이 문제에서는 사과의 가격, 사과의 개수, 소비세라는 세 변수 각각이 최종 금액에 어떻게 영향을 주느냐를 풀고자 한다.
➡️ 사과 가격에 대한 지불 금액의 미분, 사과 개수에 대한 지불 금액의 미분, 소비세에 대한 지불 금액의 미분을 구하는 것에 해당!

이를 계산 그래프의 역전파를 사용해 풀어보자!

📌 곱셈 노드의 역전파에서는 입력 신호를 서로 바꿔서 하류로 흘린다는 것을 기억하자!

그림의 결과를 보면 소비세와 사과 가격이 같은 양만큼 오르면 최종 금액에는 소비세가 200의 크기로, 사과 가격이 2.2 크기로 영향을 준다고 할 수 있다. (단, 단위는 다름. 소비세 1은 100%, 사과 가격 1은 1원)



4. 단순한 계층 구현하기

앞서 본 사과 쇼핑의 예를 파이썬으로 구현해보자!
계산 그래프의 곱셈 노드를 MulLayer, 덧셈 노드를 AddLayer라는 이름으로 구현해보자.

다음 절에서는 신경망을 구성하는 계층 각각을 하나의 클래스로 구현한다.
여기서 말하는 계층이란 신경망의 기능 단위
예) 시그모이드 함수를 위한 Sigmoid, 행렬 곱을 위한 Affine 등의 기능을 계층 단위로 구현

이번 절에서도 곱셈 노드와 덧셈 노드를 '계층' 단위로 구현해보자!


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

4-1) 곱셈 계층

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

__init__()
: 인스턴스 변수인 x와 y를 초기화 ➡️ 이 두 변수는 순전파 시의 입력 값을 유지하기 위해서 사용한다.

forward()
: x와 y를 인수로 받고 두 값을 곱해서 반환한다.

backward()
: 상류에서 넘어온 미분(dout)에 순전파 때의 값을 '서로 바꿔' 곱한 후 하류로 흘린다.


MulLayer를 이용해 위의 그림의 순전파를 구현해보자.
또한 각 변수에 대한 미분을 backward()를 통해 구현해보자.


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

# 역전파
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() 호출 순서는 forward() 때와는 반대
  • 📌 backward()가 받는 인수는 '순전파의 출력에 대한 미분'
    • mul_apple_layer 곱셈 계층

      • 순전파 때는 apple_price를 출력
      • 역전파 때는 apple_price의 미분 값인 dapple_price를 인수로 받음

4-2) 덧셈 계층

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

__init__()
: 덧셈 계층에서는 초기화가 필요 없으니 아무 일도 하지 않는다.

forward()
: 입력받은 두 인수 x, y를 더해서 반환한다.

backward()
: 상류에서 내려온 미분(dout)을 그대로 하류로 흘린다.


덧셈 계층과 곱셈 계층을 사용하여 사과 2개와 귤 3개를 사는 상황을 구현해보자!


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

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

# 순전파
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_prcie, orange_price) # (3)
price = mul_tax_layer.forward(all_price, tax) # (4)

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

print(price) # 715
print(dapple_num, dapple, dorange, dorange_num, dtax) # 110 2.2 3.3 165 650


5. 활성화 함수 계층 구현하기

이제 계산 그래프를 신경망에 적용해보자!
여기에서는 신경망을 구성하는 계층 각각을 클래스 하나로 구현한다.

우런 활성화 함수인 ReLUSigmoid 계층을 구현해보자!


5-1) ReLU 계층

다음은 활성화 함수로 사용되는 ReLU의 수식이다.

위의 식에서 x에 대한 y의 미분은 아래와 같다.

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


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


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로 유지

>>> 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으로 설정한다!


5-2) Sigmoid 계층

Sigmoid 계층의 계산 그래프를 그리면 아래와 같다.

이제 역전파를 알아보자!


1. / 노드

먼저 / 노드, 즉 y = 1/x를 미분하면 아래와 같다.

역전파 때는 상류에서 흘러온 값에 -y²(순전파의 출력을 제곱한 후 마이너스를 붙인 값)을 곱해서 하류로 전달한다.

계산 그래프는 아래와 같다.



2. + 노드

+ 노드는 상류의 값을 그대로 하류로 보낸다. 계산 그래프는 아래와 같다.



3. exp 노드

exp 노드는 y = exp(x) 연산을 수행하며 그 미분은 아래와 같다.

계산 그래프에서는 상류의 값에 순전파 때의 출력(이 예에서는 exp(-x))을 곱해 하류로 전파한다.



4. x 노드

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



위의 그림에서 볼 수 있듯이 역전파의 최종 출력은 순전파의 입력 x와 출력 y만으로 계산할 수 있다.
➡️ 계산 그래프의 중간 과정을 모두 묶어 아래처럼 단순한 'sigmoid' 노드 하나로 대체할 수 있다.

이렇게 간소화하면 역전파 과정의 중간 계산들을 생략할 수 있어 더 효율적인 계산

또한, 노드를 그룹화하여 Sigmoid 계층의 세세한 내용을 노출하지 않고 입력과 출력에만 집중할 수 있다는 것도 중요한 포인트!



한편, Sigmoid 계층의 역전파의 최종 출력은 아래와 같이 정리해서 쓸 수 있다.

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



Sigmoid 계층을 파이썬으로 구현해보자!

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


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

	def forward(self, x):
    	out = 1 / (1 + np.exp(-x))
        self.out = out

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


6. Affine / Softmax 계층 구현하기

6-1) Affine 계층

신경망의 순전파에서는 가중치 신호의 총합을 계산하기 때문에 행렬의 곱(넘파이에서는 np.dot())을 사용했었다. ("3장 3. 다차원 배열의 계산" 참고)


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

뉴런의 가중치 합은 Y = np.dot(X, W) + B로 계산한다.
이 Y를 활성화 함수로 변환해 다음 층으로 전파하는 것이 신경망 순전파의 흐름

신경망의 순전파 때 수행하는 행렬의 곱은 기하학에서 어파인 변환 (affine transformation)이라고 한다.


앞에서 수행한 (행렬의 곱과 편향의 합)을 계산 그래프로 그려보자!
곱을 계산하는 노드를 'dot'
각 변수의 이름 위에 그 변수의 형상도 표기

💥 X, W, B가 행렬(다차원 배열)이라는 점에 주의!
지금까지의 계산 그래프는 노드 사이에 스칼라값이 흘렀는데 반해, 이 예에서는 행렬이 흐르고 있다.


이제 역전파에 대해 살펴보자!

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

📌 X와 ∂L/∂X은 같은 형상이고, W와 ∂L/∂W도 같은 형상이라는 것을 기억하자!


📌 행렬 곱('dot' 노드)의 역전파는 행렬의 대응하는 차원의 원소 수가 일치하도록 곱을 조립하여 구할 수 있다!

이를 바탕으로 Affin 계층의 역전파를 계산 그래프로 살펴보면 아래와 같다.


6-2) 배치용 Affine 계층

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

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


💥 편향을 더할 때 주의해야 한다.
순전파 때의 편향 덧셈은 X · W에 대한 편향이 각 데이터에 더해진다.

>>> X_dot_W = np.array([[0, 0, 0], [10, 10, 10]])
>>> B = np.array([1, 2, 3])
>>>
>>> X_dot_W
array([[ 0, 0, 0],
	  [ 10, 10, 10]])
>>> X_dot_W + B
array([[ 1,  2,  3],
	   [11, 12, 13]])

순전파의 편향 덧셈은 각각의 데이터(1번째 데이터, 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])

데이터가 2개(N=2)라고 가정했을 때, 편향의 역전파는 그 두 데이터에 대한 미분을 데이터마다 더해서 구한다.
➡️ np.sum()에서 0번째 축(데이터를 단위로 한 축)에 대해서 총합을 구하는 것

axis=0은 x축 기준 → row 합침
axis=1은 y축 기준 → column 합침
axis=2은 z축 기준 → depth 합침


이제 Affine을 구현해보자!

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

6-3) Softmax-with-Loss 계층

마지막으로 출력층에서 사용하는 소프트맥스 함수에 대해 알아보자.

소프트맥스 함수
: 입력 값을 정규화(출력이 1이 되도록 변형)하여 출력


예) 손글씨 숫자 인식에서의 Softmax 계층의 출력

입력 이미지가 Affine 계층과 ReLU 계층을 통과하며 변환되고, 마지막 Softmax 계층에 의해서 10개의 입력이 정규화된다.
(손글씨 숫자는 10개의 클래스에 대해 분류하므로 Softmax 계층의 입력은 10개가 된다.)


신경망에서 수행하는 작업은 학습추론

  • 추론할 때는 일반적으로 Softmax 계층을 사용하지 않는다.
    • (위의 그림에서도 볼 수 있듯이) 신경망은 추론할 때는 마지막 Affine 계층의 출력을 인식 결과로 이용한다.
  • 점수: 신경망에서 정규화되지 않는 출력 결과 (위의 그림에서 Softmax 앞의 Affine 계층의 출력)
    ➡️ 신경망 추론에서 답을 하나만 내는 경우에는 가장 높은 점수만 알면 되니 Softmax 계층은 필요 없는 것!
  • 반면 신경망을 학습할 때는 Softmax 계층이 필요하다.

이제 소프트맥스 계층을 구현해보자!
손실 함수인 교차 엔트로피 오차도 포함한다는 의미에서 Softmax-with-Loss 계층이라는 이름으로 구현해보자.

Softmax-with-Loss 계층의 계산 그래프는 아래와 같다. Softmax-with-Loss 계층은 복잡하므로 여기서는 결과만 제시한다.
(도출 과정은 부록 A. Softmax-with-Loss 계층의 계산 그래프 참고하기)

위의 계산 그래프를 아래와 같이 간소화할 수 있다.

3클래스 분류를 가정하고 이전 계층에서 3개의 입력(점수)를 받는다.

  1. Softmax 계층은 입력 (a₁, a₂, a₃)를 정규화하여 (y₁, y₂, y₃)를 출력한다.
  2. Cross Entropy Error 계층은 Softmax의 출력 (y₁, y₂, y₃)와 정답 레이블 (t₁, t₂, t₃)를 받고, 이 데이터들로부터 손실 L을 출력한다.

📌 여기서 주목할 것은 역전파의 결과

Softmax 계층의 역전파는 (y₁ - t₁, y₂ - t₂, y₃ - t₃) = (Softmax 계층의 출력과 정답 레이블의 차분)
➡️ 신경망의 역전파에서는 이 차이인 오차가 앞 계층에 전해지는 것!
❗이는 신경망 학습의 중요한 성질

신경망 학습의 목적 : 신경망의 출력(Softmax의 출력)이 정답 레이블과 가까워지도록 가중치 매개변수의 값을 조정하는 것
➡️ 신경망의 출력과 정답 레이블의 오차를 효율적으로 앞 계층에 전달해야 한다.

Softmax 계층의 역전파 결과는 Softmax 계층의 출력과 정답 레이블의 차이
➡️ 신경망의 현재 출력과 정답 레이블의 오차

예1) 정답 레이블이 (0,1,0)일 때 Softmax 계층이 (0.3, 0.2, 0.5) 출력

: 정답 레이블을 보면 정답 인덱스는 1이지만, 출력에서는 이때의 확률이 겨우 0.2 → 이 시점의 신경망은 제대로 인식하지 못하고 있음!
이 경우 Softmax 계층의 역전파는 (0.3, -0.8, 0.5)라는 커다란 오차를 전파 → Softmax 계층의 앞 계층들은 큰 오차로부터 큰 깨달음을 얻음!

예2) 정답 레이블이 (0,1,0)일 때 Softmax 계층이 (0.01, 0.99, 0) 출력

: 이 신경망은 꽤 정확히 인식하고 있다.
이 경우 Softmax 계층의 역전파가 보내는 오차는 비교적 작은 (0.01, -0.01, 0)
→ 앞 계층으로 전달된 오차가 작으므로 학습하는 정도도 작아진다.


참고)

  • Softmax 함수의 손실 함수로 교차 엔트로피 오차를 사용하니 역전파가 (y₁ - t₁, y₂ - t₂, y₃ - t₃)로 말끔한 결과
    - 이는 교차 엔트로피 오차라는 함수가 그렇게 설계되었기 때문!
  • 회귀의 출력층에서 사용하는 항등 함수의 손실 함수로 오차제곱합을 이용하는 이유도 이와 같다.
    ➡️ 즉, '항등 함수'의 손실 함수로 '오차제곱합'을 사용하면 역전파의 결과가 (y₁ - t₁, y₂ - t₂, y₃ - t₃)로 말끔히 떨어진다.

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]
        dx = (self.y - self.t) / batch_size
        return dx

📌 역전파 때 전파하는 값을 배치의 수(batch_size)로 나눠서 데이터 1개당 오차를 앞 계층으로 전파하는 점 주의!



7. 오차역전파법 구현하기


앞에서 구현한 계층을 조합해서 신경망을 구축하면 된다!


7-1) 신경망 학습의 전체 그림

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

신경망 학습은 다음과 같이 4단계로 수행한다.

  1. 미니배치

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

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

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

    • 1~3단계를 반복한다.

지금까지 설명한 오차역전파법이 등장하는 단계는 두 번째인 기울기 산출

오차역전파법을 이용하면 느린 수치 미분과 달리 기울기를 효율적이고 빠르게!
(앞 장에서는 기울기를 구하기 위해 수치 미분 사용 - 구현 쉽지만 계산 오래 걸림)


7-2) 오차역전파법을 적용한 신경망 구현하기

2층 신경망을 TwoLayerNet 클래스로 구현해보자.
우선 이 클래스의 인스턴스 변수와 메서드를 정리해보자!


인스턴스 변수설명
params딕셔너리 변수로, 신경망의 매개변수를 보관
params['W1']은 1번째 층의 가중치, params['b1']은 1번째 층의 편향
params['W2']은 2번째 층의 가중치, params['b2']은 2번째 층의 편향
layers순서가 있는 딕셔너리 변수로, 신경망의 계층을 보관
layers['Affine1'], layers['Relu1'], layers['Affine2']와 같이 각 계층을 순서대로 유지
lastLayer신경망의 마지막 계층
이 예에서는 SoftmaxWithLoss 계층

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

4장 5. 학습 알고리즘 구현하기와 공통되는 부분이 많다.
앞 장과 크게 다른 부분은 계층을 사용한다는 점!
➡️ 계층을 사용함으로써 인식 결과를 얻는 처리(predict())와 기울기를 구하는 처리(gradient()) 계층의 전파만으로 정작이 이루어지는 것!


import sys, os
sys.path.append(os.pardir)
import numpy as np
import 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):
        # 순전파
        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

📌 신경망의 계층을 OrderedDict에 보관하는 점이 중요!
OrderedDict은 순서가 있는 딕셔너리; 딕셔너리에 추가한 순서를 기억

  • 순전파 때는 추가한 순서대로 각 계층의 forward() 메서드를 호출
  • 역전파 때는 계층을 반대 순서로 호출

Affine 계층과 ReLU 계층이 각자의 내부에서 순전파와 역전파를 제대로 처리하고 있으니, 계층을 올바른 순서로 연결한 다음 순서대로 (혹은 역순으로) 호출해주면 끝!

'계층'을 모듈화해서 구현하는 효과는 아주 크다.
➡️ 깊은 신경망을 만들고 싶다면, 단순히 필요한 만큼 계층을 더 추가하면 된다! (레고처럼)


7-3) 오차역전파법으로 구한 기울기 검증하기

각 계층 내부에 구현된 순전파와 역전파를 활용해 인식 처리와 학습에 필요한 기울기를 정확하게 구해보자!


기울기를 구하는 방법

  1. 수치 미분을 써서 구하는 방법
  2. 해석적으로 수식을 풀어 구하는 방법 ➡️ 오차역전파법을 이용하여 매개변수가 많아도 효율적으로 계산

수치 미분은 느리기 때문에, 수치 미분 대신 오차역전파법을 사용했다.
하지만 수치 미분은 오차역전파법을 정확히 구현했는지 확인하기 위해 필요!


기울기 확인 (gradient check)

: 두 방식으로 구한 기울기가 일치함을 확인하는 작업

수치 미분의 이점은 구현하기 쉽다는 점!
반면에 오차역전파법은 구현하기 복잡해서 실수하기 쉽다.
➡️ 수치 미분의 결과와 오차역전파법의 결과를 비교하여 오차역전파법을 제대로 구현했는지 검증


기울기 확인 구현

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

# b1:9.70418809871e-13
# W2:8041139039497e-13
# b2:101945999745e-10
# W1:2.2232446644e-13

코드의 실행 결과는 수치 미분과 오차역전파법으로 구한 기울기의 차이가 매우 작다는 것을 보여준다.
이로써 오차역전파법으로 구한 기울기가 올바르다는 것을 알 수 있고, 구현에 실수가 없다는 가능성이 커진다.

수치 미분과 오차역전파법의 결과 오차가 0이 되는 일은 드물다.
이는 컴퓨터가 할 수 있는 계산의 정밀도가 유한하기 때문.(ex. 32비트 부동소수점)
이 정밀도의 한계 때문에 오차는 대부분 0이 되지는 않지만, 올바르게 구현했다면 0에 아주 가까운 작은 값이 된다.


7-4) 오차역전파법을 사용한 학습 구현하기

마지막으로 오차역전파법을 사용한 신경망 학습을 구현해보자!
지금까지와 다른 부분은 기울기를 오차역전파법으로 구한다는 점뿐!

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

8. 정리

계산 과정을 시각적으로 보여주는 방법인 계산 그래프를 배웠다. 계산 그래프를 이용하여 신경망의 동작과 오차역전파법을 설명하고, 그 처리 과정을 계층이라는 단위로 구현했다.

  • ex) ReLU 계층, Softmax-with-Loss 계층, Affine 계층, Softmax 계층 등
  • 모든 계층에서 forward와 backward 메서드를 구현했다.
    • forward: 데이터를 순방향으로 전파
    • backward: 데이터를 역방향으로 전파함으로써 가중치 매개변수의 기울기를 효율적으로 구함
  • 동작을 모듈화한 덕분에, 신경망의 계층을 자유롭게 조합하여 원하는 신경망을 쉽게 만들 수 있다.

  • 계산 그래프의 노드는 국소적 계산으로 구성된다. 국소적 계산을 조합해 전체 계산을 구성한다.
  • 계산 그래프의 순전파는 통상의 계산을 수행한다. 한편, 계산 그래프의 역전파로는 각 노드의 미분을 구할 수 있다.
  • 신경망의 구성 요소를 계층으로 구현하여 기울기를 효율적으로 계산할 수 있다. (오차역전파법)
  • 수치 미분과 오차역전파법의 결과를 비교하면 오차역전파법의 구현에 잘못이 없는지 확인할 수 있다. (기울기 확인)

0개의 댓글