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の重なりがほとんど解消された。


全体のコードはこちら


0 件のコメント:

コメントを投稿