머신러닝·신경망

DL / 30번째 글

기울기 소실과 폭발: 깊은 네트워크의 고질적 문제

깊은 신경망 학습을 방해하는 기울기 소실(Vanishing Gradient)과 기울기 폭발(Exploding Gradient)의 수학적 원인을 이해한다. ReLU, 잔차 연결, 배치 정규화, Gradient Clipping 등 현대적 해결책을 코드와 함께 정리한다.

PALDYN Team37 MIN READ

지난 글에서 드롭아웃이 과적합을 막는 앙상블 정규화임을 배웠다. 이번 글에서는 깊은 신경망 학습의 가장 근본적인 난제를 다룬다. 기울기는 파라미터를 아주 조금 바꿨을 때 손실이 얼마나 변하는지를 나타내는 값이고, 역전파는 이 값을 출력 쪽에서 입력 쪽으로 층마다 곱해 가며 구한다. 그 곱이 입력 쪽에 닿기 전에 0에 가까워져 앞쪽 층이 더 배우지 못하는 현상이 기울기 소실(Vanishing Gradient)이고, 반대로 곱이 불어나 한 번의 갱신이 파라미터를 망가뜨리는 현상이 기울기 폭발(Exploding Gradient)이다.

두 문제는 1990년대 초에 분석되었다. 1991년 Hochreiter의 학위 논문과 1994년 Bengio 등의 논문이 순환 신경망에서 긴 거리의 기울기가 지수적으로 줄거나 늘어난다는 것을 보였고, 이것이 2000년대 중반까지 깊은 네트워크를 끝까지 학습시키기 어려웠던 큰 이유 중 하나였다. 오늘날 ResNet, LSTM, Transformer 같은 아키텍처는 모두 이 문제에 대한 답을 구조 안에 품고 있다. 이 글은 원인을 식으로 한 번 따라가고, 그것을 눈으로 확인하는 법을 본 뒤, 해결책 넷이 각각 곱의 어느 인수를 고치는지 살펴본다.

연쇄 곱

기울기 전개식

N층 네트워크에서 k번째 층은 앞 층의 출력 ak−1a_{k-1}을 받아 zk=Wkak−1+bkz_k = W_k a_{k-1} + b_k를 계산하고, 활성화 함수 ff를 거쳐 ak=f(zk)a_k = f(z_k)를 내놓는다. 손실 LL에서 첫 층의 가중치까지 기울기를 구하려면 연쇄 법칙, 곧 합성 함수의 미분은 각 단계 미분의 곱이라는 규칙을 층 수만큼 적용해야 한다.

∂L∂W1=∂L∂aN⋅∏k=2N∂ak∂ak−1⋅∂a1∂W1\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial a_N} \cdot \prod_{k=2}^{N} \frac{\partial a_k}{\partial a_{k-1}} \cdot \frac{\partial a_1}{\partial W_1}

가운데 곱의 인수 하나하나가 그 층의 로컬 기울기다. 층 하나를 지날 때 입력이 조금 흔들리면 출력이 얼마나 흔들리는지를 적은 행렬이고, 식으로는 활성화 미분과 가중치 행렬의 곱이다.

∂ak∂ak−1=diag(f′(zk)) Wk\frac{\partial a_k}{\partial a_{k-1}} = \mathrm{diag}\big(f'(z_k)\big)\, W_k

중요한 것은 모양이다. 첫 층에 도착하는 기울기는 N−1개의 행렬을 차례로 곱한 결과이고, 곱셈이 길게 이어지면 인수 하나하나가 1에서 조금만 벗어나도 결과는 지수적으로 멀어진다. 스칼라로 보면 0.9를 100번 곱하면 약 2.7×10−52.7 \times 10^{-5}이고 1.1을 100번 곱하면 약 13,781이다. 두 인수의 차이는 0.2인데 결과는 여덟 자리 넘게 벌어진다. 덧셈이 100번 이어졌다면 이런 일은 없었을 것이다. 뒤에 나올 해결책 가운데 가장 강력한 것이 바로 이 곱을 덧셈으로 바꾸는 발상이다.

시그모이드의 0.25

활성화 쪽 인수부터 보자. 시그모이드 σ(z)=1/(1+e−z)\sigma(z) = 1/(1+e^{-z})의 미분은 σ′(z)=σ(z)(1−σ(z))\sigma'(z) = \sigma(z)(1-\sigma(z))이고, 이 값은 z=0z = 0에서 가장 커서 0.25다. 가중치를 빼고 활성화 미분만 곱해도 기울기의 상한이 층마다 4분의 1씩 깎인다.

  • 5층이면 0.255≈9.8×10−40.25^{5} \approx 9.8 \times 10^{-4}
  • 10층이면 0.2510≈9.5×10−70.25^{10} \approx 9.5 \times 10^{-7}
  • 20층이면 0.2520≈9.1×10−130.25^{20} \approx 9.1 \times 10^{-13}

이것은 가장 좋은 경우다. zz가 0에서 멀어지면 시그모이드 곡선이 평평해져 미분이 더 작아진다. z=2z = 2에서 약 0.105, z=5z = 5에서 약 0.0066이다. 입력이 커서 출력이 0이나 1에 달라붙은 상태를 포화라고 부르는데, 포화된 뉴런은 입력을 바꿔도 출력이 거의 안 변하므로 그 뉴런을 지나는 기울기도 거의 0이 된다. tanh는 미분의 최대값이 1이라 시그모이드보다 낫지만 포화는 똑같이 겪는다.

가중치도 인수로 들어간다. PyTorch의 nn.Linear는 기본값으로 가중치를 ±1/fan_in\pm 1/\sqrt{\text{fan\_in}} 범위의 균등분포에서 뽑는다. 여기서 fan_in은 그 층이 받는 입력의 개수다. 입력이 64개면 범위가 ±0.125이고, 이런 행렬 하나를 지나면 벡터의 크기가 평균적으로 1/3≈0.58\sqrt{1/3} \approx 0.58배가 된다. 여기에 시그모이드 미분의 평균인 0.23 안팎을 곱하면 층 하나에 약 0.13배다. 20층이면 이 인수를 19번 곱하므로 0.1319≈2×10−170.13^{19} \approx 2 \times 10^{-17}, 열여섯에서 열일곱 자리가 사라진다.

특잇값과 갈림길

스칼라의 직관을 행렬로 옮기려면 행렬이 벡터를 몇 배로 늘리는지를 재야 한다. 행렬이 어떤 방향의 벡터든 최대로 늘리는 배율을 최대 특잇값(spectral norm)이라 부른다. 로컬 기울기 행렬들의 최대 특잇값이 꾸준히 1보다 작으면 곱은 0으로 줄어들고, 꾸준히 1보다 크면 곱은 폭발한다. 소실과 폭발은 두 개의 다른 병이 아니라 같은 곱이 1의 어느 쪽에 서 있느냐의 차이다.

곤란한 것은 갈림길이 정확히 1이라는 점이다. 층이 깊을수록 허용되는 띠가 좁아진다. 50층에서 층마다 0.98배면 끝에서 약 0.36배, 1.02배면 약 2.69배가 된다. 인수 차이 4%가 결과에서는 일곱 배 넘는 차이다. 100층이면 이 차이가 다시 제곱으로 벌어진다. 깊은 네트워크를 학습시킨다는 것은 결국 수십, 수백 개의 곱 인수를 동시에 1 근처에 붙잡아 두는 일이다.

기울기 소실과 폭발 문제

소실 관찰

층별 기울기 노름

식이 말하는 것을 실제로 확인하는 가장 쉬운 방법은 역전파를 한 번 돌리고 층마다 가중치 기울기의 크기를 재는 것이다. 한 층의 기울기 행렬을 벡터 하나로 펴서 잰 길이를 기울기 노름이라 하고, 층마다 이 값을 찍으면 기울기가 입력 쪽으로 가면서 어떻게 변하는지가 그대로 보인다.

import torch
import torch.nn as nn

def grad_norms(model, x):
    model.zero_grad()
    model(x).sum().backward()
    return [m.weight.grad.norm().item()
            for m in model if isinstance(m, nn.Linear)]

def mlp(act, depth=20, width=64):
    layers = []
    for _ in range(depth):
        layers += [nn.Linear(width, width), act()]
    return nn.Sequential(*layers)

x = torch.randn(32, 64)
for i, g in enumerate(grad_norms(mlp(nn.Sigmoid), x), 1):
    print(f"층 {i:2d}: {g:.1e}")
# 자릿수만 보면: 층 20 ≈ 1e+02, 층 10 ≈ 1e-06, 층 1 ≈ 1e-14

폭 64, 20층, 배치 32인 시그모이드 네트워크를 기본 초기화로 두고 이 계산을 따라가면, 출력 쪽 층 20의 노름은 10210^{2} 자리인데 층 15에서 10−210^{-2}, 층 10에서 10−610^{-6}, 층 1에서 10−1410^{-14} 자리까지 떨어진다. 층을 다섯 개 지날 때마다 네 자리씩 잃는 셈이고, 앞 절에서 어림한 층당 0.13배와 맞아떨어진다. 시드를 바꿔도 자릿수는 거의 같다. 정확한 값은 난수에 달려 있지만 기울기의 모양은 구조가 정하기 때문이다.

이 숫자가 실제로 무엇을 뜻하는지 학습률 10−310^{-3}을 곱해 보면 드러난다. 층 1의 가중치는 한 스텝에 10−1710^{-17} 자리만큼 움직이는데, float32는 유효 숫자가 일곱 자리 남짓이라 0.1짜리 가중치에 10−1710^{-17}을 더해도 값이 전혀 변하지 않는다. 계산은 매번 일어나지만 층 1은 초기값 그대로 얼어붙어 있다. 아래 그림의 세 곡선 가운데 맨 아래가 이 경우이고, 나머지 둘은 뒤의 해결책 절에서 다시 만난다.

20층 MLP의 층별 기울기 노름을 로그 눈금으로 비교한 그래프

멈춘 층 찾기

실제 학습에서는 한 번 찍고 끝내지 않고 스텝마다 기록한다. 층별 노름을 로그 눈금으로 그려 두면 소실은 층 번호를 따라 비스듬히 기울어진 선으로 나타나고, 폭발은 특정 스텝에서 모든 층이 한꺼번에 튀어 오르는 봉우리로 나타난다.

def log_gradient_norms(model, step, writer=None):
    total = 0.0
    for name, p in model.named_parameters():
        if p.grad is None:
            continue
        norm = p.grad.norm(2).item()
        total += norm ** 2
        if writer:
            writer.add_scalar(f"grad/{name}", norm, step)
    total = total ** 0.5
    if total < 1e-6:
        print(f"Step {step}: 기울기 소실 의심 ({total:.2e})")
    elif total > 100:
        print(f"Step {step}: 기울기 폭발 의심 ({total:.2e})")
    return total

코드의 두 임계값 10−610^{-6}과 100은 출발점일 뿐이고 모델마다 평상시의 크기가 다르다. 절대값보다 믿을 만한 것은 두 가지 비교다. 하나는 같은 스텝 안에서 층끼리의 비교로, 출력 쪽과 입력 쪽 노름이 몇 자리 차이 나는지를 본다. 다른 하나는 가중치 크기에 대한 갱신 크기의 비율이다. 한 스텝의 갱신량을 그 층 가중치의 노름으로 나눈 값이 10−310^{-3} 언저리면 건강하다는 경험칙이 자주 인용되는데, 어떤 층만 이 비율이 10−710^{-7} 아래로 떨어져 있다면 그 층은 사실상 학습에서 빠진 것이다. 시그모이드나 tanh를 쓰는 모델이라면 활성값 가운데 0이나 1 근처에 붙은 비율도 함께 기록해 두면 포화가 원인인지를 바로 가릴 수 있다.

학습 정체와의 구별

"손실이 안 떨어진다"는 증상은 기울기 소실 말고도 원인이 많다. 학습률이 너무 작을 수 있고, 라벨이 섞였거나 손실이 계산 그래프에서 떨어져 나간 버그일 수도 있다. 층별 노름은 이 셋을 서로 다른 모양으로 보여 준다.

  • 기울기 소실이면 출력 쪽 층의 노름은 정상이고 입력 쪽으로 갈수록 자릿수가 줄어든다. 층 번호를 따라 기울어 있다는 것이 핵심이다.
  • 학습률이 작으면 층별 노름은 고르게 정상인데 갱신 대 가중치 비율만 모든 층에서 작다.
  • 그래프가 끊긴 버그라면 기울기가 None이거나 모든 층에서 정확히 0이다.
  • 데이터나 라벨 문제라면 노름은 멀쩡한데 훈련 손실이 일정 수준 아래로 내려가지 않는다.

한 가지 더 확인할 수 있는 실험은 깊이를 줄여 보는 것이다. 같은 설정에서 층을 절반으로 줄였을 때 학습이 되기 시작하면 깊이 자체가 문제라는 강한 증거다. 반대로 얕은 모델도 똑같이 멈춘다면 원인은 기울기 흐름이 아닌 다른 곳에 있다.

기울기 폭발

NaN까지의 경로

폭발은 소실과 같은 곱의 반대편이다. 로컬 기울기의 최대 특잇값이 꾸준히 1보다 크면 곱은 지수적으로 커진다. 극단적으로 단순하게 크기 2짜리 선형 변환을 되풀이하면 다음과 같다.

x = torch.tensor([1.0])
W = torch.tensor([[2.0]])
for i in range(20):
    x = W @ x
# 5번째 3.2e+01, 10번째 1.0e+03, 20번째 1.0e+06

2를 20번 곱하면 1,048,576이다. 이 곱이 실제 학습을 망가뜨리는 경로는 몇 단계를 거친다. 큰 기울기가 큰 갱신을 만들고, 가중치가 커지면 다음 순전파의 활성값이 커지며, 그 활성값이 다시 더 큰 기울기를 만든다. 어느 순간 수가 표현 범위를 넘어 Inf가 되고, Inf에서 Inf를 빼거나 0에 Inf를 곱하는 연산이 NaN을 낳는다. NaN은 어떤 연산을 거쳐도 NaN이라 역전파 한 번이면 모든 파라미터로 번진다. 손실 곡선이 멀쩡하다가 한 스텝에 NaN으로 바뀌는 것이 이 경로의 끝이다.

혼합 정밀도 학습에서는 이 경로가 훨씬 짧다. float16의 최대값은 65,504라 2를 16번만 곱해도 넘친다. float32의 최대값이 약 3.4×10383.4 \times 10^{38}인 것과 비교하면 여유가 거의 없다. 순환 신경망은 폭발에 특히 취약한데, 같은 가중치 행렬을 시퀀스 길이만큼 되풀이해 곱하기 때문이다. 층마다 가중치가 다른 MLP와 달리 같은 인수가 반복되므로 특잇값이 1.1이면 길이 100에서 약 13,781배로 곧장 불어난다.

노름 클리핑과 값 클리핑

폭발에 대한 가장 직접적인 처방은 갱신 직전에 기울기의 크기를 잘라 내는 것이다. 방식은 둘이다. 노름 클리핑은 모든 파라미터의 기울기를 한 벡터로 보고 그 노름이 임계값 cc를 넘으면 벡터 전체를 c/∥g∥c/\lVert g \rVert배로 줄인다. 값 클리핑은 기울기의 원소 하나하나를 [−c,c][-c, c] 범위에 가둔다.

두 방식의 차이는 숫자 하나로 보인다. 기울기가 (3,4)(3, 4)이고 c=1c = 1이라 하자. 노름은 5이므로 노름 클리핑은 벡터를 5분의 1로 줄여 (0.6,0.8)(0.6, 0.8)을 만든다. 방향은 원래와 같은 53.1도다. 값 클리핑은 두 원소를 각각 1로 잘라 (1,1)(1, 1)을 만들고, 방향이 45도로 바뀐다. 기울기의 방향은 손실이 가장 빨리 줄어드는 쪽을 가리키는 정보라서, 크기만 줄이고 방향은 지키는 노름 클리핑이 대부분의 경우 기본값이다. Pascanu 등이 2013년에 순환 신경망 학습의 어려움을 분석하며 제안한 것도 노름 클리핑이었다.

노름 클리핑과 값 클리핑이 기울기 (3, 4)를 자르는 방식 비교

PyTorch에서는 nn.utils.clip_grad_norm_이 노름 클리핑을, nn.utils.clip_grad_value_가 값 클리핑을 맡는다. 앞의 것은 넘겨받은 모든 파라미터를 합쳐 노름을 재고, 자르기 전의 전체 노름을 돌려준다. 이 반환값을 그대로 기록하면 따로 계산하지 않아도 모니터링이 된다.

for batch, targets in dataloader:
    optimizer.zero_grad()
    out, _ = model(batch)
    loss = criterion(out, targets)
    loss.backward()
    total_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()

임계값 정하기

클리핑의 임계값은 모델과 손실의 크기에 따라 평상시 노름이 달라서 정답이 하나로 정해져 있지 않다. Transformer 학습 설정에서는 1.0이 흔히 보이지만, 그 값을 가져다 쓰기보다 자기 모델의 분포를 보고 정하는 편이 낫다.

  1. 클리핑 없이, 또는 아주 큰 임계값으로 수백 스텝을 돌리며 스텝마다 전체 노름을 기록한다.
  2. 초반의 요동이 가라앉은 구간에서 노름이 평소에 어느 범위에 있는지 본다.
  3. 임계값을 그 평소 범위보다 조금 위에 둔다. 가끔 튀는 스텝만 잘리고 보통 스텝은 건드리지 않게 하려는 것이다.
  4. 학습 중에 클리핑이 걸린 스텝의 비율을 함께 기록한다.

4번이 특히 중요하다. 임계값이 평소 노름보다 낮아 거의 모든 스텝에서 클리핑이 걸린다면, 그것은 폭발을 막는 것이 아니라 학습률을 몰래 낮춰 놓은 것과 같다. 반대로 한 번도 안 걸린다면 임계값이 쓸모없이 높거나 애초에 폭발이 없는 모델이다. 그리고 클리핑은 증상을 누르는 장치이지 원인을 고치는 장치가 아니다. 클리핑이 계속 크게 걸린다면 학습률, 초기화, 정규화를 먼저 의심해야 한다.

활성화와 초기화

ReLU의 통과 기울기

곱의 인수 가운데 활성화 쪽을 바꾸는 것이 첫 번째 해결책이다. ReLU는 max⁡(0,z)\max(0, z)이고 미분은 z>0z > 0이면 1, z≤0z \le 0이면 0이다. 켜진 뉴런은 기울기를 깎지 않고 그대로 통과시키므로, 시그모이드처럼 층마다 0.25 이하로 눌리는 일이 없고 양수 쪽에서는 포화도 없다. 이것이 2010년대 초에 ReLU가 깊은 네트워크의 기본 활성화가 된 이유다. 자세한 비교는 활성화 함수 글에 있다.

하지만 ReLU만 바꾸면 끝나지는 않는다. 앞 절의 20층 실험에서 시그모이드를 ReLU로만 바꾸고 기본 초기화를 그대로 두면, 층 20의 노름은 10110^{1}에서 10210^{2} 사이인데 층 1에서는 10−510^{-5} 자리까지 떨어진다. 시그모이드의 열여섯 자리보다는 훨씬 낫지만 여전히 일곱 자리가 사라진다. 활성화 인수는 고쳤는데 가중치 인수가 그대로이기 때문이다.

He 초기화

원인을 숫자로 보면 이렇다. 기본 초기화에서 가중치 분산은 1/(3⋅fan_in)1/(3 \cdot \text{fan\_in})이라 행렬 하나를 지나면 신호의 제곱 크기가 3분의 1이 된다. ReLU는 음수 절반을 0으로 만들어 제곱 크기를 다시 절반으로 줄이므로 층 하나에 6분의 1, 크기로는 1/6≈0.41\sqrt{1/6} \approx 0.41배다. 19번 곱하면 4×10−84 \times 10^{-8}로 실험의 일곱 자리와 맞는다.

He 초기화는 이 인수를 정확히 1로 맞추도록 가중치 분산을 고른다. 분산을 2/fan_in2/\text{fan\_in}으로 두면 행렬이 제곱 크기를 2배로 키우고 ReLU가 절반으로 줄여 곱이 1이 된다. 분자의 2가 ReLU가 버리는 절반을 되돌려 놓는 값이다. 입력 64개면 표준편차가 2/64≈0.177\sqrt{2/64} \approx 0.177이다.

relu_model = mlp(nn.ReLU)
for m in relu_model:
    if isinstance(m, nn.Linear):
        nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
        nn.init.zeros_(m.bias)
# 층 1부터 층 20까지 모두 같은 자릿수(1e+03 안팎)

초기화는 첫 층 하나가 아니라 모든 선형 층에 해야 한다. 이렇게 하면 앞의 그래프 맨 위 곡선처럼 20개 층의 노름이 모두 10310^{3} 자리 안에 머문다. 시그모이드나 tanh에는 Glorot과 Bengio가 2010년에 제안한 Xavier 초기화, 곧 분산 2/(fan_in+fan_out)2/(\text{fan\_in} + \text{fan\_out})이 같은 역할을 한다. 두 식이 어디서 나오는지는 가중치 초기화 글에서 분산 식으로 따라갔다.

죽은 ReLU

ReLU에도 약점이 있다. 어떤 뉴런의 입력이 모든 데이터에서 음수가 되면 출력과 미분이 항상 0이라 그 뉴런으로 들어오는 가중치는 다시는 갱신되지 않는다. 이렇게 영구히 꺼진 뉴런을 죽은 ReLU라 부른다. 학습률이 커서 한 번의 큰 갱신이 편향을 크게 음수로 밀어 버릴 때 잘 생긴다. 음수 쪽에도 작은 기울기를 남기는 Leaky ReLU(PyTorch 기본 기울기 0.01)나 ELU, GELU가 이 문제를 누그러뜨린다.

더 근본적인 한계도 있다. He 초기화는 학습을 시작하는 순간의 곱을 1로 맞출 뿐이다. 학습이 진행되면 가중치가 바뀌면서 층마다 인수가 1에서 조금씩 벗어나고, 층이 수십 개를 넘으면 그 작은 어긋남이 다시 곱으로 불어난다. 그래서 초기화만으로는 20층 남짓이 한계였고, 그보다 깊은 네트워크에는 구조 자체를 바꾸는 방법이 필요했다.

잔차 연결

항등 경로의 기울기

잔차 연결(Residual Connection)은 층이 입력을 변환한 결과에 입력 자신을 더하는 구조다. 블록의 출력이 y=x+f(x)y = x + f(x)가 되고, 이 블록의 로컬 기울기는 다음과 같다.

∂y∂x=I+∂f∂x\frac{\partial y}{\partial x} = I + \frac{\partial f}{\partial x}

앞 절까지 로컬 기울기는 diag(f′)W\mathrm{diag}(f')W 하나뿐이었는데, 여기에 항등 행렬 II가 더해졌다. 블록 50개를 쌓으면 곱은 ∏(I+Jk)\prod (I + J_k)가 되고, 이것을 전개하면 각 블록에서 II를 고르거나 JkJ_k를 고르는 2502^{50}개 경로의 합이 된다. 그 가운데 모든 블록에서 II만 고른 경로의 값은 정확히 1이다. 출력에서 입력까지 아무것도 곱하지 않고 기울기를 그대로 나르는 이 길을 항등 경로라 부른다.

스칼라로 비교해 보자. 층마다 인수 0.5를 50번 곱하면 약 8.9×10−168.9 \times 10^{-16}이다. 잔차 블록에서 가지의 미분이 0 근처의 작은 값이면 인수가 1 근처에 머물러 곱도 1 근처에 남는다. 다만 이것이 "항상 최소 1을 보장한다"는 뜻은 아니다. 가지의 미분이 −1에 가까우면 두 항이 상쇄될 수 있고, 가지의 미분이 0.5씩이면 1.550≈6.4×1081.5^{50} \approx 6.4 \times 10^{8}으로 오히려 폭발한다. 그래서 실제 구현은 잔차 가지를 작은 값에서 출발시키고 정규화로 그 크기를 붙잡아 둔다. 잔차 연결은 곱을 덧셈 쪽으로 옮겨 놓는 것이고, 덧셈의 항들이 작게 유지될 때 가장 잘 작동한다.

ResNet의 깊이

잔차 연결을 널리 알린 것은 He 등이 2015년에 발표한 ResNet이다. 이 논문의 출발점은 흥미롭게도 기울기 소실이 아니었다. 저자들은 잔차 연결이 없는 평범한 네트워크에서 34층이 18층보다 훈련 오차가 더 높다는 것을 관찰했다. 테스트 오차가 아니라 훈련 오차라서 과적합으로는 설명되지 않는다. 이 성능 저하(degradation) 현상에 대해 저자들은 배치 정규화를 썼으므로 기울기 소실이 원인일 가능성은 낮다고 적었고, 대신 깊은 네트워크가 항등 함수조차 배우기 어려운 최적화 문제라고 보았다.

잔차 연결은 이 문제를 뒤집는다. 가지 f(x)f(x)가 0이면 블록 전체가 항등 함수이므로, 새 층을 쌓아도 최소한 얕은 네트워크만큼은 할 수 있는 상태에서 출발한다. 층은 입력을 통째로 새로 만드는 대신 입력에 더할 차이, 곧 잔차만 배우면 된다. 이 구조로 ResNet은 ImageNet에서 152층을 학습시켰고, CIFAR-10에서는 1,000층이 넘는 모델도 학습이 되는 것을 보였다. 다만 그 초깊은 모델은 110층보다 테스트 성능이 낮았는데, 저자들은 과적합 탓으로 보았다. 학습은 되지만 깊을수록 무조건 좋아지는 것은 아니라는 뜻이다.

Pre-LN 블록

잔차 블록 안에서 정규화를 어디에 두느냐가 항등 경로를 지키는지를 가른다. 처음 Transformer 논문은 더한 뒤에 정규화하는 Post-LN, 곧 LN(x+f(x))\mathrm{LN}(x + f(x))를 썼다. 이 배치에서는 정규화가 주 경로 위에 놓여 블록마다 기울기가 정규화 층을 한 번씩 통과해야 하므로 항등 경로가 온전하지 않다. 가지 안쪽에서 먼저 정규화하는 Pre-LN, 곧 x+f(LN(x))x + f(\mathrm{LN}(x))는 더하기 경로에 아무것도 끼지 않아 항등 경로가 끝까지 살아 있다. GPT-2 이후의 대형 언어 모델 대부분이 Pre-LN을 택했고, Post-LN은 학습률 워밍업 없이는 초반 학습이 불안정하다는 분석도 있다.

class PreLNBlock(nn.Module):
    def __init__(self, dim, dropout=0.1):
        super().__init__()
        self.norm = nn.LayerNorm(dim)
        self.ff = nn.Sequential(
            nn.Linear(dim, 4 * dim), nn.GELU(),
            nn.Dropout(dropout), nn.Linear(4 * dim, dim),
        )

    def forward(self, x):
        return x + self.ff(self.norm(x))   # 더하기 경로에는 아무것도 없다

deep = nn.Sequential(
    nn.Linear(784, 256),
    *[PreLNBlock(256) for _ in range(50)],
    nn.LayerNorm(256), nn.Linear(256, 10),
)

Pre-LN에서는 블록을 지날수록 주 경로의 값이 누적되어 커지므로 마지막 출력 전에 정규화를 한 번 더 두는 것이 보통이다. 위 코드의 끝에 LayerNorm이 하나 더 있는 이유다.

게이트와 정규화

LSTM 셀 상태

잔차 연결과 같은 발상은 순환 신경망에서 먼저 나왔다. LSTM은 은닉 상태와 따로 셀 상태 ctc_t라는 통로를 두고, 이 통로를 곱이 아니라 덧셈으로 갱신한다.

ct=ft⊙ct−1+it⊙gtc_t = f_t \odot c_{t-1} + i_t \odot g_t

여기서 ftf_t는 이전 셀 상태를 얼마나 남길지 정하는 망각 게이트, iti_t는 새 정보를 얼마나 들일지 정하는 입력 게이트, gtg_t는 새로 들일 후보 값이다. 셀 상태를 따라 한 시점 거슬러 올라갈 때의 직접 경로 기울기는 ∂ct/∂ct−1=diag(ft)\partial c_t / \partial c_{t-1} = \mathrm{diag}(f_t)다. 가중치 행렬도 활성화 미분도 없이 게이트 값 하나만 곱해진다.

숫자로 보면 차이가 크다. 망각 게이트가 0.9면 100시점 뒤에 약 2.7×10−52.7 \times 10^{-5}만 남지만, 0.99면 약 0.37이 남는다. 게이트 값은 모델이 시점마다 스스로 정하므로, 오래 기억해야 할 정보에 대해서는 게이트를 1 가까이 열어 기울기를 먼 과거까지 보낼 수 있다. 1997년 Hochreiter와 Schmidhuber의 원래 LSTM에는 망각 게이트가 없어서 사실상 이 값이 1로 고정된 통로였고, 망각 게이트는 2000년에 Gers 등이 더했다. 게이트 구조는 LSTM 글에서 자세히 다룬다.

배치·레이어 정규화

정규화는 층의 입력을 평균 0, 분산 1 근처로 되돌려 놓는 연산이다. 배치 정규화는 미니배치 안의 샘플들을 모아 특성마다 통계를 내고, 레이어 정규화는 샘플 하나 안의 특성들로 통계를 낸다. 배치 크기에 기대지 않는 레이어 정규화가 순환 신경망과 Transformer에서 쓰인다.

기울기 문제에 대한 기여는 두 가지다. 첫째, 활성화 함수에 들어가는 값이 0 근처에 머물러 시그모이드나 tanh가 포화 구간으로 밀려나지 않는다. 앞에서 본 z=5z = 5의 미분 0.0066 같은 인수가 생기지 않는다는 뜻이다. 둘째, 층마다 신호의 크기를 다시 맞추므로 학습 도중 가중치가 변해도 층당 배율이 1에서 크게 벗어나지 않는다. 초기화가 출발점에서만 해 주던 일을 매 스텝 되풀이하는 셈이다.

배치 정규화가 왜 잘 듣는지에 대한 설명은 하나로 정해지지 않았다. 원 논문은 층 입력 분포가 계속 바뀌는 현상을 줄인다고 설명했지만, 이후 연구는 손실 지형을 더 매끄럽게 만드는 효과가 더 중요하다고 주장하기도 했다. 기울기 크기를 안정시킨다는 관찰 자체는 여러 설명이 공유한다. 계산 절차는 배치 정규화 글에 있다.

조합표

지금까지의 해결책을 곱의 어느 인수를 고치는지로 정리하면 다음과 같다.

문제 해결책 고치는 곳 적용된 아키텍처
소실 ReLU·GELU 활성화 활성화 미분 인수 거의 모든 현대 네트워크
소실 He·Xavier 초기화 출발점의 가중치 인수 거의 모든 현대 네트워크
소실 잔차 연결 곱을 덧셈 경로로 우회 ResNet, Transformer
소실 배치·레이어 정규화 학습 중 층당 배율 ResNet, Transformer
소실·폭발 LSTM·GRU 게이트 시간 축 곱을 게이트 하나로 순환 신경망
폭발 노름 클리핑 갱신 직전의 기울기 크기 RNN, Transformer 학습

표를 세로로 읽으면 현대 네트워크가 한 가지 방법에 기대지 않는다는 것이 보인다. Transformer 블록 하나에만 GELU, 신중한 초기화, 잔차 연결, 레이어 정규화가 함께 들어 있고, 학습 루프에는 노름 클리핑이 붙는다. 기울기 소실과 폭발은 완전히 "해결"된 것이 아니라 이 기법들의 조합으로 관리되는 것이고, 그중 하나를 빼면 문제는 곧바로 돌아온다. 새 모델이 이유 없이 학습되지 않을 때 층별 기울기 노름을 먼저 찍어 보는 습관이 여전히 유효한 까닭이다.

다음 글은 순환 신경망(RNN)이다. 층을 깊이 쌓는 대신 같은 가중치를 시간 축으로 되풀이해 곱하는 구조라, 이 글에서 본 연쇄 곱이 시퀀스 길이만큼 그대로 돌아온다. RNN이 무엇을 계산하는지, 그리고 그 곱이 왜 LSTM으로 이어졌는지를 따라간다.


읽어주셔서 감사합니다. 😊

LATEST

머신러닝·신경망의 최신 글

머신러닝·신경망2026.05.08

문맥적 임베딩: ELMo부터 BERT까지

정적 임베딩의 다의어 문제를 해결하는 문맥적 임베딩의 원리, ELMo의 양방향 LSTM 레이어 표현, BERT의 트랜스포머 기반 서브워드 임베딩 추출법을 수식과 코드로 완전히 해설한다.

12 MIN
머신러닝·신경망2026.05.08

FastText: 부분 단어로 OOV를 정복하다

FastText가 문자 n-gram 기반의 부분 단어 모델로 OOV 문제를 해결하는 방법, 한국어 형태론에서의 강점, 실전 학습과 추론 코드를 완전히 해설한다.

11 MIN
머신러닝·신경망2026.05.08

GloVe: 전역 공기 통계로 단어 벡터를 만들다

GloVe가 공기 행렬의 전역 통계와 국소 문맥 창의 장점을 결합하는 방법, 목적 함수의 수학적 의미, 사전 학습 벡터 활용법을 깊이 있게 다룬다.

11 MIN