The New Best Python Package for Visualising Network Graphs

Author:Murphy  |  View: 26038  |  Time: 2025-03-22 23:57:32
Photo by Chris Ried on Unsplash

Introduction

In this article, I will introduce to you a Python package I stumbled upon that is, in my humble opinion, the BEST tool I have seen so far for visualising network graphs.

Readers who are data scientists in need of a compact yet powerful visualisation package for quick prototyping, exploratory data analysis or debugging their network models are best suited for the contents below.

The package that we will be inspecting is called: [gravis](https://robert-haas.github.io/gravis-docs/)

gravis – gravis 0.1.0 documentation

I personally use Graph Neural Networks a lot in my day-to-day job, and quite frankly, I am annoyed that I didn't know about this package earlier as it would have saved me a lot of time and energy trying to work around the shortcomings of the packages (ipysigma and pyvis) that I wrote about here:

The Two Best Tools for Plotting Interactive Network Graphs

What makes a network visualisation package the best?

A visualisation package needs to:

  • Create a fully interactive visualisation, where I can click on nodes and edges and view its attributes, plus drag and drop them.
  • Convenient to implement – doesn't require too much code (like Dash), but powerful and flexible enough for most use cases.
  • Moderately good scalability to the number of nodes and edges – we're not making something for prod, but we need it to handle hundreds of nodes at least.
  • Compatible with commonly used network packages in Python such as networkx.

What will we be testing the package on?

Broadly speaking, we can qualify graphs according to whether they are homogenous or heterogeneous, or have directed or undirected edges.

We will therefore test the package on two types of graphs that we will generate using networkx,

(1) A homogenous, undirected graph

(2) A heterogeneous, directed multigraph

as these are the two extremes of what one might encounter. If you are unfamiliar with the terminology, I suggest you visit my previous article and have a quick read of the introduction.

TL;DR

  • Homogenous = 1 type of node
  • Heterogeneous = multiple types of nodes and/or edges
  • Undirected = Edges have no direction
  • Directed = Edges have a direction
  • Multi-graph = Two nodes can have multiple edges between them

Set Up and Installation

The package can be simply installed via pip:

pip install gravis

We also need to install the below packages in order to generate our test graphs.

pip install numpy, matplotlib, networkx

You can also find all the code I use below in this repository.

GitHub – bl3e967/medium-articles: The accompanying code to my medium articles.

Homogenous Undirected Network Example

So, to start us off, we need a graph to plot. We will write a simple graph generator function which will return a networkx.Graph object. We will add attributes to its node and edges to simulate data a data scientist might see in their work.

Graph Generator

We use a random graph generator, for which I have selected the networkx.dual_barabasi_albert_graph method to simulate a scale-free network.

We add node level attributes such as degree, betweenness_centrality, and some made up features with random numbers which we imaginatively call feature1, feature2, feature3.

We do the same for the edges as well, and add features feature1 and feature2.

We finally label each node with a uuid (Universally Unique IDentifier) to make things look more like real data.

def get_new_test_graph():
    NUM_NODES = 50
    p = 0.5
    seed = 1
    test_graph = nx.dual_barabasi_albert_graph(n=NUM_NODES, p=p, seed=seed, m1=2, m2=1)

    # add node properties
    nx.set_node_attributes(test_graph, dict(test_graph.degree()), name='degree')
    nx.set_node_attributes(test_graph, nx.betweenness_centrality(test_graph), name='betweenness_centrality')

    for node, data in test_graph.nodes(data=True):
        data['node_identifier'] = str(uuid.uuid4())
        data['feature1'] = np.random.random()
        data['feature2'] = np.random.randint(0, high=100)
        data['feature3'] = 1 if np.random.random() > 0.5 else 0

    # add edge properties
    for _, _, data in test_graph.edges(data=True):
        data['feature1'] = np.random.random()
        data['feature2'] = np.random.randint(0, high=100)

    return test_graph

When we plot the graph using networkx we get the following:

test_graph = get_new_test_graph()
nx.draw(test_graph)
Test Graph of 50 nodes, homogenous and undirected.

Plotting with gravis

We now move on to plotting this graph using gravis. It is very simple to get going with this package and in full, it looks like this.

import gravis as gv 

gv.d3(
    test_graph, 

    # graph specs
    graph_height=500,

    # node specs
    node_size_data_source="betweenness_centrality",
    use_node_size_normalization=True,
    node_size_normalization_min=15,
    node_size_normalization_max=35,
    show_node_label=True,
    node_label_data_source='node_identifier',

    # edge specs
    edge_size_data_source='feature1',
    use_edge_size_normalization=True,
    edge_size_normalization_min=1,
    edge_size_normalization_max=5,

    # force-directed graph specs
    many_body_force_strength=-500
)

Let's break this down into chunks.

Let's assume we want to scale our node sizes according to their betweenness_centrality values and our edge thicknesses by their feature1 values.

We set use_node_size_normalization=True such that the node sizes are set according to normalised values of the betweenness_centrality, and we define node_size_normalization_min and node_size_normalisation_max to set the min and max node sizes we want.

    # node specs
    node_size_data_source="betweenness_centrality",
    use_node_size_normalization=True,
    node_size_normalization_min=15,
    node_size_normalization_max=35,

We use equivalent arguments for edges to control their thickness:

    # edge specs
    edge_size_data_source='feature1',
    use_edge_size_normalization=True,
    edge_size_normalization_min=1,
    edge_size_normalization_max=5,

Finally, I set the many_body_force_strength parameter to -500 to make the edges longer than it is by default, to make the graph clearer to see.

The resulting plot is shown below.

Our homogenous, undirected graph visualised using gravis

Adding Colour

In addition to scaling the node sizes by betweenness_centrality, I also want to colour them according to this as well.

We can achieve this simply by adding a color attribute to the nodes. I can use named colours such as ‘red', ‘blue', ‘green', and I won't bother trying to do that here as that is too trivial. Let's try and use a colour scale instead.

I want to scale the values according to the winter colourmap found in matplotlib

Low centrality values will be mapped to blue, high values will be mapped to green.

I have this very helpful MplColorHelper class that I have used many times in my projects, that will convert a numeric value into an RGB string.

class MplColorHelper:

    def __init__(self, cmap_name, start_val, stop_val):
        self.cmap_name = cmap_name
        self.cmap = plt.get_cmap(cmap_name)
        self.norm = mpl.colors.Normalize(vmin=start_val, vmax=stop_val)
        self.scalarMap = cm.ScalarMappable(norm=self.norm, cmap=self.cmap)

    def get_rgba(self, val):
        return self.scalarMap.to_rgba(val, bytes=True)

    def get_rgb_str(self, val):
        r, g, b, a = self.get_rgba(val)
        return f"rgb({r},{g},{b})"

All I need to specify is the min-max values we want to scale the colormap over.

So, to colour nodes according to betweenness_centrality we do the below:

# the matplotlib colourmap we want to use
CM_NAME = "winter"

# initialise colour helper
vals = nx.get_node_attributes(test_graph, 'betweenness_centrality').values()
betweenness_min, betweenness_max = min(vals), max(vals)
node_colors = MplColorHelper(CM_NAME, betweenness_min, betweenness_max)

# get rgb string for each node
for node, data in test_graph.nodes(data=True):
    data['color'] = node_colors.get_rgb_str(data['betweenness_centrality'])

and for edges, let's colour them according to feature1.

# initialise colour helper 
vals = nx.get_edge_attributes(test_graph, 'feature1').values()
val_min, val_max = min(vals), max(vals)
edge_colors = MplColorHelper(CM_NAME, val_min, val_max)

# get rgb string for each node
for u, v, data in test_graph.edges(data=True):
    data['color'] = edge_colors.get_rgb_str(data['feature1'])

And voila, let there be colour:

Homogenous undirected network with node and edge colours

Finally, gravis allows us to display any kind of free text stored on each node or edge using an information bar on the bottom of the visualisation.

We will use this to display our feature values – we format some text with the feature values, and save it to an attribute named click:

# node features
for node, data in test_graph.nodes(data=True):
    data['click'] = (
        f"Node: {data['node_identifier']}"
        "nNode Features:" +
        f"nfeature 1: {data['feature1']:.3f}" + 
        f"nfeature 2: {data['feature2']:.3f}" + 
        f"nfeature 3: {data['feature3']:.3f}" + 
        f"nBetweenness Centrality: {data['betweenness_centrality']:.3f}" + 
        f"nDegree: {data['degree']}"
    )

# edge features
for u, v, data in test_graph.edges(data=True):
  data['click'] = (
        f"Edge: {test_graph.nodes[u]['node_identifier']} -> {test_graph.nodes[v]['node_identifier']}" +
        f"nEdge Features:" + 
        f"nfeature 1: {data['feature1']}" + 
        f"nfeature 2: {data['feature2']}"
    )

And now we are able to display our feature values very nicely using a panel on the bottom of the screen (I've made the graphs extra large here so you can see it as you would on screen).

Visualisation with node feature details displayed in the bottom panel.
Visualisation with edge feature details displayed in the bottom panel

We have a fully interactive visualisation that allows us to drag nodes, and a side-bar to change the settings for the node and edge visualisation, labels, and the layout algorithm.

It also allows you to export the visualisation to an image using the settings bar – no extra code required! Finally, if you wished to share this interactive graph with anyone, you can export it as a self-contained HTML file:

fig = gv.d3(test_graph, *args, **kwargs)
fig.export_html("graph_to_export.html")

Heterogeneous Directed Network Example

We now try and visualise a heterogeneous directed network using gravis.

Graph Generator

Again, we need a function to return such a graph for us. We will use the same function as before, but now using the nx.scale_free_graph function.

We also add in a node_type attribute to simulate a heterogeneous graph. The first 25 nodes will be node_type = 0 and the remaining will be node_type = 1.

def get_new_test_digraph():
    NUM_NODES = 50
    # We change the graph generator function here
    test_graph = nx.scale_free_graph(n=NUM_NODES, seed=0, alpha=0.5, beta=0.2, gamma=0.3)

    # add node properties
    nx.set_node_attributes(test_graph, dict(test_graph.degree()), name='degree')
    nx.set_node_attributes(test_graph, nx.betweenness_centrality(test_graph), name='betweenness_centrality')

    for node, data in test_graph.nodes(data=True):

        # assign node type so we have heterogeneous graph
        data['node_type'] = 0 if node < 25 else 1

        # same as before, a add ther node features.
        data['node_identifier'] = str(uuid.uuid4())
        data['feature1'] = np.random.random()
        data['feature2'] = np.random.randint(0, high=100)
        data['feature3'] = 1 if np.random.random() > 0.5 else 0

    # add edge properties
    for u, v, data in test_graph.edges(data=True):
        data['feature1'] = np.random.random()
        data['feature2'] = np.random.randint(0, high=100)

    return test_graph

Plotting this using nx.draw gives us this:

Heterogeneous directed multigraph, numbered to display which nodes are of which type.

Plotting with gravis

How we plot for heterogeneous graphs is exactly the same as above. We can use the same methods to:

  • generate a test multi-digraph
  • add feature values for nodes and edges
  • add colour to nodes and edges according to an attribute value

All we need to do now is specify the shape of each node, as now we deal with multiple different node types.

For our graph generator function get_new_test_digraph, we just need to add this line of code in our for loop over nodes:

for node, data in test_graph.nodes(data=True):

    data['node_type'] = 0 if node < 25 else 1 # add this line

where for our purposes of simulating real-life data, we set the first 25 nodes in our graphs to be of node_type = 0, else node_type = 1.

Next, we can use the shape attribute in gravis to specify the node shapes we want to use for each node.

Here, we will set node_type = 0 to be circles, and rectangles for node_type = 1.

for node, data in test_graph.nodes(data=True):
    data['value'] = data['betweenness_centrality'] # node size
    data['label'] = data['node_identifier'] 
    data['title'] = (
        f"Node: {data['node_identifier']}"
        "nNode Features:" +
        f"nfeature 1: {data['feature1']}" + 
        f"nfeature 2: {data['feature2']}" + 
        f"nfeature 3: {data['feature3']}" + 
        f"nBetweenness Centrality: {data['betweenness_centrality']}" + 
        f"nDegree: {data['degree']}"
    ) 
    data['color'] = node_colors.get_rgb_str(data['betweenness_centrality'])

    ## add this line to specify shape. 
    data['shape'] = 'dot' if data['node_type'] == 0 else 'rectangle'

We are now ready to plot our network in gravis. Notice we added the edge_curvature argument to be non-zero. This is necessary to prevent edges between two nodes from overlapping when there are many of them.


gv.d3(
    test_graph, 

    graph_height=700,

    node_size_data_source="betweenness_centrality",
    use_node_size_normalization=True,
    node_size_normalization_min=15,
    node_size_normalization_max=35,
    show_node_label=False,
    node_label_data_source='node_identifier',

    edge_size_data_source='feature1',
    use_edge_size_normalization=True,
    edge_size_normalization_min=1,
    edge_size_normalization_max=5,

    # Specify curvature to differentiate between the multiple
    # edges between two nodes.
    edge_curvature=-0.5,

    many_body_force_strength=-500
)
MultiDiGraph plotted on gravis with edge curvature and shapes set depending on the node type.

Voila! Simple and easy. You can see the multiple connections between some of the nodes on the left and the top of the graph.

Summary – Why I think Gravis is the best

I have already mentioned that I wrote about two other packages where I insisted they were the best – pyvis and ipysigma. I was convinced of this at the time as I had been using them myself for my day-to-day work.

However, they have their unique issues which, in hindsight, made them suboptimal and this was why I covered both of them in the same blog! Depending on the use case, one of them was better than the other and I recommended users on which one to use when.

This is why I was SHOCKED when I found gravis, as it seemed like someone had thought the exact same thing and developed this package to combine the pros of pyvis and ipysigma to make a perfect tool.

Firstly, gravis provides both a fully interactive UI as you can:

  • Not only click on nodes and edges to display information but also allows the user to drag and drop nodes, very useful for complicated networks where the physics engine may need manual tweaking (and lacking in ipysigma)
  • The node or edge information is displayed through a bar on the screen that neatly houses the information and also allows one to copy information from it (a small but huge functionality that was lacking in pyvis that gave me a lot of headaches at my work).
  • Provides a different side-bar through which you can tune the physics engine parameters, change the settings for node and edge visualisation, and buttons through which you can export the graph as images.
  • Plus it can display multiple edges between nodes (again, something ipysigma was incapable of doing).

Therefore, gravis removes the need to pick and choose between pyvis and ipysigma as it has morphed the two packages together into one, whilst maintaining all of their advantages.

In short, it ticks all the boxes and does its job well.

I highly recommend this package.

Let me know what you think of it in the comments section, and if you like my article please give a clap or share with anyone who might be interested.

Tags: Data Science Data Visualization Graph Data Science Graph Neural Networks Python

Comment