트랜스포머 — 어텐션을 손으로 계산한다 · 회전 위치 임베딩(RoPE) · 이론
위치를 더하지 않고 돌린다
한 줄 요약
자리를 벡터에 더하는 대신 벡터를 자리만큼 회전시키면, 질의와 키의 내적이 두 자리의 차이에만 의존한다. 3번과 5번에서 잰 값과 4000번과 4002번에서 잰 값이 같아진다.
왜 이게 필요했나
앞에서 사인/코사인 자리 벡터를 만들어 입력에 더해 보았다. 그것으로 순서는 분명히 들어갔다. 문제는 어떻게 들어갔느냐다.
어텐션 점수는 질의와 키의 내적 하나로 정해진다. 자리 벡터 P 를 더한 뒤 내적을 펼쳐 보면 네 항이 나온다.
(q + P[m]) · (k + P[n]) = q·k + q·P[n] + P[m]·k + P[m]·P[n]맨 앞 항은 내용끼리의 점수이고, 맨 뒤 항은 자리끼리의 점수다. 문제는 가운데 두 항이다. q·P[n] 에는 n 만, P[m]·k 에는 m 만 들어 있다. 한쪽 자리만 든 항은 차이로 묶이지 않는다. 그래서 간격이 똑같이 둘이어도 문장 앞쪽에서 잰 값과 뒤쪽에서 잰 값이 다르다.
이게 왜 나쁜가. 언어에서 중요한 것은 대개 "몇 칸 앞의 낱말인가" 이지 "문서의 몇 번째 글자인가" 가 아니다. 관형절이 꾸미는 명사는 바로 뒤에 있고, 대명사가 가리키는 것은 몇 문장 앞에 있다. 전부 거리로 적히는 관계다. 그런데 모델이 보는 점수에는 절대 자리가 섞여 들어와 있다. 학습할 때 본 적 없는 자리 번호가 서비스에서 나오면 — 더 긴 글을 넣는 순간 바로 그렇게 된다 — 그 항들이 어떤 값이 될지 아무도 모른다.
고쳐 보려는 시도는 오래 있었다. 자리 벡터를 학습시키면 표에 없는 자리에서 그대로 멈추고, 점수에 거리마다 다른 상수를 더하면 항이 하나 늘 뿐 내용과 자리가 섞이는 문제는 남는다. 어느 쪽도 내적 자체를 건드리지는 못했다.
그래서 [RoFormer 논문](https://arxiv.org/abs/2104.09864)이 던진 질문은 이것이다. 내적이 처음부터 상대 위치만 보게 만들 수는 없을까. 점수를 낸 뒤에 고치는 것이 아니라, 질의와 키를 만드는 자리에서 이미 그렇게 되도록.
더하는 대신 돌린다
답은 연산을 바꾸는 것이다. 더하지 말고 돌린다.
짝수 차원을 두 개씩 묶어 평면 위의 점으로 본다. 64차원이면 쌍이 32개다. 쌍마다 각도를 정해 두고, m 번 자리의 벡터는 각 쌍을 m 곱하기 그 각도만큼 회전시킨다.
theta_i = base ** (-2*i/d) # i 번째 쌍의 회전 속도(x0, x1) -> (x0*cos(a) - x1*sin(a), x0*sin(a) + x1*cos(a)) # a = m * theta_i0번 쌍은 속도 1 로 가장 빠르게 돌고, 뒤로 갈수록 기하급수로 느려진다. 밑은 논문의 정의에서 10000 이다.
여기서 중요한 것은 더한 것이 아무것도 없다는 점이다. 벡터에 새 값을 섞지 않았다. 원래 있던 값의 방향만 자리만큼 돌렸다.
쌍 안에서만 도는 것도 눈여겨볼 만하다. 0번 쌍은 0번과 1번 차원만 섞고 1번 쌍은 2번과 3번만 섞는다. 차원 전체를 뒤섞는 큰 행렬이 아니라, 대각선에 2 곱하기 2 짜리 작은 회전이 죽 늘어선 모양이다. 그래서 곱셈 한 번을 통째로 하지 않고 쌍마다 cos 과 sin 을 곱해 더하는 것으로 끝난다.
왜 상대 위치만 남는가
쌍 하나만 떼어 보면 한 줄로 끝난다. 회전행렬 R 의 전치는 반대 방향 회전이다.
(R(m*theta) q) · (R(n*theta) k) = q · R(m*theta)ᵀ R(n*theta) k = q · R((n-m)*theta) km 과 n 이 각각 어디인지는 사라지고 차이만 남는다. 쌍마다 이 일이 따로 일어나고, 내적은 그것들을 더하는 것이므로 벡터 전체에서도 같은 말이 성립한다.
말로 하면 이렇다. 두 개를 같은 방향으로 같이 돌리면 사이 각은 안 변한다. 시계 바늘 두 개를 통째로 돌려도 둘이 벌어진 각도는 그대로인 것과 같다. 내적은 결국 길이와 사이 각으로 정해지니, 사이 각이 안 변하면 점수도 안 변한다.
그리고 회전은 길이를 바꾸지 않는다. cos 과 sin 의 제곱을 더하면 1 이기 때문이다. 더하는 방식은 원래 신호 위에 다른 값을 얹어 크기를 바꾸지만, 회전은 얹는 것이 없다. 자리 정보를 넣으면서 내용은 손대지 않는 셈이다.
멀어지면 무엇이 남는가
쌍마다 속도가 다른 것이 여기서 값을 한다.
간격 delta 만큼 떨어진 두 자리 사이에서 i 번째 쌍은 delta * theta_i 만큼 벌어진다. 이것을 한 바퀴(2π)로 나누면 몇 바퀴를 돌았는지가 나온다. 빠른 쌍은 조금만 떨어져도 여러 바퀴를 돌아 버린다. 한 바퀴를 넘긴 쌍은 delta 와 "delta 에서 한 바퀴만큼 뺀 거리" 를 같은 각도로 적는다 — 그 쌍만 보고는 둘을 가를 수 없다는 뜻이다.
그래서 거리가 멀어지면 아직 한 바퀴를 안 돈 느린 쌍들만 그 거리를 제대로 구분한다. 가까운 거리는 빠른 쌍이 잘게 나누고, 먼 거리는 느린 쌍이 크게 나눈다. 속도를 기하급수로 깔아 둔 이유가 이것이다. 자를 여러 개 겹쳐 둔 셈인데, 눈금이 촘촘한 자는 짧은 것만 재고 눈금이 성긴 자는 긴 것을 재는 식이다.
실습에서 64차원으로 직접 재 보면 숫자가 분명하게 나온다. 간격이 1일 때는 32개 쌍이 전부 첫 바퀴 안에 있지만, 간격이 커질수록 그 수가 줄어든다. 줄어드는 모양을 눈으로 보고 나면 "문맥을 늘리는 일" 이 왜 각도를 손보는 이야기로 이어지는지가 보인다 — 남은 자를 더 길게 만들거나, 자를 더 성기게 다시 깔아야 하기 때문이다.
현장에서 만나는 모습
첫째, 문맥을 늘렸더니 품질이 무너진다. 학습 때 쓴 길이를 넘기면 느린 쌍조차 처음 보는 각도로 들어간다. 각도 자체는 계산되지만 모델이 그 각도에서 무엇을 해야 하는지는 배운 적이 없다.
둘째, 어느 층에 넣느냐를 헷갈린다. 자리 벡터를 더하는 방식은 입력 임베딩에 한 번 더하면 끝이다. 회전은 그렇지 않다 — 어텐션이 질의와 키를 쓰는 자리에서 적용된다. 값(V)은 돌리지 않는다. 돌리면 내용이 자리에 끌려다닌다.
셋째, 질의와 키가 다른 약속을 쓴다. 쌍을 이웃끼리 묶는 구현과 앞 절반과 뒤 절반을 짝지우는 구현이 둘 다 흔하다. 어느 쪽이든 성질은 같지만, 한쪽 가중치를 다른 쪽 코드에 넣으면 조용히 틀린다. 오류가 나지 않고 점수만 이상해진다.
넷째, 캐시에 돌린 값을 넣었는데 자리를 다시 센다. 이미 회전시킨 키를 캐시에 두고 나중에 또 돌리면 자리가 두 번 들어간다. 생성이 길어질수록 어긋난다.
다섯째, 밑을 바꾸면 다른 모델이 된다. 밑은 쌍들의 속도를 통째로 정하는 값이다. 학습한 밑과 다른 밑으로 추론하면 모든 쌍의 각도가 어긋난다.
실무에서 진짜 중요한 것
- 같은 간격이면 같은 점수인가를 시험으로 고정하라. 자리를 옮겨 가며 재서 값이 같은지 보는 것은 몇 줄짜리 시험인데, 이게 깨지면 아래의 모든 것이 의미를 잃는다.
- 회전은 질의와 키에만. 값에는 넣지 않는다.
- 길이 보존을 확인하라. 돌린 뒤 노름이 달라졌다면 회전이 아니라 다른 것을 한 것이다.
- 부동소수점 오차를 각오하라. 자리가 커지면 각도도 커져 오차가 늘어난다. 같은지 볼 때는
==가 아니라 허용 오차를 둔 비교를 쓴다.
다음 실습에서 할 것
/root/work/tf-rope/rope.py 를 한 단계씩 키운다. numpy 도 torch 도 쓰지 않는다 — 이 파드의 시스템 파이썬에는 numpy 가 없고(/opt/onnx-lab/bin/python 안에만 있다) 인터넷도 없다. 표준 라이브러리 math 만으로 충분하다.
쌍마다 다른 회전 속도를 만드는 것에서 시작해 2차원 회전 하나, 벡터 전체를 자리만큼 돌리기, 돌린 질의와 키의 점수까지 만든다. 그다음 더하는 방식을 나란히 만들어 같은 자리에서 두 값을 견준다.
한가운데는 여섯째 단계다. 간격을 둘로 고정해 두고 시작 자리를 3, 10, 100, 4000 으로 옮겨 가며 두 방식으로 점수를 잰다. 회전 쪽은 네 자리에서 같은 값이 나오고 더하는 쪽은 흔들린다. 그 흩어짐의 폭을 직접 숫자로 보게 된다.
마지막으로 간격이 멀어질 때 쌍마다 몇 바퀴를 도는지 세어, 아직 한 바퀴를 안 넘긴 쌍이 몇 개나 남는지 잰다. 채점기는 여러분이 적어 둔 설명을 믿지 않는다 — 여러분의 모듈을 실제로 불러 매번 다른 차원과 자리로 함수를 두드려 보고, 자기가 따로 계산한 값과 허용 오차 안에서 대조한다.