Skip to contents

Introduction

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:

  1. The type of observation being counted or analyzed,
  2. The (sub)population we are analyzing within or comparing against, and
  3. 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:

  1. The number of (unique) patients which suffered a particular AE, or
  2. 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:

  1. column (often trial arm)
  2. individual facet (row x column)
  3. row group (across all columns)
  4. ancestor row facet (across all columns)
  5. 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.