08-2: Make Regression and Summary Tables with modelsummary

Tips to make the most of the lecture notes

Transcript

The usual two navigation aids: stacked lines bottom left for a table of contents, letter o for a panel view of every slide. This deck is worth navigating deliberately, because it covers two quite separate tools that happen to live in the same package. The first half is regression tables, the thing you need when writing up results. The second half is summary tables, the thing you need for a descriptive statistics section. You will almost certainly want one before the other, and jumping straight to the half you need beats paging through the whole deck.

  • Click on the three horizontally stacked lines at the bottom left corner of the slide, then you will see table of contents, and you can jump to the section you want

  • Hit letter “o” on your keyboard and you will have a panel view of all the slides

Transcript

Same code-box conventions as always, with one thing to flag. Unlike most decks in this course, none of the cells here run in your browser. Every table you see was produced when the slides were rendered, on my machine, because modelsummary and its output formats do not travel through browser R. So the copy button is the important one on this deck. Take the code into RStudio, where you have your own regressions to point it at, and treat these slides as a reference for the arguments rather than as a sandbox. The blue-tinted area is therefore a read-only display, not an editor. Use the two-sheets icon at its top right to copy the original code, paste it into RStudio, and then run either the whole expression or just the lines you are investigating. Copying never changes the code on the slide, so you can experiment freely in your own script and return here for a clean reference version.

  • The box area with a hint of blue as the background color displays read-only code (hereafter referred to as the “code area”).
  • These chunks are read-only, so copy their code and run it in RStudio.
  • To evaluate all or part of the code, paste it into RStudio first.
  • If you want to run the codes on your computer, you can first click on the icon with two sheets of paper stacked on top of each other (top right corner of the code chunk), which copies the code in the code area. You can then paste it onto your computer.
  • The original code remains unchanged on the slide.

Create regression tables


Create regression tables with the modelsummary package

Transcript

The data for the whole lecture: county-level corn and soybean yields, together with drought indicators. Two things to note. The install line pulls a package from r-universe rather than CRAN, so it needs both repositories listed. And the line dropping the geometry column matters, because this dataset is originally spatial. We do not need the polygons here, and carrying them around would slow everything down. The variable definitions on the second tab are worth reading properly, because the d-numbers are drought severity categories and you will see them in every table from here on. Start on the Get the data tab. The install chunk has eval false, so the deck displays that one-time installation command without reinstalling the package whenever the slides are rendered. In install dot packages, the first argument is the package name, and repos supplies both the course author’s R-universe repository and CRAN. Once it is installed, data opens the packaged county-yield dataset directly, while the package argument tells R exactly where to find it. Dplyr select with minus geometry keeps every other column and removes only the spatial geometry. Then move to Look at the data. Printing county yield lets you inspect the rows and columns before you model anything. Soy yield and corn yield are measured in bushels per acre. The d-zero through d-four variables are ratios of weeks from May through September spent at the corresponding drought severity. That definition is why these quantities can appear as explanatory variables in the regressions and later as variables to summarize.

We use county_yield throughout this lecture.

First install the r.spatial.workshop.datasets package.

#--- install the r.spatial.workshop.datasets package ---#
install.packages("r.spatial.workshop.datasets", repos = c("https://tmieno2.r-universe.dev", "https://cran.r-project.org"))


Then, get the data:

#--- get the data ---#
data(county_yield, package = "r.spatial.workshop.datasets")

county_yield <- dplyr::select(county_yield, -geometry)
county_yield
# A tibble: 1,956 × 10
   corn_yield soy_yield  year county_code state_name d0_5_9 d1_5_9 d2_5_9 d3_5_9
        <dbl>     <dbl> <int> <chr>       <chr>       <dbl>  <dbl>  <dbl>  <dbl>
 1       123       42    2000 053         Kansas       2.49  2.87   0.134   0   
 2       188.      NA    2017 095         Kansas       8.72  0      0       0   
 3       169.      58.4  2016 095         Kansas       1     0      0       0   
 4       198.      NA    2015 095         Kansas       1.76  1.21   2.09    0   
 5       152.      NA    2012 095         Kansas       6.28  1.47   9.54    4.46
 6       170       42    2007 095         Kansas       0     0      0       0   
 7       193       49    2005 095         Kansas       4.32  0      0       0   
 8       173       47    2003 095         Kansas       2.29  5.16   4.46    1.09
 9       165       40    2002 095         Kansas       3.71  1.48   1.90    0   
10       171       52    2001 095         Kansas       9.88  0.188  0       0   
# ℹ 1,946 more rows
# ℹ 1 more variable: d4_5_9 <dbl>


Variable Definitions

  • soy_yield: soybean yield (bu/acre)
  • corn_yield: corn yield (bu/acre)
  • d0_5_9: ratio of weeks under drought severity of 0 from May to September
  • d1_5_9: ~ drought severity of 1 from May to September
  • d2_5_9: ~ drought severity of 2 from May to September
  • d3_5_9: ~ drought severity of 3 from May to September
  • d4_5_9: ~ drought severity of 4 from May to September
Transcript

Before you can tabulate regressions you need regressions, so this tab runs four of them: two specifications each for corn and soybeans. It also computes robust variance-covariance matrices with vcovHC from sandwich, which we do not actually use until the swapping section later on. Then the second nested tab shows the payoff. You hand msummary a list of model objects and it produces a complete table, coefficients, standard errors and goodness-of-fit statistics, with no formatting instructions at all. That default is genuinely usable, and everything that follows is about refining it. On Prepare regression results, read each lm call as outcome, tilde, predictors, with data set to county yield. Model one for each crop contains d-one and d-two. Model two adds d-three and d-four. The corn pair uses corn yield as the outcome, and the soy pair uses soy yield. D-zero is not in these formulas, so you should not expect a d-zero coefficient row in the resulting table. Each vcovHC call then takes the matching fitted model and returns a robust variance-covariance matrix. Keeping the same one-corn, two-corn, one-soy, two-soy naming pattern is what prevents you from pairing a model with the wrong matrix later. On Default table, the list fixes the left-to-right model order. Because the list elements are unnamed here, modelsummary supplies generic column labels. The function extracts the estimates and model information from each lm object; it does not rerun the regressions. This bare call gives you the baseline table to compare with every modification in the next tab.

Let’s first run regressions which we are going to report in tables.

model_1_corn <- lm(corn_yield ~ d1_5_9 + d2_5_9, data = county_yield)
model_2_corn <- lm(corn_yield ~ d1_5_9 + d2_5_9 + d3_5_9 + d4_5_9, data = county_yield)
model_1_soy <- lm(soy_yield ~ d1_5_9 + d2_5_9, data = county_yield)
model_2_soy <- lm(soy_yield ~ d1_5_9 + d2_5_9 + d3_5_9 + d4_5_9, data = county_yield)


Get White-Huber robust variance-covariance matrix for the regressions:

vcov_1_corn <- sandwich::vcovHC(model_1_corn)
vcov_2_corn <- sandwich::vcovHC(model_2_corn)
vcov_1_soy <- sandwich::vcovHC(model_1_soy)
vcov_2_soy <- sandwich::vcovHC(model_2_soy)

You can supply a list of regression results to modelsummary::msummary() to create a default regression table.

modelsummary::msummary(
  list(
    model_1_corn,
    model_2_corn,
    model_1_soy,
    model_2_soy
  )
)
(1) (2) (3) (4)
(Intercept) 181.978 183.882 56.049 56.202
(0.678) (0.690) (0.288) (0.295)
d1_5_9 -0.216 -0.367 -0.062 -0.069
(0.135) (0.133) (0.055) (0.055)
d2_5_9 -1.081 -0.836 -0.327 -0.298
(0.124) (0.129) (0.053) (0.055)
d3_5_9 -0.754 -0.173
(0.158) (0.090)
d4_5_9 -2.194 -0.137
(0.320) (0.213)
Num.Obs. 1956 1956 1100 1100
R2 0.050 0.099 0.047 0.052
R2 Adj. 0.049 0.097 0.046 0.049
AIC 17806.4 17708.0 7475.7 7474.2
BIC 17828.8 17741.4 7495.8 7504.2
Log.Lik. -8899.218 -8847.985 -3733.873 -3731.078
F 51.768 53.480 27.207 15.043
RMSE 22.89 22.30 7.21 7.19
Transcript

Here is the menu of arguments for reshaping the default table, and the nested tabs work through the important ones. Read the list on the first tab before diving in, because it tells you the shape of the whole interface. Notice how much of it is about labelling and selection rather than statistics: coef map for renaming and reordering coefficients, coef omit and gof omit for dropping rows you do not want, add rows for inserting your own. That is because the numbers come from your models. What a table package does is decide what to show and what to call it. The How tab also names the options that are not demonstrated separately. Title adds the table title, notes adds footnotes, fmt controls number formatting, and statistic chooses what appears alongside each coefficient estimate. Gof map selects, orders, and labels goodness-of-fit rows, while gof omit removes matching rows from the defaults. Stars controls significance markers, and add rows lets you supply material that was never part of the fitted model. Think of this list as a menu rather than a set of arguments you must use all at once. On Stars, stars equals TRUE requests the package defaults. The named vector gives you full control: each name is the marker printed in the table, here plus, ampersand-plus, and plus-star-plus, and each number is its p-value cutoff. The model-one-corn object is the only model in this example, so you can see the marker rule without a wide table getting in the way. Across these side-by-side examples, eval false keeps the explanatory code on the left from running, while echo false runs the duplicate on the right but hides its source so you see only the resulting table. On Coef map, the vector names are the coefficient identifiers stored in the model and the vector values are the reader-facing labels. Their vector order becomes the table’s row order, which is why the four drought categories can appear first and the intercept can be relabelled Constant at the bottom. The model list contains model two for corn and model two for soy because both include all four drought categories shown in the map. Coef omit takes text patterns instead: the pattern d-two matches d-two-five-nine, so that coefficient row disappears from both models. Gof omit works the same way on model statistics. In I-C-bar-Adj, the bar means or, so I-C matches AIC and BIC and Adj matches adjusted R squared. Finally, Add rows separates the new content from its placement. The three-column data frame has a label column plus one entry for each of the two model columns. It creates County and Year fixed-effect rows, with Yes or No under each model. The position attribute gives row three to the first new row and row four to the second. In the table call, naming the models Model one and Model two gives the output columns useful headers, gof omit removes the selected fit statistics, coef omit equals d removes drought rows matching that pattern, and add rows equals rows inserts the prepared data frame. Using the full add rows argument name matters because it makes the instruction explicit.

modelsummary::msummary() offers multiple options to modify the default regression table to your liking:

  • title: put a title to the table
  • stars: place significance symbols (and modify the symbol placement rules)
  • coef_map: change the order and label of variable names
  • notes: add footnotes
  • fmt: change the format of numbers
  • statistic: type of statistics you display along with coefficient estimates
  • gof_map: define which model statistics to display
  • gof_omit: define which model statistics to omit from the default selection of model statistics
  • add_rows: add rows of arbitrary contents to the table

Add stars = TRUE in modelsummary::msummary() to add significance markers.

You can modify significance levels and markers by supplying a named vector with its elements being the significance levels and their corresponding names being the significance markers.


Example:

#--- create a named vector ---#
stars_label <- c("+" = 0.1, "&+" = 0.05, "+*+" = 0.01)

#--- create a table ---#
modelsummary::msummary(model_1_corn, stars = stars_label)
(1)
+ p < 0.1, &+ p < 0.05, +*+ p < 0.01
(Intercept) 181.978+*+
(0.678)
d1_5_9 -0.216
(0.135)
d2_5_9 -1.081+*+
(0.124)
Num.Obs. 1956
R2 0.050
R2 Adj. 0.049
AIC 17806.4
BIC 17828.8
Log.Lik. -8899.218
F 51.768
RMSE 22.89

coef_map allows you to reorder coefficient rows and change their labels.

As with the stars option, you supply a named vector where its names are the existing labels and their corresponding elements are the new labels.

In the table, the coefficient rows are placed in the order they are ordered in the named vector.


#--- define a coef_map vector ---#
coef_map_vec <- c(
  "d1_5_9" = "DI: category 1",
  "d2_5_9" = "DI: category 2",
  "d3_5_9" = "DI: category 3",
  "d4_5_9" = "DI: category 4",
  "(Intercept)" = "Constant"
)

#--- create a table ---#
modelsummary::msummary(
  list(model_2_corn, model_2_soy),
  coef_map = coef_map_vec
)
(1) (2)
DI: category 1 -0.367 -0.069
(0.133) (0.055)
DI: category 2 -0.836 -0.298
(0.129) (0.055)
DI: category 3 -0.754 -0.173
(0.158) (0.090)
DI: category 4 -2.194 -0.137
(0.320) (0.213)
Constant 183.882 56.202
(0.690) (0.295)
Num.Obs. 1956 1100
R2 0.099 0.052
R2 Adj. 0.097 0.049
AIC 17708.0 7474.2
BIC 17741.4 7504.2
Log.Lik. -8847.985 -3731.078
F 53.480 15.043
RMSE 22.30 7.19

coef_omit lets you omit coefficient rows from the default selections.

You supply a vector of strings (and/or regular expressions), and coefficient rows that match the string pattern will be omitted.


Example

modelsummary::msummary(
  list(model_2_corn, model_2_soy),
  coef_omit = "d2"
)


d2 matches with d2_5_9, and rows associated with the coefficients on d2_5_9 are removed.

(1) (2)
(Intercept) 183.882 56.202
(0.690) (0.295)
d1_5_9 -0.367 -0.069
(0.133) (0.055)
d3_5_9 -0.754 -0.173
(0.158) (0.090)
d4_5_9 -2.194 -0.137
(0.320) (0.213)
Num.Obs. 1956 1100
R2 0.099 0.052
R2 Adj. 0.097 0.049
AIC 17708.0 7474.2
BIC 17741.4 7504.2
Log.Lik. -8847.985 -3731.078
F 53.480 15.043
RMSE 22.30 7.19

gof_omit lets you omit model statistics like R^2 from the default selections.

You supply a vector of strings (and/or regular expressions), and statistics that match the string pattern will be omitted.


Example

modelsummary::msummary(
  list(model_2_corn, model_2_soy),
  gof_omit = "IC|Adj"
)

IC matches with AIC and BIC, and Adj matches with R2 Adj

(1) (2)
(Intercept) 183.882 56.202
(0.690) (0.295)
d1_5_9 -0.367 -0.069
(0.133) (0.055)
d2_5_9 -0.836 -0.298
(0.129) (0.055)
d3_5_9 -0.754 -0.173
(0.158) (0.090)
d4_5_9 -2.194 -0.137
(0.320) (0.213)
Num.Obs. 1956 1100
R2 0.099 0.052
Log.Lik. -8847.985 -3731.078
F 53.480 15.043
RMSE 22.30 7.19

add_rows can be used to insert arbitrary rows into a table. Adding rows with it is a two-step process:

  • Creating a data.frame (or tibble) to insert
#--- create a table (data.frame) to insert ---#
(
  rows <- data.frame(
    c1 = c("FE: County", "FE: Year"),
    c2 = c("Yes", "Yes"),
    c3 = c("No", "No") #--- CHANGED: second entry read "Now" ---#
  )
)
          c1  c2 c3
1 FE: County Yes No
2   FE: Year Yes No


  • Tell which rows you will insert the data.frame at, by attr(data.frame, "position") <- row number.
#--- tell where to insert ---#
attr(rows, "position") <- c(3, 4)

#--- create a table with rows inserted ---#
modelsummary::msummary(
  list(Model1 = model_2_corn, Model2 = model_2_soy),
  gof_omit = "IC|Adj",
  coef_omit = "d",
  add_rows = rows #<<   #--- CHANGED: was add_row; it worked only by R's partial argument matching ---#
)
Model1 Model2
(Intercept) 183.882 56.202
(0.690) (0.295)
FE: County Yes No
FE: Year Yes No
Num.Obs. 1956 1100
R2 0.099 0.052
Log.Lik. -8847.985 -3731.078
F 53.480 15.043
RMSE 22.30 7.19
Transcript

This is the tab to pay attention to, and it has been corrected. You often want robust standard errors rather than the defaults, and modelsummary lets you supply your own variance-covariance matrices through the vcov argument. Older code, including the previous version of this slide, used an argument called statistic override. That name no longer exists. And because msummary accepts dot dot dot, passing it produces no error and no warning whatsoever; the table simply comes back with default standard errors. Read the red callout, then compare the two tables and check the numbers in brackets actually move. On Instruction, the syntax shows that vcov receives a list, with one matrix for each model and in exactly the same order. That ordering is doing real work because modelsummary has to know which uncertainty estimate belongs to which coefficient column. A robust matrix changes the reported standard errors and therefore the statistical testing, but it does not refit the model or change the coefficient estimates. On Compare, both tables name the two model columns Model one and Model two. Gof omit equals I-C-bar-R removes rows whose names match I-C or R, including the information criteria, R-squared rows, and R-M-S-E. Coef omit equals d-three-bar-d-four removes the category-three or category-four coefficient rows. Those choices make the standard-error comparison easier to see. The left call has no vcov argument, while the right supplies vcov-two-corn and vcov-two-soy. Match each bracketed entry across the two outputs. The movement is the evidence that the robust matrices were used. The important callout exists because a plausible-looking table is not proof that an old argument worked.

It is often the case that we replace the default variance-covariance matrix with a robust one for valid statistical testing.

You can achieve this using the vcov option. You give it a list of variance-covariance matrices, in the order their corresponding regression results appear on the table.


Syntax:

vcov <- list(vcov_1, vcov_2, ...)


statistic_override is gone, and it fails silently

Older code and older tutorials use statistic_override for this. That argument was renamed to vcov, and msummary() accepts ..., so passing the old name produces no error and no warning. The table simply comes back with the default standard errors.

If you inherit code that uses it, the output looks completely fine. Check by running the table with and without: if the standard errors do not move, your override is not being applied.

Default:

modelsummary::msummary(
  list(Model1 = model_2_corn, Model2 = model_2_soy),
  gof_omit = "IC|R",
  coef_omit = "d3|d4"
  #--- no statistical override ---#
)
Model1 Model2
(Intercept) 183.882 56.202
(0.690) (0.295)
d1_5_9 -0.367 -0.069
(0.133) (0.055)
d2_5_9 -0.836 -0.298
(0.129) (0.055)
Num.Obs. 1956 1100
Log.Lik. -8847.985 -3731.078
F 53.480 15.043

VCOV swapped:

modelsummary::msummary(
  list(Model1 = model_2_corn, Model2 = model_2_soy),
  gof_omit = "IC|R",
  coef_omit = "d3|d4",
  #--- CHANGED: was statistic_override, which is silently ignored ---#
  vcov = list(vcov_2_corn, vcov_2_soy)
)
Model1 Model2
(Intercept) 183.882 56.202
(0.635) (0.281)
d1_5_9 -0.367 -0.069
(0.137) (0.056)
d2_5_9 -0.836 -0.298
(0.144) (0.057)
Num.Obs. 1956 1100
Log.Lik. -8847.985 -3731.078
Std.Errors Custom Custom


Compare the standard errors in brackets against the default table on the left. They move. Under the old statistic_override spelling they did not, and nothing said so.

Transcript

Getting the table out of R. One argument, output, taking a filename, and the extension decides the format. The list covers html, latex, markdown, plain text, Word, PowerPoint and image formats. The note about docx is the practically important one for this audience. A table saved to Word arrives as a real editable table, so you can make final adjustments by hand where that is quicker than writing more code. That is not cheating. It is recognising that the last five percent of table polish is often faster done directly. Read the extensions as destinations: dot-html for a web page, dot-tex for LaTeX, dot-md for Markdown, dot-txt for plain text, dot-docx and dot-pptx for Microsoft Office, and dot-png or dot-jpg for images. In the example, the named model list supplies the two table columns and output equals reg-results-table-dot-docx tells msummary to write that Word file rather than merely print a table in R. The chunk uses eval false so rendering the lecture displays this recipe without creating the file. When you run it in RStudio, choose the extension according to what the next stage of your workflow can accept.

You can save the table to a file by providing a file name to the output option.

The supported file types are:

  • .html
  • .tex
  • .md
  • .txt
  • .docx, pptx
  • .png
  • .jpg


Example:

The docx option may be particularly useful for those who want to put finishing touches on the table manually on WORD:

modelsummary::msummary(
  list(Model1 = model_2_corn, Model2 = model_2_soy),
  output = "reg_results_table.docx" #<<
)

Further modify regression tables with other packages

Transcript

The same output argument does something else, and it is more interesting. Instead of a filename, give it the name of a table package, and rather than writing a file it hands you back that package’s object. The two examples produce a flextable and a gt table, and the class check underneath proves it. Why this matters: modelsummary does the statistical work, extracting and formatting the coefficients, then hands off to a dedicated table package for appearance. You are not limited to what modelsummary itself knows how to style, which matters once a journal asks for something specific. In each pipeline, the list of corn model one and soy model one is passed into msummary as its model input. Output equals flextable is a backend name rather than a filename, so the result is assigned to reg-table-f-t and class reports flextable. Output equals g-t similarly creates reg-table-g-t, whose leading class is g-t-table. The prose also lists kableExtra as another styling route. Assignment is important here because the returned object must have a name before you can send it through more formatting functions. The next two tabs show that continuation first with flextable and then with g-t.

Using the output option in modelsummary::msummary(), you can turn the regression table into R objects that are readily modifiable by the gt, kableExtra, and flextable packages.


Example: flextable

#--- create a regression table and turn it into a flextable object ---#
reg_table_ft <- list(model_1_corn, model_1_soy) %>%
  modelsummary::msummary(output = "flextable")

#--- check the class ---#
class(reg_table_ft)
[1] "flextable"


Example: gt

#--- create a regression table and turn it into a gt_tbl ---#
reg_table_gt <- list(model_1_corn, model_1_soy) %>%
  modelsummary::msummary(output = "gt")

#--- check the class ---#
class(reg_table_gt)
[1] "gt_tbl" "list"  
Transcript

And here is that handoff in practice. Because the output is a genuine flextable object, everything from the previous lecture applies unchanged. The example bolds some cells and colours another, using exactly the i and j selectors you already know. Note the row and column numbers though. They refer to the finished table, where coefficient and standard error rows alternate and the first column holds the labels. So counting rows in the output rather than in your model is how you get those numbers right, and it usually takes one attempt to find them. The named list at the start determines the four headers, Corn one, Corn two, Soy one, and Soy two. Msummary turns that list into a flextable and gof omit equals I-C-bar-Adj removes information-criterion and adjusted-R-squared rows before any cell positions are counted. Bold then uses i equals nine for the ninth body row, j equals c of three and five for the third and fifth columns, and bold equals TRUE to turn the formatting on. Color uses i equals three, j equals two, and color equals red for one specific cell. The flextable package is attached in the setup, which is why bold and color can be called without a namespace prefix. The left chunk has eval false so it presents the recipe. The right chunk repeats it with echo false so the code is hidden and the formatted result is visible. Keeping the code and output side by side lets you trace each selector to the cells it changes. Now move to the g-t tab and watch the same modelsummary object handoff use a different styling vocabulary.

Now that the regression table created using modelsummary::msummary() with output = "flextable" is a flextable object.

So, we can use our knowledge of the flextable package to further modify the regression table if you would like.

For the details of how to use the flextable package visit the flextable lecture notes.

Here I will just give you an example of the use of flextable operations.


Example

list(
  "Corn 1" = model_1_corn,
  "Corn 2" = model_2_corn,
  "Soy 1" = model_1_soy,
  "Soy 2" = model_2_soy
) %>%
  modelsummary::msummary(
    output = "flextable",
    gof_omit = "IC|Adj",
  ) %>%
  bold(i = 9, j = c(3, 5), bold = TRUE) %>%
  color(i = 3, j = 2, color = "red")

Corn 1

Corn 2

Soy 1

Soy 2

(Intercept)

181.978

183.882

56.049

56.202

(0.678)

(0.690)

(0.288)

(0.295)

d1_5_9

-0.216

-0.367

-0.062

-0.069

(0.135)

(0.133)

(0.055)

(0.055)

d2_5_9

-1.081

-0.836

-0.327

-0.298

(0.124)

(0.129)

(0.053)

(0.055)

d3_5_9

-0.754

-0.173

(0.158)

(0.090)

d4_5_9

-2.194

-0.137

(0.320)

(0.213)

Num.Obs.

1956

1956

1100

1100

R2

0.050

0.099

0.047

0.052

Log.Lik.

-8899.218

-8847.985

-3733.873

-3731.078

F

51.768

53.480

27.207

15.043

RMSE

22.89

22.30

7.21

7.19

Transcript

The same idea with gt instead. The example does two things worth knowing. tab spanner adds a heading spanning several columns, which is how you group Corn one and Corn two under a single Corn label, and that is a very common requirement in published regression tables. Then tab style colours a range of rows. Pick whichever of the two packages you already know; there is no strong reason to prefer one here. The point of both tabs is simply that you are not confined to what modelsummary itself offers. As on the previous tab, the names in the four-model list become the model column headers, output equals g-t requests a g-t-table object, and gof omit equals I-C-bar-Adj removes matching goodness-of-fit rows. G-t colon-colon tab spanner takes label equals Corn for the new group heading, while columns equals vars of Corn one and Corn two identifies the two columns underneath it. Then g-t colon-colon tab style separates what the styling is from where it goes. Cell text with color equals red defines the appearance, and cells body with rows equals seven-colon-eight targets body rows seven through eight. Again, eval false makes the left panel code-only and echo false makes the right panel output-only. The two displays are the same pipeline, so the visible red rows and Corn spanner are direct checks that the selectors did what the code requested. After this, the lecture changes from regression tables to descriptive summary tables.

Now that the regression table is a gt_tbl object, we can use our knowledge of the gt package to modify the regression table.

For the details of how to use the gt package, see the gt documentation. Here I will just give you an example of the use of gt operations.

Example

list(
  "Corn 1" = model_1_corn,
  "Corn 2" = model_2_corn,
  "Soy 1" = model_1_soy,
  "Soy 2" = model_2_soy
) %>%
  modelsummary::msummary(
    output = "gt",
    gof_omit = "IC|Adj",
  ) %>%
  gt::tab_spanner( #<<
    label = "Corn", #<<
    columns = vars("Corn 1", "Corn 2") #<<
  ) %>% #<<
  gt::tab_style( #<<
    style = cell_text(color = "red"), #<<
    locations = cells_body(rows = 7:8) #<<
  ) #<<
Corn
Soy 1 Soy 2
Corn 1 Corn 2
(Intercept) 181.978 183.882 56.049 56.202
(0.678) (0.690) (0.288) (0.295)
d1_5_9 -0.216 -0.367 -0.062 -0.069
(0.135) (0.133) (0.055) (0.055)
d2_5_9 -1.081 -0.836 -0.327 -0.298
(0.124) (0.129) (0.053) (0.055)
d3_5_9 -0.754 -0.173
(0.158) (0.090)
d4_5_9 -2.194 -0.137
(0.320) (0.213)
Num.Obs. 1956 1956 1100 1100
R2 0.050 0.099 0.047 0.052
Log.Lik. -8899.218 -8847.985 -3733.873 -3731.078
F 51.768 53.480 27.207 15.043
RMSE 22.89 22.30 7.21 7.19

Create summary tables


Example table

Transcript

Before we get into syntax, here is where we are heading. This is one call to datasummary, and it produces a table with years down the side, three variables nested under each year, and mean and standard deviation for each state across the top. That is a genuinely complicated layout, described by a single formula. It will look impenetrable right now. The next few slides build it up piece by piece, so come back to this slide at the end, and you should be able to read every part of it. Read the pipeline from the top. Filter keeps observations whose year is in twenty-ten through twenty-twelve. The filtered data then flows into datasummary, and data equals dot tells datasummary to use that piped result. On the left of the tilde, factor of year makes year categorical. The asterisk nests the parenthesized group of corn yield, soy yield, and drought category four within each year, while plus joins those three variables. Each parenthesized left-arrow expression supplies the polished label you see in the table, including the yield units. On the right of the tilde, state name times Mean plus S-D creates a Mean and an S-D column inside each state. Putting the two sides together asks for both statistics for every displayed variable, year, and state combination. The parentheses are not decoration. They control which items are added together before nesting takes place. Keep this output in mind as the target; the Basics tab starts by reducing the formula to one variable and one statistic.

county_yield %>%
  dplyr::filter(year %in% 2010:2012) %>%
  modelsummary::datasummary(
    (Year <- factor(year)) * (
      (`Corn Yield (bu/acre)` <- corn_yield) +
        (`Soy Yield (bu/acre)` <- soy_yield) +
        (`DI: category 4` <- d4_5_9)
    ) ~
      state_name * (Mean + SD),
    data = .
  )
Colorado Kansas Nebraska
Year <- factor(year) Mean SD Mean SD Mean SD
2010 `Corn Yield (bu/acre)` <- corn_yield 196.08 12.96 182.38 17.12 182.37 14.80
`Soy Yield (bu/acre)` <- soy_yield 58.79 4.30
`DI: category 4` <- d4_5_9 0.00 0.00 0.00 0.00 0.00 0.00
2011 `Corn Yield (bu/acre)` <- corn_yield 186.25 12.76 160.56 29.69 178.32 16.00
`Soy Yield (bu/acre)` <- soy_yield 60.35 5.39
`DI: category 4` <- d4_5_9 0.00 0.00 1.52 3.33 0.00 0.00
2012 `Corn Yield (bu/acre)` <- corn_yield 160.50 31.69 161.33 17.44 185.91 18.44
`Soy Yield (bu/acre)` <- soy_yield 59.80 5.21
`DI: category 4` <- d4_5_9 1.79 1.60 6.16 3.59 3.05 2.65

modelsummary::datasummary()

Transcript

The formula interface, and it is the whole idea behind datasummary. A formula has two sides separated by a tilde, exactly as in regression, but here the meaning is spatial rather than statistical. Whatever is on the left becomes the rows, whatever is on the right becomes the columns. That is all. Look at the two examples, which are the same request with the sides swapped, and see that the table simply transposes. Once you accept that the formula describes a layout rather than a model, the rest of this section is just vocabulary. The general call has two inputs doing different jobs. Formula describes the table’s arrangement, and data identifies the dataset from which values are calculated. In corn yield tilde Mean, corn yield labels the row and the capital-M Mean supplies the summary column. In Mean tilde corn yield, those roles reverse, so the statistic is on the row side and the variable is on the column side. No relationship is being estimated between corn yield and Mean. The tilde is simply dividing the row specification from the column specification. Next, you need to know which summary functions can occupy those positions.

Syntax:

modelsummary::datasummary(formula, data = dataset)

formula has two sides separated by ~ just like formula for regression.

Variables/statistics on the left-hand side (right-hand side) comprise rows (columns).

Example

modelsummary::datasummary(
  corn_yield ~ Mean,
  data = county_yield
)
Mean
corn_yield 178.25


Switching the order changes the structure of the resulting table:

modelsummary::datasummary(
  Mean ~ corn_yield,
  data = county_yield
)
corn_yield
Mean 178.25
Transcript

The package supplies its own statistics, capitalised to distinguish them from base ones: Mean, SD, Min, Max, the percentiles, and Histogram, which draws a little inline distribution. Why they exist rather than just using the base functions is in the sentence underneath, now corrected. They have na dot rm equals TRUE built in, so they quietly ignore missing values, whereas base mean returns NA the moment there is one. The two examples make the difference visible: capital M Mean gives you a number, lowercase mean gives you NA. One keystroke, and a common source of confusion. P-zero, P-twenty-five, P-fifty, P-seventy-five, and P-one-hundred are the minimum, quartiles, median, and maximum expressed as percentiles. Histogram is different because it places a compact distribution graphic in a table cell rather than returning only a printed number. In both examples, soy yield is the row variable and county yield is the data. Only the function on the right changes. That controlled comparison is why you can attribute the different output to missing-value handling rather than to a different sample or variable. If you choose a lowercase base function, you are responsible for supplying its missing-value argument yourself, which the later Function arguments tab demonstrates.

The modelsummary package offers multiple summary functions of its own:

  • Mean
  • SD
  • Min
  • Max
  • P0, P25, P50, P75, P100
  • Histogram

These functions have na.rm = TRUE built in, so they do not return NA the way their base counterparts do when the data contain missing values.

For example, compare these two:

modelsummary::datasummary(
  soy_yield ~ Mean,
  data = county_yield
)
Mean
soy_yield 54.95


modelsummary::datasummary(
  #--- mean from the base package ---#
  soy_yield ~ mean,
  data = county_yield
)
mean
soy_yield
Transcript

You are not restricted to the built-in statistics. Any function taking a vector and returning a single value can be used, including one you have just written. The example builds MinMax, which pastes the minimum and maximum into a bracketed range, then uses it exactly where you would have written Mean. Notice it returns a string rather than a number, which is fine; the table prints what it is given. This is how you produce the slightly unusual summaries that journals in a particular field expect, without stepping outside the framework. Inside the function, x is the vector datasummary will supply. Min of x and max of x find the endpoints, and na dot rm equals TRUE prevents missing observations from turning either endpoint into NA. Paste-zero joins an opening bracket, the minimum, a comma and space, the maximum, and a closing bracket without adding its own separator. The result is one character value such as a bracketed range. In the formula corn yield tilde MinMax, the bare function name tells datasummary to call that function on corn yield from county yield. The next tab reuses MinMax alongside several built-in summaries, which is why defining it first matters.

You can use a user-defined function that takes a vector of values and return a single value.

Example:

#--- define a function ---#
MinMax <- function(x) {
  paste0("[", min(x, na.rm = TRUE), ", ", max(x, na.rm = TRUE), "]")
}

#--- use it ---#
modelsummary::datasummary(corn_yield ~ MinMax, data = county_yield)
MinMax
corn_yield [0, 234.3]
Transcript

Adding more of either side is done with a plus. Several variables on the left, several statistics on the right, and you get every combination: each statistic computed for each variable. The example does four variables against four statistics, including the user-defined one from the last tab and the inline histogram. Read the sentence at the bottom, because it states the rule precisely, and it is that rule which makes the more complicated formulas later comprehensible. Plus means and, and the two sides are crossed with each other rather than paired up.

You can add more variables and statistics using +.

Example:

modelsummary::datasummary(
  corn_yield + soy_yield + d0_5_9 + d1_5_9
  ~ Mean + SD + MinMax + Histogram,
  data = county_yield
)
Mean SD MinMax Histogram
corn_yield 178.25 23.50 [0, 234.3] ▁▄▇▆▁
soy_yield 54.95 7.39 [15, 74.3] ▁▄▇▆▃▁
d0_5_9 3.92 3.94 [0, 21.3569] ▇▃▃▂▁
d1_5_9 3.15 4.15 [0, 21.4838] ▇▁▁▁▁

For each of the variables on the left-hand side, each of the statistics on the right-hand side is calculated and displayed.

Transcript

A shortcut for the common case of summarising everything numeric. All wraps your dataset and expands to all its numeric columns, so you do not have to name them. In current modelsummary, All can summarise the numeric columns of a tibble directly. That is why the example passes county yield straight to All, without wrapping it in data dot frame. Here, All of county yield supplies the complete numeric-variable set on the row side, while Mean plus S-D asks for two columns for each selected variable. Nonnumeric identifiers are not treated as quantities to average. Data equals county yield supplies the observations used in those calculations. The chunk option cache equals false tells Quarto to recompute this table during a render instead of reusing a cached result. All is useful for a quick audit, but for a published table you will often return to an explicit variable list so that identifiers and irrelevant numeric columns do not slip in.

You can use All() to create a summary table for all the numeric variables in the dataset.

In current modelsummary, All() can summarize the numeric columns of a tibble directly.

Example:

modelsummary::datasummary(
  All(county_yield)
  ~ Mean + SD,
  data = county_yield
)
Mean SD
corn_yield 178.25 23.50
soy_yield 54.95 7.39
year 2007.38 5.22
d0_5_9 3.92 3.94
d1_5_9 3.15 4.15
d2_5_9 2.82 4.51
d3_5_9 1.60 3.61
d4_5_9 0.41 1.69

More on datasummary()

Transcript

Now the operator that makes this package powerful. Where plus means and, the asterisk means nested within. So state name times Mean plus SD gives you a mean and a standard deviation for each state, side by side, which is the table you would otherwise build with group by and summarize. Look at the sentence at the bottom, because it shows both operators working together in one formula. The two statistics are nested inside state, while MinMax sits outside the nesting and is therefore computed once over the whole sample. The syntax lines show the progression. With one statistic, category variable times stat makes a separate statistic column within every category. With several statistics, parentheses keep stat one plus stat two together before that whole group is nested. In the worked call, corn yield, soy yield, d-zero, and d-one are four row variables. State name has Nebraska, Colorado, and Kansas as its displayed groups, and each group receives Mean and S-D columns. The final plus MinMax adds one more summary that is not multiplied by state name, so it describes the entire county-yield sample. Placement relative to the asterisk is therefore what decides whether a statistic is group-specific or overall.

You can nest categorical variables with *, meaning you can get summary statistics for each value of the categorical variable (like group_by() %>% summarize()).


Syntax

#--- single stat ---#
variable ~ category_variable * stat

#--- multiple stats ---#
variable ~ category_variable * (stat 1 + stat 2 + ...)


Examples:

modelsummary::datasummary(
  corn_yield + soy_yield + d0_5_9 + d1_5_9
  ~ state_name * (Mean + SD) + MinMax,
  data = county_yield
)
Colorado Kansas Nebraska
Mean SD Mean SD Mean SD MinMax
corn_yield 168.26 30.64 173.06 24.32 181.65 21.32 [0, 234.3]
soy_yield 50.74 7.34 55.80 7.11 [15, 74.3]
d0_5_9 4.23 4.67 3.69 3.81 3.97 3.89 [0, 21.3569]
d1_5_9 2.66 3.52 2.96 4.19 3.28 4.20 [0, 21.4838]

For each value of state_name (Nebraska, Colorado, Kansas), Mean and SD are shown for each of the variables on the left-hand side. But, MinMax is for the entire sample.

Transcript

And nesting composes. Multiply by two categorical variables and you get a column for every combination, so year times state gives you Kansas twenty eleven, Kansas twenty twelve, Nebraska twenty eleven and so on, each with its own mean and standard deviation. The example filters to two years and two states first, and that is deliberate. Nested columns multiply very quickly, and a table forty columns wide helps nobody. That is the practical constraint on this feature: the syntax will happily produce far more columns than will ever fit on a page. The first filter keeps years twenty-eleven and twenty-twelve, and the second keeps Kansas and Nebraska. Percent-in-percent means membership in each listed set. Factor of year is doing important work in the formula because it asks for separate categorical year groups rather than treating year as a quantity to summarize. Factor of year times state name times Mean plus S-D crosses both groupings with both statistics. The four variables on the left each receive that same column structure. Plus MinMax remains outside the nesting, so it still gives one whole-sample range per row variable. Data equals dot tells datasummary to use the already filtered data arriving through the pipe.

You can nest with multiple categorical variables by multiplying stats with multiple categorical variables.

Example:

county_yield %>%
  dplyr::filter(year %in% 2011:2012) %>%
  dplyr::filter(state_name %in% c("Kansas", "Nebraska")) %>%
  modelsummary::datasummary(
    corn_yield + soy_yield + d0_5_9 + d1_5_9
    ~ factor(year) * state_name * (Mean + SD) + MinMax,
    data = .
  )
2011 2012
Kansas Nebraska
Mean SD Mean SD Mean SD Mean SD MinMax
corn_yield 160.56 29.69 178.32 16.00 161.33 17.44 185.91 18.44 [100, 217]
soy_yield 60.35 5.39 59.80 5.21 [48, 70.3]
d0_5_9 3.52 3.18 2.86 2.01 2.15 1.25 3.11 1.34 [0, 8.7386]
d1_5_9 5.05 3.28 0.01 0.05 2.62 1.17 2.74 1.39 [0, 10.1494]

For each of the unique combinations of state_name (Nebraska, Kansas) and year (2011, 2012), Mean and SD are shown for each of the variables on the left-hand side. But, MinMax is for the entire sample.

Transcript

By default your column names and the statistic names become the labels, which is rarely what you want in a finished table. The renaming syntax is a label, an equals sign, and the thing being labelled, all wrapped in parentheses, and it works on both variables and statistics. The example renames corn yield to a proper label with units, and SD to Std dot Dev. Then read the callout: if your label contains spaces you must wrap it in back quotes. And note the last line, which is honest advice. If this syntax annoys you, rename in gt afterwards instead. The two filters and data equals dot work exactly as on the previous tab, so the table still contains only two years and two states. On the row side, the parenthesized label mapping turns corn yield into Corn Yield, with bushels per acre shown in parentheses. On the column side, Mean keeps its default name while the nested mapping changes S-D to Std dot Dev. The Corn Yield label contains spaces and punctuation, so the back quotes make it one valid label rather than several pieces of R code. Std dot Dev contains no spaces, so it does not need back quotes. Renaming here keeps the label logic in the formula; renaming later with g-t can be easier when you are already doing final visual formatting there.

By default variable and statistics names are used as the labels in the table.

You can provide labels by the following syntax: (label = variable/stat)


Example:

county_yield %>%
  dplyr::filter(year %in% 2011:2012) %>%
  dplyr::filter(state_name %in% c("Kansas", "Nebraska")) %>%
  modelsummary::datasummary(
    (`Corn Yield (bu/acre)` <- corn_yield)
    ~ state_name * (Mean + (Std.Dev. <- SD)),
    data = .
  )
Kansas Nebraska
Mean Std.Dev. <- SD Mean Std.Dev. <- SD
`Corn Yield (bu/acre)` <- corn_yield 160.99 23.31 181.95 17.56
  • corn_yield is labeled as Corn Yield (bu/acre)
  • SD is labeled as Std.Dev.

Labels with spaces

When your label contains spaces, surround it with back quotes.

If you do not like this way of changing labels, you can always use gt package.

Transcript

How to pass arguments to a statistic. You multiply the statistic by Arguments, in parentheses, listing what you want passed through. The example does two things with it. It adds na dot rm equals TRUE to the lowercase base mean and sd, which is exactly what the capitalised versions do for you automatically. And it passes probs equals nought point one to quantile to request one scalar quantile suitable for this table. Without probs, quantile returns its five default quantiles rather than one scalar value. The pipeline first limits the data to twenty-eleven and twenty-twelve in Kansas and Nebraska, and data equals dot uses that filtered sample. Parentheses bind mean plus s-d into a pair before state name nests them. Multiplying that pair by Arguments of na dot rm equals TRUE passes the same missing-value rule to both functions, so you get a mean and standard deviation within each state. The plus then starts a separate term: quantile times Arguments of probs equals zero-point-one and na dot rm equals TRUE. Because that quantile term is outside the state-name nesting, it is the overall tenth percentile for the filtered sample. Arguments exists so you can keep the compact formula interface even when a summary function needs more than its data vector.

You can pass option arguments to the stats function by: stat * Argument(options)


Example:

county_yield %>%
  dplyr::filter(year %in% 2011:2012) %>%
  dplyr::filter(state_name %in% c("Kansas", "Nebraska")) %>%
  modelsummary::datasummary(
    corn_yield
    ~ state_name * (mean + sd) * Arguments(na.rm = TRUE) +
      quantile * Arguments(probs = 0.1, na.rm = TRUE),
    data = .
  )
Kansas Nebraska
mean sd mean sd quantile
corn_yield 160.99 23.31 181.95 17.56 148.52


  • (mean + sd) * Arguments(na.rm = TRUE) adds na.rm = TRUE option to mean() and sd()
  • quantile * Arguments(probs = 0.1, na.rm = TRUE) adds probs = 0.1 and na.rm = TRUE to quantile()
Transcript

Two arguments for the surrounding text. Title puts a caption on the table, notes puts one or more footnotes underneath, and notes takes a vector so you can have several. Nothing complicated here, but do use them. A table that arrives in a document with its title and notes already attached is one you cannot later mismatch with the wrong caption, which is a genuinely common error when tables and captions are maintained separately. Build the description into the same call that produces the table, and it travels with it. The rest of this call deliberately repeats the previous tab. The two filters define the same two-year, two-state sample. State name times mean plus s-d, together with Arguments of na dot rm equals TRUE, gives the grouped summaries, and the separate quantile term gives the overall tenth percentile. Data equals dot passes the filtered rows. Title equals A title supplies one character string, while notes equals c of first note and second note supplies a character vector whose entries appear as separate notes in that order. Keeping those strings in the generating code also makes a future rerun reproduce the same surrounding text.

Example

county_yield %>%
  dplyr::filter(year %in% 2011:2012) %>%
  dplyr::filter(state_name %in% c("Kansas", "Nebraska")) %>%
  modelsummary::datasummary(
    corn_yield
    ~ state_name * (mean + sd) * Arguments(na.rm = TRUE) +
      quantile * Arguments(probs = 0.1, na.rm = TRUE),
    data = .,
    title = "A title",
    notes = c("first note", "second note")
  )
Kansas Nebraska
A title
mean sd mean sd quantile
first note
second note
corn_yield 160.99 23.31 181.95 17.56 148.52
Transcript

Column alignment, controlled by the align argument. You pass a single string with one letter per column: l for left, r for right, c for centre, and d for decimal alignment. So the nth letter aligns the nth column, and the string needs to be as long as the table is wide. The example uses four letters for a four-column table. Only those four letters are valid, so a stray character will not do what you hope. In practice you want left for the label column and either right or decimal alignment for the numbers, which is what makes columns of figures line up. In the exact string l-r-l-c, the first displayed column is left-aligned, the second is right-aligned, the third is left-aligned, and the fourth is centred. This is an intentionally visible demonstration of positional control, not a claim that every numeric column should use a different alignment. The formula also combines ideas from the previous tabs. The filters restrict the sample, the parenthesized mappings give readable labels to mean and quantile, and the Arguments calls handle missing values and select the tenth percentile. Data equals dot uses the piped sample. Once the table content is fixed, align changes presentation only; it does not change any calculated value.

You can use align to align columns. Available alignment are:

  • l: left
  • r: right
  • c: center
  • d: align numbers on the decimal point

For align, you provide a sequence of the option letters (e.g., "lrcl")

The nth letter corresponds to nth column.

Example:

county_yield %>%
  dplyr::filter(year %in% 2011:2012) %>%
  dplyr::filter(state_name %in% c("Kansas", "Nebraska")) %>%
  modelsummary::datasummary(
    corn_yield
    ~ state_name * (`This is M E A N` <- mean) * Arguments(na.rm = TRUE) +
      (`This is Q U A N T I L E` <- quantile) * Arguments(probs = 0.1, na.rm = TRUE),
    data = .,
    align = "lrlc"
  )
Kansas Nebraska
`This is M E A N` <- mean `This is M E A N` <- mean `This is Q U A N T I L E` <- quantile
corn_yield 160.99 181.95 148.52
Transcript

A short tab, and the message is that there is nothing new to learn. The output argument on datasummary behaves exactly as it does on msummary: give it a filename to write a file, or the name of a table package to get that package’s object back for further editing. That consistency is worth noticing as a design point. Once you have learned how to get one kind of table out of this package you have learned it for all of them, and the same goes for title and notes.

You can use the output option to either export the table as a file or save it as R objects which you can further modify.

This works exactly the same way as the modelsummary::msummary() function.

Convenience functions

Transcript

A convenience function for a table economists produce constantly. If you have a treatment and a control group, datasummary balance gives you means and standard deviations for each group side by side. This rendered table shows group means and standard deviations only; difference estimates require the optional estimatr support. The formula is simpler than the general case: variables to summarise on the left, the grouping variable on the right. The example uses state as the grouping variable, which is not really a treatment, but the shape of the output is exactly what you would produce for a real experiment. Read the preparation pipeline carefully because it determines what can enter the balance table. Filter keeps Nebraska and Kansas. The first select keeps state name together with every column for which where is numeric, and the next select removes year from that numeric set. All of data-frame dot then places all remaining numeric variables on the formula’s left side, while state name on the right defines the two groups. Data equals dot supplies that prepared dataset. In a real experiment, you would replace state name with the treatment indicator and inspect whether the pre-treatment variables look similar across its control and treated values. The two chunks are a display technique. The first is labelled balance-tab and has eval false, so you can read the code without running it there. The second has ref dot label equals balance-tab, which reuses the labelled code, and echo false, which hides that repeated source while showing its output. Together they put one readable recipe and one rendered balance table on the slide without maintaining two independent copies of the R code.

If your data was generated through randomized experiments (or you are using natural experiments), then datasummary_balance() can be very useful as it can generate a variable balance table.


Syntax:

modelsummary::datasummary_balance(variables to summarize ~ treatment dummy)
  • variables to summarize: list of variables to summarize
  • treatment dummy: a dummy variable that indicates whether in the treated or control group


Example:

county_yield %>%
  dplyr::filter(state_name %in% c("Nebraska", "Kansas")) %>%
  dplyr::select(c(state_name, where(is.numeric))) %>%
  dplyr::select(-year) %>%
  modelsummary::datasummary_balance(
    All(data.frame(.)) ~ state_name,
    data = .
  )
Kansas (N=534) Nebraska (N=1268)
Mean Std. Dev. Mean Std. Dev.
corn_yield 173.1 24.3 181.7 21.3
soy_yield 50.7 7.3 55.8 7.1
d0_5_9 3.7 3.8 4.0 3.9
d1_5_9 3.0 4.2 3.3 4.2
d2_5_9 2.6 4.0 2.8 4.6
d3_5_9 1.6 3.4 1.5 3.5
d4_5_9 0.7 2.4 0.3 1.3
Transcript

And the last one, a correlation matrix, in a single call with no formula at all. Note the select calls before it. The first retains state name plus the numeric columns, and the second drops year because correlating it with anything here would be meaningless. Datasummary correlation then uses the remaining numeric columns. That is worth remembering generally. These convenience functions do not know which of your numeric columns are quantities of interest and which are identifiers or dates. Filtering down to the ones that matter is your job, not theirs. The initial filter limits the observations to Nebraska and Kansas, matching the balance-table example. Where is numeric is a selection predicate, so it keeps every numeric column without listing them individually; state name is retained explicitly for context but is not one of the quantities correlated. Minus year then excludes the numeric time identifier. The final function calculates the pairwise correlations among the numeric variables that remain. In the matrix-style output, each variable has a correlation of one with itself, and one triangular half is enough because the opposite half would repeat the same pairwise values. Use datasummary correlation when that standard matrix is the table you need. Return to the general datasummary formula when you need custom row and column nesting, labels, or statistics. That distinction is the reason the lecture ends with convenience functions: they save code for familiar table shapes without replacing the more flexible interface you have just learned.

You can create a correlation table with datasummary_correlation().

county_yield %>%
  dplyr::filter(state_name %in% c("Nebraska", "Kansas")) %>%
  dplyr::select(c(state_name, where(is.numeric))) %>%
  dplyr::select(-year) %>%
  modelsummary::datasummary_correlation()
corn_yield soy_yield d0_5_9 d1_5_9 d2_5_9 d3_5_9 d4_5_9
corn_yield 1 . . . . . .
soy_yield .71 1 . . . . .
d0_5_9 .13 .04 1 . . . .
d1_5_9 -.13 -.12 .05 1 . . .
d2_5_9 -.24 -.21 -.28 .38 1 . .
d3_5_9 -.20 -.12 -.30 -.02 .29 1 .
d4_5_9 -.22 -.04 -.18 -.04 .02 .34 1