관리 메뉴

Bull

[NLP] Word2Vec | study book 본문

Artificial Intelligence/NLP

[NLP] Word2Vec | study book

Bull_ 2024. 9. 22. 00:09

2013년 구글에서 공개한 임베딩 모델로 단어 간의 유사성을 측정하기 위해 분포 가설(distributional hypothesis) 기반으로 개발됐다.

분포 가설이란 같은 문맥에서 자주 나타나는 단어가 서로 유사한 의미를 가질 가능성이 높다는 가정이다.

동시 발생(co-occurrence) 확률 분포를 이용하여 유사성 측정을 한다.

예를 들어,
"내일 자동차를 타고 부산에 간다"
"내일 비행기를 타고 부산에 간다"
에서 자동차비행기는 서로 유사할 것이라고 예상한다.

단어 벡터화


희소 표현과 밀집 표현으로 나눌 수 있다.

희소 표현 (sparse representation)


0 1 0 0 0
잃고 1 0 0 0 0
외양간 0 0 0 1 0
고친다 0 0 0 0 1
- 벡터가 커지면 공간적 낭비가 심해진다.          
- 유사성 계산 또한 많은 비용이 발생한다.          

밀집 표현 (dense representation)


0.3914 -0.1749 ... 0.5912 0.1321
잃고 -0.2893 0.3814 ... -0.1492 -0.2814
외양간 0.4812 0.1214 ... -0.2745 0.0132
고친다 -0.1314 -0.2809 ... 0.2014 0.3016
- 단어 사전의 크기가 커져도 벡터의 크기가 커지는 것은 아니다.          
- 희소 표현에 비해 효율적인 공간 활용이 가능하다.          
- 밀집표현을 단어 임베딩 벡터(Word Embedding Vector) 라고도 하며 Word2Vec의 대표 기법이다.          

CBow (Continuous Bag of Words)


중심 단어주변 단어를 통해서 예측하는 방법이다.
주변 단어의 범위를 윈도(Window) 라고 부른다.
학습을 위해 이동하는데 이를 슬라이딩 윈도(Sliding Window) 라고 한다.

CBow에 대한 내 블로그 정리

입력 문장 주변 단어 중심 단어
세상의 재미있는 일들은 모두 밤에 일어난다 재미있는, 일들은 세상의
세상의 재미있는 일들은 모두 밤에 일어난다 세상의, 일들은, 모두 재미있는
세상의 재미있는 일들은 모두 밤에 일어난다 재미있는, 모두, 밤에 일들은
세상의 재미있는 일들은 모두 밤에 일어난다 재미있는, 일들은,밤에, 일어난다 모두
세상의 재미있는 일들은 모두 밤에 일어난다 일들은,모두, 일어난다 밤에
세상의 재미있는 일들은 모두 밤에 일어난다 모두, 밤에 일어난다
- 윈도 = 2 인 CBow 테이블이다. (양쪽으로 2칸)    

이러한 데이터를 인공 신경망 학습을 위해 임베딩 벡터를 크기에 맞는 가중치 행렬로 변환하여 학습을 시킨 후 각 입력 값에 대한 예측을 한다.

이 가중치 행렬은 투사층(Projection Layer) 이라고 하고 순람표(Lookup table, LUT) 구조를 가진다.
임베딩 벡터 $E$ 는 원래 임베딩 벡터 $V_{세상의}$, $V_{일들은}$, $V_{모두}$ 의 평균을 낸 벡터가 된다. 그리고 그 벡터는 $W_{E\times V}$에 적용되어 학습시킨다. 그 $W$를 곱하면 $V$ 크기의 벡터가 나오는데 이 벡터에 소프트맥스를 적용시켜 원래의 중심 단어와 cost를 비교하여 예측을 진행한다.

Skip-gram


간단히 말해서 CBow의 반대되는 개념이다. CBow는 주변 단어를 통해서 중심 단어를 예측하지만 Skip-gram은 중심 단어를 통해 주변 단어들을 예측한다.

입력 문장 학습 데이터
세상의 재미있는 일들은 모두 밤에 일어난다 <세상의-재미있는>, <세상의-일들은>
세상의 재미있는 일들은 모두 밤에 일어난다 <재미있는-세상의>, <재미있는-일들은>,
<재미있는-모두>
세상의 재미있는 일들은 모두 밤에 일어난다 <일들은-세상의>, <일들은-재미있는>,
<일들은-모두>, <일들은-밤에>
세상의 재미있는 일들은 모두 밤에 일어난다 <모두-재미있는>, <모두-일들은>,
<모두-밤에>, <모두-일어난다>
세상의 재미있는 일들은 모두 밤에 일어난다 <밤에-일들은>, <밤에-모두>,
<밤에-일어난다>
세상의 재미있는 일들은 모두 밤에 일어난다 <일어난다-모두>, <일어난다-밤에>
- 하나의 중심 단어에 대한 주변 단어와 쌍으로 학습을 진행한다.  

 
Skip-gram에서 투사층은 CBow와 반대의 크기를 가진다. 중심 단어에 대한 임베딩 벡터 $V$를 통해 $W_{V \times E}$ 를 만들어주고 $E$ 를 통해 주변 단어 임베딩 벡터 $V$로 만들어준다. 그리고 소프트맥스 연산을 통해 주변 단어를 예측한다.  

계층적 소프트맥스(Hierachical Softmax)


출력층을 이진 트리 구조로 표현해 연산을 수행한다. 자주 등장하는 단어일수록 트리의 상위 노드에 위치한다.

  • 리프 노드는 모두 각 단어를 나타낸다.
  • Huh의 실제 확률은 0.37 x 0.62 x 0.43 = 0.098642 이 된다. 루트 노드에 거쳐지는 부모 노드의 값들을 모두 곱해주면 된다.
  • basic 소프트맥스는 $O(V)$ 시간복잡도인 반면 계층적 소프트맥스는 세그먼트 트리와 비슷하게 $O(log_2 V)$ 의 시간 복잡도를 가진다.
  • 노드에 선택되는 확률은 어떤 공식에 의해 만들어지는데 어려워 보여서 생략.

네거티브 샘플링 (Negative Sampling)


전체 단어 집합에서 일부 단어를 샘플링하여 오답 단어로 사용한다. 학습 윈도 내 등장하지 않는 단어를 n개 추출하여 정답 단어와 함께 소프트맥스 연산을 수행한다. n= 5~20개를 사용한다.
$$ P(w_i) = \frac{f(w_i)^{0.75}}{\sum^v_{j=0}f(w_j)^{0.75}} $$

  • $w_i$의 출현 빈도수를 $F(w_i)$ 로 나타낸다.
  • 말뭉치 내에 '추천해요' 가 100번 등장하고 전체 단어 빈도가 2000이면 $f(추천해요)=\frac{100}{2000}$이 된다.
  • $P(w_i)$ 는 단어 $w_i$ 가 네거티브로 추출될 확률
  • 출현 빈도수의 0.75 제곱한 값는 정규화 상수인데 연구를 통해 얻어진 최적의 값이다.
  • $V$ 는 전체 단어 집합
  • $j$ ~ $V$ 까지 $i$도 포함된 전체 단어 집합이다.
  • 이 단어는 문맥에서 나오지 않으므로 관련이 없다라는 것을 학습시킴.
입력 데이터 출력 데이터
재미있는 세상의
재미있는 일들은
재미있는 모두
↓ 아래로 변경한다.  
입력 데이터 출력 데이터
재미있는.세상의 1
재미있는.일들은 1
재미있는.모두 1
재미있는.걸어가다 0
재미있는.주십시오 0
재미있는.미리 0
재미있는.안녕하세요 0
- 그러니까 위 표에 1이 출력된 부분의 확률이 정해지는데 그 단어가 얼마나 추출됐냐에 따라 확률이 정해진다.  
- 중심 단어를 기준으로 뒀을 때의 확률  

입력 단어와 입베딩 벡터와 내적 연산을 진행 후 시그모이드 함수를 통해 확률값으로 변환된다.

실습: Skip-gram


Embedding class

embedding = torch.nn.Embedding(
   num_embeddings,
   embedding_dim,
   padding_idx=None,
   max_norm=None,
   norm_type=2.0
)
  • num_embeddings : 임베딩 수, 단어 사전의 크기를 의마한다.
  • embedding_dim : 임베딩 차원 수, 임베딩 벡터의 크기이다.
  • padding_idx : 병렬 처리를 위해 문장들을 일정 길이로 맞추는 역할을 한다.
  • max_norm : 임베딩 벡터의 크기를 제한하는 방법. L1,L2 정규화 방식있다.
  • norm_type : 임베딩 벡터의 최대 크기로, 초과되면 잘라낸다.

CODE: skip-gram

from torch import nn

class VanillaSkipgram(nn.Module):
    def __init__(self, vocab_size, embedding_dim):
        super().__init__()
        self.embedding = nn.Embedding(
            num_embeddings=vocab_size,
            embedding_dim=embedding_dim
        )
        self.linear = nn.Linear(
            in_features=embedding_dim,
            out_features=vocab_size
        )

    def forward(self, input_ids):
        embeddings = self.embedding(input_ids)
        output = self.linear(embeddings)
        return output

단순히 입력 단어와 주변 단어를 룩업 테이블에서 가져와서 내적을 계산하고 손실 함수를 통해 예측 오차를 최소화하는 방식으로 학습한다.

영화 리뷰 데이터 전처리

pip install Korpora
pip install konlpy
import pandas as pd
from Korpora import Korpora
from konlpy.tag import Okt




corpus = Korpora.load("nsmc")
corpus = pd.DataFrame(corpus.test)
tokenizer = Okt()
tokens = [tokenizer.morphs(review) for review in corpus.text]

print(tokens[:3])

결과

[['굳', 'ㅋ'], ['GDNTOPCLASSINTHECLUB'], ['뭐', '야', '이', '평점', '들', '은', '....', '나쁘진', '않지만', '10', '점', '짜', '리', '는', '더', '더욱', '아니잖아']]
  • Okt 토크나이저로 형태소를 추출한다.

단어 사전 구축

from collections import Counter

def build_vocab(corpus, n_vocab, special_tokens):
    counter = Counter()
    for tokens in corpus:
        counter.update(tokens)
    vocab = special_tokens
    for token, count in counter.most_common(n_vocab):
        vocab.append(token)
    return vocab

vocab = build_vocab(corpus=tokens, n_vocab=5000, special_tokens=["<unk>"])
token_to_id = {token: idx for idx, token in enumerate(vocab)}
id_to_token = {idx: token for idx, token in enumerate(vocab)}

print(vocab[:10])
print(len(vocab))

결과

['<unk>', '.', '이', '영화', '의', '..', '가', '에', '...', '을']
5001
  • build_vocab 함수로 단어 사전 구축
  • 문서 내에 n_vocab보다 많은 종류의 토큰이 있다면 가장 많이 등장한 토큰 순으로 구성
  • special_tokens은 <unk>으로 대체, 특별한 의미를 갖는 토큰들을 의미한다.
  • 특수 토큰 <unk>은 1개 이므로 전체 5001개의 토큰이 구성된다.

Skip-gram 단어 쌍 추출

def get_word_pairs(tokens, window_size):
    pairs = []
    for sentence in tokens:
        sentence_length = len(sentence)
        for idx, center_word in enumerate(sentence):
            window_start = max(0, idx - window_size)
            window_end = min(sentence_length, idx + window_size + 1)
            center_word = sentence[idx]
            context_words = sentence[window_start:idx] + sentence[idx+1:window_end]
            for context_word in context_words:
                pairs.append([center_word, context_word])
    return pairs

word_pairs = get_word_pairs(tokens, window_size=2)
print(word_pairs[:5])

결과

[['굳', 'ㅋ'], ['ㅋ', '굳'], ['뭐', '야'], ['뭐', '이'], ['야', '뭐']]
  • 각 쌍은 [중심 단어, 주변단어] 로 구성된다.

인덱스 쌍 변환

def get_index_pairs(word_pairs, token_to_id):
    pairs = []
    unk_index = token_to_id["<unk>"]
    for word_pair in word_pairs:
        center_word, context_word = word_pair
        center_index = token_to_id.get(center_word, unk_index)
        context_index = token_to_id.get(context_word, unk_index)
        pairs.append([center_index, context_index])
    return pairs

index_pairs = get_index_pairs(word_pairs, token_to_id)
print(index_pairs[:5])
print(len(vocab))

결과

[[595, 100], [100, 595], [77, 176], [77, 2], [176, 77]]
5001
  • [중심단어 인덱스, 주변 단어 인덱스] 로 매핑된다.

데이터로더 적용

import torch
from torch.utils.data import TensorDataset, DataLoader

index_pairs = torch.tensor(index_pairs)
center_indexs = index_pairs[:, 0]
contenxt_indexs = index_pairs[:, 1]

dataset = TensorDataset(center_indexs, contenxt_indexs)
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
  • 생성된 인덱스 쌍 리스트를 텐서 형식으로 변환한다. [N,2] 구조를 가지므로 중심 단어 주변 단어로 나눌 수 있다.

Skup-gram 모델 준비 작업

from torch import optim

device = "cuda" if torch.cuda.is_available() else "cpu"
word2vec = VanillaSkipgram(vocab_size=len(token_to_id), embedding_dim=128).to(device)
criterion = nn.CrossEntropyLoss().to(device)
optimizer = optim.SGD(word2vec.parameters(), lr=0.1)

모델 학습

for epoch in range(10):
    cost = 0.0
    for input_ids, target_ids in dataloader:
        input_ids = input_ids.to(device)
        target_ids = target_ids.to(device)

        logits = word2vec(input_ids)
        loss = criterion(logits, target_ids)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        cost += loss

    cost = cost / len(dataloader)
    print(f"Epoch : {epoch+1:4d}, Cost : {cost:.3f}")
  • 모델 학습이 완료되면 $W_{V \times E}$ 또는 $W_{E \times V}$ 행렬 중 하나를 선택하여 임베딩을 추출한다.
  • 학습 결과는 양이 많아서 생략.

임베딩 값 추출

token_to_embedding = dict()
embedding_matrix = word2vec.embedding.weight.detach().cpu().numpy()

for word, embedding in zip(vocab, embedding_matrix):
    token_to_embedding[word] = embedding

index = 30
token = vocab[30]
token_embedding = token_to_embedding[token]
print(token)
print(token_embedding)
  • 임베딩 된 값으로 단어간의 유사도를 확인할 수 있다.

임베딩 단어 유사도 계산

import numpy as np
from numpy.linalg import norm

def cosine_similarity(a, b):
    cosine = np.dot(b, a) / (norm(b, axis=1) * norm(a))
    return cosine

def top_n_index(cosine_matrix, n):
    closest_indexes = cosine_matrix.argsort()[::-1]
    top_n = closest_indexes[1 : n + 1]
    return top_n

cosine_matrix = cosine_similarity(token_embedding, embedding_matrix)
top_n = top_n_index(cosine_matrix, n=5)

print(f"{token}와 가장 유사한 5 개 단어")
for index in top_n:
    print(f"{id_to_token[index]} - 유사도 : {cosine_matrix[index]:.4f}")

Gensim


매우 간단한 구조의 Word2Vec 모델을 학습하면 데이터 수가 적어도 학습하는데 오랜 시간이 소요된다. 이런 경우 계층적 소프트 맥스, 네거티브 샘플링 같은 기법을 사용하면 효율적이다.

젠심 라이브러리는 Word2Vec 모델을 쉽게 구성할 수 있다.

pip install gensim

젠심은 Cython 을 이용해 병렬 처리나 네거티브 샘플링 등을 적용한다. Cython은 C++ 기반 확장 모듈을 파이썬 모듈로 컴파일하는 기능을 제공한다.

Word2Vec class

from gensim.models import Word2Vec

word2vec = Word2Vec(
    sentences=tokens,
    vector_size=128,
    window=5,
    min_count=1,
    sg=1,
    epochs=3,
    max_final_vocab=10000
)
  • sentences : 입력 문장
  • vector_size : 임베딩 차원 수
  • window : 윈도 크기
  • min_count : 최소 빈도, 최소 빈도만큼 등장하지 않으면 사용하지 않는다.
  • sg : skip-gram 모델 사용 여부이다. 1은 true.
  • epochs : 에폭 수
  • max_final_vocab : 단어 사전의 최대 크기, 최소 빈도 충족 단어가 최대 최종 단어 사전보다 크면 자주 등장한 단어 순으로 단어 사전 구축
word2vec.save("../models/word2vec.model")
word2vec = Word2Vec.load("../models/word2vec.model")
word = "연기"
print(word2vec.wv[word])
print(word2vec.wv.most_similar(word, topn=5))
print(word2vec.wv.similarity(w1=word, w2="연기력"))
  • word2vec 인스턴스의 wv 속성은 학습된 단어 벡터 모델을 포함한 Word2VecKeyedVectors 객체를 반환한다. 이를 통해 각 메소드를 사용할 수 있다

 

참고자료

https://product.kyobobook.co.kr/detail/S000209621433

 

파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습 | 윤대희 - 교보문고

파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습 | 트랜스포머는 딥러닝 분야에서 성능이 우수한 모델로 현대 인공지능 분야의 핵심 기술입니다. 트랜스포머와 비전 트랜스

product.kyobobook.co.kr