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)

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


全体のコードはこちら


1 件のコメント:

  1. こんにちは、共起ネットのpythonプログラムありがとうございました。源氏物語を身近に感じるためにも使わせていただきました。Plotlyでのインタラクティブなネットワーク図は目的よりも道具に関してしまいますね。
    https://vanlaborg.hatenablog.com/

    返信削除