분석하고싶은코코

NLP - 토픽 모델링(LSA, LDA) 본문

머신러닝&딥러닝/NLP

NLP - 토픽 모델링(LSA, LDA)

코코로코코 2023. 9. 26. 22:18
반응형

토픽 모델링

토픽은 한글로 주제라고 번역이 됩니다. 토픽 모델링이란 결국 텍스트 안에서 주제가 될만한 텍스트들을 찾아내는 텍스트마이닝 기법중 하나라고 생각하시면 됩니다. 이 토픽 모델링에 대해 이해하기 위해서 잠재 의미 분석 (Latent Semantic Analysis, LSA)과 잠재 디리클레 할당 (Latent Dirichlet Allocation, LDA) 두 가지에 대해서 알아보겠습니다.

 

 

잠재 의미 분석 (Latent Semantic Analysis, LSA)

우선 이 LSA는 토픽 모델링을 하기 위해서 만들어진 알고리즘이 아니라 이전에 존재했던 알고리즘은데 이 LSA의 방법이 토픽 모델링에 더 작합한 LDA를 만드는데 크게 영향을 주었기 떄문에 LSA을 알아보고 LDA에 대해서 알아보겠습니다. BoW에 기반한 DTM이나 TF-IDF는 기본적으로 단어의 빈도 수를 이용한 수치화 방법이기 때문에 단어의 의미를 고려하지 못한다는 단점이 있습니다. 이를 위한 대안으로 DTM의 잠재된(Latent) 의미를 이끌어내는 방법으로 잠재 의미 분석(Latent Semantic Analysis, LSA)이라는 방법을 사용하게 되었습니다. 이 LSA를 이해하기 위해서는 선형대수학의 특이값 분해 (Singular Value Decomposition, SVD)를 필수적으로 이해할 필요가 있습니다.

 

 

특이값 분해 (Singular Value Decomposition, SVD)

SVD란 A = m * n행렬일때 3개의 행렬로 분해하는 것을 이야기합니다. 이렇게 분해된 3개의 행렬의 곱은 다시 A로 표현이 가능하게 됩니다. 

  • U : (m * m) 직교행렬
  • V : (n * n) 직교행렬
  • Σ : (m * n) 직사각 대각행렬

U, V가 직교행렬이라 되어 있는데 직교행렬이란 n * n = A라 할때 1. A * A^T(전치행렬) = I(단위행렬) 2. A^T * A = I임을 만족하는 행렬을 직교행렬이라고 합니다. 좀 간단하게 생각해보면 전치행렬은 A의 역행렬임을 알 수 있습니다. Σ의 경우 U,V와 다르게 대각행렬이라 되어 있는데 앞에 직사각이라는 조건이 붙었습니다. 대각행렬은 (n,n)에 특정한 값을 갖고 나머지는 0으로 구성되어있는 행렬입니다. 따라서 앞에 직사각은 그냥 행렬의 모양을 나타내는 말이라고만 이해하실 수 있습니다. 그런데 이건 일반적인 대각행렬의 경우이고 SVD를 통해 나온 'Σ' 대각행렬은 특정한 값들이 내림차순으로 나온다라는 특징이 있습니다. 예를들어 12, 10, 1이 SVD를 통해 나온 Σ의 값 목록이라고 한다면 (0,0) -> 12, (1,1) -> 10, (2,2) -> 1의 값이 배치되어 있다는 이야기입니다.

 

 

절단된 SVD(Truncated SVD)

위에서 설명한 SVD는 Full SVD라고 합니다. 분리된 3개의 행렬을 통해 A를 다시 구할 수 있게 되는거죠. 그런데 LSA에서는 Full SVD를 사용하지 않습니다. 쉽게 이야기하면 Σ는 대각행렬로 특정값을 갖고 있는 대각선라인이 있을텐데 이게 토픽의 개념이라고 생각하시면 됩니다. 대각선에 있는 값의 개수를 p라고할때 우리는 p를 LSA의 하이퍼파라미터라고 합니다. 이 하이퍼파라미터는 우리가 수정하면서 다양하게 적용해볼 수 있는 값으로 이 값이 당연히 커지면 Full SVD에 가까워 지겠죠. 그런데 그만큼 비용이 많이 발생하고 노이즈가 많습니다. NLP에서는 노이즈라기보다 너무 많은 정보들이 있어서 심층적(Latent)으미를 찾는데 어려움이 생긴다고 표현할 수 있습니다. 이렇게 설정한 p에 따라 나머지 행렬인 U,V역시 p에 맞게 슬라이싱됩니다. 이를 통해 다시 원행렬 A에 대한 형태로 복원한 행렬을 A'라고 할때 완벽하게 복원할 수는 없습니다. 당연히 정보에 대한 소실이 있었기 때문에 완벽한 복원은 어렵습니다.

절단된  SVD

 

 

그러면 이제 이렇게 알아본 SVD와 절단된 SVD를 간단하게 Python을 통해 구현해보고 실제 텍스트에 대한 LSA를 적용해보겠습니다.

 

SVD

import numpy as np

# 4 * 9 행렬 초기화
A = np.array(([[0,0,0,1,0,1,1,0,0],
               [0,0,0,1,1,0,1,0,0],
               [0,1,1,0,2,0,0,0,0],
               [1,0,0,0,0,0,0,1,1]]))

U, s, VT = np.linalg.svd(A, full_matrices=True)
print('U : ', U.shape,'\n',U.round(2))
print()
print('VT :', VT.shape, '\n', VT.round(2))
print()
print('s : ', s.shape,'\n', s.round(2))
# 결과
U :  (4, 4) 
 [[ 0.24  0.75  0.    0.62]
 [ 0.51  0.44 -0.   -0.74]
 [ 0.83 -0.49 -0.    0.27]
 [ 0.   -0.    1.   -0.  ]]

VT : (9, 9) 
 [[ 0.    0.31  0.31  0.28  0.8   0.09  0.28  0.    0.  ]
 [ 0.   -0.24 -0.24  0.58 -0.26  0.37  0.58 -0.   -0.  ]
 [ 0.58 -0.    0.    0.   -0.    0.   -0.    0.58  0.58]
 [-0.    0.35  0.35 -0.16 -0.25  0.8  -0.16  0.    0.  ]
 [-0.   -0.78 -0.01 -0.2   0.4   0.4  -0.2   0.    0.  ]
 [-0.29  0.31 -0.78 -0.24  0.23  0.23  0.01  0.14  0.14]
 [-0.29 -0.1   0.26 -0.59 -0.08 -0.08  0.66  0.14  0.14]
 [-0.5  -0.06  0.15  0.24 -0.05 -0.05 -0.19  0.75 -0.25]
 [-0.5  -0.06  0.15  0.24 -0.05 -0.05 -0.19 -0.25  0.75]]

s :  (4,) 
 [2.69 2.05 1.73 0.77]
# 0으로 구성된 4*9 행렬 생성
S = np.zeros((4,9))

# 특이값 삽입
S[:4, :4] = np.diag(s)

# allclose -> 2개의 행렬이 동일 여부 확인
np.allclose(A, np.dot(np.dot(U,S), VT).round(2))

# 결과
True

 

절단된  SVD

S = S[:2, :2]
U = U[:, :2]
VT = VT[:2, :]

print('S : ', s.shape,'\n', S.round(2))
print('U : ', U.shape,'\n',U.round(2))
print('VT : ', VT.shape,'\n',VT.round(2))


## 결과
S :  (4,) 
 [[2.69 0.  ]
 [0.   2.05]]
 
 U :  (4, 2) 
 [[ 0.24  0.75]
 [ 0.51  0.44]
 [ 0.83 -0.49]
 [ 0.   -0.  ]]
 
 VT :  (2, 9) 
 [[ 0.    0.31  0.31  0.28  0.8   0.09  0.28  0.    0.  ]
 [ 0.   -0.24 -0.24  0.58 -0.26  0.37  0.58 -0.   -0.  ]]
A2 = np.dot(np.dot(U,S), VT).round(2)
print(A)
print(A2)
np.allclose(A, A2)


##결과
A
[[0 0 0 1 0 1 1 0 0]
 [0 0 0 1 1 0 1 0 0]
 [0 1 1 0 2 0 0 0 0]
 [1 0 0 0 0 0 0 1 1]]
 
A2
[[ 0.   -0.17 -0.17  1.08  0.12  0.62  1.08 -0.   -0.  ]
 [ 0.    0.2   0.2   0.91  0.86  0.45  0.91  0.    0.  ]
 [ 0.    0.93  0.93  0.03  2.05 -0.17  0.03  0.    0.  ]
 [ 0.    0.    0.    0.    0.    0.    0.    0.    0.  ]]

False

 

텍스트에 절댄된 SVD적용해보기 (Sklearn)

import pandas as pd
from sklearn.datasets import fetch_20newsgroups
import nltk
from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD

dataset = fetch_20newsgroups(shuffle=True, random_state=1, remove=('headers', ' footers', 'quotes'))
documents = dataset.data

news_df = pd.DataFrame({'document' : documents})
news_df['clean_doc'] = news_df['document'].str.replace('[^a-zA-Z]', ' ')
news_df['clean_doc'] = news_df['clean_doc'].apply(lambda x : ' '.join([w for w in x.split() if len(w)>3]))
news_df['clean_doc'] = news_df['clean_doc'].apply(lambda x : x.lower())

stop_words = stopwords.words('english')
tokenized_doc = news_df['clean_doc'].apply(lambda x : x.split())
tokenized_doc = tokenized_doc.apply(lambda x : [item for item in x if item not in stop_words])

detokenized_doc = []
for i in range(len(news_df)):
    t = ' '.join(tokenized_doc[i])
    detokenized_doc.append(t)

news_df['clean_doc'] = detokenized_doc

vectorizer = TfidfVectorizer(stop_words='english', max_features=1000, max_df=0.5, smooth_idf=True)
X = vectorizer.fit_transform(news_df['clean_doc'])


svd_model = TruncatedSVD(n_components=5, algorithm='randomized', n_iter=100, random_state=777)
svd_model.fit(X)

terms = vectorizer.get_feature_names_out()

def get_topics(components, feature_names, n = 5):
    for idx, topic in enumerate(components):
        print(f'TOPIC {idx+1} {[(feature_names[i], topic[i].round(5)) for i in topic.argsort()[:-n-1:-1]]}')

get_topics(svd_model.components_,terms)
TOPIC 1 [('like', 0.20505), ('know', 0.18838), ('people', 0.18376), ('think', 0.16767), ('good', 0.14274)]
TOPIC 2 [('thanks', 0.3379), ('windows', 0.27465), ('mail', 0.17725), ('card', 0.17113), ('drive', 0.15578)]
TOPIC 3 [('game', 0.38223), ('team', 0.32242), ('year', 0.27387), ('games', 0.24544), ('season', 0.18665)]
TOPIC 4 [('drive', 0.51326), ('scsi', 0.20344), ('disk', 0.15638), ('hard', 0.15618), ('card', 0.15153)]
TOPIC 5 [('thanks', 0.37204), ('drive', 0.3638), ('know', 0.25132), ('scsi', 0.13857), ('advance', 0.12312)]

 

 

 

 잠재 디리클레 할당 (Latent Dirichlet Allocation, LDA)

LDA는 통계기반의 알고리즘으로 각 토픽을 분리해서 그룹화시키는 개념이라고 생각히시면 쉽습니다.  예를들어 토픽이 2개라고 했을때 두 토픽간 분산은 최대한 크게 가져가고, 토픽 내부의 분산은 최대한 작게 가져가는 방식입니다. 크기와 모양이 일정하지 않은 파란색과 빨간색 공이 섞여있는 상자에서 색에 따른 공을 분류해준다는 작업으로 생각하면 이해하기 쉬우실 겁니다.

 

그런데 LDA는 통계기반한 알고리즘으로 어떤 토픽(주제)인지에 대해서는 우리에게 알려주지 않습니다. 그저 문장 내에서 사용된 단어들을 기반으로 토픽 수에 맞는 분류를 해주는 것이죠. 따라서 결과를 확인하고 어떤 토픽인지에 대해서 인지하는 것은 사용자의 몫입니다.

 

LDA의 수행 순서는 다음과 같습니다.

  1. 추정하고자 하는 토픽의 수를 k라고 할때 k를 먼저 정해줍니다.(k는 문서 전체에 대해서 k개의 토픽이 있다는 뜻입니다.)
  2. 모든 단어에 대해서 k개의 토픽 리스트 안에서 랜덤하게 배정합니다.
  3. p(topic t | document d) : 문서 d에 있는 단어에서 토픽t의 비율
  4. p(word w | topic t) : 토픽 t에서 단어 w의 비율
  5. 모든 단어에 대해서 3번과 4번 과정을 반복합니다.

순서를 읽어보시면 2번까지는 이해가 되실겁니다. LDA는 기본적으로 BoW의 행렬DTM이나 Tf-idf의 행렬을 입력값으로 사용합니다. 따라서 LDA는 문장을 구성하는 단어의 순서에  상관없이 문장을 구성하고 있는 단어들에 대해서 랜덤한 토픽을 할당하는 과정이 2번까지의 과정입니다. 3번과 4번 반복이 핵심 작업인데 글로는 이해되지 않을 수 있습니다. 그래서 간단한 예시를 통해 이 과정을 설명해보겠습니다.

아래와 같이 하나의 문장에 대해 단어들의 토픽이 다음과 같이 할당 되어있다고 해보겠습니다. 하나의 문장은 하나의 토픽을 갖을 것입니다. 그렇다면 비율로 봤을때 좀 더 높은쪽을 선택하겠죠. 그런데 지금은 A토픽이 2개 B토픽이 2개이므로 예측하고하자는 토픽은 둘 다 가능성이 있는 상황인 겁니다. 이렇게 3번과정이 끝이납니다. 여기서 토픽이 무엇이냐를 바로 결정하지 않습니다.

 

<문장예시_1>

단어 스타벅스 노트북 스타벅스 TFT TFT
토픽 A A ??? B B

 

다음 문장도 한 번 확인해보겠습니다.

<문장예시_2>

단어 스타벅스 투썸 스타벅스 탐앤탐스 메가커피
토픽 A A A A A

 이번에는 모두 A를 갖고 있지만 이번에는 이 문장 자체를 보는게 아닙니다. 문장예시_1과 2가 내가 사용할 데이터의 전부라고 할때 내가 예측하고자 하는 단어는 예시_1의 '스타벅스'라는 단어입니다. 그러면 '스타벅스'라는 단어가 전체 데이터에서 어느 토픽에 더 비율이 높냐라는 것을 보는 것입니다. 이 두 가지 과정을 거치면 문장예시_1의 3번째 단어인 스타벅스에 'A'라는 토픽이 들어갈 확률이 높은것이죠.

 

위 예시 과정에서 확인했듯 LDA는 단어가 특정 토픽에 존재할 확률과 문서에 특정 토픽이 존재할 확률을 결합확률로 추정하는 것입니다. 

 

그럼 이제 예제를 통해서 한글에 대한 LDA를 진행해보겠습니다. 한글에 대한 예시로 진행해보았고 토큰화는 KoNLPy의 Okt, Mecab 두 가지를 사용해 진행해보았습니다. (Okt의 경우 시간이 조금 걸립니다.)

 

결과를 보면 토큰화한 단어에 따라서 토픽 모델링을 진행하고 그 결과들이 나오는것을 볼 수 있습니다. 즉 전처리를 얼마나 잘 하냐에 따라서 토픽모델링의 결과가 달라짐을 알 수 있습니다. 이번에는 한글만을 사용하였고, 불용어는 커스텀리스트로 한글자를 추가해주는 정도의 전처리만 진행한 상황이었습니다. 그런데 토큰화과정에서 okt, mecab 두 가지 나눠서 진행했고 그 결과가 다르게 나오는지에 대해서 확인해볼 수 있었습니다.

 

스팀 한글리뷰 LDA

import pandas as pd
import urllib.request
from konlpy.tag import Mecab,Okt
from nltk.stem import WordNetLemmatizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import LatentDirichletAllocation as LDA

urllib.request.urlretrieve("https://raw.githubusercontent.com/bab2min/corpus/master/sentiment/steam.txt", filename="steam.txt")

total_data = pd.read_table('steam.txt', names=['label', 'reviews'])

texts = total_data['reviews']

texts['reviews_token'] = texts.str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
texts['reviews_token'] = texts['reviews_token'].dropna()

stopwords = ['도', '는', '다', '의', '가', '이', '은', '한', '에', '하', '고', '을', '를', '인', '듯', '과', '와', '네', '들', '듯', '지', '임', '게', '만', '게임', '겜', '되', '음', '면']
mecab = Mecab() 
okt = Okt()

texts['token_Mecab'] = texts['reviews_token'].apply(mecab.morphs)
texts['tokne_Okt'] = texts['reviews_token'].apply(okt.morphs)
texts['token_Mecab'] = texts['reviews_token'].apply(lambda x: [item for item in x if item not in stopwords])
texts['tokne_Okt'] = texts['tokne_Okt'].apply(lambda x: [item for item in x if item not in stopwords])

# 각 모듈에 따른 리뷰 문장 토큰화 확인
# texts['token_Mecab'].head()
# texts['tokne_Okt'].head()

detokenized_mecab = []
detokenized_okt = []

for i in range(len(texts['reviews_token'])):
    t1 = ''.join(texts['token_Mecab'][i])
    t2 = ' '.join(texts['tokne_Okt'][i])

    detokenized_mecab.append(t1)
    detokenized_okt.append(t2)
    

# Tfidf
vectorizer_m = TfidfVectorizer(max_features=1000)
vectorizer_o = TfidfVectorizer(max_features=1000)

X_1 = vectorizer_m.fit_transform(detokenized_mecab)
X_2 = vectorizer_o.fit_transform(detokenized_okt)


# LDA 모델링
lda_model_1 = LDA(n_components=10, learning_method='online', random_state=777, max_iter=1)
lda_top_1 = lda_model_1.fit_transform(X_1)

lda_model_2 = LDA(n_components=10, learning_method='online', random_state=777, max_iter=1)
lda_top_2 = lda_model_2.fit_transform(X_2)


# 결과 확인
terms_m = vectorizer_m.get_feature_names_out()
terms_o = vectorizer_o.get_feature_names_out()

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_1.components_,terms_m)
Topic 1: [('시간', 955.78), ('모르겠', 596.61), ('아직', 569.57), ('플레', 506.21), ('느낌', 423.41)]
Topic 2: [('정말', 724.01), ('버그', 641.63), ('추천', 529.17), ('전제', 503.08), ('조금', 469.48)]
Topic 3: [('그냥', 898.95), ('사람', 824.06), ('서버', 560.47), ('친구', 542.4), ('업데트', 486.39)]
Topic 4: [('아니', 1058.58), ('시발', 658.3), ('존나', 576.2), ('매우', 530.68), ('스토리', 503.82)]
Topic 5: [('재미', 1067.0), ('좋아', 464.72), ('퍼즐', 423.12), ('없어서', 341.81), ('조작', 332.17)]
Topic 6: [('그래픽', 763.52), ('별로', 526.48), ('괜찮', 452.32), ('솔직히', 412.79), ('안됨', 353.13)]
Topic 7: [('진짜', 1086.58), ('그리', 690.47), ('엔딩', 491.05), ('글화', 454.37), ('ㅠㅠ', 448.66)]
Topic 8: [('재밌', 1359.4), ('너무', 934.28), ('재미있', 763.37), ('실행', 648.47), ('조작감', 513.43)]
Topic 9: [('멀티', 559.97), ('그래', 406.44), ('때문', 357.38), ('사서', 353.15), ('일단', 341.15)]
Topic 10: [('입니', 578.48), ('어떻', 540.31), ('어렵', 488.88), ('환불', 479.94), ('계속', 451.68)]
get_topics(lda_model_2.components_,terms_o)
Topic 1: [('노잼', 656.71), ('별로', 492.14), ('ㅠㅠ', 480.09), ('패치', 461.67), ('한글화', 451.36)]
Topic 2: [('정말', 695.79), ('으로', 639.03), ('조작', 609.66), ('좋은', 574.6), ('ㅋㅋ', 420.37)]
Topic 3: [('재미', 804.99), ('그냥', 607.71), ('스토리', 602.65), ('하지', 550.48), ('할만', 530.75)]
Topic 4: [('시발', 690.6), ('시간', 682.85), ('존나', 640.03), ('진짜', 611.77), ('한글', 596.62)]
Topic 5: [('추천', 640.73), ('친구', 639.77), ('사람', 638.52), ('하는', 626.93), ('서버', 542.88)]
Topic 6: [('멀티', 688.65), ('너무', 615.09), ('보다', 553.01), ('망겜', 398.45), ('꿀잼', 381.41)]
Topic 7: [('하다', 499.36), ('어떻게', 481.99), ('이다', 477.72), ('유저', 430.4), ('사지', 395.38)]
Topic 8: [('한다', 508.05), ('스팀', 487.82), ('지금', 479.44), ('같다', 465.7), ('에서', 462.92)]
Topic 9: [('환불', 854.22), ('실행', 761.96), ('하고', 492.16), ('도전', 491.47), ('과제', 452.73)]
Topic 10: [('하나', 553.97), ('최고', 509.03), ('난이도', 439.78), ('명작', 391.92), ('무슨', 384.89)]

 

 


+ 토픽 모델링에 대한 기록을 하는 이유는 예전에 진행해보려고 데이터만 수집하고 진행하지 못했던 게임에서 일어나는 사건사고 게시판에 대한 분석을 진행해보고 싶어서다. 내가 회사의 입장이 되지는 못하기에 유저들의 직접적인 불만글(Voc) 데이터를 얻기가 힘들다. 그래서 방향을 틀어서 유저들이 불만글을 올리는게 무엇이 있을까에 대한 고민을 하게 되었고 완벽하지는 않지만 비슷한 불만에 대한 글이고 게임 안에서 발생하는 것이라 생각되어 사건사고 게시판에 올라오는 글에 대한 토픽 모델링을 통해 유저들이 어떤 부분에서 사건 사고가 많이 발생하고 이게 유저들 플레이 경험중 어떤 주제가 안좋은 경험들을 발생시키고 있는지 파악해보려고 한다.

 

 

반응형