--- title: "Using coxdev" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Using coxdev} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4) ``` `coxdev` computes the deviance of the Cox proportional hazards model, its gradient and the diagonal of its Hessian with respect to the linear predictor, the information as a matrix-free operator, the score residuals, the baseline hazard, and draws of the score. This vignette fits a model by Newton's method, computes robust standard errors and survival curves, and shows what keeping the sorted data in a `CoxDeviance` object saves. Sampling the score has its own vignette, `vignette("sampling", package = "coxdev")`. The Python package has the same two, as the notebooks `doc/using_coxdev.md` and `doc/sampling.md`. ```{r} library(coxdev) library(survival) ``` ## A survival response Survival times on a coarse grid, so that there are many ties, with delayed entry and censoring. ```{r} set.seed(1) n <- 500 p <- 3 X <- matrix(rnorm(n * p), n, p, dimnames = list(NULL, c("x1", "x2", "x3"))) beta <- c(0.5, -0.5, 0.25) time <- rexp(n, exp(drop(X %*% beta))) cens <- rexp(n, 0.5) stop <- ceiling(pmin(time, cens) * 4) / 4 status <- as.integer(time <= cens) start <- floor(runif(n, 0, 0.5) * stop * 8) / 8 y <- Surv(start, stop, status) cox <- CoxDeviance$new(y, tie_breaking = "efron") cox ``` The response can also be given as vectors, as in the Python package: `CoxDeviance$new(event = stop, status = status, start = start)`. ## Deviance, gradient and information `compute()` returns the deviance and its derivatives with respect to the linear predictor `eta`. `information()` returns the information (half the Hessian of the deviance) as an `n` by `n` operator. It is never formed: a product with it costs about as much as a gradient. For a design `X`, `crossprod(X, I %*% X)` is the information of the coefficients, so Newton's method takes a few lines. ```{r} b <- rep(0, p) for (iter in 1:10) { eta <- drop(X %*% b) res <- cox$compute(eta) score <- -drop(crossprod(X, res$gradient)) / 2 info <- crossprod(X, cox$information(eta) %*% X) step <- solve(info, score) b <- b + step if (max(abs(step)) < 1e-10) break } iter b ``` These are the estimates of `coxph` from `survival`, with the same standard errors: ```{r} fit <- coxph(y ~ X, ties = "efron") rbind(coxdev = b, coxph = coef(fit)) rbind(coxdev = sqrt(diag(solve(info))), coxph = sqrt(diag(vcov(fit)))) ``` ## Robust standard errors and the baseline hazard The score residuals of the design give the robust (sandwich) variance, `solve(I) %*% crossprod(U) %*% solve(I)`: ```{r} eta_hat <- drop(X %*% b) U <- cox$score_residuals(eta_hat, X) info_hat <- crossprod(X, cox$information(eta_hat) %*% X) robust <- solve(info_hat, t(solve(info_hat, crossprod(U)))) fit_robust <- coxph(y ~ X, ties = "efron", id = seq_len(n), robust = TRUE) rbind(coxdev = sqrt(diag(robust)), coxph = sqrt(diag(vcov(fit_robust)))) ``` `baseline_hazard()` gives Efron's estimate of the cumulative baseline hazard (Breslow's with `tie_breaking = "breslow"`), the `basehaz(fit, centered = FALSE)` of `coxph`. The survival curve of a subject with covariates `x0` is `exp(-cumulative_hazard * exp(sum(x0 * b)))`: ```{r} H <- cox$baseline_hazard(eta_hat) head(H) ``` ```{r, include = FALSE} # survival::basehaz() fails on some R-devel builds (October 2026, in # stats::delete.response(), as in survival's own CRAN check): the comparison # below is then left out. TODO: remove once survival's R-devel checks pass. basehaz_works <- !inherits(try(basehaz(fit, centered = FALSE), silent = TRUE), "try-error") ``` ```{r, eval = basehaz_works} bh <- basehaz(fit, centered = FALSE) all.equal(H$cumulative_hazard, bh$hazard[match(H$time, bh$time)]) ``` ```{r, fig.alt = "Estimated survival curves for x1 = -1, 0 and 1, with x2 = x3 = 0."} plot(NULL, xlim = c(0, max(H$time)), ylim = c(0, 1), xlab = "time", ylab = "survival") for (x1 in c(-1, 0, 1)) { surv <- exp(-H$cumulative_hazard * exp(b[1] * x1)) lines(c(0, H$time), c(1, surv), type = "s", lty = x1 + 2) } legend("topright", paste("x1 =", c(-1, 0, 1)), lty = 1:3, bty = "n") ``` ## What keeping state saves Creating a `CoxDeviance` sorts the data, once. Each `compute()` after that is linear in `n`, and repeating a call with the same linear predictor and weights returns the cached result. A stateless function has to sort on every call. That cost is paid at every step of an optimizer, and at every product with the information. We time 50 evaluations of the deviance and gradient at 100,000 observations, each at a different linear predictor. ```{r timing} set.seed(2) N <- 1e5 XN <- matrix(rnorm(N * p), N, p) etaN <- drop(XN %*% beta) stopN <- ceiling(rexp(N, exp(etaN)) * 20) / 20 statusN <- rbinom(N, 1, 0.7) startN <- floor(runif(N, 0, 0.5) * stopN * 40) / 40 yN <- Surv(startN, stopN, statusN) etas <- lapply(1:50, function(i) etaN + rnorm(N, sd = 0.1)) per_call <- function(expr) { t <- system.time(expr)[["elapsed"]] sprintf("%.1f ms", 1000 * t / 50) } setup <- system.time(coxN <- CoxDeviance$new(yN))[["elapsed"]] timings <- c( "one object, reused" = per_call(for (e in etas) coxN$compute(e)), "a new object for each call" = per_call(for (e in etas) CoxDeviance$new(yN)$compute(e)), "one object, same eta again (cached)" = per_call(for (e in etas) coxN$compute(etas[[1]])), "one information product" = per_call({ I <- coxN$information(etas[[1]]) for (e in etas) I %*% XN[, 1] }) ) knitr::kable(data.frame(time = timings), caption = sprintf( "Time per call, n = %d. Creating the object (the sort) took %.0f ms.", N, 1000 * setup)) ``` The times depend on the machine, but the pattern is stable. Reusing the object avoids the sort, which costs several times as much as an evaluation. The information operator is what makes the Hessian usable at this size: as a dense matrix it would take `r format(8 * N^2 / 2^30, digits = 3)` GB.