13  Group variables

A questionnaire often asks several questions with the same response format. We may need to inspect the same feature of each answer. Writing the operation once reduces repetition, but choosing the columns remains a research decision: similar names alone do not establish that questions belong together.

By the end of this chapter, you will be able to:

We use mutate() from Chapter 12, column selection from Chapter 3, and the familiar is.na() missingness test. Numeric recoding and factor construction are not prerequisites. Prepare the GSS extract following Appendix A, then restore it:

library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")

13.1 Begin with separate operations

Which confidence answers are missing for each respondent? Select confidence in major companies, education, and medicine. Each row continues to represent one respondent:

confidence <- gss24 |>
  select(id, conbus, coneduc, conmedic)
head(confidence)
# A tibble: 6 × 4
     id conbus       coneduc      conmedic    
  <int> <fct>        <fct>        <fct>       
1     1 a great deal a great deal a great deal
2     2 only some    only some    a great deal
3     3 only some    a great deal a great deal
4     4 NA           NA           NA          
5     5 only some    hardly any   only some   
6     6 hardly any   only some    only some   

First create one missingness flag per item using familiar commands. A flag is TRUE when the answer is missing and FALSE when it is recorded:

separate_flags <- confidence |>
  mutate(
    conbus_missing = is.na(conbus),
    coneduc_missing = is.na(coneduc),
    conmedic_missing = is.na(conmedic)
  )
head(separate_flags)
# A tibble: 6 × 7
     id conbus  coneduc conmedic conbus_missing coneduc_missing conmedic_missing
  <int> <fct>   <fct>   <fct>    <lgl>          <lgl>           <lgl>           
1     1 a grea… a grea… a great… FALSE          FALSE           FALSE           
2     2 only s… only s… a great… FALSE          FALSE           FALSE           
3     3 only s… a grea… a great… FALSE          FALSE           FALSE           
4     4 NA      NA      NA       TRUE           TRUE            TRUE            
5     5 only s… hardly… only so… FALSE          FALSE           FALSE           
6     6 hardly… only s… only so… FALSE          FALSE           FALSE           

The source answers remain beside three new logical columns. A flag describes whether an answer is available; it does not say why it is missing or whether the respondent has confidence in that institution. Some GSS items were not asked on every questionnaire.

13.2 Apply one operation across columns

Inside mutate(), across() selects columns and applies the supplied function to each one. Its first argument uses the same column-selection rules as select(). The range conbus:conmedic contains exactly the three adjacent items selected above. As with the predicate supplied to where() in Section 4.1, pass the function itself without parentheses. Here across() calls is.na separately on each column and returns a missingness flag for every entry. Unlike column selection, this transforms the selected columns rather than deciding which complete columns to retain.

Before running the block operation, choose names for the results. The .names argument supplies a naming pattern. In "{.col}_missing", {.col} stands for each source column name and _missing adds a descriptive suffix. This creates new columns beside the source answers. Without this new naming pattern, mutate(across()) would replace the selected answers with flags:

confidence_flags <- confidence |>
  mutate(across(
    conbus:conmedic,
    is.na,
    .names = "{.col}_missing"
  ))
head(confidence_flags)
# A tibble: 6 × 7
     id conbus  coneduc conmedic conbus_missing coneduc_missing conmedic_missing
  <int> <fct>   <fct>   <fct>    <lgl>          <lgl>           <lgl>           
1     1 a grea… a grea… a great… FALSE          FALSE           FALSE           
2     2 only s… only s… a great… FALSE          FALSE           FALSE           
3     3 only s… a grea… a great… FALSE          FALSE           FALSE           
4     4 NA      NA      NA       TRUE           TRUE            TRUE            
5     5 only s… hardly… only so… FALSE          FALSE           FALSE           
6     6 hardly… only s… only so… FALSE          FALSE           FALSE           

The three added columns have the same meanings as the separate flags above. Their sources remain available alongside them. A six-row preview cannot show that every entry agrees. identical() receives two objects and returns one logical result: TRUE requires agreement of the complete objects, including values, row and column order, types, and attributes such as labels.

identical(confidence_flags, separate_flags)
[1] TRUE

The check returns TRUE, so both methods produced the same complete table. A FALSE result would require checking more than the first few printed values.

There are still 3986 respondents. We grouped variables for an operation; we did not collect respondents into regions or reduce them to group summaries. Grouping observations is developed in Chapter 19.

13.2.1 Supply an unnamed function

Sometimes an operation needs an expression rather than a function name alone. \(x) defines a function without assigning it a name. The name x represents the input that the function will receive; the expression after it gives the returned result. Begin with the familiar missingness operation:

confidence_flags_function <- confidence |>
  mutate(across(
    conbus:conmedic,
    \(x) is.na(x),
    .names = "{.col}_missing"
  ))
identical(confidence_flags_function, confidence_flags)
[1] TRUE

For each selected column, across() supplies that entire column as x. is.na(x) returns one flag per entry, so this function has exactly the same result as supplying is.na directly. The comparison is TRUE. The function argument and its input are local to this expression; writing x here does not refer to a column called x. Chapter 15 later uses the same syntax for a two-step scoring operation.

13.3 Count recorded answers

How many of the three confidence answers are recorded for each respondent? Tidystata’s row_non_miss() counts non-missing values across its supplied columns within each row. Name the three items explicitly so that id cannot contribute to the count:

confidence_coverage <- confidence |>
  mutate(n_answered = row_non_miss(conbus, coneduc, conmedic))
tab(confidence_coverage, n_answered)
# A tibble: 4 × 6
  n_answered     n percent   cum  valid valid_cum
       <int> <int>   <dbl> <dbl>  <dbl>     <dbl>
1          0  1351  33.9    33.9 33.9        33.9
2          1    10   0.251  34.1  0.251      34.1
3          2    73   1.83   36.0  1.83       36.0
4          3  2552  64.0   100   64.0       100  

n_answered ranges from 0 through 3. Each frequency counts respondents with that many available answers; its percentage denominator includes all 3986 respondents because every row receives a count. Zero means none of these answers is recorded. It is not a confidence score of zero and does not identify refusal. A count of 3 marks complete responses. The item values remain unchanged; counting their availability does not require converting the categories to scores.

13.4 Conditions across several columns

To inspect respondents with at least one missing confidence answer, use if_any() inside filter(). It applies a condition to each selected column, then returns one logical result per row. With is.na, the result is TRUE if any selected answer is missing:

any_missing <- confidence |>
  filter(if_any(conbus:conmedic, is.na))
head(any_missing)
# A tibble: 6 × 4
     id conbus coneduc conmedic
  <int> <fct>  <fct>   <fct>   
1     4 NA     NA      NA      
2    10 NA     NA      NA      
3    15 NA     NA      NA      
4    16 NA     NA      NA      
5    18 NA     NA      NA      
6    20 NA     NA      NA      
nrow(any_missing)
[1] 1434

if_all() instead requires every selected column to meet the condition:

all_missing <- confidence |>
  filter(if_all(conbus:conmedic, is.na))
head(all_missing)
# A tibble: 6 × 4
     id conbus coneduc conmedic
  <int> <fct>  <fct>   <fct>   
1     4 NA     NA      NA      
2    10 NA     NA      NA      
3    15 NA     NA      NA      
4    16 NA     NA      NA      
5    18 NA     NA      NA      
6    20 NA     NA      NA      
nrow(all_missing)
[1] 1351

The first selection contains 1434 respondents; the second contains 1351. Every respondent in the second also belongs to the first. Neither result establishes refusal: the prepared extract records unavailable answers as NA without retaining every source reason.

To select complete responses, negate the result of if_any() with !:

complete_confidence <- confidence |>
  filter(!if_any(conbus:conmedic, is.na))
nrow(complete_confidence)
[1] 2552
nrow(complete_confidence) + nrow(any_missing)
[1] 3986

The complete and any-missing selections partition all 3986 respondents. This is a check of coverage, not an instruction to discard incomplete records for every analysis. Later, Section 15.4 explicitly chooses a complete-response rule for a teaching score.

13.5 Review

  • across() applies one function to selected columns inside a table operation.
  • .names gives derived columns informative names while retaining their sources.
  • An unnamed function supplies an expression for each selected column.
  • row_non_miss() counts available answers in each row; exclude identifiers and unrelated columns.
  • if_any() and if_all() combine column conditions into one result per observation.
  • Check a block operation against separate operations before extending it.
  • A block of variables differs from groups of observations; missingness flags do not explain missingness.

Chapter 14 next develops arithmetic and numeric recoding. Chapter 15 later applies a shared scoring rule to a questionnaire battery.

13.6 Exercises

  1. Create missingness flags for the three confidence items with separate operations and with across(). Verify exact agreement and confirm that the original answers remain.
  2. Select respondents with any missing answer, all three missing, and no missing answer. Explain the difference between the selections and check that the first and third cover the entire source. Count recorded answers per row, excluding id, and tabulate these counts. Which count identifies each of the all-missing and complete selections? Does zero mean zero confidence?
  3. A researcher writes mutate(across(conbus:conmedic, is.na)) and then treats the results as confidence responses. Diagnose the problem and repair the command. Would using starts_with("con") on the full GSS extract select the same three items?

Worked answers

Load the packages and saved extract in a fresh session:

library(tidyverse)
library(tidystata)
answer_gss24 <- readRDS("gss24.rds")
answer_items <- answer_gss24 |>
  select(id, conbus, coneduc, conmedic)
answer_separate <- answer_items |>
  mutate(
    conbus_missing = is.na(conbus),
    coneduc_missing = is.na(coneduc),
    conmedic_missing = is.na(conmedic)
  )
answer_block <- answer_items |>
  mutate(across(conbus:conmedic, \(x) is.na(x), .names = "{.col}_missing"))
identical(answer_separate, answer_block)
[1] TRUE
identical(select(answer_block, id, conbus, coneduc, conmedic), answer_items)
[1] TRUE

Both checks return TRUE, covering all rows rather than just the displayed preview. The original factors and their missing answers remain intact. The unnamed function receives each item column as x and returns its missingness flags, so it agrees with the named is.na operation.

library(tidyverse)
library(tidystata)
answer_gss24 <- readRDS("gss24.rds")
answer_items <- answer_gss24 |>
  select(id, conbus, coneduc, conmedic)
answer_any <- answer_items |>
  filter(if_any(conbus:conmedic, is.na))
answer_all <- answer_items |>
  filter(if_all(conbus:conmedic, is.na))
answer_complete <- answer_items |>
  filter(!if_any(conbus:conmedic, is.na))
nrow(answer_any)
[1] 1434
nrow(answer_all)
[1] 1351
nrow(answer_complete)
[1] 2552
nrow(answer_any) + nrow(answer_complete) == nrow(answer_items)
[1] TRUE
answer_coverage <- answer_items |>
  mutate(n_answered = row_non_miss(conbus, coneduc, conmedic))
tab(answer_coverage, n_answered)
# A tibble: 4 × 6
  n_answered     n percent   cum  valid valid_cum
       <int> <int>   <dbl> <dbl>  <dbl>     <dbl>
1          0  1351  33.9    33.9 33.9        33.9
2          1    10   0.251  34.1  0.251      34.1
3          2    73   1.83   36.0  1.83       36.0
4          3  2552  64.0   100   64.0       100  

The final check is TRUE. All-missing records are part of the any-missing selection, so adding those two counts would double-count some respondents. These counts describe answer availability. n_answered == 0 identifies all three missing; n_answered == 3 identifies complete responses. Counts below 3 identify the any-missing selection. Zero available answers supplies no information about a person’s confidence level.

library(tidyverse)
library(tidystata)
answer_gss24 <- readRDS("gss24.rds")
answer_flags <- answer_gss24 |>
  mutate(across(conbus:conmedic, is.na, .names = "{.col}_missing"))
answer_gss24 |>
  select(starts_with("con")) |>
  ds()
[1] "conbus"   "coneduc"  "conmedic" "conpress" "consci"   "conlegis"
identical(answer_flags$conbus, answer_gss24$conbus)
[1] TRUE

New names keep the original responses. The prefix selects six confidence items in the full extract, including confidence in the press, science, and the legislature. A convenient name pattern is not a substitute for specifying the intended battery.