Chunking: Shallow Parsing for Phrase Identification in NLP

Michael BrenndoerferUpdated March 21, 202641 min read

Part of Language AI Handbook

Covers chunking (shallow parsing) to identify noun phrases, verb phrases, and prepositional phrases using IOB tagging, regex patterns.

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

Chunking

Sentences contain meaningful groups of words that function as units. In "The quick brown fox jumps over the lazy dog," you naturally perceive "the quick brown fox" as a noun phrase describing the subject, "jumps" as the action, and "over the lazy dog" as a prepositional phrase describing where the action occurs. Chunking, also called shallow parsing, identifies these non-overlapping segments without building a full syntactic tree.

Chunking occupies a useful middle ground between part-of-speech tagging and full parsing. POS tagging labels individual words. Full parsing constructs hierarchical tree structures showing how phrases nest within phrases. Chunking finds a middle ground: it groups consecutive words into flat, non-recursive chunks without attempting to show how chunks relate to each other or how they nest.

This chapter explores chunking from multiple angles. You'll learn the major chunk types, understand how IOB tagging represents chunk boundaries, implement chunkers using both regular expressions and machine learning, and see how chunking is a preprocessing step for information extraction and other later tasks.

What Is Chunking?

Chunking identifies contiguous spans of tokens that form syntactic units. Unlike full parsing, which produces tree structures with unlimited nesting, chunking produces a flat sequence of labeled segments. Each token belongs to exactly one chunk, or no chunk at all if it falls outside every phrase boundary.

Chunking (Shallow Parsing)

Chunking is the task of grouping consecutive words into non-overlapping, non-recursive phrases such as noun phrases (NP), verb phrases (VP), and prepositional phrases (PP). It identifies phrase boundaries without building hierarchical parse trees, making it faster and less brittle than full syntactic analysis.

The word "shallow" in "shallow parsing" refers to the depth of structural analysis. Chunking reads the surface of a sentence and identifies phrase-like groups, but it does not resolve questions like whether a prepositional phrase modifies the subject or the verb. Those decisions require deeper semantic understanding, and chunking deliberately avoids them.

Consider the sentence "The black cat sat on the mat." A chunker might produce:

  • [The black cat]NP_{NP}, noun phrase (subject)
  • [sat]VP_{VP}, verb phrase (predicate)
  • [on]PP_{PP}, prepositional phrase marker
  • [the mat]NP_{NP}, noun phrase (object of preposition)

The key properties of chunking are:

  • Non-overlapping: Each word belongs to at most one chunk. Two chunks cannot share a token.
  • Non-recursive: Chunks do not contain other chunks of the same type. You won't find an NP inside an NP in a flat chunking annotation.
  • Contiguous: Chunks consist of consecutive words. A chunk cannot span across a gap.
  • Partial coverage: Some words may not belong to any chunk. Articles that introduce a phrase belong to the phrase, but punctuation often remains unchunked.

Let's see chunking in action with NLTK. The ne_chunk function identifies named entities as chunks, which is a specific form of chunking:

In[4]:
Code
from nltk import pos_tag, word_tokenize
from nltk.chunk import ne_chunk

# Download required NLTK resources (comment out after first run)
# nltk.download("punkt", quiet=True)
# nltk.download("averaged_perceptron_tagger", quiet=True)
# nltk.download("maxent_ne_chunker", quiet=True)
# nltk.download("words", quiet=True)
# nltk.download("conll2000", quiet=True)

# Example sentence with named entities
ne_sentence = "Barack Obama visited New York City yesterday."
ne_tokens = word_tokenize(ne_sentence)
ne_tagged = pos_tag(ne_tokens)
ne_chunked = ne_chunk(ne_tagged)
Out[5]:
Console
NLTK Named Entity Chunking
==================================================

Sentence: Barack Obama visited New York City yesterday.

POS Tags:
  Barack       NNP
  Obama        NNP
  visited      VBD
  New          NNP
  York         NNP
  City         NNP
  yesterday    NN
  .            .

Chunk Tree:
(S
  (PERSON Barack/NNP)
  (PERSON Obama/NNP)
  visited/VBD
  (GPE New/NNP York/NNP City/NNP)
  yesterday/NN
  ./.)

NLTK's ne_chunk function returns a tree where named entities appear as labeled subtrees. Each subtree is a chunk. This pattern, where a sequence labeling task produces labeled spans, is the core idea behind all chunking.

Chunk Types

Different chunk types capture different syntactic units. The most common types in English are noun phrases, verb phrases, and prepositional phrases, though annotation schemes vary and some include additional categories.

Noun Phrases (NP)

Noun phrases are the most important and well-studied chunk type. A noun phrase has a noun as its head, typically preceded by a determiner and optional adjectives. NP chunking is sometimes called "base NP chunking" when it targets only the innermost, non-recursive noun phrases, which is the most common definition in NLP benchmarks.

The structure of a noun phrase follows regular grammatical patterns. A determiner (the, a, my) often opens the phrase, adjectives modify the head noun, and the head noun itself can be singular, plural, or proper. Understanding these patterns is key to building effective chunkers.

In[6]:
Code
# Examples of noun phrases with varying internal structure
np_examples = [
    ("the cat", "simple: determiner + noun"),
    ("a big red ball", "adjectives: det + adj + adj + noun"),
    ("President Obama", "proper: title + name"),
    ("three blind mice", "numeral: number + adj + noun"),
    ("my favorite book", "possessive: possessive pronoun + adj + noun"),
]

# Common POS patterns that form noun phrases
np_pos_patterns = [
    "DT NN",  # the cat
    "DT JJ NN",  # the black cat
    "DT JJ JJ NN",  # the big black cat
    "NNP NNP",  # Barack Obama
    "CD NN",  # three cats
    "PRP$ NN",  # my cat
    "JJ NN",  # black cat (no determiner)
    "NN NN",  # computer screen (compound noun)
]
Out[7]:
Console
Noun Phrase Examples
=======================================================

  'the cat'
    POS tags: DT NN
    Pattern:  simple: determiner + noun

  'a big red ball'
    POS tags: DT JJ JJ NN
    Pattern:  adjectives: det + adj + adj + noun

  'President Obama'
    POS tags: NNP NNP
    Pattern:  proper: title + name

  'three blind mice'
    POS tags: CD IN NNS
    Pattern:  numeral: number + adj + noun

  'my favorite book'
    POS tags: PRP$ JJ NN
    Pattern:  possessive: possessive pronoun + adj + noun


Common NP POS Patterns:
-----------------------------------
  DT NN
  DT JJ NN
  DT JJ JJ NN
  NNP NNP
  CD NN
  PRP$ NN
  JJ NN
  NN NN

The range of NP patterns is broad, but most follow a recognizable template: optional modifier elements followed by a noun head. Regex chunkers exploit this regularity by specifying patterns over POS tags rather than over word forms.

Verb Phrases (VP)

Verb phrases contain the main verb and its auxiliaries. In shallow chunking, VP chunks typically include only the verbal elements themselves, not the objects or complements that would be included in a full verb phrase under traditional grammar.

The main source of variation in VPs is auxiliary verbs. Modals (can, will, should), perfect auxiliaries (have), and progressive auxiliaries (be) combine with main verbs to produce complex tense and aspect patterns. A chunker needs to group all these together.

In[8]:
Code
# Examples of verb phrases at different tense/aspect combinations
vp_examples = [
    ("runs", "simple present"),
    ("is running", "progressive: auxiliary + main verb"),
    ("has been running", "perfect progressive: 2 auxiliaries + main"),
    ("can swim", "modal + base form"),
    ("should have called", "modal + perfect auxiliary + past participle"),
    ("was eaten", "passive: auxiliary + past participle"),
]

# POS patterns for verb phrase chunks
vp_pos_patterns = [
    "VBZ",  # runs
    "VBD",  # ran
    "VBG",  # running (bare progressive, less common as VP)
    "MD VB",  # can run
    "VBZ VBG",  # is running
    "VBZ VBN",  # is eaten (passive)
    "MD VB VBN",  # will be eaten
    "VBZ VBN VBN",  # has been eaten (perfect passive)
]
Out[9]:
Console
Verb Phrase Examples
=======================================================

  'runs'
    POS tags: NNS
    Type:     simple present

  'is running'
    POS tags: VBZ VBG
    Type:     progressive: auxiliary + main verb

  'has been running'
    POS tags: VBZ VBN VBG
    Type:     perfect progressive: 2 auxiliaries + main

  'can swim'
    POS tags: MD VB
    Type:     modal + base form

  'should have called'
    POS tags: MD VB VBN
    Type:     modal + perfect auxiliary + past participle

  'was eaten'
    POS tags: VBD VBN
    Type:     passive: auxiliary + past participle

Prepositional Phrases (PP)

Prepositional phrases begin with a preposition and typically continue with a noun phrase. Different chunking schemes handle PPs differently. Some schemes identify only the preposition as the PP chunk, leaving the following NP separate. Others group the preposition together with its complement NP into a single PP chunk.

The choice matters for downstream applications. If you want to identify "in the house" as a single location expression, you want the inclusive scheme. If you want to identify "the house" as a noun phrase for keyword extraction, the separate scheme works better.

In[10]:
Code
# Examples of prepositional phrases
pp_examples = [
    ("in the house", "location"),
    ("with great care", "manner"),
    ("after the meeting", "time"),
    ("under the old oak tree", "complex location with adjective"),
    ("for my best friend", "beneficiary with possessive NP"),
    ("because of the rain", "causal (complex preposition)"),
]
Out[11]:
Console
Prepositional Phrase Examples
=======================================================

  'in the house'
    POS tags: IN DT NN
    Function: location

  'with great care'
    POS tags: IN JJ NN
    Function: manner

  'after the meeting'
    POS tags: IN DT NN
    Function: time

  'under the old oak tree'
    POS tags: IN DT JJ NN NN
    Function: complex location with adjective

  'for my best friend'
    POS tags: IN PRP$ JJS NN
    Function: beneficiary with possessive NP

  'because of the rain'
    POS tags: IN IN DT NN
    Function: causal (complex preposition)

Other Chunk Types

Depending on the annotation scheme, chunkers may identify additional phrase types beyond the three main categories:

In[12]:
Code
other_chunk_types = {
    "ADJP": (
        "Adjective phrase",
        "very happy, quite tired, extremely well-read",
    ),
    "ADVP": ("Adverb phrase", "very quickly, rather slowly, almost never"),
    "SBAR": (
        "Subordinate clause",
        "that he left, if it rains, although she tried",
    ),
    "PRT": ("Particle", "give up, turn on, look after"),
    "CONJP": ("Conjunction phrase", "as well as, rather than, not only"),
    "INTJ": ("Interjection", "oh, wow, alas"),
}
Out[13]:
Console
Additional Chunk Types
=================================================================

  ADJP: Adjective phrase
    Examples: very happy, quite tired, extremely well-read

  ADVP: Adverb phrase
    Examples: very quickly, rather slowly, almost never

  SBAR: Subordinate clause
    Examples: that he left, if it rains, although she tried

  PRT: Particle
    Examples: give up, turn on, look after

  CONJP: Conjunction phrase
    Examples: as well as, rather than, not only

  INTJ: Interjection
    Examples: oh, wow, alas

The Penn Treebank annotation scheme, which the CoNLL-2000 shared task uses, includes all of these types. In practice, NP chunking alone covers the majority of applications, and many systems focus exclusively on noun phrases.

IOB Tagging for Chunks

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.

Chunking vs. Full Parsing

Understanding the distinction between chunking and full parsing clarifies when each approach is appropriate.

Full syntactic parsing produces a complete tree structure showing how phrases nest within phrases. Consider the sentence "I saw the man with the telescope." A full parser faces an attachment ambiguity: does "with the telescope" modify "saw" (I used a telescope to see) or "the man" (the man had a telescope)? Resolving this ambiguity requires either syntactic context or world knowledge. Two legitimate parse trees exist for this sentence, and a parser must choose one.

Chunking sidesteps this problem entirely. It identifies the phrase "with the telescope" as a prepositional phrase but does not decide where it attaches. Similarly, it identifies "the man" as a noun phrase but does not specify whether it's the subject or object of the verb. The flat structure avoids attachment decisions.

In[19]:
Code
# Illustrate the ambiguity that chunking avoids
ambiguous = "I saw the man with the telescope"

# What chunking produces: flat, unambiguous
chunk_output = [
    ("I", "NP"),
    ("saw", "VP"),
    ("the man", "NP"),
    ("with", "PP"),
    ("the telescope", "NP"),
]

# What full parsing must choose between
parse_option_1 = "VP[saw NP[the man] PP[with NP[the telescope]]]"
parse_option_2 = "VP[saw NP[the man PP[with NP[the telescope]]]]"

parse_interp_1 = "I used the telescope to see the man"
parse_interp_2 = "I saw the man who had the telescope"
Out[20]:
Console
Chunking vs. Full Parsing
=================================================================

Sentence: 'I saw the man with the telescope'

--- Chunking (Shallow Parsing) ---
Flat sequence, no attachment decisions required:
  [I] -> NP
  [saw] -> VP
  [the man] -> NP
  [with] -> PP
  [the telescope] -> NP

--- Full Parsing ---
Must commit to one of two parse trees:

  Option 1: VP[saw NP[the man] PP[with NP[the telescope]]]
  Meaning:  I used the telescope to see the man

  Option 2: VP[saw NP[the man PP[with NP[the telescope]]]]
  Meaning:  I saw the man who had the telescope
Out[21]:
Visualization
Two-panel diagram: left shows flat chunk sequence, right shows two possible hierarchical parse trees.
Comparison of chunking and full parsing for the sentence 'I saw the man with the telescope.' Chunking (left) produces a flat, unambiguous sequence of phrase segments. Full parsing (right) must choose between two interpretations of where the prepositional phrase attaches, requiring semantic knowledge that chunking deliberately avoids.

The key tradeoffs between chunking and full parsing are:

  • Speed: Chunking runs much faster because it avoids the exponential search space of full parse trees.
  • Accuracy: Chunking achieves higher accuracy on its simpler, well-defined task. Full parsers make more mistakes because they take on harder problems.
  • Information content: Full parsing captures complete syntactic structure including attachment, coordination, and subordination. Chunking captures only phrase boundaries.
  • Ambiguity handling: Chunking avoids hard attachment decisions. Full parsing must resolve them, sometimes incorrectly.

For many practical applications, such as information extraction, named entity recognition, and text classification, chunking provides sufficient structure without the complexity and added errors of full parsing.

IOB1 vs. IOB2: The Two Variants

Before diving into implementation, it's worth understanding the historical variant IOB1, which you'll encounter in older papers and datasets.

In IOB1 (the original scheme), the B prefix marks the first token of a chunk only when the chunk immediately follows another chunk of the same type. Otherwise, the first token of a chunk uses the I prefix. This might seem like an odd design choice, but it minimizes the number of distinct labels needed when adjacent same-type chunks are rare.

Consider two adjacent NPs: "John" followed immediately by "Mary" with no separator:

In[22]:
Code
# Compare IOB1 and IOB2 for adjacent chunks of the same type
example_tokens = ["John", "Mary", "left"]
gold_chunks = [(0, 1, "NP"), (1, 2, "NP"), (2, 3, "VP")]

# IOB2: B always marks the first token of a chunk
iob2_tags = chunks_to_iob(example_tokens, gold_chunks)

# IOB1: B only used when same-type chunk follows same-type chunk
# Adjacent NPs: first NP uses I (since nothing precedes it of same type yet)
# Second NP uses B (because it follows an NP)
iob1_tags = ["I-NP", "B-NP", "I-VP"]  # IOB1 encoding
Out[23]:
Console
IOB1 vs. IOB2 Comparison
==================================================

Sentence: John Mary left
Chunks: [John]NP [Mary]NP [left]VP (two adjacent NPs)

Token    IOB2       IOB1       Difference
--------------------------------------------------
John     B-NP       I-NP       DIFFER
Mary     B-NP       B-NP       
left     B-VP       I-VP       DIFFER

Key insight:
  IOB2: B always marks chunk start (simpler, more consistent)
  IOB1: B only used to disambiguate adjacent same-type chunks
  Modern practice: use IOB2

IOB2 is strictly easier to work with. Every B tag means "new chunk starts here," regardless of context. For a sequence labeling model learning from data, IOB2 provides cleaner, more consistent targets. If you encounter IOB1-tagged data (some older CoNLL datasets use it), convert to IOB2 before training.

Regex-Based Chunking with NLTK

NLTK provides a RegexpParser that lets you define chunk patterns using regular expressions over POS tags. This approach is intuitive: you specify what sequences of POS tags should be grouped into a chunk, and the parser applies those rules to POS-tagged input.

The grammar uses angle brackets around POS tags and standard regex quantifiers:

  • <DT> matches a determiner token
  • <JJ>* matches zero or more adjectives
  • <NN.*>+ matches one or more nouns (NN, NNS, NNP, NNPS)
  • <DT|PRP$> matches either a determiner or a possessive pronoun
In[24]:
Code
from nltk import RegexpParser

# Define a grammar for NP, VP, and PP chunking
np_grammar = r"""
    NP: {<DT|PRP\$>?<JJ>*<NN.*>+}
    VP: {<MD>?<VB.*>+}
    PP: {<IN>}
"""

# Create the parser
chunker = RegexpParser(np_grammar)


# A helper to extract chunks from NLTK trees
def tree_to_chunks(tree):
    """Extract (chunk_type, text) pairs from an NLTK parse tree."""
    chunks = []
    for subtree in tree:
        if hasattr(subtree, "label"):
            chunk_type = subtree.label()
            words = " ".join(word for word, _ in subtree.leaves())
            chunks.append((chunk_type, words))
    return chunks


# Test on several sentences
test_sentences = [
    "The quick brown fox jumps over the lazy dog.",
    "A beautiful sunset illuminated the entire valley.",
    "My old computer finally crashed yesterday.",
    "Scientists discovered a new species in the deep ocean.",
]

regex_results = []
for sent in test_sentences:
    tokens = word_tokenize(sent)
    tagged = pos_tag(tokens)
    tree = chunker.parse(tagged)
    chunks = tree_to_chunks(tree)
    regex_results.append((sent, tagged, chunks))
Out[25]:
Console
Regex-Based Chunking Results
=================================================================

Sentence: The quick brown fox jumps over the lazy dog.
  POS: The/DT quick/JJ brown/NN fox/NN jumps/VBZ over/IN...
  Chunks:
    [The quick brown fox] -> NP
    [jumps] -> VP
    [over] -> PP
    [the lazy dog] -> NP

Sentence: A beautiful sunset illuminated the entire valley.
  POS: A/DT beautiful/JJ sunset/NN illuminated/VBD the/DT entire/JJ...
  Chunks:
    [A beautiful sunset] -> NP
    [illuminated] -> VP
    [the entire valley] -> NP

Sentence: My old computer finally crashed yesterday.
  POS: My/PRP$ old/JJ computer/NN finally/RB crashed/VBN yesterday/NN...
  Chunks:
    [My old computer] -> NP
    [crashed] -> VP
    [yesterday] -> NP

Sentence: Scientists discovered a new species in the deep ocean.
  POS: Scientists/NNS discovered/VBD a/DT new/JJ species/NNS in/IN...
  Chunks:
    [Scientists] -> NP
    [discovered] -> VP
    [a new species] -> NP
    [in] -> PP
    [the deep ocean] -> NP

Let's break down exactly what the NP grammar pattern matches:

In[26]:
Code
# Breakdown of the NP pattern components
np_pattern_breakdown = {
    "<DT|PRP\\$>?": "Optional: determiner (the, a, an) OR possessive pronoun (my, your, its)",
    "<JJ>*": "Zero or more adjectives (modifiers before the noun)",
    "<NN.*>+": "One or more nouns: NN (singular), NNS (plural), NNP (proper), NNPS (proper plural)",
}

regex_quantifiers = {
    "?": "zero or one (optional element)",
    "*": "zero or more (any number)",
    "+": "one or more (at least one required)",
    "|": "alternation (either/or)",
}
Out[27]:
Console
NP Pattern Breakdown: {<DT|PRP\$>?<JJ>*<NN.*>+}
============================================================

  <DT|PRP\$>?
    Optional: determiner (the, a, an) OR possessive pronoun (my, your, its)

  <JJ>*
    Zero or more adjectives (modifiers before the noun)

  <NN.*>+
    One or more nouns: NN (singular), NNS (plural), NNP (proper), NNPS (proper plural)


Regex Quantifiers in NLTK Grammar:
----------------------------------------
  ?    zero or one (optional element)
  *    zero or more (any number)
  +    one or more (at least one required)
  |    alternation (either/or)

Chinking: Excluding Tokens from Chunks

Sometimes it's easier to specify what should NOT be in a chunk than what should be. Chinking removes tokens from existing chunks using the }{ syntax. You first chunk everything with {<.*>+}, then cut out the unwanted tokens.

In[28]:
Code
# Chinking grammar:
# Step 1: {<.*>+} chunks every token into one big NP
# Step 2: }<VB.*|IN|CC|\\.>{ removes (chinks) verbs, prepositions,
#         conjunctions, and punctuation from chunks
chink_grammar = r"""
    NP: {<.*>+}
        }<VB.*|IN|CC|\.>{
"""

chink_chunker = RegexpParser(chink_grammar)

chink_sentences = [
    "The cat and the dog sat on the mat.",
    "Alice ran quickly through the dark forest.",
]

chink_results = []
for sent in chink_sentences:
    tokens = word_tokenize(sent)
    tagged = pos_tag(tokens)
    tree = chink_chunker.parse(tagged)
    chunks = tree_to_chunks(tree)
    chink_results.append((sent, tagged, chunks))
Out[29]:
Console
Chinking Examples
============================================================

Strategy: chunk everything, then remove verbs/prepositions/conjunctions

Sentence: The cat and the dog sat on the mat.
  POS: The/DT cat/NN and/CC the/DT dog/NN sat/VBD on/IN the/DT mat/NN ./.
  Resulting NP chunks:
    [The cat] -> NP
    [the dog] -> NP
    [the mat] -> NP

Sentence: Alice ran quickly through the dark forest.
  POS: Alice/NNP ran/VBD quickly/RB through/IN the/DT dark/JJ forest/NN ./.
  Resulting NP chunks:
    [Alice] -> NP
    [quickly] -> NP
    [the dark forest] -> NP

The chinking approach inverts the chunking logic. Instead of specifying which POS sequences form a chunk, you specify which POS tags break a chunk. This works well when chunks are mostly contiguous sequences of "content words" interrupted by functional words.

Limitations of Regex Chunking

Regex-based chunking is simple and fast but has structural limitations that matter in practice. The patterns match local POS sequences without access to broader context, making several phenomena difficult:

In[30]:
Code
# Cases where regex chunking struggles
hard_cases = [
    {
        "sentence": "The man I saw yesterday left.",
        "issue": "Relative clause ('I saw yesterday') interrupts the NP",
        "expected_np": "The man",
        "regex_np": "The man I saw",  # incorrectly groups relative clause
    },
    {
        "sentence": "The old man the boats.",
        "issue": "Garden path: 'man' is a verb here, not a noun",
        "expected_vp": "man",
        "regex_problem": "POS tagger likely tags 'man' as NN",
    },
    {
        "sentence": "Flying planes can be dangerous.",
        "issue": "Attachment ambiguity: 'flying' modifies 'planes' or starts a VP",
        "expected": "Ambiguous without context",
    },
]

hard_results = []
for case in hard_cases:
    tokens = word_tokenize(case["sentence"])
    tagged = pos_tag(tokens)
    tree = chunker.parse(tagged)
    found_chunks = tree_to_chunks(tree)
    hard_results.append((case, tagged, found_chunks))
Out[31]:
Console
Regex Chunking Limitations
=================================================================

Sentence: The man I saw yesterday left.
  Issue: Relative clause ('I saw yesterday') interrupts the NP
  POS: The/DT man/NN I/PRP saw/VBD yesterday/NN left/VBD ./.
  Chunks found by regex:
    [The man] -> NP
    [saw] -> VP
    [yesterday] -> NP
    [left] -> VP

Sentence: The old man the boats.
  Issue: Garden path: 'man' is a verb here, not a noun
  POS: The/DT old/JJ man/NN the/DT boats/NNS ./.
  Chunks found by regex:
    [The old man] -> NP
    [the boats] -> NP

Sentence: Flying planes can be dangerous.
  Issue: Attachment ambiguity: 'flying' modifies 'planes' or starts a VP
  POS: Flying/VBG planes/NNS can/MD be/VB dangerous/JJ ./.
  Chunks found by regex:
    [Flying] -> VP
    [planes] -> NP
    [can be] -> VP

The fundamental limitation is that regex patterns operate only on local POS sequences. They cannot condition on distant context, handle long-range dependencies, or recover from POS tagging errors. Machine learning approaches address these limitations by learning from data.

Using the CoNLL-2000 Dataset

The CoNLL-2000 shared task established the standard benchmark for chunking. It uses a portion of the Penn Treebank Wall Street Journal text, annotated with POS tags and IOB chunk labels for three chunk types: NP, VP, and PP.

In[32]:
Code
from nltk.corpus import conll2000

# Load training and test data
train_sents = conll2000.chunked_sents(
    "train.txt", chunk_types=["NP", "VP", "PP"]
)
test_sents = conll2000.chunked_sents("test.txt", chunk_types=["NP", "VP", "PP"])

# Examine the first sentence
sample_sent = train_sents[0]
Out[33]:
Console
CoNLL-2000 Dataset Overview
==================================================

Training sentences: 8,936
Test sentences:     2,012


Sample sentence (NLTK Tree format):
(S
  (NP Confidence/NN)
  (PP in/IN)
  (NP the/DT pound/NN)
  (VP is/VBZ widely/RB expected/VBN to/TO take/VB)
  (NP another/DT sharp/JJ dive/NN)
  if/IN
  (NP trade/NN figures/NNS)
  (PP for/IN)
  (NP September/NNP)
  ,/,
  due/JJ
  (PP for/IN)
  (NP release/NN)
  (NP tomorrow/NN)
  ,/,
  (VP fail/VB to/TO show/VB)
  (NP a/DT substantial/JJ improvement/NN)
  (PP from/IN)
  (NP July/NNP and/CC August/NNP)
  (NP 's/POS near-record/JJ deficits/NNS)
  ./.)

Chunk type frequency (first 1,000 training sentences):
  NP: 6,211
  VP: 2,399
  PP: 2,397

Converting CoNLL Data to IOB Format

For machine learning approaches, we need the data in (word, POS, IOB) triple format. This function converts NLTK parse trees into that representation:

In[34]:
Code
def tree_to_iob_triples(tree):
    """Convert an NLTK chunk tree to (word, pos, iob) triples."""
    triples = []
    for subtree in tree:
        if hasattr(subtree, "label"):
            chunk_type = subtree.label()
            for i, (word, pos) in enumerate(subtree.leaves()):
                iob = f"B-{chunk_type}" if i == 0 else f"I-{chunk_type}"
                triples.append((word, pos, iob))
        else:
            # Single token outside any chunk
            word, pos = subtree
            triples.append((word, pos, "O"))
    return triples


# Convert the sample sentence
sample_iob = tree_to_iob_triples(sample_sent)
Out[35]:
Console
IOB Triple Format (word, POS, IOB)
==================================================

  Word             POS      IOB
  --------------------------------------
  Confidence       NN       B-NP
  in               IN       B-PP
  the              DT       B-NP
  pound            NN       I-NP
  is               VBZ      B-VP
  widely           RB       I-VP
  expected         VBN      I-VP
  to               TO       I-VP
  take             VB       I-VP
  another          DT       B-NP
  sharp            JJ       I-NP
  dive             NN       I-NP
  if               IN       O
  trade            NN       B-NP
  figures          NNS      I-NP
  ... (22 more tokens)

Evaluating Chunkers

Chunking evaluation follows the same precision-recall-F1 framework used elsewhere in NLP, but applied at the chunk level rather than the token level. A predicted chunk is correct only if it exactly matches a gold chunk in both its boundaries and its type.

Chunk-Level Evaluation

Chunking evaluation counts a predicted chunk as correct only when it exactly matches a gold chunk in both span (start and end positions) and type (NP, VP, PP). A prediction that captures the right words but assigns the wrong type, or gets the right type but with an off-by-one boundary, receives no credit. There is no partial credit.

This strict metric has an important consequence: precision and recall trade off differently than in token-level evaluation. A chunker that consistently overshoots chunk boundaries will have high recall (it covers the gold span) but lower precision (it includes extra tokens). A chunker that only identifies the core noun in every NP will have high precision but low recall.

In[36]:
Code
def evaluate_chunker(chunker_obj, test_trees, max_sents=500):
    """
    Evaluate a chunker at the chunk level.
    Returns precision, recall, F1, and counts.
    """
    tp = fp = fn = 0

    for tree in test_trees[:max_sents]:
        # Gold chunks: set of (type, word_tuple) pairs
        gold = set()
        for subtree in tree:
            if hasattr(subtree, "label"):
                words = tuple(w for w, _ in subtree.leaves())
                gold.add((subtree.label(), words))

        # Predicted chunks
        tagged = list(tree.leaves())
        pred_tree = chunker_obj.parse(tagged)
        pred = set()
        for subtree in pred_tree:
            if hasattr(subtree, "label"):
                words = tuple(w for w, _ in subtree.leaves())
                pred.add((subtree.label(), words))

        tp += len(gold & pred)
        fp += len(pred - gold)
        fn += len(gold - pred)

    precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
    f1 = (
        2 * precision * recall / (precision + recall)
        if (precision + recall) > 0
        else 0.0
    )

    return {
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "tp": tp,
        "fp": fp,
        "fn": fn,
    }


# Evaluate the regex chunker defined earlier
regex_eval = evaluate_chunker(chunker, test_sents)
Out[37]:
Console
Regex Chunker Evaluation (on 500 test sentences)
==================================================

  Precision: 75.75%
  Recall:    72.29%
  F1 Score:  73.98%

  True Positives:  3,699
  False Positives: 1,184
  False Negatives: 1,418

The F1 score for the regex chunker on CoNLL-2000 typically falls in the 50-70% range, depending on how many constructions the grammar covers. Machine learning approaches push this to 90%+.

Training a Statistical Chunker

Machine learning chunkers learn which IOB tags to assign from annotated examples. Even simple statistical approaches that model the probability of an IOB tag given the current POS tag outperform hand-crafted regex rules.

The simplest statistical model is a unigram tagger: for each POS tag, predict the IOB label that most often followed it in training data.

In[38]:
Code
from nltk.chunk import ChunkParserI
from nltk.tag import BigramTagger, UnigramTagger


class UnigramChunker(ChunkParserI):
    """
    Chunker using a unigram model over (POS -> IOB) mappings.
    For each POS tag, predicts the most frequent IOB label seen in training.
    """

    def __init__(self, train_sents):
        # Convert each tree to (pos, iob) pairs for tagger training
        train_data = []
        for tree in train_sents:
            triples = tree_to_iob_triples(tree)
            train_data.append([(pos, iob) for _, pos, iob in triples])
        self.tagger = UnigramTagger(train_data)

    def parse(self, tagged_sent):
        """Assign IOB labels to a POS-tagged sentence."""
        pos_tags = [pos for _, pos in tagged_sent]
        iob_preds = [self.tagger.tag([pos])[0][1] or "O" for pos in pos_tags]
        iob_triples = [
            (w, p, iob) for (w, p), iob in zip(tagged_sent, iob_preds)
        ]
        return nltk.chunk.conlltags2tree(iob_triples)


class BigramChunker(ChunkParserI):
    """
    Chunker using a bigram model that conditions on the previous POS tag.
    Falls back to unigram for unseen bigrams.
    """

    def __init__(self, train_sents):
        train_data = []
        for tree in train_sents:
            triples = tree_to_iob_triples(tree)
            train_data.append([(pos, iob) for _, pos, iob in triples])
        unigram = UnigramTagger(train_data)
        self.tagger = BigramTagger(train_data, backoff=unigram)

    def parse(self, tagged_sent):
        pos_tags = [pos for _, pos in tagged_sent]
        tagged_preds = self.tagger.tag(pos_tags)
        iob_preds = [iob if iob else "O" for _, iob in tagged_preds]
        iob_triples = [
            (w, p, iob) for (w, p), iob in zip(tagged_sent, iob_preds)
        ]
        return nltk.chunk.conlltags2tree(iob_triples)


# Train both models
unigram_chunker = UnigramChunker(train_sents)
bigram_chunker = BigramChunker(train_sents)

# Evaluate
unigram_eval = evaluate_chunker(unigram_chunker, test_sents)
bigram_eval = evaluate_chunker(bigram_chunker, test_sents)
Out[39]:
Console
Statistical Chunker Evaluation Comparison
=======================================================

  Model      Precision    Recall     F1      
  --------------------------------------------
  Regex          75.75%     72.29%   73.98%
  Unigram        76.78%     87.77%   81.91%
  Bigram         81.62%     86.97%   84.21%
Out[40]:
Visualization
Grouped bar chart comparing precision, recall, and F1 for regex, unigram, and bigram chunkers.
Precision, recall, and F1 scores for three chunking approaches on the CoNLL-2000 test set. The regex chunker uses hand-crafted POS patterns. The unigram and bigram statistical chunkers learn IOB label distributions from training data. Both statistical models improve substantially over the hand-crafted baseline, with the bigram model gaining additional accuracy by conditioning on neighboring POS context.

The unigram model's improvement over regex is substantial. By learning from thousands of annotated examples, it discovers patterns that hand-crafted rules miss. The bigram model improves further by conditioning on the previous POS tag, capturing sequential patterns like "after a determiner, a noun is likely inside an NP."

Production systems typically use CRF (Conditional Random Field) models, which we cover in the Conditional Random Fields chapter. CRFs can condition on arbitrary features and optimize global sequence consistency, pushing CoNLL-2000 F1 to the low 90s.

Chunking as Preprocessing

Chunking is a preprocessing step for several NLP tasks. By identifying phrase boundaries, it provides lightweight structural annotations that simplify later processing.

Information Extraction

The most direct application is extracting structured information from text. A common pattern is identifying subject-verb-object triples by looking for NP-VP-NP sequences:

In[41]:
Code
def extract_svo_triples(sentence, chunker_obj):
    """
    Extract subject-verb-object triples using chunk sequences.
    Looks for NP-VP-NP patterns in the chunk sequence.
    """
    tokens = word_tokenize(sentence)
    tagged = pos_tag(tokens)
    tree = chunker_obj.parse(tagged)

    ordered_chunks = []
    for subtree in tree:
        if hasattr(subtree, "label"):
            text = " ".join(w for w, _ in subtree.leaves())
            ordered_chunks.append((subtree.label(), text))

    triples = []
    for i in range(len(ordered_chunks) - 2):
        if (
            ordered_chunks[i][0] == "NP"
            and ordered_chunks[i + 1][0] == "VP"
            and ordered_chunks[i + 2][0] == "NP"
        ):
            triples.append(
                {
                    "subject": ordered_chunks[i][1],
                    "verb": ordered_chunks[i + 1][1],
                    "object": ordered_chunks[i + 2][1],
                }
            )

    return triples


# Test on news-style sentences
ie_sentences = [
    "The researchers published a landmark study.",
    "The company acquired its largest competitor.",
    "Scientists identified a new protein in the brain.",
    "The president signed the bill into law.",
]

ie_results = [(s, extract_svo_triples(s, chunker)) for s in ie_sentences]
Out[42]:
Console
Subject-Verb-Object Extraction via Chunking
============================================================

Sentence: The researchers published a landmark study.
  Subject: The researchers
  Verb:    published
  Object:  a landmark study

Sentence: The company acquired its largest competitor.
  Subject: The company
  Verb:    acquired
  Object:  competitor

Sentence: Scientists identified a new protein in the brain.
  Subject: Scientists
  Verb:    identified
  Object:  a new protein

Sentence: The president signed the bill into law.
  Subject: The president
  Verb:    signed
  Object:  the bill

Noun Phrase Keyword Extraction

Noun phrases often contain the most semantically meaningful content in a text. Extracting and counting them provides a simple keyword extraction method:

In[43]:
Code
from collections import Counter


def extract_keywords_via_np_chunking(text, top_n=10):
    """
    Extract noun phrase keywords from text using chunking.
    Returns the most frequent multi-word NPs.
    """
    sentences = nltk.sent_tokenize(text)
    all_nps = []

    for sent in sentences:
        tokens = word_tokenize(sent)
        tagged = pos_tag(tokens)
        tree = chunker.parse(tagged)
        for subtree in tree:
            if hasattr(subtree, "label") and subtree.label() == "NP":
                np_text = " ".join(w.lower() for w, _ in subtree.leaves())
                # Only keep multi-word NPs (more specific)
                if len(subtree.leaves()) > 1:
                    all_nps.append(np_text)

    return Counter(all_nps).most_common(top_n)


# Apply to a sample technology text
tech_text = """
Machine learning has transformed natural language processing research.
Deep learning models achieve remarkable performance on many language tasks.
Large language models like GPT and BERT have redefined the state of the art.
Transformer architectures use attention mechanisms to capture long-range dependencies.
Researchers continue to explore new training methods and model architectures.
Language models learn rich text representations from massive training corpora.
"""

keywords = extract_keywords_via_np_chunking(tech_text)
Out[44]:
Console
Noun Phrase Keyword Extraction
==================================================

Text excerpt:
  'Machine learning has transformed natural language processing research.
Deep lear...'

Top multi-word noun phrase keywords:
   1. 'machine learning' (1 occurrence)
   2. 'natural language processing research' (1 occurrence)
   3. 'deep learning models' (1 occurrence)
   4. 'remarkable performance' (1 occurrence)
   5. 'many language tasks' (1 occurrence)
   6. 'large language models' (1 occurrence)
   7. 'the state' (1 occurrence)
   8. 'the art' (1 occurrence)
   9. 'transformer architectures' (1 occurrence)
  10. 'attention mechanisms' (1 occurrence)

This keyword extraction approach is simple but captures meaningful phrases that single-word methods miss. "Large language models" and "natural language processing" are more informative keywords than "models" or "language" alone.

Answer Candidate Extraction for Question Answering

In rule-based question answering, chunking helps identify candidate answer spans. Different question types suggest different chunk types as likely answers:

In[45]:
Code
def find_answer_candidates(question, context_sentence, chunker_obj):
    """
    Identify likely answer spans based on question type and chunking.
    """
    q_lower = question.lower()

    # Map question words to likely answer chunk types
    if q_lower.startswith("who"):
        target_type = "NP"  # Named person = noun phrase
    elif q_lower.startswith("where"):
        target_type = "PP"  # Location = prepositional phrase
    elif q_lower.startswith("what"):
        target_type = "NP"  # Thing = noun phrase
    elif q_lower.startswith("when"):
        target_type = "NP"  # Time expressions are often NPs
    else:
        target_type = "NP"

    tokens = word_tokenize(context_sentence)
    tagged = pos_tag(tokens)
    tree = chunker_obj.parse(tagged)

    candidates = []
    for subtree in tree:
        if hasattr(subtree, "label") and subtree.label() == target_type:
            candidates.append(" ".join(w for w, _ in subtree.leaves()))
    return candidates


# Example QA preprocessing
qa_examples = [
    (
        "Who wrote the novel?",
        "The famous author Ernest Hemingway wrote the novel in 1929.",
    ),
    (
        "What did scientists discover?",
        "Scientists announced a new vaccine for the disease.",
    ),
    (
        "Where did the event occur?",
        "The summit took place in the heart of Geneva.",
    ),
]

qa_results = [
    (q, c, find_answer_candidates(q, c, chunker)) for q, c in qa_examples
]
Out[46]:
Console
Answer Candidate Extraction via Chunking
============================================================

Question: Who wrote the novel?
Context:  The famous author Ernest Hemingway wrote the novel in 1929.
Candidates:
  - 'The famous author Ernest Hemingway'
  - 'the novel'

Question: What did scientists discover?
Context:  Scientists announced a new vaccine for the disease.
Candidates:
  - 'Scientists'
  - 'a new vaccine'
  - 'the disease'

Question: Where did the event occur?
Context:  The summit took place in the heart of Geneva.
Candidates:
  - 'in'
  - 'of'

Chunking-based candidate extraction predates neural QA systems but remains useful in low-resource settings and as a fast preprocessing step. The candidates narrow the search space before more expensive processing.

Chunking with spaCy

spaCy provides noun phrase chunking through its noun_chunks property. Unlike NLTK's regex-based approach, spaCy derives chunks from its dependency parse. This gives it access to syntactic relations like subject and object, producing more linguistically accurate chunks.

In[47]:
Code
import spacy

try:
    nlp = spacy.load("en_core_web_sm")
except OSError:
    import subprocess

    subprocess.run(
        ["python", "-m", "spacy", "download", "en_core_web_sm"],
        capture_output=True,
    )
    nlp = spacy.load("en_core_web_sm")

# Example sentences that test different NP structures
spacy_sentences = [
    "The quick brown fox jumped over the lazy dog.",
    "A major breakthrough in artificial intelligence was announced yesterday.",
    "The President of the United States addressed the nation.",
    "Machine learning models trained on large corpora show impressive results.",
]

spacy_results = []
for sent in spacy_sentences:
    doc = nlp(sent)
    chunks = [
        (chunk.text, chunk.root.text, chunk.root.dep_)
        for chunk in doc.noun_chunks
    ]
    spacy_results.append((sent, chunks))
Out[48]:
Console
spaCy Noun Chunk Extraction
=================================================================

Sentence: The quick brown fox jumped over the lazy dog.
  Noun chunks:
    'The quick brown fox'
      Root: 'fox' | Dependency role: nsubj
    'the lazy dog'
      Root: 'dog' | Dependency role: pobj

Sentence: A major breakthrough in artificial intelligence was announced yesterday.
  Noun chunks:
    'A major breakthrough'
      Root: 'breakthrough' | Dependency role: nsubjpass
    'artificial intelligence'
      Root: 'intelligence' | Dependency role: pobj

Sentence: The President of the United States addressed the nation.
  Noun chunks:
    'The President'
      Root: 'President' | Dependency role: nsubj
    'the United States'
      Root: 'States' | Dependency role: pobj
    'the nation'
      Root: 'nation' | Dependency role: dobj

Sentence: Machine learning models trained on large corpora show impressive results.
  Noun chunks:
    'Machine learning models'
      Root: 'models' | Dependency role: nsubj
    'large corpora'
      Root: 'corpora' | Dependency role: pobj
    'impressive results'
      Root: 'results' | Dependency role: dobj

spaCy's chunks carry more information than regex chunks. The root attribute identifies the head noun of the phrase, and dep_ identifies its syntactic role (nsubj for subject, dobj for direct object, pobj for object of a preposition). This makes spaCy's noun chunks particularly useful for information extraction and relation detection.

spaCy does not expose VP or PP chunks through a simple property because its dependency-based analysis handles those structures differently. If you need all three chunk types, NLTK's regex or statistical chunker remains the more direct tool.

Limitations and Practical Considerations

Chunking's flat, non-recursive structure is both its strength and its weakness. Avoiding recursive structure makes chunking fast and accurate, but it cannot represent certain linguistic phenomena.

Consider "The student who failed the exam requested a meeting." The subject NP is "The student who failed the exam," but it contains an embedded relative clause. Flat chunking either identifies "The student" as the NP (missing the modifier) or produces an NP that incorrectly includes "who failed the exam" as if it were an adjective phrase. When your downstream task needs the full extent of an NP including its modifiers, or when it needs to distinguish the head noun from embedded clauses, you need a deeper analysis than chunking provides.

Chunking accuracy depends heavily on POS tagging accuracy. Every POS error propagates directly into chunking errors. This is particularly problematic for domain-specific text, where a POS tagger trained on news data may struggle with unfamiliar vocabulary. Medical documents, legal text, and social media all challenge standard taggers in ways that compound into chunking errors. When deploying chunkers on specialized domains, retraining or fine-tuning the underlying POS tagger is usually necessary before the chunker can perform well.

Annotation inconsistency also creates practical challenges. Should "the very best coffee" be one NP or should "very best" be a separate ADJP? Should "more than one hundred" be an NP? Different annotation guidelines make different choices. The CoNLL-2000 benchmark uses Penn Treebank conventions, but other datasets follow different schemes. When comparing chunking systems or combining chunked output from different sources. This ensures annotation consistency is a prerequisite.

Despite these limitations, chunking remains practically useful. For applications that need phrase boundaries without full syntactic analysis, such as information extraction, keyword identification, and text summarization, chunking offers an efficient and reasonably accurate solution. Its speed advantage over full parsing is substantial, and it avoids the cascading errors that full parsers can produce on difficult sentences. In production NLP pipelines, chunking often provides the best cost-benefit tradeoff when full parsing would be overkill.

Summary

Chunking identifies non-overlapping, non-recursive phrase segments in text. This provides a useful middle ground between POS tagging and full syntactic parsing. The key concepts from this chapter are:

  • Chunk types include noun phrases (NP), verb phrases (VP), and prepositional phrases (PP). NP chunking is the most widely studied and practically applied, capturing the noun-headed phrases that carry most of the content in a sentence.
  • IOB tagging encodes chunk boundaries as per-token labels: B marks the first token of a chunk, I marks continuation tokens, and O marks tokens outside all chunks. IOB2, where B always marks the first token of every chunk, is the modern standard and is simpler to work with than the original IOB1 variant.
  • Chunking vs. parsing represents a deliberate tradeoff: chunking is faster and avoids hard attachment ambiguities that require semantic knowledge to resolve. Full parsing captures complete hierarchical structure but is slower and more error-prone.
  • Regex-based chunking with NLTK's RegexpParser defines patterns over POS tag sequences. It is intuitive and transparent but limited to local patterns and sensitive to POS errors. Chinking (the }{ syntax) provides an alternative by specifying what should be excluded from chunks.
  • Statistical chunkers learn IOB label distributions from annotated data. Even simple unigram and bigram models trained on CoNLL-2000 substantially outperform hand-crafted rules. Production systems use CRF models that condition on arbitrary features and optimize global sequence consistency.
  • Practical applications include subject-verb-object extraction, noun phrase keyword mining, and answer candidate identification for question answering. Chunking provides lightweight structural annotations that simplify downstream processing without the complexity of full parsing.

Key Parameters

The key parameters and syntax for chunking tools are:

NLTK RegexpParser grammar syntax:

  • {<pattern>}: Include matching tokens in a chunk (chunking)
  • }<pattern>{: Remove matching tokens from a chunk (chinking)
  • <DT|PRP$>: Match either DT or PRP$ POS tags (alternation)
  • <JJ>*: Zero or more JJ tokens (Kleene star)
  • <NN.*>+: One or more tokens whose POS starts with NN (plus quantifier with wildcard)

CoNLL-2000 corpus loading:

  • chunk_types=["NP", "VP", "PP"]: Specify which chunk types to retain
  • chunked_sents("train.txt"): Load as NLTK parse trees
  • tree_to_iob_triples(tree): Convert trees to (word, POS, IOB) triples for ML training

spaCy noun chunks:

  • doc.noun_chunks: Iterate over all noun phrases in a document
  • chunk.root: The head noun of the chunk
  • chunk.root.dep_: The syntactic dependency role of the head noun

The next chapters explore the probabilistic sequence models that power production-quality chunkers and other sequence labeling systems, starting with Hidden Markov Models.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about chunking and shallow parsing.

Chunking Quiz

Question 1 of 80 of 8 completed
What is the main difference between chunking and full syntactic parsing?

Comments

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

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025chunkingshallow, author = {Michael Brenndoerfer}, title = {Chunking: Shallow Parsing for Phrase Identification in NLP}, year = {2025}, url = {https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-15} }
APAAcademic
Michael Brenndoerfer (2025). Chunking: Shallow Parsing for Phrase Identification in NLP. Retrieved from https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp
MLAAcademic
Michael Brenndoerfer. "Chunking: Shallow Parsing for Phrase Identification in NLP." 2026. Web. September 15, 2026. <https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp>.
CHICAGOAcademic
Michael Brenndoerfer. "Chunking: Shallow Parsing for Phrase Identification in NLP." Accessed September 15, 2026. https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp.
HARVARDAcademic
Michael Brenndoerfer (2025) 'Chunking: Shallow Parsing for Phrase Identification in NLP'. Available at: https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp (Accessed: September 15, 2026).
SimpleBasic
Michael Brenndoerfer (2025). Chunking: Shallow Parsing for Phrase Identification in NLP. https://mbrenndoerfer.com/writing/chunking-shallow-parsing-nlp

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.