BIO Tagging › BIO Utilities in Practice

Michael BrenndoerferUpdated January 3, 20264 min read
Language AI HandbookAll chapters
Part 6 of 67Sequence Labeling
Chapter 38BIO Tagging › BIO Utilities in Practice
Reading section 9 of 14 · BIO Utilities in Practice · 4 minSection 9 of 14 · 4 min

Book contents

Section 9 of 14 · 4 min read

BIO Tagging › BIO Utilities in PracticeOne section · Sections in this chapter9 / 14 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's consolidate our functions into a reusable class and demonstrate a complete end-to-end workflow integrating with spaCy's NER system:

In[38]:
Code
class BIOConverter:
    """Utility class for BIO tagging operations."""

    @staticmethod
    def spans_to_bio(tokens, spans):
        """Convert span annotations to BIO tags."""
        tags = ["O"] * len(tokens)
        for start, end, entity_type in sorted(spans, key=lambda x: x[0]):
            if 0 <= start < end <= len(tokens):
                tags[start] = f"B-{entity_type}"
                for i in range(start + 1, end):
                    tags[i] = f"I-{entity_type}"
        return tags

    @staticmethod
    def bio_to_spans(tokens, tags):
        """Extract entity spans from BIO tags with graceful error handling."""
        spans = []
        current = None

        for i, (token, tag) in enumerate(zip(tokens, tags)):
            if tag.startswith("B-"):
                if current:
                    spans.append((*current, i))
                current = (i, tag[2:])
            elif tag.startswith("I-"):
                if current is None or current[1] != tag[2:]:
                    if current:
                        spans.append((*current, i))
                    current = (i, tag[2:])
            else:
                if current:
                    spans.append((*current, i))
                    current = None

        if current:
            spans.append((*current, len(tokens)))

        return [(s, e, t, " ".join(tokens[s:e])) for s, t, e in spans]

    @staticmethod
    def is_valid(tags):
        """Return True if the BIO sequence contains no tagging errors."""
        prev = "O"
        for tag in tags:
            if tag.startswith("I-"):
                if prev == "O":
                    return False
                if prev[0] in "BI" and prev[2:] != tag[2:]:
                    return False
            prev = tag
        return True


# Full demonstration: spans -> BIO -> validate -> decode -> verify
demo_tokens = ["Apple", "CEO", "Tim", "Cook", "announced", "iPhone", "15"]
demo_spans = [(0, 1, "ORG"), (2, 4, "PER"), (5, 7, "PRODUCT")]

converter = BIOConverter()
demo_tags = converter.spans_to_bio(demo_tokens, demo_spans)
is_valid = converter.is_valid(demo_tags)
recovered_spans = converter.bio_to_spans(demo_tokens, demo_tags)
Out[39]:
Console
BIO Converter: End-to-End Demonstration
=======================================================

Input spans:
  [0:1] 'Apple' -> ORG
  [2:4] 'Tim Cook' -> PER
  [5:7] 'iPhone 15' -> PRODUCT

Generated BIO tags:
  Apple        -> B-ORG
  CEO          -> O
  Tim          -> B-PER
  Cook         -> I-PER
  announced    -> O
  iPhone       -> B-PRODUCT
  15           -> I-PRODUCT

Sequence valid: True

Recovered spans (round-trip check):
  [0:1] 'Apple' -> ORG
  [2:4] 'Tim Cook' -> PER
  [5:7] 'iPhone 15' -> PRODUCT

Round-trip fidelity: True

Integration with spaCy

Real NER systems output entity spans that we can convert to BIO format for analysis or evaluation:

In[40]:
Code
import spacy

# spacy.cli.download("en_core_web_sm")  # Run once locally, then comment out
nlp = spacy.load("en_core_web_sm")

text = "Microsoft announced that Satya Nadella will visit London next week."
doc = nlp(text)

# Extract tokens and entity spans from spaCy output
spacy_tokens = [token.text for token in doc]
spacy_spans = []

for ent in doc.ents:
    # Map character offsets to token indices
    start_tok = None
    end_tok = None
    for i, token in enumerate(doc):
        if token.idx == ent.start_char:
            start_tok = i
        if token.idx + len(token.text) == ent.end_char:
            end_tok = i + 1
    if start_tok is not None and end_tok is not None:
        spacy_spans.append((start_tok, end_tok, ent.label_))

# Convert spaCy output to BIO tags
spacy_bio_tags = BIOConverter.spans_to_bio(spacy_tokens, spacy_spans)
Out[41]:
Console
spaCy NER Output Converted to BIO:
--------------------------------------------------
Text: Microsoft announced that Satya Nadella will visit London next week.

Token          BIO Tag       
--------------------------------------------------
Microsoft      B-ORG         
announced      O             
that           O             
Satya          B-PERSON      
Nadella        I-PERSON      
will           O             
visit          O             
London         B-GPE         
next           B-DATE        
week           I-DATE        
.              O             

Entities detected by spaCy:
  'Microsoft' -> ORG
  'Satya Nadella' -> PERSON
  'London' -> GPE
  'next week' -> DATE

The BIO representation supports token-level evaluation metrics, comparison across different taggers, and training data preparation for sequence models.

Converting spaCy output to BIO format serves a broader purpose: interoperability. Different NLP tools use different internal representations for entity annotations. spaCy uses character-offset spans with Python objects. Stanford NER uses whitespace-delimited CoNLL-style BIO text files. Hugging Face models produce token-level logits that require a decoding step. A shared BIO format provides a common currency for comparing outputs, combining predictions from multiple systems, and routing data through evaluation pipelines regardless of which library produced it.