Word2Vec 구현

Word2Vec을 pytorch를 통해서 구현해보겠습니다. 파이토치 공식홈에도 유사한 예제가 있으니 관심있으신 분들은 공식홈에 있는 내용을 읽어보시는 것이 도움이 되시리라 생각됩니다.

먼저 아래와 같이 필요한 라이브러리들을 임포트합니다. 마지막에 임포트한 matplotlib의 경우는 시각화를 위한 것으로 단어들이 어떤 상관성을 가지는지 확인해보기 위함입니다.

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

import numpy as np
import pandas as pd

아래와 같은 텍스트를 선언합니다. 몇개의 단어로 구성된 문장이고 중복된 문장들을 복사해서 붙여 넣었습니다. Word2Vec을 구현하는데 여러 방식이 있지만 이번 예제에서는 Skip-Gram 방식을 사용합니다.

위 구조에서 핵심은 가중치행렬 WW, W′W′ 두 개입니다. Word2Vec의 학습결과가 이 두 개의 행렬입니다. 그림을 자세히 보시면 입력층-은닉층, 은닉층-출력층을 잇는 가중치 행렬의 모양이 서로 전치(transpose)한 것과 동일한 것을 볼 수 있습니다. 그런데 전치하면 그 모양이 같다고 해서 완벽히 동일한 행렬은 아니라는 점에 주의할 필요가 있습니다. 물론 두 행렬을 하나의 행렬로 취급(tied)하는 방식으로 학습을 진행할 수 있고, 학습이 아주 잘되면 WW와 W′W′ 가운데 어떤 걸 단어벡터로 쓰든 관계가 없다고 합니다.

또 다른 방법은 COBOW(Continuous Bag-of-Words) 방식이 있습니다. 이 방식은 Skip-Gram과 반대의 방식입니다.
CBOW는 주변에 있는 단어들을 가지고, 중간에 있는 단어들을 예측하는 방법입니다. 반대로, Skip-Gram은 중간에 있는 단어로 주변 단어들을 예측하는 방법입니다. 메커니즘 자체는 거의 동일하기 때문에 이해하는데 어렵지는 않습니다.

보통 딥러닝이라함은, 입력층과 출력층 사이의 은닉층의 개수가 충분히 쌓인 신경망을 학습할 때를 말하는데 Word2Vec는 입력층과 출력층 사이에 하나의 은닉층만이 존재합니다. 이렇게 은닉층(hidden Layer)이 1개인 경우에는 일반적으로 심층신경망(Deep Neural Network)이 아니라 얕은신경망(Shallow Neural Network)이라고 부릅니다. 또한 Word2Vec의 은닉층은 일반적인 은닉층과는 달리 활성화 함수가 존재하지 않으며 룩업 테이블이라는 연산을 담당하는 층으로 일반적인 은닉층과 구분하기 위해 투사층(projection layer)이라고 부르기도 합니다.

corpus = [
    'he is a king',
    'she is a queen',
    'he is a man',
    'she is a woman',
    'warsaw is poland capital',
    'berlin is germany capital',
    'paris is france capital',
    'seoul is korea capital', 
    'bejing is china capital',
    'tokyo is japan capital',
]

def tokenize_corpus(corpus):
    tokens = [x.split() for x in corpus]
    return tokens

tokenized_corpus = tokenize_corpus(corpus)

단어들의 중복을 제거하여 vocabulary 리스트를 만들고 word2idx, idx2word dict를 만듭니다.

vocabulary = []
for sentence in tokenized_corpus:
    for token in sentence:
        if token not in vocabulary:
            vocabulary.append(token)

word2idx = {w: idx for (idx, w) in enumerate(vocabulary)}
idx2word = {idx: w for (idx, w) in enumerate(vocabulary)}

vocabulary_size = len(vocabulary)

Skip-Gram이나 CBOW 모두 window_size 가 필요합니다. 해당 파라메터는 주변의 단어를 몇개까지 학습에 이용할 것인가를 결정해주는 파라메터입니다. 이번 예제에서는 2개의 단어만 학습에 활용하도록 하겠습니다.

window_size = 2
idx_pairs = []

for sentence in tokenized_corpus:
    indices = [word2idx[word] for word in sentence]
    for center_word_pos in range(len(indices)):
        for w in range(-window_size, window_size + 1):
            context_word_pos = center_word_pos + w
            if context_word_pos < 0 or context_word_pos >= len(indices) or center_word_pos == context_word_pos:
                continue
            context_word_idx = indices[context_word_pos]
            idx_pairs.append((indices[center_word_pos], context_word_idx))

idx_pairs = np.array(idx_pairs) 

위와 같은 과정을 통해서 idx_pairs를 만들 수 있습니다. array에서 10개만 출력해보면 아래와 같은 배열을 볼 수 있습니다.

이것은 “he is a man”이라는 단어를 학습 할 때에 [he, is],[he,a],[is, he],[is,a],[is,man] … 형태의 학습데이터입니다. COBOW 방식은 주변의 단어들을 통해서 목적단어를 예측하는 형태라면 skip-gram 방식은 목적단어를 통해서 주변에 나올 수 있는 단어 [is, a]를 예측하는 방법으로 학습이 진행됩니다.

print(idx_pairs[0:10])
array([[0, 1],
       [0, 2],
       [1, 0],
       [1, 2],
       [1, 3],
       [2, 0],
       [2, 1],
       [2, 3],
       [3, 1],
       [3, 2]])

입력 데이터를 One-Hot 형태로 변경합니다. 참고로 One-Hot 형태를 사용하지 않고 nn.Embedding()을 통해서 룩업테이블(Look-Up Table)을 만들어 사용해도 무방합니다. nn.Embedding()을 사용하는 법은 이전 글에서 다뤘기 때문에 자세한 내용은 해당 게시물을 참조하시기 바랍니다.

def get_input_layer(word_idx):
    return np.eye(vocabulary_size)[word_idx]

X = []
y = []
for data, target in idx_pairs:
    X.append(get_input_layer(data))
    y.append(target)
    
X = torch.FloatTensor(np.array(X))
y = torch.Tensor(np.array(y)).long()

이제 신경망 모듈을 아래와 같이 생성합니다. 입력과 출력 사이에 2차원의 벡터형태로 정보를 압축하게됩니다.

class Word2VecModel(nn.Module):
    def __init__(self,inout_dim):
        super().__init__()
        self.linear1 = nn.Linear(inout_dim,2)
        self.linear2 = nn.Linear(2,inout_dim)
        
    def forward(self,x):
        return self.linear2(self.linear1(x))
    
model = Word2VecModel(X.size(dim=-1))

아래와 같이 데이터를 훈련합니다. 예측치(prediction)와 실제 값(y)를 통해서 cost를 계산하고 이를 출력해줍니다.

# optimizer 설정
optimizer = optim.Adam(model.parameters())

nb_epochs = 100
for epoch in range(nb_epochs + 1):

    # H(x) 계산
    prediction = model(X)

    # cost 계산
    cost = F.cross_entropy(prediction, y)

    # cost로 H(x) 개선
    optimizer.zero_grad()
    cost.backward()
    optimizer.step()
    
    # 20번마다 로그 출력
    if epoch % 100 == 0:
        print('Epoch {:4d}/{} Cost: {:.6f}'.format(
            epoch, nb_epochs, cost.item()
        ))

훈련이 완료된 후에 생성된 weight 정보를 출력해봅니다.

vector = model.state_dict()['linear2.weight'] + model.state_dict()['linear2.bias'].view(-1,1)
w2v_df = pd.DataFrame(vector.numpy(), columns = ['x1', 'x2'])
w2v_df['word'] = vocab
w2v_df = w2v_df[['word','x1','x2']]
w2v_df
ano = w2v_df['word'].values
x1 = w2v_df['x1'].values
x2 = w2v_df['x2'].values

fig, ax = plt.subplots(figsize=(5,5))
ax.scatter(x1, x2)

for i, txt in enumerate(ano):
    ax.annotate(txt, (x1[i], x2[i]))

2차원 벡터를 통해서 아래와 같이 시각화해봅니다.

Reference

[1]https://ratsgo.github.io/from%20frequency%20to%20semantics/2017/03/30/word2vec/
[2]https://towardsdatascience.com/nlp-101-word2vec-skip-gram-and-cbow-93512ee24314
[3]https://wikidocs.net/22660

Word2Vec 시각화

Word2Vec은 간단을 간단히 말하면 “문장안에 있는 여러 단어들을 벡터 형태로 표현하는 것” 말 그대로 Word to Vector라고 할 수 있습니다. 워드라는 말은 쉽게 이해할 수 있지만 벡터(Vector)는 어떤 뜻일까요?

물리학이나 수학에서 약간씩 차이가 있지만 공통적으로 어떤 공간에서 위치와 방향성을 가지는 값을 표현하는 것이라고 할 수 있습니다. 그러니까 문장에 많은 Word를 어떤 공간에 위치값을 표시할 뿐만아니라 이 값들이 어떤 방향성이 있는지를 표시하는 기법이 Word2Vec이라고 하겠습니다.

위의 이미지는 word2vec의 가장 유명한 그림 중에 하나입니다. 각 단어들을 보면 어떤 방향성이 있고 숫자 값을 가지고 있습니다. 그렇기 때문에 유사도를 계산 할 수도 있고 각 단어의 관계에 대한 연산이 가능합니다.
예를 들어서 “KING-MAN+WOMAN=QUEEN”이라는 관계가 나온다는 것이죠.
또 “한국-서울+도쿄=일본”라는 관계를 추출할 수 있습니다. 아래 링크를 방문해보시고 다양한 케이스를 테스트해보시기 바랍니다.
https://word2vec.kr/search/

이것은 전통적인 방법인 One-Hot-Encoding을 통해서 단어를 표현하는 것의 문제점을 극복할 수 있는 아주 유용한 방법입니다. 이렇게 단어들을 벡터로 바꾸는 것을 워드 임베딩(Word-Embedding)이라고 하고 그중에서 가장 대표적인 모델이 Word2Vec 모델로 해당 단어와 함께 자주 등장하는 단어는 비슷한 단어일것이라는 가정으로 출발합니다.

본 예제는 Word2Vec의 원리와 이론을 소개하는 것은 아니고 실제로 단어를 2차원 공간에 표시하는 방법에 대한 예제코드이기 때문에 해당 이론이 궁금하신 분들은 인터넷에 공개된 많은 예제들이 있으니 참고해보시기 바랍니다.

예제를 실해하기 위해서 먼저 필요한 라이브러리를 import합니다.
분석할 데이터는 인터넷 쇼핑몰의 마우스를 구매한 후에 남긴 후기들을 모은 것입니다. 예를 들어 제품의 이름을 선택했을 경우에 해당 단어와 가장 거리가 가까운 단어들이 긍정의 단어들이라면 제품의 평가가 좋을 것일테고 반대로 제품이 부정적인 단어들과 거리가 가깝다면 반대의 경우라고 생각할 수 있겠습니다.

import pandas as pd
import numpy as np

df_r = pd.read_excel("./mouse_review.xlsx")
df_r.head()

파일을 읽어온 뒤에 pandas의 head() 함수로 상위 5개의 데이터를 추출해봅니다.
데이터는 사용자, 작성일, 리뷰 내용, 별점, 제품명 정보가 있습니다.

이번에 사용할 text 정보는 리뷰 내용입니다. 리뷰에 보면 여러가지 특수기호, 영문자 등이 있기 때문에 정규식을 통해서 한글 외에 나머지 데이터를 걸러냅니다. 걸러낸 데이터는 review_train 컬럼을 만들어서 원본 데이터와 별도로 저장해둡니다.

df_r['review_train'] = df_r['review'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
df_r.head()

1차로 정규화를 끝낸 텍스트 데이터를 통해서 문장을 형태소별로 분리해줍니다. 또 사용하지 않는 단어들의 사전을 모아서 불용단어를 걸러냅니다. 분석하고자 하는 상황에 맞춰서 불용어를 등록해줍니다.

from konlpy.tag import Okt

stop_words = ['가','요','변','을','수','에','문','제','를','이','도','은','다','게','요','한','일','할','인데','거','좀','는데','ㅎㅎ','뭐','까','있는','잘','습니다','다면','했','주려','지','있','못','후','중','줄']

okt = Okt()
tokenized_data = []
for sentence in df_r['review_train']:
    temp_X = okt.morphs(sentence, stem=True) # 토큰화
    temp_X = [word for word in temp_X if not word in stop_words] 
    tokenized_data.append(temp_X)

이제 시각화를 위한 준비를 해줍니다. 시각화는 matplolib을 사용합니다.
한글화를 위해서 폰트를 설정해줍니다.
본 예제는 Mac OS환경에서 테스트 되었기 때문에 폰트의 위치는 Window 사용자와 틀릴 수 있으니 테스트 환경에 맞게 폰트 정보를 변경해줍니다.

import matplotlib.pyplot as plt
from matplotlib import font_manager, rc
font_name = font_manager.FontProperties(fname='/System/Library/Fonts/Supplemental/AppleGothic.ttf').get_name()
rc('font', family=font_name)

리뷰 텍스트의 정보들을 간단히 표시해줍니다.
학습에 필요한 단계는 아니니 데이터에 대한 정보를 보고자 하지 않는다면 그냥 넘어가셔도 되겠습니다. 본 예제에 사용된 데이터는 대부분 길이가 0~50글자 사이의 비교적 짧은 문장들이라는 것을 알 수 있습니다.

print('리뷰의 최대 길이 :',max(len(l) for l in tokenized_data))
print('리뷰의 평균 길이 :',sum(map(len, tokenized_data))/len(tokenized_data))
plt.hist([len(s) for s in tokenized_data], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()

이제 Word2Vec 모델을 생성할 차례입니다.
Word2Vec 모델은 가장 잘 알려진 gensim 라이브러리를 활용해보겠습니다.

사용한 파라메터의 자세한 정보는 아래 링크를 참조해보시기 바랍니다.
본 모델은 좌우 5개의 단어를 참조하는 100차원의 워드 벡터를 만드는 모델로 cobow 알고리즘을 사용하고 최소 5번 이하로 등장하는 단어들은 제외하겠습니다. worker는 thread의 갯수로 테스트하는 하드웨어의 성능에 따라서 조정할 수 있습니다.
https://radimrehurek.com/gensim/models/word2vec.html

from gensim.models import Word2Vec
model = Word2Vec(sentences = tokenized_data, size = 100, window = 5, min_count = 5, workers = 4, sg = 0)

학습 데이터가 많지 않기 대문에 학습 시간은 오래 걸리지 않습니다.
학습이 끝난 후에 단어들을 추출해서 벡터 리스트를 생성합니다. 해당 리스트 하나를 출력해보면 아래와 같은 데이터가 표시됩니다.

vocabs = model.wv.vocab.keys()
word_vocab_list = [model.wv[v] for v in vocabs]
array([ 0.45729467, -0.45482287,  0.2776271 , -0.38435346,  0.4311736 ,
       -0.36617622,  0.12129851, -0.309033  , -0.09569103, -0.27311006,
        0.28018764, -0.13276236,  0.13590969,  0.0521839 , -0.01882668,
        0.13234554, -0.02577238,  0.43111804, -0.6007069 ,  0.52846146,
        0.01065135, -0.20410554,  0.08504212, -0.5189065 ,  0.06219423,
       -0.10900757,  0.19578645, -0.01295294, -0.20757432, -0.17270625,
        0.08728364,  0.4751571 , -0.06208701, -0.3829262 ,  0.4810491 ,
       -0.27205822, -0.16547562, -0.2804698 ,  0.1357591 ,  0.16740464,
        0.53618526, -0.17420012,  0.06363445,  0.655636  ,  0.05952126,
       -0.6312642 ,  0.11448789, -0.00824977, -0.26018238, -0.33553734,
        0.18489622,  0.03913857, -0.5856825 , -0.08111028,  0.6696569 ,
        0.4201213 , -0.2061224 , -0.03785964, -0.0813726 ,  0.0297378 ,
       -0.5556496 , -0.0006753 ,  0.25876167,  0.08983239, -0.10351149,
        0.24005203,  0.21328437,  0.0797505 , -0.23059952, -0.32846287,
       -0.0017608 ,  0.51077896,  0.36693272,  0.2767188 , -0.47870687,
       -0.3036568 , -0.06708886, -0.4789917 , -0.08152916,  0.19817959,
        0.07031752, -0.34857494,  0.5963662 ,  0.02050934,  0.29983994,
        0.07854129,  0.40096822,  0.00098353, -0.26964054, -0.12954848,
        0.33181033, -0.07866482,  0.40206903, -0.37808138, -0.10669091,
       -0.15223539, -0.01180514, -0.13499472,  0.31345636,  0.08265099],
      dtype=float32)

Word2Vec에서 제공하는 함수인 most_similar()를 통해서 입력하는 단어와 가장 가까운 단어 정보를 표시해봅니다. “클릭”과 가장 가까운 단어는 “버튼”,”소리” 등의 순서로 각 단어간의 연관성이 매우 높다는 것을 알 수 있습니다.

아마도 마우스라는 제품의 특징상 클릭이라는 단어와 함께 버튼, 소리, 게임, 느낌 등의 단어가 많이 등장했다는 것을 알 수 있습니다.

print(model.wv.most_similar("클릭"))
[('버튼', 0.9999016523361206), ('소리', 0.9998948574066162), ('게임', 0.9998838305473328), ('느낌', 0.9998810291290283), ('아니다', 0.9998774528503418), ('되다', 0.9998745918273926), ('이나', 0.9998740553855896), ('만', 0.9998738765716553), ('재질', 0.9998738169670105), ('누르다', 0.9998728036880493)]

이제 각 단어와의 관계를 그래프로 나타내보겠습니다. 해당 데이터는 100차원의 데이터이고 그려보고자 하는 것은 2차원에 표시되는 그래프이기 때문에 차원을 축소할 필요가 있습니다.

잘알려진 차원축소 알고리즘으로 PCA기법이 있습니다.

PCA(Principal Component Analysis)는 차원축소(dimensionality reduction)와 변수추출(feature extraction) 기법으로 널리 쓰이고 있는 기법으로 데이터의 분산(variance)을 최대한 보존하면서 서로 직교하는 새 기저(축)를 찾아, 고차원 공간의 표본들을 선형 연관성이 없는 저차원 공간으로 변환하는 기법입니다. 
https://ratsgo.github.io/machine%20learning/2017/04/24/PCA/

from sklearn.decomposition import PCA
pca = PCA(n_components=2)
xys = pca.fit_transform(word_vocab_list)
xs = xys[:,0]
ys = xys[:,1]

#plt.figure(figsize=(10 ,10))
plt.scatter(xs, ys, marker = 'o')
plt.xlim(0,1), plt.ylim(0,0.01)
for i, v in enumerate(vocabs):
    plt.annotate(v, xy=(xs[i], ys[i]))

해당 기법을 통해서 아래와 같은 그래프를 그렸습니다. 해당 그래프는 전체 그래프에서 일부 구간(xlim, ylim)을 표시한 것으로 전체 데이터는 아닙니다.

높은 차원의 데이터를 평면으로 축소하면서 데이터의 구간이 많이 겹치는 것을 알 수 있습니다. 이러한 문제는 데이터를 더 높은 차원의 공간(3차원)에 표시한다던가 아니면 의미 없는 데이터들을 추출해서 데이터의 수를 줄여서 표시할 수도 있습니다.