Word2Vec 모델 기초 (2) - 코드 분석

원본 소스코드에서는 전체를 6개의 step으로 나누어서 정리하고 있다.
소스코드가 원래 긴데, 여기에 설명을 붙였더니.. 너무 길어졌다.
그래서.. step별로 코드를 잘라서 올린다.

원본 소스코드를 꽤 수정했다.
어차피 설명 붙이는거.. 보기 좋으면 이해도 쉽게 되니까.
원본과는 꽤 다를 수 있다. 참.. 필요없는 코드는 과감히 삭제했다.


word2vec_basic_all_by_kim.py <- 수정본(설명 포함)

word2vec_basic_core_by_kim.py <- 수정본(설명 제거)



필요한 모듈 추가. 이 부분은 step에 포함되어 있지 않다.
코드를 수정하면서 random 등의 모듈을 사용하지 않도록 처리했다.

import collections
import math
import os
import zipfile

import numpy as np
from six.moves import urllib
import tensorflow as tf


스텝 1
text8.zip 파일을 다운로드한다. 있으면 다운로드하지 않는다.
놀랍게도 압축 파일을 풀어서 사용한다. 그냥 풀어놓고 사용하면 훨씬 더 좋은데..
단어 갯수는 주석에 있는 것처럼 17,005,207.

# Step 1: 데이터 다운로드
url = 'http://mattmahoney.net/dc/'


def maybe_download(filename, expected_bytes):
"""Download a file if not present, and make sure it's the right size."""
if not os.path.exists(filename):
filename, _ = urllib.request.urlretrieve(url + filename, filename)
statinfo = os.stat(filename)
if statinfo.st_size == expected_bytes:
print('Found and verified', filename)
else:
print(statinfo.st_size)
raise Exception('Failed to verify ' + filename + '. Can you get to it with a browser?')
return filename

filename = maybe_download('text8.zip', 31344016)


def read_data(filename):
''' zip 파일에 포함된 텍스트 파일을 읽어서 단어 리스트 생성. 포함된 파일은 1개.
zip 파일은 30mb, txt 파일은 100mb. '''
with zipfile.ZipFile(filename) as f:
names = f.namelist() # ['text8']
contents = f.read(names[0]) # 크기 : 100,000,000바이트
text = tf.compat.as_str(contents) # 크기 : 100,000,000
return text.split() # 갯수 : 17005207


vocabulary = read_data(filename)
print('Data size', len(vocabulary)) # 17005207


스텝 2
1700만개의 단어 중에서 빈도가 높은 50,000개를 대상으로 데이터셋을 구축한다.
build_dataset 함수를 구성하는 것이 이번 단계의 목적인데.. 코드를 많이 수정했다.
가능하면 원본 코드를 보존하려고 했고,수정한 코드는 주석으로 막아 놓았다.
특히, 원본과 비교해서 build_dataset 함수의 반환값이 달라진 점과
딕셔너리 변수인 reverse_dictionary 대신 리스트를 사용하는 ordered_words를 사용한 것에 주의해야 한다.

비교해서# Step 2: 사전을 구축하고 거의 등장하지 않는 단어를 UNK 토큰으로 대체.
# UNK는 unknown 약자로 출현 빈도가 낮은 단어들을 모두 대체한다. UNK 갯수는 418391.
vocabulary_size = 50000


def build_dataset(words, n_words):
# count : [['UNK', -1], ('the', 1061396), ('of', 593677), ('and', 416629), ...]
# 크기는 50,000개. UNK가 들어 있고, -1을 뺐으니까 처음에 전달된 크기 사용.
# 빈도가 높은 5만개 추출.
# count에 포함된 마지막 데이터는 ('hif', 9). 9번 나왔는데 드물다고 얘기할 수 있는지는 의문.
unique = collections.Counter(words) # 중복 단어 제거
orders = unique.most_common(n_words - 1) # 단어에 대한 빈도 계산. 갯수를 지정하지 않으면 전체 계산.
count = [['UNK', -1]]
count.extend(orders)

# dictionary : (UNK, 0) (the, 1) (of, 2) (and, 3) (one, 4) (in, 5) (a, 6) (to, 7)
# 내용을 보면 단어에 번호를 매겼다는 것을 알 수 있다.
dictionary = {}
for word, _ in count:
dictionary[word] = len(dictionary)

# 위의 코드는 결국 0부터 1씩 증가하는 인덱스를 len(dictionary)로 표현했기 때문에
# enumerate 함수를 사용한 아래처럼 표현할 수 있다. len(dictionary)는 코드가 모호하다.
# for i, (word, _) in enumerate(count):
# dictionary[word] = i

# dictionary = {word: i for i, (word, _) in enumerate(count)}

# 단어 전체에 대해 인덱스 매핑. data는 단어를 가리키는 인덱스 리스트가 된다.
# 인덱스를 계산하기 위해 딕셔너리 대신 리스트를 사용할 수도 있고, 얼핏 보면 좋아보일 수도 있다.
# 리스트를 사용하면 이진 검색을 적용해야
data = []
for word in words:
if word in dictionary: # word가 dictionary에 존재한다면
index = dictionary[word]
else:
index = 0 # UNK는 0번째에 위치
count[0][1] += 1 # 갯수 : 418391
data.append(index)

# dictionary와 reversed_dictionary 내용
# 일련번호로 된 key와 value가 의미 있을까? 리스트로 처리하면 되지 않을까?
# (UNK, 0) (the, 1) (of, 2) (and, 3) (one, 4) (in, 5) (a, 6) (to, 7) (zero, 8) (nine, 9) (two, 10)
# (0, UNK) (1, the) (2, of) (3, and) (4, one) (5, in) (6, a) (7, to) (8, zero) (9, nine) (10, two)
# reversed_dictionary = dict(zip(dictionary.values(), dictionary.keys()))

# reversed_dictionary는 0부터 시작하는 일련번호를 갖기 때문에 딕셔너리로 만들면 오히려 어렵고 불편하다.
# 아래 코드는 리스트와 같다는 것을 증명하는 코드.
# a = list(dictionary.values())
# print(type(a), len(a))
# print(a[0], a[-1])
#
# b = list(range(len(dictionary.values())))
# print(b[0], b[-1])
#
# assert a == b

# reversed_dictionary 대신 key로 구성된 리스트 반환.
# [(0, UNK) (1, the) (2, of) (3, and) (4, one)]에서 인덱스를 제외하고 구성한 리스트.
# 원본에서는 dictionary 변수를 반환하고 있는데, 사용하지 않기 때문에 삭제.
return data, count, list(dictionary.keys())

# data : 단어에 대한 인덱스만으로 구성된 리스트
# count : 단어와 빈도 쌍으로 구성된 리스트. 중요한 변수이지만, 이번 코드에서는 사용 안함.
# ordered_words : 빈도에 따라 정렬된 단어 리스트
data, count, ordered_words = build_dataset(vocabulary, vocabulary_size)

# print('Most common words (+UNK)', count[:5])
# print('Sample data', data[:10], [ordered_words[i] for i in data[:10]], sep='\n')
# print('-'*50)
del vocabulary, count # 사용하지 않는 변수 삭제

# [출력 결과]
# Most common words (+UNK) [['UNK', 418390], ('the', 1061396), ('of', 593677), ('and', 416629), ('one', 411764)]
# Sample data
# [5234, 3081, 12, 6, 195, 2, 3134, 46, 59, 156]
# ['anarchism', 'originated', 'as', 'a', 'term', 'of', 'abuse', 'first', 'used', 'against']


스텝 3
전체 데이터로부터 minibatch에 사용할 샘플 데이터를 생성하는 단계로, generate_batch 함수를 만드는 것이 목적이다.
함수 처음에 assert문이 2개 있는데, 함수가 동작하기 위한 필수 조건이라고 생각하면 된다.
이 함수는 스텝5에 있는 반복문에서 한 번만 사용되기 때문에
batch_size, num_skips, skip_window와 같은 매개 변수는 항상 같은 값을 갖고 있다.
가령, num_skips는 항상 skip_window의 두 배이다.

# Step 3: skip-gram 모델에 사용할 학습 데이터를 생성할 함수 작성

# 원본에서는 data_index를 전역변수로 처리했는데, 여기서는 매개변수와 반환값으로 변경했다.
# data 변수 또한 전역변수였는데, 첫 번째 매개변수로 전달하도록 변경했다.
def generate_batch(data, batch_size, num_skips, skip_window, data_index):
''' Stochastic Gradient Descent 알고리즘에 사용할 minibatch 생성.
:param data : 단어 인덱스 리스트
:param batch_size : SGD 알고리즘에 적용할 데이터 갯수. 한 번에 처리할 크기.
:param num_skips : context window에서 구축할 (target, context) 쌍의 갯수.
:param skip_window : skip-gram 모델에 사용할 윈도우 크기.
1이라면 목표 단어(target) 양쪽에 1개 단어이므로 context window 크기는 3이 된다. (단어, target, 단어)
2라면 5가 된다. (단어 단어 target 단어 단어)
:param data_index : 첫 번째 context window에 들어갈 data에서의 시작 위치.
'''
# 조건이 False라면 비정상 종료
# num_skips <= batch_size. batch_size는 num_skips의 정수 배.
# num_skips는 skip_window의 2배 이하.
# num_skips가 skip_window의 2배일 때, context 윈도우의 모든 쌍 구성
assert batch_size % num_skips == 0
assert num_skips <= 2 * skip_window

temp = 'batch_size {}, num_skips {}, skip_window {}, data_index {}'
# 최초 : batch_size 8, num_skips 2, skip_window 1, data_index 0
# 학습 : batch_size 128, num_skips 2, skip_window 1, data_index 640000
# data_index는 64로 시작해서 64씩 증가한다. 나머지는 변경되지 않는다.
# print(temp.format(batch_size, num_skips, skip_window, data_index))

# ndarray에 값을 주지 않았다면 난수가 사용된다. 앞부분 10개만 표시.
# batch는 1차원, labels는 2차원.
# batch : [0 0 268907754 536870912 -1354956798 32767 32229680 131073]
# labels : [[0] [0] [268799296] [-805306368] [2] [0] [268811838] [131072]]
batch = np.ndarray(shape=(batch_size), dtype=np.int32)
labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32)

# span은 assert에 기술한 코드 때문에 항상 num_skips보다 크다.
span = 2 * skip_window + 1 # context = skip_window + target + skip_window
assert span > num_skips

# deque
# 처음과 마지막의 양쪽 끝에서 일어나는 입출력에 대해 가장 좋은 성능을 내는 자료구조
# maxlen 옵션이 없으면 크기 제한도 없고, 있다면 지정한 크기만큼만 사용 가능.
# maxlen을 3으로 전달하면 3개만 저장할 수 있고, 새로운 요소를 추가하면 반대쪽 요소가 자동으로 삭제됨.
# 여기서는 자동 삭제 기능 때문에 사용.
# data_index 번째부터 span 크기만큼 단어 인덱스 저장
# 첫 번째 context 윈도우 구성
buffer = collections.deque(maxlen=span)
for _ in range(span):
buffer.append(data[data_index])
data_index = (data_index + 1) % len(data) # 다음 단어 인덱스로 이동. len(data) = 17005207

# 위의 코드를 재구성(에러).
# data_index는 마지막에서 처음으로 cycle을 구성하기 때문에 오류 발생할 수 있다.
# buffer = collections.deque(data[data_index:data_index+span], maxlen=span)
# data_index = (data_index + span) % len(data)

# skip-gram은 타겟 단어로부터 주변의 컨텍스트 단어를 예측하는 모델이다.
# 학습하기 전에 단어들을 (target, context) 형태로 변환해 주어야 한다.
# 바깥 루프는 batch_size // num_skips
# 안쪽 루프는 num_skips
# batch_size는 num_skips로 나누어 떨어지기 때문에 정확하게 batch_size만큼 반복
for i in range(batch_size // num_skips):

# 원본 코드
# target = skip_window # target label은 buffer의 가운데 위치
# targets_to_avoid = [skip_window]
#
# for j in range(num_skips):
# while target in targets_to_avoid:
# target = random.randrange(span)
# targets_to_avoid.append(target)
# batch[i * num_skips + j] = buffer[skip_window]
# labels[i * num_skips + j, 0] = buffer[target]
# print(target, '**') # (2, 0), (0, 2), (0, 2), (0, 2)

# 재구성한 코드
# skip_window는 context의 가운데 위치한 단어의 인덱스
# skip_window가 3이라면 주변에 3개의 단어씩 위치하게 되고 3+1+3은 7개로 구성된 context가 만들어진다.
# 읽어올 데이터가 0부터 시작한다면 skip_window는 3이 되고, context의 가운데에 위치한다.
# 값을 생성할 때, num_skips와 span 중에서 신중하게 선택해야 한다.
# num_skips는 context로부터 구성할 단어들의 갯수이기 때문에
# num_skips를 사용하면 context에 포함된 단어들의 모든 인덱스가 반영되지 않을 수 있다.
# skip_window*2 == num_skips 일 때는 모든 단어를 사용하기 때문에 난수를 사용할 필요가 없다.
# 여기서는 항상 skip_window는 1, num_skips는 2의 값을 갖는다.
targets = list(range(span)) # 1. 0부터 span-1까지의 정수로 채운 다음
targets.pop(skip_window) # 2. skip_window번째 삭제
np.random.shuffle(targets) # 3. 난수를 사용해서 섞는다.

# batch : target 단어만 들어가고, num_skips만큼 같은 단어가 중복된다.
# labels : target을 제외한 단어만 들어가고, num_skips만큼 중복될 수 있다.
start = i * num_skips
batch[start:start+num_skips] = buffer[skip_window]

# span이 큰 값이기 때문에 targets에 포함된 모든 값을 사용하지 않을 수 있다.
# buffer는 numpy 데이터가 아니라서 슬라이스 연산 불가. 어쩔 수 없이 반복문 사용.
# numpy 배열을 사용하면서 마지막에 있는 num_skips 갯수만큼 사용하는 것이 나을 수도 있다.
for j in range(num_skips):
labels[start+j, 0] = buffer[targets[j]]
# print(targets[j], '**') # (2, 0), (0, 2), (0, 2), (0, 2)

# 새로운 요소가 들어가면서 가장 먼저 들어간 데이터 삭제
buffer.append(data[data_index])
data_index = (data_index + 1) % len(data)

# 아래는 generate_batch 함수를 최초 호출한 결과.
# 오른쪽 출력 결과를 보면, batch와 labels의 관계를 알 수 있다.
# print(buffer) # deque([195, 2, 3134], maxlen=3)
# print(data[:20]) # [5234, 3081, 12, 6, 195, 2, 3134, 46, 59, 156]
# print(batch[:20]) # [3081 3081 12 12 6 6 195 195]
# print(labels[:20]) # [[5234] [12] [6] [3081] [195] [12] [2] [6]]

# data_index는 반복문에서 batch_size // num_skips 만큼 증가한다.
# 최정적인 data_index는 마지막을 지난 위치를 가리키게 되니까, 정확한 계산을 위해 앞으로 돌려놓을 필요가 있다.
# span에 context 윈도우 전체 크기가 있으니까, span만큼 뒤로 이동한다.
data_index = (data_index + len(data) - span) % len(data)
return batch, labels, data_index

# generate_batch 함수 테스트. 아래 코드는 뒤쪽에서 사용하지 않는다.
batch, labels, data_index = generate_batch(data, batch_size=8, num_skips=2, skip_window=1, data_index=0)
for i in range(8):
print('{} {} -> {} {}'.format(batch[i], ordered_words[batch[i]],
labels[i, 0], ordered_words[labels[i, 0]]))
# [출력 결과]
# 3081 originated -> 12 as
# 3081 originated -> 5234 anarchism
# 12 as -> 6 a
# 12 as -> 3081 originated
# 6 a -> 195 term
# 6 a -> 12 as
# 195 term -> 2 of
# 195 term -> 6 a


스텝 4
Skip-Gram 모델을 구축한다.
CBOW는 데이터가 작은 경우에 사용하기 때문에 이번 코드에서는 나오지 않는다.
데이터가 달라지면 헷갈리기 때문에 앞부분에 seed 관련 함수를 호출하고 있다.
원본 코드에는 없는 부분이다.
optimizer 모델을 구축한 다음에 샘플링 단어들의 유사도를 측정하기 위해 similarity 모델도 구축하고 있다.

# Step 4: skip-gram 모델 구축
# 실행할 때마다 다른 결과가 나오는 것은 표시하려고 하는 샘플이 달라지기 때문.
# 아래 코드처럼 같은 값을 주면 항상 같은 단어에 대해 유사어를 보여준다.
# 그러나, 텐서플로우에서 사용하는 난수 때문에 유사어는 계속해서 달라진다.
# valid_examples = np.array([80, 84, 33, 81, 93, 17, 36, 82, 69, 65, 92, 39, 56, 52, 51, 32])

# numpy와 tensorflow의 난수를 모두 고정시키면 항상 같은 결과를 얻을 수 있다.
# seed를 고정시켜도 같은 결과를 얻을 수 있다. 다만 함수 호출은 난수를 사용하기 전에 일어나야 한다.
np.random.seed(1)
tf.set_random_seed(1)

# [출력 결과] seed가 1일 때, 101번 반복.
# Nearest to over: codify, maxima, asahi, varied, filament, prey, undercut, adoration,
# Nearest to war: enthusiastically, cephalon, the, as, franco, brent, of, been,
# Nearest to this: orthopedic, sla, kazakhstan, batcave, pusan, nineteen, mortar, state,
# Nearest to years: disengagement, basileus, canis, soldiery, sweeteners, foundations, polymorphism, surrey,
# Nearest to often: porgy, zero, aborted, volcanism, concealment, have, verses, bakery,
# Nearest to three: extinguished, eris, deanna, y, enders, cooled, wickedness, remus,
# Nearest to he: lucrezia, berenson, frustrating, acetylcholine, patriarchy, iggy, enacts, borgir,
# Nearest to states: hak, hung, mane, july, weakened, ambients, harder, magic,
# Nearest to may: discussions, balboa, hypersonic, subjugation, drawing, archaelogical, revitalization, ski,
# Nearest to time: adding, offers, abbey, socony, hohenstaufen, boiler, menstrual, disproved,
# Nearest to system: parchment, bifurcation, awareness, footprint, ajaccio, onetime, demoscene, smarter,
# Nearest to have: franks, the, fashioning, eugen, anarchism, chelmsford, christian, heizei,
# Nearest to many: anarchist, from, rosser, was, all, crowned, wine, believe,
# Nearest to most: modern, and, monetarism, of, prix, ned, magnets, balancing,
# Nearest to more: booby, gradually, broadcast, moynihan, logistical, conflated, dtd, tok,
# Nearest to be: euripides, to, the, think, gabor, lysis, confining, nbs,

batch_size = 128 # 일반적으로 16 <= batch_size <= 512
embedding_size = 128 # embedding vector 크기
skip_window = 1 # target 양쪽의 단어 갯수
num_skips = 2 # 컨텍스트로부터 생성할 레이블 갯수

# valid_examples : [80 84 33 81 93 17 36 82 69 65 92 39 56 52 51 32]
# replace는 중복 허용 안함. 100보다 작은 정수에서 16개 고르기.
valid_size = 16 # 유사성을 평가할 단어 집합 크기
valid_window = 100 # 앞쪽에 있는 분포들만 뽑기 위한 샘플
valid_examples = np.random.choice(valid_window, valid_size, replace=False)
num_sampled = 64 # negative 샘플링 갯수

# valid_dataset은 valid_examples 배열의 tf 상수 배열.
train_inputs = tf.placeholder(tf.int32, shape=[batch_size])
train_labels = tf.placeholder(tf.int32, shape=[batch_size, 1])
valid_dataset = tf.constant(valid_examples, dtype=tf.int32)

# NCE loss 변수. weights는 (50000, 128), biases는 (50000,).
truncated = tf.truncated_normal([vocabulary_size, embedding_size],
stddev=1.0 / math.sqrt(embedding_size))
nce_weights = tf.Variable(truncated)
nce_biases = tf.Variable(tf.zeros([vocabulary_size]))

# embeddings 벡터. embed는 바로 아래 있는 tf.nn.nce_loss 함수에서 단 1회 사용
embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0))
embed = tf.nn.embedding_lookup(embeddings, train_inputs)

# 배치 데이터에 대해 NCE loss 평균 계산
nce_loss = tf.nn.nce_loss(weights=nce_weights,
biases=nce_biases,
labels=train_labels,
inputs=embed,
num_sampled=num_sampled,
num_classes=vocabulary_size)
loss = tf.reduce_mean(nce_loss)

# SGD optimizer
optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)

# 유사도를 계산하기 위한 모델. 학습 모델은 optimizer까지 구축한 걸로 종료.
# minibatch 데이터(valid embeddings)와 모든 embeddins 사이의 cosine 유사도 계산
norm = tf.sqrt(tf.reduce_sum(tf.square(embeddings), 1, keep_dims=True))
normalized_embeddings = embeddings / norm
valid_embeddings = tf.nn.embedding_lookup(normalized_embeddings, valid_dataset)
similarity = tf.matmul(valid_embeddings, normalized_embeddings, transpose_b=True)

# print(valid_examples) # [97 11 15 75 47 64 62 36 39 77 12 91 20 3 84 68]
# print(valid_dataset) # (16,), int32)
# print('------------')
# print(embeddings) # float32, (50000, 128)
# print(embed) # float32, (128, 128)
# print(truncated) # float32, (50000, 128)
# print(nce_weights) # float32, (50000, 128)
# print(nce_biases) # float32, (50000,)
# print('------------')
# print(loss) # float32, ()
# print(norm) # float32, (50000, 1)
# print(normalized_embeddings) # float32, (50000, 128)
# print(valid_embeddings) # float32, (16, 128)
# print(similarity) # float32, (16, 50000)
# print('------------')


스텝 5
Skip-Gram 모델을 학습시키는 단계이다.
계산할 것도 많은데, 무려 10만번이나 반복하고 있다. 이러니 Negative Sampling 알고리즘을 사용하지 않을 수가 없다.

# Step 5: skip-gram 모델 학습
num_steps = 1000001 # 마지막 반복을 출력하기 위해 +1.

with tf.Session() as session:
session.run(tf.global_variables_initializer())

average_loss, data_index = 0, 0
for step in range(num_steps):
batch_inputs, batch_labels, data_index = generate_batch(data, batch_size, num_skips, skip_window, data_index)

feed_dict = {train_inputs: batch_inputs, train_labels: batch_labels}
_, loss_val = session.run([optimizer, loss], feed_dict=feed_dict)
average_loss += loss_val

# 마지막 2000번에 대한 평균 loss 표시
if step % 2000 == 0:
if step > 0:
average_loss /= 2000
print('Average loss at step {} : {}'.format(step, average_loss))
average_loss = 0

# 중간 결과 표시. 엄청난 비용이 발생하는 작업. 500번마다 적용할 경우 20%까지의 성능 감소 발생
if step % 10000 == 0:
# 16개의 단어로부터 vocabulary에 포함된 모든 단어까지의 거리 계산
# 샘플 단어 1개당 50000번 계산
# valid_dataset에 전달된 인덱스를 사용해서 유사도 계산
# valid_dataset은 valid_examples로 만든 텐서플로우 상수. 결국은 같은 값.
sim = similarity.eval() # (16, 50000)

for i in range(valid_size):
valid_word = ordered_words[valid_examples[i]]

# argsort 함수는 배열이 정렬되었을 때의 값들에 대한 순서 리스트 반환
# x = np.array([9, 4, 6])
# y = np.argsort(x) # [1, 2, 0]
# 순서대로 나열하면 [4, 6, 9]가 되어야 하므로
# 0번째는 4의 인덱스, 1번째는 6의 인덱스, 2번째는 9의 인덱스가 오게 된다.
#
# 가장 가까운 이웃 갯수. 0번째는 자기 자신.
# 전체 배열에서 [1:top_k + 1] 앞부분 8개만 슬라이싱.
# sim 앞에 - 기호를 붙여서 음수로 변환한 것은 정렬을 거꾸로 하기 위해서. 앞쪽 부분을 발췌할거니까.
# argsort 함수는 reverse 옵션이 없다.
# 0번째가 자기자신이라는 것은 0번째에 있는 인덱스가 가리키는 값이 가장 크다는 것을 뜻한다.
# 해당 단어와 가장 가까운 것은 자기자신이기 때문에 발생하는 현상.
# sim에서 i번째는 valid_dataset에서의 i번째 단어에 대한 유사도라는 뜻이다. valid_dataset에 대해 처리했으니까.
top_k = 8
nearest = (-sim[i, :]).argsort()[1:top_k + 1]

# 데이터 확인
# distances = (-sim[i, :]).argsort()
# print('nearest', distances[:5]) # nearest [ 32 16641 7 1 1811]
# print('앞쪽 유사도', sim[i, distances[:5]]) # 앞쪽 유사도 [ 0.99999994 0.34827131 0.34123397 0.3368538 0.33285105]
# print('뒤쪽 유사도', sim[i, distances[-5:]]) # 뒤쪽 유사도 [-0.32242468 -0.33061796 -0.332335 -0.33657372 -0.33887321]

# 재구성한 코드.
# 1. 단순하게 sim 배열의 i번째 데이터를 정렬하기 위한 것이므로 i번째라고 표시하는 것이 좋다.
# nearest = (-sim[i]).argsort()[1:top_k + 1]
#
# 2. 뒤집어서 정렬하는 것보다 정렬한 다음에 마지막을 발췌해서 뒤집는 것이 훨씬 좋다.
# nearest = sim[i].argsort()[-top_k - 1:-1][::-1]

# log_str = ''
# for k in range(top_k):
# close_word = ordered_words[nearest[k]]
# log_str += close_word + ', '
# print('Nearest to {}: {}'.format(valid_word, log_str))

# 재구성한 코드.
# 1. nearest에 대해 반복하면 코드가 간결해진다.
# log_str = ''
# for k in nearest:
# log_str += ordered_words[k] + ', '
# print('Nearest ** {}: {}'.format(valid_word, log_str))
#
# 2. 컴프리헨션을 사용하면 가장 쉽고 좋은 코드가 나온다.
# log_str = ', '.join([ordered_words[k] for k in nearest])
# print('Nearest -- {}: {}'.format(valid_word, log_str))

# final_embeddings는 normalized_embeddings 크기와 같고, 다음 단계에서 시각화하는 용도로 사용.
# ndarray, 크기는 (vocabulary_size, embedding_size), (50000, 128)
final_embeddings = normalized_embeddings.eval()


스텝 6
앞부분에 있는 500개의 단어를 시각화하는 단계이다.
고차원 데이터를 2차원 데이터로 변환하기 위해 tSNE 알고리즘을 사용한다.

# Step 6: embeddings 시각화

def plot_with_labels(low_dim_embs, labels, filename='tsne.png'):
assert low_dim_embs.shape[0] >= len(labels), 'More labels than embeddings'

plt.figure(figsize=(18, 18)) # in inches

# 원본 코드.
# 해당 좌표에 점을 표시하고, 오른쪽/하단 정렬로 단어를 표시한다.
for i, label in enumerate(labels):
x, y = low_dim_embs[i]

plt.scatter(x, y)
plt.annotate(label,
xy=(x, y),
xytext=(5, 2),
textcoords='offset points',
ha='right',
va='bottom')

# 재구성한 코드
# for (x, y), label in zip(low_dim_embs, labels):
# plt.scatter(x, y)
# plt.annotate(label,
# xy=(x, y),
# xytext=(5, 2),
# textcoords='offset points',
# ha='right',
# va='bottom')

plt.savefig(filename)

try:
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

# low_dim_embs 내부 갯수와 n_components가 같아야 한다.
# n_components : 차원. default는 2.
# perplexity : 가장 가까운 이웃 갯수. 보통 5~50. default는 30.
# n_iter : 최적화에 사용할 반복 횟수. 최소 200. default는 1000.
# init : embedding 초기화 방법. random과 pca 중에서 선택. pca가 보다 안정적. default는 random.
tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000)

# plot 갯수. 50000개의 embeddings로부터 앞쪽 일부만 사용.
# low_dim_embs에는 변환된 좌표 x, y가 들어있다.
plot_only = 500
low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only]) # (500, 2)
labels = ordered_words[:plot_only] # 재구성한 코드
# labels = [ordered_words[i] for i in range(plot_only)] # 원본 코드

plot_with_labels(low_dim_embs, labels)

except ImportError:
print('Please install sklearn, matplotlib, and scipy to show embeddings.')


[출력 결과]
Average loss at step 0 : 277.4032287597656
Average loss at step 2000 : 113.51876367092133
Average loss at step 4000 : 52.68634714698791
...
Average loss at step 96000 : 4.69243613755703
Average loss at step 98000 : 4.588341496825218
Average loss at step 100000 : 4.69426365506649


[시각화 결과]

'챗봇' 카테고리의 다른 글

Word2Vec 모델 기초 (2) - 코드 분석  (0) 2017.04.13
Word2Vec 모델 기초 (1) - 개념 정리  (0) 2017.04.12

Word2Vec 모델 기초 (1) - 개념 정리

챗봇을 만들어 볼 생각으로 많은 문서를 뒤졌다.
챗봇을 만드는 방법에는 여러 가지가 있지만,
대부분은 페이스북, 구글, 아마존, 마이크로소프트 등에서 제공하는 프레임웍을 사용하도록 되어 있었다.
대단한 챗봇을 만들 생각이 애초에 없었으므로
매우 어설프게 흉내만 내도 된다고 생각하고 있었는데
소스코드 수준에서의 챗봇은 찾기 힘들었다.

챗봇을 만들기에 앞서
여러 슬라이드를 보면서 배운 얕은 지식으로 보건데
word2vec, seq2seq, rnn 등의 기술은 반드시 포함되어야 하는 것 같다.

일단 첫 번째 작업으로
word2vec 알고리즘을 공부하려고 찾아보니
소스코드는 있는데.. 이것에 대한 설명이 많이 부족했다.
텐서플로우에서 word2vec 알고리즘 구현으로 가장 많이 알려진
word2vec_basic.py 파일을 분석해서 정리했다.

내용이 너무 많아서 여러 개의 문서로 나누어서 정리한다.
여기서는 개념 정리와 같은 기본적인 내용을 다루어 본다.

---------------------------------------------

참고 사이트 정리

  • word2vec 관련 이론 정리
    word2vec 모델을 이해하는데 필수적인 여러 가지 알고리즘을 일목요연하게 정리하고 있다.
    이 문서에서 사용되는 설명의 절반 이상을 이 사이트에서 참조, 정리했다.
  • word2vec 모델 설명
    텐서플로우 코리아에서 번역해 놓은 word2vec 모델에 대한 한글 설명.
    이곳에 관련 seq2seq를 비롯한 관련 설명을 함께 볼 수 있다.
  • word2vec_basic.py 원본 소스코드
    github에 올려놓은 소스 코드. word2vec 폴더에 여러 파일이 함께 있다.
  • word2vec, t-SNE 설명(최근우)
    좋은 설명과 더불어 mnist 데이터를 tSNE로 표현하고 있다. 그 외에도 머신러닝 관련해서 좋은 글이 많이 있다.
  • 텐서플로우를 이용해 자연어를 처리하기 – Word Embedding(Word2vec)
    솔라리스의 인공지능 연구실이라는 이름의 사이트로, 텐서플로우 코리아에서 번역해 놓은 문서를 좀더 친절하게 정리해 놓았다.
    많은 차이가 있지는 않지만, 번역이 좀더 자연스럽다. 그리고, word2vec_basic.py 원본 소스코드에 주석을 달아놨다.
  • 딥러닝 단어사전 - 기초 개념
    Wizard's Note라는 이름의 사이트로, 단어에 대한 설명 외에도 해외 논문을 번역해 놓은 좋은 글들이 많이 있다.

---------------------------------------------

용어 정리

  • Word Embedding
    고차원의 데이터를 그보다 낮은 차원으로 변환하면서 모든 데이터간의 관계가 성립되도록 처리하는 과정을 말한다.
    컴퓨터는 숫자만 인식할 수 있고, 한글이나 그림 등의 컴퓨터가 다루는 모든 것들은 반드시 숫자로 변환되어야 한다.
    Word Embeddins To Vector를 직역하면 "단어를 벡터(숫자)로 삽입하기" 정도가 된다. 다시 말해, 문자로 이루어진 단어를 숫자로 변환하는 것을 word embedding이라고 부른다.
    단어 자체를 아스키코드나 유니코드로 처리해서 지금까지 잘 사용해 왔지만, 이것만 갖고는 의미를 추론할 수가 없다.
    왕과 여왕이 관련 있는 단어이고, 왕은 남자, 여왕은 여자라는 사실을 인지하지는 못한다. 왕에서 남자를 빼면 여왕이 될 수도 있는데, 이걸 하고 싶은 것이다. 수없이 많은 단어들 중에서 '파이썬'이라는 단어와 가까운 단어가 무엇인지 알고 싶기 때문에 단어를 숫자로 변환할 필요가 있었다.
    word embedding에는 초기 모델인 NNLM과 RNNLM이 있고, 최근에 발표됐고 현재 가장 많이 사용하는 Word2Vec 모델은 CBOW와 Skip-Gram 모델을 사용해서 학습되어진다.
    word embedding 프로세스에는 신경망, 차원감소, 확률적 모델, 문맥상 표현 등의 여러 가지 처리 과정들이 포함된다.

  • Word2Vec 모델
    단어를 계산에 적용할 수 있는 숫자로 변환해서 다음에 올 단어를 예측하는 모델이다. 다음 단어를 예측하기 위해 Word Embedding을 구현해야 하는데, CBOW 또는 Skip-Gram 알고리즘을 사용한다.
    구글의 연구원인 Tomas Mikolov와 Kai Chen, Greg Corrado, Jeffrey Dean이 작성한 논문인 "Efficient Estimation of Word Representations in Vector Space(백터공간상에서 단어 의미의 효율적인 추정)"에서 제안한 알고리즘을 말한다. 기존의 알고리즘에 비해 자연어 처리(Natural Language Process)에서 엄청난 향상을 가져왔다. 변환된 벡터가 단순한 수학적 존재 이상의 복잡한 개념 표현을 넘어 추론까지도 쉽게 구현할 수 있다는 점에서 대단한 의미를 갖는다.

  • Vector Space Models(VSMs)
    자연어 처리(NLP) 에서 오랫동안 사용되어진 방법으로, 같은 컨텍스트에 있는 단어는 같은 semantic meaning을 공유한다고 가정한다. 이런 가정을 Distributional Hypothesis라고 한다. 이런 가정을 이용해서 자연어 처리를 하는 기법에는 크게 두 가지가 있다.
    Count-Based Methods (e.g. Latent Semantic Analysis)
    어떤 단어가 이웃 단어들과 같이 등장한 횟수를 계산하고 이 통계를 작고 밀도가 높은 벡터로 변환한다.
    Predictive Methods (e.g. Neural Probabilistic Language Models)
    작고 밀도가 높은 embeddings 벡터로 표현된 이웃 단어들을 이용해서 직접적으로 단어를 예측한다. Word2Vec은 가공하지 않은 원본 텍스트로부터 word embedding을 학습하는 계산 효율성이 좋은 predictive 모델이다.

  • 컨텍스트(context)
    CBOW와 Skip-Gram 모델에서 사용하는 용어로, "계산이 이루어지는 단어들"을 말한다. 컨텍스트는 구둣점으로 구분되어지는 문장(sentence)을 의미하는 것이 아니라 특정 단어 주변에 오는 단어들의 집합을 의미한다. "the cat sits on the" 컨텍스트가 있다면, sits라는 단어 양쪽으로 2개의 단어들이 더 있는데, 이들 단어를 모두 합친 5개의 단어가 컨텍스트가 되는 것이다. "the cat sits on the" 뒤에는 'mat'라는 추가 단어가 올 수 있지만, 컨텍스트에는 포함되지 않는다. 이 경우의 컨텍스트는 목표 단어 양쪽에 2개의 단어만을 허용한 경우이다.

  • CBOW 모델(Continuous Bag-of-Words)
    컨텍스트로부터 찾고자 하는 목표 단어를 예측하는 모델을 말한다. 예를 들어, "the cat sits on the"라는 컨텍스트로부터 ‘mat’이라는 목표 단어를 예측한다.
    "아이스크림이 너무 ____ 먹을 수 없었다.” 라는 문장에서, 누구나 생략된 ____ 부분의 단어를 추측할 수 있고, 대부분은 옳게 예측한다. CBOW 모델도 마찬가지의 방법을 사용한다. 주어진 단어에 대해 앞 뒤로 N/2개 씩 총 N개의 단어를 입력으로 사용하여, 주어진 단어를 맞추기 위한 네트워크를 만든다.
    CBOW는 크기가 작은 데이터셋에 적합하다.

  • Skip-Gram 모델
    처리하려고 하는 현재의 단어 하나를 사용해서 주변 단어들의 발생을 유추하는 모델이다. 예를 들어, 'mat'이라는 현재 단어로부터 "the cat sits on the"라는 컨텍스트를 예측한다.
    예측하는 단어들은 현재 단어 주변에서 샘플링하는데, "가까이 있는 단어일수록 현재 단어와 관련이 더 많다"는 원리를 적용하기 위해 멀리 떨어진 단어를 낮은 확률로 선택하는 방법을 사용한다. 나머지는 CBOW 모델과 방향만 반대이고, 거의 비슷하다.
    Skip-Gram model은 크기가 큰 데이터셋에 적합하다. 최근일수록 더욱 많은 데이터를 갖고 있기 때문에 주로 Skip-Gram 모델을 사용한다.

  • Skip-Gram 모델 구현
    원본 소스코드를 분석할 때, 이 부분의 설명을 반드시 이해하고 있어야 한다. 다음과 같은 데이터셋이 있다고 가정해 보자.

      the quick brown fox jumped over the lazy dog

    먼저 컨텍스트를 정의해야 한다. 컨텍스트는 어떤 형태로든 정의할 수 있지만, 사람들은 보통 구문론적 컨텍스트를 정의한다. 여기서는 간단하게 컨텍스트를 현재(목표) 단어의 왼쪽과 오른쪽에 있는 단어들의 윈도우로 정의한다. 윈도우 사이즈를 1로 하면 (context, target) 쌍으로 구성된 아래와 같은 데이터셋을 얻을 수 있다.

      ([the, brown], quick), ([quick fox], brown), ([brown, jumped], fox), …

    Skip-Gram 모델은 현재(목표) 단어로부터 컨텍스트를 예측한다는 점을 상기하라. 우리가 해야할 일은 ‘quick’이라는 현재 단어로부터 컨텍스트 ‘the’와 ‘brown’을 예측하는 것이다. 그래서, 아래와 같은 데이터셋이 만들어진다.

      (quick, the), (quick, brown), (brown, quick), (brown, fox), …

    목적 함수는 전체 데이터셋에 대해 정의될 수 있다. 그러나, 성능상의 이유로 보통 Stochastic Gradient Descent(SGD) 알고리즘을 사용해서 하나 또는 minibatch라고 부르는 일정 갯수의 데이터(배치)로 묶어서 최적화한다.
    일반적으로 데이터 갯수는 (16 <= 크기 <= 512)이다.

  • NCE(Noise-Contrastive Estimation)
    CBOW와 Skip-Gram 모델에서 사용하는 비용 계산 알고리즘을 말한다. 전체 데이터셋에 대해 SoftMax 함수를 적용하는 것이 아니라 샘플링으로 추출한 일부에 대해서만 적용하는 방법을 말한다. k개의 대비되는(contrastive) 단어들을 noise distribution에서 구해서 (몬테카를로) 평균을 구하는 것이 기본 알고리즘이다. Hierarchical SoftMax와 Negative Sampling 등의 여러 가지 방법이 있다.
    일반적으로 단어 갯수가 많을 때 사용하고, NCE를 사용하면 문제를 (실제 분포에서 얻은 샘플)과 (인공적으로 만든 잡음 분포에서 얻은 샘플)을 구별하는 이진 분류 문제로 바꿀 수 있게 된다.
    Negative Sampling에서 사용하는 목적 함수는 결과값이 최대화될 수 있는 형태로 구성한다. 현재(목표, target, positive) 단어에는 높은 확률을 부여하고, 나머지 단어(negative, noise)에는 낮은 확률을 부여해서 가장 큰 값을 만들 수 있는 공식을 사용한다. 특히, 계산 비용에서 전체 단어 V를 계산하는 것이 아니라 선택한 k개의 noise 단어들만 계산하면 되기 때문에 효율적이다. 텐서플로우에서는 tf.nn.nce_loss()에 구현되어 있다.

  • Hierarchical SoftMax
    CBOW와 Skip-Gram 모델은 내부적으로 SoftMax 알고리즘을 사용해서 계산을 진행하는데, 모든 단어에 대해 계산을 하고 normalization을 진행해야 하는데, 이것은 시간이 너무 오래 걸릴 수 밖에 없다.
    계산량을 줄일 수 있는 방법으로 Hierarchical SoftMax와 Negative Sampling 알고리즘이 있다.
    Hierarchical SoftMax 알고리즘은 계산량이 많은 SoftMax 함수를 빠르게 계산가능한 multinomial distribution 함수로 대체한다. 트리 자료구조에는 데이터를 저장하는 노드가 있고, 처음 노드를 루트(root), 마지막 노드를 리프(leaf) 또는 단말(terminal)이라고 부른다. multinomial distribution 함수는 루트에서 리프까지 가는 경로를 확률과 연동시켜서 계산 시간을 단축시킨다.
    Word2Vec 논문에서는 사용 빈도가 높은 단어에 대해 짧은 경로를 부여하는 Binary Huffman Tree를 사용한다. Huffman Tree는 경로의 길이가 일정한 full tree의 성질을 갖고 있기 때문에 성능 향상에는 더욱 이상적이게 된다.

  • Negative Sampling
    SoftMax 알고리즘을 몇 개의 샘플에 대해서만 적용하는 알고리즘이다. 전체 데이터로부터 일부만 뽑아서 SoftMax 계산을 수행하고 normalization을 진행한다. 이때 현재(목표) 단어는 반드시 계산을 수행해야 하기 때문에 Positive Sample이라 부르고, 나머지 단어를 Negative Sample이라고 부른다. Negative Sampling에서는 나머지 단어에 해당하는 Negative Sample을 추출하는 방법이 핵심이다.
    일반적으로 샘플링은 ‘Noise Distribution’을 정의하고 그 분포를 이용하여 일정 갯수를 추출하는데, 논문에서는 여러 분포를 실험적으로 사용해본 결과 ‘Unigram Distribution의 3/4 승’ (여기서 Unigram Distribution은 단어가 등장하는 비율에 비례하게 확률을 설정하는 분포라고 할 수 있다. 이 경우 각 확률을 3/4승 해준 후, Normalization factor로 나누어서 단어가 등장할 확률로 사용한 것이다) 을 이용한 분포가 unigram, uniform 등의 다른 분포들보다 훨씬 좋은 결과를 얻을 수 있었다고 한다.

  • tSNE (t-distributed Stochastic Neighbor Embedding)
    차원이 높은 데이터의 이웃한 구조를 유지하면서 낮은 차원으로 변환하는 알고리즘을 말한다. 보통 그래프의 형태로 출력하기 때문에 2차원을 주로 사용한다.
    존경하는 hinton 교수님과 Laurens van der Maaten이란 분이 만들었는데, hinton 교수님의 Stochastic Neighbor Embedding 알고리즘에 Student t-distribution을 적용해서 높은 차원의 데이터를 낮은 차원의 데이터로 낮추는 알고리즘이다.
    차원 축소 알고리즘으로 가장 유명한 PCA는 데이터 손실이 발생할 수 있기 때문에 여기서는 사용하지 않는다. 그러나, 50차원 정도의 적절한 차원으로 축소하려고 한다면, 일반적으로는 밀도가 높은 데이터에는 PCA, 밀도가 낮은 데이터에는 TruncatedSVD 알고리즘을 사용하는 것이 좋다.

---------------------------------------------

word2vec_basic.py 파일에서 사용하는 데이터 파일

  1. text8.zip(30mb)
  2. 압축을 풀면 텍스트 파일 1개 나옴. 확장자 없음.
  3. text8(대략 100mb)
    이 파일은 특이하게 100,000,000byte 크기를 가짐.
  4. 개행문자와 구둣점 없이 소문자 단어로만 구성되어 있고, 공백으로 구분됨
    개행문자가 없기 때문에 전체가 한 줄에 있음. 텍스트랭글러에서 열리지 않음.
  5. 단어 갯수는 17,005,207개.
  6. 맨 앞의 1024개만 발췌. 첫 번째 글자는 공백.
    anarchism originated as a term of abuse first used against early working
  7. class radicals including the diggers of the english revolution and
    the sans culottes of the french revolution whilst the term is still used
    in a pejorative way to describe any act that used violent means
    to destroy the organization of society it has also been taken up
    as a positive label by self defined anarchists the word anarchism is
    derived from the greek without archons ruler chief king anarchism as
    a political philosophy is the belief that rulers are unnecessary and
    should be abolished although there are differing interpretations of what
    this means anarchism also refers to related social movements that advocate
    the elimination of authoritarian institutions particularly the state
    the word anarchy as most anarchists use it does not imply chaos nihilism
    or anomie but rather a harmonious anti authoritarian society in place of
    what are regarded as authoritarian political structures and coercive
    economic institutions anarchists advocate


'챗봇' 카테고리의 다른 글

Word2Vec 모델 기초 (2) - 코드 분석  (0) 2017.04.13
Word2Vec 모델 기초 (1) - 개념 정리  (0) 2017.04.12