Stoicheia-code / meter /eval_norma_external.py
anonymous-stoicheia's picture
Upload folder using huggingface_hub
7ed86c3 verified
Raw
History Blame Contribute Delete
26.2 kB
"""
eval_norma_external.py
Evaluation harness for the "Norma Syllabarum Graecarum" (NSG) benchmark:
manually annotated Ancient Greek excerpts used to score automatic
vowel-length (macron) annotation of the three "dichrona" (ambiguous-length)
letters alpha, iota and upsilon.
The benchmark is loaded from HuggingFace by default
(https://huggingface.co/datasets/anonymous-stoicheia/norma, source="hf"), or from a
local git clone of https://github.com/anonymous-stoicheia/norma
(source="git"). These are NOT the same benchmark: the HF version
deliberately excludes two works (insolem, pindar) that also appear in a
separate training corpus, to avoid train/test contamination -- so scores
from the two sources are not directly comparable. Pick one source and
stick with it for any given comparison.
------------------------------------------------------------------------
Benchmark layout
------------------------------------------------------------------------
Either source ships the same texts twice, in two parallel directories:
norma_syllabify/<work>.txt syllable brackets only, e.g.
[Ἥ]{λι}{ο}[ν ὑμ][νεῖ][ν αὖ]{τε }{Δι}[ὸς ]{τέ}{κο}[ς ἄρ]{χε}{ο }[Μοῦ]{σα}
([...] = "heavy" syllable, {...} = "light" syllable -- but see below,
"open" is re-derived from content, not from the bracket colour)
norma_macronize/<work>.txt macron marks only, e.g.
Ἥλι^ον ὑμνεῖν αὖτε Δι^ὸς τέκος ἄρχεο Μοῦσα^
(^ after a short dichronon, _ after a long one, nothing if the
annotator left it undetermined)
The two files for a given work are line-for-line parallel (same underlying
edited text), but are NOT guaranteed to be byte-identical once brackets/marks
are stripped -- there can be tiny whitespace differences right at a bracket
boundary. This module therefore never assumes index correspondence between
the two gold files; it always re-aligns them with difflib.SequenceMatcher.
The benchmark's own methodology (see its README / the paper this evaluates)
is to score ONLY open (light) syllables: closed-syllable marks are only
sporadically supplied by the annotator and are therefore excluded from
scoring. The "evaluation set" is: every dichronon in an OPEN gold syllable
that also carries a gold mark (^ or _) in the macronize file.
------------------------------------------------------------------------
Usage
------------------------------------------------------------------------
from eval_norma import evaluate, format_report
def my_macronize_fn(line: str) -> str:
... # return line with ^/_ added after ambiguous vowels
results = evaluate(my_macronize_fn)
print(format_report(results))
`macronize_fn` must be a callable `str -> str`: given a plain line (no
brackets, no marks) it must return the same text with ^/_ markup added
(using the identical convention as the gold files). It does not need to
leave every dichronon marked -- unmarked positions are simply scored as
"no prediction" (wrong for raw accuracy; treated as an implicit "short"
guess for the "defaults-to-short" metric).
For performance, `evaluate()` calls `macronize_fn` ONCE PER WORK (all of a
work's lines joined with "\n"), then splits the result back into lines by
"\n" -- this matters a great deal for the bundled rule-based macronizer,
whose per-call overhead (~3-4s, dominated by pipeline/model invocation, not
input length) would make ~950 individual per-line calls take the better
part of an hour. If the returned text does not split back into the expected
number of lines (e.g. a model that swallows/adds newlines), this module
transparently falls back to calling `macronize_fn` once per line for that
work, with a warning. Set `batch_by_work=False` to always call per line.
"""
from __future__ import annotations
import difflib
import glob
import os
import re
import sys
import unicodedata
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
# grc_utils ships in the grc-macronizer venv (pip -e installed there).
from grc_utils import DICHRONA, vowel
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
# "git" source: a local clone of norma-syllabarum-graecarum, either given
# directly via NORMA_ROOT or assumed to sit as a sibling directory of this
# script (the layout used while the benchmark lived only on GitHub).
GIT_NORMA_ROOT = os.environ.get(
"NORMA_ROOT", os.path.join(_HERE, "norma-syllabarum-graecarum")
)
# "hf" source (default): the benchmark also ships as a HuggingFace dataset
# repo, using the identical norma_macronize/*.txt + norma_syllabify/*.txt
# layout (no new parsing logic needed, just a download step). This is the
# canonical evaluation set going forward: it deliberately excludes two
# works present in the git clone (insolem, pindar) that also appear in a
# separate training corpus, to avoid train/test contamination. Numbers
# from source="hf" and source="git" are therefore NOT directly comparable
# (different work counts) -- pick one source and stick with it.
DEFAULT_NORMA_HF_REPO = "anonymous-stoicheia/norma"
def resolve_norma_dirs(source: str = "hf", repo_id: str = DEFAULT_NORMA_HF_REPO,
norma_root: Optional[str] = None):
"""Returns (syllabify_dir, macronize_dir, stoplist_path) for the chosen
source. `norma_root`, if given, always wins and is used verbatim (both
sources share the same on-disk layout, so this works for either)."""
if norma_root is None:
if source == "git":
norma_root = GIT_NORMA_ROOT
elif source == "hf":
from huggingface_hub import snapshot_download
norma_root = snapshot_download(
repo_id=repo_id,
repo_type="dataset",
allow_patterns=["norma_macronize/*", "norma_syllabify/*", "stoplist.txt"],
)
else:
raise ValueError(f"Unknown source {source!r}, expected 'git' or 'hf'")
return (
os.path.join(norma_root, "norma_syllabify"),
os.path.join(norma_root, "norma_macronize"),
os.path.join(norma_root, "stoplist.txt"),
)
MARK_CHARS = "^_"
# ---------------------------------------------------------------------------
# Bracket-tiling helpers (syllabify files sometimes have stray punctuation
# living between/after/before bracket pairs, e.g. "...][καρ]{δί}[αν],"
# -- the trailing comma is outside any bracket. We fold such stray text into
# the neighbouring bracket so that brackets fully tile the line and every
# character can be assigned to exactly one gold syllable. This is a clean,
# self-contained re-implementation of the same idea as
# grc-macronizer/scripts/move_text_into_brackets.py.)
# ---------------------------------------------------------------------------
_BETWEEN_BRACKETS_RE = re.compile(r'([\]\}])([^\[\]\{\}]+)([\[\{])')
_TRAILING_RE = re.compile(r'([\]\}])([^\[\]\{\}]+)$')
_LEADING_RE = re.compile(r'^([^\[\{]+)([\[\{])')
_BRACKET_RE = re.compile(r'([\[\{])([^\[\]\{\}]*)([\]\}])')
def _move_text_into_brackets(line: str) -> str:
"""Move any stray (non-bracketed) text into the neighbouring bracket so
that the line is fully tiled by bracket pairs, with no gaps."""
prev = None
while prev != line:
prev = line
def _repl(m: "re.Match[str]") -> str:
return m.group(1)[:-1] + m.group(2) + m.group(1)[-1] + m.group(3)
line = _BETWEEN_BRACKETS_RE.sub(_repl, line)
m = _TRAILING_RE.search(line)
if m:
line = line[: m.start()] + m.group(1)[:-1] + m.group(2) + m.group(1)[-1]
m = _LEADING_RE.match(line)
if m:
leading, bracket = m.group(1), m.group(2)
line = bracket + leading + line[m.end():]
return line
def _syllable_is_open(content: str) -> bool:
"""A gold syllable is OPEN iff its content ends in a vowel, once trailing
non-letter characters (spaces, punctuation, elision marks) are stripped.
Deliberately independent of the [] vs {} bracket colour: a syllable
marked heavy ([...]) can still be open, e.g. because it contains a long
vowel or diphthong."""
core = content
while core and not core[-1].isalpha():
core = core[:-1]
if not core:
return False
return vowel(core[-1])
def _parse_syllabify_line(line: str):
"""Returns (plain_text, opens) where plain_text is the concatenation of
all bracket contents (in order) and opens[i] says whether plain_text[i]'s
syllable is open."""
tiled = _move_text_into_brackets(line)
chars: List[str] = []
opens: List[bool] = []
for m in _BRACKET_RE.finditer(tiled):
content = m.group(2)
is_open = _syllable_is_open(content)
chars.extend(content)
opens.extend([is_open] * len(content))
return "".join(chars), opens
def _parse_marked_line(line: str):
"""Strips ^/_ marks from `line`, returning (plain_text, marks) where
marks[i] is '^', '_' or None -- the gold/predicted mark immediately
following plain_text[i] in the original string. Works equally on gold
macronize-file lines and on a macronize_fn's output."""
plain: List[str] = []
marks: List[Optional[str]] = []
i, n = 0, len(line)
while i < n:
ch = line[i]
if ch in MARK_CHARS:
# Stray leading mark with no preceding base char; drop it.
i += 1
continue
nxt = line[i + 1] if i + 1 < n else ""
if nxt and nxt in MARK_CHARS:
plain.append(ch)
marks.append(nxt)
i += 2
else:
plain.append(ch)
marks.append(None)
i += 1
return "".join(plain), marks
def _align_open_flags(sp_text: str, sp_opens: List[bool], mp_text: str) -> List[Optional[bool]]:
"""Maps the per-character 'open' flags computed on the syllabify-gold
plain text (sp_text) onto the macronize-gold plain text (mp_text),
via difflib alignment. Positions that fall inside a non-'equal' opcode
(i.e. exactly where the two gold files differ slightly) get None and are
excluded from scoring, since we cannot reliably say which gold syllable
they belong to."""
open_for_mp: List[Optional[bool]] = [None] * len(mp_text)
sm = difflib.SequenceMatcher(None, sp_text, mp_text, autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
open_for_mp[j1 + k] = sp_opens[i1 + k]
return open_for_mp
def _word_stoplist_flags(text: str, stoplist: set) -> List[bool]:
"""For each character position in `text`, whether it belongs to a
whitespace-delimited token that (after stripping leading/trailing
non-letter characters) exactly matches an entry in the stoplist."""
flags = [False] * len(text)
if not stoplist:
return flags
for m in re.finditer(r"\S+", text):
token = m.group(0)
core = token
while core and not core[0].isalpha():
core = core[1:]
while core and not core[-1].isalpha():
core = core[:-1]
if token in stoplist or core in stoplist:
for k in range(m.start(), m.end()):
flags[k] = True
return flags
def _load_stoplist(stoplist_path: str) -> set:
if not os.path.exists(stoplist_path):
return set()
with open(stoplist_path, encoding="utf-8") as f:
return {
unicodedata.normalize("NFC", line.strip())
for line in f
if line.strip()
}
# ---------------------------------------------------------------------------
# Corpus data structures
# ---------------------------------------------------------------------------
@dataclass
class LineRecord:
work: str
line_idx: int
plain: str # marks/brackets-stripped reference text (fed to macronize_fn)
gold_marks: List[Optional[str]] # gold_marks[i] in {'^', '_', None}
is_open: List[Optional[bool]] # is_open[i]: syllable openness, or None if unalignable
in_stoplist: List[bool] # whether plain[i]'s word form is stoplisted
def _list_works(syllabify_dir: str, macronize_dir: str) -> List[str]:
files = sorted(os.path.basename(p) for p in glob.glob(os.path.join(syllabify_dir, "*.txt")))
works = [os.path.splitext(f)[0] for f in files]
missing = [
w for w in works
if not os.path.exists(os.path.join(macronize_dir, w + ".txt"))
]
if missing:
raise FileNotFoundError(
f"norma_macronize/ is missing files for: {missing} "
f"(present in norma_syllabify/)"
)
return works
def load_corpus(source: str = "hf", repo_id: str = DEFAULT_NORMA_HF_REPO,
norma_root: Optional[str] = None) -> Dict[str, List[LineRecord]]:
"""Parses every work in the benchmark into a list of LineRecord.
source : "hf" (default) downloads/caches the benchmark from the
HuggingFace dataset repo `repo_id` (anonymous-stoicheia/norma). "git" reads a
local clone instead (NORMA_ROOT env var, or a norma-syllabarum-graecarum
sibling directory of this script). `norma_root`, if given, overrides
either source and is used directly.
"""
syllabify_dir, macronize_dir, stoplist_path = resolve_norma_dirs(source, repo_id, norma_root)
stoplist = _load_stoplist(stoplist_path)
corpus: Dict[str, List[LineRecord]] = {}
for work in _list_works(syllabify_dir, macronize_dir):
syll_lines = (
open(os.path.join(syllabify_dir, work + ".txt"), encoding="utf-8")
.read()
.splitlines()
)
macro_lines = (
open(os.path.join(macronize_dir, work + ".txt"), encoding="utf-8")
.read()
.splitlines()
)
if len(syll_lines) != len(macro_lines):
raise ValueError(
f"{work}: line-count mismatch between norma_syllabify "
f"({len(syll_lines)}) and norma_macronize ({len(macro_lines)})"
)
records: List[LineRecord] = []
for idx, (sline, mline) in enumerate(zip(syll_lines, macro_lines)):
sline = unicodedata.normalize("NFC", sline)
mline = unicodedata.normalize("NFC", mline)
if not sline.strip() and not mline.strip():
continue
sp_text, sp_opens = _parse_syllabify_line(sline)
mp_text, mp_marks = _parse_marked_line(mline)
open_for_mp = _align_open_flags(sp_text, sp_opens, mp_text)
in_stoplist = _word_stoplist_flags(mp_text, stoplist)
records.append(
LineRecord(
work=work,
line_idx=idx,
plain=mp_text,
gold_marks=mp_marks,
is_open=open_for_mp,
in_stoplist=in_stoplist,
)
)
corpus[work] = records
return corpus
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
def _map_output_onto_reference(output_plain: str, ref_plain: str) -> List[Optional[int]]:
"""Aligns `output_plain` (a macronize_fn's de-marked output) onto
`ref_plain` (the line's reference plain text that was fed to
macronize_fn), returning ref_to_out[j] = the index in output_plain
corresponding to ref_plain[j], or None if unalignable.
Alignment is done case-insensitively, since e.g. the bundled rule-based
macronizer lowercases its output by design; casing differences must not
cause otherwise-identical text to be treated as non-corresponding."""
ref_to_out: List[Optional[int]] = [None] * len(ref_plain)
sm = difflib.SequenceMatcher(None, output_plain.lower(), ref_plain.lower(), autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
ref_to_out[j1 + k] = i1 + k
return ref_to_out
@dataclass
class WorkResult:
work: str
n_eval: int = 0
n_raw_correct: int = 0
n_trivial_correct: int = 0
n_default_correct: int = 0
n_unpredicted: int = 0 # eval positions with no model mark at all
@property
def raw_accuracy(self) -> Optional[float]:
return self.n_raw_correct / self.n_eval if self.n_eval else None
@property
def trivial_baseline_accuracy(self) -> Optional[float]:
return self.n_trivial_correct / self.n_eval if self.n_eval else None
@property
def default_short_accuracy(self) -> Optional[float]:
return self.n_default_correct / self.n_eval if self.n_eval else None
@property
def improvement_over_baseline(self) -> Optional[float]:
if self.n_eval == 0:
return None
return self.default_short_accuracy - self.trivial_baseline_accuracy
def evaluate(
macronize_fn: Callable[[str], str],
use_stoplist: bool = True,
batch_by_work: bool = True,
works: Optional[List[str]] = None,
verbose: bool = True,
source: str = "hf",
repo_id: str = DEFAULT_NORMA_HF_REPO,
norma_root: Optional[str] = None,
) -> Dict[str, object]:
"""Runs `macronize_fn` over the Norma Syllabarum Graecarum benchmark and
scores it against gold.
Parameters
----------
macronize_fn : callable str -> str
Given a plain (brackets/marks-stripped) line, returns the same text
with ^/_ macron markup added.
use_stoplist : bool
If True (default, matching the benchmark's own suggestion), gold
word forms listed in stoplist.txt (rare proper names etc.) are
excluded from scoring. Only the "git" source currently ships a
stoplist.txt; with "hf" this is silently a no-op.
batch_by_work : bool
If True (default), all lines of a work are joined with "\\n" and
passed to `macronize_fn` in a single call (falling back to one call
per line if the returned text doesn't split back into the expected
number of lines). This matters a lot for macronizers with high
fixed per-call overhead. Set to False to always call line-by-line.
works : list of str, optional
Restrict evaluation to these work names (default: all 16).
verbose : bool
Print progress per work while running.
source : "hf" (default) or "git" -- see load_corpus().
repo_id : HuggingFace dataset repo to use when source="hf".
norma_root : explicit local directory override for either source.
Returns a dict: {"per_work": {work: WorkResult, ...}, "total": WorkResult}
"""
corpus = load_corpus(source=source, repo_id=repo_id, norma_root=norma_root)
if works is not None:
unknown = set(works) - set(corpus)
if unknown:
raise ValueError(f"Unknown work(s): {sorted(unknown)}")
corpus = {w: corpus[w] for w in works}
per_work: Dict[str, WorkResult] = {}
total = WorkResult(work="TOTAL")
for work, records in corpus.items():
if verbose:
print(f"Evaluating {work} ({len(records)} lines)...", file=sys.stderr)
outputs: List[str] = [None] * len(records) # type: ignore[list-item]
got_batch = False
if batch_by_work and records:
joined = "\n".join(r.plain for r in records)
try:
batched_out = macronize_fn(joined)
split_out = batched_out.split("\n")
except Exception as e:
split_out = None
if verbose:
print(f" batched call failed ({e}); falling back to per-line", file=sys.stderr)
if split_out is not None and len(split_out) == len(records):
outputs = split_out
got_batch = True
elif verbose and split_out is not None:
print(
f" batched call returned {len(split_out)} lines, "
f"expected {len(records)}; falling back to per-line",
file=sys.stderr,
)
if not got_batch:
for i, r in enumerate(records):
try:
outputs[i] = macronize_fn(r.plain)
except Exception as e:
if verbose:
print(f" line {r.line_idx} raised {e}; treating as unmarked", file=sys.stderr)
outputs[i] = r.plain
wr = WorkResult(work=work)
for record, output in zip(records, outputs):
out_plain, out_marks = _parse_marked_line(output)
ref_to_out = _map_output_onto_reference(out_plain, record.plain)
for j in range(len(record.plain)):
if not record.is_open[j]:
continue
gold_mark = record.gold_marks[j]
if gold_mark is None:
continue
if use_stoplist and record.in_stoplist[j]:
continue
wr.n_eval += 1
out_idx = ref_to_out[j]
predicted_mark = out_marks[out_idx] if out_idx is not None else None
if predicted_mark is None:
wr.n_unpredicted += 1
if predicted_mark == gold_mark:
wr.n_raw_correct += 1
if gold_mark == "^":
wr.n_trivial_correct += 1
default_mark = predicted_mark if predicted_mark is not None else "^"
if default_mark == gold_mark:
wr.n_default_correct += 1
per_work[work] = wr
total.n_eval += wr.n_eval
total.n_raw_correct += wr.n_raw_correct
total.n_trivial_correct += wr.n_trivial_correct
total.n_default_correct += wr.n_default_correct
total.n_unpredicted += wr.n_unpredicted
return {"per_work": per_work, "total": total}
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def _fmt_pct(x: Optional[float]) -> str:
return f"{x:.2%}" if x is not None else "n/a"
def format_report(results: Dict[str, object]) -> str:
per_work: Dict[str, WorkResult] = results["per_work"]
total: WorkResult = results["total"]
header = (
f"{'work':<16}{'n_eval':>8}{'raw_acc':>10}{'trivial':>10}"
f"{'defl_short':>12}{'improve':>10}{'unmarked':>10}"
)
lines = [header, "-" * len(header)]
for work in sorted(per_work):
wr = per_work[work]
lines.append(
f"{work:<16}{wr.n_eval:>8}{_fmt_pct(wr.raw_accuracy):>10}"
f"{_fmt_pct(wr.trivial_baseline_accuracy):>10}"
f"{_fmt_pct(wr.default_short_accuracy):>12}"
f"{(_fmt_pct(wr.improvement_over_baseline) if wr.n_eval else 'n/a'):>10}"
f"{wr.n_unpredicted:>10}"
)
lines.append("-" * len(header))
lines.append(
f"{'TOTAL':<16}{total.n_eval:>8}{_fmt_pct(total.raw_accuracy):>10}"
f"{_fmt_pct(total.trivial_baseline_accuracy):>10}"
f"{_fmt_pct(total.default_short_accuracy):>12}"
f"{_fmt_pct(total.improvement_over_baseline):>10}"
f"{total.n_unpredicted:>10}"
)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI: evaluate either the rule-based grc-macronizer (default) or a trained
# macron_model/ checkpoint (--model_dir) over the benchmark.
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--model_dir", default=None,
help="Path to a trained macron_model/ checkpoint (e.g. runs/v1_gpu/best) "
"to evaluate instead of the rule-based macronizer.")
ap.add_argument("--device", default=None, help="cuda / cpu, only used with --model_dir")
ap.add_argument("--source", choices=["hf", "git"], default="hf",
help="Where to load Norma Syllabarum Graecarum from: the HuggingFace "
f"dataset repo (default, {DEFAULT_NORMA_HF_REPO!r}), or a local "
"git clone ('git' -- NORMA_ROOT env var, or a "
"norma-syllabarum-graecarum sibling directory of this script).")
ap.add_argument("--norma_repo", default=DEFAULT_NORMA_HF_REPO,
help="HuggingFace dataset repo id, only used with --source hf.")
ap.add_argument("--norma_root", default=None,
help="Explicit local directory override, bypassing --source entirely.")
args = ap.parse_args()
if args.model_dir:
sys.path.insert(0, os.path.join(_HERE, "macron_model"))
from predict import MacronPredictor
predictor = MacronPredictor(args.model_dir, device=args.device)
macronize_fn = predictor.macronize
label = f"trained model at {args.model_dir}"
else:
from grc_macronizer import Macronizer
macronizer = Macronizer(no_hypotactic=True, make_prints=False, lowercase=True)
macronize_fn = macronizer.macronize
label = "rule-based grc-macronizer"
eval_kwargs = dict(source=args.source, repo_id=args.norma_repo, norma_root=args.norma_root)
print(f"Running {label} over Norma Syllabarum Graecarum "
f"(source={args.source})...\n", file=sys.stderr)
results = evaluate(macronize_fn, use_stoplist=True, **eval_kwargs)
print()
print("=== WITH stoplist exclusion (default) ===")
print(format_report(results))
print(file=sys.stderr)
print("Re-running with stoplist exclusion OFF for comparison...\n", file=sys.stderr)
results_no_stop = evaluate(macronize_fn, use_stoplist=False, **eval_kwargs)
print()
print("=== WITHOUT stoplist exclusion ===")
print(format_report(results_no_stop))