gem-mapping-studio 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forome/gem/_reference/__init__.py +12 -0
- forome/gem/_reference/dimensions.md +388 -0
- forome/gem/_reference/genetic_evidence.shacl.ttl +387 -0
- forome/gem/_reference/semantic_types.yaml +949 -0
- forome/gem/extraction/__init__.py +0 -0
- forome/gem/extraction/extract_annotations.py +106 -0
- forome/gem/extraction/extract_annotations_pypdf.py +140 -0
- forome/gem/extraction/yaml_to_rdf.py +191 -0
- forome/gem/umls/__init__.py +0 -0
- forome/gem/umls/_paths.py +58 -0
- forome/gem/umls/adjudicate_ui.py +2253 -0
- forome/gem/umls/build_umls_crosswalk.py +384 -0
- forome/gem/umls/classify_credibility_sweep.py +112 -0
- forome/gem/umls/fetch_semantic_network.py +133 -0
- forome/gem/umls/local_umls.py +806 -0
- forome/gem/umls/render_crosswalk_tex.py +312 -0
- forome/gem/umls/semantic_types.py +130 -0
- forome/gem/umls/sweep.py +576 -0
- forome/gem/umls/uts_client.py +415 -0
- forome/gem/validation/__init__.py +0 -0
- forome/gem/validation/compute_coverage.py +149 -0
- forome/gem/validation/validate_annotations.py +123 -0
- gem_mapping_studio-0.2.2.dist-info/METADATA +465 -0
- gem_mapping_studio-0.2.2.dist-info/RECORD +29 -0
- gem_mapping_studio-0.2.2.dist-info/WHEEL +5 -0
- gem_mapping_studio-0.2.2.dist-info/entry_points.txt +11 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE-code.txt +202 -0
- gem_mapping_studio-0.2.2.dist-info/licenses/LICENSE.txt +395 -0
- gem_mapping_studio-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the GEM -> UMLS crosswalk by resolving each dimension/value to a
|
|
3
|
+
UMLS concept via the UTS REST API.
|
|
4
|
+
|
|
5
|
+
This is the harness. It reads the curated inventory
|
|
6
|
+
(``mapping/dimensions_inventory.yaml``), queries UMLS for each entry through a
|
|
7
|
+
tiered search (exact -> normalized -> words), assigns an honest status
|
|
8
|
+
(``mapped`` / ``review`` / ``unmapped`` / ``pending``), and writes a
|
|
9
|
+
machine-readable crosswalk to ``mapping/umls_crosswalk.yaml`` plus a coverage
|
|
10
|
+
summary. The LaTeX table is rendered separately by
|
|
11
|
+
``mapping/render_crosswalk_tex.py`` from that YAML.
|
|
12
|
+
|
|
13
|
+
Honesty by construction:
|
|
14
|
+
* Nothing is mapped without a UMLS query actually returning a concept.
|
|
15
|
+
* Without an API key the harness runs but every entry is ``pending`` (queried
|
|
16
|
+
nothing); it does not invent CUIs.
|
|
17
|
+
* A query that finds candidates but no faithful exact/normalized match is
|
|
18
|
+
``review`` (candidates recorded for a curator), never silently ``mapped``.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
# 1. verify the inventory still covers the SHACL enumerations
|
|
22
|
+
python3 mapping/build_umls_crosswalk.py --check-inventory
|
|
23
|
+
|
|
24
|
+
# 2. real run (needs a UMLS license; key in UMLS_API_KEY or --api-key)
|
|
25
|
+
UMLS_API_KEY=... python3 mapping/build_umls_crosswalk.py
|
|
26
|
+
|
|
27
|
+
# 3. offline run with no key -> all entries 'pending' (scaffold mode)
|
|
28
|
+
python3 mapping/build_umls_crosswalk.py --no-key
|
|
29
|
+
|
|
30
|
+
# 4. offline run against test fixtures (used by the unit tests)
|
|
31
|
+
python3 mapping/build_umls_crosswalk.py --fixtures mapping/fixtures \
|
|
32
|
+
--out mapping/umls_crosswalk.demo.yaml
|
|
33
|
+
|
|
34
|
+
Requirements: ``pyyaml``; ``requests`` for live runs; ``rdflib`` for
|
|
35
|
+
``--check-inventory``.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import unicodedata
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
|
|
47
|
+
from forome.gem.umls import semantic_types as stylib
|
|
48
|
+
from forome.gem.umls._paths import DATA_DIR, SCHEMA_DIR
|
|
49
|
+
|
|
50
|
+
BASE = SCHEMA_DIR.parent
|
|
51
|
+
INVENTORY = DATA_DIR / "dimensions_inventory.yaml"
|
|
52
|
+
ADJUDICATIONS = DATA_DIR / "adjudications.yaml"
|
|
53
|
+
SHACL = SCHEMA_DIR / "genetic_evidence.shacl.ttl"
|
|
54
|
+
DEFAULT_OUT = DATA_DIR / "umls_crosswalk.yaml"
|
|
55
|
+
CACHE_DIR = DATA_DIR / "cache"
|
|
56
|
+
|
|
57
|
+
# SHACL enum members deliberately NOT expected in the inventory:
|
|
58
|
+
# - the sentinel, declared a member of every dimension class
|
|
59
|
+
# - the legacy method synonyms (migrate to the renamed values)
|
|
60
|
+
ENUM_EXCLUDE = {
|
|
61
|
+
"NOT_APPLICABLE_OR_OMITTED",
|
|
62
|
+
"IN_VIVO_EXPERIMENT", "IN_VITRO_EXPERIMENT", "BIOINFORMATICS_PREDICTION",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _norm(s: str | None) -> str:
|
|
67
|
+
if not s:
|
|
68
|
+
return ""
|
|
69
|
+
s = s.lower().strip()
|
|
70
|
+
# Fold accents to their base letters (NFD decomposition + drop combining
|
|
71
|
+
# marks) before stripping non-ASCII, so e.g. "Über" -> "uber", not "ber".
|
|
72
|
+
s = unicodedata.normalize("NFD", s)
|
|
73
|
+
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
|
|
74
|
+
s = re.sub(r"[^a-z0-9 ]+", " ", s)
|
|
75
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------
|
|
79
|
+
# Inventory loading: flatten the nested YAML into a list of entries.
|
|
80
|
+
# --------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def load_entries(inventory_path: Path = INVENTORY) -> list[dict]:
|
|
83
|
+
doc = yaml.safe_load(inventory_path.read_text())
|
|
84
|
+
entries: list[dict] = []
|
|
85
|
+
for dim, body in (doc.get("dimensions") or {}).items():
|
|
86
|
+
axis = body.get("axis")
|
|
87
|
+
if axis:
|
|
88
|
+
entries.append(_entry(dim, None, "axis", axis))
|
|
89
|
+
for v in body.get("values") or []:
|
|
90
|
+
entries.append(_entry(dim, v["token"], "value", v))
|
|
91
|
+
for v in body.get("common_values") or []:
|
|
92
|
+
entries.append(_entry(dim, v["token"], "common_value", v))
|
|
93
|
+
return entries
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _entry(dim: str, token, kind: str, src: dict) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"dimension": dim,
|
|
99
|
+
"token": token,
|
|
100
|
+
"kind": kind,
|
|
101
|
+
"query": src["query"],
|
|
102
|
+
"expect": src.get("expect", "uncertain"),
|
|
103
|
+
"sab": src.get("sab"),
|
|
104
|
+
"inventory_note": src.get("note"),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
# Resolution: tiered UMLS search with honest status assignment.
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def resolve(client, query: str, sab: str | None, live: bool,
|
|
113
|
+
stys: str | None = None) -> dict:
|
|
114
|
+
if not live:
|
|
115
|
+
return {"status": "pending"}
|
|
116
|
+
|
|
117
|
+
def tier(use_sab):
|
|
118
|
+
s = sab if use_sab else None
|
|
119
|
+
for st in ("exact", "normalizedString", "words"):
|
|
120
|
+
cands = client.search(query, search_type=st, sabs=s, semantic_types=stys)
|
|
121
|
+
if cands:
|
|
122
|
+
return st, cands
|
|
123
|
+
return None, []
|
|
124
|
+
|
|
125
|
+
used_sab = bool(sab)
|
|
126
|
+
st, cands = tier(use_sab=True)
|
|
127
|
+
if not cands and sab:
|
|
128
|
+
st, cands = tier(use_sab=False)
|
|
129
|
+
used_sab = False
|
|
130
|
+
|
|
131
|
+
if not cands:
|
|
132
|
+
return {"status": "unmapped"}
|
|
133
|
+
|
|
134
|
+
top = cands[0]
|
|
135
|
+
# Honesty by construction: 'mapped' requires the returned concept name to
|
|
136
|
+
# actually match the query (normalized), regardless of which search tier
|
|
137
|
+
# produced it. The search_type label alone is NOT a guarantee that the
|
|
138
|
+
# top hit is faithful, so an 'exact'-tier hit with a mismatched name is
|
|
139
|
+
# demoted to 'review', not silently mapped.
|
|
140
|
+
exactish = _norm(top.get("name")) == _norm(query)
|
|
141
|
+
status = "mapped" if exactish else "review"
|
|
142
|
+
return {
|
|
143
|
+
"status": status,
|
|
144
|
+
"cui": top["cui"],
|
|
145
|
+
"matched_name": top["name"],
|
|
146
|
+
"root_source": top["root_source"],
|
|
147
|
+
"semantic_types": top["semantic_types"],
|
|
148
|
+
"search_type": st,
|
|
149
|
+
"sab_filtered": used_sab,
|
|
150
|
+
"sty_filtered": bool(stys),
|
|
151
|
+
"candidates": [
|
|
152
|
+
{k: c[k] for k in ("cui", "name", "root_source", "semantic_types")}
|
|
153
|
+
for c in cands[:5]
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _adj_key(dimension: str, token) -> str:
|
|
159
|
+
return f"{dimension}/{token if token is not None else '(axis)'}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_adjudications(path: Path | None = None) -> dict:
|
|
163
|
+
"""Curator decisions keyed by 'dimension/token' (or 'dimension/(axis)').
|
|
164
|
+
``path`` defaults to the CURRENT ``ADJUDICATIONS`` (resolved at call time, so
|
|
165
|
+
tests/workspaces that repoint it are honoured)."""
|
|
166
|
+
path = ADJUDICATIONS if path is None else path
|
|
167
|
+
if not path.is_file():
|
|
168
|
+
return {}
|
|
169
|
+
doc = yaml.safe_load(path.read_text()) or {}
|
|
170
|
+
return doc.get("adjudications", {}) or {}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_adjudication(entry: dict, adj: dict, client=None) -> dict:
|
|
174
|
+
"""Apply a curator decision. Honest by construction: an accepted CUI is used
|
|
175
|
+
only if it was among the query's candidates OR the harness can fetch it live
|
|
176
|
+
from UMLS (confirming it is a real concept). The harness never fabricates a
|
|
177
|
+
concept the curator merely named. A curator may also accept a concept found
|
|
178
|
+
via the adjudication UI's live search; such a CUI is fetched here."""
|
|
179
|
+
if adj.get("unmapped"):
|
|
180
|
+
return {**entry, "status": "unmapped", "curated": True,
|
|
181
|
+
"curator_note": adj.get("note")}
|
|
182
|
+
cui = adj.get("accept")
|
|
183
|
+
if cui:
|
|
184
|
+
for c in (entry.get("candidates") or []):
|
|
185
|
+
if c["cui"] == cui:
|
|
186
|
+
return {**entry, "status": "mapped", "curated": True,
|
|
187
|
+
"cui": c["cui"], "matched_name": c["name"],
|
|
188
|
+
"root_source": c["root_source"],
|
|
189
|
+
"semantic_types": c["semantic_types"],
|
|
190
|
+
"curator_note": adj.get("note")}
|
|
191
|
+
# Not among the query candidates: fetch it live to confirm it exists.
|
|
192
|
+
concept = client.get_concept(cui) if client is not None else None
|
|
193
|
+
if concept and concept.get("cui") == cui:
|
|
194
|
+
return {**entry, "status": "mapped", "curated": True, "fetched": True,
|
|
195
|
+
"cui": cui, "matched_name": concept["name"],
|
|
196
|
+
"root_source": concept.get("root_source", "MTH"),
|
|
197
|
+
"semantic_types": concept.get("semantic_types", []),
|
|
198
|
+
"curator_note": adj.get("note")}
|
|
199
|
+
# Cannot confirm the concept: refuse to fabricate it.
|
|
200
|
+
return {**entry, "status": "review", "curated": False,
|
|
201
|
+
"curator_error": f"accepted CUI {cui} not in candidates and not "
|
|
202
|
+
f"confirmable via UMLS"}
|
|
203
|
+
return entry
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build(client, live: bool, inventory_path: Path = INVENTORY,
|
|
207
|
+
adjudications: dict | None = None,
|
|
208
|
+
only_dims: set | None = None) -> dict:
|
|
209
|
+
"""Resolve the whole inventory. Each dimension AXIS maps to a UMLS semantic
|
|
210
|
+
type (from adjudication accept_sty, else the inventory's semantic_type), and
|
|
211
|
+
that type's subtree constrains the search for the dimension's VALUE concepts
|
|
212
|
+
-- so axis and values reconcile by construction (a value is only ever mapped
|
|
213
|
+
within its axis's semantic branch).
|
|
214
|
+
|
|
215
|
+
``only_dims`` restricts resolution to those dimensions (a scoped rebuild);
|
|
216
|
+
the returned doc then contains only their entries, and the caller is
|
|
217
|
+
responsible for merging into a full crosswalk."""
|
|
218
|
+
if adjudications is None:
|
|
219
|
+
adjudications = load_adjudications()
|
|
220
|
+
doc = yaml.safe_load(inventory_path.read_text())
|
|
221
|
+
out_entries = []
|
|
222
|
+
for dim, body in (doc.get("dimensions") or {}).items():
|
|
223
|
+
if only_dims is not None and dim not in only_dims:
|
|
224
|
+
continue
|
|
225
|
+
axis = body.get("axis")
|
|
226
|
+
axis_adj = adjudications.get(_adj_key(dim, None)) or {}
|
|
227
|
+
if axis_adj.get("unmapped"):
|
|
228
|
+
axis_sty = None # curator: axis untyped
|
|
229
|
+
else:
|
|
230
|
+
axis_sty = axis_adj.get("accept_sty") or (axis or {}).get("semantic_type")
|
|
231
|
+
stys = stylib.filter_param(axis_sty) # value-search filter
|
|
232
|
+
|
|
233
|
+
if axis is not None:
|
|
234
|
+
e = _entry(dim, None, "axis", axis)
|
|
235
|
+
if not live:
|
|
236
|
+
e["status"] = "pending"
|
|
237
|
+
elif axis_sty:
|
|
238
|
+
sty = stylib.get(axis_sty)
|
|
239
|
+
if sty:
|
|
240
|
+
e.update({"status": "mapped",
|
|
241
|
+
"curated": bool(axis_adj.get("accept_sty")),
|
|
242
|
+
"sty_tui": sty["tui"], "sty_name": sty["name"],
|
|
243
|
+
"sty_tree": sty.get("tree_number"),
|
|
244
|
+
"curator_note": axis_adj.get("note")})
|
|
245
|
+
else:
|
|
246
|
+
e.update({"status": "review",
|
|
247
|
+
"curator_error": f"semantic type {axis_sty} not in "
|
|
248
|
+
"semantic_types.yaml (run fetch_semantic_network.py)"})
|
|
249
|
+
else:
|
|
250
|
+
e["status"] = "unmapped"
|
|
251
|
+
if axis_adj.get("unmapped"):
|
|
252
|
+
e.update({"curated": True, "curator_note": axis_adj.get("note")})
|
|
253
|
+
out_entries.append(e)
|
|
254
|
+
|
|
255
|
+
for kind, seq in (("value", "values"), ("common_value", "common_values")):
|
|
256
|
+
for v in body.get(seq) or []:
|
|
257
|
+
e = _entry(dim, v["token"], kind, v)
|
|
258
|
+
res = resolve(client, e["query"], e.get("sab"), live, stys=stys)
|
|
259
|
+
entry = {**e, **res}
|
|
260
|
+
adj = adjudications.get(_adj_key(dim, v["token"]))
|
|
261
|
+
if adj and live:
|
|
262
|
+
entry = apply_adjudication(entry, adj, client=client)
|
|
263
|
+
out_entries.append(entry)
|
|
264
|
+
|
|
265
|
+
counts = {s: 0 for s in ("mapped", "review", "unmapped", "pending")}
|
|
266
|
+
for e in out_entries:
|
|
267
|
+
counts[e["status"]] += 1 # KeyError surfaces any unexpected status
|
|
268
|
+
counts["curated"] = sum(1 for e in out_entries if e.get("curated"))
|
|
269
|
+
counts["axis_typed"] = sum(1 for e in out_entries if e.get("sty_tui"))
|
|
270
|
+
return {
|
|
271
|
+
"meta": {
|
|
272
|
+
"generated_by": "mapping/build_umls_crosswalk.py",
|
|
273
|
+
"source_inventory": "mapping/dimensions_inventory.yaml",
|
|
274
|
+
"live": live,
|
|
275
|
+
"total": len(out_entries),
|
|
276
|
+
"counts": counts,
|
|
277
|
+
},
|
|
278
|
+
"entries": out_entries,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------
|
|
283
|
+
# Inventory <-> SHACL drift check.
|
|
284
|
+
# --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def shacl_enum_members() -> set[str]:
|
|
287
|
+
import rdflib
|
|
288
|
+
g = rdflib.Graph().parse(str(SHACL))
|
|
289
|
+
GEM = "https://w3id.org/genetic-evidence-model/"
|
|
290
|
+
enum_classes = {GEM + c for c in (
|
|
291
|
+
"KnowledgeDomain", "Method", "TargetType", "Resolution", "Credibility",
|
|
292
|
+
"PhenotypeScale", "VariantAscertainment", "MeasurementTarget",
|
|
293
|
+
"GeneRelation", "KnockoutType", "Penetrance")}
|
|
294
|
+
rdf_type = rdflib.RDF.type
|
|
295
|
+
members = set()
|
|
296
|
+
for s, _, o in g.triples((None, rdf_type, None)):
|
|
297
|
+
if str(o) in enum_classes and str(s).startswith(GEM):
|
|
298
|
+
members.add(str(s)[len(GEM):])
|
|
299
|
+
return members - ENUM_EXCLUDE
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Dimensions whose values are intentionally NOT in the SHACL enumerations
|
|
303
|
+
# (open / not schema-enumerated): the harness expects inventory tokens here
|
|
304
|
+
# that have no SHACL member.
|
|
305
|
+
OPEN_DIMS = {"subdomain", "mode_of_inheritance"}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def check_inventory() -> int:
|
|
309
|
+
members = shacl_enum_members()
|
|
310
|
+
entries = load_entries()
|
|
311
|
+
inv_tokens = {str(e["token"]) for e in entries if e["token"] is not None}
|
|
312
|
+
missing = sorted(members - inv_tokens)
|
|
313
|
+
# Extra inventory tokens are expected for the open dimensions; any OTHER
|
|
314
|
+
# extra (a token that is neither a SHACL member nor in an open dimension)
|
|
315
|
+
# is surfaced as a warning so a stray/typo'd token does not hide.
|
|
316
|
+
open_tokens = {str(e["token"]) for e in entries
|
|
317
|
+
if e["token"] is not None and e["dimension"] in OPEN_DIMS}
|
|
318
|
+
extra = sorted((inv_tokens - members) - open_tokens)
|
|
319
|
+
if missing:
|
|
320
|
+
print("INVENTORY DRIFT: SHACL enum members not covered by the inventory:")
|
|
321
|
+
for m in missing:
|
|
322
|
+
print(f" - {m}")
|
|
323
|
+
return 1
|
|
324
|
+
print(f"Inventory covers all {len(members)} SHACL enum members "
|
|
325
|
+
f"(excluding sentinel + legacy synonyms).")
|
|
326
|
+
if extra:
|
|
327
|
+
print(f"WARNING: {len(extra)} inventory token(s) outside both the SHACL "
|
|
328
|
+
f"enums and the open dimensions ({', '.join(sorted(OPEN_DIMS))}):")
|
|
329
|
+
for e in extra:
|
|
330
|
+
print(f" - {e}")
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------
|
|
335
|
+
# CLI
|
|
336
|
+
# --------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _make_client(args):
|
|
339
|
+
if args.check_inventory:
|
|
340
|
+
return None, False
|
|
341
|
+
if args.fixtures:
|
|
342
|
+
from forome.gem.umls.uts_client import FixtureClient
|
|
343
|
+
return FixtureClient(Path(args.fixtures)), True
|
|
344
|
+
key = args.api_key or os.environ.get("UMLS_API_KEY", "")
|
|
345
|
+
if key and not args.no_key:
|
|
346
|
+
from forome.gem.umls.uts_client import UTSClient
|
|
347
|
+
return UTSClient(key, cache_dir=CACHE_DIR), True
|
|
348
|
+
from forome.gem.umls.uts_client import NullClient
|
|
349
|
+
return NullClient(), False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main(argv=None) -> int:
|
|
353
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
354
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
355
|
+
ap.add_argument("--api-key", help="UMLS UTS API key (else $UMLS_API_KEY).")
|
|
356
|
+
ap.add_argument("--no-key", action="store_true",
|
|
357
|
+
help="Force scaffold mode: run with no UMLS query (all 'pending').")
|
|
358
|
+
ap.add_argument("--fixtures", help="Directory of canned UTS responses (offline tests).")
|
|
359
|
+
ap.add_argument("--out", default=str(DEFAULT_OUT), help="Output crosswalk YAML path.")
|
|
360
|
+
ap.add_argument("--check-inventory", action="store_true",
|
|
361
|
+
help="Only verify the inventory still covers the SHACL enums; do not query.")
|
|
362
|
+
args = ap.parse_args(argv)
|
|
363
|
+
|
|
364
|
+
if args.check_inventory:
|
|
365
|
+
return check_inventory()
|
|
366
|
+
|
|
367
|
+
client, live = _make_client(args)
|
|
368
|
+
doc = build(client, live)
|
|
369
|
+
Path(args.out).write_text(
|
|
370
|
+
yaml.safe_dump(doc, sort_keys=False, allow_unicode=True, width=100))
|
|
371
|
+
|
|
372
|
+
c = doc["meta"]["counts"]
|
|
373
|
+
print(f"Wrote {args.out}")
|
|
374
|
+
print(f" total={doc['meta']['total']} mapped={c['mapped']} "
|
|
375
|
+
f"review={c['review']} unmapped={c['unmapped']} pending={c['pending']}"
|
|
376
|
+
f" (curated={c.get('curated', 0)})")
|
|
377
|
+
if not live:
|
|
378
|
+
print(" (scaffold mode: no UMLS key supplied -> all entries 'pending'. "
|
|
379
|
+
"Set UMLS_API_KEY and rerun to resolve concepts.)")
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
if __name__ == "__main__":
|
|
384
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Classify the credibility-sweep candidates against the adequacy criteria.
|
|
3
|
+
|
|
4
|
+
Applies the rule families (regular expressions over the concept's preferred
|
|
5
|
+
name) that assign each candidate the criterion it fails -- A denotation,
|
|
6
|
+
B granularity, C set membership, D domain sense -- with a one-line reason.
|
|
7
|
+
Together with ``gem-umls-sweep`` and ``data/umls/sweeps/credibility.yaml``
|
|
8
|
+
this fully regenerates the curated results from any licensed UMLS copy; the
|
|
9
|
+
results themselves are not redistributed (they carry UMLS-derived strings).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
gem-umls-classify-credibility RESULTS.yaml [--check]
|
|
13
|
+
|
|
14
|
+
``--check`` reports the classification without writing. A candidate no rule
|
|
15
|
+
matches is left unclassified (``fails: null``) and reported -- extend RULES
|
|
16
|
+
rather than forcing a code.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from collections import Counter
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
# (criterion, regex on the preferred name, one-line reason). Order matters:
|
|
28
|
+
# specific families come before generic degree words.
|
|
29
|
+
RULES: list[tuple[str, str, str]] = [
|
|
30
|
+
("C", r"^(Definite|Probable|Possible|Probably|Possibly|Definitely|Unlikely|Certain)(\s*\(.*\))?$|^(Probable|Possible|Definite) diagnosis$",
|
|
31
|
+
"epistemic-modality qualifier (SNOMED diagnostic-certainty family: definite/probable/possible) — the nearest UMLS construct; denotes the likelihood that a proposition holds, not the defeasibility of trust in an evidence item; a three-point ladder with no intensified top, bound in its sources to diagnosis assertions (also D)"),
|
|
32
|
+
("D", r"Related to Intervention",
|
|
33
|
+
"adverse-event causality attribution ladder (NCI: definitely/probably/possibly related) — degree of belief about causation of one event, not warrant for a research finding"),
|
|
34
|
+
("D", r"TNM|degree of certainty of|Qualifier for TNM",
|
|
35
|
+
"SNOMED/TNM certainty factor (C-factor): certainty of a staging classification for one patient, a diagnostic-assertion qualifier, not evidence credibility"),
|
|
36
|
+
("D", r"Level of Diagnostic Certainty|Diagnosis Confidence Level|UHDRS",
|
|
37
|
+
"certainty of a clinical diagnosis (rating-scale item), not warrant for a research finding (also B: names the dimension, not a level)"),
|
|
38
|
+
("D", r"Level of evidence.*(Bld|Tiss|Molecular|Blood)",
|
|
39
|
+
"LOINC molecular-pathology 'level of evidence' (AMP/ASCO/CAP actionability tier for a variant's clinical significance): a clinical-actionability tier bound to a variant report, not curator warrant for an evidence item (also B: the attribute, not a level)"),
|
|
40
|
+
("D", r"Metabolite Identification Confidence",
|
|
41
|
+
"metabolomics identification-confidence levels (Schymanski 1–5): an ordered epistemic ladder, but about confidence in a structure identification — domain-bound (D) and defined by analytical evidence type, not defeasibility"),
|
|
42
|
+
("D", r"^Current level of confidence I can|^Current level of confidence|confidence:Find:Pt|Overall level of confidence with|I am confident|How confident|confident (that|about|in)|self[- ]?efficacy",
|
|
43
|
+
"patient-reported self-efficacy questionnaire item (LOINC/PROMIS): confidence in performing an activity, not warrant about evidence"),
|
|
44
|
+
("D", r"^(Very High|High|Moderate|Low) Confidence$|^(Very |Somewhat |Not at all |Extremely |Fairly |Quite |Slightly )?Confident$|^Not Confident",
|
|
45
|
+
"NCI Clinical or Research Assessment Answer — a questionnaire response option for a respondent's confidence (Female Sexual Function Index subset), not an epistemic grade of evidence, despite matching GEM's four-level shape"),
|
|
46
|
+
("D", r"self[- ]?esteem|Self Confidence|^Confidence$|Lack of confidence|Loss of confidence|confidence in (self|abilities)|Euphoric",
|
|
47
|
+
"psychological trait/state, not warrant about evidence"),
|
|
48
|
+
("A", r"^Level of Evidence( (I|II|III|IV|V)[A-Za-z0-9]*)?$|Level of Evidence [IVX]+|^Evidence Level|^Level of evidence$",
|
|
49
|
+
"NCI/PDQ level-of-evidence: a study-design hierarchy (RCT > cohort > case series), not degree of warrant; mapping to it would collapse GEM's method facet into credibility (also C for the GEM scale)"),
|
|
50
|
+
("A", r"Level of Confidence \(statistical\)|confidence interval|confidence limit|confidence coefficient|^Statistical",
|
|
51
|
+
"statistical quantity (interval/coefficient), not a degree of belief in evidence"),
|
|
52
|
+
("A", r"probability|likelihood|risk score|risk category|score$|Score\b|Scale$|Index$|staging category",
|
|
53
|
+
"a quantitative score/probability or risk/staging category of a clinical measurand, not epistemic warrant"),
|
|
54
|
+
("D", r"[Gg]rade|GRADE",
|
|
55
|
+
"'grade' in a non-epistemic sense (tumour, school, product grade)"),
|
|
56
|
+
("D", r"[Cc]redib",
|
|
57
|
+
"credibility of a health-information source or witness as perceived by a patient/clinician (LOINC/CHV/NIC), not the curator's warrant for an evidence item"),
|
|
58
|
+
("D", r"\[X\]|alcohol|[Ee]vidence of|evidence-based practice",
|
|
59
|
+
"clinical 'evidence of <condition>' sense (presence of a sign) or a care activity, not evidence strength"),
|
|
60
|
+
("A", r"^(High|Very high|Very High|Extremely high|Abnormally high|Highest|Low|Very low|Lowest|Moderate|\(\+\) Moderate|Medium|Minimal|Maximal|Maximum|Minimum|Mild|Moderation|Strong|Weak|Weakness|Markedly increased|Increased|Decreased|Normal|Intermediate)(\s*\(.*\))?$",
|
|
61
|
+
"generic degree/qualifier value (SNOMED qualifier-value branch, IS-A Increased/Decreased): magnitude of a measurand, not epistemic degree"),
|
|
62
|
+
("A", r"^(A )?(Medium|High|Low|Very high|Very low|Moderate|Minimal|Strong|Weak)( Amount| Level| Degree| Dose| Intensity| Frequency| Quality| Risk| Grade| Priority| Severity| Strength| Concentration| Pressure| Temperature| Volume| Density| Power| Speed| Altitude| Titer)?( of .*)?$",
|
|
63
|
+
"degree of a measured/graded property, not warrant about evidence"),
|
|
64
|
+
("D", r"[Mm]edia\b|Tunica|Lowing|Lower \(action\)|spatial qualifier|Body Site Modifier|Culture Media",
|
|
65
|
+
"lexical collision (medium/media, lower) — unrelated sense"),
|
|
66
|
+
("A", r"\b(high|low|moderate|medium|very high|very low|minimal|strong|weak|extremely high|highest|mild|maximal)\b",
|
|
67
|
+
"the qualifier appears as a magnitude/degree of a clinical or physical property (measurand degree), not as a degree of epistemic warrant"),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify(name: str) -> tuple[str, str] | None:
|
|
72
|
+
for code, rx, why in RULES:
|
|
73
|
+
if re.search(rx, name or "", re.I):
|
|
74
|
+
return code, why
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv=None) -> int:
|
|
79
|
+
ap = argparse.ArgumentParser(prog="gem-umls-classify-credibility",
|
|
80
|
+
description=__doc__.splitlines()[0])
|
|
81
|
+
ap.add_argument("results", help="a gem-umls-sweep results YAML to classify")
|
|
82
|
+
ap.add_argument("--check", action="store_true",
|
|
83
|
+
help="report only; do not write")
|
|
84
|
+
args = ap.parse_args(argv)
|
|
85
|
+
doc = yaml.safe_load(open(args.results))
|
|
86
|
+
dist: Counter = Counter()
|
|
87
|
+
residual = []
|
|
88
|
+
for c in doc.get("candidates") or []:
|
|
89
|
+
hit = classify(c.get("name"))
|
|
90
|
+
if hit is None:
|
|
91
|
+
residual.append(c)
|
|
92
|
+
continue
|
|
93
|
+
c["fails"], c["why"] = hit
|
|
94
|
+
dist[hit[0]] += 1
|
|
95
|
+
print(f"classified {sum(dist.values())} of "
|
|
96
|
+
f"{len(doc.get('candidates') or [])}: {dict(dist)}")
|
|
97
|
+
for c in residual:
|
|
98
|
+
print(f" UNCLASSIFIED {c.get('cui')} {c.get('name')!r} "
|
|
99
|
+
f"{c.get('root_source')}")
|
|
100
|
+
if residual:
|
|
101
|
+
print("extend RULES for the unclassified candidates; nothing written"
|
|
102
|
+
if not args.check else "")
|
|
103
|
+
return 1
|
|
104
|
+
if not args.check:
|
|
105
|
+
yaml.safe_dump(doc, open(args.results, "w"), sort_keys=False,
|
|
106
|
+
allow_unicode=True, width=110)
|
|
107
|
+
print(f"written: {args.results}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
sys.exit(main())
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fetch the full UMLS Semantic Network (all semantic types + tree numbers) and
|
|
3
|
+
render it as a hierarchical tree.
|
|
4
|
+
|
|
5
|
+
Writes:
|
|
6
|
+
mapping/semantic_types.yaml - the reference data (tui, name, tree, def)
|
|
7
|
+
mapping/semantic_network_tree.md - the tree, annotated with where each GEM
|
|
8
|
+
dimension axis currently *derives* to
|
|
9
|
+
(the modal semantic type of its mapped
|
|
10
|
+
values), so untyped axes are visible.
|
|
11
|
+
|
|
12
|
+
Use this to decide which dimension axes have a faithful UMLS semantic type and
|
|
13
|
+
which are untyped (unmapped). Requires UMLS_API_KEY (env or .envrc).
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 mapping/fetch_semantic_network.py
|
|
17
|
+
python3 mapping/fetch_semantic_network.py --max 220 # TUI scan upper bound
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import re
|
|
23
|
+
from collections import Counter, defaultdict
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from forome.gem.umls import build_umls_crosswalk as H
|
|
28
|
+
from forome.gem.umls import adjudicate_ui as UI # reuse api_key()
|
|
29
|
+
from forome.gem.umls.uts_client import UTSClient
|
|
30
|
+
from forome.gem.umls._paths import DATA_DIR
|
|
31
|
+
|
|
32
|
+
TYPES_YAML = DATA_DIR / "semantic_types.yaml"
|
|
33
|
+
TREE_MD = DATA_DIR / "semantic_network_tree.md"
|
|
34
|
+
CROSSWALK = DATA_DIR / "umls_crosswalk.yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tree_sort_key(tn: str):
|
|
38
|
+
key = []
|
|
39
|
+
for p in (tn or "").split("."):
|
|
40
|
+
m = re.match(r"([A-Za-z]*)(\d+)", p)
|
|
41
|
+
key.append((m.group(1), int(m.group(2))) if m else ("", 0))
|
|
42
|
+
return key
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def axis_derivations() -> dict:
|
|
46
|
+
"""semantic-type name -> [dimensions whose mapped values are modally that type]."""
|
|
47
|
+
if not CROSSWALK.is_file():
|
|
48
|
+
return {}
|
|
49
|
+
doc = yaml.safe_load(CROSSWALK.read_text())
|
|
50
|
+
per = defaultdict(Counter)
|
|
51
|
+
for e in doc["entries"]:
|
|
52
|
+
if e["kind"] in ("value", "common_value") and e["status"] == "mapped":
|
|
53
|
+
for st in (e.get("semantic_types") or []):
|
|
54
|
+
per[e["dimension"]][st] += 1
|
|
55
|
+
name_to_axes = defaultdict(list)
|
|
56
|
+
for dim, cnt in per.items():
|
|
57
|
+
if cnt:
|
|
58
|
+
name_to_axes[cnt.most_common(1)[0][0]].append(dim)
|
|
59
|
+
return name_to_axes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fetch_all(client, upper: int) -> list:
|
|
63
|
+
types = []
|
|
64
|
+
for n in range(1, upper + 1):
|
|
65
|
+
st = client.get_semantic_type(f"T{n:03d}")
|
|
66
|
+
if st and st.get("tree_number"):
|
|
67
|
+
types.append(st)
|
|
68
|
+
types.sort(key=lambda s: tree_sort_key(s["tree_number"]))
|
|
69
|
+
return types
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_type(s) -> bool:
|
|
73
|
+
return (s["tree_number"] or "").startswith(("A", "B"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_tree(types: list, derivations: dict) -> str:
|
|
77
|
+
sem = [s for s in types if _is_type(s)] # semantic TYPES (A/B)
|
|
78
|
+
rel = [s for s in types if not _is_type(s)] # relations (R)
|
|
79
|
+
lines = ["# UMLS Semantic Network — semantic types tree",
|
|
80
|
+
"",
|
|
81
|
+
f"_{len(sem)} semantic types (plus {len(rel)} relations, listed at "
|
|
82
|
+
"the end). Generated by mapping/fetch_semantic_network.py from the "
|
|
83
|
+
"live UTS API._",
|
|
84
|
+
"",
|
|
85
|
+
"Dimension **axes** map to a semantic **type** (A/B branches below); "
|
|
86
|
+
"dimension **values** map to Metathesaurus concepts. GEM axes are "
|
|
87
|
+
"annotated at the type their mapped values currently *derive* to "
|
|
88
|
+
"(modal). Axes with no typed values are untyped/unmapped candidates "
|
|
89
|
+
"(e.g. `target_type`, `resolution`, `credibility`, "
|
|
90
|
+
"`variant_ascertainment`).",
|
|
91
|
+
"", "## Semantic types", "", "```"]
|
|
92
|
+
for s in sem:
|
|
93
|
+
depth = len((s["tree_number"] or "").split("."))
|
|
94
|
+
indent = " " * (depth - 1)
|
|
95
|
+
axes = derivations.get(s["name"])
|
|
96
|
+
tag = (" <== GEM axis: " + ", ".join(sorted(axes))) if axes else ""
|
|
97
|
+
lines.append(f"{indent}{s['tree_number']:<12} {s['name']} ({s['tui']}){tag}")
|
|
98
|
+
lines += ["```", "", "## Relations (for reference)", "", "```"]
|
|
99
|
+
for s in rel:
|
|
100
|
+
depth = len((s["tree_number"] or "").split("."))
|
|
101
|
+
lines.append(f"{' ' * (depth - 1)}{s['tree_number']:<10} {s['name']} ({s['tui']})")
|
|
102
|
+
lines.append("```")
|
|
103
|
+
return "\n".join(lines) + "\n"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None) -> int:
|
|
107
|
+
ap = argparse.ArgumentParser()
|
|
108
|
+
ap.add_argument("--max", type=int, default=220, help="TUI scan upper bound")
|
|
109
|
+
args = ap.parse_args(argv)
|
|
110
|
+
|
|
111
|
+
key = UI.api_key()
|
|
112
|
+
if not key:
|
|
113
|
+
print("ERROR: no UMLS_API_KEY (env or .envrc).")
|
|
114
|
+
return 1
|
|
115
|
+
client = UTSClient(key, cache_dir=H.CACHE_DIR)
|
|
116
|
+
print(f"Fetching semantic types T001..T{args.max:03d} (cached after first run)...")
|
|
117
|
+
types = fetch_all(client, args.max)
|
|
118
|
+
TYPES_YAML.write_text(yaml.safe_dump(
|
|
119
|
+
{"count": len(types), "source": "UMLS Semantic Network (UTS API)",
|
|
120
|
+
"types": types}, sort_keys=False, allow_unicode=True, width=200))
|
|
121
|
+
tree = render_tree(types, axis_derivations())
|
|
122
|
+
TREE_MD.write_text(tree)
|
|
123
|
+
print(f"Wrote {TYPES_YAML.name} ({len(types)} types) and {TREE_MD.name}.")
|
|
124
|
+
# top-level summary to stdout
|
|
125
|
+
tops = [s for s in types if len((s["tree_number"] or "").split(".")) <= 2]
|
|
126
|
+
print("\nTop-level branches:")
|
|
127
|
+
for s in tops:
|
|
128
|
+
print(f" {s['tree_number']:<8} {s['name']} ({s['tui']})")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
raise SystemExit(main())
|