Simulate Trials with Dynamic Treatment Switching

The TrialSimulator package can handle three types of crossover designs, of which dynamic treatment switching is common in clinical trial, but simulating it is usually an unmet need due to its complexity. This vignette demonstrates how to simulate a trial where all or a subset of patients are offered the option of treatment switching over time. This happens in settings such as:

These examples differ clinically, but they share the same simulation challenge: the patient does not follow a single static treatment path from baseline to the end of follow-up. In many cases, switching is not just a change in treatment label. It changes the patient’s future outcome trajectory. For example, switching may alter post-switch survival, response probability, or adverse-event risk, while leaving earlier outcomes unchanged. To address this challenge in simulation, TrialSimulator offers to define a regimen object in which we can implement custom mechanism for crossover.

Note that there are many other types of crossover that cannot be simulated using the approach discussed in this vignette. For example, patients may be reassigned after the arm is dropped. In such a scenario, the availability of alternative regimen depend on analysis result of unblinded data and can only happen at interim. We will show how to do that in other vignette. For crossover design with wash-out periods, please refer to this vignette.

A Common Structure

Most switching designs can be reduced to three questions:

This leads naturally to a two-stage simulation strategy:

  1. generate patient data under the originally assigned arm for all enrolled patients;
  2. apply a patient-level switching mechanism that updates post-switch outcomes.

This is the core idea behind regimen in TrialSimulator.

Why regimen uses three functions?

The regimen interface splits treatment switching into three components:

A single large switching function could do all of this, but it would mix different responsibilities and become difficult to reuse. In practice, these components often vary separately across scenarios. For example, one may keep the same switching population but compare different switching times, or keep selection and timing fixed while testing different post-switch assumptions. The three-function design keeps these choices explicit.

All the three functions take a data frame patient_data as input argument. It is the data generated automatically by calling generator of endpoints for the whole life-cycle of the trial. The package will figure out patients who are eligible for the three functions, and the right timing to call.

The Role of what()

This custom function is responsible for identifying switchers and assigning their new treatment regimen. Some typical rules that we can consider include:

The output of what() consists of two columns patient_id and new_treatment. Only switchers are returned. Any patients with NA as their new_treatment are omitted. Patients not selected for switching simply remain under their original treatment path and outcome trajectory.

The Role of when()

This custom function assigns a switching time to each selected patient. A patient may switch:

Switching time can depend on the full outcome trajectory. For example, patient may have a higher chance of switching (e.g., for rescue) when being closer to the onset of major event (e.g., death). Keeping timing in when() makes scenario analysis much easier. The same switching population can be reused under different timing assumptions.

Note that patients whose data is passed to when() is a subset of patients whose data is passed to what(). The package governs this to prevent logic error and avoid unnecessary computation.

This function returns a data frame of two columns patient_id and switch_time measured from enrollment. All selected patients must be assigned a switching time, otherwise package will throw an error.

The Role of how()

This custom function modifies patient data to reflect the consequences of switching. In practice, switching does not necessarily mean replacing the patient with a whole new patient from another arm. More often, we wants to

So how() is not just a label change. It is a controlled post-switch data update. Depending on the simulation goal, how() could:

This function returns a data frame of patient_id and columns of endpoints that are altered. We do not have to return an endpoint if it is not updated for any patient. For an endpoint that is updated for a subset of switchers, simply set the unchanged cells to NA and the package will ignore them. We can also fill those cells with their original values when being passed to how(). Note that we should never apply dropout or censoring manually in how() because TrialSimulator will handle that automatically when triggering milestones and prepare locked data in action functions.

The three-functions design in regimen provides a useful balance between structure and flexibility. The interface mirrors the way investigators naturally describe switching designs. It encourages modular thinking because each part can be changed independently. Sensitivity analysis becomes straightforward under this framework as we can modify one function at a time. Another benefit is that more than one set of treatment allocator, time selector and data modifier are accepted for simulating multi-stage crossover. It also avoids overloading the arm definitions and supports partial data updates.

This flowchart illustrates how regimen is integrated with other modules in TrialSimulator.

Implement Dynamic Treatment Switching

Dynamic treatment switching can be added to any simulation codes implemented with TrialSimulator. So we only show code snippets in this vignette to illustrate what do the three functions look like in several examples.

In all cases, we need to implement functions to initialize an regimen object

treatment_allocator <- function(patient_data){...}
time_selector <- function(patient_data){...}
data_modifier <- function(patient_data){...}

regimen <- regimen(treatment_allocator, time_selector, data_modifier)

All three functions must have patient_data as argument otherwise TrialSimulator will throw exception. The regime is then registered to a trial before any arm is added to it.

trial <- trial(...)
trial$add_regime(regimen)

Any codes like below will trigger an error message because trial$add_arms(...) enrolls patients into a trial immediately but we need to make regimen ready before then.

trial <- trial(...)
trial$add_arms(sample_ratio, soc, low_dose, high_dose)
trial$add_regime(regimen)
#> Error in trial$add_regimen(regimen) :
#>   Member function trial$add_regimen() must be called before trial$add_arms(). A good practice is to call trial$add_regimen() immediately after trial() is executed.

Example 1: Crossover after progression

In oncology trials, patients initially randomized to standard of care (soc) may be allowed to switch to active treatment after disease progression, conditional on a patient-level event. If we assume 30% of the patients are assigned to low dose while 40% of the patients are assigned to high dose, we can implement the what() function like

treatment_allocator <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  switch_to <- sample(c('low', 'high', 'stay'), nrow(patient_data), 
                        replace = TRUE, prob = c(.3, .4, .3))
  data.frame(
    patient_id = patient_data$patient_id,
    new_treatment =
      dplyr::case_when(
        # patient die before progression cannot switch
        patient_data$os == patient_data$pfs ~ NA_character_, 
        patient_data$arm == 'placebo' & switch_to == 'low' ~ 'low dose',
        patient_data$arm == 'placebo' & switch_to == 'high' ~ 'high dose',
        TRUE ~ NA_character_
      )
  )

}

The when() function simply returned the progression time, i.e., switching does not relies on event after the switching time.

time_selector <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    ## all patient in patient_data progress before die
    ## thus pfs < os and can switch. 
    ## See treatment_allocator()
    switch_time = patient_data$pfs
  )
  
}

Another common way to specify the switching time is picking a time point between pfs and os, i.e.,

time_selector <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    ## all patient in patient_data progress before die
    ## thus pfs < os and can switch. 
    ## See treatment_allocator()
    switch_time = runif(nrow(patient_data), min = patient_data$pfs, max = patient_data$os)
  )
  
}

In the how() function, we extend residual survival time by a factor using the causal accelerated failure time model depending on the assigned dose. Here we only update the overall survival os and leave other endpoints (e.g., response status) or variables unchanged, assuming that we won’t use their values after switching when summarizing the simulation.

data_modifier <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  f <- ifelse(patient_data$new_treatment == 'low dose', 1.1, 1.15)
  data.frame(
    patient_id = patient_data$patient_id,
    ## other_endpoint = ..., 
    os = patient_data$switch_time + f * (patient_data$os - patient_data$switch_time)
  )

}

Example 2: Crossover for non-responsers

Here we consider a trial of a placebo arm and two active treatment arms (low and high dose). Patients in the low dose arm who do not respond are switched to a higher dose.

treatment_allocator <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    new_treatment =
      dplyr::case_when(
        patient_data$arm == 'low dose' & patient_data$response == 0 ~ 'high dose',
        TRUE ~ NA_character_
      )
  )

}

When implementing time selector for the function when(), the switching time is set to the readout time

time_selector <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    ## all patient in patient_data progress before die
    ## thus pfs < os and can switch
    switch_time = patient_data$response_readout
  )
  
}

Example 3: Crossover when condition deteriorates

Now we assume that all patients in the placebo arm can crossover when their condition deteriorate significantly. Specifically, the switching time is 1 month before a patient dies. If the overall survival is shorter than a month, the switching time is set to 0.9 * os.

treatment_allocator <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    new_treatment =
      dplyr::case_when(
        patient_data$arm == 'placebo' ~ 'new treatment',
        TRUE ~ NA_character_
      )
  )

}
time_selector <- function(patient_data){
  ## add break point to develop and debug
  # browser()
  data.frame(
    patient_id = patient_data$patient_id,
    ## all patient in patient_data progress before die
    ## thus pfs < os and can switch
    switch_time = ifelse(patient_data$os <= 1, .9 * patient_data$os, patient_data$os - 1)
  )
  
}

Example 4: Crossover multiple times

We can implement multiple rounds of crossover for each patient. To do this, simply provide regimen() with a list of treatment allocators for what, a list of time selectors for when, and a list of data modifiers for how.

what <- list(allocator1, allocator2, allocator3)
when <- list(selector1, selector2, selector3)
how <- list(modifier1, modifier2, modifier3)

regimen <- regimen(what, when, how)

trial <- trial(...)
trial$add_regimen(regimen)