Phenotype Profiles
A phenotype profile is what co-occurs in a disease cohort, exported from a model fitted to structured EHR records: a label, and one weighted list of clinical codes per domain. Profiles are curated as a separate artifact and cited from a disease entry as evidence, the same way Orphanet or ClinGen records are.
Schema: src/dismech/schema/phenotype_distribution.yaml
Tooling: src/dismech/phenotype_distribution.py
Worked example: examples/phenotype_distributions/
Curated sets: kb/phenotype_distributions/ (created by the first curated set;
this directory does not exist yet)
The shape
ProfileSet ──has──> Profile ──has──> CodeDistribution ──has──> WeightedCode
│ (per domain, (code + label
disease with its + weight
(MONDO) vocabulary) + qualifier?)
profile_source
(provenance)
One set is about one disease, declared once at the top. Each profile under it is one pattern.
The whole shape, at its smallest — this is
examples/phenotype_distributions/minimal_asthma_illustrative.yaml, with
invented weights:
collection_id: EXAMPLE-MINIMAL-ASTHMA-001
provenance_tier: ILLUSTRATIVE
disease:
disease_name: Asthma
disease_term: {term_id: MONDO:0004979, term_label: asthma}
profile_source:
resource: Illustrative example — not a real model run
method: COMPUTATIONAL_INFERENCE
weight_basis: Fraction of the whole fitted corpus.
profiles:
- profile_id: EXAMPLE-ASTHMA-OBSTRUCTIVE-001
profile_label: Asthma with obstructive airway findings
profile_weight: 0.02
code_distributions:
- clinical_domain: CONDITION
code_vocabulary: OMOP_CONCEPT_ID
truncated: true
weighted_codes:
- {code: '317009', code_label: Asthma, code_weight: 0.31}
- {code: '255573', code_label: Chronic obstructive lung disease, code_weight: 0.14}
- {code: '314754', code_label: Wheezing, code_weight: 0.10}
Everything below is why each of those fields is shaped the way it is. The same
shape filled in from a real model export — with the seventeen-digit weights,
pinned provenance and caveats that real numbers bring — is
charmpheno_population_eds.yaml, discussed under
the worked examples.
Design decisions
The shape follows the proposal from the author of the models that produce this data (CHARMPheno). Several of the decisions below are recorded because a larger alternative was tried first and did not survive review.
A disease page, not a model report
An earlier draft grew a full model layer — component counts, inference methods, hyperparameters, per-domain reliability, cohort arms and their denominators. That was the wrong altitude twice over. Per-domain weights, reliability and precision/recall are model evaluation, not disease-page content; and they are the fastest-moving part of the pipeline, so a curation schema encoding them would need revising every time the models moved.
What a page needs is a label and weighted codes. Everything else lives in
profile_source, including an open profile_metadata key/value map, so the
model layer can be reshaped freely without touching the profile contract.
One set, one MONDO term
disease sits on the set, not on each profile: every profile in a fit is about
the same cohort, so repeating the term per profile only creates a way for them
to disagree. Nesting and overlap between cohorts is expressed by MONDO's own
subclass tree, which dismech already renders, so this schema ships no cohort,
arm, or grouping vocabulary of its own.
Codes are opaque; the vocabulary is declared
code is a bare identifier — an OMOP concept id, an ICD-10-CM code, a LOINC
code — and code_vocabulary on the enclosing distribution says which system it
belongs to. Two things drove this:
- Not a CURIE. Exposing source-vocabulary CURIEs (SNOMED, RxNorm, LOINC) directly proved brittle in practice. A consumer resolves the code against the declared vocabulary and version, not against a prefix map.
- Not OMOP-shaped either. An earlier draft named the fields
concept_idandconcept_name, which quietly made OMOP the only representable case. Data may arrive already coded in something else, so the field names stay generic and the vocabulary is stated.
A profile's codes are clinical codes, not ontology terms. A consumer that reads them as HPO terms because dismech is HPO-centric would be wrong today for every existing profile.
Multi-domain, kept factored
A profile holds one distribution per domain and encodes no cross-domain combination — no weights, no merged vector. How a condition distribution and a drug distribution should be weighed against each other is an open modelling question and a rendering choice, and freezing an answer into the contract would make the schema wrong as soon as the answer changed.
Weights are not prevalences
The topic-model literature calls a component's share of corpus mass its
"prevalence". This schema never does. In a disease knowledge base prevalence
means a count of patients carrying a label over a stated denominator of patients
at risk, and dismech has a prevalence[] block that means exactly that. A
profile's share is a fraction of fitted mass; the two are not convertible, and
rendering one beside the other under the same word invites a comparison that does
not hold. The slots are profile_weight and code_weight.
Because denominator hygiene is the whole reason for the different name,
profile_source.weight_basis states what the weights are a fraction of — the
whole analysed corpus, one disease arm, one cohort — and the lint requires it
wherever any profile_weight appears. Two weights are comparable only when their
sets declare the same denominator; without it, a corpus-wide weight and an
arm-wide weight differ by orders of magnitude and look identical.
code_weight is a code's mass within its own distribution, which is not the
frequency of that finding among patients — the confusion worth guarding hardest
against, because the numbers look like frequencies.
Truncation is stated, not inferred
Almost every display export is a top-N subset, so its weights do not sum to 1.
truncated: true says so. Without it, a top-N export and a distribution that
lost mass are indistinguishable; the lint warns when weights fall short and the
flag is absent, and errors when they exceed 1.
Evidence modelling: SEPIO
The evidence layer follows SEPIO and deliberately uses the same vocabulary as the SEPIO pilot proposed for the main dismech schema in #7439:
- an
EvidenceLineis one argument, carrying a direction and a strength; - it holds one or more
DataItems (the quoted text or the statistic); - each item is
reported_ina typedDocument.
EvidenceDirectionEnum copies the permissible values of the native
EvidenceItemSupportEnum verbatim, so the two forms stay mechanically
inter-convertible; a test enforces that they do not drift apart.
Splitting direction from strength matters more for statistics than for prose. A precisely estimated null and an underpowered null point the same way with very different force.
Citing a profile from a dismech entry
A profile set names no dismech entry. Nothing else in this repo points that way — ORPHA, ClinGen, ICEES and NCIT records are cited by an entry and know nothing about it — and a source-side pointer would be a second place for the same fact to be wrong. The association a set does need is the MONDO term it already carries.
just phenodist-rebuild renders each curated profile to
references_cache/PHENODIST_<profile_id>.md, a deterministic line-oriented file
in the same format as the Orphanet, ClinGen, and ICEES structured sources. The
disease entry cites it like any other structured source, quoting a row:
evidence:
- reference: PHENODIST:CHARMPHENO-EDS-DYSAUTONOMIA-001
supports: SUPPORT
evidence_source: OTHER
snippet: "| 4159659 | Postural orthostatic tachycardia syndrome | 0.11381 |"
explanation: POTS is among the highest-weighted codes in the profile.
The column order of those rows is part of the cache contract and is pinned by a
test: reordering it would silently invalidate every snippet already quoted from
it. As with every other file in references_cache/, these are generated —
never hand-write or hand-edit one.
Provenance tier
Every set must declare provenance_tier, so "these numbers are illustrative" is
a machine-readable fact rather than a sentence in a description that tooling
cannot read:
| Tier | Meaning |
|---|---|
CURATED |
Human-curated from literature or a named source; citable from a kb entry |
TOOL_EXPORTED |
Transcribed from a model or pipeline export; the numbers are real, the mapping to disease concepts is curator judgement |
ILLUSTRATIVE |
Synthetic or placeholder; must never be cited or rendered into the cache |
The renderer raises on an ILLUSTRATIVE set rather than writing it to
references_cache/, so synthetic numbers cannot become citable even by mistake.
Note the exact scope of that guard: it is a hard error for ILLUSTRATIVE only.
A TOOL_EXPORTED set under examples/ — like the CHARMPheno one — stays
uncitable because just phenodist-rebuild only globs kb/phenotype_distributions/,
which is a directory convention rather than an enforced invariant, backed by a
test asserting no kb entry cites an example profile. The tier is the hard guard
for synthetic numbers; the directory is the guard for real-but-unreviewed ones.
What gets verified
Three checks exist because the schema's credibility rests on its terms and quotes being checkable, not merely well-formed:
-
Ontology terms.
--check-terms(on injust qc) resolves everyterm_id/term_labelpair against OAK usingconf/oak_config.yaml, and errors when a CURIE does not exist or carries a label that is not its own. This is the check that catches the hallucinated-CURIE pattern: an identifier that exists but names something else.Only prefixes present in
conf/oak_config.yamlare checked — currently HP, MONDO, CL, GO, UBERON, CHEBI, NCIT and the other configured ontologies. LOINC is not configured, so LOINC terms are not verified, and a clean--check-termsrun does not mean every term was checked. Verify LOINC by hand. A network-backed adapter that fails to respond yields a warning, not an error — an unperformed lookup is not evidence of a bad term. - Quoted evidence. ADataItemciting a fetchable document —PMID:,DOI:,clinicaltrials:,ORPHA:,CGGV:,CGDS:,ICEES:,NCIT:,PHENODIST:— must be a verbatim substring of that document'sreferences_cache/file, and the cache file must exist. Without this, an unverified quote could be rendered into a generatedPHENODIST_*.mdand then cited from a kb entry, wherevalidate-referenceswould verify it against the cache dismech itself produced — laundering an unverified quote into a validated-looking one. - Stale cache.write_cache_filesprunes orphanedPHENODIST_*.md, so a renamed or deletedprofile_idcannot leave a citable file behind. Pruning runs only for a full rebuild — no paths, or directory paths — so naming an individual file never deletes another set's cache;--no-prunedisables it entirely.
Commands
# Validate one profile set (schema + lint)
just validate-phenotype-distribution kb/phenotype_distributions/my_set.yaml
# Validate all sets, including the worked example (part of `just qc`)
just validate-phenotype-distributions
# Regenerate references_cache/PHENODIST_*.md for curated sets
just phenodist-rebuild
phenodist-rebuild is a no-op today and prints "No phenotype profile sets
found": it globs kb/phenotype_distributions/ only, and that directory does not
exist until the first curated set lands. The empty output is the correct result,
not a failure — the worked example is deliberately out of scope for it. It still
prunes, so a full rebuild after every curated set is deleted clears their cache
files rather than leaving them citable.
validate-phenotype-distributions runs schema validation, the lint, and the OAK
term check.
The lint catches what LinkML cannot express: duplicate profile ids, code weights
summing above 1, profile weights summing above 1, a short distribution that does
not declare itself truncated, a description misstating its own sum, a code
listed twice under the same qualifier, a profile_weight whose source declares
no weight_basis, and a quoted evidence item that is not a verbatim substring of
its cited reference.
The worked examples
Start with examples/phenotype_distributions/minimal_asthma_illustrative.yaml —
one disease, one profile, three codes, and a comment on each decision the shape
makes. Its numbers are invented, which is what its ILLUSTRATIVE tier says, so
the renderer refuses to make it citable.
examples/phenotype_distributions/charmpheno_population_eds.yaml is populated
from a real exported model in the CHARMPheno dashboard's population_eds
bundle. Every number — the code probabilities and the profile weights — is
transcribed from that bundle's exported JSON. What is curator judgement, and
marked as such, is the reading of an unsupervised component as a named clinical
pattern. It is excluded from just phenodist-rebuild so it cannot become
citable, and a test asserts that no kb entry cites it and no cache file for it is
committed.
Its two profiles are each chosen to show something:
| Profile | Shows |
|---|---|
CHARMPHENO-EDS-HYPERMOBILE-001 |
An anchor-quality profile with half its mass on its own diagnosis code — real, precise, and a tautology. Not worth citing. |
CHARMPHENO-EDS-DYSAUTONOMIA-001 |
A profile recovering the EDS-dysautonomia association from coding alone, with the autonomic codes outweighing the anchor. |
That the first is unusable is the point: a profile can be real, precise, well-provenanced, and still say nothing an entry does not already state.
Relationship to existing dismech blocks
| Block | Relationship |
|---|---|
phenotypes[].frequency |
A band over patients. A code_weight is mass within a fitted distribution, not a frequency; they are not convertible. |
prevalence[] |
Disease occurrence in a population, over a denominator of patients. A profile weight has a corpus denominator, not a patient one. |
association_signals |
Disease-disease co-occurrence on comorbidity entries, also EHR-derived; a profile is code co-occurrence within one disease. |
genetic[].case_fractions |
Per-gene share of cases; the same "structured estimate with its own population and evidence" pattern, restricted to genetic heterogeneity. |
Not in this iteration
An earlier version of this schema also carried a PhenotypeDistributionCollection
— the full statistical object behind a literature-derived frequency: band, with
distribution families, cohort identification chains, intervals and implied HPO
bands. It was cut as out of scope for this pass. Nothing here depends on it, and
it can return as its own schema if the use case is taken up.