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:
simdata_fast() generates PFS and OS for all trials from
an illness-death model;cutoff_fast() finds the calendar time of the PFS
analysis in each trial;analysis_fast() analyzes PFS at that time, and the
trials in which PFS is significant are selected;switch_fast() lets the control patients of those trials
switch, changing only their outcomes after the switch;The interim analysis is unaffected by step 4 by construction, which is checked below.
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")]]
)
}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.55091Three scenarios are compared with the trial without switching.
when = "later", the rule of the worked example of the
milestone crossover in TrialSimulator). Their remaining survival time is
multiplied by 1.3.when = "intermediate"), for half of the control patients
who progress, with a post-progression median survival of 18 months
instead of 12.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.
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.9Switching 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.
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.63The 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.0103The 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.
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.
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