tl;dr: How do I make graphviz stick to a grid layout of nodes?
I'm trying to draw a "full causal graph" for a time series. This means that I have some graph with Units and Time indices repeating in the time direction.
I want to draw the graph with Graphviz since it is programmatic. I don't know the number of Units, nor the number of Time steps. This will be varied as the project continues. I might also want to adjust colors, stroke widths etc programatically as a vizualisation of machine learning models.
To make the diagram readable, I have a few layout considerations to take into account:
Therefore, I'm trying to replicate this powerpoint mockup.
To accomplish this, I've got inspiration from a few 'SO posts and added subgraphs with rank=same
and also invisible edges. This post shows it: https://stackoverflow.com/a/49736304/4050510
From other SO posts, I've been able to order my nodes in the way I like. The current output is like below. Since I'm using pydot
, the python code and the dot code is quite ugly. I'll link to it on request.
As you see, it all works except a few quirks:
1) The invisible nodes are not aligned with the visible nodes 1) The orange arrows are bent, since they are colliding with the invisible arrows
Is there any way to make Graphviz deal with this elegantly? How do I force the grid layout, and how do I make the orange arrows straight?
Pydot source code for plot above
import io
import pydot
import matplotlib.image as img
import matplotlib.pyplot as plt
def render_pydot(g: pydot.Dot, prog):
# noinspection PyUnresolvedReferences
png_bytes = g.create(prog=prog, format="png")
bytes_as_inmemory_file = io.BytesIO(png_bytes)
img2 = img.imread(bytes_as_inmemory_file)
plt.figure()
plt.imshow(img2, aspect='equal')
plt.axis(False)
plt.grid(False)
plt.show()
def create_dot_for_timeseries_with_pydot():
"""Generate a dot object for a static 'full time series'"""
g = pydot.Dot(rankdir='LR')
units = ["Alfa", "Beta", "Gamma"]
time_steps = list(range(0, 5)) # five steps, two invisible
for t in time_steps:
sg = pydot.Subgraph(rank="same", rankdir="TB")
for u, _ in enumerate(units):
# create nodes
this_node_name = f"{t}_{u}"
opts = {'name': this_node_name,
'label': this_node_name
}
if t not in time_steps[1:-1]:
opts['style'] = 'invis'
opts['color'] = 'gray70'
n = pydot.Node(**opts)
# create invisible edges to enforce order vertically and horizontally
# https://stackoverflow.com/q/44274518/4050510
if u != 0:
prev = f"{t}_{u - 1}"
e = pydot.Edge(src=prev, dst=this_node_name,
style='invis',
color="gray70",
weight=1000)
sg.add_edge(e)
if t in time_steps[:-1]:
next = f"{t + 1}_{u}"
g.add_edge(pydot.Edge(src=this_node_name, dst=next,
style="invis",
color="gray70", weight=1000))
sg.add_node(n)
g.add_subgraph(sg)
# Draw lag 0 effects
if t in time_steps[1:-1]:
g.add_edge(pydot.Edge(f"{t}_{0}", f"{t}_{1}", color="orange"))
# Draw lag 1 effects
if t in time_steps[:-1]:
for u, _ in enumerate(units):
g.add_edge(pydot.Edge(f"{t}_{u}", f"{t + 1}_{u}", color="blue"))
g.add_edge(pydot.Edge(f"{t}_{0}", f"{t + 1}_{1}", color="blue"))
g.add_edge(pydot.Edge(f"{t}_{1}", f"{t + 1}_{2}", color="blue"))
# Draw lag 2 effects
if t in time_steps[:-2]:
g.add_edge(pydot.Edge(f"{t}_{0}", f"{t + 2}_{1}", color="brown"))
return g
g = create_dot_for_timeseries_with_pydot()
print(g) # print the dot document as text for inspection
render_pydot(g, prog='dot') # show the image
Generated DOT code from above python file
digraph G {
rankdir=LR;
splines=False;
"0_0" -> "1_0" [color=gray70, style=invis, weight=1000];
"0_1" -> "1_1" [color=gray70, style=invis, weight=1000];
"0_2" -> "1_2" [color=gray70, style=invis, weight=1000];
subgraph {
rank=same;
rankdir=TB;
"0_0" [color=gray70, label="0_0", style=invis];
"0_0" -> "0_1" [color=gray70, style=invis, weight=1000];
"0_1" [color=gray70, label="0_1", style=invis];
"0_1" -> "0_2" [color=gray70, style=invis, weight=1000];
"0_2" [color=gray70, label="0_2", style=invis];
}
"0_0" -> "1_0" [color=blue];
"0_1" -> "1_1" [color=blue];
"0_2" -> "1_2" [color=blue];
"0_0" -> "1_1" [color=blue];
"0_1" -> "1_2" [color=blue];
"0_0" -> "2_1" [color=brown];
"1_0" -> "2_0" [color=gray70, style=invis, weight=1000];
"1_1" -> "2_1" [color=gray70, style=invis, weight=1000];
"1_2" -> "2_2" [color=gray70, style=invis, weight=1000];
subgraph {
rank=same;
rankdir=TB;
"1_0" [label="1_0"];
"1_0" -> "1_1" [color=gray70, style=invis, weight=1000];
"1_1" [label="1_1"];
"1_1" -> "1_2" [color=gray70, style=invis, weight=1000];
"1_2" [label="1_2"];
}
"1_0" -> "1_1" [color=orange];
"1_0" -> "2_0" [color=blue];
"1_1" -> "2_1" [color=blue];
"1_2" -> "2_2" [color=blue];
"1_0" -> "2_1" [color=blue];
"1_1" -> "2_2" [color=blue];
"1_0" -> "3_1" [color=brown];
"2_0" -> "3_0" [color=gray70, style=invis, weight=1000];
"2_1" -> "3_1" [color=gray70, style=invis, weight=1000];
"2_2" -> "3_2" [color=gray70, style=invis, weight=1000];
subgraph {
rank=same;
rankdir=TB;
"2_0" [label="2_0"];
"2_0" -> "2_1" [color=gray70, style=invis, weight=1000];
"2_1" [label="2_1"];
"2_1" -> "2_2" [color=gray70, style=invis, weight=1000];
"2_2" [label="2_2"];
}
"2_0" -> "2_1" [color=orange];
"2_0" -> "3_0" [color=blue];
"2_1" -> "3_1" [color=blue];
"2_2" -> "3_2" [color=blue];
"2_0" -> "3_1" [color=blue];
"2_1" -> "3_2" [color=blue];
"2_0" -> "4_1" [color=brown];
"3_0" -> "4_0" [color=gray70, style=invis, weight=1000];
"3_1" -> "4_1" [color=gray70, style=invis, weight=1000];
"3_2" -> "4_2" [color=gray70, style=invis, weight=1000];
subgraph {
rank=same;
rankdir=TB;
"3_0" [label="3_0"];
"3_0" -> "3_1" [color=gray70, style=invis, weight=1000];
"3_1" [label="3_1"];
"3_1" -> "3_2" [color=gray70, style=invis, weight=1000];
"3_2" [label="3_2"];
}
"3_0" -> "3_1" [color=orange];
"3_0" -> "4_0" [color=blue];
"3_1" -> "4_1" [color=blue];
"3_2" -> "4_2" [color=blue];
"3_0" -> "4_1" [color=blue];
"3_1" -> "4_2" [color=blue];
subgraph {
rank=same;
rankdir=TB;
"4_0" [color=gray70, label="4_0", style=invis];
"4_0" -> "4_1" [color=gray70, style=invis, weight=1000];
"4_1" [color=gray70, label="4_1", style=invis];
"4_1" -> "4_2" [color=gray70, style=invis, weight=1000];
"4_2" [color=gray70, label="4_2", style=invis];
}
}
I think the trick in this case is to specify the full (grid-)graph and then make the unwanted parts invisible. Here is a minimal example for your case. (I have just left out the colors.)
digraph{
# Columns
subgraph {
"0_0" [style=invis]
"0_1" [style=invis]
"0_2" [style=invis]
}
subgraph {
"1_0"
"1_1"
"1_2"
}
subgraph {
"2_0"
"2_1"
"2_2"
}
subgraph {
"3_0"
"3_1"
"3_2"
}
subgraph {
"4_0" [style=invis]
"4_1" [style=invis]
"4_2" [style=invis]
}
# Rows
subgraph {
rank=same
"0_0"
"1_0"
"2_0"
"3_0"
"4_0"
}
subgraph {
rank=same
"0_1"
"1_1"
"2_1"
"3_1"
"4_1"
}
subgraph {
rank=same
"0_2"
"1_2"
"2_2"
"3_2"
"4_2"
}
# Straight edges
"0_0" -> "1_0"
"0_1" -> "1_1"
"0_2" -> "1_2"
"1_0" -> "2_0"
"1_1" -> "2_1"
"1_2" -> "2_2"
"2_0" -> "3_0"
"2_1" -> "3_1"
"2_2" -> "3_2"
"3_0" -> "4_0"
"3_1" -> "4_1"
"3_2" -> "4_2"
"0_0" -> "0_1" [style=invis]
"1_0" -> "1_1"
"2_0" -> "2_1"
"3_0" -> "3_1"
"4_0" -> "4_1" [style=invis]
"0_1" -> "0_2" [style=invis]
"1_1" -> "1_2" [style=invis]
"2_1" -> "2_2" [style=invis]
"3_1" -> "3_2" [style=invis]
"4_1" -> "4_2" [style=invis]
# Diagonal edges
"0_0" -> "1_1"
"0_0" -> "2_1"
"1_0" -> "3_1"
"2_0" -> "4_1"
"0_1" -> "1_2"
"1_1" -> "2_2"
"2_1" -> "3_2"
"3_1" -> "4_2"
}