A Dose-Time-Response Model
This vignette covers four advanced topics:
- a two-dimensional[2d] design region
- a non-trivial covariate parameterization
- vector units of observations
- the exchange algorithm
Note that although they are illustrated here together, any of them can also be used independently.
Our example model for this vignette will be a dose-time-response model. A dose-time-response model combines a pharmacokinetic (PK) model for a drug concentration that changes over time with a pharmacodynamic (PD) model that maps the concentration to a clinical outcome. The drug concentration itself is typically not observed. Specifically, we will combine a compartmental PK model with an Emax PD response. Optimal designs for this kind of model have been investigated for example by Fang and Hedayat[FH08], Dette, Pepelyshev, and Wong[DPW09], or Lange and Schmidli[LS14].
Model Setup
In this section we implement the model in Julia. We defer the discussion of the design variables and the covariate parameterization to the following sections.
Concentration over time is governed by an absorption rate $a > 0$ and an elimination rate $e > 0$, where we assume $e < a$ for identifiability. Further, it depends on the initial dose $D$ administered at time $t=0$. The Emax response is parameterized by the baseline response $e_0$, the maximal effect above baseline $e_{\max{}}$, and the concentration of half-maximal effect $\mathrm{EC}_{50}$. The mean function $\MeanFunction$ is then obtained by combining these two.
\[\begin{aligned} c(t, a, e) &= \frac{D}{1-e/a}(\exp(-et) - \exp(-at)) \\ \mathrm{E}_{\max{}}(c, e_0, e_{\max{}}, \mathrm{EC}_{50}) &= e_0 + \frac{ce_{\max{}}}{\mathrm{EC}_{50} + c} \\ \MeanFunction((D, t_1,…,t_{\DimUnit}), θ) &= \begin{bmatrix} \mathrm{E}_{\max{}}(c(t_1, a, e), e_0, e_{\max{}}, \mathrm{EC}_{50})\\ … \\ \mathrm{E}_{\max{}}(c(t_{\DimUnit}, a, e), e_0, e_{\max{}}, \mathrm{EC}_{50})\\ \end{bmatrix} \end{aligned}\]
Hence the model covariates are the initial dose $D$ and the measurement times $t_1,…,t_{\DimUnit}$. In the following example, we will consider doses between 0 and 100 mg, and times between 0 and 24 hours. I.e. the covariate set is
\[\CovariateSet = [0, 100] × [0, 24]^{\DimUnit}.\]
A unit of observation is a vector $\Unit∈\Reals^{\DimUnit}$, collecting the responses at the $\DimUnit$ measurement times. The unknown model parameter is $\Parameter=(a, e, e_0, e_{\max{}}, \mathrm{EC}_{50})$.
For simplicity we assume that the measurements are uncorrelated with identical constant variances, i.e. $\UnitCovariance = σ^2 I_{\DimUnit}$. Since $σ$ enters the D-criterion objective only as a scaling factor, we may set $σ=1$ for the remainder of this text.
For this more complex model there are no helper macros and we have to implement not only the Jacobian matrix, but also the other necessary methods by hand.
using Kirstine, Random, Plots
@kwdef struct DTRMod <: Kirstine.NonlinearRegression
sigma::Float64
m::Int64
end
mutable struct DoseTimeCovariate <: Kirstine.Covariate
dose::Float64
time::Vector{Float64}
end
Kirstine.unit_length(m::DTRMod) = m.m
function Kirstine.update_model_vcov!(s, m::DTRMod, c::DoseTimeCovariate)
fill!(s, 0.0)
for j in 1:(m.m)
s[j, j] = m.sigma^2
end
end
Kirstine.allocate_covariate(m::DTRMod) = DoseTimeCovariate(0, zeros(m.m))
@simple_parameter DTR a e e0 emax ec50
function Kirstine.jacobianmatrix!(jm, m::DTRMod, c::DoseTimeCovariate, p::DTRParameter)
for j in 1:length(c.time)
A = exp(-p.a * c.time[j]) # calculate exponentials only once
B = exp(-p.e * c.time[j])
C = B - A
rd = p.e - p.a # rate difference
den = c.dose * C * p.a - rd * p.ec50
den2 = den^2
# ∂a
jm[j, 1] = -c.dose * p.ec50 * p.emax * (A * c.time[j] * p.a * rd + p.e * C) / den2
# ∂e
jm[j, 2] = c.dose * p.a * p.ec50 * p.emax * (B * c.time[j] * rd + C) / den2
# ∂e0
jm[j, 3] = 1.0
# ∂emax
jm[j, 4] = c.dose * p.a * C / den
# ∂ec50
jm[j, 5] = c.dose * p.a * p.emax * C * rd / den2
end
return jm
end
Our example prior has an absorption rate that is about five times as large as the elimination rate, and the maximal concentration is reached after about 4 hours. The observable response varies between 1 and 3, with an $\mathrm{EC}_{50}$ at about 10.
function draw_from_prior(n)
pars = map(
0.5 .+ 0.10 .* randn(n),
0.1 .+ 0.02 .* randn(n),
1.0 .+ 0.25 .* randn(n),
2.0 .+ 0.50 .* randn(n),
exp.(2.3 .+ 0.6 .* randn(n)),
) do a, e, e0, emax, ec50
return DTRParameter(a = a, e = e, e0 = e0, emax = emax, ec50 = ec50)
end
return PriorSample(pars)
end
The following is a small wrapper around plot_expected_function
that plots a time-response curve for each initial dose and overlays the measurement point(s) implied by the design.
function plot_expected_response(d::DesignMeasure, dp::DesignProblem)
response = function (D, t, p)
con = D / (1 - p.e / p.a) * (exp(-p.e * t) - exp(-p.a * t))
r = p.e0 + p.emax * con / (p.ec50 + con)
return r
end
f(x, c, p) = response(c.dose, x, p)
g(c) = c.time
h(c, p) = [response(c.dose, t, p) for t in c.time]
xrange = range(0, 24; length = 101)
cp = covariate_parameterization(dp)
plt = plot_expected_function(f, g, h, xrange, d, model(dp), cp, prior_knowledge(dp))
plot!(plt; xguide = "time", yguide = "response", xticks = 0:4:24)
return plt
end
Single Measurement
Let's start out really simple. Each patient will be measured exactly once. What are the optimal doses and dose-specific measurement times?
In terms of our model and the design problem this means
\[\begin{aligned} \DimUnit &= 1 \\ \DesignRegion &= [0, 100] × [0, 24] \\ \CovariateParameterization(\DesignPoint) &= (\DesignPoint_1, \DesignPoint_2) \\ \end{aligned}\]
i.e. the design region is identical to the covariate set.
struct CopyBoth <: Kirstine.CovariateParameterization end
function Kirstine.map_to_covariate!(c::DoseTimeCovariate, dp, m::DTRMod, cp::CopyBoth)
c.dose = dp[1]
c.time[1] = dp[2]
return c
end
The next bit is new. In our dose-time-response model with the above covariate parameterization, design measures do not map injectively to information matrices. There are two reason for this:
- With a dose $D=0$ the response is constantly $e_0$, no matter at which time we take measurements.
- At time $t=0$ the response always starts at $e_0$, no matter what the initial dose was.
If we want unique design measures, we must implement a method for the simplify_unique
function, which should choose a stable representative for a non-unique design measure. At the same time we should keep in mind numerical inaccuracies. One possibility is to
- set $D:=0$ if $t < ε_t$,
- and set $t:=0$ if $D < ε_D$
for some constants $ε_t ≥ 0$ and $ε_D ≥ 0$ that determine what counts as a “truly positive” time or dose.
Of course, we should only do this if $D=0$ and $t=0$ are in fact inside the design region.
function Kirstine.simplify_unique(
d::DesignMeasure,
dr::DesignInterval,
m::DTRMod,
cp::CopyBoth;
minposdose = 0,
minpostime = 0,
)
res = deepcopy(d) # don't modify the input!
if lowerbound(dr) == (0, 0)
map(points(res)) do dp
if dp[1] < minposdose || dp[2] < minpostime
dp[1] = 0
dp[2] = 0
end
end
end
return res
end
The next steps are as in the tutorial: set up the design problem and solve it with direct maximization.
Iteration numbers and swarm sizes in this vignette are hand-tuned to the lowest values that work. This is in order to reduce the documentation's compilation time.
Random.seed!(4711)
dp1 = DesignProblem(
criterion = DCriterion(),
region = DesignInterval(:dose => (0, 100), :time => (0, 24)),
model = DTRMod(sigma = 1, m = 1),
covariate_parameterization = CopyBoth(),
prior_knowledge = draw_from_prior(1000),
)
Random.seed!(1357)
st1 = DirectMaximization(
optimizer = Pso(swarmsize = 30, iterations = 100),
prototype = random_design(region(dp1), 7),
)
Random.seed!(2468)
s1, r1 = solve(dp1, st1; minpostime = 1e-3, minposdose = 1e-3, maxdist = 1e-2)
s1
DesignMeasure(
[0.0, 0.0] => 0.20053030795126034,
[14.310132703488692, 7.9551274923954916] => 0.19811209094488025,
[61.493484624092964, 24.0] => 0.07683680630261554,
[99.99929278721805, 4.0783147418405] => 0.1974719271387898,
[100.0, 0.1730851666925906] => 0.19894174306143625,
[100.0, 23.998585546217786] => 0.12810712460101797,
)
To sort the design point by time instead of dose, we can use the dimorder
argument of sort_points
. We could also have directly passed it to solve
.
sort_points(s1; dimorder = [2, 1])
DesignMeasure(
[0.0, 0.0] => 0.20053030795126034,
[100.0, 0.1730851666925906] => 0.19894174306143625,
[99.99929278721805, 4.0783147418405] => 0.1974719271387898,
[14.310132703488692, 7.9551274923954916] => 0.19811209094488025,
[100.0, 23.998585546217786] => 0.12810712460101797,
[61.493484624092964, 24.0] => 0.07683680630261554,
)
For a two-dimensional design region, the gateaux derivative is plotted as a heatmap.
gd1 = plot_gateauxderivative(s1, dp1)
The next plot shows the expected time-response curve for the dose of each design point. Additionally the measurement times themselves are indicated on their respective curves.
er1 = plot_expected_response(s1, dp1)
Optimal Time
Next, let's consider a variant of the previous design problem that was investigated by Fang and Hedayat[FH08]. They were only interested in a single optimal measurement time for each patient, while taking the initial dose $D$ as given and fixed.
\[\begin{aligned} m &= 1 \\ \DesignRegion &= [0, 24] \\ \CovariateParameterization(\DesignPoint) &= (D, \DesignPoint) \end{aligned}\]
This time there are no problems with uniqueness and the implementation is straightforward. Note how the DesignProblem
differs from dp1
only in its design region and its covariate parameterization.
struct FixedDose <: Kirstine.CovariateParameterization
dose::Float64
end
function Kirstine.map_to_covariate!(c::DoseTimeCovariate, dp, m::DTRMod, cp::FixedDose)
c.dose = cp.dose
c.time[1] = dp[1]
return c
end
dp2 = DesignProblem(
dp1;
region = DesignInterval(:time => (0, 24)),
covariate_parameterization = FixedDose(100),
)
Random.seed!(111317)
st2 = DirectMaximization(
optimizer = Pso(swarmsize = 30, iterations = 50),
prototype = random_design(region(dp2), 5),
)
Random.seed!(14916)
s2, r2 = solve(dp2, st2)
s2
DesignMeasure(
[0.0] => 0.1996347804123737,
[0.15441476713296678] => 0.2000666280210823,
[2.2462632650730114] => 0.20067467321750976,
[13.338793671645664] => 0.19962045785683455,
[24.0] => 0.2000034604921996,
)
gd2 = plot_gateauxderivative(s2, dp2)
er2 = plot_expected_response(s2, dp2)
Optimal Dose
In this section we consider kind of the opposite of the previous problem. Lange and Schmidli[LS14] considered a fixed number of measurements per patient at regular intervals, and were interested in optimal initial doses only.
\[\begin{aligned} m &= 13 \\ \DesignRegion &= [0, 100] \\ \CovariateParameterization(\DesignPoint) &= (\DesignPoint, 0, 2, …, 24) \\ \end{aligned}\]
struct FixedTimes <: Kirstine.CovariateParameterization
time::Vector{Float64}
end
function Kirstine.map_to_covariate!(c::DoseTimeCovariate, dp, m::DTRMod, cp::FixedTimes)
c.dose = dp[1]
c.time .= cp.time
return c
end
dp3 = DesignProblem(
dp1;
region = DesignInterval(:dose => (0, 100)),
model = DTRMod(1, 13), # measurements every two hours
covariate_parameterization = FixedTimes(0:2:24),
)
Random.seed!(9630)
st3 = DirectMaximization(
optimizer = Pso(swarmsize = 25, iterations = 20),
prototype = equidistant_design(region(dp3), 5),
)
Random.seed!(1827)
s3, r3 = solve(dp3, st3; maxweight = 1e-4)
s3
DesignMeasure(
[0.0] => 0.1051865637147946,
[15.949800420851398] => 0.4847347290784628,
[100.0] => 0.41007870720674255,
)
gd3 = plot_gateauxderivative(s3, dp3)
Each design point now corresponds to 13 individual measurements.
er3 = plot_expected_response(s3, dp3)
Equidistant Times
Next, let's say we want to keep measuring each patient 13 times at regular intervals. But maybe we can do better than using the same interval regardless of the dose. Let's try to find dose-specific optimal $Δt$ between measurements! To keep the results comparable, we allow $Δt∈[0, 2]$, resulting in a latest measurement time at 24 hours.
\[\begin{aligned} m &= 13 \\ \DesignRegion &= [0, 100] × [0, 2] \\ \CovariateParameterization(z) &= (z_1, 0, z_2, 2z_2, …, 12z_2)\\ \end{aligned}\]
For this setup we need to think about uniqueness again.
With $Δt=0$ we would measure 13 times at $t=0$, which is not sensible in context. But since the same response is obtained with $D=0$ and arbitrary $Δt$, we set such a design point to $(0, 2)$.
Similarly, with $D=0$ the response is constantly $e_0$. Here we also set the design point to $(0, 2)$.
struct EquidistantTimes <: Kirstine.CovariateParameterization end
function Kirstine.map_to_covariate!(
c::DoseTimeCovariate,
dp,
m::DTRMod,
cp::EquidistantTimes,
)
c.dose = dp[1]
for i in 1:(m.m)
c.time[i] = (i - 1) * dp[2]
end
return c
end
function Kirstine.simplify_unique(
d::DesignMeasure,
dr::DesignInterval,
m::DTRMod,
cp::EquidistantTimes;
minposdose = 0,
minposdelta = 0,
)
res = deepcopy(d)
if lowerbound(dr)[1] == 0
map(points(res)) do dp
if dp[1] <= minposdose
dp[1] = 0
dp[2] = upperbound(dr)[2]
end
end
if lowerbound(dr)[2] == 0
map(points(res)) do dp
if dp[2] <= minposdelta
dp[1] = 0
dp[2] = upperbound(dr)[2]
end
end
end
end
return res
end
dp4 = DesignProblem(
dp3;
region = DesignInterval(:dose => (0, 100), :Δt => (0, 2)),
covariate_parameterization = EquidistantTimes(),
)
Random.seed!(124816)
st4a = DirectMaximization(
optimizer = Pso(swarmsize = 25, iterations = 75),
prototype = random_design(region(dp4), 5),
)
Random.seed!(112358)
s4a, r4a = solve(dp4, st4a; minposdose = 1e-3)
gd4a = plot_gateauxderivative(s4a, dp4; legend = :bottomleft)
This does not look like the solution yet. There are several things that could have happened.
The particle swarm optimizer was not run for enough iterations.
The optimizer got stuck in a local maximum, possibly because the number of design points is too small.
pr4a = plot(r4a)
Looking at the optimization progress it seems that running with more iterations will not help.
We could try direct maximization again, this time with a prototype with more design points. But there is a smarter thing we can try instead. The Exchange
algorithm can be understood as a kind of coordinate-wise infinite-dimensional gradient ascent, since it basically alternates the following two actions
Find the point $\DesignPoint ∈ \DesignRegion$ where the corresponding Gateaux derivative is largest and add it to the design measure.
Re-optimize only the weights, then drop any points with zero weight.
Running this with a random starting design can be slow to converge, but since we already have promising candidate, we can hope that it gives us the solution after a few steps.
For part 1, we will not use PSO, but a Kirstine.GridSearch
over the design region $\DesignRegion$. This has the advantage that grid search is deterministic and will always find the maximum (on the grid), while Pso may sometimes get stuck in a local maximum and make the overall exchange algorithm somewhat unstable. On the other hand, grid search on a very fine grid can take a lot of time.
In this particular example we save some runtime since we can reasonably guess that a single step of the exchange algorithm might be sufficient, and that the new point will be added at a point with $D=100$. This is why we can get away with a Kirstine.ProductGrid
that has only 2 different dose values. For the second dimension, the grid has 51 different values at $Δt=0,0.04,…,2$.
st4b = Exchange(
candidate = s4a,
steps = 1,
optimizer_direction = Kirstine.GridSearch(gridspec = Kirstine.ProductGrid(2, 51)),
optimizer_weight = Pso(swarmsize = 25, iterations = 50),
)
Random.seed!(314)
s4b, r4b = solve(dp4, st4b; minposdose = 1e-3, maxdist = 2e-2)
Let's look at the solution.
s4b
DesignMeasure(
[0.0, 2.0] => 0.11343676907254417,
[12.258680316012303, 0.9158234222042569] => 0.1466543099306418,
[18.960755545027716, 2.0] => 0.1450562405792781,
[99.99958420409529, 0.030724171006199445] => 0.19609399574206807,
[100.0, 0.44] => 0.051532056457751135,
[100.0, 2.0] => 0.34722662821771666,
)
efficiency(s4a, s4b, dp4)
0.9969896414364555
gd4b = plot_gateauxderivative(s4b, dp4; legend = :bottomleft)
er4 = plot_expected_response(s4b, dp4)
Log-Equidistant Times
Finally, let's consider a variant of the previous problem, but this time with equidistant measurement times on the log scale. For some multiplier $b>1$ we have:
\[\begin{aligned} m &= 13 \\ \DesignRegion &= [0, 100] × [0, 24/b^{\DimUnit-2}] \\ \CovariateParameterization(z) &= (z_1, 0, z_2, bz_2, b^2z_2, …, b^{\DimUnit-2}z_2)\\ \end{aligned}\]
In the following example we take $b=\sqrt{2}$.
struct LogEquidistantTimes <: Kirstine.CovariateParameterization
b::Float64
end
function Kirstine.map_to_covariate!(
c::DoseTimeCovariate,
dp,
m::DTRMod,
cp::LogEquidistantTimes,
)
if m.m < 2
error("need at least 2 measurements for LogEquidistantTimes")
end
c.dose = dp[1]
c.time[1] = 0
c.time[2] = dp[2]
for j in 3:(m.m)
c.time[j] = cp.b * c.time[j - 1]
end
return c
end
function Kirstine.simplify_unique(
d::DesignMeasure,
dr::DesignInterval,
m::DTRMod,
cp::LogEquidistantTimes;
minposdose = 0,
minposdelta = 0,
)
res = deepcopy(d)
if lowerbound(dr)[1] == 0
map(points(res)) do dp
if dp[1] <= minposdose
dp[1] = 0
dp[2] = upperbound(dr)[2]
end
end
if lowerbound(dr)[2] == 0
map(points(res)) do dp
if dp[2] <= minposdelta
dp[1] = 0
dp[2] = upperbound(dr)[2]
end
end
end
end
return res
end
dp5 = DesignProblem(
dp3;
covariate_parameterization = LogEquidistantTimes(sqrt(2)),
region = DesignInterval(:dose => (0, 100), :Δt => (0, 24 / 45.25483399593908)),
)
Random.seed!(132435)
st5 = DirectMaximization(
optimizer = Pso(swarmsize = 40, iterations = 80),
prototype = random_design(region(dp5), 5),
)
Random.seed!(6283)
s5, r5 = solve(dp5, st5; maxdist = 1e-3, maxweight = 1e-3, minposdelta = 1e-3)
s5
DesignMeasure(
[0.0, 0.5303300858899102] => 0.10958860244868715,
[17.56761395230221, 0.5303275816838873] => 0.3992041718670002,
[99.99726947723131, 0.011673829992532737] => 0.05224177785883233,
[100.0, 0.5303300122682958] => 0.43896544782548025,
)
gd5 = plot_gateauxderivative(s5, dp5; legend = :bottomleft)
er5 = plot_expected_response(s5, dp5)
Efficiency Comparison
Let's finally compare the relative efficiency of all these designs under their respective design problems:
sol_prob = Iterators.zip([s1, s2, s3, s4b, s5], [dp1, dp2, dp3, dp4, dp5])
eff = [efficiency(d1, d2, p1, p2) for (d1, p1) in sol_prob, (d2, p2) in sol_prob]
5×5 Matrix{Float64}:
1.0 1.48507 0.126509 0.0930306 0.101641
0.673371 1.0 0.0851876 0.0626441 0.0684424
7.90457 11.7388 1.0 0.735367 0.803431
10.7491 15.9632 1.35987 1.0 1.09256
9.83851 14.6108 1.24466 0.915283 1.0
In this matrix, d1
varies over rows and d2
varies over columns. We see that s1
is better than s2
(not surprising, since the former can have time-specific doses), and that s3
through s5
are better than both s1
and s2
. We further see that both s4b
and s5
are better than s3
(again not surprising, since the former has dose-specific intervals), and that s4b
is slightly better than s5
.
But in a sense this comparison is not entirely fair. What we have computed above is the efficiency in terms of units of observation. But isn't a unit with 13 measurements already inherently “larger” than a unit with only 1? Because of our choice of $\UnitCovariance$, a different comparison can be devised. Since we assumed uncorrelated within-unit errors with $\UnitCovariance = σ^2I_{\DimUnit}$ any of the solutions s2
through s5
can be identified with a candidate solution for dp1
. For example, we can “split up” a design point from s4
,
\[(D, Δt),\]
into $\DimUnit$ separate design points
\[(D, 0), (D, Δt), (D, 2Δt), …, (D, (\DimUnit - 1)Δt),\]
each with $1/\DimUnit$ the original weight.
Framed like that, it can make sense to compare efficiency in terms of single measurements. To compute this, we need to multiply eff
by the inverse ratio of unit lengths for the designs under comparison, i.e.
\[\frac{\DimUnit^{(2)}}{\DimUnit^{(1)}} \RelEff(\DesignMeasure_1, \DesignMeasure_2).\]
eff .* [1 1 13 13 13] ./ [1, 1, 13, 13, 13]
5×5 Matrix{Float64}:
1.0 1.48507 1.64462 1.2094 1.32134
0.673371 1.0 1.10744 0.814374 0.889751
0.608044 0.902985 1.0 0.735367 0.803431
0.826858 1.22794 1.35987 1.0 1.09256
0.756809 1.12391 1.24466 0.915283 1.0
Now we see that no design is better than s1
. This is not surprising, since any of the solutions for dp2
through dp5
can be identified with a candidate solution for dp1
, but d1
is already optimal for dp1
.
- 2dKirstine.jl supports an arbitrary number of design variables, but more than two become hard to visualize.
- FH08X. Fang and A. S. Hedayat (2008). Locally D-optimal designs based on a class of composed models resulted from blending Emax and one-compartment models. The Annals of Statistics, 36(1). doi:10.1214/009053607000000776
- DPW09Holger Dette, Andrey Pepelyshev, Wenk Kee Wong (2009). Optimal designs for composed models in pharmacokinetic-pharmacodynamic experiments. doi:10.17877/DE290R-810
- LS14Markus R. Lange and Heinz Schmidli (2014). Optimal design of clinical trials with biologics using dose-time-response models. Statistics in Medicine, 33(30), 5249–5264. doi:10.1002/sim.6299