Skip to contents
library(oncomsm)
library(ggplot2)
library(dplyr, warn.conflicts = FALSE)
library(tidyr)
library(future) # parallel processing
library(doFuture)
#> Loading required package: foreach
library(doRNG) # parallel safe random number generation
#> Loading required package: rngtools
registerDoFuture()
plan(multisession) # instruct future how to run in parallel
set.seed(42L)

tl;dr: A multi-state model allows sampling individual-level data. This can be used to impute future trial data from the prior-predictive distribution at the planning stage. The generated trial data can then be used to evaluate quantitative ‘go’ decision criteria to determine whether the sampled data would lead to a ‘go’ decision. The average sampled ‘go’ rate over multiple such data sets is the a MCMC approximation of the probability of ‘go’. A similar approach can be taken at any interim time point by sampling from the posterior predictive distribution conditioning on any observed data. This then leads to an updated (or conditional) probability of ‘go’.

For a general introduction to the multi-state approach used throughout this package, see the vignette Multi-state Models for Early Oncology.

Decision criteria and probability of ‘go’

Let \(D_t \in \mathbb{D}\) be the observed (visit) data at time point \(t\) after the start of the trial. Let \(\tau\) be the stopping time of the trial, e.g., the time point when all \(n\) individuals have been recruited and have reached their minimal follow-up. The final decision on ‘go’ can then be modeled as a function \(\phi: \mathbb{D} \to \{0,1\}\) with

\[\phi(D_\tau)=1 :\Leftrightarrow D_\tau\ \ \text{leads to 'go'}\ . \]

Let \(\theta\) be a vector of parameters for a generative model that allows to sample data \(D_\tau|\theta\). Probability of ‘go’ can the be calculated as expected value of the decision rule under a prior distribution (density) \(f(\theta)\) over the parameter space: \[ \operatorname{Pr}\big[\,go\,\big] = \int \phi(D_\tau\,|\,\theta) \cdot f(\theta) \operatorname{d}\theta \ . \] In practice, this integral can be approximated by sampling from the generative model and calculating the average ‘go’ rate.

If data \(D_t=d_t\) is observed, the probability of ‘go’ can be updated using Bayes Theorem \[ \operatorname{Pr}\big[\,go\,|\, D_t=d_t \,\big] = \int \phi(D_\tau\,|\,D_t=d_t,\theta) \cdot f(\theta\,|\,D_t=d_t) \operatorname{d}\theta \] where \(f(\theta\,|\,D_t=d_t)\) is the posterior density given the data \(d_t\) observed up to time \(t\).

Examples for such decision rules could be

  1. a quantile of the posterior distribution of the response rate being above a certain relevance threshold,
  2. a quantile of the posterior distribution of the PFS6 rate being above a certain relevance threshold,
  3. a quantile of the posterior distribution of PFS being above a certain threshold,
  4. or a combination of the above.

Consider a situation with a trial including two different arms “A” and “B”. We start with a fairly vague prior except for the shape which is fixed to about 1 (default) in absence of other evidence. This is required to stabilize inference on the other model parameters.

mdl <- create_srp_model(
  A = srp_group_prior(
    p_mean = 0.5, p_n = 3, p_eta = 0.2,
    recruitment_rate = 2
  ),
  B = srp_group_prior(
    p_mean = 0.5, p_n = 3, p_eta = 0.2,
    recruitment_rate = 2
  )
)

One can now sample from the prior and visualize the prior assumptions.

smpl_prior <- sample_prior(mdl, seed = 6835L)

plot(mdl, parameter_sample = smpl_prior, confidence = 0.9)

A composite ORR and PFS12 ‘go’ criterion

Assume that a ‘go’ is defined as a combination of a sufficiently high response rate and a sufficiently high progression-free-survival rate at 12 months:

\[\phi(D_\tau) := \begin{cases} 1 \quad \text{if} \quad \operatorname{Pr}\big[\,\text{ORR} \geq 0.3\ \&\ \text{PFS}_{t=12} \geq 0.4 \, | \, D_\tau\,\big] \geq 0.8 \\ 0 \quad \text{else.} \end{cases}\]

This decision criterion can be implemented as the following function:

go <- function(data, nsim = 200L) {
  set.seed(3819308) # fix the seed for reproducibility
  smpl <- suppressWarnings( # low sample size leads to stan warnings on ESS
                            # can be ignored, errors average out
                            # can also be avoided by increasing nsim
    sample_posterior(mdl, data = data, warmup = 200L, nsim = nsim)
  )
  tbl_pfs_orr <- inner_join(
    compute_pfs(mdl, 12, smpl), # PFS 12
    parameter_sample_to_tibble(mdl, smpl) %>% # ORR
      filter(parameter == "p") %>%
      transmute(iter = 1:(2*nsim), group_id, orr = value),
    by = c("iter", "group_id")
  )
  res <- tbl_pfs_orr %>% # apply decision criterion
    group_by(group_id) %>%
    summarize(
      go = mean(pfs >= 0.5 & orr >= 0.3) >= 0.8
    )
  return(res)
}

An example of how to apply the criterion to a sample from the prior predictive distribution conditional on specific response rates is given below

tbl_prior_predictive <- sample_predictive(
    mdl,
    n_per_group = rep(40L, 2),
    sample = smpl_prior,
    p = c(0, 1), 
    shape = matrix(c(
      1, 1, 1,
      1, 1, 1
    ), ncol = 3),
    scale = matrix(c( # median = scale * log(2)^(1/shape)
      6, 6, 12,
      3, 12, 24
    ), ncol = 3) / log(2),
    nsim = 1L,
    seed = 34930L
  )

tbl_prior_predictive %>% 
  visits_to_mstate(mdl) %>% 
  plot_mstate(mdl, relative_to_sot = FALSE)


go(tbl_prior_predictive)
#> # A tibble: 2 × 2
#>   group_id go   
#>   <chr>    <lgl>
#> 1 A        FALSE
#> 2 B        TRUE

Next, we can create a table of multiple prior-predictive samples. Grouping them by iteration and nesting the data frames results in a data frame of data frames, where tbl_prior_predictive$data[[i]] corresponds to the data in the \(i\)-th resample.

tbl_prior_predictive <- sample_predictive(
    mdl,
    n_per_group = rep(40L, 2),
    p = c(.45, .65), 
    shape = matrix(c(
      1, 1, 1,
      1, 1, 1
    ), ncol = 3),
    scale = matrix(c( # median = scale * log(2)^(1/shape)
      6, 6, 12,
      3, 12, 24
    ), ncol = 3) / log(2),
    nsim = 100L, # same, here, only for demonstration purposes
    seed = 34930L
  ) %>%
  group_by(iter) %>%
  tidyr::nest() %>%
  ungroup()

print(tbl_prior_predictive)
#> # A tibble: 100 × 2
#>     iter data                
#>    <int> <list>              
#>  1     1 <tibble [2,261 × 4]>
#>  2     2 <tibble [1,925 × 4]>
#>  3     3 <tibble [1,865 × 4]>
#>  4     4 <tibble [2,050 × 4]>
#>  5     5 <tibble [2,195 × 4]>
#>  6     6 <tibble [2,245 × 4]>
#>  7     7 <tibble [2,048 × 4]>
#>  8     8 <tibble [2,390 × 4]>
#>  9     9 <tibble [1,760 × 4]>
#> 10    10 <tibble [2,187 × 4]>
#> # … with 90 more rows

Applying the decision criterion to each of them and averaging over all iterations is an MCMC approximation of the probability of ‘go’.

# compute results in parallel
res <- foreach(i = seq_len(nrow(tbl_prior_predictive))) %dorng% {
  go(tbl_prior_predictive$data[[i]])
}

# bind results together and aggregate to probability of 'go'
tbl_pr_go <- bind_rows(res, .id = "iter") %>%
  group_by(group_id) %>%
  summarize(
    `Pr[go]` = mean(go),
    se  = sd(go) / sqrt(n())
  )

print(tbl_pr_go)
#> # A tibble: 2 × 3
#>   group_id `Pr[go]`     se
#>   <chr>       <dbl>  <dbl>
#> 1 A            0.68 0.0469
#> 2 B            0.53 0.0502

Updating the probability of ‘go’ during a trial

The Bayesian generative model allows calculating the posterior distribution at any time point given the data observed up to that point. The future course of the trial can then be sampled from the posterior predictive distribution.

To illustrate the shift in ‘go’ probability over time, a single trial realization from a set of fixed response probabilities that conflict with the prior mean is sampled as hypothetical data. As data accrues, the evidence will lead to a shift in the posterior probability and thus the probability of ‘go’.

tbl_data <- sample_predictive(
    mdl,
    p = c(0.4, 0.7),
    shape = matrix(c(
      1, 1, 1,
      1, 1, 1
    ), ncol = 3),
    scale = matrix(c( # median = scale * log(2)^(1/shape)
      3, 6, 12,
      2, 8, 24
    ), ncol = 3) / log(2),
    n_per_group = c(40L, 40L),
    nsim = 1,
    seed = 23849
  )

print(tbl_data)
#> # A tibble: 1,813 × 5
#>    subject_id group_id     t state   iter
#>    <chr>      <chr>    <dbl> <chr>  <int>
#>  1 ID00326638 A         4.03 stable     1
#>  2 ID00326638 A         5.03 stable     1
#>  3 ID00326638 A         6.03 stable     1
#>  4 ID00326638 A         7.03 stable     1
#>  5 ID00326638 A         8.03 stable     1
#>  6 ID00326638 A         9.03 stable     1
#>  7 ID00326638 A        10.0  stable     1
#>  8 ID00326638 A        11.0  stable     1
#>  9 ID00326638 A        12.0  stable     1
#> 10 ID00326638 A        13.0  stable     1
#> # … with 1,803 more rows

Next, interim views of the full data set at a sequence of time points can be created. This data subsets correspond to the data available at potential interim analyses.

tbl_data_interims <- tibble(
    t_interim = c(1, 3, 6, 9, 12, 18, 24, 36) # interim time point (months)
  ) %>%
  mutate(
    data = purrr::map(t_interim, ~filter(tbl_data, t <= .))
  )

print(tbl_data_interims)
#> # A tibble: 8 × 2
#>   t_interim data                
#>       <dbl> <list>              
#> 1         1 <tibble [8 × 5]>    
#> 2         3 <tibble [36 × 5]>   
#> 3         6 <tibble [97 × 5]>   
#> 4         9 <tibble [183 × 5]>  
#> 5        12 <tibble [293 × 5]>  
#> 6        18 <tibble [615 × 5]>  
#> 7        24 <tibble [908 × 5]>  
#> 8        36 <tibble [1,317 × 5]>

For each such interim data set, the remainder of the trial can be imputed by sampling form the posterior-predictive distribution given the data observed up to this point. The probability of ‘go’ is then the average ‘go’ rate in the posterior predictive sample when applying the decision criterion.

First, the calculation of the probability to ‘go’ is wrapped in a function that accepts a dataset (tbl) and the number of resamples (nsim).

pr_go <- function(tbl, nsim) {
  # sample forward from the posterior predictive distribution given data in tbl
  tbl_posterior_predictive <- impute(
      mdl, tbl, n_per_group = rep(40L, 2), nsim = nsim
    ) %>%
    group_by(iter) %>%
    tidyr::nest()
  # determine for each sampled trial whether it ends in a 'go'
  res <- foreach(i = 1:nsim) %dorng% {
    go(tbl_posterior_predictive$data[[i]])
  }
  tbl_pr_go <- bind_rows(res, .id = "iter") %>%
    group_by(group_id) %>%
    summarize(
      `Pr[go]` = mean(go),
      se  = sd(go) / sqrt(n())
    )
  return(tbl_pr_go)
}

Next, this function is mapped over all interim time points with the respective interim data. The number of simulations is the main factor in determining the runtime of the program besides the number of samples drawn from the posterior distribution to evaluate the decision criterion.

tbl_pr_go_over_time <- tbl_data_interims %>%
  mutate(
    res = purrr::map(data, pr_go, nsim = 33L)
  ) %>%
  tidyr::unnest(res)

tbl_pr_go_over_time
#> # A tibble: 16 × 5
#>    t_interim data                 group_id `Pr[go]`     se
#>        <dbl> <list>               <chr>       <dbl>  <dbl>
#>  1         1 <tibble [8 × 5]>     A          0.364  0.0850
#>  2         1 <tibble [8 × 5]>     B          0.212  0.0723
#>  3         3 <tibble [36 × 5]>    A          0.303  0.0812
#>  4         3 <tibble [36 × 5]>    B          0.333  0.0833
#>  5         6 <tibble [97 × 5]>    A          0.455  0.0880
#>  6         6 <tibble [97 × 5]>    B          0.545  0.0880
#>  7         9 <tibble [183 × 5]>   A          0.636  0.0850
#>  8         9 <tibble [183 × 5]>   B          0.212  0.0723
#>  9        12 <tibble [293 × 5]>   A          0.939  0.0422
#> 10        12 <tibble [293 × 5]>   B          0.424  0.0874
#> 11        18 <tibble [615 × 5]>   A          0.667  0.0833
#> 12        18 <tibble [615 × 5]>   B          0.121  0.0577
#> 13        24 <tibble [908 × 5]>   A          0.727  0.0787
#> 14        24 <tibble [908 × 5]>   B          0.0909 0.0508
#> 15        36 <tibble [1,317 × 5]> A          0.758  0.0758
#> 16        36 <tibble [1,317 × 5]> B          0      0

The change of the ‘go’ probability over time is shown below with error bars corresponding to the standard error of the simulation.

ggplot(tbl_pr_go_over_time) +
  aes(t_interim) +
  geom_errorbar(aes(ymin = `Pr[go]` - se, ymax = `Pr[go]` + se,
                    color = group_id), width = 1) +
  geom_line(aes(y = `Pr[go]`, color = group_id), alpha = 0.33) +
  scale_x_continuous("t [months]", breaks = seq(0, 36, by = 6)) +
  scale_y_continuous(breaks = seq(0, 1, by = 0.1), limits = c(0, 1))

This can be compared to the number of available responses, progressions, and stable individuals at each time point.

f <- function(tbl) {
  tbl %>%
  group_by(group_id, subject_id) %>%
  summarize(
    status = {
      if (all(state == "stable")) {
        "stable"
      } else {
        state[which(state != "stable")[1]]
      }
    },
    .groups = "drop_last"
  ) %>% 
  group_by(group_id, status) %>% 
  summarize(n = n(), .groups = "drop")
}

tibble(
  t_interim = tbl_data_interims$t_interim,
  summary = purrr::map(tbl_data_interims$data, f)
) %>%
unnest(summary) %>% 
ggplot() +
  aes(t_interim, n, color = status, linetype = group_id) +
  geom_line() +
  scale_x_continuous("t [months]", breaks = seq(0, 36, by = 6)) +
  scale_y_continuous("", breaks = seq(0, 30, by = 5))

Session info

sessionInfo()
#> R version 4.2.2 (2022-10-31)
#> Platform: x86_64-pc-linux-gnu (64-bit)
#> Running under: Ubuntu 22.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.20.so
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] doRNG_1.8.2        rngtools_1.5.2     doFuture_0.12.2    foreach_1.5.2     
#> [5] future_1.29.0      tidyr_1.2.1        dplyr_1.0.10       ggplot2_3.4.0     
#> [9] oncomsm_0.1.1.9000
#> 
#> loaded via a namespace (and not attached):
#>  [1] Rcpp_1.0.9           listenv_0.8.0        prettyunits_1.1.1   
#>  [4] ps_1.7.2             rprojroot_2.0.3      digest_0.6.30       
#>  [7] utf8_1.2.2           parallelly_1.32.1    R6_2.5.1            
#> [10] backports_1.4.1      stats4_4.2.2         evaluate_0.18       
#> [13] highr_0.9            pillar_1.8.1         rlang_1.0.6         
#> [16] callr_3.7.3          jquerylib_0.1.4      checkmate_2.1.0     
#> [19] rmarkdown_2.18       pkgdown_2.0.6        labeling_0.4.2      
#> [22] textshaping_0.3.6    desc_1.4.2           stringr_1.4.1       
#> [25] loo_2.5.1            munsell_0.5.0        compiler_4.2.2      
#> [28] xfun_0.35            rstan_2.21.7         pkgconfig_2.0.3     
#> [31] systemfonts_1.0.4    pkgbuild_1.4.0       globals_0.16.2      
#> [34] htmltools_0.5.3      tidyselect_1.2.0     tibble_3.1.8        
#> [37] gridExtra_2.3        codetools_0.2-18     matrixStats_0.63.0  
#> [40] fansi_1.0.3          crayon_1.5.2         withr_2.5.0         
#> [43] grid_4.2.2           jsonlite_1.8.3       gtable_0.3.1        
#> [46] lifecycle_1.0.3      magrittr_2.0.3       StanHeaders_2.21.0-7
#> [49] scales_1.2.1         RcppParallel_5.1.5   cli_3.4.1           
#> [52] stringi_1.7.8        cachem_1.0.6         farver_2.1.1        
#> [55] fs_1.5.2             bslib_0.4.1          ellipsis_0.3.2      
#> [58] ragg_1.2.4           generics_0.1.3       vctrs_0.5.1         
#> [61] iterators_1.0.14     tools_4.2.2          glue_1.6.2          
#> [64] purrr_0.3.5          processx_3.8.0       parallel_4.2.2      
#> [67] fastmap_1.1.0        yaml_2.3.6           RcppNumerical_0.4-0 
#> [70] inline_0.3.19        colorspace_2.0-3     memoise_2.0.1       
#> [73] knitr_1.41           patchwork_1.1.2      sass_0.4.4