The purpose of this document is to show how we can calculate the probability of success given interim data of a clinical trial, based on the TGI-OS joint model results.
Setup and load data
Here we execute the R code from the setup and data preparation chapter, see the full code here.
Fit our joint TGI-OS model
Let’s fit again the same joint TGI-OS model as in the previous session. Now we just put all the code together in a single chunk, and it is a good repetition to see all the steps in one place:
First, we will try to apply the methodology from Oudenhoven et al (2020) to estimate the marginal hazard ratio, using our joint model results. We use the new jmpost function called populationHR() for this:
So here we have the marginal log hazard ratio estimates of the baseline spline components and the treatment arm. Therefore we can get the hazard ratio estimates by exponentiating the log hazard ratio estimates:
Here we get a different HR estimate of around 0.72, which is lower than the one we got from the joint model. We should expect different results because the joint model takes into account the TGI effect, while the Cox model does not.
And if we just put the treatment arm into the Cox model we get:
Call:
survival::coxph(formula = update(joint_results@data@survival@formula,
. ~ arm), data = joint_results@data@survival@data)
coef exp(coef) se(coef) z p
armMPDL3280A -0.2414 0.7855 0.1928 -1.252 0.21
Likelihood ratio test=1.56 on 1 df, p=0.2109
n= 203, number of events= 108
Here we get a HR of 0.785 that is close to the marginal HR estimate above (when we did not condition on the other covariates), which is reassuring. On the other hand, the 95% confidence interval is:
Show the code
exp(confint(cox_mod_arm)["armMPDL3280A", ])
2.5 % 97.5 %
0.5383409 1.1461249
so actually overlaps the null hypothesis of 1. So we see that the joint model helped us to obtain a more precise estimate of the marginal hazard ratio.
PTS based on frequentist HR properties
From now on we pretend that the data we have analyzed so far is the interim data of the Oak trial, and we want to calculate the predictive power of the trial based on this interim data.
Let’s first try to use the log HR (\(\delta\)) frequentist distribution to calculate the predictive power of the trial. We have:
On the other hand, assuming that we have observed the final data, then the variance estimate for the estimator \(\hat{\delta}_{\text{fin}}\) is given by:
For \(\hat{\delta}_{\text{int}}\) we are going to plug in our MCMC samples for the marginal log HR \(\hat{\delta}_{\text{int}}\) to compute the PTS based on this:
Show the code
log_hr_samples <- pop_hr[[2]]["armMPDL3280A", ]
But first we also need the quantities that go into the variance formula:
\(\overline{p}\): average event rate
\(r\): proportion of patients in the treatment arm
\(\overline{n}\): average arm size in interim data
\(\overline{m}\): average arm size in follow up
So let’s calculate these and the resulting variances:
Now we want to leverage the rpact function getConditionalPower() to calculate the predictive power of the trial based on the log-rank test statistic properties.
Looking at the function’s example, we first have to create a DataSet object with the interim data. Here it is important that we for cumLogRanks also the z scores from a Cox regression can be used, which means for our case that can insert here the z-scores defined as:
\[
z = \frac{\hat{\delta}_{\text{int}}}{\sigma_{\text{int}}}
\]
values.
But let’s first try this with a simple fixed \(z\) value to see how it works:
Show the code
library(rpact)
Installation qualification for rpact 4.2.1 has not yet been performed.
Please run testPackage() before using the package in GxP relevant environments.
Show the code
z <-log(0.9) /sqrt(var_int) # Instead of log(0.9) we put later the marginal log HR MCMC sampledata <-getDataset(cumEvents =sum(os_data$os_event),cumLogRanks = z,cumAllocationRatios = prop_pts_treatment)data
Dataset of survival data
Stages: 1
Cumulative events: 108
Cumulative allocation ratios: 0.527
Cumulative log-ranks: -0.886
Calculated data
Number of events: 108
Allocation ratios: 0.527093596059113
Log-ranks: -0.886
This is the data set from the first stage, i.e. our interim analysis.
Now we need to define the design of the group sequential trial:
Design parameters and output of group sequential design
User defined parameters
Information rates: 0.239, 1.000
Significance level: 0.0500
Derived from user defined parameters
Maximum number of stages: 2
Stages: 1, 2
Futility bounds (non-binding): -Inf
Default parameters
Type of design: O’Brien & Fleming
Type II error rate: 0.2000
Binding futility: FALSE
Test: one-sided
Tolerance: 1e-08
Output
Cumulative alpha spending: 0.000377, 0.050000
Critical values: 3.369, 1.646
Stage levels (one-sided): 0.000377, 0.049833
We see that the significance level is almost full for the final analysis, because by default the O’Brien & Fleming design is used, which only assigns a very small \(\alpha\) to the interim analysis. This is kind of what we need here.
Finally we put the design and data in a StageResults object, which is the input for the getConditionalPower() function:
Show the code
stageResults <-getStageResults(design = design,dataInput = data,stage =1,directionUpper =FALSE)condPower <-getConditionalPower(stageResults = stageResults,thetaH1 =0.9, # Here we put later the marginal hazard ratio MCMC samplenPlanned = total_size_final,allocationRatioPlanned =1)summary(condPower)
Technical developer summary of the Conditional power results survival object ("ConditionalPowerResultsSurvival"):
[u] Planned sample size : NA, 850
[d] Planned allocation ratio : 1
[.] %simulated% : FALSE
[g] Conditional power : NA, 0.5577
[u] Assumed effect under alternative : 0.9
Legend:
u: user defined
>: derived value
d: default value
g: generated/calculated value
.: not applicable or hidden
Conditional power results survival table:
Planned sample size Conditional power
[1,] NA NA
[2,] 850 0.5576662
Show the code
condPower$conditionalPower[2]
[1] 0.5576662
OK so now that we know how it works we wrap this in a little function again.
So here we get a PTS of 84.2%, which is higher than the one we got before.
Individual Samples based Predictive Power
This is a good motivation to try out the conditional sampling approach to calculate the predictive power, which is based on the individual samples of the joint model. It does not involved any frequentist assumptions to calculate the PTS.
We will perform the following steps in this algorithm:
We add as many new patients to the survival data set as we expect to still enter the trial.
These patients have survival time 0 and are censored at that time, which means they are completely new patients.
We refit the joint model with this augmented data set. This simplifies the subsequent sampling process.
For each patient who is still being followed up for OS at IA (=does not have an event yet and did not drop out)
Obtain individual survival distribution MCMC samples over a long enough time grid
Condition on last observed censored OS time, sample once per MCMC sample from the conditional survival distribution
Define “End of Study” time, e.g. based on number of events observed, calendar time, etc.
Apply summary statistic of interest to the complete “End of Study” OS data set samples and aggregate appropriately
For example, we can use the log-rank test statistic as summary statistic
Calculate the proportion of samples that are above the critical value for the summary statistic, e.g. log-rank test statistic
Note that for the first step, we need to add some random information to the data set, because unfortunately the real data does not differentiate between patients who are still being followed up and those who have dropped out.
Add new patients and generate random lost-to-follow-up flag
Basically for those patients with a censored survival time, we randomly assign a lost-to-follow-up flag with a probability of 10%, so that we can simulate some patients who are still being followed up and some who are not. We sample the covariates from the distribution of the already observed patients.
So now we have 10 patients who have been lost to follow-up and 732 patients who are still being followed up for OS (or who will still be entering the trial). These 732 patients will be the ones we will perform the individual sampling for.
Refit the joint model with augmented data
First we need to refit the joint model to this augmented data set. That is the easiest way to also get survival curve samples for the patients who are yet to be enrolled in the trial.
We notice that here we also need to add at least some random baseline samples to the longitudinal data set, otherwise it does not work.
So for this patient 588, we can extract the individual survival distribution MCMC samples from the joint model results along a time grid that extrapolates long enough into the future, say 10 years after the last observed OS time:
As described in the slides, we can now sample a survival time for this patient from the conditional survival distribution, given the last observed OS time, by:
Drawing a standard uniform random number \(p \sim U(0, 1)\)
Finding the time \(t\) such that \((1-p)S(c) - S(t) = 0\) where $c = 2.1 is the last observed OS time
Show the code
set.seed(123)p <-runif(1)# Linear approximation function for the survival function:surv_approx <-approxfun(x = first_surv_sample$time,y = first_surv_sample$values,rule =2# extrapolation outside interval via closest data extreme)# Survival function value at the time of censoring:surv_at_censoring <-surv_approx(patient_last_os_time)# Find the time t such that (1-p) * S(c) - S(t) = 0:t <-uniroot(function(t) (1- p) * surv_at_censoring -surv_approx(t),interval =c(0, time_grid_end))$roott
[1] 2.819322
We can plot the conditional survival function together with this sampled survival time:
The challenge is now to do this efficiently for all patients who are still being followed up for OS, and for all of their MCMC samples.
Simplified Rcpp implementation for single patient/sample
For now, let’s create a simpler Rcpp function that handles a single patient’s single MCMC sample:
Show the code
library(Rcpp)# Use Rcpp function for single patient, single sample.sourceCpp(here("session-pts/conditional_sampling.cpp"))# Test the function with a simple example.set.seed(3453)t_cpp_simple <-sample_single_conditional_survival_time(time_grid = first_surv_sample$time,surv_values = first_surv_sample$values,censoring_time = patient_last_os_time)first_cond_surv_sample_plot +geom_point(data =data.frame(t = t_cpp_simple$t_result,p = t_cpp_simple$uniform_sample ),aes(x = t, y =1- p),color ="blue",size =5 )
Rcpp implementation for all patients and samples
Let’s use the second Rcpp function that handles all patients and all MCMC samples at once. We just need to prepare the data in the right format:
The first argument is the time grid, which we already have.
The second argument is a matrix of survival values, where each row is a sample/patient combination and each column is a time point.
The third argument is a vector of censoring times, where each element corresponds to a sample/patient combination.
Show the code
# First subset to the patients where we need to sample from the conditional survival distribution.pt_to_sample <- os_data_augmented |>filter(!os_event &!lost_to_follow_up)pt_to_sample_ids <- pt_to_sample$idqs <- os_surv_samples@quantitiesinclude_qs <- qs@groups %in% pt_to_sample_idsqs_samples <- qs@quantities[, include_qs]qs_times <- qs@times[include_qs]qs_groups <- qs@groups[include_qs]surv_values_samples <-matrix( qs_samples,nrow = ITER *length(pt_to_sample_ids),ncol = length_time_grid)dim(surv_values_samples)
So this function is very fast, and in this case we have less than 1% of the samples where we did not have a long enough time grid, which seems sufficient for our purposes.
Generating OS data set samples
Now that we have the OS events for the patients who are still being followed up, we can generate the complete OS data set samples for each MCMC sample.
Show the code
# Where do we need to replace the OS times?os_rows_to_replace <-match( pt_to_sample_ids, os_data_augmented$id)# From which column do we take the sampled OS times?os_cond_samples_cols <-match( pt_to_sample_ids,colnames(os_cond_samples))os_data_samples <-lapply(1:ITER, function(i) {# Create a copy of the original OS data os_data_sample <- os_data_augmented[, c("id", "arm", "os_time", "os_event", "race", "sex", "ecog", "age")]# Add the sampled OS times for the patients who are still being followed up. os_data_sample$os_time[os_rows_to_replace] <- os_cond_samples[ i, os_cond_samples_cols ]# Set the OS event to TRUE for those patients, because we assume they have an event now. os_data_sample$os_event[os_rows_to_replace] <-TRUE os_data_sample})
Calculate log rank statistic for each sampled OS data set
Now it is easy to calculate the log-rank test statistic for each sampled OS data set. Here we don’t adjust for any covariates.
In this PTS estimate we can adjust for the covariates. We see that the result of 82.8% is in line with the frequentist based PTS estimate for the HR which we calculated earlier.
Plot Kaplan-Meier curves predictions
Similarly we can extract the Kaplan-Meier curves for each sampled OS data set and plot them:
Show the code
km_curves <-lapply(os_data_samples, function(df) { survfit_obj <- survival::survfit(Surv(os_time, os_event) ~ arm, data = df) times <- survfit_obj$time surv_probs <- survfit_obj$surv strata <- survfit_obj$strata n_control <- strata[1] n_treatment <- strata[2] control_fun <-stepfun( times[1:n_control],c(1, surv_probs[1:n_control]),right =TRUE ) treatment_fun <-stepfun( times[(n_control +1):length(times)],c(1, surv_probs[(n_control +1):length(surv_probs)]),right =TRUE ) control_vals <-control_fun(time_grid) treatment_vals <-treatment_fun(time_grid)list(control = control_vals, treatment = treatment_vals)})km_control_samples <-sapply(km_curves, function(x) x$control)km_treatment_samples <-sapply(km_curves, function(x) x$treatment)survfit_ia <- survival::survfit(Surv(os_time, os_event) ~ arm, data = os_data_augmented)plot(survfit_ia, col =c(1, 2), xlim =c(0, max(time_grid)))# Add the mean KM curves for each arm.lines(time_grid, rowMeans(km_control_samples), col =1)lines(time_grid, rowMeans(km_treatment_samples), col =2)# Add pointwise confidence intervals as shaded areas.km_control_ci <-apply(km_control_samples, 1, quantile, probs =c(0.025, 0.975))km_treatment_ci <-apply( km_treatment_samples,1, quantile,probs =c(0.025, 0.975))polygon(c(time_grid, rev(time_grid)),c(km_control_ci[1, ], rev(km_control_ci[2, ])),col =adjustcolor(1, alpha.f =0.2),border =NA)polygon(c(time_grid, rev(time_grid)),c(km_treatment_ci[1, ], rev(km_treatment_ci[2, ])),col =adjustcolor(2, alpha.f =0.2),border =NA)
We can see that we assume a very long follow up of the data here. Obviously we could cut the data sets at a certain number of events or a specific time point easily.
Source Code
---title: "1. Calculate Bayesian Predictive Power"author: - Daniel Sabanés Bové - Francois Mercierdate: last-modifiededitor_options: chunk_output_type: inlineformat: html: code-fold: show html-math-method: mathjaxcache: true---The purpose of this document is to show how we can calculate the probability of success given interim data of a clinical trial, based on the TGI-OS joint model results.## Setup and load dataHere we execute the R code from the setup and data preparation chapter, see the [full code here](0_setup.qmd).```{r}#| label: setup_and_load_data#| echo: false#| output: falselibrary(here)options(knitr.duplicate.label ="allow")knitr::purl(here("session-pts/_setup.qmd"),output =here("session-pts/_setup.R"))source(here("session-pts/_setup.R"))knitr::purl(here("session-pts/_load_data.qmd"),output =here("session-pts/_load_data.R"))source(here("session-pts/_load_data.R"))```## Fit our joint TGI-OS modelLet's fit again the same joint TGI-OS model as in the previous session. Now we just put all the code together in a single chunk, and it is a good repetition to see all the steps in one place:```{r}#| label: fit_joint_modelsubj_df <- os_data |>mutate(study ="OAK") |>select(study, id, arm)subj_data <-DataSubject(data = subj_df,subject ="id",arm ="arm",study ="study")long_df <- tumor_data |>select(id, year, sld)long_data <-DataLongitudinal(data = long_df,formula = sld ~ year)surv_data <-DataSurvival(data = os_data,formula =Surv(os_time, os_event) ~ ecog + age + race + sex)joint_data <-DataJoint(subject = subj_data,longitudinal = long_data,survival = surv_data)joint_mod <-JointModel(longitudinal =LongitudinalSteinFojo(mu_bsld =prior_normal(log(65), 1),mu_ks =prior_normal(log(0.52), 1),mu_kg =prior_normal(log(1.04), 1),omega_bsld =prior_normal(0, 3) |>set_limits(0, Inf),omega_ks =prior_normal(0, 3) |>set_limits(0, Inf),omega_kg =prior_normal(0, 3) |>set_limits(0, Inf),sigma =prior_normal(0, 3) |>set_limits(0, Inf) ),survival =SurvivalWeibullPH(lambda =prior_gamma(0.7, 1),gamma =prior_gamma(1.5, 1),beta =prior_normal(0, 20) ),link =linkGrowth(prior =prior_normal(0, 20) ))options("jmpost.prior_shrinkage"=0.99)# initialValues(joint_mod, n_chains = CHAINS)save_file <-here("session-pts/jm1.rds")if (file.exists(save_file)) { joint_results <-readRDS(save_file)} else { joint_results <-sampleStanModel( joint_mod,data = joint_data,iter_sampling = ITER,iter_warmup = WARMUP,chains = CHAINS,parallel_chains = CHAINS,thin = CHAINS,seed = BAYES.SEED,refresh = REFRESH )saveObject(joint_results, file = save_file)}```## Marginal Hazard Ratio EstimationFirst, we will try to apply the methodology from [Oudenhoven et al (2020)](https://onlinelibrary.wiley.com/doi/10.1002/sim.8713) to estimate the marginal hazard ratio, using our joint model results.We use the new `jmpost` function called `populationHR()` for this:```{r}#| label: calculate_marginal_hrsave_file <-here("session-pts/jm1hr.rds")if (file.exists(save_file)) { pop_hr <-readRDS(save_file)} else { pop_hr <-populationHR( joint_results,hr_formula =~arm )saveRDS(pop_hr, file = save_file)}pop_hr$summary```So here we have the marginal log hazard ratio estimates of the baseline spline components and the treatment arm.Therefore we can get the hazard ratio estimates by exponentiating the log hazard ratio estimates:```{r}#| label: exponentiate_hr_estimateshr_est <- pop_hr$summary["armMPDL3280A", ] |>sapply(exp)```So we get a marginal hazard ratio estimate of around `r round(hr_est["mean"], 3)` and a 95% credible interval of `r paste0(signif(hr_est[c("X2.5.", "X97.5.")], 2), collapse = " - ")`. ## Comparison with simple Cox PH resultsWe can do a little sanity check by comparing this with a very simple Cox model:```{r}#| label: cox_model_comparisoncox_mod <- survival::coxph(update(joint_results@data@survival@formula, ~ . + arm),data = joint_results@data@survival@data)summary(cox_mod)```Here we get a different HR estimate of around `r round(exp(coef(cox_mod)["armMPDL3280A"]), 2)`, which is lower than the one we got from the joint model. We should expect different results because the joint model takes into account the TGI effect, while the Cox model does not.And if we just put the treatment arm into the Cox model we get:```{r}#| label: cox_model_arm_onlycox_mod_arm <- survival::coxph(update(joint_results@data@survival@formula, . ~ arm),data = joint_results@data@survival@data)cox_mod_arm```Here we get a HR of `r round(exp(coef(cox_mod_arm)["armMPDL3280A"]), 3)` that is close to the marginal HR estimate above (when we did not condition on the other covariates), which is reassuring.On the other hand, the 95% confidence interval is:```{r}#| label: cox_confidence_intervalexp(confint(cox_mod_arm)["armMPDL3280A", ])```so actually overlaps the null hypothesis of 1. So we see that the joint model helped us to obtain a more precise estimate of the marginal hazard ratio.## PTS based on frequentist HR propertiesFrom now on we pretend that the data we have analyzed so far is the interim data of the Oak trial, and we want to calculate the predictive power of the trial based on this interim data.Let's first try to use the log HR ($\delta$) frequentist distribution to calculate the predictive power of the trial. We have:$$\hat{\delta}_{\text{fin}} \vert \hat{\delta}_{\text{int}} \sim\text{Normal}(\hat{\delta}_{\text{int}}, \sigma^2_{\text{int}}) $$where the predictive variance is given by$$\sigma^2_{\text{int}} = \frac{1}{4\overline{p}r(1-r)} \frac{\overline{m}}{\overline{n} (\overline{n} + \overline{m})}$$On the other hand, assuming that we have observed the final data, then the variance estimate for the estimator $\hat{\delta}_{\text{fin}}$ is given by:$$\sigma^2_{\text{fin}} = \frac{1}{4\overline{p}r(1-r)} \frac{1}{\overline{n} + \overline{m}}$$So the $(1-\alpha)$-confidence interval for the final log HR is given by:$$\hat{\delta}_{\text{fin}} \pm z_{1 - \alpha/2} \sigma_{\text{fin}}$$and the null hypothesis is rejected if the upper bound of this confidence interval is below 0, i.e. if:$$\hat{\delta}_{\text{fin}} + z_{1 - \alpha/2} \sigma_{\text{fin}} < 0.$$Now let's again take the perspective that we are at the interim analysis and we want to calculate the predictive probability of this event, then:$$\begin{align*}\mathbb{P}(\hat{\delta}_{\text{fin}} + z_{1 - \alpha/2} \sigma_{\text{fin}} < 0)&= \mathbb{P}(\hat{\delta}_{\text{fin}} < - z_{1 - \alpha/2} \sigma_{\text{fin}}) \\&= \mathbb{P}\left(\frac{\hat{\delta}_{\text{fin}} - \hat{\delta}_{\text{int}}}{\sigma_{\text{int}}} <\frac{- z_{1 - \alpha/2} \sigma_{\text{fin}} - \hat{\delta}_{\text{int}}}{\sigma_{\text{int}}}\right) \\&= \Phi\left( \frac{- z_{1 - \alpha/2} \sigma_{\text{fin}} - \hat{\delta}_{\text{int}}}{\sigma_{\text{int}}}\right)\end{align*}$$because the left-hand side is a standard normal variable according to our formula above.Let's first write a little function to calculate this:```{r}#| label: pts_freq_hr_functionpts_freq_hr <-function(delta_int, var_int, var_final, alpha =0.05) { z_alpha <-qnorm(1- alpha /2) delta_final <--z_alpha *sqrt(var_final)pnorm((delta_final - delta_int) /sqrt(var_int))}```For $\hat{\delta}_{\text{int}}$ we are going to plug in our MCMC samples for the marginal log HR $\hat{\delta}_{\text{int}}$ to compute the PTS based on this:```{r}#| label: extract_log_hr_sampleslog_hr_samples <- pop_hr[[2]]["armMPDL3280A", ]```But first we also need the quantities that go into the variance formula:- $\overline{p}$: average event rate- $r$: proportion of patients in the treatment arm- $\overline{n}$: average arm size in interim data- $\overline{m}$: average arm size in follow upSo let's calculate these and the resulting variances:```{r}#| label: calculate_variance_quantitiesavg_event_rate <-mean(os_data$os_event)prop_pts_treatment <-mean(os_data$arm =="MPDL3280A")avg_arm_size_interim <-mean(table(os_data$arm))total_size_final <-850avg_arm_size_followup <- (total_size_final -2* avg_arm_size_interim) /2var_int <-1/ (4* avg_event_rate * prop_pts_treatment * (1- prop_pts_treatment)) * avg_arm_size_followup / (avg_arm_size_interim * (avg_arm_size_interim + avg_arm_size_followup))var_final <-1/ (4* avg_event_rate * prop_pts_treatment * (1- prop_pts_treatment)) *1/ (avg_arm_size_interim + avg_arm_size_followup)```Now we can calculate the PTS based on the frequentist HR properties:```{r}#| label: calculate_pts_freq_hrpts_freq_hr_result <-mean(pts_freq_hr(delta_int = log_hr_samples,var_int = var_int,var_final = var_final))pts_freq_hr_result```So we obtain a PTS of `r round(pts_freq_hr_result * 100, 1)`% here.## PTS based on frequentist log-rank test propertiesNow we want to leverage the `rpact` function [`getConditionalPower()`](https://rpact-com.github.io/rpact/reference/getConditionalPower.html) to calculate the predictive power of the trial based on the log-rank test statistic properties.Looking at the function's example, we first have to create a `DataSet` object with the interim data.Here it is important that we for `cumLogRanks` also the z scores from a Cox regression can be used, which means for our case that can insert here the z-scores defined as:$$z = \frac{\hat{\delta}_{\text{int}}}{\sigma_{\text{int}}}$$values.But let's first try this with a simple fixed $z$ value to see how it works:```{r}#| label: rpact_dataset_examplelibrary(rpact)z <-log(0.9) /sqrt(var_int) # Instead of log(0.9) we put later the marginal log HR MCMC sampledata <-getDataset(cumEvents =sum(os_data$os_event),cumLogRanks = z,cumAllocationRatios = prop_pts_treatment)data```This is the data set from the first stage, i.e. our interim analysis.Now we need to define the design of the group sequential trial:```{r}#| label: define_group_sequential_designevents_final <- total_size_final * avg_event_ratedesign <-getDesignGroupSequential(kMax =2,informationRates =c(sum(os_data$os_event) / events_final, 1),alpha =0.05)design```We see that the significance level is almost full for the final analysis, because by default the O'Brien & Fleming design is used, which only assigns a very small $\alpha$ to the interim analysis. This is kind of what we need here.Finally we put the `design` and `data` in a `StageResults` object, which is the input for the `getConditionalPower()` function:```{r}#| label: calculate_conditional_powerstageResults <-getStageResults(design = design,dataInput = data,stage =1,directionUpper =FALSE)condPower <-getConditionalPower(stageResults = stageResults,thetaH1 =0.9, # Here we put later the marginal hazard ratio MCMC samplenPlanned = total_size_final,allocationRatioPlanned =1)summary(condPower)condPower$conditionalPower[2]```OK so now that we know how it works we wrap this in a little function again.```{r}#| label: pts_freq_hr2_functionpts_freq_hr2 <-function( delta_int, var_int, var_final, events_int, alloc_int, events_final,alpha =0.05) { data <-getDataset(cumEvents = events_int,cumLogRanks = delta_int /sqrt(var_int),cumAllocationRatios = alloc_int ) design <-getDesignGroupSequential(kMax =2,informationRates =c(events_int / events_final, 1),alpha = alpha ) stageResults <-getStageResults(design = design,dataInput = data,stage =1,directionUpper =FALSE ) condPower <-getConditionalPower(stageResults = stageResults,thetaH1 =exp(delta_int),nPlanned = total_size_final,allocationRatioPlanned =1 ) condPower$conditionalPower[2]}```Let's try it out with the same example first again:```{r}#| label: test_pts_freq_hr2pts_freq_hr2(delta_int =log(0.9),var_int = var_int,var_final = var_final,events_int =sum(os_data$os_event),alloc_int = prop_pts_treatment,events_final = total_size_final * avg_event_rate)```OK so that gives the same result as before, which is good. Now we can plug in the MCMC samples for the marginal log HR:```{r}#| label: calculate_pts_freq_hr2save_file <-here("session-pts/jm1hr2.rds")if (file.exists(save_file)) { pts_freq_hr2_samples <-readRDS(save_file)} else { pts_freq_hr2_samples <-sapply( log_hr_samples, pts_freq_hr2,var_int = var_int,var_final = var_final,events_int =sum(os_data$os_event),alloc_int = prop_pts_treatment,events_final = total_size_final * avg_event_rate )saveRDS(pts_freq_hr2_samples, file = save_file)}pts_freq_hr2_result <-mean(pts_freq_hr2_samples)pts_freq_hr2_result```So here we get a PTS of `r round(pts_freq_hr2_result * 100, 1)`%, which is higher than the one we got before.## Individual Samples based Predictive PowerThis is a good motivation to try out the conditional sampling approach to calculate the predictive power, which is based on the individual samples of the joint model. It does not involved any frequentist assumptions to calculate the PTS.We will perform the following steps in this algorithm:1. We add as many new patients to the survival data set as we expect to still enter the trial. - These patients have survival time 0 and are censored at that time, which means they are completely new patients.1. We refit the joint model with this augmented data set. This simplifies the subsequent sampling process.1. For each patient who is still being followed up for OS at IA (=does not have an event yet and did not drop out) - Obtain individual survival distribution MCMC samples over a long enough time grid - Condition on last observed censored OS time, sample once per MCMC sample from the conditional survival distribution1. Define "End of Study" time, e.g. based on number of events observed, calendar time, etc.1. Apply summary statistic of interest to the complete "End of Study" OS data set samples and aggregate appropriately - For example, we can use the log-rank test statistic as summary statistic1. Calculate the proportion of samples that are above the critical value for the summary statistic, e.g. log-rank test statisticNote that for the first step, we need to add some random information to the data set, because unfortunately the real data does not differentiate between patients who are still being followed up and those who have dropped out.### Add new patients and generate random lost-to-follow-up flagBasically for those patients with a censored survival time, we randomly assign a lost-to-follow-up flag with a probability of 10%, so that we can simulate some patients who are still being followed up and some who are not. We sample the covariates from the distribution of the already observed patients.```{r}#| label: generate_lost_to_follow_upset.seed(689)lost_to_follow_up_flag <-sample(c(TRUE, FALSE),size =nrow(os_data),replace =TRUE,prob =c(0.1, 0.9))os_data <- os_data |>mutate(lost_to_follow_up =!os_event & lost_to_follow_up_flag)n_lost_to_follow_up <-sum(os_data$lost_to_follow_up)n_lost_to_follow_upset.seed(359)n_new_patients <-2* avg_arm_size_followupos_data_new <-data.frame(id =seq(from =max(as.integer(as.character(os_data$id))) +1, length.out = n_new_patients),arm =rep(c("Docetaxel", "MPDL3280A"), c(floor(avg_arm_size_followup), ceiling(avg_arm_size_followup))),os_time =0,os_event =FALSE,lost_to_follow_up =FALSE,ecog =sample(os_data$ecog, n_new_patients, replace =TRUE),age =sample(os_data$age, n_new_patients, replace =TRUE),race =sample(os_data$race, n_new_patients, replace =TRUE),sex =sample(os_data$sex, n_new_patients, replace =TRUE))os_data_augmented <- os_data[, names(os_data_new)] |>mutate(id =as.character(id)) |>rbind(os_data_new) |>mutate(id =factor(id))n_censored <-sum(!os_data_augmented$os_event &!os_data_augmented$lost_to_follow_up)n_censored```So now we have `r n_lost_to_follow_up` patients who have been lost to follow-up and `r n_censored` patients who are still being followed up for OS (or who will still be entering the trial). These `r n_censored` patients will be the ones we will perform the individual sampling for.### Refit the joint model with augmented dataFirst we need to refit the joint model to this augmented data set. That is the easiest way to also get survival curve samples for the patients who are yet to be enrolled in the trial.We notice that here we also need to add at least some random baseline samples to the longitudinal data set, otherwise it does not work.```{r}#| label: refit_joint_model_augmentedsubj_aug_df <- os_data_augmented |>mutate(study ="OAK") |>select(study, id, arm)subj_aug_data <-DataSubject(data = subj_aug_df,subject ="id",arm ="arm",study ="study")long_df <- tumor_data |>select(id, year, sld)new_long_df <- os_data_new |>select(id, os_time) |>rename(year = os_time) |>mutate(sld =sample(tumor_data$sld, n_new_patients, replace =TRUE))long_aug_df <- long_df |>mutate(id =as.character(id)) |>rbind(new_long_df) |>mutate(id =factor(id))stopifnot(identical(levels(long_aug_df$id), levels(subj_aug_df$id)))long_aug_data <-DataLongitudinal(data = long_aug_df,formula = sld ~ year)surv_aug_data <-DataSurvival(data = os_data_augmented,formula =Surv(os_time, os_event) ~ ecog + age + race + sex)joint_aug_data <-DataJoint(subject = subj_aug_data,longitudinal = long_aug_data,survival = surv_aug_data)save_file <-here("session-pts/jm2.rds")if (file.exists(save_file)) { joint_aug_results <-readRDS(save_file)} else { joint_aug_results <-sampleStanModel( joint_mod,data = joint_aug_data,iter_sampling = ITER,iter_warmup = WARMUP,chains = CHAINS,parallel_chains = CHAINS,thin = CHAINS,seed = BAYES.SEED,refresh = REFRESH )saveObject(joint_aug_results, file = save_file)}```### Individual Sampling of OS eventsNow we can perform the individual sampling of the OS events for the patients who are still being followed up. Let's start with a single patient:```{r}#| label: individual_sampling_single_patientpatient_id <- os_data_augmented |>filter(!os_event &!lost_to_follow_up) |>pull(id) |>unique() |>head(1)```So for this patient `r patient_id`, we can extract the individual survival distribution MCMC samples from the joint model results along a time grid that extrapolates long enough into the future, say 10 years after the last observed OS time:```{r}#| label: extract_individual_survival_samples#| cache.lazy: false#| output: falselength_time_grid <-100time_grid_end <-round(max(os_data_augmented$os_time) +10, 1)time_grid <-seq(0, time_grid_end, length = length_time_grid)save_file <-here("session-pts/jm2_surv_samples.rds")if (file.exists(save_file)) { os_surv_samples <-readRDS(save_file)} else { os_surv_samples <-SurvivalQuantities(object = joint_aug_results,grid =GridFixed(times = time_grid),type ="surv" )saveRDS(os_surv_samples, file = save_file)}os_surv_samples_df <-as.data.frame(os_surv_samples)os_surv_samples_patient <- os_surv_samples_df |>filter(group == patient_id) |>select(-group) |>mutate(sample_id =rep(1:ITER, length_time_grid))head(os_surv_samples_patient)```Now let's zoom in on the first sampled survival curve of this patient, where we show also the last observed OS time as a vertical dashed line:```{r}#| label: plot_individual_survival_curvefirst_surv_sample <- os_surv_samples_patient |>filter(sample_id ==1)patient_last_os_time <- os_data_augmented$os_time[os_data_augmented$id == patient_id]first_surv_sample_plot <-ggplot(first_surv_sample, aes(x = time, y = values)) +geom_line() +labs(title =paste("Individual Survival Curve for Patient", patient_id),x ="Time (years)",y ="Survival Probability" ) +geom_vline(xintercept = patient_last_os_time,linetype ="dashed",color ="red" ) +theme_minimal()first_surv_sample_plot```As described in the slides, we can now sample a survival time for this patient from the conditional survival distribution, given the last observed OS time, by:1. Drawing a standard uniform random number $p \sim U(0, 1)$1. Finding the time $t$ such that $(1-p)S(c) - S(t) = 0$ where $c = `r round(patient_last_os_time, 1)` is the last observed OS time```{r}#| label: sample_conditional_survival_timeset.seed(123)p <-runif(1)# Linear approximation function for the survival function:surv_approx <-approxfun(x = first_surv_sample$time,y = first_surv_sample$values,rule =2# extrapolation outside interval via closest data extreme)# Survival function value at the time of censoring:surv_at_censoring <-surv_approx(patient_last_os_time)# Find the time t such that (1-p) * S(c) - S(t) = 0:t <-uniroot(function(t) (1- p) * surv_at_censoring -surv_approx(t),interval =c(0, time_grid_end))$roott```We can plot the conditional survival function together with this sampled survival time:```{r}first_surv_sample <- first_surv_sample |>mutate(cond_values =ifelse( time <= patient_last_os_time,1, values / surv_at_censoring ) )first_cond_surv_sample_plot <-ggplot( first_surv_sample,aes(x = time, y = cond_values)) +geom_line() +labs(title =paste("Individual Conditional Survival Curve for Patient", patient_id ),x ="Time (years)",y ="Survival Probability" ) +geom_vline(xintercept = patient_last_os_time,linetype ="dashed",color ="red" ) +theme_minimal() +geom_vline(xintercept = t,linetype ="dotted",color ="blue" ) +geom_hline(yintercept =1- p,linetype ="dotted",color ="blue" ) +geom_point(data =data.frame(t = t, p = p),aes(x = t, y =1- p),color ="blue",size =5 )first_cond_surv_sample_plot```The challenge is now to do this efficiently for all patients who are still being followed up for OS, and for all of their MCMC samples.### Simplified Rcpp implementation for single patient/sampleFor now, let's create a simpler `Rcpp` function that handles a single patient's single MCMC sample:```{r}#| label: simple_rcpp_conditional_samplinglibrary(Rcpp)# Use Rcpp function for single patient, single sample.sourceCpp(here("session-pts/conditional_sampling.cpp"))# Test the function with a simple example.set.seed(3453)t_cpp_simple <-sample_single_conditional_survival_time(time_grid = first_surv_sample$time,surv_values = first_surv_sample$values,censoring_time = patient_last_os_time)first_cond_surv_sample_plot +geom_point(data =data.frame(t = t_cpp_simple$t_result,p = t_cpp_simple$uniform_sample ),aes(x = t, y =1- p),color ="blue",size =5 )```### Rcpp implementation for all patients and samplesLet's use the second `Rcpp` function that handles all patients and all MCMC samples at once.We just need to prepare the data in the right format:- The first argument is the time grid, which we already have.- The second argument is a matrix of survival values, where each row is a sample/patient combination and each column is a time point.- The third argument is a vector of censoring times, where each element corresponds to a sample/patient combination.```{r}#| label: rcpp_conditional_sampling_all# First subset to the patients where we need to sample from the conditional survival distribution.pt_to_sample <- os_data_augmented |>filter(!os_event &!lost_to_follow_up)pt_to_sample_ids <- pt_to_sample$idqs <- os_surv_samples@quantitiesinclude_qs <- qs@groups %in% pt_to_sample_idsqs_samples <- qs@quantities[, include_qs]qs_times <- qs@times[include_qs]qs_groups <- qs@groups[include_qs]surv_values_samples <-matrix( qs_samples,nrow = ITER *length(pt_to_sample_ids),ncol = length_time_grid)dim(surv_values_samples)surv_values_pt_ids <-rep(head(qs_groups, length(pt_to_sample_ids)),each = ITER)censoring_times <- pt_to_sample$os_time[match( surv_values_pt_ids, pt_to_sample_ids)]length(censoring_times)cond_surv_time_samples <-sample_conditional_survival_times(time_grid = time_grid,surv_values = surv_values_samples,censoring_times = censoring_times)mean(cond_surv_time_samples$beyond_max_time)summary(cond_surv_time_samples$t_results)hist(cond_surv_time_samples$uniform_samples)os_cond_samples <-matrix( cond_surv_time_samples$t_results,nrow = ITER,ncol =length(pt_to_sample_ids),dimnames =list(seq_len(ITER), head(qs_groups, length(pt_to_sample_ids))))```So this function is very fast, and in this case we have less than 1% of the samples where we did not have a long enough time grid, which seems sufficient for our purposes.### Generating OS data set samplesNow that we have the OS events for the patients who are still being followed up, we can generate the complete OS data set samples for each MCMC sample.```{r}#| label: generate_os_data_samples# Where do we need to replace the OS times?os_rows_to_replace <-match( pt_to_sample_ids, os_data_augmented$id)# From which column do we take the sampled OS times?os_cond_samples_cols <-match( pt_to_sample_ids,colnames(os_cond_samples))os_data_samples <-lapply(1:ITER, function(i) {# Create a copy of the original OS data os_data_sample <- os_data_augmented[, c("id", "arm", "os_time", "os_event", "race", "sex", "ecog", "age")]# Add the sampled OS times for the patients who are still being followed up. os_data_sample$os_time[os_rows_to_replace] <- os_cond_samples[ i, os_cond_samples_cols ]# Set the OS event to TRUE for those patients, because we assume they have an event now. os_data_sample$os_event[os_rows_to_replace] <-TRUE os_data_sample})```### Calculate log rank statistic for each sampled OS data setNow it is easy to calculate the log-rank test statistic for each sampled OS data set.Here we don't adjust for any covariates.```{r}#| label: calculate_log_rank_statisticlog_rank_stats_fun <-function(df) { surv_diff <- survival::survdiff(Surv(os_time, os_event) ~ arm,data = df ) surv_diff$chisq}log_rank_stats <-sapply(os_data_samples, log_rank_stats_fun)hist(log_rank_stats)log_rank_at_ia <-log_rank_stats_fun(os_data_augmented)log_rank_at_iaabline(v = log_rank_at_ia, col ="red", lwd =2)critical_value <-qchisq(0.95, df =1)abline(v = critical_value, col ="blue", lwd =2)```And using the critical value of the log-rank test statistic for a significance level of 0.05, we can calculate the PTS:```{r}#| label: calculate_pts_log_rankpts_log_rank <-mean(log_rank_stats > critical_value)pts_log_rank```So we see that this PTS with `r round(pts_log_rank * 100, 1)`% is a bit lower than the previous PTS estimates. ### Hazard Ratio based PTSWe can also calculate the PTS in an analogous way using the hazard ratio estimates:```{r}#| label: calculate_pts_hrhr_pval_fun <-function(df) { cox_mod <- survival::coxph(Surv(os_time, os_event) ~ arm + ecog + age + race + sex,data = df )summary(cox_mod)$coefficients[1, 5]}hr_pvals <-sapply(os_data_samples, hr_pval_fun)pts_hr <-mean(hr_pvals <0.05)pts_hr```In this PTS estimate we can adjust for the covariates. We see that the result of `r round(pts_hr * 100, 1)`% is in line with the frequentist based PTS estimate for the HR which we calculated earlier.### Plot Kaplan-Meier curves predictionsSimilarly we can extract the Kaplan-Meier curves for each sampled OS data set and plot them:```{r}#| label: plot_kaplan_meier_curveskm_curves <-lapply(os_data_samples, function(df) { survfit_obj <- survival::survfit(Surv(os_time, os_event) ~ arm, data = df) times <- survfit_obj$time surv_probs <- survfit_obj$surv strata <- survfit_obj$strata n_control <- strata[1] n_treatment <- strata[2] control_fun <-stepfun( times[1:n_control],c(1, surv_probs[1:n_control]),right =TRUE ) treatment_fun <-stepfun( times[(n_control +1):length(times)],c(1, surv_probs[(n_control +1):length(surv_probs)]),right =TRUE ) control_vals <-control_fun(time_grid) treatment_vals <-treatment_fun(time_grid)list(control = control_vals, treatment = treatment_vals)})km_control_samples <-sapply(km_curves, function(x) x$control)km_treatment_samples <-sapply(km_curves, function(x) x$treatment)survfit_ia <- survival::survfit(Surv(os_time, os_event) ~ arm, data = os_data_augmented)plot(survfit_ia, col =c(1, 2), xlim =c(0, max(time_grid)))# Add the mean KM curves for each arm.lines(time_grid, rowMeans(km_control_samples), col =1)lines(time_grid, rowMeans(km_treatment_samples), col =2)# Add pointwise confidence intervals as shaded areas.km_control_ci <-apply(km_control_samples, 1, quantile, probs =c(0.025, 0.975))km_treatment_ci <-apply( km_treatment_samples,1, quantile,probs =c(0.025, 0.975))polygon(c(time_grid, rev(time_grid)),c(km_control_ci[1, ], rev(km_control_ci[2, ])),col =adjustcolor(1, alpha.f =0.2),border =NA)polygon(c(time_grid, rev(time_grid)),c(km_treatment_ci[1, ], rev(km_treatment_ci[2, ])),col =adjustcolor(2, alpha.f =0.2),border =NA)```We can see that we assume a very long follow up of the data here.Obviously we could cut the data sets at a certain number of events or a specific time point easily.