pyvisでネットワーク可視化

このブログではネットワーク(グラフ)の可視化にはNetworkX(+Matplotlib)やGraphvizを使って来たのですが、最近pyvisというライブラリを気に入っていてよく使うようになりました。
訪問者の方にもぜひ触ってみていただきたいので軽く紹介していきます。

まず、どんなものが描けるのかの例です。このpyvisはvis.jsというJavaScriptのライブラリのPythonラッパーになっています。 そのため、vis.jsのドキュメントの examples ページを見るとさまざまな可視化例が載っています。こんなのが作れるんだ!ってモチベーションが上がるので一度見ていただけると幸いです。(実はこのvis.jsはネットワークの可視化以外の機能も持っているのですが、もっぱら僕の関心はネットワークの可視化にあります。)
参考: Vis Network Examples

各サンプルなのですが、それぞれのサンプル個別のページのアクセスして触っていただけると分かる通り、このライブラリで描いたネットワーク図はノードをマウスでドラッグして動かせます。これが1番のお勧めポイントです。また、単一のHTMLファイルで結果が出力されるので仕事で可視化した場合は結果は他の人に共有しやすいというのもいいですね。

それでは早速、使ってみましょう。pyvis 自体のドキュメントはこちらですが、母体のvis.jsのドキュメントや、Examplesのソースコードなども目を通されると理解が深まると思います。

本当に適当に作ってみました。インスタンス作って、ノードとエッジを追加して、最後HTML出力のメソッドを呼び出して完成です。

from pyvis.network import Network


# ネットワークのインスタンス生成
network = Network(
    height="300px",  # デフォルト "500px"
    width="400px",  # デフォルト "500px"
    notebook=True,  # これをTrueにしておくとjupyter上で結果が見れる
    bgcolor='#ffffff',  # 背景色。デフォルト "#ffffff"
    directed=True,  # Trueにすると有向グラフ。デフォルトはFalseで無向グラフ
)

# add_node でノードを追加
# label は省略可能。省略するとidと同じになる。
network.add_node(n_id=1, label=1, shape="circle")
network.add_node(n_id=2, label=2, shape="box", color="green")
network.add_node(n_id=3, label=3, shape="triangle")
network.add_node(n_id=4, label=4)  # shape を省略すると、 shape="dot"と同じになる

# add_edge でエッジを追加
network.add_edge(1, 2,)
network.add_edge(2, 4, width=2)  # width で太さを変えられる
network.add_edge(3, 4, smooth="dynamic")  # smooth を指定することで、エッジを曲線にできる
network.add_edge(4, 3, smooth="dynamic")  # エッジを曲線にすると双方向のエッジを別の線にできる。(直線だと重なる)

# 指定したファイル名でHTMLを出力。
network.show("pyvis_sample1.html")

出来上がりはこんな感じです。

動かせるっていいですね。

ソースコード中にコメントをいろいろ書いてますが、ノードもエッジも細かい設定がたくさんできます。pyvisのドキュメントだとこのページが該当するのですが、そこまで丁寧ではないので、vis.jsの方のドキュメントを読み込んで、そこから類推して使った方が理解が早いかもしれません。(場合によっては出力されたHTMLファイルを直接編集するとか。)
vis.js の方のドキュメントでは以下のページに設定可能な項目がまとまっています。
vis.js – Nodes documentation.
vis.js – Edges documentation.

もう一つ、HTML出力前に network.show_buttons() ってメソッドを実行しておくと、大量のコントロールボタンがHTMLに出力されます。(jupyterでは表示されないので、別途ブラウザでHTMLファイルを開いてください。)
これを使うと、ノードやエッジのオプションやノード配置の計算に使われている力学の設定などをインタラクティブに変更できます。

# ネットワークの作成までのコードは共通
network.show_buttons()  # 各種ボタンの表示
network.show("pyvis_sample2.html")

これも楽しいので試してみてください。(ただ、楽しいだけで今のところ有効な使い方を見出せずすみません。) ボタン多すぎ、って場合は、ドキュメントにある通り、filter_ って引数で絞り込めます。

最後に、vis.js から pyvis に移行した人への注意が一つあります。(僕はこのパターン。当初直接HTMLでvis.jsファイル書いてました。)
それは、 node の shape のデフォルト値で、 HTML/JavaScriptで書いてる場合、shapeを何も指定しなかった場合の挙動は”ellipse”です。しかし、pyvis は add_nodeでshape 引数を省略すると、出力されるJavaScirptに勝手に “shape”: “dot” をつけます。

何も指定せずに超単純なネットワークを描いたときの挙動が違うので僕は結構戸惑いました。
(ellipseは図の内側にラベルが入り、dotはラベルが外に出るという違いがちょっと気に入らなかった。その辺の話も、上記の Nodes documentation に書いてあります。)

NetworkXを使ってエイト・クイーンパズルを解く

“Python言語によるビジネスアナリティクス”と言う本の中に、NetworkXについての記述があるのですが、
その演習問題(問題43)として8-クイーン問題が出題されています。
面白そうだったので挑戦してみたところ、なんとか解けました。
この問題がネットワーク解析に関係あるとは意外ですね。

まず、8-クイーン問題については、Wikipediaの説明が分かりやすいと思います。
エイト・クイーン – Wikipedia

簡単に言うと、8×8の盤面状にチェスのクイーン(将棋の飛車と角を足した動きができる最強のコマ)をお互いに取り合えない位置に並べるパズルです。
正解は92通り(盤面の反転や回転を除いた本質的なものは12通り)あるそうです。

さて、これを解くために、次のアプローチを考えました。

1. 8×8の各マスをノードとするグラフを構築する。
2. クイーンがお互いに取り合えるマス同士をエッジで結ぶ。
3. そのグラフの補グラフを取得することで、クイーンがお互いに取り合えないマス通しが結ばれたグラフを作る。
4. ノード数が8のクリーク(その中に含まれる全てのノードが結ばれている部分グラフ)を探索する。

最初からお互いに取り合えないマスの間を結ぶのではなく、2.と3.に分けてるのはその方が実装がシンプルになるからです。


import networkx as nx

# 盤面の一辺のマス数
bord_size = 8

# グラフ生成
G = nx.Graph()

# ノードを作成する
for i in range(bord_size):
    for j in range(bord_size):
        G.add_node((i, j))

# 同じ行か同じ列に並ぶノードをエッジで結ぶ
for i in range(bord_size):
    for m in range(bord_size):
        for n in range(m+1, bord_size):
            G.add_edge((i, m), (i, n))
            G.add_edge((m, i), (n, i))

# 同じ斜め線状に並ぶノードをエッジで結ぶ
for n1 in G.nodes:
    for n2 in G.nodes:
        if n1 == n2:
            continue
        elif n1[0]+n1[1] == n2[0]+n2[1]:
            G.add_edge(n1, n2)
        elif n1[0]-n1[1] == n2[0]-n2[1]:
            G.add_edge(n1, n2)


# 補グラフを得る (お互いに取り合わない辺が結ばれている)
G_complement = nx.complement(G)

# サイズが変の数に等しいクリークの一覧を得る
answers = [
        clieque for clieque in nx.find_cliques(G_complement)
        if len(clieque) == bord_size
    ]

# 得られた解の個数
print(len(answers))
# 92

こうして、92個の解が得られました。
あとは実際にこれを可視化してみましょう。


import numpy as np
import matplotlib.pyplot as plt

fig = plt.figure(figsize=(12, 30), facecolor="w")

for i, answer in enumerate(answers, start=1):
    bord = np.zeros([bord_size, bord_size])
    ax = fig.add_subplot(16, 6, i)

    for cell in answer:
        bord[cell] = 1

    ax.imshow(bord, cmap="gray_r")

plt.show()

出力がこちらです。 一個一個みていくと確かに 縦横斜めにダブらない様にQueen(黒い点)が配置できています。
ただ、全部相異なることを確認するのは少し手間ですね。

もともと出題されていた本には解答が載っていないので、
もしかしたらこの記事のコードでは相当非効率なことをやっているかもしれませんが、
おそらくこんなイメージで解くのだと思います。

NetworkXで作成したグラフを3次元にプロットする

NetworkXでグラフを可視化する時、
2次元だとエッジが多すぎていわゆる毛玉状態になり、わけがわからないけど3次元だと少しマシになるということがあったので、3次元でプロットする方法を紹介しておきます。

公式ドキュメントの 3D Drawing のページを見ると、
Mayavi2 というのを使う方法が紹介されています。
ただ、僕がこれを使ったことがないのと、Matplotlibで十分できそうだったので、Matplotlibでやってみました。
Mayavi2 はこれはこれで便利そうですし、可視化の幅を広げられそうなので近いうちに試します。

まず、可視化するグラフデータを生成します。
今回はいつもみたいにランダム生成ではなく、エッジを具体的に指定して構築しました。
出来上がるのはサッカーボール型の多面体です。
(実はこのデータ生成の方が3次元プロットより苦労しました。)


import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import networkx as nx
import numpy as np

# エッジデータを生成
edge_list = [
    (0, 1), (1, 2), (2, 3), (3, 4), (4, 0),
    (0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
    (5, 10), (10, 11), (11, 6), (6, 12), (12, 13),
    (13, 7), (7, 14), (14, 15), (15, 8), (8, 16),
    (16, 17), (17, 9), (9, 18), (18, 19), (19, 5),
    (11, 20), (20, 21), (21, 12), (13, 22), (22, 23),
    (23, 14), (15, 24), (24, 25), (25, 16), (17, 26),
    (26, 27), (27, 18), (19, 28), (28, 29), (29, 10),
    (21, 30), (30, 31), (31, 22), (23, 32), (32, 33),
    (33, 24), (25, 34), (34, 35), (35, 26), (27, 36),
    (36, 37), (37, 28), (29, 38), (38, 39), (39, 20),
    (31, 40), (40, 41), (41, 32), (33, 42), (42, 43),
    (43, 34), (35, 44), (44, 45), (45, 36), (37, 46),
    (46, 47), (47, 38), (39, 48), (48, 49), (49, 30),
    (41, 50), (50, 42), (43, 51), (51, 44), (45, 52),
    (52, 46), (47, 53), (53, 48), (49, 54), (54, 40),
    (50, 55), (51, 56), (52, 57), (53, 58), (54, 59),
    (55, 56), (56, 57), (57, 58), (58, 59), (59, 55),
]

# 生成したエッジデータからグラフ作成
G = nx.Graph()
G.add_edges_from(edge_list)

さて、データができたので早速3次元空間にプロットしてみましょう。
方法は簡単で、以前紹介したmatplotlibの3次元プロットの方法で、
ノードとエッジを順番に出力するだけです。

ノードの方はこちらの記事が参考になります。
参考: matplotlibで3D散布図
エッジの方はまだ直接的に消化はしていませんが、2次元空間に直線を引く時と同様に、
ax.plot で描けます。

実際にやってみたのが以下のコードです。
比較用に2次元にプロットしたものを横に並べました。


# spring_layout アルゴリズムで、3次元の座標を生成する
pos = nx.spring_layout(G, dim=3)
# 辞書型から配列型に変換
pos_ary = np.array([pos[n] for n in G])

# ここから可視化
fig = plt.figure(figsize=(20, 10), facecolor="w")
ax = fig.add_subplot(121, projection="3d")

# 各ノードの位置に点を打つ
ax.scatter(
    pos_ary[:, 0],
    pos_ary[:, 1],
    pos_ary[:, 2],
    s=200,
)

# ノードにラベルを表示する
for n in G.nodes:
    ax.text(*pos[n], n)

# エッジの表示
for e in G.edges:
    node0_pos = pos[e[0]]
    node1_pos = pos[e[1]]
    xx = [node0_pos[0], node1_pos[0]]
    yy = [node0_pos[1], node1_pos[1]]
    zz = [node0_pos[2], node1_pos[2]]
    ax.plot(xx, yy, zz, c="#aaaaaa")
    
# 比較用 : 通常の2次元軸へのプロット
ax = fig.add_subplot(122)
nx.draw_networkx(G, edge_color="#aaaaaa")

# 出来上がった図を表示
plt.show()

このコードで以下の図が出力されました。

3次元の方がサッカーボール として綺麗な形になっているのがみて取れると思います。
座標軸の数値はいらないのでこれを消すなどの工夫を加えたらもっと良いかもしれませんね。

blog記事には静止画で貼り付けましたが、
jupyter notebook で実行する時は、
%matplotlib notebook を実行しておくと、
3次元プロットはグリグリ動かして確認ができます。
結構便利なので機会があれば試してみてください。

追記(2022/11/16) : エッジの描写についての解説

3次元にプロットするところの、エッジ(線)の描写部分について質問いただきましたので、解説します。

該当コードはここですね。

for e in G.edges:
    print(e)
    node0_pos = pos[e[0]]
    node1_pos = pos[e[1]]
    xx = [node0_pos[0], node1_pos[0]]
    yy = [node0_pos[1], node1_pos[1]]
    zz = [node0_pos[2], node1_pos[2]]
    ax.plot(xx, yy, zz, c="#aaaaaa")

まず、最初のfor文ですが、これはグラフのエッジをループさせています。変数eエッジの中の一つが格納され、それがどのノードからどのノードへのエッジなのかの情報がただのタプルとして入ってます。1個目だけprintしてみます。一番最初に作ったエッジデータの1個目ですね。

for e in G.edges:
    print(type(e))
    print(e)
    break #  打ち切り
"""
<class 'tuple'>
(0, 1)
"""

エッジが(0, 1)ですから、まずノード0からノード1へ線をひこう、というのが以降の処理です。そのために、ノード0とノード1はどの座標に配置されているのかの情報が必要になります。

その座標が spring_layout ってアルゴリズムで推定して、ノード:座標の形でposって変数に辞書で入ってます。(上の方のコード参照)
中身を見ておきましょう。全ノード分含まれているのですが、最初の5件blogに載せます。

from pprint import pprint


pprint(pos)
"""
{0: array([-0.6114604 ,  0.62195763,  0.31867187]),
 1: array([-0.5150625 ,  0.74024425, -0.01842863]),
 2: array([-0.17471886,  0.96503904,  0.00644528]),
 3: array([-0.02757867,  0.98970947,  0.35055788]),
 4: array([-0.30497656,  0.77198962,  0.53307487]),
# 以下略
""" 

e = (0, 1) ですから、 e[0] = 0, e[1] = 1です。(最初のedgeはインデックスと中身が一致しててややこしく、すみません)
これを使って、エッジが繋いでる2頂点の座標を取得します。アルゴリズムが乱数使っているので具体的な値は実行するたびに変わりますのでご注意ください。

node0_pos = pos[e[0]]
node1_pos = pos[e[1]]

# 一つ目のノードの座標
print(node0_pos)
# [-0.6114604   0.62195763  0.31867187]

# 二つ目のノードの座標
print(node1_pos)
# [-0.5150625   0.74024425 -0.01842863]

具体的な座標が定まったので、この2点の間に線を引きます。これは、Axes3Dをimportした状態のmatplotlibのplotメソッドで実行します。

この時にax.plot(1点目の座標, 2点目の座標)と渡すと動かないのです。
2次元のplotにおいても ax.plot(x座標の一覧, y座標の一覧) とデータを渡すように、3次元plotでもax.plot(x座標の一覧, y座標の一覧, z座標の一覧)とデータを渡す必要があります。

xxとかyyとか変な変数名で恐縮ですが、それを続くコードでやってます。

# x座標, y座標, z座標をそれぞれ取り出し
xx = [node0_pos[0], node1_pos[0]]
yy = [node0_pos[1], node1_pos[1]]
zz = [node0_pos[2], node1_pos[2]]

# 中身確認
print(xx)
# [-0.6114604015979618, -0.5150625045997115]
print(yy)
# [0.6219576319265612, 0.740244253223188]
print(zz)
# [0.3186718713992598, -0.01842863446943393]

そして、出来上がったxx,yy,zz を ax.plot()に渡してエッジが1本引けたことになります。
c=”#aaaaaa” はただの色設定(灰色)なので問題ないと思います。

これで1つ引けるので、あとはfor文で各エッジを変数eに格納して順次繰り返しています。

HITSアルゴリズム

HITSアルゴリズム(Hyperlink-Induced Topic Search)という手法がありますので紹介します。
NetworkXのドキュメントにおいて、PageRankと一緒に Link Analysis のページにあることから存在を知りました。
参考: NetworkXのドキュメントの Link Analysis
論文: A Survey of Eigenvector Methods for Web Information Retrieval (Amy N. Langville, Carl D. Meyer)
Wikipedia: HITS algorithm

まずおさらいですが、PageRankの発想は、「重要なページからリンクされているページは重要」というものでした。
そして、各ページはそれぞれ一つのスコア(PageRank)を持ちます。
PageRankの特徴として、リンクが増えると、リンクされている側のページのスコアは上がりますが、
リンク元のページにはなんら恩恵がありません。

しかし実際、優良なページに多くのリンクを貼っているのであれば、そのページも便利なページではあります。いわゆるリンク集やナビサイトです。
この、優良なページにたどり着くことを容易にするページも評価しようとしているのが、HITSアルゴリズムです。
HITSアルゴリズムは、それぞれのページに Hub(ハブ値) と Authority(権威値) という二つのスコアを付与します。
Authority の方が、PageRankに近い概念で、Hubの方は、優良なページにリンクを飛ばすと高くなる値になります。

実際の計算式に近い形でまとめて説明すると、HITSアルゴリズムは次の二つの要素からなります。
– Hubが高いページからリンクされるほど、Authorityが高くなる。
– Authorityが高いページへリンクするほど、Hubが高くなる。

ここ最近の固有ベクトル中心性PageRankの記事を読んでいただけていたら予想がつくと思うのですが、
これらの計算も行列の掛け算の繰り返しが収束することによって求まります。
そして、行列の固有ベクトルとして算出することもできます。

まず、$i$番目のノードから$j$番目のノードにリンクがある時に$(i, j)$成分が$1$に、そうでない時に$0$になる隣接行列を$L$とおきます。
数式で書くと以下の通りです。
$$
\mathbf{L} = \{a_{i, j}\} =
\left\{ \begin{align}1 (iからjにリンク有り)\\0 (iからjにリンク無し)\end{align}\right.
$$
(文献によって、定義が転置していたり、値が正規化されていたりするのでご注意ください。)

そして、それぞれのノードのハブ値のベクトル$\mathbf{h}^{(k)}$と、権威値のベクトル$\mathbf{a}^{(k)}$を次のように初期化します。
$\mathbf{1}$は全ての要素が1のベクトル、$N$はノード数です。
$$
\mathbf{h}^{(0)} = \mathbf{a}^{(0)} = \mathbf{1}/N.
$$

定義に沿って、ハブ値と、権威値を再帰的に計算するには、次の操作を繰り返すことになります。($\cdot$は行列積です)
$$
\begin{align}
&\mathbf{a}^{(k)} = \mathbf{h}^{(k-1)} \cdot L,\\
&\mathbf{h}^{(k)} = \mathbf{a}^{(k-1)} \cdot L^{\top},\\
&\mathbf{a}^{(k)}と、\mathbf{h}^{(k)}を正規化する.
\end{align}
$$

さて、numpyでこの通り実装し、ループを回して計算すると$\mathbf{h}^{(k)}$も$\mathbf{a}^{(k)}$も一定値に収束することは容易に観測されます。
一応、ランダムに生成したグラフに対して、上の数式の通りに計算して確認しておきましょう。


import numpy as np
import networkx as nx

# ランダムに有向グラフ生成
G = nx.random_graphs.fast_gnp_random_graph(n=10, p=0.2, directed=True)

# 隣接行列取得
L = nx.to_numpy_array(G)
print(L)
"""
[[0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 1. 1. 0. 1. 0. 0. 0. 0.]
 [0. 0. 0. 1. 0. 0. 0. 1. 1. 0.]
 [0. 0. 0. 0. 1. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0. 1. 0. 0.]
 [1. 0. 1. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 1. 1. 0. 1. 0. 0.]
 [1. 0. 0. 0. 0. 1. 0. 0. 1. 0.]
 [0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]
 [0. 0. 0. 0. 1. 0. 1. 0. 0. 0.]]
"""
# ハブ値と権威値を初期化
hubs = np.ones(shape=nx.number_of_nodes(G))/nx.number_of_nodes(G)
authorities = np.ones(shape=nx.number_of_nodes(G))/nx.number_of_nodes(G)

# 再帰的に計算する。
for _ in range(40):
    # ハブ値から権威値を計算する。
    authorities = hubs @ L
    # 権威値からハブ値を計算する。
    hubs = authorities @ L.T

    # 標準化
    hubs /= sum(hubs)
    authorities /= sum(authorities)
    print("authorities: ", authorities.round(3))
    print("hubs:", hubs.round(3))
"""
authorities:  [0.105 0.    0.105 0.105 0.158 0.158 0.053 0.158 0.105 0.053]
hubs: [0.    0.156 0.156 0.067 0.067 0.089 0.2   0.156 0.022 0.089]
authorities:  [0.097 0.    0.097 0.124 0.142 0.204 0.035 0.168 0.124 0.009]
hubs: [0.    0.172 0.168 0.057 0.068 0.079 0.208 0.172 0.004 0.072]
authorities:  [0.097 0.    0.097 0.131 0.13  0.213 0.028 0.172 0.131 0.001]
hubs: [0.    0.178 0.175 0.052 0.069 0.078 0.207 0.178 0.001 0.063]

-- (中略) --

authorities:  [0.1   0.    0.1   0.138 0.116 0.216 0.021 0.172 0.138 0.   ]
hubs: [0.    0.183 0.18  0.047 0.069 0.081 0.203 0.183 0.    0.055]
authorities:  [0.1   0.    0.1   0.138 0.116 0.216 0.021 0.172 0.138 0.   ]
hubs: [0.    0.183 0.18  0.047 0.069 0.081 0.203 0.183 0.    0.055]
"""

この、最後の方で収束している値が、この記事のテーマのハブ値と権威値です。
Networkxで計算することもできるので見ておきましょう。
PageRank同様に複数の実装がありますが、 numpyで実装された、hits_numpyを使えば十分でしょう。


h, a = nx.hits_numpy(G)
print(h)
"""
{0: 0.0,
 1: 0.1828404557137138,
 2: 0.18031994425802442,
 3: 0.04654212497804568,
 4: 0.06918950852466288,
 5: 0.08062191959815146,
 6: 0.20269591155066588,
 7: 0.1828404557137138,
 8: 0.0,
 9: 0.054949679663021944}
"""
print(a)
"""
{0: 0.1000629943543116,
 1: -0.0,
 2: 0.10006299435431144,
 3: 0.13792829814529123,
 4: 0.11553047637984128,
 5: 0.21586948330461359,
 6: 0.020869885042915804,
 7: 0.17174757027342366,
 8: 0.1379282981452913,
 9: -0.0}
"""

さて、例によって、再帰的に計算して、収束を待つというのは非常にコストの高い計算なので、もう少し効率化を考えます。
先ほどの計算式をよく見ると、$\mathbf{h}^{(k)}$と$\mathbf{a}^{(k)}$は相互に計算するのではなく、
自分自身の前の状態から計算できることがわかります。少し変形するとこうですね。

$$
\begin{align}
&\mathbf{a}^{(k)} = \mathbf{a}^{(k-2)} \cdot L^{\top} \cdot L,\\
&\mathbf{h}^{(k)} = \mathbf{h}^{(k-2)} \cdot L \cdot L^{\top},\\
&\mathbf{a}^{(k)}と、\mathbf{h}^{(k)}を正規化する.
\end{align}
$$

$k=0$から計算が始まるので、この式からは偶数番目の項しか出てきませんが、収束先は同じですので無視してしまって、次の漸化式の収束先を考えましょう。
$$
\begin{align}
&\mathbf{a}^{(k)} = \mathbf{a}^{(k-1)} \cdot L^{\top} \cdot L,\\
&\mathbf{h}^{(k)} = \mathbf{h}^{(k-1)} \cdot L \cdot L^{\top},\\
&\mathbf{a}^{(k)}と、\mathbf{h}^{(k)}を正規化する.
\end{align}
$$

すると、固有ベクトル中心性やPageRank時と同じように、$\mathbf{h}^{(k)}$も$\mathbf{a}^{(k)}$も、その収束先を、
行列の最大固有値に対する固有ベクトルとして算出できます。
具体的には、
ハブ値$\mathbf{h}^{(k)}$のほうは、 $(L \cdot L^{\top})^{\top} = L \cdot L^{\top}$ の固有ベクトル、
権威値$\mathbf{a}^{(k)}$のほうは、 $(L^{\top} \cdot L)^{\top} = L^{\top} \cdot L$ の固有ベクトル、
として、求まります。


hub_eig = np.linalg.eig(L@L.T)[1][:, 0]
hub_eig /= sum(hub_eig)
print(hub_eig.round(3))
# [0.    0.183 0.18  0.047 0.069 0.081 0.203 0.183 0.    0.055]

authority_eig = np.linalg.eig(L.T@L)[1][:, 0]
authority_eig /= sum(authority_eig)
print(authority_eig.round(3))
# [ 0.1   -0.     0.1    0.138  0.116  0.216  0.021  0.172  0.138 -0.   ]

バッチリ算出できました。固有ベクトル本当に便利ですね。

最後に、ノードのサイズとして両指標を可視化しておきましょう。
比較用にPageRankと媒介中心性も入れました。


import matplotlib.pyplot as plt

# ハブ値と権威値算出
h_dict, a_dict = nx.hits_numpy(G)
h_list = np.array([h_dict[n] for n in G.nodes])
a_list = np.array([a_dict[n] for n in G.nodes])

# PageRankと媒介中心性も算出
pr_dict = nx.pagerank_numpy(G)
pr_list = np.array([pr_dict[n] for n in G.nodes])
bc_dict = nx.betweenness_centrality(G)
bc_list = np.array([bc_dict[n] for n in G.nodes])

# 可視化
fig = plt.figure(figsize=(12, 12), facecolor="w")
ax = fig.add_subplot(221, title="HITS: hubs")
nx.draw_networkx(G, pos=pos, node_size=3000*h_list, ax=ax, node_color="c")
ax = fig.add_subplot(222, title="HITS: authorities")
nx.draw_networkx(G, pos=pos, node_size=3000*a_list, ax=ax, node_color="c")
ax = fig.add_subplot(223, title="PageRank")
nx.draw_networkx(G, pos=pos, node_size=3000*pr_list, ax=ax, node_color="c")
ax = fig.add_subplot(224, title="betweenness centrality")
nx.draw_networkx(G, pos=pos, node_size=3000*bc_list, ax=ax, node_color="c")

PageRankではほとんど評価されていない1番のノードが、Hubとしてきちんと評価されていること、
それによって1番からリンクを得ている、2,3,5が、PageRankに比べて、権威値が高くなっていることなど確認できますね。

実験する前は PageRank と 権威値 はもっと似てる結果になると思っていたのですが、
(確かに似てはいるけど) 予想よりも指標の特徴がいろいろ出ていますね。

ページランク(PageRank)

今回は、ページランク(PageRank)という指標について紹介します。
これは、Webページの重要度を決定するために作られた指標で、Googleの創業者のラリー・ペイジとセルゲイ・ブリンによって発明されました。
参考: Brin, S. and Page, L. (1998) The Anatomy of a Large-Scale Hypertextual Web Search Engine.

基本的に、次の条件を満たすページが重要である、という考えを元にしています。
1. 多くのページからリンクされている。
2. 重要なページからリンクされている。
3. リンク先を厳選したページからリンクされている。

これの、1.と2.が、前の記事で紹介した、固有ベクトル中心性に似ています。
そして、PageRankのアルゴリズム自体は固有ベクトル中心性を改善して作られています。
その際に、3.を実装しただけでなく、元の固有ベクトル中心性を有向グラフに適用した時に発生する
入力エッジがないノードに対する対応や、出力エッジがない行き止まりのノードに対する対応などが改善されています。

具体的には、グラフの隣接行列を次のように改良した Google行列を使って、固有ベクトル中心性と同じ操作を行います。

以下の説明ですが、紹介しているサイトによって、行と列が逆なので実験の際はご注意ください。
このページでは networkxで得られる隣接行列を使うので、
ノードiからノードjにエッジが伸びている時に(i, j)成分が1になる隣接行列を使います。

(1) 隣接行列の各行の値を合計値で割り、各行の値の合計を1にする。
(2) どこにも遷移先がないノードからは、任意のノードに等確率で遷移するものとする。
つまり、隣接行列の行ベクトルのうち、全ての値が0の行は、$1/(ノード数)$で埋める。
(3) ある一定確率$1-\alpha$、(通常は15%)で、隣接行列に関わらず、グラフ中のどれかのノードにランダムに移動する。
つまり、元の隣接行列に(1)(2)を施したものを$\mathbf{A}$、全ての成分が$1$の同サイズの行列を$\mathbf{I}$、
グラフのノード数を$N$とすると、$\alpha \mathbf{A} + (1-\alpha)\mathbf{I}/N$ を考える。
これをGoogle行列と言うそうです。
Wikipedia – Google matrix
(行と列が逆なので注意。)

(1)により、 3. のリンク先を厳選したページからのリンクが重要視される性質が実装されます。
(どちらかというと、リンクを乱発しているページからのリンクが軽視される、と考えた方がわかりやすいです。)
そして、(2)により行き止まりの問題が解消され、(3)によって、グラフが連結でなくても相互に行き来できるようになります。
また、(2)、(3)のそれぞれにより、入力エッジがないノードも無視されずに済みます。

さて、固有ベクトル中心性を再帰的に計算した時、値がどんどん大きくなってしまうので毎回l2ノルムで正規化していたのを覚えているでしょうか。
非常に都合のいいことに、Google行列で同じ操作をしても、値が大きくなり続ける現象は発生せず、
値の合計値がそのまま一定に保たれます。
そのため、この後のサンプルコードでは、ポイントの合計値を$1/(ノード数)$で開始します。

さて、それでは実装です。
まずはランダムにグラフを生成し、隣接行列からGoogle行列の算出まで行いました。


import networkx as nx
import numpy as np

# ランダムにグラフを生成する
G = nx.random_graphs.fast_gnp_random_graph(
    n=7,
    p=0.3,
    directed=True
)

# 隣接行列 (i番目からj番目のノードにパスがある時、(i, j)成分が1)
adj_matrix = nx.to_numpy_array(G)
print(adj_matrix)
"""
[[0. 1. 0. 0. 1. 0. 1.]
 [0. 0. 1. 1. 1. 0. 1.]
 [0. 1. 0. 0. 1. 0. 0.]
 [0. 0. 0. 0. 1. 1. 0.]
 [0. 1. 0. 1. 0. 0. 1.]
 [0. 0. 1. 0. 0. 0. 0.]
 [0. 0. 1. 0. 1. 1. 0.]]
"""

for i in range(adj_matrix.shape[0]):
    if sum(adj_matrix[i]) != 0:
        # 行の値の合計値を1に正規化する。
        adj_matrix[i] /= sum(adj_matrix[i])
    elif sum(adj_matrix[i]) == 0:
        # 行の値が全て0の行は、1/Nで埋める。
        adj_matrix[i] = 1/nx.number_of_nodes(G)

print(adj_matrix.round(2))
"""
[[0.   0.33 0.   0.   0.33 0.   0.33]
 [0.   0.   0.25 0.25 0.25 0.   0.25]
 [0.   0.5  0.   0.   0.5  0.   0.  ]
 [0.   0.   0.   0.   0.5  0.5  0.  ]
 [0.   0.33 0.   0.33 0.   0.   0.33]
 [0.   0.   1.   0.   0.   0.   0.  ]
 [0.   0.   0.33 0.   0.33 0.33 0.  ]]
"""
# Google行列の計算
N = nx.number_of_nodes(G)
alpha = 0.85
g_matrix = alpha*adj_matrix+(1-alpha)*np.ones(shape=adj_matrix.shape)/N
print(g_matrix)
"""
[[0.021 0.305 0.021 0.021 0.305 0.021 0.305]
 [0.021 0.021 0.234 0.234 0.234 0.021 0.234]
 [0.021 0.446 0.021 0.021 0.446 0.021 0.021]
 [0.021 0.021 0.021 0.021 0.446 0.446 0.021]
 [0.021 0.305 0.021 0.305 0.021 0.021 0.305]
 [0.021 0.021 0.871 0.021 0.021 0.021 0.021]
 [0.021 0.021 0.305 0.021 0.305 0.305 0.021]]
"""

Google 行列が求まったので、固有ベクトル中心性の時と同様に、
定数のベクトルに繰り返しかけて見ます。


# ページランクを1/Nで初期化する
page_rank = np.ones(shape=N)/N
print(page_rank.round(3))

# Google行列を繰り返し掛ける
for _ in range(30):
    page_rank = page_rank@g_matrix
    print(page_rank.round(3))

"""
[0.143 0.143 0.143 0.143 0.143 0.143 0.143]
[0.021 0.163 0.214 0.092 0.254 0.123 0.133]
[0.021 0.19  0.198 0.128 0.23  0.098 0.134]
-- (中略) --
[0.021 0.177 0.192 0.126 0.238 0.113 0.132]
[0.021 0.177 0.192 0.126 0.238 0.113 0.132]
[0.021 0.177 0.192 0.126 0.238 0.113 0.132]
"""

一定の値に収束していますね。これこそが掲題のPageRankです。

もちろんですが、networkxを使って計算することもできます。普段はこれを使いましょう。
numpyを使った実装と、scipyを使った実装、どちらも使っていないものの、3種類用意されています。
ドキュメントはこちら


print(nx.pagerank_numpy(G))
"""
{0: 0.021428571428571422,
 1: 0.17666594642678057,
 2: 0.19229348384918474,
 3: 0.12641130083513927,
 4: 0.23802782043838958,
 5: 0.11269014761536654,
 6: 0.1324827294065679}
"""

また、最初のコード例の中で定義に沿ってGoogle行列を計算しましたが、グラフからGoogle行列を計算する関数も用意してくれています。
google_matrix

一応動かして結果が同じであることを見ておきましょう。


print(nx.google_matrix(G).round(3))
"""
[[0.021 0.305 0.021 0.021 0.305 0.021 0.305]
 [0.021 0.021 0.234 0.234 0.234 0.021 0.234]
 [0.021 0.446 0.021 0.021 0.446 0.021 0.021]
 [0.021 0.021 0.021 0.021 0.446 0.446 0.021]
 [0.021 0.305 0.021 0.305 0.021 0.021 0.305]
 [0.021 0.021 0.871 0.021 0.021 0.021 0.021]
 [0.021 0.021 0.305 0.021 0.305 0.305 0.021]]
"""

値は揃っていますね。 ただ、データ型が numpy.matrix なのでそこだけ注意しましょう。
(と言っても、numpy.arrayとの違いで困ることはほぼないのですが。)

最後に、このページランクですが、固有ベクトル中心性と同じように、
Google行列の固有ベクトルとしても計算できます。
固有ベクトルを求める前に、この記事の定義だと転置する必要があることと、
numpyの固有ベクトルはl2ノルムが1に正則化されているので、
l1ノルムが1になるようにスカラー倍してやる必要があることに注意が必要です。
実際にやってみます。


eigen_vector = np.abs(np.linalg.eig(g_matrix.T)[1][:, 0])
eigen_vector /= sum(eigen_vector)
print(eigen_vector.round(3))
# [0.021 0.177 0.192 0.126 0.238 0.113 0.132]

ご覧の通り、再帰的に求めたPageRank,ライブラリで求めたPageRankと一致しました。

もともと、Webページの重要度を測るために考案されたものですが、
世の中には有向グラフの構造を持つデータは意外に多く、
その各ノードの重要度を測りたい場面というのは頻繁にあるので、活用の場面はたくさんあります。

固有ベクトル中心性

前回の記事に引き続き、グラフのノードの中心性の話です。
参考: ネットワークグラフの中心性
前回紹介した3種類の中心性はノード間のつながり方によってのみ算出されましたが、
それぞれのノードは対等に扱っていました。

例えば、次数中心性では、何個のノードとつながっているかのみを気にしており、つながっているノードの性質は気にしていません。
しかし、ノードの重要度を図る上で、同じ数のノードに繋がっているとしても、
より重要なノードと繋がっている方が需要と考えることは自然なことです。

この、重要なノードとつながっているものの方が重要であると言う概念を取り入れた中心性が、
固有ベクトル中心性(eigenvector centrality)です。

これを説明したいのですが、ちょっと複雑なので、
最初に次のようなゲームを考えてみましょう。

1. 初期の状態(t=0)において、各ノードはそれぞれ等しいポイントを持っている。
2. 次の状態(tが1増える)において、各ノードは、自分のポイントを繋がっている全てのノードに渡す。
(有向グラフの場合は、矢印の向きにのみ渡します。)
各ノードは、受け取ったポイントの合計を自分のポイントとする。
3. 2を繰り返す。

これなら、たくさんのノードとつながっていると、どんどんポイントをもらえます。
また、たくさんポイントを持っているノードとつながっていると有利です。

具体的にこれを計算してみるのですが、その前に隣接行列(adjacency matrix)という概念を導入しておきます。
これは、グラフGに対して定まる$n\times n$($n$はグラフのノード数)行列で、
$i$番目のノードから$j$番目のノードに向かって辺が伸びていたら、$(i, j)$成分が$1$、そうでなければ$0$になるものです。
(無向グラフの場合は、対称行列になります)
networkxには、隣接行列を取り出す関数がたくさん用意されています。(結果のデータ型が少しずつ違います。)
自分がよく使うのを二つだけ紹介します。この記事では、to_numpy_arrayの方を使います。

nx.to_numpy_array() # numpyのarray型で返す。グラフが小さい時はこれがオススメ.
nx.adjacency_matrix(G) # これは scipyの疎行列の型で返します。グラフが大きい時はこちら。


import networkx as nx
import numpy as np
while True:
    # ランダムにグラフを生成する
    G = nx.random_graphs.fast_gnp_random_graph(
        n=7,
        p=0.3,
        directed=False
    )
    # 連結なグラフが生成できたらループを抜ける
    if nx.is_connected(G):
        break


# 隣接行列 (i番目からj番目のノードにパスがある時、(i, j)成分が1)
adj_matrix = nx.to_numpy_array(G)
print(adj_matrix)

"""
[[0. 0. 0. 1. 1. 0. 0.]
 [0. 0. 1. 1. 0. 0. 0.]
 [0. 1. 0. 1. 0. 1. 0.]
 [1. 1. 1. 0. 0. 0. 1.]
 [1. 0. 0. 0. 0. 0. 0.]
 [0. 0. 1. 0. 0. 0. 0.]
 [0. 0. 0. 1. 0. 0. 0.]]
"""

さて、隣接行列を何に使うかというと、先ほど定義したゲームの手順2のポイントの受け渡しです。
これは、for文を回して愚直に計算すると結構面倒なのですが、なんと、ポイントのベクトルにこの連結行列をかけるだけで実装できてしまします。

全てのノードのポイントが1から初めて、$t=3$までやってみましょう。


import matplotlib.pyplot as plt

# 配置を固定しておく
pos = nx.spring_layout(G)

# 各ノードに等しい点を与える。
points_list = []

points_list = [np.ones(shape=G.number_of_nodes())]
for i in range(3):
    # ポイントベクトルに隣接行列を掛けたものが次の状態のポイントベクトル
    points_list.append(points_list[i]@adj_matrix)

# グラフを可視化
fig = plt.figure(figsize=(14, 14), facecolor="w")

for i in range(0, 4):
    ax = fig.add_subplot(2, 2, i+1, title=f"t={i}")
    nx.draw_networkx(
        G,
        ax=ax,
        node_color="c",
        pos=pos,
        node_size=700 * points_list[i] / np.mean(points_list[i]),
        labels=dict(zip(G.nodes, points_list[i])),
    )
plt.show()

出力がこちら。

このゲームのルールが、ポイントベクトルと隣接行列の積で再現できてることが確認できます。

さて、ご覧の通り、これを延々と続けていくと、各ノードのポイントがどこまでも大きくなってしまいます。
これを防ぐために、ポイントはl2ノルムで正規化することにしましょう。(要するにポイントベクトルの長さが1になるように縮めます。)

すると、面白いことに、この操作を繰り返すとポイントが一定の値に収束します。


# 各ノードに等しい点を与える。
points = np.ones(shape=G.number_of_nodes())
# l2正規化
points /= np.linalg.norm(points)

for _ in range(30):
    # 隣接行列をかける
    points = points@adj_matrix
    # l2正規化
    points /= np.linalg.norm(points)
    print(points.round(3))

"""
[0.333 0.333 0.5   0.667 0.167 0.167 0.167]
[0.34  0.476 0.476 0.544 0.136 0.204 0.272]
[0.276 0.413 0.496 0.634 0.138 0.193 0.221]
-- 中略 --
[0.288 0.444 0.502 0.596 0.116 0.203 0.241]
[0.288 0.444 0.502 0.596 0.116 0.203 0.241]
[0.288 0.444 0.502 0.596 0.116 0.203 0.241]
"""

この値こそが、記事タイトルにあげた、固有ベクトル中心性です。

何故このような名前になっているかというと、この値は隣接行列(の、転置行列の)の最大固有値に対する固有ベクトルとして算出できるからです。
無限ループさせて収束を観測するのは、大きなグラフでは大変なので、とてもありがたい性質ですね。
今回は無向グラフを使っているので、 .Tして、転置を取らなくても結果は同じですが、
有向グラフでは転置を取る必要があるのでサンプルコードにも入れておきました。

実際に、numpyで固有ベクトルを計算してみて見比べてみましょう。


eigen_vector = np.abs(np.linalg.eig(adj_matrix.T)[1][:, 0])
print(eigen_vector.round(3))
# [0.288 0.444 0.502 0.596 0.116 0.203 0.241]

確かに同じ値になっています。(符号が逆のベクトルが戻ってくることがあるので、np.absで絶対値取っています。)
(この記事の本題ではないですが、np.linalg.eig は使い方になかなかクセがありますね。
そのうち個別にまとめたいです。)

networkxにも、固有ベクトル中心性を求める関数は準備されています。しかも二つ。
eigenvector_centrality
eigenvector_centrality_numpy

それぞれ、numpyを使わない実装と使う実装のようです。
eigenvector_centrality_numpy の方がメリットが多いようなので、numpyも入っている環境ならこちらを使いましょう。

どちらも結果がdict型なので、使う時はその点だけ注意です。


print(nx.eigenvector_centrality_numpy(G))
"""
{
    0: 0.2878634949610299,
    1: 0.4438129053071405,
    2: 0.5022278145024875,
    3: 0.5959832042188999,
    4: 0.1163324095757721, 
    5: 0.20296207348193807,
    6: 0.24085083182520173
}
"""

この固有ベクトル中心性は、重要なものの近くが重要という再帰的な定義がきっちり計算できる点で数学的にも面白いです。
ただ、いくつか欠点があります。
今回の例は連結な無向グラフを扱いましたが、そうでない場合、つまり有向グラフや孤立したノードがある場合に問題が発生します。
例えば、どの点からも流入がないノードはすぐポイントが0になってしまい、
さらにそのような点からしかパスが通ってないところは連鎖的に0点になってしまいます。
行き止まりの存在も課題です。

次の記事ではこのあたりの問題への対応を紹介する予定です。

ネットワークグラフの中心性

前の記事でサンプルとして(折れ線グラフや棒グラフではなく、グラフ理論で言うところの)グラフを扱ったのでもう少し紹介します。
参考: Matplotlibの配色を別の処理でも流用したい

グラフについて分析するとき「どの点が重要なのか」という問いは非常に自然なものです。
そして、「中心にある点が重要なんじゃないか」と考えることもそこそこ自然な発想になります。
ただ、扱う対象が普通の幾何学的な図形ではなく、点とそれらの間にあるつながりという抽象的な概念で構成されたグラフの場合、
どの点が中心なのかというのは非自明な問題になります。
可視化してみれば確かにどれかの点が真ん中らへんにあるように見えるのですが、それは単に可視化の際にたままたそこにプロットされたというだけで、
グラフ理論で言うところの中心ではないからです。

この問題に対応するために、複数の中心が定義され、中心らしさを表す指標がいくつか提案されています。
networkxにも色々定義されているので、その中でも定義が単純でわかりやすいものを紹介します。
networkxのドキュメントではこちらに該当します。
Centrality

今回の記事では次のグラフをサンプルに使います。
何か特別な名前がついてるやつではなく、僕が今適当に構成したものです。


import matplotlib.pyplot as plt
import networkx as nx

G = nx.Graph()
G.add_edges_from([
    (0, 1),
    (1, 2),
    (2, 3),
    (3, 0),
    (1, 3),
    (0, 4),
    (4, 5),
    (5, 0),
    (5, 6),
])

# 可視化
fig = plt.figure(facecolor="w")
ax=fig.add_subplot(1, 1, 1, title="Sample Graph")
nx.draw_networkx(
    G,
    node_color="c"
)

さて、サンプルが用意できたので、次数中心性/近接中心性/媒介中心性の3種類の中心性の定義を紹介します。

次数中心性
一番単純でわかりやすいのが次数中心性(degree centrality)です。
これは、各ノードの次数(そのノードにつながっている、辺の本数)を指標とするものです。
正確には、各ノードの次数は最大でも「ノード数-1」までの値しか取れないので、「ノード数-1」で割って正規化したものを使います。
networkxではdegree_centralityで計算できます。


print("ノード番号, 次数中心性")
for k, v in nx.degree_centrality(G).items():
    print(k, ",", v)
"""
ノード番号, 次数中心性
0 , 0.6666666666666666
1 , 0.5
2 , 0.3333333333333333
3 , 0.5
4 , 0.3333333333333333
5 , 0.5
6 , 0.16666666666666666
"""

計算してみた各値を6(=7-1)倍すると、全部整数になり、各ノードから伸びてる辺の数に一致することがわかります。

近接中心性
距離がベースになっていて、円の中心などとイメージが近いのが近接中心性(closeness centrality)です。
これは、ある点から、そのほかの全ての点へのそれぞれの距離の平均値を元に決まります。
ただ、ほかの全ての点に近い(つまり距離が小さい)ほど、中心と見なしたいので、
距離の平均の逆数を取ります。
今回のグラフでは各辺に重み付けなどしていない(つまり全部重さ1とみなす)ので、ここで言う距離というのは最短経路を通った時に通過する辺の数です。
networkxではcloseness_centralityで計算できます。

※ 2020/12/24 コードに誤りがあることを教えていただき修正しました。


print("ノード番号, 近接中心性")
for k, v in nx.closeness_centrality(G).items():
    print(k, ",", v)
"""
ノード番号, 近接中心性
0 , 0.75
1 , 0.6
2 , 0.42857142857142855
3 , 0.6
4 , 0.5454545454545454
5 , 0.6
6 , 0.4
"""

試しに一つ具体的に計算しておきましょう。
0番のノードに着目します。
このノードの1~6番目までのノードの距離はそれぞれ、$1,2,1,1,1,2$ で、合計は$8$です。なので平均は$4/3$になります。
これの逆数をとったものが$3/4=0.75$なので、ライブラリの結果と一致しました。

媒介中心性
最後が媒介中心性(betweenness centrality)です。
個人的には、この記事で紹介した3種類の中では一番よく使います。
(主観的な感想です。媒介中心性がほかの二種に比べて理論的に優れているということを主張するものではありません。)
これは、着目している点以外の2点を結ぶ最短経路のうち、その点を通過するものの割合です。
networkxではbetweenness_centralityで計算できます。


print("ノード番号, 媒介中心性")
for k, v in nx.betweenness_centrality(G).items():
    print(k, v)
"""
ノード番号, 媒介中心性
0 0.6
1 0.13333333333333333
2 0.0
3 0.13333333333333333
4 0.0
5 0.3333333333333333
6 0.0
"""

さて、これは個別に解説したいと思います。
まず、2, 4, 6 の媒介中心性は0になっています。
これは、可視化した図を見るとわかるのですが、それぞれの点について、ほかの2点をどう選んでも、対象の点を通ると遠回りになるからです。

次にわかりやすいのは
5番です。まず、5番以外の6点から2点を選ぶ方法は全部で$6*5/2=15$通りあります。
その中で、5番を経由すると最短になるのは、6番の点と、そのほかの5点のどれかを結ぶ5通りです。
そのため、5版のノードの媒介中心性は$5/15=1/3$になります。
0番も同様に数えると、4,5,6のどれかと、1,2,3のどれかを結ぶ$3*3=9$通りの最短経路が0番を通過し、
媒介中心性は$9/15=3/5=0.6$になります。

さて、のこるは同じ値になっている1番と3番です。
まずは1番の方ですが、最短経路で1番のノードを通るのは2番のノードと、0,4,5,6のどれかを結ぶ4本になります。
となると、媒介中心性は$4/15=2.666…$になっても良さそうなのですが、実際にはこの$1/2$の値になっています。
実はこれは2番のノードと、0,4,5,6のどれかを結ぶ最短経路がそれぞれ複数あるのが原因です。
1番のノードの代わりに3番のノードを通っても良いわけです。
ということで、1番を通る最短経路を数える時に$0.5$倍して数えるので上の計算例の値になります。
3番も同様です。

辺の媒介中心性
さて、ノードの中心性を3つ紹介しましたが、最後の媒介中心性はノードだけではなく、辺についても定義できます。
ノードの場合は自分以外の2点でしたが、辺の場合はグラフ中の全てのノードから2点選び、
それらの最短経路のうち何本が自身を通過するかで定義されます。
networkxではedge_betweenness_centralityで計算できます。

計算結果が少数が複雑で、ぱっと見わかりにくかったので、
ノードの組みの場合の数、つまり$7*6/2=21$通りを掛けて、整数化したものを表示してみました。


print("辺, 辺の媒介中心性*21")
for k, v in nx.edge_betweenness_centrality(G).items():
    print(k, v*21)
"""
辺, 辺の媒介中心性*21
(0, 1) 6.0
(0, 3) 6.0
(0, 4) 4.0
(0, 5) 8.0
(1, 2) 3.0
(1, 3) 1.0
(2, 3) 3.0
(4, 5) 2.0
(5, 6) 6.0
"""

(1, 3) が 1 なのは、まさにその1と3をつなぐ時以外は通らないからですね。
一方で(5, 6)は、6とほかの点をつなぐ時は必ず通らないといけないので、中心性が高くなっています。

可視化した図では(5, 6)のエッジはかなり端っこにあるように見えるのですが、
媒介中心性は2位タイの高さということで、
可視化した時に真ん中にあるかどうかと、グラフ理論で言うところの中心は違うと言うことの雰囲気が伝わればと思います。

最後に、もう少し大きめのグラフで、それぞの中心性を算出して比較してみましょう。
計算した中心性はノードのサイズで示しました。


# ランダムにグラフデータ生成
while True:
    G=nx.random_graphs.fast_gnp_random_graph(25, 0.1)
    if nx.is_connected(G):
        # 連結なグラフができたら抜ける
        break


# 3種類の中心性をそれぞれ計算
dc_dict = nx.degree_centrality(G)
dc_list = np.array([dc_dict[n] for n in G.nodes])

cc_dict = nx.closeness_centrality(G)
cc_list = np.array([cc_dict[n] for n in G.nodes])

bc_dict = nx.betweenness_centrality(G)
bc_list = np.array([bc_dict[n] for n in G.nodes])

# 中心性をノードのサイズで表して可視化
pos = nx.spring_layout(G, iterations=300)
fig = plt.figure(facecolor="w", figsize=(8, 24))

ax=fig.add_subplot(3, 1, 1, title="Degree centrality ")
nx.draw_networkx(
    G,
    pos=pos,
    ax=ax,
    node_color="c",
    node_size=dc_list*300/np.mean(dc_list),
)
ax=fig.add_subplot(3, 1, 2, title="Closeness centrality")
nx.draw_networkx(
    G,
    pos=pos,
    ax=ax,
    node_color="c",
    node_size=cc_list*300/np.mean(cc_list),
)
ax=fig.add_subplot(3, 1, 3, title="Betweenness centrality")
nx.draw_networkx(
    G,
    pos=pos,
    ax=ax,
    node_color="c",
    node_size=bc_list*300/np.mean(bc_list),
)
plt.show()

出力された結果がこちら。

NetworkXの辺の書式を設定する

前回の記事がNetworkXの頂点の書式についての記事だったので今回は辺の書式です。

ドキュメントは同じ場所です。
ドキュメント: draw_networkx

有向グラフの場合は矢印の形などもう少し項目が多いのですが、
無向グラフの場合は、太さと色とスタイル(単線、破線、ドットなど)が設定項目になります。

このうち、太さ(width) と 色(edge_color) は単一の値だけではなく、
辺の数と同じ長さの配列で指定することができます。

配列で指定した値は、 G.edge で取得できる辺の一覧の順番と対応するように設定されます。
(初めてNetworkXをいじった時はこの辺りをしらず、思った設定ができずにいました。)

細かいですがドキュメントにデフォルトの色は’r’ (赤)と書いてありますが、これはおそらくドキュメントの誤りです。
(何も指定しないと黒になります。)

edge_color (color string, or array of floats (default=’r’))

styleに指定できるのは次の4種類です。(スタイルの指定は辺別ではなく、全体で一つ指定する必要があります。)
solid|dashed|dotted|dashdot

それでは適当なグラフで実行してみましょう。


import networkx as nx
import matplotlib.pyplot as plt

# グラフを生成
G = nx.Graph()
G.add_cycle(range(5))

# 辺の順番を確認
print(G.edges)
# [(0, 1), (0, 4), (1, 2), (2, 3), (3, 4)]

# 可視化
nx.draw_networkx(
    G,
    edge_color=["r", "g", "m", "c", "y"],
    width=[1, 2, 3, 4, 5],
    style= "dashdot",
)
plt.show()

出力がこちら。

0と1を結んでるのが一番細い赤い線で、次に0と4を結んでるが緑の線で、
と G.edges の値と対応して設定が反映されました。

NetworkXの頂点の書式を設定する

NetworkXのグラフを可視化するときに、もっと見た目を変えたいなと思うことは多々あると思います。
そのようなニーズに応えるために頂点と辺のそれぞれにいくつかのオプションが用意されています。
今回はその中から、頂点の書式に関するものをいくつか紹介します。

ドキュメント: draw_networkx

このページの node_xxx の形式で定義されているパラメーターがそれです。
この中から、node_size、node_color、node_shapeを使って、頂点の色や大きさを変えてプロットしてみましょう。
なお、node_shapeはグラフに対して1つですが、node_sizeとnode_colorは頂点ごとに設定を変えることができます。
ついでに font_family も設定して日本語のフォントを表示してみましょう。


# グラフの生成
G = nx.Graph()
for n in "あいうえお":
    G.add_node(n)

# 頂点の順番の確認。(node_size や node_color はこの順番で指定する)
print(G.nodes)
# ['あ', 'い', 'う', 'え', 'お']

nx.draw_networkx(
    G,
    node_shape="s",
    node_size=[100, 200, 300, 400, 500],
    node_color=["r", "g", "m", "c", "y"],
    font_family="IPAexGothic"
)
plt.show()

出力がこちら。

node_size はデフォルトが 300であることに注意しましよう。
node_shape には ‘so^>v<dph8’ のいずれかの文字が指定できます。

NetworkXのノード位置の指定に用意された関数を使う

前の記事の続きです。
参考:NetworkXのグラフを可視化するときに頂点の座標を指定する
こちらの記事では、頂点を円形に並べるとき、三角関数を使って座標を指定しました。
それは自分の理解のためにやったのですが、実用上はあらかじめ用意さているレイアウト用の関数を使った方が楽です。

ドキュメント:Graph Layout
たとえば、
circular_layout
を使えば、円上にノードを配置できます。

次のコードのように使います。
グラフの構成と可視化結果は前回の記事と同じなので省略します。


pos = nx.circular_layout(G)
nx.draw_networkx(G, pos=pos, node_color="c")
plt.show()