단어 가방(Bag of Words), TF-IDF와 텍스트 표현

먼저 세고, 나중에 생각합니다. TF-IDF는 2026년에도 잘 정의된 과제(task)에서는 임베딩(embedding)을 이깁니다.

유형: Build 언어: Python 선수 조건: Phase 5 · 01(Text Processing), Phase 2 · 02(Linear Regression from Scratch) 소요 시간: 약 75분

학습 목표

  • 가변 길이(variable-length) 토큰 스트림(token stream)을 분류기(classifier)가 받을 수 있는 고정 길이(fixed-size) 벡터(vector)로 바꾸는 이유를 설명합니다.
  • 단어 가방(Bag of Words; BoW)과 TF-IDF를 처음부터 구현합니다.
  • 용어 빈도(Term Frequency; TF), 문서 빈도(Document Frequency; DF), 역문서 빈도(Inverse Document Frequency; IDF), L2 정규화(L2 normalization)가 각각 어떤 일을 하는지 설명합니다.
  • scikit-learn의 CountVectorizer, TfidfVectorizer 주요 인자(argument)를 이해합니다.
  • TF-IDF가 여전히 유리한 상황과, 의미 맹점(semantic blindness) 때문에 실패하는 상황을 구분합니다.

문제

모델은 숫자가 필요합니다. 우리에게 있는 것은 문자열뿐입니다.

모든 자연어 처리(NLP) 파이프라인(pipeline)은 같은 질문에 답해야 합니다. 가변 길이 토큰 스트림을 분류기가 소비할 수 있는 고정 길이 벡터로 어떻게 바꿀 것인가. 이 분야가 가장 먼저 도달한 답은 "동작하는 가장 단순한 방식"이었습니다. 단어를 세고, 벡터를 만든다는 것이 그것입니다.

이 벡터는 그 어떤 임베딩 모델보다도 더 많은 실서비스(production) NLP를 떠받쳐 왔습니다. 스팸 필터(spam filter), 주제 분류기(topic classifier), 로그 이상 탐지(log anomaly detection), BM25 이전의 검색 순위(search ranking), 감성 분석(sentiment analysis)의 1세대, 학계 NLP 벤치마크(benchmark)의 첫 10년이 모두 여기에 기대 있습니다. 2026년의 실무자(practitioner) 역시 좁은 분류 과제에서는 가장 먼저 이 방식을 꺼내 듭니다. 빠르고, 해석 가능하며(interpretable), 단어의 존재 여부(word presence)가 핵심인 과제에서는 4억(400M) 파라미터(parameter) 임베딩 모델과 거의 구분되지 않는 성능을 보일 때도 많습니다.

이 강의에서는 단어 가방과 TF-IDF를 처음부터 만들어 보고, scikit-learn이 같은 일을 단 세 줄로 처리하는 모습을 함께 살펴봅니다. 그 뒤 언제 임베딩으로 넘어가야 하는지를 만드는 실패 양상(failure mode)도 짚습니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.NLP 분류기(classifier)가 학습 전에 가변 길이(variable-length) 토큰 스트림(token stream)을 고정 길이(fixed-size) 벡터(vector)로 변환해야 하는 이유는 무엇인가요?

2.TF-IDF에서 말뭉치(corpus)의 모든 문서에 등장하는 단어는 IDF 값이 매우 낮습니다. 분류에서 이것의 실질적인 결과는 무엇인가요?

0/2 답변 완료

개념

BoW와 TF-IDF 표현 흐름(BoW vs TF-IDF representation flow)

단어 가방(Bag of Words; BoW) 은 단어 순서(order)를 버립니다. 각 문서(document)마다 어휘(vocabulary)에 있는 단어가 몇 번 등장하는지를 셉니다. 벡터 길이는 어휘 크기(vocabulary size)와 같고, 위치 i의 값은 단어 i의 등장 횟수(count)입니다.

TF-IDF 는 BoW에 가중치(weight)를 다시 부여합니다. 모든 문서에 두루 등장하는 단어는 정보량이 적으므로 가중치를 줄입니다. 말뭉치(corpus) 전체에서는 드물지만(rare) 특정 문서에서는 자주 등장하는 단어는 신호(signal)이므로 가중치를 키웁니다.

TF-IDF(w, d) = TF(w, d) * IDF(w)
             = count(w in d) / |d| * log(N / df(w))

여기서 TF는 문서 안에서의 용어 빈도, df는 문서 빈도(즉 해당 단어를 포함하는 문서의 수), N은 전체 문서 수입니다. log는 어디에나 등장하는(ubiquitous) 단어의 가중치를 유한한 범위로 묶어 줍니다.

핵심 성질은 둘 다 해석 가능한 축(axis)을 가진 희소 벡터(sparse vector)를 만든다는 점입니다. 학습된 분류기의 가중치를 들여다보면 어떤 단어가 문서를 어느 클래스(class) 쪽으로 밀어주는지 곧바로 읽을 수 있습니다. 768차원짜리 BERT 임베딩에서는 이렇게 직관적으로 읽어 내기 어렵습니다.

만들어 보기

Step 1: 어휘 만들기

def build_vocab(docs):
    vocab = {}
    for doc in docs:
        for token in doc:
            if token not in vocab:
                vocab[token] = len(vocab)
    return vocab

입력은 토큰화된 문서 리스트입니다. 어떤 단어 단위 토크나이저(word-level tokenizer)를 써도 무방하며, 이 강의의 code/main.py는 단순한 소문자(lowercase) 변형을 사용합니다. 출력은 {word: index} 형태의 딕셔너리(dict)입니다. 파이썬 딕셔너리의 안정적인 삽입 순서(stable insertion order) 덕분에 인덱스 0은 첫 문서에서 가장 먼저 등장한 단어가 됩니다. 관례는 구현마다 다른데, scikit-learn은 알파벳 순서(alphabetically)로 정렬합니다.

Step 2: 단어 가방

def bag_of_words(docs, vocab):
    matrix = [[0] * len(vocab) for _ in docs]
    for i, doc in enumerate(docs):
        for token in doc:
            if token in vocab:
                matrix[i][vocab[token]] += 1
    return matrix
>>> docs = [["cat", "sat", "on", "mat"], ["cat", "cat", "ran"]]
>>> vocab = build_vocab(docs)
>>> bag_of_words(docs, vocab)
[[1, 1, 1, 1, 0], [2, 0, 0, 0, 1]]

행(row)은 문서이고, 열(column)은 어휘 인덱스(vocabulary index)입니다. [i][j] 자리의 값은 "문서 i에 단어 j가 몇 번 등장했는가"를 뜻합니다. 문서 1에는 cat이 두 번 나오므로 그 값이 2이고, 문서 0에는 ran이 한 번도 나오지 않으므로 0입니다.

Step 3: 용어 빈도와 문서 빈도

import math


def term_frequency(doc_bow, doc_length):
    return [c / doc_length if doc_length else 0 for c in doc_bow]


def document_frequency(bow_matrix):
    df = [0] * len(bow_matrix[0])
    for row in bow_matrix:
        for j, count in enumerate(row):
            if count > 0:
                df[j] += 1
    return df


def inverse_document_frequency(df, n_docs):
    return [math.log((n_docs + 1) / (d + 1)) + 1 for d in df]

여기서 두 가지 스무딩(smoothing) 기법을 짚어둘 만합니다. (n+1)/(d+1)log(x/0)이 되는 상황을 막아 줍니다. 마지막에 더하는 +1은 모든 문서에 등장하는 단어조차 IDF가 0이 아닌 1을 갖도록 만들어 주며, 이는 scikit-learn의 기본 동작과 같습니다. 다른 구현들은 그냥 log(N/df)를 쓰기도 합니다. 둘 다 동작하지만, 스무딩된 버전이 다루기에 더 편합니다.

Step 4: TF-IDF

def tfidf(bow_matrix):
    n_docs = len(bow_matrix)
    df = document_frequency(bow_matrix)
    idf = inverse_document_frequency(df, n_docs)
    out = []
    for row in bow_matrix:
        length = sum(row)
        tf = term_frequency(row, length)
        out.append([tf_j * idf_j for tf_j, idf_j in zip(tf, idf)])
    return out
>>> docs = [
...     ["the", "cat", "sat"],
...     ["the", "dog", "sat"],
...     ["the", "cat", "ran"],
... ]
>>> vocab = build_vocab(docs)
>>> bow = bag_of_words(docs, vocab)
>>> tfidf(bow)

문서는 세 개이고 어휘 단어는 다섯 개입니다(the, cat, sat, dog, ran). the는 세 문서 모두에 등장하므로 IDF가 낮고, dog는 한 문서에만 등장하므로 IDF가 높습니다. 그 결과 벡터는 희소하며, 변별력 있는(discriminative) 단어가 두드러집니다.

Step 5: 행 단위 L2 정규화

def l2_normalize(matrix):
    out = []
    for row in matrix:
        norm = math.sqrt(sum(x * x for x in row))
        out.append([x / norm if norm else 0 for x in row])
    return out

정규화가 없으면 더 긴 문서가 더 큰 벡터를 갖게 되어 유사도 점수(similarity score)를 지배합니다. L2 정규화는 모든 문서를 단위 초구(unit hypersphere) 위에 올려 놓습니다. 이렇게 하면 행과 행 사이의 코사인 유사도(cosine similarity)는 단순한 내적(dot product)이 됩니다.

사용해 보기

scikit-learn에는 실서비스 수준의 구현이 들어 있습니다.

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

docs = ["the cat sat on the mat", "the dog sat on the mat", "the cat ran"]

bow_vectorizer = CountVectorizer()
bow = bow_vectorizer.fit_transform(docs)
print(bow_vectorizer.get_feature_names_out())
print(bow.toarray())

tfidf_vectorizer = TfidfVectorizer()
tfidf = tfidf_vectorizer.fit_transform(docs)
print(tfidf.toarray().round(3))

CountVectorizer는 토큰화(tokenization), 어휘 구축, BoW 생성을 한 번에 처리합니다. TfidfVectorizer는 여기에 IDF 가중치와 L2 정규화를 더합니다. 둘 다 희소 행렬(sparse matrix)을 반환합니다. 문서가 10만 개 수준이 되면 조밀(dense) 표현은 메모리에 다 들어가지 않으므로, 분류기가 조밀 행렬을 요구하기 전까지는 희소 표현을 유지하는 것이 좋습니다.

결과를 크게 바꾸는 주요 인자는 다음과 같습니다.

인자효과
ngram_range=(1, 2)바이그램(bigram)을 함께 포함합니다. 보통 분류 성능이 향상됩니다.
min_df=2두 개 미만의 문서에서만 등장하는 단어를 버립니다. 잡음(noise)이 많은 데이터에서 어휘를 줄입니다.
max_df=0.9595% 초과 문서에 등장하는 단어를 버립니다. 하드코딩된 불용어(stopword) 목록 없이 비슷한 효과를 냅니다.
stop_words="english"scikit-learn이 내장한 영어 불용어 목록입니다. 과제에 따라 다릅니다. 감성 분석에서는 부정어(negation)를 버리면 안 됩니다.
sublinear_tf=True원시(raw) tf 대신 1 + log(tf)를 사용합니다. 한 문서에서 같은 용어가 여러 번 반복될 때 도움이 됩니다.

2026년에도 TF-IDF가 이기는 경우

  • 스팸 탐지(spam detection), 주제 라벨링(topic labeling), 로그 이상 신호 표시(log anomaly flagging)처럼 단어의 존재 여부가 중요하고 의미적 뉘앙스(semantic nuance)는 별로 중요하지 않을 때.
  • 라벨링된 예시가 수백 개 정도뿐인 저데이터(low-data) 상황. TF-IDF와 로지스틱 회귀(logistic regression) 조합은 사전 학습(pretraining) 비용이 들지 않습니다.
  • 지연 시간(latency)이 중요한 곳. TF-IDF와 선형 모델은 마이크로초(microsecond) 단위로 응답하지만, 트랜스포머(transformer)를 통한 문서 임베딩은 문서 한 건당 10~100밀리초(ms)가 걸립니다.
  • 예측을 설명해야 하는(explainable) 시스템. 분류기 계수(coefficient)를 들여다보면 어느 단어가 어떤 결정에 기여했는지를 그대로 읽을 수 있습니다.

TF-IDF가 실패하는 경우

가장 큰 실패 양상은 의미 맹점입니다. 다음 두 문장을 봅시다.

  • "The movie was not good at all."
  • "The movie was excellent."

하나는 부정 리뷰이고 다른 하나는 긍정 리뷰입니다. 그런데 두 문장의 TF-IDF 교집합은 정확히 {the, movie, was}뿐입니다. 단어 가방 기반 분류기는 notgood 근처에 있으면 라벨(label)이 뒤집힌다는 사실을 데이터로부터 외워야 합니다. 충분한 데이터만 있으면 학습할 수 있지만, 구문(syntax)을 이해하는 모델만큼 우아하게 처리하지는 못합니다.

또 다른 실패는 추론(inference) 시점의 미등록 단어(out-of-vocabulary; OOV)입니다. IMDb 리뷰로 학습한 BoW 모델은 학습 데이터에 없던 Zoomer-approved 같은 토큰을 마주치면 어떻게 처리해야 할지 알지 못합니다. 서브워드 임베딩(subword embedding; 강의 04)은 이런 상황을 다룰 수 있지만 TF-IDF는 그렇지 못합니다.

혼합(Hybrid): TF-IDF 가중치를 입힌 임베딩

2026년 중간 규모(medium-data) 분류의 실용적인 기본 선택지는 TF-IDF 가중치를 단어 임베딩(word embedding) 위의 주의(attention) 가중치처럼 사용하는 것입니다.

def tfidf_weighted_embedding(doc, tfidf_scores, embedding_table, dim):
    vec = [0.0] * dim
    total_weight = 0.0
    for token in doc:
        if token not in embedding_table or token not in tfidf_scores:
            continue
        weight = tfidf_scores[token]
        emb = embedding_table[token]
        for i in range(dim):
            vec[i] += weight * emb[i]
        total_weight += weight
    if total_weight == 0:
        return vec
    return [v / total_weight for v in vec]

이렇게 하면 임베딩으로부터는 의미 표현 능력(semantic capacity)을, TF-IDF로부터는 희소 단어 강조(rare-word emphasis)를 함께 얻습니다. 분류기는 풀링된(pooled) 벡터 위에서 학습합니다. 감성 분석, 주제 분류, 의도 분류에서 라벨링된 예시가 약 5만 건 미만일 때, 둘 중 하나만 단독으로 쓸 때보다 나은 성능을 보이는 경우가 많습니다.

산출물 만들기

outputs/prompt-vectorization-picker.md로 저장합니다.

---
name: vectorization-picker
description: Given a text-classification task, recommend BoW, TF-IDF, embeddings, or a hybrid.
phase: 5
lesson: 02
---

You recommend a text-vectorization strategy. Given a task description, output:

1. Representation (BoW, TF-IDF, transformer embeddings, or a hybrid). Explain why in one sentence.
2. Specific vectorizer configuration. Name the library. Quote the arguments (`ngram_range`, `min_df`, `max_df`, `sublinear_tf`, `stop_words`).
3. One failure mode to test before shipping.

Refuse to recommend embeddings when the user has under 500 labeled examples unless they show evidence of semantic failure in a TF-IDF baseline. Refuse to remove stopwords for sentiment analysis (negations carry signal). Flag class imbalance as needing more than a vectorizer change.

Example input: "Classifying 30k customer support tickets into 12 categories. Most tickets are 2-3 sentences. English only. Need explainability for audit logs."

Example output:

- Representation: TF-IDF. 30k examples is not small; explainability requirement rules out dense embeddings.
- Config: `TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_df=0.95, sublinear_tf=True, stop_words=None)`. Keep stopwords because category keywords sometimes are stopwords ("not working" vs "working").
- Failure to test: verify `min_df=3` does not drop rare category keywords. Run `get_feature_names_out` filtered by class and eyeball.

연습문제

  1. 쉬움. L2 정규화된 TF-IDF 결과 위에서 cosine_similarity(doc_vec_a, doc_vec_b)를 구현합니다. 동일한 문서끼리는 1.0, 어휘가 전혀 겹치지 않는 문서끼리는 0.0이 나오는지 확인합니다.
  2. 중간. bag_of_words에 n-그램(n-gram) 지원을 추가합니다. 매개변수 n은 n-그램 단위의 등장 횟수를 만들어야 합니다. n=2["the", "cat", "sat"]을 처리하면 ["the cat", "cat sat"]에 대한 바이그램 개수가 나와야 합니다.
  3. 어려움. 위에서 만든 TF-IDF 가중치 임베딩 혼합 방식을 GloVe 100차원 벡터로 구현합니다(한 번 내려받아 캐시(cache)합니다). 20 Newsgroups 데이터셋(dataset)에서 일반 TF-IDF, 평균 풀링(mean-pooled) 임베딩과 분류 정확도(classification accuracy)를 비교하고, 어디에서 어떤 방법이 이기는지 정리합니다.

핵심 용어

용어흔한 설명실제 의미
단어 가방(BoW)단어 빈도 벡터한 문서 안에 있는 어휘 단어들의 등장 횟수입니다. 순서를 버립니다.
용어 빈도(TF)Term Frequency문서 안의 단어 등장 횟수이며, 필요에 따라 문서 길이로 정규화(normalize)할 수 있습니다.
문서 빈도(DF)Document Frequency해당 단어가 한 번 이상 등장한 문서의 수입니다.
역문서 빈도(IDF)Inverse Document Frequency스무딩된 log(N / df). 어디에나 등장하는 단어의 가중치를 낮춥니다.
희소 벡터(Sparse vector)대부분 0인 벡터어휘는 보통 1만~10만 단어이고, 한 문서에는 그 중 대부분이 등장하지 않습니다.
코사인 유사도(Cosine similarity)벡터 사이 각도L2 정규화된 벡터의 내적입니다. 1이면 동일, 0이면 직교(orthogonal)입니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

vectorization-picker

Given a text-classification task, recommend BoW, TF-IDF, embeddings, or a hybrid.

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.TF-IDF 기반 감성 분류기(sentiment classifier)가 'The movie was not good at all'을 긍정으로 잘못 분류합니다. 근본 원인은 무엇인가요?

2.라벨링된 고객 지원 티켓(ticket) 200개를 5개 범주로 분류해야 합니다. 동료가 4억(400M) 파라미터 트랜스포머(transformer) 임베딩(embedding) 모델을 제안합니다. TF-IDF와 로지스틱 회귀(logistic regression)가 더 나은 첫 번째 선택인 이유는 무엇인가요?

3.TF-IDF 행 벡터(row vector)를 L2 정규화(L2 normalization)한 후, 두 문서 간 코사인 유사도(cosine similarity) 계산은 어떤 연산으로 단순화되나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다