sysone 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.
sysone/base.py ADDED
@@ -0,0 +1,890 @@
1
+ """SystemOneModel: the scikit-learn style estimator behind EncoderModel and DecoderModel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import inspect
7
+ import json
8
+ import math
9
+ import random
10
+ import shutil
11
+ import warnings
12
+ from collections.abc import Mapping
13
+ from numbers import Real
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import torch
18
+
19
+ from ._util import format_bytes, resolve_count, resolve_device, resolve_dtype
20
+ from .answers import answer
21
+ from .architectures.base import Architecture
22
+ from .architectures.registry import architecture_for, architecture_from_spec, architecture_spec
23
+ from .cache import FeatureCache, question_key
24
+ from .checkpoint import (
25
+ ADAPTER_DIR,
26
+ BACKBONE_FILE,
27
+ STATE_DIR,
28
+ load_adapter,
29
+ load_backbone_weights,
30
+ load_training_state,
31
+ save_adapter,
32
+ save_backbone_weights,
33
+ save_training_state,
34
+ )
35
+ from .data import Example, examples_from
36
+ from .encoding import Encoder, measure_limits
37
+ from .evaluation import score
38
+ from .features import cache_bytes, collate_features, extract
39
+ from .heads import CompactHead
40
+ from .layout import LAYOUTS, Encoded
41
+ from .losses import LOSSES
42
+ from .prompts import PromptFormat
43
+ from .questions import QUESTION_TYPES
44
+ from .training import (
45
+ CHECKS_PER_EPOCH,
46
+ OPTIMIZERS,
47
+ SCHEDULERS,
48
+ Network,
49
+ TrainItem,
50
+ TrainSettings,
51
+ TrainState,
52
+ evaluate_items,
53
+ sample_weights,
54
+ split_validation,
55
+ train,
56
+ )
57
+ from .tuning import (
58
+ LayerPlan,
59
+ add_adapters,
60
+ effective_tuning,
61
+ layer_plan,
62
+ lora_config,
63
+ quantization_config,
64
+ round_trained,
65
+ set_trainable,
66
+ trained_parameters,
67
+ wants_checkpointing,
68
+ )
69
+ from .version import __version__
70
+
71
+ TUNINGS = ("head", "qlora", "lora", "full")
72
+ HEADS = ("compact", "full")
73
+ LORA_TARGET_GROUPS = ("attention", "mlp", "all")
74
+ CONFIG_FILE = "config.json"
75
+ HEAD_FILE = "head.safetensors"
76
+ #: Above this estimated size, cached features are not kept in memory and the backbone runs every epoch.
77
+ CACHE_LIMIT_BYTES = 8 * 1024**3
78
+ #: With save(training_state="auto"), a larger training state is left out.
79
+ TRAINING_STATE_AUTO_BYTES = 100 * 1024**2
80
+ #: Examples per forward pass to start with when the backbone learns; halved if memory runs out.
81
+ START_MICRO_BATCH = 16
82
+
83
+ # Parameters that change the model's structure. If one changes between two calls to `fit`, the
84
+ # second call starts over instead of continuing training.
85
+ _STRUCTURAL = ("model_name", "revision", "architecture", "num_layers", "layout", "max_state_tokens",
86
+ "max_option_tokens", "max_len", "head", "head_layers", "head_dim", "head_dropout",
87
+ "precision", "device", "tuning", "trainable_layers", "lora_layers", "train_embeddings",
88
+ "lora_rank", "lora_alpha", "lora_dropout", "lora_targets", "quant_bits", "quant_type")
89
+ # Adapter settings that only matter for tuning="lora" / "qlora".
90
+ _ADAPTER_PARAMS = ("lora_rank", "lora_alpha", "lora_dropout", "lora_targets", "quant_bits", "quant_type")
91
+ # Settings that shape a training run. A saved run resumes only if these and the data are unchanged.
92
+ _RUN_PARAMS = ("batch_size", "validation_fraction", "validation_interval", "seed", "balance", "optimizer",
93
+ "scheduler", "warmup_ratio", "backbone_lr", "head_lr", "weight_decay", "grad_clip",
94
+ "shuffle_options", "label_smoothing")
95
+
96
+
97
+ class NotFittedError(RuntimeError):
98
+ """Raised when predicting with a model that has neither been fit nor loaded."""
99
+
100
+
101
+ class SystemOneModel:
102
+ """Fine-tune and calibrate a system-one decision model.
103
+
104
+ Every hyperparameter is a keyword argument; `__init__` only stores them, so `get_params`,
105
+ `set_params` and `clone`-style copying work. Anything learned in `fit` is stored on an
106
+ attribute with a trailing underscore. See `docs/DESIGN.md` for every parameter.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ model_name: str,
112
+ *,
113
+ architecture: Architecture | None = None,
114
+ revision: str | None = None,
115
+ # how much of the backbone learns
116
+ tuning: str = "qlora",
117
+ num_layers=None,
118
+ trainable_layers=None,
119
+ lora_layers=None,
120
+ train_embeddings: bool = False,
121
+ layout: str = "parallel",
122
+ # length limits
123
+ max_state_tokens="auto",
124
+ max_option_tokens="auto",
125
+ max_len: int | None = None,
126
+ # adapter details
127
+ lora_rank: int = 8,
128
+ lora_alpha: int | None = None,
129
+ lora_dropout: float = 0.0,
130
+ lora_targets="attention",
131
+ quant_bits: int = 4,
132
+ quant_type: str = "nf4",
133
+ # decision head
134
+ head: str = "compact",
135
+ head_layers: int = 2,
136
+ head_dim="auto",
137
+ head_dropout: float = 0.0,
138
+ # optimization
139
+ optimizer: str = "adamw",
140
+ backbone_lr="auto",
141
+ head_lr: float = 1e-3,
142
+ weight_decay: float = 0.01,
143
+ scheduler: str = "cosine",
144
+ warmup_ratio: float = 0.05,
145
+ grad_clip: float = 1.0,
146
+ batch_size: int = 64,
147
+ epochs: int = 20,
148
+ # stopping and validation
149
+ early_stopping: bool = True,
150
+ validation_fraction: float = 0.1,
151
+ n_iter_no_change: int = 3,
152
+ target_loss: float | None = None,
153
+ validation_interval="auto",
154
+ # objective and data
155
+ loss: str = "soft-ce",
156
+ label_smoothing: float = 0.0,
157
+ shuffle_options="auto",
158
+ balance: str | None = None,
159
+ calibrate: bool = True,
160
+ # runtime
161
+ cache_features="auto",
162
+ cache_dir=None,
163
+ cache_max_gb: float = 5,
164
+ precision: str = "auto",
165
+ gradient_checkpointing="auto",
166
+ device: str = "auto",
167
+ seed: int = 0,
168
+ verbose: int = 1,
169
+ ):
170
+ self.model_name = model_name
171
+ self.architecture = architecture
172
+ self.revision = revision
173
+ self.tuning = tuning
174
+ self.num_layers = num_layers
175
+ self.trainable_layers = trainable_layers
176
+ self.lora_layers = lora_layers
177
+ self.train_embeddings = train_embeddings
178
+ self.layout = layout
179
+ self.max_state_tokens = max_state_tokens
180
+ self.max_option_tokens = max_option_tokens
181
+ self.max_len = max_len
182
+ self.lora_rank = lora_rank
183
+ self.lora_alpha = lora_alpha
184
+ self.lora_dropout = lora_dropout
185
+ self.lora_targets = lora_targets
186
+ self.quant_bits = quant_bits
187
+ self.quant_type = quant_type
188
+ self.head = head
189
+ self.head_layers = head_layers
190
+ self.head_dim = head_dim
191
+ self.head_dropout = head_dropout
192
+ self.optimizer = optimizer
193
+ self.backbone_lr = backbone_lr
194
+ self.head_lr = head_lr
195
+ self.weight_decay = weight_decay
196
+ self.scheduler = scheduler
197
+ self.warmup_ratio = warmup_ratio
198
+ self.grad_clip = grad_clip
199
+ self.batch_size = batch_size
200
+ self.epochs = epochs
201
+ self.early_stopping = early_stopping
202
+ self.validation_fraction = validation_fraction
203
+ self.n_iter_no_change = n_iter_no_change
204
+ self.target_loss = target_loss
205
+ self.validation_interval = validation_interval
206
+ self.loss = loss
207
+ self.label_smoothing = label_smoothing
208
+ self.shuffle_options = shuffle_options
209
+ self.balance = balance
210
+ self.calibrate = calibrate
211
+ self.cache_features = cache_features
212
+ self.cache_dir = cache_dir
213
+ self.cache_max_gb = cache_max_gb
214
+ self.precision = precision
215
+ self.gradient_checkpointing = gradient_checkpointing
216
+ self.device = device
217
+ self.seed = seed
218
+ self.verbose = verbose
219
+
220
+ # -- parameters ----------------------------------------------------------------------------
221
+
222
+ @classmethod
223
+ def _param_defaults(cls) -> dict[str, Any]:
224
+ """Parameter names and defaults, read from the `__init__` signatures along the class
225
+ hierarchy. A subclass's default wins over its base's."""
226
+ defaults: dict[str, Any] = {}
227
+ for klass in reversed(cls.__mro__):
228
+ init = klass.__dict__.get("__init__")
229
+ if init is None or klass is object:
230
+ continue
231
+ for name, p in inspect.signature(init).parameters.items():
232
+ if name == "self" or p.kind in (p.VAR_KEYWORD, p.VAR_POSITIONAL):
233
+ continue
234
+ defaults[name] = p.default
235
+ return defaults
236
+
237
+ def get_params(self, deep: bool = True) -> dict[str, Any]:
238
+ return {name: getattr(self, name) for name in self._param_defaults()}
239
+
240
+ def set_params(self, **params) -> SystemOneModel:
241
+ valid = self._param_defaults()
242
+ for name, value in params.items():
243
+ if name not in valid:
244
+ raise ValueError("invalid parameter %r for %s; valid parameters: %s"
245
+ % (name, type(self).__name__, ", ".join(sorted(valid))))
246
+ setattr(self, name, value)
247
+ return self
248
+
249
+ def __repr__(self) -> str:
250
+ defaults = self._param_defaults()
251
+ changed = ["%s=%r" % (k, v) for k, v in self.get_params().items()
252
+ if defaults.get(k, inspect.Parameter.empty) is inspect.Parameter.empty or v != defaults[k]]
253
+ return "%s(%s)" % (type(self).__name__, ", ".join(changed))
254
+
255
+ def _log(self, message: str) -> None:
256
+ if self.verbose:
257
+ print("[sysone] %s" % message, flush=True)
258
+
259
+ # -- validation ----------------------------------------------------------------------------
260
+
261
+ def _check_params(self) -> None:
262
+ """Reject unsupported values before any weights load."""
263
+
264
+ def one_of(name, allowed):
265
+ value = getattr(self, name)
266
+ if value not in allowed:
267
+ raise ValueError("%s must be one of %s, got %r" % (name, ", ".join(map(repr, allowed)), value))
268
+
269
+ def positive_int(name):
270
+ value = getattr(self, name)
271
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
272
+ raise ValueError("%s must be a positive integer, got %r" % (name, value))
273
+
274
+ def positive_number(name, optional=False):
275
+ value = getattr(self, name)
276
+ if optional and value is None:
277
+ return
278
+ if isinstance(value, bool) or not isinstance(value, Real) or not value > 0 or not math.isfinite(value):
279
+ raise ValueError("%s must be a positive number%s, got %r"
280
+ % (name, " or None" if optional else "", value))
281
+
282
+ one_of("tuning", TUNINGS)
283
+ one_of("layout", LAYOUTS)
284
+ one_of("head", HEADS)
285
+ one_of("optimizer", OPTIMIZERS)
286
+ one_of("scheduler", SCHEDULERS)
287
+ one_of("balance", (None, "category", "question"))
288
+ one_of("quant_bits", (4, 8))
289
+ one_of("quant_type", ("nf4", "fp4"))
290
+ one_of("gradient_checkpointing", ("auto", True, False))
291
+ for name in ("batch_size", "epochs", "n_iter_no_change", "lora_rank"):
292
+ positive_int(name)
293
+ positive_number("target_loss", optional=True)
294
+ positive_number("lora_alpha", optional=True)
295
+ positive_number("cache_max_gb")
296
+ if self.backbone_lr != "auto":
297
+ positive_number("backbone_lr")
298
+ if self.validation_interval != "auto":
299
+ positive_int("validation_interval")
300
+ if not 0.0 <= self.lora_dropout < 1.0:
301
+ raise ValueError("lora_dropout must be in [0, 1), got %r" % (self.lora_dropout,))
302
+ targets = self.lora_targets
303
+ if isinstance(targets, str) and targets not in LORA_TARGET_GROUPS or not isinstance(targets, str) and (
304
+ not isinstance(targets, (list, tuple)) or not targets or not all(isinstance(t, str) for t in targets)):
305
+ raise ValueError("lora_targets must be one of %s or a list of module names, got %r"
306
+ % (", ".join(map(repr, LORA_TARGET_GROUPS)), targets))
307
+ if isinstance(self.head_layers, bool) or not isinstance(self.head_layers, int) or self.head_layers < 0:
308
+ raise ValueError("head_layers must be a non-negative integer, got %r" % (self.head_layers,))
309
+ if not 0.0 <= self.validation_fraction < 1.0:
310
+ raise ValueError("validation_fraction must be in [0, 1), got %r" % (self.validation_fraction,))
311
+ if not 0.0 <= self.label_smoothing < 1.0:
312
+ raise ValueError("label_smoothing must be in [0, 1), got %r" % (self.label_smoothing,))
313
+ for name in ("max_state_tokens", "max_option_tokens"):
314
+ value = getattr(self, name)
315
+ if value != "auto" and (isinstance(value, bool) or not isinstance(value, int) or value < 1):
316
+ raise ValueError("%s must be 'auto' or a positive integer, got %r" % (name, value))
317
+ if self.head_dim != "auto" and (isinstance(self.head_dim, bool) or not isinstance(self.head_dim, int)
318
+ or self.head_dim < 1):
319
+ raise ValueError("head_dim must be 'auto' or a positive integer, got %r" % (self.head_dim,))
320
+ if self.shuffle_options not in ("auto", None):
321
+ unknown = set(self.shuffle_options) - set(QUESTION_TYPES)
322
+ if isinstance(self.shuffle_options, str) or unknown:
323
+ raise ValueError("shuffle_options must be 'auto', None or a list of question types, got %r"
324
+ % (self.shuffle_options,))
325
+ if self.loss not in LOSSES + ("rlcd",):
326
+ raise ValueError("loss must be one of 'soft-ce', 'rlcd', got %r" % (self.loss,))
327
+ if self.cache_features not in ("auto", True, False):
328
+ raise ValueError("cache_features must be 'auto', True or False, got %r" % (self.cache_features,))
329
+ if self.tuning in ("head", "full") and self.lora_layers not in (None, 0):
330
+ raise ValueError("lora_layers=%r needs tuning='lora' or 'qlora', not %r" % (self.lora_layers, self.tuning))
331
+ if self.tuning == "qlora" and self.trainable_layers not in (None, 0):
332
+ raise ValueError("tuning='qlora' can't fully train layers (trainable_layers=%r): quantized layers "
333
+ "can't be fine-tuned; use tuning='lora'" % (self.trainable_layers,))
334
+ if self.tuning == "head" and (self.trainable_layers not in (None, 0) or self.train_embeddings):
335
+ raise ValueError("tuning='head' trains only the head; trainable_layers and train_embeddings need "
336
+ "tuning='lora', 'qlora' or 'full'")
337
+ if self.tuning != "head" and self.cache_features is True:
338
+ raise ValueError("cache_features=True needs tuning='head': features can only be cached when the "
339
+ "backbone is frozen")
340
+ if self.tuning not in ("lora", "qlora"):
341
+ defaults = self._param_defaults()
342
+ changed = [n for n in _ADAPTER_PARAMS if getattr(self, n) != defaults[n]]
343
+ if changed:
344
+ warnings.warn("%s only apply to tuning='lora' or 'qlora' and are ignored with tuning=%r"
345
+ % (", ".join(changed), self.tuning), UserWarning, stacklevel=3)
346
+ if self.cache_dir is not None and self.tuning != "head":
347
+ warnings.warn("cache_dir is ignored with tuning=%r: features can only be cached when the backbone "
348
+ "is frozen (tuning='head')" % (self.tuning,), UserWarning, stacklevel=3)
349
+
350
+ # Not implemented yet in this version.
351
+ if self.head != "compact":
352
+ raise NotImplementedError("head=%r is not implemented yet; use head='compact'" % (self.head,))
353
+ if self.loss != "soft-ce":
354
+ raise NotImplementedError("loss=%r is not implemented yet; use loss='soft-ce'" % (self.loss,))
355
+
356
+ # -- building ------------------------------------------------------------------------------
357
+
358
+ @property
359
+ def is_fitted(self) -> bool:
360
+ return getattr(self, "head_", None) is not None
361
+
362
+ def _structure(self) -> dict:
363
+ params = self.get_params()
364
+ params["architecture"] = None if self.architecture is None else architecture_spec(self.architecture)
365
+ return {k: params[k] for k in _STRUCTURAL}
366
+
367
+ def _shuffle_types(self) -> list[str]:
368
+ if self.shuffle_options == "auto":
369
+ return [] if self.layout == "parallel" else ["choice"]
370
+ return list(self.shuffle_options or [])
371
+
372
+ def _load_backbone(self, num_layers, revision, tuning: str) -> None:
373
+ """Load the tokenizer and the backbone (in 4-bit or 8-bit for tuning="qlora")."""
374
+ from transformers import AutoTokenizer
375
+
376
+ self.device_ = resolve_device(self.device)
377
+ self.dtype_ = resolve_dtype(self.precision, self.device_)
378
+ self.tuning_ = tuning
379
+ self.architecture_ = self.architecture if self.architecture is not None else \
380
+ architecture_for(self.model_name, revision=revision)
381
+ self.tokenizer_ = AutoTokenizer.from_pretrained(self.model_name, revision=revision)
382
+ self.architecture_.check_tokenizer(self.tokenizer_)
383
+ quantization = None
384
+ if tuning == "qlora":
385
+ compute = self.dtype_ if self.dtype_ != torch.float32 else torch.bfloat16
386
+ quantization = quantization_config(self.quant_bits, self.quant_type, compute)
387
+ backbone = self.architecture_.load(self.model_name, num_layers, self.dtype_, revision=revision,
388
+ quantization=quantization, device=self.device_)
389
+ backbone.requires_grad_(False)
390
+ if quantization is None:
391
+ backbone = backbone.to(self.device_)
392
+ self.backbone_ = backbone.eval()
393
+ self.base_revision_ = revision or getattr(backbone.config, "_commit_hash", None)
394
+ model_limit = self.architecture_.max_positions(backbone)
395
+ self.max_len_ = min(self.max_len, model_limit) if self.max_len else model_limit
396
+
397
+ def _plan(self, tuning: str) -> LayerPlan:
398
+ """The layer plan, worked out from the model's config before any weights load."""
399
+ from transformers import AutoConfig
400
+
401
+ total = AutoConfig.from_pretrained(self.model_name, revision=self.revision).num_hidden_layers
402
+ kept = resolve_count(self.num_layers, total, "num_layers")
403
+ return layer_plan(tuning, kept, self.trainable_layers, self.lora_layers, self.train_embeddings)
404
+
405
+ def _setup(self, examples: list[Example]) -> None:
406
+ tuning = effective_tuning(self.tuning, resolve_device(self.device), stacklevel=3)
407
+ self.plan_ = self._plan(tuning)
408
+ self._load_backbone(self.num_layers, self.revision, tuning)
409
+ if self.plan_.n_lora:
410
+ add_adapters(self.backbone_, lora_config(self.architecture_, self.backbone_, self.plan_, self.lora_targets,
411
+ self.lora_rank, self.lora_alpha, self.lora_dropout))
412
+ self.prompt_format_ = PromptFormat()
413
+ probe = Encoder(self.architecture_, self.tokenizer_, self.prompt_format_, self.layout,
414
+ 1, 1, self.max_len_)
415
+ self.max_state_tokens_, self.max_option_tokens_ = measure_limits(
416
+ probe, [ex.state for ex in examples], [ex.question for ex in examples],
417
+ self.max_state_tokens, self.max_option_tokens)
418
+ hidden = self.backbone_.config.hidden_size
419
+ self.head_dim_ = max(1, hidden // 2) if self.head_dim == "auto" else self.head_dim
420
+ self.head_ = CompactHead(hidden, self.head_dim_, self.head_layers, self.head_dropout).to(self.device_)
421
+ self.temperatures_ = {t: 1.0 for t in QUESTION_TYPES}
422
+ self.history_ = []
423
+ self.training_state_ = None
424
+ self.structure_ = self._structure()
425
+ plan = self.plan_
426
+ trains = "head only" if not plan.learns else ", ".join(part for part in (
427
+ "%d fully trained layers" % plan.n_full if plan.n_full else "",
428
+ "adapters on %d layers" % plan.n_lora if plan.n_lora else "",
429
+ "embeddings" if plan.train_embeddings else "") if part)
430
+ self._log("loaded %s (%s, %d of %d layers, %s on %s); tuning=%s: %s; max_state_tokens=%d, "
431
+ "max_option_tokens=%d"
432
+ % (self.model_name, self.architecture_.name(), self.backbone_.config.num_hidden_layers,
433
+ self.backbone_.sysone_total_layers, str(self.dtype_).replace("torch.", ""),
434
+ self.device_, self.tuning_, trains, self.max_state_tokens_, self.max_option_tokens_))
435
+
436
+ def _learning_rates(self) -> dict[str, float]:
437
+ """Learning rate per parameter group. `backbone_lr="auto"` takes the architecture's defaults
438
+ for fully trained layers and for adapters; a number sets both."""
439
+ rates = dict(self.architecture_.learning_rates)
440
+ if self.backbone_lr != "auto":
441
+ rates = {group: float(self.backbone_lr) for group in rates}
442
+ return {"head": self.head_lr, **rates}
443
+
444
+ def _autocast_dtype(self) -> torch.dtype | None:
445
+ """Trained weights are fp32 next to half-precision frozen ones, so the backbone runs under
446
+ autocast when anything in it trains."""
447
+ if self.plan_.learns and self.dtype_ != torch.float32:
448
+ return self.dtype_
449
+ return None
450
+
451
+ def _fingerprint(self, examples: list[Example]) -> str:
452
+ """Identifies the training data and the settings that shape a run, so a saved training
453
+ state is only resumed on the same run."""
454
+ h = hashlib.sha256()
455
+ for ex in examples:
456
+ q = ex.question
457
+ h.update(json.dumps([ex.state, ex.qid, q.type, q.instructions, list(q.keys), list(q.texts), ex.target,
458
+ ex.category], default=str).encode())
459
+ params = self.get_params()
460
+ h.update(json.dumps({k: params[k] for k in _RUN_PARAMS}, sort_keys=True, default=str).encode())
461
+ return h.hexdigest()
462
+
463
+ def _disk_cache(self) -> FeatureCache:
464
+ parts = {
465
+ "model_name": self.model_name, "revision": self.base_revision_,
466
+ "architecture": architecture_spec(self.architecture_),
467
+ "num_layers": self.backbone_.config.num_hidden_layers, "layout": self.layout,
468
+ "precision": str(self.dtype_), "prompt_format": self.prompt_format_.to_dict(),
469
+ "max_state_tokens": self.max_state_tokens_, "max_option_tokens": self.max_option_tokens_,
470
+ "max_len": self.max_len_,
471
+ }
472
+ return FeatureCache(self.cache_dir, parts, self.cache_max_gb * 1024**3, self._log)
473
+
474
+ def _features(self, encoded: list[Encoded], disk: FeatureCache | None):
475
+ """Frozen-backbone vectors for `encoded`, read from and added to the disk cache if given."""
476
+ def compute(items):
477
+ return extract(self.architecture_, self.backbone_, items, self.tokenizer_.pad_token_id, self.device_)
478
+
479
+ if disk is None:
480
+ return compute(encoded)
481
+ keys = [question_key(e) for e in encoded]
482
+ found = disk.lookup(keys)
483
+ todo: dict[bytes, int] = {}
484
+ for i, key in enumerate(keys):
485
+ if key not in found and key not in todo:
486
+ todo[key] = i
487
+ computed = compute([encoded[i] for i in todo.values()]) if todo else []
488
+ found.update(zip(todo, computed))
489
+ written = disk.add(list(todo), computed)
490
+ self._log("feature cache %s: %d of %d questions read from disk, %d computed; wrote %s, folder total %s"
491
+ % (disk.path, len(keys) - len(todo), len(keys), len(todo), format_bytes(written),
492
+ format_bytes(disk.folder_bytes())))
493
+ return [found[k] for k in keys]
494
+
495
+ def _encoder(self) -> Encoder:
496
+ return Encoder(self.architecture_, self.tokenizer_, self.prompt_format_, self.layout,
497
+ self.max_state_tokens_, self.max_option_tokens_, self.max_len_)
498
+
499
+ # -- fit -----------------------------------------------------------------------------------
500
+
501
+ def fit(self, data, questions: Mapping | None = None) -> SystemOneModel:
502
+ """Train on labelled rows. See `sysone.data` for the accepted input forms.
503
+
504
+ Calling `fit` again continues training the same model, unless a structural parameter
505
+ (the base model, layers, layout, head shape, token limits, tuning mode, ...) changed, in
506
+ which case it starts over. With a training state from the last run (kept in memory, or
507
+ saved with the model) and the same data and run settings, it resumes that run exactly;
508
+ `epochs` counts the total. Otherwise it warm-starts: the trained weights are kept and a new
509
+ optimizer and schedule train `epochs` more epochs.
510
+
511
+ Interrupting `fit` (Ctrl-C) keeps the best weights so far and the state to resume from.
512
+ """
513
+ self._check_params()
514
+ examples, skipped = examples_from(data, questions, labeled=True, label_smoothing=self.label_smoothing)
515
+ if not examples:
516
+ raise ValueError("the data produced no labelled questions (skipped: %s)" % dict(skipped))
517
+ if skipped:
518
+ self._log("skipped %s" % ", ".join("%d %s" % (n, why) for why, n in skipped.items()))
519
+ torch.manual_seed(self.seed)
520
+
521
+ if self.is_fitted and self.structure_ != self._structure():
522
+ self._log("structural parameters changed since the last fit; starting over")
523
+ self.head_ = None
524
+ fresh = not self.is_fitted
525
+ if fresh:
526
+ self._setup(examples)
527
+ if self.calibrate:
528
+ self._log("calibration is not implemented yet; temperatures stay at 1.0")
529
+ plan = self.plan_
530
+ backbone_groups = set_trainable(self.architecture_, self.backbone_, plan)
531
+
532
+ train_idx, val_idx = split_validation(examples, self.validation_fraction, self.seed)
533
+ train_ex = [examples[i] for i in train_idx]
534
+ val_ex = [examples[i] for i in val_idx]
535
+ self._log("%d training questions, %d validation questions" % (len(train_ex), len(val_ex)))
536
+ if self.early_stopping and not val_ex:
537
+ self._log("too little data for a validation slice; early stopping is off")
538
+
539
+ encoder = self._encoder()
540
+ shuffle = set(self._shuffle_types())
541
+ hidden = self.backbone_.config.hidden_size
542
+ n_vectors = sum(ex.question.k + 1 for ex in examples)
543
+ cache = self.cache_features
544
+ if plan.learns:
545
+ cache = False
546
+ elif cache == "auto":
547
+ cache = not shuffle and cache_bytes(n_vectors, hidden) <= CACHE_LIMIT_BYTES
548
+ elif cache and shuffle:
549
+ raise ValueError("cache_features=True can't be used while options are shuffled (shuffle_options=%r)"
550
+ % (self.shuffle_options,))
551
+ disk = None
552
+ if self.cache_dir is not None and not plan.learns:
553
+ if cache:
554
+ disk = self._disk_cache()
555
+ else:
556
+ self._log("cache_dir is unused: features aren't cached (%s)"
557
+ % ("options are shuffled" if shuffle else "cache_features=%r" % self.cache_features))
558
+
559
+ def items_for(exs: list[Example], rng: random.Random | None) -> list[TrainItem]:
560
+ encoded, items_meta = [], []
561
+ for ex in exs:
562
+ order = None
563
+ if rng is not None and ex.question.type in shuffle and ex.question.k > 1:
564
+ order = list(range(ex.question.k))
565
+ rng.shuffle(order)
566
+ target = ex.target if order is None else [ex.target[i] for i in order]
567
+ encoded.append(encoder.encode(ex.state, ex.question, order))
568
+ items_meta.append((ex.question.type_index, torch.tensor(target, dtype=torch.float32)))
569
+ inputs = encoded if plan.learns else self._features(encoded, disk if rng is None else None)
570
+ return [TrainItem(x, qtype, target) for x, (qtype, target) in zip(inputs, items_meta)]
571
+
572
+ val_items = items_for(val_ex, None)
573
+ reuse = cache or (plan.learns and not shuffle)
574
+ cached: list[TrainItem] | None = None
575
+
576
+ def train_items(epoch: int) -> list[TrainItem]:
577
+ nonlocal cached
578
+ if reuse:
579
+ if cached is None:
580
+ if not plan.learns:
581
+ self._log("computing backbone features once for %d questions" % len(train_ex))
582
+ cached = items_for(train_ex, None)
583
+ return cached
584
+ return items_for(train_ex, random.Random(self.seed + epoch) if shuffle else None)
585
+
586
+ micro = START_MICRO_BATCH if plan.learns else self.batch_size
587
+ if plan.learns:
588
+ rates = self._learning_rates()
589
+ self._log("learning rates: head %g, %s" % (rates["head"], ", ".join(
590
+ "%s %g" % ("adapters" if group == "adapter" else "fully trained layers", rates[group])
591
+ for group, named in backbone_groups.items() if named)))
592
+ lengths = sorted(len(encoder.encode(ex.state, ex.question).input_ids) for ex in train_ex)
593
+ seq_len = lengths[min(len(lengths) - 1, int(0.95 * len(lengths)))]
594
+ checkpointing = wants_checkpointing(self.gradient_checkpointing, plan, self.backbone_, seq_len,
595
+ min(micro, self.batch_size))
596
+ if checkpointing:
597
+ self.backbone_.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
598
+ self._log("gradient checkpointing is on")
599
+ elif self.backbone_.is_gradient_checkpointing:
600
+ self.backbone_.gradient_checkpointing_disable()
601
+
602
+ steps_per_epoch = math.ceil(len(train_ex) / self.batch_size)
603
+ interval = self.validation_interval
604
+ if interval == "auto":
605
+ interval = math.ceil(steps_per_epoch / CHECKS_PER_EPOCH) if plan.learns else steps_per_epoch
606
+
607
+ fingerprint = self._fingerprint(examples)
608
+ saved = getattr(self, "training_state_", None)
609
+ resume = saved is not None and saved.fingerprint == fingerprint
610
+ if resume:
611
+ state = saved
612
+ self._log("resuming at epoch %g of %d (step %d)" % (state.step / steps_per_epoch, self.epochs, state.step))
613
+ else:
614
+ state = TrainState.fresh(fingerprint, self.seed)
615
+ if fresh:
616
+ self._log("training from scratch: up to %d epochs" % self.epochs)
617
+ elif saved is not None:
618
+ self._log("warm start (the training data or run settings differ from the saved run): "
619
+ "training %d epochs" % self.epochs)
620
+ else:
621
+ self._log("warm start (no saved training state): training %d epochs" % self.epochs)
622
+
623
+ net = Network(self.head_, self.device_, backbone=self.backbone_ if plan.learns else None,
624
+ arch=self.architecture_, pad_id=self.tokenizer_.pad_token_id, backbone_groups=backbone_groups,
625
+ autocast_dtype=self._autocast_dtype())
626
+ settings = TrainSettings(
627
+ epochs=self.epochs, batch_size=self.batch_size,
628
+ lrs=self._learning_rates(),
629
+ weight_decay=self.weight_decay, optimizer=self.optimizer, scheduler=self.scheduler,
630
+ warmup_ratio=self.warmup_ratio, grad_clip=self.grad_clip, early_stopping=self.early_stopping,
631
+ n_iter_no_change=self.n_iter_no_change, target_loss=self.target_loss, validation_interval=interval,
632
+ micro_batch=micro)
633
+ self.training_state_ = state
634
+ try:
635
+ train(net, train_items, len(train_ex), val_items, sample_weights(train_ex, self.balance), settings,
636
+ state, self.history_, self._log, resume=resume)
637
+ finally:
638
+ round_trained(backbone_groups, self.dtype_)
639
+ self.backbone_.eval()
640
+ self.report_ = {
641
+ "n_train": len(train_ex),
642
+ "n_validation": len(val_ex),
643
+ "skipped": dict(skipped),
644
+ "validation": evaluate_items(net, val_items) if val_items else None,
645
+ }
646
+ return self
647
+
648
+ # -- predict -------------------------------------------------------------------------------
649
+
650
+ def _check_fitted(self) -> None:
651
+ if not self.is_fitted:
652
+ raise NotFittedError("%s is not fitted: call fit() or load a saved model with %s.load() before "
653
+ "predicting; an untrained decision head gives noise"
654
+ % (type(self).__name__, type(self).__name__))
655
+
656
+ def predict(self, X, questions: Mapping | None = None):
657
+ """Answer typed questions in the Jev `/v1/systemone` shape.
658
+
659
+ - `predict(state, questions)` -> one result dict;
660
+ - `predict([state, ...], questions)` -> a list of results, one per state;
661
+ - `predict(rows)` -> a list of results, where each row is `{"state": ..., "questions": {...}}`.
662
+
663
+ A state that is itself a list (conversation turns) must be wrapped: `predict([turns], questions)`.
664
+ """
665
+ self._check_fitted()
666
+ single = False
667
+ if questions is not None:
668
+ if isinstance(X, (str, Mapping)):
669
+ X, single = [X], True
670
+ rows = [{"state": state, "questions": questions} for state in X]
671
+ else:
672
+ if isinstance(X, Mapping):
673
+ X, single = [X], True
674
+ rows = list(X)
675
+ examples, _ = examples_from(rows, None, labeled=False)
676
+ results = [{"answers": {}, "usage": {"input_tokens": 0, "output_tokens": 0}} for _ in rows]
677
+ probs, lengths = self._predict_examples(examples)
678
+ for ex, n_tokens, p in zip(examples, lengths, probs):
679
+ result = results[ex.row]
680
+ result["answers"][ex.qid] = answer(ex.question, p[: ex.question.k])
681
+ result["usage"]["input_tokens"] += n_tokens
682
+ return results[0] if single else results
683
+
684
+ def evaluate(self, data, questions: Mapping | None = None) -> dict:
685
+ """Score the model on labelled data, in any form `fit` accepts.
686
+
687
+ Returns a plain dict with `overall`, `by_type`, `by_category` and `by_question` metrics
688
+ (accuracy, distance, log_loss, and score_error / auc where they apply). Hard and soft
689
+ labels are scored the same way; see `sysone.evaluation` for the definitions.
690
+ """
691
+ self._check_fitted()
692
+ examples, skipped = examples_from(data, questions, labeled=True)
693
+ probs, _ = self._predict_examples(examples)
694
+ return score(examples, probs, skipped)
695
+
696
+ def _predict_examples(self, examples: list[Example]) -> tuple[list[list[float]], list[int]]:
697
+ """Option probabilities (temperature-scaled) and sequence lengths for each example."""
698
+ if not examples:
699
+ return [], []
700
+ encoder = self._encoder()
701
+ encoded = [encoder.encode(ex.state, ex.question) for ex in examples]
702
+ feats = extract(self.architecture_, self.backbone_, encoded, self.tokenizer_.pad_token_id, self.device_,
703
+ self._autocast_dtype())
704
+ probs = self._probabilities(feats, [ex.question.type_index for ex in examples])
705
+ return probs, [len(e.input_ids) for e in encoded]
706
+
707
+ @torch.no_grad()
708
+ def _probabilities(self, feats, qtypes, batch_size: int = 256) -> list[list[float]]:
709
+ self.head_.eval()
710
+ temps = torch.tensor([self.temperatures_[t] for t in QUESTION_TYPES], device=self.device_)
711
+ out = []
712
+ for start in range(0, len(feats), batch_size):
713
+ options, mask, summary = collate_features(feats[start: start + batch_size], self.device_)
714
+ qtype = torch.tensor(qtypes[start: start + batch_size], dtype=torch.long, device=self.device_)
715
+ logits = self.head_(options, mask, summary, qtype) / temps[qtype][:, None]
716
+ out.extend(torch.softmax(logits, dim=-1).cpu().tolist())
717
+ return out
718
+
719
+ # -- save / load ---------------------------------------------------------------------------
720
+
721
+ def save(self, path, training_state="auto") -> Path:
722
+ """Write a self-contained folder. Frozen backbone weights are not saved; `load` downloads
723
+ the base model by name and revision.
724
+
725
+ `training_state` decides whether what a later `fit` needs to resume this run exactly
726
+ (optimizer state, schedule position, last weights, random states) is included: "auto"
727
+ includes it when it is under 100 MB, True always, False never.
728
+ """
729
+ from safetensors.torch import save_file
730
+
731
+ self._check_fitted()
732
+ if training_state not in ("auto", True, False):
733
+ raise ValueError("training_state must be 'auto', True or False, got %r" % (training_state,))
734
+ path = Path(path)
735
+ path.mkdir(parents=True, exist_ok=True)
736
+ params = self.get_params()
737
+ params["architecture"] = None
738
+ config = {
739
+ "format": "sysone",
740
+ "sysone_version": __version__,
741
+ "class": type(self).__name__,
742
+ "params": params,
743
+ "architecture": architecture_spec(self.architecture_),
744
+ "base_model": {"name": self.model_name, "revision": self.base_revision_},
745
+ "prompt_format": self.prompt_format_.to_dict(),
746
+ "type_names": list(QUESTION_TYPES),
747
+ "temperatures": self.temperatures_,
748
+ "fitted": {
749
+ "max_state_tokens": self.max_state_tokens_,
750
+ "max_option_tokens": self.max_option_tokens_,
751
+ "max_len": self.max_len_,
752
+ "head_dim": self.head_dim_,
753
+ "num_layers": self.backbone_.config.num_hidden_layers,
754
+ "hidden_size": self.backbone_.config.hidden_size,
755
+ "tuning": self.tuning_,
756
+ "trainable_layers": self.plan_.n_full,
757
+ "lora_layers": self.plan_.n_lora,
758
+ "train_embeddings": self.plan_.train_embeddings,
759
+ },
760
+ "history": self.history_,
761
+ "report": getattr(self, "report_", None),
762
+ }
763
+ with open(path / CONFIG_FILE, "w", encoding="utf-8") as f:
764
+ json.dump(config, f, indent=2, default=_json_default)
765
+ save_file({k: v.detach().cpu().contiguous() for k, v in self.head_.state_dict().items()},
766
+ str(path / HEAD_FILE))
767
+ for stale in (ADAPTER_DIR, BACKBONE_FILE, STATE_DIR): # from an earlier save to the same folder
768
+ target = path / stale
769
+ if target.is_dir():
770
+ shutil.rmtree(target)
771
+ elif target.exists():
772
+ target.unlink()
773
+ if self.plan_.n_lora:
774
+ save_adapter(self.backbone_, path / ADAPTER_DIR)
775
+ full_weights = trained_parameters(self.architecture_, self.backbone_, self.plan_)["full"]
776
+ if full_weights:
777
+ save_backbone_weights(full_weights, self.dtype_, path / BACKBONE_FILE)
778
+ state = getattr(self, "training_state_", None)
779
+ if state is not None and training_state is not False:
780
+ size = state.nbytes()
781
+ if training_state is True or size <= TRAINING_STATE_AUTO_BYTES:
782
+ save_training_state(state, path / STATE_DIR)
783
+ else:
784
+ self._log("training state (%s) not saved: over 100 MB; use save(path, training_state=True) to "
785
+ "include it so a later fit() resumes this run" % format_bytes(size))
786
+ return path
787
+
788
+ @classmethod
789
+ def load(cls, path, **overrides) -> SystemOneModel:
790
+ """Load a model saved with `save`, from a local folder or a Hugging Face Hub repo id.
791
+ `overrides` replace saved runtime parameters such as `device` or `verbose`."""
792
+ from safetensors.torch import load_file
793
+
794
+ path = Path(path)
795
+ if not path.is_dir():
796
+ from huggingface_hub import snapshot_download
797
+
798
+ path = Path(snapshot_download(str(path)))
799
+ with open(path / CONFIG_FILE, encoding="utf-8") as f:
800
+ config = json.load(f)
801
+ if config.get("format") != "sysone":
802
+ raise ValueError("%s is not a saved sysone model" % path)
803
+ klass = _model_class(config["class"])
804
+ if not issubclass(klass, cls):
805
+ raise TypeError("%s holds a %s, not a %s" % (path, klass.__name__, cls.__name__))
806
+ params = _upgrade_params(dict(config["params"]))
807
+ spec = config["architecture"]
808
+ params["architecture"] = None if spec in _registered_specs() else architecture_from_spec(spec)
809
+ params.update(overrides)
810
+ model = klass(**params)
811
+
812
+ fitted = config["fitted"]
813
+ tuning = fitted.get("tuning", "head")
814
+ if tuning == "qlora":
815
+ tuning = effective_tuning(tuning, resolve_device(model.device), stacklevel=2)
816
+ model._load_backbone(fitted["num_layers"], config["base_model"]["revision"], tuning)
817
+ if model.backbone_.config.hidden_size != fitted["hidden_size"]:
818
+ raise ValueError("the base model's hidden size changed since this model was saved")
819
+ model.plan_ = LayerPlan(fitted["num_layers"], fitted.get("trainable_layers", 0), fitted.get("lora_layers", 0),
820
+ fitted.get("train_embeddings", False))
821
+ if (path / ADAPTER_DIR).is_dir():
822
+ load_adapter(model.backbone_, path / ADAPTER_DIR)
823
+ if (path / BACKBONE_FILE).is_file():
824
+ load_backbone_weights(model.backbone_, path / BACKBONE_FILE)
825
+ model.backbone_.eval()
826
+ model.prompt_format_ = PromptFormat.from_dict(config["prompt_format"])
827
+ model.max_state_tokens_ = fitted["max_state_tokens"]
828
+ model.max_option_tokens_ = fitted["max_option_tokens"]
829
+ model.max_len_ = fitted["max_len"]
830
+ model.head_dim_ = fitted["head_dim"]
831
+ head = CompactHead(fitted["hidden_size"], fitted["head_dim"], model.head_layers, model.head_dropout)
832
+ state = load_file(str(path / HEAD_FILE))
833
+ state["type_embedding.weight"] = _reorder_types(state["type_embedding.weight"], config["type_names"])
834
+ head.load_state_dict(state)
835
+ model.head_ = head.to(model.device_).eval()
836
+ model.temperatures_ = {t: float(config["temperatures"].get(t, 1.0)) for t in QUESTION_TYPES}
837
+ model.history_ = config.get("history") or []
838
+ model.report_ = config.get("report")
839
+ model.training_state_ = load_training_state(path / STATE_DIR) if (path / STATE_DIR).is_dir() else None
840
+ model.structure_ = model._structure()
841
+ return model
842
+
843
+
844
+ def _upgrade_params(params: dict) -> dict:
845
+ """Saved parameters from earlier versions, in this version's terms: `encoder_lr` and
846
+ `adapter_lr` became `backbone_lr`. A value changed from its old default is kept."""
847
+ encoder_lr, adapter_lr = params.pop("encoder_lr", 2.5e-5), params.pop("adapter_lr", 1e-4)
848
+ if "backbone_lr" not in params:
849
+ if params.get("tuning") in ("lora", "qlora") and adapter_lr != 1e-4:
850
+ params["backbone_lr"] = adapter_lr
851
+ elif params.get("tuning") == "full" and encoder_lr != 2.5e-5:
852
+ params["backbone_lr"] = encoder_lr
853
+ return params
854
+
855
+
856
+ def _json_default(value):
857
+ if isinstance(value, float) and not math.isfinite(value):
858
+ return None
859
+ if isinstance(value, (set, tuple)):
860
+ return list(value)
861
+ raise TypeError("cannot save %r in the config" % (value,))
862
+
863
+
864
+ def _all_subclasses(cls):
865
+ for sub in cls.__subclasses__():
866
+ yield sub
867
+ yield from _all_subclasses(sub)
868
+
869
+
870
+ def _model_class(name: str) -> type[SystemOneModel]:
871
+ for klass in _all_subclasses(SystemOneModel):
872
+ if klass.__name__ == name:
873
+ return klass
874
+ raise ValueError("unknown model class %r" % name)
875
+
876
+
877
+ def _registered_specs() -> set[str]:
878
+ from .architectures.registry import _REGISTRY
879
+
880
+ return set(_REGISTRY)
881
+
882
+
883
+ def _reorder_types(weight: torch.Tensor, saved_names: list[str]) -> torch.Tensor:
884
+ """Put type-embedding rows in this version's type order, matching them by name."""
885
+ if list(saved_names) == list(QUESTION_TYPES):
886
+ return weight
887
+ missing = set(QUESTION_TYPES) - set(saved_names)
888
+ if missing:
889
+ raise ValueError("the saved model has no embedding for question types %s" % sorted(missing))
890
+ return torch.stack([weight[saved_names.index(name)] for name in QUESTION_TYPES])