Pythonでネットワークグラフを描くならNetworkx + Plotlyが便利!

python plotly networkx graph

今回は、Pythonでネットワークグラフを描いてみましたので、その方法を詳しく記事にしたいと思います。

仕事では、ほとんどPythonを使っているのですが、物と物との関係性を自動でサクッと可視化できたら便利だろうと思い、色々調べてみた結果、今回のnetworkx と plotlyという二つのライブラリを用いた方法に落ち着きました。

htmlで出力してブラウザで開けば、ノードのhover時やclick時の挙動をある程度自由に設定できるのがとても便利ですし、描写自体もかなりキレイにできます。

同じく、ネットワークグラフを描こうを思っている方の参考になれば幸いです。

ネットワークグラフとは?

今回描く対象とするのは、以下のような頂点(ノード)と辺(エッジ)で構成されるネットワークグラフです。

グラフ理論という学術分野もあるので、ただ単に「グラフ」と呼ぶのが正しいのかもしれませんが、グラフというと一般の人には色々なものが連想されるので、ここではネットワークグラフと呼ぶことにしました。

今回の記事の目的は、上の画像のようなグラフの可視化をPythonで実現することです。

環境設定

まず、Python3が使える環境で、networkxとplotlyをインストールしてください。

$ pip install networkx plotly

準備は基本的にこれだけです。この記事では、plotly v4.7.1, networkx v2.4を使っています。

可視化したネットワークグラフは、htmlファイルとして出力してブラウザで見ます。

Networkxによるグラフの定義

ネットワークグラフは、頂点(ノード)と辺(エッジ)によって構成され、エッジがどのノードとノードを繋ぐのかを定義することによって、ネットワークグラフの構造を決めていきます。

基本的には、NetworkXのチュートリアルを参考にすれば問題なく、ネットワークグラフの構造を定義することができます。

ここでは、後でplotlyを使ってネットワークグラフを可視化する上で、最低限必要な知識に絞って、説明していきます!

networkxを使えば、以下のような感じでグラフを簡単に定義できます。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)

G.add_edge(1, 2)

Gというグラフオブジェクトを定義して、そこにadd_node()add_edge()のメソッドを使ってノードとエッジを足していきます。1, 2, 3という数値は単なるラベルなので、例えば、a, b, cと言った文字列でも構いません。

上の例をグラフとして描写してみると、以下のようなグラフになります。

ノードやエッジのリストは以下のようにして簡単に取得することができます。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)

G.add_edge(1, 2)

print(G.nodes())
# Output: [1, 2, 3]

print(G.edges())
# Output: [(1, 2)]

さらに、各ノードには、任意の属性(attribute)を持たせることができます。例えば、nameidというattributeを持たせようとすると、以下のようになります。attributeの値の取り出しも直感的で分かりやすいです。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)

G.add_edge(1, 2)

G.nodes[1]["name"] = "node-1"
G.nodes[1]["id"] = "1"

print(G.nodes[1])
# Output: {'name': 'node-1', 'id': '1'}

print(G.nodes[1]["name"])
# Output: node-1

とてもシンプルで、使いやすいのが分かると思います。

この例で見せた程度の物であれば、わざわざNetworkxを使わなくてもサクッと実装できそうですが、Networkxでは、ノードの次数、最短経路の計算や、完全グラフや二部グラフと言った特殊な制約を持ったグラフを定義することが一瞬できるので、本来はそういった処理と組み合わせるとかなりメリットがあると思います。

次に、このNetworkxを使って定義したネットワークグラフをPlotlyというライブラリを用いて、可視化していきます。

Plotlyによるネットワークグラフの可視化

ここからは、Network Graphs in PythonのPlotly公式ページを参考にして書いています。

ここでは、例として以下の3ノードのネットワークグラフをNetworkxで定義します。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)

G.add_edge(1, 2)
G.add_edge(2, 3)
G.add_edge(1, 3)

このネットワークグラフを、以下の図のように自動で瞬時に可視化することが目標です!

まず、注意してほしいのはPlotlyには直接ネットワークグラフを描写するメソッドは用意されていません。なので、以下のように、ネットワークグラフをノードとエッジの二つに分けて、後で重ね合わせるという方法を取ります!

Plotlyで散布図を描く

上のノードとエッジの図はどちらもPlotlyのscatter(散布図)を使って描くことができます。

Plotlyで最もシンプルに散布図を描くには以下のようにします。

import plotly.graph_objects as go

nodes = go.Scatter(
    x=[-1, 1, 0],
    y=[0, 0, 1],
    mode="markers",
    marker=dict(size=30, line=dict(width=2)),
)

fig = go.Figure(data=[nodes])
fig.write_html("/path/to/file.html", auto_open=False)

/path/to/file.htmlで指定したパスにhtmlファイルが出力されるので、それをブラウザなどで開きます。出力結果は以下のようになります。

ポイントは、x, yで座標指定でノードの位置を指定することと、mode="markers"で、ノード(マーカー)として描写することです。

mode="lines"に変えると以下のようにエッジになり、x, yで指定した位置を前から順番に線で結んだグラフになります。簡単ですね!

あとは、うまくnetworkxで定義したデータから、散布図を描けばよいです!

Networkxで定義したグラフから散布図を描く

上でも書いたように、networkxのノードは任意の属性値を持たせることができるので、ここにノードの位置情報を追加します。以下のようにposというattributeを追加しています。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)
G.add_edge(1, 2)
G.add_edge(2, 3)
G.add_edge(1, 3)

G.nodes[1]["pos"] = (-1, 0)
G.nodes[2]["pos"] = (1, 0)
G.nodes[3]["pos"] = (0, 1)

そして、以下のようにしてnode_x, node_yというリストを用意し、順番にposに格納したノード位置情報をappendしていきます。

(省略)

node_x = []
node_y = []
for n in G.nodes():
    x, y = G.nodes[n]["pos"]
    node_x.append(x)
    node_y.append(y)

nodes = go.Scatter(
    x=node_x,
    y=node_y,
    mode="markers",
    marker=dict(size=30, line=dict(width=2)),
)

fig = go.Figure(data=[nodes])
fig.write_html("/path/to/file.html", auto_open=False)

こうすると、上の3ノードの散布図と全く同じ図が描けるはずです。

エッジも基本的な考えは同じです。各エッジの両端のノードの位置情報を取得して、edge_x, edge_yのリストにappendしていきます。

edge_x = []
edge_y = []
for e in G.edges():
    x0, y0 = G.nodes[e[0]]["pos"]
    x1, y1 = G.nodes[e[1]]["pos"]
    edge_x.append(x0)
    edge_y.append(y0)
    edge_x.append(x1)
    edge_y.append(y1)
    edge_x.append(None) #※
    edge_y.append(None) #※

edges = go.Scatter(
    x=edge_x,
    y=edge_y,
    mode="lines",
    line=dict(width=2),
)

気を付けてほしいのは、edge_x, edge_yで定義した全座標間を線で繋ぐのではなく、エッジとして定義した部分のみを繋ぐ必要があることです。そのために、※の部分のようにNoneを間に挟み込む必要があります。

エッジとノードを組み合わせてネットワークグラフを描く

上で定義したnodes, edgesを以下のように合わせて描写すると見事ネットワークグラフの完成です。data=[edges, nodes]というように指定することで二つの系列を同時に描写できます。

(省略)

fig = go.Figure(data=[edges, nodes])
fig.write_html("/path/to/file.html", auto_open=False)

以下のようにlayoutオプションを指定すると、軸や凡例、背景を消すことができますので、よりネットワークグラフっぽくなります。

(省略)

fig = go.Figure(
    data=[edges, nodes],
    layout=go.Layout(
        showlegend=False,
        xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        plot_bgcolor="rgba(0, 0, 0, 0)",
        paper_bgcolor="rgba(0, 0, 0, 0)",
    ),
)
fig.write_html("/path/to/file.html", auto_open=False)

Networkxでノード位置を自動決定する

ここまで読んでくださった方は、Networkxって必要??と思うかもしれません。実際、ここまでの内容だとNetworkxは必ずしも必要じゃないです。

今回のネットワークグラフ作成においてNetworkxが便利なところは、ノード位置(layout)を自動決定するメソッドが用意されている部分です!

ノード数が数十、数百を超えてくると、手でいちいち位置を指定するのは不可能なので、何らかのアルゴリズムで自動で決めていく必要があります。

Networkxでlayoutを自動決定するアルゴリズムはGraph Layoutに記載されているようにいくつか種類がありますが、今回は実際によく使われるspring layoutというのを使ってみます。

使い方は以下のようにnetworkx.spring_layout()と書けばよいだけなのでとても簡単です。返り値は、各ノード毎に座標になっています。

import networkx as nx

G = nx.Graph()

G.add_node(1)
G.add_node(2)
G.add_node(3)
G.add_edge(1, 2)
G.add_edge(2, 3)
G.add_edge(1, 3)

pos = nx.spring_layout(G, k=0.3, seed=1)
print(pos)
# Output: {1: array([0.71411235, 0.74240758]), 2: array([-1.        ,  0.24723564]), 3: array([ 0.28588765, -0.98964323])}

for node in G.nodes():
    G.nodes[node]["pos"] = pos[node]

得られた座標を上と同じようにノードのattributeとして設定(G.nodes[node]["pos"] = pos[node])すれば、座標をわざわざ指定することなく、ネットワークグラフを描くことができます。

このspring layoutは、ネットワークグラフをキレイに描くために考案されたアルゴリズムで、このアルゴリズムを使えば自動で見た目がキレイなグラフを描けます。

spring layoutには、いくつかパラメータがあるのですが、今回はk=0.3, seed=1というように指定しました。

seedは疑似乱数の振られ方を決定するもので、何かの値を指定しておくと、同じノード、エッジのグラフ定義を用いた場合に同じlayoutを再現させることができて便利です。

の値はかなりネットワークグラフの見た目に影響します。100ノードのグラフで適当にエッジを足したものを用意して、kの値をいろいろ変えて見た目を比較してみました。

kの値は大きくするとどんどん円に近づいていきます。感覚的な話ではありますが、0.1~0.5の間くらいで好みに応じて設定するのが良いです。

ネットワークグラフの構造としては同じ場合でも、layoutアルゴリズムによって決定される位置が変わってくるために、見た目にはかなりばらつきが出てくるのが分かりますね!

Networkxには、他にもいくつかlayoutアルゴリズムが用意されているので、色々試してみると面白いと思います!

ノードhover時に情報を表示する

Plotlyは、作成したネットワークグラフの図をhtmlとして出力させることができるので、ブラウザで表示してノードにカーソルをhoverしたときに追加で情報を表示することも簡単にできます。

ノード数が多い場合、すべての情報をはじめから表示しているととても見づらい図になってしまうことがありますが、hover時のみ表示することで、すっきりしたキレイなネットワークグラフ図にすることができます。

ここで書くノードhorver時の情報表示ついては、Hover Text and Formatting in Python を参考にしています。

実は特にoptionなどを指定しなくとも、上の例でfig.write_html()と書いたように出力すれば、カーソルのhover時にノードの座標が表示されます。

上の3ノードグラフの例に対してspring layoutで位置決定したものをhtml出力させてみた結果がこちらです。ノードにカーソルを合わせるとノードの座標の情報が表示されます。(スマホの方はタッチ)

hover時に表示される情報をカスタマイズする

ここでは、ノードhover時に出力される情報を変えてみます。

hover時の表示を制御するには、hovertemplateというパラメタを指定します。hoverinfoという別のパラメタもありますが、deprecatedとなっているようですので、基本的にはhovertemplateを使います。

Prior to the addition of hovertemplate, hover text was controlled via the now-deprecated hoverinfo attribute.

https://plotly.com/python/hover-text-and-formatting/#controlling-hover-text-with-graphobjects-and-hoverinfo

hovertemplateは、以下のようにscatterオブジエンドを定義するときに指定します。hovertemplateにテンプレートとなる文字列を渡します。%{x}%{y}を文字列中に含めるとその部分がx, y軸の値が代入されて表示されます。

例えば、以下のようにhovertemplate付きでノードを定義して、同じようにネットワークグラフ図をhtmlで出力すると

(省略)

nodes = go.Scatter(
    x=node_x,
    y=node_y,
    mode="markers",
    marker=dict(size=20, line=dict(width=2)),
    hovertemplate="x: %{x}, y: %{y}<extra></extra>",
)

(省略)

hovertemplateで最後に書いた<extra></extra>というのは、hover時にノードのトレース情報(上の図でtrace 1と表示されているもの)を表示させないためのものです。

hovertemplateには、%{x}%{y}だけでなく、%{text}%{customdata}という追加で定義した情報も追加できます。

以下では、%{text}を使う例を挙げてみます。

hovertemplateでは、文字列中に%{text}を含んだものを指定します。textの内容は、x, yと同様にtextというパラメータに渡します。textに渡す値もlistになっている必要があります。

今回はtextとしてnetworkx上のノードIDを使ってみます。textというlistを準備して、node_x, node_yのlistを準備するときに同じようにノードIDをappendしていきます。

(省略)

node_x = []
node_y = []
text = []
for n in G.nodes():
    x, y = G.nodes[n]["pos"]
    node_x.append(x)
    node_y.append(y)
    text.append(n)  # Add node IDs

nodes = go.Scatter(
    x=node_x,
    y=node_y,
    mode="markers",
    marker=dict(size=20, line=dict(width=2)),
    text=text,  # pass the text
    hovertemplate="Node: %{text}<extra></extra>",
)

(省略)

こうすると、以下のようにhover時にはノードIDが表示されるようになります。簡単ですね!

同じ要領で、hover時に好きな情報を表示することが可能で、<br>と書けばそこで改行されたり、<b>text</b>で挟むと太文字にできたりもするので、かなり応用が利くと思います。

ノードクリック時の動作を定義する

ノードをクリックできるようにするには、図を作成するときのlayoutオプションでclickmodeを指定します。

clickmodeは、select, event, select+eventの3パターンから指定できます。selectならノードをクリックしたときのそのノードのみがハイライトされるようになり、eventでは、クリック時にJavaScriptで扱えるイベントを発行します。select+eventは、その両方の動作がクリック時に行われます。

fig = go.Figure(
    data=[edges, nodes],
    layout=go.Layout(
        showlegend=False,
        xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        plot_bgcolor="rgba(0,0,0,0)",
        paper_bgcolor="rgba(0,0,0,0)",
        clickmode="select+event",
    ),
)

ノードクリック時の動作を制御するためには、JavaScriptを使ってPlotlyが発行したイベントを処理する必要があります。JavaScriptの知識が必要ですが、簡単な処理であれば少ない知識で書くことができます。

ここでは、例として、ノードのattributeとして追加したdict形式の情報をplotlyのcustomdataとして図に渡して、そのcustomdataをクリック時にhtmlのtable形式で表示してみようと思います。

customdataは、以下のようにノードに付与したnode_infoというattributeから取得するように書いてみました。

import networkx as nx
import plotly.graph_objects as go

G = nx.Graph()

# Define nodes and edges
G.add_node(1)
G.add_node(2)
G.add_node(3)
G.add_edge(1, 2)
G.add_edge(2, 3)
G.add_edge(1, 3)

# Add node_info for customdata
G.nodes[1]["node_info"] = {
    "name": "node-1",
    "id": 1,
    "description": "This is the test node 1",
}
G.nodes[2]["node_info"] = {
    "name": "node-2",
    "id": 2,
    "description": "This is the test node 2",
}
G.nodes[3]["node_info"] = {
    "name": "node-3",
    "id": 3,
    "description": "This is the test node 3",
}

# Get node positions
pos = nx.spring_layout(G, k=0.3, seed=1)

# Set node positions
for node in G.nodes():
    G.nodes[node]["pos"] = pos[node]

# Create a node trace
node_x = []
node_y = []
node_info = []
for n in G.nodes():
    x, y = G.nodes[n]["pos"]
    node_x.append(x)
    node_y.append(y)
    node_info.append(G.nodes[n]["node_info"])
nodes = go.Scatter(
    x=node_x,
    y=node_y,
    mode="markers",
    marker=dict(size=20, line=dict(width=2)),
    customdata=node_info,
)

# Create a edge trace
edge_x = []
edge_y = []
for e in G.edges():
    x0, y0 = G.nodes[e[0]]["pos"]
    x1, y1 = G.nodes[e[1]]["pos"]
    edge_x.append(x0)
    edge_y.append(y0)
    edge_x.append(x1)
    edge_y.append(y1)
    edge_x.append(None) 
    edge_y.append(None) 
edges = go.Scatter(x=edge_x, y=edge_y, mode="lines", line=dict(width=2))

# Create a figure from nodes and edges
fig = go.Figure(
    data=[edges, nodes],
    layout=go.Layout(
        showlegend=False,
        xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        plot_bgcolor="rgba(0, 0, 0, 0)",
        paper_bgcolor="rgba(0, 0, 0, 0)",
        clickmode="select+event",
    ),
)

完成したものを先にお見せすると以下のようなイメージです。(タッチパネルだと操作が難しいので、マウスを推奨)

**Node Information**

ノードの情報をhover時のポップアップに表示してもその情報をクリックしたり、コピペしたりできないのがとても使いにくく感じたので、今回のようにノード情報をテーブルに表示したりすると結構便利でした!

このようにクリック時の動作を定義するには、HTMLファイルの出力部分を以下のように変えます。今までは、Figureのwrite_html()を使っていましたが、plotly.io.to_html()というメソッドを使います。こちらの方が、出力する内容をパラメータで細かく制御できます。

full_html=Falseとすることで、完全なHTMLファイルではなく、一つの<div>要素のみを含んだ文字列を返すようになります。

# Load JavaScript
with open("plotly_click.js") as f:
    plotly_click_js = f.read()

# Create <div> element
plot_div = plotly.io.to_html(
    fig,
    include_plotlyjs=True,
    post_script=plotly_click_js,
    full_html=False,
)

実際のクリック時の制御は、読み込ませているplotly_click.jsというファイル名のJavaScriptで行っており、これをpost_scriptの引数に渡します。

JavaScriptの中身は以下のように書きました。

// plotly_click.js

var plot = document.getElementById("{plot_id}");  // Note1
var info = document.getElementById("plotly-node-info"); // Note2
plot.on('plotly_selected', function (data) { // Note3
    {
        var points = data.points;
        while (info.firstChild) info.removeChild(info.firstChild);
        for (p in points) {
            info.appendChild(DictToTable(points[p].customdata));  // Note4
        }
    }
})

function DictToTable(data) {
    var table = document.createElement("table");

    for (key in data) {
        var tr = document.createElement('tr');
        var th = document.createElement('th');
        var td = document.createElement('td');
        th.textContent = key;
        td.textContent = data[key];
        tr.appendChild(th);
        tr.appendChild(td);
        table.appendChild(tr);
    }

    return table;
}

まず、Note1の部分で、出力した出力した<div>要素を取得するのですが、to_htmlのAPIリファレンスに記載されているように、{plot_id}と書くことで、自動で<div>要素のIDに置き換えられます。

Note2の部分で、Tableを表示するための<div>要素(今回は、plotly-node-infoというID)を取得しています。

Plotlyのノードクリック時や選択時のイベントについては、Event Handlers in JavaScriptに記載されていますが、今回は、plotly_selectedというイベントをトリガーにしています。(Note3の部分)その名の通り、ノードを選択したときに発行されるイベントです。

イベントに関連するデータは、dataに渡ってきます。dataには、選択されたノードの情報が含まれており、そこからcustomdataのデータを取得できます。

DictToTable()という関数を用意して、dict形式のcustomdataからTableを生成して、<div>要素の子要素として追加しています。(Note4)

あとは少し強引ですが、以下のように完全なHTMLファイルとしてフォーマットします。

(省略)

# Load JavaScript
with open("plotly_click.js") as f:
    plotly_click_js = f.read()

# Create <div> element
plot_div = plotly.io.to_html(
    fig,
    include_plotlyjs=True,
    post_script=plotly_click_js,
    full_html=False,
)

# Build HTML
html_str = """
<html>
<head>
</head>
<body>
<div id="plotly-node-info">
<p>**Node Information**</p>
</div>
{plot_div}
</body>
</html>
""".format(
    plot_div=plot_div
)

# Write out HTML file
with open("/path/to/file", "w") as f:
    f.write(html_str)

これで、ノードをクリックするとそのノードのcustomdataが<div id="plotly-node-info"></div>の部分に表示されるはずです!

Python Plotlyはかなり便利です

以上、かなり長くなってしまいましたが、PythonでNetworkx + Plotlyを使ってネットワークグラフの可視化をやってみました!

特にPlotlyはHTMLで出力して、インタラクティブなグラフを描くのがかなり優れていると思います。hover時やクリック時の挙動は結構柔軟に定義できるので、いくらでも応用がききます。

また、見た目的にもかなりキレイなグラフが描けるので個人的にはかなりおすすめです。

Pythonでネットワークグラフを描きたいと思っている方は、ぜひこの記事を参考にして、描いてみてください!

 

 

おすすめ記事

コメントを残す

メールアドレスが公開されることはありません。