파이썬 networkx 그리기 - paisseon networkx geuligi

들어가며

 최근에 그래프를 그릴 일이 좀 있었다. Python 기반의 networkx와 matplotlib을 이용하여 그래프를 그리고 시각화를 하려고 했다. networkx는 그래프를 정의하고 시각화하는 다양한 API를 제공하고 있어 쉽게 활용할 수 있었다.

Networkx: https://networkx.org/documentation/stable/index.html

Software for Complex Networks — NetworkX 2.5 documentation

NetworkX is a Python package for the creation, manipulation, and study of the structure, dynamics, and functions of complex networks. With NetworkX you can load and store networks in standard and nonstandard data formats, generate many types of random and

networkx.org

파이썬 networkx 그리기 - paisseon networkx geuligi

다만, 한 가지 아쉬운 점이 있었는데 노드와 노드 사이를 잇는 엣지가 여러 개 일 경우에 의도대로 그려지지 않았다. 위의 그래프는 아래 코드의 실행 결과이다.

import networkx as nx
import matplotlib.pyplot as plt

MG = nx.MultiDiGraph()
edges = [
    ("A", "B", 0.5, "A to B 1"),
    ("A", "B", 0.75, "A to B 2"),
    ("A", "B", 3, "A to B 3"),
    ("A", "A", 1, "A to A"),
    ("B", "C", 0.5, "B to C"),
]

for node1, node2, weight, label in edges:
    MG.add_edge(node1, node2, weight=weight, label=label)

plt.subplot(111)
nx.draw(MG, with_labels=True)
plt.show()

원래 의도했던 그래프는 아래와 같은 모양이다.

파이썬 networkx 그리기 - paisseon networkx geuligi

그래서 직접 networkx, matplotlib을 이용하여 위와 같은 모양으로 그래프를 그려보려고 한다.

우선 node를 먼저 그려본다.

import networkx as nx
import matplotlib.pyplot as plt

MG = nx.MultiDiGraph()
edges = [
    ("A", "B", 0.5, "A to B 1"),
    ("A", "B", 0.75, "A to B 2"),
    ("A", "B", 3, "A to B 3"),
    ("B", "C", 0.5, "B to C"),
]

for node1, node2, weight, label in edges:
    MG.add_edge(node1, node2, weight=weight, label=label)

nx.draw_networkx_nodes(MG, nx.circular_layout(MG))
nx.draw_networkx_labels(MG, nx.circular_layout(MG))
plt.show()
파이썬 networkx 그리기 - paisseon networkx geuligi

다음으로는 각각의 노드 사이에 엣지를 그려주려고 한다. 아이디어는 두 노드를 잇는 직선과 수직인 직선을 구한 뒤 그 직선 위에 있는 점들중에서 일정 거리만큼 떨어진 점들을 찾아서 두 노드와 그 점을 이어줄 것이다. 우선 두 노드 사이의 점을 찾아본다.

import networkx as nx
import matplotlib.pyplot as plt

MG = nx.MultiDiGraph()
edges = [
    ("A", "B", 0.5, "A to B 1"),
    ("A", "B", 0.75, "A to B 2"),
    ("A", "B", 3, "A to B 3"),
    ("B", "C", 0.5, "B to C"),
]

for node1, node2, weight, label in edges:
    MG.add_edge(node1, node2, weight=weight, label=label)

layout = nx.circular_layout(MG)
nx.draw_networkx_nodes(MG, layout)
nx.draw_networkx_labels(MG, layout)

for node1, node2, index in MG.edges:
    x1, y1 = layout[node1]
    x2, y2 = layout[node2]

    mid_x = (x1 + x2) / 2
    mid_y = (y1 + y2) / 2

    plt.plot(mid_x, mid_y, 'o-')

plt.show()

circular_layout 함수는 각 node의 x, y 좌표를 반환하고 그 형태는 Dict[str, List[float, float]]로 이루어져 있다. 즉 layout[node]를 조회하면 node의 x, y좌표를 얻을 수 있다.

{'A': array([1.00000000e+00, 1.98682151e-08]), 'B': array([-0.50000007,  0.86602542]), 'C': array([-0.49999993, -0.86602544])}

MG.edges에는 그래프의 edge가 (node1, node2, node1에 등록된 index)의 형태로 저장되어있다.

[('A', 'B', 0), ('A', 'B', 1), ('A', 'B', 2), ('B', 'C', 0)]

위의 코드를 실행시키면 아래와 같이 각 노드 사이의 중점을 그릴 수 있다.

파이썬 networkx 그리기 - paisseon networkx geuligi

이제 처음 계획대로 두 노드를 지나는 직선에 수직이면서 일정 거리만큼 떨어진 점들을 찾아줄 것이다. 아래처럼 가, 나 점을 그리고 A와 가를 잇고 가와 B를 이어서 엣지를 표현할 것이다.

파이썬 networkx 그리기 - paisseon networkx geuligi

가, 나의 좌표는 아래와 같이 구할 수 있다.

파이썬 networkx 그리기 - paisseon networkx geuligi

아래는 이것을 코드로 표현한 것이다.

import math

import networkx as nx
import matplotlib.pyplot as plt

MG = nx.MultiDiGraph()
edges = [
    ("A", "B", 0.5, "A to B 1"),
    ("A", "B", 0.75, "A to B 2"),
    ("A", "B", 3, "A to B 3"),
    ("B", "C", 0.5, "B to C"),
]

for node1, node2, weight, label in edges:
    MG.add_edge(node1, node2, weight=weight, label=label)

layout = nx.circular_layout(MG)
nx.draw_networkx_nodes(MG, layout)
nx.draw_networkx_labels(MG, layout)

history = {}
for node1, node2, index in MG.edges:
    x1, y1 = layout[node1]
    x2, y2 = layout[node2]

    mid_x = (x1 + x2) / 2
    mid_y = (y1 + y2) / 2

    factor = history.get(tuple({node1, node2}), 0)
    theta = math.atan((y2 - y1) / (x2 - x1))

    mid_x -= factor * math.sin(theta)
    mid_y += factor * math.cos(theta)

    next_factor = -factor if factor > 0 else -factor + 0.2
    history[tuple({node1, node2})] = next_factor

    plt.plot(mid_x, mid_y, 'o-')

plt.show()
파이썬 networkx 그리기 - paisseon networkx geuligi

이제 각각의 노드와 그려놓은 점들을 이어서 엣지를 표현하고 점 위에 텍스트를 배치해서 엣지의 label을 나타내려고 한다.

위의 코드에서 plt.plot(mid_x, mid_y, 'o-') 부분을 아래처럼 바꿀것이다.

    plt.annotate(
        "",
        (x2, y2),
        xytext=(mid_x, mid_y),
        arrowprops=dict(
            arrowstyle="->",
            connectionstyle=f"arc3,rad={factor / 2}"
        )
    )
    plt.annotate(
        "",
        (mid_x, mid_y),
        xytext=(x1, y1),
        arrowprops=dict(
            arrowstyle="-",
            connectionstyle=f"arc3,rad={factor / 2}"
        )
    )
    plt.text(mid_x, mid_y,
             MG[node1][node2][index]['label']
             )
파이썬 networkx 그리기 - paisseon networkx geuligi

이제 label의 각도를 선의 기울기와 같게 바꿔주고 각 곡선의 시작점과 끝점을 좀더 부드럽게 보이도록 변경해주면 된다.

import math

import networkx as nx
import matplotlib.pyplot as plt

MG = nx.MultiDiGraph()
edges = [
    ("A", "B", 0.5, "A to B 1"),
    ("A", "B", 0.75, "A to B 2"),
    ("A", "B", 3, "A to B 3"),
    ("B", "C", 0.5, "B to C"),
]

for node1, node2, weight, label in edges:
    MG.add_edge(node1, node2, weight=weight, label=label)

layout = nx.circular_layout(MG)
nx.draw_networkx_nodes(MG, layout)
nx.draw_networkx_labels(MG, layout)

history = {}
for node1, node2, index in MG.edges:
    x1, y1 = layout[node1]
    x2, y2 = layout[node2]

    mid_x = (x1 + x2) / 2
    mid_y = (y1 + y2) / 2

    factor = history.get(tuple({node1, node2}), 0)
    theta = math.atan((y2 - y1) / (x2 - x1))

    mid_x -= factor * math.sin(theta)
    mid_y += factor * math.cos(theta)

    next_factor = -factor if factor > 0 else -factor + 0.2
    history[tuple({node1, node2})] = next_factor

    plt.annotate(
        "",
        (x2, y2),
        xytext=(mid_x, mid_y),
        arrowprops=dict(
            arrowstyle="->",
            connectionstyle=f"arc3,rad={factor / 2}",
            shrinkB=15
        )
    )
    plt.annotate(
        "",
        (mid_x, mid_y),
        xytext=(x1, y1),
        arrowprops=dict(
            arrowstyle="-",
            connectionstyle=f"arc3,rad={factor / 2}",
            shrinkA=15
        )
    )
    plt.text(mid_x, mid_y,
             MG[node1][node2][index]['label'],
             rotation=math.degrees(theta),
             rotation_mode='anchor',
             verticalalignment='center',
             horizontalalignment='center',
             backgroundcolor='white',
             )

plt.show()
파이썬 networkx 그리기 - paisseon networkx geuligi