solvi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solvi/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """solvi — a decision-system builder: a catalog of functions and checks, questions with typed answers, a strategist that
2
+ assembles the flow, execution with computed_state, a hash chain and independent replay, and an answer head that trains in milliseconds."""
3
+ from .core import Answer, AnswerType, Catalog, Question, Quote
4
+ from .system import Response, System
5
+
6
+ __all__ = ["Answer", "AnswerType", "Catalog", "Question", "Quote", "Response", "System"]
solvi/core.py ADDED
@@ -0,0 +1,100 @@
1
+ """solvi — a catalog of functions and checks, questions with typed answers.
2
+
3
+ A part's contract comes from its signature: argument names are the facts it reads, the function name is the fact it sets.
4
+ Part kinds: extract (pull a value from text, with a quote), fn (computation), check (test → bool), rule (answer to a question)."""
5
+ from __future__ import annotations
6
+
7
+ import inspect
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Callable
10
+
11
+
12
+ @dataclass
13
+ class Quote:
14
+ """A value extracted from text, with its location (doc[start:end] is the supporting quote)."""
15
+ value: Any
16
+ start: int
17
+ end: int
18
+ source: str = "doc"
19
+ confidence: float = 1.0
20
+
21
+
22
+ @dataclass
23
+ class AnswerType:
24
+ kind: str # yes_no | choice
25
+ options: list
26
+
27
+ def normalize(self, v):
28
+ if self.kind == "yes_no" and isinstance(v, bool):
29
+ return "yes" if v else "no"
30
+ if v not in self.options:
31
+ raise ValueError(f"answer {v!r} is not one of {self.options}")
32
+ return v
33
+
34
+
35
+ class Answer:
36
+ @staticmethod
37
+ def yes_no() -> AnswerType:
38
+ return AnswerType("yes_no", ["yes", "no"])
39
+
40
+ @staticmethod
41
+ def choice(options) -> AnswerType:
42
+ return AnswerType("choice", list(options))
43
+
44
+
45
+ @dataclass
46
+ class Question:
47
+ name: str
48
+ text: str
49
+ answer: AnswerType
50
+ checkpoints: list = field(default_factory=list) # parts required in every flow for this question
51
+ uses: list | None = None # hint to the strategist: which facts matter (when there is no rule or fit)
52
+
53
+
54
+ @dataclass
55
+ class Part:
56
+ kind: str # extract | fn | check | rule
57
+ name: str # fact it sets (for rule: "answer:<question>")
58
+ inputs: list
59
+ func: Callable
60
+ doc: str = ""
61
+ hard: bool = False # check only: hard check
62
+ then: dict = field(default_factory=dict) # check only: {question: answer} when the check is false
63
+ question: str | None = None # rule only
64
+
65
+
66
+ class Catalog:
67
+ """Everything the system can do; the strategist decides which parts each question needs."""
68
+
69
+ def __init__(self):
70
+ self.parts: dict[str, Part] = {}
71
+ self.rules: dict[str, Part] = {}
72
+
73
+ def _add(self, kind, f, **kw):
74
+ sig = inspect.signature(f)
75
+ p = Part(kind=kind, name=f.__name__, inputs=list(sig.parameters), func=f, doc=(f.__doc__ or "").strip(), **kw)
76
+ if kind == "rule":
77
+ p.name = "answer:" + p.question
78
+ self.rules[p.question] = p
79
+ else:
80
+ if p.name in self.parts:
81
+ raise ValueError(f"part {p.name} is already in the catalog")
82
+ self.parts[p.name] = p
83
+ return f
84
+
85
+ def extract(self, f):
86
+ return self._add("extract", f)
87
+
88
+ def fn(self, f):
89
+ return self._add("fn", f)
90
+
91
+ def check(self, f=None, *, hard=False, then=None):
92
+ if f is None:
93
+ return lambda g: self._add("check", g, hard=hard, then=then or {})
94
+ return self._add("check", f)
95
+
96
+ def rule(self, question):
97
+ return lambda f: self._add("rule", f, question=question)
98
+
99
+ def producer(self, fact):
100
+ return self.parts.get(fact)
solvi/extract_long.py ADDED
@@ -0,0 +1,224 @@
1
+ """@extract for real-world, general-purpose documents: a field is defined by its DESCRIPTION, the document may be long
2
+ (windows), and the field may be absent ("no answer"). ModernBERT: input "field description [SEP] document window", two pointer
3
+ heads; "no answer" is position 0 (the special token). Prediction: the best span across all windows; an answer exists if its
4
+ score is above the field's threshold (tuned on held-out examples). Also works for fields unseen in training, from the
5
+ description alone ("new field")."""
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import random
10
+ import time
11
+
12
+ import numpy as np
13
+
14
+ from .core import Quote
15
+
16
+
17
+ class LongSpanExtractor:
18
+ def __init__(self, model_name="answerdotai/ModernBERT-large", max_len=1024, stride=128, max_span=96, device=None):
19
+ """stride: overlap between adjacent windows in tokens (as in transformers); max_span: maximum answer length in tokens."""
20
+ import torch
21
+ from transformers import AutoModel, AutoTokenizer
22
+ self.torch = torch
23
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
24
+ self.tok = AutoTokenizer.from_pretrained(model_name)
25
+ self.enc = AutoModel.from_pretrained(model_name).to(self.device)
26
+ self.head = torch.nn.Linear(self.enc.config.hidden_size, 2).to(self.device)
27
+ self.max_len, self.stride, self.max_span = max_len, stride, max_span
28
+ self.thr = {}
29
+ self.thr_default = 0.0 # threshold for fields without their own examples (new field by description)
30
+ self.model_name = model_name
31
+ self._cache = {}
32
+ self._tok_key, self._tok_val = None, None
33
+
34
+ def _windows(self, desc, text):
35
+ """Windows "[CLS] description [SEP] text chunk [SEP]" overlapping by stride; split manually (the ModernBERT tokenizer's
36
+ overflow returns only one extra window). → {input_ids, attention_mask, offset_mapping, ctx}."""
37
+ key = hash(text)
38
+ if self._tok_key != key:
39
+ t = self.tok(text, add_special_tokens=False, return_offsets_mapping=True)
40
+ self._tok_key, self._tok_val = key, (t["input_ids"], t["offset_mapping"])
41
+ ids, offs = self._tok_val
42
+ d = self.tok(desc, add_special_tokens=False)["input_ids"][:64]
43
+ cls, sep, pad = self.tok.cls_token_id, self.tok.sep_token_id, self.tok.pad_token_id
44
+ room = self.max_len - len(d) - 3
45
+ step = max(1, room - self.stride)
46
+ out = {"input_ids": [], "attention_mask": [], "offset_mapping": [], "ctx": []}
47
+ for a in range(0, max(1, len(ids)), step):
48
+ chunk = ids[a:a + room]
49
+ seq = [cls] + d + [sep] + chunk + [sep]
50
+ n = len(seq)
51
+ out["input_ids"].append(seq + [pad] * (self.max_len - n))
52
+ out["attention_mask"].append([1] * n + [0] * (self.max_len - n))
53
+ out["offset_mapping"].append([(0, 0)] * (len(d) + 2) + list(offs[a:a + room]) + [(0, 0)] * (self.max_len - n + 1))
54
+ out["ctx"].append(list(range(len(d) + 2, len(d) + 2 + len(chunk))))
55
+ if a + room >= len(ids):
56
+ break
57
+ return out
58
+
59
+ def _ctx(self, enc, i):
60
+ return enc["ctx"][i]
61
+
62
+ def make_examples(self, items, neg_per_item=3, seed=0):
63
+ """items: [(text, description, (start, end) | None[, neg])] → training windows: every window with the answer + up to
64
+ neg_per_item (or the item's own neg) windows without it."""
65
+ rng = random.Random(seed)
66
+ out = []
67
+ for it in items:
68
+ text, desc, span = it[:3]
69
+ npi = it[3] if len(it) > 3 else neg_per_item
70
+ enc = self._windows(desc, text)
71
+ pos, neg = [], []
72
+ for i in range(len(enc["input_ids"])):
73
+ offs = enc["offset_mapping"][i]
74
+ ctx = self._ctx(enc, i)
75
+ if not ctx:
76
+ continue
77
+ c0, c1 = offs[ctx[0]][0], offs[ctx[-1]][1]
78
+ if span is not None and c0 <= span[0] < c1: # answer starts in this window (a long answer is clipped at the window edge)
79
+ st = next((j for j in ctx if offs[j][1] > span[0]), ctx[0])
80
+ en = next((j for j in reversed(ctx) if offs[j][0] < span[1]), st)
81
+ pos.append((enc["input_ids"][i], enc["attention_mask"][i], st, min(max(st, en), st + self.max_span - 1)))
82
+ else:
83
+ neg.append((enc["input_ids"][i], enc["attention_mask"][i], 0, 0))
84
+ rng.shuffle(neg)
85
+ out += pos + neg[:npi if pos or span is None else max(1, npi // 3)]
86
+ return out
87
+
88
+ def fit(self, items, epochs=3, lr=3e-5, bs=8, seed=0, neg_per_item=3, log=print):
89
+ torch = self.torch
90
+ random.seed(seed)
91
+ torch.manual_seed(seed)
92
+ ex = self.make_examples(items, neg_per_item, seed)
93
+ opt = torch.optim.AdamW([{"params": list(self.enc.parameters()), "lr": lr}, {"params": list(self.head.parameters()), "lr": lr * 10}],
94
+ weight_decay=0.01)
95
+ total = epochs * math.ceil(len(ex) / bs)
96
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / max(1, total // 10)) * max(0.0, 1 - s / total))
97
+ params = list(self.enc.parameters()) + list(self.head.parameters())
98
+ self.enc.train()
99
+ t0 = time.time()
100
+ for ep in range(epochs):
101
+ random.shuffle(ex)
102
+ # batches of windows of similar length (sorted within chunks of 64 batches), in random order
103
+ batches = []
104
+ for k in range(0, len(ex), 64 * bs):
105
+ part = sorted(ex[k:k + 64 * bs], key=lambda c: sum(c[1]))
106
+ batches += [part[b:b + bs] for b in range(0, len(part), bs)]
107
+ random.shuffle(batches)
108
+ tot = 0.0
109
+ for ch in batches:
110
+ L = max(sum(c[1]) for c in ch) # trim the batch to its longest window
111
+ ids = torch.tensor([c[0][:L] for c in ch], device=self.device)
112
+ att = torch.tensor([c[1][:L] for c in ch], device=self.device)
113
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
114
+ h = self.enc(input_ids=ids, attention_mask=att).last_hidden_state
115
+ lg = self.head(h).float()
116
+ m = att.bool()
117
+ ls, le = lg[..., 0].masked_fill(~m, -1e4), lg[..., 1].masked_fill(~m, -1e4)
118
+ loss = (torch.nn.functional.cross_entropy(ls, torch.tensor([c[2] for c in ch], device=self.device)) +
119
+ torch.nn.functional.cross_entropy(le, torch.tensor([c[3] for c in ch], device=self.device))) / 2
120
+ opt.zero_grad(set_to_none=True)
121
+ loss.backward()
122
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
123
+ opt.step()
124
+ sched.step()
125
+ tot += loss.item()
126
+ log(f"[extractL] epoch {ep + 1}/{epochs}: loss {tot / max(1, math.ceil(len(ex) / bs)):.4f}, windows {len(ex)}, {time.time() - t0:.0f} s")
127
+ self.enc.eval()
128
+ self._cache = {}
129
+ return self
130
+
131
+ def predict(self, text, desc, bs=8):
132
+ """→ (start, end, span score, "no answer" score) — the best span across all windows."""
133
+ key = (hash(text), desc)
134
+ if key in self._cache:
135
+ return self._cache[key]
136
+ torch = self.torch
137
+ max_span = self.max_span
138
+ enc = self._windows(desc, text)
139
+ best, null = (0, 0, -1.0), 0.0
140
+ with torch.no_grad():
141
+ n = len(enc["input_ids"])
142
+ for b in range(0, n, bs):
143
+ L = max(sum(a) for a in enc["attention_mask"][b:b + bs])
144
+ ids = torch.tensor([x[:L] for x in enc["input_ids"][b:b + bs]], device=self.device)
145
+ att = torch.tensor([x[:L] for x in enc["attention_mask"][b:b + bs]], device=self.device)
146
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
147
+ lg = self.head(self.enc(input_ids=ids, attention_mask=att).last_hidden_state).float()
148
+ for r in range(lg.shape[0]):
149
+ i = b + r
150
+ ctx = self._ctx(enc, i)
151
+ if not ctx:
152
+ continue
153
+ offs = enc["offset_mapping"][i]
154
+ ps = torch.softmax(lg[r, :, 0].masked_fill(~att[r].bool(), -1e4), -1).cpu().numpy()
155
+ pe = torch.softmax(lg[r, :, 1].masked_fill(~att[r].bool(), -1e4), -1).cpu().numpy()
156
+ null = max(null, float(ps[0] * pe[0]))
157
+ c0, c1 = ctx[0], ctx[-1] + 1
158
+ sc = np.triu(np.outer(ps[c0:c1], pe[c0:c1])) - np.triu(np.outer(ps[c0:c1], pe[c0:c1]), max_span)
159
+ s, e = np.unravel_index(int(sc.argmax()), sc.shape)
160
+ if sc[s, e] > best[2]:
161
+ best = (offs[c0 + s][0], offs[c0 + e][1], float(sc[s, e]))
162
+ st, en = int(best[0]), int(best[1])
163
+ while st < en and text[st].isspace(): # BPE offsets include the leading space
164
+ st += 1
165
+ out = (st, en, best[2], null)
166
+ self._cache[key] = out
167
+ return out
168
+
169
+ def tune_threshold(self, name, items, grid=(0.0003, 0.001, 0.003, 0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8)):
170
+ """Per-field "answer present" threshold from held-out examples: maximizes present/absent decision accuracy."""
171
+ best = None
172
+ for t in grid:
173
+ acc = np.mean([(self.predict(x, d)[2] >= t) == (sp is not None) for x, d, sp in items])
174
+ if best is None or acc > best[0]:
175
+ best = (acc, t)
176
+ self.thr[name] = best[1]
177
+ return best
178
+
179
+ def tune_default_threshold(self, items, grid=(0.0003, 0.001, 0.003, 0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5)):
180
+ """One threshold from held-out examples of many fields — used for fields that have no examples of their own."""
181
+ best = None
182
+ for t in grid:
183
+ acc = np.mean([(self.predict(x, d)[2] >= t) == (sp is not None) for x, d, sp in items])
184
+ if best is None or acc > best[0]:
185
+ best = (acc, t)
186
+ self.thr_default = best[1]
187
+ return best
188
+
189
+ def save(self, path):
190
+ """Encoder weights (bf16 safetensors), tokenizer, span head and settings into a directory."""
191
+ import json
192
+ from pathlib import Path
193
+ Path(path).mkdir(parents=True, exist_ok=True)
194
+ self.enc.to(self.torch.bfloat16).save_pretrained(path)
195
+ self.enc.to(self.torch.float32)
196
+ self.tok.save_pretrained(path)
197
+ self.torch.save(self.head.state_dict(), f"{path}/span_head.pt")
198
+ json.dump({"max_len": self.max_len, "stride": self.stride, "max_span": self.max_span, "thr_default": self.thr_default,
199
+ "thr": self.thr, "base_model": self.model_name}, open(f"{path}/solvi_extract.json", "w"), indent=1)
200
+
201
+ @classmethod
202
+ def load(cls, path, device=None):
203
+ """From a directory written by save(), or a Hugging Face model id (downloaded once and cached)."""
204
+ import json
205
+ import os
206
+ if not os.path.isdir(path):
207
+ from huggingface_hub import snapshot_download
208
+ path = snapshot_download(path)
209
+ cfg = json.load(open(f"{path}/solvi_extract.json"))
210
+ ex = cls(path, max_len=cfg["max_len"], stride=cfg["stride"], max_span=cfg["max_span"], device=device)
211
+ ex.enc.to(ex.torch.float32)
212
+ ex.head.load_state_dict(ex.torch.load(f"{path}/span_head.pt", map_location=ex.device))
213
+ ex.thr_default, ex.thr = cfg["thr_default"], cfg.get("thr", {})
214
+ return ex
215
+
216
+ def field(self, name, desc):
217
+ def f(doc):
218
+ s, e, sc, _ = self.predict(doc, desc)
219
+ if sc < self.thr.get(name, self.thr_default):
220
+ return Quote("", 0, 0, confidence=1 - sc)
221
+ return Quote(doc[s:e], s, e, confidence=sc)
222
+ f.__name__ = name
223
+ f.__doc__ = desc
224
+ return f
solvi/extract_model.py ADDED
@@ -0,0 +1,105 @@
1
+ """Model-backed @extract: ModernBERT + two pointer heads (span start and end) that "cut a field's value out of the text given
2
+ its description" (extractive QA). The answer is always a span of the text itself, so it comes with a quote and offsets by
3
+ construction. The field description is the @extract function's docstring (a field can be added without retraining if its
4
+ description resembles the trained ones)."""
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ import random
9
+ import time
10
+
11
+ import numpy as np
12
+
13
+
14
+ class SpanExtractor:
15
+ def __init__(self, model_name="answerdotai/ModernBERT-large", max_len=1024, device=None):
16
+ import torch
17
+ from transformers import AutoModel, AutoTokenizer
18
+ self.torch = torch
19
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
20
+ self.tok = AutoTokenizer.from_pretrained(model_name)
21
+ self.enc = AutoModel.from_pretrained(model_name).to(self.device)
22
+ self.head = torch.nn.Linear(self.enc.config.hidden_size, 2).to(self.device)
23
+ self.max_len = max_len
24
+
25
+ def _batch(self, items):
26
+ """items: [(text, description, (start, end) | None)] → tensors and target positions in tokens."""
27
+ enc = self.tok([d for _, d, _ in items], [t for t, _, _ in items], truncation="only_second", max_length=self.max_len,
28
+ padding=True, return_offsets_mapping=True, return_tensors="pt")
29
+ starts, ends, ctx_masks = [], [], []
30
+ for i, (_, _, span) in enumerate(items):
31
+ seq = enc.sequence_ids(i)
32
+ offs = enc["offset_mapping"][i].tolist()
33
+ ctx = [j for j, s in enumerate(seq) if s == 1]
34
+ m = [s == 1 for s in seq]
35
+ ctx_masks.append(m)
36
+ if span is None:
37
+ starts.append(0)
38
+ ends.append(0)
39
+ continue
40
+ s_c, e_c = span
41
+ st = next((j for j in ctx if offs[j][0] <= s_c < offs[j][1] or offs[j][0] >= s_c), ctx[0])
42
+ en = next((j for j in reversed(ctx) if offs[j][0] < e_c), st)
43
+ starts.append(st)
44
+ ends.append(max(en, st))
45
+ return enc, starts, ends, ctx_masks
46
+
47
+ def _logits(self, enc):
48
+ out = self.enc(input_ids=enc["input_ids"].to(self.device), attention_mask=enc["attention_mask"].to(self.device)).last_hidden_state
49
+ lg = self.head(out)
50
+ return lg[..., 0], lg[..., 1]
51
+
52
+ def fit(self, examples, epochs=4, lr=3e-5, bs=8, seed=0, log=print):
53
+ torch = self.torch
54
+ random.seed(seed)
55
+ torch.manual_seed(seed)
56
+ ex = [e for e in examples if e[2] is not None]
57
+ params = list(self.enc.parameters()) + list(self.head.parameters())
58
+ opt = torch.optim.AdamW([{"params": list(self.enc.parameters()), "lr": lr}, {"params": list(self.head.parameters()), "lr": lr * 10}],
59
+ weight_decay=0.01)
60
+ total = epochs * math.ceil(len(ex) / bs)
61
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / max(1, total // 10)) * max(0.0, 1 - s / total))
62
+ self.enc.train()
63
+ t0 = time.time()
64
+ for ep in range(epochs):
65
+ random.shuffle(ex)
66
+ tot = 0.0
67
+ for b in range(0, len(ex), bs):
68
+ enc, st, en, m = self._batch(ex[b:b + bs])
69
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
70
+ ls, le = self._logits(enc)
71
+ mask = torch.tensor(m, device=self.device)
72
+ ls = ls.float().masked_fill(~mask, -1e4)
73
+ le = le.float().masked_fill(~mask, -1e4)
74
+ loss = (torch.nn.functional.cross_entropy(ls, torch.tensor(st, device=self.device)) +
75
+ torch.nn.functional.cross_entropy(le, torch.tensor(en, device=self.device))) / 2
76
+ opt.zero_grad(set_to_none=True)
77
+ loss.backward()
78
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
79
+ opt.step()
80
+ sched.step()
81
+ tot += loss.item()
82
+ log(f"[extract] epoch {ep + 1}/{epochs}: loss {tot / max(1, math.ceil(len(ex) / bs)):.4f}, {time.time() - t0:.0f} s")
83
+ self.enc.eval()
84
+ return self
85
+
86
+ def predict(self, items, bs=16, max_span=64):
87
+ """items: [(text, description)] → [(start, end, confidence)] in text characters."""
88
+ torch = self.torch
89
+ out = []
90
+ with torch.no_grad():
91
+ for b in range(0, len(items), bs):
92
+ ch = [(t, d, None) for t, d in items[b:b + bs]]
93
+ enc, _, _, m = self._batch(ch)
94
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
95
+ ls, le = self._logits(enc)
96
+ mask = torch.tensor(m, device=self.device)
97
+ ps = torch.softmax(ls.float().masked_fill(~mask, -1e4), -1).cpu().numpy()
98
+ pe = torch.softmax(le.float().masked_fill(~mask, -1e4), -1).cpu().numpy()
99
+ for i in range(len(ch)):
100
+ offs = enc["offset_mapping"][i].tolist()
101
+ L = len(offs)
102
+ sc = np.triu(np.outer(ps[i, :L], pe[i, :L])) - np.triu(np.outer(ps[i, :L], pe[i, :L]), max_span)
103
+ s, e = np.unravel_index(int(sc.argmax()), sc.shape)
104
+ out.append((int(offs[s][0]), int(offs[e][1]), float(sc[s, e])))
105
+ return out
solvi/extract_multi.py ADDED
@@ -0,0 +1,155 @@
1
+ """Single-pass @extract: ModernBERT reads the document once, with a pair of pointer heads (start / end) per field.
2
+ For solvi: extractor.field(name) returns a function doc → Quote; all fields of a document come from one pass (cached by text)."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import random
8
+ import time
9
+
10
+ import numpy as np
11
+
12
+ from .core import Quote
13
+
14
+
15
+ class MultiSpanExtractor:
16
+ def __init__(self, fields, model_name="answerdotai/ModernBERT-large", max_len=1024, device=None):
17
+ import torch
18
+ from transformers import AutoModel, AutoTokenizer
19
+ self.torch = torch
20
+ self.fields = list(fields)
21
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
22
+ self.tok = AutoTokenizer.from_pretrained(model_name)
23
+ self.enc = AutoModel.from_pretrained(model_name).to(self.device)
24
+ self.head = torch.nn.Linear(self.enc.config.hidden_size, 2 * len(self.fields)).to(self.device)
25
+ self.max_len = max_len
26
+ self._cache = {}
27
+ self.temp = {f: 1.0 for f in self.fields}
28
+
29
+ def _enc(self, texts):
30
+ return self.tok(texts, truncation=True, max_length=self.max_len, padding=True, return_offsets_mapping=True, return_tensors="pt")
31
+
32
+ def _logits(self, enc):
33
+ h = self.enc(input_ids=enc["input_ids"].to(self.device), attention_mask=enc["attention_mask"].to(self.device)).last_hidden_state
34
+ return self.head(h) # [B, L, 2F]
35
+
36
+ @staticmethod
37
+ def _tok_span(offs, span):
38
+ s_c, e_c = span
39
+ idx = [j for j, (a, b) in enumerate(offs) if b > a]
40
+ st = next((j for j in idx if offs[j][1] > s_c), idx[0])
41
+ en = next((j for j in reversed(idx) if offs[j][0] < e_c), st)
42
+ return st, max(st, en)
43
+
44
+ def fit(self, docs, spans, epochs=4, lr=3e-5, bs=8, seed=0, log=print):
45
+ """docs: [text]; spans: [{field: (start, end) | None}]."""
46
+ torch = self.torch
47
+ random.seed(seed)
48
+ torch.manual_seed(seed)
49
+ idx = list(range(len(docs)))
50
+ opt = torch.optim.AdamW([{"params": list(self.enc.parameters()), "lr": lr}, {"params": list(self.head.parameters()), "lr": lr * 10}],
51
+ weight_decay=0.01)
52
+ total = epochs * math.ceil(len(idx) / bs)
53
+ sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / max(1, total // 10)) * max(0.0, 1 - s / total))
54
+ params = list(self.enc.parameters()) + list(self.head.parameters())
55
+ self.enc.train()
56
+ t0 = time.time()
57
+ F = len(self.fields)
58
+ for ep in range(epochs):
59
+ random.shuffle(idx)
60
+ tot = 0.0
61
+ for b in range(0, len(idx), bs):
62
+ ch = idx[b:b + bs]
63
+ enc = self._enc([docs[i] for i in ch])
64
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
65
+ lg = self._logits(enc).float()
66
+ mask = enc["attention_mask"].to(self.device).bool()
67
+ loss, n = 0.0, 0
68
+ for fi, f in enumerate(self.fields):
69
+ tgt_s, tgt_e, rows = [], [], []
70
+ for r, i in enumerate(ch):
71
+ sp = spans[i].get(f)
72
+ if sp is None:
73
+ continue
74
+ s, e = self._tok_span(enc["offset_mapping"][r].tolist(), sp)
75
+ tgt_s.append(s)
76
+ tgt_e.append(e)
77
+ rows.append(r)
78
+ if not rows:
79
+ continue
80
+ ls = lg[rows, :, 2 * fi].masked_fill(~mask[rows], -1e4)
81
+ le = lg[rows, :, 2 * fi + 1].masked_fill(~mask[rows], -1e4)
82
+ loss = loss + torch.nn.functional.cross_entropy(ls, torch.tensor(tgt_s, device=self.device)) + \
83
+ torch.nn.functional.cross_entropy(le, torch.tensor(tgt_e, device=self.device))
84
+ n += 2
85
+ if n == 0:
86
+ continue
87
+ loss = loss / n
88
+ opt.zero_grad(set_to_none=True)
89
+ loss.backward()
90
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
91
+ opt.step()
92
+ sched.step()
93
+ tot += loss.item()
94
+ log(f"[extract1] epoch {ep + 1}/{epochs}: loss {tot / max(1, math.ceil(len(idx) / bs)):.4f}, {time.time() - t0:.0f} s")
95
+ self.enc.eval()
96
+ self._cache = {}
97
+ return self
98
+
99
+ def predict_doc(self, text, max_span=64):
100
+ """→ {field: (start, end, confidence)} in a single pass."""
101
+ key = hashlib.sha1(text.encode()).hexdigest()
102
+ if key in self._cache:
103
+ return self._cache[key]
104
+ torch = self.torch
105
+ with torch.no_grad():
106
+ enc = self._enc([text])
107
+ with torch.autocast(self.device, dtype=torch.bfloat16, enabled=self.device == "cuda"):
108
+ lg = self._logits(enc).float()[0]
109
+ offs = enc["offset_mapping"][0].tolist()
110
+ valid = np.array([b > a for a, b in offs])
111
+ out = {}
112
+ for fi, f in enumerate(self.fields):
113
+ zs = lg[:, 2 * fi].cpu().numpy()
114
+ ze = lg[:, 2 * fi + 1].cpu().numpy()
115
+ zs[~valid], ze[~valid] = -1e4, -1e4
116
+ T = self.temp[f]
117
+ ps = np.exp((zs - zs.max()) / T)
118
+ ps /= ps.sum()
119
+ pe = np.exp((ze - ze.max()) / T)
120
+ pe /= pe.sum()
121
+ L = len(offs)
122
+ sc = np.triu(np.outer(ps, pe)) - np.triu(np.outer(ps, pe), max_span)
123
+ s, e = np.unravel_index(int(sc.argmax()), sc.shape)
124
+ out[f] = (int(offs[s][0]), int(offs[e][1]), float(sc[s, e]))
125
+ if len(self._cache) > 5000:
126
+ self._cache = {}
127
+ self._cache[key] = out
128
+ return out
129
+
130
+ def field(self, name):
131
+ def f(doc):
132
+ s, e, c = self.predict_doc(doc)[name]
133
+ return Quote(doc[s:e], s, e, confidence=c)
134
+ f.__name__ = name
135
+ return f
136
+
137
+ def fit_temperature(self, docs, gold_ok, grid=(0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0)):
138
+ """Per-field temperature fitted on held-out documents: minimizes log loss of confidence vs. whether the span matched gold.
139
+ gold_ok(field, doc_index, (start, end)) → bool."""
140
+ for f in self.fields:
141
+ best = None
142
+ for T in grid:
143
+ self.temp[f] = T
144
+ self._cache = {}
145
+ nll = 0.0
146
+ for i, d in enumerate(docs):
147
+ s, e, c = self.predict_doc(d)[f]
148
+ y = gold_ok(f, i, (s, e))
149
+ c = min(max(c, 1e-6), 1 - 1e-6)
150
+ nll -= math.log(c if y else 1 - c)
151
+ if best is None or nll < best[0]:
152
+ best = (nll, T)
153
+ self.temp[f] = best[1]
154
+ self._cache = {}
155
+ return dict(self.temp)