見出し画像

自然言語処理⑤~文章の表現・tfidf・感情分析実践とか~

前回でWord2Vecの使い方を学び、単語のベクトル化や類似する単語を検出することを学びました。

しかし、実際には文章単位で解析したいことの方が多かったりする印象があるため、ここから前回の範囲をより広げて実践に持って行けたらと思います。

いつもどおり、この辺の内容はそこらへんに転がってますので、お好きなサイトも適宜見ながらで十分です


では、今回もよろしくお願いします。

・単語から文書の表現(BOW)

単語の定量化の時に、単語をベクトル表現する方法(one-hot表現)を学びましたが、文章も解析するにあたり定量的に観測しなければいけません。

今回のBoW(Bag of Words)は語順関係なく、単語の出現頻度を観測しベクトル的に表現する方法です。

少し図で見ていきます。

スクリーンショット 2021-08-27 13.04.33

今回一つしか文章をベクトルで表さなかったので、全ての要素が1以上になっていますが、本来は大量の文書をベクトルで表すため、基本的に0が入ります。

非常にシンプルなものの、これだけでもかなりいろんなことができます。

しかし、このBoWも弱点があります。

例えば、上の例で「を」という単語が2回カウントされていますが、文章において重要な意味ではないです。

このように、接続詞などのどの文書にも必ず出てくるけど意味のないものを非常に大量にカウントしてしまうことになります。

そこで、それらの重要度を調整するためにある理論がtf-idfとなります。

・tf-idfとは?

以前勉強した時に、このtf-idfがどうしてもわからなかった時、YouTubeで非常にわかりやすい動画に助けられた記憶があるこのtf-idf。

ここではlogなどの数式が出てきますが、複雑ではないので、ぜひご覧ください。

tf-idfはそもそも
tf:term freaquency
idf:inverse document frequency

の二つの観点でアプローチしていきます。

---------------------

- tf(term freaquency)

tf(t, d) = 文書dに含まれるtの個数 / 文書dの全ての単語数

スクリーンショット 2021-08-27 13.49.19

- idf(inverse document frequency)

df(t) = 単語tが含まれている文書の個数 / 全文書の個数

このdfを逆数にしてlogを取ったものがidfとなります。(+1は0で割ることを防ぐことと0の対数を取ることを防ぐためにつけています。)

スクリーンショット 2021-08-27 14.20.45

対数をとっているのは文書数が多いほど全体の数値が大きくなりやすく、過度な重み調整がかかることを避けています。(ちなみにlog(1/確率)が情報量を表すそうですが、情報理論になっちゃうので、割愛)

---------------------

tf-idfはこれらの数値の掛け算で算出されたもので、それぞれの単語ベクトルを調整していきます。

---------------------

数値とか数式だけみて、はいそうですか、となる人もならない人もいるかと思いますので、少し文章で「具体的にtf-idfは何を意味しているものなのか?」を掘り下げてみたいと思います。

まず、tfは単語tの割合を表すため、めちゃくちゃ長い文章であるときに個数が増えて大変!という状況を補正することができます。(割合にしているため、数値的に抑えることができます)

次にidfは、対数の中身を見てみると、

全文書の中に単語 tが入っている文章がどれだけあるのか?という割合をとってきているので、その単語 tがどれくらい希少なのか?を考えたのちに、逆数をとっているので、

確率(df)が小さければ小さいほど逆数は大きくなります。

いわば、単語の「レア度」(遊戯王っぽいw)を考えています。

では、これらを掛け合わせることで全体を見て頻繁に使われる単語は有益でないと判断し、レア度の高いものを正しく捉える(検索などで優先順位をつけたりする)ことができます。

数字を使わず、例を見てみましょう。

例えば検索エンジンに「NLP 定義 とは」と調べたいとします。

そこで検索エンジンはtf-idfを計算して、どの単語を優先度を上げてクロールする必要があるのかを見なければいけません。

それぞれを見ていくと、

tf(NLP) * 1/df(NLP), 
tf(定義) * 1/df(定義), 
tf(とは) * 1/df(とは)

を計算するのですが、肌感とすれば、NLPのレア度はめちゃくちゃ高くて、「とは」の重要性はそれほど大きくないと感じますね。

つまり、検索エンジンとしてはまず「NLP」から検索していき、その次に「定義」という単語も付いていればより優先度を上げていき、結果を上位に表示していけばいいと判断しているわけです
(厳密性よりもイメージの話ですので、検索エンジンの仕組みなどは考慮してない)

・tf-idfを実装で体感してみる

実際にtf-idfは自作関数でもいけますが、もはやsklearnの領域にありますので、こちらを使っていきます。

(ダサいコードで)ちょっと今回もスクレイピングでデータを取ってくることにします。

(わかりやすい例にした方がいい気もするのですが、まぁ、実際に綺麗なtf-idfを見れることもそこまでないのでw)

なお、スクレイピングは前回同様説明に関しては割愛します。。

import re
import requests
import pandas as pd

from bs4 import BeautifulSoup


url = 'https://news.yahoo.co.jp/articles/ff5409270819b24cd383aa360b3b376bd82f10f9'

response = requests.get(url)

soup = BeautifulSoup(response.content, 'html.parser')

class_body = 'div.article_body p'
contents = soup.select(class_body)

raw_corpus = [co.text for co in contents]

re_corpus = []

for co in raw_corpus:
 co = re.sub('(\/\**)|(.*\*)|(.*;})', '', co)
 re_corpus.append(co.strip())

split_co = []
for re_co in re_corpus:
 re_coes = re_co.split('\n\u3000')
 for re_co in re_coes:
   # re_co = re_co.replace('\n', '')
   if len(re_co) > 2:
     split_co.append(re_co.replace('\n', ''))


df = pd.DataFrame(split_co, columns=['text'])
df

スクリーンショット 2021-08-27 16.48.47

今回はせっかくなので、janomeの分かち書きでも使おうと思います。

その前にstopwordのリストだけ作っておきます。

!wget http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt
# stopwordsの生成
path = './Japanese.txt'
with open(path, 'r') as f:
 stop_words = f.readlines()
 stop_words = [str.strip() for str in stop_words]

では、janomeを使いながら、一気にまずはcodeを書いてみます

from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.tokenfilter import *
split_list = [doc for row in df['text'] for doc in row.split('。') ]
split_list = [doc for doc in split_list if len(doc) > 2]

count = CountVectorizer()

wakati_list = []

t = Tokenizer()
# それぞれの要素を分ち書き
for arr in split_list:

 each_wakati_list = []

 # 各文書にて分かち書きを実行
 tokens = t.tokenize(arr, wakati=True)

 # stopwordのリスト以外の単語を一つのリストに格納
 for token in tokens:
   if token not in stop_words:
     each_wakati_list.append(token)

 # 分かち書きを空白スペースを入れてマージし、一要素として大枠に格納
 wakati_list.append(' '.join(each_wakati_list))

wakati_array = np.array(wakati_list)

bag = count.fit_transform(wakati_array)

bag.toarray()

スクリーンショット 2021-08-27 17.04.51

wakati_array

スクリーンショット 2021-08-27 17.05.17

bag.toarray().shape
>>> (10, 60)
feature_names = count.get_feature_names()
feature_names

スクリーンショット 2021-08-27 17.12.37


次のコードでtf-idfを用いるのですが、初めましてのCountVectrizerがあるので、ドキュメントを見ておきます。

class sklearn.feature_extraction.text.CountVectorizer(*, input='content', encoding='utf-8', decode_error='strict', strip_accents=None, lowercase=True, preprocessor=None, tokenizer=None, stop_words=None, token_pattern='(?u)\b\w\w+\b', ngram_range=(1, 1), analyzer='word', max_df=1.0, min_df=1, max_features=None, vocabulary=None, binary=False, dtype=<class 'numpy.int64'>)

自分がそこまでこのクラスに精通していないので、説明は割愛(ドキュメントにもいい例がなかった。。)するのですが、

ngram_rangeに関しては、以前に実装したngramの単位n(min_n,max_n)が指定できて、例えば、(2, 2)とすれば、bi-gramを用いて処理してくれます。

また、max_df(min_df): floatで設定すると全文書の指定した数値以上(min~なら以下)の割合で出てくる単語は無視する(いわゆる接続詞とかを除きたい)ことができます

# min_dfを設定
count_2 = CountVectorizer(min_df=0.2)

bag_2 = count_2.fit_transform(wakati_array)

print(bag_2.toarray().shape)
bag_2.toarray()

スクリーンショット 2021-08-27 17.22.18

次元数が減ったことを確認できました!

文書数が大量かつ一文が非常にながい時にこれをある程度制限していくといいかもしれませんね。


次に、methodsを見ていくことにしましょう。

fit_transformでdocument-term matrixという聴き馴染みのないobjectが帰ってきますが、基本的には上記のように.toarray()を使えばいいです。

ちなみに中身はそれぞれの単語と出現回数を格納した辞書を学習して出力してくれます。

どれがどの単語に対応しているのかを確認したい時はget_feature_namesを使うとみることができます。

その他にもMethodは複数ありますが、本題のために一旦進めます。

実際にtf-idfを実装しましょう。

from sklearn.feature_extraction.text import TfidfTransformer


tfidf = TfidfTransformer()

tfidf_tr = tfidf.fit_transform(bag)
tfidf_tr.toarray()

スクリーンショット 2021-08-27 17.32.29

では公式ドキュメントを見てみます。

class sklearn.feature_extraction.text.TfidfTransformer(*, norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False)

ちょっと細やかにみていきます。

norm: 機械学習でもでてくる正則化の設定を選べます(L2 or L1)

(まさに最近知ったのですが)計算時、sklearnでは裏で正則化を行うことで一文の長さによる単語数の過剰な出現を補正しているそうです。
また、厳密にはsklearnのtf-idfの計算式は上記で学んだ数式と微妙に違います
(裏で計算されているので特にどう違うのかを考える必要は基本ないと思っています。)

use_idf: idfを使い重みを調整するかどうか

smooth_idf: 全ての文書に出てくる単語の重みを0にするかどうか(こちらも裏の処理が関係しているので、基本デフォルト設定でいいと思います)

(他は割愛します)

こちらもfit_transform  で格納したarrayをtf-idfを施した状態で返ってきます(理論は説明済みなので、特に深掘りは避けます。)

これをみてなんなの?となるかと思いますが、いわば標準化のようなイメージでこの補正されたものを機械学習を用いて分析していくので、今後こちらの関数などはおそらく他のところでもみるのかなと思います。

・tf-idfを機械学習で使ってみよう!

では、せっかくなのでtf-idfのつかどころをみていきながら機械学習で感情分析のロジスティック回帰をしてみます。

パラメータのチューニングにGridSearchとか使うので、復習したいという方は過去の投稿をご覧くださいませ。

ではまずデータの準備ですが、いつも通りコピペだけでいけるようにしていますので、駆け足で終わらせちゃいます。

- unixコマンドでローデータの取得

!wget http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
!tar -zxf aclImdb_v1.tar.gz​

- データの読み込み

import os
import pandas as pd
import numpy as np


basepath = 'aclImdb'
labels = {'pos': 1, 'neg': 0}
df = pd.DataFrame()

for data_path in ('test', 'train'):
 for sentiment in ('pos', 'neg'):
   path = os.path.join(basepath, data_path, sentiment)

   for file in sorted(os.listdir(path)):
     with open(os.path.join(path, file), 'r', encoding='utf-8') as infile:
       txt = infile.read()

     df = df.append([[txt, labels[sentiment]]], ignore_index=True)


df.columns = ['review', 'sentiment']

np.random.seed(0)
df = df.reindex(np.random.permutation(df.index))​

- 正規表現で不要な文字などのクリーニング

import re


def preprocessor(text):

 text = re.sub('<[^>]*>', '', text)
 emotions = re.findall('(?::|;|=)(?:-)?(?:\)|\(|D|P)', text)

 text = (re.sub('[\W]+', ' ', text.lower()) + ' '.join(emotions).replace('-', ' '))
 return text
df['review'] = df.review.apply(preprocessor)

import nltk
nltk.download('stopwords')
from nltk.corpus import stopwords
stop = stopwords.words('english')

def tokenizer(text):
 return text.split()

- 訓練データとテストデータの準備

X_train = df.loc[:25000, 'review'].values
y_train = df.loc[:25000, 'sentiment'].values

X_test = df.loc[25000:, 'review'].values
y_test = df.loc[25000:, 'sentiment'].values

以上でデータを揃えました。

では、まずは一気に機械学習のコードを書いていきます!

ーー(以下のコードの処理はランダマイズsearchCVですけど、まぁまぁ時間かかります。colaboratoryで大体5分くらいでした。。)ーー

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import RandomizedSearchCV
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline


# tf-idfのvectorをインスタンス化
tfidf = TfidfVectorizer(strip_accents=None, lowercase=False, preprocessor=None)

param_dist = [{'vect__stop_words': [stop, None], 
               'vect__tokenizer': [tokenizer], 
               'clf__penalty': ['l2', 'l1'], 
               'clf__C': [1.0, 10.0, 0.1]},
              {'vect__stop_words': [stop, None], 
               'vect__tokenizer': [tokenizer], 
               'vect__use_idf': [False],
               'clf__C': [1.0, 10.0, 0.1]}]

lr_tfidf = Pipeline([('vect', tfidf), ('clf', LogisticRegression(solver='liblinear'))])

rs_lr_tfidf = RandomizedSearchCV(lr_tfidf, param_distributions=param_dist, 
                                cv=10)

rs_lr_tfidf.fit(X_train, y_train)

ーーーー

print(f'Score is {rs_lr_tfidf.best_score_:.3f}')

スクリーンショット 2021-08-27 19.29.17

clf = rs_lr_tfidf.best_estimator_

score = clf.score(X_test, y_test)

print(f'Test score is {score:.3f}')

スクリーンショット 2021-08-27 19.29.37

では、中身をみていきます。

class sklearn.feature_extraction.text.TfidfVectorizer(*, input='content', encoding='utf-8', decode_error='strict', strip_accents=None, lowercase=True, preprocessor=None, tokenizer=None, analyzer='word', stop_words=None, token_pattern='(?u)\b\w\w+\b', ngram_range=(1, 1), max_df=1.0, min_df=1, max_features=None, vocabulary=None, binary=False, dtype=<class 'numpy.float64'>, norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False)

めちゃくちゃパラメータが多いので、いくつかpick upして解説します。

前提としては、TfidfVectorizerはCountVectorizer とTfidfTransformerを一気に適用したものです。

文書を直接入れることで、tf-idfに適用した状態のベクトルが返ってきます。

strip_accents: character を定義(asciiかunicode)することで、accentsを取り除き標準化してくれる(その前に大体トリミングなどは終わっている気もする)

lowercase: tokenizeする前に全ての文字を小文字にするかどうか

preprocessor: n-gramとtokenizeする間にオーバーライドする関数を定義

tokenizer: n-gramと前処理の間にオーバーライドするtokenizeの関数などを適用

stop_words: list or 'english'で定義。

max_df(min_df): CountVectrizerと同じ

などなど。。

実際に今回で言えば、わりと精度高く感情分析ができました。

・一旦終わり。

今回、ちょっと長くなってきたので、ここで区切ります。

Doc2Vecやアウトオブコア学習、100本ノックは次回に持ち越します。










この記事が気に入ったらサポートをしてみませんか?