Quick summary
Summarize this blog with AI
Introduction
The statistical mode is a most-frequent value. R’s base mode() reports an object’s storage mode; it does not calculate this statistic. A useful helper must specify how it treats ties, missing values and empty input without converting decimals or labels into integers.
Tested with R 4.5.1 and dplyr 1.1.4. Run the code blocks in order. Install the named packages before starting; inputs are supplied below.
Define the contract before choosing a function
This lesson returns every value tied for maximum frequency, in first-appearance order. If all values occur once, all are returned as tied modes; some reporting conventions call that “no mode,” so state yours. Missing values are removed by default; retaining them treats missingness as one category. The allowed inputs are plain numeric, character or logical vectors and factors, not matrices or lists. Exact equality is used; nearby floating-point values are not automatically binned.
print(mode(c(2, 2, 3)))
# "numeric" describes storage, not the most frequent value.
stopifnot(mode(c(2, 2, 3)) == "numeric")
Count positions and subset the original values
Using match() with unique values creates integer category positions. tabulate() counts those positions; subsetting the original unique values preserves decimals, labels and factor levels. No table names are converted back into numeric data.
stat_modes <- function(x, na.rm = TRUE) {
valid_type <- is.factor(x) ||
(!is.object(x) && (is.numeric(x) || is.character(x) || is.logical(x)))
if (!valid_type || !is.null(dim(x))) stop("Need a supported one-dimensional vector")
if (!is.logical(na.rm) || length(na.rm) != 1L || is.na(na.rm))
stop("na.rm must be TRUE or FALSE")
if (na.rm) x <- x[!is.na(x)]
if (!length(x)) return(x)
# Treat NA and NaN as one missing category when retained.
x[is.na(x)] <- NA
values <- unique(x)
counts <- tabulate(match(x, values), nbins = length(values))
values[counts == max(counts)]
}
print(stat_modes(c(1.5, 2.5, 1.5, 2.5, 9)))
print(stat_modes(c("pear", "apple", "pear", "apple")))
print(stat_modes(c(4L, 4L, 8L)))
The outputs are c(1.5, 2.5), c("pear", "apple"), and integer 4. A helper that converts table names with as.integer() would lose this contract.
Verify ties, missingness and type preservation
Empty or all-missing input after removal returns a zero-length vector of the input type. With na.rm = FALSE, two missing entries can win the frequency count. A factor output retains its factor levels, including currently unused levels; levels are metadata, not observations.
stopifnot(identical(stat_modes(c(1.5, 2.5, 1.5, 2.5)), c(1.5, 2.5)),
identical(stat_modes(c("b", "a", "b", "a")), c("b", "a")),
identical(stat_modes(c(1L, 2L, 3L)), 1:3),
identical(stat_modes(numeric()), numeric()),
identical(stat_modes(c(NA_real_, NA_real_)), numeric()),
identical(stat_modes(c(NA_real_, NaN, 2), FALSE), NA_real_),
identical(stat_modes(c(TRUE, FALSE, TRUE)), TRUE))
f <- factor(c("low", "high", "low"), levels = c("low", "high", "unused"))
stopifnot(identical(stat_modes(f), f[1]), identical(levels(stat_modes(f)), levels(f)))
fails <- function(expr) inherits(tryCatch(expr, error = identity), "error")
stopifnot(fails(stat_modes(list(1, 2))), fails(stat_modes(matrix(1:4, 2))),
fails(stat_modes(c(1, 2), NA)))
Keep multiple group modes in a list column
A grouped summary must not silently discard ties. In dplyr summarise(), a list column gives each group one row while storing any number of modes. For very large data, measure memory and runtime on representative inputs rather than assuming one implementation is always faster.
library(dplyr)
observations <- data.frame(group = c("A", "A", "A", "B", "B", "B"),
value = c("red", "blue", "red", "red", "blue", NA))
result <- observations |>
group_by(group) |>
summarise(modes = list(stat_modes(value)), .groups = "drop")
print(result)
stopifnot(identical(result$modes, list("red", c("red", "blue"))))
GroupA has mode red; groupB has the tied modes red and blue after removing its missing observation. Base R’s storage-mode documentation and the tests above make the distinction explicit; a package is optional, not necessary for this frequency calculation.