Stoicheia-fold-9 / processing_char_bert.py
Ericu950's picture
Publish safetensors weights, config and model card
51c8c10 verified
Raw
History Blame Contribute Delete
18.8 kB
"""HF-Hub-compatible processor for Stoicheia: text <-> the model's four input planes.
Wraps the reference normalization/denormalization logic (character classification,
diacritic packing, word/sentence-boundary detection) into a single callable that
produces model-ready tensors, plus decode helpers for the three masking use cases
described in the model card (restoration, accent recovery, re-segmentation).
This is intentionally NOT a `PreTrainedTokenizer` subclass: the underlying encoding is
a row-per-letter, four-parallel-plane structure (not a single token-id stream), which
doesn't fit that base class's assumptions. It follows the same `register_for_auto_class`
mechanism transformers uses for tokenizers/feature extractors, so
`AutoProcessor.from_pretrained(repo_id, trust_remote_code=True)` works the same way.
"""
from __future__ import annotations
import json
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import torch
MASK, BLANK, PAD = 24, 25, 26
UNK_BND, UNK_DIA, UNK_PUNCT = 3, 48, 6
ALPHABET = "αβγδεζηθικλμνξοπρστυφχψω"
LETTER_IDS = {c: i for i, c in enumerate(ALPHABET)}
ID2LETTER = np.array(list(ALPHABET))
_EXTRA_BASE = {
"ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ", "ϰ": "κ", "ϱ": "ρ", "ϖ": "π",
}
_MARK_MAP = {
0x0301: "acute", 0x0341: "acute", 0x0300: "grave", 0x0340: "grave",
0x0342: "circ", 0x0302: "circ", 0x0313: "smooth", 0x0343: "smooth",
0x0314: "rough", 0x0345: "iota", 0x0308: "diaer",
}
_ACC = {"acute": 1, "grave": 2, "circ": 3}
_BR = {"smooth": 1, "rough": 2}
_MARK_CHARS = {"acute": "́", "grave": "̀", "circ": "͂",
"smooth": "̓", "rough": "̔", "iota": "ͅ", "diaer": "̈"}
# punct plane classes (matches data/normalize.py's canonical 6-way scheme):
# 0 none, 1 comma, 2 high-dot(·), 3 colon, 4 period, 5 question/exclamation
_PUNCT_CHARS = {1: ",", 2: "·", 3: ":", 4: ".", 5: ";"}
# "λόγ[5±3]καὶ" -- a lacuna of uncertain width: best guess 5 letters, plausible
# range 5-3..5+3. See CharBertProcessor.restore_elastic.
_ELASTIC_RE = re.compile(r"\[(\d+)±(\d+)\]")
def _pack_dia(acc, br, iota, diaer):
return ((acc * 3 + br) * 2 + iota) * 2 + diaer
def _unpack_dia(d):
diaer = d % 2; d //= 2
iota = d % 2; d //= 2
br = d % 3; acc = d // 3
return acc, br, iota, diaer
@dataclass
class _Encoded:
chars: list # int, may include MASK
boundary: list
dia: list
punct: list
cap: list # original capitalization, for round-tripping non-masked positions
class CharBertProcessor:
"""`processor(text)` -> dict of batched tensors ready for `CharBertModel(**batch)`."""
def __init__(self):
pass
@classmethod
def from_pretrained(cls, *_args, **_kwargs):
return cls()
def save_pretrained(self, save_directory, **_kwargs):
Path(save_directory).mkdir(parents=True, exist_ok=True)
(Path(save_directory) / "processor_config.json").write_text(json.dumps({"processor_class": "CharBertProcessor"}))
# ---------------------------------------------------------------- encode
def _classify(self, text: str) -> _Encoded:
"""Turn raw NFC/NFD polytonic text into per-letter plane lists, damage ('-' runs)
preserved as MASK/UNK positions, gold values kept for every other position."""
nfd = unicodedata.normalize("NFD", text)
chars, boundary, dia, punct, cap = [], [], [], [], []
acc = br = iota = diaer = 0
pending_bnd = 0
i = 0
while i < len(nfd):
ch = nfd[i]
if ch == "-":
run = 0
while i < len(nfd) and nfd[i] == "-":
run += 1
i += 1
for _ in range(run):
chars.append(MASK); boundary.append(UNK_BND)
dia.append(UNK_DIA); punct.append(UNK_PUNCT); cap.append(0)
continue
low = ch.lower()
base = low if low in LETTER_IDS else _EXTRA_BASE.get(low)
if base is not None:
if chars and pending_bnd:
boundary[-1] = pending_bnd
pending_bnd = 0
chars.append(LETTER_IDS[base])
cap.append(1 if ch != low else 0)
boundary.append(0)
dia.append(0) # filled in by trailing combining marks below
punct.append(0)
acc = br = iota = diaer = 0
elif unicodedata.combining(ch) or ord(ch) in _MARK_MAP:
kind = _MARK_MAP.get(ord(ch))
if kind in _ACC:
acc = _ACC[kind]
elif kind in _BR:
br = _BR[kind]
elif kind == "iota":
iota = 1
elif kind == "diaer":
diaer = 1
if dia:
dia[-1] = _pack_dia(acc, br, iota, diaer)
elif ch.isspace():
pending_bnd = max(pending_bnd, 1)
elif ch in ".;!?":
pending_bnd = max(pending_bnd, 2)
if punct:
punct[-1] = 4 if ch == "." else 5
elif ch in ",:··":
if punct:
punct[-1] = {",": 1, "·": 2, "·": 2, ":": 3}.get(ch, 0)
i += 1
if boundary:
boundary[-1] = max(boundary[-1], 2)
return _Encoded(chars, boundary, dia, punct, cap)
def __call__(self, text: str, mask_planes: list[str] | None = None, has_boundaries: bool = True):
"""Encode `text` into model-ready tensors.
mask_planes: any subset of {"chars", "boundary", "dia", "punct"} to force to
UNKNOWN at every position (in addition to any '-' runs, which are always
treated as a damaged/masked span regardless of mask_planes).
has_boundaries: set False for scriptio continua input (no real spaces) so the
boundary plane starts fully UNKNOWN rather than "no boundaries found".
"""
mask_planes = set(mask_planes or [])
enc = self._classify(text)
n = len(enc.chars)
chars = np.array(enc.chars, dtype=np.int64)
boundary = np.array(enc.boundary, dtype=np.int64)
dia = np.array(enc.dia, dtype=np.int64)
punct = np.array(enc.punct, dtype=np.int64)
if "chars" in mask_planes:
chars[:] = MASK
if "boundary" in mask_planes or not has_boundaries:
boundary[:] = UNK_BND
if "dia" in mask_planes:
dia[:] = UNK_DIA
if "punct" in mask_planes:
punct[:] = UNK_PUNCT
batch = dict(
input_ids=torch.from_numpy(chars)[None],
boundary=torch.from_numpy(boundary)[None],
dia=torch.from_numpy(dia)[None],
punct=torch.from_numpy(punct)[None],
seg_id=torch.zeros(1, n, dtype=torch.long),
)
batch["_cap"] = enc.cap # kept out-of-band; not a model input (cap is output-only)
return batch
# ---------------------------------------------------------------- decode
@staticmethod
def _restore_polytonic(chars, dia, cap, boundary, punct=None) -> str:
words, cur = [], []
for i in range(len(chars)):
ch = ID2LETTER[chars[i]] if chars[i] < 24 else "?"
a, b, io, dd = _unpack_dia(int(dia[i]))
if cap[i]:
ch = ch.upper()
s = ch
if b:
s += _MARK_CHARS[{1: "smooth", 2: "rough"}[b]]
if dd:
s += _MARK_CHARS["diaer"]
if a:
s += _MARK_CHARS[{1: "acute", 2: "grave", 3: "circ"}[a]]
if io:
s += _MARK_CHARS["iota"]
cur.append(s)
p = int(punct[i]) if punct is not None else 0
if boundary[i] >= 1:
w = "".join(cur)
if w and w[-1] == "σ":
w = w[:-1] + "ς"
w = unicodedata.normalize("NFC", w)
if p in _PUNCT_CHARS:
w += _PUNCT_CHARS[p]
elif boundary[i] == 2:
w += "."
words.append(w)
cur = []
if cur:
w = unicodedata.normalize("NFC", "".join(cur))
p = int(punct[-1]) if punct is not None else 0
if p in _PUNCT_CHARS:
w += _PUNCT_CHARS[p]
words.append(w)
return " ".join(words)
def decode_restoration(self, model_out, batch, predict_punct: bool = True,
sentence_breaks: bool = True, gap_word_breaks: bool = True) -> str:
"""Fill masked positions with the model's argmax predictions; keep every
other position exactly as given. Each plane is filled independently
wherever IT is unknown -- chars/cap only inside a '-' gap (chars==MASK),
but boundary/dia/punct wherever THAT plane is UNK, which may be the whole
sequence if mask_planes was also used for joint gap+accent+boundary
restoration (not just the '-' gap itself).
`predict_punct=False` leaves punctuation exactly as given instead of filling
it from the model. Documentary fine-tunes are trained on inscriptions and
papyri, whose editions carry almost no punctuation, so their punctuation head
is weakly supervised and tends to sprinkle stops into an otherwise correct
reading; epigraphic and papyrological use generally wants it off.
`sentence_breaks=False` demotes every *predicted* sentence boundary to a plain
word boundary, so a filled gap comes back as running text. The same fine-tunes
read editions in which sentence division is editorial rather than attested, and
will happily place a full stop inside a word they otherwise restore correctly.
`gap_word_breaks=False` forbids new word division *inside* a filled gap, so the
restored letters continue the surrounding word. This is the common editorial
case -- a break within a single word, as in `στεφά--- ἀρετῆς` -- where the model
recovers the letters correctly but the boundary head, which is free to segment
anywhere, may cut them into pieces. Leave it on when the lacuna plausibly spans
a word boundary."""
pred_char = model_out.char.argmax(-1)[0].tolist()
pred_bnd = model_out.boundary.argmax(-1)[0].tolist()
pred_dia = model_out.dia.argmax(-1)[0].tolist()
pred_cap = model_out.cap.argmax(-1)[0].tolist()
pred_punct = model_out.punct.argmax(-1)[0].tolist()
chars = batch["input_ids"][0].tolist()
boundary = batch["boundary"][0].tolist()
dia = batch["dia"][0].tolist()
punct = batch["punct"][0].tolist()
cap = batch["_cap"]
was_masked = [c == MASK for c in chars]
for i in range(len(chars)):
if chars[i] == MASK:
chars[i] = pred_char[i] if pred_char[i] < 24 else 0
cap[i] = pred_cap[i]
if boundary[i] == UNK_BND:
boundary[i] = 0 if (not gap_word_breaks and was_masked[i]) else pred_bnd[i]
if not sentence_breaks and boundary[i] == 2:
boundary[i] = 1
if dia[i] == UNK_DIA:
dia[i] = pred_dia[i]
if punct[i] == UNK_PUNCT:
punct[i] = pred_punct[i] if predict_punct else 0
return self._restore_polytonic(chars, dia, cap, boundary, punct)
def decode_diacritics(self, model_out, batch) -> str:
"""Replace the diacritic plane with the model's predictions; letters/boundaries/
capitalization/punctuation are taken from the input as given."""
pred_dia = model_out.dia.argmax(-1)[0].tolist()
chars = batch["input_ids"][0].tolist()
boundary = batch["boundary"][0].tolist()
punct = batch["punct"][0].tolist()
cap = batch["_cap"]
return self._restore_polytonic(chars, pred_dia, cap, boundary, punct)
def decode_boundaries(self, model_out, batch) -> str:
"""Replace the boundary plane with the model's predictions (0/1/2); letters/
diacritics/capitalization/punctuation are taken from the input as given.
Only useful when the input truly has no accents either (a spaced-out or
scriptio-continua text that already carries accents gives the boundary head
a strong shortcut -- each word carries exactly one accent -- so this isn't a
meaningful standalone test of the boundary head specifically; see
decode_restoration/restore_elastic for the realistic joint case)."""
pred_bnd = model_out.boundary.argmax(-1)[0].tolist()
chars = batch["input_ids"][0].tolist()
dia = batch["dia"][0].tolist()
punct = batch["punct"][0].tolist()
cap = batch["_cap"]
return self._restore_polytonic(chars, dia, cap, pred_bnd, punct)
def restore_respaced(self, model, text: str, **kw) -> str:
"""Restore a gap, then re-decide word division on the completed text.
The five planes are predicted independently in one pass, which is fine when the
whole context is bare (everything is decided together) but unreliable when a gap
sits inside already-spaced text: the boundary head sees a half-known segmentation
and hedges, so a correctly restored word can come back cut in two.
This does it in the order an editor would: fill the letters first, throw away the
spacing entirely, and run the model again over the resulting *scriptio continua*
with the boundary and diacritic planes unknown everywhere -- the regime the model
was pretrained on. `text` may carry either a `-` run or a `[N±M]` marker.
"""
if _ELASTIC_RE.search(text):
filled, _, _ = self.restore_elastic(model, text, **kw)
else:
batch = self(text)
with torch.no_grad():
out = model(**{k: v for k, v in batch.items() if not k.startswith("_")})
filled = self.decode_restoration(out, batch, **kw)
letters = "".join(ch for ch in unicodedata.normalize("NFD", filled)
if unicodedata.category(ch).startswith("L"))
batch = self(letters, mask_planes=["dia", "boundary"], has_boundaries=False)
with torch.no_grad():
out = model(**{k: v for k, v in batch.items() if not k.startswith("_")})
return self.decode_restoration(out, batch)
def restore_elastic(self, model, text: str, min_width: int = 1,
mask_dia_boundary: bool = False, predict_punct: bool = True,
sentence_breaks: bool = True, gap_word_breaks: bool = True):
"""Restore a lacuna of *uncertain* width -- the realistic editorial case,
since editors estimate a lacuna's length, they rarely know it exactly.
`text` must contain exactly one `[N±M]` marker (best-guess width N,
plausible range N-M..N+M), e.g. `"λόγ[5±3]καὶ ὁ λόγος ἦν πρὸς τὸν θεόν"`.
For every candidate width in that range, this fills the gap, then scores
each candidate by the mean log-probability of the model's own letter
predictions inside the gap specifically (that's what distinguishes widths).
`mask_dia_boundary` controls what happens OUTSIDE the gap:
- False (default): real accents/word-boundaries already present in
`text` are kept as given -- only the gap itself is filled. Use this
for text where the surrounding context is already known/accented (the
common editorial case: a lacuna in an otherwise-legible inscription).
- True: accents and word-boundaries are masked and reconstructed
everywhere, not just inside the gap -- for fully bare scriptio
continua surrounding the lacuna too (no spaces, no accents at all).
Returns `(best_text, best_width, candidates)`, where `candidates` is every
`(width, filled_text, mean_logp)` tried, sorted best-first. Needs the model
(not just its output), since it runs one forward pass per candidate width.
"""
m = _ELASTIC_RE.search(text)
if not m:
raise ValueError("text must contain one '[N±M]' marker, e.g. 'λόγ[5±3]καὶ'")
n, spread = int(m.group(1)), int(m.group(2))
prefix, suffix = text[:m.start()], text[m.end():]
gap_start = len(self._classify(prefix).chars)
candidates = []
for L in range(max(min_width, n - spread), n + spread + 1):
probe = prefix + ("-" * L) + suffix
if mask_dia_boundary:
batch = self(probe, mask_planes=["dia", "boundary"], has_boundaries=False)
else:
batch = self(probe)
with torch.no_grad():
out = model(**{k: v for k, v in batch.items() if not k.startswith("_")})
logp = torch.log_softmax(out.char, dim=-1)[0]
pred_char = out.char.argmax(-1)[0].tolist()
gap_logp = sum(logp[gap_start + i, pred_char[gap_start + i]].item()
for i in range(L)) / L
pred_bnd = out.boundary.argmax(-1)[0].tolist()
pred_dia = out.dia.argmax(-1)[0].tolist()
pred_cap = out.cap.argmax(-1)[0].tolist()
pred_punct = out.punct.argmax(-1)[0].tolist()
chars = batch["input_ids"][0].tolist()
boundary = batch["boundary"][0].tolist()
dia = batch["dia"][0].tolist()
punct = batch["punct"][0].tolist()
cap = batch["_cap"]
was_masked = [c == MASK for c in chars]
for i in range(len(chars)):
if chars[i] == MASK:
chars[i] = pred_char[i] if pred_char[i] < 24 else 0
cap[i] = pred_cap[i]
if boundary[i] == UNK_BND:
boundary[i] = 0 if (not gap_word_breaks and was_masked[i]) else pred_bnd[i]
if not sentence_breaks and boundary[i] == 2:
boundary[i] = 1
if dia[i] == UNK_DIA:
dia[i] = pred_dia[i]
if punct[i] == UNK_PUNCT:
punct[i] = pred_punct[i] if predict_punct else 0
filled = self._restore_polytonic(chars, dia, cap, boundary, punct)
candidates.append((L, filled, gap_logp))
candidates.sort(key=lambda c: -c[2])
best_L, best_text, _ = candidates[0]
return best_text, best_L, candidates