雑記: 百人一首に「秋風」が登場する歌は3首あるので逆文書頻度は log(100/3)

まとめ

sklearn.feature_extraction.text.TfidfVectorizer で TF-IDF 値を出すとデフォルトでは[その文書内でのその単語の出現回数]×[log( (1 + 全文書数) / (1 + その単語が出現する文書数) ) + 1]を文書ごとに L2 正規化したものである。

参考文献

  1. tf-idf - Wikipedia(2022年6月5日参照).
  2. sklearn.feature_extraction.text.TfidfVectorizer — scikit-learn 1.1.1 documentation(2022年6月5日参照).
  3. 6.2. Feature extraction — scikit-learn 1.1.1 documentation(2022年6月5日参照).



参考文献 [1] によると TF-IDF とは、文書集合内の各文書内の各単語に定義される数値であって、その文書内でその単語が占める割合(TF)と、全文書のうちその単語が出現する文書が占める割合の逆数の対数(IDF)をかけたものである。ただしこれは参考文献 [1] の表で「標準的な」とされている定義であり、TF にも IDF にも亜種は多い。ともかく標準的な TF-IDF の定義を素朴に計算してみる。適当な文書集合が思いつかないので百人一首を使用する(Wikipedia の「百人一首」の「歌一覧」の表記を採用する)。そうすると以下のようになる。

import numpy as np
import pandas as pd
pd.set_option('display.unicode.east_asian_width', True)
import MeCab
# 関係ないが NEologd を使用するとかえって「神のまにまに」などが1形態素になる
# tagger = MeCab.Tagger('-u "C:/Program Files/MeCab/dic/ipadic-neologd/NEologd.dic"')
tagger = MeCab.Tagger()

def to_words(s):  # 文章を単語にする関数であれば何でもよい
    tokens = tagger.parse(s).split('\n')
    tokens = [t.split('\t') for t in tokens if len(t) > 1]
    tokens = [t[0] for t in tokens if len(t) > 1]
    return tokens

docs = {}  # 文書IDをキーに各文書の情報を格納する辞書
vocab = {}  # 単語をキーに各単語の情報を格納する辞書
with open('data.txt') as ifile:  # 百人一首を各行に記述したテキストファイル
    for i, line in enumerate(ifile):
        s = line.strip()
        words = to_words(s)
        n_word = len(words)
        docs[i + 1] = {'文書': s, '単語列': words, '出現頻度': {}, 'TF': {}}
        words_unique = list(dict.fromkeys(words))
        for word in words_unique:
            freq = words.count(word)
            docs[i + 1]['出現頻度'][word] = freq
            docs[i + 1]['TF'][word] = float(freq) / n_word
            if word not in vocab:
                vocab[word] = {'出現文書ID': []}
            vocab[word]['出現文書ID'].append(i + 1)

n_doc = len(docs)
for word, d in vocab.items():
    doc_freq = len(d['出現文書ID'])
    vocab[word]['文書頻度'] = doc_freq
    vocab[word]['IDF'] = np.log(n_doc / doc_freq)

for id_ in range(1, 101):
    docs[id_]['文書頻度'] = {}
    docs[id_]['IDF'] = {}
    docs[id_]['TF-IDF'] = {}
    for word, freq in docs[id_]['TF'].items():
        docs[id_]['文書頻度'][word] = vocab[word]['文書頻度'] 
        idf = vocab[word]['IDF'] 
        docs[id_]['IDF'][word] = idf
        docs[id_]['TF-IDF'][word] = freq * idf
        

print('文書数', n_doc)
print('語彙数', len(vocab))

id_ = 79
print(f'\n◆ {id_}首目の情報')
print(docs[id_]["単語列"])
print(f'{len(docs[id_]["単語列"])} 単語 ({len(docs[id_]["出現頻度"])} ユニーク単語)')
print(pd.DataFrame({k: docs[id_][k].values() for k
                    in ['出現頻度', 'TF', '文書頻度', 'IDF', 'TF-IDF']},
                   index=docs[id_]['出現頻度'].keys()))
文書数 100
語彙数 633

◆ 79首目の情報
['秋風', 'に', '棚引く', '雲', 'の', '絶間', 'より',
 'もれ', '出', 'づる', '月', 'の', '影', 'の', 'さやけ', 'さ']
16 単語 (14 ユニーク単語)
        出現頻度      TF  文書頻度       IDF    TF-IDF
秋風           1  0.0625         3  3.506558  0.219160
に             1  0.0625        58  0.544727  0.034045
棚引く         1  0.0625         1  4.605170  0.287823
雲             1  0.0625         5  2.995732  0.187233
の             3  0.1875        85  0.162519  0.030472
絶間           1  0.0625         1  4.605170  0.287823
より           1  0.0625         4  3.218876  0.201180
もれ           1  0.0625         1  4.605170  0.287823
出             1  0.0625        10  2.302585  0.143912
づる           1  0.0625         1  4.605170  0.287823
月             1  0.0625        11  2.207275  0.137955
影             1  0.0625         1  4.605170  0.287823
さやけ         1  0.0625         1  4.605170  0.287823
さ             1  0.0625        10  2.302585  0.143912

上では 79 首目の情報のみ表示しているが、この歌は 16 単語からなるので各単語の相対頻度である TF は 0.0625 である。しかし、「の」だけは 3 回登場するので 3 倍になっている。また、「秋風」が登場する歌はこの歌を含めて 3 首あり、「秋風」の IDF は log(100/3)≒3.5 になっている。他方、「棚引く」はこの歌にしか登場しないので IDF は log(100/3)≒4.6 になっている。結果、「秋風」「棚引く」の TF-IDF は 0.219, 0.288 になっている、などがわかる。


scikit-learn で同じ処理をしたい。feature_extraction.text.TfidfVectorizer [2] を使う。文書を単語に分割する関数はさっきと同じものを使う。と、当然語彙数は同じになる。しかし、「秋風」「棚引く」の TF-IDF は 0.274, 0.318 になっており、さっきとはずれている。というか IDF が既にずれている。scikit-learn の IDF の方が大きい。

import warnings
from sklearn.feature_extraction.text import TfidfVectorizer
warnings.simplefilter('ignore', UserWarning)

vectorizer = TfidfVectorizer(tokenizer=to_words)
texts = np.array([docs[id_]['文書'] for id_ in range(1, 101)])
vectorizer.fit(texts)
tfidf = vectorizer.transform(texts)
feature_names = vectorizer.get_feature_names_out()

print('文書数', tfidf.shape[0])
print('語彙数', len(feature_names))

id_ = 79
print(f'\n◆ {id_}首目の情報')
print('\tIDF\t\t\tTF-IDF')
v = tfidf[id_ - 1].todense()
for idx in tfidf[id_ - 1].indices:
    print(f'{feature_names[idx]}\t{vectorizer.idf_[idx]}\t{v[0, idx]}')
文書数 100
語彙数 633

◆ 79首目の情報
	IDF			TF-IDF
雲	3.8233610476132043	0.24736881778628844
絶間	4.921973336281314	0.3184482737071388
秋風	4.228826155721369	0.2736021300990034
棚引く	4.921973336281314	0.3184482737071388
月	3.130213867053259	0.20252267417815306
影	4.921973336281314	0.3184482737071388
出	3.217225244042889	0.20815225014334174
より	4.005682604407159	0.25916489652419133
もれ	4.921973336281314	0.3184482737071388
の	1.160773220587752	0.2253036757860151
に	1.53758307293554	0.09948056232819362
づる	4.921973336281314	0.3184482737071388
さやけ	4.921973336281314	0.3184482737071388
さ	3.217225244042889	0.20815225014334174


そこで参考文献 [3] をみると scikit-learn の TF-IDF はデフォルトで以下の (1) (2) (3) の定義を採用しているようなので素朴な計算においてこれらの定義をそろえれば scikit-learn の結果とそろう。

from sklearn.preprocessing import normalize

for id_ in range(79, 80):
    docs[id_]['スムーズIDF'] = {}
    v = []
    for word, freq in docs[id_]['出現頻度'].items():  # (1) TF は相対度数ではない生の出現頻度
        # (2) IDF は分子と分母に1を足しさらに全体に1を足す
        idf_ = np.log((1.0 + n_doc) / (1.0 + vocab[word]['文書頻度'])) + 1.0
        docs[id_]['スムーズIDF'][word] = idf_
        v.append(freq * idf_)
    v = normalize(np.array([v]), norm='l2')  # (3) L2正規化する
    docs[id_]['正規化スムーズTF-IDF'] = {}
    for i, (word, _) in enumerate(docs[id_]['TF'].items()):
        docs[id_]['正規化スムーズTF-IDF'][word] = v[0, i]

id_ = 79
print(f'\n◆ {id_}首目の情報')
print(f'{len(docs[id_]["単語列"])} 単語 ({len(docs[id_]["出現頻度"])} ユニーク単語)')
print(pd.DataFrame({k: docs[id_][k].values() for k
                    in ['出現頻度', 'スムーズIDF', '正規化スムーズTF-IDF']},
                   index=docs[id_]['出現頻度'].keys()))
◆ 79首目の情報
16 単語 (14 ユニーク単語)
        出現頻度  スムーズIDF  正規化スムーズTF-IDF
秋風           1     4.228826              0.273602
に             1     1.537583              0.099481
棚引く         1     4.921973              0.318448
雲             1     3.823361              0.247369
の             3     1.160773              0.225304
絶間           1     4.921973              0.318448
より           1     4.005683              0.259165
もれ           1     4.921973              0.318448
出             1     3.217225              0.208152
づる           1     4.921973              0.318448
月             1     3.130214              0.202523
影             1     4.921973              0.318448
さやけ         1     4.921973              0.318448
さ             1     3.217225              0.208152