STEP 06 / 18

깊게 쌓아도 무너지지 않게: LayerNorm과 잔차 연결

이제 블록을 6층씩 쌓을 준비를 한다. 층이 깊어지면 신호가 점점 작아지거나 커져서 학습이 망가진다. 이를 막는 장치가 두 개 있다.

  • LayerNorm: 토큰 벡터 하나(512차원)를 평균 0, 표준편차 1로 맞춘 뒤 학습 가능한 스케일 a_2와 이동 b_2를 적용한다.
  • SublayerConnection: 서브층(어텐션이나 FFN)을 감싸서 x + dropout(sublayer(norm(x)))를 돌려준다. 서브층의 결과를 입력에 더하기 때문에 서브층이 아무것도 못 해도 입력은 그대로 통과한다.

논문 수식은 LayerNorm(x + Sublayer(x))(Post-LN)인데 코드는 정규화를 먼저 한다(Pre-LN). 원본 코드 주석에 "for code simplicity the norm is first"라고 적혀 있다. Pre-LN은 학습이 더 안정적이어서 요즘 LLM도 대부분 이 순서를 쓴다.

sublayer를 함수로 받는다는 점도 눈여겨보자. 다음 단계에서 어텐션은 lambda x: self.self_attn(x, x, x, mask) 형태로, FFN은 모듈 그대로 넘긴다.

실행 결과에서 볼 것

  • 정규화 뒤 평균 0, 표준편차 1.
  • 서브층이 0만 내놓아도 출력이 입력과 같다.
  • FFN 20개를 그냥 쌓으면 신호가 0.05 수준으로 쪼그라들지만, 잔차 연결과 정규화로 감싸면 크기가 유지된다.

코드 변화

+31 −0 · 파일 전체 150줄

이전 단계의 transformer.py와 비교했다. 초록 줄이 새로 생긴 줄, 빨간 줄이 사라진 줄이다.

GitHub에서 보기 ↗
117117 def forward(self, x):
118118 x = x + self.pe[:, : x.size(1)].requires_grad_(False)
119119 return self.dropout(x)
120+
121+
122+class LayerNorm(nn.Module):
123+ "Construct a layernorm module (See citation for details)."
124+
125+ def __init__(self, features, eps=1e-6):
126+ super(LayerNorm, self).__init__()
127+ self.a_2 = nn.Parameter(torch.ones(features))
128+ self.b_2 = nn.Parameter(torch.zeros(features))
129+ self.eps = eps
130+
131+ def forward(self, x):
132+ mean = x.mean(-1, keepdim=True)
133+ std = x.std(-1, keepdim=True)
134+ return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
135+
136+
137+class SublayerConnection(nn.Module):
138+ """
139+ A residual connection followed by a layer norm.
140+ Note for code simplicity the norm is first as opposed to last.
141+ """
142+
143+ def __init__(self, size, dropout):
144+ super(SublayerConnection, self).__init__()
145+ self.norm = LayerNorm(size)
146+ self.dropout = nn.Dropout(dropout)
147+
148+ def forward(self, x, sublayer):
149+ "Apply residual connection to any sublayer with the same size."
150+ return x + self.dropout(sublayer(self.norm(x)))

실행 결과

실행한 코드 demo.py 보기
1import torch
2
3from transformer import LayerNorm, SublayerConnection, PositionwiseFeedForward
4
5torch.manual_seed(0)
6
7x = torch.randn(2, 3, 8) * 5 + 3
8ln = LayerNorm(8)
9y = ln(x)
10print("LayerNorm은 토큰 하나(마지막 차원)마다 평균 0, 표준편차 1로 맞춘다")
11print(" 전 평균 :", [f"{v:.2f}" for v in x.mean(-1)[0].tolist()])
12print(" 후 평균 :", [f"{abs(v):.2f}" for v in y.mean(-1)[0].tolist()])
13print(" 후 표준편차:", [f"{v:.2f}" for v in y.std(-1)[0].tolist()])
14print(" 학습 파라미터:", [n for n, _ in ln.named_parameters()], "(스케일 γ, 이동 β)")
15print()
16
17sub = SublayerConnection(8, dropout=0.0)
18print("SublayerConnection: x + Sublayer(LayerNorm(x))")
19out = sub(x, lambda t: torch.zeros_like(t))
20print(" 서브층이 0만 내놓으면 출력 = 입력?", torch.allclose(out, x))
21print(" → 서브층이 아무것도 못 배워도 신호는 그대로 흐른다 (잔차 연결)")
22print()
23
24print("층을 깊게 쌓았을 때 신호 크기 (FFN 20개)")
25torch.manual_seed(0)
26ffns = [PositionwiseFeedForward(64, 256, dropout=0.0) for _ in range(20)]
27subs = [SublayerConnection(64, dropout=0.0) for _ in range(20)]
28h_plain = h_res = torch.randn(1, 5, 64)
29for f, s in zip(ffns, subs):
30 h_plain = f(h_plain)
31 h_res = s(h_res, f)
32print(f" 그냥 쌓기 : 표준편차 {h_plain.std():.2e}")
33print(f" 잔차 + 정규화: 표준편차 {h_res.std():.2e}")
LayerNorm은 토큰 하나(마지막 차원)마다 평균 0, 표준편차 1로 맞춘다
  전 평균  : ['0.59', '4.22', '2.29']
  후 평균  : ['0.00', '0.00', '0.00']
  후 표준편차: ['1.00', '1.00', '1.00']
  학습 파라미터: ['a_2', 'b_2'] (스케일 γ, 이동 β)

SublayerConnection: x + Sublayer(LayerNorm(x))
  서브층이 0만 내놓으면 출력 = 입력? True
  → 서브층이 아무것도 못 배워도 신호는 그대로 흐른다 (잔차 연결)

층을 깊게 쌓았을 때 신호 크기 (FFN 20개)
  그냥 쌓기    : 표준편차 4.59e-02
  잔차 + 정규화: 표준편차 1.50e+00