metrumresearchgroup / mrgsolve

Simulate from ODE-based population PK/PD and QSP models in R
https://mrgsolve.org
129 stars 36 forks source link

Questions about time-varying variable #1201

Closed kevin199011 closed 2 months ago

kevin199011 commented 2 months ago

Hi, I'm working on an ADC model, involving modeling the time-varying DAR. Below reprex shows a simplified mrgsolve code, where DAR is expressed by first-order decay, and PAYLOAD is expressed by DAR*ADC. When I was doing the simulation, I noticed how the simulation dataset was constructed has an impact on the simulation result. Please see below example. When there was an observation record with time equals dosing time, the order of this record seems to influence the result. In below example, there was a second dose at 12.

As can be seen from the output, df1 and df2 simulated very different results from time=12, while df3 and df4 produced same output, while slightly than df1.

I'm wondering if you can help explain why it has this behavior, and if it's the correct way to code with SOLVERTIME this way? Thank you!

library(tidyverse)
library(mrgsolve)
#> 
#> Attaching package: 'mrgsolve'
#> The following object is masked from 'package:stats':
#> 
#>     filter

code1 <- '
[PARAM]
ALPHA = 0.1
BETA = 0.1
DAR0 = 8
TDOSE = 0

[CMT]
ADC PAYLOAD

[MAIN]
ADC_0 = 10;

[ODE]
double TAD = SOLVERTIME - TDOSE;
double DAR = DAR0*exp(-BETA*TAD);
dxdt_ADC = -ALPHA * ADC;
dxdt_PAYLOAD = DAR*ADC;

[CAPTURE]
TDOSE TAD DAR
'
mod1 <- mcode("time_variant1", code1)
#> Building time_variant1 ...
#> done.

ev <- ev(ID = 1, amt = 0, time = c(0,12)) %>% as.data.frame
obs1 <- expand.grid(ID = 1,
                    time = c(0:24),
                    amt = NA, cmt = 1, evid = 0)
obs2 <- expand.grid(ID = 1,
                    time = c(0,6,12,18,24),
                    amt = NA, cmt = 1, evid = 0)

df1 <- bind_rows(ev, obs1) %>% 
  arrange(ID, time,desc(evid)) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)
df2 <- bind_rows(ev, obs2) %>% 
  arrange(ID, time,desc(evid)) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)

df3 <- bind_rows(ev, obs1) %>% 
  arrange(ID, time, evid) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)
df4 <- bind_rows(ev, obs2) %>% 
  arrange(ID, time, evid) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)

out1 <- mod1 %>% 
  data_set(df1) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame() %>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD1 = PAYLOAD)

out2 <- mod1 %>% 
  data_set(df2) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD2 = PAYLOAD)

out3 <- mod1 %>% 
  data_set(df3) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD3 = PAYLOAD)
#> Warning: Parameter column TDOSE must not contain missing values.

out4 <- mod1 %>% 
  data_set(df4) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD4 = PAYLOAD)
#> Warning: Parameter column TDOSE must not contain missing values.

out <- left_join(out1, out2) %>%
  left_join(out3) %>%
  left_join(out4)
#> Joining, by = c("ID", "time", "evid")
#> Joining, by = c("ID", "time", "evid")Joining, by = c("ID", "time", "evid")

knitr::kable(out)
ID time evid PAYLOAD1 PAYLOAD2 PAYLOAD3 PAYLOAD4
1 0 1 0.0000 0.0000 0.0000 0.0000
1 0 0 0.0000 0.0000 0.0000 0.0000
1 1 0 72.5077 NA 72.5077 NA
1 2 0 131.8720 NA 131.8720 NA
1 3 0 180.4753 NA 180.4753 NA
1 4 0 220.2684 NA 220.2684 NA
1 5 0 252.8482 NA 252.8482 NA
1 6 0 279.5223 279.5223 279.5223 279.5223
1 7 0 301.3612 NA 301.3612 NA
1 8 0 319.2414 NA 319.2414 NA
1 9 0 333.8804 NA 333.8804 NA
1 10 0 345.8659 NA 345.8659 NA
1 11 0 355.6787 NA 355.6787 NA
1 12 1 382.3528 559.0446 363.7128 363.7128
1 12 0 382.3528 559.0446 363.7128 363.7128
1 13 0 404.1917 NA 385.5517 NA
1 14 0 422.0719 NA 403.4319 NA
1 15 0 436.7109 NA 418.0709 NA
1 16 0 448.6964 NA 430.0564 NA
1 17 0 458.5092 NA 439.8692 NA
1 18 0 466.5433 643.2351 447.9033 447.9033
1 19 0 473.1211 NA 454.4811 NA
1 20 0 478.5065 NA 459.8665 NA
1 21 0 482.9157 NA 464.2757 NA
1 22 0 486.5256 NA 467.8856 NA
1 23 0 489.4812 NA 470.8412 NA
1 24 0 491.9010 668.5928 473.2610 473.2610

Created on 2024-06-21 by the reprex package (v2.0.0)

kylebaron commented 2 months ago

Hi @kevin199011 -

Thanks for the question and for putting together the reproducible example. The (big) discrepancy in simulation 2 is due to the next observation carry backward behavior which is similar to how NONMEM works. You can control this behavior in mrgsolve with the nocb argument (see example simulation below). I'd say any time you have time-varying covariates, the simulation result will depend on how you construct the data set.

I'd have to study the inputs more to explain the (smaller) discrepancy with simulation 1.

Also, it's possible that we can make changes to the code so that you don't have this dependence on the data set (I'm not sure ... would have to play with it a bit).

But I'm thinking that the outputs are as expected.

Does that clarify things for you?

Kyle

library(tidyverse)
library(mrgsolve)
#> 
#> Attaching package: 'mrgsolve'
#> The following object is masked from 'package:stats':
#> 
#>     filter

code1 <- '
[PARAM]
ALPHA = 0.1
BETA = 0.1
DAR0 = 8
TDOSE = 0

[CMT]
ADC PAYLOAD

[MAIN]
ADC_0 = 10;

[ODE]
double TAD = SOLVERTIME - TDOSE;
double DAR = DAR0*exp(-BETA*TAD);
dxdt_ADC = -ALPHA * ADC;
dxdt_PAYLOAD = DAR*ADC;

[CAPTURE]
TDOSE TAD DAR
'
mod1 <- mcode("time_variant1", code1)
#> Building time_variant1 ...
#> done.

ev <- ev(ID = 1, amt = 0, time = c(0,12)) %>% as.data.frame
obs1 <- expand.grid(ID = 1,
                    time = c(0:24),
                    amt = NA, cmt = 1, evid = 0)
obs2 <- expand.grid(ID = 1,
                    time = c(0,6,12,18,24),
                    amt = NA, cmt = 1, evid = 0)

df1 <- bind_rows(ev, obs1) %>% 
  arrange(ID, time,desc(evid)) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)

df2 <- bind_rows(ev, obs2) %>% 
  arrange(ID, time,desc(evid)) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)

df3 <- bind_rows(ev, obs1) %>% 
  arrange(ID, time, evid) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE, .direction = "downup")

df4 <- bind_rows(ev, obs2) %>% 
  arrange(ID, time, evid) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE, .direction = "downup")

out1 <- mod1 %>% 
  data_set(df1) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame() %>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD1 = PAYLOAD)

out2 <- mod1 %>% 
  data_set(df2) %>%
  mrgsim(carry.out = c("evid"), nocb = FALSE) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD2 = PAYLOAD)

out3 <- mod1 %>% 
  data_set(df3) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD3 = PAYLOAD)

out4 <- mod1 %>% 
  data_set(df4) %>%
  mrgsim(carry.out = c("evid")) %>%
  as.data.frame()%>%
  select(ID, time, evid, PAYLOAD) %>%
  rename(PAYLOAD4 = PAYLOAD)

out <- left_join(out1, out2) %>%
  left_join(out3) %>%
  left_join(out4)
#> Joining with `by = join_by(ID, time, evid)`
#> Joining with `by = join_by(ID, time, evid)`
#> Joining with `by = join_by(ID, time, evid)`

knitr::kable(out)
ID time evid PAYLOAD1 PAYLOAD2 PAYLOAD3 PAYLOAD4
1 0 1 0.0000 0.0000 0.0000 0.0000
1 0 0 0.0000 0.0000 0.0000 0.0000
1 1 0 72.5077 NA 72.5077 NA
1 2 0 131.8720 NA 131.8720 NA
1 3 0 180.4753 NA 180.4753 NA
1 4 0 220.2684 NA 220.2684 NA
1 5 0 252.8482 NA 252.8482 NA
1 6 0 279.5223 279.5223 279.5223 279.5223
1 7 0 301.3612 NA 301.3612 NA
1 8 0 319.2414 NA 319.2414 NA
1 9 0 333.8804 NA 333.8804 NA
1 10 0 345.8659 NA 345.8659 NA
1 11 0 355.6787 NA 355.6787 NA
1 12 1 382.3528 363.7128 363.7128 363.7128
1 12 0 382.3528 363.7128 363.7128 363.7128
1 13 0 404.1917 NA 385.5517 NA
1 14 0 422.0719 NA 403.4319 NA
1 15 0 436.7109 NA 418.0709 NA
1 16 0 448.6964 NA 430.0564 NA
1 17 0 458.5092 NA 439.8692 NA
1 18 0 466.5433 447.9033 447.9033 447.9033
1 19 0 473.1211 NA 454.4811 NA
1 20 0 478.5065 NA 459.8665 NA
1 21 0 482.9157 NA 464.2757 NA
1 22 0 486.5256 NA 467.8856 NA
1 23 0 489.4812 NA 470.8412 NA
1 24 0 491.9010 473.2610 473.2610 473.2610

Created on 2024-06-21 with reprex v2.0.2

kylebaron commented 2 months ago

@kevin199011 -

Could you describe more what you're aiming at for this simulation? I'm seeing a single ADC administration (through initialization of the ADC compartment) and a couple of DAR "administrations". Is this part of the simplification? or is it what you want for the end result?

I have some code that would get you out of some of these mechanics ... and get your simulation to be independent of the data set structure if you're looking at ADC and DAR getting "dosed" together.

Let me know.

Kyle

library(tidyverse)
library(mrgsolve)
#> 
#> Attaching package: 'mrgsolve'
#> The following object is masked from 'package:stats':
#> 
#>     filter

code1 <- '
[PARAM]
ALPHA = 0.1
BETA = 0.1
DAR0 = 8
TDOSE = 0

[CMT]
ADC PAYLOAD

[MAIN]
ADC_0 = 10;

[ODE]
double TAD = SOLVERTIME - TDOSE;
double DAR = DAR0*exp(-BETA*TAD);
dxdt_ADC = -ALPHA * ADC;
dxdt_PAYLOAD = DAR*ADC;

[CAPTURE]
TDOSE TAD DAR
'
mod1 <- mcode("time_variant1", code1)
#> Building time_variant1 ...
#> done.

ev <- ev(ID = 1, amt = 0, time = c(0,12)) %>% as.data.frame
obs1 <- expand.grid(ID = 1,
                    time = c(0:24),
                    amt = NA, cmt = 1, evid = 0)
obs2 <- expand.grid(ID = 1,
                    time = c(0,6,12,18,24),
                    amt = NA, cmt = 1, evid = 0)

df1 <- bind_rows(ev, obs1) %>% 
  arrange(ID, time,desc(evid)) %>%
  group_by(ID) %>%
  mutate(TDOSE = ifelse(evid > 0, time, NA)) %>%
  fill(TDOSE)

mod1 %>% 
  data_set(df1) %>%
  mrgsim(carry.out = c("evid")) %>%
  plot()

Created on 2024-06-21 with reprex v2.0.2

kevin199011 commented 2 months ago

Hi Kyle, thank you for the explanation. And yes, the example is just for simplification. I would like to know a bit more about your code to be independent of dataset when ADC and "DAR" dosed together. Really appreciate.

Best

Kevin

kylebaron commented 2 months ago

Hi @kevin199011 -

Here's what I was thinking. Not sure how much of the simplified example is in your real problem, but I think this is the approach I'd try - get's rid of any dependence on data set ordering

library(tidyverse)
library(mrgsolve)
#> 
#> Attaching package: 'mrgsolve'
#> The following object is masked from 'package:stats':
#> 
#>     filter
library(knitr)

code2 <- '
[PARAM]
ALPHA = 0.1
BETA = 0.1
DAR0 = 8
TDOSE = 0

[CMT]
ADC PAYLOAD DAR

[PLUGIN] evtools

[MAIN]

// Any time we see an ADC dose, we "DOSE" DAR into its compartment
// Assuming that DAR starts at DAR0 every time ADC is dosed and 
// declines with first order rate constant `BETA`
if(EVID==1) {
  evt::ev dose = evt::bolus(DAR0, 3);
  dose.evid = 8;  // replace the amount in the DAR compartment with DAR0
  dose.now = true;
  self.push(dose);
}

[ODE]
dxdt_DAR = -DAR * BETA; 
dxdt_ADC = -ALPHA * ADC;
dxdt_PAYLOAD = DAR*ADC;

'
mod1 <- mcode("time_variant2", code2)
#> Building time_variant2 ...
#> done.

ADC is dosed Q12

ev <- ev(ID = 1, amt = 10, time = c(0,12))

df0 <- as_data_set(ev)

out0 <- mrgsim(mod1, data = df0, recsort = 3, start = 1)

plot(out0)

Now, try with a couple of different time setups

df1 <- expand_observations(ev, seq(24), obs_pos = 3)

out1 <- mrgsim(mod1, data = df1)

df2 <- expand_observations(ev, seq(24))

out2 <- mrgsim(mod1, data = df2)

df3 <- expand_observations(ev, c(2, 20, seq(6,24,6)), obs_pos = 3)

out3 <- mrgsim(mod1, data = df3)

out4 <- mrgsim(mod1, ev, end = -1, add = c(5, 9, 17.1, 22))

a <- distinct(out0, time, PAYLOAD)
b <- distinct(out1, time, PAYLOAD1 = PAYLOAD)
c <- distinct(out2, time, PAYLOAD2 = PAYLOAD)
d <- distinct(out3, time, PAYLOAD3 = PAYLOAD)
e <- distinct(out4, time, PAYLOAD4 = PAYLOAD)

ans <- left_join(a,b) %>% left_join(c) %>% left_join(d) %>% left_join(e)
#> Joining with `by = join_by(time)`
#> Joining with `by = join_by(time)`
#> Joining with `by = join_by(time)`
#> Joining with `by = join_by(time)`

We get the same answer with any time

knitr::kable(ans)
time PAYLOAD PAYLOAD1 PAYLOAD2 PAYLOAD3 PAYLOAD4
0 0.0000 0.0000 0.0000 0.0000 0.0000
1 72.5077 72.5077 72.5077 NA NA
2 131.8720 131.8720 131.8720 131.8720 NA
3 180.4753 180.4753 180.4753 NA NA
4 220.2684 220.2684 220.2684 NA NA
5 252.8482 252.8482 252.8482 NA 252.8482
6 279.5223 279.5223 279.5223 279.5223 NA
7 301.3612 301.3612 301.3612 NA NA
8 319.2414 319.2414 319.2414 NA NA
9 333.8804 333.8804 333.8804 NA 333.8804
10 345.8659 345.8659 345.8659 NA NA
11 355.6787 355.6787 355.6787 NA NA
12 363.7128 363.7128 363.7128 363.7128 363.7128
13 458.0594 458.0594 458.0594 NA NA
14 535.3039 535.3039 535.3039 NA NA
15 598.5463 598.5463 598.5463 NA NA
16 650.3248 650.3248 650.3248 NA NA
17 692.7175 692.7175 692.7175 NA NA
18 727.4256 727.4256 727.4256 727.4256 NA
19 755.8423 755.8423 755.8423 NA NA
20 779.1079 779.1079 779.1079 779.1079 NA
21 798.1561 798.1561 798.1561 NA NA
22 813.7515 813.7515 813.7515 NA 813.7515
23 826.5199 826.5199 826.5199 NA NA
24 836.9738 836.9738 836.9738 836.9738 NA

Created on 2024-06-24 with reprex v2.0.2

kevin199011 commented 2 months ago

Thank you Kyle, that's very helpful!