library(tidyverse)
library(tidystata)8 Tabulate data
- Count categories and numeric values, and distinguish separate distributions from joint and pairwise tables.
- Combine one-way tables and inspect long results without discarding rows.
- Distinguish percentages of all cases from percentages of valid answers, and identify the denominator within each comparison group.
- Describe educational qualifications in the 2024 General Social Survey.
How common is a response, and how does its distribution differ across groups? A frequency table describes the distribution of categories, such as happiness or educational qualifications. It can also count each distinct recorded value of a numeric variable. Every percentage requires us to be clear about who contributes to the calculation.
tab() counts cases and reports percentages and cumulative percentages, tab1() produces a separate distribution for each selected variable, and tab2() cross-tabulates each pair of selected variables. fre() adds total rows to a frequency table.
These tabulation functions come from tidystata and return new tables without changing the data we give them.
The examples build on inspecting a dataset in Chapter 7. We supply the dataset as the first argument, followed by column names without quotation marks: for example, tab(gss24, happy). Commas separate the arguments inside the parentheses. The pipe learned in Chapter 5 gives an equivalent form: gss24 |> tab(happy). We use direct calls when a single operation is enough. A named argument such as .by = sex sets an option; the = here supplies an argument value rather than saving an object. Grouped percentages are explained where they first appear.
After installing the packages (see Section 1.3), load them at the start of your session:
We now move from country records to individual survey respondents, using one survey extract throughout this chapter and the following numerical-summary lessons. The 2024 General Social Survey (GSS) records adults’ demographic characteristics and answers about their families, work, education, and attitudes. The GSS has interviewed adults in the United States since 1972.
The one-time recipe in Appendix A explains how to obtain the freely available source data, prepare the variables, and save the gss24 extract as gss24.rds. Complete that recipe before running the examples below. If that file is in your working directory, load it with:
gss24 <- readRDS("gss24.rds")readRDS() restores the saved R object, and <- stores it under the name gss24. You only need to load it once per session. Each row is one respondent; id is the respondent identifier. A row position or printed line number is not a permanent identity. Unknown answers remain missing rather than becoming response categories. The appendix prepares categorical columns with defined levels so we can tabulate them here without constructing factors ourselves.
Our extract contains 3,986 respondents and 25 variables. The main examples use full gss24. A final application creates a working-age subset and identifies its different percentage denominators explicitly.
8.1 Read frequency tables with tab(), tab1(), and tab2()
8.1.1 Start with numeric frequencies
Frequency tables help us inspect numeric measurements. Are recorded values repeated, and how far apart are the low and high values? In gss24, age records respondents’ ages in years. It measures a quantity rather than a named category, but tab() can still count every distinct recorded value. Store that result so we can inspect it in two ways:
age_freq <- tab(gss24, age)Assignment with <- saves the full table under age_freq without printing it. Read the values and counts first; the next examples explain the percentage columns. Each row represents an exact recorded age, or a missing value, and n counts respondents with that value. These rows are not age brackets. The counts describe stored values: ages recorded in whole years are shared by many respondents.
There are many distinct values, so use the complementary previews from Section 4.4: head/tail to inspect the boundaries, and interval to inspect positions across the interior. Start with print_headtail():
print_headtail(age_freq)# A tibble: 73 × 6
age n percent cum valid valid_cum
<dbl> <int> <dbl> <dbl> <dbl> <dbl>
1 18 33 0.828 0.828 0.855 0.855
2 19 40 1.00 1.83 1.04 1.89
3 20 43 1.08 2.91 1.11 3.01
4 21 36 0.903 3.81 0.933 3.94
5 22 53 1.33 5.14 1.37 5.31
⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮
69 86 11 0.276 96.0 0.285 99.1
70 87 3 0.0753 96.0 0.0777 99.2
71 88 3 0.0753 96.1 0.0777 99.3
72 89 28 0.702 96.8 0.726 100
73 NA 127 3.19 100 NA NA
# ... 63 rows omitted in the middle; use print(x, n = Inf) to show all rows.
The header reports 73 rows, although only ten are displayed. Recorded ages run from 18 to 89. Each non-missing row counts respondents sharing a recorded age. The final NA row counts the 127 respondents with unknown age, not the oldest respondents. The omission message marks the undisplayed middle of the table.
For coverage through the middle, use print_interval() with the same default budget of ten data rows. It displays approximately evenly spaced positions through the table, including the first and last:
print_interval(age_freq)# A tibble: 73 × 6
age n percent cum valid valid_cum
<dbl> <int> <dbl> <dbl> <dbl> <dbl>
1 18 33 0.828 0.828 0.855 0.855
9 26 66 1.66 10.8 1.71 11.2
17 34 82 2.06 24.7 2.12 25.5
25 42 81 2.03 40.1 2.10 41.4
33 50 41 1.03 52.4 1.06 54.1
41 58 55 1.38 63.7 1.43 65.8
49 66 70 1.76 77.4 1.81 79.9
57 74 57 1.43 89.2 1.48 92.1
65 82 18 0.452 94.6 0.466 97.7
73 NA 127 3.19 100 NA NA
# ... 63 rows omitted between displayed positions; use print(x, n = Inf) to
# show all rows.
The row numbers show where those ages occur in the complete table. The selected positions are evenly spaced through distinct recorded values, not through individual respondents. This preview does not produce age brackets, population quantiles, or a random sample. Repeated ages represent multiple respondents, and the missing category still comes last.
Both printing helpers return the entire age_freq table unchanged and invisibly, meaning that R displays the preview without automatically printing the returned object again. They do not remove rows, combine values into bins, or recalculate percentages. In particular, cum and valid_cum still include the categories skipped in the display; adding only the displayed percentages does not reconstruct the distribution. Exact-value frequencies are useful for inspection, while the numerical summaries in Chapter 9 give more compact descriptions of location and spread.
8.1.2 Count response categories
The GSS asks respondents how happy they are, all things considered: very happy, pretty happy, or not too happy. Which answer is most common? Tidystata’s tab() takes a table and the column to describe, returning one row per response category. Start by reading n, the number of respondents in each category:
tab(gss24, happy)# A tibble: 4 × 6
happy n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy 797 20.0 20.0 20.2 20.2
2 pretty happy 2303 57.8 77.8 58.2 78.4
3 not too happy 854 21.4 99.2 21.6 100
4 NA 32 0.803 100 NA NA
Each output row represents a category rather than a respondent. The most common answer is “pretty happy,” with 2,303 respondents. The last row, <NA>, counts the 32 respondents with no recorded answer; they are still part of the dataset. Adding n across all rows gives the original 3,986 respondents.
The result is a new tibble; gss24 is unchanged. Counts tell us how many people gave each answer. The other columns express their relative sizes and make the denominator visible.
8.1.3 Read percentages
Besides the count n, tab() reports:
percent: the percentage of all respondents in each category.cum: the running total ofpercentdown the displayed rows.valid: the percentage among respondents with a recorded answer, excluding missing answers from the denominator.valid_cum: the running total ofvalidacross the non-missing categories.
For “pretty happy,” percent divides its count by all 3,986 respondents, while valid divides by the 3,954 who answered. The valid percentage is 58.2%: more than half of those with a recorded answer chose this category. Missing answers have a count and an all-case percentage, but no valid percentage of their own.
Read cumulative percentages in the displayed category order. At “pretty happy,” valid_cum combines “very happy” and “pretty happy”: 78.4% of valid answers fall in those first two categories. Cumulative percentages depend on order and are useful only when combining successive categories answers a meaningful question. They are usually less informative for unordered categories such as marital status.
The appendix prepares happy as a factor, a column with a defined set of category levels. Those levels retain the source-code order: “very happy,” “pretty happy,” then “not too happy.” That is why these categories appear in this order rather than alphabetically. Cumulative totals follow that level order; preparation has not ranked respondents by how happy they are.
Because only 32 happiness answers are missing, percent and valid differ slightly. Section 8.2 returns to the choice of denominator.
8.1.4 Questions not asked of everyone
An unavailable answer need not be a refusal. The GSS uses ballots, different versions of the questionnaire assigned to parts of the sample. The institutional-confidence questions were asked on only two of the 2024 ballots. We therefore expect substantial missingness in conpress, confidence in the press:
tab(gss24, conpress)# A tibble: 4 × 6
conpress n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 a great deal 225 5.64 5.64 8.68 8.68
2 only some 1045 26.2 31.9 40.3 49.0
3 hardly any 1321 33.1 65.0 51.0 100
4 NA 1395 35.0 100 NA NA
The missing row counts about a third of the sample. percent includes all respondents, including people never assigned this question, in its denominator. valid describes the distribution among people with recorded confidence answers. That denominator excludes missing answers both by design and for other reasons; it is not the number of everyone who was assigned the question. A frequency table alone cannot distinguish these reasons. Consult the questionnaire and source documentation before describing missingness as refusal or lack of confidence.
8.1.5 Cross-tabulate two variables
Does happiness differ with self-rated health? Start by counting each combination of the two answers:
# Two variables: one row for each combination
tab(gss24, happy, health)# A tibble: 20 × 7
happy health n percent cum valid valid_cum
<fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy excellent 246 6.17 6.17 6.24 6.24
2 very happy good 438 11.0 17.2 11.1 17.4
3 very happy fair 96 2.41 19.6 2.44 19.8
4 very happy poor 15 0.376 19.9 0.381 20.2
5 pretty happy excellent 323 8.10 28.0 8.19 28.4
6 pretty happy good 1338 33.6 61.6 33.9 62.3
7 pretty happy fair 565 14.2 75.8 14.3 76.6
8 pretty happy poor 69 1.73 77.5 1.75 78.4
9 not too happy excellent 94 2.36 79.9 2.38 80.8
10 not too happy good 360 9.03 88.9 9.13 89.9
11 not too happy fair 311 7.80 96.7 7.89 97.8
12 not too happy poor 87 2.18 98.9 2.21 100
13 very happy NA 2 0.0502 98.9 NA NA
14 pretty happy NA 8 0.201 99.1 NA NA
15 not too happy NA 2 0.0502 99.2 NA NA
16 NA excellent 2 0.0502 99.2 NA NA
17 NA good 15 0.376 99.6 NA NA
18 NA fair 3 0.0753 99.7 NA NA
19 NA poor 2 0.0502 99.7 NA NA
20 NA NA 10 0.251 100 NA NA
Each row represents a pair of categories. The first row describes respondents who are both very happy and in excellent health. Here, percent is a percentage of the entire sample, and valid uses respondents with answers to both questions. Neither column is a percentage within the excellent-health group.
To compare the counts across health groups more easily, arrange the same combinations as a contingency table with tab2():
tab2(gss24, happy, health)# A tibble: 4 × 6
`happy \\ health` excellent good fair poor `NA`
<fct> <int> <int> <int> <int> <int>
1 very happy 246 438 96 15 2
2 pretty happy 323 1338 565 69 8
3 not too happy 94 360 311 87 2
4 NA 2 15 3 2 10
Happiness categories form the rows and health categories the columns. Each cell is a count, not a percentage. For example, 246 respondents are very happy and report excellent health. The NA row and column retain missing answers; the bottom-right cell counts respondents missing both answers.
For a comparison within excellent health, divide the very-happy count by the sum of the three non-missing happiness counts in that column: 246 / (246 + 323 + 94). This gives about 37%. The corresponding share among respondents in poor health is 9%. These percentages describe those with recorded happiness answers in each health group; they are calculated from the counts, not printed by tab2() above. They show an association in this sample, not that health causes happiness. Section 8.2 shows how to request within-group percentages directly.
8.1.6 Tabulate several variables separately
Sometimes we want a separate distribution for each of several questions, rather than a table of their combinations. tab1(gss24, happy, health, marital) produces three one-way tables:
# Tabulate several variables at once with tab1()
tab1(gss24, happy, health, marital)$happy
# A tibble: 4 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy 797 20.0 20.0 20.2 20.2
2 pretty happy 2303 57.8 77.8 58.2 78.4
3 not too happy 854 21.4 99.2 21.6 100
4 NA 32 0.803 100 NA NA
$health
# A tibble: 5 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 excellent 665 16.7 16.7 16.8 16.8
2 good 2151 54.0 70.6 54.3 71.0
3 fair 975 24.5 95.1 24.6 95.6
4 poor 173 4.34 99.4 4.36 100
5 NA 22 0.552 100 NA NA
$marital
# A tibble: 6 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 married 1659 41.6 41.6 41.8 41.8
2 widowed 269 6.75 48.4 6.77 48.6
3 divorced 579 14.5 62.9 14.6 63.1
4 separated 130 3.26 66.2 3.27 66.4
5 never married 1334 33.5 99.6 33.6 100
6 NA 15 0.376 100 NA NA
By default, the result is a named list: one object containing three separate tables. Its printed headings, $happy, $health, and $marital, identify those tables. Within each, value holds that variable’s categories; the remaining columns have the same meanings as in tab(). Each table has its own denominator, so do not add counts across the three tables: the same respondents contribute to each distribution.
For a single rectangular result, set .type = "tibble":
tab1(gss24, happy, health, marital, .type = "tibble")# A tibble: 15 × 7
variable value n percent cum valid valid_cum
<chr> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 happy very happy 797 20.0 20.0 20.2 20.2
2 happy pretty happy 2303 57.8 77.8 58.2 78.4
3 happy not too happy 854 21.4 99.2 21.6 100
4 happy NA 32 0.803 100 NA NA
5 health excellent 665 16.7 16.7 16.8 16.8
6 health good 2151 54.0 70.6 54.3 71.0
7 health fair 975 24.5 95.1 24.6 95.6
8 health poor 173 4.34 99.4 4.36 100
9 health NA 22 0.552 100 NA NA
10 marital married 1659 41.6 41.6 41.8 41.8
11 marital widowed 269 6.75 48.4 6.77 48.6
12 marital divorced 579 14.5 62.9 14.6 63.1
13 marital separated 130 3.26 66.2 3.27 66.4
14 marital never married 1334 33.5 99.6 33.6 100
15 marital NA 15 0.376 100 NA NA
This argument changes the output format, not the calculation. The tables are stacked into one tibble, and the variable column identifies which question each row describes. Read variable and value together: “good” is a health answer, whereas “married” is a marital-status answer. Each row is now a category within a particular variable, rather than a combination of happiness, health, and marital status.
The happiness rows have exactly the same counts and percentages as the $happy table above; the same holds for health and marital status. Within each block, percent adds to 100 across all categories, and valid adds to 100 across non-missing answers. Each block’s counts add to 3,986, so summing n over the whole stacked result would count the same respondents three times. Stacking makes several distributions convenient to inspect together; it does not turn them into one distribution with a shared denominator.
8.1.7 Cross-tabulate several variable pairs
How are happiness, health, and marital status related to one another? We can inspect all three pairs without writing three separate calls. Give tab2() the same variables we just passed to tab1() and save its result. Typing its name displays the list at the normal console width:
pair_tables <- tab2(gss24, happy, health, marital)
pair_tables$`happy x health`
# A tibble: 4 × 6
`happy \\ health` excellent good fair poor `NA`
<fct> <int> <int> <int> <int> <int>
1 very happy 246 438 96 15 2
2 pretty happy 323 1338 565 69 8
3 not too happy 94 360 311 87 2
4 NA 2 15 3 2 10
$`happy x marital`
# A tibble: 4 × 7
`happy \\ marital` married widowed divorced separated `never married` `NA`
<fct> <int> <int> <int> <int> <int> <int>
1 very happy 473 42 94 13 172 3
2 pretty happy 965 160 329 67 774 8
3 not too happy 209 64 152 47 379 3
4 NA 12 3 4 3 9 1
$`health x marital`
# A tibble: 5 × 7
`health \\ marital` married widowed divorced separated `never married` `NA`
<fct> <int> <int> <int> <int> <int> <int>
1 excellent 295 29 97 13 228 3
2 good 953 156 285 65 685 7
3 fair 355 61 161 43 353 2
4 poor 48 22 34 8 60 1
5 NA 8 1 2 1 8 2
The result is a list of three cross-tabulations. A narrow display may omit columns and name them in its footer; those columns remain in the returned tables. Inspect the result in your editor’s viewer when necessary. The list names identify the pairs:
happy x health: happiness in the rows and health in the columns, repeating our earlier two-variable table.happy x marital: happiness in the rows and marital status in the columns.health x marital: health in the rows and marital status in the columns.
Each cell counts respondents with that pair of answers, and NA rows or columns retain missing answers. Each table uses only its two named variables: someone with missing marital status can still contribute to a recorded happiness-health combination in the first table. Across all cells, including missing categories, each table counts the full sample once.
In the happiness-marital-status table, very-happy answers are more common among married respondents than among those who have never married. Divide the very-happy count in each column by that column’s total across the three recorded happiness categories. The resulting shares are 28.7% for married respondents and 13.0% for never-married respondents. These denominators exclude missing happiness answers within each marital-status group. The comparison describes an association, not an effect of marriage on happiness.
The three functions answer different questions even when given the same variables. tab1(gss24, happy, health, marital) describes each variable separately. tab2(gss24, happy, health, marital) describes each pair separately. By contrast, tab(gss24, happy, health, marital) would count joint combinations of all three, such as very happy, excellent health, and married. Three pairwise tables do not show how the happiness-health relationship varies within marital-status groups; that requires an additional grouping step, planned for a later chapter beyond this chapter.
8.1.8 Include totals with fre()
fre() works like tab() but adds total rows. These make the two denominators visible in the output:
fre(gss24, happy)# A tibble: 6 × 6
happy n percent cum valid valid_cum
<chr> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy 797 20.0 20.0 20.2 20.2
2 pretty happy 2303 57.8 77.8 58.2 78.4
3 not too happy 854 21.4 99.2 21.6 100
4 Valid Total 3954 99.2 NA 100 NA
5 NA 32 0.803 100 NA NA
6 Total 3986 100 NA NA NA
Valid Total counts the 3,954 non-missing answers; Total counts all 3,986 respondents. The missing-answer count bridges the difference. These are summary rows, not additional response categories or additional respondents.
8.1.9 Optional reference: preserve the input table class
You can continue using tibbles throughout this chapter. If you work with other table classes, tidystata normally returns newly calculated tables in the same container as the input: a base data.frame, tibble, or data.table. This preserves the kind of table, not its original rows or columns.
as.data.frame() converts our tibble to a base data frame. Save the converted table separately:
gss_base <- as.data.frame(gss24)class() takes an object and reports its class names. Compare the same tabulation with the two inputs:
class(tab(gss24, happy))[1] "tbl_df" "tbl" "data.frame"
class(tab(gss_base, happy))[1] "data.frame"
The tibble result has classes "tbl_df", "tbl", and "data.frame"; the base result has class "data.frame". A tibble is also a kind of data frame, which is why its class listing contains all three names. The category counts and percentages agree; only the container differs.
To try a data.table input, install the optional package once if needed:
install.packages("data.table")data.table::as.data.table() converts the input without attaching the package. The :: notation names the package that supplies the function:
gss_dt <- data.table::as.data.table(gss24)
class(tab(gss_dt, happy))[1] "data.table" "data.frame"
The result has classes "data.table" and "data.frame", so a data.table input produces a data.table frequency table. These calls return new results without changing gss24 or its converted copies.
For functions that return several tables, distinguish the outer list from the tables inside it. Store the default tab1() result as tables_dt. Double brackets, [[1]], extract the first table from that list:
tables_dt <- tab1(gss_dt, happy, health, marital)
class(tables_dt)[1] "list"
class(tables_dt[[1]])[1] "data.table" "data.frame"
The outer object is a "list"; its first table is a data.table. The other two tables preserve the same class. The three-variable tab2() result follows this pattern too: a list of cross-tabulations, each in the input’s table class.
An explicit output request can override this default. As we saw above, .type = "tibble" in tab1() both stacks the one-way tables and requests a tibble, including when the input is a data.table:
class(tab1(gss_dt, happy, health, marital, .type = "tibble"))[1] "tbl_df" "tbl" "data.frame"
The class names are now "tbl_df", "tbl", and "data.frame". The distributions remain the same. When deciding how to use a result, check both its structure, one table or a list, and the class of the tables it contains.
8.2 Application: educational qualifications in the 2024 GSS
This section puts the tools together to describe education in the 2024 GSS, and shows why every percentage needs a clearly stated denominator.
8.2.1 Who has which degree?
What is the distribution of respondents’ highest educational qualifications? Each category of degree represents a highest degree, so someone with a graduate degree is not counted again under bachelor’s or high school:
tab(gss24, degree)# A tibble: 6 × 6
degree n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 less than high school 362 9.08 9.08 9.10 9.10
2 high school 1817 45.6 54.7 45.7 54.8
3 associate/junior college 360 9.03 63.7 9.05 63.8
4 bachelor's 863 21.7 85.3 21.7 85.5
5 graduate 577 14.5 99.8 14.5 100
6 NA 7 0.176 100 NA NA
The last row shows that 7 respondents did not report a degree. That is why the two kinds of percentages differ:
percentdivides by all 3,986 respondents.validdivides by the 3,979 respondents who answered.
Here the difference is tiny. Among those who answered, 45.7% have a high school diploma as their highest degree, and 36.2% hold a bachelor’s or graduate degree.
For questions with more missing answers, the choice of denominator matters a great deal (see Exercise 2).
8.2.2 Comparing groups: which denominator?
To compare women and men, we need a distribution of degrees within each category of sex. The argument .by = sex asks tab() to perform its calculation separately for those groups, for this operation only.
tab() retains missing values, including a separate group for the 30 respondents whose sex is missing. This does not change the denominators within the recorded male and female groups. First save and display the returned table:
degrees_by_sex <- tab(gss24, degree, .by = sex)
degrees_by_sex# A tibble: 17 × 7
sex degree n percent cum valid valid_cum
<fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 male less than high school 135 7.55 7.55 7.57 7.57
2 male high school 860 48.1 55.7 48.2 55.8
3 male associate/junior college 141 7.89 63.6 7.90 63.7
4 male bachelor's 395 22.1 85.7 22.1 85.8
5 male graduate 253 14.2 99.8 14.2 100
6 male NA 3 0.168 100 NA NA
7 female less than high school 208 9.59 9.59 9.60 9.60
8 female high school 952 43.9 53.5 43.9 53.5
9 female associate/junior college 219 10.1 63.6 10.1 63.6
10 female bachelor's 466 21.5 85.1 21.5 85.1
11 female graduate 322 14.8 99.9 14.9 100
12 female NA 2 0.0922 100 NA NA
13 NA less than high school 19 63.3 63.3 67.9 67.9
14 NA high school 5 16.7 80 17.9 85.7
15 NA bachelor's 2 6.67 86.7 7.14 92.9
16 NA graduate 2 6.67 93.3 7.14 100
17 NA NA 2 6.67 100 NA NA
Each output row is now a combination of sex and degree. The normal preview may omit some combinations. For this interpretation, deliberately display every row with print(). The named argument n = Inf removes the finite row limit for this call; it changes the display without selecting or recalculating the data:
print(degrees_by_sex, n = Inf)# A tibble: 17 × 7
sex degree n percent cum valid valid_cum
<fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 male less than high school 135 7.55 7.55 7.57 7.57
2 male high school 860 48.1 55.7 48.2 55.8
3 male associate/junior college 141 7.89 63.6 7.90 63.7
4 male bachelor's 395 22.1 85.7 22.1 85.8
5 male graduate 253 14.2 99.8 14.2 100
6 male NA 3 0.168 100 NA NA
7 female less than high school 208 9.59 9.59 9.60 9.60
8 female high school 952 43.9 53.5 43.9 53.5
9 female associate/junior college 219 10.1 63.6 10.1 63.6
10 female bachelor's 466 21.5 85.1 21.5 85.1
11 female graduate 322 14.8 99.9 14.9 100
12 female NA 2 0.0922 100 NA NA
13 NA less than high school 19 63.3 63.3 67.9 67.9
14 NA high school 5 16.7 80 17.9 85.7
15 NA bachelor's 2 6.67 86.7 7.14 92.9
16 NA graduate 2 6.67 93.3 7.14 100
17 NA NA 2 6.67 100 NA NA
Read the group and category columns together. Within each sex, percent adds to 100 across all degree rows, including any missing answers. valid adds to 100 across recorded degrees only. To answer “What share of women who reported a degree hold a graduate degree?”, find the female/graduate row and read valid. Its denominator is women with a recorded degree, not all degree holders in the sample.
We can combine categories within the same denominator. Adding the valid percentages for bachelor’s and graduate degrees gives 36.4% of women and 36.3% of men. This comparison describes the degree distribution within each sex among those who reported it.
A different question, “What share of graduate-degree holders are women?”, needs a different denominator. Swap the roles of the two variables and read valid within each recorded degree group:
sex_by_degree <- tab(gss24, sex, .by = degree)
print(sex_by_degree, n = Inf)# A tibble: 17 × 7
degree sex n percent cum valid valid_cum
<fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 less than high school male 135 37.3 37.3 39.4 39.4
2 less than high school female 208 57.5 94.8 60.6 100
3 less than high school NA 19 5.25 100 NA NA
4 high school male 860 47.3 47.3 47.5 47.5
5 high school female 952 52.4 99.7 52.5 100
6 high school NA 5 0.275 100 NA NA
7 associate/junior college male 141 39.2 39.2 39.2 39.2
8 associate/junior college female 219 60.8 100 60.8 100
9 bachelor's male 395 45.8 45.8 45.9 45.9
10 bachelor's female 466 54.0 99.8 54.1 100
11 bachelor's NA 2 0.232 100 NA NA
12 graduate male 253 43.8 43.8 44 44
13 graduate female 322 55.8 99.7 56 100
14 graduate NA 2 0.347 100 NA NA
15 NA male 3 42.9 42.9 60 60
16 NA female 2 28.6 71.4 40 100
17 NA NA 2 28.6 100 NA NA
Now percentages add to 100 within each degree category. The female/graduate row’s valid uses all graduate-degree holders with recorded sex as its denominator. Its percent also includes graduate-degree holders whose sex is missing, so the columns can differ. The missing-degree group is shown separately and is not part of the graduate denominator. Swapping the variables has changed the question, not the respondents’ qualifications.
For the respondents with both answers, the underlying cell counts are the same whichever distribution we calculate. tab2() arranges those counts in rows and columns, retaining missing-answer cells at the edges:
tab2(gss24, degree, sex)# A tibble: 6 × 4
`degree \\ sex` male female `NA`
<fct> <int> <int> <int>
1 less than high school 135 208 19
2 high school 860 952 5
3 associate/junior college 141 219 0
4 bachelor's 395 466 2
5 graduate 253 322 2
6 NA 3 2 2
With degree in the rows and sex in the columns, dividing a graduate cell by its column total across recorded degrees asks about degrees within a sex; dividing it by its row total across recorded sexes asks about sex within that degree. Exclude the missing-answer row or column from these valid-answer totals. Before reporting a percentage, state its denominator: all respondents or valid answers, and which group. None of these commands overwrites gss24.
These are unweighted sample descriptions: each respondent contributes equally. They describe associations, not causal effects. Survey weighting is beyond this edition.
8.3 Application: a GSS analysis subset
Suppose our next analysis will concern schooling and working hours among GSS respondents aged 18 through 64. Age alone defines this subset; we are not yet requiring a reported schooling value or working-hours answer.
Use the two inclusive bounds, then the familiar select() to keep the ID and variables needed for this question. Assign the result a new name:
working_age <- gss24 |>
filter(age >= 18, age <= 64) |>
select(id, age, educ, hrs1)
head(working_age)# A tibble: 6 × 4
id age educ hrs1
<int> <dbl> <dbl> <dbl>
1 1 33 16 43
2 2 64 16 NA
3 4 19 12 20
4 6 53 14 NA
5 7 48 13 80
6 8 30 14 NA
nrow(gss24)[1] 3986
nrow(working_age)[1] 2947
The original has 3986 respondents; working_age contains 2947. Each retained row represents a respondent with a known age in the specified range. educ records years of schooling and hrs1 hours worked last week. Their missing answers remain in the subset, and head() displays only its first six rows. The appendix explains how the survey variables were prepared.
This rule defines who is available for the proposed sample analysis. It does not establish employment, require a valid answer to every question, or produce a population estimate. The original gss24 is unchanged. We can now describe this subset with the same tabulation tools:
tab(working_age, educ)# A tibble: 20 × 6
educ n percent cum valid valid_cum
<dbl> <int> <dbl> <dbl> <dbl> <dbl>
1 0 10 0.339 0.339 0.342 0.342
2 2 1 0.0339 0.373 0.0342 0.376
3 3 3 0.102 0.475 0.103 0.478
4 5 6 0.204 0.679 0.205 0.684
5 6 18 0.611 1.29 0.615 1.30
6 7 7 0.238 1.53 0.239 1.54
7 8 22 0.747 2.27 0.752 2.29
8 9 30 1.02 3.29 1.03 3.32
9 10 58 1.97 5.26 1.98 5.30
10 11 88 2.99 8.25 3.01 8.30
11 12 706 24.0 32.2 24.1 32.4
12 13 243 8.25 40.4 8.30 40.7
13 14 418 14.2 54.6 14.3 55.0
14 15 172 5.84 60.5 5.88 60.9
15 16 629 21.3 81.8 21.5 82.4
16 17 152 5.16 87.0 5.19 87.6
17 18 193 6.55 93.5 6.60 94.2
18 19 52 1.76 95.3 1.78 96.0
19 20 118 4.00 99.3 4.03 100
20 NA 21 0.713 100 NA NA
Each row counts respondents in working_age with that many years of schooling, plus a row for missing schooling answers. Here percent divides by all 2,947 respondents in the age-defined subset. valid divides by the 2,926 subset respondents with recorded schooling. Neither denominator is the full gss24 sample. The earlier degree tables continue to describe full gss24; creating this subset has not changed them. In Chapter 9 we return to full gss24 for numerical summaries.
8.4 Chapter review
tab()counts records by category and reports percentages, retaining missing answers.percentuses all observations andvaliduses complete answers;fre()adds explicit total rows.tab1()tabulates variables separately;tab2()produces a count matrix for each selected pair..byrequests within-group distributions; check the grouping and missingness rules before interpreting a percentage.print_headtail()andprint_interval()preview a long frequency table without changing its contents or recalculating percentages.- Optional:
class()checks the returned container; supported input classes are preserved by default, while.typecan request tibbles explicitly.
Always identify a percentage’s denominator. Next, Chapter 9 describes numerical center and spread.
8.5 Exercises
1. Syntax and meaning. Tabulate marital in gss24 with tab() and then with fre(). Identify the total rows. Which column of tab() gives the share of each marital status among respondents who answered the question?
2. Interpretation. Tabulate conpress (confidence in the press). Use the questionnaire-version explanation to say why percent and valid differ. Which would you report to describe recorded confidence answers, and whose answers does that denominator include?
3. Adapt the comparison. Within each recorded category of race, what percentage of respondents who answered the degree question hold a graduate degree? Keep missing answers visible in the table, but exclude them from the percentage denominator and explain how you did so. State why this differs from asking what share of graduate-degree holders belong to each racial category.
4. Predict and diagnose. Predict how many separate distributions or pairwise tables tab1(gss24, happy, health, marital) and tab2(gss24, happy, health, marital) produce. Compare them with tab(gss24, happy, health, marital). A colleague adds n over the entire stacked tab1() result and reports a sample three times too large. Diagnose that claim, and explain why adding only the percentages shown in an interval preview is also incorrect.
Worked answers
Load the packages and the prepared GSS extract as shown at the start of the chapter before running an answer. Each answer uses the complete extract independently.
tab(gss24, marital)# A tibble: 6 × 6
marital n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 married 1659 41.6 41.6 41.8 41.8
2 widowed 269 6.75 48.4 6.77 48.6
3 divorced 579 14.5 62.9 14.6 63.1
4 separated 130 3.26 66.2 3.27 66.4
5 never married 1334 33.5 99.6 33.6 100
6 NA 15 0.376 100 NA NA
fre(gss24, marital)# A tibble: 8 × 6
marital n percent cum valid valid_cum
<chr> <int> <dbl> <dbl> <dbl> <dbl>
1 married 1659 41.6 41.6 41.8 41.8
2 widowed 269 6.75 48.4 6.77 48.6
3 divorced 579 14.5 62.9 14.6 63.1
4 separated 130 3.26 66.2 3.27 66.4
5 never married 1334 33.5 99.6 33.6 100
6 Valid Total 3971 99.6 NA 100 NA
7 NA 15 0.376 100 NA NA
8 Total 3986 100 NA NA NA
Read the valid column. Each category’s count is divided by the number of recorded marital-status answers and multiplied by 100. percent also includes the 15 respondents with a missing answer in its denominator. The category counts agree between the two commands; fre() adds total rows that describe the same respondents. Valid Total counts recorded answers, while Total includes missing answers. Cumulative percentages are not especially useful here because the marital-status categories have no natural low-to-high order.
tab(gss24, conpress)# A tibble: 4 × 6
conpress n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 a great deal 225 5.64 5.64 8.68 8.68
2 only some 1045 26.2 31.9 40.3 49.0
3 hardly any 1321 33.1 65.0 51.0 100
4 NA 1395 35.0 100 NA NA
About a third of the values are missing. Most of these are not refusals: the GSS gives different sets of questions to different parts of the sample, and the confidence questions were asked on only two of the 2024 ballots, meaning versions of the questionnaire. percent therefore includes respondents who were never asked in its denominator. To describe the distribution of recorded confidence answers, report valid and state that it excludes missing answers, including those missing by design. It describes those who provided an answer, not everyone assigned the question, and it is still an unweighted sample description.
degrees_by_race <- tab(gss24, degree, .by = race)
print(degrees_by_race, n = Inf)# A tibble: 23 × 7
race degree n percent cum valid valid_cum
<fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 white less than high school 174 7.13 7.13 7.13 7.13
2 white high school 1095 44.9 52.0 44.9 52.0
3 white associate/junior college 210 8.60 60.6 8.61 60.6
4 white bachelor's 574 23.5 84.1 23.5 84.1
5 white graduate 387 15.9 100.0 15.9 100
6 white NA 1 0.0410 100 NA NA
7 black less than high school 95 11.7 11.7 11.8 11.8
8 black high school 443 54.7 66.4 54.8 66.6
9 black associate/junior college 81 10 76.4 10.0 76.6
10 black bachelor's 120 14.8 91.2 14.9 91.5
11 black graduate 69 8.52 99.8 8.54 100
12 black NA 2 0.247 100 NA NA
13 other less than high school 82 12.5 12.5 12.6 12.6
14 other high school 245 37.4 49.9 37.6 50.2
15 other associate/junior college 59 9.01 58.9 9.06 59.3
16 other bachelor's 157 24.0 82.9 24.1 83.4
17 other graduate 108 16.5 99.4 16.6 100
18 other NA 4 0.611 100 NA NA
19 NA less than high school 11 13.8 13.8 13.8 13.8
20 NA high school 34 42.5 56.2 42.5 56.2
21 NA associate/junior college 10 12.5 68.8 12.5 68.8
22 NA bachelor's 12 15 83.8 15 83.8
23 NA graduate 13 16.2 100 16.2 100
Find the graduate-degree row within each recorded racial category and read valid. tab() calculates that column from recorded degrees within the racial category, so missing degrees are excluded from its denominator automatically. The missing-race group remains a separate group; do not include it in comparisons among the recorded racial categories. These denominators are respondents with both answers in each racial category, not all graduate-degree holders. percent retains missing degrees in each group’s denominator and can differ from valid. To ask instead about racial categories within graduate-degree holders, the distribution would be race within groups defined by degree: changing the grouping changes the denominator.
tab1(gss24, happy, health, marital)$happy
# A tibble: 4 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy 797 20.0 20.0 20.2 20.2
2 pretty happy 2303 57.8 77.8 58.2 78.4
3 not too happy 854 21.4 99.2 21.6 100
4 NA 32 0.803 100 NA NA
$health
# A tibble: 5 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 excellent 665 16.7 16.7 16.8 16.8
2 good 2151 54.0 70.6 54.3 71.0
3 fair 975 24.5 95.1 24.6 95.6
4 poor 173 4.34 99.4 4.36 100
5 NA 22 0.552 100 NA NA
$marital
# A tibble: 6 × 6
value n percent cum valid valid_cum
<fct> <int> <dbl> <dbl> <dbl> <dbl>
1 married 1659 41.6 41.6 41.8 41.8
2 widowed 269 6.75 48.4 6.77 48.6
3 divorced 579 14.5 62.9 14.6 63.1
4 separated 130 3.26 66.2 3.27 66.4
5 never married 1334 33.5 99.6 33.6 100
6 NA 15 0.376 100 NA NA
tab2(gss24, happy, health, marital)$`happy x health`
# A tibble: 4 × 6
`happy \\ health` excellent good fair poor `NA`
<fct> <int> <int> <int> <int> <int>
1 very happy 246 438 96 15 2
2 pretty happy 323 1338 565 69 8
3 not too happy 94 360 311 87 2
4 NA 2 15 3 2 10
$`happy x marital`
# A tibble: 4 × 7
`happy \\ marital` married widowed divorced separated `never married` `NA`
<fct> <int> <int> <int> <int> <int> <int>
1 very happy 473 42 94 13 172 3
2 pretty happy 965 160 329 67 774 8
3 not too happy 209 64 152 47 379 3
4 NA 12 3 4 3 9 1
$`health x marital`
# A tibble: 5 × 7
`health \\ marital` married widowed divorced separated `never married` `NA`
<fct> <int> <int> <int> <int> <int> <int>
1 excellent 295 29 97 13 228 3
2 good 953 156 285 65 685 7
3 fair 355 61 161 43 353 2
4 poor 48 22 34 8 60 1
5 NA 8 1 2 1 8 2
tab(gss24, happy, health, marital)# A tibble: 90 × 8
happy health marital n percent cum valid valid_cum
<fct> <fct> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 very happy excellent married 142 3.56 3.56 3.61 3.61
2 very happy excellent widowed 9 0.226 3.79 0.229 3.84
3 very happy excellent divorced 30 0.753 4.54 0.764 4.61
4 very happy excellent separated 3 0.0753 4.62 0.0764 4.68
5 very happy excellent never married 61 1.53 6.15 1.55 6.24
6 very happy good married 272 6.82 13.0 6.92 13.2
7 very happy good widowed 27 0.677 13.6 0.687 13.8
8 very happy good divorced 50 1.25 14.9 1.27 15.1
9 very happy good separated 5 0.125 15.0 0.127 15.2
10 very happy good never married 82 2.06 17.1 2.09 17.3
# ℹ 80 more rows
tab1(gss24, happy, health, marital, .type = "tibble")# A tibble: 15 × 7
variable value n percent cum valid valid_cum
<chr> <fct> <int> <dbl> <dbl> <dbl> <dbl>
1 happy very happy 797 20.0 20.0 20.2 20.2
2 happy pretty happy 2303 57.8 77.8 58.2 78.4
3 happy not too happy 854 21.4 99.2 21.6 100
4 happy NA 32 0.803 100 NA NA
5 health excellent 665 16.7 16.7 16.8 16.8
6 health good 2151 54.0 70.6 54.3 71.0
7 health fair 975 24.5 95.1 24.6 95.6
8 health poor 173 4.34 99.4 4.36 100
9 health NA 22 0.552 100 NA NA
10 marital married 1659 41.6 41.6 41.8 41.8
11 marital widowed 269 6.75 48.4 6.77 48.6
12 marital divorced 579 14.5 62.9 14.6 63.1
13 marital separated 130 3.26 66.2 3.27 66.4
14 marital never married 1334 33.5 99.6 33.6 100
15 marital NA 15 0.376 100 NA NA
tab1() produces three separate one-variable distributions. Each counts the same respondents once, so adding counts across all three blocks counts each respondent three times. Stacking changes the format without changing those denominators. tab2() produces the three variable pairs; each pair counts the full sample once, including missing-answer combinations. tab() produces one distribution of joint three-answer combinations, which also counts the full sample once.
An interval preview displays selected rows from a complete table. Omitted categories still contribute to cumulative percentages and to the denominators. Adding the visible percentages therefore describes only the displayed categories; it does not reconstruct the complete distribution. None of these printing or tabulating calls alters gss24.