An Introduction to ggdag

Malcolm Barrett

2024-03-07

Overview

ggdag extends the powerful dagitty package to work in the context of the tidyverse. It uses dagitty’s algorithms for analyzing structural causal graphs to produce tidy results, which can then be used in ggplot2 and ggraph and manipulated with other tools from the tidyverse, like dplyr.

Creating Directed Acyclic Graphs

If you already use dagitty, ggdag can tidy your DAG directly.

library(dagitty)
library(ggdag)
library(ggplot2)

dag <- dagitty("dag{y <- z -> x}")
tidy_dagitty(dag)
#> # A DAG with 3 nodes and 2 edges
#> #
#> # A tibble: 4 × 8
#>   name        x      y direction to       xend   yend circular
#>   <chr>   <dbl>  <dbl> <fct>     <chr>   <dbl>  <dbl> <lgl>   
#> 1 x      0.0449 -0.238 <NA>      <NA>  NA      NA     FALSE   
#> 2 y     -1.60    1.36  <NA>      <NA>  NA      NA     FALSE   
#> 3 z     -0.774   0.556 ->        x      0.0449 -0.238 FALSE   
#> 4 z     -0.774   0.556 ->        y     -1.60    1.36  FALSE

Note that, while dagitty supports a number of graph types, ggdag currently only supports DAGs.

dagitty uses a syntax similar to the dot language of graphviz. This syntax has the advantage of being compact, but ggdag also provides the ability to create a dagitty object using a more R-like formula syntax through the dagify() function. dagify() accepts any number of formulas to create a DAG. It also has options for declaring which variables are exposures, outcomes, or latent, as well as coordinates and labels for each node.

dagified <- dagify(x ~ z,
  y ~ z,
  exposure = "x",
  outcome = "y"
)
tidy_dagitty(dagified)
#> # A DAG with 3 nodes and 2 edges
#> #
#> # Exposure: x
#> # Outcome: y
#> #
#> # A tibble: 4 × 8
#>   name        x      y direction to       xend   yend circular
#>   <chr>   <dbl>  <dbl> <fct>     <chr>   <dbl>  <dbl> <lgl>   
#> 1 x     -2.00    0.939 <NA>      <NA>  NA      NA     FALSE   
#> 2 y      0.0343 -0.106 <NA>      <NA>  NA      NA     FALSE   
#> 3 z     -0.982   0.416 ->        x     -2.00    0.939 FALSE   
#> 4 z     -0.982   0.416 ->        y      0.0343 -0.106 FALSE

Currently, ggdag supports directed (x ~ y) and bi-directed (a ~~ b) relationships

tidy_dagitty() uses layout functions from ggraph and igraph for coordinates if none are provided, which can be specified with the layout argument. Objects of class tidy_dagitty or dagitty can be plotted quickly with ggdag(). If the DAG is not yet tidied, ggdag() and most other quick plotting functions in ggdag do so internally.

ggdag(dag, layout = "circle")

A tidy_dagitty object is just a list with a tbl_df, called data, and the dagitty object, called dag:

tidy_dag <- tidy_dagitty(dagified)
str(tidy_dag)
#> List of 2
#>  $ data: tibble [4 × 8] (S3: tbl_df/tbl/data.frame)
#>   ..$ name     : chr [1:4] "x" "y" "z" "z"
#>   ..$ x        : num [1:4] 1.43 -0.835 0.298 0.298
#>   ..$ y        : num [1:4] 0.756 1.119 0.938 0.938
#>   ..$ direction: Factor w/ 3 levels "->","<->","--": NA NA 1 1
#>   ..$ to       : chr [1:4] NA NA "x" "y"
#>   ..$ xend     : num [1:4] NA NA 1.43 -0.835
#>   ..$ yend     : num [1:4] NA NA 0.756 1.119
#>   ..$ circular : logi [1:4] FALSE FALSE FALSE FALSE
#>   ..- attr(*, "graph")=Classes 'tbl_graph', 'igraph'  hidden list of 10
#>   .. ..$ : num 3
#>   .. ..$ : logi TRUE
#>   .. ..$ : num [1:2] 2 2
#>   .. ..$ : num [1:2] 0 1
#>   .. ..$ : NULL
#>   .. ..$ : NULL
#>   .. ..$ : NULL
#>   .. ..$ : NULL
#>   .. ..$ :List of 4
#>   .. .. ..$ : num [1:3] 1 0 1
#>   .. .. ..$ : Named list()
#>   .. .. ..$ :List of 4
#>   .. .. .. ..$ name              : chr [1:3] "x" "y" "z"
#>   .. .. .. ..$ .ggraph.orig_index: int [1:3] 1 2 3
#>   .. .. .. ..$ .ggraph_layout_x  : num [1:3] 1.43 -0.835 0.298
#>   .. .. .. ..$ .ggraph_layout_y  : num [1:3] 0.756 1.119 0.938
#>   .. .. ..$ :List of 1
#>   .. .. .. ..$ direction: Factor w/ 2 levels "left","right": 1 2
#>   .. ..$ :<environment: 0x126db54e0> 
#>   .. ..- attr(*, "active")= chr "edges"
#>   ..- attr(*, "circular")= logi FALSE
#>  $ dag : 'dagitty' Named chr "dag {\nx [exposure]\ny [outcome]\nz\nz -> x\nz -> y\n}\n"
#>  - attr(*, "class")= chr "tidy_dagitty"

Working with DAGs

Most of the analytic functions in dagitty have extensions in ggdag and are named dag_*() or node_*(), depending on if they are working with specific nodes or the entire DAG. A simple example is node_parents(), which adds a column to the to the tidy_dagitty object about the parents of a given variable:

node_parents(tidy_dag, "x")
#> # A DAG with 3 nodes and 2 edges
#> #
#> # Exposure: x
#> # Outcome: y
#> #
#> # A tibble: 4 × 9
#>   name       x     y direction to      xend   yend circular parent
#>   <chr>  <dbl> <dbl> <fct>     <chr>  <dbl>  <dbl> <lgl>    <fct> 
#> 1 x      1.43  0.756 <NA>      <NA>  NA     NA     FALSE    child 
#> 2 y     -0.835 1.12  <NA>      <NA>  NA     NA     FALSE    <NA>  
#> 3 z      0.298 0.938 ->        x      1.43   0.756 FALSE    parent
#> 4 z      0.298 0.938 ->        y     -0.835  1.12  FALSE    parent

Or working with the entire DAG to produce a tidy_dagitty that has all pathways between two variables:

bigger_dag <- dagify(y ~ x + a + b,
  x ~ a + b,
  exposure = "x",
  outcome = "y"
)
#  automatically searches the paths between the variables labelled exposure and
#  outcome
dag_paths(bigger_dag)
#> # A DAG with 4 nodes and 15 edges
#> #
#> # Exposure: x
#> # Outcome: y
#> #
#> # A tibble: 20 × 10
#>    set   name       x      y direction to     xend   yend circular path     
#>    <chr> <chr>  <dbl>  <dbl> <fct>     <chr> <dbl>  <dbl> <lgl>    <chr>    
#>  1 1     a     -0.554 -1.23  ->        x     -1.46 -0.618 FALSE    <NA>     
#>  2 1     a     -0.554 -1.23  ->        y     -1.60 -1.53  FALSE    <NA>     
#>  3 1     b     -2.51  -0.914 ->        x     -1.46 -0.618 FALSE    <NA>     
#>  4 1     b     -2.51  -0.914 ->        y     -1.60 -1.53  FALSE    <NA>     
#>  5 1     x     -1.46  -0.618 ->        y     -1.60 -1.53  FALSE    open path
#>  6 1     y     -1.60  -1.53  <NA>      <NA>  NA    NA     FALSE    open path
#>  7 2     a     -0.554 -1.23  ->        x     -1.46 -0.618 FALSE    open path
#>  8 2     a     -0.554 -1.23  ->        y     -1.60 -1.53  FALSE    open path
#>  9 2     b     -2.51  -0.914 ->        x     -1.46 -0.618 FALSE    <NA>     
#> 10 2     b     -2.51  -0.914 ->        y     -1.60 -1.53  FALSE    <NA>     
#> 11 2     x     -1.46  -0.618 ->        y     -1.60 -1.53  FALSE    <NA>     
#> 12 2     y     -1.60  -1.53  <NA>      <NA>  NA    NA     FALSE    open path
#> 13 2     x     -1.46  -0.618 <NA>      <NA>  -1.60 -1.53  FALSE    open path
#> 14 3     a     -0.554 -1.23  ->        x     -1.46 -0.618 FALSE    <NA>     
#> 15 3     a     -0.554 -1.23  ->        y     -1.60 -1.53  FALSE    <NA>     
#> 16 3     b     -2.51  -0.914 ->        x     -1.46 -0.618 FALSE    open path
#> 17 3     b     -2.51  -0.914 ->        y     -1.60 -1.53  FALSE    open path
#> 18 3     x     -1.46  -0.618 ->        y     -1.60 -1.53  FALSE    <NA>     
#> 19 3     y     -1.60  -1.53  <NA>      <NA>  NA    NA     FALSE    open path
#> 20 3     x     -1.46  -0.618 <NA>      <NA>  -1.60 -1.53  FALSE    open path

ggdag also supports piping of functions and includes the pipe internally (so you don’t need to load dplyr or magrittr). Basic dplyr verbs are also supported (and anything more complex can be done directly on the data object).

library(dplyr)
#  find how many variables are in between x and y in each path
bigger_dag %>%
  dag_paths() %>%
  group_by(set) %>%
  filter(!is.na(path) & !is.na(name)) %>%
  summarize(n_vars_between = n() - 1L)
#> # A tibble: 1 × 1
#>   n_vars_between
#>            <int>
#> 1              9

Plotting DAGs

Most dag_*() and node_*() functions have corresponding ggdag_*() for quickly plotting the results. They call the corresponding dag_*() or node_*() function internally and plot the results in ggplot2.

ggdag_paths(bigger_dag)

ggdag_parents(bigger_dag, "x")

#  quickly get the miniminally sufficient adjustment sets to adjust for when
#  analyzing the effect of x on y
ggdag_adjustment_set(bigger_dag)

Plotting directly in ggplot2

ggdag() and friends are, by and large, fairly thin wrappers around included ggplot2 geoms for plotting nodes, text, and edges to and from variables. For example, ggdag_parents() can be made directly in ggplot2 like this:

bigger_dag %>%
  node_parents("x") %>%
  ggplot(aes(x = x, y = y, xend = xend, yend = yend, color = parent)) +
  geom_dag_point() +
  geom_dag_edges() +
  geom_dag_text(col = "white") +
  theme_dag() +
  scale_color_hue(breaks = c("parent", "child")) #  ignores NA in legend

The heavy lifters in ggdag are geom_dag_node()/geom_dag_point(), geom_dag_edges(), geom_dag_text(), theme_dag(), and scale_adjusted(). geom_dag_node() and geom_dag_text() plot the nodes and text, respectively, and are only modifications of geom_point() and geom_text(). geom_dag_node() is slightly stylized (it has an internal white circle), while geom_dag_point() looks more like geom_point() with a larger size. theme_dag() removes all axes and ticks, since those have little meaning in a causal model, and also makes a few other changes. expand_plot() is a convenience function that makes modifications to the scale of the plot to make them more amenable to nodes with large points and text scale_adjusted() provides defaults that are common in analyses of DAGs, e.g. setting the shape of adjusted variables to a square.

geom_dag_edges() is also a convenience function that plots directed and bi-directed edges with different geoms and arrows. Directed edges are straight lines with a single arrow head, while bi-directed lines, which are a shorthand for a latent parent variable between the two bi-directed variables (e.g. a <- L -> b), are plotted as an arc with arrow heads on either side.

You can also call edge functions directly, particularly if you only have directed edges. Much of ggdag’s edge functionality comes from ggraph, with defaults (e.g. arrow heads, truncated lines) set with DAGs in mind. Currently, ggdag has four type of edge geoms: geom_dag_edges_link(), which plots straight lines, geom_dag_edges_arc(), geom_dag_edges_diagonal(), and geom_dag_edges_fan().

dagify(
  y ~ x,
  m ~ x + y
) %>%
  ggplot(aes(x = x, y = y, xend = xend, yend = yend)) +
  geom_dag_point() +
  geom_dag_edges_arc() +
  geom_dag_text() +
  theme_dag()

If you have bi-directed edges but would like to plot them as directed, node_canonical() will automatically insert the latent variable for you.

dagify(
  y ~ x + z,
  x ~ ~z
) %>%
  node_canonical() %>%
  ggplot(aes(x = x, y = y, xend = xend, yend = yend)) +
  geom_dag_point() +
  geom_dag_edges_diagonal() +
  geom_dag_text() +
  theme_dag()

There are also geoms based on those in ggrepel for inserting text and labels, and a special geom called geom_dag_collider_edges() that highlights any biasing pathways opened by adjusting for collider nodes. See the vignette introducing DAGs for more info.