File size: 13,398 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 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 | """Elastic masked-diffusion noising (plan §4).
Given a packed batch of char sequences with aligned boundary planes, produce:
input_ids corrupted char ids (MASK where noised)
labels target char ids at supervised positions, -100 elsewhere
loss_w per-position loss weight (MDLM 1/t reweighting, averaged over the batch)
keep_bnd_mask per-POSITION bool: is the boundary channel known as input here
keep_dia_mask per-POSITION bool: is the diacritic channel known as input here
Noise patterns, chosen per sequence by a mixture (weights configurable), rate t ~ [0.05, 0.95]:
span contiguous char run, boundary-agnostic (geometric length) -> the realistic damage
shape: a broken stone or torn papyrus doesn't respect word edges.
word whole word(s) via boundary plane, frequency-weighted so stopwords don't dominate.
halfword a PARTIAL word, anchored to its beginning, middle, or end (weighted toward the end:
Greek is heavily suffixal, so word-final spans train ending/inflection restoration
directly; word-initial spans cover augments/prefixes; medial spans cover internal
damage, e.g. a lost dichronon).
elastic a word span replaced by a VARIABLE number of MASK slots (true length L ->
M in [L, ceil(1.3L)+2]); targets are the L chars followed by (M-L) BLANK(∅) tokens.
Teaches variable-length infilling + a gap-length signal, with no decoder.
iid independent per-char masking at rate t.
The elastic pattern changes sequence length, so noising returns a NEW packed batch
(lengths differ from the input); the collate step must therefore run noising before
building attention/segment masks. Everything here is pure PyTorch, no CUDA specifics,
so it is testable on CPU.
Channel availability (boundary/diacritics) is noised INDEPENDENTLY of char masking, per
CHARACTER POSITION (not per whole sequence): a mixture of fully-known, fully-unknown, and
genuinely patchy (rate ~Uniform(0,1) applied per position) — modeling real epigraphic/
papyrological cases where SOME word-breaks are legible and others are damaged, or SOME
accents survive and others don't, within the same text.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import torch
# reserved id layout (must match model embedding table)
# 0..V-1 char ids (V = alphabet size, currently 24)
# MASK, BLANK, PAD are appended after the alphabet
@dataclass
class NoiseConfig:
vocab: int = 24
mask_id: int = 24
blank_id: int = 25 # ∅ : "gap ends / no char here"
pad_id: int = 26
# mixture weights over patterns (need not sum to 1; normalized). pat index: 0=span,
# 1=word, 2=elastic, 3=iid, 4=halfword, 5=substitute.
w_span: float = 0.15
w_word: float = 0.20
w_elastic: float = 0.15
w_iid: float = 0.15
w_halfword: float = 0.20
w_substitute: float = 0.15 # DENOISING: scattered chars replaced by a WRONG letter (no
# mask signal) — real repaired-OCR/scribal text has substitution
# errors, not just gaps; the model must catch+correct these too.
# rate distribution: t ~ Beta(a,b) clipped to [t_min, t_max]
t_min: float = 0.05
t_max: float = 0.95
beta_a: float = 2.0
beta_b: float = 4.0 # mean ~0.33, mass in the 0.15-0.45 encoder-quality band
span_mean: float = 3.5 # geometric mean length for span/word-ish spans
span_max: int = 20
elastic_pad_frac: float = 0.3 # M = L + Bernoulli-ish extra up to ceil(elastic_pad_frac*L)+2
elastic_extra_min: int = 2
# halfword anchor mixture: weighted toward word-END (Greek inflection is suffixal)
halfword_end_p: float = 0.5
halfword_begin_p: float = 0.3
halfword_mid_p: float = 0.2
# per-position channel-availability mixture: P(fully known) + P(fully unknown) + the
# remainder is genuinely patchy (per-position Bernoulli at a rate ~ Uniform(0,1))
p_bnd_full: float = 0.5
p_bnd_none: float = 0.3
p_dia_full: float = 0.3
p_dia_none: float = 0.5
p_punct_full: float = 0.3
p_punct_none: float = 0.5
# per-DOCUMENT (not per-position) metadata dropout: real region/century values are only
# sometimes known for a real fragment (unprovenanced papyri, no surviving date), so the
# model must be trained under both conditions to be usable in either at inference. Default
# 0.0 preserves every existing caller's exact behavior (metadata conditioning off unless
# a caller explicitly sets these).
p_region_none: float = 0.0
p_century_none: float = 0.0
def _sample_t(cfg, n, g):
a = torch.full((n,), cfg.beta_a)
b = torch.full((n,), cfg.beta_b)
t = torch.distributions.Beta(a, b).sample()
return t.clamp(cfg.t_min, cfg.t_max)
def _mdlm_weight(t):
# MDLM continuous-time weight ~ 1/t (clamped); normalized later per batch
return (1.0 / t.clamp_min(0.05))
def _sample_keep_mask(n, g, p_full, p_none):
"""Per-position channel-availability mask (True = known/kept at this position). Mixture of
fully-known, fully-unknown, and patchy (rate ~ Uniform(0,1), independent per position) —
the patchy case is what lets one sequence have SOME boundaries/accents known and others not."""
u = torch.rand(1, generator=g).item()
if u < p_full:
return torch.ones(n, dtype=torch.bool)
if u < p_full + p_none:
return torch.zeros(n, dtype=torch.bool)
rate = torch.rand(1, generator=g).item()
return torch.rand(n, generator=g) < rate
def _pick_halfword_span(s, e, cfg, g):
"""s,e: char range [s,e) of one word. Returns a sub-span anchored to the word's beginning,
middle, or end (weighted toward the end — see module docstring)."""
L = e - s
if L <= 1:
return s, e
u = torch.rand(1, generator=g).item()
anchor = "end" if u < cfg.halfword_end_p else (
"begin" if u < cfg.halfword_end_p + cfg.halfword_begin_p else "mid")
l = int(torch.randint(1, L + 1, (1,), generator=g).item())
if anchor == "begin":
return s, s + l
if anchor == "end":
return e - l, e
if L <= 2: # too short for a non-edge-touching middle span
return s, e
l = max(1, min(l, L - 2))
off = int(torch.randint(1, L - l, (1,), generator=g).item()) if L - l > 1 else 1
return s + off, s + off + l
def noise_sequence(chars, boundary, cfg: NoiseConfig, g: torch.Generator, is_real_lacuna=None):
"""Corrupt ONE sequence (1D LongTensor chars, 1D boundary). Returns dict of 1D tensors.
is_real_lacuna: optional bool tensor, same length as chars. Marks positions where the
true content is GENUINELY unknown (a real '-'/'...' run from the edition itself --
see insc/data/iphi.py's/papyri.py's text_to_full_planes()), already fed as mask_id in
`chars`. When given, these positions are never eligible to be chosen as an ADDITIONAL
synthetic-masking target (there's nothing there to mask further, and `chars` at those
positions is mask_id, not a real letter -- treating it as one would produce a nonsense
label) and their label always stays -100. None (default) preserves every existing
caller's behavior exactly -- this is purely additive."""
n = chars.numel()
device = chars.device
t = _sample_t(cfg, 1, g).item()
# choose pattern: 0=span, 1=word, 2=elastic, 3=iid, 4=halfword, 5=substitute
weights = torch.tensor([cfg.w_span, cfg.w_word, cfg.w_elastic, cfg.w_iid, cfg.w_halfword,
cfg.w_substitute], dtype=torch.float)
pat = torch.multinomial(weights, 1, generator=g).item()
inp = chars.clone()
lab = torch.full((n,), -100, dtype=torch.long, device=device)
def mask_positions(pos):
inp[pos] = cfg.mask_id
lab[pos] = chars[pos]
def substitute_positions(pos):
# DENOISING: replace with a WRONG letter (never MASK) — no signal that it's corrupted.
wrong = (chars[pos] + torch.randint(1, cfg.vocab, (len(pos),), generator=g,
device=device)) % cfg.vocab
inp[pos] = wrong
lab[pos] = chars[pos]
if pat == 3: # iid (mask)
m = torch.rand(n, generator=g, device=device) < t
if is_real_lacuna is not None:
m = m & ~is_real_lacuna
if m.any():
mask_positions(m.nonzero(as_tuple=True)[0])
return _finish(inp, lab, chars, boundary, t, cfg, g)
if pat == 5: # substitute (denoise) — same scattered selection as iid, no mask token
m = torch.rand(n, generator=g, device=device) < t
if is_real_lacuna is not None:
m = m & ~is_real_lacuna
if m.any():
substitute_positions(m.nonzero(as_tuple=True)[0])
return _finish(inp, lab, chars, boundary, t, cfg, g)
# span/word/elastic/halfword all pick target spans until ~t fraction of chars covered
word_ends = (boundary >= 1).nonzero(as_tuple=True)[0]
# word start indices
starts = torch.cat([torch.tensor([0], device=device), word_ends[:-1] + 1])
words = list(zip(starts.tolist(), (word_ends + 1).tolist())) if word_ends.numel() else [(0, n)]
budget = int(t * n)
covered = 0
chosen_spans = []
tries = 0
# pre-mark real-lacuna positions as "used" -- span selection below already rejects any
# candidate overlapping a used position, so this alone keeps every chosen span entirely
# within genuinely-known text without any extra branching in the selection loop.
used = (is_real_lacuna.clone() if is_real_lacuna is not None
else torch.zeros(n, dtype=torch.bool, device=device))
while covered < budget and tries < 4 * len(words) + 8:
tries += 1
if pat == 0: # span: random contiguous run, boundary-agnostic
L = min(int(torch.distributions.Geometric(1.0 / cfg.span_mean).sample().item()) + 1,
cfg.span_max)
s = int(torch.randint(0, max(n - L, 1), (1,), generator=g).item())
e = min(s + L, n)
elif pat == 4: # halfword: partial word, anchored begin/middle/end
wi = int(torch.randint(0, len(words), (1,), generator=g).item())
ws, we = words[wi]
s, e = _pick_halfword_span(ws, we, cfg, g)
else: # word / elastic: pick a whole word
wi = int(torch.randint(0, len(words), (1,), generator=g).item())
s, e = words[wi]
if s >= e or used[s:e].any():
continue
used[s:e] = True
chosen_spans.append((s, e))
covered += e - s
if pat in (0, 1, 4): # span / word / halfword: fixed-length mask
for s, e in chosen_spans:
mask_positions(torch.arange(s, e, device=device))
return _finish(inp, lab, chars, boundary, t, cfg, g)
# pat == 2 elastic: rebuild the sequence with variable mask runs. chosen_spans is already
# guaranteed lacuna-free (see `used` above); every other position -- including any real
# lacuna -- is copied through unchanged with label -100 by _elastic_rebuild's own walk,
# so no further is_real_lacuna handling is needed once the sequence is rebuilt.
return _elastic_rebuild(chars, boundary, chosen_spans, t, cfg, g)
def _elastic_rebuild(chars, boundary, spans, t, cfg, g):
device = chars.device
n = chars.numel()
spanset = sorted(spans)
out_in, out_lab, out_bnd = [], [], []
i = 0
span_i = 0
while i < n:
if span_i < len(spanset) and i == spanset[span_i][0]:
s, e = spanset[span_i]; span_i += 1
L = e - s
extra = cfg.elastic_extra_min + int(
torch.randint(0, int(cfg.elastic_pad_frac * L) + 1, (1,), generator=g).item())
M = L + extra
out_in.append(torch.full((M,), cfg.mask_id, dtype=torch.long, device=device))
lab = torch.full((M,), -100, dtype=torch.long, device=device)
lab[:L] = chars[s:e]
lab[L:] = cfg.blank_id # predict ∅ for the surplus slots
out_lab.append(lab)
# boundary target for the gap: internal until the real word-end, unknown handled by model
b = torch.zeros(M, dtype=torch.uint8, device=device)
b[L - 1] = boundary[e - 1]
out_bnd.append(b)
i = e
else:
out_in.append(chars[i:i + 1])
out_lab.append(torch.tensor([-100], device=device))
out_bnd.append(boundary[i:i + 1])
i += 1
inp = torch.cat(out_in)
lab = torch.cat(out_lab)
bnd = torch.cat(out_bnd)
return _finish(inp, lab, chars, bnd, t, cfg, g, rebuilt=True)
def _finish(inp, lab, orig_chars, boundary, t, cfg, g, rebuilt=False):
n = inp.numel()
keep_bnd_mask = _sample_keep_mask(n, g, cfg.p_bnd_full, cfg.p_bnd_none)
keep_dia_mask = _sample_keep_mask(n, g, cfg.p_dia_full, cfg.p_dia_none)
keep_punct_mask = _sample_keep_mask(n, g, cfg.p_punct_full, cfg.p_punct_none)
w = _mdlm_weight(torch.tensor(t))
return dict(input_ids=inp, labels=lab, boundary=boundary,
loss_w=torch.full_like(inp, float(w), dtype=torch.float),
keep_bnd_mask=keep_bnd_mask, keep_dia_mask=keep_dia_mask,
keep_punct_mask=keep_punct_mask, t=t, rebuilt=rebuilt)
|