A Shiny app for visualizing DESeq2 results

Zuguang Gu ( z.gu@dkfz.de )

2023-10-24

In this vignette, I demonstrate how to implement a Shiny app for visualizing DESeq2 results with InteractiveComplexHeatmap package.

Implement from scratch

First we run DESeq2 analysis on the airway dataset:

library(airway)
data(airway)
se = airway
library(DESeq2)
dds = DESeqDataSet(se, design = ~ dex)
keep = rowSums(counts(dds)) >= 10
dds = dds[keep, ]

dds$dex = relevel(dds$dex, ref = "untrt")

dds = DESeq(dds)
res = results(dds)
res = as.data.frame(res)

Since we use InteractiveComplexHeatmap package, we start with the heatmap. Following are the thoughts of this Shiny app:

  1. The app starts from the heatmap which shows the significantly differentially expressed genes.
  2. When selecting from the heatmap, the selected genes are highlighted in a MA-plot and a volcano plot so it is easy to correspond to these genes’ base means, log2 fold changes and FDRs.
  3. There should also be a table which contains the statistics from DESeq2 analysis for the selected genes.
  4. The heatmap can be regenerated by different cutoffs for selecting the signficant genes.

We first define several functions:

  1. make_heatmap(): make the heatmap for the differential genes under specific cutoffs.
  2. make_maplot(): make the MA-plot and highlighted a subset of selected genes.
  3. make_volcano(): make the volcano plot and highlighted a subset of selected genes.

make_maplot() and make_volcano() are basically scatter plot and they are defined very similarly.

The heatmap only contains the information of the differential genes, which means, all the indices captured with InteractiveComplexHeatmap only restricted to the matrix of the differential genes. In make_maplot() and make_volcano(), they expect the indices of the genes to be from the complete gene sets, thus, we create an environment variable env which saves the indices of the differential genes in the complete and share between functions.

library(InteractiveComplexHeatmap)
library(ComplexHeatmap)
library(circlize)
library(GetoptLong)

env = new.env()

make_heatmap = function(fdr = 0.01, base_mean = 0, log2fc = 0) {
    l = res$padj <= fdr & res$baseMean >= base_mean & 
                        abs(res$log2FoldChange) >= log2fc; l[is.na(l)] = FALSE

    if(sum(l) == 0) return(NULL)

    m = counts(dds, normalized = TRUE)
    m = m[l, ]

    env$row_index = which(l)

    ht = Heatmap(t(scale(t(m))), name = "z-score",
        top_annotation = HeatmapAnnotation(
            dex = colData(dds)$dex,
            sizeFactor = anno_points(colData(dds)$sizeFactor)
        ),
        show_row_names = FALSE, show_column_names = FALSE, row_km = 2,
        column_title = paste0(sum(l), " significant genes with FDR < ", fdr),
        show_row_dend = FALSE) + 
        Heatmap(log10(res$baseMean[l]+1), show_row_names = FALSE, width = unit(5, "mm"),
            name = "log10(baseMean+1)", show_column_names = FALSE) +
        Heatmap(res$log2FoldChange[l], show_row_names = FALSE, width = unit(5, "mm"),
            name = "log2FoldChange", show_column_names = FALSE,
            col = colorRamp2(c(-2, 0, 2), c("green", "white", "red")))
    ht = draw(ht, merge_legend = TRUE)
    ht
}

# make the MA-plot with some genes highlighted
make_maplot = function(res, highlight = NULL) {
    col = rep("#00000020", nrow(res))
    cex = rep(0.5, nrow(res))
    names(col) = rownames(res)
    names(cex) = rownames(res)
    if(!is.null(highlight)) {
        col[highlight] = "red"
        cex[highlight] = 1
    }
    x = res$baseMean
    y = res$log2FoldChange
    y[y > 2] = 2
    y[y < -2] = -2
    col[col == "red" & y < 0] = "darkgreen"
    par(mar = c(4, 4, 1, 1))

    suppressWarnings(
        plot(x, y, col = col, 
            pch = ifelse(res$log2FoldChange > 2 | res$log2FoldChange < -2, 1, 16), 
            cex = cex, log = "x",
            xlab = "baseMean", ylab = "log2 fold change")
    )
}

# make the volcano plot with some genes highlited
make_volcano = function(res, highlight = NULL) {
    col = rep("#00000020", nrow(res))
    cex = rep(0.5, nrow(res))
    names(col) = rownames(res)
    names(cex) = rownames(res)
    if(!is.null(highlight)) {
        col[highlight] = "red"
        cex[highlight] = 1
    }
    x = res$log2FoldChange
    y = -log10(res$padj)
    col[col == "red" & x < 0] = "darkgreen"
    par(mar = c(4, 4, 1, 1))

    suppressWarnings(
        plot(x, y, col = col, 
            pch = 16, 
            cex = cex,
            xlab = "log2 fold change", ylab = "-log10(FDR)")
    )
}

We use shinydashboard to organize the interactive heatmap components. It has a three-column layout:

We separatedly specifiy the three interaction heatmap components by originalHeatmapOutput(), subHeatmapOutput() and HeatmapInfoOutput().

One additional htmlOutput("note") is only for printing some friendly message in the app.

library(shiny)
library(shinydashboard)
body = dashboardBody(
    fluidRow(
        column(width = 4,
            box(title = "Differential heatmap", width = NULL, solidHeader = TRUE, status = "primary",
                originalHeatmapOutput("ht", height = 800, containment = TRUE)
            )
        ),
        column(width = 4,
            id = "column2",
            box(title = "Sub-heatmap", width = NULL, solidHeader = TRUE, status = "primary",
                subHeatmapOutput("ht", title = NULL, containment = TRUE)
            ),
            box(title = "Output", width = NULL, solidHeader = TRUE, status = "primary",
                HeatmapInfoOutput("ht", title = NULL)
            ),
            box(title = "Note", width = NULL, solidHeader = TRUE, status = "primary",
                htmlOutput("note")
            ),
        ),
        column(width = 4,
            box(title = "MA-plot", width = NULL, solidHeader = TRUE, status = "primary",
                plotOutput("ma_plot")
            ),
            box(title = "Volcanno plot", width = NULL, solidHeader = TRUE, status = "primary",
                plotOutput("volcanno_plot")
            ),
            box(title = "Result table of the selected genes", width = NULL, solidHeader = TRUE, status = "primary",
                DTOutput("res_table")
            )
        ),
        tags$style("
            .content-wrapper, .right-side {
                overflow-x: auto;
            }
            .content {
                min-width:1500px;
            }
        ")
    )
)

In previous code, we add self-defined CSS code to set the minimal width of the area that contains the boxes.

We will have several self-defined actions to respond brushing event on heatmap. We put all these actions into one brush_action() call. Note here we use env$row_index[row_index] to get the indices of the selected genes that correspond to the complete gene set.

library(DT)
library(GetoptLong) # for qq() function
brush_action = function(df, input, output, session) {
    
    row_index = unique(unlist(df$row_index))
    selected = env$row_index[row_index]
        
    output[["ma_plot"]] = renderPlot({
        make_maplot(res, selected)
    })

    output[["volcanno_plot"]] = renderPlot({
        make_volcano(res, selected)
    })

    output[["res_table"]] = renderDT(
        formatRound(datatable(res[selected, c("baseMean", "log2FoldChange", "padj")], rownames = TRUE), columns = 1:3, digits = 3)
    )

    output[["note"]] = renderUI({
        if(!is.null(df)) {
            HTML(qq("<p>Row indices captured in <b>Output</b> only correspond to the matrix of the differential genes. To get the row indices in the original matrix, you need to perform:</p>
<pre>
l = res$padj <= @{input$fdr} & 
    res$baseMean >= @{input$base_mean} & 
    abs(res$log2FoldChange) >= @{input$log2fc}
l[is.na(l)] = FALSE
which(l)[row_index]
</pre>
<p>where <code>res</code> is the complete data frame from DESeq2 analysis and <code>row_index</code> is the <code>row_index</code> column captured from the code in <b>Output</b>.</p>"))
        }
    })
}

Side bar contains settings for cutoffs to select significant genes, i.e., FDR, base mean and log2 fold change. We also add an action button to trigger the generation of heatmap.

ui = dashboardPage(
    dashboardHeader(title = "DESeq2 results"),
    dashboardSidebar(
        selectInput("fdr", label = "Cutoff for FDRs:", c("0.001" = 0.001, "0.01" = 0.01, "0.05" = 0.05)),
        numericInput("base_mean", label = "Minimal base mean:", value = 0),
        numericInput("log2fc", label = "Minimal abs(log2 fold change):", value = 0),
        actionButton("filter", label = "Generate heatmap")
    ),
    body
)

On the server side, we only respond to the action button, because heatmap generation does not happen instantly (1 to 2 seconds lag). We want to generate the heatmap only when all the cutoffs are configured by users.

ignoreNULL = FALSE is set to ensure that the heatmap will be generated right after the app is loaded.

server = function(input, output, session) {
    observeEvent(input$filter, {
        ht = make_heatmap(fdr = as.numeric(input$fdr), base_mean = input$base_mean, log2fc = input$log2fc)
        if(!is.null(ht)) {
            makeInteractiveComplexHeatmap(input, output, session, ht, "ht",
                brush_action = brush_action)
        } else {
            # The ID for the heatmap plot is encoded as @{heatmap_id}_heatmap, thus, it is ht_heatmap here.
            output$ht_heatmap = renderPlot({
                grid.newpage()
                grid.text("No row exists after filtering.")
            })
        }
    }, ignoreNULL = FALSE)
}

Everything is ready, and we generate the app with shinyApp():

shinyApp(ui, server)

This app can be directly generated with htShinyExample(10.5). A demonstration of the app is in following figure:

The function interactivate()

InteractiveComplexHeatmap has a generic function interactivate() which aims to provide an API to generate Shiny apps for objects that contain results for specific analysis. Currently, it only has an implementation for the DESeqDataSet object, which is from DESeq2 analysis. It can be simply used as:

library(DESeq2)
library(airway)
data(airway)

dds = DESeqDataSet(airway, design = ~ dex)
dds = DESeq(dds)

interactivate(dds)