Nothing
#' Comprehensive Survival Analysis Table
#'
#' Performs descriptive survival analysis, Kaplan-Meier/Aalen-Johansen estimates,
#' optional life tables, cumulative incidence at selected times, incidence rate,
#' log-rank tests, Cox regression, proportional-hazards diagnostics, RMST,
#' competing-risk Fine-Gray models, and counting-process/recurrent-event Cox models.
#'
#' @param time Follow-up or stop-time variable, supplied without quotes.
#' @param event Event/status variable, supplied without quotes.
#' @param vars Optional predictor specification created by `vars()`.
#' @param by Optional grouping variable for survival curves and comparisons.
#' Hierarchical syntax is supported: in `by = vars(province, sex, treatment)`,
#' treatment is the innermost curve/comparison group and province > sex are
#' ordered outer strata.
#' @param data Optional data frame. When omitted, active R4VN data are used.
#' @param failure Value of `event` representing the event of interest. For a
#' binary event it defaults to the second factor level or larger numeric value.
#' @param compete Optional competing-event value(s). When supplied, `risk = TRUE`
#' uses the Aalen-Johansen cumulative incidence function.
#' @param id Optional subject identifier for counting-process/recurrent data.
#' @param start Optional start/entry time. When supplied, `time` is treated as stop time.
#' @param unit Optional display unit such as "day", "month", or "year".
#' @param followup Estimate median follow-up using reverse Kaplan-Meier when possible.
#' With `report = "auto"`, this is enabled unless explicitly set to `FALSE`.
#' @param km Fit Kaplan-Meier (ordinary survival) or Aalen-Johansen (competing risks).
#' @param lifetable Show a detailed life table at every observed time. The
#' default is `FALSE`. For ordinary survival, the table reports numbers at
#' risk, events, censoring, conditional survival, cumulative Kaplan-Meier
#' survival, cumulative risk, standard error, and confidence limits. With
#' competing risks, it reports the corresponding Aalen-Johansen event-history
#' table and cumulative incidence.
#' @param at Optional time points for survival/risk/rate summaries. With
#' `report = "auto"` or `"full"`, three representative follow-up times are
#' selected automatically when `at` is omitted.
#' @param risk Report cumulative risk at `at`. For ordinary survival this is 1-S(t);
#' with competing risks it is the cumulative incidence function.
#' @param cuminc Optional numeric time points at which cumulative incidence is
#' required, for example `cuminc = c(6, 12, 24)`. This directly activates
#' cumulative-risk output without also requiring `risk = TRUE`. Ordinary
#' survival uses 1-KM; competing-risk analysis uses the Aalen-Johansen CIF.
#' @param rate `FALSE`, `TRUE`, `"overall"`, `"cumulative"`, `"interval"`,
#' or `"all"`. `"all"` reports overall, cumulative, and interval-specific
#' rates. The automatic profile uses the overall incidence rate.
#' @param scale Rate multiplier, e.g. 100 for events per 100 person-time units.
#' @param logrank Perform a log-rank test when `by` is supplied and no competing risk exists.
#' @param rr,rd Compare cumulative risks between two `by` groups using approximate
#' risk ratio or risk difference inference based on survival-estimate standard errors.
#' @param irr Compare incidence rates between two `by` groups.
#' @param cox Fit crude Cox models for variables in `vars`.
#' @param adjusted FALSE/NULL, TRUE (adjust each focal predictor for all other focal
#' predictors), or a `vars()`/character set of adjustment covariates.
#' @param multi FALSE/NULL, TRUE (all `vars` in one model), or a `vars()`/character
#' set defining the final multivariable Cox model.
#' @param strata Optional stratification variable for Cox regression.
#' @param cluster Optional clustering variable for robust Cox variance.
#' @param frailty Optional shared-frailty variable. Do not combine with `cluster`.
#' @param finegray Fit a Fine-Gray subdistribution hazards model when `compete` is supplied.
#' @param recurrent FALSE/TRUE or "ag". TRUE is Andersen-Gill and requires
#' `id` and `start`. Automatic profiles suppress ordinary KM/RMST modules for
#' recurrent-event data unless the user explicitly requests them.
#' @param rmst Compute restricted mean survival time.
#' @param tau Restriction time for RMST. Defaults to the largest common curve time.
#' @param ph Test the proportional-hazards assumption with `cox.zph()` for the final Cox model.
#' @param interaction Optional `vars(a, b)` containing exactly two variables to include
#' their interaction in the final multivariable Cox model.
#' @param superby Optional outer subgroup variable retained for backward compatibility.
#' For new code, multiple ordered outer strata can be supplied directly in
#' `by = vars(stratum1, stratum2, group)`.
#' @param ci Confidence level, default 0.95.
#' @param digit,p_digit,effect_digit Display digits.
#' @param missing Show missing/exclusion information when printing.
#' @param plot Draw a survival/CIF curve using `gsurv()` after analysis. In the
#' automatic profiles, the plot includes confidence limits, the log-rank
#' p-value when available, and a number-at-risk table.
#' @param title Optional title.
#' @param show Logical; open the formatted result in the Viewer. Default `TRUE`.
#' @param console Logical; also print the traditional result in the Console. Default `FALSE`.
#' @param ai Prepare a compact de-identified interpretation payload in `$ai_text`.
#' @param ties Cox tie method: "efron", "breslow", or "exact".
#' @param report Reporting profile: `"auto"` (context-sensitive comprehensive
#' output), `"brief"` (descriptive survival summary), `"full"` (all valid
#' modules), or `"custom"` (backward-compatible concise defaults plus explicitly requested
#' modules).
#' @param plot_args Named list of additional arguments passed to `gsurv()`.
#' @param interpretation Add a cautious, deterministic interpretation table.
#' The default is `FALSE`; use `interpretation = TRUE` when narrative output
#' is wanted.
#' @param export Optional export format accepted by `tabexport()`, such as
#' `"docx"`, `"xlsx"`, or `"html"`.
#' @param file Optional export filename. Its extension may also determine the
#' export format.
#' @param open Open the exported file when supported.
#' @param strict If `TRUE`, an unavailable optional module stops the analysis.
#' The default `FALSE` keeps the main report and records a warning instead.
#'
#' @return An object of class `r4vn_surv`. Backward-compatible components are
#' retained, with a consistent reporting contract in `$descriptive`,
#' `$estimates`, `$tests`, `$diagnostics`, `$interpretation`, `$tables`,
#' `$plots`, `$models`, `$metadata`, and `$call`.
#' @export
#'
#' @examples
#' if (requireNamespace("survival", quietly = TRUE)) {
#' # Reproducible two-group data from the survival package.
#' d <- survival::lung
#' d$death <- as.integer(d$status == 2)
#' d$group <- factor(d$sex, levels = c(1, 2),
#' labels = c("Male", "Female"))
#'
#' # 1. Complete two-group report. This includes the log-rank test.
#' km <- tabsurv(
#' time, death, by = group, data = d, failure = 1,
#' unit = "day", at = c(90, 180, 365, 540),
#' report = "auto", plot = FALSE, show = FALSE
#' )
#' km$logrank
#' km$tests$logrank
#' km$logrank$p
#'
#' \donttest{
#' # 2. Cumulative incidence at 6, 12, and 24 months.
#' d$month <- d$time / 30.4375
#' ci_month <- tabsurv(
#' month, death, by = group, data = d, failure = 1,
#' cuminc = c(6, 12, 24), report = "custom", show = FALSE
#' )
#' ci_month$cuminc
#'
#' # 3. Detailed life table and interpretation are both opt-in.
#' km_detail <- tabsurv(
#' time, death, by = group, data = d, failure = 1,
#' at = c(90, 180, 365, 540),
#' lifetable = TRUE, interpretation = TRUE, show = FALSE
#' )
#' head(km_detail$lifetable)
#' km_detail$interpretation
#'
#' # 4. A compact KM plus log-rank analysis without automatic extras.
#' km_simple <- tabsurv(
#' time, death, by = group, data = d, failure = 1,
#' report = "custom", km = TRUE, logrank = TRUE,
#' plot = FALSE, show = FALSE
#' )
#'
#' # 5. Explicit two-group effect measures and RMST.
#' km_compare <- tabsurv(
#' time, death, by = group, data = d, failure = 1,
#' at = c(90, 180, 365, 540), risk = TRUE,
#' rr = TRUE, rd = TRUE, rate = "all", irr = TRUE,
#' rmst = TRUE, tau = 365, show = FALSE
#' )
#' km_compare$risk_compare
#' km_compare$irr
#' km_compare$rmst
#'
#' # 6. Publication graphs, including risk tables, are documented in ?gsurv.
#' # Keeping graphics out of this example also keeps tabsurv() examples fast
#' # and executable on non-interactive CRAN check devices.
#'
#' # 7. With competing risks, use the Aalen-Johansen CIF, not 1-KM.
#' set.seed(2026)
#' n <- 180
#' t1 <- rexp(n, 0.07)
#' t2 <- rexp(n, 0.05)
#' tc <- runif(n, 4, 30)
#' tm <- pmin(t1, t2, tc)
#' dcr <- data.frame(
#' time = tm,
#' status = ifelse(tm == t1, 1L, ifelse(tm == t2, 2L, 0L)),
#' group = factor(rep(c("A", "B"), each = n / 2))
#' )
#' cif <- tabsurv(
#' time, status, by = group, data = dcr,
#' failure = 1, compete = 2, cuminc = c(6, 12, 24),
#' report = "custom", show = FALSE
#' )
#' cif$cuminc
#' # See ?gsurv for publication CIF graphs and risk tables.
#' }
#' }
tabsurv <- function(time, event, vars = NULL, by = NULL, data = NULL,
failure = NULL, compete = NULL,
id = NULL, start = NULL, unit = NULL,
followup = NULL, km = NULL, lifetable = FALSE, at = NULL,
risk = NULL, cuminc = NULL, rate = NULL, scale = 100,
logrank = NULL, rr = NULL, rd = NULL, irr = NULL,
cox = NULL, adjusted = NULL, multi = NULL,
strata = NULL, cluster = NULL, frailty = NULL,
finegray = NULL, recurrent = FALSE,
rmst = NULL, tau = NULL,
ph = NULL, interaction = FALSE, superby = NULL,
ci = 0.95, digit = 2, p_digit = 3, effect_digit = 2,
missing = FALSE, plot = NULL,
title = NULL, show = TRUE, console = FALSE, ai = FALSE,
ties = c("efron", "breslow", "exact"),
report = c("auto", "brief", "full", "custom"),
plot_args = list(), interpretation = FALSE,
export = NULL, file = NULL, open = FALSE,
strict = FALSE) {
.r4vn_surv_require()
call <- match.call()
env <- parent.frame()
d <- .r4vn_surv_data(data)
report <- match.arg(report)
ties <- match.arg(ties)
if (!is.list(plot_args) ||
(length(plot_args) && (is.null(names(plot_args)) || any(!nzchar(names(plot_args)))))) {
stop("`plot_args` must be a named list.", call. = FALSE)
}
if (!is.logical(interpretation) || length(interpretation) != 1L || is.na(interpretation)) {
stop("`interpretation` must be TRUE or FALSE.", call. = FALSE)
}
if (!is.logical(lifetable) || length(lifetable) != 1L || is.na(lifetable)) {
stop("`lifetable` must be TRUE or FALSE.", call. = FALSE)
}
if (!is.null(cuminc) && (!is.numeric(cuminc) || !length(cuminc) ||
any(!is.finite(cuminc)) || any(cuminc < 0))) {
stop("`cuminc` must contain non-negative finite time points.", call. = FALSE)
}
if (!is.null(cuminc)) cuminc <- sort(unique(cuminc))
if (!is.logical(strict) || length(strict) != 1L || is.na(strict)) {
stop("`strict` must be TRUE or FALSE.", call. = FALSE)
}
by_expr <- substitute(by)
by_info <- .r4vn_tabsurv_by_info(by_expr, d, env)
time_name <- .r4vn_surv_name(substitute(time), d, "time")
provided <- c(
followup = !missing(followup), km = !missing(km), at = !missing(at),
risk = !missing(risk), rate = !missing(rate), logrank = !missing(logrank),
rr = !missing(rr), rd = !missing(rd), irr = !missing(irr),
cox = !missing(cox), adjusted = !missing(adjusted), multi = !missing(multi),
finegray = !missing(finegray), rmst = !missing(rmst), ph = !missing(ph),
plot = !missing(plot)
)
profile <- .r4vn_tabsurv_profile(
report = report, provided = provided,
values = list(
followup = followup, km = km, at = at, risk = risk, rate = rate,
logrank = logrank, rr = rr, rd = rd, irr = irr, cox = cox,
adjusted = adjusted, multi = multi, finegray = finegray, rmst = rmst,
ph = ph, plot = plot, vars = vars
),
data = d, time_name = time_name, by_name = by_info$by,
competing = !is.null(compete), recurrent = recurrent
)
for (nm in names(profile)) assign(nm, profile[[nm]])
if (!is.null(cuminc)) risk <- TRUE
core_call <- call
core_call[[1L]] <- quote(.r4vn_tabsurv_core)
core_call$data <- quote(.r4vn_tabsurv_data)
core_call$followup <- followup
core_call$km <- km
core_call$lifetable <- lifetable
core_call$at <- at
core_call$risk <- risk
core_call$cuminc <- cuminc
core_call$rate <- rate
core_call$logrank <- logrank
core_call$rr <- rr
core_call$rd <- rd
core_call$irr <- irr
core_call$cox <- cox
if (!isTRUE(provided[["adjusted"]]) || is.logical(adjusted)) core_call$adjusted <- adjusted
if (!isTRUE(provided[["multi"]]) || is.logical(multi)) core_call$multi <- multi
core_call$finegray <- finegray
core_call$rmst <- rmst
core_call$ph <- ph
core_call$plot <- plot
core_call$show <- FALSE
core_call$console <- FALSE
core_call$ties <- ties
core_call$report <- report
core_call$strict <- strict
core_call$plot_args <- NULL
core_call$interpretation <- NULL
core_call$export <- NULL
core_call$file <- NULL
core_call$open <- NULL
if (!is.null(by_info$spec)) {
spec <- by_info$spec
outer <- spec$strata
super_expr <- substitute(superby)
if (!.r4vn_expr_is_null(super_expr)) {
explicit <- .r4vn_resolve_name_spec(
super_expr, d, env, "superby", allow_null = TRUE, multiple = TRUE
)
outer <- unique(c(explicit, outer))
}
outer <- setdiff(outer, spec$by)
if (length(outer)) {
ids <- .r4vn_strata_indices(d, outer)
if (!length(ids)) stop("No complete strata are available for survival analysis.", call. = FALSE)
labels <- vapply(ids, function(idx) .r4vn_stratum_label(d, outer, idx), character(1))
core_call$by <- as.name(spec$by)
core_call$superby <- NULL
core_call$plot <- FALSE
core_call$ai <- FALSE
results <- lapply(ids, function(idx) {
ee <- new.env(parent = env)
ee$.r4vn_tabsurv_core <- .r4vn_tabsurv_core
ee$.r4vn_tabsurv_data <- d[idx, , drop = FALSE]
z <- eval(core_call, envir = ee)
z$call <- call
.r4vn_tabsurv_contract(z, interpretation = interpretation)
})
names(results) <- labels
out <- .r4vn_tabsurv_hierarchical_result(
results, labels, spec, outer, d, call, title, missing, ci, scale, report
)
if (isTRUE(ai)) {
out$ai_text <- lapply(results, function(z) {
tryCatch(.r4vn_surv_ai_text(z, effect_digit, p_digit),
error = function(e) NULL)
})
}
} else {
core_call$by <- as.name(spec$by)
core_call$superby <- NULL
ee <- new.env(parent = env)
ee$.r4vn_tabsurv_core <- .r4vn_tabsurv_core
ee$.r4vn_tabsurv_data <- d
out <- eval(core_call, envir = ee)
}
} else {
ee <- new.env(parent = env)
ee$.r4vn_tabsurv_core <- .r4vn_tabsurv_core
ee$.r4vn_tabsurv_data <- d
out <- eval(core_call, envir = ee)
}
out$call <- call
out <- .r4vn_tabsurv_contract(out, interpretation = interpretation)
out <- .r4vn_tabsurv_plot(
out, plot = plot, plot_args = plot_args, report = report, show = show
)
if (!is.null(export) || !is.null(file)) {
out$export <- survexport(
out, export = export, file = file, open = open,
title = title %||% "R4VN survival analysis"
)
}
.r4vn_show(out, show = show, console = console)
}
.r4vn_tabsurv_core <- function(time, event, vars = NULL, by = NULL, data = NULL,
failure = NULL, compete = NULL,
id = NULL, start = NULL, unit = NULL,
followup = TRUE, km = TRUE, lifetable = FALSE, at = NULL,
risk = FALSE, cuminc = NULL, rate = FALSE, scale = 100,
logrank = TRUE, rr = FALSE, rd = FALSE, irr = FALSE,
cox = FALSE, adjusted = FALSE, multi = FALSE,
strata = NULL, cluster = NULL, frailty = NULL,
finegray = FALSE, recurrent = FALSE,
rmst = FALSE, tau = NULL,
ph = FALSE, interaction = FALSE, superby = NULL,
ci = 0.95, digit = 2, p_digit = 3, effect_digit = 2,
missing = FALSE, plot = FALSE,
title = NULL, show = FALSE, console = FALSE, ai = FALSE,
ties = c("efron", "breslow", "exact"),
report = "custom", strict = FALSE) {
.r4vn_surv_require()
call <- match.call()
env <- parent.frame()
data <- .r4vn_surv_data(data)
ties <- match.arg(ties)
if (!is.numeric(ci) || length(ci) != 1L || is.na(ci) || ci <= 0 || ci >= 1) stop("`ci` must be between 0 and 1.", call. = FALSE)
if (!is.numeric(scale) || length(scale) != 1L || is.na(scale) || scale <= 0) stop("`scale` must be a positive number.", call. = FALSE)
if (!is.null(at)) {
if (!is.numeric(at) || any(!is.finite(at)) || any(at < 0)) stop("`at` must contain non-negative finite time points.", call. = FALSE)
at <- sort(unique(at))
}
if (!is.null(cuminc)) {
if (!is.numeric(cuminc) || !length(cuminc) || any(!is.finite(cuminc)) ||
any(cuminc < 0)) {
stop("`cuminc` must contain non-negative finite time points.", call. = FALSE)
}
cuminc <- sort(unique(cuminc))
risk <- TRUE
}
time_name <- .r4vn_surv_name(substitute(time), data, "time")
event_name <- .r4vn_surv_name(substitute(event), data, "event")
by_name <- .r4vn_surv_name(substitute(by), data, "by", TRUE)
id_name <- .r4vn_surv_name(substitute(id), data, "id", TRUE)
start_name <- .r4vn_surv_name(substitute(start), data, "start", TRUE)
strata_name0 <- .r4vn_surv_name(substitute(strata), data, "strata", TRUE)
cluster_name0 <- .r4vn_surv_name(substitute(cluster), data, "cluster", TRUE)
frailty_name0 <- .r4vn_surv_name(substitute(frailty), data, "frailty", TRUE)
superby_name <- .r4vn_surv_name(substitute(superby), data, "superby", TRUE)
if (!is.null(cluster_name0) && !is.null(frailty_name0)) stop("Use either `cluster` or `frailty`, not both in the same Cox model.", call. = FALSE)
if (!isFALSE(recurrent)) {
rec <- if (isTRUE(recurrent)) "ag" else tolower(as.character(recurrent)[1L])
if (!identical(rec, "ag")) stop("This implementation currently supports recurrent = TRUE or recurrent = 'ag' (Andersen-Gill).", call. = FALSE)
if (is.null(id_name) || is.null(start_name)) stop("Andersen-Gill recurrent-event analysis requires both `id` and `start`.", call. = FALSE)
if (is.null(cluster_name0)) cluster_name0 <- id_name
cox <- TRUE
multi <- if (isFALSE(multi)) TRUE else multi
}
status <- .r4vn_surv_event_status(data[[event_name]], failure, compete)
.r4vn_surv_validate_time(data[[time_name]], status$event, if (is.null(start_name)) NULL else data[[start_name]])
focal_spec <- .r4vn_surv_as_spec(vars, data, "vars", TRUE)
adjusted_spec <- NULL
if (!isFALSE(adjusted) && !is.null(adjusted)) {
adjusted_spec <- if (isTRUE(adjusted)) TRUE else .r4vn_surv_as_spec(adjusted, data, "adjusted", FALSE)
}
multi_spec <- NULL
if (!isFALSE(multi) && !is.null(multi)) {
multi_spec <- if (isTRUE(multi)) TRUE else .r4vn_surv_as_spec(multi, data, "multi", FALSE)
}
interaction_vars <- NULL
if (!isFALSE(interaction) && !is.null(interaction)) {
isp <- .r4vn_surv_as_spec(interaction, data, "interaction", FALSE)
if (nrow(isp) != 2L) stop("`interaction` must specify exactly two variables, for example `vars(treatment, sex)`.", call. = FALSE)
interaction_vars <- isp$variable
multi <- if (isFALSE(multi)) TRUE else multi
multi_spec <- if (is.null(multi_spec)) TRUE else multi_spec
}
all_spec <- .r4vn_surv_merge_specs(
focal_spec,
if (isTRUE(adjusted_spec)) NULL else adjusted_spec,
if (isTRUE(multi_spec)) NULL else multi_spec
)
if (isTRUE(adjusted_spec) || isTRUE(multi_spec)) all_spec <- .r4vn_surv_merge_specs(all_spec, focal_spec)
internal <- .r4vn_surv_internal_data(data, all_spec)
d <- internal$data
map <- internal$map
d$.time <- data[[time_name]]
d$.event <- status$event
d$.status_ms <- status$status_ms
if (!is.null(start_name)) d$.start <- data[[start_name]]
if (!is.null(by_name)) d$.by <- if (is.factor(data[[by_name]])) droplevels(data[[by_name]]) else factor(data[[by_name]], levels = unique(data[[by_name]][!is.na(data[[by_name]])]))
if (!is.null(id_name)) d$.id <- data[[id_name]]
if (!is.null(strata_name0)) d$.strata <- data[[strata_name0]]
if (!is.null(cluster_name0)) d$.cluster <- data[[cluster_name0]]
if (!is.null(frailty_name0)) d$.frailty <- data[[frailty_name0]]
if (!is.null(superby_name)) d$.superby <- if (is.factor(data[[superby_name]])) droplevels(data[[superby_name]]) else factor(data[[superby_name]])
# Use separate analysis sets. Descriptive survival estimates must not lose
# participants merely because a Cox covariate is missing.
competing <- !is.null(compete)
if (competing && !is.null(start_name) && is.null(id_name)) {
stop("Competing-risk start-stop data require `id`.", call. = FALSE)
}
desc_required <- c(".time", ".event")
if (!is.null(start_name)) desc_required <- c(desc_required, ".start")
if (!is.null(by_name) && (km || isTRUE(lifetable) || risk || !is.null(cuminc) ||
!identical(rate, FALSE) || logrank || rr || rd || irr || rmst ||
isTRUE(plot))) {
desc_required <- c(desc_required, ".by")
}
if (!is.null(start_name) && !is.null(id_name)) desc_required <- c(desc_required, ".id")
cc_desc <- .r4vn_surv_complete(d, desc_required)
d_desc <- cc_desc$data
if (!nrow(d_desc)) stop("No complete observations remain for survival-time analysis.", call. = FALSE)
if (".by" %in% names(d_desc)) d_desc$.by <- droplevels(d_desc$.by)
# Cox/Fine-Gray models use their own base set. Predictor-specific missingness
# is left to coxph()/finegray(), so crude models are not forced to use the
# complete-case sample of every other predictor.
model_required <- c(".time", ".event")
if (!is.null(start_name)) model_required <- c(model_required, ".start")
if (!is.null(strata_name0)) model_required <- c(model_required, ".strata")
if (!is.null(cluster_name0)) model_required <- c(model_required, ".cluster")
if (!is.null(frailty_name0)) model_required <- c(model_required, ".frailty")
if (!is.null(superby_name)) model_required <- c(model_required, ".superby")
if ((!isFALSE(recurrent) || (isTRUE(finegray) && !is.null(start_name))) && !is.null(id_name)) model_required <- c(model_required, ".id")
cc_model <- .r4vn_surv_complete(d, model_required)
d_model <- cc_model$data
if (!nrow(d_model)) stop("No complete observations remain for the requested model structure.", call. = FALSE)
if (!is.null(map) && nrow(map)) {
for (i in seq_len(nrow(map))) if (map$type[i] == "categorical") d_model[[map$internal[i]]] <- droplevels(d_model[[map$internal[i]]])
}
if (".superby" %in% names(d_model)) d_model$.superby <- droplevels(d_model$.superby)
overview <- .r4vn_surv_overview(d_desc)
descriptive <- .r4vn_surv_group_overview(d_desc)
followup_table <- if (isTRUE(followup)) .r4vn_surv_reverse_followup(d_desc, ci) else NULL
fit <- curve <- median_table <- at_table <- risk_table <- life_table <- NULL
logrank_table <- NULL
risk_times <- if (!is.null(cuminc)) cuminc else at
if (km || risk || rmst || isTRUE(lifetable) || !is.null(at) ||
!is.null(cuminc) || isTRUE(plot)) {
rhs <- if (".by" %in% names(d_desc)) ".by" else "1"
if (!competing) {
sf <- .r4vn_surv_formula(".start" %in% names(d_desc), rhs)
fit <- survival::survfit(sf, data = d_desc, conf.int = ci,
id = if (".start" %in% names(d_desc) && ".id" %in% names(d_desc)) d_desc$.id else NULL)
curve <- .r4vn_surv_curve_km(fit)
median_table <- .r4vn_surv_median(fit)
if (!is.null(at)) {
at_table <- .r4vn_surv_km_at(fit, at)
}
if (isTRUE(risk) && !is.null(risk_times)) {
rs <- if (!is.null(at_table) && identical(risk_times, at)) {
at_table
} else .r4vn_surv_km_at(fit, risk_times)
if (nrow(rs)) {
rs$risk <- 1 - rs$surv
rs$risk_lower <- 1 - rs$upper
rs$risk_upper <- 1 - rs$lower
rs$se <- rs$std.err
risk_table <- rs[, intersect(c("group", "time", "n.risk", "risk", "se", "risk_lower", "risk_upper", "surv", "lower", "upper"), names(rs)), drop = FALSE]
}
}
} else {
sf <- .r4vn_surv_ms_formula(".start" %in% names(d_desc), rhs)
fit <- survival::survfit(sf, data = d_desc, conf.int = ci, id = if (".id" %in% names(d_desc)) d_desc$.id else NULL)
curve <- .r4vn_surv_curve_cif(fit, "failure")
if (isTRUE(risk) && !is.null(risk_times)) {
risk_table <- .r4vn_surv_cif_at(curve, d_desc, risk_times)
names(risk_table)[names(risk_table) == "cif"] <- "risk"
# Use the Aalen-Johansen standard error; keep a CI-based fallback for
# older survival objects that do not expose std.err.
zcrit <- stats::qnorm(1 - (1 - ci) / 2)
risk_table$se <- risk_table$std.err
bad_se <- !is.finite(risk_table$se)
risk_table$se[bad_se] <- (risk_table$upper[bad_se] - risk_table$lower[bad_se]) / (2 * zcrit)
risk_table$risk_lower <- risk_table$lower
risk_table$risk_upper <- risk_table$upper
}
}
if (isTRUE(lifetable) && !is.null(curve) && nrow(curve)) {
life_table <- .r4vn_surv_lifetable(curve, competing = competing)
}
}
if (isTRUE(logrank) && ".by" %in% names(d_desc) && ".start" %in% names(d_desc) && !competing) {
warning("Log-rank testing is not reported for start-stop/counting-process data; use the Cox model for comparison.", call. = FALSE)
}
if (isTRUE(logrank) && ".by" %in% names(d_desc) && nlevels(d_desc$.by) > 1L && !competing && !(".start" %in% names(d_desc))) {
lf <- .r4vn_surv_formula(FALSE, ".by")
lr <- survival::survdiff(lf, data = d_desc)
df <- max(1L, length(lr$n) - 1L)
logrank_table <- data.frame(chisq = unname(lr$chisq), df = df,
p = stats::pchisq(lr$chisq, df, lower.tail = FALSE), stringsAsFactors = FALSE)
}
rate_table <- irr_table <- NULL
if (!identical(rate, FALSE)) {
mode <- if (is.character(rate)) tolower(rate[1L]) else TRUE
allowed_rate <- c("overall", "cumulative", "interval", "all")
if (is.character(mode) && !mode %in% allowed_rate) {
stop("`rate` must be FALSE, TRUE, 'overall', 'cumulative', 'interval', or 'all'.", call. = FALSE)
}
rate_table <- .r4vn_surv_rate_table(d_desc, at, mode, scale, ci)
if (isTRUE(irr)) {
if (!".by" %in% names(d_desc)) stop("`irr = TRUE` requires `by`.", call. = FALSE)
irr_table <- tryCatch(
.r4vn_surv_compare_rates(rate_table, ci),
error = function(e) {
if (isTRUE(strict)) stop(e)
warning("Incidence-rate comparison was not available: ", conditionMessage(e), call. = FALSE)
NULL
}
)
}
}
risk_compare <- NULL
if (isTRUE(rr) || isTRUE(rd)) {
if (is.null(risk_times) || !length(risk_times)) {
stop("`rr`/`rd` require `at` or `cuminc` time points.", call. = FALSE)
}
if (!".by" %in% names(d_desc)) stop("`rr`/`rd` require `by`.", call. = FALSE)
if (is.null(risk_table)) stop("Risk estimates are unavailable.", call. = FALSE)
risk_compare <- tryCatch(
.r4vn_surv_compare_risk(risk_table, ci, rr, rd),
error = function(e) {
if (isTRUE(strict)) stop(e)
warning("Cumulative-risk comparison was not available: ", conditionMessage(e), call. = FALSE)
NULL
}
)
}
rmst_result <- NULL
if (isTRUE(rmst)) {
if (competing) stop("RMST in this function is for ordinary survival. For competing risks use CIF/Fine-Gray results.", call. = FALSE)
if (is.null(fit)) {
rhs <- if (".by" %in% names(d_desc)) ".by" else "1"
fit <- survival::survfit(.r4vn_surv_formula(".start" %in% names(d_desc), rhs), data = d_desc, conf.int = ci)
}
if (is.null(tau)) {
if (is.null(fit$strata)) tau <- max(fit$time, na.rm = TRUE) else {
ends <- cumsum(as.integer(fit$strata)); starts <- c(1L, head(ends, -1L) + 1L)
tau <- min(vapply(Map(seq.int, starts, ends), function(ii) max(fit$time[ii], na.rm = TRUE), numeric(1)))
}
}
if (!is.numeric(tau) || length(tau) != 1L || !is.finite(tau) || tau <= 0) stop("`tau` must be one positive finite time point.", call. = FALSE)
rmst_result <- tryCatch(
.r4vn_surv_rmst(fit, tau, ci),
error = function(e) {
if (isTRUE(strict)) stop(e)
warning("RMST was not available: ", conditionMessage(e), call. = FALSE)
NULL
}
)
}
# Cox models -------------------------------------------------------------
cox_result <- list(crude = NULL, adjusted = NULL, multi = NULL,
interaction = NULL, diagnostics = NULL,
crude_fits = list(), adjusted_fits = list(), multi_fit = NULL)
need_cox <- isTRUE(cox) || !isFALSE(adjusted) || !isFALSE(multi) || isTRUE(ph) || !isFALSE(recurrent)
if (need_cox) {
if (is.null(focal_spec) || !nrow(focal_spec)) stop("Cox analysis requires `vars`.", call. = FALSE)
focal_map <- map[match(focal_spec$variable, map$variable), , drop = FALSE]
adj_obj <- if (isTRUE(adjusted_spec)) TRUE else if (is.null(adjusted_spec)) NULL else adjusted_spec
mul_obj <- if (isTRUE(multi_spec) || (isTRUE(multi) && is.null(multi_spec))) TRUE else if (is.null(multi_spec)) NULL else multi_spec
cox_result <- tryCatch(
.r4vn_surv_cox_models(
d_model, focal_map, map, adj_obj, mul_obj,
start = ".start" %in% names(d_model),
strata_name = if (".strata" %in% names(d_model)) ".strata" else NULL,
cluster_name = if (".cluster" %in% names(d_model)) ".cluster" else NULL,
frailty_name = if (".frailty" %in% names(d_model)) ".frailty" else NULL,
ci = ci, ties = ties, interaction_vars = interaction_vars
),
error = function(e) {
if (isTRUE(strict)) stop(e)
warning("Cox analysis was not available: ", conditionMessage(e), call. = FALSE)
list(crude = NULL, adjusted = NULL, multi = NULL,
interaction = NULL, diagnostics = NULL,
crude_fits = list(), adjusted_fits = list(), multi_fit = NULL)
}
)
if (!isTRUE(cox)) cox_result$crude <- NULL
}
ph_table <- NULL
if (isTRUE(ph)) {
ph_table <- tryCatch({
ph_fit <- cox_result$multi_fit
if (is.null(ph_fit)) {
focal_map <- map[match(focal_spec$variable, map$variable), , drop = FALSE]
ph_fit <- .r4vn_surv_fit_cox(
d_model, focal_map, ".start" %in% names(d_model),
if (".strata" %in% names(d_model)) ".strata" else NULL,
if (".cluster" %in% names(d_model)) ".cluster" else NULL,
if (".frailty" %in% names(d_model)) ".frailty" else NULL,
ties = ties
)
if (is.null(cox_result$multi_fit)) cox_result$multi_fit <- ph_fit
}
.r4vn_surv_ph(ph_fit)
}, error = function(e) {
if (isTRUE(strict)) stop(e)
warning("PH test unavailable: ", conditionMessage(e), call. = FALSE)
NULL
})
}
fg_result <- NULL
if (isTRUE(finegray)) {
if (!competing) stop("`finegray = TRUE` requires `compete`.", call. = FALSE)
fg_spec <- if (!is.null(multi_spec) && !isTRUE(multi_spec)) multi_spec else focal_spec
if (is.null(fg_spec)) stop("`finegray = TRUE` requires `vars`.", call. = FALSE)
fg_map <- map[match(fg_spec$variable, map$variable), , drop = FALSE]
fg_result <- tryCatch(
.r4vn_surv_finegray(d_model, fg_map, ".start" %in% names(d_model), ci, ties),
error = function(e) {
if (isTRUE(strict)) stop(e)
warning("Fine-Gray analysis was not available: ", conditionMessage(e), call. = FALSE)
NULL
}
)
}
# Subgroup final Cox models ---------------------------------------------
subgroup <- NULL
if (".superby" %in% names(d_model)) {
if (is.null(focal_spec) || !nrow(focal_spec)) stop("`superby` requires `vars` for subgroup Cox models.", call. = FALSE)
subgroup <- lapply(levels(d_model$.superby), function(g) {
z <- d_model[d_model$.superby == g, , drop = FALSE]
focal_map <- map[match(focal_spec$variable, map$variable), , drop = FALSE]
fit <- try(.r4vn_surv_fit_cox(z, focal_map, ".start" %in% names(z),
if (".strata" %in% names(z)) ".strata" else NULL,
if (".cluster" %in% names(z)) ".cluster" else NULL,
if (".frailty" %in% names(z)) ".frailty" else NULL,
ties = ties), silent = TRUE)
if (inherits(fit, "try-error")) return(list(group = g, fit = NULL, table = NULL))
list(group = g, fit = fit, table = .r4vn_surv_extract_cox(fit, focal_map, ci))
})
names(subgroup) <- levels(d_model$.superby)
}
# Compact analysis data kept for reusable plotting/risk tables.
analysis_data <- d_desc[, intersect(c(".time", ".event", ".start", ".by", ".id", ".status_ms"), names(d_desc)), drop = FALSE]
out <- list(
title = title,
overview = overview,
descriptive = descriptive,
followup = followup_table,
median = median_table,
lifetable = life_table,
at = at_table,
risk = if (isTRUE(risk) || isTRUE(rr) || isTRUE(rd) || !is.null(cuminc)) risk_table else NULL,
cuminc = if (isTRUE(risk) || isTRUE(rr) || isTRUE(rd) || !is.null(cuminc)) risk_table else NULL,
rate = rate_table,
risk_compare = risk_compare,
irr = irr_table,
logrank = logrank_table,
rmst = rmst_result,
cox = cox_result,
ph = ph_table,
finegray = fg_result,
subgroup = subgroup,
fit = fit,
curve = curve,
analysis_data = analysis_data,
metadata = list(time = time_name, event = event_name, by = by_name,
time_label = .r4vn_surv_label(data, time_name),
event_label = .r4vn_surv_label(data, event_name),
by_label = .r4vn_surv_label(data, by_name), id = id_name,
start = start_name, unit = unit, failure = status$failure, compete = compete,
vars = focal_spec, ci = ci, scale = scale, at = at,
cuminc = cuminc, competing = competing,
excluded = cc_desc$excluded, excluded_survival = cc_desc$excluded,
excluded_model_base = cc_model$excluded, missing = missing,
recurrent = recurrent, report = report,
lifetable = isTRUE(lifetable)),
call = call
)
class(out) <- "r4vn_surv"
if (isTRUE(ai)) out$ai_text <- .r4vn_surv_ai_text(out, effect_digit, p_digit)
out
}
.r4vn_tabsurv_by_info <- function(expr, data, env) {
if (.r4vn_expr_is_null(expr)) return(list(by = NULL, spec = NULL))
if (.r4vn_is_vars_spec_expr(expr, data, env)) {
spec <- .r4vn_by_spec(expr, data, env, allow_null = FALSE)
return(list(by = spec$by, spec = spec))
}
list(by = .r4vn_surv_name(expr, data, "by", TRUE), spec = NULL)
}
.r4vn_tabsurv_profile <- function(report, provided, values, data, time_name,
by_name = NULL, competing = FALSE,
recurrent = FALSE) {
n_vars <- if (is.null(values$vars)) 0L else if (inherits(values$vars, "r4vn_vars")) {
nrow(.r4vn_resolve_vars(values$vars, data = data, default_type = "auto", strict = TRUE))
} else if (is.character(values$vars)) length(unique(values$vars)) else 1L
has_vars <- n_vars > 0L
has_by <- !is.null(by_name)
n_groups <- if (has_by) {
length(unique(as.character(data[[by_name]][!is.na(data[[by_name]])])))
} else 0L
two_groups <- n_groups == 2L
legacy <- list(
followup = TRUE, km = TRUE, at = NULL, risk = FALSE, rate = FALSE,
logrank = TRUE, rr = FALSE, rd = FALSE, irr = FALSE,
cox = FALSE, adjusted = FALSE, multi = FALSE, finegray = FALSE,
rmst = FALSE, ph = FALSE, plot = FALSE
)
brief <- utils::modifyList(legacy, list(plot = TRUE))
auto <- list(
followup = TRUE, km = TRUE,
at = .r4vn_surv_auto_times(data[[time_name]]),
risk = TRUE, rate = "overall",
logrank = has_by && !competing,
rr = two_groups, rd = two_groups, irr = two_groups,
cox = has_vars, adjusted = FALSE, multi = n_vars > 1L,
finegray = competing && has_vars,
rmst = two_groups && !competing,
ph = has_vars, plot = TRUE
)
full <- utils::modifyList(auto, list(
adjusted = n_vars > 1L,
multi = has_vars,
rmst = has_by && !competing,
rate = "all"
))
defaults <- switch(report, custom = legacy, brief = brief, auto = auto, full = full)
out <- defaults
for (nm in names(out)) {
if (isTRUE(provided[[nm]]) && !is.null(values[[nm]])) out[[nm]] <- values[[nm]]
}
if (isTRUE(provided[["at"]])) out$at <- values$at
if (!isFALSE(recurrent)) {
out$cox <- TRUE
out$multi <- TRUE
for (nm in c("km", "risk", "logrank", "rr", "rd", "rmst", "plot", "finegray")) {
if (!isTRUE(provided[[nm]])) out[[nm]] <- FALSE
}
if (!isTRUE(provided[["at"]])) out$at <- NULL
}
if (competing) {
if (!isTRUE(provided[["logrank"]])) out$logrank <- FALSE
if (!isTRUE(provided[["rmst"]])) out$rmst <- FALSE
}
out
}
.r4vn_tabsurv_hierarchical_result <- function(results, labels, spec, outer,
data, call, title, missing,
ci, scale, report) {
n_total <- sum(vapply(results, function(z) z$overview$N[1L], numeric(1)), na.rm = TRUE)
ev_total <- sum(vapply(results, function(z) z$overview$Events[1L], numeric(1)), na.rm = TRUE)
cens_total <- sum(vapply(results, function(z) z$overview$Censored[1L], numeric(1)), na.rm = TRUE)
pt_total <- sum(vapply(results, function(z) z$overview$`Person-time`[1L], numeric(1)), na.rm = TRUE)
overview <- data.frame(
N = n_total, Events = ev_total, Censored = cens_total,
`Event percent` = if (n_total > 0L) 100 * ev_total / n_total else NA_real_,
`Person-time` = pt_total, check.names = FALSE
)
out <- list(
title = title, overview = overview,
hierarchical_results = results, strata_results = results,
strata_labels = labels,
hierarchical_by = list(
all = spec$all, strata = outer, by = spec$by,
all_labels = vapply(spec$all, function(nm) .r4vn_variable_label(data, nm), character(1)),
strata_labels = vapply(outer, function(nm) .r4vn_variable_label(data, nm), character(1)),
by_label = .r4vn_variable_label(data, spec$by)
),
metadata = list(
hierarchical = TRUE, by = spec$by, strata = outer, all_by = spec$all,
missing = missing, ci = ci, scale = scale, report = report
),
call = call
)
class(out) <- c("r4vn_surv_hierarchical", "r4vn_surv")
out
}
.r4vn_tabsurv_contract <- function(x, interpretation = FALSE) {
if (!inherits(x, "r4vn_surv")) return(x)
if (!is.null(x$hierarchical_results)) {
x$descriptive <- do.call(rbind, lapply(seq_along(x$hierarchical_results), function(i) {
z <- x$hierarchical_results[[i]]$descriptive
if (is.null(z) || !nrow(z)) return(NULL)
data.frame(Stratum = names(x$hierarchical_results)[i], z,
stringsAsFactors = FALSE, check.names = FALSE)
}))
x$estimates <- lapply(x$hierarchical_results, `[[`, "estimates")
x$tests <- lapply(x$hierarchical_results, `[[`, "tests")
x$diagnostics <- lapply(x$hierarchical_results, `[[`, "diagnostics")
x$models <- lapply(x$hierarchical_results, `[[`, "models")
if (isTRUE(interpretation)) {
x$interpretation <- do.call(rbind, lapply(seq_along(x$hierarchical_results), function(i) {
z <- x$hierarchical_results[[i]]$interpretation
if (is.null(z) || !nrow(z)) return(NULL)
data.frame(Stratum = names(x$hierarchical_results)[i], z,
stringsAsFactors = FALSE, check.names = FALSE)
}))
} else x$interpretation <- NULL
} else {
x$estimates <- list(
survival_at = x$at, life_table = x$lifetable,
cumulative_risk = x$risk, cumulative_incidence = x$cuminc,
incidence_rate = x$rate, rmst = x$rmst,
cox = x$cox[c("crude", "adjusted", "multi", "interaction")],
finegray = if (is.null(x$finegray)) NULL else x$finegray$table
)
x$tests <- list(
logrank = x$logrank, risk_comparison = x$risk_compare,
incidence_rate_ratio = x$irr
)
x$diagnostics <- list(
cox = if (is.null(x$cox)) NULL else x$cox$diagnostics,
proportional_hazards = x$ph
)
x$models <- list(
survival = x$fit,
crude_cox = if (is.null(x$cox)) list() else x$cox$crude_fits,
adjusted_cox = if (is.null(x$cox)) list() else x$cox$adjusted_fits,
multivariable_cox = if (is.null(x$cox)) NULL else x$cox$multi_fit,
finegray = if (is.null(x$finegray)) NULL else x$finegray$fit
)
if (isTRUE(interpretation)) {
x$interpretation <- .r4vn_surv_interpretation(x)
} else x$interpretation <- NULL
}
x$tables <- surv_tables(x)
x
}
.r4vn_tabsurv_plot <- function(x, plot = FALSE, plot_args = list(),
report = "custom", show = TRUE) {
if (!isTRUE(plot)) return(x)
gf <- get0("gsurv", mode = "function", inherits = TRUE)
if (is.null(gf)) {
warning("`plot = TRUE` requested but `gsurv()` is not available.", call. = FALSE)
return(x)
}
defaults <- if (identical(report, "custom")) list() else list(
ci = TRUE, pvalue = TRUE, risk_table = TRUE, median = FALSE
)
args <- utils::modifyList(defaults, plot_args)
if ("x" %in% names(args)) args$x <- NULL
if (is.null(args$show)) args$show <- isTRUE(show)
if (!isTRUE(show)) args$show <- FALSE
draw_one <- function(z, label = NULL) tryCatch({
args2 <- args
if (is.null(args2$legend) && !is.null(z$metadata$by_label)) {
args2$legend <- z$metadata$by_label
}
if (!is.null(label) && !is.null(args2$file)) {
ext <- tools::file_ext(args2$file)
stem <- if (nzchar(ext)) {
substr(args2$file, 1L, nchar(args2$file) - nchar(ext) - 1L)
} else args2$file
suffix <- gsub("[^A-Za-z0-9]+", "-", label)
suffix <- gsub("(^-+|-+$)", "", suffix)
args2$file <- paste0(stem, "-", suffix, if (nzchar(ext)) paste0(".", ext) else "")
}
do.call(gf, c(list(x = z), args2))
},
error = function(e) {
warning("Survival graph was not available: ", conditionMessage(e), call. = FALSE)
NULL
}
)
if (!is.null(x$hierarchical_results)) {
x$graphs <- Map(draw_one, x$hierarchical_results, names(x$hierarchical_results))
names(x$graphs) <- names(x$hierarchical_results)
x$plots <- x$graphs
} else {
x$graph <- draw_one(x)
x$plots <- list(survival = x$graph)
}
x
}
#' @export
print.r4vn_surv <- function(x, ...) {
if (!is.null(x$hierarchical_results)) {
if (!is.null(x$title) && nzchar(x$title)) cat(x$title, "\n", sep = "")
cat("R4VN hierarchical survival analysis\n")
cat(strrep("-", 58), "\n", sep = "")
cat("Hierarchy: ", paste(x$hierarchical_by$strata_labels %||%
x$hierarchical_by$strata, collapse = " > "),
" | Within-stratum group: ", x$hierarchical_by$by_label %||%
x$hierarchical_by$by, "\n", sep = "")
for (i in seq_along(x$hierarchical_results)) {
lab <- names(x$hierarchical_results)[i] %||% paste0("Stratum ", i)
cat("\n=== ", lab, " ===\n", sep = "")
print(x$hierarchical_results[[i]])
}
return(invisible(x))
}
if (!is.null(x$title) && nzchar(x$title)) cat(x$title, "\n", sep = "")
cat("R4VN survival analysis\n")
cat(strrep("-", 58), "\n", sep = "")
ov <- x$overview
cat(sprintf("N: %d | Events: %d | Censored: %d | Person-time: %.2f\n",
ov$N[1L], ov$Events[1L], ov$Censored[1L], ov$`Person-time`[1L]))
if (!is.null(x$descriptive) && nrow(x$descriptive)) {
cat("\nOutcome by ", x$metadata$by_label %||% "group", "\n", sep = "")
print(x$descriptive, row.names = FALSE)
}
if (isTRUE(x$metadata$missing) || x$metadata$excluded_survival > 0L || x$metadata$excluded_model_base > 0L) {
cat(sprintf("Excluded from survival summaries because time/event/group fields were incomplete: %d\n", x$metadata$excluded_survival))
if (x$metadata$excluded_model_base != x$metadata$excluded_survival) {
cat(sprintf("Excluded from the model base because structural model fields were incomplete: %d\n", x$metadata$excluded_model_base))
}
}
if (!is.null(x$followup) && nrow(x$followup)) {
cat("Median follow-up: ", .r4vn_surv_ci_text(x$followup$median[1], x$followup$lower[1], x$followup$upper[1], 2), "\n", sep = "")
}
if (!is.null(x$median) && nrow(x$median)) {
cat("\nMedian survival\n")
z <- x$median
z$estimate <- .r4vn_surv_ci_text(z$median, z$lower, z$upper, 2)
print(z[, c("group", "estimate"), drop = FALSE], row.names = FALSE)
}
if (!is.null(x$lifetable) && nrow(x$lifetable)) {
cat("\nLife table", if (isTRUE(x$metadata$competing))
" (Aalen-Johansen event history)" else " (Kaplan-Meier)", "\n", sep = "")
print(x$lifetable, row.names = FALSE)
}
if (!is.null(x$at) && nrow(x$at) && is.null(x$risk)) {
cat("\nSurvival probability at requested times\n")
z <- x$at
z$estimate <- .r4vn_surv_ci_text(z$surv, z$lower, z$upper, 2, percent = TRUE)
print(z[, intersect(c("group", "time", "n.risk", "estimate"), names(z)), drop = FALSE], row.names = FALSE)
}
if (!is.null(x$risk) && nrow(x$risk)) {
cat("\nCumulative risk", if (isTRUE(x$metadata$competing)) " (Aalen-Johansen CIF)" else " (1-KM)", "\n", sep = "")
z <- x$risk
z$estimate <- .r4vn_surv_ci_text(z$risk, z$risk_lower, z$risk_upper, 2, percent = TRUE)
print(z[, intersect(c("group", "time", "n.risk", "estimate"), names(z)), drop = FALSE], row.names = FALSE)
}
if (!is.null(x$rate) && nrow(x$rate)) {
cat("\nIncidence rate\n")
z <- x$rate
z$estimate <- .r4vn_surv_ci_text(z$rate, z$lower, z$upper, 2)
print(z[, c("group", "interval", "events", "person_time", "estimate"), drop = FALSE], row.names = FALSE)
}
if (!is.null(x$logrank)) cat("\nLog-rank: chi-square = ", .r4vn_surv_fmt(x$logrank$chisq[1], 2),
", df = ", x$logrank$df[1], ", p ", .r4vn_surv_fmt_p(x$logrank$p[1], 3), "\n", sep = "")
if (!is.null(x$risk_compare)) {
cat("\nRisk comparison\n"); print(x$risk_compare, row.names = FALSE)
}
if (!is.null(x$irr)) {
cat("\nIncidence rate ratio\n"); print(x$irr, row.names = FALSE)
}
print_model <- function(z, heading, effect = "HR") {
if (is.null(z) || !nrow(z)) return(invisible(NULL))
cat("\n", heading, "\n", sep = "")
zz <- z
zz[[paste0(effect, " (95% CI)")]] <- ifelse(zz$reference, "Ref.", .r4vn_surv_ci_text(zz$estimate, zz$lower, zz$upper, 2))
zz$p <- vapply(zz$p, .r4vn_surv_fmt_p, character(1), digits = 3)
print(zz[, intersect(c("variable", "variable_label", "level",
paste0(effect, " (95% CI)"), "p"), names(zz)), drop = FALSE],
row.names = FALSE)
}
print_model(x$cox$crude, "Crude Cox proportional hazards", "HR")
print_model(x$cox$adjusted, "Adjusted Cox proportional hazards", "HR")
print_model(x$cox$multi, "Multivariable Cox proportional hazards", "HR")
if (!is.null(x$cox$interaction) && nrow(x$cox$interaction)) {
cat("\nInteraction terms\n")
z <- x$cox$interaction
z$`HR (95% CI)` <- .r4vn_surv_ci_text(z$estimate, z$lower, z$upper, 2)
z$p <- vapply(z$p, .r4vn_surv_fmt_p, character(1), digits = 3)
print(z[, c("term", "HR (95% CI)", "p"), drop = FALSE], row.names = FALSE)
}
if (!is.null(x$cox$diagnostics) && nrow(x$cox$diagnostics)) {
z <- x$cox$diagnostics[1L, ]
cat(sprintf("\nModel N = %d; events = %d; concordance = %.3f; LR p %s\n",
as.integer(z$n), as.integer(z$events), z$concordance, .r4vn_surv_fmt_p(z$LR_p, 3)))
}
if (!is.null(x$finegray)) print_model(x$finegray$table, "Fine-Gray subdistribution hazards", "SHR")
if (!is.null(x$ph)) {
cat("\nProportional-hazards test\n")
z <- x$ph
if ("p" %in% names(z)) z$p <- vapply(z$p, .r4vn_surv_fmt_p, character(1), digits = 3)
print(z, row.names = FALSE)
}
if (!is.null(x$rmst)) {
cat("\nRestricted mean survival time\n")
print(x$rmst$table, row.names = FALSE)
if (!is.null(x$rmst$difference)) print(x$rmst$difference, row.names = FALSE)
}
if (!is.null(x$interpretation) && nrow(x$interpretation)) {
cat("\nInterpretation\n")
for (i in seq_len(nrow(x$interpretation))) {
cat("- ", x$interpretation$section[i], ": ",
x$interpretation$interpretation[i], "\n", sep = "")
}
}
invisible(x)
}
#' Direct Cox Proportional Hazards Model
#'
#' A compact R4VN wrapper around `tabsurv()` for a final multivariable Cox model.
#'
#' @inheritParams tabsurv
#' @param ph Logical; test the proportional-hazards assumption. Default `FALSE` in `cox()`. Setting `diagnosis = TRUE` also requests this diagnostic.
#' @param diagnosis Logical; if `TRUE`, append Cox-model diagnostics including concordance, proportional-hazards testing, and residual summaries. Default `FALSE`.
#' @return An object of class `r4vn_surv`.
#' @examples
#' if (requireNamespace("survival", quietly = TRUE)) {
#' d <- data.frame(
#' time = c(5, 8, 10, 12, 15, 18, 20, 22, 25, 30),
#' event = c(1, 0, 1, 1, 0, 1, 0, 1, 1, 0),
#' age = c(40, 45, 50, 55, 60, 48, 52, 63, 58, 67),
#' sex = factor(rep(c("Female", "Male"), 5))
#' )
#' cox(time, event, vars = vars(c.age, i.sex), data = d, show = FALSE)
#' cox(time, event, vars = vars(c.age, i.sex), data = d,
#' diagnosis = TRUE, show = FALSE)
#' cox(time, event, vars = vars(c.age), data = d, ph = TRUE, show = FALSE)
#' }
#' @export
cox <- function(time, event, vars, data = NULL, failure = NULL, id = NULL,
start = NULL, strata = NULL, cluster = NULL, frailty = NULL,
ph = FALSE, ci = .95, ties = c("efron", "breslow", "exact"),
diagnosis = FALSE, show = TRUE, console = FALSE) {
call <- match.call()
# Resolve the default choice before forwarding to tabsurv().
ties <- match.arg(ties)
d <- .r4vn_surv_data(data)
tn <- .r4vn_surv_name(substitute(time), d, "time")
en <- .r4vn_surv_name(substitute(event), d, "event")
idn <- .r4vn_surv_name(substitute(id), d, "id", TRUE)
stn <- .r4vn_surv_name(substitute(start), d, "start", TRUE)
srn <- .r4vn_surv_name(substitute(strata), d, "strata", TRUE)
cln <- .r4vn_surv_name(substitute(cluster), d, "cluster", TRUE)
frn <- .r4vn_surv_name(substitute(frailty), d, "frailty", TRUE)
# tabsurv() uses NSE for time/event/id/start/strata/cluster/frailty. A
# direct call such as `id = idn` would therefore expose the symbol `idn` to
# tabsurv(), even when its value is NULL, and tabsurv() would look for a
# column literally named "idn". Build the call from evaluated values so
# character variable names and literal NULLs arrive intact.
args <- list(
time = tn, event = en, vars = vars, data = d, failure = failure,
id = idn, start = stn, strata = srn, cluster = cln, frailty = frn,
km = FALSE, cox = TRUE, multi = TRUE, ph = isTRUE(ph) || isTRUE(diagnosis), ci = ci,
ties = ties, report = "custom", plot = FALSE,
interpretation = FALSE, show = FALSE, console = FALSE
)
out <- do.call(tabsurv, args)
if (!isTRUE(diagnosis) && is.list(out$cox)) out$cox$diagnostics <- NULL
if (isTRUE(diagnosis) && !is.null(out$cox$multi_fit)) {
out$model_diagnostics <- .r4vn_model_diagnosis(out$cox$multi_fit, kind = "cox")
} else if (isTRUE(diagnosis) && !is.null(out$raw$model)) {
out$model_diagnostics <- .r4vn_model_diagnosis(out$raw$model, kind = "cox")
}
out$call <- call
.r4vn_show(out, show = show, console = console)
}
# ============================================================================
# Viewer renderers for survival commands
# ============================================================================
.r4vn_surv_model_display <- function(z, effect = "HR", digit = 2, p_digit = 3) {
if (is.null(z) || !nrow(z)) return(NULL)
zz <- z
zz[[paste0(effect, " (95% CI)")]] <- ifelse(zz$reference, "Ref.",
.r4vn_surv_ci_text(zz$estimate, zz$lower, zz$upper, digit))
zz$p <- vapply(zz$p, .r4vn_surv_fmt_p, character(1), digits = p_digit)
zz[, intersect(c("variable", "variable_label", "level", "term",
paste0(effect, " (95% CI)"), "p"), names(zz)), drop = FALSE]
}
.r4vn_surv_viewer <- function(x, subtitle = "Survival analysis") {
blocks <- character()
ov <- x$overview
if (!is.null(ov) && nrow(ov)) {
overview <- data.frame(Statistic = names(ov), Value = as.character(ov[1, ]), stringsAsFactors = FALSE)
blocks <- c(blocks, paste0('<section class="r4vn-section"><h2>Overview</h2>',
.r4vn_view_key_values(overview), '</section>'))
}
if (!is.null(x$descriptive) && nrow(x$descriptive)) {
blocks <- c(blocks, .r4vn_view_section(
paste0("Outcome by ", x$metadata$by_label %||% "group"), x$descriptive
))
}
if (!is.null(x$followup) && nrow(x$followup)) {
z <- x$followup; z$`Median follow-up (95% CI)` <- .r4vn_surv_ci_text(z$median, z$lower, z$upper, 2)
blocks <- c(blocks, .r4vn_view_section("Follow-up", z[, intersect(c("group", "Median follow-up (95% CI)"), names(z)), drop = FALSE]))
}
if (!is.null(x$median) && nrow(x$median)) {
z <- x$median; z$`Median survival (95% CI)` <- .r4vn_surv_ci_text(z$median, z$lower, z$upper, 2)
blocks <- c(blocks, .r4vn_view_section("Median survival", z[, intersect(c("group", "Median survival (95% CI)"), names(z)), drop = FALSE]))
}
if (!is.null(x$lifetable) && nrow(x$lifetable)) {
ttl <- if (isTRUE(x$metadata$competing)) {
"Life table (Aalen-Johansen event history)"
} else "Life table (Kaplan-Meier)"
blocks <- c(blocks, .r4vn_view_section(ttl, x$lifetable))
}
if (!is.null(x$at) && nrow(x$at) && is.null(x$risk)) {
z <- x$at; z$`Survival (95% CI)` <- .r4vn_surv_ci_text(z$surv, z$lower, z$upper, 2, percent = TRUE)
blocks <- c(blocks, .r4vn_view_section("Survival probability at requested times", z[, intersect(c("group", "time", "n.risk", "Survival (95% CI)"), names(z)), drop = FALSE]))
}
if (!is.null(x$risk) && nrow(x$risk)) {
z <- x$risk; z$`Risk (95% CI)` <- .r4vn_surv_ci_text(z$risk, z$risk_lower, z$risk_upper, 2, percent = TRUE)
ttl <- if (isTRUE(x$metadata$competing)) "Cumulative incidence (Aalen-Johansen CIF)" else "Cumulative risk (1 - KM)"
blocks <- c(blocks, .r4vn_view_section(ttl, z[, intersect(c("group", "time", "n.risk", "Risk (95% CI)"), names(z)), drop = FALSE]))
}
if (!is.null(x$rate) && nrow(x$rate)) {
z <- x$rate; z$`Rate (95% CI)` <- .r4vn_surv_ci_text(z$rate, z$lower, z$upper, 2)
blocks <- c(blocks, .r4vn_view_section("Incidence rate", z[, intersect(c("group", "interval", "events", "person_time", "Rate (95% CI)"), names(z)), drop = FALSE]))
}
if (!is.null(x$logrank) && nrow(x$logrank)) {
z <- data.frame(
`Chi-square` = .r4vn_surv_fmt(x$logrank$chisq, 2),
df = .r4vn_surv_fmt(x$logrank$df, 0),
p = vapply(x$logrank$p, .r4vn_surv_fmt_p, character(1), digits = 3),
stringsAsFactors = FALSE, check.names = FALSE
)
blocks <- c(blocks, .r4vn_view_section("Log-rank test", z))
}
if (!is.null(x$risk_compare) && nrow(x$risk_compare)) {
z <- x$risk_compare
out <- z[, intersect(c("time", "reference", "comparison"), names(z)), drop = FALSE]
if ("rr" %in% names(z)) {
out$`RR (95% CI)` <- .r4vn_surv_ci_text(z$rr, z$rr_lower, z$rr_upper, 2)
out$`RR p` <- vapply(z$rr_p, .r4vn_surv_fmt_p, character(1), digits = 3)
}
if ("rd" %in% names(z)) {
out$`RD (95% CI)` <- .r4vn_surv_ci_text(z$rd, z$rd_lower, z$rd_upper, 3)
out$`RD p` <- vapply(z$rd_p, .r4vn_surv_fmt_p, character(1), digits = 3)
}
blocks <- c(blocks, .r4vn_view_section("Risk comparison", out))
}
if (!is.null(x$irr) && nrow(x$irr)) {
z <- x$irr
out <- z[, intersect(c("interval", "reference", "comparison"), names(z)), drop = FALSE]
out$`IRR (95% CI)` <- .r4vn_surv_ci_text(z$irr, z$lower, z$upper, 2)
out$p <- vapply(z$p, .r4vn_surv_fmt_p, character(1), digits = 3)
blocks <- c(blocks, .r4vn_view_section("Incidence rate ratio", out))
}
if (!is.null(x$cox$crude)) blocks <- c(blocks, .r4vn_view_section("Crude Cox proportional hazards", .r4vn_surv_model_display(x$cox$crude, "HR")))
if (!is.null(x$cox$adjusted)) blocks <- c(blocks, .r4vn_view_section("Adjusted Cox proportional hazards", .r4vn_surv_model_display(x$cox$adjusted, "HR")))
if (!is.null(x$cox$multi)) blocks <- c(blocks, .r4vn_view_section("Multivariable Cox proportional hazards", .r4vn_surv_model_display(x$cox$multi, "HR")))
if (!is.null(x$cox$interaction) && nrow(x$cox$interaction)) {
z <- x$cox$interaction; z$`HR (95% CI)` <- .r4vn_surv_ci_text(z$estimate, z$lower, z$upper, 2); z$p <- vapply(z$p, .r4vn_surv_fmt_p, character(1), digits = 3)
blocks <- c(blocks, .r4vn_view_section("Interaction terms", z[, intersect(c("term", "HR (95% CI)", "p"), names(z)), drop = FALSE]))
}
if (!is.null(x$cox$diagnostics) && nrow(x$cox$diagnostics)) {
z <- x$cox$diagnostics
pcols <- grep("_p$|^p$", names(z), ignore.case = TRUE, value = TRUE)
for (nm in pcols) z[[nm]] <- vapply(z[[nm]], .r4vn_surv_fmt_p, character(1), digits = 3)
for (nm in setdiff(names(z), pcols)) {
if (is.numeric(z[[nm]])) z[[nm]] <- if (nm %in% c("n", "events", "LR_df", "Wald_df", "Score_df")) .r4vn_surv_fmt(z[[nm]], 0) else .r4vn_surv_fmt(z[[nm]], 3)
}
blocks <- c(blocks, .r4vn_view_section("Cox model diagnostics", z))
}
if (!is.null(x$finegray$table)) blocks <- c(blocks, .r4vn_view_section("Fine-Gray subdistribution hazards", .r4vn_surv_model_display(x$finegray$table, "SHR")))
if (!is.null(x$model_diagnostics) && length(x$model_diagnostics)) {
for (nm in names(x$model_diagnostics)) {
z <- x$model_diagnostics[[nm]]
if (is.data.frame(z) && nrow(z)) blocks <- c(blocks, .r4vn_view_section(nm, z))
}
}
if (!is.null(x$ph) && nrow(x$ph)) {
z <- x$ph
for (nm in names(z)) {
if (tolower(nm) == "p") z[[nm]] <- vapply(z[[nm]], .r4vn_surv_fmt_p, character(1), digits = 3)
else if (is.numeric(z[[nm]])) z[[nm]] <- .r4vn_surv_fmt(z[[nm]], 3)
}
blocks <- c(blocks, .r4vn_view_section("Proportional-hazards test", z))
}
if (!is.null(x$rmst$table) && nrow(x$rmst$table)) {
z <- x$rmst$table
out <- z[, intersect(c("group", "tau"), names(z)), drop = FALSE]
if ("tau" %in% names(out)) out$tau <- .r4vn_surv_fmt(out$tau, 2)
out$`RMST (95% CI)` <- .r4vn_surv_ci_text(z$rmst, z$lower, z$upper, 2)
out$SE <- .r4vn_surv_fmt(z$se, 2)
blocks <- c(blocks, .r4vn_view_section("Restricted mean survival time", out))
}
if (!is.null(x$rmst$difference) && nrow(x$rmst$difference)) {
z <- x$rmst$difference
out <- z[, intersect(c("reference", "comparison"), names(z)), drop = FALSE]
out$`Difference (95% CI)` <- .r4vn_surv_ci_text(z$difference, z$lower, z$upper, 2)
out$p <- vapply(z$p, .r4vn_surv_fmt_p, character(1), digits = 3)
blocks <- c(blocks, .r4vn_view_section("RMST difference", out))
}
if (!is.null(x$interpretation) && nrow(x$interpretation)) {
blocks <- c(blocks, .r4vn_view_section("Interpretation", x$interpretation))
}
notes <- character()
if (isTRUE(x$metadata$missing) || x$metadata$excluded_survival > 0L)
notes <- c(notes, paste0("Excluded from survival summaries because required fields were incomplete: ", x$metadata$excluded_survival, "."))
if (!is.null(x$metadata$excluded_model_base) && x$metadata$excluded_model_base != x$metadata$excluded_survival)
notes <- c(notes, paste0("Excluded from the structural model base: ", x$metadata$excluded_model_base, "."))
.r4vn_view_document(x$title %||% "R4VN survival analysis", paste0(blocks, collapse = ""), notes = notes,
subtitle = subtitle, prefix = "r4vn-tabsurv-")
}
.r4vn_surv_hierarchical_viewer <- function(x) {
results <- x$hierarchical_results
labels <- names(results) %||% x$strata_labels %||% paste0("Stratum ", seq_along(results))
summary_rows <- lapply(seq_along(results), function(i) {
ov <- results[[i]]$overview
data.frame(
Stratum = labels[i], N = ov$N[1L], Events = ov$Events[1L],
Censored = ov$Censored[1L], `Event %` = .r4vn_surv_fmt(ov$`Event percent`[1L], 1),
`Person-time` = .r4vn_surv_fmt(ov$`Person-time`[1L], 2),
stringsAsFactors = FALSE, check.names = FALSE
)
})
blocks <- .r4vn_view_section("Hierarchy overview", do.call(rbind, summary_rows))
for (i in seq_along(results)) {
z <- results[[i]]
blocks <- paste0(
blocks, '<section class="r4vn-section"><h2>',
.r4vn_view_escape(labels[i]), '</h2></section>'
)
if (!is.null(z$descriptive) && nrow(z$descriptive)) {
blocks <- paste0(blocks, .r4vn_view_section("Outcome by group", z$descriptive))
}
if (!is.null(z$median) && nrow(z$median)) {
q <- z$median
q$`Median survival (95% CI)` <- .r4vn_surv_ci_text(q$median, q$lower, q$upper, 2)
blocks <- paste0(blocks, .r4vn_view_section(
"Median survival", q[, intersect(c("group", "Median survival (95% CI)"), names(q)), drop = FALSE]
))
}
if (!is.null(z$lifetable) && nrow(z$lifetable)) {
blocks <- paste0(blocks, .r4vn_view_section(
if (isTRUE(z$metadata$competing)) {
"Life table (Aalen-Johansen event history)"
} else "Life table (Kaplan-Meier)",
z$lifetable
))
}
if (!is.null(z$risk) && nrow(z$risk)) {
q <- z$risk
q$`Risk (95% CI)` <- .r4vn_surv_ci_text(q$risk, q$risk_lower, q$risk_upper, 2, percent = TRUE)
blocks <- paste0(blocks, .r4vn_view_section(
if (isTRUE(z$metadata$competing)) "Cumulative incidence" else "Cumulative risk",
q[, intersect(c("group", "time", "n.risk", "Risk (95% CI)"), names(q)), drop = FALSE]
))
}
if (!is.null(z$rate) && nrow(z$rate)) {
q <- z$rate
q$`Rate (95% CI)` <- .r4vn_surv_ci_text(q$rate, q$lower, q$upper, 2)
blocks <- paste0(blocks, .r4vn_view_section(
"Incidence rate", q[, intersect(c("group", "interval", "events", "person_time", "Rate (95% CI)"), names(q)), drop = FALSE]
))
}
if (!is.null(z$logrank) && nrow(z$logrank)) {
q <- data.frame(
`Chi-square` = .r4vn_surv_fmt(z$logrank$chisq, 2),
df = .r4vn_surv_fmt(z$logrank$df, 0),
p = vapply(z$logrank$p, .r4vn_surv_fmt_p, character(1), digits = 3),
stringsAsFactors = FALSE, check.names = FALSE
)
blocks <- paste0(blocks, .r4vn_view_section("Log-rank test", q))
}
model <- if (!is.null(z$cox$multi)) z$cox$multi else z$cox$crude
if (!is.null(model) && nrow(model)) {
blocks <- paste0(blocks, .r4vn_view_section(
if (!is.null(z$cox$multi)) "Multivariable Cox proportional hazards" else "Crude Cox proportional hazards",
.r4vn_surv_model_display(model, "HR")
))
}
if (!is.null(z$interpretation) && nrow(z$interpretation)) {
blocks <- paste0(blocks, .r4vn_view_section("Interpretation", z$interpretation))
}
}
notes <- paste0(
"Hierarchical by: ", paste(x$hierarchical_by$strata_labels %||%
x$hierarchical_by$strata, collapse = " > "),
"; innermost survival group: ", x$hierarchical_by$by_label %||%
x$hierarchical_by$by, "."
)
.r4vn_view_document(
x$title %||% "R4VN hierarchical survival analysis", blocks,
notes = notes, subtitle = "Hierarchical survival analysis",
prefix = "r4vn-tabsurv-hierarchical-"
)
}
.r4vn_viewer_tabsurv <- function(x) {
if (!is.null(x$hierarchical_results)) return(.r4vn_surv_hierarchical_viewer(x))
.r4vn_surv_viewer(x, "Survival, incidence and time-to-event analysis")
}
.r4vn_viewer_cox <- function(x) .r4vn_surv_viewer(x, "Cox proportional hazards model")
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.