diff --git a/DESCRIPTION b/DESCRIPTION index bc50e410e..efdcd3378 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -4,6 +4,7 @@ Version: 0.1.0.9009 Authors@R: c( person("Daniel", "Sjoberg", , "danield.sjoberg@gmail.com", role = c("aut", "cre")), person("Abinaya", "Yogasekaram", , "abinaya.yogasekaram@contractors.roche.com", role = "aut"), + person("Emily", "de la Rua", , "emily.de_la_rua@contractors.roche.com", role = c("aut")), person("F. Hoffmann-La Roche AG", role = c("cph", "fnd")) ) Description: Create extra Analysis Results Data (ARD) summary objects. @@ -32,6 +33,7 @@ Suggests: smd (>= 0.6.6), spelling, survey (>= 4.1), + survival (>= 3.2-11), testthat (>= 3.2.0), withr Remotes: diff --git a/NAMESPACE b/NAMESPACE index 77b5603b6..69efe18d3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -21,6 +21,7 @@ export(ard_proptest) export(ard_regression) export(ard_regression_basic) export(ard_smd) +export(ard_survfit) export(ard_svychisq) export(ard_svycontinuous) export(ard_svyranktest) diff --git a/NEWS.md b/NEWS.md index 54733e01c..f38a43aa7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,7 @@ - `ard_proptest()` for tests of proportions using `stats::prop.test()`. (#64) - `ard_regression_basic()` for basic regression models. The function focuses on matching terms to underlying variables names. (#46) - `ard_smd()` for calculating standardized mean differences using `smd::smd()`. (#4) + - `ard_survfit()` for survival analyses using `survival::survfit()`. (#43) - `ard_svycontinuous()` for calculating univariate summary statistics from weighted/survey data using many functions from the {survey} package. (#68) - `ard_svychisq()` for weighted/survey chi-squared test using `survey::svychisq()`. (#72) - `ard_svyttest()` for weighted/survey t-tests using `survey::svyttest()`. (#70) diff --git a/R/ard_proportion_ci.R b/R/ard_proportion_ci.R index 9ceabe174..d7d08d1a4 100644 --- a/R/ard_proportion_ci.R +++ b/R/ard_proportion_ci.R @@ -14,7 +14,7 @@ #' Default is `0.95` #' @param method (`string`)\cr #' string indicating the type of confidence interval to calculate. -#' Must be one of `r formals(ard_proportion_ci)[["method"]] |> eval() |> shQuote()`. +#' Must be one of `r formals(ard_proportion_ci)[["method"]] |> eval() |> shQuote("sh")`. #' See `?proportion_ci` for details. #' @param strata,weights,max.iterations arguments passed to `proportion_ci_strat_wilson()`, #' when `method='strat_wilson'` diff --git a/R/ard_survfit.R b/R/ard_survfit.R new file mode 100644 index 000000000..e13709af4 --- /dev/null +++ b/R/ard_survfit.R @@ -0,0 +1,343 @@ +#' ARD Survival Estimates +#' +#' @description +#' Analysis results data for survival quantiles and x-year survival estimates, extracted +#' from a [survival::survfit()] model. +#' +#' @param x ([survival::survfit()])\cr +#' a [survival::survfit()] object. See below for details. +#' @param times (`numeric`)\cr +#' a vector of times for which to return survival probabilities. +#' @param probs (`numeric`)\cr +#' a vector of probabilities with values in (0,1) specifying the survival quantiles to return. +#' @param type (`string` or `NULL`)\cr +#' type of statistic to report. Available for Kaplan-Meier time estimates only, otherwise `type` +#' is ignored. Default is `NULL`. +#' Must be one of the following: +#' ```{r, echo = FALSE} +#' dplyr::tribble( +#' ~type, ~transformation, +#' '`"survival"`', '`x`', +#' '`"risk"`', '`1 - x`', +#' '`"cumhaz"`', '`-log(x)`', +#' ) %>% +#' knitr::kable() +#' ``` +#' +#' @return an ARD data frame of class 'card' +#' @name ard_survfit +#' +#' @details +#' * Only one of either the `times` or `probs` parameters can be specified. +#' * Times should be provided using the same scale as the time variable used to fit the provided +#' survival fit model. +#' +#' @examplesIf cards::is_pkg_installed(c("survival", "broom"), reference_pkg = "cardx") +#' library(survival) +#' +#' survfit(Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> +#' ard_survfit(times = c(60, 180)) +#' +#' survfit(Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> +#' ard_survfit(probs = c(0.25, 0.5, 0.75)) +#' +#' # Competing Risks Example --------------------------- +#' set.seed(1) +#' ADTTE_MS <- cards::ADTTE %>% +#' dplyr::mutate( +#' CNSR = dplyr::case_when( +#' CNSR == 0 ~ "censor", +#' runif(dplyr::n()) < 0.5 ~ "death from cancer", +#' TRUE ~ "death other causes" +#' ) %>% factor() +#' ) +#' +#' survfit(Surv(AVAL, CNSR) ~ TRTA, data = ADTTE_MS) %>% +#' ard_survfit(times = c(60, 180)) +NULL + +#' @rdname ard_survfit +#' @export +ard_survfit <- function(x, times = NULL, probs = NULL, type = NULL) { + # check installed packages --------------------------------------------------- + cards::check_pkg_installed(c("survival", "broom"), reference_pkg = "cardx") + + # check/process inputs ------------------------------------------------------- + check_not_missing(x) + check_class(x, cls = "survfit") + if (inherits(x, "survfitcox")) { + cli::cli_abort("Argument {.arg x} cannot be class {.cls survfitcox}.") + } + + # competing risks models cannot use the type argument + if (inherits(x, c("survfitms", "survfitcoxms")) && !is.null(type)) { + cli::cli_abort("Cannot use {.arg type} argument with {.code survfit} models with class {.cls {c('survfitms', 'survfitcoxms')}}.") + } + if (!is.null(probs)) check_range(probs, c(0, 1)) + if (sum(is.null(times), is.null(probs)) != 1) { + cli::cli_abort("One and only one of {.arg times} and {.arg probs} must be specified.") + } + + # for regular KM estimators, we allow the type argument + if (!inherits(x, "survfitms") && !is.null(type)) { + type <- arg_match(type, values = c("survival", "risk", "cumhaz")) + } + + # cannot specify type arg when probs supplied + if (!is.null(probs) && !is.null(type)) { + cli::cli_abort("Cannot use {.arg type} argument when {.arg probs} argument specifed.") + } + + # build ARD ------------------------------------------------------------------ + est_type <- ifelse(is.null(probs), "times", "probs") + tidy_survfit <- switch(est_type, + "times" = .process_survfit_time(x, times, type %||% "survival"), + "probs" = .process_survfit_probs(x, probs) + ) + + .format_survfit_results(tidy_survfit) +} + +#' Process Survival Fit For Time Estimates +#' +#' @inheritParams cards::tidy_as_ard +#' @inheritParams ard_survfit +#' +#' @return a `tibble` +#' +#' @examples +#' survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> +#' cardx:::.process_survfit_time(times = c(60, 180), type = "risk") +#' +#' @keywords internal +.process_survfit_time <- function(x, times, type) { + # tidy survfit results + tidy_x <- broom::tidy(x) + + # process competing risks/multi-state models + multi_state <- inherits(x, "survfitms") + + if (multi_state == TRUE) { + # selecting state to show + state <- setdiff(unique(tidy_x$state), "(s0)")[[1]] + cli::cli_inform("Multi-state model detected. Showing probabilities into state '{state}'.") + tidy_x <- dplyr::filter(tidy_x, .data$state == .env$state) + } + + # adding time 0 to data frame + tidy_x <- tidy_x %>% + # make strata a fct to preserve ordering + dplyr::mutate(dplyr::across(dplyr::any_of("strata"), ~ factor(., levels = unique(.)))) %>% + # if CI is missing and SE is 0, use estimate as the CI + dplyr::mutate_at( + dplyr::vars("conf.high", "conf.low"), + ~ ifelse(is.na(.) & .data$std.error == 0, .data$estimate, .) + ) %>% + dplyr::select(dplyr::any_of(c("time", "estimate", "conf.high", "conf.low", "strata"))) %>% + # add data for time 0 + dplyr::bind_rows( + dplyr::group_by_at(., dplyr::vars(dplyr::any_of("strata"))) %>% + dplyr::slice(1) %>% + dplyr::mutate( + time = 0, + estimate = ifelse(multi_state, 0, 1), + conf.low = ifelse(multi_state, 0, 1), + conf.high = ifelse(multi_state, 0, 1) + ) + ) %>% + dplyr::ungroup() + + strat <- "strata" %in% names(tidy_x) + + # get requested estimates + df_stat <- tidy_x %>% + # find max time + dplyr::group_by_at(., dplyr::vars(dplyr::any_of("strata"))) %>% + dplyr::mutate(time_max = max(.data$time)) %>% + dplyr::ungroup() %>% + # add requested timepoints + dplyr::full_join( + tidy_x %>% + dplyr::select(any_of("strata")) %>% + dplyr::distinct() %>% + dplyr::mutate( + time = list(.env$times), + col_name = list(paste("stat", seq_len(length(.env$times)), sep = "_")) + ) %>% + tidyr::unnest(cols = c("time", "col_name")), + by = unlist(intersect(c("strata", "time"), names(tidy_x))) + ) + + if (strat) { + df_stat <- df_stat %>% dplyr::arrange(.data$strata) + } + + df_stat <- df_stat %>% + # if user-specifed time is unobserved, fill estimate with previous value + dplyr::arrange(.data$time) %>% + dplyr::group_by_at(dplyr::vars(dplyr::any_of("strata"))) %>% + tidyr::fill( + "estimate", "conf.high", "conf.low", "time_max", + .direction = "down" + ) %>% + dplyr::ungroup() %>% + # keep only user-specified times + dplyr::filter(!is.na(.data$col_name)) %>% + # if user-specified time is after max time, make estimate NA + dplyr::mutate_at( + dplyr::vars("estimate", "conf.high", "conf.low"), + ~ ifelse(.data$time > .data$time_max, NA_real_, .) + ) %>% + dplyr::mutate(context = type) %>% + dplyr::select(!dplyr::any_of(c("time_max", "col_name"))) + + # convert estimates to requested type + if (type != "survival") { + df_stat <- df_stat %>% + dplyr::mutate(dplyr::across( + any_of(c("estimate", "conf.low", "conf.high")), + if (type == "cumhaz") ~ -log(.x) else ~ 1 - .x + )) %>% + dplyr::rename(conf.low = "conf.high", conf.high = "conf.low") + } + + df_stat <- extract_multi_strata(x, df_stat) + + df_stat +} + +#' Process Survival Fit For Quantile Estimates +#' +#' @inheritParams cards::tidy_as_ard +#' @inheritParams ard_survfit +#' +#' @return a `tibble` +#' +#' @examples +#' survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> +#' cardx:::.process_survfit_probs(probs = c(0.25, 0.75)) +#' +#' @keywords internal +.process_survfit_probs <- function(x, probs) { + # calculate survival quantiles and add estimates to df + df_stat <- map2( + probs, + seq_along(probs), + ~ stats::quantile(x, probs = .x) %>% + as.data.frame() %>% + set_names(c("estimate", "conf.low", "conf.high")) %>% + dplyr::mutate(strata = row.names(.)) %>% + dplyr::select(dplyr::any_of(c("strata", "estimate", "conf.low", "conf.high"))) %>% + dplyr::mutate(prob = .x) + ) %>% + dplyr::bind_rows() %>% + `rownames<-`(NULL) %>% + dplyr::mutate(context = "survival") %>% + dplyr::as_tibble() + + if (length(x$n) == 1) df_stat <- df_stat %>% dplyr::select(-"strata") + + df_stat <- extract_multi_strata(x, df_stat) + + df_stat +} + +# process multiple stratifying variables +extract_multi_strata <- function(x, df_stat) { + x_terms <- attr(stats::terms(stats::as.formula(x$call$formula)), "term.labels") + x_terms <- gsub(".*\\(", "", gsub("\\)", "", x_terms)) + if (length(x_terms) > 1) { + strata_lvls <- data.frame() + + for (i in df_stat[["strata"]]) { + i <- gsub(".*\\(", "", gsub("\\)", "", i)) + terms_str <- strsplit(i, paste(c(paste0(x_terms, "="), paste0(", ", x_terms, "=")), collapse = "|"))[[1]] + s_lvl <- terms_str[nchar(terms_str) > 0] + strata_lvls <- rbind(strata_lvls, s_lvl) + } + if (nrow(strata_lvls) > 0) { + strata_lvls <- cbind(strata_lvls, t(x_terms)) + names(strata_lvls) <- c( + t(sapply(seq_along(x_terms), function(i) c(paste0("group", i, "_level"), paste0("group", i)))) + ) + df_stat <- cbind(df_stat, strata_lvls) %>% + dplyr::select(-"strata") + } + } + df_stat +} + +#' Convert Tidied Survival Fit to ARD +#' +#' @inheritParams cards::tidy_as_ard +#' +#' @return an ARD data frame of class 'card' +#' +#' @examples +#' cardx:::.format_survfit_results( +#' broom::tidy(survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE)) +#' ) +#' +#' @keywords internal +.format_survfit_results <- function(tidy_survfit) { + est <- if ("time" %in% names(tidy_survfit)) "time" else "prob" + + ret <- tidy_survfit %>% + dplyr::mutate(dplyr::across( + dplyr::any_of(c("estimate", "conf.high", "conf.low", "time", "prob")), ~ as.list(.) + )) %>% + tidyr::pivot_longer( + cols = dplyr::any_of(c("estimate", "conf.high", "conf.low")), + names_to = "stat_name", + values_to = "stat" + ) %>% + dplyr::mutate( + variable = est, + variable_level = .data[[est]] + ) %>% + dplyr::select(-all_of(est)) + + if ("strata" %in% names(ret)) { + ret <- ret %>% + tidyr::separate_wider_delim("strata", "=", names = c("group1", "group1_level")) + } + + ret %>% + dplyr::left_join( + .df_survfit_stat_labels(), + by = "stat_name" + ) %>% + dplyr::mutate( + fmt_fn = lapply( + .data$stat, + function(x) { + switch(is.integer(x), + 0L + ) %||% switch(is.numeric(x), + 1L + ) + } + ), + stat_label = dplyr::coalesce(.data$stat_label, .data$stat_name) + ) %>% + dplyr::mutate(dplyr::across(matches("group[0-9]*_level"), ~ as.list(as.factor(.x)))) %>% + dplyr::mutate( + warning = list(NULL), + error = list(NULL) + ) %>% + structure(., class = c("card", class(.))) %>% + cards::tidy_ard_column_order() %>% + cards::tidy_ard_row_order() +} + +.df_survfit_stat_labels <- function() { + dplyr::tribble( + ~stat_name, ~stat_label, + "estimate", "Survival Probability", + "conf.low", "CI Lower Bound", + "conf.high", "CI Upper Bound", + "conf.level", "CI Confidence Level", + "prob", "Quantile", + "time", "Time" + ) +} diff --git a/R/ard_svycontinuous.R b/R/ard_svycontinuous.R index cb719d27b..762069cbe 100644 --- a/R/ard_svycontinuous.R +++ b/R/ard_svycontinuous.R @@ -27,7 +27,7 @@ #' @section statistic argument: #' #' The following statistics are available: -#' `r cardx:::accepted_svy_stats(FALSE) |> shQuote() |> paste(collapse = ", ")`, +#' `r cardx:::accepted_svy_stats(FALSE) |> shQuote("sh") |> paste(collapse = ", ")`, #' where 'p##' is are the percentiles and `##` is an integer between 0 and 100. #' #' diff --git a/_pkgdown.yml b/_pkgdown.yml index 419b3d101..c58401c7b 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -45,6 +45,7 @@ reference: - ard_regression - ard_regression_basic - ard_smd + - ard_survfit - ard_svycontinuous - ard_vif diff --git a/inst/WORDLIST b/inst/WORDLIST index 70899e5b5..a703d5cda 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -6,10 +6,12 @@ Clopper Codecov Hoffmann Jeffreys +Kaplan Lifecycle McNemar's Newcombe Rao +Rua Su VIF XG @@ -17,6 +19,7 @@ Xin agresti clopper coull +de deff funder jeffreys diff --git a/man/ard_survfit.Rd b/man/ard_survfit.Rd new file mode 100644 index 000000000..56eeac987 --- /dev/null +++ b/man/ard_survfit.Rd @@ -0,0 +1,67 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/ard_survfit.R +\name{ard_survfit} +\alias{ard_survfit} +\title{ARD Survival Estimates} +\usage{ +ard_survfit(x, times = NULL, probs = NULL, type = NULL) +} +\arguments{ +\item{x}{(\code{\link[survival:survfit]{survival::survfit()}})\cr +a \code{\link[survival:survfit]{survival::survfit()}} object. See below for details.} + +\item{times}{(\code{numeric})\cr +a vector of times for which to return survival probabilities.} + +\item{probs}{(\code{numeric})\cr +a vector of probabilities with values in (0,1) specifying the survival quantiles to return.} + +\item{type}{(\code{string} or \code{NULL})\cr +type of statistic to report. Available for Kaplan-Meier time estimates only, otherwise \code{type} +is ignored. Default is \code{NULL}. +Must be one of the following:\tabular{ll}{ + type \tab transformation \cr + \code{"survival"} \tab \code{x} \cr + \code{"risk"} \tab \code{1 - x} \cr + \code{"cumhaz"} \tab \code{-log(x)} \cr +}} +} +\value{ +an ARD data frame of class 'card' +} +\description{ +Analysis results data for survival quantiles and x-year survival estimates, extracted +from a \code{\link[survival:survfit]{survival::survfit()}} model. +} +\details{ +\itemize{ +\item Only one of either the \code{times} or \code{probs} parameters can be specified. +\item Times should be provided using the same scale as the time variable used to fit the provided +survival fit model. +} +} +\examples{ +\dontshow{if (cards::is_pkg_installed(c("survival", "broom"), reference_pkg = "cardx")) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +library(survival) + +survfit(Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(times = c(60, 180)) + +survfit(Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(probs = c(0.25, 0.5, 0.75)) + +# Competing Risks Example --------------------------- +set.seed(1) +ADTTE_MS <- cards::ADTTE \%>\% + dplyr::mutate( + CNSR = dplyr::case_when( + CNSR == 0 ~ "censor", + runif(dplyr::n()) < 0.5 ~ "death from cancer", + TRUE ~ "death other causes" + ) \%>\% factor() + ) + +survfit(Surv(AVAL, CNSR) ~ TRTA, data = ADTTE_MS) \%>\% + ard_survfit(times = c(60, 180)) +\dontshow{\}) # examplesIf} +} diff --git a/man/cardx-package.Rd b/man/cardx-package.Rd index 545747c76..2afc46c23 100644 --- a/man/cardx-package.Rd +++ b/man/cardx-package.Rd @@ -24,6 +24,7 @@ Useful links: Authors: \itemize{ \item Abinaya Yogasekaram \email{abinaya.yogasekaram@contractors.roche.com} + \item Emily de la Rua \email{emily.de_la_rua@contractors.roche.com} } Other contributors: diff --git a/man/dot-format_survfit_results.Rd b/man/dot-format_survfit_results.Rd new file mode 100644 index 000000000..d1a1e36fb --- /dev/null +++ b/man/dot-format_survfit_results.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/ard_survfit.R +\name{.format_survfit_results} +\alias{.format_survfit_results} +\title{Convert Tidied Survival Fit to ARD} +\usage{ +.format_survfit_results(tidy_survfit) +} +\value{ +an ARD data frame of class 'card' +} +\description{ +Convert Tidied Survival Fit to ARD +} +\examples{ +cardx:::.format_survfit_results( + broom::tidy(survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE)) +) + +} +\keyword{internal} diff --git a/man/dot-process_survfit_probs.Rd b/man/dot-process_survfit_probs.Rd new file mode 100644 index 000000000..4e2a89923 --- /dev/null +++ b/man/dot-process_survfit_probs.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/ard_survfit.R +\name{.process_survfit_probs} +\alias{.process_survfit_probs} +\title{Process Survival Fit For Quantile Estimates} +\usage{ +.process_survfit_probs(x, probs) +} +\arguments{ +\item{x}{(\code{\link[survival:survfit]{survival::survfit()}})\cr +a \code{\link[survival:survfit]{survival::survfit()}} object. See below for details.} + +\item{probs}{(\code{numeric})\cr +a vector of probabilities with values in (0,1) specifying the survival quantiles to return.} +} +\value{ +a \code{tibble} +} +\description{ +Process Survival Fit For Quantile Estimates +} +\examples{ +survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + cardx:::.process_survfit_probs(probs = c(0.25, 0.75)) + +} +\keyword{internal} diff --git a/man/dot-process_survfit_time.Rd b/man/dot-process_survfit_time.Rd new file mode 100644 index 000000000..6fe6a0dd7 --- /dev/null +++ b/man/dot-process_survfit_time.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/ard_survfit.R +\name{.process_survfit_time} +\alias{.process_survfit_time} +\title{Process Survival Fit For Time Estimates} +\usage{ +.process_survfit_time(x, times, type) +} +\arguments{ +\item{x}{(\code{\link[survival:survfit]{survival::survfit()}})\cr +a \code{\link[survival:survfit]{survival::survfit()}} object. See below for details.} + +\item{times}{(\code{numeric})\cr +a vector of times for which to return survival probabilities.} + +\item{type}{(\code{string} or \code{NULL})\cr +type of statistic to report. Available for Kaplan-Meier time estimates only, otherwise \code{type} +is ignored. Default is \code{NULL}. +Must be one of the following:\tabular{ll}{ + type \tab transformation \cr + \code{"survival"} \tab \code{x} \cr + \code{"risk"} \tab \code{1 - x} \cr + \code{"cumhaz"} \tab \code{-log(x)} \cr +}} +} +\value{ +a \code{tibble} +} +\description{ +Process Survival Fit For Time Estimates +} +\examples{ +survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + cardx:::.process_survfit_time(times = c(60, 180), type = "risk") + +} +\keyword{internal} diff --git a/tests/testthat/_snaps/ard_survfit.md b/tests/testthat/_snaps/ard_survfit.md new file mode 100644 index 000000000..5d209bb98 --- /dev/null +++ b/tests/testthat/_snaps/ard_survfit.md @@ -0,0 +1,261 @@ +# ard_survfit() works with times provided + + Code + print(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv(AVAL, CNSR) ~ + TRTA, cards::ADTTE), times = c(60, 180)), stat = lapply(stat, function(x) + ifelse(is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + {cards} data frame: 18 x 11 + Output + group1 group1_level variable variable_level stat_name stat_label stat + 1 TRTA Placebo time 60 estimate Survival… 0.893 + 2 TRTA Placebo time 60 conf.high CI Upper… 0.966 + 3 TRTA Placebo time 60 conf.low CI Lower… 0.825 + 4 TRTA Placebo time 180 estimate Survival… 0.651 + 5 TRTA Placebo time 180 conf.high CI Upper… 0.783 + 6 TRTA Placebo time 180 conf.low CI Lower… 0.541 + 7 TRTA Xanomeli… time 60 estimate Survival… 0.694 + 8 TRTA Xanomeli… time 60 conf.high CI Upper… 0.849 + 9 TRTA Xanomeli… time 60 conf.low CI Lower… 0.568 + 10 TRTA Xanomeli… time 180 estimate Survival… 0.262 + 11 TRTA Xanomeli… time 180 conf.high CI Upper… 0.749 + 12 TRTA Xanomeli… time 180 conf.low CI Lower… 0.092 + 13 TRTA Xanomeli… time 60 estimate Survival… 0.732 + 14 TRTA Xanomeli… time 60 conf.high CI Upper… 0.878 + 15 TRTA Xanomeli… time 60 conf.low CI Lower… 0.61 + 16 TRTA Xanomeli… time 180 estimate Survival… 0.381 + 17 TRTA Xanomeli… time 180 conf.high CI Upper… 0.743 + 18 TRTA Xanomeli… time 180 conf.low CI Lower… 0.195 + Message + i 4 more variables: context, fmt_fn, warning, error + +# ard_survfit() works with different type + + Code + print(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv(AVAL, CNSR) ~ + TRTA, cards::ADTTE), times = c(60, 180), type = "risk"), stat = lapply(stat, + function(x) ifelse(is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + {cards} data frame: 18 x 11 + Output + group1 group1_level variable variable_level stat_name stat_label stat + 1 TRTA Placebo time 60 estimate Survival… 0.107 + 2 TRTA Placebo time 60 conf.high CI Upper… 0.175 + 3 TRTA Placebo time 60 conf.low CI Lower… 0.034 + 4 TRTA Placebo time 180 estimate Survival… 0.349 + 5 TRTA Placebo time 180 conf.high CI Upper… 0.459 + 6 TRTA Placebo time 180 conf.low CI Lower… 0.217 + 7 TRTA Xanomeli… time 60 estimate Survival… 0.306 + 8 TRTA Xanomeli… time 60 conf.high CI Upper… 0.432 + 9 TRTA Xanomeli… time 60 conf.low CI Lower… 0.151 + 10 TRTA Xanomeli… time 180 estimate Survival… 0.738 + 11 TRTA Xanomeli… time 180 conf.high CI Upper… 0.908 + 12 TRTA Xanomeli… time 180 conf.low CI Lower… 0.251 + 13 TRTA Xanomeli… time 60 estimate Survival… 0.268 + 14 TRTA Xanomeli… time 60 conf.high CI Upper… 0.39 + 15 TRTA Xanomeli… time 60 conf.low CI Lower… 0.122 + 16 TRTA Xanomeli… time 180 estimate Survival… 0.619 + 17 TRTA Xanomeli… time 180 conf.high CI Upper… 0.805 + 18 TRTA Xanomeli… time 180 conf.low CI Lower… 0.257 + Message + i 4 more variables: context, fmt_fn, warning, error + +# ard_survfit() works with probs provided + + Code + print(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv(AVAL, CNSR) ~ + TRTA, cards::ADTTE), probs = c(0.25, 0.75)), stat = lapply(stat, function(x) + ifelse(is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + {cards} data frame: 18 x 11 + Output + group1 group1_level variable variable_level stat_name stat_label stat + 1 TRTA Placebo prob 0.25 estimate Survival… 142 + 2 TRTA Placebo prob 0.25 conf.high CI Upper… 181 + 3 TRTA Placebo prob 0.25 conf.low CI Lower… 70 + 4 TRTA Placebo prob 0.75 estimate Survival… 184 + 5 TRTA Placebo prob 0.75 conf.high CI Upper… 191 + 6 TRTA Placebo prob 0.75 conf.low CI Lower… 183 + 7 TRTA Xanomeli… prob 0.25 estimate Survival… 44 + 8 TRTA Xanomeli… prob 0.25 conf.high CI Upper… 180 + 9 TRTA Xanomeli… prob 0.25 conf.low CI Lower… 22 + 10 TRTA Xanomeli… prob 0.75 estimate Survival… 188 + 11 TRTA Xanomeli… prob 0.75 conf.high CI Upper… NA + 12 TRTA Xanomeli… prob 0.75 conf.low CI Lower… 167 + 13 TRTA Xanomeli… prob 0.25 estimate Survival… 49 + 14 TRTA Xanomeli… prob 0.25 conf.high CI Upper… 180 + 15 TRTA Xanomeli… prob 0.25 conf.low CI Lower… 37 + 16 TRTA Xanomeli… prob 0.75 estimate Survival… 184 + 17 TRTA Xanomeli… prob 0.75 conf.high CI Upper… NA + 18 TRTA Xanomeli… prob 0.75 conf.low CI Lower… 180 + Message + i 4 more variables: context, fmt_fn, warning, error + +# ard_survfit() works with unstratified model + + Code + print(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv(time, status) ~ + 1, data = survival::lung), times = c(60, 180)), stat = lapply(stat, function( + x) ifelse(is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + {cards} data frame: 6 x 9 + Output + variable variable_level context stat_name stat_label stat + 1 time 60 survival estimate Survival… 0.925 + 2 time 60 survival conf.high CI Upper… 0.96 + 3 time 60 survival conf.low CI Lower… 0.892 + 4 time 180 survival estimate Survival… 0.722 + 5 time 180 survival conf.high CI Upper… 0.783 + 6 time 180 survival conf.low CI Lower… 0.666 + Message + i 3 more variables: fmt_fn, warning, error + +--- + + Code + print(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv(time, status) ~ + 1, data = survival::lung), probs = c(0.5, 0.75)), stat = lapply(stat, + function(x) ifelse(is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + {cards} data frame: 6 x 9 + Output + variable variable_level context stat_name stat_label stat + 1 prob 0.5 survival estimate Survival… 310 + 2 prob 0.5 survival conf.high CI Upper… 363 + 3 prob 0.5 survival conf.low CI Lower… 285 + 4 prob 0.75 survival estimate Survival… 550 + 5 prob 0.75 survival conf.high CI Upper… 654 + 6 prob 0.75 survival conf.low CI Lower… 460 + Message + i 3 more variables: fmt_fn, warning, error + +# ard_survfit() works with multiple stratification variables + + Code + print(head(dplyr::select(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv( + time, status) ~ sex + ph.ecog, data = survival::lung), times = c(60, 180)), + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x))), + "group1", "group1_level", "group2", "group2_level"), 20), n = Inf) + Message + {cards} data frame: 20 x 4 + Output + group1 group1_level group2 group2_level + 1 sex 1 ph.ecog 0 + 2 sex 1 ph.ecog 0 + 3 sex 1 ph.ecog 0 + 4 sex 1 ph.ecog 0 + 5 sex 1 ph.ecog 0 + 6 sex 1 ph.ecog 0 + 7 sex 1 ph.ecog 1 + 8 sex 1 ph.ecog 1 + 9 sex 1 ph.ecog 1 + 10 sex 1 ph.ecog 1 + 11 sex 1 ph.ecog 1 + 12 sex 1 ph.ecog 1 + 13 sex 1 ph.ecog 2 + 14 sex 1 ph.ecog 2 + 15 sex 1 ph.ecog 2 + 16 sex 1 ph.ecog 2 + 17 sex 1 ph.ecog 2 + 18 sex 1 ph.ecog 2 + 19 sex 1 ph.ecog 3 + 20 sex 1 ph.ecog 3 + +--- + + Code + print(head(dplyr::select(dplyr::mutate(ard_survfit(survival::survfit(survival::Surv( + time, status) ~ sex + ph.ecog, data = survival::lung), probs = c(0.5, 0.75)), + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x))), + "group1", "group1_level", "group2", "group2_level"), 20), n = Inf) + Message + {cards} data frame: 20 x 4 + Output + group1 group1_level group2 group2_level + 1 sex 1 ph.ecog 0 + 2 sex 1 ph.ecog 0 + 3 sex 1 ph.ecog 0 + 4 sex 1 ph.ecog 0 + 5 sex 1 ph.ecog 0 + 6 sex 1 ph.ecog 0 + 7 sex 1 ph.ecog 1 + 8 sex 1 ph.ecog 1 + 9 sex 1 ph.ecog 1 + 10 sex 1 ph.ecog 1 + 11 sex 1 ph.ecog 1 + 12 sex 1 ph.ecog 1 + 13 sex 1 ph.ecog 2 + 14 sex 1 ph.ecog 2 + 15 sex 1 ph.ecog 2 + 16 sex 1 ph.ecog 2 + 17 sex 1 ph.ecog 2 + 18 sex 1 ph.ecog 2 + 19 sex 1 ph.ecog 3 + 20 sex 1 ph.ecog 3 + +# ard_survfit() works with competing risks + + Code + print(dplyr::mutate(survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, data = ADTTE_MS) %>% + ard_survfit(times = c(60, 180)), stat = lapply(stat, function(x) ifelse( + is.numeric(x), cards::round5(x, 3), x))), n = Inf) + Message + Multi-state model detected. Showing probabilities into state 'death from cancer'. + {cards} data frame: 18 x 11 + Output + group1 group1_level variable variable_level stat_name stat_label stat + 1 TRTA Placebo time 60 estimate Survival… 0.054 + 2 TRTA Placebo time 60 conf.high CI Upper… 0.14 + 3 TRTA Placebo time 60 conf.low CI Lower… 0.021 + 4 TRTA Placebo time 180 estimate Survival… 0.226 + 5 TRTA Placebo time 180 conf.high CI Upper… 0.361 + 6 TRTA Placebo time 180 conf.low CI Lower… 0.142 + 7 TRTA Xanomeli… time 60 estimate Survival… 0.137 + 8 TRTA Xanomeli… time 60 conf.high CI Upper… 0.311 + 9 TRTA Xanomeli… time 60 conf.low CI Lower… 0.06 + 10 TRTA Xanomeli… time 180 estimate Survival… 0.51 + 11 TRTA Xanomeli… time 180 conf.high CI Upper… 0.892 + 12 TRTA Xanomeli… time 180 conf.low CI Lower… 0.292 + 13 TRTA Xanomeli… time 60 estimate Survival… 0.162 + 14 TRTA Xanomeli… time 60 conf.high CI Upper… 0.33 + 15 TRTA Xanomeli… time 60 conf.low CI Lower… 0.08 + 16 TRTA Xanomeli… time 180 estimate Survival… 0.244 + 17 TRTA Xanomeli… time 180 conf.high CI Upper… 0.516 + 18 TRTA Xanomeli… time 180 conf.low CI Lower… 0.115 + Message + i 4 more variables: context, fmt_fn, warning, error + +# ard_survfit() errors are properly handled + + Code + ard_survfit("not_survfit") + Condition + Error in `ard_survfit()`: + ! The `x` argument must be class , not a string. + +--- + + Code + ard_survfit(survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE), + times = 100, type = "notatype") + Condition + Error in `ard_survfit()`: + ! `type` must be one of "survival", "risk", or "cumhaz", not "notatype". + +--- + + Code + ard_survfit(survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE), + times = 100, probs = c(0.25, 0.75)) + Condition + Error in `ard_survfit()`: + ! One and only one of `times` and `probs` must be specified. + +# ard_survfit() errors with stratified Cox model + + Code + ard_survfit(survfit(coxph(Surv(time, status) ~ age + strata(sex), survival::lung))) + Condition + Error in `ard_survfit()`: + ! Argument `x` cannot be class . + diff --git a/tests/testthat/test-ard_survfit.R b/tests/testthat/test-ard_survfit.R new file mode 100644 index 000000000..80681fab5 --- /dev/null +++ b/tests/testthat/test-ard_survfit.R @@ -0,0 +1,143 @@ +skip_if_not(cards::is_pkg_installed(c("survival", "broom"), reference_pkg = "cardx")) + +test_that("ard_survfit() works with times provided", { + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() works with different type", { + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(times = c(60, 180), type = "risk") |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() works with probs provided", { + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(probs = c(0.25, 0.75)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() works with unstratified model", { + expect_snapshot( + survival::survfit(survival::Surv(time, status) ~ 1, data = survival::lung) |> + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) + + expect_snapshot( + survival::survfit(survival::Surv(time, status) ~ 1, data = survival::lung) |> + ard_survfit(probs = c(0.5, 0.75)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() works with multiple stratification variables", { + expect_snapshot( + survival::survfit(survival::Surv(time, status) ~ sex + ph.ecog, data = survival::lung) |> + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + dplyr::select("group1", "group1_level", "group2", "group2_level") |> + head(20) |> + print(n = Inf) + ) + + expect_snapshot( + survival::survfit(survival::Surv(time, status) ~ sex + ph.ecog, data = survival::lung) |> + ard_survfit(probs = c(0.5, 0.75)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + dplyr::select("group1", "group1_level", "group2", "group2_level") |> + head(20) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() works with competing risks", { + set.seed(1) + ADTTE_MS <- cards::ADTTE %>% + dplyr::mutate( + CNSR = dplyr::case_when( + CNSR == 0 ~ "censor", + runif(dplyr::n()) < 0.5 ~ "death from cancer", + TRUE ~ "death other causes" + ) %>% factor() + ) + + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, data = ADTTE_MS) %>% + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) |> + print(n = Inf) + ) +}) + +test_that("ard_survfit() errors are properly handled", { + expect_snapshot( + ard_survfit("not_survfit"), + error = TRUE + ) + + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(times = 100, type = "notatype"), + error = TRUE + ) + + expect_snapshot( + survival::survfit(survival::Surv(AVAL, CNSR) ~ TRTA, cards::ADTTE) |> + ard_survfit(times = 100, probs = c(0.25, 0.75)), + error = TRUE + ) +}) + +test_that("ard_survfit() works with non-syntactic names", { + expect_equal( + survival::survfit(survival::Surv(time, status) ~ factor(sex) + `ph.ecog`, data = survival::lung) |> + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ), + survival::survfit(survival::Surv(time, status) ~ sex + ph.ecog, data = survival::lung) |> + ard_survfit(times = c(60, 180)) |> + dplyr::mutate( + stat = lapply(stat, function(x) ifelse(is.numeric(x), cards::round5(x, 3), x)) + ) + ) +}) + +test_that("ard_survfit() errors with stratified Cox model", { + withr::local_namespace("survival") + expect_snapshot( + error = TRUE, + coxph(Surv(time, status) ~ age + strata(sex), survival::lung) |> + survfit() |> + ard_survfit() + ) +})