Use freq_xtab() to
arrange observation counts in a contingency matrix. Exposure categories
form the rows and outcome categories form the columns. Grouping
variables create additional array dimensions so each group has its own
matrix. The function counts input rows; it does not interpret an input
column named n as weights or accept an already aggregated
count table.
The .data argument contains individual observations.
Supply two distinct columns as exposure and
outcome. Here the four cells contain 3, 2, 3, and 2
observations. Factors explicitly place Yes before
No on each axis.
study <- data.frame(
exposure = factor(rep(c("Yes", "Yes", "No", "No"), c(3, 2, 3, 2)),
levels = c("Yes", "No")),
outcome = factor(rep(c("Yes", "No", "Yes", "No"), c(3, 2, 3, 2)),
levels = c("Yes", "No"))
)
x <- study |> freq_xtab(exposure, outcome)
x
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 3 2 5
#> Sum 6 4 10
stopifnot(identical(unname(x), matrix(c(3, 2, 5, 3, 2, 5, 6, 4, 10),
nrow = 3, byrow = TRUE)))The row totals are 5 and 5, the column totals are 6 and 4, and the grand total is 10. The result is a numeric matrix, with column names retained as dimension names. It is not limited to binary categories.
Literal strings and tidy-evaluation forwarding are supported too:
freq_xtab(study, exposure = "exposure", outcome = "outcome")
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 3 2 5
#> Sum 6 4 10
cross_counts <- function(data, e, o) freq_xtab(data, {{ e }}, {{ o }})
cross_counts(study, exposure, outcome)
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 3 2 5
#> Sum 6 4 10margins = TRUE appends a Sum row and column
by default. Set margins = FALSE when you need the cell
counts alone. Including totals as additional observations would change
subsequent calculations.
freq_xtab(study, exposure, outcome, margins = TRUE)
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 3 2 5
#> Sum 6 4 10
cells <- freq_xtab(study, exposure, outcome, margins = FALSE)
cells
#> outcome
#> exposure Yes No
#> Yes 3 2
#> No 3 2
stopifnot(sum(cells) == nrow(study), identical(cells, x[1:2, 1:2]))If a category is already named Sum, the total receives
the first available label Sum.1, Sum.2, and so
on, separately on each axis. Category labels themselves remain
unchanged. The total is always the last row or column.
labels <- data.frame(e = c("Sum", "Sum.1", "other"), o = c("a", "a", "b"))
freq_xtab(labels, e, o)
#> o
#> e a b Sum
#> Sum 1 0 1
#> Sum.1 1 0 1
#> other 0 1 1
#> Sum.2 2 1 3freq_test() currently accepts the tibble results from
freq_table(); it does not accept freq_xtab()
matrices. Likewise, the package’s existing interval and formatting
helpers target frequency-table columns. This function provides counts;
epidemiologic measures and new presentation formats remain separate
work.
group_by() supplies groups. One grouping variable
produces a three-dimensional array: exposure, outcome, then group. Every
group shares the same categories.
study$site <- rep(c("Site A", "Site B"), each = 5)
by_site <- study |> group_by(site) |> freq_xtab(exposure, outcome)
by_site
#> , , site = Site A
#>
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 0 0 0
#> Sum 3 2 5
#>
#> , , site = Site B
#>
#> outcome
#> exposure Yes No Sum
#> Yes 0 0 0
#> No 3 2 5
#> Sum 3 2 5
by_site[, , "Site A"]
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 0 0 0
#> Sum 3 2 5
stopifnot(identical(dim(by_site), c(3L, 3L, 2L)),
all(by_site["No", , "Site A"] == 0),
all(by_site["Yes", , "Site B"] == 0))Each additional grouping variable adds a dimension, in
group_by() order. Here, only Site A/female and Site B/male
occur. The other group combinations have zero-filled matrices. Margins
are calculated within each matrix; grouping dimensions do not receive a
Sum category.
study$sex <- rep(c("female", "male"), each = 5)
grouped <- study |> group_by(site, sex)
by_group <- grouped |> freq_xtab(exposure, outcome)
dim(by_group)
#> [1] 3 3 2 2
names(dimnames(by_group))
#> [1] "exposure" "outcome" "site" "sex"
by_group[, , "Site A", "female"]
#> outcome
#> exposure Yes No Sum
#> Yes 3 2 5
#> No 0 0 0
#> Sum 3 2 5
by_group[, , "Site B", "female"]
#> outcome
#> exposure Yes No Sum
#> Yes 0 0 0
#> No 0 0 0
#> Sum 0 0 0
stopifnot(identical(dim(by_group), c(3L, 3L, 2L, 2L)),
all(by_group[, , "Site B", "female"] == 0),
sum(by_group["Sum", "Sum", , ]) == nrow(study))The returned array retains singleton dimensions. R’s own subsetting
rules can drop them, so use
by_group[, , "Site A", "female", drop = FALSE] to retain
the full array shape. This subsetting option is separate from the
constructor’s drop option below.
Factors use their specified level order. Other categories are sorted:
numeric values numerically, logical values FALSE then
TRUE, and character values in C-locale order. Missing
values appear last, before any margin. Use factors to request a
particular ordering; Yes is not automatically placed
first.
drop = FALSE retains unused factor levels on exposure,
outcome, and group axes. drop = TRUE removes globally
unused levels, overriding the input’s grouping drop setting. Categories
observed elsewhere in the input remain in every group’s matrix, even
when their count in that group is zero. Both settings represent every
combination of the retained axis categories.
with_missing <- data.frame(
e = factor(c("yes", "no", NA), levels = c("yes", "no", "unused")),
o = factor(c("yes", NA, "no"), levels = c("yes", "no", "unused")),
g = factor(c("A", "B", NA), levels = c("A", "B", "empty"))
)
kept <- with_missing |> group_by(g) |> freq_xtab(e, o, drop = FALSE)
kept[, , "empty"]
#> o
#> e yes no unused <NA> Sum
#> yes 0 0 0 0 0
#> no 0 0 0 0 0
#> unused 0 0 0 0 0
#> <NA> 0 0 0 0 0
#> Sum 0 0 0 0 0
pruned <- with_missing |> group_by(g, .drop = FALSE) |> freq_xtab(e, o, drop = TRUE)
dim(kept)
#> [1] 5 5 4
dim(pruned)
#> [1] 4 4 3
stopifnot(all(kept[, , "empty"] == 0),
identical(dim(kept), c(5L, 5L, 4L)),
identical(dim(pruned), c(4L, 4L, 3L)))Missing exposure, outcome, and group values are counted as categories
under either drop setting. Numeric NaN and
NA share the missing category. Exclude observations
explicitly with filter() when appropriate:
with_missing |>
filter(!is.na(e), !is.na(o), !is.na(g)) |>
group_by(g) |> freq_xtab(e, o, drop = TRUE)
#> , , g = A
#>
#> o
#> e yes Sum
#> yes 1 1
#> Sum 1 1Dimension labels are character representations of values. A missing
label is an actual NA_character_, distinct from a literal
"NA" or "<NA>" category. Use positional
indexing to select a missing category unambiguously:
missing_group <- which(is.na(dimnames(pruned)$g))
pruned[, , missing_group]
#> o
#> e yes no <NA> Sum
#> yes 0 0 0 0
#> no 0 0 0 0
#> <NA> 0 1 0 1
#> Sum 0 1 0 1Empty data retain factor levels with zero counts when
drop = FALSE. Empty nonfactor axes have length zero before
margins. Empty ungrouped character data therefore return a 1-by-1 zero
matrix with margins, or a 0-by-0 matrix without margins. An empty
grouping axis yields no matrix slices.
... must be empty and options must be named. A third
column or unsupported option produces an informative error:
freq_xtab(study, exposure, outcome, site)
#> Error:
#> ! freq_xtab() accepts two columns and named options only; ... must be empty. Supply groups with dplyr::group_by().freq_xtab(study, exposure, outcome, na.rm = TRUE)
#> Error:
#> ! freq_xtab() accepts two columns and named options only; ... must be empty. Supply groups with dplyr::group_by().Both analysis columns must exist, be distinct, and not be grouping
variables. Create derived columns with mutate() first and
ungroup rowwise data. List, matrix, raw, and complex columns are
unsupported. If distinct values would have identical text labels (for
example, nearly equal floating-point values), recode them into
categories with unique labels first. Dense arrays grow with the product
of category counts; reduce the grouping variables or recode categories
when that shape would be too large.