forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdistill.py
More file actions
360 lines (310 loc) · 12.4 KB
/
Copy pathdistill.py
File metadata and controls
360 lines (310 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
"""v0.52.0 Part C — Knowledge Distillation schema helpers.
Schema-only support for ``task='distill'`` — teacher/student training.
Four divergence options are recognised, mirroring axolotl's distillation
plugin:
* ``kl`` (forward KL — student KL teacher, standard distillation)
* ``forward_kl`` (alias for ``kl``)
* ``reverse_kl`` (teacher KL student)
* ``js`` (Jensen-Shannon, symmetric)
The live distillation trainer lands in v0.52.1; this module exposes pure
validators so the schema gate can fail fast on misconfiguration.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
_DIVERGENCE_ALIASES: Mapping[str, str] = MappingProxyType({
"kl": "forward_kl",
"forward_kl": "forward_kl",
"reverse_kl": "reverse_kl",
"js": "js",
})
# Public, derived from the alias map so adding a new alias updates both the
# accepted-input set and the error message in lockstep.
DIVERGENCES: frozenset[str] = frozenset(_DIVERGENCE_ALIASES)
# v0.71.12 #145 — cross-tokenizer distillation modes.
# * token — column-aligned logit KL (default; requires same tokenizer, or
# training.uld_strategy for the cross-tokenizer logit-level path).
# * sequence — sequence-level KD (Kim & Rush 2016): the teacher GENERATES a
# completion per prompt and the student does plain CE on the
# re-tokenised teacher output. Works across ANY tokenizer pair.
SUPPORTED_DISTILL_MODES: frozenset[str] = frozenset({"token", "sequence"})
_MAX_TEACHER_LEN: int = 512
_MAX_DIVERGENCE_LEN: int = 16
_MAX_DISTILL_MODE_LEN: int = 16
_MIN_TEMPERATURE: float = 0.05
_MAX_TEMPERATURE: float = 100.0
# Default teacher generation budget for sequence-level KD. Clamped to
# ``data.max_length`` by the trainer; bounded here as a DoS guard.
_DEFAULT_SEQ_MAX_NEW_TOKENS: int = 256
_MAX_SEQ_MAX_NEW_TOKENS: int = 4096
@dataclass(frozen=True)
class DivergenceSpec:
"""Metadata for a divergence kernel. Frozen so callers cannot mutate."""
name: str
description: str
symmetric: bool
live_wired: bool
_DIVERGENCE_METADATA: Mapping[str, DivergenceSpec] = MappingProxyType({
"forward_kl": DivergenceSpec(
name="forward_kl",
description="Forward KL (standard distillation)",
symmetric=False,
live_wired=False,
),
"reverse_kl": DivergenceSpec(
name="reverse_kl",
description="Reverse KL (mode-seeking)",
symmetric=False,
live_wired=False,
),
"js": DivergenceSpec(
name="js",
description="Jensen-Shannon (symmetric KL)",
symmetric=True,
live_wired=False,
),
})
def validate_divergence(name: object) -> str:
"""Validate a divergence name and return the canonical form.
Accepts ``kl`` as an alias for ``forward_kl``. Mirrors v0.41.0
``validate_optimizer_name`` policy.
"""
if isinstance(name, bool):
raise TypeError(f"distill_divergence must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(
f"distill_divergence must be str, got {type(name).__name__}"
)
if not name:
raise ValueError("distill_divergence must be non-empty")
if "\x00" in name:
raise ValueError("distill_divergence must not contain null bytes")
if len(name) > _MAX_DIVERGENCE_LEN:
raise ValueError(
f"distill_divergence too long (max {_MAX_DIVERGENCE_LEN} chars)"
)
canonical = name.lower()
if canonical not in _DIVERGENCE_ALIASES:
supported = ", ".join(sorted(DIVERGENCES))
raise ValueError(
f"distill_divergence {name!r} not supported. Supported: {supported}"
)
return _DIVERGENCE_ALIASES[canonical]
def get_divergence_spec(name: str) -> DivergenceSpec:
"""Return the frozen :class:`DivergenceSpec` for ``name`` or raise."""
canonical = validate_divergence(name)
return _DIVERGENCE_METADATA[canonical]
def validate_distill_mode(value: object) -> str:
"""Validate ``training.distill_mode`` and return the canonical form.
Accepts ``token`` (default) or ``sequence`` (case-insensitive). Mirrors
the v0.41.0 / v0.52.0 validator policy (bool-first, null-byte, oversize,
case-insensitive normalisation).
"""
if isinstance(value, bool):
raise TypeError(f"distill_mode must not be bool, got {value!r}")
if not isinstance(value, str):
raise TypeError(
f"distill_mode must be str, got {type(value).__name__}"
)
if not value:
raise ValueError("distill_mode must be non-empty")
if "\x00" in value:
raise ValueError("distill_mode must not contain null bytes")
if len(value) > _MAX_DISTILL_MODE_LEN:
raise ValueError(
f"distill_mode too long (max {_MAX_DISTILL_MODE_LEN} chars)"
)
canonical = value.lower()
if canonical not in SUPPORTED_DISTILL_MODES:
supported = ", ".join(sorted(SUPPORTED_DISTILL_MODES))
raise ValueError(
f"distill_mode {value!r} not supported. Supported: {supported}"
)
return canonical
def validate_distill_temperature(value: object) -> float:
"""Validate a distillation temperature scalar.
Bounds [0.05, 100.0]. Rejects bool, NaN, ±inf.
"""
if isinstance(value, bool):
raise TypeError(
f"distill_temperature must not be bool, got {value!r}"
)
if not isinstance(value, (int, float)):
raise TypeError(
f"distill_temperature must be float, got {type(value).__name__}"
)
fval = float(value)
if not math.isfinite(fval):
raise ValueError(
f"distill_temperature must be finite, got {value!r}"
)
if fval < _MIN_TEMPERATURE:
raise ValueError(
f"distill_temperature must be >= {_MIN_TEMPERATURE}, got {fval}"
)
if fval > _MAX_TEMPERATURE:
raise ValueError(
f"distill_temperature must be <= {_MAX_TEMPERATURE}, got {fval}"
)
return fval
def validate_teacher_model(value: object) -> str:
"""Validate a teacher model string (HF repo id or local path).
Mirrors the v0.40.5 ``reward_model`` field validator: null-byte
rejection + 512-char cap.
"""
if isinstance(value, bool):
raise TypeError(f"teacher_model must not be bool, got {value!r}")
if not isinstance(value, str):
raise TypeError(
f"teacher_model must be str, got {type(value).__name__}"
)
if not value:
raise ValueError("teacher_model must be non-empty")
if "\x00" in value:
raise ValueError("teacher_model must not contain null bytes")
if len(value) > _MAX_TEACHER_LEN:
raise ValueError(
f"teacher_model too long (max {_MAX_TEACHER_LEN} chars)"
)
return value
def validate_distill_compat(
*,
task: str,
backend: str,
teacher_model: object,
) -> None:
"""Schema-time gate for ``task='distill'``.
Rejects:
- non-distill task.
- ``backend == 'mlx'`` (no MLX teacher-load path yet).
- missing teacher_model — distillation is meaningless without one.
"""
for name, value in (("task", task), ("backend", backend)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if task != "distill":
raise ValueError(
f"validate_distill_compat called with task={task!r} "
"(expected 'distill')"
)
if backend == "mlx":
raise ValueError(
"task='distill' is not supported on backend=mlx in v0.52.0"
)
if teacher_model is None:
raise ValueError(
"task='distill' requires training.teacher_model to be set"
)
# Reuse the standard validator — null-byte / oversize / type check.
validate_teacher_model(teacher_model)
def extract_prompt_messages(messages: object) -> list:
"""Return the prompt portion of a chat-messages list (v0.71.12 #145).
The "prompt" is everything up to (and excluding) a trailing assistant
turn — the part the teacher should be conditioned on for sequence-level
KD. If the final message is not an assistant turn (prompt-only dataset)
the whole list is returned.
Raises:
TypeError: ``messages`` is not a list.
"""
if not isinstance(messages, list):
raise TypeError(
f"messages must be a list, got {type(messages).__name__}"
)
prompt: list = list(messages)
# Strip a single trailing assistant turn (the teacher will regenerate it).
while prompt and isinstance(prompt[-1], dict) and prompt[-1].get("role") == "assistant":
prompt = prompt[:-1]
return prompt
def _resolve_seq_max_new_tokens(value: object) -> int:
"""Clamp the sequence-KD generation budget to ``[1, _MAX_SEQ_MAX_NEW_TOKENS]``."""
if value is None:
return _DEFAULT_SEQ_MAX_NEW_TOKENS
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError("max_new_tokens must be int")
if value < 1:
raise ValueError("max_new_tokens must be positive")
return min(value, _MAX_SEQ_MAX_NEW_TOKENS)
def build_sequence_distill_rows(
rows,
teacher,
teacher_tokenizer,
*,
max_new_tokens: object = None,
device: str | None = None,
) -> list:
"""Sequence-level KD dataset builder (v0.71.12 #145 — Kim & Rush 2016).
For each ``{"messages": [...]}`` row, the teacher GENERATES a completion
from the prompt portion (its own tokenizer + chat template), and a new
student row is emitted with the teacher's text as the assistant turn::
{"messages": prompt + [{"role": "assistant", "content": <teacher text>}]}
The student then trains with plain CE on the re-tokenised teacher output —
which works across ANY tokenizer pair. The teacher is used for generation
only; it is NOT needed in the student loss loop.
Args:
rows: iterable of dataset rows (each a dict with a ``messages`` list).
teacher: a loaded causal-LM with ``.generate`` and ``.parameters()``.
teacher_tokenizer: the teacher's tokenizer (its own chat template).
max_new_tokens: generation budget (clamped to a DoS-safe ceiling).
device: optional device override; defaults to the teacher's device.
Returns:
A list of student ``{"messages": [...]}`` rows.
"""
import torch
budget = _resolve_seq_max_new_tokens(max_new_tokens)
try:
teacher_device = next(teacher.parameters()).device
except (StopIteration, AttributeError):
teacher_device = device or "cpu"
pad_id = getattr(teacher_tokenizer, "pad_token_id", None)
if pad_id is None:
pad_id = getattr(teacher_tokenizer, "eos_token_id", None)
out_rows: list = []
for row in rows:
if not isinstance(row, dict):
continue
messages = row.get("messages")
if not isinstance(messages, list) or not messages:
continue
prompt_msgs = extract_prompt_messages(messages)
if not prompt_msgs:
continue
input_ids = teacher_tokenizer.apply_chat_template(
prompt_msgs,
add_generation_prompt=True,
return_tensors="pt",
tokenize=True,
)
if hasattr(input_ids, "to"):
input_ids = input_ids.to(teacher_device)
prompt_len = int(input_ids.shape[-1])
with torch.no_grad():
generated = teacher.generate(
input_ids=input_ids,
max_new_tokens=budget,
do_sample=False,
pad_token_id=pad_id,
)
new_tokens = generated[0][prompt_len:]
teacher_text = teacher_tokenizer.decode(
new_tokens, skip_special_tokens=True
).strip()
out_rows.append(
{
"messages": list(prompt_msgs)
+ [{"role": "assistant", "content": teacher_text}]
}
)
return out_rows
def build_distill_trainer(
config: object, **kwargs: object
) -> object:
"""Live distillation trainer factory (v0.53.2 #133).
Returns a :class:`DistillTrainerWrapper`. Lazy import keeps the heavy
transformers/peft surface out of schema-only import paths.
"""
from soup_cli.trainer.distill import DistillTrainerWrapper
return DistillTrainerWrapper(config, **kwargs) # type: ignore[arg-type]