--- title: "Treatment switching and crossover after an interim analysis" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Treatment switching and crossover after an interim analysis} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(FastSurvival) has_rpsftm <- requireNamespace("rpsftm", quietly = TRUE) && requireNamespace("survival", quietly = TRUE) ``` ## Overview In many oncology trials the control patients may switch to the experimental treatment, either at disease progression or after an interim analysis has shown a benefit on progression-free survival (PFS). Switching leaves the outcomes observed before the switch unchanged and alters only what happens afterward, typically prolonging the remaining overall survival (OS) of the switchers and diluting the intention-to-treat comparison of OS. The TrialSimulator package (Zhang, 2026) expresses such designs with `regimen()` (a switching rule fixed at enrollment) and `crossover()` (a switch that opens at a milestone), and runs the simulated trials one at a time. Because a switch never rewrites the history before it, the same designs can also be simulated with the whole set of trials at once. `switch_fast()` modifies the simulated data after their generation, and `cutoff_fast()` provides the per-trial calendar time at which crossover opens: 1. `simdata_fast()` generates PFS and OS for all trials from an illness-death model; 2. `cutoff_fast()` finds the calendar time of the PFS analysis in each trial; 3. `analysis_fast()` analyzes PFS at that time, and the trials in which PFS is significant are selected; 4. `switch_fast()` lets the control patients of those trials switch, changing only their outcomes after the switch; 5. the OS analysis is run on the modified data. The interim analysis is unaffected by step 4 by construction, which is checked below. ## Design The trial randomizes 300 patients per group over 18 months. Progression, death without progression, and death after progression follow an illness-death model with exponential transition times, and patients drop out at 5 percent per year. PFS is analyzed once, at 380 PFS events. OS is analyzed at 350 deaths. Both tests are one-sided at level 0.025. ```{r design} nsim <- 1000 seed <- 20261007 df <- simdata_fast( nsim = nsim, n = c(300, 300), a.time = c(0, 18), a.rate = 600 / 18, h01.median = list(8, 12), # progression h02.median = list(30, 36), # death without progression h12.median = list(12, 12), # death after progression d.hazard = -log(1 - 0.05) / 12, seed = seed ) # Single-endpoint view of PFS (k = 1) or OS (k = 2) for analysis_fast(). ep <- function(d, k) { data.frame( sim = d$sim, group = d$group, accrual_time = d$accrual_time, tte = d[[paste0("e", k, "_tte")]], event = d[[paste0("e", k, "_event")]] ) } ``` ## PFS analysis and the crossover decision `cutoff_fast()` counts the PFS events (the columns `e1_tte` and `e1_event`) to find the analysis time of each trial, and `analysis_fast()` analyzes PFS at those times. ```{r pfs} pfs_cut <- cutoff_fast(df, event.looks = 380, tte.col = "e1_tte", event.col = "e1_event") pfs_res <- analysis_fast(ep(df, 1), control = 1, cutoff.looks = pfs_cut, side = 1) pfs_pos <- (pfs_res$reached & pfs_res$logrank.p <= 0.025) %in% TRUE c(PFS_power = mean(pfs_pos), mean_PFS_analysis_month = mean(pfs_cut[, 1], na.rm = TRUE)) ``` ## Switching scenarios Three scenarios are compared with the trial without switching. - Crossover after a positive PFS analysis: in the trials with a significant PFS result, control patients who are alive and on study switch at progression, but not before the PFS analysis (`when = "later"`, the rule of the worked example of the milestone crossover in TrialSimulator). Their remaining survival time is multiplied by 1.3. - The same crossover opened in every trial, regardless of the PFS result. - Switching at progression from the start of the trial (`when = "intermediate"`), for half of the control patients who progress, with a post-progression median survival of 18 months instead of 12. ```{r switching} sw_gated <- switch_fast(df, group = 1, when = "later", cutoff = pfs_cut, sims = pfs_pos, aft.factor = 1.3) sw_all <- switch_fast(df, group = 1, when = "later", cutoff = pfs_cut, aft.factor = 1.3) sw_prog <- switch_fast(df, group = 1, prob = 0.5, when = "intermediate", median = 18) ``` The OS analysis at the time of the PFS analysis is the same with and without switching, because every switch happens after that time. ```{r invariance} os_ia0 <- analysis_fast(ep(df, 2), control = 1, cutoff.looks = pfs_cut, side = 1) os_ia1 <- analysis_fast(ep(sw_gated, 2), control = 1, cutoff.looks = pfs_cut, side = 1) all.equal(os_ia0, os_ia1) ``` ## Overall survival at the final analysis The OS analysis is triggered by 350 deaths in the modified data, since switching delays deaths and therefore the analysis. ```{r os-final} os_final <- function(d) { cut <- cutoff_fast(d, event.looks = 350, tte.col = "e2_tte", event.col = "e2_event") analysis_fast(ep(d, 2), control = 1, cutoff.looks = cut, stat = c("logrank", "coxph"), side = 1) } scen <- list( "no switching" = df, "crossover after positive PFS" = sw_gated, "crossover after PFS, all trials" = sw_all, "50% switch at progression" = sw_prog ) tab <- do.call(rbind, lapply(names(scen), function(nm) { d <- scen[[nm]] r <- os_final(d) data.frame( scenario = nm, control_switched = round(mean(d$switched[d$group == 1] == 1), 3), OS_power = round(mean(r$logrank.p <= 0.025, na.rm = TRUE), 3), mean_HR = round(exp(mean(r$cox.coef, na.rm = TRUE)), 3), mean_OS_month = round(mean(r$cutoff, na.rm = TRUE), 1) ) })) tab ``` Switching pulls the intention-to-treat hazard ratio for OS toward one and lowers the power of the OS test. The two crossover scenarios differ only in the trials where PFS was not significant, in which crossover is opened in the second scenario but not in the first, so opening crossover in every trial dilutes the OS comparison more. ## Computing time The whole study, from data generation to the OS analysis of one scenario, runs in a few seconds. ```{r timing} system.time({ d0 <- simdata_fast(nsim = nsim, n = c(300, 300), a.time = c(0, 18), a.rate = 600 / 18, h01.median = list(8, 12), h02.median = list(30, 36), h12.median = list(12, 12), d.hazard = -log(1 - 0.05) / 12, seed = seed) pc <- cutoff_fast(d0, event.looks = 380, tte.col = "e1_tte", event.col = "e1_event") pr <- analysis_fast(ep(d0, 1), control = 1, cutoff.looks = pc, side = 1) pos <- (pr$reached & pr$logrank.p <= 0.025) %in% TRUE d1 <- switch_fast(d0, group = 1, when = "later", cutoff = pc, sims = pos, aft.factor = 1.3) r1 <- os_final(d1) }) ``` ## A check with the rank-preserving structural failure time model The acceleration factor of `switch_fast()` is the causal model of the rank-preserving structural failure time (RPSFT) method of Robins and Tsiatis (1991). If the experimental treatment multiplies the time spent on it by a factor `f`, both from randomization in the experimental group and after the switch in the control group, the RPSFT estimate of `psi = -log(f)` should be close to the true value. The check below simulates 10 large trials in which the experimental group has all transition hazards divided by `f` (an accelerated failure time effect of `f`), lets 60 percent of the control patients who progress switch with the same factor, and estimates `psi` in each trial with the rpsftm package (Allison, White, and Bond, 2017), ignoring censoring. ```{r rpsft, eval = has_rpsftm} f <- 1.5 h01 <- log(2) / 8 h02 <- log(2) / 30 h12 <- log(2) / 12 n_rep <- 10 big <- simdata_fast( nsim = n_rep, n = c(2000, 2000), a.time = c(0, 12), a.rate = 4000 / 12, h01.hazard = list(h01, h01 / f), h02.hazard = list(h02, h02 / f), h12.hazard = list(h12, h12 / f), seed = 7 ) big <- switch_fast(big, group = 1, prob = 0.6, when = "intermediate", aft.factor = f, seed = 8) suppressWarnings(suppressPackageStartupMessages({ library(survival) library(rpsftm) })) psi_hat <- vapply(seq_len(n_rep), function(s) { one <- big[big$sim == s, ] rp_dat <- data.frame( time = one$e2_surv_time, status = 1, arm = one$group - 1, cens = 1e6 ) # Proportion of the observed time spent on the experimental treatment. rp_dat$rx <- ifelse(rp_dat$arm == 1, 1, ifelse(one$switched == 1, (one$e2_surv_time - one$switch_time) / one$e2_surv_time, 0)) fit <- tryCatch( rpsftm(Surv(time, status) ~ rand(arm, rx), data = rp_dat, censor_time = cens), error = function(e) NULL ) if (is.null(fit)) NA_real_ else unname(fit$psi) }, numeric(1)) round(c(true_psi = -log(f), mean_estimate = mean(psi_hat, na.rm = TRUE), sd_estimate = sd(psi_hat, na.rm = TRUE), se_of_mean = sd(psi_hat, na.rm = TRUE) / sqrt(sum(!is.na(psi_hat)))), 4) ``` The mean of the estimates is close to the true value relative to its standard error, so the RPSFT method recovers the acceleration factor used to generate the switched survival times. ## Remarks `switch_fast()` covers switching rules whose timing is a function of the patient's own outcomes (progression), of an opening time per trial, or of both, with an effect on the remaining time to the terminal event given by an acceleration factor or by a new hazard. Designs in which an interim decision changes the enrollment or the randomization itself, such as dropping an arm and re-randomizing new patients, change the data-generating process and are outside this approach. ## References Allison, A., White, I. R., & Bond, S. (2017). rpsftm: an R package for rank preserving structural failure time models. *The R Journal*, 9(2), 342-353. Robins, J. M. and Tsiatis, A. A. (1991). Correcting for non-compliance in randomized trials using rank preserving structural failure time models. *Communications in Statistics - Theory and Methods*, 20(8), 2609-2631. Zhang, H. (2026). TrialSimulator: Clinical Trial Simulator. R package version 1.35.8. https://CRAN.R-project.org/package=TrialSimulator