임베딩
언어 모델이 단어를 다루려면, 먼저 단어를 숫자 로 바꿔야 합니다. 어떻게 바꾸느냐가 모델이 의미를 얼마나 담을 수 있는지를 좌우합니다. 이 장은 단어를 의미가 담긴 벡터로 바꾸는 임베딩(embedding) 을, 통계 모델의 한계에서 출발해 직접 학습하는 데까지 따라갑니다.
1통계 언어 모델의 한계¶
2003년 요슈아 벤지오(Yoshua Bengio)는 통계 기반 언어 모델의 한계를 셋으로 짚었습니다.
학습 데이터에 없는 단어의 확률이 0이 된다.
긴 맥락을 반영하기 어렵다.
단어를 독립적으로 취급해, 의미적 유사도를 계산할 수 없다.
N-그램 모델은 앞의 몇 개 단어만 보고 다음 단어를 예측합니다. 참조하는 단어 수 N을 키우면 그 조합이 데이터에 없어 확률이 0이 되고, N을 줄이면 맥락이 짧아집니다. 무엇보다 "휴대폰"과 "핸드폰"을 완전히 다른 기호로 취급해, 한쪽이 자주 오는 자리에 다른 쪽도 올 수 있다는 것을 알지 못합니다. 사람은 두 단어가 같은 뜻임을 쉽게 알지만, 통계 모델은 단어를 독립적인 기호로만 세기 때문입니다.
2어휘의 수학적 표현¶
가장 단순한 표현은 각 단어에 정수를 붙이는 것입니다(개=1, 고양이=2). 하지만 정수에는 크기 순서가 있어, 개 < 고양이 같은 의미 없는 대소 관계가 끼어듭니다. 이를 피하려고 원-핫 인코딩(one-hot encoding) 을 씁니다 — 어휘 크기만 한 벡터에서 자기 자리만 1, 나머지는 0으로 둡니다.
import numpy as np
어휘 = ["개", "강아지", "사과"]
원핫 = np.eye(len(어휘))
def 코사인유사도(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
print("개·강아지:", 코사인유사도(원핫[0], 원핫[1]))
print("개·사과 :", 코사인유사도(원핫[0], 원핫[2]))개·강아지: 0.0
개·사과 : 0.0두 벡터가 얼마나 같은 방향인지는 코사인 유사도 로 잽니다(같으면 1, 직교면 0). 원-핫에서는 모든 단어가 서로 직교하므로, "개"와 "강아지"든 "개"와 "사과"든 유사도가 똑같이 0입니다. 의미가 가까운 단어를 가깝게 둘 방법이 전혀 없습니다.
3분산 표현 — 임베딩¶
원-핫의 크고 성긴 벡터 대신, 단어를 작고 빽빽한(dense) 실수 벡터로 표현하는 것이 임베딩 입니다. 의미를 여러 좌표에 나눠 담는다는 뜻에서 분산 표현(distributed representation) 이라고도 합니다.
임베딩은 어휘 크기 행, 차원 열의 조회표(lookup table) 입니다.
단어 ID를 주면 그 행을 꺼내 차원 벡터를 돌려줍니다.
Keras에서는 layers.Embedding 이 바로 이 조회표입니다.
import os
os.environ["KERAS_BACKEND"] = "torch"
import keras
from keras import layers, ops
keras.utils.set_random_seed(0)
조회표 = layers.Embedding(input_dim=3, output_dim=4) # 어휘 3개, 4차원
조회표.build((None,))
print("임베딩 행렬 모양:", tuple(조회표.embeddings.shape)) # (3, 4)
print("'개'(0번) 벡터:", ops.convert_to_numpy(조회표(np.array([0])))[0].round(3))임베딩 행렬 모양: (3, 4)
'개'(0번) 벡터: [-0.015 0.04 -0.032 -0.028]문제는 이 표의 값을 어떻게 채우느냐 입니다. 의미가 가까운 단어가 가까운 벡터를 갖도록, 데이터로부터 학습 해야 합니다. 그 대표적 방법이 word2vec입니다.
4word2vec — 문맥으로 임베딩을 학습한다¶
word2vec의 핵심 가정은 "비슷한 맥락에 나오는 단어는 비슷한 의미"라는 분포 가설 입니다. skip-gram 은 중심 단어로 그 주변 단어 를 맞히도록 임베딩을 학습합니다.
어휘 전체에 대한 softmax는 너무 비싸므로, 네거티브 샘플링(negative sampling) 을 씁니다. 실제 주변 단어(정답 1개)와 무작위 단어(오답 개)를 구분하는 작은 이진 분류로 바꾸는 것입니다.
4.1말뭉치와 토큰화¶
먼저 말뭉치를 토큰으로 나눕니다. 학습된 임베딩의 품질은 말뭉치 품질이 좌우하므로, 깨끗한 글이 중요합니다 — 여기서는 한국어 위키백과 문단(KorQuAD 말뭉치)을 씁니다. 토큰화는 SentencePiece 장에서 본 방식 그대로, 그 말뭉치에 서브워드 토크나이저를 학습시킵니다(토큰화가 깔끔할수록 임베딩도 좋아지므로 따로 떼어 둡니다).
import urllib.request, json, collections
import numpy as np
import sentencepiece as spm
URL = "https://korquad.github.io/dataset/KorQuAD_v1.0_train.json"
데이터 = json.load(urllib.request.urlopen(URL))["data"]
문서 = list(dict.fromkeys(문단["context"]
for 글 in 데이터 for 문단 in 글["paragraphs"])) # 위키백과 문단
open("wiki.txt", "w").write("\n".join(문서))
spm.SentencePieceTrainer.Train(
input="wiki.txt", model_prefix="wiki_sp",
vocab_size=16000, model_type="unigram", character_coverage=0.9995)
토크나이저 = spm.SentencePieceProcessor()
토크나이저.Load("wiki_sp.model")
문장들 = [토크나이저.encode(문장, out_type=str) for 문장 in 문서]
print("문단 수:", len(문서))
print("토큰 예:", 토크나이저.encode("대한민국의 수도는 서울이다", out_type=str))문단 수: 9663
토큰 예: ['▁대한민국', '의', '▁수도', '는', '▁서울', '이다']4.2학습 쌍 만들기¶
토큰을 정수 ID로 바꾸고, 중심 토큰과 그 좌우 창 안의 주변 토큰을 (중심, 주변) 쌍으로 모읍니다.
너무 흔한 토큰은 확률적으로 솎아내(서브샘플링) 학습이 빈출 단어에 쏠리지 않게 합니다.
난수 = np.random.default_rng(0)
빈도 = collections.Counter(t for 문장 in 문장들 for t in 문장)
어휘 = [t for t, c in 빈도.most_common() if c >= 10]
어휘색인 = {t: i for i, t in enumerate(어휘)}
어휘수 = len(어휘)
도수 = np.array([빈도[t] for t in 어휘], dtype=float)
비율 = 도수 / 도수.sum()
유지확률 = np.minimum(np.sqrt(1e-4 / 비율), 1.0) # 흔한 토큰 솎기
중심, 주변, 창 = [], [], 2
for 문장 in 문장들:
ids = [어휘색인[t] for t in 문장
if t in 어휘색인 and 난수.random() < 유지확률[어휘색인[t]]]
for i, c in enumerate(ids):
for j in range(max(0, i - 창), min(len(ids), i + 창 + 1)):
if j != i:
중심.append(c); 주변.append(ids[j])
중심 = np.array(중심, "int32"); 주변 = np.array(주변, "int32")
print("어휘수:", 어휘수, "| 학습 쌍:", len(중심))어휘수: 14892 | 학습 쌍: 5311118네거티브 샘플링용 오답은 빈도의 0.75제곱에 비례해 뽑습니다(흔한 단어를 적당히 억제하는 표준 설정). 정답 1개와 오답 개를 한 줄에 묶고, 정답 자리만 1인 라벨을 답으로 둡니다.
표본분포 = 도수 ** 0.75
표본분포 /= 표본분포.sum()
오답표 = 난수.choice(어휘수, size=8_000_000, p=표본분포) # 미리 뽑아 둔 오답 풀
K = 5
오답 = 오답표[난수.integers(0, len(오답표), size=(len(중심), K))].astype("int32")
주변오답 = np.concatenate([주변[:, None], 오답], axis=1) # (쌍, 1+K): 정답 1 + 오답 K
라벨 = np.zeros((len(중심), K + 1), "float32")
라벨[:, 0] = 1.0 # 정답 자리만 14.3Keras로 skip-gram 학습¶
임베딩 조회표 두 개를 둡니다 — 단어 임베딩(최종 결과물)과 문맥 임베딩.
중심 단어의 단어 벡터와 주변·오답 단어의 문맥 벡터를 내적 해 점수를 내고, 정답 쌍에서 크고 오답 쌍에서 작아지도록 이진 교차엔트로피로 학습합니다.
내적은 ops.einsum 한 줄로 드러냅니다.
중심입력 = keras.Input(shape=(), dtype="int32")
주변입력 = keras.Input(shape=(K + 1,), dtype="int32")
단어임베딩 = layers.Embedding(어휘수, 100, name="word")
문맥임베딩 = layers.Embedding(어휘수, 100, name="context")
점수 = ops.einsum("bd,bkd->bk", 단어임베딩(중심입력), 문맥임베딩(주변입력)) # 중심·문맥 내적
word2vec = keras.Model([중심입력, 주변입력], 점수)
word2vec.compile(optimizer="adam",
loss=keras.losses.BinaryCrossentropy(from_logits=True))
word2vec.fit([중심, 주변오답], 라벨, batch_size=4096, epochs=3, verbose=2)Epoch 1/3
1297/1297 - 20s - 16ms/step - loss: 0.4841
Epoch 2/3
1297/1297 - 20s - 16ms/step - loss: 0.4158
Epoch 3/3
1297/1297 - 20s - 16ms/step - loss: 0.39484.4학습된 이웃¶
학습이 끝나면 단어 임베딩 행렬이 우리가 원하던 조회표 입니다. 정규화한 벡터로 코사인 유사도가 가장 큰 이웃을 찾아봅니다.
임베딩행렬 = ops.convert_to_numpy(단어임베딩.embeddings)
임베딩행렬 = 임베딩행렬 / np.linalg.norm(임베딩행렬, axis=1, keepdims=True)
def 이웃(단어, n=5):
i = 어휘색인[토크나이저.encode(단어, out_type=str)[0]] # '배우' -> '▁배우'
유사도 = 임베딩행렬 @ 임베딩행렬[i]
이웃들, 본것 = [], [단어]
for j in np.argsort(-유사도):
후보 = 어휘[j].lstrip("▁")
if any(w in 후보 or 후보 in w for w in 본것): # 같은 단어의 활용형·중복은 빼고
continue
본것.append(후보)
이웃들.append((후보, round(float(유사도[j]), 2)))
if len(이웃들) == n:
break
return 이웃들
for 단어 in ["음악", "과학", "대통령", "전쟁"]:
print(f"{단어}: {이웃(단어)}")음악: [('장르', 0.82), ('팝', 0.81), ('퍼포먼스', 0.78), ('예술', 0.77), ('작품', 0.77)]
과학: [('철학', 0.84), ('학문', 0.84), ('분야에서', 0.83), ('인문학', 0.82), ('정립', 0.82)]
대통령: [('한나라당', 0.86), ('노무현', 0.85), ('김대중', 0.84), ('홍준표', 0.84), ('박근혜', 0.83)]
전쟁: [('침략', 0.82), ('함대', 0.82), ('침공', 0.81), ('프로이센', 0.8), ('전역에서', 0.78)]5MB 남짓한 위키백과만으로도 음악·예술끼리(장르·팝·퍼포먼스), 학문 분야끼리(철학·학문·인문학), 정치인끼리(한나라당·노무현·김대중·박근혜) 모입니다. 원-핫에서 모두 0이던 유사도가, 이제 의미를 반영합니다. "문맥으로 주변 단어 맞히기"라는 단순한 목표만으로 의미가 좌표에 새겨진 것입니다.
5대규모에서는 — 의미의 산술¶
작은 데이터는 이웃까지 보여 줍니다. 대규모 데이터로 학습하면 임베딩에 더 놀라운 구조가 드러납니다 — 의미의 산술.
여기서는 페이스북이 공개한 한국어 fastText(대규모 웹 크롤로 학습) 임베딩을 불러옵니다.
가중치가 텍스트(.vec) 형식이라, 별도 라이브러리 없이 순수 numpy로 읽습니다(상위 20만 단어만).
import gzip
FT_URL = "https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.ko.300.vec.gz"
경로 = keras.utils.get_file("cc.ko.300.vec.gz", FT_URL)
단어목록, 벡터들 = [], []
with gzip.open(경로, "rt", encoding="utf-8") as f:
개수, 차원 = map(int, f.readline().split())
for 줄 in f:
조각 = 줄.rstrip(" \n").split(" ")
if len(조각) != 차원 + 1:
continue
단어목록.append(조각[0])
벡터들.append(np.asarray(조각[1:], dtype=np.float32))
if len(단어목록) >= 200000:
break
대규모 = np.array(벡터들)
대규모 = 대규모 / np.linalg.norm(대규모, axis=1, keepdims=True)
ft색인 = {w: i for i, w in enumerate(단어목록)}
print("로드:", len(단어목록), "단어 x", 차원, "차원")로드: 200000 단어 x 300 차원유명한 단어 산술을 시험합니다 — 여자 + 오빠 − 남자 = ?
def 유추(a, b, c, n=4):
벡터 = 대규모[ft색인[b]] - 대규모[ft색인[a]] + 대규모[ft색인[c]]
벡터 = 벡터 / np.linalg.norm(벡터)
유사도 = 대규모 @ 벡터
for w in (a, b, c):
유사도[ft색인[w]] = -9 # 입력 단어는 제외
상위 = np.argsort(-유사도)[:n]
return [(단어목록[i], round(float(유사도[i]), 2)) for i in 상위]
print("여자+오빠-남자 →", 유추("남자", "오빠", "여자"))
print("서울+일본-한국 →", 유추("한국", "서울", "일본"))여자+오빠-남자 → [('언니', 0.57), ('누나', 0.51), ('동생', 0.51), ('형부', 0.48)]
서울+일본-한국 → [('도쿄', 0.63), ('東京', 0.47), ('강북', 0.46), ('교토', 0.45)]"여자에게 오빠에 해당하는 사람"인 언니 가 1등으로 나옵니다.
오빠 − 남자 라는 벡터 차이가 두 단어 사이의 의미 관계를 담고 있어서, 그 차이를 "여자"에 더하면 대응하는 단어 "언니"에 닿습니다.
"서울 : 한국 = 도쿄 : 일본"처럼 수도와 나라 사이의 관계도 같은 방식으로 풀립니다.
이웃도 작은 데이터보다 한결 깨끗합니다.
def ft이웃(단어, n=5):
유사도 = 대규모 @ 대규모[ft색인[단어]]
이웃들, 본것 = [], [단어]
for i in np.argsort(-유사도):
후보 = 단어목록[i]
if any(w in 후보 or 후보 in w for w in 본것): # 같은 단어의 활용형·중복은 빼고
continue
본것.append(후보)
이웃들.append((후보, round(float(유사도[i]), 2)))
if len(이웃들) == n:
break
return 이웃들
print("강아지 →", ft이웃("강아지"))강아지 → [('고양이', 0.59), ('애완견', 0.51), ('유기견', 0.49), ('반려견', 0.49), ('멍멍이', 0.47)]규모가 커질수록 단어 벡터의 기하 구조가 의미를 또렷이 반영합니다 — 이것이 word2vec이 보여 준 발견이었습니다.
6정리¶
원-핫은 모든 단어를 직교시켜, 의미적 유사도를 전혀 담지 못합니다(모든 쌍의 유사도가 0).
임베딩(분산 표현) 은 단어를 작고 빽빽한 벡터로 바꿔 의미를 좌표에 담습니다 — 핵심은 그 표 를 데이터로 학습 하는 것입니다.
word2vec 은 “문맥으로 주변 단어 맞히기”(skip-gram + 네거티브 샘플링)로 임베딩을 학습합니다. 5MB 남짓한 위키백과만으로도 의미 이웃(음악 장르·학문 분야·정치인)이 모입니다.
대규모 로 학습하면 의미의 산술(여자 + 오빠 − 남자 = 언니)까지 드러납니다.
이렇게 얻은 단어 임베딩이 이후 모든 신경망 언어 모델의 입력 표현 이 됩니다.