5장까지 왔다면 우리는 이미 신경망을 학습시킬 수 있다. 오차역전파로 기울기를 빠르게 구하고, 경사하강법으로 가중치를 갱신하면 손실이 내려간다. 그런데 여기엔 조용한 가정이 하나 깔려 있다 — “기울기 방향으로 조금씩 가면 알아서 좋은 곳에 도착한다.” 이 가정은 절반만 참이다. 같은 신경망, 같은 데이터라도 어떻게 갱신하느냐, 가중치를 무엇으로 초기화하느냐, 층의 분포를 어떻게 다스리느냐에 따라 학습이 빠르게 수렴하기도 하고, 제자리에서 진동하기도 하고, 아예 신호가 사라져 멈추기도 한다.

이 장은 새로운 신경망을 만들지 않는다. 대신 4·5장에서 만든 학습이 실제로 잘 되게 하는 기술들을 하나씩 붙인다. 옵티마이저, 가중치 초깃값, 배치 정규화, 오버피팅 대책, 하이퍼파라미터 탐색. 각각은 독립된 기법처럼 보이지만 관통하는 질문은 하나다 — 손실 지형(landscape) 위에서 우리가 딛는 걸음을 어떻게 더 똑똑하게 만들 것인가. 그리고 이 기법들은 대부분 “이론적으로 왜 최선인가”보다 “실전에서 이렇게 하면 잘 되더라”에 뿌리를 둔다. 그러니 수식으로 이해하되, 히스토그램과 그래프로 확인 사살하는 것이 이 장을 읽는 방식이다. 이 장의 갱신식들은 전부 4장의 경사하강법(기울기의 반대 방향으로 걷기)을 변주한 것이므로, 기울기·경사하강이 낯설면 먼저 수학부록2를 한 번 훑고 오면 좋다.

SGD의 한계 — 왜 곧장 안 가고 지그재그로 가는가

지금까지 쓴 갱신법은 확률적 경사하강법(SGD)이다. 미니배치로 구한 기울기 방향의 반대로 학습률만큼 이동한다.

간결하고 대체로 잘 동작한다. 문제는 손실 함수가 비등방성(anisotropic) 일 때 드러난다. 비등방성이란 방향에 따라 기울기의 크기가 크게 다른 성질이다. 대표 예가 같은 길게 늘어진 골짜기다. 이 함수의 기울기는 방향으로는 가파르고 방향으로는 완만하다. 최솟값은 원점 에 있다.

기울기가 실제로 얼마나 어긋나는지 편미분으로 확인해 보자. , 이다(편미분이 낯설면 수학부록2의 “편미분” 절을 보라 — 나머지 변수를 상수로 두고 하나만 미분하는 것이다). 점 에서 기울기는 이다. 정작 원점으로 가려면 방향, 즉 성분이 커야 하는데, 기울기는 성분이 압도적으로 크다. 걸음이 원점이 아니라 거의 축을 따라 튀는 이유가 이 숫자에 그대로 보인다.

SGD를 이 위에서 돌리면 어떻게 될까. 방향 기울기가 크므로 걸음은 주로 축을 위아래로 크게 튕긴다. 정작 가야 할 방향(골짜기 바닥을 따라 원점으로)으로는 기울기가 작아 찔끔찔끔 나아간다. 결과는 골짜기 벽을 번갈아 때리며 갈지자로 내려가는 비효율적인 경로다. 안개 속 하산 비유로 말하면, 발밑 기울기만 보고 가장 가파른 쪽으로 내딛는 사람은, 좁고 긴 협곡에서 앞으로 나아가는 대신 양쪽 벽을 튕기며 시간을 버린다.

핵심은 이것이 학습률을 잘못 잡아서가 아니라는 점이다. SGD의 방향 결정 방식 자체가 비등방성 지형에서 비효율적이다. 기울기의 방향은 반드시 최솟값을 가리키지 않는다 — 국소적으로 가장 가파른 방향일 뿐이고, 그 방향이 최솟값 쪽과 크게 어긋날 수 있다. 이 결함이 뒤따르는 세 옵티마이저의 출발점이다.

판단 기준: 학습이 진동하며 더디게 수렴하면, 학습률을 줄이기 전에 “지형이 비등방성이라 방향 자체가 튀는 것”을 먼저 의심하라. 이럴 땐 갱신 방향을 다듬는 옵티마이저(Momentum·Adam)가 학습률 조정보다 효과적이다. 함정: SGD가 느린 것을 “학습률이 크다”로만 해석해 무작정 를 줄이면, 지그재그는 줄지만 전진 속도까지 함께 죽어 더 느려진다.

Momentum — 관성으로 진동을 상쇄한다

Momentum(모멘텀)은 물리의 관성을 빌린다. 공을 지형 위에 굴린다고 상상하자. 공은 매 순간 기울기(힘)를 받지만, 이전까지의 속도도 가지고 있다. 갱신식은 속도 를 매개로 둔다.

는 보통 0.9 정도의 마찰·관성 계수다. 항이 이전 걸음의 방향을 일정 비율로 이어받는다. 여기서 진동이 왜 줄어드는지가 핵심이다. 비등방성 골짜기에서 방향 기울기는 위·아래로 부호가 계속 바뀐다. 그래서 이전 속도와 이번 기울기가 서로 상쇄되어 방향 속도는 커지지 못한다. 반대로 방향 기울기는 작지만 부호가 늘 같다. 같은 방향의 작은 힘이 관성으로 계속 누적되어 방향 속도는 점점 커진다. 결과적으로 공은 위아래 진동을 줄이면서 골짜기 바닥을 따라 원점으로 가속한다 — SGD의 갈지자가 완만한 활강으로 펴진다.

class Momentum:
    def __init__(self, lr=0.01, momentum=0.9):
        self.lr = lr
        self.momentum = momentum
        self.v = None                       # 파라미터별 속도. 첫 호출 때 0으로 만든다
 
    def update(self, params, grads):
        if self.v is None:                  # params: dict, 예) {'W1': (784,50), 'b1': (50,), ...}
            self.v = {k: np.zeros_like(val) for k, val in params.items()}
        for key in params.keys():
            # v ← αv - η·grad  (이전 속도를 관성으로 이어받고 현재 기울기를 더한다)
            self.v[key] = self.momentum * self.v[key] - self.lr * grads[key]
            params[key] += self.v[key]      # W ← W + v

주목할 구현 포인트는 v가 파라미터와 같은 shape로 상태를 유지한다는 것이다. SGD는 상태가 없지만(매번 기울기만 보면 됨), Momentum부터는 옵티마이저가 과거를 기억한다. 이 “상태를 가진 갱신”이 뒤의 AdaGrad·Adam으로 이어지는 공통 구조다.

판단 기준: 기울기 부호가 방향마다 다르게(어떤 축은 자주 뒤집히고 어떤 축은 일관되게) 나타나는 지형이면 Momentum이 SGD를 확실히 이긴다. 함정: v를 파라미터별 dict로 각각 유지하지 않고 하나로 뭉치거나, 매 스텝 0으로 리셋하면 관성이 사라져 그냥 SGD가 된다 — 속도 누적이 Momentum의 전부다.

숫자로 따라가기 (1) — SGD 한 스텝과 Momentum 한 스텝을 손으로 굴려 대조

수식만 보면 두 갱신이 얼마나 다른지 감이 안 온다. 그러니 파라미터 하나를 골라 실제 숫자로 한 스텝씩 굴려 보자. 조건을 못 박는다.

  • 파라미터 초깃값:
  • 학습률:
  • 모멘텀 계수:
  • 이 파라미터에서 매 스텝 관측되는 기울기 : 경우 A(방향 일정) = , 경우 B(방향 진동) =

경우 A — 기울기 부호가 늘 같은 축 (골짜기 바닥 방향)

SGD. 갱신식은 . 상태가 없으니 매번 씩 곧이곧대로 움직인다.

스텝기울기 갱신량 갱신 후
1
2
3

Momentum. , 그다음 . 속도 는 0에서 출발한다.

  • 스텝 1: . → .
  • 스텝 2: . → .
  • 스텝 3: . → .
스텝기울기 속도 갱신 후
1
2
3

두 표를 나란히 놓으면 핵심이 보인다. 스텝 1에서는 둘이 으로 완전히 같다 — 속도가 0에서 출발하니 첫 걸음은 SGD와 구별되지 않는다. 그런데 스텝이 쌓일수록 벌어진다. SGD는 3스텝에 , 즉 만큼 움직였다. Momentum은 같은 3스텝에 , 즉 만큼 움직였다 — 거의 2배 빠르다. 부호가 같은 기울기가 속도에 계속 더해져 걸음이 가속되기 때문이다. 이것이 “골짜기 바닥 방향으로는 완만하지만 일관된 기울기를 관성으로 키운다”는 말의 실제 숫자다.

경우 B — 기울기 부호가 매번 뒤집히는 축 (골짜기 벽 방향)

이번엔 기울기가 으로 진동한다.

SGD는 그대로 튕긴다: . 갱신량이 라 제자리에서 왕복한다 — 이게 지그재그의 정체다.

Momentum은 다르다.

  • 스텝 1: . → .
  • 스텝 2: . → .
  • 스텝 3: . → .

스텝 2의 속도를 보라. 이전 속도 과 이번 기울기가 만든 가 거의 상쇄되어 , 걸음이 쪼그라들었다. SGD가 로 크게 왕복하는 그 자리에서, Momentum은 진동을 눌러 거의 멈춰 선다. 경우 A에서는 걸음을 키우고(가속), 경우 B에서는 걸음을 죽인다(감쇠) — 같은 갱신식 하나가 축의 성격에 따라 정반대로 작동해 갈지자를 활강으로 펴는 것이다.

판단 기준: 새 옵티마이저를 처음 쓸 때는 이렇게 파라미터 하나·기울기 몇 개로 손 계산을 해 보면 “속도가 정말 누적되나”를 코드 없이 검증할 수 있다. 함정: 첫 스텝이 SGD와 같다고 “Momentum이 안 먹었다”고 오해하는 것 — 관성은 가 쌓이는 스텝 2부터 드러난다. 초기 몇 스텝만 보고 옵티마이저 효과를 판단하지 마라.

AdaGrad — 학습률을 매개변수마다 다르게, 점점 줄인다

Momentum이 방향을 다뤘다면 AdaGrad는 학습률을 다룬다. 두 가지 통찰을 결합한다. 첫째, 학습이 진행될수록 학습률을 줄이는 게 좋다(처음엔 크게 움직이고 나중엔 미세 조정) — 이를 학습률 감소(learning rate decay)라 한다. 둘째, 그 감소를 전체에 일률적으로가 아니라 매개변수마다 개별적으로 적용한다. 많이 움직인 매개변수는 학습률을 크게 줄이고, 적게 움직인 매개변수는 덜 줄인다.

이를 구현하려고 각 매개변수의 기울기 제곱을 누적한 를 둔다.

은 원소별 곱이다. 는 그 매개변수가 지금까지 받은 기울기 크기의 누적이고, 갱신할 때 를 곱하므로 많이 갱신된(=가 큰) 매개변수일수록 실효 학습률이 작아진다. 비등방성 골짜기에서 방향은 기울기가 커 가 빨리 불어나므로 곧 걸음이 잦아들고, 방향은 가 천천히 커져 상대적으로 큰 걸음을 유지한다. 방향별 학습률이 지형에 맞춰 자동 조절되는 것이다.

숫자로 잠깐. 경우 A(기울기 이 세 번)를 AdaGrad로 굴리면 로 커진다. 실효 학습률 로 스스로 줄어든다. 같은 기울기를 받아도 걸음이 점점 작아지는 것 — 학습률 감소가 코드가 아니라 의 누적으로 자동 실현된다.

class AdaGrad:
    def __init__(self, lr=0.01):
        self.lr = lr
        self.h = None
 
    def update(self, params, grads):
        if self.h is None:
            self.h = {k: np.zeros_like(val) for k, val in params.items()}
        for key in params.keys():
            self.h[key] += grads[key] * grads[key]          # 기울기 제곱 누적(원소별)
            # 1e-7은 h가 0일 때 0으로 나누는 것을 막는 안전장치
            params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + 1e-7)

AdaGrad의 약점도 식에 이미 들어 있다. 는 제곱을 계속 더하기만 하므로 단조 증가한다. 학습이 길어지면 가 0에 수렴해 사실상 갱신이 멈춘다. 이 “지나친 감쇠”를 지수이동평균으로 완화한 것이 RMSProp이고, 그 RMSProp의 아이디어가 Adam의 한 축이 된다.

판단 기준: 매개변수마다 갱신 빈도·크기가 크게 다른 문제(희소 특성 등)에서 AdaGrad가 강하다. 함정 하나는 분모의 (1e-7)을 빼먹는 것 — 초기 에서 0으로 나눠 NaN이 터진다. 함정 둘은 가 무한정 커져 학습이 조기 정지하는 것 — 긴 학습에선 RMSProp/Adam을 고려하라.

Adam — Momentum과 AdaGrad를 한 몸에

Adam은 두 흐름을 합친다. Momentum처럼 기울기의 1차 모멘트(평균, 방향) 를 이어받고, AdaGrad(정확히는 RMSProp)처럼 기울기의 2차 모멘트(제곱의 평균, 스케일) 로 학습률을 조절한다. 둘 다 단순 누적이 아니라 지수이동평균으로 두어, 과거를 부드럽게 잊는다.

초기에 가 0에서 출발해 값이 편향되므로(bias), 이를 보정한 를 쓴다.

는 갱신 횟수다. 이 편향 보정이 왜 필요한지 한 걸음 더 들어가 보자. 은 0에서 출발하므로, 첫 스텝()에서 가 된다(). 진짜 기울기는 인데 은 그 밖에 안 되니 심하게 과소평가된 것이다. 보정 분모 로 나누면 — 정확히 원래 크기로 복원된다. 가 커지면 이라 , 보정이 서서히 사라진다. 즉 편향 보정은 초반의 저평가만 정확히 되돌리고 뒤로 갈수록 손을 뗀다. 하이퍼파라미터는 보통 , 를 그대로 쓰고 학습률 만 조정하면 되는 경우가 많다 — Adam이 실전 기본값으로 자리 잡은 이유다. 방향(관성)과 스케일(적응 학습률)을 동시에 다루니, 비등방성 골짜기에서 진동은 Momentum처럼 줄고 방향별 학습률은 AdaGrad처럼 맞춰진다.

class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999):
        self.lr = lr
        self.beta1, self.beta2 = beta1, beta2
        self.iter = 0
        self.m = None
        self.v = None
 
    def update(self, params, grads):
        if self.m is None:
            self.m = {k: np.zeros_like(val) for k, val in params.items()}
            self.v = {k: np.zeros_like(val) for k, val in params.items()}
        self.iter += 1
        for key in params.keys():
            self.m[key] = self.beta1 * self.m[key] + (1 - self.beta1) * grads[key]
            self.v[key] = self.beta2 * self.v[key] + (1 - self.beta2) * (grads[key] ** 2)
            m_hat = self.m[key] / (1 - self.beta1 ** self.iter)   # 편향 보정
            v_hat = self.v[key] / (1 - self.beta2 ** self.iter)
            params[key] -= self.lr * m_hat / (np.sqrt(v_hat) + 1e-7)

이 네 옵티마이저는 전부 update(params, grads)라는 같은 인터페이스를 갖는다. 그래서 신경망 학습 루프에서 옵티마이저를 한 줄 바꿔 끼우는 것만으로 갈아탈 수 있다. 5장에서 계층(Layer)이 forward/backward라는 공통 인터페이스로 조립되던 것과 같은 설계다 — 갱신 정책을 계산과 분리해 교체 가능하게 둔 것이다.

판단 기준: 특별한 이유가 없으면 Adam을 기본값으로 시작하라(대부분의 문제에서 무난하게 빠르다). 문제 특성이 뚜렷하거나 재현·튜닝이 중요하면 SGD+Momentum이 더 잘 나오는 경우도 있으니 둘을 비교한다. 함정: “Adam이 항상 최고”는 아니다 — 일부 문제에서 SGD+Momentum이 최종 일반화 성능은 더 좋다는 보고가 흔하다. 옵티마이저는 데이터로 골라야지 명성으로 고르는 게 아니다.

같은 인터페이스로 옵티마이저 갈아 끼우기

네 옵티마이저를 update 하나로 통일했으므로, 학습 루프는 옵티마이저의 정체를 몰라도 된다. 아래 스텝은 SGD에서 시작해 Momentum·AdaGrad·Adam으로 갱신 규칙만 바꿔 끼우는 과정이다. 학습 루프(network.gradientoptimizer.update)는 처음부터 끝까지 그대로라는 점에 주목하라.

Refactoring Step SGD — 상태 없는 가장 단순한 갱신
1/4
import numpy as npclass SGD:    def __init__(self, lr=0.01):        self.lr = lr    def update(self, params, grads):        # params/grads: {'W1': ndarray, 'b1': ndarray, ...} 같은 dict        for key in params.keys():            params[key] -= self.lr * grads[key]     # W ← W - η·grad# 학습 루프는 옵티마이저의 종류를 모른다 — update만 호출한다optimizer = SGD(lr=0.01)for i in range(max_iter):    grads = network.gradient(x_batch, t_batch)      # 역전파로 기울기    optimizer.update(network.params, grads)         # 갱신 정책은 optimizer가 안다

이 스텝이 보여 주는 건 옵티마이저 네 개의 수식만이 아니다. 갱신 정책을 계산 그래프에서 떼어 내 교체 가능한 부품으로 둔 설계다. 새 옵티마이저를 실험하고 싶으면 update 하나만 구현하면 되고, 신경망도 학습 루프도 손대지 않는다. 좋은 추상은 이렇게 “무엇이 바뀔지”를 미리 알아보고 그 축을 갈아 끼우기 쉽게 만든다.

판단 기준: 여러 갱신법을 비교 실험할 계획이면, 옵티마이저를 처음부터 공통 인터페이스(update)로 두어 루프를 재사용하라. 함정: 옵티마이저마다 학습률의 좋은 범위가 다르다 — SGD의 0.01을 Adam에 그대로 쓰면(Adam 기본은 0.001) 발산할 수 있다. 옵티마이저를 바꿀 때 학습률도 함께 다시 잡아라.

가중치 초깃값 (1) — 0도, 큰 값도 안 되는 이유

옵티마이저가 걸음의 방식이라면, 가중치 초깃값은 걸음의 출발점이다. 그리고 이 출발점 하나가 학습의 성패를 가른다. 먼저 두 극단이 왜 실패하는지부터 본다.

모든 가중치를 0으로. 직관적으로 편해 보이지만 학습이 아예 안 된다. 이유는 대칭성이다. 어떤 층의 모든 가중치가 같은 값이면, 순전파에서 그 층의 모든 뉴런이 정확히 같은 출력을 낸다. 역전파에서도 모든 뉴런에 정확히 같은 기울기가 흐른다. 따라서 갱신 후에도 모든 가중치가 여전히 서로 같다. 뉴런을 여러 개 둔 의미가 사라진다 — 100개의 뉴런이 영원히 한 개처럼 움직인다. 이 대칭을 깨려면 초깃값을 무작위로 흩뿌려야 한다.

표준편차가 큰 무작위 값. 그럼 정규분포에서 크게(예: 표준편차 1) 뽑으면 될까. 여기서 활성화값 분포를 히스토그램으로 봐야 한다. 시그모이드를 쓰는 5층 신경망에 표준편차 1로 초기화하면, 각 층의 활성화값이 0과 1 양극단에 몰린다. 시그모이드는 입력이 크거나 작으면 출력이 0이나 1로 포화되고, 그 지점의 미분값은 0에 가깝다. 역전파에서 이 미분값이 곱해지면서 기울기가 층을 거슬러 올라갈수록 점점 작아져 사라진다 — 이것이 기울기 소실(gradient vanishing) 이다. 깊은 층일수록 학습 신호가 도달하지 못한다.

반대로 표준편차를 너무 작게(예: 0.01) 하면 활성화값이 죄다 0.5 근처에 몰린다. 기울기 소실은 없지만 모든 뉴런이 거의 같은 값을 내니 표현력이 제한된다 — 0으로 초기화한 대칭성 문제의 약한 버전이다. 결국 초깃값의 스케일은 “너무 크면 포화(소실), 너무 작으면 표현력 상실” 사이의 좁은 길이다.

판단 기준: 학습이 처음부터 멈춰 있으면(손실이 안 내려감) 옵티마이저보다 초깃값을 먼저 의심하라 — 특히 깊은 망에서. 활성화값 히스토그램을 층마다 찍어 양극단에 몰렸는지(소실), 한 값에 몰렸는지(표현력 상실)를 눈으로 확인하는 게 가장 빠른 진단이다. 함정: 편향은 0으로 초기화해도 대칭성 문제가 없지만, 가중치를 0(또는 모두 같은 상수) 으로 두면 뉴런이 영원히 한 몸으로 움직여 학습 자체가 성립하지 않는다.

가중치 초깃값 (2) — Xavier와 He, 분포를 살리는 스케일

그러면 “적당한” 표준편차는 얼마인가. 답은 앞 층의 노드 수 에 맞춰 스케일을 정하는 것이다. 층을 지날 때 활성화값의 분산이 유지되도록 초깃값의 분산을 조절한다는 발상이다.

Xavier 초깃값(시그모이드·tanh 같은 좌우 대칭 활성화용)은 표준편차를 으로 잡는다. 앞 층 노드가 많을수록(이 클수록) 더 좁게 뿌린다 — 입력이 많으면 가중합이 커지므로, 그만큼 개별 가중치를 작게 해 활성화값 분포가 퍼지지도 뭉치지도 않게 한다.

Xavier로 초기화하면 앞 절에서 양극단에 몰렸던 활성화 히스토그램이 각 층에서 적당히 넓게 퍼진 종 모양으로 유지된다. 층을 거쳐도 분포가 무너지지 않으니 기울기도 살아서 흐른다.

그런데 Xavier는 활성화 함수가 원점 근처에서 선형이고 좌우 대칭이라는 가정 위에 있다. ReLU는 음수 절반을 0으로 잘라 버려 이 가정이 깨진다 — 출력의 절반이 죽으니 분산이 절반으로 준다. 그래서 ReLU에는 He 초깃값을 쓴다. 표준편차를 로 잡아, ReLU가 깎아 먹는 절반을 배로 미리 보상한다.

ReLU에 Xavier를 쓰면 층이 깊어질수록 활성화값이 점점 0으로 쪼그라들어(작아진 분산이 누적) 결국 기울기 소실로 간다. He를 쓰면 깊은 층에서도 분포가 유지된다. 히스토그램으로 보면 이 차이가 선명하다 — 같은 ReLU 망이라도 He는 층마다 고른 분포를, Xavier는 갈수록 좁아지는 분포를 보인다.

# 층마다 앞 노드 수 node_num에 맞춰 스케일을 정한다
# sigmoid/tanh → Xavier
W = np.random.randn(node_num, node_num) / np.sqrt(node_num)          # std = 1/√n
# ReLU → He
W = np.random.randn(node_num, node_num) * np.sqrt(2.0 / node_num)    # std = √(2/n)

판단 기준: 활성화 함수로 초깃값을 고른다 — sigmoid·tanh면 Xavier, ReLU 계열이면 He. 활성화 히스토그램이 층을 지나도 넓게 유지되면 스케일이 맞은 것이다. 함정: ReLU에 Xavier를 쓰는 것(반대도) — 깊은 망에서 분포가 서서히 죽어 손실이 안 내려간다. 활성화 함수를 바꾸면 초깃값 스케일도 함께 바꿔야 한다.

숫자로 따라가기 (2) — 초깃값 표준편차가 활성화 분포를 정하는 계산

“표준편차가 크면 포화, 작으면 뭉침, Xavier면 딱 좋다”를 히스토그램 없이 숫자만으로 감 잡아 보자. 열쇠는 한 뉴런의 가중합이 얼마나 퍼지는가를 계산하는 한 줄짜리 공식이다.

어떤 뉴런이 앞 층 노드 개로부터 가중합 를 만든다(가중합·행렬 곱이 낯설면 수학부록3의 “신경망은 왜 온통 행렬 곱인가” 절을 보라 — 이 가 바로 np.dot(x, W)의 한 칸이다). 입력 와 가중치 가 서로 독립이고 평균 0이라 하면, 독립인 항들의 분산은 더해지므로 가중합의 분산은 이렇게 된다.

가중합의 표준편차는 배로 증폭된다. 노드가 100개면 배다. 이제 , 입력 표준편차 로 고정하고, 가중치 표준편차만 세 가지로 바꿔 가중합의 std를 계산한다.

초깃값 std()시그모이드가 받는 입력활성화 분포
까지 넓게0·1 양극단 포화 → 기울기 소실
안쪽죄다 근처 → 표현력 상실
(Xavier)대략 적당히 퍼짐

숫자가 세 절의 이야기를 그대로 재현한다. std 1은 가중합이 std 10으로 터져 시그모이드를 양 끝(, )으로 밀어 넣고, 그 지점 미분 — 거의 0이라 역전파에서 기울기가 사라진다. std 0.01은 가중합이 std 0.1로 너무 작아 시그모이드가 원점 근처 거의 직선 구간만 쓰니 출력이 전부 0.5 언저리, 뉴런들이 서로 구별되지 않는다. Xavier의 은 정확히 증폭을 상쇄해 가중합 std를 1로 되돌린다 — 그래서 이라는 스케일이 하늘에서 떨어진 게 아니라 “배 증폭을 나눠 없애는 값”으로 유도된다.

He도 같은 논리다. ReLU가 음수 절반을 잘라 출력 분산을 대략 절반으로 떨어뜨리므로, 그 손실을 미리 메우려면 가중치 분산을 2배로 키워야 한다 — , 즉 . 이면 로, Xavier의 0.1보다 배 크다. 딱 ReLU가 깎아먹는 만큼을 보상하는 크기다.

판단 기준: 초깃값 스케일이 의심스러우면 히스토그램을 찍기 전에 로 가중합 std를 먼저 암산하라 — 이 값이 활성화 함수의 “살아 있는 구간”(시그모이드면 대략 이내)에 들어오는지가 관건이다. 함정: 을 앞 층 노드 수가 아니라 뒤 층 노드 수로 잘못 넣는 것 — 증폭은 “합쳐지는 입력의 개수”에서 오므로 반드시 앞 층이다.

배치 정규화 — 분포를 강제로 정규화한다

초깃값을 잘 고르면 활성화 분포가 좋게 유지된다. 그렇다면 분포가 좋게 유지되도록 초깃값에 의존하지 말고, 각 층에서 직접 강제하면 어떨까. 이것이 배치 정규화(Batch Normalization)의 발상이다. 각 층의 활성화값이 적당히 퍼지도록, 미니배치 단위로 평균 0·분산 1로 정규화하는 계층을 활성화 함수 앞(또는 뒤)에 끼워 넣는다.

미니배치 에 대해 평균과 분산을 구하고 정규화한다.

정규화가 실제로 무엇을 하는지 작은 배치로 확인하자. 미니배치의 어떤 활성화값이 이라 하자. 평균 , 분산 , 표준편차 . 각 값을 로 바꾸면 가 된다. 원래 5를 중심으로 넓게 퍼져 있던 값들이 평균 0·분산 1로 다시 정렬됐다 — 초깃값이 어떻든 층의 입장에서 늘 같은 스케일의 입력을 받게 되는 것이다.

여기서 그냥 끝내면 모든 층이 평균 0·분산 1로 고정되어 표현력이 제약된다. 그래서 학습 가능한 두 파라미터 (스케일), (이동)로 다시 변환해, 신경망이 필요하면 정규화를 되돌릴 여지까지 준다.

배치 정규화의 이득은 셋이다. 학습이 빨라지고(큰 학습률을 써도 안정적), 초깃값 의존이 완화되며(He/Xavier에 덜 민감), 약한 정규화 효과(미니배치마다 통계가 흔들려 노이즈처럼 작용)까지 딸려 온다.

주의할 것은 train과 test에서 동작이 다르다는 점이다. 학습 때는 미니배치의 평균·분산으로 정규화하지만, 추론 때는 배치가 없거나 하나뿐일 수 있다. 그래서 학습 중 미리 이동평균으로 모아 둔 전체 평균·분산을 추론 때 쓴다. 이 전환을 빼먹으면 추론 결과가 배치 구성에 따라 흔들린다.

판단 기준: 깊은 망에서 학습이 불안정하거나 초깃값 튜닝이 번거로우면 배치 정규화를 넣어 큰 학습률로 밀어붙여 보라 — 수렴이 빨라지는 경우가 많다. 함정: train/test 모드 전환을 잊는 것 — 추론 때도 배치 통계를 쓰면 결과가 배치마다 달라진다. 드롭아웃과 함께 이 “모드에 따라 동작이 바뀌는 계층”은 반드시 플래그로 관리해야 한다.

오버피팅과 대책 — 가중치 감소와 드롭아웃

지금까지는 “학습이 되게” 하는 기술이었다. 이제 반대 문제 — 학습이 너무 잘 돼서 훈련 데이터에만 맞아 버리는 오버피팅(overfitting)을 다룬다. 오버피팅은 훈련 정확도는 100%에 가까운데 검증·시험 정확도는 한참 낮은 상태로 드러난다. 주로 매개변수가 많고 표현력이 높은데 훈련 데이터가 적을 때 생긴다.

가중치 감소(weight decay). 큰 가중치는 특정 입력에 과도하게 반응해 오버피팅을 부른다. 그래서 손실에 가중치 크기에 대한 벌점을 더해, 학습이 가중치를 키우지 못하게 누른다. L2 노름을 쓰면 손실에 를 더한다.

이 항의 미분은 이므로, 역전파 기울기에 가 더해진다 — 매 갱신에서 가중치를 자기 크기에 비례해 조금씩 0 쪽으로 당긴다(그래서 “감소”다). 계수는 바로 이 미분을 깔끔하게 만들려고 붙였다: 로 미분하면 가 내려와 와 상쇄되어 정확히 가 남는다. 가 크면 강하게 억제한다. 구현은 간단하다: 손실 계산에 벌점 항을 더하고, 각 가중치의 기울기에 를 더한다.

드롭아웃(dropout). 가중치 감소만으로 부족한, 복잡한 망에는 드롭아웃이 효과적이다. 학습 때 각 순전파마다 뉴런을 무작위로 일정 비율 꺼 버린다(출력을 0으로). 매번 다른 뉴런 조합으로 학습하니, 특정 뉴런에 과의존하지 못하고 견고한 특징을 학습하게 된다.

class Dropout:
    def __init__(self, dropout_ratio=0.5):
        self.dropout_ratio = dropout_ratio
        self.mask = None
 
    def forward(self, x, train_flg=True):
        if train_flg:
            # 매 순전파마다 새 마스크 — 살아남을 뉴런은 True
            self.mask = np.random.rand(*x.shape) > self.dropout_ratio
            return x * self.mask
        else:
            # 추론: 끄지 않되, 학습 때 꺼진 비율만큼 출력을 줄여 스케일을 맞춘다
            return x * (1.0 - self.dropout_ratio)
 
    def backward(self, dout):
        return dout * self.mask      # 꺼졌던 뉴런으로는 기울기도 흐르지 않는다

추론 때 를 곱하는 이유를 숫자로 짚자. 학습 때 비율 0.5로 끄면, 평균적으로 뉴런 절반만 살아 다음 층에 전달된다 — 다음 층이 받는 신호의 기댓값이 학습 때는 원래의 약 절반이다. 추론 때는 아무도 끄지 않으니 신호가 통째로 전달돼 학습 때보다 2배 커진다. 이 스케일 불일치를 맞추려고 추론 출력에 를 곱해 절반으로 줄인다. 학습과 추론에서 다음 층이 받는 신호의 크기를 같게 맞추는 보정이다.

드롭아웃의 깊은 의미는 앙상블 학습과의 연결이다. 앙상블은 여러 모델을 따로 학습시켜 예측을 평균 내는 기법인데, 드롭아웃은 매 순전파마다 다른 뉴런 조합(=다른 서브네트워크)을 학습시키므로 하나의 망 안에서 지수적으로 많은 서브네트워크를 앙상블하는 것과 유사하다. 추론 때 뉴런을 끄지 않고 출력에 를 곱하는 것은, 그 수많은 서브네트워크의 평균을 근사하는 셈이다.

판단 기준: 오버피팅 신호(훈련·검증 정확도 격차)가 보이면 먼저 가중치 감소를, 격차가 크고 망이 복잡하면 드롭아웃을 더한다. 두 정확도 곡선의 간격이 좁혀지는지로 효과를 확인하라. 함정: 드롭아웃·배치 정규화 모두 train과 test 동작이 다르다 — 추론 때 뉴런을 끄거나 스케일 보정을 빼먹으면 성능이 엉킨다. train_flg를 계층에 정확히 전파하는 것이 이 두 기법의 공통 급소다.

하이퍼파라미터 검증 — 시험 데이터를 오염시키지 않기

학습률, 가중치 감소 계수 , 층·뉴런 수, 배치 크기. 이런 값들은 학습으로 정해지지 않고 사람이 정한다 — 하이퍼파라미터다. 이걸 어떻게 고를 것인가. 핵심 원칙 하나가 나머지를 지배한다: 시험 데이터로 하이퍼파라미터를 조정하면 안 된다.

이유는 오염이다. 시험 데이터를 보며 하이퍼파라미터를 튜닝하면, 그 값들이 시험 데이터에 오버피팅된다. 그렇게 얻은 시험 정확도는 “본 적 없는 데이터에 대한 성능”이라는 원래 의미를 잃는다. 그래서 데이터를 셋으로 나눈다 — 훈련(가중치 학습) / 검증(validation, 하이퍼파라미터 조정) / 시험(최종 성능 확인, 마지막에 딱 한 번). 검증 데이터는 보통 훈련 데이터의 일부를 떼어 만든다.

탐색 방법으로는 격자 탐색(grid search)보다 무작위 탐색(random search) 이 흔히 권장된다. 하이퍼파라미터마다 중요도가 다른데, 격자 탐색은 덜 중요한 축에도 같은 수의 후보를 배정해 낭비한다. 무작위 탐색은 각 축을 독립적으로 무작위 샘플링하므로, 중요한 축을 더 다양하게 훑는다. 특히 학습률처럼 스케일이 넓은 값은 로그 스케일에서 뽑는다(예: 범위를 균등하게가 아니라 지수로).

대략의 절차는 이렇다. (1) 하이퍼파라미터 범위를 로그 스케일 등으로 대략 설정한다. (2) 그 범위에서 무작위로 값을 샘플링한다. (3) 그 값으로 작은 에폭만 학습해 검증 정확도를 평가한다(빠른 스크리닝). (4) 좋은 값 주변으로 범위를 좁혀 반복한다. 무한정 정밀하게 갈 필요 없이, 좋은 영역을 좁혀 가는 것이 목적이다.

판단 기준: 하이퍼파라미터는 반드시 검증 데이터로 고르고, 시험 데이터는 최종 보고 직전 단 한 번만 본다. 학습률처럼 범위가 넓은 값은 로그 스케일에서 샘플링하라. 함정: 시험 정확도를 보며 하이퍼파라미터를 반복 조정하는 것 — 정확도가 올라가는 것처럼 보여도 실은 시험 데이터에 오버피팅하는 것이고, 실제 배포 성능은 그 숫자보다 낮다. 검증과 시험의 경계를 흐리는 순간 모든 평가가 거짓이 된다.

정리 — 학습을 잘 되게 하는 손기술들

이 장의 기법들은 새 신경망이 아니라, 4·5장의 학습이 실제로 잘 되게 하는 조정 손잡이들이다. 각각은 손실 지형 위의 걸음을 다듬거나, 층의 분포를 살리거나, 오버피팅을 누른다.

  • 옵티마이저는 걸음의 방식이다. SGD는 비등방성 지형에서 지그재그로 헤맨다. Momentum은 관성으로 진동을 상쇄하고(부호가 같은 축은 가속, 뒤집히는 축은 상쇄 — 숫자로 확인했다), AdaGrad는 매개변수마다 학습률을 감쇠시키며, Adam은 둘을 합친다 — 넷 다 update 하나로 교체된다.
  • 가중치 초깃값의 스케일이 활성화 분포를 살리고 죽인다. 가중합 std는 배 증폭되므로, 0은 대칭성으로 학습 불가, 큰 값은 포화로 기울기 소실. sigmoid/tanh엔 Xavier(, 증폭을 상쇄), ReLU엔 He()를 쓰고 히스토그램으로 확인한다.
  • 배치 정규화는 초깃값에 의존하지 않고 층의 분포를 강제로 정규화해(미니배치를 평균 0·분산 1로), 학습을 가속하고 초깃값 민감도를 낮춘다. 단 train/test 동작이 다르다.
  • 오버피팅은 가중치 감소(L2 벌점으로 큰 가중치 억제)와 드롭아웃(무작위로 뉴런을 꺼 앙상블 효과)으로 누른다. 둘 다 train/test 동작 차이에 주의한다.
  • 하이퍼파라미터는 검증 데이터로 고른다. 시험 데이터로 조정하면 오염되어 평가가 거짓이 된다. 넓은 범위는 로그 스케일 무작위 탐색으로 좁혀 간다.
  • 관통하는 교훈: 이 기법들은 “이론적 최적”이 아니라 “실전에서 잘 되더라”에 뿌리를 둔다. 그래서 수식으로 이해하되 히스토그램·정확도 곡선으로 확인하는 것이, 이 장을 옳게 쓰는 방법이다.

다음 장은 지금까지의 완전연결(Affine) 신경망이 놓치는 것 — 이미지의 공간 구조 — 을 붙잡는 합성곱 신경망으로 넘어간다. 완전연결이 이미지를 1차원으로 뭉개 버렸다면, CNN은 형상을 유지한 채 학습한다.

다음장으로 7장