Skip to content

Terminology

Terminology resolution is licence-aware: no restricted vocabulary is bundled.

openbtk.terminology.bundled.BundledMinimalBackend

Bases: BaseTerminologyService

Offline ICD-10-CM lookup over a small, bundled, verified subset.

Example

backend = BundledMinimalBackend() backend.validate("E11.9", CodeSystem.ICD10CM) True backend.resolve("E11.9", CodeSystem.ICD10CM).display 'Type 2 diabetes mellitus without complications'

is_authoritative

is_authoritative(system: CodeSystem) -> bool

Never: this is a small subset, so a code missing from it (or from a system it does not cover at all) is merely unconfirmed, not invalid.

Source code in src/openbtk/terminology/bundled.py
102
103
104
105
def is_authoritative(self, system: CodeSystem) -> bool:  # noqa: ARG002
    """Never: this is a small subset, so a code missing from it (or from a
    system it does not cover at all) is merely unconfirmed, not invalid."""
    return False

map

map(code: str, from_system: CodeSystem, to_system: CodeSystem) -> list[Concept]

Always returns [] -- a single bundled vocabulary carries no crosswalk data between systems. A real, disclosed limitation, not a silent no-op standing in for unimplemented behaviour.

Source code in src/openbtk/terminology/bundled.py
107
108
109
110
111
112
113
114
115
116
def map(
    self,
    code: str,  # noqa: ARG002 -- BaseTerminologyService interface, unused by design
    from_system: CodeSystem,  # noqa: ARG002
    to_system: CodeSystem,  # noqa: ARG002
) -> list[Concept]:
    """Always returns ``[]`` -- a single bundled vocabulary carries no
    crosswalk data between systems. A real, disclosed limitation, not
    a silent no-op standing in for unimplemented behaviour."""
    return []

openbtk.terminology.local.LocalVocabBackend

LocalVocabBackend(*, path: str)

Bases: BaseTerminologyService

Resolve/validate codes from a user-supplied code,system,display CSV file.

Parameters:

Name Type Description Default
path str

Path to the CSV file. Read lazily on first real use (docs/09_CODING_STANDARDS.md rule 11 -- constructors do no I/O), not at construction time.

required
Example

import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as d: ... path = Path(d) / "vocab.csv" ... _ = path.write_text( ... "code,system,display\n" ... "73211009,SNOMED,Diabetes mellitus\n" ... ) ... backend = LocalVocabBackend(path=str(path)) ... concept = backend.resolve("73211009", CodeSystem.SNOMED) concept.display 'Diabetes mellitus'

Source code in src/openbtk/terminology/local.py
59
60
61
def __init__(self, *, path: str) -> None:
    self._path = path
    self._by_system: dict[CodeSystem, dict[str, str]] | None = None

is_authoritative

is_authoritative(system: CodeSystem) -> bool

True only for a system the supplied file has rows for: the file is taken as that system's full vocabulary. A system it has no rows for is simply not covered, so absence there proves nothing.

Source code in src/openbtk/terminology/local.py
109
110
111
112
113
def is_authoritative(self, system: CodeSystem) -> bool:
    """True only for a system the supplied file has rows for: the file is
    taken as that system's full vocabulary. A system it has no rows for is
    simply not covered, so absence there proves nothing."""
    return system in self._table()

map

map(code: str, from_system: CodeSystem, to_system: CodeSystem) -> list[Concept]

Always returns [] -- a flat code/display table carries no cross-system equivalence data. A real, disclosed limitation.

Source code in src/openbtk/terminology/local.py
115
116
117
118
119
120
121
122
123
def map(
    self,
    code: str,  # noqa: ARG002 -- BaseTerminologyService interface, unused by design
    from_system: CodeSystem,  # noqa: ARG002
    to_system: CodeSystem,  # noqa: ARG002
) -> list[Concept]:
    """Always returns ``[]`` -- a flat code/display table carries no
    cross-system equivalence data. A real, disclosed limitation."""
    return []

openbtk.terminology.umls.UMLSRestBackend

UMLSRestBackend(*, api_key: str, timeout: float = 10.0)

Bases: BaseTerminologyService

Resolve/validate/map codes via the real UMLS REST API.

Parameters:

Name Type Description Default
api_key str

A UMLS Terminology Services API key (requires a UMLS account and licence -- https://uts.nlm.nih.gov). Never defaulted or bundled.

required
timeout float

Per-request timeout, seconds.

10.0

No I/O happens in __init__ -- the httpx.Client is built lazily on first real call (docs/09_CODING_STANDARDS.md rule 11).

Source code in src/openbtk/terminology/umls.py
84
85
86
87
def __init__(self, *, api_key: str, timeout: float = 10.0) -> None:
    self._api_key = api_key
    self._timeout = timeout
    self._client: httpx.Client | None = None

openbtk.terminology.cache.CachedTerminologyService

CachedTerminologyService(*, backend: BaseTerminologyService, cache_dir: str, ttl_seconds: float = 86400.0)

Bases: BaseTerminologyService

Wrap backend with a TTL disk cache under cache_dir.

Parameters:

Name Type Description Default
backend BaseTerminologyService

The real terminology service to cache results from.

required
cache_dir str

Directory for cache files. Created lazily on first real write, not in __init__ (docs/09_CODING_STANDARDS.md rule 11).

required
ttl_seconds float

How long a cached entry stays valid. Default one day.

86400.0
Example

import tempfile from openbtk.terminology.bundled import BundledMinimalBackend with tempfile.TemporaryDirectory() as d: ... cached = CachedTerminologyService( ... backend=BundledMinimalBackend(), cache_dir=d, ... ) ... first = cached.resolve("E11.9", CodeSystem.ICD10CM) ... second = cached.resolve("E11.9", CodeSystem.ICD10CM) first == second True

Source code in src/openbtk/terminology/cache.py
59
60
61
62
63
64
65
66
67
68
def __init__(
    self,
    *,
    backend: BaseTerminologyService,
    cache_dir: str,
    ttl_seconds: float = 86400.0,
) -> None:
    self._backend = backend
    self._cache_dir = Path(cache_dir)
    self._ttl_seconds = ttl_seconds

Matching free-text terms

See the terminology guide for what the matcher will and will not do.

openbtk.terminology.normalize.ConceptNormalizer

ConceptNormalizer(concepts: Iterable[Concept], *, aliases: Mapping[str, Sequence[str]] | None = None, threshold: float = 0.85, ambiguity_margin: float = 0.05)

Map free-text terms to the concepts of a vocabulary you supply.

Parameters:

Name Type Description Default
concepts Iterable[Concept]

The vocabulary.

required
aliases Mapping[str, Sequence[str]] | None

Extra names per concept, keyed by code (a code in more than one system takes the aliases in each). This is how "CBC" reaches its code.

None
threshold float

The lowest score returned, in [0, 1]. 1.0 allows only exact and alias matches.

0.85
ambiguity_margin float

:meth:resolve gives no answer when the two best distinct codes are this close.

0.05
Example

concepts = [ ... Concept(code="I10", system=CodeSystem.ICD10CM, ... display="Essential (primary) hypertension"), ... Concept(code="E11.9", system=CodeSystem.ICD10CM, ... display="Type 2 diabetes mellitus without complications"), ... ] normalizer = ConceptNormalizer(concepts, aliases={"I10": ["HTN"]}) normalizer.resolve("htn").concept.code 'I10' normalizer.resolve("Type 1 diabetes mellitus") is None True

Source code in src/openbtk/terminology/normalize.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
def __init__(
    self,
    concepts: Iterable[Concept],
    *,
    aliases: Mapping[str, Sequence[str]] | None = None,
    threshold: float = 0.85,
    ambiguity_margin: float = 0.05,
) -> None:
    if not 0 <= threshold <= 1:
        raise ValueError("threshold must be between 0 and 1")
    if not 0 <= ambiguity_margin <= 1:
        raise ValueError("ambiguity_margin must be between 0 and 1")
    self._threshold = threshold
    self._margin = ambiguity_margin
    self._entries: list[_Entry] = []
    self._by_normal: dict[str, list[int]] = defaultdict(list)
    self._by_trigram: dict[str, list[int]] = defaultdict(list)
    for concept in concepts:
        self._add(concept, concept.display, is_alias=False)
        for alias in (aliases or {}).get(concept.code, ()):
            self._add(concept, alias, is_alias=True)

from_bundled_icd10cm classmethod

from_bundled_icd10cm(*, threshold: float = 0.85, ambiguity_margin: float = 0.05) -> ConceptNormalizer

A normaliser over the small ICD-10-CM subset that ships with Openbtk.

Source code in src/openbtk/terminology/normalize.py
226
227
228
229
230
231
232
233
234
235
236
237
@classmethod
def from_bundled_icd10cm(
    cls, *, threshold: float = 0.85, ambiguity_margin: float = 0.05
) -> ConceptNormalizer:
    """A normaliser over the small ICD-10-CM subset that ships with Openbtk."""
    from openbtk.terminology.bundled import bundled_icd10cm_concepts

    return cls(
        bundled_icd10cm_concepts(),
        threshold=threshold,
        ambiguity_margin=ambiguity_margin,
    )

from_csv classmethod

from_csv(path: str, *, threshold: float = 0.85, ambiguity_margin: float = 0.05) -> ConceptNormalizer

Read code,system,display rows and an optional aliases column.

Aliases are separated by |. system must be a :class:~openbtk.core.schemas.CodeSystem value. Read now, not lazily: this is an explicit load, not a constructor.

Raises:

Type Description
TerminologyError

If the file is missing, lacks a required column or has a row with an unknown system. The message names the line, never a value.

Source code in src/openbtk/terminology/normalize.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
@classmethod
def from_csv(
    cls, path: str, *, threshold: float = 0.85, ambiguity_margin: float = 0.05
) -> ConceptNormalizer:
    """Read ``code,system,display`` rows and an optional ``aliases`` column.

    Aliases are separated by ``|``. ``system`` must be a
    :class:`~openbtk.core.schemas.CodeSystem` value. Read now, not lazily: this is
    an explicit load, not a constructor.

    Raises:
        TerminologyError: If the file is missing, lacks a required column or has a
            row with an unknown system. The message names the line, never a value.
    """
    source = Path(path)
    try:
        handle = source.open(encoding="utf-8", newline="")
    except OSError as e:
        raise TerminologyError(
            f"Could not open the vocabulary file {source.name}.",
            context={"path": str(source)},
        ) from e
    concepts: list[Concept] = []
    aliases: dict[str, list[str]] = {}
    with handle:
        reader = csv.DictReader(handle)
        missing = {"code", "system", "display"} - set(reader.fieldnames or ())
        if missing:
            raise TerminologyError(
                f"{source.name} lacks required column(s) {sorted(missing)}.",
                context={"path": str(source)},
            )
        for number, row in enumerate(reader, start=2):
            try:
                system = CodeSystem(row["system"].strip())
                concept = Concept(
                    code=row["code"].strip(),
                    system=system,
                    display=row["display"].strip(),
                )
            except ValueError as e:
                raise TerminologyError(
                    f"{source.name} line {number} is not a valid concept.",
                    context={"path": str(source), "line": number},
                ) from e
            concepts.append(concept)
            extra = [a.strip() for a in (row.get("aliases") or "").split("|")]
            aliases.setdefault(concept.code, []).extend(a for a in extra if a)
    return cls(
        concepts,
        aliases=aliases,
        threshold=threshold,
        ambiguity_margin=ambiguity_margin,
    )

normalise

normalise(text: str, *, limit: int = 3, system: CodeSystem | None = None) -> list[ConceptMatch]

The best-scoring concepts for text, best first, at or above threshold.

One match per code (its best-scoring name). Ties break on code, so the order is deterministic. Returns [] for an empty term, one over 256 characters (a sentence, not a term), or no candidate above the threshold.

Parameters:

Name Type Description Default
text str

The term.

required
limit int

The most matches to return.

3
system CodeSystem | None

Only consider concepts of this code system.

None
Source code in src/openbtk/terminology/normalize.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def normalise(
    self, text: str, *, limit: int = 3, system: CodeSystem | None = None
) -> list[ConceptMatch]:
    """The best-scoring concepts for ``text``, best first, at or above threshold.

    One match per code (its best-scoring name). Ties break on code, so the order is
    deterministic. Returns ``[]`` for an empty term, one over
    256 characters (a sentence, not a term), or no candidate above the threshold.

    Args:
        text: The term.
        limit: The most matches to return.
        system: Only consider concepts of this code system.
    """
    if len(text) > _MAX_QUERY_CHARS or limit < 1:
        return []
    query = _normal(text)
    if not query:
        return []
    best: dict[tuple[str, CodeSystem], ConceptMatch] = {}
    for index in self._candidates(query):
        entry = self._entries[index]
        if system is not None and entry.concept.system is not system:
            continue
        score = self._score(query, entry)
        if score < self._threshold:
            continue
        key = (entry.concept.code, entry.concept.system)
        found = best.get(key)
        if found is None or score > found.score:
            method: Literal["exact", "alias", "fuzzy"] = (
                "fuzzy" if score < 1.0 else ("alias" if entry.is_alias else "exact")
            )
            shown = (
                round(score, 4)
                if method != "fuzzy"
                else min(round(score, 4), 0.9999)
            )
            best[key] = ConceptMatch(
                concept=entry.concept,
                score=shown,
                matched_text=entry.text,
                method=method,
            )
    ranked = sorted(
        best.values(),
        key=lambda m: (-m.score, m.concept.code, m.concept.system.value),
    )
    return ranked[:limit]

resolve

resolve(text: str, *, system: CodeSystem | None = None) -> ConceptMatch | None

The single best match, or None when there is none or it is not clear.

None means: nothing reached the threshold, or the two best answers are different concepts scoring within ambiguity_margin of each other. In that case call :meth:normalise and decide with a person or more context.

Source code in src/openbtk/terminology/normalize.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def resolve(
    self, text: str, *, system: CodeSystem | None = None
) -> ConceptMatch | None:
    """The single best match, or ``None`` when there is none or it is not clear.

    ``None`` means: nothing reached the threshold, or the two best answers are
    different concepts scoring within ``ambiguity_margin`` of each other. In that
    case call :meth:`normalise` and decide with a person or more context.
    """
    matches = self.normalise(text, limit=2, system=system)
    if not matches:
        return None
    if len(matches) == 2 and matches[0].score - matches[1].score <= self._margin:
        return None
    return matches[0]

openbtk.terminology.normalize.ConceptMatch

Bases: BaseModel

A candidate concept for a term, with how it matched.

method is exact (the term is the concept's display), alias (it is one of the aliases you listed) or fuzzy (a close, but not identical, string).