LabHub
배우기 러닝패스 코스

트랜스포머 — 어텐션을 손으로 계산한다 · 임베딩·가중치 묶기·로짓 · 이론

번호가 벡터가 되고, 벡터가 다시 점수가 된다

LabHub 에서 이어서 보기

한 줄 요약

임베딩은 줄을 꺼내는 표일 뿐이고, 출력 쪽의 로짓은 그 표를 한 번 더 쓰는 곱셈이다. 둘 사이에 들어 있는 수의 개수가 어휘 크기 곱하기 폭이고, 어휘를 키우는 값은 거기서 치른다.

왜 이게 필요했나

토큰화까지 끝내면 글은 정수 목록이 된다. 그런데 정수는 그대로 쓸 수 없다. 3번 토큰과 4번 토큰이 이웃이라는 뜻이 아닌데, 숫자로 두면 모든 연산이 그렇게 읽는다. 그래서 번호마다 벡터 하나를 붙여 준다. 그 벡터들을 한 장에 쌓은 것이 임베딩 표다.

여기서 처음 걸리는 것이 왜 조회인가다. 교과서는 "원-핫 벡터에 행렬을 곱한다" 고 적는데 코드는 table[token_id] 한 줄이다. 둘이 다른 이야기처럼 보인다. 다르지 않다 — 원-핫은 한 자리만 1이고 나머지가 0이라, 곱해서 더하면 그 한 줄만 살아남는다. 값은 한 치도 다르지 않고 곱셈 횟수만 다르다. 조회는 다른 연산이 아니라 같은 연산의 지름길이다.

두 번째로 걸리는 것이 나가는 길이다. 어텐션과 블록을 다 지나면 벡터 하나가 남는다. 그것을 다시 낱말로 돌려놓아야 하는데, 어휘 전체에 점수를 매기는 일이다. 폭 d 짜리 벡터를 어휘 크기 V 짜리 점수로 넓히려면 V×d 짜리 행렬이 필요하다 — 그런데 그 모양의 행렬은 이미 있다. 입력 쪽 임베딩 표가 바로 그 모양이다.

조회는 곱셈의 지름길일 뿐이다

표의 모양은 (어휘수 V, 폭 d) 다. 들고 있는 수의 개수는 V 곱하기 d 다. 폭을 그대로 두고 어휘만 두 배로 하면 그 개수도 두 배가 된다. 토큰 수를 줄이려고 어휘를 키웠는데, 값은 이 표에서 나간다.

ids = [7, 7, 41]rows = [table[i] for i in ids]      # 조회# 같은 값을 원-핫으로 계산하면one = [0.0] * V; one[7] = 1.0row = [sum(one[r] * table[r][c] for r in range(V)) for c in range(d)]# rows[0] 과 row 는 같은 값이다. 곱셈만 V 곱하기 d 번 더 했다.

여기서 한 가지가 더 드러난다. ids 의 앞 두 자리는 같은 번호라 완전히 같은 벡터가 나온다. 앞에 무엇이 있든 뒤에 무엇이 오든 같다. 임베딩에는 문맥이 없다. 같은 낱말이 자리에 따라 다르게 읽히는 일은 어텐션이 뒤에서 하는 것이고, 표는 그저 표다. [torch.nn.MultiheadAttention](https://docs.pytorch.org/docs/stable/generated/torch.nn.MultiheadAttention.html) 의 첫 인자가 embed_dim 인 것도 그래서다 — 임베딩의 폭이 곧 모델의 폭이라, 표에서 정한 d 를 그 뒤의 모든 층이 그대로 받는다.

행렬 곱을 실제로 쓰는 자리에서는 [NumPy 의 matmul](https://numpy.org/doc/stable/reference/generated/numpy.matmul.html) 같은 것이 대신 계산해 준다. 다만 이 실습 파드의 시스템 파이썬에는 numpy 가 없고 /opt/onnx-lab/bin/python 안에만 있으므로, 여기서는 표준 라이브러리로 직접 두 방법을 계산해 값이 같은지 본다.

나가는 길: 같은 표를 한 번 더 쓴다

[Attention Is All You Need](https://arxiv.org/abs/1706.03762) 는 임베딩을 다루는 짧은 절에서 두 가지를 적어 두었다. 하나는 두 임베딩 층과 소프트맥스 앞의 선형 변환이 같은 가중치 행렬을 나눠 쓴다는 것이고, 다른 하나는 임베딩 층에서 그 가중치에 √d 를 곱한다는 것이다. 앞의 것이 가중치 묶기(weight tying)다.

묶으면 두 가지가 생긴다. 첫째, 표가 한 벌이므로 수의 개수가 절반이다. 따로 두면 V·d 가 두 벌이라 2·V·d 다. 둘째, 점수를 매기는 방식이 내적이 된다. 은닉 벡터 h 에 대해 토큰 t 의 점수는 표의 t번째 줄과 h 의 내적이다. 그래서 h 가 어떤 토큰의 임베딩과 같아지면 그 토큰의 점수가 가장 커진다 — 자기 자신과의 내적은 길이의 제곱이라 다른 어떤 내적보다 크기 쉽기 때문이다.

이 성질은 편리하지만 함정도 같은 자리에서 나온다. 묶으면 하나의 표가 들어오는 뜻나가는 점수 두 가지 일을 동시에 맡는다. 한쪽에 좋은 배치가 다른 쪽에도 좋으리라는 보장은 없다. 묶을지 말지는 그래서 공짜 선택이 아니라 거래다 — 파라미터를 절반으로 줄이는 대신 표에 두 가지 일을 시킨다.

가까운 토큰은 내적으로 찾지 않는다

"이 토큰과 가까운 토큰" 을 찾을 때 내적을 그대로 쓰면 안 된다. 내적에는 상대의 길이가 곱해져 들어 있기 때문이다. 방향이 조금 덜 맞아도 길기만 하면 앞으로 나온다.

코사인은 그 길이를 나눠서 지운다. 방향만 남기는 것이다. 차이를 확인하는 가장 확실한 방법은 표의 한 줄만 몇 배로 늘여 보는 것이다. 그 줄의 코사인은 하나도 바뀌지 않고(방향이 그대로다) 내적은 전부 그 배수만큼 커진다. 이웃 목록을 코사인으로 뽑으면 순서가 그대로인데 내적으로 뽑으면 늘인 줄이 맨 앞으로 튀어나온다.

그래서 로짓의 순서와 "뜻이 가까운 순서" 는 같은 것이 아니다. 로짓은 내적 순서이고, 거기에는 길이가 섞여 있다.

논문이 √d 를 곱하는 자리

같은 절의 다른 한 줄이 임베딩에 √d 를 곱한다는 것이다. 곱하면 무엇이 달라지는가. 방향은 하나도 안 바뀐다. 모든 칸에 같은 수를 곱했으니 코사인은 그대로다. 바뀌는 것은 크기뿐이고, 크기는 정확히 √d 배가 된다.

크기가 왜 중요한가. 임베딩에 위치 정보를 더해 주는 자리에서, 두 신호의 크기가 너무 다르면 한쪽이 묻힌다. 크기를 맞춰 두는 일이 그래서 필요하다. 이 실습에서는 "왜 하필 √d 인가" 를 증명하는 대신 곱하면 크기가 √d 배가 되고 방향은 그대로다 는 것을 숫자로 확인한다. 거기까지가 재어서 말할 수 있는 것이다.

현장에서 만나는 모습

첫째, 어휘를 키우자는 제안이 메모리 회의로 끝난다. 토큰이 줄어 좋겠다 싶어 어휘를 두 배로 하자고 하면, 표가 두 배가 된다는 답이 돌아온다. 폭은 손도 안 댔는데 그렇다. 어느 쪽이 이득인지는 두 값을 나란히 세어 봐야 한다.

둘째, "묶었나 안 묶었나" 로 파라미터 수가 어긋난다. 같은 구성을 적어 놓고도 계산한 값이 한 표만큼 차이 난다. 표 하나가 통째로 있고 없고이므로 반올림 오차 같은 것이 아니다.

셋째, 유사 토큰 목록이 이상하다. 내적으로 뽑아 놓고 "뜻이 가깝다" 고 부르면, 길이가 큰 줄이 어느 질의에나 끼어든다. 코사인으로 바꾸면 그 줄이 사라진다.

넷째, 임베딩만 꺼내 문맥을 기대한다. 같은 낱말은 표에서 언제나 같은 줄이다. 문장을 나타내는 벡터가 필요하면 모델을 통과시켜야 하고, 표를 조회한 값은 문맥이 없는 값이다.

다섯째, 폭을 바꾸면 그 뒤가 전부 따라 움직인다. 임베딩의 d 는 그 뒤 모든 층이 받는 폭이라, 한 곳만 고칠 수가 없다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/tf-embed/embed.py 를 한 단계씩 키운다. 표준 라이브러리만 쓴다 — 이 파드의 시스템 파이썬에는 numpy·torch·transformers 가 없고 numpy 는 /opt/onnx-lab/bin/python 안에만 있다. 실제 모델을 부르지 않으므로 실제 모델의 어휘 크기나 파라미터 수 같은 숫자는 쓰지 않는다. 여기서 나오는 값은 전부 여러분이 만든 표에서 잰 것이다.

결정적인 임베딩 표를 만드는 것에서 시작해 모양과 파라미터 수를 세고, 번호로 줄을 꺼내고, 같은 값을 원-핫 곱으로 다시 계산해 두 값이 같은지 본다. 그다음 같은 표로 로짓을 내고, 묶었을 때와 따로 둘 때의 수 개수를 세고, 어휘를 두 배로 해 본다.

뒤의 두 단계가 요점이다. 표의 한 줄만 몇 배로 늘여 놓고 이웃 목록을 코사인과 내적으로 각각 뽑는다. 코사인 목록은 그대로인데 내적 목록에서는 늘인 줄이 맨 앞으로 나온다. 마지막으로 임베딩에 √d 를 곱해 크기가 정확히 √d 배가 되는 것과 방향이 그대로인 것을 나란히 잰다. 채점기는 여러분의 모듈을 실제로 불러 매번 다른 표와 다른 번호로 함수를 두드려 보고, 자기가 따로 계산한 값과 대조한다.