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 +6 -0
- solvi/core.py +100 -0
- solvi/extract_long.py +224 -0
- solvi/extract_model.py +105 -0
- solvi/extract_multi.py +155 -0
- solvi/heads.py +157 -0
- solvi/py.typed +0 -0
- solvi/rules.py +73 -0
- solvi/runtime.py +234 -0
- solvi/show.py +26 -0
- solvi/strategist.py +131 -0
- solvi/system.py +193 -0
- solvi-0.1.0.dist-info/METADATA +250 -0
- solvi-0.1.0.dist-info/RECORD +16 -0
- solvi-0.1.0.dist-info/WHEEL +4 -0
- solvi-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|