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
|