| """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_CHARS = {1: ",", 2: "·", 3: ":", 4: ".", 5: ";"} |
|
|
| |
| |
| _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 |
| boundary: list |
| dia: list |
| punct: list |
| cap: list |
|
|
|
|
| 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"})) |
|
|
| |
|
|
| 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) |
| 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 |
| return batch |
|
|
| |
|
|
| @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 |
|
|