tabpackage 0.0.1__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.
- tabpackage/__init__.py +18 -0
- tabpackage/_training.py +3380 -0
- tabpackage/ensemble.py +263 -0
- tabpackage/metrics.py +253 -0
- tabpackage/nn.py +2110 -0
- tabpackage/optim.py +943 -0
- tabpackage/sklearn/__init__.py +18 -0
- tabpackage/sklearn/_config.py +561 -0
- tabpackage/sklearn/_data.py +307 -0
- tabpackage/sklearn/_estimators.py +1210 -0
- tabpackage/utils/__init__.py +1 -0
- tabpackage/utils/torch.py +166 -0
- tabpackage-0.0.1.dist-info/METADATA +398 -0
- tabpackage-0.0.1.dist-info/RECORD +15 -0
- tabpackage-0.0.1.dist-info/WHEEL +4 -0
tabpackage/_training.py
ADDED
|
@@ -0,0 +1,3380 @@
|
|
|
1
|
+
"""TabPack training."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import copy
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime
|
|
7
|
+
import enum
|
|
8
|
+
import gc
|
|
9
|
+
import itertools
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import typing
|
|
16
|
+
import warnings
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any, Final, Literal, Protocol, Self, TypeAlias
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
import torch._functorch.config
|
|
22
|
+
import torch.nn as nn # noqa: PLR0402
|
|
23
|
+
from torch import Tensor
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from IPython import get_ipython
|
|
27
|
+
except ImportError:
|
|
28
|
+
get_ipython = None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import tqdm
|
|
32
|
+
except ImportError:
|
|
33
|
+
tqdm = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
import tabpackage
|
|
37
|
+
import tabpackage.ensemble # Ensembling algorithms
|
|
38
|
+
import tabpackage.metrics # Metric computation for model packs
|
|
39
|
+
import tabpackage.nn # Neural network module packs
|
|
40
|
+
import tabpackage.optim # Optimizers packs
|
|
41
|
+
import tabpackage.utils.torch # Generic PyTorch utilities
|
|
42
|
+
|
|
43
|
+
KWArgs: TypeAlias = dict[str, Any]
|
|
44
|
+
"""Keyword arguments for a callable."""
|
|
45
|
+
|
|
46
|
+
DatasetName: TypeAlias = str
|
|
47
|
+
"""A dataset name."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# >>> Model IDs
|
|
51
|
+
|
|
52
|
+
ModelID: TypeAlias = int
|
|
53
|
+
"""A unique model identifier."""
|
|
54
|
+
|
|
55
|
+
_IDS_DEVICE: Final = torch.device('cpu')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# >>> Hyperparameters
|
|
59
|
+
#
|
|
60
|
+
# Utilities for managing hyperparameter dictionaries.
|
|
61
|
+
|
|
62
|
+
HyperparameterDict: TypeAlias = dict[str, Any]
|
|
63
|
+
"""A dictionary storing arbitrary hyperparameters."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def transpose_dicts_recursively(dicts: list[dict[Any, Any]]) -> dict[Any, Any]:
|
|
67
|
+
"""Turn a list of nested dictionaries into a nested dictionary of lists.
|
|
68
|
+
|
|
69
|
+
>>> dicts = [
|
|
70
|
+
... {
|
|
71
|
+
... 'model': {
|
|
72
|
+
... 'numerical_embeddings': {'d_embedding': 16},
|
|
73
|
+
... 'n_blocks': 2,
|
|
74
|
+
... 'd_block': 256,
|
|
75
|
+
... },
|
|
76
|
+
... },
|
|
77
|
+
... {
|
|
78
|
+
... 'model': {
|
|
79
|
+
... 'numerical_embeddings': {'d_embedding': 24},
|
|
80
|
+
... 'n_blocks': 3,
|
|
81
|
+
... 'd_block': 384,
|
|
82
|
+
... },
|
|
83
|
+
... },
|
|
84
|
+
... ]
|
|
85
|
+
>>> transpose_dicts_recursively(dicts)
|
|
86
|
+
{'model': {'numerical_embeddings': {'d_embedding': [16, 24]}, 'n_blocks': [2, 3], 'd_block': [256, 384]}}
|
|
87
|
+
""" # noqa: E501
|
|
88
|
+
if not dicts:
|
|
89
|
+
raise ValueError('Provide at least one dictionary')
|
|
90
|
+
if not all(x.keys() == dicts[0].keys() for x in dicts):
|
|
91
|
+
raise ValueError('All dictionaries must have the same keys at each level')
|
|
92
|
+
|
|
93
|
+
result = {}
|
|
94
|
+
for key in dicts[0]:
|
|
95
|
+
values = [x[key] for x in dicts]
|
|
96
|
+
result[key] = (
|
|
97
|
+
transpose_dicts_recursively(values)
|
|
98
|
+
if isinstance(values[0], dict)
|
|
99
|
+
else values
|
|
100
|
+
)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def merge_dicts_recursively(this: dict, other: dict) -> None:
|
|
105
|
+
"""Merge `other` dictionary into `this`, recursively.
|
|
106
|
+
|
|
107
|
+
Overlapping keys are forbidden.
|
|
108
|
+
|
|
109
|
+
>>> this = {
|
|
110
|
+
... 'model': {
|
|
111
|
+
... 'numerical_embeddings': {'type': tabpackage.nn.CosineEmbeddingsPack},
|
|
112
|
+
... 'd_block': 256,
|
|
113
|
+
... },
|
|
114
|
+
... }
|
|
115
|
+
>>> other = {
|
|
116
|
+
... 'model': {
|
|
117
|
+
... 'numerical_embeddings': {'d_embedding': 16},
|
|
118
|
+
... 'n_block': 2,
|
|
119
|
+
... },
|
|
120
|
+
... }
|
|
121
|
+
>>> merge_dicts_recursively(this, other)
|
|
122
|
+
>>> this
|
|
123
|
+
{'model': {'numerical_embeddings': {'type': <class 'tabpackage.nn.CosineEmbeddingsPack'>, 'd_embedding': 16}, 'd_block': 256, 'n_block': 2}}
|
|
124
|
+
""" # noqa: E501
|
|
125
|
+
for key, other_value in other.items():
|
|
126
|
+
if key in this:
|
|
127
|
+
if isinstance(other_value, dict):
|
|
128
|
+
this_value = this[key]
|
|
129
|
+
if not isinstance(this_value, dict):
|
|
130
|
+
raise TypeError(
|
|
131
|
+
'Cannot merge a nested dictionary because the existing value is'
|
|
132
|
+
' not a dictionary'
|
|
133
|
+
)
|
|
134
|
+
merge_dicts_recursively(this_value, other_value)
|
|
135
|
+
else:
|
|
136
|
+
raise RuntimeError()
|
|
137
|
+
else:
|
|
138
|
+
this[key] = other_value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# >>> Model pack
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
PredictionTransform: TypeAlias = Callable[[Tensor], Tensor]
|
|
145
|
+
"""A transformation of predictions for evaluation and inference.
|
|
146
|
+
|
|
147
|
+
The transformation must be deterministic and act on each prediction independently
|
|
148
|
+
regardless of the number of batch-like dimensions. To save training results or
|
|
149
|
+
predictors with `torch.save`, use a picklable callable, such as
|
|
150
|
+
`functools.partial(torch.softmax, dim=-1)`, rather than a lambda or closure.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def forward(
|
|
155
|
+
model_pack: tabpackage.nn.TabPack,
|
|
156
|
+
batch: dict[str, Tensor],
|
|
157
|
+
*,
|
|
158
|
+
autocast: torch.autocast | None,
|
|
159
|
+
) -> Tensor:
|
|
160
|
+
"""Apply TabPack under the requested autocast context and return FP32 outputs."""
|
|
161
|
+
with contextlib.nullcontext() if autocast is None else autocast:
|
|
162
|
+
x = model_pack(batch.get('x_num', None), batch.get('x_cat', None))
|
|
163
|
+
|
|
164
|
+
# Promote the output dtype so downstream transformations, losses, metrics, and
|
|
165
|
+
# ensemble computations receive FP32 inputs. All these operations, in turn,
|
|
166
|
+
# implicitly rely on the presence of this cast.
|
|
167
|
+
return x.float()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def eval_forward(
|
|
171
|
+
model_pack: tabpackage.nn.TabPack,
|
|
172
|
+
batch: dict[str, Tensor],
|
|
173
|
+
*,
|
|
174
|
+
autocast: torch.autocast | None,
|
|
175
|
+
prediction_transform: PredictionTransform | None,
|
|
176
|
+
) -> Tensor:
|
|
177
|
+
"""Apply TabPack to a batch of objects during inference."""
|
|
178
|
+
x = forward(model_pack, batch, autocast=autocast)
|
|
179
|
+
if prediction_transform is not None:
|
|
180
|
+
x = prediction_transform(x)
|
|
181
|
+
return x
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# >>> Training
|
|
185
|
+
#
|
|
186
|
+
# Now, the essential components of a training step: optimizers, loss functions, etc.
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _max_epochs_to_max_steps(max_epochs: int | None, *, n_steps_per_epoch: int) -> int:
|
|
190
|
+
"""Convert the number of epochs to the maximum number of training steps."""
|
|
191
|
+
assert n_steps_per_epoch > 0
|
|
192
|
+
max_steps_upper_bound = torch.iinfo(torch.int64).max
|
|
193
|
+
|
|
194
|
+
if max_epochs is None:
|
|
195
|
+
return max_steps_upper_bound
|
|
196
|
+
else:
|
|
197
|
+
if max_epochs <= 0:
|
|
198
|
+
raise ValueError('The maximum number of epochs must be positive')
|
|
199
|
+
|
|
200
|
+
max_steps = max_epochs * n_steps_per_epoch
|
|
201
|
+
if max_steps > max_steps_upper_bound:
|
|
202
|
+
raise OverflowError(
|
|
203
|
+
'The epoch limit requires more training steps than `torch.int64`'
|
|
204
|
+
' supports'
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return max_steps
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class LossFn(Protocol):
|
|
211
|
+
"""A function computing per-model training losses."""
|
|
212
|
+
|
|
213
|
+
def __call__(self, input: Tensor, target: Tensor) -> Tensor:
|
|
214
|
+
"""Compute one loss per pack member.
|
|
215
|
+
|
|
216
|
+
The loss receives raw model outputs, _before_ applying any prediction
|
|
217
|
+
transformations.
|
|
218
|
+
|
|
219
|
+
Tensor shapes for model packs:
|
|
220
|
+
|
|
221
|
+
- Predictions: `(pack_size, batch_size, *prediction_shape)`
|
|
222
|
+
- Labels: `(pack_size, batch_size, *label_shape)`
|
|
223
|
+
- Output: `(pack_size,)`
|
|
224
|
+
"""
|
|
225
|
+
...
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def make_optimizer_pack(
|
|
229
|
+
model_pack: tabpackage.nn.TabPack,
|
|
230
|
+
*,
|
|
231
|
+
type: (
|
|
232
|
+
# Currently, arbitrary optimizer packs are not allowed. To remove this
|
|
233
|
+
# limitation, the creation of parameter groups and the model replacement
|
|
234
|
+
# machinery must become configurable.
|
|
235
|
+
type[tabpackage.optim.AdamWPack]
|
|
236
|
+
| type[tabpackage.optim.MuonAdamWPack]
|
|
237
|
+
| type[torch.optim.Optimizer]
|
|
238
|
+
),
|
|
239
|
+
support_replacement: bool = False,
|
|
240
|
+
**kwargs,
|
|
241
|
+
) -> torch.optim.Optimizer:
|
|
242
|
+
"""Create an optimizer pack for a model pack."""
|
|
243
|
+
cls = type
|
|
244
|
+
del type
|
|
245
|
+
|
|
246
|
+
if not issubclass(cls, torch.optim.Optimizer):
|
|
247
|
+
raise TypeError(
|
|
248
|
+
'The optimizer type must be a subclass of `torch.optim.Optimizer`'
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if support_replacement and not issubclass(cls, tabpackage.optim.OptimizerPack):
|
|
252
|
+
raise ValueError('The specified optimizer does not support member replacement')
|
|
253
|
+
|
|
254
|
+
# A list of parameter groups where a parameter may be presented multiple times
|
|
255
|
+
# both in one group and in multiple groups.
|
|
256
|
+
parameter_groups_with_duplicates: list[dict[str, Any]] = []
|
|
257
|
+
|
|
258
|
+
if issubclass(cls, tabpackage.optim.MuonAdamWPack):
|
|
259
|
+
# Add parameters optimized with Muon. Using Muon to optimize parameter packs
|
|
260
|
+
# with varying per-model shapes requires special care, so the special helper
|
|
261
|
+
# function is used to build separate per-layer parameter groups correctly.
|
|
262
|
+
for i_block, block in enumerate(model_pack.backbone.blocks):
|
|
263
|
+
parameter_group = cls.make_muon_parameter_group(block.linear)
|
|
264
|
+
if i_block == 0:
|
|
265
|
+
# The first block is used by all models, so none of the models has zero
|
|
266
|
+
# updates in this block. Giving an explicit hint about that avoids the
|
|
267
|
+
# extra device-to-host synchronization for this block.
|
|
268
|
+
parameter_group['muon_skip_zero_updates'] = False
|
|
269
|
+
parameter_groups_with_duplicates.append(parameter_group)
|
|
270
|
+
del i_block, block
|
|
271
|
+
|
|
272
|
+
# Add parameters protected from the weight decay regularization.
|
|
273
|
+
parameter_groups_with_duplicates.append(
|
|
274
|
+
{
|
|
275
|
+
'params': [
|
|
276
|
+
p
|
|
277
|
+
for mn, m in model_pack.named_modules()
|
|
278
|
+
for pn, p in m.named_parameters()
|
|
279
|
+
if tabpackage.optim.should_skip_weight_decay(mn, m, pn, p)
|
|
280
|
+
],
|
|
281
|
+
'weight_decay': 0.0,
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Add the remaining parameters.
|
|
286
|
+
parameter_groups_with_duplicates.append({'params': list(model_pack.parameters())})
|
|
287
|
+
|
|
288
|
+
# Keep each parameter in the group where it first appeared, giving Muon and
|
|
289
|
+
# no-weight-decay groups precedence over the fallback group.
|
|
290
|
+
parameters = set()
|
|
291
|
+
parameter_groups = []
|
|
292
|
+
for group in parameter_groups_with_duplicates:
|
|
293
|
+
deduplicated_params = []
|
|
294
|
+
for p in group['params']:
|
|
295
|
+
if p not in parameters:
|
|
296
|
+
parameters.add(p)
|
|
297
|
+
deduplicated_params.append(p)
|
|
298
|
+
group['params'] = deduplicated_params
|
|
299
|
+
if group['params']:
|
|
300
|
+
parameter_groups.append(group)
|
|
301
|
+
del parameter_groups_with_duplicates
|
|
302
|
+
|
|
303
|
+
# Build the optimizer.
|
|
304
|
+
return cls(
|
|
305
|
+
params=parameter_groups,
|
|
306
|
+
**(
|
|
307
|
+
{'allow_inplace_grad_mutation': True}
|
|
308
|
+
if issubclass(cls, tabpackage.optim.MuonAdamWPack)
|
|
309
|
+
else {}
|
|
310
|
+
),
|
|
311
|
+
**(
|
|
312
|
+
{
|
|
313
|
+
'pack_size': model_pack.pack_size,
|
|
314
|
+
'support_replacement': support_replacement,
|
|
315
|
+
}
|
|
316
|
+
if issubclass(cls, tabpackage.optim.OptimizerPack)
|
|
317
|
+
else {}
|
|
318
|
+
),
|
|
319
|
+
**kwargs,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def make_shuffled_batch_indices_pack(
|
|
324
|
+
*,
|
|
325
|
+
total_size: int,
|
|
326
|
+
batch_size: int,
|
|
327
|
+
pack_size: int,
|
|
328
|
+
generator: torch.Generator | None = None,
|
|
329
|
+
device: str | torch.device | None = None,
|
|
330
|
+
) -> list[Tensor]:
|
|
331
|
+
"""Generate a pack of random batch sequences."""
|
|
332
|
+
random_values = torch.rand(
|
|
333
|
+
(pack_size, total_size), generator=generator, device=device
|
|
334
|
+
)
|
|
335
|
+
batches = random_values.argsort(dim=tabpackage.nn.BATCH_DIM).split(
|
|
336
|
+
batch_size, dim=tabpackage.nn.BATCH_DIM
|
|
337
|
+
)
|
|
338
|
+
return list(batches)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# >>> Evaluation
|
|
342
|
+
#
|
|
343
|
+
# Utilities for per-model evaluation.
|
|
344
|
+
|
|
345
|
+
MetricName: TypeAlias = str
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@enum.unique
|
|
349
|
+
class MetricDirection(enum.StrEnum):
|
|
350
|
+
"""The optimization direction of a metric."""
|
|
351
|
+
|
|
352
|
+
MINIMIZE = enum.auto()
|
|
353
|
+
MAXIMIZE = enum.auto()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
357
|
+
class EvalCriterionConfig:
|
|
358
|
+
"""The dataset and metric used to select the best model and ensemble checkpoints.
|
|
359
|
+
|
|
360
|
+
Attributes:
|
|
361
|
+
dataset: the name of the dataset on which to compare checkpoints or ensembles.
|
|
362
|
+
metric: the name of the metric to compare. It must be included for this dataset
|
|
363
|
+
in `train`'s `eval_metrics` or `ensemble_eval_metrics`, respectively.
|
|
364
|
+
metric_direction: whether lower or higher metric values are better.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
dataset: DatasetName
|
|
368
|
+
metric: MetricName
|
|
369
|
+
metric_direction: MetricDirection
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class MetricFn(Protocol):
|
|
373
|
+
"""A function computing a metric for a model or model pack."""
|
|
374
|
+
|
|
375
|
+
def __call__(self, *, y_true: Tensor, y_pred: Tensor) -> Tensor:
|
|
376
|
+
"""Compute a metric from labels and predictions.
|
|
377
|
+
|
|
378
|
+
The callable accepts _transformed_ model predictions and the same labels for all
|
|
379
|
+
models. Metrics used to select checkpoints or ensemble members must produce a
|
|
380
|
+
single value for each prediction.
|
|
381
|
+
|
|
382
|
+
Tensor shapes for single models:
|
|
383
|
+
|
|
384
|
+
- Labels: `(batch_size, *label_shape)`
|
|
385
|
+
- Predictions: `(batch_size, *prediction_shape)`
|
|
386
|
+
- Output: `(*metric_shape,)`
|
|
387
|
+
|
|
388
|
+
Tensor shapes for model packs:
|
|
389
|
+
|
|
390
|
+
- Labels: `(batch_size, *label_shape)`
|
|
391
|
+
- Predictions: `(pack_size, batch_size, *prediction_shape)`
|
|
392
|
+
- Output: `(pack_size, *metric_shape,)`
|
|
393
|
+
"""
|
|
394
|
+
...
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@torch.inference_mode()
|
|
398
|
+
def _evaluate(
|
|
399
|
+
*,
|
|
400
|
+
model_pack: tabpackage.nn.TabPack,
|
|
401
|
+
prediction_transform: PredictionTransform | None = None,
|
|
402
|
+
datasets: dict[DatasetName, dict[str, Tensor]],
|
|
403
|
+
labels: dict[DatasetName, Tensor],
|
|
404
|
+
metric_fns: dict[DatasetName, dict[MetricName, MetricFn]],
|
|
405
|
+
prediction_dataset_names: tuple[DatasetName, ...],
|
|
406
|
+
batch_size: int,
|
|
407
|
+
device: torch.device,
|
|
408
|
+
aux_device: torch.device,
|
|
409
|
+
autocast: torch.autocast | None,
|
|
410
|
+
) -> tuple[
|
|
411
|
+
dict[DatasetName, Tensor], # Predictions
|
|
412
|
+
dict[DatasetName, dict[MetricName, Tensor]], # Metrics
|
|
413
|
+
]:
|
|
414
|
+
assert datasets.keys() == labels.keys()
|
|
415
|
+
assert metric_fns.keys() <= datasets.keys()
|
|
416
|
+
assert len(set(prediction_dataset_names)) == len(prediction_dataset_names)
|
|
417
|
+
assert set(prediction_dataset_names) <= datasets.keys()
|
|
418
|
+
|
|
419
|
+
model_pack.eval()
|
|
420
|
+
predictions = {}
|
|
421
|
+
metrics = {}
|
|
422
|
+
|
|
423
|
+
for dataset_name in dict.fromkeys([*metric_fns, *prediction_dataset_names]):
|
|
424
|
+
dataset = datasets[dataset_name]
|
|
425
|
+
y_true = labels[dataset_name]
|
|
426
|
+
dataset_size = len(y_true)
|
|
427
|
+
|
|
428
|
+
y_pred = torch.cat(
|
|
429
|
+
[
|
|
430
|
+
eval_forward(
|
|
431
|
+
model_pack,
|
|
432
|
+
{k: v[i : i + batch_size].to(device) for k, v in dataset.items()},
|
|
433
|
+
autocast=autocast,
|
|
434
|
+
prediction_transform=prediction_transform,
|
|
435
|
+
).to(aux_device)
|
|
436
|
+
for i in range(0, dataset_size, batch_size)
|
|
437
|
+
],
|
|
438
|
+
dim=tabpackage.nn.BATCH_DIM,
|
|
439
|
+
)
|
|
440
|
+
if dataset_name in prediction_dataset_names:
|
|
441
|
+
predictions[dataset_name] = y_pred
|
|
442
|
+
|
|
443
|
+
if dataset_name in metric_fns:
|
|
444
|
+
metrics[dataset_name] = {}
|
|
445
|
+
for metric_name, metric_fn in metric_fns[dataset_name].items():
|
|
446
|
+
metric_value = metric_fn(y_true=y_true, y_pred=y_pred).clone()
|
|
447
|
+
if metric_value.ndim <= 0:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
'Metrics must return a tensor with a dimension for the models'
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if metric_value.shape[tabpackage.nn.PACK_DIM] != model_pack.pack_size:
|
|
453
|
+
raise ValueError(
|
|
454
|
+
'The pack dimension of each metric tensor must have one entry'
|
|
455
|
+
' per model'
|
|
456
|
+
)
|
|
457
|
+
metrics[dataset_name][metric_name] = metric_value
|
|
458
|
+
del y_pred
|
|
459
|
+
|
|
460
|
+
return predictions, metrics
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def evaluate(
|
|
464
|
+
*, batch_size: int, **kwargs: Any
|
|
465
|
+
) -> tuple[
|
|
466
|
+
tuple[dict[DatasetName, Tensor], dict[DatasetName, dict[MetricName, Tensor]]],
|
|
467
|
+
int,
|
|
468
|
+
]:
|
|
469
|
+
devices = (kwargs['device'], kwargs['aux_device'])
|
|
470
|
+
|
|
471
|
+
while True:
|
|
472
|
+
try:
|
|
473
|
+
return _evaluate(batch_size=batch_size, **kwargs), batch_size
|
|
474
|
+
except RuntimeError as err:
|
|
475
|
+
message = str(err).lower()
|
|
476
|
+
if batch_size <= 1 or not (
|
|
477
|
+
isinstance(err, torch.OutOfMemoryError)
|
|
478
|
+
or 'out of memory' in message
|
|
479
|
+
or (
|
|
480
|
+
any(x.type == 'mps' for x in devices)
|
|
481
|
+
and (
|
|
482
|
+
'invalid buffer size:' in message
|
|
483
|
+
or (
|
|
484
|
+
'value cannot be converted to type dest_t without overflow'
|
|
485
|
+
in message
|
|
486
|
+
)
|
|
487
|
+
or (
|
|
488
|
+
any(x in message for x in ('2**32', 'int_max', '32-bit'))
|
|
489
|
+
and any(
|
|
490
|
+
x in message
|
|
491
|
+
for x in (
|
|
492
|
+
'not supported',
|
|
493
|
+
'larger than',
|
|
494
|
+
"can't be indexed",
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
):
|
|
501
|
+
raise
|
|
502
|
+
|
|
503
|
+
gc.collect()
|
|
504
|
+
for device in devices:
|
|
505
|
+
if device.type == 'cuda' and torch.cuda.is_available():
|
|
506
|
+
torch.cuda.empty_cache()
|
|
507
|
+
if device.type == 'mps' and torch.mps.is_available():
|
|
508
|
+
torch.mps.empty_cache()
|
|
509
|
+
|
|
510
|
+
new_batch_size = batch_size // 2
|
|
511
|
+
warnings.warn(
|
|
512
|
+
f'Evaluation batch size {batch_size} exceeds memory or tensor size limits.'
|
|
513
|
+
f' Retrying with the evaluation batch size {new_batch_size}',
|
|
514
|
+
category=RuntimeWarning,
|
|
515
|
+
)
|
|
516
|
+
batch_size = max(1, new_batch_size)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# >>> State
|
|
520
|
+
#
|
|
521
|
+
# To efficiently manage a pack of models during the run, below, we define:
|
|
522
|
+
#
|
|
523
|
+
# - Classes storing common state variables of models as stacked tensors.
|
|
524
|
+
# - Functions implementing basic training loop operations, such as recording the
|
|
525
|
+
# best-so-far checkpoints of running models or removing finished models from a pack.
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# The notion of "checkpoint key" is an internal primitive serving for implementation
|
|
529
|
+
# convenience and is not supposed to be used in public API.
|
|
530
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
531
|
+
class ModelCheckpointKey:
|
|
532
|
+
"""An identifier of a model checkpoint."""
|
|
533
|
+
|
|
534
|
+
id: int
|
|
535
|
+
step: int
|
|
536
|
+
|
|
537
|
+
def __post_init__(self) -> None:
|
|
538
|
+
assert self.id >= 0
|
|
539
|
+
assert self.step >= 0
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
543
|
+
class ModelCheckpointKeys:
|
|
544
|
+
"""An ordered collection of checkpoint keys."""
|
|
545
|
+
|
|
546
|
+
ids: Tensor # Shape: (n_checkpoints,)
|
|
547
|
+
steps: Tensor # Shape: (n_checkpoints,)
|
|
548
|
+
|
|
549
|
+
def __post_init__(self) -> None:
|
|
550
|
+
self.validate()
|
|
551
|
+
|
|
552
|
+
def __len__(self) -> int:
|
|
553
|
+
return len(self.ids)
|
|
554
|
+
|
|
555
|
+
def validate(self) -> None:
|
|
556
|
+
if self.ids.ndim != 1:
|
|
557
|
+
raise ValueError('Checkpoint IDs must be one-dimensional')
|
|
558
|
+
|
|
559
|
+
if self.ids.dtype != torch.int64:
|
|
560
|
+
raise TypeError('Checkpoint IDs must have the data type `torch.int64`')
|
|
561
|
+
|
|
562
|
+
if self.ids.device != _IDS_DEVICE:
|
|
563
|
+
raise ValueError('Checkpoint IDs must be on the CPU')
|
|
564
|
+
|
|
565
|
+
if not torch.all(self.ids >= 0):
|
|
566
|
+
raise ValueError('Checkpoint IDs must be non-negative')
|
|
567
|
+
|
|
568
|
+
assert self.steps.shape == self.ids.shape
|
|
569
|
+
assert self.steps.dtype == torch.int64
|
|
570
|
+
assert self.steps.device == self.ids.device
|
|
571
|
+
assert torch.all(self.steps >= 0)
|
|
572
|
+
|
|
573
|
+
def tolist(self) -> list[ModelCheckpointKey]:
|
|
574
|
+
return [
|
|
575
|
+
ModelCheckpointKey(id=id, step=step)
|
|
576
|
+
for id, step in zip(self.ids.tolist(), self.steps.tolist(), strict=True)
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
581
|
+
class ModelCheckpoints:
|
|
582
|
+
"""Model checkpoints."""
|
|
583
|
+
|
|
584
|
+
keys: ModelCheckpointKeys
|
|
585
|
+
state_dicts: dict[str, Tensor] # Shape: (n_checkpoints, *value_shape)
|
|
586
|
+
predictions: dict[
|
|
587
|
+
DatasetName, Tensor # Shape: (n_checkpoints, dataset_size, *prediction_shape)
|
|
588
|
+
]
|
|
589
|
+
metrics: dict[DatasetName, dict[MetricName, Tensor]] # Shape: (n_checkpoints,)
|
|
590
|
+
|
|
591
|
+
def __post_init__(self) -> None:
|
|
592
|
+
self.validate()
|
|
593
|
+
|
|
594
|
+
def __len__(self) -> int:
|
|
595
|
+
return len(self.keys)
|
|
596
|
+
|
|
597
|
+
def validate(self) -> None:
|
|
598
|
+
self.keys.validate()
|
|
599
|
+
|
|
600
|
+
size = len(self)
|
|
601
|
+
assert (
|
|
602
|
+
torch.unique(torch.stack((self.keys.ids, self.keys.steps)), dim=1).shape[1]
|
|
603
|
+
== size
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
for x in itertools.chain(
|
|
607
|
+
self.state_dicts.values(),
|
|
608
|
+
self.predictions.values(),
|
|
609
|
+
itertools.chain.from_iterable(x.values() for x in self.metrics.values()),
|
|
610
|
+
):
|
|
611
|
+
assert len(x) == size, (
|
|
612
|
+
'Checkpoint data must contain one entry per checkpoint'
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# The checkpoint store is a list of checkpoint batches.
|
|
617
|
+
CheckpointStore: TypeAlias = list[ModelCheckpoints]
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def rebuild_checkpoint_store(
|
|
621
|
+
checkpoint_store: CheckpointStore,
|
|
622
|
+
new_checkpoints: list[ModelCheckpoints],
|
|
623
|
+
*,
|
|
624
|
+
required_keys: set[ModelCheckpointKey],
|
|
625
|
+
state_dict_device: torch.device,
|
|
626
|
+
copy_new_checkpoints: bool,
|
|
627
|
+
) -> CheckpointStore:
|
|
628
|
+
"""Rebuild a checkpoint store with a required subset of current and new checkpoints.""" # noqa: E501
|
|
629
|
+
new_checkpoint_store = []
|
|
630
|
+
new_checkpoint_keys = set()
|
|
631
|
+
for source in [checkpoint_store, new_checkpoints]:
|
|
632
|
+
for checkpoints in source:
|
|
633
|
+
keep_idx = [
|
|
634
|
+
i
|
|
635
|
+
for i, x in enumerate(checkpoints.keys.tolist())
|
|
636
|
+
if x in required_keys and x not in new_checkpoint_keys
|
|
637
|
+
]
|
|
638
|
+
if keep_idx:
|
|
639
|
+
if len(keep_idx) < len(checkpoints):
|
|
640
|
+
checkpoints = tabpackage.utils.torch.index(
|
|
641
|
+
checkpoints, torch.tensor(keep_idx)
|
|
642
|
+
)
|
|
643
|
+
elif source is new_checkpoints and copy_new_checkpoints:
|
|
644
|
+
checkpoints = copy.deepcopy(checkpoints)
|
|
645
|
+
new_checkpoint_store.append(
|
|
646
|
+
dataclasses.replace(
|
|
647
|
+
checkpoints,
|
|
648
|
+
state_dicts=tabpackage.utils.torch.to(
|
|
649
|
+
checkpoints.state_dicts, state_dict_device
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
if source is new_checkpoints
|
|
653
|
+
else checkpoints
|
|
654
|
+
)
|
|
655
|
+
new_checkpoint_keys.update(checkpoints.keys.tolist())
|
|
656
|
+
assert new_checkpoint_keys == required_keys
|
|
657
|
+
return new_checkpoint_store
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def gather_checkpoint_predictions(
|
|
661
|
+
checkpoint_store: CheckpointStore,
|
|
662
|
+
checkpoint_keys: ModelCheckpointKeys,
|
|
663
|
+
dataset_name: DatasetName,
|
|
664
|
+
) -> Tensor:
|
|
665
|
+
"""Gather checkpoint predictions from the store in the order of the keys."""
|
|
666
|
+
key_to_prediction = {
|
|
667
|
+
key: prediction
|
|
668
|
+
for batch in checkpoint_store
|
|
669
|
+
for key, prediction in zip(batch.keys.tolist(), batch.predictions[dataset_name])
|
|
670
|
+
}
|
|
671
|
+
# The same key may appear in multiple batches, but it always refers to the same
|
|
672
|
+
# checkpoint, so it does not matter which of the batches provides the prediction.
|
|
673
|
+
return torch.stack([key_to_prediction[x] for x in checkpoint_keys.tolist()])
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# The ID values must not repeat within one instance of the dataclass.
|
|
677
|
+
@dataclasses.dataclass(kw_only=True)
|
|
678
|
+
class TrainingStatePack:
|
|
679
|
+
"""Training state of a model pack."""
|
|
680
|
+
|
|
681
|
+
ids: Tensor # Shape: `(pack_size,)`
|
|
682
|
+
max_steps: Tensor # Shape: `(pack_size,)`
|
|
683
|
+
steps: Tensor # Shape: `(pack_size,)`
|
|
684
|
+
n_consecutive_bad_updates: Tensor # Shape: `(pack_size,)`
|
|
685
|
+
best_checkpoints: ModelCheckpoints | None = None
|
|
686
|
+
|
|
687
|
+
def __post_init__(self) -> None:
|
|
688
|
+
self.validate()
|
|
689
|
+
|
|
690
|
+
@property
|
|
691
|
+
def pack_size(self) -> int:
|
|
692
|
+
return len(self.ids)
|
|
693
|
+
|
|
694
|
+
def validate(self) -> None:
|
|
695
|
+
assert self.ids.ndim == 1
|
|
696
|
+
assert self.ids.dtype == torch.int64
|
|
697
|
+
assert self.ids.device == _IDS_DEVICE
|
|
698
|
+
assert torch.all(self.ids >= 0)
|
|
699
|
+
assert len(torch.unique(self.ids)) == self.pack_size
|
|
700
|
+
|
|
701
|
+
for x in (self.max_steps, self.steps, self.n_consecutive_bad_updates):
|
|
702
|
+
assert x.shape == self.ids.shape
|
|
703
|
+
assert x.dtype == torch.int64
|
|
704
|
+
assert x.device == self.ids.device
|
|
705
|
+
del x
|
|
706
|
+
|
|
707
|
+
assert torch.all(self.max_steps > 0)
|
|
708
|
+
assert torch.all(self.steps >= 0)
|
|
709
|
+
assert torch.all(self.n_consecutive_bad_updates >= 0)
|
|
710
|
+
|
|
711
|
+
if self.best_checkpoints is not None:
|
|
712
|
+
assert torch.equal(self.best_checkpoints.keys.ids, self.ids)
|
|
713
|
+
|
|
714
|
+
def is_running(self, *, early_stopping_patience: int | None) -> Tensor:
|
|
715
|
+
"""Determine which models in a pack are still running."""
|
|
716
|
+
running_mask = self.steps < self.max_steps
|
|
717
|
+
if early_stopping_patience is not None:
|
|
718
|
+
assert early_stopping_patience >= 0, (
|
|
719
|
+
'The early stopping patience must be non-negative'
|
|
720
|
+
)
|
|
721
|
+
running_mask &= self.n_consecutive_bad_updates <= early_stopping_patience
|
|
722
|
+
return running_mask
|
|
723
|
+
|
|
724
|
+
def replace(
|
|
725
|
+
self, *, pack_idx: Tensor, ids: Tensor, max_steps: Tensor | None = None
|
|
726
|
+
) -> None:
|
|
727
|
+
"""Reset the state at the given positions for new models with the given IDs."""
|
|
728
|
+
tabpackage.nn.check_pack_idx(pack_idx, self.pack_size)
|
|
729
|
+
assert ids.shape == pack_idx.shape
|
|
730
|
+
if max_steps is not None:
|
|
731
|
+
assert max_steps.shape == pack_idx.shape
|
|
732
|
+
|
|
733
|
+
self.ids[pack_idx] = ids
|
|
734
|
+
if max_steps is not None:
|
|
735
|
+
self.max_steps[pack_idx] = max_steps
|
|
736
|
+
self.steps[pack_idx] = 0
|
|
737
|
+
self.n_consecutive_bad_updates[pack_idx] = 0
|
|
738
|
+
|
|
739
|
+
if self.best_checkpoints is not None:
|
|
740
|
+
self.best_checkpoints.keys.ids[pack_idx] = ids
|
|
741
|
+
self.best_checkpoints.keys.steps[pack_idx] = 0
|
|
742
|
+
for value in itertools.chain(
|
|
743
|
+
self.best_checkpoints.state_dicts.values(),
|
|
744
|
+
self.best_checkpoints.predictions.values(),
|
|
745
|
+
itertools.chain.from_iterable(
|
|
746
|
+
x.values() for x in self.best_checkpoints.metrics.values()
|
|
747
|
+
),
|
|
748
|
+
):
|
|
749
|
+
value[pack_idx] = math.nan if value.is_floating_point() else 0
|
|
750
|
+
|
|
751
|
+
self.validate()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def update_training_state(
|
|
755
|
+
training_state: TrainingStatePack,
|
|
756
|
+
*,
|
|
757
|
+
latest_checkpoints: ModelCheckpoints,
|
|
758
|
+
eval_criterion: 'EvalCriterionConfig | None',
|
|
759
|
+
) -> None:
|
|
760
|
+
"""Update the training state."""
|
|
761
|
+
assert torch.equal(latest_checkpoints.keys.ids, training_state.ids)
|
|
762
|
+
|
|
763
|
+
if eval_criterion is None:
|
|
764
|
+
# Without the criterion, any update is an improvement.
|
|
765
|
+
training_state.best_checkpoints = copy.deepcopy(latest_checkpoints)
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
latest_eval_criterion_metrics = latest_checkpoints.metrics[eval_criterion.dataset][
|
|
769
|
+
eval_criterion.metric
|
|
770
|
+
]
|
|
771
|
+
if latest_eval_criterion_metrics.ndim != 1:
|
|
772
|
+
raise ValueError(
|
|
773
|
+
'The metric used as the evaluation criterion must return one scalar per'
|
|
774
|
+
' model'
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# For a given model, NaN values of the criterion metric always counts as a
|
|
778
|
+
# non-improvement regardless of any other conditions.
|
|
779
|
+
nonimprovable_mask = torch.isnan(latest_eval_criterion_metrics)
|
|
780
|
+
|
|
781
|
+
if training_state.best_checkpoints is None:
|
|
782
|
+
training_state.n_consecutive_bad_updates[
|
|
783
|
+
nonimprovable_mask.to(training_state.n_consecutive_bad_updates.device)
|
|
784
|
+
] += 1
|
|
785
|
+
training_state.best_checkpoints = copy.deepcopy(latest_checkpoints)
|
|
786
|
+
|
|
787
|
+
else:
|
|
788
|
+
best_eval_criterion_metrics = training_state.best_checkpoints.metrics[
|
|
789
|
+
eval_criterion.dataset
|
|
790
|
+
][eval_criterion.metric]
|
|
791
|
+
assert best_eval_criterion_metrics.shape == latest_eval_criterion_metrics.shape
|
|
792
|
+
|
|
793
|
+
first_update_mask = training_state.best_checkpoints.keys.steps == 0
|
|
794
|
+
improved_mask = (
|
|
795
|
+
# Any non-NaN value is considered as an improvement over NaN.
|
|
796
|
+
torch.isnan(best_eval_criterion_metrics)
|
|
797
|
+
| (
|
|
798
|
+
latest_eval_criterion_metrics < best_eval_criterion_metrics
|
|
799
|
+
if eval_criterion.metric_direction == MetricDirection.MINIMIZE
|
|
800
|
+
else latest_eval_criterion_metrics > best_eval_criterion_metrics
|
|
801
|
+
)
|
|
802
|
+
).to(training_state.best_checkpoints.keys.steps.device)
|
|
803
|
+
improved_mask |= first_update_mask
|
|
804
|
+
improved_mask &= ~nonimprovable_mask.to(improved_mask.device)
|
|
805
|
+
training_state.n_consecutive_bad_updates[improved_mask] = 0
|
|
806
|
+
training_state.n_consecutive_bad_updates[~improved_mask] += 1
|
|
807
|
+
|
|
808
|
+
# Store the first evaluated checkpoint even if its metric is NaN.
|
|
809
|
+
updated_mask = improved_mask | first_update_mask
|
|
810
|
+
updated_pack_idx = torch.nonzero(updated_mask)[:, 0]
|
|
811
|
+
if len(updated_pack_idx) > 0:
|
|
812
|
+
# The following function call updates all tensors in a given object at given
|
|
813
|
+
# indices, recursively.
|
|
814
|
+
tabpackage.utils.torch.index_set_(
|
|
815
|
+
# Update the current best evaluation data ...
|
|
816
|
+
training_state.best_checkpoints,
|
|
817
|
+
# ... at the position corresponding to the updated models ...
|
|
818
|
+
(updated_pack_idx,),
|
|
819
|
+
# ... with the corresponding slice of the latest evaluation data.
|
|
820
|
+
tabpackage.utils.torch.index(latest_checkpoints, updated_pack_idx),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def pack_remove(
|
|
825
|
+
model_pack: tabpackage.nn.TabPack,
|
|
826
|
+
optimizer_pack: torch.optim.Optimizer,
|
|
827
|
+
training_state: TrainingStatePack,
|
|
828
|
+
*,
|
|
829
|
+
pack_idx: Tensor,
|
|
830
|
+
) -> TrainingStatePack:
|
|
831
|
+
"""Remove some models from a model pack."""
|
|
832
|
+
assert len(pack_idx) > 0
|
|
833
|
+
|
|
834
|
+
pack_idx_on_device = pack_idx.to(next(model_pack.parameters()).device)
|
|
835
|
+
|
|
836
|
+
# Remove the stopped models by removing the corresponding slices from parameter and
|
|
837
|
+
# buffer packs. Technically, this means replacing the parameter and buffer packs
|
|
838
|
+
# with smaller ones. The returned mapping links old parameter tensors to new ones.
|
|
839
|
+
old_to_new_parameters = model_pack.remove(pack_idx=pack_idx_on_device)
|
|
840
|
+
|
|
841
|
+
# Now, in the optimizer pack, replace the old optimized parameter tensors with new
|
|
842
|
+
# ones, and remove the parts of the optimizer state tensor corresponding to the
|
|
843
|
+
# removed models.
|
|
844
|
+
tabpackage.optim.optimizer_pack_remove(
|
|
845
|
+
optimizer_pack,
|
|
846
|
+
pack_idx=pack_idx_on_device,
|
|
847
|
+
old_to_new_parameters=old_to_new_parameters,
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
del pack_idx_on_device
|
|
851
|
+
|
|
852
|
+
# Determine which models to _keep_ in the pack.
|
|
853
|
+
keep_pack_idx = tabpackage.nn.make_complementary_pack_idx(
|
|
854
|
+
pack_idx, pack_size=training_state.pack_size
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
return tabpackage.utils.torch.index(training_state, keep_pack_idx)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def pack_replace(
|
|
861
|
+
model_pack: tabpackage.nn.TabPack,
|
|
862
|
+
optimizer_pack: tabpackage.optim.OptimizerPack,
|
|
863
|
+
training_state: TrainingStatePack,
|
|
864
|
+
*,
|
|
865
|
+
pack_idx: Tensor,
|
|
866
|
+
ids: list[ModelID],
|
|
867
|
+
per_model_hyperparameters: list[HyperparameterDict],
|
|
868
|
+
n_steps_per_epoch: int,
|
|
869
|
+
) -> None:
|
|
870
|
+
"""Replace some models in a model pack with new ones."""
|
|
871
|
+
assert len(pack_idx) == len(ids)
|
|
872
|
+
assert len(pack_idx) == len(per_model_hyperparameters)
|
|
873
|
+
|
|
874
|
+
pack_idx_on_device = pack_idx.to(next(model_pack.parameters()).device)
|
|
875
|
+
|
|
876
|
+
per_model_hyperparameters_T = transpose_dicts_recursively(per_model_hyperparameters)
|
|
877
|
+
|
|
878
|
+
# Reinitialize the corresponding slices of the model parameters according to the
|
|
879
|
+
# new model hyperparameters. Note that the pack was created with the upper bounds
|
|
880
|
+
# of the model shape hyperparameters, so any new model fits the existing tensors.
|
|
881
|
+
model_pack.replace(
|
|
882
|
+
pack_idx=pack_idx_on_device, **per_model_hyperparameters_T.pop('model', {})
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Reset the corresponding slices of the optimizer state and set the new optimization
|
|
886
|
+
# hyperparameters. The optimizer must be updated _after_ the model pack, because the
|
|
887
|
+
# Muon update scales are recomputed from the new model shapes.
|
|
888
|
+
optimizer_pack.replace( # ty: ignore[unresolved-attribute]
|
|
889
|
+
pack_idx=pack_idx_on_device,
|
|
890
|
+
**(
|
|
891
|
+
{'module': model_pack}
|
|
892
|
+
if isinstance(optimizer_pack, tabpackage.optim.MuonAdamWPack)
|
|
893
|
+
else {}
|
|
894
|
+
),
|
|
895
|
+
**per_model_hyperparameters_T.pop('optimizer', {}),
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
del pack_idx_on_device
|
|
899
|
+
|
|
900
|
+
max_epochs = per_model_hyperparameters_T.pop('max_epochs', None)
|
|
901
|
+
|
|
902
|
+
# Ensure that all hyperparameters have been used.
|
|
903
|
+
assert not per_model_hyperparameters_T
|
|
904
|
+
|
|
905
|
+
# Reset the training state for the replaced models.
|
|
906
|
+
ids_tensor = torch.tensor(
|
|
907
|
+
ids, dtype=training_state.ids.dtype, device=training_state.ids.device
|
|
908
|
+
)
|
|
909
|
+
training_state.replace(
|
|
910
|
+
pack_idx=pack_idx,
|
|
911
|
+
ids=ids_tensor,
|
|
912
|
+
max_steps=(
|
|
913
|
+
None
|
|
914
|
+
if max_epochs is None
|
|
915
|
+
else torch.tensor(
|
|
916
|
+
[
|
|
917
|
+
_max_epochs_to_max_steps(x, n_steps_per_epoch=n_steps_per_epoch)
|
|
918
|
+
for x in max_epochs
|
|
919
|
+
],
|
|
920
|
+
dtype=training_state.max_steps.dtype,
|
|
921
|
+
device=training_state.max_steps.device,
|
|
922
|
+
)
|
|
923
|
+
),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# >>> Ensembles
|
|
928
|
+
#
|
|
929
|
+
# The code for on-the-fly ensembling construction.
|
|
930
|
+
|
|
931
|
+
EnsembleName: TypeAlias = str
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
EnsembleAlgorithmFn: TypeAlias = Callable[
|
|
935
|
+
# predictions -> (member_idx, member_weights | None)
|
|
936
|
+
[Tensor], tuple[Tensor, Tensor | None]
|
|
937
|
+
]
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def check_ensemble_member_idx(member_idx: Tensor) -> None:
|
|
941
|
+
if member_idx.dtype not in (torch.int32, torch.int64):
|
|
942
|
+
raise TypeError(
|
|
943
|
+
'Indices of ensemble members must have the data type `torch.int32` or'
|
|
944
|
+
' `torch.int64`'
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
if member_idx.ndim != 1:
|
|
948
|
+
raise ValueError('Ensemble member indices must be one-dimensional')
|
|
949
|
+
|
|
950
|
+
if len(member_idx) == 0:
|
|
951
|
+
raise ValueError('Select at least one ensemble member')
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def check_ensemble_member_weights(member_weights: Tensor) -> None:
|
|
955
|
+
if not torch.is_floating_point(member_weights):
|
|
956
|
+
raise TypeError('Ensemble member weights must be floating-point values')
|
|
957
|
+
|
|
958
|
+
if member_weights.ndim != 1:
|
|
959
|
+
raise ValueError('Ensemble member weights must be one-dimensional')
|
|
960
|
+
|
|
961
|
+
if len(member_weights) == 0:
|
|
962
|
+
raise ValueError('Provide at least one ensemble member weight')
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def compute_ensemble_predictions(
|
|
966
|
+
member_predictions: Tensor,
|
|
967
|
+
member_weights: Tensor | None,
|
|
968
|
+
prediction_transform: PredictionTransform | None,
|
|
969
|
+
) -> Tensor:
|
|
970
|
+
predictions = tabpackage.ensemble.compute_unsanitized_ensemble_predictions(
|
|
971
|
+
member_predictions, member_weights
|
|
972
|
+
)
|
|
973
|
+
if prediction_transform is not None:
|
|
974
|
+
predictions = prediction_transform(predictions)
|
|
975
|
+
return predictions
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class Ensemble:
|
|
979
|
+
"""An ensemble that can be updated during training with new model predictions."""
|
|
980
|
+
|
|
981
|
+
def __init__(
|
|
982
|
+
self,
|
|
983
|
+
*,
|
|
984
|
+
algorithm_fn: EnsembleAlgorithmFn,
|
|
985
|
+
algorithm_dataset: DatasetName,
|
|
986
|
+
online: bool = True,
|
|
987
|
+
prediction_transform: PredictionTransform | None,
|
|
988
|
+
score_fn: Callable[[Tensor], float] | None,
|
|
989
|
+
score_dataset: DatasetName | None,
|
|
990
|
+
early_stopping_patience: int | None,
|
|
991
|
+
) -> None:
|
|
992
|
+
"""
|
|
993
|
+
Args:
|
|
994
|
+
algorithm_fn: the algorithm for building an ensemble from a given pool
|
|
995
|
+
of predictions, and optionally assigning weights to the selected
|
|
996
|
+
predictions.
|
|
997
|
+
algorithm_dataset: which dataset to use to build the ensemble.
|
|
998
|
+
online: if `True`, use current members, best checkpoints of models that have
|
|
999
|
+
finished training, and latest checkpoints of still-training models for
|
|
1000
|
+
rebuilding the ensemble after each epoch. Otherwise, use only best
|
|
1001
|
+
checkpoints of finished models, also after each epoch.
|
|
1002
|
+
prediction_transform: transform the aggregated _ensemble_ predictions
|
|
1003
|
+
before scoring (the passed individual model predictions are not
|
|
1004
|
+
affected). The function must not change the prediction shape. Pass
|
|
1005
|
+
`None` if no transformation is needed.
|
|
1006
|
+
score_fn: the scoring function for _ensemble_ predictions used to accept or
|
|
1007
|
+
reject new ensembles. Higher scores are better. If `None`, accept every
|
|
1008
|
+
rebuilt ensemble without scoring it.
|
|
1009
|
+
score_dataset: which dataset to use to score ensemble updates. Must be
|
|
1010
|
+
`None` if and only if `score_fn` is `None`.
|
|
1011
|
+
early_stopping_patience: how many unsuccessful updates to tolerate before
|
|
1012
|
+
finalizing the ensemble. Must be `None` without the scoring function.
|
|
1013
|
+
"""
|
|
1014
|
+
assert (score_fn is None) == (score_dataset is None)
|
|
1015
|
+
if early_stopping_patience is not None:
|
|
1016
|
+
assert early_stopping_patience >= 0, (
|
|
1017
|
+
'The early stopping patience must be non-negative'
|
|
1018
|
+
)
|
|
1019
|
+
assert score_fn is not None
|
|
1020
|
+
|
|
1021
|
+
self._algorithm_fn: Final = algorithm_fn
|
|
1022
|
+
self._algorithm_dataset: Final = algorithm_dataset
|
|
1023
|
+
self._online: Final = online
|
|
1024
|
+
self._prediction_transform: Final = prediction_transform
|
|
1025
|
+
|
|
1026
|
+
self._score_fn: Final = score_fn
|
|
1027
|
+
self._score_dataset: Final = score_dataset
|
|
1028
|
+
self._early_stopping_patience: Final = early_stopping_patience
|
|
1029
|
+
self._best_score: float | None = None
|
|
1030
|
+
self._n_consecutive_bad_updates = 0
|
|
1031
|
+
|
|
1032
|
+
self._member_checkpoint_keys: ModelCheckpointKeys | None = None
|
|
1033
|
+
self._member_weights: Tensor | None = None
|
|
1034
|
+
|
|
1035
|
+
@property
|
|
1036
|
+
def algorithm_dataset(self) -> DatasetName:
|
|
1037
|
+
return self._algorithm_dataset
|
|
1038
|
+
|
|
1039
|
+
@property
|
|
1040
|
+
def prediction_transform(self) -> PredictionTransform | None:
|
|
1041
|
+
"""The transformation applied to aggregated ensemble predictions."""
|
|
1042
|
+
return self._prediction_transform
|
|
1043
|
+
|
|
1044
|
+
@property
|
|
1045
|
+
def member_checkpoint_keys(self) -> ModelCheckpointKeys | None:
|
|
1046
|
+
"""The checkpoint keys of the ensemble members."""
|
|
1047
|
+
return self._member_checkpoint_keys
|
|
1048
|
+
|
|
1049
|
+
@property
|
|
1050
|
+
def member_weights(self) -> Tensor | None:
|
|
1051
|
+
"""The weights (importance) of the ensemble members."""
|
|
1052
|
+
return self._member_weights
|
|
1053
|
+
|
|
1054
|
+
def is_running(self) -> bool:
|
|
1055
|
+
"""Check if the ensemble is still running and can be further updated."""
|
|
1056
|
+
return (
|
|
1057
|
+
self._early_stopping_patience is None
|
|
1058
|
+
or self._n_consecutive_bad_updates <= self._early_stopping_patience
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
def update(
|
|
1062
|
+
self,
|
|
1063
|
+
*,
|
|
1064
|
+
checkpoint_store: CheckpointStore,
|
|
1065
|
+
finished_best_checkpoint_keys: ModelCheckpointKeys,
|
|
1066
|
+
running_latest_checkpoint_keys: ModelCheckpointKeys,
|
|
1067
|
+
) -> bool:
|
|
1068
|
+
"""Try to rebuild the ensemble using new predictions.
|
|
1069
|
+
|
|
1070
|
+
Offline updates with no finished models do not consume patience.
|
|
1071
|
+
|
|
1072
|
+
Args:
|
|
1073
|
+
checkpoint_store: the store containing eligible input checkpoints and,
|
|
1074
|
+
in online mode, current members.
|
|
1075
|
+
finished_best_checkpoint_keys: best checkpoints of all finished models.
|
|
1076
|
+
running_latest_checkpoint_keys: latest checkpoints of still-running models,
|
|
1077
|
+
considered only in online mode.
|
|
1078
|
+
Returns:
|
|
1079
|
+
`True` if the rebuilt ensemble is accepted, `False` otherwise.
|
|
1080
|
+
"""
|
|
1081
|
+
assert self.is_running()
|
|
1082
|
+
assert finished_best_checkpoint_keys or running_latest_checkpoint_keys
|
|
1083
|
+
assert len(
|
|
1084
|
+
{
|
|
1085
|
+
*finished_best_checkpoint_keys.tolist(),
|
|
1086
|
+
*running_latest_checkpoint_keys.tolist(),
|
|
1087
|
+
}
|
|
1088
|
+
) == len(finished_best_checkpoint_keys) + len(running_latest_checkpoint_keys)
|
|
1089
|
+
|
|
1090
|
+
if self._online:
|
|
1091
|
+
candidate_keys = tabpackage.utils.torch.cat(
|
|
1092
|
+
[finished_best_checkpoint_keys, running_latest_checkpoint_keys]
|
|
1093
|
+
)
|
|
1094
|
+
if self.member_checkpoint_keys is not None:
|
|
1095
|
+
current_member_checkpoint_key_set = set(
|
|
1096
|
+
self.member_checkpoint_keys.tolist()
|
|
1097
|
+
)
|
|
1098
|
+
new_candidate_idx = [
|
|
1099
|
+
i
|
|
1100
|
+
for i, key in enumerate(candidate_keys.tolist())
|
|
1101
|
+
if key not in current_member_checkpoint_key_set
|
|
1102
|
+
]
|
|
1103
|
+
# Keeping current members first favors them when the selection
|
|
1104
|
+
# algorithm breaks ties by candidate order.
|
|
1105
|
+
candidate_keys = tabpackage.utils.torch.cat(
|
|
1106
|
+
[
|
|
1107
|
+
self.member_checkpoint_keys,
|
|
1108
|
+
tabpackage.utils.torch.index(candidate_keys, new_candidate_idx),
|
|
1109
|
+
]
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
elif finished_best_checkpoint_keys:
|
|
1113
|
+
candidate_keys = finished_best_checkpoint_keys
|
|
1114
|
+
|
|
1115
|
+
else:
|
|
1116
|
+
# No finished models are available yet. In the offline mode, this does not
|
|
1117
|
+
# count as a bad update.
|
|
1118
|
+
return False
|
|
1119
|
+
|
|
1120
|
+
if (
|
|
1121
|
+
self._score_fn is not None
|
|
1122
|
+
and self.member_checkpoint_keys is not None
|
|
1123
|
+
and (
|
|
1124
|
+
set(self.member_checkpoint_keys.tolist())
|
|
1125
|
+
== set(candidate_keys.tolist())
|
|
1126
|
+
)
|
|
1127
|
+
):
|
|
1128
|
+
# The candidate pool contains exactly the current members. Skip rebuilding
|
|
1129
|
+
# and count this as a bad update.
|
|
1130
|
+
self._n_consecutive_bad_updates += 1
|
|
1131
|
+
return False
|
|
1132
|
+
|
|
1133
|
+
# Build a new ensemble.
|
|
1134
|
+
member_idx, member_weights = self._algorithm_fn(
|
|
1135
|
+
gather_checkpoint_predictions(
|
|
1136
|
+
checkpoint_store, candidate_keys, self.algorithm_dataset
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
check_ensemble_member_idx(member_idx)
|
|
1140
|
+
if member_weights is not None:
|
|
1141
|
+
if member_idx.shape != member_weights.shape:
|
|
1142
|
+
raise ValueError(
|
|
1143
|
+
'The number of member weights does not match the number of ensemble'
|
|
1144
|
+
' members'
|
|
1145
|
+
)
|
|
1146
|
+
check_ensemble_member_weights(member_weights)
|
|
1147
|
+
|
|
1148
|
+
member_checkpoint_keys = tabpackage.utils.torch.index(
|
|
1149
|
+
candidate_keys, member_idx
|
|
1150
|
+
)
|
|
1151
|
+
if len(set(member_checkpoint_keys.tolist())) != len(member_checkpoint_keys):
|
|
1152
|
+
raise ValueError(
|
|
1153
|
+
'The ensemble algorithm selected the same model checkpoint more than'
|
|
1154
|
+
' once'
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
if self._score_fn is None:
|
|
1158
|
+
score = None
|
|
1159
|
+
improved = True
|
|
1160
|
+
else:
|
|
1161
|
+
# Evaluate the new ensemble.
|
|
1162
|
+
assert self._score_dataset is not None
|
|
1163
|
+
member_predictions = gather_checkpoint_predictions(
|
|
1164
|
+
checkpoint_store, member_checkpoint_keys, self._score_dataset
|
|
1165
|
+
)
|
|
1166
|
+
ensemble_predictions = compute_ensemble_predictions(
|
|
1167
|
+
member_predictions, member_weights, self._prediction_transform
|
|
1168
|
+
)
|
|
1169
|
+
score = self._score_fn(ensemble_predictions)
|
|
1170
|
+
improved = not math.isnan(score) and (
|
|
1171
|
+
self._best_score is None or self._best_score < score
|
|
1172
|
+
)
|
|
1173
|
+
if improved:
|
|
1174
|
+
# Replace the current members with new ones.
|
|
1175
|
+
self._member_weights = member_weights
|
|
1176
|
+
self._member_checkpoint_keys = member_checkpoint_keys
|
|
1177
|
+
self._best_score = score
|
|
1178
|
+
self._n_consecutive_bad_updates = 0
|
|
1179
|
+
else:
|
|
1180
|
+
self._n_consecutive_bad_updates += 1
|
|
1181
|
+
|
|
1182
|
+
return improved
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
1186
|
+
class EnsembleConfig:
|
|
1187
|
+
"""Configuration for an ensemble built during training.
|
|
1188
|
+
|
|
1189
|
+
Attributes:
|
|
1190
|
+
algorithm: the ensemble algorithm configuration dictionary. The `"type"` entry
|
|
1191
|
+
specifies the callable used to select members and optionally assign their
|
|
1192
|
+
weights; the remaining entries are keyword arguments for that callable.
|
|
1193
|
+
See `EnsembleAlgorithmType` for details.
|
|
1194
|
+
online: whether to consider models that are still training when building the
|
|
1195
|
+
ensemble. If `True`, each update can select from the current ensemble
|
|
1196
|
+
members, the best checkpoints of finished models, and the latest
|
|
1197
|
+
checkpoints of still-training models. If `False`, only the best checkpoints
|
|
1198
|
+
of finished models are considered. In both cases, updates are attempted
|
|
1199
|
+
after each epoch.
|
|
1200
|
+
prediction_transform: the transformation applied after combining ensemble
|
|
1201
|
+
members' predictions, for evaluation and inference. It must preserve the
|
|
1202
|
+
prediction shape. `None` leaves the combined predictions unchanged.
|
|
1203
|
+
early_stopping_patience: the number of consecutive updates without improvement
|
|
1204
|
+
in `train`'s `ensemble_eval_criterion` tolerated before finalizing the
|
|
1205
|
+
ensemble. A finalized ensemble keeps its selected members and is no longer
|
|
1206
|
+
updated. `None` disables ensemble-level early stopping. Must be `None` if
|
|
1207
|
+
`ensemble_eval_criterion` is not set.
|
|
1208
|
+
algorithm_metric: the metric callable used by the algorithm to select ensemble
|
|
1209
|
+
members and, if supported, their weights. It must return one scalar per
|
|
1210
|
+
candidate prediction in the input pack. See `MetricFn` for details. This
|
|
1211
|
+
metric guides ensemble construction; `train`'s `ensemble_eval_criterion`
|
|
1212
|
+
determines whether to accept the resulting ensemble.
|
|
1213
|
+
algorithm_metric_direction: whether the algorithm should minimize or maximize
|
|
1214
|
+
`algorithm_metric`.
|
|
1215
|
+
algorithm_dataset: the name of the dataset whose labels and predictions are
|
|
1216
|
+
used to build the ensemble. Must be one of the datasets passed to `train`.
|
|
1217
|
+
algorithm_device: the device used to run the ensemble algorithm and compute
|
|
1218
|
+
`algorithm_metric`.
|
|
1219
|
+
"""
|
|
1220
|
+
|
|
1221
|
+
algorithm: KWArgs
|
|
1222
|
+
online: bool
|
|
1223
|
+
prediction_transform: PredictionTransform | None
|
|
1224
|
+
early_stopping_patience: int | None
|
|
1225
|
+
algorithm_metric: MetricFn
|
|
1226
|
+
algorithm_metric_direction: MetricDirection
|
|
1227
|
+
algorithm_dataset: DatasetName
|
|
1228
|
+
algorithm_device: torch.device
|
|
1229
|
+
|
|
1230
|
+
def __post_init__(self) -> None:
|
|
1231
|
+
if (
|
|
1232
|
+
self.early_stopping_patience is not None
|
|
1233
|
+
and self.early_stopping_patience < 0
|
|
1234
|
+
):
|
|
1235
|
+
raise ValueError('The early stopping patience must be non-negative')
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
EnsembleAlgorithmType: TypeAlias = Callable
|
|
1239
|
+
"""An ensemble algorithm.
|
|
1240
|
+
|
|
1241
|
+
The callable must accept at least the following arguments (plus any number of other
|
|
1242
|
+
arguments):
|
|
1243
|
+
|
|
1244
|
+
- `predictions: Tensor` as the first positional argument, with candidate checkpoints
|
|
1245
|
+
along the first dimension.
|
|
1246
|
+
- `score_fn: Callable[[Tensor], Tensor]`, returning one higher-is-better score per
|
|
1247
|
+
prediction in the input pack.
|
|
1248
|
+
|
|
1249
|
+
Return a one-dimensional tensor of unique selected indices, or `(indices, weights)`
|
|
1250
|
+
with one weight per selected index. Weights may be `None` for equal weighting.
|
|
1251
|
+
"""
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def make_ensemble_algorithm(
|
|
1255
|
+
type: EnsembleAlgorithmType,
|
|
1256
|
+
*,
|
|
1257
|
+
y_true: Tensor,
|
|
1258
|
+
metric_fn: MetricFn,
|
|
1259
|
+
metric_direction: MetricDirection,
|
|
1260
|
+
device: torch.device,
|
|
1261
|
+
**kwargs,
|
|
1262
|
+
) -> EnsembleAlgorithmFn:
|
|
1263
|
+
fn = type
|
|
1264
|
+
del type
|
|
1265
|
+
|
|
1266
|
+
y_true = y_true.to(device)
|
|
1267
|
+
|
|
1268
|
+
def score_fn(y_pred: Tensor) -> Tensor:
|
|
1269
|
+
value = metric_fn(y_true=y_true, y_pred=y_pred)
|
|
1270
|
+
if value.shape != (y_pred.shape[tabpackage.nn.PACK_DIM],):
|
|
1271
|
+
raise ValueError(
|
|
1272
|
+
'The metric used to select ensemble members must return one scalar per'
|
|
1273
|
+
' candidate'
|
|
1274
|
+
)
|
|
1275
|
+
return value if metric_direction == MetricDirection.MAXIMIZE else -value
|
|
1276
|
+
|
|
1277
|
+
def fn_on_device(predictions: Tensor) -> tuple[Tensor, Tensor | None]:
|
|
1278
|
+
result = fn(predictions.to(device), score_fn=score_fn, **kwargs)
|
|
1279
|
+
member_idx, member_weights = (
|
|
1280
|
+
(result, None) if isinstance(result, Tensor) else result
|
|
1281
|
+
)
|
|
1282
|
+
return (
|
|
1283
|
+
member_idx.to(predictions.device),
|
|
1284
|
+
None if member_weights is None else member_weights.to(predictions.device),
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
return fn_on_device
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def make_ensemble_score_fn(
|
|
1291
|
+
*, metric_fn: MetricFn, metric_direction: MetricDirection, y_true: Tensor
|
|
1292
|
+
) -> Callable[[Tensor], float]:
|
|
1293
|
+
def score_fn(y_pred: Tensor) -> float:
|
|
1294
|
+
value = metric_fn(y_true=y_true, y_pred=y_pred.to(y_true.device))
|
|
1295
|
+
if value.ndim != 0:
|
|
1296
|
+
raise ValueError(
|
|
1297
|
+
'The metric used to score an ensemble must return a scalar tensor'
|
|
1298
|
+
)
|
|
1299
|
+
value = value.item()
|
|
1300
|
+
return value if metric_direction == MetricDirection.MAXIMIZE else -value
|
|
1301
|
+
|
|
1302
|
+
return score_fn
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def evaluate_ensemble(
|
|
1306
|
+
*,
|
|
1307
|
+
ensemble: Ensemble,
|
|
1308
|
+
checkpoint_store: CheckpointStore,
|
|
1309
|
+
labels: dict[DatasetName, Tensor],
|
|
1310
|
+
metric_fns: dict[DatasetName, dict[MetricName, MetricFn]],
|
|
1311
|
+
aux_device: torch.device,
|
|
1312
|
+
) -> dict[DatasetName, dict[MetricName, Tensor]]:
|
|
1313
|
+
assert ensemble.member_checkpoint_keys is not None
|
|
1314
|
+
|
|
1315
|
+
metrics = {}
|
|
1316
|
+
|
|
1317
|
+
for dataset_name, dataset_metric_fns in metric_fns.items():
|
|
1318
|
+
y_pred = compute_ensemble_predictions(
|
|
1319
|
+
gather_checkpoint_predictions(
|
|
1320
|
+
checkpoint_store, ensemble.member_checkpoint_keys, dataset_name
|
|
1321
|
+
),
|
|
1322
|
+
ensemble.member_weights,
|
|
1323
|
+
ensemble.prediction_transform,
|
|
1324
|
+
).to(aux_device)
|
|
1325
|
+
|
|
1326
|
+
metrics[dataset_name] = {
|
|
1327
|
+
k: v(y_true=labels[dataset_name], y_pred=y_pred).clone()
|
|
1328
|
+
for k, v in dataset_metric_fns.items()
|
|
1329
|
+
}
|
|
1330
|
+
del y_pred
|
|
1331
|
+
|
|
1332
|
+
return metrics
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
# >>> Printing
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def update_best_criterion_metric(
|
|
1339
|
+
best: float | None, latest: float, direction: MetricDirection
|
|
1340
|
+
) -> float:
|
|
1341
|
+
return (
|
|
1342
|
+
latest
|
|
1343
|
+
if best is None
|
|
1344
|
+
or (
|
|
1345
|
+
not math.isnan(latest)
|
|
1346
|
+
and (
|
|
1347
|
+
math.isnan(best)
|
|
1348
|
+
or (
|
|
1349
|
+
latest < best
|
|
1350
|
+
if direction == MetricDirection.MINIMIZE
|
|
1351
|
+
else latest > best
|
|
1352
|
+
)
|
|
1353
|
+
)
|
|
1354
|
+
)
|
|
1355
|
+
else best
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def format_training_progress(
|
|
1360
|
+
*,
|
|
1361
|
+
elapsed_seconds: int,
|
|
1362
|
+
epoch: int,
|
|
1363
|
+
loss: float,
|
|
1364
|
+
n_finished_models: int,
|
|
1365
|
+
n_running_models: int,
|
|
1366
|
+
n_pending_models: int | None,
|
|
1367
|
+
show_best_model: bool,
|
|
1368
|
+
show_best_ensemble: bool,
|
|
1369
|
+
best_model_criterion_metric: float | None,
|
|
1370
|
+
best_ensemble_criterion_metric: float | None,
|
|
1371
|
+
) -> tuple[str, str]:
|
|
1372
|
+
separator = ' | '
|
|
1373
|
+
|
|
1374
|
+
# Assemble headers.
|
|
1375
|
+
# fmt: off
|
|
1376
|
+
group_headers = [' training ']
|
|
1377
|
+
column_headers = [' time | epoch | loss']
|
|
1378
|
+
if n_pending_models is None:
|
|
1379
|
+
group_headers.append (' models ')
|
|
1380
|
+
column_headers.append('finished | running')
|
|
1381
|
+
else:
|
|
1382
|
+
group_headers.append (' models ')
|
|
1383
|
+
column_headers.append('finished | running | pending')
|
|
1384
|
+
if show_best_model and show_best_ensemble:
|
|
1385
|
+
group_headers.append (' best ')
|
|
1386
|
+
column_headers.append(' model | ensemble')
|
|
1387
|
+
elif show_best_model:
|
|
1388
|
+
group_headers.append (' best ')
|
|
1389
|
+
column_headers.append(' model')
|
|
1390
|
+
elif show_best_ensemble:
|
|
1391
|
+
group_headers.append (' best ')
|
|
1392
|
+
column_headers.append('ensemble')
|
|
1393
|
+
# fmt: on
|
|
1394
|
+
group_header = separator.join(group_headers)
|
|
1395
|
+
column_header = separator.join(column_headers)
|
|
1396
|
+
|
|
1397
|
+
# Assemble a row.
|
|
1398
|
+
row = (
|
|
1399
|
+
f'{datetime.timedelta(seconds=elapsed_seconds)!s:0>8} | {epoch:>5} | {loss:>6.3f}' # noqa: E501
|
|
1400
|
+
f'{separator}{n_finished_models:>8} | {n_running_models:>7}'
|
|
1401
|
+
)
|
|
1402
|
+
if n_pending_models is not None:
|
|
1403
|
+
row += f' | {n_pending_models:>7}'
|
|
1404
|
+
if show_best_model:
|
|
1405
|
+
model_score = (
|
|
1406
|
+
'-'
|
|
1407
|
+
if best_model_criterion_metric is None
|
|
1408
|
+
else f'{best_model_criterion_metric:.4f}'
|
|
1409
|
+
)
|
|
1410
|
+
row += f'{separator}{model_score:>7}'
|
|
1411
|
+
if show_best_ensemble:
|
|
1412
|
+
ensemble_score = (
|
|
1413
|
+
'-'
|
|
1414
|
+
if best_ensemble_criterion_metric is None
|
|
1415
|
+
else f'{best_ensemble_criterion_metric:.4f}'
|
|
1416
|
+
)
|
|
1417
|
+
row += f'{" | " if show_best_model else separator}{ensemble_score:>8}'
|
|
1418
|
+
|
|
1419
|
+
return '\n'.join((group_header, column_header, '-' * len(column_header))), row
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _is_notebook() -> bool:
|
|
1423
|
+
shell = None if get_ipython is None else get_ipython()
|
|
1424
|
+
return shell is not None and shell.__class__.__name__ == 'ZMQInteractiveShell'
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
if tqdm is None:
|
|
1428
|
+
ProgressBar = None
|
|
1429
|
+
|
|
1430
|
+
else:
|
|
1431
|
+
|
|
1432
|
+
class ProgressBar(tqdm.tqdm):
|
|
1433
|
+
def __init__(
|
|
1434
|
+
self,
|
|
1435
|
+
batches: list[Tensor],
|
|
1436
|
+
*,
|
|
1437
|
+
training_loop_step: int,
|
|
1438
|
+
n_steps_per_epoch: int,
|
|
1439
|
+
pack_size: int,
|
|
1440
|
+
) -> None:
|
|
1441
|
+
self.pack_size = pack_size
|
|
1442
|
+
super().__init__(
|
|
1443
|
+
batches,
|
|
1444
|
+
file=sys.stdout if _is_notebook() else None,
|
|
1445
|
+
desc=f'epoch {training_loop_step // n_steps_per_epoch + 1}',
|
|
1446
|
+
bar_format=(
|
|
1447
|
+
'{l_bar}{bar}| {n_fmt}/{total_fmt} '
|
|
1448
|
+
'[{elapsed}<{remaining}, {rate_fmt}, '
|
|
1449
|
+
'{model_rate} model-it/s{postfix}]'
|
|
1450
|
+
),
|
|
1451
|
+
ncols=min(130, shutil.get_terminal_size().columns - 1),
|
|
1452
|
+
leave=False,
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
@property
|
|
1456
|
+
def format_dict(self) -> dict[str, Any]:
|
|
1457
|
+
fields = super().format_dict
|
|
1458
|
+
rate = fields['rate']
|
|
1459
|
+
if rate is None and fields['elapsed']:
|
|
1460
|
+
rate = (fields['n'] - fields['initial']) / fields['elapsed']
|
|
1461
|
+
|
|
1462
|
+
fields['model_rate'] = (
|
|
1463
|
+
'?' if rate is None else f'{rate * self.pack_size:.2f}'
|
|
1464
|
+
)
|
|
1465
|
+
return fields
|
|
1466
|
+
|
|
1467
|
+
# >>> `train`
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
@dataclasses.dataclass(frozen=True, kw_only=True)
|
|
1471
|
+
class _TabPackConfig:
|
|
1472
|
+
# Models
|
|
1473
|
+
n_models: int
|
|
1474
|
+
model: KWArgs # Must not contain per-model hyperparameters.
|
|
1475
|
+
prediction_transform: PredictionTransform | None
|
|
1476
|
+
|
|
1477
|
+
# Training
|
|
1478
|
+
loss_fn: LossFn
|
|
1479
|
+
optimizer: KWArgs # Must not contain per-model hyperparameters.
|
|
1480
|
+
batch_size: int
|
|
1481
|
+
max_epochs: int | None
|
|
1482
|
+
early_stopping_patience: int | None
|
|
1483
|
+
|
|
1484
|
+
# Per-model hyperparameters
|
|
1485
|
+
per_model_hyperparameters: list[HyperparameterDict] | None
|
|
1486
|
+
|
|
1487
|
+
# Evaluation
|
|
1488
|
+
eval_metrics: dict[DatasetName, dict[MetricName, MetricFn]]
|
|
1489
|
+
eval_criterion: EvalCriterionConfig | None
|
|
1490
|
+
eval_batch_size: int
|
|
1491
|
+
|
|
1492
|
+
# Ensembles
|
|
1493
|
+
ensembles: dict[EnsembleName, EnsembleConfig]
|
|
1494
|
+
ensemble_eval_metrics: dict[DatasetName, dict[MetricName, MetricFn]]
|
|
1495
|
+
ensemble_eval_criterion: EvalCriterionConfig | None
|
|
1496
|
+
stop_when_all_ensembles_are_finalized: bool
|
|
1497
|
+
|
|
1498
|
+
# Efficiency
|
|
1499
|
+
max_pack_size: int
|
|
1500
|
+
device: torch.device
|
|
1501
|
+
aux_device: torch.device
|
|
1502
|
+
autocast_dtype: torch.dtype | None
|
|
1503
|
+
compile_model: bool
|
|
1504
|
+
compile_model_numerical_embeddings: bool
|
|
1505
|
+
compile_model_first_block: bool
|
|
1506
|
+
compile_optimizer_muon_newton_schulz: bool
|
|
1507
|
+
|
|
1508
|
+
# Printing
|
|
1509
|
+
print_progress: bool
|
|
1510
|
+
show_progress_bar: bool
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _check_autocast_dtype(dtype: torch.dtype) -> None:
|
|
1514
|
+
# FP16 is not supported as the AMP data type, because it requires gradient
|
|
1515
|
+
# scaling, which is not implemented in this codebase.
|
|
1516
|
+
if dtype != torch.bfloat16:
|
|
1517
|
+
raise ValueError('The autocast data type must be `torch.bfloat16`')
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _check_dataset(dataset: dict[str, Tensor]) -> None:
|
|
1521
|
+
if not dataset:
|
|
1522
|
+
raise ValueError('The dataset must not be empty')
|
|
1523
|
+
|
|
1524
|
+
if not {'y'} < dataset.keys() <= {'y', 'x_num', 'x_cat'}:
|
|
1525
|
+
raise ValueError(
|
|
1526
|
+
'The dataset must contain labels under `y` and features under `x_num`,'
|
|
1527
|
+
' `x_cat`, or both, with no other keys'
|
|
1528
|
+
)
|
|
1529
|
+
|
|
1530
|
+
y = dataset['y']
|
|
1531
|
+
if y is None:
|
|
1532
|
+
raise TypeError('Dataset labels must not be `None`')
|
|
1533
|
+
|
|
1534
|
+
if y.ndim <= 0:
|
|
1535
|
+
raise ValueError('Dataset labels must have a sample dimension')
|
|
1536
|
+
|
|
1537
|
+
if not torch.all(torch.isfinite(y)):
|
|
1538
|
+
raise ValueError('Dataset labels must be finite')
|
|
1539
|
+
|
|
1540
|
+
dataset_size = len(y)
|
|
1541
|
+
if dataset_size == 0:
|
|
1542
|
+
raise ValueError('The dataset must contain at least one sample')
|
|
1543
|
+
|
|
1544
|
+
if not all(x.ndim > 0 and len(x) == dataset_size for x in dataset.values()):
|
|
1545
|
+
raise ValueError('Each dataset tensor must contain one entry per sample')
|
|
1546
|
+
|
|
1547
|
+
device = next(iter(dataset.values())).device
|
|
1548
|
+
if not all(x.device == device for x in dataset.values()):
|
|
1549
|
+
raise ValueError('All tensors in a dataset must be on the same device')
|
|
1550
|
+
|
|
1551
|
+
x_num = dataset.get('x_num')
|
|
1552
|
+
if x_num is not None:
|
|
1553
|
+
if x_num.ndim != 2:
|
|
1554
|
+
raise ValueError('Numerical features must be two-dimensional')
|
|
1555
|
+
|
|
1556
|
+
if x_num.shape[1] <= 0:
|
|
1557
|
+
raise ValueError('Numerical features must have at least one column')
|
|
1558
|
+
|
|
1559
|
+
if x_num.dtype != torch.float32:
|
|
1560
|
+
raise TypeError(
|
|
1561
|
+
'Numerical features must have the data type `torch.float32`'
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
if not torch.all(torch.isfinite(x_num)):
|
|
1565
|
+
raise ValueError('Numerical features must be finite')
|
|
1566
|
+
|
|
1567
|
+
x_cat = dataset.get('x_cat')
|
|
1568
|
+
if x_cat is not None:
|
|
1569
|
+
if x_cat.ndim != 2:
|
|
1570
|
+
raise ValueError('Categorical features must be two-dimensional')
|
|
1571
|
+
|
|
1572
|
+
if x_cat.shape[1] <= 0:
|
|
1573
|
+
raise ValueError('Categorical features must have at least one column')
|
|
1574
|
+
|
|
1575
|
+
if x_cat.dtype != torch.int64:
|
|
1576
|
+
raise TypeError(
|
|
1577
|
+
'Categorical features must have the data type `torch.int64`'
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
if not torch.all(x_cat >= 0):
|
|
1581
|
+
raise ValueError('Categorical feature values must be non-negative')
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _check_train_args(
|
|
1585
|
+
config: _TabPackConfig, datasets: dict[DatasetName, dict[str, Tensor]]
|
|
1586
|
+
) -> None:
|
|
1587
|
+
if not datasets:
|
|
1588
|
+
raise ValueError('Provide at least one dataset')
|
|
1589
|
+
|
|
1590
|
+
if 'train' not in datasets:
|
|
1591
|
+
raise ValueError('Provide a training dataset as `datasets["train"]`')
|
|
1592
|
+
|
|
1593
|
+
train_dataset = datasets['train']
|
|
1594
|
+
for dataset in datasets.values():
|
|
1595
|
+
_check_dataset(dataset)
|
|
1596
|
+
if dataset is not train_dataset:
|
|
1597
|
+
if dataset.keys() != train_dataset.keys():
|
|
1598
|
+
raise ValueError(
|
|
1599
|
+
'All datasets must have the same keys as the training dataset'
|
|
1600
|
+
)
|
|
1601
|
+
for key, value in dataset.items():
|
|
1602
|
+
train_value = train_dataset[key]
|
|
1603
|
+
if value.shape[1:] != train_value.shape[1:]:
|
|
1604
|
+
raise ValueError(
|
|
1605
|
+
'Per-sample tensor shapes must match those in the training'
|
|
1606
|
+
' dataset'
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
if value.dtype != train_value.dtype:
|
|
1610
|
+
raise TypeError(
|
|
1611
|
+
'Each dataset tensor must have the same data type as the'
|
|
1612
|
+
' corresponding training tensor'
|
|
1613
|
+
)
|
|
1614
|
+
del key, value, train_value
|
|
1615
|
+
del dataset
|
|
1616
|
+
|
|
1617
|
+
if config.n_models <= 0:
|
|
1618
|
+
raise ValueError('The number of models must be positive')
|
|
1619
|
+
|
|
1620
|
+
n_numerical_features = config.model.get('n_numerical_features')
|
|
1621
|
+
if n_numerical_features is not None:
|
|
1622
|
+
if n_numerical_features > 0:
|
|
1623
|
+
if 'x_num' not in train_dataset:
|
|
1624
|
+
raise ValueError(
|
|
1625
|
+
'The model is configured to use numerical features, but the'
|
|
1626
|
+
' training dataset is missing `x_num`'
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
if train_dataset['x_num'].shape[1] != n_numerical_features:
|
|
1630
|
+
raise ValueError(
|
|
1631
|
+
'The number of numerical features in the training dataset must'
|
|
1632
|
+
' match `n_numerical_features`'
|
|
1633
|
+
)
|
|
1634
|
+
else:
|
|
1635
|
+
if 'x_num' in train_dataset:
|
|
1636
|
+
raise ValueError(
|
|
1637
|
+
'The model is configured to use no numerical features, so the'
|
|
1638
|
+
' dataset must not contain `x_num`'
|
|
1639
|
+
)
|
|
1640
|
+
del n_numerical_features
|
|
1641
|
+
|
|
1642
|
+
# Cardinalities of categorical features must be provided and are not inferred,
|
|
1643
|
+
# because it is tricky to do that correctly in the general case.
|
|
1644
|
+
if 'categorical_cardinalities' not in config.model:
|
|
1645
|
+
raise ValueError(
|
|
1646
|
+
'Provide `categorical_cardinalities` in the model configuration'
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
categorical_cardinalities = config.model['categorical_cardinalities']
|
|
1650
|
+
if categorical_cardinalities:
|
|
1651
|
+
if 'x_cat' not in train_dataset:
|
|
1652
|
+
raise ValueError(
|
|
1653
|
+
'The model is configured to use categorical features, but the training'
|
|
1654
|
+
' dataset is missing `x_cat`'
|
|
1655
|
+
)
|
|
1656
|
+
for dataset in datasets.values():
|
|
1657
|
+
if dataset['x_cat'].shape[1] != len(categorical_cardinalities):
|
|
1658
|
+
raise ValueError(
|
|
1659
|
+
'The number of categorical features in the dataset must match the'
|
|
1660
|
+
' length of `categorical_cardinalities`'
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
if not torch.all(
|
|
1664
|
+
dataset['x_cat']
|
|
1665
|
+
<= torch.tensor(
|
|
1666
|
+
categorical_cardinalities,
|
|
1667
|
+
dtype=dataset['x_cat'].dtype,
|
|
1668
|
+
device=dataset['x_cat'].device,
|
|
1669
|
+
)[None]
|
|
1670
|
+
):
|
|
1671
|
+
raise ValueError(
|
|
1672
|
+
'Values in each categorical feature must not exceed the'
|
|
1673
|
+
' corresponding entry in `categorical_cardinalities`'
|
|
1674
|
+
)
|
|
1675
|
+
else:
|
|
1676
|
+
if 'x_cat' in train_dataset:
|
|
1677
|
+
raise ValueError(
|
|
1678
|
+
'The model is configured to use no categorical features, so the dataset'
|
|
1679
|
+
' must not contain `x_cat`'
|
|
1680
|
+
)
|
|
1681
|
+
del train_dataset, categorical_cardinalities
|
|
1682
|
+
|
|
1683
|
+
if 'd_out' not in config.model:
|
|
1684
|
+
raise ValueError('Provide `d_out` in the model configuration')
|
|
1685
|
+
|
|
1686
|
+
if config.batch_size <= 0:
|
|
1687
|
+
raise ValueError('The training batch size must be positive')
|
|
1688
|
+
|
|
1689
|
+
if config.max_epochs is not None and config.max_epochs <= 0:
|
|
1690
|
+
raise ValueError('The maximum number of epochs must be positive')
|
|
1691
|
+
|
|
1692
|
+
if config.early_stopping_patience is not None:
|
|
1693
|
+
if config.early_stopping_patience < 0:
|
|
1694
|
+
raise ValueError('The early stopping patience must be non-negative')
|
|
1695
|
+
|
|
1696
|
+
if config.eval_criterion is None:
|
|
1697
|
+
raise ValueError(
|
|
1698
|
+
'To use early stopping for models, provide `eval_criterion`'
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
for eval_metrics, eval_criterion in (
|
|
1702
|
+
(config.eval_metrics, config.eval_criterion),
|
|
1703
|
+
(config.ensemble_eval_metrics, config.ensemble_eval_criterion),
|
|
1704
|
+
):
|
|
1705
|
+
if not eval_metrics.keys() <= datasets.keys():
|
|
1706
|
+
raise ValueError(
|
|
1707
|
+
'Evaluation metrics are configured for datasets that were not provided'
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
if eval_criterion is not None:
|
|
1711
|
+
if eval_criterion.dataset not in eval_metrics:
|
|
1712
|
+
raise ValueError(
|
|
1713
|
+
'No evaluation metrics are configured for the dataset selected by'
|
|
1714
|
+
' the evaluation criterion'
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
if eval_criterion.metric not in eval_metrics[eval_criterion.dataset]:
|
|
1718
|
+
raise ValueError(
|
|
1719
|
+
'The metric selected by the evaluation criterion is not configured'
|
|
1720
|
+
' for its dataset'
|
|
1721
|
+
)
|
|
1722
|
+
del eval_metrics, eval_criterion
|
|
1723
|
+
|
|
1724
|
+
if config.eval_batch_size <= 0:
|
|
1725
|
+
raise ValueError('The evaluation batch size must be positive')
|
|
1726
|
+
|
|
1727
|
+
if config.ensembles:
|
|
1728
|
+
# The ensemble names must be valid for their use in `ModuleDict`.
|
|
1729
|
+
moduledict = nn.ModuleDict()
|
|
1730
|
+
for ensemble_name in config.ensembles:
|
|
1731
|
+
moduledict[ensemble_name] = nn.Identity()
|
|
1732
|
+
del ensemble_name
|
|
1733
|
+
del moduledict
|
|
1734
|
+
for ensemble_config in config.ensembles.values():
|
|
1735
|
+
if ensemble_config.algorithm_dataset not in datasets:
|
|
1736
|
+
raise ValueError(
|
|
1737
|
+
'The dataset selected for the ensemble algorithm was not provided'
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
if (
|
|
1741
|
+
ensemble_config.early_stopping_patience is not None
|
|
1742
|
+
and config.ensemble_eval_criterion is None
|
|
1743
|
+
):
|
|
1744
|
+
raise ValueError(
|
|
1745
|
+
'To use early stopping for ensembles, provide'
|
|
1746
|
+
' `ensemble_eval_criterion`'
|
|
1747
|
+
)
|
|
1748
|
+
del ensemble_config
|
|
1749
|
+
|
|
1750
|
+
if (
|
|
1751
|
+
config.max_epochs is not None
|
|
1752
|
+
and config.per_model_hyperparameters is not None
|
|
1753
|
+
and 'max_epochs' in config.per_model_hyperparameters[0]
|
|
1754
|
+
):
|
|
1755
|
+
raise ValueError('Set `max_epochs` either globally or per model, not both')
|
|
1756
|
+
|
|
1757
|
+
if not (
|
|
1758
|
+
config.max_epochs is not None
|
|
1759
|
+
or (
|
|
1760
|
+
config.per_model_hyperparameters is not None
|
|
1761
|
+
and all(
|
|
1762
|
+
x.get('max_epochs') is not None
|
|
1763
|
+
for x in config.per_model_hyperparameters
|
|
1764
|
+
)
|
|
1765
|
+
)
|
|
1766
|
+
or config.early_stopping_patience is not None
|
|
1767
|
+
or (
|
|
1768
|
+
config.ensembles
|
|
1769
|
+
and config.stop_when_all_ensembles_are_finalized
|
|
1770
|
+
and all(
|
|
1771
|
+
x.online and x.early_stopping_patience is not None
|
|
1772
|
+
for x in config.ensembles.values()
|
|
1773
|
+
)
|
|
1774
|
+
)
|
|
1775
|
+
):
|
|
1776
|
+
raise ValueError(
|
|
1777
|
+
'Training has no stopping condition. Set an epoch limit globally or for'
|
|
1778
|
+
' every model, enable early stopping for models, or configure all ensembles'
|
|
1779
|
+
' with `online=True` and early stopping and set'
|
|
1780
|
+
' `stop_when_all_ensembles_are_finalized=True`'
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
if config.per_model_hyperparameters is not None:
|
|
1784
|
+
if len(config.per_model_hyperparameters) != config.n_models:
|
|
1785
|
+
raise ValueError('Provide one hyperparameter dictionary per model')
|
|
1786
|
+
|
|
1787
|
+
if not config.per_model_hyperparameters[0].keys() <= {
|
|
1788
|
+
'model',
|
|
1789
|
+
'optimizer',
|
|
1790
|
+
'max_epochs',
|
|
1791
|
+
}:
|
|
1792
|
+
raise ValueError(
|
|
1793
|
+
'Each dictionary in `per_model_hyperparameters` may contain only the'
|
|
1794
|
+
' keys `model`, `optimizer`, and `max_epochs`'
|
|
1795
|
+
)
|
|
1796
|
+
# The following call ensures that all per-model hyperparameters have the
|
|
1797
|
+
# same structure. This is critical to do, because if an earlier model
|
|
1798
|
+
# specified some hyperparameter that a later model omitted, then the later
|
|
1799
|
+
# model would inherit the earlier model's hyperparameter value.
|
|
1800
|
+
transpose_dicts_recursively(config.per_model_hyperparameters)
|
|
1801
|
+
|
|
1802
|
+
if config.max_pack_size <= 0 or config.max_pack_size > config.n_models:
|
|
1803
|
+
raise ValueError(
|
|
1804
|
+
'The maximum pack size must be between 1 and the number of models'
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
if config.autocast_dtype is not None:
|
|
1808
|
+
_check_autocast_dtype(config.autocast_dtype)
|
|
1809
|
+
|
|
1810
|
+
if config.compile_model and (
|
|
1811
|
+
config.compile_model_numerical_embeddings or config.compile_model_first_block
|
|
1812
|
+
):
|
|
1813
|
+
raise ValueError(
|
|
1814
|
+
'Incorrect combination of `compile_*` arguments. Choose to compile either'
|
|
1815
|
+
' the full model or some parts of it, but not both.'
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
if config.compile_model and config.device.type != 'cuda':
|
|
1819
|
+
# As of PyTorch 2.14.1, the full compilation results in incorrect computations
|
|
1820
|
+
# on MPS. The relevant fix: https://github.com/pytorch/pytorch/pull/201460
|
|
1821
|
+
# Once the fix becomes available (supposedly in PyTorch 2.16), reevaluate
|
|
1822
|
+
# the full compilation on MPS.
|
|
1823
|
+
raise ValueError('The full model compilation is available only on CUDA')
|
|
1824
|
+
|
|
1825
|
+
# The version lower bound is motivated only by MPS limitations, but is applied
|
|
1826
|
+
# to all backends just in case; this may be relaxed in future.
|
|
1827
|
+
if (
|
|
1828
|
+
config.compile_model
|
|
1829
|
+
or config.compile_model_numerical_embeddings
|
|
1830
|
+
or config.compile_model_first_block
|
|
1831
|
+
or config.compile_optimizer_muon_newton_schulz
|
|
1832
|
+
) and torch.torch_version.TorchVersion(torch.__version__) < '2.12.0':
|
|
1833
|
+
raise RuntimeError('Compilation requires PyTorch 2.12.0 or newer')
|
|
1834
|
+
|
|
1835
|
+
if (
|
|
1836
|
+
config.compile_model_numerical_embeddings
|
|
1837
|
+
and 'numerical_embeddings' not in config.model
|
|
1838
|
+
):
|
|
1839
|
+
raise ValueError(
|
|
1840
|
+
'Cannot compile numerical embeddings because none are configured'
|
|
1841
|
+
)
|
|
1842
|
+
|
|
1843
|
+
if config.compile_optimizer_muon_newton_schulz and not issubclass(
|
|
1844
|
+
config.optimizer['type'], tabpackage.optim.MuonAdamWPack
|
|
1845
|
+
):
|
|
1846
|
+
raise ValueError(
|
|
1847
|
+
'The optimizer does not support compiling the Newton-Schulz iteration for'
|
|
1848
|
+
' Muon'
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
if config.show_progress_bar and tqdm is None:
|
|
1852
|
+
raise ImportError('`show_progress_bar=True` requires `tqdm` to be installed')
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def _infer_model_shape_kwargs(
|
|
1856
|
+
config: _TabPackConfig, datasets: dict[DatasetName, dict[str, Tensor]]
|
|
1857
|
+
) -> KWArgs:
|
|
1858
|
+
inferred_model_kwargs = {}
|
|
1859
|
+
|
|
1860
|
+
if 'n_numerical_features' not in config.model:
|
|
1861
|
+
inferred_model_kwargs['n_numerical_features'] = (
|
|
1862
|
+
datasets['train']['x_num'].shape[-1] if 'x_num' in datasets['train'] else 0
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
if (
|
|
1866
|
+
'categorical_cardinalities' not in config.model
|
|
1867
|
+
and 'x_cat' not in datasets['train']
|
|
1868
|
+
):
|
|
1869
|
+
inferred_model_kwargs['categorical_cardinalities'] = []
|
|
1870
|
+
|
|
1871
|
+
if (
|
|
1872
|
+
config.per_model_hyperparameters is not None
|
|
1873
|
+
and 'model' in config.per_model_hyperparameters[0]
|
|
1874
|
+
):
|
|
1875
|
+
if (
|
|
1876
|
+
'max_n_blocks' not in config.model
|
|
1877
|
+
and 'n_blocks' in config.per_model_hyperparameters[0]['model']
|
|
1878
|
+
):
|
|
1879
|
+
inferred_model_kwargs['max_n_blocks'] = max(
|
|
1880
|
+
x['model']['n_blocks'] for x in config.per_model_hyperparameters
|
|
1881
|
+
)
|
|
1882
|
+
if (
|
|
1883
|
+
'max_d_block' not in config.model
|
|
1884
|
+
and 'd_block' in config.per_model_hyperparameters[0]['model']
|
|
1885
|
+
):
|
|
1886
|
+
inferred_model_kwargs['max_d_block'] = max(
|
|
1887
|
+
x['model']['d_block'] for x in config.per_model_hyperparameters
|
|
1888
|
+
)
|
|
1889
|
+
if (
|
|
1890
|
+
config.model.get('numerical_embeddings') is not None
|
|
1891
|
+
and 'max_d_embedding' not in config.model['numerical_embeddings']
|
|
1892
|
+
and (
|
|
1893
|
+
'd_embedding'
|
|
1894
|
+
in config.per_model_hyperparameters[0]['model'].get(
|
|
1895
|
+
'numerical_embeddings', {}
|
|
1896
|
+
)
|
|
1897
|
+
)
|
|
1898
|
+
):
|
|
1899
|
+
inferred_model_kwargs['numerical_embeddings'] = {
|
|
1900
|
+
'max_d_embedding': max(
|
|
1901
|
+
x['model']['numerical_embeddings']['d_embedding']
|
|
1902
|
+
for x in config.per_model_hyperparameters
|
|
1903
|
+
)
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
return inferred_model_kwargs
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
class TabPackPredictor(nn.Module):
|
|
1910
|
+
"""An inference-only predictor for trained models and their ensembles.
|
|
1911
|
+
|
|
1912
|
+
Create it with `TrainedTabPack.make_predictor` to use the selected checkpoints
|
|
1913
|
+
and prediction transformations from a training run.
|
|
1914
|
+
"""
|
|
1915
|
+
|
|
1916
|
+
class EnsembleHead(nn.Module):
|
|
1917
|
+
"""Select and aggregate an ensemble's member predictions."""
|
|
1918
|
+
|
|
1919
|
+
member_pack_idx: Tensor
|
|
1920
|
+
member_weights: Tensor | None
|
|
1921
|
+
|
|
1922
|
+
def __init__(
|
|
1923
|
+
self,
|
|
1924
|
+
*,
|
|
1925
|
+
member_pack_idx: Tensor,
|
|
1926
|
+
member_weights: Tensor | None,
|
|
1927
|
+
prediction_transform: PredictionTransform | None,
|
|
1928
|
+
) -> None:
|
|
1929
|
+
check_ensemble_member_idx(member_pack_idx)
|
|
1930
|
+
if not torch.all(member_pack_idx >= 0):
|
|
1931
|
+
raise ValueError('Ensemble member indices must be non-negative')
|
|
1932
|
+
|
|
1933
|
+
if len(torch.unique(member_pack_idx)) != len(member_pack_idx):
|
|
1934
|
+
raise ValueError('Ensemble member indices must be unique')
|
|
1935
|
+
|
|
1936
|
+
if member_weights is not None:
|
|
1937
|
+
check_ensemble_member_weights(member_weights)
|
|
1938
|
+
|
|
1939
|
+
if not torch.all(torch.isfinite(member_weights)):
|
|
1940
|
+
raise ValueError('Ensemble member weights must be finite')
|
|
1941
|
+
|
|
1942
|
+
if not torch.all(member_weights >= 0):
|
|
1943
|
+
raise ValueError('Ensemble member weights must be non-negative')
|
|
1944
|
+
|
|
1945
|
+
if not torch.any(member_weights > 0):
|
|
1946
|
+
raise ValueError(
|
|
1947
|
+
'At least one ensemble member weight must be positive'
|
|
1948
|
+
)
|
|
1949
|
+
|
|
1950
|
+
super().__init__()
|
|
1951
|
+
self.register_buffer('member_pack_idx', member_pack_idx)
|
|
1952
|
+
self.register_buffer('member_weights', member_weights)
|
|
1953
|
+
self.prediction_transform = prediction_transform
|
|
1954
|
+
|
|
1955
|
+
def forward(self, x: Tensor) -> Tensor:
|
|
1956
|
+
return compute_ensemble_predictions(
|
|
1957
|
+
x[self.member_pack_idx], self.member_weights, self.prediction_transform
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
def __init__(
|
|
1961
|
+
self,
|
|
1962
|
+
*,
|
|
1963
|
+
model_pack: tabpackage.nn.TabPack,
|
|
1964
|
+
autocast_dtype: torch.dtype | None,
|
|
1965
|
+
prediction_transform: PredictionTransform | None,
|
|
1966
|
+
ensembles: dict[EnsembleName, EnsembleHead],
|
|
1967
|
+
) -> None:
|
|
1968
|
+
super().__init__()
|
|
1969
|
+
self.model_pack: Final = model_pack
|
|
1970
|
+
self.prediction_transform: Final = prediction_transform
|
|
1971
|
+
self.autocast_dtype: Final = autocast_dtype
|
|
1972
|
+
self.ensembles: Final = nn.ModuleDict(ensembles)
|
|
1973
|
+
|
|
1974
|
+
self.eval()
|
|
1975
|
+
self.requires_grad_(False)
|
|
1976
|
+
|
|
1977
|
+
def train(self, mode: bool = True) -> Self:
|
|
1978
|
+
if mode:
|
|
1979
|
+
raise RuntimeError('The module supports only evaluation mode')
|
|
1980
|
+
|
|
1981
|
+
return super().train(mode)
|
|
1982
|
+
|
|
1983
|
+
def forward(
|
|
1984
|
+
self, x_num: Tensor | None, x_cat: Tensor | None
|
|
1985
|
+
) -> tuple[Tensor, dict[EnsembleName, Tensor]]:
|
|
1986
|
+
"""Make individual and ensemble predictions for a batch of new examples.
|
|
1987
|
+
|
|
1988
|
+
Args:
|
|
1989
|
+
x_num: the numerical features.
|
|
1990
|
+
x_cat: the categorical features.
|
|
1991
|
+
|
|
1992
|
+
Returns:
|
|
1993
|
+
The individual model predictions and the ensemble predictions.
|
|
1994
|
+
"""
|
|
1995
|
+
if self.training:
|
|
1996
|
+
raise RuntimeError('The module must be in evaluation mode')
|
|
1997
|
+
|
|
1998
|
+
batch = {}
|
|
1999
|
+
if x_num is not None:
|
|
2000
|
+
batch['x_num'] = x_num
|
|
2001
|
+
if x_cat is not None:
|
|
2002
|
+
batch['x_cat'] = x_cat
|
|
2003
|
+
|
|
2004
|
+
y_pred_pack = eval_forward(
|
|
2005
|
+
self.model_pack,
|
|
2006
|
+
batch,
|
|
2007
|
+
autocast=(
|
|
2008
|
+
None
|
|
2009
|
+
if self.autocast_dtype is None
|
|
2010
|
+
else torch.autocast(
|
|
2011
|
+
next(self.model_pack.parameters()).device.type, self.autocast_dtype
|
|
2012
|
+
)
|
|
2013
|
+
),
|
|
2014
|
+
prediction_transform=self.prediction_transform,
|
|
2015
|
+
)
|
|
2016
|
+
y_pred_ensembles = {k: v(y_pred_pack) for k, v in self.ensembles.items()}
|
|
2017
|
+
|
|
2018
|
+
return y_pred_pack, y_pred_ensembles
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
class TrainedTabPack:
|
|
2022
|
+
"""Trained TabPack for making predictions and inspecting training results.
|
|
2023
|
+
|
|
2024
|
+
Use `make_predictor` to construct prediction modules. Use other methods to access
|
|
2025
|
+
training and evaluation artifacts and statistics of models and ensembles.
|
|
2026
|
+
|
|
2027
|
+
**Conventions**
|
|
2028
|
+
|
|
2029
|
+
- Model steps, state dictionaries, and metrics come from each model's best
|
|
2030
|
+
checkpoint according to the evaluation criterion, or its latest evaluated
|
|
2031
|
+
checkpoint if no criterion was provided.
|
|
2032
|
+
- Ensemble artifacts use the best ensemble according to its evaluation criterion,
|
|
2033
|
+
or its latest update if no criterion was provided. Its member checkpoints may
|
|
2034
|
+
differ from the models' individual best checkpoints.
|
|
2035
|
+
- Checkpoint state dictionaries and evaluation metrics are stored on `aux_device`
|
|
2036
|
+
from `train`.
|
|
2037
|
+
- Returned training artifacts are deeply copied, so they can be modified as needed.
|
|
2038
|
+
- Checkpoint selections preserve request order and repetitions; missing checkpoint
|
|
2039
|
+
keys are rejected.
|
|
2040
|
+
- Returned modules have the shape upper bounds of the original model pack and can
|
|
2041
|
+
exceed the shape of the actual largest pack member.
|
|
2042
|
+
"""
|
|
2043
|
+
|
|
2044
|
+
def __init__(
|
|
2045
|
+
self,
|
|
2046
|
+
*,
|
|
2047
|
+
config: _TabPackConfig,
|
|
2048
|
+
n_steps_per_epoch: int,
|
|
2049
|
+
training_loop_time: float,
|
|
2050
|
+
training_loop_step: int,
|
|
2051
|
+
prediction_transform: PredictionTransform | None,
|
|
2052
|
+
common_model_kwargs: dict[str, Any],
|
|
2053
|
+
per_model_hyperparameter_store: dict[ModelID, HyperparameterDict] | None,
|
|
2054
|
+
history: list[dict[str, Any]],
|
|
2055
|
+
checkpoints: ModelCheckpoints,
|
|
2056
|
+
finished_best_checkpoint_keys: ModelCheckpointKeys,
|
|
2057
|
+
unfinished_best_checkpoint_keys: ModelCheckpointKeys,
|
|
2058
|
+
ensemble_prediction_transforms: dict[EnsembleName, PredictionTransform | None],
|
|
2059
|
+
ensemble_history: dict[EnsembleName, list[dict[str, Any]]],
|
|
2060
|
+
) -> None:
|
|
2061
|
+
assert n_steps_per_epoch > 0
|
|
2062
|
+
assert training_loop_step > 0
|
|
2063
|
+
assert common_model_kwargs
|
|
2064
|
+
assert finished_best_checkpoint_keys or unfinished_best_checkpoint_keys
|
|
2065
|
+
|
|
2066
|
+
self._config: Final = config
|
|
2067
|
+
self._n_steps_per_epoch: Final = n_steps_per_epoch
|
|
2068
|
+
self._training_loop_time = training_loop_time
|
|
2069
|
+
self._training_loop_step: Final = training_loop_step
|
|
2070
|
+
self._prediction_transform: Final = prediction_transform
|
|
2071
|
+
self._common_model_kwargs: Final = common_model_kwargs
|
|
2072
|
+
self._per_model_hyperparameter_store: Final = per_model_hyperparameter_store
|
|
2073
|
+
self._checkpoints: Final = checkpoints
|
|
2074
|
+
self._finished_best_checkpoint_keys: Final = finished_best_checkpoint_keys
|
|
2075
|
+
self._unfinished_best_checkpoint_keys: Final = unfinished_best_checkpoint_keys
|
|
2076
|
+
self._history: Final = history
|
|
2077
|
+
self._ensemble_prediction_transforms: Final = ensemble_prediction_transforms
|
|
2078
|
+
self._ensemble_history: Final = ensemble_history
|
|
2079
|
+
|
|
2080
|
+
self._model_best_steps = {}
|
|
2081
|
+
for checkpoint_keys in (
|
|
2082
|
+
self._finished_best_checkpoint_keys,
|
|
2083
|
+
self._unfinished_best_checkpoint_keys,
|
|
2084
|
+
):
|
|
2085
|
+
assert len(set(checkpoint_keys.tolist())) == len(checkpoint_keys)
|
|
2086
|
+
self._model_best_steps.update(
|
|
2087
|
+
(x.id, x.step) for x in checkpoint_keys.tolist()
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
self._model_checkpoint_indices = {
|
|
2091
|
+
key: index for index, key in enumerate(self._checkpoints.keys.tolist())
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
# >>> Training loop
|
|
2095
|
+
|
|
2096
|
+
def get_n_steps_per_epoch(self) -> int:
|
|
2097
|
+
"""Get the number of training steps per epoch."""
|
|
2098
|
+
return self._n_steps_per_epoch
|
|
2099
|
+
|
|
2100
|
+
def get_training_loop_time(self) -> float:
|
|
2101
|
+
"""Get the time spent in the training loop, in seconds."""
|
|
2102
|
+
return self._training_loop_time
|
|
2103
|
+
|
|
2104
|
+
def get_training_loop_step(self) -> int:
|
|
2105
|
+
"""Get the number of training steps performed by the training loop."""
|
|
2106
|
+
return self._training_loop_step
|
|
2107
|
+
|
|
2108
|
+
# >>> Hyperparameters
|
|
2109
|
+
|
|
2110
|
+
def load_common_model_kwargs(self) -> KWArgs:
|
|
2111
|
+
"""Get common constructor arguments for the `tabpackage.nn.TabPack` module.
|
|
2112
|
+
|
|
2113
|
+
The returned arguments include the ones from the originally provided `model`
|
|
2114
|
+
dictionary and the ones inferred before the training, in particular shape
|
|
2115
|
+
bounds.
|
|
2116
|
+
"""
|
|
2117
|
+
return copy.deepcopy(self._common_model_kwargs)
|
|
2118
|
+
|
|
2119
|
+
def load_per_model_hyperparameters(
|
|
2120
|
+
self, model_ids: list[ModelID]
|
|
2121
|
+
) -> list[HyperparameterDict] | None:
|
|
2122
|
+
"""Get a list of per-model hyperparameter dictionaries."""
|
|
2123
|
+
return (
|
|
2124
|
+
None
|
|
2125
|
+
if self._per_model_hyperparameter_store is None
|
|
2126
|
+
else [
|
|
2127
|
+
copy.deepcopy(self._per_model_hyperparameter_store[x])
|
|
2128
|
+
for x in model_ids
|
|
2129
|
+
]
|
|
2130
|
+
)
|
|
2131
|
+
|
|
2132
|
+
# >>> Models
|
|
2133
|
+
|
|
2134
|
+
def _get_model_checkpoint_indices(
|
|
2135
|
+
self, *, model_ids: Tensor, model_steps: Tensor
|
|
2136
|
+
) -> Tensor:
|
|
2137
|
+
keys = ModelCheckpointKeys(ids=model_ids, steps=model_steps)
|
|
2138
|
+
return torch.tensor(
|
|
2139
|
+
[self._model_checkpoint_indices[key] for key in keys.tolist()],
|
|
2140
|
+
dtype=torch.int64,
|
|
2141
|
+
device=_IDS_DEVICE,
|
|
2142
|
+
)
|
|
2143
|
+
|
|
2144
|
+
def load_model_prediction_transform(
|
|
2145
|
+
self,
|
|
2146
|
+
) -> PredictionTransform | None:
|
|
2147
|
+
"""Load the transformation applied to the raw per-model outputs on inference."""
|
|
2148
|
+
return copy.deepcopy(self._prediction_transform)
|
|
2149
|
+
|
|
2150
|
+
def load_finished_model_ids(self) -> Tensor:
|
|
2151
|
+
"""Load IDs of the finished models."""
|
|
2152
|
+
return self._finished_best_checkpoint_keys.ids.clone()
|
|
2153
|
+
|
|
2154
|
+
def load_unfinished_model_ids(self) -> Tensor:
|
|
2155
|
+
"""Load IDs of unfinished models with evaluated checkpoints.
|
|
2156
|
+
|
|
2157
|
+
The unfinished models are the ones that did not finish their training
|
|
2158
|
+
(because the whole TabPack's training loop stopped for some reason) and
|
|
2159
|
+
were evaluated at least once.
|
|
2160
|
+
"""
|
|
2161
|
+
return self._unfinished_best_checkpoint_keys.ids.clone()
|
|
2162
|
+
|
|
2163
|
+
def load_model_steps(self, model_ids: Tensor) -> Tensor:
|
|
2164
|
+
"""Load the training steps of the models' checkpoints."""
|
|
2165
|
+
return torch.tensor(
|
|
2166
|
+
[self._model_best_steps[x] for x in model_ids.tolist()],
|
|
2167
|
+
dtype=torch.int64,
|
|
2168
|
+
device=_IDS_DEVICE,
|
|
2169
|
+
)
|
|
2170
|
+
|
|
2171
|
+
def _load_model_state_dicts(
|
|
2172
|
+
self, *, model_ids: Tensor, model_steps: Tensor
|
|
2173
|
+
) -> dict[str, Tensor]:
|
|
2174
|
+
return tabpackage.utils.torch.index(
|
|
2175
|
+
self._checkpoints.state_dicts,
|
|
2176
|
+
self._get_model_checkpoint_indices(
|
|
2177
|
+
model_ids=model_ids, model_steps=model_steps
|
|
2178
|
+
),
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
def load_model_state_dicts(self, model_ids: Tensor) -> dict[str, Tensor]:
|
|
2182
|
+
"""Load state dictionaries of models."""
|
|
2183
|
+
return self._load_model_state_dicts(
|
|
2184
|
+
model_ids=model_ids, model_steps=self.load_model_steps(model_ids)
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
def _load_model_metrics(
|
|
2188
|
+
self, *, model_ids: Tensor, model_steps: Tensor
|
|
2189
|
+
) -> dict[DatasetName, dict[MetricName, Tensor]]:
|
|
2190
|
+
return tabpackage.utils.torch.index(
|
|
2191
|
+
self._checkpoints.metrics,
|
|
2192
|
+
self._get_model_checkpoint_indices(
|
|
2193
|
+
model_ids=model_ids, model_steps=model_steps
|
|
2194
|
+
),
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
def load_model_metrics(
|
|
2198
|
+
self, model_ids: Tensor
|
|
2199
|
+
) -> dict[DatasetName, dict[MetricName, Tensor]]:
|
|
2200
|
+
"""Load metrics of models."""
|
|
2201
|
+
return self._load_model_metrics(
|
|
2202
|
+
model_ids=model_ids, model_steps=self.load_model_steps(model_ids)
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
def load_model_history(self) -> list[dict[str, Any]]:
|
|
2206
|
+
"""Load the models' evaluation results recorded after each training epoch."""
|
|
2207
|
+
return copy.deepcopy(self._history)
|
|
2208
|
+
|
|
2209
|
+
# >>> Ensembles
|
|
2210
|
+
|
|
2211
|
+
def get_ensemble_names(self) -> list[EnsembleName]:
|
|
2212
|
+
"""Get the names of the built ensembles."""
|
|
2213
|
+
return [k for k, v in self._ensemble_history.items() if v]
|
|
2214
|
+
|
|
2215
|
+
def load_ensemble_prediction_transform(
|
|
2216
|
+
self, ensemble_name: EnsembleName
|
|
2217
|
+
) -> PredictionTransform | None:
|
|
2218
|
+
"""Load the transformation applied to the aggregated prediction of ensemble members on inference.""" # noqa: E501
|
|
2219
|
+
return copy.deepcopy(self._ensemble_prediction_transforms[ensemble_name])
|
|
2220
|
+
|
|
2221
|
+
def load_ensemble_member_ids(self, ensemble_name: EnsembleName) -> Tensor:
|
|
2222
|
+
"""Load model IDs of ensemble members."""
|
|
2223
|
+
return self._ensemble_history[ensemble_name][-1]['members']['ids'].clone()
|
|
2224
|
+
|
|
2225
|
+
def load_ensemble_member_steps(self, ensemble_name: EnsembleName) -> Tensor:
|
|
2226
|
+
"""Load checkpoint steps of ensemble members."""
|
|
2227
|
+
return self._ensemble_history[ensemble_name][-1]['members']['steps'].clone()
|
|
2228
|
+
|
|
2229
|
+
def load_ensemble_member_weights(
|
|
2230
|
+
self, ensemble_name: EnsembleName
|
|
2231
|
+
) -> Tensor | None:
|
|
2232
|
+
"""Load weights (importances) of ensemble members."""
|
|
2233
|
+
weights = self._ensemble_history[ensemble_name][-1]['members'].get('weights')
|
|
2234
|
+
return None if weights is None else weights.clone()
|
|
2235
|
+
|
|
2236
|
+
def load_ensemble_member_metrics(
|
|
2237
|
+
self, ensemble_name: EnsembleName
|
|
2238
|
+
) -> dict[DatasetName, dict[MetricName, Tensor]]:
|
|
2239
|
+
"""Load metrics of ensemble members."""
|
|
2240
|
+
return self._load_model_metrics(
|
|
2241
|
+
model_ids=self.load_ensemble_member_ids(ensemble_name),
|
|
2242
|
+
model_steps=self.load_ensemble_member_steps(ensemble_name),
|
|
2243
|
+
)
|
|
2244
|
+
|
|
2245
|
+
def load_ensemble_metrics(
|
|
2246
|
+
self, ensemble_name: EnsembleName
|
|
2247
|
+
) -> dict[DatasetName, dict[MetricName, Tensor]]:
|
|
2248
|
+
"""Load metrics of an ensemble."""
|
|
2249
|
+
return copy.deepcopy(self._ensemble_history[ensemble_name][-1]['metrics'])
|
|
2250
|
+
|
|
2251
|
+
def load_ensemble_history(
|
|
2252
|
+
self, ensemble_name: EnsembleName
|
|
2253
|
+
) -> list[dict[str, Any]]:
|
|
2254
|
+
"""Load ensemble's members and evaluation results at each accepted update."""
|
|
2255
|
+
return copy.deepcopy(self._ensemble_history[ensemble_name])
|
|
2256
|
+
|
|
2257
|
+
# >>> Module factories
|
|
2258
|
+
|
|
2259
|
+
def _make_model_pack(
|
|
2260
|
+
self, *, model_ids: Tensor, model_steps: Tensor
|
|
2261
|
+
) -> tabpackage.nn.TabPack:
|
|
2262
|
+
"""Build a model pack from model checkpoints."""
|
|
2263
|
+
assert len(model_ids) > 0
|
|
2264
|
+
|
|
2265
|
+
model_kwargs = self.load_common_model_kwargs()
|
|
2266
|
+
per_model_hyperparameters = self.load_per_model_hyperparameters(
|
|
2267
|
+
model_ids.tolist()
|
|
2268
|
+
)
|
|
2269
|
+
if per_model_hyperparameters is not None:
|
|
2270
|
+
merge_dicts_recursively(
|
|
2271
|
+
model_kwargs,
|
|
2272
|
+
transpose_dicts_recursively(per_model_hyperparameters).get('model', {}),
|
|
2273
|
+
)
|
|
2274
|
+
|
|
2275
|
+
state_dict = self._load_model_state_dicts(
|
|
2276
|
+
model_ids=model_ids, model_steps=model_steps
|
|
2277
|
+
)
|
|
2278
|
+
some_state_tensor = next(
|
|
2279
|
+
x for x in state_dict.values() if x.is_floating_point()
|
|
2280
|
+
)
|
|
2281
|
+
|
|
2282
|
+
# Prevent parameter initialization from changing the global RNG state.
|
|
2283
|
+
with torch.random.fork_rng(devices=[]):
|
|
2284
|
+
model_pack = tabpackage.nn.TabPack(pack_size=len(model_ids), **model_kwargs)
|
|
2285
|
+
model_pack.to(some_state_tensor)
|
|
2286
|
+
model_pack.load_state_dict(state_dict)
|
|
2287
|
+
|
|
2288
|
+
return model_pack
|
|
2289
|
+
|
|
2290
|
+
def make_predictor(
|
|
2291
|
+
self,
|
|
2292
|
+
*,
|
|
2293
|
+
ensemble_names: list[EnsembleName],
|
|
2294
|
+
autocast_dtype: torch.dtype | None = None,
|
|
2295
|
+
) -> TabPackPredictor:
|
|
2296
|
+
"""Build a predictor for making predictions with selected ensembles.
|
|
2297
|
+
|
|
2298
|
+
The predictor includes only the checkpoints needed by the requested ensembles.
|
|
2299
|
+
Checkpoints shared by multiple ensembles are loaded and only once, and their
|
|
2300
|
+
predictions are also reused between the ensembles.
|
|
2301
|
+
|
|
2302
|
+
Args:
|
|
2303
|
+
ensemble_names: the ensembles to include. Choose at least one name from
|
|
2304
|
+
`get_ensemble_names()`.
|
|
2305
|
+
autocast_dtype: the data type used for automatic mixed precision during
|
|
2306
|
+
inference. Only BF16 is supported. `None` disables autocast, regardless
|
|
2307
|
+
of the setting used for training.
|
|
2308
|
+
"""
|
|
2309
|
+
if not ensemble_names:
|
|
2310
|
+
raise ValueError('Select at least one ensemble')
|
|
2311
|
+
|
|
2312
|
+
if not set(ensemble_names) <= set(self.get_ensemble_names()):
|
|
2313
|
+
raise ValueError(
|
|
2314
|
+
'The requested ensembles are not all available in the training'
|
|
2315
|
+
' artifacts'
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
if autocast_dtype is not None:
|
|
2319
|
+
_check_autocast_dtype(autocast_dtype)
|
|
2320
|
+
|
|
2321
|
+
model_ids = []
|
|
2322
|
+
model_steps = []
|
|
2323
|
+
checkpoint_keys_to_pack_indices = {}
|
|
2324
|
+
ensembles = {}
|
|
2325
|
+
|
|
2326
|
+
for ensemble_name in ensemble_names:
|
|
2327
|
+
ensemble_record = self._ensemble_history[ensemble_name][-1]
|
|
2328
|
+
|
|
2329
|
+
ensemble_member_pack_indices = []
|
|
2330
|
+
member_keys = ModelCheckpointKeys(
|
|
2331
|
+
ids=ensemble_record['members']['ids'],
|
|
2332
|
+
steps=ensemble_record['members']['steps'],
|
|
2333
|
+
)
|
|
2334
|
+
for key in member_keys.tolist():
|
|
2335
|
+
if key in checkpoint_keys_to_pack_indices:
|
|
2336
|
+
index = checkpoint_keys_to_pack_indices[key]
|
|
2337
|
+
else:
|
|
2338
|
+
index = len(checkpoint_keys_to_pack_indices)
|
|
2339
|
+
model_ids.append(key.id)
|
|
2340
|
+
model_steps.append(key.step)
|
|
2341
|
+
checkpoint_keys_to_pack_indices[key] = index
|
|
2342
|
+
ensemble_member_pack_indices.append(index)
|
|
2343
|
+
|
|
2344
|
+
ensembles[ensemble_name] = TabPackPredictor.EnsembleHead(
|
|
2345
|
+
member_pack_idx=torch.tensor(ensemble_member_pack_indices),
|
|
2346
|
+
member_weights=self.load_ensemble_member_weights(ensemble_name),
|
|
2347
|
+
prediction_transform=self.load_ensemble_prediction_transform(
|
|
2348
|
+
ensemble_name
|
|
2349
|
+
),
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
assert model_ids
|
|
2353
|
+
|
|
2354
|
+
model_pack = self._make_model_pack(
|
|
2355
|
+
model_ids=torch.tensor(model_ids, device=_IDS_DEVICE),
|
|
2356
|
+
model_steps=torch.tensor(model_steps, device=_IDS_DEVICE),
|
|
2357
|
+
)
|
|
2358
|
+
device = next(model_pack.parameters()).device
|
|
2359
|
+
return TabPackPredictor(
|
|
2360
|
+
model_pack=model_pack,
|
|
2361
|
+
autocast_dtype=autocast_dtype,
|
|
2362
|
+
prediction_transform=self.load_model_prediction_transform(),
|
|
2363
|
+
ensembles=ensembles,
|
|
2364
|
+
).to(device)
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
class RetrainedTabPack:
|
|
2368
|
+
"""Retrained TabPack with exactly one ensemble."""
|
|
2369
|
+
|
|
2370
|
+
def __init__(
|
|
2371
|
+
self,
|
|
2372
|
+
*,
|
|
2373
|
+
tabpack: TrainedTabPack,
|
|
2374
|
+
ensemble_name: EnsembleName,
|
|
2375
|
+
member_weights: Tensor | None,
|
|
2376
|
+
prediction_transform: PredictionTransform | None,
|
|
2377
|
+
) -> None:
|
|
2378
|
+
self._tabpack = tabpack
|
|
2379
|
+
self._ensemble_name = ensemble_name
|
|
2380
|
+
self._member_weights = member_weights
|
|
2381
|
+
self._prediction_transform = prediction_transform
|
|
2382
|
+
|
|
2383
|
+
def make_predictor(
|
|
2384
|
+
self, *, autocast_dtype: torch.dtype | None = None
|
|
2385
|
+
) -> TabPackPredictor:
|
|
2386
|
+
"""Build a predictor for the retrained ensemble.
|
|
2387
|
+
|
|
2388
|
+
Args:
|
|
2389
|
+
autocast_dtype: the data type used for automatic mixed precision during
|
|
2390
|
+
inference. Only BF16 is supported.
|
|
2391
|
+
"""
|
|
2392
|
+
if autocast_dtype is not None:
|
|
2393
|
+
_check_autocast_dtype(autocast_dtype)
|
|
2394
|
+
|
|
2395
|
+
# Model IDs follow submission order, while checkpoints follow completion order.
|
|
2396
|
+
model_ids = self._tabpack.load_finished_model_ids().sort().values
|
|
2397
|
+
model_pack = self._tabpack._make_model_pack(
|
|
2398
|
+
model_ids=model_ids,
|
|
2399
|
+
model_steps=self._tabpack.load_model_steps(model_ids),
|
|
2400
|
+
)
|
|
2401
|
+
device = next(model_pack.parameters()).device
|
|
2402
|
+
return TabPackPredictor(
|
|
2403
|
+
model_pack=model_pack,
|
|
2404
|
+
autocast_dtype=autocast_dtype,
|
|
2405
|
+
prediction_transform=self._tabpack.load_model_prediction_transform(),
|
|
2406
|
+
ensembles={
|
|
2407
|
+
self._ensemble_name: TabPackPredictor.EnsembleHead(
|
|
2408
|
+
member_pack_idx=torch.arange(len(model_ids)),
|
|
2409
|
+
member_weights=(
|
|
2410
|
+
None
|
|
2411
|
+
if self._member_weights is None
|
|
2412
|
+
else self._member_weights.clone()
|
|
2413
|
+
),
|
|
2414
|
+
prediction_transform=copy.deepcopy(self._prediction_transform),
|
|
2415
|
+
)
|
|
2416
|
+
},
|
|
2417
|
+
).to(device)
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
def _train(
|
|
2421
|
+
config: _TabPackConfig, datasets: dict[DatasetName, dict[str, Tensor]]
|
|
2422
|
+
) -> TrainedTabPack:
|
|
2423
|
+
_check_train_args(config, datasets)
|
|
2424
|
+
|
|
2425
|
+
# >>> Efficiency
|
|
2426
|
+
|
|
2427
|
+
train_dataset_device = next(iter(datasets['train'].values())).device
|
|
2428
|
+
autocast = (
|
|
2429
|
+
None
|
|
2430
|
+
if config.autocast_dtype is None
|
|
2431
|
+
else torch.autocast(config.device.type, config.autocast_dtype)
|
|
2432
|
+
)
|
|
2433
|
+
compilation_context = (
|
|
2434
|
+
# Backward runs outside autocast; this also covers lazy compilation.
|
|
2435
|
+
torch._functorch.config.patch(backward_pass_autocast='off')
|
|
2436
|
+
if (
|
|
2437
|
+
config.compile_model
|
|
2438
|
+
or config.compile_model_numerical_embeddings
|
|
2439
|
+
or config.compile_model_first_block
|
|
2440
|
+
)
|
|
2441
|
+
else contextlib.nullcontext()
|
|
2442
|
+
)
|
|
2443
|
+
|
|
2444
|
+
labels = {k: v['y'].to(config.aux_device, copy=True) for k, v in datasets.items()}
|
|
2445
|
+
|
|
2446
|
+
# >>> Model IDs and hyperparameters
|
|
2447
|
+
|
|
2448
|
+
_get_next_model_id = itertools.count().__next__
|
|
2449
|
+
if config.per_model_hyperparameters is None:
|
|
2450
|
+
per_model_hyperparameter_store = None
|
|
2451
|
+
_get_next_model_hyperparameters = dict
|
|
2452
|
+
else:
|
|
2453
|
+
per_model_hyperparameter_store = {}
|
|
2454
|
+
_get_next_model_hyperparameters = iter(
|
|
2455
|
+
config.per_model_hyperparameters
|
|
2456
|
+
).__next__
|
|
2457
|
+
|
|
2458
|
+
def get_next_models(n: int) -> tuple[list[ModelID], list[HyperparameterDict]]:
|
|
2459
|
+
ids_and_hyperparameters = [
|
|
2460
|
+
(_get_next_model_id(), _get_next_model_hyperparameters()) for _ in range(n)
|
|
2461
|
+
]
|
|
2462
|
+
if per_model_hyperparameter_store is not None:
|
|
2463
|
+
per_model_hyperparameter_store.update(
|
|
2464
|
+
copy.deepcopy(ids_and_hyperparameters)
|
|
2465
|
+
)
|
|
2466
|
+
return (
|
|
2467
|
+
[x[0] for x in ids_and_hyperparameters],
|
|
2468
|
+
[x[1] for x in ids_and_hyperparameters],
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
initial_pack_size = config.max_pack_size
|
|
2472
|
+
initial_model_ids, initial_per_model_hyperparameters = get_next_models(
|
|
2473
|
+
initial_pack_size
|
|
2474
|
+
)
|
|
2475
|
+
initial_per_model_hyperparameters_T = transpose_dicts_recursively(
|
|
2476
|
+
initial_per_model_hyperparameters
|
|
2477
|
+
)
|
|
2478
|
+
del initial_per_model_hyperparameters
|
|
2479
|
+
|
|
2480
|
+
# >>> Model pack
|
|
2481
|
+
|
|
2482
|
+
model_kwargs = copy.deepcopy(config.model)
|
|
2483
|
+
merge_dicts_recursively(model_kwargs, _infer_model_shape_kwargs(config, datasets))
|
|
2484
|
+
# Preserve the common arguments for materializing ensembles.
|
|
2485
|
+
common_model_kwargs = copy.deepcopy(model_kwargs)
|
|
2486
|
+
# Merge the hyperparameters of the initial models.
|
|
2487
|
+
merge_dicts_recursively(
|
|
2488
|
+
model_kwargs, initial_per_model_hyperparameters_T.pop('model', {})
|
|
2489
|
+
)
|
|
2490
|
+
|
|
2491
|
+
model_pack = tabpackage.nn.TabPack(pack_size=initial_pack_size, **model_kwargs)
|
|
2492
|
+
model_pack.to(config.device)
|
|
2493
|
+
|
|
2494
|
+
if config.compile_model:
|
|
2495
|
+
model_pack.forward = torch.compile( # ty: ignore[invalid-assignment]
|
|
2496
|
+
model_pack.forward, options={'emulate_precision_casts': True}
|
|
2497
|
+
)
|
|
2498
|
+
if config.compile_model_numerical_embeddings and model_pack.num_module is not None:
|
|
2499
|
+
model_pack.num_module.forward = torch.compile(model_pack.num_module.forward)
|
|
2500
|
+
if config.compile_model_first_block:
|
|
2501
|
+
block = model_pack.backbone.blocks[0]
|
|
2502
|
+
block.forward = torch.compile(
|
|
2503
|
+
block.forward,
|
|
2504
|
+
# Emulate eager precision casts to reduce numerical differences from
|
|
2505
|
+
# the non-compiled variant.
|
|
2506
|
+
options={'emulate_precision_casts': True},
|
|
2507
|
+
)
|
|
2508
|
+
|
|
2509
|
+
# >>> Training
|
|
2510
|
+
|
|
2511
|
+
support_replacement = config.max_pack_size < config.n_models
|
|
2512
|
+
|
|
2513
|
+
optimizer_kwargs = copy.deepcopy(config.optimizer)
|
|
2514
|
+
merge_dicts_recursively(
|
|
2515
|
+
optimizer_kwargs, initial_per_model_hyperparameters_T.pop('optimizer', {})
|
|
2516
|
+
)
|
|
2517
|
+
if config.compile_optimizer_muon_newton_schulz:
|
|
2518
|
+
optimizer_kwargs['compile_muon_newton_schulz'] = True
|
|
2519
|
+
optimizer_pack = make_optimizer_pack(
|
|
2520
|
+
model_pack, support_replacement=support_replacement, **optimizer_kwargs
|
|
2521
|
+
)
|
|
2522
|
+
del optimizer_kwargs
|
|
2523
|
+
|
|
2524
|
+
train_size = len(datasets['train']['y'])
|
|
2525
|
+
n_steps_per_epoch = math.ceil(train_size / config.batch_size)
|
|
2526
|
+
|
|
2527
|
+
initial_max_epochs = initial_per_model_hyperparameters_T.pop(
|
|
2528
|
+
'max_epochs', config.max_epochs
|
|
2529
|
+
)
|
|
2530
|
+
if not isinstance(initial_max_epochs, list):
|
|
2531
|
+
initial_max_epochs = [initial_max_epochs] * initial_pack_size
|
|
2532
|
+
|
|
2533
|
+
# >>> Evaluation
|
|
2534
|
+
|
|
2535
|
+
eval_batch_size = config.eval_batch_size
|
|
2536
|
+
history: list[dict[str, Any]] = []
|
|
2537
|
+
|
|
2538
|
+
# >>> State
|
|
2539
|
+
|
|
2540
|
+
training_loop_step = 0
|
|
2541
|
+
training_state = TrainingStatePack(
|
|
2542
|
+
ids=torch.tensor(initial_model_ids, device=_IDS_DEVICE),
|
|
2543
|
+
max_steps=torch.tensor(
|
|
2544
|
+
[
|
|
2545
|
+
_max_epochs_to_max_steps(x, n_steps_per_epoch=n_steps_per_epoch)
|
|
2546
|
+
for x in initial_max_epochs
|
|
2547
|
+
],
|
|
2548
|
+
dtype=torch.int64,
|
|
2549
|
+
device=_IDS_DEVICE,
|
|
2550
|
+
),
|
|
2551
|
+
steps=torch.zeros(initial_pack_size, dtype=torch.int64, device=_IDS_DEVICE),
|
|
2552
|
+
n_consecutive_bad_updates=torch.zeros(
|
|
2553
|
+
initial_pack_size, dtype=torch.int64, device=_IDS_DEVICE
|
|
2554
|
+
),
|
|
2555
|
+
)
|
|
2556
|
+
# During training, running models' best checkpoints live in the training state.
|
|
2557
|
+
# The following store, in turn, keeps finished models' best checkpoints and
|
|
2558
|
+
# checkpoints selected by ensembles, which may come from earlier training steps. It
|
|
2559
|
+
# _must not_ contain duplicates. To maintain this invariant, use the specialized
|
|
2560
|
+
# function for rebuilding the store with new checkpoints.
|
|
2561
|
+
checkpoint_store: CheckpointStore = []
|
|
2562
|
+
finished_best_checkpoint_keys = ModelCheckpointKeys(
|
|
2563
|
+
ids=torch.empty(0, dtype=torch.int64, device=_IDS_DEVICE),
|
|
2564
|
+
steps=torch.empty(0, dtype=torch.int64, device=_IDS_DEVICE),
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
# Ensure that all hyperparameters have been used.
|
|
2568
|
+
assert not initial_per_model_hyperparameters_T
|
|
2569
|
+
del initial_pack_size, initial_model_ids, initial_per_model_hyperparameters_T
|
|
2570
|
+
|
|
2571
|
+
# >>> Ensembles
|
|
2572
|
+
|
|
2573
|
+
ensembles = {
|
|
2574
|
+
k: Ensemble(
|
|
2575
|
+
algorithm_fn=make_ensemble_algorithm(
|
|
2576
|
+
y_true=labels[v.algorithm_dataset],
|
|
2577
|
+
metric_fn=v.algorithm_metric,
|
|
2578
|
+
metric_direction=v.algorithm_metric_direction,
|
|
2579
|
+
device=v.algorithm_device,
|
|
2580
|
+
**v.algorithm,
|
|
2581
|
+
),
|
|
2582
|
+
algorithm_dataset=v.algorithm_dataset,
|
|
2583
|
+
online=v.online,
|
|
2584
|
+
prediction_transform=v.prediction_transform,
|
|
2585
|
+
score_fn=(
|
|
2586
|
+
None
|
|
2587
|
+
if config.ensemble_eval_criterion is None
|
|
2588
|
+
else make_ensemble_score_fn(
|
|
2589
|
+
metric_fn=config.ensemble_eval_metrics[
|
|
2590
|
+
config.ensemble_eval_criterion.dataset
|
|
2591
|
+
][config.ensemble_eval_criterion.metric],
|
|
2592
|
+
metric_direction=config.ensemble_eval_criterion.metric_direction,
|
|
2593
|
+
y_true=labels[config.ensemble_eval_criterion.dataset],
|
|
2594
|
+
)
|
|
2595
|
+
),
|
|
2596
|
+
score_dataset=(
|
|
2597
|
+
None
|
|
2598
|
+
if config.ensemble_eval_criterion is None
|
|
2599
|
+
else config.ensemble_eval_criterion.dataset
|
|
2600
|
+
),
|
|
2601
|
+
early_stopping_patience=v.early_stopping_patience,
|
|
2602
|
+
)
|
|
2603
|
+
for k, v in config.ensembles.items()
|
|
2604
|
+
}
|
|
2605
|
+
ensemble_history: dict[EnsembleName, list[dict[str, Any]]] = {
|
|
2606
|
+
name: [] for name in ensembles
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
def get_prediction_dataset_names() -> tuple[DatasetName, ...]:
|
|
2610
|
+
"""Get datasets for which predictions should be stored in model checkpoints.
|
|
2611
|
+
|
|
2612
|
+
The important property of this function is that its output can only shrink over
|
|
2613
|
+
time, but never grow.
|
|
2614
|
+
"""
|
|
2615
|
+
dataset_names = [
|
|
2616
|
+
x.algorithm_dataset for x in ensembles.values() if x.is_running()
|
|
2617
|
+
]
|
|
2618
|
+
return (
|
|
2619
|
+
# Return the datasets used for building and evaluating running ensembles.
|
|
2620
|
+
tuple(dict.fromkeys([*dataset_names, *config.ensemble_eval_metrics]))
|
|
2621
|
+
if dataset_names
|
|
2622
|
+
else ()
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
prediction_dataset_names = get_prediction_dataset_names()
|
|
2626
|
+
|
|
2627
|
+
# >>> Printing
|
|
2628
|
+
|
|
2629
|
+
best_model_criterion_metric = None
|
|
2630
|
+
best_ensemble_criterion_metric = None
|
|
2631
|
+
|
|
2632
|
+
# >>> Training loop
|
|
2633
|
+
|
|
2634
|
+
# Most training logic is kept inline to make the loop easy to copy and customize.
|
|
2635
|
+
# Alternatively, local closures could make this specific loop more readable.
|
|
2636
|
+
|
|
2637
|
+
training_loop_start_time = time.perf_counter()
|
|
2638
|
+
|
|
2639
|
+
def get_training_loop_time() -> float:
|
|
2640
|
+
return time.perf_counter() - training_loop_start_time
|
|
2641
|
+
|
|
2642
|
+
training_loop_is_running = True
|
|
2643
|
+
while training_loop_is_running:
|
|
2644
|
+
# >>> Training phase
|
|
2645
|
+
|
|
2646
|
+
model_pack.train()
|
|
2647
|
+
|
|
2648
|
+
batch_losses = []
|
|
2649
|
+
batch_sizes = []
|
|
2650
|
+
batch_idx_list = make_shuffled_batch_indices_pack(
|
|
2651
|
+
total_size=train_size,
|
|
2652
|
+
batch_size=config.batch_size,
|
|
2653
|
+
pack_size=training_state.pack_size,
|
|
2654
|
+
device=train_dataset_device,
|
|
2655
|
+
)
|
|
2656
|
+
|
|
2657
|
+
for batch_idx in (
|
|
2658
|
+
ProgressBar(
|
|
2659
|
+
batch_idx_list,
|
|
2660
|
+
training_loop_step=training_loop_step,
|
|
2661
|
+
n_steps_per_epoch=n_steps_per_epoch,
|
|
2662
|
+
pack_size=training_state.pack_size,
|
|
2663
|
+
)
|
|
2664
|
+
if config.show_progress_bar and ProgressBar is not None
|
|
2665
|
+
else batch_idx_list
|
|
2666
|
+
): # batch_idx.shape: (pack_size, batch_size)
|
|
2667
|
+
batch = {
|
|
2668
|
+
k: v[batch_idx].to(config.device) for k, v in datasets['train'].items()
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
with compilation_context:
|
|
2672
|
+
y_pred = forward(model_pack, batch, autocast=autocast)
|
|
2673
|
+
losses = config.loss_fn(y_pred, batch['y'])
|
|
2674
|
+
if losses.shape != (model_pack.pack_size,):
|
|
2675
|
+
raise ValueError(
|
|
2676
|
+
'The loss function must return one scalar per model'
|
|
2677
|
+
)
|
|
2678
|
+
|
|
2679
|
+
# The scale of the gradients should not depend on the number of models,
|
|
2680
|
+
# so the individual losses are summed, not averaged.
|
|
2681
|
+
loss = losses.sum()
|
|
2682
|
+
loss.backward()
|
|
2683
|
+
optimizer_pack.step()
|
|
2684
|
+
optimizer_pack.zero_grad()
|
|
2685
|
+
|
|
2686
|
+
training_loop_step += 1
|
|
2687
|
+
training_state.steps.add_(1)
|
|
2688
|
+
|
|
2689
|
+
batch_losses.append(loss.detach())
|
|
2690
|
+
batch_sizes.append(batch_idx.shape[tabpackage.nn.BATCH_DIM])
|
|
2691
|
+
|
|
2692
|
+
del batch, batch_idx, losses, loss
|
|
2693
|
+
del batch_idx_list
|
|
2694
|
+
|
|
2695
|
+
# >>> Evaluation phase
|
|
2696
|
+
|
|
2697
|
+
# Evaluate the models.
|
|
2698
|
+
(latest_predictions, latest_metrics), eval_batch_size = evaluate(
|
|
2699
|
+
model_pack=model_pack,
|
|
2700
|
+
prediction_transform=config.prediction_transform,
|
|
2701
|
+
datasets=datasets,
|
|
2702
|
+
labels=labels,
|
|
2703
|
+
metric_fns=config.eval_metrics,
|
|
2704
|
+
prediction_dataset_names=prediction_dataset_names,
|
|
2705
|
+
batch_size=eval_batch_size,
|
|
2706
|
+
device=config.device,
|
|
2707
|
+
aux_device=config.aux_device,
|
|
2708
|
+
autocast=autocast,
|
|
2709
|
+
)
|
|
2710
|
+
history.append(
|
|
2711
|
+
{
|
|
2712
|
+
'time': get_training_loop_time(),
|
|
2713
|
+
'training_loop_step': training_loop_step,
|
|
2714
|
+
'ids': training_state.ids.clone(),
|
|
2715
|
+
'steps': training_state.steps.clone(),
|
|
2716
|
+
'metrics': latest_metrics,
|
|
2717
|
+
}
|
|
2718
|
+
)
|
|
2719
|
+
latest_checkpoints_view = ModelCheckpoints(
|
|
2720
|
+
keys=ModelCheckpointKeys(
|
|
2721
|
+
ids=training_state.ids, steps=training_state.steps
|
|
2722
|
+
),
|
|
2723
|
+
state_dicts=model_pack.state_dict(),
|
|
2724
|
+
predictions=latest_predictions,
|
|
2725
|
+
metrics=latest_metrics,
|
|
2726
|
+
)
|
|
2727
|
+
|
|
2728
|
+
# Update the best criterion metric.
|
|
2729
|
+
if config.print_progress and config.eval_criterion is not None:
|
|
2730
|
+
for value in latest_metrics[config.eval_criterion.dataset][
|
|
2731
|
+
config.eval_criterion.metric
|
|
2732
|
+
].tolist():
|
|
2733
|
+
best_model_criterion_metric = update_best_criterion_metric(
|
|
2734
|
+
best_model_criterion_metric,
|
|
2735
|
+
value,
|
|
2736
|
+
config.eval_criterion.metric_direction,
|
|
2737
|
+
)
|
|
2738
|
+
del value
|
|
2739
|
+
|
|
2740
|
+
del latest_predictions, latest_metrics
|
|
2741
|
+
|
|
2742
|
+
# Update the best checkpoints.
|
|
2743
|
+
update_training_state(
|
|
2744
|
+
training_state,
|
|
2745
|
+
latest_checkpoints=latest_checkpoints_view,
|
|
2746
|
+
eval_criterion=config.eval_criterion,
|
|
2747
|
+
)
|
|
2748
|
+
assert training_state.best_checkpoints is not None
|
|
2749
|
+
|
|
2750
|
+
# Determine which models should continue running and which should be stopped.
|
|
2751
|
+
running_pack_mask = training_state.is_running(
|
|
2752
|
+
early_stopping_patience=config.early_stopping_patience
|
|
2753
|
+
)
|
|
2754
|
+
running_pack_idx = torch.nonzero(running_pack_mask)[:, 0]
|
|
2755
|
+
stopped_pack_idx = torch.nonzero(~running_pack_mask)[:, 0]
|
|
2756
|
+
|
|
2757
|
+
# Register the best checkpoint keys of the stopped models.
|
|
2758
|
+
finished_best_checkpoint_keys = tabpackage.utils.torch.cat(
|
|
2759
|
+
[
|
|
2760
|
+
finished_best_checkpoint_keys,
|
|
2761
|
+
tabpackage.utils.torch.index(
|
|
2762
|
+
training_state.best_checkpoints.keys, stopped_pack_idx
|
|
2763
|
+
),
|
|
2764
|
+
]
|
|
2765
|
+
)
|
|
2766
|
+
|
|
2767
|
+
# >>> Ensemble updates
|
|
2768
|
+
|
|
2769
|
+
if any(x.is_running() for x in ensembles.values()):
|
|
2770
|
+
# The candidate checkpoint store can contain duplicates.
|
|
2771
|
+
candidate_checkpoint_store = [
|
|
2772
|
+
*checkpoint_store,
|
|
2773
|
+
training_state.best_checkpoints,
|
|
2774
|
+
latest_checkpoints_view,
|
|
2775
|
+
]
|
|
2776
|
+
|
|
2777
|
+
running_latest_checkpoint_keys = tabpackage.utils.torch.index(
|
|
2778
|
+
latest_checkpoints_view.keys, running_pack_idx
|
|
2779
|
+
)
|
|
2780
|
+
|
|
2781
|
+
for ensemble_name, ensemble in ensembles.items():
|
|
2782
|
+
if ensemble.is_running():
|
|
2783
|
+
ensemble_improved = ensemble.update(
|
|
2784
|
+
checkpoint_store=candidate_checkpoint_store,
|
|
2785
|
+
finished_best_checkpoint_keys=finished_best_checkpoint_keys,
|
|
2786
|
+
running_latest_checkpoint_keys=running_latest_checkpoint_keys,
|
|
2787
|
+
)
|
|
2788
|
+
if ensemble_improved:
|
|
2789
|
+
assert ensemble.member_checkpoint_keys is not None
|
|
2790
|
+
ensemble_metrics = evaluate_ensemble(
|
|
2791
|
+
ensemble=ensemble,
|
|
2792
|
+
checkpoint_store=candidate_checkpoint_store,
|
|
2793
|
+
labels=labels,
|
|
2794
|
+
metric_fns=config.ensemble_eval_metrics,
|
|
2795
|
+
aux_device=config.aux_device,
|
|
2796
|
+
)
|
|
2797
|
+
ensemble_history[ensemble_name].append(
|
|
2798
|
+
{
|
|
2799
|
+
'time': get_training_loop_time(),
|
|
2800
|
+
'training_loop_step': training_loop_step,
|
|
2801
|
+
'members': {
|
|
2802
|
+
'ids': ensemble.member_checkpoint_keys.ids.clone(),
|
|
2803
|
+
'steps': (
|
|
2804
|
+
ensemble.member_checkpoint_keys.steps.clone()
|
|
2805
|
+
),
|
|
2806
|
+
**(
|
|
2807
|
+
{}
|
|
2808
|
+
if ensemble.member_weights is None
|
|
2809
|
+
else {'weights': ensemble.member_weights}
|
|
2810
|
+
),
|
|
2811
|
+
},
|
|
2812
|
+
'metrics': ensemble_metrics,
|
|
2813
|
+
}
|
|
2814
|
+
)
|
|
2815
|
+
|
|
2816
|
+
if (
|
|
2817
|
+
config.print_progress
|
|
2818
|
+
and config.ensemble_eval_criterion is not None
|
|
2819
|
+
):
|
|
2820
|
+
best_ensemble_criterion_metric = (
|
|
2821
|
+
update_best_criterion_metric(
|
|
2822
|
+
best_ensemble_criterion_metric,
|
|
2823
|
+
ensemble_metrics[
|
|
2824
|
+
config.ensemble_eval_criterion.dataset
|
|
2825
|
+
][config.ensemble_eval_criterion.metric].item(),
|
|
2826
|
+
config.ensemble_eval_criterion.metric_direction,
|
|
2827
|
+
)
|
|
2828
|
+
)
|
|
2829
|
+
del ensemble_metrics
|
|
2830
|
+
|
|
2831
|
+
del candidate_checkpoint_store, running_latest_checkpoint_keys
|
|
2832
|
+
|
|
2833
|
+
# >>> Checkpoint store update
|
|
2834
|
+
|
|
2835
|
+
# After the ensemble updates, the list of datasets for which predictions should
|
|
2836
|
+
# be stored may shrink.
|
|
2837
|
+
prediction_dataset_names = get_prediction_dataset_names()
|
|
2838
|
+
|
|
2839
|
+
# Prune predictions for datasets that are not needed anymore. This must be done,
|
|
2840
|
+
# because checkpoints computed at the next epoch may also have the reduced set
|
|
2841
|
+
# of datasets with saved predictions, and the checkpoint structure must be the
|
|
2842
|
+
# same across all checkpoints.
|
|
2843
|
+
for checkpoints in [
|
|
2844
|
+
*checkpoint_store,
|
|
2845
|
+
training_state.best_checkpoints,
|
|
2846
|
+
latest_checkpoints_view,
|
|
2847
|
+
]:
|
|
2848
|
+
for dataset_name in list(checkpoints.predictions):
|
|
2849
|
+
if dataset_name not in prediction_dataset_names:
|
|
2850
|
+
del checkpoints.predictions[dataset_name]
|
|
2851
|
+
del checkpoints
|
|
2852
|
+
|
|
2853
|
+
# Keep only the finished best checkpoints and the checkpoints of the
|
|
2854
|
+
# ensemble members.
|
|
2855
|
+
checkpoint_store = rebuild_checkpoint_store(
|
|
2856
|
+
checkpoint_store,
|
|
2857
|
+
[training_state.best_checkpoints, latest_checkpoints_view],
|
|
2858
|
+
required_keys={
|
|
2859
|
+
*finished_best_checkpoint_keys.tolist(),
|
|
2860
|
+
*itertools.chain.from_iterable(
|
|
2861
|
+
x.member_checkpoint_keys.tolist()
|
|
2862
|
+
for x in ensembles.values()
|
|
2863
|
+
if x.member_checkpoint_keys is not None
|
|
2864
|
+
),
|
|
2865
|
+
},
|
|
2866
|
+
state_dict_device=config.aux_device,
|
|
2867
|
+
# The best and latest checkpoints contain references to data that is updated
|
|
2868
|
+
# in place during training, so they must be copied.
|
|
2869
|
+
copy_new_checkpoints=True,
|
|
2870
|
+
)
|
|
2871
|
+
|
|
2872
|
+
del latest_checkpoints_view
|
|
2873
|
+
|
|
2874
|
+
# >>> Stopping condition
|
|
2875
|
+
|
|
2876
|
+
training_loop_is_running = len(
|
|
2877
|
+
finished_best_checkpoint_keys
|
|
2878
|
+
) < config.n_models and (
|
|
2879
|
+
not ensembles
|
|
2880
|
+
or not config.stop_when_all_ensembles_are_finalized
|
|
2881
|
+
or any(x.is_running() for x in ensembles.values())
|
|
2882
|
+
)
|
|
2883
|
+
|
|
2884
|
+
# >>> Models replacement and removal
|
|
2885
|
+
|
|
2886
|
+
epoch_pack_size = model_pack.pack_size
|
|
2887
|
+
n_finished_models = len(finished_best_checkpoint_keys)
|
|
2888
|
+
n_running_models = len(running_pack_idx)
|
|
2889
|
+
n_stopped_models = len(stopped_pack_idx)
|
|
2890
|
+
|
|
2891
|
+
if training_loop_is_running:
|
|
2892
|
+
n_models_to_replace = min(
|
|
2893
|
+
n_stopped_models, config.n_models - n_finished_models - n_running_models
|
|
2894
|
+
)
|
|
2895
|
+
|
|
2896
|
+
if n_models_to_replace > 0:
|
|
2897
|
+
# Replace some of the stopped models with new ones.
|
|
2898
|
+
assert support_replacement
|
|
2899
|
+
assert isinstance(optimizer_pack, tabpackage.optim.OptimizerPack)
|
|
2900
|
+
|
|
2901
|
+
new_model_ids, new_per_model_hyperparameters = get_next_models(
|
|
2902
|
+
n_models_to_replace
|
|
2903
|
+
)
|
|
2904
|
+
pack_replace(
|
|
2905
|
+
model_pack,
|
|
2906
|
+
optimizer_pack,
|
|
2907
|
+
training_state,
|
|
2908
|
+
pack_idx=stopped_pack_idx[:n_models_to_replace],
|
|
2909
|
+
ids=new_model_ids,
|
|
2910
|
+
per_model_hyperparameters=new_per_model_hyperparameters,
|
|
2911
|
+
n_steps_per_epoch=n_steps_per_epoch,
|
|
2912
|
+
)
|
|
2913
|
+
del new_model_ids, new_per_model_hyperparameters
|
|
2914
|
+
|
|
2915
|
+
if n_stopped_models > n_models_to_replace:
|
|
2916
|
+
# Remove the stopped models that were not replaced. Importantly,
|
|
2917
|
+
# the removal invalidates all previously computed pack indices.
|
|
2918
|
+
training_state = pack_remove(
|
|
2919
|
+
model_pack,
|
|
2920
|
+
optimizer_pack,
|
|
2921
|
+
training_state,
|
|
2922
|
+
pack_idx=stopped_pack_idx[n_models_to_replace:],
|
|
2923
|
+
)
|
|
2924
|
+
|
|
2925
|
+
n_running_models += n_models_to_replace
|
|
2926
|
+
del n_models_to_replace
|
|
2927
|
+
del running_pack_mask, running_pack_idx, stopped_pack_idx
|
|
2928
|
+
|
|
2929
|
+
# >>> Printing
|
|
2930
|
+
|
|
2931
|
+
if config.print_progress:
|
|
2932
|
+
epoch = training_loop_step // n_steps_per_epoch
|
|
2933
|
+
|
|
2934
|
+
epoch_mean_loss = math.fsum(
|
|
2935
|
+
loss * size
|
|
2936
|
+
for loss, size in zip(
|
|
2937
|
+
torch.stack(batch_losses).tolist(), batch_sizes, strict=True
|
|
2938
|
+
)
|
|
2939
|
+
) / math.fsum(batch_sizes)
|
|
2940
|
+
|
|
2941
|
+
header, row = format_training_progress(
|
|
2942
|
+
elapsed_seconds=int(get_training_loop_time()),
|
|
2943
|
+
epoch=epoch,
|
|
2944
|
+
loss=epoch_mean_loss / epoch_pack_size,
|
|
2945
|
+
n_finished_models=n_finished_models,
|
|
2946
|
+
n_running_models=n_running_models,
|
|
2947
|
+
n_pending_models=(
|
|
2948
|
+
config.n_models - n_finished_models - n_running_models
|
|
2949
|
+
if support_replacement
|
|
2950
|
+
else None
|
|
2951
|
+
),
|
|
2952
|
+
show_best_model=config.eval_criterion is not None,
|
|
2953
|
+
show_best_ensemble=(
|
|
2954
|
+
bool(config.ensembles)
|
|
2955
|
+
and config.ensemble_eval_criterion is not None
|
|
2956
|
+
),
|
|
2957
|
+
best_model_criterion_metric=best_model_criterion_metric,
|
|
2958
|
+
best_ensemble_criterion_metric=best_ensemble_criterion_metric,
|
|
2959
|
+
)
|
|
2960
|
+
if epoch == 1:
|
|
2961
|
+
print(f'\n{header}')
|
|
2962
|
+
print(row)
|
|
2963
|
+
del epoch, epoch_mean_loss, header, row
|
|
2964
|
+
|
|
2965
|
+
del epoch_pack_size, n_finished_models, n_running_models, n_stopped_models
|
|
2966
|
+
|
|
2967
|
+
# >>> Finish
|
|
2968
|
+
|
|
2969
|
+
training_loop_time = get_training_loop_time()
|
|
2970
|
+
|
|
2971
|
+
# Ensemble-level stopping can leave models unfinished; save their best
|
|
2972
|
+
# checkpoints too, even if no ensemble selected them.
|
|
2973
|
+
running_pack_mask = training_state.is_running(
|
|
2974
|
+
early_stopping_patience=config.early_stopping_patience
|
|
2975
|
+
)
|
|
2976
|
+
assert training_state.best_checkpoints is not None
|
|
2977
|
+
unfinished_best_checkpoint_keys = tabpackage.utils.torch.index(
|
|
2978
|
+
training_state.best_checkpoints.keys, running_pack_mask
|
|
2979
|
+
)
|
|
2980
|
+
del running_pack_mask
|
|
2981
|
+
|
|
2982
|
+
checkpoint_store = rebuild_checkpoint_store(
|
|
2983
|
+
checkpoint_store,
|
|
2984
|
+
[training_state.best_checkpoints],
|
|
2985
|
+
required_keys={
|
|
2986
|
+
*finished_best_checkpoint_keys.tolist(),
|
|
2987
|
+
*unfinished_best_checkpoint_keys.tolist(),
|
|
2988
|
+
*itertools.chain.from_iterable(
|
|
2989
|
+
x.member_checkpoint_keys.tolist()
|
|
2990
|
+
for x in ensembles.values()
|
|
2991
|
+
if x.member_checkpoint_keys is not None
|
|
2992
|
+
),
|
|
2993
|
+
},
|
|
2994
|
+
state_dict_device=config.aux_device,
|
|
2995
|
+
copy_new_checkpoints=False, # The training is over, so no need to copy.
|
|
2996
|
+
)
|
|
2997
|
+
assert checkpoint_store
|
|
2998
|
+
for checkpoints in checkpoint_store:
|
|
2999
|
+
# Prune unused checkpoint data.
|
|
3000
|
+
checkpoints.predictions.clear()
|
|
3001
|
+
|
|
3002
|
+
return TrainedTabPack(
|
|
3003
|
+
config=config,
|
|
3004
|
+
n_steps_per_epoch=n_steps_per_epoch,
|
|
3005
|
+
training_loop_time=training_loop_time,
|
|
3006
|
+
training_loop_step=training_loop_step,
|
|
3007
|
+
prediction_transform=config.prediction_transform,
|
|
3008
|
+
common_model_kwargs=common_model_kwargs,
|
|
3009
|
+
per_model_hyperparameter_store=per_model_hyperparameter_store,
|
|
3010
|
+
checkpoints=tabpackage.utils.torch.cat(checkpoint_store),
|
|
3011
|
+
history=history,
|
|
3012
|
+
finished_best_checkpoint_keys=finished_best_checkpoint_keys,
|
|
3013
|
+
unfinished_best_checkpoint_keys=unfinished_best_checkpoint_keys,
|
|
3014
|
+
ensemble_prediction_transforms={
|
|
3015
|
+
k: v.prediction_transform for k, v in ensembles.items()
|
|
3016
|
+
},
|
|
3017
|
+
ensemble_history=ensemble_history,
|
|
3018
|
+
)
|
|
3019
|
+
|
|
3020
|
+
|
|
3021
|
+
# >>> `train`
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
@typing.final
|
|
3025
|
+
class _Missing:
|
|
3026
|
+
pass
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
_MISSING = _Missing()
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
def get_next_seed(rng: random.Random) -> int:
|
|
3033
|
+
# The following heuristic produces seed sequences that should be good enough for the
|
|
3034
|
+
# intended purposes. `Random.random` is used, because it is guaranteed to be
|
|
3035
|
+
# reproducible between Python versions. `Random.random` produces values of the form
|
|
3036
|
+
# `k / 2**53`, so the multiplication by `2**53` recovers `k`.
|
|
3037
|
+
return int(rng.random() * (1 << 53))
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
@contextlib.contextmanager
|
|
3041
|
+
def _seed_rngs(seed: int, *devices: torch.device):
|
|
3042
|
+
rng = random.Random(seed)
|
|
3043
|
+
|
|
3044
|
+
cuda_devices = sorted(
|
|
3045
|
+
{
|
|
3046
|
+
x.index if x.index is not None else torch.cuda.current_device()
|
|
3047
|
+
for x in devices
|
|
3048
|
+
if x.type == 'cuda'
|
|
3049
|
+
}
|
|
3050
|
+
)
|
|
3051
|
+
use_mps = any(x.type == 'mps' for x in devices)
|
|
3052
|
+
|
|
3053
|
+
with contextlib.ExitStack() as stack:
|
|
3054
|
+
stack.enter_context(torch.random.fork_rng(devices=cuda_devices))
|
|
3055
|
+
if use_mps:
|
|
3056
|
+
stack.enter_context(torch.random.fork_rng(devices=[0], device_type='mps'))
|
|
3057
|
+
|
|
3058
|
+
torch.default_generator.manual_seed(get_next_seed(rng))
|
|
3059
|
+
for cuda_device in cuda_devices:
|
|
3060
|
+
with torch.cuda.device(cuda_device):
|
|
3061
|
+
torch.cuda.manual_seed(get_next_seed(rng))
|
|
3062
|
+
if use_mps:
|
|
3063
|
+
torch.mps.manual_seed(get_next_seed(rng))
|
|
3064
|
+
|
|
3065
|
+
yield
|
|
3066
|
+
|
|
3067
|
+
|
|
3068
|
+
def train(
|
|
3069
|
+
*,
|
|
3070
|
+
datasets: dict[DatasetName, dict[str, Tensor]],
|
|
3071
|
+
n_models: int | _Missing = _MISSING,
|
|
3072
|
+
model: KWArgs,
|
|
3073
|
+
prediction_transform: PredictionTransform | None,
|
|
3074
|
+
loss_fn: LossFn,
|
|
3075
|
+
optimizer: KWArgs,
|
|
3076
|
+
batch_size: int,
|
|
3077
|
+
max_epochs: int | None,
|
|
3078
|
+
early_stopping_patience: int | _Missing | None = _MISSING,
|
|
3079
|
+
per_model_hyperparameters: list[HyperparameterDict] | None,
|
|
3080
|
+
eval_metrics: dict[DatasetName, dict[MetricName, MetricFn]],
|
|
3081
|
+
eval_criterion: EvalCriterionConfig | None,
|
|
3082
|
+
eval_batch_size: int = 32768,
|
|
3083
|
+
ensembles: dict[EnsembleName, EnsembleConfig],
|
|
3084
|
+
ensemble_eval_metrics: (
|
|
3085
|
+
dict[DatasetName, dict[MetricName, MetricFn]] | _Missing
|
|
3086
|
+
) = _MISSING,
|
|
3087
|
+
ensemble_eval_criterion: EvalCriterionConfig | _Missing | None = _MISSING,
|
|
3088
|
+
stop_when_all_ensembles_are_finalized: bool | _Missing = _MISSING,
|
|
3089
|
+
seed: int | None = None,
|
|
3090
|
+
max_pack_size: int | None = None,
|
|
3091
|
+
device: int | str | torch.device,
|
|
3092
|
+
aux_device: int | str | torch.device,
|
|
3093
|
+
autocast_dtype: Literal['bfloat16'] | torch.dtype | None,
|
|
3094
|
+
compile_model: bool = False,
|
|
3095
|
+
compile_model_numerical_embeddings: bool = False,
|
|
3096
|
+
compile_model_first_block: bool = False,
|
|
3097
|
+
compile_optimizer_muon_newton_schulz: bool = False,
|
|
3098
|
+
print_progress: bool = True,
|
|
3099
|
+
show_progress_bar: bool | None = None,
|
|
3100
|
+
) -> TrainedTabPack:
|
|
3101
|
+
"""Train and ensemble many MLPs in parallel.
|
|
3102
|
+
|
|
3103
|
+
Note:
|
|
3104
|
+
In the context of this function, the MLPs are referred to as "models".
|
|
3105
|
+
|
|
3106
|
+
The function trains many models in parallel, each with its own hyperparameters, and
|
|
3107
|
+
can build ensembles on the fly during training. After each epoch, the models are
|
|
3108
|
+
evaluated, their best or latest checkpoints are saved, and the ensembles are
|
|
3109
|
+
updated.
|
|
3110
|
+
|
|
3111
|
+
Each model stops according to its own stopping conditions. If the number of
|
|
3112
|
+
models trained in parallel is limited, models that have not started yet begin
|
|
3113
|
+
training as others finish. Depending on the provided arguments, the run continues
|
|
3114
|
+
either until all models finish or until all ensembles are finalized (even if some
|
|
3115
|
+
models are still training).
|
|
3116
|
+
|
|
3117
|
+
The result contains the selected checkpoints for making predictions with individual
|
|
3118
|
+
models or ensembles on new data, along with evaluation histories for inspecting
|
|
3119
|
+
their performance.
|
|
3120
|
+
|
|
3121
|
+
Args:
|
|
3122
|
+
datasets: the named datasets, including the required `"train"` dataset. Each
|
|
3123
|
+
dataset is a dictionary mapping `"y"` to labels and `"x_num"` and/or
|
|
3124
|
+
`"x_cat"` to numerical and categorical features, respectively.
|
|
3125
|
+
n_models: the total number of models to train. When omitted, it is inferred
|
|
3126
|
+
from the length of `per_model_hyperparameters`.
|
|
3127
|
+
model: the shared keyword arguments for `tabpackage.nn.TabPack`, including
|
|
3128
|
+
the required `d_out` and `categorical_cardinalities` arguments. Must not
|
|
3129
|
+
contain per-model hyperparameters (use `per_model_hyperparameters` instead).
|
|
3130
|
+
prediction_transform: the transformation applied to model outputs for
|
|
3131
|
+
evaluation and inference, such as softmax for classification models.
|
|
3132
|
+
`None` leaves the outputs unchanged.
|
|
3133
|
+
loss_fn: a callable that accepts raw predictions and labels as positional
|
|
3134
|
+
arguments, both with a leading model dimension. It returns one scalar
|
|
3135
|
+
loss per model as a tensor of shape `(pack_size,)`.
|
|
3136
|
+
optimizer: the optimizer configuration dictionary. The `"type"` entry
|
|
3137
|
+
specifies the optimizer class; the remaining entries are keyword
|
|
3138
|
+
arguments shared by all models. Must not contain per-model optimization
|
|
3139
|
+
hyperparameters (use `per_model_hyperparameters` instead).
|
|
3140
|
+
batch_size: the number of training examples per model per training step.
|
|
3141
|
+
max_epochs: the maximum number of training epochs per model. `None` means using
|
|
3142
|
+
per-model epoch limits or other stopping conditions.
|
|
3143
|
+
early_stopping_patience: the number of consecutive epochs without
|
|
3144
|
+
improvement in `eval_criterion` tolerated before stopping each model.
|
|
3145
|
+
`None` disables model-level early stopping. It must be provided when
|
|
3146
|
+
`eval_criterion` is set.
|
|
3147
|
+
per_model_hyperparameters: a list of hyperparameter dictionaries, one
|
|
3148
|
+
per model, with optional `"model"`, `"optimizer"`, and `"max_epochs"`
|
|
3149
|
+
entries. `None` means using only the shared hyperparameters, in which case
|
|
3150
|
+
models differ only in aspects affected by randomness, such as
|
|
3151
|
+
initialization, training batch sequences, dropout masks, etc.
|
|
3152
|
+
eval_metrics: the metric callables evaluated after each epoch, grouped by
|
|
3153
|
+
dataset name and metric name, returning tensors with per-model metrics. See
|
|
3154
|
+
`MetricFn` for details.
|
|
3155
|
+
eval_criterion: the evaluation criterion used to select each model's best
|
|
3156
|
+
checkpoint. `None` means always selecting its latest evaluated checkpoint.
|
|
3157
|
+
eval_batch_size: the initial number of examples per evaluation batch. May be
|
|
3158
|
+
automatically reduced during execution to fit the evaluation workload into
|
|
3159
|
+
the available device memory.
|
|
3160
|
+
ensembles: a dictionary mapping ensemble names to ensemble configurations.
|
|
3161
|
+
An empty dictionary disables ensembling.
|
|
3162
|
+
ensemble_eval_metrics: the metric callables for ensembles, grouped by
|
|
3163
|
+
dataset name and metric name. See `MetricFn` for details. Must be provided
|
|
3164
|
+
when `ensembles` is nonempty.
|
|
3165
|
+
ensemble_eval_criterion: the evaluation criterion used to select the best
|
|
3166
|
+
version of each ensemble. `None` means always selecting the latest update.
|
|
3167
|
+
Must be provided when `ensembles` is nonempty.
|
|
3168
|
+
stop_when_all_ensembles_are_finalized: whether to end training once all
|
|
3169
|
+
ensembles are finalized, even if some models are still training.
|
|
3170
|
+
Must be provided when `ensembles` is nonempty.
|
|
3171
|
+
seed: the seed for the PyTorch **global** random generators used during
|
|
3172
|
+
training. Also, if provided, when the function returns, the state of the
|
|
3173
|
+
generators is restored to what it was before the function call. `None` means
|
|
3174
|
+
using the current generator states without restoring their state in the end.
|
|
3175
|
+
max_pack_size: the maximum number of models trained in parallel. `None` means
|
|
3176
|
+
training all models at once.
|
|
3177
|
+
device: the device used for model training and evaluation.
|
|
3178
|
+
aux_device: the device used to compute evaluation metrics and store
|
|
3179
|
+
predictions and checkpoints.
|
|
3180
|
+
autocast_dtype: the data type used for automatic mixed precision
|
|
3181
|
+
during forward passes. Only BF16 is supported. `None` means not using
|
|
3182
|
+
autocast.
|
|
3183
|
+
compile_model: whether to apply `torch.compile` to the whole model.
|
|
3184
|
+
compile_model_numerical_embeddings: whether to apply `torch.compile` to
|
|
3185
|
+
the numerical embedding module's forward pass, if the model has one.
|
|
3186
|
+
compile_model_first_block: whether to apply `torch.compile` to the first
|
|
3187
|
+
backbone block's forward pass.
|
|
3188
|
+
compile_optimizer_muon_newton_schulz: whether to compile the Muon
|
|
3189
|
+
optimizer's Newton-Schulz computation.
|
|
3190
|
+
print_progress: whether to print a training summary after each epoch.
|
|
3191
|
+
show_progress_bar: whether to use `tqdm` (if installed) to show training batch
|
|
3192
|
+
progress within each epoch.
|
|
3193
|
+
|
|
3194
|
+
Returns:
|
|
3195
|
+
A trained TabPack for creating predictors and accessing training and evaluation
|
|
3196
|
+
artifacts. See `TrainedTabPack` for details.
|
|
3197
|
+
"""
|
|
3198
|
+
config_kwargs = locals()
|
|
3199
|
+
del config_kwargs['datasets']
|
|
3200
|
+
del config_kwargs['seed']
|
|
3201
|
+
|
|
3202
|
+
# This function only normalizes public arguments and resolves defaults. Everything
|
|
3203
|
+
# else, including validation of the resulting configuration, should happen in
|
|
3204
|
+
# `_train`.
|
|
3205
|
+
|
|
3206
|
+
if n_models is _MISSING and per_model_hyperparameters is None:
|
|
3207
|
+
raise ValueError(
|
|
3208
|
+
'Provide the number of models or the list of per-model hyperparameters to'
|
|
3209
|
+
' specify how many models to train'
|
|
3210
|
+
)
|
|
3211
|
+
|
|
3212
|
+
if n_models is _MISSING:
|
|
3213
|
+
assert per_model_hyperparameters is not None
|
|
3214
|
+
config_kwargs['n_models'] = len(per_model_hyperparameters)
|
|
3215
|
+
|
|
3216
|
+
if eval_criterion is None:
|
|
3217
|
+
if early_stopping_patience is _MISSING:
|
|
3218
|
+
config_kwargs['early_stopping_patience'] = None
|
|
3219
|
+
elif early_stopping_patience is _MISSING:
|
|
3220
|
+
raise ValueError(
|
|
3221
|
+
'Set the early stopping patience explicitly when using an evaluation'
|
|
3222
|
+
' criterion; use `None` to disable early stopping'
|
|
3223
|
+
)
|
|
3224
|
+
|
|
3225
|
+
if ensembles:
|
|
3226
|
+
if ensemble_eval_metrics is _MISSING:
|
|
3227
|
+
raise ValueError(
|
|
3228
|
+
'Set `ensemble_eval_metrics` explicitly when using ensembles; use an'
|
|
3229
|
+
' empty dictionary if no evaluation metrics are needed'
|
|
3230
|
+
)
|
|
3231
|
+
|
|
3232
|
+
if ensemble_eval_criterion is _MISSING:
|
|
3233
|
+
raise ValueError(
|
|
3234
|
+
'Set `ensemble_eval_criterion` explicitly when using ensembles; use'
|
|
3235
|
+
' `None` to accept ensemble updates without scoring them'
|
|
3236
|
+
)
|
|
3237
|
+
|
|
3238
|
+
if stop_when_all_ensembles_are_finalized is _MISSING:
|
|
3239
|
+
raise ValueError(
|
|
3240
|
+
'Set `stop_when_all_ensembles_are_finalized` explicitly when using'
|
|
3241
|
+
' ensembles to choose whether training should stop when all are'
|
|
3242
|
+
' finalized'
|
|
3243
|
+
)
|
|
3244
|
+
else:
|
|
3245
|
+
if ensemble_eval_metrics is _MISSING:
|
|
3246
|
+
config_kwargs['ensemble_eval_metrics'] = {}
|
|
3247
|
+
if ensemble_eval_criterion is _MISSING:
|
|
3248
|
+
config_kwargs['ensemble_eval_criterion'] = None
|
|
3249
|
+
if stop_when_all_ensembles_are_finalized is _MISSING:
|
|
3250
|
+
config_kwargs['stop_when_all_ensembles_are_finalized'] = False
|
|
3251
|
+
|
|
3252
|
+
if max_pack_size is None:
|
|
3253
|
+
config_kwargs['max_pack_size'] = config_kwargs['n_models']
|
|
3254
|
+
elif max_pack_size >= config_kwargs['n_models']:
|
|
3255
|
+
warnings.warn(
|
|
3256
|
+
f'The provided `max_pack_size={max_pack_size}` is no less than'
|
|
3257
|
+
f' the number of models {config_kwargs["n_models"]} and therefore will have'
|
|
3258
|
+
' no effect. Omit `max_pack_size` to avoid this warning',
|
|
3259
|
+
stacklevel=2,
|
|
3260
|
+
)
|
|
3261
|
+
config_kwargs['max_pack_size'] = config_kwargs['n_models']
|
|
3262
|
+
|
|
3263
|
+
if not isinstance(device, torch.device):
|
|
3264
|
+
config_kwargs['device'] = torch.device(device)
|
|
3265
|
+
if not isinstance(aux_device, torch.device):
|
|
3266
|
+
config_kwargs['aux_device'] = torch.device(aux_device)
|
|
3267
|
+
if isinstance(autocast_dtype, str):
|
|
3268
|
+
if autocast_dtype != 'bfloat16':
|
|
3269
|
+
raise ValueError(
|
|
3270
|
+
'When the autocast data type is a string, it must be `"bfloat16"`'
|
|
3271
|
+
)
|
|
3272
|
+
config_kwargs['autocast_dtype'] = torch.bfloat16
|
|
3273
|
+
|
|
3274
|
+
if show_progress_bar is None:
|
|
3275
|
+
if tqdm is None:
|
|
3276
|
+
warnings.warn(
|
|
3277
|
+
'Install `tqdm` to see the per-epoch progress bar during training.'
|
|
3278
|
+
' Pass `show_progress_bar=False` to avoid this warning',
|
|
3279
|
+
stacklevel=2,
|
|
3280
|
+
)
|
|
3281
|
+
config_kwargs['show_progress_bar'] = tqdm is not None
|
|
3282
|
+
|
|
3283
|
+
config = _TabPackConfig(**config_kwargs)
|
|
3284
|
+
|
|
3285
|
+
# The training uses global RNGs of following devices:
|
|
3286
|
+
# - CPU, for the model initialization.
|
|
3287
|
+
# - `device`, for training (e.g., for dropout mask generation).
|
|
3288
|
+
# - The training-data device, for shuffling training batches.
|
|
3289
|
+
with (
|
|
3290
|
+
contextlib.nullcontext()
|
|
3291
|
+
if seed is None
|
|
3292
|
+
else _seed_rngs(
|
|
3293
|
+
seed, config.device, next(iter(datasets['train'].values())).device
|
|
3294
|
+
)
|
|
3295
|
+
):
|
|
3296
|
+
return _train(config, datasets)
|
|
3297
|
+
|
|
3298
|
+
|
|
3299
|
+
def retrain(
|
|
3300
|
+
tabpack: TrainedTabPack,
|
|
3301
|
+
*,
|
|
3302
|
+
ensemble_name: EnsembleName,
|
|
3303
|
+
dataset: dict[str, Tensor],
|
|
3304
|
+
n_repeats: int = 1,
|
|
3305
|
+
seed: int | None = None,
|
|
3306
|
+
) -> RetrainedTabPack:
|
|
3307
|
+
"""Retrain one of TabPack's ensemble from scratch on full training data.
|
|
3308
|
+
|
|
3309
|
+
Each member trains for the number of complete epochs represented by its checkpoint
|
|
3310
|
+
selected as the ensemble's member. Validation, early stopping, and ensemble
|
|
3311
|
+
selection are disabled. Most of other settings are reused from the original training
|
|
3312
|
+
run.
|
|
3313
|
+
|
|
3314
|
+
Args:
|
|
3315
|
+
tabpack: the original trained TabPack.
|
|
3316
|
+
ensemble_name: the ensemble to retrain.
|
|
3317
|
+
dataset: the full training dataset. Redo preprocessing on the full training data
|
|
3318
|
+
before calling this function.
|
|
3319
|
+
n_repeats: the number of independently initialized copies of each ensemble
|
|
3320
|
+
member.
|
|
3321
|
+
seed: the random seed for retraining, with the same behavior as in `train`.
|
|
3322
|
+
|
|
3323
|
+
Returns:
|
|
3324
|
+
A retrained ensemble.
|
|
3325
|
+
"""
|
|
3326
|
+
if n_repeats <= 0:
|
|
3327
|
+
raise ValueError('The number of repeats must be positive')
|
|
3328
|
+
|
|
3329
|
+
member_ids = tabpack.load_ensemble_member_ids(ensemble_name)
|
|
3330
|
+
member_steps = tabpack.load_ensemble_member_steps(ensemble_name)
|
|
3331
|
+
member_weights = tabpack.load_ensemble_member_weights(ensemble_name)
|
|
3332
|
+
per_model_hyperparameters = tabpack.load_per_model_hyperparameters(
|
|
3333
|
+
member_ids.tolist()
|
|
3334
|
+
)
|
|
3335
|
+
if per_model_hyperparameters is None:
|
|
3336
|
+
per_model_hyperparameters = [{} for _ in member_ids]
|
|
3337
|
+
|
|
3338
|
+
for hyperparameters, steps in zip(
|
|
3339
|
+
per_model_hyperparameters, member_steps.tolist(), strict=True
|
|
3340
|
+
):
|
|
3341
|
+
hyperparameters['max_epochs'] = steps // tabpack.get_n_steps_per_epoch()
|
|
3342
|
+
|
|
3343
|
+
n_models = len(member_ids) * n_repeats
|
|
3344
|
+
config = dataclasses.replace(
|
|
3345
|
+
tabpack._config,
|
|
3346
|
+
n_models=n_models,
|
|
3347
|
+
max_pack_size=min(tabpack._config.max_pack_size, n_models),
|
|
3348
|
+
model=tabpack.load_common_model_kwargs(),
|
|
3349
|
+
prediction_transform=tabpack.load_model_prediction_transform(),
|
|
3350
|
+
per_model_hyperparameters=[
|
|
3351
|
+
copy.deepcopy(hyperparameters)
|
|
3352
|
+
for _ in range(n_repeats)
|
|
3353
|
+
for hyperparameters in per_model_hyperparameters
|
|
3354
|
+
],
|
|
3355
|
+
max_epochs=None,
|
|
3356
|
+
early_stopping_patience=None,
|
|
3357
|
+
eval_metrics={},
|
|
3358
|
+
eval_criterion=None,
|
|
3359
|
+
ensembles={},
|
|
3360
|
+
ensemble_eval_metrics={},
|
|
3361
|
+
ensemble_eval_criterion=None,
|
|
3362
|
+
stop_when_all_ensembles_are_finalized=False,
|
|
3363
|
+
)
|
|
3364
|
+
with (
|
|
3365
|
+
contextlib.nullcontext()
|
|
3366
|
+
if seed is None
|
|
3367
|
+
else _seed_rngs(seed, config.device, next(iter(dataset.values())).device)
|
|
3368
|
+
):
|
|
3369
|
+
retrained_tabpack = _train(config, {'train': dataset})
|
|
3370
|
+
|
|
3371
|
+
return RetrainedTabPack(
|
|
3372
|
+
tabpack=retrained_tabpack,
|
|
3373
|
+
ensemble_name=ensemble_name,
|
|
3374
|
+
member_weights=(
|
|
3375
|
+
None
|
|
3376
|
+
if member_weights is None
|
|
3377
|
+
else member_weights.repeat(n_repeats) / n_repeats
|
|
3378
|
+
),
|
|
3379
|
+
prediction_transform=tabpack.load_ensemble_prediction_transform(ensemble_name),
|
|
3380
|
+
)
|