library(tidyverse)8 Tidy data and pivoting
Objectives
- Define tidy data. Understand the three rules that make a dataset tidy: each variable must live in its own column, each observation must occupy its own row, and each value must appear in a single cell. Appreciate why a consistent data structure makes it easier to learn and use tidyverse tools.
- Lengthen data with
pivot_longer(). Usepivot_longer()from the tidyr package to reshape untidy datasets by gathering column names into a new variable and their values into another variable. Learn how to select columns to pivot, specify the names of the new variables, and optionally drop missing values. - Handle multiple variables in column names. Recognize when column headers encode multiple pieces of information (for example, method, gender, and age) and use the
names_to/names_separguments ofpivot_longer()to split them into separate variables, ornames_patternwhen a regular expression is a better fit than a fixed separator. - Extract both a variable name and a value from a column header. Recognize the special
.valuesentinel for the (surprisingly common) case where column headers mix a variable name with an identifier, such asdob_child1andname_child1. - Widen data with
pivot_wider(). Usepivot_wider()to spread rows into columns when each observation is represented across multiple rows. Learn how to choose thenames_from,values_from, andid_colsarguments so that each row uniquely identifies an observation, and recognize what happens when it does not.
Notes
What is tidy data?
Tidy data is a standard way to organize a dataset so that it works naturally with the tidyverse. In tidy data:
- Each variable is a column, and each column is a variable. A dataset like
table1from R for Data Science has one column per variable and is the easiest shape to work with. - Each observation is a row. Every row corresponds to one observation of all the variables.
- Each value is a cell. Every cell contains a single value for one variable in one observation.
The payoff is twofold. A single consistent structure makes it easier to learn a whole suite of tools, since they all assume the same underlying shape, and placing variables in columns lets R’s vectorized functions operate the way they are designed to.
Lengthening data with pivot_longer()
Most real datasets are not tidy, because they were organized for data entry or reporting rather than analysis. pivot_longer() lengthens data by gathering a set of columns into key-value pairs. Its most important arguments are cols (which columns to pivot, specified explicitly like bp1:bp2 or with a tidyselect helper like starts_with()), names_to (the name of the new variable built from the column names), values_to (the name of the new variable holding the pivoted values), and values_drop_na (set to TRUE to drop rows created only because a column happened to be empty for that observation).
The built-in billboard dataset records weekly Billboard chart positions: each row is a song, and each wk? column gives its rank in that week of its chart run.
billboard_long <- billboard |>
pivot_longer(
cols = starts_with("wk"),
names_to = "week",
values_to = "rank",
values_drop_na = TRUE
) |>
mutate(week = parse_number(week))
billboard_long |> head()# A tibble: 6 × 5
artist track date.entered week rank
<chr> <chr> <date> <dbl> <dbl>
1 2 Pac Baby Don't Cry (Keep... 2000-02-26 1 87
2 2 Pac Baby Don't Cry (Keep... 2000-02-26 2 82
3 2 Pac Baby Don't Cry (Keep... 2000-02-26 3 72
4 2 Pac Baby Don't Cry (Keep... 2000-02-26 4 77
5 2 Pac Baby Don't Cry (Keep... 2000-02-26 5 87
6 2 Pac Baby Don't Cry (Keep... 2000-02-26 6 94
This call transforms the 317 by 79 billboard tibble into a 5307 by 5 tibble with one row per song-week combination, which is exactly why nrow() no longer answers “how many songs are there”: it now answers “how many song-week observations are there.”
Splitting multiple pieces of information out of column names
Sometimes a column header packs in more than one piece of information. The who2 dataset records tuberculosis cases with column names like sp_m_014, which combine the diagnosis method (sp), gender (m), and age range (014). Supplying a vector of names to names_to along with names_sep splits each column name at the separator into those separate variables in one step.
who2_long <- who2 |>
pivot_longer(
cols = !(country:year),
names_to = c("diagnosis", "gender", "age"),
names_sep = "_",
values_to = "count"
)
who2_long |> head()# A tibble: 6 × 6
country year diagnosis gender age count
<chr> <dbl> <chr> <chr> <chr> <dbl>
1 Afghanistan 1980 sp m 014 NA
2 Afghanistan 1980 sp m 1524 NA
3 Afghanistan 1980 sp m 2534 NA
4 Afghanistan 1980 sp m 3544 NA
5 Afghanistan 1980 sp m 4554 NA
6 Afghanistan 1980 sp m 5564 NA
Extracting a variable name and a value from a header at once
Occasionally a column header encodes a variable name and an identifier together, rather than several independent pieces of information. The household dataset records each child’s date of birth and name in separate columns per child:
household# A tibble: 5 × 5
family dob_child1 dob_child2 name_child1 name_child2
<int> <date> <date> <chr> <chr>
1 1 1998-11-26 2000-01-29 Susan Jose
2 2 1996-06-22 NA Mark <NA>
3 3 2002-07-11 2004-04-05 Sam Seth
4 4 2004-10-10 2009-08-27 Craig Khai
5 5 2000-12-05 2005-02-28 Parker Gracie
dob_child1 and name_child1 are not two unrelated categories the way sp, m, and 014 were; dob and name are themselves variable names that belong in their own columns, while child1/child2 identifies which child a row describes. The special sentinel ".value" inside names_to tells pivot_longer() to use that piece of the column name as a new column name instead of as a value:
household |>
pivot_longer(
cols = !family,
names_to = c(".value", "child"),
names_sep = "_",
values_drop_na = TRUE
)# A tibble: 9 × 4
family child dob name
<int> <chr> <date> <chr>
1 1 child1 1998-11-26 Susan
2 1 child2 2000-01-29 Jose
3 2 child1 1996-06-22 Mark
4 3 child1 2002-07-11 Sam
5 3 child2 2004-04-05 Seth
6 4 child1 2004-10-10 Craig
7 4 child2 2009-08-27 Khai
8 5 child1 2000-12-05 Parker
9 5 child2 2005-02-28 Gracie
The result has genuine dob and name columns, one row per family-child combination, with families that only have one child on record contributing only one row instead of a row with an NA child.
Widening data with pivot_wider()
Sometimes a single observation is spread across multiple rows instead, and you need to widen the data into new columns. pivot_wider() increases the number of columns and decreases the number of rows; it is especially useful when a variable identifies the type of measurement and another variable holds the corresponding value. The key arguments are names_from (the column whose unique values become new column names), values_from (the column supplying the values that fill those new columns), and id_cols (the columns that uniquely identify each resulting row).
The cms_patient_experience dataset from the Centers for Medicare & Medicaid Services records several performance measures per healthcare organization, with each organization spread across multiple rows, one per measure.
cms_wide <- cms_patient_experience |>
pivot_wider(
id_cols = starts_with("org"),
names_from = measure_cd,
values_from = prf_rate
)
cms_wide |> head()# A tibble: 6 × 8
org_pac_id org_nm CAHPS_GRP_1 CAHPS_GRP_2 CAHPS_GRP_3 CAHPS_GRP_5 CAHPS_GRP_8
<chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
1 0446157747 USC CA… 63 87 86 57 85
2 0446162697 ASSOCI… 59 85 83 63 88
3 0547164295 BEAVER… 49 NA 75 44 73
4 0749333730 CAPE P… 67 84 85 65 82
5 0840104360 ALLIAN… 66 87 87 64 87
6 0840109864 REX HO… 73 87 84 67 91
# ℹ 1 more variable: CAHPS_GRP_12 <dbl>
Naming org_pac_id and org_nm as id_cols tells pivot_wider() exactly which columns identify a unique organization, so every organization lands on a single row with one column per measure code. id_cols is not optional in the way it looks; leaving it out only means pivot_wider() guesses which remaining columns identify a row, and a wrong guess produces duplicate or list-column results with no error at all (see Example 7.3).
Fringe cases and common pitfalls
names_pattern does everything names_sep does, with regular expressions instead of a fixed character.
R4DS mentions names_pattern as an alternative to names_sep for splitting column names, but never actually shows it in action. Here it is, reproducing the who2 split from this session’s notes with a regular expression capturing the same three pieces:
who2_pattern <- who2 |>
pivot_longer(
cols = !(country:year),
names_to = c("diagnosis", "gender", "age"),
names_pattern = "(.*)_(.)_(.*)",
values_to = "count"
)
identical(who2_pattern, who2_long)[1] TRUE
Each parenthesized group in the pattern becomes one of the names in names_to, in order. names_sep is really just a convenience wrapper for the common case where a single fixed character cleanly separates every piece; reach for names_pattern once a column name’s structure is too irregular for a single separator character to handle (for example, a fixed number of leading digits followed by free text).
A column that looks like a unique identifier might not be.
n_distinct(billboard$track) # fewer distinct titles than rows...[1] 316
nrow(distinct(billboard, artist, track)) # ...because artist matters too[1] 317
billboard has 317 rows but only 316 distinct track titles, because two different artists, Donell Jones and Shade Sheist, each charted a song called “Where I Wanna Be” the same year. track alone cannot serve as a unique identifier for a song; you need the combination of artist and track together. This is exactly the kind of column that looks safe to hand to pivot_wider()’s id_cols and is not, as the next example shows directly.
When id_cols does not uniquely identify a row, pivot_wider() does not error; it silently builds list-columns.
messy <- tibble(
id = c(1, 1, 2),
measure = c("bp", "bp", "bp"),
value = c(120, 125, 130)
)
messy |> pivot_wider(names_from = measure, values_from = value)Warning: Values from `value` are not uniquely identified; output will contain list-cols.
• Use `values_fn = list` to suppress this warning.
• Use `values_fn = {summary_fun}` to summarise duplicates.
• Use the following dplyr code to identify duplicates.
{data} |>
dplyr::summarise(n = dplyr::n(), .by = c(id, measure)) |>
dplyr::filter(n > 1L)
# A tibble: 2 × 2
id bp
<dbl> <list>
1 1 <dbl [2]>
2 2 <dbl [1]>
Because id = 1 has two different bp readings and nothing else distinguishes those two rows, pivot_wider() cannot put both values in one ordinary cell. Instead of raising an error, it warns you and packs every value for that combination into a list, so the resulting bp column holds <dbl [2]> for id = 1 and <dbl [1]> for id = 2, a fundamentally different (and much less convenient) kind of column than the plain numeric column you were probably expecting. If you ever see “values are not uniquely identified; output will contain list-cols,” your id_cols do not actually pin down one row per observation, exactly as in Example 7.2; decide whether you need an additional identifying column, or need to summarize the duplicates (with values_fn) before widening.
pivot_wider() orders new columns by first appearance, not numeric order, which text sorting can quietly scramble.
long <- billboard |>
select(artist, track, wk1:wk12) |>
pivot_longer(cols = starts_with("wk"), names_to = "week", values_to = "rank", values_drop_na = TRUE)
# widening right away: columns come out in the sensible order wk1, wk2, ..., wk12
long |> pivot_wider(names_from = week, values_from = rank) |> names() [1] "artist" "track" "wk1" "wk2" "wk3" "wk4" "wk5" "wk6"
[9] "wk7" "wk8" "wk9" "wk10" "wk11" "wk12"
# widening after arranging by the (character) week column first
long |> arrange(week) |> pivot_wider(names_from = week, values_from = rank) |> names() [1] "artist" "track" "wk1" "wk10" "wk11" "wk12" "wk2" "wk3"
[9] "wk4" "wk5" "wk6" "wk7" "wk8" "wk9"
The first version looks correct purely by luck: the rows happened to still be in the order pivot_longer() produced them, which visits wk1 through wk12 in their original numeric order. Sorting the long data by the week column first, perhaps while cleaning it up for display, reorders those rows alphabetically as text, so pivot_wider() (which names columns in the order their values first appear) produces wk1, wk10, wk11, wk12, wk2, and so on. If you need a guaranteed column order after widening, use relocate() or select() afterward rather than relying on row order going in.
Recap
| Term | Definition |
|---|---|
| Tidy data | Each variable is a column, each observation is a row, each value is a cell. |
pivot_longer() |
Gathers columns into key-value pairs, increasing the number of rows and decreasing the number of columns. |
names_to / values_to |
Name the new variable built from column names, and the new variable holding the pivoted values. |
names_sep |
Splits each pivoted column name into several new variables at a fixed character. |
names_pattern |
Splits each pivoted column name into several new variables using a regular expression, for cases names_sep can’t handle. |
.value sentinel |
Inside names_to, tells pivot_longer() to use part of the column name as a new column name rather than as a value. |
pivot_wider() |
Spreads values across new columns, increasing the number of columns and decreasing the number of rows. |
id_cols |
The columns that must, together, uniquely identify each resulting row when widening. |
| List-column | A column whose cells can each hold more than one value; pivot_wider() produces one when id_cols doesn’t uniquely identify a row. |
| Column order after widening | Determined by the order each names_from value first appears in the data, not by numeric or alphabetical order. |
Check your understanding
- State the three rules that make a dataset tidy.
- After running
billboard |> pivot_longer(cols = starts_with("wk"), names_to = "week", values_to = "rank", values_drop_na = TRUE), doesnrow()on the result still tell you how many songs are in the dataset? Explain. - A dataset has columns
income_2020,income_2021,expenses_2020, andexpenses_2021. What would you put innames_toto end up with separateincomeandexpensescolumns, plus ayearcolumn, in onepivot_longer()call? - You widen a dataset with
pivot_wider(names_from = measure, values_from = value)and one of the resulting columns comes back as a list-column instead of an ordinary numeric column. What does that tell you about yourid_cols? - Why might two
pivot_longer()calls that usenames_sepversusnames_patternproduce identical results? When would you have to reach fornames_patterninstead ofnames_sep?
Each variable is a column, each observation is a row, and each value occupies a single cell.
No. Pivoting longer changes the unit of observation from one row per song to one row per song-week, so
nrow()after pivoting tells you the number of song-week observations, not the number of distinct songs. Usen_distinct()on an identifying column (or combination of columns) to count the original units instead.names_to = c(".value", "year")withnames_sep = "_". The.valuesentinel picks upincome/expensesas new column names, andyearbecomes an ordinary column holding2020/2021.It tells you the named
id_colsdo not uniquely identify a row: at least one combination of your identifying columns has more than one value for that measure, sopivot_wider()had nowhere to put them except inside a list. You either need an additional identifying column, or need to summarize the duplicate values before widening.names_sepis really a convenience shorthand for the common case where a single fixed character cleanly divides every piece of a column name; internally, splitting on a fixed separator can be expressed as a fairly simple regular expression, which is why the two approaches can produce identical results. You neednames_patternonce the column names have a structure too irregular for one separator character to capture, such as a fixed-width prefix followed by free-form text, or pieces that aren’t consistently separated at all.