A display shell is the mocked-up table a statistician signs off on: titles, footnotes, the columns, the rows, the statistics in each cell. Normally a programmer reads it and writes the code by hand, and from that moment the shell and the program are two documents that have to be kept in step.

shell2tlf makes the shell the only document. The code, the table and the RTF are all derived from it.

1. The shell

path <- system.file("extdata", "demographics.yaml", package = "shell2tlf")
cat(readLines(path)[1:30], sep = "\n")
#> meta:
#>   id: t_14_1_1
#>   study: SHELLPILOT01
#>   display: summary
#> 
#> title: "Table 14.1.1"
#> subtitles:
#>   - "Summary of Demographic and Baseline Characteristics"
#>   - "Safety Population"
#> 
#> footnotes:
#>   - "Percentages are based on the number of subjects in the safety population."
#>   - "This table was generated from the shell demographics.yaml. Change the shell, not the program."
#> 
#> source: "Source: inst/extdata/adsl_demo.csv"
#> 
#> population:
#>   label: Safety Population
#>   dataset: adsl
#>   filter: SAFFL == "Y"
#> 
#> columns:
#>   var: TRT01A
#>   levels: ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
#>   spanning:
#>     var: TRTGRP
#>     levels: ["Placebo", "Xanomeline"]
#>   total: "All Subjects"
#>   show_counts: true

read_shell() parses and validates it. Structural problems – a missing title, an unknown statistic, a nested row in the wrong place – are all reported at once rather than one per run.

shell <- read_shell(path)
shell
#> 
#> ── shell2tlf display shell ─────────────────────────────────────────────────────
#>  Table 14.1.1
#>    Summary of Demographic and Baseline Characteristics
#>    Safety Population
#>  Dataset: adsl | population: Safety Population (SAFFL == "Y")
#>  Columns: TRTGRP > TRT01A + All Subjects
#> 
#> ── Rows ──
#> 
#>  [summary] AGE - Age (years) (n, mean_sd, median, q1_q3, min_max)
#>  [count] AGEGR1 - Age group (years) (n_pct)
#>  [count] SEX - Sex (n_pct)
#>  [count] RACE - Race (n_pct)
#>  [summary] BMIBL - Baseline BMI (kg/m2) (n, mean_sd, median, min_max)
#>  Footnotes: 2 | rows per page: 26 (portrait)

2. Check it against the data before running it

validate_shell() is the dry run. It never throws for a data problem; it hands back every problem it found, so one pass tells you everything to fix.

adsl <- utils::read.csv(system.file("extdata", "adsl_demo.csv",
                                    package = "shell2tlf"))
validate_shell(shell, adsl)
#> # A tibble: 0 × 4
#> # ℹ 4 variables: location <chr>, variable <chr>, severity <chr>, message <chr>

Zero rows means the shell is ready. Break it and see what comes back:

broken <- shell
broken$rows[[1]]$var <- "AGEX"          # variable is not in ADSL
broken$rows[[3]]$type <- "summary"      # a mean of SEX
broken$rows[[3]]$stats <- "mean_sd"
validate_shell(broken, adsl)
#> # A tibble: 2 × 4
#>   location  variable severity message                                           
#>   <chr>     <chr>    <chr>    <chr>                                             
#> 1 rows[[1]] AGEX     error    Variable 'AGEX' is not in 'adsl'.                 
#> 2 rows[[3]] SEX      error    Summary statistics (mean_sd) requested for 'SEX',…

This is the check a SAS programmer does by eye against PROC CONTENTS output.

3. Generate the code

shell_code() is the function that earns the package. It emits the program a reviewer reads and a programmer edits.

code <- shell_code(shell)
code
#> ── shell2tlf generated rtables code ────────────────────────────────────────────
#> # --------------------------------------------------------------------------
#> # Table 14.1.1
#> # Summary of Demographic and Baseline Characteristics
#> # Safety Population
#> #
#> # Population: Safety Population
#> # Generated by shell2tlf from shell: demographics.yaml
#> # Edit the shell and regenerate; do not edit this file by hand.
#> # --------------------------------------------------------------------------
#> 
#> library(rtables)
#> 
#> # Population: Safety Population
#> adsl <- dplyr::filter(adsl, SAFFL == "Y")
#> 
#> # Fix the level sets so every column shows the same rows in the same order.
#> adsl <- dplyr::mutate(
#>   adsl,
#>   TRTGRP = factor(TRTGRP, levels = c("Placebo", "Xanomeline")),
#>   TRT01A = factor(TRT01A, levels = c("Placebo", "Xanomeline Low Dose", "Xanomeline High Dose")),
#>   AGEGR1 = factor(AGEGR1, levels = c("<65", "65-80", ">80")),
#>   SEX    = factor(SEX, levels = c("F", "M")),
#>   RACE   = factor(RACE, levels = c("AMERICAN INDIAN OR ALASKA NATIVE", "BLACK OR AFRICAN AMERICAN", "WHITE"))
#> )
#> 
#> lyt <- basic_table(show_colcounts = TRUE) |>
#>   split_cols_by("TRTGRP") |>
#>   split_cols_by("TRT01A", split_fun = drop_split_levels) |>
#>   add_overall_col("All Subjects") |>
#>   analyze("AGE", var_labels = "Age (years)", show_labels = "visible",
#>     afun = function(x, .N_col) {
#>       in_rows(
#>         "n"         = rcell(sum(!is.na(x)), format = "xx"),
#>         "Mean (SD)" = rcell(c(mean(x, na.rm = TRUE), stats::sd(x, na.rm = TRUE)), format = "xx.x (xx.xx)"),
#>         "Median"    = rcell(stats::median(x, na.rm = TRUE), format = "xx.x"),
#>         "Q1, Q3"    = rcell(unname(stats::quantile(x, c(0.25, 0.75), na.rm = TRUE)), format = "xx.x, xx.x"),
#>         "Min, Max"  = rcell(c(min(x, na.rm = TRUE), max(x, na.rm = TRUE)), format = "xx.x, xx.x")
#>       )
#>     }) |>
#>   analyze("AGEGR1", var_labels = "Age group (years)", show_labels = "visible",
#>     afun = function(x, .N_col) {
#>       counts <- table(x)
#>       in_rows(
#>         .list   = lapply(counts, function(k) rcell(c(k, k / .N_col), format = "xx (xx.x%)")),
#>         .labels = names(counts)
#>       )
#>     }) |>
#>   analyze("SEX", var_labels = "Sex", show_labels = "visible",
#>     afun = function(x, .N_col) {
#>       counts <- table(x)
#>       in_rows(
#>         .list   = lapply(counts, function(k) rcell(c(k, k / .N_col), format = "xx (xx.x%)")),
#>         .labels = names(counts)
#>       )
#>     }) |>
#>   analyze("RACE", var_labels = "Race", show_labels = "visible",
#>     afun = function(x, .N_col) {
#>       counts <- table(x)
#>       in_rows(
#>         .list   = lapply(counts, function(k) rcell(c(k, k / .N_col), format = "xx (xx.x%)")),
#>         .labels = names(counts)
#>       )
#>     }) |>
#>   analyze("BMIBL", var_labels = "Baseline BMI (kg/m2)", show_labels = "visible",
#>     afun = function(x, .N_col) {
#>       in_rows(
#>         "n"         = rcell(sum(!is.na(x)), format = "xx"),
#>         "Mean (SD)" = rcell(c(mean(x, na.rm = TRUE), stats::sd(x, na.rm = TRUE)), format = "xx.x (xx.xx)"),
#>         "Median"    = rcell(stats::median(x, na.rm = TRUE), format = "xx.x"),
#>         "Min, Max"  = rcell(c(min(x, na.rm = TRUE), max(x, na.rm = TRUE)), format = "xx.x, xx.x")
#>       )
#>     })
#> 
#> tbl <- build_table(lyt, adsl)
#> 
#> tbl
#> ────────────────────────────────────────────────────────────────────────────────

Everything visible in the shell is visible in the code: the population filter, the level sets that keep all treatment columns showing the same rows, the spanning header as two nested column splits, and one layout step per shell row. Save it as a program if you want one:

writeLines(code, file.path(tempdir(), "t_14_1_1.R"))

4. Build the table

build_tlf() evaluates exactly the text above. There is no parallel implementation to fall out of step with the printed code.

tbl <- build_tlf(shell, adsl)
tbl
#>                                        Placebo                     Xanomeline                               
#>                                        Placebo     Xanomeline Low Dose   Xanomeline High Dose   All Subjects
#>                                        (N=59)            (N=60)                 (N=59)            (N=178)   
#> ————————————————————————————————————————————————————————————————————————————————————————————————————————————
#> Age (years)                                                                                                 
#>   n                                      59                60                     59                178     
#>   Mean (SD)                          75.5 (8.14)       74.1 (9.61)           75.0 (7.81)        74.9 (8.53) 
#>   Median                                75.0              74.0                   75.0               75.0    
#>   Q1, Q3                             69.0, 81.0        68.0, 80.0             70.0, 79.5         69.0, 80.0 
#>   Min, Max                           60.0, 92.0        52.0, 92.0             57.0, 92.0         52.0, 92.0 
#> Age group (years)                                                                                           
#>   <65                                 7 (11.9%)         8 (13.3%)              4 (6.8%)          19 (10.7%) 
#>   65-80                              37 (62.7%)        38 (63.3%)             41 (69.5%)        116 (65.2%) 
#>   >80                                15 (25.4%)        14 (23.3%)             14 (23.7%)         43 (24.2%) 
#> Sex                                                                                                         
#>   F                                  34 (57.6%)        26 (43.3%)             28 (47.5%)         88 (49.4%) 
#>   M                                  25 (42.4%)        34 (56.7%)             31 (52.5%)         90 (50.6%) 
#> Race                                                                                                        
#>   AMERICAN INDIAN OR ALASKA NATIVE    4 (6.8%)          1 (1.7%)               2 (3.4%)           7 (3.9%)  
#>   BLACK OR AFRICAN AMERICAN           7 (11.9%)         6 (10.0%)             7 (11.9%)          20 (11.2%) 
#>   WHITE                              48 (81.4%)        53 (88.3%)             50 (84.7%)        151 (84.8%) 
#> Baseline BMI (kg/m2)                                                                                        
#>   n                                      59                60                     59                178     
#>   Mean (SD)                          26.4 (4.43)       25.3 (5.71)           26.8 (4.72)        26.2 (5.00) 
#>   Median                                25.6              24.2                   26.4               25.4    
#>   Min, Max                           15.0, 36.9        13.5, 40.0             17.0, 37.5         13.5, 40.0

The result is a plain rtables TableTree, so anything in the pharmaverse that already accepts one still works.

5. Render to RTF

Titles, subtitles, footnotes, the source line, page orientation and rows-per-page all come out of the shell, so the delivered RTF cannot disagree with the document that was signed off.

out <- render_tlf(tbl, shell, file.path(tempdir(), "t_14_1_1.rtf"))
out
#> 
#> ── shell2tlf RTF output ────────────────────────────────────────────────────────
#>  Table 14.1.1
#>  /tmp/RtmpFZJv4s/t_14_1_1.rtf
#>  22 body rows x 5 columns | 3 header row(s) | 2 footnote(s)
#>  26 rows per page, portrait

Change a footnote in the shell and re-render; nothing else moves.

shell$footnotes <- c(shell$footnotes, "Amendment 2: BMI collected at screening.")
render_tlf(tbl, shell, file.path(tempdir(), "t_14_1_1_amended.rtf"))
#> 
#> ── shell2tlf RTF output ────────────────────────────────────────────────────────
#>  Table 14.1.1
#>  /tmp/RtmpFZJv4s/t_14_1_1_amended.rtf
#>  22 body rows x 5 columns | 3 header row(s) | 3 footnote(s)
#>  26 rows per page, portrait

Hierarchies: the adverse-event table

The second bundled shell nests preferred term inside system organ class, counts each subject once (unique_by: USUBJID), takes its denominator from ADSL, and sorts both levels by overall frequency.

ae_shell <- read_shell(system.file("extdata", "adverse_events.yaml",
                                   package = "shell2tlf"))
adae <- utils::read.csv(system.file("extdata", "adae_demo.csv",
                                    package = "shell2tlf"))
ae_tbl <- build_tlf(ae_shell, list(adae = adae, adsl = adsl))
ae_tbl
#> System Organ Class                        Placebo     Xanomeline Low Dose   Xanomeline High Dose   All Subjects
#>   Preferred Term                           (N=59)           (N=60)                 (N=59)            (N=178)   
#> ———————————————————————————————————————————————————————————————————————————————————————————————————————————————
#> NERVOUS SYSTEM DISORDERS                 23 (39.0%)       31 (51.7%)             27 (45.8%)         81 (45.5%) 
#>   HEADACHE                               7 (11.9%)        17 (28.3%)             13 (22.0%)         37 (20.8%) 
#>   SOMNOLENCE                             12 (20.3%)       13 (21.7%)             11 (18.6%)         36 (20.2%) 
#>   DIZZINESS                              7 (11.9%)        11 (18.3%)             15 (25.4%)         33 (18.5%) 
#> SKIN AND SUBCUTANEOUS TISSUE DISORDERS   15 (25.4%)       34 (56.7%)             22 (37.3%)         71 (39.9%) 
#>   PRURITUS                               7 (11.9%)        19 (31.7%)             9 (15.3%)          35 (19.7%) 
#>   ERYTHEMA                               6 (10.2%)        17 (28.3%)             10 (16.9%)         33 (18.5%) 
#>   RASH                                    5 (8.5%)         6 (10.0%)             7 (11.9%)          18 (10.1%) 
#> GASTROINTESTINAL DISORDERS               14 (23.7%)       28 (46.7%)             23 (39.0%)         65 (36.5%) 
#>   NAUSEA                                  5 (8.5%)        11 (18.3%)             12 (20.3%)         28 (15.7%) 
#>   DIARRHOEA                              6 (10.2%)        12 (20.0%)             6 (10.2%)          24 (13.5%) 
#>   VOMITING                                5 (8.5%)        10 (16.7%)             8 (13.6%)          23 (12.9%) 
#> CARDIAC DISORDERS                        6 (10.2%)         9 (15.0%)             13 (22.0%)         28 (15.7%) 
#>   PALPITATIONS                            1 (1.7%)         5 (8.3%)              8 (13.6%)          14 (7.9%)  
#>   ATRIAL FIBRILLATION                     3 (5.1%)         5 (8.3%)               4 (6.8%)          12 (6.7%)  
#>   SINUS BRADYCARDIA                       2 (3.4%)         1 (1.7%)               3 (5.1%)           6 (3.4%)

Because the shell names two datasets – adae for the events and adsl for the denominator – build_tlf() takes a named list rather than a single data frame.

The generated code shows why the rows line up across columns: a nested count row becomes a row split, whose levels are derived from all of the data in a facet, not from one column at a time.

writeLines(tail(unclass(shell_code(ae_shell)), 14))
#>   split_rows_by("AEDECOD", split_fun = drop_split_levels, label_pos = "topleft", split_label = "Preferred Term") |>
#>   summarize_row_groups(cfun = function(df, labelstr, .N_col) {
#>     k <- length(unique(df[["USUBJID"]]))
#>     in_rows(rcell(c(k, k / .N_col), format = "xx (xx.x%)"), .labels = labelstr)
#>   })
#> 
#> # alt_counts_df = adsl so percentages use subjects, not records.
#> tbl <- build_table(lyt, adae, alt_counts_df = adsl)
#> 
#> # Present each level in decreasing order of overall frequency.
#> tbl <- sort_at_path(tbl, path = "AEBODSYS", scorefun = cont_n_allcols)
#> tbl <- sort_at_path(tbl, path = c("AEBODSYS", "*", "AEDECOD"), scorefun = cont_n_allcols)
#> 
#> tbl

Shift tables: say which denominator

A shift table cross-tabulates a baseline category against a post-baseline one. Everything about it is easy except the denominator, so the shell states it and the package never infers it.

shift_shell <- read_shell(system.file("extdata", "lab_shift.yaml",
                                      package = "shell2tlf"))
adlb <- utils::read.csv(system.file("extdata", "adlb_demo.csv",
                                    package = "shell2tlf"))
shift_tbl <- build_tlf(shift_shell, adlb)
shift_tbl
#>             Placebo     Xanomeline Low Dose   Xanomeline High Dose   All Subjects
#> Baseline     (N=83)           (N=75)                 (N=72)            (N=230)   
#> —————————————————————————————————————————————————————————————————————————————————
#> LOW            4                 1                     1                  6      
#>   LOW       0 (0.0%)        1 (100.0%)              0 (0.0%)          1 (16.7%)  
#>   NORMAL   4 (100.0%)        0 (0.0%)              1 (100.0%)         5 (83.3%)  
#>   HIGH      0 (0.0%)         0 (0.0%)               0 (0.0%)           0 (0.0%)  
#> NORMAL         74               70                     70                214     
#>   LOW       0 (0.0%)         0 (0.0%)               0 (0.0%)           0 (0.0%)  
#>   NORMAL   70 (94.6%)       66 (94.3%)             66 (94.3%)        202 (94.4%) 
#>   HIGH      4 (5.4%)         4 (5.7%)               4 (5.7%)          12 (5.6%)  
#> HIGH           5                 3                     1                  9      
#>   LOW       0 (0.0%)         0 (0.0%)               0 (0.0%)           0 (0.0%)  
#>   NORMAL   1 (20.0%)         0 (0.0%)               0 (0.0%)          1 (11.1%)  
#>   HIGH     4 (80.0%)        3 (100.0%)             1 (100.0%)         8 (88.9%)

denominator: baseline_row means each percentage is of the subjects in the same baseline category and the same column, so each baseline block sums to 100%. The count printed beside each baseline category is that denominator – Placebo, baseline NORMAL: 74 subjects, of whom 70 (94.6%) stayed normal. The alternative, denominator: column, divides by the column N instead and makes the whole table sum to 100%.

The generated code names the population in a comment that travels with the program:

grep("Denominator|denom ", unclass(shell_code(shift_shell)), value = TRUE)
#> [1] "      # Denominator: distinct USUBJID in this baseline category and this"    
#> [2] "      denom  <- length(unique(df[[\"USUBJID\"]][which(!is.na(df[[.var]]))]))"

The other half of the denominator question is what was left out. A record whose baseline or post-baseline value is not one of the declared levels leaves the numerator and the denominator, which is why the column header can read N=230 while the baseline blocks account for 229:

validate_shell(shift_shell, adlb)
#> # A tibble: 1 × 4
#>   location  variable severity message                                           
#>   <chr>     <chr>    <chr>    <chr>                                             
#> 1 rows[[1]] BNRIND   warning  1 of 230 records have a 'BNRIND' value outside th…

Listings: every record, on purpose

A listing has no statistics to compute, and the mistake to avoid is the opposite one – combining records that should have stayed apart.

lst_shell <- read_shell(system.file("extdata", "ae_listing.yaml",
                                    package = "shell2tlf"))
lst_tbl <- build_tlf(lst_shell, adae)
c(records = sum(adae$SAFFL == "Y" & adae$TRTEMFL == "Y"),
  subjects = length(unique(adae$USUBJID[adae$SAFFL == "Y" & adae$TRTEMFL == "Y"])),
  rows = nrow(lst_tbl))
#>  records subjects     rows 
#>      335      144      335

335 records from 144 subjects give 335 rows. The generated program has no aggregation step at all, so there is nothing that could collapse a repeated key:

writeLines(tail(unclass(shell_code(lst_shell)), 12))
#>   "System Organ Class" = as.character(adae[["AEBODSYS"]]),
#>   "Preferred Term"     = as.character(adae[["AEDECOD"]]),
#>   "Severity"           = as.character(adae[["AESEV"]]),
#>   check.names = FALSE,
#>   stringsAsFactors = FALSE
#> )
#> 
#> # Row labels are the record number, so the listing shows on its own face
#> # that nrow(lst) records became nrow(lst) rows -- no de-duplication.
#> tbl <- df_to_tt(lst)
#> 
#> tbl
head(lst_tbl, 6)
#>         Subject        Treatment             System Organ Class              Preferred Term     Severity
#> ————————————————————————————————————————————————————————————————————————————————————————————————————————
#> 1   SHELLPILOT01-001    Placebo           NERVOUS SYSTEM DISORDERS              DIZZINESS         MILD  
#> 2   SHELLPILOT01-002    Placebo           NERVOUS SYSTEM DISORDERS             SOMNOLENCE         MILD  
#> 3   SHELLPILOT01-003    Placebo    SKIN AND SUBCUTANEOUS TISSUE DISORDERS         RASH            MILD  
#> 4   SHELLPILOT01-005    Placebo    SKIN AND SUBCUTANEOUS TISSUE DISORDERS       PRURITUS          MILD  
#> 5   SHELLPILOT01-006    Placebo              CARDIAC DISORDERS              SINUS BRADYCARDIA     MILD  
#> 6   SHELLPILOT01-008    Placebo          GASTROINTESTINAL DISORDERS             DIARRHOEA         MILD

It is an ordinary rtables table, so render_tlf() handles it with no listing branch:

render_tlf(lst_tbl, lst_shell, file.path(tempdir(), "l_16_2_7_1.rtf"))
#> 
#> ── shell2tlf RTF output ────────────────────────────────────────────────────────
#>  Listing 16.2.7.1
#>  /tmp/RtmpFZJv4s/l_16_2_7_1.rtf
#>  335 body rows x 6 columns | 1 header row(s) | 2 footnote(s)
#>  20 rows per page, landscape

Scope

This version supports four display types, done properly:

  • summaryn, mean, sd, mean_sd, median, q1_q3, min_max, missing for a numeric variable;
  • countn_pct or n per level of a categorical variable, optionally counting distinct subjects and nested into a hierarchy;
  • shift – baseline category against post-baseline category, n_pct or n, with the denominator stated in the shell;
  • listing – one row per record, never de-duplicated.

Out of scope, and deliberately so:

  • figures – nothing here produces or renders a plot;
  • inferential columns – no p-values, treatment differences, confidence intervals for a difference, or model-based statistics. The statistic set is closed, so a shell asking for one fails validation rather than producing a display that looks right and is not.

Also fixed: one column split plus one optional spanning header; a shift row must be the last row at its level, because rtables nests everything that follows a row split, so several parameters means several shells; and listing values are reproduced with as.character(), because rounding belongs in the ADaM step.

A wide set done loosely would be worse than a narrow set you can trust. That judgement has not changed – the set simply got wider by two displays that could be done properly.

One more thing: build_tlf() runs the code it printed

build_tlf() evaluates the text shell_code() produces. That is the design. Any second implementation is a second thing that can disagree with the reviewed program; evaluating the printed text is what makes reviewing the code equivalent to reviewing the table.

The trust boundary is therefore the shell, and it sits exactly where it sits for any R program: a shell contains expressions that are evaluated, so a shell is as trusted as an R script the same person would otherwise have written by hand. Read one you did not write before you build it. See the “Trust boundary” section of ?build_tlf.