Constituency and Dependency Parsing › Code Implementation

Michael BrenndoerferMay 6, 202510 min read
Language AI HandbookAll chapters
Part 7 of 67Linguistic Form: Morphology and Syntax
Chapter 48Constituency and Dependency Parsing › Code Implementation
Reading section 7 of 10 · Code Implementation · 10 minSection 7 of 10 · 10 min

Section 7 of 10 · 10 min read

Constituency and Dependency Parsing › Code ImplementationSection view · Sections in this chapter7 / 10 ↓
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

The code below is deliberately small. It is not a production parser. It is a set of executable models that show the core mechanisms: chart parsing, transition-based parsing, graph-based decoding, and neural arc scoring. The goal is to make the algorithms concrete, so that the prose above can be checked against running code.

A tiny CKY chart parser

We start with a small CNF grammar. The lexicon maps preterminal categories to words. The binary rules combine two constituents into a larger one.

PythonIn [7]
CollapseExpand
from __future__ import annotations

from collections import deque
from itertools import product

import numpy as np

lexicon = {
    "Det": {"the", "a"},
    "N": {"man", "dog", "telescope", "park"},
    "V": {"saw", "watched"},
    "P": {"with", "in"},
}

binary_rules = [
    ("NP", "Det", "N"),
    ("VP", "V", "NP"),
    ("PP", "P", "NP"),
    ("NP", "NP", "PP"),
    ("VP", "VP", "PP"),
    ("S", "NP", "VP"),
]

The grammar can derive "the man saw the dog with a telescope" in two ways. The prepositional phrase can attach to the noun phrase "the dog" or to the verb phrase "saw the dog". The two binary rules NP -> NP PP and VP -> VP PP are what make both readings available.

Next we implement CKY. The chart is a two-dimensional table of dictionaries. Each cell maps a nonterminal to the list of trees that derive that span. This tiny demonstration explicitly stores all trees, so its output and memory use can be exponential; it does not have the compact Viterbi chart memory bound stated above. A best-tree decoder can retain one score and a backpointer per state instead.

PythonIn [8]
CollapseExpand
def cky_parse(tokens):
    n = len(tokens)
    chart = [[{} for _ in range(n + 1)] for _ in range(n + 1)]

    for i, token in enumerate(tokens):
        for nt, words in lexicon.items():
            if token in words:
                chart[i][i + 1][nt] = [(nt, token)]

    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length
            cell = {}
            for k in range(i + 1, j):
                left = chart[i][k]
                right = chart[k][j]
                for parent, left_nt, right_nt in binary_rules:
                    if left_nt in left and right_nt in right:
                        for left_tree in left[left_nt]:
                            for right_tree in right[right_nt]:
                                cell.setdefault(parent, []).append(
                                    (parent, left_tree, right_tree)
                                )
            chart[i][j] = cell

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

The parser fills spans of increasing length. For each span, it tries every split point. If the left cell contains the left-hand nonterminal and the right cell contains the right-hand nonterminal, the binary rule adds the parent to the current cell. The outer loop over length guarantees that every sub-span has already been computed when it is needed, which is the dynamic-programming invariant in action.

We need a small renderer to print the trees.

PythonIn [9]
CollapseExpand
def render_tree(tree, indent=0):
    if len(tree) == 2:
        label, word = tree
        return "  " * indent + f"{label} -> {word}"
    label, left, right = tree
    lines = ["  " * indent + label]
    lines.append(render_tree(left, indent + 1))
    lines.append(render_tree(right, indent + 1))
    return "\n".join(lines)

Now we parse the ambiguous sentence.

PythonIn [10]
CollapseExpand
tokens = "the man saw the dog with a telescope".split()
chart, parses = cky_parse(tokens)

The output shows how many complete sentence parses the grammar found.

ConsoleOut [11]
CollapseExpand
Token count: 8
Complete S parses: 2

Parse 1:
S
  NP
    Det -> the
    N -> man
  VP
    V -> saw
    NP
      NP
        Det -> the
        N -> dog
      PP
        P -> with
        NP
          Det -> a
          N -> telescope

Parse 2:
S
  NP
    Det -> the
    N -> man
  VP
    VP
      V -> saw
      NP
        Det -> the
        N -> dog
    PP
      P -> with
      NP
        Det -> a
        N -> telescope

The grammar returns two parses. One attaches the prepositional phrase to the verb phrase, and the other attaches it to the noun phrase. That is exactly the ambiguity we described in prose. The chart parser did not decide which reading is more plausible. It enumerated both. A probabilistic or neural scorer would rank them. Explicit enumeration is manageable for this short sentence and tiny grammar. Ambiguous grammars can produce many derivations, so compact best-tree parsers retain scores and backpointers rather than materializing every tree.

We can also inspect the chart itself. The following table shows the nonterminals that derive each non-empty span.

PythonIn [12]
CollapseExpand
def chart_rows(chart, tokens):
    rows = []
    n = len(tokens)
    for length in range(1, n + 1):
        for i in range(n - length + 1):
            j = i + length
            nts = sorted(chart[i][j])
            if nts:
                rows.append((i, j, " ".join(tokens[i:j]), ", ".join(nts)))
    return rows


rows = chart_rows(chart, tokens)
ConsoleOut [13]
CollapseExpand
[0:1] 'the'                               -> Det
[1:2] 'man'                               -> N
[2:3] 'saw'                               -> V
[3:4] 'the'                               -> Det
[4:5] 'dog'                               -> N
[5:6] 'with'                              -> P
[6:7] 'a'                                 -> Det
[7:8] 'telescope'                         -> N
[0:2] 'the man'                           -> NP
[3:5] 'the dog'                           -> NP
... 17 non-empty cells in total

Each row is a span and the categories that can derive it. The cell for "the dog" contains NP; Det and N occupy its one-token sub-spans. The cell for "saw the dog" contains VP. The cell for the whole sentence contains S. This is the chart that makes dynamic programming possible. The chart is also a useful debugging tool: for this exhaustive toy grammar, inspecting a missing category and the rules for its child spans can locate a failed derivation.

A transition-based parser

Now we implement the arc-standard transition system. The stack starts with the artificial root. The buffer starts with all token indices.

PythonIn [14]
CollapseExpand
def apply_transition(stack, buffer, arcs, action, label=None):
    if action == "SHIFT":
        if not buffer:
            raise ValueError("SHIFT requires a non-empty buffer")
        stack.append(buffer.popleft())
    elif action == "LEFT-ARC":
        if len(stack) < 2 or stack[-2] == 0:
            raise ValueError(
                "LEFT-ARC requires two items and cannot attach ROOT"
            )
        head, dep = stack[-1], stack[-2]
        arcs.append((head, dep, label))
        stack.pop(-2)
    elif action == "RIGHT-ARC":
        if len(stack) < 2:
            raise ValueError("RIGHT-ARC requires two stack items")
        if stack[-2] == 0 and (buffer or len(stack) != 2):
            raise ValueError("The ROOT arc must be the final transition")
        head, dep = stack[-2], stack[-1]
        arcs.append((head, dep, label))
        stack.pop()
    else:
        raise ValueError(f"Unknown action: {action}")


def run_transitions(words, transitions):
    stack = [0]
    buffer = deque(range(1, len(words)))
    arcs = []
    for action, label in transitions:
        apply_transition(stack, buffer, arcs, action, label)
    return stack, list(buffer), arcs

The transition sequence below is a gold derivation for "She saw him". A real parser would predict each action from features of the stack and buffer.

PythonIn [15]
CollapseExpand
words = ["<ROOT>", "She", "saw", "him"]
transitions = [
    ("SHIFT", None),
    ("SHIFT", None),
    ("LEFT-ARC", "nsubj"),
    ("SHIFT", None),
    ("RIGHT-ARC", "dobj"),
    ("RIGHT-ARC", "root"),
]
stack, buffer, arcs = run_transitions(words, transitions)
ConsoleOut [16]
CollapseExpand
Final stack: [0]
Final buffer: []
  saw --nsubj--> She
  saw --dobj--> him
<ROOT> --root--> saw

The final stack contains only the root. The buffer is empty. The arcs are exactly the dependency tree for the sentence. The parser created the subject arc when "saw" was on top of the stack and "She" was just below it. It created the object arc after shifting "him".

This example uses a hand-written transition sequence. In a trained transition parser, a classifier would score SHIFT, LEFT-ARC, and RIGHT-ARC at each step. The classifier would use the top stack words, their part-of-speech tags, the next buffer word, and possibly the already-built arcs. Greedy decoding picks the highest score. Beam search keeps several configurations.

Graph-based decoding with maximum spanning trees

For graph-based parsing, we need a way to find the best tree given arc scores. The brute-force decoder below is practical only for tiny sentences, but it makes the objective explicit.

PythonIn [17]
CollapseExpand
def is_valid_tree(heads):
    n = len(heads) - 1
    if sum(1 for d in range(1, n + 1) if heads[d] == 0) != 1:
        return False
    for dep in range(1, n + 1):
        seen = set()
        cur = dep
        while cur != 0:
            if cur in seen:
                return False
            seen.add(cur)
            cur = heads[cur]
    return True


def brute_force_mst(n, arc_scores):
    best_total = float("-inf")
    best_heads = None
    for parents in product(range(n + 1), repeat=n):
        heads = [None] + list(parents)
        if any(heads[d] == d for d in range(1, n + 1)):
            continue
        if not is_valid_tree(heads):
            continue
        total = sum(arc_scores.get((heads[d], d), 0.0) for d in range(1, n + 1))
        if total > best_total:
            best_total = total
            best_heads = heads
    return best_heads, best_total

The function enumerates possible head assignments and rejects self-loops, wrong ROOT-child counts and cycles. Missing dictionary entries receive score zero in this demonstration; both supplied examples populate all permitted arcs. For four tokens there are (5^4 = 625) unfiltered assignments. This makes brute force usable for the tiny example; a scalable exact first-order decoder can use Chu-Liu/Edmonds or Eisner with the intended tree constraints.

We can give the decoder a hand-made score table. Gold arcs get a high score and all other arcs get a low score. The decoder should recover the gold tree.

PythonIn [18]
CollapseExpand
words = ["<ROOT>", "She", "saw", "the", "man"]
n = len(words) - 1
gold_arcs = {(2, 1): "nsubj", (0, 2): "root", (2, 4): "dobj", (4, 3): "det"}

arc_scores = {}
for h in range(n + 1):
    for d in range(1, n + 1):
        if h == d:
            continue
        arc_scores[(h, d)] = 5.0 if (h, d) in gold_arcs else -1.0

best_heads, best_total = brute_force_mst(n, arc_scores)
ConsoleOut [19]
CollapseExpand
Best total score: 20.0
  saw --nsubj--> She
<ROOT> --root--> saw
  man --det--> the
  saw --dobj--> man

The decoder recovers the gold tree because the gold arcs sum to 20 and every alternative includes at least one negative-scoring arc. The important point is not the hand-made scores. The important point is that the decoder enforces the tree constraint globally. A greedy per-token choice could create a cycle or two roots. The constrained toy decoder rejects both; an ordinary arborescence decoder can still allow multiple ROOT children.

Neural arc scoring

A neural graph-based parser replaces the hand-made score table with learned representations. The code below uses random vectors, projections, and affine parameters. No encoder or scorer is trained, so these are illustrative scores rather than linguistic predictions. The computation follows the biaffine form above: encode tokens, project heads and dependents, compute a score matrix, then decode a tree.

PythonIn [20]
CollapseExpand
rng = np.random.default_rng(7)
dim = 8
H = rng.normal(size=(n + 1, dim))
W_head = rng.normal(scale=0.3, size=(dim, dim))
W_dep = rng.normal(scale=0.3, size=(dim, dim))
U = rng.normal(scale=0.3, size=(dim, dim))

H_head = H @ W_head
H_dep = H @ W_dep
b_head = rng.normal(scale=0.3, size=dim)
b_dep = rng.normal(scale=0.3, size=dim)
bias = float(rng.normal(scale=0.3))
raw_scores = (
    H_head @ U @ H_dep.T
    + (H_head @ b_head)[:, None]
    + (H_dep @ b_dep)[None, :]
    + bias
)

neural_scores = {}
for h in range(n + 1):
    for d in range(1, n + 1):
        if h == d:
            continue
        neural_scores[(h, d)] = float(raw_scores[h, d])

neural_heads, neural_total = brute_force_mst(n, neural_scores)
ConsoleOut [21]
CollapseExpand
Top-scoring arcs:
  man -> saw    1.469
  man -> the    1.431
  man -> She    0.114
  She -> saw   -0.496
  She -> man   -0.598

Decoded tree total: 0.949
<ROOT> --dep--> She
  man --dep--> saw
  man --dep--> the
  She --dep--> man

The random parameters supply no learned linguistic preference. Replacing the random token vectors with contextual encoder outputs would give the scorer context-dependent inputs. Training aims to favor reference attachments; validity still depends on the decoder. Distinguish a model-ranking error from a search error: if an exact decoder optimizes its objective but the reference tree has a lower score, inspect the model, supervision and factorization. If the returned tree fails to optimize the specified objective, inspect the search approximation or decoder implementation.

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