File size: 4,939 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
"""Meter-constrained decoding of the scan head (gated Viterbi).

Vendored from an existing macronization project (scripts/scan_constraints.py grammar
NFAs + scripts/viterbi_meter.py), adapted to per-LETTER probabilities. The scan head
gives P(O/H/L/V) per letter; Viterbi finds the most probable labelling whose non-O
sequence is a valid path through the meter's automaton (last syllable emits V,
anceps/brevis in longo). The gate rejects the constrained path when it costs more
than `theta` log-prob per letter vs raw argmax (protects against grammar mismatch).
"""
from __future__ import annotations

from collections import defaultdict
from math import inf

# ---------------------------------------------------------------- grammars
PRINCEPS = (("H",),)
BICEPS = (("H",), ("L", "L"))
BREVE = (("L",),)
FINAL = (("H",), ("L",))
ANCEPS = (("H",), ("L",), ("L", "L"))
LONGUM = (("H",), ("L", "L"))

HEX = [PRINCEPS, BICEPS] * 5 + [PRINCEPS, FINAL]
PENTA = ([PRINCEPS, BICEPS] * 2 + [PRINCEPS]
         + [PRINCEPS, BREVE, BREVE, PRINCEPS, BREVE, BREVE, FINAL])
IA6 = ([ANCEPS, LONGUM, BREVE, LONGUM] * 2 + [ANCEPS, LONGUM, BREVE, FINAL])
TROCH_TET = [LONGUM, BREVE, LONGUM, ANCEPS] * 3 + [LONGUM, BREVE, FINAL]
ANAP_FOOT = (("L", "L", "H"), ("H", "H"), ("H", "L", "L"))
ANAP_TET = [ANAP_FOOT] * 7 + [FINAL]
CHOL = ([ANCEPS, LONGUM, BREVE, LONGUM] * 2 + [ANCEPS, LONGUM, (("H",),), FINAL])
BREVE_C = (("L",), ("L", "L"))
IA6C = ([ANCEPS, LONGUM, BREVE_C, LONGUM] * 2 + [ANCEPS, LONGUM, BREVE_C, FINAL])
GRAMMARS = {"hex": HEX, "penta": PENTA, "ia6": IA6,
            "troch_tet": TROCH_TET, "anap_tet": ANAP_TET, "chol": CHOL, "ia6c": IA6C}

# corpus meter names -> grammar keys (everything else: no stichic grammar)
METER_MAP = {
    "dactylic hexameter": "hex", "hexameter": "hex",
    "dactylic pentameter": "penta", "pentameter": "penta",
    "iambic trimeter (tragic)": "ia6",
    "iambic trimeter (anapaestic)": "ia6c",
    "ia6": "ia6", "ia6g": "ia6",
    "tr7": "troch_tet", "!_tr7": "troch_tet",
    "an4": "anap_tet", "an4cat": "anap_tet",
    "choliambic": "chol",
}


def build_nfa(spec):
    trans = defaultdict(set)
    for p, pos in enumerate(spec):
        for ri, real in enumerate(pos):
            prev = ("B", p)
            for j, w in enumerate(real):
                nxt = ("B", p + 1) if j == len(real) - 1 else ("I", p, ri, j + 1)
                trans[(prev, w)].add(nxt)
                prev = nxt
    return dict(trans), ("B", 0), ("B", len(spec))


NFAS = {name: build_nfa(spec) for name, spec in GRAMMARS.items()}

O, H, L, V = 0, 1, 2, 3


def viterbi_decode(logP, meter):
    """logP: (n,4) array-like of log-probs per letter -> (labels|None, score)."""
    trans, start, accept = NFAS[meter]
    N = len(logP)
    dp = [dict() for _ in range(N + 1)]
    dp[0][start] = (0.0, None)
    for i in range(N):
        if not dp[i]:
            break
        lpO, lpH, lpL, lpV = logP[i]
        for s, (sc, _) in dp[i].items():
            cand = sc + lpO                      # emit O: stay
            if cand > dp[i + 1].get(s, (-inf,))[0]:
                dp[i + 1][s] = (cand, (s, O))
            for w, lpw, wsym in ((H, lpH, "H"), (L, lpL, "L")):
                for s2 in trans.get((s, wsym), ()):
                    lab = V if s2 == accept else w
                    cand = sc + (lpV if s2 == accept else lpw)
                    if cand > dp[i + 1].get(s2, (-inf,))[0]:
                        dp[i + 1][s2] = (cand, (s, lab))
    if accept not in dp[N]:
        return None, -inf
    labels = [O] * N
    s = accept
    score = dp[N][s][0]
    for i in range(N, 0, -1):
        prev, lab = dp[i][s][1]
        labels[i - 1] = lab
        s = prev
    return labels, score


def best_meter(logP, meters=("hex", "penta", "ia6", "ia6c", "troch_tet",
                             "anap_tet", "chol")):
    """(meter, labels, per-letter score) of the best-scoring valid grammar."""
    best = (None, None, -inf)
    for mt in meters:
        labs, sc = viterbi_decode(logP, mt)
        if labs is not None and sc / len(logP) > best[2]:
            best = (mt, labs, sc / len(logP))
    return best


def gated_decode(logP, meter, theta=0.1):
    """Viterbi unless it costs > theta log-prob/letter vs argmax.
    -> (labels, applied: bool)."""
    raw = [max(p) for p in logP]
    labs, sc = viterbi_decode(logP, meter)
    if labs is not None and (sum(raw) - sc) / max(1, len(logP)) <= theta:
        return labs, True
    return [int(max(range(4), key=lambda k: p[k])) for p in logP], False


def gated_auto(logP, theta=0.1):
    """Auto-detect meter, then gate. -> (meter|None, labels, applied)."""
    mt, labs, _ = best_meter(logP)
    if mt is not None:
        raw = [max(p) for p in logP]
        _, sc = viterbi_decode(logP, mt)
        if (sum(raw) - sc) / max(1, len(logP)) <= theta:
            return mt, labs, True
    return mt, [int(max(range(4), key=lambda k: p[k])) for p in logP], False