본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research12 min read

Embedding + Positional Encoding: Self-attention은 순서를 모르는데 Transformer는 어떻게 순서를 아나

Transformer의 핵심인 self-attention은 단어의 순서를 구분하지 못합니다. 오늘은 토큰을 벡터로 바꾸는 임베딩과, 여기에 위치 정보를 더하는 사인 위치인코딩·학습형 위치 임베딩을 배웁니다. 직접 함수를 구현하고, 위치인코딩 유무에 따라 "순서 뒤집기" 태스크 정확도가 어떻게 달라지는지 실험합니다.

By 송민성

오늘의 목표

Transformer가 "나는 너를 좋아해"와 "너는 나를 좋아해"를 어떻게 다르게 읽는지 이해합니다. 이를 위해 토큰 임베딩과 위치 정보를 더하는 방법(사인형, 학습형)을 직접 구현하고, 위치정보가 없으면 실제로 무엇이 깨지는지 눈으로 확인합니다.

개념과 직관

1. 임베딩(embedding): 토큰 ID를 벡터로 바꾸는 표

컴퓨터는 단어를 숫자 ID로 다룹니다. 예를 들어 "고양이"가 ID 7이라고 합시다. 하지만 7이라는 숫자 하나로는 "고양이가 강아지와 비슷하다" 같은 의미를 담을 수 없습니다. 그래서 ID마다 길이 d_model짜리 실수 벡터(숫자 묶음)를 하나씩 대응시킵니다. 이 대응표가 임베딩(embedding, 토큰 ID를 학습 가능한 벡터로 바꾸는 표)입니다.

비유하면 학생 번호별 프로필 카드 묶음입니다. 번호 7을 받으면 7번 카드를 꺼내 줍니다. 카드의 숫자는 처음엔 랜덤이지만, 학습하면서 의미가 비슷한 토큰끼리 비슷한 카드를 갖도록 조금씩 바뀝니다.

모양으로 보면:

  • 임베딩 표: (vocab_size, d_model)
  • 입력 ID: (배치, 길이)
  • 출력: (배치, 길이, d_model)

2. self-attention은 순서를 모른다

self-attention(셀프 어텐션, 문장 안의 각 토큰이 다른 토큰들을 얼마나 참고할지 정해 정보를 섞는 연산)은 이렇게 생각하면 됩니다. 각 토큰이 "나와 관련 있는 토큰이 누구지?"를 계산해서 그 토큰들의 정보를 가중 평균합니다. 여기서 중요한 점은 계산에 토큰의 내용만 쓰이고 몇 번째 자리인지는 쓰이지 않는다는 것입니다.

비유: 사람들이 이름표만 달고 둥글게 모여 앉은 회의입니다. 누가 먼저 말했는지, 어느 자리에 앉았는지는 모릅니다. 이름표(내용)만 보고 서로 얘기합니다. "개가 사람을 물었다"와 "사람이 개를 물었다"는 이름표 묶음이 같으므로, 위치 정보가 없으면 구분하기 어렵습니다.

정확히 말하면, 입력 토큰의 순서를 바꾸면 출력도 같은 방식으로 순서만 바뀔 뿐 값은 같습니다(순열 동변성, permutation equivariance). 오늘 구현에서 이를 직접 확인할 수 있습니다. 참고로 이 주제를 다루는 데 어텐션 내부의 Q/K/V 계산을 자세히 알 필요는 없습니다. "토큰끼리 내용 기반으로 섞는 연산이고, 자리 정보는 안 쓴다"만 기억하면 됩니다.

3. 해결: 위치 정보를 입력에 더한다

가장 단순한 해결책은 각 위치(0번째, 1번째, ...)마다 고유한 벡터를 만들어 토큰 임베딩에 더하는 것입니다.

입력 = 토큰 임베딩[ID] + 위치 벡터[자리]

비유로는 회의 참석자의 이름표에 좌석 번호 스티커를 붙이는 것입니다. 같은 "개"라도 1번 자리의 "개"와 3번 자리의 "개"는 서로 다른 벡터가 됩니다.

4. 절대 위치 방식 두 가지

(a) 사인형(sinusoidal) 위치인코딩: 규칙으로 계산하는 고정 벡터입니다. 원 논문(Attention Is All You Need)이 쓴 방식입니다. 위치 pos, 차원 번호 i에 대해:

PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))

직관: 여러 속도로 도는 시계바늘 묶음입니다. 어떤 차원은 빠르게 돌고(초침), 어떤 차원은 느리게 돕니다(시침). 위치마다 바늘 조합이 달라서 고유한 "시각"이 됩니다. 학습할 파라미터가 없고, 학습 때 보지 못한 더 긴 길이도 수식상 계산은 가능합니다. (다만 긴 길이에서 성능이 실제로 좋은지는 별개의 문제입니다.)

(b) 학습형(learned) 위치 임베딩: 토큰 임베딩처럼 (최대길이, d_model) 크기의 표를 만들고, 위치 번호로 카드를 꺼내 쓰며 학습으로 값을 정합니다. 구현이 간단하고 GPT-2 같은 모델이 이 방식을 씁니다. 대신 정해둔 최대 길이를 넘는 위치는 카드가 없습니다.

5. weight tying(가중치 공유)

LLM은 마지막에 "다음 토큰이 vocab 중 무엇인가"를 맞히는 출력 층을 둡니다. 이 층의 가중치 모양은 (vocab_size, d_model)로, 입력 임베딩 표와 같은 모양입니다. 그래서 두 개를 따로 두지 않고 하나를 같이 쓰는 방식이 weight tying(입력 임베딩과 출력 층이 같은 가중치를 공유하는 기법)입니다.

  • 장점: 파라미터 수가 크게 줄어듭니다(vocab이 클수록 효과 큼).
  • 직관: "토큰 7번 카드"가 읽을 때와 말할 때 같은 의미 공간을 쓰도록 묶는 것입니다.
  • 모든 모델이 쓰는 것은 아닙니다. 쓰는 모델도, 안 쓰는 모델도 있습니다.

코드로 직접 해보기

PyTorch가 설치되어 있다고 가정합니다(pip install torch). 아직 학습하지 않은 개념(역전파 등)은 여기서 쓰지 않고, 모양과 값만 확인합니다.

단계 1. 임베딩 층 써보기

python
import torch import torch.nn as nn vocab_size, d_model = 10, 8 emb = nn.Embedding(vocab_size, d_model) ids = torch.tensor([[1, 2, 3, 2]]) # (배치=1, 길이=4) x = emb(ids) print(x.shape) # torch.Size([1, 4, 8]) print(torch.equal(x[0, 1], x[0, 3])) # True: 같은 ID(2)는 같은 벡터

마지막 줄이 핵심입니다. 같은 토큰이면 위치와 관계없이 벡터가 똑같습니다. 위치 정보가 아직 없는 상태입니다.

단계 2. 사인 위치인코딩 함수

수식을 그대로 옮깁니다.

python
import math def sinusoidal_pe(max_len, d_model): pe = torch.zeros(max_len, d_model) pos = torch.arange(max_len).unsqueeze(1).float() # (max_len, 1) # 짝수 차원 번호 0, 2, 4, ... 에 대한 분모 div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) # (d_model/2,) pe[:, 0::2] = torch.sin(pos * div) pe[:, 1::2] = torch.cos(pos * div) return pe # (max_len, d_model) pe = sinusoidal_pe(16, 8) print(pe.shape) # torch.Size([16, 8]) print(pe[0]) # 0번 위치: sin(0)=0, cos(0)=1 이 번갈아 나옴 print(pe[1] - pe[2]) # 위치마다 값이 다름

1 / 10000^(2i/d)를 exp(-log(10000) * 2i / d)로 바꿔 쓴 것입니다. 수학적으로 같은 값이며, 거듭제곱보다 계산이 안정적이라 흔히 이렇게 씁니다. d_model은 짝수여야 합니다.

단계 3. 토큰 임베딩 + 위치인코딩 합치기

python
class TokenAndPosEmbedding(nn.Module): def __init__(self, vocab_size, d_model, max_len, mode="sin"): super().__init__() self.tok = nn.Embedding(vocab_size, d_model) self.d_model = d_model self.mode = mode if mode == "sin": self.register_buffer("pe", sinusoidal_pe(max_len, d_model)) elif mode == "learned": self.pos = nn.Embedding(max_len, d_model) elif mode == "none": pass def forward(self, ids): T = ids.size(1) x = self.tok(ids) * math.sqrt(self.d_model) # 원 논문의 스케일링 if self.mode == "sin": x = x + self.pe[:T] # (T, d) 가 (B, T, d)에 브로드캐스트 elif self.mode == "learned": x = x + self.pos(torch.arange(T, device=ids.device)) return x layer = TokenAndPosEmbedding(10, 8, 16, mode="sin") out = layer(torch.tensor([[1, 2, 3, 2]])) print(torch.equal(out[0, 1], out[0, 3])) # False: 같은 토큰이어도 위치가 다르니 벡터가 달라짐

register_buffer는 "학습 대상은 아니지만 모델과 함께 저장되고 GPU로도 같이 옮겨지는 텐서"를 등록하는 방법입니다.

단계 4. self-attention이 순서를 모른다는 것 확인

attention 내부를 몰라도 되도록 PyTorch의 기본 모듈을 블랙박스로 씁니다.

python
torch.manual_seed(0) attn = nn.MultiheadAttention(embed_dim=8, num_heads=2, batch_first=True) attn.eval() x = torch.randn(1, 5, 8) # 위치정보 없는 토큰 5개 perm = torch.tensor([4, 3, 2, 1, 0]) y, _ = attn(x, x, x) y_perm, _ = attn(x[:, perm], x[:, perm], x[:, perm]) # 입력 순서를 바꿨더니 출력도 "같은 값이 순서만 바뀜" print(torch.allclose(y[:, perm], y_perm, atol=1e-6)) # True

True가 나오면 "입력을 섞으면 출력도 똑같이 섞일 뿐"이라는 뜻이므로, 이 층만으로는 순서 정보가 생기지 않습니다.

단계 5. weight tying 맛보기

python
class TinyLM(nn.Module): def __init__(self, vocab_size, d_model, tie=True): super().__init__() self.emb = nn.Embedding(vocab_size, d_model) self.head = nn.Linear(d_model, vocab_size, bias=False) if tie: self.head.weight = self.emb.weight # 같은 파라미터 객체를 공유 def count(m): return sum(p.numel() for p in m.parameters()) print(count(TinyLM(1000, 64, tie=False))) # 128000 print(count(TinyLM(1000, 64, tie=True))) # 64000

nn.Linear(in, out)의 가중치 모양은 (out, in)이라서 (vocab_size, d_model)이 되어 임베딩과 정확히 맞습니다. 공유하면 파라미터가 절반이 됩니다.

오늘의 실험

질문: 위치 정보가 없으면 "순서 뒤집기" 태스크를 풀 수 있을까?

태스크: 입력 시퀀스를 거꾸로 출력합니다. 예: [3, 1, 4, 2] → [2, 4, 1, 3]. 정답이 "어떤 토큰이 있는가"가 아니라 "어느 자리에 있는가"에 달려 있어서, 위치 정보가 꼭 필요한 태스크입니다.

설정 제안 (작게 시작하세요):

  • vocab 10개, 길이 8, d_model 64, 레이어 2개, 헤드 4개
  • 모델: 위 TokenAndPosEmbedding + nn.TransformerEncoder(블랙박스로 사용) + nn.Linear로 각 위치의 토큰을 예측
  • 입력과 같은 길이의 출력을 위치별로 맞히는 분류 문제로 만들면 됩니다(손실은 nn.CrossEntropyLoss).
  • 학습 루프는 Month 1에서 배운 것(zero_grad → forward → loss → backward → step)을 그대로 씁니다.
  • 데이터는 매 스텝 랜덤으로 생성합니다: ids = torch.randint(0, 10, (64, 8)), 정답은 ids.flip(1).

바꿔볼 변수: mode = "none", "sin", "learned" 세 가지. 나머지는 모두 동일하게, 시드(seed)도 같게 둡니다.

관찰할 것:

  1. 학습 손실 곡선: 각 모드에서 얼마나 내려가는가
  2. 별도 검증 데이터(학습에 쓰지 않은 랜덤 시퀀스)에서의 토큰 단위 정확도
  3. "none"일 때 어떤 값에서 멈추는가

예상(가설): "none"은 각 위치의 정답이 어디서 오는지 알 수 없어서 정확도가 우연 수준(vocab 10개면 대략 10% 근처)에서 크게 못 벗어날 것으로 예상합니다. 단, 이는 가설이므로 실제 숫자는 직접 확인하고 예상과 다르면 이유를 적어 보세요. 참고로 Transformer 블록의 나머지 부분(어텐션, 피드포워드, 층 정규화 등)은 위치를 구분하지 않는 구조이므로, 입력에 위치가 없으면 위치별 출력을 구분하기 어렵다는 점이 가설의 근거입니다.

기록 방법: 아래 표를 노트에 만들어 채웁니다.

mode시드스텝 수최종 train loss검증 정확도메모
none0
sin0
learned0

여유가 있으면 시드를 3개(0, 1, 2)로 바꿔 평균을 내서, 결과가 우연이 아닌지 확인하세요. 추가로 학습은 길이 8로 하고 검증은 길이 12로 해서 sin과 learned를 비교해 보면 "학습 때 못 본 길이"에서의 차이를 볼 수 있습니다. 단 learned는 max_len을 12 이상으로 잡아야 에러가 안 나며, 8~11번 위치 카드는 학습된 적이 없다는 점도 같이 관찰하세요.

왜 이걸 배우나 (LLM / Post-training 연결)

위치 정보를 어떻게 주입하느냐는 모델이 얼마나 긴 문맥을 다룰 수 있는지와 직결됩니다. 이후 Day에서 상대 위치 방식 등 더 발전된 방법을 배울 때 오늘의 "절대 위치" 개념이 비교 기준이 됩니다. weight tying은 모델 파라미터 수와 메모리를 따질 때 계속 나오는 설계 선택입니다. LLM Research Engineer로서 Post-training(사전학습된 모델을 지시 따르기·선호 정렬 등으로 추가 학습하는 단계)에서 컨텍스트 길이를 늘리거나 토크나이저·임베딩을 건드릴 때, 이 구조를 이해하고 있어야 "왜 이 수정이 모델 입력에 영향을 주는지" 설명하고 디버깅할 수 있습니다.

초보자가 막히는 지점

  • "더한다"가 이상하다 (이어 붙이지 않나?): 이어 붙이면(concat) 차원이 커지지만, 더하면 d_model이 유지되어 이후 층 설계가 단순합니다. 더해도 정보가 뭉개지지 않는 이유는, 고차원 공간에서 두 신호가 대체로 다른 방향을 쓰도록 학습되기 때문이라고 이해하면 됩니다. 처음엔 "일단 더한다"로 받아들이고 넘어가도 됩니다.
  • 모양(shape) 에러: self.pe[:T]는 (T, d_model)이고 x는 (B, T, d_model)입니다. 앞쪽 배치 차원이 자동으로 맞춰지는 브로드캐스팅 덕분에 더해집니다. 에러가 나면 print(x.shape, self.pe[:T].shape)부터 찍으세요.
  • d_model이 홀수일 때 에러: 사인/코사인을 짝수/홀수 차원에 나눠 넣기 때문에 짝수여야 합니다.
  • `learned`에서 `IndexError: index out of range`: 입력 길이가 max_len보다 길 때 생깁니다. 최대 길이를 넉넉하게 잡으세요.
  • `none`인데도 정확도가 생각보다 높게 나오는 경우: 데이터 생성이나 정답 정렬이 잘못됐을 가능성이 큽니다. 정답이 입력과 우연히 같아지는 경우(예: 회문), 혹은 평가 시 패딩 포함 여부를 점검하세요. 위치 없이는 풀 수 없는 태스크이므로, 높게 나오면 먼저 데이터 버그를 의심해야 합니다.
  • 사인 PE는 학습되는 파라미터가 아니다: nn.Parameter나 register_buffer를 안 쓰고 그냥 속성으로 두면 .to("cuda") 때 GPU로 안 옮겨져서 device 에러가 납니다. 단계 3처럼 buffer로 등록하세요.
  • weight tying 후 파라미터 수가 안 줄어든다: head.weight = emb.weight처럼 파라미터 객체 자체를 대입해야 합니다. .data를 복사하면 두 개가 따로 학습됩니다.

알고리즘 (병행 1시간)

LeetCode Medium 2문제 (수학 유형)를 풉니다. 문제는 직접 골라도 되고, 아래처럼 수학 태그에서 Medium을 2개 고르면 됩니다.

  • 예시 후보: Pow(x, n), Count Primes, Happy Number, Integer to Roman 등 (Medium 난이도·수학 태그에 해당하는 것으로 고르세요. 난이도 분류는 사이트에서 직접 확인하세요.)
  • 오늘 Pow(x, n)이 어렵게 느껴진다면, 사인 PE의 10000^(...) 계산에서 거듭제곱을 다루는 방식과 연결해 생각해 볼 수 있습니다.

풀이 후 아래 항목을 문제마다 기록하세요.

  1. 접근: 처음 떠올린 방법과 최종 방법을 한두 문장으로. 브루트포스에서 어떻게 개선했는지.
  2. 시간복잡도 / 공간복잡도: Big-O로 적고 이유를 한 줄로.
  3. 틀린 이유: 오답이나 시간 초과가 났다면 원인(예: 음수 지수 처리, 정수 오버플로, 경계값 0·1 처리 누락)을 구체적으로. 한 번에 맞혔다면 "어디서 틀릴 수 있었는지"를 적어 보세요.

시간 배분 가이드: 문제당 25분 고민, 풀리지 않으면 풀이를 보고 이해한 뒤 다음 날 다시 처음부터 직접 구현해 보세요.