pivot_across() is tabular's input-side helper: it consumes a
long Analysis Results Data (ARD) data frame (typically produced by
cards::ard_stack() or cards::ard_stack_hierarchical()) and
returns a wide display data.frame ready to pass to tabular().
Usage
pivot_across(
data,
statistic = list(continuous = "{mean} ({sd})", categorical = "{n} ({p}%)"),
column = NULL,
row_group = NULL,
label = NULL,
overall = "Total",
decimals = NULL,
fmt = NULL,
aux = NULL
)Arguments
- data
Long ARD input data.
<data.frame>: required. At minimum needsstat_nameandstat. Cards-style group columns (group1,group1_level, ...) andvariable/variable_levelare auto-detected. Tibbles /cardobjects / arrow tables are coerced viaas.data.frame().- statistic
Format spec for cell composition.
<character(1) | named list>: required. Combines one or more ARD stats into one display cell. Three accepted forms — each illustrated below. Inside a format string,{stat_name}substitutes that stat's value from the ARD (for example,"{n} ({p}%)"interpolates thenandpstats into a"53 (62%)"cell). The lookup order when a value is needed for a variable is: per-variable -> per-context ->default-> the literal"{n}".Form 1: single string
One format string applied to every variable regardless of context. Use when your ARD is homogeneous (e.g. all categorical).
# Every variable rendered as "n (p%)" — categorical-only slice. cat_only <- cdisc_saf_demo_ard[cdisc_saf_demo_ard$context == "categorical", ] pivot_across( cat_only, statistic = "{n} ({p}%)" )Form 2: named list by context
Different formats per context. This is the typical clinical-table form because demographics mix continuous and categorical variables.
The list names must match the values in the ARD's
contextcolumn verbatim. Which strings appear there depends on how the ARD was built:cards::ard_continuous()/ard_categorical()emit"continuous"/"categorical".cards::ard_summary()/ard_tabulate()emit"summary"/"tabulate".
So an ARD assembled with
ard_stack(ard_summary(...), ard_tabulate(...))is keyedsummary/tabulate, notcontinuous/categorical. Inspectunique(ard$context)when unsure.# AGE (continuous) -> "75.2 (8.59)"; SEX (categorical) -> "53 (62%)" pivot_across( cdisc_saf_demo_ard, statistic = list( continuous = "{mean} ({sd})", categorical = "{n} ({p}%)" ) )Form 3: named list by variable
Override on a per-variable basis; fall back to
defaultor context. Use when one variable needs a custom format.# AGE shows just the mean; SEX / RACE keep the categorical default. pivot_across( cdisc_saf_demo_ard, statistic = list( AGE = "{mean}", categorical = "{n} ({p}%)", default = "{mean} ({sd})" ) )Multi-row continuous spec
Any single entry can itself be a named character vector — each element becomes one display row, with the name as the row label. Use for
N / Mean (SD) / Median / Min, Max-style blocks.pivot_across( cdisc_saf_demo_ard, statistic = list( continuous = c( N = "{N}", "Mean (SD)" = "{mean} ({sd})", Median = "{median}", "Min, Max" = "{min}, {max}" ), categorical = "{n} ({p}%)" ) )- column
What runs across the top of the table.
<character | NULL>: default NULL. A single grouping variable whose unique values become arm columns (NULLauto-detects fromgroup1, or picks the single non-standard column of renamed input). Two reserved tokens turn the analysis variable into a column band:c(".variable", "<arm>")— each variable becomes a band of arm columns; the statistic entries stack as rows. The cells are combined strings (e.g."323.9 (106)"). Emitted columns are named"<variable>..<arm>".c(".variable", ".stat")— each variable becomes a band whose statistic entries become their own columns (the landscape "value and change" shell); the arm drops to a leading row stub. Emitted columns are named"<variable>..<stat-entry>".
Anything present in the ARD but not named in
column(and not inrow_group) stacks as rows. Per-variablestatistic/decimalsresolve inside each band, so bands may carry different (even different-length) stat lists; ragged bands pad withNA.Tip: you reference the emitted column names verbatim in a manual
headers()call to build the band spanners; pivot_across does not build spanners itself.- row_group
Second, non-column grouping dimension.
<character(1) | NULL>: default NULL. Names the non-arm group variable of a two-variable.by(e.g.SEXinard_stack(.by = c(ARM, SEX))). It widens into a leading row column (not a pivoted arm column), so the result composes withsubgroup(by = ...)orgroup_rows()downstream.Why it is required. cards encodes a crossing factor and a SOC/PT hierarchy identically (the second group variable appears in
variableon its by-marginal rows), so the two cannot be told apart automatically. Namingrow_groupdeclares "this is a crossing factor": the by-marginal rows are dropped and the flat path is used. Leave itNULLfor a genuine hierarchy.Restriction: Must name a second grouping variable present in the ARD and must differ from
column.- label
Variable-name to display-label map.
<character> | NULL: default NULL. Named character vector mapping variable names to display labels (e.g.c(AGE = "Age (years)", SEX = "Sex")). Applies tovariable,soc, andlabelcolumns of the output.NULLleaves the upstream variable names verbatim.Renaming the hierarchical "overall" row. A
cards::ard_stack_hierarchical(overall = TRUE)ARD carries an internal..ard_hierarchical_overall..sentinel for the grand-total ("any event") row. It is relabelled to"Overall"by default; map the sentinel key to override, e.g.label = c("..ard_hierarchical_overall.." = "TOTAL SUBJECTS WITH AN EVENT"). The raw sentinel never reaches the output at any hierarchy depth.- overall
Column name for
NA-arm (overall / total) rows.<character(1) | NULL>: default "Total". PassNULLto drop overall rows entirely (per-arm only output).Requirement: this relabels pooled rows the ARD already carries — the
NA-arm rows cards emits fromcards::ard_stack_hierarchical(overall = TRUE)or anard_*(.overall = TRUE). It does not synthesize a total: cards re-runs the calculation with thebyvariable removed, so the pooledn/N/pstay internally consistent. With no such rows in the input there is no overall column to label.Note: if a study arm is literally named the same as
overall(default"Total"), that arm and the pooled rows collide under one label and the pivot warns. Pass a distinctoverall =or rename the arm upstream.- decimals
Per-stat decimal precision.
<named integer | named list>: defaultc()“. Accepts three forms:named integer vector — global per-stat overrides (
c(mean = 1, sd = 2, p = 0)).named list keyed by variable — per-variable plus
.default(list(AGE = c(mean = 2), .default = c(p = 1))).named list keyed by
row_groupvalue — per-group precision in one call (list(SYSBP = c(mean = 0, sd = 1), WEIGHT = c(mean = 1), .default = c(mean = 1, sd = 2))). Each entry is a per-token spec (a named numeric vector, or a bare scalar applied to every token).
Built-in defaults apply when none sets a stat.
Interaction: a list
decimalsis read as per-row_grouponly whenrow_groupis set and every key (apart from.default) is one of its levels; otherwise it stays per-variable. Within the matched group the token falls back group token, then the per-group.defaulttoken, then the built-in default. A group present in the data but absent from the list (and NA / ungrouped rows) uses.default. Ifrow_groupisNULLbut the keys match no variable,pivot_across()errors and asks for arow_group.- fmt
Per-stat custom formatter functions.
<named list of function>: defaultlist()“. Each function takes a numeric value and returns a character string; overrides built-ins anddecimalsfor that stat. Useful for p-value styling and other domain-specific formatting.- aux
Auxiliary comparison columns from a second ARD.
<named list | NULL>: default NULL. Each entry is a between-arm statistic (difference, hazard ratio, p-value, ...) computed in its own ARD and bound on as a trailing column, aligned 1:1 on therow_groupkey. The entry name is the column name; reference it in a manualheaders()call. Each entry is a list:ard— the auxiliary ARD (required), one row perrow_group.statistic— its format string (e.g."{estimate} ({conf.low}, {conf.high})"); defaults to"{n}".decimals/fmt— optional, as for the main pivot.
Entries append left to right. One entry is one column; for several comparison columns (estimate then p-value) pass several entries.
Requirement: needs a
row_group; the auxiliary rows align to the main table on it, which must be unique 1:1 on both sides (a many-to-many key would silently fabricate rows, so it aborts).
Value
A wide data.frame ready for tabular(). Schema:
variable— variable name (or label afterlabel = ...).stat_label— display-row label.One column per arm level (named after the
group1_levelvalues or the renamed arm column).Total(or whateveroverallis set to) when applicable.A leading column named after
row_groupwhen set (the second grouping dimension).Hierarchical ARD adds
soc,label,row_typeinstead ofvariable.
Pass the result straight into tabular() to start the
render pipeline.
Details
tabular's package boundary is display-only: pre-summarised
data in, rendered file out. pivot_across() is the canonical
bridge between the cards aggregation backend and that boundary.
It does not aggregate — it pivots arms to columns, interpolates
per-cell display strings from the stat values, and applies
decimal precision. Filtering, weighting, and aggregation happen
upstream in cards or your own data-prep step.
Key statistic by the ARD context
statistic (and fmt) are matched against the ARD's context
column verbatim, and that value differs per generating function.
Keying by the wrong name silently drops the format. Inspect
unique(ard$context) first and key to match (or pass a single
format string / default = to cover everything). When an
explicitly-supplied statistic matches no context at all,
pivot_across() warns rather than silently emitting {n}.
| Generating function | context to key on |
cards::ard_summary() | summary |
cards::ard_tabulate() | tabulate |
cards::ard_continuous() | continuous |
cards::ard_categorical() | categorical |
cards::ard_stack_hierarchical() | tabulate + hierarchical |
cardx::ard_categorical_ci() | proportion_ci |
cardx::ard_continuous_ci() | continuous_ci |
Zero-suppression (always-on default)
A row whose n value equals zero renders the whole cell as the
bare n value instead of fully interpolating the format string.
For a categorical level with n = 0, the cell shows "0", not
"0 (0.0%)". This is clinical convention — empty cells should
read as a single zero, not advertise a meaningless rate.
How the default fires (chain of events). During cell assembly,
before format-string interpolation, the engine checks the row's
n stat. If it is zero, the engine short-circuits and returns the
formatted n value ("0") as the entire cell — {p} is never
substituted, so the (0.0%) half of the format string is dropped.
How to opt out: supply a custom fmt$n. Setting any function
under fmt$n is the engine's signal that the user owns the n
rendering. The short-circuit is disabled for the whole table; for
every row the full format string interpolates, so {n} becomes
your formatter's output and {p} becomes the standard percentage.
For n = 0, that's "0 (0.0%)".
# Force "0 (0.0%)" for n = 0 rows by attaching a custom n formatter.
# The body of fmt$n can be the default integer rendering — its
# presence alone is what disables the zero-suppression branch.
pivot_across(
cdisc_saf_demo_ard,
statistic = list(
continuous = "{mean} ({sd})",
categorical = "{n} ({p}%)"
),
fmt = list(n = function(x) sprintf("%d", as.integer(x)))
)Pharma rounding (always-on default)
A percentage that would otherwise round to 0 (when the value
is positive but smaller than the chosen precision) renders as
<0.1; one that would round to 100 (positive but smaller than
100) renders as >99.9. The threshold is precision-aware:
decimals = c(p = 2) produces <0.01 / >99.99. This matches
the pharma convention of never claiming exactly 0% or 100%
when at least one subject contributed.
Override per-stat via fmt:
# Show exact rounded percentages even at the extremes
pivot_across(
data,
statistic = "{n} ({p}%)",
decimals = c(p = 1),
fmt = list(p = function(x) sprintf("%.1f", x * 100))
)Your fmt$p receives the raw stat value (a proportion between
0 and 1) and returns the displayed string. The pharma-threshold
branch only fires inside the built-in p formatter and the
decimals-driven path, so any custom fmt$p bypasses it.
See also
Pipeline entry consumer: tabular() — wraps the wide data
frame this helper returns.
Downstream spec-build verbs: cols() / col_spec(),
headers(), sort_rows(), style(),
paginate(), preset().
Examples
# ---- Example 1: Demographics — long ARD to rendered spec ----
#
# Full pipeline from a `cards::ard_stack()`-style long ARD to a
# sorted `tabular_spec`. The multi-row continuous block (N /
# Mean (SD) / Median / Min, Max) sits above each categorical
# block; decimals are set per-stat (mean 1, sd 2, p 1) to match
# the CDISC convention.
n <- stats::setNames(cdisc_saf_n$n, cdisc_saf_n$arm_short)
cdisc_saf_demo_ard |>
pivot_across(
statistic = list(
continuous = c(
N = "{N}",
"Mean (SD)" = "{mean} ({sd})",
Median = "{median}",
"Min, Max" = "{min}, {max}"
),
categorical = "{n} ({p}%)"
),
decimals = c(mean = 1, sd = 2, p = 1, median = 1, min = 0, max = 0),
label = c(AGE = "Age (years)", SEX = "Sex", RACE = "Race")
) |>
tabular(
titles = c(
"Table 14.1.1",
"Demographics and Baseline Characteristics",
"Safety Population"
),
footnotes = "Percentages based on N per treatment group."
) |>
cols(
variable = col_spec(label = "Parameter"),
stat_label = col_spec(label = "Statistic"),
Placebo = col_spec(
label = "Placebo\nN={n['placebo']}",
align = "decimal"
),
`Xanomeline Low Dose` = col_spec(
label = "Drug 50\nN={n['drug_50']}",
align = "decimal"
),
`Xanomeline High Dose` = col_spec(
label = "Drug 100\nN={n['drug_100']}",
align = "decimal"
),
Total = col_spec(
label = "Total\nN={n['Total']}",
align = "decimal"
)
)
Table 14.1.1
Demographics and Baseline Characteristics
Safety Population
Parameter Statistic Placebo
N=86 Drug 100
N=72 Drug 50
N=96 Total
N=254 Age (years) N 86 72 96 254 Age (years) Mean (SD) 75.2 (8.59) 73.8 (7.94) 76.0 (8.11) 75.1 (8.25) Age (years) Median 76.0 75.5 78.0 77.0 Age (years) Min, Max 52 , 89 56 , 88 51 , 88 51 , 89 WEIGHT N 86 72 95 253 WEIGHT Mean (SD) 62.8 (12.77) 69.5 (14.35) 68.0 (14.50) 66.6 (14.13) WEIGHT Median 60.6 69.0 66.7 66.7 WEIGHT Min, Max 34 , 86 44 , 108 42 , 106 34 , 108 HEIGHT N 86 72 96 254 HEIGHT Mean (SD) 162.6 (11.52) 165.9 (10.28) 163.7 (10.30) 163.9 (10.76) HEIGHT Median 162.6 165.1 162.6 162.8 HEIGHT Min, Max 137 , 185 146 , 190 136 , 196 136 , 196 BMI N 86 72 95 253 BMI Mean (SD) 23.6 (3.67) 25.2 (3.97) 25.2 (4.40) 24.7 (4.09) BMI Median 23.4 24.8 24.8 24.2 BMI Min, Max 15 , 33 14 , 35 15 , 40 14 , 40 AGEGR1 18-64 14 (16.3%) 11 (15.3%) 8 ( 8.3%) 33 (13.0%) AGEGR1 >64 72 (83.7%) 61 (84.7%) 88 (91.7%) 221 (87.0%) Sex F 53 (61.6%) 35 (48.6%) 55 (57.3%) 143 (56.3%) Sex M 33 (38.4%) 37 (51.4%) 41 (42.7%) 111 (43.7%) Race WHITE 78 (90.7%) 62 (86.1%) 90 (93.8%) 230 (90.6%) Race BLACK OR AFRICAN AMERICAN 8 ( 9.3%) 9 (12.5%) 6 ( 6.2%) 23 ( 9.1%) Race ASIAN 0 0 0 0 Race AMERICAN INDIAN OR ALASKA NATIVE 0 1 ( 1.4%) 0 1 ( 0.4%) ETHNIC HISPANIC OR LATINO 3 ( 3.5%) 3 ( 4.2%) 6 ( 6.2%) 12 ( 4.7%) ETHNIC NOT HISPANIC OR LATINO 83 (96.5%) 69 (95.8%) 90 (93.8%) 242 (95.3%) ETHNIC NOT REPORTED 0 0 0 0 BMI_CAT Underweight (<18.5) 3 ( 3.5%) 1 ( 1.4%) 4 ( 4.2%) 8 ( 3.2%) BMI_CAT Normal (18.5-24.9) 57 (66.3%) 39 (54.2%) 46 (48.4%) 142 (56.1%) BMI_CAT Overweight (25-29.9) 20 (23.3%) 23 (31.9%) 32 (33.7%) 75 (29.6%) BMI_CAT Obese (>=30) 6 ( 7.0%) 9 (12.5%) 13 (13.7%) 28 (11.1%)
Percentages based on N per treatment group.
# ---- Example 2: Hierarchical SOC/PT AE table ----
#
# Hierarchical `cards::ard_stack_hierarchical()` output threaded
# through `pivot_across()`. The hierarchical ARD emits a
# (soc, label, row_type) triple plus one stat row per (arm, SOC, PT);
# `pivot_across()` folds the arm dimension to columns and preserves
# the hierarchy markers. Derive `indent_level` from `row_type` so
# `col_spec(indent = "indent_level")` drives the SOC -> PT
# indent on the `label` column.
wide <- cdisc_saf_aesocpt_ard |>
pivot_across(statistic = "{n} ({p}%)")
wide$indent_level <- as.integer(wide$row_type == "pt")
tabular(
wide,
titles = c(
"Table 14.3.1",
"Adverse Events by System Organ Class and Preferred Term",
"Safety Population"
),
footnotes = c(
"Subjects are counted once per SOC and once per PT.",
"Percentages based on N per treatment group."
)
) |>
cols(
label = col_spec(label = "SOC / PT", indent = "indent_level"),
soc = col_spec(visible = FALSE),
row_type = col_spec(visible = FALSE),
Placebo = col_spec(
label = "Placebo\nN={n['placebo']}",
align = "decimal"
),
`Xanomeline Low Dose` = col_spec(
label = "Drug 50\nN={n['drug_50']}",
align = "decimal"
),
`Xanomeline High Dose` = col_spec(
label = "Drug 100\nN={n['drug_100']}",
align = "decimal"
)
)
Table 14.3.1
Adverse Events by System Organ Class and Preferred Term
Safety Population
SOC / PT Placebo
N=86 Drug 100
N=72 Drug 50
N=96 Overall 52 (60%) 66 (92%) 81 (84%) SKIN AND SUBCUTANEOUS TISSUE DISORDERS 19 (22%) 35 (49%) 36 (38%) PRURITUS 8 ( 9%) 25 (35%) 21 (22%) ERYTHEMA 8 ( 9%) 14 (19%) 14 (15%) RASH 5 ( 6%) 8 (11%) 13 (14%) HYPERHIDROSIS 2 ( 2%) 8 (11%) 4 ( 4%) SKIN IRRITATION 3 ( 3%) 5 ( 7%) 6 ( 6%) GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS 15 (17%) 30 (42%) 36 (38%) APPLICATION SITE PRURITUS 6 ( 7%) 21 (29%) 23 (24%) APPLICATION SITE ERYTHEMA 3 ( 3%) 14 (19%) 13 (14%) APPLICATION SITE DERMATITIS 5 ( 6%) 7 (10%) 9 ( 9%) APPLICATION SITE IRRITATION 3 ( 3%) 9 (12%) 9 ( 9%) APPLICATION SITE VESICLES 1 ( 1%) 5 ( 7%) 5 ( 5%) GASTROINTESTINAL DISORDERS 13 (15%) 17 (24%) 12 (12%) DIARRHOEA 9 (10%) 3 ( 4%) 5 ( 5%) VOMITING 3 ( 3%) 6 ( 8%) 4 ( 4%) NAUSEA 3 ( 3%) 6 ( 8%) 3 ( 3%) ABDOMINAL PAIN 1 ( 1%) 1 ( 1%) 3 ( 3%) SALIVARY HYPERSECRETION 0 4 ( 6%) 0 NERVOUS SYSTEM DISORDERS 6 ( 7%) 17 (24%) 18 (19%) DIZZINESS 2 ( 2%) 10 (14%) 9 ( 9%) HEADACHE 3 ( 3%) 5 ( 7%) 3 ( 3%) SYNCOPE 0 2 ( 3%) 5 ( 5%) SOMNOLENCE 2 ( 2%) 1 ( 1%) 3 ( 3%) TRANSIENT ISCHAEMIC ATTACK 0 1 ( 1%) 2 ( 2%) CARDIAC DISORDERS 7 ( 8%) 14 (19%) 12 (12%) SINUS BRADYCARDIA 2 ( 2%) 8 (11%) 7 ( 7%) MYOCARDIAL INFARCTION 4 ( 5%) 4 ( 6%) 2 ( 2%) ATRIAL FIBRILLATION 1 ( 1%) 2 ( 3%) 2 ( 2%) SUPRAVENTRICULAR EXTRASYSTOLES 1 ( 1%) 1 ( 1%) 1 ( 1%) VENTRICULAR EXTRASYSTOLES 0 1 ( 1%) 2 ( 2%) INFECTIONS AND INFESTATIONS 12 (14%) 11 (15%) 6 ( 6%) NASOPHARYNGITIS 2 ( 2%) 6 ( 8%) 4 ( 4%) UPPER RESPIRATORY TRACT INFECTION 6 ( 7%) 3 ( 4%) 1 ( 1%) INFLUENZA 1 ( 1%) 1 ( 1%) 1 ( 1%) URINARY TRACT INFECTION 2 ( 2%) 1 ( 1%) 0 CYSTITIS 1 ( 1%) 1 ( 1%) 0 RESPIRATORY, THORACIC AND MEDIASTINAL DISORDERS 5 ( 6%) 9 (12%) 8 ( 8%) COUGH 1 ( 1%) 5 ( 7%) 5 ( 5%) NASAL CONGESTION 3 ( 3%) 3 ( 4%) 1 ( 1%) DYSPNOEA 1 ( 1%) 1 ( 1%) 1 ( 1%) EPISTAXIS 0 2 ( 3%) 1 ( 1%) PHARYNGOLARYNGEAL PAIN 0 1 ( 1%) 1 ( 1%) PSYCHIATRIC DISORDERS 7 ( 8%) 3 ( 4%) 9 ( 9%) CONFUSIONAL STATE 2 ( 2%) 1 ( 1%) 3 ( 3%) AGITATION 2 ( 2%) 0 3 ( 3%) INSOMNIA 2 ( 2%) 2 ( 3%) 0 ANXIETY 0 0 3 ( 3%) DELUSION 1 ( 1%) 1 ( 1%) 0 MUSCULOSKELETAL AND CONNECTIVE TISSUE DISORDERS 3 ( 3%) 5 ( 7%) 6 ( 6%) BACK PAIN 1 ( 1%) 3 ( 4%) 1 ( 1%) ARTHRALGIA 1 ( 1%) 1 ( 1%) 2 ( 2%) SHOULDER PAIN 1 ( 1%) 0 2 ( 2%) MUSCLE SPASMS 0 1 ( 1%) 1 ( 1%) ARTHRITIS 0 1 ( 1%) 0 INVESTIGATIONS 5 ( 6%) 3 ( 4%) 4 ( 4%) ELECTROCARDIOGRAM ST SEGMENT DEPRESSION 4 ( 5%) 0 1 ( 1%) ELECTROCARDIOGRAM T WAVE INVERSION 2 ( 2%) 1 ( 1%) 1 ( 1%) BLOOD GLUCOSE INCREASED 0 1 ( 1%) 1 ( 1%) ELECTROCARDIOGRAM T WAVE AMPLITUDE DECREASED 1 ( 1%) 0 1 ( 1%) BIOPSY 0 1 ( 1%) 0
Subjects are counted once per SOC and once per PT.
Percentages based on N per treatment group.
# ---- Example 3: Hierarchical ARD (SOC / PT) ----
#
# `cdisc_saf_aesocpt_ard` carries an `ard_stack_hierarchical` shape with
# two grouping variables (AEBODSYS / AEDECOD). `pivot_across()`
# recognises the hierarchical structure and emits dedicated `soc`,
# `label`, and `row_type` columns so the SOC -> PT nesting survives
# the pivot. The result is ready for `tabular()` plus `sort_rows()`.
head(cdisc_saf_aesocpt_ard, 3)
#> # A tibble: 3 × 10
#> group1 group1_level group2 group2_level variable variable_level
#> <chr> <list> <chr> <list> <chr> <list>
#> 1 NA <NULL> NA <NULL> TRT01A <chr [1]>
#> 2 NA <NULL> NA <NULL> TRT01A <chr [1]>
#> 3 NA <NULL> NA <NULL> TRT01A <chr [1]>
#> # ℹ 4 more variables: context <chr>, stat_name <chr>,
#> # stat_label <chr>, stat <list>
wide <- cdisc_saf_aesocpt_ard |>
pivot_across(statistic = "{n} ({p}%)")
head(wide, 3)
#> soc
#> 1 Overall
#> 2 SKIN AND SUBCUTANEOUS TISSUE DISORDERS
#> 3 SKIN AND SUBCUTANEOUS TISSUE DISORDERS
#> label row_type Placebo
#> 1 Overall overall 52 (60%)
#> 2 SKIN AND SUBCUTANEOUS TISSUE DISORDERS soc 19 (22%)
#> 3 PRURITUS pt 8 (9%)
#> Xanomeline High Dose Xanomeline Low Dose
#> 1 66 (92%) 81 (84%)
#> 2 35 (49%) 36 (38%)
#> 3 25 (35%) 21 (22%)
# ---- Example 4: Multi-row continuous spec + label re-labelling ----
#
# `statistic = c(<label> = <template>, ...)` produces one display
# row per named entry — the canonical "N / Mean (SD) / Median /
# Min, Max" block for continuous variables. `label = c(...)`
# renames the variable headings emitted into the wide output.
cdisc_saf_demo_ard |>
pivot_across(
statistic = list(
continuous = c(
N = "{N}",
"Mean (SD)" = "{mean} ({sd})",
Median = "{median}",
"Q1, Q3" = "{p25}, {p75}",
"Min, Max" = "{min}, {max}"
),
categorical = "{n} ({p}%)"
),
label = c(
AGE = "Age (years)",
WEIGHT = "Weight (kg)",
HEIGHT = "Height (cm)",
BMI = "BMI (kg/m^2)"
)
)
#> variable stat_label Placebo
#> 1 Age (years) N 86
#> 2 Age (years) Mean (SD) 75.2 (8.59)
#> 3 Age (years) Median 76.0
#> 4 Age (years) Q1, Q3 69.0, 82.0
#> 5 Age (years) Min, Max 52.0, 89.0
#> 6 Weight (kg) N 86
#> 7 Weight (kg) Mean (SD) 62.8 (12.77)
#> 8 Weight (kg) Median 60.6
#> 9 Weight (kg) Q1, Q3 53.5, 74.4
#> 10 Weight (kg) Min, Max 34.0, 86.2
#> 11 Height (cm) N 86
#> 12 Height (cm) Mean (SD) 162.6 (11.52)
#> 13 Height (cm) Median 162.6
#> 14 Height (cm) Q1, Q3 153.7, 171.4
#> 15 Height (cm) Min, Max 137.2, 185.4
#> 16 BMI (kg/m^2) N 86
#> 17 BMI (kg/m^2) Mean (SD) 23.6 (3.67)
#> 18 BMI (kg/m^2) Median 23.4
#> 19 BMI (kg/m^2) Q1, Q3 21.2, 25.7
#> 20 BMI (kg/m^2) Min, Max 15.1, 33.3
#> 21 AGEGR1 18-64 14 (16%)
#> 22 AGEGR1 >64 72 (84%)
#> 23 SEX F 53 (62%)
#> 24 SEX M 33 (38%)
#> 25 RACE WHITE 78 (91%)
#> 26 RACE BLACK OR AFRICAN AMERICAN 8 (9%)
#> 27 RACE ASIAN 0
#> 28 RACE AMERICAN INDIAN OR ALASKA NATIVE 0
#> 29 ETHNIC HISPANIC OR LATINO 3 (3%)
#> 30 ETHNIC NOT HISPANIC OR LATINO 83 (97%)
#> 31 ETHNIC NOT REPORTED 0
#> 32 BMI_CAT Underweight (<18.5) 3 (3%)
#> 33 BMI_CAT Normal (18.5-24.9) 57 (66%)
#> 34 BMI_CAT Overweight (25-29.9) 20 (23%)
#> 35 BMI_CAT Obese (>=30) 6 (7%)
#> Xanomeline High Dose Xanomeline Low Dose Total
#> 1 72 96 254
#> 2 73.8 (7.94) 76.0 (8.11) 75.1 (8.25)
#> 3 75.5 78.0 77.0
#> 4 70.0, 79.0 71.0, 82.0 70.0, 81.0
#> 5 56.0, 88.0 51.0, 88.0 51.0, 89.0
#> 6 72 95 253
#> 7 69.5 (14.35) 68.0 (14.50) 66.6 (14.13)
#> 8 69.0 66.7 66.7
#> 9 56.7, 80.3 55.8, 78.5 55.3, 77.1
#> 10 44.5, 108.0 41.7, 106.1 34.0, 108.0
#> 11 72 96 254
#> 12 165.9 (10.28) 163.7 (10.30) 163.9 (10.76)
#> 13 165.1 162.6 162.8
#> 14 157.5, 172.9 157.5, 170.2 156.2, 171.4
#> 15 146.1, 190.5 135.9, 195.6 135.9, 195.6
#> 16 72 95 253
#> 17 25.2 (3.97) 25.2 (4.40) 24.7 (4.09)
#> 18 24.8 24.8 24.2
#> 19 22.7, 27.6 22.2, 28.3 21.9, 27.3
#> 20 13.7, 34.6 15.3, 40.2 13.7, 40.2
#> 21 11 (15%) 8 (8%) 33 (13%)
#> 22 61 (85%) 88 (92%) 221 (87%)
#> 23 35 (49%) 55 (57%) 143 (56%)
#> 24 37 (51%) 41 (43%) 111 (44%)
#> 25 62 (86%) 90 (94%) 230 (91%)
#> 26 9 (12%) 6 (6%) 23 (9%)
#> 27 0 0 0
#> 28 1 (1%) 0 1 (0%)
#> 29 3 (4%) 6 (6%) 12 (5%)
#> 30 69 (96%) 90 (94%) 242 (95%)
#> 31 0 0 0
#> 32 1 (1%) 4 (4%) 8 (3%)
#> 33 39 (54%) 46 (48%) 142 (56%)
#> 34 23 (32%) 32 (34%) 75 (30%)
#> 35 9 (12%) 13 (14%) 28 (11%)
# ---- Example 5: ARD keyed by summary / tabulate contexts ----
#
# The `statistic` list names must match the ARD's `context` column
# verbatim. `cards::ard_summary()` / `ard_tabulate()` emit `"summary"` /
# `"tabulate"` (not the `"continuous"` / `"categorical"` of
# `ard_continuous()` / `ard_categorical()`), so a list keyed
# `continuous`/`categorical` would silently match nothing. Always check
# `unique(ard$context)` first. Here the bundled `cdisc_saf_demo_ard` is
# relabelled to mimic `ard_summary()` + `ard_tabulate()` output; the
# by-variable's own row drops automatically and both the summary and
# the tabulate variables survive.
card_st <- cdisc_saf_demo_ard
card_st$context[card_st$context == "continuous"] <- "summary"
card_st$context[card_st$context == "categorical"] <- "tabulate"
pivot_across(
card_st,
statistic = list(
summary = "{mean} ({sd})",
tabulate = "{n} ({p}%)"
)
)
#> variable stat_label Placebo
#> 1 AGE AGE 75.2 (8.59)
#> 2 WEIGHT WEIGHT 62.8 (12.77)
#> 3 HEIGHT HEIGHT 162.6 (11.52)
#> 4 BMI BMI 23.6 (3.67)
#> 5 AGEGR1 18-64 14 (16%)
#> 6 AGEGR1 >64 72 (84%)
#> 7 SEX F 53 (62%)
#> 8 SEX M 33 (38%)
#> 9 RACE WHITE 78 (91%)
#> 10 RACE BLACK OR AFRICAN AMERICAN 8 (9%)
#> 11 RACE ASIAN 0
#> 12 RACE AMERICAN INDIAN OR ALASKA NATIVE 0
#> 13 ETHNIC HISPANIC OR LATINO 3 (3%)
#> 14 ETHNIC NOT HISPANIC OR LATINO 83 (97%)
#> 15 ETHNIC NOT REPORTED 0
#> 16 BMI_CAT Underweight (<18.5) 3 (3%)
#> 17 BMI_CAT Normal (18.5-24.9) 57 (66%)
#> 18 BMI_CAT Overweight (25-29.9) 20 (23%)
#> 19 BMI_CAT Obese (>=30) 6 (7%)
#> Xanomeline High Dose Xanomeline Low Dose Total
#> 1 73.8 (7.94) 76.0 (8.11) 75.1 (8.25)
#> 2 69.5 (14.35) 68.0 (14.50) 66.6 (14.13)
#> 3 165.9 (10.28) 163.7 (10.30) 163.9 (10.76)
#> 4 25.2 (3.97) 25.2 (4.40) 24.7 (4.09)
#> 5 11 (15%) 8 (8%) 33 (13%)
#> 6 61 (85%) 88 (92%) 221 (87%)
#> 7 35 (49%) 55 (57%) 143 (56%)
#> 8 37 (51%) 41 (43%) 111 (44%)
#> 9 62 (86%) 90 (94%) 230 (91%)
#> 10 9 (12%) 6 (6%) 23 (9%)
#> 11 0 0 0
#> 12 1 (1%) 0 1 (0%)
#> 13 3 (4%) 6 (6%) 12 (5%)
#> 14 69 (96%) 90 (94%) 242 (95%)
#> 15 0 0 0
#> 16 1 (1%) 4 (4%) 8 (3%)
#> 17 39 (54%) 46 (48%) 142 (56%)
#> 18 23 (32%) 32 (34%) 75 (30%)
#> 19 9 (12%) 13 (14%) 28 (11%)
# ---- Example 6: Analysis variables as side-by-side column bands ----
#
# `column = c(".variable", "<arm>")` turns each analysis variable into
# its own band of arm columns (the "value and change side by side"
# shape), with statistics stacked as rows. Per-variable `statistic` /
# `decimals` resolve inside each band. Emitted columns are named
# "<variable>..<arm>"; reference them verbatim in a manual `headers()`
# call to draw the band spanners (pivot_across never builds spanners).
vitals <- cdisc_saf_demo_ard[
cdisc_saf_demo_ard$variable %in% c("AGE", "WEIGHT"),
]
vitals |>
pivot_across(
column = c(".variable", "TRT01A"),
overall = NULL,
statistic = list(
AGE = c(N = "{N}", "Mean (SD)" = "{mean} ({sd})", Median = "{median}"),
WEIGHT = c(N = "{N}", "Mean (SD)" = "{mean} ({sd})", Median = "{median}")
),
decimals = list(
AGE = c(mean = 1, sd = 2, median = 1),
WEIGHT = c(mean = 1, sd = 2, median = 1)
)
) |>
tabular(
titles = c(
"Table 14.2.1",
"Summary of Continuous Parameters by Treatment",
"Safety Population"
)
) |>
cols(stat_label = col_spec(label = "Statistic")) |>
headers(
Age = c(
"AGE..Placebo",
"AGE..Xanomeline Low Dose",
"AGE..Xanomeline High Dose"
),
Weight = c(
"WEIGHT..Placebo",
"WEIGHT..Xanomeline Low Dose",
"WEIGHT..Xanomeline High Dose"
)
)
Table 14.2.1
Summary of Continuous Parameters by Treatment
Safety Population
Age Weight Statistic AGE..Placebo AGE..Xanomeline High Dose AGE..Xanomeline Low Dose WEIGHT..Placebo WEIGHT..Xanomeline High Dose WEIGHT..Xanomeline Low Dose N 86 72 96 86 72 95 Mean (SD) 75.2 (8.59) 73.8 (7.94) 76.0 (8.11) 62.8 (12.77) 69.5 (14.35) 68.0 (14.50) Median 76.0 75.5 78.0 60.6 69.0 66.7
# ---- Example 7: Statistics as columns within each band ----
#
# `column = c(".variable", ".stat")` spreads each statistic entry into
# its own column (the landscape shell); the arm drops to a leading row
# stub. Emitted columns are named "<variable>..<stat-entry>". Bands may
# carry different stat sets, with no row-alignment needed.
vitals |>
pivot_across(
column = c(".variable", ".stat"),
overall = NULL,
statistic = list(
AGE = c(N = "{N}", Mean = "{mean}", SD = "{sd}"),
WEIGHT = c(N = "{N}", Mean = "{mean}", SD = "{sd}", Median = "{median}")
),
decimals = c(mean = 1, sd = 2, median = 1)
)
#> TRT01A AGE..N AGE..Mean AGE..SD WEIGHT..N WEIGHT..Mean
#> 1 Placebo 86 75.2 8.59 86 62.8
#> 2 Xanomeline High Dose 72 73.8 7.94 72 69.5
#> 3 Xanomeline Low Dose 96 76.0 8.11 95 68.0
#> WEIGHT..SD WEIGHT..Median
#> 1 12.77 60.6
#> 2 14.35 69.0
#> 3 14.50 66.7
# ---- Example 8: Per-row-group decimal precision ----
#
# A by-parameter vitals table where each parameter carries its own
# value precision: systolic BP to 0 dp, weight to 1 dp, in ONE call.
# `decimals` is keyed by the `row_group` (PARAM) value; the engine
# selects each row's token precision by its parameter. No bundled ARD
# carries a second grouping dimension, so build a tiny one inline.
vital_ard <- do.call(rbind, lapply(
list(
c("SYSBP", "Placebo", "mean", "133.27"),
c("SYSBP", "Placebo", "sd", "15.81"),
c("SYSBP", "Drug", "mean", "128.94"),
c("SYSBP", "Drug", "sd", "14.02"),
c("WEIGHT", "Placebo", "mean", "71.43"),
c("WEIGHT", "Placebo", "sd", "12.77"),
c("WEIGHT", "Drug", "mean", "73.06"),
c("WEIGHT", "Drug", "sd", "13.19")
),
function(r) {
data.frame(
group1 = "PARAM",
group1_level = r[[1]],
group2 = "TRTA",
group2_level = r[[2]],
variable = "AVAL",
variable_level = NA_character_,
context = "continuous",
stat_name = r[[3]],
stat_label = r[[3]],
stat = I(list(as.numeric(r[[4]]))),
stringsAsFactors = FALSE
)
}
))
vital_ard |>
pivot_across(
column = "TRTA",
row_group = "PARAM",
overall = NULL,
statistic = list(continuous = "{mean} ({sd})"),
decimals = list(
SYSBP = c(mean = 0, sd = 1),
WEIGHT = c(mean = 1, sd = 2)
)
)
#> PARAM variable stat_label Placebo Drug
#> 1 SYSBP AVAL AVAL 133 (15.8) 129 (14.0)
#> 2 WEIGHT AVAL AVAL 71.4 (12.77) 73.1 (13.19)