Network Graphs#
import numpy as np
import pandas as pd
import holoviews as hv
import networkx as nx
from holoviews import opts
hv.extension('bokeh')
defaults = dict(width=400, height=400)
hv.opts.defaults(
opts.EdgePaths(**defaults), opts.Graph(**defaults), opts.Nodes(**defaults))
Visualizing and working with network graphs is a common problem in many different disciplines. HoloViews provides the ability to represent and visualize graphs very simply and easily with facilities for interactively exploring the nodes and edges of the graph, especially using the bokeh plotting interface.
The Graph
Element
differs from other elements in HoloViews in that it consists of multiple sub-elements. The data of the Graph
element itself are the abstract edges between the nodes. By default the element will automatically compute concrete x
and y
positions for the nodes and represent them using a Nodes
element, which is stored on the Graph. The abstract edges and concrete node positions are sufficient to render the Graph
by drawing straight-line edges between the nodes. In order to supply explicit edge paths we can also declare EdgePaths
, providing explicit coordinates for each edge to follow.
To summarize a Graph
consists of three different components:
The
Graph
itself holds the abstract edges stored as a table of node indices.The
Nodes
hold the concretex
andy
positions of each node along with a nodeindex
. TheNodes
may also define any number of value dimensions, which can be revealed when hovering over the nodes or to color the nodes by.The
EdgePaths
can optionally be supplied to declare explicit node paths.
A simple Graph#
Let’s start by declaring a very simple graph connecting one node to all others. If we simply supply the abstract connectivity of the Graph
, it will automatically compute a layout for the nodes using the layout_nodes
operation, which defaults to a circular layout:
# Declare abstract edges
N = 8
node_indices = np.arange(N, dtype=np.int32)
source = np.zeros(N, dtype=np.int32)
target = node_indices
simple_graph = hv.Graph(((source, target),))
simple_graph
Accessing the nodes and edges#
We can easily access the Nodes
and EdgePaths
on the Graph
element using the corresponding properties:
simple_graph.nodes + simple_graph.edgepaths
Displaying directed graphs#
When specifying the graph edges the source and target node are listed in order, if the graph is actually a directed graph this may used to indicate the directionality of the graph. By setting directed=True
as a plot option it is possible to indicate the directionality of each edge using an arrow:
simple_graph.relabel('Directed Graph').opts(directed=True, node_size=5, arrowhead_length=0.05)
The length of the arrows can be set as an fraction of the overall graph extent using the arrowhead_length
option.
Supplying explicit paths#
Next we will extend this example by supplying explicit edges:
def bezier(start, end, control, steps=np.linspace(0, 1, 100)):
return (1-steps)**2*start + 2*(1-steps)*steps*control+steps**2*end
x, y = simple_graph.nodes.array([0, 1]).T
paths = []
for node_index in node_indices:
ex, ey = x[node_index], y[node_index]
paths.append(np.column_stack([bezier(x[0], ex, 0), bezier(y[0], ey, 0)]))
bezier_graph = hv.Graph(((source, target), (x, y, node_indices), paths))
bezier_graph
Interactive features#
Hover and selection policies#
Thanks to Bokeh we can reveal more about the graph by hovering over the nodes and edges. The Graph
element provides an inspection_policy
and a selection_policy
, which define whether hovering and selection highlight edges associated with the selected node or nodes associated with the selected edge, these policies can be toggled by setting the policy to 'nodes'
(the default) and 'edges'
.
bezier_graph.relabel('Edge Inspection').opts(inspection_policy='edges')
In addition to changing the policy we can also change the colors used when hovering and selecting nodes:
bezier_graph.opts(
opts.Graph(inspection_policy='nodes', tools=['hover', 'box_select'],
edge_hover_line_color='green', node_hover_fill_color='red'))
Additional information#
We can also associate additional information with the nodes and edges of a graph. By constructing the Nodes
explicitly we can declare additional value dimensions, which are revealed when hovering and/or can be mapped to the color by setting the color
to the dimension name (‘Weight’). We can also associate additional information with each edge by supplying a value dimension to the Graph
itself, which we can map to various style options, e.g. by setting the edge_color
and edge_line_width
.
node_labels = ['Output']+['Input']*(N-1)
np.random.seed(7)
edge_labels = np.random.rand(8)
nodes = hv.Nodes((x, y, node_indices, node_labels), vdims='Type')
graph = hv.Graph(((source, target, edge_labels), nodes, paths), vdims='Weight')
(graph + graph.opts(inspection_policy='edges', clone=True)).opts(
opts.Graph(node_color='Type', edge_color='Weight', cmap='Set1',
edge_cmap='viridis', edge_line_width=hv.dim('Weight')*10))
If you want to supply additional node information without specifying explicit node positions you may pass in a Dataset
object consisting of various value dimensions.
node_info = hv.Dataset(node_labels, vdims='Label')
hv.Graph(((source, target), node_info)).opts(node_color='Label', cmap='Set1')
Working with NetworkX#
NetworkX is a very useful library when working with network graphs and the Graph Element provides ways of importing a NetworkX Graph directly. Here we will load the Karate Club graph and use the circular_layout
function provided by NetworkX to lay it out:
G = nx.karate_club_graph()
hv.Graph.from_networkx(G, nx.layout.circular_layout).opts(tools=['hover'])
It is also possible to pass arguments to the NetworkX layout function as keywords to hv.Graph.from_networkx
, e.g. we can override the k-value of the Fruchteran Reingold layout
hv.Graph.from_networkx(G, nx.layout.fruchterman_reingold_layout, k=1)
Finally if we want to layout a Graph after it has already been constructed, the layout_nodes
operation may be used, which also allows applying the weight
argument to graphs which have not been constructed with networkx:
from holoviews.element.graphs import layout_nodes
graph = hv.Graph([
('a', 'b', 3),
('a', 'c', 0.2),
('c', 'd', 0.1),
('c', 'e', 0.7),
('c', 'f', 5),
('a', 'd', 0.3)
], vdims='weight')
layout_nodes(graph, layout=nx.layout.fruchterman_reingold_layout, kwargs={'weight': 'weight'})
Adding labels#
If the Graph
we have constructed has additional metadata we can easily use those as labels, we simply get a handle on the nodes, cast them to hv.Labels and then overlay them:
graph = hv.Graph.from_networkx(G, nx.layout.fruchterman_reingold_layout)
labels = hv.Labels(graph.nodes, ['x', 'y'], 'club')
(graph * labels.opts(text_font_size='8pt', text_color='white', bgcolor='gray'))
Animating graphs#
Like all other elements Graph
can be updated in a HoloMap
or DynamicMap
. Here we animate how the Fruchterman-Reingold force-directed algorithm lays out the nodes in real time.
hv.HoloMap({i: hv.Graph.from_networkx(G, nx.spring_layout, iterations=i, seed=10) for i in range(5, 30, 5)},
kdims='Iterations')
Real world graphs#
As a final example let’s look at a slightly larger graph. We will load a dataset of a Facebook network consisting a number of friendship groups identified by their 'circle'
. We will load the edge and node data using pandas and then color each node by their friendship group using many of the things we learned above.
kwargs = dict(width=800, height=800, xaxis=None, yaxis=None)
opts.defaults(opts.Nodes(**kwargs), opts.Graph(**kwargs))
colors = ['#000000']+hv.Cycle('Category20').values
edges_df = pd.read_csv('../assets/fb_edges.csv')
fb_nodes = hv.Nodes(pd.read_csv('../assets/fb_nodes.csv')).sort()
fb_graph = hv.Graph((edges_df, fb_nodes), label='Facebook Circles')
fb_graph.opts(cmap=colors, node_size=10, edge_line_width=1,
node_line_color='gray', node_color='circle')
Bundling graphs#
The datashader library provides algorithms for bundling the edges of a graph and HoloViews provides convenient wrappers around the libraries. Note that these operations need scikit-image
which you can install using:
conda install scikit-image
or
pip install scikit-image
from holoviews.operation.datashader import datashade, bundle_graph
bundled = bundle_graph(fb_graph)
bundled
Datashading graphs#
For graphs with a large number of edges we can datashade the paths and display the nodes separately. This loses some of the interactive features but will let you visualize quite large graphs:
(datashade(bundled, normalization='linear', width=800, height=800) * bundled.nodes).opts(
opts.Nodes(color='circle', size=10, width=1000, cmap=colors, legend_position='right'))
WARNING:param.datashade: Parameter(s) [normalization] not consumed by any element rasterizer.
WARNING:param.datashade: Parameter(s) [normalization] not consumed by any element rasterizer.
Applying selections#
Alternatively we can select the nodes and edges by an attribute that resides on either. In this case we will select the nodes and edges for a particular circle and then overlay just the selected part of the graph on the datashaded plot. Note that selections on the Graph
itself will select all nodes that connect to one of the selected nodes. In this way a smaller subgraph can be highlighted and the larger graph can be datashaded.
datashade(bundle_graph(fb_graph), normalization='linear', width=800, height=800) *\
bundled.select(circle='circle15').opts(node_fill_color='white')
WARNING:param.datashade: Parameter(s) [normalization] not consumed by any element rasterizer.
WARNING:param.datashade: Parameter(s) [normalization] not consumed by any element rasterizer.
To select just nodes that are in ‘circle15’ set the selection_mode='nodes'
overriding the default of ‘edges’:
bundled.select(circle='circle15', selection_mode='nodes')