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.
@@ -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
+ )