pyLDAvis 를 이용한 k-means 학습 결과 시각화하기

k-means 는 문서 군집화에 이용될 수 있는 대표적인 군집화 알고리즘입니다. 빠른 학습 속도와 안정성 때문에 문서 군집화에 유용하지만, 군집화 학습 결과를 해석하기 위한 방법들은 거의 없습니다. 한 가지 방법으로 이전의 포스트에서 군집화 학습 결과를 이용하여 자동으로 군집 레이블링을 하는 방법을 소개하였습니다. 군집 레이블링은 한 군집에 대한 해석력을 제공합니다. 그렇기 때문에 이 방법 만으로는 군집 간의 유사성과 차이를 이해하기는 어렵습니다. pyLDAvis 는 토픽 모델링에 이용되는 LDA 모델의 학습 결과를 시각화하는 Python 라이브러리입니다. 이전의 pyLDAvis 포스트에서 LDA 는 일종의 단어 수준의 군집화라는 이야기를 하였습니다. LDA 와 k-means 는 비슷한 점이 많기 때문에 pyLDAvis 를 이용하면 k-means 의 학습 결과를 손쉽게 시각화 할 수 있습니다.

pyLDAvis

이전 포스트에서 LDA 를 학습하는 방법과 pyLDAvis 를 이용하여 이를 시각화하는 방법에 대하여 이야기하였습니다. pyLDAvis 는 LDAvis 의 Python wrapper 로, 아래와 같은 두 가지 정보를 시각화하여 제공합니다.

좌측은 topic vector 를 2 차원으로 축소하여 토픽 간의 관계를 살펴볼 수 있도록 도와줍니다. 비슷한 좌표를 지닌 토픽들은 비슷한 맥락을 지닙니다. 이를 위하여 LDAvis 는 토픽에서 단어가 발생할 확률 벡터인 에 Principal Component Analysis (PCA) 를 적용하여 2 차원의 벡터로 압축합니다.

우측에는 각 토픽의 키워드가 제공됩니다. 키워드 점수 (relevance) 는 (1) 각 토픽에서 단어가 발생할 확률인 와 (2) 이를 단어의 기본 발생 확률로 정규화한 에 의하여 조합되어 계산됩니다. 사용자는 를 조절하며 각 토픽에 주로 등장하는 단어와, 다른 단어와 차별성이 높은 단어들을 살펴볼 수 있습니다.

Python 라이브러리인 Gensim 을 이용하여 LDA 를 학습하였다면 아래와 같은 간단한 코드로 LDAvis 의 결과를 얻을 수 있습니다. 더 자세한 학습 코드는 이전의 pyLDAvis 포스트를 참고하세요.

from gensim.models import LdaModel
import pyLDAvis.gensim as gensimvis

lda_model = LdaModel(corpus, id2word=dictionary, num_topics=50)
prepared_data = gensimvis.prepare(lda_model, corpus, dictionary)
pyLDAvis.display(prepared_data)

k-means

k-means 는 다른 군집화 알고리즘과 비교하여 매우 적은 계산 비용을 요구하면서도 안정적인 성능을 보입니다. 그렇기 때문에 큰 규모의 데이터 군집화에 적합합니다. 문서 군집화의 경우에는 문서의 개수가 수만건에서 수천만건 정도 되는 경우가 많기 때문에 다른 알고리즘보다도 k-means 가 더 많이 선호됩니다. k-partition problem 은 데이터를 k 개의 겹치지 않은 부분데이터 (partition)로 분할하는 문제 입니다. 이 때 나뉘어지는 k 개의 partiton 에 대하여, “같은 partition 에 속한 데이터 간에는 서로 비슷하며, 서로 다른 partition 에 속한 데이터 간에는 이질적”이도록 만드는 것이 군집화라 생각할 수 있습니다. k-means problem 은 각 군집 (partition)의 평균 벡터와 각 군집에 속한 데이터 간의 거리 제곱의 합 (분산, variance)이 최소가 되는 partition 을 찾는 문제입니다.

우리가 흔히 말하는 k-means 알고리즘은 Lloyd 에 의하여 제안되었습니다. 이는 다음의 순서로 이뤄져 있습니다.

  1. k 개의 군집 대표 벡터 (centroids) 를 데이터의 임의의 k 개의 점으로 선택합니다.
  2. 모든 점에 대하여 가장 가까운 centroid 를 찾아 cluster label 을 부여하고,
  3. 같은 cluster label 을 지닌 데이터들의 평균 벡터를 구하여 centroid 를 업데이트 합니다.
  4. Step 2 - 3 의 과정을 label 의 변화가 없을때까지 반복합니다.

우리가 list of str 형태의 문서 집합을 가지고 있을 때, scikit-learn 을 이용하여 k-means 를 학습하는 방법은 매우 간단합니다. 아래의 코드는 문서 집합을 bag-of-words model 로 표현한 다음 k-means 를 적용하는 과정까지의 코드입니다.

사실 문서 군집화를 위해서는 문서 간 거리를 Cosine 으로 정의하는 Spherical k-means 를 이용해야 합니다만, scikit-learn (<= 0.19.1) 에서는 이를 제공하지 않습니다. 오로직 Euclidean distance 만을 이용합니다. 그런데 Euclidean 은 벡터의 크기에 영향을 받습니다. 문서에 단어가 많을수록 term vector 의 크기 (norm) 는 커집니다. 이 문제를 해결하기 위한 임시 방편으로 L2 normalize 를 할 수 있습니다.

물론 bag-of-words model 에 L2 normalization 만을 적용한다고 Spherical k-means 가 되지는 않습니다. 이에 대한 이야기는 다른 포스트에서 설명하겠습니다. 여하튼, Euclidean distance 를 이용한 문서 군집화를 한다면 반드시 L2 normalization 을 해야 합니다.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.preprocessing import normalize
from sklearn.cluster import KMeans

docs = ['document format', 'list of str like']

# vectorizing
vectorizer = CountVectorizer()
X = vectorizer.fit_transform(docs)

# L2 normalizing
X = normalize(X, norm=2)

# training k-means
kmeans = KMeans(n_clusters=k).fit(X)

# trained labels and cluster centers
labels = kmeans.labels_
centers = kmeans.cluster_centers_

sklearn.cluster.KMeans 를 학습하면 labels_, cluster_centers_ 에 각 문서의 군집 레이블과 centroid vectors 가 저장되어 있습니다. 하지만 이 정보만을 가지고서 군집화 결과를 효과적으로 설명하기는 어렵습니다. 가장 좋은 설명은 그림이라 합니다. 이전 포스트에서 살펴보았던 pyLDAvis 를 이용하여 k-means 의 결과도 시각화 합니다.

k-means as Topic modeling

k-means 도 사실 토픽 모델링으로 이용 가능합니다. 실제로 LDA 와 같은 토픽 모델링과 k-means 와의 차이는 문서의 토픽 개수에 대한 가정입니다. LDA 는 한 문서에 여러 종류의 토픽이 존재할 수 있다 가정합니다. 하지만 k-means 는 하나의 문서가 하나의 토픽이라는 가정을 합니다. 이 가정은 오히려 어떤 문서 집합에서는 유용한 사전 지식 (prior) 이 됩니다. 주로 뉴스는 한 기사에 하나의 토픽이 있습니다. 반면 한 영화에 대한 여러 관점의 리뷰라면 여러 토픽이 섞여있을 수 있습니다. 우리가 한 문서에 하나의 토픽이 존재한다고 가정한다면 굳이 over-spec 인 LDA 를 이용할 필요는 없습니다.

또한 한 문서에 최대 두, 세개의 토픽이 균등히 섞일 수 있다고 가정한다면, 그리고 데이터의 양이 매우 많다면 k-means 를 LDA 의 대용으로 이용할 수도 있습니다. 대신에 이 경우에는 LDA 에서 기대하는 토픽의 개수가 10 개라면 k-means 에서는 100 ~ 500 개 수준으로 그 개수를 늘려야 합니다. 만약 실제 존재하는 토픽이 [] 라 할 때, 이들의 조합으로 [(), (), … ] 와 같은 토픽조합을 만들 수 있고 이들을 각각 하나의 군집으로 취급할 수 있기 때문입니다.

물론 이 때 적절한 토픽의 개수나 군집의 개수의 결정 문제가 따라오지만 이에 대해서는 일단 이야기 하지 않습니다. 이론상 k-means 를 토픽 모델링에 이용할 수 있다는 것 뿐입니다. 언제나 이론과 실제는 다르니까요 (실제로는 그냥 LDA 하나 학습하는 것도 잘 이뤄지지 않습니다).

Visualize k-means using pyLDAvis

이를 위해서는 pyLDAvis 의 함수를 뜯어볼 필요가 있습니다. 우리가 이용했던 pyLDAvis.gensim.prepare 함수는 gensim.models.LdaModel 의 parameters 로부터 필요한 정보를 추출한 뒤, pyLDAvis._prepare.prepare 함수에 넘겨줍니다. 이 함수에 입력되는 정보와 출력되는 정보를 살펴볼 필요가 있습니다.

pyLDAvis._prepare.prepare 함수

아래는 _prepare.prepare 함수의 argument 입니다. (topic, term) distribution, (doc, topic) distribution, doc length, vocab, term frequency 정보를 입력해야 합니다. 다른 값은 default 입니다. 이 값들은 이후에 다시 설명하겠습니다.

def prepare(topic_term_dists, doc_topic_dists, doc_lengths, vocab, term_frequency, \
            R=30, lambda_step=0.01, mds=js_PCoA, n_jobs=-1, \
            plot_opts={'xlab': 'PC1', 'ylab': 'PC2'}, sort_topics=True):
    ...

각 군집의 centroid 는 단어 크기의 벡터입니다. 각 element, centroid[c,w] 은 군집 에서 단어 가 얼마나 등장하였는지를 나타내는 weight 입니다. 만약 우리가 TF-IDF term weighting 을 이용하였다면 centroid[c,w] 는 군집 에서 단어 가 얼마나 중요한지를 나타낼 것입니다. 여하튼 centroid vector 의 각 elements 는 단어의 상대적인 중요도를 나타냅니다. 또한 non-negative 이기 때문에 이를 L1 normalization 을 하면 확률 분포로 이용할 수도 있습니다. 일단 topic_term_dists 를 대체할 정보는 찾았습니다.

앞서 살펴본 것처럼 k-means 는 문서에 하나의 토픽이 존재한다고 가정하는 토픽 모델링 입니다. 그렇기 때문에 doc_topic_dists 는 [0, 1, 0, …, 0] 과 같이 각 문서에 해당하는 label 에만 1 을 부여한 행렬로 대체할 수 있습니다.

문서의 길이 doc_lengths 는 bag-of-words model 로 만든 sparse matrix x 를 다음처럼 만들면 얻을 수 있습니다.

doc_lengths = x.sum(axis=1)

그런데 이 형식이 numpy.ndarray 가 아니라 그 위의 abstract class 인 matrix 입니다. 이를 numpy.ndarray 의 column vector 로 변환하려면 다음과 같은 작업을 하여야 합니다.

import numpy as np

doc_lengths = np.asarray(x.sum(axis=1)).reshape(-1)

앞서 doc lengths 는 column sum 이었다면 term frequency 는 row sum 을 하면 됩니다.

import numpy as np

term_frequency = np.asarray(x.sum(axis=0)).reshape(-1)

vocab 이야 list of str 로, vectorizing 을 하는 과정에서 이미 얻었습니다. 이 재료를 _prepare.prepare 함수에 입력하여 PreparedData 를 얻을 수도 있습니다.

pyLDAvis.PreparedData

그런데 우리는 _prepare.prepare 함수에서 일어나는 일을 몇 개 변형하려 합니다. 직접 my_prepare 함수를 만들어 봅니다. 이를 위해서는 PreparedData 를 만들기 위해 필요한 데이터의 타입을 살펴봐야 합니다. init 함수의 첫 줄의 세 정보를 우리가 만들어야 합니다 (이 세 정보가 _prepare.prepare 함수가 만드는 정보입니다).

class PreparedData:
    def __init__(topic_coordinates, topic_info, token_table,
                 R, lambda_step, plot_opts, topic_order):
        ...

앞의 세 개의 arguments 인 topic_coordinates, topic_info, token_table 은 pandas.DataFrame 형식으로 저장된 테이블입니다. 즉 csv 형식으로 저장할 수 있는 정보입니다. 그러므로 이 포스트에서는 csv 테이블로 세 정보를 설명합니다.

솔직히 LDAvis 가 이용하는 세 종류 table 의 naming 이나 각 table 의 column name 이 직관적이지 않고, 잘 정리된 형태도 아닙니다. 하지만 Java Script 를 이용하여 밑바닥부터 시각화 작업을 하는 것보다 만들어져 있는 LDAvis 를 이용하여 k-means 를 시각화 하는 것이 더 빠를테니 도전해 봅시다!

topic coordinates

topic coordinates 는 HTML 의 왼쪽에 위치한 topic vector 의 2 차원 좌표입니다. 이들은 각각 x, y 로 표시됩니다. topics 는 1 부터 num topics 까지 idx 를 증가하는 것이며, cluster 는 1 로 일괄되게 저장합니다. Freq 는 토픽을 표현하는 원의 지름 입니다. 이 크기가 클수록 자주 등장한 토픽이라는 의미이며, 이는 의 값에 비례하여 만듭니다.

topic x y topics cluster Freq
50 -0.746 -2.666 1 1 3.510
4 2.837 -2.585 2 1 3.134
54 1.428 1.844 3 1 2.442
85 0.488 -4.691 4 1 2.260
38 -0.409 -1.471 5 1 2.253
9 0.597 -3.140 6 1 2.157
13 -2.184 -1.300 7 1 2.126

topic info

topic info 는 조금 복잡합니다. pyLDAvis 에서 term 은 단어의 idx 입니다. 대문자로 시작하는 Term 은 단어의 실제 str 값입니다. (term, Category) 는 topic info 테이블의 key 입니다. Category 는 각 row 가 어떤 토픽에 해당하는지를 저장합니다. Default 는 어떤 토픽도 선택하지 않았을 때입니다. Freq 는 term 이 Category 에 등장한 횟수이며, 추정값입니다. Total 은 문서 전체에서의 term frequency 입니다. loglift 이며, distriminative power 를 표현하는 값입니다. logprob 이며, 단어 가 토픽 에 얼만큼 등장하는지 coverage 를 보여주는 값입니다.

term Category Freq Term Total loglift logprob
1985 Default 0.99 기자 27189 25.000 25.000
3424 Default 0.99 무단 21575 21.902 21.902
337 Default 0.99 20일 20858 21.507 21.507
2434 Topic1 2550 뉴시스 9950 5.324 -5.489
2681 Topic1 3731 대한 9054 4.995 -3.277
304 Topic2 1523 2016 11149 6.150 -5.320
1121 Topic2 995 것으로 10447 5.763 -4.615

token table

token table 은 각 term 이 특정 topic 에 등장한 비율입니다. 즉 한 단어에 대하여 모든 row 의 Freq 의 합은 1 이하입니다. 이름이 topic term proportion 이면 더 좋았을 것 같네요.

term Topic Freq Term
4697 1 0.299 선언
962 1 0.546 강진
2667 1 0.074 대표
4950 1 0.209 손학규
7448 1 0.223 정계복귀
7447 1 0.439 정계
6566 1 0.603 은퇴
3849 1 0.840 백련사

우리는 loglift, logprob 의 값과 topic coordinate (x, y) 를 만들어야 합니다. token table 의 Freq (topic term proportion) 은 상대적으로 만들기 쉽습니다. topic_order 는 topic coordinates 의 row 순서대로의 topic idx 입니다. 길이가 n_topics 인 list of int 입니다.

Making topic coordinates

우리는 k-means 를 이용하여 학습한 centroid 와 labels 를 이용하여 LDAvis 에 입력할 정보들을 만듭니다.

LDAvis 는 PCA 를 이용하여 2 차원 벡터를 학습합니다. 하지만 bag-of-words model 처럼 sparse vector 로 표현되는 고차원 벡터간 거리는 Cosine distance 를 이용하는 것이 좋습니다. 그리고 원 공간에서 비슷한 점들은 2 차원에서도 비슷해야 원 공간을 이해하기가 좋습니다. 즉 locality 정보가 중요합니다. 하지만 PCA 는 global variance 정보에 집중합니다. 그러므로 PCA 대신 t-SNE 를 이용합니다. 이 값을 이용하여 centroid vector 를 2 차원으로 압축합니다. scikit-learn 의 0.19.1 이후부터는 t-SNE 의 metric 을 ‘cosine’ 으로 설정할 수 있습니다. 각 좌표의 값을 [-5, 5] 사이가 되도록 scaling 도 합니다.

coordinates = TSNE(n_components=2, metric='cosine').fit_transform(centers)
coordinates = 5 * coordinates / max(coordinates.max(), abs(coordinates.min()))

cluster size 는 labels 을 카운팅함으로써 확인할 수 있습니다. 이 값의 root 에 비례하여 cluster size 를 만듭니다. 크기가 가장 작은 cluster 는 0.2 의 Freq 값을, 그리고 가장 큰 cluster 의 Freq 는 radious + 0.2 가 되도록 scaling 도 합니다.

cluster_size = np.asarray(
    [np.sqrt(cluster_size[c] + 1) for c in range(n_clusters)])
cs_min, cs_max = cluster_size.min(), cluster_size.max()
cluster_size = radius * (cluster_size - cs_min) / (cs_max - cs_min) + 0.2

위 내용을 정리하면 아래와 같습니다.

from collections import Counter
from collections import namedtuple
from sklearn.manifold import TSNE
import numpy as np

TopicCoordinates = namedtuple('TopicCoordinates', 'topic x y topics cluster Freq'.split())

cluster_size = Counter(labels)
cluster_size = np.asarray([cluster_size.get(c, 0) for c in range(n_clusters)])

def _get_topic_coordinates(centers, cluster_size, radius=5):
    n_clusters = centers.shape[0]
    coordinates = TSNE(n_components=2, metric='cosine').fit_transform(centers)
    coordinates = 5 * coordinates / max(coordinates.max(), abs(coordinates.min()))

    cluster_size = np.asarray(
        [np.sqrt(cluster_size[c] + 1) for c in range(n_clusters)])
    cs_min, cs_max = cluster_size.min(), cluster_size.max()
    cluster_size = radius * (cluster_size - cs_min) / (cs_max - cs_min) + 0.2

    topic_coordinates = [
        TopicCoordinates(c+1, coordinates[i,0], coordinates[i,1], i+1, 1, cluster_size[c])
        for i, c in enumerate(sorted(range(n_clusters), key=lambda x:-cluster_size[x]))
    ]

    topic_coordinates = sorted(topic_coordinates, key=lambda x:-x.Freq)
    return topic_coordinates

Making keyword score

loglift 와 logprob 는 keyword score 에 해당합니다. 이전 포스트에서 군집화의 키워드를 정의하는 방법을 이야기하였습니다. 간단히 요약하면 아래와 같습니다.

우리는 cluster center vectors 를 이용하여 salient and discriminative 한 키워드 집합을 선택합니다. Salient terms 는 각 군집의 center 벡터에서 큰 weight 를 지닌 단어입니다. 다음으로 우리가 해야 할 작업은 discriminative power 를 수식으로 정의하는 것입니다. Discriminative power 가 큰 단어는 해당 군집에서는 자주 등장하지만, 다른 군집에서는 등장하지 않은 단어입니다. 즉, 유독 weight 가 큰 단어입니다. 이를 다음과 같은 수식으로 정의합니다. 는 군집이며 는 단어입니다. $w(v, c)vc$$ 에서의 weight 입니다.

  • : 군집 c 의 center vector 에서의 term v 에 대한 weight
  • : 군집 c 를 제외한 다른 문서 집합들에서의 term v 에 대한 weight

loglift 는 discriminative power 에 관련된 항목입니다. 이 값으로 를 이용합니다. Coverage 에 해당하는 logprob 로 를 이용합니다.

for c, n_docs in enumerate(cluster_size):
    topic_idx = c + 1
    n_prop = l1_normalize(weighted_center_sum - (centers[c] * n_docs))
    p_prop = l1_normalize(centers[c])
    indices = np.where(p_prop > 0)[0]
    ...

각 클러스터마다 크기가 다르기 때문에 클러스터의 크기를 곱한 weighted_centers 를 만듭니다. 이 값을 이용하여 인 p_prop 와 인 n_prop 를 만듭니다. loglift 는 p_prop / (n_prop + p_prop) 로 정의합니다. p_prop 가 큰 각 클러스터마다 n_candidate_words 개의 단어에 대하여 loglift 와 logprob 에 해당하는 값을 계산하여 scores 로 만듭니다.

for c, n_docs in enumerate(cluster_size):
    ...
    indices = sorted(indices, key=lambda idx:-p_prop[idx])[:n_candidate_words]
    scores = [(idx, p_prop[idx] / (p_prop[idx] + n_prop[idx])) for idx in indices]

각 클러스터에 대해서는 loglift 와 logprob 의 값을 만들어줘야 하지만, Default Category 를 위해서는 most frequent term 만 선택하면 됩니다. 이는 term_frequency.argsort()[::-1] 를 이용하여 찾을 수 있습니다. 빈도수가 큰 n_candidate_words 에 대하여 term frequency 에 비례하는 loglift, logprob 의 값을 만들어줍니다. Scale 은 임의로 [10, 25] 로 정의하였습니다.

default_terms = term_frequency.argsort()[::-1][:n_candidate_words]
default_term_frequency = term_frequency[default_terms]
default_term_loglift = 15 * default_term_frequency / default_term_frequency.max() + 10
for term, freq, loglift in zip(default_terms, default_term_frequency, default_term_loglift):
    ...

위 내용을 정리하면 아래와 같습니다.

weighted_centers = np.zeros((n_clusters, n_terms))
for c, n_docs in enumerate(cluster_size):
    weighted_centers[c] = centers[c] * n_docs

TopicInfo = namedtuple('TopicInfo', 'term Category Freq Term Total loglift logprob'.split())

def _get_topic_info(centers, cluster_size, index2word,
    weighted_centers, term_frequency, n_candidate_words=100):

    l1_normalize = lambda x:x/x.sum()
    n_clusters, n_terms = centers.shape

    weighted_center_sum = weighted_centers.sum(axis=0)
    total_sum = weighted_center_sum.sum()
    term_proportion = weighted_centers / weighted_center_sum

    topic_info = []

    # Category: Default
    default_terms = term_frequency.argsort()[::-1][:n_candidate_words]
    default_term_frequency = term_frequency[default_terms]
    default_term_loglift = 15 * default_term_frequency / default_term_frequency.max() + 10
    for term, freq, loglift in zip(default_terms, default_term_frequency, default_term_loglift):
        topic_info.append(
            TopicInfo(
                term,
                'Default',
                0.99,
                index2word[term],
                term_frequency[term],
                loglift,
                loglift
            )
        )

    # Category: for each topic
    for c, n_docs in enumerate(cluster_size):
        if n_docs == 0:
            keywords.append([])
            continue

        topic_idx = c + 1

        n_prop = l1_normalize(weighted_center_sum - (centers[c] * n_docs))
        p_prop = l1_normalize(centers[c])

        indices = np.where(p_prop > 0)[0]
        indices = sorted(indices, key=lambda idx:-p_prop[idx])[:n_candidate_words]
        scores = [(idx, p_prop[idx] / (p_prop[idx] + n_prop[idx])) for idx in indices]

        for term, loglift in scores:
            topic_info.append(
                TopicInfo(
                    term,
                    'Topic%d' % topic_idx,
                    term_proportion[c, term] * term_frequency[term],
                    index2word[term],
                    term_frequency[term],
                    loglift,
                    p_prop[term]
                )
            )

    return topic_info

Making token table

만들어둔 topic info 를 이용하면 손쉽게 token table 을 만들 수 있습니다. weighted_centers 의 row sum 으로 weighted_centers 를 나누면 각 클러스터 마다 단어 가 전체 중에서 얼마나 등장하였는지 그 비율이 계산됩니다.

term_proportion = weighted_centers / weighted_centers.sum(axis=0)

이를 이용하여 topic info 에 입력된 단어들에 대해서만 따로 token table 을 만들어줍니다.

TokenTable = namedtuple('TokenTable', 'term Topic Freq Term'.split())

def _get_token_table(weighted_centers, topic_info, index2word):
    term_proportion = weighted_centers / weighted_centers.sum(axis=0)

    token_table = []
    for info in topic_info:
        try:
            c = int(info.Category[5:])
        except:
            # Category: Default
            continue
        token_table.append(
            TokenTable(
                info.term,
                c,
                term_proportion[c-1,info.term],
                info.Term
            )
        )

    return token_table

Making my_prepare

세 종류의 테이블을 만들 함수는 준비했습니다. 이제 데이터를 입력받아 세 테이블을 만드는 kmeans_to_prepared_data 함수를 만듭니다.

이 함수의 입력값은 bow model, index 2 word, centroid vector, labels, 이며, setting 을 위한 값들도 입력받습니다. 각각의 정보는 아래와 같습니다.

Parameter default note
bow . sparse matrix, scipy.sparse.csr_matrix
index2word . list of str
centers . numpy.ndarray
labels . 군집 레이블, numpy.ndarray or list of int
radious 3.5 원의 크기의 최대값
n_candidate_words 50 keyword 로 선정될 가능성이 있는 후보 단어의 개수 (n_printed_words 보다 크거나 같아야 함)
n_printed_words 30 HTML 우측에 출력되는 단어의 개수
lambda_step 0.01 값을 조절할 때의 step size
plot_opts {‘xlab’: ‘t-SNE1’, ‘ylab’: ‘t-SNE2’}) x, y 좌표축에 입력될 이름

위 값들을 입력받아 topic coordinates, token table, topic info 테이블을 만들고, 이를 LDAvis.PreparedData 의 입력 형식인 pandas.DataFrame 으로 변환하여 줍니다. topic oder 는 cluster_size 의 argsort 값을 역순으로 정렬하여 list 로 만들어줍니다. 그 뒤 PreparedData 에 이 값들을 입력합니다.

def kmeans_to_prepared_data(bow, index2word, centers, labels, radius=3.5,
    n_candidate_words=50, n_printed_words=30, lambda_step=0.01,
    plot_opts={'xlab': 't-SNE1', 'ylab': 't-SNE2'}):

    n_clusters = centers.shape[0]
    n_docs, n_terms = bow.shape

    cluster_size = Counter(labels)
    cluster_size = np.asarray([cluster_size.get(c, 0) for c in range(n_clusters)])

    term_frequency = np.asarray(bow.sum(axis=0)).reshape(-1)
    term_frequency[np.where(term_frequency == 0)[0]] = 0.01

    weighted_centers = np.zeros((n_clusters, n_terms))
    for c, n_docs in enumerate(cluster_size):
        weighted_centers[c] = centers[c] * n_docs

    # prepare parameters
    topic_coordinates = _get_topic_coordinates(
        centers, cluster_size, radius)

    topic_info = _get_topic_info(
        centers, cluster_size, index2word,
        weighted_centers, term_frequency, n_candidate_words)

    token_table = _get_token_table(
        weighted_centers, topic_info, index2word)

    topic_order = cluster_size.argsort()[::-1].tolist()

    # convert to pandas.DataFrame
    topic_coordinate_df = _df_topic_coordinate(topic_coordinates)
    topic_info_df = _df_topic_info(topic_info)
    token_table_df = _df_token_table(token_table)

    # ready pyLDAvis.PreparedData
    prepared_data = pyLDAvis.PreparedData(
        topic_coordinate_df,
        topic_info_df,
        token_table_df,
        n_printed_words,
        lambda_step,
        plot_opts,
        topic_order
    )

    # return
    return prepared_data

Demo

2016-10-20 뉴스 30,091 건에 대하여 명사를 추출한 다음 k=100 으로 설정하여 k-means 를 학습하였습니다. 그 뒤 위 함수를 이용하여 PreparedData 를 만들고 display 를 하였습니다.

위 코드들은 깃헙, https://github.com/lovit/kmeans_to_pyLDAvis 에 올려두었습니다.

import pyLDAvis
from kmeans_visualizer import kmeans_to_prepared_data

prepared_data = kmeans_to_prepared_data(x, index2word, centers, labels)
pyLDAvis.display(prepared_data)

말이 되지 않는 군집들이 존재하기도 합니다. 이들을 걸러내는 것은 k-means 같은 문서 군집화 알고리즘의 역할입니다. 이부분은 일단 넘어갑시다 (문서 군집화의 성능 향상은 따로 다뤄야할 어려운 문제입니다). 를 조절함으로써 군집에 많이 나오는 단어와 다른 군집과 구분이 되는 단어를 살펴볼 수 있습니다. 그리고 비슷한 2 차원 좌표를 지닌 군집들은 비슷한 키워드를 지니고 있음도 볼 수 있습니다.

사실 LDA 대신 k-means 를 이용할 때에는 k 를 훨씬 크게 잡아주고 비슷한 군집을 하나의 군집으로 묶어주는 후처리 과정도 필요합니다. 이에 대해서는 이번 포스트에서 다루지 않았습니다. 하지만, 더 이상 군집화 결과를 군집 레이블의 리스트로 보지 않아도 됩니다.

Reference

  • Buchta, C., Kober, M., Feinerer, I., & Hornik, K. (2012). Spherical k-means clustering. Journal of Statistical Software, 50(10), 1-22.