위치 인코딩은 순서를 어떻게 집어넣나
8편은 어텐션이 순열 등변이라는 것으로 끝났다. 입력 순서를 섞으면 출력도 똑같이 섞일 뿐, 값은 하나도 안 변한다. 어텐션에게 “앞” 과 “뒤” 는 없다.
문장에서 순서는 의미다. 그러니 순서를 따로 넣어 줘야 한다. 이번 편은 그 방법이다.
순진한 방법이 왜 안 되나
가장 먼저 떠오르는 건 위치 번호를 그냥 차원 하나로 붙이는 것이다. 토큰 i 의
벡터 뒤에 i 를 적는다.
해 보면 바로 망가진다. 어텐션 로짓은 내적이고, 내적에는 i × j 항이 그대로
들어간다.
위치 0~5 내용 기여 폭 3.30 위치 기여 폭 11.18 최대 확률 0.626
위치 100~105 내용 기여 폭 3.30 위치 기여 폭 458.39 최대 확률 1.000
내용이 만드는 로짓 차이는 3.30 인데 위치가 만드는 차이가 458 이다. 문장
뒤쪽으로 갈수록 이 비는 계속 벌어진다. 8편에서 로짓이 커지면 소프트맥스가
포화한다고 했는데, 여기서는 최대 확률이 1.000 이다. 어텐션이 내용을 아예
안 본다.
위치 정보에 필요한 조건이 여기서 나온다. 자리마다 달라야 하지만, 자리가 뒤라고 해서 더 커지면 안 된다.
사인파
트랜스포머가 쓴 답은 주기함수다. 차원을 둘씩 짝지어 각각 다른 주기의 사인·코사인을 넣는다.
def PE(L, d):
pos = np.arange(L)[:, None]
i = np.arange(0, d, 2)[None, :]
w = 1.0 / (10000 ** (i / d)) # 주파수, 차원마다 다르다
P = np.zeros((L, d))
P[:, 0::2] = np.sin(pos * w)
P[:, 1::2] = np.cos(pos * w)
return P
이게 무엇을 보장하는지 하나씩 재 본다. 아래는 전부 d=64, 위치 512개다.
첫째, 모든 자리의 크기가 같다. 주파수 하나마다 sin² + cos² = 1 이므로
노름은 자리와 무관하게 sqrt(d/2) 다. 측정하면 5.6569 이고 512개 자리에서
편차가 8.9e-16 이다. 순진한 방법이 깨뜨린 조건이 바로 이것이다.
둘째, 두 자리의 내적이 거리에만 의존한다. PE(i)·PE(j) 는 i 와 j 를
따로 보지 않고 i-j 만 본다. 같은 대각선 위 값들의 편차를 재면 1.8e-13 으로
부동소수점 잡음이다. 삼각함수 덧셈정리를 쓰면 이유가 한 줄로 나온다.
PE(i)·PE(j) = Σ [sin(i·w) sin(j·w) + cos(i·w) cos(j·w)] = Σ cos((i-j)·w)
실제로 Σ cos(거리 · w) 와 비교하면 최대 차이가 7.1e-15 다. 절대 위치를
넣었는데 상대 위치가 나온다. 이게 사인파를 고른 진짜 이유다.
거리가 멀수록 덜 닮는다는 말은 사실이 아니다
여기서 흔히 한 걸음 더 나간다 - “그래서 멀수록 내적이 작아지고, 가까운 토큰끼리 더 닮게 된다”. 재 보면 아니다.
가까운 거리에서는 맞다. 32.00, 30.92, 28.30, 25.59, 23.93, 23.50 으로 떨어진다.
그런데 거리 6에서 23.56 으로 다시 오른다. 511개 구간 중 244 곳에서 값이
커진다. 최소값은 거리 406 에서 2.56 이고, 그보다 먼 거리 511 에서는 오히려
6.42 로 올라가 있다.
당연하다. 주기함수를 더한 것이니 진동한다. 단조 감소하는 함수가 아니다. 사인파 위치 인코딩이 주는 것은 “가까울수록 닮음” 이 아니라 거리의 함수라는 사실 그 자체다. 그 함수가 거리에 대해 어떤 모양인지는 별개 문제이고, 보다시피 예쁘지 않다.
진짜 쓸모: 이동이 선형사상이다
주기함수를 고른 값어치는 다른 데 있다. 어떤 간격 k 를 정하면, 모든 자리에
대해 똑같이 작동하는 고정 행렬 M_k 가 있어서 PE(pos + k) = M_k · PE(pos)
가 된다. 자리마다 다른 행렬이 아니라 하나다.
각 주파수 짝이 [sin(pos·w), cos(pos·w)] 이니 k 만큼 미는 것이 각도
k·w 만큼의 회전이기 때문이다. 회전 행렬은 pos 에 안 달려 있다.
측정으로 확인한다. 위치 512개를 차원 64개에 맞추므로 미지수보다 식이 여덟 배 많다. 우연히 맞을 수 있는 상황이 아니다.
k 사인파 무작위 임베딩
1 4.9e-14 0.504
2 5.0e-14 0.489
5 4.8e-14 0.548
17 3.9e-14 0.501
50 1.6e-13 0.549
사인파는 잔차가 부동소수점 잡음이고, 같은 크기의 무작위 임베딩은 0.5 언저리다
- 상대오차로
0.898, 사실상 아무것도 못 맞춘다.
이 차이가 실질적이다. 어텐션이 “세 칸 앞” 같은 관계를 보려면 Q 와 K 를 만드는
W 가 그 관계를 표현할 수 있어야 하는데, W 가 하는 일은 선형변환이다.
사인파에서는 그 관계가 이미 선형사상으로 존재하므로 배우기만 하면 된다.
무작위로 자리마다 벡터를 하나씩 준 것에는 그런 사상이 아예 없다.
학습형 위치 임베딩이 못 쓸 것이라는 뜻은 아니다. 실제로 잘 쓰이고, 자리별 벡터를 데이터에서 배운다. 다만 이 구조를 거저 얻는 게 아니라 배워야 한다는 차이가 있다.
덤: 훈련 길이 밖
사인파는 식이라 아무 자리에나 값이 있다. 512개까지만 보고 학습해도 위치 4000의
노름은 5.6569 로 같고, 4000과 4001의 내적은 30.917 로 위치 0과 1의 값과
정확히 같다. 학습형 임베딩은 표에 없는 자리에 줄 값이 없다.
물론 값이 있다는 것과 모델이 그 구간에서 잘 동작한다는 것은 다른 얘기다. 실제로 훈련 길이를 넘기면 성능이 떨어지고, 그래서 요즘 모델은 회전 위치 인코딩처럼 다른 방식을 쓴다. 사인파가 보장하는 건 “정의된다” 까지다.
값은 치른다
공짜가 아니다. 위치를 더하면 그만큼 내용이 흐려진다. 8편의 토큰 여섯 개에 위치 인코딩을 더해 보면 첫 행이 이렇게 바뀐다.
t0 t1 t2 t3 t4 t5
위치 없음 0.510 0.030 0.015 0.389 0.039 0.016
위치 더함 0.610 0.213 0.020 0.140 0.012 0.004
t0 의 주제 짝은 t3 인데, 그 몫이 0.389 에서 0.140 으로 떨어졌다. 대신
바로 옆자리인 t1 이 0.030 에서 0.213 으로 올랐다. 위치가 내용과 경쟁한다.
이 예에서는 위치가 이겼는데, 임베딩 크기와 위치 인코딩 크기의 비가 그것을 정한다.
실제 모델은 임베딩을 sqrt(d) 배 키운 뒤 더해서 이 비를 조정한다.
그래도 목적은 이뤘다. 순열 등변성을 다시 재면 이제 깨져 있다.
Z = X + PE(6, d) # 원래 순서에 위치를 더한 것
Zp = X[perm] + PE(6, d) # 토큰만 섞고, 위치는 자리에 그대로 둔다
np.allclose(att(X[perm]), att(X)[perm]) # 위치 없음: True
np.allclose(att(Zp), att(Z)[perm]) # 위치 더함: False
Zp 를 만들 때 PE 를 섞지 않은 것이 핵심이다. 위치까지 같이 섞으면
att(Z[perm]) 은 다시 등변이 된다 - 자리표를 토큰에 붙여 들고 다니면 자리를
옮긴 게 아니기 때문이다.
그래서
- 어텐션에 순서를 넣으려면 자리마다 다르되 뒤 자리가 더 커지면 안 된다.
위치를 원시 정수로 붙이면 위치 100 근처에서 로짓 기여가
458대3.30으로 내용을 덮고 최대 확률이1.000이 된다 - 사인파는 모든 자리의 노름을
5.6569로 같게 두고, 두 자리의 내적을 거리만의 함수Σ cos(거리·w)로 만든다. 절대 위치를 넣었는데 상대 위치가 나온다 - 그 함수가 거리에 대해 단조 감소한다는 말은 틀렸다. 511개 구간 중 244곳에서 오히려 커지고 최소는 거리 406 이다
- 진짜 값어치는 이동이 고정 선형사상이라는 것이다. 잔차
5e-14대 무작위 임베딩0.5. 상대 위치 관계를W가 배우기만 하면 되는 형태로 놓아 준다 - 더하는 만큼 내용은 흐려진다. 주제 짝의 몫이
0.389에서0.140으로 떨어진다
다음 편에서는 머리를 여러 개로 쪼갠다. 어텐션 하나가 평균 하나를 낸다면, 여러 개는 무엇을 따로 보는지 실제로 꺼내 본다.
Comments