[1] 45 6
[1] 18 6
Keys, mutating joins, and filtering joins on a fisher reintroduction dataset
2026-09-10
Note
✅ Transition
Real data almost never lives in a single table. Fishers (Pekania pennanti, a large forest weasel) are reintroduced and then monitored: one table lists the animals released, another logs every field capture / collar event. Neither alone tells you a captured animal’s sex or where it came from. Joins stitch them on a shared key. (The data are simulated for teaching, patterned on real reintroduction programs.)
left_join(), inner_join(), full_join()semi_join(), anti_join()Tools today:
tidyverse (dplyr)Data (two tables, simulated):
fisher_captures.csv — one row per capture eventfisher_individuals.csv — one row per released fisherReference:
common_code/13_joinsTip
Day 1: keys + left_join. Day 2: the join family + filtering joins.
This lecture runs in four chunks over two days. After each chunk you switch to the activity and type the code yourself.
For every join, do three things:
Note
✅ Why bother?
by = c("a" = "b") as an argument — typing that syntax a dozen times is what makes it automatic under deadline pressure.left_join, inner/full, and semi/anti each get their own uncluttered mental slot.We will cover: why data comes in multiple tables, and what a key is.
Tip
🖐 After this chunk: Activity Parts 1–3 (load both tables, find the key).
[1] 45 6
[1] 18 6
captures_df — one row per capture event: date, site, mass, collar idindividuals_df — one row per released fisher: sex, age class, source, release site# A tibble: 3 × 4
animal_id capture_date site mass_kg
<chr> <date> <chr> <dbl>
1 f07 2018-03-25 Snoqualmie 1.4
2 f16 2018-04-11 Mt Rainier 5.52
3 f06 2018-04-29 Snoqualmie 2.06
# A tibble: 3 × 4
fisher_id sex site release_date
<chr> <chr> <chr> <date>
1 F01 F Mt Rainier 2019-05-10
2 F02 F Mt Rainier 2018-11-08
3 F03 M Snoqualmie 2018-02-07
A key is the column that identifies an animal and links the tables.
captures_df it’s animal_idindividuals_df it’s fisher_idNote
📖 Vocabulary
The key must be unique in the table you look things up in (individuals_df — one row per fisher). It is not unique in captures_df — a fisher can be caught many times.
# A tibble: 6 × 2
animal_id n
<chr> <int>
1 f01 3
2 f02 4
3 f03 2
4 f05 3
5 f06 5
6 f07 5
[1] 18
[1] 15
str_to_upper() — animal_id was "f07", fisher_id is "F07". Case must match or the join finds nothing"UNK" is not a real fisher — those events have no individual to attachWarning
⚠️ Watch out! A join silently fails to match if the keys differ in type (text vs number), case, or format — clean them first.
Load both tables, find the key in each, and fix its case. Predict how many capture events can be tied to a known fisher.
left_join() (Day 1)We will cover: joining the two tables when the key columns are named differently.
Tip
🖐 After this chunk: Activity Parts 4–6 (run the left join, read the result).
left_join() — Keep Every Capture EventNote
🔮 Predict first: captures_df has ~45 events; a few are unmarked animals. After a left_join (captures on the left), how many rows will we have, and how many will have a missing sex?
by = c("animal_id" = "fisher_id") — “match my animal_id to their fisher_id”left_join keeps every row on the left (all capture events)NA for sex, age, source📖 R4DS §19.3 — mutating joins
If I forget by = ..., dplyr looks for a shared column name — and both tables have a site column:
But site means different things: the capture site vs the release site. Joining on it matches a capture to every fisher released at that site — the row count explodes and the animal ids are nonsense. No error.
The fix — always name the real key:
Important
✅ Why show a silent wrong join?
Nothing throws an error, so nothing forces you to notice. captures_df and individuals_df both have a site column; dplyr matches on it happily and hands you hundreds of rows that pair a capture with the wrong animal. If the message “Joining with by = ...” names a column you didn’t pick, treat it as a bug report, not a status update.
Day 1 recap — you can now:
left_join() to keep every row on the leftDay 2 — the rest of the family:
inner_join vs left_join vs full_joinsemi_join and anti_join to filter instead of add columnsWe will cover: inner_join, left_join, and full_join, and how to choose.
Tip
🖐 After this chunk: Activity Parts 7–9 (compare the three joins).
Note
🔮 Predict first: Rank by row count: inner_join (only matches), left_join (all captures), full_join (all captures and every released fisher, even the never-recaptured ones).
# Same key, three different rules --------------------
key <- c("animal_id" = "fisher_id")
inner <- inner_join(captures_df, individuals_df, by = key)
left <- left_join (captures_df, individuals_df, by = key)
full <- full_join (captures_df, individuals_df, by = key)
c(inner = nrow(inner), left = nrow(left), full = nrow(full))inner left full
42 45 49
| Join | Keeps |
|---|---|
inner_join |
only events with a known animal |
left_join |
all capture events |
full_join |
all events and every released fisher |
inner drops the unmarked-animal events; full adds back the fishers that were released but never recaptured.
left_join — the default. Keep everything on your main table, add what you can. (Event-level analysis: keep all captures.)inner_join — only rows with a match. (When an unattributed event would ruin the analysis.)full_join — keep everything from both. (To audit what’s missing on each side — which fishers vanished.)Note
✅ Choosing among the three
Ask which rows you’d be upset to lose. Drop an unmarked capture and you undercount effort; drop a never-recaptured fisher from a survival analysis and you bias it badly. Match the join to the question.
📖 R4DS §19.3–19.4
Run all three joins, compare the row counts, and decide which one you’d use to build a map. Predict each count first.
We will cover: semi_join and anti_join — joins that filter rather than add columns.
Tip
🖐 After this chunk: Activity Parts 10–12 (find the missing reports, build the map table).
anti_join() — What Didn’t Match?Note
🔮 Predict first (two ways): how many capture events have no known animal? How many released fishers were never recaptured?
[1] 3
# A tibble: 4 × 3
fisher_id sex site
<chr> <chr> <chr>
1 F04 F Snoqualmie
2 F11 F Gifford Pinchot
3 F17 M Snoqualmie
4 F18 F Mt Rainier
anti_join keeps the left rows with no match — “what’s missing?”📖 R4DS §19.5 — filtering joins
semi_join() — Keep Only What Matchedsemi_join keeps left rows that have a match — again, no new columnssemi/anti as a filter driven by another tablesemi + anti on the same table split it in two: recaptured vs never seen# Every KNOWN-individual capture, with the animal's attributes
fisher_analysis <- captures_df %>%
inner_join(individuals_df, by = c("animal_id" = "fisher_id")) %>%
mutate(days_since_release = as.numeric(capture_date - release_date))
fisher_analysis %>%
select(animal_id, sex, age_class, capture_date, days_since_release, mass_kg) %>%
head()# A tibble: 6 × 6
animal_id sex age_class capture_date days_since_release mass_kg
<chr> <chr> <chr> <date> <dbl> <dbl>
1 F07 F juvenile 2018-03-25 -244 1.4
2 F16 F juvenile 2018-04-11 -139 5.52
3 F06 F adult 2018-04-29 -301 2.06
4 F09 F juvenile 2018-05-21 -165 2.49
5 F06 F adult 2018-05-30 -270 1.4
6 F02 F juvenile 2018-06-08 -153 4.21
You just built a real dataset from two raw ones. fisher_analysis didn’t exist in either source file — every row is a capture event plus the animal’s sex, age, and days-since-release, ready for a model.
Here inner_join is right: an event with no known animal can’t go into an individual-level analysis.
Use anti_join both directions to find the gaps, semi_join to see who was recaptured, and build the combined table. Predict the counts first.
Concepts:
by = c("a" = "b")left (all left), inner (matches), full (all)semi (has a match), anti (no match)by = ... you didn’t choose is a bugR skills:
left_join(), inner_join(), full_join()semi_join(), anti_join()str_to_upper() (and as.integer(), str_trim()) for key hygieneReferences:
common_code/13_joinsUp next — Mapping:
sf and geom_sf()