EurekaMoments

ロボットや自動車の自律移動に関する知識や技術、プログラミング、ソフトウェア開発について勉強したことをメモするブログ

自然言語データを前処理する際のPython逆引きメモ

目的

これまでにデータ解析の仕事で扱ってきたのは主に
時系列データや画像データなど、数値で表現される
データでした。

しかしながら、最近では自然言語データのように
数値データではないものも解析できることが重量と
なってきました。

今回、上記の書籍で自然言語データに対する前処理の
手法について勉強したのでこの記事でまとめておこうと
思います。

目次

自然言語処理の考え方

既に書いたように、自然言語データは数値で表現される
データではありません。そのため、そういったデータを
機械学習に利用するには、何らかの手法によってデータに
含まれる単語を数値に変換すればいいことになります。

ここから先は、そういった変換処理の手法や、それを
実現するPythonライブラリ、サンプルコードなどを
紹介していきます。

形態素解析(Morphological Analysis)

形態素とは、言語を意味のある表現要素に分割したときの
最小単位のことです。そして形態素解析とは、元の文章や
会話を形態素にまで分割して、それらがどの品詞にあたる
のかを分類する手法になります。

活用例

形態素解析の代表的な活用例としては、検索エンジンや
ニュースアプリ、かな漢字変換などがあります。

Janomeによる形態素解析

Janomeは、Pythonアプリで形態素解析を実行するのに
用いられる形態素解析ライブラリです。ここでは、Janomeを
使った形態素解析のPythonサンプルコードについて説明
します。
mocobeta.github.io

まずはこのように、解析する対象のテキストファイルを
読み込みます。

f = open("analysis_target_text.txt", encoding="utf-8")
txt = f.read()
print(txt)
f.close()

次に、こちらのコードにより読み込んだテキストファイルの
文章を単語に分割し、アルゴリズムが処理できる形式に変換
します。この処理をトークナイズと呼び、それを実行する
プログラムはトークナイザと呼びます。

from janome.tokenizer import Tokenizer

t = Tokenizer()
for token in t.tokenize(txt):
    print(token)

このように分割された単語とその属性が出力されますが、
ときには、数字や記号、句読点のように単体では意味を
なさないものまで含まれているので、これらをノイズとして
除去する処理を施す必要があります。

正規表現による不要な文字列の除去

数字や記号を取り除くには正規表現(regular expression)という、
いくつかの文字列を1つの形式で表現する処理を施します。
そのためのコードはこちらのように書けます。

import re

reg_txt = re.sub(r"[0-9a-zA-Z]+", '', txt)
reg_txt = re.sub(r"[:;/+\.~]", '', reg_txt)
print(reg_txt)

re.sub()の一つ目の引数で指定した文字列を1つでも含むなら、
それを二つ目の引数で指定した文字列で置換します。今回の
ように空白で置換するということは除去するということに
なります。

また、このようにすることで処理の対象となる文章から空白と
改行を除去することもできます。

reg_txt = re.sub(r"[\s\n]", '', reg_txt)

品詞として単語を抽出

ここまでの処理でノイズとなる文字列を除去できたので、
今度は文章中の単語を品詞として抽出してみます。つまり、
名詞、動詞、形容詞である単語を抽出するといくことです。
こちらのコードにより、指定した種類の品詞に該当する
単語を形態素解析で抽出することができます。

from janome.analyzer import Analyzer
from janome.tokenfilter import POSKeepFilter

t = Tokenizer()
token_filters = [POSKeepFilter(['名詞'])]
a = Analyzer(char_filters=[], tokenizer=t, token_filters=token_filters)
for token in a.analyze(reg_txt):
    print(token)

単語の出現回数を数える

ここまでの処理で文章を単語に分割できた場合、その中で
各単語がどのくらいの頻度で出現しているのかを解析したく
なります。

その際はこのコードのように、形態素解析によって分割された
単語をリストへ格納し、collections.Counterによりリスト内の
要素の数をカウントするようにします。

import collections

words_list = []
for token in a.analyze(reg_txt):
    words_list.append(token.surface)
# count words
c = collections.Counter(words_list)
print(c)

最終的に、このように各単語の出現個数を出力してくれます。

分割した単語をデータフレームにまとめる

ここまでに書いた処理により、文章を意味のある単位の
単語に分割できるようになりました。続いては、それらを
特徴量として分類モデルを作るためのデータセットを
作る前処理についてまとめていきます。

まずは、元の文章が書きだされたテキストファイルを全て
読み込み、それらを正規化→形態素解析して分割した単語を
データフレームにまとめます。

docterm = []
label = []
tmp1 = []
tmp2 = ''

t = Tokenizer()
token_filters = [POSKeepFilter(["名詞"])]
a = Analyzer(char_filters=[], tokenizer=t, token_filters=token_filters)

for file in files:
    f = open("TEXT_FILE_PATH", 'r', encoding="utf-8")
    txt = f.read()

    reg_txt = re.sub(r"[0-9a-zA-Z]+", '', txt)
    reg_txt = re.sub(r"[:;/+\.~]", '', reg_txt)
    reg_txt = re.sub(r"[\s\n]", '', reg_txt)

    for token in a.analyze(reg_txt):
        tmp1.append(token.surface)
        tmp2 = ' '.join(tmp1)
    docterm.append(tmp2)
    tmp1 = []

    label.append(i)

    f.close()

print(pd.DataFrame(docterm).head())
print(docterm[0])

このコードのようにすると、各ファイルにある文章を分割した
単語を行ごとにデータフレームに入れたものが作成されます。

分割した単語の文書行列を作成する

単語を分割しただけではまだ数値データではないので、
このままではアルゴリズムが処理できる状態ではありません。
そのため今度は、各単語の出現回数をカウントして
数値データへ変換する処理を行います。

こういった単語の出現回数の表現方法として一般的なもので
単語文書行列(Term-Document Matrix)というものがあります。
これは、行方向に単語、列方向に文書を並べた行列形式で
各文書における単語の出現回数をまとめたものです。

単語文書行列をPythonで作成するときは、こちらのコードの
ようにscikit-learnで提供されているCountVectorizerを利用
します。

import numpy as np

cv = CountVectorizer()
docterm_cv = cv.fit_transform(np.array(docterm))
docterm_cnt = docterm_cv.toarray()
print(pd.DataFrame(docterm_cnt).head())

変換前の形式であるdoctermは行方向に文書、列方向に
単語を並べているので、それをCountVectorizerによって
変換すると、このように行方向に文書、列方向に単語と
いう形式で表現された単語文書行列が生成されます。

出現回数が多い順に単語を列挙する

各単語の出現回数を得られるようになったら、今度はそれらを
回数が多い順に並べてみたくなると思います。そういう場合は、
まずこちらのようなコードで、単語とその出現回数をペアにした
データフレームを作ります。

word_count_pairs = []
docterm_wcnt = np.sum(a=docterm_cnt, axis=0)
for word, count in zip(cv.get_feature_names(), docterm_wcnt):
    word_count_pairs.append([word, count])
word_count_df = pd.DataFrame(word_count_pairs)

これを実行すると、次のように各単語とその出現回数が
並んだデータフレームが得られます。

そして、これを最後に実行すれば、カラムが1の列の数値が
多い順(降順)に並べ替える処理がされ、回数が多い単語を
調べることができます。

word_count_df = word_count_df.sort_values(1, ascending=False)

並び替えた結果

出現する文書の比率で次元を削減する

ここまでの手法で単語の出現回数を調べると、出現が低頻度な
ものもあれば高頻度なものもあります。こういったものに
対して次のようなコードを実行すると、各単語が出現する
文書の比率の上限値と下限値を設定することで、そこから外れる
単語を除外することができます。

cv = CountVectorizer(min_df=0.01, max_df=0.5)
docterm_cv = cv.fit_transform(np.array(docterm))
docterm_cnt = docterm_cv.toarray()
print(pd.DataFrame(docterm_cnt).head())

TF-IDF値を算出する

TF-IDF値とは、各文書に含まれる各単語が、その文書内でどれくらい
重要か、表す統計的尺度の一つです。
単語の出現頻度であるTF(Term Frequency)値と、逆にどれくらい
少ない頻度で出現するかを示すIDF(Inverse Document Frequency)値を
掛け合わせた値がTF-IDF値になります。

単純に単語の出現数を特徴量とするのでは、逆に出現数が少ない
単語を特徴量として考慮することができなくなります。そこで、
上記のTF-IDF値を計算すれば、出現数の少ない単語に対しても
特徴付けを行えるようになります。

TF-IDF値の具体的な計算例や用途については、こちらの記事で詳しく
解説されているので参照ください。
atmarkit.itmedia.co.jp

Pythonでは、scikit-learnを使ったこちらのようなコードで、
TF-IDF値の単語文書行列を作成することができます。

from sklearn.feature_extraction.text import TfidfVectorizer

tv = TfidfVectorizer(min_df=0.01, max_df=0.5, sublinear_tf=True)
docterm_tv = tv.fit_transform(np.array(docterm))
docterm_tfidf = docterm_tv.toarray()
print(pd.DataFrame(docterm_tfidf).head())

作成される単語文書行列

RNNのためのデータセットを作成する

自然言語処理において精度の高い結果を得られる分類モデルを
作れるとして有名なアルゴリズムに再帰型ニューラルネットワーク
(RNN: Recurrent Neural Network)があります。

ここからは、RNNによる分類モデルを作るために必要な
データセットを作成するコードを紹介します。

RNNの仕組みや活用例

こちらの記事で詳しく解説されています。
aismiley.co.jp

各時刻の中間層にはセルという記憶領域があり、
過去の状態を記憶して再利用するために使われます。

ある時刻の中間層は、同時刻の入力層からのデータと、
一つ前の時刻の中間層からのデータを受け取り、出力層と
次の時刻の中間層への出力を行います。

このように、中間層の特徴量を過去から未来へ伝搬していく
ことで、直前の言葉に左右されにくい演算を行えるように
しているのがRNNの特徴です。

LSTMの仕組みや活用例

RNNは、ネットワークを時間方向へ展開することにより、計算量が
多くなってしまうという問題があります。また、アルゴリズムの
特性上、記憶しているデータが長期的であればあるほど有効性が
失われていくため、現在から近い過去の特徴量に依存する傾向が
あります。

これは勾配消失問題と呼ばれており、分類モデルによる予測値
を正解に近づけるために調整したいパラメータを最適化することが
できなくなってしまうというものです。
qiita.com

そして、この問題を解決するために生み出されたのが、
長短期記憶(LSTM: Long Short Term Memory)です。
全ての情報をそのまま次の時刻の層へ渡していたRNNに対して、
LSTMは全情報の中から重要な情報だけを選択して渡していく
ように改良されています。
www.acceluniverse.com

aismiley.co.jp

単語や句読点、括弧などの単位に文書を区切る

上記で紹介したRNNやLSTMは深層学習のアルゴリズムです。
深層学習ではノイズも含めて学習することで汎化性能を
高めるので、このために作るデータセットには、単語だけで
なく句読点や括弧などのノイズも含めるようにします。

そのためにまずは、こちらのコードにより全ての文書を
単語やノイズの単位に分割してやります。

import os
import re
from janome.tokenizer import Tokenizer


base_dir = "base_dir/"
sub_dirs = ["sub_dir_0", "sub_dir_1"]

wakati = []
labels = []

t = Tokenizer(wakati=True)

for i, sub_dir in enumerate(sub_dirs):
    files = os.listdir(base_dir + sub_dir)

    for file in files:
        f = open(base_dir + sub_dir + '/' + file, 'r', encoding="utf-8")
        txt = f.read()

        reg_txt = re.sub(r"[0-9a-zA-Z]+", '', txt)
        reg_txt = re.sub(r"[:;/+\.-]", '', reg_txt)
        reg_txt = re.sub(r"[\s\n]", '', reg_txt)

        wakati.append(list(t.tokenize(reg_txt)))
        labels.append(i)
        f.close()
print(len(wakati))
print(wakati[0])
print(labels[0])

このとき、半角英数や記号などには正規表現を適用し、
文書から除去するようにします。

また、各文書はジャンルなどによってサブディレクトリに
分けておき、それぞれに0, 1などようなラベル付けを
しておきます。

出現数の降順に単語をソートする

全ての文書を単語に区切ることができたら、今度はそれぞれの
出現数をカウントします。こちらのコードにより、分割された
単語の配列から、各単語の出現数をカウントし、出現数の降順
にソートすることができます。

import itertools
import pandas as pd
from collections import Counter

words_freq = Counter(itertools.chain(* wakati))
dic = []
for word_uniq in words_freq.most_common():
    dic.append(word_uniq[0])
print(pd.DataFrame(dic).head())

例えば今回読み込ませた文書中では、これらの単語が出現数が多い
トップ5であることが分かります。

ソートした単語にIDを付与する

カウントした出現数だけでなく、ソートしたときの順番を
合わせて保存するために、こちらのようなコードで各単語に
1から連番を付与していきます。

dic_inv = {}
for i, word_uniq in enumerate(dic, start=1):
    dic_inv.update({word_uniq: i})
print(dic_inv)

各単語とその出現数順をペアにした辞書

これにより、各単語を数値として扱えるようになります。

文書中の単語を数値に変換する

上記で付与した連番のIDを利用して、各文書に出てくる単語を
数値に変換します。

words_id = [[dic_inv[word] for word in waka] for waka in wakati]

このコードを実行すると、各文書に出現する単語のIDのリストが
得られ、例えば1番目の文書に出現する単語のIDリストはこの
ようになります。

単語IDリストの長さを揃える

このままでは各単語IDリストの長さがまちまちなので、こちらの
コードで同じ長さに揃えておきます。ここでは、一番長いリストに
合わせて、それに満たないリストは末尾を0で埋めるようにします。

import numpy as np
from keras_preprocessing import sequence

wakati_id = sequence.pad_sequences(np.array(wakati_id), maxlen=3382, padding='post')
labels = np.array(labels)
print(wakati_id[0])

例えば、1番目の文書のIDリストはこのように末尾が0で埋められます。

トピック抽出のためのデータセットを作成する

自然言語処理の用途の一つとして、カテゴリに含まれる話題(トピック)
を抽出する、というものがあります。これにより、モデルによる分類
の根拠を理解できるようになります。

ネットワーク分析による抽出

トピックを抽出する手法にネットワーク分析があります。
ネットワークとは、対象と対象の関係を表現する方法であり、
ノードとエッジという2種類の要素で構成されます。

一つ一つの対象がノード、それらを繋ぐのがエッジです。
そして、文書中の単語をノードとし、2つの単語間を結んだ
ものをエッジとしたものを、単語の共起ネットワークと呼びます。
toukeier.hatenablog.com

共起ネットワークを作るためのエッジリスト

単語の共起とは、ある2つの単語が同じ文書中に同時に
出現することであり、その単語のペアを結ぶエッジは
類似度という重みを持ちます。この重みは、ペアである
2つの単語の出現回数が多いほど大きくなり、同時に
出現する回数が多いということはそれだけ類似したもの
であると考えられます。

そして、ペアとなった2つの単語と、その類似度を
リストアップしたものをエッジリストといい、それに
基づいて共起ネットワークが作られます。

ここからは、エッジリストを作成するまでの処理について
紹介していきます。

単語文書行列を作る

まずはこちらのコードで、各文書から名詞を抽出し、名詞単位に
分割します。

import os
import re
import pandas as pd
from janome.tokenizer import Tokenizer
from janome.analyzer import Analyzer
from janome.tokenfilter import POSKeepFilter


base_dir = "base_dir/"
sub_dirs = ["sub_dir_1", "sub_dir_2"]
docterm = []
label = []
tmp1 = []
tmp2 = ''

t = Tokenizer()
token_filters = [POSKeepFilter(["名詞"])]
a = Analyzer(char_filters=[], tokenizer=t, token_filters=token_filters)

for i, sub_dir in enumerate(sub_dirs):
    files = os.listdir(base_dir + sub_dir)

    for file in files:
        f = open(base_dir + sub_dir + '/' + file, 'r', encoding="utf-8")
        txt = f.read()

        reg_txt = re.sub(r"[0-9a-zA-Z]+", '', txt)
        reg_txt = re.sub(r"[:;/+\.-]", '', reg_txt)
        reg_txt = re.sub(r"[\s\n]", '', reg_txt)

        for token in a.analyze(reg_txt):
            tmp1.append(token.surface)
            tmp2 = ' '.join(tmp1)
        docterm.append(tmp2)
        tmp1 = []

        label.append(i)

        f.close()

print(pd.DataFrame(docterm).head())
print(docterm[0])

一つの文書につき、分割した結果はこのようになります。

そして、このように名詞単位で分割された各文書の入力にして、
こちらのようなコードで単語文書行列を作ります。

tv = TfidfVectorizer(min_df=0.05, max_df=0.5, sublinear_tf=True)
docterm_tv = tv.fit_transform(np.array(docterm))
docterm_tfidf = docterm_tv.toarray()
print(pd.DataFrame(docterm_tfidf).head())

TfidfVectorizerの使い方
gotutiyan.hatenablog.com

コードを実行したときに出力されるのは、このように各文書に
おける各名詞のTF-IDF値を並べた行列になります。

最後にこのコードで、文書の種類によって0, 1とラベル付けし、
label列として結合しておきます。

label = pd.DataFrame(label)
label = label.rename(columns={0: "label"})
docterm_df = pd.concat([docterm_tfidf, label], axis=1)
print(docterm_df.head())

作成された単語文書行列はこのようになります。

コサイン類似度を計算する

既に述べたように、共起ネットワークにおける各単語のペアを
結ぶエッジには、類似度という重みを持たせるようにします。

類似度には様々な種類がありますが、今回利用するのは
こちらのようなコサイン類似度というものです。
atmarkit.itmedia.co.jp

コサイン類似度とは、2つのベクトルがどのくらい似ているかを
表す尺度です。今回のような自然言語処理の問題では、各文書が
意味のある単位で分割された単語のベクトルとして扱われます。
そのことから共起ネットワークでは、エッジで結ばれる2つの単語
が出現する文書のベクトルがどの程度似ているのかを測るために
コサイン類似度を使うということになります。

コサイン類似度はこちらのようなコードで計算できます。
単語のペアの類似度はラベルごとに計算するので、ラベルが
0, 1とされた文書があるとして、まずはラベルが0の文書のみを
抽出して計算しています。

from sklearn.metrics.pairwise import cosine_similarity

docterm_0 = docterm_df[docterm_df["label"] == 0]
docterm_0 = docterm_0.drop("label", axis=1)
sim_0 = cosine_similarity(docterm_0.T)
sim_0_df = pd.DataFrame(sim_0)
print(sim_0_df.head())

計算された各ペアの類似度はこのように行列形式で出力されます。
対角成分は1.0になっていることが分かりますが、これは同じ単語
同士のペアで計算されているためです。

単語ペアと類似度をリストアップする

エッジリストとは、ペアになる2つの単語と、それを結ぶエッジの
重みをリスト形式にしたものになります。

なのでまずは、既に作成したペアの単語文書行列を、こちらの
コードでリスト形式にします。

sim_0_stack = sim_0_df.stack()
index = pd.Series(sim_0_stack.index.values)
value = pd.Series(sim_0_stack.values)
print(index.head())
print(value.head())

作成した単語文書行列は、各ペアの類似度が列方向に並んで
いたので、それを1行目の処理により行方向に並び替えます。
このとき、並べ替えた後のデータフレームのindexがペアと
なる単語のインデックス、valuesがコサイン類似度に当たり、
それらを出力すると、このようになります。

関連性の強いペアを抽出する

続いてここから、関連性の強そうな単語ペアのみを抽出します。
そのためのコードはこちらのように実装できます。

tmp3 = []
tmp4 = []
for i in range(len(index)):
    if 0.5 <= value[i] <= 0.9:
        tmp1 = str(index[i][0]) + ' ' + str(index[i][0])
        tmp2 = [int(s) for s in tmp1.split()]
        tmp3.append(tmp2)
        tmp4 = np.append(tmp4, value[i])

ペアとなる2つの単語のIDを1つのリストにまとめ、それを順番に
別のリストに格納していきます。それに加えて、ペアの
コサイン類似度も別途リストに格納しておきます。

そのあとにこちらのコードで、ペアとなる単語IDリストと
そのコサイン類似度リストをそれぞれデータフレームに
変換し、カラム名を適した名前に変更してから結合します。

tmp3 = pd.DataFrame(tmp3)
tmp3 = tmp3.rename(columns={0: "node1", 1: "node2"})
tmp4 = pd.DataFrame(tmp4)
tmp4 = tmp4.rename(columns={0: "weight"})
sim_0_list = pd.concat([tmp3, tmp4], axis=1)

エッジリストを確認する

上記のコードで作成されるデータフレームが、目的としていた
エッジリストになります。出力して内容を確認してみるとこちらの
ようになっており、先に紹介した共起エッジリストとして扱える
形式になっていることが分かります。