1

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:

  • Units aligned in rows
  • Time indices in columns
  • The edges in the graph repeat periodically (in the image below orange is vertical, blue is 1 time step wide, brown is two time step wide)

Therefore, I'm trying to replicate this powerpoint mockup. What i'm trying to draw

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.

enter image description here

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];
}
}
LudvigH
  • 3,662
  • 5
  • 31
  • 49
  • 1
    A MWE with your code would be advantageous. – albert Aug 07 '19 at 09:46
  • I'm not sure what is to be considered "minimal" in the case above, so I included the complete source for the diagram included. – LudvigH Aug 07 '19 at 09:59
  • More preferably would be the `dot` code. Regarding the bent lines maybe `splines=false` helps (see a.o. https://stackoverflow.com/questions/3006792/straight-edge-between-clusters-in-graphviz) – albert Aug 07 '19 at 10:10
  • Dot code included now. It is quite ugly, since it is generated from `pydot`. `splines=false` did not fix the issue. – LudvigH Aug 07 '19 at 10:17

1 Answers1

3

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"
}

Graphviz output

n0dus
  • 121
  • 7
  • Nice! I don't have the time at hand right now to verify it works for me (I opted for another tool) but the output you linked is exactly what I desired! So I'll mark as solved. Beautiful! – LudvigH Jun 15 '20 at 14:17
  • @LudvigH which tool did you opt for instead? – kibibu Jan 12 '23 at 22:37
  • TikZ/PGF if i recall correctly. manually computed positioning. – LudvigH Jan 14 '23 at 10:44