TF-IDFを自力実装してsklearnの処理を再現する

#はじめに
自然言語処理の勉強のためTF-IDFを自力実装してみました。
中々sklearnのTfidfVectorizerの処理結果と一致せず苦労したので、記録として残したいと思います。

#TF-IDFとは
各文書の単語にそれぞれに重み付けを行うアルゴリズムです。
「他の文書にはあんまり存在していないけどその文書には沢山出現している単語」をその文章の特徴語と認識して高い重みをつけます。

>tf-idfは、文書中に含まれる単語の重要度を評価する手法の1つであり、主に情報検索やトピック分析などの分野で用いられている。
tf-idfは、tf（英: Term Frequency、単語の出現頻度）とidf（英: Inverse Document Frequency、逆文書頻度）の二つの指標に基づいて計算される。
出典: フリー百科事典『ウィキペディア（Wikipedia）』

##TF
TFは、該当の文章の中にそれぞれの単語がどの程度含まれているのかを示す指標です。
例えば、「犬が好き」という文章の「犬」という単語のTF値は「0.33333...」という感じになります。
(「犬が好き」という単語は「犬/が/好き」という形に分解でき、全3つの単語の内1つが「犬のため」)

```math
\textrm{tf}(t,d) = \frac{n_{t,d}}{\sum_{s \in d}n_{s,d}}
```
$\textrm{tf}(t,d)$ : 文書 $d$ 内のある単語 $t$ のtf値
$n_{t,d}$ : ある単語 $t$ の文書 $d$ 内での出現回数
$\sum_{s \in d}n_{s,d}$ : 文書 $d$ 内のすべての単語の出現回数の和


##IDF
基本的にはある単語の文書出現頻度の逆数を取り、その対数を取った値になります。
ただ、IDFの算出方法は各ライブラリや本によって微妙に異なります。

下記がsklearnにおけるIDFのデフォルトの算出式になりますが、他テキストに記載されている算出式では各項に足されてる1があったり無かったります。

```math
\textrm{idf}(t) = \log{\frac{N+1}{df(t)+1}} + 1

```
$\textrm{idf}(t)$ : ある単語 $t$ のIDF値
$N$ : 全文書数
$df(t)$ : ある単語 $t$ が出現する文書の数

※logの内側の分子と分母に足されている１はゼロによる除算を防ぐ為（擬似的に全ての単語を含む文書を追加していることになる）
※logの外側に足されている1は、全ての文書の出現する単語を無視しないようにする為(このような場合、1を足さないとIDF値が0になってしまう。)

##TF-IDF
上記のTF値とIDF値を掛け合わせたものがTF-IDFの値になるですが、
sklearnでは更にL２での正規化しています（単位ベクトル化している）。

```math
\textrm{tfidf}(t,d) = ||\textrm{tf}(t,d)・\textrm{idf}(t)||_{2} 

```

#TF-IDFを自力実装する
上記TF-IDFをsklearnやgensimなどのライブラリを使用せずに自力実装してみました。
numpyとreのみ使用しています。コードがぐちゃぐちゃなのはご了承ください。

```Python

import numpy as np
import re

class TF_IDF():
    
    def __init__(self, corpus):
        self.corpus = corpus
            
    def tf(self):
        l = []
        c = []

    #corpusの各テキストを単語毎に分割。
    #sklearの結果と合わせるため、分割方法は正規表現でのマッチで行う

        for text in self.corpus:
            c +=  re.findall(r'\b\w+\b', text)

    #抽出した単語の重複を削除
        c = list(set(c))
    #各テキスト毎の単語出現回数をカウントし、該当テキストの総単語数で割る

        for text in self.corpus:
            xxx = re.findall(r'\b\w+\b', text)
            l.append([xxx.count(i)/len(xxx) for i in c])

        return np.array(l)
  
    def idf(self):
    
        terms = []

    #corpusの各テキストを単語毎に分割。
    #sklearの結果と合わせるため、分割方法は正規表現でのマッチで行う

        for text in self.corpus:
            terms +=  re.findall(r'\b\w+\b', text)

        terms = list(set(terms))

        l = []

        for term in terms:
            #各単語がそのテキストに含まれているかどうかをカウント
            c = 0

            for text in self.corpus:
                #各テキストを単語単位に分割
                word_list = re.findall(r'\b\w+\b', text)
                #該当テキスト内に含まれている単語であれば、１カウントする
                #重複カウントを防ぐ為に論理演算子は「in」を用いる
                #文章の繋がりで、単語ではないものを単語としてカウントしないように上記でリスト化している
                if term in word_list:
                    c += 1

                #各単語IDFを計算。sklearnの計算と合わせるため、分母分子に１を足し、更にその計算結果にも1を足す。
            l.append(np.log((1 + len(self.corpus))/(c+1)) + 1) 

        return np.array(l)    
    
    def l2(self, x):
        #l2ノルムで正規化する（単位ベクトル化する）
        l2 = x / np.sqrt(np.sum(x**2))

        return l2
  
    def tf_idf(self):
    
        xxxx = self.tf()*self.idf()
        #各行にl2ノルムの正規化を適用
        return np.array([self.l2(a) for a in xxxx])
```

#検証
##使用データ
sklearnを用いた場合と結果が同じになるか検証を行います。題材は宮沢賢治の「銀河鉄道の夜」を使用します。下記のような文章をテキストに単位に分割し、単語の分かち書きも行っているの前処理後のデータを用います。
（前処理済みデータを投入することを前提として実装しています。）

```
['では みなさん は 、 そういう ふう に 川 だ と 云わ れ たり 、 乳 の 流れ た あと だ と 云わ れ たり し て い た この ぼんやり と 白い もの が ほんとう は 何 か ご 承知 です か 。',
 '先生 は 、 黒板 に 吊し た 大きな 黒い 星座 の 図 の 、 上 から 下 へ 白く けぶっ た 銀河 帯 の よう な ところ を 指し ながら 、 みんな に 問 を かけ まし た 。',
 'カムパネルラ が 手 を あげ まし た 。',
 'それから 四 五 人 手 を あげ まし た 。',
 'ジョバンニ も 手 を あげよ う として 、 急い で そのまま やめ まし た 。',
 'たしかに あれ が みんな 星 だ と 、 いつか 雑誌 で 読ん だ の でし た が 、 このごろ は ジョバンニ は まるで 毎日 教室 で も ねむく 、 本 を 読む ひま も 読む 本 も ない ので 、 なんだか どんな こと も よく わから ない という 気持ち が する の でし た 。',
 'ところが 先生 は 早く も それ を 見 附け た の でし た 。',
 'ジョバンニ さん 。',
 'あなた は わかっ て いる の でしょ う 。',
 'ジョバンニ は 勢 よく 立ちあがり まし た が 、 立っ て 見る と もう はっきり と それ を 答える こと が でき ない の でし た 。',
 'ザネリ が 前 の 席 から ふりかえっ て 、 ジョバンニ を 見 て く すっと わらい まし た 。',
 'ジョバンニ は もう どぎまぎ し て まっ 赤 に なっ て しまい まし た 。',
 '先生 が また 云い まし た 。',
 '大きな 望遠鏡 で 銀河 を よっ く 調べる と 銀河 は 大体 何 でしょ う 。',
 'やっぱり 星 だ と ジョバンニ は 思い まし た が こんど も すぐ に 答える こと が でき ませ ん でし た 。',
 '先生 は しばらく 困っ た よう す でし た が 、 眼 を カムパネルラ の 方 へ 向け て 、 では カムパネルラ さん 。',
```

##比較

```Python
#自力実装
import numpy as np
import re
from module.tf_idf import TF_IDF

x = TF_IDF(l)
tf_idf_a =x.tf_idf()

print(np.sum(tf_idf_a[0]))
```
TF-IDFでベクトル化した1文目の各要素の和の出力はこちらです。

```
5.04412702742701
```

sklearnで同じ処理を行います。

```Python
#sklearnで導出
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(token_pattern="(?u)\\b\\w+\\b", lowercase = False,stop_words = None)

X = vectorizer.fit_transform(l)

np.sum(X.toarray()[0])
```
出力はこちらです。

```
5.044127027427011
```
小数点第14位までは完全に一致、sklearnの場合は小数点第15位まで含めて出力されています。
他の指標でも確認しましたが、小数点の計算範囲レベルの誤差以外は一致していました。

#おわりに
自力実装することで、TF-IDFへの理解はかなり進みました。
他のアルゴリズムの実装にも挑戦していければと思っています。