Download insc/eval/restore_strict.py from Ericu950/Stoicheia-code: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/insc/eval/restore_strict.py
- Command line
-
hf download hf://Ericu950/Stoicheia-code/insc/eval/restore_strict.py
-
curl -L -o restore_strict.py https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/insc/eval/restore_strict.py
15.9 kB
| """STRICT Ithaca-protocol restoration eval — fully symmetric between models. | |
| Protocol (no asymmetries): | |
| - unit: Ithaca's character space — SPACES COUNT. A gap of length L covers L | |
| consecutive characters (letters and/or spaces) of the inscription's ithaca_text. | |
| - context: the WHOLE inscription as edited, other lacunae ('-' runs) included as | |
| unknowns of stated width. length 50..767 (Ithaca's model window). | |
| - our model must PREDICT word division inside the gap (boundary head); division | |
| errors count in CER and break exact-match. Sigma variants folded (final sigma is | |
| deterministic given division). | |
| - CER = Levenshtein over the gap's char string (spaces incl.) / L; top-1/top-20 | |
| exact over the same string. n per length, lengths 1..10, decontaminated test. | |
| Modes: | |
| --make-samples: generate the frozen sample file both models consume. | |
| python insc_eval/restore_strict.py --make-samples --split test --n 300 \ | |
| --exclude $INS_DATA/contaminated_test_fold0.json --samples $INS_DATA/strict_test_samples.json | |
| --ckpt: evaluate OUR model on a samples file. | |
| python insc_eval/restore_strict.py --ckpt <best.pt> --samples ... --out ... | |
| (The Ithaca side consumes the same samples file via ithaca_baseline.py --samples.) | |
| """ | |
| from __future__ import annotations | |
| import argparse, json, os, sys | |
| from pathlib import Path | |
| import numpy as np | |
| sys.path.insert(1, str(Path(__file__).resolve().parents[1] / "data")) | |
| from data.normalize import ALPHABET | |
| ALIST = list(ALPHABET) | |
| A_IDX = {c: i for i, c in enumerate(ALIST)} | |
| RAW_JSONL = os.path.expandvars("$INS_DATA/raw/iphi.jsonl") | |
| MASK, UNK_BND, UNK_DIA, UNK_PUNCT = 24, 3, 48, 6 | |
| # metadata-conditioned checkpoints (finetune_whole.py) read region/century inputs; this | |
| # harness always feeds UNK -- Ithaca predicts date/region rather than consuming them, so | |
| # giving ours the true values would break the symmetry of the comparison. | |
| UNK_REGION, UNK_CENTURY = 14, 15 | |
| FOLD = {"ς": "σ", "ϲ": "σ", "ϙ": "κ", "ϛ": "σ"} | |
| def levenshtein(a, b): | |
| if not a: return len(b) | |
| if not b: return len(a) | |
| prev = list(range(len(b) + 1)) | |
| for i, ca in enumerate(a, 1): | |
| cur = [i] + [0] * len(b) | |
| for j, cb in enumerate(b, 1): | |
| cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)) | |
| prev = cur | |
| return prev[-1] | |
| def canon(s): | |
| return "".join(FOLD.get(c, c) for c in s) | |
| def load_inscriptions(split, exclude=None): | |
| """Whole inscriptions: ithaca_text (lowercase accentless, spaces, '-' damage).""" | |
| bad_phi = set() | |
| if exclude: | |
| j = json.loads(Path(os.path.expandvars(exclude)).read_text()) | |
| bad_phi = {int(x[0]) for x in j["contaminated"] | |
| if str(x[0]) != "POSITIVE_CONTROL"} # inscription-level exclusion | |
| out = [] | |
| with open(RAW_JSONL) as f: | |
| for line in f: | |
| r = json.loads(line) | |
| pid = int(r["PHI_ID"]) | |
| test_d = os.environ.get("INSC_TEST_DIGIT", "3") | |
| val_d = os.environ.get("INSC_VAL_DIGIT", "4") | |
| sp = {test_d: "test", val_d: "val"}.get(str(pid)[-1], "train") | |
| if sp != split or pid in bad_phi: | |
| continue | |
| t = (r.get("ithaca_text") or "").strip().lower() | |
| t = " ".join(t.split()) | |
| t = canon(t) | |
| if 50 <= len(t) <= 767: | |
| out.append(dict(phi_id=pid, text=t)) | |
| return out | |
| def make_samples(split, n, lengths, exclude, out_path): | |
| recs = load_inscriptions(split, exclude) | |
| rng = np.random.default_rng(1234) | |
| rng.shuffle(recs) | |
| print(f"{len(recs)} whole inscriptions in {split} (decontaminated, 50-767 chars)") | |
| 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] | |
| # gap must be attested text: no '-' damage, no double space, not | |
| # space-adjacent-to-space at the edges (unscorable), >=1 letter | |
| 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}") | |
| # ---------------- our model: joint boundary+letter beam in their char space ------- | |
| def text_to_planes(t): | |
| """ithaca_text -> letter ids, boundary flags (1 = space after), letter->char pos.""" | |
| ids, bnd, pos = [], [], [] | |
| for i, ch in enumerate(t): | |
| if ch == " ": | |
| if bnd: | |
| bnd[-1] = 1 | |
| elif ch == "-": | |
| ids.append(MASK); bnd.append(UNK_BND); pos.append(i) | |
| elif ch in A_IDX: | |
| ids.append(A_IDX[ch]); bnd.append(0); pos.append(i) | |
| # any other char: skip (ithaca_text is clean) | |
| return np.array(ids, np.int64), np.array(bnd, np.int64), pos | |
| def space_patterns(L, prev_is_letter, next_exists, max_spaces=3): | |
| """All ways to place spaces among the L gap slots (internal Greek: no ' ').""" | |
| from itertools import combinations | |
| slots = list(range(L)) | |
| pats = [] | |
| for k in range(0, min(max_spaces, L) + 1): | |
| for c in combinations(slots, k): | |
| if any(b - a == 1 for a, b in zip(c, c[1:])): | |
| continue # no double space | |
| if L - k == 0: | |
| continue # at least one letter | |
| pats.append(tuple(c)) | |
| return pats | |
| def eval_ours(ckpt, samples_path, out_path, beam_width=20, ctx=768, pat_keep=4, | |
| lengths_filter=None, shard=None): | |
| import torch | |
| from eval.intrinsic import load_model | |
| sys.path.insert(2, str(Path(__file__).resolve().parent)) | |
| from restore import beam_restore, _char_logp # reuse the letter beam | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model, _ = load_model(os.path.expandvars(ckpt), device) | |
| model.cfg.attn_impl = "sdpa" | |
| model.eval() | |
| samples = json.loads(Path(os.path.expandvars(samples_path)).read_text()) | |
| if lengths_filter: | |
| samples = [s for s in samples if s["L"] in lengths_filter] | |
| if shard: | |
| i, k = (int(x) for x in shard.split(",")) | |
| samples = samples[i::k] | |
| rows = {} | |
| import time | |
| t0 = time.time() | |
| def fill_forward(ids_list, bnd_row, free_idx): | |
| """Batched forward on FILLED candidates; returns log P(word-end) per free site.""" | |
| B = len(ids_list); T = len(ids_list[0]) | |
| batch = dict( | |
| input_ids=torch.tensor(np.stack(ids_list), dtype=torch.long, device=device), | |
| boundary=torch.tensor(bnd_row, dtype=torch.long, device=device)[None].expand(B, T).contiguous(), | |
| dia=torch.full((B, T), UNK_DIA, dtype=torch.long, device=device), | |
| punct=torch.full((B, T), UNK_PUNCT, dtype=torch.long, device=device), | |
| region=torch.full((B, T), UNK_REGION, dtype=torch.long, device=device), | |
| century=torch.full((B, T), UNK_CENTURY, dtype=torch.long, device=device), | |
| seg_id=torch.ones(B, T, dtype=torch.long, device=device)) | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"): | |
| ob = model(batch)["boundary"] | |
| pb = torch.softmax(ob.float(), -1) | |
| p_end = (pb[:, :, 1:3].sum(-1)).clamp(1e-4, 1 - 1e-4) # word-end or sent-end | |
| return p_end[:, free_idx].log().cpu().numpy(), (1 - p_end)[:, free_idx].log().cpu().numpy() | |
| A_STR = "".join(ALIST) | |
| for si, smp in enumerate(samples): | |
| t, s, L, gold = smp["text"], smp["start"], smp["L"], smp["gold"] | |
| pre, post = t[:s], t[s + L:] | |
| lead_ok = not pre.endswith(" ") and len(pre) > 0 | |
| trail_ok = not post.startswith(" ") and len(post) > 0 | |
| hyps = {} | |
| for k in range(max(1, L - 3), L + 1): | |
| n_sp = L - k | |
| # sites: [leading] + after each of the k letters (last = trailing) | |
| n_sites = k + 1 | |
| if n_sp > (1 if lead_ok else 0) + (k - 1) + (1 if trail_ok else 0): | |
| continue | |
| full = pre + "\u03b1" * k + post # k placeholder letters, no spaces | |
| ids, bnd, pos = text_to_planes(full) | |
| npre = sum(1 for q in pos if q < s) | |
| gidx = list(range(npre, npre + k)) | |
| ids2 = ids.copy(); ids2[gidx] = MASK | |
| bnd2 = np.where(bnd == UNK_BND, UNK_BND, np.minimum(bnd, 2)) | |
| bnd2[gidx] = UNK_BND # free division inside the gap | |
| if lead_ok and npre > 0: | |
| bnd2[npre - 1] = UNK_BND # leading-space site is free too | |
| # context parity holds by construction: samples are whole inscriptions | |
| # capped at 767 THEIR-chars (make_samples), i.e. both models always see | |
| # the complete text — this letter-window is never the binding constraint | |
| lo = max(0, gidx[0] - ctx // 2); hi = min(len(ids2), gidx[-1] + ctx // 2) | |
| window = ids2[lo:hi].copy() | |
| gap_rel = [g - lo for g in gidx] | |
| bw = bnd2[lo:hi].copy() | |
| with torch.no_grad(): | |
| cand = beam_restore(model, window, gap_rel, bw, device, beam_width) | |
| if not cand: | |
| continue | |
| cand = cand[:8] # fill-scored candidates per k | |
| filled = [] | |
| for txt24, _ in cand: | |
| w2 = window.copy() | |
| for j, chs in zip(gap_rel, txt24): | |
| w2[j] = A_STR.index(chs) | |
| filled.append(w2) | |
| # free sites (window-relative letter indices): pre letter + gap letters | |
| site_idx = ([gap_rel[0] - 1] if lead_ok and gap_rel[0] > 0 else []) + gap_rel | |
| lp_end, lp_no = fill_forward(filled, bw, site_idx) | |
| has_lead = bool(site_idx and lead_ok and gap_rel[0] > 0 and | |
| site_idx[0] == gap_rel[0] - 1) | |
| for ci, (txt24, bscore) in enumerate(cand): | |
| pe, pn = lp_end[ci], lp_no[ci] | |
| # valid site positions in the site list | |
| valid = [] | |
| for vi in range(len(site_idx)): | |
| if vi == 0 and has_lead: | |
| valid.append(vi) # leading space | |
| elif vi == len(site_idx) - 1: | |
| if trail_ok: | |
| valid.append(vi) # trailing space | |
| elif not (vi == 0 and has_lead): | |
| valid.append(vi) # space between letters | |
| order = sorted(valid, key=lambda v: -pe[v]) | |
| chosen = set(order[:n_sp]) if n_sp else set() | |
| if len(chosen) < n_sp: | |
| continue | |
| arr_lp = sum(pe[v] for v in chosen) + \ | |
| sum(pn[v] for v in valid if v not in chosen) | |
| total = bscore + arr_lp | |
| norm = total / (k + len(valid)) | |
| # render into the shared char space | |
| out_str = "" | |
| li = 0 | |
| for vi in range(len(site_idx)): | |
| if vi == 0 and has_lead: | |
| if vi in chosen: | |
| out_str += " " | |
| continue | |
| out_str += txt24[li]; li += 1 | |
| if vi in chosen: | |
| out_str += " " | |
| if len(out_str) != L or li != len(txt24): | |
| continue # malformed render: reject | |
| if out_str not in hyps or hyps[out_str] < norm: | |
| hyps[out_str] = norm | |
| ranked = sorted(hyps.items(), key=lambda kv: -kv[1])[:beam_width] | |
| preds = [k for k, _ in ranked] | |
| gold_c = canon(gold) | |
| r = rows.setdefault(L, dict(L=L, n=0, cers=[], t1=0, t20=0, cers_let=[])) | |
| pred = preds[0] if preds else "" | |
| r["cers"].append(levenshtein(pred, gold_c) / max(len(gold_c), 1)) | |
| # letters-only CER: same prediction, spaces stripped from both sides. The char CER | |
| # above is the Ithaca-comparable number, but ~15% of every gap is word breaks, | |
| # which are far easier to predict than letters -- so char CER is NOT comparable to | |
| # the whole-document eval (where all L positions are letters). This column is. | |
| gl = gold_c.replace(" ", "") | |
| r["cers_let"].append(levenshtein(pred.replace(" ", ""), gl) / max(len(gl), 1)) | |
| r["t1"] += int(pred == gold_c) | |
| r["t20"] += int(any(p == gold_c for p in preds)) | |
| r["n"] += 1 | |
| if (si + 1) % 50 == 0: | |
| rate = (time.time() - t0) / (si + 1) | |
| print(f" {si+1}/{len(samples)} ({rate:.1f}s/sample)", flush=True) | |
| _write_report(rows, ckpt, out_path, n_done=si + 1, n_total=len(samples)) | |
| _write_report(rows, ckpt, out_path, n_done=len(samples), n_total=len(samples), final=True) | |
| def _write_report(rows, ckpt, out_path, n_done, n_total, final=False): | |
| """Write the current per-L / avg report -- called periodically during the sample | |
| loop (not just at the very end) so a time-limit kill never loses everything, same | |
| fix as fold_crux.py's incremental checkpointing.""" | |
| out_rows = [] | |
| for L in sorted(rows): | |
| r = rows[L] | |
| row = dict(L=L, n=r["n"], CER=round(float(np.mean(r["cers"])), 4), | |
| CER_letters=round(float(np.mean(r["cers_let"])), 4) | |
| if r.get("cers_let") else None, | |
| top1=round(r["t1"] / max(r["n"], 1), 4), | |
| top20=round(r["t20"] / max(r["n"], 1), 4)) | |
| out_rows.append(row) | |
| if final: | |
| print(f"L={L:>2} CER={row['CER']:.4f} CERlet={row['CER_letters']:.4f} " | |
| f"top1={row['top1']:.4f} top20={row['top20']:.4f} (n={row['n']})", | |
| flush=True) | |
| avg = {k: round(float(np.mean([r[k] for r in out_rows if r[k] is not None])), 4) | |
| for k in ("CER", "CER_letters", "top1", "top20")} | |
| tag = "STRICT AVG" if final else f"[checkpoint {n_done}/{n_total}]" | |
| print(f"{tag}: CER={avg['CER']:.4f} CERlet={avg['CER_letters']:.4f} " | |
| f"top1={avg['top1']:.4f} top20={avg['top20']:.4f}", flush=True) | |
| Path(os.path.expandvars(out_path)).write_text(json.dumps(dict( | |
| ckpt=ckpt, protocol="strict", per_L=out_rows, avg=avg, | |
| n_done=n_done, n_total=n_total, complete=final), indent=1)) | |
| 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("--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, | |
| lengths_filter=set(lengths), shard=a.shard) | |
| else: | |
| raise SystemExit("need --make-samples or --ckpt") | |
| if __name__ == "__main__": | |
| main() | |