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 포스트를 참고하세요.
k-means
k-means 는 다른 군집화 알고리즘과 비교하여 매우 적은 계산 비용을 요구하면서도 안정적인 성능을 보입니다. 그렇기 때문에 큰 규모의 데이터 군집화에 적합합니다. 문서 군집화의 경우에는 문서의 개수가 수만건에서 수천만건 정도 되는 경우가 많기 때문에 다른 알고리즘보다도 k-means 가 더 많이 선호됩니다. k-partition problem 은 데이터를 k 개의 겹치지 않은 부분데이터 (partition)로 분할하는 문제 입니다. 이 때 나뉘어지는 k 개의 partiton 에 대하여, “같은 partition 에 속한 데이터 간에는 서로 비슷하며, 서로 다른 partition 에 속한 데이터 간에는 이질적”이도록 만드는 것이 군집화라 생각할 수 있습니다. k-means problem 은 각 군집 (partition)의 평균 벡터와 각 군집에 속한 데이터 간의 거리 제곱의 합 (분산, variance)이 최소가 되는 partition 을 찾는 문제입니다.
우리가 흔히 말하는 k-means 알고리즘은 Lloyd 에 의하여 제안되었습니다. 이는 다음의 순서로 이뤄져 있습니다.
- k 개의 군집 대표 벡터 (centroids) 를 데이터의 임의의 k 개의 점으로 선택합니다.
- 모든 점에 대하여 가장 가까운 centroid 를 찾아 cluster label 을 부여하고,
- 같은 cluster label 을 지닌 데이터들의 평균 벡터를 구하여 centroid 를 업데이트 합니다.
- 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 을 해야 합니다.
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 를 다음처럼 만들면 얻을 수 있습니다.
그런데 이 형식이 numpy.ndarray 가 아니라 그 위의 abstract class 인 matrix 입니다. 이를 numpy.ndarray 의 column vector 로 변환하려면 다음과 같은 작업을 하여야 합니다.
앞서 doc lengths 는 column sum 이었다면 term frequency 는 row sum 을 하면 됩니다.
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 도 합니다.
cluster size 는 labels 을 카운팅함으로써 확인할 수 있습니다. 이 값의 root 에 비례하여 cluster size 를 만듭니다. 크기가 가장 작은 cluster 는 0.2 의 Freq 값을, 그리고 가장 큰 cluster 의 Freq 는 radious + 0.2 가 되도록 scaling 도 합니다.
위 내용을 정리하면 아래와 같습니다.
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 로 를 이용합니다.
각 클러스터마다 크기가 다르기 때문에 클러스터의 크기를 곱한 weighted_centers 를 만듭니다. 이 값을 이용하여 인 p_prop 와 인 n_prop 를 만듭니다. loglift 는 p_prop / (n_prop + p_prop) 로 정의합니다. p_prop 가 큰 각 클러스터마다 n_candidate_words 개의 단어에 대하여 loglift 와 logprob 에 해당하는 값을 계산하여 scores 로 만듭니다.
각 클러스터에 대해서는 loglift 와 logprob 의 값을 만들어줘야 하지만, Default Category 를 위해서는 most frequent term 만 선택하면 됩니다. 이는 term_frequency.argsort()[::-1] 를 이용하여 찾을 수 있습니다. 빈도수가 큰 n_candidate_words 에 대하여 term frequency 에 비례하는 loglift, logprob 의 값을 만들어줍니다. Scale 은 임의로 [10, 25] 로 정의하였습니다.
위 내용을 정리하면 아래와 같습니다.
Making token table
만들어둔 topic info 를 이용하면 손쉽게 token table 을 만들 수 있습니다. weighted_centers 의 row sum 으로 weighted_centers 를 나누면 각 클러스터 마다 단어 가 전체 중에서 얼마나 등장하였는지 그 비율이 계산됩니다.
이를 이용하여 topic info 에 입력된 단어들에 대해서만 따로 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 에 이 값들을 입력합니다.
Demo
2016-10-20 뉴스 30,091 건에 대하여 명사를 추출한 다음 k=100 으로 설정하여 k-means 를 학습하였습니다. 그 뒤 위 함수를 이용하여 PreparedData 를 만들고 display 를 하였습니다.
위 코드들은 깃헙, https://github.com/lovit/kmeans_to_pyLDAvis 에 올려두었습니다.
말이 되지 않는 군집들이 존재하기도 합니다. 이들을 걸러내는 것은 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.