tinydistill 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (79) hide show
  1. {tinydistill-0.3.2 → tinydistill-0.3.3}/PKG-INFO +49 -3
  2. {tinydistill-0.3.2 → tinydistill-0.3.3}/README.md +45 -2
  3. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/run.sh +7 -0
  4. {tinydistill-0.3.2 → tinydistill-0.3.3}/pyproject.toml +3 -2
  5. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/__init__.py +1 -1
  6. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/analyse.py +23 -11
  7. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/cli.py +35 -10
  8. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/evaluate.py +12 -1
  9. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/export.py +8 -3
  10. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/hub.py +7 -0
  11. tinydistill-0.3.3/src/tinydistill/plots.py +326 -0
  12. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/student.py +26 -9
  13. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_cli.py +18 -1
  14. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_export.py +16 -0
  15. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_loss.py +2 -1
  16. {tinydistill-0.3.2 → tinydistill-0.3.3}/.gitignore +0 -0
  17. {tinydistill-0.3.2 → tinydistill-0.3.3}/LICENSE +0 -0
  18. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-1.5b-8shot.json +0 -0
  19. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-1.5b-8shot.md +0 -0
  20. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-1.5b.json +0 -0
  21. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-1.5b.md +0 -0
  22. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-7b-8shot.json +0 -0
  23. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-7b-8shot.md +0 -0
  24. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-7b.json +0 -0
  25. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/banking77-7b.md +0 -0
  26. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/all-MiniLM-L12-v2-20-epochs.json +0 -0
  27. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/all-MiniLM-L12-v2.json +0 -0
  28. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/all-MiniLM-L6-v2-10-epochs.json +0 -0
  29. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/all-MiniLM-L6-v2-20-epochs.json +0 -0
  30. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/all-mpnet-base-v2.json +0 -0
  31. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/bge-small-en-v1.5.json +0 -0
  32. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students/static.json +0 -0
  33. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/students.md +0 -0
  34. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/task.yaml +0 -0
  35. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/banking77/teachers.md +0 -0
  36. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-1.5b-8shot.json +0 -0
  37. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-1.5b-8shot.md +0 -0
  38. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-1.5b.json +0 -0
  39. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-1.5b.md +0 -0
  40. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-7b-20-epochs.json +0 -0
  41. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-7b-20-epochs.md +0 -0
  42. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-7b.json +0 -0
  43. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-7b.md +0 -0
  44. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-ensemble.json +0 -0
  45. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-ensemble.md +0 -0
  46. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-synthetic.json +0 -0
  47. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/financial-tweets-synthetic.md +0 -0
  48. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/run.sh +0 -0
  49. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/task.yaml +0 -0
  50. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/financial-tweets/teachers.md +0 -0
  51. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/go-emotions/go-emotions-1.5b.json +0 -0
  52. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/go-emotions/go-emotions-1.5b.md +0 -0
  53. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/go-emotions/run.sh +0 -0
  54. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/go-emotions/task.yaml +0 -0
  55. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/headline.md +0 -0
  56. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/update_readme.py +0 -0
  57. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/vllm-smoke.json +0 -0
  58. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/vllm-smoke.md +0 -0
  59. {tinydistill-0.3.2 → tinydistill-0.3.3}/examples/vllm-smoke.sh +0 -0
  60. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/data.py +0 -0
  61. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/progress.py +0 -0
  62. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/serve.py +0 -0
  63. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/shortlist.py +0 -0
  64. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/static.py +0 -0
  65. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/suggest.py +0 -0
  66. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/synth.py +0 -0
  67. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/task.py +0 -0
  68. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/teacher.py +0 -0
  69. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/tune.py +0 -0
  70. {tinydistill-0.3.2 → tinydistill-0.3.3}/src/tinydistill/web.py +0 -0
  71. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/browser/build_tiny_site.py +0 -0
  72. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/browser/check_page.mjs +0 -0
  73. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/conftest.py +0 -0
  74. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_cache.py +0 -0
  75. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_progress.py +0 -0
  76. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_scoring.py +0 -0
  77. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_serve.py +0 -0
  78. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_task.py +0 -0
  79. {tinydistill-0.3.2 → tinydistill-0.3.3}/tests/test_vllm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tinydistill
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Distil an LLM classification prompt into a small, fast model that runs on a CPU.
5
5
  Project-URL: Homepage, https://github.com/Andreas-Hum/tinydistill
6
6
  Author: Andreas-Hum
@@ -28,10 +28,13 @@ Requires-Dist: bitsandbytes>=0.43; extra == '4bit'
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: build>=1.2; extra == 'dev'
30
30
  Requires-Dist: datasets>=2.18; extra == 'dev'
31
+ Requires-Dist: matplotlib>=3.7; extra == 'dev'
31
32
  Requires-Dist: pytest>=8; extra == 'dev'
32
33
  Requires-Dist: ruff>=0.5; extra == 'dev'
33
34
  Provides-Extra: hf
34
35
  Requires-Dist: datasets>=2.18; extra == 'hf'
36
+ Provides-Extra: plot
37
+ Requires-Dist: matplotlib>=3.7; extra == 'plot'
35
38
  Description-Content-Type: text/markdown
36
39
 
37
40
  # tinydistill
@@ -86,6 +89,7 @@ browser and shows what happens inside it.</i></p>
86
89
  pip install tinydistill # label, train, export, predict
87
90
  pip install "tinydistill[hf]" # + Hugging Face datasets as input (hf:<dataset>:<split>)
88
91
  pip install "tinydistill[hf,4bit]" # + 4-bit teachers with bitsandbytes (CUDA)
92
+ pip install "tinydistill[plot]" # + charts of a run (matplotlib)
89
93
  ```
90
94
 
91
95
  Python 3.11 or newer, on Linux, macOS or Windows. Labelling with a large teacher wants a GPU; training and
@@ -94,6 +98,19 @@ inference are fine on a CPU. For a CPU-only PyTorch, install it first:
94
98
 
95
99
  ## Quickstart
96
100
 
101
+ To try a distilled model before training your own, the Banking77 student from the [results](#results) is on
102
+ the [Hugging Face Hub](https://huggingface.co/Shroomy/tinydistill-banking77-minilm):
103
+
104
+ ```python
105
+ from tinydistill import Predictor # pip install tinydistill
106
+
107
+ clf = Predictor("hf:Shroomy/tinydistill-banking77-minilm")
108
+ clf.describe(["I still haven't received my new card", "My card got stolen"])
109
+ # [{'label': 'card_arrival', 'confidence': 0.4672}, {'label': 'lost_or_stolen_card', 'confidence': 0.9921}]
110
+ ```
111
+
112
+ To distil your own:
113
+
97
114
  ```sh
98
115
  curl -LO https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/examples/banking77/task.yaml
99
116
  tinydistill run task.yaml --data hf:PolyAI/banking77:train --test hf:PolyAI/banking77:test \
@@ -110,8 +127,13 @@ clf = Predictor("runs/banking77/onnx") # or a model on the Hub: Predictor("hf:u
110
127
  clf.predict(["I still haven't received my new card"]) # a list of label names
111
128
  ```
112
129
 
113
- Or try it without installing anything: the [Colab notebook](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/quickstart.ipynb) distils a sentiment classifier in a
114
- few minutes on a free GPU.
130
+ Or try it without installing anything, on a free Colab GPU:
131
+
132
+ | Notebook | What it shows |
133
+ |---|---|
134
+ | [Quickstart](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/quickstart.ipynb) | distil a sentiment classifier in about ten minutes, with charts of what the student learned |
135
+ | [Your own task](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/your_task.ipynb) | write a task for your labels, check the teacher, distil on your texts, find the mistakes, download the model |
136
+ | [Deploy](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/deploy.ipynb) | the model from Python, behind an HTTP server, in a web page, and on the Hugging Face Hub |
115
137
 
116
138
  Labelling the full dataset with the default teacher wants a GPU. For a quick look on a CPU, add
117
139
  `--teacher Qwen/Qwen2.5-0.5B-Instruct --limit 200 --test-limit 200`. `--limit` takes the first rows, and the
@@ -299,6 +321,30 @@ results show the mean and standard deviation.
299
321
  adds them to the training texts and trains, exports and evaluates a new student on the same test set, so
300
322
  the student learns the inputs it found hard in production.
301
323
 
324
+ ### Charts and metrics
325
+
326
+ `tinydistill plot RUN` (with the `plot` extra) writes the charts of a run to `RUN/plots/`; in Python every
327
+ chart is a function in `tinydistill.plots` that returns a matplotlib figure, so it shows inline in a notebook:
328
+
329
+ | Chart | What it shows |
330
+ |---|---|
331
+ | `soft_labels` | for a few test texts, the teacher's probabilities (the soft labels) next to the student's |
332
+ | `speed_accuracy` | accuracy against latency for the teacher and every form of the student, with the baseline and upper bound |
333
+ | `training` | the loss per epoch on the training texts and, with `--val-share`, on the held-out texts and the kept epoch |
334
+ | `per_label` | F1 of every label for teacher and student, the teacher's weakest first |
335
+ | `confusions` | the confusion matrix over the labels involved in the most mistakes |
336
+ | `cascade` | accuracy as the student passes more of its least confident texts to the teacher |
337
+
338
+ <p align="center">
339
+ <img alt="Soft labels on three Banking77 test texts: the teacher's probabilities next to the student's" src="https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/docs/images/banking77-soft-labels.png" width="49%">
340
+ <img alt="Accuracy against latency on Banking77: the students on a CPU, the teacher on a GPU, the baseline and the upper bound" src="https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/docs/images/banking77-speed-accuracy.png" width="49%">
341
+ </p>
342
+
343
+ Every run's results report accuracy and macro-F1, agreement with the teacher, latency at batch 1 and 32,
344
+ throughput and size; tasks with more than five labels also get top-3 accuracy, multi-label tasks micro-F1, and
345
+ ordered labels the mean distance in label steps. `tinydistill analyse RUN` adds a page of per-label scores and
346
+ example mistakes.
347
+
302
348
  ### From Python
303
349
 
304
350
  The exported model:
@@ -50,6 +50,7 @@ browser and shows what happens inside it.</i></p>
50
50
  pip install tinydistill # label, train, export, predict
51
51
  pip install "tinydistill[hf]" # + Hugging Face datasets as input (hf:<dataset>:<split>)
52
52
  pip install "tinydistill[hf,4bit]" # + 4-bit teachers with bitsandbytes (CUDA)
53
+ pip install "tinydistill[plot]" # + charts of a run (matplotlib)
53
54
  ```
54
55
 
55
56
  Python 3.11 or newer, on Linux, macOS or Windows. Labelling with a large teacher wants a GPU; training and
@@ -58,6 +59,19 @@ inference are fine on a CPU. For a CPU-only PyTorch, install it first:
58
59
 
59
60
  ## Quickstart
60
61
 
62
+ To try a distilled model before training your own, the Banking77 student from the [results](#results) is on
63
+ the [Hugging Face Hub](https://huggingface.co/Shroomy/tinydistill-banking77-minilm):
64
+
65
+ ```python
66
+ from tinydistill import Predictor # pip install tinydistill
67
+
68
+ clf = Predictor("hf:Shroomy/tinydistill-banking77-minilm")
69
+ clf.describe(["I still haven't received my new card", "My card got stolen"])
70
+ # [{'label': 'card_arrival', 'confidence': 0.4672}, {'label': 'lost_or_stolen_card', 'confidence': 0.9921}]
71
+ ```
72
+
73
+ To distil your own:
74
+
61
75
  ```sh
62
76
  curl -LO https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/examples/banking77/task.yaml
63
77
  tinydistill run task.yaml --data hf:PolyAI/banking77:train --test hf:PolyAI/banking77:test \
@@ -74,8 +88,13 @@ clf = Predictor("runs/banking77/onnx") # or a model on the Hub: Predictor("hf:u
74
88
  clf.predict(["I still haven't received my new card"]) # a list of label names
75
89
  ```
76
90
 
77
- Or try it without installing anything: the [Colab notebook](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/quickstart.ipynb) distils a sentiment classifier in a
78
- few minutes on a free GPU.
91
+ Or try it without installing anything, on a free Colab GPU:
92
+
93
+ | Notebook | What it shows |
94
+ |---|---|
95
+ | [Quickstart](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/quickstart.ipynb) | distil a sentiment classifier in about ten minutes, with charts of what the student learned |
96
+ | [Your own task](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/your_task.ipynb) | write a task for your labels, check the teacher, distil on your texts, find the mistakes, download the model |
97
+ | [Deploy](https://colab.research.google.com/github/Andreas-Hum/tinydistill/blob/main/notebooks/deploy.ipynb) | the model from Python, behind an HTTP server, in a web page, and on the Hugging Face Hub |
79
98
 
80
99
  Labelling the full dataset with the default teacher wants a GPU. For a quick look on a CPU, add
81
100
  `--teacher Qwen/Qwen2.5-0.5B-Instruct --limit 200 --test-limit 200`. `--limit` takes the first rows, and the
@@ -263,6 +282,30 @@ results show the mean and standard deviation.
263
282
  adds them to the training texts and trains, exports and evaluates a new student on the same test set, so
264
283
  the student learns the inputs it found hard in production.
265
284
 
285
+ ### Charts and metrics
286
+
287
+ `tinydistill plot RUN` (with the `plot` extra) writes the charts of a run to `RUN/plots/`; in Python every
288
+ chart is a function in `tinydistill.plots` that returns a matplotlib figure, so it shows inline in a notebook:
289
+
290
+ | Chart | What it shows |
291
+ |---|---|
292
+ | `soft_labels` | for a few test texts, the teacher's probabilities (the soft labels) next to the student's |
293
+ | `speed_accuracy` | accuracy against latency for the teacher and every form of the student, with the baseline and upper bound |
294
+ | `training` | the loss per epoch on the training texts and, with `--val-share`, on the held-out texts and the kept epoch |
295
+ | `per_label` | F1 of every label for teacher and student, the teacher's weakest first |
296
+ | `confusions` | the confusion matrix over the labels involved in the most mistakes |
297
+ | `cascade` | accuracy as the student passes more of its least confident texts to the teacher |
298
+
299
+ <p align="center">
300
+ <img alt="Soft labels on three Banking77 test texts: the teacher's probabilities next to the student's" src="https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/docs/images/banking77-soft-labels.png" width="49%">
301
+ <img alt="Accuracy against latency on Banking77: the students on a CPU, the teacher on a GPU, the baseline and the upper bound" src="https://raw.githubusercontent.com/Andreas-Hum/tinydistill/main/docs/images/banking77-speed-accuracy.png" width="49%">
302
+ </p>
303
+
304
+ Every run's results report accuracy and macro-F1, agreement with the teacher, latency at batch 1 and 32,
305
+ throughput and size; tasks with more than five labels also get top-3 accuracy, multi-label tasks micro-F1, and
306
+ ordered labels the mean distance in label steps. `tinydistill analyse RUN` adds a page of per-label scores and
307
+ example mistakes.
308
+
266
309
  ### From Python
267
310
 
268
311
  The exported model:
@@ -47,4 +47,11 @@ tinydistill compare runs/banking77-1.5b runs/banking77-1.5b-8shot runs/banking77
47
47
  mkdir -p examples/banking77/students
48
48
  for dir in runs/banking77-students/*/; do cp "$dir/results.json" "examples/banking77/students/$(basename "$dir").json"; done
49
49
  tinydistill compare runs/banking77-7b-8shot runs/banking77-students/* > examples/banking77/students.md
50
+ python - <<'PY' # the charts in the README
51
+ from tinydistill import plots
52
+ plots.speed_accuracy("runs/banking77-7b-8shot").savefig("docs/images/banking77-speed-accuracy.png", dpi=130)
53
+ plots.soft_labels("runs/banking77-students/all-MiniLM-L6-v2-20-epochs").savefig(
54
+ "docs/images/banking77-soft-labels.png", dpi=130
55
+ )
56
+ PY
50
57
  python examples/update_readme.py
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tinydistill"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Distil an LLM classification prompt into a small, fast model that runs on a CPU."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -34,7 +34,8 @@ dependencies = [
34
34
  [project.optional-dependencies]
35
35
  hf = ["datasets>=2.18"]
36
36
  4bit = ["bitsandbytes>=0.43", "accelerate>=0.30"]
37
- dev = ["pytest>=8", "ruff>=0.5", "datasets>=2.18", "build>=1.2"]
37
+ plot = ["matplotlib>=3.7"]
38
+ dev = ["pytest>=8", "ruff>=0.5", "datasets>=2.18", "build>=1.2", "matplotlib>=3.7"]
38
39
 
39
40
  [project.scripts]
40
41
  tinydistill = "tinydistill.cli:main"
@@ -29,4 +29,4 @@ __all__ = [
29
29
  "read_examples",
30
30
  "train_student",
31
31
  ]
32
- __version__ = "0.3.2"
32
+ __version__ = "0.3.3"
@@ -53,30 +53,42 @@ CSS = """
53
53
  """
54
54
 
55
55
 
56
- def build(run: Path, top: int = 15) -> tuple[str, str]:
57
- """(HTML page, short Markdown summary) for a single-label run with a gold-labelled test set."""
56
+ def predictions(run: Path) -> dict:
57
+ """For the gold-labelled test texts of a single-label run: the task, texts, gold labels, and the teacher's
58
+ and the deployed student's predicted labels."""
58
59
  from .export import Predictor
59
60
  from .task import load_task
60
61
 
61
62
  task = load_task(run / "task.yaml")
62
63
  if task.multi_label:
63
- raise SystemExit("analyse is for single-label tasks; see results.md for multi-label runs")
64
+ raise SystemExit("this view is for single-label tasks; see results.md for multi-label runs")
64
65
  rows = [
65
66
  r
66
67
  for r in map(json.loads, (run / "test.jsonl").read_text(encoding="utf-8").splitlines())
67
68
  if r.get("gold") is not None
68
69
  ]
69
70
  if not rows:
70
- raise SystemExit(f"{run}/test.jsonl has no gold labels to analyse")
71
- names = task.labels
71
+ raise SystemExit(f"{run}/test.jsonl has no gold labels")
72
72
  texts = [r["text"] for r in rows]
73
- gold = [r["gold"] for r in rows]
74
- teacher = [max(range(len(names)), key=r["scores"].__getitem__) for r in rows]
75
- student = [int(i) for i in Predictor(run / "onnx").logits(texts).argmax(-1)]
73
+ n = len(task.labels)
74
+ return {
75
+ "task": task,
76
+ "texts": texts,
77
+ "gold": [r["gold"] for r in rows],
78
+ "teacher": [max(range(n), key=r["scores"].__getitem__) for r in rows],
79
+ "student": [int(i) for i in Predictor(run / "onnx").logits(texts).argmax(-1)],
80
+ }
81
+
82
+
83
+ def build(run: Path, top: int = 15) -> tuple[str, str]:
84
+ """(HTML page, short Markdown summary) for a single-label run with a gold-labelled test set."""
85
+ p = predictions(run)
86
+ task, texts, gold, teacher, student = p["task"], p["texts"], p["gold"], p["teacher"], p["student"]
87
+ names = task.labels
76
88
 
77
89
  t_lab, s_lab = per_label(teacher, gold, len(names)), per_label(student, gold, len(names))
78
90
  order = sorted(range(len(names)), key=lambda c: t_lab[c]["f1"]) # the teacher's weakest labels first
79
- disagree = [i for i in range(len(rows)) if teacher[i] != student[i]]
91
+ disagree = [i for i in range(len(texts)) if teacher[i] != student[i]]
80
92
  t_acc = sum(t == g for t, g in zip(teacher, gold, strict=True)) / len(gold)
81
93
  s_acc = sum(s == g for s, g in zip(student, gold, strict=True)) / len(gold)
82
94
  e = html.escape
@@ -115,7 +127,7 @@ def build(run: Path, top: int = 15) -> tuple[str, str]:
115
127
  <title>Error analysis: {e(task.name)}</title><style>{CSS}</style></head>
116
128
  <body><main>
117
129
  <h1>Error analysis: {e(task.name)}</h1>
118
- <p class="sub">{len(rows)} gold-labelled test texts. Teacher accuracy {t_acc:.3f}, student (deployed model)
130
+ <p class="sub">{len(texts)} gold-labelled test texts. Teacher accuracy {t_acc:.3f}, student (deployed model)
119
131
  accuracy {s_acc:.3f}; they disagree on {len(disagree)} texts. Written by <code>tinydistill analyse</code>.</p>
120
132
 
121
133
  <h2>Per label, the teacher's weakest first</h2>
@@ -141,7 +153,7 @@ on the student side.</p>
141
153
  worst = [names[c] for c in order[:5]]
142
154
  pairs = Counter({(names[g], names[p]): n for g, p, n, _ in confusions(teacher, gold, 5)})
143
155
  summary = (
144
- f"Teacher accuracy {t_acc:.3f}, student {s_acc:.3f}; they disagree on {len(disagree)} of {len(rows)} texts.\n"
156
+ f"Teacher accuracy {t_acc:.3f}, student {s_acc:.3f}; they disagree on {len(disagree)} of {len(texts)} texts.\n"
145
157
  f"The teacher's weakest labels: {', '.join(worst)}.\n"
146
158
  "Its most common mistakes: "
147
159
  + "; ".join(f"{g} -> {p} ({n})" for (g, p), n in pairs.most_common(5))
@@ -358,6 +358,7 @@ def cmd_eval(a: argparse.Namespace) -> None:
358
358
  ordinal_metrics,
359
359
  render,
360
360
  time_calls,
361
+ top_k_accuracy,
361
362
  )
362
363
  from .export import Predictor
363
364
  from .student import load_student, student_logits
@@ -386,7 +387,9 @@ def cmd_eval(a: argparse.Namespace) -> None:
386
387
  raise SystemExit("no texts to evaluate on: label a --test set (or training data) first")
387
388
  threads = torch.get_num_threads()
388
389
 
389
- def quality(pred: list) -> dict:
390
+ def quality(pred: list, scores: list | None = None) -> dict:
391
+ """Metrics of predictions; with each text's label scores, also top-3 accuracy (tasks with more than
392
+ five labels)."""
390
393
  if not gold:
391
394
  return {}
392
395
  if multi:
@@ -394,6 +397,8 @@ def cmd_eval(a: argparse.Namespace) -> None:
394
397
  "agreement": accuracy(pred, teacher_pred)
395
398
  }
396
399
  extra = ordinal_metrics(pred, gold) if task.ordinal else {}
400
+ if scores is not None and len(task.labels) > 5:
401
+ extra["top3_accuracy"] = top_k_accuracy(scores, gold)
397
402
  return extra | {
398
403
  "accuracy": accuracy(pred, gold),
399
404
  "macro_f1": macro_f1(pred, gold),
@@ -418,8 +423,8 @@ def cmd_eval(a: argparse.Namespace) -> None:
418
423
  examples = [(r["text"], r["gold"]) for r in _read_jsonl(run / "examples.jsonl")] or None
419
424
  members: list[dict] = []
420
425
  for ti, name in enumerate(teachers):
421
- own = [decide(r["teacher_scores"][ti]) for r in labelled] if len(teachers) > 1 else teacher_pred
422
- q = quality(own)
426
+ own_scores = [r["teacher_scores"][ti] if len(teachers) > 1 else r["scores"] for r in labelled]
427
+ q = quality([decide(s) for s in own_scores], own_scores)
423
428
  q.pop("agreement", None)
424
429
  for dev in devices:
425
430
  label = f"teacher, {prompt}" + (f": {name.split('/')[-1]}" if len(teachers) > 1 else "")
@@ -463,7 +468,7 @@ def cmd_eval(a: argparse.Namespace) -> None:
463
468
  models.extend(members)
464
469
  else: # the ensemble is the actual teacher: it runs every member, so its latency is their sum
465
470
  models.extend(m | {"role": "teacher member"} for m in members)
466
- q = quality(teacher_pred)
471
+ q = quality(teacher_pred, [r["scores"] for r in labelled])
467
472
  q.pop("agreement", None)
468
473
  for dev in devices:
469
474
  on_dev = [m for m in members if m["device"] == dev]
@@ -493,9 +498,8 @@ def cmd_eval(a: argparse.Namespace) -> None:
493
498
  per_seed = []
494
499
  for d in dirs:
495
500
  model, tok = load_student(d, "cpu")
496
- per_seed.append(
497
- quality([decide(z) for z in student_logits(model, tok, texts).tolist()] if texts else [])
498
- )
501
+ z = student_logits(model, tok, texts).tolist() if texts else []
502
+ per_seed.append(quality([decide(x) for x in z], z))
499
503
  bar.update()
500
504
  row = {"role": role, "name": row_name, "device": "cpu", **aggregate(per_seed)}
501
505
  if role == "student": # timings and size of the shipped (first-seed) model
@@ -517,15 +521,19 @@ def cmd_eval(a: argparse.Namespace) -> None:
517
521
  continue
518
522
  log(f"evaluating {f.name} (onnxruntime, CPU)")
519
523
  pr = Predictor(run / "onnx", int8=int8, threads=threads)
520
- pred = [decide(z) for z in pr.logits(texts).tolist()] if texts else []
524
+ z = pr.logits(texts).tolist() if texts else []
525
+ pred = [decide(x) for x in z]
521
526
  row = {
522
527
  "role": "student",
523
528
  "name": f"student, ONNX {'int8' if int8 else 'fp32'}",
524
529
  "device": "cpu",
525
- **quality(pred),
530
+ **quality(pred, z),
526
531
  "size_mb": dir_size_mb([f]),
527
532
  }
528
- row.update(bench(lambda b, pr=pr: pr.logits(b), bench_texts, a.reps, max(3, a.reps // 5)))
533
+ # timed on real batches (batch 32 = one onnxruntime call with 32 texts)
534
+ row.update(
535
+ bench(lambda b, pr=pr: pr.logits(b, batch_size=len(b)), bench_texts, a.reps, max(3, a.reps // 5))
536
+ )
529
537
  models.append(row)
530
538
  bar.update()
531
539
 
@@ -680,6 +688,16 @@ def cmd_serve(a: argparse.Namespace) -> None:
680
688
  server.server_close()
681
689
 
682
690
 
691
+ def cmd_plot(a: argparse.Namespace) -> None:
692
+ from .plots import save_all
693
+
694
+ files = save_all(Path(a.run), a.out)
695
+ if not files:
696
+ raise SystemExit(f"{a.run} has no data to plot: run `tinydistill eval` first")
697
+ for f in files:
698
+ print(f)
699
+
700
+
683
701
  def cmd_web(a: argparse.Namespace) -> None:
684
702
  from .export import model_path
685
703
  from .web import build_site
@@ -1044,6 +1062,13 @@ def build_parser() -> argparse.ArgumentParser:
1044
1062
  srv.add_argument("--threshold", type=float, default=0.5, help="with --cascade: see `predict --help`")
1045
1063
  srv.add_argument("--log", help="with --cascade: append the texts the teacher answered (for `refresh`)")
1046
1064
  srv.set_defaults(fn=cmd_serve)
1065
+ plo = sub.add_parser(
1066
+ "plot",
1067
+ help="charts of a run as PNG files: training, speed and accuracy, per label, confusions, cascade",
1068
+ )
1069
+ plo.add_argument("run", help="run directory")
1070
+ plo.add_argument("--out", help="folder for the PNG files (default: RUN/plots)")
1071
+ plo.set_defaults(fn=cmd_plot)
1047
1072
  web = sub.add_parser("web", help="a static web page that runs the model in the browser")
1048
1073
  web.add_argument("model", help="directory written by `export` (run/onnx), or hf:user/name on the Hub")
1049
1074
  web.add_argument("--out", default="site", help="folder to write the page and model into")
@@ -87,6 +87,12 @@ def bench(fn: Callable[[list[str]], object], texts: list[str], reps1: int, reps3
87
87
  return out
88
88
 
89
89
 
90
+ def top_k_accuracy(scores: list[list[float]], gold: list[int], k: int = 3) -> float:
91
+ """Share of texts whose gold label is among the k highest-scoring labels."""
92
+ hits = sum(g in sorted(range(len(s)), key=s.__getitem__)[-k:] for s, g in zip(scores, gold, strict=True))
93
+ return hits / len(gold) if gold else float("nan")
94
+
95
+
90
96
  def aggregate(per_seed: list[dict]) -> dict:
91
97
  """Mean of each metric over seeds; with several seeds also the sample standard deviation and values."""
92
98
  if not per_seed or not per_seed[0]:
@@ -141,7 +147,12 @@ def _pm(row: dict, key: str) -> str:
141
147
  return _fmt(row.get(key), ".3f") + (f" ± {std:.3f}" if std is not None else "")
142
148
 
143
149
 
144
- EXTRA_COLUMNS = (("micro_f1", "Micro-F1"), ("mae", "MAE (label steps)"), ("within_one", "Within one step"))
150
+ EXTRA_COLUMNS = (
151
+ ("top3_accuracy", "Top-3 accuracy"),
152
+ ("micro_f1", "Micro-F1"),
153
+ ("mae", "MAE (label steps)"),
154
+ ("within_one", "Within one step"),
155
+ )
145
156
 
146
157
 
147
158
  def markdown_table(results: dict) -> str:
@@ -178,8 +178,13 @@ class Predictor:
178
178
  self.multi_label = config.get("problem_type") == "multi_label_classification"
179
179
  self.max_length = max_length
180
180
  self.file = file
181
+ self.int8 = int8
181
182
 
182
- def logits(self, texts: list[str], batch_size: int = 32) -> np.ndarray:
183
+ def logits(self, texts: list[str], batch_size: int | None = None) -> np.ndarray:
184
+ """Label scores per text. The int8 model runs one text at a time by default: its dynamic quantisation
185
+ scales activations over the whole batch, so in a batch a text's scores would depend on the other texts
186
+ (on a CPU this costs little throughput). The fp32 model runs 32 texts at a time."""
187
+ batch_size = batch_size or (1 if self.int8 else 32)
183
188
  out = []
184
189
  for b in range(0, len(texts), batch_size):
185
190
  enc = self.tok(
@@ -196,7 +201,7 @@ class Predictor:
196
201
  out.append(self.session.run(["logits"], feed)[0])
197
202
  return np.concatenate(out) if out else np.zeros((0, len(self.labels)), dtype=np.float32)
198
203
 
199
- def predict_proba(self, texts: list[str], batch_size: int = 32) -> np.ndarray:
204
+ def predict_proba(self, texts: list[str], batch_size: int | None = None) -> np.ndarray:
200
205
  """Label probabilities per text: a softmax, or for a multi-label model each label's sigmoid."""
201
206
  z = self.logits(texts, batch_size)
202
207
  if self.multi_label:
@@ -204,7 +209,7 @@ class Predictor:
204
209
  z = np.exp(z - z.max(-1, keepdims=True))
205
210
  return z / z.sum(-1, keepdims=True)
206
211
 
207
- def predict(self, texts: list[str], batch_size: int = 32) -> list:
212
+ def predict(self, texts: list[str], batch_size: int | None = None) -> list:
208
213
  """The top label per text, or for a multi-label model the list of labels with probability >= 0.5."""
209
214
  z = self.logits(texts, batch_size)
210
215
  if self.multi_label:
@@ -25,6 +25,13 @@ def model_card(onnx_dir: Path, repo: str, license: str | None = None) -> str:
25
25
  ]
26
26
  if license:
27
27
  front.append(f"license: {license}")
28
+ student_id = str(meta.get("student", ""))
29
+ if "/" in student_id and not student_id.startswith("static"): # links the card to the encoder's page
30
+ front.append(f"base_model: {student_id}")
31
+ data = [str(meta.get(k) or "") for k in ("data", "test")]
32
+ datasets = sorted({d.split(":")[1] for d in data if d.startswith("hf:") and len(d.split(":")) > 1})
33
+ if datasets: # links the card to the datasets' pages
34
+ front += ["datasets:", *(f"- {d}" for d in datasets)]
28
35
  front.append("---")
29
36
  teacher, student = meta.get("teacher", "an LLM"), meta.get("student", "a small encoder")
30
37
  lines = [
@@ -0,0 +1,326 @@
1
+ """Charts of a run, as matplotlib figures (``pip install "tinydistill[plot]"``). Each function takes a run
2
+ directory and returns a figure, so in a notebook it shows inline; ``save_all`` writes every chart the run has
3
+ data for to ``RUN/plots/*.png`` (that is what `tinydistill plot RUN` does).
4
+
5
+ The student is blue and the teacher orange throughout, as in the web page."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import Counter
11
+ from pathlib import Path
12
+
13
+ STUDENT, TEACHER, REFERENCE = "#2a78d6", "#eb6834", "#8a8984"
14
+ SURFACE, INK, MUTED, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e4e3df"
15
+
16
+
17
+ def _plt():
18
+ try:
19
+ import matplotlib
20
+
21
+ if not _in_notebook():
22
+ matplotlib.use("Agg") # files only, no window
23
+ import matplotlib.pyplot as plt
24
+ except ImportError as e:
25
+ raise SystemExit('plots need matplotlib: pip install "tinydistill[plot]"') from e
26
+ plt.rcParams.update(
27
+ {
28
+ "figure.facecolor": SURFACE,
29
+ "axes.facecolor": SURFACE,
30
+ "axes.edgecolor": GRID,
31
+ "axes.labelcolor": MUTED,
32
+ "axes.titlecolor": INK,
33
+ "axes.titlesize": 12,
34
+ "axes.titleweight": "bold",
35
+ "axes.titlelocation": "left",
36
+ "axes.spines.top": False,
37
+ "axes.spines.right": False,
38
+ "axes.grid": True,
39
+ "grid.color": GRID,
40
+ "grid.linewidth": 0.8,
41
+ "xtick.color": MUTED,
42
+ "ytick.color": MUTED,
43
+ "text.color": INK,
44
+ "legend.frameon": False,
45
+ "font.size": 10,
46
+ }
47
+ )
48
+ return plt
49
+
50
+
51
+ def _in_notebook() -> bool:
52
+ try:
53
+ from IPython import get_ipython
54
+
55
+ return get_ipython() is not None
56
+ except ImportError:
57
+ return False
58
+
59
+
60
+ def _results(run: Path) -> dict:
61
+ f = Path(run) / "results.json"
62
+ if not f.exists():
63
+ raise FileNotFoundError(f"{f} is missing: run `tinydistill eval` first")
64
+ return json.loads(f.read_text(encoding="utf-8"))
65
+
66
+
67
+ def _name(label: str) -> str:
68
+ return label.replace("_", " ")
69
+
70
+
71
+ def training(run: str | Path):
72
+ """Training loss per epoch, and the held-out loss and the kept epoch when `--val-share` was used."""
73
+ plt = _plt()
74
+ f = Path(run) / "student" / "history.json"
75
+ if not f.exists():
76
+ raise FileNotFoundError(f"{f} is missing: train with tinydistill 0.3.3 or later")
77
+ h = json.loads(f.read_text(encoding="utf-8"))
78
+ epochs = [e["epoch"] for e in h["epochs"]]
79
+ fig, ax = plt.subplots(figsize=(7, 3.6))
80
+ held = [e.get("held_out_loss") for e in h["epochs"]]
81
+ has_held = any(v is not None for v in held)
82
+ ax.plot( # both lines are the student; the held-out loss decides, so it gets the student's colour
83
+ epochs,
84
+ [e["loss"] for e in h["epochs"]],
85
+ color=REFERENCE if has_held else STUDENT,
86
+ lw=2,
87
+ marker="o",
88
+ ms=5,
89
+ label="training texts",
90
+ )
91
+ if has_held:
92
+ ax.plot(
93
+ epochs, held, color=STUDENT, lw=2, marker="o", ms=5, label=f"{h['held_out_texts']} held-out texts"
94
+ )
95
+ ax.axvline(h["kept_epoch"], color=REFERENCE, lw=1, ls="--")
96
+ ax.annotate(
97
+ f" kept: epoch {h['kept_epoch']}", (h["kept_epoch"], ax.get_ylim()[1]), va="top", color=MUTED
98
+ )
99
+ ax.set(xlabel="epoch", ylabel="loss (lower is better)", title="Training")
100
+ ax.set_xticks(epochs)
101
+ ax.legend(loc="upper right")
102
+ fig.tight_layout()
103
+ return fig
104
+
105
+
106
+ def per_label(run: str | Path, limit: int | None = None):
107
+ """F1 of every label for the teacher and the student, the teacher's weakest labels at the top. With
108
+ ``limit``, only that many of the labels where the two differ most."""
109
+ from .analyse import per_label as scores
110
+ from .analyse import predictions
111
+
112
+ plt = _plt()
113
+ p = predictions(Path(run))
114
+ names = p["task"].labels
115
+ t = [s["f1"] for s in scores(p["teacher"], p["gold"], len(names))]
116
+ s = [x["f1"] for x in scores(p["student"], p["gold"], len(names))]
117
+ order = sorted(range(len(names)), key=lambda c: t[c])
118
+ if limit:
119
+ order = sorted(sorted(order, key=lambda c: -abs(s[c] - t[c]))[:limit], key=lambda c: t[c])
120
+ fig, ax = plt.subplots(figsize=(7, 0.24 * len(order) + 1.4))
121
+ y = range(len(order))
122
+ ax.hlines(y, [min(t[c], s[c]) for c in order], [max(t[c], s[c]) for c in order], color=GRID, lw=2)
123
+ ax.scatter([t[c] for c in order], y, color=TEACHER, s=36, zorder=3, label="teacher")
124
+ ax.scatter([s[c] for c in order], y, color=STUDENT, s=36, zorder=3, label="student")
125
+ ax.set_yticks(list(y), [_name(names[c]) for c in order], fontsize=8)
126
+ ax.set_ylim(len(order) - 0.5, -0.5)
127
+ ax.set_xlim(0, 1.02)
128
+ ax.grid(axis="y", visible=False)
129
+ ax.set(xlabel="F1 on the test texts", title="Per label: teacher and student")
130
+ ax.legend(loc="lower right", bbox_to_anchor=(1, 1), ncols=2, borderaxespad=0.2)
131
+ fig.tight_layout()
132
+ return fig
133
+
134
+
135
+ def confusions(run: str | Path, who: str = "student", labels: int = 12):
136
+ """Confusion matrix of the student (or ``who="teacher"``) over the ``labels`` labels involved in the most
137
+ mistakes: rows are the gold labels, columns the predicted ones."""
138
+ from .analyse import predictions
139
+
140
+ plt = _plt()
141
+ p = predictions(Path(run))
142
+ names, gold, pred = p["task"].labels, p["gold"], p[who]
143
+ errors = Counter()
144
+ for g, x in zip(gold, pred, strict=True):
145
+ if g != x:
146
+ errors[g] += 1
147
+ errors[x] += 1
148
+ keep = sorted(c for c, _ in errors.most_common(labels)) or list(range(min(labels, len(names))))
149
+ index = {c: i for i, c in enumerate(keep)}
150
+ m = [[0] * len(keep) for _ in keep]
151
+ for g, x in zip(gold, pred, strict=True):
152
+ if g in index and x in index:
153
+ m[index[g]][index[x]] += 1
154
+ fig, ax = plt.subplots(figsize=(0.5 * len(keep) + 3, 0.45 * len(keep) + 2))
155
+ ax.imshow(m, cmap="Blues")
156
+ top = max(max(r) for r in m) or 1
157
+ for i, r in enumerate(m):
158
+ for j, v in enumerate(r):
159
+ if v:
160
+ ax.text(
161
+ j, i, v, ha="center", va="center", fontsize=8, color="#ffffff" if v > top / 2 else INK
162
+ )
163
+ ticks = [_name(names[c]) for c in keep]
164
+ ax.set_xticks(range(len(keep)), ticks, rotation=45, ha="right", fontsize=8)
165
+ ax.set_yticks(range(len(keep)), ticks, fontsize=8)
166
+ ax.grid(False)
167
+ ax.set(xlabel="predicted", ylabel="gold", title=f"Most confused labels ({who})")
168
+ fig.tight_layout()
169
+ return fig
170
+
171
+
172
+ def speed_accuracy(run: str | Path):
173
+ """Accuracy against latency per text (batch 1, log scale) for every timed model, with the upper bound and
174
+ the gold-only baseline as reference lines when the run has them."""
175
+ plt = _plt()
176
+ rows = _results(Path(run))["models"]
177
+ timed = [r for r in rows if r.get("latency_ms_b1") and r.get("accuracy") is not None]
178
+ if not timed:
179
+ raise ValueError("no timed model with an accuracy in results.json")
180
+ fig, ax = plt.subplots(figsize=(7, 4))
181
+ students = [r for r in timed if not r["role"].startswith("teacher")]
182
+ deployed = min(students, key=lambda r: r["latency_ms_b1"]) if students else None # e.g. ONNX int8
183
+ for r in timed:
184
+ color = TEACHER if r["role"].startswith("teacher") else STUDENT
185
+ ax.scatter(r["latency_ms_b1"], r["accuracy"], color=color, s=60, zorder=3, edgecolor=SURFACE, lw=1.5)
186
+ if color == STUDENT and r is not deployed:
187
+ continue # label only the teacher and the fastest student, so labels never collide
188
+ label = r["name"].replace("teacher, ", "teacher ").replace("student, ", "student ")
189
+ ax.annotate(
190
+ f"{label} ({r['device']})",
191
+ (r["latency_ms_b1"], r["accuracy"]),
192
+ textcoords="offset points",
193
+ xytext=(6, 4),
194
+ fontsize=8,
195
+ color=MUTED,
196
+ )
197
+ for r in rows:
198
+ if r["role"] in ("upper bound", "baseline") and r.get("accuracy") is not None:
199
+ ls = "--" if r["role"] == "upper bound" else ":"
200
+ ax.axhline(r["accuracy"], color=REFERENCE, lw=1, ls=ls)
201
+ ax.annotate(
202
+ r["role"],
203
+ (1, r["accuracy"]),
204
+ xycoords=("axes fraction", "data"),
205
+ ha="right",
206
+ va="bottom",
207
+ fontsize=8,
208
+ color=MUTED,
209
+ )
210
+ from matplotlib.ticker import FuncFormatter
211
+
212
+ ax.set_xscale("log")
213
+ ax.margins(x=0.3) # room for the labels on both sides
214
+ ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g}"))
215
+ ax.xaxis.set_minor_formatter(FuncFormatter(lambda v, _: f"{v:g}" if f"{v:g}"[0] in "25" else ""))
216
+ accs = [r["accuracy"] for r in rows if r.get("accuracy") is not None]
217
+ ax.set_ylim(max(0.0, min(accs) - 0.1), min(1.0, max(accs) + 0.05)) # never zoom in on a tiny gap
218
+ ax.set(
219
+ xlabel="latency per text, batch 1 (ms, log scale)", ylabel="test accuracy", title="Accuracy and speed"
220
+ )
221
+ roles = {r["role"].startswith("teacher") for r in timed}
222
+ for is_teacher, color, label in ((True, TEACHER, "teacher"), (False, STUDENT, "student")):
223
+ if is_teacher in roles:
224
+ ax.scatter([], [], color=color, label=label)
225
+ ax.legend(loc="lower left")
226
+ fig.tight_layout()
227
+ return fig
228
+
229
+
230
+ def cascade(run: str | Path):
231
+ """Accuracy as the student passes its least confident texts to the teacher."""
232
+ plt = _plt()
233
+ points = _results(Path(run)).get("cascade")
234
+ if not points:
235
+ raise ValueError("results.json has no cascade (single-label runs with a gold-labelled test set)")
236
+ x = [100 * c["share_to_teacher"] for c in points]
237
+ y = [c["accuracy"] for c in points]
238
+ fig, ax = plt.subplots(figsize=(7, 3.6))
239
+ ax.plot(x, y, color=STUDENT, lw=2, marker="o", ms=6)
240
+ for i, text, ha in ((0, "student only", "left"), (-1, "teacher only", "right")):
241
+ ax.annotate(
242
+ text, (x[i], y[i]), textcoords="offset points", xytext=(0, -16), ha=ha, fontsize=8, color=MUTED
243
+ )
244
+ ax.margins(y=0.15)
245
+ ax.set(
246
+ xlabel="texts sent to the teacher (%)",
247
+ ylabel="test accuracy",
248
+ title="Cascade: unsure texts go to the teacher",
249
+ )
250
+ fig.tight_layout()
251
+ return fig
252
+
253
+
254
+ def soft_labels(run: str | Path, n: int = 3, k: int = 4):
255
+ """For ``n`` test texts with different gold labels: the teacher's probabilities (the soft labels the
256
+ student learned from) next to the student's, over the ``k`` most likely labels."""
257
+ import math
258
+
259
+ from .export import Predictor
260
+ from .task import load_task
261
+ from .web import _samples
262
+
263
+ plt = _plt()
264
+ run = Path(run)
265
+ task = load_task(run / "task.yaml")
266
+ if task.multi_label:
267
+ raise ValueError("soft_labels is for single-label tasks")
268
+ rows = [
269
+ json.loads(line) for line in (run / "test.jsonl").read_text(encoding="utf-8").splitlines() if line
270
+ ]
271
+ rows = _samples([r for r in rows if r.get("scores")], n)
272
+ if not rows:
273
+ raise ValueError("the run has no test texts with teacher scores")
274
+ student = Predictor(run / "onnx").predict_proba([r["text"] for r in rows])
275
+ fig, axes = plt.subplots(
276
+ len(rows), 1, figsize=(7, 1.55 * len(rows) * max(1, k / 4) + 0.6), squeeze=False, sharex=True
277
+ )
278
+ for ax, r, q in zip(axes[:, 0], rows, student, strict=True):
279
+ m = max(r["scores"])
280
+ e = [math.exp(x - m) for x in r["scores"]]
281
+ p = [x / sum(e) for x in e]
282
+ top = sorted(range(len(p)), key=lambda c: -(p[c] + q[c]))[:k]
283
+ y = range(len(top))
284
+ ax.barh([i - 0.2 for i in y], [p[c] for c in top], height=0.38, color=TEACHER, label="teacher")
285
+ ax.barh([i + 0.2 for i in y], [q[c] for c in top], height=0.38, color=STUDENT, label="student")
286
+ gold = r.get("gold")
287
+ ax.set_yticks(
288
+ list(y), [_name(task.labels[c]) + (" (gold)" if c == gold else "") for c in top], fontsize=8
289
+ )
290
+ ax.set_ylim(len(top) - 0.5, -0.5)
291
+ ax.set_xlim(0, 1)
292
+ ax.grid(axis="y", visible=False)
293
+ text = r["text"] if len(r["text"]) <= 90 else r["text"][:87] + "..."
294
+ ax.set_title(f"\u201c{text}\u201d", fontsize=9, fontweight="normal", color=MUTED)
295
+ axes[0, 0].legend(loc="lower right", bbox_to_anchor=(1, 1.18), ncols=2, borderaxespad=0)
296
+ axes[-1, 0].set_xlabel("probability")
297
+ fig.suptitle("Soft labels: teacher and student", x=0.02, ha="left", fontweight="bold", fontsize=12)
298
+ fig.tight_layout()
299
+ return fig
300
+
301
+
302
+ CHARTS = {
303
+ "training": training,
304
+ "speed_accuracy": speed_accuracy,
305
+ "per_label": per_label,
306
+ "confusions": confusions,
307
+ "cascade": cascade,
308
+ "soft_labels": soft_labels,
309
+ }
310
+
311
+
312
+ def save_all(run: str | Path, out: str | Path | None = None) -> list[Path]:
313
+ """Write every chart the run has data for to ``out`` (default ``RUN/plots``); returns the files."""
314
+ plt = _plt()
315
+ out = Path(out) if out else Path(run) / "plots"
316
+ out.mkdir(parents=True, exist_ok=True)
317
+ written = []
318
+ for name, chart in CHARTS.items():
319
+ try:
320
+ fig = chart(run)
321
+ except (FileNotFoundError, ValueError, SystemExit):
322
+ continue # this run has no data for the chart (e.g. multi-label, or no cascade)
323
+ fig.savefig(out / f"{name}.png", dpi=150)
324
+ plt.close(fig)
325
+ written.append(out / f"{name}.png")
326
+ return written
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import math
6
7
  import random
7
8
  import time
@@ -117,15 +118,23 @@ def train_student(
117
118
  if static := encoder_of(model_name):
118
119
  model, tok = new_static(static, labels, multi_label)
119
120
  else:
121
+ from transformers.utils import logging as hf_logging
122
+
120
123
  tok = AutoTokenizer.from_pretrained(model_name)
121
- model = AutoModelForSequenceClassification.from_pretrained(
122
- model_name,
123
- num_labels=len(labels),
124
- id2label=dict(enumerate(labels)),
125
- label2id={name: i for i, name in enumerate(labels)},
126
- ignore_mismatched_sizes=True,
127
- problem_type="multi_label_classification" if multi_label else "single_label_classification",
128
- )
124
+ level = hf_logging.get_verbosity()
125
+ hf_logging.set_verbosity_error() # its report of the new (missing) classifier head is expected noise
126
+ try:
127
+ model = AutoModelForSequenceClassification.from_pretrained(
128
+ model_name,
129
+ num_labels=len(labels),
130
+ id2label=dict(enumerate(labels)),
131
+ label2id={name: i for i, name in enumerate(labels)},
132
+ ignore_mismatched_sizes=True,
133
+ problem_type="multi_label_classification" if multi_label else "single_label_classification",
134
+ )
135
+ finally:
136
+ hf_logging.set_verbosity(level)
137
+ log(f"{model_name}: a new classification head for {len(labels)} labels on the pretrained encoder")
129
138
  model = model.to(device)
130
139
  gold = gold or [None] * len(texts)
131
140
  if multi_label: # multi-hot gold, and which texts have it
@@ -169,6 +178,7 @@ def train_student(
169
178
  )
170
179
 
171
180
  best_loss, best_state, best_epoch = math.inf, None, epochs
181
+ history: list[dict] = [] # per epoch, saved as history.json next to the model
172
182
  for epoch in range(epochs):
173
183
  model.train()
174
184
  rng.shuffle(order)
@@ -185,6 +195,7 @@ def train_student(
185
195
  if on_step:
186
196
  on_step()
187
197
  line = f"epoch {epoch + 1}/{epochs}: loss {total / len(order):.4f}"
198
+ history.append({"epoch": epoch + 1, "loss": total / len(order)})
188
199
  if val:
189
200
  model.eval()
190
201
  with torch.no_grad():
@@ -193,6 +204,7 @@ def train_student(
193
204
  )
194
205
  v /= len(val)
195
206
  line += f", held-out loss {v:.4f}"
207
+ history[-1]["held_out_loss"] = v
196
208
  if v < best_loss:
197
209
  best_loss, best_epoch = v, epoch + 1
198
210
  best_state = {k: t.detach().clone() for k, t in model.state_dict().items()}
@@ -206,7 +218,12 @@ def train_student(
206
218
  out_dir.mkdir(parents=True, exist_ok=True)
207
219
  model.save_pretrained(out_dir)
208
220
  tok.save_pretrained(out_dir)
209
- return best_epoch
221
+ kept = best_epoch if best_state is not None else len(history)
222
+ (out_dir / "history.json").write_text(
223
+ json.dumps({"epochs": history, "kept_epoch": kept, "held_out_texts": len(val)}, indent=1),
224
+ encoding="utf-8",
225
+ )
226
+ return kept
210
227
 
211
228
 
212
229
  def load_student(path: Path, device: str = "cpu"):
@@ -293,11 +293,14 @@ def test_push_writes_a_model_card_and_uploads(tmp_path, monkeypatch):
293
293
  onnx = tmp_path / "run" / "onnx"
294
294
  onnx.mkdir(parents=True)
295
295
  (onnx / "labels.json").write_text('["neg", "pos"]')
296
- (tmp_path / "run" / "meta.json").write_text('{"teacher": "T", "student": "S"}')
296
+ (tmp_path / "run" / "meta.json").write_text(
297
+ '{"teacher": "T", "student": "org/S", "data": "hf:org/ds:train", "test": "hf:org/ds:test"}'
298
+ )
297
299
  (tmp_path / "run" / "results.md").write_text("| Model |\n|---|\n| x |\n")
298
300
  main(["push", str(onnx), "--repo", "me/clf", "--private", "--license", "apache-2.0"])
299
301
  card = (onnx / "README.md").read_text(encoding="utf-8")
300
302
  assert "license: apache-2.0" in card and "`T`" in card and "## Results" in card and '"hf:me/clf"' in card
303
+ assert "base_model: org/S" in card and "datasets:\n- org/ds\n---" in card
301
304
  assert calls == [("create", "me/clf", True), ("upload", str(onnx), "me/clf")]
302
305
 
303
306
 
@@ -959,3 +962,17 @@ def test_train_keeps_the_best_epoch_on_held_out_texts(tmp_path):
959
962
  assert "holds out 20% of its training texts and keeps the epoch" in (run / "results.md").read_text(
960
963
  encoding="utf-8"
961
964
  )
965
+
966
+ history = json.loads((run / "student" / "history.json").read_text(encoding="utf-8"))
967
+ assert (
968
+ history["kept_epoch"] == meta["kept_epochs"]["student"][0] and "held_out_loss" in history["epochs"][0]
969
+ )
970
+ main(["plot", str(run)])
971
+ assert {p.name for p in (run / "plots").glob("*.png")} == {
972
+ "training.png",
973
+ "speed_accuracy.png",
974
+ "per_label.png",
975
+ "confusions.png",
976
+ "cascade.png",
977
+ "soft_labels.png",
978
+ }
@@ -85,3 +85,19 @@ def test_explain_export_matches_the_model(tmp_path):
85
85
  np.testing.assert_allclose(attention[0, :, : real[0]].sum(-1), 1, atol=1e-4) # a distribution over tokens
86
86
  assert np.allclose(attention[0, :, real[0] :], 0, atol=1e-6) # padding gets none
87
87
  assert export_explain(tmp_path / "student", tmp_path / "q", int8=True).name == "explain.int8.onnx"
88
+
89
+
90
+ def test_int8_scores_do_not_depend_on_the_batch(tmp_path):
91
+ torch.manual_seed(0)
92
+ model = AutoModelForSequenceClassification.from_pretrained(
93
+ TINY_ENCODER, num_labels=3, id2label={0: "a", 1: "b", 2: "c"}, ignore_mismatched_sizes=True
94
+ )
95
+ model.save_pretrained(tmp_path / "student")
96
+ AutoTokenizer.from_pretrained(TINY_ENCODER).save_pretrained(tmp_path / "student")
97
+ export_onnx(tmp_path / "student", tmp_path / "onnx", int8=True)
98
+ p = Predictor(tmp_path / "onnx")
99
+ assert p.int8
100
+ text = "the same text every time"
101
+ alone = p.logits([text])[0]
102
+ for others in (["short"], ["a much longer text with many more words in it than the first one", "x"]):
103
+ np.testing.assert_allclose(p.logits([text, *others])[0], alone, rtol=0, atol=1e-6)
@@ -175,7 +175,8 @@ def test_best_epoch_is_kept_and_training_stops_early(tmp_path):
175
175
  patience=1,
176
176
  ) # nothing is learned, so the held-out loss never improves after epoch 1
177
177
  assert kept == 1
178
- assert sum(line.startswith("epoch ") for line in lines) == 2 and "held-out loss" in lines[0]
178
+ epochs = [line for line in lines if line.startswith("epoch ")]
179
+ assert len(epochs) == 2 and "held-out loss" in epochs[0]
179
180
  assert any("keeping epoch 1" in line for line in lines) and (tmp_path / "s" / "config.json").exists()
180
181
  assert (
181
182
  train_student(texts, probs, ["a", "b"], tmp_path / "t", TINY_ENCODER, epochs=2, log=lines.append) == 2
File without changes
File without changes