토큰에 정수 번호를 붙이면 서로 구분할 수 있지만, 번호의 크기가 토큰의 의미를 나타내지는 않는다. 임베딩(Embedding)은 토큰을 실수 벡터로 표현하는 방법이다. PyTorch의 nn.Embedding은 번호에 해당하는 벡터를 조회하고, 그 벡터를 모델과 함께 학습할 수 있게 한다.
정수 인코딩(Integer encoding)은 토큰마다 고유한 번호를 붙인다. 이 번호는 식별자이므로, 두 번호가 가깝다고 두 토큰의 의미가 비슷한 것은 아니다.
nn.Embedding은 조회 표(Lookup table) 역할을 한다. 어휘 사전(Vocabulary)의 크기가 , 벡터 차원이 이면 임베딩 행렬 의 크기는 이다. 입력 번호 에 대해 0부터 세었을 때 번째 행을 꺼낸다.
정수 하나를 길이 의 벡터로 바꾸므로, 크기가 (B, T)인 배치 입력은 (B, T, E)가 된다. 는 문장 수, 는 문장 길이다. 문장 하나를 (T,)로 넣으면 출력은 (T, E)이다.
이 조회는 길이 의 원-핫 벡터(One-hot vector)를 행벡터로 만들어 에 곱하는 것과 같은 행을 선택한다. 그러나 실제로 원-핫 벡터를 만들 필요는 없다. nn.Embedding에는 정수 인덱스를 그대로 전달한다.
nn.Embedding(V, E)가 저장하는 가중치는 개이며 별도의 편향은 없다. 입력 문장이 길어지거나 같은 토큰을 반복해도 조회 횟수만 늘고 표의 크기는 늘지 않는다. num_embeddings는 문장 길이가 아니라 행의 수이며, 입력 번호는 0부터 V-1까지여야 한다.
새 nn.Embedding의 일반 토큰 벡터는 기본적으로 무작위로 초기화된다. 처음부터 단어의 의미를 알고 있는 것은 아니다. 분류 모델 안에서 학습하면 손실의 기울기가 임베딩까지 전달되어 벡터의 성분도 바뀐다. 토큰과 번호의 대응은 유지한다.
따라서 학습된 벡터는 데이터와 학습 목적의 영향을 받는다. 각 좌표를 미리 ‘긍정 정도’나 ‘재미 정도’로 정해 두는 것도 아니며, 임베딩 층을 만들었다는 사실만으로 의미 관계가 잘 학습됐다고 판단할 수 없다.
같은 가중치 표에서 같은 번호를 조회하면 같은 벡터가 나온다. nn.Embedding 자체는 주변 단어나 위치에 따라 다른 벡터를 만들지 않는다. 문맥을 반영하는 계산은 뒤에 연결한 LSTM 같은 층에서 이어진다.
<UNK>는 실제 토큰이 있지만 사전에 등록되지 않았을 때 쓰는 표식이다. <PAD>는 여러 문장을 같은 길이로 묶기 위해 빈자리를 채우는 표식이다. 모르는 단어가 있다는 것과 단어가 없는 자리는 다르므로 두 번호를 구분한다.
특수 토큰의 번호는 이름만으로 자동 지정되지 않는다. 전처리 코드와 모델이 같은 번호를 사용해야 한다. 사전에 없는 토큰을 UNK 번호로 바꾸는 일도 전처리가 담당한다. nn.Embedding이 문자열을 읽거나 범위 밖의 번호를 자동으로 UNK로 바꾸지는 않는다.
padding_idx에는 PAD 번호를 지정한다. 새로 만든 임베딩에서 해당 행은 기본적으로 영벡터이고, 임베딩 조회의 역전파는 이 행에 기울기를 누적하지 않는다. 다만 매번 그 행을 0으로 덮어쓰는 기능은 아니므로 직접 값을 바꾸면 다른 패딩 벡터를 쓸 수 있다. PAD 행도 표 안에 있으므로 저장된 가중치 수를 셀 때는 포함한다.
패딩 벡터를 0으로 만드는 것과 패딩 시점의 계산을 생략하는 것은 다르다. 임베딩 출력의 길이는 그대로이며, LSTM은 입력이 0이어도 이전 상태와 편향으로 상태를 갱신할 수 있다. 실제 문장이 끝난 위치를 쓰거나 패딩 계산을 제외하는 처리는 뒤의 모델에서 따로 해야 한다.
다음 사전은 설명을 위해 직접 정한 것이다. 일반 토큰 4개와 특수 토큰 2개를 합쳐 이다.
| 토큰 | <UNK> | <PAD> | 영화 | 좋다 | 나쁘다 | 재미있다 |
|---|---|---|---|---|---|---|
| 번호 | 0 | 1 | 2 | 3 | 4 | 5 |
이미 토큰화된 ['영화', '정말', '좋다']와 ['영화', '나쁘다']를 입력으로 사용한다. 첫 문장의 정말은 사전에 없으므로 0으로 바꾼다. 두 번째 문장에는 길이 3을 맞추기 위해 PAD인 1을 붙인다.
각 번호로 행을 꺼내는 과정을 코드로 연결하면 다음과 같다. 실행 미확인 예시이며, 벡터의 수치나 학습 성능을 측정한 결과는 아니다.
import torch
from torch import nn
vocab = {
"<UNK>": 0, "<PAD>": 1,
"영화": 2, "좋다": 3, "나쁘다": 4, "재미있다": 5,
}
tokens = [["영화", "정말", "좋다"], ["영화", "나쁘다"]]
encoded = [[vocab.get(token, vocab["<UNK>"]) for token in seq]
for seq in tokens]
max_len = max(len(seq) for seq in encoded)
padded = [seq + [vocab["<PAD>"]] * (max_len - len(seq))
for seq in encoded]
inputs = torch.tensor(padded, dtype=torch.long)
embedding = nn.Embedding(len(vocab), 3, padding_idx=vocab["<PAD>"])
vectors = embedding(inputs)
입력은 (2, 3), 가중치는 (6, 3), 출력은 (2, 3, 3)이다. 저장된 가중치는 개이다. 이 크기는 사전과 API에서 도출한 값이며 출력 로그가 아니다.
두 문장의 영화는 같은 2번 행을 조회한다. UNK인 0번 행은 일반 학습 대상이고, PAD인 1번 행만 padding_idx로 구분된다. 같은 0이라는 수라도 입력의 번호 0과 출력의 영벡터는 다른 것이다.
padding_idx.