Stoicheia-code / insc /eval /restore_dsh.py
Ericu950's picture
Stoicheia: training and evaluation code
5952424 verified
Raw
History Blame Contribute Delete
7.81 kB
"""Restoration eval on the DeepMind `predictingthepast` "recent" comparison sets.
WHY THIS EXISTS: that paper's *recent* sets (`inscr_text_recent.jsonl`,
`pap_text_ups.jsonl`) are documents edited AFTER the compared systems' training
data was collected, so neither an 8B instruction-tuned Llama nor Ithaca nor this
model can have memorized them. They are therefore the only sets in that release
on which a three-way comparison is not confounded by contamination -- which is
the whole point of this paper's memorization argument.
SCORING IS THEIRS, NOT OURS. Reproduced verbatim from eval/scripts/eval_llama_text.py
in their release so all systems are scored identically:
* CER = 1 - difflib.SequenceMatcher(None, ref, hyp).ratio() -- a similarity
ratio, NOT the Levenshtein-based CER used elsewhere in this repo. Numbers from
this script are therefore NOT comparable to our clean/whole/strict CERs.
* normalize: fold final sigma, strip spaces, '.', '·' and the digit '0'.
* the hypothesis is truncated to the reference's length before scoring.
* top-1 = normalized(truncate(beam_0)) == normalized(gold);
top-20 = any of the 20 returned beams matches under the same rule.
Their input is lowercase, unaccented Greek with word spaces and exactly one
'[N letters missing]' placeholder. N counts LETTERS ONLY (spaces/punctuation are
excluded from the count), which matches our letters-only channel convention: word
division lives in the boundary plane, so a gap of N letters is N char positions.
python insc/eval/restore_dsh.py --ckpt <ckpt> --data inscr_text_recent.jsonl \
--out recent_inscr.json [--n 500] [--max-ctx 1024]
"""
from __future__ import annotations
import argparse, difflib, json, re, sys
from pathlib import Path
import numpy as np
import torch
sys.path.insert(1, str(Path(__file__).resolve().parents[1] / "data"))
from data.normalize import ALPHABET
from eval.intrinsic import load_model
from meta_vocab import UNK_REGION, UNK_CENTURY
from insc.eval.restore import beam_restore, MASK, UNK_BND
ALIST = list(ALPHABET)
AIDX = {c: i for i, c in enumerate(ALIST)}
AIDX.setdefault("ς", AIDX.get("σ"))
GAP_RE = re.compile(r"\[(\d+) letters? missing\]")
# ---------------------------------------------------------------- their metrics
def normalize_content(s: str) -> str:
return re.sub(r"[ \.·0]", "", s.replace("ς", "σ"))
def truncate_to_real_length(pred: str, ref: str) -> str:
return pred[:len(ref)]
def calculate_cer(reference: str, hypothesis: str) -> float:
reference = normalize_content(reference)
hypothesis = truncate_to_real_length(normalize_content(hypothesis), reference)
return 1 - difflib.SequenceMatcher(None, reference, hypothesis).ratio()
# ---------------------------------------------------------------- encoding
def encode(text: str):
"""Greek text (letters + spaces) -> (char_ids, boundary_ids). Characters we
cannot map are dropped; a space sets boundary=1 on the PRECEDING letter, which
is how word division is represented in this model (never as a char token)."""
chars, bnd = [], []
for ch in text:
if ch == " ":
if bnd:
bnd[-1] = 1
continue
i = AIDX.get(ch)
if i is None:
continue
chars.append(i); bnd.append(0)
return chars, bnd
def build(user_text: str, L: int, max_ctx: int):
"""-> (window chars, gap positions, boundary row) or None. Context is centered on
the gap and truncated symmetrically to max_ctx so long documents stay in budget."""
m = GAP_RE.search(user_text)
if not m:
return None
pre, post = user_text[:m.start()], user_text[m.end():]
cpre, bpre = encode(pre)
cpost, bpost = encode(post)
half = max(0, (max_ctx - L) // 2)
if len(cpre) > half:
cpre, bpre = cpre[-half:], bpre[-half:]
if len(cpost) > half:
cpost, bpost = cpost[:half], bpost[:half]
gap = list(range(len(cpre), len(cpre) + L))
window = np.array(cpre + [MASK] * L + cpost, dtype=np.int64)
brow = np.array(bpre + [UNK_BND] * L + bpost, dtype=np.int64)
# a space immediately before the gap is unknowable evidence about the gap's
# first letter only in their format; keep it as given, consistent with restore.py
return window, gap, brow
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", required=True)
ap.add_argument("--data", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--n", type=int, default=0, help="0 = all records")
ap.add_argument("--beam", type=int, default=20)
ap.add_argument("--max-ctx", type=int, default=1024)
ap.add_argument("--shard", default="", help="i,k")
a = ap.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
import os
model, _ = load_model(os.path.expandvars(a.ckpt), device) # returns (model, cfg)
model.eval()
rows = [json.loads(l) for l in open(a.data, encoding="utf-8")]
if a.shard:
i, k = (int(x) for x in a.shard.split(","))
rows = rows[i::k]
if a.n:
rows = rows[:a.n]
per_len, skipped = {}, 0
t1 = t20 = tot = 0
for ri, r in enumerate(rows):
msgs = {m["role"]: m["content"] for m in r["messages"]}
user, gold = msgs.get("user", ""), msgs.get("assistant", "")
m = GAP_RE.search(user)
if not m or not gold:
skipped += 1; continue
L = int(m.group(1))
if not 1 <= L <= 10:
skipped += 1; continue
built = build(user, L, a.max_ctx)
if built is None:
skipped += 1; continue
window, gap, brow = built
cand = beam_restore(model, window, gap, brow, device, a.beam,
region_id=UNK_REGION, century_id=UNK_CENTURY)
if not cand:
skipped += 1; continue
preds = [c[0] for c in cand]
cer = calculate_cer(gold, preds[0])
gn = normalize_content(gold)
hit1 = int(normalize_content(truncate_to_real_length(preds[0], gold)) == gn)
hit20 = int(any(normalize_content(truncate_to_real_length(p, gold)) == gn for p in preds[:20]))
d = per_len.setdefault(L, dict(cer=[], t1=0, t20=0, n=0))
d["cer"].append(cer); d["t1"] += hit1; d["t20"] += hit20; d["n"] += 1
t1 += hit1; t20 += hit20; tot += 1
if tot % 200 == 0:
print(f" {tot}/{len(rows)} CER={np.mean([c for v in per_len.values() for c in v['cer']]):.4f} "
f"top1={t1/tot:.4f}", flush=True)
by_len = {L: dict(n=v["n"], CER=round(float(np.mean(v["cer"])), 4),
top1=round(v["t1"] / max(v["n"], 1), 4),
top20=round(v["t20"] / max(v["n"], 1), 4))
for L, v in sorted(per_len.items())}
# Their headline "Overall Average CER" is the mean over per-length means (their
# summary prints one CER per length then averages), so report both that and the
# sample-weighted mean rather than silently picking whichever looks better.
macro = round(float(np.mean([v["CER"] for v in by_len.values()])), 4) if by_len else None
micro = round(float(np.mean([c for v in per_len.values() for c in v["cer"]])), 4) if per_len else None
res = dict(data=a.data, ckpt=a.ckpt, n=tot, skipped=skipped, beam=a.beam,
CER_macro_over_lengths=macro, CER_micro=micro,
top1=round(t1 / max(tot, 1), 4), top20=round(t20 / max(tot, 1), 4),
by_length=by_len)
Path(a.out).write_text(json.dumps(res, indent=1))
print(json.dumps({k: v for k, v in res.items() if k != "by_length"}, indent=1))
print("wrote", a.out)
if __name__ == "__main__":
main()