phenoforge.eval¶
Ontology-aware metrics and the benchmark harness, scoring retrieval and decomposition against the bundled curated cohorts as ground truth. See the README’s Validation and limitations section for what’s actually been measured.
phenoforge.eval.models¶
Pure result types for the eval harness. No DB dependency.
Gives metrics.py and harness.py a shared vocabulary of typed results
without either depending on the other’s I/O — metrics.py stays a pure,
DB-free module that property-based tests can target cheaply.
- class phenoforge.eval.models.CohortEvalResult(**data)[source]¶
Bases:
BaseModelPer-cohort scoring result for one retrieval method against curated ground truth.
- Variables:
cohort_id – OHDSI Phenotype Library cohort id.
cohort_name – Cohort display name — also the query fed to retrieval methods that generated
predicted_codes.method – Label for the method under test, e.g.
"bm25","dense","hybrid","expand_descendants".predicted_codes – ICD-10-CM codes the method produced.
ground_truth_codes – ICD-10-CM codes from the curated cohort.
coverage – Ground-truth -> predicted direction score,
[0, 1]— did the whole curated set get assembled.over_inclusion_penalty – Predicted -> ground-truth direction score,
[0, 1]— how much of what was predicted has no good match anywhere in the curated set.hierarchical_score – Harmonic mean of
coverageand1 - over_inclusion_penalty; a single combined quality number.unresolvable_ground_truth – Curated codes not found in the loaded vocabulary (a data-quality escape hatch, not expected to be nonempty in normal operation).
- Parameters:
- class phenoforge.eval.models.DecompositionCaseResult(**data)[source]¶
Bases:
BaseModelOne test case’s scores: retrieval alone vs. decomposition + retrieval.
Both scores are measured against the same
cohort_id’s curated ground truth, isolating what thedecomposestep costs (or doesn’t) on top of retrieval quality that’s already measured separately byrun_benchmark.- Variables:
cohort_id – Bundled OHDSI Phenotype Library cohort id used as ground truth.
cohort_name – Cohort display name.
population_description – The free-text input fed to
decompose.seed_terms – What
decomposeactually extracted.baseline_hierarchical_score – Hybrid search’s score using the cohort’s own display name as the query directly — “if decomposition were skipped entirely.”
decomposed_hierarchical_score – Score from running the real
decompose→ per-term curated/generated resolution → assemble pipeline, starting frompopulation_description.gap –
baseline_hierarchical_score - decomposed_hierarchical_score. Positive means decomposition cost accuracy relative to querying with the right term directly; negative means decomposition (e.g. splitting into multiple terms) did better than the single-query baseline.
- Parameters:
- class phenoforge.eval.models.DecompositionReport(**data)[source]¶
Bases:
BaseModelAggregate report across every decomposition test case.
- Variables:
results – One result per test case.
mean_gap – Mean
DecompositionCaseResult.gapacross cases — decomposition’s average accuracy cost on this test set.
- Parameters:
results (list[DecompositionCaseResult])
mean_gap (float)
- results: list[DecompositionCaseResult]¶
- class phenoforge.eval.models.BenchmarkReport(**data)[source]¶
Bases:
BaseModelAggregate report across all cohorts and methods.
- Variables:
results – Every per-cohort, per-method result.
mean_coverage_by_method – Mean
CohortEvalResult.coverage, grouped by method.mean_over_inclusion_by_method – Mean
CohortEvalResult.over_inclusion_penalty, grouped by method.mean_hierarchical_score_by_method – Mean
CohortEvalResult.hierarchical_score, grouped by method.
- Parameters:
- results: list[CohortEvalResult]¶
phenoforge.eval.metrics¶
Ontology-aware scoring: tree distance, partial credit, coverage, over-inclusion.
Pure functions, no duckdb import anywhere in this file. Everything takes
already-computed ancestor chains or scores as plain list[int] /
float inputs, so the property-based tests in
tests/eval/test_metrics.py never touch a database — chain construction
lives in phenoforge.engine.expansion.ancestor_chain_ids(), and
DB-backed orchestration lives in phenoforge.eval.harness.
Standard exact-match recall is the wrong metric for this task: retrieving
E11.21 when the target is E11.9 is a near-miss under the same
parent, while retrieving a circulatory-chapter code is a total miss — both
score zero under exact match today. Every score here is instead derived
from tree_distance(), so near-misses earn partial credit and total
misses don’t.
- phenoforge.eval.metrics.tree_distance(chain_a, chain_b)[source]¶
Distance between two concepts via their lowest common ancestor.
chain_a/chain_bare each[self_id, parent_id, ..., root_id], self-inclusive and nearest-first, as returned byphenoforge.engine.expansion.ancestor_chain_ids(). Walkingchain_aoutward from self, the first id also present inchain_bis the lowest common ancestor — the closest shared node, sincechain_ais ordered nearest-to-farthest.- Parameters:
- Returns:
Tree-edge distance (sum of each concept’s distance to the LCA), or
Noneif the chains share no common ancestor (different chapters or vocabularies entirely — a true miss, not a near-miss).- Return type:
int | None
- phenoforge.eval.metrics.distance_weighted_score(distance, max_distance=6)[source]¶
Convert a tree distance into partial credit in
[0, 1].distance=0(exact match) scores1.0. Score decays linearly to0.0atdistance >= max_distance.None(no common ancestor — a total miss) scores0.0. Linear decay is chosen over exponential for interpretability at prototype scale: a near-miss likeE11.21vs.E11.9(distance 2, common parentE11) should land clearly above zero and clearly below 1.0, not be swamped by a steep curve.- Parameters:
distance (
int|None) – Tree distance fromtree_distance(), orNone.max_distance (
int) – Distance at or beyond which score floors to0.0.
- Returns:
Score in
[0.0, 1.0].- Return type:
- phenoforge.eval.metrics.set_coverage(predicted_scores, n_ground_truth)[source]¶
Mean of each ground-truth code’s best-match score against predicted codes.
The caller has already computed one best
distance_weighted_score()per ground-truth code (0.0if nothing predicted is even a near-miss) — seephenoforge.eval.harness.match_scores().- Parameters:
- Returns:
Mean coverage in
[0, 1].1.0(vacuously) ifn_ground_truth == 0.- Return type:
- Raises:
ValueError – If
len(predicted_scores) != n_ground_truth.
- phenoforge.eval.metrics.over_inclusion_penalty(predicted_scores, n_predicted)[source]¶
Fraction of predicted codes with no good match anywhere in ground truth.
The caller has already computed one best
distance_weighted_score()per predicted code against ground truth. A predicted code close to something in ground truth — a legitimately-missing sibling, given the curated set is a lower bound (seephenoforge.engine.curated.resolve_curated_concepts()) — earns partial credit rather than being flatly penalized as a false positive.- Parameters:
- Returns:
Penalty in
[0, 1]—mean(1 - score)over predicted codes.0.0(vacuously) ifn_predicted == 0.- Return type:
- Raises:
ValueError – If
len(predicted_scores) != n_predicted.
- phenoforge.eval.metrics.hierarchical_score(coverage, over_inclusion_penalty)[source]¶
Harmonic mean of coverage and precision (
1 - over_inclusion_penalty).An F1-style single quality number:
coverageplays the recall role (did we get the whole curated set),1 - over_inclusion_penaltyplays the precision role (is what we got actually relevant). Harmonic rather than arithmetic mean so a method can’t hide a bad score on one axis by being good on the other — cohort definitions fail on false positives too, not just missed codes.- Parameters:
coverage (
float) –set_coverage()output,[0, 1].over_inclusion_penalty (
float) –over_inclusion_penalty()output,[0, 1].
- Returns:
Harmonic mean in
[0, 1].0.0if both coverage and precision are0.0.- Return type:
phenoforge.eval.harness¶
Benchmark harness: score generated retrieval against curated ground truth.
Orchestration layer with DB I/O — the counterpart to the pure functions in
phenoforge.eval.metrics. For each bundled OHDSI Phenotype Library
cohort, loads the curated concept set as ground truth (free — no
hand-annotation required, since curated value sets are already the labels)
and scores each retrieval method’s output against it.
- phenoforge.eval.harness.match_scores(con, from_codes, to_codes)[source]¶
Best distance-weighted score from each of
from_codesto any ofto_codes.For each code in
from_codes, computestree_distance()(viaancestor_chain_ids()) against every code into_codesand keeps the best (lowest-distance) match, then appliesdistance_weighted_score(). Ancestor chains are fetched once per unique code and memoized for the call’s duration — cohorts run to the tens of codes, so simple O(n*m) with memoized lookups beats a precomputed global distance matrix at this scale.- Parameters:
- Returns:
One score per
from_codesentry, same order.0.0per entry ifto_codesis empty.- Return type:
- phenoforge.eval.harness.score_cohort(con, cohort_id, cohort_name, ground_truth_codes, predicted_codes, method)[source]¶
Score one method’s predictions for one cohort against curated ground truth.
Computes coverage (ground-truth -> predicted direction) and over-inclusion (predicted -> ground-truth direction) via two
match_scores()calls, thenset_coverage(),over_inclusion_penalty(), andhierarchical_score().- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.cohort_id (
str) – OHDSI Phenotype Library cohort id.cohort_name (
str) – Cohort display name, echoed into the result.ground_truth_codes (
list[str]) – ICD-10-CM codes from the curated concept set.predicted_codes (
list[str]) – ICD-10-CM codes from the method under test.method (
str) – Label for the method under test, e.g."hybrid".
- Returns:
The scored result.
- Return type:
- phenoforge.eval.harness.run_benchmark(con, library_dir, cohort_ids, methods=None, dense=None, k=25, fanout_threshold=100, dense_weight=0.5)[source]¶
Run every requested method against every cohort, aggregate the results.
Per cohort id: loads curated ground truth via
load_curated_concept_set(), generates predictions per requested method (cohort_name— the cohort’s own display name — is the query fed to"bm25"/"dense"/"hybrid";"expand_descendants"seeds from the shortest ground-truth code, a deliberately weak baseline rather than a competitive method — the least-specific curated code is the natural entry point a human would expand from), scores each withscore_cohort(), and aggregates.- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.library_dir (
Path) – Directory of fetched OHDSI Phenotype Library cohorts (scripts/fetch_phenotype_library.pyoutput).methods (
list[str] |None) – Methods to run; defaults to["bm25", "hybrid", "expand_descendants"]."dense"only runs ifdenseis given; a requested method needingdensewhen none is given is skipped for that cohort, not errored — matchingsearch_concepts’s existing graceful-degradation behavior.dense (
DenseRetriever|None) – Pre-built dense retriever, orNoneto skip dense-dependent methods. Tests inject one built with the fake embedder fromtests/engine/fake_embedder.pyso this function’s own tests never download a real model.k (
int) – Result count passed to each retrieval method.fanout_threshold (
int) – Passed through to curated ground-truth loading.dense_weight (
float) – Passed through tohybrid_search()for the"hybrid"method — dense’s share of the fused RRF score. Seescripts/sweep_hybrid_weights.pyfor sweeping this against curated ground truth.
- Returns:
Full benchmark report across all cohorts and methods.
- Return type:
phenoforge.eval.decomposition¶
Evaluate the agent’s decompose step, isolated from retrieval quality.
run_benchmark (harness.py) measures retrieval given an already-correct
search term. Nothing measures the step before that: given a free-text
population description, does decompose (a real Claude call) extract the
right seed terms? Before this module, that had been exercised exactly once,
by hand, in notebooks/agent_walkthrough.ipynb.
Scoring term strings directly would need a new, somewhat arbitrary
similarity metric and a hand-labeled “correct terms” dataset — and would
measure something beside the point, since nobody cares about the terms
themselves, only the codes that come out the other end. Instead, this
reuses the bundled cohorts’ existing ground truth and score_cohort
unchanged: for each test case, compare “hybrid search using the cohort’s
own name directly” (decomposition skipped entirely) against “the real
decompose step, then per-term curated/generated resolution” (mirroring
agent/nodes.py’s check_curated/generate, minus the human
confirmation gate — this measures decomposition and retrieval together,
not human review). The gap between those two scores, against the same
ground truth, is what decomposition costs — or saves, if splitting a
description into multiple terms does better than one query.
- class phenoforge.eval.decomposition.DecompositionTestCase(**data)[source]¶
Bases:
BaseModelA free-text population description mapped to the cohort it should resolve to.
- Variables:
population_description – Phrased like a real study population description, not copied from the cohort’s own bundled display name — otherwise decomposition would be trivially unnecessary to get right.
cohort_id – Bundled OHDSI Phenotype Library cohort id this description should resolve to; its curated concept set is ground truth.
source – Where the phrasing pattern came from, for traceability — not used by scoring, just documentation.
- Parameters:
- phenoforge.eval.decomposition.DEFAULT_TEST_CASES: list[DecompositionTestCase] = [DecompositionTestCase(population_description='adults with type 2 diabetes mellitus', cohort_id='503', source='ClinicalTrials.gov NCT03985293, "...in Adults With Type 2 Diabetes Mellitus"'), DecompositionTestCase(population_description='patients with type 2 diabetes or a documented history of diabetes', cohort_id='40', source='common trial inclusion phrasing combining current diagnosis with history of diabetes'), DecompositionTestCase(population_description='type 2 diabetes mellitus confirmed by diagnosis, treatment, or laboratory testing', cohort_id='288', source='phrasing pattern from diagnosis/treatment/lab-indexed diabetes ascertainment algorithms'), DecompositionTestCase(population_description='adult patients with type 1 diabetes mellitus', cohort_id='499', source='ClinicalTrials.gov NCT01421147, "A Study in Adults With Type 1 Diabetes"'), DecompositionTestCase(population_description='patients presenting to the emergency department with diabetic ketoacidosis', cohort_id='611', source='common DKA trial inclusion phrasing (ED presentation with primary DKA diagnosis)'), DecompositionTestCase(population_description='pregnant women diagnosed with gestational diabetes mellitus', cohort_id='619', source='common GDM trial inclusion phrasing, e.g. ClinicalTrials.gov NCT02244814'), DecompositionTestCase(population_description='adults with diabetic retinopathy', cohort_id='647', source='common diabetic-retinopathy trial inclusion phrasing'), DecompositionTestCase(population_description='patients with chronic kidney disease', cohort_id='687', source='common CKD trial inclusion phrasing framed around a CKD diagnosis'), DecompositionTestCase(population_description='adult patients hospitalized with acute myocardial infarction', cohort_id='510', source='common AMI trial phrasing (diagnosis of acute MI, hospitalized within 48h of onset)'), DecompositionTestCase(population_description='adults diagnosed with atrial fibrillation', cohort_id='513', source='common AF trial inclusion phrasing, e.g. ClinicalTrials.gov NCT04357288'), DecompositionTestCase(population_description='adult patients with a diagnosis of heart failure', cohort_id='519', source='common heart failure trial inclusion phrasing'), DecompositionTestCase(population_description='adults hospitalized with community-acquired pneumonia', cohort_id='507', source='common CAP trial inclusion phrasing (radiographic infiltrate plus symptoms)'), DecompositionTestCase(population_description="adults with a physician's diagnosis of asthma", cohort_id='521', source='common asthma trial inclusion phrasing'), DecompositionTestCase(population_description='adult patients with major depressive disorder', cohort_id='594', source='ClinicalTrials.gov NCT00795821, "A Study in Adult Patients With Major Depressive Disorder"'), DecompositionTestCase(population_description='adults with generalized anxiety disorder', cohort_id='602', source='common GAD trial inclusion phrasing, e.g. ClinicalTrials.gov NCT03829241'), DecompositionTestCase(population_description='adult patients with a diagnosis of rheumatoid arthritis', cohort_id='457', source='common RA trial inclusion phrasing (ACR/EULAR 2010 classification criteria)'), DecompositionTestCase(population_description='adults with a diagnosis of epilepsy', cohort_id='549', source='common epilepsy trial inclusion phrasing'), DecompositionTestCase(population_description='adults with a history of migraine', cohort_id='524', source='common migraine trial inclusion phrasing (ICHD-3 diagnostic criteria)'), DecompositionTestCase(population_description='adult patients presenting to the emergency department with suspected sepsis', cohort_id='463', source='common sepsis trial inclusion phrasing (ED presentation, suspected infection)'), DecompositionTestCase(population_description='adults with a new diagnosis of inflammatory bowel disease', cohort_id='775', source="common IBD trial inclusion phrasing (Crohn's disease or ulcerative colitis)"), DecompositionTestCase(population_description='adult patients with a diagnosis of liver cirrhosis', cohort_id='626', source='common cirrhosis trial inclusion phrasing'), DecompositionTestCase(population_description='adults with a diagnosis of hyperlipidemia', cohort_id='558', source='common hyperlipidemia/dyslipidemia trial inclusion phrasing'), DecompositionTestCase(population_description='adults with obesity', cohort_id='563', source='common obesity trial inclusion phrasing (BMI-based eligibility)')]¶
Phrasing patterns drawn from real ClinicalTrials.gov study titles and inclusion-criteria language for each bundled cohort’s condition — not the cohort’s own bundled display name, and not invented from scratch, so these test decomposition against how population descriptions are actually phrased in practice, not against this project’s own wording conventions.
- phenoforge.eval.decomposition.score_decomposition(con, library_dir, decompose_fn, dense=None, k=10, fanout_threshold=100, test_cases=None)[source]¶
Score
decompose_fnagainst each test case’s bundled cohort ground truth.- Parameters:
con (
DuckDBPyConnection) – Open connection tovocab.duckdb.library_dir (
Path) – Directory of fetched OHDSI Phenotype Library cohorts.decompose_fn (
Callable[[str],list[str]]) – Decomposition function under test — passdefault_decomposer()’s result for a real Claude call, or a fake for testing.dense (
DenseRetriever|None) – Pre-built dense retriever, orNoneto degrade to BM25-only, matchingsearch_concepts’s existing fallback.k (
int) – Result count passed to each retrieval call. Defaults to10to matchagent/nodes.py’scheck_curated/generateandengine/hybrid.hybrid_search’s own defaults — this function’s whole premise is comparing against what the real pipeline does, so its depth has to match that pipeline’s, not an arbitrary wider value (a mismatch here previously existed: this defaulted to 25 while production defaults to 10, silently measuring a more generous search than what actually ships).fanout_threshold (
int) – Passed through to curated ground-truth loading and curated resolution.test_cases (
list[DecompositionTestCase] |None) – Cases to run; defaults toDEFAULT_TEST_CASES.
- Returns:
Per-case scores and the mean gap.
- Return type: