tinydistill 0.2.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.
tinydistill/teacher.py ADDED
@@ -0,0 +1,272 @@
1
+ """Teacher: score each label's verbalisation under a causal LM, cache the scores on disk.
2
+
3
+ For a text x and a continuation c_1..c_m (a way of writing a label, followed by the end-of-turn token),
4
+ log p(c | prompt(x)) = sum_j log p(c_j | prompt(x), c_<j). A label can be written in several surface forms
5
+ (lowercase, Capitalised, UPPERCASE, as written; each with and without a leading space), and its score is the
6
+ log of the summed probability of its distinct forms. A softmax over the label scores gives the teacher's soft
7
+ targets. There is no free-form generation.
8
+
9
+ Speed: the prompt is split around the text into a constant prefix and a per-text suffix. The prefix's KV
10
+ cache is computed once. Per text, one forward pass covers the text and all labels at once: the labels' tokens
11
+ form a prefix tree (labels that start alike share nodes), and a 4D attention mask lets every tree node attend
12
+ to the cached prompt, the text and its own ancestors only. There is no padding and no per-label copy of the
13
+ cache; the cache is cropped back to the prompt prefix afterwards.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from collections.abc import Iterable, Iterator
21
+ from pathlib import Path
22
+
23
+ import torch
24
+ from transformers import AutoModelForCausalLM, AutoTokenizer
25
+
26
+ from .task import Task
27
+
28
+ _SENTINEL = "\x00TEXT\x00"
29
+
30
+
31
+ def pick_device(device: str) -> str:
32
+ if device == "auto":
33
+ return "cuda" if torch.cuda.is_available() else "cpu"
34
+ return device
35
+
36
+
37
+ def text_hash(text: str) -> str:
38
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:20]
39
+
40
+
41
+ def surface_variants(name: str, kinds=("lowercase", "capitalised", "uppercase")) -> list[str]:
42
+ """Common ways of writing a label: as written, plus the chosen kinds of lowercase, Capitalised and
43
+ UPPERCASE; each with and without a leading space. The caller drops forms that tokenise identically."""
44
+ low = name.lower()
45
+ variant = {"lowercase": low, "capitalised": low[:1].upper() + low[1:], "uppercase": name.upper()}
46
+ cased = [name, *(variant[k] for k in ("lowercase", "capitalised", "uppercase") if k in kinds)]
47
+ out: list[str] = []
48
+ for form in cased:
49
+ for v in (form, " " + form):
50
+ if v not in out:
51
+ out.append(v)
52
+ return out
53
+
54
+
55
+ class LabelScorer:
56
+ def __init__(
57
+ self, model, tokenizer, task: Task, use_chat_template: bool = True, surface_forms: bool = True
58
+ ):
59
+ self.model = model.eval()
60
+ self.tok = tokenizer
61
+ self.task = task
62
+ self.device = next(model.parameters()).device
63
+ self.chat = use_chat_template and bool(getattr(tokenizer, "chat_template", None))
64
+
65
+ # Split the rendered prompt around the text.
66
+ user = task.render(_SENTINEL)
67
+ if self.chat:
68
+ full = tokenizer.apply_chat_template(
69
+ [{"role": "user", "content": user}], tokenize=False, add_generation_prompt=True
70
+ )
71
+ else:
72
+ full = user
73
+ prefix, suffix = full.split(_SENTINEL)
74
+ self.suffix = suffix
75
+ ids = tokenizer(prefix, add_special_tokens=False)["input_ids"]
76
+ if not self.chat and tokenizer.bos_token_id is not None:
77
+ ids = [tokenizer.bos_token_id, *ids]
78
+ self.prefix_ids: list[int] = ids
79
+
80
+ # Continuations: each surface form of each label name + the end-of-turn token. Forms that tokenise
81
+ # identically are kept once. Without surface_forms only the name as written is scored (with a leading
82
+ # space when there is no chat template, since the answer then follows the prompt text directly).
83
+ end = [tokenizer.eos_token_id] if tokenizer.eos_token_id is not None else []
84
+ self.forms: list[list[int]] = [] # token ids of every continuation
85
+ self.form_label: list[int] = [] # which label each continuation belongs to
86
+ seen: dict[tuple[int, ...], int] = {}
87
+ for li, name in enumerate(task.verbalisations):
88
+ texts = (
89
+ surface_variants(name, task.surface_forms)
90
+ if surface_forms
91
+ else [name if self.chat else " " + name]
92
+ )
93
+ for text in texts:
94
+ ids = tuple(tokenizer(text, add_special_tokens=False)["input_ids"] + end)
95
+ if seen.get(ids, li) != li:
96
+ other = task.verbalisations[seen[ids]]
97
+ raise ValueError(f"labels {other!r} and {name!r} share the surface form {text!r}")
98
+ if ids not in seen:
99
+ seen[ids] = li
100
+ self.forms.append(list(ids))
101
+ self.form_label.append(li)
102
+ self._build_tree()
103
+ self._prefix_cache = None
104
+
105
+ def _build_tree(self) -> None:
106
+ """Prefix tree over the labels' input tokens (every label token except the last).
107
+
108
+ Node i stands for one distinct label prefix (c_1..c_d); its input token is c_d and its output predicts
109
+ c_(d+1). The first label token is predicted by the last text position (row -1 below).
110
+ """
111
+ nodes: dict[tuple[int, ...], int] = {}
112
+ parent: list[int] = []
113
+ depth: list[int] = []
114
+ tokens: list[int] = []
115
+ rows: list[int] = [] # for every label token, which output row predicts it (-1 = last text position)
116
+ targets: list[int] = []
117
+ owner: list[int] = []
118
+ for li, lab in enumerate(self.forms):
119
+ for j, tok in enumerate(lab):
120
+ rows.append(nodes[tuple(lab[:j])] if j else -1)
121
+ targets.append(tok)
122
+ owner.append(li)
123
+ if j < len(lab) - 1 and tuple(lab[: j + 1]) not in nodes:
124
+ nodes[tuple(lab[: j + 1])] = len(tokens)
125
+ parent.append(nodes[tuple(lab[:j])] if j else -1)
126
+ depth.append(j)
127
+ tokens.append(tok)
128
+ n = len(tokens)
129
+ anc = torch.eye(n, dtype=torch.bool)
130
+ for i in range(n): # parents always come before children
131
+ if parent[i] >= 0:
132
+ anc[i] |= anc[parent[i]]
133
+ dev = self.device
134
+ self._node_tokens = torch.tensor(tokens, dtype=torch.long, device=dev)
135
+ self._node_depth = torch.tensor(depth, dtype=torch.long, device=dev)
136
+ self._node_anc = anc.to(dev)
137
+ self._rows = torch.tensor(rows, dtype=torch.long, device=dev)
138
+ self._targets = torch.tensor(targets, dtype=torch.long, device=dev)
139
+ self._owner = torch.tensor(owner, dtype=torch.long, device=dev)
140
+
141
+ def text_ids(self, text: str) -> list[int]:
142
+ return self.tok(text + self.suffix, add_special_tokens=False)["input_ids"]
143
+
144
+ def fingerprint(self) -> str:
145
+ """Identifies everything that changes the scores, so caches are never mixed up."""
146
+ cfg = getattr(self.model, "config", None)
147
+ key = {
148
+ "model": getattr(cfg, "_name_or_path", ""),
149
+ "dtype": str(next(self.model.parameters()).dtype),
150
+ "quant": str(getattr(cfg, "quantization_config", "")),
151
+ "prefix": self.prefix_ids,
152
+ "suffix": self.suffix,
153
+ "forms": self.forms,
154
+ "form_label": self.form_label,
155
+ }
156
+ return hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()[:16]
157
+
158
+ def score(self, text: str) -> list[float]:
159
+ """Every label's score: log of the summed probability of its surface forms given the prompt."""
160
+ f = self.form_scores(text).double()
161
+ idx = torch.tensor(self.form_label, device=f.device)
162
+ n = len(self.task.labels)
163
+ top = torch.full((n,), -torch.inf, dtype=f.dtype, device=f.device).scatter_reduce(0, idx, f, "amax")
164
+ total = torch.zeros(n, dtype=f.dtype, device=f.device).index_add_(0, idx, (f - top[idx]).exp())
165
+ return (top + total.log()).tolist()
166
+
167
+ @torch.no_grad()
168
+ def form_scores(self, text: str) -> torch.Tensor:
169
+ """Log-probability of every continuation in ``self.forms`` given the prompt for ``text``."""
170
+ dev = self.device
171
+ P = len(self.prefix_ids)
172
+ if self._prefix_cache is None:
173
+ out = self.model(torch.tensor([self.prefix_ids], device=dev), use_cache=True)
174
+ self._prefix_cache = out.past_key_values
175
+ self._prefix_last = torch.log_softmax(out.logits[0, -1].float(), -1)
176
+ ids = torch.tensor(self.text_ids(text), dtype=torch.long, device=dev)
177
+ n, N = len(ids), len(self._node_tokens)
178
+ if n == 0 and N == 0:
179
+ return self._prefix_last[self._targets]
180
+
181
+ # Query rows: n text tokens, then N tree nodes. Keys: P cached prompt tokens, then the same rows.
182
+ allow = torch.zeros((n + N, P + n + N), dtype=torch.bool, device=dev)
183
+ allow[:, :P] = True
184
+ allow[:n, P : P + n] = torch.ones((n, n), dtype=torch.bool, device=dev).tril()
185
+ allow[n:, P : P + n] = True
186
+ allow[n:, P + n :] = self._node_anc
187
+ positions = torch.cat([torch.arange(P, P + n, device=dev), P + n + self._node_depth])
188
+ keep = torch.arange(max(n - 1, 0), n + N, device=dev) # last text position + every node
189
+ try:
190
+ out = self.model(
191
+ torch.cat([ids, self._node_tokens])[None],
192
+ attention_mask=allow[None, None],
193
+ position_ids=positions[None],
194
+ past_key_values=self._prefix_cache,
195
+ use_cache=True,
196
+ logits_to_keep=keep,
197
+ )
198
+ finally:
199
+ extra = self._prefix_cache.get_seq_length() - P
200
+ if extra:
201
+ self._prefix_cache.crop(-extra) # back to the prompt prefix
202
+ lp = torch.log_softmax(out.logits[0].float(), -1) # row 0: last text position, row 1 + i: node i
203
+ if n == 0: # the prompt prefix itself predicts the first label token
204
+ lp = torch.cat([self._prefix_last[None], lp])
205
+ per_token = lp[self._rows + 1, self._targets]
206
+ total = torch.zeros(len(self.forms), dtype=per_token.dtype, device=dev)
207
+ return total.index_add_(0, self._owner, per_token)
208
+
209
+
210
+ def load_teacher(name: str, task: Task, device: str = "auto", load_4bit: bool = False) -> LabelScorer:
211
+ device = pick_device(device)
212
+ tok = AutoTokenizer.from_pretrained(name)
213
+ kwargs: dict = {}
214
+ if load_4bit:
215
+ if not device.startswith("cuda"):
216
+ raise SystemExit("--load-4bit needs a CUDA device (bitsandbytes); use --device cuda")
217
+ from transformers import BitsAndBytesConfig
218
+
219
+ kwargs["quantization_config"] = BitsAndBytesConfig(
220
+ load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16
221
+ )
222
+ kwargs["device_map"] = {"": device}
223
+ else:
224
+ kwargs["dtype"] = torch.bfloat16 if device == "cuda" else torch.float32
225
+ model = AutoModelForCausalLM.from_pretrained(name, **kwargs)
226
+ if not load_4bit:
227
+ model.to(device)
228
+ return LabelScorer(model, tok, task)
229
+
230
+
231
+ class ScoreCache:
232
+ """Append-only JSONL of {"h": text hash, "s": label scores}; reopening it resumes where it stopped."""
233
+
234
+ def __init__(self, path: Path):
235
+ self.path = path
236
+ self.scores: dict[str, list[float]] = {}
237
+ if path.exists():
238
+ with path.open(encoding="utf-8") as f:
239
+ for line in f:
240
+ try:
241
+ row = json.loads(line)
242
+ except json.JSONDecodeError: # a line cut off by an interrupted run
243
+ continue
244
+ self.scores[row["h"]] = row["s"]
245
+
246
+ def fill(self, scorer: LabelScorer, texts: Iterable[str], progress=None) -> int:
247
+ """Score every text not in the cache yet. Returns how many were scored now."""
248
+ n = 0
249
+ with self.path.open("a", encoding="utf-8") as f:
250
+ for text in texts:
251
+ h = text_hash(text)
252
+ if h in self.scores:
253
+ continue
254
+ s = scorer.score(text)
255
+ self.scores[h] = s
256
+ f.write(json.dumps({"h": h, "s": s}) + "\n")
257
+ f.flush()
258
+ n += 1
259
+ if progress:
260
+ progress(n)
261
+ return n
262
+
263
+ def get(self, text: str) -> list[float]:
264
+ return self.scores[text_hash(text)]
265
+
266
+
267
+ def iter_unique(texts: Iterable[str]) -> Iterator[str]:
268
+ seen: set[str] = set()
269
+ for t in texts:
270
+ if t not in seen:
271
+ seen.add(t)
272
+ yield t
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.5
2
+ Name: tinydistill
3
+ Version: 0.2.0
4
+ Summary: Distil an LLM classification prompt into a small, fast model that runs on a CPU.
5
+ Project-URL: Homepage, https://github.com/Andreas-Hum/tinydistill
6
+ Author: Andreas-Hum
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: inference,knowledge distillation,llm,onnx,text classification
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: onnx>=1.15
15
+ Requires-Dist: onnxruntime>=1.17
16
+ Requires-Dist: pyyaml>=6
17
+ Requires-Dist: torch>=2.2
18
+ Requires-Dist: transformers>=4.56
19
+ Provides-Extra: 4bit
20
+ Requires-Dist: accelerate>=0.30; extra == '4bit'
21
+ Requires-Dist: bitsandbytes>=0.43; extra == '4bit'
22
+ Provides-Extra: dev
23
+ Requires-Dist: build>=1.2; extra == 'dev'
24
+ Requires-Dist: datasets>=2.18; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Provides-Extra: hf
28
+ Requires-Dist: datasets>=2.18; extra == 'hf'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # tinydistill
32
+
33
+ **Most of a large model's quality on one task, many times faster, on a CPU.**
34
+
35
+ `tinydistill` turns an LLM classification prompt into a small encoder classifier by knowledge distillation.
36
+ You describe the task in a short YAML file (labels, how to say each label, an instruction prompt) and give it
37
+ unlabelled texts. An instruction-tuned LLM labels them with soft targets, a small encoder learns to imitate
38
+ those targets, and the result is exported to ONNX (optionally int8) for fast CPU inference.
39
+
40
+ It continues my bachelor's project on inference optimisation by distilling LLMs.
41
+
42
+ ## Results
43
+
44
+ Two tasks with `Qwen/Qwen2.5-1.5B-Instruct` as the zero-shot teacher, and Banking77 also with
45
+ `Qwen/Qwen2.5-7B-Instruct`; the student is always `sentence-transformers/all-MiniLM-L6-v2`. Quality is measured on gold-labelled test texts that
46
+ neither model trains on. Every table is written by the task's `run.sh` from its saved `results*.json` file in
47
+ [`examples/`](https://github.com/Andreas-Hum/tinydistill/tree/main/examples); no number is typed by hand. The teacher's latency includes scoring every surface form
48
+ of every label, which costs most on Banking77 with its many labels, so there the GPU teacher row is the fairer
49
+ reference for speed.
50
+
51
+ ### Banking77: the hard case
52
+
53
+ Intent detection with 77 fine-grained classes ([Banking77](https://huggingface.co/datasets/PolyAI/banking77),
54
+ CC BY 4.0), reproduced by [`examples/banking77/run.sh`](https://github.com/Andreas-Hum/tinydistill/blob/main/examples/banking77/run.sh).
55
+
56
+ Teacher only, no gold labels:
57
+
58
+
59
+ <!-- results: banking77/results.md -->
60
+ | Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) | Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |
61
+ |---|---|---|---|---|---|---|---|---|---|
62
+ | teacher, zero-shot | cpu | 0.406 | 0.367 | n/a | 19955.9 | n/a | 0.1 | 6175 | 1.0x |
63
+ | teacher, zero-shot | cuda | 0.406 | 0.367 | n/a | 60.3 | n/a | 16.6 | 3087 | 331.1x |
64
+ | student, PyTorch | cpu | 0.386 | 0.315 | 0.772 | 3.9 | 78.3 | 408.9 | 91 | 5124.6x |
65
+ | student, ONNX fp32 | cpu | 0.386 | 0.315 | 0.772 | 2.6 | 82.4 | 388.4 | 91 | 7606.9x |
66
+ | student, ONNX int8 | cpu | 0.383 | 0.311 | 0.761 | 1.0 | 34.4 | 931.1 | 23 | 20502.9x |
67
+
68
+ Task `banking77`, 3080 gold-labelled test texts. Teacher: `Qwen/Qwen2.5-1.5B-Instruct`. Student: `sentence-transformers/all-MiniLM-L6-v2`. The student learns only from the teacher's soft labels on 10003 unlabelled training texts.
69
+ CPU rows use 2 threads. Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' model files. The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. Speed-up is relative to the teacher, zero-shot on cpu.
70
+ <!-- /results -->
71
+
72
+ With 8 gold labels per class, and the student trained on all gold labels as an upper bound:
73
+
74
+ <!-- results: banking77/results-k8.md -->
75
+ | Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) | Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |
76
+ |---|---|---|---|---|---|---|---|---|---|
77
+ | teacher, zero-shot | cpu | 0.406 | 0.367 | n/a | 19699.4 | n/a | 0.1 | 6175 | 1.0x |
78
+ | teacher, zero-shot | cuda | 0.406 | 0.367 | n/a | 58.3 | n/a | 17.2 | 3087 | 338.1x |
79
+ | student, PyTorch | cpu | 0.404 | 0.324 | 0.699 | 3.9 | 76.7 | 417.4 | 91 | 5056.3x |
80
+ | upper bound: student on all gold labels | cpu | 0.911 | 0.909 | 0.412 | n/a | n/a | n/a | n/a | n/a |
81
+ | student, ONNX fp32 | cpu | 0.404 | 0.324 | 0.699 | 2.7 | 72.1 | 443.6 | 91 | 7306.9x |
82
+ | student, ONNX int8 | cpu | 0.398 | 0.318 | 0.697 | 1.0 | 36.7 | 872.1 | 23 | 18792.2x |
83
+
84
+ Task `banking77`, 3080 gold-labelled test texts. Teacher: `Qwen/Qwen2.5-1.5B-Instruct`. Student: `sentence-transformers/all-MiniLM-L6-v2`. The student learns from the teacher's soft labels on all 10003 training texts, plus cross-entropy on 8 gold labels per class (616 texts, weight 1.0). The upper bound is the same student trained with cross-entropy on all 10003 gold training labels and no teacher; its speed equals the student's.
85
+ CPU rows use 2 threads. Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' model files. The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. Speed-up is relative to the teacher, zero-shot on cpu.
86
+ <!-- /results -->
87
+
88
+ With a stronger teacher, `Qwen/Qwen2.5-7B-Instruct` loaded in 4 bit (timed on the GPU only: 4-bit loading needs
89
+ CUDA), teacher only:
90
+
91
+ <!-- results: banking77/results-7b.md -->
92
+ | Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) | Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |
93
+ |---|---|---|---|---|---|---|---|---|---|
94
+ | teacher, zero-shot, 4-bit | cuda | 0.630 | 0.607 | n/a | 272.2 | n/a | 3.7 | 5443 | 1.0x |
95
+ | student, PyTorch | cpu | 0.623 | 0.587 | 0.827 | 5.5 | 93.0 | 344.2 | 91 | 49.4x |
96
+ | student, ONNX fp32 | cpu | 0.623 | 0.587 | 0.827 | 3.4 | 86.8 | 368.7 | 91 | 80.8x |
97
+ | student, ONNX int8 | cpu | 0.622 | 0.586 | 0.822 | 1.6 | 43.8 | 730.0 | 23 | 171.2x |
98
+
99
+ Task `banking77`, 3080 gold-labelled test texts. Teacher: `Qwen/Qwen2.5-7B-Instruct`. Student: `sentence-transformers/all-MiniLM-L6-v2`. The student learns only from the teacher's soft labels on 10003 unlabelled training texts.
100
+ CPU rows use 2 threads. Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' model files. The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. Speed-up is relative to the teacher, zero-shot, 4-bit on cuda.
101
+ <!-- /results -->
102
+
103
+ The same teacher with 8 gold labels per class and the upper bound:
104
+
105
+ <!-- results: banking77/results-k8-7b.md -->
106
+ | Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) | Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |
107
+ |---|---|---|---|---|---|---|---|---|---|
108
+ | teacher, zero-shot, 4-bit | cuda | 0.630 | 0.607 | n/a | 295.4 | n/a | 3.4 | 5443 | 1.0x |
109
+ | student, PyTorch | cpu | 0.627 | 0.583 | 0.765 | 4.5 | 93.6 | 341.9 | 91 | 65.5x |
110
+ | upper bound: student on all gold labels | cpu | 0.911 | 0.909 | 0.631 | n/a | n/a | n/a | n/a | n/a |
111
+ | student, ONNX fp32 | cpu | 0.627 | 0.583 | 0.765 | 3.2 | 80.9 | 395.7 | 91 | 92.5x |
112
+ | student, ONNX int8 | cpu | 0.622 | 0.576 | 0.762 | 1.1 | 40.7 | 786.4 | 23 | 262.6x |
113
+
114
+ Task `banking77`, 3080 gold-labelled test texts. Teacher: `Qwen/Qwen2.5-7B-Instruct`. Student: `sentence-transformers/all-MiniLM-L6-v2`. The student learns from the teacher's soft labels on all 10003 training texts, plus cross-entropy on 8 gold labels per class (616 texts, weight 1.0). The upper bound is the same student trained with cross-entropy on all 10003 gold training labels and no teacher; its speed equals the student's.
115
+ CPU rows use 2 threads. Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' model files. The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. Speed-up is relative to the teacher, zero-shot, 4-bit on cuda.
116
+ <!-- /results -->
117
+
118
+ ### Financial tweets: an easier task
119
+
120
+ Sentiment of financial news tweets, 3 classes
121
+ ([Twitter Financial News Sentiment](https://huggingface.co/datasets/zeroshot/twitter-financial-news-sentiment),
122
+ MIT licence per the dataset card), reproduced by
123
+ [`examples/financial-tweets/run.sh`](https://github.com/Andreas-Hum/tinydistill/blob/main/examples/financial-tweets/run.sh). The validation split is the test set.
124
+
125
+ <!-- results: financial-tweets/results.md -->
126
+ | Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) | Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |
127
+ |---|---|---|---|---|---|---|---|---|---|
128
+ | teacher, zero-shot | cpu | 0.535 | 0.557 | n/a | 976.3 | n/a | 1.0 | 6175 | 1.0x |
129
+ | teacher, zero-shot | cuda | 0.535 | 0.557 | n/a | 33.7 | n/a | 29.6 | 3087 | 28.9x |
130
+ | student, PyTorch | cpu | 0.517 | 0.542 | 0.815 | 4.5 | 145.4 | 220.1 | 91 | 219.3x |
131
+ | upper bound: student on all gold labels | cpu | 0.867 | 0.824 | 0.531 | n/a | n/a | n/a | n/a | n/a |
132
+ | student, ONNX fp32 | cpu | 0.517 | 0.542 | 0.815 | 3.2 | 148.5 | 215.4 | 91 | 303.9x |
133
+ | student, ONNX int8 | cpu | 0.513 | 0.538 | 0.810 | 1.3 | 71.5 | 447.7 | 23 | 754.7x |
134
+
135
+ Task `financial-tweets`, 2388 gold-labelled test texts. Teacher: `Qwen/Qwen2.5-1.5B-Instruct`. Student: `sentence-transformers/all-MiniLM-L6-v2`. The student learns only from the teacher's soft labels on 9543 unlabelled training texts. The upper bound is the same student trained with cross-entropy on all 9543 gold training labels and no teacher; its speed equals the student's.
136
+ CPU rows use 2 threads. Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' model files. The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. Speed-up is relative to the teacher, zero-shot on cpu.
137
+ <!-- /results -->
138
+
139
+ ## Quickstart
140
+
141
+ ```sh
142
+ pip install "tinydistill[hf]"
143
+ curl -LO https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/examples/banking77/task.yaml
144
+ tinydistill run task.yaml --data hf:PolyAI/banking77:train --test hf:PolyAI/banking77:test \
145
+ --out runs/banking77 --int8
146
+ tinydistill predict runs/banking77/onnx "I still haven't received my new card"
147
+ ```
148
+
149
+ Labelling the full dataset with the default teacher wants a GPU. For a quick look on a CPU, add
150
+ `--teacher Qwen/Qwen2.5-0.5B-Instruct --limit 200 --test-limit 200`. `--limit` takes the first rows, and the
151
+ Banking77 splits are sorted by intent, so a limited run only sees a few intents.
152
+
153
+ `run` does every step. Each step also has its own command, and they all read and write the run directory:
154
+
155
+ | Command | What it does |
156
+ |---|---|
157
+ | `tinydistill label TASK.yaml --data D [--test T] --out RUN` | teacher scores every label of every text; cached, resumable |
158
+ | `tinydistill train RUN` | distils the student from the teacher's soft targets |
159
+ | `tinydistill export RUN [--int8]` | writes `RUN/onnx` (ONNX, tokenizer, labels) |
160
+ | `tinydistill eval RUN` | writes `RUN/results.md` and `RUN/results.json` |
161
+ | `tinydistill predict RUN/onnx TEXT... [--data FILE]` | classifies texts, prints JSON lines |
162
+
163
+ Data can be `.csv`, `.jsonl` or `hf:<dataset>[:<config>]:<split>` (needs the `hf` extra). Useful options:
164
+ `--teacher` (any Hugging Face causal LM), `--load-4bit` (bitsandbytes, CUDA only: `pip install "tinydistill[4bit]"`),
165
+ `--student` (any Hugging Face encoder), `--epochs`, `--lr`, `--device`, `--threads`, and `--cache-dir` (runs that
166
+ share it label each text only once). See `tinydistill <command> --help`.
167
+
168
+ **Few labels.** With `--gold-per-class K`, the student also sees the gold labels of K randomly chosen training
169
+ examples per class, through a cross-entropy term (weight `--ce-weight`, default 1) added to the distillation
170
+ loss. `--upper-bound` additionally trains the same student on all gold training labels without the teacher,
171
+ and the results table gets an upper-bound row: how far the distilled student is from fully supervised training.
172
+
173
+ From Python:
174
+
175
+ ```python
176
+ from tinydistill import Predictor
177
+
178
+ clf = Predictor("runs/banking77/onnx") # uses model.int8.onnx when present
179
+ clf.predict(["How do I top up my card?"]) # list of label names
180
+ clf.predict_proba(["How do I top up my card?"]) # numpy array, one row per text
181
+ ```
182
+
183
+ ### The task file
184
+
185
+ ```yaml
186
+ name: banking77
187
+ text_field: text # column holding the input text
188
+ label_field: label # column holding gold labels (ids, or integers indexing `labels`)
189
+ prompt: |-
190
+ You are classifying messages that customers sent to their bank's support chat.
191
+ Choose the single intent from this list that best matches the message:
192
+ {labels}
193
+
194
+ Message: {text}
195
+
196
+ Reply with the intent exactly as written in the list.
197
+ labels:
198
+ - activate_my_card # a bare id: its name is derived, "activate my card"
199
+ - card_arrival
200
+ # ...
201
+ ```
202
+
203
+ `{labels}` (required) becomes a bulleted list of the label names, and `{text}` is the input. The name is what
204
+ the teacher scores. Each label can be a bare id as above, `id: name`, or carry a description that is shown in
205
+ the list (`- bullish: positive for the stock`) but not scored:
206
+
207
+ ```yaml
208
+ labels:
209
+ bearish:
210
+ description: negative for the stock, company or market
211
+ lost_card: lost or stolen card
212
+ ```
213
+
214
+ ## How the label scoring works
215
+
216
+ The teacher never generates free text. For an input x, the prompt is rendered with the model's chat template
217
+ (user message = the instruction with the label list, generation prompt for the assistant). A continuation is one
218
+ way of writing a label followed by the end-of-turn token, `c_1 ... c_m`, with log-probability
219
+
220
+ log p(c | prompt(x)) = sum_j log p(c_j | prompt(x), c_1 ... c_(j-1))
221
+
222
+ Multi-token labels are handled exactly, and the end-of-turn token keeps a label that is a prefix of another
223
+ (`card` versus `card arrival`) from winning by stopping early. Models do not always write a label the way it is
224
+ listed: Qwen2.5 tends to answer a sentiment question with "Bullish" even when the list says "bullish". So each
225
+ label is scored over its common surface forms, the name as written, lowercase, Capitalised and UPPERCASE, each
226
+ with and without a leading space (forms that tokenise identically count once):
227
+
228
+ score(label) = log sum over forms c of p(c | prompt(x))
229
+
230
+ This is verbaliser coverage: it only counts more ways of writing the same answer, and there is no calibration or
231
+ prior correction. All forms share the prefix tree below, but with many labels they still add up; a task file can
232
+ list fewer (`surface_forms: [lowercase, capitalised]` drops UPPERCASE, which tokenises into many pieces, and
233
+ `surface_forms: []` scores the name as written only). A softmax over the label scores gives the soft targets. The prompt lists each label in its
234
+ primary form, the name as written. The scores are cached as JSON lines keyed by a hash of the text, in a file
235
+ named after a fingerprint of the teacher, prompt and continuations, so an interrupted run resumes and a changed
236
+ prompt never reuses old scores.
237
+
238
+ To keep this fast, the part of the prompt before the text is run once and its key/value cache is kept. Each
239
+ text then needs a single forward pass that covers the text and all labels together: the tokens of all continuations
240
+ (every surface form of every label) form a prefix tree, so continuations that start alike, such as
241
+ `card arrival` and `card linking`, share nodes, and a 4D attention mask lets each tree node attend only to the
242
+ prompt, the text and its own ancestors. There is no padding and no per-label copy of the cache. Tests check the
243
+ scores against a plain forward pass per continuation.
244
+
245
+ The student minimises KL(teacher || student) over the training texts, plus `--ce-weight` times the
246
+ cross-entropy on the K gold labels per class chosen by `--gold-per-class`, if any (standard knowledge
247
+ distillation, Hinton et al., 2015).
248
+
249
+ ## Limitations
250
+
251
+ - **The teacher caps the student.** The student learns to imitate the teacher, mistakes included. A small LLM
252
+ choosing zero-shot among many fine-grained labels can be weak: Qwen2.5-0.5B-Instruct, for example, often
253
+ confuses related Banking77 intents. A bigger teacher (try `--load-4bit` with a 7B model) or a better prompt
254
+ usually helps more than anything on the student side.
255
+ - **Scores are sums, not averages.** Longer verbalisations get lower scores, so very uneven label lengths bias the
256
+ teacher. Keep verbalisations short and of similar length.
257
+ - **Only case and spacing variants are counted.** Plurals, synonyms or rephrasings of a label are not; keep label
258
+ names short and close to how the teacher would answer.
259
+ - **A too-small teacher can collapse.** Qwen2.5-0.5B-Instruct, for example, puts nearly every text into one or two
260
+ labels on the financial-tweets task, whatever the scoring; use 1.5B or larger.
261
+ - **Prompt and label order matter.** Zero-shot LLMs are sensitive to wording and to the order of the options.
262
+ - **Teacher cost grows with labels.** Every text is scored against every surface form of every label (shared
263
+ prefixes are computed once), one text at a time.
264
+ - **Single-label classification only.** Inputs are truncated to 128 tokens by default (`--max-length`).
265
+ - **Timings depend on the machine.** The results table states the threads and devices used; rerun `eval` on your
266
+ own hardware.
267
+ - ONNX export uses PyTorch's TorchScript-based exporter.
268
+
269
+ ## Development
270
+
271
+ ```sh
272
+ python -m venv .venv && . .venv/bin/activate
273
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
274
+ pip install -e ".[dev]"
275
+ ruff check . && ruff format --check . && pytest
276
+ ```
277
+
278
+ The tests run on a CPU in a few seconds with tiny random models from `hf-internal-testing`. They check the label
279
+ scores against a direct computation, the distillation loss and metrics against hand-computed values, cache
280
+ resumption, that the ONNX export reproduces the PyTorch logits, and the whole CLI end to end.
281
+
282
+ ## References
283
+
284
+ - G. Hinton, O. Vinyals, J. Dean. [Distilling the Knowledge in a Neural Network](https://arxiv.org/abs/1503.02531). arXiv:1503.02531, 2015.
285
+ - T. Schick, H. Schütze. [Exploiting Cloze Questions for Few Shot Text Classification and Natural Language Inference](https://arxiv.org/abs/2001.07676). EACL, 2021. (Verbalisers.)
286
+ - I. Casanueva, T. Temčinas, D. Gerz, M. Henderson, I. Vulić. [Efficient Intent Detection with Dual Sentence Encoders](https://arxiv.org/abs/2003.04807). NLP4ConvAI Workshop, 2020. (Banking77.)
287
+ - W. Wang, F. Wei, L. Dong, H. Bao, N. Yang, M. Zhou. [MiniLM: Deep Self-Attention Distillation for Task-Agnostic Compression of Pre-Trained Transformers](https://arxiv.org/abs/2002.10957). NeurIPS, 2020.
288
+ - N. Reimers, I. Gurevych. [Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks](https://arxiv.org/abs/1908.10084). EMNLP, 2019.
289
+ - Qwen Team. [Qwen2.5 Technical Report](https://arxiv.org/abs/2412.15115). arXiv:2412.15115, 2024.
290
+
291
+ ## Licence
292
+
293
+ [MIT](https://github.com/Andreas-Hum/tinydistill/blob/main/LICENSE). The Banking77 dataset is CC BY 4.0 (PolyAI); models are downloaded from the Hugging Face Hub under
294
+ their own licences.
@@ -0,0 +1,13 @@
1
+ tinydistill/__init__.py,sha256=h0LIgsGwV4oESxjmRmQNXjxh7xHObNPe1FnwismRWts,159
2
+ tinydistill/cli.py,sha256=cIf2_pJAXeiEKlXiqutUV_XWMgahsutETinwr-e1NZg,16694
3
+ tinydistill/data.py,sha256=VpxIeEGId6birv1fwxb3-r9qKjmg1euLJN3VWWTp5wo,3452
4
+ tinydistill/evaluate.py,sha256=-MoXGPFj6Aj44MLtr-iXQM7d00LTJXrFLA-2757OAxA,5753
5
+ tinydistill/export.py,sha256=qPypHTcDZIZj_Pz2qwR54yPlBBfo2BVAzQHW_Tp4Iqo,3833
6
+ tinydistill/student.py,sha256=zmb-IU67szW5TxPh3jIzT5ykY72UcuPe_ugCWubATFM,5145
7
+ tinydistill/task.py,sha256=XsZHlLrLVW22pLrV9wCVrUdoAMnLE_BtvkbAykTMVFY,4635
8
+ tinydistill/teacher.py,sha256=91aYoYPm_x9Eil_s2mYqDPmv_8RNIuSFZV0fh1CnNKk,12338
9
+ tinydistill-0.2.0.dist-info/METADATA,sha256=E7sTZN-jOSOnFI98TeEymZ2GBFk50s1G088Y_giICSw,19398
10
+ tinydistill-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ tinydistill-0.2.0.dist-info/entry_points.txt,sha256=khfieEGrxSXJBWXEN2ATy8J_KO9--eFulic6a4yLEt0,53
12
+ tinydistill-0.2.0.dist-info/licenses/LICENSE,sha256=G7EoDl2dH0OXmLiIMUnEsW3HXrlTA1sVEwVjThlLQC4,1068
13
+ tinydistill-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tinydistill = tinydistill.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas-Hum
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.