Pythonを使って文章から共起ネットワークを作る

スポンサーリンク

 今回は文章から共起ネットワークを作ってみたいと思います。一つの文内で出てくる単語の組合せをネットワーク化することで、文章の趣旨を明らかにしていきます。ドラマの人物相関図みたいに単語と単語のつながりをネットワーク化したものを作ります。

 前回、自然言語の前処理の勉強をした際に、福沢諭吉の「学問のすすめ」において、出現頻度が高い名詞のランキングを導出しました。TOP1〜3は「政治」、「人民」、「独立」という単語であり、この3つに関してよく取り上げられているのだろうと推測されました。これに関しては以下のブログを御覧ください。
www.dskomei.com

 ただし上記の話では、各単語が同じ文内で使われているかはわかりません。一見、3つの単語だけでは国家に関して述べられているような気はしますが、「政治」の単語は社内政治に関するテーマでよく使われているのかもしれません。そこで、同時に使われている単語の組合わせという観点で文章の趣旨を明らかにしていきたいと思います。そのために単語の組合せの繋がりをネットワークで可視化します。最終的には以下の図の共起ネットワークを作ります。

f:id:dskomei:20190407171014p:plain:w600

 本エントリーのコードはここ(Github)に置いてあります。

本エントリーの流れ
  1. 前回作成した各文の単語リストから単語の組合わせを作成
  2. 単語間の重みを測定
    単語の組合せの出現回数からJaccard係数を求める
  3. 共起ネットワークの作成

 図で表すと以下のような流れになります。

f:id:dskomei:20190408123147p:plain:w700

文の単語リストから単語の組合せを作成

 前回青空文庫にある福沢諭吉の『学問のすすめ』から各文の名詞の単語リストを作成しました。

天
この世,天,働き,心,万物,天地,位,妨げ,安楽,上下,自由自在,身,衣食住,みな,趣意,物,賤
下人,人間,有様,世界,相違
次第,明らか
智,語,実,教,愚人

 この単語リストから各文の単語の組合せを作ります。このとき、今回は文内における単語の関連性を見ることを目的としているため、1語しかない文は省いています。なので、最初の文の「天」が省かれます。単語の組合せを作るためモジュールitertoolsのcombinations関数を使用します。combinations関数に関しては itertoolsによる順列、組み合わせ、直積のお勉強 - Qiita に詳しく書かれています。

from pathlib import Path
import itertools
import collections
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import seaborn as sns
pd.set_option('display.max_columns', 100)

data_dir_path = Path('.').joinpath('data')
result_dir_path = Path('.').joinpath('result')

if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)

base_file_name = 'gakumonno_susume'
lines = read_data(base_file_name + '_cut_splited_word.txt', dir_path=data_dir_path)
    
#  単語リストのテキストを単語に分割してリスト化し、見出しは省いている
sentences = [line.replace('\n', '').split(',') for line in lines if not ('見出し' in line)]
#  文内の単語が1語しかない場合は削除
sentences = [sentence for sentence in sentences if len(sentence) > 1]
sentence_combinations = [list(itertools.combinations(sentence, 2)) for sentence in sentences]
sentence_combinations = [[tuple(sorted(words)) for words in sentence] for sentence in sentence_combinations]
print('単語の組合せ')
for combinations in sentence_combinations[:3]:
    print(combinations)
単語の組合せ
[('この世', '天'), ('この世', '働き'), ('この世', '心'), ('この世', '万物'), ('この世', '天地'), ('この世', '位'), 
[('下人', '人間'), ('下人', '有様'), ('下人', '世界'), ('下人', '相違'), ('人間', '有様'), ('世界', '人間'), ('人間', '相違'),
[('明らか', '次第')]


単語間の重みを測定

 単語の組合わせのリストができたので、そこから単語間の重みを求めます。重みの大きい組合せの方が単語間の関連性が強いとします。ただ、そのまま単語の組合せの使用頻度を重みとすると、どの文にでも現れる単語の組合せの重みが大きくなります。これでは、よく使われる単語だから自ずと組合せの出現回数も多いだけで、重みが高いから組合せが重要とはいえなくなってしまいます。

 そこで、一方の単語が出たときにもう一方の単語も文内にある組合せの重みが大きくなるようにします。二人揃っての出現が多い芸人さんのコンビの方が、単独での出現が多いコンビよりも仲がよく見えますよね。その重みを求めるために今回は、Jaccard係数を使います。ここ【技術解説】集合の類似度(Jaccard係数,Dice係数,Simpson係数)が参考になります。Jaccard係数は以下の式です。

\(\displaystyle Jaccard係数=\frac{n(A ∩ B)}{n(A ∪ B)}\)

 積集合の個数が単語の組合せの個数を表し、それを和集合の数で割ることで、一方しか多く現れていない組合せのJaccard係数が大きくならないようにしています。ただ、和集合の数を求めるのは計算コストが高いので、和集合の数を求めるための基本式である次式を利用します。

\(\displaystyle n(A ∪ B)=n(A) + n(B) - n(A ∩ B)\)

#  単語の組合せの1次元のリストに変形
target_combinations = []
for sentence in sentence_combinations:
target_combinations.extend(sentence)

##------------------------------------  Jaccard係数を求める
# Jaccard係数 = n(A ∩ B) / n(A ∪ B)

#  直積の計算(同じ文内にある2つの単語の出現回数を計算)
combi_count = collections.Counter(target_combinations)

#  単語の組合せと出現回数のデータフレームを作る
word_associates = []
for key, value in combi_count.items():
    word_associates.append([key[0], key[1], value])

word_associates = pd.DataFrame(word_associates, columns=['word1', 'word2', 'intersection_count'])

#  和集合の計算 n(A ∪ B) = n(A) + n(B) - n(A ∩ B) を利用
#  それぞれの単語の出現回数を計算
target_words = []
for word in target_combinations:
    target_words.extend(word)

word_count = collections.Counter(target_words)
word_count = [[key, value] for key, value in word_count.items()]
word_count = pd.DataFrame(word_count, columns=['word', 'count'])

#  単語の組合せの出現回数のデータにそれぞれの単語の出現回数を結合
word_associates = pd.merge(word_associates, word_count, left_on='word1', right_on='word', how='left')
word_associates.drop(columns=['word'], inplace=True)
word_associates.rename(columns={'count': 'count1'}, inplace=True)
word_associates = pd.merge(word_associates, word_count, left_on='word2', right_on='word', how='left')
word_associates.drop(columns=['word'], inplace=True)
word_associates.rename(columns={'count': 'count2'}, inplace=True)

word_associates['union_count'] = word_associates['count1'] + word_associates['count2'] - word_associates['intersection_count']
word_associates['jaccard_coefficient'] = word_associates['intersection_count'] / word_associates['union_count']

print('Jaccard係数の算出')
print(word_associates.head())
Jaccard係数の算出
  word1 word2  intersection_count  count1  count2  union_count  jaccard_coefficient  
0   この世     天                   1      59      96          154          0.006494  
1   この世    働き                   1      59     391          449          0.002227
2   この世     心                   1      59     477          535          0.001869
3   この世    万物                   1      59      30           88          0.011364
4   この世    天地                   1      59      84          142          0.007042

 上記のコードでは、単語の組合せのデータフレームを作り、そこに各単語の出現回数を結合し、和集合を求めた後、Jaccard係数を計算しています。
 ここまでこれば単語の組合せとその重みが求まっているので、ネットワークで可視化できます。

共起ネットワーウを作る前に

 共起ネットワークを作る準備が整ったので早速作りたいところですが、データの中身を軽く見てみます。Jaccard係数と各単語の出現回数をグラフにして見てみようと思います。そのために、Jaccard係数の値に応じて各単語の組合せを5つのグループに分けます。各テーブルの閾値は以下のテーブルの通りです。

f:id:dskomei:20190408163113p:plain:w300

 上記のグループ分けでプロットすると、以下の図のようになりました。下の図を見ると、第4グループはJaccard係数が一番高いグループですが、このグループは単語の出現回数が低い傾向にあることがわかります。これは、Jaccard係数は高いものの出現回数は低く、突発的にしか出ててきていない単語の組合せがだと思われます。単語の出現回数が少ない単語は文章全体に与える影響は小さいので、共起ネットワークを作る際には単語の出現回数で足切りをした方が良さそうです。

f:id:dskomei:20190408161506p:plain:w600
 このプロットまでのコードは以下の通りです。

jaccard_coefficients = word_associates['jaccard_coefficient']
group_numbers = []
for coefficient in jaccard_coefficients:
    if coefficient < 0.003:
        group_numbers.append(0)
    elif coefficient < 0.006:
        group_numbers.append(1)
    elif coefficient < 0.009:
        group_numbers.append(2)
    elif coefficient < 0.012:
        group_numbers.append(3)
    else:
        group_numbers.append(4)
word_associates['group_number'] = group_numbers

word_associates_group_sum = word_associates.groupby('group_number').count()
word_associates_group_sum.reset_index(inplace=True)
print(word_associates_group_sum.loc[:, ['group_number', 'word1']])
print('')

sns.pairplot(hue='group_number', data=word_associates.sample(800).loc[:, ['count1', 'count2', 'group_number']])
plt.savefig(image_dir_path.joinpath(base_file_name+'_jaccard_group_plot.png'))


共起ネットワークを作る

 上記まででネットワークを構築するためのデータの加工が終了したので、実際にネットワーク化します。今回はネットワークのモジュールとしてnetworkxを使っています。処理の流れは以下の通りです。

  1. ネットワークのインスタンスを作る
  2. ノードを追加
  3. エッジを追加
  4. 孤立ノードの削除
  5. ネットワークインスタンスへの書込み

 今回はノードの大きさと色をPageRankの値に応じて変えています。PageRankの説明はここ次数中心性からPageRankからまた次数中心性 - でかいチーズをベーグルするが参考になります。ざっくりいうと、他のノードからの遷移数が多いノードの値が高くなります。

n_word_lower = 150
edge_threshold = 0.01

word_associates.query('count1 >= @n_word_lower & count2 >= @n_word_lower', inplace=True)
word_associates.rename(columns={'word1':'node1', 'word2':'node2', 'jaccard_coefficient':'value'}, inplace=True)

plot_network(data=word_associates, edge_threshold=edge_threshold)

##  共起ネットワークを表示する関数
def plot_network(data, edge_threshold=0., fig_size=(15, 15), file_name=None, dir_path=None):

    nodes = list(set(data['node1'].tolist()+data['node2'].tolist()))

    G = nx.Graph()
    #  頂点の追加
    G.add_nodes_from(nodes)

    #  辺の追加
    #  edge_thresholdで枝の重みの下限を定めている
    for i in range(len(data)):
        row_data = data.iloc[i]
        if row_data['value'] > edge_threshold:
            G.add_edge(row_data['node1'], row_data['node2'], weight=row_data['value'])

    # 孤立したnodeを削除
    isolated = [n for n in G.nodes if len([i for i in nx.all_neighbors(G, n)]) == 0]
    for n in isolated:
        G.remove_node(n)

    plt.figure(figsize=fig_size)
    pos = nx.spring_layout(G, k=0.3)  # k = node間反発係数

    pr = nx.pagerank(G)

    # nodeの大きさ
    nx.draw_networkx_nodes(G, pos, node_color=list(pr.values()),
                           cmap=plt.cm.Reds,
                           alpha=0.7,
                           node_size=[60000*v for v in pr.values()])

    # 日本語ラベル
    nx.draw_networkx_labels(G, pos, fontsize=14, font_family='IPAexGothic', font_weight="bold")

    # エッジの太さ調節
    edge_width = [d["weight"] * 100 for (u, v, d) in G.edges(data=True)]
    nx.draw_networkx_edges(G, pos, alpha=0.4, edge_color="darkgrey", width=edge_width)

    plt.axis('off')

    if file_name is not None:
        if dir_path is None:
            dir_path = Path('.').joinpath('image')
        if not dir_path.exists():
            dir_path.mkdir(parents=True)
        plt.savefig(dir_path.joinpath(file_name), bbox_inches="tight")

 結果は冒頭でも貼りましたが、以下の図のようになりました。「独立」「文明」の組合せが他のノードからの繋がりが多く、メインのテーマになっていることが推測されます。更に、そこには「外国」「人民」「わが国」と繋がります。これで文章の大きなテーマが見えてきた気がします。また前回は、「交際」という単語が出現回数の多い単語ということがわかり、ロマンティックな要素があることを想像しました。ですが、繋がっているのは「外国」であり、外国との関係性の話のようですね。

f:id:dskomei:20190407171014p:plain:w700

終わりに

 今回共起ネットワークを描いて文の単語のつながりから趣旨を読み取ることを勉強しました。ネットワークの構造に関して更に深ぼりたい方はこちらの本がおすすめです。

ネットワーク分析―何が行為を決定するか (ワードマップ)

ネットワーク分析―何が行為を決定するか (ワードマップ)