본문 바로가기

AI/NLP Study

[NLP] BM25를 이용한 영화 추천

키워드 기반의 랭킹 알고리즘 BM25

BM25(a.k.a Okapi BM25)는 주어진 쿼리에 대해 문서와의 연관성을 평가하는 랭킹 함수로 사용되는 알고리즘으로, TF-IDF계열의 검색 알고리즘 중 SOTA인 것으로 알려져 있다. 

엘라스틱서치에서도 유사도 알고리즘으로 BM25 알고리즘을 채택하였다.

 

BM25 계산

BM25에서 점수를 계산할 때는 아래와 같은 식을 사용하여 계산한다.

키워드 $[q_1, q_2, \ldots, q_n]$을 포함하는 쿼리 $Q$가 주어질 때 문서 $D$에 대한 BM25 점수 계산을 다음과 같다.

$$\begin{aligned} \text{score} (D, Q) &= \sum_{i=1}^n \text{IDF}(q_i) * \frac{f(q_i, D)*(k_1 +1)}{f(q_i, D) + k_1*(1 - b + b*\frac{|D|}{avgdl})}\\ \text{IDF}(q_i) &= \ln(\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1)\end{aligned}$$

  • $f(q_i, D)$ : 문서 D에서 $q_i$가 몇 번 등장했는지에 대한 값(TF)
  • $|D|$ : 문서 D의 길이(토큰의 개수)
  • $avgdl$ : 전체 데이터에서 문서들의 평균 길이
  • $k_1, \; b$ : 파라미터. 보통 $k_1 \; \in [1.2, \; 2.0] \; \text{and} \; b = 0.75$
  • $N$ : 전체 문서의 개수
  • $n(q_i)$ : $q_i$를 포함하고 있는 문서의 개수

 

BM25 구현해 보기(직접)

import math

# IDF(qi)
def BM25_idf(data, token):
    N = len(data)
    n_qi = len([doc for doc in data if token in doc])

    return math.log(((N-n_qi+0.5) / (n_qi+0.5)) + 1)

# f(qi, D)
def BM25_tf(token, doc):
    qi_cnt = doc.count(token)
    return qi_cnt

# score(D, Q)
def BM25_score(data, doc, query, k1 = 1.2, b=0.75):
    doc_len = len(doc)
    avgdl = sum([len(doc) for doc in data]) / len(data)

    score = 0
    for q in query:
        idf_qi = BM25_idf(data, q)
        f_qi = BM25_tf(q, doc)
        score_qi = idf_qi * (f_qi *(k1+1)) / ((f_qi) + k1 * (1 - b + b *(doc_len/avgdl)))

        score += score_qi
    
    return score

# query가 주어질 때 해당 query와 데이터의 모든 문서에 대해서 bm25점수 계산
def get_bm25_score(query):
    scores = []
    for i in range(len(data)):
        bm25_score = BM25_score(data = data, doc = data['tokenized'][i], query = query)
        scores.append([i, bm25_score])

    return scores
 

영화 추천 기능을 위해 영화의 제목과 줄거리가 있는 데이터를 가져온다.

데이터는 여기서 movies_metadata.csv를 받아서 사용했다.

 

전처리는 영어 제외 전부 삭제, 소문자화, 띄어쓰기 단위로 토큰화 정도만을 진행했다.

import re
import pandas as pd
from nltk.corpus import stopwords

stop_words = stopwords.words('english')

def simple_preprocessing(text):
    text = text.strip()
    text = text.lower()
    text = re.sub(r'[^a-z]', ' ', text)
    text = text.split()
    return [word for word in text if word not in stop_words]


data = pd.read_csv('C:/Users/Myeong/dding/data/movies_metadata.csv', low_memory=False)
data = data[['title', 'overview']]
data.dropna(axis=0, inplace=True)
data.reset_index(inplace=True, drop=True)
data['tokenized'] = data['overview'].apply(lambda x : simple_preprocessing(x))
data

 

이제 영화의 제목을 입력하면 해당 영화의 줄거리와 가장 유사도가 높은 줄거리를 가진 영화 상위 10개를 리턴하는 함수를 정의하고, 몇 개의 영화 제목을 입력해서 나온 비슷한 영화 결과를 데이터프레임으로 뽑아서 확인해 준다.

def get_recommended_bm25(title):
    # 해당 타이틀 영화의 토큰화된 overview를 가져온다.
    tokenized = data[data['title']==title]['tokenized'].iloc[0]

    # bm25 점수를 계산해준다
    bm25_scores = get_bm25_score(tokenized)
 
    # 점수가 높은 순서부터 내림차순으로 정렬 해준다.
    sorted_scores = sorted(bm25_scores, reverse=True, key=lambda x : x[1])

    # 점수 상위 10개의 인덱스 뽑아줌
    top_10_idices = [idx[0] for idx in sorted_scores[1:11]]

    # 상위 10개의 영화 제목 리턴한다
    top_10_title = data['title'].iloc[top_10_idices]
    top_10_list = top_10_title.to_list()
    return top_10_list
import random
title_list = list(set(data['title'].to_list()))
title_list = random.sample(title_list, 4) + ['Toy Story']

recommended_list = []
for title in title_list:
    top_10_list = get_recommended_bm25(title)
    recommended_list.append(top_10_list)

df = pd.DataFrame(recommended_list, index=title_list)
df = df.transpose()
df

 

BM25 라이브러리 사용

직접 함수를 만들어서 사용하는 방법뿐 아니라 제공되는 라이브러리를 사용할 수도 있다.

나머지 과정은 똑같고 마지막에 get_recommended_bm25 함수 부분만 조금 차이가 있다.

from rank_bm25 import BM25Okapi

def get_recommended_bm25(title):
    # 해당 타이틀 영화의 토큰화된 overview를 가져온다.
    tokenized = data[data['title']==title]['tokenized'][0]

    # bm25 점수를 계산해준다
    bm25_scores = bm25.get_scores(tokenized)

    # 점수가 높은 순서부터 내림차순으로 정렬 해준다.
    # 인덱스도 같이 가져와야함
    bm25_scores = [score for score in enumerate(bm25_scores)]
    sorted_scores = sorted(bm25_scores, reverse=True, key=lambda x : x[1])

    # 점수 상위 10개의 인덱스 뽑아줌
    top_10_idices = [idx[0] for idx in sorted_scores[1:11]]

    # 상위 10개의 영화 제목 리턴한다
    return data['title'].iloc[top_10_idices]
    
get_recommended_bm25('Toy Story')

 

직접 구현해본 것과 라이브러리를 사용한 결과가 비슷하지만 조금 다른 것을 볼 수 있다.

이는 아마 라이브러리로 제공될 때 점수계산 방식의 디테일이 조금 차이가 있기 때문인 것 같다. 

'AI > NLP Study' 카테고리의 다른 글

[NLP] 임베딩 편향성  (0) 2022.10.02
[NLP] 워드 임베딩  (1) 2022.10.01
[NLP] 텍스트 카테고리 분류  (1) 2022.09.29
[NLP] 텍스트 벡터화  (0) 2022.09.27
[NLP] 단어사전 만들기  (1) 2022.09.25