library(tidyverse)
library(tidystata)28 Data structures
Selecting a column can return either a table or a vector. Fitting a model returns another kind of object altogether. This optional chapter explains these structures and how they affect the operations we can use.
By the end of this chapter, you will be able to:
- Construct vectors, calculate with them, and extract values by position.
- Select columns using stored names or tests of their types.
- Recognize how tables, lists, matrices, and model objects differ.
- Define a small function and apply it to several columns, choosing a list or numeric result.
The practical chapters explain column extraction (Section 9.1.1), table versus vector results (Section 5.4), and elementwise calculations (Section 14.3). Read Chapter 27 for functions as objects and implicit conversion. The practical chapters teach their own syntax; this chapter provides a fuller explanation of the structures behind it. Load the packages for the examples below:
28.1 Column types
The type under a column name tells us how R represents its values. Table 28.1 lists common abbreviations you will encounter. Use it as a reference rather than a list to memorize.
| Display | Type | Example or meaning |
|---|---|---|
<dbl> |
Double | Numeric values, including values with decimals |
<int> |
Integer | Whole numbers stored as integers |
<chr> |
Character | Text, including country names such as "Albania" |
<lgl> |
Logical | TRUE, FALSE, or NA |
<fct> |
Factor | Categories with a defined set of levels |
<ord> |
Ordered factor | Categories with an explicit order |
<date> |
Date | Calendar dates |
<dttm> |
Date-time | Dates and times together |
<list> |
List | Elements that may contain more complex objects |
Numbers written as 74 in our code are doubles even though they have no fractional part. A whole-number value does not have to use integer storage. Both integer and double columns can represent numeric quantities.
A type is also different from a variable’s statistical meaning. A numeric identifier is not a quantity to average. The numeric region codes in the country source are categorical information, rather than amounts of region. Factors provide another way to represent categories; Chapter 12 gives them a worked treatment. Dates and date-times also have special classes that let R interpret their underlying values as times.
The ordinary columns we have used are atomic vectors: within each, the values share one basic type. Tibbles can also contain list-columns and other more complex columns. That is why a table can hold richer objects than these country records. We will use ordinary columns for now. Type information helps us choose an operation, but the codebook tells us whether that operation makes substantive sense.
28.2 Survey setup
The advanced selection examples below use the prepared GSS extract from Appendix A. Save that extract as gss24.rds in your working directory. readRDS() reads the saved R object; <- gives it the name gss24 in this session:
gss24 <- readRDS("gss24.rds")28.3 Selecting columns with vectors and predicates
The selection lesson in Chapter 3 introduced names, ranges, and literal name patterns. The ranking preparation in Section 4.1 then taught stored names with all_of() and any_of(), and numeric column selection with where(is.numeric). Here we reuse those tools on survey data and extend the column tests. These examples use the gss24 object read above.
28.3.1 A stored list of names
Sometimes the required names are stored as data, such as a list agreed with a collaborator. Make a character vector with quoted names, then use all_of() to interpret those values as column names:
required_names <- c("id", "age", "maeduc")
gss24 |>
select(all_of(required_names))# A tibble: 3,986 × 3
id age maeduc
<int> <dbl> <dbl>
1 1 33 14
2 2 64 NA
3 3 69 12
4 4 19 NA
5 5 70 11
6 6 53 5
7 7 48 12
8 8 30 NA
9 9 60 0
10 10 25 NA
# ℹ 3,976 more rows
The columns follow the vector’s order. required_names is an R object outside the table; its text values identify columns inside the table. Using all_of() makes that distinction explicit.
all_of() requires every requested name to exist. The next call intentionally produces an error because income is absent (the extract uses realinc for family income):
gss24 |>
select(all_of(c("id", "income")))Error in `select()`:
ℹ In argument: `all_of(c("id", "income"))`.
Caused by error in `all_of()`:
! Can't subset elements that don't exist.
✖ Element `income` doesn't exist.
The message identifies all_of() as the failing step and names income as the absent column. An error is useful when the analysis requires a variable: it alerts us to a spelling problem or a different file. If a list contains genuinely optional variables, any_of() selects whichever names are present and omits the absent ones:
gss24 |>
select(any_of(c("id", "income")))# A tibble: 3,986 × 1
id
<int>
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
10 10
# ℹ 3,976 more rows
This returns ID alone. It has not created an income column or verified that ID alone is sufficient for the analysis. Choose between the helpers according to whether a missing variable should stop your work.
28.3.2 Column types
Recall that where() retains complete columns using a predicate, which returns one TRUE or FALSE decision per column. As in Section 4.1, is.numeric() tests numeric storage; is.character() tests for text, and is.logical() tests for logical values. Inside where(), supply the function itself without calling it on a particular column:
is.numeric(gss24$age)[1] TRUE
gss24 |>
select(where(is.numeric))# A tibble: 3,986 × 9
id age educ paeduc maeduc childs hrs1 realinc wtssps
<int> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 1 33 16 NA 14 2 43 43560 1.89
2 2 64 16 NA NA 0 NA 24502. 1.15
3 3 69 14 12 12 0 NA 43560 0.916
4 4 19 12 NA NA 0 20 58080 2.29
5 5 70 13 11 11 3 NA 24502. 1.01
6 6 53 14 5 5 2 NA 2722. 1.17
7 7 48 13 12 12 6 80 117565. 3.21
8 8 30 14 NA NA 1 NA 3267 0.424
9 9 60 14 NA 0 2 50 117565. 2.15
10 10 25 12 NA NA 0 NA NA 2.24
# ℹ 3,976 more rows
gss24 |>
select(where(is.character))# A tibble: 3,986 × 0
gss24 |>
select(where(is.logical))# A tibble: 3,986 × 0
The numeric result includes ID, age, schooling, number of children, hours, income and the weight variable. The character and logical selections each return zero-column tibbles with all 3,986 rows: this prepared extract has no columns of those types. Categorical answers use factors. Each predicate examines columns, not respondents, and keeps the complete selected columns, including missing entries.
Numeric storage does not tell us whether averaging is sensible. The GSS ID is stored numerically but identifies respondents rather than measuring a quantity. A type selection is a computational convenience, not a substantive choice of outcomes or predictors. Similarly, is.factor() can select factor columns in a prepared survey, but their category meanings still require the codebook.
28.3.3 Positions counted from the end
last_col() selects one column at the end. Its offset argument counts backward from that end, beginning at zero. Thus last_col(3) means one column, fourth from the end, rather than the last three columns:
gss24 |>
select(last_col())# A tibble: 3,986 × 1
wtssps
<dbl>
1 1.89
2 1.15
3 0.916
4 2.29
5 1.01
6 1.17
7 3.21
8 0.424
9 2.15
10 2.24
# ℹ 3,976 more rows
gss24 |>
select(last_col(3))# A tibble: 3,986 × 1
conpress
<fct>
1 a great deal
2 only some
3 only some
4 NA
5 NA
6 only some
7 only some
8 only some
9 only some
10 NA
# ℹ 3,976 more rows
gss24 |>
select(last_col(1):last_col())# A tibble: 3,986 × 2
conlegis wtssps
<fct> <dbl>
1 only some 1.89
2 only some 1.15
3 hardly any 0.916
4 NA 2.29
5 hardly any 1.01
6 a great deal 1.17
7 hardly any 3.21
8 hardly any 0.424
9 only some 2.15
10 NA 2.24
# ℹ 3,976 more rows
Here the results contain wtssps, then conpress, then the last two columns (conlegis and wtssps). last_col(1) identifies the second-to-last column and : extends the selection through the last.
28.3.4 Listing selected names
Recall the basic ds() check from Section 3.2. It returns column names in their current order:
ds(gss24) [1] "id" "age" "sex" "race" "region" "degree"
[7] "educ" "paeduc" "maeduc" "marital" "childs" "wrkstat"
[13] "hrs1" "realinc" "polviews" "partyid" "happy" "health"
[19] "conbus" "coneduc" "conmedic" "conpress" "consci" "conlegis"
[25] "wtssps"
Base R’s general name extractor, names(), gives the same result for this table. The additional arguments to ds() let us identify which names to list. Here 1:3 means column positions 1 through 3 in the current order; : includes both endpoints. It does not refer to respondent rows:
ds(gss24, 1:3)[1] "id" "age" "sex"
The result contains the names of the first three columns as a character vector. To receive a table instead, supply .type = "table":
ds(gss24, 1:3, .type = "table")# A tibble: 3 × 1
name
<chr>
1 id
2 age
3 sex
This result has one column, name, and one row per listed variable. The option changes the form of the name listing; the contents and order of gss24 remain unchanged. These name listings complement the table selections in Chapter 3.
A predicate can inspect values as well as type. The following selects columns whose proportion of missing entries is below 20%:
gss24 |>
select(where(~ mean(is.na(.x)) < 0.2))# A tibble: 3,986 × 17
id age sex race region degree educ maeduc marital childs wrkstat
<int> <dbl> <fct> <fct> <fct> <fct> <dbl> <dbl> <fct> <dbl> <fct>
1 1 33 male black northeast bache… 16 14 never … 2 workin…
2 2 64 male white northeast gradu… 16 NA never … 0 retired
3 3 69 female white northeast assoc… 14 12 married 0 retired
4 4 19 male other northeast high … 12 NA never … 0 workin…
5 5 70 female white northeast high … 13 11 divorc… 3 retired
6 6 53 male other northeast assoc… 14 5 married 2 unempl…
7 7 48 female white northeast high … 13 12 married 6 workin…
8 8 30 female white northeast high … 14 NA divorc… 1 in sch…
9 9 60 female black northeast assoc… 14 0 married 2 workin…
10 10 25 male black northeast high … 12 NA never … 0 unempl…
# ℹ 3,976 more rows
# ℹ 6 more variables: realinc <dbl>, polviews <fct>, partyid <fct>,
# happy <fct>, health <fct>, wtssps <dbl>
Here ~ introduces a short function recipe, and .x stands for the column currently being checked. is.na(.x) returns one logical value for each row. mean() averages these logical values using TRUE as 1 and FALSE as 0, so its result is the missing fraction among all 3,986 entries in that column. < 0.2 tests whether that fraction is strictly below 20%. where() keeps a column only if this final test is TRUE.
The selected names depend on actual missingness in this extract. For example, wtssps has 677 missing values out of 3,986, below 20%, so it remains; passing this rule does not make its missing weights usable. Variables with at least 20% missing entries are excluded. This demonstrates a rule, not a recommendation to discard research variables solely because of missingness. Questionnaire routing and ballot assignment matter, and the rule also needs reconsideration for a table with no rows.
Tidystata’s varlist() accepts a quoted Stata-style selection string inside a selecting function. In this string, * matches zero or more characters, ? matches exactly one character, and spaces separate alternative patterns:
gss24 |>
select(varlist("id ?aeduc con*"))# A tibble: 3,986 × 9
id paeduc maeduc conbus coneduc conmedic conpress consci conlegis
<int> <dbl> <dbl> <fct> <fct> <fct> <fct> <fct> <fct>
1 1 NA 14 a great deal a great d… a great… a great… hardl… only so…
2 2 NA NA only some only some a great… only so… a gre… only so…
3 3 12 12 only some a great d… a great… only so… a gre… hardly …
4 4 NA NA NA NA NA NA NA NA
5 5 11 11 only some hardly any only so… NA only … hardly …
6 6 5 5 hardly any only some only so… only so… a gre… a great…
7 7 12 12 only some only some a great… only so… a gre… hardly …
8 8 NA NA hardly any hardly any hardly … only so… NA hardly …
9 9 NA 0 only some only some only so… only so… only … only so…
10 10 NA NA NA NA NA NA NA NA
# ℹ 3,976 more rows
The result contains ID, paeduc, maeduc and the six confidence columns. In ?aeduc, ? matches the single initial letter; con* matches names beginning with con. The helper returns matching column positions, not matching cell values. For adjacent columns, use an unquoted tidyselect range such as age:race, rather than a hyphenated range inside the string. The core helpers suffice if you have never used Stata.
28.4 Optional reference: other column types
The main selection examples above use GSS. This optional reference returns to GSS categories and uses two familiar sources for types absent from the prepared extract: monthly dates and durations from economics, and country names with labelled region codes from lifeexp. Summary functions also encounter dates, durations, categories, and logical values. We can examine these types using recorded data instead of manufacturing a table with every possible class. The economics dataset shipped with ggplot2 contains monthly US economic series from the Federal Reserve’s FRED collection. Its date column identifies the month; uempmed records median unemployment duration in weeks.
economic_time <- ggplot2::economics |>
select(date, uempmed) |>
mutate(unemployment_duration = as.difftime(uempmed, units = "weeks"))
head(economic_time)# A tibble: 6 × 3
date uempmed unemployment_duration
<date> <dbl> <drtn>
1 1967-07-01 4.5 4.5 weeks
2 1967-08-01 4.7 4.7 weeks
3 1967-09-01 4.6 4.6 weeks
4 1967-10-01 4.9 4.9 weeks
5 1967-11-01 4.7 4.7 weeks
6 1967-12-01 4.8 4.8 weeks
economic_time |>
codebook()# A tibble: 3 × 4
variable type n unique
<chr> <chr> <int> <int>
1 date date 574 574
2 uempmed double 574 128
3 unemployment_duration duration [weeks] 574 128
economic_time |>
summ(date, uempmed, unemployment_duration)A list is returned as statistics are heterogeneous.
$numeric
# A tibble: 2 × 8
name type n unique min mean sd max
<chr> <chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 uempmed dbl 574 128 4 8.61 4.11 25.2
2 unemployment_duration drtn [weeks] 574 128 4 8.61 4.11 25.2
$date
# A tibble: 1 × 8
name type n unique mean sd min max
<chr> <chr> <int> <int> <chr> <drtn> <chr> <chr>
1 date date 574 574 1991-05-17 5047.869 days 1967-07-01 2015-04-01
select() keeps the two observed columns. mutate() adds a duration representation of the same unemployment values. Base R’s as.difftime() attaches the stated time unit; it does not create individual unemployment spells or recover interview times. Each row is a month, and its duration is already a median across unemployed people. A mean of these monthly medians describes the monthly series, not the mean duration across all unemployed individuals.
codebook() reports the variables’ types and coverage. Naming the three columns in summ() requests their summaries explicitly. summ() separates the calendar-date and duration summaries as appropriate. Its date range identifies the period represented by the data. A mean calendar date locates the center of those monthly records; it is not an economic outcome. Read the units and returned components before interpreting their numbers.
Text and categories need different interpretation. The GSS degree factor stores category codes, whereas the following logical column encodes whether a respondent reports a bachelor’s or graduate degree:
survey_types <- gss24 |>
select(degree, educ) |>
mutate(college = if_else(
is.na(degree), NA,
degree %in% c("bachelor's", "graduate")
))
survey_types |>
summ(degree, educ, college)degree is a factor (**).
# A tibble: 3 × 8
name type n unique min mean sd max
<chr> <chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 **degree fct 3979 5 1 2.87 1.27 5
2 educ dbl 3952 21 0 14.2 2.92 20
3 college lgl 3979 2 0 0.362 0.481 1
lifeexp |>
summ(country, lexp)country is non-numeric and thus removed.
# A tibble: 1 × 7
type n unique min mean sd max
<chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 dbl 68 20 57 71.7 5.16 79
is.na(degree) tests for an unknown degree. if_else() returns a logical missing value there; elsewhere %in% tests membership in the two named categories. Without that explicit missingness rule, %in% would classify an unknown degree as false. The logical mean is the proportion meeting the rule among known degrees, because true contributes 1 and false contributes 0. It is an unweighted sample proportion. The factor-code mean is not years of education; the educ mean is. summ() flags factor codes and omits country names with a message, retaining the numeric life-expectancy summary. Use a frequency table for text categories. Requesting only text would leave no suitable variable and produce an error.
28.4.1 Check whether a summary is meaningful
With only the dataset argument, summ() examines all columns rather than requiring us to name them individually. This is convenient, but it makes us responsible for deciding which results make sense:
# Select all variables at once
summ(lifeexp)country is non-numeric and thus removed.
region is a labelled variable (*).
# A tibble: 5 × 8
name type n unique min mean sd max
<chr> <chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 *region dbl+lbl 68 3 1 1.5 0.743 3
2 popgrowth dbl 68 32 -2.5 0.553 1.06 2.6
3 lexp dbl 68 20 57 71.7 5.16 79
4 gnppc dbl 67 64 170 9137. 11497. 46650
5 safewater dbl 54 32 36 77.2 20.6 100
summ() drops country because it is text. region is stored as a labelled number (1, 2, 3) with a region name attached to each code, so its “mean” of 1.5 is just the average of the codes. tidystata marks it with * and prints a message; tab() is more appropriate for it.
An all-missing numeric input can also arise from genuine records. Select GSS respondents whose working hours are unavailable, retaining their unchanged hours column:
gss24 |>
filter(is.na(hrs1)) |>
summ(hrs1)Warning: hrs1 is entirely missing; retained with n = 0.
# A tibble: 1 × 7
type n unique min mean sd max
<chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
1 dbl 0 0 NA NA NA NA
The summary retains the variable with n = 0: no observed hours remain within this deliberately selected group. Its mean is not zero. This diagnostic selection does not estimate hours among employed people or replace anyone’s missing answer. The type and the selection rule together determine what a summary can mean.
28.5 Constructing and extracting vectors
seq() generates a sequence from a beginning to an end using by; rep() repeats its input times times:
positions <- seq(from = 1, to = 5, by = 2)
positions[1] 1 3 5
rep("A", times = 3)[1] "A" "A" "A"
The results are 1, 3, 5 and "A", "A", "A". Square brackets extract values by position:
letters_to_use <- c("A", "B", "C", "D", "E")
letters_to_use[positions][1] "A" "C" "E"
The extracted values are "A", "C", and "E", in the requested order. Position extraction depends on the vector’s current order, as slice() does for table rows.
28.6 Dimensions of data
Chapter 2 introduced the structures shown in Figure 2.1. Here we examine how to construct them: data can be organized as a single value, a sequence, a table, or a collection of smaller objects. The examples below illustrate these shapes in R. Use these examples as a reference for reading objects beyond the ordinary tables used in the practical chapters.
A single value is our starting point:
1[1] 1
We can picture it as zero-dimensional (0D) information. R stores it as a vector of length one.
An atomic vector holds a sequence of values of one type. Here 1:3 produces the sequence 1, 2, 3:
1:3[1] 1 2 3
As Chapter 17 introduced, a list can hold different types together, such as a number and some text:
list(1, "a")[[1]]
[1] 1
[[2]]
[1] "a"
A matrix arranges values of one type in rows and columns:
matrix(1:4, nrow = 2) [,1] [,2]
[1,] 1 3
[2,] 2 4
This example has two rows and two columns.
A data frame also has rows and columns, but its columns can have different types:
example_frame <- data.frame(number = 1:2, text = c("a", "b"))
example_frame number text
1 1 a
2 2 b
Here one column contains numbers and the other contains text. The two columns have the same number of rows.
A tibble is a modern form of data frame used throughout the tidyverse. We can display the same table as a tibble:
tibble::as_tibble(example_frame)# A tibble: 2 × 2
number text
<int> <chr>
1 1 a
2 2 b
An array can have more than two dimensions, such as rows, columns, and layers:
array(1:8, dim = c(2, 2, 2)), , 1
[,1] [,2]
[1,] 1 3
[2,] 2 4
, , 2
[,1] [,2]
[1,] 5 7
[2,] 6 8
R displays the two layers separately.
A nested list contains other lists:
list(list(1))[[1]]
[[1]][[1]]
[1] 1
Nesting represents a hierarchy rather than the regular shape of an array.
Use the overview in Figure 2.1 to compare the shapes of these results. The examples here supply the construction details that the introductory tour deferred.
28.7 Reuse an operation
The coverage checks in Section 10.2 calculate how much of a column is recorded. We can give that repeated operation a name. A function definition uses function() to name its inputs and braces to enclose its instructions:
nonmissing_percent <- function(x) {
100 * mean(!is.na(x))
}
nonmissing_percent(gss24$age)[1] 96.81385
Here x is an argument, a name used inside the function for the supplied column. is.na(x) tests each entry for missingness; ! reverses those tests. mean() treats TRUE as 1 and FALSE as 0, and multiplication by 100 expresses the recorded fraction as a percentage. R returns the last expression’s result. Calling nonmissing_percent(gss24$age) supplies the age vector without changing it; the result is about 96.8 percent.
This function expects a vector with at least one entry. Entirely missing entries produce 0 percent; entirely recorded entries produce 100 percent. An empty vector has no denominator, so this definition does not supply a percentage for it. Recorded values are not necessarily valid answers, and the denominator here includes every respondent: GSS ballot assignment and questionnaire routing can explain unavailable values.
Now repeat the operation for three columns. A tibble is also a list of columns, so purrr’s map() can apply a function to each selected column. Pass the function’s name without parentheses: map() supplies each column as its input.
coverage_columns <- gss24 |>
select(age, educ, realinc)
coverage_list <- purrr::map(coverage_columns, nonmissing_percent)
coverage_list$age
[1] 96.81385
$educ
[1] 99.14701
$realinc
[1] 89.38786
The result is a named list with three components. Each component contains one percentage, and the column names identify which percentage is which. map() repeats the same operation on separate inputs; the arithmetic inside our function already works across all entries of one column.
If every call returns one number, map_dbl() collects those numbers into a double vector instead:
coverage_vector <- purrr::map_dbl(coverage_columns, nonmissing_percent)
coverage_vector age educ realinc
96.81385 99.14701 89.38786
The named vector contains the same three percentages as the list. The _dbl suffix requests one numeric value per input; a function returning several values for a column would not meet that requirement. Both routes keep the input order and names. The earlier across() lesson applies functions inside table operations; map() works over a list’s components and lets us choose the structure of the collected results. Use the form that fits the next step, rather than converting every result into a table automatically.
28.8 Working with tibbles
Section 5.2 used a shared table structure to connect selection and sorting. Here we examine what happens when an analysis also produces objects that are not tables.
Section 2.3 explained how a shared table structure helps connect operations. Here we follow the objects returned when a workflow includes model fitting.
This approach also helps when an analysis needs something other than a table. Fitting a statistical model, for example, produces a model object. We can turn its results into a tibble and continue working with them as a table.
Figure 2.2 in Section 2.3 shows the contrast: the base R route moves through several object types, while the right-hand route keeps country data, coefficients, starred coefficients, and report columns as tibbles. Here we run those steps. Fitting and tidying are combined between the first two tables in that figure; the model itself remains an lm object.
The code below makes this comparison concrete with the full country dataset lifeexp supplied by tidystata. Both routes relate life expectancy at birth (lexp, years) to GNI per capita (gnppc, U.S. dollars), using exactly the same observations. This is a preview of object types, not a regression lesson or a causal interpretation.
Load tidystata and use as.data.frame() to represent the dataset as an ordinary data frame. data = countries_df supplies that table to lm(), and lexp ~ gnppc names the response and predictor. na.action = na.omit tells both routes to leave out countries missing either model variable; it does not replace missing values or change the source data. Here, class() reports the kind of object returned at each stage; read its output alongside the figure’s labels.
The model summary is a named container. Its "coefficients" component contains the coefficient matrix. Recall that [[1]] extracted a component in Section 8.1.9. Here [["coefficients"]] selects by name instead of position and returns the component itself. Assigning that result to coefs makes the matrix available for inspection.
library(tidystata)
countries_df <- as.data.frame(lifeexp)
fit <- lm(lexp ~ gnppc, data = countries_df, na.action = na.omit)
fit_summary <- summary(fit)
coefs <- fit_summary[["coefficients"]]
class(countries_df)[1] "data.frame"
class(fit)[1] "lm"
class(fit_summary)[1] "summary.lm"
class(coefs)[1] "matrix" "array"
coefs Estimate Std. Error t value Pr(>|t|)
(Intercept) 6.862781e+01 5.580240e-01 122.983612 1.032290e-78
gnppc 3.315091e-04 3.817333e-05 8.684311 1.786005e-12
The reported classes follow the figure: a data frame, a model ("lm"), a model summary ("summary.lm"), and a matrix. R reports the matrix as "matrix" "array" because a matrix is a two-dimensional array. The final output contains the model’s coefficients and associated statistics.
as_tibble() converts the same country data to a tibble without changing its values. The same model procedure can then end in a tibble. The broom package, installed with the tidyverse, turns a fitted model into a tibble with tidy(). As in Chapter 5, |> passes a result to the next function: fit the model, then turn its results into a table.
library(tidyverse)
library(broom)
countries_tbl <- as_tibble(lifeexp)
coef_table <- lm(lexp ~ gnppc, data = countries_tbl, na.action = na.omit) |>
tidy()
coef_table# A tibble: 2 × 5
term estimate std.error statistic p.value
<chr> <dbl> <dbl> <dbl> <dbl>
1 (Intercept) 68.6 0.558 123. 1.03e-78
2 gnppc 0.000332 0.0000382 8.68 1.79e-12
The model in the middle is still an lm object, but the coefficients arrive as a tibble with one row per term. The numbers are the same as in the matrix above. Because the result is a tibble, we can use mutate() to add reporting information without changing the estimates.
The p.value column contains the p-values. We will attach conventional significance stars: *** below .001, ** below .01, * below .05, and an empty string otherwise. These labels do not measure effect size or substantive importance. They describe tests under the model’s assumptions, not evidence that the relationship is causal.
case_when() checks conditions in order and uses the first match. Each ~ separates a condition from its result; .default supplies the result when none matches. Checking is.na() first preserves an unknown p-value as missing text (NA_character_) rather than giving it an empty label.
starred <- coef_table |>
mutate(stars = case_when(
is.na(p.value) ~ NA_character_,
p.value < .001 ~ "***",
p.value < .01 ~ "**",
p.value < .05 ~ "*",
.default = ""
))
starred# A tibble: 2 × 6
term estimate std.error statistic p.value stars
<chr> <dbl> <dbl> <dbl> <dbl> <chr>
1 (Intercept) 68.6 0.558 123. 1.03e-78 ***
2 gnppc 0.000332 0.0000382 8.68 1.79e-12 ***
The table has one additional character column, stars; all coefficient values remain unchanged. We can now use select() to keep the term, estimate, and reporting label:
report_columns <- starred |>
select(term, estimate, stars)
report_columns# A tibble: 2 × 3
term estimate stars
<chr> <dbl> <chr>
1 (Intercept) 68.6 ***
2 gnppc 0.000332 ***
Both starred and report_columns are tibbles. Choosing columns changes what the report displays, not the fitted relationship. Retain the full coefficient table when standard errors or other statistics are needed.
A coefficient table can now be filtered, relabeled, combined with other results, or exported with write_csv() (Section 18.4), like any other data in this book. This differs from Stata, where regress prints its coefficient table as output in the Results window. That table is not data you can edit directly. Its numbers are stored in matrices such as r(table), and turning them into an editable table takes extra commands and conversions. In R, the coefficient table is simply another tibble.
The main lesson is that raw data, summaries, and model results can all be organized as tibbles. Familiar table operations can then help us inspect, change, and export them. Some steps, such as model fitting, still need other kinds of objects. We return to a tibble when a table is useful, rather than expecting every step to produce one.
I have three suggestions for people learning R:
- Use tibbles as your main structure for analysis. Learn how their columns work, then explore other structures as your tasks require them.
- Do not try to memorize everything. Look up what you need; with use, it will become familiar.
- Let go of things you do not use.
28.9 Review
var_type()identifies object kinds, and type predicates such asis.numeric()test columns.all_of()requires stored names to exist,any_of()tolerates absent names,where()applies column tests,last_col()locates columns from the end, andds()lists selected names.c(),seq(), andrep()construct vectors; square brackets extract entries by position, whose alignment matters in paired calculations.list(),matrix(),array(), anddata.frame()organize different structures;as_tibble()converts a table to a tibble.- Each step must accept the structure returned by the preceding one, including when a model result is converted to a tibble.
function()defines a reusable operation;map()returns a list, whilemap_dbl()requires one number per input and returns a numeric vector.- Optional:
varlist()selects columns through Stata-style name patterns.
Check structure as well as values when choosing the next operation, including when a model stores more than a table of results.
28.10 Exercises
Load the packages at the start of this chapter. The first two exercises supply their own objects. The third restores the prepared GSS extract and defines its function independently; none requires the model example.
Extract by position. Create
letters_to_use <- c("A", "B", "C", "D", "E")andpositions <- seq(from = 1, to = 5, by = 2). What doesletters_to_use[positions]return? Explain which positions were selected.Compare structures. Run
matrix(1:4, nrow = 2)anddata.frame(number = 1:2, text = c("a", "b")). Which object contains both numbers and text? Convert the data frame to a tibble withas_tibble(). Does this conversion change its values?Reuse a coverage calculation. Define
nonmissing_percent()and apply it toconbus,coneduc, andconmedicwithmap_dbl(). Explain the output’s length, names, and denominator. Would 100 percent establish that every answer is substantively valid?
Worked answers
letters_to_use <- c("A", "B", "C", "D", "E")
positions <- seq(from = 1, to = 5, by = 2)
positions[1] 1 3 5
letters_to_use[positions][1] "A" "C" "E"
The positions are 1, 3, and 5, so the extracted values are "A", "C", and "E". Square brackets use the positions in their supplied order.
matrix(1:4, nrow = 2) [,1] [,2]
[1,] 1 3
[2,] 2 4
example_frame <- data.frame(number = 1:2, text = c("a", "b"))
example_frame number text
1 1 a
2 2 b
as_tibble(example_frame)# A tibble: 2 × 2
number text
<int> <chr>
1 1 a
2 2 b
The matrix has two rows and two columns of numbers. The data frame combines a numeric column and a character column. Converting it to a tibble preserves both rows, both column names, and their values; the printed display changes.
Prepare the extract following Appendix A and restore it independently:
library(tidyverse)
gss24 <- readRDS("gss24.rds")nonmissing_percent <- function(x) {
100 * mean(!is.na(x))
}
answer_coverage <- gss24 |>
select(conbus, coneduc, conmedic) |>
purrr::map_dbl(nonmissing_percent)
answer_coverage conbus coneduc conmedic
64.92725 65.57953 65.47918
The result has three numeric entries, named after the three items. Each percentage uses all 3986 respondents as its denominator. Missingness can include questionnaire coverage as well as nonresponse. Even 100 percent would establish only that every entry is recorded; the source coding and meaning of those entries still need checking. Mapping repeats the function for each column without changing any answers.