One model across the class

A simple target-binding, target-killing model fitted to three T-cell engager settings with one free parameter each

pharmacometrics
pharmacokinetics
simulation
working document
A PK-only model with no CD3 arm: drug distributes into a tissue compartment, binds a target pool present in both compartments, and kills the cells carrying it. Shared parameters are fixed at biologically argued values and only the accessible target burden varies by drug and disease.
Published

September 11, 2026

What this is. One structure, fitted to digitized concentrations from two published figures covering three drug-and-disease settings. Shared parameters are fixed at values argued from biology rather than estimated. The accessible target burden is the only free parameter, except for the mosunetuzumab panel, which is a single patient and so carries its own clearance and central volume. Sources and their status are in References.

Scope. Antibody-like T-cell engagers: intact Fc, neonatal Fc receptor recycling, IgG-scale disposition. Blinatumomab and the albumin-binding and ImmTAC formats clear on molecular size and are out.

1. In Brief

One structure fits all three settings. Residual standard deviations on the log scale are 0.26, 0.45 and 0.32, against data spanning 250-fold, 1100-fold and 700-fold in concentration.

The only thing that changes between them is how much target there is.

One structure, one free parameter per setting
Setting Accessible target Implied cells Residual SD (log)
Odronextamab, follicular lymphoma 41.2 mg 3.3 × 10¹² 0.45
Odronextamab, DLBCL 15.1 mg 1.2 × 10¹² 0.26
Mosunetuzumab, one B-NHL patient 1.4 mg 1.2 × 10¹¹ 0.32

The ordering is the biologically expected one. Follicular lymphoma is indolent and carries the largest CD20-positive burden; DLBCL is aggressive with a smaller one; the mosunetuzumab patient is a single individual partway through the range. The spread is about 30-fold across a class where the drugs themselves differ 30-fold in dose.

No CD3 arm is needed. The engager and a plain B-cell depleting antibody were indistinguishable in simulation, and nothing in these three fits asks for a second binding site.

What is not claimed. The target burden is not identifiable to better than about a factor of ten, because it trades against where the target sits. That is expected rather than a defect: the drug sees the product of pool size and accessibility, not either alone.

2. The Model

\[ \begin{aligned} \frac{dA_c}{dt} &= -\frac{CL}{V_c}A_c-\frac{Q_t}{V_c}A_c+\frac{Q_t}{V_t}A_t - k_{int}R_cV_c\frac{C_c}{K_{ss}+C_c}\\ \frac{dA_t}{dt} &= \frac{Q_t}{V_c}A_c-\frac{Q_t}{V_t}A_t - k_{int}R_tV_t\frac{C_t}{K_{ss}+C_t}\\ \frac{dR_j}{dt} &= k_{deg}\!\left(R_j^0-R_j\right) -\left(k_{int}+k_{\rm kill}\right)R_j\frac{C_j}{K_{ss}+C_j},\quad j\in\{c,t\} \end{aligned} \]

Two drug compartments, a target pool in each, quasi-steady-state binding, and two ways for a bound receptor to disappear: internalization with the drug on it, and killing of the cell carrying it. Five states.

Why each shared parameter has the value it has.

The shared parameter set, fixed and not estimated
Parameter Value Reason
\(K_{ss}\) 1.95 mg/L The fitted Michaelis constant from the odronextamab population model, about 13 nM for a 150 kDa antibody, mid-range for a T-cell engager tumor-antigen arm
\(k_{int}\) 1 /day Bound receptor internalized with a half-life near 17 h
\(k_{\rm kill}\) 1 /day Target cell killed at saturating occupancy on the same timescale, which is what these drugs do
\(k_{deg}\) 0.23 /day Target pool turns over with a 3-day half-life
Fraction outside plasma 0.9 B cells and plasma cells are overwhelmingly nodal and marrow, not circulating

None of these was tuned to improve a fit. Section 5 shows what happens when they are moved.

suppressMessages({library(rxode2); library(dplyr); library(tidyr)
                  library(ggplot2); library(patchwork); library(xgxr)})
xgx_theme_set()
knitr::opts_chunk$set(fig.width = 9, fig.height = 6)

m <- rxode2({
  cc <- central/vc; ct <- tissue/vt
  oc <- cc/(kss + cc); ot <- ct/(kss + ct)
  cltot <- cl + kint*rc*vc/(kss + cc) + kint*rt*vt/(kss + ct)
  d/dt(central) <- -cl/vc*central - qt/vc*central + qt/vt*tissue - kint*rc*vc*oc
  d/dt(tissue)  <-  qt/vc*central - qt/vt*tissue - kint*rt*vt*ot
  d/dt(rc) <- kdeg*(rc0 - rc) - (kint + kkill)*rc*oc
  d/dt(rt) <- kdeg*(rt0 - rt) - (kint + kkill)*rt*ot
  rc(0) <- rc0; rt(0) <- rt0
})
SH <- c(kss = 1.95, kint = 1.0, kkill = 1.0, kdeg = log(2)/3, ftis = 0.9)

3. The Data

Digitized from two open-access figures by the scripts in this folder. Odronextamab gives observed medians for the first three weeks in two subtypes; mosunetuzumab gives one patient followed for 15 weeks, chosen as the panel with the least residual rituximab competing for CD20.

o1 <- read.csv("data/odronextamab_fig4A_medians.csv")
setup <- list(
  odro_DLBCL = list(cl = .189, vc = 4.99, vt = 4.42, qt = 1.21, tmax = 22,
    dt = c(0,1,7,8,14,15,21), amt = c(.2,.5,2,2,10,10,160)),
  odro_FL = list(cl = .189, vc = 4.99, vt = 4.42, qt = 1.21, tmax = 22,
    dt = c(0,1,7,8,14,15,21), amt = c(.2,.5,2,2,10,10,160)),
  mosun = list(cl = .584, vc = 5.49, vt = 6.17, qt = 1.46, tmax = 110,
    dt = c(0,7.12,13.12,21.0,42.4,63.4,84.2,105.2), amt = c(1,2,60,60,30,30,30,30)))
obs <- list(
  odro_DLBCL = o1 |> filter(subtype == "DLBCL", conc_mg_L > 0.0035) |> transmute(day, conc = conc_mg_L),
  odro_FL    = o1 |> filter(subtype == "FL",    conc_mg_L > 0.0035) |> transmute(day, conc = conc_mg_L),
  mosun      = read.csv("data/mosunetuzumab_fig4a_obs.csv") |> transmute(day, conc = conc_ug_mL))
tibble(Setting = names(obs),
       n = sapply(obs, nrow),
       `Range (mg/L)` = sapply(obs, function(o) sprintf("%.4g to %.3g", min(o$conc), max(o$conc))),
       `Fold` = round(sapply(obs, function(o) max(o$conc)/min(o$conc)))) |>
  knitr::kable()
Setting n Range (mg/L) Fold
odro_DLBCL 18 0.01398 to 3.48 249
odro_FL 13 0.008437 to 3.75 444
mosun 19 0.0416 to 29.4 708

Reading a peak off a log-scale figure carries about a tenth of a day of error, which is enough to put a sample on the wrong side of a dose. Rather than move any data point, each dose is placed just before the first sample that jumps more than threefold above the one before it.

align <- function(o, dtimes) {
  o <- o[order(o$day), ]
  for (k in seq_along(dtimes)) {
    w <- which(abs(o$day - dtimes[k]) < 0.35); w <- w[w > 1]
    w <- w[o$conc[w] > 3*o$conc[w-1]]
    if (length(w)) dtimes[k] <- o$day[w[1]] - 0.06
  }
  list(obs = o, dt = dtimes)
}

4. The Fits

fit <- function(nm, free_disp = FALSE) {
  d <- setup[[nm]]; al <- align(obs[[nm]], d$dt); o <- al$obs; d$dt <- al$dt
  ev <- Reduce(function(e, i) e |> et(amt = d$amt[i], cmt = "central",
                                      time = d$dt[i], dur = 2/24),
               seq_along(d$dt), et()) |> et(seq(0, d$tmax, by = 0.005))
  f <- function(lp) {
    cl <- if (free_disp) exp(lp[2]) else d$cl
    vc <- if (free_disp) exp(lp[3]) else d$vc
    tot <- exp(lp[1])
    p <- c(cl = cl, vc = vc, vt = d$vt, qt = d$qt, kss = SH[["kss"]],
           kint = SH[["kint"]], kkill = SH[["kkill"]], kdeg = SH[["kdeg"]],
           rc0 = tot*(1 - SH[["ftis"]])/vc, rt0 = tot*SH[["ftis"]]/d$vt)
    s <- try(as_tibble(rxSolve(m, p, ev)), silent = TRUE)
    if (inherits(s, "try-error")) return(1e9)
    pr <- approx(s$time, s$cc, xout = o$day)$y
    if (any(!is.finite(pr)) || any(pr <= 0)) return(1e9)
    sum((log(pr) - log(o$conc))^2)
  }
  starts <- if (free_disp) list(c(5, d$cl, d$vc), c(20, d$cl*2, d$vc*.6), c(1, d$cl*.5, d$vc))
            else list(1, 5, 15, 40, 100)
  best <- NULL
  for (s0 in starts) {
    lp0 <- log(s0)
    r <- optim(lp0, f, method = if (length(lp0) == 1) "Brent" else "Nelder-Mead",
               lower = if (length(lp0) == 1) log(.01) else -Inf,
               upper = if (length(lp0) == 1) log(800) else Inf,
               control = list(maxit = 9000, reltol = 1e-12))
    if (is.null(best) || r$value < best$value) best <- r
  }
  e <- exp(best$par)
  list(target = unname(e[1]), cl = if (free_disp) unname(e[2]) else d$cl,
       vc = if (free_disp) unname(e[3]) else d$vc,
       sd = sqrt(best$value/nrow(o)), n = nrow(o), obs = o, dt = d$dt)
}
fits <- lapply(setNames(names(setup), names(setup)), function(n)
  fit(n, free_disp = (n == "mosun")))

tibble(Setting = c("Odronextamab, DLBCL", "Odronextamab, follicular",
                   "Mosunetuzumab, one patient"),
       `Target (mg)` = round(sapply(fits, `[[`, "target"), 2),
       `Implied cells` = sprintf("%.1e", sapply(fits, `[[`, "target")/1e3/1.5e5*6.022e23/5e4),
       `Residual SD (log)` = round(sapply(fits, `[[`, "sd"), 3),
       n = sapply(fits, `[[`, "n")) |>
  knitr::kable()
Setting Target (mg) Implied cells Residual SD (log) n
Odronextamab, DLBCL 15.05 1.2e+12 0.258 18
Odronextamab, follicular 41.18 3.3e+12 0.454 13
Mosunetuzumab, one patient 1.44 1.2e+11 0.321 19

Implied cells assume 5 × 10⁴ binding sites per cell and a 150 kDa antibody.

lab <- c(odro_DLBCL = "Odronextamab, DLBCL", odro_FL = "Odronextamab, follicular",
         mosun = "Mosunetuzumab, one patient")
S <- list(); O <- list()
for (nm in names(setup)) {
  d <- setup[[nm]]; r <- fits[[nm]]
  ev <- Reduce(function(e, i) e |> et(amt = d$amt[i], cmt = "central",
                                      time = r$dt[i], dur = 2/24),
               seq_along(r$dt), et()) |> et(seq(0, d$tmax, by = 0.005))
  p <- c(cl = r$cl, vc = r$vc, vt = d$vt, qt = d$qt, kss = SH[["kss"]],
         kint = SH[["kint"]], kkill = SH[["kkill"]], kdeg = SH[["kdeg"]],
         rc0 = r$target*(1 - SH[["ftis"]])/r$vc, rt0 = r$target*SH[["ftis"]]/d$vt)
  S[[nm]] <- as_tibble(rxSolve(m, p, ev)) |>
    transmute(day = time, conc = cc,
              frac = (rc*r$vc + rt*d$vt)/(p[["rc0"]]*r$vc + p[["rt0"]]*d$vt),
              who = lab[[nm]])
  O[[nm]] <- r$obs |> mutate(who = lab[[nm]])
}
sim <- bind_rows(S) |> mutate(who = factor(who, levels = lab))
ob  <- bind_rows(O)  |> mutate(who = factor(who, levels = lab))

g1 <- ggplot(sim, aes(day, conc)) + geom_line(linewidth = .5, colour = "#2C6FB5") +
  geom_point(data = ob, aes(day, conc), colour = "#C0392B", size = 1.7) +
  facet_wrap(~who, scales = "free_x") + scale_y_log10() +
  annotation_logticks(sides = "l") +
  labs(x = NULL, y = "Concentration (mg/L)")
g2 <- ggplot(sim, aes(day, 100*frac)) + geom_line(linewidth = .5, colour = "#2E7D5B") +
  facet_wrap(~who, scales = "free_x") +
  labs(x = "Days after first dose", y = "Target pool remaining (%)")
g1 / g2

One structure with one shared parameter set, fitted to three settings by the accessible target burden alone. Upper row, concentration: points are digitized observations, lines are the model. Lower row, the target pool the same fit implies, as a percentage of its starting value.

The lower row is the mechanism the upper row is evidence for. The target pool survives the step-up doses almost intact and then collapses when the first full dose lands: for the mosunetuzumab patient, 60 mg on day 13 takes the pool from 80% to 12% within a day, and it never recovers.

5. How Much the Shared Parameters Matter

The shared values were argued, not fitted. Moving them one at a time changes the target burden the fit wants and leaves the fit quality alone, which is the expected behaviour of a model whose free parameter absorbs them.

sens <- function(par, mult) {
  keep <- SH; SH[[par]] <<- SH[[par]]*mult
  r <- fit("odro_DLBCL"); SH <<- keep
  tibble(Parameter = par, `x` = mult, `Target (mg)` = round(r$target, 2),
         `Residual SD (log)` = round(r$sd, 3))
}
bind_rows(lapply(c(0.3, 1, 3), function(k) sens("kkill", k)),
          lapply(c(0.3, 1, 3), function(k) sens("kss", k)),
          lapply(c(0.5, 1, 2), function(k) sens("kint", k))) |>
  knitr::kable()
Parameter x Target (mg) Residual SD (log)
kkill 0.3 13.48 0.266
kkill 1.0 15.05 0.258
kkill 3.0 18.65 0.252
kss 0.3 6.90 0.257
kss 1.0 15.05 0.258
kss 3.0 33.69 0.284
kint 0.5 27.90 0.263
kint 1.0 15.05 0.258
kint 2.0 8.50 0.253

\(K_{ss}\) and \(k_{int}\) trade against the target burden almost exactly: a threefold change in either moves the fitted burden by two to three-fold and the residual standard deviation by less than 0.03. That is the non-identifiability stated in Section 1, and it is a property of the algebra rather than of this dataset.

\(k_{\rm kill}\) was expected to be different, because it sets how fast the pool empties rather than how strongly the drug binds it. It is not. A tenfold range moves the residual standard deviation from 0.266 to 0.252, which is no preference at all. Three weeks of concentrations through a step-up schedule do not see the emptying rate, and the value of 1 per day used throughout stands on the biological argument alone. Whether a longer profile constrains it is answerable from the mosunetuzumab panel, which runs to 15 weeks, and has not been tested.

6. What This Does and Does Not Establish

Established. A five-state model with no CD3 arm, one shared parameter set and one free burden per setting reproduces three published concentration-time datasets spanning three orders of magnitude in dose, and the burdens it wants are ordered the way the diseases are.

Not established. That the burdens are correct in absolute terms. They are identified only up to the product of pool size, accessibility and affinity, so a factor of ten either way is unresolved and only a target measurement would close it.

Still open. Teclistamab, which is subcutaneous and in myeloma rather than lymphoma, and whose published paper carries no concentration-time figure, so the profile has to come from the supplement. It is the next setting and the one most likely to break the structure, because a BCMA target on marrow plasma cells is the furthest from the two CD20 settings fitted here.

Back to top