TF-IDF は文書の集合に含まれる文書について,文書に出現した単語の重要度を計ることができる指標です.具体的には文書集合 \(D\) に含まれる文書 \(d\) での単語 \(t\) の重要度を \(\mbox{TF-IDF}(D,d,t)\) として,次の式で表します.
ここで,\(\mbox{TF}(d,t)\) は文書\(d\) における単語 \(t\) の出現頻度 (Term Frequency) で,次の式で表されます.
一方で,\(\mbox{IDF}(D,t)\) は単語 \(t\) を含む文書の頻度の逆数 (Inverse Document Frequency) で,次の式の通りです.なお,これは文書の頻度 (Document Frequency) の逆数に対して更に対数 log を用いていることに注意してください.
ここでは,TF-IDF の計算をしてみます.まずはモジュールをインポートします.エラーが出たら,必要なモジュールを pip
でインストールしてください.
モジュールのインポート
from janome.tokenizer import Tokenizer
import os
import re # 正規表現
import numpy as np
次に TF-IDF を計算するモジュールを準備します.なお,下のコードの43行目では,名詞だけを取り出しています.動詞や形容詞,副詞も取り出したい場合は45~46行目も有効にしてください.
モジュールの準備
def strip_CRLF_from_Text(text):
"""
テキストファイルの改行,タブを削除する.
改行文字やタブ文字の前後が日本語文字の場合はそれを削除する.
それ以外はスペースに置換する.
"""
# 改行前後の文字が日本語文字の場合は改行を削除する
plaintext = re.sub('([ぁ-んー]+|[ァ-ンー]+|[\\u4e00-\\u9FFF]+|[ぁ-んァ-ンー\\u4e00-\\u9FFF]+)(\n)([ぁ-んー]+|[ァ-ンー]+|[\\u4e00-\\u9FFF]+|[ぁ-んァ-ンー\\u4e00-\\u9FFF]+)',
r'\1\3',
text)
# 残った改行とタブ記号はスペースに置換する
plaintext = plaintext.replace('\n', ' ').replace('\t', ' ')
return plaintext
def get_Text_from_Filepaths(filepaths):
"""
ファイル名のリストを与えて,辞書を作成して返す
"""
raw = ''
# ファイルを開く
for filepath in filepaths:
f = open(filepath, encoding='utf-8')
raw += f.read()
f.close()
# 改行を削除する
text = strip_CRLF_from_Text(raw)
return text
def janome_analysis(text):
"""
Janomeを使って単語を切り出してリストに詰める関数.
可視化して意味がありそうな単語を抽出するために品詞は名詞だけ(あるいは名詞,動詞,形容詞,副詞)に限定.
"""
t = Tokenizer()
results = t.tokenize(text, wakati=False)
# print(results)
words = []
for token in results:
if token.part_of_speech.split(',')[0] in ["名詞"]:
# 名詞だけをリストに追加する
words.append(token.surface) # 表層形
# 動詞(の原型),形容詞,副詞もリストに加えたい場合は次の2行を有効にする
# if token.part_of_speech.split(',')[0] in ["動詞","副詞","形容詞"]:
# words.append(token.base_form) # 原形
return words
def get_DF_from_Filepaths(filepaths, vocab):
"""
ファイル名のリストを与えて,DFの値を返します.
DFは索引語が出現する文書数のこと.
"""
# 辞書の長さと同じ長さで DF を初期化する
df = np.zeros((len(vocab), 1))
for filepath in filepaths:
f = open(filepath, encoding='utf-8')
raw = f.read()
text = strip_CRLF_from_Text(raw) # 改行を削除
words = janome_analysis(text) # 名詞だけのリストを生成
for s in set(words): # 単語の重複を除いて登場した単語を数える
df[vocab.index(s), 0] += 1
return df
def get_IDF_from_Filepaths(filepaths, vocab):
"""
ファイル名のリストを与えて,IDFの値を返します.
"""
# 辞書の長さと同じ長さで DF を初期化する
df = np.zeros((len(vocab), 1))
n_docs = len(filenames)
for filepath in filepaths:
f = open(filepath, encoding='utf-8')
raw = f.read()
text = strip_CRLF_from_Text(raw) # 改行を削除
words = janome_analysis(text) # 名詞だけのリストを生成
for s in set(words): # 単語の重複を除いて登場した単語を数える
df[vocab.index(s), 0] += 1
return np.log(n_docs / df)
def get_TF_from_Filepaths(filepaths, vocab):
"""
ファイル名のリストを与えて,TFの値を返します.
TFは索引語の出現頻度のこと.
"""
n_docs = len(filenames)
n_vocab = len(vocab)
# 行数 = 登録辞書数, 列数 = 文書数 で tf を初期化する
tf = np.zeros((n_vocab, n_docs))
for filepath in filepaths:
f = open(filepath, encoding='utf-8')
raw = f.read()
text = strip_CRLF_from_Text(raw)
words = janome_analysis(text)
for w in words:
tf[vocab.index(w), filepaths.index(filepath)] += 1
return tf
def get_TFIDF_from_TF_IDF(tf, idf):
"""
TFとDFを与えて,TF-IDFの値を返します.
"""
return tf * idf
def get_distance_matrix(tfidf):
"""
tfidf の行列を渡せば,文書間の距離を計算して,行列を返します.
"""
n_docs = tfidf.shape[1]
n_words = tfidf.shape[0]
# 結果を格納する行列を準備(初期化)する
distance_matrix = np.zeros([n_docs, n_docs]) # 文書数 x 文書数
for origin in range(n_docs): # origin : 比較元文書
tmp_matrix = np.zeros([n_words, n_docs]) # 単語数 x 文書数
# 比較元文書のTFIDFを取得する
origin_tfidf = tfidf[0:n_words, origin]
# 各要素の二乗誤差を取る
for i in range(n_docs): # 列のループ 0:5 (文書数)
for j in range(n_words): # 行のループ 0:20 (単語数)
tmp_matrix[j, i] = (tfidf[j, i] - origin_tfidf[j])**2
# 二乗誤差の合計の平方根を計算
for i in range(n_docs):
distance_matrix[origin, i] = np.sqrt(tmp_matrix.sum(axis=0)[i])
return distance_matrix
必要な関数が準備できたので,複数の文書ファイルを開いて形態素解析を実行し,名詞だけを取り出します.
複数文書の分かち書き
### 文書ファイルを指定する.
filenames = [ 'sample_1.txt',
'sample_2.txt',
'sample_3.txt',
'sample_4.txt',
'sample_5.txt'
]
### フォルダ名も付与してファイルパス一覧のリストを生成
filepaths = [os.path.sep.join(['corpora', fname]) for fname in filenames]
### 文書ファイルを開いてテキストデータを取得する
text = get_Text_from_Filepaths(filepaths)
### Janomeによる形態素解析
### すべての文書を分かち書きをして,名詞だけ取り出したリストを作成する
words = janome_analysis(text)
print(words)
['勤務', '先', '社内', '自然', '言語', '処理', '勉強', '会', '発表', '資料', '自然', '言語', '処理', '基本', '説明', '文書', '自然', '言語', '処理', '基本', '類似', '文書', '推薦', '説明', 'これ', '学会', '発表', '資料', '資料', 'ジャズ', 'ライブ', '資料', 'ライブ', '何', '説明', '自然', '言語', '処理', '基本', '説明', '自然', '言語', '処理', '基本', '説明', '自然', '言語', '処理', '基本', '説明']
取り出された名詞から重複を除いた語彙のリストを作成します.なお set()
によって集合が生成されます.
名詞のリストから重複を除いた語彙のリストを生成する
vocab = sorted(set(words))
print(vocab)
['これ', 'ジャズ', 'ライブ', '会', '何', '先', '処理', '勉強', '勤務', '基本', '学会', '推薦', '文書', '発表', '社内', '自然', '言語', '説明', '資料', '類似']
DF を計算してみます.ここで,結果のインデックス0(先頭)の [1.] は vocab[0]
の「これ」という語彙が1つの文書に登場していることを意味し,インデックス6(先頭から7番目)の [4.] は vocab[6]
の「処理」という語彙が4つの文書に登場していることを意味します.さらに「説明」という語彙はすべて(5個)の文書に登場していることもわかります.
df = get_DF_from_Filepaths(filepaths, vocab)
print(df)
[[1.] [1.] [1.] [1.] [1.] [1.] [4.] [1.] [1.] [4.] [1.] [1.] [1.] [2.] [1.] [4.] [4.] [5.] [3.] [1.]]
DF の意味が理解できたところで IDF を計算します.なお,小数点以下の表示桁数が大きくならないように,np.set_printoptions
で設定しておきます.下の実行結果を見ると,1つの文書にだけ登場する「これ」などの語彙は TF-IDF が大きくなっており,4つの文書に登場する「処理」という語彙の IDF は小さくなっている事がわかります.さらに,すべての文書に登場する「説明」という語彙については IDF がゼロになっていることもわかります.つまり,出現頻度の低い索引語の IDF が大きくなるということを意味しています.
np.set_printoptions(precision=3) # 表示桁数の指定
idf = get_IDF_from_Filepaths(filepaths, vocab)
print(idf)
[[1.609] [1.609] [1.609] [1.609] [1.609] [1.609] [0.223] [1.609] [1.609] [0.223] [1.609] [1.609] [1.609] [0.916] [1.609] [0.223] [0.223] [0. ] [0.511] [1.609]]
次は TF を計算します.TF は各文書における索引語の出現頻度を意味します.例えば,1行目の [0. 1. 0. 0. 0.] は「これ」という索引語が2番目の文書に1度出現していることを意味します.また下から3行目の [1. 1. 1. 1. 2.] は「説明」という語彙が5番目の文書に2度登場し,その他の文書では1度登場していることを意味します.
tf = get_TF_from_Filepaths(filepaths, vocab)
print(tf)
[[0. 1. 0. 0. 0.] [0. 0. 1. 0. 0.] [0. 0. 2. 0. 0.] [1. 0. 0. 0. 0.] [0. 0. 1. 0. 0.] [1. 0. 0. 0. 0.] [2. 1. 0. 1. 2.] [1. 0. 0. 0. 0.] [1. 0. 0. 0. 0.] [1. 1. 0. 1. 2.] [0. 1. 0. 0. 0.] [0. 1. 0. 0. 0.] [0. 2. 0. 0. 0.] [1. 1. 0. 0. 0.] [1. 0. 0. 0. 0.] [2. 1. 0. 1. 2.] [2. 1. 0. 1. 2.] [1. 1. 1. 1. 2.] [1. 1. 2. 0. 0.] [0. 1. 0. 0. 0.]]
TF と IDF が得られたので,TF-IDF を求めてみよう.
TF-IDF を計算する
tfidf= get_TFIDF_from_TF_IDF(tf, idf)
print(tfidf)
[[0. 1.609 0. 0. 0. ] [0. 0. 1.609 0. 0. ] [0. 0. 3.219 0. 0. ] [1.609 0. 0. 0. 0. ] [0. 0. 1.609 0. 0. ] [1.609 0. 0. 0. 0. ] [0.446 0.223 0. 0.223 0.446] [1.609 0. 0. 0. 0. ] [1.609 0. 0. 0. 0. ] [0.223 0.223 0. 0.223 0.446] [0. 1.609 0. 0. 0. ] [0. 1.609 0. 0. 0. ] [0. 3.219 0. 0. 0. ] [0.916 0.916 0. 0. 0. ] [1.609 0. 0. 0. 0. ] [0.446 0.223 0. 0.223 0.446] [0.446 0.223 0. 0.223 0.446] [0. 0. 0. 0. 0. ] [0.511 0.511 1.022 0. 0. ] [0. 1.609 0. 0. 0. ]]
単語ごとの出現頻度をわかりやすく表示させてみよう.
for i in range(len(vocab)):
print(vocab[i], tf[i])
これ [0. 1. 0. 0. 0.] ジャズ [0. 0. 1. 0. 0.] ライブ [0. 0. 2. 0. 0.] 会 [1. 0. 0. 0. 0.] 何 [0. 0. 1. 0. 0.] 先 [1. 0. 0. 0. 0.] 処理 [2. 1. 0. 1. 2.] 勉強 [1. 0. 0. 0. 0.] 勤務 [1. 0. 0. 0. 0.] 基本 [1. 1. 0. 1. 2.] 学会 [0. 1. 0. 0. 0.] 推薦 [0. 1. 0. 0. 0.] 文書 [0. 2. 0. 0. 0.] 発表 [1. 1. 0. 0. 0.] 社内 [1. 0. 0. 0. 0.] 自然 [2. 1. 0. 1. 2.] 言語 [2. 1. 0. 1. 2.] 説明 [1. 1. 1. 1. 2.] 資料 [1. 1. 2. 0. 0.] 類似 [0. 1. 0. 0. 0.]
各単語がいくつの文書に登場したかを調べてみよう.
for i in range(len(vocab)):
print(vocab[i], df[i])
print(len(vocab))
これ [1.] ジャズ [1.] ライブ [1.] 会 [1.] 何 [1.] 先 [1.] 処理 [4.] 勉強 [1.] 勤務 [1.] 基本 [4.] 学会 [1.] 推薦 [1.] 文書 [1.] 発表 [2.] 社内 [1.] 自然 [4.] 言語 [4.] 説明 [5.] 資料 [3.] 類似 [1.] 20
TF-IDFをもう一度眺めてみよう.また,実際のテキストコーパスデータも見て,TF-IDFが何を意味しているのか理解しよう.
tfidf
array([[0. , 1.609, 0. , 0. , 0. ], [0. , 0. , 1.609, 0. , 0. ], [0. , 0. , 3.219, 0. , 0. ], [1.609, 0. , 0. , 0. , 0. ], [0. , 0. , 1.609, 0. , 0. ], [1.609, 0. , 0. , 0. , 0. ], [0.446, 0.223, 0. , 0.223, 0.446], [1.609, 0. , 0. , 0. , 0. ], [1.609, 0. , 0. , 0. , 0. ], [0.223, 0.223, 0. , 0.223, 0.446], [0. , 1.609, 0. , 0. , 0. ], [0. , 1.609, 0. , 0. , 0. ], [0. , 3.219, 0. , 0. , 0. ], [0.916, 0.916, 0. , 0. , 0. ], [1.609, 0. , 0. , 0. , 0. ], [0.446, 0.223, 0. , 0.223, 0.446], [0.446, 0.223, 0. , 0.223, 0.446], [0. , 0. , 0. , 0. , 0. ], [0.511, 0.511, 1.022, 0. , 0. ], [0. , 1.609, 0. , 0. , 0. ]])
TF-IDF を用いると文書間の距離を計算することができます.距離を求める最も簡単なものはユークリッド距離を用いるものです. 文書 \(d\) での索引語 \(t\) の \(\mbox{TF-IDF}(D,d,t)\) を \({\rm{tfidf}}_{td}\) と書くこととします.また,文書集合 \(D\) に含まれる文書数は \(m\) とします.このとき,文書 \(i\) と文書 \(j\) の距離 \(d(i,j)\) は次のようになります.
実際に TF-IDF を用いて文書間の距離を計算してみよう.この結果を見ると,「sample_4.txt」と「sample_5.txt」の距離が「0.446」と最も近いことから,似た文書であることがわかります.さらに,「sample_2.txt」と「sample_3.txt」の距離が最も遠いこともわかり,特に「sample_2.txt」は他のどの文書とも距離が遠い,つまり他のどの文書とも似ていない文書であることがわかります.実際に各文書の中身を比較してみてください.
文書間の距離を計算する
distance_matrix = get_distance_matrix(tfidf)
print(distance_matrix)
[[0. 5.816 5.499 3.768 3.755] [5.816 0. 6.129 4.671 4.693] [5.499 6.129 0. 4.097 4.169] [3.768 4.671 4.097 0. 0.446] [3.755 4.693 4.169 0.446 0. ]]
なお,上で定義した get_distance_matrix
関数は文書間の距離 \(d(i,j)\) を求めていますが,処理の中で for
による繰り返し処理を行なっています.このページの例では高々5個の短い文書で距離を計算しているだけなのでその処理性能が問題になることはありません.しかしながら,文書数が多くなるとともに索引語の数も多くなると,計算処理に必要な時間が無視できないほど長くなります.次の関数 get_distance_matrix_bc
のようにNumPy のブロードキャストやベクトル化された演算を利用することで,ループの多くを置き換えることができ,処理速度の向上が可能になります.
def get_distance_matrix_bc(tfidf):
"""
tfidf の行列を渡せば,文書間の距離を計算して,行列を返します.
"""
# 文書間の距離行列を初期化
distance_matrix = np.zeros((tfidf.shape[1], tfidf.shape[1]))
# 文書間の二乗誤差を計算し、その平方根を取る
for origin in range(tfidf.shape[1]):
origin_tfidf = tfidf[:, origin].reshape(-1, 1)
distances = np.sqrt(np.sum((tfidf - origin_tfidf) ** 2, axis=0))
distance_matrix[origin, :] = distances
return distance_matrix
新たに定義した関数でも同じ結果が得られることを確認します.
distance_matrix = get_distance_matrix_bc(tfidf)
print(distance_matrix)
[[0. 5.816 5.499 3.768 3.755] [5.816 0. 6.129 4.671 4.693] [5.499 6.129 0. 4.097 4.169] [3.768 4.671 4.097 0. 0.446] [3.755 4.693 4.169 0.446 0. ]]
マジックコマンドによって処理時間を計測し,その性能を比較します.まずは for
による繰り返しを使った場合の処理時間を計測します.
%%timeit
distance_matrix = get_distance_matrix(tfidf)
150 µs ± 799 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
続いて,ブロードキャストを使った場合の処理時間を計測します.この結果,8倍程度の速度で計算できていることがわかりました.
%%timeit
distance_matrix = get_distance_matrix_bc(tfidf)
18.1 µs ± 80.6 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)