File size: 6,722 Bytes
5952424 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | """Edit scripts (form -> lemma) and the label vocabulary.
Scripts operate on *strings*, not the model's char planes: lowercased NFC with the
form-side folded so that graves become acutes and zero-information marks (macron,
breve, dot-below) are stripped. Accent shifts in inflection (ἀνθρώπου -> ἄνθρωπος)
are then literal prefix/suffix replacements, and applying a script yields the fully
accented lemma directly. Lemma-side strings keep everything except casing, which is
captured in a per-script capitalization bit.
"""
from __future__ import annotations
import json
import unicodedata
from collections import Counter
from dataclasses import dataclass, field
GRAVE, ACUTE = "̀", "́"
_STRIP_MARKS = {"̄", "̆", "̣"} # macron, breve, dot-below
XPOS_LEN = 9
def form_key(s: str) -> str:
"""Fold a surface form for script/lexicon keys: lowercase, grave->acute, strip
macron/breve/underdot. NFC output."""
s = unicodedata.normalize("NFD", s.lower())
s = s.replace(GRAVE, ACUTE)
s = "".join(ch for ch in s if ch not in _STRIP_MARKS)
return unicodedata.normalize("NFC", s)
def lemma_key(s: str) -> str:
"""Lemma side: lowercase + NFC only (macrons/homonym digits kept verbatim)."""
return unicodedata.normalize("NFC", s.lower())
# Script = (p_cut, p_add, s_cut, s_add, cap) : lemma = p_add + form[p_cut:len-s_cut] + s_add
Script = tuple[int, str, int, str, bool]
def _longest_common_substring(a: str, b: str) -> tuple[int, int, int]:
"""(start_a, start_b, length) of the longest common substring; ties -> smallest
start_a (align prefixes, since Greek inflection is mostly suffixal)."""
best = (0, 0, 0)
m = len(b)
prev = [0] * (m + 1)
for i, ca in enumerate(a):
cur = [0] * (m + 1)
for j, cb in enumerate(b):
if ca == cb:
cur[j + 1] = prev[j] + 1
l = cur[j + 1]
if l > best[2]:
best = (i - l + 1, j - l + 1, l)
prev = cur
return best
def compute_script(form: str, lemma: str) -> Script:
"""form: already form_key()-folded, lowercase. lemma: original casing, NFC."""
cap = bool(lemma[:1]) and lemma[0] != lemma[0].lower()
lem = lemma_key(lemma)
ia, ib, l = _longest_common_substring(form, lem)
if l == 0:
return (len(form), lem, 0, "", cap)
return (ia, lem[:ib], len(form) - ia - l, lem[ib + l:], cap)
def apply_script(form: str, sc: Script) -> str | None:
p_cut, p_add, s_cut, s_add, _cap = sc
if len(form) < p_cut + s_cut:
return None
return p_add + form[p_cut:len(form) - s_cut or None] + s_add
def script_str(sc: Script) -> str:
return json.dumps(list(sc), ensure_ascii=False)
@dataclass
class LabelVocab:
scripts: list[Script] = field(default_factory=list)
xpos_alpha: list[list[str]] = field(default_factory=list) # 9 per-position alphabets
tags: list[str] = field(default_factory=list) # attested full XPOS tags
upos: list[str] = field(default_factory=list)
# lexicon: form_key -> {"lemma\txpos": count} and form_key -> {lemma: count}
lex_ft: dict = field(default_factory=dict)
lex_f: dict = field(default_factory=dict)
# rule table for unencodable (non-Greek) forms: raw form -> (lemma, upos, xpos)
nongreek: dict = field(default_factory=dict)
fallback_xpos: str = "u--------"
fallback_upos: str = "u"
def __post_init__(self):
self._sid = {s: i for i, s in enumerate(self.scripts)}
self._tagset = set(self.tags)
self._tid = {t: i for i, t in enumerate(self.tags)}
self._xid = [{c: i for i, c in enumerate(a)} for a in self.xpos_alpha]
self._uid = {u: i for i, u in enumerate(self.upos)}
# ---- ids (return -100 for unseen: ignored in the loss, counted in coverage)
def script_id(self, sc: Script) -> int:
return self._sid.get(sc, -100)
def xpos_ids(self, tag: str) -> list[int]:
tag = (tag or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
return [self._xid[p].get(c, -100) for p, c in enumerate(tag)]
def upos_id(self, u: str) -> int:
return self._uid.get(u, -100)
def tag_id(self, tag: str) -> int:
tag = (tag or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
return self._tid.get(tag, -100)
@property
def n_scripts(self):
return len(self.scripts)
# ---- build / io
@classmethod
def build(cls, sentences, encodable_fn) -> "LabelVocab":
"""sentences: iterable of conllu.Sentence. encodable_fn(form)->bool decides which
tokens go through the neural path vs the non-Greek rule table."""
scripts = Counter()
pos_alpha = [set("-") for _ in range(XPOS_LEN)]
tags = Counter()
upos = Counter()
lex_ft, lex_f = {}, {}
nongreek = {}
for sent in sentences:
for t in sent.tokens:
tag = (t.xpos or "-" * XPOS_LEN)[:XPOS_LEN].ljust(XPOS_LEN, "-")
if not encodable_fn(t.form):
nongreek.setdefault(t.form, Counter())[(t.lemma, t.upos, tag)] += 1
continue
key = form_key(t.form)
sc = compute_script(key, t.lemma)
scripts[sc] += 1
for p, c in enumerate(tag):
pos_alpha[p].add(c)
tags[tag] += 1
upos[t.upos] += 1
lex_ft.setdefault(key + "\t" + tag, Counter())[t.lemma] += 1
lex_f.setdefault(key, Counter())[t.lemma] += 1
return cls(
scripts=[s for s, _ in scripts.most_common()],
xpos_alpha=[sorted(a) for a in pos_alpha],
tags=sorted(tags),
upos=sorted(upos),
lex_ft={k: dict(c) for k, c in lex_ft.items()},
lex_f={k: dict(c) for k, c in lex_f.items()},
nongreek={f: c.most_common(1)[0][0] for f, c in nongreek.items()},
)
def save(self, path):
d = dict(scripts=[list(s) for s in self.scripts], xpos_alpha=self.xpos_alpha,
tags=self.tags, upos=self.upos, lex_ft=self.lex_ft, lex_f=self.lex_f,
nongreek=self.nongreek, fallback_xpos=self.fallback_xpos,
fallback_upos=self.fallback_upos)
with open(path, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
@classmethod
def load(cls, path) -> "LabelVocab":
with open(path, encoding="utf-8") as f:
d = json.load(f)
d["scripts"] = [tuple(s[:4]) + (bool(s[4]),) for s in d["scripts"]]
d["nongreek"] = {k: tuple(v) for k, v in d["nongreek"].items()}
return cls(**d)
|