knitr::opts_chunk$set( collapse = TRUE, cache.path = 'cache/crossoverAtMilestone/', comment = '#>', dpi = 300, out.width = '100%' )
library(TrialSimulator)
The dynamic treatment switching vignette describes crossover that is decided at enrollment: every patient's switching rule is fixed when they enter the trial, off their own (fully simulated) outcome trajectory. That covers crossover-at- progression, rescue medication, and similar patient-level designs.
This vignette covers a different situation: crossover that becomes available only at, or after, a milestone, and is therefore a decision the trial makes once it reaches that milestone. Typical examples include:
The defining feature is that switching is anchored to a calendar time
that is not known until the trial runs (it may be event-driven), and it
applies only to patients who are still in the trial when that time is
reached. Already-observed outcomes must not change; only the future may.
TrialSimulator supports this through the member function
trial$crossover().
crossover() reuses the same what() / when() / how() interface as
regimen(), so this vignette assumes familiarity with the three-function
design described in the
dynamic treatment switching
vignette. Here we focus on what is specific to a milestone crossover: the
opening time, how the eligible population is determined, and how the
package validates the triplet you supply.
crossover() is calledUnlike add_regimen(), which must be registered before add_arms()
and is applied at enrollment, crossover() is meant to be called
inside the action function of a milestone:
action <- function(trial){ locked_data <- trial$get_locked_data('interim') ## ... interim analysis / decision making ... trial$crossover(what = what_fn, how = how_fn) } interim <- milestone(name = 'interim', when = eventNumber(endpoint = 'pfs', n = 300), action = action)
You do not compute the crossover time yourself. crossover() reads the
trial's current time internally and sets the earliest crossover time T
for you (see the next section) -- there is no need to call
trial$get_current_time() or to pass any time.
For context: when a milestone is triggered, TrialSimulator advances the
trial's current time to that milestone's (calendar) trigger time and
automatically stores a locked snapshot of the data as of that time. This
storing happens whether or not the action calls get_locked_data(),
which is why a later milestone's action can still retrieve a snapshot
taken at an earlier milestone. get_locked_data() does not edit the
evolving trial data; it only returns a time-cut view of it. Inside an
action, trial$get_current_time() therefore returns the current
milestone's time -- the same value crossover() uses as the basis for
T. (Calling crossover() before any milestone has fired, when the
current time is still 0, is rejected with an informative error.)
The user-friendly wrapper crossover(trial, ...) is also available for
those who prefer not to call member functions directly:
crossover(trial, what = what_fn, how = how_fn)
delay and the opening timeThe only milestone-specific argument is delay. From it, crossover()
computes the earliest crossover time -- the calendar time from which
switching may take effect:
T = (current milestone time) + delay
You set only delay; the milestone time is supplied automatically.
delay = 0 (default): crossover opens at the milestone. This is the
common "let eligible patients switch now" case.delay > 0: crossover opens a fixed amount of time after the
milestone. This is convenient for an administrative ramp-up or a
wash-out window before switching becomes effective.T is the reference time for everything that follows: it determines who
is eligible, it is the floor for the switching time, and it is the
boundary that separates already-observed outcomes (which are protected)
from future outcomes (which how() may modify).
Note that T is the earliest time a switch may take effect, not
necessarily when it does. Under the default timing every selected patient
switches exactly at T, but a when() you supply may place each
patient's switch at any time at or after T -- for instance at a
progression that occurs after the opening.
A milestone crossover only makes sense for patients who still have
something left to change. Before calling what(), the package therefore
restricts the candidate pool to patients who are still in the trial at
T, i.e. patients with at least one endpoint whose value has not yet
been settled by T. Concretely, for each patient the package checks
whether any endpoint is still open, using the reference time
ref = max(T, enroll_time) (so that not-yet-enrolled patients are
handled correctly too):
enroll_time + min(event_time, dropout_time) > ref, i.e. neither the
event nor dropout has occurred by ref;enroll_time + readout > ref and the reading will actually be
taken, that is readout <= dropout_time and the reading falls within
the trial duration. A baseline endpoint (readout = 0) is measured at
enrollment and is therefore never open.A patient enters the pool passed to what() if any of their
endpoints is open. Patients who have died, dropped out, or completed all
of their readouts by T are excluded automatically -- they have no
post-T outcome that a crossover could alter. Eligibility does not depend
on the treatment arm; restricting the switch to particular arms is the job
of what(). This mirrors the way regimen() "figures out patients who are
eligible for the three functions"; the difference is only the reference
time T.
The three functions then receive progressively smaller data frames, which keeps the logic clean and avoids unnecessary computation:
what() receives the eligible patients;when() receives the patients that what() actually selected for
switching;how() receives those same switchers, with their assigned
switch_time.All three functions share the same signature: each takes the data frame
patient_data and returns a data frame.
what(patient_data) # -> patient_id, new_treatment when(patient_data) # -> patient_id, switch_time how(patient_data) # -> patient_id, <modified endpoints>
Importantly, the patient_data they receive is already the filtered
subset shown below; there is no way to reach a non-eligible patient from
inside the triplet, because the package controls the input. This both
prevents accidental logic errors and avoids unnecessary computation.
op <- par(mar = c(0, 0, 0, 0)) plot.new(); plot.window(xlim = c(0, 10), ylim = c(0, 10)) cx <- 5 yb <- c(9.3, 7.1, 4.9, 2.7, 0.6) # band boundaries (top -> bottom) wt <- c(3.4, 2.6, 1.8, 1.0) # band top widths wd <- c(2.6, 1.8, 1.0, 0.5) # band bottom widths for (i in 1:4) { polygon(cx + c(-wt[i], wt[i], wd[i], -wd[i]) / 2, c(yb[i], yb[i], yb[i + 1], yb[i + 1]), col = "#e8eef5", border = "#33618f", lwd = 1.4) } ymid <- (head(yb, -1) + tail(yb, -1)) / 2 left <- c("all patients\n(trial_data)", "eligible\n(open endpoint at T)", "switchers\n(what() selects)", "+ switch_time") text(cx - 2.0, ymid, left, adj = 1, cex = 0.76) fn <- c("what()", "when()", "how()") for (i in 1:3) { arrows(cx + wt[i + 1] / 2 + 0.1, ymid[i + 1], cx + 2.0, ymid[i + 1], length = 0.07, col = "#33618f") text(cx + 2.1, ymid[i + 1], paste0("passed to ", fn[i]), adj = 0, cex = 0.82) } par(op)
To make timing rules easy to express, the package injects two helper
columns into patient_data for the triplet:
earliest_crossover_calendar_time, equal to T;earliest_crossover_time_from_enrollment, equal to
max(T - enroll_time, 0), i.e. the earliest admissible
switch_time measured from enrollment.These let when() express, for example, "switch at progression, but not
before crossover opens":
time_selector <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, switch_time = pmax(patient_data$pfs, patient_data$earliest_crossover_time_from_enrollment) ) }
If when() is not supplied, patients switch at the opening time T by
default (switch_time = earliest_crossover_time_from_enrollment).
Because the triplet is user-supplied, TrialSimulator validates what
each function returns and stops with an informative error when an input
would corrupt the simulation. This lets you develop the three functions
incrementally and catch mistakes early (a browser() inside any of them
is a convenient way to debug).
what() must return a data frame with columns patient_id and
new_treatment, with one row per switching patient. Patients you leave out
simply do not switch, so the returned set may be smaller than the input.
The package checks that:
patient_id is duplicated;what()
is only ever shown eligible patients, selecting a patient outside that
pool indicates a logic error and is rejected.new_treatment is a free-form label; it does not need to be an existing
arm. The randomized arm column is never changed -- the label is recorded
only in the patient's switching history (see below).
when() must return a data frame with columns patient_id and
switch_time, measured from enrollment. The package checks that:
patient_id is duplicated;NA switch_time is provided for every patient selected by
what();enroll_time + switch_time >= T for every switcher. A milestone
crossover cannot take effect before it opens, so an earlier time is
rejected.how() must return a data frame with patient_id and the endpoint
columns it modifies. You return only the endpoints you change, and within
an endpoint you change just the cells that should change and return the
rest at their original value -- the natural
ifelse(condition, new_value, original) pattern used in the examples. The
package checks that:
patient_id column, no duplicated
ids, and only columns that exist in the trial data;arm, enroll_time, dropout_time, and the
internal switching-history column) are not modified;switch_time -- a pre-switch or
already-observed outcome -- is rejected. This guarantees that the
crossover never rewrites history: outcomes observed before the switch
are preserved exactly, and the data locked at the milestone is left
intact.In practice this means a how() for a milestone crossover guards the
post-switch region explicitly, for example:
data_modifier <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, ## extend only the residual (post-switch) survival; leave os unchanged ## for patients whose event is at or before the switch os = ifelse(patient_data$os > patient_data$switch_time, patient_data$switch_time + 1.2 * (patient_data$os - patient_data$switch_time), patient_data$os) ) }
As with regimen(), never apply dropout or censoring manually in
how(); TrialSimulator re-applies dropout and calendar censoring
automatically after the crossover and when locking data at later
milestones.
The most convenient way to develop the three functions is to put a
browser() at the top of each, register them through crossover() inside
the milestone action, and run the trial. Execution then pauses inside
your function, with the actual patient_data in scope -- the eligible pool
for what(), and the selected switchers for when() and how(). You can
inspect the available columns (including the injected helper columns), try
your selection, timing, and update logic on real data, and confirm the
package handed you the subset you expected.
what <- function(patient_data){ browser() # pause here with the eligible pool in scope switchers <- patient_data[patient_data$arm == 'control', ] data.frame(patient_id = switchers$patient_id, new_treatment = 'experimental') }
Comment out browser() before production runs; it is in any case a no-op
in non-interactive sessions, so it never interferes with vignette building
or parallel workers.
The following runs a small two-arm trial end to end. Control patients who
are still on study at an interim are offered the experimental treatment;
each switches at progression, but no earlier than the moment crossover
opens, and only their post-switch survival is extended. We first define
the triplet and the action, using the full crossover() signature:
what <- function(patient_data){ ## return only the patients who switch (here, everyone on control) switchers <- patient_data[patient_data$arm == 'control', ] data.frame( patient_id = switchers$patient_id, new_treatment = 'experimental' ) } when <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, switch_time = pmax(patient_data$pfs, patient_data$earliest_crossover_time_from_enrollment) ) } how <- function(patient_data){ data.frame( patient_id = patient_data$patient_id, os = ifelse(patient_data$os > patient_data$switch_time, patient_data$switch_time + 1.3 * (patient_data$os - patient_data$switch_time), patient_data$os) ) } crossover_action <- function(trial){ trial$get_locked_data('interim') # interim decision making can go here ## delay = 0 opens crossover at the interim; use delay > 0 for a wash-out trial$crossover(what = what, when = when, how = how, delay = 0) }
The remaining set-up (endpoints, arms, enrollment, the milestones, and the
run) is ordinary TrialSimulator code and is hidden here for brevity.
os_e <- endpoint(name = 'os', type = 'tte', generator = rexp, rate = log(2) / 12) pfs_e <- endpoint(name = 'pfs', type = 'tte', generator = rexp, rate = log(2) / 6) control <- arm(name = 'control', pfs <= os); control$add_endpoints(pfs_e, os_e) trt <- arm(name = 'trt', pfs <= os); trt$add_endpoints(pfs_e, os_e) trial <- trial(name = 'demo', n_patients = 200, seed = 123, duration = 36, enroller = StaggeredRecruiter, accrual_rate = data.frame(end_time = Inf, piecewise_rate = 15), silent = TRUE) trial$add_arms(sample_ratio = c(1, 1), control, trt) lst <- listener(silent = TRUE) lst$add_milestones( milestone(name = 'interim', when = calendarTime(time = 12), action = crossover_action), milestone(name = 'final', when = calendarTime(time = 36)) ) invisible(controller(trial, lst)$run(n = 1, silent = TRUE, plot_event = FALSE))
After the run, each control switcher carries a recorded switching history
in the locked data. The recorded switch times show the timing rule at
work: a patient whose progression falls after the opening switches at
progression (a larger @ time), while a patient who would have progressed
earlier is held at the opening, so the recorded time equals
T - enroll_time -- the earliest_crossover_time_from_enrollment that
when() used:
final <- trial$get_locked_data('final') head(final[final$arm == 'control', c('patient_id', 'arm', 'regimen_trajectory', 'n_switches')])
Delayed opening (wash-out). Supplying delay opens crossover after
the milestone, which is useful when switching cannot be implemented
immediately:
trial$crossover(what = what, how = how, delay = 2) # opens 2 time units later
Custom timing. Provide when() to switch at a clinically meaningful
time rather than at the opening, using the injected helper column to
respect the opening floor (see the time_selector example above).
Patients enrolled after the milestone. A milestone crossover also applies to patients who enroll later: they switch according to the same triplet once they are in the trial. There is nothing extra to do.
Several crossovers. crossover() can be called in more than one
milestone. Each call stacks an additional switching opportunity, applied
in chronological order, so a patient may cross over more than once.
add_regimen()A milestone crossover and an enrollment-time regimen() are the same
machinery under the hood: both are sequences of what() / when() /
how() triplets, each with an earliest crossover time. The enrollment
regimen is simply the special case where that time is 0 (switching is
open from the first enrollment); crossover() appends a triplet whose
earliest time is the milestone time plus delay. The eligibility filter,
the timing floor, and the post-switch protection therefore behave
consistently across both entry points.
Every switch is recorded in the regimen_trajectory column shown above, as
a compact "arm@0;new_treatment@switch_time" string. Use expandRegimen()
to expand it into one row per regimen segment per patient for summaries and
checks (continuing the worked example above):
head(expandRegimen(final))
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.