인지야공

인지야공/딥러닝 기초 정리/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₁]
00.00000.0000 0.0000, 1.0000, 0.0000, 1.0000
11.00000.0100 0.8415, 0.5403, 0.0100, 0.9999
22.00000.0200 0.9093, −0.4161, 0.0200, 0.9998
33.00000.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):

거리 k012358101520
내적16.0015.3113.7312.2711.7810.5210.068.4110.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원논문 Transformer0길이 외삽 가능, 공짜학습형보다 약간 낮다는 보고
(b) 학습형 절대위치BERT, GPT-2, ViTmax_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칸 이동 = 고정 회전행렬 곱” 이 되어 모델이 상대 위치를 선형변환 하나로 배울 수 있기 때문이다.


시리즈 문서

표시는 이 브라우저에만 남는다. 서버로 가는 것은 없다.