diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31339a5..aa07231 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: # Format R files - repo: https://github.com/lorenzwalthert/precommit - rev: v0.4.3.9029 + rev: v0.4.3.9034 hooks: - id: style-files args: [ diff --git a/DESCRIPTION b/DESCRIPTION index ac7845b..49fcc3c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -28,7 +28,8 @@ Imports: rlang, tibble, tidyselect, - utils + utils, + survival Suggests: knitr, rmarkdown, @@ -36,7 +37,7 @@ Suggests: testthat (>= 3.0.0) VignetteBuilder: knitr -Config/roxygen2/version: 8.1.0 +Config/roxygen2/version: 8.1.1 Config/testthat/edition: 3 Encoding: UTF-8 Language: en-US diff --git a/NAMESPACE b/NAMESPACE index c6567fc..feca183 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,7 +1,8 @@ # Generated by roxygen2: do not edit by hand -importFrom("stats", "complete.cases") + export(acro_add_comments) export(acro_add_exception) +export(acro_coxph) export(acro_crosstab) export(acro_custom_output) export(acro_disable_rounding) @@ -21,3 +22,9 @@ export(acro_rename_output) export(acro_summarise) export(acro_surv_func) export(acro_table) +importFrom(stats, + complete.cases, + model.frame, + na.omit, + terms +) diff --git a/R/acro_init.R b/R/acro_init.R index f6bfd35..79f57fd 100644 --- a/R/acro_init.R +++ b/R/acro_init.R @@ -1,6 +1,6 @@ # Globals ----------------------------------------------------------------- acro_venv <- "r-acro" -acro_pkg <- "acro==1.0.2" +acro_pkg <- "acro==1.1.0" ch <- "conda-forge" diff --git a/R/acro_regression.R b/R/acro_regression.R index d0315da..7f5bf6b 100644 --- a/R/acro_regression.R +++ b/R/acro_regression.R @@ -36,3 +36,119 @@ acro_glm <- function(formula, data, family) { } model$summary() } + +#' Fits a Cox proportional hazards regression model. +#' +#' @description +#' Fits a Cox proportional hazards model via ACRO. This function is designed +#' as a drop-in replacement for \code{survival::coxph()}. +#' +#' @details +#' Please note that multi-state models (using \code{istate} or \code{statedata}) +#' and time-varying covariate models are currently not supported. +#' +#' @param formula a formula object, with the response on the left of a ~ operator, and the terms on the right. The response must be a survival object as returned by the Surv function. +#' @param data a data.frame in which to interpret the variables named in the formula, or in the subset and the weights argument. +#' @param weights vector of case weights +#' @param subset expression indicating which subset of the rows of data should be used in the fit. All observations are included by default. +#' @param na.action a missing-data filter function. +#' @param robust should a robust variance be computed. +#' @param init vector of initial values of the iteration. +#' @param cluster optional variable which clusters the observations, for the purposes of a robust variance. If present, it implies robust. This variable will normally be found in data. +#' @param istate optional variable giving the current state at the start each interval. +#' @param statedata optional data set used to describe multistate models. +#' @param ... Any other parameters. +#' +#' @returns Fitted CoxPHFitter results object +#' @export + +#' @importFrom stats na.omit terms model.frame + +acro_coxph <- function(formula, data, weights = NULL, subset = NULL, na.action = NULL, robust = TRUE, init = NULL, cluster = NULL, istate = NULL, statedata = NULL, ...) { + if (is.null(acroEnv$ac)) { + stop("ACRO has not been initialised. Please first call acro_init()") + } + + # Check for multi-state model arguments (not supported by lifelines) + if (!is.null(istate) || !is.null(statedata)) { + stop("Multi-state models utilizing 'istate' or 'statedata' are not supported at the moment.") + } + + model_data <- as.data.frame(data) + + # Handle weights + weights_col <- NULL + if (!missing(weights)) { + res <- resolve_optional_column(substitute(weights), model_data, "weights", "acro_weight") + weights_col <- res$col_name + model_data <- res$data + } + + # Handle cluster + cluster_col <- NULL + if (!missing(cluster)) { + res <- resolve_optional_column(substitute(cluster), model_data, "cluster", "acro_cluster") + cluster_col <- res$col_name + model_data <- res$data + robust <- TRUE # Clustering implies robust variance + } + + # Handle subsetting if provided + if (!missing(subset)) { + subset_expr <- substitute(subset) + subsetted_columns <- eval(subset_expr, model_data) + model_data <- model_data[subsetted_columns & !is.na(subsetted_columns), ] + } + + # Handle na.action + if (is.null(na.action)) { + na.action <- getOption("na.action") + } + + na_function <- match.fun(na.action) + model_data <- na_function(model_data) + + # Parse the formula to extract duration and event columns from the left-hand side + surv_func <- formula[[2]] + if (!inherits(surv_func, "call") || as.character(surv_func[[1]]) != "Surv") { + stop("The left-hand side of the formula must use Surv(), e.g., Surv(time, event) ~ x1 + x2") + } + duration_col <- as.character(surv_func[[2]]) + event_col <- as.character(surv_func[[3]]) + + # Validate and transform event column for python lifelines + event_values <- unique(na.omit(model_data[[event_col]])) + + if (all(event_values %in% c(1, 2))) { + model_data[[event_col]][model_data[[event_col]] == 1] <- 0 + model_data[[event_col]][model_data[[event_col]] == 2] <- 1 + } else if (!all(event_values %in% c(0, 1))) { + # If it's neither {1, 2} nor {0, 1}, throw an error + stop(paste0("In Surv(", duration_col, ", ", event_col, ") : Invalid status values")) + } + + # Extract the right-hand side (independent variables) using terms() + trm <- terms(formula, data = model_data) + term_labels <- attr(trm, "term.labels") + + # Collapse back into a robust formula string + independent_vars <- paste(term_labels, collapse = " + ") + + # Check for any unused arguments + if (length(list(...)) > 0) { + warning("Unsupported arguments were provided: ", paste0(names(list(...)), collapse = ", "), "\n", "Please use the help command to learn more about the function.") + } + + model <- acroEnv$ac$coxph( + df = model_data, + duration_col = duration_col, + event_col = event_col, + weights_col = weights_col, + robust = robust, + formula = independent_vars, + initial_point = init, + cluster_col = cluster_col + ) + + return(model) +} diff --git a/R/acro_tables.R b/R/acro_tables.R index 023ebd2..27cf3ec 100644 --- a/R/acro_tables.R +++ b/R/acro_tables.R @@ -179,6 +179,8 @@ acro_table <- function(index, columns, dnn = NULL, deparse.level = 0, useNA = "n #' @returns Summary of the data #' @export +#' @importFrom stats complete.cases + acro_summarise <- function(.data, ..., .groups = NULL, .by = NULL) { if (is.null(acroEnv$ac)) { stop("ACRO has not been initialised. Please first call acro_init()") diff --git a/R/utils.R b/R/utils.R index ef7c2a2..1a8999b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,10 +1,6 @@ #' Identify empty values; NA or NaN in an object #' -#' @param Values to check for empty values in it -#' -#' @returns logical vector that shows the places of the empty values #' @noRd - is_invalid <- function(Values) { # Check for NA or NaN in the object is.na(Values) | tolower(Values) %in% c("na", "nan") @@ -12,12 +8,7 @@ is_invalid <- function(Values) { #' Identify values to be excluded from an object #' -#' @param Values to exclude from -#' @param exclude_list Items to exclude from the values -#' -#' @returns logical vector that shows the places of the items that needs to be excluded #' @noRd - is_excluded <- function(Values, exclude_list) { # Catch everything in the exclude list mask <- Values %in% exclude_list @@ -39,12 +30,7 @@ is_excluded <- function(Values, exclude_list) { #' Convert data to factors and manage NA levels #' -#' @param Values to create factors on -#' @param useNA whether to include NA values in the table -#' -#' @returns R vector factor #' @noRd - create_factors <- function(Values, useNA) { # Check for NAs char_values <- as.character(Values) @@ -64,12 +50,7 @@ create_factors <- function(Values, useNA) { #' Convert an R factor to a Pandas Categorical #' -#' @param Values R factor vector to be converted -#' @param pd Reference to the Python `pandas` module -#' -#' @returns A Python `pandas.Categorical` object #' @noRd - to_pandas_categorical <- function(Values, pd) { # Build the Pandas Categorical pd$Categorical( @@ -79,6 +60,7 @@ to_pandas_categorical <- function(Values, pd) { ) } +#' @noRd parse_summary_expression <- function(quo) { expr <- rlang::quo_get_expr(quo) @@ -117,6 +99,7 @@ parse_summary_expression <- function(quo) { ) } +#' @noRd get_offset_hist_breaks <- function(data, column, breaks = "sturges") { # Extract the column from the data r_column <- data[[column]] @@ -131,3 +114,31 @@ get_offset_hist_breaks <- function(data, column, breaks = "sturges") { } return(breaks) } + + +#' Helper to resolve external inputs (weights, cluster) +#' @noRd +## nocov start +resolve_optional_column <- function(expr, data, arg_name, prefix) { + optional_col <- NULL + if (!missing(expr)) { + col_expr <- substitute(optional_col) + if (!is.null(col_expr)) { + # If exp passed as a quoted character string matching a column name + if (is.character(col_expr) && col_expr %in% names(data)) { + optional_col <- col_expr + } + # If exp passed as an unquoted column name + else if (deparse(col_expr) %in% names(data)) { + optional_col <- deparse(col_expr) + } + # If exp passed as an external vector, expression, or function + else { + optional_col <- paste0("acro_additional_", sample(10000:99999, 1)) + data[[optional_col]] <- eval(col_expr, data, parent.frame()) + } + } + } + return(list(col_name = optional_col, data = data)) +} +## nocov end diff --git a/inst/WORDLIST b/inst/WORDLIST index dd8e46c..c2b2046 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -53,3 +53,6 @@ tidyverse www xlsx yaml +CoxPHFitter +multistate +Surv diff --git a/man/acro_coxph.Rd b/man/acro_coxph.Rd new file mode 100644 index 0000000..afcaf35 --- /dev/null +++ b/man/acro_coxph.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/acro_regression.R +\name{acro_coxph} +\alias{acro_coxph} +\title{Fits a Cox proportional hazards regression model.} +\usage{ +acro_coxph( + formula, + data, + weights = NULL, + subset = NULL, + na.action = NULL, + robust = TRUE, + init = NULL, + cluster = NULL, + istate = NULL, + statedata = NULL, + ... +) +} +\arguments{ +\item{formula}{a formula object, with the response on the left of a ~ operator, and the terms on the right. The response must be a survival object as returned by the Surv function.} + +\item{data}{a data.frame in which to interpret the variables named in the formula, or in the subset and the weights argument.} + +\item{weights}{vector of case weights} + +\item{subset}{expression indicating which subset of the rows of data should be used in the fit. All observations are included by default.} + +\item{na.action}{a missing-data filter function.} + +\item{robust}{should a robust variance be computed.} + +\item{init}{vector of initial values of the iteration.} + +\item{cluster}{optional variable which clusters the observations, for the purposes of a robust variance. If present, it implies robust. This variable will normally be found in data.} + +\item{istate}{optional variable giving the current state at the start each interval.} + +\item{statedata}{optional data set used to describe multistate models.} + +\item{...}{Any other parameters.} +} +\value{ +Fitted CoxPHFitter results object +} +\description{ +Fits a Cox proportional hazards model via ACRO. This function is designed +as a drop-in replacement for \code{survival::coxph()}. +} +\details{ +Please note that multi-state models (using \code{istate} or \code{statedata}) +and time-varying covariate models are currently not supported. +} diff --git a/tests/testthat/test-acro_coxph.R b/tests/testthat/test-acro_coxph.R new file mode 100644 index 0000000..3d95aae --- /dev/null +++ b/tests/testthat/test-acro_coxph.R @@ -0,0 +1,258 @@ +library(survival) +test_data <- lung + +test_that("acro_coxph without initialising ACRO object first", { + acroEnv$ac <- NULL + expect_error(acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data), "ACRO has not been initialised. Please first call acro_init()") +}) + +test_that("acro_coxph throws an error with multi-state arguments", { + acro_init() + expect_error( + acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, istate = 1), + "Multi-state models utilizing 'istate' or 'statedata' are not supported at the moment." + ) + expect_error( + acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, statedata = test_data), + "Multi-state models utilizing 'istate' or 'statedata' are not supported at the moment." + ) +}) + +test_that("acro_coxph validates the left side of the formula uses Surv() function", { + acro_init() + expect_error( + acro_coxph(time ~ age + ph.karno, data = test_data), + "The left-hand side of the formula must use Surv\\(\\)" + ) +}) + +test_that("acro_coxph works", { + acro_init() + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data) + r_coefs <- stats::coef(r_model) + + # ACRO model + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with subsetting", { + acro_init() + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, subset = age > 50) + r_coefs <- stats::coef(r_model) + + # ACRO model + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, subset = age > 50) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with na.action", { + acro_init() + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, na.action = na.exclude) + r_coefs <- stats::coef(r_model) + + # ACRO model + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, na.action = na.exclude) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with invalid status options(eg. having a status as 4)", { + acro_init() + test_data$status[test_data$status == 1] <- 4 + + expect_error( + acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data), + "In Surv\\(time, status\\) : Invalid status values" + ) +}) + +test_that("acro_coxph with weights as a column from the dataset", { + acro_init() + + # add a weight column in the dataset + test_data$my_weights <- runif(nrow(test_data), 0.8, 1.2) + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = my_weights) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = my_weights) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with weights as a string column name from the dataset", { + acro_init() + + # add a weight column in the dataset + test_data$my_weights <- runif(nrow(test_data), 0.8, 1.2) + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = my_weights) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = "my_weights") + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with weights provided by the user", { + acro_init() + + # define a vector weight + my_weights <- runif(nrow(test_data), 0.8, 1.2) + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = my_weights) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, weights = my_weights) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with cluster as a column in the dataset", { + acro_init() + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = inst) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = inst) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with cluster as a quoted column name in the dataset", { + acro_init() + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = inst) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = "inst") + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph with cluster provided by the user", { + acro_init() + + # define a cluster vector + my_cluster <- sample(1:10, size = nrow(lung), replace = TRUE) + + # R Survival model + r_model <- survival::coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = my_cluster) + r_coefs <- stats::coef(r_model) + + acro_model <- acro_coxph(Surv(time, status) ~ age + ph.karno, data = test_data, cluster = my_cluster) + + # Compare the coefficients + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["age"])), + unname(r_coefs["age"]), + tolerance = 0.02 + ) + expect_equal( + reticulate::py_to_r(unname(acro_model$params_["ph.karno"])), + unname(r_coefs["ph.karno"]), + tolerance = 0.02 + ) +}) + +test_that("acro_coxph warns on unused arguments in ...", { + acro_init() + + expect_warning( + acro_coxph(Surv(time, status) ~ age, data = test_data, dump_arg = 999), + "Unsupported arguments were provided: dump_arg" + ) +})