---
title: "rpsurv: Fast Royston-Parmar Flexible Parametric Survival Models"
author: "Imad El Badisy"
date: "`r Sys.Date()`"
output:
  pdf_document:
    toc: true
    toc_depth: 3
    number_sections: true
header-includes:
  - \usepackage{amsmath}
vignette: >
  %\VignetteIndexEntry{rpsurv: Fast Royston-Parmar Flexible Parametric Survival Models}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE, comment = "#>", fig.align = "center",
  fig.width = 6, fig.height = 4.2, out.width = "80%"
)
suppressMessages({
  library(survival)
  library(rpsurv)
})
```

# 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

```{r fit-basic}
data(brcancer)
brcancer$hormon <- as.numeric(brcancer$hormon)

fit <- rpsurv(Surv(rectime, censrec) ~ hormon, data = brcancer, df = 4, scale = "hazard")
summary(fit)
```

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.

```{r predict-plot, fig.cap = "Predicted survival by hormonal therapy status."}
plot(fit, newdata = data.frame(hormon = c(0, 1)), col = c("steelblue", "firebrick"),
     main = "rpsurv: predicted survival")
```

# 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`:

```{r tve-fit}
fit_tve <- rpsurv(Surv(rectime, censrec) ~ hormon, data = brcancer,
                   df = 4, tve = "hormon", tve.df = 2)
coef(fit_tve)
```

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:

```{r tve-lrt}
lrt_stat <- 2 * (fit_tve$loglik - fit$loglik)
lrt_df <- fit_tve$df - fit$df
pchisq(lrt_stat, lrt_df, lower.tail = FALSE)
```

## 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)`:

```{r tvc-example}
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
coef(fit_cp)
```

`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

```{r diagnostics, fig.cap = "Cox-Snell residual check: points should lie near the diagonal."}
coxsnell_plot(fit)
```

```{r deviance-resid}
dev_resid <- residuals(fit, type = "deviance")
summary(dev_resid)
```

`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:

```{r km-compare, fig.cap = "Model vs. Kaplan-Meier, by hormonal therapy status."}
km_compare_plot(fit, by = "hormon")
```

# Validation against rstpm2 and flexsurv {#validation}

```{r validation, eval = requireNamespace("rstpm2", quietly = TRUE)}
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)
)
```

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 {#speed}

## 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$.

```{r benchmark-table, echo = FALSE}
res <- readRDS(system.file("extdata", "benchmark_results.rds", package = "rpsurv"))
knitr::kable(res, digits = 3, col.names = c("n", "rpsurv (s)", "stpm2 (s)", "flexsurvspline (s)"))
```

```{r benchmark-fig, echo = FALSE, out.width = "90%", fig.cap = "Fit time vs. sample size (log-log axes)."}
knitr::include_graphics("figures/benchmark_speed.png")
```

At $n=2\times10^5$, `rpsurv` is about
`r round(res$rstpm2[res$n == 2e5] / res$rpsurv[res$n == 2e5], 1)`x 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
`r round(res$flexsurv[res$n == 2e4] / res$rpsurv[res$n == 2e4], 1)`x
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:

```{r benchmark-code, eval = FALSE}
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

```{r session-info}
sessionInfo()
```
