'Machine Learning/National Language Processing'에 해당하는 글 16건

토픽 모델링 알고리즘인 LSA 의 단점을 보완한 대표적인 알고리즘이 잠재 디리클레 할당(LDA) 이다. 문서들에서 발견된 단어의 빈도수를 분석하여, 해당 문서가 어떤 주제를 다루고 있을지 예측할 수 있다. LDA 를 사용하여 토픽을 몇개 얻을 것인지 사용자가 지정해야 하는데, 이 하이퍼 파라미터로부터 결과가 달라질 수 있으므로 올바른 토픽을 얻기 위한 테스트가 필요하다.

 

LDA 는 각 문서의 토픽 분포와 각 토픽의 단어 분포를 도출하고 분석하여, 해당 문서가 어떤 주제들을 함께 다루고 있을지를 예측할 수 있다.


LDA 수행과정

 

  1. 문서 빈도수 기반의 표현 방법인, DTM 이나 TF-IDF 행렬을 입력으로 한다. (단어의 순서가 중요치 않음)
  2. 사용자가 LDA 에 하이퍼 파라미터인 토픽 개수(k) 를 전달한다.
  3. 문서에 사용할 토픽의 혼합을 확률 분포에 기반하여 결정한다.
  4. 랜덤으로 할당된 단어에 잘못 할당된 토픽을 각 단어들은 P(t|d) 와 P(w|t) 작업을 반복(역공학) 하며 토픽을 재할당한다.
     - 각 문서의 토픽 분포 P(t|d) : 해당 문서 d 에 나타난 토픽 t 의 비율을 보고 단어에 토픽 할당
     - 각 토픽의 단어 분포 P(w|t) : 는 전체 문서에서 단어 w 에 할당된 토픽 t 의 비율을 보고 단어에 토픽 할당

 

아래는 뉴스 기사의 제목을 모아놓은 약 100만개의 영어 데이터로부터 gensim 과 sklearn 을 사용하여 토픽을 추출하는 예제이다.

 

 

LDA with gensim

import gensim 
from gensim import corpora 

""" 전처리 결과가 아래와 같다고 할 때 
print(tokenized_doc[:5]) 
0    [well, sure, about, story, seem, biased, what,... 
1    [yeah, expect, people, read, actually, accept,... 
2    [although, realize, that, principle, your, str... 
3    [notwithstanding, legitimate, fuss, about, thi... 
4    [well, will, have, change, scoring, playoff, p... 
Name: clean_doc, dtype: object 
"""

# 각 단어에 정수 인코딩으로 (word_id, word_frequency) 의 형태로 변환 
dictionary = corpora.Dictionary(tokenized_doc) 
corpus = [dictionary.doc2bow(text) for text in tokenized_doc]  # [[(52, 1), (55, 1), ... , (88, 1), (89, 1)]] 

# LDA 토픽 모델링 (num_topics: 토픽 수, passes: 반복훈련횟수)
ldamodel = gensim.models.ldamodel.LdaModel(corpus, num_topics=20, id2word=dictionary, passes=15)
topics = ldamodel.print_topics(num_words=4) 
for topic in topics: 
    print(topic) 

""" 토픽 별 단어 분포 
(0, '0.015*"drive" + 0.014*"thanks" + 0.012*"card" + 0.012*"system"') 
(1, '0.009*"back" + 0.009*"like" + 0.009*"time" + 0.008*"went"') 
(2, '0.012*"colorado" + 0.010*"david" + 0.006*"decenso" + 0.005*"tyre"') 
(3, '0.020*"number" + 0.018*"wire" + 0.013*"bits" + 0.013*"filename"') 
(4, '0.038*"space" + 0.013*"nasa" + 0.011*"research" + 0.010*"medical"') 
(5, '0.014*"price" + 0.010*"sale" + 0.009*"good" + 0.008*"shipping"') 
(6, '0.012*"available" + 0.009*"file" + 0.009*"information" + 0.008*"version"') 
(7, '0.021*"would" + 0.013*"think" + 0.012*"people" + 0.011*"like"') 
(8, '0.035*"window" + 0.021*"display" + 0.017*"widget" + 0.013*"application"') 
(9, '0.012*"people" + 0.010*"jesus" + 0.007*"armenian" + 0.007*"israel"') 
(10, '0.008*"government" + 0.007*"system" + 0.006*"public" + 0.006*"encryption"') 
(11, '0.013*"germany" + 0.008*"sweden" + 0.008*"switzerland" + 0.007*"gaza"') 
(12, '0.020*"game" + 0.018*"team" + 0.015*"games" + 0.013*"play"') 
(13, '0.024*"apple" + 0.014*"water" + 0.013*"ground" + 0.011*"cable"') 
(14, '0.011*"evidence" + 0.010*"believe" + 0.010*"truth" + 0.010*"church"') 
(15, '0.016*"president" + 0.010*"states" + 0.007*"united" + 0.007*"year"') 
(16, '0.047*"file" + 0.035*"output" + 0.033*"entry" + 0.021*"program"') 
(17, '0.008*"dept" + 0.008*"devils" + 0.007*"caps" + 0.007*"john"') 
(18, '0.011*"year" + 0.009*"last" + 0.007*"first" + 0.006*"runs"') 
(19, '0.013*"outlets" + 0.013*"norton" + 0.012*"quantum" + 0.008*"neck"') 
""" 

# 문서 별 토픽 분포 
for i, topic_list in enumerate(ldamodel[corpus]): 
    if i==5:  # 상위 5개 
        break 
    print(i,'번째 문서의 topic 비율은',topic_list) 

""" 
0 번째 문서의 topic 비율은 [(7, 0.3050222), (9, 0.5070568), (11, 0.1319604), (18, 0.042834017)] 
1 번째 문서의 topic 비율은 [(0, 0.031606797), (7, 0.7529218), (13, 0.02924682), (14, 0.12861845), (17, 0.037851967)] 
2 번째 문서의 topic 비율은 [(7, 0.52241164), (9, 0.36602455), (16, 0.09760969)] 
3 번째 문서의 topic 비율은 [(1, 0.16926806), (5, 0.04912094), (6, 0.04034211), (7, 0.11710636), (10, 0.5854137), (15, 0.02776434)] 
4 번째 문서의 topic 비율은 [(7, 0.42152268), (12, 0.21917087), (17, 0.32781804)] 
""" 



LDA with sklearn

 

from sklearn.feature_extraction.text import TfidfVectorizer 
from sklearn.decomposition import LatentDirichletAllocation

""" 전처리 결과가 아래와 같다고 할 때 
print(tokenized_doc[:5]) 
0       decide community broadcast licence 
1       fire witness must aware defamation 
2    call infrastructure protection summit 
3                   staff aust strike rise 
4      strike affect australian travellers 
Name: headline_text, dtype: object 
""" 

# TF-IDF 행렬 만들기 
vectorizer = TfidfVectorizer(stop_words='english', max_features= 1000)  # 상위 1,000개의 단어로 제한 
X = vectorizer.fit_transform(tokenized_doc) 

# LDA 토픽 모델링 
lda_model = LatentDirichletAllocation(n_components=10, learning_method='online', random_state=777, max_iter=1) 
lda_top = lda_model.fit_transform(X) 

terms = vectorizer.get_feature_names() # 단어 집합 

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: [('government', 8725.19), ('sydney', 8393.29), ('queensland', 7720.12), ('change', 5874.27), ('home', 5674.38)] 
Topic 2: [('australia', 13691.08), ('australian', 11088.95), ('melbourne', 7528.43), ('world', 6707.7), ('south', 6677.03)] 
Topic 3: [('death', 5935.06), ('interview', 5924.98), ('kill', 5851.6), ('jail', 4632.85), ('life', 4275.27)] 
Topic 4: [('house', 6113.49), ('2016', 5488.19), ('state', 4923.41), ('brisbane', 4857.21), ('tasmania', 4610.97)] 
Topic 5: [('court', 7542.74), ('attack', 6959.64), ('open', 5663.0), ('face', 5193.63), ('warn', 5115.01)] 
Topic 6: [('market', 5545.86), ('rural', 5502.89), ('plan', 4828.71), ('indigenous', 4223.4), ('power', 3968.26)] 
Topic 7: [('charge', 8428.8), ('election', 7561.63), ('adelaide', 6758.36), ('make', 5658.99), ('test', 5062.69)] 
Topic 8: [('police', 12092.44), ('crash', 5281.14), ('drug', 4290.87), ('beat', 3257.58), ('rise', 2934.92)] 
Topic 9: [('fund', 4693.03), ('labor', 4047.69), ('national', 4038.68), ('council', 4006.62), ('claim', 3604.75)] 
Topic 10: [('trump', 11966.41), ('perth', 6456.53), ('report', 5611.33), ('school', 5465.06), ('woman', 5456.76)] 
""" 

 


WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

토픽 모델링(Topic Modeling) 이란 문서집합에서 추상적인 주제를 발견하기 위한 통계적 모델로, 잠재 의미 분석(Latent Semantic Analysis, LSA) / 잠재 디리클레 할당(Latent Dirichlet Allocation, LDA) 등의 알고리즘이 있다. LSA 는 기본적으로 DTM 이나 TF-IDF 행렬에 절단된 특이값 분해(truncated SVD) 를 사용하여 차원을 축소시키고, 단어들의 잠재적인 의미를 끌어내지만, SVD 의 특성상 새로운 데이터를 업데이트 하려면 처음부터 다시 계산해야 하는 단점이 있다.


특이값 분해(SVD) 는 A 가 m × n 행렬일 때,

3개의 행렬(U:m×m 직교행렬, VT:n×n 직교행렬, S:m×n 직사각 대각행렬) 의 곱으로 분해(decomposition) 하는 것이다.


(9 x 4) 행렬의 DTM 으로 절단된 특이값 분해(truncated SVD) 를 구하기.


import numpy as np
 
# 아래와 같은 DTM 이 있다고 할 때,
= [
    [000101100],
    [000110100],
    [011020000],
    [100000011]
]
 
# (4 x 9) 행렬에서
# 일단 특이값 분해 full SVD 구하기 : U x s x VT
# U : m×m 직교행렬,
# s : m×n 직사각 대각행렬,
# VT : n×n 직교행렬 이라 할 때,
U, s, VT = np.linalg.svd(A, full_matrices=True)
 
# 4 x 4 직교행렬 확인
print(U.round(2))
# [[ 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.  ]]
 
# 특이값 s 를 대각행렬로 바꾸고 직교행렬 구하기
print(s.round(2))
# [2.69 2.05 1.73 0.77]
= np.zeros((49))
S[:4, :4= np.diag(s)  # 특이값 s 를 대각행렬에 삽입
print(S.round(2))
# [[2.69 0.   0.   0.   0.   0.   0.   0.   0.  ]
#  [0.   2.05 0.   0.   0.   0.   0.   0.   0.  ]
#  [0.   0.   1.73 0.   0.   0.   0.   0.   0.  ]
#  [0.   0.   0.   0.77 0.   0.   0.   0.   0.  ]]
 
# 9 x 9 직교행렬 확인
print(VT.round(2))
# [[ 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]]
cs


여기까지 구해본 full SVD 를 역으로 계산해 보면 U x S x VT = A 와 같음을 알 수 있다.

이제 3개 행렬을 축소시킨 truncated SVD 를 구하여 다른 문서나 단어의 유사도를 구할 수 있다.


# Truncated SVD 구하기
# 특이값 상위 2개만 남기기 (t = 2)
= U[:, :2]
= S[:2, :2]
VT = VT[:2, :]
print(np.dot(np.dot(U,S), VT).round(2))
# [[ 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.  ]]
cs


위와 같이 전체 코퍼스에서 절단된 특이값 분해를 구해야 하므로, 데이터를 추가하게 되면 전과정을 처음부터 다시 실행해야 하는 단점이 있다.




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

문서들 간에 유사도를 구하기 위해서는 문서마다 동일한 단어나 비슷한 단어가 얼마나 사용되었는지를 파악할 수 있다. BoW, DTM, TF-IDF, Word2Vec 등의 방법으로 단어를 수치화 했다면, 유사도 기법을 사용하여 문서의 유사도를 구하는 게 가능하다.


  1. 코사인 유사도(Cosine similarity) 는 두 벡터의 방향에 따라 1~ -1 의 값을 가지며 1에 가까울수록 유사하다.
  2. 유클리드 거리(Euclidean distance) 는 두 점 사이의 직선 거리를 구하여 거리의 값이 작을수록 유사하다.
  3. 자카드 유사도(Jaccard similarity) 는 두개의 집합이 있을 때 합집합에서 교집합의 비율을 구하며, 1에 가까울 수록 유사하다.


문서1 : 저는 사과 좋아요

문서2 : 저는 바나나 좋아요

문서3 : 저는 바나나 좋아요 저는 바나나 좋아요


위 문서간의 유사도 구하기


from sklearn.feature_extraction.text import CountVectorizer
from numpy import dot
from numpy.linalg import norm
import numpy as np
 
corpus = [
    "저는 사과 좋아요",
    "저는 바나나 좋아요",
    "저는 바나나 좋아요 저는 바나나 좋아요",
]
vector = CountVectorizer()
dtm = vector.fit_transform(corpus).toarray()
print(dtm)
# [[0 1 1 1]
#  [1 0 1 1]
#  [2 0 2 2]]
 
def cos_sim(A, B):
    return dot(A, B) / (norm(A)*norm(B))
 
print(cos_sim(dtm[0], dtm[1])) #문서1과 문서2의 코사인 유사도 0.6666666666666667
print(cos_sim(dtm[0], dtm[2])) #문서1과 문서3의 코사인 유사도 0.6666666666666667
print(cos_sim(dtm[1], dtm[2])) #문서2과 문서3의 코사인 유사도 1.0000000000000002
 
def dist(A, B):
    return np.sqrt(np.sum((A-B)**2))
 
print(dist(dtm[0], dtm[1])) #문서1과 문서2의 유클리드 거리 1.4142135623730951
print(dist(dtm[0], dtm[2])) #문서1과 문서3의 유클리드 거리 2.6457513110645907
print(dist(dtm[1], dtm[2])) #문서2과 문서3의 유클리드 거리 1.7320508075688772
 
corp1 = corpus[0].split()
corp2 = corpus[1].split()
corp3 = corpus[2].split()
 
def jaccard(A, B):
    union = set(A).union(set(B))  # 합집합
    intersection = set(A).intersection(set(B))  # 교집합
    return len(intersection) / len(union)
 
print(jaccard(dtm[0], dtm[1])) #문서1과 문서2의 자카드 유사도 1.0
print(jaccard(dtm[0], dtm[2])) #문서1과 문서3의 자카드 유사도 0.3333333333333333
print(jaccard(dtm[1], dtm[2])) #문서2과 문서3의 자카드 유사도 0.3333333333333333
cs


위 코드에서 처럼 문서의 유사도의 성능은 각 문서의 단어들의 수치화 방법, 유사도 방법에 따라 다르다는 것을 알 수 있다.




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

문서 단어 행렬(DTM) 의 단점, 중요한 단어에 대해서 가중치를 주지 못하는 단점을 보완한 방법이 단어 빈도-역 문서 빈도(TF-IDF: Term Frequency-Inverse Document Frequency) 이다. 문서의 빈도에 특정 식을 취하여 DTM 내의 각 단어들에 가중치를 주는 방법이다. TF-IDF 의 값이 높을수록 중요도가 높다. TF-IDF 의 특징은 모든 문서에서 자주 등장하는 a 나 the 같은 단어는 중요도가 낮다고 판단하며, 특정 문서에서만 자주 등장하는 단어를 중요도가 높다고 판단한다.



IF-IDF 에 적용되는 특정 식은 TF 값 과 IDF 값을 곱하는 것이다. (IDF 값은 DF 값의 역수이다.)

  • tf(d,t) : 특정 문서 d 에서의 특정 단어 t 의 등장 횟수.
  • df(t) : 특정 단어 t가 등장한 문서의 수.
  • idf(d,t) : df(t)에 반비례하는 수. n 을 총 문서 개수라고 할 때, 자연로그 ln(n/(1+df(t))) 


import pandas as pd
from math import log
 
docs = [
  '먹고 싶은 사과',
  '먹고 싶은 바나나',
  '길고 노란 바나나 바나나',
  '저는 과일이 좋아요'
]
 
vocab = list(set(w for doc in docs for w in doc.split()))  # 중복 제거, 단어 토큰화
vocab.sort()  # 오름차순 정렬 : ['과일이', '길고', '노란', '먹고', '바나나', '사과', '싶은', '저는', '좋아요']
 
= len(docs)  # 총 문서 수
 
def tf(t, d):
    return d.count(t)
 
def idf(t):
    df = 0
    for doc in docs:
        df += t in doc  # in 연산. True / False... True = 1
    return log(N/(df + 1))
 
def tfidf(t, d):
    return tf(t, d) * idf(t)
 
result = []
for i in range(N):  # 각 문서별
    result.append([])
    d = docs[i]
    for j in range(len(vocab)):
        t = vocab[j]
        result[-1].append(tf(t,d))  # tf : 문서별 단어 빈도수 구하기
        # [[0, 0, 0, 1, 0, 1, 1, 0, 0], [0, 0, 0, 1, ...
 
tf_ = pd.DataFrame(result, columns = vocab)
""" DTM
   과일이  길고  노란  먹고  바나나  사과  싶은  저는  좋아요
0    0   0   0   1    0   1   1   0    0
1    0   0   0   1    1   0   1   0    0
2    0   1   1   0    2   0   0   0    0
3    1   0   0   0    0   0   0   1    1
"""
 
result = []
for j in range(len(vocab)):
    t = vocab[j]
    result.append(idf(t))
 
idf_ = pd.DataFrame(result, index = vocab, columns = ["IDF"])
"""
          IDF
과일이  0.693147
길고   0.693147
노란   0.693147
먹고   0.287682
바나나  0.287682
사과   0.693147
싶은   0.287682
저는   0.693147
좋아요  0.693147
"""
 
result = []
for i in range(N):
    result.append([])
    d = docs[i]
    for j in range(len(vocab)):
        t = vocab[j]
        result[-1].append(tfidf(t,d))
 
tfidf_ = pd.DataFrame(result, columns = vocab)
"""
        과일이        길고        노란  ...        싶은        저는       좋아요
0  0.000000  0.000000  0.000000  ...  0.287682  0.000000  0.000000
1  0.000000  0.000000  0.000000  ...  0.287682  0.000000  0.000000
2  0.000000  0.693147  0.693147  ...  0.000000  0.000000  0.000000
3  0.693147  0.000000  0.000000  ...  0.000000  0.693147  0.693147
"""
cs


공교롭게도 바나나가 잘렸지만; 문서2 에서의 바나나 tfidf(0.28) 보다, 문서3 에서의 바나나 tfidf(0.57) 가 높은 것으로 보아 문서3 에서의 바나나가 더 중요하다는 것을 인식해야 한다.



TfidfVectorizer 를 이용한 TF-IDF


from sklearn.feature_extraction.text import TfidfVectorizer
 
corpus = [
    'you know I want your love',
    'I like you',
    'what should I do ',
]
 
tfidfv = TfidfVectorizer().fit(corpus)
print(tfidfv.transform(corpus).toarray())
print(tfidfv.vocabulary_)
 
"""
[[0.         0.46735098 0.         0.46735098 0.         0.46735098  0.         0.35543247 0.46735098]
 [0.         0.         0.79596054 0.         0.         0.          0.         0.60534851 0.        ]
 [0.57735027 0.         0.         0.         0.57735027 0.          0.57735027 0.         0.        ]]
{'you': 7, 'know': 1, 'want': 5, 'your': 8, 'love': 3, 'like': 2, 'what': 6, 'should': 4, 'do': 0}
"""
cs




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,

Bow(Bag of Words) 는 가방 속의 단어를 무작위로 뽑는 것처럼, 단어의 등장 순서를 고려하지 않는 빈도수(frequency) 기반의 단어 표현 방법이다. 빈도수로 유사도를 구하고 그에 따라 분류하는데 사용될 수 있다. 또한 서로 다른 문서들의 Bow 를 결합한 표현 방법을 문서 단어 행렬(DTM: Document-Term Matrix / TDM) 이라고 하는데, 원-핫 벡터와 같이 단어 집합을 줄이기 위해 불용어 처리가 중요하며, 정작 중요한 단어에 대해서 가중치를 주지 못하는 단점이 있다.


Bow 생성 과정은 단어 토큰화, 중복제거, 인덱스 부여, 빈도수 벡터 생성으로 만들 수 있다.


from konlpy.tag import Okt
import re
 
okt = Okt()
text = "정부가 발표하는 물가상승률과 소비자가 느끼는 물가상승률은 다르다."
token = re.sub("(\.)""", text)  # 기호제거
token = okt.morphs(token)
 
word2index = {}
bow = []
for voca in token:
    if voca not in word2index.keys():
        word2index[voca] = len(word2index)
        bow.insert(len(word2index) - 11)
    else:
        index = word2index[voca]
        bow[index] += 1
 
print(word2index)  # {'정부': 0, '가': 1, '발표': 2, '하는': 3, '물가상승률': 4, '과': 5, '소비자': 6, '느끼는': 7, '은': 8, '다르다': 9}
print(bow)  # [1, 2, 1, 1, 2, 1, 1, 1, 1, 1]
cs



CountVectorizer 로 Bow 만들기


from sklearn.feature_extraction.text import CountVectorizer
 
corpus = ['you know I want your love. because I love you.']
vector = CountVectorizer()
 
print(vector.fit_transform(corpus).toarray())  # [[1 1 2 1 2 1]]
print(vector.vocabulary_)  # {'you': 4, 'know': 1, 'want': 3, 'your': 5, 'love': 2, 'because': 0}
cs



CountVectorizer 에 불용어 사용하기

# 불용어 사용자 정의
vector = CountVectorizer(stop_words=["the""a""an""is""not"])
 
# CountVectorizer 영문 불용어 사용
vector = CountVectorizer(stop_words="english")
 
# NLTK 영문 불용어 사용
from nltk.corpus import stopwords
sw = stopwords.words("english")
vector = CountVectorizer(stop_words = sw)
cs




WRITTEN BY
손가락귀신
정신 못차리면, 벌 받는다.

,