Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export(add_grade_column)
export(add_hierarchical_count_row)
export(add_label_column)
export(add_overall)
export(add_proportion_difference)
export(add_proportion_odds_ratio)
export(adjust_stat_columns_wrap)
export(annotate_coxph)
export(annotate_lineplot_df)
Expand Down Expand Up @@ -68,6 +70,7 @@ export(tbl_hierarchical_rate_by_grade)
export(tbl_listing)
export(tbl_mmrm)
export(tbl_null_report)
export(tbl_proportion)
export(tbl_rmpt)
export(tbl_roche_subgroups)
export(tbl_roche_summary)
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

* `gg_lineplot()` gains a `show_n` argument to append group sizes to the legend (e.g. `"Placebo (N=42)"`) and a `jitter` argument to horizontally separate overlapping groups while keeping points, lines, and error bars aligned. (#307)

* Added `tbl_proportion()` to summarize a binary or multinomial variable as proportions with confidence intervals (estimate and CI on separate rows), with a selectable CI `method` and `conf.level`. The CI row label can be overridden with `ci_label`; the default for the continuity-corrected methods now reads "with continuity correction". (#303)

* Added `add_proportion_difference()` to append the difference in rates, its confidence interval, and a p-value (`"chisq"`, `"fisher"`, or `"cmh"`), with stratified (Cochran-Mantel-Haenszel) support via `strata`. The `correct` flag controls the continuity correction on the difference interval and `test_correct` (default `FALSE`) the Yates correction on the chi-squared p-value. (#303)

* Added `add_proportion_odds_ratio()` to append an odds ratio, with stratified (Cochran-Mantel-Haenszel) support via `strata`. (#303)

* Added `modify_split_caption()` to subtitle each page of a split `{gtsummary}` table (e.g. from `tbl_listing()`, `tbl_baseline_chg()`, or `tbl_shift()`) from its split level via a glue `pattern` (default `"Parameter: {spl_level}"`) and hide the now-redundant split column. (#282)

* `annotate_lineplot_df()` and `annotate_pkc_df()` gain a `font_size` argument to control the summary table font size, matching `annotate_riskdf()`. The `text_size` argument of `annotate_pkc_df()` is soft-deprecated in favor of `font_size`. (#295)
Expand Down
279 changes: 279 additions & 0 deletions R/add_proportion_difference.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
#' Proportion Comparisons
#'
#' @description
#' Add between-group comparison rows to a [tbl_proportion()] table.
#'
#' - `add_proportion_difference()` adds a block with the difference in response
#' rates, its confidence interval, and a test p-value.
#' - `add_proportion_odds_ratio()` adds an odds-ratio row (with confidence
#' interval).
#'
#' Both verbs compare every non-reference group against `reference`. Supplying
#' `strata` switches the p-value (and odds ratio) to their stratified
#' (Cochran-Mantel-Haenszel) counterparts, so an unstratified and a stratified
#' block can be stacked by calling the verb twice.
#'
#' All statistical choices (test, method, confidence level, continuity
#' correction) live on these functions. Row wording is overridable through the
#' label arguments.
#'
#' @param x (`tbl_proportion`)\cr
#' a table created by [tbl_proportion()] on a binary variable (built with the
#' `value` argument).
#' @param reference (`string`)\cr
#' value of the `by` variable used as reference. Defaults to the first level.
#' @param test (`string`)\cr
#' test for the p-value: `"chisq"` (Chi-squared, default), `"fisher"`
#' (Fisher's exact), or `"cmh"` (Cochran-Mantel-Haenszel, requires `strata`).
#' @param strata (`string`)\cr
#' optional stratification variable; when supplied the p-value uses CMH and
#' the default header becomes `"Stratified Analysis"`. Default `NULL`.
#' @param conf.level (`scalar`)\cr
#' confidence level. Default `0.95`.
#' @param correct (`flag`)\cr
#' continuity correction for the difference confidence interval (the
#' "Wald, with continuity correction" interval from [stats::prop.test()]).
#' Default `TRUE`.
#' @param test_correct (`flag`)\cr
#' Yates continuity correction for the chi-squared test p-value. Default
#' `FALSE`, matching the NEST 1.0 catalog (RSPT01). Has no effect on the
#' Fisher or CMH tests.
#' @param header,diff_label,pvalue_label,ci_label (`string`)\cr
#' row labels. `header` defaults to `"Unstratified Analysis"` /
#' `"Stratified Analysis"`; `pvalue_label` and `ci_label` are derived from
#' `test`, `conf.level` and `correct` when `NULL`.
#' @param estimate_fun,pvalue_fun (`function`)\cr
#' formatters for the difference/CI values and the p-value.
#'
#' @returns the input `tbl_proportion` with comparison rows appended.
#' @name add_proportion_difference
#' @seealso [tbl_proportion()]
#'
#' @examples
#' set.seed(1)
#' df <- data.frame(
#' arm = factor(sample(c("A", "B", "C"), 200, TRUE), levels = c("A", "B", "C")),
#' rsp = sample(c(TRUE, FALSE), 200, TRUE)
#' )
#' tbl_proportion(df, "rsp", "arm", value = TRUE) |>
#' add_proportion_difference(reference = "A") |>
#' add_proportion_odds_ratio(reference = "A")
#'
#' @export
add_proportion_difference <- function(x,
reference = NULL,
test = c("chisq", "fisher", "cmh"),
strata = NULL,
conf.level = 0.95,
correct = TRUE,
test_correct = FALSE,
header = NULL,
diff_label = "Difference in Response rate (%)",
pvalue_label = NULL,
ci_label = NULL,
estimate_fun = label_roche_number(digits = 1),
pvalue_fun = label_roche_pvalue()) {
set_cli_abort_call()
check_class(x, "tbl_proportion")
check_range(conf.level, range = c(0, 1))
check_class(estimate_fun, "function")
check_class(pvalue_fun, "function")
check_string(diff_label)
check_scalar_logical(correct)
check_scalar_logical(test_correct)
test <- arg_match(test)
if (!is.null(strata)) test <- "cmh"

meta <- .check_proportion_comparison(x, strata, test)
reference <- reference %||% meta$lvls[1]
.check_reference(reference, meta$lvls)

header <- header %||% if (is.null(strata)) "Unstratified Analysis" else "Stratified Analysis"
ci_label <- ci_label %||%
glue("{style_roche_number(conf.level, scale = 100)}% CI ({if (correct) 'Wald, with continuity correction' else 'Wald'})")
pvalue_label <- pvalue_label %||% .default_test_label(test)

# per-level statistics -------------------------------------------------------
cmp_lvls <- setdiff(meta$lvls, reference)
stats_by_level <- lapply(
cmp_lvls,
function(lv) {
.proportion_diff_one(
data = meta$data, variable = meta$variable, by = meta$by,
value = meta$value, reference = reference, level = lv,
strata = strata, test = test, conf.level = conf.level,
correct = correct, test_correct = test_correct
)
}
)
names(stats_by_level) <- cmp_lvls

# build the comparison rows using the reliable column -> level map -----------
rows <- .format_comparison_block(
x = x, reference = reference, stats_by_level = stats_by_level,
header = header, diff_label = diff_label, ci_label = ci_label,
pvalue_label = pvalue_label, estimate_fun = estimate_fun, pvalue_fun = pvalue_fun,
block = "..diff.."
)

x |>
gtsummary::modify_table_body(~ dplyr::bind_rows(.x, rows)) |>
gtsummary::modify_indent(
columns = "label",
rows = .data$variable %in% "..diff.." & .data$label %in% c(diff_label, pvalue_label),
indent = 4L
) |>
gtsummary::modify_indent(
columns = "label",
rows = .data$variable %in% "..diff.." & .data$label %in% ci_label,
indent = 8L
) |>
structure(class = c("tbl_proportion", "gtsummary"))
}

# Map stat columns to their by-level using the header's `modify_stat_level`.
# This is the robust column mapping (no parsing of markdown header labels).
.stat_col_level_map <- function(x) {
x$table_styling$header |>
dplyr::filter(grepl("^stat_\\d+$", .data$column)) |>
dplyr::select("column", "modify_stat_level") |>
tibble::deframe()
}

# Assemble a header row plus one estimate/CI/p-value block into table-body rows,
# placing each comparison statistic in the correct stat column.
.format_comparison_block <- function(x, reference, stats_by_level, header,
diff_label, ci_label, pvalue_label,
estimate_fun, pvalue_fun, block,
rows_spec = c("estimate", "ci", "pvalue")) {
col_map <- .stat_col_level_map(x)
stat_cols <- names(col_map)
template <- x$table_body[1, , drop = FALSE]
template[stat_cols] <- NA_character_

mk_row <- function(label, filler) {
r <- template
r$variable <- block
r$row_type <- "level"
r$label <- label
for (col in stat_cols) {
lvl <- col_map[[col]]
r[[col]] <- if (identical(lvl, reference) || is.null(stats_by_level[[lvl]])) {
NA_character_
} else {
filler(stats_by_level[[lvl]])
}
}
r
}

hdr <- template
hdr$variable <- block
hdr$row_type <- "label"
hdr$label <- header

parts <- list(hdr)
if ("estimate" %in% rows_spec) {
parts <- c(parts, list(mk_row(diff_label, function(s) estimate_fun(s$estimate))))
}
if ("ci" %in% rows_spec) {
parts <- c(parts, list(mk_row(ci_label, function(s) {
if (is.na(s$conf.low)) NA_character_ else glue("({estimate_fun(s$conf.low)}, {estimate_fun(s$conf.high)})")
})))
}
if ("pvalue" %in% rows_spec) {
parts <- c(parts, list(mk_row(pvalue_label, function(s) pvalue_fun(s$p.value))))
}
if ("or" %in% rows_spec) {
# single estimate (CI) row; the header IS the row (no separate header line)
parts <- list(mk_row(header, function(s) {
if (is.na(s$estimate)) {
NA_character_
} else {
glue("{estimate_fun(s$estimate)} ({estimate_fun(s$conf.low)}, {estimate_fun(s$conf.high)})")
}
}))
}
dplyr::bind_rows(parts)
}

# Shared checks for the comparison verbs; returns pulled metadata.
.check_proportion_comparison <- function(x, strata, test) {
by <- x$inputs$by
if (is_empty(by)) {
cli::cli_abort(
"Cannot add a comparison when {.fun tbl_proportion} was built without a
{.arg by} variable.",
call = get_cli_abort_call()
)
}
if (is.null(x$inputs$value)) {
cli::cli_abort(
"Comparisons require a binary summary. Rebuild {.fun tbl_proportion} with
the {.arg value} argument.",
call = get_cli_abort_call()
)
}
if (test == "cmh" && is.null(strata)) {
cli::cli_abort(
"The {.val cmh} test requires a {.arg strata} variable.",
call = get_cli_abort_call()
)
}
data <- x$inputs$data
data[[by]] <- factor(data[[by]])
list(
data = data, by = by, variable = x$inputs$variable,
value = x$inputs$value, lvls = levels(data[[by]])
)
}

.check_reference <- function(reference, lvls) {
if (!reference %in% lvls) {
cli::cli_abort(
"The {.arg reference} {.val {reference}} is not a level of the grouping variable.",
call = get_cli_abort_call()
)
}
}

.default_test_label <- function(test) {
switch(test,
chisq = "p-value (Chi-Squared Test)",
fisher = "p-value (Fisher's Exact Test)",
cmh = "p-value (Cochran-Mantel-Haenszel Test)",
"p-value"
)
}

# Difference, CI and p-value for one comparison (level - reference). Uses
# stats::prop.test / mantelhaen.test so the CI matches the "Wald, with
# correction" convention without re-implementing interval math.
.proportion_diff_one <- function(data, variable, by, value, reference, level,
strata, test, conf.level, correct, test_correct) {
sub <- data[data[[by]] %in% c(reference, level), , drop = FALSE]
sub$.success <- sub[[variable]] == value
sub[[by]] <- factor(sub[[by]], levels = c(level, reference)) # level first

if (is.null(strata)) {
ok <- stats::complete.cases(sub$.success, sub[[by]])
sub <- sub[ok, , drop = FALSE]
tab <- table(sub[[by]], factor(sub$.success, levels = c(TRUE, FALSE)))
pt <- stats::prop.test(tab, conf.level = conf.level, correct = correct)
est <- unname(pt$estimate[1] - pt$estimate[2]) * 100
pval <- switch(test,
chisq = stats::chisq.test(tab, correct = test_correct)$p.value,
fisher = stats::fisher.test(tab)$p.value,
pt$p.value
)
list(estimate = est, conf.low = pt$conf.int[1] * 100, conf.high = pt$conf.int[2] * 100, p.value = pval)
} else {
ok <- stats::complete.cases(sub$.success, sub[[by]], sub[[strata]])
sub <- sub[ok, , drop = FALSE]
arr <- table(factor(sub$.success, levels = c(TRUE, FALSE)), sub[[by]], sub[[strata]])
mh <- stats::mantelhaen.test(arr, correct = correct, conf.level = conf.level)
p_lvl <- mean(sub$.success[sub[[by]] == level], na.rm = TRUE)
p_ref <- mean(sub$.success[sub[[by]] == reference], na.rm = TRUE)
list(estimate = (p_lvl - p_ref) * 100, conf.low = NA_real_, conf.high = NA_real_, p.value = mh$p.value)
}
}
89 changes: 89 additions & 0 deletions R/add_proportion_odds_ratio.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#' @describeIn add_proportion_difference
#'
#' Add an odds-ratio row (with confidence interval) comparing each non-reference
#' group to `reference`. Unstratified odds ratios come from logistic regression
#' ([stats::glm()], via [cardx::ard_regression_basic()]); when `strata` is
#' supplied the common (CMH) odds ratio from [stats::mantelhaen.test()] is used.
#'
#' @param or_label (`string`)\cr
#' label for the odds-ratio row. Default `"Odds Ratio (95% CI)"`.
#'
#' @export
add_proportion_odds_ratio <- function(x,
reference = NULL,
strata = NULL,
conf.level = 0.95,
or_label = NULL,
estimate_fun = label_roche_ratio()) {
set_cli_abort_call()
check_class(x, "tbl_proportion")
check_range(conf.level, range = c(0, 1))
check_class(estimate_fun, "function")

meta <- .check_proportion_comparison(x, strata, test = if (is.null(strata)) "chisq" else "cmh")
reference <- reference %||% meta$lvls[1]
.check_reference(reference, meta$lvls)
or_label <- or_label %||% glue("Odds Ratio ({style_roche_number(conf.level, scale = 100)}% CI)")

cmp_lvls <- setdiff(meta$lvls, reference)
stats_by_level <- lapply(
cmp_lvls,
function(lv) {
.odds_ratio_one(
data = meta$data, variable = meta$variable, by = meta$by,
value = meta$value, reference = reference, level = lv,
strata = strata, conf.level = conf.level
)
}
)
names(stats_by_level) <- cmp_lvls

# single OR row: reuse the block formatter, estimate row only ----------------
rows <- .format_comparison_block(
x = x, reference = reference, stats_by_level = stats_by_level,
header = or_label, diff_label = or_label, ci_label = or_label,
pvalue_label = or_label, estimate_fun = estimate_fun, pvalue_fun = estimate_fun,
block = "..or..", rows_spec = "or"
)

x |>
gtsummary::modify_table_body(~ dplyr::bind_rows(.x, rows)) |>
structure(class = c("tbl_proportion", "gtsummary"))
}

# Odds ratio (level vs reference) with CI. Logistic GLM when unstratified;
# CMH common odds ratio when a stratification variable is supplied.
.odds_ratio_one <- function(data, variable, by, value, reference, level,
strata, conf.level) {
sub <- data[data[[by]] %in% c(reference, level), , drop = FALSE]
sub$.success <- as.integer(sub[[variable]] == value)
sub[[by]] <- stats::relevel(factor(sub[[by]], levels = c(reference, level)), ref = reference)

if (is.null(strata)) {
ok <- stats::complete.cases(sub$.success, sub[[by]])
sub <- sub[ok, , drop = FALSE]
ard <- cardx::construct_model(
data = sub,
formula = stats::reformulate(response = ".success", termlabels = by),
method = "glm",
method.args = list(family = stats::binomial)
) |>
cardx::ard_regression_basic(exponentiate = TRUE, conf.level = conf.level) |>
dplyr::filter(.data$variable == .env$by)
get_stat <- function(nm) {
v <- ard$stat[ard$stat_name == nm]
if (length(v) == 0) NA_real_ else unlist(v)[1]
}
list(estimate = get_stat("estimate"), conf.low = get_stat("conf.low"), conf.high = get_stat("conf.high"))
} else {
ok <- stats::complete.cases(sub$.success, sub[[by]], sub[[strata]])
sub <- sub[ok, , drop = FALSE]
arr <- table(
factor(sub$.success, levels = c(1L, 0L)),
factor(sub[[by]], levels = c(level, reference)),
sub[[strata]]
)
mh <- stats::mantelhaen.test(arr, correct = FALSE, conf.level = conf.level)
list(estimate = unname(mh$estimate), conf.low = mh$conf.int[1], conf.high = mh$conf.int[2])
}
}
Loading
Loading