概要
最近文書分類にはまっていまして、ただライブラリを振り回すだけではさみしいなと思い、自分の理解を深めることも兼ねて記事にしたいと思います。
自然言語処理にて文書分類を行う場合、大きく次のようなステップを踏みます。
- 文書の形態素解析
- 文書の定量化(ベクトル化)
- 定量化した文書によるモデルの作成
- 検証データによるモデルの評価
タイトルにもあるtf-idfは「2. 文書の定量化(ベクトル化)」、単純ベイズ分類器は「3. 定量化した文書によるモデルの作成」と「4. 検証データによるモデルの評価」で使用します。
今回は国会議事録検索APIにより抽出した国会の議事録を分類してみたいと思います。
具体的には上記APIを用いて2008〜2017年の過去10年分の国会の常任委員会の発言内容を学習させ、2018年の発言内容を与えたときに正しく委員会を分類できるかやってみたいと思います。今回の分類対象の委員会については下記の通りです。
本会議
内閣委員会
総務委員会
法務委員会
外務委員会
財務金融委員会
文部科学委員会
厚生労働委員会
農林水産委員会
経済産業委員会
国土交通委員会
環境委員会
安全保障委員会
予算委員会
決算行政監視委員会
議院運営委員会
*懲罰委員会、国家基本政策委員会については常任委員会ではあるが数が極端に少ないため除外する。
データセットについては下記のような形をしたcsvファイルとなっております。議会、委員会名、発言者、日付、発言順、発言内容ごとに、csv形式で保存されています(2008~2017年分がtrain.csv、2018年分がtest.csv。両ファイルとも形式は同一)。
| houses | committee | speaker | date | speech_order | speech_text |
|--:|:--|:--|:--|--:|--:|:--|
| 衆議院 | 本会議 | 河野洋平 | 20080111 | 1 | これより会議を開きます。◇ |
| 衆議院 | 本会議 | 河野洋平 | 20080111 | 2 | 本日、参議院から、本院送付のテロ対策海上阻止活動に対する補給支援活動の実施に関する特別措置法... |実際に、tf-idf→MultinomialNBなどの実装も見かけたことがあります(Qiitaなどでも見たことがあるので漁れば出てくると思います)。
| 衆議院 | 本会議 | 河野洋平 | 20080111 | 3 | 大島理森君外百三名から、憲法第五十九条第二項に基づき、テロ対策海上阻止活動に対する補給支援活... |
| 衆議院 | 本会議 | 仙谷由人 | 20080111 | 4 | 民主党の仙谷由人でございます。私は、民主党・無所属クラブを代表して、内閣提出のいわゆるテロ新... |
| 衆議院 | 本会議 | 河野洋平 | 20080111 | 5 | 小坂憲次君。〔小坂憲次君登壇〕 |
ソースについてはJupyter形式にてこちらで公開しております。
1. 文書の形態素解析
ここは今回の主題ではないので省略気味でいきます。
まず、文書を定量化するにあたり日本語を意味のある最小単位(大雑把にいえば単語)に区切る必要があります。今回はそのためにMeCabというライブラリを用います。MeCabを使用して文書を形態素解析します。今回は以下の手続きを実施します。
- 意味のある品詞のみを残す(今回は名詞、動詞、形容詞、副詞)。
- 活用語については原型に直す
- stopwordについては今回は設定しない
また、メイントピックではないため詳細は割愛しますが、mecab-ipadic-NEologd、neologdn、mojimojiというライブラリを用いています。mecab-ipadic-NEologdについてはこちら、neologdnについてはこちら、mojimojiについてはこちらを参照してください。
今回はこのように実装しました。
import mojimoji
import neologdn
import MeCab
def normalize_text(text):
result = mojimoji.zen_to_han(text, kana=False)
result = neologdn.normalize(result)
return result
def text_to_words(text):
m = MeCab.Tagger('-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd')
m.parse('')
#neologdnにより正規化処理をする。
text = normalize_text(text)
m_text = m.parse(text)
basic_words = []
#mecabの出力結果を単語ごとにリスト化
m_text = m_text.split('\n')
for row in m_text:
#Tab区切りで形態素、その品詞等の内容と分かれているので単語部のみ取得
word = row.split("\t")[0]
#最終行はEOS
if word == 'EOS':
break
else:
pos = row.split('\t')[1]
slice_ = pos.split(',')
#品詞を取得する
parts = slice_[0]
if parts == '記号':
if word != '。':
continue
#読点のみ残す
basic_words.append(word)
#活用語の場合は活用指定ない原型を取得する。
elif slice_[0] in ('形容詞', '動詞'):
basic_words.append(slice_[-3])
#活用しない語についてはそのままの語を取得する
elif slice_[0] in ('名詞', '副詞'):
basic_words.append(word)
basic_words = ' '.join(basic_words)
return basic_words
実行例
実行例は以下のようになります。
ex_str1 = '日本語難しいね。日本語って単語にも分かれていないし面倒だわ〜。嫌いよ〜、本当に嫌いだわ。'
print(text_to_words(ex_str1))
結果は以下のようになります。
日本語 難しい 。 日本語 単語 分かれる いる 面倒 。 嫌い 本当に 嫌い 。
こちらを国会の議事録適用します。
先述の通り議会、委員会名、日付ごとに発言内容を統合したデータをデータセットとします。
以下のように実装しました。
'''
学習データ
x_train_text:発言が全て結合された配列
y_train:学習データのラベル
テストデータ
x_test_text, y_test
'''
df_train = pd.read_csv('./train.csv', header=0, sep='\t')
df_test = pd.read_csv('./test.csv', header=0, sep='\t')
x_train_text, y_train = [], []
x_test_text, y_test = [], []
train_flg = True
for df in (df_train, df_test):
houses, committee, date = '', '', ''
speech_text = []
for item in tqdm_notebook(df.iterrows()):
if (houses == item[1]['houses'] and committee == item[1]['committee'] and date == item[1]['date']) :
speech_text.append(copy.copy(item[1]['speech_text']))
continue
if train_flg:
x_train_text.append(' '.join(copy.copy(speech_text)))
y_train.append(copy.copy(committee))
else:
x_test_text.append(' '.join(copy.copy(speech_text)))
y_test.append(copy.copy(committee))
speech_text.clear()
houses = item[1]['houses']
committee = item[1]['committee']
date = item[1]['date']
train_flg= False
#形態素解析の実施
x_train_text = [text_to_words(x) for x in tqdm_notebook(x_train_text)]
x_test_text = [text_to_words(x) for x in tqdm_notebook(x_test_text)]
#便宜上、ラベルをIDに置き換えておく。
classify_dict = {'本会議':0, '内閣委員会':1, '総務委員会':2, '法務委員会':3, '外務委員会':4, '財務金融委員会':5, '文部科学委員会':6, '厚生労働委員会':7, '農林水産委員会':8, \
'経済産業委員会':9, '国土交通委員会':10, '環境委員会':11, '安全保障委員会':12, '予算委員会':13, '決算行政監視委員会':14, '議院運営委員会':15}
y_train = [classify_dict[x] for x in y_train]
y_test = [classify_dict[x] for x in y_test]
2. 文書の定量化(ベクトル化)
形態素解析が終わった文書をベクトル化します。今回はtf-idfという手法で文書をベクトル化します。
忙しい人向け
下記のように、学習デーダの文書と、テスト用の文書を結合した状態でCountVectorizer、TfidfTransformerを順番に実行していきます。このようにすることで、学習データとテストデータの文書がいい感じにベクトル化してくれます。ですので、作成された行列を学習データと、テストデータに分ければ完成です。
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
corpus = x_train_text + x_test_text
train_size= len(x_train_text)
cv = CountVectorizer()
wc = cv.fit_transform(corpus)
ttf = TfidfTransformer()
tfidf = ttf.fit_transform(wc)
x_train = tfidf[:train_size,:]
x_test = tfidf[train_size:,:]
tf-idfの結果はscipy形式の行列形式で出力され、行部分が各文書(上記corpusの配列の要素)、列部分が各単語に対応しています。行列各要素が文書ごとのtf-idf値に対応しており、大雑把に言えば、その単語の各文書内の出現頻度が多く、かつ他の文書での出現頻度が少なければ少ないほど大きな値になるように算出されます。
少し詳しく
算出方法
tf-idf値はtf値と、idf値の積によって算出されます。
$$ \textrm{tfidf}_{i,j} = \textrm{tf}_{i,j} \times \textrm{idf}_{j} $$
ここで、tf値とidf値について説明します。文書全体を$ D $とし、文書全体は$ m $個の文書で構成されているとして、文書を構成する各文書を$d_{1}, d_{2}, ...d_{m} $と表し、文書全体$ D $は$ n $個の単語で構成されているとして、各単語を$t_{1}, t_{2}, ...t_{n} $と表すことにします。また単語全体の集合を$ T $とします。
tf値は、各文書ごとのある単語の出現頻度を表します。文書$ d_{i} \in D $におけるある単語$ t_{j} \in T $のtf値は以下のように表せます。
$$ \textrm{tf}_{i,j} = \frac{\mbox{文書} d_{i} \mbox{に含まれる} t_{j} \mbox{の個数} }{\mbox{文書} d_{i} \mbox{内のすべての単語の個数} } = \frac{ | t_{j} \in d_{i} | }{ \sum_{t_{l} \in d_{i}} | t_{l} | } $$
ただし、$ |\cdot| $は要素$ \cdot $の個数を表すものとします。
idf値は文書間の単語の出現頻度の逆数に対数を取ったで定義され、$ t_{j} \in T $のidf値は以下のように定義されます。
$$ \textrm{idf}_{j} = \log \left( \frac{ \mbox{全文書数} }{ \mbox{単語} t_{j} \mbox{が含まれる文書数} } \right) +1 = \log \left( \frac{|D|}{ | \{ d: t_{j} \in d \} |} \right) +1 $$
今回用いる、TfidfTransformerではsmooth_idfというパラメタををもっており、こちらをTrueにすると(デフォルトTrue)、logの分母分子にそれぞれ1加算されて計算されます。
$$ \textrm{idf}_{j} = \log \left( \frac{|D| + 1}{ | \{ d: t_{j} \in d \} | +1 } \right) +1 $$
tf-idf値は(文書数✕全文書に出現する語彙数)の行列形式で返されます。
\left(
\begin{array}{cccc}
\textrm{tfidf}_{1,1} & \textrm{tfidf}_{1,2} & ... & \textrm{tfidf}_{1,n} \\
\textrm{tfidf}_{2,1} & \textrm{tfidf}_{2,2} & ... & \textrm{tfidf}_{2,n} \\
... & & & \\
\textrm{tfidf}_{m,1} & \textrm{tfidf}_{n,2} & ... & \textrm{tfidf}_{m,n}
\end{array}
\right)
列ベクトルがそれぞれの文書$ d_{i} $のベクトル表現$ \textbf{x}_{i} $に対応します。このベクトルは長さ1に規格化されて出力されます。
\textbf{x}_{i} = \frac{1}{ \sqrt{ \sum_{l=1}^{n} \textrm{tfidf}_{i,l}^2} } \left( \textrm{tfidf}_{i,1}, \textrm{tfidf}_{i,2}, ..., \textrm{tfidf}_{i,n} \right)^T
大まかな動きはこのような感じですが、1加えたりなどライブラリや与えるパラメータによって動きが微妙に違うので詳細は自身が使用されるライブラリを確認いただければと思います。大まかな動きがわかっていればあまり細かい数値を追いかけるケースは早々ないかとは思いますが。
実行例
基本的には上で示したコードを実施すれば、良いのですがここで動きを把握するために短い文書でもtf-idfを求めてみます。
ex_corpus = ['日本語 難しい ', '日本語 単語 分かれる いる 面倒 ', '嫌い 本当に 嫌い ']
ex_cv = CountVectorizer()
ex_wc = ex_cv.fit_transform(ex_corpus)
ex_ttf = TfidfTransformer()
ex_tfidf = ex_ttf.fit_transform(ex_wc)
#get_feature_names()で列がどの単語に対応しているか確認できる
display(pd.DataFrame(ex_tfidf.toarray(), columns=ex_cv.get_feature_names(), index=ex_corpus))
結果は以下のようになります。
いる | 分かれる | 単語 | 嫌い | 日本語 | 本当に | 難しい | 面倒 | |
---|---|---|---|---|---|---|---|---|
日本語 難しい | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.605349 | 0.000000 | 0.795961 | 0.000000 |
日本語 単語 分かれる いる 面倒 | 0.467351 | 0.467351 | 0.467351 | 0.000000 | 0.355432 | 0.000000 | 0.000000 | 0.467351 |
嫌い 本当に 嫌い | 0.000000 | 0.000000 | 0.000000 | 0.894427 | 0.000000 | 0.447214 | 0.000000 | 0.000000 |
一文目に着目すると「日本語」というという文字も「難しい」という文字も共に一回ずつ登場していますが、「日本語」は一文目と二文目どちらにも登場しているので、tf-idf値が小さくなっています。逆に3文目の「嫌い」という単語は一文のみで複数回出力されているので大きな値となっています。
ここまでで準備が整いましたので、忙しい人向けに記載したコードを実行すれば学習データ、テストデータのベクトル化、および、学習データと、テストデータへの分離が完了します。
3. 定量化した文書によるモデルの作成
前処理した文書を用いて単純ベイズ分類器による文書分類を実施します。先ほど形態素解析した文書を一文ずつ区切った文書でtf-idfを求めてみます。
忙しい人向け
下記に示すコードを実施してください。MultinomialNBを呼び出して、clf.fitのコードでtrainファイルで学習をします。trainファイルで学習し作成したモデルを元に、clf.predictにてテストデータを与えたときの予測値を返します。これとy_testとどの程度一致しているかをみて結果を評価します。
ところで、MultinomialNBを呼び出したときに設定しているalphaのパラメータを設定しています。こちらは詳細は「少し詳しく」のところで触れますが、学習時のfittingの強さを調整する指標になります(デフォルトは1)。今回は何通りか試して0.001で成績が比較的良くなったので、その値を用いています。
from sklearn.naive_bayes import MultinomialNB
clf = MultinomialNB(alpha=0.001)
clf.fit(x_train, y_train)
test_predict = clf.predict(x_test)
少し詳しく
単純ベイズモデルではベイズの定理と呼ばれる定理を用います。
単純ベイズモデル
いま何らかの特徴ベクトル$ \textbf{x} = \left( x_{1}, x_{2}, ... ,x_{n} \right)^T $が与えられたときに、クラス$ C :\{ C_{1},C_{2}, ...,C_{m} \} $のいずれかに存在する確率を考えることにします。いま特徴ベクトル$ \textbf{x} $が与えられたとき、$C_{i}$に存在する確率は条件つき確率の定義から
P \left( C_{i} | \textbf{x} \right) = \frac{ P \left( C_{i}, \textbf{x} \right) }{P\left( \textbf{x} \right)}
同様に$P \left( C_{i}, \textbf{x} \right) = P \left(C_{i} \right) P \left( \textbf{x} | C_{i} \right)$と表せますので
P \left( C_{i} | \textbf{x} \right) = \frac{P \left(C_{i} \right) P \left( \textbf{x} | C_{i} \right)}{P\left( \textbf{x} \right)}
となります。これがベイスの定理と呼ばれる定理になります。
ここで、$x_{k}$は他の変数$x_{l}$(ただし $k \neq l$)に対して条件つき独立であるという仮定をおきます。すなわち
\begin{split}
P \left( x_{1}, x_{2}, ...,x_{n} | C_{i} \right) = & P \left( x_{1} | C_{i} \right) P \left( x_{2} | C_{i} \right) ...P \left( x_{n} | C_{i} \right) \\
=& \prod_{k=1}^n P \left( x_{k} | C_{i} \right)
\end{split}
となると、先述の特徴ベクトル$ \textbf{x} $が与えられたときに$C_{i}$に存在する確率は
P \left( C_{i} | \textbf{x} \right) = \frac{ P \left(C_{i} \right) \prod_{k=1}^n P \left( x_{k} | C_{i} \right) }{P\left( \textbf{x} \right)}
となります。この確率がクラス$ C :\{ C_{1},C_{2}, ...,C_{m} \} $のどれで最大値をとるかを考えるのが、単純ベイズモデルとなり、分母はクラスによらず一定のため
\max \left[ P \left(C_{1} \right)\prod_{k=1}^n P \left( x_{k} | C_{1} \right) ,P \left(C_{2} \right) \prod_{k=1}^n P \left( x_{k} | C_{2} \right), ..., P \left(C_{m} \right)\prod_{k=1}^n P \left( x_{k} | C_{m} \right) \right]
となるクラスを求めることとなります。
多項分布モデルの導入
単純ベイズモデルでは、$P \left( \textbf{x} | C_{i} \right)$をある分布に従っているものとしてモデル化します。今回用いているMultinomialNBでは多項分布で置いています。多項分布は二項分布の拡張で以下のように書けます。
P \left( \textbf{x} | C_{i} \right) = \frac{\left( \sum_{k=1}^{n} x_{k} \right) !}{\prod_{l=1}^{n} x_{l} !} \prod_{s=1}^{n} p_{s}^{x_{s}} \\
\sum_{s=1}^n p_{s} = 1 \\
^{\forall}x_{s} \geq 0, s = 1,2,...,n
よって、
P \left(C_{i} \right)\prod_{k=1}^n P \left( x_{k} | C_{i} \right) = P \left( C_{i} \right) \frac{\left( \sum_{k=1}^{n} x_{k} \right) !}{\prod_{l=1}^{n} x_{l} !} \prod_{s=1}^{n} p_{s}^{x_{s}}
と表されます。注意点としてはパラメタ$p_{s}$はクラスごとに設定されパラメタとなりますので、クラス$C_{i}$が与えられたときのパラメータを
\textbf{p}_{i} = \left( p_{i1}, p_{i2} ...., p_{in} \right) ^T \\
i=1,2, ....,m
とします。イメージとしては、このクラス$C_{i}$が与えられた下で、$\textbf{p}_{i}$が、文書全体で与えられている単語(上記tf-idf行列の列部分)の発生確率に対応しております。文書はここから文の長さだけ、単語の抽出を繰り返した結果ということになります。単純ベイズモデルは語順などの文脈は考慮せず、あくまでコーパスにどの単語がどの頻度で登場しているかについてのみ考える対象とします。
多項分布の単純ベイズモデルでは、この値が最も大きくなるクラス$C_{i}$でを出力することになります。
ここで学習データ数が$d$個存在したときの学習データとラベルが$ ( c_{1}, \textbf{x}_{1} ) , ( c_{2}, \textbf{x}_{2} ) , ..., ( c_{d}, \textbf{x}_{d} ) $と与えられたとした時の(ただし $ c_{i} \in C $ )、パラメタ$\textbf{p}_{i}$を最尤推定法により求めます。$ \textbf{x}_{i} = ( x_{i1}, x_{i2}, ...., x_{in} ) $ と表すことにすると、尤度方程式を$L \left( \textbf{p}_{1} , \textbf{p}_{2}, ..., \textbf{p}_{m} \right)$とすると
L \left( \textbf{p}_{1} , \textbf{p}_{2}, ..., \textbf{p}_{m} \right) = \prod_{i=1}^{d} \left( P \left( c_{i} \right) \frac{\left( \sum_{k=1}^{n} x_{ik} \right) !}{\prod_{l=1}^{n} x_{il} !} \prod_{s=1}^{n} p_{is}^{x_{is}} \right)
ここから対数をとった対数尤度より、$\textbf{p}_{i}$を求めます。ここでは、Lagrange未定乗数決定法を用いるなど、計算過程が煩雑になるので結果のみ示します。気になる方は、自然言語処理のための機械学習などに記載がありますので参考にしてみて下さい。クラス$ C_{i} $における、$\textbf{p}_{i} = \left( p_{i1}, p_{i2} ...., p_{in} \right) ^T $の成分$ p_{ij} $の推定量を$\tilde{p}_{ij}$とすると以下のようになります。
\tilde{p}_{ij} = \frac{ \mbox{クラス} i \mbox{に属する学習データの} x_{j} \mbox{成分} }{\mbox{クラス} i \mbox{に属する学習データの} x_{1} \mbox{成分から} x_{n} \mbox{成分のすべての総和}} = \frac{ \sum_{ \left( c, \textbf{x} \right) \in C_{i} } x_{ \cdot j} }{ \sum_{ \left( c, \textbf{x} \right) \in C_{i} } \sum_{k=1}^{n} x_{ \cdot k} }
また、MultinomialNBのライブラリでは、パラメータによる$\alpha$値を与え以下の式で計算されています(デフォルト値は1)。
\tilde{p}_{ij} = \frac{ \sum_{ \left( c, \textbf{x} \right) \in C_{i} } x_{ \cdot j} + \alpha }{ \sum_{ \left( c, \textbf{x} \right) \in C_{i} } \sum_{k=1}^{n} x_{ \cdot k} + \alpha n }
ここの$\alpha$値が先述の実装例のMultinomialNB呼び出し時に与えたパラメタのalpha値に対応します。
4. 検証データによるモデルの評価
実際の正解率と、予測と結果をマトリクスで表します。コードは以下のように実装しました。
#正解数をカウント
cnt = 0
#予想と結果をプロットするための行列
y_matrix = np.zeros((len(classify_dict), len(classify_dict)))
for idx, y in enumerate(y_test):
if y == test_predict[idx]:
cnt +=1
y_matrix[y, test_predict[idx]] += 1
print('accuracy: %f' % (cnt/len(y_test)))
class_list = [x for x in classify_dict.keys()]
class_index = ['Actually_%s' % x for x in classify_dict]
class_col = ['Predict_%s' % x for x in classify_dict]
display(pd.DataFrame(y_matrix, index=class_index, columns=class_col, dtype=int))
y_matrixの行が実際の正解、列が予測ラベル数が来るように実装しています(結果については長くなるのでgithubのソースとご覧ください)。全体の正答率が0.834でマトリクスを見るに本会議の正答率が低いように見受けられます。このあたり他の委員会と比べても特徴が出にくいのかもしれません。
また、アルファ値をデフォルト値に近づけると全体の正答率が3割くらいまで落ち込んだり、改めて機械学習の奥は深いなぁと感じたところでした。
追記(2019/07/27) *多項分布のモデルに連続値をとるベクトルを用いることについて
今回はTF-IDFを用いた前処理を実施していて、こちらは$\textbf{x}_{i}$の各成分が$[0,1]$で連続値をとりますが、他方でこちらは今回用いている多項分布によるモデルでは、各成分が$0$以上という仮定を置いており、あまり適切でないのではないかと言うコメントをいただきました。
確かに、モデルの定義上はその通りでして本来であれば、Bag of wordsのような単語の出現頻度を単にカウントしたような前処理が適切かと思います(Bag of wordsの説明はこちらが詳しいです)。
ただし、下記の引用の通りあくまで実用上TF-IDFで動かしても問題なく動くことが知られています。$\alpha$値を小さくする必要があったのも、TF-IDFによって全体的に値が小さくなりすぎたのが関係していると思われます。
MultinomialNB implements the naive Bayes algorithm for multinomially distributed data, and is one of the two classic naive Bayes variants used in text classification (where the data are typically represented as word vector counts, although tf-idf vectors are also known to work well in practice).
引用元)
https://scikit-learn.org/stable/modules/naive_bayes.html#multinomial-naive-bayes
参考
[第2版]Python 機械学習プログラミング 達人データサイエンティストによる理論と実践 Sebastian Raschkaなど インプレス
言語処理のための機械学習入門 (自然言語処理シリーズ) 高村 大也 コロナ社