Dependency Syntax and Universal Dependencies › Code: Analyzing a Dependency Treebank

Michael BrenndoerferMay 5, 202514 min read
Language AI HandbookAll chapters
Part 7 of 67Linguistic Form: Morphology and Syntax
Chapter 47Dependency Syntax and Universal Dependencies › Code: Analyzing a Dependency Treebank
Reading section 10 of 13 · Code: Analyzing a Dependency Treebank · 14 minSection 10 of 13 · 14 min

Section 10 of 13 · 14 min read

Dependency Syntax and Universal Dependencies › Code: Analyzing a Dependency TreebankSection view · Sections in this chapter10 / 13 ↓
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 following cells load a CoNLL-U sample, validate the structural axioms, draw two example trees, and check projectivity. Each step turns an earlier definition into an executable check on supplied annotations. The cells do not infer a tree from raw text.

Setup

The examples use the book's scientific Python environment and a small standard-library reader. No corpus or pretrained model is downloaded. The reader preserves all ten CoNLL-U fields as text, except for the integer ID and HEAD values used by the tree computations. It supports this chapter's basic integer-ID word records only: it explicitly rejects range IDs and empty-node IDs. It is not a general CoNLL-U parser or validator.

Loading CoNLL-U

We define a small three-sentence treebank in the CoNLL-U format. It includes a coordination example, a ditransitive example, and the non-projective relative clause example from earlier. Building the sample by hand keeps the chapter self-contained and lets us focus on structure rather than download mechanics.

PythonIn [3]
CollapseExpand
CONLLU_SAMPLE = """
# sent_id = 1
# text = They buy and sell books.
1\tThey\tthey\tPRON\tPRP\tCase=Nom|Number=Plur\t2\tnsubj\t_\t_
2\tbuy\tbuy\tVERB\tVBP\tNumber=Plur\t0\troot\t_\t_
3\tand\tand\tCCONJ\tCC\t_\t4\tcc\t_\t_
4\tsell\tsell\tVERB\tVBP\tNumber=Plur\t2\tconj\t_\t_
5\tbooks\tbook\tNOUN\tNNS\tNumber=Plur\t2\tobj\t_\tSpaceAfter=No
6\t.\t.\tPUNCT\t.\t_\t2\tpunct\t_\t_

# sent_id = 2
# text = She gave him a book.
1\tShe\tshe\tPRON\tPRP\tCase=Nom|Gender=Fem|Number=Sing|Person=3|PronType=Prs\t2\tnsubj\t_\t_
2\tgave\tgive\tVERB\tVBD\tMood=Ind|Tense=Past|VerbForm=Fin\t0\troot\t_\t_
3\thim\the\tPRON\tPRP\tCase=Acc|Gender=Masc|Number=Sing|Person=3|PronType=Prs\t2\tiobj\t_\t_
4\ta\ta\tDET\tDT\tDefinite=Ind|PronType=Art\t5\tdet\t_\t_
5\tbook\tbook\tNOUN\tNN\tNumber=Sing\t2\tobj\t_\tSpaceAfter=No
6\t.\t.\tPUNCT\t.\t_\t2\tpunct\t_\t_

# sent_id = 3
# text = I saw a dog yesterday which was a Yorkshire Terrier.
1\tI\tI\tPRON\tPRP\tCase=Nom|Number=Sing|Person=1|PronType=Prs\t2\tnsubj\t_\t_
2\tsaw\tsee\tVERB\tVBD\tMood=Ind|Tense=Past|VerbForm=Fin\t0\troot\t_\t_
3\ta\ta\tDET\tDT\tDefinite=Ind|PronType=Art\t4\tdet\t_\t_
4\tdog\tdog\tNOUN\tNN\tNumber=Sing\t2\tobj\t_\t_
5\tyesterday\tyesterday\tNOUN\tNN\tNumber=Sing\t2\tobl:unmarked\t_\t_
6\twhich\twhich\tPRON\tWDT\tPronType=Rel\t10\tnsubj\t_\t_
7\twas\tbe\tAUX\tVBD\tMood=Ind|Number=Sing|Tense=Past|VerbForm=Fin\t10\tcop\t_\t_
8\ta\ta\tDET\tDT\tDefinite=Ind|PronType=Art\t10\tdet\t_\t_
9\tYorkshire\tYorkshire\tPROPN\tNNP\tNumber=Sing\t10\tcompound\t_\t_
10\tTerrier\tterrier\tNOUN\tNN\tNumber=Sing\t4\tacl:relcl\t_\tSpaceAfter=No
11\t.\t.\tPUNCT\t.\t_\t2\tpunct\t_\t_
"""

The executable sample supplies basic dependency annotations only; DEPS is unspecified. Enhanced coordination and relative-clause links were introduced above, but this reader neither infers them nor validates a complete enhanced graph. The breed-denoting head Terrier is treated as the common noun NOUN, with lemma terrier and Penn-style tag NN; its capitalization does not establish a proper name. Keeping the supplied linguistic choices separate from the graph checks makes their limits explicit.

We now read the three sentence blocks. Blank lines terminate sentences, comments are ignored for the tree computations, and every word row must have ten nonempty tab-separated fields. Morphological features and enhanced dependencies remain literal field strings; this reader does not interpret or validate their internal syntax.

PythonIn [4]
CollapseExpand
FIELDS = (
    "id",
    "form",
    "lemma",
    "upos",
    "xpos",
    "feats",
    "head",
    "deprel",
    "deps",
    "misc",
)


def read_basic_conllu(text):
    sentences, rows = [], []
    for line in text.splitlines() + [""]:
        if not line.strip():
            if rows:
                sentences.append(rows)
                rows = []
            continue
        if line.startswith("#"):
            continue
        values = line.split("\t")
        if len(values) != 10 or any(value == "" for value in values):
            raise ValueError("Expected ten nonempty tab-separated fields")
        row = dict(zip(FIELDS, values))
        if not row["id"].isdigit() or not row["head"].isdigit():
            raise ValueError(
                "This teaching reader supports integer word IDs and HEADs only"
            )
        row["id"], row["head"] = int(row["id"]), int(row["head"])
        rows.append(row)
    return sentences


parsed = read_basic_conllu(CONLLU_SAMPLE)

Let's inspect seven selected fields of the second sentence, She gave him a book, as a DataFrame. The reader also retains XPOS, FEATS, and MISC; this display concentrates on the words and their attachments.

ConsoleOut [5]
CollapseExpand
Loaded 3 sentences into CoNLL-U records
 ID FORM LEMMA  UPOS  HEAD DEPREL DEPS
  1  She   she  PRON     2  nsubj    _
  2 gave  give  VERB     0   root    _
  3  him    he  PRON     2   iobj    _
  4    a     a   DET     5    det    _
  5 book  book  NOUN     2    obj    _
  6    .     . PUNCT     2  punct    _

The output shows the seven selected CoNLL-U fields. HEAD is an integer index into the same sentence, not a word form. Word 3 (him) has HEAD 2, meaning gave is its head. The row stores that index rather than repeating the form gave. A program can resolve the index against the word records of the same sentence.

Structural Validation

The axioms of a dependency tree are simple enough to check programmatically. We build a directed graph with a virtual node 0 for the artificial root, add one edge from each head to its dependent, and then check the basic-tree constraints together with word IDs and root labels.

PythonIn [6]
CollapseExpand
def build_graph(tokens):
    G = nx.DiGraph()
    G.add_node(0)
    for t in tokens:
        G.add_node(t["id"])
    for t in tokens:
        G.add_edge(t["head"], t["id"])
    return G


def validate_tree(tokens):
    ids = [t["id"] for t in tokens]
    well_formed = ids == list(range(1, len(tokens) + 1))
    valid_heads = all(
        t["head"] in {0, *ids} and t["head"] != t["id"] for t in tokens
    )
    root_labels = all(
        (t["head"] == 0) == (t["deprel"] == "root") for t in tokens
    )
    G = build_graph(tokens)
    return {
        "word_ids": well_formed,
        "valid_heads": valid_heads,
        "single_head": well_formed and all(G.in_degree(i) == 1 for i in ids),
        "single_root": sum(t["head"] == 0 for t in tokens) == 1,
        "root_labels": root_labels,
        "acyclic": nx.is_directed_acyclic_graph(G),
        "connected": set(nx.descendants(G, 0)) | {0} == set(G.nodes()),
    }
ConsoleOut [7]
CollapseExpand
Sentence 1: {'word_ids': True, 'valid_heads': True, 'single_head': True, 'single_root': True, 'root_labels': True, 'acyclic': True, 'connected': True}
Sentence 2: {'word_ids': True, 'valid_heads': True, 'single_head': True, 'single_root': True, 'root_labels': True, 'acyclic': True, 'connected': True}
Sentence 3: {'word_ids': True, 'valid_heads': True, 'single_head': True, 'single_root': True, 'root_labels': True, 'acyclic': True, 'connected': True}

All three hand-written examples satisfy these checks. Counting rows with HEAD=0 is essential: the graph with arcs 0 -> 1 and 0 -> 2 is an arborescence rooted at the artificial node, but it has two sentence roots and violates the basic UD single-root requirement. The root_labels check also enforces the correspondence between HEAD=0 and DEPREL=root. These checks do not replace UD's full annotation and format validators.

The validation step is worth running even when you trust the data source. Annotation errors can occur in a treebank; importing, converting, or generating head assignments is also an opportunity to check the basic-tree constraints. A quick structural check catches cycles, multiple roots, and disconnected tokens before those errors propagate into training or evaluation.

Drawing Trees

A diagram makes the head-dependent choices easier to inspect. We build a small helper that places each word on a horizontal line and draws each arc above it, labelled with its relation. Word spacing accounts for word length. Arc heights increase with their horizontal span so that nested attachments remain legible; the height is a drawing choice, not a linguistic measurement.

PythonIn [8]
CollapseExpand
def draw_dep_tree(tokens, ax, highlight=None):
    # Word spacing accounts for word length; the arcs encode attachment only.
    positions = [0.0]
    for left, right in zip(tokens, tokens[1:]):
        positions.append(
            positions[-1] + (len(left["form"]) + len(right["form"])) / 2 + 2.5
        )
    pos = {t["id"]: x for t, x in zip(tokens, positions)}
    ink = PALETTE["ink"]
    max_height = 0.0
    for t in tokens:
        ax.text(pos[t["id"]], 0, t["form"], ha="center", va="center", color=ink)
        if t["head"] == 0:
            ax.text(
                pos[t["id"]], -0.55, "root", ha="center", va="top", color=ink
            )
            continue
        xh, xd = pos[t["head"]], pos[t["id"]]
        height = 0.075 * abs(xd - xh)
        max_height = max(max_height, height)
        color = (
            PALETTE["amber"]
            if (t["head"], t["id"]) in (highlight or set())
            else ink
        )
        path = MatplotlibPath(
            [(xh, 0.25), ((xh + xd) / 2, 2 * height + 0.25), (xd, 0.25)],
            [
                MatplotlibPath.MOVETO,
                MatplotlibPath.CURVE3,
                MatplotlibPath.CURVE3,
            ],
        )
        ax.add_patch(
            FancyArrowPatch(
                path=path,
                arrowstyle="-|>",
                mutation_scale=10,
                lw=1.1,
                color=color,
            )
        )
        ax.text(
            (xh + xd) / 2,
            height + 0.32,
            t["deprel"],
            ha="center",
            va="bottom",
            fontsize=8,
            color=color,
            bbox={
                "facecolor": PALETTE["paper"],
                "edgecolor": "none",
                "alpha": 0.92,
                "pad": 0.6,
            },
        )
    ax.set_xlim(-3, positions[-1] + 3)
    ax.set_ylim(-1, max_height + 1.15)
    ax.axis("off")

Now we draw the projective sentence She gave him a book. Its local determiner attachment gives us a visual baseline for the later crossing example.

VisualizationOut [9]
CollapseExpand
Projective dependency tree with labelled arcs above the words.
A projective dependency tree for *She gave him a book*. The subject, two objects, and punctuation depend on *gave*. The determiner *a* depends on *book*, and that arc nests inside the object arc from *gave* to *book*. No two arcs cross.

The arcs fan out from gave: nsubj to She, iobj to him, obj to book, and punct to the period. The only arc headed by a nonroot word is det from book to a, and it sits entirely inside the span of obj.

Now the non-projective sentence. We highlight the offending arc from dog to Terrier in amber so the crossing is visible.

VisualizationOut [10]
CollapseExpand
Relative-clause arc crossing the main-clause attachment of yesterday.
A non-projective dependency tree for a sentence with a relative clause. The `acl:relcl` arc from *dog* (token 4) to *Terrier* (token 10) spans *yesterday*, *which*, *was*, *a*, and *Yorkshire*. Only *yesterday* is outside the subtree of *dog*. The highlighted arc crosses the arc from *saw* to *yesterday*, so a parser restricted to projective outputs cannot recover this analysis exactly.

The highlighted arc violates projectivity because yesterday belongs to the main clause. A parser restricted to projective outputs must change at least one attachment. A parser that allows non-projective outputs can represent the supplied analysis, but it still needs evidence to choose the intended attachments.

Checking Projectivity

Visual inspection is nice, but we need a computation. The definition translates directly into a loop: for every arc, look at every token strictly between the head and dependent, and check whether it belongs to the head's subtree. If any intervening token is outside, the arc is a violation.

PythonIn [11]
CollapseExpand
def projectivity_violations(tokens):
    children = defaultdict(list)
    for t in tokens:
        if t["head"] != 0:
            children[t["head"]].append(t["id"])

    subtree = {}

    def collect(tid):
        s = {tid}
        for c in children[tid]:
            s |= collect(c)
        subtree[tid] = s
        return s

    for t in tokens:
        if t["head"] == 0:
            collect(t["id"])

    pos = {t["id"]: i for i, t in enumerate(tokens)}
    violations = []
    for t in tokens:
        h, d = t["head"], t["id"]
        if h == 0:
            continue
        lo, hi = min(pos[h], pos[d]), max(pos[h], pos[d])
        for k in range(lo + 1, hi):
            k_id = tokens[k]["id"]
            if k_id not in subtree[h]:
                violations.append((h, d, k_id))
    return violations
ConsoleOut [12]
CollapseExpand
Sentence 1: projective
Sentence 2: projective
Sentence 3: non-projective (1 non-projective arc(s))
  offending arcs: [(4, 10)]

The first two sentences return no violation tuples. The third returns exactly [(4, 10, 5)]: the acl:relcl from dog to Terrier has the outside intervening word yesterday. The output loop reduces tuples to distinct (head, dependent) pairs before counting, so it reports one offending arc, (4, 10). In general, one arc can produce several tuples if several intervening words are outside its head's subtree. This counts arcs violating the descendant condition, rather than every arc that participates in a crossing. Here, 2→5 participates in the visible crossing but is itself projective: tokens 3 and 4 are descendants of 2.

The numerical check can also be read as a picture. The next cell turns each non-root arc into a horizontal segment from head to dependent. Amber segments are the arcs that violate projectivity. The map uses every non-root arc in all three sample sentences and performs no random sampling.

PythonIn [13]
CollapseExpand
projectivity_arc_rows = []
for sent_i, tokens in enumerate(parsed, 1):
    violations = {(h, d) for h, d, _ in projectivity_violations(tokens)}
    sent_rows = []
    for t in tokens:
        h, d = t["head"], t["id"]
        if h == 0:
            continue
        sent_rows.append(
            {
                "sentence": f"S{sent_i}",
                "head": h,
                "dep": d,
                "span": abs(d - h),
                "projective": (h, d) not in violations,
                "label": t["deprel"],
            }
        )
    sent_rows.sort(key=lambda row: (row["span"], row["head"], row["dep"]))
    n_arcs = len(sent_rows)
    for rank, row in enumerate(sent_rows):
        row["y"] = sent_i + (rank - (n_arcs - 1) / 2) * 0.08
        projectivity_arc_rows.append(row)

projectivity_sentence_labels = [f"S{i}" for i in range(1, len(parsed) + 1)]
projectivity_max_token = max(t["id"] for tokens in parsed for t in tokens)
VisualizationOut [14]
CollapseExpand
Horizontal arc-span plot with one highlighted non-projective arc crossing several tokens.
Arc spans in the three sample sentences. Each horizontal segment runs from a head to its dependent, and amber segments mark projectivity violations. The long amber arc in sentence 3 is the `acl:relcl` attachment from *dog* to *Terrier*, which spans one token outside the subtree of *dog*: *yesterday*. Sentences 1 and 2 contain only projective arcs, while sentence 3 shows the non-projective analysis.

For a valid tree with nn consecutively indexed words, this teaching implementation has O(n2)O(n^2) worst-case time under the usual constant-time set-membership assumption. The nested arc-and-intervening-word loop can inspect quadratically many pairs. The stored descendant sets can also contain 1+2+⋯+n1+2+\cdots+n entries across a chain-shaped tree, so constructing and retaining those sets takes O(n2)O(n^2) worst-case work and space. Recursive depth can reach nn, which is another reason to keep this implementation to short examples. These bounds describe the algorithm; they do not supply a typical operation count for natural-language sentences.

A more memory-efficient check is possible when the input is already known to be a valid tree. For each word vv, a postorder traversal can retain only the smallest descendant position pvp_v, the largest position qvq_v, and the number of words cvc_v in its subtree, including vv. That subtree occupies a contiguous interval exactly when

cv=qv−pv+1.c_v = q_v-p_v+1.

Here qv−pv+1q_v-p_v+1 counts the word positions from the leftmost member to the rightmost member. Equality says that every position in that interval belongs to the subtree, with no gap. A dependency tree is projective exactly when this holds for every word's subtree: contiguity ensures that any position between a head and its dependent is dominated by that head, and projectivity ensures that the positions along each descendant path leave no outside word inside the subtree's span. For example, the subtree of dog in our third analysis has positions {3,4,6,7,8,9,10}\{3,4,6,7,8,9,10\}, so cv=7c_v=7 while qv−pv+1=8q_v-p_v+1=8. The missing position is yesterday, at 5. Combining these three quantities once per edge gives an O(n)O(n) whole-tree check and O(n)O(n) storage, rather than an additional O(n)O(n) scan for every arc.

Annotation Checks Are Not Parsing

The code above inspects a supplied analysis. It does not infer a tree from raw text. That distinction matters: a structurally valid tree can still attach a phrase to the wrong word or choose a relation inconsistent with the sentence's interpretation. Acyclicity and projectivity are mathematical properties; annotation correctness also requires linguistic judgment.

Consider I saw the man with a telescope. If with a telescope describes the instrument used for seeing, telescope can depend on saw with obl. If it describes the man, telescope can depend on man with nmod. In both analyses, with is a case dependent of telescope. Both structures can be single-headed, connected, acyclic, and projective. The sentence alone may not settle which interpretation the writer intended.

That is why a validation routine should state what it proves. Our routine checks the chosen basic-tree constraints. It does not certify relation definitions, morphological features, the interpretation of an ambiguous sentence, or all of the CoNLL-U specification. Parser training and decoding appear in the next chapter; comparison with reference annotations and attachment metrics follow in the chapter on structured prediction and parsing evaluation.

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