The world’s leading publication for data science, AI, and ML professionals.

Graph Visualization: 7 Steps from Easy to Advanced

Making visualization with Python, NetworkX, and D3.JS

Davis's Southern Club graph, Image by author
Davis’s Southern Club graph, Image by author

Some data types, like social networks or knowledge graphs, can be "natively" represented in graph form. Visualization of this kind of data can be challenging, and there is no universal recipe for that. In this article, I will show several steps of graph visualization with an open-source NetworkX library.

Let’s get started!

Basic Example

If we want to use a graph in Python, NetworkX is probably the most popular choice. It’s an open-source Python package for network analysis that includes different algorithms and powerful functionality. As we know, every graph contains nodes (vertices) and edges; we can easily create a simple graph in NetworkX:

import networkx as nx

G = nx.Graph()
G.add_node("A")
G.add_node("B")
G.add_edge("A", "B")
...

However, creating a large graph this way can be exhausting, and in this article, I will use a "Davis’s Southern Club Women" graph included in the NetworkX library (3-clause BSD license). This data was collected by A. Davis et al. in the 1930s (A. Davis, 1941, Deep South, Chicago: University of Chicago Press). It represents the observed attendance at 14 social events by 18 Southern women. Let’s load the graph and draw it:

import networkx as nx
import matplotlib.pyplot as plt

G = nx.davis_southern_women_graph()

fig1 = plt.figure(figsize=(12, 8))
nx.draw(G, with_labels=True)
plt.show()

The result looks like this:

Davis's Southern Club Women graph, Image by author
Davis’s Southern Club Women graph, Image by author

It works, but this image can definitely be improved. Let’s see different ways to do it.

1. Layout

A graph itself, by definition, contains only nodes and relations between them; it does not have any coordinates. The same graph can be displayed in many different ways, and different layouts are available in NetworkX. There is no universal solution that fits all, and visual impressions can also be subjective. The best way is to try different options and find the image that fits better with the specific dataset.

Spiral LayoutThis layout can be generated by using the spiral_layout method:

pos = nx.spiral_layout(G)
print(pos)
#> {'Evelyn Jefferson': array([-0.51048124,  0.00953613]),
#   'Laura Mandeville': array([-0.59223481, -0.08317364]), ... }

nx.draw(G, pos=pos, with_labels=True)

As we can see from the print output, the layout itself is just a dictionary with coordinates. This layout can be specified as an optional parameter for the draw method. The result looks like this:

Spiral layout, Image by author
Spiral layout, Image by author

For this type of graph, it is not the best one; let’s try other ways.

Circular LayoutHere, the code logic is the same. First, we create a layout, then we use it in code:

pos = nx.circular_layout(G)
nx.draw(G, pos=pos, with_labels=True)

The result:

Circular layout, Image by author
Circular layout, Image by author

As in the previous example, a circular layout is not the best for this graph.

Kamada-Kawai LayoutThis method uses a Kamada-Kawai path-length cost function:

pos = nx.kamada_kawai_layout(G)
nx.draw(G, pos=pos, with_labels=True)

The result looks better:

Kamada-Kawai layout, Image by author
Kamada-Kawai layout, Image by author

Spring LayoutThis method uses a Fruchterman-Reingold force-directed algorithm, which works as a sort of "anti-gravity force," pulling nodes away from each other unless the system comes to equilibrium.

pos = nx.spring_layout(G, seed=42)
nx.draw(G, pos=pos, with_labels=True)

Subjectively, the result looks the best:

Spring layout, Image by author
Spring layout, Image by author

The seed parameter here is useful if we want results to be the same, otherwise, each redraw will produce another looking graph.

Other graph layout types are available in NetworkX; readers are welcome to test them on their own.

2. Node Colors

As a reminder, our graph represents 18 women participating in 14 social events. All events in the graph have "Exx" names; let’s change their colors for better visual representation.

First, I will create a helper method to detect if the node is an event:

def is_event_node(node: str) -> bool:
    """ Check if events starts with Exx """
    return re.match("^Ed", node) is not None

Here, I used a regex to determine the node pattern (my first attempt was to use the node.startswith("E") method, but some women’s names can also start with "E"). Now, we can easily create an array of colors for each node and use it to draw the graph:

def get_node_color(node: str) -> str:
    """ Get color of the individual node """
    return "#00AA00" if is_event_node(node) else "#00AAEE"

node_colors = [get_node_color(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, with_labels=True)

The result looks like this:

Node colors, Image by author
Node colors, Image by author

3. Node Sizes

In the same way as for colors, we can specify the size of each node. Let’s make "event" nodes bigger; the node size can also be proportional to the number of connections it has:

edges = {node:len(G.edges(node)) for node in G.nodes()}

def node_size(node: str) -> int:
    """ Get size of the individual node """
    k = 4 if is_event_node(node) else 1
    return 100*k + 100 + 50*edges[node]

node_sizes = [node_size(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
        with_labels=True)

Here, I saved the number of edges per node in a separate dictionary. I also used the same is_event_node method as before to make "event" nodes larger.

The result looks like this:

Different node sizes, Image by author
Different node sizes, Image by author

4. Edge Colors

We can specify not only nodes but also edge colors. As an example, let’s highlight all the events visited by Theresa Anderson. To do this, I will need three helper methods:

highlighted_node = "Theresa Anderson"

def get_node_color(node: str) -> str:
    """ Get color of the individual node """
    if is_event_node(node):
        if G.has_edge(node, highlighted_node):
            return "#00AA00"
    elif node == highlighted_node:
        return "#00AAEE"
    return "#AAAAAA"

def edge_color(node1: str, node2: str) -> str:
    """ Get color of the individual edge """
    if node1 == highlighted_node or node2 == highlighted_node:
        return "#992222"
    return "#999999"

def edge_weight(node1: str, node2: str) -> str:
    """ Get width of the individual edge """
    if node1 == highlighted_node or node2 == highlighted_node:
        return 3
    return 1

Here, I used a separate color for Theresa Anderson’s node. I also changed the color of all the events she visited; a has_edge method is an easy way to find if two nodes have a common edge. I also changed the edge weights.

Now, we can draw the graph:

edge_colors = [edge_color(n1, n2) for n1, n2 in G.edges()]
edge_weights = [edge_weight(n1, n2) for n1, n2 in G.edges()]
node_colors = [get_node_color(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
        edge_color=edge_colors, width=edge_weights, with_labels=True)

The result looks like this:

Graph with highlighted nodes and edges, Image by author
Graph with highlighted nodes and edges, Image by author

5. Node Labels

When we have a graph with different node types, we can use different fonts for different nodes. However, to my surprise, in NetworkX there is no easy way to specify fonts, like we did for colors. To draw the "event" and "people" nodes, we can split the graph into subgraphs and draw them separately:

node_events = [node for node in G.nodes() if is_event_node(node)]
node_people = [node for node in G.nodes() if not is_event_node(node)]

nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
        with_labels=False)
nx.draw_networkx_labels(G.subgraph(node_events),
                        pos=pos, font_weight="bold")
nx.draw_networkx_labels(G.subgraph(node_people),
                        pos=pos, font_weight="normal", font_size=11)

Here, I first draw the nodes as before, but I set the with_labels parameter to False. Then I used the draw_networkx_labels method twice with different font settings.

The output looks like this:

Graph with different labels, Image by author
Graph with different labels, Image by author

6. Node Attributes

We were able to set node colors and sizes using the helper Python methods. However, with the node attributes, we can also save this information into a graph itself:

colors_dict = {node: get_node_color(node) for node in G.nodes()}
nx.set_node_attributes(G, colors_dict, "color")

We can also specify attributes manually for some nodes:

custom_colors_dict = {
             "Frances Anderson": "orange",
             "Theresa Anderson": "orange",
             "E3": "darkgreen",
             "E5": "darkgreen",
             "E6": "darkgreen"
}
nx.set_node_attributes(G, custom_colors_dict, "color")

Then, we can save the graph to a file, and all information will be preserved:

nx.write_gml(G, "davis_southern_women.gml")

Later, we can load the graph and extract all colors from the node attributes; we don’t need any helper methods anymore:

attributes = nx.get_node_attributes(G, "color")
node_color_attrs = [attributes[node] for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_color_attrs, node_size=node_sizes, with_labels=False)
nx.draw_networkx_labels(G.subgraph(node_events), pos=pos, font_weight="bold")
nx.draw_networkx_labels(G.subgraph(node_people), pos=pos, font_weight="normal", font_size=11)

The result looks like this:

Graph with node attributes, Image by author
Graph with node attributes, Image by author

Here, we see the same colors as we used before and four nodes with custom colors; all information was saved into a GML file.

7. Bonus: Draw a Graph with D3.JS

When we draw a graph, NetworkX uses Matplotlib "under the hood." This is fine for a small graph like this, but if the graph contains 1000+ nodes, Matplotlib becomes painfully slow. Much better results can be achieved with D3.JS. D3.JS is an open-source Javascript library that can do graph visualization much more efficiently. D3 is a mature project for Data Visualization (the first version was released in 2011), and it works not only for graphs; many beautiful images can be found in the Examples gallery.

I did not find a "native" way to export the NetworkX graph to D3; however, we can do it in several lines of code:

def convert_to_d3(graph: nx.Graph) -> dict:
    """ Convert nx.Graph to D3 data """
    nodes, edges = [], []
    for node_name in graph.nodes:
        nodes.append({"id": node_name,
                      "color": get_node_color(node_name),
                      "radius": 0.01*node_size(node_name)})
    for node1, node2 in graph.edges:
        edges.append({"source": node1, "target": node2})
    return {"nodes": nodes, "links": edges}

# Save in D3 format
d3 = convert_to_d3(G)
with open('d3_graph.json', 'w', encoding='utf-8') as f_out:
    json.dump(d3, f_out, ensure_ascii=False, indent=2)

After that, we can load the JSON data into a Javascript page:

<script type="module">
    // Specify the dimensions of the chart.
    const width = window.innerWidth;
    const height = window.innerHeight;

    // Specify the color scale.
    const color = d3.scaleOrdinal(d3.schemeTableau10);

    const data = await d3.json("./d3_graph.json");
    const links = data.links.map(d => ({...d}));
    const nodes = data.nodes.map(d => ({...d}));

    // Create the SVG container.
    const svg = d3.create("svg")
        .attr("width", width)
        .attr("height", height)
        .attr("viewBox", [0, 0, width, height])
        .attr("style", "max-width: 100%; height: auto;");

    ...      

    // Create a simulation with several forces.
    const simulation = d3.forceSimulation(nodes)
        .force("link", d3.forceLink(links).id(d => d.id))
        .force("charge", d3.forceManyBody())
        .force("center", d3.forceCenter(width / 2, height / 2))
        .on("tick", ticked);

    // Append the SVG element.
    container.append(svg.node());
</script>

This article is not focused on JavaScript itself; code examples for drawing a graph in D3.JS can be easily found online (this one can be a good start; the link to a full source code is also available at the end of this page).

Finally, we can run the local server and open a page in the browser. As an advantage of JavaScript, we can make a graph interactive and even move nodes with drag and drop:

Graph visualization, Image by author
Graph visualization, Image by author

As a disadvantage, almost every change in D3 rendering requires going deep into JavaScript, CSS, and HTML styles. I’m not a front-end web developer, and even small adjustments take too much time (however, it’s always nice to learn something new:). For example, the default graph size in my example was too small, and I did not find an easy way to set a default "zoom" value for it. However, for complex graphs, there is just no other choice because Matplotlib rendering is too slow. I used the D3 library for visualization of the modern artists’ graph, and the results were good:

Python Data Analysis: What Do We Know About Modern Artists?

Conclusion

In this article, I showed different ways to make a graph visualization with NetworkX. As we can see, the process is mostly straightforward, and we can easily adjust many parameters like node size or color. More complex graphs can be exported to JSON and used with JavaScript. After that, we can use a powerful D3.JS library – the rendering process in the web browser is probably hardware-accelerated and much faster.

In my previous article, I used the NetworkX library to analyze the data about modern artists, collected from Wikipedia. Those who are interested in social data analysis are also welcome to read other posts:

If you enjoyed this story, feel free to subscribe to Medium, and you will get notifications when my new articles will be published, as well as full access to thousands of stories from other authors. You are also welcome to connect via LinkedIn. If you want to get the full source code for this and other posts, feel free to visit my Patreon page.

Thanks for reading.


Related Articles