신경망 학습에서 가중치 매개변수에 대한 손실 함수의 기울기는 수치 미분을 사용해 계산했다. 수치 미분은 단순하고 구현하기도 쉽지만 계산 시간이 오래 걸린다는 단점이 있다. 이에 비해 오차역전파법(backpropagation)
은 가중치 매개변수의 기울기를 효율적으로 계산한다.
계산 그래프
계산 그래프(computational graph)
는 계산 과정을 그래프로 나타낸 것이다. 이는 복수의 노드(node)
와 에지(edge)`로 표현되며, 노드 사이의 직선을 에지라고 한다. 계산 그래프의 문제풀이는 다음과 같은 흐름으로 진행된다.
- 계산 그래프를 구성한다.
- 그래프에서 계산을 왼쪽에서 오른쪽으로 진행한다.
여기서 2번째 단계인 그래프에서 계산을 왼쪽에서 오른쪽으로 진행하는 단계를 순전파(forward propagation)
라고 한다. 순전파는 계산 그래프의 출발점부터 종착점으로의 전파이다. 반대로 오른쪽에서 왼쪽으로의 전파는 역전파(backward propagation)
라고 하며, 이는 미분을 계산할 때 중요한 역할을 한다.
계산 그래프의 특징은 국소적 계산을 전파함으로써 최종 결과를 얻는다는 점이다. 여기서 국소적이란 '자신과 직접 관계된 작은 범위'를 뜻한다. 국소적 계산은 결국 전체에서 어떤 일이 벌어지든 상관없이 자신과 관계된 정보만으로 결과를 출력한다. 이러한 국소적 계산은 단순하지만, 그 결과를 전달함으로써 전체를 구성하는 복잡한 계산을 해낼 수 있다.
계산 그래프의 이점은 다음과 같다.
- 국소적 계산을 통해 전체가 아무리 복잡해도 각 노드에서는 단순한 계산에 집중하여 문제를 단순화
- 중간 계산 결과를 모두 보관
- 역전파를 통해 미분을 효율적으로 계산 가능
연쇄법칙
계산 그래프의 역전파
역전파는 '국소적인 미분'을 순방향과는 다르게 오른쪽에서 왼쪽으로 전달한다. 이 국소적 미분을 전달하는 원리는 연쇄법칙(chain rule)
에 따른 것이다. y = f(x)라는 계산의 역전파를 그려보면 다음과 같다.
역전파의 계산 절차는 신호 E에 국소적 미분을 곱한 후 다음 노드로 전달하는 것이다. 국소적 미분은 순전파 때의 y = f(x) 계산의 미분을 구한다는 것이며, 이는 y에 대한 x의 미분을 구한다는 뜻이다. 이렇게 구한 국소적 미분을 상류에서 전달한 값(위 그림에선 E)에 곱해 앞쪽 노드로 전달한다. 이러한 방식을 따르면 목표로 하는 미분 값을 효율적으로 구할 수 있다.
연쇄 법칙이란?
합성 함수
란 여러 함수로 구성된 함수이다. 연쇄법칙은 합성 함수의 미분에 대한 성질이며, '합성 함수의 미분은 합성 함수를 구성하는 각 함수의 미분의 곱으로 나타낼 수 있다'로 정의할 수 있다. 연쇄 법칙은 다음과 같이 나타낼 수 있다.
연쇄법칙과 계산 그래프
역전파의 계산 절차에서는 노드로 들어온 입력 신호에 그 노드의 국소적 미분(편미분)을 곱한 후 다음 노드로 전달한다. 예를 들어 '**2' 노드를 확인하면 다음과 같다.
순전파 시에 입력이 t이고 출력이 z인 경우 이 노드에서 편미분 값을 곱하고 다음 노드로 넘긴다. 또한, 가장 왼쪽의 역전파를 확인해 보면 결국 z에 대한 x의 미분이 되어 역전파가 하는 일은 연쇄법칙의 원리와 같다는 것을 확인할 수 있다.
역전파
덧셈 노드의 역전파
z = x + y라는 식에 대한 역전파는 다음과 같다.
이와 같이 덧셈 노드의 역전파는 1을 곱하기만 할 뿐이므로 입력된 값을 그대로 다음 노드로 보내게 된다.
곱셈 노드의 역전파
z = xy라는 식에 대한 역전파는 다음과 같다.
곱셈 노드의 역전파는 상류의 값에 순전파 때의 입력 신호들을 서로 바꾼 값, 즉 입력 신호가 x였다면 역전파에서는 y, 순전파에서 y였다면 역전파에서는 x를 곱해서 하류로 보낸다. 앞서 본 덧셈의 역전파에서는 상류의 값을 그대로 흘려보내서 순방향 입력 신호의 값은 필요하지 않았다. 하지만 곱셈의 역전파는 순방향 입력 신호의 값이 필요하기 때문에 곱셈 노드를 구현할 때는 순전파의 입력 신호를 변수에 저장해 둔다.
역전파의 구체적인 계산 예시는 다음과 같다.
단순한 계층 구현하기
곱셈 계층
모든 계층은 공통적으로 순전파를 나타내는 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
def backward(self, dout):
dx = dout * self.y
dy = dout * self.x
return dx, dy
__init__()
: 인스턴스 변수인 x와 y를 초기화 → 두 변수는 순전파 시의 입력 값을 유지하기 위해 사용forward()
: x와 y를 인수로 받고 두 값을 곱해서 반환backward()
: 상류에서 넘어온 미분(dout)에 순전파 때의 값을 서로 바꿔 곱한 후 하류로 흐름
덧셈 계층
덧셈 노드를 구현하면 다음과 같다.
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__()
: 초기화가 필요 없기 때문에 passforward()
: 입력받은 두 인수 x, y를 더해서 반환backward()
: 상류에서 내려온 미분(dout)이 그대로 하류로 흐름
이렇게 구현한 덧셈 계층과 곱셈 계층을 사용하여 위에서 본 전체적인 역전파 흐름을 구현하면 다음과 같다. 여기서 1은 apple_num, 2는 apple, 3은 orange, 4는 orange_num, 5는 tax이다.
apple = 100
apple_num = 2
orange = 150
orange_num = 3
tax = 1.1
# 계층
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)
orange_price = mul_orange_layer.forward(orange, orange_num)
all_price = add_apple_orange_layer.forward(apple_price, orange_price)
price = mul_tax_layer.forward(all_price, tax)
# 역전파
dprice = 1
dall_price, dtax = mul_tax_layer.backward(dprice)
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price)
dorange, dorange_num = mul_orange_layer.backward(dorange_price)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)
print(price)
print(dapple_num, dapple, dorange, dorange_num, dtax)
활성화 함수 계층 구현하기
ReLU 계층
ReLU 함수의 경우 순전파 때의 입력인 x가 0보다 크면 역전파는 상류의 값을 그대로 하류로 흘린다. 순전파 때 x가 0 이하면 역전파 때는 하류로 신호를 보내지 않고 0을 보낸다. ReLU 함수의 수식과 계산 그래프는 다음과 같다.
이를 코드로 구현하면 다음과 같다.
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보다 큰 인덱스는 Fasle로 유지한다. 역전파 때는 순전파 때의 mask에서 True인 곳에는 상류에서 전파된 dout을 0으로 설정한다.
Sigmoid 계층
시그모이드 함수의 수식과 계산 그래프는 다음과 같다.
- ' / ' 노드 : 순전파에선 y = 1 / x 계산을 수행하며, 역전파에선 상류에서 흘러온 값에 순전파의 출력을 제곱한 후 마이너스를 붙인 값을 곱해서 하류로 전달
- ' + ' 노드 : 역전파에서 상류의 값을 여과 없이 하류로 내보냄
- ' exp ' 노드 : 순전파에선 y = exp(x) 계산을 수행하며, 역전파에서의 미분값도 동일하게 exp(x). 계산 그래프에서는 상류의 값에 순전파 때의 출력을 곱해 하류로 전달
- ' * ' 노드 : 순전파 때의 값을 서로 바꿔 곱함
Sigmoid 계층의 역전파 계산 그래프에서 최종 출력 값을 보며 입력 x와 출력 y만을 통해 이를 계산할 수 있는 것을 확인할 수 있다. 따라서 계산 그래프의 중간 과정들을 모두 묶어 다음과 같은 sigmoid 노드 하나로 간소화할 수 있다. 이러한 간소화 버전은 역전파 과정의 중간 계산들을 생략할 수 있어 더욱 효율적인 계산이라고 할 수 있다. 또한 노드를 그룹화하여 SIgmoid 계층의 세세한 내용을 노출하지 않고 입력과 출력에만 집중할 수 있다.
또한 Sigmoid 계층의 출력 값은 다음과 같이 정리해서 쓸 수 있다.
이처럼 Sigmoid 계층의 역전파는 순전파의 출력(y) 만으로 계산할 수 있다. 이를 코드로 구현하면 다음과 같다.
class Sigmoid:
def __init__(self):
self.out = None
def forward(self, x):
out = 1 / (1 + np.exp(-x))
self.oit = out
return out
def backward(self, dout):
dx = dout * (1.0 - self.out) * self.out
return dx
Affine/Softmax 계층 구현하기
Affine 계층
신경망의 순전파에서는 가중치 신호의 총합을 계산하기 때문에 행렬의 곱을 사용한다. 이러한 행렬의 곱 계산은 대응하는 차원의 원소 수를 일치시키는 게 핵심이다. 이러한 행렬의 곱은 기하학에서 어파인 변환(affine transformation)
이라고 한다. 따라서 어파인 변환을 수행하는 처리를 Affine 계층
이라는 이름으로 구현한다. 행렬의 곱과 편향의 합에 대한 계산 그래프는 다음과 같다.
기존에 확인한 계산 그래프와의 차이점은 스칼라값이 아닌 X, W, B가 모두 행렬이라는 점이다. 이에 대한 역전파는 다음과 같다.
행렬을 사용한 역전파도 행렬의 원소마다 전개해 보면 스칼라값을 사용한 지금까지의 계산 그래프와 같은 순서로 생각할 수 있다. 행렬 곱(dot 노드)의 역전파는 행렬의 대응하는 차원의 원소 수가 일치하도록 곱을 조립하여 구할 수 있다.
배치용 Affine 계층
위에서 확인한 Affine 계층은 입력 데이터로 X 하나만을 고려한 것이었다. 이번에는 데이터 N개를 묶어 순전파하는 경우를 나타내는 배치용 Affine 계층을 고려한다.
기존과 다른 부분은 입력인 X의 형상이 (1,2)에서 (N,2)가 된 것이다. 또한 역전파 때는 행렬의 형상에 주의하면 이전과 같이 도출할 수 있다. 또한 편향을 더할 때 주의해야 할 점은, 순전파 때의 편향 덧셈은 XW에 대한 편향이 각 데이터에 더해진다는 점이다. 즉, N=2(데이터가 2개)인 경우 편향은 두 데이터 각각의 계산 결과에 더해진다. 편향의 역전파는 각 데이터에 대한 미분을 데이터마다 더해서 구한다.
이러한 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
Softmax-with-Loss 계층
소프트맥스(Softmax)
함수는 입력 값을 정규화하여 출력 값의 합이 1이 되도록 출력한다. 신경망에서 수행하는 두 가지 작업인 학습과 추론 중 추론 과정에선 일반적으로 Softmax 계층을 사용하지 않는다. 이는 신경망 추론에서는 답을 하나만 내는 경우에는 가장 높은 점수(score)
만 알면 되어서 굳이 Softmax 계층이 필요 없기 때문이다. 그러나 신경망을 학습하는 과정에서는 Softmax 계층이 필요하다.
소프트맥스 함수와 손실 함수인 교차 엔트로피 오차도 포함하여 'Softmax-with-Loss 계층'이라는 이름으로 구현한다. 복잡한 계산 과정은 포함하지 않고 간소화한 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을 출력
여기서 주목할 부분은 바로 역전파의 결과이다. 소프트맥스 함수의 손실 함수로 교차 엔트로피 오차를 사용하니 역전파가 (y1 - t1, y2 - t2, y3 - t3)로 깔끔하게 정리된다. 이것은 Softmax 계층의 출력과 정답 레이블의 차분, 즉 오차가 앞 계층에 전달된다. 이는 신경망 학습의 중요한 성질이다. 이와 유사하게 회귀 문제에서 항등 함수의 손실 함수로 오차제곱합을 사용하면 똑같은 결과가 나오게 된다.
이러한 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개당 오차를 앞 계층으로 전파한다.
오차역전파법 구현하기
오차역전파법을 적용한 신경망 구현하기
오차역전파법을 적용한 2차 신경망을 TwoLayerNet 클래스를 통해 구현했다.
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
먼저, TwoLayerNet 클래스의 인스턴스 변수들을 살펴보면 다음과 같다.
- params : 딕셔너리 변수로, 신경망의 매개변수를 보관 → W1, b1, W2, b2
- layers : 순서가 있는 딕셔너리 변수로, 신경망의 계층을 보관
- layers['Affine1'], layers['Relu1'], layers['Affine2']와 같이 각 계층을 순서대로 유지
- lastLayer : 신경망의 마지막 계층 → SoftmaxWithLoss 계층
여기서 주목할 점은 바로 OrderedDict
, 즉 순서가 있는 딕셔너리에 신경망의 계층을 보관한다는 점이다. 순서가 있다는 것은 딕셔너리에 추가한 순서를 기억한다는 것이다. 따라서 순전파 때는 추가한 순서대로 각 계층의 forward()
메서드를 호출하기만 하면 처리가 완료되며, 역전파 때는 계층을 반대 순서로 호출하기만 하면 된다.
다음은 TwoLayerNet 클래스의 메서드이다.
- __init__ : 초기화 수행
- 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) : 가중치 매개변수의 기울기를 오차역전법으로 계산
오차역전파법으로 구한 기울기 검증하기
기울기를 구하는 방법은 두 가지가 있다. 하나는 수치 미분이며, 또 다른 방법은 해석적으로 수식을 풀어 구하는 방법으로 오차역전파법을 이용하여 매개변수가 많아도 효율적으로 계산할 수 있다. 수치 미분은 그에 반해 느리다. 그러나 수치 미분은 구현하기 쉽다는 이점을 가지고 있어 버그가 숨어 있기 어렵지만, 오차역전파법은 구현하기 복잡해서 실수가 있을 수 있다. 따라서 이러한 수치 미분의 결과와 오차역전파법의 결과를 비교하여 오차역전파를 제대로 구현했는지 검증하곤 하며, 이를 기울기 확인(gradient check)
이라고 한다. 이를 코드로 구현하면 다음과 같다.
# Gradient Check
(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_numerical[key] - grad_backprop[key]))
print(key + ":" + str(diff))
MNIST 데이터셋을 불러들인 후, 훈련 데이터의 일부를 수치 미분으로 구한 기울기와 오차역전파법으로 구한 기울기의 오차를 확인한다. 오차의 경우 각 가중치의 절댓값을 구하고, 이를 평균한 값으로 계산했다. 4개의 매개변수 모두 차이값이 매우 작은 것을 확인할 수 있다. 이는 오차역전파법이 잘 구현되었다는 것을 의미한다.
이 포스팅은 '밑바닥부터 시작하는 딥러닝' 교재를 공부하며 작성한 글입니다.
해당 포스팅에 포함된 코드는 깃허브에서 확인 가능합니다.