Chunking › IOB Tagging for Chunks

Michael BrenndoerferUpdated March 21, 20267 min read
Language AI HandbookAll chapters
Part 6 of 67Sequence Labeling
Chapter 39Chunking › IOB Tagging for Chunks
Reading section 4 of 15 · IOB Tagging for Chunks · 7 minSection 4 of 15 · 7 min

Book contents

Section 4 of 15 · 7 min read

Chunking › IOB Tagging for ChunksOne section · Sections in this chapter4 / 15 ↓
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

As we explored in the BIO Tagging chapter, representing multi-token spans as per-token labels requires an encoding scheme. For chunking, the standard encoding is IOB (Inside-Outside-Beginning) tagging, which uses exactly the same logic as BIO tagging for named entity recognition.

IOB Tagging for Chunks

IOB tagging represents chunk boundaries using per-token labels. B-NP marks the first word of a noun phrase, I-NP marks subsequent words in the same NP, and O marks words outside any chunk. The terminology IOB and BIO refer to the same scheme; the order of the letters differs but the encoding is identical.

There is one historical variant worth knowing. The original IOB1 scheme used the B tag only when a chunk immediately followed another chunk of the same type, to distinguish the boundary between two adjacent NPs. The more common IOB2 scheme always uses B for the first token of every chunk, which is simpler and more consistent. All modern benchmarks use IOB2.

Let's see IOB tagging applied to a sentence with multiple chunk types:

In[14]:
Code
# Example sentence with IOB chunk tags
sentence = "The quick brown fox jumps over the lazy dog"
tokens = sentence.split()

# IOB2 tags: B marks the START of every chunk
iob_tags = [
    "B-NP",  # The      (starts NP)
    "I-NP",  # quick    (inside NP)
    "I-NP",  # brown    (inside NP)
    "I-NP",  # fox      (inside NP)
    "B-VP",  # jumps    (starts VP)
    "B-PP",  # over     (starts PP)
    "B-NP",  # the      (starts new NP)
    "I-NP",  # lazy     (inside NP)
    "I-NP",  # dog      (inside NP)
]
Out[15]:
Console
IOB Chunk Tagging
==================================================

Sentence: The quick brown fox jumps over the lazy dog

Token        IOB Tag    Role
---------------------------------------------
The          B-NP       Begin noun phrase
quick        I-NP       Inside noun phrase
brown        I-NP       Inside noun phrase
fox          I-NP       Inside noun phrase
jumps        B-VP       Begin verb phrase
over         B-PP       Begin prep phrase
the          B-NP       Begin noun phrase
lazy         I-NP       Inside noun phrase
dog          I-NP       Inside noun phrase

Chunks identified:
  [The quick brown fox] -> NP
  [jumps] -> VP
  [over] -> PP
  [the lazy dog] -> NP
Out[16]:
Visualization
Horizontal token sequence with colored IOB tag boxes below each word, grouped with labeled brackets.
IOB tagging applied to syntactic chunks in the sentence 'The quick brown fox jumps over the lazy dog.' Each token receives a label encoding its position within phrase boundaries. B (blue/green/red) marks the first token of each chunk, I (lighter shade) marks continuation, and the brackets beneath show the resulting phrase groups.

Converting Between Chunks and IOB Tags

In practice, you often need to convert between two representations: span annotations (start index, end index, type) and per-token IOB sequences. Building on the converter utilities from the BIO Tagging chapter, let's implement these conversions for chunks:

In[17]:
Code
def chunks_to_iob(tokens, chunks):
    """
    Convert chunk spans to IOB2 tags.

    Args:
        tokens: List of token strings
        chunks: List of (start_idx, end_idx, chunk_type) tuples
                where end_idx is exclusive (Python slicing convention)

    Returns:
        List of IOB2 tags, one per token
    """
    tags = ["O"] * len(tokens)

    for start, end, chunk_type in sorted(chunks, key=lambda x: x[0]):
        if start < 0 or end > len(tokens) or start >= end:
            continue
        tags[start] = f"B-{chunk_type}"
        for i in range(start + 1, end):
            tags[i] = f"I-{chunk_type}"

    return tags


def iob_to_chunks(tokens, tags):
    """
    Extract chunk spans from IOB2 tags.

    Returns:
        List of (start_idx, end_idx, chunk_type, text) tuples
    """
    chunks = []
    current_chunk = None  # (start_idx, chunk_type)

    for i, (token, tag) in enumerate(zip(tokens, tags)):
        if tag.startswith("B-"):
            # Close any open chunk first
            if current_chunk is not None:
                start, ctype = current_chunk
                chunks.append((start, i, ctype))
            # Start new chunk
            current_chunk = (i, tag[2:])

        elif tag.startswith("I-"):
            chunk_type = tag[2:]
            # Handle orphan I tag (I without preceding B)
            if current_chunk is None:
                current_chunk = (i, chunk_type)
            elif current_chunk[1] != chunk_type:
                # Type mismatch: close old, start new
                start, ctype = current_chunk
                chunks.append((start, i, ctype))
                current_chunk = (i, chunk_type)

        else:  # O tag
            if current_chunk is not None:
                start, ctype = current_chunk
                chunks.append((start, i, ctype))
                current_chunk = None

    # Handle chunk at end of sequence
    if current_chunk is not None:
        start, ctype = current_chunk
        chunks.append((start, len(tokens), ctype))

    # Attach text spans
    result = []
    for start, end, ctype in chunks:
        text = " ".join(tokens[start:end])
        result.append((start, end, ctype, text))

    return result


# Test round-trip conversion
test_tokens = "The quick brown fox jumps over the lazy dog".split()
test_chunks_input = [
    (0, 4, "NP"),  # The quick brown fox
    (4, 5, "VP"),  # jumps
    (5, 6, "PP"),  # over
    (6, 9, "NP"),  # the lazy dog
]

test_iob = chunks_to_iob(test_tokens, test_chunks_input)
recovered = iob_to_chunks(test_tokens, test_iob)
Out[18]:
Console
Chunk <-> IOB Round-Trip Conversion
=======================================================

Original chunks:
  [0:4] 'The quick brown fox' -> NP
  [4:5] 'jumps' -> VP
  [5:6] 'over' -> PP
  [6:9] 'the lazy dog' -> NP

Generated IOB tags:
  The          -> B-NP
  quick        -> I-NP
  brown        -> I-NP
  fox          -> I-NP
  jumps        -> B-VP
  over         -> B-PP
  the          -> B-NP
  lazy         -> I-NP
  dog          -> I-NP

Recovered chunks:
  [0:4] 'The quick brown fox' -> NP
  [4:5] 'jumps' -> VP
  [5:6] 'over' -> PP
  [6:9] 'the lazy dog' -> NP

Round-trip match: True

The round-trip works because IOB2 uniquely encodes every possible chunk configuration. The only ambiguity in the decoding step is handling "orphan I" tags (an I tag without a preceding B of the same type), which we resolve by treating the I as implicitly starting a new chunk.