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/questions.py ADDED
@@ -0,0 +1,186 @@
1
+ """Typed questions: validation, options per type, and training targets from labels.
2
+
3
+ A question is a plain dict in the Jev `/v1/systemone` shape::
4
+
5
+ {"type": "choice", "instructions": "...", "criteria": ["a", "b"] or {"a": "desc", ...}}
6
+ {"type": "score", "instructions": "...", "criteria": ["level 0", "level 1", ...]}
7
+ {"type": "noul", "instructions": "...", "criteria": {"false": "...", "true": "..."}} # criteria optional
8
+
9
+ Every type is reduced to a list of options, so one decision head can serve all of them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import math
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ QUESTION_TYPES = ("choice", "score", "noul")
20
+ TYPE_INDEX = {name: i for i, name in enumerate(QUESTION_TYPES)}
21
+
22
+ _TRUE_STRINGS = {"true", "yes", "1"}
23
+ _FALSE_STRINGS = {"false", "no", "0"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Question:
28
+ """A validated question.
29
+
30
+ `keys` are the option identifiers answers are reported with: the caller's choice labels, the
31
+ level indices for a score, and `False`/`True` for a noul. `texts` are the option descriptions
32
+ (`None` where none was given): a choice label's description, a score level's text, or a noul
33
+ criterion.
34
+ """
35
+
36
+ type: str
37
+ instructions: str
38
+ keys: tuple
39
+ texts: tuple
40
+
41
+ @property
42
+ def k(self) -> int:
43
+ return len(self.keys)
44
+
45
+ @property
46
+ def type_index(self) -> int:
47
+ return TYPE_INDEX[self.type]
48
+
49
+
50
+ def _as_text(value: Any) -> str:
51
+ if isinstance(value, str):
52
+ return value
53
+ return json.dumps(value, ensure_ascii=False, default=str)
54
+
55
+
56
+ def parse_question(qid: Any, qdef: Any) -> Question:
57
+ """Validate a question dict and return a `Question`. Errors name the question and the problem."""
58
+ where = "question %r" % (qid,)
59
+ if not isinstance(qdef, dict):
60
+ raise ValueError("%s: definition must be a dict, got %s" % (where, type(qdef).__name__))
61
+ qtype = qdef.get("type")
62
+ if qtype not in QUESTION_TYPES:
63
+ raise ValueError("%s: unknown type %r; use one of %s" % (where, qtype, ", ".join(QUESTION_TYPES)))
64
+ instructions = qdef.get("instructions")
65
+ if instructions is None or (isinstance(instructions, (str, list, dict)) and not instructions):
66
+ raise ValueError("%s: 'instructions' is missing or empty" % where)
67
+ instructions = _as_text(instructions).strip()
68
+ if not instructions:
69
+ raise ValueError("%s: 'instructions' is missing or empty" % where)
70
+ criteria = qdef.get("criteria")
71
+
72
+ if qtype == "choice":
73
+ if isinstance(criteria, dict):
74
+ keys = tuple(criteria.keys())
75
+ texts = tuple(None if v is None or v == "" else _as_text(v) for v in criteria.values())
76
+ elif isinstance(criteria, (list, tuple)):
77
+ keys = tuple(criteria)
78
+ texts = (None,) * len(keys)
79
+ else:
80
+ raise ValueError("%s: choice 'criteria' must be a list of labels or a {label: description} dict"
81
+ % where)
82
+ if len(keys) < 2:
83
+ raise ValueError("%s: a choice needs at least 2 options, got %d" % (where, len(keys)))
84
+ if len({str(k) for k in keys}) != len(keys):
85
+ raise ValueError("%s: choice labels must be distinct" % where)
86
+ if any(str(k).strip() == "" for k in keys):
87
+ raise ValueError("%s: choice labels must not be empty" % where)
88
+ return Question("choice", instructions, keys, texts)
89
+
90
+ if qtype == "score":
91
+ if not isinstance(criteria, (list, tuple)):
92
+ raise ValueError("%s: score 'criteria' must be a list of level descriptions, low to high" % where)
93
+ if len(criteria) < 2:
94
+ raise ValueError("%s: a score needs at least 2 levels, got %d" % (where, len(criteria)))
95
+ return Question("score", instructions, tuple(range(len(criteria))), tuple(_as_text(c) for c in criteria))
96
+
97
+ # noul
98
+ if criteria is None:
99
+ criteria = {}
100
+ if not isinstance(criteria, dict):
101
+ raise ValueError("%s: noul 'criteria' must be a {\"false\": ..., \"true\": ...} dict" % where)
102
+ normalized = {str(k).lower(): v for k, v in criteria.items()}
103
+ unknown = set(normalized) - {"false", "true"}
104
+ if unknown:
105
+ raise ValueError("%s: noul 'criteria' keys must be 'false' and 'true', got %s"
106
+ % (where, sorted(unknown)))
107
+ texts = tuple(None if normalized.get(k) in (None, "") else _as_text(normalized[k]) for k in ("false", "true"))
108
+ return Question("noul", instructions, (False, True), texts)
109
+
110
+
111
+ def _label_index(question: Question, value: Any, where: str) -> int:
112
+ if question.type == "choice":
113
+ for i, key in enumerate(question.keys):
114
+ if value == key or str(value) == str(key):
115
+ return i
116
+ raise ValueError("%s: label %r is not one of the options %r" % (where, value, list(question.keys)))
117
+ if question.type == "noul":
118
+ if isinstance(value, bool):
119
+ return int(value)
120
+ if isinstance(value, (int, float)) and value in (0, 1):
121
+ return int(value)
122
+ if isinstance(value, str) and value.strip().lower() in _TRUE_STRINGS | _FALSE_STRINGS:
123
+ return int(value.strip().lower() in _TRUE_STRINGS)
124
+ raise ValueError("%s: label %r cannot be read as true/false" % (where, value))
125
+ for i, text in enumerate(question.texts):
126
+ if isinstance(value, str) and value == text:
127
+ return i
128
+ raise ValueError("%s: label %r is not a level index or level text" % (where, value))
129
+
130
+
131
+ def target_from_label(question: Question, value: Any, label_smoothing: float = 0.0,
132
+ where: str = "label") -> list[float]:
133
+ """A target distribution over the question's options from one hard label.
134
+
135
+ A score label may be fractional: `2.5` puts half its mass on level 2 and half on level 3.
136
+ `label_smoothing` mixes in a uniform distribution: `(1 - eps) * target + eps / k`.
137
+ """
138
+ if not 0.0 <= label_smoothing < 1.0:
139
+ raise ValueError("label_smoothing must be in [0, 1), got %r" % (label_smoothing,))
140
+ k = question.k
141
+ target = [0.0] * k
142
+ is_number = isinstance(value, (int, float)) and not isinstance(value, bool)
143
+ if question.type == "score" and is_number:
144
+ if not math.isfinite(value) or not 0 <= value <= k - 1:
145
+ raise ValueError("%s: score %r is outside the levels 0..%d" % (where, value, k - 1))
146
+ low = int(math.floor(value))
147
+ frac = value - low
148
+ target[low] = 1.0 - frac
149
+ if frac > 0:
150
+ target[low + 1] = frac
151
+ else:
152
+ target[_label_index(question, value, where)] = 1.0
153
+ if label_smoothing:
154
+ target = [(1.0 - label_smoothing) * t + label_smoothing / k for t in target]
155
+ return target
156
+
157
+
158
+ def target_from_gold(question: Question, gold: Any, where: str = "gold") -> list[float]:
159
+ """A target distribution from soft probabilities.
160
+
161
+ `gold` is `{"probabilities": {...}}` or the probabilities dict itself, keyed by choice label,
162
+ `"false"`/`"true"` for a noul, or the level index (as a string or int) for a score. Missing
163
+ keys count as 0. The result is normalised; an all-zero target becomes uniform.
164
+ """
165
+ probs = gold.get("probabilities", gold) if isinstance(gold, dict) else None
166
+ if not isinstance(probs, dict):
167
+ raise ValueError("%s: expected a dict of probabilities, got %r" % (where, gold))
168
+ lookup = {str(k).lower() if isinstance(k, bool) else str(k): v for k, v in probs.items()}
169
+ if question.type == "noul":
170
+ names = ["false", "true"]
171
+ else:
172
+ names = [str(key) for key in question.keys]
173
+ unknown = set(lookup) - set(names)
174
+ if unknown:
175
+ raise ValueError("%s: probabilities name unknown options %s" % (where, sorted(unknown)))
176
+ target = []
177
+ for name in names:
178
+ value = lookup.get(name, 0.0)
179
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
180
+ raise ValueError("%s: probability for %r must be a finite non-negative number, got %r"
181
+ % (where, name, value))
182
+ target.append(float(value))
183
+ total = sum(target)
184
+ if total <= 0:
185
+ return [1.0 / len(target)] * len(target)
186
+ return [t / total for t in target]
sysone/training.py ADDED
@@ -0,0 +1,399 @@
1
+ """Validation split and the training loop.
2
+
3
+ The loop trains a `Network`: the decision head, plus the backbone when any of it learns. Items
4
+ are cached option and summary vectors (`Features`) for a frozen backbone, or token layouts
5
+ (`Encoded`) that go through the backbone on every step.
6
+
7
+ All progress lives in a `TrainState`, so a run that is interrupted, or saved and loaded, resumes
8
+ exactly where it stopped.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import math
15
+ import random
16
+ from collections import Counter, defaultdict
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass, field
19
+
20
+ import torch
21
+ from torch import nn
22
+
23
+ from ._util import autocast, empty_device_cache, is_out_of_memory
24
+ from .data import Example
25
+ from .features import Features, collate_features, read_vectors, run_in_batches
26
+ from .layout import Encoded, collate
27
+ from .losses import soft_ce
28
+
29
+ OPTIMIZERS = ("adamw", "adamw_8bit", "sgd")
30
+ SCHEDULERS = ("cosine", "linear", "constant")
31
+ #: Validation checks per epoch with validation_interval="auto" when the backbone learns.
32
+ CHECKS_PER_EPOCH = 10
33
+
34
+
35
+ @dataclass
36
+ class TrainItem:
37
+ inputs: Features | Encoded
38
+ qtype: int
39
+ target: torch.Tensor # [k], in slot order
40
+
41
+
42
+ @dataclass
43
+ class TrainSettings:
44
+ epochs: int
45
+ batch_size: int
46
+ lrs: dict # learning rate per parameter group: "head", "adapter", "full"
47
+ weight_decay: float
48
+ optimizer: str
49
+ scheduler: str
50
+ warmup_ratio: float
51
+ grad_clip: float
52
+ early_stopping: bool
53
+ n_iter_no_change: int # epochs without a lower validation loss before stopping
54
+ target_loss: float | None
55
+ validation_interval: int # optimizer steps between validation checks
56
+ micro_batch: int # examples per forward pass to start with; halved when memory runs out
57
+
58
+
59
+ @dataclass
60
+ class TrainState:
61
+ """Everything needed to continue a run as if it had not stopped."""
62
+
63
+ fingerprint: str # of the training data and the settings that shape the run
64
+ epoch_generator: torch.Tensor # sampling generator state at the start of the current epoch
65
+ step: int = 0 # optimizer steps done
66
+ last_check: int = 0 # step of the last validation check
67
+ best_loss: float = math.inf
68
+ best_step: int = 0 # step of the check with the lowest validation loss
69
+ running_loss: float = 0.0 # training loss summed since the last check
70
+ running_n: int = 0
71
+ rng: dict = field(default_factory=dict) # torch generator states after the last step
72
+ optimizer: dict | None = None # optimizer state after the last step, on the CPU
73
+ current: dict | None = None # trained weights after the last step, fp32 on the CPU
74
+
75
+ @classmethod
76
+ def fresh(cls, fingerprint: str, seed: int) -> TrainState:
77
+ return cls(fingerprint, torch.Generator().manual_seed(seed).get_state())
78
+
79
+ def nbytes(self) -> int:
80
+ tensors = list((self.current or {}).values())
81
+ for slot in (self.optimizer or {}).get("state", {}).values():
82
+ tensors.extend(v for v in slot.values() if torch.is_tensor(v))
83
+ return sum(t.numel() * t.element_size() for t in tensors)
84
+
85
+
86
+ class Network:
87
+ """The trained parts of the model: the head, and the backbone when it learns.
88
+
89
+ With `backbone=None` items carry cached `Features`; otherwise they carry `Encoded` layouts,
90
+ run through the backbone under autocast to `autocast_dtype`.
91
+ """
92
+
93
+ def __init__(self, head: nn.Module, device: torch.device, backbone: nn.Module | None = None, arch=None,
94
+ pad_id: int = 0, backbone_groups: dict | None = None, autocast_dtype: torch.dtype | None = None):
95
+ self.head = head
96
+ self.device = device
97
+ self.backbone = backbone
98
+ self.arch = arch
99
+ self.pad_id = pad_id
100
+ self.autocast_dtype = autocast_dtype
101
+ self.param_groups = {"head": [("head." + n, p) for n, p in head.named_parameters()]}
102
+ for group, named in (backbone_groups or {}).items():
103
+ if named:
104
+ self.param_groups[group] = [("backbone." + n, p) for n, p in named]
105
+
106
+ def named_trainable(self) -> list[tuple[str, nn.Parameter]]:
107
+ return [item for named in self.param_groups.values() for item in named]
108
+
109
+ def train(self, mode: bool = True) -> None:
110
+ self.head.train(mode)
111
+ if self.backbone is not None:
112
+ self.backbone.train(mode)
113
+
114
+ def forward(self, items: Sequence[TrainItem]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
115
+ """`(logits [B, K], option_mask [B, K], target [B, K])` for a batch of items."""
116
+ qtype = torch.tensor([it.qtype for it in items], dtype=torch.long, device=self.device)
117
+ if self.backbone is None:
118
+ options, mask, summary = collate_features([it.inputs for it in items], self.device)
119
+ else:
120
+ batch = {k: v.to(self.device) for k, v in collate([it.inputs for it in items], self.pad_id).items()}
121
+ with autocast(self.device, self.autocast_dtype):
122
+ hidden = self.arch.hidden_states(self.backbone, batch)
123
+ options, summary = read_vectors(hidden, batch)
124
+ mask = batch["option_mask"]
125
+ target = torch.zeros(mask.shape, dtype=torch.float32)
126
+ for i, it in enumerate(items):
127
+ target[i, : it.target.shape[0]] = it.target
128
+ return self.head(options, mask, summary, qtype), mask, target.to(self.device)
129
+
130
+ def snapshot(self) -> dict[str, torch.Tensor]:
131
+ return {n: p.detach().to("cpu", torch.float32, copy=True) for n, p in self.named_trainable()}
132
+
133
+ @torch.no_grad()
134
+ def restore(self, weights: dict[str, torch.Tensor]) -> None:
135
+ for name, p in self.named_trainable():
136
+ p.copy_(weights[name])
137
+
138
+
139
+ def _length(item: TrainItem) -> int:
140
+ return len(item.inputs.input_ids) if isinstance(item.inputs, Encoded) else item.inputs.options.shape[0]
141
+
142
+
143
+ def split_validation(examples: Sequence[Example], fraction: float, seed: int) -> tuple[list[int], list[int]]:
144
+ """Indices `(train, validation)`.
145
+
146
+ Questions about the same state stay on the same side, so the validation slice never shares a
147
+ state with training. The split is stratified by category: each category gives about
148
+ `fraction` of its states. Returns an empty validation list when the data is too small to give
149
+ both sides at least one example.
150
+ """
151
+ if fraction <= 0:
152
+ return list(range(len(examples))), []
153
+ groups: dict[str, list[int]] = defaultdict(list)
154
+ for i, ex in enumerate(examples):
155
+ groups[ex.state].append(i)
156
+ strata: dict[str | None, list[str]] = defaultdict(list)
157
+ for state, members in groups.items():
158
+ strata[examples[members[0]].category].append(state)
159
+ rng = random.Random(seed)
160
+ held: set[str] = set()
161
+ for category in sorted(strata, key=lambda c: (c is None, str(c))):
162
+ states = sorted(strata[category])
163
+ rng.shuffle(states)
164
+ held.update(states[: int(round(fraction * len(states)))])
165
+ val = [i for i, ex in enumerate(examples) if ex.state in held]
166
+ train = [i for i, ex in enumerate(examples) if ex.state not in held]
167
+ if not val or not train:
168
+ return list(range(len(examples))), []
169
+ return train, val
170
+
171
+
172
+ def sample_weights(examples: Sequence[Example], balance: str | None) -> torch.Tensor | None:
173
+ """Per-example sampling weights that even out categories or questions."""
174
+ if balance is None:
175
+ return None
176
+ if balance == "category":
177
+ keys = [ex.category for ex in examples]
178
+ elif balance == "question":
179
+ keys = [(ex.qid, ex.question.instructions) for ex in examples]
180
+ else:
181
+ raise ValueError("balance must be None, 'category' or 'question', got %r" % (balance,))
182
+ counts = Counter(keys)
183
+ return torch.tensor([1.0 / counts[k] for k in keys], dtype=torch.double)
184
+
185
+
186
+ def make_optimizer(name: str, groups: list[dict], weight_decay: float) -> torch.optim.Optimizer:
187
+ if name == "adamw":
188
+ return torch.optim.AdamW(groups, weight_decay=weight_decay)
189
+ if name == "sgd":
190
+ return torch.optim.SGD(groups, weight_decay=weight_decay, momentum=0.9)
191
+ if name == "adamw_8bit":
192
+ try:
193
+ import bitsandbytes as bnb
194
+ except ImportError as exc:
195
+ raise ImportError("optimizer='adamw_8bit' needs bitsandbytes, which is not installed on this "
196
+ "platform; use optimizer='adamw'") from exc
197
+ return bnb.optim.AdamW8bit(groups, weight_decay=weight_decay)
198
+ raise ValueError("optimizer must be one of %s, got %r" % (", ".join(OPTIMIZERS), name))
199
+
200
+
201
+ def make_scheduler(optimizer, kind: str, warmup_ratio: float, total_steps: int, start: int = 0):
202
+ """A warmup-then-decay schedule, positioned at optimizer step `start`."""
203
+ warmup = int(warmup_ratio * total_steps)
204
+
205
+ def factor(step: int) -> float:
206
+ if step < warmup:
207
+ return (step + 1) / (warmup + 1)
208
+ if kind == "constant":
209
+ return 1.0
210
+ progress = (step - warmup) / max(1, total_steps - warmup)
211
+ if kind == "linear":
212
+ return max(0.0, 1.0 - progress)
213
+ return 0.5 * (1.0 + math.cos(math.pi * min(1.0, progress)))
214
+
215
+ if kind not in SCHEDULERS:
216
+ raise ValueError("scheduler must be one of %s, got %r" % (", ".join(SCHEDULERS), kind))
217
+ for group in optimizer.param_groups:
218
+ group.setdefault("initial_lr", group["lr"])
219
+ return torch.optim.lr_scheduler.LambdaLR(optimizer, factor, last_epoch=start - 1)
220
+
221
+
222
+ @torch.no_grad()
223
+ def evaluate_items(net: Network, items: Sequence[TrainItem], batch_size: int = 64) -> dict:
224
+ """Mean loss and accuracy (argmax of the prediction vs argmax of the target)."""
225
+ net.train(False)
226
+
227
+ def batch_fn(chunk: list[TrainItem]) -> list[tuple[float, bool]]:
228
+ logits, mask, target = net.forward(chunk)
229
+ losses = soft_ce(logits, target, mask, reduction="none")
230
+ hits = logits.argmax(-1) == target.argmax(-1)
231
+ return list(zip(losses.tolist(), hits.tolist()))
232
+
233
+ results = run_in_batches(items, batch_fn, start=batch_size, length=_length)
234
+ n = max(1, len(items))
235
+ return {"loss": sum(r[0] for r in results) / n, "accuracy": sum(r[1] for r in results) / n, "n": len(items)}
236
+
237
+
238
+ def _rng_states() -> dict:
239
+ states = {"cpu": torch.get_rng_state()}
240
+ if torch.cuda.is_available():
241
+ states["cuda"] = torch.cuda.get_rng_state()
242
+ if torch.backends.mps.is_available():
243
+ states["mps"] = torch.mps.get_rng_state()
244
+ return states
245
+
246
+
247
+ def _set_rng_states(states: dict) -> None:
248
+ if "cpu" in states:
249
+ torch.set_rng_state(states["cpu"])
250
+ if "cuda" in states and torch.cuda.is_available():
251
+ torch.cuda.set_rng_state(states["cuda"])
252
+ if "mps" in states and torch.backends.mps.is_available():
253
+ torch.mps.set_rng_state(states["mps"])
254
+
255
+
256
+ def _to_cpu(value):
257
+ if torch.is_tensor(value):
258
+ return value.detach().to("cpu", copy=True)
259
+ if isinstance(value, dict):
260
+ return {k: _to_cpu(v) for k, v in value.items()}
261
+ if isinstance(value, list):
262
+ return [_to_cpu(v) for v in value]
263
+ return copy.deepcopy(value)
264
+
265
+
266
+ def train(net: Network, train_items: Callable[[int], Sequence[TrainItem]], n_train: int,
267
+ val_items: Sequence[TrainItem], weights: torch.Tensor | None, settings: TrainSettings,
268
+ state: TrainState, history: list, log: Callable[[str], None], resume: bool = False) -> TrainState:
269
+ """Train `net` in place, continuing from `state`; returns the updated state.
270
+
271
+ `train_items(epoch)` returns that epoch's training items: the same cached list every epoch, or
272
+ fresh ones when options are shuffled. Validation runs every `validation_interval` optimizer
273
+ steps and at the end; each check appends a record to `history`. Training stops after
274
+ `epochs`, after `n_iter_no_change` epochs without a lower validation loss (with early
275
+ stopping; counted in epochs however often validation runs), or once the monitored loss
276
+ reaches `target_loss`. The weights from the best check
277
+ are then restored, and `state` keeps the last weights so a later call can resume.
278
+
279
+ A `KeyboardInterrupt` stops training the same way and is then raised again.
280
+ """
281
+ steps_per_epoch = math.ceil(n_train / settings.batch_size)
282
+ total_steps = steps_per_epoch * settings.epochs
283
+ have_val = len(val_items) > 0
284
+ stop_early = settings.early_stopping and have_val
285
+ if settings.target_loss is not None and not have_val:
286
+ log("no validation slice: target_loss applies to the training loss")
287
+
288
+ optimizer = make_optimizer(settings.optimizer, [
289
+ {"params": [p for _, p in named], "lr": settings.lrs[group]} for group, named in net.param_groups.items()
290
+ ], settings.weight_decay)
291
+ best = None
292
+ if resume:
293
+ optimizer.load_state_dict(state.optimizer)
294
+ if math.isfinite(state.best_loss):
295
+ best = net.snapshot() # the model holds the best weights of the saved run
296
+ net.restore(state.current)
297
+ _set_rng_states(state.rng)
298
+ scheduler = make_scheduler(optimizer, settings.scheduler, settings.warmup_ratio, total_steps, state.step)
299
+ params = [p for _, p in net.named_trainable()]
300
+ generator = torch.Generator()
301
+ micro = max(1, min(settings.micro_batch, settings.batch_size))
302
+
303
+ def optimizer_step(chunk: list[TrainItem]) -> float:
304
+ nonlocal micro
305
+ chunk = sorted(chunk, key=_length, reverse=True) # similar lengths share a forward pass
306
+ while True:
307
+ try:
308
+ optimizer.zero_grad(set_to_none=True)
309
+ total = 0.0
310
+ for start in range(0, len(chunk), micro):
311
+ part = chunk[start: start + micro]
312
+ logits, mask, target = net.forward(part)
313
+ loss = soft_ce(logits, target, mask)
314
+ (loss * (len(part) / len(chunk))).backward()
315
+ total += loss.item() * len(part)
316
+ break
317
+ except (RuntimeError, torch.OutOfMemoryError) as exc:
318
+ if not is_out_of_memory(exc) or micro == 1:
319
+ raise
320
+ optimizer.zero_grad(set_to_none=True)
321
+ empty_device_cache()
322
+ micro = max(1, micro // 2)
323
+ log("out of memory: running %d examples per forward pass" % micro)
324
+ if settings.grad_clip:
325
+ nn.utils.clip_grad_norm_(params, settings.grad_clip)
326
+ optimizer.step()
327
+ scheduler.step()
328
+ return total
329
+
330
+ def check() -> bool:
331
+ """Validate at `state.step`; True when a stopping rule says to stop."""
332
+ nonlocal best
333
+ record = {"step": state.step, "epoch": round(state.step / steps_per_epoch, 3),
334
+ "train_loss": state.running_loss / max(1, state.running_n)}
335
+ if have_val:
336
+ metrics = evaluate_items(net, val_items)
337
+ record.update(val_loss=metrics["loss"], val_accuracy=metrics["accuracy"])
338
+ if record["val_loss"] < state.best_loss - 1e-6:
339
+ state.best_loss, state.best_step, best = record["val_loss"], state.step, net.snapshot()
340
+ state.running_loss, state.running_n, state.last_check = 0.0, 0, state.step
341
+ history.append(record)
342
+ log("epoch %g/%d step %d " % (record["epoch"], settings.epochs, state.step)
343
+ + " ".join("%s=%.4f" % (k, v) for k, v in record.items() if k not in ("epoch", "step")))
344
+ monitored = record["val_loss"] if have_val else record["train_loss"]
345
+ if settings.target_loss is not None and monitored <= settings.target_loss:
346
+ log("stopping: %s loss %.4f reached target_loss=%g"
347
+ % ("validation" if have_val else "training", monitored, settings.target_loss))
348
+ return True
349
+ if stop_early and state.step - state.best_step >= settings.n_iter_no_change * steps_per_epoch:
350
+ log("early stopping: no lower validation loss in %g epochs"
351
+ % round((state.step - state.best_step) / steps_per_epoch, 3))
352
+ return True
353
+ return False
354
+
355
+ def check_due() -> bool:
356
+ return state.step > state.last_check and (state.step % settings.validation_interval == 0
357
+ or state.step == total_steps)
358
+
359
+ interrupted = False
360
+ try:
361
+ stop = check_due() and check() # a check interrupted before it finished
362
+ if state.step >= total_steps and not stop:
363
+ log("all %d epochs are already trained; raise epochs to train more" % settings.epochs)
364
+ while not stop and state.step < total_steps:
365
+ epoch, first = divmod(state.step, steps_per_epoch)
366
+ generator.set_state(state.epoch_generator)
367
+ if weights is not None:
368
+ order = torch.multinomial(weights, n_train, replacement=True, generator=generator).tolist()
369
+ else:
370
+ order = torch.randperm(n_train, generator=generator).tolist()
371
+ items = train_items(epoch)
372
+ net.train(True)
373
+ for s in range(first, steps_per_epoch):
374
+ chunk = [items[i] for i in order[s * settings.batch_size: (s + 1) * settings.batch_size]]
375
+ loss_sum = optimizer_step(chunk)
376
+ state.step += 1
377
+ state.running_loss += loss_sum
378
+ state.running_n += len(chunk)
379
+ state.rng = _rng_states()
380
+ if s == steps_per_epoch - 1:
381
+ state.epoch_generator = generator.get_state()
382
+ if check_due():
383
+ stop = check()
384
+ if stop:
385
+ break
386
+ net.train(True)
387
+ except KeyboardInterrupt:
388
+ interrupted = True
389
+ log("interrupted at step %d (epoch %g); fit() again to resume" % (state.step, state.step / steps_per_epoch))
390
+ finally:
391
+ optimizer.zero_grad(set_to_none=True)
392
+ state.current = net.snapshot()
393
+ state.optimizer = _to_cpu(optimizer.state_dict())
394
+ if best is not None:
395
+ net.restore(best)
396
+ net.train(False)
397
+ if interrupted:
398
+ raise KeyboardInterrupt
399
+ return state