3  Building an Adverse Event Table

Adverse Event (AE) tables present the safety profile of investigational treatments by summarizing the adverse events experienced by participants during a clinical trial.

A well-constructed AE table typically presents multiple levels of aggregation simultaneously:

This hierarchical presentation allows clinicians and regulators to quickly assess both the overall safety profile and drill down into specific types of adverse events.

3.1 Data Pre-processing

The first step in creating an AE table is to prepare our data sources. We’ll work with two standard CDISC datasets (available from package ‘pharmaverseadam’):

  • adsl: Subject-Level Analysis Dataset containing demographic and disposition information
  • adae: Adverse Events Analysis Dataset containing all reported adverse events
Code
# Load example datasets
adsl <- pharmaverseadam::adsl
adae <- pharmaverseadam::adae

adae <- adae |>
  dplyr::filter(
    # safety population
    SAFFL == "Y"
  )
adae
# A tibble: 1,191 × 107
   STUDYID DOMAIN USUBJID AESEQ AESPID AETERM AELLT AELLTCD AEDECOD AEPTCD AEHLT
   <chr>   <chr>  <chr>   <dbl> <chr>  <chr>  <chr>   <dbl> <chr>    <dbl> <chr>
 1 CDISCP… AE     01-701…     1 E07    APPLI… APPL…      NA APPLIC…     NA HLT_…
 2 CDISCP… AE     01-701…     2 E08    APPLI… APPL…      NA APPLIC…     NA HLT_…
 3 CDISCP… AE     01-701…     3 E06    DIARR… DIAR…      NA DIARRH…     NA HLT_…
 4 CDISCP… AE     01-701…     2 E09    ERYTH… LOCA…      NA ERYTHE…     NA HLT_…
 5 CDISCP… AE     01-701…     1 E08    ERYTH… ERYT…      NA ERYTHE…     NA HLT_…
 6 CDISCP… AE     01-701…     4 E08    ERYTH… ERYT…      NA ERYTHE…     NA HLT_…
 7 CDISCP… AE     01-701…     3 E10    ATRIO… AV B…      NA ATRIOV…     NA HLT_…
 8 CDISCP… AE     01-701…     1 E04    APPLI… APPL…      NA APPLIC…     NA HLT_…
 9 CDISCP… AE     01-701…     2 E05    APPLI… APPL…      NA APPLIC…     NA HLT_…
10 CDISCP… AE     01-701…     1 E08    APPLI… APPL…      NA APPLIC…     NA HLT_…
# ℹ 1,181 more rows
# ℹ 96 more variables: AEHLTCD <dbl>, AEHLGT <chr>, AEHLGTCD <dbl>,
#   AEBODSYS <chr>, AEBDSYCD <dbl>, AESOC <chr>, AESOCCD <dbl>, AESEV <chr>,
#   AESER <chr>, AEACN <chr>, AEREL <chr>, AEOUT <chr>, AESCAN <chr>,
#   AESCONG <chr>, AESDISAB <chr>, AESDTH <chr>, AESHOSP <chr>, AESLIFE <chr>,
#   AESOD <chr>, AEDTC <chr>, AESTDTC <chr>, AEENDTC <chr>, AESTDY <dbl>,
#   AEENDY <dbl>, TRTSDT <date>, TRTEDT <date>, DTHDT <date>, EOSDT <date>, …
Why filter by SAFFL == Y?

In clinical trials, the safety population typically includes all subjects who received at least one dose of study medication. The Safety Flag (SAFFL) identifies these subjects, ensuring we analyze only relevant safety data.

3.1.1 Calculating Treatment Group Denominators

For percentage calculations, we need to know the total number of subjects in each treatment arm. This denominator is crucial for interpreting adverse event rates.

Code
CT_ARM <- adsl |>
  semi_join(adae, by = "ARM") |>
  count(ARM, name = "denom")
CT_ARM
# A tibble: 3 × 2
  ARM                  denom
  <chr>                <int>
1 Placebo                 86
2 Xanomeline High Dose    84
3 Xanomeline Low Dose     84

The semi_join() ensures we count only subjects who are in both datasets and meet our safety population criteria.

3.2 Building a Hierarchical Structure

The key to creating a professional AE table is to construct multiple levels of aggregation and then stack them together into a single dataset. This technique, often called stacking or layering, allows us to present summary statistics at different hierarchical levels within one cohesive table.

We’ll build three separate aggregation levels, each progressively more detailed:

3.2.1 Level 3: Preferred Term (Most Detailed)

The finest level of detail shows individual adverse event terms (Preferred Terms or PTs) within each System Organ Class. This is where we see specific conditions like “Headache” or “Nausea”.

Code
AE_TABLE_LEV2 <- adae |>
  group_by(ARM, AESOC, AEDECOD) |>
  summarise(
    n = n_distinct(USUBJID),
    .groups = "drop"
  ) |>
  arrange(AESOC, AEDECOD, ARM) |>
  tibble::add_column(agg_level = 2L)
AE_TABLE_LEV2
# A tibble: 373 × 5
   ARM                  AESOC             AEDECOD                    n agg_level
   <chr>                <chr>             <chr>                  <int>     <int>
 1 Placebo              CARDIAC DISORDERS ATRIAL FIBRILLATION        1         2
 2 Xanomeline High Dose CARDIAC DISORDERS ATRIAL FIBRILLATION        3         2
 3 Xanomeline Low Dose  CARDIAC DISORDERS ATRIAL FIBRILLATION        1         2
 4 Xanomeline High Dose CARDIAC DISORDERS ATRIAL FLUTTER             1         2
 5 Xanomeline Low Dose  CARDIAC DISORDERS ATRIAL FLUTTER             1         2
 6 Placebo              CARDIAC DISORDERS ATRIAL HYPERTROPHY         1         2
 7 Placebo              CARDIAC DISORDERS ATRIOVENTRICULAR BLOC…     1         2
 8 Xanomeline Low Dose  CARDIAC DISORDERS ATRIOVENTRICULAR BLOC…     1         2
 9 Placebo              CARDIAC DISORDERS ATRIOVENTRICULAR BLOC…     2         2
10 Xanomeline High Dose CARDIAC DISORDERS ATRIOVENTRICULAR BLOC…     3         2
# ℹ 363 more rows
Note
  • We use n_distinct(USUBJID) to count unique subjects, not total events (a subject experiencing the same AE multiple times counts once)
  • The agg_level = 2L marker identifies this as the most detailed level
  • We group by ARM (treatment), AESOC (System Organ Class), and AEDECOD (Preferred Term)

3.2.2 Level 2: System Organ Class (Intermediate)

The middle level aggregates all events within each body system category (e.g., “Gastrointestinal disorders”, “Nervous system disorders”). It provides a summary view of which body systems are most affected.

Code
AE_TABLE_LEV1 <- adae |>
  group_by(ARM, AESOC) |>
  summarise(
    n = n_distinct(USUBJID),
    .groups = "drop"
  ) |>
  arrange(AESOC, ARM) |>
  tibble::add_column(agg_level = 1L)
AE_TABLE_LEV1
# A tibble: 61 × 4
   ARM                  AESOC                                        n agg_level
   <chr>                <chr>                                    <int>     <int>
 1 Placebo              CARDIAC DISORDERS                           13         1
 2 Xanomeline High Dose CARDIAC DISORDERS                           18         1
 3 Xanomeline Low Dose  CARDIAC DISORDERS                           13         1
 4 Xanomeline High Dose CONGENITAL, FAMILIAL AND GENETIC DISORD…     2         1
 5 Xanomeline Low Dose  CONGENITAL, FAMILIAL AND GENETIC DISORD…     1         1
 6 Placebo              EAR AND LABYRINTH DISORDERS                  1         1
 7 Xanomeline High Dose EAR AND LABYRINTH DISORDERS                  1         1
 8 Xanomeline Low Dose  EAR AND LABYRINTH DISORDERS                  2         1
 9 Placebo              EYE DISORDERS                                4         1
10 Xanomeline High Dose EYE DISORDERS                                1         1
# ℹ 51 more rows

3.2.3 Level 1: Overall Summary (Highest Level)

The top level shows the broadest view: how many subjects experienced any adverse event at all, regardless of type.

Code
AE_TABLE_LEV0 <- adae |>
  group_by(ARM) |>
  summarise(
    n = n_distinct(USUBJID)
  ) |>
  arrange(ARM) |>
  tibble::add_column(agg_level = 0L, AESOC = "ANY ADVERSE EVENTS")
AE_TABLE_LEV0
# A tibble: 3 × 4
  ARM                      n agg_level AESOC             
  <chr>                <int>     <int> <chr>             
1 Placebo                 69         0 ANY ADVERSE EVENTS
2 Xanomeline High Dose    79         0 ANY ADVERSE EVENTS
3 Xanomeline Low Dose     77         0 ANY ADVERSE EVENTS

3.2.4 Stacking the Layers: Combining All Levels

Now comes the crucial step: we stack (vertically combine) all three aggregation levels into a single dataset. This stacking technique creates a hierarchical structure that will translate directly into the visual hierarchy of our final table.

Code
AE_TABLE <- bind_rows(AE_TABLE_LEV0, AE_TABLE_LEV1, AE_TABLE_LEV2) |>
  left_join(CT_ARM, by = "ARM") |>
  mutate(
    pct = n / denom,
    denom = NULL,
    first_level = is.na(AEDECOD)
  ) |>
  arrange(AESOC, agg_level, first_level, AEDECOD, ARM) |>
  mutate(
    LABEL = coalesce(AEDECOD, AESOC),
    LABEL = factor(LABEL, levels = unique(LABEL)),
    stat_str = fmt_n_percent(n, pct, digit = 1)
  )
AE_TABLE
# A tibble: 437 × 9
   ARM               n agg_level AESOC AEDECOD    pct first_level LABEL stat_str
   <chr>         <int>     <int> <chr> <chr>    <dbl> <lgl>       <fct> <chr>   
 1 Placebo          69         0 ANY … <NA>    0.802  TRUE        ANY … 69 (80.…
 2 Xanomeline H…    79         0 ANY … <NA>    0.940  TRUE        ANY … 79 (94.…
 3 Xanomeline L…    77         0 ANY … <NA>    0.917  TRUE        ANY … 77 (91.…
 4 Placebo          13         1 CARD… <NA>    0.151  TRUE        CARD… 13 (15.…
 5 Xanomeline H…    18         1 CARD… <NA>    0.214  TRUE        CARD… 18 (21.…
 6 Xanomeline L…    13         1 CARD… <NA>    0.155  TRUE        CARD… 13 (15.…
 7 Placebo           1         2 CARD… ATRIAL… 0.0116 FALSE       ATRI… 1 (1.2%)
 8 Xanomeline H…     3         2 CARD… ATRIAL… 0.0357 FALSE       ATRI… 3 (3.6%)
 9 Xanomeline L…     1         2 CARD… ATRIAL… 0.0119 FALSE       ATRI… 1 (1.2%)
10 Xanomeline H…     1         2 CARD… ATRIAL… 0.0119 FALSE       ATRI… 1 (1.2%)
# ℹ 427 more rows

By stacking these levels together, we’ve created a single dataset that contains all the information needed for our table, with a structure that naturally reflects the hierarchical organization we want to display. Each row knows its level in the hierarchy (agg_level) and can be formatted appropriately.

Note
  1. bind_rows(AE_TABLE_LEV0, AE_TABLE_LEV1, AE_TABLE_LEV2): Vertically stacks all three levels, preserving the structure of each. Rows from AE_TABLE_LEV0 appear first, followed by AE_TABLE_LEV1, then AE_TABLE_LEV2.

  2. left_join(CT_ARM, by = "ARM"): Adds the denominator for each treatment arm, enabling percentage calculations.

  3. mutate(pct = n / denom): Calculates the percentage of subjects experiencing each event. This is critical for clinical interpretation—raw counts alone can be misleading if treatment groups have different sizes.

  4. first_level = is.na(AEDECOD): Creates a flag to identify SOC-level rows (which lack a Preferred Term). This will help with formatting later.

  5. arrange(AESOC, agg_level, first_level, AEDECOD, ARM): Carefully orders the data so that:

    • Events are grouped by System Organ Class
    • Within each SOC, the summary appears first (lower agg_level)
    • Then individual Preferred Terms follow
    • Treatment arms stay together for each term
  6. LABEL = coalesce(AEDECOD, AESOC): Creates a display label that shows the Preferred Term when available, otherwise shows the System Organ Class. This unified label column simplifies table creation.

  7. stat_str = fmt_n_percent(n, pct, digit = 1): Formats the count and percentage into a standard clinical reporting format (e.g., “15 (23.4%)”).

3.3 Creating the Flextable

Now that we have our stacked hierarchical dataset, we need to transform it into a presentation-ready table. This involves two key steps: reshaping the data from long to wide format, and then applying sophisticated formatting with flextable.

3.3.1 Pivoting from Long to Wide Format

Our current dataset has one row per treatment arm per adverse event, which is ideal for data manipulation but not for presentation. We need to pivot it so that treatment arms become columns, creating the familiar clinical trial table layout.

For this example, we’ll work with a subset of the data to keep things manageable during the workshop:

Code
AE_TABLE_SAMPLE <- AE_TABLE |>
  filter(
    AESOC %in% c(
      "ANY ADVERSE EVENTS",
      "CARDIAC DISORDERS",
      "GASTROINTESTINAL DISORDERS"
    )
  )

x <- select(AE_TABLE_SAMPLE, LABEL, AESOC, AEDECOD, ARM, agg_level, stat_str) |>
  tidyr::pivot_wider(
    names_from = "ARM",
    values_from = c("stat_str"),
    values_fill = "0 (0%)"
  )
x
# A tibble: 39 × 7
   LABEL                  AESOC AEDECOD agg_level Placebo `Xanomeline High Dose`
   <fct>                  <chr> <chr>       <int> <chr>   <chr>                 
 1 ANY ADVERSE EVENTS     ANY … <NA>            0 69 (80… 79 (94.0%)            
 2 CARDIAC DISORDERS      CARD… <NA>            1 13 (15… 18 (21.4%)            
 3 ATRIAL FIBRILLATION    CARD… ATRIAL…         2 1 (1.2… 3 (3.6%)              
 4 ATRIAL FLUTTER         CARD… ATRIAL…         2 0 (0%)  1 (1.2%)              
 5 ATRIAL HYPERTROPHY     CARD… ATRIAL…         2 1 (1.2… 0 (0%)                
 6 ATRIOVENTRICULAR BLOC… CARD… ATRIOV…         2 1 (1.2… 0 (0%)                
 7 ATRIOVENTRICULAR BLOC… CARD… ATRIOV…         2 2 (2.3… 3 (3.6%)              
 8 BRADYCARDIA            CARD… BRADYC…         2 1 (1.2… 0 (0%)                
 9 BUNDLE BRANCH BLOCK L… CARD… BUNDLE…         2 1 (1.2… 0 (0%)                
10 BUNDLE BRANCH BLOCK R… CARD… BUNDLE…         2 1 (1.2… 0 (0%)                
# ℹ 29 more rows
# ℹ 1 more variable: `Xanomeline Low Dose` <chr>

Understanding the pivot:

  • names_from = "ARM": Treatment arms become column names
  • values_from = "stat_str": The formatted statistics (n, %) populate the cells
  • values_fill = "0 (0%)": When no subjects experienced a particular AE in a treatment arm, we display “0 (0%)” rather than leaving the cell empty

Notice that we keep AESOC, AEDECOD, and agg_level in the dataset even though they’re not displayed columns. These metadata columns will help us apply conditional formatting later.

3.3.2 Setting Global Formatting Standards

Before we create the table, let’s establish formatting defaults that align with our standards:

Code
set_flextable_defaults(
  font.family = "Arial",
  font.size = 11,
  padding = 5,
  table.layout = "autofit"
)

3.3.3 Building the Base Table with Visual Hierarchy

The first step in creating our flextable is to establish the basic structure and create visual hierarchy through indentation:

Code
ft <- x |>
  flextable(col_keys = c("LABEL", CT_ARM$ARM)) |>
  # Indent sub-level items
    prepend_chunks(
      j = "LABEL",
      i = ~ agg_level == 2,
      as_chunk("\t")
    ) |> 
  add_header_lines("Table 15.3: 1 AE by SOC/PT") |>
  add_footer_lines(as_paragraph(as_sup("(1)"), " n (%)")) |>
  set_table_properties(layout = "fixed") |>
  width(width = 1) |>
  width(width = 2, j = 1)
ft

Table 15.3: 1 AE by SOC/PT

LABEL

Placebo

Xanomeline High Dose

Xanomeline Low Dose

ANY ADVERSE EVENTS

69 (80.2%)

79 (94.0%)

77 (91.7%)

CARDIAC DISORDERS

13 (15.1%)

18 (21.4%)

13 (15.5%)

ATRIAL FIBRILLATION

1 (1.2%)

3 (3.6%)

1 (1.2%)

ATRIAL FLUTTER

0 (0%)

1 (1.2%)

1 (1.2%)

ATRIAL HYPERTROPHY

1 (1.2%)

0 (0%)

0 (0%)

ATRIOVENTRICULAR BLOCK FIRST DEGREE

1 (1.2%)

0 (0%)

1 (1.2%)

ATRIOVENTRICULAR BLOCK SECOND DEGREE

2 (2.3%)

3 (3.6%)

0 (0%)

BRADYCARDIA

1 (1.2%)

0 (0%)

0 (0%)

BUNDLE BRANCH BLOCK LEFT

1 (1.2%)

0 (0%)

0 (0%)

BUNDLE BRANCH BLOCK RIGHT

1 (1.2%)

0 (0%)

1 (1.2%)

CARDIAC DISORDER

0 (0%)

1 (1.2%)

0 (0%)

CARDIAC FAILURE CONGESTIVE

1 (1.2%)

0 (0%)

0 (0%)

MYOCARDIAL INFARCTION

4 (4.7%)

4 (4.8%)

2 (2.4%)

PALPITATIONS

0 (0%)

0 (0%)

2 (2.4%)

SINUS ARRHYTHMIA

1 (1.2%)

0 (0%)

0 (0%)

SINUS BRADYCARDIA

2 (2.3%)

8 (9.5%)

7 (8.3%)

SUPRAVENTRICULAR EXTRASYSTOLES

1 (1.2%)

1 (1.2%)

1 (1.2%)

SUPRAVENTRICULAR TACHYCARDIA

0 (0%)

0 (0%)

1 (1.2%)

TACHYCARDIA

1 (1.2%)

0 (0%)

0 (0%)

VENTRICULAR EXTRASYSTOLES

0 (0%)

1 (1.2%)

2 (2.4%)

VENTRICULAR HYPERTROPHY

1 (1.2%)

0 (0%)

0 (0%)

WOLFF-PARKINSON-WHITE SYNDROME

0 (0%)

0 (0%)

1 (1.2%)

GASTROINTESTINAL DISORDERS

17 (19.8%)

21 (25.0%)

15 (17.9%)

ABDOMINAL DISCOMFORT

0 (0%)

1 (1.2%)

0 (0%)

ABDOMINAL PAIN

1 (1.2%)

1 (1.2%)

3 (3.6%)

CONSTIPATION

1 (1.2%)

0 (0%)

0 (0%)

DIARRHOEA

9 (10.5%)

4 (4.8%)

5 (6.0%)

DYSPEPSIA

1 (1.2%)

1 (1.2%)

1 (1.2%)

DYSPHAGIA

0 (0%)

0 (0%)

1 (1.2%)

FLATULENCE

1 (1.2%)

0 (0%)

0 (0%)

GASTROINTESTINAL HAEMORRHAGE

0 (0%)

1 (1.2%)

0 (0%)

GASTROOESOPHAGEAL REFLUX DISEASE

1 (1.2%)

0 (0%)

0 (0%)

GLOSSITIS

1 (1.2%)

0 (0%)

0 (0%)

HIATUS HERNIA

1 (1.2%)

0 (0%)

0 (0%)

NAUSEA

3 (3.5%)

6 (7.1%)

3 (3.6%)

RECTAL HAEMORRHAGE

0 (0%)

0 (0%)

1 (1.2%)

SALIVARY HYPERSECRETION

0 (0%)

4 (4.8%)

0 (0%)

STOMACH DISCOMFORT

0 (0%)

1 (1.2%)

0 (0%)

VOMITING

3 (3.5%)

7 (8.3%)

3 (3.6%)

(1) n (%)

Breaking down each formatting choice
  1. col_keys = c("LABEL", CT_ARM$ARM): Specifies exactly which columns to display. We show the LABEL column first, followed by each treatment arm. This hides the metadata columns (AESOC, AEDECOD, agg_level) that we kept for conditional formatting.

  2. padding(j = "LABEL", i = ~ agg_level == 2, padding.left = 12): Creates visual hierarchy by indenting Preferred Terms (level 2). This immediately shows readers which rows are detailed terms versus summary categories. The ~ syntax allows us to use a formula to conditionally apply formatting.

  3. add_header_lines("Table 15.3: 1 AE by SOC/PT"): Adds a title row that spans all columns. This follows standard pharmaceutical numbering conventions for tables.

  4. add_footer_lines(as_paragraph(as_sup("(1)"), " n (%)")): Adds a footnote explaining the data format, with superscript notation linking to the column headers we’ll add next.

  5. set_table_properties(layout = "fixed"): Fixes column widths so the table doesn’t expand/contract in different contexts. This ensures consistency when the document is viewed in different Word versions or settings.

  6. width(width = 1) and width(width = 2, j = 1): Sets column widths in inches. Treatment arm columns get 1 inch each, while the LABEL column gets 2 inches to accommodate longer adverse event terms.

3.3.4 Adding Sample Size Information to Headers

In clinical tables, it’s important to display the number of subjects in each treatment arm. This context allows readers to properly interpret the percentages and assess the reliability of the data:

Code
ft <- ft |>
  append_chunks(
    i = 1, j = -1, part = "header",
    as_sup(" (1)")
  ) |>
  append_chunks(
    i = 1, j = -1, part = "header",
    as_chunk(fmt_header_n(CT_ARM$denom, newline = TRUE))
  )
ft

Table 15.3: 1 AE by SOC/PT

LABEL

Placebo

Xanomeline High Dose

Xanomeline Low Dose

ANY ADVERSE EVENTS

69 (80.2%)

79 (94.0%)

77 (91.7%)

CARDIAC DISORDERS

13 (15.1%)

18 (21.4%)

13 (15.5%)

ATRIAL FIBRILLATION

1 (1.2%)

3 (3.6%)

1 (1.2%)

ATRIAL FLUTTER

0 (0%)

1 (1.2%)

1 (1.2%)

ATRIAL HYPERTROPHY

1 (1.2%)

0 (0%)

0 (0%)

ATRIOVENTRICULAR BLOCK FIRST DEGREE

1 (1.2%)

0 (0%)

1 (1.2%)

ATRIOVENTRICULAR BLOCK SECOND DEGREE

2 (2.3%)

3 (3.6%)

0 (0%)

BRADYCARDIA

1 (1.2%)

0 (0%)

0 (0%)

BUNDLE BRANCH BLOCK LEFT

1 (1.2%)

0 (0%)

0 (0%)

BUNDLE BRANCH BLOCK RIGHT

1 (1.2%)

0 (0%)

1 (1.2%)

CARDIAC DISORDER

0 (0%)

1 (1.2%)

0 (0%)

CARDIAC FAILURE CONGESTIVE

1 (1.2%)

0 (0%)

0 (0%)

MYOCARDIAL INFARCTION

4 (4.7%)

4 (4.8%)

2 (2.4%)

PALPITATIONS

0 (0%)

0 (0%)

2 (2.4%)

SINUS ARRHYTHMIA

1 (1.2%)

0 (0%)

0 (0%)

SINUS BRADYCARDIA

2 (2.3%)

8 (9.5%)

7 (8.3%)

SUPRAVENTRICULAR EXTRASYSTOLES

1 (1.2%)

1 (1.2%)

1 (1.2%)

SUPRAVENTRICULAR TACHYCARDIA

0 (0%)

0 (0%)

1 (1.2%)

TACHYCARDIA

1 (1.2%)

0 (0%)

0 (0%)

VENTRICULAR EXTRASYSTOLES

0 (0%)

1 (1.2%)

2 (2.4%)

VENTRICULAR HYPERTROPHY

1 (1.2%)

0 (0%)

0 (0%)

WOLFF-PARKINSON-WHITE SYNDROME

0 (0%)

0 (0%)

1 (1.2%)

GASTROINTESTINAL DISORDERS

17 (19.8%)

21 (25.0%)

15 (17.9%)

ABDOMINAL DISCOMFORT

0 (0%)

1 (1.2%)

0 (0%)

ABDOMINAL PAIN

1 (1.2%)

1 (1.2%)

3 (3.6%)

CONSTIPATION

1 (1.2%)

0 (0%)

0 (0%)

DIARRHOEA

9 (10.5%)

4 (4.8%)

5 (6.0%)

DYSPEPSIA

1 (1.2%)

1 (1.2%)

1 (1.2%)

DYSPHAGIA

0 (0%)

0 (0%)

1 (1.2%)

FLATULENCE

1 (1.2%)

0 (0%)

0 (0%)

GASTROINTESTINAL HAEMORRHAGE

0 (0%)

1 (1.2%)

0 (0%)

GASTROOESOPHAGEAL REFLUX DISEASE

1 (1.2%)

0 (0%)

0 (0%)

GLOSSITIS

1 (1.2%)

0 (0%)

0 (0%)

HIATUS HERNIA

1 (1.2%)

0 (0%)

0 (0%)

NAUSEA

3 (3.5%)

6 (7.1%)

3 (3.6%)

RECTAL HAEMORRHAGE

0 (0%)

0 (0%)

1 (1.2%)

SALIVARY HYPERSECRETION

0 (0%)

4 (4.8%)

0 (0%)

STOMACH DISCOMFORT

0 (0%)

1 (1.2%)

0 (0%)

VOMITING

3 (3.5%)

7 (8.3%)

3 (3.6%)

(1) n (%)

Understanding chunk composition

The append_chunks() function allows us to build complex cell content by appending text chunks with different formatting.

  1. First append_chunks() call: Adds a superscript “(1)” to all treatment arm columns (j = -1 means “all columns except the first”). This superscript links to the footer note explaining the data format.

  2. Second append_chunks() call: Adds the sample size for each treatment arm on a new line. The fmt_header_n() function formats the denominators in parentheses, creating headers like:

Placebo
(N=86)

3.3.5 Applying Labels and Formatting

The final step is to replace technical variable names with reader-friendly labels and apply alignment conventions:

Code
ft <- ft |>
  labelizor(
    labels = c(LABEL = "", unlist(labelled::var_label(adae))),
    j = "LABEL"
  ) |>
  labelizor(
    labels = stringr::str_to_sentence,
    j = "LABEL"
  ) |>
  mk_par(
    i = 1, j = 1, part = "header",
    as_paragraph(
      labelled::get_variable_labels(adae)$AESOC,
      "\n\t",
      labelled::get_variable_labels(adae)$AEDECOD
    )
  ) |>
  align(j = -1, align = "right", part = "all")
ft

Primary System Organ Class
Dictionary-Derived Term

Placebo

Xanomeline High Dose

Xanomeline Low Dose

Any adverse events

69 (80.2%)

79 (94.0%)

77 (91.7%)

Cardiac disorders

13 (15.1%)

18 (21.4%)

13 (15.5%)

Atrial fibrillation

1 (1.2%)

3 (3.6%)

1 (1.2%)

Atrial flutter

0 (0%)

1 (1.2%)

1 (1.2%)

Atrial hypertrophy

1 (1.2%)

0 (0%)

0 (0%)

Atrioventricular block first degree

1 (1.2%)

0 (0%)

1 (1.2%)

Atrioventricular block second degree

2 (2.3%)

3 (3.6%)

0 (0%)

Bradycardia

1 (1.2%)

0 (0%)

0 (0%)

Bundle branch block left

1 (1.2%)

0 (0%)

0 (0%)

Bundle branch block right

1 (1.2%)

0 (0%)

1 (1.2%)

Cardiac disorder

0 (0%)

1 (1.2%)

0 (0%)

Cardiac failure congestive

1 (1.2%)

0 (0%)

0 (0%)

Myocardial infarction

4 (4.7%)

4 (4.8%)

2 (2.4%)

Palpitations

0 (0%)

0 (0%)

2 (2.4%)

Sinus arrhythmia

1 (1.2%)

0 (0%)

0 (0%)

Sinus bradycardia

2 (2.3%)

8 (9.5%)

7 (8.3%)

Supraventricular extrasystoles

1 (1.2%)

1 (1.2%)

1 (1.2%)

Supraventricular tachycardia

0 (0%)

0 (0%)

1 (1.2%)

Tachycardia

1 (1.2%)

0 (0%)

0 (0%)

Ventricular extrasystoles

0 (0%)

1 (1.2%)

2 (2.4%)

Ventricular hypertrophy

1 (1.2%)

0 (0%)

0 (0%)

Wolff-parkinson-white syndrome

0 (0%)

0 (0%)

1 (1.2%)

Gastrointestinal disorders

17 (19.8%)

21 (25.0%)

15 (17.9%)

Abdominal discomfort

0 (0%)

1 (1.2%)

0 (0%)

Abdominal pain

1 (1.2%)

1 (1.2%)

3 (3.6%)

Constipation

1 (1.2%)

0 (0%)

0 (0%)

Diarrhoea

9 (10.5%)

4 (4.8%)

5 (6.0%)

Dyspepsia

1 (1.2%)

1 (1.2%)

1 (1.2%)

Dysphagia

0 (0%)

0 (0%)

1 (1.2%)

Flatulence

1 (1.2%)

0 (0%)

0 (0%)

Gastrointestinal haemorrhage

0 (0%)

1 (1.2%)

0 (0%)

Gastrooesophageal reflux disease

1 (1.2%)

0 (0%)

0 (0%)

Glossitis

1 (1.2%)

0 (0%)

0 (0%)

Hiatus hernia

1 (1.2%)

0 (0%)

0 (0%)

Nausea

3 (3.5%)

6 (7.1%)

3 (3.6%)

Rectal haemorrhage

0 (0%)

0 (0%)

1 (1.2%)

Salivary hypersecretion

0 (0%)

4 (4.8%)

0 (0%)

Stomach discomfort

0 (0%)

1 (1.2%)

0 (0%)

Vomiting

3 (3.5%)

7 (8.3%)

3 (3.6%)

(1) N (%)

Understanding the labeling process
  1. First labelizor() call: Replaces column headers using the variable labels stored in the CDISC dataset metadata. The c(LABEL = "", ...) syntax sets the LABEL column header to empty, then uses the actual variable labels from adae for any matching column names.

  2. Second labelizor() call: Applies sentence case formatting to labels using stringr::str_to_sentence. This converts labels like “CARDIAC DISORDERS” to “Cardiac disorders”.

  3. mk_par() for the first header cell: Creates a more informative header for the LABEL column by combining two pieces of information:

    • The SOC label (“System Organ Class”)
    • The PT label (“Dictionary-Derived Term”)

    The \n\t creates a new line with a tab, visually organizing the two-level hierarchy even in the header.

3.3.6 Save in a Word document

We now have a publication-ready Adverse Event table that:

  • Presents three levels of aggregation in a single view
  • Uses visual hierarchy (indentation) to show relationships
  • Includes sample sizes for proper interpretation
  • Follows defined formatting standards

This table is ready to be included in a Word document.

Code
sub_path <- file.path("output", "ae-table", "flextable-ae-first")
dir.create(sub_path, showWarnings = FALSE, recursive = TRUE)
out_path <- file.path(sub_path, "AE-TABLE.docx")
Code
save_as_docx(ft, path = out_path)

Click to download output/ae-table/flextable-ae-first/AE-TABLE.docx.

It appears you don't have a PDF plugin for this browser.