Stoicheia-code / meter /norma_score.py
Ericu950's picture
Stoicheia: training and evaluation code
5952424 verified
Raw
History Blame Contribute Delete
9.59 kB
"""Canonical scorer for the Norma benchmark (macronize + syllabify).
Self-contained on purpose (stdlib only) so this can be lifted out and dropped
straight into the Norma dataset repo for anyone to score their own predictions
against -- it does not import anything else from Stoicheia.
Both tasks are scored by projecting the annotation onto LETTER ORDINALS (the
n-th actual Greek letter in the line, ignoring whitespace/punctuation/marks
entirely) rather than by diffing the annotated strings themselves. This makes
scoring robust to cosmetic differences in mark/bracket placement around
whitespace -- e.g. for syllabify, a predicted "{ word}" scores identical to a
gold " {word}": the leading space carries no ordinal and is not itself part of
either span, so which side of the bracket it sits on cannot change which
letters the label applies to.
Usage:
python -m meter.norma_score --gold test.jsonl --pred pred.jsonl [--task macronize|syllabify|both]
`gold` is Norma's own format: one {"text", "source", "task"} object per line
(see https://huggingface.co/datasets/Ericu950/norma). `pred` must have the same
number of lines in the same order, each a plain annotated string (macronize:
"_"/"^" after long/short letters; syllabify: "[heavy]"/"{light}" spans) -- or a
{"text": "..."} object with the same content under "text".
"""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
from collections import defaultdict
MAC_LONG, MAC_SHORT = 0, 1
SCAN_HEAVY, SCAN_LIGHT, SCAN_VERSE = 1, 2, 3
_LONG_MARKS = {"_", "̄"} # ASCII underscore, combining macron
_SHORT_MARKS = {"^", "̆"} # ASCII caret, combining breve
_ALL_MARKS = _LONG_MARKS | _SHORT_MARKS
_GREEK_BLOCKS = ((0x0370, 0x03FF), (0x1F00, 0x1FFF)) # Greek+Coptic, Greek Extended
def _is_letter(ch: str) -> bool:
"""Is this (possibly precomposed) character one Greek letter, for ordinal-
counting purposes? Decomposes first so accented/breathed precomposed forms
(e.g. "ά", "ᾧ") are recognized via their base letter."""
base = unicodedata.normalize("NFD", ch)[0]
cp = ord(base)
return any(lo <= cp <= hi for lo, hi in _GREEK_BLOCKS) and unicodedata.category(base).startswith("L")
def parse_macron_line(marked: str):
""""βα^ρύκτυ^πος" -> (plain, {letter_ordinal: MAC_LONG|MAC_SHORT})."""
nfd = unicodedata.normalize("NFD", marked)
kept, labels = [], {}
ordinal = -1
for ch in nfd:
if ch in _ALL_MARKS:
if ordinal >= 0:
labels[ordinal] = MAC_LONG if ch in _LONG_MARKS else MAC_SHORT
continue
if _is_letter(ch):
ordinal += 1
kept.append(ch)
return unicodedata.normalize("NFC", "".join(kept)), labels
_SYL = re.compile(r"\[([^\]]*)\]|\{([^}]*)\}")
def parse_scan_line(bracketed: str):
""""[ὦ] [παῖ] {τέ}[λος]" -> (plain, {letter_ordinal: scan class}), weight on
the LAST letter of each span; the line's last labeled letter becomes
SCAN_VERSE (brevis in longo). Whitespace on either side of a bracket is
inert: it carries no ordinal, so it cannot shift which letter a label
lands on regardless of which side of the bracket it's written on."""
plain_parts, labels = [], {}
ordinal = -1
pos = 0
last_labeled = None
def advance(text):
nonlocal ordinal
last = None
for ch in text:
if _is_letter(ch):
ordinal += 1
last = ordinal
plain_parts.append(text)
return last
for m in _SYL.finditer(bracketed):
advance(bracketed[pos:m.start()])
text, weight = ((m.group(1), SCAN_HEAVY) if m.group(1) is not None
else (m.group(2), SCAN_LIGHT))
last = advance(text)
if last is not None:
labels[last] = weight
last_labeled = last
pos = m.end()
advance(bracketed[pos:])
if last_labeled is None:
return None
labels[last_labeled] = SCAN_VERSE
return unicodedata.normalize("NFC", "".join(plain_parts)), labels
def _text_of(row) -> str:
return row["text"] if isinstance(row, dict) else row
def mac_metrics(pairs):
"""pairs: [(gold, pred)] in {MAC_LONG, MAC_SHORT} -> acc, balanced acc, per-class F1."""
if not pairs:
return None
n = len(pairs)
acc = sum(g == p for g, p in pairs) / n
out = {"n": n, "acc": round(acc, 4)}
recalls = []
for cls, name in [(MAC_LONG, "long"), (MAC_SHORT, "short")]:
tp = sum(1 for g, p in pairs if g == cls and p == cls)
fp = sum(1 for g, p in pairs if g != cls and p == cls)
fn = sum(1 for g, p in pairs if g == cls and p != cls)
prec = tp / (tp + fp) if tp + fp else 0.0
rec = tp / (tp + fn) if tp + fn else None
out[f"{name}_f1"] = round(2 * prec * rec / (prec + rec)
if rec is not None and prec + rec else 0.0, 4)
if rec is not None:
recalls.append(rec)
out["bal_acc"] = round(sum(recalls) / len(recalls), 4) if recalls else None
return out
def scan_metrics(pairs):
"""pairs: [(gold 0..3, pred 0..3)] -> acc, balanced acc, boundary-F1, weight acc
(heavy-vs-light accuracy conditional on both sides agreeing a boundary exists)."""
if not pairs:
return None
n = len(pairs)
acc = sum(g == p for g, p in pairs) / n
recalls = []
for c in range(4):
tot = sum(1 for g, _ in pairs if g == c)
if tot:
recalls.append(sum(1 for g, p in pairs if g == c and p == c) / tot)
tp = sum(1 for g, p in pairs if g > 0 and p > 0)
fp = sum(1 for g, p in pairs if g == 0 and p > 0)
fn = sum(1 for g, p in pairs if g > 0 and p == 0)
w_pairs = [(g, p) for g, p in pairs if g in (SCAN_HEAVY, SCAN_LIGHT) and p in (SCAN_HEAVY, SCAN_LIGHT)]
return dict(n=n, acc=round(acc, 4),
bal_acc=round(sum(recalls) / len(recalls), 4) if recalls else None,
boundary_f1=round(2 * tp / max(2 * tp + fp + fn, 1), 4),
weight_acc=round(sum(g == p for g, p in w_pairs) / len(w_pairs), 4)
if w_pairs else None)
def score_macronize(gold_rows, pred_rows):
"""gold_rows/pred_rows: parallel lists of macronize-annotated strings (or
{"text": ...} dicts). -> (overall metrics, {source: metrics})."""
pairs, by_source = [], defaultdict(list)
for g, p in zip(gold_rows, pred_rows):
gplain, glabels = parse_macron_line(_text_of(g))
pplain, plabels = parse_macron_line(_text_of(p))
assert gplain == pplain, f"plain-text mismatch: {gplain!r} vs {pplain!r}"
source = g.get("source") if isinstance(g, dict) else None
for k, gv in glabels.items():
pv = plabels.get(k, MAC_LONG) # old convention: default-to-long if unmarked
pairs.append((gv, pv))
if source:
by_source[source].append((gv, pv))
per_source = {s: mac_metrics(ps) for s, ps in by_source.items()}
return mac_metrics(pairs), per_source
def score_syllabify(gold_rows, pred_rows):
"""Same shape as score_macronize, but for bracketed syllabify text. Comparison
happens over the FULL per-letter array (0 = no boundary at that letter), not
just the labeled ordinals, so a boundary predicted at the wrong letter shows
up as a mismatch on both the letter that should have it and the one that
wrongly does."""
pairs, by_source = [], defaultdict(list)
for g, p in zip(gold_rows, pred_rows):
gparsed = parse_scan_line(_text_of(g))
pparsed = parse_scan_line(_text_of(p))
if gparsed is None:
continue
gplain, glabels = gparsed
pplain, plabels = pparsed if pparsed is not None else (gplain, {})
assert gplain == pplain, f"plain-text mismatch: {gplain!r} vs {pplain!r}"
n = sum(1 for ch in gplain if _is_letter(ch))
source = g.get("source") if isinstance(g, dict) else None
for k in range(n):
gv, pv = glabels.get(k, 0), plabels.get(k, 0)
pairs.append((gv, pv))
if source:
by_source[source].append((gv, pv))
per_source = {s: scan_metrics(ps) for s, ps in by_source.items()}
return scan_metrics(pairs), per_source
def _load_jsonl(path):
with open(path, encoding="utf-8") as f:
return [json.loads(l) for l in f if l.strip()]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--gold", required=True, help="Norma-format jsonl (text/source/task)")
ap.add_argument("--pred", required=True, help="parallel jsonl, same order, annotated text")
ap.add_argument("--task", choices=["macronize", "syllabify", "both"], default="both")
a = ap.parse_args()
gold_all, pred_all = _load_jsonl(a.gold), _load_jsonl(a.pred)
assert len(gold_all) == len(pred_all), \
f"{a.gold}: {len(gold_all)} rows vs {a.pred}: {len(pred_all)} rows"
for task, scorer in [("macronize", score_macronize), ("syllabify", score_syllabify)]:
if a.task not in (task, "both"):
continue
pairs = [(g, p) for g, p in zip(gold_all, pred_all) if g.get("task") == task]
if not pairs:
continue
gold_rows, pred_rows = zip(*pairs)
overall, per_source = scorer(list(gold_rows), list(pred_rows))
print(f"=== {task} ===")
print(f" overall: {json.dumps(overall)}")
for s in sorted(per_source):
print(f" {s}: {json.dumps(per_source[s])}")
if __name__ == "__main__":
main()