Overview
Today covers two complementary approaches to clinical table production:
gtsummary - builds publication-quality tables directly from ADaM data, and can extract an ARD via gather_ard()
tfrmt - separates data preparation from display spec; one format spec applies to many tables
Setup
library (gtsummary)
library (tfrmt)
library (pharmaverseadam)
library (dplyr)
library (gt)
# Used explicitly later
library (purrr)
library (tidyr)
# Safety population
adsl_safe <- pharmaverseadam:: adsl |>
dplyr:: filter (SAFFL == "Y" )
# Lab data for tfrmt section
adlb <- pharmaverseadam:: adlb |>
dplyr:: filter (SAFFL == "Y" , ! is.na (AVAL))
cat ("ADSL Safety N:" , nrow (adsl_safe), " \n " )
cat ("ADLB records:" , nrow (adlb), " \n " )
cat (" \n ADLB columns (first 30): \n " )
print (utils:: head (names (adlb), 30 ))
[1] "STUDYID" "DOMAIN" "USUBJID" "LBSEQ" "LBTESTCD" "LBTEST"
[7] "LBCAT" "LBORRES" "LBORRESU" "LBORNRLO" "LBORNRHI" "LBSTRESC"
[13] "LBSTRESN" "LBSTRESU" "LBSTNRLO" "LBSTNRHI" "LBNRIND" "LBBLFL"
[19] "VISITNUM" "VISIT" "VISITDY" "LBDTC" "LBDY" "TRTSDT"
[25] "TRTEDT" "TRT01A" "TRT01P" "ADT" "ADY" "PARAMCD"
Part 1: gtsummary
Step 1: Demographics Table
tbl_demo <- adsl_safe |>
gtsummary:: tbl_summary (
by = TRT01A,
include = c (AGE, AGEGR1, SEX, RACE, ETHNIC),
type = list (AGE ~ "continuous2" ),
statistic = list (
gtsummary:: all_continuous2 () ~ c ("{mean} ({sd})" , "{median} ({p25}, {p75})" ),
gtsummary:: all_categorical () ~ "{n} ({p}%)"
),
digits = gtsummary:: all_continuous () ~ 1 ,
missing = "no"
) |>
gtsummary:: add_overall (last = TRUE ) |>
gtsummary:: add_n () |>
gtsummary:: modify_header (label = "**Characteristic**" ) |>
gtsummary:: bold_labels () |>
gtsummary:: add_stat_label ()
tbl_demo
Step 2: Convert to gt and Add Headers
tbl_demo |>
gtsummary:: as_gt () |>
gt:: tab_header (
title = "Table 14.1.1" ,
subtitle = "Demographic and Baseline Characteristics (Safety Population)"
) |>
gt:: tab_source_note (
source_note = "Source: pharmaverseadam::adsl | Population: Safety (SAFFL=Y)"
)
Step 4: Lab Summary Table from ADLB
# Summarise ALT at baseline per treatment
# (Do not assume AVALU exists in pharmaverseadam::adlb)
adlb_alt_bl <- adlb |>
dplyr:: filter (PARAMCD == "ALT" , ABLFL == "Y" ) |>
dplyr:: select (TRT01A, AVAL)
cat ("ALT baseline records:" , nrow (adlb_alt_bl), " \n " )
ALT baseline records: 254
tbl_lab <- adlb_alt_bl |>
gtsummary:: tbl_summary (
by = TRT01A,
include = AVAL,
type = list (AVAL ~ "continuous2" ),
statistic = list (
gtsummary:: all_continuous2 () ~ c (
"{mean} ({sd})" ,
"{median} ({p25}, {p75})" ,
"{min}, {max}"
)
),
digits = gtsummary:: all_continuous () ~ 1 ,
missing = "no" ,
label = list (AVAL ~ "ALT" )
) |>
gtsummary:: add_overall (last = TRUE ) |>
gtsummary:: add_n () |>
gtsummary:: bold_labels ()
tbl_lab
Step 5: Logistic Regression Table
# Logistic regression: SEX ~ AGE + TRT01A
adsl_model <- adsl_safe |>
dplyr:: mutate (SEX_BIN = dplyr:: if_else (SEX == "M" , 1 L, 0 L))
logit_mod <- glm (
SEX_BIN ~ AGE + TRT01A,
data = adsl_model,
family = binomial ()
)
tbl_reg <- logit_mod |>
gtsummary:: tbl_regression (
exponentiate = TRUE ,
label = list (AGE ~ "Age (years)" , TRT01A ~ "Treatment Arm" )
) |>
gtsummary:: add_n () |>
gtsummary:: add_nevent () |>
gtsummary:: bold_p (t = 0.05 ) |>
gtsummary:: bold_labels ()
tbl_reg
Step 6: Merge Tables with tbl_merge
tbl_merged <- gtsummary:: tbl_merge (
tbls = list (tbl_demo, tbl_lab),
tab_spanner = c ("**Demographics**" , "**ALT at Baseline**" )
)
tbl_merged
Part 2: tfrmt
Step 8: Prepare BDS Summary Data for tfrmt
# Summarise ADLB: mean and SD of AVAL by PARAMCD, AVISIT, TRT01A
adlb_summary <- adlb |>
dplyr:: filter (PARAMCD %in% c ("ALT" , "AST" , "BILI" )) |>
dplyr:: filter (! is.na (AVISIT), ! is.na (AVAL)) |>
dplyr:: group_by (PARAMCD, AVISIT, TRT01A) |>
dplyr:: summarise (
mean = mean (AVAL, na.rm = TRUE ),
sd = sd (AVAL, na.rm = TRUE ),
.groups = "drop"
) |>
tidyr:: pivot_longer (
cols = c (mean, sd),
names_to = "PARAM" ,
values_to = "VALUE"
) |>
dplyr:: rename (VISIT = AVISIT)
cat ("tfrmt input rows:" , nrow (adlb_summary), " \n " )
# A tibble: 10 × 5
PARAMCD VISIT TRT01A PARAM VALUE
<chr> <chr> <chr> <chr> <dbl>
1 ALT Ambul Ecg Removal Xanomeline Low Dose mean 15
2 ALT Ambul Ecg Removal Xanomeline Low Dose sd NA
3 ALT Baseline Placebo mean 17.6
4 ALT Baseline Placebo sd 9.22
5 ALT Baseline Xanomeline High Dose mean 19.2
6 ALT Baseline Xanomeline High Dose sd 10.3
7 ALT Baseline Xanomeline Low Dose mean 18.1
8 ALT Baseline Xanomeline Low Dose sd 8.74
9 ALT POST-BASELINE LAST Placebo mean 16.6
10 ALT POST-BASELINE LAST Placebo sd 10.8
Step 10: Mock Table from tfrmt Spec
tfrmt:: print_mock_gt (my_fmt, .data = adlb_summary)
ALT
Ambul Ecg Removal
xx.x (xx.xx)
Baseline
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE LAST
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MAXIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MINIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Retrieval
xx.x (xx.xx)
Unscheduled 1.1
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.3
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 12.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 13.1
xx.x (xx.xx)
Unscheduled 4.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 4.2
xx.x (xx.xx)
Unscheduled 5.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 6.1
xx.x (xx.xx)
Unscheduled 7.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 8.2
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 9.2
xx.x (xx.xx)
Week 12
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 16
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 20
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 24
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 26
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 4
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 6
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 8
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
AST
Ambul Ecg Removal
xx.x (xx.xx)
Baseline
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE LAST
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MAXIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MINIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Retrieval
xx.x (xx.xx)
Unscheduled 1.1
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.3
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 12.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 13.1
xx.x (xx.xx)
Unscheduled 4.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 4.2
xx.x (xx.xx)
Unscheduled 5.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 6.1
xx.x (xx.xx)
Unscheduled 7.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 8.2
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 9.2
xx.x (xx.xx)
Week 12
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 16
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 20
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 24
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 26
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 4
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 6
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 8
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
BILI
Ambul Ecg Removal
xx.x (xx.xx)
Baseline
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE LAST
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MAXIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
POST-BASELINE MINIMUM
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Retrieval
xx.x (xx.xx)
Unscheduled 1.1
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 1.3
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 12.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 13.1
xx.x (xx.xx)
Unscheduled 4.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 4.2
xx.x (xx.xx)
Unscheduled 5.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 6.1
xx.x (xx.xx)
Unscheduled 7.1
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 8.2
xx.x (xx.xx)
xx.x (xx.xx)
Unscheduled 9.2
xx.x (xx.xx)
Week 12
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 16
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 2
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 20
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 24
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 26
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 4
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 6
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Week 8
xx.x (xx.xx)
xx.x (xx.xx)
xx.x (xx.xx)
Step 11: Export tfrmt table to HTML
tfrmt_gt |>
gt:: gtsave ("tfrmt_lab_summary.html" )
cat ("✓ tfrmt_lab_summary.html exported \n " )
✓ tfrmt_lab_summary.html exported
Validation Checks
cat (" \n === Day 25 Validation === \n\n " )
=== Day 25 Validation ===
# Check 1: Safety N by arm
cat ("Check 1 - Safety N by arm: \n " )
Check 1 - Safety N by arm:
print (adsl_safe |> dplyr:: count (TRT01A))
# A tibble: 3 × 2
TRT01A n
<chr> <int>
1 Placebo 86
2 Xanomeline High Dose 72
3 Xanomeline Low Dose 96
# Check 2: ARD extracted from gtsummary has rows
cat (" \n Check 2 - ARD rows from gather_ard():" , nrow (ard_from_gtsumm), " \n " )
Check 2 - ARD rows from gather_ard(): 193
# Check 3: tfrmt data has all 3 parameters
cat (" \n Check 3 - PARAMCD in tfrmt data: \n " )
Check 3 - PARAMCD in tfrmt data:
print (adlb_summary |> dplyr:: distinct (PARAMCD))
# A tibble: 3 × 1
PARAMCD
<chr>
1 ALT
2 AST
3 BILI
# Check 4: No NA VALUE in tfrmt input
cat (" \n Check 4 - NA VALUE rows:" , nrow (adlb_summary |> dplyr:: filter (is.na (VALUE))), " \n " )
Check 4 - NA VALUE rows: 54
cat (" \n ✓ Validation complete \n " )
Key Takeaways
tbl_summary(by = TRT01A) is the workhorse for clinical demographics tables
gather_ard() extracts a CDISC-compatible ARD from any gtsummary table
tfrmt decouples format spec from data; print_mock_gt() helps you agree shells early
Always validate dataset columns (e.g., do not assume AVALU exists)
Next Steps
Day 26: flextable + officer - Word/RTF clinical output