Keys, mutating joins, and filtering joins to build a map-ready dataset
2026-07-05
Two files live in your data/ folder:
bfro_reports.csv — one row per sighting report (year, season, state, class, the written account), keyed by REPORT_NUMBERbfro_locations.csv — the coordinates for reports that were geocoded, keyed by numberleft_join() when the key columns are named differentlyinner, left, and full joinssemi_join() / anti_join() to filter, and build the map-ready tableHow to use this worksheet
- Work through the parts in order. Type the code into a new R script and run it line by line.
- Blocks marked ▶ Run this should be executed as written.
- Blocks marked ✏️ Your turn ask you to write, modify, or interpret.
- Day 1 = Parts 1–6. Day 2 = Parts 7–12.
🔮 Predict before you run — and type, don’t paste
Before every join, predict the number of rows you’ll get. Then type the code and check. A join that returns the wrong row count is the single most common data bug — predicting first is how you catch it.
🧩 Chunk 1 — Two tables & a key (Day 1, after lecture Chunk 1)
Parts 1–3: load both tables and clean the key.
▶ Run this:
✏️ Your turn:
reports_df: rows = cols =
locations_df: rows = cols =
Which table has more rows? Why might that be?
▶ Run this:
✏️ Your turn: What column links the two tables, and what is it called in each?
Key in reports_df:
Key in locations_df:
Are the names the same? Y / N
▶ Run this:
✏️ Your turn: REPORT_NUMBER was stored like 30680.0 but number is 30680. Why must we fix that before joining?
Your answer:
🧩 Chunk 2 — Your first join (Day 1, after lecture Chunk 2)
Parts 4–6: the left join and the accidental-key trap.
🔮 Predict first: After a
left_join, how many rows will you have (all reports? only matched?), and roughly how many will have a latitude?
▶ Run this:
✏️ Your turn:
Total rows after left_join:
Rows WITH coordinates:
Rows with NA coordinates:
▶ Run this (on purpose — watch the message):
✏️ Your turn: What column did dplyr announce it was joining on? Why is joining on index completely wrong here?
dplyr joined on:
Why that's wrong:
⚠️ Watch out! When dplyr prints “Joining with
by = ...” and you didn’t choose that column, stop and name your real key.
▶ Run this (the correct join):
✏️ Your turn: Look at the first rows — do any already show NA for latitude/longitude? What does that mean about those reports?
Your answer:
🛑 End of Day 1. You can join two tables on a named key. Day 2: the rest of the join family.
🧩 Chunk 3 — The join family (Day 2, after lecture Chunk 3)
Parts 7–9: inner vs left vs full.
🔮 Predict first: Rank these three by row count, most to fewest:
inner_join,left_join,full_join.
▶ Run this:
inner <- inner_join(reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
left <- left_join (reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
full <- full_join (reports_df, locations_df, by = c("REPORT_NUMBER" = "number"))
c(inner = nrow(inner), left = nrow(left), full = nrow(full))✏️ Your turn:
inner rows = left rows = full rows =
Was your ranking right? Y / N
✏️ Your turn: Fill in what each join does, in your own words.
inner_join keeps:
left_join keeps:
full_join keeps:
Which reports does inner_join DROP that left_join keeps?
✏️ Your turn: You want to make a map of all sightings, keeping every report even if some can’t be placed. Which join do you use, and why?
Join:
Why:
💡 Key idea: start from “which rows do I refuse to lose?” — that answer picks the join.
🧩 Chunk 4 — Filtering joins & the payoff (Day 2, after lecture Chunk 4)
Parts 10–12: anti/semi joins and the map-ready table.
anti_join() — what’s missing🔮 Predict first: How many reports have NO coordinates? (Total reports minus the number with a latitude from Part 4.)
▶ Run this:
✏️ Your turn:
Reports with no coordinates:
Did anti_join ADD any columns, or just filter rows?
semi_join() — keep only matches▶ Run this:
✏️ Your turn: How is semi_join different from inner_join? (Hint: look at the columns, not just the rows.)
Your answer:
▶ Run this:
# Reports + coordinates, ready for next week's map -------
bigfoot_mappable <- reports_df %>%
left_join(locations_df, by = c("REPORT_NUMBER" = "number")) %>%
filter(!is.na(latitude), !is.na(longitude))
bigfoot_mappable %>%
select(REPORT_NUMBER, STATE, SEASON, REPORT_CLASS, latitude, longitude) %>%
head()✏️ Your turn: How many rows are in your map-ready table? How does this compare to the original reports_df?
Map-ready rows:
Difference from reports_df, and why:
You should now be able to:
✏️ Your turn — before you move on: Run your whole script top to bottom. Does it run cleanly?
Ran cleanly? Y / N
If not, what error appeared:
Optional — do this if you finish early.
▶ Try this: join, then count sightings per state per class.
✏️ Your turn: Which state + class combination has the most reports?
Your answer:
✏️ Your turn: Use an anti_join the other direction to find locations that have no matching report. How many are there, and what might explain them?
Locations with no report:
Possible explanation:
by must be supplied / weird match → the key columns are named differently; use by = c("REPORT_NUMBER" = "number").as.integer() on REPORT_NUMBER) — text won’t match a number.distinct() the lookup key first.by = join_by(index) → you forgot to name your key; both tables share an index column.💡 Key idea: joins are how real datasets get built — one table of what, another of where, combined on a key. You just made the file you’ll map next week.
End of Worksheet 12. Next: Worksheet 13 — mapping the sightings you just assembled.