File size: 9,071 Bytes
140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 140267e aa29302 | 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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | # imports
from __future__ import annotations
import string
from typing import Callable
import spacy
from spacy.language import Language
from spacy.tokens import Doc, Token
from spacy.tokenizer import Tokenizer
from spacy.util import (
compile_infix_regex,
compile_prefix_regex,
compile_suffix_regex,
)
# ----- canonical text normalization: surface (token.text) + NORM (token.norm_) ----- #
#
# The grc treebanks encode the elision apostrophe four different ways — Perseus
# U+0313 (a combining-diacritic Betacode artifact), PROIEL/GLAUx U+2019, PTNK
# U+1FBF — which makes tokenization AND evals inconsistent. We canonicalize to
# U+2019 (Unicode Standard §6.2; Nick Nicholas; James Tauber's greek-normalisation)
# and adopt Tauber's `greek-normalisation` for the heavy lifting. This runs
# IDENTICALLY in treebank conversion (gold, all splits) and in the tokenizer below
# (inference) so gold and runtime can never drift.
#
# normalize_surface/normalize_norm/normalize_lookup_key live in latincy-preprocess
# (latincy_preprocess.grc) — the single shared implementation every grc repo now
# imports, rather than each repo defining (or partially reimplementing) its own
# copy. See latincy-preprocess's tests/test_grc_rules.py for the normalization
# contract this depends on.
from latincy_preprocess.grc import normalize_lookup_key, normalize_norm, normalize_surface
# ----- custom tokenizer: canonical normalization + attached elision ----- #
#
# Runtime mirror of the conversion-time normalization: normalise input text to the
# canonical surface form BEFORE tokenizing (so runtime tokenization matches the
# normalized gold corpus and evals align), keep the elision apostrophe attached
# (drop the apostrophe suffix rules so ἔνθ' / δ' stay one token like the gold), and
# set token.norm_ to the isolation form (ἀλλ'→ἀλλά). Crasis handling composes via the
# separate grc_keep_crasis_whole after_creation callback.
# apostrophe suffix patterns in the grc default ruleset (found by inspection):
# r"\'" (ascii) and "’" (U+2019). Removing them stops a trailing elision mark from
# splitting off as its own token.
_APOS_SUFFIX_PATTERNS = {r"\'", "’", "᾿", "ʼ", "'"}
def _grc_defaults():
from spacy.lang.grc import AncientGreekDefaults
return AncientGreekDefaults
def _grc_suffix_search():
"""grc default suffixes minus the apostrophe patterns."""
d = _grc_defaults()
suffixes = [s for s in d.suffixes
if s not in _APOS_SUFFIX_PATTERNS and "’" not in s]
return compile_suffix_regex(suffixes).search
class GreekTokenizer(Tokenizer):
"""grc tokenizer with LatinCy canonical normalization baked in.
Inherits the full spaCy ``lang/grc`` ruleset but (a) normalises input text via
``normalize_surface`` before tokenizing and (b) drops the apostrophe suffix rule so
the (now canonical U+2019) elision mark stays attached. ``token.norm_`` is set to
the ``normalize_norm`` isolation form. Serialises like the base Tokenizer; the
registered factory below reconstructs it on load.
"""
def __init__(self, vocab):
d = _grc_defaults()
super().__init__(
vocab,
rules=d.tokenizer_exceptions,
prefix_search=compile_prefix_regex(d.prefixes).search,
suffix_search=_grc_suffix_search(),
infix_finditer=compile_infix_regex(d.infixes).finditer,
token_match=d.token_match,
url_match=d.url_match,
)
def __call__(self, text: str) -> Doc:
doc = super().__call__(normalize_surface(text))
for tok in doc:
tok.norm_ = normalize_norm(tok.text)
return doc
@spacy.registry.tokenizers("grc_normalizing_tokenizer.v1")
def create_grc_tokenizer() -> Callable[[Language], Tokenizer]:
"""Factory: `[nlp.tokenizer] @tokenizers = "grc_normalizing_tokenizer.v1"`."""
def create_tokenizer(nlp: Language) -> Tokenizer:
return GreekTokenizer(nlp.vocab)
return create_tokenizer
# ----- Crasis tokenization (GLAUx standard: keep crasis whole) ----- #
def _is_crasis_exception_key(key: str) -> bool:
"""True if `key` is a single all-Greek-letter word (i.e. a crasis form).
spaCy's built-in ``grc`` tokenizer ships exceptions that split crasis forms
into their underlying words (κἀγώ → κἀ + γώ, κᾆτα → κ + ᾆτα, τοὔνομα → τοὔ +
νομα, …). GLAUx treats crasis as a single token, so our training corpus keeps
κἀγὼ whole (1075× as one PRON). Dropping these exceptions aligns runtime
tokenization with the gold standard.
Elision exceptions (δ', ἀλλ', παρ') are single-token and their apostrophe
makes ``str.isalpha()`` False, so they are preserved.
"""
return bool(key) and key.isalpha() and all(
"Ͱ" <= c <= "Ͽ" or "ἀ" <= c <= "" for c in key
)
@spacy.registry.callbacks("grc_keep_crasis_whole.v1")
def make_keep_crasis_whole() -> Callable[[Language], Language]:
"""`[nlp] after_creation` callback: keep crasis whole (GLAUx standard).
Removes the crasis-splitting exceptions from the tokenizer so forms like
κἀγὼ remain a single token, matching the GLAUx training corpus. This is a
pure tokenizer change baked into the serialized model — no retraining needed.
"""
def keep_crasis_whole(nlp: Language) -> Language:
rules = dict(nlp.tokenizer.rules)
for key, value in list(rules.items()):
if len(value) > 1 and _is_crasis_exception_key(key):
del rules[key]
nlp.tokenizer.rules = rules
return nlp
return keep_crasis_whole
# ----- lookup_lemmatizer ----- #
_LOOKUPS = None
def _get_lookups():
"""Load Greek lemma lookup table from the installed grc-latincy-lookups
package, via spaCy's lookup entry-point system.
Dev/training-time fallback only — see lookup_lemmatizer, which prefers the
table embedded in the model's own vocab.lookups when present. Returns a
spaCy Table object (dict-like, supports .get()).
"""
global _LOOKUPS
if _LOOKUPS is None:
from spacy.lookups import load_lookups
lookups_data = load_lookups(lang="grc", tables=["lemma_lookup"])
_LOOKUPS = lookups_data.get_table("lemma_lookup")
return _LOOKUPS
Token.set_extension("predicted_lemma", default=None, force=True)
@Language.component(name="lookup_lemmatizer")
def lookup_lemmatizer(doc: Doc) -> Doc:
"""Lookup-based lemmatizer for Ancient Greek.
Assigns lemmas using a 1.2M-entry dictionary built from CLTK Morpheus,
UD treebanks, and Wiktionary. Normalizes grave→acute accents at query
time so running-text forms (φονὸς) match citation entries (φονός).
Runs after trainable_lemmatizer: overrides only when a lookup match
exists, preserving the trainable model's output for unseen forms.
Prefers the lemma table baked into this model's own vocab.lookups (done at
packaging time by prepare_package.py / repackage_patch.sh) — published
wheels are self-contained and need no extra pip package at inference time.
Falls back to the pip-installed grc-latincy-lookups package for local
dev/training, before the table has been injected into vocab. Single
function for both cases, rather than the two independently-maintained
lookup_lemmatizer copies (dev vs. packaging-ready) this repo used to carry.
"""
if doc.vocab.lookups.has_table("lemma_lookup"):
lookups = doc.vocab.lookups.get_table("lemma_lookup")
else:
lookups = _get_lookups()
for token in doc:
# Store trainable lemmatizer's prediction
token._.predicted_lemma = token.lemma_
# Skip punctuation
if token.pos_ == "PUNCT" or token.text in string.punctuation:
continue
# Normalize for lookup (grave→acute, strip macron/breve, elision->U+2019)
normalized = normalize_lookup_key(token.text)
# Direct match
if normalized in lookups:
token.lemma_ = lookups[normalized]
continue
# Case-insensitive fallback for capitalized words
if normalized and normalized[0].isupper():
lower = normalized.lower()
if lower in lookups:
token.lemma_ = lookups[lower]
continue
# Elision fallback: for an elided form the (harmonized U+2019) table still
# misses, resolve via the restored isolation form (normalize_norm: δʼ→δέ,
# ἀλλʼ→ἀλλά) and, failing that, use the restored surface — but NEVER leave the
# trainable lemmatizer's apostrophe-token hallucination (lg: ὧδʼ→ὧδʼῖς, ἀλλʼ→̔ἀλλʼ).
# Ambiguous elisions (μʼ = με/μοι) fall back to their canonical surface, not garbage.
if "’" in normalized:
restored = normalize_norm(token.text)
token.lemma_ = lookups[restored] if restored in lookups else restored
return doc
|