Pathograph → synthetic patients (the synthvae bridge)
This is the bridge between the dismech pathograph and the synthetic-patient VAE generator (justaddcoffee/synthetic_vae_generator), which trains a conditional VAE on real phenopackets to learn HPO-symptom co-occurrence and sample novel synthetic patients for benchmarking (e.g. Exomiser reranking).
The two artifacts share a join key (HP terms) and a disease key (MONDO), so a curated dismech disorder can be projected into the exact shape the VAE world consumes — but grounded in curated biology rather than learned from sparse cases.
Two pieces
- Extractor —
src/dismech/export/synthvae_export.pyProjects every MONDO-anchored disorder to a per-disease generative prior: numeric phenotype probabilities plus the causal-branch each phenotype hangs off. Reuses the frequency parsing inhpoa_export(literal12%/3/20, an HP Frequency term, or the dismechFrequencyEnum) and maps HP Frequency bands to midpoint probabilities. The branch is the nearest upstreampathophysiologynode in the entry's pathograph, ornullwhen no causal edge reaches the phenotype. The pathograph file is looked up inpathographs/index.jsonby the entry'sname: when several entries share a MONDO id,pathograph_exportwritesMONDO_<id>.jsonfor the first andMONDO_<id>__<slug>.jsonfor the rest, so a lookup by MONDO id alone would hand one entry another's graph.
Only relationship_type: CAUSATIVE genes from genetic[] are recorded, one
per (gene, subtype), with the curated case_fractions share where present.
About half the KB's genetic[] rows set no relationship_type, so an entry
with no CAUSATIVE row falls back to its untyped rows, which keep
relationship_type: null. Rows typed as modifier, susceptibility, risk
factor, somatic driver and so on, and genes named on mechanism nodes, are
never treated as diagnoses. Each phenotype and gene carries its curated subtype, if any.
uv run python -m dismech.export.synthvae_export
# -> output/synthvae/disease_priors.json (3,396 diseases, ~44k phenotypes)
Each record:
{
"disease_id": "MONDO:0011581",
"disease_name": "arrhythmogenic cardiomyopathy with wooly hair and keratoderma",
"dismech_name": "Carvajal Syndrome",
"subtypes": [],
"genes": [{"id": "hgnc:3052", "label": "DSP", "relationship_type": null,
"subtype": null, "case_fraction": null}],
"pathograph": "MONDO_0011581.json",
"phenotypes": [
{"hpo_id": "HP:0001644", "label": "Dilated cardiomyopathy",
"probability": 0.9, "frequency_source": "band",
"branch": "Contractile and Electrical Dysfunction", "subtype": null},
...
],
"excluded": []
}
- Sampler —
scripts/synthvae_sampler.pyA white-box knowledge-based sampler that turns priors into phenopacket-v2-style synthetic patients. The counterpart to the learned VAE: it works for rare / cold-start diseases with no real cases, and can act as a structured prior or a plausibility baseline next to the VAE.
uv run python scripts/synthvae_sampler.py --disease MONDO:0011581 -n 5 --mode branch
# -> output/synthvae/synthetic_patients.jsonl
Each patient is first given one causal gene (weighted by case_fractions
when every candidate gene has one, uniformly otherwise) and, for an entry
with subtypes, one subtype: the gene's own subtype when it has one, otherwise
a uniform draw, since dismech records no subtype frequencies. Phenotypes
scoped to any other subtype are not drawn, so a patient never mixes the
features of mutually exclusive subtypes. The gene is written as a single
phenopacket v2 GeneDescriptor (valueId: HGNC:<n>, symbol) with
interpretationStatus: CAUSATIVE; the subtype is recorded in
metaData.externalReferences.
Why the branch matters (curated co-occurrence)
--mode marginal draws each phenotype independently. --mode branch (default)
draws one shared u ~ Uniform(0,1) per mechanistic branch; a phenotype is
present iff u < probability. This preserves each phenotype's marginal while
inducing positive within-branch co-occurrence — exactly the structure the VAE
otherwise has to estimate from data. Two conditions apply:
- Subtypes. A subtype-scoped phenotype keeps its curated marginal within its subtype; across the whole cohort it is diluted by the share of patients drawn into other subtypes. Unscoped phenotypes are unaffected.
--min-features. It defaults to 0. Any higher value redraws patients with too few features, which conditions every kept patient and pushes the marginals up, most for diseases whose phenotypes are all low-frequency (the rare and cold-start diseases this sampler is for). The sampler prints a note when it is set.
Measured on Carvajal (N=4000, no subtypes, --min-features 0), for two
same-branch cardiac phenotypes each with P≈0.17:
| mode | P(VT) | P(SCD) | P(both) | independent expectation |
|---|---|---|---|---|
| marginal | 0.164 | 0.166 | 0.028 | 0.027 |
| branch | 0.163 | 0.163 | 0.163 | 0.027 |
In branch mode the rarer cardiac signs (ventricular tachycardia, syncope, sudden cardiac death) co-segregate when the cardiac branch is "severe", instead of scattering independently — biologically faithful co-occurrence for free.
Three ways the VAE project can use this
- Standalone sampler for cold-start / rare diseases (no real cases to train on).
- Structured prior to regularize the VAE where case data are sparse.
- Plausibility check: score VAE output by pathograph-branch coherence — see the QC layer below.
QC layer (non-invasive plausibility check)
scripts/synthvae_qc.py implements Milestone 1 of
synthetic_vae_generator#4:
score any phenopacket cohort (VAE output, the sampler above, or real
phenopacket-store cases) against the pathograph without touching model
training. It reports, per patient and per cohort:
- the matched disease entry (by MONDO id, else by causal gene). A MONDO id
shared by several entries is resolved only through the pathograph file the
patient names (as the sampler's output does) and is otherwise reported as
mondo_ambiguousrather than guessed; - each HPO term as on-graph (a causal edge in the pathograph reaches it, so
a curated mechanism explains it), listed, unconnected (curated for the
disease but no causal edge reaches it — about half the KB's phenotypes; see
just compliance-connectivity), or off-graph (not curated for the disease at all — the plausibility red flag).coveragecounts on-graph terms only;listed_coveragecounts both of the first two; - which mechanistic branches the on-graph terms activate.
uv run python scripts/synthvae_qc.py output/synthvae/synthetic_patients.jsonl
# -> output/synthvae/qc_report.json + a printed summary
This is disease-mechanism plausibility, complementary to HPO ontology logical
checks: ontology checks catch impossible term combinations; this catches
phenotype sets that don't hang together on the curated causal graph. Exact-term
matching today; HPO ancestor/descendant expansion (via OAK, as in
scripts/pathograph_overlap.py) is the natural refinement.
All outputs land under the gitignored output/synthvae/.