04 · ggplot2 Basics¶
Level 1's base plotting (plot(),
barplot(), hist()) is quick and dependency-free, but each function has
its own quirky set of arguments, and combining multiple layers (points plus
a trend line plus grouping by color) gets awkward fast. ggplot2
implements a different idea — the "grammar of graphics" — where every plot
is built by adding layers to a base declaration of data and mappings.
Once the grammar clicks, it scales to far more complex plots with barely
more code than a simple one.
library(ggplot2)
sales <- data.frame(
quarter = rep(c("Q1", "Q2", "Q3", "Q4"), each = 2),
region = rep(c("East", "West"), times = 4),
revenue = c(120, 90, 150, 130, 170, 140, 200, 160)
)
The grammar: ggplot() + aes() + geom_*()¶
Every ggplot2 plot follows the same skeleton:
ggplot(data, aes(...))declares which data and which columns map to which visual properties (x position, y position, color, size, ...) — this alone draws nothing.geom_col()(orgeom_point(),geom_line(), ...) is a layer that says how to draw it — bars, points, lines.+adds layers.
This separation is the entire idea: swap geom_col() for geom_line() and
you get a completely different chart from the exact same data declaration.
Scatter plots — geom_point()¶
students <- data.frame(
hours_studied = c(1, 2, 3, 4, 5, 6, 7, 8),
score = c(52, 58, 63, 70, 75, 82, 88, 91)
)
ggplot(students, aes(x = hours_studied, y = score)) +
geom_point(color = "steelblue", size = 3) +
labs(title = "Study Hours vs. Score", x = "Hours Studied", y = "Exam Score")
labs() sets the title and axis labels — the ggplot2 equivalent of base
R's main/xlab/ylab arguments, but added as its own layer rather than
passed to the plotting call itself.
Bar charts — geom_col() vs. geom_bar()¶
This is a very common point of confusion:
# geom_col(): the data ALREADY has one row per bar with the height you want
ggplot(sales, aes(x = quarter, y = revenue)) +
geom_col(fill = "seagreen")
# geom_bar(): counts rows per category automatically -- no y needed
ggplot(sales, aes(x = quarter)) +
geom_bar() # height = number of rows per quarter, not revenue
geom_col() plots a value you already computed; geom_bar() computes a
count for you. Using geom_bar() when you meant geom_col() produces a
chart of row counts instead of your actual values — a genuinely common
mistake, since both draw bars and the error is silent (no error message,
just a wrong-looking chart).
Mapping a column to color — grouping for free¶
ggplot(sales, aes(x = quarter, y = revenue, fill = region)) +
geom_col(position = "dodge") +
labs(title = "Revenue by Quarter and Region", x = "Quarter", y = "Revenue")
Mapping region to fill inside aes() automatically colors and legends
each region — no manual loop over groups, no manual legend construction.
position = "dodge" places same-quarter bars side by side instead of
stacked (the default for geom_col() with a fill grouping).
Line plots over time¶
temps <- data.frame(
day = 1:7,
temp = c(15, 17, 16, 19, 22, 21, 18)
)
ggplot(temps, aes(x = day, y = temp)) +
geom_line(color = "darkred", linewidth = 1) +
geom_point(color = "darkred") +
labs(title = "Temperature Over a Week", x = "Day", y = "°C")
Layers stack: this plot has both a line and points on top of it, from two
separate geom_*() calls sharing the same aes() mapping declared in
ggplot().
Histograms and boxplots¶
set.seed(42)
exam_scores <- data.frame(score = rnorm(200, mean = 75, sd = 10))
ggplot(exam_scores, aes(x = score)) +
geom_histogram(bins = 15, fill = "cornflowerblue", color = "white")
groups <- data.frame(
group = rep(c("A", "B"), each = 5),
score = c(88, 92, 79, 65, 85, 70, 95, 60, 55, 80)
)
ggplot(groups, aes(x = group, y = score, fill = group)) +
geom_boxplot()
Facets — one small plot per category, automatically¶
facet_wrap(~ column) splits one plot into a grid of small multiples, one
panel per unique value of column, all with matching axes by default —
the ggplot2 way to compare groups side by side without a manual loop or a
cluttered single plot.
Themes — controlling overall appearance¶
ggplot(sales, aes(x = quarter, y = revenue, fill = region)) +
geom_col(position = "dodge") +
theme_minimal() + # a clean built-in theme -- try theme_bw(), theme_classic() too
labs(title = "Revenue by Quarter and Region")
Saving a plot — ggsave()¶
p <- ggplot(sales, aes(x = quarter, y = revenue)) +
geom_col(fill = "seagreen") +
labs(title = "Quarterly Revenue")
ggsave("revenue.png", plot = p, width = 8, height = 6, dpi = 150)
Unlike base R's png()/dev.off() pair, ggsave() takes a plot object
directly and infers the file format from the extension — no explicit device
open/close needed. Saving p as a variable first (instead of printing the
plot immediately) is also useful for reusing or modifying a plot before
deciding to save it.
ggplot2 vs. base R cheat sheet¶
| Task | Base R | ggplot2 |
|---|---|---|
| Declare data + mapping | (implicit in each call) | ggplot(data, aes(x=, y=)) |
| Scatter | plot(x, y) |
+ geom_point() |
| Line | plot(x, y, type="l") |
+ geom_line() |
| Bar (pre-computed values) | barplot(x) |
+ geom_col() |
| Bar (row counts) | table(x) then barplot() |
+ geom_bar() |
| Histogram | hist(x) |
+ geom_histogram() |
| Boxplot | boxplot(x, y) |
+ geom_boxplot() |
| Group by color | manual loop/legend | aes(fill = group) |
| Small multiples | manual par(mfrow=) + loop |
+ facet_wrap(~ group) |
| Save to file | png() / dev.off() |
ggsave("file.png", plot) |
How It Actually Works¶
ggplot2 doesn't draw anything the moment you write ggplot(df, aes(...))
— it builds up a ggplot object that's just a list: a data frame, a
mapping, and a growing list of layers, scales, facets, and a theme.
Actual rendering happens only when the object is printed (auto-triggered
at the console, or explicitly), at which point ggplot2 runs its
layered grammar pipeline: for each layer it (1) applies any
statistical transformation (stat_*, e.g. binning for histograms), (2)
maps the transformed data through each aesthetic's scale (a function
from data values to visual properties like x-position or color, built
by inspecting the full range of the data first), and (3) hands the result
to a geom_*'s draw_panel() method, which emits actual grid graphical
objects (grobs).
This is why scales are computed globally across all layers before any drawing happens — ggplot2 has to see every layer's data to know the overall x/y range and color domain before it can decide where "halfway" on an axis or "middle of the color gradient" actually falls, which is also why adding a layer with wildly different data can suddenly rescale a plot you thought was already finished.
Exercise¶
Using the sales data frame from the top of this page: build a single
ggplot2 chart that shows revenue per quarter, grouped/colored by region,
faceted is not required here — instead use position = "dodge" so both
regions' bars sit side by side within each quarter. Add a title and axis
labels with labs(), apply theme_minimal(), and save the result to
quarterly_revenue.png with ggsave(). Then make a second version that
uses facet_wrap(~ region) instead of dodged bars, and write one sentence
comparing which version communicates the East/West comparison better.