knitr::opts_chunk$set( collapse = TRUE, cache.path = 'cache/dynamicTreatmentSwitching/', comment = '#>', dpi = 300, out.width = '100%' )
library(TrialSimulator) library(mvtnorm) library(kableExtra)
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.
Most switching designs can be reduced to three questions:
This leads naturally to a two-stage simulation strategy:
This is the core idea behind regimen in TrialSimulator.
regimen uses three functions?The regimen interface splits treatment switching into three
components:
what(), a treatment allocator for switcherswhen(), a time selector for switchinghow(), a data modifier altering post-switch outcome trajectoryA 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.
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, with one row per switcher. Patients not included simply
remain under their original treatment path and outcome trajectory.
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.
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 only a
subset of switchers, return the unchanged cells at their original value --
the natural ifelse(condition, new_value, original) pattern. 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.
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)
msg <- tryCatch( { stop(' 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. ') NULL }, error = function(e) { cat('Error in trial$add_regimen(regimen) :\n', e$message) } )
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)) ## placebo patients who progressed before death (pfs < os) and were ## assigned a dose are the switchers; the rest are simply not returned is_switcher <- patient_data$arm == 'placebo' & patient_data$pfs < patient_data$os & switch_to != 'stay' sw <- patient_data[is_switcher, ] data.frame( patient_id = sw$patient_id, new_treatment = ifelse(switch_to[is_switcher] == 'low', 'low dose', 'high dose') ) }
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) ) }
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() ## low-dose non-responders switch to high dose sw <- patient_data[patient_data$arm == 'low dose' & patient_data$response == 0, ] data.frame( patient_id = sw$patient_id, new_treatment = 'high dose' ) }
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 ) }
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() ## all placebo patients switch sw <- patient_data[patient_data$arm == 'placebo', ] data.frame( patient_id = sw$patient_id, new_treatment = 'new treatment' ) }
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) ) }
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)
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.