Skip to contents
library(rxode2)
#> rxode2 5.0.2 using 2 threads (see ?getRxThreads)
#>   no cache: create with `rxCreateCache()`
library(ggplot2)
library(data.table)

Overview

Exposure-response (E-R) simulation links a pharmacokinetic (PK) model to a pharmacodynamic (PD) model to predict how changes in drug exposure translate to clinical or biomarker outcomes. Common PD model types supported in rxode2 include:

  • Direct effect (Emax, sigmoid Emax, linear)
  • Indirect response (stimulation/inhibition of input/output)
  • Turnover / tolerance
  • Effect compartment (biophase link)
  • Categorical / binary (using simulation distributions)
  • Time-to-event (hazard models)

Direct Emax model

The simplest link model connects the PK model’s central compartment concentration directly to an Emax expression.

emaxMod <- function() {
  ini({
    tka   <- log(1.5)
    tcl   <- log(4.0)
    tv    <- log(35)
    emax  <- 80          # maximum effect (%)
    ec50  <- 2.0         # EC50 (mg/L)
    hill  <- 1.5         # Hill coefficient
    eta.cl ~ 0.09
    eta.v  ~ 0.09
    add.sd <- 2
  })
  model({
    ka <- exp(tka)
    cl <- exp(tcl + eta.cl)
    v  <- exp(tv  + eta.v)
    d/dt(depot)  <- -ka * depot
    d/dt(center) <- ka * depot - cl / v * center
    cp     <- center / v
    effect <- emax * cp^hill / (ec50^hill + cp^hill)
    effect ~ add(add.sd)
  })
}
et <- et(amt = 100, ii = 24, addl = 6) |>
  et(seq(0, 168, by = 1))

sim <- rxSolve(emaxMod, et, nSub = 500, returnType = "data.frame")
dt  <- as.data.table(sim)

ci <- dt[, .(p05 = quantile(effect, 0.05),
             p50 = quantile(effect, 0.50),
             p95 = quantile(effect, 0.95)), by = time]

ggplot(ci, aes(x = time)) +
  geom_ribbon(aes(ymin = p05, ymax = p95), fill = "steelblue", alpha = 0.3) +
  geom_line(aes(y = p50), colour = "steelblue", linewidth = 1) +
  labs(x = "Time (h)", y = "Effect (%)",
       title = "Direct sigmoid Emax model — population prediction") +
  theme_bw()

Exposure-response relationship plot

Plot individual subject AUC vs PD endpoint (time-averaged effect over the last dosing interval at steady state) to visualise the E-R relationship across doses.

doses <- c(25, 50, 100, 200, 400)

simDoses <- rbindlist(lapply(doses, function(d) {
  res <- rxSolve(emaxMod,
                 et(amt = d, ii = 24, addl = 6) |> et(seq(144, 168)),
                 nSub = 200, returnType = "data.frame")
  res$dose <- d
  res
}))

# Compute AUC and mean effect per subject per dose
er <- as.data.table(simDoses)[, .(
  AUC    = sum(diff(time) * (cp[-length(cp)]     + cp[-1])     / 2),
  meanEff = sum(diff(time) * (effect[-length(effect)] + effect[-1]) / 2) / 24
), by = .(sim.id, dose)]

ggplot(er, aes(x = AUC, y = meanEff, colour = factor(dose))) +
  geom_point(alpha = 0.3, size = 0.8) +
  geom_smooth(method = "loess", se = FALSE, linewidth = 1) +
  labs(x = "Steady-state AUC_tau (mg·h/L)",
       y = "Mean effect over last dosing interval (%)",
       colour = "Dose (mg)",
       title = "Exposure-response relationship") +
  theme_bw()

Indirect response model (IDR)

An indirect response model is used when the drug does not act directly but instead modulates production or elimination of a response variable. Here CL is stimulated by the drug effect (IDR type IV: drug stimulates output elimination).

idrMod <- function() {
  ini({
    tka   <- log(1.5)
    tcl   <- log(4.0)
    tv    <- log(35)
    kin   <- 10      # zero-order production rate of response
    kout  <- 0.1     # first-order elimination rate of response
    imax  <- 0.8     # maximum inhibition
    ic50  <- 1.5     # IC50 (mg/L)
    eta.cl ~ 0.09
  })
  model({
    ka <- exp(tka)
    cl <- exp(tcl + eta.cl)
    v  <- exp(tv)
    d/dt(depot)    <- -ka * depot
    d/dt(center)   <- ka * depot - cl / v * center
    cp             <- center / v
    ## IDR type II: drug inhibits production (kin)
    d/dt(response) <- kin * (1 - imax * cp / (ic50 + cp)) - kout * response
    response(0)    <- kin / kout   # initialise at baseline
  })
}
etIdr <- et(amt = 100, ii = 24, addl = 13) |>
  et(seq(0, 336, by = 2))

simIdr <- rxSolve(idrMod, etIdr, nSub = 200, returnType = "data.frame")
dtIdr  <- as.data.table(simIdr)

ciIdr <- dtIdr[, .(p05 = quantile(response, 0.05),
                   p50 = quantile(response, 0.50),
                   p95 = quantile(response, 0.95)), by = time]

ggplot(ciIdr, aes(x = time)) +
  geom_ribbon(aes(ymin = p05, ymax = p95), fill = "coral", alpha = 0.3) +
  geom_line(aes(y = p50), colour = "coral", linewidth = 1) +
  geom_hline(yintercept = 100, linetype = "dashed") +
  labs(x = "Time (h)", y = "Biomarker response (units)",
       title = "Indirect response model — inhibition of production",
       subtitle = "Dashed: baseline. 14-day 100 mg QD dosing") +
  theme_bw()

An effect compartment introduces a delay between plasma concentration and the observed effect.

ecMod <- function() {
  ini({
    tka  <- log(1.5)
    tcl  <- log(4.0)
    tv   <- log(35)
    keo  <- 0.2     # equilibration rate constant (1/h)
    emax <- 75
    ec50 <- 1.5
    eta.cl ~ 0.09
  })
  model({
    ka <- exp(tka)
    cl <- exp(tcl + eta.cl)
    v  <- exp(tv)
    d/dt(depot)  <- -ka * depot
    d/dt(center) <- ka * depot - cl / v * center
    cp <- center / v
    ## Effect compartment
    d/dt(ce) <- keo * (cp - ce)
    effect   <- emax * ce / (ec50 + ce)
  })
}
simEc <- rxSolve(ecMod,
                 et(amt = 100) |> et(seq(0, 24, by = 0.25)),
                 nSub = 300, returnType = "data.frame")
dtEc  <- as.data.table(simEc)

ciEc <- dtEc[, .(
  p50cp  = median(cp),
  p50ce  = median(ce),
  p50eff = median(effect)
), by = time]

dtEc_long <- melt(ciEc, id.vars = "time",
                  measure.vars = c("p50cp", "p50ce", "p50eff"),
                  variable.name = "var", value.name = "value")
dtEc_long$var <- factor(dtEc_long$var,
                        labels = c("Plasma Cp", "Effect-cmt Ce", "Effect (%)"))

ggplot(dtEc_long, aes(x = time, y = value, colour = var)) +
  geom_line(linewidth = 1) +
  facet_wrap(~var, scales = "free_y") +
  labs(x = "Time (h)", y = NULL,
       title = "Effect compartment model — single 100 mg dose") +
  theme_bw() +
  theme(legend.position = "none")

Binary (logistic) PD endpoint

Simulate a binary response (responder/non-responder) using a logistic function of AUC.

# Simulate AUC for 500 subjects under 100 mg QD steady state
simBin <- rxSolve(emaxMod,
                  et(amt = 100, ii = 24, addl = 6) |> et(seq(144, 168)),
                  nSub = 500, returnType = "data.frame")
dtBin  <- as.data.table(simBin)
aucBin <- dtBin[, .(AUC = sum(diff(time) * (cp[-length(cp)] + cp[-1]) / 2)),
                by = sim.id]

# Logistic model: log-odds = -2 + 0.3 * log(AUC)
set.seed(42)
aucBin$logOdds <- -2 + 0.3 * log(aucBin$AUC)
aucBin$prob    <- 1 / (1 + exp(-aucBin$logOdds))
aucBin$resp    <- rbinom(nrow(aucBin), 1, aucBin$prob)

ggplot(aucBin, aes(x = AUC, y = resp)) +
  geom_jitter(height = 0.05, alpha = 0.3, size = 0.8) +
  geom_smooth(method = "glm", method.args = list(family = "binomial"),
              colour = "steelblue", linewidth = 1) +
  labs(x = "Steady-state AUC_tau (mg·h/L)",
       y = "Probability of response",
       title = "Exposure-response: binary endpoint") +
  theme_bw()

Tips

  • Use mix() for mixture models where a sub-population is a non-responder (e.g. poor metabolisers or intrinsic non-responders).