Stoicheia-code / insc /eval /restore_strict_papyri.py
Ericu950's picture
Stoicheia: training and evaluation code
5952424 verified
Raw
History Blame Contribute Delete
7.25 kB
"""STRICT protocol restoration eval for PAPYRI -- same methodology as restore_strict.py
(inscriptions), applied to whole DDbDP documents instead of pre-split segments.
Protocol (identical to restore_strict.py, no relaxations):
- unit: L consecutive characters (letters and/or spaces) of the document's flattened
text -- SPACES COUNT, exactly as for inscriptions. 8 letters + 2 word boundaries
inside the gap = L10, not L8.
- context: the WHOLE papyrus document (up to 3000 flattened chars; ctx=3200 letters
in eval_ours guarantees every sample's full document fits in the model's window --
no truncation to a local span around the gap).
- our model predicts word division inside the gap (boundary head); division errors
count in CER and break exact-match, same as inscriptions.
- documents containing '…' (extent="unknown" gaps -- indeterminate length) are
EXCLUDED entirely: their surrounding lacunae have no fixed width, which would make
the "other lacunae are known-width unknowns" assumption invalid. Only '-' runs
(known-width lost text) are kept as in-context lacunae, exactly like inscriptions.
- unlike inscriptions' ithaca_text (already flattened: lowercase, accentless, only
letters/space/'-'), papyri_clean.jsonl keeps real accents/case/extra punctuation
(see clean_papyri.py). flatten() folds to the SAME reduced alphabet (24 base
letters + space + '-') that restore_strict.py's text_to_planes()/eval_ours()
already operate on, so all of its beam-search + scoring machinery is reused
UNCHANGED -- this file only supplies papyri-specific data loading.
Modes (same CLI shape as restore_strict.py):
--make-samples: python insc_eval/restore_strict_papyri.py --make-samples --split test \
--n 300 --samples $INS_DATA/strict_test_samples_papyri.json
--ckpt: python insc_eval/restore_strict_papyri.py --ckpt <best.pt> \
--samples $INS_DATA/strict_test_samples_papyri.json --out <out.json>
"""
from __future__ import annotations
import argparse, json, os, re, sys, unicodedata
from pathlib import Path
import numpy as np
sys.path.insert(1, str(Path(__file__).resolve().parents[1] / "data"))
sys.path.insert(2, str(Path(__file__).resolve().parent))
from data.normalize import ALPHABET
from restore_strict import eval_ours, levenshtein, canon # reused verbatim
A_IDX = {c: i for i, c in enumerate(ALPHABET)}
PAP_JSONL = os.path.expandvars("$AGD_DATA/data/papyri_clean.jsonl")
# codepoints that fold onto the 24-letter alphabet (mirrors data/normalize.py's
# _EXTRA_BASE, kept local so this file has no hidden coupling beyond ALPHABET itself)
_EXTRA = {"ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ",
"ϰ": "κ", "ϱ": "ρ", "ϖ": "π"}
_PUNCT_TO_SPACE = str.maketrans({c: " " for c in "·,."})
def to_base_greek(ch):
"""Lowercase + NFD-strip accents + fold sigma/symbol variants -> one of the 24
base letters (chars-plane alphabet), or None if not a modeled Greek letter."""
lo = ch.lower()
lo = _EXTRA.get(lo, lo)
if lo in A_IDX:
return lo
dec = unicodedata.normalize("NFD", lo)
base = _EXTRA.get(dec[0], dec[0]) if dec else ""
return base if base in A_IDX else None
def flatten(text):
"""Fold a papyri_clean.jsonl 'text' string down to restore_strict.py's alphabet:
24 base letters + space + '-' (known-width lacuna) only -- same vocabulary as
inscriptions' ithaca_text, so text_to_planes()/eval_ours() need no changes."""
t = text.translate(_PUNCT_TO_SPACE)
out = []
for ch in t:
if ch in (" ", "-"):
out.append(ch)
else:
b = to_base_greek(ch)
out.append(b if b is not None else " ")
flat = re.sub(r" +", " ", "".join(out)).strip()
return flat
def split_of(tm):
s = str(tm).strip()
if not s or not s[-1].isdigit():
return "train"
test_d = os.environ.get("INSC_TEST_DIGIT", "3")
val_d = os.environ.get("INSC_VAL_DIGIT", "4")
return {val_d: "val", test_d: "test"}.get(s[-1], "train")
def load_papyri(split, exclude=None):
"""Whole papyri documents, flattened. Docs with '…' (unknown-width gap)
dropped entirely (see module docstring)."""
bad_tm = set()
if exclude:
j = json.loads(Path(os.path.expandvars(exclude)).read_text())
bad_tm = {str(x[0]) for x in j["contaminated"] if str(x[0]) != "POSITIVE_CONTROL"}
out = []
with open(PAP_JSONL, encoding="utf-8") as f:
for line in f:
r = json.loads(line)
tm = str(r.get("TM", ""))
sp = split_of(tm)
if sp != split or tm in bad_tm or "…" in (r.get("text") or ""):
continue
t = flatten(r.get("text") or "")
if 50 <= len(t) <= 3000:
out.append(dict(phi_id=tm, text=t))
return out
def make_samples(split, n, lengths, exclude, out_path):
recs = load_papyri(split, exclude)
rng = np.random.default_rng(1234)
rng.shuffle(recs)
print(f"{len(recs)} whole papyri documents in {split} (decontaminated, "
f"flattened, 50-3000 chars, no '…')")
samples = []
for L in lengths:
lrng = np.random.default_rng(0 + L)
tot = 0
for r in recs:
t = r["text"]
if len(t) <= L + 8:
continue
s = int(lrng.integers(4, len(t) - L - 4))
gap = t[s:s + L]
if ("-" in gap or " " in gap or gap.strip(" ") == "" or
(s > 0 and t[s - 1] == " " and gap[0] == " ") or
(t[s + L:s + L + 1] == " " and gap[-1] == " ")):
continue
samples.append(dict(phi_id=r["phi_id"], L=L, start=s, gold=gap, text=t))
tot += 1
if tot >= n:
break
print(f"L={L}: {tot} samples")
Path(os.path.expandvars(out_path)).write_text(json.dumps(samples))
print(f"wrote {len(samples)} samples -> {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--make-samples", action="store_true")
ap.add_argument("--split", default="test")
ap.add_argument("--n", type=int, default=300)
ap.add_argument("--lengths", default="1,2,3,4,5,6,7,8,9,10")
ap.add_argument("--exclude", default=None)
ap.add_argument("--samples", required=True)
ap.add_argument("--ckpt", default=None)
ap.add_argument("--out", default=None)
ap.add_argument("--beam", type=int, default=20)
ap.add_argument("--ctx", type=int, default=3200,
help="letter-window width around the gap; >= max doc length (3000 "
"chars, fewer letters after flattening) guarantees full-document context")
ap.add_argument("--shard", default=None, help="i,k -> process samples[i::k]")
a = ap.parse_args()
lengths = [int(x) for x in a.lengths.split(",")]
if a.make_samples:
make_samples(a.split, a.n, lengths, a.exclude, a.samples)
elif a.ckpt:
eval_ours(a.ckpt, a.samples, a.out, beam_width=a.beam, ctx=a.ctx,
lengths_filter=set(lengths), shard=a.shard)
else:
raise SystemExit("need --make-samples or --ckpt")
if __name__ == "__main__":
main()