library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")14 Logicals and numbers
A recorded number may need a different unit, a comparison with another answer, or a category chosen for a research question. These transformations do different jobs. We will calculate them in new columns, retain the source measurements, and check what happens when an answer is unknown.
By the end of this chapter, you will be able to:
- Use arithmetic and parentheses, then apply calculations to aligned column values.
- Convert units and combine numeric columns belonging to the same respondent.
- Create logical conditions and two-outcome indicators without treating unknown answers as false.
- Recode numeric values into categories and check their boundaries.
- Distinguish meaningful numeric summaries from averages of category codes.
We use mutate() and rename() from Chapter 12, the conditions from Chapter 6, and the summaries from Chapter 9. Some GSS answers are factors, columns with a defined set of categories. We can use their existing names in conditions without changing the categories; Chapter 15 develops their construction and labels. The optional programming chapters are not prerequisites. All calculations describe the sample without survey weights.
Prepare the extract following Appendix A, then restore the saved file in your project folder:
Keep ID, hours worked last week, and years of schooling, using the clearer names from Chapter 12:
work <- gss24 |>
select(id, hrs1, educ)
work_named <- work |>
rename(hours_last_week = hrs1, schooling_years = educ)
head(work_named)# A tibble: 6 × 3
id hours_last_week schooling_years
<int> <dbl> <dbl>
1 1 43 16
2 2 NA 16
3 3 NA 14
4 4 20 12
5 5 NA 13
6 6 NA 14
There are 3986 respondents; only six are displayed. hours_last_week refers to the previous week, not a respondent’s usual schedule or commuting time. Missing hours do not imply zero work.
14.1 Numbers
Use + for addition and - for subtraction:
8 + 2[1] 10
8 - 2[1] 6
The results are 10 and 6. Use * for multiplication and / for division:
8 * 2[1] 16
8 / 2[1] 4
These return 16 and 4. To calculate the average of 8 and 2, add them and divide the sum by two:
(8 + 2) / 2[1] 5
The result is 5. Parentheses make R calculate the sum first.
Use ^ for a power. For example, 2 ^ 3 multiplies three copies of 2:
2 ^ 3[1] 8
The result is 8. Powers are evaluated before multiplication and division, which in turn come before addition and subtraction. Compare:
8 + 2 / 2[1] 9
(8 + 2) / 2[1] 5
The first result is 9: R divides 2 by two before adding 8. The second is 5 because the sum is calculated first. Only the second expression computes the average of the two numbers.
We can pass a calculation directly to a function:
round(8 / 3, digits = 1)[1] 2.7
R calculates 8 / 3, then rounds the result to one decimal place, giving 2.7.
Two calculations can print alike without storing exactly the same number. R represents many decimal fractions approximately. Subtract two fractions, then compare the result with a tenth:
calculated_tenth <- 7 / 10 - 6 / 10
calculated_tenth[1] 0.1
calculated_tenth == 1 / 10[1] FALSE
near(calculated_tenth, 1 / 10)[1] TRUE
The printed result looks like 0.1, but exact comparison returns FALSE. Dplyr’s near() receives the two numbers and returns TRUE when their difference is within a small computational tolerance. Here it accommodates approximation in the calculation. This does not establish that two measured quantities are substantively equivalent. Keep exact comparisons for identifiers, category codes, and the age boundaries used later in this chapter.
14.2 Calculate numeric columns
How many minutes correspond to the recorded hours? Use the column-creation method from Section 12.2. Multiplication by 60 converts each recorded hour into minutes:
work_minutes <- work_named |>
mutate(minutes_last_week = hours_last_week * 60)
head(work_minutes)# A tibble: 6 × 4
id hours_last_week schooling_years minutes_last_week
<int> <dbl> <dbl> <dbl>
1 1 43 16 2580
2 2 NA 16 NA
3 3 NA 14 NA
4 4 20 12 1200
5 5 NA 13 NA
6 6 NA 14 NA
The returned table has the same respondents and one additional column. The number 60 applies the same hours-to-minutes conversion to every row. Both units remain available. Missing hours produce missing minutes, and expressing an answer in minutes does not make the original hour measurement more precise.
Using an existing name replaces that column in the returned table:
work_named |>
mutate(hours_last_week = hours_last_week * 60)# A tibble: 3,986 × 3
id hours_last_week schooling_years
<int> <dbl> <dbl>
1 1 2580 16
2 2 NA 16
3 3 NA 14
4 4 1200 12
5 5 NA 13
6 6 NA 14
7 7 4800 13
8 8 NA 14
9 9 3000 14
10 10 NA 12
# ℹ 3,976 more rows
The calculation returns minutes under a heading that still says hours. This illustrates why a replacement needs a suitable name. Prefer the additional column above, or rename a replacement to match its new unit. Neither command alters work_named unless its result is assigned back.
A calculation can also combine two recorded columns. To inspect educational differences between respondents and their fathers, subtract father’s schooling from respondent’s schooling:
schooling <- gss24 |>
select(id, educ, paeduc)
schooling_changes <- schooling |>
mutate(schooling_difference = educ - paeduc)
head(schooling_changes)# A tibble: 6 × 4
id educ paeduc schooling_difference
<int> <dbl> <dbl> <dbl>
1 1 16 NA NA
2 2 16 NA NA
3 3 14 12 2
4 4 12 NA NA
5 5 13 11 2
6 6 14 5 9
A positive difference means that the respondent reports more completed years than their father; zero means the reported years agree. If either answer is missing, the difference is missing. The appendix explains the parental-variable definition, including its treatment of same-sex parents. This arithmetic describes paired reported schooling, not an effect of parental education.
How far is a respondent’s schooling from the sample average? Tidystata’s std() subtracts the mean of the recorded values and divides by their sample standard deviation. This expresses differences in standard-deviation units. Keep schooling in years alongside the new column:
schooling_standardized <- gss24 |>
select(id, educ) |>
mutate(schooling_z = std(educ))
head(schooling_standardized)# A tibble: 6 × 3
id educ schooling_z
<int> <dbl> <dbl>
1 1 16 0.603
2 2 16 0.603
3 3 14 -0.0816
4 4 12 -0.767
5 5 13 -0.424
6 6 14 -0.0816
Each row still represents the same respondent. A positive schooling_z indicates schooling above this sample’s mean, a negative value indicates schooling below it, and zero corresponds to the mean. A value of 1 is one sample standard deviation above the mean. Missing schooling remains missing; the mean and standard deviation use respondents with recorded schooling.
Compare the original and transformed units with the familiar summary command:
summ(schooling_standardized, educ, schooling_z)# A tibble: 2 × 8
name type n unique min mean sd max
<chr> <chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 educ dbl 3952 21 0 1.42e+ 1 2.92 20
2 schooling_z dbl 3952 21 -4.88 -2.16e-16 1 1.97
educ remains in completed years. schooling_z has a mean approximately zero and a standard deviation of one; a tiny printed difference from zero can arise from computer arithmetic. These reference values come from this sample, so the same schooling value can receive a different standardized value in another sample. Standardization does not make the distribution normal. If there is no variation or fewer than two recorded values, std() returns missing values because the required standard deviation is zero or undefined.
14.3 Calculate with aligned values
c() combines supplied values in their written order. Start with two short vectors whose calculations we can read in full:
x <- c(2, 4, 6)
y <- c(1, 2, 3)
x - y[1] 1 2 3
x * 2[1] 4 8 12
Subtraction pairs values at the same position: 2 - 1, 4 - 2, and 6 - 3, returning 1, 2, and 3. Multiplication applies the single value 2 to every element of x, returning 4, 8, and 12. Adding, dividing, taking powers, and rounding also act element by element:
round(x / 3, digits = 2)[1] 0.67 1.33 2.00
The result is 0.67, 1.33, and 2. Division happens first, then round() rounds each result to two decimal places. Printing may omit trailing zeros. We use equal-length vectors or a single shared value; more general recycling rules are beyond this example.
The same rule applies to recorded columns. To revisit the schooling comparison in Section 14.2, select the respondent’s and father’s years of education from the GSS extract loaded above. Keeping both columns in one table preserves their pairing:
schooling <- gss24 |>
select(id, educ, paeduc)Extract the two columns and subtract:
respondent_years <- schooling$educ
father_years <- schooling$paeduc
length(respondent_years)[1] 3986
length(father_years)[1] 3986
var_type(respondent_years)[1] "double"
(respondent_years - father_years) |>
head()[1] NA NA 2 NA 2 9
Both vectors have 3986 elements. Their positions refer to the same respondents; subtraction returns one schooling difference per respondent. A missing input produces a missing difference. Equal lengths alone do not guarantee correct alignment: sorting only one vector would break these pairs. Keeping related columns in one table helps preserve those connections.
14.4 Conditions and recoding
mutate() can store a logical condition as a new column. The comparison produces one result per respondent:
people <- gss24 |>
select(id, age)
people |>
mutate(older = age >= 65)# A tibble: 3,986 × 3
id age older
<int> <dbl> <lgl>
1 1 33 FALSE
2 2 64 FALSE
3 3 69 TRUE
4 4 19 FALSE
5 5 70 TRUE
6 6 53 FALSE
7 7 48 FALSE
8 8 30 FALSE
9 9 60 FALSE
10 10 25 FALSE
# ℹ 3,976 more rows
Every respondent remains. Known ages at least 65 produce TRUE, younger ages produce FALSE, and unknown ages produce NA. This differs from filter(), which uses a condition to decide which rows remain.
14.4.1 One result per value
A logical vector records TRUE, FALSE, or NA for each element:
x <- c(2, 4, NA, 8)
x >= 4[1] FALSE TRUE NA TRUE
(x >= 4) & (x <= 6)[1] FALSE TRUE NA FALSE
var_type(x >= 4)[1] "logical"
The first comparison returns FALSE, TRUE, NA, and TRUE. Adding the upper bound changes the last answer to FALSE; 8 is greater than 6. & combines conditions at each position, while | asks whether at least one holds. The results are logical vectors with the same length as x.
The next examples use conditions inside mutate() to retain a logical column without selecting rows.
14.4.2 Two outcomes
if_else() receives a condition, an output for true cases, and an output for false cases. Here 1 identifies respondents aged at least 65 and 0 identifies younger respondents. An unknown condition produces a missing result by default:
age_indicators <- people |>
mutate(older = if_else(age >= 65, 1, 0))
head(age_indicators)# A tibble: 6 × 3
id age older
<int> <dbl> <dbl>
1 1 33 0
2 2 64 0
3 3 69 1
4 4 19 0
5 5 70 1
6 6 53 0
The rule creates an indicator without changing recorded ages. Its true and false outputs must have compatible types: both are numeric here. A missing age is not evidence that a respondent is younger than 65.
14.4.3 Several categories
stata_recode() from tidystata maps numeric values to new codes. Supply the input column first, then rules with the old values to the left of ~ and the new code to the right. Inside a rule, 18:34 means the closed interval from 18 through 34, including both endpoints. The special upper bound max in 65:max means any value at least 65. Rules use the original values, and the first matching rule wins. .default = NA makes unmatched values missing:
age_groups <- people |>
mutate(age_group = stata_recode(
age,
18:34 ~ 1,
35:64 ~ 2,
65:max ~ 3,
.default = NA
))
head(age_groups)# A tibble: 6 × 3
id age age_group
<int> <dbl> <dbl>
1 1 33 1
2 2 64 2
3 3 69 3
4 4 19 1
5 5 70 3
6 6 53 2
The new column uses 1 for ages 18-34, 2 for ages 35-64, and 3 for ages 65 and above, retaining every row and the original age. Unknown ages and values outside the stated ranges remain missing. Without .default = NA, stata_recode() would keep unmatched input values, which could mix ages with category codes. A broad range placed first would capture values before a later, narrower rule could apply. Categories are analytical choices, not replacements for the original ages.
14.5 Compare ways to form groups
Age bands can represent a substantive threshold or a position within this particular sample. Those are different definitions. Tidystata’s cut_breaks() places numeric values into intervals at specified break points. Supply the numeric column and breaks, a vector of internal boundaries:
age_cuts <- people |>
mutate(age_bands = cut_breaks(age, breaks = c(35, 65)))
tab(age_cuts, age_bands)# A tibble: 4 × 6
age_bands n percent cum valid valid_cum
<ord> <int> <dbl> <dbl> <dbl> <dbl>
1 [18,35) 983 24.7 24.7 25.5 25.5
2 [35,65) 1964 49.3 73.9 50.9 76.4
3 [65,89] 912 22.9 96.8 23.6 100
4 NA 127 3.19 100 NA NA
With its default right = FALSE, cut_breaks() includes the lower boundary of each interval. Here 35 enters the middle band and 65 enters the oldest band. The outer limits shown in the output are the observed minimum and maximum. The final bracket includes the maximum, so the oldest recorded age remains classified. The default labels reflect this sample’s observed limits; they are not definitions of every possible age in another sample. The new category column leaves the source ages unchanged.
cut_quantile() instead uses sample quantiles, dividing the recorded distribution into four groups by default. Apply it to the same source ages and retain both definitions:
age_cuts <- age_cuts |>
mutate(age_quartiles = cut_quantile(age))
tab(age_cuts, age_quartiles)# A tibble: 5 × 6
age_quartiles n percent cum valid valid_cum
<ord> <int> <dbl> <dbl> <dbl> <dbl>
1 [18,34] 983 24.7 24.7 25.5 25.5
2 (34,48] 1000 25.1 49.7 25.9 51.4
3 (48,64] 964 24.2 73.9 25.0 76.4
4 (64,89] 912 22.9 96.8 23.6 100
5 NA 127 3.19 100 NA NA
Quantile groups instead describe relative position among the recorded ages. In the displayed labels, ( excludes an endpoint and ] includes it. The first interval also includes the observed minimum. Equal ages stay together under the default quantile method, so ties can prevent exactly equal group sizes; a variable with too few distinct values may not support the requested split. The same age can occupy a different quantile group in another sample. Do not choose this method merely to make substantive categories look balanced.
Check known boundaries against their source ages:
age_cuts |>
filter(age %in% c(34, 35, 64, 65) | is.na(age)) |>
select(id, age, age_bands, age_quartiles)# A tibble: 424 × 4
id age age_bands age_quartiles
<int> <dbl> <ord> <ord>
1 2 64 [35,65) (48,64]
2 17 64 [35,65) (48,64]
3 18 NA NA NA
4 42 65 [65,89] (64,89]
5 49 NA NA NA
6 50 65 [65,89] (64,89]
7 57 NA NA NA
8 83 65 [65,89] (64,89]
9 89 34 [18,35) [18,34]
10 98 34 [18,35) [18,34]
# ℹ 414 more rows
count() receives a table and the columns defining groups. It returns one row per observed group and a column n giving its row count. Here that is the number of respondents in each age category, with missing categories counted separately. It retains no individual respondent rows in this returned summary and does not change age_cuts:
count(age_cuts, age_bands)# A tibble: 4 × 2
age_bands n
<ord> <int>
1 [18,35) 983
2 [35,65) 1964
3 [65,89] 912
4 NA 127
count(age_cuts, age_quartiles)# A tibble: 5 × 2
age_quartiles n
<ord> <int>
1 [18,34] 983
2 (34,48] 1000
3 (48,64] 964
4 (64,89] 912
5 NA 127
Both methods retain all 3986 respondents and preserve unknown ages as missing groups. Among recorded ages, the smallest fixed band contains 23.6 percent and the smallest quantile group contains 23.6 percent. Neither split produces a group below 15 percent here. In another sample, inspect small groups before proceeding and reconsider cut points only with a substantive justification. Keep continuous age available: either grouping loses differences within its intervals.
14.6 Review
- Arithmetic operates element by element; parentheses control grouping and
round()controls rounding. - Numeric calculations preserve row alignment; check the meaning and units of each result.
- Missing inputs normally produce missing calculated values. A missing value is not zero or a negative answer.
- Logical comparisons return
TRUE,FALSE, orNA;if_else()supplies two outcomes while preserving unknown conditions. stata_recode()maps original numeric values and ranges to codes. Check endpoints, rule order, and unmatched values.cut_breaks()forms intervals at chosen boundaries;cut_quantile()forms sample-relative groups. Check ties, group sizes, and missingness.- Preserve original measurements alongside transformations. Numeric category codes do not establish meaningful distances.
- Optional:
std()expresses a numeric measure in sample standard-deviation units;near()compares calculated decimals with a small computational tolerance.
Next, Chapter 15 gives codes readable category labels, controls their display order, and develops an explicit questionnaire-scoring rule.
14.7 Exercises
Keep ID and hours worked, rename the hours column, and create minutes worked as a separate column. Check the dimensions and explain what happens to unknown hours.
Apply the age-group rule to respondents aged 34, 35, 64, or 65, and to those with unknown ages. Explain which category each known boundary enters and why unknown ages stay missing. Compare the membership with
cut_breaks()at 35 and 65. Would quantile groups answer the same substantive question? Diagnose a rule that puts18:max ~ 1before the three age-band rules. Then explain what omitting.default = NAwould do to an unmatched plain input of 16. Check both predictions.Use the selection and transformation skills from this part of the book to compare years of schooling for respondents with and without a bachelor’s or graduate degree. Keep only
degreeandeduc, exclude missing degrees, create a logical indicator of those two degree categories, and summarize schooling separately for itsTRUEandFALSEgroups. Report the number of non-missing schooling values and the mean in years. Explain why a missing degree must not be silently treated as no college degree, and why averaging the stored category codes would not answer this question. Do not invent a new numeric missing code for the prepared GSS file.Predict and explain. What do
8 + 2 / 2and(8 + 2) / 2return? Which computes the average of 8 and 2? Check the types of12and"12"withvar_type(). Then store"12"as text, convert it to numeric, subtract 4, and divide the difference by 2. Save the result. What happens if the 4 is replaced byNA? If you later change the numeric input, will the saved result update automatically?
Worked answers
Prepare the extract following Appendix A, then restore the saved file in your project folder:
library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")work <- gss24 |>
select(id, hrs1)
result <- work |>
rename(hours_last_week = hrs1) |>
mutate(minutes_last_week = hours_last_week * 60)
head(result)# A tibble: 6 × 3
id hours_last_week minutes_last_week
<int> <dbl> <dbl>
1 1 43 2580
2 2 NA NA
3 3 NA NA
4 4 20 1200
5 5 NA NA
6 6 NA NA
nrow(result)[1] 3986
ncol(result)[1] 3
ds(work)[1] "id" "hrs1"
The result has 3986 rows and three columns. Hours and minutes remain aligned with ID; unknown hours produce unknown minutes. The original work still has ID and its hrs1 heading. Changing units does not change the reference period or precision of the recorded answer.
Prepare the extract following Appendix A, then restore the saved file in your project folder:
library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")people <- gss24 |>
select(id, age) |>
filter(age %in% c(34, 35, 64, 65) | is.na(age))
result <- people |>
mutate(age_group = stata_recode(
age,
18:34 ~ 1,
35:64 ~ 2,
65:max ~ 3,
.default = NA
))
head(result)# A tibble: 6 × 3
id age age_group
<int> <dbl> <dbl>
1 2 64 2
2 17 64 2
3 18 NA NA
4 42 65 3
5 49 NA NA
6 50 65 3
Age 34 receives code 1 (18-34), 35 and 64 receive code 2 (35-64), and 65 receives code 3 (65+). An unknown age remains missing. The preview contains actual respondents, including repeated ages; it is not a list of distinct age-category pairs. A default oldest category would assert information that is absent.
Use plain numeric inputs to inspect rule order and unmatched values directly. These are syntax checks, not fabricated respondents:
stata_recode(c(34, 35, 64, 65), 18:max ~ 1, 35:64 ~ 2, 65:max ~ 3,
.default = NA)[1] 1 1 1 1
stata_recode(16, 18:34 ~ 1, 35:64 ~ 2, 65:max ~ 3)[1] 16
stata_recode(16, 18:34 ~ 1, 35:64 ~ 2, 65:max ~ 3, .default = NA)[1] NA
The broad first rule assigns all four boundary values to code 1 before later rules can apply. With no explicit default, an unmatched 16 remains 16 and could be confused with a category code. The explicit default instead makes it missing. Repair the first command by restoring the non-overlapping age ranges taught above.
Form both alternative groupings from all respondents before inspecting boundary cases. Quantiles calculated on only the boundary subset would describe a different distribution:
answer_cuts <- gss24 |>
select(id, age) |>
mutate(
age_bands = cut_breaks(age, breaks = c(35, 65)),
age_quartiles = cut_quantile(age)
)
answer_cuts |>
filter(age %in% c(34, 35, 64, 65) | is.na(age))# A tibble: 424 × 4
id age age_bands age_quartiles
<int> <dbl> <ord> <ord>
1 2 64 [35,65) (48,64]
2 17 64 [35,65) (48,64]
3 18 NA NA NA
4 42 65 [65,89] (64,89]
5 49 NA NA NA
6 50 65 [65,89] (64,89]
7 57 NA NA NA
8 83 65 [65,89] (64,89]
9 89 34 [18,35) [18,34]
10 98 34 [18,35) [18,34]
# ℹ 414 more rows
count(answer_cuts, age_bands)# A tibble: 4 × 2
age_bands n
<ord> <int>
1 [18,35) 983
2 [35,65) 1964
3 [65,89] 912
4 NA 127
count(answer_cuts, age_quartiles)# A tibble: 5 × 2
age_quartiles n
<ord> <int>
1 [18,34] 983
2 (34,48] 1000
3 (48,64] 964
4 (64,89] 912
5 NA 127
The fixed breaks agree with the earlier age-group membership, including missing ages. Quantile boundaries depend on the recorded sample and need not coincide with these thresholds; they answer a relative-position question. Both methods retain the measured ages for later checks and analyses.
Prepare the extract following Appendix A, then restore the saved file in your project folder:
library(tidyverse)
library(tidystata)
gss24 <- readRDS("gss24.rds")gss24 |>
select(degree, educ) |>
filter(!is.na(degree)) |>
mutate(college = degree %in% c("bachelor's", "graduate")) |>
tabstat(educ, .by = college, .stat = c("n", "mean"))# A tibble: 2 × 4
college type n mean
<lgl> <chr> <int> <dbl>
1 FALSE dbl 2505 12.6
2 TRUE dbl 1440 17.0
degree is categorical, educ is a numeric quantity in completed years, and college is a logical condition. %in% tests membership in the two named categories; the prior filter ensures that an unknown degree is not classified as FALSE. No source-specific missing codes remain after the appendix’s preparation, so we handle ordinary NA values here. The summary has one row per logical group. Its n counts that group’s recorded schooling values, matching the observations used by mean(). Respondents in the TRUE group have more completed schooling on average; this comparison describes the sample and does not establish a causal effect of a degree. The race exercise in Section 8.5 uses a within-race percentage denominator, whereas this exercise averages years within each degree group.
library(tidystata)
8 + 2 / 2[1] 9
(8 + 2) / 2[1] 5
var_type(12)[1] "double"
var_type("12")[1] "character"
The first expression gives 9 because division precedes addition. Parentheses make the second expression add first and return the average, 5. The types are "double" and "character".
number_text <- "12"
x <- as.numeric(number_text)
result <- (x - 4) / 2
result[1] 4
Subtraction gives 8; division gives 4. An unknown input makes the calculation unknown:
(x - NA) / 2[1] NA
The result is NA. Changing x does not update the previously saved result; rerun result <- (x - 4) / 2 to calculate with a new input.