Skip to contents

Introduction

Kenneth Rothman argued in 2017 that epidemiologic papers had drifted away from their data: results tables show adjusted coefficients and little else, and a reader can no longer see the counts, the person-time and the crude contrasts the estimates came from. Konrad Stopsack’s rifttable package answers that with a table whose rows are estimands and whose columns are the levels of an exposure, so “Deaths/N”, “Rate”, “Hazard ratio” and “Age-adjusted hazard ratio” sit in one table with the descriptive numbers above the modeled ones.

tbl_rift() builds that table in ltsummary. The design vocabulary is rifttable’s, so a design written for rifttable::rifttable() builds the same table here, computed in base R plus survival, and the result is an ordinary ltsummary table: modify_header(), bold_labels(), inline_text(), tbl_stack() and as_lt() all apply. This article walks through the design, the estimands, and the estimators behind them, and closes with notes for rifttable users.

The examples use the package’s trial data set: two chemotherapy arms (trt), tumor response (response), death and months of follow-up (death, ttdeath), age and tumor grade.

A first table

A design is a data frame with one row per table row. type names the estimand; label is the text of the first column; exposure and outcome name the variables. Only type is required.

design <- data.frame(
  label = c("Responders/N (%)", "Risk ratio (95% CI)", "Risk difference (95% CI)"),
  type = c("outcomes/total (risk)", "rr", "rd"),
  outcome = "response",
  exposure = "trt",
  na_rm = TRUE
)
tbl_rift(design, trial, risk_percent = TRUE)

Each level of the exposure is a column, and the comparative rows show every level against the first, whose cell reads 1 (reference) or 0 (reference). The risk ratio and risk difference come from marginal standardization after logistic regression, which without covariates is the crude ratio with its log-scale interval and the crude difference with its Wald interval.

na_rm = TRUE drops the seven patients whose response is missing. Without it the cells that depend on the count would read --, because rifttable, and so tbl_rift(), treat a missing value as making the count unknown rather than dropping it silently.

The design

Beyond label, type, exposure and outcome, a design may carry:

  • time and event for the event-time types, and time2 for late entry;
  • effect_modifier and stratum to restrict a row to one stratum of another variable;
  • confounders, the tail of a model formula such as "+ age + grade";
  • trend, a numeric version of the exposure whose per-unit estimate goes in a Trend column;
  • digits, nmin, na_rm, ci and to, per-row overrides of the table’s formatting;
  • arguments, a list column of named lists: timepoint for the survival types, approach for the risk models and the survival contrasts, robust for the Cox model.

Labels carry the layout. A label that starts with two spaces is indented, a label in double asterisks is bold, and an empty type gives a spacer row or a section header:

design <- data.frame(
  label = c("**Tumor response**", "  Responders/N (%)", "  Risk ratio (95% CI)", "",
            "**Age**", "  Mean (SD)", "  Difference (95% CI)"),
  type = c("", "outcomes/total (risk)", "rr", "", "", "mean (sd)", "diff"),
  outcome = c(NA, "response", "response", NA, NA, "age", "age"),
  exposure = "trt",
  na_rm = TRUE
)
tbl_rift(design, trial, risk_percent = TRUE, diff_digits = 1)

?tbl_rift_design lists every type: counts, proportions and Wilson intervals for binary outcomes; sums, means, medians, ranges and geometric means for continuous ones; events, person-time, rates, survival, cumulative incidence, median survival and median follow-up for event-time outcomes; and the comparative types rr, rd, or, diff, fold, foldlog, irr, irrrob, hr, survdiff, survratio, cumincdiff and cumincratio.

Survival rows

The event-time types take time and event. A timepoint in arguments fixes the time of the survival estimates and contrasts.

design <- data.frame(
  label = c("Deaths/N", "Deaths per 100 person-months (95% CI)", "12-month survival (95% CI)",
            "Difference in 12-month survival (95% CI)", "Hazard ratio (95% CI)",
            "Age-adjusted hazard ratio (95% CI)"),
  type = c("events/total", "rate (ci)", "surv (ci)", "survdiff", "hr", "hr"),
  confounders = c("", "", "", "", "", "+ age"),
  time = "ttdeath",
  event = "death",
  exposure = "trt"
)
design$arguments <- list(NULL, NULL, list(timepoint = 12), list(timepoint = 12), NULL, NULL)
tbl_rift(design, trial, factor = 100, risk_percent = TRUE)

The rate is scaled by factor and carries normal-log limits. The survival probability and its limits are those of survival::survfit(), and the difference between two arms uses the MOVER method of Zou and Donner (2008), which builds the interval of a difference from the two arms’ own intervals and keeps their asymmetry. The hazard ratios come from survival::coxph(), and strata() and cluster() may appear in confounders.

With competing events, event = "variable@level" names a factor whose first level is the censoring code, and the cumulative-incidence types switch to the Aalen-Johansen estimate of that level.

Strata, joint models and a trend

effect_modifier and stratum restrict a row to one stratum. A type with the suffix _joint fits one model of the exposure and the effect modifier together, with a single reference category, and the row shows the coefficients of its stratum; the classic display of effect modification puts the stratified and the joint estimates one under the other.

design <- data.frame(
  label = c("**Grade I**", "  Deaths/N", "  Hazard ratio (95% CI)",
            "**Grade II**", "  Deaths/N", "  Hazard ratio (95% CI)",
            "**Grade III**", "  Deaths/N", "  Hazard ratio (95% CI)",
            "**Joint model**", "  Grade I", "  Grade II", "  Grade III"),
  type = c("", "events/total", "hr", "", "events/total", "hr", "", "events/total", "hr",
           "", "hr_joint", "hr_joint", "hr_joint"),
  stratum = c(NA, "I", "I", NA, "II", "II", NA, "III", "III", NA, "I", "II", "III"),
  effect_modifier = "grade",
  time = "ttdeath",
  event = "death",
  exposure = "trt"
)
tbl_rift(design, trial)

A trend variable adds a column with the estimate per unit of a numeric exposure, next to the categorical contrasts:

trial$age_decade <- trial$age / 10
trial$age_group <- cut(trial$age, c(0, 45, 55, 100), labels = c("<45", "45-54", "55+"))
design <- data.frame(
  label = c("Deaths/N", "Hazard ratio (95% CI)"),
  type = c("events/total", "hr"),
  trend = c(NA, "age_decade"),
  time = "ttdeath",
  event = "death",
  exposure = "age_group"
)
tbl_rift(design, trial) |>
  modify_header(trend = "**Per decade**")

Formatting

The table-level arguments follow rifttable: risk_percent shows risks as percentages, risk_digits, diff_digits, ratio_digits and rate_digits set the decimals of each family, and ratio_digits_decrease drops decimals from large ratios so that ratios read 1.23, 3.5 and 12 (Wilcox, 2004). reference is the text of the reference cell, overall = TRUE adds a first column with the descriptive estimands over all patients, and exposure_levels decides whether missing values of the exposure get a column of their own. nmin in the design replaces sparse cells with -- (<nmin).

to, the separator between confidence limits, defaults to the pkgwide-str:ci.sep theme element, so a journal theme changes it along with the rest of the document:

with_ltsummary_theme(
  theme_ltsummary_journal("jama", set_theme = FALSE),
  tbl_rift(design, trial)
)

After the table

The result is an ltsummary table. The header statistics of the exposure columns are filled, so {level} and {n} work in modify_header(); add_overall() adds the overall column later; and inline_text() reads a cell, or the numbers behind it:

tbl <- tbl_rift(design, trial) |>
  add_overall() |>
  modify_header(all_stat_cols() ~ "**{level}**  \nN = {n}") |>
  bold_labels()
tbl
inline_text(tbl, variable = "Hazard ratio (95% CI)", column = "55+")
#> [1] "1.04 (0.50, 2.18)"
inline_text(tbl, variable = "Hazard ratio (95% CI)", column = "55+", pattern = "HR {estimate}, p {p.value}")
#> [1] "HR 1.04, p >0.9"
inline_text(tbl, variable = "Deaths/N", column = "<45", pattern = "{events} of {N}")
#> [1] "8 of 17"

Two tables with the same exposure stack, and a table merges with another built on the same rows.

The estimators on their own

The numbers behind the comparative rows are exported as functions, so they can be used outside a design. rift_risk_ratio() and rift_risk_difference() estimate a risk ratio or difference by marginal standardization (or with a log-binomial model, or a Poisson model with robust variance), rift_survival_difference() and rift_survival_ratio() contrast survival or cumulative incidence at a time point, and rift_rate() reports an incidence rate:

rift_risk_ratio(trial, variable = "response", by = "trt", adj.vars = "grade")
#>    level reference estimate std.error statistic   p.value  conf.low conf.high
#> 1 Drug B    Drug A 1.078507 0.2557608 0.2955004 0.7676117 0.6533094  1.780438
#>                                                method
#> 1 Risk ratio (marginal standardization, delta method)
rift_survival_difference(Surv(ttdeath, death) ~ trt, trial, time = 12)
#>    level reference  estimate  std.error statistic    p.value     conf.low
#> 1 Drug B    Drug A 0.0962406 0.05221127  1.843292 0.06528644 -0.008266038
#>   conf.high                      method
#> 1 0.1963984 Survival difference (MOVER)
rift_rate(trial, event = "death", time = "ttdeath", factor = 100)
#>   events    time estimate conf.low conf.high                      method
#> 1     93 3941.58  2.35946 1.925516    2.8912 Incidence rate (normal-log)

The risk functions follow the calling convention of a custom add_difference() method, so a summary table can carry a risk ratio where gtsummary offers only a difference in proportions:

trial |>
  tbl_summary(by = trt, include = response, missing = "no") |>
  add_difference(test = ~ "rift_risk_ratio", estimate_fun = ~ label_style_ratio()) |>
  modify_header(estimate = "**Risk Ratio**")

And any of them fits a stat_fns function of tbl_custom_summary():

trial |>
  tbl_custom_summary(
    include = grade,
    stat_fns = ~ function(data, ...) rift_rate(data, "death", "ttdeath", factor = 100),
    statistic = ~ "{estimate} ({conf.low}, {conf.high})",
    digits = ~ 1
  ) |>
  modify_header(stat_0 = "**Deaths per 100 person-months (95% CI)**")

Migrating from rifttable

A design moves over unchanged: the column names, the type strings, the arguments list column and the arguments of tbl_rift() are those of rifttable 0.7. rt_gt() has no counterpart because the result is already a table; print it, or convert it with as_lt(). The cells match rifttable’s on its own examples, with these differences:

  • Numbers are rounded half away from zero and carry the theme’s decimal and big marks, where rifttable rounds to even and prints no big mark.
  • The cumulative-incidence contrasts (cumincdiff, cumincratio) pair each arm’s lower limit with its lower limit, as Zou and Donner describe. rifttable 0.7.2 from CRAN pairs them the other way round, which mirrors its interval about the estimate; rifttable’s development version corrects this (stopsack/rifttable#14, for rifttable 0.7.3), and these rows then agree. The survival contrasts agree in every version.
  • A model that cannot be fit, or does not converge, shows -- and the message is reported once after the table is built; a continuous exposure is an error rather than a warning.
  • type2, layout = "cols", weights, id, custom estimate_*() functions and the quantreg type are not available in this version.

References

Rothman KJ. The growing rift between epidemiologists and their data. Eur J Epidemiol 2017;32:863-5.

Stopsack KH. rifttable: Results tables to bridge the rift between epidemiologists and their data. R package, https://stopsack.github.io/rifttable/.

Zou GY, Donner A. Construction of confidence limits about effect measures: a general approach. Stat Med 2008;27:1693-702.

Wilcox AJ. On precision. Epidemiology 2004;15:1.