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/__init__.py +6 -0
- tinydistill/cli.py +459 -0
- tinydistill/data.py +92 -0
- tinydistill/evaluate.py +140 -0
- tinydistill/export.py +103 -0
- tinydistill/student.py +140 -0
- tinydistill/task.py +108 -0
- tinydistill/teacher.py +272 -0
- tinydistill-0.2.0.dist-info/METADATA +294 -0
- tinydistill-0.2.0.dist-info/RECORD +13 -0
- tinydistill-0.2.0.dist-info/WHEEL +4 -0
- tinydistill-0.2.0.dist-info/entry_points.txt +2 -0
- tinydistill-0.2.0.dist-info/licenses/LICENSE +21 -0
tinydistill/__init__.py
ADDED
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)
|