permuco4brain has currently 2 built-in functions for plotting. The image() function provides a heat-map of the results for 1 effect and the plot() function draws the adjacency graph for 1 time and 1 effect where the vertices are colored depending and the \(p\)-value. These functions are fast but not easy to custom. Hence they are useful for checking results of the test but are may not be useful for publication purpose.

In this vignette, we show how to extract the results of the test to produce 3 types of graphical representations using ggplot2 (Wickham 2016). These function may be slower to run but provides plots that can be custom for publication.

These representation may be useful if you performed the cluster-mass test (Maris and Oostenveld 2007), the TFCE (Smith and Nichols 2009) or the procedure from Troendle (1995). However, the interpretation may be different.

We assume that you have already run the example in the download-example-cheval included in permuco4brain:

devtools::install_github("jaromilfrossard/permuco4brain", build_vignettes = TRUE)
vignette("download-example-cheval",package = "permuco4brain")

In the download-example-cheval vignette, the final step is to perform test on a repeated measures ANCOVA model using signal ~ action*stimuli*mvpa_c + Error(participant/(action*stimuli)). In the following tutorial, we will assume the same data-set but adjusting a slightly simpler model, using the formula: signal ~ action*stimuli + Error(participant/(action*stimuli)).

The heat-map

In this section, we create a similar heat-map than the image() function applied to a brainperm object. We need the following package:

Make sure to load the brainperm model:

load("model.RData")

We need first to get the channel’s attributes saved in the igraph object. We need from this object the spatial position of the channels.

chan_tbl <- get.vertex.attribute(model$graph)%>%
  as_tibble()

Next we get the results of the test by specifying the table_type = "full" argument from the summary() method. It produces a list with a table per effect/interaction. Here we transform it into a large table:

### test tibble
df_gg <- summary(model, table_type = "full")
df_gg <-
  tibble(effect = names(df_gg),
         data = df_gg)%>%
  unnest(data)

Next we merge the channel’s information to the data-frame with the tests information:

### add channel info
df_gg <- df_gg%>%
  nest(data = -channel)%>%
  left_join(chan_tbl, by = c("channel" = "name"))

We sort the channels and effects which will be is useful for ordering the panels:

### order channel
df_gg <- df_gg%>%
  arrange(y, desc(x))%>%
  mutate(order = 1:n())%>%
  mutate(channel = fct_reorder(channel, order, min))%>%
  select(-order)%>%
  unnest(data)%>%
  mutate(effect = fct_relevel(effect, c("action", "stimuli", "action:stimuli")))

In the heat-map, we represent the test below the threshold transparent, the non-significant clusters in white and the significant clusters colored accordingly to their \(p\)-value:

df_gg%>%
  ggplot() +
  geom_tile(data = .%>%filter(cluster_id != 0, pvalue >= 0.05),
            aes(x = sample, y = channel), fill = "white") +
  geom_tile(data = .%>%filter(cluster_id != 0, pvalue < 0.05),
            aes(x = sample, y = channel, fill = pvalue)) +
  scale_fill_gradientn(colours = c("red", "yellow"),
                       limits = c(0, 0.05)) +
  facet_grid(col = vars(effect)) +
  theme(legend.position = 'bottom')

We can slight change the coloring by choosing to display the observed statistics of the individual tests. It may provide a good visual information of which time-points or channels drive the effect.

df_gg%>%
  ggplot() +
  geom_tile(data = .%>%filter(cluster_id != 0, pvalue >= 0.05),
            aes(x = sample, y = channel), fill = "white") +
  geom_tile(data = .%>%filter(cluster_id != 0, pvalue < 0.05),
            aes(x = sample, y = channel, fill = statistic)) +
  scale_fill_gradientn(colours = c("yellow", "red")) +
  facet_grid(col = vars(effect)) +
  theme(legend.position = 'bottom')

The signals on grid

In EEG analysis, it is common to plot the ERP of each channel on the representation of the head. This type representation usually displays the data or average per group but we adapt it to display the statistical signal for each effect. We will use the geofacet (Hafen 2019) package and its web-app to construct the grid.

We need the following package:

First we use the table_type = "full" from the summary() method to get the result from all the tests (all effects, all time points, all channels) and we store it in a data-frame:

df_gg <- summary(model, table_type = "full")
df_gg <- tibble(effect = names(df_gg),
                data = df_gg)%>%
  unnest(data)

We get the position from the channels by getting the attributes of the graph:

chan_tbl = get.vertex.attribute(model$graph)%>%
  as_tibble()

Then we use an internal function from eeguana (Nicenboim 2018) to transform the 3D coordinate system into 2D:

chan_polar <- eeguana:::polar(chan_tbl%>% pull(x),
                              chan_tbl%>% pull(y),
                              chan_tbl%>% pull(z))%>%
  as_tibble()%>%
  rename(x_pol = "x", 
         y_pol = "y")

chan_tbl <- chan_tbl%>%
  bind_cols(chan_polar)

Next we transform the position of the channel in order to simplify the creation of the grid as we need positive and integer values:

df_grid = chan_tbl%>%
  select(name, x_pol, y_pol)%>%
  transmute(name = name,
            code = name,
            row = as.integer(round(y_pol*5)),
            col = as.integer(round(x_pol*5)))%>%
  mutate(row = row - min(row),
         row = max(row) - row + 1,
         col = col - min(col) + 1)

Then, running the function grid_design() launches the web-app from geofacet on your browser. It allows to fine-tune the position of each channel and copy-paste the resulting data-frame.

# grid_design(data= df_grid)
df_grid <- data.frame(
  name = c("Fp2", "Fp1", "Fpz", "AF4", "AF3", "AFz", "AF8", "AF7", "Fz", "F2", "F1", "F3", 
           "F4", "F6", "F5", "F8", "F7", "FT8", "FT7", "FC5", "FC3", "FC1", "FCz", "FC2", 
           "FC4", "FC6", "T7", "C5", "C3", "C1", "Cz", "C2", "C4", "C6", "T8", "CP3", "CP1", 
           "CPz", "CP2", "CP4", "CP6", "CP5", "TP8", "TP7", "P7", "P5", "P3", "P1", "Pz", 
           "P2", "P4", "P6", "P8", "P10", "P9", "PO7", "PO3", "POz", "PO4", "PO8", "O1", 
           "Oz", "O2", "Iz"),
  code = c("Fp2", "Fp1", "Fpz", "AF4", "AF3", "AFz", "AF8", "AF7", "Fz", "F2", "F1", "F3", 
           "F4", "F6", "F5", "F8", "F7", "FT8", "FT7", "FC5", "FC3", "FC1", "FCz", "FC2", 
           "FC4", "FC6", "T7", "C5", "C3", "C1", "Cz", "C2", "C4", "C6", "T8", "CP3", "CP1", 
           "CPz", "CP2", "CP4", "CP6", "CP5", "TP8", "TP7", "P7", "P5", "P3", "P1", "Pz", "P2", 
           "P4", "P6", "P8", "P10", "P9", "PO7", "PO3", "POz", "PO4", "PO8", "O1", "Oz", "O2", 
           "Iz"),
  row = c(1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 
          5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 
          8, 8, 8, 8, 9, 9, 9, 10),
  col = c(7, 5, 6, 8, 4, 6, 10, 2, 6, 7, 5, 4, 8, 9, 3, 10, 2, 10, 2, 3, 4, 5, 6, 7, 8, 9, 2, 
          3, 4, 5, 6, 7, 8, 9, 10, 4, 5, 6, 7, 8, 9, 3, 10, 2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 
          1, 2, 4, 6, 8, 10, 4, 6, 8, 6),
  stringsAsFactors = FALSE)

The final plot is created by using the grid and highlighting the time-points in significant clusters. Here is the example for the interaction:

df_gg%>%
  filter(effect == "action:stimuli")%>%
  ggplot()+
  geom_ribbon(data =.%>%
                mutate(statistic = if_else(pvalue<0.05, statistic, NA_real_)),
              aes(x = sample,ymin = 0, ymax = statistic), fill = "red")+
  geom_line(aes(x = sample, y = statistic))+
  labs(x = "", y = "")+
  facet_geo(~channel, grid = df_grid)+
  theme(axis.title = element_blank(),
        axis.text.x = element_text(angle = 90))

The graph of the channels

In the following section, we plot the graph of adjacency of the channel for 1 sample by highlighting the channels belonging to significant clusters. It produces an accurate representation the clusters at a precise time-point.

In addition to the usual package we need ggforce (Pedersen 2019) as it facilitates the representation of the channels (using geom_circle()).

As previously, we get the channels position data:

chan_tbl = get.vertex.attribute(model$graph)%>%
  as_tibble()

We compute their 2D projection using the hidden function from eeguana:

chan_polar <- eeguana:::polar(chan_tbl%>% pull(x),
                              chan_tbl%>% pull(y),
                              chan_tbl%>% pull(z))%>%
  as_tibble()%>%
  rename(x_pol = "x",y_pol = "y")

chan_tbl <- chan_tbl%>%
  bind_cols(chan_polar)

We store the data from the test in a data-frame:

df_gg <- summary(model, table_type = "full")
df_gg <-
  tibble(effect = names(df_gg),
         data = df_gg)%>%
  unnest(data)

And we merge data from test and the channel position:

df_gg <- df_gg%>%
  nest(data = -channel)%>%
  left_join(chan_tbl, by = c("channel" = "name"))%>%
  unnest(data)%>%
  mutate(effect = fct_relevel(effect, c("action", "stimuli", "action:stimuli")))

Next we use the channel position to compute the size of the channel. Here the radius of the circles will be the \(1/5\) of the minimal distance between 2 channels.

rad <- 
  (chan_tbl%>%
  select(x_pol, y_pol)%>%
  dist()%>%
  min())/5

We get the data of the edges of the adjacency graph:

df_chan_pos <- 
  chan_tbl%>%
  select(channel = name, x_pol, y_pol)

edge_tbl = as_data_frame(model$graph, what = c("edges"))%>%
  as_tibble()%>%
  left_join(df_chan_pos, by = c("from" = "channel"))%>%
  rename(x_from = x_pol, y_from = y_pol)%>%
  left_join(df_chan_pos, by = c("to"="channel"))%>%
  rename(x_to = x_pol, y_to = y_pol)

In order to have a representation of the edges that fits nicely with the size of the circles, we reduce the length of each segment using simple triangle geometry formula:

edge_tbl<- 
  edge_tbl%>%
  mutate(slope = (y_from - y_to)/(x_from - x_to),
         dy = sqrt(rad^2/(1 + 1/slope^2)),
         dx = sqrt(rad^2/(1 + slope^2)))%>%
  transmute(from = from,to = to,
            x_from = if_else(x_from < x_to, x_from + dx, x_from - dx),
            x_to = if_else(x_from < x_to, x_to- dx, x_to + dx),
            y_from = if_else(y_from < y_to, y_from + dy, y_from - dy),
            y_to = if_else(y_from < y_to, y_to - dy, y_to + dy))

Finally, the plot the result for 1 time-frame:

df_gg%>%
  filter(sample%in%c(80))%>%
  ggplot()+
  geom_circle(data=.%>%filter(cluster_id == 0),
              aes(x0 = x_pol, y0 = y_pol,r = rad), fill="#00000000")+
  geom_circle(data=.%>%filter(cluster_id!=0&pvalue>0.05),
              aes(x0 = x_pol, y0 = y_pol,r=rad), fill="white")+
  geom_circle(data=.%>%filter(pvalue<0.05),
              aes(x0 = x_pol, y0 = y_pol,r=rad,fill=pvalue))+
  scale_fill_gradientn(colours = c("red","yellow"),
                       limits = c(0, 0.05))+
  geom_segment(data=edge_tbl, 
               mapping = aes(x = x_from, y = y_from, xend = x_to, yend= y_to),
               inherit.aes = F, col = "grey20")+
  geom_text(aes(x = x_pol, y = y_pol + 2.4*rad, label = channel))+
  coord_fixed()+
  facet_grid(sample~effect)+  
  theme(axis.title = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank(),
        legend.position = 'bottom')

Or several time-frames of 1 effect (here the interaction). We deleted the names of the channels not to overload the representation.

df_gg%>%
  filter(sample%in%(c(5:13)*10),effect=="action:stimuli")%>%
  ggplot()+
  geom_circle(aes(x0 = x_pol, y0 = y_pol,r = rad), fill = "#00000000")+
  geom_circle(data=.%>%filter(cluster_id!=0),
              aes(x0 = x_pol, y0 = y_pol,r = rad), fill = "white")+
  geom_circle(data=.%>%filter(pvalue<0.05),
              aes(x0 = x_pol, y0 = y_pol,r = rad,fill = pvalue))+
  scale_fill_gradientn(colours = c("red","yellow"),
                       limits = c(0, 0.05))+
  geom_segment(data = edge_tbl, 
               mapping=aes(x = x_from, y = y_from, xend = x_to, yend = y_to),
             inherit.aes = F, col = "grey20")+
  coord_fixed()+
  facet_wrap(~sample, ncol = 3)+ 
  theme(axis.title = element_blank(),
        axis.ticks = element_blank(),
        axis.text = element_blank(),
        legend.position = 'bottom')

Reference

Hafen, Ryan. 2019. Geofacet: ’Ggplot2’ Faceting Utilities for Geographical Data. https://CRAN.R-project.org/package=geofacet.

Maris, Eric, and Robert Oostenveld. 2007. “Nonparametric Statistical Testing of EEG- and MEG-Data.” Journal of Neuroscience Methods 164 (1): 177–90. https://doi.org/10.1016/j.jneumeth.2007.03.024.

Nicenboim, Bruno. 2018. eeguana: A Package for Manipulating EEG Data in R. https://doi.org/10.5281/zenodo.2533138.

Pedersen, Thomas Lin. 2019. Ggforce: Accelerating ’Ggplot2’. https://CRAN.R-project.org/package=ggforce.

Smith, S, and T Nichols. 2009. “Threshold-Free Cluster Enhancement: Addressing Problems of Smoothing, Threshold Dependence and Localisation in Cluster Inference.” NeuroImage 44 (1): 83–98. https://doi.org/10.1016/j.neuroimage.2008.03.061.

Troendle, James F. 1995. “A Stepwise Resampling Method of Multiple Hypothesis Testing.” Journal of the American Statistical Association 90 (429): 370–78. https://doi.org/10.1080/01621459.1995.10476522.

Wickham, Hadley. 2016. Ggplot2: Elegant Graphics for Data Analysis. Springer-Verlag New York. https://ggplot2.tidyverse.org.