【Python】NetworkXで辺の太さを指定するとき、add_edgeとwidthの順番でハマった話

# 1. はじめに
下山輝昌・松田雄馬・三木孝行著『Python実践データ分析100本ノック』（第1版第10刷）をちょこちょこと進めていたところ、第6章ノック57をやっていて、NetworkXの文法でちょっとハマったので備忘録。

ハマりポイントは、NetworkXでグラフを描画するときに、**各辺の線の太さを指定するには、設定されている辺の順番通りに太さの値が入ったリストを渡す**のですが、この**辺の順番と、指定したい太さのリストの順番ってどうやったら一致するの**？というところでした。

本記事では、NetworkXにおいて、設定された辺の順番についての文法理解と、ついでに辺の太さの指定方法について自分なりのコードを考えたので、記録に残しておきます。
もちろん太さの指定方法や順番の決め方は上記テキストを進めている人はテキスト通りでもよいですし、今回は自分の感覚に合ったコードを書いてみたいなとも思ったので、文法の整理をしながらまとめてみます。

# 2. 前提
今回は、上記書籍がjupyter notebookを使って学習を進めていくことを前提としていたので、本記事でもこれに倣ってjupyter notebookでコードを書くことを想定してサンプルコードを示しています。
また私が使用した環境・バージョンは以下です。
* Python : 3.9.1
* Jupyter Notebook : 6.4.8
* NetworkX : 2.7.1
* matplotlib : 3.5.1
* pandas : 1.4.0

# 3. NetworkXさくっと基本文法
NetworkXは、グラフ（折れ線グラフとかではなく、頂点と辺からなるデータ構造のこと）やネットワークの描画や計算を行うためのPythonライブラリです。
例えば以下のようにすれば、3つのノード（頂点）とそれぞれを辺で結んだ三角形のようなグラフを描画することができます。
```python: python3 (jupyter notebook)
import networkx as nx
import matplotlib.pyplot as plt

# グラフオブジェクト作成
G = nx.Graph()

# 頂点を設定
G.add_node("nodeA")
G.add_node("nodeB")
G.add_node("nodeC")

# 頂点の座標を設定
pos = {}
pos["nodeA"] = (0, 0)
pos["nodeB"] = (1, 1)
pos["nodeC"] = (0, 1)

# 辺を設定
G.add_edge('nodeA', 'nodeB')
G.add_edge('nodeB', 'nodeC')
G.add_edge('nodeC', 'nodeA')

# グラフ作成・描画
nx.draw(G, pos)  # ← 引数でラベルの表示有無やノードの大きさ、辺の太さ等を指定できる（後述）
plt.show()
```
nodeAを座標(0, 0)、nodeBを座標(1, 1)、nodeCを座標(0, 1)に設定して各頂点どうしを結びました。
これを実行すれば、以下のようなグラフが出力されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/f73f109d-d9f8-777d-0ab3-9d182ada8b66.png)

ちなみに最後から2行目の`nx.draw(G, pos)`については、以下のようにすれば、ノードのラベル有無、フォントサイズ、ノードサイズ、ノードの色、文字の色、辺の太さ（**こいつが今回の主人公です**）、などを指定することができます。
```python: 
# 辺の太さを指定するリスト
edge_weights = [1, 1, 1]

# グラフ作成・描画
nx.draw(G, pos, with_labels=True, font_size=10, node_size=1000, node_color='k', font_color='w', width=edge_weights)
plt.show()
```
辺の太さを指定する引数`width`については、最初のコード内で実行した`add_edge`によって辺を3つ登録したので、**リストの形で3辺それぞれに「太さ1」という指定を与えて**います。
これによって以下のようなグラフが描画されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/5396cc8e-45c1-fd34-b98c-5d9b9daedec4.png)

こんな感じで、NetworkXではネットワークを自分でカスタマイズして描画することができます。
かっちょいい。

# 4. 辺の太さの指定でハマった点
さて、最初のコードで、辺の登録は以下の3行で行っていました。
```python: 再掲
# 辺を設定
G.add_edge('nodeA', 'nodeB')
G.add_edge('nodeB', 'nodeC')
G.add_edge('nodeC', 'nodeA')
```
例えば1行目は、直感的には「nodeAとnodeBを結んだ辺を登録します」という意味ですね。
これらに対して最後の`G.draw()`で辺の太さを指定するとき、引数`width`にリストを渡すということは、当然グラフオブジェクト`G`に登録されている辺の順番通りにリストを作成しないといけないわけです。

そこで僕は、「きっと辺の順番がどうなっているかは分かりやすく決まっているだろうから、add_edgeをした順番通りに辺が登録されているのだろう」と考えました。
つまり、例えば、**辺を登録した順番どおり、「nodeA―nodeB」が太さ1、「nodeB―nodeC」が太さ2、「nodeC―nodeA」が太さ3、としたければ`width=[1, 2, 3]`を渡せばいいだろう**、ということです。

ということで実際に以下の太さリストに変更してみます。（`edge_weights`のみ変更）
```python: 
# 辺の太さを指定するリスト（変更）
edge_weights = [1, 2, 3]

# グラフ作成・描画
nx.draw(G, pos, with_labels=True, font_size=10, node_size=1000, node_color='k', font_color='w', width=edge_weights )
```
さて、これで出力されたのは以下のグラフでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/1c6823b4-188a-19f6-1302-0d9a346fa9ae.png)

どうでしょう。
**「nodeA―nodeB」が太さ1なのは良いものの、「nodeB―nodeC」が太さ3、「nodeC―nodeA」が太さ2**となっています。
つまり僕の予想は間違っており、次のことが分かりました。

# 5. 辺は`add_edge()`した順番に登録されるわけではない
ということで、とりあえず今回はどんな順番で辺が設定されているかを調べるには、以下のコード１行を叩けばOKです。
```python
G.edges # またはG.edges()
# 出力： EdgeView([('nodeA', 'nodeB'), ('nodeA', 'nodeC'), ('nodeB', 'nodeC')])
```
この出力を見ると、以下の3つのことが分かります。
* 辺はタプルの組で登録されていること
* 登録された辺の順序は`add_edge()`を書いた順番からは入れ替わりうること
* タプル内の順序も、`add_edge()`で引数に渡した順番からは入れ替わりうること

つまり、辺の登録はあくまで集合であって、`add_edge()`した順番で辺が格納されるわけではないし、よく見ると各タプル内の順番も、`add_edge('nodeC', 'nodeA')`としたからといって`G.edges`の中で('nodeC', 'nodeA')となっているとは限らないのです。

# 6. 【本題】辺の太さ指定方法
## 6.1 `add_edge()`の時点で辺の太さを指定しておく方法

**例えば「nodeA―nodeB」が太さ1、「nodeB―nodeC」が太さ2、「nodeC―nodeA」が太さ3**としたい場合を考えます。

1つずつ書き下してしまえる程度であれば、`add_edge()`の際に引数`weight`で辺の太さ（重み）を渡しておき、最後に`G.edges`を再度読み込んで辺の太さを指定するリストを作成するのが簡単です。
```python
# (中略)

# 辺の登録時に、weightで太さ（重み）を登録しておく
G.add_edge('nodeA', 'nodeB', weight=1)
G.add_edge('nodeB', 'nodeC', weight=2)
G.add_edge('nodeC', 'nodeA', weight=3)

# (中略)

# 辺の太さを指定するリストを、G.edgesの順番に従って作成
edge_weights = [G[u][v]['weight'] for u,v in G.edges]

# グラフ作成・描画
nx.draw(G, pos, with_labels=True, font_size=10, node_size=1000, node_color='k', font_color='w', width=edge_weights )
plt.show()
```
出力結果は以下の通り、思い通りの描画ができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/c53c9988-8994-d425-2506-85a53fd2d522.png)

ここで、辺の太さを指定するリストの作り方についてちょっと解説です。
```python:補足：グラフオブジェクトの中身確認
for u, v in G.edges:
    print(u, v)
# 出力は以下
# nodeA nodeB
# nodeA nodeC
# nodeB nodeC

G['nodeA']
# 出力: AtlasView({'nodeB': {'weight': 1}, 'nodeC': {'weight': 3}})

G['nodeA']['nodeB']
# 出力: {'weight': 1}

G['nodeA']['nodeB']['weight']
# 出力: 1
```
上記のように、`G.edges`をu, vで受けてfor文で回すと、タプルの組がそれぞれuとvに格納されます。
またグラフオブジェクトはインデックスにノード名を指定すると、辞書のようにそのノードが他のどのノードとペアを組んでいるか（タプルの組を作っているか）、そしてそのweightまで得ることができます。
よって、グラフオブジェクトに対して各タプルの組をインデックスに指定してweightを順次求めれば、グラフオブジェクトに登録されている順番通りの太さリストを作成することができます。

## 6.2 外部データに合わせて太さリストを作成する場合
『Python実践データ分析100本ノック』第6章ノック57で少し行き詰った人のために、本書の状況に寄せた条件で、僕なりの太さリスト作成方法も考えてみます。
（本書の内容をそのまま書くわけにはいかないと思いますので、少し変更・簡略化して書かせていただきますが、これでもまだ著作権等で何か問題がありそうでしたらご連絡ください…！）

例えばnodeA、nodeB、nodeCの３点が、nodeDという点とそれぞれ1辺ずつで結ばれていて、その重み（線の太さ）は以下のような`param.csv`にデータとして記載されていたとしましょう。

<img width=350 alt=pram.png src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/4f8d5070-7053-70e2-8a78-d9066ead965d.png>

描きたいのは下のように、**「nodeA―nodeD」が太さ1、「nodeB―nodeD」が太さ2、「nodeC―nodeD」が太さ3**というグラフです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1337870/914edc0f-6099-cfff-9926-bb944e7ec2cd.png)

まずpandasを使って、csvから太さの情報を読み込みます。
```python:csv読み込み
import pandas as pd

# csv読み込み
param = pd.read_csv("param.csv", index_col="太さ")

# === 以下は出力の確認のみ ===
param.columns
# 出力：Index(['nodeA', 'nodeB', 'nodeC'], dtype='object')

param.index
# 出力：Index(['nodeD'], dtype='object', name='太さ')

param['nodeA']['nodeD']
# 出力：1

```

param.csvをこのようにpandasで読み込むと、`param.columns`とすれば列項目（横）のリスト（nodeA～nodeC）が、`param.index`とすれば行項目（縦）のリスト（nodeD）が得られます。
また`param['nodeA']['nodeD']`のように、列項目・行項目をインデックスに渡すと、それに対応するセルのデータ（辺の太さ）が得られます。
逆に`param['nodeD']['nodeA']`とするとエラーが出てしまうので、インデックスの順番を間違えてはいけません。

よって、`G.edges`に('nodeA', 'nodeD')という辺があれば、これに対して`param['nodeA']['nodeD']`を与えてやればよいことになります。
ただし、`G.edges`の中で('nodeA', 'nodeD')と('nodeD', 'nodeA')のどちらのタプルになっているかは分かりません（今回は単純な名称なので前者であることが容易に想像できますが、一般的にはどちらか分からない）。

従って、とりあえず`G.edges`のタプルを順番に1つずつu, vとして取り出してみて、uとvがそれぞれ`param.columns`と`param.index`のどちらに該当する名称か確かめてから`param[列項目名][行項目名]`と指定してやれば、`G.edges`の順番通りに辺の太さをリスト化することができます。

これをコードにすると以下のようになります。
```python:グラフ作成
import networkx as nx
import matplotlib.pyplot as plt

# グラフオブジェクト作成
G = nx.Graph()

# 頂点を設定
G.add_node("nodeA")
G.add_node("nodeB")
G.add_node("nodeC")
G.add_node("nodeD")

# 辺を設定
G.add_edge('nodeA', 'nodeD')
G.add_edge('nodeB', 'nodeD')
G.add_edge('nodeC', 'nodeD')

# 頂点の座標を設定
pos = {}
pos["nodeA"] = (0, 0)
pos["nodeB"] = (1, 1)
pos["nodeC"] = (0, 1)
pos["nodeD"] = (1, 0)

# 辺の太さを指定するリストを、G.edgesの順番に従ってcsvデータから格納する
edge_weights = []
for u, v in G.edges:
    if (u in param.columns) and (v in param.index):
        weight = param[u][v]
    elif(u in param.index) and (v in param.columns):
        weight = param[v][u]
    edge_weights.append(weight)

# グラフ作成・描画
nx.draw(G, pos, with_labels=True, font_size=10, node_size=1000, node_color='k', font_color='w', width=edge_weights)
plt.show()
```
本書では少し別のコードで処理していますが、自分の感覚ではこちらの考え方のほうがスッキリして分かりやすいかなぁと考えました。
もしかしたら本書では、私が考慮できていない状況まで考えられているかも知れませんので、もしそのようなことがあればご指摘いただけると勉強になります。

# 7. まとめ
NetworkXでは、`add_edge()`で登録した辺の順番、タプルの順番がソートされてグラフオブジェクトに登録されるので、`グラフオブジェクト.edges`で得られる順番のタプルに応じて、重み（辺の太さ）リストを作成するのがよさそう。

# 8. 参考
* [NetworkX 公式ドキュメント](https://networkx.org/documentation/stable/index.html#)
* [下山輝昌・松田雄馬・三木孝行著『Python実践データ分析100本ノック』](https://www.amazon.co.jp/Python%E5%AE%9F%E8%B7%B5%E3%83%87%E3%83%BC%E3%82%BF%E5%88%86%E6%9E%90100%E6%9C%AC%E3%83%8E%E3%83%83%E3%82%AF-%E4%B8%8B%E5%B1%B1-%E8%BC%9D%E6%98%8C/dp/4798058750/ref=sr_1_2_sspa)
Amazonのリンクを貼っておきます。自分が購入したのは第1版第10刷でした。
折り返し地点を越えたばかりですが、ここまでやってくるとデータ分析初心者にも分かりやすくありがたい本だなぁと分かってきます。
最後まで頑張るぞ！
これを一通り終えたらKaggleにでも手を出してみたいと思っています。

* [NetworkXの辺の書式を設定する](https://analytics-note.xyz/graph-theory/networkx-edge-setting/)