Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

임베딩

언어 모델이 단어를 다루려면, 먼저 단어를 숫자 로 바꿔야 합니다. 어떻게 바꾸느냐가 모델이 의미를 얼마나 담을 수 있는지를 좌우합니다. 이 장은 단어를 의미가 담긴 벡터로 바꾸는 임베딩(embedding) 을, 통계 모델의 한계에서 출발해 직접 학습하는 데까지 따라갑니다.

1통계 언어 모델의 한계

2003년 요슈아 벤지오(Yoshua Bengio)는 통계 기반 언어 모델의 한계를 셋으로 짚었습니다.

N-그램 모델은 앞의 몇 개 단어만 보고 다음 단어를 예측합니다. 참조하는 단어 수 N을 키우면 그 조합이 데이터에 없어 확률이 0이 되고, N을 줄이면 맥락이 짧아집니다. 무엇보다 "휴대폰"과 "핸드폰"을 완전히 다른 기호로 취급해, 한쪽이 자주 오는 자리에 다른 쪽도 올 수 있다는 것을 알지 못합니다. 사람은 두 단어가 같은 뜻임을 쉽게 알지만, 통계 모델은 단어를 독립적인 기호로만 세기 때문입니다.

2어휘의 수학적 표현

가장 단순한 표현은 각 단어에 정수를 붙이는 것입니다(개=1, 고양이=2). 하지만 정수에는 크기 순서가 있어, 개 < 고양이 같은 의미 없는 대소 관계가 끼어듭니다. 이를 피하려고 원-핫 인코딩(one-hot encoding) 을 씁니다 — 어휘 크기만 한 벡터에서 자기 자리만 1, 나머지는 0으로 둡니다.

=[1,0,0],강아지=[0,1,0],사과=[0,0,1]\text{개}=[1,0,0], \quad \text{강아지}=[0,1,0], \quad \text{사과}=[0,0,1]
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) 이라고도 합니다.

xt=C(wt),CRV×dx_t = C(w_t), \qquad C \in \mathbb{R}^{|V| \times d}

임베딩은 어휘 크기 V|V| 행, 차원 dd 열의 조회표(lookup table) CC 입니다. 단어 ID를 주면 그 행을 꺼내 dd 차원 벡터를 돌려줍니다. 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개)와 무작위 단어(오답 KK개)를 구분하는 작은 이진 분류로 바꾸는 것입니다.

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개와 오답 KK개를 한 줄에 묶고, 정답 자리만 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                                              # 정답 자리만 1

4.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.3948

4.4학습된 이웃

학습이 끝나면 단어 임베딩 행렬이 우리가 원하던 조회표 CC 입니다. 정규화한 벡터로 코사인 유사도가 가장 큰 이웃을 찾아봅니다.

임베딩행렬 = 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정리