Intermediate rtables - Identifying Required Analysis Behavior
Contributed by Johnson & Johnson Innovative Medicine
Gabriel Becker
Dan Hofstaedter
2025-10-22
Source:vignettes/guided_intermediate_afun_reqs.Rmd
guided_intermediate_afun_reqs.RmdIntroduction
Analysis functions can do anything. While this provides one of the
backbones of rtables’ power and flexibility, it also means
that analysis functions must be chosen (and designed) with care to
ensure our resulting tables contain the correct statistics.
While the diversity of tables is such that custom analysis functions will inevitably be needed in some cases, we will not address the creation of analysis functions in detail here; that is covered in within the advanced portion of this guided tour. Here we will focus on selecting analysis functions and the reasoning that goes into that choice. While this might seem like a strange distinction at first glance, this is in fact how most tables will be created - when a template script or function is not used to create them from whole cloth; packages like tern and junco provide libraries of well-tested analysis functions that statistical programmers will often simply select from when creating production outputs.
Who Are Your Numbers And What Do They Do?
The details of what a desired analysis behavior calls for can be categorized into two distinct types: specific and fundamental. Specific analysis behaviors are things like which particular statistical test or confidence interval method should be used, how missing or censored data should be handled, etc.
Before specific details can be considered, however, the fundamental aspects of the desired behavior must be determined. These come down to three aspects fundamental to how the contents of our table are intended to be interpreted:
- The type of observation being counted or analyzed,
- The (sub)population we are analyzing within or comparing against, and
- Whether the behavior is conditional, depending on the facet it is populating.
There’s Counting And Then There’s Counting…
Consider the “simple” act of counting the frequency of different types of adverse events (AEs) that occurred in a clinical trial; this could mean one of two quite different things:
- The number of (unique) patients which suffered a particular AE, or
- The number of times the AE was suffered
These will generally give dramatically different numbers, as illustrated by the table below, and one or both may be desired in a given table.
double_count <- function(x) {
in_rows(
"Unique Patients" = length(unique(x)),
"Total Events" = length(x)
)
}
lyt_counts <- basic_table() |>
split_cols_by("ARM") |>
split_rows_by("AEBODSYS", split_fun = trim_levels_in_group("AEDECOD")) |>
split_rows_by("AEDECOD") |>
analyze("USUBJID", afun = double_count)
build_table(lyt_counts, ex_adae, alt_counts_df = ex_adsl) A: Drug X B: Placebo C: Combination
—————————————————————————————————————————————————————————————
cl A.1
dcd A.1.1.1.1
Unique Patients 50 45 63
Total Events 64 62 88
dcd A.1.1.1.2
Unique Patients 48 48 50
Total Events 68 68 72
cl B.1
dcd B.1.1.1.1
Unique Patients 47 49 43
Total Events 56 60 62
cl B.2
dcd B.2.1.2.1
Unique Patients 49 44 52
Total Events 65 62 66
dcd B.2.2.3.1
Unique Patients 48 54 51
Total Events 64 76 77
cl C.1
dcd C.1.1.1.3
Unique Patients 43 46 43
Total Events 55 63 64
cl C.2
dcd C.2.1.2.1
Unique Patients 35 48 55
Total Events 48 53 65
cl D.1
dcd D.1.1.1.1
Unique Patients 50 42 51
Total Events 61 51 71
dcd D.1.1.4.2
Unique Patients 48 42 50
Total Events 66 55 64
cl D.2
dcd D.2.1.5.3
Unique Patients 47 58 57
Total Events 62 72 74
Denominators
Once we have defined counting, we often want to display percents alongside the absolute counts. Even with the meaning of our counts fixed, however, this can mean a number of different things, depending on the (sub)population we are considering the count a percent of. Different denominators used in production can include the overall relevant count for the:
- column (often trial arm)
- individual facet (row x column)
- row group (across all columns)
- ancestor row facet (across all columns)
- ancestor row facet for a single column
Keeping in mind that each of the above might be either of the two types of counting discussed above (generally events or patients in a clinical trial setting).
Furthermore, these denominators can often need to be derived from the
alt_counts_df used to build the table when counting
patients, rather than the data being directly tabulated, due to the fact
that not all patients are guaranteed to appear in event based datasets
(e.g., ADAE).
As before, we can see that these denominators can vary wildly, which
would cause the percents displayed to do so as well. We will use a
custom afun (disp_denoms) to explore the differences
between the above types of denominators. The details of how we implement
the disp_denoms function used below is not germane to our
discussion here but motivated readers can choose to inspect it
below.
disp_denoms <- function(x, .var, .N_col, .df_row, .alt_df_full, .spl_context) {
## myfn <- RefFootnote("Patients observed in AE data", symbol = "*")
parent_df <- .spl_context$full_parent_df[[3]]
parent_df_onecol <- parent_df[eval(.spl_context$cur_col_expr[[1]], envir = parent_df), ]
gparent_df <- .spl_context$full_parent_df[[2]]
gparent_df_onecol <- gparent_df[eval(.spl_context$cur_col_expr[[1]], envir = gparent_df), ]
cur_race <- .spl_context$value[2]
cur_aebodsys <- .spl_context$value[3]
cur_aedecod <- .spl_context$value[4]
alt_df_race <- subset(.alt_df_full, RACE == cur_race)
alt_df_race_col <- alt_df_race[eval(.spl_context$cur_col_expr[[1]], envir = alt_df_race), ]
vals <- list(
.N_col,
length(x),
length(unique(x)),
NA,
NROW(.df_row),
length(unique(.df_row[[.var]])),
NA,
NROW(parent_df),
length(unique(parent_df[[.var]])),
NROW(parent_df_onecol),
length(unique(parent_df_onecol[[.var]])),
NROW(gparent_df),
length(unique(gparent_df[[.var]])),
NROW(alt_df_race),
NROW(gparent_df_onecol),
length(unique(gparent_df_onecol[[.var]])),
NROW(alt_df_race_col)
)
names(vals) <- c(
"Column N",
"facet events",
"facet patients",
"facet patients - alt df",
sprintf("%s events", cur_aedecod),
sprintf("%s patients", cur_aedecod),
sprintf("%s patients - alt df", cur_aedecod),
sprintf("%s events (all arms)", cur_aebodsys),
sprintf("%s patients (all arms)", cur_aebodsys),
sprintf("%s events (this arm)", cur_aebodsys),
sprintf("%s patients (this arm)", cur_aebodsys),
sprintf("%s events (all arms)", cur_race),
sprintf("%s patients (all arms)", cur_race),
sprintf("%s patients (all arms) - alt df", cur_race),
sprintf("%s events (this arms)", cur_race),
sprintf("%s patients (this arms)", cur_race),
sprintf("%s patients (this arms) - alt df", cur_race)
)
in_rows(.list = vals)
}With disp_denoms defined, we can proceed with using it
to see how varied the options for denominator are when calculating a
percent:
map <- tribble(
~RACE, ~AEBODSYS, ~AEDECOD,
"ASIAN", "cl B.2", "dcd B.2.2.3.1",
"WHITE", "cl A.1", "dcd A.1.1.1.1"
)
lyt_denoms <- basic_table() |>
split_cols_by("ARM") |>
split_rows_by("RACE", split_fun = trim_levels_to_map(map)) |> ## keep_split_levels(c("ASIAN", "WHITE"))) |>
split_rows_by("AEBODSYS") |> # , split_fun = trim_levels_in_group("AEDECOD")) |>
split_rows_by("AEDECOD") |>
analyze("USUBJID", afun = disp_denoms)
build_table(lyt_denoms, ex_adae, alt_counts_df = ex_adsl) A: Drug X B: Placebo C: Combination
———————————————————————————————————————————————————————————————————————————————————
ASIAN
cl B.2
dcd B.2.2.3.1
Column N 134 134 132
facet events 37 38 48
facet patients 27 27 30
facet patients - alt df NA NA NA
dcd B.2.2.3.1 events 123 123 123
dcd B.2.2.3.1 patients 84 84 84
dcd B.2.2.3.1 patients - alt df NA NA NA
cl B.2 events (all arms) 123 123 123
cl B.2 patients (all arms) 84 84 84
cl B.2 events (this arm) 37 38 48
cl B.2 patients (this arm) 27 27 30
ASIAN events (all arms) 123 123 123
ASIAN patients (all arms) 84 84 84
ASIAN patients (all arms) - alt df 208 208 208
ASIAN events (this arms) 37 38 48
ASIAN patients (this arms) 27 27 30
ASIAN patients (this arms) - alt df 68 67 73
WHITE
cl A.1
dcd A.1.1.1.1
Column N 134 134 132
facet events 14 19 13
facet patients 9 14 10
facet patients - alt df NA NA NA
dcd A.1.1.1.1 events 46 46 46
dcd A.1.1.1.1 patients 33 33 33
dcd A.1.1.1.1 patients - alt df NA NA NA
cl A.1 events (all arms) 46 46 46
cl A.1 patients (all arms) 33 33 33
cl A.1 events (this arm) 14 19 13
cl A.1 patients (this arm) 9 14 10
WHITE events (all arms) 46 46 46
WHITE patients (all arms) 33 33 33
WHITE patients (all arms) - alt df 74 74 74
WHITE events (this arms) 14 19 13
WHITE patients (this arms) 9 14 10
WHITE patients (this arms) - alt df 27 26 21
The above table displays the various denominators we might use across both types of counting discussed in the previous section. Each of these denominator choices would result in at least one difference in calculated percent from all other choices across the full table (barring any coincidental equality between count types in your data). Thus when programming against a table shell with the ubiquitous “xx (xx.x%)” family of formats, we must carefully choose an afun which calculates the correct type of percent.
Many existing analysis functions provided by production libraries
such as tern and junco support different
denominator options for use when calculating percents out of the box,
though they do not all support the full breadth of variety explored
above. Nonetheless, careful consideration of the existing analysis
functions available to you and what they support should be made before
choosing to create a custom one.
“Yeah But What If” … Conditionality In afuns
While many tables call for analysis functions which perform identical
calculations across all facets they are applied within, this is
not a requirement of the rtables
framework. In this vignette we will not discuss the creation of
analysis functions which support creating facet-conditional cell values;
rather we will discuss identifying when such analysis functions
are required, leaving the implementation of afuns with
complex behavior to advanced portion(s) of this tour.
Conditionality Based On Column Facet
In point of fact, we saw conditionality on the current column in the
previous
vignette when translating shells with risk difference columns; there
the dummy afun we used calculated count-percent values for
facets in the “main portion” of the table and displayed a custom string
for facets in the risk difference portion. In real tables that string
would be replaced with actual risk difference calculations, of course,
but the conditionality itself is the same.
Generally speaking, afuns we use will need this type of
column-facet conditionality any time we have a heterogeneous column
structure (i.e., any time we have non-nested column faceting in our
layout). In these cases analysts will need to use afuns
specifically designed to support the specific type of column structure
their layout declares.
There is not currently a production-ready way to combine
afuns into a single function and declare the conditions
under which each should be used, though we we will explore this as an
exercise in the advanced portion of this tour.
Conditionality Based On Row Facet
Unlike column-conditional afuns, purely row-conditional
afuns do not generate rows which have values with different
meanings for different cells in the row. Rather, row-conditionality
manifests as different row facets containing different numbers of rows
and/or entire rows or sets of rows with different types of content based
on the current row facet.
A common example of a shell with different numbers of rows depending
on the row facet is one summarizing patient visits
(AVISIT). In such tables, the row facet for the initial or
BASELINE visit will often have only a subset of those in
the other facets, as those will contain comparisons to the baseline
visit.
avisit_afun <- function(df, .var, .spl_context) {
cur_visit <- tail(.spl_context$value, 1)
vals <- list("Mean Patient DIABP" = mean(df[[.var]]))
pardf <- head(.spl_context$full_parent_df, 1)[[1]]
if (!(as.character(cur_visit) %in% c("SCREENING", "BASELINE"))) {
pardf <- subset(pardf, AVISIT %in% c("BASELINE", cur_visit))
difs <- tapply(
seq_len(nrow(pardf)), pardf$USUBJID,
function(iis) {
avalvec <- pardf$AVAL[iis]
bl <- which(as.character(pardf[iis, ]$AVISIT) == "BASELINE")
mean(avalvec[-bl] - avalvec[bl])
}
)
vals <- c(vals, list("Mean Diff From Patient's Baseline DIABP" = mean(difs)))
}
in_rows(.list = vals)
}We can see this behavior using a basic row-conditional
afun (defined above for those curious):
lyt_rowcond <- basic_table() |>
split_rows_by("AVISIT") |>
analyze("AVAL", avisit_afun, format = "xx.xx")
build_table(lyt_rowcond, subset(ex_advs, PARAMCD == "DIABP")) all obs
———————————————————————————————————————————————————
SCREENING
Mean Patient DIABP 50.30
BASELINE
Mean Patient DIABP 50.04
WEEK 1 DAY 8
Mean Patient DIABP 49.60
Mean Diff From Patient's Baseline DIABP -0.44
WEEK 2 DAY 15
Mean Patient DIABP 50.18
Mean Diff From Patient's Baseline DIABP 0.13
WEEK 3 DAY 22
Mean Patient DIABP 49.92
Mean Diff From Patient's Baseline DIABP -0.13
WEEK 4 DAY 29
Mean Patient DIABP 49.80
Mean Diff From Patient's Baseline DIABP -0.24
WEEK 5 DAY 36
Mean Patient DIABP 49.34
Mean Diff From Patient's Baseline DIABP -0.70
In the above table, we display only the mean of the measurement for
that visit (simulated DIABP in this case) for the
SCREENING and BASELINE visits, while we
additionally display the mean of the per-patient difference between
their values for that visit and their baseline visit for follow-up
visits.
Why Not Both?
It is important to note that while we described row- and column-facet
conditionality separately in this section, an afun can be
both simultaneously. In fact an afun’s column
conditionality can itself be dependent on which row facet it is in.
The most straightforward example of this type of “dual conditionality” is when we have heterogeneous column structure, but the additional columns should only be populated for some portions of the table and left blank for others.
For this example we repeat the same data preparation as in the Risk Difference Columns section in the previous chapter without further comment.
advs <- ex_advs
advs$span_label <- "Active Treatment"
advs$span_label[advs$ARM == "B: Placebo"] <- " "
span_label_map <- tribble(
~span_label, ~ARM,
"Active Treatment", "A: Drug X",
"Active Treatment", "C: Combination",
" ", "B: Placebo",
)
advs$rr_header <- "Risk Differences"
advs$rr_label <- paste(substr(advs$ARM, 1, 1), "vs B")We can then define an afun which populates the “risk difference” columns only for follow up visits and leaves them blank otherwise:
in_risk_diff <- function(spl_context) grepl("Risk Differences", spl_context$cur_col_id[1])
avisit_afun2 <- function(df, .var, .spl_context) {
in_rd <- in_risk_diff(.spl_context)
cur_visit <- tail(.spl_context$value, 1)
is_followup <- !(as.character(cur_visit) %in% c("SCREENING", "BASELINE"))
if (!in_rd) {
vals <- list("Mean Patient DIABP" = mean(df[[.var]]))
} else if (!is_followup) {
vals <- list("Mean Patient DIABP" = NULL)
} else {
vals <- list("Mean Patient DIABP" = rcell("-", format = "xx"))
}
pardf <- head(.spl_context$full_parent_df, 1)[[1]]
if (is_followup) {
if (!in_rd) {
pardf <- subset(pardf, AVISIT %in% c("BASELINE", cur_visit))
difs <- tapply(
seq_len(nrow(pardf)), pardf$USUBJID,
function(iis) {
avalvec <- pardf$AVAL[iis]
bl <- which(as.character(pardf[iis, ]$AVISIT) == "BASELINE")
mean(avalvec[-bl] - avalvec[bl])
}
)
vals <- c(vals, list("Mean Diff From Baseline" = mean(difs)))
} else {
armlabel <- tail(.spl_context$cur_col_split_val[[1]], 1) # last split value, ie arm
armletter <- substr(armlabel, 1, 1)
vals <- c(vals, list("Mean Diff From Baseline" = rcell(paste(armletter, "vs B"), format = "xx")))
}
}
in_rows(.list = vals)
}With that done, we can construct a table that has dual conditionality in its cell contents.
lyt_fullcond <- basic_table() |>
split_cols_by("span_label", split_fun = trim_levels_to_map(span_label_map)) |>
split_cols_by("ARM", show_colcounts = TRUE) |>
split_cols_by("rr_header", nested = FALSE) |>
split_cols_by("ARM", labels_var = "rr_label", split_fun = remove_split_levels("B: Placebo")) |>
split_rows_by("AVISIT") |>
analyze("AVAL", avisit_afun2, format = "xx.xx")
build_table(lyt_fullcond, subset(advs, PARAMCD == "DIABP")) Active Treatment
A: Drug X C: Combination B: Placebo Risk Differences
(N=938) (N=924) (N=938) A vs B C vs B
—————————————————————————————————————————————————————————————————————————————————————————
SCREENING
Mean Patient DIABP 49.97 50.19 50.75
BASELINE
Mean Patient DIABP 48.60 51.11 50.44
WEEK 1 DAY 8
Mean Patient DIABP 50.26 48.86 49.67 - -
Mean Diff From Baseline -0.44 -0.44 -0.44 A vs B C vs B
WEEK 2 DAY 15
Mean Patient DIABP 50.84 49.98 49.72 - -
Mean Diff From Baseline 0.13 0.13 0.13 A vs B C vs B
WEEK 3 DAY 22
Mean Patient DIABP 50.71 49.94 49.09 - -
Mean Diff From Baseline -0.13 -0.13 -0.13 A vs B C vs B
WEEK 4 DAY 29
Mean Patient DIABP 50.07 49.71 49.62 - -
Mean Diff From Baseline -0.24 -0.24 -0.24 A vs B C vs B
WEEK 5 DAY 36
Mean Patient DIABP 50.57 49.09 48.37 - -
Mean Diff From Baseline -0.70 -0.70 -0.70 A vs B C vs B
A Final Note On afun Complexity
Many production afuns, such as those provided by
tern or junco, support multiple behaviors that
are controlled user specified arguments (passed via
extra_args). This can be used to control denominator types,
choice of statistical methods, and anything else the developers wish.
This is a separate type of complexity from behavior that is conditional
on column or row structure – and in fact can be combined with such – but
it is nonetheless important to consider when choosing which
afun to use.