How to make nice publishable adverse event tables using tidyverse

This blog post is just an answer to a colleague to provide R code for the generation of Adverse Event tables. And it is also nice to have the code available when I need it in the future. Probably I will pull my hair at the horrible code, but this gives room to enhance it later.

Functions

First I define all functions to be used. I reuse some of the ideas in the post where I show how to make publishable tables using purrr here.

ae_n_pct <-  function(data, var, group, level = 1) {
  var <- ensym(var)
  group <- ensym(group)

  data %>% 
    group_by(subjectid, !!group, !!var) %>% 
    summarise(n = sum(!!var)) %>% 
    group_by(!!group, !!var) %>% 
    summarise(n_ae = sum(n),
            n_pat = n()) %>% 
    group_by(!!group) %>% 
    mutate(N_pat = sum(n_pat),
           pct = round(n_pat/N_pat*100,digits = 1),
           txt = paste0(n_pat, " (", pct, "%)")) %>%
    filter(!!var %in% !!level) %>% 
    ungroup %>% 
    select(!!group, txt) %>% 
    deframe
}

ae_N_n_pct <-  function(data, var, group, level = 1) {
  var <- ensym(var)
  group <- ensym(group)

  data %>% 
    group_by(subjectid, !!group) %>% 
    summarise(n = sum(!!var)) %>% 
    mutate(!!var := if_else(n==0, 0, 1)) %>% 
    group_by(!!group, !!var) %>% 
    summarise(n_ae = sum(n),
            n_pat = n()) %>% 
    group_by(!!group) %>% 
    mutate(N_pat = sum(n_pat),
           pct = round(n_pat/N_pat*100,digits = 1),
           txt = paste0("[", n_ae,"] ", n_pat, " (", pct, "%)")) %>%
    mutate(txt = if_else(n_ae == 0, "[0] 0 (0%)", txt)) %>% 
    filter(!!var %in% !!level) %>% 
    ungroup %>% 
    select(!!group, txt) %>% 
    deframe
}

stats_exec <- function(f, data, var, group, ...){
    exec(f, data, var, group, !!!(...))
}

Then I do a bit of data wrangling. The mock-up data can be downloaded in .rds format here

adae <- readRDS("adae.rds") %>% 
  mutate(anyae = if_else(is.na(pt), 0, 1),
         sae = if_else(is.na(pt), 0, sae)
  ) %>%  
  group_by(subjectid) %>% 
  mutate(n_ae = sum(anyae),
         one_ae = n_ae == 1,
         two_ae = n_ae == 2,
         three_plus_ae = n_ae > 2,
         anysae = max(sae)) %>% 
  ungroup

Summary of Adverse Events

The summary of Adverse Events is a nice table just summing up the adverse events in the trial. Note the “[N] n (%)”-format which is the number of events, number of patients with events and percentage of patients with event.

arms <- c("Active", "Control")
total_n <- n_distinct(adae$subjectid)

header_ae <- adae %>%
  group_by(trt, subjectid) %>%
  summarise(n=n()) %>%
  group_by(trt) %>%
  summarise(n = n()) %>%
  ungroup() %>%
  mutate(armtxt = arms) %>%
  mutate(txt = paste0(armtxt, " (N=", n, ")")) %>%
  select(txt) %>%
  deframe
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
ae_summary_table <- tribble(
    ~text,  ~var, ~f,
  "Number of AEs", "anyae", "ae_N_n_pct",
  "Number of patients with any AEs?", "anyae", "ae_n_pct",
  "Number of patients with one AE", "one_ae", "ae_n_pct",
  "Number of patients with two AE", "two_ae", "ae_n_pct",
  "Number of patients with three or more AEs", "three_plus_ae", "ae_n_pct",
  "Number of SAEs", "sae", "ae_N_n_pct",
  "Number of patients with any SAEs?", "anysae","ae_n_pct"
)

ae_summary_table %>%
  mutate(data = list(adae),
         group = "trt",
         param = list(level = 1)) %>%
  mutate(res = pmap(list(f, data, var, group, param), stats_exec)) %>%
  mutate(id = map(res,names)) %>%
  unnest(c(res, id)) %>%
  mutate(id = paste0("txt", id)) %>%
  pivot_wider(values_from = res, names_from = id) %>%
  select(text, starts_with("txt")) %>%
  kable(col.names = c("Parameter", header_ae),
        caption = "Summary of Adverse Events",
        booktabs = TRUE)
## `summarise()` has grouped output by 'subjectid'. You can override using the
## `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid', 'trt'. You can override using
## the `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid', 'trt'. You can override using
## the `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid', 'trt'. You can override using
## the `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid', 'trt'. You can override using
## the `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid'. You can override using the
## `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
## `summarise()` has grouped output by 'subjectid', 'trt'. You can override using
## the `.groups` argument.
## `summarise()` has grouped output by 'trt'. You can override using the `.groups`
## argument.
Table 1: Summary of Adverse Events
Parameter Active (N=81) Control (N=80)
Number of AEs [149] 74 (91.4%) [165] 79 (98.8%)
Number of patients with any AEs? 74 (86%) 79 (92.9%)
Number of patients with one AE 14 (17.3%) 12 (15%)
Number of patients with two AE 13 (16%) 16 (20%)
Number of patients with three or more AEs 49 (60.5%) 52 (65%)
Number of SAEs [4] 4 (4.9%) [2] 2 (2.5%)
Number of patients with any SAEs? 6 (7.4%) 6 (7.5%)

Adverse Events by System Organ Class and Preferred term

This table is almost a listing, but it gives a nice overview of all Adverse Events in the trial. First we need to make function which does most of the work.

ae_table_fns <- function(data, filtervar){

  filtervar = ensym(filtervar)

data %>%
  group_by(trt) %>%
  mutate(N_pat = n_distinct(subjectid)) %>%
  filter(!!filtervar == 1)  %>%
  group_by(subjectid, trt, N_pat, soc, pt) %>%
  summarise(n_ae = n()) %>%
  filter(!is.na(pt)) %>%
  group_by(trt, N_pat, soc, pt) %>%
  summarise(n_pat = n(),
            n_ae = sum(n_ae)) %>%
  mutate(pct = round(n_pat/N_pat*100,digits = 1),
         txt = paste0("[", n_ae,"] ", n_pat, " (", pct, "%)"),
         arm = paste0("arm", trt)) %>%
  ungroup %>% select(arm, soc, pt, txt) %>%
  pivot_wider(values_from = txt, names_from = arm) %>%
  mutate_at(vars(starts_with("arm")), ~if_else(is.na(.), "", .)) %>%
  arrange(soc, pt) %>%  group_by(soc2 = soc) %>%
  mutate(soc = if_else(row_number() != 1, "", soc)) %>% ungroup() %>% select(-soc2)
}
adae %>%
  bind_rows(adae, .id="added") %>%
  mutate(pt = if_else(added == 2, "#Total", pt)) %>%
  mutate(all = 1) %>%
  ae_table_fns("all") %>%
  knitr::kable(col.names = c("System Organ Class", "Preferred Term", header_ae),
               caption = " Adverse Events by System Organ Class and Preferred term*",
         booktabs = TRUE,
         longtable = TRUE)
## `summarise()` has grouped output by 'subjectid', 'trt', 'N_pat', 'soc'. You can
## override using the `.groups` argument.
## `summarise()` has grouped output by 'trt', 'N_pat', 'soc'. You can override
## using the `.groups` argument.
Table 2: Adverse Events by System Organ Class and Preferred term*
System Organ Class Preferred Term Active (N=81) Control (N=80)
Blood and lymphatic system disorders #Total [1] 1 (1.2%) [2] 2 (2.5%)
Increased tendency to bruise [1] 1 (1.2%)
Neutropenia [1] 1 (1.2%)
Thrombocytopenia [1] 1 (1.2%)
Cardiac disorders #Total [2] 2 (2.5%) [2] 2 (2.5%)
Palpitations [2] 2 (2.5%) [2] 2 (2.5%)
Eye disorders #Total [2] 2 (2.5%) [5] 5 (6.2%)
Dry eye [2] 2 (2.5%)
Eye irritation [2] 2 (2.5%) [1] 1 (1.2%)
Vision blurred [2] 2 (2.5%)
Gastrointestinal disorders #Total [42] 32 (39.5%) [25] 19 (23.8%)
Abdominal discomfort [2] 2 (2.5%) [1] 1 (1.2%)
Abdominal pain [1] 1 (1.2%)
Abdominal pain upper [1] 1 (1.2%) [1] 1 (1.2%)
Angular cheilitis [1] 1 (1.2%)
Constipation [1] 1 (1.2%)
Diarrhoea [3] 3 (3.7%)
Diverticulum intestinal [1] 1 (1.2%)
Dyspepsia [1] 1 (1.2%)
Flatulence [1] 1 (1.2%)
Gastritis [2] 2 (2.5%)
Gastrooesophageal reflux disease [2] 2 (2.5%)
Glossodynia [1] 1 (1.2%)
Lip ulceration [1] 1 (1.2%)
Mouth ulceration [1] 1 (1.2%) [2] 2 (2.5%)
Nausea [25] 22 (27.2%) [11] 10 (12.5%)
Oral mucosal blistering [1] 1 (1.2%) [1] 1 (1.2%)
Paraesthesia oral [1] 1 (1.2%)
Tooth loss [1] 1 (1.2%)
Vomiting [4] 4 (4.9%)
General disorders and administration site conditions #Total [12] 12 (14.8%) [12] 11 (13.8%)
Asthenia [1] 1 (1.2%)
Fatigue [4] 4 (4.9%) [5] 5 (6.2%)
Impaired healing [1] 1 (1.2%)
Influenza like illness [1] 1 (1.2%)
Infusion site swelling [1] 1 (1.2%)
Injection site bruising [1] 1 (1.2%)
Injection site reaction [1] 1 (1.2%)
Malaise [1] 1 (1.2%)
Nodule [1] 1 (1.2%)
Pain [1] 1 (1.2%)
Pyrexia [4] 4 (4.9%) [2] 2 (2.5%)
Immune system disorders #Total [1] 1 (1.2%) [2] 2 (2.5%)
Hypersensitivity [1] 1 (1.2%) [2] 2 (2.5%)
Infections and infestations #Total [19] 18 (22.2%) [38] 31 (38.8%)
Borrelia infection [1] 1 (1.2%)
Bronchitis [1] 1 (1.2%) [1] 1 (1.2%)
Conjunctivitis [1] 1 (1.2%)
Conjunctivitis bacterial [1] 1 (1.2%)
Diverticulitis [1] 1 (1.2%)
Epididymitis [1] 1 (1.2%)
Furuncle [1] 1 (1.2%)
Gastroenteritis [2] 2 (2.5%)
Gastroenteritis viral [1] 1 (1.2%)
Gingival abscess [1] 1 (1.2%)
Herpes virus infection [1] 1 (1.2%)
Infected skin ulcer [1] 1 (1.2%)
Influenza [1] 1 (1.2%) [2] 2 (2.5%)
Localised infection [1] 1 (1.2%) [1] 1 (1.2%)
Nail bed infection [1] 1 (1.2%)
Nasopharyngitis [5] 5 (6.2%) [11] 10 (12.5%)
Oral herpes [1] 1 (1.2%)
Otitis media [1] 1 (1.2%)
Respiratory tract infection [1] 1 (1.2%)
Rhinitis [1] 1 (1.2%)
Sinusitis [1] 1 (1.2%)
Tinea versicolour [1] 1 (1.2%)
Upper respiratory tract infection [4] 4 (4.9%) [8] 8 (10%)
Urinary tract infection [1] 1 (1.2%) [1] 1 (1.2%)
Urinary tract infection bacterial [1] 1 (1.2%)
Injury, poisoning and procedural complications #Total [3] 3 (3.7%) [7] 7 (8.8%)
Arthropod bite [2] 2 (2.5%)
Arthropod sting [1] 1 (1.2%)
Contusion [1] 1 (1.2%)
Incorrect dose administered [1] 1 (1.2%)
Joint dislocation [1] 1 (1.2%)
Limb injury [1] 1 (1.2%)
Road traffic accident [1] 1 (1.2%)
Skin wound [1] 1 (1.2%) [1] 1 (1.2%)
Investigations #Total [21] 17 (21%) [15] 13 (16.2%)
Alanine aminotransferase increased [13] 11 (13.6%) [9] 9 (11.2%)
Biopsy prostate [1] 1 (1.2%)
Blood bilirubin increased [2] 2 (2.5%)
Blood pressure decreased [1] 1 (1.2%)
Blood pressure increased [1] 1 (1.2%)
Blood triglycerides increased [1] 1 (1.2%)
Chest X-ray abnormal [1] 1 (1.2%)
Hepatic enzyme increased [3] 3 (3.7%)
Platelet count decreased [2] 2 (2.5%)
Transaminases increased [1] 1 (1.2%)
Weight increased [1] 1 (1.2%)
Metabolism and nutrition disorders #Total [3] 3 (3.8%)
Decreased appetite [1] 1 (1.2%)
Hyperlipidaemia [1] 1 (1.2%)
Hypertriglyceridaemia [1] 1 (1.2%)
Musculoskeletal and connective tissue disorders #Total [7] 7 (8.6%) [6] 6 (7.5%)
Arthralgia [1] 1 (1.2%)
Back pain [1] 1 (1.2%)
Groin pain [1] 1 (1.2%)
Intervertebral disc protrusion [1] 1 (1.2%)
Musculoskeletal pain [1] 1 (1.2%) [1] 1 (1.2%)
Neck pain [2] 2 (2.5%)
Pain in extremity [2] 2 (2.5%) [1] 1 (1.2%)
Rheumatoid arthritis [1] 1 (1.2%)
Rotator cuff syndrome [1] 1 (1.2%)
Neoplasms benign, malignant and unspecified (incl cysts and polyps) #Total [1] 1 (1.2%)
Malignant melanoma [1] 1 (1.2%)
Nervous system disorders #Total [8] 8 (9.9%) [17] 15 (18.8%)
Anosmia [1] 1 (1.2%)
Dementia [1] 1 (1.2%)
Dizziness [2] 2 (2.5%) [6] 5 (6.2%)
Dysgeusia [1] 1 (1.2%) [1] 1 (1.2%)
Headache [2] 2 (2.5%) [4] 4 (5%)
Hypoaesthesia [2] 2 (2.5%)
Muscle contractions involuntary [1] 1 (1.2%)
Paraesthesia [2] 2 (2.5%)
Taste disorder [1] 1 (1.2%)
Tension headache [1] 1 (1.2%)
Psychiatric disorders #Total [2] 2 (2.5%) [2] 2 (2.5%)
Anxiety [1] 1 (1.2%)
Insomnia [2] 2 (2.5%)
Terminal insomnia [1] 1 (1.2%)
Renal and urinary disorders #Total [1] 1 (1.2%) [1] 1 (1.2%)
Dysuria [1] 1 (1.2%)
Renal mass [1] 1 (1.2%)
Reproductive system and breast disorders #Total [1] 1 (1.2%) [1] 1 (1.2%)
Hypomenorrhoea [1] 1 (1.2%)
Uterine polyp [1] 1 (1.2%)
Respiratory, thoracic and mediastinal disorders #Total [8] 8 (9.9%) [7] 7 (8.8%)
Cough [3] 3 (3.7%) [2] 2 (2.5%)
Dysphonia [1] 1 (1.2%)
Dyspnoea [1] 1 (1.2%) [1] 1 (1.2%)
Epistaxis [2] 2 (2.5%)
Interstitial lung disease [1] 1 (1.2%)
Nasal discomfort [1] 1 (1.2%)
Oropharyngeal pain [2] 2 (2.5%)
Throat tightness [1] 1 (1.2%)
Skin and subcutaneous tissue disorders #Total [14] 12 (14.8%) [17] 17 (21.2%)
Acne [1] 1 (1.2%)
Alopecia [4] 4 (4.9%) [2] 2 (2.5%)
Blister [4] 3 (3.7%) [1] 1 (1.2%)
Erythema [1] 1 (1.2%)
Hyperhidrosis [1] 1 (1.2%)
Night sweats [1] 1 (1.2%)
Pain of skin [1] 1 (1.2%)
Pruritus [2] 2 (2.5%)
Purpura [1] 1 (1.2%)
Rash [2] 2 (2.5%) [6] 6 (7.5%)
Rash erythematous [1] 1 (1.2%)
Urticaria [1] 1 (1.2%) [2] 2 (2.5%)
Social circumstances #Total [1] 1 (1.2%)
Stress at work [1] 1 (1.2%)
Surgical and medical procedures #Total [2] 2 (2.5%) [1] 1 (1.2%)
Parathyroidectomy [1] 1 (1.2%)
Rehabilitation therapy [1] 1 (1.2%)
Rheumatoid nodule removal [1] 1 (1.2%)
Vascular disorders #Total [2] 2 (2.5%) [1] 1 (1.2%)
Hypertension [2] 2 (2.5%) [1] 1 (1.2%)
NA #Total [12] 12 (14.8%) [7] 6 (7.5%)

Serious Adverse Events by System Organ Class and Preferred term

Then the sub-table with only the serious adverse events.

adae %>%
  bind_rows(adae, .id="added") %>%
  mutate(pt = if_else(added == 2, "#Total", pt)) %>%
  ae_table_fns("sae") %>%
  knitr::kable( col.names = c("System Organ Class", "Preferred Term", header_ae),
               caption = "Serious Adverse Events by System Organ Class and Preferred term*",
         booktabs = TRUE,
         longtable = TRUE)
## `summarise()` has grouped output by 'subjectid', 'trt', 'N_pat', 'soc'. You can
## override using the `.groups` argument.
## `summarise()` has grouped output by 'trt', 'N_pat', 'soc'. You can override
## using the `.groups` argument.
Table 3: Serious Adverse Events by System Organ Class and Preferred term*
System Organ Class Preferred Term Active (N=81) Control (N=80)
General disorders and administration site conditions #Total [1] 1 (1.2%)
Pyrexia [1] 1 (1.2%)
Infections and infestations #Total [1] 1 (1.2%)
Epididymitis [1] 1 (1.2%)
Neoplasms benign, malignant and unspecified (incl cysts and polyps) #Total [1] 1 (1.2%)
Malignant melanoma [1] 1 (1.2%)
Reproductive system and breast disorders #Total [1] 1 (1.2%)
Uterine polyp [1] 1 (1.2%)
Surgical and medical procedures #Total [2] 2 (2.5%)
Parathyroidectomy [1] 1 (1.2%)
Rehabilitation therapy [1] 1 (1.2%)

Usually there is also a table of probable/possible study treatment related AE/SAEs, and maybe also a AE/SAE of special interest table. They are made similarly to the SAE table.

Most common Adverse events

Lastly a table of the most common Adverse events. It is easy to change the treshold.

adae %>%
  group_by(trt) %>%
  mutate(N_pat = n_distinct(subjectid)) %>%
  group_by(subjectid, trt, N_pat, soc, pt) %>%
  summarise(n_ae = n()) %>%
  group_by(trt, N_pat, soc, pt) %>%
  summarise(n_pat = n(),
            n_ae = sum(n_ae)) %>%
  mutate(pct = round(n_pat/N_pat*100,digits = 1),
         txt = paste0("[", n_ae,"] ", n_pat, " (", pct, "%)"),
         arm = paste0("arm", trt)) %>%
  group_by(pt) %>%
  mutate(N_pat = sum(n_pat),
         pct_tot = N_pat /total_n) %>%
  filter(pct_tot>0.05) %>%
  ungroup %>%
  select(arm, soc, pt, txt, pct_tot) %>%
  pivot_wider(values_from = txt, names_from = arm) %>%
  mutate_at(vars(starts_with("arm")), ~if_else(is.na(.), "", .)) %>%
  arrange(desc(pct_tot)) %>% select( pt, everything()) %>% select(-pct_tot, -soc)  %>%
  knitr::kable( col.names = c( "Preferred Term", header_ae),
               caption = "Most common Adverse Events (more than 5 percent) by Preferred Term",
         booktabs = TRUE,
         longtable = TRUE)
## `summarise()` has grouped output by 'subjectid', 'trt', 'N_pat', 'soc'. You can
## override using the `.groups` argument.
## `summarise()` has grouped output by 'trt', 'N_pat', 'soc'. You can override
## using the `.groups` argument.
Table 4: Most common Adverse Events (more than 5 percent) by Preferred Term
Preferred Term Active (N=81) Control (N=80)
Nausea [25] 22 (27.2%) [11] 10 (12.5%)
Alanine aminotransferase increased [13] 11 (13.6%) [9] 9 (11.2%)
NA [12] 12 (14.8%) [7] 6 (7.5%)
Nasopharyngitis [5] 5 (6.2%) [11] 10 (12.5%)
Upper respiratory tract infection [4] 4 (4.9%) [8] 8 (10%)
Fatigue [4] 4 (4.9%) [5] 5 (6.2%)
Rash [2] 2 (2.5%) [6] 6 (7.5%)
Dizziness [2] 2 (2.5%) [6] 5 (6.2%)
Pyrexia [4] 4 (4.9%) [2] 2 (2.5%)
Headache [2] 2 (2.5%) [4] 4 (5%)
Alopecia [4] 4 (4.9%) [2] 2 (2.5%)
Statistician

My research interests include statistical methods for clinical trials