redefiner 1.0.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.
redefiner/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entity extraction and span labeling, guided by the rulebook you write. Define what to extract. In plain English."""
2
+
3
+ from .api import LabelResult, Span, Tagger
4
+ from .rulebook import to_text
5
+
6
+ __version__ = "1.0.0"
7
+ __all__ = ["LabelResult", "Span", "Tagger", "to_text", "__version__"]
redefiner/api.py ADDED
@@ -0,0 +1,306 @@
1
+ """Tagger: entity extraction and span labeling, configured by a rulebook.
2
+
3
+ from redefiner import Tagger
4
+ tagger = Tagger.from_pretrained("PATH_OR_HF_REPO")
5
+ tagger.extract("Ada Lovelace worked with Charles Babbage.", rulebook)
6
+ tagger.label("Ada Lovelace worked with Charles Babbage.", [(0, 12)], rulebook)
7
+
8
+ Offsets are character offsets into the original text, start inclusive, end exclusive.
9
+
10
+ Long texts: the model reads at most `max_query_tokens` (256) tokens of text per query. Longer texts are split into
11
+ overlapping windows, cut at sentence ends where possible. extract() keeps, where two windows disagree, the span that
12
+ sits further from a window edge; label() places each window around the spans it labels. Each window is labelled on its
13
+ own, so the same string can get different labels in different windows of one document.
14
+ """
15
+
16
+ import collections
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import TypedDict
21
+
22
+ import torch
23
+
24
+ from .model import build_int8
25
+ from .rulebook import EncodedRulebook, Encoder, Query, to_text
26
+ from .windows import edge_distance, label_windows, text_windows
27
+
28
+ # decoding defaults, used where a release folder's config.json does not set them
29
+ DEFAULTS = {"d_span": 512, "max_width": 16, "max_query_tokens": 256, "threshold": 0.5, "none_bias": 2.0}
30
+ TOP_K = 3 # label(): the chosen type plus up to two alternatives
31
+
32
+ Rulebook = str | dict
33
+
34
+
35
+ class Span(TypedDict):
36
+ start: int
37
+ end: int
38
+ text: str
39
+ label: str
40
+ confidence: float
41
+
42
+
43
+ class LabelResult(TypedDict, total=False):
44
+ start: int
45
+ end: int
46
+ text: str
47
+ label: str
48
+ confidence: float
49
+ alternatives: list[tuple[str, float]]
50
+ snapped: bool # present (True) when snap=True widened the span to whole tokens
51
+ unsupported: str # present instead of label/confidence/alternatives when the span can't be labelled
52
+
53
+
54
+ class Tagger:
55
+ """Extraction and labeling with one model and a cache of encoded rulebooks.
56
+
57
+ Create it with `from_pretrained`. Each distinct rulebook is run through the model once and kept (up to
58
+ `cache_size` rulebooks), so later calls with the same rulebook only pay for the text. Calls are not thread-safe:
59
+ serve one call at a time per Tagger.
60
+ """
61
+
62
+ def __init__(self, model: torch.nn.Module, tokenizer, config: dict, cache_size: int = 8):
63
+ self.model = model
64
+ self.tok = tokenizer
65
+ self.config = config
66
+ self.encoder = Encoder(tokenizer)
67
+ self.max_q = config["max_query_tokens"]
68
+ self.max_width = config["max_width"]
69
+ self.cache_size = cache_size
70
+ self._cache = collections.OrderedDict() # rulebook key -> (encoded rulebook, markers, states, prefix length)
71
+
72
+ @classmethod
73
+ def from_pretrained(cls, path: str | Path, device: str | None = None, revision: str | None = None,
74
+ cache_size: int = 8, cpu_fast_conv: bool = True) -> "Tagger":
75
+ """Load a release folder: config.json, model.safetensors (weight-only int8) and the tokenizer files.
76
+
77
+ Args:
78
+ path: a local folder, or a Hugging Face repo id to download.
79
+ device: "cpu", "cuda", "cuda:1", ... Defaults to "cuda" when a GPU is available, else "cpu".
80
+ revision: the Hugging Face revision to download (ignored for a local folder).
81
+ cache_size: how many encoded rulebooks to keep.
82
+ cpu_fast_conv: on devices other than CUDA, replace the depthwise convolution used by transformers'
83
+ Qwen3.5 module with an equivalent that is 7-14x faster on CPU builds of torch without MKLDNN
84
+ (see fastconv.py).
85
+
86
+ Off CUDA this also sets sys.modules["fla"] = None, so transformers uses its torch implementation instead of
87
+ flash-linear-attention's GPU kernels. Both changes apply to the whole process.
88
+
89
+ Raises:
90
+ ValueError: the folder is not an int8 release folder.
91
+ """
92
+ from safetensors.torch import load_file
93
+ from transformers.models.qwen3_5.tokenization_qwen3_5 import Qwen3_5Tokenizer
94
+ root = Path(path).expanduser()
95
+ if not root.exists():
96
+ from huggingface_hub import snapshot_download
97
+ root = Path(snapshot_download(str(path), revision=revision))
98
+ config = dict(DEFAULTS)
99
+ config.update(json.loads((root / "config.json").read_text()))
100
+ if config.get("format") != "int8":
101
+ raise ValueError(f"{root} is not an int8 release folder (config.json format != 'int8')")
102
+ device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
103
+ if device.type != "cuda":
104
+ sys.modules.setdefault("fla", None)
105
+ if cpu_fast_conv:
106
+ from . import fastconv
107
+ fastconv.install()
108
+ # AutoTokenizer picks Qwen2Tokenizer for this folder, which splits some texts differently
109
+ tok = Qwen3_5Tokenizer.from_pretrained(str(root))
110
+ model = build_int8(config["text_config"], load_file(str(root / "model.safetensors")),
111
+ d_span=config["d_span"], max_width=config["max_width"])
112
+ model.to(device).eval()
113
+ return cls(model, tok, config, cache_size=cache_size)
114
+
115
+ # ---------------------------------------------------------------- shared steps
116
+ def _rulebook(self, rulebook: Rulebook):
117
+ rb = self.encoder.rulebook(to_text(rulebook))
118
+ if rb.key in self._cache:
119
+ self._cache.move_to_end(rb.key)
120
+ else:
121
+ markers, states, prefix_len = self.model.encode_prefix(rb.ids, rb.marker_pos)
122
+ self._cache[rb.key] = (rb, markers, states, prefix_len)
123
+ while len(self._cache) > self.cache_size:
124
+ self._cache.popitem(last=False)
125
+ return self._cache[rb.key]
126
+
127
+ def _states(self, entry, queries: list[Query], batch_size: int):
128
+ """The marker states and, per query, the model's final states for its tokens."""
129
+ _, markers, states, prefix_len = entry
130
+ out = []
131
+ for i in range(0, len(queries), batch_size):
132
+ batch = queries[i:i + batch_size]
133
+ out += self.model.encode_queries(states, prefix_len, [q.ids for q in batch], self.tok.pad_token_id)
134
+ return markers, out
135
+
136
+ def _window_query(self, text: str, offsets: list[tuple[int, int]], s: int, e: int) -> tuple[Query, int]:
137
+ """The query for tokens s..e-1 of the full text, tokenized on its own as a standalone text would be.
138
+
139
+ The window shrinks until it fits max_q tokens. Returns the query and the window's character offset.
140
+ """
141
+ while True:
142
+ c0, c1 = offsets[s][0], offsets[e - 1][1]
143
+ ids, w_offs = self.encoder.tokens(text[c0:c1])
144
+ if len(ids) <= self.max_q or e - s <= 1:
145
+ return self.encoder.query(text[c0:c1], ids[: self.max_q], w_offs[: self.max_q]), c0
146
+ e -= 1
147
+
148
+ # ---------------------------------------------------------------- extract
149
+ @torch.no_grad()
150
+ def extract(self, texts: str | list[str], rulebook: Rulebook, threshold: float | None = None,
151
+ none_bias: float | None = None, batch_size: int = 16) -> list[Span] | list[list[Span]]:
152
+ """Find and label the spans the rulebook asks for.
153
+
154
+ Args:
155
+ texts: a string, or a list of strings.
156
+ rulebook: rulebook text, or a dict (see `to_text`).
157
+ threshold: minimum span probability (default from config.json, 0.5).
158
+ none_bias: subtracted from the NONE logit before a type is chosen (default from config.json, 2.0).
159
+ batch_size: queries per forward pass.
160
+
161
+ Returns:
162
+ Per text, its spans sorted by start; a single list when `texts` is a string.
163
+
164
+ Raises:
165
+ ValueError: the rulebook has no [TYPE] sections.
166
+ """
167
+ single = isinstance(texts, str)
168
+ texts = [texts] if single else list(texts)
169
+ threshold = self.config["threshold"] if threshold is None else threshold
170
+ none_bias = self.config["none_bias"] if none_bias is None else none_bias
171
+ entry = self._rulebook(rulebook)
172
+ rb: EncodedRulebook = entry[0]
173
+ queries, meta = [], [] # meta: (text index, char offset, window starts the text, window ends the text)
174
+ for ti, text in enumerate(texts):
175
+ ids, offs = self.encoder.tokens(text)
176
+ if not ids:
177
+ continue
178
+ for s, e in text_windows(text, offs, self.max_q):
179
+ if (s, e) == (0, len(ids)):
180
+ q, c0 = self.encoder.query(text, ids, offs), 0
181
+ else:
182
+ q, c0 = self._window_query(text, offs, s, e)
183
+ queries.append(q)
184
+ meta.append((ti, c0, s == 0, e == len(ids)))
185
+ found = [[] for _ in texts]
186
+ if queries:
187
+ markers, hs = self._states(entry, queries, batch_size)
188
+ for q, h, (ti, c0, at_start, at_end) in zip(queries, hs, meta):
189
+ n = len(q.offsets)
190
+ for s, e, k, conf in self.model.predict(markers, h, q.q2_start, threshold=threshold,
191
+ none_bias=none_bias):
192
+ cs, ce = Encoder.char_span(q, s, e)
193
+ found[ti].append((edge_distance(s, e, n, at_start, at_end), conf, c0 + cs, c0 + ce, rb.types[k]))
194
+ results = []
195
+ for ti, spans in enumerate(found):
196
+ kept = []
197
+ for _, conf, cs, ce, label in sorted(spans, key=lambda x: (-x[0], -x[1])):
198
+ if all(ce <= a or cs >= b for a, b, _, _ in kept):
199
+ kept.append((cs, ce, label, conf))
200
+ results.append([Span(start=cs, end=ce, text=texts[ti][cs:ce], label=label, confidence=round(conf, 3))
201
+ for cs, ce, label, conf in sorted(kept)])
202
+ return results[0] if single else results
203
+
204
+ # ---------------------------------------------------------------- label
205
+ @torch.no_grad()
206
+ def label(self, texts: str | list[str], spans: list, rulebook: Rulebook, allow_none: bool = False,
207
+ snap: bool = False, none_bias: float | None = None,
208
+ batch_size: int = 16) -> list[LabelResult] | list[list[LabelResult]]:
209
+ """The type of each given span (label head only, no extraction).
210
+
211
+ Args:
212
+ texts: a string, or a list of strings.
213
+ spans: for a single string, a list of (start, end) or {"start", "end"} character offsets; for a list of
214
+ strings, one such list per text.
215
+ rulebook: rulebook text, or a dict (see `to_text`).
216
+ allow_none: False forces a choice among the rulebook's types; True lets the model answer "NONE".
217
+ snap: label a span that does not start and end on token boundaries as the span widened to whole tokens
218
+ (returning the widened offsets and "snapped": True), instead of reporting it unsupported.
219
+ none_bias: with allow_none, subtracted from the NONE logit first (default from config.json, 2.0).
220
+ batch_size: queries per forward pass.
221
+
222
+ Returns:
223
+ Per text, one result per span in the given order; a single list when `texts` is a string. A span is
224
+ unsupported (an "unsupported" reason instead of a label) when its offsets are invalid, when it is longer
225
+ than `max_width` model tokens, or when it does not start and end on token boundaries and snap is False.
226
+
227
+ Raises:
228
+ ValueError: the rulebook has no [TYPE] sections, or `spans` does not match `texts`.
229
+ """
230
+ single = isinstance(texts, str)
231
+ if single:
232
+ texts, spans = [texts], [spans]
233
+ if len(texts) != len(spans):
234
+ raise ValueError("label() needs one list of spans per text")
235
+ none_bias = self.config["none_bias"] if none_bias is None else none_bias
236
+ entry = self._rulebook(rulebook)
237
+ names = entry[0].types
238
+ results = [[None] * len(sp) for sp in spans]
239
+ queries, jobs = [], [] # jobs[i]: (text index, char offset, [(span index, start, end)]) for queries[i]
240
+ for ti, (text, sp) in enumerate(zip(texts, spans)):
241
+ todo = []
242
+ for si, (s, e) in enumerate(_offsets(x) for x in sp):
243
+ if 0 <= s < e <= len(text):
244
+ todo.append((si, s, e))
245
+ else:
246
+ results[ti][si] = LabelResult(start=s, end=e, text=text[max(0, s):max(0, e)],
247
+ unsupported="invalid offsets")
248
+ if not todo:
249
+ continue
250
+ ids, offs = self.encoder.tokens(text)
251
+ if len(ids) <= self.max_q:
252
+ queries.append(self.encoder.query(text, ids, offs))
253
+ jobs.append((ti, 0, todo))
254
+ continue
255
+ for s_tok, e_tok, group in label_windows(offs, todo, self.max_q):
256
+ q, c0 = self._window_query(text, offs, s_tok, e_tok)
257
+ queries.append(q)
258
+ jobs.append((ti, c0, group))
259
+ if queries:
260
+ markers, hs = self._states(entry, queries, batch_size)
261
+ for q, h, (ti, c0, group) in zip(queries, hs, jobs):
262
+ starts, ends, v, _ = self.model.spans(h[q.q2_start:])
263
+ where = {(a, b): n for n, (a, b) in enumerate(zip(starts.tolist(), ends.tolist()))}
264
+ logits = self.model.label_logits(v, markers).float()
265
+ for si, s, e in group:
266
+ results[ti][si] = self._label_span(texts[ti], q, c0, s, e, where, logits, names, allow_none,
267
+ snap, none_bias)
268
+ return results[0] if single else results
269
+
270
+ def _label_span(self, text, q, c0, s, e, where, logits, names, allow_none, snap, none_bias) -> LabelResult:
271
+ """The result for span s..e of `text`, read from query `q`, which starts at character c0 of the text."""
272
+ item = LabelResult(start=s, end=e, text=text[s:e])
273
+ ls, le = s - c0, e - c0 # offsets inside this query
274
+ first = next((t for t, (_, b) in enumerate(q.offsets) if b > ls), None)
275
+ last = max((t for t, (a, _) in enumerate(q.offsets) if a < le), default=None)
276
+ if first is None or last is None or last < first or ls < 0 or le > len(q.text):
277
+ return {**item, "unsupported": "span is outside the text the model can read"}
278
+ if last - first + 1 > self.max_width:
279
+ return {**item, "unsupported": f"span is longer than {self.max_width} tokens"}
280
+ if (first, last) not in where:
281
+ return {**item, "unsupported": "span is not available"}
282
+ cs, ce = Encoder.char_span(q, first, last)
283
+ if (cs, ce) != (ls, le):
284
+ if not snap:
285
+ return {**item, "unsupported": "would be snapped: the span does not start and end on token boundaries"}
286
+ item.update(start=c0 + cs, end=c0 + ce, text=text[c0 + cs:c0 + ce], snapped=True)
287
+ z = logits[where[(first, last)]].clone()
288
+ n_types = len(names)
289
+ if allow_none:
290
+ z[n_types] -= none_bias
291
+ choices = names + ["NONE"]
292
+ else:
293
+ z = z[:n_types]
294
+ choices = names
295
+ pr = z.softmax(-1)
296
+ top = pr.topk(min(TOP_K, len(pr)))
297
+ ranked = [(choices[k], round(p, 3)) for k, p in zip(top.indices.tolist(), top.values.tolist())]
298
+ item.update(label=ranked[0][0], confidence=ranked[0][1], alternatives=ranked[1:])
299
+ return item
300
+
301
+
302
+ def _offsets(span) -> tuple[int, int]:
303
+ """(start, end) from a (start, end) pair or a {"start", "end"} dict."""
304
+ if isinstance(span, dict):
305
+ return int(span["start"]), int(span["end"])
306
+ return int(span[0]), int(span[1])
redefiner/export.py ADDED
@@ -0,0 +1,78 @@
1
+ """Building the int8 release model from a training checkpoint (needs the `export` extra: peft, accelerate).
2
+
3
+ The checkpoint holds LoRA adapters and the span/label heads. The backbone is the pinned Qwen3.5-0.8B base; the
4
+ adapters are merged into it and every linear layer and the token embedding are converted to weight-only int8.
5
+ See scripts/export_weights.py.
6
+ """
7
+
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+ from .model import ROTARY, Int8Embedding, Int8Linear, SpanModel, replace_linears, set_submodule
12
+
13
+ BASE_MODEL = "Qwen/Qwen3.5-0.8B"
14
+ BASE_REVISION = "2fc06364715b967f1860aea9cf38778875588b17"
15
+ LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "in_proj_qkv", "in_proj_z", "out_proj",
16
+ "gate_proj", "up_proj", "down_proj"]
17
+
18
+
19
+ def load_backbone(name: str = BASE_MODEL, revision: str | None = BASE_REVISION, device: str = "cpu") -> nn.Module:
20
+ """The base model's text decoder in bf16 (the vision tower and LM head are dropped)."""
21
+ from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5ForConditionalGeneration
22
+ full = Qwen3_5ForConditionalGeneration.from_pretrained(name, revision=revision, dtype=torch.bfloat16,
23
+ device_map=device, attn_implementation="sdpa")
24
+ lm = full.model.language_model
25
+ del full
26
+ return lm
27
+
28
+
29
+ def add_lora(lm: nn.Module, rank: int, alpha: int) -> nn.Module:
30
+ from peft import LoraConfig, inject_adapter_in_model
31
+ lm = inject_adapter_in_model(LoraConfig(r=rank, lora_alpha=alpha, lora_dropout=0.0,
32
+ target_modules=LORA_TARGETS), lm)
33
+ for p in lm.parameters():
34
+ p.requires_grad = False
35
+ return lm
36
+
37
+
38
+ def float_model(trainable: dict, rank: int) -> SpanModel:
39
+ """The bf16 SpanModel with the checkpoint's LoRA adapters and heads loaded."""
40
+ lm = load_backbone()
41
+ d_span, max_width = int(trainable["none"].shape[0]), int(trainable["width.weight"].shape[0])
42
+ model = SpanModel(add_lora(lm, rank, 2 * rank), d_span=d_span, max_width=max_width)
43
+ model.marker.data = lm.embed_tokens.weight.detach().float().mean(0).clone() # training's initial value
44
+ res = model.load_state_dict(trainable, strict=False)
45
+ if res.unexpected_keys:
46
+ raise ValueError(f"unexpected keys in the checkpoint: {res.unexpected_keys[:5]}")
47
+ return model
48
+
49
+
50
+ def merge_lora(module: nn.Module) -> nn.Module:
51
+ """Fold every LoRA adapter into its base nn.Linear and put the plain layer back in its place."""
52
+ from peft.tuners.lora import LoraLayer
53
+ for name, m in list(module.named_modules()):
54
+ if isinstance(m, LoraLayer):
55
+ m.merge()
56
+ set_submodule(module, name, m.get_base_layer())
57
+ return module
58
+
59
+
60
+ def quantize(model: SpanModel) -> SpanModel:
61
+ """The float SpanModel (LoRA adapters on) as weight-only int8, with everything that stays float in fp32."""
62
+ lm = model.lm
63
+ lm.embed_tokens = Int8Embedding.from_embedding(lm.embed_tokens)
64
+ for layer in lm.layers:
65
+ merge_lora(layer.float())
66
+ replace_linears(layer, Int8Linear.from_linear)
67
+ for t in list(model.parameters()) + list(model.buffers()):
68
+ if t.is_floating_point():
69
+ t.data = t.data.float() # norms, rotary, marker, heads
70
+ return model
71
+
72
+
73
+ def int8_state(model: SpanModel) -> dict[str, torch.Tensor]:
74
+ """Everything an int8 release file holds: the quantized model's state plus the exact rotary tables."""
75
+ state = {k: v.contiguous() for k, v in model.state_dict().items()}
76
+ for name, buf in model.lm.rotary_emb.named_buffers():
77
+ state[ROTARY + name] = buf.detach().clone().contiguous()
78
+ return state
redefiner/fastconv.py ADDED
@@ -0,0 +1,37 @@
1
+ """Depthwise conv1d fast path for Qwen3.5's Gated DeltaNet layers on CPU.
2
+
3
+ Without the GPU-only causal-conv kernels, transformers falls back to F.conv1d(groups=channels) with a 4-tap kernel.
4
+ On torch builds without MKLDNN (the macOS arm64 wheels) that takes a slow generic path: 0.35 s per call, 6.3 s of a
5
+ 7.3 s query on an M2. The same result is K shifted multiply-adds (equal to F.conv1d within float rounding, about
6
+ 2e-6). Only the Qwen3.5 modeling module's `F` is replaced; every other convolution is untouched.
7
+ """
8
+ import types
9
+
10
+ import torch.nn.functional as F
11
+
12
+
13
+ def _conv1d(input, weight, bias=None, stride=1, padding=0, dilation=1, groups=1):
14
+ C = input.shape[1]
15
+ if not (groups == C and weight.shape[0] == C and weight.shape[1] == 1 and stride == 1 and dilation == 1
16
+ and isinstance(padding, int)):
17
+ return F.conv1d(input, weight, bias, stride, padding, dilation, groups)
18
+ K = weight.shape[-1]
19
+ x = F.pad(input, (padding, padding)) if padding else input
20
+ L = x.shape[-1] - K + 1
21
+ w = weight[:, 0, :]
22
+ out = x[:, :, 0:L] * w[:, 0].view(1, C, 1)
23
+ for k in range(1, K):
24
+ out = out + x[:, :, k:k + L] * w[:, k].view(1, C, 1)
25
+ if bias is not None:
26
+ out = out + bias.view(1, C, 1)
27
+ return out
28
+
29
+
30
+ def install():
31
+ from transformers.models.qwen3_5 import modeling_qwen3_5 as m
32
+ if getattr(m.F, "_redefiner_fastconv", False):
33
+ return
34
+ proxy = types.SimpleNamespace(**{k: getattr(F, k) for k in dir(F) if not k.startswith("__")})
35
+ proxy.conv1d = _conv1d
36
+ proxy._redefiner_fastconv = True
37
+ m.F = proxy