The display shell is the source of truth. Generate the table code from it.
Every study starts from a shell: the mocked-up table with its titles, footnotes, column structure and required statistics. Today a programmer reads that shell and hand-writes the code, and the two drift apart the moment a footnote changes. shell2tlf reads the shell, emits the {rtables} code, builds the table from that same code, and renders RTF whose titles, footnotes and pagination come straight back out of the shell.
It is the sibling of ardflow. ardflow starts from an analysis-results spec and computes results; shell2tlf starts from a display shell and produces the program. Different entry point, same family.
shell.yaml -> shell_code() -> reviewable rtables program
-> build_tlf() -> rtables TableTree
-> render_tlf() -> RTF (titles + footnotes + pagination from the shell)
-> validate_shell(data) -> tibble of problems, before anyone runs it
install.packages(
"https://clincoder.cloud/shell2tlf/shell2tlf_0.0.0.9000.tar.gz",
repos = NULL, type = "source"
)
library(shell2tlf)
shell <- read_shell(system.file("extdata", "demographics.yaml", package = "shell2tlf"))
adsl <- utils::read.csv(system.file("extdata", "adsl_demo.csv", package = "shell2tlf"))
validate_shell(shell, adsl) # 0 rows: nothing to fix
shell_code(shell) # the program a reviewer reads
tbl <- build_tlf(shell, adsl) # the same program, evaluated
render_tlf(tbl, shell, file.path(tempdir(), "t_14_1_1.rtf"))shell_code(shell) prints a program, not a black box:
# --------------------------------------------------------------------------
# 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")
)
}) |>
# ... one analyze() per shell row ...
tbl <- build_table(lyt, adsl)
tblbuild_tlf() evaluates that text. There is no second implementation, so reviewing the code is reviewing the table:
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%)
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."]
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"
rows:
- { var: AGE, label: "Age (years)", type: summary, stats: [n, mean_sd, median, q1_q3, min_max] }
- { var: SEX, label: "Sex", type: count, stats: [n_pct], levels: ["F", "M"] }
pagination: { rows_per_page: 26, orientation: portrait }An adverse-event shell nests preferred term inside system organ class, counts distinct subjects, and sorts by frequency:
| SAS idiom | shell2tlf |
|---|---|
Shell document retyped into a PROC REPORT program |
read_shell() + shell_code()
|
TITLE/FOOTNOTE statements maintained apart from the table |
render_tlf() reads them from the shell |
Checking the shell against PROC CONTENTS by eye |
validate_shell(shell, data) |
ODS RTF with hand-set page breaks |
pagination: in the shell |
PROC FREQ with TABLES base*post and a hand-worked denominator |
type: shift with denominator:
|
PROC REPORT listing whose ORDER variables collapse repeated records |
display: listing |
A shift table is a cross-tabulation, and the thing that goes wrong with one is the denominator. The shell states it; the package never infers it.
rows:
- var: ANRIND # post-baseline category -- the rows
baseline_var: BNRIND # baseline category -- the row groups
label: "Baseline"
type: shift
stats: [n_pct]
levels: ["LOW", "NORMAL", "HIGH"]
denominator: baseline_row # or: column
unique_by: USUBJID 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%)
baseline_row (default): the denominator is the subjects in the same baseline category and the same column. Each block sums to 100%, and the number beside each baseline category is that denominator, printed so a reviewer can check it against the page.column: the denominator is the column N and the whole table sums to 100%. With unique_by, also set population.denominator, or the column N counts records rather than subjects.A record whose baseline or post-baseline value is not one of the declared levels leaves both the numerator and the denominator. validate_shell() counts them, because an unnoticed handful is how a shift table stops reconciling with its own column header.
display: listing
population: { dataset: adae, filter: SAFFL == "Y" & TRTEMFL == "Y" }
listing:
vars: [USUBJID, TRT01A, AEBODSYS, AEDECOD, AESEV]
labels: ["Subject", "Treatment", "System Organ Class", "Preferred Term", "Severity"]
sort_by: [USUBJID, AEBODSYS, AEDECOD]A listing is deliberately not de-duplicated: 335 treatment-emergent records from 144 subjects produce 335 rows. The generated program has no aggregation step at all – filter, arrange(), data.frame(), df_to_tt() – so there is nothing that could collapse a repeated key. render_tlf() takes a listing without a listing branch, because it comes back as an ordinary rtables table.
Three table display types plus listings, 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 (unique_by) and nested into a hierarchy.n_pct or n, with the denominator stated in the shell.PARAMCD in population.filter.as.character(). Rounding and formatting of derived variables belong in the ADaM step, not the display.A wide set done loosely is worse than a narrow set you can trust.