File size: 21,696 Bytes
4e76c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""Lossless character inputs and complete-span heads for the v3 controls.

The encoder still sees one position per Unicode scalar, so UTF-8 fallback does
not consume a different positional budget or split an output character. Existing
vocabulary IDs and embeddings are preserved. Unknown scalars have a reversible
integer ID and four ordered UTF-8 byte slots, never the shared UNK ID alone.
"""

import numpy as np
import torch
from torch import nn

from fp import charclass
from fp.parser2 import Extractor, KINDS, LABELS, predict_spans, tag_paths, legality_matrix

TYPES = ["O", *KINDS]


def utf8_slots(codepoints):
    """UTF-8 bytes + 1, padded with zero to four slots (tensorized, ordered)."""
    cp = codepoints.clamp_min(0)
    two, three, four = cp >= 0x80, cp >= 0x800, cp >= 0x10000
    first = torch.where(four, 0xF0 | (cp >> 18), torch.where(
        three, 0xE0 | (cp >> 12), torch.where(two, 0xC0 | (cp >> 6), cp)))
    second = torch.where(four, 0x80 | ((cp >> 12) & 63), torch.where(
        three, 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)))
    third = torch.where(four, 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
    fourth = 0x80 | (cp & 63)
    return torch.stack((first + 1, torch.where(two, second + 1, 0),
                        torch.where(three, third + 1, 0),
                        torch.where(four, fourth + 1, 0)), dim=-1)


class Parser(Extractor):
    def __init__(self, representation="unicode", head="bio", *, encoder=None, tokenizer=None,
                 char_classes=False):
        if representation not in ("legacy", "unicode", "subword") or head not in ("bio", "span"):
            raise ValueError("unknown representation or head")
        if encoder is None:
            super().__init__("pretrained", len(LABELS))
        else:
            # Tiny injected encoders make alignment/export tests independent of downloads.
            nn.Module.__init__(self)
            self.encoder, self.tokenizer, self.mode = encoder, tokenizer, "pretrained"
            self.tags = nn.Linear(encoder.config.hidden_size, len(LABELS))
        self.representation, self.head = representation, head
        self.base_vocab_size = self.encoder.config.vocab_size
        hidden = self.encoder.config.hidden_size
        if representation == "unicode":
            if hidden % 4:
                raise ValueError("hidden size must be divisible by four")
            self.byte_slots = nn.ModuleList([nn.Embedding(257, hidden // 4, padding_idx=0)
                                             for _ in range(4)])
            for embedding in self.byte_slots:
                nn.init.normal_(embedding.weight, std=0.02)
                with torch.no_grad():
                    embedding.weight[0].zero_()
            self.byte_projection = nn.Linear(hidden, hidden, bias=False)
            nn.init.eye_(self.byte_projection.weight)
        if head == "span":
            self.tags = nn.Linear(hidden, len(TYPES))
            self.starts = nn.Linear(hidden, len(TYPES))
            self.ends = nn.Linear(hidden, len(TYPES))
        self.char_classes = char_classes
        # (structural, other, fallback) identity-dropout rates; set by the trainer.
        self.identity_dropout = (0.0, 0.0, 0.0)
        if char_classes:
            if representation != "unicode":
                raise ValueError("character classes need the unicode representation")
            # Zero-initialised: a model built on an existing encoder starts out
            # computing exactly what it computed without class features.
            self.category_embedding = nn.Embedding(charclass.N_CATEGORIES, hidden)
            self.script_embedding = nn.Embedding(charclass.N_SCRIPTS, hidden)
            nn.init.zeros_(self.category_embedding.weight)
            nn.init.zeros_(self.script_embedding.weight)
            # Stands in for a dropped character identity during training only.
            self.anonymous = nn.Parameter(torch.randn(hidden) * 0.02)
            category, script = charclass.tables()
            id_cp = torch.full((self.base_vocab_size,), -1, dtype=torch.long)
            for token, index in self.tokenizer.get_vocab().items():
                if len(token) == 1 and index < self.base_vocab_size:
                    id_cp[index] = ord(token)
            # Lookup tables are rebuilt from code, never saved: the exported Go
            # artifact carries the same assignment as ranges in its JSON sidecar.
            self.register_buffer("id_codepoint", id_cp, persistent=False)
            self.register_buffer("category_table", torch.from_numpy(category.astype("int64")),
                                 persistent=False)
            self.register_buffer("script_table", torch.from_numpy(script.astype("int64")),
                                 persistent=False)
            structural = torch.zeros(charclass.N_CATEGORIES, dtype=torch.bool)
            structural[list(charclass.STRUCTURAL)] = True
            self.register_buffer("structural_category", structural, persistent=False)

    def fits(self, row):
        """Encoder-window check: characters for character encoders, tokens for subword.

        The frozen character encoder sees one position per code point plus the
        two specials; a subword encoder sees the tokenizer's pieces. No row is
        ever truncated, so the caller filters with this before encoding.
        """
        if self.representation == "subword":
            tokens = self.tokenizer(row["query"], add_special_tokens=True)["input_ids"]
            return len(tokens) <= self.encoder.config.max_position_embeddings
        return len(row["query"]) + 2 <= self.encoder.config.max_position_embeddings

    def encode(self, row):
        if self.representation == "subword":
            return self._encode_subword(row)
        query = row["query"]
        # encode() rejects surrogate code points as well as invalid UTF-8 text.
        query.encode("utf-8", errors="strict")
        tokens = self.tokenizer.tokenize(query)
        if tokens != list(query):
            raise ValueError("v3 requires a character tokenizer with exact original offsets")
        ids = self.tokenizer.convert_tokens_to_ids(tokens)
        if self.representation == "unicode":
            ids = [self.base_vocab_size + ord(char) if token == self.tokenizer.unk_token_id
                   else token for char, token in zip(query, ids)]
        ids = [self.tokenizer.cls_token_id, *ids, self.tokenizer.sep_token_id]
        if len(ids) > self.encoder.config.max_position_embeddings:
            raise ValueError(f"{len(ids)} positions exceed encoder limit; no truncation")
        labels = self._labels(query, row["spans"], row.get("partial", False))
        return ids, list(range(1, len(query) + 1)), list(range(2, len(query) + 2)), labels, row

    def _labels(self, query, spans, partial=False):
        """BIO labels for one row, refusing invalid or misquoted spans.

        Partial rows mark uncovered characters -100 so the loss ignores them:
        the row asserts only what its spans cover, not that nothing else exists.
        """
        labels = [-100 if partial else 0] * len(query)
        previous = 0
        for span in spans:
            a, b, kind = span["start"], span["end"], span["kind"]
            if not 0 <= previous <= a < b <= len(query):
                raise ValueError("invalid or overlapping span offsets")
            previous = b
            if span.get("text", query[a:b]) != query[a:b]:
                raise ValueError("span text does not match original offsets")
            labels[a] = LABELS.index("B-" + kind)
            labels[a + 1:b] = [LABELS.index("I-" + kind)] * (b - a - 1)
        return labels

    def _encode_subword(self, row):
        """Subword tokens with one character window per code point.

        Each character shares the window of the token(s) covering it, so the
        cumulative-mean pooling in forward() still yields exactly one feature per
        character and the span head keeps character resolution. A character the
        tokenizer never covers is an error, never a silent guess.
        """
        query = row["query"]
        query.encode("utf-8", errors="strict")
        encoding = self.tokenizer(query, add_special_tokens=True,
                                  return_offsets_mapping=True)
        ids, offsets = encoding["input_ids"], encoding["offset_mapping"]
        if len(ids) > self.encoder.config.max_position_embeddings:
            raise ValueError(f"{len(ids)} tokens exceed encoder limit; no truncation")
        starts, ends = [], []
        for index in range(len(query)):
            first = last = None
            for position, (start, stop) in enumerate(offsets):
                if start == stop or not start <= index < stop:
                    continue
                if first is None:
                    first = position
                last = position
            if first is not None:
                starts.append((first, last + 1))
            else:
                starts.append(None)
        if query and all(window is None for window in starts):
            raise ValueError(f"tokenizer covers nothing in {query!r}")
        # Word-piece tokenizers normalize some characters away (sentencepiece
        # collapses repeated whitespace). Those characters inherit the nearest
        # covered character's window so the character grid stays intact; the
        # count is recorded on the model and reported in the run config.
        uncovered = 0
        for index in range(len(query)):
            if starts[index] is not None:
                continue
            uncovered += 1
            previous = next((starts[position] for position in range(index - 1, -1, -1)
                             if starts[position] is not None), None)
            following = next((starts[position] for position in range(index + 1, len(query))
                              if starts[position] is not None), None)
            starts[index] = previous or following
        self.uncovered_chars = getattr(self, "uncovered_chars", 0) + uncovered
        ends = [window[1] for window in starts]
        starts = [window[0] for window in starts]
        return (ids, starts, ends,
                self._labels(query, row["spans"], row.get("partial", False)), row)

    def character_embeddings(self, ids):
        fallback = ids >= self.base_vocab_size
        known = ids.masked_fill(fallback, self.tokenizer.unk_token_id)
        embedded = self.encoder.embeddings.word_embeddings(known)
        if self.representation == "legacy":
            return embedded
        slots = utf8_slots(ids - self.base_vocab_size)
        composed = torch.cat([embedding(slots[..., i])
                              for i, embedding in enumerate(self.byte_slots)], dim=-1)
        composed = self.byte_projection(composed.reshape(-1, composed.shape[-1])).reshape_as(embedded)
        embedded = torch.where(fallback.unsqueeze(-1), composed, embedded)
        if not self.char_classes:
            return embedded
        codepoint = torch.where(fallback, ids - self.base_vocab_size,
                                self.id_codepoint[ids.clamp(0, self.base_vocab_size - 1)])
        is_char = codepoint >= 0
        safe = codepoint.clamp(0, charclass.LIMIT - 1)
        category = torch.where(is_char, self.category_table[safe], 0)
        script = torch.where(is_char, self.script_table[safe], 0)
        structural, other, unseen = self.identity_dropout
        if self.training and (structural or other or unseen):
            rate = torch.where(self.structural_category[category], structural, other)
            rate = torch.where(fallback, torch.clamp(rate, min=unseen), rate)
            dropped = is_char & (torch.rand(rate.shape, device=rate.device) < rate)
            embedded = torch.where(dropped.unsqueeze(-1), self.anonymous.to(embedded.dtype),
                                   embedded)
        return embedded + self.category_embedding(category) + self.script_embedding(script)

    def forward(self, batch):
        ids, attention, starts, ends = batch
        if self.representation in ("legacy", "subword"):
            hidden = self.encoder(input_ids=ids, attention_mask=attention).last_hidden_state
        else:
            hidden = self.encoder(inputs_embeds=self.character_embeddings(ids),
                                  attention_mask=attention).last_hidden_state
        cumulative = torch.cat((torch.zeros_like(hidden[:, :1]), hidden.cumsum(1)), 1)
        a = starts.unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
        b = ends.unsqueeze(-1).expand_as(a)
        chars = (cumulative.gather(1, b) - cumulative.gather(1, a)) / (
            ends - starts).clamp_min(1).unsqueeze(-1)
        logits = self.tags(chars.reshape(-1, chars.shape[-1]))
        return logits.reshape(*chars.shape[:2], -1), chars

    def boundary_logits(self, chars):
        flat = chars.reshape(-1, chars.shape[-1])
        shape = (*chars.shape[:2], len(TYPES))
        return self.starts(flat).reshape(shape), self.ends(flat).reshape(shape)


def span_targets(labels):
    """Type/start/end class targets, respecting adjacent same-type gold spans."""
    valid = labels >= 0
    types = torch.where(valid, (labels + 1) // 2, -100)
    starts = torch.where(valid, torch.where(labels % 2 == 1, types, 0), -100)
    following = torch.cat((labels[:, 1:], torch.zeros_like(labels[:, :1])), dim=1)
    continues = (following == 2 * types) & (types > 0)
    ends = torch.where(valid, torch.where((types > 0) & ~continues, types, 0), -100)
    return types, starts, ends


def class_weights(targets, size):
    """Smoothed inverse-square-root weights; unseen classes never have zero loss."""
    valid = targets[targets >= 0]
    counts = torch.bincount(valid, minlength=size).float().add(1)
    weights = counts.rsqrt()
    return weights / (weights * counts).sum() * counts.sum()


def loss(model, logits, chars, labels, weights, *, semicrf=0.0, lengths=None,
         teacher=None, kd_weight=0.0, kd_temperature=1.0):
    """Factorised span/BIO cross-entropy, plus the optional structured and KD terms.

    `weights` entries may be None (uniform classes). `semicrf` adds the globally
    normalised interval objective for fully labelled rows; `teacher` holds the
    teacher's (type, start, end) logits for distillation on labelled characters.
    """
    if model.head == "bio":
        return nn.functional.cross_entropy(logits.reshape(-1, len(LABELS)),
                                           labels.reshape(-1), weight=weights[0])
    start, end = model.boundary_logits(chars)
    targets = span_targets(labels)
    terms = [nn.functional.cross_entropy(pred.reshape(-1, len(TYPES)), target.reshape(-1),
                                        weight=weight)
             for pred, target, weight in zip((logits, start, end), targets, weights)]
    total = sum(terms) / len(terms)
    if semicrf:
        total = total + semicrf * semicrf_loss(logits, start, end, labels, lengths)
    if teacher is not None and kd_weight:
        total = total + kd_weight * distillation_loss((logits, start, end), teacher, labels,
                                                      kd_temperature)
    return total


def interval_potentials(type_logits, start_logits, end_logits):
    """Per-kind log-odds against the null class: the scores `interval_decode` sums."""
    return [x[..., 1:] - x[..., :1] for x in (type_logits, start_logits, end_logits)]


def semicrf_log_partition(body, start, end, lengths):
    """log sum over every segmentation of exp(score), batched.

    A segmentation is background characters (score 0) and non-overlapping typed
    spans scoring interior body + start + end, exactly the space `interval_decode`
    maximises over. O(L^2 K) per row.
    """
    batch, length, kinds = body.shape
    prefix = torch.cat((body.new_zeros(batch, 1, kinds), body.cumsum(1)), dim=1)
    alpha = [body.new_zeros(batch)]
    for stop in range(1, length + 1):
        previous = torch.stack(alpha, dim=1)                       # [B, stop]
        spans = (previous.unsqueeze(-1) + prefix[:, stop:stop + 1] - prefix[:, :stop]
                 + start[:, :stop] + end[:, stop - 1:stop])        # [B, stop, K]
        alpha.append(torch.logaddexp(alpha[-1], spans.reshape(batch, -1).logsumexp(-1)))
    alpha = torch.stack(alpha, dim=1)
    return alpha.gather(1, lengths.unsqueeze(1)).squeeze(1)


def semicrf_loss(type_logits, start_logits, end_logits, labels, lengths):
    """Per-character negative log-likelihood of the gold segmentation.

    Rows carrying unlabelled characters (partial rows) have no single gold
    segmentation and are left to the factorised terms.
    """
    positions = torch.arange(labels.shape[1], device=labels.device)
    inside = positions.unsqueeze(0) < lengths.unsqueeze(1)
    full = ~((labels < 0) & inside).any(1) & (lengths > 0)
    if not full.any():
        return type_logits.sum() * 0
    body, start, end = interval_potentials(type_logits[full], start_logits[full],
                                           end_logits[full])
    labels, lengths = labels[full], lengths[full]
    types, starts, ends = span_targets(labels)

    def gathered(scores, target):
        picked = scores.gather(2, (target.clamp_min(1) - 1).unsqueeze(-1)).squeeze(-1)
        return (picked * (target > 0)).sum(1)

    gold = gathered(body, types) + gathered(start, starts) + gathered(end, ends)
    partition = semicrf_log_partition(body, start, end, lengths)
    return ((partition - gold) / lengths.clamp_min(1)).mean()


def distillation_loss(student, teacher, labels, temperature):
    """Temperature-scaled KL to the teacher, on labelled characters only.

    Characters a row leaves unlabelled are excluded, so no teacher prediction ever
    stands in for a missing gold label.
    """
    mask = (labels >= 0).reshape(-1)
    if not mask.any():
        return student[0].sum() * 0
    total = 0
    for s, t in zip(student, teacher):
        s = s.reshape(-1, s.shape[-1])[mask] / temperature
        t = t.reshape(-1, t.shape[-1])[mask] / temperature
        total = total + nn.functional.kl_div(s.log_softmax(-1), t.log_softmax(-1),
                                             log_target=True, reduction="batchmean")
    return total * temperature ** 2 / len(student)


def record_confidence(type_logits, start_logits, end_logits, length):
    """exp(score(best segmentation) - log Z): the semi-CRF probability of the decode.

    A score for ranking and thresholds, not a calibrated probability unless the
    model was trained with the structured term and checked for calibration.
    """
    body, start, end = interval_potentials(type_logits[:length].unsqueeze(0),
                                           start_logits[:length].unsqueeze(0),
                                           end_logits[:length].unsqueeze(0))
    spans = interval_decode(type_logits[:length], start_logits[:length], end_logits[:length])
    best = 0.0
    for span in spans:
        k = KINDS.index(span["kind"])
        best += float(body[0, span["start"]:span["end"], k].sum()
                      + start[0, span["start"], k] + end[0, span["end"] - 1, k])
    partition = semicrf_log_partition(body, start, end,
                                      torch.tensor([length], device=body.device))
    return float(torch.exp(torch.tensor(best) - partition.cpu()[0]))


def interval_decode(type_logits, start_logits, end_logits):
    """Best non-overlapping complete spans, with an explicit all-background path.

    A candidate's score is interior type-vs-background log odds plus type-vs-null
    odds at its start and end. No vocabulary, delimiter, or position rule enters
    decoding. Adjacent spans are legal, including two of the same type.
    """
    arrays = [x.detach().cpu().numpy() if isinstance(x, torch.Tensor) else np.asarray(x)
              for x in (type_logits, start_logits, end_logits)]
    if any(a.ndim != 2 or a.shape != arrays[0].shape for a in arrays):
        raise ValueError("span logits must be aligned matrices")
    if not all(np.isfinite(a).all() for a in arrays):
        raise ValueError("non-finite span logits")
    body, start, end = [a[:, 1:] - a[:, :1] for a in arrays]
    length, kinds = body.shape
    prefix = np.concatenate((np.zeros((1, kinds)), body.cumsum(0)), axis=0)
    best, back = np.zeros(length + 1), [None] * (length + 1)
    for stop in range(1, length + 1):
        scores = (best[:stop, None] + prefix[stop] - prefix[:stop]
                  + start[:stop] + end[stop - 1])
        index = int(scores.argmax())
        begin, kind = divmod(index, kinds)
        if scores[begin, kind] > best[stop - 1]:
            best[stop], back[stop] = scores[begin, kind], (begin, kind)
        else:
            best[stop] = best[stop - 1]
    spans, stop = [], length
    while stop:
        if back[stop] is None:
            stop -= 1
        else:
            begin, kind = back[stop]
            spans.append({"kind": KINDS[kind], "start": begin, "end": stop})
            stop = begin
    return list(reversed(spans))


def decode_batch(model, logits, chars, items, decoder):
    lengths = [len(item[-1]["query"]) for item in items]
    if model.head == "bio":
        paths = tag_paths(logits.detach().cpu(), lengths, decoder, legality_matrix())
        return [predict_spans(item[-1]["query"], path) for item, path in zip(items, paths)]
    if decoder != "span":
        raise ValueError("span head requires span decoding")
    starts, ends = model.boundary_logits(chars)
    return [interval_decode(logits[i, :n], starts[i, :n], ends[i, :n])
            for i, n in enumerate(lengths)]