-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathextract_byzantine.py
More file actions
333 lines (280 loc) · 11.4 KB
/
Copy pathextract_byzantine.py
File metadata and controls
333 lines (280 loc) · 11.4 KB
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
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
"""Byzantine vernacular corpus loader for the next-word LM pipeline.
Factored out of ``train_lm.py`` to stay merge-friendly alongside other
corpus ingestors (Diorisis, polytonic MG) landing on sibling branches.
The corpus lives at ``~/Documents/corpus-of-open-greek/sources/byzantine/``
and consists of medieval / early-modern Greek vernacular literature
(Digenes Akritas, Ptochoprodromika, Poulologos, Apokopos, etc.). Most
files are polytonic; two of the largest (Erotokritos and Chronicle of
the Moreas) are *monotonic*. The polytonic LM artifact should not
learn monotonic forms as parallel vocab entries, so those two files
are filtered out by default. The threshold is expressed as a
polytonic-character ratio on the file contents, not a hard-coded
blacklist, so adding new texts to the corpus doesn't need code
changes.
Interface mirrors ``iter_glaux_sentences`` in ``train_lm.py``: yields
``(sent_id, [token, ...])`` tuples where ``tokens[0] == BOS_TOK`` and
``tokens[-1] == EOS_TOK``. Punctuation is collapsed to ``</s>`` for
sentence-enders and dropped otherwise, matching the GLAUx pass.
"""
from __future__ import annotations
import re
import unicodedata
from pathlib import Path
from typing import Iterable, Iterator
# Match the reserved tokens in train_lm.py. Duplicated here so this
# module stays importable without a circular dependency.
BOS_TOK = "<s>"
EOS_TOK = "</s>"
SENT_END = {".", ";", "·", "!", "?"}
DEFAULT_CORPUS_DIR = (
Path.home() / "Documents" / "corpus-of-open-greek" / "sources" / "byzantine"
)
# Files that are substantially monotonic. Computed by measuring the
# ratio of polytonic-only codepoints to all Greek codepoints per file,
# thresholded at ~2%. Erotokritos is 0.0% polytonic, Chronikon tou
# Moreos is 0.6% (stray polytonic quotations inside a monotonic
# edition). Everything else is 5-17% polytonic.
#
# We compute this at load time rather than hard-coding so the filter
# keeps working if the corpus directory grows.
MONOTONIC_THRESHOLD = 0.02 # <2% polytonic codepoints => monotonic file
_POLYTONIC_BLOCK_LO = "\u1F00"
_POLYTONIC_BLOCK_HI = "\u1FFF"
_GREEK_BLOCK_LO = "\u0370"
_GREEK_BLOCK_HI = "\u03FF"
# Combining diacritics that indicate polytonic orthography. Tonos
# (U+0301) and dialytika (U+0308) appear in both polytonic and
# monotonic Greek, so they don't count.
_POLYTONIC_COMBINING = {
"\u0313", # combining comma above (spiritus lenis)
"\u0314", # combining reversed comma above (spiritus asper)
"\u0342", # combining Greek perispomeni (circumflex)
"\u0345", # combining ypogegrammeni (iota subscript)
"\u0300", # combining grave (varia)
}
def nfc(s: str) -> str:
return unicodedata.normalize("NFC", s)
def _polytonic_share(text: str) -> float:
"""Fraction of Greek codepoints that are polytonic-only.
Counts precomposed polytonic glyphs in the U+1F00-U+1FFF block
plus any Greek characters bearing a polytonic combining diacritic
in NFD form, divided by the total Greek-letter count.
"""
if not text:
return 0.0
greek = 0
poly_precomposed = 0
for c in text:
if _GREEK_BLOCK_LO <= c <= _GREEK_BLOCK_HI:
greek += 1
elif _POLYTONIC_BLOCK_LO <= c <= _POLYTONIC_BLOCK_HI:
greek += 1
poly_precomposed += 1
if greek == 0:
return 0.0
# decomposed combining marks
comb_poly = 0
for c in unicodedata.normalize("NFD", text):
if c in _POLYTONIC_COMBINING:
comb_poly += 1
return (poly_precomposed + comb_poly) / greek
def classify_files(
corpus_dir: Path,
) -> tuple[list[Path], list[Path]]:
"""Split corpus files into polytonic and monotonic buckets.
``corpus.txt`` is the concatenation of the per-work files, so
including it would double-count. Any file that isn't a ``.txt``
running-text file is skipped.
"""
polytonic: list[Path] = []
monotonic: list[Path] = []
for p in sorted(corpus_dir.glob("*.txt")):
if p.name == "corpus.txt":
continue
try:
text = p.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
share = _polytonic_share(text)
if share >= MONOTONIC_THRESHOLD:
polytonic.append(p)
else:
monotonic.append(p)
return polytonic, monotonic
# Token regex: grab contiguous runs of Greek letters (both blocks),
# allowing medial apostrophe / modifier letter apostrophe for elided
# forms like κ᾿, ν᾿, σ᾿ which appear frequently in vernacular texts.
# A single leading Latin capital that visually matches a Greek capital
# (M, T, A, ...) is tolerated because source files occasionally
# contain those as transcription artifacts. We fix them up to the
# Greek letter after matching.
_APOSTROPHES = "'\u02BC\u1FBD\u1FBF\u2019\u1FBE" # ' ʼ ᾽ ᾿ ’ ι
_LATIN_HOMOGLYPHS = {
"A": "Α", "B": "Β", "E": "Ε", "Z": "Ζ", "H": "Η", "I": "Ι",
"K": "Κ", "M": "Μ", "N": "Ν", "O": "Ο", "P": "Ρ", "T": "Τ",
"X": "Χ", "Y": "Υ",
}
_TOKEN_RE = re.compile(
rf"[{''.join(_LATIN_HOMOGLYPHS)}]?"
rf"[\u0370-\u03FF\u1F00-\u1FFF]+"
rf"(?:[{_APOSTROPHES}][\u0370-\u03FF\u1F00-\u1FFF]*)*"
rf"|[{_APOSTROPHES}][\u0370-\u03FF\u1F00-\u1FFF]+"
)
_SENT_SPLIT_RE = re.compile(r"([.;·!?])")
def _line_is_metadata(line: str) -> bool:
"""Heuristic: lines with more Latin letters than Greek are editor
metadata (page headers, citation blocks, "Επιμέλεια Wilhelm
Wagner, ..." notes, verse numbers). Strip them before splitting
into sentences so they don't glue onto the first real sentence.
"""
greek = 0
latin = 0
for c in line:
if ("A" <= c <= "Z") or ("a" <= c <= "z"):
latin += 1
elif (_GREEK_BLOCK_LO <= c <= _GREEK_BLOCK_HI
or _POLYTONIC_BLOCK_LO <= c <= _POLYTONIC_BLOCK_HI):
greek += 1
return latin > greek and latin > 3
def _fix_latin_homoglyphs(token: str) -> str:
"""Replace a leading Latin homoglyph with its Greek twin."""
if token and token[0] in _LATIN_HOMOGLYPHS:
return _LATIN_HOMOGLYPHS[token[0]] + token[1:]
return token
def _is_greek_token(t: str) -> bool:
for c in t:
if (_GREEK_BLOCK_LO <= c <= _GREEK_BLOCK_HI
or _POLYTONIC_BLOCK_LO <= c <= _POLYTONIC_BLOCK_HI):
return True
return False
def _sentence_looks_polytonic(tokens: list[str]) -> bool:
"""Reject purely-monotonic sentences inside a polytonic-classified
file. Editor metadata paragraphs at the top of some files are
monotonic Greek prose ("Σημείωση: μετά τους στίχους..."). A real
sentence from a polytonic source contains at least one token with
a polytonic diacritic.
"""
for t in tokens:
for c in t:
if _POLYTONIC_BLOCK_LO <= c <= _POLYTONIC_BLOCK_HI:
return True
# also check combining diacritics
for c in unicodedata.normalize("NFD", t):
if c in _POLYTONIC_COMBINING:
return True
return False
def _iter_raw_sentences(text: str) -> Iterator[list[str]]:
"""Split text into sentences by punctuation.
Editor metadata lines (mostly Latin script) are dropped before the
punctuation split so they don't glue onto the first real
sentence.
"""
kept = []
for line in text.split("\n"):
if _line_is_metadata(line):
continue
kept.append(line)
flat = " ".join(kept)
parts = _SENT_SPLIT_RE.split(flat)
buf: list[str] = []
for i, p in enumerate(parts):
if i % 2 == 0:
raw = _TOKEN_RE.findall(p)
for t in raw:
buf.append(_fix_latin_homoglyphs(t))
else:
if buf:
yield buf
buf = []
if buf:
yield buf
def iter_byzantine_sentences(
corpus_dir: Path | None = None,
include_monotonic: bool = False,
) -> Iterator[tuple[str, list[str]]]:
"""Yield ``(sent_id, [token, ...])`` from the Byzantine corpus.
Each sentence is framed with ``BOS_TOK`` / ``EOS_TOK`` so the
output matches ``iter_glaux_sentences`` in ``train_lm.py``.
Parameters
----------
corpus_dir: location of the ``.txt`` files. Defaults to
``~/Documents/corpus-of-open-greek/sources/byzantine``.
include_monotonic: if True, include files classified as monotonic
(Erotokritos, Chronicle of the Moreas). Defaults to False
because mixing monotonic surface forms (``της``) with
polytonic (``τῆς``) would inflate the vocab with parallel
entries for the same word. A polytonic keyboard LM should not
learn monotonic spellings.
"""
if corpus_dir is None:
corpus_dir = DEFAULT_CORPUS_DIR
corpus_dir = Path(corpus_dir)
polytonic, monotonic = classify_files(corpus_dir)
files = list(polytonic)
if include_monotonic:
files.extend(monotonic)
for path in sorted(files):
doc_id = path.stem
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
text = nfc(text)
# Files we already classified as polytonic still contain the
# occasional monotonic editor-note sentence; drop those so
# they don't seed parallel monotonic vocab entries.
is_polytonic_file = path in polytonic
sent_counter = 0
for raw_toks in _iter_raw_sentences(text):
clean = [t for t in raw_toks if _is_greek_token(t)]
if not clean:
continue
if (is_polytonic_file
and not _sentence_looks_polytonic(clean)):
continue
sent_counter += 1
sent_id = f"byz:{doc_id}:{sent_counter}"
tokens = [BOS_TOK] + clean + [EOS_TOK]
if len(tokens) >= 3:
yield sent_id, tokens
def corpus_stats(corpus_dir: Path | None = None) -> dict:
"""Return a summary of what the loader will emit."""
if corpus_dir is None:
corpus_dir = DEFAULT_CORPUS_DIR
corpus_dir = Path(corpus_dir)
polytonic, monotonic = classify_files(corpus_dir)
def _count(files: Iterable[Path], require_polytonic: bool) -> tuple[int, int]:
n_sents = 0
n_toks = 0
for p in files:
text = nfc(p.read_text(encoding="utf-8"))
for sent in _iter_raw_sentences(text):
clean = [t for t in sent if _is_greek_token(t)]
if not clean:
continue
if require_polytonic and not _sentence_looks_polytonic(clean):
continue
n_sents += 1
n_toks += len(clean) + 2 # <s> + toks + </s>
return n_sents, n_toks
p_sents, p_toks = _count(polytonic, require_polytonic=True)
m_sents, m_toks = _count(monotonic, require_polytonic=False)
return {
"corpus_dir": str(corpus_dir),
"polytonic_files": [p.name for p in polytonic],
"monotonic_files_excluded": [p.name for p in monotonic],
"polytonic_sentences": p_sents,
"polytonic_tokens": p_toks,
"monotonic_sentences": m_sents,
"monotonic_tokens": m_toks,
}
if __name__ == "__main__":
import json
stats = corpus_stats()
print(json.dumps(stats, ensure_ascii=False, indent=2))
print()
print("Sample sentences:")
for i, (sid, toks) in enumerate(iter_byzantine_sentences()):
if i >= 5:
break
print(f" {sid}: {' '.join(toks)}")