Grammars and Constituency Structure › Code Implementation

Michael BrenndoerferMay 4, 20258 min read
Language AI HandbookAll chapters
Part 7 of 67Linguistic Form: Morphology and Syntax
Chapter 46Grammars and Constituency Structure › Code Implementation
Reading section 8 of 11 · Code Implementation · 8 minSection 8 of 11 · 8 min

Section 8 of 11 · 8 min read

Grammars and Constituency Structure › Code ImplementationSection view · Sections in this chapter8 / 11 ↓
Reading tools Definitions, links & notes

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

Notes & comments

We will build a small CKY parser from scratch. The grammar is already in CNF, so we can use it directly. We will then add a simple PCFG scorer and show how the number of possible binary bracketings grows. The implementation is deliberately small. It is meant to make the recurrence concrete, not to compete with a production parser. The core parsing and scoring operations follow the definitions from the previous sections.

The parser and scorer use the Python standard library; NumPy and Matplotlib support the figures. No corpus or model downloads are needed. This keeps the algorithm visible while the small grammar makes every result inspectable.

Representing Trees

We start with a small Tree class. A tree has a label and a list of children. A child is either another Tree or a terminal string. The class provides three useful views: the yield, an indented pretty print, and a bracketed string. These views correspond to the three ways we have been talking about trees: as token sequences, as hierarchical diagrams, and as compact structural notation.

PythonIn [7]
CollapseExpand
import math
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Union


@dataclass
class Tree:
    label: str
    children: List[Union["Tree", str]] = field(default_factory=list)

    def leaf_tokens(self) -> List[str]:
        tokens = []
        for child in self.children:
            if isinstance(child, Tree):
                tokens.extend(child.leaf_tokens())
            else:
                tokens.append(child)
        return tokens

    def pretty(self, indent: int = 0) -> str:
        pad = "  " * indent
        lines = [f"{pad}{self.label}"]
        for child in self.children:
            if isinstance(child, Tree):
                lines.append(child.pretty(indent + 1))
            else:
                lines.append(f"{'  ' * (indent + 1)}{child}")
        return "\n".join(lines)

    def bracket(self) -> str:
        parts = []
        for child in self.children:
            parts.append(child.bracket() if isinstance(child, Tree) else child)
        return f"({self.label} {' '.join(parts)})"

The leaf_tokens method recovers the yield of a tree. The pretty method prints an indented tree. The bracket method produces the compact bracketed notation we used earlier. Keeping all three views in one class makes it easy to check that every tree has the expected yield.

Defining the Grammar

Each rule is a pair: a left-hand-side nonterminal and a tuple of right-hand-side symbols. Lexical rules have a single terminal on the right. This representation is simple, but it is enough to support the CKY recurrence. The grammar is small on purpose, so every rule can be inspected by eye.

PythonIn [8]
CollapseExpand
Rule = Tuple[str, Tuple[str, ...]]

rules: List[Rule] = [
    ("S", ("NP", "VP")),
    ("VP", ("V", "NP")),
    ("VP", ("VP", "PP")),
    ("NP", ("Det", "N")),
    ("NP", ("NP", "PP")),
    ("PP", ("P", "NP")),
    ("Det", ("the",)),
    ("Det", ("a",)),
    ("N", ("dog",)),
    ("N", ("cat",)),
    ("N", ("telescope",)),
    ("V", ("chased",)),
    ("P", ("with",)),
]

Let's check the size and shape of the grammar.

ConsoleOut [9]
CollapseExpand
Grammar has 13 rules
Binary nonterminal rules: 6
Lexical rules: 7

The grammar has 13 rules: 6 binary rules and 7 lexical rules. Every rule is in Chomsky Normal Form, so CKY can use it without any conversion step. The binary rules build phrases, and the lexical rules connect terminals to preterminal categories.

Implementing CKY

The parser fills a chart indexed by spans. Each chart cell maps a nonterminal to a list of trees for that span. Keeping the trees, rather than just the nonterminals, lets us inspect every parse. This version explicitly enumerates parse trees. Its time and storage can therefore grow exponentially with ambiguity, unlike the membership-only CKY bounds above. We use only a tiny sentence with two parses so the full trees remain practical to inspect.

PythonIn [10]
CollapseExpand
def cky_parse(words: List[str], grammar: List[Rule]):
    n = len(words)
    chart = [[{} for _ in range(n + 1)] for _ in range(n + 1)]

    nonterminals = {lhs for lhs, _ in grammar}
    lexical: Dict[str, List[str]] = {}
    binary: Dict[Tuple[str, str], List[str]] = {}

    for lhs, rhs in grammar:
        if len(rhs) == 1 and rhs[0] not in nonterminals:
            lexical.setdefault(rhs[0], []).append(lhs)
        elif len(rhs) == 2:
            binary.setdefault((rhs[0], rhs[1]), []).append(lhs)

    for i, word in enumerate(words):
        for lhs in lexical.get(word, []):
            chart[i][i + 1].setdefault(lhs, []).append(Tree(lhs, [word]))

    for span in range(2, n + 1):
        for i in range(n - span + 1):
            j = i + span
            cell = chart[i][j]
            for k in range(i + 1, j):
                left = chart[i][k]
                right = chart[k][j]
                for (b, c), lhs_list in binary.items():
                    if b not in left or c not in right:
                        continue
                    for left_tree in left[b]:
                        for right_tree in right[c]:
                            for a in lhs_list:
                                cell.setdefault(a, []).append(
                                    Tree(a, [left_tree, right_tree])
                                )

    return chart, chart[0][n].get("S", [])

The base case fills one-token spans from lexical rules. The recursive case loops over span lengths, start positions, split points, and binary rules. If the left and right subspans both contain the required nonterminals, the parser combines every pair of trees into a new tree for the parent nonterminal. The order of the loops matters. By processing spans from short to long, the parser guarantees that chart[i][k] and chart[k][j] are already complete when they are used.

Parsing the Ambiguous Sentence

Now we parse the sentence and count the complete S trees.

PythonIn [11]
CollapseExpand
sentence = "the dog chased the cat with a telescope"
tokens = sentence.split()
chart, parses = cky_parse(tokens, rules)
ConsoleOut [12]
CollapseExpand
Tokens: ['the', 'dog', 'chased', 'the', 'cat', 'with', 'a', 'telescope']
Number of S parses: 2

The parser finds two complete S trees. Both have the same yield, which is the original sentence. This confirms the ambiguity we analyzed by hand.

Let's print both trees and their bracketed forms.

ConsoleOut [13]
CollapseExpand
Parse 1
S
  NP
    Det
      the
    N
      dog
  VP
    V
      chased
    NP
      NP
        Det
          the
        N
          cat
      PP
        P
          with
        NP
          Det
            a
          N
            telescope
Yield: the dog chased the cat with a telescope
Bracketed: (S (NP (Det the) (N dog)) (VP (V chased) (NP (NP (Det the) (N cat)) (PP (P with) (NP (Det a) (N telescope))))))

Parse 2
S
  NP
    Det
      the
    N
      dog
  VP
    VP
      V
        chased
      NP
        Det
          the
        N
          cat
    PP
      P
        with
      NP
        Det
          a
        N
          telescope
Yield: the dog chased the cat with a telescope
Bracketed: (S (NP (Det the) (N dog)) (VP (VP (V chased) (NP (Det the) (N cat))) (PP (P with) (NP (Det a) (N telescope)))))

The output shows the two attachment patterns directly. In one tree, the PP is inside the object NP. In the other, the PP is a child of the VP. The yields are identical, which is exactly the point: the string does not determine the tree.

Scoring with a PCFG

A plain CFG treats both trees as equally possible. A PCFG can rank them. We assign illustrative probabilities to the rules. For each left-hand side, the probabilities sum to 1.

PythonIn [14]
CollapseExpand
rule_probs: Dict[Rule, float] = {
    ("S", ("NP", "VP")): 1.0,
    ("VP", ("V", "NP")): 0.7,
    ("VP", ("VP", "PP")): 0.3,
    ("NP", ("Det", "N")): 0.8,
    ("NP", ("NP", "PP")): 0.2,
    ("PP", ("P", "NP")): 1.0,
    ("Det", ("the",)): 0.8,
    ("Det", ("a",)): 0.2,
    ("N", ("dog",)): 0.4,
    ("N", ("cat",)): 0.4,
    ("N", ("telescope",)): 0.2,
    ("V", ("chased",)): 1.0,
    ("P", ("with",)): 1.0,
}


def tree_probability(tree: Tree, probs: Dict[Rule, float]) -> float:
    rhs = tuple(
        child.label if isinstance(child, Tree) else child
        for child in tree.children
    )
    probability = probs[(tree.label, rhs)]
    for child in tree.children:
        if isinstance(child, Tree):
            probability *= tree_probability(child, probs)
    return probability

The function walks the tree recursively. At each node, it looks up the rule probability and multiplies in the probabilities of all subtrees. The result is the product of rule probabilities for the entire tree.

ConsoleOut [15]
CollapseExpand
Rank 1: probability = 0.00044040
(S (NP (Det the) (N dog)) (VP (VP (V chased) (NP (Det the) (N cat))) (PP (P with) (NP (Det a) (N telescope)))))

Rank 2: probability = 0.00029360
(S (NP (Det the) (N dog)) (VP (V chased) (NP (NP (Det the) (N cat)) (PP (P with) (NP (Det a) (N telescope))))))

With these illustrative probabilities, the VP-attachment parse ranks higher. The absolute probabilities are tiny because many probabilities are multiplied together. What matters is the relative ranking. This ranking directly illustrates why a PCFG can resolve the ambiguity that the plain grammar left open: the grammar defines which structures are possible, and the hand-assigned rule probabilities express an illustrative attachment preference. A PCFG estimated from a treebank would use observed rule counts. Handling words absent from that treebank requires an additional lexical strategy, such as unknown-word rules or a model that reserves probability for unseen events.

A linear scale starting at zero shows the relative scores without exaggerating their difference. The longer bar marks the highest-scoring parse under these weights.

PythonIn [16]
CollapseExpand
import numpy as np

parse_prob_values = np.array([prob for prob, _ in scored])
parse_attachment_labels = [
    "VP attachment" if "(VP (VP" in tree.bracket() else "NP attachment"
    for _, tree in scored
]
VisualizationOut [17]
CollapseExpand
Horizontal bar chart comparing probabilities of two attachment parses.
Illustrative PCFG probabilities rank the verb-phrase attachment parse above the noun-phrase attachment parse.

How Fast Does Ambiguity Grow?

Finally, let's quantify the combinatorial explosion. The number of binary bracketings of nn tokens is the Catalan number Cn−1C_{n-1}.

PythonIn [18]
CollapseExpand
def catalan(n: int) -> int:
    return math.comb(2 * n, n) // (n + 1)
ConsoleOut [19]
CollapseExpand
5 tokens: 14 binary bracketings
10 tokens: 4,862 binary bracketings
15 tokens: 2,674,440 binary bracketings
20 tokens: 1,767,263,190 binary bracketings

The numbers grow rapidly. At 20 tokens, there are already more than 1.7 billion binary bracketings before any grammar constraints are applied. A grammar can exclude many of these shapes. Membership-only CKY shares subproblems without enumerating every tree, while the small tutorial implementation deliberately materializes its two complete trees. Unrestricted enumeration can therefore become impractical even for short inputs. Algorithms such as CKY share subproblems to avoid enumerating every complete tree.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

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

Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy