You can modify the format functions and the expected denominator as follows. Default values are listed in the functions' documentation (e.g. for `?count_occurrences` it is `"count_fraction_fixed_dp"`). Notice that format default for that is `tern::format_count_fraction_fixed_dp` (that I copied and modified according to your needs). The parameter `denom `is the usual handler for changing the denominator. Can be seen in the same documentation page (`?count_occurrences`).
```r
library(pharmaverseadam)
library(tern)
library(dplyr)
adsl <- adsl %>%
df_explicit_na()
adae <- adae %>%
df_explicit_na()
adae <- adae %>%
var_relabel(
AEBODSYS = "MedDRA System Organ Class",
AEDECOD = "MedDRA Preferred Term"
) %>%
dplyr::filter(SAFFL == "Y")
# Define the split function
split_fun <- drop_split_levels
lyt <- basic_table(show_colcounts = TRUE) %>%
split_cols_by(var = "ACTARM") %>%
add_overall_col(label = "All Patients") %>%
analyze_num_patients(
vars = "USUBJID",
.stats = c("unique", "nonunique"),
.labels = c(
unique = "Total number of patients with at least one adverse event",
nonunique = "Overall total number of events"
)
) %>%
split_rows_by(
"AEBODSYS",
child_labels = "visible",
nested = FALSE,
split_fun = split_fun,
label_pos = "topleft",
split_label = obj_label(adae$AEBODSYS)
) %>%
summarize_num_patients(
var = "USUBJID",
.stats = c("unique", "nonunique"),
.labels = c(
unique = "Total number of patients with at least one adverse event",
nonunique = "Total number of events"
)
) %>%
# Here denom is changed and .formats is specified. Notice that this is a copy of
# tern::format_count_fraction_fixed_dp
count_occurrences(
vars = "AEDECOD",
.indent_mods = -1L,
.stats = "count_fraction_fixed_dp",
denom = "n",
.formats = list("count_fraction_fixed_dp" = function(x, ...) {
attr(x, "label") <- NULL
if (any(is.na(x))) {
return("NA")
}
checkmate::assert_vector(x)
checkmate::assert_integerish(x[1])
assert_proportion_value(x[2], include_boundaries = TRUE)
result <- if (x[1] == 0) {
"0"
} else if (x[2] == 1) {
sprintf("%d (100%%)", x[1])
} else {
# browser()
sprintf("%d/%d (%.1f%%)", x[1], round(x[1] / x[2], 0), x[2] * 100)
}
return(result)
})
) %>%
append_varlabels(adae, "AEDECOD", indent = 1L)
result <- build_table(lyt, df = adae, alt_counts_df = adsl) |>
head(n = 10)
result
#> MedDRA System Organ Class Placebo Xanomeline High Dose Xanomeline Low Dose All Patients
#> MedDRA Preferred Term (N=86) (N=72) (N=96) (N=306)
#> —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
#> Total number of patients with at least one adverse event 69 (80.2%) 70 (97.2%) 86 (89.6%) 225 (73.5%)
#> Overall total number of events 301 436 454 1191
#> CARDIAC DISORDERS
#> Total number of patients with at least one adverse event 13 (15.1%) 15 (20.8%) 16 (16.7%) 44 (14.4%)
#> Total number of events 27 30 34 91
#> ATRIAL FIBRILLATION 1/13 (7.7%) 2/15 (13.3%) 2/16 (12.5%) 5/44 (11.4%)
#> ATRIAL FLUTTER 0 1/15 (6.7%) 1/16 (6.2%) 2/44 (4.5%)
#> ATRIAL HYPERTROPHY 1/13 (7.7%) 0 0 1/44 (2.3%)
#> ATRIOVENTRICULAR BLOCK FIRST DEGREE 1/13 (7.7%) 0 1/16 (6.2%) 2/44 (4.5%)
#> ATRIOVENTRICULAR BLOCK SECOND DEGREE 2/13 (15.4%) 1/15 (6.7%) 2/16 (12.5%) 5/44 (11.4%)
```
<sup>Created on 2025-07-10 with [reprex v2.1.1](https://reprex.tidyverse.org)</sup>
I would go for something like the following. Note that you can change and make the analysis function fancier if you need.
```
``` r
library(dplyr)
library(tern)
adsl <- random.cdisc.data::cadsl
adae <- random.cdisc.data::cadae
# Ensure character variables are converted to factors and empty strings and NAs are explicit missing levels.
adsl <- df_explicit_na(adsl)
adae <- df_explicit_na(adae) %>%
var_relabel(
AEBODSYS = "MedDRA System Organ Class",
AEDECOD = "MedDRA Preferred Term"
) %>%
filter(ANL01FL == "Y")
# Define the split function
split_fun <- drop_split_levels
# Define the column functions for analysis
colfuns <- list(
function(x, .spl_context) {
# browser() # Debugging point to inspect the context
rcell(table(as.character(x)) |> unlist(), format = "xx.x", label = .spl_context$value[nrow(.spl_context)])
},
function(x, .N_row, .spl_context) { # see ?.additional_fun_params
rcell(table(as.character(x)) |> unlist() / .N_row , format = "xx.x%", label = .spl_context$value[nrow(.spl_context)])
}
)
# build layout
lyt <- basic_table() %>%
split_rows_by(
"AEBODSYS",
child_labels = "visible",
nested = FALSE,
split_fun = split_fun,
label_pos = "topleft",
split_label = obj_label(adae$AEBODSYS)
) %>%
split_rows_by(
"AEDECOD",
child_labels = "hidden",
nested = TRUE,
split_fun = split_fun,
label_pos = "topleft",
split_label = obj_label(adae$AEDECOD)
) %>%
split_cols_by("ARM") |>
# Add the analysis variables
split_cols_by_multivar(vars = c("ARM", "ARM"), varlabels = c("n", "%")) |>
# Add the analysis functions
analyze_colvars(afun = colfuns, format = "xx.xx")
result <- build_table(lyt, df = adae, alt_counts_df = adsl)
result
#> MedDRA System Organ Class A: Drug X B: Placebo C: Combination
#> MedDRA Preferred Term n % n % n %
#> ————————————————————————————————————————————————————————————————————————————
#> cl A.1
#> dcd A.1.1.1.1 57.0 32.8% 43.0 24.7% 74.0 42.5%
#> dcd A.1.1.1.2 58.0 32.8% 56.0 31.6% 63.0 35.6%
#> cl B.1
#> dcd B.1.1.1.1 44.0 32.1% 43.0 31.4% 50.0 36.5%
#> cl B.2
#> dcd B.2.1.2.1 52.0 32.1% 51.0 31.5% 59.0 36.4%
#> dcd B.2.2.3.1 50.0 28.9% 55.0 31.8% 68.0 39.3%
#> cl C.1
#> dcd C.1.1.1.3 47.0 30.9% 51.0 33.6% 54.0 35.5%
#> cl C.2
#> dcd C.2.1.2.1 39.0 28.7% 40.0 29.4% 57.0 41.9%
#> cl D.1
#> dcd D.1.1.1.1 52.0 33.3% 40.0 25.6% 64.0 41.0%
#> dcd D.1.1.4.2 54.0 36.5% 44.0 29.7% 50.0 33.8%
#> cl D.2
#> dcd D.2.1.5.3 49.0 28.7% 57.0 33.3% 65.0 38.0%
```
<sup>Created on 2025-07-01 with </sup>[<sup>reprex v2.1.1</sup>](https://reprex.tidyverse.org)