Pythonを使ってテキストマイニングのための前処理を行う 〜自然言語処理における前処理〜

スポンサーリンク

 
 今回は、テキストから何らかの示唆を出すテキストマイニングを行うためのテキストの加工処理(前処理)について書いていきます。テキストは手元にたくさんあるのに、どうしたらいいかわからない、単語の数は数えられたけどノイズな単語が多くて示唆が出せなかった、といったことは起こりがちです。


 テキストマイニングにより役に立つ情報を得るためには、テキストの適切な前処理が必要になります。例えば、「AI」という単語についてテキストの中では、「ai」「Ai」「人工知能」など様々な表記方法があり、これらを別々でカウントしてしまうと、「AI」の適切なボリュームを表していないことになります。これらは、できれば統一したいです。


 本記事では、Pythonを使ってテキストを前処理する方法について書きました。この記事で作成したコードはこちら(Github)にあります。



使用したテキストデータ


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


文章を単語に分割


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


 そこで、文の最小単位である単語を数値化することで、コンピュータが計算できるようにします。そのための第一歩が、文を単語に分割することです。これは、言わずとしれたMeCabを使うと簡単です。MeCabのインストール方法やPythonでの使い方はここを参考にしました。


 今回使うモジュールをまとめてインポートします。どのモジュールも pip によりインストールできます。

from pathlib import Path
import mojimoji
import neologdn
import unicodedata
import MeCab
import urllib
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

data_dir_path = Path('data')


 MeCabを使って文章を単語に分割します。MeCabは各単語に品詞と語幹をつけてくれるすぐれものです。

mecab = MeCab.Tagger('-Ochasen')

#  「学問のすすめ」のデータを読み込む
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 = mecab.parse(sentence)

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

f:id:dskomei:20210524200909p:plain:w400


 上の図を見てもらうと、元の文が単語に分割できていることがわかります。更に、各単語に品詞もついているので、分析の目的に合わせて特定の品詞の単語を抽出することもできます。


不要な単語の削除


 文を単語に分割できる方法がわかったので、どの単語がよく出現しているのかを見てみようと思います。

mecab = MeCab.Tagger('-Owakati')
words = []
for sentence in sentences:
    words.extend(list(filter(
        lambda x: x != '',
        mecab.parse(sentence).replace('\n', '').split(' ')
    )))

data = pd.DataFrame({'word': words}).assign(freq=1).groupby(
    'word'
)['freq'].count().reset_index().sort_values('freq', ascending=False)

top_n_word = 30
plt.figure(figsize=(8, 6))

sns.barplot(
    data=data.head(top_n_word),
    x='freq',
    y='word'
)
plt.title('単語の出現回数TOP{}'.format(top_n_word), fontsize=18)
plt.xlabel('')
plt.ylabel('')
plt.yticks(fontsize=12)
plt.tight_layout()
plt.savefig(result_dir_path.joinpath('word_count_top{}.png'.format(top_n_word)), dpi=300)

f:id:dskomei:20210524205513p:plain:w500


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


名詞かつストップワード以外の単語を抽出


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


 下記では、ストップワードをダウンロードし、名詞かつストップワードに含まれていない単語のみを取り出しています。

def get_noun_words_from_sentence(sentence, mecab, stopword_list=[]):
    return [
        x.split('\t')[0] for x in mecab.parse(sentence).split('\n') if len(x.split('\t')) > 1 and \
         '名詞' in x.split('\t')[3] and x.split('\t')[0] not in stopword_list
    ]

mecab = MeCab.Tagger('-Ochasen')

sentence = sentences[0]

nouns = get_noun_words_from_sentence(
    sentence=sentence, mecab=mecab, stopword_list=stopword_list
)

print('元の文:')
print(sentence)
print('')
print('MeCabにより名詞抽出後:')
print(nouns)

f:id:dskomei:20210524204838p:plain:w400


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


単語の正規化


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

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


表記揺れの統一


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

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

f:id:dskomei:20210524210104p:plain:w600


 実行結果を見ると「キタ」→「キタ」に変わり、長音付が1つになり、全角の英文字が半角になっています。しかしながら、実行結果をみると「海海神神」は海と神に統一されているように見えますが、実際は統一されていません。

word1 = s[20]
word2 = s[21]
print('{} == {}'.format(word1, word2))
print('{}'.format(word1 == word2))

f:id:dskomei:20210524210910p:plain:w600


 更に正規化をしていきます。

s = unicodedata.normalize("NFKC", s)
print(s)

f:id:dskomei:20210524210957p:plain:w600


 丸数字と㌔㍉が修正されていますし、「海海神神」も統一されています。

word1 = s[23]
word2 = s[24]
print('{} == {}'.format(word1, word2))
print('{}'.format(word1 == word2))

f:id:dskomei:20210524211134p:plain


大文字を小文字に統一


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

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

f:id:dskomei:20210524212801p:plain:w600


数字を0に統一


 文の中にある数字は意味がある場合もありますが、単語の関連性を分析するうえでは、数字は一つにまとめた方が良い事が多いです。

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

f:id:dskomei:20210524221328p:plain:w600


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


一連の前処理による単語の出現回数


 これまで述べた正規化を行うと、単語の出現回数の上位TOP30は大幅に変わりました。

def split_sentence(sentence, mecab, stopword_list):
    sentence = neologdn.normalize(sentence)
    sentence = unicodedata.normalize("NFKC", sentence)
    words = get_noun_words_from_sentence(
        sentence=sentence, mecab=mecab, stopword_list=stopword_list
    )
    words = list(map(lambda x: re.sub(r'\d+\.*\d*', '0', x.lower()), words))
    return words

mecab = MeCab.Tagger('-Ochasen')
words = []
for sentence in sentences:
    words.extend(split_sentence(sentence=sentence, mecab=mecab, stopword_list=stopword_list))

data = pd.DataFrame({'word': words}).assign(freq=1).groupby(
    'word'
)['freq'].count().reset_index().sort_values('freq', ascending=False)

top_n_word = 30
plt.figure(figsize=(8, 6))

sns.barplot(
    data=data.head(top_n_word),
    x='freq',
    y='word'
)
plt.title('正規化後の単語の出現回数TOP{}'.format(top_n_word), fontsize=18)
plt.xlabel('')
plt.ylabel('')
plt.yticks(fontsize=12)
plt.tight_layout()
plt.savefig(result_dir_path.joinpath('word_count_normalized_top{}.png'.format(top_n_word)), dpi=300)


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


テキストを行列に変換する


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

  1. 単語の出現回数を求める
  2. 出現回数ごとの単語数を求める
  3. 出現回数が一定以上の単語を使い、各分の単語の出現回数行列を作る
mecab = MeCab.Tagger('-Ochasen')

words = []
for sentence in sentences:
    words.extend(split_sentence(sentence=sentence, mecab=mecab, stopword_list=stopword_list))

word_freq_pd = pd.DataFrame({'word': words}).assign(freq=1).groupby(
    'word'
)['freq'].count().reset_index().sort_values('freq', ascending=False)

word_freq_pd.head(10)

f:id:dskomei:20210524234321p:plain:w500


 これまでも何回も行ってきたように単語の出現回数を求めました。テキストデータでよくあるのが、実は出現回数が少ない単語ばかりだっと言うパターンです。この場合は、全体に影響しないノイズ的な単語によって適切な分析ができなくなるかもしれません。なので、出現回数ごとの単語数を見ます。

freq_pd = word_freq_pd.groupby('freq')['word'].count().reset_index().rename(
    columns={'word': 'n_word'}
).assign(total_n_word=lambda x: x.n_word.sum()).assign(
    rate=lambda x: x.n_word / x.total_n_word
).assign(cumsum=lambda x: x.rate.cumsum())

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

ax1.bar(freq_pd['freq'], freq_pd['n_word'], color='blue', label="単語数")
ax2.plot(freq_pd['freq'], freq_pd['cumsum'], color='red', label="累積割合")

handler1, label1 = ax1.get_legend_handles_labels()
handler2, label2 = ax2.get_legend_handles_labels()
ax2.set_ylim(0, 1)

ax1.legend(handler1 + handler2, label1 + label2, loc=2, borderaxespad=0.)
plt.tight_layout()
plt.savefig(result_dir_path.joinpath('word_count_cumsum.png'), dpi=300)

f:id:dskomei:20210524225849p:plain:w600


 出現回数が1回しかない単語が全体の半分を占めていることがわかります。このような出現回が少ない単語は、全体に影響力がないので削減してしまいます。

freq_lower = 3

bag_of_words = []
for i, sentence in enumerate(sentences):
    bag_of_words.extend([
        (i, word) for word in split_sentence(sentence=sentence, mecab=mecab, stopword_list=stopword_list)
    ])
    
bag_of_words = pd.DataFrame(bag_of_words, columns=['number', 'word'])
bag_of_words = pd.merge(
    bag_of_words,
    word_freq_pd.query('freq >= {}'.format(freq_lower))[['word']],
    on='word', how='inner'
).assign(freq=1).groupby(['number', 'word'])['freq'].count().reset_index().pivot_table(
    index='number',
    columns='word',
    fill_value=0
)
bag_of_words.columns = list(map(lambda x: x[1], bag_of_words.columns))
bag_of_words.to_csv(data_dir_path.joinpath('bag_of_words.csv'), index=False)
bag_of_words.head()

f:id:dskomei:20210524232100p:plain:w600


 上記の処理で各文を単語の出現回数で表したデータが完成しました。これは Bag of Words と呼ばれています。数字列に変換できたので、このデータを使ってクラスタリングや予測モデルを作るのに使えます。


 また、上記の Bag of Words を numpy で保存するのは以下の方法でできます。

np.save(data_dir_path.joinpath('bag_of_words.npy'), bag_of_words.values)



終わりに


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