AIアルゴリズム徹底解説 14TF-IDF: 単語頻度と逆文書頻度

目次

  • [【AIアルゴリズム徹底解説 14】TF-IDF:単語頻度と逆文書頻度](#【AIアルゴリズム徹底解説 14】TF-IDF:単語頻度と逆文書頻度)
    • [一、なぜ TF-IDF が必要なのか](#一、なぜ TF-IDF が必要なのか)
    • 二、アルゴリズムの原理
      • [2.1 基本定義](#2.1 基本定義)
      • [2.2 TFのバリエーション](#2.2 TFのバリエーション)
      • [2.3 IDFのバリエーション](#2.3 IDFのバリエーション)
      • [2.4 TF-IDFの完全な導出](#2.4 TF-IDFの完全な導出)
      • [2.5 ベクトル化と類似度計算](#2.5 ベクトル化と類似度計算)
    • [3. Pythonによる実装](#3. Pythonによる実装)
      • [3.1 一から実装](#3.1 一から実装)
      • [3.2 sklearn 実践](#3.2 sklearn 実践)
    • [4. パラメータのチューニング / 閾値の選択 / バリエーションの比較](#4. パラメータのチューニング / 閾値の選択 / バリエーションの比較)
      • [4. 1 主要パラメータのチューニング](#4.1 主要パラメータのチューニング)
      • [4.2 TF-IDF のバリエーション比較](#4.2 TF-IDF のバリエーション比較)
      • [4.3 TF-IDF 対 BM25 の定量的比較](#4.3 TF-IDF 対 BM25 の定量的比較)
      • [4.4 閾値選択の経験則](#4.4 閾値選択の経験則)
    • 5. カスタマーサービスシステム/注文システムにおける実際の応用
      • [5.1 インテリジェントなチケットルーティング](#5.1 インテリジェントなチケットルーティング)
      • [5.2 注文商品の検索と並べ替え](#5.2 注文商品の検索と並べ替え)
      • [5.3 チケットの重複排除とナレッジベースへの蓄積](#5.3 チケットの重複排除とナレッジベースへの蓄積)
    • 六、よくある落とし穴
      • [落とし穴の詳細解説:IDFによるデータ漏洩](#落とし穴の詳細解説:IDFによるデータ漏洩)
    • 七、 まとめ
      • 一覧表による要約
      • [検索システムにおけるTF-IDFの位置づけ](#検索システムにおけるTF-IDFの位置づけ)
      • [TF-IDF から BM25 への進化の道筋](#TF-IDF から BM25 への進化の道筋)
      • 重要なポイントの振り返り

【AIアルゴリズム徹底解説 14】TF-IDF:語頻と逆文書頻度

一、なぜTF-IDFが必要なのか

情報検索やテキストマイニングの分野には、根本的な問題があります:ある文書集合と検索語が与えられたとき、ある単語が特定の文書にとってどれほど重要かをどのように測定すればよいのでしょうか?

カスタマーサービスシステムのシナリオを考えてみましょう。ユーザーが「返金手続き」と検索した場合、システムは数万件のチケットの中から最も関連性の高い文書を見つけ出す必要があります。もし単語頻度(Term Frequency)だけでスコアを算出すると、「の」「了」「は」といったストップワードがランキングの上位を占めてしまいます----- ――これらはどの文書でも最も頻繁に現れるからです。逆に、文書頻度だけでフィルタリングすると、「出現回数が多い」というシグナルを見逃してしまいます。

TF-IDFの核心となる考え方は、一言で要約できます:

ある単語が1つの文書にとって持つ重要性は、その文書内での出現頻度に比例し、文書集合全体での出現範囲に反比例する。

この考え方は、1972年にKaren Spärck Jonesが発表した論文『A Statistical Interpretation of Term Specificity and Its Application in Retrieval』で提唱された。半世紀が経過した今でも、TF-IDFは依然として:

  • 検索エンジンのスコアリングにおける基礎的な構成要素
  • キーワード抽出の標準的手法
  • テキストベクトル化における重要なベースライン
  • より複雑なアルゴリズム(BM25、TextRank)の参照枠

深層学習モデル(BERT、GPT)は意味理解の点でTF -IDFをはるかに凌駕しているものの、エンジニアリングの実践においては、TF-IDFは計算が高速で、解釈可能であり、GPUを必要とせず、コールドスタートに優れているといった利点により、依然として多くのシステムにおいて第一選択肢またはバックアップソリューションとして採用されている。Google Searchの初期バージョンのコア評価関数には、TF-IDFの変種が含まれていた。また、ElasticsearchのTF-IDF類似度モジュールは、現在もメンテナンスが続けられている。


二、アルゴリズムの原理

2.1 基本定義

文書集合 D = { d 1 , d 2 , ... , d N } D = \\{d_1, d_2, \\ldots, d_N\\} D={d1,d2,...,dN} とし、合計 N N N 件の文書を含むとする。用語 t t t および文書 d d d について:

  • TF(Term Frequency) :用語 t t t が文書 d d d 中に現れる回数を f t , d f_{t,d} ft,d
  • DF(Document Frequency) : 用語 t t t を含む文書の数。n t n_t nt(または df ( t ) \\text{df}(t) df(t))
  • IDF(Inverse Document Frequency) :用語 t t t の希少度

TF-IDF の基本式は以下の通りである:

TF-IDF ( t , d ) = tf ( t , d ) ⋅ idf ( t ) \\text{TF-IDF}(t, d) = \\text{tf}(t, d) \\cdot \\text{idf}(t) TF-IDF(t,d)=tf (t,d)⋅idf(t)

2.2 TFのバリエーション

元の単語頻度 f_{t,d} には、ある問題がある。10回出現する単語は、1回出現する単語よりも10倍重要なのか?直感的には、重要度は減少していくはずだ----- - 0から1への増加は、100から101への増加よりもはるかに大きい。

そのため、さまざまなTFのバリエーションが生まれた:

(1)原始単語頻度(Raw Count)

tf ( t , d ) = f t , d \\text{tf}(t, d) = f_{t,d} tf(t,d)=ft,d

単純明快だが、値の範囲のばらつきが大きいため、文書間の比較には適さない。

(2)対数単語頻度(Logarithmic TF)

tf ( t , d ) = { 1 + log ⁡ ( f t , d ) if f t , d > 0 0 otherwise \\text{tf}(t, d) = \\begin{cases} 1 + \\log(f_{t,d}) & \\text{if } f_{t,d} > 0 \\\\ 0 & \\ text{otherwise} \\end{cases} tf(t,d)={1+log(ft,d)0if ft,d>0otherwise

ここで、log ⁡ \\log log は通常、底を 10 または e e e とする。この変種は Spärck Jones の原論文に由来し、その直観は「出現回数が倍増しても重みは 1 単位しか増えない」というものである 。これは、学術界や検索エンジンで最も一般的に使用されているTFの変種である。

(3)正規化された単語頻度(Normalized TF)

tf ( t , d ) = f t , d max ⁡ t ′ ∈ d f t ′ , d \\text{tf}(t, d) = \\frac{f_{t,d}} {\\max_{t『 \\in d} f_{t』,d}} tf(t,d)=maxt′∈dft′,dft,d

を文書内の最大単語頻度で除算し、すべての文書のTF値が 0 , 1 0, 1 0,1 の範囲に収まるようにする。これにより、長さの異なる文書間の比較可能性の問題が解決される。

(4)Augmented TF(拡張TF)

tf ( t , d ) = 0.5 + 0.5 ⋅ f t , d max ⁡ t ′ ∈ d f t ′ , d \\text{tf}(t, d) = 0.5 + 0.5 \\cdot \\frac{f_{t,d}}{\\max_ {t『 \\in d} f_{t』,d}} \\text{tf}(t,d)=0.5+0.5\\cdot\\max_{t『\\in d}f_{t』,d}

正規化に基づいて0.5の平滑化項を追加し、完全にゼロになるのを防ぎつつ、値の範囲を 0.5, 1 0.5,1。この変種は、確率がゼロになるのを防ぐ点で非常に有効である。

(5)ブール型 TF(Boolean TF)

tf ( t , d ) = { 1 if f t , d > 0 0 otherwise \\text{tf}(t, d) = \\begin{cases} 1 & \\text {if } f_{t,d} > 0 \\\\ 0 & \\text{otherwise} \\end{cases} tf(t,d)={10if ft,d>0otherwise

最も単純化された形式であり、単語の出現の有無のみに注目し、頻度の違いは無視する。粗い粒度ではあるが、短いテキストのシナリオ(検索クエリやツイートなど)では、概して良好な性能を発揮する。

2.3 IDFのバリエーション

IDFの主な目的は、語の「希少度」を測定することである。ある語が出現する文書数が少ないほど、その識別力は高くなる。

(1)標準IDF

idf ( t ) = log ⁡ N n t \\text{idf}(t) = \\log \\frac{N}{n_t} idf(t)=logntN

ここで、N N N は文書総数、n_t n_t nt は語 t t t を含む文書の数である。n_t = N n_t = N nt=N(すべての文書にその語が含まれる)の場合、idf(t) = log ⁡ 1 = 0 \\text{idf}(t) = \\log 1 = 0 idf(t)=log1=0 となり、その語の重みは はゼロとなる――これが、ストップワードが自動的に低減されるメカニズムである。

(2)スムースIDF (Smooth IDF)

idf ( t ) = log ⁡ ( 1 + N n t ) \\text{idf}(t) = \\log \\left(1 + \\frac{N}{n_t}\\right) idf(t)=log(1+ntN)

n_t = 0 の場合、標準IDFは無限大(ゼロ除算)となるが、平滑IDFはこの問題を回避する。さらに重要なのは、n_t = N の場合、idf(t) = log 2 ≈ 0.693 ≠ 0 となり、たとえすべての文書にその単語が含まれていても、その値は0ではないということである。) = \\log 2 \\approx 0.693 \\neq 0 となり、たとえすべての文書にその単語が含まれていたとしても、わずかな重みが残される――これにより、特定の状況下で信号が完全に失われることを回避できる。

sklearn の TfidfVectorizer は、デフォルトでこの変種を使用している:

idf ( t ) = ln ⁡ 1 + N 1 + n t + 1 \\text{idf}(t) = \\ln \\frac{1 + N}{1 + n_t} + 1 idf (t)=ln1+nt1+N+1

分子と分母にそれぞれ1を加えるのはラプラス平滑化であり、+1 +1 +1というオフセットにより、IDFが常に正となることが保証される。

(3)確率的IDF(Probabilistic IDF)

idf ( t ) = log ⁡ N − n t n t \\text{idf}(t) = \\log \\frac{N - n_t} {n_t} idf(t)=logntN−nt

これはRobertson-Sparck Jonesの確率的検索モデルに由来する。n_tがNに近づくと、分子は0に収束し、IDFは−∞
−∞ に収束する。この変種は高頻度語に対するペナルティがより厳格であるが、負の重みを生じさせる可能性があるため、実際の応用では切り捨てが必要となる。

(4)BM25 の IDF

idf ( t ) = log ⁡ N − n t + 0.5 n t + 0.5 \\text{idf} (t) = \\log \\frac{N - n_t + 0.5}{n_t + 0.5} idf(t)=lognt+0.5N−nt+0.5

これは BM25 アルゴリズムで使用される IDF の変種であり、分子と分母にそれぞれ 0.5 を加えることで平滑化を行っている。これは確率的検索モデルの理論的導出により近く、実用上では標準的な IDF よりも優れた性能を発揮することが多い。

2.4 TF-IDF の完全な導出

上記の要素を組み合わせると、最も一般的に使用される TF-IDF の式は次のようになります:

TF-IDF ( t , d ) = ( 1 + log ⁡ ( f t , d ) ) ⋅ log ⁡ N n t \\text{TF-IDF}(t, d) = (1 + \\log(f_{t,d})) \\cdot \\log \\frac{N}{n_t} TF-IDF(t,d)=(1+log(ft,d))⋅logntN

この式には、2つの直観が反映されている:

  1. 局所信号(TF):単語が現在の文書に多く出現するほど重要度が高くなる――ただし、限界効用は逓減する (対数関数によって保証される)
  2. グローバル信号(IDF):単語がより多くの文書に現れるほど、識別力は弱くなる(反比例関係によって保証される)

具体的な例を導き出すことができる。N = 10000 N = 10000 N=10000 件の文書があると仮定する:

語項 f_{t,d} ft,d n_t nt TF(対数) IDF TF-IDF
「返金」 5 200 1 + ln ⁡ 5 = 2.609 1+\\ ln5=2.609 1+ln5=2.609 ln ⁡ 10000 200 = 3.912 \\ln\\frac{10000}{200}=3.912 ln20010000=3.912 10.207 10.207 10.207
「の」 50

9999 1 + ln ⁡ 50 = 4.912 1+\\ln50=4.912 1+ln50=4.912 ln ⁡ 10000 9999 = 0. 0001 \\ln\\frac{10000}{9999}=0.0001 ln999910000=0.0001 0.0005 0.0005 0.0005
「プロセス」 3 1500 1 + ln ⁡ 3 = 2.099 1+\\ln3=2.099 1+ln3=2.099 ln ⁡ 10000 1500 = 1.897 \\ln\\frac {10000}{1500}=1.897 ln150010000=1.897 3.982 3.982 3.982

見てわかるように、「的」は50回出現している(「返金」の5回をはるかに上回る)ものの、ほぼすべての文書に含まれているため、IDFは0に近づき、最終的にTF-IDF値はごくわずかとなる。一方、「返金」は200件の文書にしか出現していないため、高いIDF重みを得ている。

2.5 ベクトル化と類似度計算

ある文書 d d d については、すべての語項の TF-IDF ベクトルとして表すことができる:

d ⃗ = TF-IDF ( t 1 , d ) , TF-IDF ( t 2 , d ) , ... , TF-IDF ( t V , d ) \\vec{d} = \\ \\text{TF-IDF}(t_1, d), \\\\text{TF-IDF}(t_2, d), \\\\ldots, \\\\text{TF-IDF}(t_V, d) d =TF-IDF(t1,d), TF-IDF(t2,d),...,TF-IDF(tV,d)

ここで、V V V は語彙のサイズである。2つの文書間の類似度は、通常、コサイン類似度を用いて計算される:

sim ( d 1 , d 2 ) = cos ⁡
( d_1⃗, d_2⃗ ) = d_1⃗ ⋅ d_2⃗ ∥ d_1⃗ ∥ ⋅ ∥ d_2⃗ ∥ \\text{sim}(d_1, d_2) = \\cos(\\vec{d_1}, \\vec{d_2}) = \\frac{\\vec{d_1} \\cdot \\vec{d_2}}{\\|\\vec{d_1}\\| \\cdot \\|\\vec{d_2}\\|} \\text{sim}(d_1, d_2) = \\cos(\\vec{d_1}, \\vec{d_2}) = \\|d_1\\| \\cdot \\|d_2\\| \\text{d_1} \\cdot d_2

コサイン類似度の利点は、文書長を自動的に正規化してくれる点にある----- -長い文書と短い文書を同じ尺度で比較できる。これが、TF-IDF文書ベクトルに通常L2正規化が必要な理由でもある:

d ⃗ ^ = d ⃗ ∥ d ⃗ ∥ 2 \\hat{\\vec{d}} = \\frac{\\vec{d}}{\\|\\vec {d}\\|_2} d ^=∥d ∥2d

正規化後、コサイン類似度は内積に帰着する:

sim ( d 1 , d 2 ) = d 1 ⃗ ^ ⋅ d 2 ⃗ ^ \\text{sim}(d_1, d_2) = \\hat{\\vec{d_1}} \\cdot \\hat{\\vec{d_2}} \\text{sim}(d_1, d_2) = d_1 ^ \\cdot d_2 ^

これにより計算が大幅に簡略化され、特に大規模な検索シナリオでは、行列乗算を用いてバッチ処理が可能となる。


3. Pythonによる実装

3.1 ゼロから実装

以下では、numpy 以外のサードパーティ製ライブラリに依存することなく、TF-IDF の計算プロセスを低レベルから完全に実装します:

import numpy as np
from collections import Counter
import math
from typing import List, Dict, Tuple


class TfidfFromScratch:
    「」「ゼロから実装した TF-IDF ベクトル化関数」「」
    
def __init__(
        self,
        tf_mode: str = 「log」, # raw / log / normalized / augmented
        idf_mode: str = 『smooth』, # standard / smooth / probabilistic / bm25
        norm: str = 「l2」, # l2 / none
        stop_words: set = None,
    
):
        self.tf_mode = tf_mode
        self.idf_mode = idf_mode
        self.norm = norm
        self.stop_words = stop_words or set()
        self.vocabulary_: Dict[str, int] = {}
        self.idf_: np.ndarray = None

    
def _tokenize(self, text: str) -> List[str]:
        「」「簡易な中国語・英語の単語分割:英語はスペース単位、中国語は文字単位」「」
        tokens = []
        for word in text.lower().split():
            if word.isascii():
                if word not in self.stop_words and len(word) > 1:
                    tokens. append(word)
            else:
                # 中国語は文字単位で分割
                for ch in word:
                    if ch not in self.stop_words and ch.strip():
                        tokens.append(ch)
        return tokens

    def _compute_tf(self, term_freq: int, doc_max_freq: int) -> float:
        
「」「tf_mode に基づいて用語頻度を計算する」「」
        if term_freq == 0:
            return 0.0
        if self.tf_mode == 『raw』:
            return float(term_freq)
        elif self.tf_mode == 「log」:
            return 1.0 + math.log(term_freq)
        
elif self.tf_mode == 「normalized」:
            return term_freq / doc_max_freq (doc_max_freq > 0 の場合)、そうでない場合は 0.0
        elif self.tf_mode == 「augmented」:
            return 0.5 + 0.5 * (term_freq / doc_max_freq (doc_max_freq > 0 なら 0、そうでなければ 0)
        else:
            raise ValueError(f「Unknown tf_mode: {self.tf_mode}」)

    def _compute_idf(self, n_t: int, N: int) -> float:
        「」「idf_mode に基づいて逆文書頻度を計算する」『』
        if self.idf_mode == 「standard」:
            return math. log(N / n_t) if n_t > 0 else 0.0
        elif self.idf_mode == 「smooth」:
            return math.log(1 + N / n_t) if n_t > 0 else math.log(1 + N)
        elif self.idf_mode == 「probabilistic」:
            val = math.log ((N - n_t) / n_t) if n_t > 0 else float(『inf』)
            return max(val, 0.0) # 負の値を切り捨てる
        elif self.idf_mode == 「bm25」:
            
return math.log((N - n_t + 0.5) / (n_t + 0.5)) if n_t > 0 else 0.0
        else:
            raise ValueError(f「Unknown idf_mode: {self.idf_mode}」)
    
def fit(self, documents: List[str]) -> 「TfidfFromScratch」:
        『』「フィッティング:語彙表の構築とIDFの計算」「」
        tokenized_docs = [self._tokenize(doc) for doc in documents]
        N = len(tokenized_docs)

        # 語彙表と文書頻度の構築
        
df_counter = Counter()
        for tokens in tokenized_docs:
            unique_tokens = set(tokens)
            for t in unique_tokens:
                df_counter[t] += 1

        self.vocabulary_ = {word: idx for idx, word in enumerate(sorted(df_counter))}

        # IDFベクトルの計算
        
vocab_size = len(self.vocabulary_)
        self.idf_ = np.zeros(vocab_size)
        for word, idx in self.vocabulary_.items():
            self.idf_[idx] = self._compute_idf(df_counter [word], N)

        return self

    def transform(self, documents: List[str]) -> np.ndarray:
        「」「ドキュメントを TF-IDF ベクトル行列に変換する」「」
        n_docs = len(documents)
        vocab_size = len(self.vocabulary_)
        
tfidf_matrix = np.zeros((n_docs, vocab_size))

        for i, doc in enumerate(documents):
            tokens = self._tokenize(doc)
            token_counts = Counter(tokens)
            max_freq = max(token_counts.values()) if token_counts else 1
            
for word, count in token_counts.items():
                if word in self.vocabulary_:
                    j = self.vocabulary_[word]
                    tf = self._compute_tf(count, max_freq)
                    tfidf_matrix[i, j] = tf * self.idf_[j]

        # L2正規化
        
if self.norm == 「l2」:
            norms = np.linalg.norm(tfidf_matrix, axis=1, keepdims=True)
            norms[norms == 0] = 1.0
            tfidf_matrix = tfidf_matrix / norms

        return tfidf_matrix

    def get_top_keywords(self, doc_id x: int, matrix: np.ndarray, top_k: int = 10) -> List[Tuple[str, float]]:
        「」「ドキュメントの上位 top_k 個のキーワードを抽出する」「」
        row = matrix[doc_idx]
        
top_indices = np.argsort(row)[::-1][:top_k]
        id_to_word = {v: k for k, v in self.vocabulary_.items()}
        return [(id_to_word[idx], row[idx]) for idx in top_indices if row[idx] > 0]


# ============ テスト実行 ========= ===
if __name__ == 「__main__」:
    documents = [
        「ユーザーが返金を申請しました。返金処理には3営業日かかります。返金は元の支払い方法へ返金されます」,
        「注文状況の照会。配送情報によると発送済みで、明日到着予定です」,
        「返金審査に失敗しました。返金額と注文金額が一致しません。ご確認ください」,
        「配送先住所の変更方法。注文詳細ページで住所を変更できます」,
        
「返金着金時期:返金は処理済みです。1~3営業日以内に着金する見込みです」,
    ]

    stop_words = {「の」, 「ました」, 「で」, 「できます」, 「です」, 「です」, 『です』, 「と」}

    vectorizer = TfidfFromScratch(
        tf_mode="log",
        
idf_mode=「smooth」,
        norm="l2",
        stop_words=stop_words,
    )

    vectorizer.fit(documents)
    tfidf_matrix = vectorizer.transform(documents)

    
print(f「語彙サイズ: {len(vectorizer.vocabulary_)}」)
    print(f"TF-IDF 行列の形状: {tfidf_matrix.shape}「)
    print()

    for i, doc in enumerate(documents):
        keywords = vectorizer.get_top_keywords(i, tfidf_matrix, top_k=3)
        print(f」文書 {i}: {doc[:30]}...")
        
for word, score in keywords:
            print(f「 {word}: {score:.4f}」)
        print()

    # 文書間のコサイン類似度を計算
    
sim_matrix = tfidf_matrix @ tfidf_matrix.T
    print(「文書間の余弦類似度行列:」)
    print(np.round(sim_matrix, 3))

実行結果の例:

語彙サイズ: 28
TF-IDF 行列の形状: (5, 28)

文書 0: ユーザーが返金を申請 返金手続きには3営業日かかる 返金は元の支払方法へ返金される...
    
返: 0.5234
    金: 0.3987
    流: 0.2891

文書 1: 注文状況の照会 物流情報によると発送済み 明日到着予定...
    注: 0.4521
    文: 0.4521
    物流: 0.3201

文書間コサイン類似度行列 :
[[1. 0. 0.42 0. 0.38]
 [0. 1. 0. 0.15 0. ]
 [0.42 0. 1. 0. 0.29]
 [0. 0.15 0. 1. 0. ]
 [0.38 0. 0.29 0. 1. ]]

図からわかるように、文書 0、2、4(いずれも「返金」に関連)間の類似度は明らかに高く、TF-IDF が意味的な関連性をうまく捉えていることがわかります。

3.2 sklearn の実践

実際のプロジェクトでは、sklearn の TfidfVectorizerの使用を推奨します。これは高度に最適化されており、疎行列、n-gram、並列計算などの機能をサポートしています:

import numpy as npfrom sklearn.feature_extraction.text import TfidfVectorizerfrom sklearn.metrics.pairwise import cosine_similarityfrom sklearn.datasets import fetch_20newsgroups

# ============ 基本的な使い方 ============
print(「=」 * 60)
print(「1. 基本的な使い方:カスタマーサポートのチケットからのキーワード抽出」)
print(「=」 * 60)

documents = [
    「ユーザーが返金を申請 返金処理には3営業日かかります 返金は元の支払い方法へ返金されます」,
    「注文状況の照会 配送情報によると発送済み 明日到着予定」,
    「返金審査に失敗 返金額と注文金額が一致しません ご確認ください」,
    「配送先住所の変更方法 注文詳細ページで住所を変更できます」,
    
「返金着金時期:返金は処理済み。1~3営業日以内に着金予定」,
]

vectorizer = TfidfVectorizer(
    token_pattern=r「(?u)\\b\\w+\\b」, # 中国語の単字と英語の単語にマッチ
    ngram_range= (1, 2), # ユニグラム + バイグラムを使用
    smooth_idf=True, # 平滑化IDFを使用: ln((1+N)/(1+n_t)) + 1
    sublinear_tf=False, # Trueに設定すると 1+log(tf) を使用
    norm="l2", # L2正規化
    min_df=1, # 最小文書頻度
    
max_df=0.9, # 最大文書頻度の割合
    max_features=1000, # 最大特徴数
)

tfidf_matrix = vectorizer.fit_transform(documents)

print(f「語彙サイズ: {len(vectorizer.vocabulary_)}」)
print(f「行列の型: {type(tfidf_matrix)}」)
print(f 「行列の形状: {tfidf_matrix.shape}」)
print(f「ゼロ以外の要素の割合: {tfidf_matrix.nnz / (tfidf_matrix.shape[0] * tfidf_matrix.shape[1]):.2%}」)
print()

# IDF 値を取得
feature_names = vectorizer.get_feature_names_out()
idf_values = vectorizer.idf_
print(「一部の語項の IDF 値(低い順):」)
sorted_pairs = sorted(zip(feature_names, idf_values), key=lambda x: x[1])
for name, idf in sorted_pairs[: 10]:
    print(f「 {name}: {idf:.4f}」)
for name, idf in sorted_pairs[-5:]:
    print(f「 {name}: {idf:.4f}」)
print()

# キーワードの抽出
print(「各ドキュメントのトップ5キーワード:」)
for i, doc in enumerate(documents):
    
row = tfidf_matrix[i].toarray().flatten()
    top_idx = np.argsort(row)[::-1][:5]
    keywords = [(feature_names[j], row[j]) for j in top_idx if row[j] > 0]
    print(f" ドキュメント {i}: {'', 『.join(f』{w}({s:.3f})'' for w, s in keywords)}「)

# ============ 応用:文書検索 ============
print(」\\n「 + 」=「 * 60)
print(」2. 応用:TF-IDF に基づく文書検索「)
print(」=" * 60)

categories = [「sci.space」, 「sci.electronics」, 「talk.politics.mideast」]
newsgroups = fetch_20newsgroups(
    subset="train",
    categories=categories,
    remove=(「headers」, 「footers」, 『quotes』),
)

print(f「ドキュメント数: {len(newsgroups.data)}」)
print (f「カテゴリ: {newsgroups.target_names}」)

retrieval_vectorizer = TfidfVectorizer(
    max_df=0.5,
    min_df=2,
    max_features=10000,
    stop_words="english",
    ngram_range=(1, 1),
    sublinear_tf=True,
)

doc _vectors = retrieval_vectorizer.fit_transform(newsgroups.data)
print(f「語彙サイズ: {len(retrieval_vectorizer.vocabulary_)}」)
print(f「スパース行列のサイズ: {doc_vectors.shape}」)

queries = [
    「NASA スペースシャトルの打ち上げ」,
    『回路基板の電圧増幅器』,
    「イスラエル・パレスチナ和平協定」,
]

for query in queries:
    query_vec = retrieval_vectorizer.transform([query])
    scores = cosine_similarity(query_vec, doc_vectors).flatten()
    top_k = 5
    
top_indices = np.argsort(scores)[::-1][:top_k]

    print(f「\\nクエリ: 『{query}』」)
    print(f「上位{top_k}件の結果:」)
    for rank, idx in enumerate(top_indices, 1):
        category = newsgroups.target_names[newsgroups.target[idx]]
        
snippet = newsgroups.data[idx][:80].replace(「\\n」, 「 」)
        print(f「 {rank}. [{category}] (score={scores[idx]:.4f}) {snippet}...」)

# ============ 上級:パラメータの組み合わせ比較 ============
print(「\\n」 + 「=」 * 60)
print(「3. パラメータ比較:sublinear_tf と max_df の影響」)
print(「=」 * 60)

configs = [
    {「name」: 「標準 TF-IDF」, 「sublinear_tf」: False, 「max_df」: 1.0},
    {『name』: 「Sublinear TF」, "sublinear_tf 「: True, 」max_df「: 1.0},
    {」name「: 」Sublinear + max_df=0.5「, 『sublinear_tf』: True, 」max_df": 0.5},
    
{「name」: 「Sublinear + max_df=0.5 + ngram(1,2)」, 「sublinear_tf」: True, 「max_df」: 0.5, 『ngram_range』: (1, 2)},
]

query = 「NASA space shuttle launch」
for cfg in configs:
    
params = {
        「sublinear_tf」: cfg[「sublinear_tf」],
        「max_df」: cfg[「max_df」],
        『min_df』: 2,
        「max_features」
 : 10000,
        「stop_words」: 「english」,
    }
    if 「ngram_range」 in cfg:
        params[『ngram_range』] = cfg[「ngram_range」]

    vec = TfidfVectorizer(**params)
    mat = vec.fit_transform(newsgroups.data)
    
qv = vec.transform([query])
    sc = cosine_similarity(qv, mat).flatten()
    top_score = np.sort(sc)[::-1][:5].mean()

    print (f「 {cfg[『name』]:40s} | 語彙: {len(vec.vocabulary_):5d} | Top-5 平均スコア: {top_score:.4f}」)

4. パラメータのチューニング / 閾値の選択 / バリエーションの比較

4.1 主要パラメータのチューニング

パラメータ 役割 推奨値 調整の提案
tf_mode TFの計算方式 log / sublinear 長い文書には対数 TF、短い文書には元の TF を使用
idf_mode IDF の計算方式 smooth ゼロ除算の問題を回避するため、 sklearnのデフォルト
max_df 最大文書頻度 0.85-0.95 分野固有のストップワードを除外。中国語の場合はやや低く設定可能
min_df 最小文書頻度 2~5 スペルミスや極めて低頻度の単語を除外
ngram_range n-gramの範囲 (1,1) または (1,2) 短いテキストでは (1,2) を使用してフレーズを捕捉し、長い文書では (1,1) を使用
norm 正規化方式 l2 コサイン類似度は L2 正規化必須
sublinear_tf 対数 TF True sklearn における同等の設定
max_features 最大特徴数 10000-100000 メモリおよび遅延の制約に基づいて設定

4.2 TF-IDF のバリエーション比較

バリエーション TF の計算式 IDF の計算式 利点 欠点 適用シナリオ
標準 TF-IDF f t , d f_{t,d} ft,d log ⁡ N n t \\log\\frac{N} {n_t} logntN シンプルで直感的 高頻度語の重みが過大 教育用、迅速なベースライン
対数 TF-IDF 1 + log ⁡ f t , d 1+\\log f_{t,d} 1+log f_{t,d} log ⁡ N n t \\log\\frac{N}{n_t} logntN 限界が減少するため、より合理的 正規化を行っていない 汎用検索
平滑化TF-IDF 1 + log ⁡ f t , d 1+\\log f_ {t,d} 1+logft,d log ⁡ ( 1 + N n t ) \\log(1+\\frac{N}{n_t}) log(1+ntN) ゼロ除算リスクなし 高頻度語はゼロにならない sklearnのデフォルト
正規化 TF-IDF f_{t,d} \\frac{\\max f}{f_{t,d}} \\max f_{t,d} log \\frac {N}{n_t} \\log\\frac{nt}{N} 文書間での比較が可能 絶対頻度情報が失われる 文書クラスタリング
BM25 $\\frac{f(k_1+1)}{f+k_1(1-b+b\\cdot\\frac{ d }{avgdl})}$ log ⁡ N − n_t + 0.5 n_t + 0.5 \\log\\frac{N-n_t+0.5}{n_t+0.5} lognt+0.5N−nt+0.5 文書長正規化、
最良の結果

4.3 TF-IDF 対 BM25 の定量的比較

BM25 は TF-IDF の拡張版と見なすことができ、主に TF の飽和度制御と文書長の正規化という 2 点で改良が加えられている。

BM25 のスコア計算式:

BM25 ( t , d ) = idf ( t ) ⋅ f t , d ⋅ ( k 1 + 1 ) f t , d + k 1 ⋅ ( 1 − b + b ⋅ ∣ d ∣ avgdl ) \\text{BM25}(t, d) = \\text{idf} (t) \\cdot \\frac{f_{t,d} \\cdot (k_1 + 1)}{f_{t,d} + k_1 \\cdot (1 - b + b \\cdot \\frac{|d|}{\\text{avgdl}})} BM25(t,d)=idf(t) ・ft,d+k1・(1−b+b・avgdl|d|)ft,d・(k1+1)

ここで、k 1 ∈ 1.2 , 2.0 k_1 \\in 1.2, 2.0 k1∈1.2,2.0 は TF の飽和速度を制御し、b ∈ 0 , 1 b \\in 0, 1 b∈0,1 文書長の正規化強度を制御する。avgdl \\text{avgdl} avgdl は平均文書長である。

以下、実験を通じて定量的な比較を行います:

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from collections import Counter

def bm25_score(query_tokens, doc_tokens_list, k1=1.5, b=0.75):
    
「」「比較用の簡易 BM25 実装」「」
    N = len(doc_tokens_list)
    df = Counter()
    for doc in doc_tokens_list:
        for t in set(doc):
            df[t] += 1
    avgdl = np.mean([len(doc) for doc in doc_tokens_list])
    
scores = np.zeros(N)
    for i, doc in enumerate(doc_tokens_list):
        doc_len = len(doc)
        tf_counter = Counter(doc)
        for t in query_tokens:
            if t not in df:
                continue
            n_t = df[t]
            idf = np.log((N - n_t + 0.5) / (n_t + 0.5))
            f = tf_counter.get(t, 0)
            tf_component = (f * (k1 + 1)) / (f + k1 * (1 - b + b * doc_len / avgdl)) if f > 0 else 0
            scores[i] += idf * tf_component
    return scores

# 実験データ
docs = [
    「the space shuttle launched by NASA completed its mission successfully」.split(),
    「NASA announced a new space telescope will be deployed next year」.split(),
    
「the circuit board design uses high voltage capacitors」.split(),
    「Israel and Palestine reached a peace agreement yesterday」.split(),
    「the shuttle docked at the international space station」.split(),
]
doc_texts = [" 「.join(d) for d in docs]
query = 」space shuttle launch「
query_tokens = query.split()

# TF-IDF スコアリング
vectorizer = TfidfVectorizer(stop_words=」english", sublinear_tf=True)
doc_vectors = vectorizer.fit_transform(doc_texts)
query_vec = vectorizer.transform([query])
tfidf_scores = cosine_similarity(query_vec, doc_vectors).flatten()

# BM25 スコアリング
bm25_scores = bm25_score(query_tokens, docs)

print(f「クエリ: 『{query}』」)
print(f"{『Rank』:<6} {『Doc#』:<6} {『TF-IDF』:<10} {『BM25』:<10} {『Content』}")
print(「-」 * 80)
ranking = np.argsort(tfidf_scores)[::-1]
for rank, idx in enumerate(ranking, 1):
    print(f"{rank:<6} {idx:<6} {tfidf_scores[idx]:<10.4f} {bm25_scores [idx]:<10.4f} {doc_texts[idx][:50]}")

典型的な出力:

クエリ: 『space shuttle launch』
ランク ドキュメント番号 TF-IDF BM25 コンテンツ
------------------------------------------------------- -----
1 0 0.5472 3.2105 NASAが打ち上げたスペースシャトルは…
2 4 0.4831 2.8765 シャトルは国際宇宙ステーションにドッキングした…
3 1 0.2987 1.5432 NASAは、新しい宇宙望遠鏡が… ..
4 2 0.0000 0.0000 回路基板の設計には高電圧が使用されている...
5 3 0.0000 0.0000 イスラエルとパレスチナは和平合意に達した...

両手法の順位付けは一致しているが、BM25はTop-1とTop-2の識別度が高く(3.21 対 2.88、差 0.33)、TF-IDF (0.55 対 0.48、差 0.07)よりも顕著であり、これは複数の結果を統合する際に有利である。

4.4 閾値選択の経験則

適用シナリオ 類似度閾値 説明
重複排除 > 0.85 高い閾値で誤削除を回避
関連のおすすめ 0.3 - 0.6 中程度の閾値で関連性を確保
あいまい一致 > 0.15 低閾値でリコール率を重視
キーワード抽出 TF-IDF > 平均値 + 1.5σ 統計的閾値

五、 カスタマーサービスシステム/注文システムにおける実際の応用

5.1 インテリジェントなチケットルーティング

カスタマーサービスシステムでは、毎日大量のチケット(チケットタイトル+問題の説明)が適切な処理チームに割り当てられる必要があります。TF-IDF を使用することで、チケットの分類とルーティングを迅速に行うことができます:

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


class TicketRouter:
    「」「TF-IDF に基づくチケットルーティングシステム」「」

    def __init__(self):
        
self.vectorizer = TfidfVectorizer(
            max_df=0.8,
            min_df=1,
            ngram_range=(1, 2),
            sublinear_tf=True,
            norm="l2",
        )
        self.category_docs = {}
        self.category_vectors = {}
        self.category_matrix = None
        
self.category_names = []

    def add_category(self, category: str, sample_tickets: list[str]):
        「」「カテゴリとサンプルチケットを追加する」「」
        self.category_docs[category] = sample_tickets

    def fit(self):
        『』「学習:各カテゴリについて TF-IDF 中心ベクトルを計算する」「」
        
all_docs = []
        for cat, docs in self.category_docs.items():
            all_docs.extend(docs)
            self.category_names.append(cat)

        all_tfidf = self.vectorizer.fit_transform(all_docs)
        
# 各カテゴリの中心ベクトルを計算
        self.category_vectors = {}
        start = 0
        for i, (cat, docs) in enumerate(self.category_docs.items()):
            end = start + len(docs)
            center = all_tfidf[start:end].mean(axis=0)
            self.category_vectors[cat] = np.asarray (center).flatten()
            start = end

        self.category_matrix = np.vstack(
            [self.category_vectors[cat] for cat in self.category_names]
        )

    def route(self, ticket_text: str, top_k: int = 2) -> list[dict]:
        「」" チケットをルーティングすべきカテゴリを予測する「」"
        ticket_vec = self.vectorizer.transform([ticket_text])
        ticket_array = ticket_vec.toarray()
        scores = cosine_similarity(ticket_array, self.category_matrix).flatten()

        top_indices = np.argsort(scores)[::-1][:top_k]
        
results = []
        for idx in top_indices:
            results.append({
                「category」: self.category_names[idx],
                『confidence』: float(scores[idx]),
            })
        return results


# ============ 実際のデプロイ例 ============
if __name__ == 「__main__」:
    router = TicketRouter()
    
# 各カテゴリのサンプルチケット
    router.add_category(「返金グループ」, [
        「ユーザーによる返金申請 返金処理には3営業日かかります」,
        「返金審査に失敗 返金額と注文金額が一致しません」,
        「返金着金時期の照会 返金は処理済みです」,
        
「7日間無理由返金申請 商品は返送済み」,
    ])

    router.add_category(『物流グループ』, [
        「注文の配送状況照会 発送済み、明日到着予定」,
        「受取先住所の変更 注文は発送済み」,
        
「配送物の紛失による再発送申請 配送状況では受取済みと表示されているが未受領」,
        「配送遅延 予定配達時間を3日超過」,
    ])

    router.add_category(『技術チーム』, [
        「アプリのクラッシュ 商品詳細ページを開くと強制終了する」,
        「決済失敗 ネットワークエラーと表示されるが、ネットワークは正常」,
        "ログイン認証コードが届かない 携帯電話番号は正しい 「,
        」ページ読み込み時に白画面になる。キャッシュを消去しても改善しない「,
    ])

    router.fit()

    # 新規チケットのシミュレーション
    new_tickets = [
        」顧客から、支払い時にシステムエラーが表示され、決済が完了できないとの報告「,
        」購入者が以前購入した服の返品を希望。サイズが合わないとのこと「,
        」荷物は受取済みと表示されているが、顧客は受け取っていないと主張",
    ]
    
for ticket in new_tickets:
        results = router.route(ticket, top_k=2)
        print(f「チケット: {ticket}」)
        for r in results:
            print(f「 -> {r[『category』]} (信頼度: {r[『confidence』]:.4f})」)
        
print()

実行結果の例:

チケット: 顧客から、支払い時にシステムエラーが表示され、支払いを完了できないとの報告
  -> 技術チーム (信頼度: 0.4213)
  -> 返金チーム (信頼度: 0.1024)

チケット: 購入者が以前購入した服の返品を希望しており、サイズが合わないとのこと
  
-> 返金チーム (信頼度: 0.3856)
  -> 物流チーム (信頼度: 0.0892)

チケット: 荷物は受領済みと表示されているが、顧客は受け取っていないと主張している
  -> 物流チーム (信頼度: 0.4521)
  -> 返金チーム (信頼度: 0.1567)

この手法の利点は、データのアノテーションが不要でコールドスタートが可能、新規事業ラインの導入時にはサンプルチケットを追加するだけで再学習が可能である。十分なデータが蓄積された後は、BERT分類器へスムーズに移行できる。

5.2 注文商品の検索と並べ替え

EC注文システムにおいて、ユーザーが商品を検索した際、関連する結果を迅速に返す必要がある。TF-IDFは、検索スコアリングの第一段階としての粗選別として利用できる:

< pre>import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel # L2正規化後の内積に相当

class ProductSearch:
「」「TF-IDFに基づく商品検索エンジン」" "

def __init__(self):
self.vectorizer = TfidfVectorizer(
max_df=0.7, # 高頻度な商品用語をフィルタリング
min_df=2,
ngram_range=(1, 2), # バイグラムで「スマホケース」などのフレーズを捕捉
sublinear_tf=True,
norm="l2",
)

self.product_matrix = None
self.product_ids = []
self.product_titles = []

def index(self, products: list[dict]):
「」「商品リストのインデックス作成
products: [{」id「: 」P001「, 」title「: 『商品タイトル』, 」...「: 」..."}]

「」「
self.product_ids =

for p in products]
self.product_titles =
for p in products]

# 商品タイトルの TF-IDF 行列を構築
self.product_matrix = self.vectorizer.fit_transform(self.product_titles)

def search(self, query: str, top_k: int = 10) -> list[dict]:

「」「商品を検索し、Top-Kの結果を返す」「」
query_vec = self.vectorizer.transform([query])

# linear_kernelを使用(L2正規化後のコサイン類似度に相当)
scores = linear_kernel(query_vec, self.product_matrix).flatten()

top_indices = np.argsort(scores)[::-1][:top_k]
results = []
for idx in top_indices:
if scores[idx] > 0:

results.append({
「product_id」: self.product_ids[idx],
『title』: self.product_titles[idx],
「score」: float(scores[idx]),
})
return results

# ============ 商品検索のシミュレーション ============
if __name__ == 「__main__」:
products = [
{「id」: 「P001」, 『title』: 「iPhone 15 Pro スマホケース シリコン保護カバー」},

{「id」: 「P002」, 「title」: 「Huawei Mate 60 スマホケース マグネット式・落下防止」},
{「id」: 「P003」, 「title」: 「Xiaomi 充電器 67W 急速充電アダプター」},
{『id』: 「P004」 , 「title」: 「iPhone 15 Pro スクリーンプロテクター 強化ガラス」},
{「id」: 「P005」, 「title」: 「Huawei Mate 60 純正充電器」},
{「id」: 『P006』, 「title」: 「スマホホルダー 車載ナビ用固定架」},
{「id」: 「P007」, 「title」: 「iPhone 15 Pro データケーブル Type-C 編み込みタイプ」},
{「id」: 「P008」, 『title』: 「Huawei Mate 60 スマホケース 透明ソフトケース」},
]

search_engine = ProductSearch ()
search_engine.index(products)

queries = [「iPhone 15 スマホケース」, 「Huawei 充電器」, 「スマホホルダー」]
for query in queries:

results = search_engine.search(query, top_k=3)
print(f「検索: 『{query}』」)
for r in results:
print(f「 [{r[『product_id』]}] {r[『title』]} (score={r[『score』]:.4f})」)
print()

出力例:

検索: 『iPhone 15 スマホケース』
  [P001] iPhone 15 Pro スマホケース シリコンケース (score=0.7821)
  [P004] iPhone 15 Pro スクリーンプロテクター 強化ガラス (score=0.3541)
  [P007] iPhone 15 Pro 充電ケーブル Type-C 編み込み (score=0.2987)

検索: 『Huawei 充電器』
  [P005] Huawei Mate 60 純正充電器 (score=0.6543)
  [P003] Xiaomi 充電器 67W 急速充電アダプター (score=0.2398)

検索: 『スマホスタンド』
  [P006] スマホスタンド 車載ナビ用固定架 (score=0.8932)

5.3 チケットの重複排除とナレッジベースへの蓄積

カスタマーサポートシステムでは、多くのチケットが重複した問い合わせです。TF-IDF類似度を用いて重複チケットを自動的に識別し、頻出する問題をナレッジベースのエントリとして蓄積します:

import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


class TicketDeduplicator:
    「」「TF-IDF に基づくチケット重複排除ツール」「」
    
def __init__(self, similarity_threshold: float = 0.75):
        self.threshold = similarity_threshold
        self.vectorizer = TfidfVectorizer(
            ngram_range=(1, 2),
            sublinear_tf=True,
            norm="l2",
        )
        
self.ticket_vectors = None
        self.ticket_texts = []
        self.clusters = [] # 各クラスター:{「representative」: 「...」, 「members」: [idx], 『count』: n}

    def process(self, tickets: list[str]) -> list[dict]:
        「」" チケットのリストを処理し、重複を除去したクラスタリング結果を返す「」"
        self.ticket_texts = tickets
        self.ticket_vectors = self.vectorizer.fit_transform(tickets)

        n = len(tickets)
        assigned = [False] * n
        clusters = []

        for i in range(n):
            if assigned[i]:
                continue
            # ticket_i との類似度が閾値を超えるすべてのチケットを検索
            sims = cosine_similarity(
                self.ticket_vectors[i:i+1],
                
self.ticket_vectors
            ).flatten()

            members = []
            for j in range(n):
                if not assigned[j] and sims[j] >= self.threshold:
                    members.append(j)
                    assigned[j] = True

            clusters.append({
                「representative」: tickets[i],
                
「members」: members,
                『count』: len(members),
            })

        self.clusters = clusters
        return clusters


# ============ チケットの重複排除のシミュレーション ============
if __name__ == 「__main__」:
    tickets = [
        
「返金はいつ振り込まれますか?もう3日も待っています」,
        「返金の振り込み時期 返金は処理済みです。1~3営業日以内に振り込まれる予定です」,
        「返金はいつ着きますか? 返金はすでに処理済みです」,
        「アプリが開けません。開くとすぐにクラッシュします」,
        「アプリがクラッシュします。開くとすぐに強制終了します」,
        「配送状況はどこまで進んでいますか? 注文の配送状況を照会」,
        「注文の配送状況照会 発送済み」,
        
「返金は振り込まれましたか? 数日間待っています」,
    ]

    dedup = TicketDeduplicator(similarity_threshold=0.55)
    clusters = dedup.process(tickets)

    print(f「元のチケット数: {len(tickets)}」)
    
print(f「重複除去後のクラスター数: {len(clusters)}」)
    print()
    for i, cluster in enumerate(clusters):
        print(f「クラスター {i+1} (計 {cluster[『count』]} 件):」)
        print(f「 代表: {cluster[『representative』]}」)
        for idx in cluster[『members』]:
            
print(f「 - {tickets[idx]}」)
        print()

出力例:

元のチケット数: 8
重複除去後のクラスター数: 3

クラスター 1 (計 4 件):
  代表例: 返金はいつ振り込まれますか?もう3日も待っています
  - 返金はいつ振り込まれますか?もう3日も待っています
  - 返金の振り込み時期 返金は処理済みです。1~3営業日以内に振り込まれる予定です
  - 返金はいつ振り込まれますか?返金はすでに処理済みです
  - 返金は振り込まれましたか?数日間待っています

クラスター 2 (計 2 件):
  代表例: アプリが開かない。開くとすぐにクラッシュする
  - アプリが開かない。開くとすぐにクラッシュする
  
- アプリがクラッシュする 起動するとすぐにクラッシュする

クラスター 3 (計 2 件):
  代表例: 配送状況は? 注文の配送状況を照会
  - 配送状況は? 注文の配送状況を照会
  - 注文の配送状況照会 発送済み

8件のチケットが自動的に3つのクラスターに分類され、各クラスターごとにナレッジベースのFAQを1件生成することで、手作業による重複回答のコストを大幅に削減できます。


六、よくある落とし穴

番号 落とし穴 説明 結果 解決策
1 ストップワードのフィルタリングが行われていない 中国語の「的、了、是」や英語の「the、is、a」がフィルタリングされていない ストップワードが高いTF値を取得し、順位付けに支障をきたす max_df=0.85 による自動フィルタリングを使用するか、ストップワードリストを手動で管理する
2 中国語を単語分割せずに文字単位で直接使用 文字単位で直接分割したため、「手机壳」が「手」「机」「壳」の3つの単字に分割された フレーズの意味が失われ、類似度の計算が不正確になる まず jieba/HanLP で単語分割を行い、その後 TfidfVectorizer に入力する
3 IDFにテストセットのデータが使用されている transform段階でIDFを再フィットした データリークにより、評価結果が過大評価されている fitはトレーニングセットでのみ実行され、テストセットではtransformのみを呼び出す
4 文書長の正規化を無視 長い文書は本来TF値が高くなるため、正規化を行っていない 類似度計算において長い文書が優先されている norm=「l2」を使用するか、BM25(長さ正規化が組み込まれている)に切り替える
5 辞書が肥大化し、メモリオーバーフローが発生 max_featuresを制限しておらず、大規模コーパスの辞書が100万語規模に達した

行列が大きすぎて、メモリオーバーフローやクエリ遅延が急増 max_featuresの上限を設定するか、疎行列+増分更新を使用

落とし穴の詳細解説:IDFデータ漏洩

これはエンジニアリングの実践において最も犯しやすい間違いの一つです。正しい手順は以下の通りです:

# ❌ 誤った方法:全データに対して fit を実行
vectorizer = TfidfVectorizer()
all_vectors = vectorizer.fit_transform(all_documents) # IDF にテストセットの情報が含まれる

# ✅ 正しい方法:トレーニングセットのみに対して fit を実行
tfidf = TfidfVectorizer()
train_vectors = tfidf.fit_transform (train_documents) # IDFはトレーニングセットのみによって決定される
test_vectors = tfidf.transform(test_documents) # トレーニングセットのIDFを用いてテストセットを変換

誤った方法では、テストセットの語彙分布がIDF値に影響を与え、テストセットの評価結果が過大評価されてしまいます。オフライン評価では差はそれほど大きくないかもしれませんが(1~3%)、A/Bテストではオンラインでの効果の著しい低下が露呈することになります。


七、まとめ

一表でまとめ

次元 コンテンツ
コアモデル TF-IDF = TF (t,d) × IDF(t)
重要な公式 TF-IDF ( t , d ) = ( 1 + log ⁡ f t , d ) ⋅ log ⁡ N n t \\text{TF-IDF}(t,d) = (1+\\log f_{t,d}) \\cdot \\log\\frac{N}{n_t} TF-IDF(t,d)=(1+logft,d)⋅logntN
主要パラメータ TFのバリエーション(raw/log/normalized)、IDFのバリエーション(standard/smooth/bm25)、max_df、min_df、ngram_range、norm
主な利点 ① 計算が高速 (O(N・V)、疎行列による高速化)② 解釈可能(各重みに明確な意味がある)③ GPU不要 ④ コールドスタートに優れる(ラベル付きデータ不要)
主な欠点 ① 同義語を区別できない(「携帯電話」と「モバイル電話 」が一致しない)② 語順情報を無視する ③ 形態変化に敏感でない(手動での語幹化が必要)
ダウングレード戦略 TF-IDFによるリコールが不十分な場合、セマンティックベクトル(BERT/word2vec)を組み合わせて第2段階の精選を行うか、または直接BM25にアップグレードする
選定の推奨 コールドスタート段階では、迅速なベースラインとして TF-IDF を優先的に採用する。データ量が増加した後は BM25 にアップグレードする。セマンティックマッチングの要件が強い場合は、ベクトル検索(BERT 埋め込み + FAISS)を導入する

検索システムにおける TF-IDF の位置づけ

クエリ入力
    │
    ▼
┌──────────────┐
│ クエリ分析 │ ← 単語分割、ストップワードフィルタリング、類義語の拡張
└──────┬───────┘
       │
       ▼
┌─────── ───────┐
│ TF-IDF 粗選別 │ ← ミリ秒単位、100万件の文書からTop-1000を選別
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ BM25 精密ランキング │ ← より精細なスコアリング、Top-100
└──────┬───────┘
       │
       ▼
┌──────── ──────┐
│ 意味モデルの再並べ替え │ ← BERT/LLMによるTop-10最終順位付け
└──────┬───────┘
       │
       ▼
   
最終結果

このファネルにおいて、TF-IDFは最初のふるいとしての役割を果たしています。最高精度である必要はありませんが、最速であることが求められます。最小限の計算コストで候補セットを百万単位から千単位に絞り込み、その後行われるより複雑だが処理速度の遅いモデルへの入力データを提供します。

TF-IDF から BM25 への進化の道筋

  1. 第一段階:標準的な TF-IDF を導入し、ベースライン指標(リコール率、精度、MRR)を確立する
  2. 第2ステップ :サブリニアTFに切り替え(sublinear_tf=True)、指標の変化を観察
  3. 第3ステップmax_df および min_df を調整し、ノイズとなる語彙をフィルタリング
  4. 第4ステップ :ビッグラム (ngram_range=(1,2))を導入し、フレーズ信号を捕捉
  5. 第5ステップ:BM25(Elasticsearchのデフォルトの類似度アルゴリズム)へアップグレードし、文書長正規化機能を実現
  6. ステップ6:BM25を基盤に意味ベクトルを重ね合わせ、ハイブリッド検索を行う(例:RRFによるBM25とBERTベクトルの融合)

この進化の道筋により、各ステップで定量化可能な効果が確保され、 一挙に複雑さを導入しても、効果の要因を特定できないという事態を回避します。

重要なポイントの振り返り

  • TF-IDFの本質は、2つのシグナルの積です:局所頻度シグナル × 全局希少度シグナル
  • 対数TFは元のTFよりも合理的です――限界効用の逓減は人間の直感に合致します
  • 平滑化IDFはエンジニアリング上の標準手法------除零や不必要なゼロ重みを回避
  • L2正規化はコサイン類似度の前提条件------正規化を行わない内積には比較可能性がない
  • BM25はTF-IDFの超集合 ----- - TFの飽和制御(k1)および文書長正規化(bb)が追加されている
  • 実装上の優先順位:まず TF-IDF ベースラインを導入 → パラメータ調整による最適化 → BM25 へのアップグレード → セマンティック検索の重ね合わせ

TF-IDF は 1972 年のアルゴリズムですが、「時代遅れ」ではなく「古典」です。TF-IDF を理解することは、情報検索分野全体を理解するための出発点です---- --BM25、LSI、word2vec、BERTによる検索は、いずれもTF-IDFの特定の側面に対する改良と見なすことができます。この基礎をしっかりと固めておけば、その後の各段階のアップグレードにおいて、単に「何をしているか」だけでなく、「なぜそうするのか」も理解できるようになります。