Add pkpd-modeling skill
Pharmacokinetic and pharmacodynamic modelling and simulation: NCA, compartmental fitting, population PK dataset validation, regimen simulation, exposure-response, bioequivalence, allometric scaling and first-in-human dose, ICH M12 drug-interaction prediction, and MAP Bayesian therapeutic drug monitoring. Linear mammillary models are solved analytically -- one eigendecomposition of the rate matrix yields the impulse response, and each input type is a closed-form convolution. This is far faster than an ODE solve inside a fitter, keeps solver tolerance out of the objective function, and makes the removable singularity at ka = -lambda (the flip-flop boundary) an exact limit rather than a nan. Michaelis-Menten and TMDD, which have no closed form, integrate with LSODA and apply doses by restarting at each event. Numerics are validated against results that do not depend on the implementation: closed-form identities (AUC = D/CL, Vss, MRT, Bateman), analytical profiles with known parameters that NCA and the fitter must recover, the PowerTOST bioequivalence sample-size table (exact at six CVs), the EMA ABEL cap, and the FDA body-surface-area conversion factors. 92 tests pass in an isolated numpy+scipy environment. Documents current tooling verified against live sources rather than recalled: Pharmpy 2.1.1 including the 2.0.0 row-index and 2.1.0 set_placebo_model breaking changes, NONMEM 7.6 ADVAN16/17, and the 2024-2025 status of ICH M12, M13A/B, E11A and FDA Project Optimus. Provenance for every version- and date-specific claim is recorded in references/source-ledger.md.
This commit is contained in:
381
skills/pkpd-modeling/SKILL.md
Normal file
381
skills/pkpd-modeling/SKILL.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
name: pkpd-modeling
|
||||
description: Pharmacokinetic and pharmacodynamic modelling and simulation - non-compartmental analysis, compartmental and population PK, PK/PD and exposure-response, TMDD, PBPK orientation, bioequivalence, allometric scaling and first-in-human dose, drug interaction prediction, and Bayesian therapeutic drug monitoring. Use when analysing concentration-time data, deriving exposure metrics, fitting PK or PD models, or evaluating dosing regimens. Triggers include "pharmacokinetics", "pharmacodynamics", "PK/PD", "NCA", "non-compartmental", "AUC", "Cmax", "lambda z", "half-life", "clearance", "volume of distribution", "compartmental model", "population PK", "popPK", "NONMEM", "nlmixr2", "Pharmpy", "Monolix", "exposure-response", "Emax", "EC50", "indirect response", "effect compartment", "TMDD", "PBPK", "bioequivalence", "RSABE", "ABEL", "allometric scaling", "first-in-human", "MABEL", "drug-drug interaction", "DDI", "ICH M12", "concentration-QTc", "therapeutic drug monitoring", "MIPD", and "dosing regimen".
|
||||
license: MIT
|
||||
compatibility: Requires Python 3.11+ with numpy and scipy. No network access and no proprietary software. The estimation tools this skill orients you towards (NONMEM, Monolix, Phoenix, Simcyp, GastroPlus) are licensed separately and are never invoked by these scripts.
|
||||
allowed-tools: Read Write Edit Bash
|
||||
metadata:
|
||||
version: "1.0"
|
||||
skill-author: K-Dense Inc.
|
||||
last-reviewed: "2026-07-27"
|
||||
---
|
||||
|
||||
# Pharmacokinetic and Pharmacodynamic Modelling
|
||||
|
||||
## When to use
|
||||
|
||||
Any question about what the body does to a drug or what the drug does to the body: deriving
|
||||
exposure metrics from concentration-time data, fitting a structural model, building or checking a
|
||||
population analysis, choosing a dose or a regimen, relating exposure to effect, comparing
|
||||
formulations, or scaling to a new population.
|
||||
|
||||
## The three rules
|
||||
|
||||
**1. Fix the exposure metric and the analysis population before computing anything.** AUC(0-t),
|
||||
AUC(0-inf), AUC(0-tau) at steady state, and Cavg are different quantities and answer different
|
||||
questions. So do AUCinf based on observed versus predicted Clast. Choosing after seeing the
|
||||
numbers is how a negative study becomes positive.
|
||||
|
||||
**2. Structural model, variability model, and covariate model are three separate decisions.** They
|
||||
get conflated constantly — an extra compartment added to absorb what is really unmodelled
|
||||
between-occasion variability, a covariate added to fix what is really a misspecified absorption
|
||||
model. Diagnose which one is wrong before changing any of them.
|
||||
|
||||
**3. Convergence is not identifiability.** A fit that converges with 200% relative standard error
|
||||
on a parameter, or a correlation of 0.99 between two, has told you the data cannot separate them.
|
||||
Every fitting script here reports both and flags them, because the parameter table alone looks
|
||||
fine in exactly this situation.
|
||||
|
||||
## Scope
|
||||
|
||||
This skill computes, diagnoses, and structures. It does **not** decide that a formulation is
|
||||
bioequivalent, select a dose for a trial, recommend a dose for a patient, conclude that a drug has
|
||||
no QT liability, or replace a qualified pharmacometrician, clinical pharmacologist, or the
|
||||
regulatory review. The scripts report; none of them concludes. `tdm_bayes.py` in particular is a
|
||||
modelling aid — any change to a patient's regimen is the treating clinician's decision.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
cd skills/pkpd-modeling/scripts
|
||||
```
|
||||
|
||||
| Script | Question answered |
|
||||
| --- | --- |
|
||||
| `nca.py` | What are the exposure metrics, and is the terminal phase good enough to report them? |
|
||||
| `fit_compartmental.py` | Which structural model do these data support, and are its parameters identifiable? |
|
||||
| `simulate_regimen.py` | What does this regimen do at steady state, and to what fraction of the population? |
|
||||
| `check_popk_dataset.py` | Will NONMEM read this dataset the way I think it will? |
|
||||
| `exposure_response.py` | Is there an exposure-response relationship, and is the plateau in the data? |
|
||||
| `bioequivalence.py` | Does the 90% CI meet the criterion, and which criterion applies? |
|
||||
| `allometry_and_fih.py` | What is the starting dose, or the dose in a smaller/younger population? |
|
||||
| `ddi_static.py` | Does the in vitro data trigger a clinical DDI study under ICH M12? |
|
||||
| `tdm_bayes.py` | What are this patient's individual parameters from their measured levels? |
|
||||
|
||||
All take `--format table|tsv|json`. Data goes to stdout, provenance and findings to stderr, so
|
||||
`> out.tsv` keeps them separate. Exit code is `0` for no findings, `1` when findings were raised,
|
||||
`2` for bad input, so any of them can gate a workflow.
|
||||
|
||||
Two private modules carry the shared machinery: `_models.py` (analytical solutions for linear
|
||||
mammillary models, plus integrated Michaelis-Menten, TMDD and indirect-response structures) and
|
||||
`_common.py` (I/O and reporting). Import them rather than re-deriving a Bateman function.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Non-compartmental analysis
|
||||
|
||||
```bash
|
||||
python3 nca.py -i profile.csv --dose 100 --route extravascular --partial-auc 0-24
|
||||
```
|
||||
|
||||
Four choices decide the answer and are usually left implicit. This script makes all four explicit:
|
||||
`--auc-method` (default `linup-logdown`), `--blq-rule`, `--lambda-z-points` or an explicit
|
||||
`--lambda-z-window`, and whether you report `auc_inf_obs` or `auc_inf_pred`.
|
||||
|
||||
Lambda_z selection uses the standard rule: start from the last three quantifiable points, extend
|
||||
backwards, keep the longer window only if **adjusted** r-squared improves by more than 0.0001.
|
||||
Plain r-squared can only rise as points are added, so it would always pick the longest window.
|
||||
Points at or before Tmax are never eligible — including Tmax fits the tail of absorption and
|
||||
biases half-life, Vz and AUCinf downward.
|
||||
|
||||
On a noiseless simulated one-compartment oral profile with CL/F = 5, V/F = 20, ka = 1.2:
|
||||
|
||||
```
|
||||
id cmax tmax auc_last lambda_z t_half r2_adj auc_inf_obs pct_auc_extrap cl_f vz_f
|
||||
1 3.29678 1.5 19.8737 0.25 2.77259 1 19.8739 0.000781037 5.03173 20.1269
|
||||
```
|
||||
|
||||
The 0.6% overestimate of CL/F is the trapezoidal rule on a sparsely sampled absorption phase, not
|
||||
an error — it is the irreducible bias of NCA on that sampling schedule, and it is why NCA and
|
||||
compartmental estimates of clearance never agree exactly.
|
||||
|
||||
The findings are the point. A steady-state profile truncated at tau produces:
|
||||
|
||||
```
|
||||
finding: subject A: 25.2% of AUCinf is extrapolated (above 20%); AUCinf is driven by the
|
||||
lambda_z fit, not by data
|
||||
finding: subject A: lambda_z window spans 0.58 half-lives (below 2.0); the terminal phase may
|
||||
not have been reached
|
||||
```
|
||||
|
||||
Both are correct and both are routinely ignored. At steady state the reportable exposure metric is
|
||||
AUC(0-tau), not AUCinf; the script computes AUCinf anyway and tells you not to trust it.
|
||||
|
||||
### 2. Compartmental fitting and model selection
|
||||
|
||||
```bash
|
||||
python3 fit_compartmental.py -i profile.csv --dose 500 --route iv-bolus --compare 1cmt,2cmt,3cmt
|
||||
```
|
||||
|
||||
Parameters are estimated on the log scale, so they cannot go negative and their confidence
|
||||
intervals come out asymmetric. Weighting defaults to `1/y2` (constant CV), which is the right
|
||||
default for PK and the wrong one for a homoscedastic PD endpoint.
|
||||
|
||||
Fitting simulated two-compartment data (CL 4, V1 12, Q 6, V2 40, 8% proportional error):
|
||||
|
||||
```
|
||||
model parameters wssr aic bic f_vs_simpler f_p_value compared_with
|
||||
1cmt 2 3.13201 -19.4956 -18.0795 n/a n/a n/a
|
||||
2cmt 4 0.0309579 -84.7477 -81.9155 550.936 9.38016e-12 1cmt
|
||||
3cmt 6 0.0232859 -85.0193 -80.771 1.4826 0.27762 2cmt
|
||||
```
|
||||
|
||||
**AIC picks the three-compartment model. BIC and the F test both reject it.** AIC's fixed penalty
|
||||
of 2 per parameter is weak at this sample size, and it selects the overparameterised model more
|
||||
often than practitioners expect. The parameter table settles it:
|
||||
|
||||
```
|
||||
finding: fit: Q3 has 98% RSE - not estimable from these data at this model size
|
||||
finding: fit: V3 has 71% RSE - not estimable from these data at this model size
|
||||
```
|
||||
|
||||
The one-compartment fit meanwhile earns:
|
||||
|
||||
```
|
||||
finding: fit: residual signs are not random (runs test p = 0.0036) - a structural
|
||||
misspecification, which no amount of reweighting will fix
|
||||
```
|
||||
|
||||
That distinction — structural misspecification versus a wrong error model — is the one to get
|
||||
right. A residual-versus-time plot with runs of the same sign means the *model shape* is wrong.
|
||||
Heteroscedastic residuals with random signs mean the *weighting* is wrong. Reweighting the first
|
||||
case hides it without fixing it.
|
||||
|
||||
### 3. Population PK
|
||||
|
||||
Check the dataset before running anything. This is where the time actually goes.
|
||||
|
||||
```bash
|
||||
python3 check_popk_dataset.py -i nmdata.csv --covariates WT,CRCL --time-varying WT
|
||||
```
|
||||
|
||||
The defects that matter are the silent ones. NM-TRAN does not reject a non-numeric DV — it reads
|
||||
`BLQ` as zero and fits it as a genuine zero concentration. A blank covariate becomes 0, so a
|
||||
missing body weight becomes a 0 kg patient. `ADDL` without `II` places no additional doses.
|
||||
Records sharing a timestamp are applied in file order, so whether a level is pre- or post-dose
|
||||
depends on which row came first. None of these stop a run.
|
||||
|
||||
```
|
||||
severity check detail
|
||||
error non-numeric DV DV contains text... NM-TRAN reads them as 0
|
||||
error subject with no dose 1 subject(s) have observations but no dose: 2
|
||||
error TIME not sorted 1 subject(s) have out-of-order TIME: 1
|
||||
error covariate WT missing 1 record(s) have no value...
|
||||
warning duplicate TIME within a subject NONMEM applies them in file order...
|
||||
```
|
||||
|
||||
For the estimation itself, this skill does not reimplement NLME — see
|
||||
`references/population-pk.md` for estimation methods, the BLQ M1-M7 methods, covariate model
|
||||
building, and the diagnostics that decide whether a model is acceptable, and
|
||||
`references/software-ecosystem.md` for which tool to reach for.
|
||||
|
||||
### 4. Simulation and regimen selection
|
||||
|
||||
```bash
|
||||
python3 simulate_regimen.py --cl 5 --v 40 --dose 500 --interval 12 --n-doses 10 --steady-state
|
||||
python3 simulate_regimen.py --cl 5 --v 40 --dose 500 --interval 12 --n-doses 10 \
|
||||
--simulate 2000 --omega-cl 0.35 --omega-v 0.25 --target-trough 4.0
|
||||
```
|
||||
|
||||
Deterministic simulation answers "what does the typical patient look like", which is almost never
|
||||
the question:
|
||||
|
||||
```
|
||||
metric p5 p25 median p75 p95 geo_mean
|
||||
peak 11.861 14.6018 16.6702 19.1476 22.9339 16.6453
|
||||
trough 0.863226 2.15191 3.64412 5.49655 9.21053 3.27035
|
||||
|
||||
target fraction_attaining
|
||||
trough >= 4 0.444
|
||||
```
|
||||
|
||||
The typical trough is 3.6 and the target is 4, so **44% of the population attains it**. A regimen
|
||||
tuned on the typical patient leaves about half the population on the wrong side of the target.
|
||||
Reported attainment is still optimistic here: this is between-subject variability only, with no
|
||||
residual or between-occasion component.
|
||||
|
||||
Linear models are solved analytically and superposed, which is exact. `--nonlinear` switches to
|
||||
integrated Michaelis-Menten elimination, where superposition is invalid and multiple-dose
|
||||
behaviour cannot be inferred from a single dose at all.
|
||||
|
||||
### 5. Exposure-response
|
||||
|
||||
```bash
|
||||
python3 exposure_response.py --emax -i er.csv --sigmoid
|
||||
python3 exposure_response.py --cqtc -i qt.csv --cmax 250
|
||||
```
|
||||
|
||||
The Emax fit reports `fraction_of_emax_reached` and flags a fit whose plateau is outside the data.
|
||||
When the highest observed exposure reaches only a third of the estimated Emax, Emax and EC50 are
|
||||
extrapolations that are strongly correlated with each other; quoting them as independent estimates
|
||||
is not supportable, and a "linear" exposure-response is simply the low-concentration limb of the
|
||||
same curve.
|
||||
|
||||
`--cqtc` evaluates the **upper bound of the two-sided 90% confidence interval** of predicted
|
||||
placebo-corrected change-from-baseline QTc against the 10 ms threshold, which is the question ICH
|
||||
E14 actually asks. A point estimate, or a 95% interval, answers a different one. The bundled model
|
||||
is an ordinary linear regression for screening; a submission-grade C-QTc analysis needs a mixed
|
||||
model with random intercept and slope per subject.
|
||||
|
||||
Every mode carries the same caveat, because it is the one that gets forgotten: patients are
|
||||
randomised to **dose**, not to **exposure**. Exposure-response across quantiles is observational
|
||||
even inside a randomised trial, and can reflect the covariates that drive clearance.
|
||||
|
||||
### 6. Bioequivalence
|
||||
|
||||
```bash
|
||||
python3 bioequivalence.py -i be.csv --design 2x2 --metric AUC
|
||||
python3 bioequivalence.py -i be.csv --design replicate --metric Cmax --scaling both
|
||||
python3 bioequivalence.py --power --cv 0.30 --gmr 0.95 --target-power 0.80
|
||||
```
|
||||
|
||||
Three criteria share the word "bioequivalence" and are not interchangeable: average BE (90% CI
|
||||
inside 80.00-125.00%), EMA's ABEL (limits widened as a function of CVwR, capped at
|
||||
69.84-143.19%, point estimate still within 80-125%), and FDA's RSABE (a scaled linearised bound
|
||||
via Hyslop's method, not an interval at all). `--scaling` refuses to run on a 2x2 design:
|
||||
|
||||
```
|
||||
error: reference-scaling requires --design replicate. High observed variability in a 2x2 study
|
||||
does not license widening: without replicated reference administrations there is no estimate of
|
||||
within-subject reference variability to scale to.
|
||||
```
|
||||
|
||||
Sample size reproduces the published tables exactly (CV 30%, GMR 0.95, 80% power → N = 40 for a
|
||||
2x2). Power is computed by integrating over the sampling distribution of the estimated standard
|
||||
deviation rather than treating the standard error as known — the normal approximation overstates
|
||||
power at realistic sample sizes. Note that **N is driven far more by the assumed GMR than by CV**;
|
||||
assuming 1.00 instead of 0.95 roughly halves the calculated N and is the usual reason a BE study
|
||||
comes in underpowered.
|
||||
|
||||
### 7. Scaling, paediatrics, and first-in-human
|
||||
|
||||
```bash
|
||||
python3 allometry_and_fih.py --scale --cl 5 --weight-from 70 --weight-to 6 --pma-weeks 44
|
||||
python3 allometry_and_fih.py --fih --noael rat=50,dog=10 --safety-factor 10
|
||||
```
|
||||
|
||||
Scaling by size alone below about 2 years of age overpredicts clearance, in a neonate by several
|
||||
fold, because clearance is limited by enzyme and renal maturation rather than by size. Supplying
|
||||
`--pma-weeks` adds the Anderson-Holford sigmoidal maturation term; omitting it below 20 kg raises
|
||||
a finding.
|
||||
|
||||
```
|
||||
parameter reference exponent size_scaled maturation_factor final
|
||||
CL 5 0.75 0.792063 0.30634 0.242641
|
||||
V 40 1 3.42857 1 3.42857
|
||||
```
|
||||
|
||||
Size alone would predict 0.79 L/h; with maturation at 44 weeks post-menstrual age it is 0.24 L/h,
|
||||
a 3.3-fold difference. Volume is not matured — maturation describes eliminating capacity, not
|
||||
distribution space.
|
||||
|
||||
`--fih` uses the body-surface-area conversion from FDA's 2005 maximum-safe-starting-dose guidance
|
||||
and always emits a finding that a NOAEL-derived MRSD is not sufficient on its own for agonist
|
||||
immunomodulators: compute MABEL with `--mabel` and take the lower value.
|
||||
|
||||
### 8. Drug interactions
|
||||
|
||||
```bash
|
||||
python3 ddi_static.py --basic --ki 0.5 --imax 2.0 --fu 0.05 --dose 0.4
|
||||
python3 ddi_static.py --msm --ki 0.5 --imax 2.0 --fu 0.05 --dose 0.4 --fm 0.9 --fg 0.7
|
||||
```
|
||||
|
||||
ICH M12 basic models with their cut-offs (R1 ≥ 1.02 hepatic, ≥ 11 intestinal; R2 ≥ 1.25 for TDI;
|
||||
R3 ≤ 0.8 for induction; transporter cut-offs by site), plus the mechanistic static model. The
|
||||
basic models are deliberately conservative: a negative is meaningful, a positive is a trigger for
|
||||
further work, not a prediction of clinical magnitude.
|
||||
|
||||
The mechanistic static model reports the ceiling alongside the prediction:
|
||||
|
||||
```
|
||||
note: With fm = 0.9, no inhibitor of this pathway can raise the victim AUC above 10.00-fold. If
|
||||
the prediction approaches that ceiling, fm is doing more work than the inhibition constants.
|
||||
```
|
||||
|
||||
`fm` and `Fg` dominate the answer far more than the inhibition constants, and are usually the
|
||||
least well established numbers in the calculation.
|
||||
|
||||
### 9. Therapeutic drug monitoring
|
||||
|
||||
```bash
|
||||
python3 tdm_bayes.py --model vancomycin-adult --weight 80 --crcl 75 \
|
||||
--dose 1500 --interval 12 --level 18.2@11.5 --level 42@2 --target-auc24 500
|
||||
```
|
||||
|
||||
MAP Bayesian estimation shrinks towards the population when the data are uninformative and follows
|
||||
the data when they are not, which is why it beats both a trough read against population parameters
|
||||
and log-linear regression on two points. A single level raises a finding: it cannot separate
|
||||
clearance from volume, and whichever parameter the sample is uninformative about has simply
|
||||
returned its prior.
|
||||
|
||||
The bundled vancomycin parameterisation is explicitly labelled illustrative. Substitute a model
|
||||
validated in your population before the output means anything.
|
||||
|
||||
## Software ecosystem
|
||||
|
||||
Verified against live sources on 2026-07-27; see `references/software-ecosystem.md` for the full
|
||||
map and `references/source-ledger.md` for provenance.
|
||||
|
||||
- **Pharmpy 2.1.1** (2026-05-19) is the practical Python entry point — model-agnostic, drives
|
||||
NONMEM/nlmixr2/rxode2, and ships 19 `run_*` tools including `run_amd`, `run_modelsearch`,
|
||||
`run_covsearch`, `run_structsearch`, `run_pdsearch`, `run_modelrank`, `run_vpc` and `run_qa`.
|
||||
Two breaking changes are recent enough to catch you out: **2.0.0 (2026-02-12) changed dataset
|
||||
row indices to start at 1**, and **2.1.0 (2026-05-08) renamed `add_placebo_model` to
|
||||
`set_placebo_model`** and now requires numpy ≥ 2.
|
||||
- **NONMEM 7.6** (user guides dated November 2025) remains the regulatory default. New since 7.5:
|
||||
ADVAN16 (RADAR5 implicit Runge-Kutta for stiff delay differential equations), ADVAN17 (stiff
|
||||
delay differential-algebraic), NUTS Bayesian sampling, and SAEM storage of individual samples.
|
||||
- **nlmixr2** (requires rxode2 ≥ 5.0.0) is the credible open-source NLME alternative;
|
||||
`babelmixr2` and `monolix2rx` translate models between it, NONMEM and Monolix.
|
||||
- **PKPy** (PeerJ, 2025) is a Python popPK framework but is **GitHub-only — not on PyPI**, so
|
||||
`pip install pkpy` fails. `chi-drm` (1.0.3) is on PyPI for Bayesian PKPD.
|
||||
- **Open Systems Pharmacology Suite v12** (PK-Sim/MoBi) is the open-source PBPK platform; Simcyp
|
||||
and GastroPlus are the commercial ones. `ospsuite` is R-only and needs .NET 8.
|
||||
|
||||
Python has no mature NCA or NLME package of regulatory standing. That gap is why this skill ships
|
||||
its own validated NCA and fitting implementations rather than wrapping one.
|
||||
|
||||
## What this skill exists to prevent
|
||||
|
||||
1. Lambda_z chosen by plain r-squared, or fitted through Tmax.
|
||||
2. AUCinf reported from a profile where 25% of it was extrapolated.
|
||||
3. AIC allowed to select a compartment whose intercompartmental clearance has 98% RSE.
|
||||
4. Reweighting used to fix non-random residuals, which are a structural problem.
|
||||
5. `BLQ` left in a DV column, where NM-TRAN reads it as a real zero.
|
||||
6. A regimen chosen on the typical patient, with no attainment estimate for the population.
|
||||
7. Emax and EC50 quoted as independent estimates when the plateau was never observed.
|
||||
8. Reference-scaled bioequivalence limits applied to a 2x2 study.
|
||||
9. Allometric scaling to a neonate with no maturation term.
|
||||
10. An MRSD from a NOAEL used as the starting dose for an agonist immunomodulator.
|
||||
|
||||
## References
|
||||
|
||||
- `references/nca-conventions.md` — parameter definitions, lambda_z rules, BLQ handling, steady state
|
||||
- `references/structural-models.md` — closed-form solutions, parameterisations, NONMEM ADVAN/TRANS map
|
||||
- `references/population-pk.md` — NLME estimation, covariate building, BLQ M1-M7, diagnostics, VPC
|
||||
- `references/pd-and-exposure-response.md` — Emax, indirect response, effect compartment, ER analysis
|
||||
- `references/tmdd-and-biologics.md` — TMDD approximations, monoclonal antibody PK, immunogenicity
|
||||
- `references/pbpk.md` — when PBPK earns its cost, platforms, and what verification requires
|
||||
- `references/bioequivalence.md` — designs, ABE/ABEL/RSABE, ICH M13 series, highly variable drugs
|
||||
- `references/special-populations.md` — paediatrics, renal and hepatic impairment, obesity, pregnancy
|
||||
- `references/dataset-standards.md` — CDISC PC/PP and ADPC/ADPP, NONMEM data items, common defects
|
||||
- `references/ddi-and-qt.md` — ICH M12 stepwise assessment, static models, ICH E14/S7B C-QTc
|
||||
- `references/antimicrobial-and-tdm.md` — PK/PD indices, PTA/CFR, vancomycin AUC-guided dosing, MIPD
|
||||
- `references/software-ecosystem.md` — every tool, what it is for, licensing, and verified versions
|
||||
- `references/regulatory-guidance.md` — the guidance ledger with dates, status, and what each requires
|
||||
- `references/source-ledger.md` — provenance and research dates for every claim in this skill
|
||||
|
||||
## Assets
|
||||
|
||||
- `assets/popk-analysis-plan.md` — population analysis plan structure, with the decisions stated up front
|
||||
- `assets/nca-reporting-checklist.md` — what an NCA report has to state for the numbers to be interpretable
|
||||
72
skills/pkpd-modeling/assets/nca-reporting-checklist.md
Normal file
72
skills/pkpd-modeling/assets/nca-reporting-checklist.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# NCA reporting checklist
|
||||
|
||||
An NCA result is uninterpretable — and irreproducible — unless every item below is stated. Most
|
||||
disagreements between two analyses of the same data resolve to one of the first four.
|
||||
|
||||
## The four conventions that change the answer
|
||||
|
||||
- [ ] **Trapezoidal rule**: linear / linear-up-log-down / log-linear
|
||||
- [ ] **BLQ handling**, stated separately for each position:
|
||||
- leading (before the first quantifiable sample): [ zero / excluded ]
|
||||
- embedded: [ zero / LLOQ÷2 / excluded ]
|
||||
- trailing: [ excluded / other ]
|
||||
- [ ] **Lambda_z selection**: the rule, the minimum number of points, whether Tmax was excluded, and
|
||||
the window and point count actually used **for each subject**
|
||||
- [ ] **AUCinf basis**: observed Clast or predicted Clast
|
||||
|
||||
## Data
|
||||
|
||||
- [ ] Analyte, matrix, assay, LLOQ, and the bioanalytical validation report reference
|
||||
- [ ] Actual elapsed times used, not nominal — and nominal times used only for grouping
|
||||
- [ ] Dose actually administered per subject, including any deviations
|
||||
- [ ] Records excluded, with the reason, and confirmation the criteria were set before unblinding
|
||||
- [ ] Deviations in sampling time above [ ]% of the nominal time, and how they were handled
|
||||
|
||||
## Parameters reported
|
||||
|
||||
- [ ] Cmax and Tmax as **observed** values, never interpolated
|
||||
- [ ] AUClast, AUCinf (both observed- and predicted-based, or one with the basis stated)
|
||||
- [ ] % AUC extrapolated, per subject
|
||||
- [ ] lambda_z, t½, and the number of points and time span of the terminal fit, per subject
|
||||
- [ ] CL or CL/F, Vz or Vz/F — with `/F` used for every extravascular route
|
||||
- [ ] Vss **only** for intravenous data
|
||||
- [ ] At steady state: AUC(0-tau), Cavg, Cmin, PTF%, accumulation ratio — and **not** AUCinf
|
||||
- [ ] Partial AUCs, if pre-specified, with their intervals
|
||||
|
||||
## Terminal-phase quality, per subject
|
||||
|
||||
- [ ] Adjusted r-squared of the lambda_z regression
|
||||
- [ ] Span ratio (window duration ÷ t½); flag below 2
|
||||
- [ ] % AUC extrapolated; flag above 20%
|
||||
- [ ] Number of points in the fit; flag below 3
|
||||
- [ ] Subjects for whom lambda_z was not estimable, and how they were handled in the summary
|
||||
|
||||
## Summary statistics
|
||||
|
||||
- [ ] Exposure metrics (AUC, Cmax) as **geometric mean and geometric CV%**
|
||||
- [ ] Tmax as **median and range**
|
||||
- [ ] Arithmetic mean, SD and CV% alongside, if wanted, but not instead
|
||||
- [ ] n for each parameter, since it differs when lambda_z fails for some subjects
|
||||
|
||||
## Presentation
|
||||
|
||||
- [ ] Individual concentration-time profiles on both linear and semi-logarithmic axes
|
||||
- [ ] Mean profiles with a stated rule for handling BLQ in the mean
|
||||
- [ ] A table of individual parameters, not only summary statistics
|
||||
|
||||
## Method and provenance
|
||||
|
||||
- [ ] Software and version
|
||||
- [ ] Units for every parameter, and confirmation that dose and concentration units are consistent
|
||||
- [ ] Whether the analysis was pre-specified, and the reference to the plan
|
||||
- [ ] Any deviation from the plan, with its reason
|
||||
|
||||
## The traps this checklist exists to catch
|
||||
|
||||
1. Reporting AUCinf from a truncated steady-state profile.
|
||||
2. Interpolating Cmax, or reporting a mean Tmax.
|
||||
3. Quoting Vz as if it were Vss, or reporting Vss from oral data.
|
||||
4. Applying one BLQ rule to the test arm and another to the reference.
|
||||
5. Presenting arithmetic means for AUC and Cmax.
|
||||
6. Summarising across subjects without saying that lambda_z failed for some of them.
|
||||
7. Omitting the lambda_z window, which makes the half-life unreproducible.
|
||||
136
skills/pkpd-modeling/assets/popk-analysis-plan.md
Normal file
136
skills/pkpd-modeling/assets/popk-analysis-plan.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Population Pharmacokinetic Analysis Plan
|
||||
|
||||
> Template. Every bracketed field is a decision to make and record **before** the analysis starts.
|
||||
> A plan written after the modelling is not an analysis plan, and the difference is visible to a
|
||||
> reviewer.
|
||||
|
||||
**Study/programme:** [ ] **Compound:** [ ] **Plan version and date:** [ ]
|
||||
**Author:** [ ] **Reviewers:** [ ]
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectives
|
||||
|
||||
Primary objective: [ ]
|
||||
|
||||
Each objective must name the decision it informs — a dose for the next study, a label statement, a
|
||||
covariate adjustment, a waiver. "Characterise the population pharmacokinetics" is not an objective;
|
||||
it is an activity.
|
||||
|
||||
Secondary objectives: [ ]
|
||||
|
||||
**Intended use of the model:** [ ] — regulators evaluate a model against its intended use, and the
|
||||
required rigour follows from it.
|
||||
|
||||
## 2. Data
|
||||
|
||||
| Item | Specification |
|
||||
| --- | --- |
|
||||
| Studies included | [ ] |
|
||||
| Analysis population | [ ] |
|
||||
| Analyte and matrix | [ ] |
|
||||
| Assay and LLOQ | [ ] (see the bioanalytical validation report) |
|
||||
| Time reference | actual elapsed time from the most recent dose |
|
||||
| Dataset specification | [ reference the document ] |
|
||||
| Derivation script | [ path / repository ] |
|
||||
|
||||
**Exclusions**, defined now and applied blind to the model:
|
||||
|
||||
- [ ] Records with no matching dose record
|
||||
- [ ] Concentrations flagged by the bioanalytical laboratory
|
||||
- [ ] Subjects with documented non-compliance
|
||||
- [ ] Pre-dose concentrations in a first-dose profile above [ ]% of Cmax
|
||||
- [ ] Other: [ ]
|
||||
|
||||
**BLQ handling:** [ M1 / M3 / other ]. Justification: [ ]. Expected BLQ fraction: [ ]%.
|
||||
If the observed BLQ fraction exceeds [ ]%, the method changes to M3.
|
||||
|
||||
**Missing covariates:** [ imputation rule, or exclusion ]. Missingness will be tabulated before
|
||||
imputation.
|
||||
|
||||
## 3. Software
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Estimation | [ NONMEM 7.x / Monolix / nlmixr2 ] version [ ] |
|
||||
| Orchestration and post-processing | [ Pharmpy / PsN / R ] version [ ] |
|
||||
| Estimation method | [ FOCE-I / SAEM followed by IMP ] |
|
||||
| Environment | [ container / lockfile reference ] |
|
||||
|
||||
## 4. Structural model
|
||||
|
||||
Starting point: [ ] compartments, [ ] absorption, [ ] elimination.
|
||||
|
||||
Candidate structures to be evaluated: [ ]
|
||||
|
||||
Parameterisation is clearance-based (CL, V, Q, Vp) in all candidates.
|
||||
|
||||
Selection criteria, in this order: physiological plausibility; residual patterns; likelihood-ratio
|
||||
test for nested models (ΔOFV > [3.84] at 1 df); BIC; parameter precision. **An extra compartment
|
||||
whose intercompartmental clearance has RSE above [50]% is not retained regardless of the objective
|
||||
function.**
|
||||
|
||||
## 5. Between-subject and between-occasion variability
|
||||
|
||||
- IIV on: [ ] Distribution: [ exponential ]
|
||||
- Correlations estimated between: [ ]
|
||||
- IOV on: [ ], with an occasion defined as [ ]
|
||||
- Rule for removing a variance component: [ ]
|
||||
|
||||
## 6. Residual error
|
||||
|
||||
Candidates: [ proportional / additive / combined / log-transform-both-sides ]. Separate error
|
||||
models by [ study / assay / matrix ]: [ yes / no, with justification ].
|
||||
|
||||
## 7. Covariate model
|
||||
|
||||
**Covariates included a priori on mechanistic grounds, not tested:**
|
||||
|
||||
- Body size: allometric scaling on CL (exponent [0.75], [fixed]) and V (exponent [1.0], [fixed])
|
||||
- Maturation, if paediatric subjects are included: [ function, parameters, fixed or estimated ]
|
||||
- Other: [ ]
|
||||
|
||||
**Covariates to be evaluated:**
|
||||
|
||||
| Covariate | Parameter(s) | Functional form | Rationale |
|
||||
| --- | --- | --- | --- |
|
||||
| [ ] | [ ] | [ ] | [ ] |
|
||||
|
||||
**Procedure:** [ stepwise covariate modelling / full model estimation ].
|
||||
If stepwise: forward inclusion at p < [0.05] (ΔOFV > 3.84), backward elimination at p < [0.001]
|
||||
(ΔOFV > 10.83). Note that stepwise selection biases effect sizes upward and narrows intervals; a
|
||||
full-model approach is preferred where the objective is to quantify an effect.
|
||||
|
||||
Clinical relevance threshold: a covariate effect is reported as relevant if it changes [ exposure
|
||||
metric ] by more than [ ]% across the [5th–95th] percentile of the covariate.
|
||||
|
||||
## 8. Model evaluation
|
||||
|
||||
- Goodness-of-fit: DV vs PRED and IPRED; CWRES vs time and vs PRED; |IWRES| vs IPRED
|
||||
- Eta shrinkage reported for every eta; covariate plots not interpreted above [30]% shrinkage
|
||||
- Prediction-corrected VPC, [ n ] replicates, stratified by [ ]
|
||||
- NPDE with tests of mean, variance and normality
|
||||
- Parameter uncertainty by [ covariance step / bootstrap (n = ) / SIR / log-likelihood profiling ]
|
||||
- Condition number reported; above 1000 is treated as ill-conditioned
|
||||
|
||||
**Acceptance criteria for the final model:** [ ]
|
||||
|
||||
## 9. Simulations
|
||||
|
||||
Purpose: [ ] Scenarios: [ ] Replicates: [ ] Population sampled from: [ ]
|
||||
Uncertainty in fixed effects propagated: [ yes / no ] Endpoint summarised: [ ]
|
||||
|
||||
## 10. Deviations
|
||||
|
||||
Any departure from this plan is recorded in the report with its reason and the date it was decided.
|
||||
Post hoc analyses are labelled as such and reported separately from the pre-specified analysis.
|
||||
|
||||
---
|
||||
|
||||
**Approvals**
|
||||
|
||||
| Role | Name | Signature | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| Author | | | |
|
||||
| Reviewer | | | |
|
||||
| Clinical pharmacology | | | |
|
||||
110
skills/pkpd-modeling/references/antimicrobial-and-tdm.md
Normal file
110
skills/pkpd-modeling/references/antimicrobial-and-tdm.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Antimicrobial PK/PD and therapeutic drug monitoring
|
||||
|
||||
## PK/PD indices
|
||||
|
||||
Antimicrobial efficacy correlates with one of three exposure indices, determined by whether killing
|
||||
is concentration-dependent or time-dependent. The index is a property of the drug class, and using
|
||||
the wrong one leads to the wrong dosing strategy.
|
||||
|
||||
| Index | Killing pattern | Classes | Dosing strategy |
|
||||
| --- | --- | --- | --- |
|
||||
| **fT>MIC** — fraction of the interval with free concentration above MIC | Time-dependent, minimal persistent effect | Beta-lactams (penicillins, cephalosporins, carbapenems) | More frequent dosing, or extended/continuous infusion |
|
||||
| **fAUC/MIC** | Time-dependent with persistent effect | Vancomycin, fluoroquinolones, linezolid, azithromycin, tetracyclines | Total daily dose matters; interval matters less |
|
||||
| **fCmax/MIC** | Concentration-dependent | Aminoglycosides, daptomycin, colistin, metronidazole | Once-daily, high peak |
|
||||
|
||||
Targets commonly cited from preclinical and clinical work — targets, not regulation, and they vary
|
||||
by organism and endpoint:
|
||||
|
||||
| Drug or class | Target |
|
||||
| --- | --- |
|
||||
| Penicillins | fT>MIC ≥ 50% (stasis to 1-log kill) |
|
||||
| Cephalosporins | fT>MIC ≥ 60-70% |
|
||||
| Carbapenems | fT>MIC ≥ 40% |
|
||||
| Vancomycin | **AUC₂₄/MIC 400-600** (MIC = 1 mg/L by broth microdilution) |
|
||||
| Fluoroquinolones | fAUC/MIC ≥ 100-125 for Gram-negatives; ≥ 30-40 for *S. pneumoniae* |
|
||||
| Aminoglycosides | Cmax/MIC ≥ 8-10 |
|
||||
| Daptomycin | fAUC/MIC ~ 666 (*S. aureus*) |
|
||||
| Linezolid | fAUC/MIC 80-120 |
|
||||
|
||||
**The free (unbound) fraction is what matters.** For a highly bound agent such as ceftriaxone or
|
||||
daptomycin, total concentrations overstate the active exposure substantially.
|
||||
|
||||
## Probability of target attainment and cumulative fraction of response
|
||||
|
||||
- **PTA** — for a *fixed* MIC, the fraction of a simulated population reaching the PK/PD target at
|
||||
a given regimen. Plotted against MIC, the PTA curve gives the **PK/PD breakpoint**: the highest
|
||||
MIC at which the regimen achieves (conventionally) ≥ 90% attainment.
|
||||
- **CFR** — PTA integrated over the MIC distribution of the actual pathogen population, giving a
|
||||
single expected success probability for empirical therapy against that organism.
|
||||
|
||||
Both need a population PK model with realistic variability. `simulate_regimen.py --simulate` with
|
||||
`--target-auc` or `--target-trough` gives the machinery; note it includes between-subject
|
||||
variability only, so real attainment is lower once residual and between-occasion variability are
|
||||
added.
|
||||
|
||||
Critically ill patients are the population where this matters most and where standard models fail:
|
||||
augmented renal clearance (creatinine clearance above 130 mL/min, common in young trauma and
|
||||
sepsis patients) can put a standard beta-lactam regimen well below target, while acute kidney
|
||||
injury and renal replacement therapy move it the other way.
|
||||
|
||||
## Vancomycin: AUC-guided dosing
|
||||
|
||||
The 2020 consensus guideline (ASHP/IDSA/PIDS/SIDP) moved the target from trough-guided to
|
||||
**AUC₂₄/MIC of 400-600**, assuming an MIC of 1 mg/L, for serious MRSA infections.
|
||||
|
||||
Why troughs were abandoned: trough concentration is a poor surrogate for AUC. Achieving the
|
||||
historical 15-20 mg/L trough target frequently produces AUC₂₄ well above 600 and is associated with
|
||||
more nephrotoxicity, without better efficacy. Two patients with the same trough can have AUCs
|
||||
differing by 50% depending on their volume and interval.
|
||||
|
||||
Two accepted methods for estimating AUC:
|
||||
|
||||
1. **Bayesian estimation** from one or two levels against a population model. Works with a single
|
||||
level, tolerates levels drawn at imprecise times, and is the preferred approach.
|
||||
2. **First-order equations** from a peak and a trough within the same interval, both drawn at
|
||||
steady state, with the peak at least 1-2 hours after the end of the infusion so that
|
||||
distribution is complete.
|
||||
|
||||
`tdm_bayes.py --model vancomycin-adult` implements method 1. Its bundled parameterisation is
|
||||
explicitly illustrative — substitute a model validated in your population, because vancomycin
|
||||
population models differ substantially between general ward, ICU, obese, paediatric and dialysis
|
||||
populations.
|
||||
|
||||
## Model-informed precision dosing
|
||||
|
||||
MAP Bayesian forecasting combines a population prior with a patient's measured concentrations:
|
||||
|
||||
```
|
||||
minimise sum_j (obs_j - pred_j)^2 / var_j + sum_k (eta_k / omega_k)^2
|
||||
```
|
||||
|
||||
The second term is the prior penalty. Its consequences:
|
||||
|
||||
- **A single level is enough to be useful** but cannot separate clearance from volume. Whichever
|
||||
parameter the sample is uninformative about returns essentially its population value; the
|
||||
reported "individual" estimate for it is the prior.
|
||||
- **Sample timing determines what is learned.** Troughs are informative about clearance; a peak
|
||||
(after distribution) is informative about volume. All-trough sampling leaves volume weakly
|
||||
identified.
|
||||
- **A large eta is a data-quality signal first.** An individual clearance three-fold the population
|
||||
value is more often a mis-recorded sampling or infusion time than a genuinely unusual patient.
|
||||
Check the times before acting on the estimate.
|
||||
- The prior must be **appropriate to the patient**. A model built in general medical inpatients
|
||||
applied to a patient on continuous renal replacement therapy will shrink towards the wrong place,
|
||||
and the fit statistics will not reveal it.
|
||||
|
||||
Other drug classes where MIPD is established: aminoglycosides, busulfan (AUC-targeted
|
||||
conditioning), methotrexate rescue, immunosuppressants (tacrolimus, ciclosporin, mycophenolate),
|
||||
antiepileptics, infliximab and other anti-TNF biologics, and increasingly beta-lactams in
|
||||
critical care.
|
||||
|
||||
## Reporting a TDM calculation
|
||||
|
||||
State the population model and its source, the assay and matrix, the actual (not scheduled) dose
|
||||
and sampling times, whether steady state was reached, the estimated individual parameters with the
|
||||
etas, the predicted exposure metric, and the target with its justification. Without the actual
|
||||
times, the calculation cannot be reproduced or audited.
|
||||
|
||||
Any change to a patient's regimen is a clinical decision that depends on the organism, the site of
|
||||
infection, renal trajectory, concomitant nephrotoxins and local protocol. The model provides an
|
||||
exposure estimate; it does not provide the decision.
|
||||
132
skills/pkpd-modeling/references/bioequivalence.md
Normal file
132
skills/pkpd-modeling/references/bioequivalence.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Bioequivalence
|
||||
|
||||
## The ICH M13 series
|
||||
|
||||
M13 is the first globally harmonised bioequivalence guidance, replacing a patchwork of regional
|
||||
requirements.
|
||||
|
||||
| Guideline | Scope | Status |
|
||||
| --- | --- | --- |
|
||||
| **M13A** | BE for immediate-release solid oral dosage forms: study design and data analysis | Step 4 July 2024; came into effect 25 January 2025 |
|
||||
| **M13B** | Additional strengths, including additional-strength biowaivers | Endorsed 13 March 2025; Step 2b, public consultation opened 9 April 2025, comments closed 9 July 2025 |
|
||||
| **M13C** | Data analysis for highly variable drugs, narrow therapeutic index drugs, and complex BE study designs | Follows M13B; **this is where reference-scaling will finally be harmonised** |
|
||||
|
||||
Until M13C is adopted, reference-scaled approaches remain **regional and mutually incompatible**.
|
||||
That is the single most important practical fact about scaled BE: FDA and EMA do not accept each
|
||||
other's method, and a study must be designed for the criterion of the agency it is going to.
|
||||
|
||||
## Average bioequivalence
|
||||
|
||||
The default criterion everywhere:
|
||||
|
||||
> The 90% confidence interval for the geometric mean ratio (test/reference) of AUC and Cmax must
|
||||
> lie entirely within **80.00% to 125.00%**.
|
||||
|
||||
Computed on **log-transformed** data — the interval is symmetric on the log scale and asymmetric
|
||||
back-transformed, which is why the limits are 0.80 and 1.25 rather than ±20%.
|
||||
|
||||
Narrow therapeutic index drugs are tightened to **90.00-111.11%** in several regions, and the FDA
|
||||
additionally requires a comparison of within-subject variability between test and reference.
|
||||
|
||||
## Designs
|
||||
|
||||
| Design | Periods | Gives you |
|
||||
| --- | --- | --- |
|
||||
| 2×2 crossover (RT/TR) | 2 | Average BE. Cannot estimate within-subject variability of the reference separately |
|
||||
| Parallel | 1 | For long half-life drugs; much larger N; only total variability |
|
||||
| Partial replicate (RRT/RTR/TRR) | 3 | CVwR, so reference-scaling becomes possible |
|
||||
| Full replicate (RTRT/TRTR or RTR/TRT) | 3-4 | CVwR **and** CVwT; required for the FDA NTI approach |
|
||||
| Williams design | ≥3 treatments | Balanced for first-order carryover |
|
||||
|
||||
A crossover removes between-subject variability, which is why it needs far fewer subjects than a
|
||||
parallel design. It requires an adequate washout — at least 5 terminal half-lives — and pre-dose
|
||||
concentrations in later periods should be below 5% of Cmax, or the subject is excluded.
|
||||
|
||||
## Reference-scaled approaches for highly variable drugs
|
||||
|
||||
A highly variable drug is one with CVwR > 30%. Both approaches require a **replicate design**;
|
||||
neither can be applied to a 2×2 study however high the observed variability, because without
|
||||
repeated reference administrations there is no CVwR to scale to.
|
||||
|
||||
### EMA: average bioequivalence with expanding limits (ABEL)
|
||||
|
||||
```
|
||||
limits = exp(± 0.760 * swR) capped at CVwR = 50% -> 69.84% - 143.19%
|
||||
```
|
||||
|
||||
Conditions: replicate design; the widening must be pre-specified in the protocol with clinical
|
||||
justification; the point estimate must still fall within 80.00-125.00%; and widening is applied to
|
||||
Cmax (and for some products AUC, though EMA generally does not permit AUC widening).
|
||||
|
||||
### FDA: reference-scaled average bioequivalence (RSABE)
|
||||
|
||||
Not an interval criterion at all. The criterion is
|
||||
|
||||
```
|
||||
(mu_T - mu_R)^2 - theta^2 * s2wR <= 0 with theta = ln(1.25)/0.25 = 0.8926
|
||||
```
|
||||
|
||||
evaluated as a **95% upper confidence bound** using Hyslop's linearised method:
|
||||
|
||||
```
|
||||
E = (Ybar_T - Ybar_R)^2 Eh = (|Ybar_T - Ybar_R| + t(0.95,df)*SE)^2
|
||||
H = -theta^2 * s2wR Hh = -theta^2 * s2wR * df / chi2(0.05, df)
|
||||
upper bound = E + H + sqrt((Eh-E)^2 + (Hh-H)^2)
|
||||
```
|
||||
|
||||
Pass requires the upper bound ≤ 0 **and** the point estimate within 80-125%. Applied when
|
||||
CVwR ≥ 30%; below that, unscaled ABE applies. `bioequivalence.py --scaling rsabe` implements this.
|
||||
|
||||
The two criteria can disagree on the same dataset. Which applies is a regulatory fact, not a
|
||||
statistical choice, and must be pre-specified.
|
||||
|
||||
## Sample size
|
||||
|
||||
Driven by three things, in order of influence: the assumed true GMR, the within-subject CV, and the
|
||||
target power.
|
||||
|
||||
Published values for a 2×2 crossover, GMR 0.95, 80% power, 80-125% limits — reproduced exactly by
|
||||
`bioequivalence.py --power`:
|
||||
|
||||
| CVw | N |
|
||||
| --- | --- |
|
||||
| 15% | 12 |
|
||||
| 20% | 20 |
|
||||
| 25% | 28 |
|
||||
| 30% | 40 |
|
||||
| 35% | 52 |
|
||||
| 40% | 66 |
|
||||
|
||||
**Assuming a GMR of 1.00 rather than 0.95 roughly halves the calculated N**, and is the most common
|
||||
reason a bioequivalence study comes in underpowered. A GMR of exactly 1.00 is not a realistic
|
||||
planning assumption for two different formulations.
|
||||
|
||||
Power must be computed by integrating over the sampling distribution of the estimated standard
|
||||
deviation (equivalently, Owen's Q). Treating the standard error as known overstates power at these
|
||||
sample sizes.
|
||||
|
||||
## Common errors
|
||||
|
||||
1. **Using a t test.** "p > 0.05, therefore the formulations are equivalent" inverts the hypothesis.
|
||||
Failing to detect a difference is not evidence of equivalence, and on a small BE dataset that
|
||||
outcome is nearly guaranteed. The 90% CI (equivalently, two one-sided tests at α = 0.05) is the
|
||||
test.
|
||||
2. **Analysing untransformed data.** AUC and Cmax are log-normal; the criterion is defined on the
|
||||
log scale.
|
||||
3. **Scaling from a 2×2 design.** Refused by `bioequivalence.py`, and by regulators.
|
||||
4. **Post hoc scaling.** Deciding to widen limits after seeing high variability is not
|
||||
pre-specification.
|
||||
5. **Dropping subjects after unblinding** for reasons not defined in the protocol.
|
||||
6. **Reporting only AUC.** Cmax must meet the criterion too, and it is the more variable of the two.
|
||||
7. **Ignoring the period effect** by analysing as a paired comparison. `bioequivalence.py` labels
|
||||
this explicitly when the sequence column is missing.
|
||||
|
||||
## Endogenous compounds and other special cases
|
||||
|
||||
- **Endogenous substances** (potassium, iron, hormones) require baseline correction, and the
|
||||
baseline-correction method changes the answer. Pre-specify it.
|
||||
- **Long half-life drugs**: AUC(0-72h) is accepted in place of AUC(0-inf) under M13A for immediate
|
||||
release products, avoiding a very long sampling schedule.
|
||||
- **Highly variable Cmax with acceptable AUC** is the usual pattern that pushes a programme towards
|
||||
a replicate design.
|
||||
- **Fed versus fasted**: both usually required; the food effect study is separate from BE.
|
||||
103
skills/pkpd-modeling/references/dataset-standards.md
Normal file
103
skills/pkpd-modeling/references/dataset-standards.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# PK dataset standards: CDISC, NONMEM data items, and the defects that survive review
|
||||
|
||||
## The two worlds
|
||||
|
||||
Regulatory submission data is CDISC. Modelling data is NONMEM-format. They are different shapes and
|
||||
converting between them is where most defects are introduced.
|
||||
|
||||
| Layer | Domain / dataset | Contents |
|
||||
| --- | --- | --- |
|
||||
| SDTM | **PC** | Pharmacokinetic concentrations, as collected |
|
||||
| SDTM | **PP** | Pharmacokinetic parameters (NCA output) |
|
||||
| SDTM | **EX** | Exposure — what was actually administered |
|
||||
| ADaM | **ADPC** | Analysis-ready concentrations |
|
||||
| ADaM | **ADPP** | Analysis-ready parameters |
|
||||
| — | NONMEM dataset | One row per event, wide covariates, numeric only |
|
||||
|
||||
Useful SDTM PC variables: `PCTESTCD`/`PCTEST` (analyte), `PCORRES`/`PCSTRESN` (result as collected
|
||||
and standardised), `PCSTRESU`, `PCLLOQ`, `PCTPT`/`PCTPTNUM` (nominal time), `PCDTC` (actual
|
||||
date/time), `PCSPEC` (matrix). PP parameters use the CDISC `PKPARM`/`PKUNIT` controlled
|
||||
terminology — `AUCALL`, `AUCIFO`, `AUCIFP`, `CMAX`, `TMAX`, `LAMZ`, `LAMZHL`, `CLFO`, `VZFO`.
|
||||
|
||||
**Nominal versus actual time is the single most consequential conversion decision.** NCA and
|
||||
population modelling should use **actual** elapsed time from the most recent dose. Using nominal
|
||||
time flattens the absorption phase, biases Cmax and Tmax, and inflates residual error. Nominal time
|
||||
is for grouping and presentation only.
|
||||
|
||||
## NONMEM data items
|
||||
|
||||
| Item | Meaning | Traps |
|
||||
| --- | --- | --- |
|
||||
| `ID` | Subject | Must be numeric and contiguous per subject; records for one subject must be together |
|
||||
| `TIME` | Elapsed time | Must be non-decreasing within a subject. Use one unit consistently |
|
||||
| `DV` | Dependent variable | **Must be numeric.** See below |
|
||||
| `AMT` | Dose amount | On a dose record only; units must match the model's |
|
||||
| `EVID` | Event ID | 0 observation, 1 dose, 2 other, 3 reset, 4 reset+dose |
|
||||
| `MDV` | Missing DV | 1 means the record contributes nothing to the objective function |
|
||||
| `CMT` | Compartment | Which compartment is dosed or observed |
|
||||
| `RATE` | Infusion rate | `>0` a rate; `-1` model-estimated duration; `-2` model-estimated rate |
|
||||
| `SS` | Steady state | 1 = achieve steady state before this dose; **requires `II`** |
|
||||
| `II` | Interdose interval | Required by both `SS` and `ADDL` |
|
||||
| `ADDL` | Additional doses | `n` further doses every `II`; **silently does nothing without `II`** |
|
||||
|
||||
## The defects that do not stop a run
|
||||
|
||||
These are the reason `check_popk_dataset.py` exists. None of them raises an error in NM-TRAN.
|
||||
|
||||
1. **Non-numeric `DV`.** `BLQ`, `<LLOQ`, `ND` are read as **0** and fitted as genuine zero
|
||||
concentrations. This is the most damaging defect in the list, and it is invisible.
|
||||
2. **Missing covariate read as 0.** A blank or `.` in a `WT` column becomes a 0 kg patient in the
|
||||
covariate model. Missing covariates must be imputed explicitly and the imputation documented, or
|
||||
the subject excluded.
|
||||
3. **`ADDL` without `II`.** No additional doses are placed. Exposure is understated by the whole
|
||||
accumulation.
|
||||
4. **`SS` without `II`.** Same class of failure.
|
||||
5. **Duplicate timestamps.** A dose and an observation at the same `TIME` are applied in file
|
||||
order, so whether the sample is pre- or post-dose depends on row order. Order dose records
|
||||
before observations at the same time, or offset the observation by a small negative amount.
|
||||
6. **Unsorted `TIME` within a subject.** NONMEM does not sort for you.
|
||||
7. **A subject with doses but no observations.** They contribute no information but appear in the
|
||||
N of the analysis and their etas come entirely from the prior.
|
||||
8. **A subject with observations but no dose.** Their predictions are zero and their residuals are
|
||||
the whole observation.
|
||||
9. **Time-varying covariate declared as baseline.** The model uses whichever value is on the record
|
||||
being evaluated, which is rarely what was intended.
|
||||
10. **Units.** Dose in mg with concentrations in ng/mL gives a volume off by 10⁶. Nothing checks
|
||||
this; the fit will converge on a nonsense volume.
|
||||
11. **`RATE` left on an oral record**, turning first-order absorption into a zero-order infusion.
|
||||
12. **Mixed time origins** — some subjects timed from first dose, others from screening.
|
||||
|
||||
## Handling BLQ properly
|
||||
|
||||
Keep the numeric `DV` and add a separate flag:
|
||||
|
||||
```
|
||||
ID,TIME,DV,AMT,EVID,MDV,BLQ,LLOQ
|
||||
1,0,.,100,1,1,0,0.5
|
||||
1,1,4.21,.,0,0,0,0.5
|
||||
1,24,0.5,.,0,0,1,0.5 <- DV set to LLOQ, BLQ flag set, method chosen in the control stream
|
||||
```
|
||||
|
||||
Then implement the chosen method (usually M3) in the model rather than by editing the data. See
|
||||
`population-pk.md` for the M1-M7 comparison.
|
||||
|
||||
## Structuring covariates
|
||||
|
||||
- **Baseline covariates** appear once per subject and are repeated on every record.
|
||||
- **Time-varying covariates** change between records and must be declared as such. Last-observation
|
||||
carried forward is the usual interpolation, and it is an assumption worth stating.
|
||||
- Categorical covariates need a numeric coding and a documented reference level. Never leave a
|
||||
category blank to mean "reference".
|
||||
- Derived covariates (creatinine clearance, BSA, lean body weight) should be computed once,
|
||||
documented with the formula used, and stored — not recomputed in the control stream where the
|
||||
formula is invisible to a reviewer.
|
||||
|
||||
## Dataset specification
|
||||
|
||||
Every population analysis dataset should ship with a specification listing, per column: name,
|
||||
label, type, units, derivation (including the source SDTM/ADaM variable), permissible values, and
|
||||
the missing-data rule. This is the document a reviewer reads first, and producing it usually
|
||||
surfaces at least one defect on its own.
|
||||
|
||||
A reproducible derivation script from the ADaM datasets to the modelling dataset is worth more than
|
||||
the dataset itself: it is what makes a re-run possible after a database lock update.
|
||||
132
skills/pkpd-modeling/references/ddi-and-qt.md
Normal file
132
skills/pkpd-modeling/references/ddi-and-qt.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Drug interactions (ICH M12) and QT assessment (ICH E14/S7B)
|
||||
|
||||
## ICH M12 status
|
||||
|
||||
The first globally harmonised guidance on pharmacokinetic drug interactions mediated by metabolic
|
||||
enzymes and transporters. Step 4 in 2024, then:
|
||||
|
||||
| Region | Adoption |
|
||||
| --- | --- |
|
||||
| FDA | Adopted 2 August 2024, with an accompanying *M12 Drug Interaction Studies: Questions & Answers* |
|
||||
| EMA / EU | Effective 30 November 2024 |
|
||||
| NMPA (China) | Implemented 29 October 2024 |
|
||||
|
||||
It replaces the previous FDA in vitro and clinical DDI guidances and EMA's DDI guideline as the
|
||||
operative framework.
|
||||
|
||||
## The stepwise, risk-based approach
|
||||
|
||||
1. **In vitro characterisation** — is the drug a substrate, inhibitor or inducer of the major
|
||||
enzymes and transporters?
|
||||
2. **Basic models** with conservative cut-offs — do the in vitro data rule the interaction out?
|
||||
3. **Mechanistic static or PBPK modelling** — refine a positive basic-model signal.
|
||||
4. **Clinical study** — where modelling cannot rule it out or the interaction is decision-relevant.
|
||||
5. **Labelling** — dose adjustment, contraindication, or monitoring.
|
||||
|
||||
The basic models are deliberately conservative: they are built to over-predict, so a **negative
|
||||
result is meaningful** and a positive one is a trigger for further work, never an estimate of
|
||||
clinical magnitude.
|
||||
|
||||
## Basic model cut-offs
|
||||
|
||||
| Mechanism | Model | Cut-off |
|
||||
| --- | --- | --- |
|
||||
| Reversible inhibition, hepatic | `R1 = 1 + Imax,u / Ki` | R1 ≥ 1.02 |
|
||||
| Reversible inhibition, intestinal (CYP3A4) | `R1,gut = 1 + Igut / Ki`, `Igut = dose / 250 mL` | R1,gut ≥ 11 |
|
||||
| Time-dependent inhibition | `R2 = (kobs + kdeg) / kdeg`, `kobs = kinact·I / (KI + I)` at 50 × Imax,u | R2 ≥ 1.25 |
|
||||
| Induction (basic) | `R3 = 1 / (1 + d·Emax·I / (EC50 + I))` at 10 × Imax,u | R3 ≤ 0.80 |
|
||||
| Hepatic uptake transporters (OATP1B1/1B3) | `1 + fu·Iin,max / Ki,u` | ≥ 1.1 |
|
||||
| Intestinal transporters (P-gp, BCRP) | `Igut / IC50` | ≥ 10 |
|
||||
| Renal transporters (OAT, OCT, MATE) | `Imax,u / Ki` | ≥ 0.1 |
|
||||
|
||||
The hepatic inlet concentration for uptake transporters is
|
||||
|
||||
```
|
||||
Iu,inlet,max = fu * (Imax + Fa*Fg*ka*Dose / (Qh * RB))
|
||||
```
|
||||
|
||||
which is higher than systemic Imax and is what the liver actually sees during absorption.
|
||||
|
||||
An alternative induction assessment is the **correlation / relative induction score** approach,
|
||||
calibrated against known inducers, which is less conservative than the basic R3 model.
|
||||
|
||||
## Mechanistic static model
|
||||
|
||||
```
|
||||
AUCR = 1 / (Ag·Bg·Cg·(1 - Fg) + Fg) × 1 / (Ah·Bh·Ch·fm + (1 - fm))
|
||||
```
|
||||
|
||||
with, at each site,
|
||||
|
||||
```
|
||||
A = 1 / (1 + I/Ki) reversible inhibition
|
||||
B = kdeg / (kdeg + kinact·I/(KI + I)) time-dependent inhibition
|
||||
C = 1 + d·Emax·I/(EC50 + I) induction
|
||||
```
|
||||
|
||||
**`fm` and `Fg` dominate the result.** The ceiling on any inhibition of a single pathway is
|
||||
`1/(1 - fm)`: with `fm = 0.9` no inhibitor can raise AUC more than 10-fold, and with `fm = 0.7`, no
|
||||
more than 3.3-fold. These two fractions are usually the least well established inputs, and a
|
||||
sensitivity analysis across their plausible range is more informative than the point prediction.
|
||||
`ddi_static.py --msm` prints the ceiling alongside the prediction.
|
||||
|
||||
## Perpetrator classification
|
||||
|
||||
| Class | AUC ratio |
|
||||
| --- | --- |
|
||||
| Strong inhibitor | ≥ 5 |
|
||||
| Moderate inhibitor | ≥ 2 and < 5 |
|
||||
| Weak inhibitor | ≥ 1.25 and < 2 |
|
||||
| No relevant effect | > 0.8 and < 1.25 |
|
||||
| Weak inducer | > 0.5 and ≤ 0.8 |
|
||||
| Moderate inducer | > 0.2 and ≤ 0.5 |
|
||||
| Strong inducer | ≤ 0.2 |
|
||||
|
||||
## Clinical study design points
|
||||
|
||||
- Use **index perpetrators** (itraconazole or clarithromycin for strong CYP3A4 inhibition,
|
||||
rifampicin for strong induction, and the corresponding index substrates) so the result is
|
||||
interpretable against the classification bands.
|
||||
- Worst-case first: a study with a strong index perpetrator that shows no interaction removes the
|
||||
need for weaker ones.
|
||||
- Induction requires **multiple-dose** administration of the perpetrator; a single dose can even
|
||||
show inhibition from the same compound.
|
||||
- Timing matters for time-dependent inhibition and for induction, both of which take days to
|
||||
develop and days to reverse.
|
||||
- A **cocktail study** can assess several pathways at once, provided the probes are validated as
|
||||
non-interacting.
|
||||
|
||||
---
|
||||
|
||||
# QT assessment: ICH E14 and S7B
|
||||
|
||||
## The framework
|
||||
|
||||
- The threshold of regulatory concern is a **QTc effect above 10 ms**, assessed as the **upper bound
|
||||
of the two-sided 90% confidence interval** for placebo-corrected change-from-baseline QTc (ΔΔQTc)
|
||||
at the clinically relevant high exposure.
|
||||
- A prospective **concentration-QTc analysis** on Phase I data can substitute for a dedicated
|
||||
thorough QT study, and this is now the routine path.
|
||||
- The **2022 E14/S7B Q&As** introduced the "double negative" integrated nonclinical risk
|
||||
assessment — a negative hERG assay plus a negative in vivo QTc study — as supplementary evidence.
|
||||
This allows a submission to cover high clinical exposure without attaining a high multiple of
|
||||
clinically relevant exposure, and its uptake in FDA reviews rose sharply after 2022.
|
||||
|
||||
## Getting a C-QTc analysis right
|
||||
|
||||
- **Correction method**: QTcF (Fridericia) is the standard. QTcB (Bazett) over-corrects at high
|
||||
heart rates and should not be primary. Where heart rate changes materially with treatment, a
|
||||
study-specific or individual correction is preferable.
|
||||
- **Model**: linear mixed effects on time-matched ΔQTc against plasma concentration, with a random
|
||||
intercept and slope per subject and a treatment-specific intercept. Assess the intercept — a
|
||||
non-zero one suggests the baseline or the placebo correction is wrong.
|
||||
- **Linearity**: the extrapolation to supratherapeutic exposure depends on it. Check for curvature,
|
||||
and check that the highest observed concentrations actually cover the exposure of interest.
|
||||
- **Hysteresis**: if the QTc effect lags concentration, a direct model is misspecified and an effect
|
||||
compartment is needed. Plot ΔQTc against concentration coloured by time to see it.
|
||||
- Sample size is driven by the number of subjects **and** the spread of concentrations achieved;
|
||||
a study where everyone has similar exposure estimates the slope poorly regardless of N.
|
||||
|
||||
`exposure_response.py --cqtc` implements the ordinary linear version for screening and flags
|
||||
extrapolation beyond the observed concentration range. It is not a substitute for the mixed model
|
||||
in a submission.
|
||||
128
skills/pkpd-modeling/references/nca-conventions.md
Normal file
128
skills/pkpd-modeling/references/nca-conventions.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Non-compartmental analysis: conventions that change the answer
|
||||
|
||||
NCA is arithmetic on a concentration-time curve. What makes two analyses of the same data disagree
|
||||
is never the arithmetic — it is the four conventions below, which are frequently left unstated.
|
||||
|
||||
## 1. Which trapezoidal rule
|
||||
|
||||
| Rule | Segment AUC | When |
|
||||
| --- | --- | --- |
|
||||
| Linear | `(C1+C2)/2 * dt` | Rising phase; sparse data; regulatory default for some agencies on ascending segments |
|
||||
| Linear-up / log-down | linear while rising, log while falling | The usual default for a drug with log-linear decline |
|
||||
| Log-linear | `(C1-C2)/k`, `k = ln(C1/C2)/dt` | Whole curve; fails on any rising or flat segment |
|
||||
|
||||
Linear trapezoid **overestimates** AUC on a convex declining curve, because the chord lies above
|
||||
the exponential. The error grows with the sampling interval, so a sparse late-phase schedule biases
|
||||
AUC upward under the linear rule and the two rules can differ by several percent.
|
||||
|
||||
Under log-down, the AUMC segment is
|
||||
|
||||
```
|
||||
AUMC = (t1*C1 - t2*C2)/k + (C1 - C2)/k^2 with k = ln(C1/C2)/(t2 - t1)
|
||||
```
|
||||
|
||||
which is not what you get by applying the AUC substitution naively.
|
||||
|
||||
## 2. How lambda_z was selected
|
||||
|
||||
The dominant convention: fit `ln C` on time over the last three quantifiable points, extend the
|
||||
window backwards one point at a time, and keep the longer window only when **adjusted** r-squared
|
||||
improves by more than 0.0001.
|
||||
|
||||
- Plain r-squared is monotone in the number of points, so it always selects the longest window.
|
||||
Adjusted r-squared is the only version of this rule that discriminates.
|
||||
- Points at or before Tmax are never eligible. Including Tmax fits the tail of absorption, which
|
||||
biases lambda_z upward and therefore half-life, Vz and AUCinf downward.
|
||||
- Trailing BLQ samples are excluded from the regression, not set to zero — a zero cannot be
|
||||
log-transformed and a half-LLOQ substitution in the tail flattens the slope.
|
||||
|
||||
Reportability criteria, all conventions rather than regulation, and all worth pre-specifying:
|
||||
|
||||
| Diagnostic | Usual threshold | What it means when it fails |
|
||||
| --- | --- | --- |
|
||||
| Points in the window | ≥ 3 | The slope is an interpolation between two points |
|
||||
| Adjusted r-squared | ≥ 0.80 (sometimes 0.85) | The terminal phase is not log-linear, or is noise |
|
||||
| Span ratio: window duration / t½ | ≥ 2 | The true terminal phase may not have been reached |
|
||||
| % AUC extrapolated | ≤ 20% | AUCinf is driven by the fit, not by data |
|
||||
|
||||
A profile can pass all four and still be wrong if sampling stopped during a distribution phase: the
|
||||
"terminal" slope is then the beta phase of a drug whose gamma phase was never observed, and Vz and
|
||||
t½ are both underestimated. Only the sampling duration relative to the true terminal half-life
|
||||
fixes this, and NCA cannot detect it.
|
||||
|
||||
## 3. What happened to BLQ values
|
||||
|
||||
| Rule | Effect |
|
||||
| --- | --- |
|
||||
| Set to zero | Standard for leading BLQ before the first quantifiable sample |
|
||||
| LLOQ/2 | Common for embedded BLQ; biases AUC upward slightly and t½ downward |
|
||||
| Treated as missing | Standard for trailing BLQ; avoids fabricating a tail |
|
||||
|
||||
The usual regulatory-acceptable combination is: leading BLQ = 0, embedded BLQ = 0 or LLOQ/2 with
|
||||
the choice stated, trailing BLQ excluded. Whatever you choose, apply it identically to every
|
||||
profile and to every treatment arm — a rule applied to the test formulation and not the reference
|
||||
biases the ratio directly.
|
||||
|
||||
## 4. Observed or predicted Clast
|
||||
|
||||
```
|
||||
AUCinf_obs = AUClast + Clast_observed / lambda_z
|
||||
AUCinf_pred = AUClast + Clast_predicted / lambda_z (Clast_predicted from the lambda_z fit)
|
||||
```
|
||||
|
||||
They differ whenever the last observation sits off the fitted line, which is exactly when the last
|
||||
observation is noisy. `_pred` is more stable; `_obs` is more common. Report which.
|
||||
|
||||
## Parameter definitions
|
||||
|
||||
| Parameter | Definition | Notes |
|
||||
| --- | --- | --- |
|
||||
| Cmax, Tmax | Highest observed concentration and its time | **Observed values, never interpolated.** Tmax is summarised as median and range, not mean and SD |
|
||||
| AUClast | AUC to the last quantifiable concentration | The only exposure metric that involves no extrapolation |
|
||||
| AUCinf | AUClast + Clast/lambda_z | |
|
||||
| AUMCinf | AUMClast + tlast·Clast/λz + Clast/λz² | |
|
||||
| MRT | AUMCinf/AUCinf | Subtract Tinf/2 for a zero-order infusion |
|
||||
| CL or CL/F | Dose/AUCinf | Apparent (`/F`) for any extravascular route |
|
||||
| Vz or Vz/F | Dose/(λz · AUCinf) | Terminal-phase volume; depends on λz and inherits its error |
|
||||
| Vss | CL · MRT | **Intravenous only.** Vss from extravascular data is not defined, because MRT then includes mean absorption time |
|
||||
| AUC(0-tau) | AUC over one dosing interval at steady state | The reportable exposure metric at steady state |
|
||||
| Cavg | AUC(0-tau)/tau | |
|
||||
| PTF% | 100·(Cmax − Cmin)/Cavg | Peak-trough fluctuation |
|
||||
| Swing | (Cmax − Cmin)/Cmin | More sensitive than PTF to a low trough |
|
||||
| Rac | AUC(0-tau),ss / AUC(0-tau),first dose | Observed accumulation; compare with 1/(1 − e^(−λz·tau)) |
|
||||
|
||||
**Vz versus Vss.** Vz is a terminal-phase parameter and is systematically larger than Vss for a
|
||||
multi-compartment drug. They are not alternative estimates of the same thing, and a covariate model
|
||||
built on one does not transfer to the other.
|
||||
|
||||
## Steady state
|
||||
|
||||
Do not compute AUCinf from a truncated steady-state profile. The `nca.py` extrapolation finding
|
||||
fires on exactly this, because the tail beyond tau is not observed and the extrapolated area is a
|
||||
fiction. Report AUC(0-tau).
|
||||
|
||||
Attainment of steady state should be demonstrated, not assumed — by trough concentrations across at
|
||||
least three consecutive intervals showing no trend, not by counting half-lives, because the half
|
||||
life you would count with is the one you are trying to estimate.
|
||||
|
||||
## Urinary data
|
||||
|
||||
- `Ae` — cumulative amount excreted unchanged; `fe = Ae(0-inf)/Dose`
|
||||
- `CLr = Ae(0-t)/AUC(0-t)` over the **same** interval; mismatching the intervals is the standard error
|
||||
- `CLnr = CL − CLr`
|
||||
|
||||
Incomplete collection biases `fe` and `CLr` downward and is not detectable from the data alone.
|
||||
|
||||
## Sparse sampling
|
||||
|
||||
With one or two samples per subject, per-subject NCA is not possible. The Bailer method and its
|
||||
Nedelman-Jia extension estimate a mean AUC and its standard error across a batch design. Do not
|
||||
average per-subject AUCs computed from single points; do not run the destructive-sampling data
|
||||
through an individual NCA and summarise the result.
|
||||
|
||||
## Reporting
|
||||
|
||||
State, for every NCA: the trapezoidal rule; the BLQ rule at each position; the lambda_z selection
|
||||
rule with the window and number of points per subject; whether AUCinf is observed- or
|
||||
predicted-based; and the exclusion criteria applied, decided before unblinding. Summarise exposure
|
||||
metrics as geometric mean with geometric CV%, and Tmax as median with range.
|
||||
103
skills/pkpd-modeling/references/pbpk.md
Normal file
103
skills/pkpd-modeling/references/pbpk.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Physiologically based pharmacokinetics: when it earns its cost
|
||||
|
||||
PBPK divides the body into anatomical compartments with literature blood flows and volumes, and
|
||||
represents the drug through physicochemical and in vitro properties. Nothing about the *system* is
|
||||
fitted; the drug parameters are.
|
||||
|
||||
## When PBPK is the right tool
|
||||
|
||||
It earns its cost where the question requires extrapolating **outside** the observed data in a way
|
||||
a population model cannot:
|
||||
|
||||
- **Drug interactions** — the most common regulatory use by a wide margin. Predicting an
|
||||
untested combination, an untested dose of a perpetrator, or a staggered dosing schedule. ICH M12
|
||||
points to PBPK when a basic model signals a possible interaction and a refined estimate is needed.
|
||||
- **Paediatric first-dose selection**, where enzyme ontogeny and organ maturation are represented
|
||||
mechanistically instead of by an empirical maturation function.
|
||||
- **Organ impairment** — predicting exposure in hepatic or renal impairment without a dedicated
|
||||
study.
|
||||
- **Food effect and formulation**, using absorption models (ACAT, ADAM) that represent transit,
|
||||
dissolution and regional permeability.
|
||||
- **Tissue concentrations** that cannot be measured — brain, tumour, lung.
|
||||
|
||||
It is the wrong tool when the question is "what is the exposure in the population I studied" — a
|
||||
population PK model answers that better, with the variability estimated rather than assumed.
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Nature | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Simcyp** | Commercial (Certara) | The de facto regulatory standard for DDI; extensive validated population libraries |
|
||||
| **GastroPlus** | Commercial (Simulations Plus) | Strong oral absorption modelling (ACAT) |
|
||||
| **PK-Sim / MoBi** | **Open source** (Open Systems Pharmacology Suite, v12) | Free, credible, scriptable. `ospsuite` R package requires .NET 8 and Windows or Ubuntu |
|
||||
| **Certara PBPK / Phoenix** | Commercial | |
|
||||
|
||||
There is no mature open-source PBPK library in Python. PK-Sim/MoBi with the R toolchain is the
|
||||
realistic open route; the Python ecosystem is limited to building the ODE system yourself.
|
||||
|
||||
## Structure
|
||||
|
||||
Perfusion-limited tissue (the default for most tissues and small molecules):
|
||||
|
||||
```
|
||||
V_t * dC_t/dt = Q_t * (C_arterial - C_t / Kp_t)
|
||||
```
|
||||
|
||||
Permeability-limited tissue splits into vascular and extravascular subcompartments and adds a
|
||||
permeability-surface-area product. Use it for tissues with tight barriers (brain), for large
|
||||
molecules, and for transporter-mediated distribution.
|
||||
|
||||
**Tissue-to-plasma partition coefficients (Kp)** are predicted from physicochemistry rather than
|
||||
measured, most often via:
|
||||
|
||||
- **Rodgers & Rowland** — accounts for ionisation and binding to acidic phospholipids; the usual
|
||||
choice for bases
|
||||
- **Poulin & Theil** — lipophilicity- and composition-based; better for neutrals and acids
|
||||
- **Berezhkovskiy** — a correction to Poulin & Theil's volume terms
|
||||
- **Schmitt**
|
||||
|
||||
Different methods can give Kp values differing several-fold, which propagates directly into Vss.
|
||||
Choosing the method that reproduces the observed Vss is a legitimate calibration step, but it must
|
||||
be reported as such.
|
||||
|
||||
## In vitro to in vivo extrapolation
|
||||
|
||||
Hepatic clearance is built up from intrinsic clearance measured in microsomes or hepatocytes:
|
||||
|
||||
```
|
||||
CLint,in vivo = CLint,in vitro * MPPGL (or HPGL) * liver weight
|
||||
CL_hepatic = Q_h * fu_b * CLint / (Q_h + fu_b * CLint) (well-stirred model)
|
||||
```
|
||||
|
||||
Scaling factors: microsomal protein per gram of liver ≈ 40 mg/g; hepatocytes ≈ 99-120 × 10⁶ cells/g;
|
||||
liver weight ≈ 1500-1800 g in an adult. The well-stirred model is standard; parallel-tube and
|
||||
dispersion models give different answers for high-extraction drugs.
|
||||
|
||||
**IVIVE routinely under-predicts clearance**, often 2-5 fold, especially for low-clearance
|
||||
compounds. Empirical scaling factors are widely applied and must be declared. This is the weakest
|
||||
link in a PBPK model and the first place to look when predictions are off.
|
||||
|
||||
## Verification and credibility
|
||||
|
||||
A PBPK model used for a regulatory decision must be *verified* against observed clinical data
|
||||
before being applied to the untested scenario, and the standard of verification scales with how
|
||||
much the decision rests on it:
|
||||
|
||||
- **Predict the observed data first.** A model that cannot reproduce single-dose and multiple-dose
|
||||
plasma profiles in healthy adults should not be used to predict a DDI.
|
||||
- **Verify the perpetrator model independently** using a known index substrate before predicting a
|
||||
novel victim, and vice versa.
|
||||
- Acceptance is usually judged on predicted-to-observed AUC and Cmax ratios within 2-fold, with a
|
||||
tighter criterion where the decision is more consequential.
|
||||
- **Sensitivity analysis** on the uncertain inputs — fu, fm, Ki, CLint, Kp method — is expected,
|
||||
not optional. Report the range of predictions, not a single number.
|
||||
- Software version, model file, and every parameter with its source must be reportable. Regulators
|
||||
ask for the model files.
|
||||
|
||||
## Reporting
|
||||
|
||||
State: the platform and version, the population library used, every drug-specific parameter with
|
||||
its source (measured, predicted, or optimised — and if optimised, against what), the Kp prediction
|
||||
method, the absorption model, the verification datasets and their outcome, and the sensitivity
|
||||
analysis. A PBPK prediction whose inputs are not individually traceable cannot be evaluated by
|
||||
anyone else, and will not be accepted.
|
||||
149
skills/pkpd-modeling/references/pd-and-exposure-response.md
Normal file
149
skills/pkpd-modeling/references/pd-and-exposure-response.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Pharmacodynamics and exposure-response
|
||||
|
||||
## Direct effect models
|
||||
|
||||
```
|
||||
Linear E = E0 + S*C
|
||||
Log-linear E = E0 + S*ln(C) no plateau; only valid over a narrow range
|
||||
Emax E = E0 + Emax*C/(EC50 + C)
|
||||
Sigmoid Emax E = E0 + Emax*C^h/(EC50^h + C^h)
|
||||
Inhibitory E = E0 * (1 - Imax*C^h/(IC50^h + C^h))
|
||||
```
|
||||
|
||||
The Hill coefficient `h` controls steepness. `h = 1` is simple hyperbolic binding; `h > 1` implies
|
||||
cooperativity or an amplification step; `h` between 2 and 4 is common for a downstream clinical
|
||||
endpoint even when the receptor binding itself is 1:1.
|
||||
|
||||
**The single most important diagnostic is how far up the curve the data reach.** If the highest
|
||||
observed exposure produces less than half of the estimated Emax:
|
||||
|
||||
- Emax and EC50 are extrapolations, not estimates;
|
||||
- they are strongly correlated with each other — the data determine their *ratio* (the slope of the
|
||||
low-concentration limb, `Emax/EC50`) but not either one;
|
||||
- a "linear exposure-response" is that same limb. Linear and Emax are not competing models; linear
|
||||
is the limit of Emax below EC50.
|
||||
|
||||
`exposure_response.py --emax` reports `fraction_of_emax_reached` and raises a finding below 0.5.
|
||||
When you are in this regime, report the slope, not Emax and EC50.
|
||||
|
||||
`Imax` is bounded by 1 for a model that can achieve complete inhibition; estimating `Imax > 1` means
|
||||
the model is being used outside its structure.
|
||||
|
||||
## Indirect response models (Dayneka & Jusko)
|
||||
|
||||
Response `R` has its own turnover: production `kin`, first-order loss `kout`, baseline
|
||||
`R0 = kin/kout`. The drug perturbs one of the two.
|
||||
|
||||
| Type | Equation | Drug acts on | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| I | `dR/dt = kin*(1 - I) - kout*R` | Inhibits production | Statins on cholesterol synthesis |
|
||||
| II | `dR/dt = kin - kout*(1 - I)*R` | Inhibits loss | Diuretic effects on sodium |
|
||||
| III | `dR/dt = kin*(1 + S) - kout*R` | Stimulates production | Erythropoietin on reticulocytes |
|
||||
| IV | `dR/dt = kin - kout*(1 + S)*R` | Stimulates loss | |
|
||||
|
||||
where `I = Imax*C^h/(IC50^h + C^h)` and `S = Emax*C^h/(EC50^h + C^h)`.
|
||||
|
||||
Key properties that distinguish IDR from a direct model with an effect compartment:
|
||||
|
||||
- **The delay is dose-dependent.** Higher doses reach maximum effect sooner. An effect compartment's
|
||||
`ke0` gives a delay that does not change with dose. This is the cleanest way to tell them apart,
|
||||
and it requires more than one dose level to see.
|
||||
- **Return to baseline is governed by `kout`**, not by the drug's half-life. A drug can be
|
||||
eliminated completely while the biomarker takes days to recover.
|
||||
- `kin` and `kout` are not both identifiable from a single-dose experiment with an observed
|
||||
baseline: the baseline gives you their ratio, and only the time course of recovery gives you
|
||||
`kout` separately.
|
||||
|
||||
## Effect compartment (Sheiner link model)
|
||||
|
||||
```
|
||||
dCe/dt = ke0 * (C - Ce), E = f(Ce)
|
||||
```
|
||||
|
||||
A hypothetical compartment with no mass that collapses counter-clockwise hysteresis. `ke0` sets the
|
||||
equilibration half-life, `ln(2)/ke0`. Use it when the hysteresis is a distribution delay
|
||||
(anaesthetics, neuromuscular blockers). Use IDR when the delay is turnover of the measured
|
||||
response.
|
||||
|
||||
`_models.effect_compartment()` solves each interval exactly under a linear interpolation of plasma
|
||||
concentration, so the result does not depend on grid density; the usual explicit-Euler
|
||||
implementation understates Ce peaks on sparse grids.
|
||||
|
||||
## Tolerance and rebound
|
||||
|
||||
- **Counter-regulation**: a second, slower indirect response opposing the first. Produces tolerance
|
||||
during dosing and rebound above baseline afterwards.
|
||||
- **Precursor pool**: a depletable pool feeding the response, giving tolerance that recovers only
|
||||
as the pool refills.
|
||||
- **Down-regulation of receptors**: an effect compartment whose Emax declines with cumulative
|
||||
exposure.
|
||||
|
||||
If the response drifts back towards baseline during constant exposure, no direct or simple IDR
|
||||
model will fit, and adding compartments to the PK will not help.
|
||||
|
||||
## Disease progression
|
||||
|
||||
For a chronic endpoint the placebo/natural-history trajectory must be modelled, or the drug effect
|
||||
absorbs it:
|
||||
|
||||
```
|
||||
Linear progression S(t) = S0 + alpha*t
|
||||
Asymptotic S(t) = S_ss + (S0 - S_ss)*exp(-k*t)
|
||||
```
|
||||
|
||||
with the drug entering as **symptomatic** (an offset that disappears on withdrawal) or
|
||||
**disease-modifying** (a change in `alpha` that persists). Distinguishing these requires a
|
||||
randomised-withdrawal or delayed-start design; no amount of modelling of a parallel-group trial
|
||||
will separate them.
|
||||
|
||||
## Exposure-response analysis for dose selection
|
||||
|
||||
The three metrics, and when each is the right one:
|
||||
|
||||
| Metric | Right for |
|
||||
| --- | --- |
|
||||
| AUC or Cavg | Effects driven by total exposure over time |
|
||||
| Cmax | Concentration-driven toxicity; some cardiovascular effects |
|
||||
| Cmin / Ctrough | Effects requiring continuous target coverage; antivirals, antibiotics |
|
||||
|
||||
Choose on mechanism before fitting, not by comparing fits. The three are highly correlated within a
|
||||
single regimen, so the data will rarely discriminate; different regimens (same daily dose, different
|
||||
interval) are what separate them.
|
||||
|
||||
**The confounding problem.** Patients are randomised to dose, not to exposure. Within a dose group,
|
||||
exposure varies because of clearance, and clearance varies with the same factors that drive outcome
|
||||
— renal function, hepatic function, albumin, body size, inflammatory status, disease severity. In
|
||||
oncology this produces a well-documented artefact: an apparent efficacy benefit of higher exposure
|
||||
that is partly a marker of better baseline health. Mitigations: include the confounders as
|
||||
covariates in the E-R model, use case-matching, or compare across randomised dose groups rather
|
||||
than across exposure quantiles.
|
||||
|
||||
**Safety and efficacy E-R must both be modelled.** A dose optimal for efficacy alone is not
|
||||
optimal. FDA's Project Optimus dose-optimisation guidance (final, August 2024) makes this explicit
|
||||
for oncology: identify a dosage that maximises benefit-risk rather than the maximum tolerated dose,
|
||||
support it with PK/PD and exposure-response, and use randomised comparison of more than one dosage.
|
||||
The PK sampling and analysis plan should be in each protocol and sufficient to support population
|
||||
PK and dose/exposure-response analyses.
|
||||
|
||||
## Concentration-QTc
|
||||
|
||||
The framework in ICH E14 and its Q&A documents, and ICH S7B:
|
||||
|
||||
- The threshold of regulatory concern is an effect on QTc **above 10 ms**, judged by the **upper
|
||||
bound of the two-sided 90% confidence interval** of placebo-corrected change from baseline
|
||||
(ΔΔQTc) at the clinically relevant exposure.
|
||||
- A prospective C-QTc analysis on Phase I data can substitute for a dedicated thorough QT study.
|
||||
This is now the routine path rather than the exception.
|
||||
- The 2022 E14/S7B Q&A additions allow an integrated nonclinical risk assessment — a
|
||||
"double negative" of a negative hERG assay and a negative in vivo QTc study — as supplementary
|
||||
evidence, which relaxes the requirement to attain a high multiple of clinical exposure.
|
||||
- Correction method matters: **QTcF** (Fridericia) is standard; QTcB (Bazett) over-corrects at high
|
||||
heart rates and should not be the primary method. A study-specific correction is preferred when
|
||||
heart rate changes substantially.
|
||||
- The regulatory-grade model is a **linear mixed-effects** model with random intercept and slope per
|
||||
subject and a treatment-specific intercept, on time-matched ΔQTc against concentration. The
|
||||
linear model in `exposure_response.py --cqtc` is for screening only.
|
||||
|
||||
Check the model assumption of linearity and of a zero intercept: a non-zero intercept suggests the
|
||||
placebo correction or the baseline is wrong, and a nonlinear concentration-effect relationship
|
||||
invalidates the extrapolation to supratherapeutic exposure.
|
||||
133
skills/pkpd-modeling/references/population-pk.md
Normal file
133
skills/pkpd-modeling/references/population-pk.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Population PK: estimation, covariates, BLQ, and diagnostics
|
||||
|
||||
## The model has three layers, and they are diagnosed separately
|
||||
|
||||
```
|
||||
Structural: C_ij = f(theta_i, dose_i, t_ij)
|
||||
Individual: theta_i = theta_pop * exp(eta_i), eta_i ~ N(0, Omega)
|
||||
Residual: y_ij = C_ij * (1 + eps_prop) + eps_add, eps ~ N(0, Sigma)
|
||||
```
|
||||
|
||||
Almost every difficult population model problem is a layer confusion: an extra compartment added to
|
||||
absorb between-occasion variability, a covariate added to fix a misspecified absorption model, a
|
||||
proportional error inflated to cover a structural bias at low concentrations. Identify the layer
|
||||
before changing anything.
|
||||
|
||||
Exponential (log-normal) inter-individual variability is the default because clearances and volumes
|
||||
are positive and right-skewed. `omega` is reported as an approximate CV: `CV ≈ sqrt(exp(omega²)−1)`,
|
||||
which is close to `omega` itself below about 30%.
|
||||
|
||||
## Estimation methods
|
||||
|
||||
| Method | Character | When |
|
||||
| --- | --- | --- |
|
||||
| FO | First-order linearisation about eta = 0 | Obsolete for final models; biased with large IIV. Still useful for initial estimates |
|
||||
| FOCE | Linearises about the individual eta | The workhorse |
|
||||
| FOCE-I (`INTERACTION`) | FOCE with eta-epsilon interaction | **Required whenever the residual error is proportional or combined.** Omitting `INTERACTION` with a proportional error model is a common and consequential mistake |
|
||||
| Laplace | Second-order expansion | Needed for non-continuous data (categorical, count, time-to-event) and for `LIKELIHOOD`/`-2LL` models |
|
||||
| SAEM | Stochastic approximation EM | Robust to poor initial estimates and to complex models; less prone to local minima; does not itself give an objective function for comparison — follow with an IMP evaluation |
|
||||
| Importance sampling (IMP) | Monte Carlo integration | Accurate objective function; usually run after SAEM |
|
||||
| MCMC / NUTS | Full Bayesian | NONMEM 7.6 adds NUTS; also Stan, Torsten, nlmixr2 |
|
||||
|
||||
Compare objective functions only between models fitted with the **same** method on the **same**
|
||||
records. An OFV drop obtained by switching from FOCE to IMP is not evidence about the model.
|
||||
|
||||
## Below the limit of quantification
|
||||
|
||||
Beal's methods, and what they actually do:
|
||||
|
||||
| Method | Treatment | Verdict |
|
||||
| --- | --- | --- |
|
||||
| M1 | Discard all BLQ observations | Biased upward whenever a meaningful fraction is BLQ; acceptable only when that fraction is small |
|
||||
| M2 | Discard BLQ, condition the likelihood on being above LLOQ | Better than M1, rarely used |
|
||||
| M3 | **Maximum likelihood: BLQ contributes the probability that the observation is below LLOQ** | The reference method. Needs `F_FLAG=1` and the Laplace method |
|
||||
| M4 | M3 conditioned on the concentration being positive | Marginal improvement over M3 |
|
||||
| M5 | Substitute LLOQ/2 | Biased; still common; at least state it |
|
||||
| M6 | Substitute LLOQ/2 for the first BLQ in a run, discard the rest | Ad hoc |
|
||||
| M7 | Substitute 0 | Worst; badly biases the terminal phase |
|
||||
|
||||
Rule of thumb: below roughly 10% BLQ, M1 is defensible; above that, use M3. The bias from M1 falls
|
||||
on the terminal phase, so it propagates directly into half-life, Vz and accumulation predictions.
|
||||
|
||||
## Covariate model building
|
||||
|
||||
Order of operations that avoids the usual traps:
|
||||
|
||||
1. **Get the structural and variability models right first.** A covariate added to a misspecified
|
||||
structural model can absorb the misspecification and look significant.
|
||||
2. **Include size and maturation on mechanistic grounds, not on statistical ones.** Allometric
|
||||
weight scaling on clearance and volume is a prior, not a hypothesis to test. Fixing the
|
||||
exponents at 0.75/1.0 is standard and usually preferable to estimating them.
|
||||
3. **Screen on eta-covariate plots**, not on the objective function, to generate candidates.
|
||||
4. **Forward inclusion at p < 0.05 (ΔOFV > 3.84, 1 df), backward elimination at p < 0.001
|
||||
(ΔOFV > 10.83).** The asymmetry exists because forward selection on the same data inflates the
|
||||
false-positive rate.
|
||||
5. Prefer full-model or full random-effects approaches when the goal is to *quantify* a covariate
|
||||
effect and its uncertainty rather than to *select* covariates. Stepwise selection produces
|
||||
biased effect sizes (selection bias towards large effects) and confidence intervals that are too
|
||||
narrow.
|
||||
|
||||
The likelihood-ratio test relies on the ΔOFV being chi-square distributed, which is only
|
||||
approximately true, and is **not** true on the boundary — testing whether a variance component is
|
||||
zero puts the null on the edge of the parameter space, and the nominal p-value is conservative.
|
||||
|
||||
Correlated covariates (weight and BMI; age and renal function) cannot both be included informatively.
|
||||
Collinearity shows up as an inflated condition number and unstable estimates rather than as a
|
||||
failure.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
**Goodness-of-fit plots.** DV vs PRED and DV vs IPRED; CWRES vs time and vs PRED; |IWRES| vs IPRED.
|
||||
Use CWRES, not WRES — WRES is computed under the FO approximation and is misleading for a
|
||||
FOCE-estimated model. Trends in CWRES against time indicate structural misspecification; a fan
|
||||
shape against PRED indicates the residual error model.
|
||||
|
||||
**Shrinkage.** `eta shrinkage = 1 − SD(eta_i)/omega`. Above roughly 20-30%, individual estimates
|
||||
have collapsed towards the population mean, and:
|
||||
|
||||
- eta-covariate plots become uninformative and can show spurious relationships;
|
||||
- IPRED-based diagnostics look artificially good, because IPRED is being pulled towards the data;
|
||||
- individual parameter estimates should not be used for secondary analysis.
|
||||
|
||||
Epsilon shrinkage above ~30% makes IWRES-based diagnostics unreliable for the same reason. **FDA's
|
||||
2022 population pharmacokinetics guidance states that model selection based on shrinkage is not
|
||||
necessary** — shrinkage is a caveat on how you may interpret diagnostics, not a selection criterion.
|
||||
|
||||
**Visual predictive check.** Simulate many replicates of the study design; plot observed percentiles
|
||||
against the simulated prediction intervals for those percentiles. Use **prediction-corrected VPC**
|
||||
whenever doses or covariates differ across subjects, otherwise the between-subject spread in
|
||||
predictions swamps the comparison. A VPC evaluates the whole model — structural, variability and
|
||||
residual — and is the single most informative diagnostic.
|
||||
|
||||
**NPDE** are the recommended numerical counterpart: decorrelated, and should be N(0,1) under a
|
||||
correct model. Test mean, variance, and normality, and plot against time and predictions.
|
||||
|
||||
**Parameter uncertainty.** The `$COVARIANCE` sandwich estimator is standard but fails on
|
||||
overparameterised models. Alternatives: nonparametric bootstrap (expensive; also fails when the
|
||||
model is unstable, which is informative in itself), log-likelihood profiling (best for a single
|
||||
poorly determined parameter), and sampling importance resampling (SIR), which is much cheaper than
|
||||
bootstrap and works when the covariance step fails.
|
||||
|
||||
**Condition number** — the ratio of largest to smallest eigenvalue of the correlation matrix of the
|
||||
estimates. Above about 1000 indicates ill-conditioning and unreliable standard errors.
|
||||
`fit_compartmental.py` reports this quantity using the same definition, so the familiar threshold
|
||||
applies.
|
||||
|
||||
## Model evaluation checklist
|
||||
|
||||
- Minimisation successful, and the covariance step completed
|
||||
- Parameter RSEs: fixed effects below ~30%, variance components below ~50%
|
||||
- No parameter at a bound
|
||||
- Condition number below 1000
|
||||
- Eta shrinkage below 30% for any eta used in a covariate plot
|
||||
- CWRES without trend against time or PRED
|
||||
- pcVPC with observed percentiles inside the simulated intervals
|
||||
- The model reproduces the quantity the analysis exists to predict — not just the observations
|
||||
|
||||
## What a population analysis has to report
|
||||
|
||||
FDA's 2022 guidance expects the analysis plan to be prospective and the report to state: the data
|
||||
(including exclusions and how BLQ was handled), the structural and statistical models with
|
||||
justification, the covariate strategy defined *before* analysis, the estimation method and software
|
||||
version, the diagnostics, and the model's intended use. Deviations from the plan are documented,
|
||||
not silently absorbed.
|
||||
82
skills/pkpd-modeling/references/regulatory-guidance.md
Normal file
82
skills/pkpd-modeling/references/regulatory-guidance.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Regulatory guidance for PK/PD analyses
|
||||
|
||||
Status verified 2026-07-27. ICH guidelines are published openly and their requirements are
|
||||
summarised directly; always work from the authoritative copy for a submission.
|
||||
|
||||
## ICH
|
||||
|
||||
| Guideline | Subject | Status |
|
||||
| --- | --- | --- |
|
||||
| **M12** | Drug interaction studies | Step 4 in 2024. FDA adopted **2 August 2024** with a Questions & Answers document; effective in the **EU 30 November 2024**; **China 29 October 2024**. First harmonised DDI guidance |
|
||||
| **M13A** | Bioequivalence for immediate-release solid oral dosage forms | Step 4 **July 2024**, effective **25 January 2025** |
|
||||
| **M13B** | Additional strengths and additional-strength biowaivers | Endorsed **13 March 2025**, Step 2b; consultation **9 April – 9 July 2025** |
|
||||
| **M13C** | BE data analysis for highly variable drugs, narrow therapeutic index drugs, and complex designs | Begins after M13B reaches Step 2. **Reference-scaling remains regional until this lands** |
|
||||
| **E11A** | Pediatric extrapolation | Step 4 **21 August 2024**, effective **25 January 2025** |
|
||||
| **M10** | Bioanalytical method validation | The assay behind every concentration; see the `analytical-method-validation` skill |
|
||||
| **E14** | Clinical evaluation of QT/QTc prolongation | With Q&A revisions; the **2022 Q&As** added the double-negative nonclinical pathway |
|
||||
| **S7B** | Nonclinical evaluation of QT prolongation | Paired with E14 through the joint Q&As |
|
||||
| **E4** | Dose-response information to support registration | Foundational for exposure-response |
|
||||
| **E7** | Studies in support of special populations: geriatrics | |
|
||||
|
||||
## FDA
|
||||
|
||||
| Guidance | Date | What it requires that gets missed |
|
||||
| --- | --- | --- |
|
||||
| **Population Pharmacokinetics** | Final, **February 2022** | A prospective analysis plan; explicit BLQ handling; simulation-based diagnostics (VPC, pcVPC, NPC, NPDE). Notably states that **model selection based on parameter shrinkage is not necessary** |
|
||||
| **Optimizing the Dosage of Human Prescription Drugs and Biological Products for the Treatment of Oncologic Diseases** (Project Optimus) | Final, **August 2024** | Identify a dosage maximising benefit-risk rather than the MTD; compare more than one dosage, randomised; a PK sampling and analysis plan in **each** protocol, sufficient for population PK and dose/exposure-response for safety and efficacy; early evaluation of intrinsic factors and DDIs |
|
||||
| **Exposure-Response Relationships** | 2003 | Still the reference for E-R study design and analysis |
|
||||
| **Estimating the Maximum Safe Starting Dose in Initial Clinical Trials for Therapeutics in Adult Healthy Volunteers** | 2005 | The body-surface-area HED conversion table (Km factors) used by `allometry_and_fih.py` |
|
||||
| **Physiologically Based Pharmacokinetic Analyses — Format and Content** | 2018 | What a PBPK submission must contain |
|
||||
| **Clinical Pharmacology Considerations for Human Radiolabeled Mass Balance Studies** | | |
|
||||
| Renal and hepatic impairment guidances | | Study design, including the reduced/staged design |
|
||||
|
||||
## EMA
|
||||
|
||||
| Guideline | Subject |
|
||||
| --- | --- |
|
||||
| Reporting the results of population pharmacokinetic analyses (EMA/CHMP/EWP/185990/2006) | Structure and content of a popPK report |
|
||||
| Investigation of bioequivalence | Being superseded in scope by ICH M13A; EMA has published implementation considerations |
|
||||
| Use of PBPK modelling and simulation | Qualification and reporting of PBPK |
|
||||
| Reporting of physiologically based pharmacokinetic modelling and simulation | |
|
||||
| Evaluation of anticancer medicinal products | Dose optimisation expectations parallel to Project Optimus |
|
||||
|
||||
## What each analysis type has to state
|
||||
|
||||
**Non-compartmental analysis.** Trapezoidal rule; BLQ rule at leading, embedded and trailing
|
||||
positions; the lambda_z selection rule with the window and point count per subject; whether AUCinf
|
||||
is observed- or predicted-based; exclusion criteria fixed before unblinding; software and version.
|
||||
|
||||
**Population PK.** A prospective analysis plan. Data assembly with exclusions and BLQ handling.
|
||||
Structural, statistical and covariate models with justification. Estimation method and software
|
||||
version. Diagnostics including a pcVPC. Parameter estimates with uncertainty. The model's intended
|
||||
use, and its qualification for that use. Deviations from the plan documented rather than absorbed.
|
||||
|
||||
**Exposure-response.** The exposure metric and why it is the mechanistically right one. Both
|
||||
efficacy and safety relationships. Explicit treatment of confounding between exposure and
|
||||
prognosis. The dose or exposure range covered by the data, and what is extrapolation.
|
||||
|
||||
**Bioequivalence.** Design and justification; log-transformed analysis; the 90% CI against
|
||||
pre-specified limits; the criterion (ABE, ABEL or RSABE) fixed in the protocol; the handling of
|
||||
dropouts and pre-dose concentrations; sample-size justification with its assumed GMR and CV.
|
||||
|
||||
**PBPK.** Platform and version; every drug parameter with its source and whether it was measured,
|
||||
predicted or optimised; the Kp prediction method; verification against observed clinical data before
|
||||
the untested application; sensitivity analysis on uncertain inputs; the model files.
|
||||
|
||||
**DDI.** The stepwise assessment with the basic-model results and cut-offs; what triggered further
|
||||
work; the mechanistic static or PBPK refinement with its verification; the clinical studies with
|
||||
index perpetrators and substrates; the labelling conclusion.
|
||||
|
||||
## Model-informed drug development
|
||||
|
||||
Both FDA and EMA operate programmes for discussing model-based evidence before submission — FDA's
|
||||
MIDD Paired Meeting Program and EMA's Qualification of Novel Methodologies. Where a model is
|
||||
intended to *replace* a study rather than support one, engaging early is what determines whether the
|
||||
model is accepted. The level of rigour expected scales with what the model is being asked to carry.
|
||||
|
||||
## The general principle
|
||||
|
||||
Regulators evaluate a model against its **intended use**, not in the abstract. A model adequate for
|
||||
choosing a Phase II dose is not automatically adequate for waiving a paediatric study or supporting
|
||||
a labelling claim. State the intended use first; the required evidence, verification and
|
||||
documentation follow from it.
|
||||
123
skills/pkpd-modeling/references/software-ecosystem.md
Normal file
123
skills/pkpd-modeling/references/software-ecosystem.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# The PK/PD software ecosystem
|
||||
|
||||
Versions verified 2026-07-27 against PyPI, CRAN, vendor documentation and a live install; see
|
||||
`source-ledger.md`. Check again before relying on a version-specific claim.
|
||||
|
||||
## The honest summary for Python users
|
||||
|
||||
**Python has no mature, regulatory-standing NCA or NLME package.** Pharmacometrics remains an
|
||||
R, NONMEM and commercial-tool field. Python's role is orchestration, data preparation, simulation
|
||||
and analysis around those tools — which is what Pharmpy does well. This is why this skill ships its
|
||||
own validated NCA and compartmental-fitting implementations instead of wrapping a package.
|
||||
|
||||
## Nonlinear mixed-effects estimation
|
||||
|
||||
| Tool | Licence | Notes |
|
||||
| --- | --- | --- |
|
||||
| **NONMEM 7.6** | Commercial (ICON) | The regulatory default. Fortran control streams. New in 7.6: **ADVAN16** (RADAR5 implicit Runge-Kutta for stiff delay differential equations), **ADVAN17** (stiff delay differential-algebraic), NUTS Bayesian sampling, SAEM storage of individual parameter samples, and optimal-design evaluation. User guides dated November 2025 |
|
||||
| **Monolix** (Lixoft/Simulations Plus) | Commercial | SAEM-based, strong GUI, good diagnostics |
|
||||
| **nlmixr2** | Open source (R, CRAN) | The credible open alternative. Requires **rxode2 ≥ 5.0.0**. FOCEi, SAEM, and more. `babelmixr2` and `monolix2rx` translate models to and from NONMEM and Monolix |
|
||||
| **Pumas** | Commercial (Julia) | Fast; growing regulatory use |
|
||||
| **Phoenix NLME** (Certara) | Commercial | Paired with WinNonlin |
|
||||
| **Stan / Torsten** | Open source | Full Bayesian; Torsten adds PK/PD event handling to Stan |
|
||||
| **saemix** | Open source (R) | SAEM in R |
|
||||
|
||||
## Pharmpy — the Python entry point
|
||||
|
||||
`pharmpy-core`, from the Uppsala Pharmacometrics group. Model-agnostic: it reads and writes NONMEM,
|
||||
nlmixr2 and rxode2 models and runs tools against whichever estimation engine is installed.
|
||||
|
||||
**Current version 2.1.1 (2026-05-19), requires Python ≥ 3.11.** Two recent breaking changes:
|
||||
|
||||
- **2.0.0 (2026-02-12): dataset row indices now start at 1, not 0.** Any code indexing into a
|
||||
model's dataset by row breaks silently, off by one.
|
||||
- **2.1.0 (2026-05-08): `modeling.add_placebo_model` renamed to `modeling.set_placebo_model`**;
|
||||
numpy ≥ 2 now required; `modeling.get_observations` now includes all DVIDs by default. Also added
|
||||
`convert_unit`, `set_unit`, `get_unit_of`, `add_output_variable`, dataset `Provenance` tracking,
|
||||
an exhaustive stepwise algorithm for `pdsearch`, and pure-PD support in `add_indirect_effect`.
|
||||
|
||||
The 19 tools available as `pharmpy.tools.run_*`:
|
||||
|
||||
```
|
||||
run_allometry run_amd run_bootstrap run_covsearch run_estmethod
|
||||
run_iivsearch run_iovsearch run_linearize run_modelfit run_modelrank
|
||||
run_modelsearch run_pdsearch run_qa run_retries run_ruvsearch
|
||||
run_simulation run_structsearch run_tool run_vpc
|
||||
```
|
||||
|
||||
`run_amd` is the automatic model development pipeline; `run_structsearch` covers PKPD, drug
|
||||
metabolite and TMDD structures; `run_pdsearch` takes `type='pd'` or `'kpd'`.
|
||||
|
||||
Model transformations worth knowing (all verified present in 2.1.1):
|
||||
|
||||
```python
|
||||
import pharmpy.modeling as m
|
||||
|
||||
m.set_tmdd(model, type="qss") # 'full' | 'ib' | 'cr' | 'crib' | 'qss' | 'wagner' | 'mmapp'
|
||||
m.add_indirect_effect(model, expr="emax", prod=True) # 'linear' | 'emax' | 'sigmoid'
|
||||
m.set_direct_effect(model, expr="sigmoid")
|
||||
m.add_effect_compartment(model, expr="emax")
|
||||
m.add_allometry(model, allometric_variable="WT", reference_value=70)
|
||||
m.set_transit_compartments(model, n=3, keep_depot=True)
|
||||
m.set_michaelis_menten_elimination(model)
|
||||
m.calculate_eta_shrinkage(model, ...)
|
||||
```
|
||||
|
||||
## Non-compartmental analysis
|
||||
|
||||
| Tool | Licence | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Phoenix WinNonlin** (Certara) | Commercial | The de facto standard for regulatory NCA |
|
||||
| **PKNCA** | Open source (R) | Mature, well tested, widely used |
|
||||
| **aNCA** | Open source (R) | Newer; Roche with Appsilon and Human Predictions, in the pharmaverse |
|
||||
| **`nca.py` in this skill** | MIT | Validated against analytical profiles; explicit about all four conventions |
|
||||
|
||||
**PKPy** (PeerJ, 2025) is a Python framework combining NCA with population modelling, but it is
|
||||
**GitHub-only and not published on PyPI** — `pip install pkpy` fails. Install from source if you
|
||||
want it.
|
||||
|
||||
## Simulation and trial design
|
||||
|
||||
| Tool | Licence | Notes |
|
||||
| --- | --- | --- |
|
||||
| **mrgsolve** | Open source (R) | Fast C++ ODE simulation; the standard for large trial simulations |
|
||||
| **rxode2** | Open source (R) | Simulation engine underneath nlmixr2 |
|
||||
| **Simulx** (Lixoft) | Commercial | Monolix's simulation companion |
|
||||
| **`simulate_regimen.py`** | MIT | Analytical linear models plus integrated Michaelis-Menten; PTA out of the box |
|
||||
|
||||
## PBPK
|
||||
|
||||
| Tool | Licence |
|
||||
| --- | --- |
|
||||
| **Simcyp** (Certara) | Commercial; the DDI regulatory standard |
|
||||
| **GastroPlus** (Simulations Plus) | Commercial; strongest oral absorption modelling |
|
||||
| **PK-Sim / MoBi** — Open Systems Pharmacology Suite **v12** | **Open source.** `ospsuite` R package needs R 4.x, .NET 8, Windows or Ubuntu |
|
||||
|
||||
## Bioequivalence
|
||||
|
||||
| Tool | Licence | Notes |
|
||||
| --- | --- | --- |
|
||||
| **PowerTOST** | Open source (R) | The reference for BE power and sample size. `bioequivalence.py --power` reproduces its 2×2 tables exactly |
|
||||
| **replicateBE** | Open source (R) | ABEL and RSABE evaluation |
|
||||
| **`bioequivalence.py`** | MIT | ABE, ABEL, RSABE via Hyslop, and exact TOST power |
|
||||
|
||||
## Python packages that are genuinely useful here
|
||||
|
||||
| Package | Version checked | Role |
|
||||
| --- | --- | --- |
|
||||
| `numpy` | 2.5.1 | Everything |
|
||||
| `scipy` | 1.18.0 (**requires Python ≥ 3.12**) | Optimisation, ODE integration, distributions |
|
||||
| `pharmpy-core` | 2.1.1 | Model manipulation and tool orchestration |
|
||||
| `chi-drm` | 1.0.3 | Bayesian PK/PD modelling built on PINTS |
|
||||
| `pints` | 0.6.1 | Inference for ODE models |
|
||||
| `lmfit` | 1.3.4 | Convenient nonlinear least squares with bounded parameters |
|
||||
|
||||
## Choosing
|
||||
|
||||
- **Regulatory population PK submission** → NONMEM (or Monolix), orchestrated with Pharmpy, or
|
||||
nlmixr2 if an open toolchain is required.
|
||||
- **Regulatory NCA** → Phoenix WinNonlin, or PKNCA with a documented validation.
|
||||
- **Exploratory analysis, teaching, prototyping, CI** → the scripts in this skill.
|
||||
- **DDI prediction beyond the static models** → Simcyp, or PK-Sim if the budget is zero.
|
||||
- **Trial simulation at scale** → mrgsolve.
|
||||
- **Bioequivalence planning** → PowerTOST, or `bioequivalence.py --power`.
|
||||
89
skills/pkpd-modeling/references/source-ledger.md
Normal file
89
skills/pkpd-modeling/references/source-ledger.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Source ledger
|
||||
|
||||
Provenance for the version- and date-specific claims in this skill. Everything below was checked on
|
||||
**2026-07-27**. Pharmacological and statistical methods that are stable textbook material are not
|
||||
listed; this covers the claims that go stale.
|
||||
|
||||
## Verified by direct execution
|
||||
|
||||
| Claim | How verified |
|
||||
| --- | --- |
|
||||
| Pharmpy 2.1.1 is the current release | `pip install pharmpy-core` into a clean environment; `pharmpy.__version__` returned `2.1.1` |
|
||||
| Pharmpy exposes 19 `run_*` tools, including `run_pdsearch`, `run_modelrank`, `run_qa`, `run_vpc` | `dir(pharmpy.tools)` on the installed package |
|
||||
| `modeling.add_placebo_model` no longer exists; `set_placebo_model` does | `hasattr` check on the installed `pharmpy.modeling` |
|
||||
| `set_tmdd` accepts `full`, `ib`, `cr`, `crib`, `qss`, `wagner`, `mmapp` | `inspect.signature(pharmpy.modeling.set_tmdd)` |
|
||||
| `add_indirect_effect` accepts `linear`, `emax`, `sigmoid` and a `prod` flag | `inspect.signature` on the installed package |
|
||||
| Bioequivalence sample sizes (2×2, GMR 0.95, 80% power): 12/20/28/40/52/66 at CV 15/20/25/30/35/40% | `bioequivalence.py --power` reproduces the published PowerTOST table exactly |
|
||||
| ABEL limits cap at 69.84–143.19% at CVwR = 50% | Computed from `exp(±0.760 · swR)` at the cap |
|
||||
| NCA recovers λz, t½, CL/F and Vz/F from an analytical one-compartment oral profile | `nca.py` against a simulated noiseless profile with known parameters |
|
||||
| The model library reproduces closed-form AUC = D/CL, Vss = ΣV, MRT = Vss/CL for 1-, 2- and 3-compartment models | Analytical checks in `tests/pkpd-modeling/test_scripts.py` |
|
||||
|
||||
## Package versions from PyPI
|
||||
|
||||
Retrieved from the PyPI JSON API on 2026-07-27.
|
||||
|
||||
| Package | Version | Note |
|
||||
| --- | --- | --- |
|
||||
| `pharmpy-core` | 2.1.1 (2026-05-19) | 2.1.0 on 2026-05-08; 2.0.0 on 2026-02-12; 1.12.0 on 2025-12-05 |
|
||||
| `numpy` | 2.5.1 | |
|
||||
| `scipy` | 1.18.0 | `requires_python >= 3.12` |
|
||||
| `chi-drm` | 1.0.3 | |
|
||||
| `pints` | 0.6.1 | |
|
||||
| `lmfit` | 1.3.4 | |
|
||||
| `pkpy` | **not on PyPI** | Queried and returned no such project; PKPy is GitHub-only |
|
||||
|
||||
## Regulatory status
|
||||
|
||||
| Claim | Source |
|
||||
| --- | --- |
|
||||
| ICH M12 Step 4 2024; FDA adopted 2 August 2024 with a Q&A; EU effective 30 November 2024; China 29 October 2024 | FDA M12 final-guidance announcement and materials; EMA M12 scientific guideline page |
|
||||
| ICH M13A Step 4 July 2024, effective 25 January 2025 | ICH M13A Step 4 materials; EMA M13A scientific guideline page |
|
||||
| ICH M13B endorsed 13 March 2025, Step 2b, consultation 9 April – 9 July 2025 | EMA M13B scientific guideline page |
|
||||
| ICH M13C covers highly variable drugs, NTI drugs and complex designs, and follows M13B | ICH M13 workplan as described in the M13A/M13B materials |
|
||||
| ICH E11A Step 4 21 August 2024, effective 25 January 2025 | ICH E11A Step 4 guideline; EMA E11A Step 5 document |
|
||||
| FDA Population Pharmacokinetics guidance final February 2022; states model selection based on shrinkage is not necessary; lists VPC, pcVPC, NPC, NPDE | FDA guidance document and Federal Register notice of availability, 4 February 2022 |
|
||||
| FDA oncology dose-optimisation guidance (Project Optimus) finalised August 2024; PK sampling and analysis plan in each protocol sufficient for popPK and exposure-response | FDA final guidance and contemporaneous coverage |
|
||||
| ICH E14/S7B 2022 Q&As introduced the double-negative nonclinical assessment | E14/S7B Q&A document; FDA review-experience analysis published 2025 |
|
||||
| FDA 2005 maximum-safe-starting-dose guidance supplies the Km body-surface-area conversion table | FDA guidance, Table 1 |
|
||||
|
||||
## Software versions from vendor and project sources
|
||||
|
||||
| Claim | Source |
|
||||
| --- | --- |
|
||||
| NONMEM 7.6 exists; adds ADVAN16 (RADAR5 stiff DDE), ADVAN17 (stiff delay DAE), NUTS Bayesian, SAEM sample storage | ICON NONMEM 7.6 workshop description and NONMEM 7.6.0 user guides dated November 2025 |
|
||||
| nlmixr2 requires rxode2 ≥ 5.0.0; CRAN package updated 30 November 2025, manual dated 9 May 2026 | CRAN nlmixr2 package page and reference manual |
|
||||
| `babelmixr2` and `monolix2rx` interchange models between nlmixr2, NONMEM and Monolix | nlmixr2 project documentation and a February 2026 methods paper |
|
||||
| Open Systems Pharmacology Suite v12 (with Update 2) is current; `ospsuite` R package needs R 4.x and .NET 8 | OSP Suite GitHub releases and v12 documentation |
|
||||
| PKPy is a 2025 Python popPK framework | PeerJ 2025 paper and its GitHub repository |
|
||||
| PKNCA and aNCA are the R NCA packages; aNCA is a Roche/Appsilon/Human Predictions pharmaverse project | Package documentation sites |
|
||||
|
||||
## Method sources
|
||||
|
||||
Standard methods used in the scripts, cited so the implementation can be checked:
|
||||
|
||||
- **Lambda_z by best adjusted r-squared**, extend-backwards with a 0.0001 improvement threshold —
|
||||
the convention implemented in Phoenix WinNonlin and PKNCA.
|
||||
- **Linear-up/log-down trapezoid** and the corresponding AUMC formula — standard NCA texts; the
|
||||
AUMC log-down form is re-derived in the `nca.py` source comment.
|
||||
- **Hyslop's linearised upper bound** for the FDA reference-scaled criterion, with
|
||||
`theta = ln(1.25)/0.25` — the method in FDA's progesterone product-specific guidance.
|
||||
- **ABEL** widening `exp(±0.760·swR)` capped at CVwR 50% — EMA bioequivalence guideline.
|
||||
- **Beal's M1–M7** methods for BLQ data — Beal, *J Pharmacokinet Pharmacodyn* 2001.
|
||||
- **Dayneka & Jusko** indirect response models I–IV.
|
||||
- **Sheiner** effect-compartment link model.
|
||||
- **Mager & Jusko** TMDD; **Gibiansky** QSS approximation.
|
||||
- **Anderson & Holford** sigmoidal maturation on post-menstrual age (TM50 54.2 weeks, Hill 3.92 as
|
||||
generic clearance values).
|
||||
- **Mahmood & Balian** rule of exponents for interspecies scaling.
|
||||
- **Savic** transit-compartment absorption model.
|
||||
- **2020 ASHP/IDSA/PIDS/SIDP consensus** for vancomycin AUC₂₄/MIC 400–600.
|
||||
|
||||
## Known gaps and deliberate omissions
|
||||
|
||||
- USP and ISO documents are copyrighted and paywalled; where relevant they are cited by designation
|
||||
only, never transcribed.
|
||||
- Product-specific guidances change frequently and are not enumerated here.
|
||||
- The vancomycin population parameters in `tdm_bayes.py` are a conventional illustrative
|
||||
parameterisation, labelled as such in the code and output, not a validated published model.
|
||||
- PK/PD index targets for antimicrobials vary by organism, endpoint and study; the values given are
|
||||
commonly cited ranges, not regulation.
|
||||
126
skills/pkpd-modeling/references/special-populations.md
Normal file
126
skills/pkpd-modeling/references/special-populations.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Special populations
|
||||
|
||||
## Paediatrics
|
||||
|
||||
### Size and maturation are separate
|
||||
|
||||
Body size alone explains paediatric clearance well from roughly 2 years upward. Below that, enzyme
|
||||
and renal maturation dominate, and size-only scaling **overpredicts clearance — in a neonate by
|
||||
several fold**.
|
||||
|
||||
```
|
||||
CL_child = CL_adult * (WT/70)^0.75 * MF
|
||||
MF = PMA^Hill / (TM50^Hill + PMA^Hill) Anderson & Holford
|
||||
```
|
||||
|
||||
Generic clearance values: `TM50 ≈ 54.2 weeks` post-menstrual age, `Hill ≈ 3.92`. Drug-specific
|
||||
ontogeny is much better where it exists, because individual enzymes mature on very different
|
||||
schedules.
|
||||
|
||||
**Use post-menstrual age (gestational + postnatal), not postnatal age.** A 4-week-old born at 28
|
||||
weeks and a 4-week-old born at term have very different eliminating capacity.
|
||||
|
||||
Enzyme ontogeny, in outline:
|
||||
|
||||
| Enzyme | Maturation |
|
||||
| --- | --- |
|
||||
| CYP3A7 | High at birth, declines over the first year |
|
||||
| CYP3A4 | Low at birth, adult levels by ~1 year |
|
||||
| CYP2D6 | Reaches adult activity within weeks; genotype dominates thereafter |
|
||||
| CYP1A2 | Slow; adult levels around 4-5 months, and caffeine clearance in neonates is very low |
|
||||
| UGT2B7, UGT1A1 | Slow; morphine and bilirubin conjugation are limited in neonates |
|
||||
| Renal (GFR) | ~30% of adult (per surface area) at term birth; adult by 6-12 months |
|
||||
|
||||
### ICH E11A pediatric extrapolation
|
||||
|
||||
Step 4 adopted **21 August 2024**, effective **25 January 2025**. It formalises a framework for
|
||||
using adult (or other-population) data to support paediatric conclusions:
|
||||
|
||||
- Build a **pediatric extrapolation concept** from the similarity of disease, response to
|
||||
treatment, and exposure-response between the source and target populations.
|
||||
- Quantify the assumptions and the residual uncertainty; the amount of new paediatric data required
|
||||
scales inversely with confidence in the extrapolation.
|
||||
- Where exposure matching is the basis, the standard applies the 90% CI to 80-125% bounds for AUC
|
||||
and Cmax — but a model-informed approach using dose-response or exposure-response parameters
|
||||
(Emax, EC50, slope) within acceptable limits is an accepted alternative.
|
||||
- Modelling and simulation, including popPK and PBPK, are central rather than supportive.
|
||||
|
||||
The practical consequence: paediatric dose selection is expected to be model-informed, with a
|
||||
prospective plan, not a mg/kg extrapolation from the adult label.
|
||||
|
||||
### Other paediatric points
|
||||
|
||||
- Volume of distribution per kg is **higher** in neonates (greater total body water), so a loading
|
||||
dose per kg is often larger while maintenance is smaller.
|
||||
- Protein binding is lower in neonates (less albumin, less alpha-1-acid glycoprotein, and
|
||||
competition from bilirubin), raising the unbound fraction.
|
||||
- Oral absorption differs: higher gastric pH, slower gastric emptying, immature biliary function.
|
||||
|
||||
## Renal impairment
|
||||
|
||||
Classified by eGFR (mL/min/1.73 m²): normal ≥ 90, mild 60-89, moderate 30-59, severe 15-29, kidney
|
||||
failure < 15.
|
||||
|
||||
- The relevant question is not only whether the **parent** drug is renally cleared, but whether an
|
||||
**active or toxic metabolite** is. Morphine-6-glucuronide accumulating in renal failure is the
|
||||
standard example.
|
||||
- Renal impairment also reduces some **non-renal** clearance pathways — uraemic toxins inhibit
|
||||
CYP and transporter activity — so a low `fe` does not guarantee no effect.
|
||||
- Protein binding falls in uraemia for acidic drugs, raising unbound fraction; total concentrations
|
||||
then understate the change in unbound exposure.
|
||||
- **Dialysis is a separate question** with its own study: whether the drug is removed depends on
|
||||
molecular size, protein binding and volume of distribution, and the dosing implication is about
|
||||
timing relative to the session as much as about dose.
|
||||
- Cockcroft-Gault (creatinine clearance) versus CKD-EPI (eGFR, normalised to 1.73 m²) matters. For
|
||||
dosing, de-normalise eGFR to the individual's body surface area; using a normalised eGFR as if it
|
||||
were an individual clearance misdoses people at the extremes of size.
|
||||
|
||||
## Hepatic impairment
|
||||
|
||||
Child-Pugh A/B/C is the conventional classification, though it is a crude proxy for drug-metabolic
|
||||
capacity and correlates poorly with any specific enzyme.
|
||||
|
||||
- Effects include reduced enzyme content, reduced hepatic blood flow, portosystemic shunting
|
||||
(raising oral bioavailability of high-extraction drugs sharply), reduced albumin, and altered
|
||||
transporter expression.
|
||||
- For a **high-extraction** drug given orally, the dominant effect is loss of first-pass
|
||||
extraction, and exposure can rise many-fold — much more than clearance alone would suggest.
|
||||
- Reduced albumin raises the unbound fraction; for a low-extraction, highly bound drug the unbound
|
||||
concentration may be nearly unchanged while total concentration falls. Interpreting total
|
||||
concentrations alone gives the wrong dose adjustment.
|
||||
|
||||
## Obesity
|
||||
|
||||
Which size descriptor to scale by depends on the parameter and the drug:
|
||||
|
||||
| Descriptor | Use |
|
||||
| --- | --- |
|
||||
| Total body weight | Volume of distribution for lipophilic drugs |
|
||||
| Lean body weight | Clearance, most of the time; the best general-purpose descriptor |
|
||||
| Fat-free mass + a fraction of fat mass ("normal fat mass") | Where lean weight under-predicts |
|
||||
| Body surface area | Conventional in oncology; poorly justified for most agents |
|
||||
| Ideal body weight | Older convention, largely superseded |
|
||||
|
||||
Fixed allometric exponents derived across species do not automatically apply within a species
|
||||
across the obesity range. Fitting the descriptor and letting the data choose is legitimate here.
|
||||
|
||||
## Pregnancy
|
||||
|
||||
Physiological changes across gestation are large and progressive: plasma volume up ~50%, GFR up
|
||||
~50%, albumin down, CYP3A4 and CYP2D6 induced, CYP1A2 and CYP2C19 inhibited. A single "pregnancy"
|
||||
covariate is inadequate — the effect is gestational-age dependent. PBPK with a pregnancy population
|
||||
model is the usual approach, since dedicated PK studies in pregnancy are rare.
|
||||
|
||||
## Geriatric
|
||||
|
||||
Age effects are mostly mediated: declining renal function, reduced hepatic blood flow and mass,
|
||||
changed body composition (less water, more fat), lower albumin. **Include the mediators as
|
||||
covariates rather than age itself** where possible — a model with age standing in for renal
|
||||
function will mispredict a fit 80-year-old and a frail 60-year-old in opposite directions.
|
||||
|
||||
## Organ impairment study design
|
||||
|
||||
Both regulators accept a reduced ("staged") design: study severe impairment first, and if exposure
|
||||
is unchanged, the intermediate categories can often be waived. A full design covers each category
|
||||
against matched controls. Match on age, weight and sex; unmatched controls are the usual reason an
|
||||
organ-impairment study is uninterpretable.
|
||||
140
skills/pkpd-modeling/references/structural-models.md
Normal file
140
skills/pkpd-modeling/references/structural-models.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Structural PK models: solutions, parameterisations, and the ADVAN map
|
||||
|
||||
## Always parameterise in clearance and volume
|
||||
|
||||
Micro-constants (`k10`, `k12`, `k21`) and macro-constants (`A`, `alpha`, `B`, `beta`) are outputs,
|
||||
not parameters to estimate. Clearance and volume are the parameters that:
|
||||
|
||||
- have physiological meaning and known covariate relationships (CL scales with weight^0.75,
|
||||
V with weight^1.0, CL with renal function);
|
||||
- are comparable across studies, formulations and populations;
|
||||
- keep their meaning when a compartment is added — `k10` changes when you add a peripheral
|
||||
compartment, `CL` does not.
|
||||
|
||||
`_models.py` accepts only `cl, v1, q, vp`. `micro_constants()` converts for reporting.
|
||||
|
||||
## Linear mammillary disposition
|
||||
|
||||
Any linear model with a central compartment and *n* peripheral compartments has a unit-bolus
|
||||
impulse response that is a sum of *n+1* exponentials:
|
||||
|
||||
```
|
||||
C(t) = (D) * sum_i coef_i * exp(lambda_i * t)
|
||||
```
|
||||
|
||||
`_models.disposition()` gets `lambda_i` and `coef_i` by eigendecomposition of the rate matrix
|
||||
rather than from the textbook quadratic/cubic root formulas. Same answer, no special cases, works
|
||||
for any number of compartments.
|
||||
|
||||
Given that impulse response, every input is a convolution with a closed form:
|
||||
|
||||
| Input | Solution |
|
||||
| --- | --- |
|
||||
| IV bolus, dose D | `D * sum coef_i exp(lambda_i t)` |
|
||||
| Infusion, rate R over T | `R * sum (coef_i / -lambda_i)(1 - exp(lambda_i min(t,T))) exp(lambda_i (t - min(t,T)))` |
|
||||
| First-order absorption, ka | `F*D*ka * sum coef_i (exp(lambda_i t) - exp(-ka t)) / (ka + lambda_i)` |
|
||||
|
||||
**The absorption term has a removable singularity at `ka = -lambda_i`.** The limit is
|
||||
`coef_i * t * exp(lambda_i t)`. This is not an edge case: it is precisely the flip-flop boundary,
|
||||
and an optimiser walking through it returns `inf` or `nan` without the branch. `_models.py` handles
|
||||
it; a hand-written Bateman function usually does not.
|
||||
|
||||
Useful identities that hold for every linear model, and make good unit tests:
|
||||
|
||||
```
|
||||
AUC(0-inf) after an IV bolus = D / CL (independent of the number of compartments)
|
||||
Vss = V1 + sum(Vp)
|
||||
MRT after an IV bolus = Vss / CL
|
||||
```
|
||||
|
||||
## Flip-flop kinetics
|
||||
|
||||
When absorption is slower than elimination (`ka < lambda_z`), the terminal slope of an oral profile
|
||||
reflects **absorption**, not elimination. The two exponentials are mathematically interchangeable:
|
||||
the fit is identical if you swap `ka` and `k`. Consequences:
|
||||
|
||||
- t½ from an oral profile is the absorption half-life, and Vz/F is meaningless;
|
||||
- which root the optimiser lands on is a starting-value accident;
|
||||
- **the assignment cannot be resolved from extravascular data alone.** It needs IV data, or a
|
||||
formulation with faster absorption, or an external argument.
|
||||
|
||||
`fit_compartmental.py` reports `flip_flop_suspected` and raises a finding. Depot formulations,
|
||||
extended-release products and subcutaneous biologics are routinely flip-flop.
|
||||
|
||||
## Absorption models
|
||||
|
||||
| Model | Parameters | Use when |
|
||||
| --- | --- | --- |
|
||||
| First-order | `ka` | Default; adequate for most immediate-release oral data |
|
||||
| First-order with lag | `ka`, `tlag` | A genuine delay before any drug appears; a discontinuous derivative that some estimation methods dislike |
|
||||
| Zero-order into central | `duration` | Absorption that looks constant-rate; often fits an IR tablet better than expected |
|
||||
| Sequential zero- then first-order | `duration`, `ka` | Delayed then first-order |
|
||||
| Transit compartments (Savic) | `MTT`, `n` | Smooth delay; `n` is estimated as a continuous parameter, `ktr = (n+1)/MTT` |
|
||||
| Weibull | scale, shape | Empirical, flexible, no mechanistic reading |
|
||||
|
||||
The transit model must be evaluated with `lgamma`, not a literal factorial: fitted `n` routinely
|
||||
exceeds 20 and the direct form overflows. `_models.conc_transit()` does this.
|
||||
|
||||
A lag time and a transit chain describe the same phenomenon differently. The transit model is
|
||||
continuous and usually estimates better; the lag is easier to explain. Do not fit both.
|
||||
|
||||
## Nonlinear elimination
|
||||
|
||||
Michaelis-Menten: `dA/dt = -Vmax * C / (Km + C)`, with `C = A/V1`.
|
||||
|
||||
- Below `Km`, clearance is approximately `Vmax/Km` and the drug looks linear;
|
||||
- above `Km`, clearance falls and exposure rises faster than the dose;
|
||||
- **superposition is invalid**, so multiple-dose behaviour cannot be derived from a single dose,
|
||||
and the accumulation ratio is dose-dependent.
|
||||
|
||||
Phenytoin is the classic example: within the therapeutic range a 10% dose increase can produce a
|
||||
much larger exposure increase. If a dose-proportionality analysis shows AUC rising faster than
|
||||
dose, MM elimination is one explanation; saturable first-pass metabolism and solubility-limited
|
||||
absorption (which produce *less* than proportional increases) are others.
|
||||
|
||||
## Combining PK with a delayed effect
|
||||
|
||||
| Structure | Distinguishing feature |
|
||||
| --- | --- |
|
||||
| Direct effect | Effect tracks concentration with no hysteresis |
|
||||
| Effect compartment | Counter-clockwise hysteresis; one parameter `ke0`; the effect site is a modelling construct with no mass |
|
||||
| Indirect response | Delay arises from turnover of the response; return to baseline is governed by `kout` |
|
||||
| Transit/signal transduction | A chain of compartments producing a smooth, longer delay |
|
||||
|
||||
An effect compartment and an indirect response model can both fit a hysteresis loop. They differ in
|
||||
what happens when the drug is stopped, and in how the delay scales with dose — an indirect response
|
||||
model's onset is dose-dependent while `ke0` is not. See `pd-and-exposure-response.md`.
|
||||
|
||||
## NONMEM ADVAN/TRANS map
|
||||
|
||||
For translating a model built here into a control stream:
|
||||
|
||||
| ADVAN | Model | Common TRANS |
|
||||
| --- | --- | --- |
|
||||
| ADVAN1 | One compartment, IV | TRANS2 → `CL`, `V` |
|
||||
| ADVAN2 | One compartment with depot | TRANS2 → `CL`, `V`, `KA` |
|
||||
| ADVAN3 | Two compartment, IV | TRANS4 → `CL`, `V1`, `Q`, `V2` |
|
||||
| ADVAN4 | Two compartment with depot | TRANS4 → `CL`, `V2`, `Q`, `V3`, `KA` |
|
||||
| ADVAN11 | Three compartment, IV | TRANS4 → `CL`, `V1`, `Q2`, `V2`, `Q3`, `V3` |
|
||||
| ADVAN12 | Three compartment with depot | TRANS4 |
|
||||
| ADVAN13 | General nonlinear ODE | user-written `$DES` |
|
||||
| ADVAN6, ADVAN8, ADVAN9 | General ODE (non-stiff, stiff, equilibrium) | user-written `$DES` |
|
||||
| ADVAN15 | General with equilibrium compartments | |
|
||||
| ADVAN16, ADVAN17 | **New in NONMEM 7.6**: stiff delay differential equations (RADAR5) and stiff delay differential-algebraic equations | |
|
||||
|
||||
Watch the compartment numbering: in ADVAN4 the central compartment is 2 and `V2` is the central
|
||||
volume, whereas in ADVAN3 the central compartment is 1 and `V1` is central. Mixing the two
|
||||
conventions when converting a model is a standard source of a silently wrong volume.
|
||||
|
||||
Analytical ADVANs are far faster and more numerically stable than `$DES` and should be used
|
||||
whenever the model is linear. Reach for ADVAN13 only when the structure genuinely is not.
|
||||
|
||||
## Choosing the number of compartments
|
||||
|
||||
Use, in order: the residual pattern (runs of the same sign mean the shape is wrong), the F test for
|
||||
nested models, BIC, and the parameter precision. Do not use AIC alone — its fixed penalty of 2 per
|
||||
parameter frequently selects an extra compartment whose intercompartmental clearance has an RSE
|
||||
above 50%. `fit_compartmental.py --compare` prints all four side by side.
|
||||
|
||||
Adding a compartment is justified when it changes the *conclusions* — Vss, the terminal half-life,
|
||||
the accumulation ratio, the predicted trough — not merely when it improves the objective function.
|
||||
115
skills/pkpd-modeling/references/tmdd-and-biologics.md
Normal file
115
skills/pkpd-modeling/references/tmdd-and-biologics.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Target-mediated drug disposition and biologics PK
|
||||
|
||||
## The full TMDD model
|
||||
|
||||
When a drug binds its target with high affinity and the target is present at a concentration
|
||||
comparable to the drug's, binding is not just pharmacology — it is a clearance pathway.
|
||||
|
||||
```
|
||||
dL/dt = In - kel*L - kon*L*R + koff*RL free drug
|
||||
dR/dt = ksyn - kdeg*R - kon*L*R + koff*RL free target
|
||||
dRL/dt = kon*L*R - (koff + kint)*RL complex
|
||||
```
|
||||
|
||||
The characteristic profile has four phases: a rapid initial drop as the target is bound, a slower
|
||||
linear phase while the target is saturated, a steep terminal drop as drug falls below target
|
||||
capacity and target-mediated clearance resumes, and a final linear phase. **Dose-normalised
|
||||
profiles that do not superimpose, with the low dose disappearing faster, is the signature.**
|
||||
|
||||
The model is stiff by construction — `kon` is typically 10³ to 10⁶ times `kel` — which is why
|
||||
`_models.simulate_tmdd()` uses LSODA rather than a fixed-step explicit method.
|
||||
|
||||
## Approximations, in order of increasing assumption
|
||||
|
||||
| Approximation | Assumes | Parameters |
|
||||
| --- | --- | --- |
|
||||
| Full | Nothing | `kon`, `koff`, `kint`, `ksyn`, `kdeg`, `kel`, `V` |
|
||||
| Rapid binding (QE) | Binding at equilibrium: `Kd = koff/kon` | replaces `kon`, `koff` with `Kd` |
|
||||
| Quasi-steady-state (QSS) | Complex at steady state: `Kss = (koff + kint)/kon` | replaces `kon`, `koff` with `Kss` |
|
||||
| Michaelis-Menten | Target dynamics fast and target constant | `Vmax`, `Km` — loses all target information |
|
||||
| Wagner / constant Rtot | Total target constant | |
|
||||
| Irreversible binding (IB) | `koff` negligible | |
|
||||
|
||||
**QSS is the usual practical choice.** The full model is rarely identifiable from plasma drug
|
||||
concentrations alone: `kon` and `koff` appear almost exclusively as their ratio, and estimating them
|
||||
separately requires target or complex measurements. Pharmpy 2.1.1 exposes exactly this hierarchy —
|
||||
`set_tmdd(model, type=...)` accepts `'full'`, `'ib'`, `'cr'`, `'crib'`, `'qss'`, `'wagner'` and
|
||||
`'mmapp'`, with `dv_types` to map observations to drug, total drug, target, total target, or
|
||||
complex.
|
||||
|
||||
**Which quantity was measured is a first-order question.** A ligand-binding assay typically reports
|
||||
**total** drug (free + complex), while the model's natural state is free drug. Fitting a total-drug
|
||||
observation to a free-drug prediction produces a badly wrong Kd, and nothing in the fit statistics
|
||||
reveals it. `_models.simulate_tmdd()` returns free drug, free target, complex and total drug
|
||||
separately for this reason.
|
||||
|
||||
## Monoclonal antibody pharmacokinetics
|
||||
|
||||
Typical IgG behaviour, useful as a sanity check on any fitted mAb model:
|
||||
|
||||
| Property | Typical value |
|
||||
| --- | --- |
|
||||
| Clearance | 0.1-0.5 L/day (linear component) |
|
||||
| Central volume | ~3 L, close to plasma volume |
|
||||
| Vss | 5-10 L; distribution is largely confined to plasma and interstitial fluid |
|
||||
| Terminal half-life | 2-4 weeks for a typical IgG1 |
|
||||
| Subcutaneous bioavailability | 50-80% |
|
||||
| Time to SC Tmax | 2-8 days |
|
||||
|
||||
Mechanisms that matter:
|
||||
|
||||
- **FcRn recycling** is what gives IgG its long half-life. Antibodies engineered for higher FcRn
|
||||
affinity at endosomal pH (YTE, LS mutations) extend half-life several-fold.
|
||||
- **Catabolism** is nonspecific proteolysis, not renal or hepatic clearance. Renal impairment does
|
||||
not meaningfully change mAb clearance; molecules below ~60 kDa are a different story.
|
||||
- **Target-mediated clearance** dominates at low doses; the drug looks nonlinear until the target
|
||||
is saturated.
|
||||
- **Subcutaneous absorption** is via the lymphatics, slow and incomplete, and produces flip-flop
|
||||
kinetics: the apparent terminal slope after SC dosing can reflect absorption.
|
||||
|
||||
Allometric exponents for mAbs are often closer to 0.85-0.9 for clearance and near 1.0 for volume
|
||||
rather than the small-molecule 0.75.
|
||||
|
||||
## Immunogenicity
|
||||
|
||||
Anti-drug antibodies increase clearance, sometimes by an order of magnitude, and typically appear
|
||||
after weeks. Handling in a model:
|
||||
|
||||
- Treat ADA status as a **time-varying** covariate, not a baseline one. A subject who seroconverts
|
||||
at week 8 has two different clearances in one profile.
|
||||
- ADA-positive subjects often show a bimodal concentration distribution rather than a shifted one;
|
||||
a covariate on clearance may fit poorly where a mixture model fits well.
|
||||
- Neutralising versus binding ADA, and titre, matter more than the binary status.
|
||||
- Assay drug tolerance limits ADA detection when drug is present, so ADA-negative at trough is not
|
||||
the same as ADA-negative.
|
||||
|
||||
## Antibody-drug conjugates
|
||||
|
||||
An ADC needs at least three analytes modelled, and they answer different questions:
|
||||
|
||||
- **ADC** (conjugated antibody) — the dosed entity
|
||||
- **Total antibody** (conjugated + unconjugated) — the deconjugation rate is the difference
|
||||
- **Unconjugated payload** — usually drives systemic toxicity
|
||||
|
||||
Drug-to-antibody ratio changes over time as the conjugate deconjugates, so "the ADC" is a
|
||||
distribution of species, not one molecule. Payload exposure is generally the safety-relevant
|
||||
metric.
|
||||
|
||||
## Bispecifics and cell engagers
|
||||
|
||||
Ternary complex formation (drug + target + effector) is not captured by standard TMDD. The
|
||||
concentration-effect relationship is typically **bell-shaped**: at high concentrations the drug
|
||||
saturates both arms separately and forms fewer ternary complexes ("hook effect"). A monotone Emax
|
||||
model fitted to such data will mislead about the optimal dose in exactly the region that matters.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Do not fit a full TMDD model to plasma drug data alone. Start with QSS; move up only if target or
|
||||
complex measurements exist.
|
||||
- Check dose-normalised profiles first. If they superimpose across the clinical dose range, TMDD is
|
||||
saturated throughout and a linear model is adequate for that range — but it will not extrapolate
|
||||
to lower doses.
|
||||
- Baseline target concentration `R0 = ksyn/kdeg` is often measurable and should be fixed to the
|
||||
measurement rather than estimated.
|
||||
- Report which analyte each observation is. This single piece of metadata resolves more confused
|
||||
biologics models than any structural change.
|
||||
327
skills/pkpd-modeling/scripts/_common.py
Normal file
327
skills/pkpd-modeling/scripts/_common.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Shared I/O, formatting, and reporting helpers for the pkpd-modeling scripts.
|
||||
|
||||
Every script in this skill follows the same contract:
|
||||
|
||||
* data goes to **stdout**, provenance and findings go to **stderr**, so
|
||||
``script.py ... > out.tsv`` keeps them apart;
|
||||
* ``--format table|tsv|json`` selects the stdout rendering, and ``json`` is
|
||||
self-contained (it carries findings and notes too, because a machine reading
|
||||
stdout should not have to also parse stderr);
|
||||
* exit status is ``0`` when nothing was flagged, ``1`` when at least one
|
||||
finding was raised, and ``2`` for unusable input — so any script can gate a
|
||||
pipeline.
|
||||
|
||||
Nothing here does pharmacology. The numerics live in ``_models.py`` and in the
|
||||
individual scripts; this module only moves tables around.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_FINDINGS = 1
|
||||
EXIT_INPUT = 2
|
||||
|
||||
_MISSING = {"", ".", "na", "n/a", "nan", "null", "none", "-"}
|
||||
|
||||
|
||||
class InputError(Exception):
|
||||
"""Unusable input. Callers map this to exit code 2."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------- formatting
|
||||
|
||||
|
||||
def fmt(value: Any, digits: int = 6) -> str:
|
||||
"""Render a value for a fixed-width table without lying about precision."""
|
||||
if value is None:
|
||||
return "n/a"
|
||||
if isinstance(value, bool):
|
||||
return "yes" if value else "no"
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (int,)):
|
||||
return str(value)
|
||||
try:
|
||||
x = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if math.isnan(x):
|
||||
return "n/a"
|
||||
if math.isinf(x):
|
||||
return "inf" if x > 0 else "-inf"
|
||||
if x == 0:
|
||||
return "0"
|
||||
return f"{x:.{digits}g}"
|
||||
|
||||
|
||||
def parse_float(text: Any, field_name: str = "value", allow_missing: bool = False):
|
||||
"""Parse a float, treating the usual missing-value spellings as ``None``."""
|
||||
if text is None:
|
||||
if allow_missing:
|
||||
return None
|
||||
raise InputError(f"{field_name}: missing")
|
||||
if isinstance(text, (int, float)):
|
||||
x = float(text)
|
||||
if math.isnan(x) and not allow_missing:
|
||||
raise InputError(f"{field_name}: not a number")
|
||||
return x
|
||||
s = str(text).strip()
|
||||
if s.lower() in _MISSING:
|
||||
if allow_missing:
|
||||
return None
|
||||
raise InputError(f"{field_name}: missing")
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError as exc:
|
||||
raise InputError(f"{field_name}: {s!r} is not a number") from exc
|
||||
|
||||
|
||||
def parse_positive(text: Any, field_name: str) -> float:
|
||||
x = parse_float(text, field_name)
|
||||
if x is None or x <= 0:
|
||||
raise InputError(f"{field_name}: must be greater than zero, got {text!r}")
|
||||
return x
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- tables
|
||||
|
||||
|
||||
def _sniff_delimiter(sample: str, path: Path) -> str:
|
||||
if path.suffix.lower() in {".tsv", ".tab"}:
|
||||
return "\t"
|
||||
if path.suffix.lower() == ".csv":
|
||||
return ","
|
||||
counts = {d: sample.count(d) for d in (",", "\t", ";")}
|
||||
return max(counts, key=lambda d: counts[d]) if max(counts.values()) else ","
|
||||
|
||||
|
||||
def read_table(source: str | Path) -> list[dict[str, str]]:
|
||||
"""Read a delimited table into a list of row dicts.
|
||||
|
||||
Accepts ``-`` for stdin. Column names are lower-cased and stripped so a
|
||||
file with ``Time`` and one with ``TIME`` behave the same; blank lines and
|
||||
lines beginning with ``#`` are skipped, which lets a fixture carry a
|
||||
provenance header.
|
||||
"""
|
||||
if str(source) == "-":
|
||||
text = sys.stdin.read()
|
||||
path = Path("stdin.csv")
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.is_file():
|
||||
raise InputError(f"no such file: {path}")
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
|
||||
lines = [ln for ln in text.splitlines() if ln.strip() and not ln.lstrip().startswith("#")]
|
||||
if not lines:
|
||||
raise InputError(f"{path}: no data rows")
|
||||
|
||||
delimiter = _sniff_delimiter(lines[0], path)
|
||||
reader = csv.DictReader(lines, delimiter=delimiter)
|
||||
if not reader.fieldnames:
|
||||
raise InputError(f"{path}: no header row")
|
||||
|
||||
fieldnames = [(name or "").strip().lower() for name in reader.fieldnames]
|
||||
if len(set(fieldnames)) != len(fieldnames):
|
||||
dupes = sorted({n for n in fieldnames if fieldnames.count(n) > 1})
|
||||
raise InputError(f"{path}: duplicate column names: {', '.join(dupes)}")
|
||||
|
||||
rows: list[dict[str, str]] = []
|
||||
for raw in reader:
|
||||
row = {}
|
||||
for key, value in zip(fieldnames, (raw.get(orig) for orig in reader.fieldnames)):
|
||||
row[key] = (value or "").strip()
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
raise InputError(f"{path}: header only, no data rows")
|
||||
return rows
|
||||
|
||||
|
||||
def require_columns(rows: Sequence[Mapping[str, Any]], required: Iterable[str], where: str) -> None:
|
||||
present = set(rows[0].keys())
|
||||
missing = [c for c in required if c not in present]
|
||||
if missing:
|
||||
raise InputError(
|
||||
f"{where}: missing required column(s): {', '.join(missing)}. "
|
||||
f"Found: {', '.join(sorted(present))}"
|
||||
)
|
||||
|
||||
|
||||
def column(rows: Sequence[Mapping[str, Any]], name: str, allow_missing: bool = False) -> list:
|
||||
return [parse_float(r.get(name), f"{name} (row {i + 1})", allow_missing) for i, r in enumerate(rows)]
|
||||
|
||||
|
||||
def group_by(rows: Sequence[Mapping[str, Any]], key: str) -> dict[str, list[Mapping[str, Any]]]:
|
||||
"""Stable grouping that preserves first-seen order of the keys."""
|
||||
out: dict[str, list[Mapping[str, Any]]] = {}
|
||||
for row in rows:
|
||||
out.setdefault(str(row.get(key, "")).strip(), []).append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reports
|
||||
|
||||
|
||||
@dataclass
|
||||
class Table:
|
||||
title: str
|
||||
rows: list[dict[str, Any]]
|
||||
columns: list[str] | None = None
|
||||
|
||||
def headers(self) -> list[str]:
|
||||
if self.columns:
|
||||
return list(self.columns)
|
||||
seen: list[str] = []
|
||||
for row in self.rows:
|
||||
for key in row:
|
||||
if key not in seen:
|
||||
seen.append(key)
|
||||
return seen
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""Accumulates output, then renders it once at the end.
|
||||
|
||||
Findings are the reason a script exits non-zero. They are deliberately
|
||||
plain sentences rather than codes: they are read by a person deciding
|
||||
whether an analysis is defensible.
|
||||
"""
|
||||
|
||||
tables: list[Table] = field(default_factory=list)
|
||||
findings: list[str] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
scalars: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def table(self, title: str, rows: Sequence[Mapping[str, Any]], columns: Sequence[str] | None = None) -> None:
|
||||
self.tables.append(Table(title, [dict(r) for r in rows], list(columns) if columns else None))
|
||||
|
||||
def scalar(self, name: str, value: Any) -> None:
|
||||
self.scalars[name] = value
|
||||
|
||||
def note(self, text: str) -> None:
|
||||
self.notes.append(text)
|
||||
|
||||
def finding(self, text: str) -> None:
|
||||
self.findings.append(text)
|
||||
|
||||
# -- rendering
|
||||
|
||||
def _render_table(self, table: Table) -> str:
|
||||
headers = table.headers()
|
||||
cells = [[fmt(row.get(h)) for h in headers] for row in table.rows]
|
||||
widths = [len(h) for h in headers]
|
||||
for line in cells:
|
||||
for i, value in enumerate(line):
|
||||
widths[i] = max(widths[i], len(value))
|
||||
out = [""]
|
||||
if table.title:
|
||||
out.append(table.title)
|
||||
out.append(" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip())
|
||||
for line in cells:
|
||||
out.append(" ".join(v.ljust(widths[i]) for i, v in enumerate(line)).rstrip())
|
||||
return "\n".join(out)
|
||||
|
||||
def emit(self, fmt_name: str, stream=None, err=None) -> int:
|
||||
stream = stream or sys.stdout
|
||||
err = err or sys.stderr
|
||||
|
||||
if fmt_name == "json":
|
||||
payload = {
|
||||
"scalars": self.scalars,
|
||||
"tables": [
|
||||
{"title": t.title, "columns": t.headers(), "rows": t.rows} for t in self.tables
|
||||
],
|
||||
"findings": self.findings,
|
||||
"notes": self.notes,
|
||||
}
|
||||
print(json.dumps(payload, indent=2, default=_json_default), file=stream)
|
||||
elif fmt_name == "tsv":
|
||||
for key, value in self.scalars.items():
|
||||
print(f"{key}\t{fmt(value)}", file=stream)
|
||||
for table in self.tables:
|
||||
headers = table.headers()
|
||||
print("\t".join(headers), file=stream)
|
||||
for row in table.rows:
|
||||
print("\t".join(fmt(row.get(h)) for h in headers), file=stream)
|
||||
else:
|
||||
if self.scalars:
|
||||
width = max(len(k) for k in self.scalars)
|
||||
for key, value in self.scalars.items():
|
||||
print(f"{key.ljust(width)} {fmt(value)}", file=stream)
|
||||
for table in self.tables:
|
||||
print(self._render_table(table), file=stream)
|
||||
|
||||
if fmt_name != "json":
|
||||
for note in self.notes:
|
||||
print(f"note: {note}", file=err)
|
||||
for finding in self.findings:
|
||||
print(f"finding: {finding}", file=err)
|
||||
|
||||
return EXIT_FINDINGS if self.findings else EXIT_OK
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
if hasattr(obj, "item"): # numpy scalar
|
||||
return obj.item()
|
||||
if hasattr(obj, "tolist"): # numpy array
|
||||
return obj.tolist()
|
||||
if isinstance(obj, float) and math.isnan(obj):
|
||||
return None
|
||||
raise TypeError(f"not JSON serialisable: {type(obj).__name__}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def add_format_argument(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=("table", "tsv", "json"),
|
||||
default="table",
|
||||
help="stdout rendering (default: table). json is self-contained.",
|
||||
)
|
||||
|
||||
|
||||
def main_wrapper(func, argv: Sequence[str] | None = None) -> int:
|
||||
"""Run a script body, mapping InputError to exit code 2.
|
||||
|
||||
Keeps the ``if __name__`` block in every script down to one line and makes
|
||||
the exit-code contract impossible to get wrong in one script and right in
|
||||
another.
|
||||
"""
|
||||
try:
|
||||
return func(argv)
|
||||
except InputError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return EXIT_INPUT
|
||||
except BrokenPipeError: # `| head`
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EXIT_OK",
|
||||
"EXIT_FINDINGS",
|
||||
"EXIT_INPUT",
|
||||
"InputError",
|
||||
"Report",
|
||||
"Table",
|
||||
"add_format_argument",
|
||||
"column",
|
||||
"fmt",
|
||||
"group_by",
|
||||
"main_wrapper",
|
||||
"parse_float",
|
||||
"parse_positive",
|
||||
"read_table",
|
||||
"require_columns",
|
||||
]
|
||||
673
skills/pkpd-modeling/scripts/_models.py
Normal file
673
skills/pkpd-modeling/scripts/_models.py
Normal file
@@ -0,0 +1,673 @@
|
||||
"""Structural PK and PD model library.
|
||||
|
||||
Linear mammillary models are solved **analytically**, not numerically. The
|
||||
disposition of any 1-, 2-, or 3-compartment model is reduced once to a sum of
|
||||
exponentials by eigendecomposition of the rate matrix, and every input type
|
||||
(bolus, zero-order infusion, first-order absorption) is then the convolution of
|
||||
that impulse response with the input function, in closed form. This matters for
|
||||
three reasons:
|
||||
|
||||
* fitting calls the model thousands of times, and a closed form is ~10^3 faster
|
||||
than an ODE solve;
|
||||
* an ODE solver's tolerance shows up as noise in the objective function, which
|
||||
makes gradients unreliable and covariance matrices optimistic;
|
||||
* the singular cases have exact limits (see ``_absorption_term``), whereas a
|
||||
solver silently returns whatever the step size gives.
|
||||
|
||||
Nonlinear structures — Michaelis-Menten elimination and target-mediated drug
|
||||
disposition — have no closed form and are integrated with LSODA, which switches
|
||||
to a stiff method on its own. TMDD is stiff by construction: binding is orders
|
||||
of magnitude faster than elimination.
|
||||
|
||||
Parameterisation is always **clearance-based** (CL, V1, Q, V2, ...), never
|
||||
micro-constants. Micro-constants are not identifiable across studies, do not
|
||||
scale allometrically, and cannot be given a covariate model that means
|
||||
anything. ``micro_constants`` converts one way for reporting; nothing in this
|
||||
skill fits them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
try: # pragma: no cover - exercised only when scipy is absent
|
||||
from scipy.integrate import solve_ivp
|
||||
except ImportError: # pragma: no cover
|
||||
solve_ivp = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- disposition
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Disposition:
|
||||
"""Impulse response of a linear mammillary model, in concentration units.
|
||||
|
||||
``concentration(t)`` for a unit IV bolus is ``sum(coef * exp(lam * t))``.
|
||||
``lam`` are the negative eigenvalues (``-alpha``, ``-beta``, ``-gamma``)
|
||||
sorted from fastest to slowest, so ``lam[-1]`` is the terminal slope.
|
||||
"""
|
||||
|
||||
lam: np.ndarray
|
||||
coef: np.ndarray
|
||||
cl: float
|
||||
v1: float
|
||||
q: tuple[float, ...] = ()
|
||||
vp: tuple[float, ...] = ()
|
||||
|
||||
@property
|
||||
def n_compartments(self) -> int:
|
||||
return 1 + len(self.q)
|
||||
|
||||
@property
|
||||
def half_lives(self) -> np.ndarray:
|
||||
return np.log(2.0) / -self.lam
|
||||
|
||||
@property
|
||||
def terminal_half_life(self) -> float:
|
||||
return float(np.log(2.0) / -self.lam[-1])
|
||||
|
||||
@property
|
||||
def vss(self) -> float:
|
||||
return float(self.v1 + sum(self.vp))
|
||||
|
||||
@property
|
||||
def auc_unit_dose(self) -> float:
|
||||
"""AUC(0-inf) after a unit IV bolus; equals 1/CL for any linear model."""
|
||||
return float(np.sum(self.coef / -self.lam))
|
||||
|
||||
@property
|
||||
def mrt_iv(self) -> float:
|
||||
"""Mean residence time after an IV bolus; equals Vss/CL."""
|
||||
aumc = float(np.sum(self.coef / self.lam**2))
|
||||
return aumc / self.auc_unit_dose
|
||||
|
||||
|
||||
def disposition(cl: float, v1: float, q: Sequence[float] = (), vp: Sequence[float] = ()) -> Disposition:
|
||||
"""Build the impulse response for a mammillary model.
|
||||
|
||||
``q`` and ``vp`` are the intercompartmental clearances and peripheral
|
||||
volumes; pass none for one-compartment, one each for two, two each for
|
||||
three. All values are in consistent units (e.g. L/h and L).
|
||||
"""
|
||||
q = tuple(float(x) for x in q)
|
||||
vp = tuple(float(x) for x in vp)
|
||||
if len(q) != len(vp):
|
||||
raise ValueError(f"got {len(q)} intercompartmental clearances but {len(vp)} peripheral volumes")
|
||||
if cl <= 0 or v1 <= 0:
|
||||
raise ValueError("CL and V1 must be positive")
|
||||
if any(x <= 0 for x in q + vp):
|
||||
raise ValueError("Q and Vp must be positive")
|
||||
|
||||
n = 1 + len(q)
|
||||
a = np.zeros((n, n))
|
||||
a[0, 0] = -cl / v1
|
||||
for j, (qj, vpj) in enumerate(zip(q, vp), start=1):
|
||||
k1j = qj / v1
|
||||
kj1 = qj / vpj
|
||||
a[0, 0] -= k1j
|
||||
a[0, j] = kj1
|
||||
a[j, 0] = k1j
|
||||
a[j, j] = -kj1
|
||||
|
||||
if n == 1:
|
||||
lam = np.array([a[0, 0]])
|
||||
coef = np.array([1.0 / v1])
|
||||
else:
|
||||
# A mammillary rate matrix is similar to a symmetric matrix, so its
|
||||
# eigenvalues are real and negative. eig() can still return a tiny
|
||||
# imaginary part from round-off; discard it rather than propagate a
|
||||
# complex concentration.
|
||||
values, vectors = np.linalg.eig(a)
|
||||
values = np.real(values)
|
||||
vectors = np.real(vectors)
|
||||
inverse = np.linalg.inv(vectors)
|
||||
coef = vectors[0, :] * inverse[:, 0] / v1
|
||||
lam = values
|
||||
|
||||
order = np.argsort(lam) # most negative (fastest) first
|
||||
lam, coef = lam[order], coef[order]
|
||||
if np.any(lam >= 0):
|
||||
raise ValueError("non-negative eigenvalue: parameters do not describe a stable model")
|
||||
return Disposition(lam=lam, coef=coef, cl=float(cl), v1=float(v1), q=q, vp=vp)
|
||||
|
||||
|
||||
def micro_constants(d: Disposition) -> dict[str, float]:
|
||||
"""Micro-constants and macro-constants, for reporting only."""
|
||||
out = {"k10": d.cl / d.v1}
|
||||
for j, (qj, vpj) in enumerate(zip(d.q, d.vp), start=1):
|
||||
out[f"k1{j + 1}"] = qj / d.v1
|
||||
out[f"k{j + 1}1"] = qj / vpj
|
||||
for i, (lam, coef) in enumerate(zip(d.lam, d.coef)):
|
||||
out[f"lambda{i + 1}"] = -lam
|
||||
out[f"coef{i + 1}_per_dose"] = coef
|
||||
out[f"t_half_{i + 1}"] = math.log(2.0) / -lam
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------- input terms
|
||||
|
||||
|
||||
def _absorption_term(lam: np.ndarray, ka: float, t: np.ndarray) -> np.ndarray:
|
||||
"""(exp(lam t) - exp(-ka t)) / (ka + lam), with the removable singularity handled.
|
||||
|
||||
When ``ka`` approaches ``-lam`` the denominator vanishes. The limit is
|
||||
``t * exp(lam t)``. This is not a corner case: it is exactly the
|
||||
flip-flop boundary where absorption and elimination rates coincide, and a
|
||||
fitter walking through it produces inf or nan without this branch.
|
||||
"""
|
||||
denom = ka + lam[None, :]
|
||||
near = np.abs(denom) < 1e-8
|
||||
safe = np.where(near, 1.0, denom)
|
||||
regular = (np.exp(lam[None, :] * t[:, None]) - np.exp(-ka * t)[:, None]) / safe
|
||||
limit = t[:, None] * np.exp(lam[None, :] * t[:, None])
|
||||
return np.where(near, limit, regular)
|
||||
|
||||
|
||||
def conc_bolus(t: np.ndarray, dose: float, d: Disposition) -> np.ndarray:
|
||||
t = np.atleast_1d(np.asarray(t, dtype=float))
|
||||
out = dose * np.exp(d.lam[None, :] * t[:, None]) @ d.coef
|
||||
return np.where(t < 0, 0.0, out)
|
||||
|
||||
|
||||
def conc_infusion(t: np.ndarray, dose: float, duration: float, d: Disposition) -> np.ndarray:
|
||||
"""Zero-order input of ``dose`` over ``duration``, starting at t = 0."""
|
||||
t = np.atleast_1d(np.asarray(t, dtype=float))
|
||||
if duration <= 0:
|
||||
return conc_bolus(t, dose, d)
|
||||
rate = dose / duration
|
||||
t_in = np.clip(t, 0.0, duration) # time spent infusing
|
||||
t_post = np.maximum(t - duration, 0.0) # time since infusion ended
|
||||
ramp = (1.0 - np.exp(d.lam[None, :] * t_in[:, None])) / -d.lam[None, :]
|
||||
decay = np.exp(d.lam[None, :] * t_post[:, None])
|
||||
return np.where(t < 0, 0.0, rate * (ramp * decay) @ d.coef)
|
||||
|
||||
|
||||
def conc_oral(t: np.ndarray, dose: float, ka: float, d: Disposition, f: float = 1.0, tlag: float = 0.0) -> np.ndarray:
|
||||
"""First-order absorption from a depot with bioavailable fraction ``f``."""
|
||||
t = np.atleast_1d(np.asarray(t, dtype=float))
|
||||
shifted = np.maximum(t - tlag, 0.0)
|
||||
out = f * dose * ka * (_absorption_term(d.lam, ka, shifted) @ d.coef)
|
||||
return np.where(t <= tlag, 0.0, out)
|
||||
|
||||
|
||||
def conc_transit(
|
||||
t: np.ndarray, dose: float, mtt: float, n: float, d: Disposition, f: float = 1.0, ka: float | None = None
|
||||
) -> np.ndarray:
|
||||
"""Savic transit-compartment absorption, evaluated with the log-gamma form.
|
||||
|
||||
The literal factorial form overflows for ``n`` above ~20, and a fitted
|
||||
transit number routinely lands there. Using ``lgamma`` keeps it finite.
|
||||
``n`` need not be an integer: it is estimated as a continuous parameter.
|
||||
"""
|
||||
t = np.atleast_1d(np.asarray(t, dtype=float))
|
||||
if mtt <= 0 or n < 0:
|
||||
raise ValueError("MTT must be positive and n non-negative")
|
||||
ktr = (n + 1.0) / mtt
|
||||
ka = ktr if ka is None else ka
|
||||
# Input rate into the central compartment, convolved numerically with the
|
||||
# analytic disposition on a fine grid: the transit chain has no compact
|
||||
# closed form once it is combined with a multi-exponential disposition.
|
||||
grid = np.linspace(0.0, float(np.max(t)) if np.max(t) > 0 else 1.0, 4096)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
log_rate = (
|
||||
math.log(f * dose) + math.log(ktr) + n * np.log(np.maximum(grid, 1e-300) * ktr) - ktr * grid - math.lgamma(n + 1.0)
|
||||
)
|
||||
rate = np.where(grid > 0, np.exp(log_rate), 0.0)
|
||||
step = grid[1] - grid[0]
|
||||
out = np.zeros_like(t)
|
||||
for i, ti in enumerate(t):
|
||||
if ti <= 0:
|
||||
continue
|
||||
mask = grid <= ti
|
||||
tau = grid[mask]
|
||||
response = np.exp(d.lam[None, :] * (ti - tau)[:, None]) @ d.coef
|
||||
out[i] = np.trapezoid(rate[mask] * response, dx=step)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- dosing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dose:
|
||||
"""One dosing event.
|
||||
|
||||
``duration`` of 0 with ``route='iv'`` is a bolus; a positive duration is a
|
||||
zero-order infusion. ``route='oral'`` uses first-order absorption and
|
||||
applies ``f`` and ``tlag``.
|
||||
"""
|
||||
|
||||
time: float
|
||||
amount: float
|
||||
duration: float = 0.0
|
||||
route: str = "iv"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.route not in {"iv", "oral"}:
|
||||
raise ValueError(f"route must be 'iv' or 'oral', got {self.route!r}")
|
||||
if self.amount < 0:
|
||||
raise ValueError("dose amount must not be negative")
|
||||
|
||||
|
||||
def build_regimen(
|
||||
amount: float,
|
||||
interval: float | None = None,
|
||||
n_doses: int = 1,
|
||||
start: float = 0.0,
|
||||
duration: float = 0.0,
|
||||
route: str = "iv",
|
||||
loading: float | None = None,
|
||||
) -> list[Dose]:
|
||||
"""Evenly spaced doses, optionally with a different first dose."""
|
||||
if n_doses < 1:
|
||||
raise ValueError("n_doses must be at least 1")
|
||||
if n_doses > 1 and (interval is None or interval <= 0):
|
||||
raise ValueError("a multiple-dose regimen needs a positive interval")
|
||||
doses = []
|
||||
for i in range(n_doses):
|
||||
amt = loading if (i == 0 and loading is not None) else amount
|
||||
doses.append(Dose(start + i * (interval or 0.0), amt, duration, route))
|
||||
return doses
|
||||
|
||||
|
||||
def simulate_linear(
|
||||
times: Sequence[float],
|
||||
regimen: Sequence[Dose],
|
||||
d: Disposition,
|
||||
ka: float | None = None,
|
||||
f: float = 1.0,
|
||||
tlag: float = 0.0,
|
||||
) -> np.ndarray:
|
||||
"""Concentration-time profile by superposition.
|
||||
|
||||
Superposition is exact for a linear model and is what makes multiple-dose
|
||||
and irregular-interval simulation cheap. It is **not** valid once any
|
||||
element of the model is nonlinear — Michaelis-Menten elimination,
|
||||
saturable binding, time-varying clearance — which is the single most common
|
||||
way a hand-rolled multiple-dose simulation goes wrong.
|
||||
"""
|
||||
times = np.atleast_1d(np.asarray(times, dtype=float))
|
||||
total = np.zeros_like(times)
|
||||
for dose in regimen:
|
||||
offset = times - dose.time
|
||||
if dose.route == "oral":
|
||||
if ka is None:
|
||||
raise ValueError("oral dosing needs ka")
|
||||
total += conc_oral(offset, dose.amount, ka, d, f=f, tlag=tlag)
|
||||
elif dose.duration > 0:
|
||||
total += conc_infusion(offset, dose.amount, dose.duration, d)
|
||||
else:
|
||||
total += conc_bolus(offset, dose.amount, d)
|
||||
return total
|
||||
|
||||
|
||||
def steady_state_metrics(d: Disposition, dose: float, interval: float, f: float = 1.0) -> dict[str, float]:
|
||||
"""Closed-form steady-state summaries for a linear model.
|
||||
|
||||
Accumulation ratio is computed per exponential rather than from the
|
||||
terminal slope alone. For a two-compartment drug given at an interval
|
||||
short relative to the distribution phase, the terminal-slope shortcut
|
||||
``1/(1 - exp(-lambda_z tau))`` overstates accumulation, sometimes badly.
|
||||
"""
|
||||
if interval <= 0:
|
||||
raise ValueError("interval must be positive")
|
||||
auc_tau = f * dose * d.auc_unit_dose
|
||||
cavg = auc_tau / interval
|
||||
# Cmax/Cmin at steady state for a bolus: sum over exponentials of the
|
||||
# geometric series for repeated dosing.
|
||||
ss_coef = d.coef / (1.0 - np.exp(d.lam * interval))
|
||||
cmax_ss = float(f * dose * np.sum(ss_coef))
|
||||
cmin_ss = float(f * dose * np.sum(ss_coef * np.exp(d.lam * interval)))
|
||||
single_cmax = float(f * dose * np.sum(d.coef))
|
||||
return {
|
||||
"auc_tau_ss": auc_tau,
|
||||
"cavg_ss": cavg,
|
||||
"cmax_ss_bolus": cmax_ss,
|
||||
"cmin_ss_bolus": cmin_ss,
|
||||
"accumulation_ratio_auc": 1.0 / (1.0 - math.exp(d.lam[-1] * interval)),
|
||||
"accumulation_ratio_cmax_bolus": cmax_ss / single_cmax if single_cmax else float("nan"),
|
||||
"peak_trough_fluctuation_pct": 100.0 * (cmax_ss - cmin_ss) / cavg if cavg else float("nan"),
|
||||
"time_to_90pct_ss": -math.log(0.10) / -d.lam[-1],
|
||||
"time_to_95pct_ss": -math.log(0.05) / -d.lam[-1],
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------ nonlinear structures
|
||||
|
||||
|
||||
def _require_scipy(what: str) -> None:
|
||||
if solve_ivp is None: # pragma: no cover
|
||||
raise RuntimeError(f"{what} needs scipy; install scipy to use this model")
|
||||
|
||||
|
||||
def _integrate_with_doses(
|
||||
rhs: Callable[[float, np.ndarray], np.ndarray],
|
||||
y0: np.ndarray,
|
||||
times: np.ndarray,
|
||||
regimen: Sequence[Dose],
|
||||
dose_compartment: int,
|
||||
rtol: float = 1e-8,
|
||||
atol: float = 1e-10,
|
||||
) -> np.ndarray:
|
||||
"""Integrate across dose events by restarting at each one.
|
||||
|
||||
Bolus doses are state discontinuities. Handing them to a solver as part of
|
||||
the right-hand side (a narrow spike, or a conditional) is how people get
|
||||
doses silently skipped when the adaptive step jumps over them. Restarting
|
||||
the integration at every event makes that impossible.
|
||||
"""
|
||||
_require_scipy("ODE-based models")
|
||||
events = sorted(regimen, key=lambda x: x.time)
|
||||
infusions = [(e.time, e.time + e.duration, e.amount / e.duration) for e in events if e.duration > 0]
|
||||
|
||||
def rhs_with_infusions(t: float, y: np.ndarray) -> np.ndarray:
|
||||
dy = np.asarray(rhs(t, y), dtype=float)
|
||||
for start, end, rate in infusions:
|
||||
if start <= t < end:
|
||||
dy[dose_compartment] += rate
|
||||
return dy
|
||||
|
||||
breakpoints = sorted({0.0, *(e.time for e in events), *(e.time + e.duration for e in events if e.duration > 0), float(np.max(times))})
|
||||
breakpoints = [b for b in breakpoints if b <= np.max(times) + 1e-12]
|
||||
|
||||
out = np.zeros((len(times), len(y0)))
|
||||
state = np.array(y0, dtype=float)
|
||||
for index, start in enumerate(breakpoints):
|
||||
for event in events:
|
||||
if math.isclose(event.time, start, rel_tol=0, abs_tol=1e-12) and event.duration == 0:
|
||||
state[dose_compartment] += event.amount
|
||||
stop = breakpoints[index + 1] if index + 1 < len(breakpoints) else float(np.max(times))
|
||||
window = (times >= start - 1e-12) & (times <= stop + 1e-12)
|
||||
if stop <= start:
|
||||
out[window] = state
|
||||
continue
|
||||
solution = solve_ivp(
|
||||
rhs_with_infusions,
|
||||
(start, stop),
|
||||
state,
|
||||
method="LSODA",
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
dense_output=True,
|
||||
max_step=(stop - start),
|
||||
)
|
||||
if not solution.success: # pragma: no cover - solver failure path
|
||||
raise RuntimeError(f"integration failed between t={start} and t={stop}: {solution.message}")
|
||||
if np.any(window):
|
||||
out[window] = solution.sol(np.clip(times[window], start, stop)).T
|
||||
state = solution.y[:, -1]
|
||||
return out
|
||||
|
||||
|
||||
def simulate_michaelis_menten(
|
||||
times: Sequence[float],
|
||||
regimen: Sequence[Dose],
|
||||
vmax: float,
|
||||
km: float,
|
||||
v1: float,
|
||||
q: Sequence[float] = (),
|
||||
vp: Sequence[float] = (),
|
||||
ka: float | None = None,
|
||||
f: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Concentration with saturable (Michaelis-Menten) elimination.
|
||||
|
||||
``vmax`` is an amount per unit time, ``km`` a concentration. Doubling the
|
||||
dose of such a drug does not double exposure, and no amount of
|
||||
superposition will reproduce that — this must be integrated.
|
||||
"""
|
||||
times = np.atleast_1d(np.asarray(times, dtype=float))
|
||||
q = tuple(q)
|
||||
vp = tuple(vp)
|
||||
n_periph = len(q)
|
||||
depot = 1 if ka is not None else 0
|
||||
size = 1 + n_periph + depot
|
||||
|
||||
def rhs(_t: float, y: np.ndarray) -> np.ndarray:
|
||||
dy = np.zeros(size)
|
||||
central = y[depot]
|
||||
conc = central / v1
|
||||
elimination = vmax * conc / (km + conc)
|
||||
dy[depot] -= elimination
|
||||
if depot:
|
||||
dy[0] = -ka * y[0]
|
||||
dy[depot] += f * ka * y[0]
|
||||
for j in range(n_periph):
|
||||
idx = depot + 1 + j
|
||||
flux = q[j] * (conc - y[idx] / vp[j])
|
||||
dy[depot] -= flux
|
||||
dy[idx] += flux
|
||||
return dy
|
||||
|
||||
y0 = np.zeros(size)
|
||||
states = _integrate_with_doses(rhs, y0, times, regimen, dose_compartment=0 if depot else 0)
|
||||
return states[:, depot] / v1
|
||||
|
||||
|
||||
def simulate_tmdd(
|
||||
times: Sequence[float],
|
||||
regimen: Sequence[Dose],
|
||||
cl: float,
|
||||
v1: float,
|
||||
kon: float,
|
||||
koff: float,
|
||||
kint: float,
|
||||
ksyn: float,
|
||||
kdeg: float,
|
||||
q: float | None = None,
|
||||
vp: float | None = None,
|
||||
approximation: str = "full",
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""Target-mediated drug disposition.
|
||||
|
||||
``approximation`` is ``full`` (Mager-Jusko) or ``qss`` (quasi-steady-state,
|
||||
Gibiansky). The full model is stiff — binding is typically 10^3 to 10^6
|
||||
times faster than elimination — which is why LSODA is used rather than a
|
||||
fixed-step explicit method.
|
||||
|
||||
Returns free drug, free target, complex, and total drug concentrations. The
|
||||
distinction matters more than it looks: a ligand-binding assay usually
|
||||
measures **total** drug, and fitting a total-drug observation to a free-drug
|
||||
prediction is a standard way to get a badly wrong Kd.
|
||||
"""
|
||||
times = np.atleast_1d(np.asarray(times, dtype=float))
|
||||
if approximation not in {"full", "qss"}:
|
||||
raise ValueError("approximation must be 'full' or 'qss'")
|
||||
has_periph = q is not None and vp is not None
|
||||
kel = cl / v1
|
||||
kd_qss = (koff + kint) / kon
|
||||
|
||||
if approximation == "full":
|
||||
# y = [free drug amount, free target conc, complex conc, (peripheral amount)]
|
||||
size = 4 if has_periph else 3
|
||||
|
||||
def rhs(_t: float, y: np.ndarray) -> np.ndarray:
|
||||
drug = max(y[0], 0.0) / v1
|
||||
target, complex_ = max(y[1], 0.0), max(y[2], 0.0)
|
||||
binding = kon * drug * target - koff * complex_
|
||||
dy = np.zeros(size)
|
||||
dy[0] = -kel * y[0] - binding * v1
|
||||
dy[1] = ksyn - kdeg * target - binding
|
||||
dy[2] = binding - kint * complex_
|
||||
if has_periph:
|
||||
flux = q * (drug - y[3] / vp)
|
||||
dy[0] -= flux
|
||||
dy[3] = flux
|
||||
return dy
|
||||
|
||||
y0 = np.zeros(size)
|
||||
y0[1] = ksyn / kdeg
|
||||
states = _integrate_with_doses(rhs, y0, times, regimen, dose_compartment=0)
|
||||
free = states[:, 0] / v1
|
||||
target = states[:, 1]
|
||||
complex_ = states[:, 2]
|
||||
return {"free_drug": free, "free_target": target, "complex": complex_, "total_drug": free + complex_}
|
||||
|
||||
# QSS: binding assumed at equilibrium, solved from the total-drug quadratic.
|
||||
size = 3 if has_periph else 2
|
||||
|
||||
def rhs_qss(_t: float, y: np.ndarray) -> np.ndarray:
|
||||
total_drug = max(y[0], 0.0) / v1
|
||||
total_target = max(y[1], 0.0)
|
||||
b = total_drug - total_target - kd_qss
|
||||
free = 0.5 * (b + math.sqrt(b * b + 4.0 * kd_qss * total_drug))
|
||||
free = max(free, 0.0)
|
||||
complex_ = total_target * free / (kd_qss + free) if (kd_qss + free) > 0 else 0.0
|
||||
dy = np.zeros(size)
|
||||
dy[0] = -kel * free * v1 - kint * complex_ * v1
|
||||
dy[1] = ksyn - kdeg * (total_target - complex_) - kint * complex_
|
||||
if has_periph:
|
||||
flux = q * (free - y[2] / vp)
|
||||
dy[0] -= flux
|
||||
dy[2] = flux
|
||||
return dy
|
||||
|
||||
y0 = np.zeros(size)
|
||||
y0[1] = ksyn / kdeg
|
||||
states = _integrate_with_doses(rhs_qss, y0, times, regimen, dose_compartment=0)
|
||||
total_drug = states[:, 0] / v1
|
||||
total_target = states[:, 1]
|
||||
b = total_drug - total_target - kd_qss
|
||||
free = 0.5 * (b + np.sqrt(b * b + 4.0 * kd_qss * np.maximum(total_drug, 0.0)))
|
||||
free = np.maximum(free, 0.0)
|
||||
complex_ = total_target * free / (kd_qss + free)
|
||||
return {
|
||||
"free_drug": free,
|
||||
"free_target": np.maximum(total_target - complex_, 0.0),
|
||||
"complex": complex_,
|
||||
"total_drug": total_drug,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- PD models
|
||||
|
||||
|
||||
def emax(conc: np.ndarray, e0: float, emax_value: float, ec50: float, hill: float = 1.0) -> np.ndarray:
|
||||
"""Sigmoid Emax. ``hill = 1`` is the ordinary Emax model."""
|
||||
conc = np.maximum(np.asarray(conc, dtype=float), 0.0)
|
||||
if hill == 1.0:
|
||||
return e0 + emax_value * conc / (ec50 + conc)
|
||||
powered = np.power(conc, hill)
|
||||
return e0 + emax_value * powered / (np.power(ec50, hill) + powered)
|
||||
|
||||
|
||||
def imax(conc: np.ndarray, e0: float, imax_value: float, ic50: float, hill: float = 1.0) -> np.ndarray:
|
||||
"""Inhibitory sigmoid model; ``imax_value`` of 1 permits complete inhibition."""
|
||||
conc = np.maximum(np.asarray(conc, dtype=float), 0.0)
|
||||
powered = np.power(conc, hill)
|
||||
return e0 * (1.0 - imax_value * powered / (np.power(ic50, hill) + powered))
|
||||
|
||||
|
||||
def effect_compartment(times: Sequence[float], conc: Sequence[float], ke0: float) -> np.ndarray:
|
||||
"""Hysteresis-collapsing effect compartment, integrated on the observed grid.
|
||||
|
||||
Solved exactly per interval under a linear interpolation of plasma
|
||||
concentration, so the result does not depend on how densely the profile
|
||||
was sampled — the usual explicit-Euler version does, and understates Ce
|
||||
peaks on sparse grids.
|
||||
"""
|
||||
times = np.asarray(times, dtype=float)
|
||||
conc = np.asarray(conc, dtype=float)
|
||||
if times.shape != conc.shape:
|
||||
raise ValueError("times and conc must have the same length")
|
||||
if ke0 <= 0:
|
||||
raise ValueError("ke0 must be positive")
|
||||
ce = np.zeros_like(times)
|
||||
for i in range(1, len(times)):
|
||||
dt = times[i] - times[i - 1]
|
||||
if dt <= 0:
|
||||
ce[i] = ce[i - 1]
|
||||
continue
|
||||
c0, c1 = conc[i - 1], conc[i]
|
||||
slope = (c1 - c0) / dt
|
||||
decay = math.exp(-ke0 * dt)
|
||||
# Exact solution of dCe/dt = ke0 (c0 + slope*t - Ce) over [0, dt].
|
||||
ce[i] = ce[i - 1] * decay + (c0 - slope / ke0) * (1.0 - decay) + slope * dt
|
||||
return ce
|
||||
|
||||
|
||||
IDR_TYPES = {
|
||||
1: "inhibition of production (kin)",
|
||||
2: "inhibition of loss (kout)",
|
||||
3: "stimulation of production (kin)",
|
||||
4: "stimulation of loss (kout)",
|
||||
}
|
||||
|
||||
|
||||
def indirect_response(
|
||||
times: Sequence[float],
|
||||
conc_fn: Callable[[float], float],
|
||||
kin: float,
|
||||
kout: float,
|
||||
idr_type: int,
|
||||
max_effect: float,
|
||||
c50: float,
|
||||
hill: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Dayneka-Jusko indirect response models I-IV.
|
||||
|
||||
Baseline is ``kin / kout`` by construction, so the four models differ in
|
||||
*how* the drug perturbs turnover, not in where the response starts. This is
|
||||
the whole point: a direct Emax fit to a delayed biomarker will absorb the
|
||||
delay into a falsely large EC50, and the two are distinguishable only by
|
||||
the shape of the return to baseline.
|
||||
|
||||
``max_effect`` is Imax for types I-II (bounded by 1 for complete
|
||||
inhibition) and Emax for types III-IV (unbounded).
|
||||
"""
|
||||
_require_scipy("indirect response models")
|
||||
if idr_type not in IDR_TYPES:
|
||||
raise ValueError(f"idr_type must be one of {sorted(IDR_TYPES)}")
|
||||
if kin <= 0 or kout <= 0 or c50 <= 0:
|
||||
raise ValueError("kin, kout and C50 must be positive")
|
||||
|
||||
def drive(t: float) -> float:
|
||||
conc = max(float(conc_fn(t)), 0.0)
|
||||
powered = conc**hill
|
||||
return max_effect * powered / (c50**hill + powered)
|
||||
|
||||
def rhs(t: float, y: np.ndarray) -> np.ndarray:
|
||||
fraction = drive(t)
|
||||
if idr_type == 1:
|
||||
return np.array([kin * (1.0 - fraction) - kout * y[0]])
|
||||
if idr_type == 2:
|
||||
return np.array([kin - kout * (1.0 - fraction) * y[0]])
|
||||
if idr_type == 3:
|
||||
return np.array([kin * (1.0 + fraction) - kout * y[0]])
|
||||
return np.array([kin - kout * (1.0 + fraction) * y[0]])
|
||||
|
||||
times = np.atleast_1d(np.asarray(times, dtype=float))
|
||||
span = (float(min(times.min(), 0.0)), float(times.max()))
|
||||
solution = solve_ivp(
|
||||
rhs, span, [kin / kout], method="LSODA", rtol=1e-8, atol=1e-10, dense_output=True, max_step=max(span[1] / 200.0, 1e-6)
|
||||
)
|
||||
if not solution.success: # pragma: no cover
|
||||
raise RuntimeError(f"indirect response integration failed: {solution.message}")
|
||||
return solution.sol(times)[0]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Disposition",
|
||||
"Dose",
|
||||
"IDR_TYPES",
|
||||
"build_regimen",
|
||||
"conc_bolus",
|
||||
"conc_infusion",
|
||||
"conc_oral",
|
||||
"conc_transit",
|
||||
"disposition",
|
||||
"effect_compartment",
|
||||
"emax",
|
||||
"imax",
|
||||
"indirect_response",
|
||||
"micro_constants",
|
||||
"simulate_linear",
|
||||
"simulate_michaelis_menten",
|
||||
"simulate_tmdd",
|
||||
"steady_state_metrics",
|
||||
]
|
||||
346
skills/pkpd-modeling/scripts/allometry_and_fih.py
Normal file
346
skills/pkpd-modeling/scripts/allometry_and_fih.py
Normal file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Allometric scaling, maturation, and first-in-human starting dose.
|
||||
|
||||
Two different jobs share this script because they share a failure mode: taking
|
||||
a number derived for one purpose and using it for another. An HED is not a
|
||||
starting dose. A NOAEL-derived MRSD is not appropriate for an agonist
|
||||
immunomodulator. Allometry with a fixed 0.75 exponent describes clearance in
|
||||
adults and is wrong in neonates unless maturation is modelled separately.
|
||||
|
||||
python3 allometry_and_fih.py --scale --cl 5 --weight-from 70 --weight-to 15
|
||||
python3 allometry_and_fih.py --scale --cl 5 --weight-from 70 --weight-to 6 --pma-weeks 44
|
||||
python3 allometry_and_fih.py --exponent -i species.csv
|
||||
python3 allometry_and_fih.py --fih --noael rat=50,dog=10 --safety-factor 10 --human-weight 60
|
||||
python3 allometry_and_fih.py --mabel --ec50 2.5 --target-occupancy 0.2 --cl 0.2 --weight 70
|
||||
|
||||
``--exponent`` input is a table of ``species,weight,cl`` for cross-species
|
||||
regression.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import (
|
||||
InputError,
|
||||
Report,
|
||||
add_format_argument,
|
||||
main_wrapper,
|
||||
parse_float,
|
||||
read_table,
|
||||
require_columns,
|
||||
)
|
||||
|
||||
# FDA, "Estimating the Maximum Safe Starting Dose in Initial Clinical Trials
|
||||
# for Therapeutics in Adult Healthy Volunteers" (July 2005), Table 1. Km is the
|
||||
# body-weight-to-surface-area factor; HED in mg/kg = animal mg/kg * Km_animal /
|
||||
# Km_human.
|
||||
KM_FACTORS = {
|
||||
"human-adult": (60.0, 37.0),
|
||||
"human-child": (20.0, 25.0),
|
||||
"mouse": (0.020, 3.0),
|
||||
"hamster": (0.080, 5.0),
|
||||
"rat": (0.150, 6.0),
|
||||
"ferret": (0.300, 7.0),
|
||||
"guinea-pig": (0.400, 8.0),
|
||||
"rabbit": (1.8, 12.0),
|
||||
"dog": (10.0, 20.0),
|
||||
"monkey": (3.0, 12.0),
|
||||
"marmoset": (0.350, 6.0),
|
||||
"squirrel-monkey": (0.600, 7.0),
|
||||
"baboon": (12.0, 20.0),
|
||||
"micro-pig": (20.0, 27.0),
|
||||
"mini-pig": (40.0, 35.0),
|
||||
}
|
||||
|
||||
# Anderson & Holford sigmoidal maturation on post-menstrual age. The defaults
|
||||
# are the widely used generic clearance values; a drug with a known
|
||||
# ontogeny profile should override them.
|
||||
DEFAULT_TM50_WEEKS = 54.2
|
||||
DEFAULT_MATURATION_HILL = 3.92
|
||||
|
||||
|
||||
def maturation_fraction(pma_weeks: float, tm50: float = DEFAULT_TM50_WEEKS, hill: float = DEFAULT_MATURATION_HILL) -> float:
|
||||
"""Fraction of adult clearance attributable to enzyme maturation."""
|
||||
if pma_weeks <= 0:
|
||||
raise InputError("post-menstrual age must be positive")
|
||||
return pma_weeks**hill / (tm50**hill + pma_weeks**hill)
|
||||
|
||||
|
||||
def allometric(value: float, weight_from: float, weight_to: float, exponent: float) -> float:
|
||||
return value * (weight_to / weight_from) ** exponent
|
||||
|
||||
|
||||
def hed_mg_per_kg(animal_dose_mg_kg: float, species: str) -> float:
|
||||
if species not in KM_FACTORS:
|
||||
raise InputError(f"unknown species {species!r}; known: {', '.join(sorted(KM_FACTORS))}")
|
||||
_, km_animal = KM_FACTORS[species]
|
||||
_, km_human = KM_FACTORS["human-adult"]
|
||||
return animal_dose_mg_kg * km_animal / km_human
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- exponent
|
||||
|
||||
|
||||
def fit_exponent(weights: np.ndarray, values: np.ndarray) -> dict[str, float]:
|
||||
"""Log-log regression of a parameter on body weight across species."""
|
||||
if len(weights) < 3:
|
||||
raise InputError("cross-species regression needs at least 3 species")
|
||||
x = np.log(weights)
|
||||
y = np.log(values)
|
||||
n = len(x)
|
||||
slope, intercept = np.polyfit(x, y, 1)
|
||||
fitted = intercept + slope * x
|
||||
ss_res = float(np.sum((y - fitted) ** 2))
|
||||
ss_tot = float(np.sum((y - y.mean()) ** 2))
|
||||
se_slope = math.sqrt(ss_res / (n - 2) / float(np.sum((x - x.mean()) ** 2))) if n > 2 else float("nan")
|
||||
return {
|
||||
"exponent": float(slope),
|
||||
"coefficient": float(math.exp(intercept)),
|
||||
"se_exponent": se_slope,
|
||||
"ci95_low": float(slope - 1.96 * se_slope),
|
||||
"ci95_high": float(slope + 1.96 * se_slope),
|
||||
"r2": 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan"),
|
||||
"n_species": n,
|
||||
}
|
||||
|
||||
|
||||
def rule_of_exponents(exponent: float) -> str:
|
||||
"""Mahmood & Balian's rule of exponents, stated with its own caveat."""
|
||||
if exponent < 0.55:
|
||||
return "below 0.55: simple allometry is unreliable; human clearance is likely overpredicted"
|
||||
if exponent <= 0.70:
|
||||
return "0.55-0.70: simple allometry"
|
||||
if exponent <= 1.00:
|
||||
return "0.71-1.00: apply the maximum-life-span-potential correction"
|
||||
return "above 1.00: apply the brain-weight correction; predictions are poor in this range"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Allometric scaling, paediatric maturation, and first-in-human dose estimation.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
mode = parser.add_argument_group("mode (choose one)")
|
||||
mode.add_argument("--scale", action="store_true", help="scale parameters between two body weights")
|
||||
mode.add_argument("--exponent", action="store_true", help="estimate the allometric exponent from species data")
|
||||
mode.add_argument("--fih", action="store_true", help="NOAEL-based human equivalent dose and MRSD")
|
||||
mode.add_argument("--mabel", action="store_true", help="minimum anticipated biological effect level")
|
||||
|
||||
parser.add_argument("-i", "--input", help="species table for --exponent (species,weight,cl)")
|
||||
parser.add_argument("--cl", type=float, help="clearance at the reference weight")
|
||||
parser.add_argument("--volume", type=float, help="volume at the reference weight")
|
||||
parser.add_argument("--weight-from", type=float, default=70.0, help="reference weight, kg (default: 70)")
|
||||
parser.add_argument("--weight-to", type=float, help="target weight, kg")
|
||||
parser.add_argument("--cl-exponent", type=float, default=0.75)
|
||||
parser.add_argument("--v-exponent", type=float, default=1.0)
|
||||
parser.add_argument("--pma-weeks", type=float, help="post-menstrual age; adds a maturation factor")
|
||||
parser.add_argument("--tm50", type=float, default=DEFAULT_TM50_WEEKS)
|
||||
parser.add_argument("--maturation-hill", type=float, default=DEFAULT_MATURATION_HILL)
|
||||
|
||||
parser.add_argument("--noael", help="comma-separated species=mg/kg, e.g. rat=50,dog=10")
|
||||
parser.add_argument("--safety-factor", type=float, default=10.0)
|
||||
parser.add_argument("--human-weight", type=float, default=60.0, help="kg, for converting MRSD to a total dose")
|
||||
|
||||
parser.add_argument("--ec50", type=float, help="in vitro potency (same units as the target concentration)")
|
||||
parser.add_argument("--target-occupancy", type=float, default=0.10, help="fraction of target engagement to aim for")
|
||||
parser.add_argument("--weight", type=float, default=70.0)
|
||||
parser.add_argument("--mabel-safety-factor", type=float, default=1.0)
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
chosen = [m for m in ("scale", "exponent", "fih", "mabel") if getattr(args, m)]
|
||||
if len(chosen) != 1:
|
||||
raise InputError("choose exactly one of --scale, --exponent, --fih, --mabel")
|
||||
report = Report()
|
||||
|
||||
if args.scale:
|
||||
if args.weight_to is None:
|
||||
raise InputError("--scale needs --weight-to")
|
||||
if args.cl is None and args.volume is None:
|
||||
raise InputError("--scale needs --cl and/or --volume")
|
||||
rows = []
|
||||
maturation = maturation_fraction(args.pma_weeks, args.tm50, args.maturation_hill) if args.pma_weeks else 1.0
|
||||
if args.cl is not None:
|
||||
size_only = allometric(args.cl, args.weight_from, args.weight_to, args.cl_exponent)
|
||||
rows.append(
|
||||
{
|
||||
"parameter": "CL",
|
||||
"reference": args.cl,
|
||||
"exponent": args.cl_exponent,
|
||||
"size_scaled": size_only,
|
||||
"maturation_factor": maturation,
|
||||
"final": size_only * maturation,
|
||||
}
|
||||
)
|
||||
if args.volume is not None:
|
||||
size_only = allometric(args.volume, args.weight_from, args.weight_to, args.v_exponent)
|
||||
rows.append(
|
||||
{
|
||||
"parameter": "V",
|
||||
"reference": args.volume,
|
||||
"exponent": args.v_exponent,
|
||||
"size_scaled": size_only,
|
||||
"maturation_factor": 1.0,
|
||||
"final": size_only,
|
||||
}
|
||||
)
|
||||
report.table(f"scaled from {args.weight_from} kg to {args.weight_to} kg", rows)
|
||||
report.note("volume is not matured: maturation describes eliminating capacity, not distribution space")
|
||||
if args.pma_weeks:
|
||||
report.scalar("post_menstrual_age_weeks", args.pma_weeks)
|
||||
report.scalar("maturation_fraction_of_adult_cl", maturation)
|
||||
report.note(
|
||||
f"maturation uses TM50 = {args.tm50} weeks PMA and Hill = {args.maturation_hill} "
|
||||
"(Anderson & Holford). These are generic clearance values; substitute drug-specific "
|
||||
"ontogeny where it is known, because the difference in a neonate is several-fold."
|
||||
)
|
||||
else:
|
||||
if args.weight_to < 20:
|
||||
report.finding(
|
||||
"scaling to a body weight below 20 kg with size alone. Below roughly 2 years of age, "
|
||||
"clearance is limited by enzyme and renal maturation, not by size; supply --pma-weeks "
|
||||
"or the prediction will overestimate clearance, in a neonate by several fold."
|
||||
)
|
||||
report.note("allometry is a covariate model for size, not evidence of a mechanism")
|
||||
|
||||
elif args.exponent:
|
||||
if not args.input:
|
||||
raise InputError("--exponent needs -i with columns species,weight,cl")
|
||||
rows = read_table(args.input)
|
||||
require_columns(rows, ["weight", "cl"], str(args.input))
|
||||
weights = np.asarray([parse_float(r["weight"], "weight") for r in rows], dtype=float)
|
||||
values = np.asarray([parse_float(r["cl"], "cl") for r in rows], dtype=float)
|
||||
if np.any(weights <= 0) or np.any(values <= 0):
|
||||
raise InputError("weights and clearances must be positive")
|
||||
fit = fit_exponent(weights, values)
|
||||
for key, value in fit.items():
|
||||
report.scalar(key, value)
|
||||
report.scalar("rule_of_exponents", rule_of_exponents(fit["exponent"]))
|
||||
report.table(
|
||||
"observed vs fitted",
|
||||
[
|
||||
{
|
||||
"species": r.get("species", f"#{i + 1}"),
|
||||
"weight_kg": w,
|
||||
"cl_observed": v,
|
||||
"cl_fitted": fit["coefficient"] * w ** fit["exponent"],
|
||||
"fold_error": (fit["coefficient"] * w ** fit["exponent"]) / v,
|
||||
}
|
||||
for i, (r, w, v) in enumerate(zip(rows, weights, values))
|
||||
],
|
||||
)
|
||||
if fit["n_species"] < 4:
|
||||
report.finding(
|
||||
f"exponent estimated from {fit['n_species']} species; the confidence interval "
|
||||
f"({fit['ci95_low']:.2f} to {fit['ci95_high']:.2f}) is too wide to distinguish 0.75 from "
|
||||
"most alternatives"
|
||||
)
|
||||
if not (fit["ci95_low"] <= 0.75 <= fit["ci95_high"]):
|
||||
report.note("the 95% interval excludes 0.75; a fixed-exponent model would be misspecified here")
|
||||
report.note(
|
||||
"Cross-species allometry predicts human clearance within 2-fold about half the time. Treat "
|
||||
"it as one input to the starting dose, never as the sole basis."
|
||||
)
|
||||
|
||||
elif args.fih:
|
||||
if not args.noael:
|
||||
raise InputError("--fih needs --noael, e.g. --noael rat=50,dog=10")
|
||||
entries = []
|
||||
for chunk in args.noael.split(","):
|
||||
if "=" not in chunk:
|
||||
raise InputError(f"--noael entries look like species=mg/kg; got {chunk!r}")
|
||||
species, dose_text = chunk.split("=", 1)
|
||||
species = species.strip().lower()
|
||||
dose = parse_float(dose_text, f"NOAEL for {species}")
|
||||
if dose is None or dose <= 0:
|
||||
raise InputError(f"NOAEL for {species} must be positive")
|
||||
hed = hed_mg_per_kg(dose, species)
|
||||
weight, km = KM_FACTORS[species]
|
||||
entries.append(
|
||||
{
|
||||
"species": species,
|
||||
"noael_mg_kg": dose,
|
||||
"reference_weight_kg": weight,
|
||||
"km": km,
|
||||
"divide_by": KM_FACTORS["human-adult"][1] / km,
|
||||
"hed_mg_kg": hed,
|
||||
}
|
||||
)
|
||||
report.table("human equivalent dose by species", entries)
|
||||
most_sensitive = min(entries, key=lambda e: e["hed_mg_kg"])
|
||||
mrsd = most_sensitive["hed_mg_kg"] / args.safety_factor
|
||||
report.scalar("most_sensitive_species", most_sensitive["species"])
|
||||
report.scalar("lowest_hed_mg_kg", most_sensitive["hed_mg_kg"])
|
||||
report.scalar("safety_factor", args.safety_factor)
|
||||
report.scalar("mrsd_mg_kg", mrsd)
|
||||
report.scalar("mrsd_total_mg", mrsd * args.human_weight)
|
||||
report.note(
|
||||
"HED conversion uses body-surface-area scaling from FDA's 2005 maximum-safe-starting-dose "
|
||||
"guidance, Table 1. It applies to small molecules; for biologics whose clearance is not "
|
||||
"surface-area-related, mg/kg or exposure matching is generally more appropriate."
|
||||
)
|
||||
report.note(
|
||||
"The most sensitive species is used unless there is a justified reason to prefer another - "
|
||||
"for example a species known not to be pharmacologically responsive."
|
||||
)
|
||||
if args.safety_factor < 10:
|
||||
report.finding(
|
||||
f"safety factor of {args.safety_factor} is below the default 10; a reduction has to be "
|
||||
"justified, and an increase is expected for steep dose-response, irreversible toxicity, "
|
||||
"novel targets, or nonlinear PK"
|
||||
)
|
||||
report.finding(
|
||||
"MRSD from a NOAEL is not appropriate on its own for agonist immunomodulators or other "
|
||||
"agents with a plausible risk of severe on-target toxicity - compute MABEL as well and take "
|
||||
"the lower value"
|
||||
)
|
||||
|
||||
else: # --mabel
|
||||
if args.ec50 is None:
|
||||
raise InputError("--mabel needs --ec50")
|
||||
if not 0 < args.target_occupancy < 1:
|
||||
raise InputError("--target-occupancy must be between 0 and 1")
|
||||
if args.cl is None:
|
||||
raise InputError("--mabel needs --cl to convert a target concentration into a dose")
|
||||
target_conc = args.ec50 * args.target_occupancy / (1.0 - args.target_occupancy)
|
||||
# Steady-state-equivalent dose to reach the target average concentration.
|
||||
dose_rate = target_conc * args.cl
|
||||
report.scalar("ec50", args.ec50)
|
||||
report.scalar("target_receptor_occupancy", args.target_occupancy)
|
||||
report.scalar("target_concentration", target_conc)
|
||||
report.scalar("clearance", args.cl)
|
||||
report.scalar("dose_rate_for_target_concentration", dose_rate)
|
||||
report.scalar("dose_rate_with_safety_factor", dose_rate / args.mabel_safety_factor)
|
||||
report.note(
|
||||
"MABEL derives the starting dose from the lowest exposure expected to produce any biological "
|
||||
"effect, using in vitro potency in human cells plus target expression - not from the NOAEL. "
|
||||
"It became the expected approach for agonist immunomodulators after TGN1412."
|
||||
)
|
||||
report.note(
|
||||
"The occupancy-to-concentration step assumes simple 1:1 binding at equilibrium and no "
|
||||
"target-mediated disposition. For a drug with appreciable TMDD, occupancy at a given free "
|
||||
"concentration is not a fixed function and this calculation understates the dose needed."
|
||||
)
|
||||
report.finding(
|
||||
"MABEL requires human-cell in vitro potency, target expression in the relevant tissue, and "
|
||||
"a defined concentration-occupancy relationship. Confirm all three are drug-specific before "
|
||||
"using this number."
|
||||
)
|
||||
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
480
skills/pkpd-modeling/scripts/bioequivalence.py
Normal file
480
skills/pkpd-modeling/scripts/bioequivalence.py
Normal file
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bioequivalence assessment: average BE, reference-scaled BE, and sample size.
|
||||
|
||||
Three separate criteria live under the word "bioequivalence" and they are not
|
||||
interchangeable. Average BE puts a 90% confidence interval for the geometric
|
||||
mean ratio inside 80.00-125.00%. EMA's ABEL widens those limits as a function
|
||||
of the reference within-subject variability. FDA's RSABE replaces the interval
|
||||
criterion altogether with a scaled linearised bound. Applying the wrong one, or
|
||||
scaling without a replicate design, is a refuse-to-file class of error.
|
||||
|
||||
python3 bioequivalence.py -i be.csv --design 2x2 --metric auc
|
||||
python3 bioequivalence.py -i be.csv --design replicate --metric cmax --scaling both
|
||||
python3 bioequivalence.py --power --cv 0.30 --gmr 0.95 --target-power 0.80
|
||||
|
||||
Input columns: ``subject``, ``treatment`` (T or R), ``value``, plus
|
||||
``sequence`` and ``period`` for crossover designs. Replicate designs simply
|
||||
have more than one record per subject per treatment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import (
|
||||
InputError,
|
||||
Report,
|
||||
add_format_argument,
|
||||
main_wrapper,
|
||||
parse_float,
|
||||
read_table,
|
||||
require_columns,
|
||||
)
|
||||
|
||||
try:
|
||||
from scipy.stats import chi2, norm
|
||||
from scipy.stats import t as t_dist
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("bioequivalence.py needs scipy: pip install scipy") from exc
|
||||
|
||||
|
||||
# The FDA regulatory constant: ln(1.25) / 0.25, the point at which the scaled
|
||||
# criterion coincides with the conventional limits at sigma_w0 = 0.25.
|
||||
THETA_FDA = math.log(1.25) / 0.25
|
||||
# EMA's ABEL widening coefficient, and the CVwR at which widening is capped.
|
||||
K_ABEL = 0.760
|
||||
CV_ABEL_CAP = 0.50
|
||||
SWR_ABEL_CAP = math.sqrt(math.log(1.0 + CV_ABEL_CAP**2))
|
||||
CV_SCALING_THRESHOLD = 0.30
|
||||
|
||||
|
||||
def cv_from_s2(s2: float) -> float:
|
||||
return math.sqrt(math.exp(s2) - 1.0)
|
||||
|
||||
|
||||
def s2_from_cv(cv: float) -> float:
|
||||
return math.log(1.0 + cv**2)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ average BE
|
||||
|
||||
|
||||
@dataclass
|
||||
class AverageBE:
|
||||
gmr: float
|
||||
ci_low: float
|
||||
ci_high: float
|
||||
df: int
|
||||
se: float
|
||||
cv_within: float | None
|
||||
n_subjects: int
|
||||
method: str
|
||||
|
||||
|
||||
def crossover_2x2(records: list[dict], design: str = "2x2") -> AverageBE:
|
||||
"""Crossover analysis via within-subject period differences.
|
||||
|
||||
Averaging the sequence-specific mean differences cancels the period effect
|
||||
exactly, which is the whole reason a crossover is run. The estimate and its
|
||||
standard error are identical to what a sequence/subject(sequence)/period/
|
||||
treatment ANOVA gives on balanced or unbalanced data, without building the
|
||||
design matrix.
|
||||
|
||||
For replicate designs each subject's repeated administrations are averaged
|
||||
before differencing, which is the same intra-subject contrast the FDA
|
||||
reference-scaled procedure is built on. The within-subject CV reported for a
|
||||
replicate design is therefore a T-and-R mixture; the reference-specific
|
||||
``CVwR`` used for scaling comes from ``reference_variability`` instead.
|
||||
"""
|
||||
by_subject: dict[str, dict[str, list[float]]] = {}
|
||||
sequences: dict[str, str] = {}
|
||||
for row in records:
|
||||
by_subject.setdefault(row["subject"], {}).setdefault(row["treatment"], []).append(row["logvalue"])
|
||||
if row.get("sequence"):
|
||||
sequences[row["subject"]] = row["sequence"]
|
||||
|
||||
differences: dict[str, list[float]] = {}
|
||||
for subject, treatments in by_subject.items():
|
||||
if "T" not in treatments or "R" not in treatments:
|
||||
continue # incomplete subject: dropped, as in the standard analysis
|
||||
diff = float(np.mean(treatments["T"])) - float(np.mean(treatments["R"]))
|
||||
sequence = sequences.get(subject, "?")
|
||||
differences.setdefault(sequence, []).append(diff)
|
||||
|
||||
total = sum(len(v) for v in differences.values())
|
||||
if total < 3:
|
||||
raise InputError("fewer than 3 subjects completed both treatments")
|
||||
|
||||
if len(differences) < 2:
|
||||
# No sequence information: fall back to a paired analysis and say so.
|
||||
values = np.asarray(next(iter(differences.values())))
|
||||
n = len(values)
|
||||
estimate = float(values.mean())
|
||||
se = float(values.std(ddof=1) / math.sqrt(n))
|
||||
df = n - 1
|
||||
s2w = float(values.var(ddof=1)) / 2.0
|
||||
method = "paired (no sequence column: period effects are NOT removed)"
|
||||
else:
|
||||
means = {seq: float(np.mean(v)) for seq, v in differences.items()}
|
||||
estimate = float(np.mean(list(means.values())))
|
||||
pooled_num = sum(float(np.var(v, ddof=1)) * (len(v) - 1) for v in differences.values() if len(v) > 1)
|
||||
pooled_den = sum(len(v) - 1 for v in differences.values())
|
||||
s2d = pooled_num / pooled_den if pooled_den else float("nan")
|
||||
se = math.sqrt(s2d / 4.0 * sum(1.0 / len(v) for v in differences.values()))
|
||||
df = pooled_den
|
||||
s2w = s2d / 2.0
|
||||
method = (
|
||||
"replicate crossover (subject means differenced; period effect removed)"
|
||||
if design == "replicate"
|
||||
else "2x2 crossover (period effect removed)"
|
||||
)
|
||||
|
||||
crit = float(t_dist.ppf(0.95, df))
|
||||
return AverageBE(
|
||||
gmr=math.exp(estimate),
|
||||
ci_low=math.exp(estimate - crit * se),
|
||||
ci_high=math.exp(estimate + crit * se),
|
||||
df=df,
|
||||
se=se,
|
||||
cv_within=cv_from_s2(s2w) if s2w > 0 else None,
|
||||
n_subjects=total,
|
||||
method=method,
|
||||
)
|
||||
|
||||
|
||||
def parallel_design(records: list[dict]) -> AverageBE:
|
||||
test = np.asarray([r["logvalue"] for r in records if r["treatment"] == "T"])
|
||||
ref = np.asarray([r["logvalue"] for r in records if r["treatment"] == "R"])
|
||||
if len(test) < 2 or len(ref) < 2:
|
||||
raise InputError("a parallel design needs at least 2 subjects per arm")
|
||||
n1, n2 = len(test), len(ref)
|
||||
pooled = ((n1 - 1) * test.var(ddof=1) + (n2 - 1) * ref.var(ddof=1)) / (n1 + n2 - 2)
|
||||
se = math.sqrt(pooled * (1.0 / n1 + 1.0 / n2))
|
||||
df = n1 + n2 - 2
|
||||
estimate = float(test.mean() - ref.mean())
|
||||
crit = float(t_dist.ppf(0.95, df))
|
||||
return AverageBE(
|
||||
gmr=math.exp(estimate),
|
||||
ci_low=math.exp(estimate - crit * se),
|
||||
ci_high=math.exp(estimate + crit * se),
|
||||
df=df,
|
||||
se=se,
|
||||
cv_within=cv_from_s2(float(pooled)), # total, not within-subject
|
||||
n_subjects=n1 + n2,
|
||||
method="parallel (pooled variance; the CV shown is total, not within-subject)",
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- scaled BE
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReferenceVariability:
|
||||
s2wr: float
|
||||
df: int
|
||||
n_subjects: int
|
||||
|
||||
@property
|
||||
def swr(self) -> float:
|
||||
return math.sqrt(self.s2wr)
|
||||
|
||||
@property
|
||||
def cvwr(self) -> float:
|
||||
return cv_from_s2(self.s2wr)
|
||||
|
||||
|
||||
def reference_variability(records: list[dict]) -> ReferenceVariability:
|
||||
"""Within-subject variance of the reference, from replicated R administrations."""
|
||||
numerator = 0.0
|
||||
df = 0
|
||||
subjects = 0
|
||||
for subject in {r["subject"] for r in records}:
|
||||
values = [r["logvalue"] for r in records if r["subject"] == subject and r["treatment"] == "R"]
|
||||
if len(values) < 2:
|
||||
continue
|
||||
numerator += float(np.var(values, ddof=1)) * (len(values) - 1)
|
||||
df += len(values) - 1
|
||||
subjects += 1
|
||||
if df == 0:
|
||||
raise InputError(
|
||||
"no subject received the reference more than once. Reference-scaling requires a replicate "
|
||||
"design (partial replicate RRT/RTR/TRR or full replicate RTRT/TRTR) - it cannot be applied "
|
||||
"to a 2x2 study whatever the observed variability."
|
||||
)
|
||||
return ReferenceVariability(s2wr=numerator / df, df=df, n_subjects=subjects)
|
||||
|
||||
|
||||
def abel_limits(rv: ReferenceVariability) -> tuple[float, float, bool]:
|
||||
"""EMA widened acceptance limits. Returns (low, high, widened)."""
|
||||
if rv.cvwr <= CV_SCALING_THRESHOLD:
|
||||
return 0.80, 1.25, False
|
||||
swr = min(rv.swr, SWR_ABEL_CAP)
|
||||
return math.exp(-K_ABEL * swr), math.exp(K_ABEL * swr), True
|
||||
|
||||
|
||||
def rsabe_bound(estimate: float, se: float, df_point: int, rv: ReferenceVariability) -> dict[str, float]:
|
||||
"""FDA reference-scaled criterion via the Hyslop linearised 95% upper bound.
|
||||
|
||||
The criterion is ``(mu_T - mu_R)^2 - theta^2 * s2wR <= 0``. Its upper
|
||||
confidence bound is not the sum of the two separate bounds; Hyslop's method
|
||||
combines them as ``E + H + sqrt((Eh-E)^2 + (Hh-H)^2)``, which is what the
|
||||
FDA progesterone guidance implements.
|
||||
"""
|
||||
e_point = estimate**2
|
||||
e_bound = (abs(estimate) + float(t_dist.ppf(0.95, df_point)) * se) ** 2
|
||||
h_point = -(THETA_FDA**2) * rv.s2wr
|
||||
h_bound = -(THETA_FDA**2) * rv.s2wr * rv.df / float(chi2.ppf(0.05, rv.df))
|
||||
upper = e_point + h_point + math.sqrt((e_bound - e_point) ** 2 + (h_bound - h_point) ** 2)
|
||||
return {
|
||||
"criterion_point_estimate": e_point + h_point,
|
||||
"criterion_95_upper_bound": upper,
|
||||
"passes_scaled_criterion": float(upper <= 0.0),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- power / N
|
||||
|
||||
|
||||
def tost_power(n_total: int, cv: float, gmr: float, design: str = "2x2", limits: tuple[float, float] = (0.80, 1.25)) -> float:
|
||||
"""Exact TOST power by integrating over the sampling distribution of s.
|
||||
|
||||
Treating the standard error as known — the normal approximation that
|
||||
appears in most quick calculations — overstates power at the sample sizes
|
||||
bioequivalence studies actually use. This integrates the conditional power
|
||||
over the chi distribution of the estimated standard deviation, which agrees
|
||||
with Owen's Q to numerical precision.
|
||||
"""
|
||||
sigma = math.sqrt(s2_from_cv(cv))
|
||||
delta = math.log(gmr)
|
||||
theta_low, theta_high = math.log(limits[0]), math.log(limits[1])
|
||||
|
||||
if design == "parallel":
|
||||
df = n_total - 2
|
||||
factor = math.sqrt(2.0 / (n_total / 2.0)) # equal arms
|
||||
elif design == "2x2":
|
||||
df = n_total - 2
|
||||
factor = math.sqrt(2.0 / n_total)
|
||||
else: # 3- or 4-period replicate, treated as a crossover with more df
|
||||
df = 2 * n_total - 3
|
||||
factor = math.sqrt(1.0 / n_total)
|
||||
if df < 1:
|
||||
return 0.0
|
||||
|
||||
crit = float(t_dist.ppf(0.95, df))
|
||||
# s^2 * df / sigma^2 ~ chi2_df
|
||||
grid = np.linspace(1e-6, 1 - 1e-6, 2001)
|
||||
chi_values = chi2.ppf(grid, df)
|
||||
s_values = np.sqrt(chi_values / df) * sigma
|
||||
se_values = s_values * factor
|
||||
upper = (theta_high - crit * se_values - delta) / (sigma * factor)
|
||||
lower = (theta_low + crit * se_values - delta) / (sigma * factor)
|
||||
conditional = np.clip(norm.cdf(upper) - norm.cdf(lower), 0.0, 1.0)
|
||||
return float(np.mean(conditional))
|
||||
|
||||
|
||||
def sample_size(cv: float, gmr: float, target: float, design: str = "2x2", limits: tuple[float, float] = (0.80, 1.25)) -> tuple[int, float]:
|
||||
step = 2 if design != "parallel" else 2
|
||||
for n in range(4, 5002, step):
|
||||
power = tost_power(n, cv, gmr, design, limits)
|
||||
if power >= target:
|
||||
return n, power
|
||||
raise InputError("no sample size below 5000 reaches the target power; the GMR is too far from 1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def load_records(args: argparse.Namespace) -> list[dict]:
|
||||
rows = read_table(args.input)
|
||||
require_columns(rows, [args.subject_column, args.treatment_column, args.value_column], str(args.input))
|
||||
records = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
treatment = row[args.treatment_column].strip().upper()
|
||||
if treatment in {"T", "TEST"}:
|
||||
treatment = "T"
|
||||
elif treatment in {"R", "REF", "REFERENCE"}:
|
||||
treatment = "R"
|
||||
else:
|
||||
raise InputError(f"row {index}: treatment must be T or R, got {row[args.treatment_column]!r}")
|
||||
value = parse_float(row[args.value_column], f"{args.value_column} (row {index})")
|
||||
if value is None or value <= 0:
|
||||
raise InputError(f"row {index}: value must be positive to log-transform, got {value!r}")
|
||||
records.append(
|
||||
{
|
||||
"subject": row[args.subject_column].strip(),
|
||||
"treatment": treatment,
|
||||
"value": value,
|
||||
"logvalue": math.log(value),
|
||||
"sequence": (row.get("sequence") or "").strip().upper(),
|
||||
"period": (row.get("period") or "").strip(),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Average, reference-scaled, and prospective bioequivalence calculations.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("-i", "--input")
|
||||
parser.add_argument("--design", choices=("2x2", "parallel", "replicate"), default="2x2")
|
||||
parser.add_argument("--metric", default="", help="label for the metric being tested, used in output only")
|
||||
parser.add_argument("--subject-column", default="subject")
|
||||
parser.add_argument("--treatment-column", default="treatment")
|
||||
parser.add_argument("--value-column", default="value")
|
||||
parser.add_argument(
|
||||
"--scaling",
|
||||
choices=("none", "abel", "rsabe", "both"),
|
||||
default="none",
|
||||
help="reference-scaled criteria to apply (replicate designs only)",
|
||||
)
|
||||
parser.add_argument("--limits", default="0.80,1.25", help="acceptance limits for average BE (default: 0.80,1.25)")
|
||||
parser.add_argument("--nti", action="store_true", help="narrow therapeutic index: apply 90.00-111.11%% limits")
|
||||
parser.add_argument("--power", action="store_true", help="prospective power / sample size instead of an analysis")
|
||||
parser.add_argument("--cv", type=float, help="assumed within-subject CV (as a fraction) for --power")
|
||||
parser.add_argument("--gmr", type=float, default=0.95, help="assumed true GMR for --power (default: 0.95)")
|
||||
parser.add_argument("--target-power", type=float, default=0.80)
|
||||
parser.add_argument("--n", type=int, help="compute power at this N instead of solving for N")
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
report = Report()
|
||||
|
||||
limits = (0.90, 1.1111) if args.nti else tuple(float(x) for x in args.limits.split(","))
|
||||
if len(limits) != 2 or limits[0] >= limits[1]:
|
||||
raise InputError(f"--limits must be low,high with low < high; got {args.limits!r}")
|
||||
|
||||
if args.power:
|
||||
if args.cv is None:
|
||||
raise InputError("--power needs --cv")
|
||||
if args.n:
|
||||
power = tost_power(args.n, args.cv, args.gmr, args.design, limits)
|
||||
report.scalar("n_total", args.n)
|
||||
report.scalar("power", power)
|
||||
else:
|
||||
n, power = sample_size(args.cv, args.gmr, args.target_power, args.design, limits)
|
||||
report.scalar("n_total_required", n)
|
||||
report.scalar("achieved_power", power)
|
||||
report.scalar("assumed_cv_within", args.cv)
|
||||
report.scalar("assumed_gmr", args.gmr)
|
||||
report.scalar("acceptance_limits", f"{limits[0]:.4f}-{limits[1]:.4f}")
|
||||
report.note(
|
||||
"Power is computed exactly by integrating over the sampling distribution of the estimated "
|
||||
"standard deviation; the normal approximation overstates it at these sample sizes."
|
||||
)
|
||||
report.note(
|
||||
"Sample size is driven far more by the assumed GMR than by CV. Assuming GMR = 1.00 rather "
|
||||
"than 0.95 typically halves the calculated N and is the most common way a BE study ends up "
|
||||
"underpowered."
|
||||
)
|
||||
return report.emit(args.format)
|
||||
|
||||
if not args.input:
|
||||
raise InputError("give -i INPUT for an analysis, or --power for a sample-size calculation")
|
||||
|
||||
records = load_records(args)
|
||||
label = args.metric or args.value_column
|
||||
|
||||
result = parallel_design(records) if args.design == "parallel" else crossover_2x2(records, args.design)
|
||||
passes = limits[0] <= result.ci_low and result.ci_high <= limits[1]
|
||||
|
||||
report.scalar("metric", label)
|
||||
report.scalar("design", args.design)
|
||||
report.scalar("analysis", result.method)
|
||||
report.scalar("n_subjects", result.n_subjects)
|
||||
report.scalar("gmr_pct", 100.0 * result.gmr)
|
||||
report.scalar("ci90_low_pct", 100.0 * result.ci_low)
|
||||
report.scalar("ci90_high_pct", 100.0 * result.ci_high)
|
||||
report.scalar("acceptance_limits_pct", f"{100 * limits[0]:.2f}-{100 * limits[1]:.2f}")
|
||||
report.scalar("average_be_met", passes)
|
||||
report.scalar("degrees_of_freedom", result.df)
|
||||
if result.cv_within is not None:
|
||||
report.scalar(
|
||||
"cv_within_pct" if args.design != "replicate" else "cv_within_pct_T_and_R_mixture",
|
||||
100.0 * result.cv_within,
|
||||
)
|
||||
|
||||
if not passes:
|
||||
report.finding(
|
||||
f"{label}: the 90% CI ({100 * result.ci_low:.2f}-{100 * result.ci_high:.2f}%) is not contained "
|
||||
f"in {100 * limits[0]:.2f}-{100 * limits[1]:.2f}%; average bioequivalence is not demonstrated"
|
||||
)
|
||||
if args.nti:
|
||||
report.note("narrow therapeutic index limits applied (90.00-111.11%)")
|
||||
|
||||
if args.scaling != "none":
|
||||
if args.design != "replicate":
|
||||
raise InputError(
|
||||
"reference-scaling requires --design replicate. High observed variability in a 2x2 study "
|
||||
"does not license widening: without replicated reference administrations there is no "
|
||||
"estimate of within-subject reference variability to scale to."
|
||||
)
|
||||
rv = reference_variability(records)
|
||||
report.scalar("cvwr_pct", 100.0 * rv.cvwr)
|
||||
report.scalar("swr", rv.swr)
|
||||
report.scalar("cvwr_degrees_of_freedom", rv.df)
|
||||
report.scalar("subjects_with_replicated_reference", rv.n_subjects)
|
||||
|
||||
rows = []
|
||||
if args.scaling in {"abel", "both"}:
|
||||
low, high, widened = abel_limits(rv)
|
||||
abel_pass = low <= result.ci_low and result.ci_high <= high and limits[0] <= result.gmr <= limits[1]
|
||||
rows.append(
|
||||
{
|
||||
"criterion": "EMA ABEL",
|
||||
"applicable": "yes" if rv.cvwr > CV_SCALING_THRESHOLD else "no (CVwR <= 30%)",
|
||||
"limits_pct": f"{100 * low:.2f}-{100 * high:.2f}",
|
||||
"widened": widened,
|
||||
"point_estimate_constraint": "80.00-125.00%",
|
||||
"met": abel_pass,
|
||||
}
|
||||
)
|
||||
if widened and rv.cvwr > CV_ABEL_CAP:
|
||||
report.note(
|
||||
f"CVwR is {100 * rv.cvwr:.1f}%, above the 50% cap; ABEL limits are frozen at "
|
||||
"69.84-143.19% rather than widening further."
|
||||
)
|
||||
if not abel_pass:
|
||||
report.finding(f"{label}: EMA ABEL criterion not met")
|
||||
if args.scaling in {"rsabe", "both"}:
|
||||
estimate = math.log(result.gmr)
|
||||
scaled = rsabe_bound(estimate, result.se, result.df, rv)
|
||||
point_ok = limits[0] <= result.gmr <= limits[1]
|
||||
applicable = rv.cvwr >= CV_SCALING_THRESHOLD
|
||||
met = bool(scaled["passes_scaled_criterion"]) and point_ok
|
||||
rows.append(
|
||||
{
|
||||
"criterion": "FDA RSABE",
|
||||
"applicable": "yes" if applicable else "no (CVwR < 30%: use unscaled ABE)",
|
||||
"limits_pct": "linearised scaled bound",
|
||||
"widened": applicable,
|
||||
"point_estimate_constraint": "80.00-125.00%",
|
||||
"met": met if applicable else passes,
|
||||
}
|
||||
)
|
||||
report.scalar("rsabe_criterion_point", scaled["criterion_point_estimate"])
|
||||
report.scalar("rsabe_criterion_95_upper_bound", scaled["criterion_95_upper_bound"])
|
||||
if applicable and not met:
|
||||
report.finding(f"{label}: FDA RSABE criterion not met")
|
||||
report.table("reference-scaled criteria", rows)
|
||||
report.note(
|
||||
"ABEL and RSABE are different criteria and can disagree on the same dataset. Which one "
|
||||
"applies is decided by the regulator the application goes to, and must be pre-specified."
|
||||
)
|
||||
|
||||
report.note("all statistics computed on the natural-log scale; ratios are geometric means")
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
400
skills/pkpd-modeling/scripts/check_popk_dataset.py
Normal file
400
skills/pkpd-modeling/scripts/check_popk_dataset.py
Normal file
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a NONMEM/nlmixr2-ready population PK dataset before it costs you a run.
|
||||
|
||||
Most population analyses lose more time to dataset defects than to modelling.
|
||||
The defects that hurt are the silent ones: NONMEM reads a non-numeric DV as
|
||||
zero rather than refusing it, a missing II turns ADDL into nothing, records
|
||||
that share a timestamp are applied in file order, and a dose with no
|
||||
observations contributes an individual whose ETAs are pure prior. None of these
|
||||
stop a run. They just change the answer.
|
||||
|
||||
python3 check_popk_dataset.py -i nmdata.csv
|
||||
python3 check_popk_dataset.py -i nmdata.csv --covariates WT,AGE,CRCL --time-varying WT
|
||||
python3 check_popk_dataset.py -i nmdata.csv --strict --format json
|
||||
|
||||
Checks are grouped as errors (the run will be wrong), warnings (the run may be
|
||||
wrong), and notes (worth confirming). Exit code is 1 if anything at or above
|
||||
``--fail-on`` was raised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from typing import Sequence
|
||||
|
||||
from _common import (
|
||||
Report,
|
||||
add_format_argument,
|
||||
main_wrapper,
|
||||
read_table,
|
||||
)
|
||||
|
||||
SEVERITY_ORDER = {"note": 0, "warning": 1, "error": 2}
|
||||
|
||||
# NONMEM data item conventions. EVID 3 resets the system, 4 resets and doses.
|
||||
EVID_MEANINGS = {
|
||||
"0": "observation",
|
||||
"1": "dose",
|
||||
"2": "other-type event (no dose, no observation)",
|
||||
"3": "reset",
|
||||
"4": "reset and dose",
|
||||
}
|
||||
|
||||
MISSING = {"", ".", "na", "n/a", "nan", "null", "none"}
|
||||
|
||||
|
||||
def _num(value: str) -> float | None:
|
||||
text = (value or "").strip()
|
||||
if text.lower() in MISSING:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_missing(value: str) -> bool:
|
||||
return (value or "").strip().lower() in MISSING
|
||||
|
||||
|
||||
class Findings:
|
||||
def __init__(self) -> None:
|
||||
self.items: list[dict[str, object]] = []
|
||||
|
||||
def add(self, severity: str, check: str, detail: str, rows: Sequence[int] = ()) -> None:
|
||||
listed = list(rows)[:8]
|
||||
self.items.append(
|
||||
{
|
||||
"severity": severity,
|
||||
"check": check,
|
||||
"detail": detail,
|
||||
"n_records": len(rows),
|
||||
"example_rows": ", ".join(str(r) for r in listed) + ("..." if len(rows) > 8 else ""),
|
||||
}
|
||||
)
|
||||
|
||||
def max_severity(self) -> int:
|
||||
return max((SEVERITY_ORDER[i["severity"]] for i in self.items), default=-1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ checks
|
||||
|
||||
|
||||
def check_dataset(rows: list[dict[str, str]], args: argparse.Namespace) -> tuple[Findings, dict]:
|
||||
found = Findings()
|
||||
columns = list(rows[0].keys())
|
||||
upper = {c.lower(): c for c in columns}
|
||||
|
||||
def col(name: str) -> str | None:
|
||||
return upper.get(name.lower())
|
||||
|
||||
id_col = col(args.id_column)
|
||||
time_col = col(args.time_column)
|
||||
dv_col = col(args.dv_column)
|
||||
amt_col = col(args.amt_column)
|
||||
evid_col, mdv_col = col("evid"), col("mdv")
|
||||
rate_col, ss_col, ii_col, addl_col = col("rate"), col("ss"), col("ii"), col("addl")
|
||||
|
||||
for required, name in ((id_col, args.id_column), (time_col, args.time_column), (dv_col, args.dv_column)):
|
||||
if required is None:
|
||||
found.add("error", "required column", f"no '{name}' column; NONMEM cannot build a dataset without it")
|
||||
if amt_col is None:
|
||||
found.add("error", "required column", "no AMT column: without it no dose is ever administered")
|
||||
if evid_col is None:
|
||||
found.add(
|
||||
"warning",
|
||||
"EVID absent",
|
||||
"no EVID column. NONMEM then infers events from AMT, which works only if every dose "
|
||||
"record has AMT>0 and every observation has AMT=0 or missing. Supplying EVID is safer.",
|
||||
)
|
||||
if not id_col or not time_col:
|
||||
return found, {}
|
||||
|
||||
# ---- column-name hazards
|
||||
long_names = [c for c in columns if len(c) > 20]
|
||||
if long_names:
|
||||
found.add("note", "column names", f"unusually long column name(s): {', '.join(long_names[:4])}")
|
||||
non_alnum = [c for c in columns if not c.replace("_", "").isalnum()]
|
||||
if non_alnum:
|
||||
found.add(
|
||||
"warning",
|
||||
"column names",
|
||||
f"column name(s) with characters NM-TRAN will reject in $INPUT: {', '.join(non_alnum[:6])}",
|
||||
)
|
||||
|
||||
# ---- per-record checks
|
||||
bad_dv_text, neg_time, neg_conc, bad_evid = [], [], [], []
|
||||
dose_without_amt, obs_with_amt, obs_missing_dv, mdv_conflict = [], [], [], []
|
||||
rate_issues, ss_missing_ii, addl_missing_ii, zero_ii = [], [], [], []
|
||||
|
||||
subjects: dict[str, list[tuple[int, dict[str, str]]]] = {}
|
||||
for index, row in enumerate(rows, start=2): # header is line 1
|
||||
subject = (row.get(id_col) or "").strip()
|
||||
subjects.setdefault(subject, []).append((index, row))
|
||||
|
||||
evid = (row.get(evid_col) or "0").strip() if evid_col else None
|
||||
if evid_col and evid not in EVID_MEANINGS and not _is_missing(evid):
|
||||
bad_evid.append(index)
|
||||
|
||||
time = _num(row.get(time_col, ""))
|
||||
if time is None:
|
||||
found.add("error", "TIME not numeric", f"row {index}: TIME is {row.get(time_col)!r}", [index])
|
||||
elif time < 0 and not args.allow_negative_time:
|
||||
neg_time.append(index)
|
||||
|
||||
dv_raw = row.get(dv_col, "") if dv_col else ""
|
||||
amt = _num(row.get(amt_col, "")) if amt_col else None
|
||||
is_dose = (evid in {"1", "4"}) if evid_col else bool(amt and amt > 0)
|
||||
is_obs = (evid == "0") if evid_col else not is_dose
|
||||
|
||||
if is_obs:
|
||||
if not _is_missing(dv_raw) and _num(dv_raw) is None:
|
||||
bad_dv_text.append(index)
|
||||
value = _num(dv_raw)
|
||||
if value is not None and value < 0:
|
||||
neg_conc.append(index)
|
||||
mdv = (row.get(mdv_col) or "0").strip() if mdv_col else "0"
|
||||
if _is_missing(dv_raw) and mdv != "1":
|
||||
obs_missing_dv.append(index)
|
||||
if not _is_missing(dv_raw) and mdv == "1":
|
||||
mdv_conflict.append(index)
|
||||
if amt not in (None, 0.0):
|
||||
obs_with_amt.append(index)
|
||||
if is_dose and (amt is None or amt <= 0):
|
||||
dose_without_amt.append(index)
|
||||
|
||||
if rate_col and not _is_missing(row.get(rate_col, "")):
|
||||
rate = _num(row.get(rate_col, ""))
|
||||
if rate is None or (rate < 0 and rate not in (-1.0, -2.0)):
|
||||
rate_issues.append(index)
|
||||
if ss_col and (row.get(ss_col) or "0").strip() not in {"0", "", "."}:
|
||||
ii = _num(row.get(ii_col, "")) if ii_col else None
|
||||
if not ii:
|
||||
ss_missing_ii.append(index)
|
||||
if addl_col and (_num(row.get(addl_col, "")) or 0) > 0:
|
||||
ii = _num(row.get(ii_col, "")) if ii_col else None
|
||||
if ii is None:
|
||||
addl_missing_ii.append(index)
|
||||
elif ii <= 0:
|
||||
zero_ii.append(index)
|
||||
|
||||
if bad_dv_text:
|
||||
found.add(
|
||||
"error",
|
||||
"non-numeric DV",
|
||||
"DV contains text (for example 'BLQ' or '<LLOQ'). NM-TRAN does not reject these - it reads "
|
||||
"them as 0, so they enter the fit as genuine zero concentrations. Recode to a numeric "
|
||||
"value plus a BLQ flag column and choose an M-method.",
|
||||
bad_dv_text,
|
||||
)
|
||||
if neg_time:
|
||||
found.add("error", "negative TIME", "TIME before zero without --allow-negative-time", neg_time)
|
||||
if neg_conc:
|
||||
found.add("warning", "negative DV", "negative concentrations in observation records", neg_conc)
|
||||
if bad_evid:
|
||||
found.add("error", "invalid EVID", f"EVID outside {sorted(EVID_MEANINGS)}", bad_evid)
|
||||
if dose_without_amt:
|
||||
found.add("error", "dose without AMT", "EVID marks a dose but AMT is missing or not positive", dose_without_amt)
|
||||
if obs_with_amt:
|
||||
found.add(
|
||||
"warning",
|
||||
"observation carries AMT",
|
||||
"records marked as observations also carry a non-zero AMT; if EVID is dropped from $INPUT "
|
||||
"these become doses",
|
||||
obs_with_amt,
|
||||
)
|
||||
if obs_missing_dv:
|
||||
found.add("error", "missing DV with MDV=0", "observation record has no DV but is not flagged MDV=1", obs_missing_dv)
|
||||
if mdv_conflict:
|
||||
found.add("warning", "MDV=1 with a DV present", "the observation will be ignored by the estimation", mdv_conflict)
|
||||
if rate_issues:
|
||||
found.add("error", "invalid RATE", "RATE must be >0, or -1 (modelled duration), or -2 (modelled rate)", rate_issues)
|
||||
if ss_missing_ii:
|
||||
found.add("error", "SS without II", "a steady-state record needs a positive II", ss_missing_ii)
|
||||
if addl_missing_ii:
|
||||
found.add("error", "ADDL without II", "ADDL repeats a dose every II; with no II the extra doses never happen", addl_missing_ii)
|
||||
if zero_ii:
|
||||
found.add("error", "ADDL with II<=0", "II must be positive for ADDL to place additional doses", zero_ii)
|
||||
|
||||
# ---- per-subject checks
|
||||
no_obs, no_dose, unsorted, duplicated, first_not_dose = [], [], [], [], []
|
||||
obs_counts, dose_counts = [], []
|
||||
for subject, records in subjects.items():
|
||||
if subject == "":
|
||||
found.add("error", "blank ID", "records with an empty ID column", [r[0] for r in records])
|
||||
continue
|
||||
times = []
|
||||
n_obs = n_dose = 0
|
||||
for index, row in records:
|
||||
time = _num(row.get(time_col, ""))
|
||||
times.append((time, index))
|
||||
evid = (row.get(evid_col) or "0").strip() if evid_col else None
|
||||
amt = _num(row.get(amt_col, "")) if amt_col else None
|
||||
if (evid in {"1", "4"}) if evid_col else bool(amt and amt > 0):
|
||||
n_dose += 1
|
||||
elif (evid == "0") if evid_col else True:
|
||||
if not _is_missing(row.get(dv_col, "")):
|
||||
n_obs += 1
|
||||
obs_counts.append(n_obs)
|
||||
dose_counts.append(n_dose)
|
||||
if n_obs == 0:
|
||||
no_obs.append(subject)
|
||||
if n_dose == 0:
|
||||
no_dose.append(subject)
|
||||
numeric = [t for t, _ in times if t is not None]
|
||||
if any(b < a for a, b in zip(numeric, numeric[1:])):
|
||||
unsorted.append(subject)
|
||||
counts = Counter(numeric)
|
||||
if any(v > 1 for v in counts.values()):
|
||||
duplicated.append(subject)
|
||||
if records:
|
||||
first_evid = (records[0][1].get(evid_col) or "0").strip() if evid_col else None
|
||||
first_amt = _num(records[0][1].get(amt_col, "")) if amt_col else None
|
||||
is_dose_first = (first_evid in {"1", "4"}) if evid_col else bool(first_amt and first_amt > 0)
|
||||
if not is_dose_first:
|
||||
first_not_dose.append(subject)
|
||||
|
||||
if no_obs:
|
||||
found.add(
|
||||
"error",
|
||||
"subject with no observations",
|
||||
f"{len(no_obs)} subject(s) contribute no DV: {', '.join(no_obs[:6])}. Their ETAs are drawn "
|
||||
"entirely from the prior and they inflate the apparent N of the analysis.",
|
||||
)
|
||||
if no_dose:
|
||||
found.add("error", "subject with no dose", f"{len(no_dose)} subject(s) have observations but no dose: {', '.join(no_dose[:6])}")
|
||||
if unsorted:
|
||||
found.add("error", "TIME not sorted", f"{len(unsorted)} subject(s) have out-of-order TIME: {', '.join(unsorted[:6])}")
|
||||
if duplicated:
|
||||
found.add(
|
||||
"warning",
|
||||
"duplicate TIME within a subject",
|
||||
f"{len(duplicated)} subject(s) have records sharing a timestamp: {', '.join(duplicated[:6])}. "
|
||||
"NONMEM applies them in file order, so a dose and an observation at the same time give a "
|
||||
"pre-dose or post-dose value depending purely on which row came first.",
|
||||
)
|
||||
if first_not_dose:
|
||||
found.add(
|
||||
"note",
|
||||
"first record is not a dose",
|
||||
f"{len(first_not_dose)} subject(s) start with a non-dose record: {', '.join(first_not_dose[:6])}. "
|
||||
"Fine for a pre-dose baseline; wrong if a dose is missing.",
|
||||
)
|
||||
|
||||
# ---- covariates
|
||||
for name in args.covariates:
|
||||
cov_col = col(name)
|
||||
if cov_col is None:
|
||||
found.add("error", "covariate absent", f"--covariates names '{name}' but there is no such column")
|
||||
continue
|
||||
missing_rows, non_numeric = [], []
|
||||
per_subject_values: dict[str, set[str]] = {}
|
||||
for index, row in enumerate(rows, start=2):
|
||||
raw = row.get(cov_col, "")
|
||||
subject = (row.get(id_col) or "").strip()
|
||||
per_subject_values.setdefault(subject, set()).add(raw.strip())
|
||||
if _is_missing(raw):
|
||||
missing_rows.append(index)
|
||||
elif _num(raw) is None:
|
||||
non_numeric.append(index)
|
||||
if missing_rows:
|
||||
found.add(
|
||||
"error",
|
||||
f"covariate {name} missing",
|
||||
f"{len(missing_rows)} record(s) have no value. A blank or '.' is read as 0, not as "
|
||||
f"missing, so those records enter the covariate model with {name} = 0 - a 0 kg "
|
||||
"body weight, a 0 mL/min creatinine clearance - rather than being excluded.",
|
||||
missing_rows,
|
||||
)
|
||||
if non_numeric:
|
||||
found.add("error", f"covariate {name} non-numeric", "values NM-TRAN cannot read as numbers", non_numeric)
|
||||
varying = [s for s, values in per_subject_values.items() if len(values) > 1]
|
||||
if varying and name.lower() not in {t.lower() for t in args.time_varying}:
|
||||
found.add(
|
||||
"warning",
|
||||
f"covariate {name} varies within subject",
|
||||
f"{len(varying)} subject(s) have more than one value: {', '.join(varying[:6])}. "
|
||||
"If this is intended, list it in --time-varying; if not, the model will use whichever "
|
||||
"value is on the record being evaluated.",
|
||||
)
|
||||
|
||||
summary = {
|
||||
"records": len(rows),
|
||||
"subjects": len(subjects),
|
||||
"observations": sum(obs_counts),
|
||||
"dose_records": sum(dose_counts),
|
||||
"median_observations_per_subject": sorted(obs_counts)[len(obs_counts) // 2] if obs_counts else 0,
|
||||
"subjects_with_one_observation": sum(1 for c in obs_counts if c == 1),
|
||||
"columns": len(columns),
|
||||
}
|
||||
if summary["subjects"] and summary["observations"] / max(summary["subjects"], 1) < 2:
|
||||
found.add(
|
||||
"note",
|
||||
"sparse sampling",
|
||||
"fewer than two observations per subject on average. A structural model with an absorption "
|
||||
"phase is unlikely to be identifiable; consider fixing parameters from a richer study.",
|
||||
)
|
||||
return found, summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a population PK dataset against NONMEM/nlmixr2 data conventions.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("-i", "--input", required=True)
|
||||
parser.add_argument("--id-column", default="ID")
|
||||
parser.add_argument("--time-column", default="TIME")
|
||||
parser.add_argument("--dv-column", default="DV")
|
||||
parser.add_argument("--amt-column", default="AMT")
|
||||
parser.add_argument("--covariates", default="", help="comma-separated covariate columns to check")
|
||||
parser.add_argument("--time-varying", default="", help="covariates that are expected to change within a subject")
|
||||
parser.add_argument("--allow-negative-time", action="store_true")
|
||||
parser.add_argument(
|
||||
"--fail-on",
|
||||
choices=("note", "warning", "error"),
|
||||
default="error",
|
||||
help="lowest severity that makes the exit code 1 (default: error)",
|
||||
)
|
||||
parser.add_argument("--strict", action="store_true", help="shorthand for --fail-on warning")
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
args.covariates = [c.strip() for c in args.covariates.split(",") if c.strip()]
|
||||
args.time_varying = [c.strip() for c in args.time_varying.split(",") if c.strip()]
|
||||
if args.strict:
|
||||
args.fail_on = "warning"
|
||||
|
||||
rows = read_table(args.input)
|
||||
found, summary = check_dataset(rows, args)
|
||||
|
||||
report = Report()
|
||||
for key, value in summary.items():
|
||||
report.scalar(key, value)
|
||||
if found.items:
|
||||
ordered = sorted(found.items, key=lambda i: -SEVERITY_ORDER[str(i["severity"])])
|
||||
report.table("dataset findings", ordered)
|
||||
else:
|
||||
report.table("dataset findings", [{"severity": "none", "check": "all checks passed", "detail": "", "n_records": 0, "example_rows": ""}])
|
||||
|
||||
report.note(
|
||||
"This checks data conventions, not pharmacology. A dataset that passes can still be wrong: "
|
||||
"verify units, time origin, and that DV is the quantity the model predicts."
|
||||
)
|
||||
threshold = SEVERITY_ORDER[args.fail_on]
|
||||
for item in found.items:
|
||||
if SEVERITY_ORDER[str(item["severity"])] >= threshold:
|
||||
report.finding(f"[{item['severity']}] {item['check']}: {item['detail']}")
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
346
skills/pkpd-modeling/scripts/ddi_static.py
Normal file
346
skills/pkpd-modeling/scripts/ddi_static.py
Normal file
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drug-drug interaction prediction: ICH M12 basic models and the mechanistic static model.
|
||||
|
||||
ICH M12 (Step 4, 2024) sets out a stepwise risk assessment: in vitro data feed
|
||||
basic models whose cut-offs decide whether a clinical study is needed, and a
|
||||
mechanistic static or PBPK model can be used to refine a positive basic-model
|
||||
signal. The basic models are deliberately conservative — they are designed to
|
||||
over-predict, so a negative result is meaningful and a positive one is only a
|
||||
trigger for further work.
|
||||
|
||||
python3 ddi_static.py --basic --ki 0.5 --imax 2.0 --fu 0.05 --dose 100
|
||||
python3 ddi_static.py --basic --tdi --ki-inact 1.2 --kinact 0.04 --imax 2.0 --fu 0.05
|
||||
python3 ddi_static.py --msm --ki 0.5 --imax 2.0 --fu 0.05 --dose 100 --fm 0.9 --fg 0.7
|
||||
|
||||
All concentrations are in the same molar or mass units as Ki, KI and EC50. The
|
||||
script does not know your units and cannot check them; a Ki in micromolar
|
||||
against an Imax in ng/mL is a silent, and common, error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Sequence
|
||||
|
||||
from _common import InputError, Report, add_format_argument, main_wrapper
|
||||
|
||||
# ICH M12 basic-model cut-offs.
|
||||
CUTOFF_R1_HEPATIC = 1.02
|
||||
CUTOFF_R1_GUT = 11.0
|
||||
CUTOFF_R2_TDI = 1.25
|
||||
CUTOFF_R3_INDUCTION = 0.80
|
||||
CUTOFF_TRANSPORTER_HEPATIC_UPTAKE = 1.1
|
||||
CUTOFF_TRANSPORTER_INTESTINAL = 10.0
|
||||
CUTOFF_TRANSPORTER_RENAL = 0.1
|
||||
|
||||
# Default intestinal dissolution volume used to form the nominal gut
|
||||
# concentration, and the hepatic blood flow used for the inlet concentration.
|
||||
GUT_VOLUME_ML = 250.0
|
||||
HEPATIC_BLOOD_FLOW_L_H = 97.0
|
||||
DEFAULT_KDEG_HEPATIC = 0.0005 # per minute; roughly a 23 h enzyme half-life
|
||||
|
||||
|
||||
def r1_reversible(i_conc: float, ki: float) -> float:
|
||||
"""Basic reversible inhibition ratio, ``1 + [I]/Ki``."""
|
||||
if ki <= 0:
|
||||
raise InputError("Ki must be positive")
|
||||
return 1.0 + i_conc / ki
|
||||
|
||||
|
||||
def r2_time_dependent(i_conc: float, ki_inact: float, kinact: float, kdeg: float) -> float:
|
||||
"""Basic TDI ratio, ``(kobs + kdeg) / kdeg``."""
|
||||
if ki_inact <= 0 or kdeg <= 0:
|
||||
raise InputError("KI and kdeg must be positive")
|
||||
kobs = kinact * i_conc / (ki_inact + i_conc)
|
||||
return (kobs + kdeg) / kdeg
|
||||
|
||||
|
||||
def r3_induction(i_conc: float, emax: float, ec50: float, scaling: float = 1.0) -> float:
|
||||
"""Basic induction ratio; values at or below 0.80 flag a potential inducer."""
|
||||
if ec50 <= 0:
|
||||
raise InputError("EC50 must be positive")
|
||||
return 1.0 / (1.0 + scaling * emax * i_conc / (ec50 + i_conc))
|
||||
|
||||
|
||||
def inlet_concentration(imax: float, fu: float, dose: float, fa: float, fg: float, ka: float, blood_ratio: float = 1.0) -> float:
|
||||
"""Maximum unbound hepatic inlet concentration.
|
||||
|
||||
``Iu,inlet,max = fu * (Imax + Fa*Fg*ka*Dose / (Qh*RB))``. This is the
|
||||
concentration the liver actually sees during absorption, which is higher
|
||||
than systemic Imax and is what M12 asks for in hepatic uptake-transporter
|
||||
assessments.
|
||||
"""
|
||||
portal = fa * fg * ka * dose / (HEPATIC_BLOOD_FLOW_L_H * blood_ratio)
|
||||
return fu * (imax + portal)
|
||||
|
||||
|
||||
def mechanistic_static(
|
||||
ih: float,
|
||||
ig: float,
|
||||
fm: float,
|
||||
fg: float,
|
||||
ki: float | None = None,
|
||||
ki_inact: float | None = None,
|
||||
kinact: float | None = None,
|
||||
kdeg_h: float = DEFAULT_KDEG_HEPATIC,
|
||||
kdeg_g: float = 0.0005,
|
||||
ind_emax: float = 0.0,
|
||||
ind_ec50: float | None = None,
|
||||
ind_scaling: float = 1.0,
|
||||
) -> dict[str, float]:
|
||||
"""Mechanistic static model AUC ratio, combining reversible, TDI and induction terms.
|
||||
|
||||
``AUCR = 1/(Ag*Bg*Cg*(1-Fg) + Fg) * 1/(Ah*Bh*Ch*fm + (1-fm))``
|
||||
|
||||
The two fractions that dominate the answer are ``fm`` (the fraction of
|
||||
systemic clearance through the affected enzyme) and ``Fg`` (the fraction
|
||||
escaping gut metabolism). A perpetrator cannot raise the victim's AUC more
|
||||
than ``1/(1-fm)`` however potent it is, so an fm assumed at 1.0 when it is
|
||||
really 0.7 changes an unbounded prediction into a 3.3-fold ceiling.
|
||||
"""
|
||||
def terms(i_conc: float, kdeg: float) -> tuple[float, float, float]:
|
||||
a = 1.0 / (1.0 + i_conc / ki) if ki else 1.0
|
||||
if ki_inact and kinact:
|
||||
b = kdeg / (kdeg + kinact * i_conc / (ki_inact + i_conc))
|
||||
else:
|
||||
b = 1.0
|
||||
c = 1.0 + ind_scaling * ind_emax * i_conc / (ind_ec50 + i_conc) if ind_ec50 else 1.0
|
||||
return a, b, c
|
||||
|
||||
ah, bh, ch = terms(ih, kdeg_h)
|
||||
ag, bg, cg = terms(ig, kdeg_g)
|
||||
gut = 1.0 / (ag * bg * cg * (1.0 - fg) + fg)
|
||||
hepatic = 1.0 / (ah * bh * ch * fm + (1.0 - fm))
|
||||
return {
|
||||
"Ah_reversible": ah,
|
||||
"Bh_tdi": bh,
|
||||
"Ch_induction": ch,
|
||||
"Ag_reversible": ag,
|
||||
"Bg_tdi": bg,
|
||||
"Cg_induction": cg,
|
||||
"gut_component": gut,
|
||||
"hepatic_component": hepatic,
|
||||
"auc_ratio": gut * hepatic,
|
||||
"maximum_possible_auc_ratio": 1.0 / (1.0 - fm) if fm < 1 else float("inf"),
|
||||
}
|
||||
|
||||
|
||||
def classify(auc_ratio: float) -> str:
|
||||
"""FDA/ICH perpetrator classification bands for an AUC ratio."""
|
||||
if auc_ratio >= 5.0:
|
||||
return "strong inhibitor (AUCR >= 5)"
|
||||
if auc_ratio >= 2.0:
|
||||
return "moderate inhibitor (2 <= AUCR < 5)"
|
||||
if auc_ratio >= 1.25:
|
||||
return "weak inhibitor (1.25 <= AUCR < 2)"
|
||||
if auc_ratio > 0.8:
|
||||
return "no clinically relevant effect predicted (0.8 < AUCR < 1.25)"
|
||||
if auc_ratio > 0.5:
|
||||
return "weak inducer (0.5 < AUCR <= 0.8)"
|
||||
if auc_ratio > 0.2:
|
||||
return "moderate inducer (0.2 < AUCR <= 0.5)"
|
||||
return "strong inducer (AUCR <= 0.2)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ICH M12 basic DDI models and the mechanistic static model.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--basic", action="store_true", help="run the basic models and report against their cut-offs")
|
||||
parser.add_argument("--msm", action="store_true", help="run the mechanistic static model")
|
||||
|
||||
parser.add_argument("--imax", type=float, help="maximum total systemic plasma concentration of the perpetrator")
|
||||
parser.add_argument("--fu", type=float, default=1.0, help="unbound fraction in plasma (default: 1.0)")
|
||||
parser.add_argument("--dose", type=float, help="perpetrator molar dose, for the gut concentration")
|
||||
parser.add_argument("--ka", type=float, default=0.1, help="perpetrator absorption rate constant, per min (default: 0.1)")
|
||||
parser.add_argument("--fa", type=float, default=1.0, help="fraction absorbed (default: 1.0)")
|
||||
parser.add_argument("--fg-perpetrator", type=float, default=1.0, help="perpetrator gut availability (default: 1.0)")
|
||||
parser.add_argument("--blood-ratio", type=float, default=1.0, help="blood-to-plasma ratio (default: 1.0)")
|
||||
|
||||
parser.add_argument("--ki", type=float, help="reversible inhibition constant")
|
||||
parser.add_argument("--tdi", action="store_true", help="evaluate time-dependent inhibition")
|
||||
parser.add_argument("--ki-inact", type=float, help="KI for time-dependent inactivation")
|
||||
parser.add_argument("--kinact", type=float, help="maximum inactivation rate constant, per min")
|
||||
parser.add_argument("--kdeg", type=float, default=DEFAULT_KDEG_HEPATIC, help="hepatic enzyme degradation rate, per min")
|
||||
parser.add_argument("--ind-emax", type=float, default=0.0, help="maximum fold induction minus one")
|
||||
parser.add_argument("--ind-ec50", type=float, help="induction EC50")
|
||||
parser.add_argument("--ind-scaling", type=float, default=1.0, help="induction scaling factor d (default: 1.0)")
|
||||
|
||||
parser.add_argument("--fm", type=float, help="fraction of victim clearance via the affected enzyme")
|
||||
parser.add_argument("--fg", type=float, default=1.0, help="victim fraction escaping gut metabolism (default: 1.0)")
|
||||
parser.add_argument("--transporter", choices=("hepatic-uptake", "intestinal", "renal"), help="also run the transporter basic model")
|
||||
parser.add_argument("--transporter-ki", type=float)
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.basic == args.msm:
|
||||
raise InputError("choose exactly one of --basic or --msm")
|
||||
if args.imax is None:
|
||||
raise InputError("--imax is required")
|
||||
if not 0 < args.fu <= 1:
|
||||
raise InputError("--fu must be in (0, 1]")
|
||||
|
||||
report = Report()
|
||||
unbound = args.imax * args.fu
|
||||
gut = (args.dose / (GUT_VOLUME_ML / 1000.0)) if args.dose else None
|
||||
report.scalar("imax_total", args.imax)
|
||||
report.scalar("imax_unbound", unbound)
|
||||
if gut is not None:
|
||||
report.scalar("gut_concentration", gut)
|
||||
report.note(
|
||||
"Units are not checked. Ki, KI, EC50, Imax and dose must all be expressed on the same molar "
|
||||
"basis; the gut concentration is dose divided by 250 mL."
|
||||
)
|
||||
|
||||
if args.basic:
|
||||
rows = []
|
||||
if args.ki:
|
||||
r1 = r1_reversible(unbound, args.ki)
|
||||
rows.append(
|
||||
{
|
||||
"model": "reversible inhibition, hepatic",
|
||||
"value": r1,
|
||||
"cutoff": f">= {CUTOFF_R1_HEPATIC}",
|
||||
"triggers_study": r1 >= CUTOFF_R1_HEPATIC,
|
||||
"basis": "1 + Imax,u/Ki",
|
||||
}
|
||||
)
|
||||
if gut is not None:
|
||||
r1g = r1_reversible(gut, args.ki)
|
||||
rows.append(
|
||||
{
|
||||
"model": "reversible inhibition, intestinal (CYP3A4)",
|
||||
"value": r1g,
|
||||
"cutoff": f">= {CUTOFF_R1_GUT}",
|
||||
"triggers_study": r1g >= CUTOFF_R1_GUT,
|
||||
"basis": "1 + Igut/Ki, Igut = dose/250 mL",
|
||||
}
|
||||
)
|
||||
if args.tdi:
|
||||
if not (args.ki_inact and args.kinact):
|
||||
raise InputError("--tdi needs --ki-inact and --kinact")
|
||||
r2 = r2_time_dependent(unbound * 50.0, args.ki_inact, args.kinact, args.kdeg)
|
||||
rows.append(
|
||||
{
|
||||
"model": "time-dependent inhibition, hepatic",
|
||||
"value": r2,
|
||||
"cutoff": f">= {CUTOFF_R2_TDI}",
|
||||
"triggers_study": r2 >= CUTOFF_R2_TDI,
|
||||
"basis": "(kobs + kdeg)/kdeg at 50 x Imax,u",
|
||||
}
|
||||
)
|
||||
if args.ind_ec50 and args.ind_emax:
|
||||
r3 = r3_induction(unbound * 10.0, args.ind_emax, args.ind_ec50, args.ind_scaling)
|
||||
rows.append(
|
||||
{
|
||||
"model": "induction",
|
||||
"value": r3,
|
||||
"cutoff": f"<= {CUTOFF_R3_INDUCTION}",
|
||||
"triggers_study": r3 <= CUTOFF_R3_INDUCTION,
|
||||
"basis": "1/(1 + d*Emax*I/(EC50+I)) at 10 x Imax,u",
|
||||
}
|
||||
)
|
||||
if args.transporter:
|
||||
if not args.transporter_ki:
|
||||
raise InputError("--transporter needs --transporter-ki")
|
||||
if args.transporter == "hepatic-uptake":
|
||||
if args.dose is None:
|
||||
raise InputError("hepatic uptake needs --dose for the inlet concentration")
|
||||
conc = inlet_concentration(args.imax, args.fu, args.dose, args.fa, args.fg_perpetrator, args.ka, args.blood_ratio)
|
||||
value = 1.0 + conc / args.transporter_ki
|
||||
cutoff, triggers = CUTOFF_TRANSPORTER_HEPATIC_UPTAKE, value >= CUTOFF_TRANSPORTER_HEPATIC_UPTAKE
|
||||
basis = "1 + Iu,inlet,max/Ki,u (OATP1B1/1B3)"
|
||||
elif args.transporter == "intestinal":
|
||||
if gut is None:
|
||||
raise InputError("intestinal transporter assessment needs --dose")
|
||||
value = gut / args.transporter_ki
|
||||
cutoff, triggers = CUTOFF_TRANSPORTER_INTESTINAL, value >= CUTOFF_TRANSPORTER_INTESTINAL
|
||||
basis = "Igut/IC50 (P-gp, BCRP)"
|
||||
else:
|
||||
value = unbound / args.transporter_ki
|
||||
cutoff, triggers = CUTOFF_TRANSPORTER_RENAL, value >= CUTOFF_TRANSPORTER_RENAL
|
||||
basis = "Imax,u/Ki (OAT, OCT, MATE)"
|
||||
rows.append(
|
||||
{
|
||||
"model": f"transporter inhibition, {args.transporter}",
|
||||
"value": value,
|
||||
"cutoff": f">= {cutoff}",
|
||||
"triggers_study": triggers,
|
||||
"basis": basis,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise InputError("--basic needs at least one of --ki, --tdi, or --ind-ec50 with --ind-emax")
|
||||
report.table("ICH M12 basic models", rows)
|
||||
for row in rows:
|
||||
if row["triggers_study"]:
|
||||
report.finding(
|
||||
f"{row['model']}: {row['value']:.4g} meets the cut-off {row['cutoff']} - a clinical "
|
||||
"DDI study or a refined mechanistic/PBPK assessment is indicated"
|
||||
)
|
||||
report.note(
|
||||
"Basic models are intentionally conservative. A result below the cut-off supports not "
|
||||
"studying the interaction; a result above it is a trigger for further evaluation, not a "
|
||||
"prediction of clinical magnitude."
|
||||
)
|
||||
|
||||
else:
|
||||
if args.fm is None:
|
||||
raise InputError("--msm needs --fm")
|
||||
if not 0 < args.fm <= 1:
|
||||
raise InputError("--fm must be in (0, 1]")
|
||||
if not 0 < args.fg <= 1:
|
||||
raise InputError("--fg must be in (0, 1]")
|
||||
result = mechanistic_static(
|
||||
ih=unbound,
|
||||
ig=gut if gut is not None else 0.0,
|
||||
fm=args.fm,
|
||||
fg=args.fg,
|
||||
ki=args.ki,
|
||||
ki_inact=args.ki_inact if args.tdi else None,
|
||||
kinact=args.kinact if args.tdi else None,
|
||||
kdeg_h=args.kdeg,
|
||||
ind_emax=args.ind_emax,
|
||||
ind_ec50=args.ind_ec50,
|
||||
ind_scaling=args.ind_scaling,
|
||||
)
|
||||
report.table("mechanistic static model", [{"term": k, "value": v} for k, v in result.items()])
|
||||
report.scalar("predicted_auc_ratio", result["auc_ratio"])
|
||||
report.scalar("classification", classify(result["auc_ratio"]))
|
||||
report.note(
|
||||
f"With fm = {args.fm}, no inhibitor of this pathway can raise the victim AUC above "
|
||||
f"{result['maximum_possible_auc_ratio']:.2f}-fold. If the prediction approaches that "
|
||||
"ceiling, fm is doing more work than the inhibition constants."
|
||||
)
|
||||
if gut is None:
|
||||
report.note("no --dose given, so the intestinal component was evaluated at zero inhibitor concentration")
|
||||
if args.fm > 0.95:
|
||||
report.finding(
|
||||
f"fm = {args.fm} implies almost all clearance goes through one enzyme. This is rarely "
|
||||
"established and it drives the prediction; state the evidence for it or run a sensitivity "
|
||||
"analysis across a plausible range."
|
||||
)
|
||||
if result["auc_ratio"] >= 2.0:
|
||||
report.finding(
|
||||
f"predicted AUC ratio {result['auc_ratio']:.2f} - {classify(result['auc_ratio'])}; "
|
||||
"a clinical evaluation and labelling implications follow"
|
||||
)
|
||||
report.note(
|
||||
"The mechanistic static model assumes a single constant perpetrator concentration and no "
|
||||
"time course. It is a screening refinement; where the interaction is decision-relevant, "
|
||||
"ICH M12 points to PBPK with a verified perpetrator model."
|
||||
)
|
||||
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
328
skills/pkpd-modeling/scripts/exposure_response.py
Normal file
328
skills/pkpd-modeling/scripts/exposure_response.py
Normal file
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exposure-response analysis: Emax, logistic, concentration-QTc, and exposure quartiles.
|
||||
|
||||
Exposure-response is where dose selection is actually decided, and where the
|
||||
most consequential statistical mistakes are made. Two dominate. The first is
|
||||
fitting Emax to data that never approached the plateau, which produces an Emax
|
||||
and EC50 that are individually meaningless but jointly reproduce the observed
|
||||
slope. The second is reading an exposure-response relationship causally when
|
||||
exposure is itself a consequence of the patient's condition — sicker patients
|
||||
clear drug differently, so a flat or inverted E-R curve can be confounding
|
||||
rather than pharmacology.
|
||||
|
||||
python3 exposure_response.py --emax -i er.csv
|
||||
python3 exposure_response.py --logistic -i er.csv --response-column responder
|
||||
python3 exposure_response.py --cqtc -i qt.csv --cmax 250
|
||||
python3 exposure_response.py --quartiles -i er.csv --response-column response
|
||||
|
||||
Default columns are ``exposure`` and ``response``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import (
|
||||
InputError,
|
||||
Report,
|
||||
add_format_argument,
|
||||
main_wrapper,
|
||||
parse_float,
|
||||
read_table,
|
||||
require_columns,
|
||||
)
|
||||
|
||||
try:
|
||||
from scipy.optimize import least_squares, minimize
|
||||
from scipy.stats import norm
|
||||
from scipy.stats import t as t_dist
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("exposure_response.py needs scipy: pip install scipy") from exc
|
||||
|
||||
# ICH E14's threshold of regulatory concern: an upper bound of the two-sided
|
||||
# 90% confidence interval for placebo-corrected change-from-baseline QTc above
|
||||
# 10 ms.
|
||||
QTC_THRESHOLD_MS = 10.0
|
||||
|
||||
|
||||
def fit_emax(exposure: np.ndarray, response: np.ndarray, sigmoid: bool) -> dict:
|
||||
"""Fit E0 + Emax*C^h/(EC50^h + C^h) by least squares on the log of positive parameters."""
|
||||
e0_init = float(np.min(response))
|
||||
emax_init = float(np.max(response) - np.min(response)) or 1.0
|
||||
ec50_init = float(np.median(exposure[exposure > 0])) if np.any(exposure > 0) else 1.0
|
||||
|
||||
def unpack(theta: np.ndarray) -> tuple[float, float, float, float]:
|
||||
e0 = theta[0]
|
||||
emax_value = theta[1]
|
||||
ec50 = math.exp(theta[2])
|
||||
hill = math.exp(theta[3]) if sigmoid else 1.0
|
||||
return e0, emax_value, ec50, hill
|
||||
|
||||
def predict(theta: np.ndarray, x: np.ndarray) -> np.ndarray:
|
||||
e0, emax_value, ec50, hill = unpack(theta)
|
||||
powered = np.power(np.maximum(x, 0.0), hill)
|
||||
return e0 + emax_value * powered / (ec50**hill + powered)
|
||||
|
||||
start = [e0_init, emax_init, math.log(max(ec50_init, 1e-9))] + ([0.0] if sigmoid else [])
|
||||
result = least_squares(lambda th: predict(th, exposure) - response, start, max_nfev=20000)
|
||||
e0, emax_value, ec50, hill = unpack(result.x)
|
||||
|
||||
n, p = len(exposure), len(result.x)
|
||||
residuals = response - predict(result.x, exposure)
|
||||
ssr = float(np.sum(residuals**2))
|
||||
sigma2 = ssr / max(n - p, 1)
|
||||
try:
|
||||
_, s, vt = np.linalg.svd(result.jac, full_matrices=False)
|
||||
threshold = np.finfo(float).eps * max(result.jac.shape) * s[0]
|
||||
s_inv = np.array([1.0 / x if x > threshold else 0.0 for x in s])
|
||||
cov = (vt.T * s_inv**2) @ vt * sigma2
|
||||
se = np.sqrt(np.maximum(np.diag(cov), 0.0))
|
||||
except np.linalg.LinAlgError: # pragma: no cover
|
||||
se = np.full(p, np.nan)
|
||||
|
||||
max_observed = float(np.max(exposure))
|
||||
return {
|
||||
"e0": e0,
|
||||
"emax": emax_value,
|
||||
"ec50": ec50,
|
||||
"hill": hill,
|
||||
"se_e0": float(se[0]),
|
||||
"se_emax": float(se[1]),
|
||||
"rse_ec50_pct": 100.0 * float(se[2]),
|
||||
"rse_hill_pct": 100.0 * float(se[3]) if sigmoid else float("nan"),
|
||||
"residual_sd": math.sqrt(sigma2),
|
||||
"n": n,
|
||||
"max_observed_exposure": max_observed,
|
||||
"ec50_over_max_exposure": ec50 / max_observed if max_observed else float("nan"),
|
||||
"predicted_effect_at_max_exposure": float(predict(result.x, np.array([max_observed]))[0]),
|
||||
"fraction_of_emax_reached": float(
|
||||
(predict(result.x, np.array([max_observed]))[0] - e0) / emax_value
|
||||
)
|
||||
if emax_value
|
||||
else float("nan"),
|
||||
}
|
||||
|
||||
|
||||
def fit_logistic(exposure: np.ndarray, response: np.ndarray) -> dict:
|
||||
"""Maximum-likelihood logistic regression of a binary response on exposure."""
|
||||
unique = set(np.unique(response).tolist())
|
||||
if not unique <= {0.0, 1.0}:
|
||||
raise InputError("logistic exposure-response needs a 0/1 response column")
|
||||
|
||||
def neg_loglik(theta: np.ndarray) -> float:
|
||||
eta = theta[0] + theta[1] * exposure
|
||||
# log(1+exp(eta)) computed stably
|
||||
return float(np.sum(np.logaddexp(0.0, eta) - response * eta))
|
||||
|
||||
result = minimize(neg_loglik, np.array([0.0, 0.0]), method="BFGS")
|
||||
intercept, slope = result.x
|
||||
try:
|
||||
cov = result.hess_inv
|
||||
se = np.sqrt(np.maximum(np.diag(np.asarray(cov)), 0.0))
|
||||
except (ValueError, TypeError): # pragma: no cover
|
||||
se = np.array([float("nan"), float("nan")])
|
||||
|
||||
ec50 = -intercept / slope if slope else float("nan")
|
||||
return {
|
||||
"intercept": float(intercept),
|
||||
"slope": float(slope),
|
||||
"se_slope": float(se[1]),
|
||||
"slope_z": float(slope / se[1]) if se[1] else float("nan"),
|
||||
"slope_p": float(2 * (1 - norm.cdf(abs(slope / se[1])))) if se[1] else float("nan"),
|
||||
"odds_ratio_per_unit": float(math.exp(slope)),
|
||||
"exposure_at_50pct_probability": float(ec50),
|
||||
"n": len(exposure),
|
||||
"n_responders": int(response.sum()),
|
||||
"neg_loglik": float(result.fun),
|
||||
}
|
||||
|
||||
|
||||
def fit_cqtc(conc: np.ndarray, delta_qtc: np.ndarray, cmax: float | None) -> dict:
|
||||
"""Linear concentration-QTc model with the two-sided 90% CI of the predicted effect.
|
||||
|
||||
ICH E14 asks whether the **upper bound of the two-sided 90% confidence
|
||||
interval** for placebo-corrected change-from-baseline QTc exceeds 10 ms at
|
||||
the clinically relevant exposure. Reporting the point estimate, or a 95%
|
||||
interval, answers a different question than the guideline asks.
|
||||
"""
|
||||
n = len(conc)
|
||||
if n < 4:
|
||||
raise InputError("concentration-QTc analysis needs at least 4 observations")
|
||||
design = np.column_stack([np.ones(n), conc])
|
||||
coefficients, *_ = np.linalg.lstsq(design, delta_qtc, rcond=None)
|
||||
fitted = design @ coefficients
|
||||
residuals = delta_qtc - fitted
|
||||
dof = n - 2
|
||||
sigma2 = float(residuals @ residuals) / dof
|
||||
xtx_inv = np.linalg.inv(design.T @ design)
|
||||
se = np.sqrt(np.diag(xtx_inv) * sigma2)
|
||||
crit = float(t_dist.ppf(0.95, dof)) # two-sided 90%
|
||||
|
||||
out = {
|
||||
"intercept_ms": float(coefficients[0]),
|
||||
"slope_ms_per_conc": float(coefficients[1]),
|
||||
"se_slope": float(se[1]),
|
||||
"slope_ci90_low": float(coefficients[1] - crit * se[1]),
|
||||
"slope_ci90_high": float(coefficients[1] + crit * se[1]),
|
||||
"residual_sd_ms": math.sqrt(sigma2),
|
||||
"n": n,
|
||||
"degrees_of_freedom": dof,
|
||||
}
|
||||
if cmax is not None:
|
||||
x0 = np.array([1.0, cmax])
|
||||
prediction = float(x0 @ coefficients)
|
||||
se_mean = math.sqrt(float(x0 @ xtx_inv @ x0) * sigma2)
|
||||
out.update(
|
||||
{
|
||||
"exposure_evaluated": cmax,
|
||||
"predicted_delta_delta_qtc_ms": prediction,
|
||||
"ci90_low_ms": prediction - crit * se_mean,
|
||||
"ci90_high_ms": prediction + crit * se_mean,
|
||||
"upper_bound_exceeds_10ms": float((prediction + crit * se_mean) > QTC_THRESHOLD_MS),
|
||||
}
|
||||
)
|
||||
if cmax > float(np.max(conc)):
|
||||
out["extrapolated_beyond_observed"] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def quartile_summary(exposure: np.ndarray, response: np.ndarray, n_bins: int) -> list[dict]:
|
||||
edges = np.quantile(exposure, np.linspace(0, 1, n_bins + 1))
|
||||
edges[-1] = np.nextafter(edges[-1], np.inf)
|
||||
rows = []
|
||||
for i in range(n_bins):
|
||||
mask = (exposure >= edges[i]) & (exposure < edges[i + 1])
|
||||
if not np.any(mask):
|
||||
continue
|
||||
values = response[mask]
|
||||
rows.append(
|
||||
{
|
||||
"bin": i + 1,
|
||||
"exposure_range": f"{edges[i]:.4g} - {edges[i + 1]:.4g}",
|
||||
"n": int(mask.sum()),
|
||||
"median_exposure": float(np.median(exposure[mask])),
|
||||
"mean_response": float(values.mean()),
|
||||
"sd_response": float(values.std(ddof=1)) if len(values) > 1 else float("nan"),
|
||||
"responder_fraction": float(values.mean()) if set(np.unique(values).tolist()) <= {0.0, 1.0} else float("nan"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Exposure-response modelling: Emax, logistic, concentration-QTc, quartiles.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--emax", action="store_true")
|
||||
parser.add_argument("--sigmoid", action="store_true", help="estimate the Hill coefficient too")
|
||||
parser.add_argument("--logistic", action="store_true")
|
||||
parser.add_argument("--cqtc", action="store_true")
|
||||
parser.add_argument("--quartiles", action="store_true")
|
||||
parser.add_argument("-i", "--input", required=True)
|
||||
parser.add_argument("--exposure-column", default="exposure")
|
||||
parser.add_argument("--response-column", default="response")
|
||||
parser.add_argument("--cmax", type=float, help="exposure at which to evaluate the C-QTc prediction")
|
||||
parser.add_argument("--bins", type=int, default=4)
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
modes = [m for m in ("emax", "logistic", "cqtc", "quartiles") if getattr(args, m)]
|
||||
if len(modes) != 1:
|
||||
raise InputError("choose exactly one of --emax, --logistic, --cqtc, --quartiles")
|
||||
|
||||
rows = read_table(args.input)
|
||||
require_columns(rows, [args.exposure_column, args.response_column], str(args.input))
|
||||
exposure = np.asarray([parse_float(r[args.exposure_column], "exposure") for r in rows], dtype=float)
|
||||
response = np.asarray([parse_float(r[args.response_column], "response") for r in rows], dtype=float)
|
||||
if np.any(exposure < 0):
|
||||
raise InputError("exposure must not be negative")
|
||||
|
||||
report = Report()
|
||||
report.note(
|
||||
"Exposure-response is observational even inside a randomised trial: patients are randomised to "
|
||||
"dose, not to exposure. Differences across exposure quantiles can reflect the covariates that "
|
||||
"drive clearance rather than the drug."
|
||||
)
|
||||
|
||||
if args.emax:
|
||||
fit = fit_emax(exposure, response, args.sigmoid)
|
||||
report.table("Emax model", [{"parameter": k, "value": v} for k, v in fit.items()])
|
||||
if fit["fraction_of_emax_reached"] < 0.5:
|
||||
report.finding(
|
||||
f"the highest observed exposure reaches only {100 * fit['fraction_of_emax_reached']:.0f}% "
|
||||
"of the estimated Emax. Emax and EC50 are then extrapolations, strongly correlated with "
|
||||
"each other, and should not be quoted as independent estimates."
|
||||
)
|
||||
if fit["ec50_over_max_exposure"] > 1.0:
|
||||
report.finding(
|
||||
f"EC50 ({fit['ec50']:.4g}) exceeds the highest observed exposure "
|
||||
f"({fit['max_observed_exposure']:.4g}); the plateau is entirely outside the data"
|
||||
)
|
||||
if math.isfinite(fit["rse_ec50_pct"]) and fit["rse_ec50_pct"] > 50:
|
||||
report.finding(f"EC50 has {fit['rse_ec50_pct']:.0f}% relative standard error")
|
||||
report.note("a linear-looking E-R relationship is the low-concentration limb of an Emax curve; the two are not distinguishable without data near the plateau")
|
||||
|
||||
elif args.logistic:
|
||||
fit = fit_logistic(exposure, response)
|
||||
report.table("logistic exposure-response", [{"parameter": k, "value": v} for k, v in fit.items()])
|
||||
if fit["n_responders"] < 10 or fit["n"] - fit["n_responders"] < 10:
|
||||
report.finding(
|
||||
f"{fit['n_responders']} responders out of {fit['n']}; with fewer than about 10 events "
|
||||
"per covariate the slope estimate is unstable and its confidence interval unreliable"
|
||||
)
|
||||
if math.isfinite(fit["slope_p"]) and fit["slope_p"] > 0.05:
|
||||
report.note(f"the exposure slope is not statistically distinguishable from zero (p = {fit['slope_p']:.3f})")
|
||||
|
||||
elif args.cqtc:
|
||||
fit = fit_cqtc(exposure, response, args.cmax)
|
||||
report.table("concentration-QTc", [{"parameter": k, "value": v} for k, v in fit.items()])
|
||||
report.note(
|
||||
"ICH E14 evaluates the upper bound of the two-sided 90% confidence interval for "
|
||||
"placebo-corrected change-from-baseline QTc against a 10 ms threshold. The response column "
|
||||
"must already be that placebo-corrected change; this script does not compute it."
|
||||
)
|
||||
if fit.get("upper_bound_exceeds_10ms"):
|
||||
report.finding(
|
||||
f"the 90% upper bound at the evaluated exposure is {fit['ci90_high_ms']:.2f} ms, above "
|
||||
"the 10 ms threshold of regulatory concern"
|
||||
)
|
||||
if fit.get("extrapolated_beyond_observed"):
|
||||
report.finding(
|
||||
f"the evaluated exposure ({fit['exposure_evaluated']:.4g}) is above the highest observed "
|
||||
"concentration; the prediction is an extrapolation and the interval understates its uncertainty"
|
||||
)
|
||||
report.note(
|
||||
"This is an ordinary linear model. A regulatory C-QTc analysis uses a mixed model with a "
|
||||
"random intercept and slope per subject and a treatment-specific intercept; use this for "
|
||||
"screening and exploration, not for submission."
|
||||
)
|
||||
|
||||
else:
|
||||
rows_out = quartile_summary(exposure, response, args.bins)
|
||||
report.table(f"exposure {args.bins}-quantile summary", rows_out)
|
||||
if len(rows_out) >= 2:
|
||||
first, last = rows_out[0]["mean_response"], rows_out[-1]["mean_response"]
|
||||
report.scalar("response_change_across_range", last - first)
|
||||
report.note(
|
||||
"Quantile summaries are descriptive. They do not adjust for the covariates that determine "
|
||||
"exposure, and a monotone trend across quantiles is not by itself evidence of a causal "
|
||||
"dose-response relationship."
|
||||
)
|
||||
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
558
skills/pkpd-modeling/scripts/fit_compartmental.py
Normal file
558
skills/pkpd-modeling/scripts/fit_compartmental.py
Normal file
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fit compartmental PK models to concentration-time data, with identifiability diagnostics.
|
||||
|
||||
The fit itself is the easy part. What decides whether the result means anything
|
||||
is: was the residual error model right, is the extra compartment actually
|
||||
supported, and are the parameters identifiable from these data at all. This
|
||||
script reports all three, and flags the cases where a converged fit is still
|
||||
uninterpretable.
|
||||
|
||||
python3 fit_compartmental.py -i profile.csv --dose 100 --route iv-bolus --model 2cmt
|
||||
python3 fit_compartmental.py -i profile.csv --dose 100 --route oral --compare 1cmt,2cmt
|
||||
python3 fit_compartmental.py -i profile.csv --dose 500 --route iv-infusion --tinf 1 \
|
||||
--model 2cmt --weight 1/y2
|
||||
|
||||
Input columns default to ``time`` and ``conc``; an ``id`` column fits each
|
||||
subject separately. Parameters are estimated on the log scale, so they cannot
|
||||
go negative and their confidence intervals come out asymmetric, which is the
|
||||
honest shape for a clearance or a volume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import (
|
||||
EXIT_INPUT,
|
||||
InputError,
|
||||
Report,
|
||||
add_format_argument,
|
||||
group_by,
|
||||
main_wrapper,
|
||||
parse_float,
|
||||
read_table,
|
||||
require_columns,
|
||||
)
|
||||
from _models import Dose, build_regimen, disposition, simulate_linear
|
||||
|
||||
try:
|
||||
from scipy.optimize import least_squares
|
||||
from scipy.stats import f as f_dist
|
||||
from scipy.stats import t as t_dist
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("fit_compartmental.py needs scipy: pip install scipy") from exc
|
||||
|
||||
|
||||
MODELS = {
|
||||
"1cmt": ["CL", "V1"],
|
||||
"2cmt": ["CL", "V1", "Q2", "V2"],
|
||||
"3cmt": ["CL", "V1", "Q2", "V2", "Q3", "V3"],
|
||||
}
|
||||
|
||||
WEIGHTS = {
|
||||
"uniform": "constant variance (additive error)",
|
||||
"1/y": "variance proportional to the observation",
|
||||
"1/y2": "constant CV (proportional error) - the usual PK default",
|
||||
"1/yhat": "variance proportional to the prediction",
|
||||
"1/yhat2": "constant CV against the prediction",
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- model
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitSpec:
|
||||
model: str
|
||||
route: str
|
||||
dose: float
|
||||
tinf: float | None
|
||||
interval: float | None
|
||||
n_doses: int
|
||||
absorption: str
|
||||
estimate_lag: bool
|
||||
|
||||
@property
|
||||
def parameter_names(self) -> list[str]:
|
||||
names = list(MODELS[self.model])
|
||||
if self.route == "oral":
|
||||
names = [f"{n}/F" for n in names]
|
||||
names.append("MTT" if self.absorption == "transit" else "ka")
|
||||
if self.absorption == "transit":
|
||||
names.append("Ntr")
|
||||
if self.estimate_lag:
|
||||
names.append("tlag")
|
||||
return names
|
||||
|
||||
|
||||
def _regimen(spec: FitSpec) -> list[Dose]:
|
||||
return build_regimen(
|
||||
spec.dose,
|
||||
interval=spec.interval,
|
||||
n_doses=spec.n_doses,
|
||||
duration=spec.tinf or 0.0,
|
||||
route="oral" if spec.route == "oral" else "iv",
|
||||
)
|
||||
|
||||
|
||||
def predictor(spec: FitSpec) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
|
||||
"""Return f(times, theta) -> predicted concentration."""
|
||||
regimen = _regimen(spec)
|
||||
n_disp = len(MODELS[spec.model])
|
||||
|
||||
def predict(times: np.ndarray, theta: np.ndarray) -> np.ndarray:
|
||||
cl, v1 = theta[0], theta[1]
|
||||
q = tuple(theta[2:n_disp:2])
|
||||
vp = tuple(theta[3:n_disp:2])
|
||||
disp = disposition(cl, v1, q, vp)
|
||||
if spec.route != "oral":
|
||||
return simulate_linear(times, regimen, disp)
|
||||
extra = theta[n_disp:]
|
||||
if spec.absorption == "transit":
|
||||
mtt, ntr = extra[0], extra[1]
|
||||
tlag = extra[2] if spec.estimate_lag else 0.0
|
||||
from _models import conc_transit # local: only this branch needs it
|
||||
|
||||
total = np.zeros_like(times, dtype=float)
|
||||
for dose in regimen:
|
||||
total += conc_transit(np.maximum(times - dose.time - tlag, 0.0), dose.amount, mtt, ntr, disp)
|
||||
return total
|
||||
ka = extra[0]
|
||||
tlag = extra[1] if spec.estimate_lag else 0.0
|
||||
return simulate_linear(times, regimen, disp, ka=ka, f=1.0, tlag=tlag)
|
||||
|
||||
return predict
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fitting
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitResult:
|
||||
theta: np.ndarray
|
||||
names: list[str]
|
||||
se_log: np.ndarray
|
||||
wssr: float
|
||||
n: int
|
||||
p: int
|
||||
correlation: np.ndarray
|
||||
condition: float
|
||||
predicted: np.ndarray
|
||||
residuals: np.ndarray
|
||||
weighted_residuals: np.ndarray
|
||||
sigma: float
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
@property
|
||||
def aic(self) -> float:
|
||||
return self.n * math.log(self.wssr / self.n) + 2 * self.p
|
||||
|
||||
@property
|
||||
def bic(self) -> float:
|
||||
return self.n * math.log(self.wssr / self.n) + self.p * math.log(self.n)
|
||||
|
||||
|
||||
def _weights(y: np.ndarray, yhat: np.ndarray, scheme: str) -> np.ndarray:
|
||||
floor = 1e-12
|
||||
if scheme == "uniform":
|
||||
return np.ones_like(y)
|
||||
if scheme == "1/y":
|
||||
return 1.0 / np.maximum(np.abs(y), floor)
|
||||
if scheme == "1/y2":
|
||||
return 1.0 / np.maximum(y**2, floor)
|
||||
if scheme == "1/yhat":
|
||||
return 1.0 / np.maximum(np.abs(yhat), floor)
|
||||
if scheme == "1/yhat2":
|
||||
return 1.0 / np.maximum(yhat**2, floor)
|
||||
raise InputError(f"unknown weighting scheme {scheme!r}")
|
||||
|
||||
|
||||
def initial_estimates(spec: FitSpec, time: np.ndarray, conc: np.ndarray) -> np.ndarray:
|
||||
"""Heuristic starting values from the shape of the data.
|
||||
|
||||
Bad initial estimates are the usual cause of a 'model that will not fit'.
|
||||
These come from the same quantities NCA would give: the terminal slope sets
|
||||
the slow disposition, the AUC sets clearance, and the peak sets the central
|
||||
volume.
|
||||
"""
|
||||
positive = conc > 0
|
||||
t, c = time[positive], conc[positive]
|
||||
if len(t) < 3:
|
||||
raise InputError("need at least 3 positive concentrations to fit")
|
||||
tail = slice(max(len(t) - 3, 0), len(t))
|
||||
slope = np.polyfit(t[tail], np.log(c[tail]), 1)[0]
|
||||
lam = max(-slope, 1e-4)
|
||||
auc = float(np.trapezoid(c, t)) + c[-1] / lam
|
||||
cl = spec.dose / max(auc, 1e-9)
|
||||
cmax = float(c.max())
|
||||
v1 = spec.dose / cmax if spec.route == "iv-bolus" else cl / lam
|
||||
v1 = max(v1, 1e-6)
|
||||
|
||||
theta = [cl, v1]
|
||||
if spec.model in {"2cmt", "3cmt"}:
|
||||
theta += [cl, v1 * 2.0]
|
||||
if spec.model == "3cmt":
|
||||
theta += [cl / 3.0, v1 * 5.0]
|
||||
if spec.route == "oral":
|
||||
tmax = float(t[int(np.argmax(c))])
|
||||
if spec.absorption == "transit":
|
||||
theta += [max(tmax * 0.7, 1e-3), 3.0]
|
||||
else:
|
||||
theta += [max(2.0 / max(tmax, 1e-3), 1e-3)]
|
||||
if spec.estimate_lag:
|
||||
theta += [max(float(t.min()) * 0.5, 1e-3)]
|
||||
return np.asarray(theta, dtype=float)
|
||||
|
||||
|
||||
def fit_one(
|
||||
spec: FitSpec,
|
||||
time: np.ndarray,
|
||||
conc: np.ndarray,
|
||||
scheme: str,
|
||||
starts: int = 5,
|
||||
seed: int = 20260727,
|
||||
) -> FitResult:
|
||||
predict = predictor(spec)
|
||||
names = spec.parameter_names
|
||||
p = len(names)
|
||||
n = len(time)
|
||||
if n <= p:
|
||||
raise InputError(f"{n} observations cannot support {p} parameters")
|
||||
|
||||
base = initial_estimates(spec, time, conc)
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
def residual(log_theta: np.ndarray) -> np.ndarray:
|
||||
theta = np.exp(log_theta)
|
||||
try:
|
||||
yhat = predict(time, theta)
|
||||
except (ValueError, np.linalg.LinAlgError):
|
||||
return np.full(n, 1e6)
|
||||
if not np.all(np.isfinite(yhat)):
|
||||
return np.full(n, 1e6)
|
||||
w = _weights(conc, yhat, scheme)
|
||||
return np.sqrt(w) * (conc - yhat)
|
||||
|
||||
best = None
|
||||
for attempt in range(max(1, starts)):
|
||||
start = base if attempt == 0 else base * np.exp(rng.normal(0.0, 0.5, size=p))
|
||||
try:
|
||||
candidate = least_squares(
|
||||
residual,
|
||||
np.log(np.maximum(start, 1e-12)),
|
||||
method="trf",
|
||||
x_scale="jac",
|
||||
max_nfev=5000 * p,
|
||||
)
|
||||
except (ValueError, np.linalg.LinAlgError):
|
||||
continue
|
||||
if best is None or candidate.cost < best.cost:
|
||||
best = candidate
|
||||
if best is None: # pragma: no cover - every start failed
|
||||
raise InputError("optimiser failed from every starting point; check dose, units and route")
|
||||
|
||||
theta = np.exp(best.x)
|
||||
yhat = predict(time, theta)
|
||||
weights = _weights(conc, yhat, scheme)
|
||||
residuals = conc - yhat
|
||||
weighted = np.sqrt(weights) * residuals
|
||||
wssr = float(np.sum(weighted**2))
|
||||
dof = n - p
|
||||
sigma2 = wssr / dof
|
||||
|
||||
jac = best.jac
|
||||
try:
|
||||
# Covariance on the log scale from the Gauss-Newton approximation.
|
||||
_, s, vt = np.linalg.svd(jac, full_matrices=False)
|
||||
threshold = np.finfo(float).eps * max(jac.shape) * (s[0] if s.size else 0.0)
|
||||
s_inv = np.array([1.0 / x if x > threshold else 0.0 for x in s])
|
||||
cov = (vt.T * s_inv**2) @ vt * sigma2
|
||||
se_log = np.sqrt(np.maximum(np.diag(cov), 0.0))
|
||||
outer = np.outer(se_log, se_log)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
corr = np.where(outer > 0, cov / np.where(outer > 0, outer, 1.0), np.nan)
|
||||
# NONMEM's condition number: largest over smallest eigenvalue of the
|
||||
# *correlation* matrix of the estimates. Reported this way so the
|
||||
# familiar ">1000 is ill-conditioned" rule of thumb actually applies;
|
||||
# the condition number of the Jacobian is a different quantity on a
|
||||
# different scale, and the two get confused constantly.
|
||||
if np.all(np.isfinite(corr)):
|
||||
eigenvalues = np.linalg.eigvalsh(corr)
|
||||
smallest = float(eigenvalues.min())
|
||||
condition = float(eigenvalues.max() / smallest) if smallest > 0 else float("inf")
|
||||
else:
|
||||
condition = float("inf")
|
||||
except np.linalg.LinAlgError: # pragma: no cover
|
||||
se_log = np.full(p, np.nan)
|
||||
corr = np.full((p, p), np.nan)
|
||||
condition = float("inf")
|
||||
|
||||
return FitResult(
|
||||
theta=theta,
|
||||
names=names,
|
||||
se_log=se_log,
|
||||
wssr=wssr,
|
||||
n=n,
|
||||
p=p,
|
||||
correlation=corr,
|
||||
condition=condition,
|
||||
predicted=yhat,
|
||||
residuals=residuals,
|
||||
weighted_residuals=weighted,
|
||||
sigma=math.sqrt(sigma2),
|
||||
success=bool(best.success),
|
||||
message=str(best.message),
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- derivations
|
||||
|
||||
|
||||
def secondary_parameters(spec: FitSpec, theta: np.ndarray) -> dict[str, float]:
|
||||
n_disp = len(MODELS[spec.model])
|
||||
cl, v1 = theta[0], theta[1]
|
||||
q = tuple(theta[2:n_disp:2])
|
||||
vp = tuple(theta[3:n_disp:2])
|
||||
disp = disposition(cl, v1, q, vp)
|
||||
suffix = "/F" if spec.route == "oral" else ""
|
||||
out = {
|
||||
f"Vss{suffix}": disp.vss,
|
||||
"MRT_iv": disp.mrt_iv,
|
||||
"terminal_t_half": disp.terminal_half_life,
|
||||
}
|
||||
for i, half in enumerate(disp.half_lives, start=1):
|
||||
out[f"t_half_phase{i}"] = float(half)
|
||||
if spec.route == "oral" and spec.absorption != "transit":
|
||||
ka = theta[n_disp]
|
||||
out["absorption_t_half"] = math.log(2.0) / ka
|
||||
if ka < 1.0 / disp.terminal_half_life * math.log(2.0):
|
||||
out["flip_flop_suspected"] = 1.0
|
||||
if spec.interval:
|
||||
from _models import steady_state_metrics
|
||||
|
||||
out.update({k: v for k, v in steady_state_metrics(disp, spec.dose, spec.interval).items()})
|
||||
return out
|
||||
|
||||
|
||||
def runs_test_p(residuals: np.ndarray) -> float:
|
||||
"""Two-sided Wald-Wolfowitz runs test on residual signs."""
|
||||
signs = np.sign(residuals)
|
||||
signs = signs[signs != 0]
|
||||
n = len(signs)
|
||||
if n < 8:
|
||||
return float("nan")
|
||||
n_pos = int(np.sum(signs > 0))
|
||||
n_neg = n - n_pos
|
||||
if n_pos == 0 or n_neg == 0:
|
||||
return 0.0
|
||||
runs = 1 + int(np.sum(signs[1:] != signs[:-1]))
|
||||
mean = 2.0 * n_pos * n_neg / n + 1.0
|
||||
var = (mean - 1.0) * (mean - 2.0) / (n - 1.0)
|
||||
if var <= 0:
|
||||
return float("nan")
|
||||
z = (runs - mean) / math.sqrt(var)
|
||||
return float(2.0 * (1.0 - 0.5 * (1.0 + math.erf(abs(z) / math.sqrt(2.0)))))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fit 1/2/3-compartment models with identifiability and model-selection diagnostics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("-i", "--input", required=True)
|
||||
parser.add_argument("--dose", type=float, required=True)
|
||||
parser.add_argument("--route", choices=("iv-bolus", "iv-infusion", "oral"), default="iv-bolus")
|
||||
parser.add_argument("--model", choices=sorted(MODELS), default="1cmt")
|
||||
parser.add_argument("--compare", help="comma-separated models to compare, e.g. 1cmt,2cmt,3cmt")
|
||||
parser.add_argument("--tinf", type=float, help="infusion duration for --route iv-infusion")
|
||||
parser.add_argument("--interval", type=float, help="dosing interval for a multiple-dose profile")
|
||||
parser.add_argument("--n-doses", type=int, default=1)
|
||||
parser.add_argument("--absorption", choices=("first-order", "transit"), default="first-order")
|
||||
parser.add_argument("--lag", action="store_true", help="estimate an absorption lag time")
|
||||
parser.add_argument("--weight", choices=sorted(WEIGHTS), default="1/y2")
|
||||
parser.add_argument("--starts", type=int, default=5, help="random restarts (default: 5)")
|
||||
parser.add_argument("--time-column", default="time")
|
||||
parser.add_argument("--conc-column", default="conc")
|
||||
parser.add_argument("--subject-column", default="id")
|
||||
parser.add_argument("--max-rse", type=float, default=50.0, help="flag %%RSE above this (default: 50)")
|
||||
parser.add_argument("--max-corr", type=float, default=0.95, help="flag |correlation| above this (default: 0.95)")
|
||||
parser.add_argument("--predictions", action="store_true", help="also emit the observed/predicted table")
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def _fit_and_report(spec: FitSpec, args, subject: str, time, conc, report: Report) -> FitResult:
|
||||
result = fit_one(spec, time, conc, args.weight, starts=args.starts)
|
||||
label = f"subject {subject}" if subject else "fit"
|
||||
|
||||
rows = []
|
||||
crit = t_dist.ppf(0.975, max(result.n - result.p, 1))
|
||||
for i, name in enumerate(result.names):
|
||||
se = result.se_log[i]
|
||||
rows.append(
|
||||
{
|
||||
"parameter": name,
|
||||
"estimate": result.theta[i],
|
||||
"rse_pct": 100.0 * se if math.isfinite(se) else float("nan"),
|
||||
"ci95_low": result.theta[i] * math.exp(-crit * se) if math.isfinite(se) else float("nan"),
|
||||
"ci95_high": result.theta[i] * math.exp(crit * se) if math.isfinite(se) else float("nan"),
|
||||
}
|
||||
)
|
||||
if math.isfinite(se) and 100.0 * se > args.max_rse:
|
||||
report.finding(
|
||||
f"{label}: {name} has {100 * se:.0f}% RSE - not estimable from these data at this model size"
|
||||
)
|
||||
report.table(f"{label}: {spec.model} {spec.route} parameters", rows)
|
||||
|
||||
secondary = secondary_parameters(spec, result.theta)
|
||||
report.table(f"{label}: secondary parameters", [{"parameter": k, "value": v} for k, v in secondary.items()])
|
||||
if secondary.get("flip_flop_suspected"):
|
||||
report.finding(
|
||||
f"{label}: ka is slower than the terminal disposition rate - the fit is in the flip-flop "
|
||||
"branch, where ka and k are numerically exchangeable. Without IV data the assignment is a "
|
||||
"modelling assumption, not an estimate."
|
||||
)
|
||||
|
||||
report.table(
|
||||
f"{label}: fit statistics",
|
||||
[
|
||||
{
|
||||
"statistic": "observations",
|
||||
"value": result.n,
|
||||
},
|
||||
{"statistic": "parameters", "value": result.p},
|
||||
{"statistic": "weighted SSR", "value": result.wssr},
|
||||
{"statistic": "residual SD", "value": result.sigma},
|
||||
{"statistic": "AIC", "value": result.aic},
|
||||
{"statistic": "BIC", "value": result.bic},
|
||||
{"statistic": "condition number", "value": result.condition},
|
||||
{"statistic": "runs-test p (residual signs)", "value": runs_test_p(result.weighted_residuals)},
|
||||
{"statistic": "converged", "value": result.success},
|
||||
],
|
||||
)
|
||||
|
||||
with np.errstate(invalid="ignore"):
|
||||
for i in range(result.p):
|
||||
for j in range(i + 1, result.p):
|
||||
rho = result.correlation[i, j]
|
||||
if math.isfinite(rho) and abs(rho) > args.max_corr:
|
||||
report.finding(
|
||||
f"{label}: {result.names[i]} and {result.names[j]} are correlated at "
|
||||
f"{rho:+.3f} - the data cannot separate them; consider a simpler model"
|
||||
)
|
||||
if math.isfinite(result.condition) and result.condition > 1000:
|
||||
report.finding(
|
||||
f"{label}: condition number {result.condition:.0f} exceeds 1000 - the model is "
|
||||
"over-parameterised for these data and the standard errors are unreliable"
|
||||
)
|
||||
runs_p = runs_test_p(result.weighted_residuals)
|
||||
if math.isfinite(runs_p) and runs_p < 0.05:
|
||||
report.finding(
|
||||
f"{label}: residual signs are not random (runs test p = {runs_p:.4f}) - a structural "
|
||||
"misspecification, which no amount of reweighting will fix"
|
||||
)
|
||||
if not result.success:
|
||||
report.finding(f"{label}: optimiser did not report convergence ({result.message})")
|
||||
|
||||
if args.predictions:
|
||||
report.table(
|
||||
f"{label}: observed vs predicted",
|
||||
[
|
||||
{
|
||||
"time": float(t),
|
||||
"observed": float(o),
|
||||
"predicted": float(p),
|
||||
"residual": float(r),
|
||||
"weighted_residual": float(w),
|
||||
}
|
||||
for t, o, p, r, w in zip(time, conc, result.predicted, result.residuals, result.weighted_residuals)
|
||||
],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.route == "iv-infusion" and not args.tinf:
|
||||
print("error: --route iv-infusion needs --tinf", file=sys.stderr)
|
||||
return EXIT_INPUT
|
||||
|
||||
rows = read_table(args.input)
|
||||
require_columns(rows, [args.time_column, args.conc_column], str(args.input))
|
||||
subjects = group_by(rows, args.subject_column) if args.subject_column in rows[0] else {"": rows}
|
||||
|
||||
report = Report()
|
||||
report.note(f"weighting: {args.weight} ({WEIGHTS[args.weight]})")
|
||||
report.note("parameters estimated on the log scale; confidence intervals are therefore asymmetric")
|
||||
if args.route == "oral":
|
||||
report.note(
|
||||
"extravascular data alone identify CL/F and V/F, never CL and V separately. "
|
||||
"Bioavailability requires an intravenous reference."
|
||||
)
|
||||
|
||||
models = [m.strip() for m in args.compare.split(",")] if args.compare else [args.model]
|
||||
for name in models:
|
||||
if name not in MODELS:
|
||||
raise InputError(f"unknown model {name!r}; choose from {', '.join(sorted(MODELS))}")
|
||||
|
||||
for subject, subject_rows in subjects.items():
|
||||
time = np.asarray([parse_float(r[args.time_column], "time") for r in subject_rows], dtype=float)
|
||||
conc = np.asarray([parse_float(r[args.conc_column], "conc") for r in subject_rows], dtype=float)
|
||||
order = np.argsort(time)
|
||||
time, conc = time[order], conc[order]
|
||||
keep = conc > 0
|
||||
if np.sum(keep) < len(conc):
|
||||
report.note(f"subject {subject or '1'}: dropped {int(np.sum(~keep))} non-positive concentration(s)")
|
||||
time, conc = time[keep], conc[keep]
|
||||
|
||||
fits = {}
|
||||
for name in models:
|
||||
spec = FitSpec(
|
||||
model=name,
|
||||
route=args.route,
|
||||
dose=args.dose,
|
||||
tinf=args.tinf,
|
||||
interval=args.interval,
|
||||
n_doses=args.n_doses,
|
||||
absorption=args.absorption,
|
||||
estimate_lag=args.lag,
|
||||
)
|
||||
fits[name] = _fit_and_report(spec, args, subject, time, conc, report)
|
||||
|
||||
if len(models) > 1:
|
||||
comparison = []
|
||||
ordered = sorted(fits.items(), key=lambda kv: kv[1].p)
|
||||
for index, (name, fit) in enumerate(ordered):
|
||||
entry = {"model": name, "parameters": fit.p, "wssr": fit.wssr, "aic": fit.aic, "bic": fit.bic}
|
||||
if index > 0:
|
||||
simpler_name, simpler = ordered[index - 1]
|
||||
d_p = fit.p - simpler.p
|
||||
if d_p > 0 and fit.wssr > 0 and fit.n - fit.p > 0:
|
||||
f_stat = ((simpler.wssr - fit.wssr) / d_p) / (fit.wssr / (fit.n - fit.p))
|
||||
entry["f_vs_simpler"] = f_stat
|
||||
entry["f_p_value"] = float(1.0 - f_dist.cdf(f_stat, d_p, fit.n - fit.p)) if f_stat > 0 else 1.0
|
||||
entry["compared_with"] = simpler_name
|
||||
comparison.append(entry)
|
||||
report.table(f"subject {subject or '1'}: model comparison", comparison)
|
||||
best = min(fits.items(), key=lambda kv: kv[1].aic)[0]
|
||||
report.note(
|
||||
f"subject {subject or '1'}: lowest AIC is {best}. AIC and BIC are comparable here only "
|
||||
"because every candidate was fitted to the same observations with the same weighting."
|
||||
)
|
||||
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
587
skills/pkpd-modeling/scripts/nca.py
Normal file
587
skills/pkpd-modeling/scripts/nca.py
Normal file
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Non-compartmental analysis of concentration-time data.
|
||||
|
||||
NCA looks arithmetically trivial and is not. Essentially every disagreement
|
||||
between two NCA results traces to one of four choices that are rarely written
|
||||
down: how lambda_z was selected, which trapezoidal rule was used, what happened
|
||||
to BLQ values, and whether AUCinf was based on observed or predicted Clast.
|
||||
This script makes all four explicit, reports the diagnostics that decide
|
||||
whether the result is reportable, and refuses to hide them behind a default.
|
||||
|
||||
python3 nca.py -i profile.csv --dose 100 --route extravascular
|
||||
python3 nca.py -i profile.csv --dose-column dose --route iv-infusion --tinf 1
|
||||
python3 nca.py -i ss.csv --dose 50 --route extravascular --tau 12
|
||||
python3 nca.py -i profile.csv --dose 100 --partial-auc 0-24 --partial-auc 0-72
|
||||
|
||||
Input is a table with subject, time, and concentration columns (default names
|
||||
``id``, ``time``, ``conc``). Concentrations below the limit of quantification
|
||||
may be given as the text ``BLQ`` / ``BQL`` / ``<LLOQ``, or as blanks, or as a
|
||||
separate 0/1 ``blq`` column.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import (
|
||||
EXIT_INPUT,
|
||||
InputError,
|
||||
Report,
|
||||
add_format_argument,
|
||||
main_wrapper,
|
||||
parse_float,
|
||||
read_table,
|
||||
require_columns,
|
||||
)
|
||||
|
||||
BLQ_TOKENS = {"blq", "bql", "<lloq", "lloq", "bloq", "nq", "<loq"}
|
||||
|
||||
ROUTES = {
|
||||
"iv-bolus": "intravenous bolus",
|
||||
"iv-infusion": "intravenous infusion (zero order)",
|
||||
"extravascular": "extravascular (oral, SC, IM, ...)",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- parsing
|
||||
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
subject: str
|
||||
time: np.ndarray
|
||||
conc: np.ndarray
|
||||
is_blq: np.ndarray
|
||||
dose: float
|
||||
tau: float | None = None
|
||||
tinf: float | None = None
|
||||
|
||||
|
||||
def _parse_conc(raw: str, lloq: float | None, rule: str) -> tuple[float | None, bool]:
|
||||
"""Return (value, is_blq). ``None`` means 'drop this record'."""
|
||||
text = (raw or "").strip()
|
||||
lowered = text.lower()
|
||||
blq = lowered in BLQ_TOKENS or lowered.startswith("<") or lowered == ""
|
||||
if blq:
|
||||
if rule == "missing":
|
||||
return None, True
|
||||
if rule == "half-lloq":
|
||||
if lloq is None:
|
||||
raise InputError("--blq-rule half-lloq needs --lloq")
|
||||
return lloq / 2.0, True
|
||||
return 0.0, True
|
||||
value = parse_float(text, "conc")
|
||||
if value is None:
|
||||
return None, True
|
||||
if lloq is not None and value < lloq:
|
||||
if rule == "missing":
|
||||
return None, True
|
||||
return (lloq / 2.0 if rule == "half-lloq" else 0.0), True
|
||||
return value, False
|
||||
|
||||
|
||||
def load_profiles(args: argparse.Namespace) -> list[Profile]:
|
||||
rows = read_table(args.input)
|
||||
require_columns(rows, [args.time_column, args.conc_column], str(args.input))
|
||||
has_subject = args.subject_column in rows[0]
|
||||
if args.dose is None and args.dose_column not in rows[0]:
|
||||
raise InputError(
|
||||
f"give a dose with --dose, or a per-subject '{args.dose_column}' column in the input"
|
||||
)
|
||||
|
||||
buckets: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
subject = row[args.subject_column].strip() if has_subject else "1"
|
||||
buckets.setdefault(subject or "1", []).append(row)
|
||||
|
||||
profiles = []
|
||||
for subject, subject_rows in buckets.items():
|
||||
times, concs, blqs = [], [], []
|
||||
for index, row in enumerate(subject_rows):
|
||||
time = parse_float(row.get(args.time_column), f"{args.time_column} (row {index + 1})")
|
||||
flagged = str(row.get("blq", "")).strip() in {"1", "y", "yes", "true"}
|
||||
value, is_blq = _parse_conc(row.get(args.conc_column, ""), args.lloq, args.blq_rule)
|
||||
if flagged and not is_blq:
|
||||
value, is_blq = _parse_conc("BLQ", args.lloq, args.blq_rule)
|
||||
if value is None:
|
||||
continue
|
||||
times.append(time)
|
||||
concs.append(value)
|
||||
blqs.append(is_blq)
|
||||
if not times:
|
||||
raise InputError(f"subject {subject}: no usable concentration records")
|
||||
|
||||
order = np.argsort(np.asarray(times, dtype=float), kind="stable")
|
||||
time_array = np.asarray(times, dtype=float)[order]
|
||||
conc_array = np.asarray(concs, dtype=float)[order]
|
||||
blq_array = np.asarray(blqs, dtype=bool)[order]
|
||||
|
||||
if args.dose is not None:
|
||||
dose = args.dose
|
||||
else:
|
||||
dose = parse_float(subject_rows[0].get(args.dose_column), args.dose_column)
|
||||
if dose is None or dose <= 0:
|
||||
raise InputError(f"subject {subject}: dose must be positive")
|
||||
|
||||
tau = args.tau
|
||||
if args.tau is None and "tau" in subject_rows[0]:
|
||||
tau = parse_float(subject_rows[0].get("tau"), "tau", allow_missing=True)
|
||||
tinf = args.tinf
|
||||
if args.tinf is None and "tinf" in subject_rows[0]:
|
||||
tinf = parse_float(subject_rows[0].get("tinf"), "tinf", allow_missing=True)
|
||||
|
||||
profiles.append(Profile(subject, time_array, conc_array, blq_array, float(dose), tau, tinf))
|
||||
return profiles
|
||||
|
||||
|
||||
# ------------------------------------------------------------- trapezoidal
|
||||
|
||||
|
||||
def _segment(t0: float, t1: float, c0: float, c1: float, method: str) -> tuple[float, float]:
|
||||
"""AUC and AUMC over one interval. Returns (auc, aumc)."""
|
||||
dt = t1 - t0
|
||||
if dt <= 0:
|
||||
return 0.0, 0.0
|
||||
use_log = (
|
||||
method in {"linup-logdown", "log"}
|
||||
and c0 > 0
|
||||
and c1 > 0
|
||||
and (c1 < c0 or method == "log")
|
||||
and not math.isclose(c0, c1)
|
||||
)
|
||||
if not use_log:
|
||||
return (c0 + c1) / 2.0 * dt, (t0 * c0 + t1 * c1) / 2.0 * dt
|
||||
k = math.log(c0 / c1) / dt
|
||||
auc = (c0 - c1) / k
|
||||
aumc = (t0 * c0 - t1 * c1) / k + (c0 - c1) / (k * k)
|
||||
return auc, aumc
|
||||
|
||||
|
||||
def cumulative_auc(time: np.ndarray, conc: np.ndarray, method: str) -> tuple[np.ndarray, np.ndarray]:
|
||||
auc = np.zeros_like(time)
|
||||
aumc = np.zeros_like(time)
|
||||
for i in range(1, len(time)):
|
||||
d_auc, d_aumc = _segment(time[i - 1], time[i], conc[i - 1], conc[i], method)
|
||||
auc[i] = auc[i - 1] + d_auc
|
||||
aumc[i] = aumc[i - 1] + d_aumc
|
||||
return auc, aumc
|
||||
|
||||
|
||||
def interpolate(time: np.ndarray, conc: np.ndarray, target: float, method: str) -> float:
|
||||
"""Concentration at an arbitrary time, consistent with the AUC rule."""
|
||||
if target <= time[0]:
|
||||
return float(conc[0])
|
||||
if target >= time[-1]:
|
||||
return float(conc[-1])
|
||||
i = int(np.searchsorted(time, target))
|
||||
t0, t1, c0, c1 = time[i - 1], time[i], conc[i - 1], conc[i]
|
||||
use_log = method in {"linup-logdown", "log"} and c0 > 0 and c1 > 0 and (c1 < c0 or method == "log")
|
||||
if not use_log:
|
||||
return float(c0 + (c1 - c0) * (target - t0) / (t1 - t0))
|
||||
k = math.log(c0 / c1) / (t1 - t0)
|
||||
return float(c0 * math.exp(-k * (target - t0)))
|
||||
|
||||
|
||||
def partial_auc(time: np.ndarray, conc: np.ndarray, start: float, end: float, method: str) -> float:
|
||||
inner = [t for t in time if start < t < end]
|
||||
knots = [start, *inner, end]
|
||||
values = [interpolate(time, conc, t, method) for t in knots]
|
||||
total = 0.0
|
||||
for i in range(1, len(knots)):
|
||||
total += _segment(knots[i - 1], knots[i], values[i - 1], values[i], method)[0]
|
||||
return total
|
||||
|
||||
|
||||
# --------------------------------------------------------------- lambda_z
|
||||
|
||||
|
||||
@dataclass
|
||||
class LambdaZ:
|
||||
lam: float | None = None
|
||||
intercept: float | None = None
|
||||
r2: float | None = None
|
||||
r2_adj: float | None = None
|
||||
n_points: int = 0
|
||||
t_first: float | None = None
|
||||
t_last: float | None = None
|
||||
clast_pred: float | None = None
|
||||
reason: str = ""
|
||||
excluded_cmax: bool = True
|
||||
|
||||
@property
|
||||
def half_life(self) -> float | None:
|
||||
return math.log(2.0) / self.lam if self.lam and self.lam > 0 else None
|
||||
|
||||
|
||||
def _loglinear_fit(t: np.ndarray, c: np.ndarray) -> tuple[float, float, float]:
|
||||
"""Slope, intercept, r2 of ln(C) on time."""
|
||||
y = np.log(c)
|
||||
tbar, ybar = t.mean(), y.mean()
|
||||
sxx = float(np.sum((t - tbar) ** 2))
|
||||
slope = float(np.sum((t - tbar) * (y - ybar)) / sxx)
|
||||
intercept = float(ybar - slope * tbar)
|
||||
fitted = intercept + slope * t
|
||||
ss_res = float(np.sum((y - fitted) ** 2))
|
||||
ss_tot = float(np.sum((y - ybar) ** 2))
|
||||
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
|
||||
return slope, intercept, r2
|
||||
|
||||
|
||||
def estimate_lambda_z(
|
||||
time: np.ndarray,
|
||||
conc: np.ndarray,
|
||||
is_blq: np.ndarray,
|
||||
tmax: float,
|
||||
min_points: int = 3,
|
||||
manual: tuple[float, float] | None = None,
|
||||
) -> LambdaZ:
|
||||
"""Select the terminal window by best adjusted r-squared.
|
||||
|
||||
The rule implemented is the widely used one: start from the last three
|
||||
quantifiable points, extend backwards one point at a time, and keep the
|
||||
longer window only when adjusted r-squared improves by more than 0.0001.
|
||||
Adjusted r-squared, not r-squared, is essential — plain r-squared can only
|
||||
rise as points are added, so it would always select the longest window.
|
||||
|
||||
Points at or before Tmax are never eligible. Including Tmax makes the fit
|
||||
describe the tail of absorption rather than elimination, which biases
|
||||
lambda_z upward and the half-life, Vz, and AUCinf downward.
|
||||
"""
|
||||
eligible = (~is_blq) & (conc > 0) & (time > tmax)
|
||||
idx = np.flatnonzero(eligible)
|
||||
if manual is not None:
|
||||
lo, hi = manual
|
||||
idx = np.flatnonzero((~is_blq) & (conc > 0) & (time >= lo) & (time <= hi))
|
||||
if len(idx) < 2:
|
||||
return LambdaZ(reason=f"manual window {lo}-{hi} contains {len(idx)} quantifiable points")
|
||||
slope, intercept, r2 = _loglinear_fit(time[idx], conc[idx])
|
||||
n = len(idx)
|
||||
r2_adj = 1.0 - (1.0 - r2) * (n - 1) / (n - 2) if n > 2 else float("nan")
|
||||
return LambdaZ(
|
||||
lam=-slope,
|
||||
intercept=intercept,
|
||||
r2=r2,
|
||||
r2_adj=r2_adj,
|
||||
n_points=n,
|
||||
t_first=float(time[idx][0]),
|
||||
t_last=float(time[idx][-1]),
|
||||
clast_pred=float(math.exp(intercept + slope * time[idx][-1])),
|
||||
reason="manual window",
|
||||
excluded_cmax=bool(np.all(time[idx] > tmax)),
|
||||
)
|
||||
|
||||
if len(idx) < min_points:
|
||||
return LambdaZ(reason=f"only {len(idx)} quantifiable points after Tmax; need {min_points}")
|
||||
|
||||
best: LambdaZ | None = None
|
||||
for start in range(len(idx) - min_points, -1, -1):
|
||||
window = idx[start:]
|
||||
n = len(window)
|
||||
slope, intercept, r2 = _loglinear_fit(time[window], conc[window])
|
||||
if n <= 2 or not math.isfinite(r2):
|
||||
continue
|
||||
r2_adj = 1.0 - (1.0 - r2) * (n - 1) / (n - 2)
|
||||
candidate = LambdaZ(
|
||||
lam=-slope,
|
||||
intercept=intercept,
|
||||
r2=r2,
|
||||
r2_adj=r2_adj,
|
||||
n_points=n,
|
||||
t_first=float(time[window][0]),
|
||||
t_last=float(time[window][-1]),
|
||||
clast_pred=float(math.exp(intercept + slope * time[window][-1])),
|
||||
reason="best adjusted r2",
|
||||
)
|
||||
if best is None or (candidate.r2_adj or -math.inf) > (best.r2_adj or -math.inf) + 1e-4:
|
||||
best = candidate
|
||||
if best is None:
|
||||
return LambdaZ(reason="no window of at least 3 points could be fitted")
|
||||
if best.lam is not None and best.lam <= 0:
|
||||
return LambdaZ(reason=f"terminal slope is not negative (lambda_z = {best.lam:.4g})", n_points=best.n_points)
|
||||
return best
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- NCA
|
||||
|
||||
|
||||
def analyse(profile: Profile, args: argparse.Namespace) -> tuple[dict, LambdaZ, list[str]]:
|
||||
findings: list[str] = []
|
||||
time, conc, is_blq = profile.time, profile.conc, profile.is_blq
|
||||
quantifiable = (~is_blq) & (conc > 0)
|
||||
|
||||
if not np.any(quantifiable):
|
||||
findings.append(f"subject {profile.subject}: every sample is BLQ; no parameters computed")
|
||||
return {"id": profile.subject}, LambdaZ(reason="all BLQ"), findings
|
||||
|
||||
peak = int(np.argmax(np.where(quantifiable, conc, -np.inf)))
|
||||
cmax, tmax = float(conc[peak]), float(time[peak])
|
||||
last = int(np.flatnonzero(quantifiable)[-1])
|
||||
clast, tlast = float(conc[last]), float(time[last])
|
||||
|
||||
auc_cum, aumc_cum = cumulative_auc(time, conc, args.auc_method)
|
||||
auc_last, aumc_last = float(auc_cum[last]), float(aumc_cum[last])
|
||||
|
||||
lz = estimate_lambda_z(time, conc, is_blq, tmax, args.lambda_z_points, args.lambda_z_window)
|
||||
|
||||
row: dict[str, object] = {
|
||||
"id": profile.subject,
|
||||
"dose": profile.dose,
|
||||
"n_obs": int(len(time)),
|
||||
"n_blq": int(np.sum(is_blq)),
|
||||
"cmax": cmax,
|
||||
"tmax": tmax,
|
||||
"clast": clast,
|
||||
"tlast": tlast,
|
||||
"auc_last": auc_last,
|
||||
}
|
||||
if profile.tau:
|
||||
row["auc_tau"] = partial_auc(time, conc, 0.0, profile.tau, args.auc_method)
|
||||
|
||||
if lz.lam:
|
||||
lam = lz.lam
|
||||
row["lambda_z"] = lam
|
||||
row["t_half"] = lz.half_life
|
||||
row["r2_adj"] = lz.r2_adj
|
||||
row["lambda_z_n"] = lz.n_points
|
||||
auc_inf_obs = auc_last + clast / lam
|
||||
auc_inf_pred = auc_last + (lz.clast_pred or clast) / lam
|
||||
aumc_inf = aumc_last + tlast * clast / lam + clast / lam**2
|
||||
row["auc_inf_obs"] = auc_inf_obs
|
||||
row["auc_inf_pred"] = auc_inf_pred
|
||||
row["pct_auc_extrap"] = 100.0 * (auc_inf_obs - auc_last) / auc_inf_obs
|
||||
row["pct_aumc_extrap"] = 100.0 * (aumc_inf - aumc_last) / aumc_inf if aumc_inf > 0 else float("nan")
|
||||
|
||||
mrt = aumc_inf / auc_inf_obs
|
||||
if args.route == "iv-infusion" and profile.tinf:
|
||||
mrt -= profile.tinf / 2.0
|
||||
row["mrt"] = mrt
|
||||
|
||||
clearance = profile.dose / auc_inf_obs
|
||||
row["cl_f" if args.route == "extravascular" else "cl"] = clearance
|
||||
row["vz_f" if args.route == "extravascular" else "vz"] = clearance / lam
|
||||
if args.route != "extravascular":
|
||||
row["vss"] = clearance * mrt
|
||||
|
||||
span = (tlast - (lz.t_first or tlast)) / (lz.half_life or math.inf)
|
||||
row["span_ratio"] = span
|
||||
|
||||
prefix = f"subject {profile.subject}"
|
||||
if row["pct_auc_extrap"] > args.max_extrap:
|
||||
findings.append(
|
||||
f"{prefix}: {row['pct_auc_extrap']:.1f}% of AUCinf is extrapolated "
|
||||
f"(above {args.max_extrap:.0f}%); AUCinf is driven by the lambda_z fit, not by data"
|
||||
)
|
||||
if lz.r2_adj is not None and lz.r2_adj < args.min_r2_adj:
|
||||
findings.append(
|
||||
f"{prefix}: terminal-phase adjusted r2 is {lz.r2_adj:.4f} (below {args.min_r2_adj}); "
|
||||
"half-life, Vz and AUCinf inherit that uncertainty"
|
||||
)
|
||||
if span < args.min_span:
|
||||
findings.append(
|
||||
f"{prefix}: lambda_z window spans {span:.2f} half-lives (below {args.min_span}); "
|
||||
"the terminal phase may not have been reached"
|
||||
)
|
||||
if lz.n_points < 3:
|
||||
findings.append(f"{prefix}: lambda_z estimated from {lz.n_points} points")
|
||||
else:
|
||||
findings.append(f"subject {profile.subject}: lambda_z not estimable - {lz.reason}")
|
||||
|
||||
if profile.tau:
|
||||
tau = profile.tau
|
||||
auc_tau = float(row["auc_tau"])
|
||||
cmin = float(np.min(conc[(time >= 0) & (time <= tau) & quantifiable])) if np.any(quantifiable) else float("nan")
|
||||
cavg = auc_tau / tau
|
||||
row["cavg_ss"] = cavg
|
||||
row["cmin_ss"] = cmin
|
||||
row["ptf_pct"] = 100.0 * (cmax - cmin) / cavg if cavg else float("nan")
|
||||
row["swing"] = (cmax - cmin) / cmin if cmin else float("nan")
|
||||
row["cl_ss_f" if args.route == "extravascular" else "cl_ss"] = profile.dose / auc_tau
|
||||
if lz.lam:
|
||||
row["accumulation_index"] = 1.0 / (1.0 - math.exp(-lz.lam * tau))
|
||||
|
||||
for start, end in args.partial_auc:
|
||||
if end > tlast:
|
||||
findings.append(
|
||||
f"subject {profile.subject}: partial AUC {start:g}-{end:g} extends past the last "
|
||||
f"quantifiable sample at {tlast:g}; the tail is interpolated from Clast"
|
||||
)
|
||||
row[f"auc_{start:g}_{end:g}"] = partial_auc(time, conc, start, end, args.auc_method)
|
||||
|
||||
prefix = f"subject {profile.subject}"
|
||||
if peak == len(time) - 1:
|
||||
findings.append(f"{prefix}: Cmax is the last sample; the peak and the terminal phase are not characterised")
|
||||
if peak == 0 and args.route == "extravascular":
|
||||
findings.append(f"{prefix}: Cmax is the first sample; the true peak may precede the first draw")
|
||||
if is_blq[:peak].any() and args.blq_rule == "zero":
|
||||
row["leading_blq_set_to_zero"] = int(np.sum(is_blq[:peak]))
|
||||
|
||||
return row, lz, findings
|
||||
|
||||
|
||||
# --------------------------------------------------------------- summaries
|
||||
|
||||
|
||||
def summarise(rows: Sequence[dict], keys: Sequence[str]) -> list[dict]:
|
||||
out = []
|
||||
for key in keys:
|
||||
values = [float(r[key]) for r in rows if isinstance(r.get(key), (int, float)) and math.isfinite(float(r[key]))]
|
||||
if len(values) < 1:
|
||||
continue
|
||||
arr = np.asarray(values)
|
||||
entry: dict[str, object] = {
|
||||
"parameter": key,
|
||||
"n": len(arr),
|
||||
"mean": float(arr.mean()),
|
||||
"sd": float(arr.std(ddof=1)) if len(arr) > 1 else float("nan"),
|
||||
"cv_pct": float(100.0 * arr.std(ddof=1) / arr.mean()) if len(arr) > 1 and arr.mean() else float("nan"),
|
||||
"median": float(np.median(arr)),
|
||||
"min": float(arr.min()),
|
||||
"max": float(arr.max()),
|
||||
}
|
||||
if np.all(arr > 0):
|
||||
logs = np.log(arr)
|
||||
entry["geo_mean"] = float(np.exp(logs.mean()))
|
||||
entry["geo_cv_pct"] = (
|
||||
float(100.0 * math.sqrt(math.exp(float(logs.var(ddof=1))) - 1.0)) if len(arr) > 1 else float("nan")
|
||||
)
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def _partial_spec(text: str) -> tuple[float, float]:
|
||||
try:
|
||||
start, end = text.split("-", 1)
|
||||
lo, hi = float(start), float(end)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"partial AUC must look like 0-24, got {text!r}") from exc
|
||||
if hi <= lo:
|
||||
raise argparse.ArgumentTypeError(f"partial AUC end must exceed start, got {text!r}")
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _window_spec(text: str) -> tuple[float, float]:
|
||||
return _partial_spec(text)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Non-compartmental analysis with explicit lambda_z, BLQ, and trapezoidal choices.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("-i", "--input", required=True, help="concentration-time table (CSV/TSV, or - for stdin)")
|
||||
parser.add_argument("--dose", type=float, help="dose given to every subject")
|
||||
parser.add_argument("--dose-column", default="dose", help="per-subject dose column (default: dose)")
|
||||
parser.add_argument("--subject-column", default="id")
|
||||
parser.add_argument("--time-column", default="time")
|
||||
parser.add_argument("--conc-column", default="conc")
|
||||
parser.add_argument(
|
||||
"--route",
|
||||
choices=sorted(ROUTES),
|
||||
default="extravascular",
|
||||
help="route; decides whether CL/Vz are apparent (/F) and whether Vss is reported",
|
||||
)
|
||||
parser.add_argument("--tinf", type=float, help="infusion duration, for --route iv-infusion")
|
||||
parser.add_argument("--tau", type=float, help="dosing interval; requests steady-state parameters")
|
||||
parser.add_argument(
|
||||
"--auc-method",
|
||||
choices=("linup-logdown", "linear", "log"),
|
||||
default="linup-logdown",
|
||||
help="trapezoidal rule (default: linup-logdown, the usual regulatory choice)",
|
||||
)
|
||||
parser.add_argument("--lloq", type=float, help="lower limit of quantification")
|
||||
parser.add_argument(
|
||||
"--blq-rule",
|
||||
choices=("zero", "half-lloq", "missing"),
|
||||
default="zero",
|
||||
help="how BLQ samples enter the AUC (default: zero)",
|
||||
)
|
||||
parser.add_argument("--lambda-z-points", type=int, default=3, help="minimum points in the terminal fit (default: 3)")
|
||||
parser.add_argument("--lambda-z-window", type=_window_spec, help="force the terminal window, e.g. 8-48")
|
||||
parser.add_argument("--partial-auc", type=_partial_spec, action="append", default=[], help="e.g. --partial-auc 0-24")
|
||||
parser.add_argument("--min-r2-adj", type=float, default=0.80, help="flag terminal fits below this (default: 0.80)")
|
||||
parser.add_argument("--max-extrap", type=float, default=20.0, help="flag %%AUC extrapolated above this (default: 20)")
|
||||
parser.add_argument("--min-span", type=float, default=2.0, help="flag lambda_z spans below this many half-lives (default: 2)")
|
||||
parser.add_argument("--no-summary", action="store_true", help="per-subject table only")
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.route == "iv-infusion" and args.tinf is None:
|
||||
print("error: --route iv-infusion needs --tinf (or a tinf column)", file=sys.stderr)
|
||||
return EXIT_INPUT
|
||||
|
||||
profiles = load_profiles(args)
|
||||
report = Report()
|
||||
report.note(f"route: {ROUTES[args.route]}")
|
||||
report.note(f"trapezoidal rule: {args.auc_method}; BLQ rule: {args.blq_rule}")
|
||||
report.note(
|
||||
"AUCinf_obs uses the observed Clast; AUCinf_pred uses the value predicted by the "
|
||||
"lambda_z fit. Report which one you used - they are not interchangeable."
|
||||
)
|
||||
|
||||
rows, diagnostics, all_findings = [], [], []
|
||||
for profile in profiles:
|
||||
row, lz, findings = analyse(profile, args)
|
||||
rows.append(row)
|
||||
diagnostics.append(
|
||||
{
|
||||
"id": profile.subject,
|
||||
"lambda_z": lz.lam,
|
||||
"t_half": lz.half_life,
|
||||
"n_points": lz.n_points,
|
||||
"window_start": lz.t_first,
|
||||
"window_end": lz.t_last,
|
||||
"r2": lz.r2,
|
||||
"r2_adj": lz.r2_adj,
|
||||
"clast_pred": lz.clast_pred,
|
||||
"basis": lz.reason,
|
||||
}
|
||||
)
|
||||
all_findings.extend(findings)
|
||||
|
||||
report.table("per-subject parameters", rows)
|
||||
report.table("lambda_z diagnostics", diagnostics)
|
||||
|
||||
if not args.no_summary and len(rows) > 1:
|
||||
candidates = [
|
||||
"cmax",
|
||||
"tmax",
|
||||
"auc_last",
|
||||
"auc_inf_obs",
|
||||
"t_half",
|
||||
"cl_f",
|
||||
"cl",
|
||||
"vz_f",
|
||||
"vz",
|
||||
"vss",
|
||||
"mrt",
|
||||
"auc_tau",
|
||||
"cavg_ss",
|
||||
]
|
||||
keys = [k for k in candidates if any(k in r for r in rows)]
|
||||
keys += [k for k in rows[0] if k.startswith("auc_") and k not in keys and k not in {"auc_last", "auc_tau"}]
|
||||
report.table("summary statistics", summarise(rows, keys))
|
||||
report.note(
|
||||
"Exposure metrics (AUC, Cmax) are conventionally summarised as geometric mean with "
|
||||
"geometric CV%; Tmax as median and range. Arithmetic statistics are shown alongside."
|
||||
)
|
||||
|
||||
for finding in all_findings:
|
||||
report.finding(finding)
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
323
skills/pkpd-modeling/scripts/simulate_regimen.py
Normal file
323
skills/pkpd-modeling/scripts/simulate_regimen.py
Normal file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simulate dosing regimens, with or without between-subject variability.
|
||||
|
||||
Deterministic simulation answers "what does the typical patient look like".
|
||||
That is almost never the question. The question is what fraction of patients
|
||||
stay inside the therapeutic window, and the two answers differ by a lot: a
|
||||
regimen whose typical trough sits exactly at the target leaves roughly half the
|
||||
population below it.
|
||||
|
||||
python3 simulate_regimen.py --cl 5 --v 40 --dose 500 --interval 12 --n-doses 10
|
||||
python3 simulate_regimen.py --cl 5 --v 40 --q 8 --v2 60 --dose 500 --interval 8 \\
|
||||
--route oral --ka 1.2 --f 0.7 --steady-state
|
||||
python3 simulate_regimen.py --cl 5 --v 40 --dose 500 --interval 12 --simulate 2000 \\
|
||||
--omega-cl 0.35 --omega-v 0.25 --target-trough 2.0
|
||||
python3 simulate_regimen.py --vmax 200 --km 5 --v 40 --dose 300 --interval 24 \\
|
||||
--n-doses 7 --nonlinear
|
||||
python3 simulate_regimen.py --compare "500@12,750@8,1000@24" --cl 5 --v 40
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import InputError, Report, add_format_argument, main_wrapper
|
||||
|
||||
from _models import (
|
||||
build_regimen,
|
||||
disposition,
|
||||
simulate_linear,
|
||||
simulate_michaelis_menten,
|
||||
steady_state_metrics,
|
||||
)
|
||||
|
||||
|
||||
def summarise_interval(evaluate, start: float, end: float, points: int = 4001) -> dict[str, float]:
|
||||
"""Summarise one dosing interval on its own dedicated grid.
|
||||
|
||||
The interval is closed at the start (immediately *after* that dose) and
|
||||
open at the end (immediately *before* the next one). Subsetting a shared
|
||||
grid instead gets both ends wrong: a point landing exactly on the next dose
|
||||
reports that dose's peak as this interval's Cmax, and a point landing on
|
||||
this interval's own dose can be read as the previous trough.
|
||||
"""
|
||||
window_t = np.linspace(start, end, points)
|
||||
window_t[-1] = end - 1e-9 # pre-dose trough, not the next dose's peak
|
||||
window_c = np.asarray(evaluate(window_t), dtype=float)
|
||||
auc = float(np.trapezoid(window_c, window_t))
|
||||
cmax = float(window_c.max())
|
||||
cmin = float(window_c.min())
|
||||
cavg = auc / (end - start)
|
||||
return {
|
||||
"auc_tau": auc,
|
||||
"cmax": cmax,
|
||||
"tmax": float(window_t[int(np.argmax(window_c))] - start),
|
||||
"cmin": cmin,
|
||||
"cavg": cavg,
|
||||
"peak_trough_fluctuation_pct": 100.0 * (cmax - cmin) / cavg if cavg else float("nan"),
|
||||
"swing": (cmax - cmin) / cmin if cmin > 0 else float("nan"),
|
||||
}
|
||||
|
||||
|
||||
def _regimen_spec(text: str) -> tuple[float, float]:
|
||||
if "@" not in text:
|
||||
raise argparse.ArgumentTypeError(f"regimen must look like 500@12 (dose@interval), got {text!r}")
|
||||
dose_text, interval_text = text.split("@", 1)
|
||||
try:
|
||||
return float(dose_text), float(interval_text)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"could not parse regimen {text!r}") from exc
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Simulate single- and multiple-dose regimens, deterministically or with IIV.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--cl", type=float, help="clearance")
|
||||
parser.add_argument("--v", type=float, required=True, help="central volume")
|
||||
parser.add_argument("--q", type=float, action="append", default=[], help="intercompartmental clearance (repeatable)")
|
||||
parser.add_argument("--v2", type=float, action="append", default=[], help="peripheral volume (repeatable, pairs with --q)")
|
||||
parser.add_argument("--route", choices=("iv-bolus", "iv-infusion", "oral"), default="iv-bolus")
|
||||
parser.add_argument("--ka", type=float, help="absorption rate constant for --route oral")
|
||||
parser.add_argument("--f", type=float, default=1.0, help="bioavailable fraction (default: 1.0)")
|
||||
parser.add_argument("--tlag", type=float, default=0.0)
|
||||
parser.add_argument("--tinf", type=float, default=0.0, help="infusion duration for --route iv-infusion")
|
||||
|
||||
parser.add_argument("--dose", type=float, help="dose amount")
|
||||
parser.add_argument("--interval", type=float, help="dosing interval")
|
||||
parser.add_argument("--n-doses", type=int, default=1)
|
||||
parser.add_argument("--loading", type=float, help="different first dose")
|
||||
parser.add_argument("--steady-state", action="store_true", help="report closed-form steady-state metrics")
|
||||
parser.add_argument("--compare", help="comma-separated dose@interval regimens to compare")
|
||||
|
||||
parser.add_argument("--nonlinear", action="store_true", help="Michaelis-Menten elimination instead of linear")
|
||||
parser.add_argument("--vmax", type=float, help="maximum elimination rate (amount/time) for --nonlinear")
|
||||
parser.add_argument("--km", type=float, help="Michaelis constant (concentration) for --nonlinear")
|
||||
|
||||
parser.add_argument("--simulate", type=int, help="number of virtual subjects for a Monte Carlo simulation")
|
||||
parser.add_argument("--omega-cl", type=float, default=0.0, help="between-subject CV of clearance")
|
||||
parser.add_argument("--omega-v", type=float, default=0.0, help="between-subject CV of volume")
|
||||
parser.add_argument("--seed", type=int, default=20260727)
|
||||
parser.add_argument("--target-trough", type=float, help="report the fraction of subjects above this trough")
|
||||
parser.add_argument("--target-peak-below", type=float, help="report the fraction of subjects with a peak below this")
|
||||
parser.add_argument("--target-auc", type=float, help="report the fraction of subjects above this AUC over the interval")
|
||||
|
||||
parser.add_argument("--profile", action="store_true", help="also emit the concentration-time profile")
|
||||
parser.add_argument("--profile-points", type=int, default=25)
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if len(args.q) != len(args.v2):
|
||||
raise InputError(f"{len(args.q)} --q values but {len(args.v2)} --v2 values; they pair up")
|
||||
if args.route == "oral" and args.ka is None:
|
||||
raise InputError("--route oral needs --ka")
|
||||
if args.route == "iv-infusion" and args.tinf <= 0:
|
||||
raise InputError("--route iv-infusion needs a positive --tinf")
|
||||
if args.nonlinear and (args.vmax is None or args.km is None):
|
||||
raise InputError("--nonlinear needs --vmax and --km")
|
||||
if not args.nonlinear and args.cl is None:
|
||||
raise InputError("--cl is required for a linear model")
|
||||
|
||||
report = Report()
|
||||
|
||||
# ---- regimen comparison
|
||||
if args.compare:
|
||||
regimens = [_regimen_spec(chunk.strip()) for chunk in args.compare.split(",")]
|
||||
if args.nonlinear:
|
||||
raise InputError("--compare currently supports linear models only")
|
||||
disp = disposition(args.cl, args.v, args.q, args.v2)
|
||||
rows = []
|
||||
for dose, interval in regimens:
|
||||
metrics = steady_state_metrics(disp, dose, interval, f=args.f)
|
||||
rows.append(
|
||||
{
|
||||
"regimen": f"{dose:g} q{interval:g}h",
|
||||
"daily_dose": dose * 24.0 / interval,
|
||||
"auc_tau_ss": metrics["auc_tau_ss"],
|
||||
"cavg_ss": metrics["cavg_ss"],
|
||||
"cmax_ss_bolus": metrics["cmax_ss_bolus"],
|
||||
"cmin_ss_bolus": metrics["cmin_ss_bolus"],
|
||||
"ptf_pct": metrics["peak_trough_fluctuation_pct"],
|
||||
"accumulation_ratio_auc": metrics["accumulation_ratio_auc"],
|
||||
}
|
||||
)
|
||||
report.table("steady-state comparison", rows)
|
||||
report.note(
|
||||
"Cmax and Cmin here are the bolus-equivalent extremes. Average concentration depends only "
|
||||
"on the daily dose and clearance, so regimens matched on daily dose differ in fluctuation, "
|
||||
"not in Cavg."
|
||||
)
|
||||
return report.emit(args.format)
|
||||
|
||||
if args.dose is None or (args.n_doses > 1 and not args.interval):
|
||||
raise InputError("--dose is required, and a multiple-dose regimen needs --interval")
|
||||
interval = args.interval or 0.0
|
||||
|
||||
regimen = build_regimen(
|
||||
args.dose,
|
||||
interval=args.interval,
|
||||
n_doses=args.n_doses,
|
||||
duration=args.tinf,
|
||||
route="oral" if args.route == "oral" else "iv",
|
||||
loading=args.loading,
|
||||
)
|
||||
horizon = interval * args.n_doses if args.n_doses > 1 else max(interval, 1.0) * 10
|
||||
times = np.linspace(0.0, horizon, max(200 * max(args.n_doses, 1), 400) + 1)
|
||||
|
||||
# ---- deterministic profile
|
||||
if args.nonlinear:
|
||||
def evaluate(grid: np.ndarray) -> np.ndarray:
|
||||
return simulate_michaelis_menten(
|
||||
grid,
|
||||
regimen,
|
||||
vmax=args.vmax,
|
||||
km=args.km,
|
||||
v1=args.v,
|
||||
q=args.q,
|
||||
vp=args.v2,
|
||||
ka=args.ka if args.route == "oral" else None,
|
||||
f=args.f,
|
||||
)
|
||||
|
||||
conc = evaluate(times)
|
||||
report.note(
|
||||
"Michaelis-Menten elimination: exposure is not proportional to dose and superposition does "
|
||||
"not apply, so multiple-dose behaviour cannot be inferred from a single-dose profile."
|
||||
)
|
||||
cl_at_steady = args.vmax / (args.km + float(conc.max())) if conc.size else float("nan")
|
||||
report.scalar("clearance_at_peak_concentration", cl_at_steady)
|
||||
else:
|
||||
disp = disposition(args.cl, args.v, args.q, args.v2)
|
||||
|
||||
def evaluate(grid: np.ndarray) -> np.ndarray:
|
||||
return simulate_linear(grid, regimen, disp, ka=args.ka, f=args.f, tlag=args.tlag)
|
||||
|
||||
conc = evaluate(times)
|
||||
report.scalar("terminal_half_life", disp.terminal_half_life)
|
||||
report.scalar("vss", disp.vss)
|
||||
report.scalar("mrt_iv", disp.mrt_iv)
|
||||
for i, half in enumerate(disp.half_lives, start=1):
|
||||
report.scalar(f"t_half_phase{i}", float(half))
|
||||
|
||||
if args.n_doses > 1:
|
||||
last = summarise_interval(evaluate, (args.n_doses - 1) * interval, args.n_doses * interval)
|
||||
first = summarise_interval(evaluate, 0.0, interval)
|
||||
report.table(
|
||||
"dosing-interval summary",
|
||||
[
|
||||
{"interval": "first", **first},
|
||||
{"interval": f"last (#{args.n_doses})", **last},
|
||||
{
|
||||
"interval": "accumulation (last/first)",
|
||||
"auc_tau": last["auc_tau"] / first["auc_tau"] if first["auc_tau"] else float("nan"),
|
||||
"cmax": last["cmax"] / first["cmax"] if first["cmax"] else float("nan"),
|
||||
"cmin": last["cmin"] / first["cmin"] if first["cmin"] > 0 else float("nan"),
|
||||
"cavg": last["cavg"] / first["cavg"] if first["cavg"] else float("nan"),
|
||||
},
|
||||
],
|
||||
)
|
||||
else:
|
||||
report.scalar("cmax", float(conc.max()))
|
||||
report.scalar("tmax", float(times[int(np.argmax(conc))]))
|
||||
report.scalar("auc_over_horizon", float(np.trapezoid(conc, times)))
|
||||
|
||||
if args.steady_state and not args.nonlinear:
|
||||
metrics = steady_state_metrics(disposition(args.cl, args.v, args.q, args.v2), args.dose, interval, f=args.f)
|
||||
report.table("closed-form steady state", [{"metric": k, "value": v} for k, v in metrics.items()])
|
||||
if args.n_doses > 1 and args.n_doses * interval < metrics["time_to_95pct_ss"]:
|
||||
report.finding(
|
||||
f"the simulation covers {args.n_doses * interval:g} time units but 95% of steady state "
|
||||
f"is not reached until {metrics['time_to_95pct_ss']:.1f}; the last interval shown is not "
|
||||
"steady state"
|
||||
)
|
||||
|
||||
# ---- Monte Carlo
|
||||
if args.simulate:
|
||||
if args.nonlinear:
|
||||
raise InputError("--simulate currently supports linear models only")
|
||||
if args.omega_cl <= 0 and args.omega_v <= 0:
|
||||
raise InputError("--simulate needs at least one of --omega-cl or --omega-v above zero")
|
||||
rng = np.random.default_rng(args.seed)
|
||||
n = args.simulate
|
||||
sd_cl = math.sqrt(math.log(1.0 + args.omega_cl**2)) if args.omega_cl > 0 else 0.0
|
||||
sd_v = math.sqrt(math.log(1.0 + args.omega_v**2)) if args.omega_v > 0 else 0.0
|
||||
cl_draws = args.cl * np.exp(rng.normal(0.0, sd_cl, n)) if sd_cl else np.full(n, args.cl)
|
||||
v_draws = args.v * np.exp(rng.normal(0.0, sd_v, n)) if sd_v else np.full(n, args.v)
|
||||
|
||||
start = (args.n_doses - 1) * interval if args.n_doses > 1 else 0.0
|
||||
end = args.n_doses * interval if args.n_doses > 1 else horizon
|
||||
window = np.linspace(start, end, 401)
|
||||
troughs = np.empty(n)
|
||||
peaks = np.empty(n)
|
||||
aucs = np.empty(n)
|
||||
for i in range(n):
|
||||
disp_i = disposition(cl_draws[i], v_draws[i], args.q, args.v2)
|
||||
profile = simulate_linear(window, regimen, disp_i, ka=args.ka, f=args.f, tlag=args.tlag)
|
||||
troughs[i] = profile[-1] if args.n_doses > 1 else profile.min()
|
||||
peaks[i] = profile.max()
|
||||
aucs[i] = float(np.trapezoid(profile, window))
|
||||
|
||||
def percentiles(values: np.ndarray, label: str) -> dict:
|
||||
return {
|
||||
"metric": label,
|
||||
"p5": float(np.percentile(values, 5)),
|
||||
"p25": float(np.percentile(values, 25)),
|
||||
"median": float(np.percentile(values, 50)),
|
||||
"p75": float(np.percentile(values, 75)),
|
||||
"p95": float(np.percentile(values, 95)),
|
||||
"geo_mean": float(np.exp(np.mean(np.log(np.maximum(values, 1e-12))))),
|
||||
}
|
||||
|
||||
report.table(
|
||||
f"simulated population (n = {n})",
|
||||
[percentiles(peaks, "peak"), percentiles(troughs, "trough"), percentiles(aucs, "auc_over_interval")],
|
||||
)
|
||||
|
||||
attainment = []
|
||||
if args.target_trough is not None:
|
||||
fraction = float(np.mean(troughs >= args.target_trough))
|
||||
attainment.append({"target": f"trough >= {args.target_trough:g}", "fraction_attaining": fraction})
|
||||
if args.target_peak_below is not None:
|
||||
fraction = float(np.mean(peaks <= args.target_peak_below))
|
||||
attainment.append({"target": f"peak <= {args.target_peak_below:g}", "fraction_attaining": fraction})
|
||||
if args.target_auc is not None:
|
||||
fraction = float(np.mean(aucs >= args.target_auc))
|
||||
attainment.append({"target": f"AUC >= {args.target_auc:g}", "fraction_attaining": fraction})
|
||||
if args.target_trough is not None and args.target_peak_below is not None:
|
||||
both = float(np.mean((troughs >= args.target_trough) & (peaks <= args.target_peak_below)))
|
||||
attainment.append({"target": "both trough and peak targets", "fraction_attaining": both})
|
||||
if attainment:
|
||||
report.table("probability of target attainment", attainment)
|
||||
for row in attainment:
|
||||
if row["fraction_attaining"] < 0.9:
|
||||
report.finding(
|
||||
f"{row['target']}: only {100 * row['fraction_attaining']:.1f}% of simulated "
|
||||
"subjects attain this target"
|
||||
)
|
||||
report.note(
|
||||
"Between-subject variability only. Residual/assay variability and between-occasion "
|
||||
"variability would widen these intervals further, so the attainment fractions here are "
|
||||
"optimistic."
|
||||
)
|
||||
|
||||
if args.profile:
|
||||
step = max(len(times) // max(args.profile_points, 1), 1)
|
||||
report.table(
|
||||
"concentration-time profile",
|
||||
[{"time": float(t), "conc": float(c)} for t, c in zip(times[::step], conc[::step])],
|
||||
)
|
||||
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
312
skills/pkpd-modeling/scripts/tdm_bayes.py
Normal file
312
skills/pkpd-modeling/scripts/tdm_bayes.py
Normal file
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maximum a posteriori Bayesian forecasting for therapeutic drug monitoring.
|
||||
|
||||
Given a published population model and one or two measured concentrations,
|
||||
MAP estimation produces individual parameters that shrink towards the
|
||||
population when the data are uninformative and follow the data when they are
|
||||
not. That property is exactly why it beats the alternatives clinicians reach
|
||||
for: a single trough interpreted with population parameters ignores the
|
||||
individual, and log-linear regression on two points ignores the population and
|
||||
falls apart when a level is drawn during distribution.
|
||||
|
||||
python3 tdm_bayes.py --model vancomycin-adult --weight 80 --crcl 75 \\
|
||||
--dose 1500 --interval 12 --level 18.2@11.5 --level 42@1.5
|
||||
|
||||
python3 tdm_bayes.py --custom --cl-pop 4.2 --v-pop 45 --omega-cl 0.30 \\
|
||||
--omega-v 0.25 --prop-error 0.12 --dose 1000 --interval 8 --level 12@7.5
|
||||
|
||||
Each ``--level`` is ``concentration@time-after-the-most-recent-dose``. The
|
||||
regimen is assumed to have been given long enough to be at steady state unless
|
||||
``--doses-given`` says otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from _common import InputError, Report, add_format_argument, main_wrapper
|
||||
|
||||
from _models import build_regimen, disposition, simulate_linear
|
||||
|
||||
try:
|
||||
from scipy.optimize import minimize
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit("tdm_bayes.py needs scipy: pip install scipy") from exc
|
||||
|
||||
|
||||
@dataclass
|
||||
class PopulationModel:
|
||||
name: str
|
||||
description: str
|
||||
cl_pop: float # L/h at the reference covariates
|
||||
v_pop: float # L
|
||||
omega_cl: float # apparent CV of between-subject variability
|
||||
omega_v: float
|
||||
prop_error: float
|
||||
add_error: float
|
||||
target: str
|
||||
reference: str
|
||||
|
||||
def individualise(self, weight: float | None, crcl: float | None) -> tuple[float, float]:
|
||||
return self.cl_pop, self.v_pop
|
||||
|
||||
|
||||
@dataclass
|
||||
class VancomycinAdult(PopulationModel):
|
||||
def individualise(self, weight: float | None, crcl: float | None) -> tuple[float, float]:
|
||||
if weight is None or crcl is None:
|
||||
raise InputError("the vancomycin model needs --weight and --crcl")
|
||||
# A conventional two-covariate adult parameterisation: clearance
|
||||
# proportional to creatinine clearance, volume proportional to weight.
|
||||
return 0.048 * crcl, 0.72 * weight
|
||||
|
||||
|
||||
LIBRARY: dict[str, PopulationModel] = {
|
||||
"vancomycin-adult": VancomycinAdult(
|
||||
name="vancomycin-adult",
|
||||
description="Adult vancomycin, one compartment, CL from creatinine clearance and V from weight",
|
||||
cl_pop=3.6,
|
||||
v_pop=58.0,
|
||||
omega_cl=0.27,
|
||||
omega_v=0.25,
|
||||
prop_error=0.15,
|
||||
add_error=1.0,
|
||||
target="AUC24/MIC 400-600 with an assumed MIC of 1 mg/L (2020 consensus guideline)",
|
||||
reference="illustrative parameterisation - substitute the model validated for your population",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def map_estimate(
|
||||
model: PopulationModel,
|
||||
cl_prior: float,
|
||||
v_prior: float,
|
||||
dose: float,
|
||||
interval: float,
|
||||
n_doses: int,
|
||||
infusion: float,
|
||||
levels: list[tuple[float, float]],
|
||||
) -> dict:
|
||||
"""Minimise the MAP objective: weighted residuals plus the prior penalty.
|
||||
|
||||
The objective is ``sum((obs - pred)^2 / var_i) + sum(eta_k^2 / omega_k^2)``.
|
||||
The second term is what makes this Bayesian rather than a two-point fit,
|
||||
and it is the reason a single trough can still yield a usable individual
|
||||
estimate.
|
||||
"""
|
||||
last_dose_time = (n_doses - 1) * interval
|
||||
|
||||
def predict(eta: np.ndarray, times: np.ndarray) -> np.ndarray:
|
||||
cl = cl_prior * math.exp(eta[0])
|
||||
v = v_prior * math.exp(eta[1])
|
||||
disp = disposition(cl, v)
|
||||
regimen = build_regimen(dose, interval=interval, n_doses=n_doses, duration=infusion)
|
||||
return simulate_linear(times + last_dose_time, regimen, disp)
|
||||
|
||||
times = np.asarray([t for _, t in levels], dtype=float)
|
||||
observed = np.asarray([c for c, _ in levels], dtype=float)
|
||||
|
||||
def objective(eta: np.ndarray) -> float:
|
||||
predicted = predict(eta, times)
|
||||
variance = (model.prop_error * predicted) ** 2 + model.add_error**2
|
||||
residual = float(np.sum((observed - predicted) ** 2 / variance + np.log(variance)))
|
||||
prior = float((eta[0] / model.omega_cl) ** 2 + (eta[1] / model.omega_v) ** 2)
|
||||
return residual + prior
|
||||
|
||||
best = minimize(objective, np.zeros(2), method="Nelder-Mead", options={"xatol": 1e-8, "fatol": 1e-10, "maxiter": 4000})
|
||||
eta = best.x
|
||||
cl = cl_prior * math.exp(eta[0])
|
||||
v = v_prior * math.exp(eta[1])
|
||||
predicted = predict(eta, times)
|
||||
return {
|
||||
"cl_individual": cl,
|
||||
"v_individual": v,
|
||||
"eta_cl": float(eta[0]),
|
||||
"eta_v": float(eta[1]),
|
||||
"cl_fold_vs_population": cl / cl_prior,
|
||||
"v_fold_vs_population": v / v_prior,
|
||||
"half_life": math.log(2.0) * v / cl,
|
||||
"objective": float(best.fun),
|
||||
"predictions": predicted,
|
||||
"observed": observed,
|
||||
"times": times,
|
||||
"converged": bool(best.success),
|
||||
}
|
||||
|
||||
|
||||
def exposure_metrics(cl: float, v: float, dose: float, interval: float, infusion: float) -> dict:
|
||||
disp = disposition(cl, v)
|
||||
regimen = build_regimen(dose, interval=interval, n_doses=60, duration=infusion)
|
||||
grid = np.linspace(59 * interval, 60 * interval, 2001)
|
||||
profile = simulate_linear(grid, regimen, disp)
|
||||
auc_tau = float(np.trapezoid(profile, grid))
|
||||
per_day = 24.0 / interval
|
||||
return {
|
||||
"auc_tau": auc_tau,
|
||||
"auc_24h": auc_tau * per_day,
|
||||
"cmax_ss": float(profile.max()),
|
||||
"cmin_ss": float(profile.min()),
|
||||
"cavg_ss": auc_tau / interval,
|
||||
}
|
||||
|
||||
|
||||
def recommend_dose(cl: float, target_auc24: float) -> float:
|
||||
"""Total daily dose to hit a target AUC24: linear PK makes this exact."""
|
||||
return target_auc24 * cl
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- CLI
|
||||
|
||||
|
||||
def _level(text: str) -> tuple[float, float]:
|
||||
if "@" not in text:
|
||||
raise argparse.ArgumentTypeError(f"--level must look like 18.2@11.5 (conc@time), got {text!r}")
|
||||
conc_text, time_text = text.split("@", 1)
|
||||
try:
|
||||
conc, time = float(conc_text), float(time_text)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"could not parse --level {text!r}") from exc
|
||||
if conc <= 0 or time < 0:
|
||||
raise argparse.ArgumentTypeError("concentration must be positive and time non-negative")
|
||||
return conc, time
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MAP Bayesian individualisation of a population PK model from measured levels.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--model", choices=sorted(LIBRARY), help="a bundled population model")
|
||||
parser.add_argument("--custom", action="store_true", help="supply population parameters directly")
|
||||
parser.add_argument("--cl-pop", type=float)
|
||||
parser.add_argument("--v-pop", type=float)
|
||||
parser.add_argument("--omega-cl", type=float, default=0.30)
|
||||
parser.add_argument("--omega-v", type=float, default=0.25)
|
||||
parser.add_argument("--prop-error", type=float, default=0.15)
|
||||
parser.add_argument("--add-error", type=float, default=0.5)
|
||||
parser.add_argument("--weight", type=float)
|
||||
parser.add_argument("--crcl", type=float, help="creatinine clearance, mL/min")
|
||||
parser.add_argument("--dose", type=float, required=True)
|
||||
parser.add_argument("--interval", type=float, required=True)
|
||||
parser.add_argument("--infusion", type=float, default=1.0, help="infusion duration, h (default: 1)")
|
||||
parser.add_argument("--doses-given", type=int, default=20, help="doses administered before the levels (default: 20)")
|
||||
parser.add_argument("--level", type=_level, action="append", required=True, help="conc@time-after-last-dose")
|
||||
parser.add_argument("--target-auc24", type=float, help="target AUC24 for a dose recommendation")
|
||||
add_format_argument(parser)
|
||||
return parser
|
||||
|
||||
|
||||
def run(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if bool(args.model) == bool(args.custom):
|
||||
raise InputError("choose either --model NAME or --custom")
|
||||
if args.custom:
|
||||
if args.cl_pop is None or args.v_pop is None:
|
||||
raise InputError("--custom needs --cl-pop and --v-pop")
|
||||
model = PopulationModel(
|
||||
name="custom",
|
||||
description="user-supplied population parameters",
|
||||
cl_pop=args.cl_pop,
|
||||
v_pop=args.v_pop,
|
||||
omega_cl=args.omega_cl,
|
||||
omega_v=args.omega_v,
|
||||
prop_error=args.prop_error,
|
||||
add_error=args.add_error,
|
||||
target="",
|
||||
reference="user supplied",
|
||||
)
|
||||
cl_prior, v_prior = args.cl_pop, args.v_pop
|
||||
else:
|
||||
model = LIBRARY[args.model]
|
||||
cl_prior, v_prior = model.individualise(args.weight, args.crcl)
|
||||
|
||||
if args.interval <= 0 or args.dose <= 0:
|
||||
raise InputError("dose and interval must be positive")
|
||||
if args.infusion >= args.interval:
|
||||
raise InputError("infusion duration must be shorter than the dosing interval")
|
||||
for _, time in args.level:
|
||||
if time > args.interval:
|
||||
raise InputError(f"a level at {time} h is beyond the {args.interval} h dosing interval")
|
||||
|
||||
result = map_estimate(
|
||||
model, cl_prior, v_prior, args.dose, args.interval, args.doses_given, args.infusion, args.level
|
||||
)
|
||||
|
||||
report = Report()
|
||||
report.scalar("model", model.name)
|
||||
report.scalar("population_cl", cl_prior)
|
||||
report.scalar("population_v", v_prior)
|
||||
report.scalar("individual_cl", result["cl_individual"])
|
||||
report.scalar("individual_v", result["v_individual"])
|
||||
report.scalar("eta_cl", result["eta_cl"])
|
||||
report.scalar("eta_v", result["eta_v"])
|
||||
report.scalar("individual_half_life", result["half_life"])
|
||||
|
||||
report.table(
|
||||
"observed vs individual prediction",
|
||||
[
|
||||
{
|
||||
"time_after_dose": float(t),
|
||||
"observed": float(o),
|
||||
"predicted": float(p),
|
||||
"residual": float(o - p),
|
||||
"pct_error": 100.0 * (o - p) / o if o else float("nan"),
|
||||
}
|
||||
for t, o, p in zip(result["times"], result["observed"], result["predictions"])
|
||||
],
|
||||
)
|
||||
|
||||
current = exposure_metrics(result["cl_individual"], result["v_individual"], args.dose, args.interval, args.infusion)
|
||||
report.table("predicted exposure on the current regimen", [{"metric": k, "value": v} for k, v in current.items()])
|
||||
|
||||
if args.target_auc24:
|
||||
daily = recommend_dose(result["cl_individual"], args.target_auc24)
|
||||
per_dose = daily / (24.0 / args.interval)
|
||||
report.scalar("target_auc24", args.target_auc24)
|
||||
report.scalar("recommended_total_daily_dose", daily)
|
||||
report.scalar("recommended_dose_per_interval", per_dose)
|
||||
report.note(
|
||||
"The dose recommendation assumes linear pharmacokinetics, so AUC scales exactly with dose. "
|
||||
"It says nothing about whether the target itself is right for this patient."
|
||||
)
|
||||
|
||||
if abs(result["eta_cl"]) > 2 * model.omega_cl:
|
||||
report.finding(
|
||||
f"the individual clearance is {result['cl_fold_vs_population']:.2f}-fold the population "
|
||||
f"value (eta = {result['eta_cl']:+.2f}, more than 2 omega). Either this patient is genuinely "
|
||||
"atypical, or a level was drawn at a mis-recorded time, or the population model does not "
|
||||
"apply to them. Check the sampling times before acting on the estimate."
|
||||
)
|
||||
if len(args.level) == 1:
|
||||
report.finding(
|
||||
"a single concentration cannot separate clearance from volume; the estimate of whichever "
|
||||
"parameter the sample is uninformative about has simply been pulled back to the population value"
|
||||
)
|
||||
trough_only = all(time > 0.7 * args.interval for _, time in args.level)
|
||||
if trough_only and len(args.level) > 1:
|
||||
report.note(
|
||||
"all levels are late in the interval, so volume is weakly identified. A peak (1-2 h after "
|
||||
"the end of the infusion) plus a trough constrains both parameters."
|
||||
)
|
||||
if not result["converged"]:
|
||||
report.finding("the MAP optimiser did not converge; treat the individual estimates as unreliable")
|
||||
|
||||
if model.target:
|
||||
report.note(f"target: {model.target}")
|
||||
report.note(f"population model provenance: {model.reference}")
|
||||
report.note(
|
||||
"This is a modelling aid, not a dosing decision. Any change to a patient's regimen is the "
|
||||
"responsibility of the treating clinician and depends on the clinical picture, the assay, the "
|
||||
"organism, and local protocol."
|
||||
)
|
||||
return report.emit(args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main_wrapper(run))
|
||||
8
tests/pkpd-modeling/fixtures/broken_nmdata.csv
Normal file
8
tests/pkpd-modeling/fixtures/broken_nmdata.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
ID,TIME,AMT,DV,EVID,MDV,CMT,WT,CRCL
|
||||
1,0,100,.,1,1,1,70,90
|
||||
1,1,.,5.2,0,0,1,70,90
|
||||
1,4,.,BLQ,0,0,1,70,90
|
||||
1,2,.,3.1,0,0,1,70,85
|
||||
2,0,.,1.5,0,0,1,,80
|
||||
3,0,100,.,1,1,1,65,70
|
||||
3,0,.,9.9,0,0,1,65,70
|
||||
|
11
tests/pkpd-modeling/fixtures/clean_nmdata.csv
Normal file
11
tests/pkpd-modeling/fixtures/clean_nmdata.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
ID,TIME,AMT,DV,EVID,MDV,CMT,WT
|
||||
1,0,100,.,1,1,1,70
|
||||
1,0.5,.,8.10,0,0,1,70
|
||||
1,1,.,7.15,0,0,1,70
|
||||
1,4,.,4.11,0,0,1,70
|
||||
1,12,.,1.12,0,0,1,70
|
||||
2,0,150,.,1,1,1,82
|
||||
2,0.5,.,10.44,0,0,1,82
|
||||
2,1,.,9.21,0,0,1,82
|
||||
2,4,.,5.30,0,0,1,82
|
||||
2,12,.,1.44,0,0,1,82
|
||||
|
861
tests/pkpd-modeling/test_scripts.py
Normal file
861
tests/pkpd-modeling/test_scripts.py
Normal file
@@ -0,0 +1,861 @@
|
||||
"""Tests for the pkpd-modeling skill scripts.
|
||||
|
||||
The value of this skill rests entirely on the numbers being right, so the tests
|
||||
check against things that are true independently of the implementation:
|
||||
|
||||
* closed-form identities every linear PK model must satisfy (AUC = D/CL,
|
||||
Vss = sum of volumes, MRT = Vss/CL, the Bateman function, the flip-flop
|
||||
limit);
|
||||
* analytical profiles with known parameters, which NCA and the compartmental
|
||||
fitter must recover;
|
||||
* published reference values -- the PowerTOST bioequivalence sample-size table,
|
||||
the EMA ABEL cap, and the FDA body-surface-area conversion factors;
|
||||
* hand-computable regressions for the concentration-QTc model.
|
||||
|
||||
Everything runs offline.
|
||||
|
||||
uv run --with pytest python -m pytest tests/pkpd-modeling -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import skill_contract
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[2] / "skills" / "pkpd-modeling"
|
||||
SCRIPTS = SKILL_ROOT / "scripts"
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import _common # noqa: E402
|
||||
import _models # noqa: E402
|
||||
import allometry_and_fih # noqa: E402
|
||||
import bioequivalence # noqa: E402
|
||||
import ddi_static # noqa: E402
|
||||
import exposure_response # noqa: E402
|
||||
import nca # noqa: E402
|
||||
|
||||
CliHelpTests = skill_contract.cli.help_test_case(SKILL_ROOT)
|
||||
|
||||
|
||||
def run_script(name: str, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / f"{name}.py"), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=SCRIPTS,
|
||||
)
|
||||
|
||||
|
||||
def write_csv(directory: Path, name: str, header: str, rows) -> Path:
|
||||
path = directory / name
|
||||
lines = [header] + [",".join(str(v) for v in row) for row in rows]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ structure
|
||||
|
||||
|
||||
class TestSkillStructure(unittest.TestCase):
|
||||
def test_frontmatter(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.frontmatter_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_length(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.length_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_no_stray_tests(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.stray_test_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_no_bytecode(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.bytecode_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_scripts_compile(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.compile_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_internal_links_resolve(self) -> None:
|
||||
self.assertEqual(skill_contract.structure.link_problems(SKILL_ROOT), [])
|
||||
|
||||
def test_every_referenced_file_exists(self) -> None:
|
||||
text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
missing = []
|
||||
for kind in ("references", "assets"):
|
||||
for line in text.splitlines():
|
||||
marker = f"`{kind}/"
|
||||
if marker in line:
|
||||
name = line.split(marker, 1)[1].split("`", 1)[0]
|
||||
if not (SKILL_ROOT / kind / name).is_file():
|
||||
missing.append(f"{kind}/{name}")
|
||||
self.assertEqual(missing, [], f"SKILL.md references files that do not exist: {missing}")
|
||||
|
||||
def test_every_script_is_documented(self) -> None:
|
||||
text = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
undocumented = [
|
||||
path.name
|
||||
for path in sorted(SCRIPTS.glob("*.py"))
|
||||
if not path.name.startswith("_") and path.name not in text
|
||||
]
|
||||
self.assertEqual(undocumented, [])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- model library
|
||||
|
||||
|
||||
class TestDispositionIdentities(unittest.TestCase):
|
||||
"""Identities that hold for every linear mammillary model."""
|
||||
|
||||
def test_one_compartment_matches_closed_form(self) -> None:
|
||||
d = _models.disposition(cl=5.0, v1=20.0)
|
||||
t = np.array([0.0, 1.0, 4.0, 12.0])
|
||||
np.testing.assert_allclose(_models.conc_bolus(t, 100.0, d), 100 / 20 * np.exp(-0.25 * t))
|
||||
|
||||
def test_auc_equals_dose_over_clearance(self) -> None:
|
||||
for cl, v1, q, vp in [(5.0, 20.0, (), ()), (3.0, 10.0, (2.0,), (25.0,)), (4.0, 8.0, (3.0, 1.0), (20.0, 60.0))]:
|
||||
with self.subTest(compartments=1 + len(q)):
|
||||
d = _models.disposition(cl, v1, q, vp)
|
||||
self.assertAlmostEqual(500 * d.auc_unit_dose, 500 / cl, places=6)
|
||||
|
||||
def test_vss_and_mrt(self) -> None:
|
||||
d = _models.disposition(4.0, 8.0, (3.0, 1.0), (20.0, 60.0))
|
||||
self.assertAlmostEqual(d.vss, 88.0, places=9)
|
||||
self.assertAlmostEqual(d.mrt_iv, 88.0 / 4.0, places=5)
|
||||
|
||||
def test_initial_concentration_is_dose_over_v1(self) -> None:
|
||||
d = _models.disposition(3.0, 10.0, (2.0,), (25.0,))
|
||||
self.assertAlmostEqual(float(_models.conc_bolus(np.array([0.0]), 500.0, d)[0]), 50.0, places=9)
|
||||
|
||||
def test_oral_matches_bateman(self) -> None:
|
||||
cl, v, ka, f = 5.0, 20.0, 1.2, 0.8
|
||||
d = _models.disposition(cl, v)
|
||||
t = np.array([0.5, 1.0, 2.0, 6.0, 24.0])
|
||||
k = cl / v
|
||||
expected = f * 100 * ka / (v * (ka - k)) * (np.exp(-k * t) - np.exp(-ka * t))
|
||||
np.testing.assert_allclose(_models.conc_oral(t, 100.0, ka, d, f=f), expected)
|
||||
|
||||
def test_absorption_singularity_uses_the_limit(self) -> None:
|
||||
"""ka == k is the flip-flop boundary; the naive formula divides by zero."""
|
||||
cl, v = 5.0, 20.0
|
||||
k = cl / v
|
||||
d = _models.disposition(cl, v)
|
||||
t = np.array([0.5, 1.0, 4.0])
|
||||
result = _models.conc_oral(t, 100.0, k, d, f=1.0)
|
||||
np.testing.assert_allclose(result, 100 * k * t * np.exp(-k * t) / v)
|
||||
self.assertTrue(np.all(np.isfinite(result)))
|
||||
|
||||
def test_infusion_reaches_rate_over_clearance(self) -> None:
|
||||
d = _models.disposition(5.0, 20.0, (3.0,), (40.0,))
|
||||
css = _models.conc_infusion(np.array([500.0]), 100.0 * 500.0, 500.0, d)[0]
|
||||
self.assertAlmostEqual(float(css), 100.0 / 5.0, places=6)
|
||||
|
||||
def test_steady_state_metrics_match_brute_force_superposition(self) -> None:
|
||||
d = _models.disposition(5.0, 20.0, (3.0,), (40.0,))
|
||||
tau = 12.0
|
||||
metrics = _models.steady_state_metrics(d, 100.0, tau)
|
||||
regimen = _models.build_regimen(100.0, interval=tau, n_doses=400)
|
||||
grid = np.linspace(399 * tau, 400 * tau, 20001)
|
||||
profile = _models.simulate_linear(grid, regimen, d)
|
||||
self.assertAlmostEqual(metrics["cmax_ss_bolus"], float(profile.max()), places=6)
|
||||
self.assertAlmostEqual(metrics["cmin_ss_bolus"], float(profile.min()), places=6)
|
||||
self.assertAlmostEqual(metrics["cavg_ss"], float(np.trapezoid(profile, grid)) / tau, places=4)
|
||||
|
||||
def test_michaelis_menten_reduces_to_linear_at_high_km(self) -> None:
|
||||
t = np.linspace(0, 24, 121)
|
||||
linear = _models.conc_bolus(t, 100.0, _models.disposition(5.0, 20.0))
|
||||
nonlinear = _models.simulate_michaelis_menten(
|
||||
t, [_models.Dose(0.0, 100.0)], vmax=5.0 * 1e7, km=1e7, v1=20.0
|
||||
)
|
||||
np.testing.assert_allclose(linear, nonlinear, rtol=1e-4)
|
||||
|
||||
def test_michaelis_menten_is_not_dose_proportional(self) -> None:
|
||||
t = np.linspace(0, 48, 97)
|
||||
low = _models.simulate_michaelis_menten(t, [_models.Dose(0.0, 100.0)], vmax=20.0, km=1.0, v1=20.0)
|
||||
high = _models.simulate_michaelis_menten(t, [_models.Dose(0.0, 200.0)], vmax=20.0, km=1.0, v1=20.0)
|
||||
auc_low = float(np.trapezoid(low, t))
|
||||
auc_high = float(np.trapezoid(high, t))
|
||||
self.assertGreater(auc_high / auc_low, 2.2, "saturable elimination must give more than proportional AUC")
|
||||
|
||||
def test_effect_compartment_matches_analytical_solution(self) -> None:
|
||||
t = np.linspace(0, 50, 501)
|
||||
k, ke0 = 0.2, 0.7
|
||||
conc = 5 * np.exp(-k * t)
|
||||
expected = 5 * ke0 / (ke0 - k) * (np.exp(-k * t) - np.exp(-ke0 * t))
|
||||
np.testing.assert_allclose(_models.effect_compartment(t, conc, ke0), expected, atol=2e-3)
|
||||
|
||||
def test_effect_compartment_equilibrates_to_constant_input(self) -> None:
|
||||
t = np.linspace(0, 50, 501)
|
||||
ce = _models.effect_compartment(t, np.full_like(t, 10.0), ke0=0.5)
|
||||
self.assertAlmostEqual(float(ce[-1]), 10.0, places=6)
|
||||
|
||||
def test_indirect_response_starts_at_baseline(self) -> None:
|
||||
d = _models.disposition(5.0, 20.0)
|
||||
t = np.linspace(0, 72, 145)
|
||||
|
||||
def conc(time: float) -> float:
|
||||
return float(_models.conc_bolus(np.array([time]), 100.0, d)[0])
|
||||
|
||||
response = _models.indirect_response(t, conc, kin=10.0, kout=0.1, idr_type=1, max_effect=0.9, c50=1.0)
|
||||
self.assertAlmostEqual(float(response[0]), 100.0, places=4)
|
||||
self.assertLess(float(response.min()), 100.0, "inhibition of production must lower the response")
|
||||
self.assertGreater(float(response[-1]), float(response.min()), "response must recover towards baseline")
|
||||
|
||||
def test_tmdd_target_is_suppressed_by_drug_and_recovers(self) -> None:
|
||||
"""A large bolus binds essentially all target under QSS, then it recovers.
|
||||
|
||||
Free target at t=0 is near zero, not at the ksyn/kdeg baseline: the
|
||||
quasi-steady-state assumption binds instantaneously. What must hold is
|
||||
that free target plus complex accounts for the target present, and that
|
||||
free target returns towards baseline as drug is cleared.
|
||||
"""
|
||||
baseline = 0.1 / 0.1
|
||||
t = np.linspace(0, 400, 801)
|
||||
result = _models.simulate_tmdd(
|
||||
t, [_models.Dose(0.0, 100.0)], cl=0.2, v1=3.0, kon=1.0, koff=0.01,
|
||||
kint=0.1, ksyn=0.1, kdeg=0.1, approximation="qss",
|
||||
)
|
||||
self.assertLess(float(result["free_target"][0]), 0.01 * baseline)
|
||||
self.assertAlmostEqual(float(result["free_target"][0] + result["complex"][0]), baseline, places=6)
|
||||
self.assertGreater(float(result["free_target"][-1]), 0.5 * baseline)
|
||||
np.testing.assert_allclose(
|
||||
result["total_drug"], result["free_drug"] + result["complex"], rtol=1e-6, atol=1e-9
|
||||
)
|
||||
|
||||
def test_tmdd_without_drug_sits_at_the_target_baseline(self) -> None:
|
||||
t = np.linspace(0, 50, 101)
|
||||
result = _models.simulate_tmdd(
|
||||
t, [_models.Dose(0.0, 0.0)], cl=0.2, v1=3.0, kon=1.0, koff=0.01,
|
||||
kint=0.1, ksyn=0.4, kdeg=0.2, approximation="qss",
|
||||
)
|
||||
np.testing.assert_allclose(result["free_target"], 0.4 / 0.2, rtol=1e-6)
|
||||
np.testing.assert_allclose(result["free_drug"], 0.0, atol=1e-12)
|
||||
|
||||
def test_disposition_rejects_mismatched_peripherals(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
_models.disposition(5.0, 20.0, q=(3.0,), vp=())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- NCA
|
||||
|
||||
|
||||
def analytical_oral_profile(tmp: Path) -> Path:
|
||||
"""A noiseless one-compartment oral profile with known parameters."""
|
||||
d = _models.disposition(cl=5.0, v1=20.0)
|
||||
t = np.array([0, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 36, 48], dtype=float)
|
||||
c = _models.conc_oral(t, 100.0, 1.2, d)
|
||||
return write_csv(tmp, "oral.csv", "id,time,conc", [(1, ti, f"{ci:.10g}") for ti, ci in zip(t, c)])
|
||||
|
||||
|
||||
class TestNCA(unittest.TestCase):
|
||||
def test_recovers_known_parameters(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = analytical_oral_profile(Path(tmpdir))
|
||||
result = run_script("nca", "-i", str(path), "--dose", "100", "--format", "json")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
row = payload["tables"][0]["rows"][0]
|
||||
self.assertAlmostEqual(row["lambda_z"], 0.25, places=8)
|
||||
self.assertAlmostEqual(row["t_half"], math.log(2) / 0.25, places=6)
|
||||
# AUCinf is slightly low because the trapezoidal rule under-reads
|
||||
# the absorption phase on this sampling schedule; CL/F inherits it.
|
||||
self.assertAlmostEqual(row["auc_inf_obs"], 20.0, delta=0.2)
|
||||
self.assertAlmostEqual(row["cl_f"], 5.0, delta=0.05)
|
||||
self.assertAlmostEqual(row["vz_f"], 20.0, delta=0.2)
|
||||
self.assertLess(row["pct_auc_extrap"], 1.0)
|
||||
|
||||
def test_lambda_z_excludes_tmax(self) -> None:
|
||||
time = np.array([0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0])
|
||||
conc = np.array([0.0, 5.0, 9.0, 6.0, 3.0, 1.5, 0.2])
|
||||
blq = np.zeros_like(time, dtype=bool)
|
||||
lz = nca.estimate_lambda_z(time, conc, blq, tmax=2.0)
|
||||
self.assertIsNotNone(lz.t_first)
|
||||
self.assertGreater(lz.t_first, 2.0)
|
||||
|
||||
def test_lambda_z_refuses_with_too_few_points(self) -> None:
|
||||
time = np.array([0.0, 1.0, 2.0, 4.0])
|
||||
conc = np.array([0.0, 5.0, 9.0, 6.0])
|
||||
lz = nca.estimate_lambda_z(time, conc, np.zeros(4, dtype=bool), tmax=2.0)
|
||||
self.assertIsNone(lz.lam)
|
||||
self.assertIn("need 3", lz.reason)
|
||||
|
||||
def test_log_down_trapezoid_is_below_linear_on_a_declining_curve(self) -> None:
|
||||
time = np.array([0.0, 4.0, 8.0, 12.0])
|
||||
conc = 10 * np.exp(-0.3 * time)
|
||||
linear, _ = nca.cumulative_auc(time, conc, "linear")
|
||||
logdown, _ = nca.cumulative_auc(time, conc, "linup-logdown")
|
||||
self.assertLess(logdown[-1], linear[-1])
|
||||
exact = 10 / 0.3 * (1 - math.exp(-0.3 * 12))
|
||||
self.assertAlmostEqual(logdown[-1], exact, places=9)
|
||||
|
||||
def test_aumc_log_down_matches_the_analytical_integral(self) -> None:
|
||||
t0, t1, c0 = 2.0, 6.0, 8.0
|
||||
k = 0.25
|
||||
c1 = c0 * math.exp(-k * (t1 - t0))
|
||||
_, aumc = nca._segment(t0, t1, c0, c1, "linup-logdown")
|
||||
grid = np.linspace(t0, t1, 200001)
|
||||
expected = float(np.trapezoid(grid * c0 * np.exp(-k * (grid - t0)), grid))
|
||||
self.assertAlmostEqual(aumc, expected, places=6)
|
||||
|
||||
def test_partial_auc_matches_full_auc_over_the_whole_range(self) -> None:
|
||||
time = np.array([0.0, 1.0, 2.0, 4.0, 8.0])
|
||||
conc = np.array([0.0, 5.0, 9.0, 6.0, 3.0])
|
||||
cumulative, _ = nca.cumulative_auc(time, conc, "linup-logdown")
|
||||
self.assertAlmostEqual(nca.partial_auc(time, conc, 0.0, 8.0, "linup-logdown"), cumulative[-1], places=9)
|
||||
|
||||
def test_blq_rules_change_the_result_as_documented(self) -> None:
|
||||
rows = [("A", 0, "BLQ"), ("A", 1, 5.0), ("A", 2, 9.0), ("A", 4, 6.0), ("A", 8, 3.0), ("A", 12, "BLQ")]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "blq.csv", "id,time,conc", rows)
|
||||
zero = json.loads(
|
||||
run_script("nca", "-i", str(path), "--dose", "50", "--lloq", "1", "--blq-rule", "zero", "--format", "json").stdout
|
||||
)["tables"][0]["rows"][0]
|
||||
half = json.loads(
|
||||
run_script("nca", "-i", str(path), "--dose", "50", "--lloq", "1", "--blq-rule", "half-lloq", "--format", "json").stdout
|
||||
)["tables"][0]["rows"][0]
|
||||
self.assertGreater(half["auc_last"], zero["auc_last"])
|
||||
|
||||
def test_flags_excessive_extrapolation(self) -> None:
|
||||
# Truncated profile: a large fraction of AUCinf comes from the tail.
|
||||
d = _models.disposition(cl=1.0, v1=40.0)
|
||||
t = np.array([0.0, 1.0, 2.0, 4.0, 6.0])
|
||||
c = _models.conc_bolus(t, 100.0, d)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "trunc.csv", "id,time,conc", [(1, a, f"{b:.8g}") for a, b in zip(t, c)])
|
||||
result = run_script("nca", "-i", str(path), "--dose", "100", "--route", "iv-bolus")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("extrapolated", result.stderr)
|
||||
|
||||
def test_steady_state_parameters(self) -> None:
|
||||
d = _models.disposition(cl=5.0, v1=40.0)
|
||||
tau = 12.0
|
||||
regimen = _models.build_regimen(500.0, interval=tau, n_doses=40)
|
||||
t = np.array([0, 0.5, 1, 2, 4, 6, 8, 10, 12], dtype=float) + 39 * tau
|
||||
c = _models.simulate_linear(t, regimen, d)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "ss.csv", "id,time,conc", [(1, a - 39 * tau, f"{b:.8g}") for a, b in zip(t, c)])
|
||||
payload = json.loads(
|
||||
run_script("nca", "-i", str(path), "--dose", "500", "--tau", "12", "--route", "iv-bolus", "--format", "json").stdout
|
||||
)
|
||||
row = payload["tables"][0]["rows"][0]
|
||||
self.assertAlmostEqual(row["auc_tau"], 100.0, delta=1.0)
|
||||
self.assertAlmostEqual(row["cavg_ss"], 100.0 / 12.0, delta=0.1)
|
||||
|
||||
def test_rejects_missing_dose(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "nodose.csv", "id,time,conc", [(1, 0, 1.0), (1, 1, 2.0)])
|
||||
result = run_script("nca", "-i", str(path))
|
||||
self.assertEqual(result.returncode, _common.EXIT_INPUT)
|
||||
|
||||
|
||||
# ------------------------------------------------------- compartmental fits
|
||||
|
||||
|
||||
class TestCompartmentalFitting(unittest.TestCase):
|
||||
@staticmethod
|
||||
def two_compartment_data(tmp: Path) -> Path:
|
||||
d = _models.disposition(4.0, 12.0, (6.0,), (40.0,))
|
||||
t = np.array([0.083, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 36, 48])
|
||||
rng = np.random.default_rng(7)
|
||||
c = _models.conc_bolus(t, 500.0, d) * np.exp(rng.normal(0, 0.08, len(t)))
|
||||
return write_csv(tmp, "two.csv", "time,conc", [(a, f"{b:.8g}") for a, b in zip(t, c)])
|
||||
|
||||
def test_recovers_two_compartment_parameters(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self.two_compartment_data(Path(tmpdir))
|
||||
payload = json.loads(
|
||||
run_script(
|
||||
"fit_compartmental", "-i", str(path), "--dose", "500",
|
||||
"--route", "iv-bolus", "--model", "2cmt", "--format", "json",
|
||||
).stdout
|
||||
)
|
||||
estimates = {r["parameter"]: r["estimate"] for r in payload["tables"][0]["rows"]}
|
||||
self.assertAlmostEqual(estimates["CL"], 4.0, delta=0.4)
|
||||
self.assertAlmostEqual(estimates["V1"], 12.0, delta=1.5)
|
||||
self.assertAlmostEqual(estimates["Q2"], 6.0, delta=1.5)
|
||||
self.assertAlmostEqual(estimates["V2"], 40.0, delta=5.0)
|
||||
|
||||
def test_flags_unidentifiable_third_compartment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self.two_compartment_data(Path(tmpdir))
|
||||
result = run_script(
|
||||
"fit_compartmental", "-i", str(path), "--dose", "500",
|
||||
"--route", "iv-bolus", "--model", "3cmt",
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("RSE", result.stderr)
|
||||
|
||||
def test_f_test_rejects_the_third_compartment_and_accepts_the_second(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self.two_compartment_data(Path(tmpdir))
|
||||
payload = json.loads(
|
||||
run_script(
|
||||
"fit_compartmental", "-i", str(path), "--dose", "500",
|
||||
"--route", "iv-bolus", "--compare", "1cmt,2cmt,3cmt", "--format", "json",
|
||||
).stdout
|
||||
)
|
||||
comparison = next(t for t in payload["tables"] if "model comparison" in t["title"])
|
||||
rows = {r["model"]: r for r in comparison["rows"]}
|
||||
self.assertLess(rows["2cmt"]["f_p_value"], 0.001)
|
||||
self.assertGreater(rows["3cmt"]["f_p_value"], 0.05)
|
||||
self.assertLess(rows["2cmt"]["bic"], rows["3cmt"]["bic"])
|
||||
|
||||
def test_flags_structural_misspecification_via_runs_test(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self.two_compartment_data(Path(tmpdir))
|
||||
result = run_script(
|
||||
"fit_compartmental", "-i", str(path), "--dose", "500", "--route", "iv-bolus", "--model", "1cmt"
|
||||
)
|
||||
self.assertIn("residual signs are not random", result.stderr)
|
||||
|
||||
|
||||
# --------------------------------------------------------- bioequivalence
|
||||
|
||||
|
||||
class TestBioequivalence(unittest.TestCase):
|
||||
def test_sample_size_matches_published_table(self) -> None:
|
||||
"""PowerTOST reference values: 2x2 crossover, GMR 0.95, 80% power."""
|
||||
for cv, expected in [(0.15, 12), (0.20, 20), (0.25, 28), (0.30, 40), (0.35, 52), (0.40, 66)]:
|
||||
with self.subTest(cv=cv):
|
||||
n, power = bioequivalence.sample_size(cv, 0.95, 0.80, "2x2")
|
||||
self.assertEqual(n, expected)
|
||||
self.assertGreaterEqual(power, 0.80)
|
||||
|
||||
def test_power_is_monotone_in_sample_size(self) -> None:
|
||||
powers = [bioequivalence.tost_power(n, 0.30, 0.95) for n in (20, 30, 40, 60)]
|
||||
self.assertEqual(powers, sorted(powers))
|
||||
|
||||
def test_abel_cap_is_exact(self) -> None:
|
||||
rv = bioequivalence.ReferenceVariability(s2wr=bioequivalence.s2_from_cv(0.50), df=30, n_subjects=30)
|
||||
low, high, widened = bioequivalence.abel_limits(rv)
|
||||
self.assertTrue(widened)
|
||||
self.assertAlmostEqual(100 * low, 69.84, places=2)
|
||||
self.assertAlmostEqual(100 * high, 143.19, places=2)
|
||||
|
||||
def test_abel_does_not_widen_below_30_percent(self) -> None:
|
||||
rv = bioequivalence.ReferenceVariability(s2wr=bioequivalence.s2_from_cv(0.25), df=30, n_subjects=30)
|
||||
low, high, widened = bioequivalence.abel_limits(rv)
|
||||
self.assertFalse(widened)
|
||||
self.assertEqual((low, high), (0.80, 1.25))
|
||||
|
||||
def test_cv_roundtrip(self) -> None:
|
||||
for cv in (0.1, 0.25, 0.5, 0.8):
|
||||
self.assertAlmostEqual(bioequivalence.cv_from_s2(bioequivalence.s2_from_cv(cv)), cv, places=12)
|
||||
|
||||
def test_crossover_removes_the_period_effect(self) -> None:
|
||||
"""A pure period effect must cancel, leaving the GMR at 1."""
|
||||
records = []
|
||||
for i in range(1, 21):
|
||||
sequence = "RT" if i % 2 else "TR"
|
||||
base = 100.0 * math.exp(0.3 * (i - 10) / 10)
|
||||
for period in (1, 2):
|
||||
treatment = sequence[period - 1]
|
||||
value = base * (1.20 if period == 2 else 1.0) # 20% period effect, no treatment effect
|
||||
records.append(
|
||||
{
|
||||
"subject": str(i),
|
||||
"treatment": treatment,
|
||||
"value": value,
|
||||
"logvalue": math.log(value),
|
||||
"sequence": sequence,
|
||||
"period": str(period),
|
||||
}
|
||||
)
|
||||
result = bioequivalence.crossover_2x2(records)
|
||||
self.assertAlmostEqual(result.gmr, 1.0, places=9)
|
||||
|
||||
def test_scaling_refused_without_a_replicate_design(self) -> None:
|
||||
rows = [(i, "RT" if i % 2 else "TR", p, ("RT" if i % 2 else "TR")[p - 1], 100 + i) for i in range(1, 13) for p in (1, 2)]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "be.csv", "subject,sequence,period,treatment,value", rows)
|
||||
result = run_script("bioequivalence", "-i", str(path), "--design", "2x2", "--scaling", "abel")
|
||||
self.assertEqual(result.returncode, _common.EXIT_INPUT)
|
||||
self.assertIn("replicate", result.stderr)
|
||||
|
||||
def test_reference_variability_needs_replicated_reference(self) -> None:
|
||||
records = [
|
||||
{"subject": "1", "treatment": "R", "logvalue": 1.0},
|
||||
{"subject": "1", "treatment": "T", "logvalue": 1.1},
|
||||
]
|
||||
with self.assertRaises(_common.InputError):
|
||||
bioequivalence.reference_variability(records)
|
||||
|
||||
def test_rsabe_bound_passes_when_difference_is_small_and_cvwr_high(self) -> None:
|
||||
rv = bioequivalence.ReferenceVariability(s2wr=bioequivalence.s2_from_cv(0.45), df=40, n_subjects=40)
|
||||
passing = bioequivalence.rsabe_bound(estimate=math.log(0.98), se=0.05, df_point=40, rv=rv)
|
||||
self.assertTrue(passing["passes_scaled_criterion"])
|
||||
failing = bioequivalence.rsabe_bound(estimate=math.log(0.60), se=0.05, df_point=40, rv=rv)
|
||||
self.assertFalse(failing["passes_scaled_criterion"])
|
||||
|
||||
def test_nti_limits_applied(self) -> None:
|
||||
rows = [(i, "RT" if i % 2 else "TR", p, ("RT" if i % 2 else "TR")[p - 1], 100 + (i % 3)) for i in range(1, 25) for p in (1, 2)]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "nti.csv", "subject,sequence,period,treatment,value", rows)
|
||||
payload = json.loads(
|
||||
run_script("bioequivalence", "-i", str(path), "--design", "2x2", "--nti", "--format", "json").stdout
|
||||
)
|
||||
self.assertEqual(payload["scalars"]["acceptance_limits_pct"], "90.00-111.11")
|
||||
|
||||
|
||||
# --------------------------------------------------- allometry and FIH dose
|
||||
|
||||
|
||||
class TestAllometryAndFIH(unittest.TestCase):
|
||||
def test_fda_conversion_factors(self) -> None:
|
||||
"""FDA 2005 guidance Table 1: divide animal mg/kg by these to get HED."""
|
||||
for species, divisor in [("mouse", 12.3), ("rat", 6.2), ("dog", 1.85), ("monkey", 3.08), ("rabbit", 3.08)]:
|
||||
with self.subTest(species=species):
|
||||
hed = allometry_and_fih.hed_mg_per_kg(10.0, species)
|
||||
self.assertAlmostEqual(10.0 / hed, divisor, places=1)
|
||||
|
||||
def test_mrsd_uses_the_most_sensitive_species(self) -> None:
|
||||
result = run_script("allometry_and_fih", "--fih", "--noael", "rat=50,dog=10", "--safety-factor", "10", "--format", "json")
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["scalars"]["most_sensitive_species"], "dog")
|
||||
self.assertAlmostEqual(payload["scalars"]["mrsd_mg_kg"], 10.0 * 20 / 37 / 10, places=6)
|
||||
|
||||
def test_maturation_is_monotone_and_bounded(self) -> None:
|
||||
values = [allometry_and_fih.maturation_fraction(pma) for pma in (30, 40, 54.2, 80, 200)]
|
||||
self.assertEqual(values, sorted(values))
|
||||
self.assertAlmostEqual(values[2], 0.5, places=6)
|
||||
self.assertLess(values[-1], 1.0)
|
||||
self.assertGreater(values[-1], 0.95)
|
||||
|
||||
def test_allometric_scaling(self) -> None:
|
||||
self.assertAlmostEqual(allometry_and_fih.allometric(5.0, 70.0, 35.0, 0.75), 5.0 * 0.5**0.75, places=12)
|
||||
|
||||
def test_neonate_without_maturation_raises_a_finding(self) -> None:
|
||||
result = run_script("allometry_and_fih", "--scale", "--cl", "5", "--weight-from", "70", "--weight-to", "6")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("maturation", result.stderr)
|
||||
|
||||
def test_exponent_regression_recovers_a_known_slope(self) -> None:
|
||||
weights = np.array([0.02, 0.15, 1.8, 10.0, 70.0])
|
||||
values = 0.5 * weights**0.75
|
||||
fit = allometry_and_fih.fit_exponent(weights, values)
|
||||
self.assertAlmostEqual(fit["exponent"], 0.75, places=8)
|
||||
self.assertAlmostEqual(fit["coefficient"], 0.5, places=8)
|
||||
self.assertAlmostEqual(fit["r2"], 1.0, places=10)
|
||||
|
||||
def test_rule_of_exponents_bands(self) -> None:
|
||||
self.assertIn("simple allometry", allometry_and_fih.rule_of_exponents(0.65))
|
||||
self.assertIn("maximum-life-span", allometry_and_fih.rule_of_exponents(0.85))
|
||||
self.assertIn("brain-weight", allometry_and_fih.rule_of_exponents(1.10))
|
||||
|
||||
def test_unknown_species_rejected(self) -> None:
|
||||
with self.assertRaises(_common.InputError):
|
||||
allometry_and_fih.hed_mg_per_kg(10.0, "capybara")
|
||||
|
||||
|
||||
# ----------------------------------------------------------- DDI static
|
||||
|
||||
|
||||
class TestDDIStatic(unittest.TestCase):
|
||||
def test_basic_reversible_ratio(self) -> None:
|
||||
self.assertAlmostEqual(ddi_static.r1_reversible(0.1, 0.5), 1.2, places=12)
|
||||
|
||||
def test_tdi_ratio_reduces_to_one_without_inactivation(self) -> None:
|
||||
self.assertAlmostEqual(ddi_static.r2_time_dependent(0.0, 1.0, 0.05, 0.0005), 1.0, places=12)
|
||||
|
||||
def test_induction_ratio_below_one(self) -> None:
|
||||
r3 = ddi_static.r3_induction(10.0, emax=5.0, ec50=1.0)
|
||||
self.assertLess(r3, 1.0)
|
||||
self.assertGreater(r3, 0.0)
|
||||
|
||||
def test_mechanistic_static_model_matches_hand_calculation(self) -> None:
|
||||
result = ddi_static.mechanistic_static(ih=0.1, ig=1.6, fm=0.9, fg=0.7, ki=0.5)
|
||||
self.assertAlmostEqual(result["Ah_reversible"], 1 / 1.2, places=12)
|
||||
self.assertAlmostEqual(result["Ag_reversible"], 1 / 4.2, places=12)
|
||||
self.assertAlmostEqual(result["hepatic_component"], 1 / (0.9 / 1.2 + 0.1), places=12)
|
||||
self.assertAlmostEqual(result["gut_component"], 1 / ((1 / 4.2) * 0.3 + 0.7), places=12)
|
||||
self.assertAlmostEqual(result["auc_ratio"], result["gut_component"] * result["hepatic_component"], places=12)
|
||||
|
||||
def test_auc_ratio_cannot_exceed_the_fm_ceiling(self) -> None:
|
||||
for fm in (0.5, 0.7, 0.9):
|
||||
result = ddi_static.mechanistic_static(ih=1e6, ig=0.0, fm=fm, fg=1.0, ki=0.5)
|
||||
self.assertLessEqual(result["auc_ratio"], 1 / (1 - fm) + 1e-6)
|
||||
self.assertAlmostEqual(result["maximum_possible_auc_ratio"], 1 / (1 - fm), places=9)
|
||||
|
||||
def test_no_perpetrator_gives_unity(self) -> None:
|
||||
result = ddi_static.mechanistic_static(ih=0.0, ig=0.0, fm=0.9, fg=0.7, ki=0.5)
|
||||
self.assertAlmostEqual(result["auc_ratio"], 1.0, places=12)
|
||||
|
||||
def test_classification_bands(self) -> None:
|
||||
self.assertIn("strong inhibitor", ddi_static.classify(6.0))
|
||||
self.assertIn("moderate inhibitor", ddi_static.classify(3.0))
|
||||
self.assertIn("weak inhibitor", ddi_static.classify(1.5))
|
||||
self.assertIn("no clinically relevant", ddi_static.classify(1.0))
|
||||
self.assertIn("strong inducer", ddi_static.classify(0.1))
|
||||
|
||||
|
||||
# ------------------------------------------------------- exposure-response
|
||||
|
||||
|
||||
class TestExposureResponse(unittest.TestCase):
|
||||
def test_emax_recovers_known_parameters(self) -> None:
|
||||
conc = np.array([0, 0.5, 1, 2, 4, 8, 16, 32, 64, 128], dtype=float)
|
||||
response = _models.emax(conc, e0=5.0, emax_value=100.0, ec50=8.0)
|
||||
fit = exposure_response.fit_emax(conc, response, sigmoid=False)
|
||||
self.assertAlmostEqual(fit["e0"], 5.0, places=4)
|
||||
self.assertAlmostEqual(fit["emax"], 100.0, places=3)
|
||||
self.assertAlmostEqual(fit["ec50"], 8.0, places=4)
|
||||
|
||||
def test_sigmoid_emax_recovers_the_hill_coefficient(self) -> None:
|
||||
conc = np.logspace(-1, 2.5, 24)
|
||||
response = _models.emax(conc, e0=2.0, emax_value=50.0, ec50=10.0, hill=2.5)
|
||||
fit = exposure_response.fit_emax(conc, response, sigmoid=True)
|
||||
self.assertAlmostEqual(fit["hill"], 2.5, places=3)
|
||||
self.assertAlmostEqual(fit["ec50"], 10.0, places=3)
|
||||
|
||||
def test_flags_a_plateau_outside_the_data(self) -> None:
|
||||
conc = np.linspace(0.1, 3.0, 12)
|
||||
response = _models.emax(conc, e0=0.0, emax_value=100.0, ec50=50.0)
|
||||
rows = [(f"{c:.6g}", f"{r:.6g}") for c, r in zip(conc, response)]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "er.csv", "exposure,response", rows)
|
||||
result = run_script("exposure_response", "--emax", "-i", str(path))
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("Emax", result.stderr)
|
||||
|
||||
def test_cqtc_matches_ordinary_least_squares(self) -> None:
|
||||
conc = np.array([0.0, 50.0, 100.0, 150.0, 200.0, 250.0])
|
||||
delta = 1.0 + 0.02 * conc
|
||||
fit = exposure_response.fit_cqtc(conc, delta, cmax=200.0)
|
||||
self.assertAlmostEqual(fit["slope_ms_per_conc"], 0.02, places=10)
|
||||
self.assertAlmostEqual(fit["intercept_ms"], 1.0, places=10)
|
||||
self.assertAlmostEqual(fit["predicted_delta_delta_qtc_ms"], 5.0, places=10)
|
||||
|
||||
def test_cqtc_flags_extrapolation_and_the_10ms_threshold(self) -> None:
|
||||
conc = np.array([0.0, 50.0, 100.0, 150.0, 200.0, 250.0])
|
||||
delta = 1.0 + 0.06 * conc
|
||||
fit = exposure_response.fit_cqtc(conc, delta, cmax=400.0)
|
||||
self.assertEqual(fit["extrapolated_beyond_observed"], 1.0)
|
||||
self.assertEqual(fit["upper_bound_exceeds_10ms"], 1.0)
|
||||
|
||||
def test_logistic_slope_sign(self) -> None:
|
||||
rng = np.random.default_rng(3)
|
||||
conc = rng.uniform(0, 100, 300)
|
||||
response = (rng.uniform(0, 1, 300) < 1 / (1 + np.exp(-(conc - 50) / 15))).astype(float)
|
||||
fit = exposure_response.fit_logistic(conc, response)
|
||||
self.assertGreater(fit["slope"], 0.0)
|
||||
self.assertAlmostEqual(fit["exposure_at_50pct_probability"], 50.0, delta=12.0)
|
||||
|
||||
def test_logistic_rejects_non_binary(self) -> None:
|
||||
with self.assertRaises(_common.InputError):
|
||||
exposure_response.fit_logistic(np.array([1.0, 2.0]), np.array([0.0, 3.0]))
|
||||
|
||||
def test_quartile_summary_is_ordered(self) -> None:
|
||||
exposure = np.arange(1.0, 41.0)
|
||||
response = 2.0 * exposure
|
||||
rows = exposure_response.quartile_summary(exposure, response, 4)
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual([r["n"] for r in rows], [10, 10, 10, 10])
|
||||
means = [r["mean_response"] for r in rows]
|
||||
self.assertEqual(means, sorted(means))
|
||||
|
||||
|
||||
# ------------------------------------------------------ popPK dataset check
|
||||
|
||||
|
||||
class TestPopPKDatasetCheck(unittest.TestCase):
|
||||
def test_catches_planted_defects(self) -> None:
|
||||
result = run_script("check_popk_dataset", "-i", str(FIXTURES / "broken_nmdata.csv"), "--covariates", "WT,CRCL", "--format", "json")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
payload = json.loads(result.stdout)
|
||||
checks = {row["check"] for row in payload["tables"][0]["rows"]}
|
||||
for expected in (
|
||||
"non-numeric DV",
|
||||
"TIME not sorted",
|
||||
"subject with no dose",
|
||||
"covariate WT missing",
|
||||
"duplicate TIME within a subject",
|
||||
):
|
||||
self.assertIn(expected, checks)
|
||||
|
||||
def test_clean_dataset_passes(self) -> None:
|
||||
result = run_script("check_popk_dataset", "-i", str(FIXTURES / "clean_nmdata.csv"), "--covariates", "WT")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("all checks passed", result.stdout)
|
||||
|
||||
def test_addl_without_ii_is_an_error(self) -> None:
|
||||
rows = [
|
||||
("1", 0, 100, ".", 1, 1, 1, 5),
|
||||
("1", 1, ".", 5.0, 0, 0, 1, ""),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = write_csv(Path(tmpdir), "addl.csv", "ID,TIME,AMT,DV,EVID,MDV,CMT,ADDL", rows)
|
||||
result = run_script("check_popk_dataset", "-i", str(path), "--format", "json")
|
||||
payload = json.loads(result.stdout)
|
||||
checks = {row["check"] for row in payload["tables"][0]["rows"]}
|
||||
self.assertIn("ADDL without II", checks)
|
||||
|
||||
def test_strict_mode_fails_on_warnings(self) -> None:
|
||||
result = run_script("check_popk_dataset", "-i", str(FIXTURES / "clean_nmdata.csv"), "--strict")
|
||||
self.assertIn(result.returncode, (0, 1))
|
||||
|
||||
|
||||
# --------------------------------------------------------- TDM and simulation
|
||||
|
||||
|
||||
class TestTDMAndSimulation(unittest.TestCase):
|
||||
def test_map_recovers_a_known_individual(self) -> None:
|
||||
"""Levels simulated from known individual parameters must be recovered."""
|
||||
cl_true, v_true = 5.0, 50.0
|
||||
dose, interval, infusion = 1000.0, 12.0, 1.0
|
||||
disp = _models.disposition(cl_true, v_true)
|
||||
regimen = _models.build_regimen(dose, interval=interval, n_doses=20, duration=infusion)
|
||||
times = np.array([2.0, 11.0])
|
||||
observed = _models.simulate_linear(times + 19 * interval, regimen, disp)
|
||||
args = [
|
||||
"tdm_bayes", "--custom", "--cl-pop", "4.0", "--v-pop", "45.0",
|
||||
"--omega-cl", "0.5", "--omega-v", "0.5", "--prop-error", "0.05", "--add-error", "0.1",
|
||||
"--dose", str(dose), "--interval", str(interval), "--infusion", str(infusion),
|
||||
"--doses-given", "20", "--format", "json",
|
||||
]
|
||||
for time, conc in zip(times, observed):
|
||||
args.extend(["--level", f"{conc:.6f}@{time}"])
|
||||
payload = json.loads(run_script(*args).stdout)
|
||||
self.assertAlmostEqual(payload["scalars"]["individual_cl"], cl_true, delta=0.4)
|
||||
self.assertAlmostEqual(payload["scalars"]["individual_v"], v_true, delta=6.0)
|
||||
|
||||
def test_single_level_raises_a_finding(self) -> None:
|
||||
result = run_script(
|
||||
"tdm_bayes", "--custom", "--cl-pop", "4", "--v-pop", "45",
|
||||
"--dose", "1000", "--interval", "12", "--level", "12@11",
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("single concentration", result.stderr)
|
||||
|
||||
def test_level_beyond_the_interval_rejected(self) -> None:
|
||||
result = run_script(
|
||||
"tdm_bayes", "--custom", "--cl-pop", "4", "--v-pop", "45",
|
||||
"--dose", "1000", "--interval", "12", "--level", "12@20",
|
||||
)
|
||||
self.assertEqual(result.returncode, _common.EXIT_INPUT)
|
||||
|
||||
def test_simulate_regimen_matches_closed_form_steady_state(self) -> None:
|
||||
payload = json.loads(
|
||||
run_script(
|
||||
"simulate_regimen", "--cl", "5", "--v", "40", "--dose", "500",
|
||||
"--interval", "12", "--n-doses", "10", "--steady-state", "--format", "json",
|
||||
).stdout
|
||||
)
|
||||
summary = next(t for t in payload["tables"] if "dosing-interval" in t["title"])
|
||||
last = next(r for r in summary["rows"] if str(r["interval"]).startswith("last"))
|
||||
self.assertAlmostEqual(last["auc_tau"], 100.0, places=4)
|
||||
self.assertAlmostEqual(last["cmax"], 12.5 / (1 - math.exp(-0.125 * 12)), places=4)
|
||||
self.assertAlmostEqual(last["cmin"], last["cmax"] * math.exp(-0.125 * 12), places=4)
|
||||
|
||||
def test_accumulation_ratio_is_consistent_across_metrics(self) -> None:
|
||||
payload = json.loads(
|
||||
run_script(
|
||||
"simulate_regimen", "--cl", "5", "--v", "40", "--dose", "500",
|
||||
"--interval", "12", "--n-doses", "10", "--format", "json",
|
||||
).stdout
|
||||
)
|
||||
summary = next(t for t in payload["tables"] if "dosing-interval" in t["title"])
|
||||
row = next(r for r in summary["rows"] if str(r["interval"]).startswith("accumulation"))
|
||||
expected = 1 / (1 - math.exp(-0.125 * 12))
|
||||
for key in ("auc_tau", "cmax", "cmin", "cavg"):
|
||||
self.assertAlmostEqual(row[key], expected, places=4, msg=key)
|
||||
|
||||
def test_monte_carlo_attainment_is_a_fraction(self) -> None:
|
||||
payload = json.loads(
|
||||
run_script(
|
||||
"simulate_regimen", "--cl", "5", "--v", "40", "--dose", "500", "--interval", "12",
|
||||
"--n-doses", "10", "--simulate", "400", "--omega-cl", "0.35", "--target-trough", "4.0",
|
||||
"--format", "json",
|
||||
).stdout
|
||||
)
|
||||
attainment = next(t for t in payload["tables"] if "target attainment" in t["title"])
|
||||
fraction = attainment["rows"][0]["fraction_attaining"]
|
||||
self.assertGreaterEqual(fraction, 0.0)
|
||||
self.assertLessEqual(fraction, 1.0)
|
||||
|
||||
def test_regimen_comparison_preserves_cavg_for_equal_daily_dose(self) -> None:
|
||||
payload = json.loads(
|
||||
run_script("simulate_regimen", "--cl", "5", "--v", "40", "--compare", "500@12,250@6", "--format", "json").stdout
|
||||
)
|
||||
rows = payload["tables"][0]["rows"]
|
||||
self.assertAlmostEqual(rows[0]["cavg_ss"], rows[1]["cavg_ss"], places=9)
|
||||
self.assertGreater(rows[0]["ptf_pct"], rows[1]["ptf_pct"])
|
||||
|
||||
def test_mismatched_peripheral_arguments_rejected(self) -> None:
|
||||
result = run_script("simulate_regimen", "--cl", "5", "--v", "40", "--q", "3", "--dose", "100")
|
||||
self.assertEqual(result.returncode, _common.EXIT_INPUT)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- CLI contract
|
||||
|
||||
|
||||
class TestCLIContract(unittest.TestCase):
|
||||
"""Every script shares one exit-code and stream contract."""
|
||||
|
||||
def test_json_output_is_parseable_and_self_contained(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = analytical_oral_profile(Path(tmpdir))
|
||||
result = run_script("nca", "-i", str(path), "--dose", "100", "--format", "json")
|
||||
payload = json.loads(result.stdout)
|
||||
for key in ("scalars", "tables", "findings", "notes"):
|
||||
self.assertIn(key, payload)
|
||||
self.assertEqual(result.stderr, "", "json format must not also write to stderr")
|
||||
|
||||
def test_tsv_output_has_no_notes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = analytical_oral_profile(Path(tmpdir))
|
||||
result = run_script("nca", "-i", str(path), "--dose", "100", "--format", "tsv")
|
||||
self.assertNotIn("note:", result.stdout)
|
||||
self.assertIn("note:", result.stderr)
|
||||
|
||||
def test_missing_input_file_exits_two(self) -> None:
|
||||
result = run_script("nca", "-i", "/nonexistent/nope.csv", "--dose", "100")
|
||||
self.assertEqual(result.returncode, _common.EXIT_INPUT)
|
||||
self.assertIn("error:", result.stderr)
|
||||
|
||||
def test_duplicate_columns_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "dupe.csv"
|
||||
path.write_text("time,conc,conc\n0,1,2\n1,2,3\n", encoding="utf-8")
|
||||
with self.assertRaises(_common.InputError):
|
||||
_common.read_table(path)
|
||||
|
||||
def test_comment_lines_and_blank_lines_skipped(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "commented.csv"
|
||||
path.write_text("# provenance header\n\ntime,conc\n0,1\n1,2\n", encoding="utf-8")
|
||||
rows = _common.read_table(path)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]["time"], "0")
|
||||
|
||||
def test_tsv_is_detected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "data.tsv"
|
||||
path.write_text("time\tconc\n0\t1\n1\t2\n", encoding="utf-8")
|
||||
rows = _common.read_table(path)
|
||||
self.assertEqual(rows[1]["conc"], "2")
|
||||
|
||||
def test_column_names_are_case_insensitive(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "upper.csv"
|
||||
path.write_text("TIME,CONC\n0,1\n1,2\n", encoding="utf-8")
|
||||
rows = _common.read_table(path)
|
||||
self.assertIn("time", rows[0])
|
||||
|
||||
def test_report_exit_codes(self) -> None:
|
||||
report = _common.Report()
|
||||
report.table("t", [{"a": 1}])
|
||||
self.assertEqual(report.emit("json", stream=open("/dev/null", "w")), _common.EXIT_OK)
|
||||
report.finding("something")
|
||||
self.assertEqual(report.emit("json", stream=open("/dev/null", "w")), _common.EXIT_FINDINGS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -274,6 +274,9 @@ packages = [
|
||||
[skills.statistical-power]
|
||||
packages = ["statsmodels", "lifelines", "pingouin", "matplotlib", "numpy", "pandas", "scipy"]
|
||||
|
||||
[skills.pkpd-modeling]
|
||||
packages = ["numpy", "scipy"]
|
||||
|
||||
[skills.pymc]
|
||||
packages = ["pymc", "arviz", "matplotlib", "numpy", "pandas", "scikit-learn"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user