Week 3 · Tuesday, Oct 13 · Lecture
| Time | What |
|---|---|
| 1:30 | Warm-up: trace a loop on paper |
| 1:40 | Six ways to pick elements |
| 2:00 | %in%, and why == lies |
| 2:10 | Lists, models, and data frames: the comma matters |
| 2:30 | A cleaning pass on a messy file, one checked step at a time |
| 2:55 | scripts/clean-survey.R: cleaning you can source() |
Learning goals: pick exactly the rows, columns and values you want, and turn a messy file into a clean one with a script anyone can re-run.
Type along in week03/subsetting.R. Still no AI.
⏱ 5 min
On paper. Write what out holds after each step, then the final result.
"small" "mid" "mid" "big". We discuss your DS printout on Tuesday, Oct 20. This is the kind of trace we’ll do on it.
Create week03/subsetting.R:
Tip
If those two lines don’t run cleanly after a restart, fix that first. The check script was Thursday’s last activity.
Most data cleaning is three moves:
NAAll three are subsetting. R has three operators, [ ], [[ ]] and $, and six ways to say which elements you want.
w[w > 6] reads “the w’s where w > 6 is TRUE”. A logical vector, one value per element, does the picking. That’s the move behind almost all cleaning.
⏱ 5 min
In your script:
Produce target from w in three different ways: by position, by name, and by a condition. Check each with identical(..., target).
Then: which of the three still gives the right answer if someone adds a new element at the front of w?
w[c(2, 4, 5)] or w[-c(1, 3)] · w[c("b", "d", "e")] · w[w > 6]. Names and conditions survive a new element; positions don’t.
%in%: matching several values== with a vector gives wrong answers, quietlyThe Andes, from gapminder:
3 countries × 12 years should be 36. What did == do?
== recycled andes: row 1 vs. Bolivia, row 2 vs. Chile, row 3 vs. Peru, row 4 vs. Bolivia… No warning, because 1704 is a multiple of 3.
“For each row: is this country one of these?” One TRUE/FALSE per row, no recycling.
[ ] vs [[ ]] on a listA fitted model is a list, so the same tools pull numbers out of it:
We’ll come back to models in Week 5. For now: names() and $ let you look inside anything.
[rows, columns]gapminder[gapminder$year == 2007, ] # rows by condition
gapminder[gapminder$lifeExp > 81, c("country", "year", "lifeExp")]
#> country year lifeExp
#> 72 Australia 2007 81.235
#> 671 Hong Kong China 2002 81.495
#> ...
gapminder[, -(1:2)] # drop columns 1, 2
gapminder[gapminder$year %in% c(1967, 2007), ] # 284 rows⏱ 8 min
Each line has one common mistake. Fix it, and say what the mistake was in a comment.
For each fix, write the number of rows you expect before you check with nrow().
== not = · -(1:3) · missing comma · c(3, 4) · year == 1967 | year == 2002, or better, year %in% c(1967, 2002). q5 as written keeps every row: 2002 on its own is TRUE.
A short, made-up campus survey: where people live on campus, age, commute time in minutes, and visits per week to the library. Typed in by hand, by several people.
survey <- read.csv("data/field_survey.csv")
str(survey)
#> 'data.frame': 48 obs. of 5 variables:
#> $ id : int 1 2 3 4 5 6 7 8 9 10 ...
#> $ site : chr "East" "North" "East" " north" ...
#> $ age : int 26 38 39 18 21 -99 23 28 39 37 ...
#> $ commute_min: chr "23" "11" "17" "7" ...
#> $ visits : int 1 6 0 0 4 6 0 5 5 5 ...⏱ 3 min
Using only that output: write down three things that look wrong. There are at least four.
-99 is a code for “didn’t answer”, not an age. 230 is a typo"Est"commute_min is chr: one entry isn’t a numberNote
read.csv() turns blank text cells into "", not NA. That’s the unlabeled column of 1 in the table.
One small step. Check it. Next step.
After every step, look at one of:
Important
The raw file in data/ is never touched. Every fix happens in code, so it’s written down, it can be checked, and it can be undone.
48 − 3 = 45. Expected, checked. duplicated() marks the second copy of each exact repeat, so !duplicated() keeps the first.
survey[which(survey$age < 0 | survey$age > 110), ]
#> id site age commute_min visits
#> 6 6 South -99 21 6
#> 14 14 South 230 15 2
#> 29 29 North -99 34 3
survey$age[survey$age == -99] <- NA
survey$age[survey$age > 110] <- NA
sum(is.na(survey$age)) #> [1] 3
summary(survey$age)
#> Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
#> 18.00 21.00 28.00 28.83 35.75 41.00 3Warning
Once a column has NAs, survey[survey$age > 110, ] returns junk rows full of NA. Use which() to look: it gives only the positions that are truly TRUE.
survey$site <- tolower(trimws(survey$site)) # " north", "NORTH" -> "north"
table(survey$site, useNA = "ifany")
#> east est north south
#> 1 10 1 15 18
survey$site[survey$site == "est"] <- "east"
survey$site[survey$site == ""] <- NA
table(survey$site, useNA = "ifany")
#> east north south <NA>
#> 11 15 18 111 + 15 + 18 + 1 = 45. Every row accounted for. "est" → "east" is a judgment call: write a comment saying why you made it.
Don’t silence the warning. Find what it choked on:
⏱ 12 min
Create scripts/clean-survey.R with all four steps, in order, starting from:
Give each step a comment that says why, not just what. Then restart R and source() it. Does str(survey) show 45 rows and commute_min as num?
Stuck on a step? Run the script with source("scripts/clean-survey.R", echo = TRUE) and read up from the first error.
Thursday’s stop(), now guarding the clean data. At the bottom of scripts/clean-survey.R:
# Checks: stop loudly if the cleaning didn't do what we think it did
if (nrow(survey) != 45) stop("expected 45 rows, found ", nrow(survey))
if (any(duplicated(survey))) stop("duplicate rows remain")
if (!all(survey$site %in% c("north", "south", "east", NA))) {
stop("unexpected site: ", setdiff(survey$site, c("north", "south", "east", NA)))
}
if (any(survey$age < 0 | survey$age > 110, na.rm = TRUE)) stop("impossible age")
if (!is.numeric(survey$commute_min)) stop("commute_min is not numeric")
message("survey cleaned: ", nrow(survey), " rows")%in% again: every site must be one of the allowed values. And na.rm = TRUE inside any(), because if can’t decide on NA.
source()week03/survey-summary.R:
Tip
The analysis never sees the raw file, and the cleaning never sees the analysis. If you find another typo next week, you fix it once, in clean-survey.R, and every script that sources it gets the fix.
⏱ 8 min
week03/survey-summary.R from the previous slidesource("week03/survey-summary.R")data/field_survey.csv in Positron and confirm it still has 48 rows and " north" with its space. You never touched itIf step 2 works from a fresh session, someone else can run your whole analysis from your folder. That is what we’ll ask of your project.
stat266a/
├── data/
│ ├── gapminder_data.csv
│ └── field_survey.csv <- raw, never edited
├── scripts/
│ ├── load-gapminder.R
│ ├── check-gapminder.R
│ └── clean-survey.R <- read + clean + check
└── week03/
├── control-flow.R
├── ds03_notes.R
├── subsetting.R
└── survey-summary.R <- starts with source()
Your project repository should look like this from the first commit: data/ for raw files, scripts/ for code you source(), and analysis scripts that start from them.
[[ ]]/$ for one element%in%, not ==, to match against several valuesdf[rows, cols]: the comma decides which is whichNAs after each onesource()stop() checksTuesday, Oct 20: Project proposal. 3 minutes plus questions, each partner answers. Guidelines.
scripts/load-<yourdata>.RAlso Tuesday, Oct 20: your printed DS 3 PDF (Lesson 6) and a pencil. The discussion check moved there, before the proposals, so Thursday is all ggplot2.
Tonight or tomorrow: Lesson 7: Cleaning Data in Practice, to lock in today. Not graded, still no AI.
Next week: graphics with ggplot2. You’ll want clean data for that.

STAT 266A · Fall 2026 · UC Santa Cruz