Constituency and Dependency Parsing › Code Implementation
Language AI HandbookAll chapters 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
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]Expand
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]Expand
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]Expand
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]Expand
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]Expand
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 -> telescopeThe 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]Expand
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]Expand
[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]Expand
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), arcsThe 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]Expand
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]Expand
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]Expand
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_totalThe 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]Expand
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]Expand
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]Expand
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]Expand
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!