3장까지의 신경망은 이미 똑똑한 가중치를 어딘가에서 받아 왔다. MNIST를 인식할 때도 학습이 끝난 sample_weight.pkl을 불러다 순전파만 돌렸다. 그러니 3장의 신경망은 엄밀히 말하면 “추론하는 기계”였지 “배우는 기계”는 아니었다. 이 장이 채우는 것이 바로 그 빈칸이다 — 가중치와 편향을 사람이 정하지 않고, 데이터를 보고 신경망이 스스로 정하게 하는 것. 이것을 학습이라 부른다.

문제는 “스스로 정한다”를 어떻게 기계가 실행할 수 있는 절차로 바꾸느냐다. 이 장의 답은 세 개의 부품으로 이루어진다. 먼저 지금 가중치가 얼마나 나쁜지를 하나의 수로 재는 손실 함수, 그 수를 각 가중치로 미분해 “어느 방향으로 움직이면 나빠짐이 줄어드는가”를 알려 주는 기울기, 그리고 그 방향으로 조금씩 가중치를 밀어 나쁨을 깎아 내리는 경사하강법이다. 이 셋을 조립하면 신경망은 예제 데이터만 주면 스스로 파라미터를 찾아간다. 이 장은 그 셋을 수식으로 유도하고, 넘파이로 만들고, 중간에 실제 숫자로 한 걸음씩 손수 굴려 본 뒤, 2층 신경망에 붙여 손실이 실제로 내려가는 것을 눈으로 확인하는 여정이다. 이 장의 수학이 낯설게 느껴지면, 미분·편미분·기울기·경사하강은 수학부록2가, 로그·교차 엔트로피·소프트맥스는 수학부록4가 숫자로 천천히 떼어 준다 — 막히는 자리에서 먼저 그쪽을 보고 오면 좋다.

학습이란 무엇인가 — 특징을 손으로 설계하던 시대의 끝

기계학습 이전의 방식은 사람이 규칙을 짰다. 숫자 5를 알아보려면 “위쪽에 가로선, 아래쪽에 둥근 곡선” 같은 특징을 사람이 정의하고, 그 특징을 뽑는 코드를 손으로 작성했다. 기계학습은 여기서 한 걸음 물러난다 — 특징(SIFT, HOG 같은)은 여전히 사람이 설계하되, 그 특징으로 분류하는 규칙은 데이터에서 학습한다. 신경망(딥러닝)은 한 걸음 더 물러난다. 특징을 뽑는 일까지 신경망이 데이터로부터 스스로 배운다. 사람이 개입하는 지점이 규칙에서 특징으로, 특징에서 다시 “아무것도”로 줄어든 것이다. 그래서 딥러닝을 종단간(end-to-end) 학습이라 부른다 — 입력(이미지)에서 출력(정답)까지 사람의 설계를 거치지 않고 한 번에 잇는다.

이 자율성에는 대가로 규율이 따른다. 신경망이 학습한 가중치가 특정 데이터셋에만 맞춰지면, 그 데이터에서만 잘하고 처음 보는 데이터에서는 무너진다. 그래서 데이터를 훈련 데이터시험 데이터로 나눠, 훈련 데이터로 학습하고 시험 데이터로 평가한다. 한 데이터셋에만 지나치게 최적화된 상태를 오버피팅이라 하며, 이것을 감시하는 것이 학습의 정직성을 지키는 장치다.

판단 기준: 모델이 “잘한다”고 말하려면 반드시 학습에 쓰지 않은 시험 데이터에서의 성능을 봐야 한다. 훈련 데이터 성능만 보는 것은 시험 문제를 미리 외운 학생을 두고 똑똑하다 하는 것과 같다. 함정: 시험 데이터를 보며 하이퍼파라미터를 튜닝하면, 그 순간 시험 데이터는 훈련 데이터로 오염된다 — 범용 성능을 재는 잣대가 사라진다(이 문제는 6장에서 검증 데이터로 다시 다룬다).

손실 함수 — 나쁨을 하나의 수로 재는 자

학습을 하려면 먼저 “지금 얼마나 못하고 있나”를 숫자로 재야 한다. 그 숫자를 손실 함수(loss function)라 부른다. 손실이 크면 못하는 것, 작으면 잘하는 것이다. 학습이란 결국 이 손실을 최소로 만드는 가중치를 찾는 일이다.

가장 단순한 손실은 **평균제곱오차(MSE)**다. 신경망 출력 와 정답 레이블 의 차이를 제곱해 더한다. 여러 항을 더하는 (시그마) 기호가 낯설면 수학부록1을 먼저 보면 된다 — “여러 개를 죽 더한다”는 약속일 뿐이다.

은 나중에 미분할 때 제곱에서 내려오는 2와 상쇄되어 식을 깔끔하게 만들려고 붙인 것뿐이다. 여기서 는 정답 인덱스만 1이고 나머지는 0인 원-핫 표현이다. 출력이 정답에 가까울수록 각 항의 제곱이 작아지므로 가 작아진다.

분류 문제에서 더 자주 쓰는 것은 **교차 엔트로피 오차(cross entropy error)**다. 로그()가 낯설면 수학부록4가 “로그는 곱셈을 되감는 되돌리기 버튼, 0에 가까울수록 큰 음수”라는 성질을 숫자로 떼어 준다 — 이 성질이 아래 손실의 비대칭을 그대로 만든다.

가 원-핫이므로 이 합은 사실상 정답 인덱스 하나의 항만 살아남는다. 정답이 2번이라면 가 전부다. 즉 교차 엔트로피는 “신경망이 정답 클래스에 부여한 확률”만 본다. 그 확률 가 1에 가까우면 는 0에 가깝고, 가 0에 가까우면 는 무한대로 치솟는다. 정답을 확신할수록 손실이 0으로, 정답을 놓칠수록 손실이 폭발적으로 커지는 이 비대칭이 분류 학습에 잘 맞는다.

def cross_entropy_error(y, t):
    delta = 1e-7                    # log(0) = -inf 방지용 미소값
    return -np.sum(t * np.log(y + delta))

np.log(y + delta)delta가 핵심이다. 신경망이 정답 확률을 정확히 0으로 내면 가 되어 계산이 터진다. 아주 작은 값 1e-7을 더해 로그의 입력이 0이 되지 않도록 막는다.

판단 기준: 회귀(연속값 예측)면 MSE, 분류(클래스 예측)면 교차 엔트로피를 기본으로 잡는다. 분류에서 교차 엔트로피가 나은 이유는 소프트맥스와 결합했을 때 미분이 깔끔하게 떨어지기 때문인데, 이 아름다움은 5장에서 드러난다. 함정: log에 0이 들어갈 수 있다는 것을 잊는 것 — delta 없이 짜면 학습 도중 손실이 갑자기 nan이나 inf로 튀고, 그 뒤 모든 가중치가 오염된다.

숫자로 따라가기 ① — 교차 엔트로피를 손으로 계산해 본다

수식만 보면 교차 엔트로피가 “정답을 확신할수록 작고 틀릴수록 폭발”한다는 말이 아직 추상적이다. 실제 숫자를 넣어 두 경우를 나란히 계산해 보면 이 비대칭이 눈에 잡힌다. 0~9를 분류하는 신경망이 있고, 어떤 그림의 정답이 2번이라고 하자. 정답 레이블은 원-핫이다.

경우 A — 잘 맞힌 출력. 신경망이 2번에 높은 확률 0.6을 주고 나머지는 낮게 흩뿌렸다고 하자.

교차 엔트로피는 인데, 가 2번만 1이고 나머지는 0이므로 다른 항은 전부 0으로 죽고 2번 항 하나만 살아남는다.

경우 B — 못 맞힌 출력. 이번엔 같은 그림인데 신경망이 헷갈려서 정답 2번에 겨우 0.1만 주고, 엉뚱하게 7번에 0.6을 실었다고 하자.

여전히 살아남는 항은 정답 2번 하나뿐이다. 신경망이 7번에 아무리 큰 확률을 줬어도 교차 엔트로피는 거기엔 눈길도 주지 않는다 — 오직 “정답 자리에 얼마를 줬나”만 본다.

경우정답(2번)에 준 확률손실
A (잘 맞힘)
B (헷갈림)

정답 확률이 0.6에서 0.1로 떨어졌을 뿐인데 손실은 0.51에서 2.30으로 4배 넘게 뛰었다. 만약 정답에 0.01밖에 못 줬다면 로 다시 두 배가 된다. 로그의 “0에 가까울수록 아래로 뚝 떨어지는” 성질(수학부록4)이 그대로 “정답을 놓칠수록 벌점이 눈덩이처럼 커지는” 손실로 번역된 것이다. 넘파이로 확인해도 똑같다.

t = np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
y_good = np.array([0.1, 0.05, 0.6, 0.0, 0.05, 0.1, 0.0, 0.1, 0.0, 0.0])
y_bad  = np.array([0.1, 0.05, 0.1, 0.0, 0.05, 0.1, 0.0, 0.6, 0.0, 0.0])
print(cross_entropy_error(y_good, t))  # 0.5108...
print(cross_entropy_error(y_bad,  t))  # 2.3025...

판단 기준: 손실이 정말 “잘함=작게, 못함=크게”를 지키는지 의심되면 이렇게 극단적인 두 출력을 손으로 넣어 대소를 확인하라. 함정: 정답이 아닌 자리의 확률 배치를 보고 손실을 짐작하는 것 — 교차 엔트로피는 정답 자리 하나만 보므로, 오답들에 확률이 어떻게 흩어졌는지는 손실값에 전혀 안 나타난다.

미니배치 — 전체를 근사하는 표본

손실 함수는 데이터 한 개가 아니라 훈련 데이터 전체에 대해 정의되어야 한다. 개 데이터의 평균 손실은 이렇게 쓴다.

앞의 식을 개에 대해 더하고 으로 나눠 평균 냈을 뿐이다. 그런데 MNIST는 6만 장이고 큰 데이터셋은 수백만 장이다. 매 학습 단계마다 전체 데이터의 손실과 기울기를 계산하면 한 걸음 내딛는 데 너무 오래 걸린다. 그래서 전체에서 일부를 무작위로 뽑아 그 표본의 평균 손실로 전체를 근사한다. 이 표본을 미니배치라 부른다.

train_size = x_train.shape[0]              # 60000
batch_size = 100
batch_mask = np.random.choice(train_size, batch_size)  # 0~59999 중 100개 무작위
x_batch = x_train[batch_mask]              # (100, 784)
t_batch = t_train[batch_mask]              # (100, 10)

여론조사가 유권자 전체가 아니라 표본 몇천 명으로 전체 민심을 추정하듯, 미니배치는 100장으로 6만 장의 기울기 방향을 추정한다. 표본이라 매번 조금씩 다르지만, 평균적으로는 전체 방향을 가리키므로 학습이 진행된다. 배치를 지원하려면 cross_entropy_error가 데이터 한 개와 여러 개 모두를 받게 손봐야 한다.

def cross_entropy_error(y, t):
    if y.ndim == 1:                # 데이터 한 개면 배치 형태로 세운다
        t = t.reshape(1, t.size)   # (10,) → (1, 10)
        y = y.reshape(1, y.size)
    batch_size = y.shape[0]
    return -np.sum(t * np.log(y + 1e-7)) / batch_size   # 배치 크기로 나눠 평균

/ batch_size로 나누는 것이 중요하다. 이렇게 평균을 내야 배치 크기가 100이든 128이든 손실의 척도가 일정하게 유지되어, 학습률 같은 하이퍼파라미터가 배치 크기에 휘둘리지 않는다.

판단 기준: 데이터가 커서 전체 계산이 느리면 미니배치로 근사한다. 배치가 클수록 기울기 추정이 안정적이지만 한 걸음이 느려지고, 작을수록 흔들리지만 빠르다 — 그 사이에서 고른다. 함정: 손실을 배치 크기로 나누지 않는 것 — 합만 쓰면 배치가 커질 때 손실과 기울기가 비례해 커져, 사실상 학습률을 몰래 키운 꼴이 되어 발산할 수 있다.

왜 정확도가 아니라 손실인가 — 미분이 대부분 0이라는 벽

여기서 자연스러운 의문이 든다. 우리가 정말 원하는 것은 높은 정확도인데, 왜 정확도를 직접 지표로 삼아 최대화하지 않고 굳이 손실이라는 대리 지표를 두는가.

이유는 미분에 있다. 학습은 “지표를 파라미터로 미분해, 그 미분이 가리키는 방향으로 파라미터를 조금 움직이는” 방식으로 진행된다. 미분이 낯설면 수학부록2를 먼저 보고 오면 좋다 — 미분은 “조금 움직이면 얼마나 바뀌나”를 재는 것뿐이다. 그런데 정확도는 파라미터를 아주 조금 움직여도 거의 항상 변하지 않는다. 100장 중 32장을 맞히고 있을 때 가중치를 미세하게 흔들어도 여전히 32장을 맞힌다 — 정확도는 32%에서 꼼짝하지 않는다. 어쩌다 한 장의 판정이 뒤집히는 순간 33%로 계단처럼 툭 뛴다. 즉 정확도의 미분은 대부분의 지점에서 정확히 0이고, 드문 지점에서만 불연속으로 튄다. 미분이 0이면 “어느 방향으로 가야 하는가”라는 신호가 나오지 않으므로, 파라미터를 어떻게 움직여야 할지 알 수 없다.

손실 함수는 다르다. 가중치를 미세하게 흔들면 출력 확률 가 연속적으로 움직이고, 그에 따라 손실도 0.9247…에서 0.9241…처럼 연속적으로 미세하게 변한다. 그래서 손실의 미분은 대부분의 지점에서 0이 아니며, “이쪽으로 가면 손실이 준다”는 방향을 항상 내놓는다. 이것이 계단 함수 대신 시그모이드를 활성화 함수로 쓴 3장의 선택과 정확히 같은 이유다 — 미분이 곳곳에서 0이 아니어야 학습 신호가 흐른다. 계단 함수는 정확도처럼 대부분의 지점에서 미분이 0이라 신경망을 학습시킬 수 없다.

판단 기준: 학습의 지표로 삼을 함수는 “파라미터를 조금 바꿨을 때 값이 매끄럽게 따라 변하는가”를 물어라. 매끄럽게 변해야 미분이 방향을 주고, 방향이 있어야 경사하강이 작동한다. 함정: “우리가 원하는 게 정확도니 정확도를 최적화하자”는 직관은 여기서 함정이다 — 정확도는 계단이라 미분 신호가 없다. 정확도는 학습의 목표이되, 최적화의 대상은 그 매끄러운 대리인인 손실이다.

수치 미분 — 기울기를 정의로 되찾다

미분이 학습의 엔진이라면, 미분을 어떻게 계산하느냐가 다음 문제다. 이 장에서는 미분의 정의를 거의 그대로 코드로 옮기는 수치 미분을 쓴다. 미분의 정의는 이렇다.

컴퓨터는 극한을 취할 수 없으니 를 아주 작은 유한한 값으로 두고 근사한다. 그런데 이 순진한 전방차분(forward difference)에는 두 가지 오차가 있다. 하나는 를 0으로 못 보내서 생기는 근사 오차, 다른 하나는 컴퓨터의 반올림 오차다.

먼저 반올림 오차. 를 무작정 작게, 이를테면 1e-50으로 잡으면 부동소수점이 그 미세한 값을 표현하지 못하고 0으로 반올림해 버린다. 그러면 가 그냥 가 되어 분자가 0이 된다. 그래서 에는 하한이 있다 — 너무 작으면 오히려 무너진다. 경험적으로 1e-4 정도가 안정적이다.

다음으로 근사 오차. 전방차분 두 점을 잇는 기울기라, 진짜 접선(그 사이 어딘가의 기울기)과 어긋난다. 이 어긋남을 줄이는 것이 **중앙차분(central difference)**이다.

를 중심으로 좌우 대칭인 두 점 의 기울기를 재면, 한쪽으로 치우친 전방차분보다 진짜 접선에 훨씬 가깝다. 왜 그런지는 테일러 전개로 보인다. 이고, 이다. 이 둘을 빼면 짝수 차수 항( 항)이 상쇄되고 홀수 차수만 남는다.

양변을 로 나누면 가 되어, 오차의 우두머리 항이 에 비례한다. 반면 전방차분은 같은 방식으로 따지면 오차가 에 비례한다 — 즉 전방차분의 오차는 , 중앙차분은 로 한 차수 작다. 이면 이니 그 차이가 크다. 그래서 이 장은 중앙차분을 표준으로 쓴다.

def numerical_diff(f, x):
    h = 1e-4                              # 너무 작으면 반올림 오차로 무너진다
    return (f(x + h) - f(x - h)) / (2 * h)  # 중앙차분: x를 중심으로 좌우 대칭

판단 기준: 수치 미분은 중앙차분을, 1e-4 안팎을 쓴다 — 반올림 오차의 하한과 근사 오차의 상한 사이의 절충점이다. 함정: “더 정확하려면 를 더 작게”라는 직관은 여기서 배신한다. 1e-4보다 훨씬 작게 잡으면 부동소수점 반올림이 로 뭉개 버려 오차가 오히려 커진다.

숫자로 따라가기 ② — 의 미분을 로 재본다

중앙차분이 정말 진짜 미분에 가까운지, 손으로 확인해 보자. 의 미분은 손으로 풀면 이므로, 에서의 참값은 이다. 이제 코드가 아니라 컴퓨터가 쓰는 그 절차를 그대로 종이에 옮겨, 로 중앙차분을 계산해 본다.

먼저 두 점에서의 함숫값을 구한다.

이 둘을 빼면 다. 이제 로 나눈다.

참값 6이 소수점 아래로도 정확히 떨어졌다. 사실 는 3차 이상의 도함수가 0이라, 위에서 본 중앙차분의 오차 항()까지 통째로 0이 되어 오차가 원리적으로 없다. 그래서 이 예는 유독 깔끔하게 6이 나온다. 넘파이로 돌려도 부동소수점 반올림 탓에 6.00000000000378 같은 값이 나오는데, 6과의 차이가 수준이라 사실상 6이다.

def f(x):
    return x ** 2
 
print(numerical_diff(f, 3))   # 6.00000000000378  (참값 6과 사실상 일치)

여기서 수학부록2가 같은 곡선을 전방차분(를 1, 0.1, 0.01…로 줄이며 7 → 6.1 → 6.01…로 6에 다가가는)으로 보여 준 것과 비교하면, 중앙차분은 하나로 단번에 6에 착 붙는다. 좌우 대칭으로 재서 한쪽 치우침을 상쇄했기 때문이다.

판단 기준: 수치 미분 코드를 새로 짰으면, 손으로 미분을 아는 간단한 함수(, 등)에 먼저 걸어 참값이 나오는지 확인하라 — 검산이 되는 함수로 도구를 먼저 믿을 수 있게 만들고 신경망에 붙인다. 함정: 이 딱 6으로 떨어졌다고 모든 함수가 그렇게 정확할 거라 믿는 것 — 3차 이상 도함수가 살아 있는 함수에서는 오차가 실제로 나타나, 1e-4에서 소수 몇째 자리부터는 참값과 어긋난다.

편미분과 기울기 — 방향을 가진 미분

신경망의 손실은 수백~수만 개의 가중치에 동시에 의존하는 함수다. 이렇게 변수가 여럿인 함수에서 한 변수에만 주목해 미분한 것이 편미분이다. 편미분·기울기가 처음이면 수학부록2가 “나머지는 다 고정하고 하나만 조금 건드리기(소금만 조금 넣어 맛보기)“로 풀어 준다. 예컨대 에서 에 대한 편미분 을 상수로 고정한 채 만 미분해 이 된다.

모든 변수에 대한 편미분을 한 벡터로 묶은 것이 **기울기(gradient)**다. 처럼. 기울기는 단순한 미분값 하나가 아니라 방향을 가진 화살표다 — 각 지점에서 함수값이 가장 가파르게 증가하는 방향을 가리킨다. 그러니 그 반대 방향으로 가면 함수값이 가장 빠르게 줄어든다. 이것이 다음 절의 경사하강법이 서 있는 토대다.

기울기를 수치적으로 구하려면, 변수 하나하나를 차례로 조금 흔들며 중앙차분을 반복한다.

def numerical_gradient(f, x):
    h = 1e-4
    grad = np.zeros_like(x)                 # x와 같은 shape의 0 배열
    for idx in range(x.size):
        tmp = x[idx]
        x[idx] = tmp + h                     # idx번째만 +h
        fxh1 = f(x)
        x[idx] = tmp - h                     # idx번째만 -h
        fxh2 = f(x)
        grad[idx] = (fxh1 - fxh2) / (2 * h)  # 그 변수에 대한 편미분
        x[idx] = tmp                         # 반드시 원상복구
    return grad

x[idx] = tmp로 원상복구하는 마지막 줄이 조용한 급소다. 이걸 빠뜨리면 이전 변수를 흔든 값이 그대로 남아 다음 변수의 미분이 엉뚱한 점에서 계산된다. 실제로 이 함수를 에 걸면 각 변수를 중앙차분으로 흔들어 을 돌려주는데, 손으로 구한 편미분과 정확히 맞는다 — 앞의 스텝 플레이어 첫 스텝에서 다시 확인한다.

판단 기준: 여러 변수 함수의 기울기는 변수마다 따로 중앙차분을 돌려 벡터로 모은다. 기울기 벡터의 방향이 “가장 가파른 오르막”, 그 음수가 “가장 가파른 내리막”임을 기억하라. 함정: 한 변수를 흔든 뒤 원래 값으로 되돌리지 않는 것 — 루프가 서로를 오염시켜 기울기 전체가 틀어진다. 그리고 이 이중 루프가 변수 하나당 를 두 번씩 부른다는 사실을 눈여겨 둬라. 가중치가 수만 개면 한 번의 기울기 계산에 를 수만 번 호출한다 — 이 느림이 이 장의 마지막 복선이다.

경사하강법 — 안개 속에서 발밑 기울기만 보고 내려가기

이제 부품이 다 모였다. 손실 함수라는 지형이 있고, 기울기가 그 지형에서 가장 가파른 방향을 알려 준다. 손실을 최소로 만드는 골짜기를 찾아가는 방법이 경사하강법이다.

직관은 이렇다. 짙은 안개 속 산에 서 있어 전체 지형은 보이지 않고 발밑의 기울기만 느낄 수 있다면, 가장 확실한 하산법은 지금 발밑에서 가장 가파르게 내려가는 방향으로 한 걸음 딛고, 새 자리에서 다시 발밑 기울기를 재고, 또 한 걸음 딛는 것을 반복하는 것이다. 전체 지도가 없어도 국소적인 기울기만으로 골짜기로 내려갈 수 있다. 수식으로는 이렇게 쓴다.

기울기 는 오르막 방향이므로 그 앞에 마이너스를 붙여 내리막으로 뒤집었다. (에타)는 학습률로, 한 걸음의 보폭이다. 이 갱신을 여러 번 반복하면 가 점점 손실이 작은 쪽으로 이동한다.

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                   # 내리막으로 lr만큼 한 걸음
    return x

학습률 는 크지도 작지도 않아야 한다. 너무 크면 골짜기를 훌쩍 뛰어넘어 반대편 벽을 때리고 다시 튕겨 나가, 손실이 줄기는커녕 발산한다(값이 inf로 폭발). 너무 작으면 한 걸음이 미미해 골짜기에 닿기 전에 학습이 끝나 버린다 — 정체. 안개 속 하산에 비유하면, 보폭이 너무 크면 골짜기를 건너뛰어 반대편 산비탈로 올라가 버리고, 너무 작으면 해가 질 때까지 산중턱에 머문다.

한 가지 못 박아 둘 것. 경사하강법은 국소적인 기울기만 따르므로, 지형에 골짜기가 여럿이면 가장 낮은 골짜기(전역 최솟값)가 아니라 가까운 골짜기(국소 최솟값)에 갇힐 수 있다. 안개 속에서는 지금 내려가는 이 골짜기가 산 전체에서 제일 낮은 곳인지 알 길이 없다. 다행히 신경망 학습에서는 이것이 치명적 문제가 되지 않는 경우가 많지만, 원리적 한계로 알아 둔다.

판단 기준: 학습률은 손실 곡선을 보며 고른다 — 손실이 발산하면 학습률이 너무 큰 것, 지지부진하면 너무 작은 것이다. 0.1, 0.01처럼 자릿수를 바꿔 가며 탐색한다. 함정: 학습률은 가중치처럼 데이터로 학습되는 값이 아니라 사람이 정하는 하이퍼파라미터다 — 이것을 신경망이 알아서 정해 주리라 기대하면 안 된다. 발산과 정체 사이의 좁은 창을 손으로 찾아야 한다.

숫자로 따라가기 ③ — 경사하강 한 걸음을 실제 학습률로 굴려 본다

경사하강이 정말 손실을 내리는지, 밥그릇 함수 에서 한 걸음을 손으로 굴려 본다. 이 함수의 바닥(최솟값)은 원점 이고, 그 지점의 손실은 0이다. 출발점을 로 두면 지금 손실은 다. 기울기는 각 변수의 편미분을 모은 이다.

출발점 기울기. 에서 . 이 화살표가 “가장 가파른 오르막”을 가리킨다.

학습률 로 한 걸음. 갱신식 를 두 좌표에 각각 적용한다.

한 걸음 뒤 위치는 다. 이 자리의 손실을 재 보자.

손실이 25에서 16으로 내려갔다. 정말 낮은 쪽으로 한 걸음 굴러간 것이다. 한 걸음 더 가 보자. 의 기울기는 이고,

두 걸음 뒤 손실은 다. 25 → 16 → 10.24로 착실히 줄어든다. 표로 보면 골짜기로 굴러가는 흐름이 한눈에 들어온다.

걸음위치 손실
출발
1걸음
2걸음
def f(x):
    return x[0]**2 + x[1]**2
 
x = np.array([3.0, 4.0])
lr = 0.1
for i in range(3):
    grad = np.array([2*x[0], 2*x[1]])   # 이 함수의 참 기울기
    print(x, f(x))                       # [3. 4.] 25.0 → [2.4 3.2] 16.0 → ...
    x = x - lr * grad

여기서 학습률의 감각을 하나 더 챙기자. 위에서 이라 위치가 매 걸음 배로 줄었다(는 정확히 ). 만약 를 너무 크게, 이를테면 으로 잡으면 이 되어 반대편으로 같은 거리만큼 튕겨 나가고, 손실은 25에서 꼼짝도 안 한다. 더 키우면 가 오히려 커져 발산한다. 반대로 이면 한 걸음에 로 거의 안 움직여 골짜기에 닿기 전에 지친다. 수학부록2에도 같은 출발· 하산이 여러 걸음까지 이어져 있으니, 이 굴러감이 원점으로 수렴하는 끝까지 보고 싶으면 그쪽을 보면 된다.

판단 기준: 경사하강이 도는지 의심되면, 이렇게 참 기울기를 아는 밥그릇 함수에 걸어 손실이 매 걸음 줄어드는지 먼저 확인하라 — 여기서 안 줄면 코드나 학습률이 잘못된 것이다. 함정: 한 걸음 뒤 손실이 오히려 커졌는데 “원래 그럴 수도 있지” 하고 넘기는 것 — 볼록한 밥그릇에서 손실이 커졌다면 십중팔구 학습률이 너무 크거나 기울기 부호가 뒤집힌 것이다.

손으로 짜 보는 학습 — 수치 미분 기울기에서 2층 신경망 학습 루프까지

지금까지의 부품을 스텝으로 쌓아 올린다. 단일 함수의 기울기에서 출발해, 그 기울기를 신경망 가중치 전체로 확장하고, 마침내 미니배치를 뽑아 손실을 깎는 학습 루프까지 자라나게 한다. 각 스텝의 shape 흐름을 주석에 명시했다.

Refactoring Step 단일 함수의 기울기 — 중앙차분을 변수마다 반복
1/5
import numpy as npdef numerical_gradient(f, x):    h = 1e-4    grad = np.zeros_like(x)              # x와 같은 shape의 0 배열    for idx in range(x.size):        tmp = x[idx]        x[idx] = tmp + h                 # idx번째 변수만 +h        fxh1 = f(x)        x[idx] = tmp - h                 # idx번째 변수만 -h        fxh2 = f(x)        grad[idx] = (fxh1 - fxh2) / (2 * h)        x[idx] = tmp                     # 원상복구 — 빠뜨리면 다음 변수가 오염된다    return grad# f(x0, x1) = x0^2 + x1^2 의 (3, 4)에서의 기울기def f(x):    return x[0] ** 2 + x[1] ** 2print(numerical_gradient(f, np.array([3.0, 4.0])))  # [6. 8.]  = (2*3, 2*4)# 손으로 구한 편미분 (2x0, 2x1)과 정확히 일치한다.

마지막 스텝의 네 단계가 이 책 전체를 관통하는 학습의 뼈대다. 앞으로 옵티마이저(6장)를 바꾸든 역전파(5장)로 기울기를 빠르게 구하든, 이 “미니배치 → 기울기 → 갱신 → 기록”의 루프 자체는 변하지 않는다. 지금 바꾸는 것은 그중 오직 한 부품, 기울기를 구하는 방법의 속도다. 그리고 방금 “숫자로 따라가기 ③”에서 밥그릇 함수에 손으로 굴린 그 한 걸음(3번 갱신)이, 여기서는 25차원이 아니라 39,760개 파라미터 차원에서 벌어질 뿐 원리는 완전히 같다.

판단 기준: 신경망 클래스를 짤 때 predict(순전파), loss(순전파 + 손실), numerical_gradient(기울기)의 세 책임을 분리하라. 학습 루프는 이 셋을 조합만 한다. 함정: loss_W = lambda W: self.loss(x, t)에서 인자 W를 쓰지 않는 것이 실수처럼 보이지만 의도된 설계다 — numerical_gradientself.params의 배열을 제자리에서 흔들고, self.loss가 그 흔들린 상태를 읽는다. 이 우회를 이해 못 하면 “왜 W를 안 쓰지?” 하고 코드를 잘못 고쳐 기울기가 전부 0이 된다.

학습이 정말 손실을 내리는가 — 에폭과 정확도로 확인

루프를 돌리면 train_loss_list에 손실이 쌓인다. 이것을 그려 보면 초반에 가파르게, 이후 완만하게 내려가는 곡선이 나온다 — 신경망이 실제로 배우고 있다는 증거다. 다만 미니배치 손실은 표본마다 튀므로 곡선이 지글거린다.

더 믿을 만한 지표는 에폭 단위 정확도다. 1에폭은 훈련 데이터 전체를 한 번 다 본 횟수를 뜻한다 — 6만 장을 100장씩 뽑으면 600번의 갱신이 1에폭이다. 매 에폭마다 훈련 데이터와 시험 데이터 각각의 정확도를 재서, 둘 다 올라가면 학습이 건강한 것이다.

iter_per_epoch = max(train_size / batch_size, 1)   # 600
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)
    # 에폭이 지날수록 두 정확도가 함께 오르면 건강, 훈련만 오르고 시험이
    # 정체·하락하면 오버피팅의 신호다.

훈련 정확도와 시험 정확도가 나란히 올라 거의 겹치면, 신경망이 훈련 데이터를 외운 게 아니라 일반적인 패턴을 배운 것이다. 두 선이 벌어지기 시작하는 지점이 오버피팅이 고개를 드는 곳이며, 6장에서 이것을 억누르는 기법들을 다룬다.

판단 기준: 학습이 되는지는 손실 곡선의 하강으로, 일반화가 되는지는 훈련·시험 정확도가 함께 오르는지로 판단한다. 두 잣대를 함께 봐야 “배우고 있으면서 외우지는 않는” 상태를 확인할 수 있다. 함정: 미니배치 손실 하나가 우연히 튀는 것을 보고 학습이 잘못됐다 단정하는 것 — 표본 노이즈다. 추세는 여러 스텝의 흐름과 에폭 정확도로 봐야 한다.

정리 — 배우는 기계를 처음으로 세우다, 그러나 느리다

이 장은 3장의 “받은 가중치로 추론하는 신경망”을 “데이터로 가중치를 스스로 정하는 신경망”으로 바꿨다. 그 전환의 뼈대는 손실 함수, 기울기, 경사하강법 세 부품의 조립이었고, 그 셋을 각각 실제 숫자로 손수 굴려 확인했다.

  • 학습이란 손실 함수를 최소로 만드는 가중치를 데이터에서 찾는 일이다. 손실은 지금 신경망이 얼마나 못하는지를 하나의 수로 잰다 — 분류에는 교차 엔트로피 를 쓴다. 정답에 0.6을 주면 손실 0.51, 0.1을 주면 2.30으로, 정답을 놓칠수록 벌점이 폭발한다는 것을 숫자로 확인했다.
  • 지표로 정확도가 아니라 손실을 쓰는 이유는 미분이다. 정확도는 파라미터를 조금 흔들어도 계단처럼 대부분 0으로 반응해 학습 신호를 못 준다. 손실은 매끄러워 곳곳에서 0이 아닌 기울기를 내놓는다 — 계단 함수 대신 시그모이드를 쓴 것과 같은 이유다.
  • 수치 미분은 중앙차분 로 미분의 정의를 그대로 옮긴다. 에서 재면 참값 6이 소수점 아래까지 떨어진다. 를 더 작게 잡으면 반올림 오차로 오히려 무너진다. 기울기는 모든 변수의 편미분을 묶은, 방향을 가진 벡터다.
  • 경사하강법 은 기울기의 반대 방향으로 한 걸음씩 내려간다 — 안개 속에서 발밑 기울기만 보고 하산하듯. 에서 로 한 걸음 굴리면 손실이 25에서 16으로 내려간다. 학습률 는 크면 발산, 작으면 정체하는 사람이 정하는 하이퍼파라미터다.
  • 2층 신경망을 수치 미분만으로 학습시켜 손실이 실제로 내려가고 정확도가 오르는 것을 확인했다. 학습의 심장은 “미니배치 → 기울기 → 갱신 → 기록”의 루프다.

그러나 이 장의 방식에는 치명적인 대가가 있다. 수치 미분은 옳지만 참을 수 없이 느리다. 가중치 하나의 편미분을 구하려고 순전파를 두 번씩 돌리는데, 신경망의 가중치는 수만 개다. 한 번의 기울기 계산에 순전파를 수만 번 반복하니, 위 학습 루프를 MNIST에서 끝까지 돌리면 몇 시간이 걸린다. 원리를 이해하기엔 더없이 정직한 방법이지만, 실전에서 쓰기엔 너무 굼뜨다.

그래서 다음 장은 같은 기울기를 비교할 수 없이 빠르게 구하는 방법을 찾는다. 손실을 각 가중치로 흔들어 보는 대신, 순전파를 한 번 흘린 뒤 그 계산 과정을 거꾸로 되짚어 모든 기울기를 단번에 얻는다 — 오차역전파법이다. 그 뿌리인 연쇄법칙(“겉미분 × 속미분”)은 수학부록2에 숫자로 떼어 두었다. 계산 그래프 위에서 국소 미분을 곱해 전달하는 그 우아한 절차가, 이 장에서 세운 학습의 골격에 속도라는 심장을 달아 준다.

다음장으로 5장