The synpmx PMX Model Algorithm
Andrew Stein
Source:vignettes/pmxmodel-algorithm.Rmd
pmxmodel-algorithm.RmdThis article is the full specification of
synpmx_model(), which fits a small set of linear
pharmacokinetic (PK) models to a study and generates new subjects by
simulating from the fit. Use of the algorithm is demonstrated in
vignette("pmxmodel-demo").
The aim is a simple model that roughly reproduces the
properties of the study, not one that characterises it. The
synthetic profiles it generates are for prototyping analysis code. The
goodness of fit of the selected model is not assessed:
model_report() says what the search had to go on, not
whether the model describes the source. The candidate set is the
package’s linear models and the covariate model is allometric scaling or
nothing, which is too little to answer a scientific question, so the
fitted parameters are not estimates to report, even though the printed
object looks exactly like the output of a real population analysis.
No formal privacy guarantee is offered. The protections on what
leaves the study, and the checks that measure them, are set out in Privacy
protections in the PMX model generator; every number that leaves is
listed in vignette("pmxmodel-fingerprint").
Three Exported Functions
| Function | Reads patient data | Needs nlmixr2
|
Returns |
|---|---|---|---|
synpmx_model_estimate(data, roles, ...) |
yes | yes | pmx_fitted_model |
synpmx_model_generate(fitted_model, n_subjects, seed) |
no | no | data frame |
synpmx_model(data, roles, n_subjects, seed, ...) |
yes | yes | data frame |
model_report() inventories everything the fitted object
carries, model_candidates() returns the attempted models
and their acceptance results, and model_parameters()
returns the fixed effects, the between-subject covariance matrix and the
residual error. model_fingerprint() returns the part of the
fit that generation reads, which is what
synpmx_model_generate() attaches to its output, and
model_privacy_checks() runs five checks on it (Step 5).
nlmixr2est is an imported dependency, used by
synpmx_model_estimate(), and the candidate set is exactly
the models the generator can evaluate in closed form. A candidate the
fitter could estimate and the generator could not simulate would be a
model that fits and then generates nothing, so the two lists are one
list. The vignettes read a stored synpmx_model_estimate()
fit, so R CMD check and the pkgdown site never compile a
model.
The Arguments
Beyond the roles and the seed:
| Argument | Default | Effect |
|---|---|---|
pk |
NULL |
One of the built-in models, forcing it and skipping the search; or several, which is how a search of your own choosing is asked for. |
pd |
NULL |
Named vector of PD shapes per endpoint. Skips that search. |
pd_by_arm |
FALSE |
Fit each PD endpoint’s shape, and read each binary or ordinal endpoint’s level frequencies, per arm rather than once over the pooled cohort. |
endpoint_roles |
NULL |
Declares concentration endpoints with pk, or a PD-only
study with c(pd = "response"). More than one concentration
may be named. |
start_param |
NULL |
Starting values for the population fit, keyed by endpoint where more than one concentration is fitted. The escape hatch where the non-compartmental read of the median profile starts the optimizer somewhere it cannot move from. |
covariate_effects |
"none" |
"none" puts no covariate in the structural model;
"auto" applies allometric scaling on clearance and volume
where a weight-like covariate is declared. |
min_subjects |
20L |
Warn and fit anyway below this cohort size. |
min_arm_patients |
3L |
Warn and drop the patients in any arm below this, as
synpmx_pca_summarize() does. |
min_time_bins |
6L |
Warn and fit anyway below this many distinct nominal times after a dose; no post-dose observation at all still refuses. |
max_fit_subjects |
60L |
Fit the population model to this many subjects, drawn in proportion to the arms. The dosing, visit and covariate models read every subject. |
estimation |
"focei" |
Passed to nlmixr2. |
drop_influential |
TRUE |
Leave out of the PK, PD and covariate estimates any patient who
moves a released estimate too far, and estimate again (Step 5).
FALSE keeps every patient and only reports the
reading. |
The default tries two compartments first, with a
one-compartment fallback only if the first fit fails acceptance checks.
An ambiguous administration route gets this sequence for each possible
route. A named pk model is fitted alone; a vector of names
requests an Akaike information criterion (AIC) comparison of acceptable
fits.
Two Time Axes, and nominal_time Is Required
Estimation reads recorded times. A population PK fit is a statement about time after the dose that was actually given, so the subject-level dosing records go to the fitter as recorded, reductions and skipped cycles included. Fitting against the planned schedule where a patient’s dose was reduced would push the drop in concentration that followed into clearance, and the model would report a population that eliminates the drug faster than the real one.
Everything else reads the nominal grid. The dosing model, the visit model, and every signal in Steps 1 and 2 sit on the protocol’s planned times. On recorded times a “median profile” is one point per time, because no two patients share a clock reading, and a study sampled at six times looks like a study sampled at two hundred. A shape, a peak position and a count of sampling times are all statements about the protocol.
Inferring that grid is a statement about the protocol only the caller
can make, so nominal_time is required rather than
derived.
Repeated doses are written out before anything reads
them. A regimen recorded as one record plus ADDL
and II — “and 364 more like it, every day” — is expanded by
pmx_expand_doses() on the way in, so that every dose the
patient received is a row to the dosing model, the derived time after
dose and the estimation table alike. Before this, every reader counted
rows: onc_sim showed the fitter 237 dose records standing
for 71,180 doses and got back a clearance six orders of magnitude off,
from a fit that had moved and so tripped no gate (REV-051).
The fitter is handed the compact form again, because
nlmixr2 reads it natively and is fastest on it, and the
generated study is folded back by pmx_compress_doses() on
the way out, so it comes back in the encoding its source used.
Overview of Algorithm
- Classify the endpoints. Four signals decide which endpoint or endpoints are drug concentrations. Everything else continuous becomes a pharmacodynamic (PD) endpoint.
- Detect the design. The route of administration determines the candidate models. Sampling richness is reported alongside the fit.
-
Estimate the parameters. Fit each surviving PK
model as a population nonlinear mixed-effects model through
nlmixr2, screening before fallback, once per concentration endpoint. No covariate enters the structural model unlesscovariate_effects = "auto"asks for it. Fit each PD endpoint’s time course by least squares. - Fit a dosing model and a visit model: a planned dose schedule per arm, rates for reduction, interruption and discontinuation pooled over the arms, the visits each arm has, and one attendance rate per endpoint.
- Store the fit: the structural model, the parameters, the arm models and the schema, and no per-subject quantity.
- Generate new subjects. Draw covariates and random effects, draw the dose schedule, evaluate the profile against that schedule, add residual error and put the assay limit back.
Steps 1 to 4 read the source. Step 6 reads only what Step 5 stored,
which is why generation runs on base R and needs neither
nlmixr2 nor a compiler.
Step 1: Identify the Drug Concentration Endpoint
pmx_endpoint_types() already infers continuous, integer,
binary and ordinal from the values, and that inference is reused
unchanged. What this generator adds is a second classification on top of
it. Four signals, computed for every endpoint that is a time course:
-
Compartment. The
cmtrole puts the endpoint in the compartment the doses go to, or one above a dosing compartment nobody observes, which is the depot-and-central convention. - Post-dose only. Observations at or before the first recorded dose are absent, zero, or at or below the censoring limit, in most subjects. Recorded observation and dose times are compared on the same clock. Later troughs do not count as baseline.
- Shape. The median profile within the richest dose interval rises to a maximum and declines without rising again.
- Dose proportionality. Between the highest and lowest dose level, the ratio of median subject maxima is within a factor of two of the dose ratio.
Signals 2 and 4 are required; 1 and 3 break ties. Being absent before the first dose and scaling with the dose are properties only a drug concentration has. A compartment number is a convention the dataset’s author chose, and a rise-and-fall shape is one many biomarkers also have.
Signal 4 is not computable on a single-level study, nor on one dosed by body weight. A study that gives every patient a slightly different amount has no dose levels: reading each amount as its own level compares a median of one patient against a median of one patient over a dose ratio near 1, which almost any endpoint passes. Where the distinct amounts outnumber half the cohort the signal reports “not computable” and signals 1 to 3 decide.
Where every continuous endpoint fails the baseline signal, the study
is inferred as PD-only. Otherwise, if no endpoint passes or two pass and
neither tie-break separates them, the function errors and names
endpoint_roles as the way through. That is the
inference-versus-declaration fork, answered the way
dose_covariate answers it: infer where the data settles the
question, offer the declaration as an override.
A study can contain only PD endpoints
endpoint_roles = c(pd = "response") declares a
response-only study.
endpoint_roles = list(pk = character()) makes the same
declaration for all observed endpoints. The pipeline fits continuous
responses using the PD shapes in Step 3 and discrete responses using
visit frequencies. It skips compartment selection, population PK
estimation, and the PK acceptance checks. It also skips the post-dose
coverage gate: dose records need not exist, although the nominal
observation grid remains required.
A positive baseline at the first dose time identifies
wbcSim as PD-only without an override. Missing doses
likewise provide no evidence of PK. An immediate post-bolus sample
sharing the first dose timestamp, a pretreated patient, or an endogenous
concentration can contradict this baseline heuristic; use
endpoint_roles = c(pk = "response") when the assay is known
to measure a concentration. The available continuous PD shapes remain
constant, linear and exponential in study time, with no exposure term or
cell-turnover model.
A PD-only fitted object has structural = NULL,
parameters = NULL, and an empty pk_models
list. model_candidates() returns an empty PK candidate
table; the PD candidates are in
model_report(fit)$pd[[endpoint]]$candidates, and
model_parameters(fit)$pd returns the PD fits. Generation
still carries the declared dosing, observation, covariate and arm
summaries.
More than one endpoint can be a concentration
A study measuring two drugs, or a parent and its metabolite, has two drug concentrations, and each is fitted its own population model — its own structural model, its own parameters, its own residual error. No correlation between their random effects is estimated, which is a limitation rather than a claim: a patient with a high parent clearance is given an independently drawn metabolite clearance.
Which doses drive which endpoint is a declaration. By default every dose record drives every concentration: one administration, two analytes, which is what a parent and its metabolite are. Two different drugs given together are the opposite case — each model has to see its own drug’s doses and not the other’s, or a clearance and a volume come back having absorbed an input the patient never received of that drug.
The data cannot settle it. A metabolite has no dose records of its
own, so “this endpoint’s doses are the ones marked 2” and
“this endpoint has no doses” are the same table, and guessing wrong
leaves a model with no input at all. So it is declared, on the argument
routes is declared on:
pmx_roles(
adm = "ADM",
routes = c("1" = "extravascular", "2" = "extravascular"),
dose_endpoints = c("1" = "drug A PK", "2" = "drug B PK")
)In a NONMEM dataset the compartment already does this job, so declare
the column twice — cmt = "CMT", adm = "CMT" — and key the
mapping on compartment numbers.
Declared, each drug gets its own dose schedule per arm — its own
amounts, its own interval, its own ladder of reductions — and each
endpoint’s fit and generated profile read only that drug’s doses.
Including the dosing history each observation is read
against: time after dose, the dose interval and the first dose
amount follow the endpoint’s own drug, which is what the starting
values, the route reading, the sampling richness and the
dose-proportionality signal are all computed from. Without that half, a
fit table split correctly still starts from a volume scaled by the wrong
drug’s milligrams. The generated study then writes both drugs’ dose
records back with their own adm value and compartment.
Measured on a simulated combination — 100 mg weekly at
cl 5, v 50, ka 1.2 beside 900 mg
fortnightly at cl 20, v 200, ka
0.5, sixteen subjects — the second drug comes back cl 14.4,
v 3.26, ka 9.34 with the doses pooled, and
cl 19.3, v 166, ka 0.57 with them
separated, which is what that data fitted on its own gives.
Undeclared, model_report() says so on a study with more
than one concentration, which is the case where it matters.
Inference promotes a second endpoint only on signal
4. Dose proportionality is the positive evidence that something
is a drug: a concentration scales with the dose and a biomarker does
not. Signal 2 is necessary and not sufficient, and it is satisfied by
any endpoint with no pre-dose observation. The distinction matters
because signal 4 is often not computable, and the required test
treats that as passing — which is right for choosing one endpoint and
wrong for promoting a second. Unknown dose proportionality does not
promote a second endpoint into PK. Positive baselines independently
identify warfarin’s prothrombin activity and
onc_sim’s tumour size as PD.
A study whose two endpoints both pass the baseline signal and scale with dose is classified with two concentrations. If the remaining evidence cannot separate them, the caller must declare the endpoint roles.
endpoint_roles = list(pk = c("parent", "metabolite"))
declares it where inference cannot. Declaring several is worth doing
wherever it is true: a concentration demoted to a pharmacodynamic
endpoint is fitted a time course with no dose term in it, so the
generated values lose their dose ordering entirely — measured at 1.05
times across a three-fold dose range, against 2.7 in the source
(SIM-080).
model_report(fit)$endpoints$signals
#> endpoint compartment post_dose shape proportional
#> 1 cp NA TRUE TRUE NA
#> 2 pca NA FALSE FALSE NAWhat this cannot do. On a study with one continuous
endpoint and one dose level the classification rests on signal 2 alone,
and signal 2 passes for any endpoint with no pre-dose observation,
including a white-cell count. The remedy is endpoint_roles,
and the evidence is in the table above: a row reading “not computable”
under proportional is the classification saying how little
it had to go on.
Step 2: Detect the Design
Detection prunes the candidate set; the fit picks among what survives. Neither property below decides the model on its own.
Route
A declared route is not detected at all.
pmx_roles(adm = "ADM", routes = c("1" = "iv", "2" = "extravascular"))
names the administration column and says what its values mean, and the
two are declared together or not at all: which id is which route is a
convention of the dataset and cannot be read off the numbers, so a guess
would put every dose in the wrong compartment without failing.
Where a study declares both routes, the route is a property of each dose record rather than of the study, and the candidates are the mixed models — the intravenous form for the doses given intravenously and the extravascular form for the rest, summed. That sum is exact rather than an approximation, because every model in the closed-form set is linear in dose, and it is what lets one patient receive both. Bioavailability scales the extravascular doses and not the intravenous ones, which is what bioavailability means and why it is identifiable in a study dosed both ways and not in one dosed a single way. It is fitted as a fixed effect with no between-subject term: a design with one dose each way identifies the contrast between the routes, not a per-subject distribution over it.
A mixed model is fitted with explicit compartments rather
than the closed form, and bioavailability is the whole reason.
Under linCmt() the f(depot) directive does not
reach the doses the data sends to the depot: evaluated at known
parameters it scaled the intravenous doses instead, so f
was estimated as its own reciprocal and clearance and volume as
cl/f and v/f — a fit whose own predictions
tracked the data while reporting parameters that meant something else
(SIM-077). Written as d/dt(depot) and
d/dt(central), the compartments are real, f()
binds to the one it names, and the predictions match the analytic
profile on both routes. Only a mixed study pays the solver cost; a study
dosed one way has no f to place and keeps the closed
form.
Starting values for a mixed study are read one route at a time, for
the same reason the fit has to be: pooled, the cohort’s median profile
is an intravenous decline and an extravascular rise averaged together,
and no non-compartmental quantity read off it describes either route.
Disposition comes from the intravenous records, where f is
not in the way; absorption from the extravascular ones, where there is a
peak to find; and their two clearances give f itself a
starting value, since an extravascular read returns
cl/f.
Detection below runs only where nothing was declared. It answers with
one route for the whole study, which is the right answer for a study
dosed one way and no answer at all for a study dosed two ways — the
rate test reads a single nonzero rate anywhere as “this is
an infusion study”, so a mixed study came back as an infusion model with
no absorption in it.
A rate role carrying a nonzero value is an infusion, and
the infusion models are the candidates. Otherwise one property separates
the two remaining routes, and it is not how many patients peak
early.
A drug given by mouth cannot be in the blood at the moment it is swallowed. So a concentration observed at time zero after a dose is intravenous, and a median profile that rises before it falls is oral. A profile that declines from a first sample drawn some time after the dose is both of those at once, an intravenous bolus or an oral dose whose absorption finished before anybody looked, and no amount of reading the source separates them. There both routes are offered and AIC settles it.
Counting subjects instead is the mistake this replaces. In a study whose sampling starts well past the peak, every subject’s profile declines from its first point, and a per-subject vote reads an oral study as intravenous: it counts the sampling schedule rather than the drug. The share of subjects whose own profile rises is still reported, because it says how much of the cohort the median profile speaks for, but it decides nothing.
model_report(fit)$design$reason
#> [1] "the median profile rises to a peak at 9 before declining, and 31% of subjects do too"Sampling Richness
Across the cohort, distinct nominal times after a
dose holding an observation. Below six the fit still runs and warns: a
one-compartment model is not identifiable from that sampling, so its
parameters stay near their starting values and the simulated profiles
are a plausible shape rather than this study’s. There is no simpler
structural model to drop to — the simplest candidate is already the
one-compartment model. synpmx_avatar() and
synpmx_pca() carry sparse sampling without fitting a
structure to it. No post-dose observation at all is an error, since
there is then no curve to fit.
Per subject, distinct nominal times after a dose within the richest dose interval. Four or more, with two after the median peak, is labelled richer sampling. A sample before the peak is required for that label only where there is an ascending limb to sample, since an intravenous bolus peaks at the dose. This sampling heuristic does not establish parameter identifiability.
This count decides nothing. Both compartment counts remain available,
and model_report() prints it beside the selection, because
what it answers is how much within-subject time coverage the fitted
curve rests on.
Choosing the Compartment Count
The default first fits two compartments for the detected route. A fit that passes the checks in Step 3 is accepted without fitting one compartment. If it fails, one compartment is tried under the same checks. If both fail, estimation stops and lists the reasons. A stable two-compartment fit may be accepted even when a one-compartment model could describe the study.
Where the route is ambiguous, each route gets its own fallback
sequence. The accepted models are then compared by Akaike information
criterion (AIC). pk = "1cmt_oral" or
pk = "2cmt_oral" requests exactly that model, with no
automatic substitution. A vector such as
pk = c("1cmt_oral", "2cmt_oral") fits both and selects the
acceptable model with the lowest AIC, without an extra preference for
two compartments.
Step 3: Estimate the PK and PD Parameters
The population-fitting and acceptance operations below apply only to PK endpoints. A declared PD-only study proceeds directly to the PD time-course fits later in this step.
The population model is fitted to at most
max_fit_subjects patients, 60 by default, drawn in
proportion to the arms with the run’s seed. Fit time is linear in
subjects and goes into the per-subject inner loop — on
onc_sim, 40 patients fit in four minutes and 200 in
twenty-five — while the parameters a synthetic study needs are settled
long before the sixtieth patient. Every other model in Step 4 still
reads the whole study, and the report states the count.
The candidates are the structural PK models Step 2 left
standing. Each one is fitted as a population nonlinear
mixed-effects model through nlmixr2, which estimates the
fixed effects, the between-subject covariance matrix and the residual
error, and where more than one candidate is offered they are compared by
the rule above. The PD endpoints are estimated separately, and not as
mixed-effects models.
The candidate set is drawn from the package’s closed-form set and never goes outside it. A candidate the fitter could estimate and the generator could not simulate would be a model that fits and then generates nothing, so the two lists are one list. That is also why no dose effect on clearance is fitted: it would mean a candidate with no closed form, and generation would need a solver.
A subject the endpoint was never measured in is not
fitted. It adds no term to the likelihood, and a record
carrying doses and no samples is at best ignored by the solver. Its
dosing and its visits are still real and still reach the models of Step
4, which read the study rather than the table handed to the fitter. How
many subjects that left out is said before the fit, and it warns where
it takes the fitted cohort under min_subjects.
Each candidate carries log-normal between-subject variability on every structural parameter and a proportional residual error. An endpoint holding values at or below zero after censoring is handled gets an additive error instead, and the substitution is recorded on the fit.
The concentration is fitted with its censored observations
censored. Where the study declares a censoring column, the rows
below the assay limit reach nlmixr2 as censored – the limit
in DV, the flag in CENS, and the other bound
in LIMIT where the study reports one – so each contributes
the probability of falling below the limit rather than a value nobody
measured. The residual error determines the scatter of generated
observations, so substituting invented concentrations in the likelihood
would change the quantity that generation uses. The
case1_pkpd example retains an optimizer false-convergence
warning while passing the parameter and generation checks.
Everything else reads an imputed value, because nothing else has a likelihood to put censoring in: the visit model, the covariate model and the PD shapes are least-squares fits, and a stack of identical boundary substitutions would bend each of them toward the limit. Those rows are replaced by a uniform draw inside the censoring region – a draw rather than a fixed LLOQ/2, which would swap one artificial spike for another – and the boundary is put back when the synthetic data is emitted. The share of each endpoint sitting below the limit is reported with the fit either way.
Starting values come from a non-compartmental reading of the source
rather than from a guess, because a population fit started far from the
answer either converges slowly or reports the starting values back. The
terminal slope is fitted by log-linear regression over the points after
the peak, and everything leans on it. Clearance is the dose over the
area extrapolated to infinity, not over the trapezoid
alone, which on a study sampled to four half-lives understates the area
by about a tenth and overstates clearance by the same. Volume is
clearance over the terminal slope, which is the quantity the terminal
phase identifies, since dose over the peak is not a volume of any kind
for an oral dose. Absorption is solved from where the peak falls: for a
one-compartment oral model the peak sits at
log(ka/ke)/(ka - ke), one equation in one unknown once the
slope has given ke. For a two-compartment model the
steady-state volume comes from the mean residence time, with the
absorption mean taken back off for an oral dose, and is split between
the central and peripheral compartments.
The default tries two compartments, with one compartment as fallback. Allometric scaling, when requested, is folded into each fit.
synpmx_model_estimate(data, roles) # two compartments, then fallback if needed
synpmx_model_estimate(data, roles, pk = "2cmt_oral") # this model only
synpmx_model_estimate(data, roles, pk = c("1cmt_oral", "2cmt_oral")) # AICAcceptance Checks
A finite AIC alone does not establish convergence. For first-order conditional estimation with interaction (FOCEi), the optimizer must return a successful termination code and a finite AIC, except that false convergence alone is a warning. The report explains it as:
Warning: the optimizer stalled before convergence was confirmed. Check that the synthetic data reasonably reproduces the source data’s patterns and variability.
A fit with that warning can be accepted if its parameters and
generated profiles pass the remaining checks; converged
stays false and accepted is true. Iteration or evaluation
limits and other failures still reject the candidate. Stochastic
approximation expectation-maximization (SAEM) has no equivalent
termination code: the check compares mean parameter values in two halves
of the final fifth of its trajectory (at least ten rows). A change
exceeding 50% of the larger of one and the first-half absolute mean
rejects the fit. A trajectory with fewer than 20 usable rows or
non-finite values in its checked tail also fails. This is a coarse drift
screen, not proof that a stochastic fit has converged. A missing SAEM
AIC only prevents comparison when multiple accepted candidates
remain.
Fixed effects must be finite and positive, residual error finite and non-negative, and the between-subject covariance finite, symmetric and positive semidefinite within numerical tolerance. Standard errors and peripheral-parameter precision are not acceptance criteria. Estimates remaining within 1% of their starts warn; little movement alone does not prove failure.
The generation screen draws four new subjects for each fitted subject’s recorded dose and sampling design, using the generator’s population-effect, profile and residual-error functions. It uses expanded dose records, including infusion durations and mixed administration routes, and declared allometric scaling. It never uses the original subjects’ fitted random effects. An internal seed of 104729 makes the screen repeatable and preserves the caller’s random-number stream.
Missing observed designs, non-finite parameters or concentrations, nonpositive drawn parameters, and concentrations below -1e-8 fail. Tiny negative roundoff is clamped to zero. Each source reading supplies lower and upper bounds: an exact reading has identical bounds, a left-censored reading lies between zero and its limit, and an unbounded right-censored reading lies above its limit. A declared second interval boundary tightens those bounds. Inconsistent bounds fail. The medians of the lower and upper bounds enclose the source median. A generated median below 0.01 times a positive lower bound or above 100 times a finite positive upper bound fails. With uncensored data this is the ordinary median ratio check.
More than 10% of simulated profiles exceeding 100 times their source-design maximum also fails. The source maximum uses the upper observation bounds; designs with an unbounded right-censored reading are omitted from this check, and that omission is reported. These are engineering screens for gross failures, not validated goodness-of-fit cutoffs.
Less decisive discrepancies warn: a generated median more than twofold outside the source median bounds, an uncensored generated/source interquartile-range ratio outside 0.5 to 2, or a typical profile differing by more than fourfold at the median positive uncensored observation. Censoring disables the spread comparison, and the omission is reported. Numerical checks and comparisons supported by the remaining bounds still run. The pooled comparisons can miss time- or arm-specific errors; inspect the source-versus-synthetic plots for the profile’s rise, decline and spread.
model_candidates() lists attempted candidates in order,
with separate converged and accepted columns
and warning or rejection reasons in note. A fallback that
was not needed has no row. model_report() states why the
model was selected. The measured time includes fitting and acceptance
checks.
model_candidates(fit)
#> model converged accepted aic seconds note
#> 1 2cmt_oral TRUE TRUE 922.2948 50.869Covariates
The default "none" puts no covariate in the structural
model. A synthetic study does not need the relationship: covariates are
generated from the source’s own study-wide distributions either way, so
a clearance that moves with weight buys the generator nothing.
covariate_effects = "auto" applies allometric scaling on
clearance and volume where a weight-like covariate is declared, and fits
nothing else. The exponents are the standard 0.75 and 1 rather than
estimated ones, and the effect is asserted rather than
tested: it is folded into each fit, not compared against a
model without it. Testing it would double the cost of the whole call.
The reference weight the scaling is centred on is the cohort’s median
rounded to one significant figure, 70 rather than 71.7, because the
median of an odd number of patients is one patient’s weight and the
reference is stored with the fit.
The covariate is recognised by name, wt,
weight, bw and the like, and must be numeric
and positive. There is no way to recognise a body weight from its values
alone.
The cost of this default is explicit. A covariate
that influences the real profiles and is not in the model is generated
independently of them, so the synthetic data carries no relationship
between the two. model_report() reports the correlation
between each declared covariate and the individual random effects, which
is where an unmodelled relationship shows up.
Pharmacodynamics
Every remaining continuous endpoint is fitted against a constant, a
linear and an exponential time course and selected on AIC, with
between-subject variability on the baseline.
pd = c(endpoint = "linear") names the shape and skips the
search for that endpoint; a named shape that will not fit is an error
rather than a silent fallback. There is no minimum number of
observations: a shape is a candidate where it has one residual
degree of freedom, which is what stops a line being fitted
exactly through two points and then winning the comparison it was never
tested by. Below any candidate the endpoint is a constant at its mean,
which is what an endpoint measured once is, and the report says that
rather than naming a search that did not run.
These shapes have no concentration term, so a PD endpoint driven by exposure is reproduced as a time course that happens to resemble the average subject’s response.
One shape for the whole cohort by default, and one per arm on
request. The pooled fit predicts a single number for every arm,
so a synthetic patient’s dose does not reach their response.
pd_by_arm = TRUE fits the shape within each arm instead,
which is the same per-arm summary the dosing and visit models already
are and asserts no dose-response form – an arm is fitted on its own
observations, on the same three-candidate ladder, or not at all. It is
not the default because it is a different claim about the study: the
pooled shape says these endpoints are a time course, the per-arm shape
says each arm has its own. An arm holding too little of an endpoint to
fit keeps the pooled shape, so no arm is left with nothing to
generate.
The between-subject spread and the residual are read around
each subject’s own curve, not around the population one. Taking
the residual as the spread of every point about the typical curve puts
all of the between-subject variation into it, and generation then emits
that as independent noise per observation. On a study where subjects
share a shape and sit at different levels, almost the whole of the
structure comes back as scatter and no synthetic subject has a profile
at all. .pd_profile() is linear in baseline
for every shape, so a subject’s own baseline is a least-squares
projection needing no second optimizer, and it is the same quantity
generation draws: the between-subject term is the spread of those
baselines on the log scale, and the residual is what is left around each
subject’s own curve.
A shape that fails to fit stays in the table. The
exponential is tried from four start sets rather than one, because the
single median-based set fails on any response that falls and then
recovers, which is the shape a turnover endpoint has. Where none of the
four converges the candidate is reported with converged
false and the solver’s message rather than dropped, so a flat line
winning on AIC against two other flat lines is visible as that.
All three shapes are fitted by least squares rather than through
nlmixr2, so all three together cost no measurable time, and
the only compiled fits in the whole call are the PK
candidates. model_report() breaks the wait down by
fit: each concentration’s block carries what that fit took, one line per
candidate where the search compiled more than one. A search that spent
nine of its ten minutes in a second candidate is a run whose answer is
to name pk.
Binary and ordinal endpoints are not fitted at all. They are drawn
from one set of level frequencies over the whole study, as the shapes
are one time course, or one set per arm under
pd_by_arm = TRUE, so the response does not change over the
visits. A level fewer than min_category_patients patients
held is folded into the endpoint’s most common level first, as a rare
categorical covariate level is excluded; an endpoint at which no level
is held by that many patients is not drawn at all.
Step 4: Fit the Dosing Model and the Visit Model
Everything that is not the concentration-time curve comes from the
models synpmx_pca_summarize() already builds, unchanged in
what they represent. Dose reductions, interrupted cycles,
discontinuation and missed visits are modelled here exactly as they are
there, along with arm sizes, the censoring boundary and the schema.
Baseline covariates use the study-wide distributions described below.
The two generators call the same code rather than a copy of it; what
differs is only the adapter that hands it the grid, and this generator
writes its own over the nominal times the source holds.
The planned schedule is per arm, because it is the protocol. Everything read from how patients departed from it, and the visit model, is pooled over the arms, as the PD shapes are:
| Model | What it holds | Drawn at generation as |
|---|---|---|
| Planned schedule, per arm | The nominal dose times enough of the arm reached, and the modal
amount and infusion rate at each cycle among patients still on their
starting dose. Where no amount is shared by
min_arm_patients patients, as on a study dosed per
kilogram, the arm’s mean to two significant figures |
The cycle grid every subject starts from |
| Dose ladder | The levels patients dropped to, as ratios to their own starting dose
to one significant figure, built from within-patient decreases: every
level min_arm_patients patients of some arm share, one
ladder for the study |
The amount multiplier in force at a cycle |
| Reduction rate | Discrete-time hazard of stepping down a level, pooled over arms | Decided before the cycle is dosed |
| Interruption rate | Discrete-time hazard of skipping a cycle without ending treatment, pooled over arms | Decided at the cycle |
| Discontinuation rate | Discrete-time hazard of stopping treatment, pooled over arms | Decided after the cycle is dosed |
| Visit model | The visits each arm has, where at least
min_arm_patients of its patients were observed, and one
attendance rate per endpoint: the share of that endpoint’s scheduled
visits at which a patient was observed |
Attendance, drawn per visit at its endpoint’s rate |
Pooling puts every patient behind each rate. Its cost is that an arm whose patients reduced, stopped or missed visits more often than the others’ takes the study’s rates, so a dose-dependent discontinuation is not reproduced, and that attendance no longer depends on when in the study a visit falls: a late visit few patients reached is generated as often as an early visit of the same endpoint, though each endpoint’s number of observations per patient is kept on average. An arm that never has a visit, such as a placebo arm without drug sampling, still has none.
A study where nobody reduces, skips or stops early has all three rates at zero and one level, and the model then reproduces the planned schedule exactly. No detector decides which kind of study this is, because the rates already say it.
Every arm, visit, rate and level rests on at least
min_arm_patients patients on each side, 3 by
default. A grid cell fewer patients were observed at is dropped, a
dose-change rate whose events fewer patients had, across every arm, is
zero, and an endpoint’s attendance rate is 1 where fewer patients missed
any of its visits. Each arm also carries the values of its
strata and keep columns, copied from the arm’s
first patient; a keep value fewer than
min_category_patients of the arm’s patients hold is written
as missing, with a warning. Why both sides of a rate are floored is in
the privacy article.
Baseline covariates are drawn independently from study-wide distributions. Each patient’s first row contributes one value, after small arms are excluded.
A continuous covariate is summarized by a mean and an SD: of its logarithm where every value is positive, of the value otherwise. Both are taken after the highest and lowest 5% of patients are set aside, at least one at each end, and the SD is divided by the SD a standard normal keeps under the same trimming, since the tails are what was removed. A cohort too small to leave three values after trimming is summarized whole.
For factor, character and logical covariates, levels held by fewer
than min_category_patients patients (default 3) are
excluded. The remaining counts are renormalized to probabilities, and
every synthetic subject draws from that distribution. Set
min_category_patients = 1 to retain all observed levels.
Numeric category codes must be supplied as factors; numeric covariates
keep their continuous distribution models. Binary and ordinal endpoints
are folded by the same threshold at each visit (Step 3).
If no categorical level remains, generation keeps the column with missing values and estimation warns. Excluded factor labels are also removed from the stored schema. This changes the category proportions and can remove a covariate entirely from analysis. The threshold is applied during estimation, so stored fits must be re-estimated to use it.
Step 5: Store the Fit
Estimated by nlmixr2: the selected structural model, the
candidate comparison table, fixed effects, the between-subject
covariance matrix, the residual error, the covariate effects that
survived, the PD shape and parameters per endpoint, and how long the
fitting and the whole call took.
Not estimated: the dosing model, a planned schedule per arm with three rates pooled over arms, the visit model, arm sizes, the covariate distributions, the censoring boundary, the schema and the roles.
Only the fingerprint leaves the study. The fit also
carries diagnostics for whoever ran it, such as the AIC of every
candidate, the starting values and the correlations between covariates
and random effects, and generation reads none of them.
model_fingerprint() returns the part generation reads, and
synpmx_model_generate() attaches that to its output rather
than the fit. Printing the fit, or model_report(), gives
the same account; vignette("pmxmodel-fingerprint") lists
every number in a fingerprint.
Four operations protect the fingerprint as it is stored, each set out with its evidence in the privacy article:
- Rounding. Every released PK and PD estimate is rounded to one significant figure, and the covariate summaries to two. The acceptance checks of Step 3 run on the fit before rounding, and the full-precision PK estimates stay on the fit, outside the fingerprint.
-
Single-patient influence. Estimation reads how far
leaving out each patient would move each released estimate. A patient
who moves one by 15 or more (in percent for a typical value, in points
of between-subject SD on the log scale for a spread, and also by one
standard error for a PD shape parameter) is left out of the PK fit, the
PD fits and the covariate summaries, and estimation runs again. The
patient stays in the dosing, visit and arm models. Rounds repeat until
nobody is flagged, and stop before a tenth of the cohort has been left
out; a reading still at 15 is a review and at 30 a failure.
drop_influential = FALSEkeeps every patient and only reports the reading. - No individual estimates. Each patient’s empirical Bayes estimates (EBEs) are read for the covariate correlations and the influence reading, then dropped. Generation draws random effects from the covariance matrix instead.
-
Rare levels. The schema stores no factor or
discrete-endpoint level fewer than
min_category_patientspatients hold. An inferred binary or ordinal type left with fewer than two levels is stored as whole numbers.
model_privacy_checks() runs five checks on the
result.
model_report(fit)
#> Summarized from the source, not estimated
#> cohort 32 patients in 1 arm(s)
#> all (32)
#> dose changes none
#> visit grid 2 endpoint(s) at 16 nominal time(s), 22 slot(s) in all
#> visit attendance share of scheduled slots attended, one rate per
#> endpoint: cp 55%, pca 91%
#> covariates wt lognormal, age lognormal, sex categorical, drawn
#> once for the whole study, independently of the
#> profiles
#> columns emitted id, time, ntime, dv, amt, evid, dvid, wt, age, sex
#>
#> Values at the lower limit of what was observed
#> cp 0.2, no assay limit: half the lowest value several patients reached, rounded down
#> pca 5, no assay limit: half the lowest value several patients reached, rounded down
#>
#> Each non-PK continuous endpoint, fitted as constant, linear, or exponential
#> pca exponential
#> plateau 30
#> baseline 100
#> rate 0.1
#> between-subject 0.1 (SD on the log baseline)
#> residual additive 10
#> chosen on AIC from constant, linear, exponential
#>
#> PK endpoint for the PopPK model
#> cp inferred from the following data characteristics:
#> absent before the first dose
#> rises to one peak and comes back down within one
#> dose interval
#> route oral: the median profile rises to a peak at 9 before
#> declining, and 31% of subjects do too
#> sampling median 6 distinct times after a dose, 6 after the
#> peak: sparse within-subject sampling
#>
#> The PopPK model
#>
#> Estimated by nlmixr2
#> structural model 2cmt_oral
#> selected by two-compartment model passed acceptance checks
#> fitted on all 32 patients with a concentration
#> fixed effects cl 0.1, v 7, q 0.1, v2 2, ka 0.4
#> between-subject cl 0.265, v 0.2, ka 0.548, q 0.0775, v2 0.632 (as SD on the log scale)
#> residual error proportional 0.2
#> time to fit 50.9 s
#> whole call 51.1 s, against 50.9 s in the fitter
#>
#> Privacy
#> one patient's pull pass: largest: cp: ka between-subject SD, 9.84 points;
#> `model_privacy_checks()` has every checkStep 6: Generate New Subjects
Per synthetic subject: assign an arm keeping the source arm shares, draw covariates from the study-wide covariate model, draw random effects from the covariance matrix, apply the covariate effects to the typical parameters, draw the dose schedule from the arm’s dosing model, draw the visits attended from the visit model, evaluate the profile at the attended times against the drawn schedule, add residual error, apply the censoring boundary and emit.
The order of the two draws is fixed. The schedule is
drawn first and the profile is computed from it, so a synthetic subject
who steps down a dose level has a lower exposure from that cycle on, and
one who skips a cycle has the trough that implies. This is the one thing
the generator does that neither synpmx_avatar() nor
synpmx_pca() does. Drawing a profile and then a schedule
would put a subject’s dose reduction in the dosing records and not in
the concentrations.
The proportional multiplier is lognormal. Written as
1 + N(0, cv) it is non-positive with probability
pnorm(-1 / cv), which is 2.3% of draws at a coefficient of
variation of 0.5 and 7.7% at 0.7, and each of those is a concentration
clamped to zero. A coefficient of variation that high is a statement
that the structural model does not describe the data, and it belongs in
the output as a wide band rather than as a scatter of zeros on the floor
of a log axis. exp(N(0, sqrt(log(1 + cv^2)))) has the same
coefficient of variation and a median of one, so the spread the fit
estimated is preserved and no draw reaches zero.
A value is floored near the smallest the study
reported. The residual is not the only thing that can put a
synthetic value below anything the assay could return: a one-compartment
profile evaluated late in a long dose interval underflows on its own. A
study that declares a censoring column says where its assay stopped, and
that boundary is put back. A study that declares none still had an
assay, and its lowest values are the only evidence of where the limit
sat. The floor is half the lowest value that at least
min_arm_patients patients reached, each patient’s own
smallest value taken first, rounded down to 1, 2 or 5 times a power of
ten, the way assay limits are written. It is emitted wherever a draw
falls below it, so it is not half the single smallest value, which is
one patient’s measurement. An endpoint that reports a zero is given no
floor, since a zero is a value a floor would contradict.
model_report() carries the floor per endpoint.
A floor that catches a large share of the output is a
warning, not a repair. Raising a value is still changing it,
and a floor doing that to much of a dataset is hiding a fitted model
that does not describe the low end of the data. What the floor caught is
recorded on the generated dataset as the pmx_floored
attribute, and above one observation in twenty the generator says
so.
Gates
Only a study the generator cannot fit at all is refused. A study it can fit badly is fitted, and told about.
| Gate | Threshold | Result |
|---|---|---|
| Cohort size | 20 subjects | Warns and fits. A covariance matrix fitted to a handful of subjects
describes those subjects, and nothing downstream will say so: the
scorecard asks whether the output copies anybody or changed the study’s
shape, and a small-cohort fit does neither. synpmx_pca()’s
floor is 10 and this one is higher because a parameter estimate
concentrates on its cohort faster than a score does. |
| Fit cap |
max_fit_subjects = 60 |
The PK model is fitted to a subset drawn in proportion to the arms;
the dosing, visit and covariate models read every subject. Reported in
model_report(). Cannot be set below
min_subjects. |
| PK cohort time coverage | 6 distinct nominal times after a dose | Warns and fits. Below it a one-compartment model is not identifiable and the parameters sit close to their starting values, which is the caller’s call to accept. |
| No observation after a dose | PK endpoints only | Errors, naming which of the role columns the empty count came from: nothing selected as an observation, nothing selected as a dose, or the two never meeting in one subject. |
| Arm size | 3 patients | Warns and drops those patients before anything is fitted, so the arm is absent from the model and from the data generated from it. Inherited from the dosing and visit models, which are summaries of an arm: an arm of one or two has no rates to pool. |
nominal_time undeclared |
— | Errors. The grid is a statement about the protocol only the caller can make. |
| Rare categorical level |
min_category_patients = 3 |
Exclude levels held by fewer distinct patients; renormalize remaining counts. If none remain, warn and generate missing values. Set to 1 to retain all observed levels. |
| Rare discrete-endpoint level |
min_category_patients = 3 over the whole study, or per
arm under pd_by_arm
|
Fold the level into the endpoint’s most common level. An endpoint with no level held by that many patients is not drawn. |
| Single-patient influence | a move of 15 or more, in % or points; for a PD shape parameter, also one standard error or more | Leaves the patient out of the PK, PD and covariate estimates and
estimates again, at most a tenth of the cohort, naming them on the
console. A move still at 15 is a review and at 30 a failure that warns;
drop_influential = FALSE skips the leaving out. Read by
model_privacy_checks() and scorecard row E3; nothing per
patient is stored. |
| Thin dose-change rate | fewer than min_arm_patients patients with the event,
over every arm |
The rate, pooled over arms, is zero. |
| Attendance rate | fewer than min_arm_patients patients who missed any of
an endpoint’s visits; fewer than min_arm_patients of an
arm’s patients at a visit |
That endpoint’s rate is 1. An arm with too few of its patients at a visit does not have it. |
| Rare kept value | a keep value of any type fewer than
min_category_patients patients in the arm hold |
Written as missing, with a warning. strata columns are
carried unchanged. |
| Rare level in the schema | a factor column’s level, or a binary or ordinal endpoint’s level,
fewer than min_category_patients patients hold |
Not stored. A column copied onto arms keeps the values its arms carry. An inferred binary or ordinal type left with fewer than two levels is stored as whole numbers. |
| No grid cell shared | min_arm_patients |
The cell is dropped. A nominal time one patient attended is that patient. |
| Administration column |
adm and routes together |
Errors on either alone. What an administration id means is a convention of the dataset, and reading it wrong routes every dose to the wrong compartment silently. |
| PD shape candidacy | 1 residual degree of freedom | The shape is dropped from the comparison, not the endpoint from the
study. Below every candidate the endpoint is generated as a constant at
its mean. A shape fitted exactly through its own points has
AIC -Inf and would win any comparison it
entered. |
| No PK endpoint identified | — | All continuous endpoints fail the baseline signal, or there are only
discrete endpoints: infer PD-only and skip PK fitting. Other unresolved
classifications error and name endpoint_roles. |
| Optimizer convergence | Successful termination or false convergence alone; finite AIC for FOCEi | False convergence warns and leaves acceptance to the remaining checks; other failures reject the candidate. SAEM uses the trajectory screen described in Step 3. |
| Parameter validity | Positive finite fixed effects; non-negative finite residual; usable covariance | Rejects the candidate. |
| Generation validity | Finite parameters and concentrations; numerical negatives below -1e-8 rejected | Rejects the candidate, using new population draws on the recorded designs. |
| Gross generation discrepancy | Median more than 100-fold outside source median bounds, or over 10% of profiles above 100 times their source-design maximum | Rejects the candidate. Censoring supplies bounds; unbounded source maxima omit the affected designs from the upper-tail check. Omissions are reported. |
| Moderate generation discrepancy | Median more than twofold outside source median bounds; uncensored interquartile-range ratio outside 0.5–2; median typical-profile discrepancy over fourfold on positive uncensored observations | Warns; does not trigger fallback. Censoring omits the spread comparison. |
| No candidate accepted | — | Errors with each attempted model’s rejection reason. |
| The between-subject terms did not move | 1% of the eta init, fixed effects moved | A line under the parameter block in model_report(), not
the banner: the fixed effects are estimates, but the spread synthetic
subjects get is the starting value rather than this study’s. |
| Two endpoints tie as concentrations | neither tie-break separates them | Errors, naming both and endpoint_roles. Naming several
is accepted, and each is fitted its own model. |
| The fit did not move | 1% of every starting value | Warns, and prints !! THE FIT DID NOT MOVE !! above the
parameter block in model_report(). A converged-looking fit
is not necessarily an estimated one: where the optimizer takes no
effective step, nlmixr2 returns an objective, an AIC and a
full table of starting values, and nothing else downstream contradicts
them — the generator simulates from them and the scorecard passes,
because it asks whether the output copies anybody or changed the study’s
shape and a fit that never moved does neither. Each parameter’s start,
estimate and percent change are listed, because “did not move” is a
claim the reader has to be able to check. |
| A fitted model as a public input | — |
pmx_prior(), synpmx_prior() and
synpmx_calibrated() refuse a pmx_fitted_model,
because its parameters were estimated from the confidential study. |