Multivariate normative modeling for the clinical modality in mental health.
Score clinical instruments correctly. Turn each scale into an age-, sex- and cohort-adjusted centile. Then model the dependence between those centiles — and read the signal no single score can see.
Warning
psynorm is in pre-alpha design stage. This repository currently contains the design specification, not a released package. The API shown below is the target surface, not yet installable from PyPI. Nothing here should be used for clinical decisions — see Scope of use.
Someone can sit at the 60th percentile for depression and the 55th for anxiety — perfectly normal on both — and still be profoundly atypical, because those two scales correlate at 0.75 in the population and this person shows a 30-centile gap between them.
A univariate z-score cannot see that. A joint model can.
That discrepancy — two scales that move together in the population failing to move together in one person — is exactly the signal that no individual score captures, and it is what a dimensional clinical profile should surface.
Mature normative-modeling tools (BrainChart, CentileBrain, PCNtoolkit, Brain MoNoCle) all come from neuroimaging, and they model each feature in isolation. The clinical modality — questionnaires, scales, screeners — has no equivalent, and joint modeling of several scales at once is done by no one.
psynorm builds three chained capabilities:
- Score instruments with correct, cited, auditable rules, preserving the item level.
- Normativize — convert each scale into a centile adjusted for age, sex and cohort.
- Model the dependence between those centiles, and with it deliver:
- cross-instrument prediction, returned as a full distribution (never a point value),
- multivariate atypicality, decomposed by scale and by pair,
- a Gaussian expert exporting natural parameters, couplable to other modalities.
The third layer is the contribution. The first two are its condition of possibility.
"Impute a missing questionnaire from the others" is multiple imputation — forty years old and well implemented (mice and kin). If psynorm were framed as imputation, it would not be novel. What is genuinely different:
| MICE | psynorm | |
|---|---|---|
| Operates on | raw scores | normative centiles, already adjusted for age, sex and cohort |
| Produces | imputed values | a distribution per scale, plus a couplable expert for other modalities |
| Joint atypicality | not its goal | yes — decomposed by scale and by pair |
| Marginals | implicit in the conditional model | explicit, calibrated, separately validatable |
The comparison against MICE is mandatory and reported no matter the outcome. Hiding it would be the worst strategic error possible.
These are not details — each one changes the architecture.
- P1 — Scale scores are discrete, bounded, and floor-heavy. A PHQ-9 total lives in {0,…,27}, with much of a community sample at 0–3. The ordinary probability integral transform
u = F(x)does not produce uniforms on discrete data — it creates massive ties that bias the dependence matrix toward zero. psynorm uses a randomized PIT internally and reports centile intervals to the user. A PHQ-9 of 3 in a community sample may span the 40th–60th centile: that is the instrument's real resolution, not method noise. - P2 — A single dominant factor is expected. Self-report psychiatric scales load onto a general distress factor (the p-factor). Σ will, to first approximation, be "everything correlates with everything." That is a replicated finding to be measured and reported, not discovered late.
- P3 — Common method variance. Much of the correlation between two self-reports is shared method, not shared construct. The expert partially learns response style, not psychopathology — a self-report-specific confound to be probed where informant reports exist.
import psynorm as pn
# --- Layer 1: scoring ---
scored = pn.score_frame(
df, instrument="phq9",
columns=pn.map_columns(df, "phq9", prefix="phq_"),
id_column="subject_id",
)
# --- Layer 2: marginal normatives ---
marg = pn.NormativeMarginal.fit(
scored, scale="phq9.total",
covariates=["age", "sex"], batch="cohort_id",
family="betabinomial", seed=0,
)
cent = marg.centiles(new_data) # returns centile_lo, centile_hi
# --- Layer 3: dependence ---
dep = pn.Dependence.fit(
centile_table, method="factor",
n_factors=None, # None = out-of-sample likelihood selection
seed=0,
)
dep.n_factors # chosen
dep.sigma # correlation matrix, guaranteed PSD
# --- Layer 4: the three outputs ---
prof = dep.profile(subject_centiles)
prof.predict("bdi2.total") # full distribution over integer scores
prof.atypicality # d², df, centile, decomposed by scale and pair
prof.expert # GaussianExpert(J, h) — likelihood, not posterior
# --- Report ---
pn.report(prof, out="sub-001.html")Deliberate API decisions:
predictreturns a distribution, never a number. An imputed value presented as data is the failure that most contaminates downstream analysis.fitrequiresseedwith no default. Reproducibility is not opt-in.atypicalitycomes decomposed. Ad²without saying where it comes from is not clinically usable.- No
**kwargsin any public function.
Note
Pre-alpha. The package is not yet released; the command below is the intended install path and does not work today.
pip install psynorm # planned — see Project statusRequires Python ≥ 3.11. Development uses uv with a committed lockfile for determinism.
psynorm is built around one property that orders every rule in the codebase:
An error here does not raise an exception. It produces a plausible, wrong number.
A mis-coded reverse item returns 14 instead of 13 and stays inside [0, 27]. A Σ estimated with an un-randomized PIT is biased toward zero and is still a perfectly presentable correlation matrix. None of it leaves a trace. The whole codebase is therefore biased toward explicitness and redundant verification, at the cost of verbosity — auditable ugly code over elegant code trusted on faith.
Concretely:
- Instruments are data, not code. Every definition lives in versioned YAML; a per-instrument
ifin Python is forbidden. The five scoring rules are a closed set — noeval, no arbitrary expressions. - Nothing without a citation, and never an invented one. Every rule, cutpoint, missing policy and constant carries its source. A fabricated citation is the worst possible failure in this repository.
- Missing data is never silent. No
fillna, nodropna, noexcept: pass. Every missing value is counted, governed by a cited policy, and reported. - Out of range is an error, not a missing value — it usually signals a whole shifted column.
- No individual value without uncertainty. A centile without its interval, a
d²without its empirical calibration, a prediction without its distribution — forbidden by design. - The statistical spec is normative.
METODOS.mddefines the exact method; the code implements it, it does not reinterpret or "improve" it. - Every statistical test needs its counter-test — one the correct implementation passes and one the naive implementation fails.
psynorm produces normative deviation maps with explicit uncertainty. It is not a diagnostic instrument, not a medical device, and is not validated for clinical decision-making — it must not be used for it. Effect sizes in dimensional psychopathology are moderate, and estimated dependence structures do not transfer automatically between populations.
This is not caution for its own sake: it is what separates a scientific tool from a promise. The moment such a tool suggests a clinical decision it enters software-as-a-medical-device territory (FDA / MDR).
On proprietary instruments: the schema separates scoring/ (rules, always codable) from item_text/ (only if the license explicitly permits it). Each instrument ships a legal dossier with URL and date.
v0.1 — target: 18 weeks
- 8–12 license-verified instruments, item level preserved
- 2–3 cohorts, at least one contributing the paired battery needed for Σ
- Beta-binomial marginals with age splines, cohort effect, bootstrap uncertainty
- Factor-model dependence via EM under missingness, out-of-sample factor selection
- The three outputs, with the full validation suite of
METODOS.md§M8 - Single-file HTML report per subject
- Mandatory comparison against MICE
- PyPI · Zenodo DOI · JOSS submission · preprint
v0.2 — not before there are users
IRT scoring (continuous θ), t / vine copula for tail dependence, covariate-dependent Σ, published crosswalks, Bayesian marginals.
v1.0
API stability, and a second modality (EEG) plugged into the product of experts.
Deliberately frozen — deep latent spaces (VAE/MMVAE), DICOM ingest, interactive web UI, plugin systems. Each freeze is an active decision with a review date, recorded in DECISIONS.md.
| Document | What it defines |
|---|---|
PROYECTO.md |
What is built and why — thesis, architecture, scope, risks |
METODOS.md |
The normative statistical specification — exact method, validation gates |
PLAN.md |
The 18-week execution plan, each step gated by a pass/fail verification |
CLAUDE.md |
Operating rules for development in this repository |
(Design documents are currently in Spanish; user-facing documentation will be in English.)
Pre-alpha, design stage. The specification is complete; implementation has not begun. The plan opens with data-access and legal audits before a single line of model code — the real scope is determined by which cohorts turn out to be usable, not assumed.
v0.1 is considered successful only when marginal calibration passes on real data (a blocking gate), conditional prediction has correct coverage, the MICE comparison is run and reported either way, and atypicality shows a nominal false-positive rate on a held-out reference cohort.
A CITATION.cff and a Zenodo DOI will accompany the first release. Until then, please cite this repository and the accompanying preprint (forthcoming).
MIT. psynorm implements its own marginals and depends on no GPL components; if PCNtoolkit (GPL-3.0) is ever needed, it would live in a separate package with its own license.