PythonpythonプログラミングTech Blog自然言語処理 

pythonによる日本語前処理備忘録

はじめに

こんにちは。DATUM STUDIOの安達です。

最近社内で日本語のテキストを用いた自然言語処理でよく質問を受けるのですが、前処理についてはそこそこ同じような内容になるため、本記事では社内共有の意味も込めて前処理に関して用いてきた&用いれそうな手法を列挙します。

比較的同じ内容を扱った既存の記事としては以下のようなものもあり、読者の方はこれらも参考にされて要件に合わせて取捨選択してください。

 

本記事における使用言語、環境は以下の通りです。

  • ・osx 10.13.6
  • ・anaconda 5.2.0
  • ・python 3.5.2

Table of contents

  • ・形態素解析段階での前処理

    •  ・文字表現の正規化
    •  ・URLテキストの除外
    •  ・Mecab + neologd 辞書による形態素解析

 

  • ・形態素解析後の前処理

    •  ・品詞による絞り込み
    •  ・特定の名詞の除外

 

  • ・文書ベクトル化段階での前処理

    •  ・単語頻度による除外
    •  ・文書頻度による除外
  • ・まとめ

形態素解析段階での前処理

文字表現の正規化

pythonパッケージneologdnは日本語テキストに対して、Mecab+neologd辞書を用いる前に推奨される正規化(表記ゆれの是正)を加えてくれます。Mecabとneologd辞書については後述します。

neologdnライブラリのインストール

pip install neologdn

neologdnそのものはneologd辞書がなくても動作します。

import neologdn
neologdn.normalize('おいし〜〜〜〜い') 
> おいしい 
neologdn.normalize('C++ 完全に 理解した') 
> C++完全に理解した 
neologdn.normalize('あなたと Java 今すぐ ダウンローーード') 
> あなたとJava今すぐダウンロード

URLテキストの除外

テキストとしてURLやエスケープコードが混じっているケースではこれらを形態素解析をする前に予め除外します

  • エスケープコード(&:amp,>:gt,<:lt…)
  • http, httpsで始まるURL

URLの文字列については単純な正規表現を用いて以下のように省けますが、URLが複雑なケースでは更に工夫が必要かもしれません

import re
 
re.sub(
    r'(http|https)://([-\w]+\.)+[-\w]+(/[-\w./?%&=]*)?', 
    "", 
    "お求めの商品はコチラ!! → https://www.someecsite.com/shoppinglist/index.html"
)
> お求めの商品はコチラ!! →

Mecab + neologd 辞書による形態素解析

日本語は膠着語とよばれ、英語のようにテキスト内の単語それぞれがスペースで分かれている言語ではないです。

それ故前処理にあたってはテキストを単語単位に分割する必要があります。これを分かち書きといい、それには形態素解析エンジンが用いられることが多いです。

形態素解析エンジンには処理速度や形態素の網羅性から、ほとんどの案件でmecab + neologd辞書を用いています。処理対象のテキストによっては、対象ドメイン(e.g. 医療系用語, 通信系用語)特有の形態素も辞書として追加します。

Mecab + neologdのインストール

brew install mecab
brew install mecab-ipadic
git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git $(your_neologd_path)
cd $(your_neologd_path)
./bin/install-mecab-ipadic-neologd -n

Mecabをインストールしたら、pythonのMecabバインディングをインストールします

pip install mecab-python3

Mecabの出力はいろいろな形式で出力できますが、形態素品詞体系としてChasen品詞体系を用いることが多く、Mecabの形態素タグ付けをするオブジェクトであるタガーを取得する際に -Ochasen フラグを指定します。

また、-d フラグで辞書も指定できるため、

echo `mecab-config --dicdir`"/mecab-ipadic-neologd"` 

で出力されるneologd辞書のパスを指定します

import MeCab
neologd_tagger = MeCab.Tagger('-Ochasen -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd')
print(neologd_tagger.parse('庭には二羽鶏がいる'))
>庭  ニワ  庭   名詞-一般       
に   ニ   に   助詞-格助詞-一般       
は   ハ   は   助詞-係助詞      
二羽  ニワ  二羽  名詞-固有名詞-人名-姓        
鶏   ニワトリ    鶏   名詞-一般       
が   ガ   が   助詞-格助詞-一般       
いる  イル  いる  動詞-自立   一段  基本形
EOS

このような形式の出力については、後々処理しやすいようpandasのDataFrameに格納しておくと便利です。

import pandas as pd
 
def parse_text(text: str):
    parsed_text = neologd_tagger.parse(text).split('\n')
    parsed_results = pd.Series(parsed_text).str.split('\t').tolist()
    df = pd.DataFrame.from_records(parsed_results)
    columns = ['surface', 'spell', 'orig', 'type', 'katsuyoukei', 'katsuyoukata']
    df.columns = columns
    return df.query("surface != 'EOS'").query("surface != ''")
    
parse_text('庭には二羽鶏がいる')
>   surface spell   orig    type    katsuyoukei katsuyoukata
0   庭   ニワ  庭   名詞-一般       
1   に   ニ   に   助詞-格助詞-一般       
2   は   ハ   は   助詞-係助詞      
3   二羽  ニワ  二羽  名詞-固有名詞-人名-姓        
4   鶏   ニワトリ    鶏   名詞-一般       
5   が   ガ   が   助詞-格助詞-一般       
6   いる  イル  いる  動詞-自立   一段  基本形

最後のカラム名の意味は形態素解析の結果に対応しており、それぞれ以下の意味を持ちます。

  • surface(表層型): テキスト中の表現型
  • spell(スペル): どう発音するか
  • orig(原型): 形態素の原型、例えば動詞の活用形によっては原型と表層型が異なる場合があります。
  • type : 形態素の品詞型
  • katsuyoukei : 活用形
  • katsuyoukata : 活用型

形態素解析後の前処理

品詞による絞り込み

対象や目的にもよりますが、多くのタスクでは名詞を中心に抽出します。先程のように形態素解析の結果で得られた品詞についてはツールごとに品詞体系なるものが定められており、-Ochasen フラグではChasen品詞体系が用いられます。

Chasen品詞体系のうち、以下の3種類はよく用います。

  • 名詞-一般
  • 名詞-サ変接続
  • 名詞-固有名詞

サ変は聞き慣れない人もいると思いますが、「くしゃみ」や「握手」などの後に「〜する」を置くと動詞として利用できる種類の名詞のことです。

parsed = parse_text('竹屋の竹薮に竹立てかけたのは,竹立てかけたかったから')
noun_df = parsed[
    parsed['type'].str.startswith('名詞-一般') | 
    parsed['type'].str.startswith('名詞-サ変接続') |
    parsed['type'].str.startswith('名詞-固有名詞')
]
noun_df
> 0 竹屋  タケヤ 竹屋  名詞-固有名詞-人名-姓        
2   竹薮  タケヤブ    竹薮  名詞-一般       
4   竹   タケ  竹   名詞-一般       
10  竹   タケ  竹   名詞-一般   

品詞タグ「名詞-固有名詞」の下には小さなカテゴリとして「人名」や「組織」、「地域」も続くため、これらについてもタスクによっては品詞の型による除外が可能です。

特定の名詞の除外

名詞の中でも以下の類のものはタスクとして抽出の必要のないケースが多く、除外することが多いです。(といってもタスクによっては除外しない方がいい時もありますが)

  • 数値 + 単位(1日、2年、5個…)
  • 漢数字 + 単位(一個、十時…)
  • 日付
  • 季節に関する用語(春夏秋冬)
  • 曜日(月火水木金土日)
  • 方角(北南西東)
  • 絵文字(😀😁😂🍎等)
  • その他ストップワード

中でもうっかり忘れてしまいがちなのが絵文字で、neologd辞書には原型が通常の日本語として登録されているため、除外を忘れると原型による集計の段階でテキストには現れなかったような文字が大量に含まれてしまうことになります。

parse_text('🍎')
> surface   spell   orig    type    katsuyoukei katsuyoukata
0   🍎   リンゴ りんご 記号-一般   

絵文字の除外はunicodeの特定の範囲を正規表現で指定することで実現可能です。

emoji_regex = re.compile("^["
    u"\U00002190-\U000021FF"  # Arrows
    u"\U00002300-\U000023FF"  # Miscellaneous Technical
    u"\U000025A0-\U000025FF"  # Geometric Shapes
    u"\U00002600-\U000026FF"  # Miscellaneous Symbols
    u"\U00002700-\U000027BF"  # Dingbats
    u"\U00002B00-\U00002BFF"  # Miscellaneous Symbols and Arrows
    u"\U0001F100-\U0001F1FF"  # Enclosed Alphanumeric Supplement
    u"\U0001F300-\U0001F5FF"  # Miscellaneous Symbols and Pictographs
    u"\U0001F600-\U0001F64F"  # Emoticons     
    u"\U0001F680-\U0001F6FF"  # Transport and Map Symbols    
    u"\U0001F900-\U0001F9FF"  # Supplemental Symbols and Pictographs
    "$]+",
    flags=re.UNICODE
)
emojis = parse_text('😀😁😂🍎')
emojis 
>   surface spell   orig    type    katsuyoukei katsuyoukata
0   😀   エガオ 笑顔  名詞-一般       <br />1   😁   ウッシッシ   うっしっし(笑)    記号-一般       <br />2   😂   ウレシナミダ  嬉し涙 名詞-一般       <br />3   🍎   リンゴ りんご 記号-一般     
 
emojis[~emojis['surface'].str.match(emoji_regex)]
> surface   spell   orig    type    katsuyoukei katsuyoukata

unicodeのバージョンが変更されるたびに絵文字のunicode範囲が次第に広がっていっているのが辛いところです。

文書ベクトル化段階での前処理

単語頻度による除外

高頻度や低頻度の単語はTFIDF特徴量に於いて頻度による重み付けがされた場合でも頻度の影響が強いケースがあるため、対象の単語を除外することがあります。除外方法には複数あり、主に以下の2つが候補として挙げられます。

  1. 頻度順に単語を並べた時に上位下位x%に入るか
  2. 頻度を用いたクラスタリングによるクラスタ除外

頻度順に単語を並べた時に上位下位x%に入るか

1番目については文書中の単語の頻度を集計した上で頻度でソートします。頻度でソートした結果で除外すべき対象に含まれるかどうかを単語頻度のランキングで決定します。

例えば blog という単語が全体の文書のうち、頻度200で文書全体2000単語のうちの100位に入っていたとします。もしもしきい値xを5%と定めているのであれば blog という単語を除外対象として含むことになります。

ドキュメント郡から単語頻度を取得するサンプルを動かすためにまず以下のシェルで健康アドバイスデータセットをダウンロードします。

wget http://lotus.kuee.kyoto-u.ac.jp/nl-resource/health_advice_dataset/lifelog_advice_corpus.zip -P inputs && unzip inputs/lifelog_advice_corpus.zip -d inputs

そしてダウンロードしたxmlデータから文書IDと名詞を中心とした分かち書き表現からなるデータフレームを取得します。

from xml.etree import ElementTree
import numpy as np
 
# ダウンロードしたxmlを展開
tree = ElementTree.parse('./inputs/lifelog_advice_corpus/lifelog_advice_corpus.xml')
root = tree.getroot()
 
# 属性の辞書のリストを作る
lifelog_data = [{
    'lifelog_id': lifelog.get('id') , 
    'line_id': line.get('id'), 
    'text':text
} for lifelog in root.findall('.//lifelog') 
    for line in lifelog.findall('l_s') 
    for text in line.itertext()
]
 
# 文書、行IDと文の対をデータフレームとして展開
lifelog_df = pd.DataFrame(lifelog_data)
lifelog_text_df = lifelog_df.groupby(['lifelog_id', 'line_id'])[['text']].apply(
    lambda rec: np.sum(rec.text + ' ')
).reset_index(name='text')
 
# 名詞を中心とした形態素のみを抽出する関数
def extract_noun(text: str):
    norm_text = neologdn.normalize(text)
    parsed = parse_text(norm_text)
    noun_df = parsed[
        parsed.type.str.startswith('名詞-一般') | 
        parsed['type'].str.startswith('名詞-固有名詞') |
        parsed.type.str.startswith('名詞-サ変接続') 
    ]
    return ' '.join(noun_df.orig.tolist())
 
# 文書IDと名詞分かち書き表現のデータフレームを取得
lifelog_text_df['bow'] = np.vectorize(extract_noun)(lifelog_text_df.text)
lifelog_bow_df = lifelog_text_df.groupby('lifelog_id')[['bow']].apply(
    lambda rec: np.sum(rec.bow + ' ')
).reset_index(name='bow')

このlifelogbowdfはドキュメントID(lifelog_id)と対応するドキュメントを正規化した上で名詞を中心に形態素を抽出し、分かち書きしたテキスト(bow)が含まれるものになります。

このテキストに含まれる単語を文書全体で集計してみます。

from sklearn.feature_extraction.text import CountVectorizer
 
# ワードカウント疎行列の抽出
cv = CountVectorizer()
count_mat = cv.fit_transform(lifelog_bow_df['bow'])
 
# 単語列IDと単語リストのデータフレーム
col_word_df = pd.DataFrame({'word': list(cv.vocabulary_.keys())}, index=cv.vocabulary_.values())
 
# 文書と単語と対応頻度のデータフレーム
coo = count_mat.tocoo()
count_df = pd.DataFrame({'index': coo.row, 'col': coo.col, 'count': coo.data})
doc_word_count_df = pd.merge(
  count_df, col_word_df, left_on='col', right_index=True
)[['index', 'word', 'count']]
 
# 上位10単語の表示
doc_word_count_df.groupby('word')['count'].sum().reset_index().sort_values('count', ascending=False).reset_index(drop=True)[:10]
 
>       word    count
0   blog    187
1   ご飯  92
2   食事  85
3   コーヒー    70
4   カロリー    66
5   サラダ 64
6   味噌汁 64
7   こんにちは   64
8   体重  63
9   運動  62

最終的に得られたデータフレームは頻度順位がインデックスに対応しており、このようにして頻度の上位x%に含まれる単語を特定することで前処理に活かせます。

頻度を用いたクラスタリングによるクラスタ除外

2番目の頻度を用いたクラスタリングは単語と頻度の対をサンプルとみなしてその集合を頻度という1次元の量によってクラスタリングします。K-meansによるサンプルコードを下記に挙げます。

from sklearn.cluster import KMeans
 
def cluster_word_by_count(count_frame, seed=0):
    '''
    単語毎にカウント数に応じて頻度クラスタリング
    :param count_frame: カウント数(count)と形態素(word)のデータフレーム
    :param seed: クラスタリングで用いるシード
    :return: word, カウント数(count), 頻度のカテゴリ(cluster)
    '''
 
    sorted_count_frame = count_frame.sort_values('count')
 
    cluster_results = KMeans(n_clusters=3, random_state=seed)\
        .fit_predict(sorted_count_frame['count'].values.reshape(-1, 1))
    low_cluster_id = cluster_results[0]
    high_cluster_id = cluster_results[-1]
 
    # 頻度文字列へのマッピングのためのローカル関数
    def id_to_hml(cluster_id):
        if cluster_id == low_cluster_id:
            return '低'
        elif cluster_id == high_cluster_id:
            return '高'
        else:
            return '中'
 
    hmls = np.vectorize(id_to_hml)(cluster_results)
    hml_frame = pd.DataFrame({
        'word': sorted_count_frame.word,
        'cluster': hmls
    })
    return pd.merge(
        count_frame,
        hml_frame,
        on='word'
    )

このメソッドに単語頻度のデータフレームを与えることで新しくclusterカラムに’高中低’の頻度情報が紐ついたデータフレームが得られることになります。その中から適宜高頻度、低頻度に含まれる単語を除外して最終的な対象の単語を定められます。

文書頻度による除外

先程の単語そのものの頻度ではなく文書頻度とは単語について全体の文書のうちどのくらいの文書に渡ってその単語が含まれているかを示す量になります。これを用いた単語の除外はscikit-learnの CountVectorizer,TfidfVectorizerの初期化引数として含まれるmaxdf, mindfを用いることで実現可能です。

from sklearn.feature_extraction.text import TfidfVectorizer
 
# 単語を除外しないTFIDF特徴量抽出
tv = TfidfVectorizer()
mat = tv.fit_transform(lifelog_bow_df['bow'])
 
# 低文書頻度(下位1%)の単語を除外したTFIDF特徴量抽出
min_erased_tv = TfidfVectorizer(min_df = 0.01)
min_erased_mat = min_erased_tv.fit_transform(lifelog_bow_df['bow'])
 
tv_word_df = pd.DataFrame({'word': list(tv.vocabulary_.keys())}, index=tv.vocabulary_.values())
min_erased_tv_word_df = pd.DataFrame({'word': list(min_erased_tv.vocabulary_.keys())}, index=min_erased_tv.vocabulary_.values())
print(len(tv_word_df))
print(len(min_erased_tv_word_df))
 
> 2344
310

まとめ

いかがでしたでしょうか。文書中のソースコードではpandasの操作で少々トリッキーなところがあったかもしれません。自然言語処理における前処理の概観として本記事が参考になれば幸いです。

引用

Tetsuaki Nakamura, Takashi Awamura, Yiqi Zhang, Eiji Aramaki, Daisuke Kawahara and Sadao Kurohashi, Toward an Advice Agent for Diet and Exercise Based on Diary Texts, In proceedings of 2015 AAAI Spring Symposium Series – Ambient Intelligence for Health and Cognitive Enhancement, pp.43-48, Mar.2015.

DATUM STUDIOでは様々なAI/データ分析の支援を行っております。
詳細につきましてはこちら

詳細/サービスについてのお問い合わせはこちら

このページをシェアする:



DATUM STUDIOは、クライアントの事業成長と経営課題解決を最適な形でサポートする、データ・ビジネスパートナーです。
データ分析の分野でお客様に最適なソリューションをご提供します。まずはご相談ください。