library(tidyverse) # readr, dplyr, tidyr, stringr, etc.
library(readxl) # read .xlsx files
library(janitor) # clean_names(), round_half_up()Data Skills for Psychology — Module 6
These slides are adapted from Applied Data Skills Ch. 9: Data Wrangling.
By the end of this class, you will be able to:
case_when() to recode values with multiple conditions|>)Make sure you have these packages loaded:
Tip
You can grab the Data transformation cheat sheet — it’s a handy reference for everything we’ll cover today.
We’ll use UN data on public transport access (SDG 11.2.1):
“Proportion of population that has convenient access to public transport, by sex, age and persons with disabilities.”
Download SDG-11.2.1.xlsx into your data/ folder:
The
| Verb | What it does |
|---|---|
select() |
pick columns |
filter() |
pick rows |
arrange() |
sort rows |
mutate() |
add or change columns |
summarise() |
collapse rows into summary statistics |
group_by() |
apply other verbs separately to each group |
You already met summarise() and group_by() before. Today we focus on the other four.
Before wrangling, look at what you have:
It looks like most rows aggregate across age and sex. If we want gender comparisons, we’ll need to filter.
Some columns have only NA:
These won’t help with analysis — good candidates to drop.
Try it!
transport datasetcount() to figure out how many countries are in the datacount() to figure out how many different years are coveredselect() chooses which columns to keep:
Use a colon to grab a span of consecutive columns:
Warning
The colon is fast, but check your output! The columns must already be in the order you want.
Put a minus sign in front to drop columns instead:
When you have many columns, name patterns are powerful:
| function | what it picks |
|---|---|
everything() |
all columns |
starts_with() |
columns whose names start with a string |
ends_with() |
columns whose names end with a string |
contains() |
columns whose names contain a string |
where() |
columns where a function returns TRUE |
all_of() |
all columns named in a vector |
any_of() |
any columns from a vector that exist |
# all columns starting with "geo_"
transport |> select(starts_with("geo_"))
# all numeric columns
transport |> select(where(is.numeric))
# drop a vector of column names
one_val_cols <- c("goal", "target", "indicator",
"series_code", "time_coverage",
"upper_bound", "lower_bound",
"base_period", "geo_info_url",
"custom_breakdown", "reporting_type",
"units")
transport_clean <- transport |>
select(-all_of(one_val_cols))You can rename and reorder inside select(), but dedicated verbs make pipelines easier to read:
.after and .before take a column name and tell R where to put the moved columns.
filter() keeps rows that meet a condition:
Warning
Use == (double equals) to compare. A single = is for assignment.
Separate conditions with commas — rows must match all of them:
%in% OperatorFor “match any of these values,” use %in% rather than chaining lots of |s:
| Operator | Meaning |
|---|---|
A < B |
A is less than B |
A <= B |
A is less than or equal to B |
A > B |
A is greater than B |
A >= B |
A is greater than or equal |
A == B |
A exactly equals B |
A != B |
A does not equal B |
A %in% B |
A is an element of vector B |
You can pass any function that returns TRUE/FALSE. For text, str_detect() finds substrings:
Note
str_detect() is case-sensitive. Use tolower() or toupper() first if you need to match regardless of case.
Warning
filter() is powerful — and easy to misuse. With multiple criteria, it’s surprisingly common to specify slightly the wrong thing.
Always check your output. Use count() or glimpse() afterwards to confirm you got what you intended.
Try it!
Starting from transport_clean:
count() to verify each filter returned what you expectedarrange() sorts your data — alphabetically for text, ascending for numbers:
Wrap a column in desc() to reverse the order. You can sort by multiple columns — earlier columns take priority.
For non-alphabetical category orders, convert to a factor and set the levels:
mutate() creates a new column or overwrites an existing one. Syntax: new_column = expression.
You can build several columns in one mutate(), separated by commas. Later definitions can use earlier ones.
A Boolean expression returns TRUE/FALSE:
Warning
If you reuse a column name, you overwrite the old column. Make sure that’s what you want!
case_when() for Multiple CategoriesWhen you need more than two outcomes, use case_when():
R checks each condition top to bottom and assigns the first match.
case_when()You can mix multiple variables in each rule:
transport_data <- transport_factor |>
mutate(
urban = str_detect(foot_note, "Urban average"),
value = as.numeric(value),
category = case_when(
urban & value < 75 ~ "low",
urban & value < 85 ~ "average",
urban & value >= 85 ~ "high",
!urban & value < 20 ~ "low",
!urban & value < 60 ~ "average",
!urban & value >= 60 ~ "high",
.default = NA
)
)Tip
.default uses =, while every other rule uses ~. Easy to mix up!
Try it!
Using transport_factor (or your own version):
value numeric with as.numeric()era that is:
"early" if year is before 2010"mid" if year is 2010 to 2019"recent" if year is 2020 or latercount() to see how many rows fall in each eravalue_label column that says "high" if value > 80, otherwise "not high"summarise()summarise() collapses many rows into one (or one per group). Name each summary on the left:
Without grouping, you get a single-row summary of the whole dataset.
R rounds 0.5 to the nearest even number — this prevents bias when many values are halfway. It’s not a bug.
Best practice: leave missing cells empty in your raw data.
If “missing” appears as a word, the whole column becomes character data — you can’t do math on it.
If 0 or 999 stand in for missing, those values may sneak into calculations as if they were real.
NASuppose someone wrote "missing" instead of leaving the cell blank:
Now mean(value) errors out — it’s a character column, not numeric.
na_if()Convert the bad string to NA, then back to numeric:
mean() to Ignore NAsMost summary functions take na.rm = TRUE:
Why isn’t this the default? Because NA means “I don’t know,” and the average of known values plus unknown values is unknown. R wants you to make the call deliberately.
is.na() returns TRUE/FALSE for each value. Combine with sum() or mean():
drop_na()Warning
drop_na() with no arguments drops a row if any column is missing — including columns you don’t care about (notes, comments, etc.). Be specific about which columns matter.
Try it!
Using missing_data (or recreate it from the slides):
value overall, ignoring missing valuesvaluecomplete_data that drops only rows missing value — leave other columns alonesummarise() to find which country has the highest proportion of missing valuesLet’s apply everything to the screentime dataset — over 120,000 adolescents’ digital screen time and mental well-being scores (Przybylski & Weinstein, 2017).
Several ways to grab what you want:
The mwbi column uses negative numbers as missing-value codes — filter them out:
Warning
Always check how missing data is coded in your dataset. Here, “missing” looks like a real (negative) number — easy to miss!
Average two well-being items into a new column:
Warning
Watch out! mean(c(WBOptimf, WBUseful)) would average across the whole column — giving every row the same value. For a per-row average, use arithmetic ((a + b) / 2) or rowMeans().
Compare mean well-being across demographic groups:
The whole workflow as one pipeline:
Read it top-to-bottom: take screentime, then select these columns, then filter to valid scores in this region, then make a new measure, then summarise by demographic group.
select, filter, arrange, mutate, summarise, group_by) compose well — chain them with |>glimpse(), count(), head())case_when() is your friend for multi-way recoding