Skip to content

Span offset projection

Translation augmentation moves entity spans from a source text to a translated text. The offset projection helpers in openmed.training.synthetic do the bookkeeping: they normalize span records, check that offsets still point at the right characters, and re-locate spans in translated text. If a span cannot be recovered, they raise rather than guess, so the caller can drop the example instead of emitting a noisy label. See the overview for shared conventions and translation augmentation for the pipeline that uses them.

Normalize spans

normalize_span_annotations accepts a record (a mapping with a span list under gold_spans, labels, entities, or spans) or a plain iterable of span mappings, and returns SpanAnnotation rows (start, end, label, text, metadata):

from openmed.training.synthetic import normalize_span_annotations

record = {
    "text": "Patient reports fever and cough.",
    "labels": [
        {"start": 16, "end": 21, "label": "SYMPTOM"},
        {"start": 26, "end": 31, "label": "SYMPTOM"},
    ],
}
spans = normalize_span_annotations(record)
print([(s.label, s.text) for s in spans])
# [('SYMPTOM', 'fever'), ('SYMPTOM', 'cough')]

A missing text on a span is filled from the record's text; an unusable start or end, or a missing label, raises SpanProjectionError.

Validate offsets

from openmed.training.synthetic import (
    SpanAnnotation,
    SpanProjectionError,
    span_corruption_count,
    validate_span_integrity,
)

validate_span_integrity(record["text"], spans)           # passes silently
bad = [SpanAnnotation(start=0, end=3, label="X", text="Zzz")]
print(span_corruption_count("Patient", bad))            # 1
try:
    validate_span_integrity("Patient", bad)
except SpanProjectionError as error:
    print(error)                                         # span text mismatch for X: 0:3

validate_span_integrity raises for out-of-range, empty, mismatched, or (by default) overlapping spans; pass allow_overlap=True to permit overlaps. span_corruption_count is the non-raising variant: it returns how many spans no longer match their text, which is useful as a dataset quality metric.

Realign spans in translated text

realign_translated_spans translates each source span on its own with the supplied translator, then finds the result in the already translated text, left to right, and only on token boundaries:

from openmed.training.synthetic import realign_translated_spans


class LexiconTranslator:
    def translate(self, text, source_lang, target_lang):
        return {"fever": "Fieber", "cough": "Husten"}.get(text, text)


projected = realign_translated_spans(
    source_text=record["text"],
    translated_text="Patient berichtet Fieber und Husten.",
    source_spans=spans,
    translator=LexiconTranslator(),
    source_language="en",
    target_language="de",
)
print([(s.start, s.end, s.text) for s in projected])
# [(18, 24, 'Fieber'), (29, 35, 'Husten')]
print(dict(projected[0].metadata))
# {'projected_from_end': 21, 'projected_from_start': 16}

Any translator with a translate(text, source_lang, target_lang) method works (the SpanTranslator protocol). If a span cannot be found, the call raises SpanProjectionError:

try:
    realign_translated_spans(
        source_text=record["text"],
        translated_text="Patient berichtet nichts.",
        source_spans=spans,
        translator=LexiconTranslator(),
        source_language="en",
        target_language="de",
    )
except SpanProjectionError as error:
    print(error)    # could not realign SYMPTOM span 16:21

Known limits

  • Matching is by exact translated string with word-boundary checks. A translator that inflects a span differently when translated alone than in context (common in morphologically rich languages) makes the span unrecoverable, and the example is dropped.
  • Spans are located in order; reordering by the translator can cause a miss.
  • Offsets are Python string indices (code points), not bytes or grapheme clusters.
  • These helpers check offsets, not translation quality or label correctness.