anonymous-stoicheia commited on
Commit
1db6237
·
verified ·
1 Parent(s): 9f49f9a

Publish safetensors weights, config and model card

Browse files
README.md CHANGED
@@ -1,17 +1,50 @@
1
  ---
2
  license: apache-2.0
3
- language: [grc]
4
- tags: [ancient-greek, masked-diffusion, fine-tuned]
 
 
 
 
 
 
 
 
 
 
5
  ---
6
- # Stoicheia-restoration-test4
7
 
8
- Documentary restoration, PHI/TM final digit 4 held out. Part of the Stoicheia release: a 405M-parameter character-level
9
- masked-diffusion Transformer for Ancient Greek whose input factors into five
10
- independently maskable planes (letters, word/sentence boundaries, diacritics,
11
- capitalization, punctuation).
12
 
13
- Anonymous release accompanying a paper under review. Code, usage examples, and
14
- reproduction instructions: https://huggingface.co/anonymous-stoicheia/Stoicheia-code
 
 
 
 
 
15
 
16
- Checkpoint format: PyTorch state dict (`torch.load`), architecture and loading
17
- code in the accompanying code repository (`model/char_bert.py`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ language:
4
+ - grc
5
+ library_name: transformers
6
+ tags:
7
+ - ancient-greek
8
+ - classical-philology
9
+ - character-level
10
+ - masked-diffusion
11
+ - text-restoration
12
+ - epigraphy
13
+ - papyrology
14
+ pipeline_tag: fill-mask
15
  ---
 
16
 
17
+ # Stoicheia -- documentary restoration (digit 4 held out)
 
 
 
18
 
19
+ **Stoicheia** is a 405M-parameter character-level masked-diffusion encoder for Ancient Greek
20
+ (`d_model` 1024, depth 32, banded attention: three of every four blocks attend within a
21
+ 256-character window, the fourth globally). Its input is factored into five aligned planes --
22
+ letters, word/sentence boundaries, diacritics, capitalization, punctuation -- each of which can
23
+ be masked independently to an explicit *unknown* state at inference. That is what lets one model
24
+ read an edited text, *scriptio continua*, and a lacuna of unknown length without changing
25
+ anything but its input.
26
 
27
+ Anonymous release accompanying a paper under review.
28
+
29
+ Fine-tuned from `Stoicheia-doc_clean` to restore damaged inscriptions and papyri, on every
30
+ document **except** those whose PHI/TM identifier ends in **4**.
31
+
32
+ Ten such checkpoints are released, one per digit, because a fixed split makes a model useless
33
+ for exactly the documents an editor cares about: whatever inscription or papyrus you are working
34
+ on, one of the ten has provably never read it. Pick that one and the reading it proposes cannot
35
+ be a memory of the edition you are trying to check.
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ import torch
41
+ from transformers import AutoModel
42
+ model = AutoModel.from_pretrained("anonymous-stoicheia/Stoicheia-restoration-test4", trust_remote_code=True).eval()
43
+
44
+ from processing_char_bert import CharBertProcessor
45
+ proc = CharBertProcessor()
46
+ # "[7]" is a lacuna of known width; "[7±2]" if the width itself is uncertain
47
+ text = "αγαθηιτυχηιεδοξεντ[7]βουληικαιτωιδημωι"
48
+ best, width, cands = proc.restore_elastic(model, text, mask_dia_boundary=True)
49
+ print(best)
50
+ ```
config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "n_alpha": 24,
3
+ "mask_id": 24,
4
+ "blank_id": 25,
5
+ "pad_id": 26,
6
+ "n_char_ids": 27,
7
+ "n_boundary": 4,
8
+ "n_dia": 49,
9
+ "n_punct": 7,
10
+ "n_heads": 16,
11
+ "char_window": 256,
12
+ "attn_impl": "sdpa",
13
+ "qk_norm": true,
14
+ "d_model": 1024,
15
+ "depth": 32,
16
+ "model_type": "char_bert",
17
+ "auto_map": {
18
+ "AutoConfig": "configuration_char_bert.CharBertConfig",
19
+ "AutoModel": "modeling_char_bert.CharBertModel"
20
+ }
21
+ }
configuration_char_bert.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF-Hub-compatible config for Stoicheia (CharBertEncoder)."""
2
+ from transformers import PretrainedConfig
3
+
4
+
5
+ class CharBertConfig(PretrainedConfig):
6
+ model_type = "char_bert"
7
+
8
+ def __init__(
9
+ self,
10
+ n_alpha: int = 24,
11
+ mask_id: int = 24,
12
+ blank_id: int = 25,
13
+ pad_id: int = 26,
14
+ n_char_ids: int = 27,
15
+ n_boundary: int = 4,
16
+ n_dia: int = 49,
17
+ n_punct: int = 7,
18
+ d_model: int = 1024,
19
+ n_heads: int = 16,
20
+ depth: int = 32,
21
+ char_window: int = 256,
22
+ attn_impl: str = "sdpa",
23
+ qk_norm: bool = True,
24
+ **kwargs,
25
+ ):
26
+ self.n_alpha = n_alpha
27
+ self.mask_id = mask_id
28
+ self.blank_id = blank_id
29
+ self.pad_id = pad_id
30
+ self.n_char_ids = n_char_ids
31
+ self.n_boundary = n_boundary
32
+ self.n_dia = n_dia
33
+ self.n_punct = n_punct
34
+ self.d_model = d_model
35
+ self.n_heads = n_heads
36
+ self.depth = depth
37
+ self.char_window = char_window
38
+ self.attn_impl = attn_impl
39
+ self.qk_norm = qk_norm
40
+ super().__init__(**kwargs)
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c34b21c26b17524a74fea11b0732c6e53ada466bbf407d72adb839e3b4eb59e7
3
+ size 1620144776
modeling_char_bert.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF-Hub-compatible model for Stoicheia (CharBertEncoder).
2
+
3
+ Self-contained: vendors the transformer primitives (RMSNorm/RoPE/Attention/GeGLU/Block)
4
+ so this file has no dependency on the original research repo. Uses the SDPA attention
5
+ path only (portable to CPU and any CUDA GPU) -- the original training code also supports
6
+ a compiled FlexAttention block-sparse path for long packed sequences on GPU, which is not
7
+ needed for standalone inference on single passages and is omitted here for portability.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ from transformers import PreTrainedModel
18
+ from transformers.modeling_outputs import ModelOutput
19
+
20
+ from .configuration_char_bert import CharBertConfig
21
+
22
+
23
+ class RMSNorm(nn.Module):
24
+ def __init__(self, d, eps=1e-6):
25
+ super().__init__()
26
+ self.w = nn.Parameter(torch.ones(d))
27
+ self.eps = eps
28
+
29
+ def forward(self, x):
30
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
31
+ return x * self.w
32
+
33
+
34
+ class RoPE(nn.Module):
35
+ def __init__(self, dim, base=10000.0):
36
+ super().__init__()
37
+ self.dim = dim
38
+ self.base = base
39
+
40
+ def cos_sin(self, pos):
41
+ # Recomputed on every call rather than cached in a registered buffer: a
42
+ # persistent=False buffer is never covered by the checkpoint's state dict,
43
+ # so it depends entirely on __init__-time materialization -- which some
44
+ # transformers versions' meta-device/low_cpu_mem_usage loading path can
45
+ # skip, silently leaving this tensor uninitialized. Recomputing here is
46
+ # immune to that regardless of how the model was constructed/loaded.
47
+ inv = 1.0 / (self.base ** (torch.arange(0, self.dim, 2, device=pos.device).float() / self.dim))
48
+ f = torch.outer(pos.float(), inv)
49
+ emb = torch.cat([f, f], -1)
50
+ return emb.cos(), emb.sin()
51
+
52
+
53
+ def _rotate_half(x):
54
+ d = x.shape[-1] // 2
55
+ return torch.cat([-x[..., d:], x[..., :d]], -1)
56
+
57
+
58
+ def apply_rope(q, k, cos, sin):
59
+ cos = cos[None, None]
60
+ sin = sin[None, None]
61
+ return q * cos + _rotate_half(q) * sin, k * cos + _rotate_half(k) * sin
62
+
63
+
64
+ class Attention(nn.Module):
65
+ def __init__(self, d, n_heads, rope: RoPE, qk_norm=False):
66
+ super().__init__()
67
+ self.h = n_heads
68
+ self.dh = d // n_heads
69
+ self.qkv = nn.Linear(d, 3 * d, bias=False)
70
+ self.o = nn.Linear(d, d, bias=False)
71
+ self.rope = rope
72
+ self.qk_norm = qk_norm
73
+ if qk_norm:
74
+ self.q_norm = RMSNorm(self.dh)
75
+ self.k_norm = RMSNorm(self.dh)
76
+
77
+ def forward(self, x, pos, attn_mask):
78
+ B, T, D = x.shape
79
+ qkv = self.qkv(x).view(B, T, 3, self.h, self.dh).permute(2, 0, 3, 1, 4)
80
+ q, k, v = qkv[0], qkv[1], qkv[2]
81
+ if self.qk_norm:
82
+ q, k = self.q_norm(q), self.k_norm(k)
83
+ cos, sin = self.rope.cos_sin(pos)
84
+ cos, sin = cos.to(x.dtype), sin.to(x.dtype)
85
+ q, k = apply_rope(q, k, cos, sin)
86
+ out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask)
87
+ out = out.transpose(1, 2).reshape(B, T, D)
88
+ return self.o(out)
89
+
90
+
91
+ class GeGLU(nn.Module):
92
+ def __init__(self, d, mult=8 / 3):
93
+ super().__init__()
94
+ hidden = int(d * mult)
95
+ hidden = (hidden + 63) // 64 * 64
96
+ self.wi = nn.Linear(d, 2 * hidden, bias=False)
97
+ self.wo = nn.Linear(hidden, d, bias=False)
98
+
99
+ def forward(self, x):
100
+ a, b = self.wi(x).chunk(2, -1)
101
+ return self.wo(F.gelu(a) * b)
102
+
103
+
104
+ class Block(nn.Module):
105
+ def __init__(self, d, n_heads, rope, window=0, qk_norm=False):
106
+ super().__init__()
107
+ self.n1 = RMSNorm(d)
108
+ self.attn = Attention(d, n_heads, rope, qk_norm=qk_norm)
109
+ self.n2 = RMSNorm(d)
110
+ self.mlp = GeGLU(d)
111
+ self.window = window # 0 = global; >0 = local sliding window (characters)
112
+
113
+ def forward(self, x, pos, base_mask):
114
+ x = x + self.attn(self.n1(x), pos, base_mask)
115
+ x = x + self.mlp(self.n2(x))
116
+ return x
117
+
118
+
119
+ def build_attn_mask(seg_id, window, device, dtype):
120
+ """Additive mask (B,1,T,T): same-segment AND (window==0 or |i-j|<window)."""
121
+ B, T = seg_id.shape
122
+ same = seg_id[:, None, :] == seg_id[:, :, None]
123
+ if window and window > 0:
124
+ idx = torch.arange(T, device=device)
125
+ near = (idx[None, :] - idx[:, None]).abs() < window
126
+ same = same & near[None]
127
+ mask = torch.zeros(B, 1, T, T, dtype=dtype, device=device)
128
+ mask.masked_fill_(~same[:, None], float("-inf"))
129
+ return mask
130
+
131
+
132
+ @dataclass
133
+ class CharBertOutput(ModelOutput):
134
+ char: torch.FloatTensor = None
135
+ boundary: torch.FloatTensor = None
136
+ dia: torch.FloatTensor = None
137
+ cap: torch.FloatTensor = None
138
+ punct: torch.FloatTensor = None
139
+ hidden_states: Optional[tuple] = None
140
+
141
+
142
+ class CharBertModel(PreTrainedModel):
143
+ config_class = CharBertConfig
144
+
145
+ def __init__(self, config: CharBertConfig):
146
+ super().__init__(config)
147
+ self.e_char = nn.Embedding(config.n_char_ids, config.d_model)
148
+ self.e_bnd = nn.Embedding(config.n_boundary, config.d_model)
149
+ self.e_dia = nn.Embedding(config.n_dia, config.d_model)
150
+ self.e_punct = nn.Embedding(config.n_punct, config.d_model)
151
+ rope = RoPE(config.d_model // config.n_heads)
152
+ blocks = []
153
+ for i in range(config.depth):
154
+ win = 0 if i % 4 == 3 else config.char_window # 3 local : 1 global
155
+ blocks.append(Block(config.d_model, config.n_heads, rope, window=win, qk_norm=config.qk_norm))
156
+ self.blocks = nn.ModuleList(blocks)
157
+ self.norm_out = RMSNorm(config.d_model)
158
+ self.head_char = nn.Linear(config.d_model, config.n_char_ids, bias=False)
159
+ self.head_bnd = nn.Linear(config.d_model, 3, bias=False)
160
+ self.head_dia = nn.Linear(config.d_model, 48, bias=False)
161
+ self.head_cap = nn.Linear(config.d_model, 2, bias=False)
162
+ self.head_punct = nn.Linear(config.d_model, 6, bias=False)
163
+ self.post_init()
164
+
165
+ def _init_weights(self, module):
166
+ if isinstance(module, nn.Linear):
167
+ nn.init.normal_(module.weight, std=0.02)
168
+ elif isinstance(module, nn.Embedding):
169
+ nn.init.normal_(module.weight, std=0.02)
170
+
171
+ def forward(
172
+ self,
173
+ input_ids: torch.LongTensor,
174
+ boundary: torch.LongTensor,
175
+ dia: torch.LongTensor,
176
+ punct: torch.LongTensor,
177
+ seg_id: Optional[torch.LongTensor] = None,
178
+ output_hidden_states: bool = False,
179
+ return_dict: bool = True,
180
+ **kwargs,
181
+ ):
182
+ cfg = self.config
183
+ B, T = input_ids.shape
184
+ pos = torch.arange(T, device=input_ids.device)
185
+ seg = seg_id if seg_id is not None else torch.zeros(B, T, dtype=torch.long, device=input_ids.device)
186
+
187
+ x = self.e_char(input_ids) + self.e_bnd(boundary) + self.e_dia(dia) + self.e_punct(punct)
188
+ attn_mask = build_attn_mask(seg, cfg.char_window, input_ids.device, x.dtype)
189
+ glob_mask = build_attn_mask(seg, 0, input_ids.device, x.dtype)
190
+
191
+ hidden_states = [] if output_hidden_states else None
192
+ for blk in self.blocks:
193
+ m = glob_mask if blk.window == 0 else attn_mask
194
+ x = blk(x, pos, m)
195
+ if output_hidden_states:
196
+ hidden_states.append(x)
197
+
198
+ x = self.norm_out(x)
199
+ out = dict(
200
+ char=self.head_char(x),
201
+ boundary=self.head_bnd(x),
202
+ dia=self.head_dia(x),
203
+ cap=self.head_cap(x),
204
+ punct=self.head_punct(x),
205
+ )
206
+ if output_hidden_states:
207
+ out["hidden_states"] = tuple(hidden_states) + (x,)
208
+ if not return_dict:
209
+ return tuple(v for v in out.values() if v is not None)
210
+ return CharBertOutput(**out)
processing_char_bert.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF-Hub-compatible processor for Stoicheia: text <-> the model's four input planes.
2
+
3
+ Wraps the reference normalization/denormalization logic (character classification,
4
+ diacritic packing, word/sentence-boundary detection) into a single callable that
5
+ produces model-ready tensors, plus decode helpers for the three masking use cases
6
+ described in the model card (restoration, accent recovery, re-segmentation).
7
+
8
+ This is intentionally NOT a `PreTrainedTokenizer` subclass: the underlying encoding is
9
+ a row-per-letter, four-parallel-plane structure (not a single token-id stream), which
10
+ doesn't fit that base class's assumptions. It follows the same `register_for_auto_class`
11
+ mechanism transformers uses for tokenizers/feature extractors, so
12
+ `AutoProcessor.from_pretrained(repo_id, trust_remote_code=True)` works the same way.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import unicodedata
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+ MASK, BLANK, PAD = 24, 25, 26
26
+ UNK_BND, UNK_DIA, UNK_PUNCT = 3, 48, 6
27
+
28
+ ALPHABET = "αβγδεζηθικλμνξοπρστυφχψω"
29
+ LETTER_IDS = {c: i for i, c in enumerate(ALPHABET)}
30
+ ID2LETTER = np.array(list(ALPHABET))
31
+
32
+ _EXTRA_BASE = {
33
+ "ς": "σ", "ϲ": "σ", "Ϲ": "σ", "ϐ": "β", "ϑ": "θ", "ϕ": "φ", "ϰ": "κ", "ϱ": "ρ", "ϖ": "π",
34
+ }
35
+ _MARK_MAP = {
36
+ 0x0301: "acute", 0x0341: "acute", 0x0300: "grave", 0x0340: "grave",
37
+ 0x0342: "circ", 0x0302: "circ", 0x0313: "smooth", 0x0343: "smooth",
38
+ 0x0314: "rough", 0x0345: "iota", 0x0308: "diaer",
39
+ }
40
+ _ACC = {"acute": 1, "grave": 2, "circ": 3}
41
+ _BR = {"smooth": 1, "rough": 2}
42
+ _MARK_CHARS = {"acute": "́", "grave": "̀", "circ": "͂",
43
+ "smooth": "̓", "rough": "̔", "iota": "ͅ", "diaer": "̈"}
44
+ # punct plane classes (matches data/normalize.py's canonical 6-way scheme):
45
+ # 0 none, 1 comma, 2 high-dot(·), 3 colon, 4 period, 5 question/exclamation
46
+ _PUNCT_CHARS = {1: ",", 2: "·", 3: ":", 4: ".", 5: ";"}
47
+
48
+ # "λόγ[5±3]καὶ" -- a lacuna of uncertain width: best guess 5 letters, plausible
49
+ # range 5-3..5+3. See CharBertProcessor.restore_elastic.
50
+ _ELASTIC_RE = re.compile(r"\[(\d+)±(\d+)\]")
51
+
52
+
53
+ def _pack_dia(acc, br, iota, diaer):
54
+ return ((acc * 3 + br) * 2 + iota) * 2 + diaer
55
+
56
+
57
+ def _unpack_dia(d):
58
+ diaer = d % 2; d //= 2
59
+ iota = d % 2; d //= 2
60
+ br = d % 3; acc = d // 3
61
+ return acc, br, iota, diaer
62
+
63
+
64
+ @dataclass
65
+ class _Encoded:
66
+ chars: list # int, may include MASK
67
+ boundary: list
68
+ dia: list
69
+ punct: list
70
+ cap: list # original capitalization, for round-tripping non-masked positions
71
+
72
+
73
+ class CharBertProcessor:
74
+ """`processor(text)` -> dict of batched tensors ready for `CharBertModel(**batch)`."""
75
+
76
+ def __init__(self):
77
+ pass
78
+
79
+ @classmethod
80
+ def from_pretrained(cls, *_args, **_kwargs):
81
+ return cls()
82
+
83
+ def save_pretrained(self, save_directory, **_kwargs):
84
+ Path(save_directory).mkdir(parents=True, exist_ok=True)
85
+ (Path(save_directory) / "processor_config.json").write_text(json.dumps({"processor_class": "CharBertProcessor"}))
86
+
87
+ # ---------------------------------------------------------------- encode
88
+
89
+ def _classify(self, text: str) -> _Encoded:
90
+ """Turn raw NFC/NFD polytonic text into per-letter plane lists, damage ('-' runs)
91
+ preserved as MASK/UNK positions, gold values kept for every other position."""
92
+ nfd = unicodedata.normalize("NFD", text)
93
+ chars, boundary, dia, punct, cap = [], [], [], [], []
94
+ acc = br = iota = diaer = 0
95
+ pending_bnd = 0
96
+ i = 0
97
+ while i < len(nfd):
98
+ ch = nfd[i]
99
+ if ch == "-":
100
+ run = 0
101
+ while i < len(nfd) and nfd[i] == "-":
102
+ run += 1
103
+ i += 1
104
+ for _ in range(run):
105
+ chars.append(MASK); boundary.append(UNK_BND)
106
+ dia.append(UNK_DIA); punct.append(UNK_PUNCT); cap.append(0)
107
+ continue
108
+ low = ch.lower()
109
+ base = low if low in LETTER_IDS else _EXTRA_BASE.get(low)
110
+ if base is not None:
111
+ if chars and pending_bnd:
112
+ boundary[-1] = pending_bnd
113
+ pending_bnd = 0
114
+ chars.append(LETTER_IDS[base])
115
+ cap.append(1 if ch != low else 0)
116
+ boundary.append(0)
117
+ dia.append(0) # filled in by trailing combining marks below
118
+ punct.append(0)
119
+ acc = br = iota = diaer = 0
120
+ elif unicodedata.combining(ch) or ord(ch) in _MARK_MAP:
121
+ kind = _MARK_MAP.get(ord(ch))
122
+ if kind in _ACC:
123
+ acc = _ACC[kind]
124
+ elif kind in _BR:
125
+ br = _BR[kind]
126
+ elif kind == "iota":
127
+ iota = 1
128
+ elif kind == "diaer":
129
+ diaer = 1
130
+ if dia:
131
+ dia[-1] = _pack_dia(acc, br, iota, diaer)
132
+ elif ch.isspace():
133
+ pending_bnd = max(pending_bnd, 1)
134
+ elif ch in ".;!?":
135
+ pending_bnd = max(pending_bnd, 2)
136
+ if punct:
137
+ punct[-1] = 4 if ch == "." else 5
138
+ elif ch in ",:··":
139
+ if punct:
140
+ punct[-1] = {",": 1, "·": 2, "·": 2, ":": 3}.get(ch, 0)
141
+ i += 1
142
+ if boundary:
143
+ boundary[-1] = max(boundary[-1], 2)
144
+ return _Encoded(chars, boundary, dia, punct, cap)
145
+
146
+ def __call__(self, text: str, mask_planes: list[str] | None = None, has_boundaries: bool = True):
147
+ """Encode `text` into model-ready tensors.
148
+
149
+ mask_planes: any subset of {"chars", "boundary", "dia", "punct"} to force to
150
+ UNKNOWN at every position (in addition to any '-' runs, which are always
151
+ treated as a damaged/masked span regardless of mask_planes).
152
+ has_boundaries: set False for scriptio continua input (no real spaces) so the
153
+ boundary plane starts fully UNKNOWN rather than "no boundaries found".
154
+ """
155
+ mask_planes = set(mask_planes or [])
156
+ enc = self._classify(text)
157
+ n = len(enc.chars)
158
+ chars = np.array(enc.chars, dtype=np.int64)
159
+ boundary = np.array(enc.boundary, dtype=np.int64)
160
+ dia = np.array(enc.dia, dtype=np.int64)
161
+ punct = np.array(enc.punct, dtype=np.int64)
162
+
163
+ if "chars" in mask_planes:
164
+ chars[:] = MASK
165
+ if "boundary" in mask_planes or not has_boundaries:
166
+ boundary[:] = UNK_BND
167
+ if "dia" in mask_planes:
168
+ dia[:] = UNK_DIA
169
+ if "punct" in mask_planes:
170
+ punct[:] = UNK_PUNCT
171
+
172
+ batch = dict(
173
+ input_ids=torch.from_numpy(chars)[None],
174
+ boundary=torch.from_numpy(boundary)[None],
175
+ dia=torch.from_numpy(dia)[None],
176
+ punct=torch.from_numpy(punct)[None],
177
+ seg_id=torch.zeros(1, n, dtype=torch.long),
178
+ )
179
+ batch["_cap"] = enc.cap # kept out-of-band; not a model input (cap is output-only)
180
+ return batch
181
+
182
+ # ---------------------------------------------------------------- decode
183
+
184
+ @staticmethod
185
+ def _restore_polytonic(chars, dia, cap, boundary, punct=None) -> str:
186
+ words, cur = [], []
187
+ for i in range(len(chars)):
188
+ ch = ID2LETTER[chars[i]] if chars[i] < 24 else "?"
189
+ a, b, io, dd = _unpack_dia(int(dia[i]))
190
+ if cap[i]:
191
+ ch = ch.upper()
192
+ s = ch
193
+ if b:
194
+ s += _MARK_CHARS[{1: "smooth", 2: "rough"}[b]]
195
+ if dd:
196
+ s += _MARK_CHARS["diaer"]
197
+ if a:
198
+ s += _MARK_CHARS[{1: "acute", 2: "grave", 3: "circ"}[a]]
199
+ if io:
200
+ s += _MARK_CHARS["iota"]
201
+ cur.append(s)
202
+ p = int(punct[i]) if punct is not None else 0
203
+ if boundary[i] >= 1:
204
+ w = "".join(cur)
205
+ if w and w[-1] == "σ":
206
+ w = w[:-1] + "ς"
207
+ w = unicodedata.normalize("NFC", w)
208
+ if p in _PUNCT_CHARS:
209
+ w += _PUNCT_CHARS[p]
210
+ elif boundary[i] == 2:
211
+ w += "."
212
+ words.append(w)
213
+ cur = []
214
+ if cur:
215
+ w = unicodedata.normalize("NFC", "".join(cur))
216
+ p = int(punct[-1]) if punct is not None else 0
217
+ if p in _PUNCT_CHARS:
218
+ w += _PUNCT_CHARS[p]
219
+ words.append(w)
220
+ return " ".join(words)
221
+
222
+ def decode_restoration(self, model_out, batch) -> str:
223
+ """Fill masked positions with the model's argmax predictions; keep every
224
+ other position exactly as given. Each plane is filled independently
225
+ wherever IT is unknown -- chars/cap only inside a '-' gap (chars==MASK),
226
+ but boundary/dia/punct wherever THAT plane is UNK, which may be the whole
227
+ sequence if mask_planes was also used for joint gap+accent+boundary
228
+ restoration (not just the '-' gap itself)."""
229
+ pred_char = model_out.char.argmax(-1)[0].tolist()
230
+ pred_bnd = model_out.boundary.argmax(-1)[0].tolist()
231
+ pred_dia = model_out.dia.argmax(-1)[0].tolist()
232
+ pred_cap = model_out.cap.argmax(-1)[0].tolist()
233
+ pred_punct = model_out.punct.argmax(-1)[0].tolist()
234
+ chars = batch["input_ids"][0].tolist()
235
+ boundary = batch["boundary"][0].tolist()
236
+ dia = batch["dia"][0].tolist()
237
+ punct = batch["punct"][0].tolist()
238
+ cap = batch["_cap"]
239
+ for i in range(len(chars)):
240
+ if chars[i] == MASK:
241
+ chars[i] = pred_char[i] if pred_char[i] < 24 else 0
242
+ cap[i] = pred_cap[i]
243
+ if boundary[i] == UNK_BND:
244
+ boundary[i] = pred_bnd[i]
245
+ if dia[i] == UNK_DIA:
246
+ dia[i] = pred_dia[i]
247
+ if punct[i] == UNK_PUNCT:
248
+ punct[i] = pred_punct[i]
249
+ return self._restore_polytonic(chars, dia, cap, boundary, punct)
250
+
251
+ def decode_diacritics(self, model_out, batch) -> str:
252
+ """Replace the diacritic plane with the model's predictions; letters/boundaries/
253
+ capitalization/punctuation are taken from the input as given."""
254
+ pred_dia = model_out.dia.argmax(-1)[0].tolist()
255
+ chars = batch["input_ids"][0].tolist()
256
+ boundary = batch["boundary"][0].tolist()
257
+ punct = batch["punct"][0].tolist()
258
+ cap = batch["_cap"]
259
+ return self._restore_polytonic(chars, pred_dia, cap, boundary, punct)
260
+
261
+ def decode_boundaries(self, model_out, batch) -> str:
262
+ """Replace the boundary plane with the model's predictions (0/1/2); letters/
263
+ diacritics/capitalization/punctuation are taken from the input as given.
264
+ Only useful when the input truly has no accents either (a spaced-out or
265
+ scriptio-continua text that already carries accents gives the boundary head
266
+ a strong shortcut -- each word carries exactly one accent -- so this isn't a
267
+ meaningful standalone test of the boundary head specifically; see
268
+ decode_restoration/restore_elastic for the realistic joint case)."""
269
+ pred_bnd = model_out.boundary.argmax(-1)[0].tolist()
270
+ chars = batch["input_ids"][0].tolist()
271
+ dia = batch["dia"][0].tolist()
272
+ punct = batch["punct"][0].tolist()
273
+ cap = batch["_cap"]
274
+ return self._restore_polytonic(chars, dia, cap, pred_bnd, punct)
275
+
276
+ def restore_elastic(self, model, text: str, min_width: int = 1,
277
+ mask_dia_boundary: bool = False):
278
+ """Restore a lacuna of *uncertain* width -- the realistic editorial case,
279
+ since editors estimate a lacuna's length, they rarely know it exactly.
280
+
281
+ `text` must contain exactly one `[N±M]` marker (best-guess width N,
282
+ plausible range N-M..N+M), e.g. `"λόγ[5±3]καὶ ὁ λόγος ἦν πρὸς τὸν θεόν"`.
283
+ For every candidate width in that range, this fills the gap, then scores
284
+ each candidate by the mean log-probability of the model's own letter
285
+ predictions inside the gap specifically (that's what distinguishes widths).
286
+
287
+ `mask_dia_boundary` controls what happens OUTSIDE the gap:
288
+ - False (default): real accents/word-boundaries already present in
289
+ `text` are kept as given -- only the gap itself is filled. Use this
290
+ for text where the surrounding context is already known/accented (the
291
+ common editorial case: a lacuna in an otherwise-legible inscription).
292
+ - True: accents and word-boundaries are masked and reconstructed
293
+ everywhere, not just inside the gap -- for fully bare scriptio
294
+ continua surrounding the lacuna too (no spaces, no accents at all).
295
+
296
+ Returns `(best_text, best_width, candidates)`, where `candidates` is every
297
+ `(width, filled_text, mean_logp)` tried, sorted best-first. Needs the model
298
+ (not just its output), since it runs one forward pass per candidate width.
299
+ """
300
+ m = _ELASTIC_RE.search(text)
301
+ if not m:
302
+ raise ValueError("text must contain one '[N±M]' marker, e.g. 'λόγ[5±3]καὶ'")
303
+ n, spread = int(m.group(1)), int(m.group(2))
304
+ prefix, suffix = text[:m.start()], text[m.end():]
305
+ gap_start = len(self._classify(prefix).chars)
306
+
307
+ candidates = []
308
+ for L in range(max(min_width, n - spread), n + spread + 1):
309
+ probe = prefix + ("-" * L) + suffix
310
+ if mask_dia_boundary:
311
+ batch = self(probe, mask_planes=["dia", "boundary"], has_boundaries=False)
312
+ else:
313
+ batch = self(probe)
314
+ with torch.no_grad():
315
+ out = model(**{k: v for k, v in batch.items() if not k.startswith("_")})
316
+
317
+ logp = torch.log_softmax(out.char, dim=-1)[0]
318
+ pred_char = out.char.argmax(-1)[0].tolist()
319
+ gap_logp = sum(logp[gap_start + i, pred_char[gap_start + i]].item()
320
+ for i in range(L)) / L
321
+
322
+ pred_bnd = out.boundary.argmax(-1)[0].tolist()
323
+ pred_dia = out.dia.argmax(-1)[0].tolist()
324
+ pred_cap = out.cap.argmax(-1)[0].tolist()
325
+ pred_punct = out.punct.argmax(-1)[0].tolist()
326
+ chars = batch["input_ids"][0].tolist()
327
+ boundary = batch["boundary"][0].tolist()
328
+ dia = batch["dia"][0].tolist()
329
+ punct = batch["punct"][0].tolist()
330
+ cap = batch["_cap"]
331
+ for i in range(len(chars)):
332
+ if chars[i] == MASK:
333
+ chars[i] = pred_char[i] if pred_char[i] < 24 else 0
334
+ cap[i] = pred_cap[i]
335
+ if boundary[i] == UNK_BND:
336
+ boundary[i] = pred_bnd[i]
337
+ if dia[i] == UNK_DIA:
338
+ dia[i] = pred_dia[i]
339
+ if punct[i] == UNK_PUNCT:
340
+ punct[i] = pred_punct[i]
341
+ filled = self._restore_polytonic(chars, dia, cap, boundary, punct)
342
+ candidates.append((L, filled, gap_logp))
343
+
344
+ candidates.sort(key=lambda c: -c[2])
345
+ best_L, best_text, _ = candidates[0]
346
+ return best_text, best_L, candidates
training_metadata.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "whole_v4_t4v5",
3
+ "test_digit": "4",
4
+ "val_digit": "5",
5
+ "seed": 0,
6
+ "attn": "flex",
7
+ "compile": true,
8
+ "seq_len": 8192,
9
+ "micro_batch": 4,
10
+ "grad_accum": 2,
11
+ "lr": 0.0001,
12
+ "wd": 0.1,
13
+ "warmup": 200,
14
+ "total_steps": 6000,
15
+ "decay_len": 1500,
16
+ "eval_every": 1000000000,
17
+ "eval_n": 256,
18
+ "dev_cer_n_per_L": 60,
19
+ "dev_cer_L_max": 20,
20
+ "stall_window": 8,
21
+ "stall_eps": 0.002,
22
+ "anneal_patience": 3,
23
+ "log_every": 20,
24
+ "lam": 0.1,
25
+ "meta_condition": true,
26
+ "min_len": 20,
27
+ "torso": "best.pt",
28
+ "mix": {
29
+ "iphi": 1.0,
30
+ "iphi_syn": 0.5,
31
+ "papyri": 1.0,
32
+ "iphi_seg": 0.5,
33
+ "pap_seg": 0.3,
34
+ "literary": 0.3,
35
+ "literary_n": 60000
36
+ },
37
+ "noise": {
38
+ "w_span": 0.45,
39
+ "w_word": 0.15,
40
+ "w_elastic": 0.05,
41
+ "w_iid": 0.15,
42
+ "w_halfword": 0.15,
43
+ "w_substitute": 0.05,
44
+ "span_mean": 4.0,
45
+ "span_max": 12,
46
+ "p_region_none": 0.3,
47
+ "p_century_none": 0.3
48
+ },
49
+ "dev_exclude": "",
50
+ "out_dir": "whole_v4_t4v5",
51
+ "save_every": 0,
52
+ "_source_checkpoint": "final.pt",
53
+ "_source_step": 6000
54
+ }