Download parser/biaffine.py from Ericu950/Stoicheia-code: direct link, hf CLI and curl.
- Browser
- Download file 6.53 kB
-
https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/parser/biaffine.py
- Command line
-
hf download hf://Ericu950/Stoicheia-code/parser/biaffine.py
-
curl -L -o biaffine.py https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/parser/biaffine.py
6.53 kB
| """Deep biaffine dependency parser (Dozat & Manning 2017) over a frozen Stoicheia | |
| encoder, combined via an ELMo-style learned scalar mix of all layers (same recipe the | |
| tagger used for XPOS/lemma: scalar_mix + light head, here applied to arc/label MLPs | |
| instead of tag heads). | |
| Backbones are FROZEN (only the scalar-mix weights + biaffine head train) — this keeps a fair, | |
| fast 3-way ablation (char / lemma / fused) without re-touching either finished pretraining run. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| class ScalarMix(nn.Module): | |
| """Learned softmax-weighted sum over N layer outputs (+ a global scale), ELMo-style.""" | |
| def __init__(self, n_layers): | |
| super().__init__() | |
| self.w = nn.Parameter(torch.zeros(n_layers)) | |
| self.gamma = nn.Parameter(torch.ones(1)) | |
| def forward(self, layers): # layers: list of (B,T,D), len n_layers | |
| w = torch.softmax(self.w, 0) | |
| mixed = sum(wi * h for wi, h in zip(w, layers)) | |
| return self.gamma * mixed | |
| def pool_words(hidden, word_id, W, mode="mean"): | |
| B, T, D = hidden.shape | |
| flat = hidden.reshape(B * T, D) | |
| wid = word_id.reshape(B * T) | |
| valid = wid >= 0 | |
| off = (torch.arange(B, device=hidden.device) * W).repeat_interleave(T) | |
| idx = (wid + off)[valid] | |
| out = hidden.new_zeros(B * W, D) | |
| out.index_add_(0, idx, flat[valid]) | |
| cnt = hidden.new_zeros(B * W).index_add_(0, idx, torch.ones_like(idx, dtype=hidden.dtype)) | |
| out = out / cnt.clamp(min=1).unsqueeze(-1) | |
| return out.reshape(B, W, D) | |
| class MLP(nn.Module): | |
| def __init__(self, d_in, d_out, dropout=0.33): | |
| super().__init__() | |
| self.lin = nn.Linear(d_in, d_out) | |
| self.act = nn.LeakyReLU(0.1) | |
| self.drop = nn.Dropout(dropout) | |
| def forward(self, x): | |
| return self.drop(self.act(self.lin(x))) | |
| class Biaffine(nn.Module): | |
| """s(x,y) = [x;1]^T W [y;1] (per output channel). x:(B,Lx,Di) y:(B,Ly,Di) -> (B,n_out,Lx,Ly).""" | |
| def __init__(self, d_in, n_out=1, bias_x=True, bias_y=True): | |
| super().__init__() | |
| self.bias_x, self.bias_y = bias_x, bias_y | |
| self.W = nn.Parameter(torch.zeros(n_out, d_in + int(bias_x), d_in + int(bias_y))) | |
| nn.init.xavier_uniform_(self.W) | |
| def forward(self, x, y): | |
| if self.bias_x: | |
| x = torch.cat([x, torch.ones_like(x[..., :1])], -1) | |
| if self.bias_y: | |
| y = torch.cat([y, torch.ones_like(y[..., :1])], -1) | |
| s = torch.einsum("bxi,oij,byj->boxy", x, self.W, y) | |
| return s.squeeze(1) if s.shape[1] == 1 else s | |
| class ParserConfig: | |
| d_arc: int = 500 | |
| d_rel: int = 150 | |
| dropout: float = 0.33 | |
| n_labels: int = 40 | |
| class BiaffineHead(nn.Module): | |
| """Arc + label scoring over word vectors (B,W,D). A learnable ROOT vector is the head | |
| candidate for column 0 (D&M's pseudo-root). Arc loss / label loss are standard CE.""" | |
| def __init__(self, d_in, cfg: ParserConfig): | |
| super().__init__() | |
| self.cfg = cfg | |
| self.root = nn.Parameter(torch.zeros(d_in)) | |
| nn.init.normal_(self.root, std=0.02) | |
| self.arc_dep = MLP(d_in, cfg.d_arc, cfg.dropout) | |
| self.arc_head = MLP(d_in, cfg.d_arc, cfg.dropout) | |
| self.rel_dep = MLP(d_in, cfg.d_rel, cfg.dropout) | |
| self.rel_head = MLP(d_in, cfg.d_rel, cfg.dropout) | |
| self.arc_biaf = Biaffine(cfg.d_arc, n_out=1, bias_x=True, bias_y=False) | |
| self.rel_biaf = Biaffine(cfg.d_rel, n_out=cfg.n_labels, bias_x=True, bias_y=True) | |
| def forward(self, w, word_mask): | |
| """w: (B,W,D) word vectors. word_mask: (B,W) bool, True at real words. | |
| Returns arc_scores (B,W,W+1) [col0=root], rel_scores (B,W,W+1,n_labels).""" | |
| B, W, D = w.shape | |
| root = self.root.view(1, 1, D).expand(B, 1, D) | |
| heads_in = torch.cat([root, w], 1) # (B,W+1,D): col0=root | |
| h_dep_arc = self.arc_dep(w) # (B,W,d_arc) | |
| h_head_arc = self.arc_head(heads_in) # (B,W+1,d_arc) | |
| arc_scores = self.arc_biaf(h_dep_arc, h_head_arc) # (B,W,W+1) | |
| # mask: dependent i cannot pick itself as head (col i+1), and padded cols get -inf | |
| pad_head = torch.cat([torch.ones(B, 1, dtype=torch.bool, device=w.device), word_mask], 1) | |
| arc_scores = arc_scores.masked_fill(~pad_head[:, None, :], float("-inf")) | |
| self_idx = torch.arange(W, device=w.device) | |
| arc_scores[:, self_idx, self_idx + 1] = float("-inf") | |
| h_dep_rel = self.rel_dep(w) # (B,W,d_rel) | |
| h_head_rel = self.rel_head(heads_in) # (B,W+1,d_rel) | |
| rel_scores = self.rel_biaf(h_dep_rel, h_head_rel) # (B,n_labels,W,W+1) | |
| rel_scores = rel_scores.permute(0, 2, 3, 1) # (B,W,W+1,n_labels) | |
| return arc_scores, rel_scores | |
| def loss(self, arc_scores, rel_scores, heads, labels, word_mask): | |
| """heads: (B,W) gold head col index (0=root, else 1..W); labels: (B,W) gold label id; | |
| both -100 where padded/not-a-word.""" | |
| m = heads != -100 | |
| arc_loss = F.cross_entropy(arc_scores[m], heads[m]) | |
| B, W = heads.shape | |
| bi = torch.arange(B, device=heads.device)[:, None].expand(B, W) | |
| wi = torch.arange(W, device=heads.device)[None, :].expand(B, W) | |
| gold_head = heads.clamp(min=0) | |
| sel = rel_scores[bi, wi, gold_head] # (B,W,n_labels) | |
| rel_loss = F.cross_entropy(sel[m], labels[m]) | |
| return arc_loss + rel_loss, dict(arc=round(arc_loss.item(), 4), rel=round(rel_loss.item(), 4)) | |
| def decode(self, arc_scores, rel_scores, word_mask): | |
| """Greedy per-token argmax head (col 0=root) + label argmax at the chosen head. | |
| Not tree-constrained (no MST projection) — the official conll18 LAS/UAS scorer | |
| compares HEAD/DEPREL per token regardless of global tree-validity, so this is a | |
| correct, simple decode for that metric (the standard simplification vs. full MST).""" | |
| heads_out = arc_scores.argmax(-1) # (B,W) in [0..W], 0=root | |
| B, W = heads_out.shape | |
| bi = torch.arange(B, device=arc_scores.device)[:, None].expand(B, W) | |
| wi = torch.arange(W, device=arc_scores.device)[None, :].expand(B, W) | |
| labels_out = rel_scores[bi, wi, heads_out].argmax(-1) | |
| return heads_out.cpu(), labels_out.cpu() | |