Plotting graphs for structural equation models

tidySEM offers a user-friendly, tidy workflow for plotting graphs for SEM models. The workflow is largely programmatic, meaning that graphs are created mostly automatically from the output of an analysis. At the same time, all elements of the graph are stored as data.frames, which allows swift and easy customization of graphics, and artistic freedom. Some existing graphing packages automatically create a layout, but are very difficult to customize. Particularly for complex SEM models, it may be preferable to make the layout by hand, including only nodes one wishes to plot, and reporting the rest in a comprehensive table of coefficients (e.g., one obtained through table_results().

For users wholly unfamiliar with SEM graphs, I recommend reading the vignette about SEM graphing conventions first.

Let’s load the required packages:

library(tidySEM)
library(lavaan)
library(ggplot2)

The tidySEM workflow

The workflow underlying graphing in tidySEM is as follows:

  1. Run an analysis, e.g., using lavaan::sem() or MplusAutomation::mplusModeler(), passing the output to an object, e.g., fit
  2. Plot the graph using the function graph(fit), or customize the graph further by following the optional steps below.
  3. Optionally Examine what nodes and edges can be extracted from the fit model object, by running get_nodes(fit) and get_edges(fit)
  4. Optionally Specify a layout for the graph using get_layout()
  5. Optionally, prepare graph data before plotting, by running prepare_graph(fit, layout). Store the resulting graph data in an object, e.g., graph_data
  6. Optionally, access the nodes and edges in graph_data using nodes(graph_data) and edges(graph_data)
  7. Optionally, modify the nodes and edges in graph_data using nodes(graph_data) <- ...and edges(graph_data) <- ...

This workflow ensures a high degree of transparency and customizability. Objects returned by all functions are “tidy” data, i.e., tabular data.frames, and can be modified using the familiar suite of functions in the ‘tidyverse’.

Example: Graphing a CFA

Step 1: Run an analysis

As an example, let’s make a graph for a classic lavaan tutorial example for CFA. First, we conduct the SEM analysis:

Step 2: Plot the graph

At this point, we could simply plot the graph:

This uses a default layout, provided by the igraph package. However, the node placement is not very aesthetically pleasing. One of the areas where tidySEM really excells is customization. Because every aspect of the graph is represented as tidy data (basically, a spreadsheet), it is easy to move nodes around and create a custom layout.

Optional step 3: Customizing the layout

In tidySEM, the layout is specified as a matrix (grid) with node names and empty spaces (NA or ""). There are essentially three ways to specify the layout:

  1. Automatically, from the fit model
  2. Manually in R
  3. In a spreadsheet program

Specifying layout manually in R

Manually specifying the layout can be done by providing node names and empty spaces (NA or ""), and the number of rows of the desired layout matrix. For example:

Of course, it is also possible to simply define a matrix using matrix().

Specifying layout in a spreadsheet program

Specifying the layout in a spreadsheet program is very user-friendly, because one can visually position the nodes, e.g.:

To obtain the layout matrix, one can save the spreadsheet as .csv file and load it in R like this:

Alternatively, one can select the layout as in the image above, copy it to the clipboard, and then read it into R from the clipboard. This works differently on Windows and Mac.

On Windows, run:

On Mac, run:

#>   V1     V2 V3
#> 1 x1     x2 x3
#> 2    visual

Examples of user-defined layout

We can specify a simple layout for two hypothetical nodes x and y is generated as follows:

For a mediation model, one might specify a layout like this:

For a three-item CFA model, one might specify:

And for the CFA model we estimated above:

We could plot the CFA model with this custom layout as follows:

Optional step 4: Examine nodes and edges

The function For the simple model above, it is easy to verify the names of the nodes and edges from the syntax above: The nodes consist of three latent variables (visual, textual, and speed), and nine observed variables (x1-x9). The edges are nine factor loadings - and three latent variable correlations, included by default. We can confirm which nodes are available by running get_nodes():

And for the edges:

Optional step 5: accessing graph data before plotting

One important feature of tidySEM graphing is that the data used to compose the plot can be conveniently accessed an modified before plotting. First, use prepare_graph() to assign the plot data to an object.

Optional step 6: Access the nodes and edges

The nodes and edges can be examined using nodes(graph_data) and edges(graph_data):

nodes(graph_data)
#>       name shape   label  x y node_xmin node_xmax node_ymin node_ymax
#> 1    speed  oval   speed 14 4      13.5      14.5       3.5       4.5
#> 2  textual  oval textual 10 4       9.5      10.5       3.5       4.5
#> 3   visual  oval  visual  6 4       5.5       6.5       3.5       4.5
#> 4       x1  rect      x1  2 2       1.4       2.6       1.6       2.4
#> 5       x2  rect      x2  4 2       3.4       4.6       1.6       2.4
#> 6       x3  rect      x3  6 2       5.4       6.6       1.6       2.4
#> 7       x4  rect      x4  8 2       7.4       8.6       1.6       2.4
#> 8       x5  rect      x5 10 2       9.4      10.6       1.6       2.4
#> 9       x6  rect      x6 12 2      11.4      12.6       1.6       2.4
#> 10      x7  rect      x7 14 2      13.4      14.6       1.6       2.4
#> 11      x8  rect      x8 16 2      15.4      16.6       1.6       2.4
#> 12      x9  rect      x9 18 2      17.4      18.6       1.6       2.4
edges(graph_data)
#>       from      to   label arrow curvature connect_from connect_to
#> 1   visual      x1 0.77***  last        NA         left      right
#> 2   visual      x2 0.42***  last        NA       bottom      right
#> 3   visual      x3 0.58***  last        NA       bottom        top
#> 4  textual      x4 0.85***  last        NA       bottom      right
#> 5  textual      x5 0.86***  last        NA       bottom        top
#> 6  textual      x6 0.84***  last        NA       bottom       left
#> 7    speed      x7 0.57***  last        NA       bottom        top
#> 8    speed      x8 0.72***  last        NA       bottom       left
#> 9    speed      x9 0.67***  last        NA        right       left
#> 10      x1      x1 0.40***  both        NA       bottom     bottom
#> 11      x2      x2 0.82***  both        NA       bottom     bottom
#> 12      x3      x3 0.66***  both        NA       bottom     bottom
#> 13      x4      x4 0.27***  both        NA       bottom     bottom
#> 14      x5      x5 0.27***  both        NA       bottom     bottom
#> 15      x6      x6 0.30***  both        NA       bottom     bottom
#> 16      x7      x7 0.68***  both        NA       bottom     bottom
#> 17      x8      x8 0.48***  both        NA       bottom     bottom
#> 18      x9      x9 0.56***  both        NA       bottom     bottom
#> 19  visual  visual    1.00  both        NA        right      right
#> 20 textual textual    1.00  both        NA         left       left
#> 21   speed   speed    1.00  both        NA         left       left
#> 22  visual textual 0.46***  none        60          top        top
#> 23  visual   speed 0.47***  none        60          top        top
#> 24 textual   speed 0.28***  none        60          top        top

Optional step 7: Modify the nodes and edges

At this stage, we may want to improve the basic plot slightly. The functions nodes(graph_data) <- ... and edges(graph_data) <- ... can be used to modify the nodes and edges. These functions pair well with the general ‘tidyverse’ workflow. For example, we might want to print node labels for latent variables in Title Case instead of just using the variable names:

Now, for the edges, we see that the default edging algorithm has connected some nodes side-to-side (based on the smallest possible Euclidian distance). However, in this simple graph, it makes more sense to connect all nodes top-to-bottom - except for the latent variable covariances. We can use the same conditional replacement for the edges:

Plot the customized graph

We can plot a customized graph using plot(graph_data); a generic plot method for sem_graph objects:

Connecting nodes

The functions graph_sem() and prepare_graph() will always try to connect nodes in an aesthetically pleasing way. To do this, they connect nodes by one of the four sides (top, bottom, left and right), based on the shortest distance between two nodes. Alternatively, users can specify a value to the angle parameter. This parameter connects nodes top-to-bottom when they are within angle degrees above and below each other. Remaining nodes are connected side-to-side. Thus, by increasing angle to a larger number (up to 180 degrees), we can also ensure that all nodes are connected top to bottom:

graph_sem(model = fit, layout = lay, angle = 170)

Visual aspects

The functions graph_sem() and prepare_graph() accept several optional visual parameters that can be used to customize the resulting image (see ?graph_sem()). These parameters can be passed as extra columns to the nodes() and edges() objects. Specifically, edges have the following aesthetics (see ?geom_path()):

edg <- data.frame(from = "x",
                  to = "y",
                  linetype = 2,
                  colour = "red",
                  size = 2,
                  alpha = .5)

graph_sem(edges = edg, layout = get_layout("x", "y", rows = 1))

Nodes have the following aesthetics (see ?geom_polygon()):

edg <- data.frame(from = "x",
                  to = "y")
nod <- data.frame(name = c("x", "y"),
                    shape = c("rect", "oval"),
                    linetype = c(2, 2),
                    colour = c("blue", "blue"),
                    fill = c("blue", "blue"),
                    size = c(2, 2),
                    alpha = .5)
graph_sem(edges = edg, nodes = nod, layout = get_layout("x", "y", rows = 1))

These aesthetics can also be passed to the sem_graph object after preparing the data, for example, for highlighting a specific model element, such as the low factor loading for x2 on visual in the CFA example from before:

edges(graph_data) %>%
  mutate(colour = "black") %>%
  mutate(colour = replace(colour, from == "visual" & to == "x2", "red")) %>%
  mutate(linetype = 1) %>%
  mutate(linetype = replace(linetype, from == "visual" & to == "x2", 2)) %>%
  mutate(alpha = 1) %>%
  mutate(alpha = replace(alpha, from == "visual" & to == "x2", .5)) -> edges(graph_data)
plot(graph_data)

Visual aspects of edge labels

Like nodes and edges, edge labels can be customized. Labels have the same aesthetics as the ggplot function geom_label() (see ?geom_label). However, to disambiguate them from the edge aesthetics, all label aesthetics are prefaced by "label_":

edg <- data.frame(from = "x",
                  to = "y",
                  label = "text",
                  label_colour = "blue",
                  label_fill = "red",
                  label_size = 6,
                  label_alpha = .5,
                  label_family = "mono",
                  label_fontface = "bold",
                  label_hjust = "left",
                  label_vjust = "top",
                  label_lineheight = 1.5,
                  label_location = .2
                  )

graph_sem(edges = edg, layout = get_layout("x", "y", rows = 1))