Ericu950's picture
Stoicheia: training and evaluation code
5952424 verified
Raw
History Blame Contribute Delete
5.64 kB
"""Chu-Liu-Edmonds maximum-spanning-arborescence decoding for non-projective dependency trees.
The biaffine head's greedy per-token argmax can produce non-trees (cycles, multiple roots).
CLE returns the maximum-scoring valid tree rooted at the pseudo-root, which typically lifts
LAS by a few tenths of a point over greedy on non-projective treebanks like AGDT.
"""
from __future__ import annotations
import numpy as np
def chu_liu_edmonds(score):
"""score: (n, n) float, score[h, d] = weight of arc h->d; node 0 is the root (no incoming
arc). Returns heads: int array length n with heads[d] the selected head of d, heads[0] = -1.
Maximization. O(n^3) with cycle contraction — fine for sentence-length n."""
n = score.shape[0]
S = score.astype(np.float64).copy()
np.fill_diagonal(S, -np.inf)
S[:, 0] = -np.inf # root has no incoming arc
# greedily pick the best head for each non-root node
heads = np.full(n, -1, dtype=np.int64)
for d in range(1, n):
heads[d] = int(np.argmax(S[:, d]))
cyc = _find_cycle(heads, n)
if cyc is None:
return heads
# contract the cycle into a single node and recurse
cyc_set = set(cyc)
others = [v for v in range(n) if v not in cyc_set]
cnode = len(others) # index of the contracted node in the subproblem
remap = {v: i for i, v in enumerate(others)}
m = len(others) + 1
S2 = np.full((m, m), -np.inf)
# cycle-internal arc weight into each cycle node (for the "swap" bookkeeping)
in_w = {v: S[heads[v], v] for v in cyc}
# arcs among non-cycle nodes: copy through
for h in others:
for d in others:
if h != d:
S2[remap[h], remap[d]] = S[h, d]
# arcs entering the cycle: best over cycle targets, discounted by the broken internal arc
back = {} # (real_head) -> (real cycle node it best enters)
for h in others:
best, bestv = -np.inf, None
for v in cyc:
w = S[h, v] - in_w[v]
if w > best:
best, bestv = w, v
S2[remap[h], cnode] = best
back[h] = bestv
# arcs leaving the cycle: best over cycle sources (root can't be a dependent)
fwd = {}
for d in others:
if d == 0:
continue
best, bestv = -np.inf, None
for v in cyc:
if S[v, d] > best:
best, bestv = S[v, d], v
S2[cnode, remap[d]] = best
fwd[d] = bestv
sub = chu_liu_edmonds(S2)
# expand the contracted solution back to the original nodes
res = np.full(n, -1, dtype=np.int64)
for d in others:
if d == 0:
continue # root keeps head -1
h_sub = sub[remap[d]]
if h_sub == cnode:
res[d] = fwd[d] # arc came out of the cycle
else:
res[d] = others[h_sub]
# the contracted node's incoming arc: keep the whole cycle except the one broken edge
h_sub = sub[cnode]
real_h = others[h_sub]
enter = back[real_h] # cycle node that the external arc enters
res[enter] = real_h
for v in cyc:
if v != enter:
res[v] = heads[v] # keep original internal cycle arc
return res
def _find_cycle(heads, n):
"""Return one cycle (list of nodes) in the head-pointer graph, or None if it's a forest."""
state = [0] * n # 0=unvisited,1=on-stack,2=done
for start in range(1, n):
if state[start] != 0:
continue
path, v = [], start
while v > 0 and state[v] == 0:
state[v] = 1; path.append(v); v = heads[v]
if v > 0 and state[v] == 1: # hit a node on the current stack -> cycle
cyc = path[path.index(v):]
for u in path:
state[u] = 2
return cyc
for u in path:
state[u] = 2
return None
def decode_mst(arc_scores, mask):
"""arc_scores: (B, W, W+1) [col 0 = root]. mask: (B, W) bool. Returns heads (B, W) long
tensor on cpu with values in [0..W] (0 = root), padded positions = 0 — matching the greedy
decode's output convention so downstream LAS counting is identical."""
import torch
B, W, _ = arc_scores.shape
out = torch.zeros(B, W, dtype=torch.long)
sc = arc_scores.detach().float().cpu().numpy()
lengths = mask.sum(1).cpu().numpy()
for b in range(B):
L = int(lengths[b])
if L == 0:
continue
# build (L+1)x(L+1): node 0 = root, nodes 1..L = words; score[h, d]
S = np.full((L + 1, L + 1), -1e9)
block = sc[b, :L, :L + 1] # (L, L+1): [dep, head_col]
S[:, 1:] = block.T # score[h, d] = arc_scores[dep=d-1, head=h]
heads = chu_liu_edmonds(S) # heads[d] in [0..L]
out[b, :L] = torch.from_numpy(heads[1:]) # word d (1..L) -> column index (0=root)
return out
def mst_heads_labels(arc_scores, rel_scores, mask):
"""MST-decoded heads + the label argmax at each chosen head. Mirrors BiaffineHead.decode's
(heads_cpu, labels_cpu) return so LAS/UAS counting is unchanged."""
import torch
pred_heads = decode_mst(arc_scores, mask) # (B,W) cpu, 0=root
B, W = pred_heads.shape
dev = rel_scores.device
bi = torch.arange(B, device=dev)[:, None].expand(B, W)
wi = torch.arange(W, device=dev)[None, :].expand(B, W)
pred_labels = rel_scores[bi, wi, pred_heads.to(dev)].argmax(-1).cpu()
return pred_heads, pred_labels