suggested_dependent_pkgs <- c("dplyr") knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = all(vapply( suggested_dependent_pkgs, requireNamespace, logical(1), quietly = TRUE )) )
knitr::opts_chunk$set(comment = "#")
The first - and often largest - hurdle to creating a table via
rtables is translating the desired table structure (typically in the
form of a table shell) into an rtables layout. We will cover
that translation process in this vignette.
Table shells can come in various forms. We will begin with a table shell which is essentially the entire table with desired formatting indicated instead of values:
suppressPackageStartupMessages(library(rtables)) suppressPackageStartupMessages(library(dplyr)) fixed_shell <- function(tt) { mystr <- table_shell_str(tt) regex_hits <- gregexpr("[(]N=[[:digit:]]+[)]", mystr)[[1]] hit_lens <- attr(regex_hits, "match.length") if (regex_hits[1] > 0) { for (i in seq_along(regex_hits)) { start <- regex_hits[i] len <- hit_lens[i] substr(mystr, start, start + len - 1) <- padstr("(N=xx)", len, just = "center") } } cat(mystr) } knitr::opts_chunk$set(comment = "")
adsl <- ex_adsl %>% filter(SEX %in% c("M", "F") & RACE %in% c("ASIAN", "BLACK OR AFRICAN AMERICAN", "WHITE")) %>% mutate( BMEASIFL = factor(as.character(BMEASIFL), levels = c("Y", "N"), labels = c("Yes", "No") ), SEX = factor(as.character(SEX), levels = c("M", "F", "UNDIFFERENTIATED", "U"), labels = c("Male", "Female", "Undifferentiated", "Unknown") ), RACE = factor(as.character(RACE), levels = c("ASIAN", "BLACK OR AFRICAN AMERICAN", "WHITE"), labels = c("Asian", "Black", "White") ) ) lyt <- basic_table( title = "Subject Response by Race and Sex; Treated Subjects", show_colcounts = TRUE ) %>% split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) %>% split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo"))) %>% analyze("BMEASIFL", afun = counts_wpcts, var_labels = "All Patients", show_labels = "visible") %>% split_rows_by( var = "RACE", label_pos = "topleft", split_fun = keep_split_levels(only = c("Asian", "Black", "White")) ) %>% split_rows_by( var = "SEX", label_pos = "topleft", split_fun = keep_split_levels(only = c("Male", "Female")) ) %>% summarize_row_groups( var = "SEX", format = "xx" ) %>% analyze( vars = "BMEASIFL", afun = counts_wpcts ) result <- build_table(lyt, adsl) fixed_shell(result)
We will use this shell to illustrate the translation process to an rtables layout, and thus ultimately a table output.
rtables LayoutsFor an in-depth discussion of how constructing a layout works we refer the reader to other documentation. That said, there are a couple things to remember as we consider translating shells into layouts:
analyze* callsanalyze)analyze will not be nestedWith those in mind, we will now discuss how to translate shells into layouts.
There are three aspects to a shell that we must translate:
We will explore each portion of the translation process separately.
Our first task, translating column structure, revolves around identifying faceting in the column dimension of a shell or desired table.
Our shell gives us the following to indicate column structure:
fixed_shell(result[0, ])
The easiest way to identify faceting is to look at column- or row-labels and determine the scope (i.e., the set of individual columns or rows) they apply to.
For example, we see that the "A" column label applies to a group of
multiple columns each of which represent an individual arm:
fixed_shell(result[0, c("A", "*")])
Thus we have strata faceting with arm faceting nested within it.
Faceting most commonly represents partitioning the data being
tabulated by the values of a categorical variable, though rtables
supports a generalized concept of faceting where the data group can
overlap and need not be exhaustive.
For our table, the faceting is nested faceting by the "STRATA1", and
"ARM" variables. We achieve this by repeated calls to
split_cols_by (with the default nested = TRUE), with the call declaring
the outermost faceting first:
lyt_cols <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) %>% split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo"))) build_table(lyt_cols, adsl)
This is almost correct. To fully achieve our shell we need the column
counts to show up, which we do via the show_colcounts argument in
the relevant split_cols_by call:
lyt_cols <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) %>% split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo")), show_colcounts = TRUE ) build_table(lyt_cols, adsl)
This is a relatively straightforward column structure. We will cover
more complex ones later. Nevertheless we have translated our shell's
column space into rtables layouting instructions.
Moving to the second aspect of translation, we will now translate the row structure of our shell. Interpreting row structure is similar to interpreting column structure with the caveat that individual rows do not come from faceting, but rather from analysis (which is in charge of populating the contents of the table's primary, non-marginal cells).
Our row structure is slightly less trivial than our column
structure. We can see two sections in our shell, one that displays the
response ("BMEASIFL") of all patients collectively (by the column
structure):
fixed_shell(tt_at_path(result, "BMEASIFL"))
and one that subsets the patients before displaying the response within each subset, and with some marginal rows for context.
fixed_shell(tt_at_path(result, "RACE"))
Because none of the labels or cell-values from the all patients portion of the table apply directly to the subset analysis portion - and vice versa - we can treat these separately.
In point of fact, the first portion does not require any structure beyond an analysis of the `"BMEASIFL" variable with a label, so we can leave that for the third translation step.
We can illustrate this using a dummy analyze as follows:
dummy_afun <- function(x, ...) in_rows("Analysis" = "-") lyt_a <- basic_table() |> analyze("BMEASIFL", afun = dummy_afun, var_labels = "All Patients", show_labels = "visible" ) build_table(lyt_a, adsl)
While we do not have the individual rows we desired, as that is left to step 3 of translation, we can see that we have successfully created the first portion of the row structure.
Note that in most tables the column and row structure are orthogonal and so we do not need to worry about columns when we are translating the row structure.
Also note we could say that there is a facet there which contains all the
patients and has the name/label "All Patients"; this would result in
an equivalent table from an output perspective but there isn't really
any benefit to the added layouting instructions that would be
required, so we will not do so here.
The second portion of the table contains labels and rows which do apply to multiple individual rows.
We see that the "Asian" label, for example, applies across the
corresponding "Male" and "Female" labels/marginal rows, each of
which in turn applies to a group of individual rows ("Yes", and
"No").
Thus we can recreate this section via nested faceting, this time with
lyt_b <- basic_table() |> split_rows_by("RACE") |> split_rows_by("SEX") |> analyze("BMEASIFL", afun = dummy_afun) head(build_table(lyt_b, adsl), 30)
We are almost there, but we see extra "SEX" values that weren't in
our shell. We can prevent this with the keep_split_levels function
provided by rtables:
lyt_b2 <- basic_table() |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> analyze("BMEASIFL", afun = dummy_afun) build_table(lyt_b2, adsl)
Finally, we can combine the two sections by simply combining the relevant layout instructions:
lyt_b3 <- basic_table() |> analyze("BMEASIFL", afun = dummy_afun, var_labels = "All Patients", show_labels = "visible" ) |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> analyze("BMEASIFL", afun = dummy_afun) build_table(lyt_b3, adsl)
Note here that row split instructions which directly follow an
analyze call will automatically be non-nested, so we do not need to
specify nested = FALSE in the "RACE" split, though doing so would
not harm anything.
We can convince ourselves that treating the column and row structure separately by combining the layouting instructions for both to receive something equivalent in structure (i.e., up individual rows and marginal cell contents) to our shell:
lyt_struct <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) |> split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo")), show_colcounts = TRUE ) |> analyze("BMEASIFL", afun = dummy_afun, var_labels = "All Patients", show_labels = "visible" ) |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> analyze("BMEASIFL", afun = dummy_afun) build_table(lyt_struct, adsl)
We can see that the marginal cells for "Male" and "Female" within
each race are not present, but we will handle those in the third
translation step.
Finally, we will finish our translation with the third step: translating cell contents.
Tables can contain up to two types of rows with non-empty cells as
reckoned by the rtables conceptual model: individual analysis rows,
and marginal group summary rows (called content rows by the
rtables internals).
Analysis rows are declared via analyze during layout construction;
an analysis function (the afun argument) specifying how all cells
within a single facet pane should be simultaneously created.
We see in our shell that we want two rows whenever we analyze
BMEASIFL response: one for "Yes" and one for "No".
Most analysis functions provided by rtables or extensions like
tern or junco will automatically generate multiple rows when
analyzing a categorical variable (i.e., factor):
rw_lyt <- basic_table() |> analyze("BMEASIFL", var_labels = "All Patients", show_labels = "visible" ) build_table(rw_lyt, adsl)
Further, recall that the faceting does the work of identifying subsets and applying our analyses within those facets/subsets automatically. Thus by applying the structural layout instructions we translated above, we get something that is getting pretty close to our desired table:
rw_lyt_struct <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) |> split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo")), show_colcounts = TRUE ) |> analyze("BMEASIFL", var_labels = "All Patients", show_labels = "visible" ) |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> analyze("BMEASIFL") build_table(rw_lyt_struct, adsl)
Two aspects remain before we have matched our desired shell: our
marginal counts in the the individual gender rows within each race are
missing, and our analysis rows contain only counts rather than
matching the desired "xx (xx.x%)" format of count and percent.
rtables provides a (very) simple afun to calculate count percent
values (counts_wpcts) which we can use for illustration purposes
here. We will see later that it is not flexible enough to meet a
study team's full set of needs and more complex afuns will be used in
practice in production.
rw_lyt_structb <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) |> split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo")), show_colcounts = TRUE ) |> analyze("BMEASIFL", afun = counts_wpcts, var_labels = "All Patients", show_labels = "visible" ) |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> analyze("BMEASIFL", afun = counts_wpcts) build_table(rw_lyt_structb, adsl)
Now, all we need is the marginal gender counts. We do this by adding
summarize_row_groups directly after the relevant row faceting
(split_rows_by) instruction in the layout. This function can accept
a fully custom function (the cfun argument), but for our purposes,
we can control whether the percent is included in the default group
summary with the format argument.
lyt_final <- basic_table() |> split_cols_by("STRATA1", split_fun = keep_split_levels(only = c("A", "B"))) |> split_cols_by("ARM", split_fun = keep_split_levels(only = c("A: Drug X", "B: Placebo")), show_colcounts = TRUE ) |> analyze("BMEASIFL", afun = counts_wpcts, var_labels = "All Patients", show_labels = "visible" ) |> split_rows_by("RACE") |> split_rows_by("SEX", split_fun = keep_split_levels(only = c("Male", "Female"))) |> summarize_row_groups(format = "xx") |> analyze("BMEASIFL", afun = counts_wpcts) build_table(lyt_final, adsl)
Thus, we have fully translated our shell into an rtables declarative
layout and realized our desired table output.
In the remainder of this vignette we will walk through a number of
shells with more complex structural elements and how to translate them
into rtables layouts.
Some shells will call for spanning labels in column space which do not directly reflect a categorical variable in the raw data, but rather represent groups of levels in a variable, e.g., trial arms.
For example, we might have the following column structure in a shell:
span_map <- data.frame( ARM = c("A: Drug X", "B: Placebo", "C: Combination"), span_label = c("Active Treatment", " ", "Active Treatment") ) lyt_span <- basic_table() |> split_cols_by("span_label", split_fun = trim_levels_to_map(span_map)) |> split_cols_by("ARM", show_colcounts = TRUE) adsl2 <- adsl adsl2$span_label <- "Active Treatment" adsl2$span_label[adsl2$ARM == "B: Placebo"] <- " " tbl_colspans <- build_table(lyt_span, adsl2) fixed_shell(tbl_colspans)
Here we see the "Active Treatment" label spanning arms A and C, while no label appears above the column for arm B. There are a couple things to decode here that will collapse this column structure into a nested faceting structure as we saw above.
Most importantly, while uneven splitting is possible with rtables,
including in column space, we can get our desired output by allowing
the B arm to have an invisible spanning label which is simply a single
space (" "). Viewing the structure this way, we can see that we have
two levels of faceting, one which splits between so called active
treatments and the remaining arms, and within that, we facet on
individual arm.
This brings us to our second issue: we don't have a variable for active vs non-active treatments. There are a few ways to address this, but the most user-friendly way is simply to create one as a preprocessing step on the data before we make our table:
adsl_forspans <- adsl adsl_forspans$span_label <- "Active Treatment" adsl_forspans$span_label[adsl_forspans$ARM == "B: Placebo"] <- " " qtable(adsl_forspans, "ARM", "span_label")
With that we can build a table with the desired nested splitting:
lyt_cspan <- basic_table() |> split_cols_by("span_label") |> split_cols_by("ARM", show_colcounts = TRUE) build_table(lyt_cspan, adsl_forspans)
So we are getting close, but our individual arm columns are not only showing up under their correct spanning label (though we see that the data are being siphoned under the correct labels by the column counts).
This type of non-full-factorial nesting is common; we often only want facets that make logical sense within a nested faceting structure, while wanting to omit any that don't (e.g., in our table, the Active Treatment - Placebo facet).
rtables provides multiple ways to declare this behavior in the form
of both full split functions and split function behavior building
blocks, the latter being for use within make_split_fun. For now, we
will use a built-in full split function as we will be covering
make_split_fun in a different vignette.
Our two options for split functions are trim_levels_in_group and
trim_levels_to_map; the former is empirical and will keep all
combinations which are observed in the data, omitting any that
aren't. The latter requires us to provide a map of all combinations to
be displayed, but is more robust to sparse data (e.g., a data snapshot
from an in-flight trial) and allows for displaying zero counts for
unobserved but desired combinations.
Other than being empirical and declarative, respectively,
trim_levels_in_group and trim_levels_to_map behave similarly: when
used while splitting on a variable (the "outer variable"), the
observations and factor levels of of another ("inner") variable are
restricted independently within each facet for the outer variable.
In our case, our outer variable is "span_label", while our inner
variable would be "ARM". Thus we want to restrict the levels of
"ARM" within each facet of "span_label". For our toy example here,
the two split functions will be equivalent, but we will use
trim_levels_to_map as it is more robust and appropriate for more
cases of production use.
Thus we need to create our map, a data.frame that contains the two
variables with each desired combination as a separate row:
span_label_map <- tribble( ~span_label, ~ARM, "Active Treatment", "A: Drug X", "Active Treatment", "C: Combination", " ", "B: Placebo", ) lyt_cspan_final <- basic_table() |> split_cols_by("span_label", split_fun = trim_levels_to_map(span_label_map) ) |> split_cols_by("ARM", show_colcounts = TRUE) build_table(lyt_cspan_final, adsl_forspans)
Thus we have again achieved a "table" matching our desired shell. We can consider only the column structure because in this case as previously the column structure, row structure, and analysis are all orthogonal. We will see an example where that isn't fully the case below
Note: in the general case, the level map used in
trim_levels_to_map will be a function of the data dictionaries for
the relevant variables within your study, thus for combinations of
actual variables these maps should not require manual construction as
we did above.
In our previous examples, the column structure was simple nested faceting, both in the case of faceting on two variables from the data, and in the case we wanted spanning labels.
While this simple nesting structure is relatively common, particularly for column structure, it does not fit the shells for all tables we might need to create. One example of this is risk difference columns, as found in modern FDA guidance for Adverse Event (AE) tables.
In this section we will translate a shell with both spanning headers
and risk difference columns into a layout. To avoid subtleties about
counting we will analyze the BMRKR2 variable in our synthetic
ADSL dataset rather than going for a realistic AE table. These
counting issues and realistic AE tables will be addressed elsewhere in
this series of vignettes.
Many tables call for "risk difference", or comparison columns, in addition to those used for the primary counts. When combined with spanning labels, the column structure of our shell would look something like:
adsl2$rr_header <- "Risk Differences" adsl2$rr_label <- paste(adsl2$ARM, "vs B: Placebo") lyt_rr <- basic_table() |> split_cols_by("span_label", split_fun = trim_levels_to_map(span_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")) tbl_rr_shell <- build_table(lyt_rr, adsl2) fixed_shell(tbl_rr_shell)
We see that the first portion of the column structure is the same, but we now have the risk difference structure in addition. There are a number of different ways to model risk difference columns but we will do so as a separate nested substructure. Thus as we did with the "Active Treatment" spanning label, we will create and then facet on a variable that gives us the "Risk Differences" label.
We can build up this substructure separately and then combine it with the structure we created above to match the full shell.
adsl_rr <- adsl_forspans adsl_rr$rr_header <- "Risk Differences" lyt_only_rr <- basic_table() |> split_cols_by("rr_header") |> split_cols_by("ARM") build_table(lyt_only_rr, adsl_rr)
This is getting close there are two issues: first, we don't want a placebo column (which would nonsensically compare placebo against itself), and the labels are simply the individual arms rather than the pair of arms being compared as in our shell.
We can restrict the facets generated using the remove_split_levels
(or sibling keep_split_levels) split function provided by
rtables. In addition the split_*_by functions accept the
labels_var argument which specifies an additional variable which
should be used for the labels (not names) of the facets
generated. With preprocessing to create such a variable, and combining
these two approaches, we can achieve the risk difference structure:
adsl_rr$rr_label <- paste(adsl_rr$ARM, "vs B: Placebo") lyt_only_rr2 <- basic_table() |> split_cols_by("rr_header") |> split_cols_by("ARM", split_fun = remove_split_levels("B: Placebo"), labels_var = "rr_label" ) build_table(lyt_only_rr2, adsl_rr)
To combine our two sections of column structure, we simply combine the
sets of layouting instructions and add nested = FALSE to our split
on "rr_header":
lyt_rr_cols <- 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", split_fun = remove_split_levels("B: Placebo"), labels_var = "rr_label" ) build_table(lyt_rr_cols, adsl_rr)
Note that because we used show_colcounts in our split_cols_by call
for "ARM", rather than in build_table, we have counts for our main
arm columns but not for our comparison columns, as desired.
One caveat here, however, is that we will need a more sophisticated analysis function because its behavior is no longer independent of which facet it is in: it might generate e.g., counts for the primary arm columns and then confidence intervals for our risk difference columns.
Typically trial teams will be using pre-existing analysis functions for this, but we will illustrate these can be constructed now.
Our analysis function needs two "modes": the primary arm column mode and the risk difference mode, and it needs to be able to distinguish between them.
Analysis (and content, i.e., row group summary) functions can accept
the optional .spl_context argument to receive information where in
the faceting structure the facet they are currently populating is. We
will leave a detailed discussion of the full contents of the split
context to other documentation and simply use the portions we need
here.
In particular, we will use the cur_col_id column of .spl_context
to determine which section of the column structure we are under. Note
that due to the vagaries of the current implementation, this is
constructed of the labels for the column facets rather than their
names. This is the split/value pairs of each column split in order
concatenated together, so it suffices to define
in_risk_diff <- function(spl_context) grepl("Risk Differences", spl_context$cur_col_id[1])
For simplicity, we will not worry about calculating risk differences here, and simply write an analysis function that emits something different to show that it can tell it is in "risk difference mode".
Thus a very simplistic afun is as follows:
rr_afun <- function(x, .N_col, .spl_context) { xtbl <- table(x) if (in_risk_diff(.spl_context)) { armlabel <- tail(.spl_context$cur_col_split_val[[1]], 1) # last split value, ie arm armletter <- substr(armlabel, 1, 1) vals <- as.list(rep(paste(armletter, "vs B"), length(xtbl))) fmts <- rep("xx", length(xtbl)) } else { vals <- lapply(xtbl, function(x) x * c(1, 1 / .N_col)) ## count and pct fmts <- rep("xx.x (xx.x%)", length(xtbl)) } names(vals) <- names(xtbl) names(fmts) <- names(vals) in_rows(.list = vals, .formats = fmts) }
With this we can create a table. We will analyze BMRKR2 (biomarker
2) for the sake of brevity. This is an oversimplifaction, as typically
this would be, e.g., AEDECOD in an adae dataset, but this requires
more sophisticated calculation of counts and/or percents that is
important but not germane to this specific issue.
lyt_rr_full <- 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", split_fun = remove_split_levels("B: Placebo"), labels_var = "rr_label" ) |> analyze("BMRKR2", afun = rr_afun) build_table(lyt_rr_full, adsl_rr)
The blank space above the column counts is a known issue which we expect to be resolved in a future release due to the fact that the header construction/wrapping behavior is not accounting for the fact that the two sections of the column structure are independent.
Note that while our analysis function was dependent on where in the column structure we are, it remains independent of where in the row faceting structure we are. Thus we can use our analysis function within row faceting without changes:
lyt_rr_full2 <- 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", split_fun = remove_split_levels("B: Placebo"), labels_var = "rr_label" ) |> split_rows_by("STRATA1") |> split_rows_by("SEX", split_fun = keep_split_levels(c("Female", "Male"))) |> analyze("BMRKR2", afun = rr_afun) tbl <- build_table(lyt_rr_full2, adsl_rr) cwidths <- propose_column_widths(tbl) cwidths[cwidths > 15] <- 15 cat(export_as_txt(tbl, colwidths = cwidths)) ## for wrapping
A more complete exploration of creating production ready analysis functions will be presented elsewhere in this vignette series.
In practice, the row structure in most shells can be translated to a layout using combinations of the methods shown above. Some shells, however, essentially call for group summaries for all levels of a categorical variable, but additionally call for analysis within those groups for only some levels of the variable.
In clinical trial outputs we have seen this most commonly in disposition tables, the shells of which might look something like:
simple_two_tier_init <- function(df, .var, .N_col, inner_var, drill_down_levs) { outer_tbl <- table(df[[.var]]) cells <- lapply( names(outer_tbl), function(nm) { cont_cell <- rcell(outer_tbl[nm] * c(1, 1 / .N_col), format = "xx (xx.x%)") if (nm %in% drill_down_levs) { inner_tbl <- table(df[[inner_var]]) detail_cells <- lapply( names(inner_tbl), function(innm) rcell(inner_tbl[innm] * c(1, 1 / .N_col), format = "xx (xx.x%)", indent_mod = 1L) ) names(detail_cells) <- names(inner_tbl) } else { detail_cells <- NULL } c(setNames(list(cont_cell), nm), detail_cells) } ) in_rows(.list = unlist(cells, recursive = FALSE)) } two_tier_shell_lyt <- basic_table() |> split_cols_by("span_label", split_fun = trim_levels_to_map(span_label_map) ) |> split_cols_by("ARM", show_colcounts = TRUE) |> split_rows_by("RACE", split_fun = keep_split_levels(c("Asian", "Black"))) |> analyze("EOSSTT", afun = simple_two_tier_init, extra_args = list( inner_var = "DCSREAS", drill_down_levs = "DISCONTINUED" ) ) two_tier_shell <- build_table(two_tier_shell_lyt, adsl_rr) ## don't need the rr bits fixed_shell(two_tier_shell)
In this shell, the COMPLETED, DISCONTINUED and ONGOING rows are
siblings (derived from the EOSSTT variable), however only the
DISCONTINUED row acts as a group summary row for a facet containing
further analysis; the other two essentially act as individual rows.
This type of structure where individual analysis rows and facets/group
summary rows are direct siblings is not currently supported by the
rtables layouting and tabulation engines, and is somewhat
supported when created via, e.g., trimming rows of a created table.
The above said, we can arrive at a table which renders as desired using the two-tier analysis function strategy.
The key to the two-tier analysis function strategy is to generate both levels of row in the same analysis function and simply use indent modifiers to differentiate them.
Below is a simple afun that implements this strategy. For the
purposes of this lesson readers can ignore the details of what this
function does if desired; analysis function design and implementation
will be covered in another vignette in the advanced section.
simple_two_tier <- function(df, .var, .N_col, inner_var, drill_down_levs) { ## group EOSSTT counts outer_tbl <- table(df[[.var]]) cells <- lapply( names(outer_tbl), function(nm) { ## simulated group summary rows cont_cell <- rcell(outer_tbl[nm] * c(1, 1 / .N_col), format = "xx (xx.x%)" ) if (nm %in% drill_down_levs) { ## detail (DCSREAS) counts inner_tbl <- table(df[[inner_var]]) ## note indent_mod detail_cells <- lapply( names(inner_tbl), function(innm) { rcell(inner_tbl[innm] * c(1, 1 / .N_col), format = "xx (xx.x%)", ## appearance of "detail drill-down" indent_mod = 1L ) } ) names(detail_cells) <- names(inner_tbl) } else { detail_cells <- NULL } c(setNames(list(cont_cell), nm), detail_cells) } ) in_rows(.list = unlist(cells, recursive = FALSE)) } lyt_two_tier <- basic_table() |> analyze("EOSSTT", afun = simple_two_tier, extra_args = list(inner_var = "DCSREAS", drill_down_levs = "DISCONTINUED") ) build_table(lyt_two_tier, adsl_rr)
As in other cases, we can add the row- and column- structure orthogonally (provided the analysis behavior is truly orthogonal to the faceting, as it is in this shell):
lyt_two_tier_full <- basic_table() |> split_cols_by("span_label", split_fun = trim_levels_to_map(span_label_map) ) |> split_cols_by("ARM", show_colcounts = TRUE) |> split_rows_by("RACE", split_fun = keep_split_levels(c("Asian", "Black"))) |> analyze("EOSSTT", afun = simple_two_tier, extra_args = list(inner_var = "DCSREAS", drill_down_levs = "DISCONTINUED") ) build_table(lyt_two_tier_full, adsl_rr)
Thus we have created our desired output.
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.