Pythonを使って自然言語処理の前処理を行う

広告

 今回はテキストを使ってあんなことやこんなことをやるために、テキストを扱いやすい形に変換する方法を勉強します。準備の話で終わりなのですが、初デートに行こうと思ったらキャラクタTシャツしかなかったとにならないようにまさしく準備は大切ですよね。


 本エントリーを書くにあたっては@Hironsanさんの下記のブログを参考にさせていただきました。ありがとうございます!!
 自然言語処理における前処理の種類とその威力 - Qiita


 本エントリーで作成したコードはこちら(Github)にあります。

使用したテキストデータ

 今回使用したテキストデータは青空文庫にある福沢諭吉の『学問のすすめ』です。この本は有名ですがあの一言しか知らない人も多いかと思います。読んでみると含蓄深い良書であるので是非おすすめします。1万円札がこれまでより重く感じる様になりますが、それはキャッシュレス社会とは逆行していますね。そんなことはさておき、こちらからZIPファイルをダウンロードできます。

文章を単語に分割

 長々と前置きを書いてしまいましたが本題に入ります。文章を分析するためには、各文を数値に置き換えなければいけません。コンピュータは0-1でしか理解できないからです。ただ文をそのまま数値に置き換えるのは難しそうです。それぞれの文に番号を割り振ってもよいのですが、1語異なるだけで違う文となり、ギャル語なんてものが入ってきたら、理解できないコンピュータはおじさんの仲間入りと言われるかもしれません。


 そこで、文の最小単位である単語を数値化することでコンピュータに計算できるようにします。したがって、文を単語に分割することが第一歩となります。そのために今回は言わずとしれたMeCabを使います。MeCabのインストール方法やPythonでの使い方はここを参考にしました。ただそのまま最新のpythonのmecabを入れると、parseToNodeの部分がバグっているため適切な動作をしません。なので、バージョンを指定して入れています。こちらを参考にしました。

from pathlib import Path
import mojimoji as mj
import MeCab

data_dir_path = Path('.').joinpath('data')

tagger = MeCab.Tagger("-Ochasen")
tagger.parse('')

#  「学問のすすめ」のデータを読み込む
with open(data_dir_path.joinpath('gakumonno_susume.txt'), 'r', encoding='utf-8') as file:
    lines = file.readlines()

sentences = []
for sentence in lines:
    texts = sentence.split('。')
    sentences.extend(texts)

sentence = sentences[0]
words = tagger.parse(sentence)

print('元の文: ', sentence)
print('')
print('MeCabにより分割後')
print(words)


 結果は以下の様になりました。

元の文:  「天は人の上に人を造らず人の下に人を造らず」と言えり

MeCabにより分割後
「	「	「	記号-括弧開		
天	テン	天	名詞-一般		
は	ハ	は	助詞-係助詞		
人	ヒト	人	名詞-一般		
の	ノ	の	助詞-連体化		
上	ウエ	上	名詞-非自立-副詞可能		
に	ニ	に	助詞-格助詞-一般		
人	ヒト	人	名詞-一般		
を	ヲ	を	助詞-格助詞-一般		
造ら	ツクラ	造る	動詞-自立	五段・ラ行	未然形
ず	ズ	ぬ	助動詞	特殊・ヌ	連用ニ接続


 元の文が単語に分割できているのがわかります。更に、各単語に品詞もついているので、分析の目的に合わせて品詞を制限することもできます。

単語の削除

f:id:dskomei:20190403233931p:plain:right:w500
 右のグラフは先程分割した単語の出現頻度のTOP30を表しています。これを見て明らかな通り、助詞と記号の出現頻度が高いです。テキストの分析においてやりたいことは何かしらの示唆を得ることです。その際、出現頻度の高い単語に着目することが多いです。しかし、このままでは「の」の影響を強く受けた分析になりそうですが、そこから導かれた結果に意味があるとは思えません。なので、分析する際に必要のないものは削っておきましょう。


 今後行う分析では、使用されている単語の関連性を可視化していこうと思うので、名詞だけに絞っています。更に意味のなさそうな(分析の観点では必要のない)単語のリスト Slothlib が用意されているので、これらも省きたいと思います。この意味のない単語郡のことをストップワードと呼びます。


 下記では、ストップワードをダウンロードし、指定した品詞(今回は名詞のみ)でストップワードに含まれていない単語のみを取り出しています。単語分割の処理は関数 split_sentence でしており、複数分においてはループを行うことで単語に分けられます。

# ストップワードをダウンロード
url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
urllib.request.urlretrieve(url, 'stop_word.txt')

with open('stop_word.txt', 'r', encoding='utf-8') as file:
    stopwords = [word.replace('\n', '') for word in file.readlines()]

def split_sentence(sentence, stopwords=()):

    node = tagger.parseToNode(sentence)
    words = []
    while node:
        word = node.surface
        feature = node.feature.split(',')
        if feature[0] == '名詞' and node.surface not in stopwords:
            words.append(word)
        node = node.next

    return words

sentence = sentences[0]
words = sentence_split(sentence, stopwords=stopwords)

print('元の文: ', sentence)
print('')
print('MeCabにより分割後')
print(words)


 下記は実行結果です。分割後では「天」のみが残りました。「上」・「下」も名詞ですが、ストップワードに含まれているため削除されました。この文において「上」「下」は重要な意味を持っているため省くかどうかは悩みますが、一般的には重要ではないかもしれません。時と場合によってこのストップワードの修正が必要かもしれません。

元の文:  「天は人の上に人を造らず人の下に人を造らず」と言えり

MeCabにより分割後
['天']


単語の正規化

 単語の分割をしてみると表記揺れがあることに気づきます。「iPhone」と「iphone」や「イイネ」と「イイネ」など。特にSNSから取得したテキストには多く見られます。これらをこのままにしてしまうと、異なる語となってしまい適切な分析ができません。そこで、このような表記揺れの統一を行います。ポイントは以下3点です。

  1. 表記揺れの統一
  2. 大文字を小文字に統一
  3. 数字を0に統一


・表記揺れの統一
 表記揺れを修正するために便利なモジュールneologdhがあります。これを使えば、連続した長音付を一つにまとめたり、半角の日本語を全角に変換したりしてくれます。pipを使えば秒で入れられます。

import neologdn
s = "キターーーーー━(・∀・)━!!!!DQⅢ①⑳海海神神㌔㍉"
s = neologdn.normalize(s)
print(s)
キター(・∀・)ー!!!!DQⅢ①⑳海海神神㌔㍉

 実行結果を見ると「キタ」→「キタ」に変わり、長音付が1つになり、全角の英文字が半角になっています。ただ「海海神神」に関しては海と神に統一されていません(上記のコード画面では「海海神神」になっているかもしれませんが、異なる単語になっています)。まだ修正できそうな箇所を対応してみます。

import unicodedata
s = unicodedata.normalize("NFKC", s)
print(s)
キター(・∀・)ー!!!!DQIII120海海神神キロミリ

 丸数字と㌔㍉が修正されていることがわかります。「海海神神」も修正されています(これは本当です)。


・大文字を小文字に統一
 正式名称が大文字と小文字が混じっているものでも、全て小文字に変換します。人により入力の差異がある部分はなくした方が分析結果から適切な意図を汲み取れます。

s = 'iPhone'
s = s.lower()
print(s)
iphone


・数字を0に統一
 文においてはそれぞれの数字の値に意味がある場合もありますが、単語の関連性を分析するうえでは数字は数字として一つにまとめた方が良いです。

s = '円周率は3.14である'
s = re.sub(r'\d+\.*\d*', '0', s)
print(s)
円周率は0である

 実行結果を見ると、数字の部分が0に変わっていることがわかります。ただ「円周率は0である」とはゆとり教育もびっくりですね。


 これまで述べた正規化を行うと、頻出単語数の上位TOP30は以下のようになりました。下記の図を見る通り、意味のある単語が上位にあります。また、政府・人民・独立の単語が多く含まれることから、これらをテーマにしている記述が多いことが推測されます。ところで、22位に「交際」があるところは気になりますね。急な物語の展開があるのかもしれません。

f:id:dskomei:20190404125724p:plain:w600

テキストを行列へ

 これまでの工程のおかげで文章を数値の行列に変換する準備は整いました。長々と書きましたが、データ分析は8割の作業が前処理というように、ここまでの作業が大変です。しかし、あとは絶対的な守護神がマウンドに上ったときのように、ジャンプしながら気楽にゲームセットまで待ちましょう(もう少し手を動かす必要はありますが)。


 考え方は下記の手順通りです。

  1. 単語の出現回数の多いものから番号を振る
  2. それぞれの文の単語を1.で求めた番号に置き換える
  3. 行数が文の数、列数が単語数のゼロ行列において、それぞの文の行の2.で求めた番号の列を1で埋める
splited_setences_1d = []
for sentence in splited_setences:
    splited_setences_1d.extend(sentence)

#  単語の出現回数を求める
word_count = collections.Counter(splited_setences_1d)

n_word = len(word_count)
print(word_count.most_common(30))
print('文の数:{}, 単語数:{}'.format(len(splited_setences), n_word))
[('政府', 197), ('人民', 126), ('独立', 76), ('身', 72), ('心', 72), ('理', 71), ('人間', 69), ('世', 69),
文の数:1595, 単語数:3525

 上記の処理で単語の出現回数を求めました。先程グラフで見たものと出現回数の多い順が合致しています。しかし、文の数と単語数を比較してみると、単語数が多いことがわかります。そこで、どの出現回数で多いかを求めてみます。

import pandas as pd
df = pd.DataFrame(word_count.most_common(n_word), columns=['word', 'count'])
df_word_count = df.groupby('count').count()
df_word_count.reset_index(inplace=True)
df_word_count.columns = ['count', 'n_word']
df_word_count['cumsum'] = df_word_count['n_word'].cumsum()
df_word_count['cumsum_rate'] = df_word_count['cumsum'] / df_word_count['n_word'].sum()

print(df_word_count.head())
fig, ax1 = plt.subplots(figsize=(10, 6))
ax2 = ax1.twinx()

# それぞれのaxesオブジェクトのlines属性にLine2Dオブジェクトを追加
ax1.bar(df_word_count['count'], df_word_count['n_word'], color='blue', label="単語数")
ax2.plot(df_word_count['count'], df_word_count['cumsum_rate'], color='red', label="累積割合")

# 凡例
# グラフの本体設定時に、ラベルを手動で設定する必要があるのは、barplotのみ。plotは自動で設定される>
handler1, label1 = ax1.get_legend_handles_labels()
handler2, label2 = ax2.get_legend_handles_labels()
# 凡例をまとめて出力する
ax1.legend(handler1 + handler2, label1 + label2, loc=2, borderaxespad=0.)
plt.savefig(image_dir_path.joinpath('word_count_cumsum.png'), bbox_inches="tight")


 実行結果は下記のとおりであり、出現回数が1回しかない単語が全体の半分を占めていることがわかります。下記の出現回数と単語数のグラフを見ても明らかです。この結果より、出現回数3回以上の単語(上位23%)に絞ります。

   count  n_word  cumsum  cumsum_rate
0      1    1832    1832     0.519716
1      2     602    2434     0.690496
2      3     303    2737     0.776454
3      4     185    2922     0.828936
4      5     106    3028     0.859007

f:id:dskomei:20190404164440p:plain:w550
 それでは単語数を制限して行列に変換していきます。

#  単語数を制限
n_word = int(n_word * (1 - df_word_count['cumsum_rate'][2]))

#  出現回数が多い順に番号をふる
word_dicts = dict([[key[0], i] for key, i in zip(word_count.most_common(n_word), range(n_word))])

#  各文の単語を出現回数ランキング番号に変換する
word_numbers = [[word_dicts[word] for word in words if word_dicts.get(word, False)] for words in splited_setences]

# 行数:文の数、列数:単語個数に対応している
bag_of_words = np.zeros((len(splited_setences), n_word))
# それぞれの文で出現する単語に頻出回数ランキングの番号の列に1を代入
for i, numbers in enumerate(word_numbers):
    bag_of_words[i][numbers] = 1

#  行列化が完了したデータを保存
np.save(data_dir_path.joinpath('bag_of_words.npy'), bag_of_words)
終わりに

 今回テキストの前処理に関して書きましたが、更に発展してテキストにおける機械学習を行うためのコーディングに関しては下の本が参考になります。

実践 機械学習システム

実践 機械学習システム