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/__init__.py +22 -0
- sysone/_util.py +102 -0
- sysone/answers.py +57 -0
- sysone/architectures/__init__.py +12 -0
- sysone/architectures/base.py +144 -0
- sysone/architectures/modernbert.py +43 -0
- sysone/architectures/qwen.py +48 -0
- sysone/architectures/registry.py +53 -0
- sysone/base.py +890 -0
- sysone/cache.py +196 -0
- sysone/checkpoint.py +96 -0
- sysone/data.py +190 -0
- sysone/encoding.py +80 -0
- sysone/evaluation.py +167 -0
- sysone/features.py +98 -0
- sysone/heads.py +66 -0
- sysone/layout.py +129 -0
- sysone/losses.py +17 -0
- sysone/models.py +24 -0
- sysone/prompts.py +60 -0
- sysone/questions.py +186 -0
- sysone/training.py +399 -0
- sysone/tuning.py +210 -0
- sysone/version.py +1 -0
- sysone-0.2.0.dist-info/METADATA +144 -0
- sysone-0.2.0.dist-info/RECORD +28 -0
- sysone-0.2.0.dist-info/WHEEL +4 -0
- sysone-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|