Building an Adverse Events Table
adverse-events.RmdIntroduction
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
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(seevignette("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 pairwiseassoc_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
-
vignette("count")– count-layer fundamentals, population data, nested counts, and stat columns. -
vignette("denom")– denominator control in depth, including single-proportion confidence intervals and the “no events reported” row. -
vignette("riskdiff")– risk differences on single-level count layers. -
vignette("binding-statistics")– the fullassoc_test()contract (omnibus, pairwise, nested, character returns) and binding externally computed model results. -
vignette("sort")– ordering rows by frequency, factor levels, or a VARN companion. -
vignette("post_processing")–collapse_row_labels(),as_display(), and other display helpers.