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.
@@ -0,0 +1,6 @@
1
+ """tinydistill: distil an LLM classification prompt into a small, fast model."""
2
+
3
+ from .export import Predictor
4
+
5
+ __all__ = ["Predictor"]
6
+ __version__ = "0.2.0"
tinydistill/cli.py ADDED
@@ -0,0 +1,459 @@
1
+ """Command-line interface: label -> train -> export -> eval, or `run` for all of them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from .task import load_task
15
+
16
+ DEFAULT_TEACHER = "Qwen/Qwen2.5-1.5B-Instruct"
17
+
18
+
19
+ def log(msg: str) -> None:
20
+ print(msg, file=sys.stderr, flush=True)
21
+
22
+
23
+ def _read_jsonl(path: Path) -> list[dict]:
24
+ if not path.exists():
25
+ return []
26
+ with path.open(encoding="utf-8") as f:
27
+ return [json.loads(line) for line in f if line.strip()]
28
+
29
+
30
+ def _meta(run: Path, update: dict | None = None) -> dict:
31
+ path = run / "meta.json"
32
+ meta = json.loads(path.read_text()) if path.exists() else {}
33
+ if update:
34
+ meta.update(update)
35
+ path.write_text(json.dumps(meta, indent=2))
36
+ return meta
37
+
38
+
39
+ def _softmax(scores: list[float]) -> list[float]:
40
+ m = max(scores)
41
+ e = [math.exp(s - m) for s in scores]
42
+ z = sum(e)
43
+ return [x / z for x in e]
44
+
45
+
46
+ def _argmax(xs) -> int:
47
+ return max(range(len(xs)), key=xs.__getitem__)
48
+
49
+
50
+ # ---------- steps ----------
51
+
52
+
53
+ def cmd_label(a: argparse.Namespace) -> None:
54
+ from .data import read_examples
55
+ from .teacher import ScoreCache, iter_unique, load_teacher
56
+
57
+ task = load_task(a.task)
58
+ run = Path(a.out)
59
+ run.mkdir(parents=True, exist_ok=True)
60
+ (run / "task.yaml").write_text(
61
+ yaml.safe_dump(task.to_dict(), sort_keys=False, allow_unicode=True), encoding="utf-8"
62
+ )
63
+ log(f"loading teacher {a.teacher}")
64
+ scorer = load_teacher(a.teacher, task, a.device, a.load_4bit)
65
+ fp = scorer.fingerprint()
66
+ cache_dir = Path(a.cache_dir) if a.cache_dir else run
67
+ cache_dir.mkdir(parents=True, exist_ok=True)
68
+ cache = ScoreCache(cache_dir / f"teacher-{fp}.jsonl")
69
+ if cache.scores:
70
+ log(f"resuming: {len(cache.scores)} texts already scored")
71
+ seconds, scored = 0.0, 0
72
+ for split, spec, limit in (("train", a.data, a.limit), ("test", a.test, a.test_limit)):
73
+ if not spec:
74
+ continue
75
+ t0 = time.perf_counter()
76
+
77
+ def progress(n: int, split=split, t0=t0) -> None:
78
+ if n % 100 == 0:
79
+ log(f"{split}: scored {n} new texts ({n / (time.perf_counter() - t0):.1f}/s)")
80
+
81
+ texts = iter_unique(e.text for e in read_examples(spec, task, limit, with_gold=False))
82
+ n = cache.fill(scorer, texts, progress)
83
+ seconds += time.perf_counter() - t0
84
+ scored += n
85
+ rows = 0
86
+ with (run / f"{split}.jsonl").open("w", encoding="utf-8") as f:
87
+ for e in read_examples(spec, task, limit):
88
+ f.write(json.dumps({"text": e.text, "gold": e.gold, "scores": cache.get(e.text)}) + "\n")
89
+ rows += 1
90
+ log(f"{split}: {rows} texts ({n} scored now, the rest from the cache)")
91
+ _meta(
92
+ run,
93
+ {
94
+ "teacher": a.teacher,
95
+ "load_4bit": a.load_4bit,
96
+ "teacher_device": str(scorer.device),
97
+ "fingerprint": fp,
98
+ "data": a.data,
99
+ "test": a.test,
100
+ "label_seconds": round(seconds, 1),
101
+ "label_texts_scored": scored,
102
+ },
103
+ )
104
+
105
+
106
+ def cmd_train(a: argparse.Namespace) -> None:
107
+ import torch
108
+
109
+ from .student import few_gold, train_student
110
+ from .teacher import pick_device
111
+
112
+ run = Path(a.run)
113
+ task = load_task(run / "task.yaml")
114
+ rows = _read_jsonl(run / "train.jsonl")
115
+ if not rows:
116
+ raise SystemExit(f"{run}/train.jsonl is missing or empty: run `tinydistill label` first")
117
+ texts = [r["text"] for r in rows]
118
+ all_gold = [r["gold"] for r in rows]
119
+ if (a.gold_per_class or a.upper_bound) and not any(g is not None for g in all_gold):
120
+ raise SystemExit("--gold-per-class and --upper-bound need gold labels in the training data")
121
+ gold = few_gold(all_gold, a.gold_per_class, a.seed) if a.gold_per_class else None
122
+ n_gold = sum(g is not None for g in gold) if gold else 0
123
+ device = pick_device(a.device)
124
+ probs = torch.tensor([_softmax(r["scores"]) for r in rows])
125
+ hp = (a.epochs, a.batch_size, a.lr, a.max_length, device, a.seed, log)
126
+
127
+ log(f"distilling {a.student} on {len(rows)} texts, {n_gold} with gold labels ({device})")
128
+ t0 = time.perf_counter()
129
+ train_student(texts, probs, task.labels, run / "student", a.student, gold, a.ce_weight, *hp)
130
+ meta = {
131
+ "student": a.student,
132
+ "epochs": a.epochs,
133
+ "lr": a.lr,
134
+ "batch_size": a.batch_size,
135
+ "gold_per_class": a.gold_per_class,
136
+ "n_gold": n_gold,
137
+ "ce_weight": a.ce_weight if n_gold else 0.0,
138
+ "student_device": device,
139
+ "train_seconds": round(time.perf_counter() - t0, 1),
140
+ "upper_bound": False,
141
+ }
142
+ if a.upper_bound:
143
+ keep = [i for i, g in enumerate(all_gold) if g is not None]
144
+ log(f"training the upper bound: {a.student} on all {len(keep)} gold training labels, no teacher")
145
+ train_student(
146
+ [texts[i] for i in keep],
147
+ None,
148
+ task.labels,
149
+ run / "student-gold",
150
+ a.student,
151
+ [all_gold[i] for i in keep],
152
+ 0.0,
153
+ *hp,
154
+ )
155
+ meta.update(upper_bound=True, n_gold_upper_bound=len(keep))
156
+ _meta(run, meta)
157
+
158
+
159
+ def cmd_export(a: argparse.Namespace) -> None:
160
+ from .export import export_onnx
161
+
162
+ run = Path(a.run)
163
+ for p in export_onnx(run / "student", run / "onnx", int8=a.int8):
164
+ log(f"wrote {p} ({p.stat().st_size / 1e6:.1f} MB)")
165
+
166
+
167
+ def cmd_eval(a: argparse.Namespace) -> None:
168
+ import torch
169
+
170
+ from .evaluate import accuracy, bench, dir_size_mb, environment, hf_weights_mb, macro_f1, markdown_table
171
+ from .export import Predictor
172
+ from .student import load_student, student_logits
173
+ from .teacher import load_teacher
174
+
175
+ run = Path(a.run)
176
+ meta = _meta(run)
177
+ task = load_task(run / "task.yaml")
178
+ test = _read_jsonl(run / "test.jsonl")
179
+ train = _read_jsonl(run / "train.jsonl")
180
+ labelled = [r for r in test if r["gold"] is not None]
181
+ gold = [r["gold"] for r in labelled]
182
+ texts = [r["text"] for r in labelled]
183
+ teacher_pred = [_argmax(r["scores"]) for r in labelled]
184
+ bench_texts = [r["text"] for r in (test or train)]
185
+ if not bench_texts:
186
+ raise SystemExit("no texts to evaluate on: label a --test set (or training data) first")
187
+ threads = torch.get_num_threads()
188
+
189
+ def quality(pred: list[int]) -> dict:
190
+ if not gold:
191
+ return {}
192
+ return {
193
+ "accuracy": accuracy(pred, gold),
194
+ "macro_f1": macro_f1(pred, gold),
195
+ "agreement": accuracy(pred, teacher_pred),
196
+ }
197
+
198
+ models: list[dict] = []
199
+ teacher_q = quality(teacher_pred)
200
+ teacher_q.pop("agreement", None)
201
+ devices = [d for d in (a.teacher_devices or meta.get("teacher_device", "cpu")).split(",") if d]
202
+ for dev in devices:
203
+ row = {
204
+ "role": "teacher",
205
+ "name": "teacher, zero-shot" + (", 4-bit" if meta.get("load_4bit") else ""),
206
+ "device": dev,
207
+ **teacher_q,
208
+ "size_mb": hf_weights_mb(meta["teacher"]),
209
+ }
210
+ if not a.skip_teacher_bench:
211
+ log(f"benchmarking the teacher on {dev}")
212
+ scorer = load_teacher(meta["teacher"], task, dev, meta.get("load_4bit", False))
213
+ row["params_m"] = sum(p.numel() for p in scorer.model.parameters()) / 1e6
214
+ row["size_mb"] = scorer.model.get_memory_footprint() / 1e6 # weights as loaded (e.g. 4-bit)
215
+ row.update(
216
+ bench(
217
+ lambda b, s=scorer: [s.score(t) for t in b],
218
+ bench_texts,
219
+ a.teacher_reps,
220
+ )
221
+ )
222
+ del scorer
223
+ if dev.startswith("cuda"):
224
+ torch.cuda.empty_cache()
225
+ models.append(row)
226
+
227
+ student_dir = run / "student"
228
+ if student_dir.exists():
229
+ log("evaluating the student (PyTorch, CPU)")
230
+ model, tok = load_student(student_dir, "cpu")
231
+ pred = student_logits(model, tok, texts).argmax(-1).tolist() if texts else []
232
+ row = {
233
+ "role": "student",
234
+ "name": "student, PyTorch",
235
+ "device": "cpu",
236
+ **quality(pred),
237
+ "params_m": sum(p.numel() for p in model.parameters()) / 1e6,
238
+ "size_mb": dir_size_mb(list(student_dir.glob("*.safetensors")) + list(student_dir.glob("*.bin"))),
239
+ }
240
+ row.update(
241
+ bench(
242
+ lambda b: student_logits(model, tok, b, batch_size=32),
243
+ bench_texts,
244
+ a.reps,
245
+ max(3, a.reps // 5),
246
+ )
247
+ )
248
+ models.append(row)
249
+ gold_dir = run / "student-gold"
250
+ if gold_dir.exists():
251
+ log("evaluating the upper bound (PyTorch, CPU)")
252
+ model, tok = load_student(gold_dir, "cpu")
253
+ pred = student_logits(model, tok, texts).argmax(-1).tolist() if texts else []
254
+ models.append(
255
+ {
256
+ "role": "upper bound",
257
+ "name": "upper bound: student on all gold labels",
258
+ "device": "cpu",
259
+ **quality(pred),
260
+ }
261
+ )
262
+ for int8 in (False, True):
263
+ f = run / "onnx" / ("model.int8.onnx" if int8 else "model.onnx")
264
+ if not f.exists():
265
+ continue
266
+ log(f"evaluating {f.name} (onnxruntime, CPU)")
267
+ pr = Predictor(run / "onnx", int8=int8, threads=threads)
268
+ pred = pr.logits(texts).argmax(-1).tolist() if texts else []
269
+ row = {
270
+ "role": "student",
271
+ "name": f"student, ONNX {'int8' if int8 else 'fp32'}",
272
+ "device": "cpu",
273
+ **quality(pred),
274
+ "size_mb": dir_size_mb([f]),
275
+ }
276
+ row.update(bench(lambda b, pr=pr: pr.logits(b), bench_texts, a.reps, max(3, a.reps // 5)))
277
+ models.append(row)
278
+
279
+ results = {
280
+ "task": task.name,
281
+ "teacher": meta.get("teacher"),
282
+ "student": meta.get("student"),
283
+ "data": {
284
+ "train": meta.get("data"),
285
+ "test": meta.get("test"),
286
+ "n_train": len(train),
287
+ "n_test": len(labelled),
288
+ },
289
+ "training": {
290
+ k: meta.get(k)
291
+ for k in (
292
+ "epochs",
293
+ "lr",
294
+ "batch_size",
295
+ "gold_per_class",
296
+ "n_gold",
297
+ "upper_bound",
298
+ "n_gold_upper_bound",
299
+ "label_texts_scored",
300
+ "ce_weight",
301
+ "student_device",
302
+ "train_seconds",
303
+ "teacher_device",
304
+ "label_seconds",
305
+ "load_4bit",
306
+ )
307
+ },
308
+ "environment": environment(threads),
309
+ "models": models,
310
+ }
311
+ (run / "results.json").write_text(json.dumps(results, indent=2))
312
+ md = markdown_table(results)
313
+ (run / "results.md").write_text(md)
314
+ print(md)
315
+
316
+
317
+ def cmd_predict(a: argparse.Namespace) -> None:
318
+ from .export import Predictor
319
+
320
+ texts = list(a.texts)
321
+ if a.data:
322
+ from .data import read_examples
323
+ from .task import Task
324
+
325
+ field = Task("", ["a", "b"], ["a", "b"], "{text}", text_field=a.text_field, label_field=None)
326
+ texts += [e.text for e in read_examples(a.data, field)]
327
+ if not texts:
328
+ raise SystemExit("give texts as arguments or --data FILE")
329
+ pr = Predictor(a.model, int8=False if a.fp32 else None)
330
+ probs = pr.predict_proba(texts)
331
+ for t, p in zip(texts, probs, strict=True):
332
+ i = int(p.argmax())
333
+ print(
334
+ json.dumps(
335
+ {"text": t, "label": pr.labels[i], "confidence": round(float(p[i]), 4)}, ensure_ascii=False
336
+ )
337
+ )
338
+
339
+
340
+ def cmd_run(a: argparse.Namespace) -> None:
341
+ import gc
342
+
343
+ import torch
344
+
345
+ a.run = a.out
346
+ cmd_label(a)
347
+ gc.collect() # release the teacher before training
348
+ if torch.cuda.is_available():
349
+ torch.cuda.empty_cache()
350
+ cmd_train(a)
351
+ cmd_export(a)
352
+ cmd_eval(a)
353
+
354
+
355
+ # ---------- parser ----------
356
+
357
+
358
+ def build_parser() -> argparse.ArgumentParser:
359
+ common = argparse.ArgumentParser(add_help=False)
360
+ common.add_argument("--device", default="auto", help="auto, cpu or cuda (teacher labelling and training)")
361
+ common.add_argument("--threads", type=int, help="CPU threads for torch and onnxruntime")
362
+
363
+ label = argparse.ArgumentParser(add_help=False)
364
+ label.add_argument("task", help="task YAML file")
365
+ label.add_argument(
366
+ "--data", required=True, help="unlabelled texts: .csv, .jsonl or hf:<dataset>[:<config>]:<split>"
367
+ )
368
+ label.add_argument("--test", help="optional gold-labelled test set, same formats")
369
+ label.add_argument("--out", required=True, help="run directory")
370
+ label.add_argument("--teacher", default=DEFAULT_TEACHER, help="Hugging Face causal LM")
371
+ label.add_argument(
372
+ "--load-4bit", action="store_true", help="load the teacher in 4 bit (bitsandbytes, CUDA)"
373
+ )
374
+ label.add_argument(
375
+ "--cache-dir",
376
+ help="where teacher scores are cached (default: the run directory); "
377
+ "runs that share it label each text once",
378
+ )
379
+ label.add_argument("--limit", type=int, help="use only the first N training texts")
380
+ label.add_argument("--test-limit", type=int, help="use only the first N test texts")
381
+
382
+ train = argparse.ArgumentParser(add_help=False)
383
+ train.add_argument(
384
+ "--student", default="sentence-transformers/all-MiniLM-L6-v2", help="Hugging Face encoder"
385
+ )
386
+ train.add_argument("--epochs", type=int, default=5)
387
+ train.add_argument("--batch-size", type=int, default=32)
388
+ train.add_argument("--lr", type=float, default=1e-4)
389
+ train.add_argument("--max-length", type=int, default=128)
390
+ train.add_argument(
391
+ "--gold-per-class",
392
+ type=int,
393
+ default=0,
394
+ metavar="K",
395
+ help="also use the gold labels of K training examples per class (cross-entropy term)",
396
+ )
397
+ train.add_argument("--ce-weight", type=float, default=1.0, help="weight of that cross-entropy term")
398
+ train.add_argument(
399
+ "--upper-bound",
400
+ action="store_true",
401
+ help="also train the student on all gold training labels, without the teacher",
402
+ )
403
+ train.add_argument("--seed", type=int, default=0)
404
+
405
+ export = argparse.ArgumentParser(add_help=False)
406
+ export.add_argument("--int8", action="store_true", help="also write an int8 dynamically quantised model")
407
+
408
+ ev = argparse.ArgumentParser(add_help=False)
409
+ ev.add_argument(
410
+ "--teacher-devices",
411
+ help="comma-separated devices to time the teacher on (default: the labelling device)",
412
+ )
413
+ ev.add_argument("--skip-teacher-bench", action="store_true", help="report teacher quality only")
414
+ ev.add_argument("--reps", type=int, default=50, help="timed runs per student benchmark at batch 1")
415
+ ev.add_argument("--teacher-reps", type=int, default=10, help="timed runs of the teacher (batch 1)")
416
+
417
+ run_dir = argparse.ArgumentParser(add_help=False)
418
+ run_dir.add_argument("run", help="run directory")
419
+
420
+ p = argparse.ArgumentParser(
421
+ prog="tinydistill", description="Distil an LLM classification prompt into a small, fast model."
422
+ )
423
+ sub = p.add_subparsers(dest="cmd", required=True)
424
+ sub.add_parser("label", parents=[label, common], help="score every label with the teacher").set_defaults(
425
+ fn=cmd_label
426
+ )
427
+ sub.add_parser("train", parents=[run_dir, train, common], help="distil the student").set_defaults(
428
+ fn=cmd_train
429
+ )
430
+ sub.add_parser(
431
+ "export", parents=[run_dir, export, common], help="export the student to ONNX"
432
+ ).set_defaults(fn=cmd_export)
433
+ sub.add_parser("eval", parents=[run_dir, ev, common], help="quality and speed table").set_defaults(
434
+ fn=cmd_eval
435
+ )
436
+ pr = sub.add_parser("predict", parents=[common], help="classify texts with an exported model")
437
+ pr.add_argument("model", help="directory written by `export` (run/onnx)")
438
+ pr.add_argument("texts", nargs="*")
439
+ pr.add_argument("--data", help="file with texts (.csv, .jsonl or hf:...)")
440
+ pr.add_argument("--text-field", default="text")
441
+ pr.add_argument("--fp32", action="store_true", help="use the fp32 model even if an int8 one exists")
442
+ pr.set_defaults(fn=cmd_predict)
443
+ sub.add_parser(
444
+ "run", parents=[label, train, export, ev, common], help="label, train, export and eval"
445
+ ).set_defaults(fn=cmd_run)
446
+ return p
447
+
448
+
449
+ def main(argv: list[str] | None = None) -> None:
450
+ a = build_parser().parse_args(argv)
451
+ if a.threads:
452
+ import torch
453
+
454
+ torch.set_num_threads(a.threads)
455
+ a.fn(a)
456
+
457
+
458
+ if __name__ == "__main__":
459
+ main()
tinydistill/data.py ADDED
@@ -0,0 +1,92 @@
1
+ """Read texts (and optional gold labels) from CSV, JSONL or a Hugging Face dataset, as a stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from itertools import islice
10
+ from pathlib import Path
11
+
12
+ from .task import Task
13
+
14
+
15
+ @dataclass
16
+ class Example:
17
+ text: str
18
+ gold: int | None = None # index into Task.labels
19
+
20
+
21
+ def _gold_index(value: object, task: Task, names: list[str] | None) -> int | None:
22
+ """Map a raw label value to a class index: label names by name, integers by position."""
23
+ if value is None or value == "":
24
+ return None
25
+ if isinstance(value, int) and not isinstance(value, bool):
26
+ value = names[value] if names else value # HF ClassLabel: int -> name first
27
+ if isinstance(value, int):
28
+ if not 0 <= value < len(task.labels):
29
+ raise ValueError(f"label index {value} out of range")
30
+ return value
31
+ s = str(value)
32
+ if s in task.labels:
33
+ return task.labels.index(s)
34
+ if s in task.verbalisations:
35
+ return task.verbalisations.index(s)
36
+ if s.isdigit() and int(s) < len(task.labels):
37
+ return int(s)
38
+ raise ValueError(f"unknown label {s!r}; expected one of the task's label names")
39
+
40
+
41
+ def _rows(spec: str) -> tuple[Iterator[dict], dict[str, list[str]] | None]:
42
+ if spec.startswith("hf:"):
43
+ # hf:<dataset>[:<config>]:<split>
44
+ try:
45
+ from datasets import load_dataset
46
+ except ImportError as e:
47
+ raise SystemExit("Reading Hugging Face datasets needs: pip install 'tinydistill[hf]'") from e
48
+ parts = spec[3:].split(":")
49
+ if len(parts) == 2:
50
+ name, config, split = parts[0], None, parts[1]
51
+ elif len(parts) == 3:
52
+ name, config, split = parts
53
+ else:
54
+ raise ValueError(f"bad dataset spec {spec!r}; use hf:<dataset>[:<config>]:<split>")
55
+ ds = load_dataset(name, config, split=split, streaming=True)
56
+ return iter(ds), None if ds.features is None else _class_names(ds.features)
57
+ path = Path(spec)
58
+ if path.suffix == ".csv":
59
+
60
+ def gen() -> Iterator[dict]:
61
+ with path.open(newline="", encoding="utf-8") as f:
62
+ yield from csv.DictReader(f)
63
+
64
+ return gen(), None
65
+ if path.suffix in (".jsonl", ".json"):
66
+
67
+ def gen() -> Iterator[dict]:
68
+ with path.open(encoding="utf-8") as f:
69
+ for line in f:
70
+ if line.strip():
71
+ yield json.loads(line)
72
+
73
+ return gen(), None
74
+ raise ValueError(f"unsupported data file {spec!r}; use .csv, .jsonl or hf:<dataset>:<split>")
75
+
76
+
77
+ def _class_names(features: dict) -> dict[str, list[str]]:
78
+ return {k: list(f.names) for k, f in features.items() if hasattr(f, "names")}
79
+
80
+
81
+ def read_examples(
82
+ spec: str, task: Task, limit: int | None = None, with_gold: bool = True
83
+ ) -> Iterator[Example]:
84
+ rows, class_names = _rows(spec)
85
+ names = class_names.get(task.label_field or "") if class_names else None
86
+ for row in islice(rows, limit):
87
+ if task.text_field not in row:
88
+ raise KeyError(f"{spec}: no field {task.text_field!r} (set text_field in the task file)")
89
+ gold = None
90
+ if with_gold and task.label_field and task.label_field in row:
91
+ gold = _gold_index(row[task.label_field], task, names)
92
+ yield Example(str(row[task.text_field]), gold)