TrialSimulator

Empowering clinical trial design with simulations

Han Zhang

2026-03-05

What’s TrialSimulator

  • Patient-level simulation
  • Declarative programming, inspired by the modular grammar of trial design
  • Customizable for complex adaptive designs
  • Validated
    • unit test
    • replicating results from rpact of fixed design
    • replicating results in literature of adaptive designs
  • Parallelizable

Common structure of simulation

generate_trial_data <- function(...)
{...}

analyze_trial_data <- function(...)
{...}

arg1 <- ...
...
argk <- ...

run_simulation <- function(...) {
  for(i in 1:n){
    dat <- generate_trial_data(...)
    res <- analyze_trial_data(...)
    # update output with res
  }
  summarize(output)
}

A function generating trial data of

  • Multiple endpoints
  • Multiple arms
  • Multiple milestones

Common structure of simulation

generate_trial_data <- function(...)
{...}

analyze_trial_data <- function(...)
{...}

arg1 <- ...
...
argk <- ...

run_simulation <- function(...) {
  for(i in 1:n){
    dat <- generate_trial_data(...)
    res <- analyze_trial_data(...)
    # update output with res
  }
  summarize(output)
}

A function analyzing trial data of

  • Multiple endpoints
  • Multiple arms
  • Multiple milestones

Common structure of simulation

generate_trial_data <- function(...)
{...}

analyze_trial_data <- function(...)
{...}

arg1 <- ...
...
argk <- ...

run_simulation <- function(...) {
  for(i in 1:n){
    dat <- generate_trial_data(...)
    res <- analyze_trial_data(...)
    # update output with res
  }
  summarize(output)
}

A long list of parameters

  • Endpoint distributions
  • Decision boundaries, etc.
  • Hard to manage, usually bad naming conventions

Common structure of simulation

generate_trial_data <- function(...)
{...}

analyze_trial_data <- function(...)
{...}

arg1 <- ...
...
argk <- ...

run_simulation <- function(...) {
  for(i in 1:n){
    dat <- generate_trial_data(...)
    res <- analyze_trial_data(...)
    # update output with res
  }
  summarize(output)
}

A function

  • running simulation
  • calculating metrics for operating characteristic

Issues

generate_trial_data <- function(...)
{...}

analyze_trial_data <- function(...)
{...}

arg1 <- ...
...
argk <- ...

run_simulation <- function(...) {
  for(i in 1:n){
    dat <- generate_trial_data(...)
    res <- analyze_trial_data(...)
    # update output with res
  }
  summarize(output)
}
  • Decision of adaptation may depend on analysis results at milestone
  • Impossible to decouple data generation and data analysis
  • Hard to reuse when design changes (even slightly)
  • Hard to QC

Existing solutions

  • Well-validated packages
    • gsDesign, rpact, etc.
    • Fixed design, lack of patient-level simulation or multiple endpoint support
  • Commercial platforms
    • black-box, limited flexibility
  • Method-centric open-source packages
    • CRAN task view: Clinical trial design, monitoring, analysis and reporting

Is a better solution possible?

  • Yes with TrialSimulator
  • Defining independent modules of a trial design
    • endpoints, arms, milestones, and customized actions
  • Stacking or combining modules to create designs

Adaptive design

Milestones in a fixed design

Adaptive design

Make it a group sequential design

Adaptive design

Add a futility

Adaptive design

Make it a seamless design, even with dose selection

Adaptations

  • data-driven decisions and actions
    • depend on one or more endpoints
    • create data cut for users
  • remove/add arms
  • update sample ratio
  • extend trial duration
  • increase sample size
  • eliminate sub-population
  • crossover*

Milestones

  • A milestone defines the time point when data is unblinded
  • Users obtain access to patient-level data
  • Analysis is carried out, decision is made, action is taken
  • Tedious coding is usually needed to determine timing and create data-cut
    • hard to QC and reuse

Solutions

final_action <- function(trial){
  locked_data <- trial$get_locked_data('final') # name of milestone
  # do whatever analysis
  # trial$save(value, name)
}

final <- milestone(name = 'final',  # to request for data cut
                   when = enrollment(n = 1000) & 
                     eventNumber(endpoint = 'os', n = 300) & (
                       calendarTime(time = 28) | 
                         eventNumber(endpoint = 'pfs', n = 520)
                     ),
                   action = final_action
)

Define more milestones

EOPh2 <- milestone(
  name = 'End of Ph2', # to request for data cut
  when = eventNumber(endpoint = 'orr', n = 200), 
  action = EOPh2_action # defined by user
)

futility <- milestones(
  name = 'Fu',
  when = calendarTime(time = 8),
  action = fu_action
)

IAPh3 <- milestone(
  name = 'IA of Ph3',
  when = eventNumber(endpoint = 'pfs', n = 250)
)

Define more milestones

EOPh2_action <- function(trial){

  # request for data access using milestone name
  locked_data <- trial$get_locked_data('End of Ph2')
  
  # do planned analysis
  if(condition1_is_met){
    trial$remove_arms('high dose')
    trial$save(value = 'low', name = 'kept_arm')
  }else if(condition2_is_met){
    trial$remove_arms('low dose')
    trial$save(value = 'high', name = 'kept_arm')
  }else{
    trial$save(value = 'both', name = 'kept_arm')
  }
  
  # do something else
}

Listener monitoring milestones

# define listener to trigger milestones
listener <- listener()

# register milestones with listener
listener$add_milestones(
  futility, # comment this line to remove futility
  EOPh2, # comment this line to remove dose-selection
  IAPh3, # comment this line to remove IA
  final
)

Define endpoints in placebo arm

pfs <- endpoint(
  name = 'pfs', type = 'tte', 
  generator = rexp, rate = log(2) / 5)

os <- endpoint(
  name = 'os', type = 'tte',
  generator = rexp, rate = log(2) / 14)
  
surrogate <- endpoint(
  name = 'surrogate', type = 'non-tte',
  readout = c(surrogate = 1),
  generator = rbinom, size = 1, prob = .05)
                      
pbo <- arm(name = 'placebo')
# collect endpoints in arm
pbo$add_endpoints(pfs, os, surrogate)

# print summary of arm
pbo

Define a trial

accrual_rate <- data.frame(end_time = c(10, Inf),
                           piecewise_rate = c(30, 50))

trial <- trial(
  name = 'Trial-3415', 
  n_patients = 1000,
  duration = 40,
  enroller = StaggeredRecruiter, accrual_rate = accrual_rate,
  dropout = rweibull, shape = 2.139, scale = 38.343
)

trial$add_arms(sample_ratio = c(1, 1, 1), low, high, pbo)

Execute simulation

# register trial and listener to a controller
controller <- controller(trial, listener)

# simulate a trial
controller$run(n = 1, plot_event = TRUE)

# obtain saved results
controller$get_output()

Example: terminating arms in dose-selection

action <- function(trial){

  locked_data <- 
    trial$get_locked_data(
      'milestone_name')
  ...
  trial$remove_arms('high dose')
  ...
  
}

Example: adding arms

action <- function(trial){

  ...
  ep1 <- endpoint(...)
  arm1 <- arm(name = 'dose = 0.5')
  arm1$add_endpoints(ep1)
  
  ep2 <- endpoint(...)
  arm2 <- arm(name = 'dose = 1.5')
  arm2$add_endpoints(ep2)
  ...
  
  trial$add_arms(
    sample_ratio = ..., 
    arm1, arm2, ...)
  ...
}

Example: modifying sample ratio

stage_action <- function(trial){
  ...
  trial$update_sample_ratio(
    arm_names = c('0.0', 
                  '20.0', 
                  '25.0', 
                  '30.0', 
                  '35.0'),
    sample_ratios = 
      new_sample_ratio)
  
  ...
}

Supported Aadaptations