2019.06.11
본 글은 https://wikidocs.net/book/2155을 참고하여 작성되었음을 밝힙니다.
핵심키워드
- 잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)
잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)
토픽 모델링은 문서의 집합에서 토픽을 찾아내는 프로세스를 말한다. (토픽은 한국어로는 주제라고 한다.) 이는 검색 엔진, 고객 민원 시스템 등과 같이 문서의 주제를 알아내는 일이 중요한 곳에서 사용된다. 잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)은 토픽 모델링의 대표적인 알고리즘이다.
1. 잠재 디리클레 할당(Latent Dirichlet Allocation, LDA)
우선 LDA의 내부 매커니즘에 대해서 이해하기 전에, LDA를 일종의 블랙 박스로 보고 LDA에 문서 집합을 입력했다면, 어떤 결과를 보여주는지 예를 들어보겠다. 아래와 같은 3개의 문서가 있다고 가정한다면, 지금의 예제는 간단해서 눈으로도 토픽 모델링을 할 수 있을 것 같지만, 실제 수십만개 이상의 문서가 있는 경우는 LDA의 알고리즘의 도움이 필요하다.
문서1: 저는 사과랑 바나나를 먹어요
문서2 : 우리는 귀여운 강아지가 좋아요
문서3 : 저의 깜찍하고 귀여운 강아지가 바나나를 먹어요
LDA를 수행할 때 문서 집합에서 토픽이 몇 개가 존재할지 가정하는 것은 사용자가 해야할 일이다. 여기서는 LDA 알고리즘에 2개의 토픽을 찾으라고 요청하겠다. 토픽의 개수를 변수 k라고 하였을 때, k = 2 이다. k의 값을 잘못 선택하면 원치않는 이상한 결과가 나올 수 있다. 이렇게 모델의 성능에 영향을 주는 사용자가 직접 선택하는 매개변수를 머신러닝 용어로 하이퍼파라미터라고 한다. 이러한 하이퍼파라미터의 선택은 여러 실험을 통해 얻은 값일 수도 있고, 우선 시도해보는 것일 수도 있다.
LDA 알고리즘이 위의 세 문서로부터 2개의 토픽을 찾은 결과는 아래와 같다. 여기서는 LDA 입력 전에 주어와 불필요한 조사 등을 제거하는 전처리 과정은 거쳤다고 가정한다. 즉, 전처리 과정을 거친 TDM이 LDA의 입력이 되었다고 가정한다.
LDA는 각 문서의 토픽 분포와 각 토픽 내의 단어 분포를 추정한다.
<각 문서의 토픽 분포>
문서1 : 토픽 A 100%
문서2 : 토픽 B 100%
문서3 : 토픽 B 60%, 토픽 A 40%
<각 토픽의 단어 분포>
토픽A : 사과 20%, 바나나 40%, 먹어요 40%, 귀여운 0%, 강아지 0%, 깜찍하고 0%, 좋아요 0%
토픽B : 사과 0%, 바나나 0%, 먹어요 0%, 귀여운 33%, 강아지 33%, 깜찍하고 16%, 좋아요 16%
LDA 알고리즘은 토픽의 제목을 정해주지 않지만, 이 시점에서 알고리즘의 사용자는 두 토픽이 각각 과일에 대한 토픽과 강아지에 대한 토픽이라는 것을 알 수 있다.
2. LDA의 가정
LDA는 문서의 집합으로부터 어떤 토픽이 존재하는지를 알아내기 위한 알고리즘이다. LDA 알고리즘은 앞서 배운 빈도수 기반의 표현 방법들인 TDM 또는 TF-IDF 행렬을 입력으로 하는 데, 이로부터 알 수 있는 사실은 LDA는 단어의 순서는 신경쓰지 않는다는 점이다.
LDA는 문서들로부터 토픽을 뽑아내기 위해서 어떤 가정을 염두에두고 있다. 실제로 문서의 작성자가 그랬는지 안 그랬는지는 중요하지 않다. LDA는 기본적으로 이러한 가정을 전제로 한다. 모든 문서 하나, 하나가 작성될 때 그 문서의 작성자는 이러한 생각을 했다.
‘나는 이 문서를 작성하기 위해서 이런 주제들을 넣을거고, 이런 주제들을 위해서는 이런 단어들을 넣을 거야.’ 조금 더 구체적으로 알아보면, 각각의 문서는 다음과 같은 과정을 거쳐서 작성되었다고 가정한다.
1) 문서에 사용할 단어의 개수 N을 정한다.
ex) 5개의 단어를 정했다.
2) 문서에 사용할 토픽의 혼합을 결정한다.
ex) 위 예제와 같이 토픽이 2개라고 하였을 때 강아지 토픽을 60%, 과일 토픽을 40%와 같이 선택할 수 있다.
3) 문서에 사용할 각 단어를 (아래와 같이) 정한다.
3–1) 토픽 분포에서 토픽 T를 확률적으로 고른다.
ex) 60% 확률로 강아지 토픽을 선택하고, 40% 확률로 과일 토픽을 선택할 수 있다.
3–2) 선택한 토픽 T에서 단어의 출현 확률 분포에 기반해 문서에 사용할 단어를 고른다.
ex) 강아지 토픽을 선택했다면, 33% 확률로 강아지란 단어를 선택할 수 있다. 이제 3) 을 반복하면서 문서를 완성한다.
이러한 과정을 통해 문서가 작성되었다는 가정 하에 LDA는 토픽을 뽑아내기 위하여 위 과정을 역으로 추적하는 역공학(reverse engneering)을 수행한다. 앞서 LDA의 결과로 문서 내 토픽 분포와 토픽 내 단어 분포가 나왔던 이유가 여기에 있다.
3. LDA의 수행하기
이제 LDA의 수행 과정을 정리해보자.
1) 사용자는 알고리즘에게 토픽의 개수 k를 알려준다.
앞서 말하였듯이 알고리즘에게 토픽의 개수를 알려주는 역할은 사용자의 역할이다. LDA 알고리즘은 토픽 k가 M개의 문서에 걸쳐 분포되었다고 가정한다.
2) 모든 단어를 k개 중 하나의 토픽에 할당한다.
이제 각 문서는 토픽을 가지며, 토픽은 단어 분포를 가지는 상태이다. 물론 랜덤으로 할당하였기 때문에 사실 전부 틀린 상태이다. 만약 한 단어가 한 문서에서 2회 이상 등장하였다면, 각 단어는 서로 다른 토픽에 할당되었을 수도 있다.
3) 이제 모든 문서의 모든 단어에 대해서 아래의 사항을 반복 진행한다.(iterative)
3–1) 어떤 문서의 각 단어 w는 자신은 잘못된 토픽에 할당되어져 있지만, 다른 단어들은 전부 올바른 토픽에 할당되어져 있는 상태라고 가정한다. 이에 따라 단어 w는 아래의 두 가지 기준에 따라서 토픽이 재할당된다.
- p(topic t | document d) : 문서 d의 단어들 중 토픽 t에 해당하는 단어의 비율
- p(word w | topic t) : 단어 w를 갖고 있는 모든 문서들 중 토픽 t가 할당된 비율
이를 반복하면, 모든 할당이 완료된 수렴 상태가 된다. 두 가지 기준이 어떤 의미인지 예를 들어보자. 설명의 편의를 위해서 저자는 두 개의 문서라는 새로운 예를 사용했다.
위의 그림은 두 개의 문서 doc1과 doc2를 보여준다. 여기서는 doc1의 세번째 단어 apple의 토픽을 결정하고자 한다.
우선 첫번째로 사용하는 기준은 문서 doc1의 단어들이 어떤 토픽에 해당하는지에 대한 단어들의 비율이다. doc1의 모든 단어들은 토픽 A와 토픽 B에 50 대 50의 비율로 할당되어져 있으므로, 이 기준에 따르면 단어 apple은 토픽 A 또는 토픽 B 둘 중 어디에도 속할 가능성이 있다.
두번째 기준은 단어 ‘apple’이 전체 문서에서 어떤 토픽에 할당되어져 있는지를 본다. 이 기준에 따르면 단어 ‘apple’은 토픽 B에 할당될 가능성이 높다. 이러한 두 가지 기준을 참고하여 LDA는 doc1의 ‘apple’을 어떤 토픽에 할당할지 결정한다.
4. 잠재 디리클레 할당과 잠재 의미 분석의 차이
LSA : 단어 문서 행렬을 차원 축소하여 축소 차원에서 근접 단어들을 토픽으로 묶는다.
LDA : 단어가 특정 토픽에 존재할 확률과 문서에 특정 토픽이 존재할 확률을 결합확률로 추정하여 토픽을 추출한다.
5. 실습을 통한 이해
1) 뉴스 제목 데이터에 대한 이해
여기서 사용할 데이터는 약 15년 동안 발행되었던 뉴스 제목들을 모아놓은 영어 데이터다. 해당 데이터는 아래 링크를 통해 다운받을 수 있다.
import pandas as pd
data = pd.read_csv("./abcnews-date-text.csv", error_bad_lines=False)data.head()
text = data[['headline_text']]
text.head()
2) 텍스트 전처리
이번 챕터에서느 이전에 텍스트 전처리 챕터에서 배웠던 토큰화, 불용어 제거, 표제어 추출을 사용해보도록 한다. 차근차근 차례대로 수행하면서, 데이터가 어떤 식으로 전처리되어가는지 확인해보도록 한다.
import nltk
text['headline_text'] = text.apply(lambda row: nltk.word_tokenize(row['headline_text']), axis=1)
text.head()
단어 토큰화가 정상적으로 되었음을 확인하였다. 이제 자연어 처리에 불필요한 불용어를 제거해보도록 한다.
from nltk.corpus import stopwords
stop = stopwords.words('english')
text['headline_text'] = text['headline_text'].apply(lambda x: [word for word in x if word not in (stop)])
text.head()
불용어 처리가 완료되었다면, 이제 표제어를 추출해보도록 한다. 표제어 추출로 3인칭 단수 표현을 1인칭으로 바꾸고, 과거 현재형 동사를 현재형으로 바꾼다.
from nltk.stem import WordNetLemmatizer
text['headline_text'] = text['headline_text'].apply(lambda x : [WordNetLemmatizer().lemmatize(word, pos='v') for word in x])
text.head()
표제어 추출이 된 것을 확인할 수 있다. 이제 길이가 3이하인 단어에 대해서 제거하는 작업을 수행한다. 그리고 이번에는 결과를 tokenize_doc이라는 새로운 변수에 저장한다.
tokenized_doc = text['headline_text'].apply(lambda x : [word for word in x if len(word) > 3])
tokenized_doc[:5]
이제 TF-IDF 행렬을 만들어보자.
3) TF-IDF 행렬 만들기
TfidfVectorizer는 기본적으로 토큰화가 되어있지 않은 텍스트 데이터를 입력으로 사용한다. 그렇기 때문에 TfidfVectorizer를 사용해서 TF-IDF 행렬을 만들기 위해서 다시 토큰화 작업을 역으로 취소하는 역토큰화(Detokenization) 작업을 수행해본다.
# 역토큰화
detokenized_doc = []
for i in range(len(text)):
t = ' '.join(tokenized_doc[i])
detokenized_doc.append(t)text['headline_text'] = detokenized_doc
# 다시 text['headline_text'] 에 저장text.head()
정상적으로 역토큰화가 수행되었음을 알 수 있다. 이제 사이킷런의 TfidfVecotrizer를 통해 단어 1,000개에 대한 TF-IDF 행렬을 만든다. 물론 텍스트 데이터에 있는 모든 단어를 가지고 행렬을 만들 수는 있겠지만, 여기서는 1,000개의 단어로 제한하도록 한다. 만약 시간과 서버(컴퓨터)의 성능이 넉넉하다면, 모든 단어를 가지고 진행해도 상관이 없다.
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(stop_words='english', max_features=1000)
X = vectorizer.fit_transform(text['headline_text'])X.shape
(1103663, 1000)
1,103,664행 1000열을 가진 TF-IDF 행렬이 생겼다. 이제 이에 LDA를 수행한다.
4) 토픽 모델링
from sklearn.decomposition import LatentDirichletAllocation
lda_model = LatentDirichletAllocation(n_components=10, learning_method='online', random_state=777, max_iter=1)
lda_top = lda_model.fit_transform(X)print(lda_model.components_)
print(lda_model.components_.shape)[[1.00001714e-01 1.00001113e-01 1.00003516e-01 ... 1.00004331e-01
1.00006663e-01 1.00002621e-01]
[1.00000674e-01 1.00000498e-01 1.00002463e-01 ... 1.00004467e-01
1.00007043e-01 1.00003391e-01]
[1.00000354e-01 1.00000771e-01 1.00002270e-01 ... 1.00005788e-01
1.00008030e-01 1.00002175e-01]
...
[1.00003856e-01 1.00001750e-01 2.68617297e+03 ... 1.00005484e-01
1.00006449e-01 1.00002815e-01]
[1.00002347e-01 1.00001176e-01 1.00004315e-01 ... 1.00002852e-01
1.00002624e-01 1.00003783e-01]
[1.00003309e-01 8.66344603e+02 1.00004532e-01 ... 1.00004007e-01
1.00005097e-01 1.00004615e-01]](10, 1000)terms = vectorizer.get_feature_names()
# 단어 집합. 1,000개의 단어가 저장되어있음.def get_topics(components, feature_names, n=5):
for idx, topic in enumerate(components):
print("Topic %d :" % (idx+1), [(feature_names[i], topic[i].round(2)) for i in topic.argsort()[:-n -1:-1]])get_topics(lda_model.components_, terms)Topic 1 : [('queensland', 8131.17), ('melbourne', 7594.6), ('open', 5621.85), ('woman', 5613.3), ('coast', 5458.64)]
Topic 2 : [('north', 6266.31), ('kill', 6096.78), ('interview', 5889.32), ('plan', 5609.09), ('tasmania', 4442.27)]
Topic 3 : [('police', 12134.76), ('death', 6075.66), ('miss', 4600.47), ('family', 4152.65), ('fight', 4084.38)]
Topic 4 : [('sydney', 8600.27), ('attack', 6819.4), ('court', 6023.77), ('shoot', 5147.89), ('state', 4858.8)]
Topic 5 : [('australia', 14004.8), ('south', 6252.43), ('donald', 5752.4), ('live', 5655.16), ('warn', 5157.49)]
Topic 6 : [('charge', 8433.73), ('murder', 6412.27), ('house', 6220.31), ('years', 5555.01), ('brisbane', 5300.13)]
Topic 7 : [('election', 7647.73), ('adelaide', 6839.31), ('make', 6216.07), ('test', 5595.48), ('crash', 5494.41)]
Topic 8 : [('perth', 6330.82), ('canberra', 6286.7), ('china', 4421.78), ('return', 3531.12), ('john', 3313.67)]
Topic 9 : [('market', 5094.08), ('council', 3855.3), ('share', 3813.1), ('national', 3793.01), ('lead', 3461.01)]
Topic 10 : [('trump', 13043.94), ('australian', 11390.1), ('government', 8668.23), ('world', 7027.23), ('year', 5986.46)]