Skip to content

Publication Ready Data Tables in R

The R package gt helps researchers produce publication quality data tables. Without using gt, scientists will typically get a bland data table as shown below.

trt age marker stage grade response death ttdeath
Drug A 23 0.16 T1 II 0 0 24
Drug B 9 1.107 T2 I 1 0 24
Drug A 31 0.277 T1 II 0 0 24
Drug A NA 2.067 T3 III 1 1 17.64
Drug A 51 2.767 T4 III 1 1 16.43
Drug B 39 0.613 T4 I 0 1 15.64
Drug A 37 0.354 T1 II 0 0 24
Drug A 32 1.739 T1 I 0 1 18.43
Drug A 31 0.144 T1 II 0 0 24

Source: The above is a glimpse of the trial data that comes with the gt package.

Learning Objectives

After this class, participants will know how to:

  • Convert standard R data frames into polished, publication-ready tables using the gt package.
  • Wrangle raw datasets into structured summaries and render them as clean, organized tables.
  • Enhance data tables with custom footnotes, styling, and automated statistical summaries using gtsummary.

Anatomy of a gt Table

Image source: https://gt.rstudio.com

The components of a gt table include:

  • The Table Header (optional; with a title and possibly a subtitle)
  • The Stub and the Stub Head (optional; contains row labels, optionally within row groups having row group labels and possibly summary labels when a summary is present)
  • The Column Labels (contains column labels, optionally under spanner column labels)
  • The Table Body (contains columns and rows of cells)
  • The Table Footer (optional; possibly with footnotes and source notes)

Turning a Regular Data Table into a GT Table

First load the following packages.

library(gt) ## For constructing GT tables
library(gtsummary) ## For adding descriptive statistics to GT tables
library(tidyverse) ## For data wrangling

Next, load an example dataset that compares two drugs across different disease stages and grades. This dataset comes with the gt package.

data(trial)

Take a look at the trial in R Studio to see what it looks like.

View(trial)

The labels on the bottom of the column headings in the trial data table are attributes. Just doing names(trial) will not show these.

names(trial)
[1] "trt"      "age"      "marker"   "stage"    "grade"   
[6] "response" "death"    "ttdeath" 

However, issuing the command below will confirm those as attributes.

str(trial)
tibble [200 × 8] (S3: tbl_df/tbl/data.frame)
 $ trt     : chr [1:200] "Drug A" "Drug B" "Drug A" "Drug A" ...
  ..- attr(*, "label")= chr "Chemotherapy Treatment"
 $ age     : num [1:200] 23 9 31 NA 51 39 37 32 31 34 ...
  ..- attr(*, "label")= chr "Age"
 $ marker  : num [1:200] 0.16 1.107 0.277 2.067 2.767 ...
  ..- attr(*, "label")= chr "Marker Level (ng/mL)"
 $ stage   : Factor w/ 4 levels "T1","T2","T3",..: 1 2 1 3 4 4 1 1 1 3 ...
  ..- attr(*, "label")= chr "T Stage"
 $ grade   : Factor w/ 3 levels "I","II","III": 2 1 2 3 3 1 2 1 2 1 ...
  ..- attr(*, "label")= chr "Grade"
 $ response: int [1:200] 0 1 0 1 1 0 0 0 0 0 ...
  ..- attr(*, "label")= chr "Tumor Response"
 $ death   : int [1:200] 0 0 0 1 1 1 0 1 0 1 ...
  ..- attr(*, "label")= chr "Patient Died"
 $ ttdeath : num [1:200] 24 24 24 17.6 16.4 ...
  ..- attr(*, "label")= chr "Months to Death/Censor"

Tip

To add a label attribute to a data frame column heading use attr(df$column, "label") <- "attribute".

To construct a bare minimum gt table, users will need to provide the input data as an argument to the gt command. The column heading label attributes are used in the gt table. In the code below, rather than enclosing the trial data in the gt() command, it is sent via pipe (%>%).

trial %>% gt()

Add a Title to the Table

The next exercise is to add a title for this gt table by using tab_header and enclose within it the table title.

trial %>% gt() %>% tab_header("Drug Response")

Data Wrangling Task for Answering Questions about the Trial Data

Question 1: What is the overall treatment response for each drug?

To find out, some data wrangling needs to be performed. First, use group_by from tidyverse to group the data by drug (trt) and response. Then, count the type of responses for each drug using summarise from tidyverse. Inside summarise the argument number.responses=n() is included where number.response will be a new column in the resulting table and when set to n() will report the number of each response type according to drug treatment. Next, send the results from group_by and summarise to gt() to create a gt table with the title "Response by Drug" included in tab_header.

trial %>% group_by(trt, response) %>% summarise(number.responses=n()) %>%
gt() %>%
  tab_header("Response by Drug")

However, some refinement of the output is needed to make it more legible. Modify the code above to include:

  • tab_options(row_group.as_column = TRUE): this will move the drugs into the table stub and align tumor response type (see Tumor Response column) and the number of each response (see number.response column) nicely.
  • cols_label enables users to re-label the column names in the gt table output. Here, the response column will be labeled "Tumor Response" and number.of.responses column will be labeled "Number of Responses".
trial %>% group_by(trt,response) %>% summarise(number.of.responses=n()) %>% 
gt() %>% tab_options(row_group.as_column = TRUE) %>% 
tab_header("Number Responses for each Drug") %>%
  cols_label(
    response="Tumor Response",
    number.of.responses="Number of Responses"
  )

Question 2: How does tumor grade and disease stage affect treatment response for each drug?

To find out, some data wrangling using group_by as well as summarise functions from Tidyverse is needed. First use group_by to group the trial data by treatment (trt), grade, stage, and response. Next, use summarise to count the number responses for each combination of treatment (trt), grade, stage, and response. Remember that response is a binary variable so it can be grouped and counted. Do the following afterwards.

trial %>% group_by(trt, grade, stage, response) %>% summarise(number.responses=n()) %>%
gt()
The resulting table may need more work to make it look pleasant. Initially, the row labels are just concatenations of drug, disease grade and stage.

Looking at the gt table list object from the above table confirms that the _row_groups under _stub_df are concatenations of the drug, disease grade and stage.

To set only the drugs as the row labels set groupname_col="trt" inside the gt() command. The resulting table is below.

The gt table list object shows the _row_groups are now just the drugs.

To make the table look even more pleasant, move the group labels (ie. drugs) to their own column by adding the following.

  • tab_options(row_group.as_column=TRUE): where setting row_group.as_column=TRUE moves the group or row labels (ie. drugs) to their own column.
  • tab_header("Drug Response"): adds "Drug Response" as the table title.
  • cols_label: enables customization of column heading labels.
  • cols_align("center"): centers the column headings with respect to the column values.
trial %>% group_by(trt, grade, stage, response) %>% 
summarise(number.responses=n()) %>% 
gt(groupname_col="trt") %>% 
tab_options(row_group.as_column=TRUE) %>% 
tab_header("Drug Response") %>% cols_label(
  stage="Stage",
  response = "Tumor Response",
  number.responses = "Number of Responses"
) %>%
  cols_align("center")

Question 3: Is there a statistical difference between response for each drug?

To find out, use the gtsummary package, which is an extension to gt that enables addition of descriptive and statistical summaries to data tables.

  • In the code below, trial is piped (%>%) to tbl_summary from the gtsummary package, which calculates statistics for data. The following arguments are used.
  • by: enables users to set which data variable to stratify or do the statistical comparison for (in this case it would be drug treatment type or the trt column).
  • include: the dependent variable for which statistical comparison is needed (ie. drug response).
  • missing: tells tbl_summary how to handle missing values; setting to no will not display missing values in the table.
  • type: allows for specifying the dependent variable type. Here, response has two values (0 for non-responders, and 1 for responders), hence it is categorical. The variable is included inside the list command and the variable type specified using the ~ (ie. the operator that defines a formula object in R).

The output from tbl_summary is then sent to add_p(), which adds p-values to the table. gtsummary will determine the best statistical test based on the data, but users can specify. This output is then sent to as_gt(), which converts a gtsummary object into a gt table. Finally tab_footnote to add custom footnotes. Here, a footnote is added to clarify that 0 and 1 code for non-responder and responder, respectively and location determines where the footnotes are added. Here, the footnote is referring to something in the table body as specified by cells_body and will be placed on the labels column as specified by setting columns=label.

trial %>%
    tbl_summary(
        by = trt,
        include = response,
        missing = "no",
        type = list(response ~ "categorical")
      ) %>%
      add_p() %>%
  as_gt() %>%
  tab_footnote(
    footnote = "0 = No response; 1 = Responder",
    locations = cells_body(columns = label))

Question 4: Does disease grade and stage statistically influence response from each drug?

To solve this question, the gtsummary package will once again be used. In the code below, the trial data is sent via pipe (%>%) to tbl_strata from gtsummary to create a stratified data table.

Terminology

"A stratified data table in gtsummary is a summary table that is split into separate sections or columns by the levels of a grouping variable. In practice, this means gtsummary computes the same descriptive summary within each stratum (ie. treatment group, disease grade, disease stage) so researchers can compare distributions across subgroups." -- as defined by Codex.

The arguments in tbl_strata can be explained as follows.

  • strata: this argument accepts the argument(s) to stratify the table by. Here, a vector is provided and contains the variables grade and stage from the trial data table. Hence, statistics are computed for each disease grade and stage combination.
  • .tbl_fun: this argument tells tbl_strata what information to build in the table. In .tbl_fun:
    • ~ is used to define a formula that tells tbl_strata what to compute within each stratified dataset.
    • .x is a place holder for the input data.
    • tbl_summary: is used to construct a descriptive statistics summary table. In this argument:
      • by is the grouping variable used to split the table so the descriptive statistics are summarized for each group in by, which in this case is trt or each drug.
      • include is used to specify which quantitative variable to actually calculate the descriptive statistics on (in this case response).
      • missing when set to no will not display missing values in the output.
    • add_n "adds a column with the total number of non-missing (or missing) observations" -- R documentation.
    • add_p tells tbl_summary to include statistical test results. By default, the best test for the data is chosen.
trial %>%
  tbl_strata(
    strata = c(grade, stage),
    .tbl_fun = ~ .x %>%
      tbl_summary(
        by = trt,
        include = response,
        missing = "no",
      ) %>%
      add_n() %>%
      add_p()
)

The resulting table shows disease grade and stage stratified across the top columns, the number of responders (ie. where the response value is 1) for each drug as well as p-value.

Including the following in tbl_strata will create the long version of the above table. Here,

  • setting .combine_with to tbl_stack will stack all of the individual stratified tables on top of each other.
  • .header when set to {strata} will include the disease grade and stage in the header of each stratified table.
.combine_with = "tbl_stack",
.header = "{strata}"

Next, remove "Tumor Response" from each row and the N values in the column headings labeled "Drug A" and "Drug B" by adding the following:

# This is added inside tbl_summary and removes "Tumor Response" from each row.
label=list(response="") 
# This is added after add_p() using %>% to remove the N values under column headings labeled "Drug A" and "Drug B"
# modify_header modifies the table header.
# all_stats_cols() takes all of the numerical columns in the tables and by setting to **{level}** results in the inclusion of "Drug A" and "Drug B" in the column heading since the levels in this table are the two drugs.
modify_header(
        all_stat_cols() ~ "**{level}**" 
      )

The next step is to align the grade and stage heading with the actual response data and remove the column heading labeled "Characteristic".

  modify_header(label = "") %>% # where the label in the gtsummary object was Tumor Response, it is now replaced with "".
  as_gt() %>% # turns the gtsummary object into a gt table object
  tab_options(row_group.as_column = TRUE) %>% # this will put the row labels (ie. disease grade and stage combinations) as a column in the table.
  tab_stubhead(label = "Grade, Stage") # tab_stubhead will create a user specified label for the row heading or stub head.