spinf-client 0.3.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.
- spinf_client/__init__.py +27 -0
- spinf_client/_pipeline.py +234 -0
- spinf_client/calibration.py +70 -0
- spinf_client/client.py +368 -0
- spinf_client/media.py +152 -0
- spinf_client/moderation.py +176 -0
- spinf_client/packs.py +135 -0
- spinf_client-0.3.0.dist-info/METADATA +206 -0
- spinf_client-0.3.0.dist-info/RECORD +11 -0
- spinf_client-0.3.0.dist-info/WHEEL +4 -0
- spinf_client-0.3.0.dist-info/licenses/LICENSE +202 -0
spinf_client/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""spinf-client: the spinf scoring API from Python (scored queries, extraction slots, images, video frames, prompt
|
|
2
|
+
packs and content moderation)."""
|
|
3
|
+
|
|
4
|
+
from . import calibration, media, moderation, packs
|
|
5
|
+
from .client import SpinfClient, SpinfError, answers, billed_tokens, pack, pack_result, query, text
|
|
6
|
+
from .media import image, video, video_frames
|
|
7
|
+
from .moderation import Category, Moderation
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Category",
|
|
11
|
+
"Moderation",
|
|
12
|
+
"SpinfClient",
|
|
13
|
+
"SpinfError",
|
|
14
|
+
"answers",
|
|
15
|
+
"billed_tokens",
|
|
16
|
+
"calibration",
|
|
17
|
+
"image",
|
|
18
|
+
"media",
|
|
19
|
+
"moderation",
|
|
20
|
+
"pack",
|
|
21
|
+
"pack_result",
|
|
22
|
+
"packs",
|
|
23
|
+
"query",
|
|
24
|
+
"text",
|
|
25
|
+
"video",
|
|
26
|
+
"video_frames",
|
|
27
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""A pack's answers -> reads -> category scores -> decision (standard library only, no GPU). The one evaluator: the hosted
|
|
2
|
+
API, spinf-client and the pack workbench import this module, so every evaluator gives the same answer, and the golden fixtures of
|
|
3
|
+
spinf-prompts (packs/<name>/fixtures/<version>.json) pin it within float rounding. Format: spinf-prompts docs/pack-format.md.
|
|
4
|
+
|
|
5
|
+
Per content:
|
|
6
|
+
answers the option probabilities per (query id, combination value F), renormalised over the query's options; from the
|
|
7
|
+
result's `p` or, when the pack says "answers": "logprob", from the full-precision logprobs
|
|
8
|
+
reads pair: sign * logit(p[0]); option: ln p[j] - mu; any: logsumexp(a[1:]) - a[0];
|
|
9
|
+
set: logsumexp(a[unsafe]) - logsumexp(a[rest]), with a = ln p - mu; then z = (value - mean) / sd
|
|
10
|
+
categories macro = the mean z of the category's reads ("views": the mean of the per-view means);
|
|
11
|
+
score = sigmoid(a * macro + b); percentile = the score in the normal-traffic quantiles; flagged = score >= threshold
|
|
12
|
+
(threshold null = reported only); a category with `requires` is flagged only while the categories it lists are
|
|
13
|
+
decision max: the highest score of the deciding categories >= one threshold; per_category: any deciding category flagged;
|
|
14
|
+
micro_layer: w . x + b >= threshold over category macros ("c:") and read z's ("r:"), only while the lock matches,
|
|
15
|
+
else the fallback mode; a deciding category has a threshold and not `decides: false`; then, in every mode, a
|
|
16
|
+
flagged category listed in decision.critical forces unsafe (not part of the lock: editable like the thresholds)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import bisect
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import struct
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
CLIP = 1e-9
|
|
29
|
+
PAIR_CLIP = 1e-6
|
|
30
|
+
MODES = ("max", "per_category", "micro_layer")
|
|
31
|
+
LOCK_WARNING = "micro-layer lock mismatch: the pack was edited; using the fallback mode"
|
|
32
|
+
|
|
33
|
+
Answers = dict[tuple[str, str | None], list[float]]
|
|
34
|
+
Layer = tuple[Callable[[list[float]], float], float]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def answers_from_result(result: dict[str, Any] | list[dict[str, Any]], query_ids: set[str] | None = None, source: str = "p") -> Answers:
|
|
38
|
+
"""One input's scoring result (or its `queries` list) -> {(query id, F value or None): [p per option]} for the pack's queries
|
|
39
|
+
(the caller's own queries can share the result). source = the pack's "answers": "p" (the API's p, rounded to 6 decimals, so a
|
|
40
|
+
rare option's p sits on a 1e-6 grid) or "logprob" (exp(logprob - logsumexp over the query's options), full precision)."""
|
|
41
|
+
out: Answers = {}
|
|
42
|
+
for q in result["queries"] if isinstance(result, dict) else result:
|
|
43
|
+
if query_ids is not None and q["id"] not in query_ids:
|
|
44
|
+
continue
|
|
45
|
+
for c in q["combinations"]:
|
|
46
|
+
if source == "logprob":
|
|
47
|
+
lp = [o["logprob"] for o in c["options"]]
|
|
48
|
+
lse = _logsumexp(lp)
|
|
49
|
+
ps = [math.exp(x - lse) for x in lp]
|
|
50
|
+
else:
|
|
51
|
+
ps = [o["p"] for o in c["options"]]
|
|
52
|
+
out[(q["id"], (c.get("values") or {}).get("F"))] = ps
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _logsumexp(xs: list[float]) -> float:
|
|
57
|
+
m = max(xs)
|
|
58
|
+
return m + math.log(sum(math.exp(x - m) for x in xs))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_value(read: dict[str, Any], answers: Answers) -> float:
|
|
62
|
+
p = [max(x, CLIP) for x in answers[(read["query"], read.get("F"))]]
|
|
63
|
+
kind = read["kind"]
|
|
64
|
+
if kind == "pair":
|
|
65
|
+
pa = min(max(p[0] / (p[0] + p[1]), PAIR_CLIP), 1 - PAIR_CLIP)
|
|
66
|
+
return read.get("sign", 1) * math.log(pa / (1 - pa))
|
|
67
|
+
total = sum(p)
|
|
68
|
+
logs = [math.log(x / total) for x in p]
|
|
69
|
+
if kind == "option":
|
|
70
|
+
return logs[read["option"]] - read["mu"]
|
|
71
|
+
a = [x - mu for x, mu in zip(logs, read["mu"], strict=True)]
|
|
72
|
+
if kind == "any":
|
|
73
|
+
return _logsumexp(a[1:]) - a[0]
|
|
74
|
+
if kind == "set":
|
|
75
|
+
unsafe = set(read["unsafe"])
|
|
76
|
+
return _logsumexp([a[i] for i in sorted(unsafe)]) - _logsumexp([x for i, x in enumerate(a) if i not in unsafe])
|
|
77
|
+
raise ValueError(f"unknown read kind {kind!r}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_z(pack: dict[str, Any], answers: Answers) -> dict[str, float]:
|
|
81
|
+
return {r["id"]: (read_value(r, answers) - r["mean"]) / r["sd"] for r in pack["reads"]}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _mean(xs: list[float]) -> float:
|
|
85
|
+
return sum(xs) / len(xs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def category_macros(pack: dict[str, Any], z: dict[str, float]) -> dict[str, float]:
|
|
89
|
+
by: dict[str, list[float]] = {}
|
|
90
|
+
views: dict[str, dict[str, list[float]]] = {}
|
|
91
|
+
for r in pack["reads"]:
|
|
92
|
+
by.setdefault(r["category"], []).append(z[r["id"]])
|
|
93
|
+
if r.get("view"):
|
|
94
|
+
views.setdefault(r["category"], {}).setdefault(r["view"], []).append(z[r["id"]])
|
|
95
|
+
return {
|
|
96
|
+
c: _mean([_mean(v) for v in views[c].values()]) if spec.get("aggregate") == "views" else _mean(by[c])
|
|
97
|
+
for c, spec in pack["categories"].items()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _sigmoid(x: float) -> float:
|
|
102
|
+
return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def interp(x: float, xp: list[float], fp: list[float]) -> float:
|
|
106
|
+
"""numpy.interp: piecewise linear, clamped at both ends (xp increasing)."""
|
|
107
|
+
if x <= xp[0]:
|
|
108
|
+
return fp[0]
|
|
109
|
+
if x >= xp[-1]:
|
|
110
|
+
return fp[-1]
|
|
111
|
+
i = bisect.bisect_right(xp, x) - 1
|
|
112
|
+
return fp[i] + (x - xp[i]) * (fp[i + 1] - fp[i]) / (xp[i + 1] - xp[i])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def percentile(spec: dict[str, Any], score: float) -> float | None:
|
|
116
|
+
q = spec.get("normal_quantiles")
|
|
117
|
+
return interp(score, q["values"], q["levels"]) if q else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def lock_of(pack: dict[str, Any]) -> str:
|
|
121
|
+
"""sha256 of the canonical JSON of what the micro-layer depends on (queries, reads, Platt maps, inputs)."""
|
|
122
|
+
ml = pack["decision"]["micro_layer"]
|
|
123
|
+
obj = {
|
|
124
|
+
"queries": pack["scoring"]["queries"],
|
|
125
|
+
"reads": pack["reads"],
|
|
126
|
+
"platt": {c: s["platt"] for c, s in pack["categories"].items()},
|
|
127
|
+
"inputs": ml["inputs"],
|
|
128
|
+
}
|
|
129
|
+
return "sha256:" + hashlib.sha256(json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def micro_layer(pack: dict[str, Any]) -> Layer:
|
|
133
|
+
"""The decoded layer: (x -> value, threshold). Weights (float32, base64): linear [w..., b, threshold]; with hidden units
|
|
134
|
+
[W1 (inputs x hidden), b1, w2, b2, threshold], tanh."""
|
|
135
|
+
ml = pack["decision"]["micro_layer"]
|
|
136
|
+
if ml.get("dtype", "float32") != "float32":
|
|
137
|
+
raise ValueError(f"micro-layer dtype {ml['dtype']!r}: only float32")
|
|
138
|
+
raw = base64.b64decode(ml["weights"])
|
|
139
|
+
w = list(struct.unpack(f"<{len(raw) // 4}f", raw))
|
|
140
|
+
n, h = len(ml["inputs"]), ml.get("hidden", 0)
|
|
141
|
+
if len(w) != micro_layer_size(n, h):
|
|
142
|
+
raise ValueError(f"micro-layer weights: {len(w)} floats for {n} inputs and {h} hidden units ({micro_layer_size(n, h)} expected)")
|
|
143
|
+
if h:
|
|
144
|
+
w1 = [w[i * h : (i + 1) * h] for i in range(n)]
|
|
145
|
+
b1, w2 = w[n * h : n * h + h], w[n * h + h : n * h + 2 * h]
|
|
146
|
+
b2, t = w[n * h + 2 * h], w[n * h + 2 * h + 1]
|
|
147
|
+
|
|
148
|
+
def hidden(x: list[float]) -> float:
|
|
149
|
+
units = [math.tanh(sum(x[i] * w1[i][k] for i in range(n)) + b1[k]) for k in range(h)]
|
|
150
|
+
return sum(u * v for u, v in zip(units, w2, strict=True)) + b2
|
|
151
|
+
|
|
152
|
+
return hidden, t
|
|
153
|
+
ww, b, t = w[:n], w[n], w[n + 1]
|
|
154
|
+
return (lambda x: sum(a * c for a, c in zip(x, ww, strict=True)) + b), t
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def micro_layer_size(n_inputs: int, hidden: int) -> int:
|
|
158
|
+
"""how many float32 the weights of a layer hold, the threshold included"""
|
|
159
|
+
return n_inputs * hidden + 2 * hidden + 2 if hidden else n_inputs + 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def deciding(pack: dict[str, Any], limits: dict[str, float | None]) -> list[str]:
|
|
163
|
+
"""the categories that can decide: a threshold (the pack's or the call's) and not `decides: false`"""
|
|
164
|
+
return [c for c, spec in pack["categories"].items() if limits[c] is not None and spec.get("decides", True)]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def evaluate(
|
|
168
|
+
pack: dict[str, Any],
|
|
169
|
+
answers: Answers,
|
|
170
|
+
mode: str | None = None,
|
|
171
|
+
threshold: float | None = None,
|
|
172
|
+
thresholds: dict[str, float | None] | None = None,
|
|
173
|
+
critical: list[str] | None = None,
|
|
174
|
+
lock_ok: bool | None = None,
|
|
175
|
+
layer: Layer | None = None,
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
"""One content's answers -> {"categories", "decision", "macros"}.
|
|
178
|
+
|
|
179
|
+
mode: the decision mode (default: the pack's). threshold: the one threshold of max or micro_layer (default: the pack's).
|
|
180
|
+
thresholds: per-category overrides (None = reported only). critical: the categories that force unsafe when flagged (default:
|
|
181
|
+
the pack's decision.critical; [] = none). lock_ok / layer: precomputed by a loaded Pack, else computed here."""
|
|
182
|
+
z = read_z(pack, answers)
|
|
183
|
+
macros = category_macros(pack, z)
|
|
184
|
+
limits = {c: spec.get("threshold") for c, spec in pack["categories"].items()}
|
|
185
|
+
for c, t in (thresholds or {}).items():
|
|
186
|
+
if c not in limits:
|
|
187
|
+
raise KeyError(f"no category {c!r}")
|
|
188
|
+
limits[c] = t
|
|
189
|
+
cats: dict[str, dict[str, Any]] = {}
|
|
190
|
+
for c, spec in pack["categories"].items():
|
|
191
|
+
a, b = spec["platt"]
|
|
192
|
+
s = _sigmoid(a * macros[c] + b)
|
|
193
|
+
t = limits[c]
|
|
194
|
+
cats[c] = {"score": s, "percentile": percentile(spec, s), "flagged": None if t is None else s >= t}
|
|
195
|
+
for c, spec in pack["categories"].items(): # requires: flagged only while the required categories are
|
|
196
|
+
if cats[c]["flagged"] and spec.get("requires"):
|
|
197
|
+
cats[c]["flagged"] = all(cats[r]["flagged"] for r in spec["requires"])
|
|
198
|
+
|
|
199
|
+
dec = pack["decision"]
|
|
200
|
+
mode = mode or dec["mode"]
|
|
201
|
+
if mode not in MODES:
|
|
202
|
+
raise ValueError(f"mode must be one of {', '.join(MODES)}")
|
|
203
|
+
used = mode
|
|
204
|
+
if mode == "micro_layer":
|
|
205
|
+
ml = dec.get("micro_layer")
|
|
206
|
+
if lock_ok is None:
|
|
207
|
+
lock_ok = bool(ml) and ml.get("lock") == lock_of(pack)
|
|
208
|
+
if not lock_ok:
|
|
209
|
+
used = dec.get("fallback", "per_category")
|
|
210
|
+
decide = deciding(pack, limits)
|
|
211
|
+
out: dict[str, Any]
|
|
212
|
+
if used == "micro_layer":
|
|
213
|
+
f, t = layer or micro_layer(pack)
|
|
214
|
+
t = t if threshold is None else threshold
|
|
215
|
+
x = [macros[i[2:]] if i.startswith("c:") else z[i[2:]] for i in dec["micro_layer"]["inputs"]]
|
|
216
|
+
v = f(x)
|
|
217
|
+
out = {"unsafe": v >= t, "mode": used, "score": v, "threshold": t}
|
|
218
|
+
elif used == "max":
|
|
219
|
+
t = dec["max"]["threshold"] if threshold is None or used != mode else threshold
|
|
220
|
+
top = max(decide, key=lambda c: cats[c]["score"]) if decide else None
|
|
221
|
+
v = cats[top]["score"] if top else 0.0
|
|
222
|
+
out = {"unsafe": top is not None and v >= t, "mode": used, "score": v, "threshold": t, "category": top}
|
|
223
|
+
else:
|
|
224
|
+
hit = [c for c in decide if cats[c]["flagged"]]
|
|
225
|
+
out = {"unsafe": bool(hit), "mode": "per_category", "categories": hit}
|
|
226
|
+
crit = [c for c in (dec.get("critical", []) if critical is None else critical) if cats.get(c, {}).get("flagged")]
|
|
227
|
+
if crit:
|
|
228
|
+
out["critical"] = crit
|
|
229
|
+
if not out["unsafe"]:
|
|
230
|
+
out["unsafe"] = True
|
|
231
|
+
out["by"] = "critical"
|
|
232
|
+
if used != mode:
|
|
233
|
+
out["warning"] = LOCK_WARNING
|
|
234
|
+
return {"categories": cats, "decision": out, "macros": macros}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Calibrating an extraction slot's probability on your own labelled examples.
|
|
2
|
+
|
|
3
|
+
The raw probability of an extracted answer ranks right answers above wrong ones, but it is under-confident by an amount
|
|
4
|
+
that depends on the field (part of the probability goes to other spellings and formats of the same answer). A two-number
|
|
5
|
+
correction fitted on ~50 labelled examples of the field makes it calibrated (spinf-serve's `docs/calibration.md`):
|
|
6
|
+
|
|
7
|
+
P(correct) = sigmoid(a * logit(p) + b)
|
|
8
|
+
|
|
9
|
+
a, b = fit(ps, rights) # ps: top-answer p per example; rights: was the top answer right?
|
|
10
|
+
query("amount", "\\nQ: ...\\nA:{$}", calibration=(a, b)) # the API then returns p_calibrated
|
|
11
|
+
|
|
12
|
+
With 50 labels the calibration error was 0.02-0.10 on every task tested. Without labels, DEFAULT_MAPS (fitted pooled
|
|
13
|
+
over tasks) is right on average but can be off by ~0.2 on one field, and over-corrects answers the model gives from
|
|
14
|
+
memory rather than from the content.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
# label-free fallback maps (a, b), pooled over brand / breed / SQuAD / NQ (research, 2026-09-28): the correction depends
|
|
23
|
+
# on the task, hardly on the model
|
|
24
|
+
DEFAULT_MAPS = {"spinf-12b": (0.76, 1.42), "spinf-31b": (0.71, 1.34)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _logit(p: float) -> float:
|
|
28
|
+
p = min(max(p, 1e-9), 1 - 1e-9)
|
|
29
|
+
return math.log(p / (1 - p))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sigmoid(z: float) -> float:
|
|
33
|
+
return 1 / (1 + math.exp(-z)) if z > -700 else 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply(p: float, a: float, b: float) -> float:
|
|
37
|
+
return _sigmoid(a * _logit(p) + b)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fit(
|
|
41
|
+
ps: Sequence[float], rights: Sequence[bool], l2: float = 1e-3, iters: int = 5000, lr: float = 0.2
|
|
42
|
+
) -> tuple[float, float]:
|
|
43
|
+
"""Logistic regression of rightness on logit(p): -> (a, b). A small pull toward a = 1 keeps tiny samples sane."""
|
|
44
|
+
if len(ps) != len(rights) or not ps:
|
|
45
|
+
raise ValueError("ps and rights must be non-empty and the same length")
|
|
46
|
+
x = [_logit(p) for p in ps]
|
|
47
|
+
y = [1.0 if r else 0.0 for r in rights]
|
|
48
|
+
n = len(x)
|
|
49
|
+
a, b = 1.0, 0.0
|
|
50
|
+
for _ in range(iters):
|
|
51
|
+
ga = gb = 0.0
|
|
52
|
+
for xi, yi in zip(x, y):
|
|
53
|
+
d = _sigmoid(a * xi + b) - yi
|
|
54
|
+
ga += d * xi
|
|
55
|
+
gb += d
|
|
56
|
+
a -= lr * (ga / n + l2 * (a - 1.0))
|
|
57
|
+
b -= lr * gb / n
|
|
58
|
+
return a, b
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ece(ps: Sequence[float], rights: Sequence[bool], bins: int = 10) -> float:
|
|
62
|
+
"""Expected calibration error: the mean |confidence - accuracy| over equal-width bins, weighted by bin size."""
|
|
63
|
+
total = 0.0
|
|
64
|
+
for k in range(bins):
|
|
65
|
+
idx = [i for i, p in enumerate(ps) if min(int(p * bins), bins - 1) == k]
|
|
66
|
+
if idx:
|
|
67
|
+
conf = sum(ps[i] for i in idx) / len(idx)
|
|
68
|
+
acc = sum(1.0 if rights[i] else 0.0 for i in idx) / len(idx)
|
|
69
|
+
total += abs(conf - acc) * len(idx) / len(ps)
|
|
70
|
+
return total
|