tensorcode 0.1.0a1__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.
- tensorcode/__init__.py +84 -0
- tensorcode/actions.py +137 -0
- tensorcode/answer_type.py +222 -0
- tensorcode/awareness.py +344 -0
- tensorcode/backends/__init__.py +0 -0
- tensorcode/backends/builtin.py +167 -0
- tensorcode/backends/hf_local.py +89 -0
- tensorcode/backends/linear.py +133 -0
- tensorcode/backends/neural.py +361 -0
- tensorcode/causal.py +262 -0
- tensorcode/change.py +566 -0
- tensorcode/chunking.py +195 -0
- tensorcode/cognition.py +311 -0
- tensorcode/context.py +97 -0
- tensorcode/control.py +291 -0
- tensorcode/cues.py +192 -0
- tensorcode/expectation.py +270 -0
- tensorcode/frames.py +232 -0
- tensorcode/language/__init__.py +36 -0
- tensorcode/language/chart.py +558 -0
- tensorcode/language/discourse.py +132 -0
- tensorcode/language/domains/__init__.py +0 -0
- tensorcode/language/domains/desktop.py +552 -0
- tensorcode/language/english.py +459 -0
- tensorcode/language/features.py +112 -0
- tensorcode/language/generate.py +574 -0
- tensorcode/language/grammar.py +893 -0
- tensorcode/language/semantics.py +349 -0
- tensorcode/learning/__init__.py +30 -0
- tensorcode/learning/certificate.py +148 -0
- tensorcode/learning/induce.py +304 -0
- tensorcode/learning/library.py +217 -0
- tensorcode/learning/literals.py +126 -0
- tensorcode/learning/verify.py +253 -0
- tensorcode/memory.py +303 -0
- tensorcode/metacognition.py +351 -0
- tensorcode/ops.py +207 -0
- tensorcode/outcomes.py +99 -0
- tensorcode/permanence.py +376 -0
- tensorcode/priming.py +191 -0
- tensorcode/py.typed +0 -0
- tensorcode/quantity.py +311 -0
- tensorcode/records.py +728 -0
- tensorcode/relation.py +771 -0
- tensorcode/runtime.py +471 -0
- tensorcode/semantics_bridge.py +308 -0
- tensorcode/social.py +380 -0
- tensorcode/temporal.py +189 -0
- tensorcode/wants.py +185 -0
- tensorcode-0.1.0a1.dist-info/METADATA +196 -0
- tensorcode-0.1.0a1.dist-info/RECORD +53 -0
- tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
- tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""A small learned text classifier: TF-IDF features + multinomial logistic regression.
|
|
2
|
+
|
|
3
|
+
Requires the ``learned`` extra (scikit-learn, numpy). Abstention is governed by a
|
|
4
|
+
threshold chosen on held-out validation data to meet a target selective accuracy,
|
|
5
|
+
after temperature scaling on the same split. The fit report records what the
|
|
6
|
+
threshold is based on; nothing here is claimed beyond that split.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
18
|
+
from sklearn.linear_model import LogisticRegression
|
|
19
|
+
from sklearn.pipeline import FeatureUnion
|
|
20
|
+
|
|
21
|
+
from ..outcomes import Score, Unknown
|
|
22
|
+
from ..runtime import Output, Profile, Request, Traits
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class FitReport:
|
|
27
|
+
train_size: int
|
|
28
|
+
validation_size: int
|
|
29
|
+
temperature: float
|
|
30
|
+
threshold: float
|
|
31
|
+
target_accuracy: float
|
|
32
|
+
validation_coverage: float
|
|
33
|
+
validation_selective_accuracy: float
|
|
34
|
+
validation_accuracy: float
|
|
35
|
+
fit_seconds: float
|
|
36
|
+
basis: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _softmax(z: np.ndarray) -> np.ndarray:
|
|
40
|
+
z = z - z.max(axis=1, keepdims=True)
|
|
41
|
+
e = np.exp(z)
|
|
42
|
+
return e / e.sum(axis=1, keepdims=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LinearTextClassifier:
|
|
47
|
+
labels: type[enum.Enum]
|
|
48
|
+
features: FeatureUnion
|
|
49
|
+
model: LogisticRegression
|
|
50
|
+
temperature: float
|
|
51
|
+
threshold: float
|
|
52
|
+
basis: str
|
|
53
|
+
name: str = "tfidf-logreg"
|
|
54
|
+
version: str = "1"
|
|
55
|
+
op: str = "classify"
|
|
56
|
+
traits: Traits = Traits(locality="in_process", egress=False, deterministic=True, requires=frozenset({"sklearn"}))
|
|
57
|
+
profile: Profile = field(default_factory=lambda: Profile(source="declared: in-process, no metered spend; latency unmeasured", usd_per_call=0.0))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def fit(
|
|
61
|
+
cls,
|
|
62
|
+
texts: Sequence[str],
|
|
63
|
+
labels: Sequence[enum.Enum],
|
|
64
|
+
*,
|
|
65
|
+
label_type: type[enum.Enum],
|
|
66
|
+
validation: tuple[Sequence[str], Sequence[enum.Enum]],
|
|
67
|
+
target_accuracy: float,
|
|
68
|
+
basis: str,
|
|
69
|
+
seed: int = 0,
|
|
70
|
+
) -> tuple[LinearTextClassifier, FitReport]:
|
|
71
|
+
t0 = time.perf_counter()
|
|
72
|
+
features = FeatureUnion(
|
|
73
|
+
[
|
|
74
|
+
("word", TfidfVectorizer(ngram_range=(1, 2), min_df=1, sublinear_tf=True)),
|
|
75
|
+
("char", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5), min_df=2, sublinear_tf=True)),
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
x = features.fit_transform(texts)
|
|
79
|
+
y = [label.value for label in labels]
|
|
80
|
+
model = LogisticRegression(C=20.0, max_iter=2000, random_state=seed)
|
|
81
|
+
model.fit(x, y)
|
|
82
|
+
|
|
83
|
+
val_texts, val_labels = validation
|
|
84
|
+
logits = model.decision_function(features.transform(val_texts))
|
|
85
|
+
val_y = np.array([model.classes_.tolist().index(label.value) for label in val_labels])
|
|
86
|
+
|
|
87
|
+
# temperature scaling: minimize validation NLL over a log grid
|
|
88
|
+
grid = np.exp(np.linspace(np.log(0.05), np.log(5.0), 200))
|
|
89
|
+
nll = [-np.log(_softmax(logits / t)[np.arange(len(val_y)), val_y] + 1e-12).mean() for t in grid]
|
|
90
|
+
temperature = float(grid[int(np.argmin(nll))])
|
|
91
|
+
probs = _softmax(logits / temperature)
|
|
92
|
+
conf, pred = probs.max(axis=1), probs.argmax(axis=1)
|
|
93
|
+
correct = pred == val_y
|
|
94
|
+
|
|
95
|
+
# lowest threshold whose selective accuracy on validation meets the target
|
|
96
|
+
order = np.argsort(-conf)
|
|
97
|
+
cum_acc = np.cumsum(correct[order]) / np.arange(1, len(order) + 1)
|
|
98
|
+
ok = np.nonzero(cum_acc >= target_accuracy)[0]
|
|
99
|
+
k = int(ok.max()) + 1 if len(ok) else 0
|
|
100
|
+
threshold = float(conf[order][k - 1]) if k else 1.0
|
|
101
|
+
impl = cls(label_type, features, model, temperature, threshold, basis)
|
|
102
|
+
report = FitReport(
|
|
103
|
+
train_size=len(texts),
|
|
104
|
+
validation_size=len(val_texts),
|
|
105
|
+
temperature=temperature,
|
|
106
|
+
threshold=threshold,
|
|
107
|
+
target_accuracy=target_accuracy,
|
|
108
|
+
validation_coverage=k / len(order),
|
|
109
|
+
validation_selective_accuracy=float(cum_acc[k - 1]) if k else float("nan"),
|
|
110
|
+
validation_accuracy=float(correct.mean()),
|
|
111
|
+
fit_seconds=time.perf_counter() - t0,
|
|
112
|
+
basis=basis,
|
|
113
|
+
)
|
|
114
|
+
return impl, report
|
|
115
|
+
|
|
116
|
+
def accepts(self, request: Request) -> bool:
|
|
117
|
+
return request.op == "classify" and request.target is self.labels and isinstance(request.subject, str)
|
|
118
|
+
|
|
119
|
+
def probabilities(self, texts: Sequence[str]) -> np.ndarray:
|
|
120
|
+
return _softmax(self.model.decision_function(self.features.transform(texts)) / self.temperature)
|
|
121
|
+
|
|
122
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
123
|
+
probs = self.probabilities([r.subject for r in requests])
|
|
124
|
+
classes = self.model.classes_
|
|
125
|
+
outs = []
|
|
126
|
+
for row in probs:
|
|
127
|
+
top = np.argsort(-row)[:3]
|
|
128
|
+
cands = tuple((self.labels(classes[i]), Score(float(row[i]), "probability", self.basis)) for i in top)
|
|
129
|
+
if row[top[0]] < self.threshold:
|
|
130
|
+
outs.append(Output(Unknown("below_threshold", f"p={row[top[0]]:.3f} < {self.threshold:.3f}", cands)))
|
|
131
|
+
else:
|
|
132
|
+
outs.append(Output(cands[0][0], cands[0][1]))
|
|
133
|
+
return outs
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Learned implementations: a trained request parser and a trained extractive answerer.
|
|
2
|
+
|
|
3
|
+
Requires the ``learned-neural`` extra (torch, transformers). Both are registered the same
|
|
4
|
+
way every other implementation is, so the runtime routes to them by declared traits and
|
|
5
|
+
the trace records which one answered.
|
|
6
|
+
|
|
7
|
+
The parser turns an utterance into ``ParsedRequest(act, slots)``; the answerer turns a
|
|
8
|
+
question plus passages into ``Answer(text, evidence)``. Each loads an artifact directory
|
|
9
|
+
that carries its own label space and its calibrated abstention threshold, so the code
|
|
10
|
+
here cannot drift from what was trained. Confidence is the calibrated probability that
|
|
11
|
+
the output is correct, measured on a held-out split named in the artifact; below the
|
|
12
|
+
threshold the implementation abstains with ``Unknown`` rather than guessing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Mapping, Sequence
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
from torch import nn
|
|
24
|
+
from transformers import AutoModel, AutoTokenizer
|
|
25
|
+
|
|
26
|
+
from ..outcomes import Score, Unknown
|
|
27
|
+
from ..runtime import Output, Profile, Request, Traits
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------- what they answer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ParsedRequest:
|
|
35
|
+
"""What an utterance asks for: an act and its slots, as the assistant's procedures expect."""
|
|
36
|
+
|
|
37
|
+
act: str
|
|
38
|
+
slots: Mapping[str, Any] = field(default_factory=dict)
|
|
39
|
+
confidence: float | None = None
|
|
40
|
+
speech_act: str | None = None # command / question / self_disclosure / world_statement / other
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"{self.act}({', '.join(f'{k}={v!r}' for k, v in sorted(self.slots.items()))})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class QuestionOverPassages:
|
|
48
|
+
"""A question and the passages that may contain its answer."""
|
|
49
|
+
|
|
50
|
+
question: str
|
|
51
|
+
passages: tuple[tuple[str, str], ...] # (title, sentence)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Answer:
|
|
56
|
+
text: str
|
|
57
|
+
evidence: tuple[tuple[str, str], ...] = () # the passages the span came from
|
|
58
|
+
confidence: float | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------- the models
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RequestParserModel(nn.Module):
|
|
65
|
+
"""One encoder, four kinds of head: the act, a BIO tagger for span slots, closed choices, flags."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, encoder_id: str, n_acts: int, n_tags: int, closed_sizes: Sequence[int], n_flags: int) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.encoder = AutoModel.from_pretrained(encoder_id)
|
|
70
|
+
h = self.encoder.config.hidden_size
|
|
71
|
+
self.act = nn.Linear(h, n_acts)
|
|
72
|
+
self.tags = nn.Linear(h, n_tags)
|
|
73
|
+
self.closed = nn.ModuleList([nn.Linear(h, n) for n in closed_sizes])
|
|
74
|
+
self.flags = nn.Linear(h, n_flags)
|
|
75
|
+
|
|
76
|
+
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> dict[str, torch.Tensor]:
|
|
77
|
+
hidden = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
|
|
78
|
+
pooled = hidden[:, 0]
|
|
79
|
+
return {
|
|
80
|
+
"act": self.act(pooled),
|
|
81
|
+
"tags": self.tags(hidden),
|
|
82
|
+
"closed": [head(pooled) for head in self.closed],
|
|
83
|
+
"flags": self.flags(pooled),
|
|
84
|
+
"pooled": pooled,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SpanAnswererModel(nn.Module):
|
|
89
|
+
"""Start/end over the passage, plus one head for "the passage does not answer this"."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, encoder_id: str) -> None:
|
|
92
|
+
super().__init__()
|
|
93
|
+
self.encoder = AutoModel.from_pretrained(encoder_id)
|
|
94
|
+
h = self.encoder.config.hidden_size
|
|
95
|
+
self.span = nn.Linear(h, 2)
|
|
96
|
+
self.answerable = nn.Linear(h, 2)
|
|
97
|
+
|
|
98
|
+
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor, token_type_ids: torch.Tensor | None = None) -> dict[str, torch.Tensor]:
|
|
99
|
+
kw = {"token_type_ids": token_type_ids} if token_type_ids is not None else {}
|
|
100
|
+
hidden = self.encoder(input_ids=input_ids, attention_mask=attention_mask, **kw).last_hidden_state
|
|
101
|
+
start, end = self.span(hidden).split(1, dim=-1)
|
|
102
|
+
return {"start": start.squeeze(-1), "end": end.squeeze(-1), "answerable": self.answerable(hidden[:, 0])}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ inference
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _device(prefer: str | None = None) -> str:
|
|
109
|
+
if prefer:
|
|
110
|
+
return prefer
|
|
111
|
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class NeuralRequestParser:
|
|
116
|
+
"""A trained utterance -> (act, slots) parser, as a ``parse`` implementation."""
|
|
117
|
+
|
|
118
|
+
artifact: Path
|
|
119
|
+
device: str | None = None
|
|
120
|
+
batch_size: int = 64
|
|
121
|
+
threshold: float | None = None # overrides the artifact's calibrated threshold
|
|
122
|
+
name: str = "neural-request-parser"
|
|
123
|
+
op: str = "parse"
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
self.artifact = Path(self.artifact)
|
|
127
|
+
self.config = json.loads((self.artifact / "config.json").read_text())
|
|
128
|
+
self.device = _device(self.device)
|
|
129
|
+
self.tokenizer = AutoTokenizer.from_pretrained(str(self.artifact / "tokenizer"))
|
|
130
|
+
closed = self.config["closed_heads"]
|
|
131
|
+
self.model = RequestParserModel(
|
|
132
|
+
self.config["encoder"], len(self.config["acts"]), len(self.config["tags"]),
|
|
133
|
+
[len(v) for _, v in closed], len(self.config["flags"]),
|
|
134
|
+
)
|
|
135
|
+
state = torch.load(self.artifact / "weights.pt", map_location="cpu", weights_only=True)
|
|
136
|
+
self.model.load_state_dict(state)
|
|
137
|
+
self.model.to(self.device).eval()
|
|
138
|
+
self.version = str(self.config.get("version", "1"))
|
|
139
|
+
self._threshold = self.threshold if self.threshold is not None else float(self.config.get("threshold", 0.0))
|
|
140
|
+
self.traits = Traits(locality="in_process", egress=False, deterministic=True,
|
|
141
|
+
requires=frozenset({"cuda"} if self.device.startswith("cuda") else set()))
|
|
142
|
+
q = self.config.get("quality", {})
|
|
143
|
+
self.profile = Profile(source=self.config.get("measured_on", "unmeasured"), quality=q,
|
|
144
|
+
latency_ms_p50=self.config.get("latency_ms_p50"), latency_ms_p95=self.config.get("latency_ms_p95"),
|
|
145
|
+
usd_per_call=0.0, peak_memory_mb=self.config.get("peak_memory_mb"))
|
|
146
|
+
|
|
147
|
+
# -- runtime contract
|
|
148
|
+
|
|
149
|
+
def accepts(self, request: Request) -> bool:
|
|
150
|
+
return request.op == "parse" and request.target is ParsedRequest and isinstance(request.subject, str)
|
|
151
|
+
|
|
152
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
153
|
+
texts = [r.subject for r in requests]
|
|
154
|
+
parsed = self.parse(texts)
|
|
155
|
+
outs: list[Output] = []
|
|
156
|
+
for got in parsed:
|
|
157
|
+
score = Score(got.confidence or 0.0, "probability", self.profile.source or "unmeasured")
|
|
158
|
+
if got.act == "unknown":
|
|
159
|
+
reason = "not_a_request" if got.speech_act == "world_statement" else "not_a_known_request"
|
|
160
|
+
detail = ("this states something rather than asking for anything"
|
|
161
|
+
if got.speech_act == "world_statement" else f"no act fits this utterance (p={got.confidence:.2f})")
|
|
162
|
+
outs.append(Output(Unknown(reason, detail), score))
|
|
163
|
+
elif (got.confidence or 0.0) < self._threshold:
|
|
164
|
+
outs.append(Output(Unknown("below_threshold", f"p={got.confidence:.3f} < {self._threshold:.3f}", (got,)), score))
|
|
165
|
+
else:
|
|
166
|
+
outs.append(Output(got, score))
|
|
167
|
+
return outs
|
|
168
|
+
|
|
169
|
+
# -- the work
|
|
170
|
+
|
|
171
|
+
@torch.inference_mode()
|
|
172
|
+
def parse(self, texts: Sequence[str]) -> list[ParsedRequest]:
|
|
173
|
+
out: list[ParsedRequest] = []
|
|
174
|
+
for i in range(0, len(texts), self.batch_size):
|
|
175
|
+
out.extend(self._parse_batch(list(texts[i : i + self.batch_size])))
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
@torch.inference_mode()
|
|
179
|
+
def embed(self, texts: Sequence[str]) -> torch.Tensor:
|
|
180
|
+
"""Sentence embeddings from the same encoder, for associative recall over claims."""
|
|
181
|
+
vecs = []
|
|
182
|
+
for i in range(0, len(texts), self.batch_size):
|
|
183
|
+
batch = self.tokenizer(list(texts[i : i + self.batch_size]), return_tensors="pt", padding=True,
|
|
184
|
+
truncation=True, max_length=self.config["max_length"]).to(self.device)
|
|
185
|
+
vecs.append(self.model(batch["input_ids"], batch["attention_mask"])["pooled"].float().cpu())
|
|
186
|
+
return torch.cat(vecs) if vecs else torch.zeros(0, self.model.encoder.config.hidden_size)
|
|
187
|
+
|
|
188
|
+
def _parse_batch(self, texts: list[str]) -> list[ParsedRequest]:
|
|
189
|
+
cfg = self.config
|
|
190
|
+
enc = self.tokenizer(texts, return_tensors="pt", padding=True, truncation=True,
|
|
191
|
+
max_length=cfg["max_length"], return_offsets_mapping=True)
|
|
192
|
+
offsets = enc.pop("offset_mapping")
|
|
193
|
+
enc = {k: v.to(self.device) for k, v in enc.items()}
|
|
194
|
+
got = self.model(enc["input_ids"], enc["attention_mask"])
|
|
195
|
+
act_p = got["act"].softmax(-1).cpu()
|
|
196
|
+
tag_id = got["tags"].argmax(-1).cpu()
|
|
197
|
+
closed_id = [c.argmax(-1).cpu() for c in got["closed"]]
|
|
198
|
+
flag_on = (got["flags"] > 0).cpu()
|
|
199
|
+
|
|
200
|
+
whole_input = frozenset(cfg.get("whole_input_slots", ()))
|
|
201
|
+
results = []
|
|
202
|
+
for row, text in enumerate(texts):
|
|
203
|
+
act = cfg["acts"][int(act_p[row].argmax())]
|
|
204
|
+
confidence = float(act_p[row].max())
|
|
205
|
+
spans = _decode_spans(cfg["tags"], tag_id[row].tolist(), offsets[row].tolist(), text,
|
|
206
|
+
enc["attention_mask"][row].cpu().tolist())
|
|
207
|
+
slots: dict[str, Any] = {}
|
|
208
|
+
allowed = frozenset(cfg["act_slots"].get(act, []))
|
|
209
|
+
for slot, value in spans.items():
|
|
210
|
+
if slot in allowed and slot != "place":
|
|
211
|
+
slots[slot] = value
|
|
212
|
+
closed = {name: vocab[int(closed_id[k][row])] for k, (name, vocab) in enumerate(cfg["closed_heads"])}
|
|
213
|
+
if "place" in allowed:
|
|
214
|
+
kind = closed.get("place_kind", "none")
|
|
215
|
+
if kind == "span" and "place" in spans:
|
|
216
|
+
slots["place"] = spans["place"]
|
|
217
|
+
elif kind not in ("none", "span"):
|
|
218
|
+
slots["place"] = kind
|
|
219
|
+
if "target" in allowed and closed.get("target_kind", "none") == "@it":
|
|
220
|
+
slots["target"] = "@it" # "read it": the thing acted on is whatever was last touched
|
|
221
|
+
if "name" in allowed and closed.get("name_canon", "none") != "none" and "name" not in slots:
|
|
222
|
+
slots["name"] = closed["name_canon"] # "make a readme": a name supplied by convention
|
|
223
|
+
if act == "info" and closed.get("info_topic", "none") != "none":
|
|
224
|
+
slots["topic"] = closed["info_topic"]
|
|
225
|
+
if "unit" in allowed and closed.get("unit", "none") != "none":
|
|
226
|
+
slots["unit"] = closed["unit"]
|
|
227
|
+
for slot in allowed & whole_input:
|
|
228
|
+
slots[slot] = text # nothing to tag: the procedure reads the whole request back out
|
|
229
|
+
if "aspect" in allowed:
|
|
230
|
+
slots["aspect"] = closed.get("aspect", "none")
|
|
231
|
+
for k, flag in enumerate(cfg["flags"]):
|
|
232
|
+
if flag in allowed:
|
|
233
|
+
slots[flag] = bool(flag_on[row][k])
|
|
234
|
+
speech_act = closed.get("speech_act")
|
|
235
|
+
if speech_act == "world_statement":
|
|
236
|
+
# a remark about the world is not a request; acting on it would be a wrong action,
|
|
237
|
+
# so the speech act overrules whatever the act head preferred
|
|
238
|
+
act, slots = "unknown", {}
|
|
239
|
+
results.append(ParsedRequest(act, slots, confidence, speech_act))
|
|
240
|
+
return results
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _decode_spans(tags: Sequence[str], ids: Sequence[int], offsets: Sequence[Sequence[int]], text: str,
|
|
244
|
+
mask: Sequence[int]) -> dict[str, str]:
|
|
245
|
+
"""First contiguous B/I run per slot, mapped back to the original characters."""
|
|
246
|
+
spans: dict[str, tuple[int, int]] = {}
|
|
247
|
+
current: tuple[str, int, int] | None = None
|
|
248
|
+
for position, tag_id in enumerate(ids):
|
|
249
|
+
if position >= len(offsets) or not mask[position]:
|
|
250
|
+
continue
|
|
251
|
+
start, end = offsets[position]
|
|
252
|
+
if start == end: # special token
|
|
253
|
+
continue
|
|
254
|
+
tag = tags[tag_id]
|
|
255
|
+
if tag == "O":
|
|
256
|
+
if current:
|
|
257
|
+
spans.setdefault(current[0], (current[1], current[2]))
|
|
258
|
+
current = None
|
|
259
|
+
continue
|
|
260
|
+
prefix, slot = tag.split("-", 1)
|
|
261
|
+
if current and current[0] == slot and prefix == "I":
|
|
262
|
+
current = (slot, current[1], end)
|
|
263
|
+
else:
|
|
264
|
+
if current:
|
|
265
|
+
spans.setdefault(current[0], (current[1], current[2]))
|
|
266
|
+
current = (slot, start, end)
|
|
267
|
+
if current:
|
|
268
|
+
spans.setdefault(current[0], (current[1], current[2]))
|
|
269
|
+
return {slot: text[a:b].strip() for slot, (a, b) in spans.items() if text[a:b].strip()}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class NeuralAnswerer:
|
|
274
|
+
"""A trained extractive answerer over passages, as a ``parse`` implementation."""
|
|
275
|
+
|
|
276
|
+
artifact: Path
|
|
277
|
+
device: str | None = None
|
|
278
|
+
batch_size: int = 16
|
|
279
|
+
threshold: float | None = None
|
|
280
|
+
max_length: int = 384
|
|
281
|
+
name: str = "neural-span-answerer"
|
|
282
|
+
op: str = "parse"
|
|
283
|
+
|
|
284
|
+
def __post_init__(self) -> None:
|
|
285
|
+
self.artifact = Path(self.artifact)
|
|
286
|
+
self.config = json.loads((self.artifact / "config.json").read_text())
|
|
287
|
+
self.device = _device(self.device)
|
|
288
|
+
self.tokenizer = AutoTokenizer.from_pretrained(str(self.artifact / "tokenizer"))
|
|
289
|
+
self.model = SpanAnswererModel(self.config["encoder"])
|
|
290
|
+
self.model.load_state_dict(torch.load(self.artifact / "weights.pt", map_location="cpu", weights_only=True))
|
|
291
|
+
self.model.to(self.device).eval()
|
|
292
|
+
self.version = str(self.config.get("version", "1"))
|
|
293
|
+
self._threshold = self.threshold if self.threshold is not None else float(self.config.get("threshold", 0.0))
|
|
294
|
+
self.traits = Traits(locality="in_process", egress=False, deterministic=True,
|
|
295
|
+
requires=frozenset({"cuda"} if self.device.startswith("cuda") else set()))
|
|
296
|
+
self.profile = Profile(source=self.config.get("measured_on", "unmeasured"), quality=self.config.get("quality", {}),
|
|
297
|
+
latency_ms_p50=self.config.get("latency_ms_p50"), latency_ms_p95=self.config.get("latency_ms_p95"),
|
|
298
|
+
usd_per_call=0.0, peak_memory_mb=self.config.get("peak_memory_mb"))
|
|
299
|
+
|
|
300
|
+
def accepts(self, request: Request) -> bool:
|
|
301
|
+
return request.op == "parse" and request.target is Answer and isinstance(request.subject, QuestionOverPassages)
|
|
302
|
+
|
|
303
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
304
|
+
answers = self.answer([r.subject for r in requests])
|
|
305
|
+
outs = []
|
|
306
|
+
for got in answers:
|
|
307
|
+
score = Score(got.confidence or 0.0, "probability", self.profile.source or "unmeasured")
|
|
308
|
+
if not got.text:
|
|
309
|
+
outs.append(Output(Unknown("not_in_passage", f"no span answers this (p={got.confidence:.2f})"), score))
|
|
310
|
+
elif (got.confidence or 0.0) < self._threshold:
|
|
311
|
+
outs.append(Output(Unknown("below_threshold", f"p={got.confidence:.3f} < {self._threshold:.3f}", (got,)), score))
|
|
312
|
+
else:
|
|
313
|
+
outs.append(Output(got, score))
|
|
314
|
+
return outs
|
|
315
|
+
|
|
316
|
+
@torch.inference_mode()
|
|
317
|
+
def answer(self, items: Sequence[QuestionOverPassages]) -> list[Answer]:
|
|
318
|
+
out: list[Answer] = []
|
|
319
|
+
for i in range(0, len(items), self.batch_size):
|
|
320
|
+
out.extend(self._answer_batch(list(items[i : i + self.batch_size])))
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
@torch.inference_mode()
|
|
324
|
+
def _answer_batch(self, items: list[QuestionOverPassages]) -> list[Answer]:
|
|
325
|
+
contexts = [" ".join(s for _, s in it.passages) for it in items]
|
|
326
|
+
enc = self.tokenizer([it.question for it in items], contexts, return_tensors="pt", padding=True,
|
|
327
|
+
truncation="only_second", max_length=self.max_length, return_offsets_mapping=True)
|
|
328
|
+
offsets = enc.pop("offset_mapping")
|
|
329
|
+
seq_ids = [enc.sequence_ids(i) for i in range(len(items))]
|
|
330
|
+
enc = {k: v.to(self.device) for k, v in enc.items()}
|
|
331
|
+
got = self.model(**{k: v for k, v in enc.items() if k in ("input_ids", "attention_mask", "token_type_ids")})
|
|
332
|
+
answerable = got["answerable"].softmax(-1)[:, 1].cpu()
|
|
333
|
+
starts, ends = got["start"].cpu(), got["end"].cpu()
|
|
334
|
+
|
|
335
|
+
results = []
|
|
336
|
+
for row, item in enumerate(items):
|
|
337
|
+
allowed = [i for i, s in enumerate(seq_ids[row]) if s == 1]
|
|
338
|
+
if not allowed:
|
|
339
|
+
results.append(Answer("", (), float(answerable[row])))
|
|
340
|
+
continue
|
|
341
|
+
s_log, e_log = starts[row][allowed], ends[row][allowed]
|
|
342
|
+
s_p, e_p = s_log.softmax(-1), e_log.softmax(-1)
|
|
343
|
+
best, span = 0.0, None
|
|
344
|
+
top_s = torch.topk(s_p, k=min(20, len(allowed))).indices.tolist()
|
|
345
|
+
top_e = torch.topk(e_p, k=min(20, len(allowed))).indices.tolist()
|
|
346
|
+
for a in top_s:
|
|
347
|
+
for b in top_e:
|
|
348
|
+
if b < a or b - a > 30:
|
|
349
|
+
continue
|
|
350
|
+
p = float(s_p[a] * e_p[b])
|
|
351
|
+
if p > best:
|
|
352
|
+
best, span = p, (allowed[a], allowed[b])
|
|
353
|
+
if span is None:
|
|
354
|
+
results.append(Answer("", (), float(answerable[row])))
|
|
355
|
+
continue
|
|
356
|
+
a, b = offsets[row][span[0]][0].item(), offsets[row][span[1]][1].item()
|
|
357
|
+
text = contexts[row][a:b].strip()
|
|
358
|
+
confidence = float(answerable[row]) * best ** 0.5
|
|
359
|
+
evidence = tuple(p for p in item.passages if p[1] and text and p[1].find(text) >= 0)
|
|
360
|
+
results.append(Answer(text if float(answerable[row]) >= 0.5 else "", evidence, confidence))
|
|
361
|
+
return results
|