Skip to contents

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: sochealth

Sorting

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: sochealth

Sort 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: sochealth

Cumulative 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: sochealth

Weighted 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: weight

Set 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: sh

Custom 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: sh

Declared 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: sh2

Set 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.1

Setting 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