TF-IDFのお勉強

少し前に会社の勉強会で発表した資料をブログにも転記しておきます。TF-IDFは自然言語処理の初心者にもとっつきやすく理解しやすい内容でした。

Wikipediaの記載に基づき手計算

概要

Wikipediaには以下のような説明がなされています。

TF-IDFは、文書中に含まれる単語の重要度を評価する手法の1つであり、主に情報検索やトピック分析などの分野で用いられている。 TF(英: Term Frequency、単語の出現頻度)と IDF(英: Inverse Document Frequency、逆文書頻度) の二つの指標に基づいて計算される。

TF(単語出現頻度)とIDF(逆文書頻度)の二つの指標を元に文書中の単語の重要度を評価する手法であることがわかります。

Wikipediaの計算式

計算式を見ると、TF-IDF値は、TF値とIDF値を掛け合わせたものであることが分かります。

TF値は文書中の単語出現頻度のことです。この計算式においては、例えばI have a pen. I have an apple.の中に単語haveは、出現回数2を全単語数8で割った 2/8=0.25になります。文書中に頻繁に登場する単語はこの値が大きくなり重要と判断されます。

IDF値はある単語が全文書中いくつの文書に登場するか(=文書頻度)の逆数です。aIなど一般的に登場するような単語は、文書頻度が高くなるので、その逆数である逆文書頻度は当然小さくなります。つまり、一般的に登場するような単語はこの数値が小さくなり、重要ではないと判断されます。IDFはその母集団における一般語を除外する一般語フィルターとして働くことになります。

{ \displaystyle

tfidf_{i,j} = tf_{i,j} * idf_{i} = \frac{n_{i,j}}{\sum_{k}n_{k,j}} * log\frac{\vert D \vert}{\vert \{ d: d \ni t_{i} \}\vert}\\
 n_{i,j} : 文書j中の単語iの出現回数\\
 \vert D \vert : 総文書数\\
 \vert \{ d: d \ni t_{i} \}\vert : 単語iが含まれる文書数

}

手計算してみる

文書1: I have a red pen and a blue pen
文書2: I like red
文書3: You have a pen
文書4: I have a red mechanical pencil

それでは、上記の4文書において、TF-IDF値を実際に計算してみます。全部計算するのは大変なので、3単語について計算してます。

{ \displaystyle

文書1のblue: tfidf_{blue,1} = \frac{1}{9} * log\frac{4}{1} = 0.066\\
文書1のred: tfidf_{red,1} = \frac{1}{9} * log\frac{4}{3} = 0.013\\
文書2のred: tfidf_{red,2} = \frac{1}{3} * log\frac{4}{3} = 0.041\\
}

gensimのTfidfModelを使って実装

python実装

gensimのTfidfModelを使って実装しました。横スクロールしてみにくいですが、実装自体は非常にシンプルですね。

from typing import List, Tuple
import string
import decimal
from decimal import Decimal

import nltk
from nltk import tokenize
from nltk.stem.porter import PorterStemmer
from nltk.corpus import stopwords
from gensim import corpora
from gensim import models
from gensim.interfaces import TransformedCorpus

nltk.download('punkt')
nltk.download('stopwords')


def tfidf(sentences: List[str]) -> TransformedCorpus:
    # 単語分割
    words_list: List[List[str]] = list(tokenize.word_tokenize(sentence) for sentence in sentences)
    # 小文字化
    words_list = list(list(word.lower() for word in words) for words in words_list)
    # 辞書作成(単語文字列 -> 単語ID)
    dictionary: corpora.Dictionary = corpora.Dictionary(words_list)
    # コーパス化
    corpus: List[List[Tuple[int, int]]] = list(map(lambda words: dictionary.doc2bow(words), words_list))
    # TF-IDF モデル生成
    tfidf_model: models.TfidfModel = models.TfidfModel(corpus)    
    # モデル適用
    tfidf_corpus: TransformedCorpus = tfidf_model[corpus]
    return tfidf_corpus

sentences: List[str] = [
    'I have a red pen and a blue pen',
    'I like red',
    'You have a pen', 
    'I have a red mechanical pencil',
]
tfidf(sentences)

実装内容を順に解説してみます。

  • 単語分割
    • nlktのtokenizeを使って文書を分割して単語のリストにしています
  • 辞書作成
    • 出現する単語に対して、ID(連番)を振っています
  • コーパス
    • コーパスとは、自然言語処理をしやすいように文書を構造化したもので、多くはベクトル表現のことをさすようです
    • doc2bowを使ってBoW(Bag of Words)化しています。Bag of Wordsは直訳すると単語の袋です。同一の単語を袋にまとめて、単語毎の出現回数を数えるようなイメージで捉えています
  • TF-IDFモデル生成
    • ここでは実際の計算はおこなわれません。あくまで、TF-IDFの計算に必要な変数を裏で保持しておくことが目的です。具体的には、全文書数ある単語が出現する文書数などだと思います
  • モデル適用
    • モデルに対してTF-IDF値を計算したい文書ベクトルを与えることで、TF-IDF値を計算します

計算結果

以下のような計算結果となりました。

[('a', 0.2284), ('and', 0.5504), ('blue', 0.5504), ('have', 0.1142), ('i', 0.1142), ('pen', 0.5504), ('red', 0.1142)]
[('i', 0.1991), ('red', 0.1991), ('like', 0.9595)]
[('a', 0.1795), ('have', 0.1795), ('pen', 0.4326), ('you', 0.8651)]
[('a', 0.1408), ('have', 0.1408), ('i', 0.1408), ('red', 0.1408), ('mechanical', 0.6785), ('pencil', 0.6785)]

and you blue pen like pencil mechanicalなどが0.5を超える値になっています。and youのような一般的な単語が高い値になっているので違和感がありますが、計算式を考えると重要度が高いものが値が高くなっている印象はあります。

Wikipediaとgensimで計算結果が異なる理由

比較結果

残念ながら、Wikipediaを元にした手計算とgensimのTfidfModelでの計算結果は数値の傾向は似ているようですが異なります。これはgensimのデフォルトのTF-IDFの計算式がWikipediaの計算式と異なるためです。

{ \displaystyle

Wikipedia: tfidf_{blue,1} = 0.066 ,  tfidf_{red,1} = 0.013,  tfidf_{red,2} = 0.041\\
gensim: tfidf_{blue,1} = 0.550 ,  tfidf_{red,1} = 0.114,  tfidf_{red,2} = 0.199
}

gensimの計算式

gensimのTfidfModelのドキュメントを見ると以下のようなことが書いてあります(適当に意訳してあります)。

TF-IDFの計算は、単語頻度と逆文書頻度を掛けた結果の文書ベクトルの長さを単位長にノーマライズすることで算出しており、母集団文書群Dのコーパスにおける文書jに含まれる単語iの正規化前の重みは以下の計算式で表される。

{ \displaystyle

weight_{i,j} = frequency_{i,j} * log_{2}\frac{D}{document\_freq_{i}}
}

この中には、具体的な計算式が書いてないですが、ドキュメントを少し眺めていると、smartirsというオプションがあり、このオプションでTF IDF 文書lengthの正規化 の三つの計算方式を指定することができます。

デフォルトだとnfcなので、具体的な計算式はこちらを参照して確認できます。Wikipediaの記述に合わせて変数名を少し読み替えて説明していこうと思います。

TF

{ \displaystyle
f_{i,j}: 文書j中の単語iの出現回数
}

Wikipediaでは文書jの単語数で割ることで文書lengthの違いを正規化していましたが、こちらでは割っていません。文書lengthの正規化を別で行なっているためですね。

IDF

{ \displaystyle

log_{2}\frac{D}{\vert \{ d: d \ni t_{i} \}\vert}: 単語iが含まれる文書数を総文書数で割った値(文書頻度)の逆数の対数をとったもの
}

Wikipediaは、底10で対数をとっていましたが、こちらでは底2で対数をとっている点が異なっています。

文書lengthの正規化

{ \displaystyle
\sqrt{\sum^{t}_{i=1}w^{2}_{i,j}}: 文書jに含まれる全単語の重みの二乗の合計の平方根
}

文書jに含まれる全単語の重みの二乗の合計の平方根とは、文書jの重みベクトルのトルク、つまり、ベクトルの大きさを表しています。 TFおよびIDFで計算した文書j中の各単語の重みをトルクで割ることによって、文書の重みベクトルの大きさを1に正規化しています。生成したベクトルのユークリッド距離をとって類似度を比較したりするのであれば、この正規化は必須ですね。逆にコサイン類似度で比較するのであれば、この正規化は不要そうです。

最終的な計算式

この三つの計算式をまとめた最終的な計算式は、以下のようになります。

{ \displaystyle

tfidf_{i,j} = \frac{f_{i,j} * log_{2}\frac{D}{\vert \{ d: d \ni t_{i} \}\vert}}{\sqrt{\sum^{t}_{i=1}w^{2}_{i,j}}}
}

手計算してみる

この計算式にしたがって文書2I like redについて計算した結果は以下のようになります。

{ \displaystyle
w_{I,2} = 1 * log_{2}\frac{4}{3} = 0.415\\
w_{like,2} = 1 * log_{2}\frac{4}{1} = 2.000\\
w_{red,2} = 1 * log_{2}\frac{4}{3} = 0.415\\
norm_{2} = \sqrt{0.415^{2} + 2.000^{2} + 0.415^{2}} = 2.084\\

tfidf_{I,2} = \frac{w_{I,2}}{norm_{2}} = \frac{0.415}{2.084} = 0.1991\\
tfidf_{like,2} = \frac{w_{like,2}}{norm_{2}} = \frac{2.000}{2.084} = 0.9595\\
tfidf_{red,2} = \frac{w_{red,2}}{norm_{2}} = \frac{0.415}{2.084} = 0.1991\\
}

一方で、gensimをつかったプログラムの結果は以下の通りです。

[('i', 0.1991), ('red', 0.1991), ('like', 0.9595)]

完全一致しましたね!

[蛇足] stopwordを除外することで重み付けの結果を改善

本筋とは関係ないいですが、上記で記載した実装ではand youなどが重要な単語としてピックアップされていて違和感がありました。これについては、前処理でstopword(一般後など自然言語処理でノイズとなるような単語)を除外する処理を追加することで改善改善できます。

    (省略)
    # 小文字化
    words_list = list(list(word.lower() for word in words) for words in words_list)
    # ストップワード、記号、1文字の英数字を除外
    stop_words: List[str] = stopwords.words('english')
    exclude_words: List[str] = stop_words + list(string.ascii_lowercase) + list(string.digits) + list(string.punctuation)
    words_list: List[List[str]] = list(
        list(word for word in words if word not in exclude_words) for words in words_list
    )
    # 辞書作成(単語文字列 -> 単語ID)
    dictionary: corpora.Dictionary = corpora.Dictionary(words_list)
    (省略)

これにより出力結果が以下のように変化します。

# inputの文書
"I have a red pen and a blue pen"
"I like red"
"You have a pen"
"I have a red mechanical pencil"
# 変更前(stopword除外なし)
[('a', 0.2284), ('and', 0.5504), ('blue', 0.5504), ('have', 0.1142), ('i', 0.1142), ('pen', 0.5504), ('red', 0.1142)]
[('i', 0.1991), ('red', 0.1991), ('like', 0.9595)]
[('a', 0.1795), ('have', 0.1795), ('pen', 0.4326), ('you', 0.8651)]
[('a', 0.1408), ('have', 0.1408), ('i', 0.1408), ('red', 0.1408), ('mechanical', 0.6785), ('pencil', 0.6785)]
# 変更後(stopword除外あり)
[('blue', 0.6996), ('pen', 0.6996), ('red', 0.1452)]
[('red', 0.2032), ('like', 0.9791)]
[('pen', 1.0)]
[('red', 0.1452), ('mechanical', 0.6996), ('pencil', 0.6996)]

これで違和感がなくなりましたね。