File size: 9,587 Bytes
5952424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""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()