Descriptive tables with {gtsummary}

Make an easy Table 1

What is {gtsummary}?

  • Create tables that are publication-ready
  • Highly customizable
  • Descriptive tables, regression tables, etc.

gtsummary::tbl_summary()

library(gtsummary)

tbl_summary(
  nlsy,
  by = sex_cat,
  include = c(
    sex_cat, race_eth_cat,
    region_cat, eyesight_cat,
    glasses, age_bir))
Characteristic Male
N = 6,4031
Female
N = 6,2831
race_eth_cat

    Hispanic 1,000 (16%) 1,002 (16%)
    Black 1,613 (25%) 1,561 (25%)
    Non-Black, Non-Hispanic 3,790 (59%) 3,720 (59%)
region_cat

    Northeast 1,296 (21%) 1,254 (20%)
    North Central 1,488 (24%) 1,446 (23%)
    South 2,251 (36%) 2,317 (38%)
    West 1,253 (20%) 1,142 (19%)
    Unknown 115 124
eyesight_cat

    Excellent 1,582 (38%) 1,334 (31%)
    Very good 1,470 (35%) 1,500 (35%)
    Good 792 (19%) 1,002 (23%)
    Fair 267 (6.4%) 365 (8.5%)
    Poor 47 (1.1%) 85 (2.0%)
    Unknown 2,245 1,997
glasses 1,566 (38%) 2,328 (54%)
    Unknown 2,241 1,995
age_bir 25 (21, 29) 22 (19, 27)
    Unknown 3,652 3,091
1 n (%); Median (Q1, Q3)

You can also refer to variables using helper functions

library(gtsummary)

tbl_summary(
  nlsy,
  by = sex_cat,
  include = c(
    ends_with("cat"),
    glasses, age_bir))
Characteristic Male
N = 6,4031
Female
N = 6,2831
region_cat

    Northeast 1,296 (21%) 1,254 (20%)
    North Central 1,488 (24%) 1,446 (23%)
    South 2,251 (36%) 2,317 (38%)
    West 1,253 (20%) 1,142 (19%)
    Unknown 115 124
race_eth_cat

    Hispanic 1,000 (16%) 1,002 (16%)
    Black 1,613 (25%) 1,561 (25%)
    Non-Black, Non-Hispanic 3,790 (59%) 3,720 (59%)
eyesight_cat

    Excellent 1,582 (38%) 1,334 (31%)
    Very good 1,470 (35%) 1,500 (35%)
    Good 792 (19%) 1,002 (23%)
    Fair 267 (6.4%) 365 (8.5%)
    Poor 47 (1.1%) 85 (2.0%)
    Unknown 2,245 1,997
glasses 1,566 (38%) 2,328 (54%)
    Unknown 2,241 1,995
age_bir 25 (21, 29) 22 (19, 27)
    Unknown 3,652 3,091
1 n (%); Median (Q1, Q3)

We probably want to name the variables

tbl_summary(
  nlsy,
  by = sex_cat,
  include = c(
    sex_cat, race_eth_cat,
    region_cat, eyesight_cat,
    glasses, age_bir),
  label = list(
    race_eth_cat ~
      "Race/ethnicity",
    region_cat ~ "Region",
    eyesight_cat ~ "Eyesight",
    glasses ~ "Wears glasses",
    age_bir ~ "Age at first birth"
  ),
  missing_text = "Missing")
Characteristic Male
N = 6,4031
Female
N = 6,2831
Race/ethnicity

    Hispanic 1,000 (16%) 1,002 (16%)
    Black 1,613 (25%) 1,561 (25%)
    Non-Black, Non-Hispanic 3,790 (59%) 3,720 (59%)
Region

    Northeast 1,296 (21%) 1,254 (20%)
    North Central 1,488 (24%) 1,446 (23%)
    South 2,251 (36%) 2,317 (38%)
    West 1,253 (20%) 1,142 (19%)
    Missing 115 124
Eyesight

    Excellent 1,582 (38%) 1,334 (31%)
    Very good 1,470 (35%) 1,500 (35%)
    Good 792 (19%) 1,002 (23%)
    Fair 267 (6.4%) 365 (8.5%)
    Poor 47 (1.1%) 85 (2.0%)
    Missing 2,245 1,997
Wears glasses 1,566 (38%) 2,328 (54%)
    Missing 2,241 1,995
Age at first birth 25 (21, 29) 22 (19, 27)
    Missing 3,652 3,091
1 n (%); Median (Q1, Q3)

And do a million other things

tbl_summary(
  nlsy,
  by = sex_cat,
  include = c(
    sex_cat, race_eth_cat,
    eyesight_cat, glasses,
    age_bir),
  label = list(
    race_eth_cat ~
      "Race/ethnicity",
    eyesight_cat ~ "Eyesight",
    glasses ~ "Wears glasses",
    age_bir ~ "Age at first birth"
  ),
  missing_text = "Missing") |>
  add_p(test = list(
    all_continuous() ~ "t.test",
    all_categorical() ~
      "chisq.test")) |>
  add_overall(
    col_label = "**Total**") |>
  bold_labels() |>
  remove_footnote_header() |>
  modify_header(
    label = "**Variable**",
    p.value = "**P**") |>
  modify_caption(
    "**Participant characteristics**")
Participant characteristics
Variable Total Male
N = 6,403
Female
N = 6,283
P
Race/ethnicity


0.8
    Hispanic 2,002 (16%) 1,000 (16%) 1,002 (16%)
    Black 3,174 (25%) 1,613 (25%) 1,561 (25%)
    Non-Black, Non-Hispanic 7,510 (59%) 3,790 (59%) 3,720 (59%)
Eyesight


<0.001
    Excellent 2,916 (35%) 1,582 (38%) 1,334 (31%)
    Very good 2,970 (35%) 1,470 (35%) 1,500 (35%)
    Good 1,794 (21%) 792 (19%) 1,002 (23%)
    Fair 632 (7.5%) 267 (6.4%) 365 (8.5%)
    Poor 132 (1.6%) 47 (1.1%) 85 (2.0%)
    Missing 4,242 2,245 1,997
Wears glasses 3,894 (46%) 1,566 (38%) 2,328 (54%) <0.001
    Missing 4,236 2,241 1,995
Age at first birth 23 (20, 28) 25 (21, 29) 22 (19, 27) <0.001
    Missing 6,743 3,652 3,091

Two ways to change that table

That last example did two different kinds of customization:

tbl_summary(
  nlsy,
  by = sex_cat,
  include = c(sex_cat, race_eth_cat, age_bir),
  label = list(age_bir ~ "Age at first birth"),
  missing_text = "Missing"
) |>
  add_p() |>
  add_overall(col_label = "**Total**") |>
  bold_labels() |>
  modify_caption("**Participant characteristics**")

Some things are arguments inside tbl_summary(). Others are separate functions we piped to – and those can have arguments of their own.

What the pipe does

x |> f() is just another way of writing f(x) (or x %>% f() if you’re used to the {magrittr} pipe). So these are equivalent:

# these are the same thing
add_p(tbl_summary(nlsy, by = sex_cat))

tbl_summary(nlsy, by = sex_cat) |> add_p()

Every one of those {gtsummary} functions takes a table and returns a table. That’s why you can keep stacking them:

tbl_summary(nlsy) |> add_p() |> bold_labels() |> modify_caption("Table 1")
#          table -> table  -> table          -> table

So which one do I need?

  • Argument to tbl_summary() – decisions about what the table is made of: which variables, which grouping, which statistics, what to call them. You need the data to answer these.
  • A function you pipe to – things you do to a table that already exists: add a column, restyle it, caption it, export it.

A rough test: if you can’t answer the question without looking at the data, it’s probably an argument. If you’re adding to or restyling a table you already have, look for a function. Sometimes it’s arbitrary, though, and there’s no way to know without looking at the documentation!

The function names are a map

Once you know you want a function, the prefix tells you where to look:

prefix what it does examples
add_ adds a row or column add_p(), add_overall(), add_n()
modify_ changes labels, headers, captions modify_header(), modify_caption()
bold_ / italicize_ styling bold_labels(), italicize_levels()
remove_ takes something away remove_footnote_header()
as_ converts to another format as_flex_table(), as_gt()

Tip

In RStudio, type gtsummary:: and hit Tab to scroll the whole list. For arguments, ?tbl_summary.

Additional arguments

We saw include =, by =, label =, missing_text = in the example

statistic =:

  • The default is list(all_continuous() ~ "{median} ({p25}, {p75})", all_categorical() ~ "{n} ({p}%)")
  • For categorical variables, you can use {n} (frequency), {N} (denominator), {p} formatted percentage
  • For continuous variables, you can use {median}, {mean}, {sd}, {var}, {min}, {max}, {sum}, ⁠{p##} (any percentile), or any function {foo}
  • You can refer to individual variables with their names: list(age ~ "min = {min}; max = {max}")

Additional arguments

digits =:

  • It will do its best to guess the appropriate number of digits
  • Otherwise, you can pass a function:
    • digits = everything() ~ style_sigfig
  • Or a value for each statistic shown
    • statistic = list(age ~ "min = {min}; max = {max}", year_of_birth = "{median} ({p25}, {p75})") :
    • digits = list(age ~ c(1, 1) year_of_birth ~ c(0, 0, 0))

Additional arguments

type =:

  • One of “continuous”, “continuous2”, “categorical”, “dichotomous”
    • If a variable only has 0/1, TRUE/FALSE, or yes/no values, it will be treated as dichotomous
      • You can override this with type = list(``varname``~ "categorical")
      • Dichotomous variables only show one row (i.e., the percentage of 1’s) unless you change to categorical
        • You can change which level to show with value = list(varname ~ "level to show")
    • “continuous2” variables can have multiple rows of statistics

missing =:

  • Show NA values in the table (“no”, “ifany”, “always”)

Additional functions

  • add_overall(): In a stratified table, add a column for all strata combined
  • bold_labels(): Bold the variable names (also bold_levels(), italicize_levels())
  • add_p(): Add a p-value (required by some journals 🤷‍♀️)
  • remove_footnote_header(): Remove the default footnotes (see also modify_footnote_header() and modify_footnote_body() to add your own)
  • modify_header(): Change the header column
  • modify_caption(): Add a table caption

A warning about asking AI for gtsummary code

{gtsummary} changed a lot in version 2.0, so you might get the old syntax when you ask AI.

The one you’re most likely to see:

# what AI often suggests -- deprecated since gtsummary 2.0
modify_footnote(update = everything() ~ NA)

# current
remove_footnote_header()

Tip

It still runs, and the deprecation warning is easy to miss. When a suggestion doesn’t match the package documentation, trust the documentation.

tbl_summary()

  • Incredibly customizeable

    • So many options can be overwhelming
    • The FAQ/gallery is an incredible resource
  • You can view it in the browser and copy/paste into Word

    • I do this more than I’d like to admit, but it’s the one step in your analysis that isn’t reproducible

Saving a table without copy and paste

Everything else we’re doing this week is about not redoing work by hand when the data changes. Tables are no different:

# install.packages("flextable")
tbl_summary(nlsy, by = sex_cat, include = c(race_eth_cat, age_bir)) |>
  as_flex_table() |>
  flextable::save_as_docx(path = here::here("results", "table1.docx"))

Re-run the analysis, re-run this line, and the Word file is up to date.

Tip

Best of all is to put the table directly in a quarto document, which we’ll do tomorrow – then there’s no separate file to keep in sync at all.

Exercises

  1. Open the script with some examples.

  2. Install {gtsummary} and run the examples.

We’ll come back together here to discuss before you’re on your own.

3-7. You’re on your own! Work with your neighbors, and we’ll come back together to go over these.

Extra time? Start a table using the data you want to use for your final project! Make sure you switch to that R project!