Token Classification
Transformers
Safetensors
Ancient Greek (to 1453)
char_bert_meter
ancient-greek
classical-philology
character-level
macronization
metrical-scansion
custom_code
Instructions to use Ericu950/CharDiff-grc-meter with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Ericu950/CharDiff-grc-meter with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="Ericu950/CharDiff-grc-meter", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Ericu950/CharDiff-grc-meter", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
File size: 12,048 Bytes
96b4fd6 7699766 96b4fd6 7699766 96b4fd6 7699766 96b4fd6 7699766 96b4fd6 | 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 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | """HF-Hub-compatible processor for CharDiff-grc-meter: text <-> the model's five
input planes (chars/boundary/dia/punct/cap -- capitalization is a real input here,
unlike the base pretrained model, where it is output-only), plus decode helpers for
the two production tasks: macronization (vowel-length marks on ambiguous dichrona)
and metrical scansion (heavy/light/verse-final syllable bracketing).
Mark conventions and the ambiguous-dichrona rule are ported verbatim from
meter/marks.py (the training-side projection code) so a converted checkpoint's
predictions decode identically to the original project's `meter.predict` CLI.
"""
from __future__ import annotations
import json
import unicodedata
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import torch
MASK, BLANK, PAD = 24, 25, 26
UNK_BND, UNK_DIA, UNK_PUNCT = 3, 48, 6
ALPHABET = "αβγδεζηθικλμνξοπρστυφχψω"
LETTER_IDS = {c: i for i, c in enumerate(ALPHABET)}
ID2LETTER = np.array(list(ALPHABET))
_EXTRA_BASE = {
"ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ", "ϰ": "κ", "ϱ": "ρ", "ϖ": "π",
}
_MARK_MAP = {
0x0301: "acute", 0x0341: "acute", 0x0300: "grave", 0x0340: "grave",
0x0342: "circ", 0x0302: "circ", 0x0313: "smooth", 0x0343: "smooth",
0x0314: "rough", 0x0345: "iota", 0x0308: "diaer",
}
_ACC = {"acute": 1, "grave": 2, "circ": 3}
_BR = {"smooth": 1, "rough": 2}
def _pack_dia(acc, br, iota, diaer):
return ((acc * 3 + br) * 2 + iota) * 2 + diaer
def _unpack_dia(d):
diaer = d % 2; d //= 2
iota = d % 2; d //= 2
br = d % 3; acc = d // 3
return acc, br, iota, diaer
# ---- macron/scansion label conventions (meter/marks.py, reproduced verbatim) ----
MAC_LONG, MAC_SHORT = 0, 1
SCAN_O, SCAN_HEAVY, SCAN_LIGHT, SCAN_VERSE = 0, 1, 2, 3
_A, _E, _H, _I, _O, _Y, _W = (LETTER_IDS[c] for c in "αεηιουω")
DICHRONA_IDS = np.array([_A, _I, _Y])
VOWEL_IDS = np.array([_A, _E, _H, _I, _O, _Y, _W])
DIPHTHONGS = {(_A, _I), (_A, _Y), (_E, _I), (_E, _Y), (_H, _Y),
(_O, _I), (_O, _Y), (_Y, _I), (_W, _Y)}
def ambiguous_mask(chars: np.ndarray, boundary: np.ndarray, dia: np.ndarray) -> np.ndarray:
"""Which plane positions are ambiguous dichrona (the macronizer's domain)? A
position is ambiguous iff it's a base alpha/iota/upsilon without circumflex or
iota subscript, and not part of a diphthong (diaeresis on the second vowel
breaks the diphthong; pairs never span a word boundary)."""
n = len(chars)
d = np.asarray(dia, dtype=np.int64)
acc, _br, iota, diaer = _unpack_dia(d.copy())
is_dich = np.isin(chars, DICHRONA_IDS)
out = is_dich & (acc != 3) & (iota == 0)
if n > 1:
chars = np.asarray(chars)
pair = np.zeros(n - 1, dtype=bool)
for f, s in DIPHTHONGS:
pair |= (chars[:-1] == f) & (chars[1:] == s)
pair &= np.asarray(boundary[:-1]) == 0
out[1:] &= ~(pair & (diaer[1:] == 0))
out[:-1] &= ~(pair & (diaer[1:] == 0))
return out
def merge_vowelless_syllables(chars: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
"""A predicted syllable span with no vowel isn't a real syllable -- it's a
boundary placed one letter early, typically at the first letter of a geminate
consonant pair (e.g. "{λε}[ν]" for what should be one closed syllable
"[λεν]"). Merge any such span into the preceding one, keeping its own weight
label: that label (usually already correct, since it's typically a closing
consonant) is normally right for the merged syllable -- only the boundary was
misplaced. A vowel-less span at the very start of the line is left as-is."""
out = np.asarray(scan_labels).copy()
is_vowel = np.isin(np.asarray(chars), VOWEL_IDS)
kept = []
start = 0
for i in range(len(out)):
if out[i] == SCAN_O:
continue
if not is_vowel[start:i + 1].any() and kept:
out[kept.pop()] = SCAN_O
kept.append(i)
start = i + 1
return out
def enforce_circumflex_heavy(dia: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
"""A syllable containing a circumflexed vowel is always heavy -- a fixed rule
of Greek prosody, not something the per-letter classifier can get wrong in
principle, only in practice. Flip any SCAN_LIGHT span containing a circumflex
to SCAN_HEAVY; SCAN_VERSE is left alone (it already renders as a heavy-looking
bracket). Boundary placement itself is untouched -- this only corrects weight."""
d = np.asarray(dia, dtype=np.int64)
acc, _br, _iota, _diaer = _unpack_dia(d.copy())
has_circ = acc == 3
out = np.asarray(scan_labels).copy()
start = 0
for i in range(len(out)):
if out[i] != SCAN_O:
if out[i] == SCAN_LIGHT and has_circ[start:i + 1].any():
out[i] = SCAN_HEAVY
start = i + 1
return out
def _is_letter(ch: str) -> bool:
low = ch.lower()
if low in LETTER_IDS or low in _EXTRA_BASE:
return True
dec = unicodedata.normalize("NFD", low)
return bool(dec) and (dec[0] in LETTER_IDS or dec[0] in _EXTRA_BASE)
def insert_marks(plain: str, labels: dict) -> str:
"""Write `_` (long) / `^` (short) after the letters given by
{letter_ordinal: MAC_LONG|MAC_SHORT} -- production macron output format."""
nfc = unicodedata.normalize("NFC", plain)
out, ordinal, pending = [], -1, None
for ch in nfc:
if pending is not None and not unicodedata.category(ch).startswith("M"):
out.append(pending)
pending = None
out.append(ch)
if _is_letter(ch):
ordinal += 1
if ordinal in labels:
pending = "_" if labels[ordinal] == MAC_LONG else "^"
if pending is not None:
out.append(pending)
return "".join(out)
def bracketize(plain: str, labels: dict) -> str:
"""Render per-letter scan labels back into [heavy]{light} syllable spans
(verse-final span rendered as heavy, matching brevis-in-longo display)."""
nfc = unicodedata.normalize("NFC", plain)
out, cur, ordinal = [], [], -1
for ch in nfc:
cur.append(ch)
if _is_letter(ch):
ordinal += 1
lab = labels.get(ordinal, SCAN_O)
if lab != SCAN_O:
o, c = ("{", "}") if lab == SCAN_LIGHT else ("[", "]")
out.append(o + "".join(cur) + c)
cur = []
if cur:
out.append("".join(cur))
return "".join(out)
@dataclass
class _Encoded:
chars: list
boundary: list
dia: list
punct: list
cap: list
class CharBertMeterProcessor:
"""`processor(text)` -> dict of batched tensors ready for `CharBertMeterModel(**batch)`."""
def __init__(self):
pass
@classmethod
def from_pretrained(cls, *_args, **_kwargs):
return cls()
def save_pretrained(self, save_directory, **_kwargs):
Path(save_directory).mkdir(parents=True, exist_ok=True)
(Path(save_directory) / "processor_config.json").write_text(
json.dumps({"processor_class": "CharBertMeterProcessor"}))
def _classify(self, text: str) -> _Encoded:
nfd = unicodedata.normalize("NFD", text)
chars, boundary, dia, punct, cap = [], [], [], [], []
acc = br = iota = diaer = 0
pending_bnd = 0
i = 0
while i < len(nfd):
ch = nfd[i]
if ch == "-":
run = 0
while i < len(nfd) and nfd[i] == "-":
run += 1
i += 1
for _ in range(run):
chars.append(MASK); boundary.append(UNK_BND)
dia.append(UNK_DIA); punct.append(UNK_PUNCT); cap.append(0)
continue
low = ch.lower()
base = low if low in LETTER_IDS else _EXTRA_BASE.get(low)
if base is not None:
if chars and pending_bnd:
boundary[-1] = pending_bnd
pending_bnd = 0
chars.append(LETTER_IDS[base])
cap.append(1 if ch != low else 0)
boundary.append(0)
dia.append(0)
punct.append(0)
acc = br = iota = diaer = 0
elif unicodedata.combining(ch) or ord(ch) in _MARK_MAP:
kind = _MARK_MAP.get(ord(ch))
if kind in _ACC:
acc = _ACC[kind]
elif kind in _BR:
br = _BR[kind]
elif kind == "iota":
iota = 1
elif kind == "diaer":
diaer = 1
if dia:
dia[-1] = _pack_dia(acc, br, iota, diaer)
elif ch.isspace():
pending_bnd = max(pending_bnd, 1)
elif ch in ".;!?":
pending_bnd = max(pending_bnd, 2)
if punct:
punct[-1] = 4 if ch == "." else 5
elif ch in ",:··":
if punct:
punct[-1] = {",": 1, "·": 2, "·": 2, ":": 3}.get(ch, 0)
i += 1
if boundary:
boundary[-1] = max(boundary[-1], 2)
return _Encoded(chars, boundary, dia, punct, cap)
def __call__(self, text: str, has_boundaries: bool = True):
"""Encode `text` into model-ready tensors. Unlike the base pretraining
processor, `cap` is a real model input here (fine-tune-only channel)."""
enc = self._classify(text)
n = len(enc.chars)
chars = np.array(enc.chars, dtype=np.int64)
boundary = np.array(enc.boundary, dtype=np.int64)
dia = np.array(enc.dia, dtype=np.int64)
punct = np.array(enc.punct, dtype=np.int64)
cap = np.array(enc.cap, dtype=np.int64)
if not has_boundaries:
boundary[:] = UNK_BND
batch = dict(
input_ids=torch.from_numpy(chars)[None],
boundary=torch.from_numpy(boundary)[None],
dia=torch.from_numpy(dia)[None],
punct=torch.from_numpy(punct)[None],
cap=torch.from_numpy(cap)[None],
seg_id=torch.zeros(1, n, dtype=torch.long),
)
batch["_text"] = text # kept out-of-band for decode (marks splice into the original string)
batch["_chars"] = chars
batch["_boundary"] = boundary
batch["_dia"] = dia
return batch
# ---------------------------------------------------------------- decode
def decode_macronization(self, model_out, batch) -> str:
"""Insert `_`/`^` (long/short) after every ambiguous alpha/iota/upsilon --
matches `meter.predict --macronize` exactly (only ambiguous dichrona get a
mark; unambiguous positions -- eta, omega, diphthongs, iota subscript,
circumflexed vowels -- are left bare, since their length isn't in doubt)."""
pred_mac = model_out.mac.argmax(-1)[0].numpy()
amb = ambiguous_mask(batch["_chars"], batch["_boundary"], batch["_dia"])
labels = {int(i): int(pred_mac[i]) for i in np.flatnonzero(amb)}
return insert_marks(batch["_text"], labels)
def decode_scansion(self, model_out, batch) -> str:
"""Bracket every syllable the model assigns a non-trivial weight to:
[heavy], {light}, with the line-final syllable (brevis in longo) shown as
heavy -- matches `meter.predict --scan` exactly, including its two
deterministic corrections: merge_vowelless_syllables (a vowel-less
predicted span gets folded into the preceding syllable) and
enforce_circumflex_heavy (a circumflexed syllable is always heavy)."""
pred_scan = model_out.scan.argmax(-1)[0].numpy()
pred_scan = merge_vowelless_syllables(batch["_chars"], pred_scan)
pred_scan = enforce_circumflex_heavy(batch["_dia"], pred_scan)
labels = {i: int(c) for i, c in enumerate(pred_scan) if c > 0}
return bracketize(batch["_text"], labels)
|