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 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], noun phrase (subject)
- [sat], verb phrase (predicate)
- [on], prepositional phrase marker
- [the mat], 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:
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)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.
# 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)
]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 NNThe 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.
# 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)
]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 participlePrepositional 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.
# 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)"),
]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:
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"),
}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, alasThe 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 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:
# 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)
]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

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:
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)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.
# 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"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

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:
# 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 encodingIOB1 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
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))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] -> NPLet's break down exactly what the NP grammar pattern matches:
# 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)",
}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.
# 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))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] -> NPThe 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:
# 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))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] -> VPThe 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.
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]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:
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)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.
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.
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)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.
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)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%

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:
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]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:
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)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:
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
]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.
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))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: dobjspaCy'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
RegexpParserdefines 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 retainchunked_sents("train.txt"): Load as NLTK parse treestree_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 documentchunk.root: The head noun of the chunkchunk.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
Reference
Citation details
Cite or share this article.
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 HandbookStay up to date
Get articles, book updates, and news delivered to your inbox.
No spam, unsubscribe anytime.
Join the community
Sign in to remove popups, track your reading progress, and join the discussion.

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