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/evaluate.py
ADDED
|
@@ -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
|
+
)
|