머신러닝&딥러닝/NLP

NLP - KeyBert 토픽 모델링

코코로코코 2023. 10. 9. 16:09
반응형

토픽 모델링 방법 중 하나인 KeyBERT에 대해서 이번에 포스팅해보겠습니다.

 

KeyBERT Github : https://github.com/MaartenGr/KeyBERT

 

GitHub - MaartenGr/KeyBERT: Minimal keyword extraction with BERT

Minimal keyword extraction with BERT. Contribute to MaartenGr/KeyBERT development by creating an account on GitHub.

github.com

 

0) 코사인 유사도(Cosin-Similarity)

KeyBERT를 이해하기에 앞서 코사인 유사도에 대한 개념을 먼저 알고 해당 기술을 이해해야 훨씬 잘 이해할 수 있습니다. 그렇기에 코사인 유사도에 대한 개념에 대해서 간단하게 짚고 넘어가보겠습니다.

 

코사인 유사도는 [-1<= cos() <= 1]의 값을 갖는 값으로 두 벡터간의 유사성을 확인하는데 사용됩니다. 벡터는 한쪽 방향을 가리키는데 두 벡터가 이 가리키는 방향이 얼마나 비슷한가를 척도로 나타낸 값이 코사인 유사도라고 생각하시면 됩니다. 1에 가까울 수록 서로 방향이 비슷하고 -1에 가까울수록 반대되는 방향을 가리키고 있는 벡터라고 볼 수 있습니다.

코사인 유사도

우리가 사용할 문서 역시 임베딩 과정을 거쳐서 하나의 벡터로 표현이 가능하게 됩니다. 그렇다면 문장은 하나의 벡터가 되고 두 개의 문장이 얼마나 비슷한지 확인하는 방법으로 이 코사인 유사도를 두 문장 벡터에 사용해볼 수 있겠죠. 이를 간단한 예시로 확인해보겠습니다. 우리는 위 공식을 사용할 필요 없이 간단하게 함수를 호출해서 코사인 유사도를 쉽게 구할 수 있습니다. 아래와 같은 문장 3개가 있고 이 문장들간의 코사인 유사도를 구해보겠습니다.

  • 한국어 영어 좋아요
  • 나는 한국어 좋아요
  • 한국어 공부 좋아요 영어 공부 좋아요
  나는 한국어 영어 좋아요 공부
문장1 0 1 1 1 0
문장2 1 1 0 1 0
문장3 0 1 1 2 2

 

위 표는 간단하게 카운트에 기반한 벡터를 구성하였습니다. 이제 이 벡터들을 통해서 코사인 유사도를 구해보겠습니다. 구현 방법은 아래 코드와 결과를 확인해보시면 됩니다. 결과를 확인해보면 코사인 유사도를 통해서 문서 1과 3이 가장 유사한 문장으로 판단할 수 있습니다. 우리가 직접 확인해보기에도 한국어와 영어를 좋아하는 문장1과 한국어 영어 공부를 좋아하는 문장3이 가장 유사한게 맞는 결과 같아 보입니다. 이러한 코사인 유사도를 통해서 이제 문서 안에서 가장 문서와 유사한 단어구를 찾아 대표적인 단어구를 찾는 KeyBERT를 알아보겠습니다.

import numpy as np
from numpy import dot
from numpy.linalg import norm

def cos_sim(A, B):
  return dot(A, B)/(norm(A)*norm(B))

doc1 = np.array([0,1,1,1,0])
doc2 = np.array([1,1,0,1,0])
doc3 = np.array([0,1,1,2,2])

print('문서 1과 문서2의 유사도 :',cos_sim(doc1, doc2))
print('문서 1과 문서3의 유사도 :',cos_sim(doc1, doc3))
print('문서 2와 문서3의 유사도 :',cos_sim(doc2, doc3))
문서 1과 문서2의 유사도 : 0.6666666666666667
문서 1과 문서3의 유사도 : 0.7302967433402214
문서 2와 문서3의 유사도 : 0.5477225575051661

 

1) KeyBERT란

KeyBERT는 BERT의 문장 임베딩을 활용하여 주어진 문서에서 문서를 구성하는 단어들 중 문서를 가장 잘 대표할 수 있는 토픽(주제) 단어 문구를 추출하는 기술입니다. KeyBERT의 대표 문구 추출 기법은 이해하기 어렵지 않습니다. 문서가 주어지면 해당 문서에 대한 임베딩 값을 갖고있고 이제 그 문서를 구성하고 있는 단어를 N-gram으로 분리하는 작업을 진행합니다. 여기서 N은 사용자의 선택에 따라 달리지는 하이퍼파라미터입니다. 이후 분리된 단어 문구를 문서 전체와 코사인 유사도를 구합니다. 그렇게 되면 가장 높은 코사인 유사도 값을 갖는 단어 문구가 문서를 가장 잘 대표할 수 있는 단어 문구가 된다는 기술입니다.

 

그런데 이렇게 진행하게 되면 문제점이 있습니다. 단순히 문서와 단어 뭉치간의 코사인 유사도를 구해서 상위 5개를 추출한다고하면 분명 5개의 단어 문구는 똑같지 않지만 분명 비슷한 5개의 문구가 추출 될 것입니다. 이를 방지하고자 사용하는 방법인 Max-Sum-Distance, Maximal Marginal Relevance 두 가지를 통해 단어 문구를 추출하는 방법이 있습니다. 기본적인 KeyBERT를 알아보고 유사 키워드를 제외한 키워드 추출 방법인 두 가지 방법에 대해서도 알아보겠습니다.

 

 

2) KeyBERT

우선 기본적인 KeyBERT를 구현해보겠습니다. 모듈을 불러와서 손쉽게 사용이 가능하지만 직접 KeyBERT 과정을 구현해보겠습니다.

 

이번 KeyBERT 실습에서는 나무위키에 있는 블루아카이브 한국 서비스 관련 문서를 예시 문서로 사용하였습니다. 우선 문서를 형태소 분석기를 통해서 명사들을 추출하고 이를 다시 하나의 문서로 만드는 작업을 진행하였습니다. 그 이유는 아래서 사용할 CounterVectorizer와 연관이 있습니다. CounterVectorizer는 띄어쓰기를 기준으로 작업이 이뤄지는데 이는 한국어에서는 지양하는 방법중 하나이므로 이전 작업에서 그에 맞는 형태로 바꿔주는 작업으로 진행합니다. 그게 명사의 형태로 문서를 재구성하여 N-gram을 생성하는 방법입니다. 여기서 형태소 분석기 선택은 본인의 데이터를 잘 분리시켜주는 형태소 분석기를 선택하면 됩니다. 저는 Mecab과 Okt두 가지를 사용하기는 했는데 코드에서는 Okt만을 사용해서 진행하였습니다.

 

!! KeyBERT모듈을 통한 구현도 같이 포스팅하지만 이 방법은 한국어에 맞는 방법이 아니기에 사용에 추천드리지 않습니다

import numpy as np
import itertools

from konlpy.tag import Okt, Mecab
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sentence_transformers import SentenceTransformer

doc = '''
문서 내용이 길어서 생략.
'''

# 형태소 분석기
okt = Okt()
tokenized_doc = okt.pos(doc)
tokenized_nonus = ' '.join([word[0] for word in tokenized_doc if word[1] == 'Noun'])

# Mecab 사용시
# mecab = Mecab
# tokenized_doc = mecab.nouns(doc)
# tokenized_nonus = ' '.join(tokenized_doc)

 

이제 명사들로만 구성된 문서를 CountVectorizer를 통해서 N-gram 인자들로 만들어줍니다. CounterVectorizer를 통하면 손 쉽게 원하는 N-gram 형태의 인자들을 얻을 수 있기 때문에 이를 사용합니다. 나눠진 5개의 단어구를 출력해보니 의미를 알 것 같기도 하고 아닌 것 같기도 한데 이는 구분된 단어구 중 앞에 위치한 5개의 단어구를 예시로 출력한 것입니다. 이제 이 단어구들 중에서 문서를 가장 잘 대표할 수 있는 단어구를 찾아보겠습니다.

count = CountVectorizer(ngram_range= (3,3)).fit([tokenized_nonus])
candidates = count.get_feature_names_out()

print('trigram cnt : ', len(candidates))
print('trigram examples 5 : ', candidates[:5])
trigram cnt :  550
trigram examples 5 :  ['가세 하루 사전' '가지 일화 선물' '가챠 중복 레프' '간격 주치 일정' '간다 구글 플레이스토어']

 

SBERT를 사용한 모델을 불러와 사용합니다. 여기서 모델을 선택할때는 한국어를 포함한 모델을 선택해주어야 합니다. 아무 모델만 불러와서 사용하면 원하는 결과를 얻지 못합니다. 이후 모델을 통해서 문서와 후보 단어구들을 인코딩해줍니다. 그리고 문서와 단어간 코사인 유사도를 계산하여 줍니다. 그 결과는 아래 코드에서는 distances안에 들어가 있고 이제 이 리스트를 정렬하여 원하는 5개의 단어구를 가져옵니다. 

 

결과를 보면 블루 아카이브의 한국 서비스 관련 요약 문서는 일본과 관련된 이야기가 많은 것으로 보입니다. 그런데 5개의 키워드에 공통적으로 등장하는게 일본입니다. 좀 더 다양한 단어구를 보기 원할 수 있기 떄문에 나온 방법이 위에서 언급한 두 가지 방법입니다. 이를 이 부분에 하나씩 적용하는 과정을 진행해보겠습니다.

model = SentenceTransformer('sentence-transformers/xlm-r-100langs-bert-base-nli-stsb-mean-tokens')

doc_embedding = model.encode([doc])
candidate_embeddings = model.encode(candidates)

top_n = 5
distances = cosine_similarity(doc_embedding, candidate_embeddings)
keywords = [candidates[index] for index in distances.argsort()[0][-top_n:]]
print(keywords)
['서버 일본 접속', '수용 서버 일본', '서버 이용 일본인', '일본 먼저 한국', '튜토리얼 스킵 일본']

 

KeyBERT 모듈을 통한 구현

앞서 직접 구현한 곳에서는 명사를 기준으로 진행했기 때문에 명사로 구분된 3개의 단어로 나왔지만 모듈을 통한 결과는 문서에서 사용된 문구 그대로를 보여주고 있습니다. 두 개의 결과가 다른 이유는 KeyBERT 자체가 한국어에 맞춤형 모듈이 아니기 때문입니다. 앞서 이야기 했듯 띄어쓰기를 기준으로 하나의 단어구를 생성하게 되는데 이는 한국어에서는 지양하는 방법이기 때문에 위에서 처럼 명사로만 구성된 단어구를 구성하여 코사인 유사도를 구하는게 더 좋은 방법입니다. 가장 밑에 있는 코드는 원래의 문서에서 하이라이트를 적용하여 문서를 출력해주는 기능입니다. 원문이 길어서 결과는 생략하겠습니다.

from keybert import KeyBERT

model = SentenceTransformer('sentence-transformers/xlm-r-100langs-bert-base-nli-stsb-mean-tokens')
kw_model = KeyBERT(model=model)

kw_model.extract_keywords(doc, keyphrase_ngram_range=(3, 3), stop_words=None

# 결과
[('서비스 개시한 일본서버에', 0.5418),
 ('내수용 서버는 일본에서', 0.5353),
 ('서버는 일본에서 접속을', 0.5251),
 ('일본으로 여행가면서 플레이', 0.5208),
 ('일본에서 실행하면 점검', 0.4865)]
# 원문에서 하이라이트 하여 출력
keywords = kw_model.extract_keywords(doc, highlight=True)

 

3) Max-Sum-Distance

최대 합 거리(Max-Sum-Distance)는 쉽게 후보군 간의 유사성은 최소화 하면서 문서와 후보군의 유사성을 최대화하는 기법입니다. 이를 위해서 위에서 진행한 문서와 후보군간의 코사인 유사도를 구할 뿐 아니라 후보군끼리의 코사인 유사도를 구해서 그 후부군 간의 코사인 유사도를 구해서 그 안에서 상위 n개의 단어구를 추출하는 방법입니다.

 

최대 합 거리에 대한 과정을 천천히 따라가보겠습니다. 우선 이전과 같이 문서와 후보군간의 코사인 유사도 계산합니다. 이후 이번에 확인할 후보군간의 코사인 유사도를 구합니다. 이제 이 두개를 가지고 최대 합 거리를 통한 최종 후보군을 추출합니다. 그러기 위해서 우선 문서와 후보군 중에서 최대 합 거리를 계산할 후보군들의 인덱스를 먼저 추출합니다. 그게 매개변수로 받는 nr_candidates입니다. 그렇게 추출한 인덱스를 통해 최종 결과에 들어갈 수 있는 단어군 집합임 words_vals에 저장하여 따로 관리를 해줍니다. 이후에는 선정된 후보군간의 거리를 계산합니다. numpy의 ix_는 들어온 인자들의 교차 곱을 실행합니다. np.ix_([1,2],[1,2])이렇게 들어왔다고 하면 [1,1],[1,2][2,1][2,2]의 결과를 보여줍니다. 이를 통해서 최대 합 거리를 구할 후보군간의 코사인 유사도를 빠르게 구할 수 있습니다. 이후에는 후보군 간의 조합을 만들어주는데 중복되지 않는 조합을 만들어주는 itertools.combinations를 사용하여 조합중 가장 유사도가 적은 후보군 그룹을 선정하고 이를 최종 결과로 반환해주는 작업을 해줍니다. 이렇게하면 문서와 가장 유사도가 높은 n개의 단어를 선정해서 n개의 단어들 중에서 가장 유사도가 낮은 m개의 그룹군으로 결과를 보내주는 작업이 되는 것입니다. 이게 최대 합 거리 방식을 통해서 너무 유사한 키워드가 나오지 않게 하는 방법입니다. 첫 비교 그룹군을 많이 선정할 수록 다양한 키워드 그룹군이 나와 보다 다양한 단어구를 결과로 받아 볼 수 있게 됩니다.

def max_sum_sim(doc_embedding, candidate_embeddings, words, top_n, nr_candidates):

    # 문서와 각 키워드들 간의 유사도
    distances = cosine_similarity(doc_embedding, candidate_embeddings)

    # 각 키워드들 간의 유사도
    distances_candidates = cosine_similarity(candidate_embeddings, candidate_embeddings)

    words_idx = list(distances.argsort()[0][-nr_candidates:])
    words_vals = [candidates[index] for index in words_idx]
    distances_candidates = distances_candidates[np.ix_(words_idx, words_idx)]

    # 각 키워들 중에서 가장 덜 유사한 키워드들간의 조합을 계산
    min_sim = np.inf
    candidate = None
    for combination in itertools.combinations(range(len(words_idx)), top_n):
        sim = sum([distances_candidates[i][j] for i in combination for j in combination if i != j])

        if sim < min_sim:
            candidate = combination
            min_sim = sim

    return [words_vals[idx] for idx in candidate]
max_sum_sim(doc_embedding, candidate_embeddings, candidates, top_n = 5, nr_candidates=30)

# 결과
['개시 서버 도입', '일본인 일본 여행가', '본래 일본 선공', '한국 서버 리세', '학원 표기 일본']

 

KeyBERT 모듈을 통한 구현

kw_model.extract_keywords(doc, keyphrase_ngram_range=(3, 3), stop_words=None, use_maxsum=True, nr_candidates=20, top_n=5)

# 결과
[('정식공개 이후 앱스토어', 0.4208),
 ('일본서버에 도입된 편의성', 0.4263),
 ('37 실제로 일본에서', 0.4322),
 ('한국 서버 공식', 0.4663),
 ('학원으로 표기하는 일본식', 0.4801)]

 

 

4) Maxium Marginal Relevance(MMR)

MMR은 결과의 중복을 최소화하여 결과의 다양성을 확보하는데 목표가 있습니다. 그래서 매개변수로 다양성(diversity)를 받는데 이 수치가 높을수록 보다 다양한 단어구를 받아 볼 수 있습니다. MMR의 방법은 가장 유사성이 높은 단어구를 먼저 추출하고 그 이후 해당 단어구를 기준으로 나머지 후보군들의 코사인 유사도에서 다양성을 측정하여 가장 거리가 먼 후보군을 하나씩 추출하는 과정을 거쳐 원하는 n개의 단어구를 추출하는 방법입니다.

 

아래 결과를 보면 다양성의 수치가 낮을수록 그냥 코사인 유사도를 통해 추출한 상위 단어구 목록과 비슷하지만 다양성의 수치를 높이면 다양한 단어구들이 결과로 나옴을 확인할 수 있습니다.

def mmr(doc_embedding, candidate_embeddings, words, top_n, diversity):

    # 문서와 각 키워드들 간의 유사도 리스트
    word_doc_similarity = cosine_similarity(candidate_embeddings, doc_embedding)

    # 키워간 유사도
    word_similarity = cosine_similarity(candidate_embeddings)

    # 문서와 가장 유사도가 높은 인덱스 추출
    keywords_idx = [np.argmax(word_doc_similarity)]

    # 가장 높은 유사도 인덱스를 제외한 idx리스트
    candidates_idx = [i for i in range(len(words)) if i != keywords_idx[0]]

    for _ in range(top_n-1):
        candidate_similarites = word_doc_similarity[candidates_idx, :]
        target_similarities = np.max(word_similarity[candidates_idx][:,keywords_idx], axis=1)

        mmr = (1-diversity) * candidate_similarites - diversity * target_similarities.reshape(-1,1)
        mmr_idx = candidates_idx[np.argmax(mmr)]

        keywords_idx.append(mmr_idx)
        candidates_idx.remove(mmr_idx)

    return [words[idx] for idx in keywords_idx]
mmr(doc_embedding, candidate_embeddings, candidates, top_n=5, diversity=0.2)

# 결과
['튜토리얼 스킵 일본', '일본 먼저 한국', '서버 이용 일본인', '초기 점검 일본', '실제 일본 실행']


mmr(doc_embedding, candidate_embeddings, candidates, top_n=5, diversity=0.7)

# 결과
['튜토리얼 스킵 일본', '더욱 인기 생방송', '이외 중국 베트남', '공공기관 게임 관리', '레고 광고 영상']

 

KeyBERT 모듈을 통한 구현

kw_model.extract_keywords(doc, keyphrase_ngram_range=(3, 3), stop_words=None, use_mmr=True, diversity=0.7)

# 결과
[('서비스 개시한 일본서버에', 0.5418),
 ('만든 웹형식 스토리', 0.1412),
 ('1개월 후인 축제대소동', 0.1368),
 ('태국의 커피 프랜차이즈', 0.1332),
 ('기자들을 상대로 합동', 0.0598)]
반응형