Why Visualisation Matters
in Bioinformatics
Understand why plots are not optional in genomics, learn the grammar of graphics, and build your first publication-ready chart with ggplot2.
1 · Why Visualisation Matters
🔬 The core idea
Bioinformatics produces enormous tables of numbers — thousands of gene expression values, millions of variant calls, billions of aligned reads. The human brain cannot process raw tables at that scale. A well-chosen plot can reveal a pattern in seconds that would take hours to find in a spreadsheet. Visualisation is not decoration — it is the primary tool for biological discovery and the main way reviewers and readers judge your results.
Consider a typical RNA-seq experiment on sorghum under drought stress. DESeq2 outputs a table of 30,000 genes with log₂ fold-change values and adjusted p-values. Without a volcano plot, you cannot see at a glance which genes are both statistically significant and biologically meaningful. Without a heatmap, you cannot see whether the top 50 DE genes cluster samples correctly. Without a PCA plot, you cannot check whether your replicates group together as expected.
Every stage of a bioinformatics workflow has a characteristic plot type that serves as a quality check:
| Workflow stage | Key plot | What it checks |
|---|---|---|
| Raw reads QC | Per-base quality score plot | Sequencing quality, adapter contamination |
| Alignment | Coverage track (genome browser) | Read depth distribution, mapping artefacts |
| Exploratory DE | PCA / sample distance heatmap | Batch effects, outliers, replicate consistency |
| Differential expression | Volcano plot, MA plot | Fold-change vs significance landscape |
| Gene sets | Heatmap with clustering | Expression patterns across conditions |
| GWAS | Manhattan plot, QQ plot | Genomic inflation, significant loci |
| scRNA-seq | UMAP / t-SNE | Cell type clustering, trajectory |
2 · Plot Types in Bioinformatics
Before writing any code, you should be able to look at a biological question and immediately know which plot type answers it. This is a skill most people never learn explicitly — they just imitate what they have seen in papers. This module will make it deliberate.
| Plot type | Best for | Primary R tool |
|---|---|---|
| Scatter plot | Two continuous variables, correlation | ggplot2 + geom_point() |
| Line plot | Time series, ordered sequences | ggplot2 + geom_line() |
| Bar / column chart | Comparing counts or means across groups | ggplot2 + geom_col() |
| Boxplot | Distribution summary, outlier detection | ggplot2 + geom_boxplot() |
| Violin plot | Full distribution shape by group | ggplot2 + geom_violin() |
| Heatmap | Matrix data — expression, correlation | pheatmap, ComplexHeatmap |
| Volcano plot | DE results: significance vs fold-change | EnhancedVolcano, ggplot2 |
| MA plot | DE results: mean vs fold-change (bias check) | DESeq2::plotMA() |
| Manhattan plot | GWAS: chromosomal position vs –log₁₀(p) | qqman, CMplot |
| QQ plot | GWAS: expected vs observed p-values | qqman |
| PCA plot | Sample clustering, batch effects | ggplot2 + prcomp() |
| UMAP / t-SNE | scRNA-seq cell embedding | Seurat, Scanpy |
| Genome track | Read coverage, gene models, variants | Gviz, ggbio |
| Network plot | Protein interactions, pathway connections | igraph, ggraph |
3 · The Grammar of Graphics
🧠 Why this concept matters
In the 1990s, statistician Leland Wilkinson wrote a book called The Grammar of Graphics. It argued that every statistical chart — no matter how complex — is built from the same small set of components, combined in different ways. Hadley Wickham implemented this idea as ggplot2 in 2005, and it became the most widely used scientific plotting library in the world. Once you understand the grammar, you stop memorising individual plot recipes and start composing any plot you can imagine.
The grammar has seven layers. Every ggplot2 call is just a combination of these:
| Layer | ggplot2 component | Example |
|---|---|---|
| 1. Data | ggplot(data = ...) | Your data frame |
| 2. Aesthetics | aes(x = , y = , colour = ) | Which columns map to which visual properties |
| 3. Geometry | geom_point(), geom_line() | The shape used to draw the data |
| 4. Statistics | stat_smooth(), stat_summary() | Transformations applied before drawing |
| 5. Scales | scale_colour_manual() | How data values map to visual values |
| 6. Coordinates | coord_flip(), coord_polar() | The coordinate system |
| 7. Facets | facet_wrap(), facet_grid() | Split into multiple panels by a variable |
You do not need all seven layers in every plot. The minimum is Data + Aesthetics + Geometry. Every other layer is optional and added with +. This is why ggplot2 code looks like stacking building blocks:
# The minimum viable ggplot2 call ggplot(data = my_data, # Layer 1: data aes(x = gene_expression, # Layer 2: aesthetics y = fold_change, colour = condition)) + geom_point() # Layer 3: geometry # Add more layers with + ggplot(data = my_data, aes(x = gene_expression, y = fold_change, colour = condition)) + geom_point(size = 2, alpha = 0.7) + # geometry geom_smooth(method = "lm") + # statistics scale_colour_manual( # scale values = c("drought" = "#c0392b", "control" = "#2980b9")) + facet_wrap(~chromosome) + # facet theme_classic() # theme (bonus layer)
+ operator in ggplot2 works like the pipe (|>) in data wrangling — it passes the plot object to the next layer. The difference is that + adds a layer rather than transforming data. Always put the + at the end of a line, never at the start of the next line, or R will throw an error.
4 · ggplot2 Layers in Depth
Let's go through each layer with bioinformatics examples so you know what each one does before you start combining them.
Layer 1 — Data
ggplot2 expects a tidy data frame: one row per observation, one column per variable. If your RNA-seq results are in a matrix (genes as rows, samples as columns), you need to convert them to tidy format first using pivot_longer() from the tidyr package.
# Wide format (NOT what ggplot2 wants) # gene sample1 sample2 sample3 # Sobic1 12.4 14.1 11.8 # Sobic2 3.2 2.8 4.1 # Tidy format (what ggplot2 wants) # gene sample count # Sobic1 sample1 12.4 # Sobic1 sample2 14.1 # Sobic1 sample3 11.8 library(tidyr) tidy_counts <- wide_matrix |> as.data.frame() |> tibble::rownames_to_column("gene") |> pivot_longer( cols = starts_with("sample"), names_to = "sample", values_to = "count" )
Layer 2 — Aesthetics (aes)
Aesthetics are the mappings between columns in your data frame and visual properties of the plot. The most common aesthetics are:
| Aesthetic | Controls | Bioinformatics use |
|---|---|---|
| x, y | Position on axes | log₂FC vs –log₁₀(padj) in volcano plot |
| colour | Outline / line colour | Up-regulated vs down-regulated genes |
| fill | Interior fill colour | Sample groups in boxplot |
| size | Point or line size | Expression level as bubble size |
| alpha | Transparency (0–1) | Reducing overplotting in large datasets |
| shape | Point shape (1–25) | Different tissues or conditions |
| label | Text label | Gene names on significant points |
🔑 aes() vs direct argument
Put a property inside aes() when it maps to a data column — it will vary per point. Put it outside aes() when you want a fixed value for all points. For example: geom_point(aes(colour = condition)) colours by the condition column, but geom_point(colour = "red") makes everything red. This is the single most common source of confusion for ggplot2 beginners.
Layer 3 — Geometry (geom_*)
The geometry determines the visual shape drawn for each observation. Here are the most important geoms for bioinformatics:
# Points — scatter, volcano, PCA geom_point(size = 2, alpha = 0.6) # Lines — time series, expression trends geom_line(linewidth = 1) # Horizontal/vertical reference lines geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") geom_vline(xintercept = c(-1, 1), linetype = "dashed", colour = "grey50") # Boxplot — distribution by group geom_boxplot(outlier.size = 1, fill = "white") # Violin — full distribution shape geom_violin(trim = FALSE) # Bars — counts, means geom_col() # uses actual y values geom_bar() # counts rows (stat = "count") # Text labels — gene names geom_text(aes(label = gene_name), size = 3, vjust = -1) geom_label(aes(label = gene_name)) # boxed label # Smooth regression line geom_smooth(method = "lm", se = FALSE)
5 · Your First Plot: Sorghum Gene Expression
📋 What we will build
We will create a simulated sorghum RNA-seq dataset with gene expression values under two conditions (drought stress vs control), then build a scatter plot that shows the relationship between the two conditions. This is a common first sanity check — genes with similar expression in both conditions should cluster around the diagonal.
Step 1 — Install and load packages
# Install once (skip if already installed) install.packages(c("ggplot2", "dplyr", "tidyr", "patchwork")) # Load every session library(ggplot2) library(dplyr) library(tidyr)
Step 2 — Create simulated sorghum expression data
set.seed(42) # reproducible random numbers n_genes <- 500 sorghum_expr <- data.frame( gene_id = paste0("Sobic.", sprintf("%04d", 1:n_genes)), control = rnorm(n_genes, mean = 8, sd = 2), # log2 CPM values drought = rnorm(n_genes, mean = 8, sd = 2) ) # Add a fold-change column (drought vs control) sorghum_expr <- sorghum_expr |> mutate( log2FC = drought - control, is_de = abs(log2FC) > 1, # simple DE flag direction = case_when( log2FC > 1 ~ "Up in drought", log2FC < -1 ~ "Down in drought", TRUE ~ "Not DE" ) ) head(sorghum_expr)
Step 3 — Build the scatter plot
# Build the plot layer by layer p1 <- ggplot( data = sorghum_expr, aes(x = control, # x axis: control expression y = drought, # y axis: drought expression colour = direction) # colour by DE status ) + geom_point(size = 1.8, alpha = 0.7) + # draw points geom_abline(slope = 1, intercept = 0, # diagonal = no change linetype = "dashed", colour = "grey40", linewidth = 0.7) + scale_colour_manual( # custom colours values = c( "Up in drought" = "#c0392b", "Down in drought" = "#2980b9", "Not DE" = "#bdc3c7" ) ) + labs( title = "Sorghum gene expression: drought vs control", subtitle = "500 simulated genes · values in log2 CPM", x = "Control (log2 CPM)", y = "Drought (log2 CPM)", colour = "Expression change", caption = "Points above diagonal: higher in drought" ) + theme_classic(base_size = 13) + # clean theme theme( plot.title = element_text(face = "bold"), legend.position = "top" ) # Display the plot p1 # Save to file (publication quality) ggsave("plots/sorghum_scatter.png", plot = p1, width = 7, height = 6, dpi = 300)
dpi = 300 for print-quality output. Journals typically require 300–600 DPI for raster figures. For vector output (scalable to any size), use ggsave("plot.pdf") — PDF is always vector by default in ggplot2.
6 · Mapping Aesthetics to Data
Let's extend the plot to encode a third variable (gene length) as point size and a fourth variable (chromosome) as facets. This demonstrates how ggplot2 allows you to visualise four dimensions in a single 2D plot.
# Add chromosome and gene length to simulated data sorghum_expr <- sorghum_expr |> mutate( chromosome = paste0("Chr", sample(1:10, n_genes, replace = TRUE)), gene_length = runif(n_genes, 500, 5000) # bp ) # Select just chr 1-4 for a cleaner faceted plot sub_data <- sorghum_expr |> filter(chromosome %in% paste0("Chr", 1:4)) p2 <- ggplot(sub_data, aes(x = control, y = drought, colour = direction, size = gene_length)) + # 4th aesthetic geom_point(alpha = 0.6) + geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "grey50", linewidth = 0.5) + facet_wrap(~chromosome, nrow = 2) + # split by chromosome scale_colour_manual( values = c("Up in drought" = "#c0392b", "Down in drought" = "#2980b9", "Not DE" = "#bdc3c7")) + scale_size_continuous(range = c(0.5, 4), name = "Gene length (bp)") + labs(title = "Expression by chromosome — Sorghum drought vs control", x = "Control (log2 CPM)", y = "Drought (log2 CPM)", colour = "DE status") + theme_classic(base_size = 12) + theme(strip.background = element_rect(fill = "#e8f5ee"), strip.text = element_text(face = "bold"), legend.position = "bottom") p2 ggsave("plots/sorghum_facet_scatter.png", p2, width = 10, height = 7, dpi = 300)
7 · Choosing Geoms: The Decision Framework
When you face a new plotting task, ask these three questions in order:
| Question | Answer → geom |
|---|---|
| How many variables am I visualising? | 1 continuous → histogram/density; 1 categorical → bar; 2 continuous → scatter; 1 continuous + 1 categorical → boxplot/violin |
| What is my message? | Comparison → bar/boxplot; Relationship → scatter; Distribution → histogram/violin; Change over time → line; Part of whole → bar (NOT pie) |
| How many data points? | < 100: show all points; 100–10,000: semi-transparent points + summary; > 10,000: density plot or hexbin |
🌱 Sorghum example
In a sorghum drought experiment with 3 replicates per condition (6 samples total), you want to compare library sizes. With only 6 points, a bar chart works fine — show each bar as one sample. If you had 60 samples across 10 conditions, switch to a boxplot per condition. If you had 600 samples from a meta-analysis, use a violin plot to show the full distribution.
Here is a practical comparison of the three distribution geoms:
library(patchwork) # for combining multiple plots # Simulate library sizes for sorghum samples lib_data <- data.frame( sample = paste0("S", 1:30), condition = rep(c("Control", "Drought"), each = 15), lib_size = c(rnorm(15, 20e6, 3e6), rnorm(15, 18e6, 4e6)) ) # Plot A: boxplot pA <- ggplot(lib_data, aes(x = condition, y = lib_size / 1e6, fill = condition)) + geom_boxplot(alpha = 0.7) + labs(title = "A: Boxplot", y = "Library size (M reads)", x = NULL) + scale_fill_manual(values = c("#2980b9", "#c0392b")) + theme_classic() + theme(legend.position = "none") # Plot B: violin pB <- ggplot(lib_data, aes(x = condition, y = lib_size / 1e6, fill = condition)) + geom_violin(trim = FALSE, alpha = 0.7) + geom_boxplot(width = 0.1, fill = "white") + # inner boxplot labs(title = "B: Violin + boxplot", y = NULL, x = NULL) + scale_fill_manual(values = c("#2980b9", "#c0392b")) + theme_classic() + theme(legend.position = "none") # Plot C: jitter (best for small n) pC <- ggplot(lib_data, aes(x = condition, y = lib_size / 1e6, colour = condition)) + geom_jitter(width = 0.15, size = 3, alpha = 0.8) + stat_summary(fun = mean, geom = "crossbar", width = 0.4, colour = "black") + labs(title = "C: Jitter + mean bar", y = NULL, x = NULL) + scale_colour_manual(values = c("#2980b9", "#c0392b")) + theme_classic() + theme(legend.position = "none") # Combine with patchwork pA + pB + pC + plot_layout(ncol = 3) ggsave("plots/distribution_comparison.png", width = 12, height = 5, dpi = 300)
+ to place plots side by side, / to stack them vertically, and plot_annotation(tag_levels = "A") to add A, B, C panel labels automatically.
8 · Exercises
You have RNA-seq data from 4 sorghum tissues (root, leaf, seed, stem) with 20 samples per tissue. You want to compare the distribution of total read counts per sample across tissues. Which geom is most appropriate — bar chart, boxplot, violin, or scatter? Why?
Show answer
This code is supposed to colour all points red, but instead it creates a legend with one category called "red". Find and fix the bug:
ggplot(sorghum_expr, aes(x = control, y = drought, colour = "red")) + geom_point()
Show answer
colour = "red" outside of aes() and into geom_point() directly. Inside aes(), R treats the string "red" as a column name / data value — it maps the constant "red" to all points and assigns a colour from the default palette. The corrected code:ggplot(sorghum_expr, aes(x = control, y = drought)) +
geom_point(colour = "red")
Take the p1 scatter plot from Section 5 and add a linear regression line for each DE direction group (Up, Down, Not DE) separately. The line should be drawn without a confidence interval ribbon.
Show answer
p1 + geom_smooth(method = "lm", se = FALSE, linewidth = 0.8)Because
colour = direction is set in the global aes(), geom_smooth() automatically fits a separate line per group (colour group). Setting se = FALSE removes the confidence interval ribbon. linewidth = 0.8 makes the line visible without dominating the points.
Create the directory structure for the bioinformatics-visualisation repo, then run the simulated data code and save both plots to a plots/ subfolder. Verify the files were created.
Show answer
mkdir -p ~/bioinformatics-visualisation/plots cd ~/bioinformatics-visualisation Rscript lesson-01-visualisation-intro.R ls -lh plots/You should see
sorghum_scatter.png, sorghum_facet_scatter.png, and distribution_comparison.png in the plots/ directory.