scikit-learnのSimpleImputerで欠損値の補完

とある講演を聞いていて、SimpleImputerという機能の存在を知ったのでその紹介です。(その講演自体は別のテーマで話されていたのですが、その中でSimpleImputerは常識みたいに扱われていたのに、僕は使ったことがなかったので試そうと思いました。)

これは何かというと、pandasのDataFrameやNumPyのArray中にある欠損値を補完してくれるものです。目的はpandasのDataFrameの機能でいうところのfillna()に近いですね。
fillna()で十分だという意見もあると思いますし、実際僕もfillna()で済ませてきたのでこれの存在を知らなかったのですが、ちゃんとSimpleImputerのメリットもあるのでその辺説明していきたいと思います。

ドキュメントはこちらです。
sklearn.impute.SimpleImputer — scikit-learn 1.0.1 documentation
6.4. Imputation of missing values — scikit-learn 1.0.1 documentation

version 0.20 から登場したモデルで、その前まで存在した、 sklearn.preprocessing.Imputer の置き換えとして実装されたようですね。

とりあえず補完対象となる欠損値を含むデータがないと始まらないので、適当に準備します。

import pandas as pd


# 欠損値を含むDataFrameを生成
df = pd.DataFrame(
    {
        "col1": [8, None, 6, 3, None],
        "col2": [None, 8, 2, 2, 10],
        "col3": [3, 10, None, 0, 3],
    }
)
print(df)
"""
   col1  col2  col3
0   8.0   NaN   3.0
1   NaN   8.0  10.0
2   6.0   2.0   NaN
3   3.0   2.0   0.0
4   NaN  10.0   3.0
"""

これで一部のデータが欠損しているDataFrameができましたね。それでは、SimpleImputer を使ってきましょう。SimpleImputer を使うときには、まず欠損値を埋める方法を決める必要があります。 その列の欠損してない値の 平均値、中央値、最頻値を用いて欠損値を埋めるか、もしくは定数を使って埋めることになります。

埋め方を決めたらそれは strategy 引数で指定します。対応は以下の通りです。
– mean ・・・ 平均値
– median ・・・ 中央値
– most_frequent ・・・ 最頻値
– constant ・・・ 定数 (別途、fill_value 引数で定数を指定する)

今回はお試しなので、 mean (平均値) でやってみます。

# インスタンスを生成
imp_mean = SimpleImputer(strategy='mean')
# 学習
imp_mean.fit(df)
# 学習したパラメーター(補完に使う平均値を表示)
print(imp_mean.statistics_)
# [5.66666667 5.5        4.        ]

# 欠損値を補完
imp_ary = imp_mean.transform(df)
# 結果を表示
print(imp_ary)
"""
[5.66666667 5.5        4.        ]
[[ 8.          5.5         3.        ]
 [ 5.66666667  8.         10.        ]
 [ 6.          2.          4.        ]
 [ 3.          2.          0.        ]
 [ 5.66666667 10.          3.        ]]
"""

# 補完後の型はNumpyのArraryになる
print(type(imp_ary))
# <class 'numpy.ndarray'>

fit で各列の平均値を学習し、その値を使ってNaNだったところを埋めてくれていますね。
注意しないといけないのは transform して戻ってくるデータはNumPyのArrayになっていることです。(上のサンプルコードで型を見ている通り。)

欠損値補完後のデータもDataFrameで欲しいんだという場合は再度DataFrameに変換する必要があるようです。モデルの引数でそういうオプションがあるといいのですが、今の時点のバージョン(1.0.1)ではなさそうなので自分でやりましょう。

imp_df = pd.DataFrame(imp_mean.transform(df), columns=df.columns)
print(imp_df)
"""
       col1  col2  col3
0  8.000000   5.5   3.0
1  5.666667   8.0  10.0
2  6.000000   2.0   4.0
3  3.000000   2.0   0.0
4  5.666667  10.0   3.0
"""

正直、このように単一のDataFrameにたいしてそのDataFrameの統計量を使って補完するのであれば、fillnaの方が使いやすい気がします。次のようにして同じ結果が得られるので。

print(df.fillna(value=df.mean()))
"""
       col1  col2  col3
0  8.000000   5.5   3.0
1  5.666667   8.0  10.0
2  6.000000   2.0   4.0
3  3.000000   2.0   0.0
4  5.666667  10.0   3.0
"""

これは、fillnaが列ごとに個別の値を設定できることを利用し、valueにそれぞれの列の平均値(df.mean())を渡すことによって実現しているものです。

ちなみに、SimpleImputer で strategy=’constant’ を指定する場合、fillnaのように列ごとに違う値を指定することはできません。次のように定数を一つだけ指定してそれで補完します。
この点はfillnaと比較したときに明確なデメリットですね。

imp_cons = SimpleImputer(strategy='constant', fill_value=-1)
imp_cons.fit(df)
print(imp_cons.transform(df))
"""
[[ 8. -1.  3.]
 [-1.  8. 10.]
 [ 6.  2. -1.]
 [ 3.  2.  0.]
 [-1. 10.  3.]]
"""

strategy=’constant’ の場合、補完する数値を計算する必要がないので、fit()したときに何を学習しているのか不明だったのですが、どうやらここで渡した列数を記憶しているようです。(fit に3列のDataFrameを渡すと、transformメソッドも3列のDataFrameしか受け付けなくなる。)

さて、これまでの説明だと、どうもfillna()の方が便利に思えてきますね。(Numpyにはfillnaがないので、元のデータがDataFrameではなく2次元のArrayの場合は使えるかもしれませんが。)

ここから SimpleImputer のメリットの紹介になります。

一つの目のメリットは、一度学習させた補完値を他のデータにも適用することができるということです。例えば機械学習の前処理でデータを補完する場合、訓練データの欠損値をある値で補完したのであれば、検証データの欠損値もその値で補完したいですし、本番環境にリリースして実運用が始まった後も欠損値があるデータに対して補完をかけるのであれば訓練時と同じデータで補完したいです。

SimpleImputer であれば、 fitした時点で補完する値を学習しているので、別のデータに対してtransformしてもその値を使って補完してくれます。
fillna でも、補完に使うデータをどこかに退避しておいてそれを使えばいいじゃないか、という声も聞こえてきそうですし、実際そうなのですが、fitしたモデルを保存しておいてそれを使う方が、補完に使うデータ(辞書型か配列か)をどこかに退避しておくより気楽に感じます。(個人の感想です。なぜそう感じるのかは自分でもよくわかりません。)

# 元と別のデータ
df2 = pd.DataFrame({
    "col1": [7, None, 2],
    "col2": [2, 2, None],
    "col3": [6, 8, None],
})

# 学習済みの値を使って補完される
print(imp_mean.transform(df2))
"""
[[7.         2.         6.        ]
 [5.66666667 2.         8.        ]
 [2.         5.5        4.        ]]
"""

もう一つの利点は、これがscikit-learnのモデルなので、Pipelineに組み込めるということです。完全に適当な例なのですが、
平均値で欠損値の補完 -> データの標準化 -> ロジスティック回帰
と処理するパイプラインを構築してみます。
ロジスティック回帰をやるので正解ラベルも適当に作ります。

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression


# ロジスティック回帰のサンプルを動かすために架空の正解ラベルを作る
y = [0, 0, 0, 1, 1]

# 欠損値を補完した後、StandardScalerで標準化してロジスティック回帰で予測するパイプライン
clf = Pipeline([
    ("si", SimpleImputer(strategy="mean")),
    ("ss", StandardScaler()),
    ("lr", LogisticRegression()),
])
# 学習
clf.fit(df, y)

このようにPipelineに組み込めるというのは fillna の方にはないメリットだと思います。

繰り返しますが、上の例は完全に適当に作ったパイプラインです。
一般的な話として、前処理次に欠損値を補完した後に標準化するという前処理を推奨しているわけではないのでご注意ください。あくまでも、SimpleImputerを組み込んだパイプラインは動きますということだけを示した例です。

Pythonを使ってよく連続する文字列を検索する

前回の記事で紹介したn-gram(といっても今回使うのはユニグラムとバイグラム)の応用です。
テキストデータの中から高確率で連続して登場する単語を探索する方法を紹介します。
参考: scikit-learnで単語nグラム

コーパスとして、昔作成したライブドアニュースコーパスをデータフレームにまとめたやつを使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

今回の記事で使うライブラリの読み込み、データの読み込み、さらに分かち書きに使う関数の準備とそれを使った単語の形態素解析まで済ませておきます。

import re
import pandas as pd
import MeCab
from sklearn.feature_extraction.text import CountVectorizer

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規かとアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")


def mecab_tokenizer(text):
    # テキストを分かち書きする関数を準備する
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    return " ".join(token_list)

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

さて、データの準備が整ったので本題の処理の方に移っていきましょう。
やることは簡単で、ユニグラムモデル(単語単位のBoW)とバイグラムモデル(2単語連続で学習したBoW)を作成します。そして、「単語1」の出現回数で、「単語1 単語2」の出現回数を割ることによって、「単語1」の後に「単語2」が出現する確率を求めてそれがある程度より高かったらこの2単語は連続しやすいと判断します。また逆に、「単語2」の出現回数で、「単語1 単語2」の出現回数を割って同様の判定もかけます。

そのためにまず、バイグラムとユニグラムの単語の出現回数の辞書を作成します。
それぞれモデルを作ってBoWにし、語彙と出現回数のペアの辞書へと変換します。

# モデル作成
uni_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(1, 1),
        min_df=30,
    )
bi_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(2, 2),
        min_df=30,
    )

# BoWへ変換
uni_bow = uni_model.fit_transform(df["tokens"])
bi_bow = bi_model.fit_transform(df["tokens"])

# 学習した語彙数
print(len(uni_model.get_feature_names()), len(bi_model.get_feature_names()))
# 6921 12227

# 出現回数の辞書へ変換
uni_gram_count_dict = dict(zip(
        uni_model.get_feature_names(),
        uni_bow.toarray().sum(axis=0)
    ))
bi_gram_count_dict = dict(zip(
        bi_model.get_feature_names(),
        bi_bow.toarray().sum(axis=0)
    ))

これで計算に必要な情報が揃いました。min_df は少し大きめの30にしていますが、これは利用するコーパスの大きやさかける時間、求める精度などによって調整してください。(少し大きめの値にしておかないと、特にバイグラムの語彙数が膨れ上がり、次の処理が非常に時間がかかるようになります。)

さて、これで出現回数の情報が得られたのでこれを使って「単語1」の次に来やすい「単語2」を探してみましょう。余りたくさん出てきても困るので95%以上の確率で続くなら出力するようにしたのが次のコードです。

for uni_word, uni_count in uni_gram_count_dict.items():
    # uni_word: ユニグラムモデルでカウントした単語
    # uni_count: 上記単語が出現した回数

    # 対象の単語で始まる単語ペアにマッチする正規表現
    pattern = f"^{uni_word}\\b"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.match(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        # bi_words: バイグラムモデルでカウントした単語ペア
        # bi_count: 上記単語ペアが出現した回数

        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
1677 万 73回/73回
2106 bpm 107回/107回
75m bps 68回/70回
84回 アカデミー賞 94回/96回
888 毎日 41回/42回
angrybirds 風 41回/41回
bci 3 81回/81回
blu ray 339回/339回
deji 通 614回/619回
details id 160回/162回
digi 2 322回/326回
icecream sandwich 939回/941回
kamikura digi 89回/89回
katsuosh digi 56回/56回
let s 76回/79回
-- 以下省略 -- 
"""

途中正規表現を使っていますが、これは、選択中の「単語1」に対して「単語1 単語2」という文字列に一致させるものです。^は先頭、\\b(エスケープされて実際は\b)は単語区切りにマッチします。

出力はたくさん出ますので、先頭の方を上のコード中に例示しました。
84回 アカデミー賞 94回/96回 は 「84回」って単語が96回登場し、そのうち、94回は「84回 アカデミー賞」と続いたという意味です。
1677 万 などは 1677万画素って単語の一部ですね。

続いて、ある単語の後ろではなく前に登場しやすい単語も探してみましょう。
これは、正規表現のpatternが少し違うだけです、と行きたかったのですがもう1箇所違います。re.matchが先頭マッチの探索しかしてくれないので、正規表現でマッチさせるところのメソッドがre.searchになります。

for uni_word, uni_count in uni_gram_count_dict.items():

    # 対象の単語で終わる単語ペアにマッチする正規表現
    pattern = f"\\b{uni_word}$"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.search(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
iphone 3gs 38回/38回
第 84回 96回/96回
成長率 888 41回/42回
msm 8960 118回/118回
with amazlet 38回/38回
の angrybirds 41回/41回
パック bci 81回/81回
2106 bpm 107回/109回
apps details 160回/162回
after effects 63回/64回
パッケージ ffp 77回/77回
wi fi 869回/882回
モバイルwi fiルーター 136回/136回
-- 以下省略 -- 
"""

こちらもうまく出力されましたね。読み解き方はは先ほどと同じです。

そもそも、なぜこのような処理を作ろうと思ったかと言うと、MeCab等で分かち書きした時に、本当は1単語なのに複数単語に分かれてしまっているようなものを効率よく検索したかったためです。

この記事のコードでは新語辞書(mecab-ipadic-neologd)を使ってるので、あからさまなものは少ないですが、デフォルトのIPA辞書を使うと、 クラウド が 「クラ」と「ウド」に分かれている例などがポロポロ見つかります。

この記事の目的は上のコードで果たしたので以下は補足です。

さて、今回の結果を辞書の改善等に使うことを考えると、ここで出力された単語ペアを結合させて放り込んでいけば良さそうに見えます。しかし、実際は話はそう単純ではありません。
例えば、「wi fi」とか、「モバイルwi fiルーター」といった単語が出てきていますが、なるほど、辞書にWiFIやモバイルWiFiルーターが含まれてないんだな、と勘違いしそうになります。

しかし実際は、Neologdを使うと、どちらも正しく形態素解析できていて、「Wi-Fi」や「モバイルWi-Fiルーター」と言う単語で出力されるんですね。いつ分かれているかと言うと、sickit-learnが学習する時に-(ハイフン)を単語境界文字として扱っているのでここで切ってしまっています。

同様の例として、「ウォルト ディズニー」などもあります。これも実はMeCabは「ウォルト・ディズニー」と一単語にしているのに、scikit-learnが「・」で勝手に区切ってます。

このほかにも、出力を見ていくと「くだける 充電」と言うのが出てきますが、世の中に「くだける充電」という単語があるのか、と考えると間違えます。
これは元のテキストを見ると「おくだけ充電」という言葉があり、これが、「お」「くだけの原型のくだける」「充電」と形態素解析されて後半の2単語がくっついて出てきたものです。

このほか、「特有 の」とか「非常 に」のように、確かによく連続するんだろうけど、これは単語として分かれるのが正常だよね、って言う例も多く確認はかなり手間なようでした。

そもそも出力が意外に多かったです。(サンプルコードでは0.95以上としましたが、本気で探すならこの閾値はもっと下げた方が良さそうです。しかしそれをやるとどんどん出力が増えます。)

本当は1単語なのに間違って分かれてしまっている単語を探す、と言う目的に対しては、思ったよりノイズが多かったのですが、それでもかなり有効な方法だと思うので同様の課題をお持ちの方は試してみてください。細かくは書いていませんが、形態素解析する段階で品詞を絞っておく(例えば名詞のみする)とか、てにをは的なワードを除いておくなど改善の余地多いので色々試すのも楽しいです。

scikit-learnで単語nグラム

自然言語処理の前処理に、nグラム(n-gram)という概念があります。
これは隣り合って出現したn単語のことです。nの値が小さいときは特別な名前がついていて、
n=1の場合をユニグラム(unigram)、n=2の場合をバイグラム(bigram)、n=3の場合をトライグラム(trigram)と呼びます。

ネットで検索すると、「n単語」のことではなく、隣り合って出現した「n文字」をnグラムと言うという説明も見かけた(例えば、Wikipediaもそうです)ので、誤解を避けるためこの記事のタイトルは単語nグラムとしましたが、面倒なので以下記事中でnグラムと書いたら単語nグラムを指すものとします。
「言語処理のための機械学習入門」などの書籍でも、単語nグラムのことをnグラムと呼んでいるのでおかしくはないと思っています。(P. 62)

自然言語処理を勉強し始めた駆け出しの頃、テキストをBoWでベクトル化する時に、ユニグラムだけでなく、バイグラムを加えて単語間のつながりを考慮するというアイデアを知って、これはいいアイデアだと思って試したりしました。しかし、バイグラムを使って機械学習の精度が上がった経験というのはほとんどなく、その後もたまに試すけど有効だった覚えがほぼ無くだんだん試さなくなってきていました。
しかし、最近機械学習とは少し違う目的でnグラムを使いたいことがあったので、この機会にsciki-learnでBoWを作る時のnグラム関連の引数の挙動をまとめておこうと思ったのでこの記事に整理していきます。

今回は、サンプルに使うテキストデータはあらかじめ分かち書きしたやつを用意しておきます。題材はいつもメロスなので今回は幸福の王子にしました。(青空文庫から拝借)

corpus = [
    "町 の 上 に 高い 柱 が そびえる 、 その 上 に 幸福 の 王子 の 像 が 立つ て いる ます た 。",
    "王子 の 像 は 全体 を 薄い 純金 で 覆う れる 、 目 は 二つ の 輝く サファイア で 、 王子 の 剣 の つ か に は 大きな 赤い ルビー が 光る て いる ます た 。",
    "王子 は 皆 の 自慢 です た 。",
    "「 風見鶏 と 同じ くらい に 美しい 」 と 、 芸術 的 だ センス が ある という 評判 を 得る たい がる て いる 一 人 の 市会 議員 が 言う ます た 。",
    "「 もっとも 風見鶏 ほど 便利 じゃ ない が ね 」 と 付け加える て 言う ます た 。",
    "これ は 夢想 家 だ と 思う れる ない よう に 、 と 心配 する た から です 。",
    "実際 に は 彼 は 夢想 家 なんか じゃ ない た の です が 。",
]

さて、早速やっていきましょう。
scikit-learnのテキストの前処理には、BoWを作るCountVectorizer と、 tf-idfを作るTfidfVectorizer がありますが、nグラムに関しては両方とも同じくngram_range という引数で設定することができます。(最小)何グラムから(最大)何グラムまでを学習に含めるかをタプルで指定するもので、デフォルトは、 ngram_range=(1, 1) です。(ユニグラムのみ)。

バイグラムを学習させたければ(2, 2)と指定すればよく、ユニグラムからトライグラムまで学習したいなら(1, 3)です。

とりあえず、(1, 2)でやってみます。1文字の単語も学習させるため、token_patternも指定します。

from sklearn.feature_extraction.text import CountVectorizer


# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の先頭10個
print(bow_model.get_feature_names()[:10])
# ['ある', 'ある という', 'いる', 'いる ます', 'いる 一', 'か', 'か に', 'から', 'から です', 'が']

get_feature_names() で学習した単語を取得してみましたが、「ある」「いる」「か」などの1単語の語彙に混ざって、「ある という」「いる ます」などのバイグラムの語彙も混ざっていますね。

ユニグラムとバイグラムで合計、194の語彙を学習しているので、このモデルを使ってBoWを作ると、テキスト数(7) * 語彙数(194)の疎行列になります。

print(len(bow_model.get_feature_names()))
# 194

print(bow_model.transform(corpus).shape)
# (7, 194)

さて、もう少しライブラリの挙動を詳しくみていきましょう。次はstopwordとの関連です。
CountVectorizer は stop_wordsという引数で学習に含めない単語を明示的に指定できます。
試しにてにをは的な文字をいくつか入れていみます。(ここでは学習対象はバイグラムだけにします)

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(2, 2),
    stop_words=["て", "に", "を", "は", "が", "の", "た",]
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[50: 60])
# ['夢想 家', '大きな 赤い', '実際 彼', '家 だ', '家 なんか', '市会 議員', '幸福 王子', '彼 夢想', '得る たい', '心配 する']

学習した語彙の一部を表示していますが、わかりやすいのは「幸福 王子」というペアが含まれていることです。これはもちろん「幸福 の 王子」の部分から学習されたものです。
「の」が stopwordに含まれているので、まず、「の」が取り除かれて、「幸福 王子」になってからバイグラムの学習が行われたのだとわかります。
「幸福 の」と「の 王子」を学習してからstopwordを除くわけでは無いということが確認できました。

ちなみに、token_patternがデフォルトの場合、1文字の単語は学習されませんが、この時も似たような挙動になります。

# モデル作成
bow_model = CountVectorizer(
    ngram_range=(2, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[30: 40])
# ['同じ くらい', '夢想 なんか', '夢想 思う', '大きな 赤い', '実際 夢想', '市会 議員', '幸福 王子', '得る たい', '心配 する', '思う れる']

続いて、学習結果に含まれる単語を出現頻度で間引くmid_df/max_df の挙動を確認しておきます。min_dfの方がわかりやすいのでそちらを例に使います。
min_df=3(3テキスト以上に含まれる単語だけ学習する。3回以上では無いので注意)と指定してみます。

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
    min_df=3,
)

# 学習
bow_model.fit(corpus)

# 学習した単語
print(bow_model.get_feature_names())
# ['いる', 'が', 'た', 'て', 'て いる', 'です', 'と', 'ない', 'に', 'の', 'は', 'ます', 'ます た', '王子']

「て いる」は3テキストに含まれているので学習されていますね。その一方で「王子 は」は1テキストにしか含まれていないので学習結果に含まれていません。「王子」と「は」はそれぞれ3テキスト以上に含まれていますが、「王子 は」という並びで登場したのが1回だけだったので対象外になっているのです。

このことから、min_dfによる間引きはまずn-gramを学習してその後に行われていることがわかりますね。stop_wordsと実行タイミングが違うので注意しましょう。

例示はしませんがmax_dfも話は同様です。ちなみに、 max_df=0.7 とすると、 ユニグラムの「た」は学習結果らから除外されて、バイグラムの「ます た」などは含まれることが確認できます。

kerasのTokenizerで文書をtfidfベクトル化したときの計算式について

前回で終わりにしようと思っていたのですが今回もtfidfの記事です。
あまり使う機会はないのですが、kerasのテキストの前処理機能である、
keras.preprocessing.text.Tokenizer にも実はコーパスをtfidf化する機能が実装されています。

ドキュメントを読んでもtfidf機能があること自体が書いてありません。(したがって、実装されているtfidfの定義も書いてありません。)
参考: テキストの前処理 – Keras Documentation
そして、実際に動かしてみると、一般的な定義とは違う定義で動いているようなのでどのような計算式なのか調べました。

とりあえずいつもの例文で動かしてみます。
動かし方は、インスタンスを作り、fit_on_textsでコーパスを学習した後、
texts_to_matrixで変換するときに、引数でmode="tfidf"するだけです。

やってみます。


import pandas as pd
from tensorflow.keras.preprocessing.text import Tokenizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

# データを準備
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

keras_tokenizer = Tokenizer()
keras_tokenizer.fit_on_texts(corpus)

# 学習した単語のリストを作成(インデック0はpaddingした単語に予約されている)
word_list = [
        keras_tokenizer.index_word.get(i, "")
        for i in range(len(keras_tokenizer.index_word)+1)
    ]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
      this     is    the  document  first  second    and  third    one
0    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
1    0.0  0.588  0.588  0.588     1.174  0.000   1.099  0.000  0.000  0.000
2    0.0  0.588  0.588  0.588     0.000  0.000   0.000  1.099  1.099  1.099
3    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
"""

出現する文書数とidfの対応は以下のようになっていそうです。
出現回数1回 ・・・ idf 1.099
出現回数2回 ・・・ idf 0.847
出現回数3回 ・・・ idf 0.693
出現回数4回 ・・・ idf 0.588

このようになる数式を探してみたところ、
$$
\text{idf}_{t} = \log{\left(1+\frac{\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}
$$
が当てはまるようです。

scikit-learnのデフォルトの定義である、
$$
\text{idf}_{t} = \log{\left(\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}+1
$$
と似てるけど微妙に違いますね。

検算します。


for n in range(1, 5):
    print(np.log(1+4/(n+1)))

"""
1.0986122886681098
0.8472978603872034
0.6931471805599453
0.5877866649021191
"""

さて、idfがわかったので次はtdです。
tdに着目しやすくするために、単語1種類だけにして、出現回数だけ変えたコーパスで実験します。


corpus_2 = [
    "document",
    "document document",
    "document document document",
    "document document document document",
]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus_2, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
     this   is  the  document  first  second  and  third  one
0    0.0   0.0  0.0  0.0     0.693    0.0     0.0  0.0    0.0  0.0
1    0.0   0.0  0.0  0.0     1.174    0.0     0.0  0.0    0.0  0.0
2    0.0   0.0  0.0  0.0     1.455    0.0     0.0  0.0    0.0  0.0
3    0.0   0.0  0.0  0.0     1.654    0.0     0.0  0.0    0.0  0.0
"""

ifidfの値が、出現回数1回の場合(=idf)の何倍になっているかみます。


print(df["document"]/df["document"].iloc[0])
"""
0    1.000
1    1.693
2    2.099
3    2.386
Name: document, dtype: float64
"""

どうやら、
$$
\text{tf}_{t,d} = 1+\log{\text{文書d中の単語tの出現回数}}
$$
のようです。

検算がこちら。


for n in range(1, 5):
    print(1+np.log(n))

"""
1.0
1.6931471805599454
2.09861228866811
2.386294361119891
"""

これで、実験的にkerasにおけるtfidfの定義がわかりました。

あとはドキュメントと突き合わせて確認したかったのですが、冒頭に書いた通りドキュメントには記載がありません。
と言うことで、ソースコードを直接みてみます。

どうやらこの部分が該当するようです。
参考: keras-preprocessing/text.py at master · keras-team/keras-preprocessing · GitHub


                elif mode == 'tfidf':
                    # Use weighting scheme 2 in
                    # https://en.wikipedia.org/wiki/Tf%E2%80%93idf
                    tf = 1 + np.log(c)
                    idf = np.log(1 + self.document_count /
                                 (1 + self.index_docs.get(j, 0)))
                    x[i][j] = tf * idf

ここまで実験的に導いてきた結論と一致しますね。
ハードコーディングされているのでscikit-learnのような細かなオプションはなさそうです。

TfidfVectorizerのtfについて

前回の記事が、TfidfVectorizerのidfにフォーカスしたので、今回はtfの方を取り上げます。

以前の記事で書いた通り、一般的なtdの定義(Wikipedia日本語版の定義)では、tfはその単語の文書中の出現頻度です。
$$
\text{tf}_{t,d} = \frac{\text{文書d中の単語tの出現回数}}{\text{文書dの全ての単語の出現回数の和}}
$$
しかし、TfidfVectorizerにおいては、単純に出現回数が採用されています。
$$
\text{tf}_{t,d} = \text{文書d中の単語tの出現回数}
$$

これは、TfidfVectorizerでは通常の設定(norm=’l2′)では文書ベクトルは最後に長さ1になるように正規化するので、
tfを定数倍しても結果が変わらず、無駄な操作になるからだと考えられます。

とりあえず、norm=Falseを指定して、正規化せずにtfがただの出現回数になっていることを見ていきましょう。
サンプルのコーパスで学習して、idfとtfidfを出してみました。
出現回数が1回の単語はidf=tfidfとなり、2回の単語は、idf*2=tdidfとなっているのがみて取れます。


# %%pycodestyle
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

# データを準備しておく
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
)
tfidf_model.fit(corpus)

tfidf = tfidf_model.transform(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf.round(3))
"""
and 1.916
document 1.223
first 1.511
is 1.0
one 1.916
second 1.916
the 1.0
third 1.916
this 1.0
"""

# tfidfの値
print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.446  0.000  1.0  0.000   1.916  1.0  0.000   1.0
2  1.916     0.000  0.000  1.0  1.916   0.000  1.0  1.916   1.0
3  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
"""

もし本来の定義で使いたいのであれば、それ専用のオプションは用意されていないので、
各文のベクトルをそれぞれの文の単語数で割ってあげる必要があります。
あまり綺麗な書き方が思いつかなかったのですが、実装するとしたら次のようなコードになるでしょうかでしょうか。
途中のif分は元の文が空だったときに0除算を発生させないためのものです。


for i in range(len(corpus)):
    word_count = len(corpus[i].split(" "))
    if word_count > 0:
        tfidf[i] = tfidf[i] / word_count

# tfidfの値
print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.245  0.302  0.200  0.000   0.000  0.200  0.000  0.200
1  0.000     0.408  0.000  0.167  0.000   0.319  0.167  0.000  0.167
2  0.319     0.000  0.000  0.167  0.319   0.000  0.167  0.319  0.167
3  0.000     0.245  0.302  0.200  0.000   0.000  0.200  0.000  0.200
"""

TfidfVectorizerのインスタンスを作るときに、use_id = False と指定すると、idfが1で統一されるので、tfの結果だけ確認できます。
要はCountVectorizerと似た挙動になりますね。(CountVectorizerは正規化しませんが)


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0       2.0    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0       0.0    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

さて、tfの値ですが、全ての単語数で割る一般的な定義を実現するオプションはなくてもそれ以外の亜種のオブションはあります。
参考: sklearn.feature_extraction.text.TfidfVectorizer

わかりやすくみるために、norm=False, usr_idf=False で試していきましょう。
まず、binaryと言う引数をTrueにすると、出現回数ではなく、出現する(1)か出現しない(0)かの2値になります。


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
    binary=True,
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0       1.0    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0       0.0    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0       1.0    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

次に、 sublinear_tfという引数にTrueを渡すと、tfが、$1+\log(\text{tf})$に置き換えられます。
$1+\log(2)=1.693\dots$に注意して結果を見てください。


tf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    use_idf=False,
    sublinear_tf=True
)
tf_model.fit(corpus)

tf = tf_model.transform(corpus)
print(pd.DataFrame(tf.toarray(), columns=tf_model.get_feature_names()))
"""
   and  document  first   is  one  second  the  third  this
0  0.0     1.000    1.0  1.0  0.0     0.0  1.0    0.0   1.0
1  0.0     1.693    0.0  1.0  0.0     1.0  1.0    0.0   1.0
2  1.0     0.000    0.0  1.0  1.0     0.0  1.0    1.0   1.0
3  0.0     1.000    1.0  1.0  0.0     0.0  1.0    0.0   1.0
"""

英語版のWikipediaにtfの亜種がいろいろ紹介されていますが、その中にもない珍しいタイプの定義です。
$\log(1+\text{tf})$ならあるのですが。
参考: tf–idf – Wikipedia (English)

一つの文章の中に同じ単語が極端に繰り返し出現した場合などに、その影響を抑えられる良い形式だと思います。

TfidfVectorizerのidfについて

この記事では、scikit-learnのTfidfVectorizerの、idf部分について掘り下げてみていきます。
以前の記事でも書いた通り、デフォルトでのidfの挙動は一般的な定義とは異なります。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

単語$t$についてみていくと、通常の定義は
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$
であり、
TfidfVectorizer のデフォルトのオプションでは、
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$
となっています。

まず、実際にこの数式通り動いていることを見ておきましょう。


import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer

# データを準備しておく
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)

"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
"""

# 出現回数から計算したidfの値
for i in range(1, 5):
    print(f"出現回数{i}回, idf: ", np.log((1+4)/(1+i))+1)
"""
出現回数1回, idf:  1.916290731874155
出現回数2回, idf:  1.5108256237659907
出現回数3回, idf:  1.2231435513142097
出現回数4回, idf:  1.0
"""

document は3文書に登場しているから、idfは 1.2231435513142097、firtstは2文書に登場しているから、idfは1.5108256237659907と言うふうに、
scikit-learnのドキュメント通りに計算されていることがわかりますね。

さて、scikit-learnの定義がデフォルトと異なっている理由は、次の2つがあります。
(1) コーパス中に登場しない単語で0除算が発生しないように、log中の分数の分子と分母に1を足す。
(2) コーパス中の全ての文書に登場した単語のidfが0にならないように全ての単語のidfに1を足す。

(1)の方は平滑化と呼ばれる操作です。
実はTfidfVectorizerは、学習する単語をコーパスから自動的にピックアップするのではなく、モデルの引数として渡すことができます。
次のコード例では、コーパスに登場しない、oovという単語を明示的に加えてみました。
結果、$\log((1+4)/(1+0))+1 = 2.6094379124341005$ がoovのidf値になっていることがわかります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
)
tfidf_model.fit(corpus)

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 1.916290731874155
document 1.2231435513142097
first 1.5108256237659907
is 1.0
one 1.916290731874155
second 1.916290731874155
the 1.0
third 1.916290731874155
this 1.0
oov 2.6094379124341005
"""

この分子と分母の+1については、smooth_idfと言う引数にFalseを渡すことで使わないようにもできます。
参考: sklearn.feature_extraction.text.TfidfVectorizer

この場合、vocabulary引数で、コーパスに登場しない単語が渡されていると、0除算のワーニングが発生し、該当単語のidf値はinfになります。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    vocabulary=['and', 'document', 'first', 'is',
                'one', 'second', 'the', 'third', 'this', "oov"],
    smooth_idf=False,
)
tfidf_model.fit(corpus)
"""
RuntimeWarning: divide by zero encountered in true_divide
  idf = np.log(n_samples / df) + 1
"""

# モデルから抽出した idfの値
for t, idf in zip(tfidf_model.get_feature_names(), tfidf_model.idf_):
    print(t, idf)
"""
and 2.386294361119891
document 1.2876820724517808
first 1.6931471805599454
is 1.0
one 2.386294361119891
second 2.386294361119891
the 1.0
third 2.386294361119891
this 1.0
oov inf
"""

tfidfの本来の定義に近い値で使う場合は、smooth_idf=Falseを指定するべきですが、この場合は安全のため、vocabularyは指定せず、
コーパスから自動的に学習するのに任せるべきでしょう。
(僕の場合は、vocabulary引数を使うことはほとんどありません。)

次に、(2)の全てのコーパスに倒叙する単語のidfが0にならないように、全部単語のidfに1足されている部分についてです。
こちらについては、これをオフにする引数のようなものは実装されていなさそうです。

どうしてもtfidfの本来の定義で使いたいんだ、と言う場合は、かなり無理矢理な操作になりますが、
モデルが学習したidfの値(idf_プロパティに格納されている)から1を引いてしまう手があります。
(当然、サポートされている操作ではないので実行は自己責任でお願いします。)

transform する際には、idf_ の値が使われるので次のようになります。


import pandas as pd
# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False,
    smooth_idf=False,
)
tfidf_model.fit(corpus)
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.575  0.000  1.0  0.000   2.386  1.0  0.000   1.0
2  2.386     0.000  0.000  1.0  2.386   0.000  1.0  2.386   1.0
3  0.000     1.288  1.693  1.0  0.000   0.000  1.0  0.000   1.0
"""

# idf_ に足されている1を引く 
tfidf_model.idf_ -= 1
tfidf = tfidf_model.transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
1  0.000     0.575  0.000  0.0  0.000   1.386  0.0  0.000   0.0
2  1.386     0.000  0.000  0.0  1.386   0.000  0.0  1.386   0.0
3  0.000     0.288  0.693  0.0  0.000   0.000  0.0  0.000   0.0
"""

コーパスの全ての文書に含まれていた、 is, the, this の idf値とtfidf値が0になっていることが確認できます。
また、他の単語についても、出現回数分値が落ちているのがみて取れます。

文書をTfidfVectorizerでベクトル化したときの正規化について

scikit-learnのtfidfについての記事の2本目です。
今回は結果のベクトルの正規化について紹介していきます。
前の記事でも書きましたが、TfidfVectorizerはデフォルトでは結果のベクトルを長さが1になるように正規化します。
参考: tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

ドキュメントのsklearn.feature_extraction.text.TfidfVectorizerのページの norm の説明にある通りです。

一応実験しておきます。サンプルの文章はUser Guideのページから拝借しました。


import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l2',  # デフォルト
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
1  0.000     0.688   0.00  0.281  0.000   0.539  0.281  0.000  0.281
2  0.512     0.000   0.00  0.267  0.512   0.000  0.267  0.512  0.267
3  0.000     0.470   0.58  0.384  0.000   0.000  0.384  0.000  0.384
"""

# ベクトルの長さが1であることの確認
print((tfidf.toarray()**2).sum(axis=1)**0.5)
# [1. 1. 1. 1.]

さて、ドキュメントにnorm{‘l1’, ‘l2’}, default=’l2’とある通り、norm='l1'と指定することもできます。
そうすると、ベクトルの長さを1にする代わりに、各要素の絶対値の和が1になるように正規化してくれます。
これも実験しておきましょう。なお、tfidfの結果は元々各成分がプラスなので、絶対値を取る処理は省略しています。
厳密にやるのであれば和をとる前にnp.abs()にかけましょう。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='l1',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first     is    one  second    the  third   this
0  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
1  0.000     0.332  0.000  0.136  0.000    0.26  0.136  0.000  0.136
2  0.219     0.000  0.000  0.114  0.219    0.00  0.114  0.219  0.114
3  0.000     0.213  0.263  0.174  0.000    0.00  0.174  0.000  0.174
"""

# ベクトルの要素の和が1であることを確認
print(tfidf.toarray().sum(axis=1))
# [1. 1. 1. 1.]

ドキュメントでは、normには'l1'と'l2'の値しかサポートされていなように書いてありますが、実は他にも指定できる文字があります。
と言うのもGithubのソースコード(この記事を書いている時点ではバージョンは0.24.0)では、次のように実装されているからです。
参考: 該当箇所


        if self.norm:
            X = normalize(X, norm=self.norm, copy=False)

sklearn.preprocessing.normalize が呼び出されてます。
そして、そのドキュメントをみると、'l1','l2'に加えて'max'も対応しています。
'max'は、一番絶対値が大きい要素の絶対値が1になるように、要するに全部の要素の値が-1から1の範囲に収まるように、そして絶対値が一番大きい成分の値が±1になるように正規化してくれます。
ドキュメントに無い使い方なので、ご利用される場合は自己責任でお願いしたいのですが、次のコードの通り問題なく動作します。


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm='max',
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
   and  document  first     is  one  second    the  third   this
0  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
1  0.0      1.00    0.0  0.409  0.0   0.783  0.409    0.0  0.409
2  1.0      0.00    0.0  0.522  1.0   0.000  0.522    1.0  0.522
3  0.0      0.81    1.0  0.662  0.0   0.000  0.662    0.0  0.662
"""

また、もう一つ、これもドキュメントには無いのですが、正規化しないようにすることも可能です。
if self.norm:
とif文で分岐していますので、ここでFalseと判定される値を入れておけば大丈夫です。
FalseやNoneを入れておけば正規化されません。
(空白文字列でも、空配列でも、0でもお好きな値を使えますが、あまりトリッキーなことをしても意味はないのでFalseで良いでしょう)


tfidf_model = TfidfVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    norm=False, 
)
# コーパスを学習
tfidf = tfidf_model.fit_transform(corpus)

print(pd.DataFrame(tfidf.toarray(), columns=tfidf_model.get_feature_names()))
"""
     and  document  first   is    one  second  the  third  this
0  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
1  0.000     2.446  0.000  1.0  0.000   1.916  1.0  0.000   1.0
2  1.916     0.000  0.000  1.0  1.916   0.000  1.0  1.916   1.0
3  0.000     1.223  1.511  1.0  0.000   0.000  1.0  0.000   1.0
"""

次の記事からtfとidfの定義に関するオプションをいじって挙動を見ていくのですが、
その際は違いを見やすくするために正規化は行わないで試していきます。

tf-idfの一般的な定義とscikit-learnにおけるtf-idfの定義

テキストをベクトル化するときに用いる基本的な方法の一つであるtf-idfについてこれから数回の更新でまとめていこうと思います。
最初にこの記事では一般的な定義と、scikit-learn(TfidfVectorizer)で引数を特に指定しなかった場合に実行されるデフォルトの定義について紹介し、
今後の記事でそのバリエーションとして、オプションを変えるとどのような定義で計算できるのかを紹介していく予定です。

さて、一言にtf-idfといってもその定義には非常に多くのバリエーションがあります。
英語版のWikipediaを見ていただくとそれらが紹介されています。
参考: tf–idf – Wikipedia (English)

一方で日本語版(この記事執筆時点)のWikipediaではそのうちの1個が紹介されています。
一般的にはこれを指すことが多そうなので、この記事ではこれを一般的な定義と呼びましょう。
参考: tf-idf – Wikipedia (日本語)

早速一般的な定義について紹介していきます。
文書$d$のtf-idfは、コーパス中の単語数に等しい長さのベクトルです。
そのベクトル中の単語$t$に対応する成分$\text{tfidf}_{t,d}$は次のように単語の出現頻度(Term Frequency)と、逆文書頻度(Inverse Document Frequency)の積として計算されます。
$$
\text{tfidf}_{t,d} = \text{tf}_{t,d}\cdot \text{idf}_{t}
$$
単語の出現頻度の定義は次のようになります。
$$
\text{tf}_{t,d} = \frac{\text{文書d中の単語tの出現回数}}{\text{文書dの全ての単語の出現回数の和}}
$$
そして、逆文書頻度の定義は次のようになっています。
$$
\text{idf}_{t} = \log{\frac{\text{総文書数}}{\text{単語tを含む文書数}}}
$$

ある文書に登場する単語は、その文書に多く登場するほど、tfが高まり、コーパス全体で珍しいほどidfが高まって、結果的にtfidfが高くなるようになります。

さて、ここまでが一般的なtfidfの定義の話でした。
普段、文書をtfidfでベクトル化するときは、僕はsciki-learnのTfidfVectorizerを使うことが多いです。
そのため、重要なのはTfidfVectorizerでどのように実装されているのかです。

User Guide をみると、tf, idfそれぞれ一般的なものとは違う設計になっていることがわかります。
参考: 6.2.3. Text feature extraction

まず、一番大きな特徴として、TfidfVectorizer(デフォルト設定)でベクトル化した文書はベクトルの長さが1になるように正則化されます。
これは単純に、tfidfベクトルに変換した後に、結果のベクトルをそれ自体の長さで割って返す実装になっています。

次に、tf,idfの定義がそれぞれ一般的なものと違います。
tf は、単語の出現頻度ではなく、単純に出現回数になっています。

$$
\text{tf}_{t,d} = \text{文書d中の単語tの出現回数}
$$

そして、idfについては、$\log$の中の分数の分子と分母にそれぞれ$1$を足す平滑化処理が行われており、さらに、最後に$1$が足されています。
つまり、TfidfVectorizerのidfは次の式で計算されます。
$$
\text{idf}_{t} = \log{\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}}+1
$$

これらの3つの特徴と、オプションを変えた場合の挙動について今後の記事で紹介していきたいと思います。
– ベクトルの正規化
– tfの定義の違い
– idfの定義の違い

Random projection について

前回の記事で紹介した、Johnson–Lindenstraussの補題を理論的背景に持つ次元削減の手法として、
Random projection と呼ばれるものがあります。
参考: Random projection – Wikipedia

手法は至ってシンプルで、乱数で生成した行列を掛けるによってデータを低次元へと埋め込んでしまうようです。

補題が主張する、距離を維持できる線形写像があるぞって話と、
ランダムに生成した行列で定義される線型写像で距離が維持できるぞと言う話は、
かなり論理にギャップがあるように感じ、その間のロジックをまだ正確には追えていないのですが、
scikit-learnに実装があるのでまずは試してみることにしました。

2000次元のデータを1000件用意し、Gaussian Random Projection という要するに正規分布で生成した行列で低次元にうつす手法を使い、
距離が保たれていることを確認します。

まずはデータの生成です。


import numpy as np

X = np.random.randn(1000, 2000)*10
print(X.shape)
# (1000, 2000)

続いて、Gaussian Random Projection のモデルのインスタンスを生成して、学習、変換します。
ハイパーパラメーター$\varepsilon$は$0.5$としました。
ドキュメントによると、次元削減後の次元は
$n\_components >= 4 \log(n\_samples) / (eps^2 / 2 – eps^3 / 3)$
となるようです。


print(4*np.log(len(X))/(eps**2/2-eps**3/3))
# 331.5722533911425

ですから、 332次元に圧縮されるかと思いきや、結果は331次元になります。
(ドキュメントのミスなのかバグなのか不明)


from sklearn.random_projection import GaussianRandomProjection


eps = 0.5
grp = GaussianRandomProjection(eps=eps)
grp.fit(X)
X_new = grp.transform(X)
print(X_new.shape)
# (1000, 331)

これで、元々2000次元だったデータを331次元に圧縮することができました。
それも、331×2000次元のランダム行列を掛けただけという、超単純な方法でです。

ちなみにその行列自体は grp.components_で取得できます。


print(grp.components_.shape)
# (331, 2000)
print(grp.components_.mean())
# -7.730060662586934e-05
print(grp.components_.var())
# 0.0030132642015398016

要素の平均はほぼ$0$であり、分散は$1/331=0.0030211…$に近い値になっており、ドキュメント通りであることが確認できます。

さて、最後に距離がある程度保存できていることを確認しておきましょう。
元のデータ X と、変換後のデータX_new それぞれについて距離行列を算出します。
そして、比率(変化率)を算出しその最小最大を見てみましょう。


X_pdist = pdist(X)
X_new_pdist = pdist(X_new)

print((X_new_pdist/X_pdist).min(), (X_new_pdist/X_pdist).max())
# 0.8230255694414008 1.18365715425175

eps が 0.5 だったので、 0.5倍〜1.5倍の範囲に収まる想定だったのですが、それよりもずっと良い精度で距離を維持できていることがわかりました。
今回は元のデータが正規分布だったので簡単だったのかもしれませんね。

Johnson–Lindenstraussの補題

『Pythonではじめる教師なし学習 ―機械学習の可能性を広げるラベルなしデータの利用』
という本の中で、「Johnson–Lindenstraussの補題」と言うものを知ったのでその紹介です。

これは、(高次元の)ユークリッド空間内の要素をそれぞれの要素間の距離をある程度保ったまま、
別の(低次元の)ユークリッド空間へ線型写像で移せることを主張するものです。

英語版のWikipediaに主張があるので、それを日本語訳しました。
Johnson–Lindenstrauss lemma – Wikipedia

$\varepsilon$を$0 < \varepsilon < 1$とし、$X$を $\mathbb{R}^N$内の$m$点の集合とします。さらに、$n$が$n > 8\log(m)/\varepsilon^2$を満たすとします。
すると、線型写像 $f:\mathbb{R}^N \longrightarrow \mathbb{R}^n$であって、
任意の$u, v \in X$ に対して、
$$
(1-\varepsilon)\|u-v\|^2 \leq \|f(u)-f(v)\|^2 \leq (1+\varepsilon)\|u-v\|^2
$$
を満たすものが存在する。

僕の個人的な感想ですが、この補題のすごいところは$n$の条件に元の次元$N$が含まれていないことです。
$N$が非常に大きな数だった場合に、要素間の距離をそこそこ保ったままはるかに小さな次元に埋め込める可能性を秘めています。

その一方で、$N$が小さく、要素数$m$が極端に大きい場合は、次元削減としての効果はあまり得られません。