rpsurv: Fast Royston-Parmar Flexible Parametric Survival Models

Introduction

rpsurv fits the Royston & Parmar (2002) family of flexible parametric survival models: the baseline distribution is represented by a restricted cubic spline in \(\log t\) on a chosen transformed scale (proportional hazards, proportional odds, or probit), which lets a smooth, monotone hazard/survival shape be estimated without committing to a Weibull, log-normal, or other single parametric family, while still producing a fully parametric, smooth, extrapolable fit (unlike the Cox model).

rpsurv re-implements this model with the log-likelihood and its analytic gradient evaluated in C++ via RcppParallel, so that fitting scales to hundreds of thousands of rows in about a second (see the Speed benchmark section). Coefficients, standard errors, and log-likelihoods are validated against rstpm2::stpm2 and flexsurv::flexsurvspline (the Validation section).

This vignette covers:

  • the spline basis and the likelihood/gradient for all three scales,
  • the fitting API and coxph-style output,
  • prediction and plotting helpers,
  • the distinction between a time-varying effect and a time-varying covariate, and how each is fit,
  • residual diagnostics,
  • the speed benchmark against rstpm2 and flexsurv, and what is delegated to C++ to get there.

The Royston-Parmar model

Spline basis

Let \(x = \log t\). A restricted (natural) cubic spline with boundary knots \(k_{\min}, k_{\max}\) and interior knots \(k_1, \dots, k_m\) is written, in the Durrleman & Simon / Royston & Parmar parameterisation, as

\[ s(x) = \gamma_0 + \gamma_1 x + \sum_{j=1}^{m} \gamma_{1+j}\, v_j(x), \]

where the linear term keeps the domain \(x \in \mathbb{R}\) and each nonlinear basis term is

\[ v_j(x) = (x - k_j)_+^3 - \lambda_j (x - k_{\min})_+^3 - (1-\lambda_j)(x - k_{\max})_+^3, \qquad \lambda_j = \frac{k_{\max} - k_j}{k_{\max} - k_{\min}}, \]

with \((u)_+ = \max(u, 0)\). This is exactly the basis in rcs_basis() (R/splines.R); by construction \(s(x)\) is linear beyond the boundary knots, which keeps extrapolation well-behaved. Its derivative, needed for the hazard, is

\[ v_j'(x) = 3(x-k_j)_+^2 - 3\lambda_j (x-k_{\min})_+^2 - 3(1-\lambda_j)(x-k_{\max})_+^2 . \]

Boundary knots default to the min/max of \(\log t\) among events, and interior knots to equally spaced centiles of \(\log t\) among events (default_knots()), matching rstpm2/flexsurv defaults.

Linear predictor and scales

Covariates enter proportionally on the modelled scale:

\[ \eta(t \mid x) = s(\log t) + \beta^\top x . \]

rpsurv supports three transformations \(g\) of the survival function, selected via scale:

scale \(g(S)=\eta\) \(S(\eta)\) Interpretation of \(\exp(\beta)\)
"hazard" \(\log(-\log S)\) \(\exp(-\exp(\eta))\) hazard ratio (PH)
"odds" \(\log\{(1-S)/S\}\) \(1/(1+\exp(\eta))\) odds ratio (PO)
"normal" \(\Phi^{-1}(1-S)\) \(1-\Phi(\eta)\) probit index

The hazard follows from differentiating \(S(t)=S(\eta(t))\) with respect to \(t\) via the chain rule through \(x=\log t\):

\[ h(t) = \frac{f(t)}{S(t)} = -\frac{d\eta}{d\log t}\cdot\frac{1}{t}\cdot\frac{S'(\eta)}{S(\eta)} . \]

Writing \(g(\eta) = \log h(t) - \log\!\big(\tfrac{d\eta}{d\log t}\big) + \log t\) (a function of \(\eta\) alone, on each scale), the three cases reduce to closed forms:

\[ g(\eta) = \begin{cases} \eta & \text{hazard scale} \\ \eta - \log(1+e^{\eta}) & \text{odds scale} \\ \log\phi(\eta) - \log\{1-\Phi(\eta)\} & \text{probit scale} \end{cases} \]

so that \(\log h(t) = \log\!\big(\tfrac{d\eta}{d\log t}\big) - \log t + g(\eta)\). This is exactly scale_terms() in src/rpsurv_loglik.cpp.

Log-likelihood

For a right-censored observation with event time \(t_i\), status \(d_i\), and (for left-truncated / counting-process data) entry time \(e_i\), the contribution is

\[ \ell_i(\beta) = d_i \log h(t_i) + \log S(t_i) - \log S(e_i), \]

with \(\log S(e_i) \equiv 0\) when \(e_i = 0\) (no truncation). Summing gives the full log-likelihood \(\ell(\beta) = \sum_i \ell_i(\beta)\), maximised by stats::optim(method = "BFGS") using the analytic gradient below.

Analytic gradient

Because \(\eta\) and \(\tfrac{d\eta}{d\log t}\) are both linear in \(\beta\) (through the design matrices \(X\) and \(\dot X = dX/d\log t\)), the gradient of \(\ell_i\) with respect to \(\beta_k\) is

\[ \frac{\partial \ell_i}{\partial \beta_k} = d_i\left[\frac{\dot X_{ik}}{\dot\eta_i} + g'(\eta_i) X_{ik}\right] + \frac{d\log S}{d\eta}(\eta_i)\, X_{ik} - \mathbb{1}[e_i>0]\,\frac{d\log S}{d\eta}(\eta_{e_i})\, X^{(e)}_{ik}, \]

where \(X^{(e)}\) is the design row evaluated at \(\log e_i\). The scale-specific pieces \(g'(\eta)\) and \(d\log S/d\eta\) are, again, closed form (hazard: \(g'=1\), \(d\log S/d\eta = -e^\eta\); odds: \(g'=S\), \(d\log S/d\eta=-(1-S)\); probit: \(g'=-\eta+\phi(\eta)/S\), \(d\log S/d\eta=-\phi(\eta)/S\)). Evaluating \(\ell\) and \(\nabla\ell\) this way, rather than by finite differences, avoids the \(O(p)\) extra likelihood evaluations per optimiser step that a numerical gradient would cost, which is the main source of rpsurv’s speed advantage together with parallel evaluation across observations (RcppParallel::parallelReduce, src/rpsurv_loglik.cpp).

An observation with \(d_i=1\) and \(\dot\eta_i \le 0\) (a locally decreasing, i.e. invalid, hazard) is given a large penalty rather than NaN, likewise any row where \(S(e_i) < S(t_i)\) (non-monotone survival across a truncation interval); both keep the optimiser inside the region where the model is a valid survival distribution.

Fitting API and output

data(brcancer)
brcancer$hormon <- as.numeric(brcancer$hormon)

fit <- rpsurv(Surv(rectime, censrec) ~ hormon, data = brcancer, df = 4, scale = "hazard")
summary(fit)
#> Royston-Parmar flexible parametric survival model
#> Call:
#> rpsurv(formula = Surv(rectime, censrec) ~ hormon, data = brcancer, 
#>     df = 4, scale = "hazard")
#> 
#> Covariate effects (hazard scale):
#>           coef exp(coef) [HR] se(coef)      z Pr(>|z|)   
#> hormon -0.3641         0.6948   0.1249 -2.914  0.00356 **
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Baseline spline terms:
#>                   coef  exp(coef)   se(coef)      z Pr(>|z|)
#> (Intercept) -1.960e+01  3.086e-09  3.257e+00 -6.016 1.79e-09
#> s(logt)1     2.993e+00  1.994e+01  5.966e-01  5.016 5.26e-07
#> s(logt)2    -4.429e-01  6.422e-01  5.711e-01 -0.776   0.4380
#> s(logt)3     1.357e+00  3.884e+00  8.188e-01  1.657   0.0974
#> s(logt)4    -8.841e-01  4.131e-01  4.176e-01 -2.117   0.0342
#> 
#> n = 686 , number of events = 299 , parameters = 6 
#> Log-likelihood = -2606   AIC = 5225   BIC = 5247

The summary separates interpretable covariate effects (with hazard/odds ratios and Wald tests) from the baseline spline coefficients, which are nuisance parameters describing the shape of \(h_0(t)\) and are not individually interpretable.

Prediction and plots

predict() returns survival, cumulative hazard, hazard, or the linear predictor, optionally with delta-method confidence limits; plot() wraps it for quick visualisation.

plot(fit, newdata = data.frame(hormon = c(0, 1)), col = c("steelblue", "firebrick"),
     main = "rpsurv: predicted survival")
Predicted survival by hormonal therapy status.

Predicted survival by hormonal therapy status.

Time-varying effect vs. time-varying covariate

These are two different extensions of the base model, and rpsurv supports both, separately or together.

Time-varying effect (non-proportional hazards)

A covariate’s value is fixed for a subject, but its coefficient \(\beta(t)\) is allowed to change over follow-up: instead of \(\beta x\), the linear predictor gets \(x \cdot s_{\text{tve}}(\log t)\), i.e. its own spline in log time multiplying the covariate. Request this with tve:

fit_tve <- rpsurv(Surv(rectime, censrec) ~ hormon, data = brcancer,
                   df = 4, tve = "hormon", tve.df = 2)
coef(fit_tve)
#>     (Intercept)        s(logt)1        s(logt)2        s(logt)3        s(logt)4 
#>    -19.19777720      2.93098413     -0.42391914      1.32597578     -0.87854493 
#>          hormon hormon:s(logt)1 hormon:s(logt)2 
#>     -2.34822990      0.32187562      0.03601483

The hormon:s(logt)* coefficients trace out how the hormonal-therapy hazard ratio evolves with time; a likelihood-ratio test against fit (both fitted by maximum likelihood, nested models) tests proportionality:

lrt_stat <- 2 * (fit_tve$loglik - fit$loglik)
lrt_df <- fit_tve$df - fit$df
pchisq(lrt_stat, lrt_df, lower.tail = FALSE)
#> [1] 0.8122301

Time-varying covariate

Here the covariate’s value itself changes during follow-up (e.g. a biomarker updated at clinic visits). This has nothing to do with tve; it is a data-representation question. Split each subject’s follow-up into consecutive intervals over which the covariate is constant, and supply counting-process data via Surv(start, stop, status):

d <- brcancer
d$id <- seq_len(nrow(d))
half <- d$rectime / 2
first  <- data.frame(id = d$id, start = 0, stop = half, status = 0,
                      hormon = d$hormon, x1 = d$x1)
second <- data.frame(id = d$id, start = half, stop = d$rectime, status = d$censrec,
                      hormon = d$hormon, x1 = d$x1 + 1)  # covariate value changes
long <- rbind(first, second)
long <- long[long$start < long$stop, ]

fit_cp <- rpsurv(Surv(start, stop, status) ~ hormon + x1, data = long, df = 4)
fit_cp$counting
#> [1] TRUE
coef(fit_cp)
#>   (Intercept)      s(logt)1      s(logt)2      s(logt)3      s(logt)4 
#> -19.826159484   2.991760891  -0.438400286   1.348304243  -0.878671736 
#>        hormon            x1 
#>  -0.385157266   0.004545335

rpsurv detects the 3-column Surv object and fits the left-truncated likelihood of Section 2 automatically: each interval contributes \(\log S(\text{stop}) - \log S(\text{start})\), correctly accounting for the subject having already survived to start under the covariate value(s) of the previous interval. The same mechanism (Surv(start, stop, status)) also handles ordinary left truncation (delayed entry) when the covariate values do not change.

Diagnostics

coxsnell_plot(fit)
Cox-Snell residual check: points should lie near the diagonal.

Cox-Snell residual check: points should lie near the diagonal.

dev_resid <- residuals(fit, type = "deviance")
summary(dev_resid)
#>     Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
#> -1.51819 -1.07107 -0.68466 -0.03882  0.98077  3.40559

km_compare_plot() overlays the model-implied survival curve on the nonparametric Kaplan-Meier estimate, by strata of a covariate: a direct visual check of whether the chosen spline df and scale capture the data:

km_compare_plot(fit, by = "hormon")
Model vs. Kaplan-Meier, by hormonal therapy status.

Model vs. Kaplan-Meier, by hormonal therapy status.

Validation against rstpm2 and flexsurv

suppressPackageStartupMessages(library(rstpm2))
ref <- stpm2(Surv(rectime, censrec) ~ hormon, data = brcancer, df = 4)
data.frame(
  term = c("hormon", "logLik"),
  rpsurv = c(coef(fit)["hormon"], fit$loglik),
  stpm2  = c(coef(ref)["hormon"], -ref@min)
)
#>          term        rpsurv         stpm2
#> hormon hormon    -0.3640642    -0.3640574
#>        logLik -2606.4716870 -2606.4716869

Coefficients, standard errors, and log-likelihoods agree with rstpm2 to 4-5 decimal places across all three scales (hazard, odds, normal) and with flexsurv::flexsurvspline on the hazard scale; see tests/testthat/test-rpsurv.R for the full parity suite, which also covers left truncation.

Speed benchmark

What is delegated to C++

Everything on the hot path of fitting is C++, not just the likelihood sum:

  • the log-likelihood and its analytic gradient are computed in a single parallel pass (RcppParallel::parallelReduce) over observations, rather than two separate passes;
  • optim(method = "BFGS") calls the objective (fn) and the gradient (gr) separately at every trial point, which would otherwise mean two full data passes per iteration for the same point; rpsurv caches the fused C++ result keyed on the parameter vector, so each distinct point is evaluated exactly once;
  • the restricted cubic spline basis itself (rcs_basis(), both the basis and its derivative) is computed by rcs_basis_cpp() in C++ rather than with R’s vectorised-but-still-interpreted arithmetic;
  • the closed-form starting values (rp_start_values()) are computed from a bounded subsample (default 20,000 rows) of the Kaplan-Meier curve; they only need to be in the right neighbourhood for BFGS, so this avoids an \(O(n\log n)\) sort of the full data purely for initialisation.

What remains in R (model.frame()/model.matrix() formula parsing, one cbind() to assemble the design matrix) runs once per fit, not once per optimiser iteration, and is a small fraction of total time at the sample sizes below.

Benchmark

The figure and table compare wall-clock fit time against both canonical flexible parametric survival packages, rstpm2::stpm2 and flexsurv::flexsurvspline, on simulated Weibull-hazard data with two covariates (reproducible via data-raw/benchmark.R, shipped with the package source). flexsurv::flexsurvspline is materially slower per fit, so it is only run up to \(n=2\times10^4\) to keep the benchmark tractable; rstpm2::stpm2 is run up to \(n=2\times10^5\).

n rpsurv (s) stpm2 (s) flexsurvspline (s)
1e+03 0.031 0.017 0.121
5e+03 0.037 0.058 0.636
2e+04 0.080 0.238 2.330
5e+04 0.165 1.290 NA
1e+05 0.247 2.410 NA
2e+05 0.413 4.912 NA
5e+05 1.182 NA NA
Fit time vs. sample size (log-log axes).

Fit time vs. sample size (log-log axes).

At \(n=2\times10^5\), rpsurv is about 11.9x faster than rstpm2::stpm2, and continues to scale to \(n=5\times10^5\) (rstpm2 was not run at that size). Against flexsurv::flexsurvspline, at \(n=2\times10^4\) rpsurv is about 29.1x faster. The advantage widens with \(n\) in both comparisons, consistent with rpsurv doing \(O(n)\) work per optimiser step against a single parallel pass, versus repeated R-level likelihood evaluation (numerical gradients, or gradients built from R-level matrix algebra) in the other two packages. The benchmark code itself:

simulate_data <- function(n, seed = 1) {
  set.seed(seed)
  x1 <- rnorm(n); x2 <- rbinom(n, 1, 0.5)
  lp <- 0.5 * x1 - 0.3 * x2
  u <- runif(n)
  event_time <- (-log(u) / (0.01 * exp(lp)))^(1 / 1.2)
  cens_time <- rexp(n, 0.008)
  data.frame(time = pmin(event_time, cens_time),
             status = as.numeric(event_time <= cens_time), x1 = x1, x2 = x2)
}
d <- simulate_data(200000)
system.time(rpsurv(Surv(time, status) ~ x1 + x2, data = d, df = 4))
system.time(stpm2(Surv(time, status) ~ x1 + x2, data = d, df = 4))
system.time(flexsurvspline(Surv(time, status) ~ x1 + x2, data = d, k = 3))

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] splines   stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#> [1] rstpm2_1.7.1    rpsurv_0.8.0    survival_3.8-12
#> 
#> loaded via a namespace (and not attached):
#>  [1] nlme_3.1-171        cli_3.6.6           knitr_1.52         
#>  [4] rlang_1.3.0         xfun_0.61           otel_0.2.0         
#>  [7] jsonlite_2.0.0      RcppParallel_6.2.1  buildtools_1.0.0   
#> [10] htmltools_0.5.9     maketools_1.3.2     sys_3.4.3          
#> [13] stats4_4.6.1        sass_0.4.10         rmarkdown_2.32     
#> [16] grid_4.6.1          evaluate_1.0.5      jquerylib_0.1.4    
#> [19] MASS_7.3-66         fastmap_1.2.0       mvtnorm_1.4-2      
#> [22] numDeriv_2016.8-1.1 yaml_2.3.12         lifecycle_1.0.5    
#> [25] compiler_4.6.1      bbmle_1.0.26        Rcpp_1.1.2         
#> [28] mgcv_1.9-4          lattice_0.23-1      digest_0.6.39      
#> [31] R6_2.6.1            bslib_0.12.0        Matrix_1.7-6       
#> [34] tools_4.6.1         bdsmatrix_1.3-7     cachem_1.1.0