tabpackage 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tabpackage/nn.py ADDED
@@ -0,0 +1,2110 @@
1
+ """Neural network modules, including the "Module Pack" pattern.
2
+
3
+ A module pack is a set of same-family modules with potentially different
4
+ hyperparameters that are stored and applied as one module, which makes it possible to
5
+ train many models in parallel efficiently. The base class for module packs is
6
+ `ModulePack`. The pack dimension of all tensors of a module pack is `PACK_DIM`.
7
+ """
8
+
9
+ import abc
10
+ import contextlib
11
+ import enum
12
+ import inspect
13
+ import itertools
14
+ import math
15
+ import typing
16
+ from collections import OrderedDict
17
+ from typing import Any, Final, NamedTuple, TypeAlias, TypeVar
18
+
19
+ import torch
20
+ import torch.nn as nn # noqa: PLR0402
21
+ import torch.optim.optimizer
22
+ from torch import Tensor
23
+
24
+ _TORCH_IS_OLDER_THAN_2_15 = torch.torch_version.TorchVersion(torch.__version__) < '2.15'
25
+
26
+ # In many places, the implementation _implicitly_ assumes the following values
27
+ # and is _not_ ready for other values. Thus, these constants exist only for additional
28
+ # clarity where possible, and must never be changed.
29
+ PACK_DIM: Final = 0
30
+ BATCH_DIM: Final = 1
31
+
32
+
33
+ KWArgs: TypeAlias = dict[str, Any]
34
+
35
+
36
+ # >>> Generic modules
37
+
38
+
39
+ class PackView(nn.Module):
40
+ """Turn a tensor to a valid input for a module pack.
41
+
42
+ This is a helper module for module packs that is not itself a module pack, but
43
+ follows some of the conventions described in `ModulePack`.
44
+
45
+ .. note::
46
+ The module uses `torch.Tensor.expand` under the hood and thus does not create
47
+ any copies.
48
+
49
+ **Shape**
50
+
51
+ - Input: `(batch_size, d)` or `(pack_size, batch_size, d)`
52
+ - Output: `(pack_size, batch_size, d)`
53
+
54
+ **Examples**
55
+
56
+ Two-dimensional tensors are expanded to three-dimensional tensors holding `k`
57
+ identical views of the original input:
58
+
59
+ >>> m = PackView(pack_size=2)
60
+ >>> x = torch.randn(3, 4)
61
+ >>> m(x).shape
62
+ torch.Size([2, 3, 4])
63
+ >>> torch.all(m(x) == x.unsqueeze(PACK_DIM)).item()
64
+ True
65
+
66
+ Three-dimensional tensors are accepted only in training mode and returned as-is:
67
+
68
+ >>> m = PackView(pack_size=2)
69
+ >>> x = torch.randn(2, 3, 4)
70
+ >>> m(x) is x
71
+ True
72
+ """
73
+
74
+ def __init__(self, *, pack_size: int) -> None:
75
+ super().__init__()
76
+ self._pack_size = pack_size
77
+
78
+ @property
79
+ def pack_size(self) -> int:
80
+ """The pack size."""
81
+ return self._pack_size
82
+
83
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
84
+ if pack_idx is not None:
85
+ check_pack_idx(pack_idx, self.pack_size)
86
+
87
+ pack_size = self.pack_size if pack_idx is None else len(pack_idx)
88
+ if x.ndim == 2:
89
+ x = x.unsqueeze(PACK_DIM).expand(pack_size, -1, -1)
90
+ else:
91
+ if x.ndim != 3:
92
+ raise ValueError('The input must have two or three dimensions')
93
+ if not self.training:
94
+ raise RuntimeError('Three-dimensional input requires training mode')
95
+ if x.shape[PACK_DIM] != pack_size:
96
+ raise ValueError(
97
+ 'The pack dimension of the input must match the selected pack size'
98
+ )
99
+ return x
100
+
101
+
102
+ # >>> Tensor packs
103
+
104
+
105
+ def _check_idx(idx: Tensor) -> None:
106
+ if not (idx.dtype == torch.int32 or idx.dtype == torch.int64):
107
+ raise TypeError(
108
+ 'Indices must have the data type `torch.int32` or `torch.int64`'
109
+ )
110
+ if idx.ndim != 1:
111
+ raise ValueError('Indices must be one-dimensional')
112
+ if len(idx) == 0:
113
+ raise ValueError('Indices must not be empty')
114
+
115
+
116
+ def check_pack_idx(pack_idx: Tensor, pack_size: int | None) -> None:
117
+ """Check basic requirements for indexing pack members."""
118
+ # Only cheap checks are performed. In particular, the values of `pack_idx`
119
+ # are not checked, because that would require a device-to-host synchronization.
120
+ _check_idx(pack_idx)
121
+ if pack_size is not None and len(pack_idx) > pack_size:
122
+ raise ValueError('The number of pack indices must not exceed the pack size')
123
+
124
+
125
+ def make_complementary_pack_idx(pack_idx: Tensor, *, pack_size: int) -> Tensor:
126
+ """Make the indices of the pack members that are not in `pack_idx`."""
127
+ check_pack_idx(pack_idx, pack_size)
128
+
129
+ mask = torch.ones(pack_size, dtype=torch.bool, device=pack_idx.device)
130
+ mask[pack_idx] = False
131
+ # The function could have a keyword argument like this:
132
+ # `assume_unique_nonnegative_pack_idx: bool = False`; so that when it is `True`,
133
+ # then `torch.nonzero_static` is used to avoid the device-to-host synchronization.
134
+ # However, to keep it simple and less error-prone for users, the argument is not
135
+ # added for now.
136
+ return torch.nonzero(mask)[:, 0]
137
+
138
+
139
+ @typing.overload
140
+ def _get_tensor_pack(x: None, pack_idx: Tensor | None) -> None: ...
141
+
142
+
143
+ @typing.overload
144
+ def _get_tensor_pack(x: Tensor, pack_idx: Tensor | None) -> Tensor: ...
145
+
146
+
147
+ def _get_tensor_pack(x, pack_idx):
148
+ if x is None or pack_idx is None:
149
+ return x
150
+
151
+ _check_idx(pack_idx)
152
+ # `index_select` seems to be a little bit faster for the expected workloads than the
153
+ # square-brackets-based indexing. However, it requires nonnegative indices.
154
+ return x.index_select(
155
+ PACK_DIM, torch.where(pack_idx < 0, pack_idx + x.shape[PACK_DIM], pack_idx)
156
+ )
157
+
158
+
159
+ class ParameterPack(nn.Parameter):
160
+ """Parameter pack.
161
+
162
+ Module packs _must_ use this class to store their trainable parameters.
163
+ """
164
+
165
+
166
+ class _BufferPackMeta(nn.parameter._BufferMeta): # ty: ignore[unresolved-attribute]
167
+ """
168
+ `BufferPack` relies on this metaclass for `isinstance(..., BufferPack)` to work
169
+ as one would expect. Otherwise, because of `torch.nn.Buffer` implementation details,
170
+ the following (unexpected) behavior would take place:
171
+
172
+ >>> class NaiveBufferPack(nn.Buffer):
173
+ ... pass
174
+ ...
175
+ >>> naive_buffer_pack = NaiveBufferPack(torch.zeros(1))
176
+ >>> isinstance(naive_buffer_pack, NaiveBufferPack)
177
+ False
178
+ """
179
+
180
+ def __instancecheck__(self, instance):
181
+ return super().__instancecheck__(instance) or (
182
+ self is BufferPack
183
+ and isinstance(instance, torch.Tensor)
184
+ and getattr(instance, '_is_buffer_pack', False)
185
+ )
186
+
187
+
188
+ @typing.final
189
+ class BufferPack(nn.Buffer, metaclass=_BufferPackMeta):
190
+ """Buffer pack.
191
+
192
+ Module packs _must_ use this class to store their non-trainable tensors.
193
+ """
194
+
195
+ def __new__(cls, *args, **kwargs):
196
+ t = nn.Buffer.__new__(cls, *args, **kwargs)
197
+ t._is_buffer_pack = True # ty: ignore[unresolved-attribute]
198
+ return t
199
+
200
+ def __init_subclass__(cls, *args, **kwargs):
201
+ raise TypeError('Subclassing is not allowed')
202
+
203
+
204
+ _TensorPackT = TypeVar('_TensorPackT', bound='ParameterPack | BufferPack')
205
+
206
+
207
+ def _make_tensor_pack_like(x: _TensorPackT, *, data: Tensor) -> _TensorPackT:
208
+ # `type` works as expected for `ParameterPack` and inherited classes, but not for
209
+ # `BufferPack`. This is why inheriting from `BufferPack` is not allowed.
210
+ cls = type(x) if isinstance(x, ParameterPack) else BufferPack
211
+ return cls(data).requires_grad_(x.requires_grad) # ty: ignore[invalid-return-type]
212
+
213
+
214
+ def _load_tensor_packs_from_state_dict_at(
215
+ module: nn.Module,
216
+ state_dict: dict[str, Tensor],
217
+ prefix: str,
218
+ *,
219
+ pack_idx: Tensor,
220
+ state_dict_idx: Tensor | None,
221
+ ) -> None:
222
+ with torch.no_grad():
223
+ for name, x in itertools.chain(
224
+ module.named_parameters(recurse=False, remove_duplicate=False),
225
+ (
226
+ (name, value)
227
+ for name, value in module.named_buffers(
228
+ recurse=False, remove_duplicate=False
229
+ )
230
+ if name not in module._non_persistent_buffers_set
231
+ ),
232
+ ):
233
+ if not isinstance(x, ParameterPack | BufferPack):
234
+ raise TypeError(
235
+ 'The module contains parameters or buffers that do not support pack'
236
+ ' operations'
237
+ )
238
+
239
+ key = prefix + name
240
+ value = state_dict[key]
241
+
242
+ if not isinstance(value, Tensor):
243
+ raise TypeError('State dictionary values must be tensors')
244
+ if value.ndim != x.ndim:
245
+ raise ValueError(
246
+ 'The source and destination tensors have incompatible shapes'
247
+ )
248
+ if value.shape[1:] != x.shape[1:]:
249
+ raise ValueError(
250
+ 'The source and destination tensors have incompatible shapes'
251
+ )
252
+ if state_dict_idx is None and value.shape[PACK_DIM] != len(pack_idx):
253
+ raise ValueError(
254
+ 'The state dictionary contains a different number of pack members'
255
+ ' than the destination selection'
256
+ )
257
+
258
+ x[pack_idx] = value if state_dict_idx is None else value[state_dict_idx]
259
+
260
+
261
+ # >>> Module packs
262
+
263
+
264
+ class _ModulePackBuffers(OrderedDict):
265
+ """
266
+ This class ensures that reassigning a module pack's field that is a buffer pack
267
+ will result in a field that is also a buffer pack.
268
+ """
269
+
270
+ def __setitem__(self, key, value) -> None:
271
+ current_value = self.get(key)
272
+ if (
273
+ current_value is not None
274
+ # The following checks are the reason why `_BufferPackMeta` is needed.
275
+ and isinstance(current_value, BufferPack)
276
+ and not isinstance(value, BufferPack)
277
+ ):
278
+ value = BufferPack(value)
279
+ return super().__setitem__(key, value)
280
+
281
+
282
+ class ModulePack(nn.Module, abc.ABC):
283
+ """The base class for module packs.
284
+
285
+ A module pack is a set of modules of the same family that may differ in their
286
+ hyperparameters.
287
+
288
+ **Examples**
289
+
290
+ - `DropoutPack`: a pack of dropout layers with different dropout ratios.
291
+ - `LinearPack`: a pack of linear layers with different input and output shapes.
292
+
293
+ **Conventions**
294
+
295
+ - Module packs _must_ use `ParameterPack` and `BufferPack` classes to store
296
+ parameters and buffers rather than `torch.nn.Parameter` and `torch.nn.Buffer`.
297
+ Otherwise, the behavior will be incorrect.
298
+ - Module pack constructors _must_ take a required `pack_size: int` argument denoting
299
+ the number of modules in the pack.
300
+ - Module pack constructors _must_ take per-pack-member hyperparameters as lists of
301
+ per-pack-member hyperparameter values.
302
+ - Module packs _may_ implement the `replace` method for replacing some of the
303
+ current members with new ones, with potentially different hyperparameters and
304
+ reinitialized parameters. When doing this during training, also call the optimizer
305
+ pack's `replace` method for the same members to reset their optimizer state.
306
+ - Module pack forward passes _may_ take an additional `pack_idx: Tensor | None`
307
+ argument to perform a forward pass with a selected set of pack members. Inputs
308
+ with a pack dimension must already follow the order and repetitions in `pack_idx`;
309
+ the forward pass selects module state, not input rows. Note that selecting pack
310
+ members with `pack_idx` copies their selected entries. Therefore, this mechanism
311
+ should be used only when this overhead is acceptable, or when there are no
312
+ alternatives (e.g., for performing a training step for a subset of pack members).
313
+ As an example of the second use case, `MLPBackbonePack` currently relies on this
314
+ mechanism for depth-varying MLPs. Examples of use cases where `pack_idx` should
315
+ _not_ be used: (1) permanently disabling some of the pack members; instead, use
316
+ `ModulePack.remove`; (2) evaluating some of the pack members; instead, use
317
+ `ModulePack.temporarily_select`.
318
+ """
319
+
320
+ def __init__(self, *args, pack_size: int, **kwargs) -> None:
321
+ del pack_size
322
+ super().__init__(*args, **kwargs)
323
+ self._buffers = _ModulePackBuffers()
324
+
325
+ @property
326
+ @abc.abstractmethod
327
+ def pack_size(self) -> int:
328
+ """The pack size."""
329
+
330
+ def try_get_pack_size(self) -> int | None:
331
+ """Get the pack size, or `None` if it cannot be inferred."""
332
+ try:
333
+ return self.pack_size
334
+ except RuntimeError:
335
+ return None
336
+
337
+ def load_state_dict_at(
338
+ self,
339
+ state_dict: dict[str, Tensor],
340
+ *,
341
+ pack_idx: Tensor,
342
+ state_dict_idx: Tensor | None = None,
343
+ ) -> None:
344
+ """Load state dictionary for specific pack members.
345
+
346
+ Source rows are matched positionally to destination rows selected by `pack_idx`.
347
+ If `state_dict_idx` is omitted, source tensors must contain exactly
348
+ `len(pack_idx)` members; otherwise, `state_dict_idx` selects the source rows.
349
+
350
+ Warning:
351
+ This method does _not_ call the hooks registered for `load_state_dict`.
352
+ """
353
+ _check_module_pack(self)
354
+ check_pack_idx(pack_idx, self.pack_size)
355
+ if state_dict_idx is not None:
356
+ _check_idx(state_dict_idx)
357
+ if pack_idx.shape != state_dict_idx.shape:
358
+ raise ValueError(
359
+ 'The source and destination selections contain different numbers of'
360
+ ' pack members'
361
+ )
362
+ if self.state_dict().keys() != state_dict.keys():
363
+ raise ValueError(
364
+ 'The state dictionary has missing or unexpected keys for this module'
365
+ )
366
+
367
+ def load(
368
+ module: nn.Module, local_state_dict: dict[str, Tensor], prefix: str
369
+ ) -> None:
370
+ (
371
+ type(module)._load_from_state_dict_at
372
+ if isinstance(module, ModulePack)
373
+ else _load_tensor_packs_from_state_dict_at
374
+ )(
375
+ module, # ty: ignore[invalid-argument-type]
376
+ local_state_dict,
377
+ prefix,
378
+ pack_idx=pack_idx,
379
+ state_dict_idx=state_dict_idx,
380
+ )
381
+
382
+ # `.named_children()` is not used, because it removes duplicates.
383
+ for name, child in module._modules.items():
384
+ if child is not None:
385
+ child_prefix = prefix + name + '.'
386
+ child_state_dict = {
387
+ k: v
388
+ for k, v in local_state_dict.items()
389
+ if k.startswith(child_prefix)
390
+ }
391
+ load(child, child_state_dict, child_prefix)
392
+
393
+ load(self, state_dict, '')
394
+
395
+ def _load_from_state_dict_at(
396
+ self,
397
+ state_dict: dict[str, Tensor],
398
+ prefix: str,
399
+ *,
400
+ pack_idx: Tensor,
401
+ state_dict_idx: Tensor | None,
402
+ ) -> None:
403
+ _load_tensor_packs_from_state_dict_at(
404
+ self,
405
+ state_dict,
406
+ prefix,
407
+ pack_idx=pack_idx,
408
+ state_dict_idx=state_dict_idx,
409
+ )
410
+
411
+ def remove(self, *, pack_idx: Tensor) -> dict[ParameterPack, ParameterPack]:
412
+ """Remove some members from the module pack.
413
+
414
+ The method replaces the current parameter packs and buffer packs with smaller
415
+ ones. To continue training, pass the returned parameter mapping and the same
416
+ `pack_idx` to `tabpackage.optim.optimizer_pack_remove` to update the optimizer.
417
+
418
+ Args:
419
+ pack_idx: the pack member indices to remove.
420
+ Returns:
421
+ The mapping from the replaced parameter packs to the new ones.
422
+ """
423
+ _check_module_pack(self)
424
+ check_pack_idx(pack_idx, self.pack_size)
425
+ # Duplicate indices would make the `PackView` size updates incorrect, so the
426
+ # cost of device-to-host synchronization is paid here to avoid the issue.
427
+ if len(pack_idx) != len(torch.unique(pack_idx)):
428
+ raise ValueError('Pack indices to remove must be unique')
429
+
430
+ keep_pack_idx = None
431
+ old_to_new_parameters = {}
432
+ for name, x in itertools.chain(self.named_parameters(), self.named_buffers()):
433
+ if isinstance(x, ParameterPack | BufferPack):
434
+ if keep_pack_idx is None:
435
+ keep_pack_idx = make_complementary_pack_idx(
436
+ pack_idx, pack_size=x.shape[PACK_DIM]
437
+ )
438
+
439
+ if '.' in name:
440
+ submodule_name, attr = name.rsplit('.', 1)
441
+ submodule = self.get_submodule(submodule_name)
442
+ else:
443
+ submodule = self
444
+ attr = name
445
+
446
+ new_x = _make_tensor_pack_like(x, data=x.data[keep_pack_idx])
447
+ if isinstance(x, BufferPack):
448
+ submodule.register_buffer(
449
+ attr,
450
+ new_x,
451
+ persistent=attr not in submodule._non_persistent_buffers_set,
452
+ )
453
+ else:
454
+ setattr(submodule, attr, new_x)
455
+
456
+ if isinstance(x, ParameterPack):
457
+ assert isinstance(new_x, ParameterPack)
458
+ old_to_new_parameters[x] = new_x
459
+
460
+ for submodule in self.modules():
461
+ if isinstance(submodule, PackView):
462
+ submodule._pack_size -= len(pack_idx)
463
+
464
+ return old_to_new_parameters
465
+
466
+ # A potential alternative to the following method could be `ModulePack.index`
467
+ # that would produce a copy of the original module with selected pack members.
468
+ # Contrary to that hypothetical method, this method performs the minimal possible
469
+ # number of copy operations (one per tensor pack).
470
+ @contextlib.contextmanager
471
+ def temporarily_select(self, *, pack_idx: Tensor):
472
+ """Temporarily use specified pack members in evaluation mode.
473
+
474
+ Args:
475
+ pack_idx: the indices of the pack members to use.
476
+
477
+ The selection is performed literally based on the provided `pack_idx`. That is,
478
+ the order of the new pack members will follow that of `pack_idx`, and repeating
479
+ values in `pack_idx` means repeating pack members, which in particular can
480
+ result in a module pack of a larger size than the original.
481
+
482
+ Technically, the method temporarily replaces all parameter packs and buffer
483
+ packs with new ones. This in particular means that all in-place modifications of
484
+ the tensor packs within the context are discarded on exit.
485
+ """
486
+ _check_module_pack(self)
487
+ check_pack_idx(pack_idx, None)
488
+
489
+ for submodule in self.modules():
490
+ # Since the method replaces the original tensor objects with new ones,
491
+ # which in particular means disconnecting the module from its optimizer,
492
+ # training the module in its temporary state is rather an unusual use case.
493
+ if submodule.training:
494
+ raise RuntimeError(
495
+ 'The module and all its submodules must be in evaluation mode to'
496
+ ' use this method'
497
+ )
498
+
499
+ original_modules_and_tensors = []
500
+ original_pack_views = []
501
+
502
+ try:
503
+ for name, x in itertools.chain(
504
+ self.named_parameters(), self.named_buffers()
505
+ ):
506
+ if isinstance(x, ParameterPack | BufferPack):
507
+ if '.' in name:
508
+ submodule_name, attr = name.rsplit('.', 1)
509
+ submodule = self.get_submodule(submodule_name)
510
+ else:
511
+ submodule = self
512
+ attr = name
513
+ setattr(
514
+ submodule,
515
+ attr,
516
+ _make_tensor_pack_like(x, data=x.data[pack_idx]),
517
+ )
518
+ original_modules_and_tensors.append((submodule, attr, x))
519
+
520
+ for submodule in self.modules():
521
+ if isinstance(submodule, PackView):
522
+ original_pack_views.append((submodule, submodule.pack_size))
523
+ submodule._pack_size = len(pack_idx)
524
+
525
+ yield
526
+
527
+ finally:
528
+ for submodule, attr, parameter in original_modules_and_tensors:
529
+ setattr(submodule, attr, parameter)
530
+ for submodule, pack_size in original_pack_views:
531
+ submodule._pack_size = pack_size
532
+
533
+
534
+ def _check_module_pack(module: ModulePack) -> None:
535
+ for word, cls, iter_named_tensors in (
536
+ ('parameter', ParameterPack, module.named_parameters),
537
+ ('buffer', BufferPack, module.named_buffers),
538
+ ):
539
+ values_to_names = {}
540
+ for name, value in iter_named_tensors(remove_duplicate=False):
541
+ if value in values_to_names:
542
+ raise RuntimeError(
543
+ f'The module pack contains aliased {word}s, which is not allowed:'
544
+ f' "{values_to_names[value]}" and "{name}"' # ty: ignore[invalid-argument-type]
545
+ )
546
+ if not isinstance(value, cls):
547
+ raise RuntimeError( # noqa: TRY004
548
+ f'In module packs, {cls} must be used to store {word}s.'
549
+ f' However, the {word} "{name}" is stored as `{type(value)}`.'
550
+ )
551
+ values_to_names[value] = name
552
+
553
+
554
+ def _prepare_positive_integer_pack(
555
+ value: int | list[int], max_value: int | None, *, pack_size: int, **factory_kwargs
556
+ ) -> tuple[BufferPack | None, int]:
557
+ if isinstance(value, list):
558
+ # The values are specified for all pack members separately.
559
+ if len(value) != pack_size:
560
+ raise ValueError(
561
+ 'The number of per-member hyperparameter values does not match the pack'
562
+ ' size'
563
+ )
564
+ if not all(x > 0 for x in value):
565
+ raise ValueError('All values must be positive')
566
+
567
+ if max_value is None:
568
+ max_value = max(value)
569
+ elif not all(x <= max_value for x in value):
570
+ raise ValueError('Values must not exceed `max_value`')
571
+
572
+ value_buffer = BufferPack(torch.tensor(value, **factory_kwargs))
573
+ else:
574
+ # The same value for all pack members.
575
+ if value <= 0:
576
+ raise ValueError('The value must be positive')
577
+
578
+ if max_value is None:
579
+ max_value = value
580
+ elif max_value != value:
581
+ raise ValueError('The configured maximum must equal the shared value')
582
+
583
+ value_buffer = None
584
+
585
+ return value_buffer, max_value
586
+
587
+
588
+ def _check_replace_arguments(
589
+ pack_idx: Tensor, *hyperparameter_lists: list | None
590
+ ) -> None:
591
+ if not all(x is None or len(x) == len(pack_idx) for x in hyperparameter_lists):
592
+ raise ValueError(
593
+ 'Each provided hyperparameter list must contain one value for each selected'
594
+ ' pack member'
595
+ )
596
+
597
+
598
+ class LinearPack(ModulePack):
599
+ """A pack of linear layers.
600
+
601
+ **Shape**
602
+
603
+ - Input: `(pack_size, batch_size, self.max_in_features)`
604
+ - Output: `(pack_size, batch_size, self.max_out_features)`
605
+
606
+ **Implementation details**
607
+
608
+ * The module assumes that the input is already properly masked according to
609
+ `in_features` passed to the constructor. Therefore, `in_features` is only used for
610
+ the parameter initialization in `reset_parameters`, but not in `forward`.
611
+ * The module properly masks its output in `forward` according to `out_features`
612
+ passed to the constructor.
613
+
614
+ **Examples**
615
+
616
+ The same layer shapes for all pack members:
617
+
618
+ >>> m = LinearPack(4, 5, pack_size=2)
619
+ >>> m.pack_size
620
+ 2
621
+ >>> x = torch.randn(2, 3, 4)
622
+ >>> m(x).shape # Input masking is assumed
623
+ torch.Size([2, 3, 5])
624
+
625
+ Different layer shapes:
626
+
627
+ >>> m = LinearPack([4, 5], [6, 7], pack_size=2)
628
+ >>> m.max_in_features
629
+ 5
630
+ >>> m.max_out_features
631
+ 7
632
+ >>> x = torch.randn(2, 3, 5)
633
+ >>> m(x).shape # Input masking is assumed
634
+ torch.Size([2, 3, 7])
635
+
636
+ Different layer shapes, with spare capacity for replacing layers with larger ones
637
+ later:
638
+
639
+ >>> m = LinearPack(
640
+ ... [4, 5], [6, 7], max_in_features=8, max_out_features=9, pack_size=2
641
+ ... )
642
+ >>> m.max_in_features
643
+ 8
644
+ >>> m.max_out_features
645
+ 9
646
+ >>> x = torch.randn(2, 3, 8)
647
+ >>> m(x).shape # Input masking is assumed
648
+ torch.Size([2, 3, 9])
649
+ """
650
+
651
+ def __init__(
652
+ self,
653
+ in_features: int | list[int],
654
+ out_features: int | list[int],
655
+ bias: bool = True,
656
+ *,
657
+ max_in_features: int | None = None,
658
+ max_out_features: int | None = None,
659
+ pack_size: int,
660
+ dtype: torch.dtype | None = None,
661
+ device: str | torch.device | None = None,
662
+ ) -> None:
663
+ if pack_size <= 0:
664
+ raise ValueError('The pack size must be positive')
665
+
666
+ factory_kwargs: KWArgs = {'dtype': dtype, 'device': device}
667
+ in_features_buffer, max_in_features = _prepare_positive_integer_pack(
668
+ in_features, max_in_features, pack_size=pack_size, **factory_kwargs
669
+ )
670
+ out_features_buffer, max_out_features = _prepare_positive_integer_pack(
671
+ out_features, max_out_features, pack_size=pack_size, **factory_kwargs
672
+ )
673
+
674
+ super().__init__(pack_size=pack_size)
675
+ self.in_features = in_features_buffer
676
+ self.out_features = out_features_buffer
677
+ self.weight = ParameterPack(
678
+ torch.empty(pack_size, max_out_features, max_in_features, **factory_kwargs)
679
+ )
680
+ self.bias = (
681
+ ParameterPack(torch.empty(pack_size, max_out_features, **factory_kwargs))
682
+ if bias
683
+ else None
684
+ )
685
+
686
+ self.reset_parameters()
687
+
688
+ @property
689
+ def max_in_features(self) -> int:
690
+ """The maximum supported number of input features."""
691
+ return self.weight.shape[-1]
692
+
693
+ @property
694
+ def max_out_features(self) -> int:
695
+ """The maximum supported number of output features."""
696
+ return self.weight.shape[-2]
697
+
698
+ @property
699
+ def pack_size(self) -> int:
700
+ return self.weight.shape[PACK_DIM]
701
+
702
+ def reset_parameters(self, *, pack_idx: Tensor | None = None) -> None:
703
+ """Initialize the parameters."""
704
+ if pack_idx is not None:
705
+ check_pack_idx(pack_idx, self.pack_size)
706
+
707
+ if self.in_features is None:
708
+ d_in_rsqrt = self.max_in_features**-0.5
709
+ init_args = (-d_in_rsqrt, d_in_rsqrt)
710
+
711
+ for p in (self.weight, self.bias):
712
+ if p is not None:
713
+ if pack_idx is None:
714
+ nn.init.uniform_(p, *init_args)
715
+ else:
716
+ with torch.no_grad():
717
+ p[pack_idx] = torch.empty(
718
+ len(pack_idx),
719
+ *p.shape[1:],
720
+ dtype=p.dtype,
721
+ device=p.device,
722
+ ).uniform_(*init_args)
723
+
724
+ else:
725
+ in_features = _get_tensor_pack(self.in_features, pack_idx)
726
+ d_in_rsqrt = in_features.float().rsqrt()
727
+ d_in_rsqrt_neg = -d_in_rsqrt
728
+
729
+ for p in (self.weight, self.bias):
730
+ if p is not None:
731
+ single_shape = p.shape[1:]
732
+ broadcast_idx = (slice(None), *((None,) * (p.ndim - 1)))
733
+ p_init = torch.lerp(
734
+ d_in_rsqrt_neg[broadcast_idx].expand(-1, *single_shape),
735
+ d_in_rsqrt[broadcast_idx].expand(-1, *single_shape),
736
+ torch.rand(
737
+ self.pack_size if pack_idx is None else len(pack_idx),
738
+ *single_shape,
739
+ dtype=p.dtype,
740
+ device=p.device,
741
+ ),
742
+ )
743
+ with torch.no_grad():
744
+ if pack_idx is None:
745
+ p.copy_(p_init)
746
+ else:
747
+ p[pack_idx] = p_init
748
+
749
+ def replace(
750
+ self,
751
+ *,
752
+ pack_idx: Tensor,
753
+ in_features: list[int] | None = None,
754
+ out_features: list[int] | None = None,
755
+ ) -> None:
756
+ check_pack_idx(pack_idx, self.pack_size)
757
+ _check_replace_arguments(pack_idx, in_features, out_features)
758
+
759
+ updates = []
760
+ for buffer, max_n_features, n_features in [
761
+ (self.in_features, self.max_in_features, in_features),
762
+ (self.out_features, self.max_out_features, out_features),
763
+ ]:
764
+ if n_features is not None:
765
+ if buffer is None:
766
+ raise RuntimeError(
767
+ 'Cannot replace feature counts that are shared by all pack'
768
+ ' members'
769
+ )
770
+ if not all(0 < x <= max_n_features for x in n_features):
771
+ raise ValueError(
772
+ 'Feature counts must be positive and must not exceed the'
773
+ ' allocated maximum'
774
+ )
775
+
776
+ # Validate both feature counts before updating either buffer.
777
+ updates.append(
778
+ (
779
+ buffer,
780
+ torch.tensor(
781
+ n_features, dtype=buffer.dtype, device=buffer.device
782
+ ),
783
+ )
784
+ )
785
+ for buffer, values in updates:
786
+ buffer[pack_idx] = values
787
+
788
+ # The parameters are reset _after_ updating the hyperparameters, because the
789
+ # initialization depends on the hyperparameter values.
790
+ self.reset_parameters(pack_idx=pack_idx)
791
+
792
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
793
+ if x.ndim != 3:
794
+ raise ValueError('The input must have three dimensions')
795
+ if x.shape[-1] != self.max_in_features:
796
+ raise ValueError(
797
+ 'The number of input features must be equal to the maximum supported'
798
+ ' number of features'
799
+ )
800
+
801
+ weight = _get_tensor_pack(self.weight, pack_idx)
802
+ bias = _get_tensor_pack(self.bias, pack_idx)
803
+ out_features = _get_tensor_pack(self.out_features, pack_idx)
804
+
805
+ x = (
806
+ torch.bmm(x, weight.transpose(-2, -1))
807
+ if bias is None
808
+ else torch.baddbmm(bias.unsqueeze(BATCH_DIM), x, weight.transpose(-2, -1))
809
+ )
810
+ if out_features is not None:
811
+ output_mask = (
812
+ torch.arange(self.max_out_features, device=x.device)[None]
813
+ < out_features[:, None]
814
+ )
815
+ x = x * output_mask.to(x.dtype).unsqueeze(BATCH_DIM)
816
+ return x
817
+
818
+
819
+ class DropoutPack(ModulePack):
820
+ """A pack of dropout layers.
821
+
822
+ Dropout probabilities must be in `[0, 1)`.
823
+
824
+ **Shapes**
825
+
826
+ - Input: `(pack_size, *)`
827
+ - Output: `(pack_size, *)`
828
+
829
+ **Examples**
830
+
831
+ The same dropout ratio for all pack members:
832
+
833
+ >>> m = DropoutPack(0.5, pack_size=2)
834
+ >>> x = torch.randn(2, 3, 4)
835
+ >>> m(x).shape
836
+ torch.Size([2, 3, 4])
837
+
838
+ Different dropout ratios:
839
+
840
+ >>> m = DropoutPack([0.1, 0.5], pack_size=2)
841
+ >>> x = torch.randn(2, 3, 4)
842
+ >>> m(x).shape
843
+ torch.Size([2, 3, 4])
844
+ """
845
+
846
+ def __init__(self, p: float | list[float], *, pack_size: int) -> None:
847
+ if isinstance(p, int | float):
848
+ if not math.isfinite(p) or p < 0.0 or p >= 1.0:
849
+ raise ValueError('The dropout probability must be finite and in [0, 1)')
850
+ else:
851
+ if len(p) != pack_size:
852
+ raise ValueError('Provide one dropout probability per pack member')
853
+ if any(not math.isfinite(x) or x < 0.0 or x >= 1.0 for x in p):
854
+ raise ValueError('Dropout probabilities must be finite and in [0, 1)')
855
+
856
+ super().__init__(pack_size=pack_size)
857
+ self.p = (
858
+ float(p)
859
+ if isinstance(p, int | float)
860
+ else BufferPack(torch.tensor([float(x) for x in p]))
861
+ )
862
+
863
+ @property
864
+ def pack_size(self) -> int:
865
+ if isinstance(self.p, int | float):
866
+ raise RuntimeError('Cannot infer the pack size') # noqa: TRY004
867
+ return self.p.shape[PACK_DIM]
868
+
869
+ def replace(self, *, pack_idx: Tensor, p: list[float] | None = None) -> None:
870
+ if p is None:
871
+ return
872
+
873
+ if not isinstance(self.p, BufferPack):
874
+ raise RuntimeError( # noqa: TRY004
875
+ 'Cannot replace dropout probabilities when one probability is shared by'
876
+ ' all pack members'
877
+ )
878
+
879
+ check_pack_idx(pack_idx, self.pack_size)
880
+ _check_replace_arguments(pack_idx, p)
881
+ if any(not math.isfinite(x) or x < 0.0 or x >= 1.0 for x in p):
882
+ raise ValueError('Dropout probabilities must be finite and in [0, 1)')
883
+
884
+ self.p[pack_idx] = torch.tensor(p, dtype=self.p.dtype, device=self.p.device)
885
+
886
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
887
+ if pack_idx is not None:
888
+ check_pack_idx(pack_idx, self.try_get_pack_size())
889
+
890
+ # Passing the tuple instead of `int | float` keeps code traceable by
891
+ # PyTorch 2.7-2.9 strict export.
892
+ if isinstance(self.p, (int, float)):
893
+ return nn.functional.dropout(x, p=self.p, training=self.training)
894
+
895
+ p = _get_tensor_pack(self.p, pack_idx)
896
+
897
+ if self.training:
898
+ p_keep = 1.0 - p
899
+ p_keep = p_keep[(slice(None), *((None,) * (x.ndim - 1)))]
900
+ mask = torch.empty_like(x).bernoulli_(p_keep.expand_as(x))
901
+ return mask.div_(p_keep) * x
902
+ else:
903
+ return x
904
+
905
+
906
+ class LeakyReLUPack(ModulePack):
907
+ """A pack of leaky ReLU activations.
908
+
909
+ This class is not used in the original paper experiments and exists as an additional
910
+ illustration of how the "Module Pack" pattern can be used.
911
+
912
+ **Shapes**
913
+
914
+ - Input: `(pack_size, *)`
915
+ - Output: `(pack_size, *)`
916
+
917
+ **Examples**
918
+
919
+ >>> m = LeakyReLUPack(negative_slope=[0.01, 0.02], pack_size=2)
920
+ >>> x = torch.randn(2, 3)
921
+ >>> m(x).shape
922
+ torch.Size([2, 3])
923
+ """
924
+
925
+ def __init__(self, *, negative_slope: float | list[float], pack_size: int) -> None:
926
+ if isinstance(negative_slope, int | float):
927
+ if not math.isfinite(negative_slope) or negative_slope <= 0:
928
+ raise ValueError('The slope must be finite and positive')
929
+ else:
930
+ if len(negative_slope) != pack_size:
931
+ raise ValueError(
932
+ 'The size of the list of per-member slope values must match the'
933
+ ' pack size'
934
+ )
935
+ if any(not math.isfinite(x) or x <= 0 for x in negative_slope):
936
+ raise ValueError('Slopes must be finite and positive')
937
+
938
+ super().__init__(pack_size=pack_size)
939
+ self.negative_slope = (
940
+ BufferPack(torch.tensor([float(x) for x in negative_slope]))
941
+ if isinstance(negative_slope, list)
942
+ else float(negative_slope)
943
+ )
944
+
945
+ @property
946
+ def pack_size(self) -> int:
947
+ if isinstance(self.negative_slope, int | float):
948
+ raise RuntimeError('Cannot infer the pack size') # noqa: TRY004
949
+ return self.negative_slope.shape[PACK_DIM]
950
+
951
+ def replace(
952
+ self, *, pack_idx: Tensor, negative_slope: list[float] | None = None
953
+ ) -> None:
954
+ if negative_slope is None:
955
+ return
956
+
957
+ if not isinstance(self.negative_slope, BufferPack):
958
+ raise RuntimeError( # noqa: TRY004
959
+ 'Cannot replace slopes that are shared by all pack members'
960
+ )
961
+
962
+ check_pack_idx(pack_idx, self.pack_size)
963
+ _check_replace_arguments(pack_idx, negative_slope)
964
+ if any(not math.isfinite(x) or x <= 0 for x in negative_slope):
965
+ raise ValueError('Slopes must be finite and positive')
966
+
967
+ self.negative_slope[pack_idx] = torch.tensor(
968
+ negative_slope,
969
+ dtype=self.negative_slope.dtype,
970
+ device=self.negative_slope.device,
971
+ )
972
+
973
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
974
+ if pack_idx is not None:
975
+ check_pack_idx(pack_idx, self.try_get_pack_size())
976
+
977
+ # Passing the tuple instead of `int | float` keeps code traceable by
978
+ # PyTorch 2.7-2.9 strict export.
979
+ if isinstance(self.negative_slope, (int, float)):
980
+ return nn.functional.leaky_relu(x, self.negative_slope)
981
+
982
+ negative_slope = _get_tensor_pack(self.negative_slope, pack_idx)
983
+ unsqueeze_idx = (slice(None), *((None,) * (x.ndim - 1)))
984
+ return torch.where(x >= 0, x, x * negative_slope[unsqueeze_idx])
985
+
986
+
987
+ def _make_activation(type: type[nn.Module], *, pack_size: int, **kwargs) -> nn.Module:
988
+ cls = type
989
+ del type
990
+
991
+ return cls(
992
+ **kwargs, **({'pack_size': pack_size} if issubclass(cls, ModulePack) else {})
993
+ )
994
+
995
+
996
+ class MLPBackbonePack(ModulePack):
997
+ """A pack of multilayer perceptron backbones.
998
+
999
+ **Examples**
1000
+
1001
+ >>> m = MLPBackbonePack(
1002
+ ... d_in=[4, 5],
1003
+ ... n_blocks=[6, 7],
1004
+ ... d_block=[8, 9],
1005
+ ... dropout=[0.1, 0.2],
1006
+ ... pack_size=2,
1007
+ ... )
1008
+ >>> m.pack_size
1009
+ 2
1010
+ >>> x = torch.randn(2, 3, 5)
1011
+ >>> m(x).shape # Input masking is assumed
1012
+ torch.Size([2, 3, 9])
1013
+ """
1014
+
1015
+ class Block(ModulePack):
1016
+ """A pack of MLP blocks (linear layer + activation + dropout)."""
1017
+
1018
+ def __init__(
1019
+ self,
1020
+ *,
1021
+ activation: KWArgs,
1022
+ dropout: float | list[float],
1023
+ pack_size: int,
1024
+ **linear_kwargs,
1025
+ ) -> None:
1026
+ super().__init__(pack_size=pack_size)
1027
+ self.linear = LinearPack(pack_size=pack_size, **linear_kwargs)
1028
+ self.activation = _make_activation(pack_size=pack_size, **activation)
1029
+ self.dropout = DropoutPack(dropout, pack_size=pack_size)
1030
+
1031
+ @property
1032
+ def pack_size(self) -> int:
1033
+ return self.linear.pack_size
1034
+
1035
+ def replace(
1036
+ self,
1037
+ *,
1038
+ pack_idx: Tensor,
1039
+ in_features: list[int] | None = None,
1040
+ out_features: list[int] | None = None,
1041
+ activation: KWArgs | None = None,
1042
+ dropout: list[float] | None = None,
1043
+ ) -> None:
1044
+ self.linear.replace(
1045
+ pack_idx=pack_idx, in_features=in_features, out_features=out_features
1046
+ )
1047
+
1048
+ activation_replace = (
1049
+ getattr(self.activation, 'replace', None)
1050
+ if isinstance(self.activation, ModulePack)
1051
+ else None
1052
+ )
1053
+ if activation_replace is not None and not inspect.ismethod(
1054
+ activation_replace
1055
+ ):
1056
+ raise RuntimeError(
1057
+ 'The activation module does not support member replacement'
1058
+ )
1059
+ if activation is not None and activation_replace is None:
1060
+ raise RuntimeError(
1061
+ 'The activation module does not support member replacement'
1062
+ )
1063
+
1064
+ if activation_replace is not None:
1065
+ activation_replace(
1066
+ pack_idx=pack_idx, **({} if activation is None else activation)
1067
+ )
1068
+
1069
+ self.dropout.replace(pack_idx=pack_idx, p=dropout)
1070
+
1071
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1072
+ x = self.linear(x, pack_idx)
1073
+ x = (
1074
+ self.activation(x, pack_idx)
1075
+ if isinstance(self.activation, ModulePack)
1076
+ else self.activation(x)
1077
+ )
1078
+ x = self.dropout(x, pack_idx)
1079
+ return x
1080
+
1081
+ @enum.unique
1082
+ class ForwardImpl(enum.StrEnum):
1083
+ """The implementation of the forward pass."""
1084
+
1085
+ DENSE = enum.auto()
1086
+ """
1087
+ All blocks are applied to all pack members with masking. This implementation is
1088
+ usually faster for light workloads (e.g., small input batches) and makes the
1089
+ module easier to export.
1090
+ """
1091
+
1092
+ INDEXED = enum.auto()
1093
+ """
1094
+ In each block, only the pack members that are still active are computed. This
1095
+ implementation is faster for training (large) packs of depth-varying MLPs. For
1096
+ non-trivial export scenarios (e.g., using ONNX or `torch.export`) with this
1097
+ implementation:
1098
+
1099
+ - First, set the module's device and data type as needed.
1100
+ - Then, do a warmup forward pass _without_ passing `pack_idx`.
1101
+ - Then, export the module. The exported module must be used _without_ passing
1102
+ `pack_idx` to the forward pass.
1103
+
1104
+ Note:
1105
+ With this implementation, for depth-varying packs, passing `pack_idx` to the
1106
+ forward pass triggers a host-device synchronization in addition to the usual
1107
+ overhead associated with using `pack_idx`. See the documentation for
1108
+ `ModulePack` for more details on the `pack_idx` mechanism.
1109
+ """
1110
+
1111
+ class _ActiveLocalPackIdxCache(NamedTuple):
1112
+ n_blocks: BufferPack | None
1113
+ n_blocks_version: int | None
1114
+ value: list[Tensor | None]
1115
+
1116
+ @staticmethod
1117
+ def _default_load_state_dict_post_hook(m: 'MLPBackbonePack', _) -> None:
1118
+ m._reset_active_local_pack_idx_cache()
1119
+
1120
+ def __init__(
1121
+ self,
1122
+ *,
1123
+ d_in: int | list[int],
1124
+ max_d_in: int | None = None,
1125
+ n_blocks: int | list[int],
1126
+ d_block: int | list[int],
1127
+ activation: KWArgs | None = None,
1128
+ dropout: float | list[float],
1129
+ max_n_blocks: int | None = None,
1130
+ max_d_block: int | None = None,
1131
+ forward_impl: ForwardImpl | str = ForwardImpl.DENSE,
1132
+ pack_size: int,
1133
+ ) -> None:
1134
+ n_blocks_buffer, max_n_blocks = _prepare_positive_integer_pack(
1135
+ n_blocks, max_n_blocks, pack_size=pack_size
1136
+ )
1137
+
1138
+ super().__init__(pack_size=pack_size)
1139
+ cls = type(self)
1140
+ self.blocks = nn.ModuleList(
1141
+ [
1142
+ cls.Block(
1143
+ in_features=d_in if i == 0 else d_block,
1144
+ out_features=d_block,
1145
+ max_in_features=max_d_in if i == 0 else max_d_block,
1146
+ max_out_features=max_d_block,
1147
+ activation={'type': nn.ReLU} if activation is None else activation,
1148
+ dropout=dropout,
1149
+ pack_size=pack_size,
1150
+ )
1151
+ for i in range(max_n_blocks)
1152
+ ]
1153
+ )
1154
+
1155
+ self._max_n_blocks: Final = max_n_blocks
1156
+ self.n_blocks = n_blocks_buffer
1157
+ # For depth-varying packs, deeper blocks may have fewer active members. Their
1158
+ # indices are computed from `self.n_blocks` and cached to avoid repeating the
1159
+ # host-device synchronization required to compute them.
1160
+ #
1161
+ # Keep the cached `self.n_blocks` reference inside the tuple: assigning it
1162
+ # directly as a module attribute would register an aliased buffer, which
1163
+ # is forbidden in module packs.
1164
+ #
1165
+ # Finally, note that modifying `self.n_blocks` in place invalidates the cache.
1166
+ # Therefore, use `self.n_blocks_index_put_` for any in-place modifications.
1167
+ self._active_local_pack_idx_cache: (
1168
+ MLPBackbonePack._ActiveLocalPackIdxCache | None
1169
+ ) = None
1170
+
1171
+ self.register_load_state_dict_post_hook(
1172
+ MLPBackbonePack._default_load_state_dict_post_hook
1173
+ )
1174
+
1175
+ self._forward_impl = MLPBackbonePack.ForwardImpl(forward_impl)
1176
+
1177
+ @property
1178
+ def pack_size(self) -> int:
1179
+ return self.blocks[0].pack_size # ty: ignore[invalid-return-type]
1180
+
1181
+ @property
1182
+ def max_n_blocks(self) -> int:
1183
+ """The maximum supported number of blocks."""
1184
+ return self._max_n_blocks
1185
+
1186
+ @property
1187
+ def forward_impl(self) -> ForwardImpl:
1188
+ """The implementation of the forward pass."""
1189
+ return self._forward_impl
1190
+
1191
+ def set_forward_impl(self, impl: ForwardImpl | str) -> None:
1192
+ """Set the implementation of the forward pass."""
1193
+ self._forward_impl = MLPBackbonePack.ForwardImpl(impl)
1194
+
1195
+ def _reset_active_local_pack_idx_cache(self) -> None:
1196
+ self._active_local_pack_idx_cache = None
1197
+
1198
+ def _load_from_state_dict_at(
1199
+ self,
1200
+ state_dict: dict[str, Tensor],
1201
+ prefix: str,
1202
+ *,
1203
+ pack_idx: Tensor,
1204
+ state_dict_idx: Tensor | None,
1205
+ ) -> None:
1206
+ self._reset_active_local_pack_idx_cache()
1207
+ super()._load_from_state_dict_at(
1208
+ state_dict, prefix, pack_idx=pack_idx, state_dict_idx=state_dict_idx
1209
+ )
1210
+
1211
+ def n_blocks_index_put_(self, indices: tuple[Tensor, ...], values: Tensor) -> None:
1212
+ """Modify `n_blocks` in place.
1213
+
1214
+ This method is the only correct way to modify `n_blocks` in place. The method
1215
+ directly calls `self.n_blocks.index_put_` with the provided arguments, and
1216
+ updates the parts of the module's state that depend on `self.n_blocks`.
1217
+ """
1218
+ if self.n_blocks is None:
1219
+ raise RuntimeError(
1220
+ 'Cannot change block counts when all pack members share a fixed depth'
1221
+ )
1222
+
1223
+ self._reset_active_local_pack_idx_cache()
1224
+ self.n_blocks.index_put_(indices, values)
1225
+
1226
+ def replace(
1227
+ self,
1228
+ *,
1229
+ pack_idx: Tensor,
1230
+ d_in: list[int] | None = None,
1231
+ n_blocks: list[int] | None = None,
1232
+ d_block: list[int] | None = None,
1233
+ **kwargs,
1234
+ ) -> None:
1235
+ """
1236
+ All blocks of a given MLP share the replacement width, activation settings,
1237
+ and dropout rate.
1238
+ """
1239
+ check_pack_idx(pack_idx, self.pack_size)
1240
+ _check_replace_arguments(pack_idx, n_blocks)
1241
+
1242
+ if n_blocks is None:
1243
+ new_n_blocks = None
1244
+ else:
1245
+ if self.n_blocks is None:
1246
+ raise RuntimeError(
1247
+ 'Cannot change block counts when all pack members share a fixed'
1248
+ ' depth'
1249
+ )
1250
+ if not all(0 < x <= self.max_n_blocks for x in n_blocks):
1251
+ raise ValueError(
1252
+ 'Block counts must be positive and must not exceed `max_n_blocks`'
1253
+ )
1254
+
1255
+ # Defer the depth update until all block replacements succeed.
1256
+ new_n_blocks = torch.tensor(
1257
+ n_blocks, dtype=self.n_blocks.dtype, device=self.n_blocks.device
1258
+ )
1259
+
1260
+ # For simplicity, all the blocks are replaced regardless of `n_blocks`.
1261
+ for i_block, block in enumerate(self.blocks):
1262
+ block.replace(
1263
+ pack_idx=pack_idx,
1264
+ in_features=d_in if i_block == 0 else d_block,
1265
+ out_features=d_block,
1266
+ **kwargs,
1267
+ )
1268
+
1269
+ if new_n_blocks is not None:
1270
+ self.n_blocks_index_put_((pack_idx,), new_n_blocks)
1271
+
1272
+ def _forward_dense(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1273
+ if pack_idx is not None:
1274
+ check_pack_idx(pack_idx, self.pack_size)
1275
+
1276
+ n_blocks = _get_tensor_pack(self.n_blocks, pack_idx)
1277
+ for i_block, block in enumerate(self.blocks):
1278
+ x_new = block(x, pack_idx)
1279
+ # All members are active in the first block.
1280
+ if n_blocks is None or i_block == 0:
1281
+ x = x_new
1282
+ else:
1283
+ assert PACK_DIM == 0
1284
+ active_pack_mask = i_block < n_blocks
1285
+ x = torch.where(
1286
+ active_pack_mask.view(-1, *((1,) * (x.ndim - 1))), x_new, x
1287
+ )
1288
+ return x
1289
+
1290
+ # The method must return non-inference tensors to make the computed indices usable
1291
+ # in all modes regardless of the mode in which the indices are computed.
1292
+ @torch.inference_mode(False)
1293
+ def _compute_active_local_pack_idx_by_block(
1294
+ self, pack_idx: Tensor | None
1295
+ ) -> list[Tensor | None]:
1296
+ n_blocks = _get_tensor_pack(self.n_blocks, pack_idx)
1297
+
1298
+ if n_blocks is None:
1299
+ return [None for _ in range(self.max_n_blocks)]
1300
+
1301
+ else:
1302
+ pack_size = self.pack_size if pack_idx is None else len(pack_idx)
1303
+ # Consider the following example (`pack_size = 6`):
1304
+ # n_blocks = [1, 2, 3, 1, 2, 3]
1305
+ #
1306
+ # The i-th row of `mask` indicates which pack members are active
1307
+ # in the i-th block:
1308
+ # mask = [
1309
+ # [True, True, True, True, True, True],
1310
+ # [False, True, True, False, True, True],
1311
+ # [False, False, True, False, False, True],
1312
+ # ]
1313
+ #
1314
+ # `counts` contains the number of pack members active in each block:
1315
+ # counts = [6, 4, 2]
1316
+ #
1317
+ # `counts` enables the use of `nonzero_static` instead of `nonzero`,
1318
+ # reducing the number of host-device synchronizations in this function
1319
+ # to one (triggered by the `.tolist()` call below).
1320
+ mask = (
1321
+ torch.arange(self.max_n_blocks, device=n_blocks.device)[:, None]
1322
+ < n_blocks[None]
1323
+ )
1324
+ counts = mask.long().sum(1).tolist()
1325
+ return [
1326
+ (
1327
+ # `None` means that all selected members have a depth of at least
1328
+ # `i_block + 1`.
1329
+ None
1330
+ if counts[i_block] == pack_size
1331
+ else torch.nonzero_static(mask[i_block], size=counts[i_block])[
1332
+ :, 0
1333
+ ].clone()
1334
+ )
1335
+ for i_block in range(self.max_n_blocks)
1336
+ ]
1337
+
1338
+ def _get_active_local_pack_idx_by_block(
1339
+ self, pack_idx: Tensor | None
1340
+ ) -> list[Tensor | None]:
1341
+ if pack_idx is not None:
1342
+ # The cache is maintained only for the whole pack, since caching the result
1343
+ # for arbitrary `pack_idx` values would require a significantly more complex
1344
+ # caching mechanism. As a result, in depth-varying packs, member indexing
1345
+ # always triggers the host-device synchronization described in the index
1346
+ # computation method.
1347
+ return self._compute_active_local_pack_idx_by_block(pack_idx)
1348
+
1349
+ n_blocks_version = (
1350
+ None
1351
+ if self.n_blocks is None or self.n_blocks.is_inference()
1352
+ else self.n_blocks._version
1353
+ )
1354
+ if (
1355
+ self._active_local_pack_idx_cache is None
1356
+ or self._active_local_pack_idx_cache.n_blocks is not self.n_blocks
1357
+ # Additional protection from in-place modifications of `n_blocks` without
1358
+ # the `n_blocks_index_put_` method. The protection is not absolute, in
1359
+ # particular it does not detect modifications of inference tensors (which
1360
+ # have no version counter) or modifications through `n_blocks.data`.
1361
+ # Any code outside of this method must _not_ rely on this additional
1362
+ # protection and should be written as this protection does not exist.
1363
+ or self._active_local_pack_idx_cache.n_blocks_version != n_blocks_version
1364
+ ):
1365
+ self._reset_active_local_pack_idx_cache()
1366
+ self._active_local_pack_idx_cache = (
1367
+ MLPBackbonePack._ActiveLocalPackIdxCache(
1368
+ self.n_blocks,
1369
+ n_blocks_version,
1370
+ self._compute_active_local_pack_idx_by_block(None),
1371
+ )
1372
+ )
1373
+ return self._active_local_pack_idx_cache.value
1374
+
1375
+ def _forward_indexed(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1376
+ if pack_idx is not None:
1377
+ check_pack_idx(pack_idx, self.pack_size)
1378
+
1379
+ # Without autograd, the in-place version is safe and faster. It never modifies
1380
+ # the input, because all members are active in the first block, which is
1381
+ # explicitly checked by the assertion below.
1382
+ index_copy = (
1383
+ Tensor.index_copy if torch.is_grad_enabled() else Tensor.index_copy_
1384
+ )
1385
+
1386
+ active_local_pack_idx_by_block = self._get_active_local_pack_idx_by_block(
1387
+ pack_idx
1388
+ )
1389
+ assert (
1390
+ active_local_pack_idx_by_block and active_local_pack_idx_by_block[0] is None
1391
+ )
1392
+
1393
+ for block, active_local_pack_idx in zip(
1394
+ self.blocks, active_local_pack_idx_by_block, strict=True
1395
+ ):
1396
+ if active_local_pack_idx is None:
1397
+ # All the pack members continue the forward pass.
1398
+ x = block(x, pack_idx)
1399
+ elif active_local_pack_idx.numel() > 0:
1400
+ # Only some of the pack members continue the forward pass. Note that
1401
+ # the computed indices are applied to `x`, i.e. they refer to the
1402
+ # selected members, while the block itself must be indexed with the
1403
+ # corresponding indices in the whole pack.
1404
+ x = index_copy(
1405
+ x,
1406
+ PACK_DIM,
1407
+ active_local_pack_idx,
1408
+ block(
1409
+ x.index_select(PACK_DIM, active_local_pack_idx),
1410
+ (
1411
+ active_local_pack_idx
1412
+ if pack_idx is None
1413
+ else pack_idx[active_local_pack_idx]
1414
+ ),
1415
+ ),
1416
+ )
1417
+ else:
1418
+ # None of the pack members continue the forward pass.
1419
+ break
1420
+ return x
1421
+
1422
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1423
+ if self._forward_impl is MLPBackbonePack.ForwardImpl.INDEXED:
1424
+ return self._forward_indexed(x, pack_idx)
1425
+ elif self._forward_impl is MLPBackbonePack.ForwardImpl.DENSE:
1426
+ return self._forward_dense(x, pack_idx)
1427
+ else:
1428
+ raise ValueError(f'Unknown forward implementation: {self._forward_impl}')
1429
+
1430
+
1431
+ class NumericalFeatureEmbeddingsPack(ModulePack, abc.ABC):
1432
+ """The base class for packs of numerical feature embeddings.
1433
+
1434
+ Each numerical feature is mapped to its own learned vector before entering the main
1435
+ backbone. `d_embedding` controls the vector size per feature.
1436
+
1437
+ In addition to implementing the abstract methods, inherited classes must also
1438
+ accept the following arguments in their constructors:
1439
+
1440
+ - `n_features`
1441
+ - `d_embedding`
1442
+ - `max_d_embedding`
1443
+ - `pack_size`
1444
+ """
1445
+
1446
+ @abc.abstractmethod
1447
+ def get_output_shape(self) -> torch.Size:
1448
+ """Get the shape of the embeddings of one object."""
1449
+ ...
1450
+
1451
+
1452
+ class LinearEmbeddingsPack(NumericalFeatureEmbeddingsPack):
1453
+ """A pack of linear feature embeddings."""
1454
+
1455
+ def __init__(
1456
+ self,
1457
+ n_features: int,
1458
+ d_embedding: int | list[int],
1459
+ *,
1460
+ max_d_embedding: int | None = None,
1461
+ pack_size: int,
1462
+ ) -> None:
1463
+ d_embedding_buffer, max_d_embedding = _prepare_positive_integer_pack(
1464
+ d_embedding, max_d_embedding, pack_size=pack_size
1465
+ )
1466
+
1467
+ super().__init__(pack_size=pack_size)
1468
+ self.weight = ParameterPack(torch.empty(pack_size, n_features, max_d_embedding))
1469
+ self.bias = ParameterPack(torch.empty(pack_size, n_features, max_d_embedding))
1470
+ self.d_embedding = d_embedding_buffer
1471
+ self.reset_parameters()
1472
+
1473
+ @property
1474
+ def pack_size(self) -> int:
1475
+ return self.weight.shape[PACK_DIM]
1476
+
1477
+ @property
1478
+ def max_d_embedding(self) -> int:
1479
+ """The maximum supported embedding size."""
1480
+ return self.weight.shape[-1]
1481
+
1482
+ def get_output_shape(self) -> torch.Size:
1483
+ """Get the shape of the embeddings of one object."""
1484
+ return self.weight.shape[1:] # ty:ignore[invalid-return-type]
1485
+
1486
+ def reset_parameters(self, *, pack_idx: Tensor | None = None) -> None:
1487
+ """Initialize the parameters."""
1488
+ if pack_idx is not None:
1489
+ check_pack_idx(pack_idx, self.pack_size)
1490
+
1491
+ if self.d_embedding is None:
1492
+ d_rsqrt = self.max_d_embedding**-0.5
1493
+ init_args = (-d_rsqrt, d_rsqrt)
1494
+
1495
+ for p in (self.weight, self.bias):
1496
+ if pack_idx is None:
1497
+ nn.init.uniform_(p, *init_args)
1498
+ else:
1499
+ with torch.no_grad():
1500
+ p[pack_idx] = torch.empty(
1501
+ len(pack_idx), *p.shape[1:], dtype=p.dtype, device=p.device
1502
+ ).uniform_(*init_args)
1503
+
1504
+ else:
1505
+ d_embedding = _get_tensor_pack(self.d_embedding, pack_idx)
1506
+ d_rsqrt = d_embedding.float().rsqrt()
1507
+ d_rsqrt_neg = -d_rsqrt
1508
+
1509
+ for p in (self.weight, self.bias):
1510
+ single_shape = p.shape[1:]
1511
+ broadcast_idx = (slice(None), *((None,) * (p.ndim - 1)))
1512
+ p_init = torch.lerp(
1513
+ d_rsqrt_neg[broadcast_idx].expand(-1, *single_shape),
1514
+ d_rsqrt[broadcast_idx].expand(-1, *single_shape),
1515
+ torch.rand(
1516
+ self.pack_size if pack_idx is None else len(pack_idx),
1517
+ *single_shape,
1518
+ dtype=p.dtype,
1519
+ device=p.device,
1520
+ ),
1521
+ )
1522
+ with torch.no_grad():
1523
+ if pack_idx is None:
1524
+ p.copy_(p_init)
1525
+ else:
1526
+ p[pack_idx] = p_init
1527
+
1528
+ def replace(
1529
+ self, *, pack_idx: Tensor, d_embedding: list[int] | None = None
1530
+ ) -> None:
1531
+ check_pack_idx(pack_idx, self.pack_size)
1532
+ _check_replace_arguments(pack_idx, d_embedding)
1533
+
1534
+ if d_embedding is not None:
1535
+ if not isinstance(self.d_embedding, BufferPack):
1536
+ raise RuntimeError(
1537
+ 'Cannot replace embedding sizes that are shared by all pack members'
1538
+ )
1539
+ if not all(0 < x <= self.max_d_embedding for x in d_embedding):
1540
+ raise ValueError(
1541
+ 'Embedding sizes must be positive and must not exceed'
1542
+ ' `max_d_embedding`'
1543
+ )
1544
+
1545
+ self.d_embedding[pack_idx] = torch.tensor(
1546
+ d_embedding,
1547
+ dtype=self.d_embedding.dtype,
1548
+ device=self.d_embedding.device,
1549
+ )
1550
+
1551
+ # The parameters are reset _after_ updating the hyperparameters, because the
1552
+ # initialization depends on the hyperparameter values.
1553
+ self.reset_parameters(pack_idx=pack_idx)
1554
+
1555
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1556
+ if x.ndim != 3:
1557
+ raise ValueError('The input must have three dimensions')
1558
+ if x.shape[-1] != self.weight.shape[-2]:
1559
+ raise ValueError(
1560
+ 'The number of input features must match the number of features'
1561
+ ' configured for the embeddings'
1562
+ )
1563
+ if pack_idx is not None:
1564
+ check_pack_idx(pack_idx, self.pack_size)
1565
+
1566
+ weight = _get_tensor_pack(self.weight, pack_idx)
1567
+ bias = _get_tensor_pack(self.bias, pack_idx)
1568
+ d_embedding = _get_tensor_pack(self.d_embedding, pack_idx)
1569
+
1570
+ if d_embedding is not None:
1571
+ # Masking parameters is significantly faster than masking activations.
1572
+ parameter_mask = (
1573
+ torch.arange(self.max_d_embedding, device=x.device)[None]
1574
+ < d_embedding[:, None]
1575
+ )
1576
+ parameter_mask = parameter_mask.float().unsqueeze(-2)
1577
+ weight = weight * parameter_mask
1578
+ bias = bias * parameter_mask
1579
+
1580
+ return torch.addcmul(
1581
+ bias.unsqueeze(BATCH_DIM), weight.unsqueeze(BATCH_DIM), x[..., None]
1582
+ )
1583
+
1584
+
1585
+ class LinearReLUEmbeddingsPack(NumericalFeatureEmbeddingsPack):
1586
+ """A pack of linear embeddings followed by the ReLU activation."""
1587
+
1588
+ def __init__(
1589
+ self,
1590
+ n_features: int,
1591
+ d_embedding: int | list[int],
1592
+ *,
1593
+ max_d_embedding: int | None = None,
1594
+ pack_size: int,
1595
+ ) -> None:
1596
+ super().__init__(pack_size=pack_size)
1597
+ self.linear = LinearEmbeddingsPack(
1598
+ n_features,
1599
+ d_embedding,
1600
+ max_d_embedding=max_d_embedding,
1601
+ pack_size=pack_size,
1602
+ )
1603
+ self.activation = nn.ReLU()
1604
+
1605
+ @property
1606
+ def pack_size(self) -> int:
1607
+ return self.linear.pack_size
1608
+
1609
+ @property
1610
+ def max_d_embedding(self) -> int:
1611
+ """The maximum supported embedding size."""
1612
+ return self.linear.max_d_embedding
1613
+
1614
+ def get_output_shape(self) -> torch.Size:
1615
+ """Get the shape of the embeddings of one object."""
1616
+ return self.linear.get_output_shape()
1617
+
1618
+ def replace(
1619
+ self, *, pack_idx: Tensor, d_embedding: list[int] | None = None
1620
+ ) -> None:
1621
+ self.linear.replace(pack_idx=pack_idx, d_embedding=d_embedding)
1622
+
1623
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1624
+ return self.activation(self.linear(x, pack_idx))
1625
+
1626
+
1627
+ class CosineEmbeddingsPack(NumericalFeatureEmbeddingsPack):
1628
+ """A pack of cosine feature embeddings.
1629
+
1630
+ These embeddings are proposed in the TabPack paper. They combine learned cosine
1631
+ functions of each numerical feature with the original feature value. `init_scale`
1632
+ controls the initial cosine frequencies.
1633
+
1634
+ **Shape**
1635
+
1636
+ - Input: `(pack_size, batch_size, n_features)`
1637
+ - Output: `(pack_size, batch_size, n_features, self.max_d_embedding)`
1638
+
1639
+ **Examples**
1640
+
1641
+ >>> m = CosineEmbeddingsPack(4, [5, 6], init_scale=[0.01, 0.1], pack_size=2)
1642
+ >>> m.max_d_embedding
1643
+ 6
1644
+ >>> x = torch.randn(2, 3, 4)
1645
+ >>> m(x).shape
1646
+ torch.Size([2, 3, 4, 6])
1647
+ """
1648
+
1649
+ def __init__(
1650
+ self,
1651
+ n_features: int,
1652
+ d_embedding: int | list[int],
1653
+ *,
1654
+ max_d_embedding: int | None = None,
1655
+ init_scale: float | list[float],
1656
+ pack_size: int,
1657
+ ) -> None:
1658
+ concat_input = True
1659
+
1660
+ d_embedding_buffer, max_d_embedding = _prepare_positive_integer_pack(
1661
+ d_embedding, max_d_embedding, pack_size=pack_size
1662
+ )
1663
+
1664
+ super().__init__(pack_size=pack_size)
1665
+ self.weight = ParameterPack(
1666
+ torch.empty(pack_size, n_features, max_d_embedding - int(concat_input))
1667
+ )
1668
+ self.bias = ParameterPack(torch.empty(self.weight.shape))
1669
+ self.elementwise_affine_weight = ParameterPack(
1670
+ torch.empty(
1671
+ *self.weight.shape[:-1], self.weight.shape[-1] + int(concat_input)
1672
+ )
1673
+ )
1674
+ self.elementwise_affine_bias = ParameterPack(
1675
+ torch.empty(self.elementwise_affine_weight.shape)
1676
+ )
1677
+
1678
+ self.d_embedding = d_embedding_buffer
1679
+ self.init_scale = (
1680
+ float(init_scale)
1681
+ if isinstance(init_scale, int | float)
1682
+ else BufferPack(torch.tensor([float(x) for x in init_scale]))
1683
+ )
1684
+
1685
+ self._concat_input: Final = concat_input
1686
+
1687
+ self.reset_parameters()
1688
+
1689
+ @property
1690
+ def pack_size(self) -> int:
1691
+ return self.weight.shape[PACK_DIM]
1692
+
1693
+ @property
1694
+ def max_d_embedding(self) -> int:
1695
+ """The maximum supported embedding size."""
1696
+ return self.weight.shape[-1] + int(self._concat_input)
1697
+
1698
+ def get_output_shape(self) -> torch.Size:
1699
+ """Get the shape of the embeddings of one object."""
1700
+ return torch.Size(
1701
+ (self.weight.shape[1], self.weight.shape[2] + int(self._concat_input))
1702
+ )
1703
+
1704
+ def reset_parameters(self, *, pack_idx: Tensor | None = None) -> None:
1705
+ """Initialize the parameters."""
1706
+ if pack_idx is not None:
1707
+ check_pack_idx(pack_idx, self.pack_size)
1708
+
1709
+ bound = 3.0
1710
+ weight = (
1711
+ self.weight
1712
+ if pack_idx is None
1713
+ else torch.empty(
1714
+ len(pack_idx),
1715
+ *self.weight.shape[1:],
1716
+ dtype=self.weight.dtype,
1717
+ device=self.weight.device,
1718
+ )
1719
+ )
1720
+ nn.init.trunc_normal_(weight, a=-bound, b=bound)
1721
+ with torch.no_grad():
1722
+ if isinstance(self.init_scale, int | float):
1723
+ weight *= self.init_scale
1724
+ else:
1725
+ init_scale = _get_tensor_pack(self.init_scale, pack_idx)
1726
+ weight *= init_scale[(slice(None), *((None,) * (weight.ndim - 1)))]
1727
+ if pack_idx is not None:
1728
+ with torch.no_grad():
1729
+ self.weight[pack_idx] = weight
1730
+
1731
+ for p in (
1732
+ self.bias,
1733
+ self.elementwise_affine_weight,
1734
+ self.elementwise_affine_bias,
1735
+ ):
1736
+ if pack_idx is None:
1737
+ nn.init.zeros_(p)
1738
+ else:
1739
+ with torch.no_grad():
1740
+ p[pack_idx] = 0.0
1741
+
1742
+ def replace(
1743
+ self,
1744
+ *,
1745
+ pack_idx: Tensor,
1746
+ d_embedding: list[int] | None = None,
1747
+ init_scale: list[float] | None = None,
1748
+ ) -> None:
1749
+ check_pack_idx(pack_idx, self.pack_size)
1750
+ _check_replace_arguments(pack_idx, d_embedding, init_scale)
1751
+
1752
+ updates = []
1753
+ if d_embedding is not None:
1754
+ if not isinstance(self.d_embedding, BufferPack):
1755
+ raise RuntimeError(
1756
+ 'Cannot replace embedding sizes that are shared by all pack members'
1757
+ )
1758
+ if not all(0 < x <= self.max_d_embedding for x in d_embedding):
1759
+ raise ValueError(
1760
+ 'Embedding sizes must be positive and must not exceed'
1761
+ ' `max_d_embedding`'
1762
+ )
1763
+
1764
+ # Defer buffer updates until both hyperparameters have been checked.
1765
+ updates.append((self.d_embedding, d_embedding))
1766
+ if init_scale is not None:
1767
+ if not isinstance(self.init_scale, BufferPack):
1768
+ raise RuntimeError(
1769
+ 'Cannot replace initialization scales that are shared by all pack'
1770
+ ' members'
1771
+ )
1772
+
1773
+ updates.append((self.init_scale, init_scale))
1774
+ for buffer, values in updates:
1775
+ buffer[pack_idx] = torch.tensor(
1776
+ values, dtype=buffer.dtype, device=buffer.device
1777
+ )
1778
+
1779
+ # The parameters are reset _after_ updating the hyperparameters, because the
1780
+ # initialization depends on the hyperparameter values.
1781
+ self.reset_parameters(pack_idx=pack_idx)
1782
+
1783
+ def forward(self, x: Tensor, pack_idx: Tensor | None = None) -> Tensor:
1784
+ if x.ndim != 3:
1785
+ raise ValueError('The input must have three dimensions')
1786
+ if x.shape[-1] != self.weight.shape[-2]:
1787
+ raise ValueError(
1788
+ 'The number of input features must match the number of features'
1789
+ ' configured for the embeddings'
1790
+ )
1791
+ if pack_idx is not None:
1792
+ check_pack_idx(pack_idx, self.pack_size)
1793
+
1794
+ weight = _get_tensor_pack(self.weight, pack_idx)
1795
+ bias = _get_tensor_pack(self.bias, pack_idx)
1796
+ elementwise_affine_weight = _get_tensor_pack(
1797
+ self.elementwise_affine_weight, pack_idx
1798
+ )
1799
+ elementwise_affine_bias = _get_tensor_pack(
1800
+ self.elementwise_affine_bias, pack_idx
1801
+ )
1802
+ d_embedding = _get_tensor_pack(self.d_embedding, pack_idx)
1803
+
1804
+ if d_embedding is not None:
1805
+ # Masking parameters is significantly faster than masking activations.
1806
+ parameter_mask = (
1807
+ torch.arange(self.max_d_embedding, device=x.device)[None]
1808
+ < d_embedding[:, None]
1809
+ )
1810
+ parameter_mask = parameter_mask.float().unsqueeze(-2)
1811
+ elementwise_affine_weight = elementwise_affine_weight * parameter_mask
1812
+ elementwise_affine_bias = elementwise_affine_bias * parameter_mask
1813
+
1814
+ # Scaling parameters is usually faster than scaling the input, especially for
1815
+ # non-trivial batch sizes.
1816
+ weight = weight * (2 * torch.pi)
1817
+ bias = bias * (2 * torch.pi)
1818
+
1819
+ x_input = x
1820
+ x = torch.addcmul(
1821
+ bias.unsqueeze(BATCH_DIM), weight.unsqueeze(BATCH_DIM), x[..., None]
1822
+ )
1823
+ x = torch.cos(x)
1824
+ if self._concat_input:
1825
+ # The input goes first so it is always included by the width mask.
1826
+ x = torch.cat([x_input[..., None], x], dim=-1)
1827
+ x = torch.addcmul(
1828
+ elementwise_affine_bias.unsqueeze(BATCH_DIM),
1829
+ elementwise_affine_weight.unsqueeze(BATCH_DIM),
1830
+ x,
1831
+ )
1832
+
1833
+ return x
1834
+
1835
+
1836
+ class _OneHotEncoding(nn.Module):
1837
+ """One-hot encoding of categorical features, with support of unknown categories.
1838
+
1839
+ For a categorical feature with cardinality `C` (the number of distinct values), the
1840
+ feature values must be from `range(C + 1)`, where the value `C` means "unknown"
1841
+ (out-of-vocabulary) category.
1842
+
1843
+ **Shape**
1844
+
1845
+ - Input: `(*, n_categorical_features=len(cardinalities))`.
1846
+ - Output: `(*, sum(cardinalities))`.
1847
+ """
1848
+
1849
+ def __init__(self, cardinalities: list[int]) -> None:
1850
+ super().__init__()
1851
+ self._cardinalities = cardinalities
1852
+
1853
+ def forward(self, x: Tensor) -> Tensor:
1854
+ if x.ndim == 0:
1855
+ raise ValueError('The categorical input must have at least one dimension')
1856
+ if x.shape[-1] != len(self._cardinalities):
1857
+ raise ValueError(
1858
+ 'The input must contain one categorical feature for each entry in'
1859
+ ' `cardinalities`'
1860
+ )
1861
+
1862
+ return torch.cat(
1863
+ [
1864
+ # The following produces the standard one-hot encoding for in-vocabulary
1865
+ # values, and all-zeros encoding for out-of-vocabulary values. The
1866
+ # latter requires the unknown categories to be encoded as `cardinality`.
1867
+ nn.functional.one_hot(x[..., i], cardinality + 1)[..., :-1]
1868
+ for i, cardinality in enumerate(self._cardinalities)
1869
+ ],
1870
+ -1,
1871
+ )
1872
+
1873
+
1874
+ def _make_num_module(
1875
+ type: type[NumericalFeatureEmbeddingsPack], **kwargs
1876
+ ) -> ModulePack:
1877
+ cls = type
1878
+ del type
1879
+
1880
+ return cls(**kwargs)
1881
+
1882
+
1883
+ class TabPack(ModulePack):
1884
+ """A pack of multilayer perceptrons for tabular data, with optional feature embeddings.
1885
+
1886
+ Args:
1887
+ n_numerical_features: the number of numerical features.
1888
+ categorical_cardinalities: the number of known categories for each categorical
1889
+ feature, in input column order. Use an empty list for numerical-only data.
1890
+ Encode categories as consecutive integers starting at zero; use the
1891
+ cardinality for unknown categories, which receive an all-zero encoding.
1892
+ d_out: the number of raw outputs per model.
1893
+ n_blocks: the number of MLP blocks, shared or per model.
1894
+ d_block: the MLP block width, shared or per model.
1895
+ dropout: the dropout rate, shared or per model.
1896
+ pack_size: the number of models evaluated in parallel.
1897
+ numerical_embeddings: the numerical embedding configuration. The `"type"` entry
1898
+ selects an embedding class, such as `CosineEmbeddingsPack`; the remaining
1899
+ entries are its keyword arguments, including `d_embedding`. The feature
1900
+ count and pack size are supplied automatically. `None` means passing the raw
1901
+ numerical features directly to the MLP backbones.
1902
+ **backbone_kwargs: optional arguments for `MLPBackbonePack`.
1903
+ """ # noqa: E501
1904
+
1905
+ def __init__(
1906
+ self,
1907
+ *,
1908
+ n_numerical_features: int,
1909
+ categorical_cardinalities: list[int],
1910
+ d_out: int,
1911
+ n_blocks: int | list[int],
1912
+ d_block: int | list[int],
1913
+ dropout: float | list[float],
1914
+ pack_size: int,
1915
+ numerical_embeddings: KWArgs | None = None,
1916
+ **backbone_kwargs,
1917
+ ) -> None:
1918
+ super().__init__(pack_size=pack_size)
1919
+
1920
+ if numerical_embeddings is None:
1921
+ self.num_module = None
1922
+ # The same representation size of numerical features for all pack members.
1923
+ d_in_num = n_numerical_features
1924
+ max_d_in_num = None
1925
+ else:
1926
+ self.num_module = _make_num_module(
1927
+ n_features=n_numerical_features,
1928
+ pack_size=pack_size,
1929
+ **numerical_embeddings,
1930
+ )
1931
+ d_num_embedding = numerical_embeddings['d_embedding']
1932
+ if isinstance(d_num_embedding, int):
1933
+ # The same representation size of numerical features for all members.
1934
+ d_in_num = d_num_embedding * n_numerical_features
1935
+ max_d_in_num = None
1936
+ else:
1937
+ # The representation size of numerical features varies between members.
1938
+ if not isinstance(d_num_embedding, list):
1939
+ raise TypeError(
1940
+ 'The `d_embedding` setting in `numerical_embeddings` must be an'
1941
+ ' integer or a list'
1942
+ )
1943
+
1944
+ max_d_num_embedding = numerical_embeddings.get('max_d_embedding')
1945
+ if max_d_num_embedding is None:
1946
+ max_d_num_embedding = max(d_num_embedding)
1947
+ d_in_num = [x * n_numerical_features for x in d_num_embedding]
1948
+ max_d_in_num = max_d_num_embedding * n_numerical_features
1949
+
1950
+ self.cat_module = (
1951
+ _OneHotEncoding(categorical_cardinalities)
1952
+ if categorical_cardinalities
1953
+ else None
1954
+ )
1955
+ d_in_cat = sum(categorical_cardinalities)
1956
+
1957
+ self.pack_view = PackView(pack_size=pack_size)
1958
+ self.backbone = MLPBackbonePack(
1959
+ d_in=(
1960
+ d_in_num + d_in_cat
1961
+ if isinstance(d_in_num, int)
1962
+ else [x + d_in_cat for x in d_in_num]
1963
+ ),
1964
+ max_d_in=None if max_d_in_num is None else max_d_in_num + d_in_cat,
1965
+ n_blocks=n_blocks,
1966
+ d_block=d_block,
1967
+ dropout=dropout,
1968
+ pack_size=pack_size,
1969
+ **backbone_kwargs,
1970
+ )
1971
+ self.output = LinearPack(
1972
+ d_block,
1973
+ d_out,
1974
+ max_in_features=backbone_kwargs.get('max_d_block'),
1975
+ pack_size=pack_size,
1976
+ )
1977
+
1978
+ self._n_numerical_features = n_numerical_features
1979
+ self._categorical_cardinalities = categorical_cardinalities
1980
+
1981
+ @property
1982
+ def pack_size(self) -> int:
1983
+ return self.backbone.pack_size
1984
+
1985
+ def replace(
1986
+ self,
1987
+ *,
1988
+ pack_idx: Tensor,
1989
+ numerical_embeddings: KWArgs | None = None,
1990
+ d_block: list[int] | None = None,
1991
+ **kwargs,
1992
+ ) -> None:
1993
+ if numerical_embeddings is not None and self.num_module is None:
1994
+ raise RuntimeError(
1995
+ 'Cannot replace numerical embeddings because this model has none'
1996
+ )
1997
+
1998
+ if self.num_module is not None:
1999
+ self.num_module.replace(
2000
+ pack_idx=pack_idx,
2001
+ **({} if numerical_embeddings is None else numerical_embeddings),
2002
+ ) # ty: ignore[call-non-callable]
2003
+
2004
+ d_num_embedding = (
2005
+ None
2006
+ if numerical_embeddings is None
2007
+ else numerical_embeddings.get('d_embedding')
2008
+ )
2009
+ self.backbone.replace(
2010
+ pack_idx=pack_idx,
2011
+ d_in=(
2012
+ None
2013
+ if d_num_embedding is None
2014
+ else [
2015
+ x * self._n_numerical_features
2016
+ + sum(self._categorical_cardinalities)
2017
+ for x in d_num_embedding
2018
+ ]
2019
+ ),
2020
+ d_block=d_block,
2021
+ **kwargs,
2022
+ )
2023
+
2024
+ self.output.replace(pack_idx=pack_idx, in_features=d_block)
2025
+
2026
+ def forward(
2027
+ self, x_num: Tensor | None, x_cat: Tensor | None, pack_idx: Tensor | None = None
2028
+ ) -> Tensor:
2029
+ """Return raw outputs for each model, with a leading model dimension.
2030
+
2031
+ Pass a shared batch of features, or a separate batch per model during training.
2032
+ Use `None` for a feature group that was not configured.
2033
+ """
2034
+ if x_num is None and x_cat is None:
2035
+ raise ValueError('Provide at least one of `x_num` and `x_cat`')
2036
+ if pack_idx is not None:
2037
+ check_pack_idx(pack_idx, self.pack_size)
2038
+
2039
+ x_list: list[Tensor] = []
2040
+
2041
+ # For models without `num_module`, when the input batch is shared between pack
2042
+ # members (e.g., on inference) the input representations passed to the MLP
2043
+ # backbones are identical for all pack members, which makes it possible to pass
2044
+ # a repeated view of this representation to the backbones instead of a pack of
2045
+ # true representation copies. Technically, this is achieved by deferring the
2046
+ # use of the `pack_view` module.
2047
+ pack_view_used = False
2048
+
2049
+ if x_num is None:
2050
+ if self._n_numerical_features != 0:
2051
+ raise ValueError(
2052
+ '`x_num` is required when numerical features are configured'
2053
+ )
2054
+ if self.num_module is not None:
2055
+ raise ValueError(
2056
+ '`x_num` is required when numerical embeddings are configured'
2057
+ )
2058
+ else:
2059
+ if self._n_numerical_features <= 0:
2060
+ raise ValueError(
2061
+ '`x_num` can only be provided when numerical features are'
2062
+ ' configured'
2063
+ )
2064
+
2065
+ if self.num_module is None:
2066
+ x_list.append(x_num)
2067
+ else:
2068
+ x_num_ = self.pack_view(x_num, pack_idx)
2069
+ pack_view_used = True
2070
+ x_num_ = self.num_module(x_num_, pack_idx)
2071
+ x_list.append(x_num_.flatten(-2))
2072
+ del x_num_
2073
+
2074
+ if x_cat is None:
2075
+ if self.cat_module is not None:
2076
+ raise ValueError(
2077
+ '`x_cat` is required when categorical features are configured'
2078
+ )
2079
+ else:
2080
+ if self.cat_module is None:
2081
+ raise ValueError(
2082
+ '`x_cat` can only be provided when categorical features are'
2083
+ ' configured'
2084
+ )
2085
+
2086
+ x_cat_ = self.cat_module(x_cat).to(torch.get_default_dtype())
2087
+ if pack_view_used:
2088
+ x_cat_ = self.pack_view(x_cat_, pack_idx)
2089
+ x_list.append(x_cat_)
2090
+ del x_cat_
2091
+
2092
+ if len(x_list) > 1:
2093
+ if (
2094
+ x_list[0].device.type == 'mps'
2095
+ and _TORCH_IS_OLDER_THAN_2_15
2096
+ and sum(x.numel() for x in x_list) > 2**31
2097
+ ):
2098
+ raise RuntimeError(
2099
+ 'MPS concatenation output larger than INT_MAX is not supported'
2100
+ ' with PyTorch < 2.15'
2101
+ )
2102
+ x = torch.cat(x_list, dim=-1)
2103
+ else:
2104
+ x = x_list[0]
2105
+
2106
+ if not pack_view_used:
2107
+ x = self.pack_view(x, pack_idx)
2108
+ x = self.backbone(x, pack_idx)
2109
+ x = self.output(x, pack_idx)
2110
+ return x