Treatment switching and crossover after an interim analysis

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.

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.

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))
#>               PFS_power mean_PFS_analysis_month 
#>                 0.93900                21.55091

Switching scenarios

Three scenarios are compared with the trial without 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.

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)
#> [1] TRUE

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.

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
#>                          scenario control_switched OS_power mean_HR
#> 1                    no switching            0.000    0.399   0.834
#> 2    crossover after positive PFS            0.516    0.141   0.913
#> 3 crossover after PFS, all trials            0.550    0.134   0.918
#> 4       50% switch at progression            0.380    0.082   0.950
#>   mean_OS_month
#> 1          34.4
#> 2          35.6
#> 3          35.7
#> 4          35.9

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.

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)
})
#>    user  system elapsed 
#>    0.57    0.03    0.63

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.

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)
#>      true_psi mean_estimate   sd_estimate    se_of_mean 
#>       -0.4055       -0.3929        0.0326        0.0103

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

mirror server hosted at Truenetwork, Russian Federation.