GloVe, FastText와 서브워드 임베딩
Word2Vec는 단어마다 하나의 임베딩을 학습했습니다. GloVe는 동시 등장 행렬을 분해했습니다. FastText는 단어의 조각을 임베딩했습니다. BPE는 이 흐름을 트랜스포머(transformer)로 이어 주었습니다.
유형: Build
언어: Python
선수 지식: Phase 5 · 03 (Word2Vec from Scratch)
예상 시간: 약 45분
학습 목표
- GloVe(Global Vectors)가 동시 등장(co-occurrence) 행렬을 어떻게 임베딩으로 바꾸는지 설명합니다.
- FastText가 문자 n-그램(character n-gram)으로 미등록 단어(Out of Vocabulary; OOV)를 다루는 방식을 이해합니다.
- BPE(Byte-Pair Encoding)가 서브워드(subword) 어휘를 학습하는 절차를 구현합니다.
- GloVe, FastText, BPE, SentencePiece를 어떤 상황에서 선택해야 하는지 판단합니다.
문제
Word2Vec에는 두 가지 열린 질문이 남았습니다.
첫째, 스킵그램(skip-gram)을 온라인으로 업데이트하는 방식 말고 동시 등장 행렬을 직접 분해하는 연구 흐름도 있었습니다. 잠재 의미 분석(Latent Semantic Analysis; LSA), 초공간 아날로그 언어 모델(Hyperspace Analogue to Language; HAL) 같은 방법입니다. Word2Vec의 반복 학습이 본질적으로 더 나은 것인지, 아니면 두 방법이 빈도(count)를 다루는 방식 때문에 차이가 난 것인지가 질문이었습니다. GloVe는 여기에 답합니다. 잘 설계한 손실 함수(loss)를 사용한 행렬 분해(matrix factorization)는 Word2Vec과 비슷하거나 더 좋은 성능을 내면서도 학습 비용이 더 낮을 수 있습니다.
둘째, Word2Vec과 GloVe는 학습 중 한 번도 보지 못한 단어를 잘 다루지 못합니다. Zoomer-approved, dogecoin, 지난주에 새로 생긴 고유명사, 드문 어근의 다양한 굴절형이 모두 문제입니다. FastText는 문자 n-그램을 임베딩해 이 문제를 해결합니다. 단어는 형태소(morpheme)를 포함한 조각의 합이므로, 어휘(vocabulary) 밖의 단어도 어느 정도 의미 있는 벡터를 얻습니다.
셋째, 트랜스포머(transformer)가 등장한 뒤 질문은 다시 바뀌었습니다. 단어 단위 어휘는 보통 백만 개 안팎에서 한계가 생기지만, 실제 언어는 훨씬 열려 있습니다. BPE(Byte-Pair Encoding)와 그 계열 방법은 자주 나오는 서브워드(subword) 단위를 학습해 거의 모든 텍스트를 표현합니다. 현대 거대 언어 모델(Large Language Model; LLM)의 토크나이저(tokenizer)는 모두 서브워드 토크나이저입니다.
이 강의에서는 세 방법을 차례로 구현하고, 언제 어떤 방법을 선택할지 정리합니다.
개념

GloVe(Global Vectors). 단어-단어 동시 등장 행렬 X를 만듭니다. X[i][j]는 단어 i의 주변 문맥에 단어 j가 몇 번 등장했는지를 뜻합니다. 그런 다음 v_i · v_j + b_i + b_j ≈ log(X[i][j])가 되도록 벡터를 학습합니다. 자주 등장하는 쌍(pair)이 손실 함수를 지배하지 않도록 가중치를 둡니다.
FastText. 단어는 문자 n-그램과 단어 자신을 더한 값입니다. 예를 들어 where는 <wh, whe, her, ere, re>, <where> 같은 조각으로 표현됩니다. 단어 벡터는 이런 구성 벡터(component vector)의 합입니다. 학습 방식은 Word2Vec과 비슷합니다. 장점은 whereupon처럼 보지 못한 단어도 이미 알고 있는 n-그램으로 조합할 수 있다는 점입니다.
BPE(Byte-Pair Encoding). 개별 바이트(byte) 또는 문자(character)에서 시작합니다. 말뭉치(corpus)에서 인접한 쌍을 세고, 가장 자주 등장하는 쌍을 새 토큰(token)으로 병합합니다. 이를 k번 반복합니다. 결과는 자주 나오는 시퀀스(sequence)인 ing, tion, the 같은 조각은 하나의 토큰이 되고, 드문 단어는 익숙한 조각으로 쪼개지는 어휘입니다.
직접 만들기
GloVe: 동시 등장 행렬 분해
import numpy as np
from collections import Counter
def build_cooccurrence(docs, window=5):
pair_counts = Counter()
vocab = {}
for doc in docs:
for token in doc:
if token not in vocab:
vocab[token] = len(vocab)
for doc in docs:
indexed = [vocab[t] for t in doc]
for i, center in enumerate(indexed):
for j in range(max(0, i - window), min(len(indexed), i + window + 1)):
if i != j:
distance = abs(i - j)
pair_counts[(center, indexed[j])] += 1.0 / distance
return vocab, pair_counts
def glove_train(vocab, pair_counts, dim=16, epochs=100, lr=0.05, x_max=100, alpha=0.75, seed=0):
n = len(vocab)
rng = np.random.default_rng(seed)
W = rng.normal(0, 0.1, size=(n, dim))
W_tilde = rng.normal(0, 0.1, size=(n, dim))
b = np.zeros(n)
b_tilde = np.zeros(n)
for epoch in range(epochs):
for (i, j), x_ij in pair_counts.items():
weight = (x_ij / x_max) ** alpha if x_ij < x_max else 1.0
diff = W[i] @ W_tilde[j] + b[i] + b_tilde[j] - np.log(x_ij)
coef = weight * diff
grad_W_i = coef * W_tilde[j]
grad_W_tilde_j = coef * W[i]
W[i] -= lr * grad_W_i
W_tilde[j] -= lr * grad_W_tilde_j
b[i] -= lr * coef
b_tilde[j] -= lr * coef
return W + W_tilde
GloVe의 핵심은 두 가지입니다. 가중 함수 f(x) = (x/x_max)^alpha는 (the, and)처럼 너무 자주 등장하는 쌍이 손실 함수를 지배하지 않도록 낮은 가중치를 줍니다. 최종 임베딩은 중심 단어 테이블(center table) W와 문맥 테이블(context table) W_tilde의 합을 사용합니다. 두 테이블을 더하는 것은 논문에서 제안한 실용적인 방법이며, 한쪽만 쓰는 것보다 성능이 더 좋은 경우가 많습니다.
FastText: 서브워드를 아는 임베딩
def char_ngrams(word, n_min=3, n_max=6):
wrapped = f"<{word}>"
grams = {wrapped}
for n in range(n_min, n_max + 1):
for i in range(len(wrapped) - n + 1):
grams.add(wrapped[i:i + n])
return grams
>>> char_ngrams("where")
{'<where>', '<wh', 'whe', 'her', 'ere', 're>', '<whe', 'wher', 'here', 'ere>', '<wher', 'where', 'here>'}
각 단어는 보통 3-6글자의 n-그램 집합으로 표현됩니다. 단어 임베딩은 n-그램 임베딩의 합입니다. 미등록 단어라도 일부 n-그램이 학습되어 있으면 벡터를 얻을 수 있습니다. 예를 들어 whereupon은 where와 <wh, her, ere, <where 같은 n-그램을 공유하므로 두 단어가 비슷한 위치에 놓이게 됩니다.
def fasttext_vector(word, ngram_table):
grams = char_ngrams(word)
vecs = [ngram_table[g] for g in grams if g in ngram_table]
if not vecs:
return None
return np.sum(vecs, axis=0)
BPE: 학습되는 서브워드 어휘
def learn_bpe(corpus, k_merges):
vocab = Counter()
for word, freq in corpus.items():
tokens = tuple(word) + ("</w>",)
vocab[tokens] = freq
merges = []
for _ in range(k_merges):
pair_freq = Counter()
for tokens, freq in vocab.items():
for a, b in zip(tokens, tokens[1:]):
pair_freq[(a, b)] += freq
if not pair_freq:
break
best = pair_freq.most_common(1)[0][0]
merges.append(best)
new_vocab = Counter()
for tokens, freq in vocab.items():
new_tokens = []
i = 0
while i < len(tokens):
if i + 1 < len(tokens) and (tokens[i], tokens[i + 1]) == best:
new_tokens.append(tokens[i] + tokens[i + 1])
i += 2
else:
new_tokens.append(tokens[i])
i += 1
new_vocab[tuple(new_tokens)] = freq
vocab = new_vocab
return merges
def apply_bpe(word, merges):
tokens = list(word) + ["</w>"]
for a, b in merges:
new_tokens = []
i = 0
while i < len(tokens):
if i + 1 < len(tokens) and tokens[i] == a and tokens[i + 1] == b:
new_tokens.append(a + b)
i += 2
else:
new_tokens.append(tokens[i])
i += 1
tokens = new_tokens
return tokens
>>> corpus = Counter({"low": 5, "lower": 2, "newest": 6, "widest": 3})
>>> merges = learn_bpe(corpus, k_merges=10)
>>> apply_bpe("lowest", merges)
['low', 'est</w>']
첫 번째 반복(iteration)은 가장 흔한 인접 쌍을 병합합니다. 충분히 반복하면 자주 나오는 부분 문자열(substring)인 low, est, tion 같은 조각은 단일 토큰이 되고, 드문 단어는 익숙한 조각으로 깔끔하게 나뉩니다.
실제 GPT, BERT, T5 토크나이저는 30k-100k 정도의 병합(merge) 또는 어휘를 학습합니다. 결과적으로 어떤 텍스트도 알려진 ID의 제한된 길이 시퀀스로 바뀌며, 단어 단위 OOV 문제는 사라집니다.
사용하기
실무에서는 보통 직접 학습하기보다 사전학습된 체크포인트(checkpoint)를 불러옵니다.
import fasttext.util
fasttext.util.download_model("en", if_exists="ignore")
ft = fasttext.load_model("cc.en.300.bin")
print(ft.get_word_vector("whereupon").shape)
print(ft.get_word_vector("zoomerapproved").shape)
트랜스포머 시대의 BPE 계열 서브워드 토크나이저는 다음처럼 사용합니다.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("gpt2")
print(tok.tokenize("unbelievably tokenized"))
['un', 'bel', 'iev', 'ably', 'Ġtoken', 'ized']
Ġ 접두사(prefix)는 단어 경계(word boundary)를 표시하는 GPT-2 관례입니다. 현대 토크나이저는 대부분 BPE 변형, WordPiece(BERT), SentencePiece(T5, LLaMA) 중 하나입니다.
무엇을 선택할까
| 상황 | 선택 |
|---|
| 범용 사전학습 단어 벡터가 필요하고 OOV 허용이 중요하지 않음 | GloVe 300d |
| 오탈자, 신조어, 형태 변화가 풍부한 언어를 다뤄야 함 | FastText |
| 트랜스포머 입력 또는 추론 | 모델이 함께 제공한 토크나이저를 그대로 사용합니다. 절대 바꾸지 않습니다. |
| 언어 모델을 처음부터 학습 | 말뭉치에 맞춰 BPE 또는 SentencePiece 토크나이저를 먼저 학습합니다. |
| 선형 모델 기반 운영(production) 텍스트 분류 | 여전히 TF-IDF입니다. Lesson 02를 봅니다. |
산출물 만들기
outputs/skill-tokenizer-picker.md로 저장합니다.
---
name: tokenizer-picker
description: Pick a tokenization approach for a new language model or text pipeline.
version: 1.0.0
phase: 5
lesson: 04
tags: [nlp, tokenization, embeddings]
---
Given a task and dataset description, you output:
Guide the student in Korean.
1. Tokenization strategy (word-level, BPE, WordPiece, SentencePiece, byte-level). One-sentence reason.
2. Vocabulary size target (e.g., 32k for an English-only LM, 64k-100k for multilingual).
3. Library call with the exact training command. Name the library. Quote the arguments.
4. One reproducibility pitfall. Tokenizer-model mismatch is the single most common silent production bug; call out which pair must be used together.
Refuse to recommend training a custom tokenizer when the user is fine-tuning a pretrained LLM. Refuse to recommend word-level tokenization for any model targeting production inference. Flag non-English / multi-script corpora as needing SentencePiece with byte fallback.
연습문제
- 쉬움.
char_ngrams("playing")와 char_ngrams("played")를 실행합니다. 두 n-그램 집합의 자카드 중첩도(Jaccard overlap)를 계산합니다. pla, lay, play처럼 공유 조각이 많아 FastText가 형태 변형에 강한 이유를 확인합니다.
- 중간.
learn_bpe를 확장해 어휘 증가(vocabulary growth)를 추적합니다. 병합 횟수에 따른 말뭉치 글자당 토큰 수(tokens-per-corpus-character)를 그래프로 그립니다. 처음에는 압축이 빠르게 좋아지고, 이후에는 토큰당 약 2-3글자 근처로 완만해지는 모습을 확인합니다.
- 어려움. Shakespeare 전체 말뭉치에 1k 병합 BPE를 학습합니다. 흔한 단어와 드문 고유명사의 토큰화(tokenization)를 비교하고, 단어당 평균 토큰 수(tokens per word)가 학습 전후 어떻게 달라지는지 측정합니다. 예상과 달랐던 점을 적습니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 동시 등장 행렬(Co-occurrence matrix) | 단어-단어 빈도표 | X[i][j]는 단어 i 주변 윈도우(window)에 단어 j가 등장한 횟수입니다. |
| 서브워드(Subword) | 단어의 조각 | FastText의 문자 n-그램이거나 BPE/WordPiece/SentencePiece가 학습한 토큰입니다. |
| BPE(Byte-Pair Encoding) | 바이트 쌍 부호화 | 어휘가 목표 크기에 도달할 때까지 가장 자주 등장하는 인접 쌍을 반복 병합하는 방법입니다. |
| OOV(Out of vocabulary) | 어휘 밖 단어 | Word2Vec/GloVe는 실패하지만 FastText와 BPE는 처리할 수 있습니다. |
| 바이트 단위 BPE(Byte-level BPE) | 원시 바이트(raw byte) 위의 BPE | GPT-2 방식입니다. 256개 바이트에서 시작하므로 어떤 입력도 OOV가 되지 않습니다. |
더 읽을거리