04.4 기울기
앞 절의 예에서는 $x_0$와 $x_1$의 편미분을 변수별로 따로 계산했습니다. 그럼 $x_0$와 $x_1$의 편미분을 동시에 계산하고 싶다면 어떻게 할까요? 가령 $x_0 = 3, x_1 = 4$일 때 $(x_0, x_1)$ 양쪽의 편미분을 묶어서 $\left(\frac{\partial f}{\partial x_0}, \frac{\partial f}{\partial x_1}\right)$을 계산한다고 생각해봅시다. 이때 $\left(\frac{\partial f}{\partial x_0}, \frac{\partial f}{\partial x_1}\right)$처럼 모든 변수의 편미분을 벡터로 정리한 것을 기울기 gradient라고 합니다. 기울기는 예를 들어 다음과 같이 구현할 수 있습니다.
def numerical_gradient(f, x):
h = 1e-4 # 0.0001
grad = np.zeros_like(x) # x와 형상이 같은 배열을 생성
for idx in range(x.size):
tmp_val = x[idx]
# f(x+h) 계산
x[idx] = tmp_val + h
fxh1 = f(x)
# f(x-h) 계산
x[idx] = tmp_val - h
fxh2 = f(x)
grad[idx] = (fxh1 - fxh2) / (2*h)
x[idx] = tmp_val # 값 복원
return grad
numerical_gradient(f, x) 함수의 구현은 좀 복잡하게 보이지만, 동작 방식은 변수가 하나일 때의 수치 미분과 거의 같습니다. 참고로, np.zeros_like(x)는 x와 형상이 같고 그 원소가 모두 0인 배열을 만듭니다.
numerical_gradient(f, x) 함수의 인수인 f는 함수이고 x는 넘파이 배열이므로 넘파이 배열 x의 각 원소에 대해서 수치 미분을 구합니다. 그러면 이 함수를 사용해서 실제로 기울기를 계산해봅시다. 여기에서는 세 점 $(3, 4)$, $(0, 2)$, $(3, 0)$에서의 기울기를 구해보겠습니다.
>>> numerical_gradient(function_2, np.array([3.0, 4.0]))
array([ 6., 8.]) *
>>> numerical_gradient(function_2, np.array([0.0, 2.0]))
array([ 0., 4.])
>>> numerical_gradient(function_2, np.array([3.0, 0.0]))
array([ 6., 0.])
이처럼 $(x_0, x_1)$의 각 점에서의 기울기를 계산할 수 있습니다. 앞의 예에서는 점 $(3, 4)$의 기울기는 $(6, 8)$, 점 $(0, 2)$의 기울기는 $(0, 4)$, 점 $(3, 0)$의 기울기는 $(6, 0)$이라는 결과를 얻었습니다. 그런데 이 기울기라는 게 의미하는 건 뭘까요? 그림으로 그려보면 이해가 될 겁니다. 다만, 여기에서는 기울기의 결과에 마이너스를 붙인 벡터를 그려보겠습니다(소스 코드는 ch04/gradient_2d.py에 있습니다).
기울기 그림은 [그림 4-9]처럼 방향을 가진 벡터(화살표)로 그려집니다. 이 그림을 보면 기울기는 함수의 ‘가장 낮은 장소(최솟값)’를 가리키는 것 같습니다. 마치 나침반처럼 화살표들은 한 점을 향하고 있지요. 또 ‘가장 낮은 곳’에서 멀어질수록 화살표의 크기가 커짐을 알 수 있습니다.
그림 4-9 $f(x_0, x_1) = x_0^2 + x_1^2$의 기울기

[그림 4-9]에서 기울기는 가장 낮은 장소를 가리킵니다만, 실제는 반드시 그렇다고는 할 수 없습니다. 사실 기울기는 각 지점에서 낮아지는 방향을 가리킵니다. 더 정확히 말하자면 기울기가 가리키는 쪽은 각 장소에서 함수의 출력 값을 가장 줄이는 방향입니다. 이건 중요한 포인트이니 확실히 기억하세요!
지니의 마법 기울기 나침반
- 지니: “도로시, 변수가 여러 개인 다차원 공간에서는 동서남북 어느 방향으로 가야 손실 값이 줄어들지 알기 어려워. 이때 각 변수들의 편미분을 묶은 기울기(Gradient) 벡터를 구하면, 그 방향이 바로 ‘현재 위치에서 출력값을 가장 빠르게 줄여주는 방향’을 가리키는 마법의 나침반이 된단다!”
- 도로시: “아! 칠판을 보니 화살표들이 나침반 바늘처럼 전부 골짜기의 최저점(최솟값)을 향해 모이고 있네! 나침반 바늘만 잘 따라 내려가면 길을 잃지 않고 최저점에 도달할 수 있겠어!”
- 토토: “멍멍! (나침반을 보며) 바늘 방향이 최저점 골짜기다 멍!”
지니가 어두운 밤길에서 골짜기(손실 최솟값)의 가장 가파른 하강 방향을 비추는 마법의 기울기 나침반을 들고 도로시와 토토의 길을 밝혀주고 있어요.
4.4.1 경사법(경사 하강법)
기계학습 문제 대부분은 학습 단계에서 최적의 매개변수를 찾아냅니다. 신경망 역시 최적의 매개변수(가중치와 편향)를 학습 시에 찾아야 합니다. 여기에서 최적이란 손실 함수가 최솟값이 될 때의 매개변수 값입니다. 그러나 일반적인 문제의 손실 함수는 매우 복잡하죠. 매개변수 공간이 광대하여 어디가 최솟값이 되는 곳인지를 알아내기가 만만치 않습니다. 이런 상황에서 기울기를 잘 이용해 함수의 최솟값(또는 가능한 한 작은 값)을 찾으려는 것이 경사법입니다.
여기에서 주의할 점은 각 지점에서 함수의 값을 낮추는 방안을 제시하는 지표가 기울기라는 것입니다. 그러나 기울기가 가리키는 곳에 정말 함수의 최솟값이 있는지, 즉 그쪽이 정말로 나아갈 방향인지는 보장할 수 없습니다. 실제로 복잡한 함수에서는 기울기가 가리키는 방향에 최솟값이 없는 경우가 대부분입니다.
WARNING_ 함수가 극솟값, 최솟값, 또 안장점 saddle point이 되는 장소에서는 기울기가 0입니다. 극솟값은 국소적인 최솟값, 즉 한정된 범위에서의 최솟값인 점입니다. 안장점은 어느 방향에서 보면 극댓값이고 다른 방향에서 보면 극솟값이 되는 점입니다.* 경사법은 기울기가 0인 장소를 찾지만 그것이 반드시 최솟값이라고는 할 수 없습니다(극솟값이나 안장점일 가능성이 있습니다). 또, 복잡하고 찌그러진 모양의 함수라면 (대부분) 평평한 곳으로 파고들면서 고원 plateau(플래토)이라 하는, 학습이 진행되지 않는 정체기에 빠질 수 있습니다.
기울어진 방향이 꼭 최솟값을 가리키는 것은 아니나, 그 방향으로 가야 함수의 값을 줄일 수 있습니다. 그래서 최솟값이 되는 장소를 찾는 문제(아니면 가능한 한 작은 값이 되는 장소를 찾는 문제)에서는 기울기 정보를 단서로 나아갈 방향을 정해야 합니다.
드디어 경사법이 등장할 차례입니다. 경사법은 현 위치에서 기울어진 방향으로 일정 거리만큼 이동합니다. 그런 다음 이동한 곳에서도 마찬가지로 기울기를 구하고, 또 그 기울어진 방향으로 나아가는 일을 반복합니다. 이렇게 해서 함수의 값을 점차 줄이는 것이 경사법 gradient method입니다. 경사법은 기계학습의 최적화 문제에서 흔히 쓰는 방법입니다. 특히 신경망 학습에는 경사법을 많이 사용합니다.
NOTE_ 경사법은 최솟값을 찾느냐, 최댓값을 찾느냐에 따라 이름이 다릅니다. 전자를 경사 하강법 gradient descent method, 후자를 경사 상승법 gradient ascent method이라고 하죠. 다만 손실 함수의 부호를 반전시키면 최솟값을 찾는 문제와 최댓값을 찾는 문제는 같은 것이니 하강이냐 상승이냐는 본질적으로는 중요하지 않습니다. 일반적으로 신경망(딥러닝) 분야에서의 경사법은 ‘경사 하강법’으로 등장할 때가 많습니다.
그럼, 경사법을 수식으로 나타내보겠습니다.
\[x_0 = x_0 - \eta \frac{\partial f}{\partial x_0}\] \[x_1 = x_1 - \eta \frac{\partial f}{\partial x_1} \hfill \text{[식 4.7]}\][식 4.7]은 갱신하는 양을 나타냅니다. 이를 신경망 학습에서는 학습률 learning rate이라고 합니다. 한 번의 학습으로 얼마만큼 학습해야 할지, 즉 매개변수 값을 얼마나 갱신하느냐를 정하는 것이 학습률입니다.
[식 4.7]은 1회에 해당하는 갱신이고, 이 단계를 반복합니다. 즉, [식 4.7]처럼 변수의 값을 갱신하는 단계를 여러 번 반복하면서 서서히 함수의 값을 줄이는 것입니다. 또, 여기에서는 변수가 2개인 경우를 보여줬지만, 변수의 수가 늘어도 같은 식(각 변수의 편미분 값)으로 갱신하게 됩니다.
또한 학습률 값은 0.01이나 0.001 등 미리 특정 값으로 정해두어야 하는데요, 일반적으로 이 값이 너무 크거나 작으면 ‘좋은 장소’를 찾아갈 수 없습니다. 신경망 학습에서는 보통 이 학습률 값을 변경하면서 올바르게 학습하고 있는지를 확인하면서 진행합니다.
경사 하강법은 다음과 같이 간단하게 구현할 수 있습니다.
def gradient_descent(f, init_x, lr=0.01, step_num=100):
x = init_x
for i in range(step_num):
grad = numerical_gradient(f, x)
x -= lr * grad
return x
인수 f는 최적화하려는 함수, init_x는 초깃값, lr은 learning rate를 의미하는 학습률, step_num은 경사법에 따른 반복 횟수를 뜻합니다. 함수의 기울기는 numerical_gradient(f, x)로 구하고, 그 기울기에 학습률을 곱한 값으로 갱신하는 처리를 step_num번 반복합니다.
이 함수를 사용하면 함수의 극솟값을 구할 수 있고 잘하면 최솟값을 구할 수도 있습니다. 그럼, 연습 삼아 다음 문제를 풀어볼까요?
문제 : 경사법으로 $f(x_0, x_1) = x_0^2 + x_1^2$의 최솟값을 구하라.
>>> def function_2(x):
... return x[0]**2 + x[1]**2
...
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=0.1, step_num=100)
array([ -6.11110793e-10, 8.14814391e-10])
여기에서는 초깃값을 $(-3.0, 4.0)$으로 설정한 후 경사법을 사용해 최솟값 탐색을 시작합니다. 최종 결과는 $(-6.1\text{e-}10, 8.1\text{e-}10)$으로, 거의 $(0, 0)$에 가까운 결과입니다. 실제로 진정한 최솟값은 $(0, 0)$이므로 경사법으로 거의 정확한 결과를 얻은 것입니다.
경사법을 사용한 이 갱신 과정을 그림으로 나타내면 [그림 4-10]처럼 됩니다. 값이 가장 낮은 장소인 원점에 점차 가까워지고 있지요. 이 그림을 그려주는 소스 코드는 ch04/gradient_method.py에 있습니다(단, 이 코드에서는 등고선을 나타내는 점선은 표시하지 않습니다).
그림 4-10 경사법에 의한 $f(x_0, x_1) = x_0^2 + x_1^2$의 갱신 과정 : 점선은 함수의 등고선을 나타낸다.

여기서 잠시, 학습률이 너무 크거나 작으면 좋은 결과를 얻을 수 없다고 한 것을 잊지 않았겠죠? 그럼 그 두 경우를 실험해보도록 하겠습니다.
# 학습률이 너무 큰 예 : lr=10.0
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=10.0, step_num=100)
array([ -2.58983747e+13, -1.29524862e+12])
# 학습률이 너무 작은 예 : lr=1e-10
>>> init_x = np.array([-3.0, 4.0])
>>> gradient_descent(function_2, init_x=init_x, lr=1e-10, step_num=100)
array([-2.99999994, 3.99999992])
이 실험 결과와 같이 학습률이 너무 크면 큰 값으로 발산해버립니다. 반대로 너무 작으면 거의 갱신되지 않은 채 끝나버리네요. 이것으로 학습률을 적절히 설정하는 일이 왜 중요한지 아셨을 겁니다.
도로시의 발걸음 훈련 (학습률 하이퍼파라미터)
- 도로시: “학습률(Learning Rate)은 경사 하강을 할 때 ‘한 걸음의 보폭’이네! 보폭을 너무 크게 잡으면($lr = 10.0$) 언덕 건너편 절벽으로 껑충 날아가 발산해 버리고, 너무 좁게 잡으면($lr = 10^{-10}$) 개미 걸음처럼 느려서 날이 저물 때까지 골짜기 밑에 못 가겠어!”
- 지니: “맞아! 가중치는 기계가 자동으로 갱신해 주지만, 학습률 같은 하이퍼파라미터는 우리 마법사(사람)가 수많은 실험을 통해 최적의 보폭을 세심히 지정해 줘야 하는 마법 상수의 일종이란다.”
- 토토: “왈왈! (지니가 그어놓은 최적 보폭 라인에 발을 올리며) 보폭이 딱 맞아야 제시간에 골짜기에 골인한다 멍!”
도로시가 큰 학습률을 선택하여 낭떠러지로 크게 발을 딛거나, 작은 학습률로 땀을 흘리며 제자리걸음을 하는 대조적인 상태에서 지니가 가장 알맞은 ‘최적 보폭’을 짚어 설명하고 있어요.
NOTE_ 학습률 같은 매개변수를 하이퍼파라미터 hyper parameter(초매개변수)라고 합니다. 이는 가중치와 편향 같은 신경망의 매개변수와는 성질이 다른 매개변수입니다. 신경망의 가중치 매개변수는 훈련 데이터와 학습 알고리즘에 의해서 ‘자동’으로 획득되는 매개변수인 반면, 학습률 같은 하이퍼파라미터는 사람이 직접 설정해야 하는 매개변수인 것이죠. 일반적으로는 이들 하이퍼파라미터는 여러 후보 값 중에서 시험을 통해 가장 잘 학습하는 값을 찾는 과정을 거쳐야 합니다.
4.4.2 신경망에서의 기울기
신경망 학습에서도 기울기를 구해야 합니다. 여기서 말하는 기울기는 가중치 매개변수에 관한 손실 함수의 기울기입니다. 예를 들어 형상이 $2 \times 3$, 가중치가 $\mathbf{W}$, 손실 함수가 $L$인 신경망을 생각해봅시다. 이 경우 경사는 $\frac{\partial L}{\partial \mathbf{W}}$로 나타낼 수 있습니다. 수식으로는 다음과 같습니다.
\[\mathbf{W} = \begin{pmatrix} w_{11} & w_{21} & w_{31} \\ w_{12} & w_{22} & w_{32} \end{pmatrix}\] \[\frac{\partial L}{\partial \mathbf{W}} = \begin{pmatrix} \frac{\partial L}{\partial w_{11}} & \frac{\partial L}{\partial w_{21}} & \frac{\partial L}{\partial w_{31}} \\ \frac{\partial L}{\partial w_{12}} & \frac{\partial L}{\partial w_{22}} & \frac{\partial L}{\partial w_{32}} \end{pmatrix} \hfill \text{[식 4.8]}\]$\frac{\partial L}{\partial \mathbf{W}}$의 각 원소는 각각의 원소에 관한 편미분입니다. 예를 들어 1행 1번째 원소인 $\frac{\partial L}{\partial w_{11}}$은 $w_{11}$을 조금 변경했을 때 손실 함수 $L$이 얼마나 변화하느냐를 나타냅니다. 여기서 중요한 점은 $\frac{\partial L}{\partial \mathbf{W}}$의 형상이 $\mathbf{W}$와 같다는 것입니다. 실제로 [식 4.8]에서 $\mathbf{W}$와 $\frac{\partial L}{\partial \mathbf{W}}$의 형상은 모두 $2 \times 3$입니다.
그럼 간단한 신경망을 예로 들어 실제로 기울기를 구하는 코드를 구현해보겠습니다. 먼저 simpleNet 클래스를 살펴보시죠(소스 코드는 ch04/gradient_simplenet.py에 있습니다).
import sys, os
sys.path.append(os.pardir)
import numpy as np
from common.functions import softmax, cross_entropy_error
from common.gradient import numerical_gradient
class simpleNet:
def __init__(self):
self.W = np.random.randn(2, 3) # 정규분포로 초기화
def predict(self, x):
return np.dot(x, self.W)
def loss(self, x, t):
z = self.predict(x)
y = softmax(z)
loss = cross_entropy_error(y, t)
return loss
여기에서는 common/functions.py에 정의한 softmax와 cross_entropy_error 메서드를 이용합니다. 그리고 common/gradient.py에 정의한 numerical_gradient 메서드도 이용합니다. 자, simpleNet 클래스는 형상이 $2 \times 3$인 가중치 매개변수 하나를 인스턴스 변수로 갖습니다. 메서드는 2개인데, 하나는 예측을 수행하는 predict(x)이고, 다른 하나는 손실 함수의 값을 구하는 loss(x, t)입니다. 여기서 인수 x는 입력 데이터, t는 정답 레이블입니다. 그럼 simpleNet을 사용해 몇 가지 시험을 해봅시다.*
>>> net = simpleNet()
>>> print(net.W) # 가중치 매개변수
[[ 0.47355232, 0.9977393 , 0.84668094],
[ 0.85557411, 0.03563661, 0.69422093]])
>>>
>>> x = np.array([0.6, 0.9])
>>> p = net.predict(x)
>>> print(p)
[ 1.13282549 0.66052348 1.20919114]
>>> np.argmax(p) # 최댓값의 인덱스
2
>>>
>>> t = np.array([0, 0, 1]) # 정답 레이블
>>> net.loss(x, t)
0.92806853663411326
이어서 기울기를 구해볼까요? 지금까지처럼 numerical_gradient(f, x)를 써서 구하면 됩니다(여기에서 정의한 f(W) 함수의 인수 W는 더미로 만든 것입니다. numerical_gradient(f, x) 내부에서 f(x)를 실행하는데, 그와의 일관성을 위해 f(W)를 정의한 것입니다).
>>> def f(W):
... return net.loss(x, t)
...
>>> dW = numerical_gradient(f, net.W)
>>> print(dW)
[[ 0.21924763 0.14356247 -0.36281009]
[ 0.32887144 0.2153437 -0.54421514]]
numerical_gradient(f, x)의 인수 f는 함수, x는 함수 f의 인수입니다. 그래서 여기에서는 net.W를 인수로 받아 손실 함수를 계산하는 새로운 함수 f를 정의했습니다. 그리고 이 새로 정의한 함수를 numerical_gradient(f, x)에 넘깁니다.
dW는 numerical_gradient(f, net.W)의 결과로, 그 형상은 $2 \times 3$의 2차원 배열입니다. dW의 내용을 보면, 예를 들어 $\mathbf{W}$의 $w_{11}$은 대략 0.2입니다. 이는 $w_{11}$을 $h$만큼 늘리면 손실 함수의 값은 $0.2h$만큼 증가한다는 의미죠. 마찬가지로 $w_{23}$은 대략 $-0.5$이니, $w_{23}$을 $h$만큼 늘리면 손실 함수의 값은 $0.5h$만큼 감소하는 것이죠. 그래서 손실 함수를 줄인다는 관점에서는 $w_{23}$은 양의 방향으로 갱신하고 $w_{11}$은 음의 방향으로 갱신해야 함을 알 수 있습니다. 또, 한 번에 갱신되는 양에는 $w_{23}$이 $w_{11}$보다 크게 기여한다는 사실도 알 수 있습니다.
참고로 이 구현에서는 새로운 함수를 정의하는 데 “def f(x):…” 문법을 썼는데, 파이썬에서는 간단한 함수라면 람다 lambda 기법을 쓰면 더 편합니다. 가령 lambda를 쓰면 다음과 같이 구현할 수 있습니다.
>>> f = lambda w: net.loss(x, t)
>>> dW = numerical_gradient(f, net.W)
신경망의 기울기를 구한 다음에는 경사법에 따라 가중치 매개변수를 갱신하기만 하면 됩니다. 다음 절에서는 2층 신경망을 대상으로 학습 과정 전체를 구현합니다.
NOTE_ 여기서 사용한
numerical_gradient()는 가중치 매개변수W가 다차원 배열을 처리할 수 있도록 앞의 구현에서 조금 수정했습니다. 다차원 배열 처리 외에는 수정하지 않았으니 설명은 생략하겠습니다. 자세히 알고 싶은 분은 소스 코드(common/gradient.py)를 참고하세요.
* 실제로는 [6.0000000000037801, 7.9999999999991189]라는 값이 얻어지지만 [6., 8.]으로 출력됩니다. 이는 넘파이 배열을 출력할 때 수치를 ‘보기 쉽도록’ 가공하기 때문입니다.
* 옮긴이_ 말 안장의 모양을 떠올려보세요.(그림 출처: 위키백과)
* 옮긴이_ 다음 코드를 직접 수행해보면 출력 값들이 이 책과는 다를 겁니다. 이는 simpleNet이 초기화될 때 가중치 W를 무작위로 초기화하기 때문입니다.