BIO Tagging for Named Entity Recognition

Michael BrenndoerferUpdated January 3, 202655 min read

Part of Language AI Handbook

Covers the BIO tagging scheme for named entity recognition, including BIOES variants, span-to-tag conversion, decoding.

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

BIO Tagging

Sequence labeling tasks like named entity recognition face a fundamental challenge: how do you represent multi-word entities using per-token labels? The sentence "New York City is beautiful" contains three tokens that together form a single location entity. Assigning all three the label "LOC" creates ambiguity. Does "New" start a new entity, or does it continue one that began earlier? Are "New," "York," and "City" three separate locations, or one?

BIO tagging solves this problem elegantly. The scheme uses a small set of prefixes to encode entity boundaries directly in the labels. B marks the beginning of an entity, I marks inside (continuation), and O marks outside (no entity). With BIO tags, "New York City" becomes B-LOC I-LOC I-LOC, unambiguously marking a single three-token entity.

The idea is deceptively simple. Three letters carry enough information to reconstruct any flat entity annotation from scratch. Any system reading the tag sequence can tell exactly where each entity starts, where it continues, and where the text returns to non-entity territory, without any additional context or lookup table. That parsimony, combined with perfect expressiveness for non-overlapping spans, explains why BIO tagging became the standard format for nearly every NER dataset and evaluation benchmark produced in the past two decades.

This chapter explores BIO tagging from its basic mechanics through practical implementation. You will learn the standard BIO scheme and its variants, implement converters between span annotations and BIO tags, build decoders that extract entities from tagged sequences, and handle the edge cases that arise in real-world tagging scenarios. Building on the entity types from the Named Entity Recognition chapter, here we focus on the encoding mechanism that makes those entity types usable in sequence models. The concepts here apply directly to the Hidden Markov Model and Conditional Random Field chapters, where you will see how these tag sequences get used as training targets and how transition constraints between tags can be learned from data.

The BIO Scheme

BIO tagging encodes entity boundaries through prefix annotations. Each token receives a label combining a position indicator (B, I, or O) with an entity type. The three positions work together to delimit entity spans without ambiguity.

BIO Tagging Scheme

BIO (Beginning-Inside-Outside) is a tagging scheme for sequence labeling where each token receives a label indicating its position relative to entity spans. B marks the first token of an entity, I marks subsequent tokens within the same entity, and O marks tokens outside any entity.

The design is minimal but complete. You need exactly three pieces of information to reconstruct any flat (non-overlapping) entity annotation: where does the entity start, where does it continue, and where is there no entity. B, I, and O capture precisely these three cases. Any more prefixes would be redundant; any fewer would lose boundary information.

The scheme originated in work on text chunking and part-of-speech tagging before NER, where researchers needed a way to identify multi-word phrases using per-word labels. When NER became a major shared task at conferences like CoNLL in the early 2000s, BIO tagging was adopted as the standard label format because annotators, training pipelines, and evaluation scripts could all work with the same consistent representation. The CoNLL-2003 dataset, which remains a standard English NER benchmark, uses BIO tags over four entity types (PER, LOC, ORG, MISC) and set the template that most NER datasets have followed since.

Let's see how BIO tagging works on a concrete example:

In[3]:
Code
# A sentence with named entities
sentence = ["Barack", "Obama", "visited", "New", "York", "City", "yesterday"]

# Entity spans as (start_idx, end_idx, entity_type)
# Note: end_idx is exclusive (Python convention)
entities = [
    (0, 2, "PER"),  # Barack Obama
    (3, 6, "LOC"),  # New York City
]

# BIO tags for each token
bio_tags = ["B-PER", "I-PER", "O", "B-LOC", "I-LOC", "I-LOC", "O"]

meanings = {
    "B-PER": "Begin person entity",
    "I-PER": "Inside person entity",
    "B-LOC": "Begin location entity",
    "I-LOC": "Inside location entity",
    "O": "Outside any entity",
}
Out[4]:
Console
Token-level BIO Annotation:
----------------------------------------
Token        BIO Tag    Meaning
----------------------------------------
Barack       B-PER      Begin person entity
Obama        I-PER      Inside person entity
visited      O          Outside any entity
New          B-LOC      Begin location entity
York         I-LOC      Inside location entity
City         I-LOC      Inside location entity
yesterday    O          Outside any entity

The output shows how each token receives exactly one label. "Barack" gets B-PER because it is the first token of the person entity. "Obama" gets I-PER because it continues the same entity. "visited" gets O because it is not part of any entity. "New" starts a fresh entity with B-LOC, and "York" and "City" continue it with I-LOC.

Out[5]:
Visualization
Horizontal sequence of token boxes and colored BIO tag boxes with entity span brackets below.
BIO tagging applied to a seven-token sentence containing two named entities. Token boxes (top row) appear above their BIO tag labels (bottom row, colored). Blue boxes mark person entity tokens (Barack Obama), green boxes mark location entity tokens (New York City), and gray boxes mark non-entity tokens. Brackets below show the reconstructed entity spans.

The BIO scheme achieves two critical goals. First, it marks entity boundaries explicitly. When you see a B tag, you know a new entity starts at that position. When you see an I tag following a B tag of the same type, you know the entity continues. Second, it handles adjacent entities correctly. If "Barack Obama" and "Michelle Obama" appeared consecutively without a gap, the B prefix on "Michelle" would clearly mark the second entity's start, producing B-PER I-PER B-PER I-PER.

It is worth pausing to appreciate why this property matters so much in practice. Annotators frequently tag lists of proper nouns: "John Smith, Jane Doe, and Robert Johnson attended the summit." Without the B marker, a reading of consecutive PER tokens would be ambiguous about how many people are named. With BIO, each B-PER resets the entity counter, so even a list of ten names is perfectly unambiguous. The same logic applies to location lists in news text ("Paris, Berlin, and London"), organization lists in financial documents, and product names in e-commerce text.

Why Not Just Use Entity Types?

A simpler approach might label each token with just its entity type: PER, LOC, or O. Let's see why this fails.

In[6]:
Code
# Two consecutive person entities
sentence_adjacent = ["Barack", "Obama", "Michelle", "Obama", "attended"]

# With simple entity-type labels (no B/I distinction)
simple_labels = ["PER", "PER", "PER", "PER", "O"]

# With BIO labels
bio_labels = ["B-PER", "I-PER", "B-PER", "I-PER", "O"]
Out[7]:
Console
Adjacent Entities Problem:
-------------------------------------------------------
Token        Simple     BIO       
-------------------------------------------------------
Barack       PER        B-PER     
Obama        PER        I-PER     
Michelle     PER        B-PER     
Obama        PER        I-PER     
attended     O          O         

Simple labels: 1 entity 'Barack Obama Michelle Obama'
BIO labels:    2 entities 'Barack Obama' and 'Michelle Obama'

Without the B prefix, there is no way to determine where one entity ends and another begins. The simple scheme makes adjacent same-type entities indistinguishable from single multi-token entities. Real text contains many such cases: lists of names, multiple locations in a sentence, consecutive organization mentions. BIO tagging handles all of them correctly.

This failure mode is not theoretical. Consider sports reporting: "Federer Djokovic Nadal will all compete." Or financial news: "Microsoft Google Apple reported earnings." Labeling these with simple type tags produces one enormous entity rather than three distinct ones. Any downstream application that counts entities, links them to databases, or resolves coreferences would produce completely wrong results from the simpler scheme.

The deeper reason BIO works is that it encodes both the type of each token and its structural role within an entity. The structural information, whether a token is the start, the interior, or entirely outside, is independent of the type information and adds a structural dimension that flat type labels cannot provide.

The O Tag

The O tag marks tokens that don't belong to any entity. It carries no suffix because "outside" is the only interpretation, there is no entity type to specify. In typical NER datasets, O tokens vastly outnumber entity tokens since most words in a sentence are not named entities:

In[8]:
Code
from collections import Counter

example_text = [
    "The",
    "president",
    "of",
    "the",
    "United",
    "States",
    "met",
    "with",
    "Angela",
    "Merkel",
    "in",
    "Berlin",
    ".",
]

example_bio = [
    "O",
    "O",
    "O",
    "O",
    "B-LOC",
    "I-LOC",
    "O",
    "O",
    "B-PER",
    "I-PER",
    "O",
    "B-LOC",
    "O",
]

tag_counts = Counter(example_bio)
total = len(example_bio)
Out[9]:
Console
Tag Distribution in Sample Sentence:
----------------------------------------
O       :  8 tokens ( 61.5%)
B-LOC   :  2 tokens ( 15.4%)
I-LOC   :  1 tokens (  7.7%)
B-PER   :  1 tokens (  7.7%)
I-PER   :  1 tokens (  7.7%)

Entity tokens: 5/13 (38.5%)
Outside tokens: 8/13 (61.5%)
Out[10]:
Visualization
Bar chart showing tag frequency counts with O tag having the highest count and entity tags having 1-2 tokens each.
Tag frequency distribution for a sample sentence, illustrating the class imbalance typical in NER datasets. The O (outside) tag accounts for the majority of tokens, while B and I entity tags are relatively rare. This imbalance challenges training algorithms and motivates strategies like weighted loss functions or focal loss.

This class imbalance, where O tokens dominate, is characteristic of sequence labeling tasks. Training algorithms must account for it, often through weighted loss functions or sampling strategies. A baseline model that always predicts O achieves deceptively high accuracy but zero utility, since it never identifies any entity.

The imbalance is especially severe for fine-grained entity types. In a dataset with 18 entity types, each individual type might account for only one or two percent of all tokens. If you train a model that treats every token prediction independently with equal loss weights, the model learns that the safest policy is to predict O for everything, since the loss from missing an entity is small compared to the aggregate loss from misclassifying the overwhelming majority of non-entity tokens. Weighted cross-entropy, focal loss, and oversampling entity-bearing sentences all address this, but the fundamental imbalance is a structural property of language: most words in any document are connective tissue rather than named referents.

Extended Tagging Schemes

The basic BIO scheme is sufficient for many applications, but more complex annotation scenarios have motivated several extensions. These variants add prefixes to capture additional boundary information or handle special cases.

The four main schemes range from the simplest possible (IO) to the most expressive (BIOES):

Comparison of sequence labeling tagging schemes.
SchemeTagsKey Property
IOI, OSimplest but cannot distinguish adjacent same-type entities
BIOB, I, OStandard, handles all flat entity annotations
BIOESB, I, O, E, SExplicit end and single-token markers
BMEWOB, M, E, W, OEquivalent to BIOES with different naming

The IO scheme is sometimes called the "naïve" scheme. It assigns I to every token inside any entity and O to everything else. You might wonder why this was ever used: as we saw, it cannot handle adjacent entities of the same type. Its only advantage is simplicity, having two labels instead of three or more. For tasks where the training data happens to have no adjacent same-type entities, IO performs identically to BIO. But this is a fragile assumption, and BIO's overhead of one additional label is so small that there is rarely a reason to choose IO.

At the other extreme, BIOES and its synonymous BILOU form offer richer supervision. The rationale is that teaching the model to recognize both starts and ends of entities, rather than just starts, helps it learn more precise boundary detection. Experiments on standard NER benchmarks have shown that models trained with BIOES sometimes outperform BIO-trained models by a small margin, particularly for longer entities and in settings where exact boundary matching is critical. The improvement is not universal, and the increased label vocabulary means more parameters in the output layer and more possible confusion between label types.

The BMEWO scheme renames the tags: B (beginning), M (middle, analogous to I for non-first non-last), E (end), W (whole, analogous to S for single-token), and O (outside). The semantics are identical to BIOES, and the choice between them is purely a matter of convention in different research communities or software libraries.

BIOES and BILOU

The BIOES scheme adds two more prefixes: E for the end of a multi-token entity and S for single-token entities. Some practitioners call this BILOU, using L (last) instead of E and U (unit) instead of S. The semantics are identical, only the names differ.

In[11]:
Code
# BIOES tag meanings
bioes_tags = {
    "B": "Beginning of multi-token entity",
    "I": "Inside multi-token entity (not first, not last)",
    "O": "Outside any entity",
    "E": "End (last token) of multi-token entity",
    "S": "Single-token entity (complete span)",
}

# Examples showing how BIOES differs from BIO
examples = [
    (
        ["New", "York", "is", "great"],
        ["B-LOC", "E-LOC", "O", "O"],
        "Two-token entity",
    ),
    (["Paris", "is", "beautiful"], ["S-LOC", "O", "O"], "Single-token entity"),
    (
        ["The", "United", "States", "of", "America"],
        ["O", "B-LOC", "I-LOC", "I-LOC", "E-LOC"],
        "Multi-token entity",
    ),
]
Out[12]:
Console
BIOES Tag Meanings:
--------------------------------------------------
  B: Beginning of multi-token entity
  I: Inside multi-token entity (not first, not last)
  O: Outside any entity
  E: End (last token) of multi-token entity
  S: Single-token entity (complete span)

BIOES Examples:
--------------------------------------------------

Two-token entity:
  New            -> B-LOC
  York           -> E-LOC
  is             -> O
  great          -> O

Single-token entity:
  Paris          -> S-LOC
  is             -> O
  beautiful      -> O

Multi-token entity:
  The            -> O
  United         -> B-LOC
  States         -> I-LOC
  of             -> I-LOC
  America        -> E-LOC

Why add more tags? BIOES provides two benefits. First, the model learns to recognize entity endpoints explicitly rather than inferring them from tag transitions. Research has shown modest accuracy improvements from BIOES over BIO, particularly for longer entities where boundary precision matters. Second, BIOES makes certain decoding errors impossible: a valid BIOES sequence must have every B eventually paired with an E, and S must stand alone without following B or I tags. These structural constraints can be enforced during decoding.

The S prefix is especially useful for single-token entities, which are common in NER. Names like "Paris" or "IBM" are frequent entities that don't need any B/I distinction. With BIO, a single-token entity uses B, which might be confusing since there is no continuation. With BIOES, the S prefix explicitly signals "this is a complete entity in itself."

There is also a subtle training advantage to the S prefix. When a model predicts S-LOC for "Paris," it is making a confident assertion about the entity's complete extent in a single decision. With BIO, the model predicts B-LOC for "Paris" and then has to predict O for the next token to implicitly close the entity. The BIO model therefore needs to learn a two-step decision for single-token entities, while BIOES encodes the same information in one step. In practice, the difference is small but consistent: BIOES models tend to have slightly better precision on single-token entities.

Valid Tag Transitions

Understanding which tag sequences are valid helps when designing decoders or training models with constraints. Not all tag combinations make sense. An I-PER cannot follow a B-LOC, and an I tag cannot appear after O without a preceding B tag.

In[13]:
Code
import numpy as np

# Tags we will track (limited to 2 entity types for clarity)
tags_for_transition = ["O", "B-PER", "I-PER", "B-LOC", "I-LOC"]
n = len(tags_for_transition)

# Build transition validity matrix
# transitions[i, j] = 1 if tag[i] can be followed by tag[j]
transitions = np.zeros((n, n))

# O can be followed by: O, B-PER, B-LOC (not I tags)
transitions[0, :] = [1, 1, 0, 1, 0]
# B-PER can be followed by: O, B-PER, I-PER (same type), B-LOC
transitions[1, :] = [1, 1, 1, 1, 0]
# I-PER can be followed by: O, B-PER, I-PER (same type), B-LOC
transitions[2, :] = [1, 1, 1, 1, 0]
# B-LOC can be followed by: O, B-PER, B-LOC, I-LOC (same type)
transitions[3, :] = [1, 1, 0, 1, 1]
# I-LOC can be followed by: O, B-PER, B-LOC, I-LOC (same type)
transitions[4, :] = [1, 1, 0, 1, 1]
Out[14]:
Visualization
Heatmap of valid and invalid BIO tag transitions with green for valid and red for invalid cells.
Valid BIO tag transitions for two entity types (PER and LOC). Green cells show allowed transitions (for example, B-PER can be followed by I-PER or O), while red cells show forbidden transitions (for example, B-PER cannot be followed by I-LOC). The asymmetry between types is the core constraint: an I tag can only follow a B or I tag of the same entity type.

The key constraint is that I tags must match the type of their preceding B or I tag. An I-PER can follow B-PER or I-PER, but not B-LOC or I-LOC. This constraint can be enforced during inference using constrained beam search or CRF layers, improving the coherence of predicted tag sequences. We will explore CRF layers, which explicitly model these transitions as learned parameters, in the Conditional Random Fields chapter.

The transition constraint also explains why CRF decoders are particularly natural for BIO-tagged sequences. A neural model that predicts tag logits independently for each position might assign high probability to I-PER at position 5 even though the previous tag was B-LOC. The model's local context window might not catch this inconsistency. A CRF layer added on top of the neural logits explicitly scores entire tag sequences, penalizing forbidden transitions like I-PER after B-LOC. The Viterbi algorithm then finds the highest-scoring valid sequence. This global decoding step, which considers the entire tag sequence simultaneously, is precisely what makes CRF-augmented NER models so effective at maintaining entity boundary consistency.

The transition matrix also reveals a useful asymmetry: B tags and O have more liberal "next" options than I tags. You can transition from O to any B tag or to another O, but never to an I tag. B tags can transition to O, another B tag, or the same-type I tag. This asymmetry reflects the linguistic reality that entities can start anywhere but continuations are tightly constrained by what came before.

Converting Spans to BIO Tags

Annotation tools often store entity information as character or token spans rather than per-token labels. Converting these span annotations to BIO format is a common preprocessing step. Let.s build a converter that handles common edge cases.

The conversion logic is straightforward: initialize all tokens as O, then for each span, mark the first token with B and all subsequent tokens with I.

In[15]:
Code
def spans_to_bio(tokens, spans):
    """
    Convert span annotations to BIO tags.

    Args:
        tokens: List of tokens
        spans: List of (start_idx, end_idx, entity_type) tuples.
               Indices refer to token positions. end_idx is exclusive.

    Returns:
        List of BIO tags, one per token.
    """
    # Initialize all tokens as Outside
    tags = ["O"] * len(tokens)

    # Sort by start position to handle overlaps deterministically
    sorted_spans = sorted(spans, key=lambda x: x[0])

    for start, end, entity_type in sorted_spans:
        # Skip invalid spans
        if start < 0 or end > len(tokens) or start >= end:
            continue

        # First token gets B prefix
        tags[start] = f"B-{entity_type}"

        # Remaining tokens get I prefix
        for i in range(start + 1, end):
            tags[i] = f"I-{entity_type}"

    return tags


# Test the converter
test_tokens = ["John", "Smith", "works", "at", "Google", "Inc", "."]
test_spans = [
    (0, 2, "PER"),  # John Smith
    (4, 6, "ORG"),  # Google Inc
]

bio_result = spans_to_bio(test_tokens, test_spans)
Out[16]:
Console
Span to BIO Conversion:
---------------------------------------------
Token      BIO Tag   
---------------------------------------------
John       B-PER     
Smith      I-PER     
works      O         
at         O         
Google     B-ORG     
Inc        I-ORG     
.          O         

Input spans:
  [0:2] 'John Smith' -> PER
  [4:6] 'Google Inc' -> ORG

The converter handles the common case cleanly. But real-world data presents edge cases: single-token entities, spans at sentence boundaries, and adjacent same-type entities.

In practice, reliability here requires handling inconsistent inputs. Annotation pipelines receive data from many sources: manual annotators using web-based tools, automatic pre-annotation systems that flag candidate spans, and programmatic extraction from databases or knowledge graphs. Each source may represent spans slightly differently, including off-by-one errors in indices, character offsets rather than token offsets, or overlapping spans when two annotators tagged the same region. The converter must either handle or explicitly reject these inputs, and the choice between handling and rejecting depends on whether downstream consumers can tolerate recovered approximations.

In[17]:
Code
# Edge case tests
edge_cases = [
    {
        "name": "Single-token entity",
        "tokens": ["Paris", "is", "lovely"],
        "spans": [(0, 1, "LOC")],
    },
    {
        "name": "Entity at sentence end",
        "tokens": ["Visit", "New", "York"],
        "spans": [(1, 3, "LOC")],
    },
    {
        "name": "Adjacent same-type entities",
        "tokens": ["Obama", "Biden", "met"],
        "spans": [(0, 1, "PER"), (1, 2, "PER")],
    },
    {
        "name": "All tokens form one entity",
        "tokens": ["Barack", "Obama"],
        "spans": [(0, 2, "PER")],
    },
]

edge_results = []
for case in edge_cases:
    tags = spans_to_bio(case["tokens"], case["spans"])
    edge_results.append(
        {
            "name": case["name"],
            "tokens": case["tokens"],
            "tags": tags,
        }
    )
Out[18]:
Console
Edge Case Handling:
=======================================================

Single-token entity:
  Paris          -> B-LOC
  is             -> O
  lovely         -> O

Entity at sentence end:
  Visit          -> O
  New            -> B-LOC
  York           -> I-LOC

Adjacent same-type entities:
  Obama          -> B-PER
  Biden          -> B-PER
  met            -> O

All tokens form one entity:
  Barack         -> B-PER
  Obama          -> I-PER

Single-token entities receive only a B tag since there is no continuation token. Adjacent same-type entities each start with B, correctly distinguishing them. Entity spans at sequence boundaries are handled without special-casing since the index bounds check catches out-of-range spans.

The adjacent same-type case is particularly important to verify. The converter sorts spans by start position and processes them in order, so when "Obama" (span 0-1) and "Biden" (span 1-2) are both PER, the first loop iteration sets tags[0] = "B-PER", and the second sets tags[1] = "B-PER". The result is two separate single-token entities rather than a merged two-token entity, which is exactly correct. This deterministic behavior relies on the span representation distinguishing between (0, 1) and (1, 2), which is why exclusive end indices are the right convention.

BIOES Conversion

For applications requiring BIOES format, we extend the converter to track entity boundaries and assign the appropriate suffix:

In[19]:
Code
def spans_to_bioes(tokens, spans):
    """
    Convert span annotations to BIOES tags.

    Single-token entities get S tag.
    Multi-token entities get B...I...E pattern.
    """
    tags = ["O"] * len(tokens)
    sorted_spans = sorted(spans, key=lambda x: x[0])

    for start, end, entity_type in sorted_spans:
        if start < 0 or end > len(tokens) or start >= end:
            continue

        span_length = end - start

        if span_length == 1:
            # Single-token entity uses S prefix
            tags[start] = f"S-{entity_type}"
        else:
            # Multi-token entity: B at start, I in middle, E at end
            tags[start] = f"B-{entity_type}"
            for i in range(start + 1, end - 1):
                tags[i] = f"I-{entity_type}"
            tags[end - 1] = f"E-{entity_type}"

    return tags


# Compare BIO vs BIOES on the same span
comparison_tokens = ["The", "New", "York", "Times", "reported"]
comparison_spans = [(1, 4, "ORG")]  # New York Times

bio_output = spans_to_bio(comparison_tokens, comparison_spans)
bioes_output = spans_to_bioes(comparison_tokens, comparison_spans)
Out[20]:
Console
BIO vs BIOES Comparison:
--------------------------------------------------
Token        BIO          BIOES       
--------------------------------------------------
The          O            O           
New          B-ORG        B-ORG       
York         I-ORG        I-ORG       
Times        I-ORG        E-ORG       
reported     O            O

The BIOES output makes the entity endpoint explicit: "Times" receives E-ORG rather than I-ORG, marking it as the final token in the span. This is the key difference: with BIO, you can only determine that an entity has ended after seeing a B or O tag at the next position; with BIOES, the E tag tells you immediately.

This "look-ahead" property of BIO is a subtle but real limitation. During sequential decoding, a BIO decoder reading token by token does not know whether the current I tag is the last one in the entity until it reads the next token. This means the decoder cannot output complete entity spans until it sees what comes after. BIOES eliminates this look-ahead dependency: an E tag immediately signals "the entity I've been tracking ends here." For streaming applications or interactive systems that display entity annotations as they process each token, BIOES provides a cleaner interface.

Decoding BIO Tags to Spans

The inverse operation extracts entity spans from a sequence of BIO tags. This is essential for evaluating model predictions and converting output to a usable format. The decoder maintains state across tokens, tracking whether we are inside an entity and of what type.

In[21]:
Code
def bio_to_spans(tokens, tags):
    """
    Extract entity spans from BIO-tagged sequence.

    Handles malformed sequences gracefully:
    - Orphan I tags treated as beginning a new entity
    - Type mismatches close old entity and start new one

    Returns list of (start_idx, end_idx, entity_type, text) tuples.
    """
    spans = []
    current_entity = None  # Tracks (start_idx, entity_type)

    for i, (token, tag) in enumerate(zip(tokens, tags)):
        if tag.startswith("B-"):
            # Close any open entity, then start a new one
            if current_entity is not None:
                start, etype = current_entity
                spans.append((start, i, etype))

            entity_type = tag[2:]
            current_entity = (i, entity_type)

        elif tag.startswith("I-"):
            entity_type = tag[2:]

            if current_entity is None:
                # Orphan I: treat as a beginning
                current_entity = (i, entity_type)
            elif current_entity[1] != entity_type:
                # Type mismatch: close old entity, start new one
                start, etype = current_entity
                spans.append((start, i, etype))
                current_entity = (i, entity_type)
            # Otherwise: continue current entity (do nothing)

        else:  # O tag
            if current_entity is not None:
                start, etype = current_entity
                spans.append((start, i, etype))
                current_entity = None

    # Handle entity extending to end of sequence
    if current_entity is not None:
        start, etype = current_entity
        spans.append((start, len(tokens), etype))

    # Attach text for each span
    return [(s, e, t, " ".join(tokens[s:e])) for s, e, t in spans]


# Test decoding
test_decode_tokens = ["Barack", "Obama", "visited", "New", "York", "City"]
test_decode_tags = ["B-PER", "I-PER", "O", "B-LOC", "I-LOC", "I-LOC"]

decoded_spans = bio_to_spans(test_decode_tokens, test_decode_tags)
Out[22]:
Console
BIO to Span Decoding:
--------------------------------------------------
Input tokens: Barack Obama visited New York City
Input tags:   B-PER I-PER O B-LOC I-LOC I-LOC

Extracted entities:
  [0:2] 'Barack Obama' -> PER
  [3:6] 'New York City' -> LOC

The decoder correctly reconstructs both entities. Notice how the state machine approach handles the end of the sequence: if we reach the final token while inside an entity, the entity is closed at position len(tokens).

The state machine framing is worth dwelling on. At any position in the sequence, the decoder is in one of two states: either inside an entity of a specific type, or outside all entities. The current tag determines what transition to make. An O tag always moves to the "outside" state. A B tag always starts a new entity, possibly closing the current one first. An I tag either extends the current entity (if the types match) or handles the error case. This is a classic pattern in sequence processing: keeping just enough state to make local decisions while deferring final output until you know an entity is complete.

The design choice to return the entity text directly, computed by joining the token slice, is a practical convenience. In a real pipeline, you would want to return both the span indices (for indexing into the original text) and the reconstructed string (for display or lookup). The joined string is correct for whitespace-tokenized text but may not reconstruct the original surface form perfectly for tokenizers that handle punctuation differently.

Handling Malformed Sequences

Model predictions don't always produce valid BIO sequences. Common errors include I tags without a preceding B tag and type mismatches where I-LOC follows B-PER. A defensive decoder must handle these gracefully:

In[23]:
Code
# Three common malformed sequence patterns
malformed_cases = [
    {
        "name": "Orphan I tag (no preceding B)",
        "tokens": ["went", "to", "York", "City"],
        "tags": ["O", "O", "I-LOC", "I-LOC"],
    },
    {
        "name": "Type mismatch in continuation",
        "tokens": ["John", "Smith", "Jr"],
        "tags": ["B-PER", "I-PER", "I-ORG"],  # I-ORG after PER entity
    },
    {
        "name": "Consecutive B tags (no I between them)",
        "tokens": ["Paris", "London", "Berlin"],
        "tags": ["B-LOC", "B-LOC", "B-LOC"],
    },
]

malformed_results = []
for case in malformed_cases:
    spans = bio_to_spans(case["tokens"], case["tags"])
    malformed_results.append(
        {
            "name": case["name"],
            "tokens": case["tokens"],
            "tags": case["tags"],
            "spans": spans,
        }
    )
Out[24]:
Console
Handling Malformed Sequences:
============================================================

Orphan I tag (no preceding B):
  Tokens: ['went', 'to', 'York', 'City']
  Tags:   ['O', 'O', 'I-LOC', 'I-LOC']
  Decoded entities:
    [2:4] 'York City' -> LOC

Type mismatch in continuation:
  Tokens: ['John', 'Smith', 'Jr']
  Tags:   ['B-PER', 'I-PER', 'I-ORG']
  Decoded entities:
    [0:2] 'John Smith' -> PER
    [2:3] 'Jr' -> ORG

Consecutive B tags (no I between them):
  Tokens: ['Paris', 'London', 'Berlin']
  Tags:   ['B-LOC', 'B-LOC', 'B-LOC']
  Decoded entities:
    [0:1] 'Paris' -> LOC
    [1:2] 'London' -> LOC
    [2:3] 'Berlin' -> LOC

The decoder applies sensible recovery strategies. Orphan I tags are treated as beginning a new entity. Type mismatches close the previous entity and start a fresh one. Consecutive B tags produce separate single-token entities. These choices maximize recall at the cost of some precision, which is often preferable for downstream error analysis.

The philosophy behind these recovery strategies deserves some explanation. When evaluating a model, you want to compare its predictions against gold annotations as fairly as possible. A decoder that rejects malformed sequences outright would throw away potentially useful partial information: a model that predicted I-LOC instead of B-LOC at the right position is closer to correct than a model that predicted O. The lenient decoder recovers what it can, allowing evaluation metrics to distinguish degrees of error rather than treating all invalid sequences identically.

In a production NER system, you might choose stricter behavior: log all malformed sequences for human review rather than silently recovering them. This is particularly important during dataset preparation, where silent repairs can mask systematic annotation errors in your training data. A checker that reports every malformed sequence during annotation helps annotators maintain consistent boundary conventions.

BIOES Decoding

Decoding BIOES follows the same state machine pattern, with additional transitions for the S and E prefixes:

In[25]:
Code
def bioes_to_spans(tokens, tags):
    """
    Extract entity spans from BIOES-tagged sequence.

    S tags produce single-token spans directly.
    E tags close the current entity at the current position.
    """
    spans = []
    current_entity = None

    for i, (token, tag) in enumerate(zip(tokens, tags)):
        if tag.startswith("S-"):
            # Close any open entity, then add this single-token entity
            if current_entity is not None:
                start, etype = current_entity
                spans.append((start, i, etype))
                current_entity = None
            entity_type = tag[2:]
            spans.append((i, i + 1, entity_type))

        elif tag.startswith("B-"):
            if current_entity is not None:
                start, etype = current_entity
                spans.append((start, i, etype))
            entity_type = tag[2:]
            current_entity = (i, entity_type)

        elif tag.startswith("I-"):
            entity_type = tag[2:]
            if current_entity is None or current_entity[1] != entity_type:
                if current_entity is not None:
                    start, etype = current_entity
                    spans.append((start, i, etype))
                current_entity = (i, entity_type)

        elif tag.startswith("E-"):
            # Close entity at current position (inclusive)
            entity_type = tag[2:]
            if current_entity is not None and current_entity[1] == entity_type:
                start, _ = current_entity
                spans.append((start, i + 1, entity_type))
            else:
                # Orphan E: treat as single-token entity
                spans.append((i, i + 1, entity_type))
            current_entity = None

        else:  # O tag
            if current_entity is not None:
                start, etype = current_entity
                spans.append((start, i, etype))
                current_entity = None

    if current_entity is not None:
        start, etype = current_entity
        spans.append((start, len(tokens), etype))

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


# Test BIOES decoding
bioes_tokens = ["John", "visited", "New", "York", "and", "Paris"]
bioes_tags = ["S-PER", "O", "B-LOC", "E-LOC", "O", "S-LOC"]

bioes_decoded = bioes_to_spans(bioes_tokens, bioes_tags)
Out[26]:
Console
BIOES Decoding:
--------------------------------------------------
Input: John visited New York and Paris
Tags:  S-PER O B-LOC E-LOC O S-LOC

Extracted entities:
  [0:1] 'John' -> PER
  [2:4] 'New York' -> LOC
  [5:6] 'Paris' -> LOC

The S tags directly produce single-token entities, while B-E pairs define multi-token spans. This explicit boundary marking simplifies validation: you can check that every B is eventually followed by a matching E, and that S tags stand alone.

Tag Consistency and Validation

Real-world tagging systems produce inconsistent output. A well-designed pipeline includes validation to detect problems and, where possible, repair them automatically.

Validation matters at two distinct pipeline stages. Before training, you validate annotation data to catch inconsistencies introduced during the labeling process. Annotators may disagree on boundaries, tools may produce off-by-one errors, and automatic pre-annotation may generate malformed sequences that human annotators did not catch. Feeding invalid sequences to a sequence model during training can produce subtle bugs: the model might learn spurious patterns from incorrectly tagged tokens, or the loss computation might silently ignore malformed positions.

After inference, you validate model output to ensure it can be parsed correctly downstream. Even a model trained entirely on valid sequences may occasionally predict invalid ones, particularly on out-of-distribution inputs where the model is uncertain. The application reading NER output may crash or produce wrong results if it assumes valid sequences and receives invalid ones.

In[27]:
Code
def validate_bio_sequence(tags):
    """
    Validate a BIO tag sequence and report all errors.

    Returns list of (position, error_type, description) tuples.
    """
    errors = []
    prev_tag = "O"

    for i, tag in enumerate(tags):
        if tag == "O":
            prev_tag = tag
            continue

        if not (tag.startswith("B-") or tag.startswith("I-")):
            errors.append((i, "INVALID_TAG", f"Unrecognized tag: {tag}"))
            continue

        prefix = tag[0]
        entity_type = tag[2:] if len(tag) > 2 else ""

        if not entity_type:
            errors.append(
                (i, "MISSING_TYPE", f"Tag missing entity type: {tag}")
            )

        if prefix == "I":
            if prev_tag == "O":
                errors.append(
                    (i, "ORPHAN_I", f"I tag without preceding B: {tag}")
                )
            elif prev_tag.startswith("B-") or prev_tag.startswith("I-"):
                prev_type = prev_tag[2:]
                if prev_type != entity_type:
                    errors.append(
                        (
                            i,
                            "TYPE_MISMATCH",
                            f"I-{entity_type} follows {prev_tag}",
                        )
                    )

        prev_tag = tag

    return errors


# Test with a problematic sequence
problematic_tags = ["O", "I-PER", "I-PER", "B-LOC", "I-ORG", "O", "B-PER", "O"]

validation_errors = validate_bio_sequence(problematic_tags)
Out[28]:
Console
Sequence Validation:
-------------------------------------------------------
Tags: ['O', 'I-PER', 'I-PER', 'B-LOC', 'I-ORG', 'O', 'B-PER', 'O']

Found 2 error(s):
  Position 1: [ORPHAN_I] I tag without preceding B: I-PER
  Position 4: [TYPE_MISMATCH] I-ORG follows B-LOC

Once errors are identified, we can apply heuristic repairs:

In[29]:
Code
def repair_bio_sequence(tags):
    """
    Repair common BIO sequence errors.

    Strategies applied:
    - Convert orphan I tags to B tags
    - Resolve type mismatches by starting new entities

    Returns (repaired_tags, repair_log).
    """
    repaired = tags.copy()
    repairs = []
    prev_tag = "O"

    for i, tag in enumerate(repaired):
        if tag == "O":
            prev_tag = tag
            continue

        if tag.startswith("I-"):
            entity_type = tag[2:]

            if prev_tag == "O":
                # Orphan I: convert to B
                repaired[i] = f"B-{entity_type}"
                repairs.append((i, tag, repaired[i], "orphan I -> B"))

            elif prev_tag[0] in "BI" and prev_tag[2:] != entity_type:
                # Type mismatch: start new entity
                repaired[i] = f"B-{entity_type}"
                repairs.append((i, tag, repaired[i], "type mismatch -> new B"))

        prev_tag = repaired[i]

    return repaired, repairs


repaired_tags, repair_log = repair_bio_sequence(problematic_tags)
Out[30]:
Console
Sequence Repair:
-------------------------------------------------------
Original: ['O', 'I-PER', 'I-PER', 'B-LOC', 'I-ORG', 'O', 'B-PER', 'O']
Repaired: ['O', 'B-PER', 'I-PER', 'B-LOC', 'B-ORG', 'O', 'B-PER', 'O']

Applied 2 repair(s):
  Position 1: I-PER -> B-PER (orphan I -> B)
  Position 4: I-ORG -> B-ORG (type mismatch -> new B)

The repair function converts orphan I tags into B tags and creates new entity boundaries at type mismatches. These are the two most common patterns in model output, where the model may predict the correct type but miss a boundary.

Out[31]:
Visualization
Colored tag boxes for the original sequence showing errors at positions 1 and 4.
Original BIO tag sequence containing two errors: an orphan I-PER at position 1 (no preceding B tag) and an I-ORG type mismatch at position 4 (follows a B-LOC). Both positions receive standard dark borders.
Colored tag boxes for the repaired sequence with orange-bordered tags at changed positions.
Repaired BIO sequence after applying heuristic fixes. Positions 1 and 4 (highlighted with orange borders) were converted from invalid I tags to B tags, creating clean entity boundaries.

The repair visualization highlights positions that changed (shown with orange borders). Position 1 was an orphan I-PER that became B-PER. Position 4 was a type mismatch I-ORG that became B-ORG. Both repairs create clean entity boundaries that the decoder can now process correctly.

It is important to log all repairs rather than applying them silently. When you repair sequences during training data preparation, the log tells you whether your annotation pipeline has systematic problems. If 20% of sequences require repairs, something is wrong with your annotation tool or guidelines. If only 0.1% require repairs, you have an isolated edge case. The repair log is also a diagnostic tool during model development: when a model's output requires many repairs at inference time, it signals the model is confused about boundary detection and may need targeted fine-tuning on boundary examples.

Multi-Label BIO Tagging

Standard BIO tagging assumes each token belongs to at most one entity. But some applications require overlapping annotations. Consider "Bank of America," which might be tagged as both an organization (the company) and a location ("America" is a place). Nested named entities present similar challenges.

The need for multi-label or nested annotations arises frequently in specialized domains. Biomedical text is the canonical example: a phrase like "EGFR inhibitor resistance" might simultaneously span a gene ("EGFR"), a chemical class ("inhibitor"), and a medical concept ("EGFR inhibitor resistance") as a clinical finding. The ACE 2004 and 2005 annotation corpora, used in relation extraction research, include nested entities where a person mention can contain an organization name and vice versa. Scientific literature often contains nested mentions where a model name or acronym is embedded within a longer description.

Several approaches handle multi-label scenarios:

In[32]:
Code
# Approach 1: Multiple tag columns (one per entity type)
multi_column_example = {
    "tokens": ["Bank", "of", "America", "CEO"],
    "ORG_tags": ["B-ORG", "I-ORG", "I-ORG", "O"],
    "LOC_tags": ["O", "O", "B-LOC", "O"],
}

# Approach 2: Separate passes per entity type
separate_passes = {
    "pass_1_ORG": ["B-ORG", "I-ORG", "I-ORG", "O"],
    "pass_2_LOC": ["O", "O", "B-LOC", "O"],
}

# Approach 3: Combined labels (for small, fixed overlapping sets)
combined_example = {
    "tokens": ["Bank", "of", "America", "CEO"],
    "combined": ["B-ORG", "I-ORG", "I-ORG+B-LOC", "O"],
}
Out[33]:
Console
Multi-Label BIO: Three Approaches
=======================================================

1. Multiple Tag Columns (one per entity type):
Token      ORG          LOC         
-----------------------------------
Bank       B-ORG        O           
of         I-ORG        O           
America    I-ORG        B-LOC       
CEO        O            O           

2. Combined Labels (for overlapping spans):
Token      Combined            
-----------------------------------
Bank       B-ORG               
of         I-ORG               
America    I-ORG+B-LOC         
CEO        O

The multiple-column approach is cleanest but requires training separate models or a model with multiple output heads. Combined tags work for small label sets but explode combinatorially with many types. In practice, most NER systems use flat BIO tagging and handle overlaps through post-processing or by defining a type hierarchy.

The multiple-output-head approach is the current best practice for multi-type NER. Modern transformer-based NER models like those built on BERT can easily support multiple output heads: one shared encoder produces contextual token representations, and separate linear layers on top map those representations to tag logits for each entity type. The loss is computed independently for each head and summed. This architecture scales cleanly to any number of entity types and handles complete overlap (a token tagged as both PER and ORG by different heads) without any special combinatorial logic.

Nested Entity Encoding

For nested entities like "New York University" where "New York" is LOC and "New York University" is ORG, specialized schemes use layered BIO tags, one layer per nesting depth:

In[34]:
Code
def encode_nested_entities(tokens, entities):
    """
    Encode nested entities using layered BIO tags.

    Larger (outer) spans get assigned to earlier layers.
    Smaller (inner) spans fill later layers.
    """
    # Determine how many layers we need
    # A token can appear in at most as many layers as entities cover it
    max_depth = 0
    for pos in range(len(tokens)):
        depth = sum(1 for s, e, _ in entities if s <= pos < e)
        max_depth = max(max_depth, depth)

    if max_depth == 0:
        return [["O"] * len(tokens)]

    layers = [["O"] * len(tokens) for _ in range(max_depth)]

    # Assign larger spans first so outer entities go to earlier layers
    sorted_entities = sorted(entities, key=lambda x: (-(x[1] - x[0]), x[0]))

    for start, end, etype in sorted_entities:
        # Find the first layer where this span is fully available
        for layer in layers:
            if all(layer[i] == "O" for i in range(start, end)):
                layer[start] = f"B-{etype}"
                for i in range(start + 1, end):
                    layer[i] = f"I-{etype}"
                break

    return layers


# Nested entity example
nested_tokens = ["New", "York", "University", "is", "great"]
nested_entities = [
    (0, 3, "ORG"),  # New York University (outer)
    (0, 2, "LOC"),  # New York (inner)
]

nested_layers = encode_nested_entities(nested_tokens, nested_entities)
Out[35]:
Console
Nested Entity Encoding:
--------------------------------------------------
Token         Layer 1       Layer 2       
--------------------------------------------------
New           B-ORG         B-LOC         
York          I-ORG         I-LOC         
University    I-ORG         O             
is            O             O             
great         O             O             

Layer 1: outer ORG span (New York University)
Layer 2: inner LOC span (New York)

This layered approach preserves all entity information but requires models that can predict multiple layers simultaneously. Modern nested NER systems often use span-based prediction instead, directly outputting all valid spans regardless of nesting depth.

The span-based approach treats NER as a classification problem over candidate spans rather than a sequence labeling problem over individual tokens. Every possible span (or every span up to some maximum length) is enumerated and fed through a classifier that decides whether it is an entity and, if so, what type. Span-based models handle nested entities naturally because different spans can be classified independently, without any constraint that each token belongs to exactly one entity. The tradeoff is computational cost: enumerating all spans is quadratic in sentence length, whereas sequence tagging is linear.

BIO Evaluation and Comparison to Gold Standards

Measuring NER system accuracy requires comparing predicted BIO sequences against gold-standard annotations. The standard evaluation protocol counts entities at the span level, not the token level. A prediction is correct only if both the entity boundaries and the entity type match the gold standard exactly.

This strict matching criterion has practical effects. Consider a gold annotation of "New York City" as LOC. A model that predicts B-LOC I-LOC for "New York" (missing "City") scores zero on this entity. Neither a partial credit nor a type-only credit is awarded. The full-span requirement means that boundary errors are penalized just as heavily as type errors. This motivates the attention to boundary precision we saw in the BIOES scheme discussion.

In[36]:
Code
def evaluate_ner(gold_tokens, gold_tags, pred_tags):
    """
    Evaluate NER predictions against gold standard.

    Uses exact span matching: both boundaries and type must match.
    Returns precision, recall, and F1 score.
    """
    gold_spans = set(
        (s, e, t) for s, e, t, _ in bio_to_spans(gold_tokens, gold_tags)
    )
    pred_spans = set(
        (s, e, t) for s, e, t, _ in bio_to_spans(gold_tokens, pred_tags)
    )

    true_positives = gold_spans & pred_spans
    false_positives = pred_spans - gold_spans
    false_negatives = gold_spans - pred_spans

    precision = len(true_positives) / len(pred_spans) if pred_spans else 0.0
    recall = len(true_positives) / len(gold_spans) if gold_spans else 0.0
    f1 = (
        2 * precision * recall / (precision + recall)
        if precision + recall > 0
        else 0.0
    )

    return {
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "tp": len(true_positives),
        "fp": len(false_positives),
        "fn": len(false_negatives),
        "true_positives": true_positives,
        "false_positives": false_positives,
        "false_negatives": false_negatives,
    }


# Gold and predicted sequences for evaluation example
eval_tokens = ["Barack", "Obama", "visited", "New", "York", "City", "yesterday"]
eval_gold = ["B-PER", "I-PER", "O", "B-LOC", "I-LOC", "I-LOC", "O"]

# Prediction 1: Perfect prediction
pred_perfect = ["B-PER", "I-PER", "O", "B-LOC", "I-LOC", "I-LOC", "O"]

# Prediction 2: Boundary error (misses last LOC token)
pred_boundary = ["B-PER", "I-PER", "O", "B-LOC", "I-LOC", "O", "O"]

# Prediction 3: Type error (predicts ORG instead of LOC)
pred_type_err = ["B-PER", "I-PER", "O", "B-ORG", "I-ORG", "I-ORG", "O"]
Out[37]:
Console
NER Evaluation: Three Prediction Scenarios
============================================================

Perfect prediction:
  Predicted: B-PER I-PER O B-LOC I-LOC I-LOC O
  TP=2, FP=0, FN=0
  Precision=1.00, Recall=1.00, F1=1.00

Boundary error (New York only):
  Predicted: B-PER I-PER O B-LOC I-LOC O O
  TP=1, FP=1, FN=1
  Precision=0.50, Recall=0.50, F1=0.50
  Missed spans: {(3, 6, 'LOC')}
  Wrong spans:  {(3, 5, 'LOC')}

Type error (LOC predicted as ORG):
  Predicted: B-PER I-PER O B-ORG I-ORG I-ORG O
  TP=1, FP=1, FN=1
  Precision=0.50, Recall=0.50, F1=0.50
  Missed spans: {(3, 6, 'LOC')}
  Wrong spans:  {(3, 6, 'ORG')}

The evaluation results illustrate the strictness of span-level matching. A boundary error that misses just one token of a three-token entity scores F1=0.5, the same as a type error where the entire entity is correctly bounded but labeled as the wrong type. Both errors are completely wrong from the perspective of exact span matching. A system designer choosing between BIOES and BIO might find this motivating: if boundary errors are equally costly as type errors, the explicit end markers of BIOES, which help the model learn exact boundaries, can directly improve F1.

BIO Utilities in Practice

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.

BIO Tagging in Transformer-Based Models

Modern NER systems built on BERT and similar transformers require a careful treatment of BIO tags due to subword tokenization. BERT's WordPiece tokenizer splits words into subword units, so "Washington" might become ["Washington"] or ["Wash", "##ington"] depending on whether it appears in the model's vocabulary. The "##" prefix marks continuation pieces of the same word.

This creates an alignment problem: your BIO tags are defined over words, but the model processes subword tokens. The standard approach is to assign the word's BIO tag to the first subword piece and a special label, typically "-100" in Hugging Face terminology, to all subsequent pieces. The loss function ignores positions labeled "-100," so the model only predicts tags for the first piece of each word.

In[42]:
Code
def align_labels_with_tokens(word_ids, word_labels):
    """
    Align word-level BIO labels with subword tokenization.

    word_ids: list from tokenizer.word_ids(), maps token position to word index
              (None for special tokens like [CLS] and [SEP])
    word_labels: list of BIO tags, one per word

    Returns label_ids with -100 for special tokens and continuation subwords.
    """
    label_ids = []
    previous_word_idx = None

    for word_idx in word_ids:
        if word_idx is None:
            # Special token ([CLS], [SEP], [PAD])
            label_ids.append(-100)
        elif word_idx != previous_word_idx:
            # First subword of a new word: use the word's label
            label_ids.append(word_labels[word_idx])
        else:
            # Continuation subword: ignore during loss computation
            label_ids.append(-100)
        previous_word_idx = word_idx

    return label_ids


# Simulate tokenization with subword splitting
# Word: Barack Obama visited New York City
words = ["Barack", "Obama", "visited", "New", "York", "City"]
bio_word_labels = [0, 1, 2, 3, 4, 4]  # 0=B-PER, 1=I-PER, 2=O, 3=B-LOC, 4=I-LOC

# Simulated word_ids (e.g., "visited" splits into "visit" + "##ed")
# word_ids: [None, 0, 1, 2, 2, 3, 4, 5, None]
#            CLS  Br  Ob  vis ##ed New York City SEP
simulated_word_ids = [None, 0, 1, 2, 2, 3, 4, 5, None]

aligned = align_labels_with_tokens(simulated_word_ids, bio_word_labels)
Out[43]:
Console
Subword Alignment for Transformer NER:
---------------------------------------------
Subword    Word ID  Label
---------------------------------------------
[CLS]      None     [IGN]
Barack     0        B-PER
Obama      1        I-PER
visit      2        O
##ed       2        [IGN]
New        3        B-LOC
York       4        I-LOC
City       5        I-LOC
[SEP]      None     [IGN]

Note: [IGN] positions are excluded from loss computation.
The '##ed' piece of 'visited' is ignored since the first
subword 'visit' already carries the O label.

This alignment procedure ensures that the model makes one prediction per word (from the first subword) and that subword boundaries do not corrupt the BIO sequence. Without proper alignment, a model might produce a BIO sequence over subword units that is longer than the word sequence, making it impossible to recover coherent entity spans.

The alignment also affects how you interpret predictions at inference time. After the model predicts labels for all subword positions, you collect only the labels at first-subword positions (where word_ids changes to a new value) and discard continuation labels. These first-subword labels form the word-level BIO sequence, which you can then decode using the standard bio_to_spans function we implemented earlier. The subword processing is a preprocessing and postprocessing layer wrapped around the same core BIO machinery.

Annotation Guidelines and Span Definition Challenges

BIO tags are only as consistent as the annotation guidelines that define what counts as an entity and where its boundaries lie. Ambiguous guidelines produce inconsistent tags, which in turn produce models that learn unreliable boundary conventions.

Several boundary ambiguities recur across NER projects. One common case involves possessive suffixes: should "IBM's" be tagged as B-ORG and the apostrophe-s excluded, or should the full string "IBM's" be the entity? Different datasets make different choices, and a model trained on one convention will have lower precision or recall when evaluated on data annotated with a different one. Tokenization choices interact here: if the tokenizer splits "IBM's" into ["IBM", "'s"], the question is whether "'s" gets I-ORG or O.

Another frequent ambiguity involves titles and honorifics. In "President Biden visited Paris," should "President Biden" be the person entity (including the title) or just "Biden"? The CoNLL-2003 English dataset typically excludes titles, but other datasets include them. Models trained on CoNLL-2003 data will therefore systematically miss the title token when applied to data where titles are included in the span.

Prepositional attachment creates similar challenges. "Bank of America" is clearly a single ORG entity. But "University of California at Berkeley" might be annotated as one span or as "University of California" with "Berkeley" as a separate LOC, depending on how the annotation guidelines treat prepositional phrases. Fine-grained guidelines that address these cases explicitly produce more consistent training data and better-calibrated evaluation results.

In[44]:
Code
# Illustrate span boundary ambiguity
ambiguous_cases = [
    {
        "text": ["President", "Biden", "visited", "Paris"],
        "narrow_spans": [(1, 2, "PER"), (3, 4, "LOC")],  # exclude title
        "broad_spans": [(0, 2, "PER"), (3, 4, "LOC")],  # include title
    },
    {
        "text": ["IBM", "'s", "revenue", "grew"],
        "narrow_spans": [(0, 1, "ORG")],  # exclude possessive
        "broad_spans": [(0, 2, "ORG")],  # include possessive
    },
    {
        "text": ["University", "of", "California", "at", "Berkeley"],
        "single_span": [(0, 5, "ORG")],  # whole phrase is one ORG
        "split_spans": [(0, 3, "ORG"), (4, 5, "LOC")],  # split at "Berkeley"
    },
]

for case in ambiguous_cases:
    tokens = case["text"]
    for scheme_name, spans in list(case.items())[1:]:
        tags = spans_to_bio(tokens, spans)
        print(f"{scheme_name}: {' '.join(tags)}")
    print()
Out[45]:
Console
Span Boundary Ambiguities in Annotation:
============================================================

Sentence: President Biden visited Paris
  narrow_spans   : O B-PER O B-LOC
  broad_spans    : B-PER I-PER O B-LOC

Sentence: IBM 's revenue grew
  narrow_spans   : B-ORG O O O
  broad_spans    : B-ORG I-ORG O O

Sentence: University of California at Berkeley
  single_span    : B-ORG I-ORG I-ORG I-ORG I-ORG
  split_spans    : B-ORG I-ORG I-ORG O B-LOC

These ambiguity examples are not edge cases to be dismissed. They represent systematic differences that produce measurably different model behavior. A model trained on narrow-span annotations for person names (excluding titles) will have systematically lower recall when applied to text where broad-span conventions are expected. Annotation consistency within a dataset is at least as important as annotation quality at any single example.

Limitations and Practical Considerations

BIO tagging is the dominant approach for sequence labeling, but it has limitations worth understanding before choosing it for a new application.

The fundamental constraint is that standard BIO assumes non-overlapping entities. Each token receives exactly one tag, so nested or overlapping annotations cannot be represented directly. The workarounds we discussed, including multiple layers, combined tags, and separate passes, add complexity and may not suit all applications. For domains with extensive nesting, such as biomedical text where gene mentions overlap with protein mentions, span-based or graph-based approaches may be more appropriate.

Boundary precision is another challenge. Models often predict the correct entity type but miss exact boundaries. The sentence "the New York Stock Exchange" might yield predictions starting at "New" when guidelines say the entity begins at "the." BIO's token-level representation means every boundary error affects multiple labels simultaneously. BIOES mitigates this slightly by making endpoints explicit, but the underlying challenge remains. Training data annotation guidelines must be precise about boundary rules, otherwise the model learns inconsistent patterns.

Long entities pose particular difficulties for sequence models. An entity spanning ten tokens requires the model to maintain consistent predictions across all ten positions. In BIO, a single mistake, predicting O instead of I in the middle, breaks the entity into two fragments. CRF layers and constrained decoding help by enforcing valid transitions, but very long entities remain error-prone. The Conditional Random Fields chapter will show how to incorporate transition constraints directly into training.

The class imbalance problem is structural and unavoidable with BIO tagging. Because O tokens dominate in every NLP text, models face constant pressure to default to O predictions. Addressing this requires deliberate choices about loss weighting, data sampling, or label-smoothing strategies. There is no free lunch: every technique that helps the model pay more attention to entity tokens risks overfitting to rare types or producing false positives on common non-entity words that superficially resemble entity tokens.

The interaction between tokenization and BIO tagging deserves special mention as a source of subtle bugs. Different tokenizers produce different splits of the same surface text, and the BIO sequence for a sentence differs depending on which tokenizer was used. A model trained with one tokenizer applied to data tokenized with a different tokenizer will have alignment problems that can silently degrade accuracy without producing obvious errors. When combining data from multiple sources or switching tokenizers between training and inference, always verify the alignment between BIO tags and token sequences.

Despite these limitations, BIO tagging works remarkably well in practice. Its simple format, broad tooling support, and compatibility with sequence models like BiLSTM-CRF and BERT-based taggers make it the right choice for most NER applications. The format has been validated on hundreds of benchmarks across dozens of languages and dozens of entity type systems. Its decades of use mean that nearly every NLP library includes built-in support for reading, writing, validating, and evaluating BIO-tagged data. Understanding when and why it fails helps you design better systems and interpret evaluation results more accurately.

Summary

BIO tagging provides a standardized format for representing entity boundaries in sequence labeling tasks. The key concepts from this chapter:

The BIO scheme uses three prefixes: B (beginning) marks the first token of an entity, I (inside) marks continuation tokens, and O (outside) marks non-entity tokens. This encoding unambiguously represents entity boundaries, handling adjacent same-type entities correctly. The scheme's success stems from its completeness: three bits of information are both necessary and sufficient to reconstruct any flat entity annotation.

Extended schemes like BIOES add explicit end markers (E) and single-token markers (S) for stronger supervision and easier validation. BIOES trades a slightly larger label vocabulary for clearer boundary signals, which research has shown produces modest but consistent accuracy improvements in boundary-sensitive evaluations. The choice between BIO and BIOES involves a tradeoff between simplicity and boundary precision.

Conversion utilities transform between span annotations and per-token BIO tags. Reliable converters handle edge cases like single-token entities, adjacent entities, and sequence boundaries. Decoders must gracefully handle malformed sequences from model predictions, applying recovery strategies that maximize recall for downstream error analysis.

Validation and repair catch common errors like orphan I tags and type mismatches. Repair strategies can automatically fix many issues, improving downstream usability. Logging every repair is as important as applying it, because the repair log reveals systemic problems in annotation pipelines or model behavior.

Multi-label scenarios require extensions like multiple tag columns or layered encoding for nested entities. Standard BIO assumes non-overlapping annotations, and domains with extensive nesting, such as biomedical NER, often benefit from span-based approaches that handle overlaps natively.

Evaluation at the span level requires exact matching of both boundaries and type. A partial boundary error scores zero, which motivates careful attention to boundary conventions in annotation guidelines and BIOES encoding for models where boundary precision is critical.

Transformer integration requires aligning word-level BIO tags with subword tokenization by labeling only the first subword piece of each word and ignoring continuation pieces during loss computation. This alignment step is a required preprocessing component for any BERT-style NER system.

The next chapters apply BIO tagging to chunking and introduce the probabilistic models, Hidden Markov Models and Conditional Random Fields, that power production sequence labeling systems.

Key Parameters

When working with BIO tagging utilities, these parameters control conversion and validation behavior:

  • tokens: List of string tokens representing the input sequence. Must align with span indices for correct conversion.
  • spans: List of tuples in (start_idx, end_idx, entity_type) format. Uses Python's exclusive end convention where end_idx points to the position after the last entity token.
  • tags: List of BIO tag strings, one per token. Valid formats are B-TYPE, I-TYPE, and O.
  • entity_type: String identifier for the entity category such as PER, LOC, or ORG. Appears as the suffix after the hyphen in BIO tags.

For BIOES conversion, two additional prefixes apply:

  • S-TYPE: Marks single-token entities that do not need a B/I/E structure.
  • E-TYPE: Marks the final token of multi-token entities.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about BIO tagging for sequence labeling.

BIO Tagging

Question 1 of 80 of 8 completed
In the BIO tagging scheme, what does the 'B' prefix indicate?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025biotagging, author = {Michael Brenndoerfer}, title = {BIO Tagging for Named Entity Recognition}, year = {2025}, url = {https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-15} }
APAAcademic
Michael Brenndoerfer (2025). BIO Tagging for Named Entity Recognition. Retrieved from https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner
MLAAcademic
Michael Brenndoerfer. "BIO Tagging for Named Entity Recognition." 2026. Web. September 15, 2026. <https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner>.
CHICAGOAcademic
Michael Brenndoerfer. "BIO Tagging for Named Entity Recognition." Accessed September 15, 2026. https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner.
HARVARDAcademic
Michael Brenndoerfer (2025) 'BIO Tagging for Named Entity Recognition'. Available at: https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner (Accessed: September 15, 2026).
SimpleBasic
Michael Brenndoerfer (2025). BIO Tagging for Named Entity Recognition. https://mbrenndoerfer.com/writing/bio-tagging-sequence-labeling-ner

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.