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:
Timothy Kassis
2026-07-27 18:38:01 -07:00
parent 061882ba79
commit 4c90a52d54
32 changed files with 7817 additions and 0 deletions

View 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

View 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.

View 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 [5th95th] 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 | | | |

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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`.

View 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.84143.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 M1M7** methods for BLQ data — Beal, *J Pharmacokinet Pharmacodyn* 2001.
- **Dayneka & Jusko** indirect response models IIV.
- **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 400600.
## 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.

View 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.

View 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.

View 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.

View 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",
]

View 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",
]

View 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))

View 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))

View 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))

View 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))

View 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))

View 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))

View 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))

View 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))

View 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))

View 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
1 ID TIME AMT DV EVID MDV CMT WT CRCL
2 1 0 100 . 1 1 1 70 90
3 1 1 . 5.2 0 0 1 70 90
4 1 4 . BLQ 0 0 1 70 90
5 1 2 . 3.1 0 0 1 70 85
6 2 0 . 1.5 0 0 1 80
7 3 0 100 . 1 1 1 65 70
8 3 0 . 9.9 0 0 1 65 70

View 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
1 ID TIME AMT DV EVID MDV CMT WT
2 1 0 100 . 1 1 1 70
3 1 0.5 . 8.10 0 0 1 70
4 1 1 . 7.15 0 0 1 70
5 1 4 . 4.11 0 0 1 70
6 1 12 . 1.12 0 0 1 70
7 2 0 150 . 1 1 1 82
8 2 0.5 . 10.44 0 0 1 82
9 2 1 . 9.21 0 0 1 82
10 2 4 . 5.30 0 0 1 82
11 2 12 . 1.44 0 0 1 82

View 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()

View File

@@ -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"]