Interaction effect plot with CIs and emmeans contrast

Viewed 31

I'm having trouble creating an interaction effect plot. There is probably something fairly simple I don't yet know how to do. I'm pretty new to R and ggplot. My reprex is below. Your insight is greatly appreciated!

The data is from UCLA and I'm also adapting their example for my purposes here.

library(here)
library(emmeans)
library(tidyverse)

dat <- read.csv("https://stats.idre.ucla.edu/wp-content/uploads/2019/03/exercise.csv")

Convert prog into factor variable

dat$prog <- factor(dat$prog, labels = c("jog","swim","read"))

The model

contcat <- lm(loss ~ hours * prog, data=dat)
summary(contcat)

I create mylist with certain points on hours and the two categories in prog that I want to contrast.

(mylist <- list(hours = seq(0, 4, .5), prog=c("jog","read")))

I then pass the object contcat into the emmeans. I request that predicted values of every combination of hours and prog be specified in at=mylist and store the output into an object called emcontcat.

emcontcat  <- emmeans(contcat, ~ hours * prog, at=mylist)

I use emmip to output a set of values using plotit=FALSE.

contcatdat <- emmip(contcat, prog ~ hours, at = mylist, CIs=TRUE, plotit=FALSE)

The output object is fed to ggplot. The interaction effect is plotted along with CI bands.

ggplot(data=contcatdat, aes(x=hours, y=yvar, color=prog)) + 
  geom_line() +
  geom_ribbon(aes(ymax=UCL, aymin=LCL, fill=prog), alpha=0.4)

The plot looks like this: Interaction plot w/ CI bands

But overlapping CIs do not always correspond to the portions of the lines where there is no significant differences in predicted values. I want to add hashed lines for the portions of the lines where there is no significant difference in predicted values. This figure below shows the kind of figure I'm trying to create. (The figure is from a paper by Trenton Mize (2019) found here at Fig. 14.) Fig. 14. in https://sociologicalscience.com/articles-v6-4-81/

To get the simple effect (i.e., difference of two predicted values), I pass emcontcat into a function called contrast where we can request "pairwise" differences (or simple effects). P-values are given for jog - read at each level of hours that was specified in mylist.

contrast(emcontcat, "pairwise", by="hours")

The output:

enter image description here

Where I am having trouble is how to incorporate the simple effect (i.e., the parts of hours where jog - read are significantly different or not) into ggplot as hashed or solid portions of the lines like the Mize 2019 figure.

1 Answers

We want to know if the intervals overlap, and if so, we want dashed lines. Actually that's easy by writing a respective function itvl_is_l(). However, on the LHS of the plot, there is just one point, but to draw a line we need a minimum of two. So we have to interpolate with "approximate", which is also done internally in the plot functions. Since we want to do everything for the two progs, we use by.

Preprocessing

## merge interpolations by prog
aux <- by(contcatdat, contcatdat$prog, \(x) {
  x <- merge(x, data.frame(hours=with(x, seq.int(min(hours), max(hours), 
                                                 length.out=1e3))), all=TRUE)
  x$prog <- unique(na.omit(x$prog))
  u <- c('yvar', 'LCL', 'UCL')
  x[u] <- lapply(x[u], \(x) approx(x, xout=seq_along(x))$y) 
  x
})

## logical interval intersect function
itvl_is_l <- \(a, b) {unname(as.vector(ifelse(b[, 1] > a[, 2] | a[, 1] > b[2], TRUE, FALSE)))}

## check if intersecting CIs
its <- itvl_is_l(aux$jog[c('LCL', 'UCL')], aux$read[c('LCL', 'UCL')])
aux <- lapply(aux, `[<-`, 'its', val=its)  ## add as variable
aux <- lapply(aux, \(x) transform(x, itsn=cumsum(c(0, diff(x$its)) != 0) + 1))  ## making a sequence out of it

contcatdat <- do.call(rbind, aux)  ## combine back as contcatdat

Plot

clr <- c('#FF0000', '#0000FF', '#0000001A')  ## some colors


png('foo.png', 600, 400)  ## open .png device

plot(yvar ~ hours, contcatdat, type='n')
grid()
## lines left
lines(yvar ~ hours, contcatdat, subset=prog == 'jog' & itsn > 2, lwd=2, col=clr[1])
lines(yvar ~ hours, contcatdat, subset=prog == 'read' & itsn > 2, lwd=2, col=clr[2])
## lines middle, dashed
lines(yvar ~ hours, contcatdat, subset=prog == 'jog' & itsn == 2, lwd=2, col=clr[1], lty=2)
lines(yvar ~ hours, contcatdat, subset=prog == 'read' & itsn == 2, lwd=2, col=clr[2], lty=2)
## lines right
lines(yvar ~ hours, contcatdat, subset=prog == 'jog' & itsn < 2, lwd=2, col=clr[1])
lines(yvar ~ hours, contcatdat, subset=prog == 'read' & itsn < 2, lwd=2, col=clr[2])
## CIs
with(subset(contcatdat, prog == 'jog'), 
     polygon(c(hours, rev(hours)), c(UCL, rev(LCL)), border=NA, col=clr[3]))
with(subset(contcatdat, prog == 'read'), 
     polygon(c(hours, rev(hours)), c(UCL, rev(LCL)), border=NA, col=clr[3]))
## legend
legend('topleft', legend=unique(contcatdat$prog), title='Group', col=clr[1:2], lty=1, lwd=2)

dev.off()  ## close .png device

enter image description here

You could also try to plot the polygons first and opaque with a border, if that might look better.


Data:

contcatdat <- structure(list(prog = structure(c(1L, 2L, 1L, 2L, 1L, 2L, 1L, 
2L, 1L, 2L, 1L, 2L, 1L, 2L, 1L, 2L, 1L, 2L), levels = c("jog", 
"read"), class = "factor"), hours = c(0, 0, 0.5, 0.5, 1, 1, 1.5, 
1.5, 2, 2, 2.5, 2.5, 3, 3, 3.5, 3.5, 4, 4), yvar = c(-6.78065983345649, 
2.21637209230689, -3.05428518360714, 0.738291278604121, 0.672089466242214, 
-0.739789535098646, 4.39846411609157, -2.21787034880141, 8.12483876594092, 
-3.69595116250418, 11.8512134157903, -5.17403197620695, 15.5775880656396, 
-6.65211278990971, 19.303962715489, -8.13019360361248, 23.0303373653383, 
-9.60827441731525), SE = c(1.64384530410457, 1.48612021916972, 
1.25520349531108, 1.14711211184156, 0.87926401607137, 0.820840725755632, 
0.543079708493216, 0.531312719216624, 0.375535476484592, 0.376041650300328, 
0.558013604603198, 0.501120592808483, 0.89777081499028, 0.781944232621328, 
1.27470257475094, 1.1056003463909, 1.66373129934114, 1.44356083265185
), df = c(894, 894, 894, 894, 894, 894, 894, 894, 894, 894, 894, 
894, 894, 894, 894, 894, 894, 894), LCL = c(-10.0069052579393, 
-0.700318757711651, -5.51777400669205, -1.51305511813823, -1.05357261502514, 
-2.35078883599747, 3.33260443922245, -3.26063588462286, 7.38780492844162, 
-4.43397842739773, 10.7560441598055, -6.15754180868669, 13.815604150934, 
-8.18677301395645, 16.8022045883112, -10.3000681349591, 19.7650632676689, 
-12.4414373187615), UCL = c(-3.55441440897366, 5.13306294232543, 
-0.590796360522233, 2.98963767534648, 2.39775154750957, 0.871209765800175, 
5.46432379296068, -1.17510481297997, 8.86187260344022, -2.95792389761063, 
12.946382671775, -4.19052214372721, 17.3395719803452, -5.11745256586298, 
21.8057208426668, -5.96031907226584, 26.2956114630078, -6.77511151586902
), tvar = structure(c(1L, 2L, 1L, 2L, 1L, 2L, 1L, 2L, 1L, 2L, 
1L, 2L, 1L, 2L, 1L, 2L, 1L, 2L), levels = c("jog", "read"), class = "factor"), 
    xvar = c(0, 0, 0.5, 0.5, 1, 1, 1.5, 1.5, 2, 2, 2.5, 2.5, 
    3, 3, 3.5, 3.5, 4, 4)), estName = "yvar", clNames = c("lower.CL", 
"upper.CL"), pri.vars = c("prog", "hours"), adjust = "none", side = 0, delta = 0, type = "link", mesg = "Confidence level used: 0.95", row.names = c(NA, 
18L), class = c("summary_emm", "data.frame"), labs = list(xlab = "hours", 
    ylab = "Linear prediction", tlab = "prog"), vars = list(byvars = character(0), 
    tvars = "prog"))
Related