| """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 = {"_", "̄"} |
| _SHORT_MARKS = {"^", "̆"} |
| _ALL_MARKS = _LONG_MARKS | _SHORT_MARKS |
|
|
| _GREEK_BLOCKS = ((0x0370, 0x03FF), (0x1F00, 0x1FFF)) |
|
|
|
|
| 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) |
| 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() |
|
|