Enterprise {gtsummary}

Themes, ARDs, and Custom Extensions for Pharma

Headshot of Daniel Sjoberg Headshot of Shannon Pileggi

Daniel D. Sjoberg and Shannon Pileggi

R/Pharma 2026

workshop logo

Introduction

Acknowledgements

gtsummary logo

This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA4.0).

Your instructors

Headshot of Daniel Sjoberg
Daniel D. Sjoberg Executive Director of Data Sciences & Clinical Data Analytics, Kardigan
Headshot of Shannon Pileggi
Shannon Pileggi Director of Data Science, The Prostate Cancer Clinical Trials Consortium

Questions

Please ask questions at any time!

What we will cover

  1. Analysis Results Datasets — build the numbers first with {cards}, so every table has something auditable behind it.

  2. {gtsummary} fundamentals — tbl_summary(), hierarchical adverse event tables, merging and stacking.

  3. ARD-first tables — every cell traceable, and QC by comparing one ARD against another.

  4. Print engines — {gt}, {flextable}, and submission-ready Word output.

  5. Adopting {gtsummary} — a house theme, wrappers of your own, and {crane}.

  6. Coding agents — writing your standards down so an agent follows them without being reminded.

About you

We are assuming you:

  • Are comfortable in R and work in the tidyverse — the native pipe, {dplyr}, {tidyr}.

  • Have used {gtsummary} before, even just once.

This is a workshop about adopting {gtsummary} across an organization — not a first tour of the package. We cover only the very basics before moving on to themes, ARDs, and extensions, so a little background with the package goes a long way.

AGENTS.md and SKILL.md

Two ways to write your standards down for a coding agent. Both are committed to the repo, so every agent on the project reads the same thing.

  • AGENTS.md — how we work in this repo, every time. House code style, where the data comes from, project layout, how to build. Lives at the root; the agent loads it for every task.

  • SKILL.md — how to do one particular task, loaded only when that task comes up. Its description is the trigger the agent matches against, so it is written as concrete situations, not a summary.

This repo ships two skills we wrote: ard-creation and gtsummary-tables, in .agents/skills/. We will read both later today. ➡️

Analysis Results Datasets

What are ARDs?

  • Dataset that stores key metadata and raw results from analysis

  • Long dataset that is 1 record per result value

  • May contain formatted values as well

  • The ARD can be used to to subsequently create tables and figures.

  • The ARD does not describe the layout of the results

Analysis Results Data (ARD)

  • After the initial creation of an ARD, the results can later be re-used again and again for subsequent reporting needs.

A few notes about ARDs

  • ARDs give us the opportunity to rethink QC

    • QC can be focused on the raw value, as well as the formatted display

      • You don’t have to waste time trying to match formatting to match QC
  • ARDs can be flexibly saved to different file types

    • For example: a dataset (rds, xpt, etc) or json file

Zooming Out: The Analysis Results Standard (ARS)

Objectives include:

Proposed Metadata Framework in the ARS

  • The ARS provides a metadata-driven infrastructure for analysis

  • {cards} serves as the engine for the analysis and {gtsummary} as the builder-of-tables

ARDs using {cards}

cards website

{cards}: Introduction

  • Part of the Pharmaverse

  • Collaboration among Roche, GSK, Novartis, Eli Lilly, Pfizer, Clymb, Kardigan

  • Contains a variety of utilities for making ARDs

  • Can be used within the ARS workflow and separately

  • 59K downloads per month 🤯

Data used in examples

ADSL from pharmaverseadam

Code
adsl <- pharmaverseadam::adsl |>
  dplyr::filter(SAFFL=="Y") |> 
  dplyr::mutate(ARM2 = ifelse(startsWith(ARM, "Xanomeline"), "Xanomeline", ARM))

ADAE from pharmaverseadam

Code
adae <- pharmaverseadam::adae |>
  dplyr::filter(SAFFL=="Y") |> 
  dplyr::mutate(ARM2 = ifelse(startsWith(ARM, "Xanomeline"), "Xanomeline", ARM)) |> 
  dplyr::filter(AESOC %in% unique(AESOC)[1:3]) |> 
  dplyr::group_by(AESOC) |> 
  dplyr::filter(AEDECOD %in% unique(AEDECOD)[1:3]) |> 
  dplyr::ungroup()

{cards}: ard_tabulate()

  • includes n, %, N by default
  • Any unobserved levels of the variables will be present in the resulting ARD.
library(cards)

adsl |> 
  ard_tabulate(
    variables = AGEGR1
  ) 
# An ARD data frame: 6 × 9
  variable variable_level context  stat_name stat_label    stat fmt_fun warning error 
* <chr>    <list>         <chr>    <chr>     <chr>       <list> <list>  <list>  <list>
1 AGEGR1   18-64          tabulate n         n           33     0       <NULL>  <NULL>
2 AGEGR1   18-64          tabulate N         N          254     0       <NULL>  <NULL>
3 AGEGR1   18-64          tabulate p         %            0.130 <fn>    <NULL>  <NULL>
4 AGEGR1   >64            tabulate n         n          221     0       <NULL>  <NULL>
5 AGEGR1   >64            tabulate N         N          254     0       <NULL>  <NULL>
6 AGEGR1   >64            tabulate p         %            0.870 <fn>    <NULL>  <NULL>

{cards}: ard_tabulate()

  • includes n, %, N by default
  • Any unobserved levels of the variables will be present in the resulting ARD.
adsl |>
  ard_tabulate(
    by = ARM2,
    variables = AGEGR1
  )
# An ARD data frame: 12 × 11
  group1 group1_level variable variable_level context  stat_name stat_label    stat fmt_fun warning error 
* <chr>  <list>       <chr>    <list>         <chr>    <chr>     <chr>       <list> <list>  <list>  <list>
1 ARM2   Placebo      AGEGR1   18-64          tabulate n         n           14     0       <NULL>  <NULL>
2 ARM2   Placebo      AGEGR1   18-64          tabulate N         N           86     0       <NULL>  <NULL>
3 ARM2   Placebo      AGEGR1   18-64          tabulate p         %            0.163 <fn>    <NULL>  <NULL>
4 ARM2   Placebo      AGEGR1   >64            tabulate n         n           72     0       <NULL>  <NULL>
5 ARM2   Placebo      AGEGR1   >64            tabulate N         N           86     0       <NULL>  <NULL>
6 ARM2   Placebo      AGEGR1   >64            tabulate p         %            0.837 <fn>    <NULL>  <NULL>
7 ARM2   Xanomeline   AGEGR1   18-64          tabulate n         n           19     0       <NULL>  <NULL>
8 ARM2   Xanomeline   AGEGR1   18-64          tabulate N         N          168     0       <NULL>  <NULL>
# ℹ 4 more rows

{cards}: ard_summary()

# create ARD with default summary statistics
adsl |>
  ard_summary(
    variables = AGE
  )
# An ARD data frame: 8 × 8
  variable context stat_name stat_label   stat fmt_fun warning error 
* <chr>    <chr>   <chr>     <chr>      <list>  <list> <list>  <list>
1 AGE      summary N         N          254          0 <NULL>  <NULL>
2 AGE      summary mean      Mean        75.1        1 <NULL>  <NULL>
3 AGE      summary sd        SD           8.25       1 <NULL>  <NULL>
4 AGE      summary median    Median      77          1 <NULL>  <NULL>
5 AGE      summary p25       Q1          70          1 <NULL>  <NULL>
6 AGE      summary p75       Q3          81          1 <NULL>  <NULL>
7 AGE      summary min       Min         51          1 <NULL>  <NULL>
8 AGE      summary max       Max         89          1 <NULL>  <NULL>

{cards}: ard_summary() by variable

by: summary statistics are calculated by all combinations of the by variables, including unobserved factor levels

adsl |>
  ard_summary(
    variables = AGE,
    by = ARM2         # stats by treatment arm
  )
# An ARD data frame: 16 × 10
  group1 group1_level variable context stat_name stat_label   stat fmt_fun warning error 
* <chr>  <list>       <chr>    <chr>   <chr>     <chr>      <list>  <list> <list>  <list>
1 ARM2   Placebo      AGE      summary N         N           86          0 <NULL>  <NULL>
2 ARM2   Placebo      AGE      summary mean      Mean        75.2        1 <NULL>  <NULL>
3 ARM2   Placebo      AGE      summary sd        SD           8.59       1 <NULL>  <NULL>
4 ARM2   Placebo      AGE      summary median    Median      76          1 <NULL>  <NULL>
5 ARM2   Placebo      AGE      summary p25       Q1          69          1 <NULL>  <NULL>
6 ARM2   Placebo      AGE      summary p75       Q3          82          1 <NULL>  <NULL>
7 ARM2   Placebo      AGE      summary min       Min         52          1 <NULL>  <NULL>
8 ARM2   Placebo      AGE      summary max       Max         89          1 <NULL>  <NULL>
# ℹ 8 more rows

{cards}: ard_summary() statistics

statistic: specify univariate summary statistics. Accepts any function, base R, from a package, or user-defined.

cv <- function(x)  sd(x, na.rm = TRUE)/mean(x, na.rm = TRUE)

adsl |> 
  ard_summary(  
    variables = AGE,
    by = ARM2,
    statistic = ~ list(cv = cv) # customize statistics
  )
# An ARD data frame: 2 × 10
  group1 group1_level variable context stat_name stat_label   stat fmt_fun warning error 
* <chr>  <list>       <chr>    <chr>   <chr>     <chr>      <list>  <list> <list>  <list>
1 ARM2   Placebo      AGE      summary cv        cv          0.114       1 <NULL>  <NULL>
2 ARM2   Xanomeline   AGE      summary cv        cv          0.108       1 <NULL>  <NULL>

{cards}: ard_summary() statistics

Customize the statistics returned for each variable

adsl |>
  dplyr::mutate(AGE2 = AGE) |>
  ard_summary(
    variables = c(AGE, AGE2),
    by = ARM2,
    statistic = list(AGE = list(cv = cv),
                     AGE2 = continuous_summary_fns(c("mean","median")))
  )
# An ARD data frame: 6 × 10
  group1 group1_level variable context stat_name stat_label   stat fmt_fun warning error 
* <chr>  <list>       <chr>    <chr>   <chr>     <chr>      <list>  <list> <list>  <list>
1 ARM2   Placebo      AGE      summary cv        cv          0.114       1 <NULL>  <NULL>
2 ARM2   Placebo      AGE2     summary mean      Mean       75.2         1 <NULL>  <NULL>
3 ARM2   Placebo      AGE2     summary median    Median     76           1 <NULL>  <NULL>
4 ARM2   Xanomeline   AGE      summary cv        cv          0.108       1 <NULL>  <NULL>
5 ARM2   Xanomeline   AGE2     summary mean      Mean       75.0         1 <NULL>  <NULL>
6 ARM2   Xanomeline   AGE2     summary median    Median     77           1 <NULL>  <NULL>

{cards}: ard_summary() fmt_fun

  • Override the default formatting functions
  • Can also update later via update_ard_fmt_fun()
adsl |>
  ard_summary(
    variables = AGE,
    by = ARM2,
    fmt_fun = AGE ~ list(mean = 0)
  ) |>
  apply_fmt_fun() # add a character column of rounded results
# An ARD data frame: 16 × 11
  group1 group1_level variable context stat_name stat_label   stat stat_fmt fmt_fun warning error 
  <chr>  <list>       <chr>    <chr>   <chr>     <chr>      <list> <list>    <list> <list>  <list>
1 ARM2   Placebo      AGE      summary N         N           86    86             0 <NULL>  <NULL>
2 ARM2   Placebo      AGE      summary mean      Mean        75.2  75             0 <NULL>  <NULL>
3 ARM2   Placebo      AGE      summary sd        SD           8.59 8.6            1 <NULL>  <NULL>
4 ARM2   Placebo      AGE      summary median    Median      76    76.0           1 <NULL>  <NULL>
5 ARM2   Placebo      AGE      summary p25       Q1          69    69.0           1 <NULL>  <NULL>
6 ARM2   Placebo      AGE      summary p75       Q3          82    82.0           1 <NULL>  <NULL>
7 ARM2   Placebo      AGE      summary min       Min         52    52.0           1 <NULL>  <NULL>
# ℹ 9 more rows

{cards}: Other Summary Functions

  • ard_tabulate_value(): similar to ard_tabulate(), but for dichotomous tabulations

  • ard_hierarchical(): similar to ard_tabulate(), but built for nested tabulations, e.g. AE terms within SOC

  • ard_mvsummary(): similar to ard_summary(), for multivariate summaries. The function accepts other arguments like the full and subsetted (within the by groups) data sets.

  • ard_missing(): tabulates rates of missingness

The results from all these functions are entirely compatible with one another, and can be stacked into a single data frame. 🥞

{cards}: Other Functions

In addition to exporting functions to prepare summaries, {cards} exports many utilities for wrangling ARDs and creating new ARDs.

Constructing: bind_ard(), tidy_as_ard(), nest_for_ard(), check_ard_structure(), and many more

Wrangling: get_ard_statistics(), replace_null_statistic(), etc.

{cards}: Stacking utilities

  • data and .by are shared by all ard_* calls

  • Additional Options .overall, .missing, .attributes, and .total_n provide even more results

  • By default, summaries of the .by variable are included

adsl |>
  ard_stack(
    .by = ARM2,
    ard_summary(variables = AGE, statistic = ~ continuous_summary_fns(c("mean","sd"))), 
    ard_tabulate(variables = AGEGR1, statistic = ~ "p")
  )  
# An ARD data frame: 14 × 11
  group1 group1_level variable variable_level context  stat_name stat_label   stat fmt_fun warning error 
  <chr>  <list>       <chr>    <list>         <chr>    <chr>     <chr>      <list> <list>  <list>  <list>
1 ARM2   Placebo      AGE      <NULL>         summary  mean      Mean       75.2   1       <NULL>  <NULL>
2 ARM2   Placebo      AGE      <NULL>         summary  sd        SD          8.59  1       <NULL>  <NULL>
3 ARM2   Placebo      AGEGR1   18-64          tabulate p         %           0.163 <fn>    <NULL>  <NULL>
4 ARM2   Placebo      AGEGR1   >64            tabulate p         %           0.837 <fn>    <NULL>  <NULL>
5 ARM2   Xanomeline   AGE      <NULL>         summary  mean      Mean       75.0   1       <NULL>  <NULL>
6 ARM2   Xanomeline   AGE      <NULL>         summary  sd        SD          8.09  1       <NULL>  <NULL>
# ℹ 8 more rows

{cards}: Hierarchical Summary Functions

Following hierarchical summary functions aid in nested tabulations (e.g. AE terms within SOC):

  • ard_stack_hierarchical(): calculating nested subject-level rates

  • ard_stack_hierarchical_count(): calculating nested event-level counts

{cards}: ard_stack_hierarchical

  • Our go-to function for nested subject-level rates — one call handles the whole hierarchy

  • A single call returns rows for both the System Organ Class (group2_level) and the nested AE term (variable_level)

  • id checks for duplicate rows and subsets the data along the way; denominator dictates the denominator for the rates

adae |>
  ard_stack_hierarchical(
    variables = c(AESOC, AEDECOD),
    by = TRT01A,
    id = USUBJID,
    denominator = pharmaverseadam::adsl
  )
# An ARD data frame: 12 × 13
  group1 group1_level group2 group2_level               variable variable_level             context      stat_name stat_label   stat fmt_fun warning error 
  <chr>  <list>       <chr>  <list>                     <chr>    <list>                     <chr>        <chr>     <chr>      <list> <list>  <list>  <list>
1 TRT01A Placebo      <NA>   <NULL>                     AESOC    GASTROINTESTINAL DISORDERS hierarchical n         n          12     0       <NULL>  <NULL>
2 TRT01A Placebo      <NA>   <NULL>                     AESOC    GASTROINTESTINAL DISORDERS hierarchical N         N          86     0       <NULL>  <NULL>
3 TRT01A Placebo      <NA>   <NULL>                     AESOC    GASTROINTESTINAL DISORDERS hierarchical p         %           0.140 <fn>    <NULL>  <NULL>
4 TRT01A Placebo      AESOC  GASTROINTESTINAL DISORDERS AEDECOD  DIARRHOEA                  hierarchical n         n           9     0       <NULL>  <NULL>
# ℹ 8 more rows

Why the stacking functions?

  • Displays for hierarchical data report on each level of the hierarchy (by System Organ Class, by Preferred Term)

  • Producing that by hand means several calls to the simpler ard_hierarchical() / ard_hierarchical_count() functions, each on a different subset of the data

  • ard_stack_hierarchical*() runs those calls in the background and stacks the results into one ARD

Exercise 🏃‍➡️

  1. Open Exercise 1 on the workshop site and copy it into Positron/RStudio.

  2. Compute the nested AE tabulations as described.

  3. Report the rate of subjects who experienced fatigue.

Exercise 1 solution

ard_ae <-
  ard_stack_hierarchical(
    data = adae,
    variables = c(AESOC, AEDECOD),
    by = ARM,
    id = USUBJID,
    denominator = adsl
  )

ard_ae
# An ARD data frame: 117 × 13
  group1 group1_level group2 group2_level variable variable_level       context  stat_name stat_label    stat fmt_fun warning error 
  <chr>  <list>       <chr>  <list>       <chr>    <list>               <chr>    <chr>     <chr>       <list> <list>  <list>  <list>
1 <NA>   <NULL>       <NA>   <NULL>       ARM      Placebo              tabulate n         n           86     0       <NULL>  <NULL>
2 <NA>   <NULL>       <NA>   <NULL>       ARM      Placebo              tabulate N         N          254     0       <NULL>  <NULL>
3 <NA>   <NULL>       <NA>   <NULL>       ARM      Placebo              tabulate p         %            0.339 <fn>    <NULL>  <NULL>
4 <NA>   <NULL>       <NA>   <NULL>       ARM      Xanomeline High Dose tabulate n         n           84     0       <NULL>  <NULL>
5 <NA>   <NULL>       <NA>   <NULL>       ARM      Xanomeline High Dose tabulate N         N          254     0       <NULL>  <NULL>
6 <NA>   <NULL>       <NA>   <NULL>       ARM      Xanomeline High Dose tabulate p         %            0.331 <fn>    <NULL>  <NULL>
7 <NA>   <NULL>       <NA>   <NULL>       ARM      Xanomeline Low Dose  tabulate n         n           84     0       <NULL>  <NULL>
8 <NA>   <NULL>       <NA>   <NULL>       ARM      Xanomeline Low Dose  tabulate N         N          254     0       <NULL>  <NULL>
# ℹ 109 more rows

Exercise 1 solution

The rate of subjects with fatigue is one dplyr::filter() away.

ard_ae |>
  dplyr::filter(variable == "AEDECOD", variable_level == "FATIGUE") |>
  apply_fmt_fun() # add a character column of rounded results
# An ARD data frame: 9 × 14
  group1 group1_level         group2 group2_level                                         variable variable_level context      stat_name stat_label    stat stat_fmt fmt_fun warning error 
  <chr>  <list>               <chr>  <list>                                               <chr>    <list>         <chr>        <chr>     <chr>       <list> <list>   <list>  <list>  <list>
1 ARM    Placebo              AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical n         n           1      1        0       <NULL>  <NULL>
2 ARM    Placebo              AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical N         N          86      86       0       <NULL>  <NULL>
3 ARM    Placebo              AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical p         %           0.0116 1.2      <fn>    <NULL>  <NULL>
4 ARM    Xanomeline High Dose AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical n         n           5      5        0       <NULL>  <NULL>
5 ARM    Xanomeline High Dose AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical N         N          84      84       0       <NULL>  <NULL>
6 ARM    Xanomeline High Dose AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical p         %           0.0595 6.0      <fn>    <NULL>  <NULL>
7 ARM    Xanomeline Low Dose  AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical n         n           5      5        0       <NULL>  <NULL>
8 ARM    Xanomeline Low Dose  AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical N         N          84      84       0       <NULL>  <NULL>
9 ARM    Xanomeline Low Dose  AESOC  GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD  FATIGUE        hierarchical p         %           0.0595 6.0      <fn>    <NULL>  <NULL>

{cardx}

  • Extension of the {cards} package, providing additional functions to create Analysis Results Datasets (ARDs)

  • The {cardx} package exports many ard_*() function for statistical methods.

  • We will not go into {cardx} in this workshop — just know it is there when you need statistical results in an ARD.

cards and cardx package logos

When things go wrong 😱

What happens when statistics are un-calculable?

ard_gone_wrong <-
  adsl |> 
  ard_summary(
    by = ARM2,
    variable = AGEGR1,
    statistic = ~list(kurtosis = \(x) e1071::kurtosis(x))
  ) |> 
  cards::replace_null_statistic()
ard_gone_wrong
# An ARD data frame: 2 × 10
  group1 group1_level variable context stat_name stat_label stat   fmt_fun warning                                          error                                  
* <chr>  <list>       <chr>    <chr>   <chr>     <chr>      <list> <list>  <list>                                           <list>                                 
1 ARM2   Placebo      AGEGR1   summary kurtosis  kurtosis   NA     <fn>    argument is not numeric or logical: returning NA non-numeric argument to binary operator
2 ARM2   Xanomeline   AGEGR1   summary kurtosis  kurtosis   NA     <fn>    argument is not numeric or logical: returning NA non-numeric argument to binary operator
cards::print_ard_conditions(ard_gone_wrong)

An ARD Skill

A Skill is a folder with a SKILL.md. The YAML description is the trigger — write it as when to use it — and the body holds the procedure, loaded on demand.

Lives at .agents/skills/ard-creation/SKILL.md. Bundle reference files (worked examples, a {cardx} catalog) beside it. Skills are portable across agents that read the .agents/ convention.

---
name: ard-creation
description: Build ARDs (Analysis Results Datasets) in R with cards and
  cardx. Use whenever the user asks for an ARD, or for the numbers
  behind a table -- counts, descriptive statistics, or test results.
---

# Analysis Results Datasets

Setup: load cards; add cardx and broom as needed.

## Decision order
1. {cards} ard_*() first -- counting and tabulating, univariate
   summaries, some multivariable summaries, and more:
   ard_tabulate(), ard_summary(), ard_mvsummary(), ard_missing();
   stack with ard_stack(). For nested tabulations use the stack
   versions ard_stack_hierarchical*(), not ard_hierarchical*()
2. statistical methods and tests -> {cardx}, e.g.
   ard_stats_t_test(), ard_regression()
3. method not in {cardx} -> wrap broom::tidy() and convert to ARD,
   e.g. \(x) t.test(x) |> broom::tidy() inside ard_summary()
4. no tidy method -> build the ARD brute force with dplyr, nest_for_ard(), bind_ard()

Always check_ard_structure() the result.

Why ARDs? 🤝

  • Compute once, reuse everywhere — one ARD feeds any number of tables and figures

  • QC the values — numeric or rounded/formatted…or both!

  • Composable by design — every ard_*() result stacks into one tidy data frame 🥞

  • The engine for the ARS — {cards} powers a metadata-driven, reproducible analysis workflow

ARDs are the foundation. Next, we turn these results into tables. ➡️

Learn more

cards and cardx package logos

📦 {cards} — build ARDs

📦 {cardx} — statistical ARDs

🌐 pharmaverse.org

📖 CDISC Analysis Results Standard

Thank you! Questions? 🙋

{gtsummary} fundamentals

How it started

  • Began to address reproducibility issues while working in academia

  • Goal to build a package to summarize study results with code that was both simple and customizable

How it’s going

  • The stats

    • 2,300,000+ installations from CRAN
    • 1,200 GitHub stars
    • 1,500 citations in peer-reviewed articles
    • 50 code contributors
  • Won the 2025 Brian Bole Award of Excellence from R in Pharma

  • Won the 2021 American Statistical Association (ASA) Innovation in Programming Award

  • Won the 2024 Posit Pharma Table Contest

{pharmaverse} 30-day CRAN Downloads

{gtsummary} overview

Create tabular summaries with sensible defaults but highly customizable.

For our workshop, we will focus on the following summary types as well as themes and print engines.

  • tbl_summary()

  • tbl_hierarchical()

Other helpful functions we’re not covering:

  • tbl_hierarchical_count(): similar to tbl_hierarchical() for counts instead of rates

  • tbl_cross(): cross tabulations

  • tbl_continuous(): summarizing continuous variables by 2 categorical variables

  • tbl_wide_summary(): similar to tbl_summary() but statistics are presented in separate columns

  • regression models, survival data, survey data, and many more!

Basic tbl_summary()

library(gtsummary)

adsl |>
  tbl_summary(
    include = c(AGE, ETHNIC, FEMALE)
  )
Characteristic N = 2541
Age 77 (70, 81)
Ethnicity
    HISPANIC OR LATINO 12 (4.7%)
    NOT HISPANIC OR LATINO 242 (95%)
Female 143 (56%)
1 Median (Q1, Q3); n (%)
  • Four types of summaries: continuous, continuous2, categorical, and dichotomous

  • Statistics are median (IQR) for continuous, n (%) for categorical/dichotomous

  • Variables coded 0/1, TRUE/FALSE, Yes/No treated as dichotomous by default

  • Label attributes are printed automatically

Customize tbl_summary() output

adsl |>
  tbl_summary(
    include = c(AGE, ETHNIC, FEMALE),
    by = ARM2,
    type = AGE ~ "continuous2",
    statistic =
      list(
        AGE ~ c("{mean} ({sd})",
                "{min}, {max}"),
        FEMALE ~ "{n} / {N} ({p}%)"
      ),
    label =
      AGE ~ "Age, years",
    digits = AGE ~ list(sd = 1) # report SD(age) to one decimal place
  )
Characteristic Placebo
N = 861
Xanomeline
N = 1681
Age, years

    Mean (SD) 75 (8.6) 75 (8.1)
    Min, Max 52, 89 51, 88
Ethnicity

    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%)
Female 53 / 86 (62%) 90 / 168 (54%)
1 n (%); n / N (%)
  • by: specify a column variable for cross-tabulation

  • type: specify the summary type

  • statistic: customize the reported statistics

  • label: change or customize variable labels

  • digits: specify the number of decimal places for rounding

{gtsummary} + formulas

This syntax is also used in {cards}, {cardx}, {crane}, and {gt}.

Named list are OK too! label = list(age = "Patient Age")

{gtsummary} selectors

  • Use the following helpers to select groups of variables: all_continuous(), all_categorical()

  • Use all_stat_cols() to select the summary statistic columns

Add-on functions in {gtsummary}

tbl_summary() objects can also be updated using related functions.

  • add_*() add additional column of statistics or information, e.g. p-values, q-values, overall statistics, treatment differences, N obs., and more

  • modify_*() modify table headers, spanning headers, footnotes, and more

Update tbl_summary() with add_*()

adsl |>
  tbl_summary(
    by = ARM2,
    include = c(AGE, ETHNIC, FEMALE)
  ) |>
  add_overall(last = TRUE)
Characteristic Placebo
N = 861
Xanomeline
N = 1681
Overall
N = 2541
Age 76 (69, 82) 77 (71, 81) 77 (70, 81)
Ethnicity


    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%) 12 (4.7%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%) 242 (95%)
Female 53 (62%) 90 (54%) 143 (56%)
1 Median (Q1, Q3); n (%)
  • add_overall(): adds a column of overall statistics

Update tbl_summary() with add_*()

ad_onco |>
  tbl_summary(
    by = ARM2,
    include = c(TUMOR_SIZE, PFS_EVENT),
    missing = "no"
  ) |>
  add_n() |>
  add_p(
    pvalue_fun = scales::label_pvalue()
  )
Characteristic N Placebo
N = 961
Xanomeline
N = 1771
p-value2
Tumor Size, mm 25 78 (38, 96) 67 (26, 92) 0.659
Progression 273 6 (6.3%) 9 (5.1%) 0.687
1 Median (Q1, Q3); n (%)
2 Wilcoxon rank sum exact test; Pearson’s Chi-squared test
  • add_n(): adds a column non-missing counts

  • add_p(): adds a column of p-values

  • add_difference(): mean and rate differences between two groups. Can also be adjusted differences

  • add_stat(): add any statistic you like, placed on the label or the level rows, in a single column or many

  • And many more!

Update tbl_summary() with modify_*()

tbl <-
  adsl |>
  tbl_summary(by = ARM2, include = c("AGE", "ETHNIC", "FEMALE")) |>
  modify_header(
    stat_1 ~ "**Group A**",
    stat_2 ~ "**Group B**"
  ) |>
  modify_spanning_header(
    all_stat_cols() ~ "**Drug**") |>
  modify_footnote_header(
    footnote = paste("median (IQR) for continuous;",
                     "n (%) for categorical"),
    columns = all_stat_cols()
  )
tbl
Characteristic
Drug
Group A1 Group B1
Age 76 (69, 82) 77 (71, 81)
Ethnicity

    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%)
Female 53 (62%) 90 (54%)
1 median (IQR) for continuous; n (%) for categorical

Use show_header_names() to see the header names available to modify_header():

show_header_names(tbl)
Column Name   Header                 level*             N*          n*          p*             
label         "**Characteristic**"                      254 <int>                              
stat_1        "**Group A**"             Placebo <chr>   254 <int>    86 <int>   0.339 <dbl>    
stat_2        "**Group B**"          Xanomeline <chr>   254 <int>   168 <int>   0.661 <dbl>    
* These values may be dynamically placed into headers (and other locations).
ℹ Review the `modify_header()` (`?gtsummary::modify_header()`) help for examples.

Adverse Event Reporting (and friends)

Use tbl_hierarchical() and tbl_hierarchical_count() for reporting of AEs, Con Meds, and more.

tbl_ae <- adae |>
  tbl_hierarchical(
    by = "ARM2",
    variables = c("AESOC", "AEDECOD"),
    id = "USUBJID",
    denominator = adsl
  )
tbl_ae
Primary System Organ Class
    Dictionary-Derived Term
Placebo
N = 861
Xanomeline
N = 1681
CARDIAC DISORDERS 4 (4.7%) 8 (4.8%)
    ATRIAL FLUTTER 0 (0%) 2 (1.2%)
    MYOCARDIAL INFARCTION 4 (4.7%) 6 (3.6%)
EYE DISORDERS 1 (1.2%) 0 (0%)
    EYE ALLERGY 1 (1.2%) 0 (0%)
    EYE SWELLING 1 (1.2%) 0 (0%)
1 n (%)

The same add_*() and modify_*() verbs work here too, e.g. add_overall(last = TRUE) and add_difference() for risk differences between arms.

Sort and filter the hierarchy

tbl_ae |>
  sort_hierarchical() |>
  filter_hierarchical(sum(n) >= 5)
Primary System Organ Class
    Dictionary-Derived Term
Placebo
N = 861
Xanomeline
N = 1681
CARDIAC DISORDERS 4 (4.7%) 8 (4.8%)
    MYOCARDIAL INFARCTION 4 (4.7%) 6 (3.6%)
1 n (%)
  • sort_hierarchical(): orders rows by descending event frequency (default). Use sort = everything() ~ "alphanumeric" to sort alphabetically instead

  • filter_hierarchical(): keeps AEs meeting an occurrence threshold. The filter is an expression on the underlying counts, e.g. sum(n) >= 5, n_overall > 20, or p_overall > 0.05

tbl_merge() for side-by-side tables

tbl_n <- 
  tbl_summary(adsl, include = ETHNIC, statistic = ETHNIC ~ "{n}") |> 
  modify_header(all_stat_cols() ~ "**N**") |> # update column header
  remove_footnote_header() # remove footnote
tbl_age <-
  tbl_continuous(adsl, include = ETHNIC, variable = AGE, by = ARM2) |> 
  modify_header(all_stat_cols() ~ "**{level}**") # update header

# combine the tables side by side
list(tbl_n, tbl_age) |>
  tbl_merge(tab_spanner = FALSE) # suppress default header
Characteristic N Placebo1 Xanomeline1
Ethnicity


    HISPANIC OR LATINO 12 64 (63, 86) 63 (56, 78)
    NOT HISPANIC OR LATINO 242 76 (70, 82) 77 (71, 81)
1 Age: Median (Q1, Q3)

tbl_stack() to combine vertically

tbl_drug_a <- filter(adsl, ARM2 == "Placebo") |> 
  tbl_summary(include = ETHNIC)
tbl_drug_b <- filter(adsl, ARM2 == "Xanomeline") |> 
  tbl_summary(include = ETHNIC)

# stack the two tables 
list(tbl_drug_a, tbl_drug_b) |> 
  tbl_stack(group_header = c("Placebo", "Xanomeline"), quiet = TRUE) |> # optionally include headers for each table
  modify_header(all_stat_cols() ~ "**Summary Statistics**")
Characteristic Summary Statistics1
Placebo
Ethnicity
    HISPANIC OR LATINO 3 (3.5%)
    NOT HISPANIC OR LATINO 83 (97%)
Xanomeline
Ethnicity
    HISPANIC OR LATINO 9 (5.4%)
    NOT HISPANIC OR LATINO 159 (95%)
1 n (%)

Break ☕

Covered so far: Analysis Results Datasets · {gtsummary} fundamentals

Up next

  1. ARD-first tables — every cell traceable, and QC by comparing one ARD against another.

  2. Print engines — {gt}, {flextable}, and submission-ready Word output.

  3. Adopting {gtsummary} — a house theme, wrappers of your own, and {crane}.

  4. Coding agents — writing your standards down so an agent follows them without being reminded.

ARD-first tables

Where are the ARDs?

  • ARDs are the backbone for all calculations in gtsummary

  • Every gtsummary table saves the ARDs from each calculation

cards package hex logo
ARDs
gtsummary package hex logo
Tables
tbl <- tbl_summary(adsl, by = "ARM2", include = "AGE")
gather_ard(tbl)
$tbl_summary
# An ARD data frame: 27 × 12
   group1 group1_level variable variable_level context  stat_name stat_label stat      fmt_fun warning error  gts_column
   <chr>  <list>       <chr>    <list>         <chr>    <chr>     <chr>      <list>    <list>  <list>  <list> <chr>     
 1 ARM2   Placebo      AGE      <NULL>         summary  median    Median     76        <fn>    <NULL>  <NULL> stat_1    
 2 ARM2   Placebo      AGE      <NULL>         summary  p25       Q1         69        <fn>    <NULL>  <NULL> stat_1    
 3 ARM2   Placebo      AGE      <NULL>         summary  p75       Q3         82        <fn>    <NULL>  <NULL> stat_1    
 4 ARM2   Xanomeline   AGE      <NULL>         summary  median    Median     77        <fn>    <NULL>  <NULL> stat_2    
 5 ARM2   Xanomeline   AGE      <NULL>         summary  p25       Q1         71        <fn>    <NULL>  <NULL> stat_2    
 6 ARM2   Xanomeline   AGE      <NULL>         summary  p75       Q3         81        <fn>    <NULL>  <NULL> stat_2    
 7 <NA>   <NULL>       AGE      <NULL>         attribu… label     Variable … Age       <fn>    <NULL>  <NULL> <NA>      
 8 <NA>   <NULL>       AGE      <NULL>         attribu… class     Variable … numeric   <NULL>  <NULL>  <NULL> <NA>      
 9 <NA>   <NULL>       ARM2     <NULL>         attribu… label     Variable … ARM2      <fn>    <NULL>  <NULL> <NA>      
10 <NA>   <NULL>       ARM2     <NULL>         attribu… class     Variable … character <NULL>  <NULL>  <NULL> <NA>      
# ℹ 17 more rows

ARD + QC

ARDs are wonderful for QCing {gtsummary} tables. 😻

  • ARDs include the formatted and un-formatted numbers that appear in the table.

  • Extract the ARD from the {gtsummary} table.

  • Build fresh ARD from source data, and compare it to the ARD from the table.

ARD + QC: Compare with compare_ard()

We built ard_table with gather_ard(), and ard_source independently from the source data with cards::ard_stack(). compare_ard() lines the two up on their key columns and reports any rows that differ — here, both the raw stat and the formatted stat_fmt.

cards::compare_ard(ard_table, ard_source, columns = c("stat", "stat_fmt"))
The comparison `keys` are "group1", "group1_level", "variable", "variable_level", and "stat_name".
The comparison columns are "stat" and "stat_fmt".


── Mis-matched Rows ────────────────────────────────────────────────────────────────────────────────────────────────────

✔ No rows in `x` that do not appear in `y`.

✔ No rows in `y` that do not appear in `x`.



── Comparison Results ──────────────────────────────────────────────────────────────────────────────────────────────────

✔ No differences found in column "stat".

✔ No differences found in column "stat_fmt".

ARD-first Tables

Similar to functions that accept a data frame, the package exports functions with nearly identical APIs that accept an ARD.

tbl_summary()

tbl_hierarchical()

tbl_continuous()

tbl_wide_summary()
tbl_ard_summary()

tbl_ard_hierarchical()

tbl_ard_continuous()

tbl_ard_wide_summary()

ARD-first Tables

We can use the skills we learned earlier today to create ARDs for gtsummary tables, then pass the ARD to tbl_ard_summary().

library(cards)

ard <- ard_stack(
  data = adsl,
  ard_summary(variables = AGE),
  ard_tabulate(variables = ETHNIC),
  ard_tabulate_value(variables = FEMALE),
  # add these for best-looking tables
  .attributes = TRUE,
  .missing = TRUE
)
tbl_ard_summary(ard)
Characteristic Overall1
Age 77.0 (70.0, 81.0)
Ethnicity
    HISPANIC OR LATINO 12 (4.7%)
    NOT HISPANIC OR LATINO 242 (95.3%)
Female 143 (56.3%)
1 Median (Q1, Q3); n (%)

ARD-first Table Shells

adsl |>
  labelled::set_variable_labels(AGE = "Age, years") |>
  ard_stack(
    .by = ARM2,
    ard_tabulate(variables = ETHNIC),
    # add these for best-looking tables
    .attributes = TRUE,
    .missing = TRUE
  ) |>
  cards::update_ard_fmt_fun(stat_names = c("n", "p"), fmt_fun = \(x) "xx") |>
  tbl_ard_summary(
    by = ARM2,
    type = all_continuous() ~ "continuous2",
    statistic = all_continuous() ~ c("{mean} ({sd})", "{min} - {max}"),
    missing = "no"
  ) |>
  modify_header(all_stat_cols() ~ "**{level}**  \nN = xx")

ARD-first Table Shells

Characteristic Placebo
N = xx1
Xanomeline
N = xx1
Ethnicity

    HISPANIC OR LATINO xx (xx%) xx (xx%)
    NOT HISPANIC OR LATINO xx (xx%) xx (xx%)
1 n (%)

One-step Word export

# one-step export to a Word document
tbl |> save_flex_docx(path = "table1.docx")

save_flex_docx() writes any {gtsummary} (or {flextable}) table to a submission-ready Word document in a single step.

  • Report headers & footers — we can now place customized output into the header and footer areas of the word document, and place field codes (e.g. Page X of Y)

  • Source notes & footnotes — by default, footnotes, source notes, and abbreviations are placed in the Word document’s footer area

  • Templates & page setup — supply a Word template= or pr_section= to control page size, margins, and landscape orientation

The AE table on the next slide is built with a custom header_ft, three source notes (the last carries the git SHA), and saved to the repo — then converted to PDF.

save_flex_docx() output

Adopting {gtsummary}

How to Adopt {gtsummary}

Three moves, in order.

1. Set a theme

Your house defaults — rounding, headers, print engine — applied to every table, without anyone passing an argument.

2. Write wrappers

Named functions for the tables your team builds over and over, so the shape of a table is written down once.

3. Ship them in a package

Adoption becomes a single library() call, and the standard is versioned and testable.

Themes that ship with {gtsummary}

A theme is set once, before you build any tables, and every table in the session picks it up.

  • theme_gtsummary_journal("jama") — defaults matching a journal’s guidelines

  • theme_gtsummary_compact() — smaller font and cell padding

  • theme_gtsummary_language("es") — translate every table

  • theme_gtsummary_printer(print_engine = "flextable") — change the print engine

Themes stack: call several, and each one layers onto the last.

theme_gtsummary_journal("jama")
theme_gtsummary_compact()

A theme is a named list

Every element is named "<function>-<input type>:<description>".

  • "pkgwide-str:print_engine" — applies package-wide, takes a string

  • "tbl_summary-fn:percent_fun" — applies to tbl_summary(), takes a function

Elements come in two flavors:

  1. Internal behavior you cannot reach with a function argument.

  2. Argument defaults — anything named *-arg:* sets that argument’s default.

Full catalog of theme elements: {gtsummary} themes vignette

Exercise: your own theme 🏃

  1. Open Exercise 2 on the workshop site and copy it into Positron/RStudio.

  2. Fill in the four theme elements, then check and set your theme.

Build a theme

Ship it as a function, the same way {gtsummary} and {crane} do — and start from a theme that already exists.

theme_gtsummary_rpharma <- function(set_theme = TRUE) {
  lst_theme <-
    utils::modifyList(
      # start from the shipped compact theme, then layer ours on top
      theme_gtsummary_compact(set_theme = FALSE),
      list(
        "pkgwide-str:theme_name"        = "R/Pharma 2026",
        "pkgwide-str:print_engine"      = "flextable",
        "tbl_summary-fn:percent_fun"    = scales::label_number(scale = 100, accuracy = 0.1),
        "pkgwide-fn:pvalue_fun"         = scales::label_pvalue(),
        "tbl_summary-str:header-withby" = "**{level}**  \n(N = {n})"
      )
    )

  if (set_theme == TRUE) set_gtsummary_theme(lst_theme)
  invisible(lst_theme)
}

Before and after

adsl |>
  tbl_summary(by = ARM2, include = c(AGE, ETHNIC)) |>
  add_p()

Package defaults · printed with {gt}

Characteristic Placebo
N = 861
Xanomeline
N = 1681
p-value2
Age 76 (69, 82) 77 (71, 81) 0.8
Ethnicity

0.8
    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%)
1 Median (Q1, Q3); n (%)
2 Wilcoxon rank sum test; Fisher’s exact test

After theme_gtsummary_rpharma() · printed with {flextable}

Characteristic

Placebo
(N = 86)1

Xanomeline
(N = 168)1

p-value2

Age

76 (69, 82)

77 (71, 81)

0.816

Ethnicity

0.756

HISPANIC OR LATINO

3 (3.5%)

9 (5.4%)

NOT HISPANIC OR LATINO

83 (96.5%)

159 (94.6%)

1Median (Q1, Q3); n (%)

2Wilcoxon rank sum test; Fisher's exact test

Why a theme for your company?

  • Consistency without memorization. Nobody has to remember that percentages go to one decimal place — the theme already knows.

  • One place to change. When the standard changes, edit the theme and re-render. You are not grepping for digits = across forty programs.

  • Better reviews. If formatting is handled, review comments are about the numbers instead of the decimal places.

  • A shorter first day. New team members inherit the house style before they have written a line of code.

  • Portable. Put the theme function in your company package and adoption is one library() call.

Themes end where names begin

A theme changes defaults — everywhere, all at once, for functions that already exist.

A wrapper earns its place when you want something a default cannot give you:

  • a name your team already says out loud — “the demography table”

  • documentation and tests that live with the function

  • a table built from several {gtsummary} calls, not one

  • defaults that apply to this table only, not the whole session

Exercise: your own wrapper 🏃

  1. Open Exercise 3 on the workshop site and copy it into Positron/RStudio.

  2. Finish tbl_pharma_summary(), then use it to summarize AGE and ETHNIC by ARM2.

tbl_pharma_summary()

A thin wrapper: multi-line continuous summaries and our house statistics, with everything still overridable.

tbl_pharma_summary <- function(
    data,
    by = NULL,
    type = NULL,
    statistic = list(
      all_continuous() ~ c("{mean} ({sd})", "{median}",
                           "{p25}, {p75}", "{min}, {max}"),
      all_categorical() ~ "{n} ({p}%)"
    ),
    ...) {
  with_gtsummary_theme(
    list("tbl_summary-str:default_con_type" = "continuous2"),
    tbl_summary(
      data = data,
      by = {{ by }},
      type = type,
      statistic = statistic,
      ...
    )
  )
}

tbl_pharma_summary() in action

adsl |>
  tbl_summary(
    by = ARM2,
    include = c(AGE, ETHNIC)
  )
Characteristic Placebo
N = 861
Xanomeline
N = 1681
Age 76 (69, 82) 77 (71, 81)
Ethnicity

    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%)
1 Median (Q1, Q3); n (%)
adsl |>
  tbl_pharma_summary(
    by = ARM2,
    include = c(AGE, ETHNIC)
  )
Characteristic Placebo
N = 861
Xanomeline
N = 1681
Age

    Mean (SD) 75 (9) 75 (8)
    Median 76 77
    Q1, Q3 69, 82 71, 81
    Min, Max 52, 89 51, 88
Ethnicity

    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (97%) 159 (95%)
1 n (%)

A default, not a lock

An explicit argument still beats the theme element, so nothing is taken away from the caller.

adsl |>
  tbl_pharma_summary(
    by = ARM2,
    include = AGE,
    type = all_continuous() ~ "continuous",
    statistic = all_continuous() ~ "{median} ({p25}, {p75})"
  )
Characteristic Placebo
N = 861
Xanomeline
N = 1681
Age 76 (69, 82) 77 (71, 81)
1 Median (Q1, Q3)

Extension Package:{crane}

We just wrote a one-function version of this. {crane} is the same idea, maintained as a real package — Roche’s theme and wrappers, on CRAN.

  • Continuous variables default to continuous2.

  • tbl_summary(missing*) arguments have been changed to tbl_roche_summary(nonmissing*).

    • Highlights non-missing counts over missing counts, which are the default in {gtsummary}
  • Counts represented by 0 (0%) print as 0.

library(crane)

adsl |>
  dplyr::mutate(ETHNIC = forcats::fct_expand(ETHNIC, "REFUSED")) |>
  tbl_roche_summary(
    by = ARM2,
    include = c(AGE, ETHNIC),
    nonmissing = "always"
  )

Extension Package:{crane}

Placebo
(N = 86)
Xanomeline
(N = 168)
Age

    n 86 168
    Mean (SD) 75.2 (8.6) 75.0 (8.1)
    Median 76.0 77.0
    Min - Max 52 - 89 51 - 88
ETHNIC

    n 86 168
    HISPANIC OR LATINO 3 (3.5%) 9 (5.4%)
    NOT HISPANIC OR LATINO 83 (96.5%) 159 (94.6%)
    REFUSED 0 0

What else is in {crane}?

Lab values are summarized by visit and include the change from baseline.

This is a simple table that is just a tbl_merge() of the AVAL summary and the CHG summary.

But the general structure appears enough times in our catalog, we make it simple for our programmers to create.

adlb |>
  dplyr::filter(PARAM == "Albumin (g/L)") |>
  tbl_baseline_chg(
    by = "ARM",
    baseline_level = "Baseline",
    denominator = adsl
  )

What else is in {crane}?

What else is in {crane}?

What else is in {crane}?

What else is in {crane}?

A pharma theme with {crane}

Our theme is implemented in crane::theme_gtsummary_roche(). Primary changes include a custom function for rounding percentages, p-values rounded to four decimal places, headers that default to bold and include N in parentheses (e.g. 'Placebo \n (N = 184)'), and printing all tables with {flextable} using Roche-specific styling — font, font size, borders, cell padding, etc.

Same named list, same set_theme argument — just with style_roche_percent() and style_roche_pvalue() in the function slots, and eleven elements instead of five.

theme_gtsummary_roche()

adsl |>
  dplyr::mutate(ETHNIC = forcats::fct_expand(ETHNIC, "REFUSED")) |>
  tbl_roche_summary(
    by = ARM2,
    include = c(AGE, ETHNIC),
    nonmissing = "always"
  )

Placebo
(N = 86)

Xanomeline
(N = 168)

Age

n

86

168

Mean (SD)

75.2 (8.6)

75.0 (8.1)

Median

76.0

77.0

Min - Max

52 - 89

51 - 88

ETHNIC

n

86

168

HISPANIC OR LATINO

3 (3.5%)

9 (5.4%)

NOT HISPANIC OR LATINO

83 (96.5%)

159 (94.6%)

REFUSED

0

0

cardinal collaboration

The cardinal initiative is an industry collaborative effort under the pharmaverse.

The site includes examples for building ARDs and tables from the FDA Standard Safety Tables and Figures Integrated Guide using {cards} and {gtsummary}.

Let’s Go!

Coding agents

From chat LLM to coding partner

Earlier we pasted a prompt into a chat LLM and copied code back.

A coding agent (e.g. Claude Code) instead lives inside your project: it reads your files, runs R, and iterates — and it follows instructions you commit to the repo.

The goal: a coding partner that adheres to your domain standards without constant reminders.

The golden path

Give the agent an explicit priority order for building a table:

  1. Set the theme — crane::theme_gtsummary_roche() after loading packages.

  2. Prefer {crane} wrappers — tbl_roche_summary(), tbl_baseline_chg(), … when one fits.

  3. Fall back to {gtsummary} — tbl_summary(), tbl_hierarchical(), …

  4. ARD-first for complex/custom — build ARDs with {cards}/{cardx}, render with gtsummary::tbl_ard_*().

  5. Last resort — build the ARD yourself, then gtsummary::as_gtsummary().

A gtsummary Skill

The golden path above, encoded. Lives at .agents/skills/gtsummary-tables/SKILL.md, with reference files (a crane catalog, worked examples) bundled beside it.

---
name: gtsummary-tables
description: Build clinical summary tables in R with crane, gtsummary,
  and cards. Use whenever the user asks for a summary / demographics /
  AE / lab table or a "Table 1".
---

# Clinical summary tables

Setup: load tidyverse, crane, gtsummary, cards; then
crane::theme_gtsummary_roche().

## Decision order
1. {crane} wrapper (tbl_roche_summary(), tbl_baseline_chg()) if one fits
2. else {gtsummary} tbl_*() (tbl_summary(), tbl_hierarchical())
3. complex/custom -> ARD-first: cards::ard_stack() / cardx::ard_*()
   -> tbl_ard_*()
4. last resort -> build the ARD, then as_gtsummary()

Never use gtsummary::tbl_custom_summary(): it is not ARD-based.

Always gather_ard() the result for QC.

In Closing

Three ideas to take home

Consistent verbs, every table

One consistent set of tbl_*() verbs — from a demographics table to AE and lab tables.

ARD-backed means trust

Every cell is traceable and QC-ready via gather_ard() — submission-ready by construction.

Encode it, then scale it

A shared theme and reusable wrappers capture your house standards; a coding agent applies them without reminders.

Where to go next

{cardinal} — FDA safety-table catalog


Ask on stackoverflow.com — use the gtsummary tag. Just kidding — nobody uses Stack Overflow anymore; ask an AI.

Thank you

2,300,000+ installations — go build something cute.