[Python] TF-IDF와 BM25
TF-IDF와 BM25는 정보 검색 부분에서 많이 활용되는 부분으로, 지금도 많은 연구에서 활용되고 있고, 이를 기반으로 한 새로운 방법들이 많이 등장하고 있습니다.
TF-IDF
TF-IDF (Term Frequency-Inverse Document Frequency)는 단어의 빈도와 역 빈도를 사용해 문서-단어 행렬에서의 각 단어들마다 중요한 정도를 다르게 가중치로 주는 방법을 말합니다. TF-IDF는 문서-단어 행렬 (Document-Term Matrix)를 만든 다음, TF-IDF 가중치를 부여합니다.
TF-IDF는 주로 문서의 유사도를 구하거나, 검색 시스템에서 검색 결과의 중요도를 정하는 작업, 특정 단어의 중요도를 구하는 작업에서 많이 사용됩니다.
DTM : Document-Term Matrix
DTM은 다수의 문서에서 등장하는 각 단어들의 빈도를 행렬로 표현한 것을 말합니다. 주로
- 행(Row) : 문서
- 열(Column) : 단어 또는 n-gram 토큰
- 값(Value) : 그 문서에서 그 단어가 얼마나 나왔는지 빈도 혹은 TF-IDF 가중치를 주로 나타냅니다.
다음 4개의 문서가 있다고 가정하겠습니다.
- 문서1 : 먹고 싶은 사과
- 문서2 : 먹고 싶은 바나나
- 문서3 : 길고 노란 바나나 바나나
- 문서4 : 저는 과일이 좋아요
간단한 예시를 통해 표로 만든다면, 다음과 같은 형태로 완성됩니다. (빈도수 기준입니다.) 단어 순서(열): 과일이, 길고, 노란, 먹고, 바나나, 사과, 싶은, 저는, 좋아요
| 문서 | 과일이 | 길고 | 노란 | 먹고 | 바나나 | 사과 | 싶은 | 저는 | 좋아요 |
|---|---|---|---|---|---|---|---|---|---|
| 문서1 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 0 | 0 |
| 문서2 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 0 |
| 문서3 | 0 | 1 | 1 | 0 | 2 | 0 | 0 | 0 | 0 |
| 문서4 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
DTM을 코드로 구현하면 크게 다음과 같이 작성됩니다.
import pandas as pd
from collections import Counter
docs_text = {
"문서1": "먹고 싶은 사과",
"문서2": "먹고 싶은 바나나",
"문서3": "길고 노란 바나나 바나나",
"문서4": "저는 과일이 좋아요",
}
# 1) 공백 단위 토큰화
tokenized = {k: v.split() for k, v in docs_text.items()}
# 2) 전체 vocabulary 자동 생성 (정렬로 고정)
vocab = sorted({tok for toks in tokenized.values() for tok in toks})
# 3) DTM 채우기
dtm = pd.DataFrame(0, index=tokenized.keys(), columns=vocab, dtype=int)
for doc, toks in tokenized.items():
c = Counter(toks)
for term, cnt in c.items():
dtm.loc[doc, term] = cnt
print("=== DTM (auto vocab) ===")
print(dtm)
print("\ncolumns:", list(dtm.columns))
TF
TF는 Term Frequency의 줄임말입니다. 단순한 빈도의 개념으로서, 특정 단어가 그 문서 안에서 얼마나 자주 나오는지에 대한 지표를 말합니다.
\[\mathrm{tf}(t, d) = \mathrm{count}(t, d)\]다음과 같은 수식으로 표현되며, $t$는 특정 단어, $d$는 문서를 말합니다.
IDF
IDF (Inverse Document Frequency)는 DF의 반대 개념입니다. DF는 특정 단어 t가 등장한 문서의 수를 나타냅니다. DF의 식은 다음과 같습니다. \(\mathrm{df}(t) = |\{ d \mid t \in d \}|\)
반대로, IDF는 log와 분모에 1을 더해주는 식으로 역수를 취합니다. 여기서 log를 사용하는 이유는 총 문서의 수가 커질수록 IDF의 값이 기하급수적으로 커지기 때문입니다. 이를 방지하기 위해 log를 사용해줍니다. IDF의 식은 다음과 같습니다.
\[\mathrm{idf}(t) = \log\left(\frac{N+1}{\mathrm{df}(t)+1}\right) + 1\]여기서 $N$은 전체 문서 수를 말합니다.
위 3개 개념에 대한 예시 코드는 다음과 같습니다.
N = len(docs)
def tf(t, d):
return d.count(t)
def idf(t):
df = 0
for doc in docs:
df += t in doc
return log(N/(df+1))
def tfidf(t, d):
return tf(t,d)* idf(t)
이렇게 TF와 IDF 지수를 곱해 어떤 문서에서 자주 등장하고, 다른 문서들에서는 잘 나오지 않은 단어가 높은 점수를 받아 “그 문서의 특징적인 단어”가 됩니다. TF-IDF의 식은 다음과 같습니다.
\[\mathrm{tfidf}(t, d) = \mathrm{tf}(t, d)\times \mathrm{idf}(t)\]이 TF-IDF에 추가적으로 다른 옵션을 여러 개 사용합니다. 대표적으로는
- Stopwords : this, is과 같은 지나치게 자주 사용되는 단어는 제외해줍니다.
- Ngram : 단어 단위만이 아닌, Phrase(구) 형태의 빈도도 포함하는 경우도 있습니다.
- 범위 설정 : 지나치게 희귀하거나, 너무 흔한 단어는 포함하지 않는 경우도 있습니다.
- L2 정규화 : 문서 벡터 길이를 맞춰서 추후 코사인 유사도를 사용할 때 유리하도록 합니다.
특히, L2 정규화는 다음의 식을 사용합니다. \(\hat{\mathbf{x}}_d = \frac{\mathbf{x}_d}{\|\mathbf{x}_d\|_2}\)
여기서,
\(\|\mathbf{x}_d\|_2 = \sqrt{\sum_i x_{d,i}^2}\) 문서 $d$의 TF-IDF 벡터를 $\mathbf{x}_d$라고 하면, L2 정규화는 다음과 같이 수행합니다.
TF-IDF를 구하는 코드는 다음과 같습니다.
result = []
# 각 문서에 대해서 아래 연산을 반복 -> 이 과정으로 DTM이 출력되어, 이전의 DTM의 결과와 같아집니다.
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_ = pd.DataFrame(result, columns = vocab)
# IDF를 구하는 코드
result = []
for j in range(len(vocab)):
t = vocab[j]
result.append(idf(t))
# TF-IDF를 구하는 코드입니다.
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))
다음과 같이 구하지만, Scikit-learn 라이브러리를 활용해서 DTM과 TF-IDF를 쉽게 구현할 수 있습니다. 해당 코드는 다음과 같습니다.
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
'you know I want your love',
'I like you',
'what should I do ',
]
vector = CountVectorizer()
corpus_tfidf = [
'you know I want your love',
'I like you',
'what should I do ',
]
tfidfv = TfidfVectorizer().fit(corpus_tfidf)
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
docs = {
"문서1": "먹고 싶은 사과",
"문서2": "먹고 싶은 바나나",
"문서3": "길고 노란 바나나 바나나",
"문서4": "저는 과일이 좋아요",
}
terms = ["과일이", "길고", "노란", "먹고", "바나나", "사과", "싶은", "저는", "좋아요"]
doc_names = list(docs.keys())
texts = [docs[d] for d in doc_names]
# -------------------------
# 1) DTM (count)
# - vocabulary를 고정하면 열 순서가 고정됨
# -------------------------
cv = CountVectorizer(
vocabulary=terms, # 열 고정
tokenizer=str.split, # 띄어쓰기 토큰화
preprocessor=None,
token_pattern=None # tokenizer를 쓰면 token_pattern은 None으로
)
X_count = cv.transform(texts)
df_dtm = pd.DataFrame(X_count.toarray(), index=doc_names, columns=terms)
print("=== DTM (count) ===")
print(df_dtm)
# -------------------------
# 2) TF-IDF
# - smooth_idf=True: idf = log((N+1)/(df+1)) + 1
# - norm=None: raw TF-IDF
# - norm="l2": L2 정규화된 TF-IDF (기본값이기도 함)
# -------------------------
tv_raw = TfidfVectorizer(
vocabulary=terms,
tokenizer=str.split,
preprocessor=None,
token_pattern=None,
smooth_idf=True,
norm=None, # raw TF-IDF 원하면 None
use_idf=True
)
X_tfidf_raw = tv_raw.fit_transform(texts)
df_tfidf_raw = pd.DataFrame(X_tfidf_raw.toarray(), index=doc_names, columns=terms)
print("\n=== TF-IDF (raw) ===")
print(df_tfidf_raw.round(4))
tv_l2 = TfidfVectorizer(
vocabulary=terms,
tokenizer=str.split,
preprocessor=None,
token_pattern=None,
smooth_idf=True,
norm="l2", # L2 정규화
use_idf=True
)
X_tfidf_l2 = tv_l2.fit_transform(texts)
df_tfidf_l2 = pd.DataFrame(X_tfidf_l2.toarray(), index=doc_names, columns=terms)
print("\n=== TF-IDF (L2 normalized) ===")
print(df_tfidf_l2.round(4))
# (옵션) idf 값 확인
idf_values = tv_raw.idf_ # terms 순서와 동일
df_idf = pd.DataFrame({"term": terms, "idf": idf_values})
print("\n=== IDF (smooth) ===")
print(df_idf.round(4))
BM25
BM25 알고리즘은 정보 검색 분야에서 문서의 관련성을 평가하기 위해 사용되는 랭킹함수입니다. 이는 사용자의 검색 쿼리에 가장 잘 매치되는 문서를 찾아 순위를 매기는 것에 사용합니다. 앞에서 소개한 TF-IDF를 개선한 버전으로, 문서 내 특정 단어의 빈도수와 문서 집합 전체에서 그 단어가 얼마나 일반적인지를 고려하여 문서의 관련성을 계산합니다.
BM25는 TF-IDF에서 길이 보정이 포함되어 있습니다. 이 상황에서 BM25를 사용하면 좋을 때는 다음과 같습니다.
- Search/Retrieval에서 Top-k 랭킹을 뽑을 때
- 문서의 길이가 천차만별일 때
- 특정 키워드가 반복되는 데이터가 있을 때
키워드 매칭 기반으로 후보를 잘 뽑고 싶다가 목적인 경우 BM25는 좋은 선택지가 될 수 있습니다.
BM25의 식은 다음과 같이 정의합니다. \(\text{score}(d, q)=\sum_{t \in q} \text{IDF}(t)\cdot\frac{tf(t,d)\cdot (k_1+1)}{tf(t,d)+k_1\cdot\left(1-b+b\cdot\frac{|d|}{avgdl}\right)}\)
여기서 각 파라미터의 의미는
- $t$: 질의에 포함된 term(단어/토큰)
- $tf(t,d)$: 문서 $d$ 안에서 term $t$가 등장한 횟수(term frequency)
-
$ d $: 문서 $d$의 길이(토큰 수 등) - $avgdl$: 전체 문서 컬렉션에서의 평균 문서 길이(average document length)
- $k_1$: TF 포화(saturation) 정도를 조절하는 파라미터 (보통 1.2~2.0 근처)
- $b$: 길이 정규화 강도를 조절하는 파라미터 (0~1, 보통 0.75)
이 식에서 IDF를 사용할 때, 흔히 위의 식과는 조금 다르게
\[\text{IDF}(t)=\log\left(\frac{N-df(t)+0.5}{df(t)+0.5}\right)\]로 식을 사용합니다. 필요하면 이전처럼 log 정규화를 수행해주는 경우도 있습니다.
BM25를 사용해서 쿼리와 가장 유사한 Top-k 정보를 가져오는 코드는 다음과 같습니다.
from rank_bm25 import BM25Okapi
import re
# 토크나이저 코드 -> 상황에 따라 수정해서 사용하면 좋습니다.
def tok(s: str):
s = s.lower()
s = re.sub(r"[^0-9a-z\s]+", " ", s) # 기호 제거
s = re.sub(r"\s+", " ", s).strip()
return s.split()
# corpus 준비
corpus = [
"The Eiffel Tower is a landmark in Paris made of wrought iron.",
"Photosynthesis converts sunlight into chemical energy in plants.",
"A database index can speed up data retrieval in large tables.",
"The Great Barrier Reef is the largest coral reef system in Australia.",
"Inflation is a general increase in prices and a fall in purchasing power.",
]
# BM25 인덱스 만들기 (문서를 토큰 리스트로 변환)
tokenized_corpus = [tok(doc) for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
# 검색 쿼리
query = "speed up data retrieval using index"
tokenized_query = tok(query)
# 문서별 점수 계산
scores = bm25.get_scores(tokenized_query)
# Top-k 출력
top_k = 3
top_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
print("Query:", query, "\n")
for rank, i in enumerate(top_idx, start=1):
print(f"{rank}. score={scores[i]:.4f} | doc_id={i} | {corpus[i]}")
참조 : https://wikidocs.net/31698
Leave a comment