File size: 10,914 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
"""Collate shard records into model-ready batches: noise, then bin-pack.

Pipeline per record: raw planes (chars/boundary/dia/cap/punct) -> noise_sequence (elastic
patterns change length) -> pack B records into fixed-width rows, building seg_id for
doc-masked attention (multiple documents per row, ~99% fill vs ~20% one-per-row).

Aux-head labels (boundary/dia/cap/punct) are supervised only where the character was masked
OR the corresponding input channel was dropped for that position — elsewhere it would just
be copy-through, teaching the model nothing.
"""
from __future__ import annotations

import numpy as np
import torch

from train.noising import NoiseConfig, noise_sequence

UNK_BND, UNK_DIA, UNK_PUNCT = 3, 48, 6
# Metadata-conditioning ids (insc/data/meta_vocab.py's UNK_REGION/UNK_CENTURY) -- kept as
# plain constants here, not imported, so base pretraining (which never sets these) doesn't
# pick up a dependency on the insc-only package. Records without region_id/century_id
# (every non-insc shard) fall back to these UNK rows; CharBertEncoder only reads them at
# all when its optional e_region/e_century tables are enabled (n_region/n_century > 0).
UNK_REGION, UNK_CENTURY = 14, 15


def _dropped_region_century(rec, cfg, g):
    """Per-DOCUMENT metadata dropout (not per-position): with probability p_region_none /
    p_century_none, force UNK regardless of whether the true value is known -- so the model
    is trained under both "metadata given" and "metadata withheld" conditions (a real
    fragment's provenance/date is often genuinely unknown at inference too). Independent
    draws: knowing a find-spot doesn't imply knowing the date or vice versa."""
    region_id = rec.get("region_id", UNK_REGION)
    century_id = rec.get("century_id", UNK_CENTURY)
    if cfg.p_region_none > 0 and torch.rand(1, generator=g).item() < cfg.p_region_none:
        region_id = UNK_REGION
    if cfg.p_century_none > 0 and torch.rand(1, generator=g).item() < cfg.p_century_none:
        century_id = UNK_CENTURY
    return region_id, century_id


def pack_batch(record_iter, cfg: NoiseConfig, T_char, rows, g):
    """Greedy sequence packing: pull records, noise them, bin-pack into `rows` rows of width
    T_char. Each doc within a row gets a distinct seg id (1,2,..) so attention never crosses
    a document boundary (block-diagonal, not a context discount)."""
    B = rows
    ids = torch.full((B, T_char), cfg.pad_id, dtype=torch.long)
    bnd_in = torch.full((B, T_char), UNK_BND, dtype=torch.long)
    dia_in = torch.full((B, T_char), UNK_DIA, dtype=torch.long)
    punct_in = torch.full((B, T_char), UNK_PUNCT, dtype=torch.long)
    region_in = torch.full((B, T_char), UNK_REGION, dtype=torch.long)
    century_in = torch.full((B, T_char), UNK_CENTURY, dtype=torch.long)
    seg = torch.zeros(B, T_char, dtype=torch.long)
    labels = torch.full((B, T_char), -100, dtype=torch.long)
    loss_w = torch.zeros(B, T_char)
    bnd_lab = torch.full((B, T_char), -100, dtype=torch.long)
    dia_lab = torch.full((B, T_char), -100, dtype=torch.long)
    cap_lab = torch.full((B, T_char), -100, dtype=torch.long)
    punct_lab = torch.full((B, T_char), -100, dtype=torch.long)

    for b in range(B):
        cpos = 0          # char cursor in this row
        doc = 0            # doc id within row
        misses = 0         # consecutive records that didn't fit the tail
        while True:
            rec = next(record_iter)
            chars = torch.from_numpy(rec["chars"].astype(np.int64))
            boundary = torch.from_numpy(rec["boundary"].astype(np.int64))
            real_lac = (torch.from_numpy(rec["is_real_lacuna"]) if "is_real_lacuna" in rec
                       else None)
            out = noise_sequence(chars, boundary.to(torch.uint8), cfg, g, is_real_lacuna=real_lac)
            L = out["input_ids"].numel()
            if L > T_char:          # over-long single doc: truncate to fit an empty row
                L = T_char
            if cpos + L > T_char:
                if cpos == 0:       # row empty: force-place a truncated copy so we never stall
                    L = T_char
                else:               # try a few more (smaller) records before giving up on this row
                    misses += 1
                    if misses >= 6:
                        break
                    continue
            misses = 0
            doc += 1
            sl = slice(cpos, cpos + L)
            ids[b, sl] = out["input_ids"][:L]
            labels[b, sl] = out["labels"][:L]
            loss_w[b, sl] = out["loss_w"][:L]
            seg[b, sl] = doc
            region_id, century_id = _dropped_region_century(rec, cfg, g)
            region_in[b, sl] = region_id
            century_in[b, sl] = century_id
            kb = out["keep_bnd_mask"][:L]; kd = out["keep_dia_mask"][:L]; kp = out["keep_punct_mask"][:L]
            bnd_true = out["boundary"][:L].long().clamp(max=2)
            bnd_in[b, sl] = torch.where(kb, bnd_true, torch.full_like(bnd_true, UNK_BND))
            if not out["rebuilt"]:
                dia_true = torch.from_numpy(rec["dia"].astype(np.int64))[:L]
                dia_in[b, sl] = torch.where(kd, dia_true, torch.full_like(dia_true, UNK_DIA))
                punct_true = torch.from_numpy(rec["punct"].astype(np.int64))[:L]
                punct_in[b, sl] = torch.where(kp, punct_true, torch.full_like(punct_true, UNK_PUNCT))
            masked = ids[b, sl] == cfg.mask_id
            if not out["rebuilt"]:
                Lc = min(L, chars.numel())
                # real-lacuna positions have no ground truth for ANY channel (not just chars)
                # -- exclude them from aux supervision too, same as the char loss.
                not_lac = (~real_lac[:Lc] if real_lac is not None
                          else torch.ones(Lc, dtype=torch.bool))
                sup_b = (masked[:Lc] | (~kb[:Lc])) & not_lac
                sup_d = (masked[:Lc] | (~kd[:Lc])) & not_lac
                sup_p = (masked[:Lc] | (~kp[:Lc])) & not_lac
                sup_c = masked[:Lc] & not_lac
                bnd_t = boundary[:Lc]; dia_t = torch.from_numpy(rec["dia"].astype(np.int64))[:Lc]
                cap_t = torch.from_numpy(rec["cap"].astype(np.int64))[:Lc]
                punct_t = torch.from_numpy(rec["punct"].astype(np.int64))[:Lc]
                bnd_lab[b, cpos:cpos+Lc] = torch.where(sup_b, bnd_t, torch.full_like(bnd_t, -100))
                dia_lab[b, cpos:cpos+Lc] = torch.where(sup_d, dia_t, torch.full_like(dia_t, -100))
                cap_lab[b, cpos:cpos+Lc] = torch.where(sup_c, cap_t, torch.full_like(cap_t, -100))
                punct_lab[b, cpos:cpos+Lc] = torch.where(sup_p, punct_t, torch.full_like(punct_t, -100))
            cpos += L
            if cpos >= T_char - 8:
                break
    return dict(
        input_ids=ids, boundary=bnd_in, dia=dia_in, punct=punct_in, seg_id=seg,
        region=region_in, century=century_in,
        labels=labels, loss_w=loss_w, bnd_lab=bnd_lab, dia_lab=dia_lab, cap_lab=cap_lab,
        punct_lab=punct_lab,
    )


def collate(records, cfg: NoiseConfig, T_char, g):
    """records: list of dicts with chars/boundary/dia/cap/punct (np.uint8 arrays). One doc
    per row (no packing) — used for eval batches where records are already pre-selected."""
    B = len(records)
    ids = torch.full((B, T_char), cfg.pad_id, dtype=torch.long)
    bnd_in = torch.full((B, T_char), UNK_BND, dtype=torch.long)
    dia_in = torch.full((B, T_char), UNK_DIA, dtype=torch.long)
    punct_in = torch.full((B, T_char), UNK_PUNCT, dtype=torch.long)
    region_in = torch.full((B, T_char), UNK_REGION, dtype=torch.long)
    century_in = torch.full((B, T_char), UNK_CENTURY, dtype=torch.long)
    seg = torch.zeros(B, T_char, dtype=torch.long)
    labels = torch.full((B, T_char), -100, dtype=torch.long)
    loss_w = torch.zeros(B, T_char)
    bnd_lab = torch.full((B, T_char), -100, dtype=torch.long)
    dia_lab = torch.full((B, T_char), -100, dtype=torch.long)
    cap_lab = torch.full((B, T_char), -100, dtype=torch.long)
    punct_lab = torch.full((B, T_char), -100, dtype=torch.long)

    for b, rec in enumerate(records):
        chars = torch.from_numpy(rec["chars"].astype(np.int64))
        boundary = torch.from_numpy(rec["boundary"].astype(np.int64))
        real_lac = (torch.from_numpy(rec["is_real_lacuna"]) if "is_real_lacuna" in rec
                   else None)
        out = noise_sequence(chars, boundary.to(torch.uint8), cfg, g, is_real_lacuna=real_lac)
        seqlen = min(out["input_ids"].numel(), T_char)
        ids[b, :seqlen] = out["input_ids"][:seqlen]
        labels[b, :seqlen] = out["labels"][:seqlen]
        loss_w[b, :seqlen] = out["loss_w"][:seqlen]
        seg[b, :seqlen] = b + 1
        region_id, century_id = _dropped_region_century(rec, cfg, g)
        region_in[b, :seqlen] = region_id
        century_in[b, :seqlen] = century_id
        kb = out["keep_bnd_mask"][:seqlen]; kd = out["keep_dia_mask"][:seqlen]
        kp = out["keep_punct_mask"][:seqlen]
        bnd_true = out["boundary"][:seqlen].long().clamp(max=2)
        bnd_in[b, :seqlen] = torch.where(kb, bnd_true, torch.full_like(bnd_true, UNK_BND))
        if not out["rebuilt"]:
            dia_true = torch.from_numpy(rec["dia"].astype(np.int64))[:seqlen]
            dia_in[b, :seqlen] = torch.where(kd, dia_true, torch.full_like(dia_true, UNK_DIA))
            punct_true = torch.from_numpy(rec["punct"].astype(np.int64))[:seqlen]
            punct_in[b, :seqlen] = torch.where(kp, punct_true, torch.full_like(punct_true, UNK_PUNCT))

        masked = ids[b] == cfg.mask_id
        if not out["rebuilt"]:
            L = min(seqlen, chars.numel())
            not_lac = (~real_lac[:L] if real_lac is not None
                      else torch.ones(L, dtype=torch.bool))
            sup_b = (masked[:L] | (~kb[:L])) & not_lac
            sup_d = (masked[:L] | (~kd[:L])) & not_lac
            sup_p = (masked[:L] | (~kp[:L])) & not_lac
            sup_c = masked[:L] & not_lac
            cap_t = torch.from_numpy(rec["cap"].astype(np.int64))
            bnd_t = boundary.clone()
            dia_t = torch.from_numpy(rec["dia"].astype(np.int64))
            punct_t = torch.from_numpy(rec["punct"].astype(np.int64))
            bnd_lab[b, :L] = torch.where(sup_b, bnd_t[:L], torch.full_like(bnd_t[:L], -100))
            dia_lab[b, :L] = torch.where(sup_d, dia_t[:L], torch.full_like(dia_t[:L], -100))
            cap_lab[b, :L] = torch.where(sup_c, cap_t[:L], torch.full_like(cap_t[:L], -100))
            punct_lab[b, :L] = torch.where(sup_p, punct_t[:L], torch.full_like(punct_t[:L], -100))

    return dict(
        input_ids=ids, boundary=bnd_in, dia=dia_in, punct=punct_in, seg_id=seg,
        region=region_in, century=century_in,
        labels=labels, loss_w=loss_w, bnd_lab=bnd_lab, dia_lab=dia_lab, cap_lab=cap_lab,
        punct_lab=punct_lab,
    )