Skip to main content

phenotopo

A QC and explainability toolkit for phenotype cohorts.

tests Python License: MIT PyPI DOI

phenotopo: annotation-depth QC, discordant patients, and connectivity verdicts that survive six configurations

[![version](https://img.shields.io/badge/version-0.6.0-informational.svg)](CHANGELOG.md)

Two things go wrong when a rare-disease cohort is analysed by HPO terms.

The first is bookkeeping mistaken for biology: some patients were phenotyped in two vague terms and others in twenty specific ones, the difference tracks a site or a clinician, and every map then shows structure that is really annotation depth. The second is clusters that are not there: a cohort described by phenotype is usually a continuum with local structure, and one UMAP with twenty colours invites everyone to read islands into it.

phenotopo is built around those two failures. It scores how well each patient was phenotyped and flags when the group signal might be bookkeeping; it finds the patients who sit away from their own group; it says which phenotypes actually separate two groups, tested in a way that respects the ontology; and it reports cohort structure only where the answer survives a change of distance, of k and of resampling. Treating a cohort as a continuum rather than a set of clusters is the methodology underneath; QC and explainability are what it is for.

Everything runs locally. No patient data leaves the machine, and the library makes no network calls — the single exception is the explicit phenotopo ontology install command, which prints the URL it fetches.

pip install phenotopo
phenotopo demo          # a report from a synthetic cohort, in a browser, in 30 seconds

Installation

pip install phenotopo                 # core: cohorts, QC, outliers, comparison, connectivity
pip install "phenotopo[hyperbolic]"   # + Poincaré disk (gensim)
pip install "phenotopo[tda]"          # + Mapper graph (kmapper)
pip install "phenotopo[all]"          # everything, incl. umap-learn

For development, clone the repository and pip install -e ".[dev]".

If adjustText is installed, overlapping labels on the connectivity graph and the Poincaré disk are pushed apart automatically; without it the plots still work.

Without writing Python

phenotopo demo                                             # see what a report looks like
phenotopo ontology install hp                              # cache the HPO release once
phenotopo report patients/ --labels diagnosis --compare GENE_A GENE_B -o report.html

report takes a directory of Phenopackets, a single .json packet, or a CSV/XLSX table of HPO terms, and writes the same self-contained HTML file the Python API produces. This is not a substitute for a real interface — it is what makes the tool usable by someone who will not open a notebook.

Quick start

import phenotopo as pt

cohort = pt.from_phenopackets("patients/", ontology="hp.obo")   # or from_hpo_table(df, ...)
D      = cohort.distance()                                      # SimGIC, the ontology-aware default
qc     = pt.phenotype_qc(cohort)                                # how was each patient annotated?
bias   = pt.annotation_bias(qc, cohort.labels("site"),          # could the group signal be bookkeeping?
                            distance=D)
out    = pt.patient_outliers(D, cohort.labels("diagnosis"),     # who sits away from their group?
                             ids=cohort.ids)
diff   = pt.explain_groups(cohort, cohort.labels("gene"),       # which phenotypes separate them?
                           "GENE_A", "GENE_B")
pt.cohort_report(cohort, labels="diagnosis", distance=D,        # one local HTML file
                 comparisons=diff, path="cohort_report.html")

A Cohort is optional throughout: every analysis function also takes a plain distance matrix and an array of labels, so phenotopo sits on top of a pipeline you already have rather than replacing it.

Similarity, and which fields are actually used

cohort.distance() is SimGIC — the IC-weighted Jaccard of the propagated term sets (Pesquita et al. 2008), the ontology-aware measure the HPO literature uses. distance("cosine") (cosine between IC-weighted propagated vectors) is kept as a sensitivity analysis, and cohort.distances() returns both, ready to hand to the robustness protocol. Both are hierarchy-aware: two patients sharing no term but sharing an ancestor are still similar.

from_phenopackets reads GA4GH Phenopackets v2 and keeps what a bare list of term IDs throws away. What each field currently affects — stated plainly, because storing a field is not the same as using it:

Field Read Used by
present phenotypes yes everything
excluded phenotypes (looked for and absent) yes phenotype_qc; distance(..., negatives="use"), where a shared ruled-out phenotype makes two patients more similar; reported as a separate column by explain_groups and never mixed into the test
onset per observation yes phenotype_qc completeness only — distance(..., onset=...) is declared and raises, rather than pretending
disease, gene, sex yes metadata, so labels("gene") and explain_groups work straight after reading

not recorded and looked for and absent are different observations, and only the second is evidence of absence — so negatives are opt-in and refuse to run on a cohort where absence was never recorded.


The figures below come from examples/quickstart.py, run on synthetic cohorts with designed faults and structure, so each can be checked against ground truth.

1 · Phenotyping quality

qc = pt.phenotype_qc(cohort)
qc["summary"]        # median terms, % low depth, % low specificity, % redundant, ...
qc["flagged"]        # worst-annotated patients first, with reasons
pt.plot_qc(qc, cohort.labels("site"))

Per patient: terms asserted and after propagation, explicitly excluded phenotypes, observations with an onset, mean and total information content, mean ontology depth, redundant ancestor terms (a term recorded next to its own child — bookkeeping, not information), the specificity percentile, and flags.

The flags are deliberately relative to this cohort and descriptive: LOW_ANNOTATION_DEPTH, LOW_SPECIFICITY_RELATIVE_TO_COHORT, HIGH_REDUNDANCY, resolving to review recommended. Six well-chosen terms can describe a skeletal dysplasia completely while fifteen vague ones describe a neurodevelopmental case badly, so the tool never declares a patient "under-phenotyped" in absolute terms.

Then the question that decides whether any of the rest can be believed:

bias = pt.annotation_bias(qc, labels, distance=D)
bias["kruskal"]      # do the groups differ in annotation depth? H, p, epsilon-squared
bias["recovery"]     # {'phenotype': 0.92, 'annotation_only': 1.00, 'confound_risk': 'HIGH', ...}

Group labels are predicted twice by cross-validated k-NN — from the phenotype distance, and from how much was written down alone (n_terms, n_propagated). Specificity measures are deliberately excluded from that second model: they depend on which terms a patient has, so including them would report real biology as bias.

The result is an annotation-confound risk (LOW / MODERATE / HIGH), not a verdict. That annotation counts predict the group does not establish that the separation is caused by them: a group that genuinely differs in phenotype severity is usually also annotated more thoroughly, and the chain runs group → severity → annotation depth. HIGH means the analysis must address the confound — by matching, stratification or a depth-controlled comparison — not that the finding is an artefact.

2 · Outliers — who sits away from their own group

out = pt.patient_outliers(D, labels, ids=cohort.ids, k=15)
pt.explain_outlier(cohort, index, D, labels)

Two things get called an outlier, and mixing them produces nonsense, so they are reported separately: isolation (far from everybody — a sparse or unusual phenotype) and discordance (plenty of close neighbours, and they belong to a different group). explain_outlier names the terms that make a patient unusual for its neighbourhood, and — often more useful — the terms its neighbourhood has that it lacks, which is as likely to be a phenotyping gap as biology.

The output is never a claim that a diagnosis is wrong. It is phenotypically discordant with the assigned group: a candidate for review in a diagnostic cohort, a genotype–phenotype study or a reclassification project.

3 · What separates two groups

diff = pt.explain_groups(cohort, labels, "GENE_A", "GENE_B", min_effect=0.15)
diff["top"]     # term, name, prevalence in each group, effect in pp with CI, adjusted p
pt.plot_explain(diff)

One test per HPO term, sorted by p-value, answers this badly, for two reasons specific to ontologies:

  • The terms are not independent. After propagation a patient annotated Status epilepticus is also annotated Seizure and everything up to the root, so one finding lights up a whole ancestor chain and Benjamini–Hochberg assumes far more independence than exists. explain_groups therefore uses a Westfall–Young max-T permutation: labels are shuffled and the largest prevalence difference over all terms is recorded each time, so the null already contains the ontology's correlation structure and the adjusted p-values control the family-wise error rate.
  • Significance is not the answer. With a few thousand patients a 2-point difference is significant and clinically empty. Terms are reported only above an explicit effect-size threshold in percentage points, with a Newcombe confidence interval, and redundant ancestors of an already-reported term are pruned — ties go to the deeper term, so Seizure is dropped for Status epilepticus and never the other way round.

4 · Cohort structure, only where it is robust

res = pt.connectivity_robustness({"cosine": D_cos, "simgic": D_gic}, labels,
                                 ks=(10, 15, 30), min_size=100)
res["summary"]        # per pair: ratio, CI, range across configurations, verdict
pt.plot_forest(res)

The connectivity ratio compares the k-NN edges crossing between two groups with the number expected under a degree-preserving null (the PAGA abstraction, Wolf et al. 2019): 1 is random mixing, > 1 blending, < 1 separation, and the diagonal is within-group cohesion. A ratio from one distance, one k and one sample is a single draw, so the protocol (i) drops groups below min_size, whose expected counts are tiny and whose ratios explode; (ii) recomputes every ratio for every distance × k with a permutation q and a bootstrap CI; (iii) applies an effect-size threshold, not just significance; and (iv) issues a verdict only if the criterion holds in every configuration. What survives is a sentence you can write down.

The underlying views, when you want to look rather than test:

conn = pt.group_connectivity(pt.knn_graph(distance=D, k=15), labels)
pt.plot_connectivity(conn, embedding=emb, labels=labels)          # node-link, few groups
pt.plot_connectivity_heatmap(conn, min_size=30,                   # every pair, many groups
                             significance=pt.permutation_test(pt.knn_graph(distance=D), labels))

Nodes sit at each group's median position, node area is group size, edge width ∝ log ratio. Past about eight groups a node–link drawing stops being readable and the heatmap is the honest form: every pair, the separated ones (ratio < 1) included, * where the permutation q survives correction.

5 · Looking at the continuum

pt.plot_density(emb, labels, levels=(0.5, 0.85))    # where groups overlap, drawn not hidden
pt.plot_small_multiples(emb, labels, ncols=5)       # one panel per group, no 20-colour legend

Two further views, for hierarchy and shape:

from phenotopo.hyperbolic import poincare_terms, place_patients, plot_disk
from phenotopo.mapper import mapper_graph, node_values, plot_mapper

The Poincaré disk fits a hierarchy into two dimensions with the root at the centre and specific leaves at the rim; patients sit at the Einstein midpoint of their terms, so phenotyping specificity becomes a radial axis — the annotation-depth confound made geometric instead of hidden. The Mapper graph covers the space with overlapping bins and clusters locally inside each, showing branches, bridges and flares without forcing every patient into a cluster; colour it by any per-patient outcome (here a synthetic diagnostic yield) to see where that outcome concentrates.

6 · One file to hand over

pt.cohort_report(cohort, labels="diagnosis", distance=D,
                 robustness=res, comparisons=[diff], path="cohort_report.html")

Cohort overview, QC and the annotation-bias verdict, the outlier list, the robustness table with its forest plot and every group comparison — in a single self-contained HTML file, figures embedded, no server and no network. It opens in a browser and survives being emailed.


Reading the connectivity ratio

ratio Meaning
≫ 1 groups blend — many more cross edges than chance
≈ 1 as connected as random mixing
≪ 1 separated — a real boundary in phenotype space

The null preserves every group's total degree (the configuration model behind modularity), so large groups are not rewarded merely for being large.

API

Module Contents
phenotopo.cohort Cohort, Ontology, from_hpo_table, from_phenopackets
phenotopo.qc phenotype_qc, annotation_bias, plot_qc
phenotopo.outliers patient_outliers, explain_outlier
phenotopo.explain explain_groups, plot_explain
phenotopo.report cohort_report
phenotopo.graph knn_graph, group_connectivity, group_centroids
phenotopo.stats permutation_test, bootstrap_ratio, benjamini_hochberg
phenotopo.robustness connectivity_robustness, plot_forest
phenotopo.layout plot_connectivity, plot_connectivity_heatmap, plot_density, plot_small_multiples, default_palette
phenotopo.hyperbolic poincare_terms, einstein_midpoint, place_patients, radial_specificity, plot_disk
phenotopo.mapper mapper_graph, node_values, plot_mapper
phenotopo.data synthetic_cohort, synthetic_hpo_cohort, synthetic_hierarchy, synthetic_term_lists
phenotopo.cli phenotopo demo, phenotopo report, phenotopo ontology install/path

Does it hold on real data?

Two benchmarks, both reproducible from this repository — full write-ups in benchmarks/.

Real published cases — 2,691 cases from Phenopacket Store 0.1.27, the 16 rare diseases with n ≥ 80, curated by the Monarch Initiative and not by anyone involved here:

  • phenotype recovers the reported diagnosis in 93.8 % of cases (17.2 % baseline);
  • all 16 diseases are robustly cohesive and 104 pairs robustly separated, with exactly one blend — two chromatin-related neurodevelopmental syndromes whose published phenotypes genuinely are not separable;
  • annotation-confound risk is LOW across the 16 diseases and HIGH for the most unequally annotated pair — the check fires where it should and stays quiet where it should;
  • KBG vs Glass syndrome: macrodontia comes out top at +63 pp, the textbook hallmark of KBG, recovered without being told;
  • using excluded phenotypes lifts diagnosis recovery from 93.8 % to 96.0 %.

Permutation calibration — on 200 simulated null cohorts the max-statistic permutation raises a false term in 5.0 % of them, at a nominal 5 %, against 75 % for uncorrected testing; on planted effects it is also more powerful than Benjamini–Hochberg (87.9 % vs 81.8 %), because the max-statistic null absorbs the ancestor correlation that makes BH conservative on ontology data.

The published-case corpus is biased toward typical presentations and its labels are the reported diagnoses; the benchmark write-up states what that does and does not license.

Scope

phenotopo deliberately does not prioritise genes or diagnoses for a single patient — Exomiser and LIRICAL do that — and does not capture phenotypes, pedigrees or variants, which is PhenoTips' job. It sits after capture and beside prioritisation: quality control, cohort structure, outliers and group comparison, reading Phenopackets so it fits between the two.

Citation

Soloshenko M. phenotopo: a QC and explainability toolkit for phenotype cohorts. Zenodo. https://doi.org/10.5281/zenodo.22358033

@software{phenotopo,
  author  = {Soloshenko, Margarita},
  title   = {phenotopo: a QC and explainability toolkit for phenotype cohorts},
  year    = {2026},
  doi     = {10.5281/zenodo.22358033},
  url     = {https://github.com/MargoSolo/phenotopo}
}

That is the concept DOI — it always resolves to the latest version, which is what belongs in a paper. Each release also gets its own DOI (10.5281/zenodo.22358034 for 0.5.1) if you need to pin the exact version you ran. Machine-readable metadata is in CITATION.cff.

Tests

pytest -q

Runs on synthetic data only, by design: no patient data belongs in this repository, and .gitignore blocks ontology dumps, cohort files and analysis output. The figures in this README come from synthetic cohorts with designed structure, which makes them checkable against ground truth but is not evidence about real cohorts — for that, see the benchmarks below.

References

  • Wolf F.A. et al. PAGA: graph abstraction reconciles clustering with trajectory inference through a topology preserving map of single cells. Genome Biology, 2019.
  • Westfall P.H., Young S.S. Resampling-based multiple testing. Wiley, 1993 — max-T procedure.
  • Newcombe R.G. Interval estimation for the difference between independent proportions. Statistics in Medicine, 1998.
  • Pesquita C. et al. Metrics for GO based protein semantic similarity: a systematic evaluation. BMC Bioinformatics, 2008 — SimGIC.
  • Jacobsen J.O.B. et al. The GA4GH Phenopacket schema defines a computable representation of clinical data. Nature Biotechnology, 2022.
  • Nickel M., Kiela D. Poincaré embeddings for learning hierarchical representations. NeurIPS, 2017.
  • Ungar A.A. Analytic hyperbolic geometry. World Scientific, 2005 — Einstein midpoint.
  • Singh G., Mémoli F., Carlsson G. Topological methods for the analysis of high dimensional data sets. Eurographics, 2007 — Mapper.
  • van Veen H.J. et al. Kepler Mapper. Journal of Open Source Software, 2019.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

phenotopo-0.6.0.tar.gz (1.6 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

phenotopo-0.6.0-py3-none-any.whl (57.2 kB view details)

Uploaded Python 3

File details

Details for the file phenotopo-0.6.0.tar.gz.

File metadata

  • Download URL: phenotopo-0.6.0.tar.gz
  • Upload date:
  • Size: 1.6 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for phenotopo-0.6.0.tar.gz
Algorithm Hash digest
SHA256 65ecc5a055dec6b6bdce35fe3d88ab49e57ad6ac705ea86b3dadb4e7a1d7601f
MD5 43c61e6f081ea18df1f6cedf0f7bf825
BLAKE2b-256 60423faee114f92ed3b0c0da4a16636683ff3ede8d563a52e2a9385bb1fe64ea

See more details on using hashes here.

File details

Details for the file phenotopo-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: phenotopo-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 57.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.13

File hashes

Hashes for phenotopo-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 83f1ec6a1d6edb5c07b7c2aaea3221045f2d523fd0b12c750268a6b248e719b1
MD5 c9cb0c0fe172b7ec5cdab75692d37800
BLAKE2b-256 3bb1ab2737de0ba9a741f98de70d7a2252c042d6437c3fb7f0797ecbcfe1a593

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 files

0.5.1

2 files

0.5.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page