TF-IDF

概要

  • 文章を特徴づける単語を見つけたい
  • 今日も蒸し暑いですね。ところで、『君の名は』って映画知ってます?」という会話なら、ポイントとなるのは多分『君の名は』の部分(蒸し暑い~の部分は社交辞令だし、多分特徴として価値が小さい、なぜなら他の会話にも頻繁に出てきそうだから)
  • したがって、他の会話(他の文書データ)も観察する必要がある
  • ポイントとなる単語ほど大きな値を返す評価軸が欲しい
  • 文書全体であまり登場しない、かつ特定の文書ではよく出てくる単語(文節)がイイね(!)

(ゆる)定義

{Term\,Frequency = \frac{ある単語の出現頻度}{ある文書の総単語数}}
{Inverse\,Document\,Frequency = \log{\frac{総文書数}{ある単語を含む文書数}}}

Term Frequency(=TF)は文字通り、(ある単一の文書における)ある単語の出現頻度。ある文書においてよく出てくる単語は、その文書を特徴づける可能性が高いはず(必ずしもすべての場合においてではないが)。

Inverse Document Frequency(=IDF)は、全ての文書の中で、ある単語を含む文書の割合の逆数の対数。会話に社交辞令が含まれている場合、他の会話にも社交辞令の部分が含まれていればおそらくその部分の価値は低い。

イメージ

アイスランドに旅行に行った。
当然、周りにはアイスランド人がほとんど。(感覚的には)これがTF。TFが大きい状態。アイスランドにおけるアイスランド人の割合は当然大きい

次に、地球全体を俯瞰してみることにする。
すると、全世界でアイスランド人が一人でも住んでいる国の割合は(アイスランド人自体が少ないので多分)小さい。割合としてとても小さいので、逆数にすると逆に数値は大きくなる。ついでに対数化しとく。これが(感覚的には)IDF。

逆に、地球全体の国のうち、人間が住んでいる国の割合、とするとこれは(人が国を形成するので)1(=分母、分子が同じ)。珍しくもなんともない。逆数の1を対数化するのでlog(1)=0。特徴としての価値はない。

この両者を掛け合わせる。

IDFの態度がデカい

なぜならIDFは値が0になり得るから。TFも値が0になる可能性はあるが、そうなるのは定義より「ある文書にその単語(文節)が含まれていない場合」なので自明すぎて対象外(登場しないものの特徴としての価値は0に決まっている)。TF-IDFはTFとIDFを掛けた結果なので、IDFが0になるとTF-IDFの値は問答無用で0になるTF関係なし。「いつかはクラウン」というのは、皆がクラウンに乗れるわけではないから憧れなのであって、日本中でクラウンしか走ってないなら俺の自慢の車庫にクラウンが100台あっても(特徴としての)価値はなくなってしまう。

映画の例やらコードやら*1

TF≒「ある映画の中の、あるセリフ中の、単語の出現頻度」。
つまり「あるセリフのくどさ」。何度も同じ単語を繰り返すとセリフがくどくなる。くどいからこそ、その単語がそのセリフを特徴づけるものである可能性が出てくる。

def tf(term, doc):
    """
    Term Frequencyを算出する

    Term Frequncy = ある文書中の単語の出現頻度の割合
    """
    
    # その文書の、全ての単語数
    n_terms_all = len(doc)

    # その文書の、ある単語の出現頻度
    n_terms = float(doc.count(term))    # ※ただこれだと1-gramにしか使えない

    if n_terms == 0.0:
        return 0.0
    else:
        return n_terms / n_terms_all

映画の例 & IDFの実装

IDF≒「映画における、その単語のレア度」。
映画の中で一度しか出てこない単語のレア度は高い。

def idf(term, docs):
    """
    Inverse Document Frequencyを算出する

    Inverse Document Frequency = 
        総文書中の、対象の単語を含む文書の割合の逆数(の対数)
    """

    # 総文書数
    n_docs = len(docs)

    # 単語が登場する文書数
    n_docs_term = len([
        doc for doc in docs if term in doc
    ])

    return np.log(n_docs / n_docs_term)

まとめると

以上をまとめるとTF-IDF≒「映画中のあるセリフの、単語のくどさ x 映画全体におけるその単語のレア度=その単語の特徴としての価値」、となる。くどさxレア度

def tfidf(term, doc, docs):
    """
    TF-IDFを算出する

    TF-IDF = TF x IDF
    """
    tf_val = tf(term, doc)
    idf_val = idf(term, docs)
    
    return tf_val * idf_val

具体例(1) ~ IDFが0で全体が0になるパターン=用無し

"君"という単語はいずれの文書にも含まれているので(IDFが0となり)TF-IDF全体で0。どれだけ連呼しても0。

docs = (
    ("君", "の", "名", "は"),    # TFが0.25
    ("君", "の", "名", "は", "w", "w", "w", "w"),    # TFが0.125 ( 半分 )
    ("君", "君", "君", "君",),    # ( 階段で連呼? )
)
for doc in docs:
    print(tfidf("君", doc, docs))

# >>>
# 0.0
# 0.0
# 0.0

具体例(2) ~ IDFが非ゼロで特徴としての価値が出てくる

"w"はdocs[1]のみに含まれているため、特徴としての価値が出てくる。docs[0]に"w"は含まれていないので、TF=0となり結果は0。存在しないものについての価値は想定することができない(TFが0になるパターン)。

※なお、単純化するためにストップワードは考慮していない。というかストップワードとはそもそも「どこにでも頻繁に登場しすぎて、特徴として価値がない文節」のことであるから、IDF=0(つまりTF-IDF=0)であることが自明であるもの、という見方もできる。逆にストップワードで取り除けなかった不要な単語を、TF-IDFで取り除いている、とも。

docs = (
    ("君", "の", "名", "は"),    # TFが0.0
    ("君", "の", "名", "は", "w", "w", "w", "w"),    # TFが0.5 ( 笑い過ぎ )、"w"はこちらだけ
)
for doc in docs:
    print(tfidf("w", doc, docs))

# >>>
# 0.0
# 0.34657359027997264

おまけ (単語の頻度ベクトルとTfIdfベクトルを算出するクラスの実装)

sklearn等のそれだと、普通な単語がすべてストップワード認定されたりするので、簡易的に試したい場合に。辞書(単語数が増えればトライ構造)で実装したほうがよいとは思う(というか完成品を使うべし)。

import numpy as np
import abc

# ※上記のtfidf()を利用


class Vectorizer(metaclass=abc.ABCMeta):
    
    def __init__(self):
        self.split_lines = []
        self.uniq_words = None

    def _get_uniq_words(self, lines):
        """一意な単語リストを取得する"""
        words = []    # ※dict で実装するアイデアもあった..
        for line in lines:
            split_line = line.split(" ")
            self.split_lines.append(split_line)
            for word in split_line:
                words.append(word)
        return np.unique(words)

    @classmethod
    def _exclude_stop(cls, words, uniq_words):
        """stop-wordsを除外する"""
        z = np.zeros_like(uniq_words, dtype=int)
        for w in words:

            # stop word な部分が 1、それ以外が 0
            z += (uniq_words == w) * 1

        is_stop = z.astype(bool)
        return uniq_words[~is_stop]

    @abc.abstractmethod
    def evaluate(self, word, doc, docs):
        """単語を評価する"""
        pass

    def _to_v(self, lines, uniq_words):
        """文書を単語の頻度でベクトル化する"""
        n_words = uniq_words.shape[0]
        n_lines = len(lines)
        v = np.zeros((n_lines, n_words))
        for i, doc in enumerate(self.split_lines):
            for j, word in enumerate(uniq_words.tolist()):
                score = self.evaluate(word, doc, lines)
                v[i, j] = score
        return v

    def transform(self, lines, stopwords=()):
        
        # 単語リストを取得(一意)
        uniq_words = self._get_uniq_words(lines)
        self.uniq_words = uniq_words

        # stop wordsを取り除くならここで
        uniq_words = Vectorizer._exclude_stop(
            stopwords, uniq_words)

        # 文書をベクトル化する
        v = self._to_v(lines, uniq_words)

        return v


class CountVectorizer(Vectorizer):
    """文書を単語の頻度ベクトルに変換するクラス"""

    def evaluate(self, word, doc, docs):
        """ある文書中の、単語の頻度をカウントする"""
        return doc.count(word)


class TfIdfVectorizer(Vectorizer):
    """単語のTF-IDF値のベクトルに変換するクラス"""

    def evaluate(self, word, doc, docs):
        """ある文書中の、単語のTF-IDF値を算出する"""
        return tfidf(word, doc, docs)


def main():

    # 文書データ
    docs = (
        "君 の 名 は",
        "君 の 名 は w w w w",
    )
    
    # 単語の頻度ベクトル
    cv = CountVectorizer()
    v = cv.transform(docs)
    print(cv.uniq_words)
    print(v)

    # 単語の TF-IDF 値のベクトル
    tv = TfIdfVectorizer()
    v = tv.transform(docs)
    print(v)


if __name__ == '__main__':
    main()

# >>>
# ['w' 'の' 'は' '名' '君']
# [[0. 1. 1. 1. 1.]
#  [4. 1. 1. 1. 1.]]
# [[0.         0.         0.         0.         0.        ]
#  [0.34657359 0.         0.         0.         0.        ]]

おまけ(2) (ベクトルをきれいに表示する)

pd.DataFrameに移し替えるだけ。『wのは名君 ~ ワロタのはおうさま ~』。

import pandas as pd


def display(X, feature_names, transpose=False,
    round_digits=None):
    """文書ベクトルを見やすく表示する"""
    kwargs = {}

    if transpose:
        X = X.T
        kwargs["index"] = feature_names
    else:
        kwargs["columns"] = feature_names

    # 指定桁数で丸める
    if round_digits:
        r = np.vectorize(
            lambda v: np.round(v, round_digits)
        )
        X = r(X)

    print(pd.DataFrame(X, **kwargs))


# CountVectorizer の場合
display(X, cv.uniq_words)
# >>>
#      w    の    は    名    君
# 0  0.0  1.0  1.0  1.0  1.0
# 1  4.0  1.0  1.0  1.0  1.0

# TfIdfVectorizer の場合
display(v, tv.uniq_words, round_digits=2)
# >>>
#       w    の    は    名    君
# 0  0.00  0.0  0.0  0.0  0.0
# 1  0.35  0.0  0.0  0.0  0.0

まとめ

アイスランド行きたいアイスランド行きたいアイスランド行きたいア...

*1:『実践 機械学習システム』(Willi Rechret、オライリー社) のコードを参考にしているというかコードにすると大体こうなる