본문 바로가기

Data Science/추천시스템과 NLP

[NLP] 문서 군집화(Clustering)와 문서간 유사도(Similarity) 측정하기

반응형

이번 포스팅에서는 여러가지의 문서들을 군집화시켜보고 특정 하나의 문서가 다른 문서들간의 유사도를 측정해보는 방법에 대해서 알아보려고 한다. 군집분석은 비지도 학습으로 비슷한 데이터들끼리 서로 군집을 이루는 것을 말한다. 이러한 방법은 텍스트로 이루어진 문서 데이터에도 적용이 된다. 단어 발생 빈도수에 기반하는 BOW(Bag Of Words) 방식을 이용해 Feature(문서들을 이루고 있는 단어들)를 벡터화시키거나 단어들간의 의미 관계 즉, 단어 벡터들간의 방향을 고려해 Word embedding을 통해 벡터화 시킨다. 

 

하나의 것들이 모여 클러스터를 이룬다.

 

이번 포스팅에서는 BOW에 기반한 Tf-idf 방법을 사용한다. 다른 BOW 방식인 Count Vectorizer는 단순히 그저 단어 발생 빈도수에만 초점을 맞추기 때문에 여러가지 문서들간의 관계를 잘 고려하지 못한다. 그럼 이제 본론으로 들어가보자.

 

1. K-means를 활용한 Document Clustering

문서 군집화를 수행하기 위해서 K-means 군집 알고리즘이 자주 이용된다고 한다. 이유는 정확히 모르겠지만 개인적인 얕은(?)지식을 이용해 생각해본다면 K-means 알고리즘은 이상치에 민감한 알고리즘인데 텍스트 데이터에서는 수치형 데이터와는 달리 이상치 발생 빈도수가 현저하게 적지 않을까!? 라는 추론도 해본다. 지극히 개인적인 판단이므로 이에 대한 지적이나 피드백은 언제나 환영이다 :)

 

그럼 K-means를 활용해서 문서 군집화를 수행해보자. 데이터를 불러오고 전처리하는 코드과정은 생략하겠다.

 

import pandas as pd
document_df = pd.DataFrame({'filename':filename_lst,
                           'opinion_text':opinion_text})
document_df.head()

 

문서로 이루어진 데이터프레임 결과값은 다음과 같다. opinion_text 칼럼의 값들엔 ... 으로 되어 있지만 자세히 살펴보면 여러 문장으로 이루어져있다. 또한 filename은 해당 데이터의 label과 같은 값들로 각 opinion_text가 어떤 내용으로 이루어져 있는지에 대한 label이다. 실제 문서 군집화는 이 filename값들이 없는 상태에서 opinion_text들로만 군집화를 실시한다. 하지만 비지도학습인 군집화도 어찌됐든 성능을 평가하기 위해서는 label이 존재하긴 해야 하므로 filename 값들은 군집화 성능 비교를 위한 값들이라는 것을 알아두자.

 

 

이제 각 opinion_text에 들어있는 여러 문장들을 토큰화시키고 동시에 어근을 추출해주기 위해 NLTK의 Lemmatize 함수를 사용해보자.

 

# 텍스트 단어들의 어근 원형을 추출하기 위해 함수 생성
from sklearn.feature_extraction.text import TfidfVectorizer
from nltk.stem import WordNetLemmatizer
import nltk
import string
# string.puncutaion에 문자열의 모든 구두점이 들어있음
# 이를 활용해서 Tokenize시킬 때 구두점들을 제외하기 위한 것
# ord('문자열') => 문자열의 ASCII코드를 반환해줌!
# dict(key, value)형태로 모든 구두점의 각 ASCII코드를 key값으로 넣어주자!
remove_punct_dict = dict((ord(punct), None) for punct in string.punctuation)
lemmar = WordNetLemmatizer()

# 토큰화한 각 단어들의 원형들을 리스트로 담아서 반환
def LemTokens(tokens):
    return [lemmar.lemmatize(token) for token in tokens]
# 텍스트를 Input으로 넣어서 토큰화시키고 토큰화된 단어들의 원형들을 리스트로 담아 반환
def LemNormalize(text):
    # .translate인자에 구두점 dict넣어주어서 구두점 삭제해준 상태로 토큰화시키기!
    return LemTokens(nltk.word_tokenize(text.lower().translate(remove_punct_dict)))

# Tf-idf 벡터화시키면서 cusotmized해준 토큰화+어근추출 방식 tokenizer인자에 넣어주기
# 벡터화시킬 Tf-idf 도구 옵션 추가해서 구축
# 1,2gram적용, 빈도수 0.05이하, 0.85이상의 빈도수 단어들 제거
tfidf_vect = TfidfVectorizer(tokenizer=LemNormalize,
                            stop_words='english', ngram_range=(1,2),
                            min_df=0.05, max_df=0.85)
# fit_transform으로 위에서 구축한 도구로 텍스트 벡터화
ftr_vect = tfidf_vect.fit_transform(document_df['opinion_text'])

 

이제 각 문서들을 토큰화시키면서 어근을 추출했고 Tf-idf에 기반하여 단어들을 벡터화시켜 Feature들로 만드는 과정까지 마쳤다. 이제 K-means 알고리즘에 학습시켜 문서끼리 군집화를 시켜보자.

 

# K-means로 3개 군집으로 문서 군집화시키기
from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=3, max_iter=10000, random_state=42)
# 비지도 학습이니 feature로만 학습시키고 예측
cluster_label = kmeans.fit_predict(ftr_vect)

# 군집화한 레이블값들을 document_df 에 추가하기
document_df['cluster_label'] = cluster_label
print(document_df.sort_values(by=['cluster_label']))

 

군집화를 수행한 결과를 보자. 결과를 살펴보면 cluster_label값들이 0인 filename들은 주로 'inn', 'hotel'과 같은 '숙박 시설'과 관련된 주제임을 알 수 있다.

 

문서 군집화 수행 결과

 

다음은 K-means의 알고리즘의 특성 중 하나인 각 클러스터의 중심 좌표를 반환해주는 것을 이용해보자. 각 Feature들과 클러스터의 중심 간의 상대적인 위치를 반환해주는 값들을 활용해 군집별 핵심 단어를 추출해볼 수 있다. 주의할 점은 위치값들은 0~1사이의 값으로 나오게 되는데 1로 갈수록 특정 단어 Feature와 클러스터 중심과의 거리가 멀리 떨어져 있다는 것이 아닌 가장 가깝고 관계가 있다는 의미라는 것을 알아두자. 우선 각 Feature와 클러스터 중심간의 상대적인 위치값을 살펴보자.

 

# 문서의 feature(단어별) cluster_centers_확인해보자
cluster_centers = kmeans.cluster_centers_
print(cluster_centers.shape)
print(cluster_centers)
# shape의 행은 클러스터 레이블, 열은 벡터화 시킨 feature(단어들)

각 Feature와 클러스터 중심간의 상대적인 위치값들

 

이제 위 값들을 활용해서 각 클러스터간의 핵심적인 단어를 추출해보자. 

 

def get_cluster_details(cluster_model, cluster_data, feature_names,
                       cluster_num, top_n_features=10):
    cluster_details = {}
    # 각 클러스터 레이블별 feature들의 center값들 내림차순으로 정렬 후의 인덱스를 반환
    center_feature_idx = cluster_model.cluster_centers_.argsort()[:,::-1]
    
    # 개별 클러스터 레이블별로 
    for cluster_num in range(cluster_num):
        # 개별 클러스터별 정보를 담을 empty dict할당
        cluster_details[cluster_num] = {}
        cluster_details[cluster_num]['cluster'] = cluster_num
        
        # 각 feature별 center값들 정렬한 인덱스 중 상위 10개만 추출
        top_ftr_idx = center_feature_idx[cluster_num, :top_n_features]
        top_ftr = [feature_names[idx] for idx in top_ftr_idx]
        # top_ftr_idx를 활용해서 상위 10개 feature들의 center값들 반환
        # 반환하게 되면 array이기 떄문에 리스트로바꾸기
        top_ftr_val = cluster_model.cluster_centers_[cluster_num, top_ftr_idx].tolist()
        
        # cluster_details 딕셔너리에다가 개별 군집 정보 넣어주기
        cluster_details[cluster_num]['top_features'] = top_ftr
        cluster_details[cluster_num]['top_featrues_value'] = top_ftr_val
        # 해당 cluster_num으로 분류된 파일명(문서들) 넣어주기
        filenames = cluster_data[cluster_data['cluster_label']==cluster_num]['filename']
        # filenames가 df으로 반환되기 떄문에 값들만 출력해서 array->list로 변환
        filenames = filenames.values.tolist()
        cluster_details[cluster_num]['filenames'] = filenames
    
    return cluster_details

def print_cluster_details(cluster_details):
    for cluster_num, cluster_detail in cluster_details.items():
        print(f"#####Cluster Num: {cluster_num}")
        print()
        print("상위 10개 feature단어들:\n", cluster_detail['top_features'])
        print()
        print(f"Cluster {cluster_num}으로 분류된 문서들:\n{cluster_detail['filenames'][:5]}")
        print('-'*20)

feature_names = tfidf_vect.get_feature_names()
cluster_details = get_cluster_details(cluster_model=kmeans,
                                     cluster_data=document_df,
                                     feature_names=feature_names,
                                     cluster_num=3,
                                     top_n_features=10)
print_cluster_details(cluster_details)

 

결과값은 다음과 같이 나오게 된다. 

 

각 클러스터별 핵심 단어 추출하기

 

2. 문서들간의 유사도 측정하기

문서들간의 유사도는 어떻게 측정할까? 이는 단어의 의미적인 관계를 고려해야하기 때문에 단어를 벡터화하여 벡터들 간의 거리를 측정한다. 벡터간의 거리를 대표적인 방법으로는 '유클리디안 거리'가 있지만 '유클리디안 거리'는 이는 단어 빈도수 측면에서 한계가 존재한다.

따라서 코사인 유사도(Cosine Similarity)를 사용한다. 즉 벡터들 간의 사잇값을 계산하여 유사도를 측정하게 된다. 벡터간의 거리에 대한 여러가지 측정 방법은 여기를 참고하자.

 

https://deepai.org/machine-learning-glossary-and-terms/cosine-similarity

 

코사인 유사도는 두 벡터간의 각도에 따라 -1 ~ 1 사이의 값으로 계산이 된다. 그리고 그 각도에 따라 두 벡터(NLP에서는 2개의 단어들)간의 관계를 파악할 수 있다.

 

Cosine graph를 보면서 각도에 따라 값이 어떻게 되는지 살펴보자.

 

  • Cosine 0° : 두 벡터(단어)간에 매우 유사한 관계를 지닌다. Cosine Similarity값은 1이 된다.
  • Cosine 90° : 두 벡터간에 관계가 없음을 의미한다. Cosine Similarity값은 0이 된다. 
  • Cosine 180° : 두 벡터간에 반대관계를 의미한다. Cosine Similarity값은 -1이 된다.

하지만 텍스트 즉, 단어들을 Feature화 시킬 때 음수값이 나올 수 없으므로 Cosine 180° 인 경우는 존재하지 않는다. 따라서 단어 벡터들간의 유사도는 0 ~ 1사이의 값으로 나오게 된다.

 

코사인 유사도는 Scikit-learn에서 API를 제공해준다. cosine_similarity() 함수로 제공하는데, X와 y인자가 들어간다. 즉, X라는 벡터(들)에 대해서 y벡터(들)간의 유사도를 행렬로 보여준다. 마치 변수들 간의 상관관계 행렬처럼 말이다.

 

이제 K-means 실습 때 수행했던 문서들 간의 군집화 결과를 바탕으로 특정 클러스터 레이블의 문서 하나를 선정하고 그와 똑같은 label 문서들 간의 유사도를 직접 측정해보자. 

 

# 클러스터링된 문서들 중에서 특정 문서를 하나 선택한 후 비슷한 문서 추출
from sklearn.metrics.pairwise import cosine_similarity

hotel_idx = document_df[document_df['cluster_label']==1].index
print("호텔 카테고리로 클러스터링된 문서들의 인덱스:\n",hotel_idx)
print()
# 호텔 카테고리로 클러스터링 된 문서들의 인덱스 중 하나 선택해 비교 기준으로 삼을 문서 선정
comparison_doc = document_df.iloc[hotel_idx[0]]['filename']
print("##유사도 비교 기준 문서 이름:",comparison_doc,'##')
print()

# 위에서 추출한 호텔 카테고리로 클러스터링된 문서들의 인덱스 중 0번인덱스(비교기준문서)제외한
# 다른 문서들과의 유사도 측정
similarity = cosine_similarity(ftr_vect[hotel_idx[0]], ftr_vect[hotel_idx])
print(similarity)

결과값

 

다음은 위에서 유사도를 측정한 값들로 선정한 'battery-life_ipod_nono_8gb' 라는 주제의 문서가 어떤 문서들과 유사도가 큰지 시각화해보자. 

 

# 비교기준 문서와 다른 문서들간의 유사도 살펴보기
import seaborn as sns
import numpy as np
import matplotlib.pyplot as plt
# array 내림차순으로 정렬한 후 인덱스 반환 [:,::-1] 모든행에 대해서 열을 내림차순으로!
sorted_idx = similarity.argsort()[:,::-1]
# 비교문서 당사자는 제외한 인덱스 추출
sorted_idx = sorted_idx[:, 1:]

# 유사도가 큰 순으로 hotel_idx(label=1인 즉, 호텔과관련된 내용의 문서이름들의 index들)에서 재 정렬 
# index로 넣으려면 1차원으로 reshape해주기!
hotel_sorted_idx = hotel_idx[sorted_idx.reshape(-1,)]
# 유사도 행렬값들을 유사도가 큰 순으로 재정렬(비교 문서 당사자는 제외)
hotel_sim_values = np.sort(similarity.reshape(-1,))[::-1]
hotel_sim_values = hotel_sim_values[1:]
# 이렇게 되면 비교문서와 가장 유사한 순으로 '해당문서의index-유사도값' 으로 동일한 위치가 매핑된 두 개의 array!
# 그래서 그대로 데이터프레임의 각 칼럼으로 넣어주기
print(hotel_sorted_idx)
print(hotel_sim_values)
print()
print("길이 비교", len(hotel_sorted_idx), len(hotel_sim_values))
print()
# 빈 데이터프레임 생성
hotel_sim_df = pd.DataFrame()
# hotel_sorted_idx 와 hotel_sim_values 매핑시킨 array임
hotel_sim_df['filename'] = document_df.iloc[hotel_sorted_idx]['filename']
hotel_sim_df['similarity'] = hotel_sim_values

plt.figure(figsize=(15,10))
sns.barplot(data=hotel_sim_df, x='similarity', y='filename')
plt.title(comparison_doc)

문서간 유사도 시각화 

 

가장 유사도가 높은 내용들의 문서는 주로 'battery' 에 대한 주제의 문서임을 알 수 있다.

 

반응형