Skip to contents

ltsummary makes the descriptive tables that clinical reports start with, using the same function names and arguments as gtsummary, and renders them with the lt package. If you have used gtsummary, the only new function you need is as_lt(), and you rarely need to call it yourself. This vignette walks through a typical table from the first draft to the sentence in the results section that quotes it.

The package ships trial, a simulated data set of 200 patients with the same columns as gtsummary’s example data: treatment arm, age, a tumor marker, stage, grade, response, death and survival time.

head(trial)
#>      trt age marker stage grade response death ttdeath
#> 1 Drug A  66  0.629    T3     I        0     1   20.96
#> 2 Drug B  72  0.622    T3   III        0     0   24.00
#> 3 Drug A  72  2.389    T4    II        0     1   23.36
#> 4 Drug A  73  1.591    T2    II        0     1   15.89
#> 5 Drug B  43  0.744    T2    II        1     1   22.71
#> 6 Drug A  81     NA    T1   III        0     0   24.00

A first table

tbl_summary() summarizes every column it is given. Continuous variables get the median and quartiles, categorical variables get counts and percentages, and variables coded 0/1, TRUE/FALSE or yes/no are shown on one line. Column labels come from the label attribute of the data when there is one.

tbl_summary(trial, include = c(age, grade, response))

Add by to split the table by the levels of a variable. Rows with a missing by value are dropped with a message.

tbl_summary(trial, by = trt, include = c(age, marker, grade, response))

Changing the defaults

Most arguments accept a formula list: the left-hand side selects variables and the right-hand side gives the value. Variables can be selected by name, with a character vector, or with all_continuous(), all_categorical() and all_dichotomous().

tbl_summary(
  trial,
  by = trt,
  include = c(age, marker, grade),
  statistic = list(
    all_continuous() ~ "{mean} ({sd})",
    all_categorical() ~ "{n} / {N} ({p}%)"
  ),
  digits = list(age ~ 1, marker ~ c(2, 3)),
  label = list(grade ~ "Tumor grade"),
  missing = "no"
)

Statistics are written with the name in braces. Continuous variables can use {mean}, {sd}, {var}, {median}, {min}, {max}, {sum} and any percentile from {p0} to {p100}; categorical variables can use {n}, {N} and {p}. The name of a function of one argument also works, so "{IQR}" or "{my_trimmed_mean}" are valid.

The number of decimal places is guessed from the spread of each variable. Pass digits to override it with a single number for every statistic, a vector matched to the statistics in order, or a named list such as list(mean = 1, sd = 2). Functions such as label_style_sigfig() are accepted too.

type changes how a variable is summarized. The usual reason is to show every level of a 0/1 variable, or to summarize a numeric variable with few distinct values as continuous.

tbl_summary(
  trial,
  include = c(response, grade),
  type = list(response ~ "categorical"),
  value = list(grade ~ "III"),
  label = list(grade ~ "Grade III")
)

A "continuous2" variable gets one line per statistic:

tbl_summary(
  trial,
  include = c(age, marker),
  type = all_continuous() ~ "continuous2",
  statistic = all_continuous() ~ c("{median} ({p25}, {p75})", "{mean} ({sd})", "{min}, {max}")
)

Adding columns

add_overall() adds a column for all patients, add_n() adds the number of non-missing observations, add_p() adds p-values, and add_stat_label() writes the statistic next to each variable label so the footnote is no longer needed.

tbl <- trial |>
  tbl_summary(by = trt, include = c(age, marker, grade, response)) |>
  add_overall() |>
  add_n() |>
  add_p() |>
  add_stat_label()

tbl

add_difference() reports the difference between two groups with its confidence interval instead of a p-value alone, add_ci() puts an interval next to every statistic, and add_stat() adds a column computed by a function you write.

By default add_p() compares continuous variables with the Wilcoxon rank sum test (or the Kruskal-Wallis test for more than two groups) and categorical variables with Pearson’s chi-squared test, falling back to Fisher’s exact test when an expected cell count is below 5. The test argument takes a formula list of test names or functions, and test.args passes extra arguments through; ?tests lists what is built in.

trial |>
  tbl_summary(by = trt, include = c(age, grade)) |>
  add_p(
    test = list(all_continuous() ~ "t.test", all_categorical() ~ "fisher.test"),
    test.args = list(age ~ list(var.equal = TRUE)),
    pvalue_fun = label_style_pvalue(digits = 2)
  )

Headers, footnotes and formatting

Header text can reference the column’s statistics: {N} is the total, {n} the number in the column’s group, {p} the proportion and {level} the group label. Text supports a small markdown subset, **bold**, _italic_ and line breaks.

tbl |>
  modify_header(label = "**Variable**", all_stat_cols() ~ "**{level}**  \nn = {n} ({style_percent(p)}%)") |>
  modify_spanning_header(c(stat_1, stat_2) ~ "**Treatment arm**") |>
  modify_caption("**Table 1. Baseline characteristics (N = {N})**") |>
  modify_footnote_header("Randomized 1:1", columns = c(stat_1, stat_2)) |>
  modify_abbreviation(c("Q1 = first quartile", "Q3 = third quartile")) |>
  modify_source_note("Simulated data.") |>
  bold_labels() |>
  italicize_levels() |>
  bold_p(t = 0.1)

show_header_names() prints the column names and the values available to each header, which is the quickest way to see what {n} and {level} will resolve to.

Cells can be formatted after the fact with modify_fmt_fun(), missing values replaced with modify_missing_symbol(), columns hidden with modify_column_hide() or merged with modify_column_merge(), and rows removed with remove_row_type().

tbl |>
  remove_row_type(type = "missing") |>
  modify_column_hide(stat_0) |>
  modify_fmt_fun(p.value = label_style_pvalue(digits = 3))

Quoting the table in text

inline_text() pulls a formatted cell out of the table. Select the variable, the column (a by level or a column name such as p.value) and, for categorical variables, the level.

inline_text(tbl, variable = age, column = "Drug A")
#> [1] "59 (51, 66)"
inline_text(tbl, variable = grade, level = "II", column = "Drug B")
#> [1] "43 (41%)"
inline_text(tbl, variable = age, column = p.value)
#> [1] "p=0.043"

A pattern combines the statistics behind a cell in any arrangement:

inline_text(tbl, variable = age, column = "Drug A", pattern = "median {median} (IQR {p25} to {p75})")
#> [1] "median 59 (IQR 51 to 66)"

Without column, the pattern works on the columns of the row instead, which is how to quote two arms and their p-value in one go. column defaults to the overall column when the table has one, so this form needs a table without add_overall(). The p-value sits on the variable’s label row, so pair it with variable alone; a level row has the counts but no p-value.

tbl_arms <- trial |>
  tbl_summary(by = trt, include = c(age, grade)) |>
  add_p()

inline_text(tbl_arms, variable = age, pattern = "{stat_1} vs. {stat_2} ({p.value})")
#> [1] "59 (51, 66) vs. 62 (54, 71) (p=0.043)"
inline_text(tbl_arms, variable = grade, level = "III", pattern = "{stat_1} vs. {stat_2}")
#> [1] "30 (32%) vs. 25 (24%)"

In a Quarto or R Markdown document these calls go in inline code, so the sentence “Median age was `r inline_text(tbl, variable = age, column = "Drug A")` years in the Drug A arm” updates with the data.

Under the hood, and getting out

as_lt() converts the table to an lt object. Printing does this for you, but calling it yourself gives access to the lt verbs, for example to set the table width or to save a static copy.

as_lt(tbl) |>
  lt::lt_width("80%") |>
  lt::lt_export("table1.html")

as.data.frame() returns the formatted cells as a plain data frame, with the header labels as column names. The underlying object is a list with a table_body data frame and a table_styling list, the same structure gtsummary uses, so code that works with x$table_body carries over.

as.data.frame(tbl)[1:4, 1:4]
#>                      **Characteristic** **N** **Overall**  \nN = 200
#> 1                  Age, Median (Q1, Q3)   189            60 (53, 68)
#> 2                               Unknown  <NA>                     11
#> 3 Marker Level (ng/mL), Median (Q1, Q3)   190      0.68 (0.36, 1.19)
#> 4                               Unknown  <NA>                     10
#>   **Drug A**  \nN = 95
#> 1          59 (51, 66)
#> 2                    4
#> 3    0.72 (0.39, 1.19)
#> 4                    8

Further reading