Skip to contents

Introduction

The adverse events (AE) table is the most common safety display in a clinical study report, and it exercises more of tplyr2 than almost any other single table: hierarchical (system-organ-class by preferred-term) counts, subject-level incidence, population-based denominators, an overall summary row, sorting, and often a between-arm comparison. Those pieces are documented individually across several vignettes; this one puts them together and builds a complete AE table step by step.

We use the bundled tplyr_adae (one row per adverse event) and tplyr_adsl (one row per subject) datasets, which follow the CDISC ADaM structure.

The Population Principle

The single most important thing to get right in an AE table is the denominator. tplyr_adae contains only subjects who experienced at least one event; if you count it on its own, every percentage is computed against “subjects who had an AE” instead of the number of subjects at risk, and the incidences come out far too high.

The denominator must come from the population dataset – here ADSL, the safety population. You wire it up in two places: a pop_data() mapping in the spec, and the population data frame supplied at build time. Throughout this vignette the spec carries

pop_data = pop_data(cols = c("TRTA" = "TRT01A"))

which maps the treatment variable in the analysis data (TRTA, actual treatment) to its counterpart in the population data (TRT01A). We also restrict the analysis data to treatment-emergent events with a table-level where, while the denominators continue to come from the full population. (See vignette("count") and vignette("denom") for the underlying mechanics.)

Step 1: Incidence by System Organ Class and Preferred Term

An AE table is a nested count: system organ class (AEBODSYS) as the outer level, preferred term (AEDECOD) as the inner level. Passing a two-element vector to group_count() produces the hierarchy, and distinct_by = "USUBJID" counts each subject once (a subject with three headaches counts once for HEADACHE).

ae_settings <- layer_settings(
  distinct_by = "USUBJID",
  format_strings = list(
    n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
  )
)

spec <- tplyr_spec(
  cols = "TRTA",
  where = TRTEMFL == "Y",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count(c("AEBODSYS", "AEDECOD"), settings = ae_settings)
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
kable(head(result[, c("rowlabel1", "rowlabel2", "res1", "res2", "res3")], 12))
rowlabel1 rowlabel2 res1 res2 res3
CARDIAC DISORDERS 4 ( 4.7%) 6 ( 7.1%) 5 ( 6.0%)
CARDIAC DISORDERS ATRIAL FIBRILLATION 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%)
CARDIAC DISORDERS ATRIAL FLUTTER 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%)
CARDIAC DISORDERS ATRIAL HYPERTROPHY 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%)
CARDIAC DISORDERS BUNDLE BRANCH BLOCK RIGHT 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%)
CARDIAC DISORDERS CARDIAC FAILURE CONGESTIVE 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%)
CARDIAC DISORDERS MYOCARDIAL INFARCTION 0 ( 0.0%) 1 ( 1.2%) 2 ( 2.4%)
CARDIAC DISORDERS SINUS BRADYCARDIA 0 ( 0.0%) 3 ( 3.6%) 1 ( 1.2%)
CARDIAC DISORDERS SUPRAVENTRICULAR EXTRASYSTOLES 1 ( 1.2%) 0 ( 0.0%) 1 ( 1.2%)
CARDIAC DISORDERS SUPRAVENTRICULAR TACHYCARDIA 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%)
CARDIAC DISORDERS TACHYCARDIA 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%)
CARDIAC DISORDERS VENTRICULAR EXTRASYSTOLES 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%)

Outer (system organ class) rows carry an empty rowlabel2 and count the distinct subjects with any event in that class; inner (preferred term) rows carry the term in rowlabel2. Because the denominators come from ADSL, the column N is the full arm size – confirm it with tplyr_header_n():

kable(tplyr_header_n(result))
TRTA .n
Placebo 86
Xanomeline High Dose 84
Xanomeline Low Dose 84

Step 2: “Any Adverse Event” and Subjects With No Events

Two summary rows round out the table. total_row = TRUE adds an overall row – the number of subjects with any adverse event – and missing_subjects = TRUE adds a row for subjects who are in the population but have no records in the analysis data (a large share of the safety population for most studies). Both depend on population data being present.

ae_settings <- layer_settings(
  distinct_by = "USUBJID",
  total_row = TRUE,
  total_row_label = "Any adverse event",
  missing_subjects = TRUE,
  missing_subjects_label = "No adverse events",
  format_strings = list(
    n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
  )
)

spec <- tplyr_spec(
  cols = "TRTA",
  where = TRTEMFL == "Y",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count(c("AEBODSYS", "AEDECOD"), settings = ae_settings)
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)

# The "Any adverse event" and "No adverse events" summary rows
summary_rows <- result[result$rowlabel1 %in% c("Any adverse event", "No adverse events"), ]
kable(summary_rows[, c("rowlabel1", "res1", "res2", "res3")])
rowlabel1 res1 res2 res3
102 Any adverse event 32 (37.2%) 41 (48.8%) 49 (58.3%)

Step 3: A Between-Arm Comparison

Reviewers frequently want a comparison against the control arm on every row. assoc_test() in its pairwise mode does this: supply a reference arm and the comparisons to make, and a function that takes a 2x2 incidence matrix and returns a p-value. It emits one pval column per comparison, with a value on every row of the table – each preferred term, each system-organ-class subtotal, and (unless you turn it off) the “Any adverse event” total row. The 2x2 uses each row’s distinct counts and the population denominators, so it is consistent with the incidences on display.

ae_settings <- layer_settings(
  distinct_by = "USUBJID",
  total_row = TRUE,
  total_row_label = "Any adverse event",
  format_strings = list(
    n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
  ),
  assoc_test = assoc_test(
    fn = function(m) suppressWarnings(fisher.test(m)$p.value),
    reference   = "Placebo",
    comparisons = c("Xanomeline High Dose", "Xanomeline Low Dose"),
    format      = f_str("x.xxx", "p")
  )
)

spec <- tplyr_spec(
  cols = "TRTA",
  where = TRTEMFL == "Y",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count(c("AEBODSYS", "AEDECOD"), settings = ae_settings)
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
kable(head(result[, c("rowlabel1", "rowlabel2", "res1", "res2", "res3",
                      "pval1", "pval2")], 12))
rowlabel1 rowlabel2 res1 res2 res3 pval1 pval2
CARDIAC DISORDERS 4 ( 4.7%) 6 ( 7.1%) 5 ( 6.0%) 0.533 0.745
CARDIAC DISORDERS ATRIAL FIBRILLATION 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%) 1.000 0.494
CARDIAC DISORDERS ATRIAL FLUTTER 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%) 0.494 1.000
CARDIAC DISORDERS ATRIAL HYPERTROPHY 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
CARDIAC DISORDERS BUNDLE BRANCH BLOCK RIGHT 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
CARDIAC DISORDERS CARDIAC FAILURE CONGESTIVE 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
CARDIAC DISORDERS MYOCARDIAL INFARCTION 0 ( 0.0%) 1 ( 1.2%) 2 ( 2.4%) 0.494 0.243
CARDIAC DISORDERS SINUS BRADYCARDIA 0 ( 0.0%) 3 ( 3.6%) 1 ( 1.2%) 0.118 0.494
CARDIAC DISORDERS SUPRAVENTRICULAR EXTRASYSTOLES 1 ( 1.2%) 0 ( 0.0%) 1 ( 1.2%) 1.000 1.000
CARDIAC DISORDERS SUPRAVENTRICULAR TACHYCARDIA 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%) 1.000 0.494
CARDIAC DISORDERS TACHYCARDIA 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
CARDIAC DISORDERS VENTRICULAR EXTRASYSTOLES 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%) 0.494 1.000

The two pval columns compare each Xanomeline arm to Placebo. assoc_test() is fully general – any test that takes a 2x2 matrix plugs in, and the function can even return a finished display string (a significance flag, a >.99 ceiling) instead of a number. vignette("binding-statistics") covers the full contract, including nested layers, the total-row toggle, and character returns.

Risk difference vs. association test on a nested layer. risk_diff (see vignette("riskdiff")) is the tool for a single-level count layer – it emits a risk difference with a confidence interval. For the nested SOC/PT layout above, use pairwise assoc_test() as shown here.

Step 4: A “Most Frequent Events” Variant, With Risk Difference

A common companion display drops the body-system hierarchy and lists preferred terms overall, sorted by descending frequency, often with a risk difference. This is a single-level count layer, which is where risk_diff applies (on a nested layer, use the pairwise assoc_test() from Step 3).

spec <- tplyr_spec(
  cols = "TRTA",
  where = TRTEMFL == "Y",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count("AEDECOD",
      settings = layer_settings(
        distinct_by = "USUBJID",
        order_count_method = "bycount",
        result_order_var = "distinct_n",
        format_strings = list(
          n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
        ),
        risk_diff = list(
          comparisons = list(c("Xanomeline High Dose", "Placebo")),
          format = f_str("xx.x (xx.x, xx.x)", "rdiff", "lower", "upper")
        )
      )
    )
  )
)

result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
result <- result[order(result$ord_layer_1), ]

kable(head(result[, c("rowlabel1", "res1", "res2", "res3", "rdiff1")], 10))
rowlabel1 res1 res2 res3 rdiff1
PRURITUS 3 ( 3.5%) 8 ( 9.5%) 6 ( 7.1%) 6.0 (-1.3, 13.4)
APPLICATION SITE PRURITUS 4 ( 4.7%) 6 ( 7.1%) 4 ( 4.8%) 3.7 (-3.7, 11.1)
ERYTHEMA 3 ( 3.5%) 3 ( 3.6%) 2 ( 2.4%) -1.1 (-7.0, 4.9)
APPLICATION SITE ERYTHEMA 0 ( 0.0%) 3 ( 3.6%) 4 ( 4.8%) 3.6 (-0.4, 7.5)
APPLICATION SITE DERMATITIS 1 ( 1.2%) 3 ( 3.6%) 2 ( 2.4%) 2.4 (-2.2, 7.0)
APPLICATION SITE IRRITATION 1 ( 1.2%) 3 ( 3.6%) 2 ( 2.4%) 3.6 (-1.5, 8.7)
UPPER RESPIRATORY TRACT INFECTION 4 ( 4.7%) 1 ( 1.2%) 1 ( 1.2%) -3.5 (-8.5, 1.6)
DIARRHOEA 3 ( 3.5%) 1 ( 1.2%) 1 ( 1.2%) -2.3 (-6.8, 2.2)
RASH 0 ( 0.0%) 2 ( 2.4%) 3 ( 3.6%) 2.4 (-0.9, 5.6)
DIZZINESS 0 ( 0.0%) 1 ( 1.2%) 3 ( 3.6%) 1.2 (-1.1, 3.5)

order_count_method = "bycount" with result_order_var = "distinct_n" sorts the preferred terms by the number of distinct subjects affected, descending, so the most frequent events lead. The rdiff1 column is the High Dose minus Placebo incidence difference with its 95% confidence interval. (risk_diff applies to single-level count layers; on the nested layer of Step 3 use the pairwise assoc_test() shown there.)

Step 5: A Display-Ready Table

The final step turns the machine-friendly build into something a renderer can consume. as_display() trims the frame to just the display columns (rowlabel*, res*, pval*), dropping the internal ord_* columns and, with labels = TRUE, renaming the result columns to their (N=) header labels. collapse_row_labels() then merges the two row-label columns into a single indented SOC/PT stub. Applying as_display() first keeps the header labels while leaving the rowlabel* columns for collapse_row_labels() to fold together.

# Rebuild the Step 3 table (nested + Fisher p-values)
result <- tplyr_build(tplyr_spec(
  cols = "TRTA",
  where = TRTEMFL == "Y",
  pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
  layers = tplyr_layers(
    group_count(c("AEBODSYS", "AEDECOD"), settings = ae_settings)
  )
), tplyr_adae, pop_data = tplyr_adsl)

display <- result |>
  as_display(labels = TRUE) |>
  collapse_row_labels("rowlabel1", "rowlabel2", indent = "   ")

kable(head(display, 14))
row_label Placebo (N=86) Xanomeline High Dose (N=84) Xanomeline Low Dose (N=84) Placebo vs Xanomeline High Dose Placebo vs Xanomeline Low Dose
CARDIAC DISORDERS
4 ( 4.7%) 6 ( 7.1%) 5 ( 6.0%) 0.533 0.745
ATRIAL FIBRILLATION 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%) 1.000 0.494
ATRIAL FLUTTER 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%) 0.494 1.000
ATRIAL HYPERTROPHY 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
BUNDLE BRANCH BLOCK RIGHT 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
CARDIAC FAILURE CONGESTIVE 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
MYOCARDIAL INFARCTION 0 ( 0.0%) 1 ( 1.2%) 2 ( 2.4%) 0.494 0.243
SINUS BRADYCARDIA 0 ( 0.0%) 3 ( 3.6%) 1 ( 1.2%) 0.118 0.494
SUPRAVENTRICULAR EXTRASYSTOLES 1 ( 1.2%) 0 ( 0.0%) 1 ( 1.2%) 1.000 1.000
SUPRAVENTRICULAR TACHYCARDIA 0 ( 0.0%) 0 ( 0.0%) 1 ( 1.2%) 1.000 0.494
TACHYCARDIA 1 ( 1.2%) 0 ( 0.0%) 0 ( 0.0%) 1.000 1.000
VENTRICULAR EXTRASYSTOLES 0 ( 0.0%) 1 ( 1.2%) 0 ( 0.0%) 0.494 1.000
CONGENITAL, FAMILIAL AND GENETIC DISORDERS

The result reads like the final table shell: an indented SOC/PT stub column, one incidence column per arm carrying its (N=) header, and the two comparison p-value columns – built entirely within tplyr2, with the safety-population denominators applied throughout.

See Also