TFIDF検索のパラドクス: 検索対象外のコーパスで計算すると精度が上がる例

# 概要
TF-IDFに基づく検索では、検索対象のコーパスを使ってIDFを求めることが普通ですが、検索対象とは異なるコーパスで求めたIDFを使った方が精度が高まる場合があることを確かめました。

具体的には、miraclという検索データセットから、クエリの正例を含む検索対象コーパスと、それと重複しない非検索対象コーパスを作ります。そしてそれぞれでIDF辞書を作り、検索対象コーパスにおける検索精度を比較すると、後者のほうが有意に高いという結果になりました。

おそらくですが、検索対象には正例が含まれるため、正例に含まれる出現頻度が上がってしまい、IDFが小さく計算されることによって、正例が上位になりにくくなったのだと思われます。このような効果を気にする状況は、現実的にはあまり起こらないと思いますが、面白かったので紹介しました。

# 実験設定
## データセット
検索評価のデータセットとしてMIRACLを使います。

HuggingFace: https://huggingface.co/datasets/miracl/miracl
論文: https://arxiv.org/pdf/2210.09984.pdf

MIRACLはクエリと対応するWikipdeiaの記事で構成される多言語の検索評価用データセットです。日本語は860個のクエリとクエリに対する正例2000件ほどを含む約695万件の検索対象文書が用意されています。

## 条件
正例を含む検索コーパスと含まない非検索コーパスがある前提で次の２条件を比較します。
### 検索コーパス条件
検索対象とする文書からIDFを計算し、検索コーパスを検索したときの精度を求めます。
### 非検索コーパス条件
検索対象とする文書とは重複しない同サイズの文書からIDFを計算し、検索コーパスを検索したときの精度を求めます。

## 評価指標
recall@nを複数回計算し、条件間で平均値を比較します。

## その他細かい設定
クエリ３０件、コーパスサイズ500のときのrecall@5を100回計算し、条件間で平均値を比較します。
コーパスは検索コーパスと非検索コーパスの２種類用意します。検索コーパスはクエリ３０件の正例を含み、正例以外はランダムに取得された500件のコーパスです。非検索コーパスは検索コーパスと重複しないようにランダムに取得された500件のコーパスです。クエリおよび各コーパスは試行のたびにランダムに生成します。

# 実装
MIRACLデータセットの使い方は以下を参考にしました。
https://github.com/oshizo/JapaneseEmbeddingEval

まずライブラリをimportします。また、比較するコーパスサイズのリストを作ります。

```Python
import json
import os
import datasets
import tqdm
import MeCab
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.feature_extraction.text import CountVectorizer
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd

import dotenv
dotenv.load_dotenv()
```

HuggingFaceからmiraclのデータセットをダウンロードします。
環境変数にHuggingFaceのアクセストークンをセットする必要があります。
full_dsがクエリと正例の情報を含みます。full_corpusは全コーパスです。

```Python
# query and positives
full_ds = datasets.load_dataset(
    "miracl/miracl", "ja", use_auth_token=os.environ["HF_ACCESS_TOKEN"], split="dev"
)

# all corpus texts
full_corpus = datasets.load_dataset("miracl/miracl-corpus", "ja")
```

分かち書き用の関数を定義します。

```Python
mecab = MeCab.Tagger("-Owakati")

def tokenize_jp(text: str) -> str:
    tokens = mecab.parse(text).split()
    return tokens
```

クエリとコーパスをランダムに取得する関数を作ります。

```Python
def get_corpus(query_size, corpus_size):
    # ランダムにクエリを選択
    ds = full_ds.select(random.sample(range(len(full_ds)), query_size))

    positive_corpus_json = []
    query_texts = []
    done_docids = set()
    for item in ds:
        query_texts.append(tokenize_jp(item["query"]))
        for pp in item["positive_passages"]:
            if pp["docid"] in done_docids:
                continue
            positive_corpus_json.append({
                "text": tokenize_jp(pp["text"]),
                "docid": pp["docid"]
            })
    positive_docids = set([x["docid"] for x in positive_corpus_json])

    # ランダムにコーパスを選択
    max_corpus_size = corpus_size*2 + len(positive_docids)
    corpus_without_positive = full_corpus["train"].select(random.sample(range(len(full_corpus["train"])), max_corpus_size)).filter(lambda x: x["docid"] not in positive_docids)
    corpus_without_positive_json = [{"docid": doc["docid"], "text": tokenize_jp(doc["text"])} for doc in corpus_without_positive]

    train_corpus = corpus_without_positive_json[:corpus_size]
    test_corpus = positive_corpus_json + corpus_without_positive_json[corpus_size:corpus_size*2-len(positive_corpus_json)]
    assert len(test_corpus) == corpus_size
    assert len(train_corpus) == corpus_size
    return ds, query_texts, train_corpus, test_corpus

```

recallの計算を100回実施します。

```Python
CORPUS_SIZE = 500
QUERY_SIZE = 30
REPEAT_NUM = 100
train_recalls = []
test_recalls = []
for _ in tqdm.tqdm(range(REPEAT_NUM)):
    ds, query_texts, train_corpus, test_corpus = get_corpus(QUERY_SIZE, CORPUS_SIZE)
    
    def calc_result(test_corpus, vectorizer, n = 5):
        global query_texts, ds
        
        test_matrix = vectorizer.transform([doc["text"] for doc in test_corpus])
        query_matrix = vectorizer.transform(query_texts)

        # 類似度行列を計算し、queryのdocidのランクを取得
        similarity_matrix = cosine_similarity(query_matrix, test_matrix)
        ranking_matrix = np.argsort(similarity_matrix, axis=1)[:, ::-1]

        test_docid2indice = {item["docid"]: i for i, item in enumerate(test_corpus)}    

        query_result = []
        for item, ranking in zip(ds, ranking_matrix):
            # rankingの何番目にdocidがあるかを取得
            docids = [pp["docid"] for pp in item["positive_passages"]]
            docid_indices = [test_docid2indice[docid] for docid in docids if docid in test_docid2indice]
            ranks = [list(ranking).index(docid_index) for docid_index in docid_indices]
            query_result.append({
                "query_id": item["query_id"],
                "ranks": ranks
            })
        
        # recall@nを計算
        recall_at_n = np.mean([np.mean([1 if rank < n else 0 for rank in item["ranks"]]) for item in query_result])
        return recall_at_n
    full_vocabulary = set()

    for query_text in query_texts:
        full_vocabulary.update(query_text)
    for doc in train_corpus + test_corpus:
        full_vocabulary.update(doc["text"])

    train_vectorizer = TfidfVectorizer(analyzer=lambda x: x, vocabulary=full_vocabulary)
    train_vectorizer.fit([x["text"] for x in train_corpus])

    test_vectorizer = TfidfVectorizer(analyzer=lambda x: x, vocabulary=full_vocabulary)
    test_vectorizer.fit([x["text"] for x in test_corpus])
    
    train_recall = calc_result(test_corpus, train_vectorizer, 5)
    test_recall = calc_result(test_corpus, test_vectorizer, 5)

    train_recalls.append(train_recall)
    test_recalls.append(test_recall)

# 平均と標準偏差を計算
train_recall_mean = np.mean(train_recalls)
train_recall_std = np.std(train_recalls)
test_recall_mean = np.mean(test_recalls)
test_recall_std = np.std(test_recalls)
# 表示
print(f"train recall@5: {train_recall_mean} ± {train_recall_std}")  
print(f"test recall@5: {test_recall_mean} ± {test_recall_std}")   
```

対応ありのt検定で結果を比較します。

```Python
# recallsを対応ありのt検定で比較
from scipy import stats
t, p = stats.ttest_rel(train_recalls, test_recalls)
print(f"t検定: t={t}, p={p}")
```

# 結果
各条件におけるrecallの平均値と標準偏差は以下の通りです。trainが非検索コーパス、testが検索コーパスです。

```
train recall@5: 0.8964511664261664 ± 0.04661392374955171
test recall@5: 0.8862122775372776 ± 0.04984320112025396
```

対応ありt検定の結果は以下です。5%水準で有意なので、非検索コーパス条件のほうが有意に検索精度が高いということになります。

```
t検定: t=5.004463367082107, p=2.435972729064351e-06
```

# 考察
有意に非検索コーパス条件のほうが検索精度が高く、直感とは逆の結果です。直感的には、IDFはコーパスに依存する値ですから、検索対象のコーパスから求めたIDFを用いた方が、精度が上がりそうですが、そうはなりませんでした。

コーパスサイズとクエリ数は今回、固定にしましたが、手元でより少ない試行回数でラフに試した範囲では、コーパスサイズやクエリ数を増やしても減らしても同様の傾向でした。今回の実験設定が特別ということではないです。

理由として考えられるのは、検索対象コーパスには正例が含まれるため、正例に含まれる語彙の希少性が薄れ、IDFが小さくなってしまうことです。

例えば、ループの途中の状態を取得して、とあるクエリに含まれる語彙のIDFをtrain_vectorizerとtest_vectorizerで比較してみます。

```Python
print(query_texts[0])
id = train_vectorizer.vocabulary_["サッカー"]
print("サッカー")
print(train_vectorizer.idf_[id])
print(test_vectorizer.idf_[id])
id = train_vectorizer.vocabulary_["発祥"]
print("発祥")
print(train_vectorizer.idf_[id])
print(test_vectorizer.idf_[id])```
```

```
['サッカー', 'の', '発祥', '地', 'は', 'どこ']
サッカー
5.830311739964975
5.270695952029551
発祥
7.2166061010848646
5.607168188650765
```

「サッカー」も「発祥」も検索対象コーパスから計算されたIDFが小さくなっています。したがって、これらの語彙が検索順位に与える影響は相対的に弱まってしまい、正例が上位になりにくくなることが考えられます。

なぜこのようなことが起こるかというと、検索コーパスも非検索コーパスもMIRACLという共通のコーパスのサブセットであり、似た性質のデータだったため、正例を含むことの影響が見えやすくなったのかもしれません。

もし上記の考察が正しければ、検索コーパスからIDF計算時は正例を除くようにすれば、差がなくなるはずです。試してみます。

```Python
CORPUS_SIZE = 500
QUERY_SIZE = 30
REPEAT_NUM = 100
train_recalls = []
test_recalls = []
for _ in tqdm.tqdm(range(REPEAT_NUM)):
    ds, query_texts, train_corpus, test_corpus = get_corpus(QUERY_SIZE, CORPUS_SIZE)
    # test_corpusからpositive_passageを除いたコーパスを作成
    docids = set([pp["docid"] for item in ds for pp in item["positive_passages"]])
    test_corpus_without_positive = [doc for doc in test_corpus if doc["docid"] not in docids]
    assert len(docids & set([item["docid"] for item in test_corpus_without_positive])) == 0
    # train_corpusのサイズをtest_corpus_without_positiveに合わせる
    train_corpus = train_corpus[:len(test_corpus_without_positive)]
    
    def calc_result(test_corpus, vectorizer, n = 5):
        #global query_texts, ds
        
        test_matrix = vectorizer.transform([doc["text"] for doc in test_corpus])
        query_matrix = vectorizer.transform(query_texts)

        # 類似度行列を計算し、queryのdocidのランクを取得
        similarity_matrix = cosine_similarity(query_matrix, test_matrix)
        ranking_matrix = np.argsort(similarity_matrix, axis=1)[:, ::-1]

        test_docid2indice = {item["docid"]: i for i, item in enumerate(test_corpus)}    

        query_result = []
        for item, ranking in zip(ds, ranking_matrix):
            # rankingの何番目にdocidがあるかを取得
            docids = [pp["docid"] for pp in item["positive_passages"]]
            docid_indices = [test_docid2indice[docid] for docid in docids if docid in test_docid2indice]
            ranks = [list(ranking).index(docid_index) for docid_index in docid_indices]
            query_result.append({
                "query_id": item["query_id"],
                "ranks": ranks
            })
        
        # recall@nを計算
        recall_at_n = np.mean([np.mean([1 if rank < n else 0 for rank in item["ranks"]]) for item in query_result])
        return recall_at_n
    full_vocabulary = set()

    for query_text in query_texts:
        full_vocabulary.update(query_text)
    for doc in train_corpus + test_corpus:
        full_vocabulary.update(doc["text"])

    train_vectorizer = TfidfVectorizer(analyzer=lambda x: x, vocabulary=full_vocabulary)
    train_vectorizer.fit([x["text"] for x in train_corpus])

    test_vectorizer = TfidfVectorizer(analyzer=lambda x: x, vocabulary=full_vocabulary)
    test_vectorizer.fit([x["text"] for x in test_corpus_without_positive])
    
    train_recall = calc_result(test_corpus, train_vectorizer, 5)
    test_recall = calc_result(test_corpus, test_vectorizer, 5)

    train_recalls.append(train_recall)
    test_recalls.append(test_recall)

# 平均と標準偏差を計算
train_recall_mean = np.mean(train_recalls)
train_recall_std = np.std(train_recalls)
test_recall_mean = np.mean(test_recalls)
test_recall_std = np.std(test_recalls)
# 表示
print(f"train recall@5: {train_recall_mean} ± {train_recall_std}")  
print(f"test recall@5: {test_recall_mean} ± {test_recall_std}")   
```

```
train recall@5: 0.8850380832130831 ± 0.04741114869844214
test recall@5: 0.8847444324194323 ± 0.0484814206153425
```

```Python
# recallsを対応ありのt検定で比較
from scipy import stats
t, p = stats.ttest_rel(train_recalls, test_recalls)
print(f"t検定: t={t}, p={p}")
```

```
t検定: t=0.1411137642080945, p=0.888066811464762
```

期待通り有意ではなくなりました。ただ、正例の効果が除外された状態では、検索コーパス条件のほうが検索精度が高くなっても良いと思っていたのですが、そういうわけでもなさそうです。これはcorpus_sizeによる違いがひょっとしたらあるかもしれません。また気が向いたら調べてみます。

# おわりに
TFIDF検索で、検索対象と同じコーパスでIDFを求めると、そうでない場合に比べ、検索精度が低下する場面があることを確認しました。これは検索コーパスと非検索コーパスが同じコーパスのサブセットという特殊な状況であったためと思われます。したがって、現実の多くの場面では、素直に、検索対象からIDFを求めるのが良いと思います。

制限事項として、MIRACL以外のコーパスで同様の傾向があるかはまだ検証していません。また実装や考え方にミスがある可能性もあります。もし気づいたことがあったら教えてください。