Day to_day

[하루한끼_데이터] 문서 간 유사도 검사를 통한 추천 시스템 만들기 #2 본문

Project

[하루한끼_데이터] 문서 간 유사도 검사를 통한 추천 시스템 만들기 #2

m_inglet 2022. 12. 21. 01:08
728x90
반응형

전체 코드는 "[NLP] 문서 군집화(Clustering)와 문서 간 유사도(Similarity) 측정하기"를 참고하여 작성되었습니다. 

 

저번 포스팅에서 문서 간 유사도 검사를 위해 각 문서의 TF-IDF를 벡터화시켰다.

이제 코사인 유사도 검사를 하기 전에 클러스터링을 통해 그룹을 먼저 나눠주고 그 그룹 내에서 유사도 검사를 진행해 하나의 레시피를 몇 백개와 수없이 비교하는 것을 방지한다.

 

그러면 내가 사용할 클러스터링 알고리즘은 K-means clustering이다. 왜 하필 K-means clustering이냐고? 그게 제일 먼저 알고 있던 알고리즘이기도 하고, 또 다른 군집 모델을 알지 못했다;; 

나중에 유사도 검사를 다 끝내 놓고 알고 보니 데이터의 분포에 따라 잘 작동하는 클러스터링 모델이 있다고 한다. 

 

 

위 사진은 사이킷런 공식 문서에서 가져온 clustering method이다. 근데 데이터의 분포를 먼저 확인해보기엔 "차원 축소"라는 개념이 먼저 필요했다. 일단은 K-means 알고리즘을 먼저 사용해보고 성능 확인 후 디벨롭할 때 시도해 보기로 한다.

 

 

그러면 일단 K-means Clustering에 대해서 알아야 한다.

 

K-means Clustering


 데이터는 항상 label 값이 있는 것이 아니고 라벨링 되어있지 않은 데이터들을 가지고 숨겨진 패턴을 찾는 것을 비지도 학습이라고 한다. 그중 Clustering은 가장 널리 알려진 비지도 학습 중 한 가지 기법으로, 비슷한 유형의 데이터를 그룹화 함으로써 라벨링 되어있지 않은 데이터에 숨겨진 구조를 파악한다.

 K-means 알고리즘은 가장 유명한 클러스터링 알고리즘으로 “K”가 의미하는 바는 주어진 데이터로부터 그룹화할 그룹, 즉 묶음의 수를 말한다. “Means”는 각 클러스터의 중심과 데이터들의 평균 거리를 의미한다. 이때 클러스터의 중심을 centroids라고 한다.

 

 

<K-means 알고리즘의 진행 순서>

  1. 데이터셋에서 K 개의 centroids를 임의로 지정
  2. 각 데이터들을 가장 가까운 centroids가 속한 그룹에 할당
  3. 2번 과정에서 할당된 결과를 바탕으로 centroids를 새롭게 지정
  4. 2 ~ 3번 과정을 centroids가 더 이상 변하지 않을 때까지 반복

 

 

이제 코드를 보자

from sklearn.cluster import KMeans

kmeans = KMeans(n_clusters=5, max_iter=10000, random_state=42)

# 비지도 학습이므로 feature로만 학습시키고 예측
cluster_label = kmeans.fit_predict(ftr_vect)

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

 

코드에서 쓰인 KMeans의 인자로 n_clusters는 그룹의 개수(클러스터의 개수), max_iter 최대 반복 횟수, random_state는 호출할 때마다 동일한 학습/테스트용 데이터 세트를 생성하기 위해 주어지는 난수 값이다.

 

 

 

첫 번째 실행 결과

얼추 확인해 봤을 땐 cluster 개수가 문젠 건지 잘 군집화가 안된 모습이 한눈에 보인다.

(salsa와 cookies가 한 묶음에?)

 

 

그래서 클러스터링이 최대한 잘 될 수 있도록 변수들을 조금씩 조절해가면서 다시 학습시켜야 한다. 

'잘' 학습이 되었는지를 알기 위해선 다른 작업이 추가적으로 필요하다. 앞서 말했듯이 K-means 클러스터링 알고리즘은 비지도 학습이다. 즉 그룹을 묶었는데 이게 잘 묶였는지를 확인할 '정답지'가 없다는 것이다. 

일단 변수 수정을 해보고 잘 묶었는지 판단해야 하니 각 클러스터(그룹) 별로 문서들의 대표되는 feature 단어를 보고, 그 대표되는 feature 단어와 그 안에 속해있는 레시피(문서)의 제목을 보고 진짜 잘 묶였는지를 판단하기로 한다. 

 

 

 

 

 

각 클러스터 중심 좌표 반환하여 핵심 단어 추출


# 문서의 feature(단어 별) cluster_centers 확인하기
cluster_centers = kmeans.cluster_centers_
print(cluster_centers.shape) # (클러스터 레이블, feature 단어들)
print(cluster_centers)

 

실행 결과

 

cluster_centers.shape : (5, 164)

cluster_centers :

cluster_centers

 

결과에 대한 의미

  • 위의 좌표들은 각 feature 들과 중심 간의 상대적인 위치를 반환한다.
  • 위치 값들은 0-1 사이의 값으로 나오는데 1로 갈수록 특정 단어 feature와 클러스터 중심과의 거리가 가장 가깝고 관계가 있다는 의미
  • 여기서 cluster_centers.shape를 찍어보면 (5, 164)라는 값이 나오는데 처음엔 5개 그룹과 164개의 문서의 비슷한 정도를 말하는 줄 알았다. 근데 그게 아니고 5는 클러스터 숫자를 의미하는 것은 맞는데, 뒤에 164가 의미하는 것은 feature 단어를 의미한다.
  • 정리하자면 5개의 각 클러스터 별로 164개의 feature 단어들을 뽑은 것임.
  • 그래서 아래에서 상위 5개의 feature 단어를 구하는 게 최대 164개까지 있는 거고 그중에서 1에 가까운 순서대로 나열해서 가장 앞쪽에 있는 5개의 특징 단어들을 추출한 것!
  • feature 단어를 뽑는 게 무슨 의미가 있나요?
    1. 일반적으로 머신러닝, 딥러닝을 수행할 땐 라벨링이 되어있는 상태에서 training 데이터와 test 데이터로 나눠서 잘 학습되었는지 정확도를 평가 지표로 삼는다. 그래서 정량적으로 수치를 낼 수 있는 것.
    2. 하지만 위의 데이터의 경우는 내가 직접 태그를 붙여서 라벨링을 해준 것도 아니고 비지도 학습이기 때문에 수치적으로 알 수가 없음. 그래서 사람이 판단하기로 feature 단어를 뽑아서 대충 이 단어들을 봤을 때 레시피의 내용을 짐작할 수 있게 하는 판단의 근거로 쓸 수 있음
    3. 그래서 feature 단어를 뽑고 그것을 기준으로 clustering이 잘 되었는지, 잘 안되었는지를 알 수가 있다.

 

 

 

 

Feature의 좌표를 이용한 클러스터 당 핵심 단어 추출


def get_cluster_details(cluster_model, cluster_data, feature_names,
                       cluster_num, top_n_features=10):
    cluster_details = {}
    
    # 각 클러스터 레이블별 feature들의 center값들 내림차순으로 정렬 후의 인덱스를 반환
    # cluster_model = kmeans로 사용할 것이므로 위에서 테스트 했던 대로 cluster_centers_를 이용하면 상대 좌표가 나옴
    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
        #print(cluster_details) #{0: {'cluster': 0}}
        
        # 각 feature별 center값들 정렬한 인덱스 중 상위 5개만 추출 top_n_features = 5
        # feature_names : feature 단어들을 추출한 것
        top_ftr_idx = center_feature_idx[cluster_num, :top_n_features] # 상위 5개 인덱스 번호
        top_ftr = [feature_names[idx] for idx in top_ftr_idx] # 상위 5개 feature 단어

        
        # top_ftr_idx를 활용해서 상위 5개 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 # 상위 5개 feature 단어
        cluster_details[cluster_num]['top_featrues_value'] = top_ftr_val # 상위 5개 중심으로부터 상대 좌표
        
        # 해당 cluster_num으로 분류된 메뉴명 넣어주기
        title_names = cluster_data[cluster_data['cluster_label']==cluster_num]['title'] 
        #df[df['cluster_label']==cluster_num]['title']
        
        # title_names가 df으로 반환되기 떄문에 값들만 출력해서 array->list로 변환
        title_names = title_names.values.tolist()
        cluster_details[cluster_num]['title_names'] = title_names

    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("상위 5개 feature단어들:\n", cluster_detail['top_features'])
        print()
        print(f"Cluster {cluster_num}으로 분류된 레시피들:\n{cluster_detail['title_names'][:5]}")
        print('-'*20)

feature_names = tfidf_vect.get_feature_names()
cluster_details = get_cluster_details(cluster_model=kmeans,
                                     cluster_data=df,
                                     feature_names=feature_names,
                                     cluster_num=30,
                                     top_n_features=5) #상위 10개 feature 추출하는게 디폴트이지만 5개만 추출
print_cluster_details(cluster_details)

 

 

함수 실행 결과 예시

 

 

 

 

코드 설명


 

  • center_feature_idx : 각 클러스터 별로 centroid 간의 상대 좌표를 내림차순으로 정렬한 것이다.
    • argsort()는 일반적인 sort() 함수와 달리, sorting을 한 후 값을 array로 재 정렬하는 것이 아닌 값의 인덱스 값을 array로 정렬한다.

center_feature_idx

 

  • top_ftr_idx : 위에서 인덱스로 array를 만든 center_feature에서 상위 5개의 인덱스를 가져오는 것.
    • 만약 클러스터 넘버가 0이라면, top_ftr_idx = center_feature_idx [0, :5] 이런 식으로 들어가서 0번째에 해당하는 [64 35 109 …. 107 111 117]이 array에서 상위 5개의 인덱스를 가져온다.

top_ftr_idx

 

  • top_ftr : feaure_names(feature 단어들)에서 상위 5개에 해당하는 인덱스를 대입해 그 인덱스에 해당하는 featrue 단어를 넣은 것

top_ftr

 

  • title_names : cluster_num에 따른 메뉴명(title)을 넣어준 데이터

title_names

 

  • cluster_details : get_cluster_details 함수의 리턴 값

cluster_details

  • print_cluster_details 함수에 들어가는 인풋이 된다. print_cluster_details의 for 문은 cluster_details.item()으로 받아와서 cluster_num와 cluster_detail을 사용한다.
  • { cluster_num : 0 , cluster_detail : {’cluster’ : 0 ……} }

 

 

 

그래서 클러스터링을 몇 번 하면서 적절한 클러스터 개수, 변수들을 조절해서 

n_clusters는 30, max_iter 최대 반복 횟수 1000, random_state 42로 두고 진행했다.

 

 

 

 

문서들 간의 유사도 측정


코드를 보기 전에 난 코사인 유사도를 이용하여 문서의 유사도를 구할 것이다.

 

코사인 유사도 (Cosine Similarity)

코사인 유사도는 두 벡터 간의 코사인 각도를 이용하여 구할 수 있는 두 벡터의 유사도를 의미한다. 두 벡터의 방향이 완전히 동일한 경우 1의 값을 가지며, 90도의 각을 이루면 0, 180도로 반대 방향을 가지면 -1의 값을 갖게 된다. 

결국 코사인 유사도는 -1 이상 1 이하의 값을 가지며 값이 1에 가까울수록 유사도가 높다고 판단할 수 있다.

 

아래의 두 사진을 보면 바로 이해가 될 것이다.

 

그런데 텍스트의 경우엔 단어들을 feature화 시킬 때 음수 값이 나올 수 없으므로 코사인 유사도가 -1이 나올 경우는 없다. 

따라서 단어 벡터들 간의 유사도는 0~1 사이의 값으로 나온다.

 

 

코드

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

choco_idx = df[df['cluster_label']==8].index
print("chocolate 카테고리로 클러스터링된 레시피들의 인덱스:\n", choco_idx)
print()

# 초콜렛 카테고리로 클러스터링 된 문서들의 인덱스 중 하나 선택해 비교 기준으로 삼을 문서 선정
compare_recipe = df.iloc[choco_idx[0]]['title']
print('##유사도 비교 기준 문서 이름: ', compare_recipe, '##')
print()

# 위에서 추출한 초콜렛 카테고리로 클러스터링된 문서들의 인덱스 중 0번 인덱스(비교기준문서) 제외한
# 다른 문서들과의 유사도 측정
similarity = cosine_similarity(ftr_vect[choco_idx[0]], ftr_vect[choco_idx])
print(similarity)
  • 그래도 얼추 잘 나눠진 것 같은 카테고리를 선택했고 cluster number는 8이었다.
  • 키워드는 chocolate
  • 비교할 기준이 있어야 하니깐 compare_recipe는 0번째 인덱스로 둔다.
  • 유사도 측정을 할 땐 sklearn에서 제공하는 cosine_similarity를 사용한다.
  • ftr_vect는 아래와 같이 위쪽 코드에서 전체 문서에 대해 feature vector를 생성했다.
    • ftr_vect = tfidf_vect.fit_transform(df['ingredients_list'])
    • ftr_vect[choco_idx[0]] 은 인덱스 19의 feature vector를 불러온다는 것
    • choco_idx 

choco_idx

 

즉 compare_recipe의 feature vector와 다른 나머지 레시피의 feature vector를 코사인 유사도를 이용해서 구한다는 말이다.

 

 

 

시각화 결과

유사도를 시각화하여 결과를 살펴보면 아래와 같다. (시각화 코드는 생략했다.)

결과 : ‘Nutty Chocolate Truffles’와 비교해 봤을 때 Gluten-free Brownies가 가장 유사도가 높은 것으로 보였다.

 

 

추가!

재료를 기준으로 유사도 검사를 해서 레시피를 추천하는 것과 조리 과정을 기준으로 하는 것의 결과 차이는 어떨까?

 

조리 과정으로 유사한 레시피 구하기

결과 : ‘Nutty Chocolate Truffles’와 비교해봤을 때 Chocolate Nests with Sea Salt Caramel Eggs 가 가장 유사도가 높은 것으로 보였다.

 

 (왼) ‘Nutty Chocolate Truffles’  (오) Chocolate Nests with Sea Salt Caramel Eggs

 

 

 

결론

재료가 비슷한 클러스터로 묶였기 때문에 조리 과정을 기준으로 유사도를 검사했을 땐, 재료의 유사성과 조리법의 유사도 둘 다 비슷한 레시피가 나오는 것 같다.

하지만 여기서 고민해 봐야 할 점은 레시피 추천 시스템을 만들기 위해서 과연 유저가 1) 비슷한 재료를 가지고 비슷한 음식을 만들기를 원할지, 또는 2) 비슷한 재료를 가지고 다른 종류의 음식을 만들기를 원할 것 인지이다.

추가로 현재 초콜릿 카테고리에 대해서만 실험해보았지만 다른 카테고리에서도 잘 분류를 하는지에 대한 추가 실험이 필요할 것 같다.

 

 

 

 

Ref

딥러닝을 이용한 자연어 처리 입문 - 코사인 유사도

 

728x90
반응형