ラベル text mining の投稿を表示しています。 すべての投稿を表示
ラベル text mining の投稿を表示しています。 すべての投稿を表示

2022年3月21日月曜日

Pythonで新語に対応したmecab-unidic-NEologdを使う

PythonとMeCabでUniDicを使うでは、MeCabとUniDicで形態素解析して、IPA辞書との結果を比較してみた。UniDicにしてもIPA辞書にしても、よく使われる固有名詞には対応しているものの、世の中のさまざまな固有名詞には対応しきれない。ネット資源をもとにIPA辞書をカスタマイズしたmecab-ipadic-NEologdなら、最近登場した新語にも対応できる(mecab-ipadic-NEologdで形態素解析を新語に対応させる)。UniDicにも同様のmecab-unidic-NEologdがあるので、今回はmecab-unidic-NEologdをPythonで使ってみる。


環境

WSL2(Ubuntu20.04)。

$ lsb_release -dr
Description:    Ubuntu 20.04.3 LTS
Release:        20.04
$ python3 -V
Python 3.8.10
$ git version
git version 2.25.1


MeCabのインストール

IPA辞書やUniDicを使うだけならMeCabのPythonラッパーであるmecab-python3をpipでインストールすればよかったが、NEologdを使うためにはMeCabをインストールする。MeCabのインストールはRaspberry PiのPython3でMecabを使うを参照。


mecab-unidic-NEologdのインストール

gitでリポジトリをクローンして、インストールスクリプトを実行する。

$ git clone --depth 1 https://github.com/neologd/mecab-unidic-neologd.git
$ cd mecab-unidic-neologd
$ ./bin/install-mecab-unidic-neologd -n

インストールできたら、mecab-unidic-NEologdのインストール先ディレクトリを確認しておく。

$ echo `mecab-config --dicdir`"/mecab-unidic-neologd"
/usr/local/lib/mecab/dic/mecab-unidic-neologd


mecab-unidic-NEologdで形態素解析

準備ができたのでmecab-unidic-NEologdで形態素解析してみる。dオプションでmecab-unidic-NEologdのインストールディレクトリを指定する。

import MeCab

tagger = MeCab.Tagger('-d /usr/local/lib/mecab/dic/mecab-unidic-neologd')
text = '青いカザミドリがクルクル回る'
res = tagger.parse(text)
print(res)

上記コードを実行すると次のような結果になる。書字形出現形、発音形出現形、語彙素読み、語彙素、品詞(ハイフン区切り)、活用型、活用形が出力される。この出力項目のフォーマットは、/usr/local/lib/mecab/dic/mecab-unidic-neologd/dicrcで指定されている。

青い    アオイ  アオイ  青い    形容詞-一般     形容詞  連体形-一般

カザミ  カザミ  カザミ  Kazami  名詞-固有名詞-一般

ドリ    ドリ    トリ    鳥      名詞-普通名詞-一般

が      ガ      ガ      が      助詞-格助詞

クルクル        クルクル        クルクル        クルクル        名詞-固有名詞-一般

回る    マワル  マワル  回る    動詞-非自立可能 五段-ラ行       終止形-一般

EOS

以下はUniDicで同じ文字列を形態素解析した結果で、mecab-unidic-NEologdの結果とけっこう違う。またmecab-unidic-NEologdのデフォルトでは、UniDicに比べて取得できる項目が少なくなっている。

青い    形容詞,一般,,,形容詞,連体形-一般,アオイ,青い,青い,アオイ,青い,アオイ,和,"","","","","","",相,アオイ,アオイ,アオイ,アオイ,"2","C1","",73950814151361,269

カザミドリ      名詞,普通名詞,一般,,,

が      助詞,格助詞,,,,,ガ,が,が,ガ,が,ガ,和,"","","","","","",格助,ガ,ガ,ガ,ガ,"","動詞%F2@0,名詞%F1","",2168520431510016,7889

クルクル        副詞,,,,,,クルクル,くるくる,クルクル,クルクル,クルクル,クルクル,和,"","","","","","",相,クルクル,クルクル,クルクル,クルクル,"1","","",2892273994048000,10522

回る    動詞,非自立可能,,,五段-ラ行,連体形-一般,マワル,回る,回る,マワル,回る,マワル,和,"","","","","","",用,マワル,マワル,マワル,マワル,"0","C2","",9928873533907649,36121

EOS

次に、mecab-unidic-NEologdの効果がわかるように「カールおじさんインスタ映え」を形態素解析してみる。以下はmecab-unidic-NEologdを使用した結果。

カールおじさん  カールオジサン  カールオジサン  カールおじさん  名詞-固有名詞-一般

インスタ映え    インスタハエ    インスタハエ    インスタ映え    名詞-固有名詞-一般

EOS

固有名詞と「インスタ映え」とがちゃんと形態素解析できている。対して以下がUniDicの結果。固有名詞などが形態素として認識されていない。

カール  名詞,固有名詞,人名,一般,,,カール,カール-Karl,カール,カール,カール,カール,固,"","","","","","",人名,カール,カール,カール,カール,"1","","",2166596286161408,7882

おじ    名詞,普通名詞,一般,,,,オジ,伯父,おじ,オジ,おじ,オジ,和,"","","","","","",体,オジ,オジ,オジ,オジ,"0","C4","",1362303530443264,4956

さん    接尾辞,名詞的,一般,,,,サン,さん,さん,サン,さん,サン,和,"","","","","","",接尾体,サン,サン,サン,サン,"","C4","",3984363884782080,14495

インスタ        名詞,固有名詞,一般,,,,インスタ,インスタ,インスタ,インスタ,インスタ,インスタ,固,"","","","","","",固有名,インスタ,インスタ,インスタ,インスタ,"","","",92251508095197696,335609

映え    名詞,普通名詞,一般,,,,ハエ,映え,映え,バエ,映え,バエ,和,"ハ濁","濁音形","","","","",体,バエ,バエ,バエ,ハエ,"2","C3","",17388235261100544,63258

EOS


2022年3月9日水曜日

PythonとMeCabでUniDicを使う

PythonとMeCabで形態素解するときに利用できる辞書としては、IPA辞書やネットの資源を利用してIPA辞書をカスタマイズしたmecab-ipadic-NEologdがある。

Raspberry PiのPython3でMecabを使う

mecab-ipadic-NEologdで形態素解析を新語に対応させる

IPA辞書はしばらく更新されていないが、mecab-ipadic-NEologdは定期的に更新されている。ただmecab-ipadic-NEologdは場合によっては副作用がある。

mecab-ipadic-NEologdの辞書を修正する

そこで今回は、今も更新がされているUniDicを使って形態素解析をしてみる。


環境

WSL2(Ubuntu20.04)。

$ lsb_release -dr
Description:    Ubuntu 20.04.3 LTS
Release:        20.04
$ python3 -V
Python 3.8.10


必要なPythonモジュールとUniDicのインストール

まずはPythonでMeCabとUniDicを使用するため、MeCabのPythonラッパーであるmecab-python3とunidic-pyをインストールする。また、あとでUniDicとIPA辞書の比較をするので、IPA辞書もインストールする。

$ pip3 install mecab-python3 unidic ipadic
$ pip3 show mecab-python3
Name: mecab-python3
Version: 1.0.5
...
$ pip3 show unidic
Name: unidic
Version: 1.1.0
...
$ pip3 show ipadic
Name: ipadic
Version: 1.0.0
...

インストールできたらUniDicをダウンロードしておく。

$ python3 -m unidic download


MeCabとUniDicで形態素解析をする

環境が整ったので、UniDicで形態素解析をしてみる。

import MeCab
import unidic

tagger = MeCab.Tagger('-d ' + unidic.DICDIR)
text = '青いカザミドリがクルクル回る'
res = tagger.parse(text)
print(res)

上記コードを実行すると以下の結果が得られる。結果は分割された語ごとに、pos1からlemma_idまでがカンマ区切りになっている。結果の各項目についてはunidic-pyに説明がある。

また、「カザミドリ」は辞書に登録されていないのですべての項目はない。このフォーマットはunidic-pyがインストールされたディレクトリ配下のpython3.8/site-packages/unidic/dicdir/dicrcなどに記述されている。

青い    形容詞,一般,,,形容詞,連体形-一般,アオイ,青い,青い,アオイ,青い,アオイ,和,"","","","","","",相,アオイ,アオイ,アオイ,アオイ,"2","C1","",73950814151361,269

カザミドリ      名詞,普通名詞,一般,,,

が      助詞,格助詞,,,,,ガ,が,が,ガ,が,ガ,和,"","","","","","",格助,ガ,ガ,ガ,ガ,"","動詞%F2@0,名詞%F1","",2168520431510016,7889

クルクル        副詞,,,,,,クルクル,くるくる,クルクル,クルクル,クルクル,クルクル,和,"","","","","","",相,クルクル,クルクル,クルクル,クルクル,"1","","",2892273994048000,10522

回る    動詞,非自立可能,,,五段-ラ行,連体形-一般,マワル,回る,回る,マワル,回る,マワル,和,"","","","","","",用,マワル,マワル,マワル,マワル,"0","C2","",9928873533907649,36121

EOS

次に、書字形基本形だけを取り出してみる。

import MeCab
import unidic

tagger = MeCab.Tagger('-d ' + unidic.DICDIR)
text = '青いカザミドリがクルクル回る'
node = tagger.parseToNode(text)
tokens = []
while node:
    features = node.feature.split(',')
    if features[0] == 'BOS/EOS':
        # BOS/EOSをスキップ
        node = node.next
        continue
    if len(features) >= 11:
        # 書字形基本形
        base_form = features[10]
    else:
        # 書字形基本形がない場合は表層形を使う
        base_form = node.surface

    tokens.append(base_form)
    node = node.next

print(tokens)

結果は以下の通り。

['青い', 'カザミドリ', 'が', 'クルクル', '回る']

さらにUniDicを使うと語彙素も取得できる。語彙素とは異なる形態であるが同じ語であると考えられるものからなる語の集合。「おおきな蒼いふるどけい」を形態素解析すると以下の結果が得られる。品詞からはじまる項目の8番目が語彙素で、語彙素だけを取り出すと['大きな', '青い', 'フル', '時計']となる。「おおきな」が「大きな」、「蒼い」が「青い」などとなる。表記ゆれ対策に使えるかもしれない。

おおきな        連体詞,,,,,,オオキナ,大きな,おおきな,オーキナ,おおきな,オーキナ,和,"","","","","","",相,オオキナ,オオキナ,オオキナ,オオキナ,"1","","",1254551491584512,4564

蒼い    形容詞,一般,,,形容詞,連体形-一般,アオイ,青い,蒼い,アオイ,蒼い,アオイ,和,"","","","","","",相,アオイ,アオイ,アオイ,アオイ,"2","C1","",73950881260225,269

ふる    名詞,固有名詞,地名,一般,,,フル,フル,ふる,フル,ふる,フル,固,"","","","","","",地名,フル,フル,フル,フル,"1","","",56409902964417024,205218

どけい  名詞,普通名詞,一般,,,,トケイ,時計,どけい,ドケー,どけい,ドケー,漢,"ト濁","濁音形","","","","",体,ドケイ,ドケイ,ドケイ,トケイ,"0","C2","",7244141050348032,26354


IPA辞書との比較

UniDicとIPA辞書でどの程度違いがあるのか比較してみる。形態素解析するテキストは、Pythonで青空文庫の作品テキストからルビなどを取り除くの方法でルビなどを除去した青空文庫の『坊ちゃん』。botxhan.txtとして保存しておく。このテキストを形態素解析して、IPA辞書の原型とUnidicの書字形基本形の数をカウントする。以下は名詞のみをカウントして出現数上位20を比較するコード。

from collections import Counter

import MeCab
import unidic
import ipadic

def count(text, dic_arg, base_form_idx):
    tagger = MeCab.Tagger(dic_arg)
    node = tagger.parseToNode(text)
    tokens = []
    while node:
        feature_split = node.feature.split(',')
        pos1 = feature_split[0]

        if pos1 != '名詞':
            node = node.next
            continue

        if len(feature_split) >= base_form_idx+1:
            # 書字形基本形/原形
            base_form = feature_split[base_form_idx]
        else:
            # 書字形基本形/原形がない場合は表層形を使う
            base_form = node.surface

        tokens.append(base_form)
        node = node.next

    return Counter(tokens)

def main():
    # 『坊ちゃん』のテキスト読み込み
    with open('./botchan.txt', encoding='utf8') as f:
        text = f.read()

    unidic_count = count(text, '-d ' + unidic.DICDIR, base_form_idx=10)
    ipadic_count = count(text, ipadic.MECAB_ARGS, base_form_idx=6)

    for rank, (ucnt, icnt) in enumerate(zip(unidic_count.most_common(20), ipadic_count.most_common(20))):
        print(rank+1, ucnt, icnt)

if __name__ == '__main__':
    main()

結果は以下の通り。左側がUnidic、右側がIPA辞書を使った結果。

1 ('事', 292) ('おれ', 472)
2 ('もの', 220) ('の', 373)
3 ('赤', 175) ('事', 291)
4 ('シャツ', 170) ('*', 236)
5 ('山嵐', 154) ('もの', 218)
6 ('一', 153) ('ん', 216)
7 ('時', 115) ('人', 213)
8 ('方', 108) ('君', 184)
9 ('学校', 107) ('赤', 178)
10 ('人', 93) ('一', 173)
11 ('清', 89) ('よう', 173)
12 ('野', 88) ('シャツ', 170)
13 ('顔', 80) ('山嵐', 155)
14 ('気', 79) ('何', 146)
15 ('生徒', 77) ('二', 120)
16 ('今', 73) ('方', 115)
17 ('奴', 72) ('時', 108)
18 ('ところ', 72) ('それ', 100)
19 ('二', 71) ('これ', 99)
20 ('うち', 70) ('三', 92)

形容詞のみにした場合も比較してみる。

1 ('ない', 418) ('ない', 263)
2 ('いい', 121) ('いい', 114)
3 ('よい', 44) ('よい', 42)
4 ('わるい', 36) ('わるい', 36)
5 ('早い', 26) ('早い', 25)
6 ('悪るい', 23) ('長い', 17)
7 ('面白い', 17) ('面白い', 16)
8 ('長い', 17) ('強い', 15)
9 ('強い', 16) ('大きい', 11)
10 ('えらい', 11) ('小さい', 10)
11 ('小さい', 10) ('大人しい', 10)
12 ('大人しい', 10) ('えらい', 10)
13 ('旨い', 10) ('旨い', 10)
14 ('狭い', 10) ('狭い', 10)
15 ('大きい', 10) ('痛い', 9)
16 ('痛い', 9) ('蒼い', 9)
17 ('蒼い', 9) ('まずい', 9)
18 ('難有し', 9) ('嬉しい', 8)
19 ('嬉しい', 8) ('っぽい', 8)
20 ('むずかしい', 8) ('暗い', 8) 

この結果をみただけではUniDicとIPA辞書のどちらがいいのかの判断はできないが、それなりに違いはありそう。


2022年1月10日月曜日

Pythonで絵文字のワードクラウドを作成する

PythonではWordcloudを使うと簡単にワードクラウドを作成できる(Pythonで国会会議録からワードクラウドを作成する)。ワークラウドは文章中のに出現する単語をその出現数などに応じて大きさなどを変えて配置するものだが、今回は絵文字だけを使ったワードクラウド(絵文字クラウド)を作成してみる。


環境

WSL2(Ubuntu20.04)。

$ lsb_release -dr
Description:    Ubuntu 20.04.3 LTS
Release:        20.04
$ python3 -V
Python 3.8.10


データの収集

絵文字クラウドを作成するには絵文字を含むテキストが必要になる。そこでTwitter APIを利用してツイートを収集する。今回はTweepy4でリアルタイムツイートを取得するのようにTweepyを利用する。以下コードでツイートのランダムサンプリングを1000件取得してpickle形式で保存しておく。

import pickle

import tweepy

# Twitter APIの認証情報
# Twitterの開発者向けのページで取得したキーとトークンを使う
CONSUMER_KEY = 'Consumer Key'
CONSUMER_SECRET = 'Consumer Secret'
ACCESS_TOKEN = 'Access Token'
ACCESS_TOKEN_SECRET = 'Access Token Secret'

# 取得するツイート数
TWEET_COUNT = 1000

# 取得したツイートを保存するパス
PKL_PATH = './tweet.pkl'


class TwStream(tweepy.Stream):
    texts = []

    def on_status(self, status):
        # status(APIで取得できるデータ)を受け取ったときにon_statusが実行される
        print('-------------------------')
        # スクリーンネームとツイートを表示
        print(f'{len(self.texts)} @{status.author.screen_name} {status.text}')

        self.texts.append(status.text)

        if len(self.texts) >= TWEET_COUNT:
            self.disconnect()


def main():
    twstream = TwStream(
        CONSUMER_KEY,
        CONSUMER_SECRET,
        ACCESS_TOKEN,
        ACCESS_TOKEN_SECRET
    )

    # 日本語ツイートに限定してランダムサンプリングを取得
    twstream.sample(languages=['ja'])

    with open(PKL_PATH, 'wb') as f:
        pickle.dump(twstream.texts, f)


if __name__ == '__main__':
    main()


必要なモジュールなどのインストール

絵文字クラウドの作成に必要なPythonモジュールなどをインストールする。

$ pip3 install wordcloud emoji

$ pip3 list | grep -e wordcloud -e emoji
emoji                    1.2.0
wordcloud                1.8.1


絵文字クラウドの作成

準備ができたので絵文字クラウドを作成する。保存したツイートからから絵文字を抽出して絵文字ごとの出現頻度を集計し、ワードクラウドを作成する。ただし、pngなどの画像に保存するとフォントが表示されない絵文字があるのでSVGで保存する。

import pickle
from collections import Counter

from wordcloud import WordCloud
import emoji

# 取得したツイートを保存したパス
PKL_PATH = './tweet.pkl'

# 最小絵文字頻度
MIN_COUNT = 3


def extract_emojis(text):
    # テキストから絵文字を抽出
    return [c for c in text if c in emoji.UNICODE_EMOJI['en']]


def counter(texts):
    # 絵文字がないテキストを除外
    col = [Counter(t) for t in texts if len(t) > 0]

    # 各テキストの絵文字数を合計
    return sum(col, Counter())


def load_tweets():
    with open(PKL_PATH, 'rb') as f:
        texts = pickle.load(f)

    print(f'len={len(texts)}')
    return texts


def create_word_cloud(features):
    # 絵文字クラウドの作成
    wordcloud = WordCloud(
        background_color='white',
        width=300,
        height=300,
        max_font_size=200
    )
    wordcloud.generate_from_frequencies(frequencies=features)

    # 絵文字クラウドを保存
    # pngなどの画像に直接保存するとフォントが正常に表示されない絵文字があるのでSVGで保存
    # wordcloud.to_file("./emojicloud.png")
    wordcloud_svg = wordcloud.to_svg(embed_font=True)
    with open('./emojicloud.svg', 'w') as f:
        f.write(wordcloud_svg)


def main():
    texts = load_tweets()
    emojis = [extract_emojis(t) for t in texts]
    emoji_freq = counter(emojis)

    # 出現頻度MIN_COUNT以上に制限
    features = {term: cnt for term, cnt in emoji_freq.items() if cnt >= MIN_COUNT}
    print(features)

    create_word_cloud(features)


if __name__ == '__main__':
    main()


作成したSVGをWindowsのChrome、Firefoxで表示するとそれぞれ以下の通り。フォントが違うのか微妙な違いがある。



2021年3月7日日曜日

LDAの最適なトピック数を決めたい

トピックモデルのひとつとしてLDA(潜在的ディリクレ配分法)がある。文書に複数の潜在的なトピックがあることを仮定したモデルで、文書分類などに使われる。以前には国会会議録の発話を分類してみたこともある(PythonとLDAで国会会議録の発話を分類して可視化する)。LDAモデルを作成する時には事前にトピック数を指定する必要があが、これがけっこう悩ましい。今回はこのトピック数を決める方法を試してみる。


PerplexityとCoherence

LDAモデルを作成するときのトピック数を決める指標としてよく登場するのがPerplexityとCoherence。Perplexityは低い数値、Coherenceは高い数値が良いとされている。トピック数を変えてモデルを作成し、それぞれの値を算出して最適なトピック数を決めることになる。

ただ、PerplexityもCoherenceも絶対的な指標ではなく、これらを使ったからといって最適なトピック数が必ず決められるわけではない。今回は青空文庫にある夏目漱石の『こころ』のテキストを章ごとに分割してLDAモデルを作成し、PerplexityとCoherenceを求めてみる。


環境


WSL2(Ubuntu20.04)上にJupyter LabとMeCabがインストールされた環境。形態素解析を行うためのMeCabのインストール方法はMeCab: Yet Another Part-of-Speech and Morphological Analyzerを参照。
 

以下のPythonライブラリを使用するのでインストール。


テキストデータの準備

テキストデータを準備する。青空文庫にある夏目漱石の『こころ』を章ごとに形態素解析する。この処理には青空文庫にある夏目漱石の『こころ』のテキストを章ごとに分割して形態素解析するのコードを使う。このコードを実行すると、以下のように章ごとの単語リストが作成される。

[['人', '常に', '先生', '呼ぶ', '先生', '書く', '本名', '打ち明ける', '世間', 'かる', '遠慮', 'いう', '自然', '人', '記憶', '呼ぶ', '起す', '先生', 'いう', 'なる', '筆', '執る', '心持', 'よそよそしい', '頭文字', '使う', 'なる', '先生', '知り合い', 'なる', '鎌倉', '若々しい', '書生', '暑中', '休暇', '利用', 'する', '海水浴', '行く', '友達', 'ぜひ', '来る', ...


PerplexityとCoherenceの値を算出

続いてトピック数5から150まで5ずつ増やしてPythonライブラリのGensimでLDAモデルを作成し、算出したPerplexityとCoherenceの値をグラフ化する。Gensimには4種類のCoherence算出方法がある(Topic coherence pipeline)が、今回はu_massを使う。

%matplotlib inline

import pandas as pd
from gensim.corpora import Dictionary
from gensim.models import LdaModel, CoherenceModel
import matplotlib.pyplot as plt

from preprocess_kokoro import preprocess

# 『こころ』のテキストを章ごとに形態素解析
texts = preprocess()

dct = Dictionary(texts)
corpus = [dct.doc2bow(t) for t in texts]

def compute_metrics(model, texts, corpus, dct):
    # Perplexity算出
    pp = np.exp2(-model.log_perplexity(corpus))

    # Coherenceの算出
    cm = CoherenceModel(model=model, texts=texts, dictionary=dct, coherence='u_mass')
    ch = cm.get_coherence()

    return pp, ch

# トピック数を5から150まで5ずつ増やしてPerplexityとCoherenceを算出
perplexity = []
coherence = []
num_topic_range = range(5, 151, 5)
for num_topics in num_topic_range:
    model = LdaModel(corpus=corpus, id2word=dct, num_topics=num_topics, minimum_probability=0.0, random_state=0)
    pp, ch = compute_metrics(model, texts, corpus, dct)
    perplexity.append(pp)
    coherence.append(ch)

metrics = pd.DataFrame({'num_topic': num_topic_range, 'perplexity': perplexity, 'coherence': coherence})

# PerplexityとCoherenceをグラフ化
fig, ax1 = plt.subplots(figsize=(9, 6))
metrics.plot(x='num_topic', y='perplexity', color='b', legend=False, ax=ax1)
ax1.set_xlabel('num_topics')
ax1.set_ylabel('perplexity', color='b')
ax1.tick_params('y', colors='b')

ax2 = ax1.twinx()
metrics.plot(x='num_topic', y='coherence', color='r', legend=False, ax=ax2)
ax2.set_ylabel('coherence', color='r')
ax2.tick_params('y', colors='r')

fig.tight_layout()
plt.show()

結果は以下の通り。Perplexityは低い数値、Coherenceは高い数値が良いとされているが、それを適用するとトピック数最小の5が最適なトピック数ということになる。この結果を見る限り、今回のテキストデータについてはPerplexityとCoherenceは使えなさそう。


ldaturningによるトピック数の決定

ldaturningというRのパッケージがあって、これを使うとトピック数を決める指標となる4種類の値を算出してくれるらしい。Select number of topics for LDA modelにそのサンプルコードがある。このパッケージを使って、最適なトピック数を求めてみる。


データの準備

PerplexityとCoherenceを算出したときと同じデータを使いたいので、Pythonでベクトル化したデータをcsvに出力して、それをRで読み込む。以下はPythonでベクトルデータ(kokoro_vec.csv)を作成するコード。

import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

from preprocess_kokoro import preprocess

# 『こころ』のテキストを章ごとに形態素解析
texts = preprocess()

# 単語リストをタブ区切りテキストに変換
tab_separated = ['\t'.join(t) for t in texts]

# tokenizerでタブで分割するように指定
vectorizer = CountVectorizer(lowercase=False, tokenizer=lambda x: x.split('\t'))
vec = vectorizer.fit_transform(tab_separated)

df = pd.DataFrame(vec.toarray(), columns=vectorizer.get_feature_names())
display(df.head())
df.to_csv('kokoro_vec.csv', index=False, header=True)

PandasのDataFrameの中身は以下のとおり。









R環境の準備


RStudioを使ってもいいのだが、ここではJupyter LabでRを使えるようにする。まずはRのインストール。


Jupyter Lab上でRを使えるようにするためにIRkernelをインストールする。

これでJupyter LabでRが使える。














続いてldaturningに必要なパッケージをインストールする

最後にRのライブラリをインストール。


ldaturningで指標の算出

Rのライブラリldaturningを使うにはdocument-term matrix形式のデータ(DocumentTermMatrix)が必要。まずはPythonで作成したベクトルデータ(kokoro_vec.csv)からDocumentTermMatrixを作成し、ldaturningでトピック数が5から150まで5ずつ増やしていき指標値を算出する。さらにその結果をグラフ化する。

library("ldatuning")
library("dplyr")
library("tm")

# Pythonで作成したベクトルデータ読み込んでDocumentTermMatrix形式に変換
dtm <- read.csv("kokoro_vec.csv") %>% as.DocumentTermMatrix(weighting = weightTf)

# 4種類の指標値の算出
result = FindTopicsNumber(
    dtm,
    topics = seq(from = 5, to = 150, by = 5),
    metrics = c("Griffiths2004", "CaoJuan2009", "Arun2010", "Deveaud2014"),
    method = "Gibbs",
    control = list(seed = 77),
    mc.cores = 2L,
    verbose = TRUE
)

# 結果のグラフ化
ldatuning::FindTopicsNumber_plot(values = result)

結果は次の通り。4つの指標のうちCaoJuan2009とArun2010は小さいほど、Griffiths2004とDeveaud2014は大きいほどよい。この結果をみるとトピック数20あたりがよさそう。














2021年2月27日土曜日

Pythonでインタラクティブな共起ネットワークを作成する

Pythonで共起ネットワークを作成するでは、青空文庫にある夏目漱石の『こころ』のテキストデータをもとにPythonライブラリのNetworkXとグラフ描画パッケージのGraphvizとで共起ネットワークを作成した。ここでの共起とは2単語が同じ章に出現することで、この単語ペアをカウントしてJaccard係数(Jaccard係数の計算式と特徴(1)を参照)を計算した後に、その結果をもとにネットワーク図を作成した。今回はグラフライブラリのPlotlyを利用してインタラクティブな共起ネットワーク図を作成する。

※Jupyter Lab拡張機能インストール方法の追加とソースコードコメント修正(2021/3/21)


環境


WSL2(Ubuntu20.04)上にJupyter LabとMeCabがインストールされた状態。態素解析を行うためのMeCabのインストール方法はMeCab: Yet Another Part-of-Speech and Morphological Analyzerを参照。
 

グラフ描画パッケージGraphvizをインストール。
 

さらに以下のPythonライブラリを使用するのでインストール。
 

Jupyter LabでPlotlyの図を表示できるようにJupyter Lab拡張機能をインストール。拡張機能のインストールにはnode.jsが必要なので先にインストールしておく。

最後に日本語フォントをインストール。


テキストデータの準備から形態素解析まで


今回もテキストデータとして青空文庫にある夏目漱石の『こころ』を使う。テキストファイル(773_ruby_5968.zip)をダウンロードして解凍する。

青空文庫のテキストデータにはルビなどがあって、テキスト分析の際には不要なので本文以外のテキストを取り除いたテキストファイル(kokoro.txt)を作成する(Pythonで青空文庫の作品テキストからルビなどを取り除く参照)。

『こころ』のテキストを章ごとに分割して形態素解析し、単語の原型リストを作成する。また、形態素解析したすべての単語を使うのでなく、名詞のうち詳細分類1が「サ変接続」「ナイ形容詞語幹」「形容動詞語幹」「一般」「固有名詞」の単語のみを対象とする。ここまではPythonで共起ネットワークを作成するのコードと同じ。


Jaccard係数の算出

Jaccard係数の算出は以前と別の方法を使う。以前は章ごとに単語ペアの数をカウントするなどしてJaccard係数を求めたが、今回はテキストをベクトル化した上でJaccrad係数を算出する。また、以前は単語ペアの出現数やJaccard係数の閾値でネットワーク図に表示されるedge数を調整するようにしていたが、この方法だと入力するテキストによってedge数が変わって汎用性がないのでJaccard係数の上位~個を対象とするように変更する。また、すべての単語を対象にするとベクトルデータのサイズが大きくなりすぎるので対象単語を文書頻度(Document Frequency)で制限する。

以下がJaccard係数を算出するコード。入力データはPythonで共起ネットワークを作成するのchapter2bformの結果(形態素解析した章ごとの単語リスト)を使う。まずは対象単語を文書頻度上位80語に限定し、章ごとのタブ区切りテキストにする。それをScikit-learnのCountVectorizerを使ってベクトルにする。その結果をone-hotベクトルにして、Scipyのpdistを使ってJaccrad係数を求める。

def filter_word_by_freqency(texts, freq_top=80):
    # Document frequency
    c_word = Counter([word for t in texts for word in set(t)])

    top_word = [word for word, cnt in c_word.most_common(freq_top)]

    texts_fitered = []
    for t in texts:
        filtered = list(set(top_word).intersection(set(t)))
        if len(filtered) > 0:
            texts_fitered.append(filtered)

    return texts_fitered

def compute_jaccard_coef(texts, edge_top):

	# 単語リストをタブ区切りテキストに変換
    tab_separated = ['\t'.join(t) for t in texts]

    # tokenizerでタブで分割するように指定
    vectorizer = CountVectorizer(lowercase=False, tokenizer=lambda x: x.split('\t'))
    vec = vectorizer.fit_transform(tab_separated)

    # 単語リスト
    words = vectorizer.get_feature_names()

    # 0/1のベクトルにするためにカウント1以上はすべて1にする
    vec_one = (vec.toarray() >= 1).astype(int).transpose()

    # pdistを使うと結果は密行列(condensed matrix)で得られる
    # pdistで得られるのは距離なので、係数にするために1から引く
    jaccard_coef = 1 - pdist(vec_one, metric='jaccard')

    # 密行列の順番はitertools.combinationsnの結果と一致するのでcombinationsでJaccard係数に対応する単語ペアを作成する
    # How does condensed distance matrix work? (pdist)
    # https://stackoverflow.com/questions/13079563/how-does-condensed-distance-matrix-work-pdist
    w_pair = list(combinations(words, 2))

    # 単語ペアをキーとするJaccard係数のdict
    dict_jaccard = {pair: value for pair, value in zip(w_pair, jaccard_coef)}

    # Jaccard係数はネットワーク図のedgeに相当する
    # その数を一定数に限定する
    dict_jaccard = dict(sorted(dict_jaccard.items(), key = lambda x: x[1], reverse = True)[:edge_top])

    return dict_jaccard

# ルビなど除去済みのテキストを読み込む
with open('kokoro.txt') as f:
    doc = f.read()

# 章ごとの単語原型リスト
bform_2l = chapter2bform(doc2chapter(doc))

texts_filtered = filter_word_by_freqency(bform_2l, freq_top=80)
jaccard_dict = compute_jaccard_coef(texts_filtered, edge_top=60)
print(jaccard_dict)
結果は次のようにJaccard係数が算出される。

以前の結果をJaccard係数降順でソートした結果と比較してみる。はじめの3ペアは出現頻度が低いので今回は対象外になっている。以降のペアのJaccard係数は一致している。


インタラクティブな共起ネットワークを作成

Jaccard係数が計算できたら、それをもとに共起ネットワークを作成する。NetworkXとGraphvizでネットワーク図のnodeの配置を作成し、それをもとにPlotlyでインタラクティブなネットワーク図を作成する。

def build_interactive_network(G, pos, node_sizes, node_colors):
     
    # edgeデータの作成
    edge_x = []
    edge_y = []
    for edge in G.edges():
        x0, y0 = pos[edge[0]]
        x1, y1 = pos[edge[1]]
        edge_x.append(x0)
        edge_x.append(x1)
        edge_x.append(None)
        edge_y.append(y0)
        edge_y.append(y1)
        edge_y.append(None)
 
    edge_trace = go.Scatter(
        x=edge_x, y=edge_y,
        line=dict(width=0.5, color='#888'),
        hoverinfo='none',
        mode='lines')
 
    # nodeデータの作成
    node_x = []
    node_y = []
    for node in G.nodes():
        x, y = pos[node]
        node_x.append(x)
        node_y.append(y)
 
    # nodeの色、サイズ、マウスオーバーしたときに表示するテキストの設定
    node_trace = go.Scatter(
        x=node_x,
        y=node_y,
        text=list(G.nodes()),
        hovertext=node_sizes,
        textposition='top center',
        mode='markers+text',
        hoverinfo='text',
        marker=dict(
            showscale=True,
            colorscale='Portland',
            reversescale=False,
            color=node_colors,
            size=node_sizes,
            colorbar=dict(
                thickness=15,
                title='Page Ranking',
            ),
            line_width=2))
    
    data = [edge_trace, node_trace]

    # レイアウトの設定
    layout=go.Layout(
                paper_bgcolor='rgba(0,0,0,0)',
                plot_bgcolor='rgba(0,0,0,0)',
                showlegend=False,
                hovermode='closest',
                margin=dict(b=10, l=5, r=5, t=10),
                font=dict(size=10),
                xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
                yaxis = dict(showgrid = False, zeroline = False, showticklabels = False))

    fig = go.Figure(data=data, layout=layout)
    fig.show()

def build_coonw(texts, freq_top=80, edge_top=60):
    # 対象をDocument Frequency上位に限定
    texts_filtered = filter_word_by_freqency(texts, freq_top)

    dict_jaccard = compute_jaccard_coef(texts_filtered, edge_top)
    print('dict_jaccard=', dict_jaccard)

    # Document frequency
    df = Counter([word for t in texts_filtered for word in set(t)])

    # nodeリスト
    nodes = sorted(set([word for pair in dict_jaccard.keys() for word in pair]))

    # 単語出現数でnodeサイズを変更する
    c_word = {n: df[n] for n in nodes}

    G = nx.Graph()

    #  接点/単語(node)の追加
    G.add_nodes_from(nodes)
    print('Number of nodes: {}'.format(G.number_of_nodes()))

    #  線(edge)の追加
    for pair, coef in dict_jaccard.items():
        G.add_edge(pair[0], pair[1], weight=coef)
    print('Number of edges: {}'.format(G.number_of_edges()))

    # nodeの配置方法の指定
    seed = 0
    np.random.seed(seed)
    pos = nx_agraph.graphviz_layout(
        G,
        prog='neato',
        args='-Goverlap="scalexy" -Gsep="+6" -Gnodesep=0.8 -Gsplines="polyline" -GpackMode="graph" -Gstart={}'.format(seed))

    # nodeの色をページランクアルゴリズムによる重要度により変える
    pr = nx.pagerank(G)

    # インタラクティブな共起ネットワークの可視化
    build_interactive_network(G, pos, list(c_word.values()), list(pr.values()))

# ルビなど除去済みのテキストを読み込む
with open('kokoro.txt') as f:
    doc = f.read()

# 章ごとの単語原型リスト
bform_2l = chapter2bform(doc2chapter(doc))

# 共起ネットワークの作成
build_coonw(bform_2l, freq_top=80, edge_top=60)

結果、以下のような共起ネットワークが作成される。マウスオーバーで単語数を表示したり、図のズームなどができる。


全体のコードはこちら


2021年1月12日火曜日

KH Coderでぴえんツイートを分析する2

KH Coderでぴえんツイートを分析するでは「今年の新語 2020」の大賞に選ばれた「ぴえん」を含むツイートをKH Coderで分析して大辞林の「ぴえん」の説明と比較してみた。今回はツイートされた時間帯に注目してKH Coderで分析してみる。ちなみに大辞林の「ぴえん」の説明は次の通り。

(若者言葉で)軽度の悲しみや落胆、また喜びや感激の気持ちを表す語。


環境


Windows10 Pro Version 2004
Windows版 KH Coder Version 3.Beta.01g
形態素解析器はMeCabで辞書としてmecab-ipadic-neologdを使用


分析対象のツイート


Twitter APIで収集した「ぴえん」を含むツイート(リツイート除く)で、期間は12月5日~12月11日の1週間。ここではKH Coderでぴえんツイートを分析するの再分析を行ったときと同じ状態で分析を行う。つまり、特定のツイートを除外したり、いくつかの語を強制抽出したりしている。

KH Coderで読み込むツイートはCSVとして保存してあり、1時、2時などの時間帯情報の列(hour)もある。時間帯ごとのツイート数は以下の通り(%は全体に占める割合)。

*0時 6194 5.74%
*1時 3634 3.36%
*2時 2126 1.97%
*3時 1284 1.19%
*4時   761       0.70%
*5時   689       0.64%
*6時 1090 1.01%
*7時 2286 2.12%
*8時 2632 2.44%
*9時 2678 2.48%
*10時 3078 2.85%
*11時 3489 3.23%
*12時 4949 4.58%
*13時 4197 3.89%
*14時 3846 3.56%
*15時 4405 4.08%
*16時 5075 4.70%
*17時 6287 5.82%
*18時 7265 6.73%
*19時 7890 7.31%
*20時 8408 7.79%
*21時 8694 8.05%
*22時 9024 8.36%
*23時 8020 7.43%

KH Coderで分析


今回はKH Coderで対応分析を行った。「抽出語×外部変数」では1時間ごとの時間帯を外部変数として指定した。さらに「最小出現数」と「最小文書数」は同数とし、対象が80語以上になるようにした上で、差異が顕著な上位80語を分析に使用した。以下は原点付近を拡大した結果。

0~23時の時間帯がほぼ時計回りに並んでいる。時間帯によってツイート内容が違っていると考えてよさそう。1日のうちでツイート数の多い21時、22時付近にはより多くの語が配置されている。

続いて、時間帯ごとの特徴語を出力してみる。KH Coderで読み込んだCSVに「hour」という時間帯の列があるとして、[ツール]>[外部変数と見出し]で「hour」列を選択して「特徴語」ボタンを押すと時間帯ごとの特徴語をExcelやCSVで出力できる。以下は12時台から23時台までの特徴語(数値はJaccard係数)。




昼間は「仕事」が特徴語になっている時間帯が多い。仕事に関係したツイートで「ぴえん」が使われることが多いのかもしれない。夜間は「ありがとう」が特徴語になっているが、大辞林の説明で言うところの喜びや感激の気持ちを表しているのだろうか。

2020年12月27日日曜日

KH Coderでぴえんツイートを分析する

三省堂が選んだ「今年の新語 2020」の大賞に「ぴえん」が選ばれた。

「今年の新語 2020」の選考結果

4種類の辞書の説明が掲載されているが、そのうちの大辞林の説明は次のようになっている。

(若者言葉で)軽度の悲しみや落胆、また喜びや感激の気持ちを表す語。

今回はTwitterのデータをKH Coderで分析して、Twitter上の「ぴえん」の使われ方と大辞林の説明を比較してみる。


環境


Windows10 Pro Version 2004
Windows版 KH Coder Version 3.Beta.01g
形態素解析器はMeCabで辞書としてmecab-ipadic-neologdを使用


分析対象のツイート


対象はTwitter APIで収集した「ぴえん」を含むツイート(リツイート除く)で、期間は12月5日~12月11日の1週間。多数投稿するユーザーのツイートを除外するために収集期間に8回以上投稿したユーザーのツイートを除外した(8回に特別な理由はないが、中央値は1なので投稿1回のユーザーが多い)。ちなみにツイート数上位10ユーザーの投稿数は次の通りで、中には1000を超えるユーザーもいた。

1326
128
116
90
76
73
60
54
42
40

さらに、ツイートからURL、@ではじまるユーザーネーム、絵文字を除去した。最終的な対象ツイートは109660件で、日ごとのツイート数は次の通り。

12/05 16669
12/06 16142
12/07 15257
12/08 14825
12/09 15686
12/10 15581
12/11 15500

ツイートの収集や集計などはPythonで行っているが詳細は省略。


KH Coderで分析

まずは抽出語を確認。もちろん「ぴえん」は圧倒的な最上位にある。

次に共起ネットワークを作成した。設定では「最小出現数」と「最小文書数」は同数とし、対象が80語以上になるようにした上で、上位80語を使用した。


赤枠の箇所では「ぱおん」や「ぴえんこえてぱおん」「ぴえんヶ丘どすこい之助」という「ぴえん」の上位語? らしいが出現している。mecab-ipadic-neologdを使ってもこれらは認識できなかったようだ。これらの語は後の再分析でKH Coderで強制的に語として抽出できるようにする。

オレンジ枠では、Peing(Twitterで利用できる質問サービス)を利用した投稿とDMを募集する投稿が多くを占めている。それぞれ「みんなからの匿名質問を募集中!」と「会う話したい。」という同じフレーズではじまっているツイートが多い。同じフレーズが多いと共起ネットワークに表れやすくなってしまうので、これらのツイートは除外する(後の再分析前に別途Pythonで処理)。

さらに、「ぴえん」はすべてのツイートに含まれるため、後の再分析でKH Coderで使用しない語に設定する。また「言う」「思う」は分析に寄与しないと考えられるので同様に使用しない語に設定する。


KH Coderで再分析

もろもろの設定などをして再度共起ネットワークを作成した。

まずは、「みんなからの匿名質問を募集中!」と「会う話したい。」という同じフレーズではじまるツイートを除外した結果、対象ツイートは108001件となった。

続いて強制抽出する語の設定。KH Coderでは強制抽出する語のリストをテキストファイルから読み込める。次のようなテキストファイルを用意。

---cell---
ぴえんヶ丘どすこいの助
ぴえんヶ丘どすこい之助
ぴえんヶ丘どすこいのすけ
ぴえんこえてぱおん
ぴえん超えてぱおん
ぱおん

使用しない語のリストもテキストファイルから読み込める。「ぴえん」「言う」「思う」以外に抽出語上位の記号、ひらがな一文字もリストに加えておく。

---cell---
ぴえん
思う
言う
()

再度共起ネットワークを作成する。設定は前回と同じ。


peingなどの投稿で使われた語が出現しなくなった。また、「ぴえん超えてぱおん」といった語が認識されていて、設定などの効果があったことが確認できた。ただ、ここで改めて抽出語を確認してみると、「ぴえん」と関係がありそうな抽出語52番目の「悲しい」が共起ネットワークに出現していない。これは「悲しい」と共起している語が多くないためと考えられる。


そこで多次元尺度構成法を使ってみる。さらに、気持ちを表す形容詞に注目しようと思うので形容詞B(ひらがなのみの形容詞)も対象に加えた。



気持ちを表す形容詞に注目すると、大辞林の説明と一致するのは緑枠の「悲しい」や「嬉しい」「楽しい」で、それに「辛い」を含めていいと思う。ただ、赤枠の「怖い」は「悲しみ」や「落胆」と関連づけるのは微妙。形容詞ではないが気になったのが「怖い」の右隣に布置された「怒る」。辞書の説明と一致していないように思えたが、確認すると「怒られた」という文脈で使われていることが多いので「落胆」に相当すると考えられる。全体としては、おおむね大辞林の説明と一致していると言えそう。

2020年3月30日月曜日

pyLDAvisとUMAPでLDAを可視化する

LDAの結果を次元削減して可視化することがあるが、PythonではpyLDAvisを使うと簡単に可視化できる(PythonとLDAで国会会議録の発話を分類して可視化する)。pyLDAvisではPCAやt-SNEなどの次元削減に対応しているが、t-SNEよりも高速に次元削減できると言われているUMAPには対応していない。そこで、pyLDAvisのソースコードを改修してUMAPに対応させてみる。


環境


Windows10(1903)のWSL(Ubuntu 18.04)とJupyter Notebookを使用。



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


pyLDAvisとUMAP用のライブラリUMAP-learnをインストールする。


インストールされたバージョンは以下の通り。



pyLDAvisのソースコードの改修


まずはPythonライブラリなどがインストールされているディレクトリを確認する。ホームディレクトリ内の「.local/lib/python3.6/site-packages」がそのディレクトリで、その配下にpyLDAvisというディレクトリがある。修正するのはpyLDAvisディレクトリ内の_prepare.py。


pyLDAvisではscikit-learn(sklearn.manifold.TSNE)でt-SNEによる次元削減を行っている。UMAP-learnはTSNEクラスと同様の書式で使えるので、基本的にはTSNE用のコードを参考にUMAP用のコードを追加する。

TSNEではパラメータのmetricで距離の計算方法を指定でき、それはUMAP-learnでも同じ。ただし、pyLDAvisでは独自の関数で定義したJensen-Shannonダイバージェンスを距離の計算に使用しており、TSNEのパラメータmetricに「precomputed」を指定して計算済みのデータを渡している。UMAP-learnのドキュメントにはmetricに「precomputed」はないが、Does UMAP accept a distance matrix as custom metric?によると、バージョン0.2以降では対応しているらしい。

上記をふまえて、_prepare.pyを以下のように変更する。
# 23行目に以下を追記
try:
    from umap import UMAP
    umaplearn_present = True
except ImportError:
    umaplearn_present = False

# js_TSNE関数の後(170行目あたり)に以下を追記
def js_UMAP(distributions, **kwargs):
    """Dimension reduction via UMAP

    Parameters
    ----------
    distributions : array-like, shape (`n_dists`, `k`)
        Matrix of distributions probabilities.

    **kwargs : Keyword argument to be passed to `umap.UMAP()`

    Returns
    -------
    umap : array, shape (`n_dists`, 2)
    """

    dist_matrix = squareform(pdist(distributions, metric=_jensen_shannon))
    model = UMAP(n_components=2, metric='precomputed', random_state=0, **kwargs)
    return model.fit_transform(dist_matrix)

# 382行(mds = js_PCoAの次の行)に以下を追記
        elif mds == 'umap':
            if umaplearn_present:
                mds = js_UMAP
            else:
                logging.warning('umap-learn not present, switch to PCoA')
                mds = js_PCoA


データセットの準備


改修したpyLDAvisを試すために、株式会社 ロンウイットが公開しているlivedoor ニュースコーパス(通常テキスト:ldcc-20140209.tar.gz )を使用してgensimでLDAモデルを作成する。ダウンロードして解凍するとtextディレクトリ配下にニュースカテゴリーごとに9のディレクトリがある。それぞれのディレクトリ内には記事ごとのテキストファイルがある。




記事ごとのテキストファイルについては、textディレクトリ配下のREADME.txtにフォーマットの説明がある。
1行目:記事のURL
2行目:記事の日付
3行目:記事のタイトル
4行目以降:記事の本文

以下のPyhtonスクリプトで、記事テキストを読み込んでPandasのデータフレームにしてpickle形式で保存する。
import os
import glob
import re
import csv
from datetime import datetime

import pandas as pd

# ニュースカテゴリーのディレクトリ名
NEWS_CATEGORY = ['it-life-hack', 'movie-enter', 'sports-watch', 'kaden-channel', 'peachy', 'topic-news', 'dokujo-tsushin', 'livedoor-homme', 'smax']

def clean(text):
    # テキストのクリーニング

    text = text.strip()

    # ■関連リンク/サイト/ニュース/記事/情報 以降の削除
    res = re.split('■関連', text)

    if len(res) > 1:
        text = res[0]

    # 【関連情報】/【関連記事】 以降の削除
    res = re.split('【関連情報】|【関連記事】', text)
    if len(res) > 1:
        text = res[0]

    # メールアドレス削除
    text = re.sub('[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*', '', text)

    # URL削除
    text = re.sub('https?://[\w!?/\+\-_~=;\.,*&@#$%\(\)\'\[\]]+', '', text)

    # ~年~月~日を削除
    text = re.sub('[0-9]{1,2}月|[0-9]{1,2}日|[0-9]{2,4}年', '', text)

    return text

def main():
    df = pd.DataFrame()
    for category in NEWS_CATEGORY:
        path = os.path.join('text', category, '{}-*.txt'.format(category))

        txtpaths = glob.glob(path)

        for txtfile in txtpaths:
            with open(txtfile, mode='r') as f:
                # URL
                url= f.readline().strip()

                # 日付
                dt = datetime.strptime(f.readline().split('+')[0].strip(), "%Y-%m-%dT%H:%M:%S")

                # 記事タイトル
                title= f.readline().strip()

                # 記事テキスト
                txt = f.read().strip()

            df = df.append([[category, url, dt, title, clean(txt)]], ignore_index=True)

    columns = ['category', 'url', 'date', 'title', 'body']
    df.columns = columns

    print(df.info())
    print(df.head())
    df.to_pickle('news.pkl')

if __name__ == '__main__':
    main()

上記スクリプトを実行して作成されたデータフレームには7367件のデータがある。



LDAモデルの作成


作成したデータフレーム内のテキストをMeCabで形態素解析して、gensimでLDAモデルを作成するスクリプトを用意する。今回は名詞、動詞、形容詞、副詞のみを使用し、LDAのトピック数はニュースカテゴリー数の9とする。
import pandas as pd

import MeCab
from gensim.corpora import Dictionary
from gensim.models import LdaModel

POS_LIST = ['名詞', '動詞', '形容詞', '副詞']
STOP_LIST = ['*']

m = MeCab.Tagger('-Ochasen')
m.parse('')

def text2list(text):
    # 形態素の原形リストを取得する

    node = m.parseToNode(text)
    term_l = []
    while node:
        feature_split = node.feature.split(',')

        pos1 = feature_split[0]
        base_form = feature_split[6]

        if pos1 in POS_LIST and base_form not in STOP_LIST:
            term_l.append(base_form)

        node = node.next
    
    return term_l

def ldamodel(text_2l, n_topics):
    # LDAモデルの作成

    dct =  Dictionary(text_2l)

    # no_below: no_belowを下回るドキュメント数にしか含まれない語を除外
    # no_above: 語を含む文書数/全文書数 がno_aboveを上回る語を除外
    dct.filter_extremes(no_below=5, no_above=0.7)

    # コーパスの作成
    corpus = [dct.doc2bow(text) for text in text_2l]

    # ニュースカテゴリー数をトピック数に指定してモデル作成
    model = LdaModel(corpus=corpus, num_topics=n_topics, minimum_probability=0.0, id2word=dct, random_state=1,
        alpha='auto', eta='auto', chunksize=1500, passes=2)

    return model, corpus, dct

def build_model():
    # 保存しておいたニュースのDataFrameの読み込み
    df = pd.read_pickle('news.pkl')

    # ニュースカテゴリー数
    n_categories = df['category'].nunique()

    # ニュース記事テキストを形態素解析してリストにする
    df['term_l'] = df['body'].apply(text2list)

    # LDAモデル作成
    return ldamodel(df['term_l'].tolist(), n_categories)

上記のLDAモデルを作成するスクリプト(lda_news.py)とニュース記事のデータフレーム(news.pkl)を同じディレクトリ(news)に置いておく。


pyLDAvisとUMAPでLDAを可視化する


準備が整ったので、修正したpyLDAvisでLDAの結果をJupyter Notebookで可視化してみようと思ったが、Jupyter Notebook上で表示できなかったのでhtmlに出力する(pyLDAvisを修正していない状態でも同じなので、今回の修正の影響ではない)。
import pyLDAvis
import pyLDAvis.gensim
pyLDAvis.enable_notebook()

# 実行ディレクトリをlda_news.pyとnews.pklがあるディレクトリに変更
import os
from pathlib import Path
home = str(Path.home())
os.chdir(os.path.join(home, 'news'))

from lda_news import build_model

model, corpus, dct = build_model()

# mdsに追加した「umap」を指定
vis = pyLDAvis.gensim.prepare(model, corpus, dct, mds='umap', sort_topics=False)

# htmlに出力
pyLDAvis.save_html(vis, 'lda.html')

UMAPで可視化した結果は以下の通り。


続いてt-SNEでの可視化の結果。

UMAPのほうがt-SNEよりも実行時間が短くなるのではと期待していたが、実際にはほとんど同じだった。もっと大規模なデータセットでないと差がでないのだろうか。

2020年3月8日日曜日

mecab-ipadic-NEologdの辞書を修正する

mecab-ipadic-NEologdで形態素解析を新語に対応させるのように、MeCabで形態素解析するときにmecab-ipadic-NEologdを使うと新語をひとつの語として判定してくれるので、標準のIPA辞書では判定できない固有名詞などにも対応できる。ただしmecab-ipadic-NEologdの辞書は自動で更新されているようで、使いたくない新語などが登録されていることがある。そういったデータを修正しようと思ったが、ドキュメントなどに方法が見つからなかったので、自分で調べて行った方法をまとめておく。


環境


Windows10(1903)のWSL(Ubuntu 18.04)。MeCabはインストール済みとする。



meab-ipadic-NEologdの準備


まずはmecab-ipadic-NEologdの手順通りにgithubからクローンしてインストールする。


インストールが完了したらインストールディレクトリを確認しておく。


mecab-ipadic-NEologdの動作確認。



meab-ipadic-NEologdの直したい辞書データ


mecab-ipadic-NEologdは、インターネット上をクロールして自動収集した語を加えているので新語に強いが、自動化されているためか、使うには微妙な語が登録されていることがある。以下の例ではmecab-ipadic-NEologdを使うと「涙。」が固有名詞として認識されている。



meab-ipadic-NEologdの辞書データを修正


「涙。」は「涙」と「。」として判定されるようにmecab-ipadic-NEologdの辞書データを修正する。クローンしたmecab-ipadic-NEologdのディレクトリ配下のseedに辞書データの元となるcsvがxz形式で圧縮されている。これらのファイルをまずは解凍する。


解凍したcsvを「涙。」でgrep検索してみる。


いくつか検索結果がヒットするが、その中に対象となる登録があった。mecab-user-dict-seed.20200130.csv内で「涙。」が固有名詞として登録されている。さらに「涙」も固有名詞として登録されていて、原形が「涙。」になっている。少なくとも自分にはこれらの登録は不要なのでこの2行を削除する。


修正したmecab-user-dict-seed.20200130.csvをxzで圧縮し直す。


辞書データの修正を反映させるためにmecab-ipadic-NEologdを再インストールする。


再インストール後に形態素解析してみると、「涙」と「。」が分割された。


2019年11月4日月曜日

KH CoderでStack OverflowのDeveloper Surveyを可視化する

KH Coderはテキストデータを統計的に分析するためのフリーソフトウェアで、テキストデータを形態素解析して、テキスト中の単語の出現頻度を集計し、共起ネットワークなどの分析を行える。このブログではKH Coderで国会会議録を対応分析するなど何度か記事を書いている。

KH Coderでは基本的にはテキストデータの分析が想定されていると思うが、アンケートなどの回答も、場合によっては連結してテキストにしたらKH Coderで分析できそうな気がする。例えば、選択肢から複数選択して回答するアンケート結果で、回答のひとつを単語とみなせば、KH Coderで分析できそう。

この考えを試すのにちょうどいいデータがあった。日ごろよくお世話になっているStack Overflowが毎年アンケート調査をやっていて、結果を公開している(Developer Survey Results 2019)。そして、そのアンケート結果のデータをStack Overflow Annual Developer Surveyからダウンロードできる。

このアンケートの質問項目に、過去に経験のあるプログラミング言語やデータベースなどの技術を選択肢から複数選択するものがある。回答者をひとつの文単位とみなすと、技術は単語とみなせる。今回は、Pythonでこのアンケケート結果を加工して、KH Coderで分析してみる。


環境


KH Coder
Windows10(May 2019 Update 1903)
Windows版 KH Coder Version 3.Alpha.17b

Windows10のWSL(Ubuntu 18.04)。KH Coderで使用するためのデータの加工はPythonのPandasで行う。



Developer Survey 2019データ


Stack Overflow Annual Developer Surveyから2019年のデータ(developer_survey_2019.zip)をダウンロード。

解凍すると以下のファイルやディレクトリがある。アンケート結果のデータはsurvey_results_public.csv。質問項目はso_survey_2019.pdfで確認できる。
  • README_2019.txt
  • so_survey_2019.pdf       
  • survey_results_schema.csv
  • __MACOSX       
  • survey_results_public.csv
質問項目には、過去に経験のあるプログラミング言語やデータベース、フレームワークなど、開発で使われる技術に関する質問があって、今回はRespondent(ランダムに割り振られた回答者ID)と以下の6項目のデータを使う。この6項目は、それぞれ複数項目選択可能で、「;」区切りのデータになっている。また、選択肢として「Other(s):」があるが、今回はこれは除外する。

LanguageWorkedWith(プログラミング言語、スクリプト、マークアップ言語)
DatabaseWorkedWith(データベース)
PlatformWorkedWith(プラットフォーム)
WebFrameWorkedWith(ウェブフレームワーク)
MiscTechWorkedWith(その他の技術)
DevEnviron(開発環境)


Developer Survey 2019データの加工


KH Coderが扱えるのはテキストデータなので、Pythonでアンケート結果のcsvを加工する。使用する複数の回答を連結して、回答者(Respondent)ごとにひとつのテキストデータにする。それぞれの回答は「;」区切りなので、回答の連結も「;」で行う。その際に、「Other(s):」は除外する。

加工したデータはsurvey_results_tech.csvとして出力し、さらに、KH Coderで強制抽出する単語(技術)のリストをtech.txtとして出力する。

以下のコードをsurvey_results_public.csvと同じディレクトリで実行する。
import os
import numpy as np
import pandas as pd

def remove_others(x):
    tech_l = x.split(';')

    # 「Other(s):」を削除
    try:
        tech_l.remove('Other(s):')
    except ValueError:
        # 「Other(s):」が存在しない場合
        pass

    if len(tech_l) == 0:
        return np.NaN
    else:
        return ';'.join(tech_l)

def clean_df(df):
    # NaNが含まれる行を削除
    df.dropna(how='any', inplace=True)

    # 「Other(s):」を削除してNaNが含まれる行を削除
    return df.applymap(remove_others).dropna(how='any')

def to_df():
    # Respondent(回答者ID)をインデックスとしてcsvをDataFrameとして読み込む
    with open(os.getcwd() + '/survey_results_public.csv', mode='r') as f:
        # error_bad_lines=True: カラム数の不一致などのエラーはdropする
        # skipinitialspace=True: カンマのあとの空白を削除
        df = pd.read_csv(f, encoding='utf-8', index_col=['Respondent'], header=0, error_bad_lines=True, skipinitialspace=True,
            usecols=['Respondent','LanguageWorkedWith', 'DatabaseWorkedWith', 'PlatformWorkedWith',
            'WebFrameWorkedWith', 'MiscTechWorkedWith', 'DevEnviron'])

    print('*** DataFrame information ***')
    print(df.info())
    print()

    with pd.option_context('display.max_rows', None, 'display.max_columns', None):
        print('*** Numbers of unique data ***')
        # 列ごとのユニークなデータの数
        print(df.nunique())
        print()

    return df

def create_extraction_list(df):
    # 「;」で文字列を分割して複数列のDataFrameを作成
    expanded = df['tech'].str.split(';', expand=True)

    # DataFrame内の項目を2次元Numpy配列にして、さらに1次元に展開してユニークな値を取得
    # 'K'の場合、メモリ内の発生順序で要素を読み取り
    items_unique = pd.unique(expanded.values.ravel('K'))

    # str以外を削除(np.nanやNoneを削除)
    items_unique = [x for x in items_unique if isinstance(x, str)]

    # 「Other(s):」を削除
    try:
        items_unique.remove('Other(s):')
    except ValueError:
        # 「Other(s):」が存在しない場合
        pass

    # KH Coder用の強制抽出単語リスト作成
    with open('tech.txt', mode='w') as f:
        f.write('\n'.join(items_unique))

def main():
    # csvからデータを読み込んでDataFrameにする
    df = to_df()

    # データのクリーニング
    df = clean_df(df)

    # 「;」区切りの文字列にして半角スペースをアンダースコアに変換
    df['tech'] = df.apply(lambda row: ';'.join(row).replace(' ', '_'), axis=1)
    print(df['tech'].head())

    # 強制抽出語のリストをファイルに出力
    create_extraction_list(df)

    # KH Coderで読み込むcsv作成
    df.to_csv('survey_results_tech.csv', columns=['tech'])        

if __name__ == '__main__':
    main()

head survey_results_tech.csvの中身。


tech.txtの結果。



KH Coderでデータを読み込む



データの加工ができたら、KH Coderの[プロジェクト]>[新規]でsurvey_results_tech.csvを分析対象として選択し、分析対象とする列を「tech」とする。

次に強制抽出する語を指定する。Pythonで作成したtech.txtには強制抽出する語のリストがあるが、「C」を強制抽出すると「C++」や「C#」が抽出できなくなる。こういった語はコメントアウトしておく。さらに、「---cell---」「;」を追記する。最終的に以下のようなリストになった。


強制抽出のリストができたら、KH Coderの[前処理]>[語の取捨選択]>[強制抽出する語の指定]で「ファイルから読み込む」でtech.txtを指定する。さらに、「使用しない語の指定」にも「---cell---」「;」を追加しておく。

ここまでできたら、[前処理]>[前処理の実行]で前処理を実行する。

前処理が完了したら、まずは[ツール]>[抽出語]>[抽出リスト]で、回答項目の文字列がきちんと抽出できているか確認。


KH Coderで分析


回答文字列の抽出ができているようなので、KH Coderで分析してみる。まずは対応分析。「抽出語 X 文章」を選択し、「バブルプロット」にチェックをつけて実行。図の左側にはWindows関連の技術、下側にはPython関連の技術がまとまっている。

続いて共起ネットワークを作成する。こちらはデフォルト設定のまま。共起ネットワークでも、Windows関連の技術がまとまっている。


2019年10月19日土曜日

Pythonで共起ネットワークを作成する

テキスト分析で使われる方法のひとつに共起ネットワークがあって、フリーソフトのKH Coderやユーザーローカルのテキストマイニングツールを使ってできる。共起というのは、ある文章中に単語Aと単語Bが出現していれば、単語Aと単語Bはこの文章で共起していると言えるもの。この文書の単位は特に決まっておらず、段落でも章でも、分析によって決める。この共起関係をネットワーク図にしたものが共起ネットワーク。

単純に分析をやりたいだけなら、上記ソフトウェアやツールを使えばいいのだけど、興味があって共起ネットワーク図をPythonで作成してみることにした。青空文庫にある夏目漱石の『こころ』のテキストデータをMeCabで形態素解析し、PythonライブラリのNetworkXでネットワーク図を作成する。

インタラクティブな共起ネットワークを作成する場合は以下を参照
Pythonでインタラクティブな共起ネットワークを作成する


環境


Windows10(1903)のWSL(Ubuntu 18.04)。Numpyの他に、態素解析を行うためのMeCab(MeCab: Yet Another Part-of-Speech and Morphological Analyzer)とMeCabのPythonラッパーmecab-python3はインストール済みとする。



テキストデータの準備


まずは分析を行うテキストを準備する。使用するテキストは、青空文庫にある夏目漱石の『こころ』。青空文庫のデータはGitHubからダウンロード可能(青空文庫のデータを一括ダウンロードする)で、このGitHubのデータを使う。GitHubからダウンロードしたデータから、以下のようなコマンドでタイトルに「こころ」を含む作品のファイルパスを検索できる。


青空文庫のテキストデータにはルビなどがあって、テキスト分析の際には不要なのでPythonで青空文庫の作品テキストからルビなどを取り除くにあるPythonスクリプトで本文以外のテキストを取り除いたテキストファイル(kokoro.txt)を作成する。



形態素解析


『こころ』は上中下の三部構成で、上中下あわせると110章ある。今回は、同じ章に共起する単語で共起ネットワークを作成する。そのためには、『こころ』のテキストを章ごとに分割して形態素解析し、単語の原型リストを作成する。

また、今回は形態素解析したすべての単語を使うのでなく、名詞のうち詳細分類1が「サ変接続」「ナイ形容詞語幹」「形容動詞語幹」「一般」「固有名詞」の単語のみを対象とした。

品詞一覧は、PythonとMeCabで形態素解析して品詞ごとに語を抽出するのように、辞書ディレクトリにあるpos-id.defで確認できる。MeCab辞書のパスは以下のコマンドで確認できる。


品詞一覧を確認するには以下のコマンド。


以下のコードで章ごとに形態素解析して単語原形のリストを作成する。
import re
import MeCab

# 対象の品詞
TARGET_POS1 = ['名詞']

# 対象の詳細分類1
TARGET_POS2 = ['サ変接続', 'ナイ形容詞語幹', '形容動詞語幹', '一般', '固有名詞']

# ストップワード
STOP_WORDS = ['*']

def remove_blank(chapter):
    # 空白行と段落先頭の空白を削除

    lines = chapter.splitlines()

    # 空白行削除
    # 行頭の空白削除
    lines_cleaned = [l.strip() for l in lines if len(l) != 0]

    return '\n'.join(lines_cleaned)

def doc2chapter(doc):
    # 文章を章ごとに分割

    # タイトル削除
    doc = doc.replace('上 先生と私', '').replace('中 両親と私', '').replace('下 先生と遺書', '')

    # 章番号で章ごとに分割
    doc_split = re.split('[一二三四五六七八九十]{1,3}\n', doc)

    # 先頭は空白行なので削除
    del doc_split[0]

    print('Total chapter number: ', len(doc_split))

    chapter_l = list(map(remove_blank, doc_split))

    return chapter_l

def chapter2bform(chapter_l):
    # 章ごとに形態素解析して単語の原型のリストを作成

    m = MeCab.Tagger('-Ochasen')
    m.parse('')

    bform_2l = []
    for i, chapter in enumerate(chapter_l):
        node = m.parseToNode(chapter)

        bform_l = []
        while node:
            feature_split = node.feature.split(',')

            pos1 = feature_split[0]
            pos2 = feature_split[1]
            base_form = feature_split[6]

            if pos1 in TARGET_POS1 and pos2 in TARGET_POS2 and base_form not in STOP_WORDS:
                bform_l.append(base_form)

            node = node.next

        bform_2l.append(bform_l)

        print('Term number of chapter {}: '.format(i+1), len(bform_l))

    return bform_2l

def main():
    with open('kokoro.txt') as f:
        doc = f.read()

    # 章ごとの単語原型リスト
    bform_2l = chapter2bform(doc2chapter(doc))

if __name__ == '__main__':
    main()

こんな感じの2次元リストが作成される。



Jaccard係数


共起する頻度が多い語の組み合わせを単純に共起の程度が大きいとしてしまうと、多くの文書に存在する語の共起の程度が大きくなる。多くの文書に存在する語というのは文章に特徴的な単語とは言えないので、こういった語が共起の上位にあっても、文章全体の特徴をよく表すことができない。例えば「する」「なる」などは出現頻度が高い語であるが、これらの語は、あらゆる文章で出現頻度が高いので、文章を特徴づける語とはならない。

ただ、出現頻度が低すぎても文章を特徴づける語として採用するのは適切とは言えないので、そのあたりを考慮して共起の程度を測る必要がある。そこで共起の程度を計る指標としてJaccard係数を使う。Jaccard係数についてはJaccard係数の計算式と特徴(1)を参照。


PythonでJaccard係数を計算する


形態素解析した結果からPythonでJaccard係数を計算してみる。

はじめに、章ごとの原型リストからユニークな単語ペアのリストを作成し、単語ペアが全文書中でいくつの章に出現しているかカウントする。ただし、出現数5未満の単語ペアは除外した。

続いて、単語ごとの出現章数をカウントし、それと先にカウントした単語ペアごとの出現章数からJaccard係数を計算する。ただし、係数が0.4未満の単語ペアは除外した。

Pythonコードは以下の通り(原形リストを作成するコードは省略)
from itertools import combinations, dropwhile
from collections import Counter, OrderedDict

def bform2pair(bform_2l, min_cnt=5):
    # 単語ペアの出現章数をカウント

    # 全単語ペアのリスト
    pair_all = []

    for bform_l in bform_2l:
        # 章ごとに単語ペアを作成
        # combinationsを使うと順番が違うだけのペアは重複しない
        # ただし、同単語のペアは存在しえるのでsetでユニークにする
        pair_l = list(combinations(set(bform_l), 2))

        # 単語ペアの順番をソート
        for i,pair in enumerate(pair_l):
            pair_l[i] = tuple(sorted(pair))

        pair_all += pair_l

    # 単語ペアごとの出現章数
    pair_count = Counter(pair_all)

    # ペア数がmin_cnt以上に限定
    for key, count in dropwhile(lambda key_count: key_count[1] >= min_cnt, pair_count.most_common()):
        del pair_count[key]

    return pair_count

def pair2jaccard(pair_count, bform_2l, edge_th=0.4):
    # jaccard係数を計算

    # 単語ごとの出現章数
    word_count = Counter()
    for bform_l in bform_2l:
        word_count += Counter(set(bform_l))

    # 単語ペアごとのjaccard係数を計算
    jaccard_coef = []
    for pair, cnt in pair_count.items():
        jaccard_coef.append(cnt / (word_count[pair[0]] + word_count[pair[1]] - cnt))

    # jaccard係数がedge_th未満の単語ペアを除外
    jaccard_dict = OrderedDict()
    for (pair, cnt), coef in zip(pair_count.items(), jaccard_coef):
        if coef >= edge_th:
            jaccard_dict[pair] = coef
            print(pair, cnt, coef, word_count[pair[0]], word_count[pair[1]], sep='\t')

    return jaccard_dict

def main():
    with open('kokoro.txt') as f:
        doc = f.read()

    # 章ごとの単語原型リスト
    bform_2l = chapter2bform(doc2chapter(doc))

    pair_count = bform2pair(bform_2l, min_cnt=5)
    jaccard_dict = pair2jaccard(pair_count, bform_2l, edge_th=0.4)

if __name__ == '__main__':
    main()

以下のように、単語ペア、単語ペアの出現章数、jaccard係数、単語ペアの各単語の出現章数が出力された。



共起ネットワークを作成する


Jaccard係数が計算できたら、それをもとに共起ネットワークを作成する。ネットワーク図の作成はPyhtonライブラリのmatplotlibNetworkXで行う。どちらもpipでインストールできる。


NetworkXで、node(ネットワークの接点)として単語を追加し、共起(単語のペア)をedge(接点間のリンク)として追加する。このときにソートしてから追加しないと、同じデータでも作図のたびに配置が変わってしまう。

以下のコードで共起ネットワーク図をco-occurance.pngとして保存する(共起ネットワーク作成部分のみ)。
import networkx as nx
# matplotlibのターミナル対応
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

def build_network(jaccard_dict):
    # 共起ネットワークを作成

    G = nx.Graph()

    #  接点/単語(node)の追加
    # ソートしないとネットワーク図の配置が実行ごとに変わる
    nodes = sorted(set([j for pair in jaccard_dict.keys() for j in pair]))
    G.add_nodes_from(nodes)

    print('Number of nodes =', G.number_of_nodes())

    #  線(edge)の追加
    for pair, coef in jaccard_dict.items():
        G.add_edge(pair[0], pair[1], weight=coef)

    print('Number of edges =', G.number_of_edges())

    plt.figure(figsize=(15, 15))

    # nodeの配置方法の指定
    seed = 0
    np.random.seed(seed)
    pos = nx.spring_layout(G, k=0.3, seed=seed)

    # nodeの大きさと色をページランクアルゴリズムによる重要度により変える
    pr = nx.pagerank(G)
    nx.draw_networkx_nodes(
        G,
        pos,
        node_color=list(pr.values()),
        cmap=plt.cm.rainbow,
        alpha=0.7,
        node_size=[100000*v for v in pr.values()])

    # 日本語ラベルの設定
    nx.draw_networkx_labels(G, pos, fontsize=15, font_family='TakaoPGothic', font_weight='bold')

    # エッジ太さをJaccard係数により変える
    edge_width = [d['weight'] * 8 for (u, v, d) in G.edges(data=True)]
    nx.draw_networkx_edges(G, pos, alpha=0.7, edge_color='darkgrey', width=edge_width)

    plt.axis('off')
    plt.tight_layout()

    plt.savefig('co-occurance.png', bbox_inches='tight')

def main():
    with open('kokoro.txt') as f:
        doc = f.read()

    # 章ごとの単語原型リスト
    bform_2l = chapter2bform(doc2chapter(doc))

    # Jaccard係数の計算
    pair_count = bform2pair(bform_2l, min_cnt=4)
    jaccard_dict = pair2jaccard(pair_count, bform_2l, edge_th=0.4)

    # 共起ネットワーク作成
    build_network(jaccard_dict)

if __name__ == '__main__':
    main()

以下のような共起ネットワーク図が作成された。



nodeがなるべく重ならないように共起ネットワークを作成する


作成した共起ネットワークではnodeがけっこう重なっている。そこで、なるべくnodeが重ならないような共起ネットワークを作成する。そのためにはGraphvizを利用する。

Graphvizはaptコマンでインストールできる。コマンドはdotを使う。


Graphvizにもいくつかnodeの配置方法(レイアウト)があるが、ここではneatを使う。GraphvizのFAQ(How can I avoid node-edge overlaps in neato?)にneatで重なりを防ぐ方法が紹介されている。GraphvizのオプションはNode, Edge and Graph Attributesに説明があるので、他のオプションも試して、何となく良さげな配置になるオプションを採用した。

先述コードのbuild_network内の「pos = nx.spring_layout(G, k=0.3, seed=seed)」を以下のコードと置き換える。FAQ通りに「-Gsplines=true」としなかったのは、edgeを直線だけにしたかったから。
    pos = nx_agraph.graphviz_layout(
        G,
        prog='neato',
        args='-Goverlap="scalexy" -Gsep="+6" -Gnodesep=0.8 -Gsplines="polyline" -GpackMode="graph" -Gstart={}'.format(seed))

結果は以下の通り。nodeの重なりがほとんど解消された。


全体のコードはこちら


2019年9月21日土曜日

PythonとCaboChaで係り受け解析をする

日本語には係り受け関係があって、例えば「青い鳥」の場合は「青い」が「鳥」を修飾している。そういう係り受けの関係を解析できるのがCaboCha(CaboCha/南瓜: Yet Another Japanese Dependency Structure Analyzer)で、Pythonでも利用できる。WSL(Ubuntu 18.04)にCaboChaをインストールして、Pythonで係り受け解析をやってみる。


環境


Windows10(1903)のWSL(Ubuntu 18.04)。CaboChaを利用するためには形態素解析器も必要で、MeCab(MeCab: Yet Another Part-of-Speech and Morphological Analyzer)を使用する。MeCabはインストール済みとする。



CRF++のインストール


CaboChaの使用にはCRF++も必要なのでインストールする。まずはcrfpp downloadsから現時点の最新版(CRF++-0.58.tar.gz)をダウンロードする。curlコマンドなどでダウンロードできないので、Windows10のブラウザでダウンロードしてWSLのディレクトリにコピーした。

ダウンロードしたファイルを以下のコマンドでインストールする。



CaboChaのインストール


続いてCaboChaをインストールする。CaboCha downloadsから現時点の最新版(cabocha-0.69.tar.bz2)をダウンロードする。CRF++のときと同様にWindows10でダウンロードして、WSLのディレクトリにコピーした。

以下のコマンドでインストール。デフォルト文字コードはeucなので、MeCabに合わせてutf8に変更する。



CaboChaの実行


CaboChaのライブラリは/usr/local/libにインストールされたが、このパスが共有ライブラリ検索パスにないとCaboChaは動作しない。


CaboChaのライブラリパスを共有ライブラリ検索パスに追加。


再度CaboChaを実行すると、今度は動作した。


LD_LIBRARY_PATHへのパス追加はOSを再起動すると元に戻ってしまうので、再起動しても追加したパスが保持されるようにするには、~/.bashrcに「export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib」を追記しておく。


PythonからCaboChaを使う


ダウンロードしたcabocha-0.69.tar.bz2を解凍するとpythonというディレクトリがあり、その中にPython用ラッパーのインストールスクリプトがある。


インストールが完了したらPythonで使用してみる。まずはCaboChaでTree表示をする。


続いて、係り受け解析レイヤの出力フォーマットで表示してみる。



mecab-ipadic-NEologdを使う


mecab-ipadic-NEologdで形態素解析を新語に対応させるのように、形態素解析を行うMeCabの辞書をデフォルトのIPA辞書からmecab-ipadic-NEologdに変更できる。CaboChaからmecab-ipadic-NEologdを使用するには、以下のようにmecab-ipadic-NEologdのパスを指定する。



2019年9月8日日曜日

PythonでPDFの表からデータを抽出する

Python3でPDFのテキストを抽出するではPDFMinerでPDFからテキストを抽出したが、表データが含まれたPDFもよくある。PDFMinerでもテキストデータとして抽出して整形すればできないことはなさそうだが、tabula-javaのPythonラッパーであるtabula-pyを使うと簡単に表のデータを抽出できるので実際にやってみる。

抽出する表を含むPDFは、何かと話題のキャッシュレス決済によるポイント還元制度の公式サイトからダウンロードできる登録加盟店一覧のPDF。


環境


Windows10(1903)のWSL(Ubuntu 18.04)。



ポイント還元制度の登録加盟店一覧のPDF


まずは、データを抽出するPDFの内容を確認しておく。ポイント還元制度の登録加盟店一覧のPDFを以下のコマンドでダウンロード。ファイル名はkameiten_touroku_list.pdfで、サイズは約31MB。


9月7日時点でダウンロードできたPDFは、9月5日時点の登録申請状況をもとにした一覧で6360ページある。1ページ目は目次、2ページ目は加盟登録申請状況で、3ページ目から加盟店一覧の表が続く。加盟店一覧の表は② 固定店舗(EC・通信販売を除く)、③EC・通信販売(楽天市場)、④ EC・通信販売(Yahoo!ショッピング)、⑤ EC・通信販売(その他ECサイト)の4種類あるが、今回は② 固定店舗(EC・通信販売を除く)の表からデータを抽出する。対象のページは3ページから5536ページまで。

② 固定店舗(EC・通信販売を除く)の表には「No.」「都道府県」「市町村区」「事業所名(屋号)」「業種」「還元率」の6つのカラムがあるが、「業種」は2つのセルからなる。また、表のカラム名の行は各ページにある。


tabula-pyのインストール


PDFからPythonで表データを抽出するためにtabula-pyをインストールするが、先にJDKをインストールしておく。


続いてtabula-pyのインストール。



PDFから表データの抽出


tabula-pyでPDFから表データを抽出してみる。tabula-pyは読み込んだPDF内の表をPandasのデータフレームとして返してくれる。以下のコードでPDFの表をデータフレームとして抽出できる。

import pandas as pd
import tabula

PDF_PATH = 'kameiten_touroku_list.pdf'

def main():
    # lattice=Trueでテーブルの軸線でセルを判定
    df = tabula.read_pdf(PDF_PATH, lattice=True, pages = '3-5536')
    print(df.head())

    # データフレームの情報確認
    print(df.info())
if __name__ == '__main__':
    main()

以下のようにPDFの表がデータフレームとして抽出できた。最後のカラム名が「Unnamed: 6」になっているのは「業種」カラムが2セルに分かれているため。


また、データフレームの情報も確認しておく。PDFを見る限り232391行のデータがあるはずだが、各ページのカラム名の行も取り込んでいるので237924行ある。



抽出したデータのクリーニング


とりあえずデータフレームにしたが、カラム名の行や重複データが混ざっているのでクリーニングする。
import pandas as pd
import tabula

PDF_PATH = 'kameiten_touroku_list.pdf'

def cleaning(df_original):
    df = df_original.copy()

    # 「業種」カラムが2セルに分かれていてヘッダーとデータがずれるのでカラム名を再設定する
    df.columns = ['No.', '都道府県', '市町村区', '事業所名(屋号)', '業種1', '業種2', '還元率']

    # 各ページのテーブルにヘッダーがあるので、ヘッダーを除外
    df = df[df['No.']!='No.']

    # 「No.」カラムの数字にはカンマがあるので削除してint型に変換
    df['No.'] = pd.to_numeric(df['No.'].str.replace(',', ''))

    # 「%」削除してintに変換
    df['還元率'] = pd.to_numeric(df['還元率'].str.replace('%', ''))

    col_cat = ['都道府県', '市町村区', '事業所名(屋号)', '業種1', '業種2']
    # category型に変換
    df[col_cat] = df[col_cat].astype('category')

    # 重複データ削除
    df = df[~df[col_cat].duplicated()]

    # インデックス再設定
    df.reset_index(drop=True, inplace=True)

    return df

def main():
    # lattice=Trueでテーブルの軸線でセルを判定
    df = tabula.read_pdf(PDF_PATH, lattice=True, pages = '3-5536')
    #print(df.head())

    # データフレームのクリーニング
    df_cleaned = cleaning(df)

    # データフレーム情報の確認
    print(df_cleaned.info())

    # カラムごとのユニークなデータの数
    print(df_cleaned.nunique())
if __name__ == '__main__':
    main()

クリーニング後のデータフレーム情報の確認。最終的に228687行のデータになった。


カラムごとのユニークなデータの数を確認。都道府県は47だし、還元率は2%と5%だけなので大丈夫そう。



データの可視化


せっかくクリーニングもしたので、matplotlibで業種ごとの還元率を比較してみる。以下はグラフ作成部のみのコード。
# ターミナル環境用の設定
import matplotlib
matplotlib.use('Agg')

import matplotlib.pyplot as plt

# 日本語フォントの設定
font = {'family' : 'TakaoPGothic'}
matplotlib.rc('font', **font)

# 業種ごとの積み上げ棒グラフ
plt.figure()
df_sector = df_cleaned.groupby(['業種2', '還元率'])['業種2'].count().unstack('還元率').fillna(0)
df_sector.plot(kind='bar', stacked=True)
plt.tight_layout()
plt.savefig('sector_stacked.png')

結果は以下の通り。還元率5%のところが多いが、ガソリンスタンドはほとんどが2%、食料品は約半分の店舗が2%になっている。