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
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.
Colour is a variable
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.
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" )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
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")