Intermediate track — twenty quests on mutate, group_by, lead/lag, joins, and pivots.
Author
Pablo Fuenzalida
Published
July 21, 2026
Functional programming · Intermediate (dplyr)
dplyr verbs read like sentences: select columns, filter rows, mutate new variables, summarise within groups. This level builds verb-by-verb mental models so you can read, write, and fix pipelines on reef, fishery, acoustic, and biologging data.
Learning progresses when you connect new syntax to problems you already solved in base R (Bransford et al., 2000). Each quest (~10 minutes) ends with a prediction you can check: row counts, column names, or a plot you recognise from the field.
Treat dplyr as a contract with your data: every verb documents an intent. Choosing which steps belong in a pipeline is the same scientific reasoning habit Klahr and Dunbar describe in experiment design — applied to code.
Harder verbs (group_by, lead/lag, case_when, across, joins, pivots) include multiple worked examples on different ecological tables.
Q1 · select() & rename()
The select() verb keeps or drops columns by name, type, or tidy helpers like starts_with(). rename() changes names without touching other columns — cleaner than reassigning names(df) when AI adds a typo.
Before typing select(), list which columns your analysis actually needs. Extra columns increase cognitive load when reading joins downstream.
We trim the reef table to identifiers and abundance, renaming count to n_fish for publication-ready headers.
reef_sl <- reef |># start with reef transect dataselect(transect_id, site, species, count, depth_m) |># keep core columns onlyrename(n_fish = count) # clearer name for countsglimpse(reef_sl) # confirm column names
filter() keeps rows where a logical condition is TRUE. Unlike base subsetting, dplyr verbs error helpfully when you use = instead of == in many contexts — but AI still generates wrong variable names.
Always check nrow() before and after a filter; it is cheap feedback on whether your logic matches the field protocol.
Keep East site transects with depth at least five metres.
reef_east_deep <- reef |># pipe data into filterfilter(site =="East", depth_m >=5) # both conditions must holdnrow(reef_east_deep) # rows retained
[1] 10
distinct(reef_east_deep, transect_id) # which transects survived
transect_id
1 T03
2 T06
Q3 · mutate() new columns
mutate() adds or replaces columns while keeping all rows — the workhorse for unit conversions, binary flags, and intermediate biology.
Mutate is vectorised: each expression sees whole columns. That matches how R thinks and differs from row-wise loops unless you call rowwise().
Add kg from tonnes on fishery landings and a heavy-landing flag.
fishery_mut <- fishery |># fisheries landing tablemutate(kg = tonnes *1000, # metric conversionheavy = tonnes >80# logical flag for large trips )summary(fishery_mut$kg) # QC numeric range
Min. 1st Qu. Median Mean 3rd Qu. Max.
13900 42700 56950 60358 79850 144500
Q4 · transmute() keep only new
transmute() is mutate that drops all previous columns — useful when you only want derived variables for modelling or export.
AI sometimes chains select() after mutate() when transmute() would be clearer; recognising the verb saves clutter.
Extract animal id and scaled detections from acoustic data.
acoustic_derived <- acoustic |># acoustic telemetry rowstransmute( animal_id, # keep tag iddetections_scaled = detections /max(detections) # scale to 0-1 for plotting )head(acoustic_derived) # only new columns remain
distinct() returns unique rows, optionally by a subset of columns — dplyr’s answer to unique() on data frames.
Use it after joins to confirm you did not duplicate keys — a common AI join mistake.
List unique site–depth combinations on reef surveys.
sites_depths <- reef |># reef datadistinct(site, depth_m) |># one row per comboarrange(site, depth_m) # readable tablesites_depths # print result
site depth_m
1 East 3.4
2 East 4.1
3 East 6.3
4 East 9.9
5 North 6.2
6 North 7.7
7 North 7.9
8 North 8.5
9 South 5.2
10 South 9.6
11 South 10.4
12 South 11.9
Q7 · slice() row index
slice() picks rows by position; helpers like slice_max() pick top values per group when combined with group_by().
Index slicing is fragile if row order changes — document why order is stable before slicing.
Take the deepest reading per burst from biologging.
deepest <- biolog |># all burst secondsgroup_by(burst_id) |># partition by burstslice_max(depth_m, n =1, with_ties =FALSE) # deepest single row eachdeepest # one row per burst_id
lag() shifts values backward in row order; lead() shifts forward — essential for step changes in depth or pitch along seconds.
They require sorted data within groups — you arranged biologging in Q5 for this reason.
Compute second-to-second depth change within bursts.
depth_steps <- biolog_ord |># sorted biolog datagroup_by(burst_id) |># independent time within burstmutate(depth_change = depth_m -lag(depth_m)) |># current minus previousfilter(!is.na(depth_change)) # drop first second each bursthead(depth_steps, 8) # inspect changes
Example 3 — fishery (file order):lag(tonnes) within species to compare consecutive landing records in the CSV order (not true calendar time — always check sort first).
group_modify() passes each group as a tibble to a function that returns a tibble — when summarise() is too limited for custom row-wise tables.
Use sparingly; it is slower than vectorised summaries but mirrors base lapply on splits explicitly.
Per species on reef: min, max, and range of counts.
sp_range <- reef |># species countsgroup_by(species) |># one group per speciesgroup_modify(~ { # .x is species subsettibble( # return tibble shapemin_n =min(.x$count),max_n =max(.x$count),range_n =max(.x$count) -min(.x$count) ) })sp_range # custom table per species
left_join() keeps all rows from the left table and adds matching columns from the right — the join ecologists use for tagging metadata.
Always join on explicit keys; verify row count unchanged unless you expect duplication.
Attach a synthetic port region lookup to fishery landings.
ports <-tibble( # small lookup tableport =c("Cairns", "Broome", "Mooloolaba"),region =c("GBR", "NW", "SEQ"))fishery_geo <- fishery |># landings left sideleft_join(ports, by ="port") # add region columncount(fishery_geo, region) # rows per region
region n
1 GBR 9
2 NW 9
3 SEQ 6
Example 2 — acoustic: join tag metadata (hypothetical deployment reef) onto detections.
replace_na() swaps NA for a value; coalesce() picks the first non-NA from several columns — handy when merging sensor streams.
Document why NA existed: missing vs not applicable changes whether replace is scientific.
Replace NA in optional column after join simulation on reef depths.
reef_na <- reef |># original reefmutate(depth_m =if_else(species =="Grouper", NA_real_, depth_m)) # inject NA demoreef_fix <- reef_na |># repair for plottingmutate(depth_m =replace_na(depth_m, median(reef$depth_m, na.rm =TRUE)))sum(is.na(reef_fix$depth_m)) # should be zero
[1] 0
Q20 · dplyr capstone pipeline
Capstone: one readable pipeline from raw landings to a figure — the shape of a methods supplement AI should not invent without your sign-off.
Read bottom-up: what object enters ggplot(), what one row represents, which filters happened.
Fishery totals by species and port with plot.
cap <- fishery |># start landingsfilter(year >=2019) |># recent yearsgroup_by(species, port) |># reporting cellssummarise(total_t =sum(tonnes), .groups ="drop") |># summed tonnesarrange(desc(total_t)) # largest firstggplot(cap, aes(x =reorder(species, total_t), y = total_t, fill = port)) +geom_col(position ="dodge") +# compare portscoord_flip() +# readable labelslabs(title ="Landings by species and port (2019+)", x =NULL, y ="Tonnes")