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 +7 -0
- redefiner/api.py +306 -0
- redefiner/export.py +78 -0
- redefiner/fastconv.py +37 -0
- redefiner/model.py +317 -0
- redefiner/rulebook.py +160 -0
- redefiner/serve.py +123 -0
- redefiner/windows.py +84 -0
- redefiner-1.0.0.dist-info/METADATA +757 -0
- redefiner-1.0.0.dist-info/RECORD +14 -0
- redefiner-1.0.0.dist-info/WHEEL +5 -0
- redefiner-1.0.0.dist-info/entry_points.txt +2 -0
- redefiner-1.0.0.dist-info/licenses/LICENSE +202 -0
- redefiner-1.0.0.dist-info/top_level.txt +1 -0
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
|