flowchart LR
A["Raw file<br>never edited"] --> B["Code makes<br>every change"] --> C["Check after<br>every step"]
Data Manipulation
Data Manipulation
Cleaning is part of the analysis
Chapter 3
Polla Fattah
By the end of today you can
- explain what clean data means, and why every decision is recorded;
- recognise data that is not tidy;
- chain steps with the pipe
|>; - choose cases and variables, sort, create variables, and summarise by group;
- reshape between wide and long, and join tables;
- clean a real survey export from start to finish;
- compute scale scores with reversed items;
- report the cleaning in a thesis.
Raw data is never ready
- test responses sit alongside real ones;
- the same form is submitted twice;
- one person types “female”, another “F”;
- missing answers are stored as
99; - a slip of the finger turns 25 into 250.
None of this is unusual, and none of it is harmless.
Five qualities of clean data
| Quality | Meaning | Failure in the export |
|---|---|---|
| valid | every value is possible | an age of 250 |
| accurate | values mean what was meant | 99 read as an answer |
| complete | gaps are marked, not hidden | missing codes |
| consistent | one answer, one spelling | “F”, “female”, “Female” |
| unique | each case exactly once | tests and duplicates |
One missing code shifts the average
answers <- c(4, 3, 5, 99, 2)
mean(answers)
#> [1] 22.6
mean(na_if(answers, 99), na.rm = TRUE)
#> [1] 3.5With the code left in, the average is impossible on a 1-to-5 scale.
In real data the distortion is smaller, and so harder to notice.
Three rules for every cleaning decision
Record every decision in the script, and in the thesis where it affects the results.
Many cleaning mistakes produce no error message at all.
Tidy data
Each variable forms a column, each observation forms a row, each value sits in its own cell.
Once data is tidy, every tool in today’s lecture works on it.
A table that looks neat but is not tidy
| student | wellbeing_s1 | wellbeing_s2 |
|---|---|---|
| S1 | 58 | 63 |
| S2 | 71 | 70 |
| S3 | 64 | 69 |
One variable, wellbeing, is spread over two columns.
A second variable, the semester, is hidden in the column names.
The same data, tidy
| student | semester | wellbeing |
|---|---|---|
| S1 | 1 | 58 |
| S1 | 2 | 63 |
| S2 | 1 | 71 |
| S2 | 2 | 70 |
One row is one student in one semester.
“Average wellbeing per semester” becomes a simple grouped summary.
The tidyverse
| Package | Job |
|---|---|
| dplyr | working with rows and columns |
| tidyr | reshaping data |
| stringr | working with text |
| readr | turning text into numbers, reading and writing files |
library(tidyverse) loads them all. This course loads only what each chapter needs.
Nested functions read inside out
First the mean, then the rounding, but written the other way round.
As steps multiply, this gets hard to follow.
The pipe reads in order
The pipe passes the result on its left as the first argument on its right.
Read it as “and then”. Ctrl+Shift+M types it in RStudio.
Older code uses %>%, which does the same job.
dplyr verbs each do one job
| Verb | Job |
|---|---|
filter() |
choose cases |
select() |
choose variables |
arrange() |
sort |
mutate() |
create or change variables |
summarise() |
reduce to summaries |
count() |
count |
Every verb takes a data frame first and returns a data frame, so verbs chain.
A tiny table to learn on
pilot <- tibble(
student = c("S1", "S2", "S3", "S4", "S5"),
faculty = c("Education", "Humanities", "Education",
"Health Sciences", "Humanities"),
sleep = c(6.5, 7.5, 5.5, 8, 6),
stress = c(3.2, 2.1, 4.5, 1.8, 3.9)
)A tibble is the tidyverse’s data frame, which prints more neatly.
filter() chooses cases
pilot |> filter(sleep < 7)
pilot |> filter(sleep < 7, faculty == "Education")
pilot |> filter(faculty %in% c("Education", "Humanities"))Conditions separated by commas must all be true. %in% matches any of several values.
Every filter is a decision about the sample
“Students who completed all four semesters” is a different population from “all students”.
Report each filter, and the number of cases it removed.
select() and arrange()
pilot |> select(student, sleep) # keep two columns
pilot |> select(-faculty) # drop one column
pilot |> arrange(desc(stress)) # most stressed firstselect() keeps columns in the order named. desc() sorts largest first.
mutate() creates variables
A total score, a unit conversion, a category from a number: all new columns from existing ones.
case_when() sorts values into categories
pilot |> mutate(
stress_level = case_when(
stress >= 4 ~ "High",
stress >= 2.5 ~ "Medium",
.default = "Low"
)
)Conditions are checked in order; the first true one wins. .default catches the rest.
Categories lose information
A stress score of 3.9 and one of 2.6 both become “Medium”.
Useful for description, but analyses should normally keep the original number.
summarise() reduces a table
One row of summaries. n() counts the rows.
.by summarises each group
pilot |> summarise(
mean_sleep = mean(sleep),
students = n(),
.by = faculty
)
pilot |> count(faculty)The real power of summaries. Older code writes group_by(faculty) |> summarise(...), with the same result.
Verbs chained answer real questions
semesters |>
left_join(students |> select(student_id, workshop),
join_by(student_id)) |>
summarise(mean_wellbeing = mean(wellbeing),
students = n(),
.by = c(semester, workshop)) |>
arrange(semester, workshop)Semester records, and then the workshop group, and then averages per semester and group, and then sort.
What the chain shows
| Semester | Not invited | Invited |
|---|---|---|
| 1 | 60.3 | 60.6 |
| 2 | 60.2 | 65.5 |
| 3 | 59.2 | 63.2 |
| 4 | 58.4 | 60.9 |
Level before the workshop, invited students ahead from semester 2. Chapter 7 tests it.
From semester 3 there are 563 students, not 600: some left the study.
Wide and long
flowchart LR
W["Wide<br>one column per occasion"] -->|pivot_longer| L["Long<br>one row per measurement"]
L -->|pivot_wider| W
Spreadsheets and SPSS often use wide. Analysis in R is usually easier in long.
pivot_longer() stacks columns
long <- wide |>
pivot_longer(
cols = c(wellbeing_1, wellbeing_2),
names_to = "semester",
names_prefix = "wellbeing_",
values_to = "wellbeing"
)| Argument | Says |
|---|---|
cols |
which columns to stack |
names_to |
where the old names go |
names_prefix |
which part of the names to drop |
values_to |
where the values go |
pivot_wider() is for presenting
A wide table is easier for a reader.
The semester-by-workshop table two slides back was made with pivot_wider().
Joins combine tables by an identifier
The study keeps students, semester records, and supervisors in separate tables.
left_join() keeps every row of the first table
Matching information from people is added to each score.
S4 has a score but no entry in people, so their faculty is NA.
anti_join() finds what is missing
The rows of the first table with no match in the second.
Here: S3, the student without any scores.
A join answers a new question
students |>
left_join(supervisors |> select(supervisor_id, rank),
join_by(supervisor_id)) |>
summarise(considering_dropout = mean(considering_dropout == "Yes"),
students = n(), .by = rank)Considering dropout by supervisor rank: from about 12% to about 18%.
Differences this small could easily be chance (Chapter 7).
Who left the study?
left <- students |>
anti_join(semesters |> filter(semester == 3),
join_by(student_id))
nrow(left)
#> [1] 3737 students have no semester 3 record, and 22 of them had said they were considering dropping out.
Those who left are not a random selection (Chapter 6).
Cleaning the survey export
flowchart TB
subgraph R1[" "]
direction LR
A[Inspect] --> B[Tests and<br>duplicates] --> C[Names] --> D[Categories]
end
subgraph R2[" "]
direction LR
E[Missing codes<br>and text] --> F[Impossible<br>values] --> G[Scale scores] --> H[Check]
end
R1 --> R2
Each step is short. The order matters, and so does checking after each one.
Inspect first
raw <- read_excel(data2thesis_example("wellbeing_raw.xlsx"))
dim(raw)
#> [1] 615 64
raw |> count(Q3_Gender)615 rows for 600 students. Gender is spelled six ways: F, Female, female, M, Male, male.
Names with spaces, such as `Q1_Student ID`, need backticks.
Remove tests and duplicates
responses <- raw |>
filter(!str_detect(str_to_upper(`Q1_Student ID`), "^TEST")) |>
select(-`Response ID`) |>
distinct()
nrow(responses)
#> [1] 600! is “not”, ^ is “at the start”. The response ID differs even between duplicates, so it is dropped before distinct().
3 test responses and 12 duplicates removed: one row per student.
Usable variable names
responses <- responses |>
rename(
student_id = `Q1_Student ID`,
age = Q2_Age,
financial_worry = `Q10_How worried are you about money? (1-5)`
# ... and the rest
) |>
rename_with(~ item_names, .cols = Q11_1:Q11_22)rename() puts the new name first. The questionnaire wording belongs in the codebook.
Naming items in one go
item_names <- c(paste0("stress_", 1:6), paste0("burnout_", 1:6),
paste0("support_", 1:6), paste0("satisfaction_", 1:4))paste0("stress_", 1:6) builds stress_1 to stress_6.
rename_with() gives all 22 items their scale names at once.
Fix categories by pattern
responses <- responses |>
mutate(
gender = if_else(str_starts(str_to_lower(str_trim(gender)), "f"),
"Female", "Male"),
faculty = case_when(
str_detect(str_to_lower(faculty), "educ") ~ "Education",
str_detect(str_to_lower(faculty), "social") ~ "Social Sciences",
str_detect(str_to_lower(faculty), "natural|^sciences$") ~ "Natural Sciences"
# ... and the other faculties
))Lower-case and trim first, then look for a pattern instead of listing every spelling.
Order and across()
“Social sciences” also contains “sciences”, so Social Sciences is checked before Natural Sciences.
across(c(has_children, lives_away, considering_dropout),
~ if_else(str_starts(str_to_lower(.x), "y"), "Yes", "No"))across() applies one change to several columns. Count the categories again after each fix.
Missing codes and numbers stored as text
responses <- responses |>
mutate(
financial_worry = financial_worry |> na_if("99") |> na_if("-9") |>
as.integer(),
across(all_of(item_names), ~ as.integer(na_if(.x, "99"))),
age = parse_number(age)
)na_if() turns a code into NA; as.integer() turns text into whole numbers.
Do not replace 99 everywhere
A 99 is not always a code.
On a wellbeing score from 0 to 100, it is a perfectly real value.
Replace missing codes only in the columns where you know they are codes.
parse_number() and the decimal comma
parse_number(c("7 hrs", "6.5", "6,5"))
#> [1] 7.0 6.5 65.0
parse_number(str_replace(c("7 hrs", "6.5", "6,5"), ",", "."))
#> [1] 7.0 6.5 6.5parse_number() reads the number out of text, but a comma separates thousands in English.
No error message warns you. Only checking does.
Impossible values become NA
responses |> filter(age > 100) |> select(student_id, age)
responses <- responses |>
mutate(age = if_else(age > 100, NA, age))Student S0007’s age is 250. It might have been 25, or 50: the honest fix is NA, not a guess.
Unusual but possible values, such as 4 hours of sleep, are real data and stay.
Reversed items point the other way
stress_4: “I feel confident handling problems in my studies.”
Agreeing means less stress. Averaged as it stands, it cancels part of the scale.
On a 1-to-5 scale, 6 - x reverses it: 1 becomes 5, 2 becomes 4.
Scale scores
scores <- questionnaire_clean |>
mutate(
stress_4 = 6 - stress_4,
stress_score = rowMeans(pick(stress_1:stress_6), na.rm = TRUE),
support_score = rowMeans(pick(support_1:support_6), na.rm = TRUE)
# ... burnout and satisfaction
) |>
select(student_id, ends_with("_score"))pick() selects a scale’s items; rowMeans() averages each student’s answers.
Decisions inside a scale score
With na.rm = TRUE, a student who skipped one item still gets a score.
How many skipped items is too many is a decision to report.
Keep the original items: Chapter 9 needs them to check the scales.
Semesters from wide to long
semesters_clean <- responses |>
select(student_id, matches("_S[1-4]$")) |>
pivot_longer(cols = -student_id,
names_to = c(".value", "semester"),
names_sep = "_S")GPA_S1 holds two pieces of information: the variable and the semester.
.value uses the first piece as a column name.
The same steps, on the semesters
semesters_clean <- semesters_clean |>
mutate(
sleep_hours = parse_number(str_replace(sleep_hours, ",", ".")),
sleep_hours = if_else(sleep_hours > 24, NA, sleep_hours),
study_hours = if_else(study_hours > 168, NA, study_hours),
gpa = if_else(gpa > 4, NA, gpa)
) |>
filter(!if_all(gpa:wellbeing, is.na))The last line removes empty rows for students who had left the study.
Check the missing values
About 1% of the records still have gaps: answers really not given, and impossible values set to NA.
These are genuine gaps (Chapter 6).
Compare with a trusted result
same(questionnaire_clean |> arrange(student_id),
questionnaire |> arrange(student_id))
#> [1] TRUE
same(semesters_clean |> arrange(student_id, semester),
semesters |> arrange(student_id, semester))
#> [1] TRUEThe package’s clean tables came from the same export, so the cleaning matches exactly.
Writing it up
The survey export contained 615 responses. Test responses (3) and duplicate submissions (12) were removed, leaving 600 students. Missing-answer codes (99 and −9) were recoded as missing, and impossible values (4 in all) were set to missing. The reverse-worded item
stress_4was recoded before scale scores were computed.
In the thesis, fill these numbers in with code.
Save the cleaning as a script
# 01-clean-data.R: from the raw file to the clean tables
write_csv(semesters_clean, here::here("data", "semesters_clean.csv"))Never edit the raw file by hand.
A new problem next month is fixed in the script and run again, and every step stays on record.
In your field: environmental and public health
airquality |>
summarise(mean_ozone = mean(Ozone, na.rm = TRUE),
missing_days = sum(is.na(Ozone)),
.by = Month)Ozone peaks in July and August, and June has 21 missing days.
If the missing days were mostly hot ones, the June average would be too low.
Practical lab: the Chapter 3 playground
Work through the playground exercises in your browser, with hints and solutions.
Every exercise also runs in RStudio, from the downloadable chapter project.
Practical exercises 1–3: verbs and reshaping
- Average age by faculty, sorted from oldest to youngest.
- Classify semester sleep as Short, Moderate, or Recommended, and count each.
- A table of GPA with one row per student and one column per semester.
Practical exercises 4–6: joins and judgement
- Join
semesterstostudentsand compare full-time and part-time GPA. - Explain what happens to stress scores if
stress_4is not reversed. - Give one example of each quality of clean data from your own field.
Try this yourself
Take any messy spreadsheet you have used.
- list its problems under the five qualities;
- write the cleaning as one script from the untouched file;
- count the categories after every fix;
- write a three-sentence cleaning report.
Then change one value in the raw file and run the script again.
Troubleshooting guide (Part 1)
| Symptom | Likely cause |
|---|---|
| an impossible average | a missing code left in the data |
| more rows than participants | test responses or duplicates |
| one category appears three times | inconsistent spelling or trailing spaces |
distinct() removes nothing |
a column such as the response ID differs |
Troubleshooting guide (Part 2)
| Symptom | Likely cause |
|---|---|
| 6.5 hours became 65 | a decimal comma read by parse_number() |
| real 99s disappeared | a missing code replaced in every column |
| a scale’s reliability is low | a reversed item not recoded |
everyone lands in the wrong case_when() group |
conditions in the wrong order |
Completion checklist
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
| cleaning is a chore before the analysis | cleaning decisions shape every result |
| no error message means no mistake | many mistakes are silent; check each step |
| a neat spreadsheet is tidy | tidy means one variable per column |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| an impossible value can be corrected by guessing | set it to NA |
99 is always a missing code |
only where the codebook says so |
| scale scores can replace the items | keep the items for checking the scales |
The chapter in one sentence
Clean in code from an untouched file, check after every step, and report every decision.
Next: Chapter 4
The next chapter turns clean data into graphs:
- what a graph is for, and how people read graphs;
- choosing a graph for the question;
- the grammar of graphics with ggplot2;
- graphs for publication, and honest graphs;
- exporting figures.
Questions
What is the messiest dataset you have worked with?
Which of its problems would have been silent?