Skip to contents

In this vignette we illustrate how to use the DoseFinding package with time-to-event (survival) endpoints. We show how to simulate a dose-finding trial, how to apply the generalized MCP-Mod methodology to the log hazard ratios estimated from a Cox proportional hazards model, and how to perform power and sample size calculations following Deng et al. (2019).

For continuously distributed data see the analysis of normal data vignette, and for binary data the binary data vignette.

library(DoseFinding)
library(ggplot2)
library(survival)
## set seed and ensure reproducibility across R versions
set.seed(2010, kind = "Mersenne-Twister", sample.kind = "Rejection", normal.kind = "Inversion")

Example trial

We will first generate an appropriate dataset that we can use to illustrate the analysis of time-to-event data.

Assume a dose-finding study is planned for a hypothetical investigational treatment. The endpoint of interest is the time to a certain event. The treatment is tested with doses 0 (placebo), 0.05, 0.2, 0.5 and 1, with an allocation ratio of 2:1:1:1:2. The larger allocation to placebo and highest dose is chosen to increase the power. It is assumed that the hazard rate for the placebo group is \(\lambda_0 = 0.6\) and that the hazard ratio for the maximum effect over the dose range is \(\text{HR} = 0.5\). This results in a hazard rate of \(\lambda_1 = 0.3\).

We define the parameters for the example just described:

doses <- c(0, 0.05, 0.2, 0.5, 1) # placebo and 4 treatment groups
lambda0 <- 0.6 # yearly hazard rate for the placebo group
HR1 <- 0.5 # hazard ratio for the treatment effect at the highest dose
lambda1 <- lambda0 * HR1 # hazard rate for the highest dose
alRatio <- c(2, 1, 1, 1, 2) # allocation ratio across the dose groups

We use the following candidate set of dose-response models for the mean response: an \(\text{E}_{\text{max}}\), a sigmoidal \(\text{E}_{\text{max}}\), a quadratic and an exponential model.

In the survival context a decreasing response (a lower hazard) typically corresponds to a beneficial treatment effect, so we specify direction = "decreasing".

The dose-response models are placed on the log hazard scale, because dose-response models in the DoseFinding package assume additive effects. Consequently:

  • placEff is the log hazard rate of the placebo arm, i.e. \(\log(\lambda_0)\).
  • maxEff is the maximum treatment effect within the dose range, i.e. the difference between the response at the highest dose and placEff. On the log hazard scale this equals the log hazard ratio, since \[ \log(\text{HR}) = \log(\lambda_1) - \log(\lambda_0). \]
mods <- Mods(
  emax = c(0.1, 0.5),
  sigEmax = c(0.5, 4),
  quadratic = -0.9,
  exponential = 0.2,
  doses = doses,
  direction = "decreasing", # decreasing (lower hazard) is beneficial
  placEff = log(lambda0), # log hazard rate of the placebo arm
  maxEff = log(lambda1) - log(lambda0) # log hazard ratio at the highest dose
)
plotMods(mods, superpose = TRUE, ylab = "log(hazard rate)")

To illustrate the methodology we simulate a survival data set based on the trial assumptions and one of our assumed candidate models. The function below draws survival times from an exponential distribution under one of the candidate models. It supports

  • event-driven censoring: the study is run until a total of etotal events have been observed, and
  • optional staggered study entry: an entry (calendar) time is added to each patient’s event time, the data are censored on the calendar-time scale at the time of the etotal-th event, and the observed follow-up time is then expressed on the study-time scale.
#' @param mods `Mods` object defining the candidate models to simulate from
#' @param model name or index of the model whose hazards are used to simulate
#'    (refers to column names or column indices of getResp(mods))
#' @param alRatio allocation ratio across the dose groups
#' @param n total sample size
#' @param etotal number of events at which event-driven censoring is applied
#' @param staggered logical, whether to use staggered study entry
#' @return data.frame with columns group, time, status
simulate_surv_data <- function(mods, model, alRatio, n, etotal,
                               staggered = FALSE) {
  doses <- attr(mods, "doses")
  y <- getResp(mods)
  lambda0 <- exp(attr(mods, "placEff"))
  ## assign each patient to a dose group (log hazard rate) according to alRatio
  log_rate_resp <- rep(y[, model], rndDesign(alRatio/sum(alRatio), n))

  ## entry (calendar) times
  entry <- if (staggered) {
    runif(n, 0, qexp(etotal / n, lambda0))
  } else {
    rep(0, n)
  }

  ## event times on the calendar-time scale
  event <- entry + rexp(n, exp(log_rate_resp))

  ## event-driven censoring at the time of the etotal-th event
  t_cut <- sort(event)[etotal]
  status <- as.numeric(event <= t_cut)
  obs_time <- pmin(event, t_cut) - entry # follow-up on the study-time scale

  data.frame(
    group = factor(names(log_rate_resp), levels = as.character(doses)),
    time = obs_time,
    status = status
  )
}

We simulate a trial of 300 patients using the hazards from the \(\text{E}_{\text{max}}\) model (model = 1) and assuming we will require 130 events.

n <- 300
dat <- simulate_surv_data(
  mods, model = 1, alRatio = alRatio, n = n, etotal = 130,
  staggered = TRUE
)
head(dat)
  group       time status
1     0 1.29867714      1
2     0 0.16112692      1
3     0 0.04348961      1
4     0 1.08575291      0
5     0 0.44484421      1
6     0 1.32996990      0

We can use a Kaplan-Meier plot to visualize the survival times by dose.

km <- survfit(Surv(time, status) ~ group, data = dat)
plot(km, col = seq_along(doses), lwd = 2,
     xlab = "Years", ylab = "Survival probability",
     main = "Kaplan-Meier estimator by dose")
legend("topright", legend = paste("dose", doses),
       col = seq_along(doses), lwd = 2, bty = "n")

Analysis with MCP-Mod

We will now walk through the typical steps of a MCP-Mod analysis using our simulated trial data.

MCP step

The generalized MCP-Mod approach requires an estimate \(\hat\mu\) of the (in this case placebo-adjusted) dose-group effects together with its covariance matrix \(\hat S\). We obtain both from a Cox proportional hazards model, which expresses the hazard as \[ \lambda(t \mid \text{dose}) = \lambda_0(t)\exp(\beta_1 x_1 + \dots + \beta_p x_p), \] where the coefficients \(\beta\) are the log hazard ratios of each dose group relative to placebo.

coxfit <- coxph(Surv(time, status) ~ group, data = dat)
summary(coxfit)
Call:
coxph(formula = Surv(time, status) ~ group, data = dat)

  n= 300, number of events= 130 

             coef exp(coef) se(coef)      z Pr(>|z|)   
group0.05 -0.3513    0.7038   0.2727 -1.288  0.19775   
group0.2  -0.3868    0.6792   0.2776 -1.393  0.16355   
group0.5  -0.5437    0.5806   0.2899 -1.875  0.06074 . 
group1    -0.6632    0.5152   0.2344 -2.829  0.00466 **
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

          exp(coef) exp(-coef) lower .95 upper .95
group0.05    0.7038      1.421    0.4124    1.2011
group0.2     0.6792      1.472    0.3942    1.1704
group0.5     0.5806      1.722    0.3289    1.0248
group1       0.5152      1.941    0.3254    0.8156

Concordance= 0.569  (se = 0.025 )
Likelihood ratio test= 9.02  on 4 df,   p=0.06
Wald test            = 9.18  on 4 df,   p=0.06
Score (logrank) test = 9.42  on 4 df,   p=0.05
coef_cox <- coef(coxfit) # placebo-adjusted log hazard ratios
S_cox <- vcov(coxfit) # covariance matrix of the estimates

We now test for a dose-response signal with MCTtest(). Since the coxfit model returns placebo-adjusted estimates (there is no placebo coefficient), we use placAdj = TRUE and drop the placebo dose from dose, we test one-sided.

mct <- MCTtest(
  dose = doses[-1], # placebo-adjusted, so drop placebo
  resp = coef_cox,
  S = S_cox,
  models = mods,
  type = "general",
  alternative = "one.sided",
  placAdj = TRUE
)
mct
Multiple Contrast Test

Contrasts:
      emax1  emax2 sigEmax quadratic exponential
0.05  0.150  0.263   0.254     0.217       0.195
0.2  -0.246 -0.024   0.218    -0.406       0.175
0.5  -0.390 -0.286  -0.164    -0.880       0.112
1    -0.875 -0.921  -0.928    -0.116      -0.959

Contrast Correlation:
            emax1 emax2 sigEmax quadratic exponential
emax1       1.000 0.958   0.810     0.786       0.680
emax2       0.958 1.000   0.940     0.659       0.832
sigEmax     0.810 0.940   1.000     0.414       0.936
quadratic   0.786 0.659   0.414     1.000       0.131
exponential 0.680 0.832   0.936     0.131       1.000

Multiple Contrast Test:
            t-Stat   adj-p
emax1        2.962 0.00431
emax2        2.856 0.00643
sigEmax      2.510 0.01646
exponential  2.192 0.03639
quadratic    2.112 0.04414

The output reports, per candidate model, the optimal-contrast \(t\)-statistics together with multiplicity-adjusted \(p\)-values. Small adjusted \(p\)-values give evidence for a dose-response relationship. If we would have specified a significance level of 0.025 one-sided, then we can consider the null hypothesis of no dose-response effect versus placebo is rejected since several of the adjusted p-values are smaller than 0.025.

Dose-response estimation

Dose-response modeling can then proceed with a combination of bootstrapping and model averaging. For detailed explanations refer to the vignette for analysis of continuous data. Fitting is done on the log-hazard ratio scale so we are using placebo-adjusted models. Since maFitMod does not support placebo-adjusted dose-response estimation we need to define a couple of helper functions to perform the model averaging.

one_bootstrap_prediction <- function(mu_hat, S_hat, doses, bounds, dose_seq) {
  sim <- drop(mvtnorm::rmvnorm(1, mu_hat, S_hat))
  fit <- lapply(c("emax", "sigEmax", "exponential", "quadratic"), function(mod)
    fitMod(doses, sim, model = mod, S = S_hat, type = "general", bnds = bounds[[mod]], placAdj = TRUE))
  index <- which.min(sapply(fit, gAIC))
  pred <- predict(fit[[index]], doseSeq = dose_seq, predType = "effect-curve")
  return(pred)
}

## bs_predictions is a doses x replications matrix,
## probs is a 4-element vector of increasing probabilities for the quantiles
summarize_predictions <- function(bs_predictions, probs) {
  stopifnot(length(probs) == 4)
  med <- apply(bs_predictions, 1, median)
  quants <- apply(bs_predictions, 1, quantile, probs = probs)
  bs_df <- as.data.frame(cbind(med, t(quants)))
  names(bs_df) <- c("median", "low_out", "low_in", "high_in", "high_out")
  return(bs_df)
}

predict_and_plot <- function(mu_hat, S_hat, doses, dose_seq, n_rep) {
  bs_rep <- replicate(
    n_rep, one_bootstrap_prediction(mu_hat, S_hat, doses, defBnds(max(doses)), dose_seq))
  bs_summary <- summarize_predictions(bs_rep, probs = c(0.025, 0.25, 0.75, 0.975))
  bs_summary <- as.data.frame(exp(bs_summary)) # back to hazard ratio scale
  ci_half_width <- qnorm(0.975) * sqrt(diag(S_hat))
  cox_summary <- data.frame(dose = doses, mu_hat = exp(mu_hat),
                            low = exp(mu_hat - ci_half_width),
                            high = exp(mu_hat + ci_half_width))
  gg <- ggplot(cbind(bs_summary, dose_seq = dose_seq)) + geom_line(aes(dose_seq, median)) +
    geom_ribbon(aes(x = dose_seq, ymin = low_in, ymax = high_in), alpha = 0.2) +
    geom_ribbon(aes(x = dose_seq, ymin = low_out, ymax = high_out), alpha = 0.2) +
    geom_point(aes(dose, mu_hat), cox_summary) +
    scale_y_continuous(breaks = seq(0.5, 1.5, 0.25)) + 
    geom_errorbar(aes(dose, ymin = low, ymax = high), cox_summary, width = 0, alpha = 0.5) +
    xlab("Dose") + ylab("Hazard ratio") +
    labs(title = "Bootstrap estimates for population hazard ratio",
         subtitle = "confidence levels 50% and 95%")
  return(gg)
}

We then use these functions to plot the estimated dose-response curve together with the Cox model estimates on the original hazard ratio scale.

predict_and_plot(coef_cox, S_cox, doses[-1], dose_seq = seq(0, 1, 0.01), 1000)

Power and sample size calculations at design stage

For power and sample size calculations in generalized MCP-Mod we need to make an assumption about the covariance matrix \(S\) of the placebo-adjusted estimates under the alternative. Deng et al. (2019) derive an approximation for the covariance matrix of the estimated log hazard ratios for a Cox model which we will use in the following.

Let \(n_k\) be the number of patients in dose group \(k\) (\(k = 0\) for placebo), \(D\) the total number of events, and \(\beta_k\) the log hazard ratio of dose group \(k\) vs. placebo (with \(\beta_0 = 0\)). Then, with \[ p_k = \frac{(n_k/n_0)\exp(\beta_k)}{1 + \sum_{i=1}^{K}(n_i/n_0)\exp(\beta_i)}, \] the covariance matrix \(S\) of \(\hat\beta = (\hat\beta_1, \dots, \hat\beta_K)\) is approximately \[ S \approx \frac{1}{D} \begin{pmatrix} p_1^{-1} + p_0^{-1} & p_0^{-1} & \cdots & p_0^{-1}\\ p_0^{-1} & p_2^{-1} + p_0^{-1} & \cdots & p_0^{-1}\\ \vdots & \vdots & \ddots & \vdots\\ p_0^{-1} & p_0^{-1} & \cdots & p_K^{-1} + p_0^{-1} \end{pmatrix}. \]

Since this matrix depends on the total number of events rather than the total number of patients, this facilitates the use of event-driven dose-finding designs. Under the null hypothesis of no dose-response effect versus placebo (\(\beta_k = 0\) for all \(k\)) the covariance matrix simplifies to a matrix \(S_0\) that depends only on the allocation ratios to each arm and the total number of events, and is used to obtain the critical value for the multiple contrast test.

The following function calculates both \(S\) (one per candidate model) and \(S_0\). Following Deng et al. (2019) we evaluate \(S\) at half the assumed effect, 0.5 * beta, an intermediate value between the null and the alternative. Then using \(S\) and \(S_0\) we compute the power under each candidate model. The function below builds the optimal contrast for each model (using its own \(S\)), derives the critical value from \(S_0\), and evaluates the power for a given number of events with powMCT(). We also define a function that allows us to calculate the required number of events to achieve a target average power using binary search.

#' Power of the multiple contrast test for a time-to-event endpoint
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation weights for all treatment groups,
#'   including placebo.
#' @param etotal Positive number of events.
#'
#' @return A list containing:
#' - `power_table`: power under each candidate model and the average power;
#' - `power`: unrounded power under each candidate model;
#' - `power_av`: unrounded average power;
#' - `contMat`: matrix of optimal contrasts;
#' - `critV`: critical value for the multiple contrast test.
powMCT_TTE <- function(mods, alpha, alRatio, etotal) {
 
  y <- getResp(mods)
  doses <- attr(mods, "doses")
  n_group <- nrow(y)
  n_model <- ncol(y)
  n_active <- n_group - 1
  model_names <- colnames(y)
  if (is.null(model_names)) {
    model_names <- paste0("model", seq_len(n_model))
  }
  # Placebo-adjusted log hazard ratios.
  beta <- sweep(y, 2, y[1, ], FUN = "-")
  # Group probabilities evaluated at half the assumed treatment effect.
  weighted_hazards <- exp(0.5 * beta) * alRatio
  p <- sweep(
    weighted_hazards,
    2,
    colSums(weighted_hazards),
    FUN = "/"
  )
  # Covariance matrices under each candidate alternative.
  S <- array(
    0,
    dim = c(n_active, n_active, n_model),
    dimnames = list(
      as.character(doses[-1]),
      as.character(doses[-1]),
      model_names
    )
  )

  for (i in seq_len(n_model)) {
    p0 <- p[1, i]
    S_i <- matrix(
      1 / p0,
      nrow = n_active,
      ncol = n_active
    )
    diag(S_i) <- 1 / p[-1, i] + 1 / p0
    S[, , i] <- S_i / etotal
  }
  # Covariance matrix under the null hypothesis.
  p_null <- alRatio / sum(alRatio)
  p0_null <- p_null[1]
  S0 <- matrix(
    1 / p0_null,
    nrow = n_active,
    ncol = n_active
  )
  diag(S0) <- 1 / p_null[-1] + 1 / p0_null
  S0 <- S0 / etotal

  # Obtain the optimal contrast for each candidate model using the
  # covariance matrix associated with that model.
  contMat <- vapply(
    seq_len(n_model),
    FUN = function(i) {
      contrast_i <- optContr(
        mods,
        doses = doses[-1],
        S = S[, , i],
        placAdj = TRUE
      )[[1]]
      contrast_i[, i]
    },
    FUN.VALUE = numeric(n_active)
  )
  dimnames(contMat) <- list(
    as.character(doses[-1]),
    model_names
  )
  # The null correlation matrix and critical value do not depend on which
  # candidate model is treated as the true model.
  covMat0 <- crossprod(contMat, S0 %*% contMat)
  corMat0 <- cov2cor(covMat0)
  
  integration_control <- mvtnorm.control()
  critV <- critVal(
    corMat0,
    alpha,
    df = 0,
    alternative = "one.sided",
    control = integration_control
  )
  model_power <- numeric(n_model)
  for (i in seq_len(n_model)) {
    model_power[i] <- powMCT(
      contMat,
      alpha = alpha,
      altModels = mods,
      S = S[, , i],
      placAdj = TRUE,
      alternative = "one.sided",
      df = Inf,
      critV = critV,
      control = integration_control
    )[i]
  }
  average_power <- mean(model_power)

  power_table <- data.frame(
    model = c(model_names, "average"),
    power = round(c(model_power, average_power), 3),
    row.names = NULL
  )
  list(
    power_table = power_table,
    power = stats::setNames(model_power, model_names),
    power_av = average_power,
    contMat = contMat,
    critV = critV
  )
}

#' Required number of events for a target power
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation weights for all treatment groups,
#'   including placebo.
#' @param targPow Target average power.
#' @param lowerE Lower bound for the number of events.
#' @param upperE Upper bound for the number of events.
#'
#' @return A list containing:
#' - `events`: smallest integer event count attaining the target power;
#' - `power`: average power at that event count;
#' - `power_table`: model-specific powers at that event count;
#' - `target_power`: requested average power.
eventsMCT_TTE <- function(mods,
                          alpha,
                          alRatio,
                          targPow,
                          lowerE = 1,
                          upperE = 1000) {

  # alRatio = 1 makes sampSize() iterate the total number of events directly;
  # targFunc still uses the alRatio argument of eventsMCT_TTE (lexical scoping)
  events <- sampSize(
    upperN = upperE,
    lowerN = lowerE,
    targFunc = function(e) {
      powMCT_TTE(mods = mods, alpha = alpha, alRatio = alRatio, etotal = e)$power_av
    },
    target = targPow,
    alRatio = 1,
    Ntype = "total"
  )$samp.size

  final_result <- powMCT_TTE(
    mods = mods,
    alpha = alpha,
    alRatio = alRatio,
    etotal = events
  )
  list(
    events = events,
    power = final_result$power_av,
    power_table = final_result$power_table,
    target_power = targPow
  )
}

For example, the power for 120 events at a one-sided significance level of 0.025 is:

powMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, etotal = 120)
$power_table
        model power
1       emax1 0.848
2       emax2 0.826
3     sigEmax 0.835
4   quadratic 0.548
5 exponential 0.806
6     average 0.773

$power
      emax1       emax2     sigEmax   quadratic exponential 
  0.8477427   0.8262836   0.8346780   0.5481925   0.8058722 

$power_av
[1] 0.7725538

$contMat
          emax1       emax2    sigEmax  quadratic exponential
0.05  0.1651135  0.29352764  0.2781398  0.2506229   0.2046634
0.2  -0.2195730 -0.01266064  0.2514774 -0.4046574   0.1954682
0.5  -0.3773126 -0.28480843 -0.1759215 -0.8689606   0.1362722
1    -0.8843984 -0.91245021 -0.9101917 -0.1354550  -0.9493866

$critV
[1] 2.353151

And we can calculate the number of required events to achieve an average power of 80%:

eventsMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, targPow = 0.8, lowerE = 100, upperE = 300)
$events
[1] 129

$power
[1] 0.8014544

$power_table
        model power
1       emax1 0.873
2       emax2 0.854
3     sigEmax 0.862
4   quadratic 0.583
5 exponential 0.836
6     average 0.801

$target_power
[1] 0.8

Calculating required sample size for a trial

For trial planning purposes we may want to determine the required sample size for the trial rather than the number of events. To connect the number of events to a sample size, Deng et al. (2019) use the relationship between the sample size and the expected number of events under exponentially distributed survival times. More specifically, assume that patients are recruited uniformly over \([0, T_r]\) (end of recruitment) with study end \(T_s\), and that survival times in group \(k\) are exponential with hazard \(\lambda_k\). Then the probability that a patient in group \(k\) experiences an event by the end of the study is

\[q_k = 1 - \frac{\exp\{-\lambda_k(T_s-T_r)\}-\exp(-\lambda_kT_s)}{\lambda_kT_r}.\] If \(w_k=r_k/\sum_i r_i\) is the allocation proportion, the expected total number of events for a sample size \(N\) is \[E(D\mid N) = N\sum_{k=0}^{K}w_kq_k.\] We can use these formulas to calculate the expected number of events for each candidate model for a given sample size and determine the required sample size to achieve a specified target average power. Note that this function could also be easily modified to use the minimum or maximum power instead of the mean across candidate models.

#' Required sample size for target average power with a time-to-event endpoint
#'
#' For each candidate model, the expected number of events is calculated from
#' that model's hazards. Model-specific powers are then evaluated at the
#' corresponding expected event counts and averaged across candidate models.
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation ratios for all treatment groups,
#'   including placebo.
#' @param targPow Target average power across candidate models.
#' @param end_rec End of the recruitment period, in years.
#' @param end_stu End of the study, in years.
#' @param lowerN Lower bound for the total sample size.
#' @param upperN Upper bound for the total sample size.
#'
#' @return A list containing:
#' - `sampSize`: smallest integer sample size attaining the target average power;
#' - `power`: average power at the returned sample size;
#' - `model_power`: model-specific powers at the returned sample size;
#' - `expected_events`: expected events under each candidate model;
#' - `event_probability`: overall event probability under each candidate model;
#' - `event_probability_by_group`: group-specific event probabilities;
#' - `allocation`: allocation proportions;
#' - `power_table`: model-specific expected events and powers;
#' - `target_power`: requested average power.
sampSizeMCT_TTE <- function(mods,
                            alpha,
                            alRatio,
                            targPow,
                            end_rec,
                            end_stu,
                            lowerN = 1,
                            upperN = 1000) {

  y <- as.matrix(getResp(mods))
  n_group <- nrow(y)
  n_model <- ncol(y)
  model_names <- colnames(y)
  if (is.null(model_names)) {
    model_names <- paste0("model", seq_len(n_model))
  }
  allocation <- alRatio / sum(alRatio)
  # Each column contains the group-specific hazards under one candidate model.
  lambda <- exp(y)

  # Group-specific probability of observing an event by the end of the study.
  #
  # This is equivalent to
  #
  # 1 - (
  #   exp(-lambda * (end_stu - end_rec)) -
  #   exp(-lambda * end_stu)
  # ) / (lambda * end_rec)
  #
  # but expm1() is more stable when lambda * end_rec is small.
  event_probability_by_group <-
    1 -
    exp(-lambda * (end_stu - end_rec)) *
    (-expm1(-lambda * end_rec)) /
    (lambda * end_rec)
  # Overall event probability under each candidate model.
  event_probability <- colSums(
    sweep(
      event_probability_by_group,
      MARGIN = 1,
      STATS = allocation,
      FUN = "*"
    )
  )
  names(event_probability) <- model_names
  colnames(event_probability_by_group) <- model_names
  
  # Evaluate average power for a specified integer sample size.
  evaluate_sample_size <- function(n) {
    expected_events <- n * event_probability
    model_power <- vapply(
      seq_len(n_model),
      FUN = function(j) {
        power_result <- powMCT_TTE(
          mods = mods,
          alpha = alpha,
          alRatio = alRatio,
          etotal = expected_events[j]
        )
        unname(power_result$power[j])
      },
      FUN.VALUE = numeric(1)
    )
    names(expected_events) <- model_names
    names(model_power) <- model_names

    list(
      expected_events = expected_events,
      model_power = model_power,
      power_av = mean(model_power)
    )
  }

  # alRatio = 1 makes sampSize() iterate the total sample size directly;
  # evaluate_sample_size() still uses the alRatio argument of sampSizeMCT_TTE
  required_n <- sampSize(
    upperN = upperN,
    lowerN = lowerN,
    targFunc = function(n) evaluate_sample_size(n)$power_av,
    target = targPow,
    alRatio = 1,
    Ntype = "total"
  )$samp.size
  final_result <- evaluate_sample_size(required_n)

  power_table <- data.frame(
    model = c(model_names, "average"),
    expected_events = c(
      unname(final_result$expected_events),
      mean(final_result$expected_events)
    ),
    power = c(
      unname(final_result$model_power),
      final_result$power_av
    ),
    row.names = NULL
  )
  list(
    sampSize = required_n,
    power = final_result$power_av,
    model_power = final_result$model_power,
    expected_events = final_result$expected_events,
    event_probability = event_probability,
    event_probability_by_group = event_probability_by_group,
    allocation = allocation,
    power_table = power_table,
    target_power = targPow
  )
}

Now we can calculate how many patients we would need to recruit to achieve an average power of 80%, assuming we would recruit for 2 years and we would end the study after 4 years.

sampSizeMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, end_rec = 2, end_stu = 4, targPow = 0.8, lowerN = 100, upperN = 500)
$sampSize
[1] 177

$power
[1] 0.801062

$model_power
      emax1       emax2     sigEmax   quadratic exponential 
  0.8539448   0.8463860   0.8662920   0.5898005   0.8488866 

$expected_events
      emax1       emax2     sigEmax   quadratic exponential 
   122.1105    126.3789    130.7627    131.6437    133.4838 

$event_probability
      emax1       emax2     sigEmax   quadratic exponential 
  0.6898896   0.7140052   0.7387722   0.7437499   0.7541458 

$event_probability_by_group
         emax1     emax2   sigEmax quadratic exponential
0    0.8246031 0.8246031 0.8246031 0.8246031   0.8246031
0.05 0.7433897 0.7957634 0.8245814 0.7879583   0.8242087
0.2  0.6539275 0.7286888 0.8191402 0.6893716   0.8222112
0.5  0.6080951 0.6497690 0.7038692 0.5897943   0.8087857
1    0.5873043 0.5873043 0.5873043 0.7449593   0.5873043
attr(,"parList")
attr(,"parList")$emax1
        e0       eMax       ed50 
-0.5108256 -0.7624619  0.1000000 

attr(,"parList")$emax2
        e0       eMax       ed50 
-0.5108256 -1.0397208  0.5000000 

attr(,"parList")$sigEmax
        e0       eMax       ed50          h 
-0.5108256 -0.7364689  0.5000000  4.0000000 

attr(,"parList")$quadratic
        e0         b1         b2 
-0.5108256 -2.4953299  2.2457969 

attr(,"parList")$exponential
          e0           e1        delta 
-0.510825624 -0.004702071  0.200000000 


$allocation
[1] 0.2857143 0.1428571 0.1428571 0.1428571 0.2857143

$power_table
        model expected_events     power
1       emax1        122.1105 0.8539448
2       emax2        126.3789 0.8463860
3     sigEmax        130.7627 0.8662920
4   quadratic        131.6437 0.5898005
5 exponential        133.4838 0.8488866
6     average        128.8759 0.8010620

$target_power
[1] 0.8

References

Deng, Q., Bai, X., Liu, D., Roy, D., Ying, Z., and Lin, D.-Y. (2019), “Power and sample size for dose-finding studies with survival endpoints under model uncertainty,” Biometrics, 75, 308–314. https://doi.org/10.1111/biom.12968.