library(tidyverse)13 Missing values and data cleaning
Objectives
- Differentiate explicit and implicit missing values. Explicitly missing values appear in your data as
NA, whereas implicitly missing values arise when an entire observation is absent. Recognizing the difference, “the presence of an absence” versus “the absence of a presence,” guides how you handle them. - Understand the infectious nature of
NA, and its cousinNaN. Missing values propagate through calculations and comparisons. Learn howNaN(“not a number”) differs fromNA, and why the two are easy to confuse. - Handle explicit missing values. Learn how to carry values forward (or backward) with
tidyr::fill(), replace missing values with fixed values usingdplyr::coalesce(), and convert special sentinel codes (such as-99) toNAwithdplyr::na_if(). Understand why these functions insist on matching types rather than silently coercing them. - Reveal and fill implicit missing values. Use
tidyr::pivot_wider()to make implicit missings explicit andtidyr::complete()to generate all combinations of variables so that missing combinations appear as rows. Understand when to drop structurally missing values by settingvalues_drop_na = TRUE. - Deal with empty groups. Recognize that groups with no observations are a form of missingness, preserve them by setting
.drop = FALSEincount()orgroup_by(), and know exactly what summary functions do when handed a zero-length vector.
Notes
Why worry about missing values?
Missing data is ubiquitous: a value can be unknown, unrecorded, or simply not applicable to that observation. R represents an absent value with the special marker NA, and NA is infectious: almost any arithmetic or logical operation involving it produces another missing result, which forces you to consciously decide how to handle missingness before drawing any conclusion. Missing values come in two forms. An explicit missing value is the presence of an absence, an actual NA sitting in a cell. An implicit missing value is the absence of a presence, a row that should exist (a quarter with no recorded stock price, a species-conservation combination that never occurred) but simply isn’t there at all.
Tools for explicit missing values
Carrying values forward or backward. In hand-entered data, a blank often means “same as the previous value.” fill() copies the most recent non-missing value forward by default; its .direction argument also accepts "up" (backward) or "updown".
treatment <- tribble(
~person, ~visit, ~response,
"Derrick Whitmore", 1, 7,
NA, 2, 10,
NA, 3, NA,
"Katherine Burke", 1, 4
)
treatment |> fill(person)# A tibble: 4 × 3
person visit response
<chr> <dbl> <dbl>
1 Derrick Whitmore 1 7
2 Derrick Whitmore 2 10
3 Derrick Whitmore 3 NA
4 Katherine Burke 1 4
Replacing with fixed values. coalesce() returns the first non-missing value among its arguments, and is vectorized across rows; coalesce(a, b, c) picks a where it’s non-missing, otherwise b, and otherwise c.
x <- c(1, 4, 5, 7, NA)
coalesce(x, 0) # replace NA with a fixed value[1] 1 4 5 7 0
df <- tibble(x = c(2, NA, 5), y = c(1, 3, NA))
df |> mutate(value = coalesce(x, y)) # take x where present, else y# A tibble: 3 × 3
x y value
<dbl> <dbl> <dbl>
1 2 1 2
2 NA 3 3
3 5 NA 5
Converting sentinel values to NA. Older systems sometimes encode a missing value as a special number, like -99 for a missing age. na_if(x, sentinel) converts every occurrence of that sentinel to a genuine NA, which matters more than it might sound like it should (see Example 12.4).
age <- c(25, -99, 30)
na_if(age, -99)[1] 25 NA 30
coalesce() and na_if() both insist on matching types
Unlike base R, which will happily coerce mismatched types together, these tidyverse functions require every argument to share a compatible type and refuse to guess otherwise (see Example 12.3). This catches a real class of bugs, such as accidentally comparing a numeric column to the sentinel "-99" (a string) instead of -99 (a number), before it can silently do nothing.
NaN, a different kind of missing. Some calculations, like 0/0 or Inf - Inf, are indeterminate rather than simply unknown, and R represents that result as NaN (“not a number”) rather than NA. NaN behaves like NA almost everywhere, including inside is.na(), but the two are not identical (see Example 12.1); is.nan() tests specifically for NaN.
0 / 0[1] NaN
Inf - Inf[1] NaN
Implicit missing values and how to reveal them
Implicit missing values occur when some combination of variables is simply absent from the data, such as forgetting to record a stock price for one quarter. Two tidyverse tools reveal them.
Pivoting. Widening data with pivot_wider() can turn implicit missings into explicit ones, since every row-by-new-column combination needs a value, filled with NA if there isn’t one. Pivoting longer with values_drop_na = TRUE does the reverse, turning explicit missings back into implicit ones by dropping those rows entirely.
Completing combinations. complete() generates every combination of the variables you name and inserts NA rows for any combination not already present. You can hand it an explicit range, like year = 2019:2021, to make sure the result spans exactly the period you intend, even if some years never appear in the raw data at all; full_seq() generates such a range automatically, spaced evenly from the minimum to the maximum value actually observed.
counts <- msleep |> count(vore, conservation)
counts_complete <- counts |>
complete(vore, conservation) |>
mutate(n = coalesce(n, 0))
counts_complete |> arrange(vore, conservation)# A tibble: 35 × 3
vore conservation n
<chr> <chr> <dbl>
1 carni cd 1
2 carni domesticated 2
3 carni en 1
4 carni lc 5
5 carni nt 1
6 carni vu 4
7 carni <NA> 5
8 herbi cd 1
9 herbi domesticated 7
10 herbi en 2
# ℹ 25 more rows
A third way to reveal implicit missingness, anti_join(), finds rows in one table with no match in another; you’ll use it for exactly that purpose once joins are introduced next session.
Empty groups and zero-length vectors
When you group by a factor, some levels can end up with zero observations after filtering, and count() and group_by() both drop those empty groups by default; set .drop = FALSE to keep them (and scale_x_discrete(drop = FALSE) if you need a ggplot axis to keep showing a category with no bar at all).
Summarizing an empty group means calling a summary function on a zero-length vector, and the result is not always NA the way you might guess.
empty <- numeric(0)
mean(empty) # NaN: an average of nothing is undefined, not "unknown"[1] NaN
sum(empty) # 0: an empty sum is a perfectly well-defined zero[1] 0
max(empty) # -Inf, with a warning, because "the biggest of nothing" has no answerWarning in max(empty): no non-missing arguments to max; returning -Inf
[1] -Inf
Three different, all internally consistent, answers to “what does a summary of nothing look like.” Always check what an empty group actually produced rather than assuming it became a tidy NA.
Fringe cases and common pitfalls
is.na() catches NaN, but is.nan() doesn’t catch NA, and NaN isn’t even equal to itself.
is.na(NaN) # TRUE: is.na() treats NaN as a kind of missingness[1] TRUE
is.nan(NA) # FALSE: is.nan() only ever matches NaN specifically[1] FALSE
NaN == NaN # NA, not TRUE[1] NA
is.na() is the broader check, catching both ordinary NA and the indeterminate NaN, which is exactly why it’s the right default tool for “is this value missing.” is.nan() is narrower and only reports TRUE for NaN itself. And even NaN compared to another NaN doesn’t return TRUE: R treats “not a number” the same way it treats any other unknown quantity in a comparison, refusing to assert two indeterminate results are equal to each other. Reach for is.nan() only when you specifically need to distinguish “this came from an indeterminate calculation” from “this was always missing,” which is rarer than it sounds.
fill()’s default direction cannot fill in values that come before the first known value.
leading_gap <- tribble(
~person, ~visit,
NA, 1,
NA, 2,
"Chen", 3
)
leading_gap |> fill(person) # default "down": nothing above row 3 to copy# A tibble: 3 × 2
person visit
<chr> <dbl>
1 <NA> 1
2 <NA> 2
3 Chen 3
leading_gap |> fill(person, .direction = "up") # "up": copies Chen backward instead# A tibble: 3 × 2
person visit
<chr> <dbl>
1 Chen 1
2 Chen 2
3 Chen 3
With the default .direction = "down", fill() only ever copies a value into the rows after it, so missing values at the very start of a group, with no earlier non-missing value to copy from, stay missing no matter how many rows come after them. If the value that should apply to earlier rows only appears later in the data (a form filled out once for the whole group, entered on the last row instead of the first), .direction = "up" is the one you actually need.
coalesce() and na_if() reject a type mismatch instead of quietly coercing it.
coalesce(c(1, NA, 3), c("a", "b", "c"))Error in `coalesce()`:
! Can't combine `..1` <double> and `..2` <character>.
Base R functions often coerce silently; paste0() turning a missing value into the text "NA" back in Session 10 is one example of coercion happening whether you wanted it or not. coalesce() and na_if() take the opposite approach and refuse to combine incompatible types at all, erroring immediately rather than producing a column that is sometimes a number and sometimes text. If you hit this error while trying to replace a sentinel value, the fix is almost always to check that the sentinel you supplied (-99 versus "-99") has the same type as the column itself.
Forgetting to convert a sentinel value before summarizing corrupts the answer, silently and dramatically.
ages <- c(25, 31, 42, -99, 29, 37, -99, 45)
mean(ages) # includes the sentinel as if it were real data[1] 1.375
mean(na_if(ages, -99), na.rm = TRUE) # correctly excludes it[1] 34.83333
The sentinel values don’t produce an error, a warning, or an obviously nonsensical output; 1.375 looks exactly like a valid, if implausible, output for mean(), especially inside a longer pipeline where you might never print this particular intermediate number at all. Genuine NAs at least announce themselves by propagating into an NA result; sentinel values disguised as ordinary numbers do not, which is exactly why converting them with na_if() immediately after import, before any analysis touches the column, matters far more than it might seem.
Recap
| Term | Definition |
|---|---|
| Explicit missing value | A cell that actually contains NA; “the presence of an absence.” |
| Implicit missing value | A row that should exist but doesn’t appear in the data at all; “the absence of a presence.” |
fill() |
Carries the most recent non-missing value forward (or, with .direction = "up", backward) to fill NAs. |
coalesce() |
Returns the first non-missing value among its arguments; requires all arguments to share a compatible type. |
na_if() |
Converts a specific sentinel value to NA; also requires matching types. |
NaN |
The result of an indeterminate calculation (0/0, Inf - Inf); caught by is.na() but not identical to itself under ==. |
is.nan() |
Tests specifically for NaN, narrower than is.na(). |
complete() |
Generates every combination of the named variables, inserting NA rows for combinations not already present. |
full_seq() |
Generates an evenly spaced sequence spanning the minimum to maximum of a variable, useful inside complete(). |
.drop = FALSE |
Keeps empty factor levels visible in count(), group_by(), or a discrete ggplot scale, instead of silently dropping them. |
| Summary of an empty vector | Not always NA: mean() gives NaN, sum() gives 0, and max()/min() give -Inf/Inf with a warning. |
Check your understanding
- Explain the difference between an explicit and an implicit missing value, and give an example of each.
is.na(NaN)returnsTRUE, butis.nan(NA)returnsFALSE. Explain why these aren’t contradictory.- A dataset records one form per household, filled out on the last member’s row, with
NAfor every earlier member of that household in thehousehold_incomecolumn. Whichfill()call correctly fills in the income for every member, and why would the default call not work? - You try to replace a sentinel value with
coalesce(df$score, -1)and get an error instead of a filled-in column. What is the most likely cause, given thatcoalesce()andna_if()behave the same way in this respect? - After grouping a dataset by a factor with an unused level and summarizing with
mean(), one row showsNaNinstead of a number. Is this a bug? What does it actually mean, and what wouldsum()have shown for the same empty group?
An explicit missing value is an actual
NAsitting in a cell you can see, such as a survey respondent who left an age field blank. An implicit missing value is a row that should exist but doesn’t appear anywhere in the data at all, such as a store that recorded sales every month except one, leaving no row whatsoever for that month rather than a row with a blank value.is.na()is intentionally the broader test: it treatsNaNas a form of missingness, alongside ordinaryNA, because in practice you almost always want to catch both when checking “is this usable data.”is.nan()is a narrower, more specific test that only matches the indeterminateNaNvalue and correctly does not match a plainNA, since a plainNAwas never the result of an indeterminate calculation in the first place. Both functions are behaving exactly as designed; they simply answer different, more and less specific, questions.fill(household_income, .direction = "up")is the correct call, because the known value sits on the last row of each household and needs to be copied backward, up, into the earlier rows above it. The default.direction = "down"only copies a value into rows that come after it, so it would leave every earlier household member’s income asNA, since there is no non-missing value above them to copy down in the first place.coalesce()(likena_if()) refuses to combine arguments of incompatible types, so this error almost always meansdf$scoreand-1don’t actually share a type, most commonly becausedf$scoreis a character column (perhaps due to some earlier import or parsing issue) while-1is a plain number. Checkingclass(df$score)and fixing the underlying type mismatch (or supplying a matching-type replacement, like"-1") resolves it.It is not a bug.
NaNfrommean()on an empty group means “the average of zero values is undefined,” which is a different, more specific statement than “unknown” (NA).sum()on the same empty group would have shown0, since an empty sum is well defined even though an empty average is not; the “right” answer for a summary of nothing depends entirely on which summary you’re computing.