Download app/fp/parser3_model.py from Enrell/lain-parser: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/Enrell/lain-parser/resolve/main/app/fp/parser3_model.py
- Command line
-
hf download hf://Enrell/lain-parser/app/fp/parser3_model.py
-
curl -L -o parser3_model.py https://huggingface.co/Enrell/lain-parser/resolve/main/app/fp/parser3_model.py
21.7 kB
| """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)] | |