인지야공/딥러닝 기초 정리/8번째 글
위치 인코딩 — 어텐션이 잃어버린 순서를 되돌려 놓는 법
실행:
python 05_positional_encoding.py아래 숫자는 전부 그 스크립트의 실제 출력값입니다.
0. 한 줄 요약
어텐션은 순서를 모른다. 그래서 토큰 벡터에 “몇 번째 자리인가”를 더해 준다.
1. 왜 필요한가 — 어텐션은 순서를 모른다 (증명)
nn.MultiheadAttention 에 토큰을 뒤섞어 넣어 보면:
원본 출력을 섞은 것 vs 섞은 입력의 출력
최대 차이 = 1.34e-07 ← 부동소수점 오차. 사실상 0
차이가 0이다 = 입력을 섞으면 출력도 똑같이 섞일 뿐, 값 자체는 하나도 안 변한다. 이 성질을 permutation equivariant(순열 등변) 라고 한다.
즉 어텐션만으로는
- “나는 밥을 먹었다”
- “밥은 나를 먹었다”
를 원리적으로 구분할 수 없다.
RNN/LSTM 은 한 스텝씩 순서대로 읽으니 순서가 공짜였다. 어텐션은 전부 한 번에 보는 대가로 순서를 잃었고, 그래서 따로 넣어 줘야 한다. (→ LSTM 완전 풀이 — 초등학생도 따라오는 계산 과정, Attention 은 어떻게 계산되는가 — Q·K·V 와 병렬 계산의 원리)
2. 사인/코사인 공식
PE(pos, 2i) = sin( pos / 10000^(2i/d_model) ) ← 짝수 번째 차원
PE(pos, 2i+1) = cos( pos / 10000^(2i/d_model) ) ← 홀수 번째 차원
| 기호 | 의미 |
|---|---|
pos | 토큰의 위치 (0, 1, 2, …) |
i | 차원 쌍의 번호 (0 ~ d_model/2 − 1) |
10000^(2i/d) | 그 쌍이 도는 속도(주기) |
직관: 차원 쌍마다 주기가 다른 시계바늘을 하나씩 달아 두고, 바늘 각도의 조합으로 위치를 표현한다. 2진수로 숫자를 표현하는 것과 같되, 0/1 대신 연속적인 각도를 쓰는 버전이다.
차원별 파장 (d_model = 4)
차원쌍 i=0 : 각속도 1/10000^(0/4) = 1.000000 → 파장 6.28 토큰 (초침)
차원쌍 i=1 : 각속도 1/10000^(2/4) = 0.010000 → 파장 628.32 토큰 (시침)
손계산 (d_model = 4)
| pos | 각도 i=0 (pos/1) | 각도 i=1 (pos/100) | PE 벡터 [sin₀, cos₀, sin₁, cos₁] |
|---|---|---|---|
| 0 | 0.0000 | 0.0000 | 0.0000, 1.0000, 0.0000, 1.0000 |
| 1 | 1.0000 | 0.0100 | 0.8415, 0.5403, 0.0100, 0.9999 |
| 2 | 2.0000 | 0.0200 | 0.9093, −0.4161, 0.0200, 0.9998 |
| 3 | 3.0000 | 0.0300 | 0.1411, −0.9900, 0.0300, 0.9996 |
빠른 차원(i=0)은 pos마다 값이 확 바뀌고, 느린 차원(i=1)은 거의 그대로인 게 핵심이다.
구현 — log 공간에서 계산하는 이유
def sinusoidal_pe(max_len, d_model):
pe = torch.zeros(max_len, d_model)
pos = torch.arange(max_len).float().unsqueeze(1) # (L,1)
div = torch.exp(torch.arange(0, d_model, 2).float()
* (-math.log(10000.0) / d_model)) # (d/2,)
pe[:, 0::2] = torch.sin(pos * div) # 짝수 차원
pe[:, 1::2] = torch.cos(pos * div) # 홀수 차원
return pe
10000 ** (2i/d) 를 그대로 쓰면 d가 클 때 큰 수의 거듭제곱에서 정밀도가 깨진다.
그래서 exp(−log(10000)·2i/d) 로 곱셈을 덧셈으로 바꿔 안정적으로 계산한다.
3. 성질 ① — 값이 −1~1, 위치마다 다른 벡터
전체 최소/최대 : −0.99999 / 1.0 → 임베딩을 망가뜨리지 않는 크기
각 위치 벡터의 norm (d=32) : 4.0, 4.0, 4.0, 4.0, 4.0
이론값 √(d_model/2) = √16 = 4.0 → 모든 위치가 정확히 같은 길이
서로 다른 두 위치 사이 최소 거리 : 1.1716 → 0이 아님 = 위치마다 전부 다른 벡터
norm이 항상 √(d/2)인 이유: sin²+cos²=1 인 쌍이 d/2개 있으므로 제곱합이 정확히 d/2.
→ 어떤 위치든 똑같은 세기로 더해진다. 앞쪽 토큰만 세게 흔들리는 일이 없다.
4. 성질 ② — 가까운 위치일수록 비슷하다
pos=25 기준, 거리별 내적 (최댓값 = d_model/2 = 16):
| 거리 k | 0 | 1 | 2 | 3 | 5 | 8 | 10 | 15 | 20 |
|---|---|---|---|---|---|---|---|---|---|
| 내적 | 16.00 | 15.31 | 13.73 | 12.27 | 11.78 | 10.52 | 10.06 | 8.41 | 10.47 |
거리 0에서 최대이고 멀어질수록 전체적으로 감소한다.
주의 — 완전한 단조감소가 아니다. 사인파들의 합이라 멀리서는 약간 진동한다 (위 표에서 거리 15 → 8.41 이후 거리 20 → 10.47 로 되오름). 핵심은 “가까울수록 확실히 크다” 는 국소적 성질이며, 모델은 이 신호로 인접/원거리를 구분한다.
5. 성질 ③ — PE(pos+k)는 PE(pos)의 회전 ★가장 중요★
삼각함수 덧셈정리
sin(a+b) = sin a · cos b + cos a · sin b
cos(a+b) = cos a · cos b − sin a · sin b
를 쓰면, [sin, cos] 쌍이 k칸 이동하는 것은 고정된 회전행렬을 곱하는 것과 같다:
[ sin(ω(p+k)) ] [ cos(ωk) sin(ωk) ] [ sin(ωp) ]
[ cos(ω(p+k)) ] = [ −sin(ωk) cos(ωk) ] [ cos(ωp) ]
회전행렬이 pos와 무관하게 k에만 의존한다.
→ 모델이 “3칸 뒤” 같은 상대 관계를 단 하나의 선형변환으로 배울 수 있다.
어텐션의 Q/K 투영이 바로 그 선형변환이므로 학습이 쉬워진다.
이것이 수많은 함수 중 sin/cos 을 고른 진짜 이유다.
코드로 검증
k=3 회전행렬 M을 만들어 M @ PE(pos) 와 PE(pos+3) 을 비교:
pos = 0..19 전체 최대 오차 = 1.19e-07 ← 정확히 성립
M @ PE(5) = [0.98936, −0.14550, 0.71736, 0.69671]
PE(8) = [0.98936, −0.14550, 0.71736, 0.69671]
6. 실제 사용법 — 임베딩에 더한다.
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(dropout)
pe = sinusoidal_pe(max_len, d_model).unsqueeze(0) # (1, max_len, d)
self.register_buffer("pe", pe) # 학습 대상 아님. state_dict 에는 저장됨
def forward(self, x): # x: (B, N, d_model)
return self.dropout(x + self.pe[:, :x.size(1)])
register_buffer 를 쓰는 이유: 학습 파라미터는 아니지만 .to(device) 로 같이 옮겨지고
state_dict 에 저장돼야 하므로. (self.pe = ... 로 그냥 두면 GPU로 안 따라간다.)
왜 concat 이 아니라 덧셈인가?
| 덧셈 (실제 방식) | concat | |
|---|---|---|
| 차원 | d_model 유지 | d_model 증가 → 모든 가중치가 커짐 |
| 비용 | 0 | 파라미터·연산 증가 |
| 원리 | 고차원에서는 서로 다른 주파수 성분이 거의 직교해서 섞이지 않고 공존 | — |
d_model이 충분히 크면 어텐션이 “의미 성분”과 “위치 성분”을 알아서 분리해 읽는다.
효과 확인
같은 단어 id=5 가 0번 자리와 3번 자리에 있을 때:
위치인코딩 전 두 벡터 차이 : 0.0 ← 완전히 동일. 구별 불가
위치인코딩 후 두 벡터 차이 : 1.9900 ← 구별됨
학습 파라미터 수 : 0 ← 공짜
순서를 바꿨을 때 어텐션 출력이 달라지는지:
PE 없이 : 차이 0.000000 ← 순서를 구분 못 함
PE 있음 : 차이 0.190533 ← 순서를 구분함
실험 설계 함정: 반드시 토큰을 먼저 섞고 그 다음에 PE를 더해야 한다. PE를 더한 뒤에 섞으면 위치정보까지 같이 섞여서 아무 의미 없는 실험이 된다.
√d_model 곱하기
e = tok_emb(ids) * math.sqrt(D) # 원논문 방식
z = pos_enc(e)
임베딩 초기값은 표준편차가 작은데 PE는 진폭이 −1~1로 고정이다. 스케일을 안 맞추면 단어 정보가 위치 정보에 묻힌다. (→ Transformer 각 층 상세 해부 ① 참조)
7. 다른 방식들 — 학습형 / 상대위치 / RoPE
| 방식 | 대표 모델 | 파라미터 | 장점 | 단점 |
|---|---|---|---|---|
| (a) 고정 sin/cos | 원논문 Transformer | 0 | 길이 외삽 가능, 공짜 | 학습형보다 약간 낮다는 보고 |
| (b) 학습형 절대위치 | BERT, GPT-2, ViT | max_len × d | 데이터에 최적화, 구현 최단순 | max_len 초과 길이는 값 자체가 없음 |
| (c) 상대위치 편향 | T5, Swin | 소량 | 위치를 ‘관계’로 다뤄 길이 일반화 우수 | 어텐션 내부 수정 필요 |
| (d) RoPE (회전) | LLaMA, Qwen, 최신 LLM 대부분 | 0 | 내적이 상대거리에만 의존, 길이 확장에 강함 | 구현이 조금 더 복잡 |
# (b) 학습형 — 그냥 Embedding 을 학습시킨다
learned = nn.Embedding(512, 32) # 파라미터 16,384개
# (c) 상대위치 편향 — 어텐션 점수에 거리별 학습 편향을 더한다
scores = Q @ K.T / sqrt(d_k) + b[i - j]
RoPE — 더하지 않고 회전시킨다
임베딩에 더하는 대신, Q와 K를 위치 각도만큼 회전시킨다.
def rope(x, pos, base=10000.0):
d = x.shape[-1]
i = torch.arange(0, d, 2).float()
theta = pos / (base ** (i / d)) # (d/2,)
cos, sin = torch.cos(theta), torch.sin(theta)
x1, x2 = x[..., 0::2], x[..., 1::2]
out = torch.empty_like(x)
out[..., 0::2] = x1 * cos - x2 * sin # 2D 회전
out[..., 1::2] = x1 * sin + x2 * cos
return out
검증 — 같은 q, k 를 여러 (m, n) 위치에 두고 내적:
| (m, n) | 상대거리 n−m | 내적 |
|---|---|---|
| (0, 0) / (5, 5) / (100, 100) | 0 | −9.243921 / −9.243920 / −9.243921 |
| (0, 3) / (5, 8) / (100, 103) | 3 | −7.272338 / −7.272337 / −7.272328 |
| (0, 10) / (50, 60) | 10 | −11.489014 / −11.489010 |
절대 위치가 0이든 100이든, 상대거리가 같으면 내적이 같다.
회전행렬은 직교행렬이라 ⟨R_m q, R_n k⟩ = ⟨q, R_(n−m) k⟩ 가 되기 때문이다.
→ 이것이 RoPE가 ‘상대 위치 인코딩’인 이유이며, 학습 때보다 긴 문맥으로 확장하기 쉬운 이유다.
8. 위치 인코딩 표를 눈으로 (텍스트 히트맵)
가로 = 차원 015, 세로 = 위치 023 (값 −1 → ' ', +1 → '@')
0123456789012345
p 0 +@+@+@+@+@+@+@+@
p 1 @#*@+@+@+@+@+@+@
p 2 @:#@+@+@+@+@+@+@
p 3 + @#*@+@+@+@+@+@
p 4 ..@**@+@+@+@+@+@
p 5 *@=#@+@+@+@+@+@
p 6 -@@-#@+@+@+@+@+@
p 7 %%@:%%*@+@+@+@+@
p 8 @=# %%*@+@+@+@+@
p 9 # * %%*@+@+@+@+@
p10 : = @#*@+@+@+@+@
p11 +- @#*@+@+@+@+@
p12 :@..@**@+@+@+@+@
p13 #@ :@**@+@+@+@+@
p14 @+ -@+#@+@+@+@+@
p15 %. +@+#@+@+@+@+@
p16 - *@=#@+@+@+@+@
p17 -.%@=#@+@+@+@+@
p18 .%:@@-#@+@+@+@+@
p19 +@-@@-#@+@+@+@+@
p20 @#+@@:#@+@+@+@+@
p21 @:*@@:%%*@+@+@+@
p22 = %%@:%%*@+@+@+@
p23 :@#%.%%*@+@+@+@
- 왼쪽 차원(i 작음) = 주기가 짧아 빠르게 깜빡임 → 바로 옆 칸 구분 담당
- 오른쪽 차원(i 큼) = 주기가 매우 길어 거의 안 변함 → 문장 앞/중간/뒤 같은 큰 위치 담당
- 오른쪽으로 갈수록 패턴이
+@+@+@로 굳어지는 게 보인다 (파장이 수백~수만 토큰이라 24칸 안에서는 거의 상수)
= 시침 / 분침 / 초침을 한꺼번에 붙여 놓은 시계.
한 장 요약
| 항목 | 내용 |
|---|---|
| 왜 필요 | 어텐션은 permutation equivariant → 순서를 전혀 모름 (실측 차이 1.34e-07) |
| 공식 | 짝수 차원 sin(pos/10000^(2i/d)), 홀수 차원 cos(...) |
| 직관 | 주기가 다른 시계바늘 d/2개의 각도 조합 = 연속판 2진수 |
| 성질 ① | 값 −1~1, 모든 위치 norm이 √(d/2)로 동일 |
| 성질 ② | 가까울수록 내적 큼 (국소적으로만, 멀리서는 약간 진동) |
| 성질 ③ | PE(pos+k) = R(k) · PE(pos) — 상대위치가 선형변환 하나 ★핵심★ |
| 사용법 | 임베딩(×√d)에 더한다 register_buffer, 학습 파라미터 0개 |
| 현대 표준 | RoPE — Q/K를 회전시켜 내적이 상대거리에만 의존 |
한 문장 요약:
위치 인코딩 = 주기가 다른 sin/cos 시계바늘 묶음을 임베딩에 더하는 것. sin/cos 을 고른 이유는 “k칸 이동 = 고정 회전행렬 곱” 이 되어 모델이 상대 위치를 선형변환 하나로 배울 수 있기 때문이다.