using DeepPumas
using CairoMakie
using StableRNGs
using PumasPlots
set_mlp_backend(:staticflux)
Mixed Effects Pharmacodynamics Identification using DeepNLME
1 Learning objectives
By the end of this tutorial, you will be able to use neural-embedded nonlinear mixed effects (DeepNLME) modeling to learn and improve existing dynamics models in the presence of random effects.
2 Setup
First, we need to load the required packages. We also set the backend for the MLPDomain
to use :staticflux
.
3 Synthetic data generation
We will generate synthetic data from the indirect response model (IDR) model defined below.
= @model begin
datamodel @param begin
∈ RealDomain()
tvKa ∈ RealDomain()
tvCL ∈ RealDomain()
tvVc ∈ RealDomain()
tvSmax ∈ RealDomain()
tvn ∈ RealDomain()
tvSC50 ∈ RealDomain()
tvKout ∈ RealDomain()
tvKin ∈ PDiagDomain(5)
Ω ∈ RealDomain()
σ end
@random begin
~ MvNormal(Ω)
η end
@pre begin
= tvSmax * exp(η[1])
Smax = tvSC50 * exp(η[2])
SC50 = tvKa * exp(η[3])
Ka = tvVc * exp(η[4])
Vc = tvKout * exp(η[5])
Kout = tvKin
Kin = tvCL
CL = tvn
n end
@init begin
= Kin / Kout
Response end
@vars begin
:= max(Central / Vc, 0.0)
cp := Smax * cp^n / (SC50^n + cp^n)
EFF end
@dynamics begin
' = -Ka * Depot
Depot' = Ka * Depot - (CL / Vc) * Central
Central' = Kin * (1 + EFF) - Kout * Response
Responseend
@derived begin
~ @. Normal(Response, σ)
Outcome end
end
The following parameters are used to generate the synthetic data.
= (;
p_data = 0.5,
tvKa = 1.0,
tvCL = 1.0,
tvVc = 2.9,
tvSmax = 1.5,
tvn = 0.05,
tvSC50 = 2.2,
tvKout = 0.8,
tvKin = Diagonal(fill(0.1, 5)),
Ω = 0.1,
σ )
To generate the synthetic data, we will use the simobs
function. The obstimes
argument specifies the time points at which observations are made. We assume all subjects are observed at times 0, 1, 2, …, 24 hours. Each subject receives two doses of 1 unit. The first dose is given at baseline and the second dose is at a random time centered around hour 5. A total of 22 subjects are generated, with 20 subjects used for training and 12 for testing.
= 0:24
obstimes = 20
ntrain = 12
ntest = map(1:ntrain+ntest) do i
pop = StableRNG(i)
rng = DosageRegimen(1.0)
dose_1 = DosageRegimen(1.0; time = rand(rng, Gamma(40, 5 / 40)))
dose_2 = simobs(
sim
datamodel,Subject(; id = i, events = DosageRegimen(dose_1, dose_2)),
p_data;
obstimes,
rng,
)Subject(sim)
end
= pop[1:ntrain]
trainpop = pop[(ntrain+1):end] testpop
We then visualize the synthetic data and the predictions of the data-generating model. The specified obstimes
with a smaller time step is just to get a smooth plot.
= predict(datamodel, testpop, p_data; obstimes = 0:0.1:24);
pred_datamodel plotgrid(pred_datamodel)
4 Neural-embedded NLME modeling
To model the synthetic data, we will use the neural-embedded nonlinear mixed effects (DeepNLME) model shown below. This model includes a multi-layer perceptron (MLP) to capture the nonlinear relationship between the drug concentration and the response. The MLP is defined in the @param
block using the MLPDomain
function. The MLP has 5 inputs (2 state variables + 3 random effects) and a single output.
= @model begin
model @param begin
∈ MLPDomain(5, 7, 7, (1, identity); reg = L2(1.0))
NN ∈ RealDomain(; lower = 0)
tvKa ∈ RealDomain(; lower = 0)
tvCL ∈ RealDomain(; lower = 0)
tvVc ∈ RealDomain(; lower = 0)
tvR₀ ∈ RealDomain(; lower = 0)
ωR₀ ∈ PDiagDomain(2)
Ω ∈ RealDomain(; lower = 0)
σ end
@random begin
~ MvNormal(Ω)
η ~ MvNormal(0.01 * I(3))
η_nn end
@pre begin
= tvKa * exp(η[1])
Ka = tvVc * exp(η[2])
Vc = tvCL
CL = tvR₀ * exp(10 * ωR₀ * η_nn[1])
Response₀ = fix(NN, η_nn)
iNN end
@init begin
= Response₀
Response end
@dynamics begin
' = -Ka * Depot
Depot' = Ka * Depot - (CL / Vc) * Central
Central' = iNN(Central / Vc, Response)[1]
Responseend
@derived begin
~ @. Normal(Response, σ)
Outcome end
end
The model above has 5 random effects, 2 in η
and 3 in η_nn
. The two random effects in η
are used to model the between-subject variability of the pharmacokinetic (PK) parameters. The remaining 3 random effects in η_nn
are used as input to the MLP. However, η_nn[1]
is also used to define the initial value of the response variable Response
using Response₀ = tvR₀ * exp(10 * ωR₀ * η_nn[1])
.
Note that η_nn[1]
is a normally distributed random variable with mean 0 and standard deviation 1. The term 10 * ωR₀ * η_nn[1]
is therefore another normally distributed random variable with mean 0 and standard deviation 10 * ωR₀
. The term 10 * ωR₀ * η_nn[1]
can be interpreted as a multiplicative deviation from the typical value tvR₀
, and η_nn[1]
is simply that deviation standardized. Using η_nn[1]
both as the random effect on the initial value of Response
and as an input to the MLP allows the model to learn how the initial value of Response
affects the dynamics of the system.
The fix
function is used to fix the random effects η_nn
as non-dynamic inputs to the MLP, effectively creating an “individual” neural network iNN
for each subject. Since the MLP has 5 inputs and η_nn
is 3-dimensional, the output of fix
will be another function with the remaining 2 inputs.
5 Fitting the model
Next, we fit the model to the training data. The fit
function is used to estimate the parameters of the model using the training population trainpop
. The initial parameter estimates are provided by init_params(model)
, and we use the MAP(FOCE())
method for fitting. The reason MAP
is required is that under the hood, the regularization is implemented as a prior distribution on the parameters. Without MAP
, the regularization would not be applied. FOCE()
indicates that the first-order conditional estimation method will be used to approximate the marginal likelihood when fitting. We run the fit up to 150 iterations. Note that this step will take some time.
= fit(
fpm
model,
trainpop,init_params(model),
MAP(FOCE());
= (; iterations = 300),
optim_options )
6 Evaluating the model on the training data
We can calculate the individual and population predictions using the fitted model on the training data using predict
and visualize the predictions using plotgrid
.
= predict(fpm; obstimes = 0:0.1:24);
pred_traindata plotgrid(pred_traindata)
After fitting, we can use inspect
function to compute a number of useful statistics using the fitted model, including the empirical Bayes estimates of the random effects, the individual and population predictions, and the weighted residuals. The output of inspect
can be passed to a number of plotting functions to visualize the results. The goodness_of_fit
is a generally useful function that can be used to visualize multiple goodness of fit metrics.
= inspect(fpm)
ins goodness_of_fit(ins)
[ Info: Calculating predictions. [ Info: Calculating weighted residuals. [ Info: Calculating empirical bayes. [ Info: Evaluating dose control parameters. [ Info: Evaluating individual parameters. [ Info: Done.
Another standard diagnostic plot in pharmacometrics is the visual predictive check (VPC). The VPC compares the observed data with the simulated data from the fitted model. We can use the vpc
function to compute the VPC metrics and then plot them using vpc_plot
.
= vpc(fpm, observations = [:Outcome], bandwidth = 3) vpc_res
vpc_plot(
vpc_res;= true,
simquantile_medians = (xlabel = "Time (hours)", ylabel = "Outcome"),
axis )
7 Evaluating the model on the test data
A model can generally be considered successful if its predictions match the observations well for test data, not seen during the fit, and if the other goodness of fit diagnostics are satisfactory. We can predict for the test data using predict
and visualize the predictions using plotgrid
.
= predict(model, testpop, coef(fpm); obstimes = 0:0.1:24);
pred plotgrid(pred; ylabel = "Outcome (Test data)")
We can also plot the VPC for the test data using the vpc
function. This will give us an idea of how well the model generalizes to unseen data.
= vpc(model, testpop, coef(fpm); observations = [:Outcome], bandwidth = 3) vpc_res_test
vpc_plot(
vpc_res_test;= true,
simquantile_medians = (xlabel = "Time (hours)", ylabel = "Outcome"),
axis )
8 New dosing regimen
Assuming the actual relationship between the drug and response is identifiable from the data, if we discovered this relationship, we should be able to use the previously fitted model to fit the data for new synthetic subjects under a dosing regimen that the model has never seen before in the training data. Let’s see what happens if we supply three low doses (0.3), wait a while and then supply two high doses (1.5).
To test this, we generate some synthetic test data using the synthetic_data
function. The dosing regimen is specified using the DosageRegimen
function.
= DosageRegimen(0.3, ii = 3, addl = 2)
dr2 = DosageRegimen(1.5, time = 25, ii = 8, addl = 1)
dr3 = synthetic_data(
testpop2
datamodel,DosageRegimen(dr2, dr3),
p_data;= 12,
nsubj = 0:2:48,
obstimes )
We then use the fitted model to predict the response for the new subjects.
= predict(model, testpop2, coef(fpm); obstimes = 0:0.01:48);
pred2 plotgrid(pred2)
We can then overlay the data-generating model’s individual predictions of these test subjects to compare them against our previously fitted model. Note that no model fine-tuning is done here, we are simply using the fitted model to predict the response for the new subjects.
= predict(datamodel, testpop2, p_data; obstimes = 0:0.01:48);
pred_truth plotgrid!(
pred_truth;= false,
pred = (; color = Cycled(3), label = "DataModel ipred"),
ipred )
We can see that the model has successfully learned some generalizable dynamics for the system, as evidenced by the DeepNLME model’s ability to predict the test data accurately, including dosing regimens that it was not trained on.
9 Conclusion
In this tutorial, we have demonstrated how to use DeepNLME to model data generated from an indirect response model. We have shown how to define a neural-embedded NLME model, fit it to the training data, plot fitting diagnostics, and then validate the model’s performance on test data. The model successfully learned a good dynamics model that was able to generalize to new dosing regimens.