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)One model across the class
A simple target-binding, target-killing model fitted to three T-cell engager settings with one free parameter each
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.
| 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.
| 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.
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
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.
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.