Day 2 · 09:30 · 105 min

Quantitative visualisation

The grammar of graphics, for EXCO

Day 2 · 09:30

ggplot2 is a grammar, not a gallery. Once you can name the data, the aesthetics and the geom, you can build any board chart — and you can finally stop shipping bars that hide the distribution.

You will leave able to

  • Map variables to x, y, colour and fill, then add geoms.
  • Visualise a distribution (histogram, density, violin + box) instead of a mean.
  • Read an interaction in a breakfast-panel experiment that looks like a promotion test.

This session is the commercial rewrite of a lesson that used to involve haggis and porridge. You will use a breakfast panel from a national grocer: households that buy oats versus instant cereal, in modern trade versus the open market. The question management asked was *which breakfast drives higher category spend*. The data will refuse to answer that until you look at the distribution.

The grammar

R
library(tidyverse)
# data %>% ggplot(aes(...)) + geom_*() + labs()
breakfast <- read_csv("breakfast-panel.csv")
breakfast %>%
ggplot(aes(age, monthly_spend_ngn)) +
geom_point(alpha = 0.45) +
geom_smooth(method = "lm") +
labs(
title = "Age and category spend",
x = "Age",
y = "Monthly spend (NGN)"
)
  • data — a tidy table. One row, one household.
  • aes() — which variables become position, colour, size.
  • geom — what to draw: point, smooth, violin, col, tile.
  • Geoms are additive. A line of best fit on top of points is two geoms, not a new chart type.
Monthly category spend versus age. Colour is breakfast group — oats (navy) versus instant (gold). The slope is not a promotion effect.

Colour is a variable

R
breakfast %>%
ggplot(aes(age, monthly_spend_ngn, colour = group)) +
geom_point(alpha = 0.5) +
geom_smooth(method = "lm", se = FALSE) +
scale_colour_manual(values = c(oats = "#0B1F3A", instant = "#B8963E")) +
labs(colour = "Breakfast group")

The instant line is almost flat. The oats line is not. A single colour-blind scatter would have invited a single slope — and a single, wrong, national promotion.

Ban the bar

A bar of means with a ±1 SD error bar is the default in too many packs. It hides skew, hides multimodality, and invents a symmetry the data do not have. When EXCO asks *‘are the customers clustered or spread?’* the bar cannot answer.

R
breakfast %>%
ggplot(aes(group, monthly_spend_ngn, fill = trade_channel)) +
geom_violin(alpha = 0.45, position = position_dodge(width = 0.9)) +
geom_boxplot(width = 0.15, position = position_dodge(width = 0.9),
outlier.size = 0.8) +
scale_fill_manual(values = c(modern_trade = "#0B1F3A",
open_market = "#B8963E")) +
labs(
title = "Spend by breakfast group and trade channel",
y = "Monthly spend (NGN)",
fill = "Channel"
)
Mean monthly spend (NGN) by breakfast group and trade channel. Instant is almost flat across channels; oats is not. That is the interaction.

Reading the interaction: instant cereal spend barely moves between modern trade and the open market. Oats spend does. A campaign that treats ‘breakfast’ as one number will over-invest in a group whose variance is a channel story, not a brand story.

A banking twin

The same grammar applies to nps-by-channel.csv. Mobile is not ‘better’ because its mean is higher. Check whether the left tail — the detractors — is fatter on USSD, which is where a regulator will look.

Exercise 2.1

NPS distributions, not NPS means

Load nps-by-channel.csv. Draw a violinbox of nps by channel. Then a density of nps filled by channel with alpha = 0.45. Which channel has the nastier left tail?

  • ggplot(aes(channel, nps, fill = channel)) + geom_violin() + geom_boxplot(width = 0.15)
  • USSD will look ‘a bit worse’ on a bar. On a density it looks like a different product.

Labels, themes, files

R
last_plot() +
labs(
title = "NPS by channel",
subtitle = "Distributions, last 90 days — not a league table",
caption = "Source: Northridge Bank voice file, 2026 Q2 extract"
) +
theme_minimal(base_family = "Source Sans 3")
ggsave("nps-channel.png", width = 9, height = 5.5, units = "in")