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:
-
timeandeventfor the event-time types, andtime2for late entry; -
effect_modifierandstratumto 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 aTrendcolumn; -
digits,nmin,na_rm,ciandto, per-row overrides of the table’s formatting; -
arguments, a list column of named lists:timepointfor the survival types,approachfor the risk models and the survival contrasts,robustfor 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, customestimate_*()functions and thequantregtype 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.