Skip to contents

Where statistics come from in tplyr2

tplyr2 is a grammar for building clinical summary tables, not a statistical modeling engine. Every number in a table arrives through one of four routes, and knowing which to reach for is most of the work:

You need Use Notes
A descriptive summary (mean, SD, quantiles, …) group_desc() built-in stats See vignette("desc")
Counts / incidence, n (%) group_count() built-in stats See vignette("count")
A test or interval computed across the treatment columns from the cell counts risk_diff, assoc_test(), single-proportion CIs This vignette
A statistic computed by a model fit (MMRM, ANCOVA, Cox, logistic, …) fit it externally and bind the formatted result on This vignette
A fully custom in-spec statistic computed within one column group_analyze() See vignette("analyze")

The dividing line is simple: if the statistic can be built from the assembled cell counts (or the raw rows) across arms, tplyr2 can compute it natively. If it requires fitting a model, tplyr2’s job is to build the descriptive block and give you a clean seam to attach the model result. This vignette covers the last two rows of that table.

Native comparisons across the treatment columns

These are computed by the layer itself, so the comparison shares one source of truth with the n (%) / summary block beside it.

Risk difference

risk_diff emits pairwise, per-level difference-in-proportion columns (rdiff1, rdiff2, … — one per comparison, a value on every target-level row) via stats::prop.test(). It has its own vignette, vignette("riskdiff"); reach for it when you want an asymptotic risk difference with a confidence interval.

Association tests with assoc_test()

assoc_test() attaches an arbitrary test — you supply the function, so Fisher’s exact, a chi-square, or a coin::cmh_test() all drop in. It has two modes.

Omnibus mode (the default) runs your function once per by group over the raw source rows for that group (all treatment columns at once), and lands a single value on the group’s first output row. It works on count, shift, and desc layers (a desc layer is shown below). This is the shape for a per-analyte test that collapses across arms:

fisher_p <- function(.data) {
  suppressWarnings(fisher.test(table(.data$TRT01P, .data$SEX))$p.value)
}

spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_count("SEX",
      settings = layer_settings(
        format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct")),
        assoc_test = assoc_test(fn = fisher_p,
                                format = f_str("x.xxx", "p"),
                                label = "Fisher p")))))

b <- tplyr_build(spec, tplyr_adsl)
kable(as_display(b))
rowlabel1 res1 res2 res3 pval1
F 53 (61.6%) 40 (47.6%) 50 (59.5%) 0.152
M 33 (38.4%) 44 (52.4%) 34 (40.5%)

The p-value sits in the trailing pval1 column on the first row. Your fn receives the raw rows, so any test that works on a data frame works here.

Omnibus mode also works on a group_desc layer — the natural home for a continuous comparison across arms (ANOVA, Kruskal-Wallis, a t-test). The contract is identical (one p per by group, on that group’s first statistic row), so a demographics table gets its continuous p-values the same way its categorical ones do, sharing a single pval1 column:

spec_age <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_desc("AGE",
      settings = layer_settings(
        format_strings = list(Mean = f_str("xx.x", "mean"), SD = f_str("xx.xx", "sd")),
        assoc_test = assoc_test(
          fn = function(.data) anova(lm(AGE ~ TRT01P, .data))[["Pr(>F)"]][1],
          format = f_str("x.xxx", "p"), label = "ANOVA p")))))

kable(as_display(tplyr_build(spec_age, tplyr_adsl)))
rowlabel1 res1 res2 res3 pval1
Mean 75.2 74.4 75.7 0.593
SD 8.59 7.89 8.29

Pairwise / per-level mode (new in 0.2.0, count layers only) is switched on by supplying comparisons. It compares a reference arm to each other arm, emits one pval<k> column per comparison with a value on every target-level row, and hands your fn a ready-made incidence 2x2 per (level, comparison) built from the assembled counts and population denominators:

group_count("AEDECOD",
  settings = layer_settings(
    distinct_by = "USUBJID",
    stat_columns = list("n" = f_str("xx (xx.x%)", "distinct_n", "distinct_pct")),
    assoc_test = assoc_test(
      fn          = function(m) fisher.test(m)$p.value,  # m = 2x2 count matrix
      reference   = "Placebo",
      comparisons = c("Low", "High"),
      format      = f_str("x.xxx", "p"),
      label       = c("Placebo vs Low", "Placebo vs High"))))
#>   rowlabel1       res1       res2       res3 pval1 pval2
#> 1  HEADACHE  3 (50.0%)  1 (20.0%)  2 (50.0%) 0.524 1.000
#> 2    NAUSEA  2 (33.3%)  3 (60.0%)  1 (25.0%) 0.524 1.000

The fn receives matrix(c(n_ref, n_cmp, N_ref - n_ref, N_cmp - n_cmp), nrow = 2) — rows are the (reference, comparison) arm, columns are (event, no-event) — using distinct counts and denominators when distinct_by is set. Because it is just a matrix in and a scalar out, any 2x2 test (Fisher, chi-square, relative risk, …) plugs in.

These snippets show only the count layer, to keep the focus on assoc_test(). In a real adverse-event build the enclosing tplyr_spec() sets pop_data(), so the displayed incidence and the N_ref/N_cmp cells of the 2x2 use the safety population rather than only the subjects who had events (see vignette("count") and vignette("adverse-events"), which builds this pattern end to end). The stat_columns shown here is also optional — format_strings works identically; pairwise mode does not require stat_columns.

Nested layers — the AE-by-SOC/PT case — work the same way: give group_count() a nested target such as c("AEBODSYS", "AEDECOD") and the pval columns land on every row of every level. Each preferred-term row and each system-organ-class subtotal row gets its own 2x2, built from that row’s counts (the subtotal uses the SOC-level “any event in that class” subject count) and the population denominators — the standard per-row Fisher column. When the layer carries a total row, the grand-total (“any event anywhere”) p-value is computed too; pass assoc_test(..., total_row = FALSE) to leave it blank. Missing rows are always blank.

Returning the finished display string. fn may return a character string instead of a number, and it is dropped into the cell verbatim (format then applies only to numeric returns). The function that computes the test can therefore also encode the display conventions clinical AE tables use — a significance flag, a >.99/<.0001 ceiling, trailing-space alignment, or a "NE" sentinel — right where the raw p-value is in hand (NA, numeric or character, still renders a blank):

ae_pval <- function(m) {
  if (sum(m[, 1]) == 0) return(NA_character_)          # both arms zero -> blank
  p <- fisher.test(m)$p.value
  d <- formatC(round(p, 3), format = "f", digits = 3)
  if (p > .99) ">.99" else if (p < .15) paste0(d, "*") else paste0(d, " ")
}
group_count("AEDECOD",
  settings = layer_settings(
    distinct_by = "USUBJID",
    stat_columns = list("n" = f_str("xx (xx.x%)", "distinct_n", "distinct_pct")),
    assoc_test = assoc_test(fn = ae_pval,
                            reference = "Placebo", comparisons = c("Low", "High"))))

Returning several statistics. A p-value is not the only thing a test produces. When format references more than one variable, fn returns a numeric vector matching it (mapped positionally), so an effect size and its confidence interval land in a single cell. For example, an odds ratio with a 95% CI straight from fisher.test():

or_ci <- function(m) {
  ft <- fisher.test(m)
  c(ft$estimate, ft$conf.int[1], ft$conf.int[2])   # OR, lower, upper
}
group_count("AEDECOD",
  settings = layer_settings(
    distinct_by = "USUBJID",
    stat_columns = list("n" = f_str("xx (xx.x%)", "distinct_n", "distinct_pct")),
    assoc_test = assoc_test(fn = or_ci,
                            reference = "Placebo", comparisons = c("Low", "High"),
                            format = f_str("xx.xx (xx.xx, xx.xx)", "or", "lo", "hi"),
                            label = "OR (95% CI)")))

Each pval column then reads like 1.85 (1.10, 3.02) – the odds ratio and its interval in one cell. Any statistical procedure that emits a small tuple fits this pattern: an estimate with a p-value, a hazard ratio with an interval, and so on. The values map to the format variables positionally, so their names are free; an all-NA return (or one whose length does not match format) blanks the cell.

Single-proportion confidence intervals

Count layers can attach an exact or score confidence interval for each cell’s proportion (new in 0.2.0). Set ci_method / ci_level and reference the ci_lower / ci_upper (or distinct_ci_lower / distinct_ci_upper) keywords in a format string:

group_count("AEDECOD",
  settings = layer_settings(
    distinct_by = "USUBJID",
    ci_method   = "clopper_pearson",     # also: wilson, wald, agresti_coull, jeffreys
    format_strings = list(
      n_counts = f_str("xx (xx.x%) [xx.x, xx.x]",
                       "distinct_n", "distinct_pct",
                       "distinct_ci_lower", "distinct_ci_upper"))))
#>   rowlabel1                       res1
#> 1  HEADACHE  12 (30.0%) [16.6, 46.5]

clopper_pearson matches SAS PROC FREQ ... EXACT (and stats::binom.test); wilson matches stats::prop.test(correct = FALSE).

Bringing your own statistics

Model-based results — MMRM/ANCOVA p-values, LS-means, hazard ratios, odds ratios, confidence-interval strings — are computed by a dedicated package (emmeans, mmrm, survival, rbmi, …) and bound onto the assembled table. The workflow is always the same five steps:

  1. Build the descriptive block with tplyr2 and pull the display frame with as_display() (drops the internal ord_* columns; the build is already ordered).
  2. Compute the statistic with whatever package you like.
  3. Format the result into character cells with apply_formats() (the same f_str() spec, rounding, and alignment as the rest of the table).
  4. Align it to the table — as a new column (join on the row label) or a new row (rbind), matching the rowlabel* / res* shape.
  5. Hand off to your table-rendering package.

Attach a statistic as a column

Here we compute a per-level p-value ourselves and bind it beside the n (%) block. (We use base R so the vignette stays dependency-free; swap in your own model.)

# 1. descriptive block
spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_count("SEX",
      settings = layer_settings(
        format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct"))))))
disp <- as_display(tplyr_build(spec, tplyr_adsl))

# 2. compute a statistic per row (here: chi-square of SEX x TRT for each SEX
#    level vs the rest). Substitute any model here.
pval <- vapply(disp$rowlabel1, function(lvl) {
  tab <- table(tplyr_adsl$TRT01P, tplyr_adsl$SEX == lvl)
  suppressWarnings(chisq.test(tab)$p.value)
}, numeric(1))

# 3. format with the SAME machinery as the table
disp$pval <- apply_formats(f_str("x.xxx", "p"), pval)

# 4. it is already aligned row-for-row (as_display() preserves build order)
kable(disp)
rowlabel1 res1 res2 res3 pval
F 53 (61.6%) 40 (47.6%) 50 (59.5%) 0.141
M 33 (38.4%) 44 (52.4%) 34 (40.5%) 0.141

Formatting the external value through apply_formats() — rather than sprintf() or format() — keeps rounding (including the IBM half-up option) and width identical to the layer-computed cells.

Attach a statistic as a row

For a p-value that belongs under a descriptive block (e.g. an ANCOVA of a continuous endpoint), format one cell and rbind a labelled row:

spec <- tplyr_spec(
  cols = "TRT01P",
  layers = tplyr_layers(
    group_desc("AGE",
      settings = layer_settings(
        format_strings = list(n = f_str("xx", "n"),
                              "Mean (SD)" = f_str("xx.x (xx.xx)", "mean", "sd"))))))
disp <- as_display(tplyr_build(spec, tplyr_adsl))

# an overall test across arms (substitute lm()/emmeans()/mmrm() as needed)
p <- anova(lm(AGE ~ TRT01P, tplyr_adsl))[["Pr(>F)"]][1]

# build a matching row: label + one p-value cell in the first result column,
# blank in the rest, then bind it on
res_cols <- grep("^res", names(disp), value = TRUE)
p_row <- disp[1, ]                                   # a row of the right shape
p_row$rowlabel1 <- "p-value"
p_row[res_cols] <- ""
p_row[[res_cols[1]]] <- apply_formats(f_str("x.xxx", "p"), p)

out <- rbind(disp, p_row)
rownames(out) <- NULL
kable(out)
rowlabel1 res1 res2 res3
n 86 84 84
Mean (SD) 75.2 ( 8.59) 74.4 ( 7.89) 75.7 ( 8.29)
p-value 0.593

Missing or non-estimable results

When a model does not converge or a cell is not estimable, the statistic is NA. apply_formats() gains an na argument (new in 0.2.0) so a missing value collapses to a truly empty string rather than a run of spaces — which keeps is.na() / nzchar() checks and downstream trimming honest:

apply_formats(f_str("xx.x", "x"), c(2.3, NA, 12.7), na = "")
#> [1] " 2.3" ""     "12.7"

apply_formats(f_str("xx.x", "x"), NA_real_, na = "NE")   # or any placeholder
#> [1] "NE"

An optional width = (with pad = "right"/"left") pads the token to a fixed total width for monospace alignment when the renderer does not own it.

group_analyze() for in-spec custom statistics

group_analyze() runs a function you supply inside the spec, so a custom statistic travels with the layer, ordering, and metadata. It is the right tool for a bespoke descriptive statistic (a trimmed mean, a custom responder definition, a formatted median with a non-standard CI). See vignette("analyze") for the full contract.

One boundary is worth stating here: analyze_fn is called once per cols x by combination, so it only ever sees a single treatment column at a time. It therefore cannot compute a statistic that compares across arms — for that, use assoc_test() (which sees all columns) or the external-binding pattern above.

Handing off to a table package

as_display() returns the display columns only (rowlabel*, res*, rdiff*, pval*), already ordered, ready for clinify, flextable, gt, or huxtable. Pass labels = TRUE to rename the result columns to their column-group headers, and see vignette("post_processing") for row masking and label collapsing.

b <- tplyr_build(
  tplyr_spec(cols = "TRT01P",
             layers = tplyr_layers(group_count("SEX",
               settings = layer_settings(
                 format_strings = list(n_counts = f_str("xx (xx.x%)", "n", "pct")))))),
  tplyr_adsl)
kable(as_display(b, labels = TRUE))
rowlabel1 Placebo (N=86) Xanomeline High Dose (N=84) Xanomeline Low Dose (N=84)
F 53 (61.6%) 40 (47.6%) 50 (59.5%)
M 33 (38.4%) 44 (52.4%) 34 (40.5%)