Shedding-curve modelling: methods
How the fitted estimates in shedding_hub/data/shedding_catalog.yaml are
produced, what they mean, and where they should not be trusted.
The catalog currently holds 184 fits over 64 studies and 120 analytes: 119 exponential, 46 gamma, 19 gamma_shifted, across 14 biomarkers.
1. Models
All three are expressed on the log10 scale, which is the scale the likelihood is
evaluated on. c0 is a natural-log-scale intercept, so a log10 concentration is
c0 / ln(10).
| model | curve | parameters | peak |
|---|---|---|---|
exponential |
c(t) = c0 · e^(−a0·t) |
a0, c0 |
t = 0 |
gamma |
c(t) = c0 · t^b0 · e^(−a0·t) |
a0, b0, c0 |
t = b0/a0 |
gamma_shifted |
c(t) = c0 · (t−t0)^b0 · e^(−a0·(t−t0)) |
a0, b0, c0, t0 |
t = t0 + b0/a0 |
a0 is the decay rate, giving a half-life of ln(2)/a0 — exact for the
exponential model, asymptotic for the other two once t is well past the peak.
b0 governs the rise. t0 is the onset of shedding.
Choosing between them is not an AIC comparison. The models are fitted to
different observation sets, and AIC only compares models fitted to the same
data. gamma discards every reading at t ≤ 0, where its curve is undefined;
gamma_shifted keeps the detected ones. On kissler2021viral that is 2072
observations against 1679, and the resulting AICs are not commensurable. The
selection rule is data availability, encoded in the gates below, not fit
statistic. Compare n_measurements before comparing aic.
gamma_shifted exists because the reference event is not the same event across
studies — the catalog spans symptom onset, enrollment, confirmation date,
vaccination and hospital admission — and because gamma was discarding 26,023
detected measurements at exactly t = 0. Its t0 is the quantity that makes
those five reference events commensurable.
2. Estimation
Censored maximum likelihood. Roughly 37% of measurements in the repository
are reported negative. They enter the likelihood as left-censored
observations — contributing the information that the concentration was below the
limit — rather than being dropped, which biases decay rates slow and inflates
simulated late-phase shedding.
The censoring limit is the analyte's declared limit of quantification, then its
limit of detection, used as declared. Reported positives below that limit are
kept as observed data: a number the assay reported is still a measurement, and
the limit describes only the value a negative is known to lie under.
Two-stage, fitted jointly. One optimisation per analyte over every subject's
parameters plus a single shared measurement-error SD sigma, by L-BFGS-B on
theta. For the positive-scale parameters theta = log(parameter), which makes
positivity automatic; gamma_shifted's t0 is a time on the whole real line and
is carried untransformed, with its constraint imposed as a bound (§4).
Population summary. Subjects are summarised as a multivariate normal, but not in their log-parameters — see §3. The summary uses only non-degenerate subjects (§4).
3. Population coordinates
A population summary averages subjects and treats the result as a Gaussian. That is only defensible in coordinates where the subjects form a compact, roughly-elliptical cloud, and the natural log-parameters are not those coordinates for any of the three models.
| model | coordinates |
|---|---|
exponential |
log_a0, peak_log10 |
gamma |
log_a0, log_peak_day, peak_log10 |
gamma_shifted |
log_a0, log_rise_days, peak_log10, t0 |
A cycle-threshold fit names its height peak_cycles instead — it is cycles
below CT_REFERENCE, not a log10 concentration. The temporal coordinates are
unchanged, because they mean the same thing on either scale.
For the rise-and-fall models, c0 is not separately meaningful. It is the
concentration at t = 1, so a plausible value depends entirely on b0. On
woelfel2020virological stool, b0 spans 0.024 to 6.80 while c0 counter-varies
from 19.6 down to 2.5, corr(log b0, log c0) = −0.63. The subjects lie on a
curved ridge, and a coordinate-wise average lands off it: the naive summary
peaked at 3.33 log10, below every one of the six subjects it summarised.
For the exponential model the reason is the tail, not the ridge. Since
log10 c(0) = c0/ln(10), modelling log c0 as normal makes the log10
concentration lognormal and the concentration a double exponential of the
draw. Simulated day-0 concentrations reached a 99.9th percentile of 10^16.6 and a
worst draw of 10^25, against eight real subjects topping out at 10^9, with the top
0.1% of a cohort carrying essentially all of its shed load. Taking peak_log10
as a coordinate makes a draw k units above the mean land exactly k log10
above it, and the worst draw falls to 10^15.4.
This costs a little median-individual accuracy — measured over a 10-fit sample the new coordinate was closer on only 3 of 10 — and is accepted because the two costs differ by orders of magnitude and simulation is what the catalog is for.
median_params is the population mean mapped back to parameters. Because each
coordinate is normal, that is the median individual, not a compromise. Note it
is not the population's mean trajectory: to aggregate load across a cohort,
simulate rather than scaling it up.
4. What is excluded, and why
Readings
| rule | applies to | reason |
|---|---|---|
t < −5 days |
all models | Every measurement in the repository earlier than about day −3 is censored, and across all 93 datasets the earliest detected reading anywhere is day −5, so no measured value is ever discarded by this rule. Under a decay-only model those censored points are near-impossible and distort the fit: tsang2016individual NPSOPS had its pre-event readings a median 4.01 log10 below its own curve. |
t ≤ 0 |
gamma |
ln(t) is undefined. Not a judgement — there is nothing to evaluate. |
t ≤ 0 and censored |
gamma_shifted |
The curve dives toward −∞ near t0, so "below the limit" there is explained for free and t0 becomes a support parameter pulled onto its own bound. A detected reading at the same time is kept, and repels t0 instead. |
Subjects
A subject is excluded from the population summary — but kept in
subject_params, flagged — when its fit is an artifact rather than an estimate:
- collapsed: any parameter at or below 0.01
- pinned: any parameter at the optimizer's bound of
e^25 - runaway decay: implied half-life below 0.1 days
- over-extrapolated: implied peak more than 3 log10 above the highest concentration the analyte ever recorded
The last two thresholds are calibrated in log10 units and applied to whatever scale the fit is on, so on a cycle-threshold fit they are stricter by roughly the standard-curve slope. See Cycle-threshold fits below.
The last of these is judged against the data rather than a fixed bound, because
what counts as absurd depends on what the assay can see. It matters: when the gate was
introduced, 66 subjects across 34 fits implied peaks beyond that ceiling,
reaching 10^47. One
fajnzylber2020sars nasopharyngeal subject with a 0.30-day half-life, first
sampled on day 30, implied 10^33 gc/mL — a hundred half-lives of backward
extrapolation — while its own highest reading was 10^2.7. Averaging those in left
87% of that analyte's observations below its own median-individual curve;
excluding them brought it to 48%.
For gamma_shifted, t0 is additionally bounded half a day below each subject's
earliest reading, so a curve is never undefined at its own observations.
Analytes
catalog.skipped records a reason for every refusal. Current counts:
| reason | n | meaning |
|---|---|---|
ct_units |
234 | Cycle thresholds, excluded from this catalog but not unfitted — see below |
no_rise_observed |
69 | Fewer than 50% of subjects peaked later than their first reading, leaving b0 unidentifiable |
no_pre_event_readings |
68 | gamma_shifted only: no detected reading at or before day 0, so t0 has nothing to locate |
too_few_subjects |
59 | No subject cleared the per-subject minimum |
degenerate_fit |
31 | Too few subjects survived the checks above |
too_few_subjects_for_population |
26 | Too few subjects to estimate a covariance |
non_pathogen_biomarker |
12 | crAssphage, PMMoV, mtDNA — indicators, not shed pathogens |
no_positive_measurements |
4 | Nothing ever detected |
no_data_after_reference_event |
1 | Sampling stopped at day 0, so no post-event trajectory exists |
ct_units is a routing decision, not a refusal. Cycle-threshold analytes are
fitted — 50 converged fits over 23 studies at the time of writing — and those
fits are what the website's dataset pages
draw for a Ct analyte. They are kept out of this catalog because its heights
are log10 concentrations and a Ct fit's are cycles below CT_REFERENCE, so an
ensemble averaging the two would be averaging incommensurable quantities. Build
them into a catalog of their own with
fit_shedding_models(datasets, value_types=("ct",)), or fit one directly with
fit_shedding_model, which needs no opting in at all.
Cycle-threshold fits
Ct = α − β·log10 C is affine, so a gamma curve in concentration is a gamma
curve in Ct. Cycle-threshold analytes are fitted on CT_REFERENCE − Ct —
cycles below a fixed reference of 40, which rises with viral load exactly as a
log10 concentration does.
Because a0 and b0 both come back multiplied by the unknown standard-curve
slope, that slope cancels in b0/a0. Peak time, onset and rise duration are
therefore invariant as algebra, with no assumption about PCR efficiency —
where the two fits converge to corresponding solutions they agree to about
0.01 days. Decay rate, half-life and peak height are not invariant:
SheddingFit.comparable_with says which is which for any pair of fits.
That invariance is a statement about the parameters, not a promise about the estimates. Measured on the studies that report both scales, the two fitted population peak times are not interchangeable; see How far invariance actually gets you below before comparing one against the other.
Onset is invariant for a different reason from the other two, and it is worth
being exact about which. Rise duration is b0/a0 and peak time is that ratio,
so for them the slope cancels. t0 is a time: the transform rescales and
offsets the response axis and leaves the time axis untouched, so an onset in
days is the same number on either scale — there is no slope in it to cancel.
A Ct fit's height coordinate is named peak_cycles, not peak_log10, and
catalog.table carries a value_type column, so a height in cycles cannot be
read as a log10 concentration by accident. simulate_shedding refuses a Ct
source for the same reason: turning cycles into a concentration needs that
assay's standard curve, which the studies do not report.
What is not invariant across scales: which subjects are kept
Two of the subject-level gates in §4 are calibrated in log10 units and applied unchanged to the Ct response, which is in cycles:
| gate | calibrated as | on the Ct scale |
|---|---|---|
| over-extrapolation margin | 3 log10 above the highest observation | 3 cycles, ≈ 0.9 log10 at a slope of 3.3 |
| minimum half-life | 0.1 days | ≈ 0.1 days on a decay rate carrying the slope, so ≈ 0.33 days in concentration terms |
Both are therefore roughly β times stricter on the Ct scale, β being the standard-curve slope (about 3.3 for a fully efficient assay). They are deliberately not rescaled: dividing by a nominal slope would put an assumed PCR efficiency back into the fitting path, and this package fits Ct directly precisely so that no efficiency is ever assumed.
The consequence is that subject retention is not scale-invariant even though
the parameter mathematics is. It is not a marginal effect: on
cdc2024nhphrn, 40% of the subjects retained on the Ct scale sit within twice
the minimum half-life, against 2.8% on the concentration scale, and 20 subjects
are excluded as degenerate on Ct alone against 4 on concentration alone.
How far invariance actually gets you
Three studies report both a Ct and a concentration analyte on the same specimen
and subjects. teunis2015shedding supports only the exponential model, whose
peak_day is pinned to 0 by construction, so it tests nothing; the evidence is
kissler2021viral and cdc2024nhphrn. Comparing subjects retained as
non-degenerate on both scales, so that retention differences are excluded:
| study | model | n | median |Δ peak day| | IQR | max |
|---|---|---|---|---|---|
kissler2021viral |
gamma | 33 | 0.46 d | 0.07–1.66 | 105.9 |
kissler2021viral |
gamma_shifted | 35 | 0.57 d | 0.24–0.86 | 4.9 |
cdc2024nhphrn |
gamma | 51 | 0.19 d | 0.05–0.60 | 4.0 |
cdc2024nhphrn |
gamma_shifted | 48 | 1.03 d | 0.45–1.50 | 2.7 |
The algebra is not what fails. a0_ct/a0_conc should equal the standard-curve
slope, and kissler2021viral reports one of −3.61: among the nine subjects
whose ratio lands within 2% of it, the median peak-time difference is
0.013 days. Between 2% and 10% it is 0.38 days, and beyond 10% it is
1.97 days — a rank correlation of 0.83 between ratio error and peak
disagreement. What diverges is the optimiser, on subjects too weakly
identified to pin down: kissler2021viral is 91% censored.
Two cautions on top of that. Under gamma the disagreement is directional —
Ct peaks earlier than concentration in both studies — which convergence noise
alone does not explain, and the over-extrapolation gate above is a plausible
but unconfirmed cause. And in both studies the concentration column is derived
from Ct through a standard curve rather than measured independently, so these
numbers test the transform's algebra, not two independent assays.
Practical guidance. Treat a Ct fit's peak time as comparable with a concentration fit's in kind, not in value. It is sound for ranking and for order-of-magnitude reasoning, and for well-identified subjects it is accurate; it is not a drop-in substitute at the study level, and a difference of half a day between scales is ordinary rather than a sign of error.
5. Simulation
simulate_shedding draws each individual's coordinates from
MVN(population_mean, population_cov), maps them back to parameters, and
evaluates that individual's own curve. incubation_period shifts the clock from
the reference event to infection; include_measurement_error adds N(0, sigma)
assay noise, off by default because an agent-based model wants the concentration
the host is shedding, not what an assay would report.
Simulated cohorts are over-dispersed, always in the same direction. Two-stage
estimation does not shrink individual estimates toward the population mean, so
population_cov carries within-subject estimation error on top of true
between-subject variance. The bias only runs one way, which is what makes the
dispersion argument a defensible correction rather than a fudge: it scales the
covariance by dispersion², preserving the centre and the correlation structure.
There is no automatic way to choose it — it is a judgement about how much of the
fitted spread is real.
On woelfel2020virological stool, day-0 concentration:
| dispersion | 99th pct | max | top 1% share of load |
|---|---|---|---|
| 1.0 | 11.13 | 15.43 | 98% |
| 0.7 | 9.70 | 12.71 | 82% |
| 0.5 | 8.75 | 10.90 | 50% |
The principled fix is variance deconvolution (Global Two-Stage): subtract the mean within-subject estimation covariance. It is not implemented — it needs per-subject uncertainty the fitter does not estimate and the catalog does not serialize.
Reference events are not interchangeable
The catalog spans seven reference events, in three classes. inoculation and
vaccination are the exposure, so nothing separates them from time zero.
symptom onset is a natural-history landmark, a defined and documented offset
from infection. enrollment, confirmation date, hospital admission and
treatment are administrative: they record when a subject entered a study, was
tested, or was admitted, which depends on testing behaviour and health-system
access rather than on their infection.
Only the landmark class earns an infection time origin. simulate_shedding
warns when an incubation period is applied to either of the others, and records
time_origin as "<event>_shifted" rather than "infection". shedding_for
prefers the classes that can be anchored, which is why it will pass over a
better-supported fit measured from an administrative date.
These are two different ideas and the ranking uses the weaker one: exposure and
landmark events can both be placed on an infection timeline, which is what
shedding_for prefers, but only a landmark earns
attrs["time_origin"] == "infection" — an exposure event already is the
exposure, so there is no incubation period to shift back through.
6. Reading an estimate honestly
peak_log10is evaluated at the peak, which for the exponential model ist = 0by definition. Read it besidemedian_first_observed_day: when that is well above zero, the peak is a backward extrapolation to a time most subjects were never observed at, not a measured concentration.- A high
pct_censoredis not a defect. It means the analyte is rarely detected, and a lowpeak_log10there is the fitter reporting honestly. converged=Falsedisqualifies the rest of the row. No fit in the shipped catalog currently carries it. The five that used to — allnatarajan2022gastrointestinal— were not failures of the model but of the evaluation budget: L-BFGS-B stopped for want of allowance while still descending. The optimizer now continues a round that ended only for that reason, and all five converge. One had been badly short rather than marginally so, gaining 76 log-likelihood units when allowed to finish.n_subjectscounts subjects fitted, not subjects summarised.n_degenerate_subjectsis the difference.
7. Reference tables
make parameters writes both, from the shipped catalog, fitting nothing:
docs/shedding_parameters.json— one record per fit, each carrying the population mean and covariance,sigmaand the censoring limit. Enough to simulate from without this package and without refitting.docs/shedding_parameters.csv— the flat browsing view, one row per fit. Drops the covariance, which is a k×k matrix per fit and does not belong in a spreadsheet column.
In Python, load_shedding_catalog().table is the same flat view as a DataFrame,
and catalog.select(...) returns a fit ready to simulate.
8. Design records
Each decision above was recorded when it was made:
docs/superpowers/specs/2026-07-18-shedding-simulation-design.md— the fitting and simulation pipelinedocs/superpowers/specs/2026-07-27-catalog-fit-plots-design.md—plot_catalog_fitsdocs/superpowers/specs/2026-07-29-catalog-fit-diagnostics-design.md—plot_fit_diagnosticdocs/superpowers/specs/2026-07-29-simulation-dispersion-design.md— the exponential coordinate change,dispersion, and the over-extrapolation gate