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,140 @@
1
+ """Quality metrics, latency benchmarks and the results table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import statistics
8
+ import time
9
+ from collections.abc import Callable, Sequence
10
+ from pathlib import Path
11
+
12
+ import torch
13
+
14
+
15
+ def accuracy(pred: Sequence[int], gold: Sequence[int]) -> float:
16
+ return sum(p == g for p, g in zip(pred, gold, strict=True)) / len(gold) if gold else float("nan")
17
+
18
+
19
+ def macro_f1(pred: Sequence[int], gold: Sequence[int]) -> float:
20
+ """Unweighted mean F1 over the classes that occur in gold or pred (as scikit-learn's 'macro')."""
21
+ classes = set(gold) | set(pred)
22
+ if not classes:
23
+ return float("nan")
24
+ f1s = []
25
+ for c in classes:
26
+ tp = sum(p == c and g == c for p, g in zip(pred, gold, strict=True))
27
+ fp = sum(p == c and g != c for p, g in zip(pred, gold, strict=True))
28
+ fn = sum(p != c and g == c for p, g in zip(pred, gold, strict=True))
29
+ f1s.append(2 * tp / (2 * tp + fp + fn) if tp else 0.0)
30
+ return sum(f1s) / len(f1s)
31
+
32
+
33
+ def time_calls(fn: Callable[[list[str]], object], texts: list[str], batch: int, reps: int) -> float:
34
+ """Median wall time (ms) of fn on `reps` different batches of `batch` texts, after one warm-up call."""
35
+
36
+ def batch_at(i: int) -> list[str]:
37
+ return [texts[(i * batch + j) % len(texts)] for j in range(batch)]
38
+
39
+ fn(batch_at(reps)) # warm-up on a batch that is not timed
40
+ times = []
41
+ for i in range(reps):
42
+ b = batch_at(i)
43
+ t0 = time.perf_counter()
44
+ fn(b)
45
+ times.append((time.perf_counter() - t0) * 1000)
46
+ return statistics.median(times)
47
+
48
+
49
+ def bench(fn: Callable[[list[str]], object], texts: list[str], reps1: int, reps32: int = 0) -> dict:
50
+ """Latency at batch 1 and (if reps32 > 0) batch 32. Throughput comes from batch 32 when measured,
51
+ otherwise from batch 1 (for models that process one text at a time)."""
52
+ out = {"latency_ms_b1": time_calls(fn, texts, 1, reps1)}
53
+ if reps32:
54
+ out["latency_ms_b32"] = time_calls(fn, texts, 32, reps32)
55
+ out["throughput_per_s"] = 32 / (out["latency_ms_b32"] / 1000)
56
+ else:
57
+ out["throughput_per_s"] = 1000 / out["latency_ms_b1"]
58
+ return out
59
+
60
+
61
+ def dir_size_mb(paths: Sequence[Path]) -> float:
62
+ return sum(p.stat().st_size for p in paths if p.exists()) / 1e6
63
+
64
+
65
+ def hf_weights_mb(name: str) -> float | None:
66
+ """Size of a Hub model's weight files in the local cache (or of a local directory)."""
67
+ p = Path(name)
68
+ if not p.exists():
69
+ try:
70
+ from huggingface_hub import snapshot_download
71
+
72
+ p = Path(snapshot_download(name, local_files_only=True))
73
+ except Exception:
74
+ return None
75
+ files = list(p.glob("*.safetensors")) or list(p.glob("*.bin"))
76
+ return dir_size_mb(files) if files else None
77
+
78
+
79
+ def environment(threads: int) -> dict:
80
+ return {
81
+ "cpu": platform.processor() or platform.machine(),
82
+ "cpu_threads": threads,
83
+ "torch": torch.__version__,
84
+ "gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
85
+ "omp_num_threads": os.environ.get("OMP_NUM_THREADS"),
86
+ }
87
+
88
+
89
+ def _fmt(v, spec: str) -> str:
90
+ if v is None or (isinstance(v, float) and v != v):
91
+ return "n/a"
92
+ return format(v, spec)
93
+
94
+
95
+ def markdown_table(results: dict) -> str:
96
+ rows = results["models"]
97
+ ref = next((r for r in rows if r["role"] == "teacher" and r.get("latency_ms_b1")), None)
98
+ head = (
99
+ "| Model | Device | Accuracy | Macro-F1 | Agreement with teacher | Latency, batch 1 (ms) "
100
+ "| Latency, batch 32 (ms) | Throughput (texts/s) | Size (MB) | Speed-up, batch 1 |\n"
101
+ "|---|---|---|---|---|---|---|---|---|---|\n"
102
+ )
103
+ lines = []
104
+ for r in rows:
105
+ speed = ref["latency_ms_b1"] / r["latency_ms_b1"] if ref and r.get("latency_ms_b1") else None
106
+ lines.append(
107
+ f"| {r['name']} | {r['device']} "
108
+ f"| {_fmt(r.get('accuracy'), '.3f')} | {_fmt(r.get('macro_f1'), '.3f')} "
109
+ f"| {_fmt(r.get('agreement'), '.3f')} | {_fmt(r.get('latency_ms_b1'), '.1f')} "
110
+ f"| {_fmt(r.get('latency_ms_b32'), '.1f')} | {_fmt(r.get('throughput_per_s'), '.1f')} "
111
+ f"| {_fmt(r.get('size_mb'), '.0f')} | {_fmt(speed, '.1f') + 'x' if speed else 'n/a'} |"
112
+ )
113
+ ds = results["data"]
114
+ tr = results.get("training") or {}
115
+ k = tr.get("gold_per_class") or 0
116
+ trained = (
117
+ f"The student learns from the teacher's soft labels on all {ds['n_train']} training texts, "
118
+ f"plus cross-entropy on {k} gold labels per class "
119
+ f"({tr.get('n_gold')} texts, weight {tr.get('ce_weight')})."
120
+ if k
121
+ else f"The student learns only from the teacher's soft labels on {ds['n_train']} "
122
+ "unlabelled training texts."
123
+ )
124
+ if tr.get("upper_bound"):
125
+ trained += (
126
+ " The upper bound is the same student trained with cross-entropy on all "
127
+ f"{tr.get('n_gold_upper_bound')} gold training labels and no teacher; "
128
+ "its speed equals the student's."
129
+ )
130
+ notes = [
131
+ "",
132
+ f"Task `{results['task']}`, {ds['n_test']} gold-labelled test texts. "
133
+ f"Teacher: `{results['teacher']}`. Student: `{results['student']}`. {trained}",
134
+ f"CPU rows use {results['environment']['cpu_threads']} threads. "
135
+ "Latency is the median over repeated runs. Size is the teacher's weights as loaded and the students' "
136
+ "model files. "
137
+ "The teacher scores one text at a time, so it is timed at batch 1 and its throughput is 1 / latency. "
138
+ + (f"Speed-up is relative to the {ref['name']} on {ref['device']}." if ref else ""),
139
+ ]
140
+ return head + "\n".join(lines) + "\n" + "\n".join(notes) + "\n"
tinydistill/export.py ADDED
@@ -0,0 +1,103 @@
1
+ """Export the student to ONNX (optionally int8 dynamic quantisation) and run it with onnxruntime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+
12
+ from .student import load_student
13
+
14
+
15
+ class _Logits(torch.nn.Module):
16
+ def __init__(self, model):
17
+ super().__init__()
18
+ self.model = model
19
+
20
+ def forward(self, input_ids, attention_mask):
21
+ return self.model(input_ids=input_ids, attention_mask=attention_mask).logits
22
+
23
+
24
+ def export_onnx(student_dir: Path, out_dir: Path, int8: bool = False, opset: int = 17) -> list[Path]:
25
+ model, tok = load_student(student_dir)
26
+ out_dir.mkdir(parents=True, exist_ok=True)
27
+ enc = tok(["an example input", "another one"], padding=True, return_tensors="pt")
28
+ path = out_dir / "model.onnx"
29
+ torch.onnx.export(
30
+ _Logits(model),
31
+ (enc["input_ids"], enc["attention_mask"]),
32
+ str(path),
33
+ input_names=["input_ids", "attention_mask"],
34
+ output_names=["logits"],
35
+ dynamic_axes={
36
+ "input_ids": {0: "batch", 1: "seq"},
37
+ "attention_mask": {0: "batch", 1: "seq"},
38
+ "logits": {0: "batch"},
39
+ },
40
+ opset_version=opset,
41
+ dynamo=False,
42
+ )
43
+ tok.save_pretrained(out_dir)
44
+ labels = [model.config.id2label[i] for i in range(model.config.num_labels)]
45
+ (out_dir / "labels.json").write_text(json.dumps(labels, indent=1), encoding="utf-8")
46
+ written = [path]
47
+ if int8:
48
+ from onnxruntime.quantization import QuantType, quantize_dynamic
49
+
50
+ q = out_dir / "model.int8.onnx"
51
+ quantize_dynamic(str(path), str(q), weight_type=QuantType.QInt8)
52
+ written.append(q)
53
+ return written
54
+
55
+
56
+ class Predictor:
57
+ """Run an exported model: ``Predictor("runs/x/onnx").predict(["text", ...])``."""
58
+
59
+ def __init__(
60
+ self, path: str | Path, int8: bool | None = None, threads: int | None = None, max_length: int = 128
61
+ ):
62
+ import onnxruntime as ort
63
+ from transformers import AutoTokenizer
64
+
65
+ path = Path(path)
66
+ if int8 is None:
67
+ int8 = (path / "model.int8.onnx").exists()
68
+ file = path / ("model.int8.onnx" if int8 else "model.onnx")
69
+ opts = ort.SessionOptions()
70
+ threads = threads or int(os.environ.get("OMP_NUM_THREADS", "0"))
71
+ if threads:
72
+ opts.intra_op_num_threads = threads
73
+ opts.inter_op_num_threads = 1
74
+ self.session = ort.InferenceSession(str(file), opts, providers=["CPUExecutionProvider"])
75
+ self.tok = AutoTokenizer.from_pretrained(path)
76
+ self.labels: list[str] = json.loads((path / "labels.json").read_text(encoding="utf-8"))
77
+ self.max_length = max_length
78
+ self.file = file
79
+
80
+ def logits(self, texts: list[str], batch_size: int = 32) -> np.ndarray:
81
+ out = []
82
+ for b in range(0, len(texts), batch_size):
83
+ enc = self.tok(
84
+ texts[b : b + batch_size],
85
+ padding=True,
86
+ truncation=True,
87
+ max_length=self.max_length,
88
+ return_tensors="np",
89
+ )
90
+ feed = {
91
+ "input_ids": enc["input_ids"].astype(np.int64),
92
+ "attention_mask": enc["attention_mask"].astype(np.int64),
93
+ }
94
+ out.append(self.session.run(["logits"], feed)[0])
95
+ return np.concatenate(out) if out else np.zeros((0, len(self.labels)), dtype=np.float32)
96
+
97
+ def predict_proba(self, texts: list[str], batch_size: int = 32) -> np.ndarray:
98
+ z = self.logits(texts, batch_size)
99
+ z = np.exp(z - z.max(-1, keepdims=True))
100
+ return z / z.sum(-1, keepdims=True)
101
+
102
+ def predict(self, texts: list[str], batch_size: int = 32) -> list[str]:
103
+ return [self.labels[i] for i in self.logits(texts, batch_size).argmax(-1)]
tinydistill/student.py ADDED
@@ -0,0 +1,140 @@
1
+ """Student: a small encoder classifier trained on the teacher's soft targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ import time
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
14
+
15
+ DEFAULT_STUDENT = "sentence-transformers/all-MiniLM-L6-v2"
16
+
17
+
18
+ def distillation_loss(
19
+ student_logits: torch.Tensor,
20
+ teacher_probs: torch.Tensor,
21
+ gold: torch.Tensor | None = None,
22
+ ce_weight: float = 0.0,
23
+ ) -> torch.Tensor:
24
+ """Mean over the batch of KL(teacher || student), plus ce_weight * mean CE on the examples with a gold
25
+ label (gold = -1 marks examples without one). Standard knowledge distillation (Hinton et al., 2015)."""
26
+ log_q = F.log_softmax(student_logits.float(), dim=-1)
27
+ p = teacher_probs.float()
28
+ kl = (p * (torch.log(p.clamp_min(1e-12)) - log_q)).sum(-1).mean()
29
+ if ce_weight and gold is not None and (gold >= 0).any():
30
+ has = gold >= 0
31
+ kl = kl + ce_weight * F.nll_loss(log_q[has], gold[has])
32
+ return kl
33
+
34
+
35
+ def few_gold(gold: list[int | None], k: int, seed: int = 0) -> list[int | None]:
36
+ """Keep the gold label of k randomly chosen examples per class (fewer if a class has fewer) and drop the
37
+ rest, so only a few labels per class are used."""
38
+ by_class: dict[int, list[int]] = {}
39
+ for i, g in enumerate(gold):
40
+ if g is not None:
41
+ by_class.setdefault(g, []).append(i)
42
+ rng = random.Random(seed)
43
+ keep: set[int] = set()
44
+ for c in sorted(by_class):
45
+ idx = by_class[c]
46
+ keep.update(rng.sample(idx, min(k, len(idx))))
47
+ return [g if i in keep else None for i, g in enumerate(gold)]
48
+
49
+
50
+ def train_student(
51
+ texts: list[str],
52
+ teacher_probs: torch.Tensor | None,
53
+ labels: list[str],
54
+ out_dir: Path,
55
+ model_name: str = DEFAULT_STUDENT,
56
+ gold: list[int | None] | None = None,
57
+ ce_weight: float = 0.0,
58
+ epochs: int = 5,
59
+ batch_size: int = 32,
60
+ lr: float = 1e-4,
61
+ max_length: int = 128,
62
+ device: str = "cpu",
63
+ seed: int = 0,
64
+ log: Callable[[str], None] = print,
65
+ ) -> None:
66
+ torch.manual_seed(seed)
67
+ rng = random.Random(seed)
68
+ tok = AutoTokenizer.from_pretrained(model_name)
69
+ model = AutoModelForSequenceClassification.from_pretrained(
70
+ model_name,
71
+ num_labels=len(labels),
72
+ id2label=dict(enumerate(labels)),
73
+ label2id={name: i for i, name in enumerate(labels)},
74
+ ignore_mismatched_sizes=True,
75
+ ).to(device)
76
+ gold_t = torch.tensor([-1 if g is None else g for g in (gold or [None] * len(texts))])
77
+
78
+ steps = epochs * math.ceil(len(texts) / batch_size)
79
+ opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
80
+ warm = max(1, steps // 10)
81
+ sched = torch.optim.lr_scheduler.LambdaLR(
82
+ opt, lambda s: min((s + 1) / warm, max(0.0, (steps - s) / (steps - warm + 1e-9)))
83
+ )
84
+
85
+ order = list(range(len(texts)))
86
+ for epoch in range(epochs):
87
+ model.train()
88
+ rng.shuffle(order)
89
+ t0, total = time.perf_counter(), 0.0
90
+ for b in range(0, len(order), batch_size):
91
+ idx = order[b : b + batch_size]
92
+ enc = tok(
93
+ [texts[i] for i in idx],
94
+ padding=True,
95
+ truncation=True,
96
+ max_length=max_length,
97
+ return_tensors="pt",
98
+ )
99
+ enc = {k: v.to(device) for k, v in enc.items()}
100
+ logits = model(**enc).logits
101
+ if (
102
+ teacher_probs is None
103
+ ): # supervised only (the upper-bound model): every example has a gold label
104
+ loss = F.cross_entropy(logits.float(), gold_t[idx].to(device))
105
+ else:
106
+ loss = distillation_loss(
107
+ logits, teacher_probs[idx].to(device), gold_t[idx].to(device), ce_weight
108
+ )
109
+ loss.backward()
110
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
111
+ opt.step()
112
+ sched.step()
113
+ opt.zero_grad(set_to_none=True)
114
+ total += loss.item() * len(idx)
115
+ log(f"epoch {epoch + 1}/{epochs}: loss {total / len(texts):.4f} ({time.perf_counter() - t0:.0f}s)")
116
+ out_dir.mkdir(parents=True, exist_ok=True)
117
+ model.save_pretrained(out_dir)
118
+ tok.save_pretrained(out_dir)
119
+
120
+
121
+ def load_student(path: Path, device: str = "cpu"):
122
+ tok = AutoTokenizer.from_pretrained(path)
123
+ model = AutoModelForSequenceClassification.from_pretrained(path).to(device).eval()
124
+ return model, tok
125
+
126
+
127
+ @torch.no_grad()
128
+ def student_logits(model, tok, texts: list[str], batch_size: int = 64, max_length: int = 128) -> torch.Tensor:
129
+ dev = next(model.parameters()).device
130
+ out = []
131
+ for b in range(0, len(texts), batch_size):
132
+ enc = tok(
133
+ texts[b : b + batch_size],
134
+ padding=True,
135
+ truncation=True,
136
+ max_length=max_length,
137
+ return_tensors="pt",
138
+ )
139
+ out.append(model(**{k: v.to(dev) for k, v in enc.items()}).logits.float().cpu())
140
+ return torch.cat(out) if out else torch.empty(0)
tinydistill/task.py ADDED
@@ -0,0 +1,108 @@
1
+ """Task definition: label ids, their natural-language names (what the teacher scores), optional descriptions
2
+ and the instruction prompt."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ SURFACE_FORMS = ("lowercase", "capitalised", "uppercase")
13
+
14
+
15
+ def label_name(label_id: str) -> str:
16
+ """Natural-language name for a label id: card_arrival -> 'card arrival', top-up -> 'top up'."""
17
+ return re.sub(r"[\s_\-.]+", " ", label_id).strip()
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Task:
22
+ name: str
23
+ labels: list[str] # label ids, in a fixed order (class index = position)
24
+ verbalisations: list[str] # natural-language label names; the teacher scores these
25
+ prompt: str # must contain {labels} and {text}
26
+ text_field: str = "text"
27
+ label_field: str | None = "label"
28
+ descriptions: list[str | None] = field(default_factory=list) # optional, shown in the prompt only
29
+ # Extra ways of writing each name that the teacher's score includes (the name as written always counts).
30
+ surface_forms: tuple[str, ...] = SURFACE_FORMS
31
+
32
+ def render(self, text: str) -> str:
33
+ """The user message for one input text."""
34
+ descs = self.descriptions or [None] * len(self.labels)
35
+ options = "\n".join(
36
+ f"- {v}: {d}" if d else f"- {v}" for v, d in zip(self.verbalisations, descs, strict=True)
37
+ )
38
+ return self.prompt.replace("{labels}", options).replace("{text}", text)
39
+
40
+ def to_dict(self) -> dict:
41
+ labels = {}
42
+ for i, (lid, v) in enumerate(zip(self.labels, self.verbalisations, strict=True)):
43
+ d = self.descriptions[i] if self.descriptions else None
44
+ labels[lid] = {"name": v, "description": d} if d else v
45
+ return {
46
+ "name": self.name,
47
+ "text_field": self.text_field,
48
+ "label_field": self.label_field,
49
+ "prompt": self.prompt,
50
+ "surface_forms": list(self.surface_forms),
51
+ "labels": labels,
52
+ }
53
+
54
+
55
+ def _parse_label(path, lid: str, value) -> tuple[str, str | None]:
56
+ if value is None:
57
+ return label_name(lid), None
58
+ if isinstance(value, str):
59
+ return value, None
60
+ if isinstance(value, dict):
61
+ unknown = set(value) - {"name", "description"}
62
+ if unknown:
63
+ raise ValueError(f"{path}: label {lid!r} has unknown keys {sorted(unknown)}")
64
+ name = value.get("name") or label_name(lid)
65
+ desc = value.get("description")
66
+ return str(name), (str(desc) if desc else None)
67
+ raise ValueError(f"{path}: label {lid!r} must be a name, a mapping with name/description, or empty")
68
+
69
+
70
+ def _parse_forms(path, value) -> tuple[str, ...]:
71
+ if not isinstance(value, list) or not set(value) <= set(SURFACE_FORMS):
72
+ raise ValueError(f"{path}: 'surface_forms' must be a list drawn from {list(SURFACE_FORMS)}")
73
+ return tuple(f for f in SURFACE_FORMS if f in value)
74
+
75
+
76
+ def load_task(path: str | Path) -> Task:
77
+ """Load a task YAML file. ``labels`` is a list of label ids, or a mapping from id to either a name or
78
+ ``{name: ..., description: ...}`` (both optional). A missing name is derived from the id."""
79
+ raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
80
+ if not isinstance(raw, dict):
81
+ raise ValueError(f"{path}: expected a mapping at the top level")
82
+ labels = raw.get("labels")
83
+ if isinstance(labels, dict):
84
+ items = [(str(k), v) for k, v in labels.items()]
85
+ elif isinstance(labels, list):
86
+ items = [(str(k), None) for k in labels]
87
+ else:
88
+ raise ValueError(f"{path}: 'labels' must be a mapping or a list")
89
+ if len(items) < 2:
90
+ raise ValueError(f"{path}: need at least two labels")
91
+ parsed = [_parse_label(path, lid, v) for lid, v in items]
92
+ verbs = [p[0] for p in parsed]
93
+ descs = [p[1] for p in parsed]
94
+ if len(set(verbs)) != len(verbs):
95
+ raise ValueError(f"{path}: label names must be unique")
96
+ prompt = raw.get("prompt")
97
+ if not isinstance(prompt, str) or prompt.count("{text}") != 1 or "{labels}" not in prompt:
98
+ raise ValueError(f"{path}: 'prompt' must contain {{labels}} and contain {{text}} exactly once")
99
+ return Task(
100
+ name=str(raw.get("name", Path(path).stem)),
101
+ labels=[lid for lid, _ in items],
102
+ verbalisations=verbs,
103
+ prompt=prompt,
104
+ text_field=str(raw.get("text_field", "text")),
105
+ label_field=raw.get("label_field", "label"),
106
+ descriptions=descs if any(descs) else [],
107
+ surface_forms=_parse_forms(path, raw.get("surface_forms", list(SURFACE_FORMS))),
108
+ )