getting-started.RmdA 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.
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: trueread_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)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.
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"))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.0The result is a plain rtables TableTree, so
anything in the pharmaverse that already accepts one still works.
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, portraitChange 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, portraitThe 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)
#>
#> tblA 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…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 335335 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 MILDIt 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, landscapeThis version supports four display types, done properly:
n, mean,
sd, mean_sd, median,
q1_q3, min_max, missing for a
numeric variable;n_pct or n per
level of a categorical variable, optionally counting distinct subjects
and nested into a hierarchy;n_pct or n, with the denominator
stated in the shell;Out of scope, and deliberately so:
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.
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.