1. TF-IDF?
정의
- 단어의 빈도(TF)와 역문서빈도(IDF)를 곱해서, 문서 내 각 단어의 "중요도"를 가중치로 매기는 방법
- 단순히 "몇 번 나왔는가"만 세는 빈도수 카운트(Bag of Words)의 한계를 보완
쓰임
- 문서 간 유사도 계산
- 검색 결과 랭킹(중요한 단어가 많이 매칭될수록 상위 노출)
- 특정 문서 안에서 핵심 키워드 추출
2. 왜 필요한가? - 단순 빈도수의 한계
컴퓨터는 "배송 문의 요청"이라는 문자열을 그 자체로 이해하지 못함 → 비교·계산을 하려면 숫자(벡터)로 바꿔야 함.
가장 단순한 방법: 단어가 몇 번 나왔는지 세는 것(Bag of Words)
- 문제: "문의", "요청" 같이 거의 모든 문서에 등장하는 흔한 단어가 카운트를 지배
- 진짜 그 문서를 구별짓는 단어("환불", "파손", "교환")는 빈도수만으로는 묻혀버림
TF-IDF의 해법: 흔한 단어는 점수를 깎고, 흔치 않은(=특징적인) 단어는 점수를 올린다.
3. 계산
수식
$$\text{TF-IDF}(t,d) = \text{TF}(t,d) \times \text{IDF}(t)$$
TF (Term Frequency, 단어빈도)
$$\text{TF}(t,d) = \frac{f_{t,d}}{\sum_k f_{k,d}}$$
- 문서 $d$ 안에서 단어 $t$가 등장한 횟수를, 그 문서의 전체 단어 수로 나눈 값
- 문서 길이에 관계없이 비율로 비교 가능하게 정규화
IDF (Inverse Document Frequency, 역문서빈도)
$$\text{IDF}(t) = \log\frac{N}{1+|\{d : t \in d\}|}$$
- $N$ = 전체 문서 수
- 분모 = 단어 $t$가 등장한 문서의 개수(+1은 분모가 0이 되는 것 방지)
- 모든 문서에 등장하는 단어 → 분모가 $N$에 가까워짐 → $\log(1)\approx0$ → 가중치 0에 수렴
- 일부 문서에만 등장하는 단어 → IDF 값이 커짐 → 가중치 큼
직관: "흔할수록 정보량이 적다"는 정보이론의 기본 원리. 모든 문서에 "이/그/것"이 나온다면 그 단어는 문서를 구별하는 데 도움이 안 됨(정보량 0). 반면 "환불"이 일부 문서에만 나온다면 "이 문서가 무엇에 관한 것인지" 알려주는 정보량이 큼.
왜 하필 로그(log)인가?
단순히 "흔할수록 깎는다"는 거라면 $1/\text{DF}$ 같은 역수를 써도 될 텐데, 굳이 로그를 쓰는 이유가 있다.
단어 $t$가 무작위로 고른 문서에 등장할 확률을 $P(t) \approx \text{DF}(t)/N$로 보면, 섀넌(Shannon)의 정보량 공식은:
$$I(t) = -\log P(t) = \log\frac{N}{\text{DF}(t)}$$
이게 정확히 IDF 공식의 형태다.
정보이론에서 "흔한 사건일수록(발생확률 $P$가 클수록) 그 사건이 일어났다는 소식의 정보량이 적다"는 원리를 그대로 가져온 것.
동전을 던져서 "앞면이 나왔다"는 소식보다 "주사위를 던져서 6이 나왔다"는 소식이 더 "놀랍고" 정보량이 많은 것과 같은 이치다.
$1/\text{DF}$ 같은 역수를 쓰면 희귀한 단어의 가중치가 지나치게 폭주하는데, 로그를 쓰면 그 폭주를 완만하게 눌러주는 효과도 있다.

예제
예제 문서 4개 (쇼핑몰 CS 문의 제목 같은 짧은 텍스트라고 가정)
문서1: 배송 문의 요청
문서2: 교환 문의 요청
문서3: 배송 환불 지연 파손 요청
문서4: 포장 문의
TF (단어 등장 횟수)
| 배송 | 문의 | 요청 | 교환 | 환불 | 지연 | 파손 | 포장 | |
|---|---|---|---|---|---|---|---|---|
| 문서1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
| 문서2 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| 문서3 | 1 | 0 | 1 | 0 | 1 | 1 | 1 | 0 |
| 문서4 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
IDF (자연로그 ln 사용, $N=4$)
| 단어 | DF(등장 문서 수) | IDF = ln(4/(1+DF)) |
|---|---|---|
| 배송 | 2 | ln(4/3) = 0.2877 |
| 문의 | 3 | ln(4/4) = 0 |
| 요청 | 3 | ln(4/4) = 0 |
| 교환 | 1 | ln(4/2) = 0.6931 |
| 환불 | 1 | ln(4/2) = 0.6931 |
| 지연 | 1 | ln(4/2) = 0.6931 |
| 파손 | 1 | ln(4/2) = 0.6931 |
| 포장 | 1 | ln(4/2) = 0.6931 |
여기서 흥미로운 지점: "문의"와 "요청"은 4개 문서 중 3개에 등장해서 IDF가 0이 되어버림.
거의 모든 CS 티켓에 박혀 나오는 정형화된 단어라서, "이 문서가 어떤 종류인지" 구별하는 데 전혀 도움이 안 된다고 통계적으로 판정된 것이다.
반대로 "환불", "파손", "교환" 같은 단어는 일부 문서에만 등장하므로 IDF가 높게 유지된다 — 사람이 "이 단어가 중요하다"고 지정하지 않아도, 통계만으로 그 단어들을 자동으로 가려낸 것이다.
TF-IDF (TF × IDF)
| 배송 | 문의 | 요청 | 교환 | 환불 | 지연 | 파손 | 포장 | |
|---|---|---|---|---|---|---|---|---|
| 문서1 | 0.2877 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 문서2 | 0 | 0 | 0 | 0.6931 | 0 | 0 | 0 | 0 |
| 문서3 | 0.2877 | 0 | 0 | 0 | 0.6931 | 0.6931 | 0.6931 | 0 |
| 문서4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0.6931 |
"문의"와 "요청"은 모든 문서에서 가중치 0이 되어 사실상 사라졌다.
각 문서를 실제로 구별짓는 단어들만 0이 아닌 값으로 남는다.

4. 구현
import pandas as pd
from math import log
docs = [
'배송 문의 요청',
'교환 문의 요청',
'배송 환불 지연 파손 요청',
'포장 문의',
]
vocab = sorted(set(w for doc in docs for w in doc.split()))
N = len(docs)
def tf(t, d):
return d.split().count(t)
def idf(t):
df = sum(t in doc.split() for doc in docs)
return log(N / (df + 1))
def tfidf(t, d):
return tf(t, d) * idf(t)
result = [[tfidf(t, d) for t in vocab] for d in docs]
pd.DataFrame(result, columns=vocab)
* 직접 계산한 표와 정확히 같은 결과.
scikit-learn
from sklearn.feature_extraction.text import TfidfVectorizer
corpus = [
'배송 문의 요청',
'교환 문의 요청',
'배송 환불 지연 파손 요청',
'포장 문의',
]
tfidfv = TfidfVectorizer().fit(corpus)
print(tfidfv.transform(corpus).toarray())
print(tfidfv.vocabulary_)
손으로 구한 값과 미묘하게 다른 숫자가 나오는데, scikit-learn의 기본 구현이 위 기본 공식에서 두 가지를 조정하기 때문이다:
- IDF 분자에도 +1: $\text{IDF}(t)=\ln\frac{1+N}{1+\text{DF}(t)}+1$ — 어떤 단어의 IDF도 정확히 0이 되지 않도록 함(가중치가 완전히 사라지는 것을 방지)
- L2 정규화: 각 문서 벡터의 길이(norm)를 1로 맞춤 — 문서 길이가 달라도 벡터끼리 공정하게 비교 가능
의도(흔한 단어는 깎고 특징적인 단어는 올림)는 동일하지만, 실전에서는 0이 되는 가중치를 막고 벡터 비교를 안정시키기 위한 보정이 들어간다.
5. sublinear_tf : 빈도 폭주 막기
원래 TF는 등장 횟수에 선형 비례한다. 그런데 한 문서에 어떤 단어가 100번 나온다고 해서 1번 나온 것보다 100배 더 "중요"한 건 아니다.
$$
\text{TF}{\text{sublinear}}(t,d) = 1 + \log(f{t,d})
$$
로그를 씌워서 빈도가 늘어날수록 증가폭이 완만해지게 만든다(체감효과). TfidfVectorizer(sublinear_tf=True)로 켤 수 있다.
6. n-gram: 단어를 어떤 단위로 자를 것인가
- word n-gram (ngram_range=(1,2)): 단어 1개 또는 연속 2개를 하나의 단위로. "환불"뿐 아니라 "환불 요청"도 하나의 토큰으로 봄 → 단어 조합의 의미를 일부 포착
- char n-gram (analyzer="char_wb", ngram_range=(2,4)): 글자 2~4개 단위로 자름. "환불" → ["환불", "환", "불"] 식으로 부분 글자 조각까지 토큰화. 오타·줄임말·신조어에 강함 — "환불요청"과 "환불 요청해요"는 띄어쓰기나 어미가 달라 단어 단위로는 다른 토큰이 되기 쉽지만, 글자 단위로는 "환불"이라는 공통 조각을 공유해서 어느 정도 유사하게 잡힘
실전에서는 두 가지를 같이 쓰고 가중치를 다르게 줘서 합치는 경우가 많다.
7. 실전에서 여러 특징을 합칠 때: 가중치 결합
실무에서는 word TF-IDF 하나만 쓰기보다, word TF-IDF + char TF-IDF + 구조적 범주형 변수(카테고리 등)를 합쳐서 하나의 특징 행렬로 쓰는 경우가 많다.
e.g.) 텍스트 분류·클러스터링 시스템
word_vec = TfidfVectorizer(
analyzer="word", ngram_range=(1, 2),
min_df=2, max_features=4000, sublinear_tf=True,
)
char_vec = TfidfVectorizer(
analyzer="char_wb", ngram_range=(2, 4),
min_df=3, max_features=8000, sublinear_tf=True,
)
세 블록을 합칠 때는 각 블록의 중요도를 가중치로 조절해서 가로로 이어붙인다(`scipy.sparse.hstack`):
$$X = w_{\text{word}} X_{\text{word}} \;\Vert\; w_{\text{char}} X_{\text{char}} \;\Vert\; w_{\text{struct}} X_{\text{struct}}$$
자연어 텍스트(word/char)에 더 높은 가중치를 주고, 구조적 변수(카테고리 코드 등)는 보조 신호로만 낮은 가중치를 주는 식이 일반적.
예를 들어 $w_{\text{word}}=1.0$, $w_{\text{char}}=0.7$, $w_{\text{struct}}=0.35$ 같은 비율. 텍스트가 주된 신호를 만들고, 구조적 변수는 그걸 살짝 보정하는 역할만 하게 하려는 의도다.
결과물은 문서 수 × 단어 종류 수 크기의 행렬인데, 한 문서에 모든 단어가 다 등장할 리는 없으니 대부분이 0이다(희소 행렬, sparse matrix). 0을 일일이 저장하면 메모리 낭비가 심해서, `scipy.sparse`로 "0이 아닌 값의 위치와 값만" 저장한다.
8. TF-IDF는 머신러닝인가?
엄밀히 말하면 아니다.
머신러닝이라 부르려면 보통 "명시적인 목적함수를 데이터에 맞춰 최적화하는 과정"이 있어야 하는데,
TF-IDF는 말뭉치에서 단어 등장 횟수를 세서 위 공식에 대입하는 것뿐이다.
최소화할 목적함수도, 반복적으로 수렴시키는 과정도 없다.
비교하면:
- KMeans: WCSS라는 목적함수를 반복적으로 최소화 → 명백한 학습
- SVD: 재구성 오차를 최소화하는 최적해를 구함(닫힌 형태긴 해도 명시적 최적화) → 학습이라 부를 만함
- TF-IDF: 빈도 통계를 공식에 대입 → 최적화 과정 없음
정확히는 통계적 특징공학(feature engineering), 또는 전통적 정보검색(IR) 기법으로 분류하는 게 맞다.
다만 scikit-learn에서 `fit()`/`transform()`이라는 ML 모델과 똑같은 API로 제공되고(`fit()`이 말뭉치의 단어별 문서빈도를 계산해두고, `transform()`이 그 값을 새 문서에 재사용),
결과가 데이터(말뭉치)에 따라 달라지며, 실전에서 거의 항상 ML 파이프라인의 전처리 단계로 쓰이다 보니 "고전적 ML"의 일부처럼 취급되는 경우가 많다.
즉, 정확한 표현이라기보다는 실용적인 편의상 분류에 가깝다.
9. 정리
- TF-IDF = TF(단어빈도) × IDF(역문서빈도)
- 흔한 단어는 가중치를 깎고, 희귀한(특징적인) 단어는 가중치를 올림
- IDF가 0이 되는 단어는 "모든 문서에 다 나오는, 구별력 없는 단어"라는 뜻
- 실전에서는 sublinear_tf(빈도 폭주 완화), word/char n-gram 조합, 가중치 결합 등으로 보강해서 사용
- 엄밀히는 머신러닝이 아니라 통계적 특징공학이지만, ML 파이프라인의 표준 전처리 단계로 취급됨.
TF-IDF로 텍스트를 벡터로 바꿨다면, 그다음 질문은 "두 벡터가 얼마나 비슷한가?"다.
이게 벡터 유사도(코사인 유사도)의 역할이다.
TF-IDF는 "텍스트→숫자" 변환이고, 코사인 유사도는 "숫자→숫자 사이 거리" 계산이라는 점에서 서로 다른 단계임을 구분해두면 헷갈리지 않는다.
GitHub 댓글