Trial simulation with quasi-continuous toxicity measure

Overview

Dose-finding designs traditionally use the dose-limiting toxicity (DLT), a binary endpoint (yes/no DLT) to identify the maximum tolerated dose (MTD) or the recommended phase 2 dose (RP2D). A DLT is defined based on the severity of an adverse event (AE) and measured ordinally from 0 (no AE), to 4 (severe AE). The standard measure of toxicity dichotomizes this scale and generally defines a dose-limiting toxicity (DLT) as an AE of grade 3 or higher.

The normalized Total Toxicity Profile (nTTP), developed by Ezzalfani et al. (2013), is a quasi-continuous measure that accounts for different kinds of toxicity and adverse event grade by weighting them based on patient burden. For example, a grade 2 neurological AE may be less burdensome than a grade 2 gastrointestinal AE. The weight matrix \(W\) is comprised of weights \(w_{l,j}\), and defined as

\[ W= \begin{bmatrix} w_{1,0} & \cdots & w_{1,4} \\ \vdots & \ddots & \vdots \\ w_{L,0} & \cdots & w_{L,4} \end{bmatrix} \]

for grades \(j=0,…,4\) and toxicity types \(l=1,…,L\). The non-normalized TTP for individual i on dose d is calculated directly from this matrix as the Euclidean norm, defined as

\[ TTP_{i,d} = \sqrt{ \sum_{l=1}^L \sum_{j=0}^{4} w_{l,j}^2 \textbf{I} (G_{i,d,l}=j)} \]

where \(\textbf{I} (G_{i,d,l}=j)\) is 1 when the maximum grade for toxicity type \(l\) is equal to \(j\), and 0 otherwise. By construction, the TTP can be considered to be a quasi-continuous variable with limited range of variation. The nTTP is formed by diving the TTP by normalization constant v, which is the TTP when all toxicity types are observed at grade 4. In this way, the nTTP range is constrained between 0 and 1. See the original publication for further details.

Statistical model for nTTP within the iAdapt framework

See the README and Chiuzan et al. (2018) for details on the iAdapt design framework.

For any dose \(d \in \{1,...,D \}\) , let \(X_{1,d},...,X_{n,d} \overset{\text{iid}}{\sim} N(\mu_d, \sigma^2)\) truncated to \([0,1]\) be the observed nTTP scores for n patients with the following density function:

\[ f_d (x_i; \mu_d, \sigma^2) = \frac{\phi(\frac{x_i - \mu_d}{\sigma})}{\sigma[\Phi(\frac{1-\mu_d}{\sigma}) - \Phi(\frac{0-\mu_d}{\sigma})]} \text{ for } i=1,2,...,n \]

where \(\mu_d\) is the unknown mean nTTP for dose \(d\), and \(\sigma^2\) is the constant variance across doses.

Simulation example

Suppose we have \(D=6\) doses to test, and are concerned with \(L=3\) toxicity types (e.g. renal, neurological, and hematological). A DLT is defined as an AE of grade 3, 3, and 4 (or higher, excluding grade 5) associated with each toxicity type, respectively.

Let \(H_1: \mu=0.35 \text{ vs. } H_2: \mu=0.1\). Variance of observed nTTP is \(\sigma^2 = 0.15\). Weight matrix W, adapted from Du et al. (2019), is

\[ W= \begin{bmatrix} 0 & 0.5 & 0.75 & 1 & 1.5 \\ 0 & 0.5 & 0.75 & 1 & 1.5 \\ 0 & 0 & 0 & 0.5 & 1 \end{bmatrix} \]

Note that the first column contains zero values assuming a weight of 0 for a grade 0 event. We also specify the probability of observing an AE of each grade for a given toxicity type and dose level, taken from Du et al.(2019).

Specify design parameters

library(iAdapt)
std.nTTP = 0.15 # standard deviation of nTTP value

coh.size = 3 # number pts per dose
ntox <- 3 # Number of unique toxicities
d <- 6 # Number of dose levels
N <- 25 # maximum number of patients

# Variance of the efficacy endpoints used for stage 2 randomization, assumed known and constant across doses.
v <- rep(0.01, 6) 

K = 2 # for LRT

# Stopping rule: if dose 1 is the only safe dose, allocate up to 9 pts before ending the trial to collect more information
stop.rule <- 9 

# Dose-efficacy curve
m = c(10, 20, 30, 40, 70, 90)

#### Define the weight matrix, from Du et al.(2019)
W <- matrix(c(0, 0.5, 0.75, 1.0, 1.5, # Burden weight for grades 1-4 for toxicity 1
              0, 0.5, 0.75, 1.0, 1.5, # Burden weight for grades 1-4 for toxicity 2
              0, 0.0, 0.00, 0.5, 1), ## Burden weight for grades 1-4 for toxicity 3
            nrow = ntox, byrow = T)

#### Define an array to hold toxicitiy probabilities 
data("TOX"); TOX # laod sample TOX array
## , , 1
## 
##       [,1]  [,2]  [,3]  [,4]  [,5]
## [1,] 0.823 0.152 0.022 0.002 0.001
## [2,] 0.791 0.172 0.032 0.004 0.001
## [3,] 0.758 0.180 0.043 0.010 0.009
## [4,] 0.685 0.190 0.068 0.044 0.013
## [5,] 0.662 0.200 0.078 0.046 0.014
## [6,] 0.605 0.223 0.082 0.070 0.020
## 
## , , 2
## 
##       [,1]  [,2]  [,3]  [,4]  [,5]
## [1,] 0.970 0.027 0.002 0.001 0.000
## [2,] 0.968 0.029 0.002 0.001 0.000
## [3,] 0.813 0.172 0.006 0.009 0.000
## [4,] 0.762 0.183 0.041 0.010 0.004
## [5,] 0.671 0.205 0.108 0.011 0.005
## [6,] 0.397 0.258 0.277 0.060 0.008
## 
## , , 3
## 
##       [,1]  [,2]  [,3]  [,4]  [,5]
## [1,] 0.930 0.060 0.005 0.001 0.004
## [2,] 0.917 0.070 0.007 0.001 0.005
## [3,] 0.652 0.280 0.010 0.021 0.037
## [4,] 0.536 0.209 0.031 0.090 0.134
## [5,] 0.015 0.134 0.240 0.335 0.276
## [6,] 0.005 0.052 0.224 0.372 0.347
## Grade at which an AE is defined as DLT for each toxicity type
grade.thresh = c(3, 3, 4)

Calculate mean nTTP (mnTTP) and corresponding DLT rate per dose, and specify hypotheses

# Obtain the expected mean toxicity score at each dose level
tru_mnTTP = get.thresh(dose = d, ntox = ntox, W = W, TOX = TOX)

# Get the expected probability of a DLT at each dose level
pDLT = dlt.prob(dose = d, TOX = TOX, ntox = ntox, grade.thresh = grade.thresh) 

# Specify hypotheses
h1 = 0.35
h2 = 0.10

To choose hypotheses, we used the following method:

Ultimately, selection of hypotheses is left to the discretion of the investigator/statistician. We recommend simulating under different hypotheses. Remember that the nTTP value is uninterpretable, which is why corresponding DLT rate is used to help select appropriate hypothesis values.

Simulate a single trial

Stage 1: Establish the safety profile for all initial doses.

Stage 1 establishes the safety profiles of the predefined doses.

Function to generate and tabulate toxicities per dose level:

set.seed(3)
tox.profile.nTTP(dose = d, p1 = h1, p2 = h2, K = K, coh.size = coh.size, ntox = ntox, W = W, TOX = TOX, std.nTTP = std.nTTP)
## $mnTTP
##      [,1]       [,2] [,3]    [,4]
## [1,]    1 0.00000000    1 4202.43
## [2,]    2 0.07106691    2  393.28
## [3,]    3 0.07106691    3  393.28
## [4,]    4 0.17766726    4   11.26
## [5,]    5 0.31980107    5    0.10
## 
## $all_nTTP
##  [1] 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000 0.2132007 0.2132007
##  [8] 0.0000000 0.0000000 0.5330018 0.0000000 0.0000000 0.5330018 0.0000000
## [15] 0.4264014

The first column indicates the cohort number and third columns gives the dose assignment for all specified doses (in this case, 5). The second column gives the number of DLTs observed at that dose. The fourth column gives the likelihood ratio calculated from the observed patient-level nTTP. A dose is considered acceptably safe if the LR > 1/K and unacceptably safe if LR <= 1/K.

Now let's see which doses the design selects as being acceptably safe using K=2.

Function to select only the acceptable toxic doses:

set.seed(3)
safe.dose.nTTP(dose = d, p1 = h1, p2 = h2, K = K, coh.size = coh.size, W = W, TOX = TOX, ntox = ntox, std.nTTP = std.nTTP) 
## $alloc.safe
##      [,1]       [,2]
## [1,]    1 0.00000000
## [2,]    2 0.07106691
## [3,]    3 0.07106691
## [4,]    4 0.17766726
## 
## $alloc.total
##  [1] 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
## 
## $n1
## [1] 15
## 
## $all_nttp
##  [1] 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000 0.2132007 0.2132007
##  [8] 0.0000000 0.0000000 0.5330018 0.0000000 0.0000000 0.5330018 0.0000000
## [15] 0.4264014

We can see that the design selects only doses 1 through 4; $alloc.safe gives the dose assignment (first column) and mean nTTP for that dose (second column). $alloc.total gives the dose assignment for all enrolled patients ($n1 gives the total sample size used in stage 1, in this case 15 subjects), where we see that 3 patients were assigned to each dose as specified by coh.size.

Stage 1 is mainly used to establish safety, but efficacy outcomes are also collected for each dose.

Function to generate efficacy outcomes (here T-cell percent persistence) for each dose:

set.seed(3)
eff.stg1.nTTP(dose = d, p1 = h1, p2 = h2, K = K, m = m, v = v, coh.size = coh.size, nbb = 100, W = W, TOX = TOX, ntox = ntox, std.nTTP = std.nTTP) 
## $Y.safe
##  [1] 22 25  8  9 39 37 32 48 23 56 50 37
## 
## $d.safe
##  [1] 1 1 1 2 2 2 3 3 3 4 4 4
## 
## $tox.safe
## [1] 0.00000000 0.07106691 0.07106691 0.17766726
## 
## $n1
## [1] 15
## 
## $Y.alloc
##  [1] 22 25  8  9 39 37 32 48 23 56 50 37 57 75 80
## 
## $d.alloc
##  [1] 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5
## 
## $all_nttp
##  [1] 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000 0.2132007 0.2132007
##  [8] 0.0000000 0.0000000 0.5330018 0.0000000 0.0000000 0.5330018 0.0000000
## [15] 0.4264014

$Y.safe and $d.safe give the efficacy values and dose allocations for all subjects enrolled at acceptably safe doses; $tox.safe gives the mean nTTP for each dose level; $Y.alloc and $d.alloc gives the efficacy values and dose allocations for all subjects enrolled in stage 1 (safe and unsafe doses). Notice that the $Y.safe and $d.safe are subsets of $Y.alloc and $d.alloc.

Stage 2: Adaptive randomization based on efficacy outcomes.

If 2 or more doses are considered acceptable after stage 1, the remaining patients are randomized to these open doses until the total sample size N is reached. If only dose 1 is acceptable after stage 1, allocate up to 9 patients (stop.rule = 9). Toxicity is still being monitored (in the 'background') throughout stage 2, so acceptable doses (declared in stage 1) can still be discarded based on observed DLTs. The discarded dose and all levels above it cannot be revisited.

Function to fit a linear regression for the continuous efficacy outcomes, compute the randomization probabilities per dose and allocate the next subject to an acceptable safe dose that has the highest randomization probability:

set.seed(3)
rand.stg2.nTTP(dose = d, p1 = h1, p2 = h2, K = K, coh.size = coh.size, m = m, v = v, N = N, stop.rule = stop.rule, cohort = 1, samedose = TRUE, nbb = 100, W = W, TOX = TOX, ntox = ntox, std.nTTP = std.nTTP) 
## $Y.final
##  [1] 22 25  8  9 39 37 32 48 23 56 50 37 57 75 80 29 11 26 54 54 45  3 35 33 45
## 
## $d.final
##  [1] 1 1 1 2 2 2 3 3 3 4 4 4 5 5 5 2 2 4 4 4 3 1 4 3 4
## 
## $n1
## [1] 15

Simulate 100 trials

To simulate this trial 100 times, we can run the following:

set.seed(3)
sims = 1e2 # number of trials to simulate

simulations = sim.trials.nTTP(numsims = sims,
                              dose = d,
                              p1 = h1,
                              p2 = h2,
                              K = K,
                              coh.size = coh.size,
                              m = m,
                              v = v,
                              N = N,
                              W = W,
                              TOX = TOX,
                              ntox = ntox,
                              std.nTTP = std.nTTP)

$safe.d indicates whether each dose (column) was declared safe in stage 1 (1 = yes, 0 = no) for each trial (row).

head(simulations$safe.d)
##      [,1] [,2] [,3] [,4] [,5] [,6]
## [1,]    1    1    1    1    0    0
## [2,]    1    1    1    1    0    0
## [3,]    1    1    1    1    1    0
## [4,]    1    1    1    0    0    0
## [5,]    1    1    1    0    0    0
## [6,]    1    1    1    1    1    0
head(simulations$sim.Y)
##      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
## [1,]   22   25    8    9   39   37   32   48   23    56    50    37    57    75
## [2,]   12    2    2   35    7    5   11   25   32    44    51    32    68    76
## [3,]    2   15   10   25   46   20   22   36   19    45    32    48    75    70
## [4,]    7   22    8   23   20   12   38   21   37    33    64    44    40    18
## [5,]    6   13    2   32    9   20   32   32   21    41    40    42    14    48
## [6,]    3   16   26    6   17   12   30   23   36    52    28    50    84    71
##      [,15] [,16] [,17] [,18] [,19] [,20] [,21] [,22] [,23] [,24] [,25]
## [1,]    80    29    11    26    54    54    45     3    35    33    45
## [2,]    66    15    32     3    34    20    35    17    39    47    29
## [3,]    59    91    99    93    61    11    35    20     4    74    83
## [4,]    12    24    12    19    42    43    26    13    25    10    12
## [5,]    23    16    11     6    31    38    20    38    10    13    22
## [6,]    65    81    67    70    26    63    69    26     0    23    18
head(simulations$sim.d)
##      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10] [,11] [,12] [,13] [,14]
## [1,]    1    1    1    2    2    2    3    3    3     4     4     4     5     5
## [2,]    1    1    1    2    2    2    3    3    3     4     4     4     5     5
## [3,]    1    1    1    2    2    2    3    3    3     4     4     4     5     5
## [4,]    1    1    1    2    2    2    3    3    3     4     4     4     3     3
## [5,]    1    1    1    2    2    2    3    3    3     4     4     4     3     3
## [6,]    1    1    1    2    2    2    3    3    3     4     4     4     5     5
##      [,15] [,16] [,17] [,18] [,19] [,20] [,21] [,22] [,23] [,24] [,25]
## [1,]     5     2     2     4     4     4     3     1     4     3     4
## [2,]     5     3     4     1     4     1     3     3     4     4     3
## [3,]     5     6     6     6     4     2     4     3     1     5     5
## [4,]     1     3     3     3     3     3     2     3     1     1     3
## [5,]     2     1     3     3     3     3     3     3     2     1     3
## [6,]     5     6     6     6     3     5     5     4     1     3     2

$sim.Y gives the observed outcomes, where each column corresponds to 1 patient (maximum of N columns), and each row is a simulated trial. Correspondingly, $sim.d gives the dose allocation for each patient (column) in each trial (row).

To see the proportion of times we've designated each dose as safe in stage 1, we can simply take the column totals of the $safe.d matrix:

colSums(simulations$safe.d) / sims
## [1] 1.00 1.00 0.93 0.68 0.28 0.05

Simulation results can be summarized. For each dose level, the inter-quartile range (25th percentile, median, 75th percentile) for the percent of subjects treated and observed efficacy are given in tables.

sim.summary(simulations)
## $pct.treated
##      [,1] [,2] [,3] [,4] [,5]
## [1,]    1   12   16   20   17
## [2,]    2   16   20   24   23
## [3,]    3   16   24   32   26
## [4,]    4   12   20   25   20
## [5,]    5    0   12   16   10
## [6,]    6    0    0   12    4
## 
## $efficacy
##      [,1] [,2]  [,3]   [,4]  [,5]
## [1,]    1    3  7.25 11.500  8.08
## [2,]    2   14 17.25 22.000 18.32
## [3,]    3   26 29.00 32.000 29.10
## [4,]    4   35 39.00 44.375 39.44
## [5,]    5   68 71.00 75.000 70.80
## [6,]    6   88 92.00 94.000 89.67