05.7 오차역전파법 구현하기
앞 절에서 구현한 계층을 조합하면 마치 레고 블록을 조합하듯 신경망을 구축할 수 있습니다. 이번 절에서는 지금까지 구현한 계층을 조합해서 신경망을 구축해보겠습니다.
5.7.1 신경망 학습의 전체 그림
이야기가 조금 길어졌으니, 구체적인 구현에 들어가기 전에 신경망 학습의 전체 그림을 복습해 보겠습니다. 다음은 신경망 학습 순서입니다.*
전제 신경망에는 적응 가능한 가중치와 편향이 있고, 이 가중치와 편향을 훈련 데이터에 적응하도록 조정하는 과정을 ‘학습’이라 합니다. 신경망 학습은 다음과 같이 4단계로 수행합니다.
- 1단계 - 미니배치 훈련 데이터 중 일부를 무작위로 가져옵니다. 이렇게 선별한 데이터를 미니배치라 하며, 그 미니배치의 손실 함수 값을 줄이는 것을 목표로 한다.
- 2단계 - 기울기 산출 미니배치의 손실 함수 값을 줄이기 위해 각 가중치 매개변수의 기울기를 구합니다. 기울기는 손실 함수의 값을 가장 작게 하는 방향을 제시합니다.
- 3단계 - 매개변수 갱신 가중치 매개변수를 기울기 방향으로 아주 조금 갱신합니다.
- 4단계 - 반복 1~3단계를 반복합니다.
지금까지 설명한 오차역전파법이 등장하는 단계는 두 번째인 ‘기울기 산출’입니다. 앞 장에서는 이 기울기를 구하기 위해서 수치 미분을 사용했지요. 하지만 수치 미분은 구현하기는 쉽지만 계산이 오래 걸렸습니다. 오차역전파법을 이용하면 느린 수치 미분과 달리 기울기를 효율적이고 빠르게 구할 수 있습니다.
5.7.2 오차역전파법을 적용한 신경망 구현하기
드디어 본격적인 구현입니다. 여기에서는 2층 신경망을 TwoLayerNet 클래스로 구현합니다. 우선은 이 클래스의 인스턴스 변수와 메서드를 정리한 [표 5-1]과 [표 5-2]를 살펴봅시다.
표 5-1 TwoLayerNet 클래스의 인스턴스 변수
| 인스턴스 변수 | 설명 |
|---|---|
params |
딕셔너리 변수로, 신경망의 매개변수를 보관params['W1']은 1번째 층의 가중치, params['b1']은 1번째 층의 편향params['W2']는 2번째 층의 가중치, params['b2']는 2번째 층의 편향 |
layers |
순서가 있는 딕셔너리 변수로, 신경망의 계층을 보관layers['Affine1'], layers['Relu1'], layers['Affine2']와 같이 각 계층을 순서대로 유지 |
lastLayer |
신경망의 마지막 계층 이 예에서는 SoftmaxWithLoss 계층 |
표 5-2 TwoLayerNet 클래스의 메서드
| 메서드 | 설명 |
|---|---|
__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
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):
# 순전파
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 계층이 각자의 내부에서 순전파와 역전파를 제대로 처리하고 있으니, 여기에서는 그냥 계층을 올바른 순서로 연결한 다음 순서대로(혹은 역순으로) 호출해주면 끝입니다.
도로시가 순서가 있는 딕셔너리(OrderedDict) 조립 기차 칸 위에 Affine, ReLU, Affine 계층을 블록 조립하듯이 차례로 조립해 얹어놓고 있고, 지니와 토토가 그 순서가 보장되는 메커니즘을 설명하고 있어요.
도로시의 OrderedDict 열차 비유
- 도로시: “와! 신경망에 들어가는 다양한 레이어들을 순서가 고정된 열차 칸(
OrderedDict)에 차례로 태워두니까, 순전파 때는 기차 머리(입력)에서부터 꼬리(출력) 방향으로 순서대로 호출하면 되고, 역전파 때는 꼬리에서부터 머리 방향으로 기차를 후진해서 호출해주기만 하면 되는구나! 복잡한 연결 고리가 정말 일목요연해져!”- 지니: “맞아! 파이썬 일반 딕셔너리는 키의 추가 순서를 보장하지 않아서 연산이 엉망진창이 될 수 있지만,
OrderedDict을 사용하면 우리가 기차 칸을 조립한 순서가 칼같이 유지되므로 복잡한 신경망의 앞뒤 왕복 전파가 아주 단순한 루프문 하나로 안전하게 처리된단다!”
이처럼 신경망의 구성 요소를 ‘계층’으로 구현한 덕분에 신경망을 쉽게 구축할 수 있었습니다. ‘계층’으로 모듈화해서 구현한 효과는 아주 큽니다. 예컨대 5층, 10층, 20층, …과 같이 깊은 신경망을 만들고 싶다면, 단순히 필요한 만큼 계층을 더 추가하면 되니까요(마치 레고 블록을 조립하듯).
이어서 다음 절에서는 각 계층 내부에 구현된 순전파와 역전파를 활용해 인식 처리와 학습에 필요한 기울기를 정확하게 구해보겠습니다.
5.7.3 오차역전파법으로 구한 기울기 검증하기
지금까지 기울기를 구하는 방법을 두 가지 설명했습니다. 하나는 수치 미분을 써서 구하는 방법, 또 하나는 해석적으로 수식을 풀어 구하는 방법입니다. 후자의 해석적으로 구하는 방법은 오차역전파법을 이용하여 매개변수가 많아도 효율적으로 계산할 수 있었습니다. 그러니 이제부터는 느린 수치 미분 대신 오차역전파법을 사용하기로 합시다.
수치 미분은 느립니다. 그리고 오차역전파법을 제대로 구현해두면 수치 미분은 더 이상 필요 없습니다. 그렇다면 수치 미분은 정말 아무런 쓸모가 없을까요? 사실은 수치 미분은 오차역전파법을 정확히 구현했는지 확인하기 위해 필요합니다.
수치 미분의 이점은 구현하기 쉽다는 것이죠. 그래서 수치 미분의 구현에는 버그가 숨어 있기 어려운 반면, 오차역전파법은 구현하기 복잡해서 종종 실수를 하곤 합니다. 그래서 수치 미분의 결과와 오차역전파법의 결과를 비교하여 오차역전파법을 제대로 구현했는지 검증하곤 한답니다. 이처럼 두 방식으로 구한 기울기가 일치함(엄밀히 말하면 거의 같음)을 확인하는 작업을 기울기 확인${}^{\text{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)
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))
도로시가 수치 미분과 오차역전파법의 기울기 오차를 딕셔너리 루프로 돌며 평균 절댓값 차이(diff)를 꼼꼼하게 검출하는 스크립트를 작성하고 있고, 지니가 그 정밀성을 칭찬하고 있어요.
도로시의 루프 오차 검출
- 도로시: “지니! 루프를 돌면서 두 기울기 딕셔너리에서 같은 가중치 키(
key)를 꺼내서 차이의 평균인np.average(np.abs(grad_backprop[key] - grad_numerical[key]))를 연산했어. 이 값이 $10^{-6}$보다 한참 작은 극소 오차 범위(epsilon) 이내인지 프로그램이 직접 판별해 주니까 너무 안심돼!”- 지니: “맞아! 코딩 실수가 잦은 역전파 알고리즘을 이 짧은 기울기 확인 루프 스크립트 하나로 확실하게 검증해 낼 수 있어. 역전파 코딩의 소중한 보호막과도 같단다!”
- 토토: “왈왈! (프로그램 출력을 보며) 오차 검증 오케이 완료다 멍!”
언제나처럼 가장 먼저 MNIST 데이터셋을 읽습니다. 그리고 훈련 데이터 일부를 수치 미분으로 구한 기울기와 오차역전파법으로 구한 기울기의 오차를 확인합니다. 여기에서는 각 가중치 매개변수의 차이의 절댓값을 구하고, 그 평균을 낸 값이 오차가 됩니다. 이 코드의 실행 결과는 다음과 같습니다.
b1:9.70418809871e-13
W2:8.41139039497e-13
b2:1.1945999745e-10
W1:2.2232446644e-13
이 결과는 수치 미분과 오차역전파법으로 구한 기울기의 차이가 매우 작다고 말해줍니다. 가령 1번째 층의 편향 오차는 $9.7\mathrm{e}{-13}$ ($0.00000000000097$)입니다. 이로써 오차역전파법으로 구한 기울기도 올바름이 드러나면서 실수 없이 구현했다는 믿음이 커지는 것이죠.
도로시의 완벽한 검증 (기울기 확인 - Gradient Check)
- 도로시: “지니! 수치 미분 컴퓨터로 구한 값과 오차역전파법 컴퓨터로 구한 값의 오차가 $10^{-13}$ 수준으로 거의 0에 수렴해! 두 연산 장치의 출력이 소수점 아래 12자리까지 완벽하게 일치해!”
- 지니: “축하해, 도로시! 수치 미분은 느리지만 단순해서 버그가 없고, 역전파는 빠르지만 수식이 많아 버그가 생기기 쉽지. 이렇게 두 값을 직접 비교하여 일치하는지 신뢰성을 교차 검증하는 기법을 기울기 확인(Gradient Check)이라고 한단다. 이제 네가 짠 역전파 코드는 버그 없이 완벽하다는 신뢰성 보장을 받았어!”
- 토토: “왈왈! (기울기 확인 검증 합격 도장을 찍으며) 완벽히 일치한다 멍!”
도로시가 수치 미분 컴퓨터와 역전파 컴퓨터의 출력 값이 소수점 아래까지 완벽히 일치하는 보고서를 보고 기뻐하고 있고, 지니가 만족스럽게 ‘기울기 검증’ 인증 도장을 쾅 찍어주며, 토토가 신나게 도약하고 있어요.
NOTE_ 수치 미분과 오차역전파법의 결과 오차가 0이 되는 일은 드뭅니다. 이는 컴퓨터가 할 수 있는 계산의 정밀도가 유한하기 때문입니다(가령 32비트 부동소수점). 이 정밀도의 한계 때문에 오차는 대부분 0이 되지는 않지만, 올바르게 구현했다면 0에 아주 가까운 작은 값이 됩니다. 만약 그 값이 크면 오차역전파법을 잘못 구현했다고 의심해봐야 하겠죠.
5.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)
* 옮긴이_ 4.5절에서 본 순서입니다.