TensorFlow - (7) word2vec - Implementation

TensorFlow

Vector Representations of Words

word2vec or word embedding.

tensorflow/examples/tutorials/word2vec/word2vec_basic.py
tensorflow/models/embedding/word2vec.py
위 두 코드를 참고하자. 여기서 다루는 내용은 word2vec_basic.py 에 해당한다.

Implementation

원문에서는 TF graph를 생성하는 부분에 대해서, 그리고 트레이닝 하는 과정에 대해서 약간 다루지만 여기서는 주석을 단 코드로 대체하였다. 아래의 코드를 참고하자 (코드가 길기 때문에 제일 아래에 두었다). 원문에서 다루는 그래프 생성은 Step 5, 트레이닝은 Step 6에 해당한다. 또한 이 코드에서는 tSNE를 통한 2차원 시각화까지 제공하니, 원문을 참조하도록 하자.

Analogical Reasoning

word2vec이 주목받을 수 있었던 건 바로 이 유추 (analogical reasoning) 가 가능하기 때문이다. 단어를 유의미한 벡터공간으로 매핑하므로, 단어간의 유사도를 측정하여 king is to queen as father is to ? 따위의 질문에 대답이 가능하다. 조금 더 심플하게 표현하면 king - man + woman = queen 이러한 것이 가능하다는 것이다.

Optimizing the Implementation

하이퍼파라메터 (hyperparameter) 의 선택은 모델의 정확도에 큰 영향을 끼친다. 본 튜토리얼에서는 다루지 않지만, 이를 위해 데이터 subsampling 등 여러 트릭을 사용하여 하이퍼파라메터를 잘 튜닝해야 한다.

이 vanilla implementation 은 텐서플로의 유연성을 잘 보여준다. 예를 들어, object (loss) function 을 tf.nn.nce_loss() 대신 tf.nn.sampled_softmax_loss() 를 사용할 수도 있다. 만약 새로운 아이디어가 있다면, 직접 코드를 작성해도 된다. 텐서플로가 도함수를 계산해 줄 것이다. 이러한 유연성은 머신러닝 모델을 탐색할 때에는 별 의미가 없지만, 모델의 구조를 정한 후 속도를 최적화하고 코드를 개선할 때에는 매우 유용하다. 예를 들어, 우리의 코드에서 데이터를 읽어오는 과정이 상당한 시간을 소비하는데, New Data Formats 을 통해 최적화된 data reader 를 구현할 수 있다. word2vec.py 코드를 참고하자.

더이상 우리의 모델이 I/O bound가 아닌데도, 즉 데이터를 읽어오는 시간을 줄였는데도, 여전히 퍼포먼스를 향상시키고 싶다면 Adding a New Op 에서 설명하는 대로 직접 TensorFlow Ops 를 구현할 수 있다. tensorflow/models/embedding/word2vec_optimized.py 를 참고하자. 이러한 최적화 단계는 C++ 을 써야 할 가능성이 높다.

Conclusion

이 튜토리얼에서는 효율적으로 word embedding 을 학습하는 word2vec 모델에 대해서 다뤘다. word embedding 이 왜 유용한지, 어떻게 효율적으로 학습할 수 있는지 그리고 텐서플로로 어떻게 구현할 수 있는지. 또한 텐서플로가 머신러닝 모델을 탐색하는 초기 실험에서부터 모델 확립 후의 디테일한 최적화까지 유연하게 제공한다는 것을 보았다.

Code

# coding: utf-8
'''
참고: Step 3 가 없음. 원문이 그래서 그렇게 놔두었음.
'''

from __future__ import absolute_import
from __future__ import print_function

import collections
import math
import numpy as np
import os
import random
from six.moves import urllib
from six.moves import xrange  # pylint: disable=redefined-builtin
import tensorflow as tf
import zipfile

# Step 1: Download the data.
# 데이터를 다운로드함. 파일이 이미 있다면 제대로 받아졌는지 (파일 크기가 같은지) 확인.
# 다운로드 받은 후 filename을 리턴함.
print("Step 1: Download the data.")
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)


# Read the data into a string.
# file (zipfile) 을 읽어옴
# text8.zip 의 내용은 파일 하나임. 코드를 봐서는 ' '로 구분된 단어들인 듯.
def read_data(filename):
    f = zipfile.ZipFile(filename)
    for name in f.namelist():
        return f.read(name).split()
    f.close()

words = read_data(filename)
print('Data size', len(words))
print('Sample words: ', words[:10])

# Step 2: Build the dictionary and replace rare words with UNK token.
print("\nStep 2: Build the dictionary and replace rare words with UNK token.")
vocabulary_size = 50000

def build_dataset(words):
    """
    vocabulary_size 는 사용할 빈발 단어의 수를 뜻함.
    등장 빈도가 상위 50000개 (vocabulary_size) 안에 들지 못하는 단어들은 전부 UNK로 처리한다.

    :param words: 말 그대로 단어들의 list
    :return data: indices of words including UNK. 즉 words index list.
    :return count: 각 단어들의 등장 빈도를 카운팅한 collections.Counter
    :return dictionary: {"word": "index"}
    :return reverse_dictionary: {"index": "word"}. e.g.) {0: 'UNK', 1: 'the', ...}
    """
    count = [['UNK', -1]]
    count.extend(collections.Counter(words).most_common(vocabulary_size - 1))
    dictionary = dict()
    for word, _ in count:
        dictionary[word] = len(dictionary) # insert index to dictionary (len이 계속 증가하므로 결과적으로 index의 효과)
    data = list()
    unk_count = 0
    for word in words:
        if word in dictionary:
            index = dictionary[word]
        else:
            index = 0  # dictionary['UNK']
            unk_count = unk_count + 1
        data.append(index)
    count[0][1] = unk_count
    reverse_dictionary = dict(zip(dictionary.values(), dictionary.keys()))
    return data, count, dictionary, reverse_dictionary

data, count, dictionary, reverse_dictionary = build_dataset(words)
del words  # Hint to reduce memory.
print('Most common words (+UNK)', count[:5])
print('Sample data: ', data[:10])
print('Sample count: ', count[:10])
print('Sample dict: ', dictionary.items()[:10])
print('Sample reverse dict: ', reverse_dictionary.items()[:10])

data_index = 0


# Step 4: Function to generate a training batch for the skip-gram model.
print("\nStep 4: Function to generate a training batch for the skip-gram model.")
def generate_batch(batch_size, num_skips, skip_window):
    """
    minibatch를 생성하는 함수.
    data_index는 global로 선언되어 여기서는 static의 역할을 함. 즉, 이 함수가 계속 재호출되어도 data_index의 값은 유지된다.

    :param batch_size   : batch_size.
    :param num_skips    : context window 내에서 (target, context) pair를 얼마나 생성할 지.
    :param skip_window  : context window size. skip-gram 모델은 타겟 단어로부터 주변 단어를 예측하는데, skip_window가 그 주변 단어의 범위를 한정한다.
    :return batch       : mini-batch of data.
    :return labels      : labels of mini-batch. [batch_size][1] 의 2d array.
    """
    global data_index
    assert batch_size % num_skips == 0  # num_skips의 배수로 batch가 생성되므로.
    assert num_skips <= 2 * skip_window # num_skips == 2*skip_window 이면 모든 context window의 context에 대해 pair가 생성된다.
    # 즉, 그 이상 커지면 안 됨.

    batch = np.ndarray(shape=(batch_size), dtype=np.int32)
    labels = np.ndarray(shape=(batch_size, 1), dtype=np.int32)
    span = 2 * skip_window + 1 # [ skip_window target skip_window ]
    buffer = collections.deque(maxlen=span)
    # Deques are a generalization of stacks and queues.
    # The name is pronounced "deck" and is short for "double-ended queue".
    # 양쪽에 모두 push(append) & pop 을 할 수 있음.

    # buffer = data[data_index:data_index+span] with circling
    for _ in range(span):
        buffer.append(data[data_index])
        data_index = (data_index + 1) % len(data)

    # // 는 나머지 혹은 소수점 아래를 버리는 연산자
    # skip-gram은 타겟 단어로부터 주변의 컨텍스트 단어를 예측하는 모델이다.
    # skip-gram model을 학습하기 전에, words를 (target, context) 형태로 변환해 주어야 한다.
    # 아래 코드는 그 작업을 batch_size 크기로 수행한다.
    for i in range(batch_size // num_skips):
        target = skip_window  # target label at the center of the buffer
        targets_to_avoid = [ skip_window ]
        for j in range(num_skips):
            while target in targets_to_avoid:
                # context window에서 context를 뽑아내는 작업은 랜덤하게 이루어진다.
                # 단, skip_window*2 == num_skips 인 경우, 어차피 모든 context를 다 뽑아내므로 랜덤은 별 의미가 없음. 순서가 랜덤하게 될 뿐.
                target = random.randint(0, span - 1)

            targets_to_avoid.append(target)
            batch[i * num_skips + j] = buffer[skip_window]
            labels[i * num_skips + j, 0] = buffer[target]

        buffer.append(data[data_index])
        data_index = (data_index + 1) % len(data)

    return batch, labels

# batch가 어떻게 구성되는지를 보기 위해 한번 뽑아서 출력:
print("Generating batch ... ")
batch, labels = generate_batch(batch_size=8, num_skips=2, skip_window=1)
print("Sample batches: ", batch[:10])
print("Sample labels: ", labels[:10])
for i in range(8):
    print(batch[i], '->', labels[i, 0])
    print(reverse_dictionary[batch[i]], '->', reverse_dictionary[labels[i, 0]])


# Step 5: Build and train a skip-gram model.
print("\nStep 5: Build and train a skip-gram model.")
batch_size = 128
embedding_size = 128  # Dimension of the embedding vector.
skip_window = 1       # How many words to consider left and right.
num_skips = 2         # How many times to reuse an input to generate a label.

# We pick a random validation set to sample nearest neighbors. Here we limit the
# validation samples to the words that have a low numeric ID, which by
# construction are also the most frequent.
valid_size = 16     # Random set of words to evaluate similarity on.
valid_window = 100  # Only pick dev samples in the head of the distribution.
valid_examples = np.array(random.sample(np.arange(valid_window), valid_size))
# [0 ~ valid_window] 의 numpy array를 만들고 거기서 valid_size 만큼 샘플링함.
# 즉, 여기서는 0~99 사이의 수 중 랜덤하게 16개를 고른 것이 valid_examples 임.
num_sampled = 64    # Number of negative examples to sample.

print("valid_examples: ", valid_examples)

graph = tf.Graph()

with graph.as_default():

    # Input data.
    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)

    # Ops and variables pinned to the CPU because of missing GPU implementation
    # embedding_lookup이 GPU implementation이 구현이 안되어 있어서 CPU로 해야함.
    # default가 GPU라서 명시적으로 CPU라고 지정해줌.
    with tf.device('/cpu:0'):
        # Look up embeddings for inputs.
        # embedding matrix (vectors)
        embeddings = tf.Variable(tf.random_uniform([vocabulary_size, embedding_size], -1.0, 1.0))
        # 전체 embedding matrix에서 train_inputs (mini-batch; indices) 이 가리키는 임베딩 벡터만을 추출
        embed = tf.nn.embedding_lookup(embeddings, train_inputs)

        # Construct the variables for the NCE loss
        # NCE loss 는 logistic regression model 을 사용해서 정의된다.
        # 즉, logistic regression 을 위해, vocabulary의 각 단어들에 대해 weight와 bias가 필요함.
        nce_weights = tf.Variable(
            tf.truncated_normal([vocabulary_size, embedding_size],
                                stddev=1.0 / math.sqrt(embedding_size)))
        nce_biases = tf.Variable(tf.zeros([vocabulary_size]))

    # Compute the average NCE loss for the batch.
    # tf.nce_loss automatically draws a new sample of the negative labels each
    # time we evaluate the loss.
    loss = tf.reduce_mean(
        tf.nn.nce_loss(nce_weights, nce_biases, embed, train_labels,
                       num_sampled, vocabulary_size))

    # Construct the SGD optimizer using a learning rate of 1.0.
    optimizer = tf.train.GradientDescentOptimizer(1.0).minimize(loss)

    # Compute the cosine similarity between minibatch examples and all embeddings.
    # minibatch (valid_embeddings) 와 all embeddings 사이의 cosine similarity를 계산한다.
    # 이 과정은 학습이 진행되면서 각 valid_example 들에게 가장 가까운 단어가 어떤 것인지를 보여주기 위함이다 (즉 학습 과정을 보여주기 위함).
    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)

# Step 6: Begin training
print("\nStep 6: Begin training")
num_steps = 100001

with tf.Session(graph=graph) as session:
    # We must initialize all variables before we use them.
    tf.initialize_all_variables().run()
    print("Initialized")

    average_loss = 0
    for step in xrange(num_steps):
        batch_inputs, batch_labels = generate_batch(batch_size, num_skips, skip_window)
        feed_dict = {train_inputs : batch_inputs, train_labels : batch_labels}

        # We perform one update step by evaluating the optimizer op (including it
        # in the list of returned values for session.run()
        # feed_dict를 사용해서 placeholder에 데이터를 집어넣고 학습시킴.
        _, loss_val = session.run([optimizer, loss], feed_dict=feed_dict)
        average_loss += loss_val

        if step % 2000 == 0:
            if step > 0:
                average_loss = average_loss / 2000
            # The average loss is an estimate of the loss over the last 2000 batches.
            print("Average loss at step ", step, ": ", average_loss)
            average_loss = 0

        # note that this is expensive (~20% slowdown if computed every 500 steps)
        if step % 10000 == 0:
            sim = similarity.eval()
            for i in xrange(valid_size):
                valid_word = reverse_dictionary[valid_examples[i]]
                top_k = 8 # number of nearest neighbors
                nearest = (-sim[i, :]).argsort()[1:top_k+1]
                log_str = "Nearest to %s:" % valid_word
                for k in xrange(top_k):
                    close_word = reverse_dictionary[nearest[k]]
                    log_str = "%s %s," % (log_str, close_word)
                print(log_str)
    final_embeddings = normalized_embeddings.eval()

# Step 7: Visualize the embeddings.
print("\nStep 7: Visualize the 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')

    plt.savefig(filename)

try:
    # 혹시 여기서 에러가 난다면, scikit-learn 과 matplotlib 을 최신버전으로 업데이트하자.
    from sklearn.manifold import TSNE
    import matplotlib.pyplot as plt

    tsne = TSNE(perplexity=30, n_components=2, init='pca', n_iter=5000)
    plot_only = 500

    low_dim_embs = tsne.fit_transform(final_embeddings[:plot_only,:])
    labels = [reverse_dictionary[i] for i in xrange(plot_only)]
    plot_with_labels(low_dim_embs, labels)

except ImportError:
    print("Please install sklearn and matplotlib to visualize embeddings.")

'DataScience > Deep Learning' 카테고리의 다른 글

Backpropgation  (0) 2015.12.30
Transfer Learning  (0) 2015.12.26
TensorFlow - (7) word2vec - Implementation  (0) 2015.12.26
TensorFlow - (6) word2vec - Theory  (0) 2015.12.26
TensorFlow - (5) MNIST - CNN  (2) 2015.12.03
TensorFlow - (4) MNIST - Softmax Regression  (0) 2015.12.03

TensorFlow - (6) word2vec - Theory

TensorFlow

Vector Representations of Words

word2vec or word embedding.

Highlights

  • 왜 단어를 벡터로 나타내야 하는가?
  • 모델의 개념과 어떻게 학습되는가
  • 텐서플로를 통한 간단한 버전의 구현
  • 간단한 버전을 좀 더 복잡하게

기본 버전인 word2vec_basic.py 과 좀 더 진보된 버전인 word2vec.py 를 제공하니 참고하자.

하지만 그 전에, 왜 word embedding을 해야 하는지를 먼저 살펴보자.

Motivation: Why Learn Word Embeddings?

이미지나 오디오 데이터는 dense데이터임. 얼굴인식이나 음성인식 등의 이미지/오디오 데이터를 사용하는 작업은 이 데이터 안에 모든 필요한 정보가 담겨있다 (사람의 경우를 생각해보자 - 이미지만 보고 그 이미지로부터 얼굴을 인식할 수 있다). 반면, NLP에서는 단어를 표현하기 위해 one-hot 벡터를 사용하고, 이 방법은 단어간의 관계를 나타내는데 아무 도움이 안 됨. one-hot 벡터 방식으로 데이터를 나타내는 것은 데이터의 sparsity 문제를 가질 뿐만 아니라 통계적인 모델을 학습할 때 굉장히 많은 데이터가 필요하게 된다 - 예를 들면 ‘cat’에 대해 학습한 내용은 ‘dog’에 대해 학습한 내용에 전혀 영향을 끼치지 못함. 실제로는 서로 충분히 유사한 개체임에도 불구하고. 이러한 문제를 해결하기 위해, 단어의 concept에 대한 feature를 벡터로 나타낸 것이 바로 word embedding이다.

audio images text

Vector space models (VSMs) 는 연속적인 벡터 공간에서 단어를 나타낸다 (임베딩한다) - 비슷한 의미의 단어끼리 모이도록. VSM은 NLP에서 오래도록 다루어졌지만, 거의 모든 방법론들은 같은 컨텍스트의 단어들은 그 의미를 공유한다는 Distributional Hypothesis 에 기반한다.

Distributional Hypothesis는 언어학에서의 의미이론이다. 같은 컨텍스트에서 사용된 단어는 비슷한 의미를 가진다는. 즉 같이 사용된 주변 단어로부터 그 단어를 규정할 수 있다는 것.

이와 다른 접근법은 두가지 카테고리로 나눌 수 있다: count-based methods (e.g. Latent Semantic Analysis) 와 predictive methods (neural probabilistic language models). Count-based method는 거대한 텍스트 코퍼스에서 단어들이 어떤 단어들과 같이 등장하는지를 세고, 이를 작고 dense한 벡터로 압축한다. Predictive model은 이미 학습된 주변 단어들로부터 타겟 단어의 벡터를 예측한다.

랭귀지 모델은 자연어로 된 텍스트 (sequence of words) 에 대해, 단어 시퀀스의 중요한 통계적 분포 특징을 내포한다. 즉, 이 모델은 자연어 텍스트에 대해 통계적 단어 시퀀스 분포를 갖고, 이를 통해 특정 단어 시퀀스 뒤에 어떤 단어가 나올지 확률적 예측을 할 수 있다.
뉴럴 네트워크 랭귀지 모델은 NN에 기반한 랭귀지 모델이다. curse of dimensionality 의 효과를 줄이는 distributed representation을 학습할 수 있다. 러닝 알고리즘의 컨텍스트에서 curse of dimensionality란, 복잡한 함수를 학습할 때 방대한 양의 트레이닝 데이터가 필요한 것을 가리킨다. 인풋 변수의 수가 증가하면 학습을 위한 데이터의 수도 지수적으로 증가한다.

Word2vec은 계산-효율적인 (computationally-efficient) predictive model이다. word2vec은 CBOW (Continuous Bag-of-Words model) 와 Skip-Gram model 이라는 두가지 주요한 특징을 갖는다. 이 두 모델은 알고리즘적으로 유사한데, 단지 CBOW는 컨텍스트 (주변 단어들) 로부터 타겟 단어의 벡터를 예측하고 반대로 skip-gram 은 타겟 단어로부터 주변 컨텍스트 단어들의 벡터를 예측한다. 이 inversion은 이상해보일 수 있는데, 통계학적으로 CBOW는 전체 컨텍스트를 하나의 관찰로 다룸으로써 분산되어 있는 정보를 스무스하게 만드는 효과가 있다. 이는 작은 데이터셋에서 효과적이다. 반면 skip-gram은 모든 타겟 단어 - 컨텍스트 단어 페어 각각을 새로운 관찰로 다루고, 이는 커다란 데이터셋에서 효과적이다. 우리는 skip-gram 모델에 집중할것이다.

Scaling up with Noise-Contrastive Training

Maximum-Likelihood Estimation (MLE)

들어가기 전에 MLE를 먼저 살펴보자. 이 개념이 매번 헷갈려서 정리도 한번 했었는데 아직도 헷갈림. -_-

MLE는 주어진 데이터의 통계모델의 파라메터를 추정하는 방법론이다. 예를 들어, 성장한 여성 펭귄의 키 분포를 알고 싶다고 하자. 모든 펭귄의 키를 다 잴수는 없다. 키 분포가 정규분포를 따른다고 가정하자. 그러면 이때 평균과 분산을 알면 전체 분포를 알 수 있다. 이를 어떻게 알 수 있을까? MLE는 여기서 일부 데이터를 기반으로 모집단의 파라메터, 즉 평균과 분산을 추정한다 - 측정한 데이터가 나올 확률이 가장 높은 모집단을 추정하는 방식으로.

일반적으로, 고정된 수의 데이터와 통계 모델에 기반해서, MLE는 likelihood function을 최대화하는 파라메터를 선택한다. 직관적으로, 관찰된 데이터로부터 선택된 모델의 “agreement” 를 최대화한다. 그리고 결과 분포에서 주어진 데이터의 확률을 최대화한다.

likelihood function (or simply likelihood) : 통계모델의 파라메터의 함수. “probability” 와 비슷하게 쓰이지만, 통계학적으로 “결과” 냐 “파라메터” 냐의 차이가 있다. Probability는 정해진 파라메터를 기반으로 결과 함수를 설명할 때 쓰인다 - e.g. 동전을 10번 튀겼고, 공평한 동전이라면, 이 때 항상 앞면이 나올 probability는 얼마일까? 반면 likelihood는 주어진 결과를 기반으로 파라메터의 함수를 설명할때 쓰인다 - e.g. 동전을 10번 튀겼고, 10번 다 앞면이 나왔을 때, 이 동전이 공평할 likelihood는 얼마일까?

Return to Scaling up with Noise-Contrastive Training

Neural probabilistic language model은 이전 단어들 (for “history”) 가 주어졌을 때 다음 단어 (for “target”) 의 확률을 추정하는 MLE를 통해 학습된다. 이 과정은 softmax function에 기반한다:

여기서 는 타겟 단어 와 컨텍스트 의 공존 가능성 (compatibility) 를 계산한다 - 보통 dot product를 쓴다. 이 모델을 학습하기 위해 트레이닝 셋에 대해, log-likelihood를 최대화한다:

근데 가 probability (posterior) 아닌가? likelihood면 여야 할 것 같은데…

이 방법은 적절하게 normalized된 probabilistic language model 을 학습하지만, 문제는 이 방법은 너무 비싸다. 다음에 어떤 단어가 나올지를 예측하기 위해 모든 단어들에 대해 확률을 전부 계산하고 노멀라이즈 해야 한다. 그리고 이 과정을 모든 training step마다 반복해야 한다.

softmax-nplm

반면, word2vec의 feature learning 에서는 full probabilistic model을 학습할 필요가 없다. CBOW와 skip-gram 모델이 binary classification object (logistic regression) 을 사용해서 학습하는 대신, 같은 컨텍스트에서 개의 가상의 (noise) 단어 로부터 타겟 단어 를 구별한다. 아래는 CBOW에 대한 그림이다. skip-gram은 단지 방향만 반대로 하면 된다:

CBOW

잘 이해는 안 가지만, 이미지를 참고하면, 원래는 모든 가능한 단어 에 대해 확률을 구해봐야 했지만 CBOW 혹은 skip-gram 에서는 k개의 imaginary (noise) 단어 에 대해서만 테스트하여 학습 속도를 향상시킨다.

수학적으로, 이 예제에 대해, 다음 objective 를 최대화 하는 것을 목표로 한다:

는, 임베딩 벡터 를 학습하면서, 데이터셋 에서 컨텍스트 하에서 단어 가 나올 확률을 계산하는 binary logistic regression probability 모델이다. 실제 학습에서는, noise distribution으로부터 k contrastive words를 샘플링 (drawing) 함으로써 기대값 (expectation) 을 추정한다. (즉, Monte Carlo average 를 계산한다)

Monte Carlo average (integration):
몬테카를로 적분 (integration) 은 랜덤을 이용해서 적분하는 방법이다. 랜덤하게 난수를 발생시키고, 해당 난수가 적분 범위 안에 들어가는 확률을 계산하여 이를 통해 적분한다. 도형의 넓이를 계산한다고 생각해 보면 쉽게 이해할 수 있음.

여기서 임베딩 벡터 는 word embedding에서 바로 그 임베딩 벡터를 말함.

위의 목적 함수 (objective) 는 real word에 높은 확률을 할당하고 noise word에 낮은 확률을 할당했을 때 최대화된다. 기술적으로, 이는 Negative Sampling 이라 불린다. 이 함수는 위 소프트맥스 함수 () 를 근사하지만 훨씩 더 적은 계산량을 가지고, 이는 훨씬 빠른 학습속도를 제공한다. 우리는 정확하게는 이와 거의 유사한 noise-contrastive estimation (NCE) 를 사용한다. 이는 TensorFlow 에서 tf.nn.nce_loss()라는 함수로 제공하므로 편리하게 사용할 수 있다.

The Skip-gram Model

the quick brown fox jumped over the lazy dog

라는 데이터셋을 생각해보자. ‘context’ 라는 것은 다양하게 정의될 수 있지만, syntactic contexts는 보통 타겟 단어의 주변 단어를 가리킨다. 일단, ‘context’ 가 타겟 단어의 좌우 1칸을 가리킨다고 해 보자:

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

살펴보면 알겠지만 이는 (context, target) 쌍이다. skip-gram은 타겟 단어로부터 컨텍스트 단어를 예측한다. 즉, 우리는 ‘quick’ 으로부터 ‘the’ 와 ‘brown’ 을 예측하고, ‘brown’ 으로부터 ‘quick’ 과 ‘fox’ 를 예측해야 한다. 자, 그러면 데이터셋을 (input, output) 으로 구성하면 이렇게 된다:

(quick, the), (quick, brown), (brown, quick), (brown, fox), ...

object function 은 데이터셋 전체에 대한 함수이지만, 우리는 학습을 위해 online 혹은 minibatch learning 을 사용한다. 이를 자세히 살펴보자. 보통 minibatch 에서 batch_size 는 16에서 512 사이다.

위 트레이닝 셋에서 제일 첫 번째 케이스로 트레이닝 스텝 를 생각해보자. 우리의 목표는 quick 으로부터 the 를 예측하는 것이다. 먼저 noisy (contrastive) example 의 수를 나타내는 num_noise 를 선택해야 한다. noisy example 은 noise distribution 을 따르며, 이 분포는 일반적으로 unigram distribution 이다. 간단하게 하기 위해 num_noise=1 이라 하고 noisy example 로는 sheep 을 사용하자. 그러면 loss function을 계산할 수 있다:

unigram distribution 라는 것은 전체 데이터셋에서 각 단어의 unigram으로 생성한 확률분포를 의미하는 듯. sheep 이 위 데이터셋에 없다는 것이 이상한데, 일단 위 예제는 데이터셋의 일부라고 생각해보자.

이 과정의 목표는 임베딩 파라메터 를 업데이트하여 object function 을 최적화 (여기서는 최대화) 하는 것이다. 이를 위해, 임베딩 파라메터 에 대해 loss의 gradient를 계산한다. 여기서는 를 계산한다 - TensorFlow는 이를 위한 함수를 제공한다. 이후 이 gradient의 방향으로 임베딩 파라메터를 약간 업데이트한다. 이 과정을 전체 데이터셋에 대해 반복하면, 임베딩 벡터는 점차 실제 단어의 위치로 이동한다 - real words와 noise words가 분리될때까지.

이 학습 과정을 t-SNE dimensionality reduction technique 같은 방법을 사용해서 2차원 혹은 3차원 공간으로 차원축소하여 시각화 할 수 있다. 이 과정을 살펴보면, 우리가 원하는 대로 단어의 의미를 잘 추출하여 벡터공간에 임베딩하는 것을 확인할 수 있다:

word2vec visualization

즉, 이 벡터들은 기존의 NLP prediction task에서 훌륭한 특성으로 사용될 수 있다 - POS tagging or named entity recognition 등. Collobert et al. 또는 Turian et al. 을 참고하자.

'DataScience > Deep Learning' 카테고리의 다른 글

Transfer Learning  (0) 2015.12.26
TensorFlow - (7) word2vec - Implementation  (0) 2015.12.26
TensorFlow - (6) word2vec - Theory  (0) 2015.12.26
TensorFlow - (5) MNIST - CNN  (2) 2015.12.03
TensorFlow - (4) MNIST - Softmax Regression  (0) 2015.12.03
TensorFlow - (3) Basic Usage  (0) 2015.12.03

[kaggle] Bag of Words Meet Bags of Popcorn - (1) Part 1: Bag of Words

Part 1: For Beginners - Bag of Words

What is NLP?

NLP는 텍스트 문제에 접근하는 테크닉들의 집합이다. 이 페이지에서는 IMDB 영화 리뷰를 로드하고, 클리닝하고, 간단한 Bag of Words모델을 적용하여 리뷰가 긍정인지 부정인지 예측해본다.

Code

파트 1의 코드는 여기서 확인할 수 있다.

Reading the Data

데이터 페이지에서 필요한 파일들을 받을 수 있다. 일단 25,000개의 IMDB 영화 리뷰가 들어 있는 unlabeldTrainData.tsv가 필요하다.

그럼 이제 탭으로 구분되어 있는(tab-delimited) 파일을 파이썬으로 읽어보자. 이를 위해 pandas를 사용한다.

# Import the pandas package, then use the "read_csv" function to read
# the labeled training data
import pandas as pd       
train = pd.read_csv("labeledTrainData.tsv", header=0, \
                    delimiter="\t", quoting=3)

“header=0”은 파일의 첫번째 줄이 컬럼의 이름이라는 것을 나타내고, “delimiter=\t”는 구분자가 탭이라는 것을, “quoting=3”은 쌍따옴표(doubled quote)를 무시하라는 것을 의미한다. quoting을 주지 않으면 데이터를 불러올 때 쌍따옴표를 제거하고 불러온다.

아래와 같이 데이터를 출력해볼 수 있다.

print(train["review"][0])

Data Cleaning and Text Preprocessing

Removing HTML Markup: The BeautifulSoup Package

데이터를 보면 알겠지만 리뷰에 HTML태그가 포함되어 있다. 이를 제거하기 위해 Beautiful Soup를 사용하자.

# Import BeautifulSoup into your workspace
from bs4 import BeautifulSoup

# Initialize the BeautifulSoup object on a single movie review
example1 = BeautifulSoup(train["review"][0])

# Print the raw review and then the output of get_text(), for
# comparison
print(train["review"][0])
print(example1.get_text())

get_text함수는 html문서에서 text만 뽑아내는 것으로, 위와 같이 출력해 보면 태그가 삭제된 것을 확인할 수 있다.

Dealing with Punctuation, Numbers and Stopwords: NLTK and regular expressions

텍스트를 클리닝 할 때는 우리가 해결하고자 하는 문제가 무엇인지에 대해 생각해 보아야 한다. 많은 문제에서는 구두점(punctuation)을 제거하는 것이 일반적이지만, 이 문제에서는 감정분석 문제를 다루고 있고, “!!!”이나 “:-(” 등이 감정표현이 될 수 있으므로 구두점도 단어로 다뤄야 한다. 그러나 이 튜토리얼에서는 문제의 단순화를 위해 전부 제거할 것이다.

마찬가지로 이 튜토리얼에서는 숫자를 제거할 것이지만, 실제로는 숫자를 다루는 여러 방법이 있다. 숫자 또한 단어로 취급한다거나, 모든 숫자를 “NUM”이라는 플레이스홀더로 대체한다거나.

모든 구두점과 숫자를 제거하기 위해, 정규표현식(regular expression) 패키지 re를 사용하자.

import re
# Use regular expressions to do a find-and-replace
letters_only = re.sub("[^a-zA-Z]",           # The pattern to search for
                      " ",                   # The pattern to replace it with
                      example1.get_text() )  # The text to search
print(letters_only)

해보면 모든 구두점들이 공백으로 바뀐 것을 확인할 수 있다.

이제, 각 단어들을 소문자로 바꾸고 단어별로 분리하자. (NLP 에서는 “tokenization”이라 한다)

lower_case = letters_only.lower()        # Convert to lower case
words = lower_case.split()               # Split into words

자 이제 마지막으로, “stop words“라고 불리는 큰 의미가 없는 단어들을 어떻게 다룰 것인지 결정해야 한다. 영어에서 “a”, “and”, “is”, “the” 등이 여기에 속한다. 이를 처리하기 위해 Natural Language Toolkit (NLTK)를 사용한다. 참고로, nltk는 설치 후 nltk.download()를 통해 구성요소들을 다운로드 해 주어야 한다. (하지 않으면 하라고 에러 메시지가 뜬다)

import nltk
# nltk.download()  # Download text data sets, including stop words

from nltk.corpus import stopwords # Import the stop word list
print(stopwords.words("english"))

# Remove stop words from "words"
words = [w for w in words if not w in stopwords.words("english")]
print(words)

nltk는 stopwords corpus를 가지고 있다. 이를 불러와서 우리의 리뷰 데이터에서 stopword를 제거해주자. example1, 즉 첫번째 리뷰의 단어가 437개에서 222개로 절반 가까이 줄어드는 것을 확인할 수 있다!

텍스트 데이터에 할 수 있는 더 많은 것들이 있는데, 예를 들면 Porter Stemming and Lemmatizing(둘 다 nltk에서 할 수 있다)은 “messages”, “message”, “messaging”등의 단어들을 한 단어로 다룰 수 있게 해주는 매우 유용한 도구다. 하지만 간단함을 위해, 이 튜토리얼에서는 여기까지만 하도록 하자.

Putting it all together

이 코드들을 재사용할 수 있도록 함수로 합치자:

def review_to_words( raw_review ):
    # Function to convert a raw review to a string of words
    # The input is a single string (a raw movie review), and
    # the output is a single string (a preprocessed movie review)
    #
    # 1. Remove HTML
    review_text = BeautifulSoup(raw_review).get_text()
    #
    # 2. Remove non-letters
    letters_only = re.sub("[^a-zA-Z]", " ", review_text)
    #
    # 3. Convert to lower case, split into individual words
    words = letters_only.lower().split()
    #
    # 4. In Python, searching a set is much faster than searching
    #   a list, so convert the stop words to a set
    stops = set(stopwords.words("english"))
    #
    # 5. Remove stop words
    meaningful_words = [w for w in words if not w in stops]
    #
    # 6. Join the words back into one string separated by space,
    # and return the result.
    return( " ".join( meaningful_words ))

함수로 합치면서 두 가지가 달라졌는데, 첫번째는 속도를 위해 사용하는 set이고, 두번째는 최종적으로 추출한 단어들을 join으로 한 문장으로 합쳐 리턴한다.

clean_review = review_to_words( train["review"][0] )
print(clean_review)

이렇게 써 보면 동일한 결과를 확인할 수 있다. 이제, 모든 데이터를 클리닝 해 보자. 데이터가 25,000개나 되기 때문에 시간이 좀 걸린다:

# Get the number of reviews based on the dataframe column size
num_reviews = train["review"].size

# Initialize an empty list to hold the clean reviews
clean_train_reviews = []

# Loop over each review; create an index i that goes from 0 to the length of the movie review list
for i in range(num_reviews):
    if (i+1) % 1000 == 0:
        print("Review {0} of {1}".format(i+1, num_reviews))
    # Call our function for each one, and add the result to the list of clean reviews
    clean_train_reviews.append( review_to_words( train["review"][i] ) )

Creating Features from a Bag of Words (Using scikit-learn)

자, 그럼 이제 우리에겐 깔끔하게 정리된 리뷰들이 있다. 그럼 이걸 어떻게 숫자로 표현(numeric representation)할 것인가? 수치화 시켜야 데이터 마이닝 알고리즘을 적용할 수 있다. 가장 기본적인 방법은 Bag of Words다. 이 방법은 모든 도큐먼트의 단어들을 모아서 bag 벡터(원문에서는 vocabulary라고 표현한다)를 만들고, 각 도큐먼트의 단어 등장 횟수를 세어 bag 벡터로 표현한다.

예를 들어 “hello world”, “hello city”가 있다면, bag은 [hello, world, city]로 구성되고 따라서 위 두 도큐먼트는 각각 [1, 1, 0], [1, 0, 1]로 표현할 수 있다.

IMDB 데이터에는 수많은 리뷰가 있고 이는 커다란 bag을 형성한다. 이를 제한하기 위해 최대 bag의 크기를 정해야 한다. 여기서는, 5000개의 빈발 단어를 사용하기로 하자.

bag-of-words 특성을 추출하기 위해 scikit-learnfeature_extraction 모듈을 사용한다.

print("Creating the bag of words...")
from sklearn.feature_extraction.text import CountVectorizer

# Initialize the "CountVectorizer" object, which is scikit-learn's bag of words tool.
vectorizer = CountVectorizer(analyzer = "word",   \
                             tokenizer = None,    \
                             preprocessor = None, \
                             stop_words = None,   \
                             max_features = 5000)

# fit_transform() does two functions: First, it fits the model
# and learns the vocabulary; second, it transforms our training data
# into feature vectors. The input to fit_transform should be a list of
# strings.
train_data_features = vectorizer.fit_transform(clean_train_reviews)

# Numpy arrays are easy to work with, so convert the result to an array
train_data_features = train_data_features.toarray()
print(train_data_features.shape)

이후에 train_data_features.shape을 찍어 보면 (25000, 5000)의 매트릭스임을 확인할 수 있다. 위에서 볼 수 있듯이, CountVectorzier는 preprocessing, tokenization, stop word removal 등의 옵션을 제공한다. 자세한 건 function documentation에서 확인하자. 이 튜토리얼에서는 과정을 단계별로 보여주기 위해 직접 구현하였다.

이제, Bag of Words 모델을 학습했으니 bag의 구성요소를 살펴보자.

# Take a look at the words in the vocabulary
vocab = vectorizer.get_feature_names()
print(vocab)

원한다면, 각 단어들이 얼마나 등장했는지도 세어 볼 수 있다:

# Sum up the counts of each vocabulary word
dist = np.sum(train_data_features, axis=0)

# For each, print the vocabulary word and the number of times it
# appears in the training set
for tag, count in zip(vocab, dist):
    print(count, tag)

Random Forest

자, 이제 우리에겐 Bag of Words로 수치화된 특성들과 각 특성에 해당하는 오리지널 감정 라벨들이 있다. 그럼 이제 supervised learning을 해 보자! 여기서는 scikit-learn에서 제공하는 Random Forest classifier를 사용하고, 트리의 개수는 적절한 기본값인 100개로 설정한다. 트리의 개수를 늘리면 더 정확한 결과가 나오겠으나 더 오래 걸린다. 특성의 수도 마찬가지다. 참고로 100개도 충분히 오래 걸린다. 몇 분 걸릴 수 있으니 기다리자.

print("Training the random forest...")
from sklearn.ensemble import RandomForestClassifier

# Initialize a Random Forest classifier with 100 trees
forest = RandomForestClassifier(n_estimators = 100)

# Fit the forest to the training set, using the bag of words as
# features and the sentiment labels as the response variable
#
# This may take a few minutes to run
forest = forest.fit( train_data_features, train["sentiment"] )

Creating a Submission

이제 남은 것은 학습된 Random Forest에 테스트셋을 넣어 결과를 뽑고, 이를 submission file로 출력하는 일이다. testData.tsv를 보면 25,000개의 리뷰와 아이디가 있다. 각각의 감정 라벨을 예측해야 한다.

아래에서 Bag of Words를 테스트셋에 적용할 때는 트레이닝셋에 사용했던 “fit_transform”이 아니라 “transform”을 사용한다는 것을 알아두자. “fit_transform”을 사용하면 우리의 모델이 테스트셋에 fit하게 되고, 다시 말해 테스트셋에 overfitting하게 된다. 이러한 이유로 테스트셋은 prediction을 하기 전에는 사용하지 않는다.

코드를 잘 보면, vectorizer가 fit_transform을 사용한다. 일반적으로 overfitting이라 함은 모델이 데이터에 overfitting하는 것이고, vectorzier는 단순히 데이터를 수치화 하는 것인데? 라는 의문이 들 수 있다.

그러나 다시 한번 생각해 보면, Bag of Words또한 데이터로부터 “학습” 하는 것이다. 즉, 이 경우에 테스트셋에 대해 fit_transform을 사용하게 되면 Random Forest classifier는 그대로지만 Bag of Words모델이 테스트셋에 오버피팅하게 되는 것이다. 결국, 테스트셋을 수치화 할 때에도 트레이닝 데이터로 만든 bag 벡터(vocabulary)를 기반으로 해야 한다는 것을 알 수 있다.

# Read the test data
test = pd.read_csv("testData.tsv", header=0, delimiter="\t",quoting=3 )

# Verify that there are 25,000 rows and 2 columns
print(test.shape)

# Create an empty list and append the clean reviews one by one
num_reviews = len(test["review"])
clean_test_reviews = []

print("Cleaning and parsing the test set movie reviews...\n")
for i in range(num_reviews):
    if( (i+1) % 1000 == 0 ):
        print("Review {0} of {1}".format(i+1, num_reviews))
    clean_review = review_to_words( test["review"][i] )
    clean_test_reviews.append( clean_review )

# Get a bag of words for the test set, and convert to a numpy array
test_data_features = vectorizer.transform(clean_test_reviews)
test_data_features = test_data_features.toarray()

# Use the random forest to make sentiment label predictions
result = forest.predict(test_data_features)

# Copy the results to a pandas dataframe with an "id" column and a "sentiment" column
output = pd.DataFrame( data={"id":test["id"], "sentiment":result} )

# Use pandas to write the comma-separated output file
output.to_csv( "Bag_of_Words_model.csv", index=False, quoting=3 )

이제 드디어 submission을 할 수 있다! 여러가지를 수정해보고 결과를 비교해보자. 리뷰 클리닝을 다르게 해 보고, Bag of Words의 단어 수를 다르게 해 보고, Porter Stemming을 써 보고, 다른 classifier를 써 보는 등 다양한 걸 해 보자. 다른 데이터셋을 다뤄 보고 싶으면, Rotten Tomatoes competition에 도전해 보자. 또는, 완전히 다른 것에 대한 준비가 되었다면 Deep Learning and Word Vector 페이지로 가자!

[kaggle] Bag of Words Meet Bags of Popcorn - (0) Description

Bag of Words Meet Bags of Popcorn - (0) Description

들어가기 전에

kaggle tutorial. 요약번역. 원문의 코드는 python 2지만 여기서는 python 3이다(별 차이는 없다).

원문도 쉽게 되어 있기 때문에 원문을 보는 것도 좋다. 공부하는 겸 해서 옮겨 보았다. 이 튜토리얼을 한번 훑고 나면, kaggle competition에 참가하는 방법도 알 수 있고, nltk와 scikit-learn을 사용하는 python에서의 텍스트 마이닝 기본 과정도 경험할 수 있으며, 딥러닝을 통한 워드 임베딩인 word2vec도 살펴볼 수 있다. 지난번에는 scikit-learn에서 제공하는 20 newspaper tutorial을 해 보았는데 Bags of Popcorn까지 하고 나면 확실히 텍스트 마이닝에 입문할 수 있을 것이다.

무엇보다도, 쉽기 때문에 머리 아플때 보기 좋다!

Introduction

이 튜토리얼은 감정 분석(sentiment analysis)을 조금 “deeper”하게 살펴본다. 구글의 Word2Vec은 단어의 의미에 초점을 맞춘 멋진 딥러닝 메소드다. Word2Vec은 단어의 의미를 이해하고 단어간의 의미 관계를 파악한다. Word2Vec은 RNN(recurrent neural network)이나 DNN(deep neural nets) 등의 deep approach처럼 작동하지만 그보다 효율적이다. 이 튜토리얼에서는 Word2Vec의 감정분석에 초점을 맞춘다.

감정분석은 머신러닝에서 중요한 주제 중 하나다. 사람들의 표현은 애매모호한 경우가 많기 때문에 사람에게나 컴퓨터에게나 오해하기 쉽다. 영화 리뷰를 감정분석하는 또다른 캐글 컴페티션가 있다. 이 튜토리얼에서는 이와 비슷한 문제에 Word2Vec을 어떻게 적용하는지를 살펴본다.

drug discoverycat and dog image recognition 등 딥러닝에 관련된 몇가지 캐글 컴페티션들이 더 있으니 관심 있으면 살펴보도록 하자.

Tutorial Overview

이 튜토리얼은 두가지 목표로 구성되어 있다:
Basic NLP(Natural Language Processing): Part 1. 기본적인 NLP 프로세싱 테크닉을 다룬다.
Deep Learning for Text Understanding: Part 2, 3. Word2Vec을 사용해서 어떻게 모델을 학습하고 결과로 나오는 워드 벡터를 사용해서 어떻게 감정분석을 하는지 자세히 살펴본다.

딥러닝은 아직 정립되지 않은 빠르게 발전하고 있는 분야이기 때문에, 파트 3 또한 정확한 정답이라기 보다 Word2Vec을 사용하는 여러가지 방법을 실험하고 제시한다.

이 튜토리얼에서는 IMDB의 감정분석 데이터셋(IMDB sentiment analysis data set)을 사용한다. 이 데이터셋은 100,000개의 영화 리뷰로 구성되어 있다.

Data Set

라벨링된 데이터셋은 50,000개의 IMDB 영화 리뷰로 구성되어 있다. 이 리뷰들은 감정분석을 위해 특별히 선택되었다. 리뷰의 감정은 binary로 되어 있는데, IMDB 평점이 5 미만이면 0, 7이상이면 1로 되어 있다. 각 영화들의 리뷰가 30개를 넘지 않으며, 25,000개의 라벨링된 트레이닝셋과 테스트셋은 같은 영화가 전혀 없다. 추가로, 50,000개의 라벨링되지 않은 데이터가 제공된다.

File descriptions

  • labeldTrainData - 라벨링된 트레이닝 셋. 내용은 tab으로 구분되며 id, sentiment, review로 구성된 header row와 25,000개의 row들이 존재한다.
  • testData - 25,000개의 id, review. 모델을 트레이닝 할 때 테스트셋으로 사용하라는 게 아니라 최종적으로 이 데이터셋을 사용해서 판단한다는 의미로 보인다 (라벨링이 안 되어 있음).
  • unlabeledTrainData - 50,000개의 추가적인 라벨링되지 않은 트레이닝 셋.
  • sampleSubmission - 제출 포멧.

Code

github repo

Text Mining Tutoral: 20 newsgroups

Text Mining Tutoral: 20 newsgroups

scikit-learn의 텍스트 데이터를 다루는 튜토리얼이다.
이 튜토리얼에서 우리는 이러한 것들을 다룰 것이다:

  • 파일 내용과 카테고리를 로드하기
  • 머신러닝에 알맞은 특성 벡터를 추출하기
  • 분류하기 위해 리니어 모델을 학습하기
  • 특성 추출과 분류기 학습에 대해 좋은 파라메터를 찾기 위해 그리드 서치(grid search) 사용하기

요약이니 자세한 내용은 원문을 참고하자.

Tutorial setup

여기서는 Window/Anaconda 환경에서 해 보았다. sklearn만 잘 깔려있으면 문제없을 것이다. 원문에서 말하는 데이터셋은 찾지 못했지만 필요하지 않다.

Loading the 20 newsgroups dataset

이 데이터셋은 “Twenty Newsgroups”라고 불린다. 20개의 뉴스그룹으로 분리된 20,000여개의 뉴스그룹 도큐먼트다. 이 데이터는 머신러닝에서의 대표적인 튜토리얼용 텍스트 데이터이다.

from sklearn.datasets import fetch_20newsgroups

categories = ['alt.atheism', 'soc.religion.christian', 'comp.graphics', 'sci.med']
twenty_train = fetch_20newsgroups(subset='train', categories=categories, shuffle=True, random_state=42)

이렇게 데이터를 불러올 수 있다. sklearn에서 데이터를 다운받고 쉽게 원하는 부분만 사용할 수 있도록 제공한다.

Extracting features from text files

텍스트 마이닝을 위해, 먼저 텍스트를 numerical feature vector로 바꿀 필요가 있다.

Bags of words

가장 직관적인 방법이다.
X[i, j] = #(w)
도큐먼트 i에서 단어 w가 등장하는 횟수. j는 단어 w의 인덱스.

보통 단어는 100,000개 이상인데, 샘플의 수가 10000개라고 한다면, float32로 구성된 numpy array에 이를 저장한다고 할 때 10000 x 100000 x 4 byte = 4GB in RAM 이 필요하다. 다행히도 X의 대부분의 값은 0이고, 따라서 bags of words는 high-dimensional sparse datasets이다. 이러한 데이터는 0이 아닌 값만 저장하여 메모리를 아낄 수 있다.

scipy에서는 scipy.sparse 라는 여기에 딱 맞는 자료구조를 제공한다.

Tokenizing text with scikit-learn

특성 딕셔너리를 만들고 도큐먼트를 특성 벡터로 변환하기 위해, stopwords의 텍스트 프리프로세싱, 필터링 그리고 토크나이징이 지원된다.

from sklearn.feature_extraction.text import CountVectorizer

count_vect = CountVectorizer()
X_train_counts = count_vect.fit_transform(twenty_train.data)
print(X_train_counts.shape)
// (2257, 35788)

도큐먼트의 수(데이터 샘플 수)가 총 2257개 이고, 35788은 아마 총 word의 수 일 것 같다.

또한 CountVectorizer는 단어의 카운팅도 지원한다. vocabulary가 dictionary형태로 들고 있다.

print(count_vect.vocabulary_.get('algorithm'))
print(count_vect.vocabulary_['algorithm'])
print(count_vect.__class__)
print(count_vect.vocabulary_.__class__)
// 4690
// 4690
// <class 'sklearn.feature_extraction.text.CountVectorizer'>
// <class 'dict'>

From occurrences to frequencies

Occurence는 나쁘지 않은 시작이지만 문제가 많다. TF-IDF(Term Frequency times Inverse Document Frequency)가 많이 쓰인다. 먼저 TF를 구해보자.

from sklearn.feature_extraction.text import TfidfTransformer

tf_transformer = TfidfTransformer(use_idf=False).fit(X_train_counts)
X_train_tf = tf_transformer.transform(X_train_counts)

위 코드에서 fit()함수와 transform()함수는 fit_transform()함수로 통합하여 중복 계산을 줄일 수 있다. 이 방법을 사용해서 TF-IDF를 구해보자.

tfidf_transformer = TfidfTransformer()
X_train_tfidf = tfidf_transformer.fit_transform(X_train_counts)

각각의 shape를 찍어보고, [0]에 들어있는 내용을 확인해 보면 차이를 알 수 있다.

print(X_train_counts.shape)
print(X_train_tf.shape)
print(X_train_tfidf.shape)

print(X_train_counts[0])
print(X_train_tf[0])
print(X_train_tfidf[0])

Training a classifier

이제 우리의 특성을 갖게 되었으니, 분류기(classifier)를 학습할 수 있다. Naïve Bayes를 학습해 보자. sklearn은 다양한 NB(Naïve Bayes)를 학습할 수 있다. word count에 가장 적합한 건 multinomial 버전이다.

from sklearn.naive_bayes import MultinomialNB
clf = MultinomialNB().fit(X_train_tfidf, twenty_train.target)

자 그럼 이제 새로운 도큐먼트를 데이터로 삼아 predict를 해 봐야 할 텐데, 그러기 위해 이전에 특성 추출을 한 것과 같이 새로운 도큐먼트에 대해서도 특성 추출이 필요하다. 위에서 했던 과정과의 차이는, 이미 training data에 대해 fit해 있기 때문에 fit_transform()이 아닌 transform()을 사용한다.

min-max 노멀라이징을 할 때, training data에 대해 min-max노멀라이징을 한 뒤 해당 min-max를 기억하고 있다가 new data에 대해서도 동일한 min-max를 적용하는 데, 이와 같다. fit이라는 게 이런 의미인 듯함.

docs_new = ['God is love', 'OpenGL on the GPU is fast']
X_new_counts = count_vect.transform(docs_new)
X_new_tfidf = tfidf_transformer.transform(X_new_counts)

predicted = clf.predict(X_new_tfidf)

for doc, category in zip(docs_new, predicted):
    print("{} => {}".format(doc, twenty_train.target_names[category]))

// God is love => soc.religion.christian
// OpenGL on the GPU is fast => comp.graphics

여기까지 하면 분류기의 생성까지 완료된 것인데, 지금까지의 작업 수행 시간을 살펴보면 대략 총 1초가 걸린다:

[Elapsed time] get data: 0.24381279945373535
[Elapsed time] get bags of words(vectorize): 0.725600004196167
[Elapsed time] get TF & TF-IDF: 0.02526402473449707
[Elapsed time] learn multinomial NB: 0.010046005249023438

Building a pipeline

지금까지 한 작업을 정리해 보면, vectorizer => transformer => classifier 의 세 단계다. 이 작업을 더 쉽게 할 수 있도록 sklearnPipeline을 제공한다.

from sklearn.pipeline import Pipeline
text_clf = Pipeline([('vect', CountVectorizer()),
                     ('tfidf', TfidfTransformer()),
                     ('clf', MultinomialNB())])
text_clf = text_clf.fit(twenty_train.data, twenty_train.target)

// predicted = text_clf.predict(docs_new) // differenct!

이렇게 pipeline으로 묶으면 수행시간도 0.7초 정도로 30%가량 빨라진다. 위의 ‘vect’, ‘tfidf’, ‘clf’는 마음대로 지정하면 되는데 이후 grid search에서 사용한다.

여기서 최종적으로 얻은 text_clf는 위에서 얻은 clf와 조금 다른데, predict 과정에서 사용하는 데이터가 TF-IDF 데이터가 아닌 그냥 도큐먼트다.

Evaluation of the performance on the test set

import numpy as np

twenty_test = fetch_20newsgroups(subset='test', categories=categories, shuffle=True, random_state=42)
start = time()
docs_test = twenty_test.data
predicted = text_clf.predict(docs_test)

print(len(docs_test))
print(np.mean(predicted == twenty_test.target))

// 1502
// 0.834886817577

참고로 1502개의 테스트 데이터를 가져오는 데에는 0.2초 가량이, predict하는 데에는 0.45초 가량이 소요되었다.

이렇게 Naive Bayes를 통해서 83.4%의 정확도를 확보하였다. 이번엔 Support Vector Machine (SVM)을 사용해서 정확도를 개선해 보자. SVM은 가장 널리 알려진 강력한 텍스트 분류 알고리즘이다. 물론 Naive Bayes에 비하면 조금 느리지만. 우리는 파이프라인에서 classifier를 바꿔 끼우는 것만으로 학습기를 변경할 수 있다.

from sklearn.linear_model import SGDClassifier
text_clf = Pipeline([('vect', CountVectorizer()),
                     ('tfidf', TfidfTransformer()),
                     ('clf', SGDClassifier(loss='hinge', penalty='l2', alpha=1e-3, n_iter=5, random_state=42))])
_ = text_clf.fit(twenty_train.data, twenty_train.target)

predicted = text_clf.predict(docs_test)
print(np.mean(predicted == twenty_test.target))

// 0.912782956059

분류기의 학습 시간은 0.75초 가량으로 0.05초 정도가 더 소요되었고, 분류기를 사용하여 예측하는 시간은 0.45초 정도로 Naive Bayes와 비슷했다.

단순히 정확도를 보는 것 외에, sklearn은 더 자세하게 결과를 분석할 수 있도록 해 준다:

from sklearn import metrics
print(metrics.classification_report(twenty_test.target, predicted, target_names=twenty_test.target_names))
print(metrics.confusion_matrix(twenty_test.target, predicted))

// report
                        precision    recall  f1-score   support

           alt.atheism       0.94      0.82      0.87       319
         comp.graphics       0.88      0.98      0.92       389
               sci.med       0.95      0.89      0.92       396
soc.religion.christian       0.90      0.95      0.92       398

           avg / total       0.92      0.91      0.91      1502

// confusion matrix
[[261  10  12  36]
 [  5 380   2   2]
 [  7  32 353   4]
 [  6  11   4 377]]

예상대로 confusion matrix는 atheism/christian 뉴스그룹간에 혼동이 많이 일어난다.

결과가 원문과 살짝 다른데 이유는 잘 모르겠음.

이미 여러번 등장했지만 분류기의 학습에는 수많은 파라메터들이 필요하다. 이 파라메터의 확인은 모듈의 도큐먼트를 확인하거나 파이썬의 help함수를 사용하자.

Grid Search를 이용하면, 이러한 파라메터들에 대한 테스트를 손쉽게 진행할 수 있다. SVM에서 단어별로 구분할 것인지 bigram(2글자)으로 구분할 것인지, idf를 쓸지 말지, penalty 파라메터의 값을 0.01로 할지 또는 0.001로 할지.

from sklearn.grid_search import GridSearchCV

parameters = {'vect__ngram_range': [(1,1), (1,2)],
              'tfidf__use_idf': (True, False),
              'clf__alpha': (1e-2, 1e-3)}

이와 같은 exhaustive search(전역검색)는 매우 비싼 작업이다. 만약 우리가 여러개의 CPU를 사용할 수 있다면 n_jobs파라메터를 사용해서 parallel하게 작업을 수행할 수 있다. 만약 이 값에 -1을 준다면, 그리드 서치가 알아서 코어의 수를 체크하고 모두 사용하여 작업을 수행한다.

gs_clf = GridSearchCV(text_clf, parameters, n_jobs=-1)
gs_clf = gs_clf.fit(twenty_train.data[:400], twenty_train.target[:400])

위와 같이, grid search도 보통의 sklearn의 분류기 학습처럼 진행된다. 단, 데이터셋을 그대로 쓰면 너무 느릴 수 있으니 작은 데이터셋을 사용하자.

(아마도)윈도우 환경에서는 그리드 서치를 하려면 if __name__ == "__main__": 이 필요하다. 없이 하면 에러가 난다.

print(twenty_train.target_names[gs_clf.predict(['God is love'])])
// soc.religion.christian

best_parameters, score, _ = max(gs_clf.grid_scores_, key=lambda x: x[1])
for param_name in sorted(parameters.keys()):
    print("%s: %r" % (param_name, best_parameters[param_name]))
// clf__alpha: 0.001
// tfidf__use_idf: True
// vect__ngram_range: (1, 1)

print(score)
// 0.9025

결국 분류기가 학습되는 과정이므로 위와 같이 predict를 해볼 수 있다. 아마 가장 좋은 분류기가 들어있을 것으로 보인다. 또한 이때의 파라메터들을 확인해 볼 수 있다.

Fianlly

원문을 보면, 추가로 더 학습할 수 있도록 다양한 예제들과, 이후에 뭘 해야 할 지 가이드라인을 제시한다. 더 공부하고자 하면 이를 참고해서 공부하도록 하자.