freq() and cross_tab() are the core
tabulation functions in spicy. They handle factors, labelled variables
(from haven or labelled), weights, and missing values out of the box.
This vignette covers the main options using the bundled
sochealth dataset.
Frequency tables with freq()
Basic usage
Pass a data frame and a variable name to get counts and percentages:
freq(sochealth, education)
#> Frequency table: education
#>
#> Category │ Values Freq. Percent
#> ────────────┼───────────────────────────────────────
#> Valid │ Lower secondary 261 21.8
#> │ Upper secondary 539 44.9
#> │ Tertiary 400 33.3
#> ────────────┼───────────────────────────────────────
#> Total │ 1200 100.0
#>
#> Label: Highest education level
#> Class: ordered, factor
#> Data: sochealthSorting
Sort by frequency with sort = "-" (decreasing) or
sort = "+" (increasing). Sort alphabetically with
sort = "name+" or sort = "name-":
freq(sochealth, education, sort = "-")
#> Frequency table: education
#>
#> Category │ Values Freq. Percent
#> ────────────┼───────────────────────────────────────
#> Valid │ Upper secondary 539 44.9
#> │ Tertiary 400 33.3
#> │ Lower secondary 261 21.8
#> ────────────┼───────────────────────────────────────
#> Total │ 1200 100.0
#>
#> Label: Highest education level
#> Class: ordered, factor
#> Data: sochealthSort alphabetically:
freq(sochealth, education, sort = "name+")
#> Frequency table: education
#>
#> Category │ Values Freq. Percent
#> ────────────┼───────────────────────────────────────
#> Valid │ Lower secondary 261 21.8
#> │ Tertiary 400 33.3
#> │ Upper secondary 539 44.9
#> ────────────┼───────────────────────────────────────
#> Total │ 1200 100.0
#>
#> Label: Highest education level
#> Class: ordered, factor
#> Data: sochealthCumulative percentages
Add cumulative columns with cum = TRUE:
freq(sochealth, smoking, cum = TRUE)
#> Frequency table: smoking
#>
#> Category │ Values Freq. Percent Valid Percent Cum. Percent
#> ────────────┼───────────────────────────────────────────────────────────────
#> Valid │ No 926 77.2 78.8 77.2
#> │ Yes 249 20.8 21.2 97.9
#> Missing │ NA 25 2.1 100.0
#> ────────────┼───────────────────────────────────────────────────────────────
#> Total │ 1200 100.0 100.0 100.0
#>
#> Category │ Values Cum. Valid Percent
#> ────────────┼────────────────────────────────
#> Valid │ No 78.8
#> │ Yes 100.0
#> Missing │ NA
#> ────────────┼────────────────────────────────
#> Total │ 100.0
#>
#> Label: Current smoker
#> Class: factor
#> Data: sochealthWeighted frequencies
Supply a weight variable with weights. By default
(rescale = FALSE), the table shows the raw weighted
counts:
freq(sochealth, education, weights = weight)
#> Frequency table: education
#>
#> Category │ Values Freq. Percent
#> ────────────┼───────────────────────────────────────
#> Valid │ Lower secondary 258 21.6
#> │ Upper secondary 545 45.5
#> │ Tertiary 394 32.9
#> ────────────┼───────────────────────────────────────
#> Total │ 1196 100.0
#>
#> Label: Highest education level
#> Class: ordered, factor
#> Data: sochealth
#> Weight: weightSet rescale = TRUE to adjust the weighted total to match
the unweighted sample size:
freq(sochealth, education, weights = weight, rescale = TRUE)
#> Frequency table: education
#>
#> Category │ Values Freq. Percent
#> ────────────┼───────────────────────────────────────
#> Valid │ Lower secondary 259 21.6
#> │ Upper secondary 546 45.5
#> │ Tertiary 395 32.9
#> ────────────┼───────────────────────────────────────
#> Total │ 1200 100.0
#>
#> Label: Highest education level
#> Class: ordered, factor
#> Data: sochealth
#> Weight: weight (rescaled)Labelled variables
When a variable has value labels (e.g., imported from SPSS or Stata
with haven), freq() shows them by default with the
[code] label format. Control this with
labelled_levels:
# Create a labelled version of the smoking variable
sh <- sochealth
sh$smoking_lbl <- labelled::labelled(
ifelse(sh$smoking == "Yes", 1L, 0L),
labels = c("Non-smoker" = 0L, "Current smoker" = 1L)
)
# Default: [code] label
freq(sh, smoking_lbl)
#> Frequency table: smoking_lbl
#>
#> Category │ Values Freq. Percent Valid Percent
#> ────────────┼───────────────────────────────────────────────────────────
#> Valid │ [0] Non-smoker 926 77.2 78.8
#> │ [1] Current smoker 249 20.8 21.2
#> Missing │ NA 25 2.1
#> ────────────┼───────────────────────────────────────────────────────────
#> Total │ 1200 100.0 100.0
#>
#> Class: haven_labelled, vctrs_vctr, integer
#> Data: sh
# Labels only (no codes)
freq(sh, smoking_lbl, labelled_levels = "labels")
#> Frequency table: smoking_lbl
#>
#> Category │ Values Freq. Percent Valid Percent
#> ────────────┼───────────────────────────────────────────────────────
#> Valid │ Non-smoker 926 77.2 78.8
#> │ Current smoker 249 20.8 21.2
#> Missing │ NA 25 2.1
#> ────────────┼───────────────────────────────────────────────────────
#> Total │ 1200 100.0 100.0
#>
#> Class: haven_labelled, vctrs_vctr, integer
#> Data: sh
# Codes only (no labels)
freq(sh, smoking_lbl, labelled_levels = "values")
#> Frequency table: smoking_lbl
#>
#> Category │ Values Freq. Percent Valid Percent
#> ────────────┼───────────────────────────────────────────────
#> Valid │ 0 926 77.2 78.8
#> │ 1 249 20.8 21.2
#> Missing │ NA 25 2.1
#> ────────────┼───────────────────────────────────────────────
#> Total │ 1200 100.0 100.0
#>
#> Class: haven_labelled, vctrs_vctr, integer
#> Data: shCustom missing values
Raw data files often store nonresponse as an ordinary level – a
“Refused” or “Don’t know” answer sitting next to the substantive
categories. na_val reclassifies such a value as missing: it
moves to the Missing block and valid percentages are computed without
it.
# Simulate a raw file where nonresponse was typed as a level
sh$income_raw <- as.character(sochealth$income_group)
sh$income_raw[is.na(sh$income_raw)] <- "Refused"
# "Refused" counts as a valid answer...
freq(sh, income_raw)
#> Frequency table: income_raw
#>
#> Category │ Values Freq. Percent
#> ────────────┼────────────────────────────────────
#> Valid │ High 219 18.2
#> │ Low 247 20.6
#> │ Lower middle 388 32.3
#> │ Refused 18 1.5
#> │ Upper middle 328 27.3
#> ────────────┼────────────────────────────────────
#> Total │ 1200 100.0
#>
#> Class: character
#> Data: sh
# ...until na_val reclassifies it as missing
freq(sh, income_raw, na_val = "Refused")
#> Frequency table: income_raw
#>
#> Category │ Values Freq. Percent Valid Percent
#> ────────────┼─────────────────────────────────────────────────────
#> Valid │ High 219 18.2 18.5
#> │ Low 247 20.6 20.9
#> │ Lower middle 388 32.3 32.8
#> │ Upper middle 328 27.3 27.7
#> Missing │ NA 18 1.5
#> ────────────┼─────────────────────────────────────────────────────
#> Total │ 1200 100.0 100.0
#>
#> Class: character
#> Data: shDeclared missing values
Survey files imported with haven often declare codes as missing at
the source – 8 = Refused, 9 = Don't know –
through na_values/na_range (SPSS-style) or
tagged NAs (Stata-style). The two behave differently in R. SPSS-style
codes are not NA, so most packages silently treat
them as valid answers. Stata-style tagged NAs are genuine
NAs, but most packages collapse them into a single
undifferentiated NA count. spicy honors both declarations
by default: the codes are excluded from valid percentages and
statistics, and freq() shows them as labelled rows of its
Missing block instead of erasing them.
sh2 <- sochealth
sh2$trust <- labelled::labelled_spss(
c(1, 2, 8, 1, 9, 2, 1, 8, 2, 1, rep(c(1, 2), 595)),
labels = c("Low" = 1, "High" = 2, "Refused" = 8, "Don't know" = 9),
na_values = c(8, 9)
)
freq(sh2, trust)
#> Frequency table: trust
#>
#> Category │ Values Freq. Percent Valid Percent
#> ────────────┼───────────────────────────────────────────────────────
#> Valid │ [1] Low 599 49.9 50.0
#> │ [2] High 598 49.8 50.0
#> Missing │ [8] Refused 2 0.2
#> │ [9] Don't know 1 0.1
#> ────────────┼───────────────────────────────────────────────────────
#> Total │ 1200 100.0 100.0
#>
#> Class: haven_labelled_spss, haven_labelled, vctrs_vctr, double
#> Data: sh2Set user_na = FALSE to treat SPSS-style declared codes
as ordinary categories instead; tagged NAs are genuine NAs
either way, so for them it only collapses the per-tag breakdown into the
regular NA count. The same argument, with the same default,
exists in cross_tab(), the descriptive helpers
table_categorical(), table_continuous() and
table_continuous_lm(), and the row-wise functions
mean_n(), sum_n() and count_n().
(table_regression() takes a fitted model, not raw data, so
the argument does not apply there.) cross_tab() and the
descriptive helpers disclose the exclusion in the table note; the
row-wise functions return bare numeric vectors, so they count the
declared codes as missing without any note. See the “Declared missing
values” section of ?freq for the full contract.
Cross-tabulations with cross_tab()
Basic two-way table
Cross two variables to get a contingency table with a chi-squared test and effect size:
cross_tab(sochealth, smoking, education)
#> Crosstable: smoking x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 179 415 332 │ 926
#> Yes │ 78 112 59 │ 249
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 257 527 391 │ 1175
#>
#> Chi-2(2) = 21.6, p <.001
#> Cramer's V = 0.14
#> Missing values removed: smoking (25).Row and column percentages
Use percent = "row" or percent = "column"
to display percentages instead of raw counts:
cross_tab(sochealth, smoking, education, percent = "column")
#> Crosstable: smoking x education (Column %)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 69.6 78.7 84.9 │ 78.8
#> Yes │ 30.4 21.3 15.1 │ 21.2
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 100.0 100.0 100.0 │ 100.0
#> N │ 257 527 391 │ 1175
#>
#> Chi-2(2) = 21.6, p <.001
#> Cramer's V = 0.14
#> Missing values removed: smoking (25).
cross_tab(sochealth, smoking, education, percent = "row")
#> Crosstable: smoking x education (Row %)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total N
#> ──────────┼──────────────────────────────────────────────────┼─────────────────
#> No │ 19.3 44.8 35.9 │ 100.0 926
#> Yes │ 31.3 45.0 23.7 │ 100.0 249
#> ──────────┼──────────────────────────────────────────────────┼─────────────────
#> Total │ 21.9 44.9 33.3 │ 100.0 1175
#>
#> Chi-2(2) = 21.6, p <.001
#> Cramer's V = 0.14
#> Missing values removed: smoking (25).Grouping with by
Stratify the table by a third variable:
cross_tab(sochealth, smoking, education, by = sex)
#> Crosstable: smoking x education (N) | sex = Female
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 95 220 160 │ 475
#> Yes │ 38 62 31 │ 131
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 133 282 191 │ 606
#>
#> Chi-2(2) = 7.1, p = .029
#> Cramer's V = 0.11
#> Missing values removed: smoking (14).
#>
#> Crosstable: smoking x education (N) | sex = Male
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 84 195 172 │ 451
#> Yes │ 40 50 28 │ 118
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 124 245 200 │ 569
#>
#> Chi-2(2) = 15.6, p <.001
#> Cramer's V = 0.17
#> Missing values removed: smoking (11).For more than one grouping variable, use
interaction():
cross_tab(sochealth, smoking, education,
by = interaction(sex, age_group))
#> Crosstable: smoking x education (N) | sex x age_group = Female.25-34
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 23 49 29 │ 101
#> Yes │ 9 9 7 │ 25
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 32 58 36 │ 126
#>
#> Chi-2(2) = 2.1, p = .356
#> Cramer's V = 0.13
#> Missing values removed: smoking (4).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Male.25-34
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 9 42 32 │ 83
#> Yes │ 11 11 4 │ 26
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 20 53 36 │ 109
#>
#> Chi-2(2) = 14.2, p <.001
#> Cramer's V = 0.36
#> Missing values removed: smoking (3).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Female.35-49
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 24 73 48 │ 145
#> Yes │ 10 20 8 │ 38
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 34 93 56 │ 183
#>
#> Chi-2(2) = 3.0, p = .223
#> Cramer's V = 0.13
#> Missing values removed: smoking (7).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Male.35-49
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 33 59 60 │ 152
#> Yes │ 14 17 7 │ 38
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 47 76 67 │ 190
#>
#> Chi-2(2) = 6.9, p = .032
#> Cramer's V = 0.19
#> Missing values removed: smoking (3).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Female.50-64
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 28 63 45 │ 136
#> Yes │ 8 16 6 │ 30
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 36 79 51 │ 166
#>
#> Chi-2(2) = 2.0, p = .360
#> Cramer's V = 0.11
#> Missing values removed: smoking (3).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Male.50-64
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 28 58 42 │ 128
#> Yes │ 8 13 5 │ 26
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 36 71 47 │ 154
#>
#> Chi-2(2) = 2.1, p = .343
#> Cramer's V = 0.12
#> Missing values removed: smoking (4).
#>
#> Crosstable: smoking x education (N) | sex x age_group = Female.65-75
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 20 35 38 │ 93
#> Yes │ 11 17 10 │ 38
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 31 52 48 │ 131
#>
#> Chi-2(2) = 2.5, p = .282
#> Cramer's V = 0.14
#>
#> Crosstable: smoking x education (N) | sex x age_group = Male.65-75
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 14 36 38 │ 88
#> Yes │ 7 9 12 │ 28
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 21 45 50 │ 116
#>
#> Chi-2(2) = 1.4, p = .499
#> Cramer's V = 0.11
#> Missing values removed: smoking (1).Ordinal variables
When both variables are ordered factors, cross_tab()
automatically switches from Cramer’s V to Kendall’s Tau-b:
cross_tab(sochealth, self_rated_health, education)
#> Crosstable: self_rated_health x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Poor │ 28 28 5 │ 61
#> Fair │ 86 118 62 │ 266
#> Good │ 102 263 193 │ 558
#> Very good │ 44 118 133 │ 295
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 260 527 393 │ 1180
#>
#> Chi-2(6) = 73.2, p <.001
#> Kendall's Tau-b = 0.20
#> Missing values removed: self_rated_health (20).You can override the automatic selection with
assoc_measure:
cross_tab(sochealth, self_rated_health, education, assoc_measure = "gamma")
#> Crosstable: self_rated_health x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Poor │ 28 28 5 │ 61
#> Fair │ 86 118 62 │ 266
#> Good │ 102 263 193 │ 558
#> Very good │ 44 118 133 │ 295
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 260 527 393 │ 1180
#>
#> Chi-2(6) = 73.2, p <.001
#> Goodman-Kruskal Gamma = 0.31
#> Missing values removed: self_rated_health (20).Confidence intervals for effect sizes
Add a 95% confidence interval for the association measure with
assoc_ci = TRUE:
cross_tab(sochealth, smoking, education, assoc_ci = TRUE)
#> Crosstable: smoking x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 179 415 332 │ 926
#> Yes │ 78 112 59 │ 249
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 257 527 391 │ 1175
#>
#> Chi-2(2) = 21.6, p <.001
#> Cramer's V = 0.14, 95% CI [0.08, 0.19]
#> Missing values removed: smoking (25).Weighted cross-tabulations
Weights are supplied via weights, just like in
freq(), and both functions share the same default:
rescale = FALSE (raw weighted counts).
cross_tab(sochealth, smoking, education, weights = weight)
#> Crosstable: smoking x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 176 417 324 │ 917
#> Yes │ 79 114 60 │ 253
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 254 531 384 │ 1170
#>
#> Chi-2(2) = 21.3, p <.001
#> Cramer's V = 0.13
#> Weight: weight
#> Missing values removed: smoking (25).Pass rescale = TRUE to make the weighted total match the
unweighted sample size:
cross_tab(sochealth, smoking, education, weights = weight, rescale = TRUE)
#> Crosstable: smoking x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> No │ 176 419 325 │ 921
#> Yes │ 79 115 60 │ 254
#> ──────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 256 534 386 │ 1175
#>
#> Chi-2(2) = 21.4, p <.001
#> Cramer's V = 0.13
#> Weight: weight (rescaled)
#> Missing values removed: smoking (25).Monte Carlo simulation
The chi-squared test relies on an asymptotic approximation that
degrades when expected cell counts are small. cross_tab()
checks Cochran’s rule and warns when a table is too sparse – as in this
student subsample:
students <- subset(sochealth, employment_status == "Student")
cross_tab(students, self_rated_health, education)
#> Crosstable: self_rated_health x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Poor │ 2 3 0 │ 5
#> Fair │ 11 14 4 │ 29
#> Good │ 5 43 28 │ 76
#> Very good │ 4 12 14 │ 30
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 22 72 46 │ 140
#>
#> Chi-2(6) = 23.4, p <.001
#> Kendall's Tau-b = 0.28
#> Warning: 5 expected cells < 5 (41.7%). 1 expected cell < 1. Minimum expected = 0.79. Consider `simulate_p = TRUE` or set globally via `options(spicy.simulate_p = TRUE)`.
#> Missing values removed: self_rated_health (3).The warning itself names the remedy: simulate_p = TRUE
replaces the asymptotic p-value with one estimated by Monte Carlo
simulation (simulate_B random tables). A simulated test has
no degrees of freedom, which is why the statistic prints as
Chi-2(NA):
cross_tab(students, self_rated_health, education,
simulate_p = TRUE, simulate_B = 5000)
#> Crosstable: self_rated_health x education (N)
#>
#> Values │ Lower secondary Upper secondary Tertiary │ Total
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Poor │ 2 3 0 │ 5
#> Fair │ 11 14 4 │ 29
#> Good │ 5 43 28 │ 76
#> Very good │ 4 12 14 │ 30
#> ─────────────┼──────────────────────────────────────────────────┼─────────
#> Total │ 22 72 46 │ 140
#>
#> Chi-2(NA) = 23.4, p <.001 (simulated)
#> Kendall's Tau-b = 0.28
#> Missing values removed: self_rated_health (3).Data frame output
Set output = "data.frame" to get a plain data frame for
further processing. The same argument works in freq(), and
its two values (“default”, “data.frame”) are shared with the
output argument of the table_*() family – but
the rendered engines that family also accepts (“tinytable”, “gt”,
“flextable”, …) are not available here:
cross_tab(sochealth, smoking, education,
percent = "column", output = "data.frame")
#> Values Lower secondary Upper secondary Tertiary
#> 1 No 69.6 78.7 84.9
#> 2 Yes 30.4 21.3 15.1Setting global defaults
You can set tabulation defaults with options() so you
don’t have to repeat arguments. spicy.rescale is read by
cross_tab(), freq(), and
table_categorical(); spicy.percent and
spicy.simulate_p are read by cross_tab():
options(
spicy.percent = "column",
spicy.simulate_p = TRUE,
spicy.rescale = TRUE
)Learn more
-
vignette("association-measures")- choosing the right effect size for your contingency table. -
vignette("table-categorical")- building publication-ready categorical tables. -
vignette("summary-tables-reporting")- end-to-end reporting workflow that ties the fourtable_*helpers together along the APA Table 1 / 2 / 3 sequence (includingtable_regression()for the coefficient table). -
?freqand?cross_tabfor the full argument reference.