If you have worked with clinical data long enough, you know that summary tables – demographics tables, adverse event tables, lab shift tables – all share a common structural pattern. Each section of the table represents some kind of summary: a set of counts, a block of descriptive statistics, or a cross-tabulation.
tplyr2 is built around this idea. Rather than writing bespoke data manipulation code for every table, you describe what the table should contain using a declarative specification, and tplyr2 handles the computing, formatting, and assembly.
The key concepts are:
tplyr_spec() object is pure
configuration. It describes the column structure, global filters, and a
list of layers. No data processing happens when you create a spec.group_count()) produce frequencies. Descriptive
layers (group_desc()) compute means, medians, and other
summaries. Shift layers (group_shift()) create
cross-tabulations. Custom layers (group_analyze()) accept
user-defined functions.tplyr_build(spec, data)
executes the spec against a dataset and returns a formatted data
frame.This separation of configuration from execution is intentional. A spec can be saved to disk, reviewed, reused across studies, or applied to different datasets at build time. If you are familiar with the PHUSE white paper Analyses & Displays Associated with Demographics, Disposition, and Medications in Phase 2-4 Clinical Trials and Integrated Summary Documents, the layer model maps naturally to the summary blocks described there.
Every table starts with a tplyr_spec(). At minimum, you
need the column variable (typically the treatment arm) and at least one
layer.
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_count("SEX"),
group_desc("AGE")
)
)
spec
#> tplyr2 table specification
#> Column variables: TRT01PLayers: 2[1] count: SEX (Layer 1)[2] desc: AGE (Layer 2)The cols parameter determines which variable defines the
output columns. Each unique value becomes a result column. The function
also accepts optional parameters:
where: A global filter expression (e.g.,
where = SAFFL == "Y").pop_data: A pop_data() configuration for
population-based denominators.total_groups: A list of total_group()
objects for “Total” columns.custom_groups: A list of custom_group()
objects for combined treatment arms.layers: Layer objects wrapped in
tplyr_layers().The spec contains no data. Data is supplied at build time, so the same spec can be reused across datasets.
Layers are the building blocks of a tplyr2 table. You create them
with the group_*() family: group_count(),
group_desc(), group_shift(), and
group_analyze(). Every layer constructor accepts:
target_var: The variable(s) being summarized.by: Optional row grouping. Strings matching column
names become grouping variables; other strings become text labels in the
output. Use label() for explicit disambiguation.where: An optional layer-level filter expression.settings: A layer_settings() object for
detailed configuration.Multiple layers are collected with tplyr_layers():
layers <- tplyr_layers(
group_count("SEX", by = "Sex n (%)"),
group_count("RACE", by = "Race n (%)"),
group_desc("AGE", by = "Age (Years)")
)Layers are processed in order and stacked vertically in the output.
Once you have a spec and a dataset, building is a single function call. Let’s look at a demographics-style table with both count and descriptive layers:
spec <- tplyr_spec(
cols = "TRT01P",
where = SAFFL == "Y",
layers = tplyr_layers(
group_count("SEX", by = "Sex n (%)"),
group_desc(
"AGE",
by = "Age (Years)",
settings = layer_settings(
format_strings = list(
"n" = f_str("xxx", "n"),
"Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd"),
"Median" = f_str("xx.x", "median"),
"Min, Max" = f_str("xx, xx", "min", "max")
)
)
)
)
)
result <- tplyr_build(spec, tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | rowlabel2 | res1 | res2 | res3 |
|---|---|---|---|---|
| Sex n (%) | F | 53 (61.6%) | 40 (47.6%) | 50 (59.5%) |
| Sex n (%) | M | 33 (38.4%) | 44 (52.4%) | 34 (40.5%) |
| Age (Years) | n | 86 | 84 | 84 |
| Age (Years) | Mean (SD) | 75.2 ( 8.59) | 74.4 ( 7.89) | 75.7 ( 8.29) |
| Age (Years) | Median | 76.0 | 76.0 | 77.5 |
| Age (Years) | Min, Max | 52, 89 | 56, 88 | 51, 88 |
The output is a standard data frame. Here is what the columns mean:
by label adds one column; a by data variable
adds another; and the target variable adds one more.cols. Each carries a label attribute with
the column name and, when population data is available, an
(N=n) suffix.One of the most important features of tplyr2 is the format string
system. Clinical tables have specific alignment and precision
requirements. The f_str() function lets you declare exactly
how numbers should appear.
An f_str() has two parts: a template string that defines
the layout, and variable names that map into the template’s format
groups. A format group is a sequence of x characters (with
optional decimal point) that determines width and precision.
# Two decimal places for mean, two for SD, with parentheses
f_str("xx.xx (xx.xx)", "mean", "sd")
#> tplyr format string: "xx.xx (xx.xx)"
#> Variables: mean, sd
# Integer count only
f_str("xxx", "n")
#> tplyr format string: "xxx"
#> Variables: n
# Count with percentage
f_str("xx (xx.x%)", "n", "pct")
#> tplyr format string: "xx (xx.x%)"
#> Variables: n, pctThe number of x characters in the integer part sets the
total width (including leading spaces for alignment). Characters after
the decimal point set decimal precision. Everything between format
groups is literal text.
For descriptive statistics layers, format strings
are a named list and each name becomes a row label. For count
and shift layers, a single format string goes under the
reserved key n_counts:
# count layer: n (pct%) -- the default
list(n_counts = f_str("xx (xx.x%)", "n", "pct"))
# count layer: distinct subjects rather than records
list(n_counts = f_str("xx (xx.x%)", "distinct_n", "distinct_pct"))Each layer type computes its own set of statistics, and
vignette("format_strings") carries the complete reference.
Two more characters extend the template: a lets the
data determine the decimal width, and uppercase X
closes the gap between a number and its delimiter – both covered in
vignette("precision_alignment").
Because the format and source variables are declared together, the package knows exactly which numbers produced each cell – this is the foundation of the metadata and traceability system.
Descriptive statistics layers summarize continuous variables. Without custom format strings, the default produces six rows: n, Mean (SD), Median, Q1/Q3, Min/Max, and Missing.
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_desc("AGE")
)
)
result <- tplyr_build(spec, tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | res1 | res2 | res3 |
|---|---|---|---|
| n | 86 | 84 | 84 |
| Mean (SD) | 75.2 ( 8.59) | 74.4 ( 7.89) | 75.7 ( 8.29) |
| Median | 76.0 | 76.0 | 77.5 |
| Q1, Q3 | 69.2, 81.8 | 70.8, 80.0 | 71.0, 82.0 |
| Min, Max | 52, 89 | 56, 88 | 51, 88 |
| Missing | 0 | 0 | 0 |
When you need to summarize more than one variable in the same layer –
for example, Age and Average Daily Dose – pass a vector of names to
target_var. Each variable gets its own block of rows, with
the variable name as an additional row label.
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_desc(
c("AGE", "AVGDD"),
settings = layer_settings(
format_strings = list(
"n" = f_str("xxx", "n"),
"Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd"),
"Min, Max" = f_str("xx, xx", "min", "max")
)
)
)
)
)
result <- tplyr_build(spec, tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | rowlabel2 | res1 | res2 | res3 |
|---|---|---|---|---|
| AGE | n | 86 | 84 | 84 |
| AGE | Mean (SD) | 75.2 ( 8.59) | 74.4 ( 7.89) | 75.7 ( 8.29) |
| AGE | Min, Max | 52, 89 | 56, 88 | 51, 88 |
| AVGDD | n | 86 | 84 | 84 |
| AVGDD | Mean (SD) | 0.0 ( 0.00) | 71.6 ( 8.11) | 54.0 ( 0.00) |
| AVGDD | Min, Max | 0, 0 | 54, 79 | 54, 54 |
Count layers tabulate the frequency of categorical variable levels.
By default, they produce counts and percentages formatted as
"xx (xx.x%)":
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_count("RACE")
)
)
result <- tplyr_build(spec, tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | res1 | res2 | res3 |
|---|---|---|---|
| WHITE | 78 (90.7%) | 74 (88.1%) | 78 (92.9%) |
| BLACK OR AFRICAN AMERICAN | 8 ( 9.3%) | 9 (10.7%) | 6 ( 7.1%) |
| AMERICAN INDIAN OR ALASKA NATIVE | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
A total row sums the counts across all levels. Enable it with
total_row = TRUE in layer_settings():
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_count(
"RACE",
settings = layer_settings(total_row = TRUE)
)
)
)
result <- tplyr_build(spec, tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | res1 | res2 | res3 |
|---|---|---|---|
| WHITE | 78 (90.7%) | 74 (88.1%) | 78 (92.9%) |
| BLACK OR AFRICAN AMERICAN | 8 ( 9.3%) | 9 (10.7%) | 6 ( 7.1%) |
| AMERICAN INDIAN OR ALASKA NATIVE | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
| Total | 86 (100.0%) | 84 (100.0%) | 84 (100.0%) |
In adverse event tables, you often need to count the number of
subjects who experienced an event, not the number of event
records. Use distinct_by to specify the subject identifier.
Because ADAE contains only subjects with events, the
denominator must come from the full population (ADSL),
supplied through pop_data() – otherwise the percentages are
computed against subjects-with-events and come out too large (see
vignette("count") and vignette("denom")):
spec <- tplyr_spec(
cols = "TRTA",
pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
layers = tplyr_layers(
group_count(
"AEBODSYS",
settings = layer_settings(
distinct_by = "USUBJID",
format_strings = list(
n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
)
)
)
)
)
result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | res1 | res2 | res3 |
|---|---|---|---|
| CARDIAC DISORDERS | 4 ( 4.7%) | 6 ( 7.1%) | 5 ( 6.0%) |
| CONGENITAL, FAMILIAL AND GENETIC DISORDERS | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
| GASTROINTESTINAL DISORDERS | 6 ( 7.0%) | 4 ( 4.8%) | 3 ( 3.6%) |
| GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS | 9 (10.5%) | 15 (17.9%) | 18 (21.4%) |
| IMMUNE SYSTEM DISORDERS | 0 ( 0.0%) | 0 ( 0.0%) | 1 ( 1.2%) |
| INFECTIONS AND INFESTATIONS | 5 ( 5.8%) | 4 ( 4.8%) | 3 ( 3.6%) |
| INJURY, POISONING AND PROCEDURAL COMPLICATIONS | 2 ( 2.3%) | 1 ( 1.2%) | 2 ( 2.4%) |
| INVESTIGATIONS | 3 ( 3.5%) | 1 ( 1.2%) | 1 ( 1.2%) |
| METABOLISM AND NUTRITION DISORDERS | 2 ( 2.3%) | 2 ( 2.4%) | 0 ( 0.0%) |
| MUSCULOSKELETAL AND CONNECTIVE TISSUE DISORDERS | 1 ( 1.2%) | 2 ( 2.4%) | 0 ( 0.0%) |
| NERVOUS SYSTEM DISORDERS | 1 ( 1.2%) | 5 ( 6.0%) | 6 ( 7.1%) |
| PSYCHIATRIC DISORDERS | 3 ( 3.5%) | 2 ( 2.4%) | 3 ( 3.6%) |
| RENAL AND URINARY DISORDERS | 1 ( 1.2%) | 0 ( 0.0%) | 0 ( 0.0%) |
| RESPIRATORY, THORACIC AND MEDIASTINAL DISORDERS | 0 ( 0.0%) | 3 ( 3.6%) | 1 ( 1.2%) |
| SKIN AND SUBCUTANEOUS TISSUE DISORDERS | 5 ( 5.8%) | 18 (21.4%) | 19 (22.6%) |
| VASCULAR DISORDERS | 0 ( 0.0%) | 0 ( 0.0%) | 1 ( 1.2%) |
Many clinical tables require hierarchical counts – for example,
adverse events by body system and preferred term. Pass a vector of
variable names to target_var:
spec <- tplyr_spec(
cols = "TRTA",
pop_data = pop_data(cols = c("TRTA" = "TRT01A")),
layers = tplyr_layers(
group_count(
c("AEBODSYS", "AEDECOD"),
settings = layer_settings(
distinct_by = "USUBJID",
format_strings = list(
n_counts = f_str("xxx (xx.x%)", "distinct_n", "distinct_pct")
)
)
)
)
)
result <- tplyr_build(spec, tplyr_adae, pop_data = tplyr_adsl)
kable(head(result[, !grepl("^ord", names(result))], 15))| rowlabel1 | rowlabel2 | res1 | res2 | res3 |
|---|---|---|---|---|
| CARDIAC DISORDERS | 4 ( 4.7%) | 6 ( 7.1%) | 5 ( 6.0%) | |
| CARDIAC DISORDERS | ATRIAL FIBRILLATION | 0 ( 0.0%) | 0 ( 0.0%) | 1 ( 1.2%) |
| CARDIAC DISORDERS | ATRIAL FLUTTER | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
| CARDIAC DISORDERS | ATRIAL HYPERTROPHY | 1 ( 1.2%) | 0 ( 0.0%) | 0 ( 0.0%) |
| CARDIAC DISORDERS | BUNDLE BRANCH BLOCK RIGHT | 1 ( 1.2%) | 0 ( 0.0%) | 0 ( 0.0%) |
| CARDIAC DISORDERS | CARDIAC FAILURE CONGESTIVE | 1 ( 1.2%) | 0 ( 0.0%) | 0 ( 0.0%) |
| CARDIAC DISORDERS | MYOCARDIAL INFARCTION | 0 ( 0.0%) | 1 ( 1.2%) | 2 ( 2.4%) |
| CARDIAC DISORDERS | SINUS BRADYCARDIA | 0 ( 0.0%) | 3 ( 3.6%) | 1 ( 1.2%) |
| CARDIAC DISORDERS | SUPRAVENTRICULAR EXTRASYSTOLES | 1 ( 1.2%) | 0 ( 0.0%) | 1 ( 1.2%) |
| CARDIAC DISORDERS | SUPRAVENTRICULAR TACHYCARDIA | 0 ( 0.0%) | 0 ( 0.0%) | 1 ( 1.2%) |
| CARDIAC DISORDERS | TACHYCARDIA | 1 ( 1.2%) | 0 ( 0.0%) | 0 ( 0.0%) |
| CARDIAC DISORDERS | VENTRICULAR EXTRASYSTOLES | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
| CONGENITAL, FAMILIAL AND GENETIC DISORDERS | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) | |
| CONGENITAL, FAMILIAL AND GENETIC DISORDERS | VENTRICULAR SEPTAL DEFECT | 0 ( 0.0%) | 1 ( 1.2%) | 0 ( 0.0%) |
| GASTROINTESTINAL DISORDERS | 6 ( 7.0%) | 4 ( 4.8%) | 3 ( 3.6%) |
The outer level (body system) appears in rowlabel1, and
the inner level (preferred term) appears in rowlabel2.
Outer rows contain aggregate counts for the body system; inner rows
contain per-term counts. Use
collapse_row_labels(result, nest = TRUE) to merge these
into a single indented column (see the Count Layer vignette for
details).
Shift tables display a cross-tabulation of a baseline value against a
post-baseline value within each treatment arm. The
group_shift() function requires a named character vector
with row and column elements:
set.seed(12345)
shift_data <- data.frame(
USUBJID = paste0("SUBJ", 1:30),
TRTA = rep(c("Placebo", "Active"), each = 15),
BNRIND = factor(
sample(c("LOW", "NORMAL", "HIGH"), 30, replace = TRUE, prob = c(0.2, 0.6, 0.2)),
levels = c("LOW", "NORMAL", "HIGH")
),
ANRIND = factor(
sample(c("LOW", "NORMAL", "HIGH"), 30, replace = TRUE, prob = c(0.15, 0.5, 0.35)),
levels = c("LOW", "NORMAL", "HIGH")
)
)
spec <- tplyr_spec(
cols = "TRTA",
layers = tplyr_layers(
group_shift(
c(row = "BNRIND", column = "ANRIND"),
settings = layer_settings(
format_strings = list(n_counts = f_str("xxx", "n"))
)
)
)
)
result <- tplyr_build(spec, shift_data)
kable(result[, !grepl("^ord", names(result))])| rowlabel1 | res1 | res2 | res3 | res4 | res5 | res6 |
|---|---|---|---|---|---|---|
| LOW | 0 | 1 | 1 | 0 | 2 | 1 |
| NORMAL | 1 | 8 | 1 | 2 | 4 | 2 |
| HIGH | 0 | 1 | 2 | 1 | 1 | 2 |
In the output, rowlabel1 contains the baseline (row)
variable values, and the result columns represent each combination of
treatment arm and post-baseline (column) level. Factor ordering is
respected, so setting levels appropriately (e.g.,
c("LOW", "NORMAL", "HIGH")) ensures the intended display
order.
Formatted cells look good in a final report, but sometimes you need
the raw numbers – for validation, further computation, or archiving.
Every tplyr2 build retains unformatted numeric data as an attribute. Use
tplyr_numeric_data() to retrieve it:
spec <- tplyr_spec(
cols = "TRT01P",
layers = tplyr_layers(
group_count("SEX"),
group_desc(
"AGE",
settings = layer_settings(
format_strings = list(
"n" = f_str("xxx", "n"),
"Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd")
)
)
)
)
)
result <- tplyr_build(spec, tplyr_adsl)
# Raw numeric data for the count layer (layer 1)
kable(tplyr_numeric_data(result, layer = 1))| TRT01P | SEX | n | pct | total |
|---|---|---|---|---|
| Placebo | F | 53 | 61.62791 | 86 |
| Placebo | M | 33 | 38.37209 | 86 |
| Xanomeline High Dose | F | 40 | 47.61905 | 84 |
| Xanomeline High Dose | M | 44 | 52.38095 | 84 |
| Xanomeline Low Dose | F | 50 | 59.52381 | 84 |
| Xanomeline Low Dose | M | 34 | 40.47619 | 84 |
| TRT01P | n | n_records | mean | sd | median | var | min | max | iqr | q1 | q3 | missing | total | pct |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Placebo | 86 | 86 | 75.20930 | 8.590167 | 76.0 | 73.79097 | 52 | 89 | 12.50 | 69.25 | 81.75 | 0 | 86 | 100 |
| Xanomeline High Dose | 84 | 84 | 74.38095 | 7.886094 | 76.0 | 62.19048 | 56 | 88 | 9.25 | 70.75 | 80.00 | 0 | 84 | 100 |
| Xanomeline Low Dose | 84 | 84 | 75.66667 | 8.286051 | 77.5 | 68.65863 | 51 | 88 | 11.00 | 71.00 | 82.00 | 0 | 84 | 100 |
This gives you the exact counts, percentages, means, and every other computed statistic in raw numeric form, keyed by the same grouping variables used during the build.
This vignette covers the fundamentals. Here are the topics covered in other vignettes:
vignette("table") – Spec-level structure: column
variables, table filters, total/custom groups, and population data.vignette("count") – Count layers in depth:
population-based denominators, distinct counts, nested summaries, stat
columns, missing values.vignette("desc") – Descriptive layers: custom
summaries, auto-precision, stats-as-columns.vignette("shift") – Shift layers, shift denominators,
and the denominator row.vignette("denom") – Denominator control: population
data, header N, denoms_by, denom_where,
single-proportion confidence intervals.vignette("adverse-events") – A full adverse event table
built end to end.vignette("riskdiff") and
vignette("binding-statistics") – Risk differences and
association-test p-value columns.vignette("sort") – Row ordering by frequency, factor
levels, or a VARN companion.vignette("format_strings") – The f_str()
format-string system: the grammar, the complete statistic keyword
reference per layer type, rounding, and missing-value handling.vignette("precision_alignment") – Data-driven decimal
precision (a/A) and parenthesis hugging
(X/A).vignette("display_conventions") – <1%
and >99% percents, zero-count suppression, statistics as
columns, missing rows.vignette("post_processing") – Row masks, row label
collapsing, conditional formatting, as_display(), and text
wrapping.vignette("metadata") – Cell-level metadata and
traceability.vignette("serialization"), vignette("ard")
– Saving/loading specs and Analysis Results Data conversion.vignette("analyze") – Custom analysis layers with
user-defined functions.vignette("options") – Package options (rounding,
quantiles, precision).vignette("migration") – Moving from Tplyr v1 to
tplyr2.