Skip to content

Unify UMLS semantic-type to Biolink-class mappings into one registry#804

Open
gaurav wants to merge 5 commits into
mainfrom
fix-umls-types
Open

Unify UMLS semantic-type to Biolink-class mappings into one registry#804
gaurav wants to merge 5 commits into
mainfrom
fix-umls-types

Conversation

@gaurav

@gaurav gaurav commented May 29, 2026

Copy link
Copy Markdown
Collaborator

What and why

Babel assigns a Biolink Model class to every UMLS concept from its UMLS semantic type, but the logic was scattered: seven hardcoded tree-number maps across createcompendia/*.py, plus a separate Biolink-Model lookup in leftover_umls.py. This PR consolidates all of it into one tree-number-keyed registry, src/datahandlers/umls/semantic_types.py, and adds a mechanism to record (and test) the places where Babel deliberately disagrees with the Biolink Model.

This is the UMLS slice of #735 (consolidate per-vocabulary tree-partition registries so filters don't overlap). Scope here is UMLS only and behavior-preserving — see "Deliberately out of scope" below.

Changes

  • Package conversion: src/datahandlers/umls.py -> src/datahandlers/umls/__init__.py (no content change; all imports unchanged). Tracking issue to split the rest of __init__.py into subfiles: Reorganize src/datahandlers/umls into subfiles #802.
  • Registry (semantic_types.py):
    • SEMANTIC_NETWORK — full 127-entry UMLS Semantic Network (TUI <-> tree number <-> name), hardcoded for offline use, with translation helpers.
    • UMLS_TYPE_MAP — one UMLSTypeAssignment per tree number: the Biolink class Babel assigns, the owning compendium, an optional proposed_biolink_type + tracking issue for disagreements with the Biolink Model. Validated at import.
    • category_map_for() derives each partition map; umls_tree_number_to_biolink_type() / resolve_biolink_types() power the leftover catch-all (registry-first, Biolink Model fallback).
  • Consumers: the six category_map-style write_umls_ids() sites and leftover_umls.py now derive from the registry. Per-compendium blocklists stay local (exclusion logic, not type assignments). gene.py (bespoke MRCONSO path) and the RxNorm path are intentionally left alone.
  • Tests: golden-snapshot of the six partition maps, translation, resolver, and validation (unit); a network test that fails/xfails once the Biolink Model adopts a proposed mapping; a pipeline drift test that checks SEMANTIC_NETWORK against the live MRSTY.RRF.
  • Docs: docs/UMLSSemanticTypes.md, a CLAUDE.md subsection, and tests/README.md entries.

Behavior impact

The partition maps are byte-identical to the previous hardcoded maps (verified by the golden-snapshot test and a direct before/after diff on the live MRSTY.RRF — same CUI set, same counts). Three disagreements are recorded but not applied (proposed_biolink_type only), so build output is unchanged.

One intended difference in leftover_umls: residual CUIs whose semantic type Babel partitions are now typed from the registry (registry-first) rather than from the Biolink Model — e.g. a protein-tree CUI that was blocklisted into the leftover compendium now resolves to Protein instead of Biolink's Polypeptide. This never moves a CUI between compendia; it only makes leftover typing consistent with the partition decisions.

Deliberately out of scope (follow-ups)

Testing

uv run pytest tests/datahandlers/test_umls_semantic_types.py -m unit -q     # offline
uv run pytest tests/datahandlers/test_umls_semantic_types.py --network -v   # vs live Biolink
uv run pytest tests/pipeline/test_umls.py --pipeline --no-cov -v            # drift + partitioning
uv run pytest -m unit -q                                                    # full unit suite (92 pass)

Closes part of #735 (UMLS portion).

🤖 Generated with Claude Code

gaurav and others added 5 commits May 29, 2026 02:06
Move src/datahandlers/umls.py to src/datahandlers/umls/__init__.py with no
content changes, so that UMLS semantic-type logic can live in its own module
(src/datahandlers/umls/semantic_types.py, added next). All existing
`from src.datahandlers import umls` imports keep working unchanged.

The rest of __init__.py should be split into focused subfiles later; tracked
in #802.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce src/datahandlers/umls/semantic_types.py as the single source of
truth for mapping UMLS semantic types to Biolink classes, keyed by the UMLS
semantic-type tree number (the canonical key across MeSH/UMLS partitioning):

- SEMANTIC_NETWORK: the full 127-entry UMLS Semantic Network (TUI <-> tree
  number <-> name), hardcoded so the module is usable offline, with helpers
  to translate between TUIs and tree numbers.
- UMLS_TYPE_MAP: one UMLSTypeAssignment per tree number recording the Biolink
  class Babel assigns, the owning compendium, an optional proposed_biolink_type
  (what we argue the Biolink Model itself should map the type to) and tracking
  issue. Validated at import (one owner per tree number, known categories
  constants, issue required for every disagreement).
- category_map_for(): derives each compendium's partition map.
- umls_tree_number_to_biolink_type()/resolve_biolink_types(): resolve types
  for the leftover catch-all, registry-first with the Biolink Model as
  fallback.

Add biolink:Phenomenon and biolink:ClinicalFinding to src/categories.py for
use as proposed mappings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the seven hardcoded UMLS semantic-type maps in createcompendia with
calls to umls.semantic_types.category_map_for(); per-compendium blocklists
stay local to each module (they are exclusion logic, not type assignments).
leftover_umls now resolves residual CUIs via resolve_biolink_types() (registry
first, Biolink Model fallback) instead of an inline nested function.

This is behavior-preserving: the derived partition maps are byte-identical to
the previous hardcoded maps. The registry records three tracked disagreements
with the Biolink Model (#111 Neoplastic Process, #569 Finding and Laboratory
or Test Result) via proposed_biolink_type, but none are applied yet, so build
output is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tests/datahandlers/test_umls_semantic_types.py:
- unit: registry reproduces the legacy per-compendium partition maps (golden
  snapshot), TUI<->tree-number translation, the leftover resolver prefers the
  registry over the Biolink Model, and validation rejects malformed entries.
- network: each tracked disagreement (proposed_biolink_type) fails or xfails
  once the live Biolink Model adopts it, signalling the override is redundant.

tests/pipeline/test_umls.py:
- pipeline: SEMANTIC_NETWORK must still match the live MRSTY.RRF (drift
  detection at UMLS upgrade time).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/UMLSSemanticTypes.md (registry shape, how to change an assignment
with the no-CUI-moves check, the disagreement -> track -> retire workflow),
a "UMLS semantic-type mappings" subsection in CLAUDE.md, and entries in
tests/README.md for the new test files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gaurav gaurav added this to the Translator June Hackathon 2026 milestone May 29, 2026
gaurav added a commit that referenced this pull request Jun 4, 2026
…erage report (#816)

## Context

Several issues report that "leftover" UMLS concepts get the wrong
Biolink type
([#569](#569), umbrella
[#410](#410),
[#579](#579)). The
correct long-term fix is in the Biolink Model, but it's hard to predict
how a Biolink change lands in real Babel data. PR
[#804](#804) is a
comprehensive UMLS consolidation that's too large to review safely
before the next release.

This is the small, reviewable alternative: keep the existing
leftover-UMLS flow but correct the typing with declarative override
tables, guard them with a test, and add a coverage report.

## What changed

### Typing overrides (`src/createcompendia/leftover_umls.py`)

Two declarative tables at the top of the module replace the inline
`if`-chain:

- **`STY_OVERRIDES: dict[str, str | None]`** — overrides the Biolink
type bmt assigns to a single UMLS semantic type (`None` = deliberately
reject). Each entry cites its motivating issue:
- `T033` "Finding" → `biolink:Phenomenon` (#569; no STY mapping in
biolink 4.4.0, so these were dropped as unmapped).
- `T034` "Laboratory or Test Result" → `biolink:ClinicalFinding` (#569;
bmt maps it to the broader `biolink:Phenomenon`).
- `T058` "Health Care Activity" → `biolink:ClinicalIntervention` (#90;
bmt assigns the generic `biolink:Activity`).
- `T045` "Genetic Function" → `biolink:BiologicalProcess` (#421; no STY
mapping).
- `T021` "Fully Formed Anatomical Structure" →
`biolink:GrossAnatomicalStructure` (#421; no STY mapping).
- `T120` "Chemical Viewed Functionally" → `biolink:ChemicalEntity`
(#421; no STY mapping).
- `T122` "Biomedical or Dental Material" → `biolink:ChemicalEntity`
(#421; no STY mapping).
- `T168` "Food" → `biolink:Food` (#421; no STY mapping). With `T121`
"Pharmacologic Substance" this resolves to `biolink:Food` via the
`{Drug, Food} → Food` entry in `TYPE_COMBO_OVERRIDES`.
- `T090`/`T091`/`T097` (occupation/discipline) →
`biolink:PopulationOfIndividualOrganisms` as a placeholder (#817; ~8,881
CUIs previously dropped). Imprecise pending a proper Biolink fix.
- **`TYPE_COMBO_OVERRIDES: dict[frozenset[str], str]`** — disambiguates
a concept that resolves to multiple Biolink types (migrated verbatim
from the inline `if`-chain).

The resolution logic now classifies each semantic type as **mapped /
rejected / unmapped** and reports the skip reason accordingly
(`NO_UMLS_TYPE`, `REJECTED`, `MULTIPLE_UMLS_TYPES`). The conservative
behavior is unchanged: a concept with any unmapped semantic type is
still skipped entirely. bmt lookups are now memoized per TUI.

`tui_to_biolink_type()` deliberately does **not** apply the overrides —
it returns the raw Biolink answer so the test can compare against it.

### Drift test (`tests/createcompendia/test_leftover_umls.py`,
`network`-marked)

Records the current Biolink `STY:<code>` mapping for each override in
`RECORDED_STY_BASELINE` and, on each run:

- **hard-fails** when the live mapping diverges from the recorded
baseline (Biolink changed → re-review the override), and
- **warns** when the live mapping has come to equal the override
(Biolink now agrees → override is redundant).

Also checks that every override has a recorded baseline and that every
type named in `TYPE_COMBO_OVERRIDES` is a real class in the pinned
model. Run it when bumping `biolink_version`.

### Coverage report (consolidated under `babel_outputs/reports/umls/`)

The rule's `report` output moved from `reports/umls.txt` to
`reports/umls/log.txt`, and all UMLS reports now live in that directory.
`log.txt` is the complete per-CURIE record; the CSVs are aggregate
counts plus up to 5 sample `CURIE=label`s per bucket:

- `compendium-coverage.csv` — per input compendium: total UMLS CURIEs
and single-UMLS-clique count.
- `types-coverage.csv` — per Biolink type of the leftover cliques: count
+ samples.
- `unmapped-types.csv` — per semantic type that was unmapped or
rejected: status, affected CUI count, samples.
- `multi-type-curies.csv` — CURIEs still resolving to multiple Biolink
types after `TYPE_COMBO_OVERRIDES`: the type combo, count, samples.
- `tui-sty.tsv` — raw STY→semantic-type-name dump (was
`reports/umls-types.tsv`).

### Supporting changes

- **New constants** in `src/categories.py`: `CLINICAL_FINDING`,
`CLINICAL_INTERVENTION`, `COHORT`, `PHENOMENON`,
`POPULATION_OF_INDIVIDUAL_ORGANISMS`.
- **Docs** — a Leftover UMLS section in `CLAUDE.md` and a new
`docs/sources/UMLS/Leftover.md` (indexed in `docs/sources/README.md`),
covering the override tables, the drift test, and the counts-vs-samples
design of the CSVs.

## Issues

**Closed by the overrides here** (they fire for concepts that fall
through to the leftover sweep):

Closes #90
Closes #421

**Related #410 children not addressed here** — the mis-typing originates
outside the leftover-UMLS path (in `diseasephenotype.py`, EFO/NCIT
typing, or LitCoin NER), so an `STY_OVERRIDES` entry can't reach those
concepts: #111, #255, #269, #324, #392.

**Handled separately:**

- **#239** (toxic waste, `T131`) — already fixed upstream: `STY:T131`
now maps to `biolink:ChemicalEntity` in biolink 4.4.0. No override
needed.
- **#817** — `T090`/`T091`/`T097` mapped to
`biolink:PopulationOfIndividualOrganisms` as a placeholder; a proper
Biolink fix is tracked there.
- **#569** (the `T033`/`T034` seed) — closed by the stacked follow-up
**#818**, not here. Most T033/T034 CUIs are still claimed upstream by
`diseasephenotype.py` (as `PhenotypicFeature`) and never reach the
leftover sweep; #818 stops that so these overrides can take effect.

## Testing

- `ruff`, `snakefmt`, `rumdl` clean; unit + network tests pass (the
drift test confirms the live biolink 4.4.0 baseline for every override).
- Synthetic smoke test confirms overrides apply, unmapped/rejected types
are reported, already-claimed CUIs are skipped, and all CSVs are
correct.
- **Not yet run against a full build** — @gaurav will validate override
effects and the new `reports/umls/*.csv` in the next full pipeline run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@gaurav gaurav removed this from the Translator June Hackathon 2026 milestone Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant