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/ensemble.py ADDED
@@ -0,0 +1,263 @@
1
+ """PyTorch ensembling utilities."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import TypeAlias
5
+
6
+ import torch
7
+ from torch import Tensor
8
+
9
+ _ScoreFn: TypeAlias = Callable[
10
+ [Tensor], # Shape: (n_predictions, *)
11
+ Tensor, # Shape: (n_predictions,)
12
+ ]
13
+
14
+
15
+ def _validate_predictions(predictions: Tensor) -> None:
16
+ if predictions.ndim < 2:
17
+ raise ValueError(
18
+ 'Predictions must have a dimension for ensemble members and at least one'
19
+ " dimension for each member's predictions"
20
+ )
21
+ if predictions.numel() == 0:
22
+ raise ValueError('Predictions must not be empty')
23
+
24
+
25
+ def compute_unsanitized_ensemble_predictions(
26
+ member_predictions: Tensor, member_weights: Tensor | None
27
+ ) -> Tensor:
28
+ """Aggregate predictions of ensemble members into one _RAW_ prediction.
29
+
30
+ Warning:
31
+ Due to the floating-point arithmetic limitations:
32
+
33
+ - The computed raw predictions can contain values slightly beyond the range of
34
+ `member_predictions`, i.e. below/above their minimum/maximum values.
35
+ Transform the result as needed, e.g. with `.clamp(max=1.0)` for
36
+ probabilities.
37
+ - If `member_predictions` are probabilities, the computed distributions may
38
+ not sum to `1.0`.
39
+
40
+ Args:
41
+ member_predictions: the predictions of the ensemble members.
42
+ Shape: `(n_members, *)`.
43
+ member_weights: weights to aggregate the predictions with, normalized by their
44
+ sum. Pass `None` for equal weights. Shape: `(n_members,)`.
45
+ Returns:
46
+ The aggregated prediction. Shape: `(*)`.
47
+ """
48
+ _validate_predictions(member_predictions)
49
+
50
+ if member_weights is None:
51
+ return member_predictions.mean(0)
52
+ else:
53
+ if member_weights.ndim != 1:
54
+ raise ValueError('`member_weights` must be a one-dimensional tensor')
55
+ if len(member_weights) != len(member_predictions):
56
+ raise ValueError(
57
+ 'The number of member weights must match the number of ensemble members'
58
+ )
59
+ return (
60
+ (member_weights / member_weights.sum()) @ member_predictions.flatten(1)
61
+ ).view(member_predictions.shape[1:])
62
+
63
+
64
+ def topk_ensemble(predictions: Tensor, *, score_fn: _ScoreFn, k: int) -> Tensor:
65
+ """Build an equally weighted ensemble of the top-k individual performers.
66
+
67
+ Args:
68
+ predictions: the predictions to build the ensemble from.
69
+ score_fn: the scoring function taking multiple stacked predictions as input and
70
+ returning their performance scores (one per prediction). Higher scores are
71
+ better. Ties are resolved by selecting the earliest predictions.
72
+ k: the number of predictions to select, capped by the number available.
73
+ Returns:
74
+ The selected prediction indices.
75
+ """
76
+ _validate_predictions(predictions)
77
+ if k <= 0:
78
+ raise ValueError('`k` must be positive to select at least one ensemble member')
79
+
80
+ prediction_scores = score_fn(predictions)
81
+ if prediction_scores.ndim != 1:
82
+ raise ValueError(
83
+ 'The scoring function must return a one-dimensional tensor of scores'
84
+ )
85
+ if len(prediction_scores) != len(predictions):
86
+ raise ValueError('The scoring function must return one score per prediction')
87
+
88
+ return (
89
+ torch.argmax(prediction_scores)[None]
90
+ if k == 1
91
+ else torch.argsort(prediction_scores, descending=True, stable=True)[:k]
92
+ )
93
+
94
+
95
+ def greedy_ensemble(
96
+ predictions: Tensor,
97
+ *,
98
+ score_fn: _ScoreFn,
99
+ init_top_k: int = 1,
100
+ max_ensemble_size: int | None = None,
101
+ with_replacement: bool = False,
102
+ ) -> tuple[Tensor, Tensor | None]:
103
+ """Build an ensemble by greedily adding the best candidate one at a time.
104
+
105
+ The algorithm initializes the ensemble by unconditionally selecting the top-k
106
+ predictions based on their individual performance, and then greedily adds more
107
+ predictions one at a time, stopping when reaching the allowed ensemble size or when
108
+ adding more predictions does not improve performance.
109
+
110
+ Args:
111
+ predictions: the predictions to build the ensemble from.
112
+ score_fn: the scoring function taking multiple stacked predictions as input and
113
+ returning their performance scores (one per prediction). Higher scores are
114
+ better. Usually, it is based on the main task metric (e.g., accuracy,
115
+ _negative_ RMSE, etc.). The algorithm stops when the ensemble performance
116
+ stops improving according to this score.
117
+ init_top_k: the number of the best individual performers to unconditionally add
118
+ to the ensemble on initialization.
119
+ max_ensemble_size: the maximum allowed ensemble size. When
120
+ `with_replacement=False`, it is the maximum number of distinct ensemble
121
+ members. Otherwise, it is the maximum number of member selections that the
122
+ algorithm performs, including the unconditional selections in the beginning.
123
+ Must be specified when `with_replacement=True`.
124
+ with_replacement: if `True`, each prediction can be added to the ensemble any
125
+ number of times.
126
+ Returns:
127
+ The selected indices in original prediction order, and their selection counts
128
+ when `with_replacement=True`; otherwise, the second item is `None`.
129
+ """
130
+ _validate_predictions(predictions)
131
+ if not torch.is_floating_point(predictions):
132
+ raise TypeError('Predictions must be floating-point values')
133
+ if init_top_k <= 0:
134
+ raise ValueError(
135
+ '`init_top_k` must be positive to initialize the ensemble with at least one'
136
+ ' member'
137
+ )
138
+ if with_replacement and max_ensemble_size is None:
139
+ raise ValueError(
140
+ 'Set `max_ensemble_size` to limit the number of ensemble members when'
141
+ ' `with_replacement=True`'
142
+ )
143
+ if max_ensemble_size is not None and init_top_k > max_ensemble_size:
144
+ raise ValueError('`init_top_k` must not exceed `max_ensemble_size`')
145
+
146
+ device = predictions.device
147
+
148
+ # Individual candidate scores.
149
+ prediction_scores = score_fn(predictions)
150
+
151
+ # Initial ensemble members.
152
+ init_idx = topk_ensemble(
153
+ predictions, score_fn=lambda _: prediction_scores, k=init_top_k
154
+ )
155
+
156
+ # Prediction-related constants.
157
+ n_predictions = len(predictions)
158
+ prediction_range_idx = torch.arange(n_predictions, device=device)
159
+
160
+ # The mask indicating if a given prediction is selected as an ensemble member.
161
+ ensemble_mask = torch.zeros(n_predictions, dtype=torch.bool, device=device)
162
+ ensemble_mask[init_idx] = True
163
+
164
+ if with_replacement:
165
+ # Each prediction's weight is the number of times it was selected.
166
+ ensemble_weights = torch.zeros(
167
+ n_predictions, dtype=predictions.dtype, device=device
168
+ )
169
+ ensemble_weights[init_idx] = 1.0
170
+ else:
171
+ ensemble_weights = None
172
+
173
+ # Current ensemble prediction.
174
+ ensemble_prediction = predictions[init_idx].mean(0)
175
+
176
+ # Current ensemble score.
177
+ ensemble_score = score_fn(ensemble_prediction[None])[0]
178
+
179
+ ensemble_size = len(init_idx) # The number of distinct ensemble members.
180
+ ensemble_weight_sum = ensemble_size
181
+
182
+ # Without replacement, the weight sum equals the number of distinct members.
183
+ while ensemble_weight_sum < (
184
+ n_predictions
185
+ if max_ensemble_size is None # True only when `with_replacement=False`.
186
+ else max_ensemble_size
187
+ if with_replacement
188
+ else min(n_predictions, max_ensemble_size)
189
+ ):
190
+ # Get the indices of predictions available for selection.
191
+ candidate_idx = (
192
+ prediction_range_idx
193
+ if with_replacement
194
+ else torch.nonzero_static(
195
+ ~ensemble_mask, size=n_predictions - ensemble_size
196
+ )[:, 0]
197
+ )
198
+
199
+ # Compute the ensemble predictions for all possible ensembles formed by adding
200
+ # one of the candidate predictions.
201
+ candidate_predictions = (
202
+ predictions.clone()
203
+ if with_replacement
204
+ else predictions.index_select(0, candidate_idx)
205
+ )
206
+ candidate_predictions.lerp_(
207
+ ensemble_prediction[None], ensemble_weight_sum / (ensemble_weight_sum + 1)
208
+ )
209
+
210
+ # Compute ensemble scores of all possible ensembles.
211
+ candidates_scores = score_fn(candidate_predictions)
212
+
213
+ # Get the best possible ensemble score.
214
+ best_candidate_score = candidates_scores.max()
215
+ # If the obtained score is no better than that of the current ensemble, stop the
216
+ # algorithm.
217
+ if best_candidate_score <= ensemble_score:
218
+ break
219
+
220
+ # Find all candidates with the best score.
221
+ candidate_local_mask = candidates_scores == best_candidate_score
222
+ best_candidate_local_idx = torch.nonzero(candidate_local_mask)[:, 0]
223
+ best_candidate_idx = candidate_idx[candidate_local_mask]
224
+ if len(best_candidate_idx) == 0:
225
+ raise ValueError(
226
+ 'Cannot select the best ensemble candidate because the scoring function'
227
+ ' returned a NaN score'
228
+ )
229
+
230
+ if len(best_candidate_idx) == 1:
231
+ # Select the only candidate with the best score.
232
+ best_candidate_local_index = best_candidate_local_idx[0]
233
+ best_candidate_index = best_candidate_idx[0]
234
+ else:
235
+ # Break ties by individual score, then by the original prediction order.
236
+ best_candidate_local_index = best_candidate_local_idx[
237
+ torch.argmax(prediction_scores[best_candidate_idx])
238
+ ]
239
+ best_candidate_index = candidate_idx[best_candidate_local_index]
240
+
241
+ # Update the ensemble state.
242
+ ensemble_mask[best_candidate_index] = True
243
+ if ensemble_weights is not None:
244
+ ensemble_weights[best_candidate_index] += 1.0
245
+ ensemble_score = candidates_scores[best_candidate_local_index]
246
+ # Clone the only needed prediction and delete the rest to leave more free memory
247
+ # for the next iteration.
248
+ ensemble_prediction = candidate_predictions[best_candidate_local_index].clone()
249
+ del candidate_predictions
250
+ if (
251
+ ensemble_weights is None
252
+ or ensemble_weights[best_candidate_index].item() == 1.0
253
+ ):
254
+ ensemble_size += 1
255
+ ensemble_weight_sum += 1
256
+
257
+ if not with_replacement:
258
+ assert ensemble_size == ensemble_weight_sum
259
+
260
+ ensemble_idx = torch.nonzero_static(ensemble_mask, size=ensemble_size)[:, 0]
261
+ return ensemble_idx, (
262
+ None if ensemble_weights is None else ensemble_weights[ensemble_idx]
263
+ )
tabpackage/metrics.py ADDED
@@ -0,0 +1,253 @@
1
+ """Metric computation using PyTorch.
2
+
3
+ Common conventions:
4
+
5
+ - The pack dimension must be `tabpackage.nn.PACK_DIM`.
6
+ - The `*_pack` functions (`accuracy_pack`, `mse_pack`, etc.) compute common metrics for
7
+ prediction packs; their distinct property is that the pack dimension is preserved
8
+ during the metric aggregation.
9
+ - The argument naming follows that of Scikit-learn 1.9+. In particular, predicted
10
+ labels are accepted as `y_pred`, predicted probabilities as `y_proba` and decision
11
+ scores (the higher - the better) as `y_score`.
12
+ - Prediction tensors are expected to have exactly one pack dimension.
13
+ - Ground-truth labels with omitted pack and batch dimensions are broadcast along these
14
+ dimensions to match predictions. This in particular means that labels without the pack
15
+ dimension are shared between all pack members.
16
+ """
17
+
18
+ import torch
19
+ import torch.nn as nn # noqa: PLR0402
20
+ from torch import Tensor
21
+
22
+ import tabpackage.utils.torch
23
+ from tabpackage.nn import PACK_DIM
24
+
25
+
26
+ def _check_y_shapes(*, y_true: Tensor, y_pred: Tensor, n_trailing_dims: int) -> None:
27
+ assert n_trailing_dims >= 0
28
+
29
+ if y_pred.ndim < 1 + n_trailing_dims:
30
+ raise ValueError(
31
+ 'The prediction tensor has too few dimensions to represent a pack of'
32
+ ' outputs'
33
+ )
34
+ if y_true.ndim > y_pred.ndim - n_trailing_dims:
35
+ raise ValueError('Labels have too many dimensions for the provided predictions')
36
+
37
+ if y_true.ndim > 0:
38
+ # Only missing leading dimensions may be broadcast; existing ones must match.
39
+ end = y_pred.ndim - n_trailing_dims
40
+ if y_true.shape != y_pred.shape[end - y_true.ndim : end]:
41
+ raise ValueError(
42
+ 'The label and prediction shapes are incompatible; only missing leading'
43
+ ' label dimensions may be broadcast'
44
+ )
45
+
46
+ if y_pred.numel() == 0:
47
+ raise ValueError('Predictions must not be empty')
48
+
49
+
50
+ def _mean_nonpack(x: Tensor) -> Tensor:
51
+ assert x.ndim >= 1 # Must have at least the pack dimension.
52
+
53
+ if x.ndim == 1:
54
+ # Relying on the `else` branch would be incorrect, because it would result in
55
+ # `x.mean(())`, which means aggregating across all dimensions in PyTorch.
56
+ # The only remaining dimension must be the pack dimension.
57
+ assert PACK_DIM == 0
58
+ return x # No dimensions to aggregate across.
59
+ else:
60
+ nonpack_dims = tuple(dim for dim in range(x.ndim) if dim != PACK_DIM)
61
+ return x.mean(nonpack_dims)
62
+
63
+
64
+ # The implementation follows `_mean_nonpack`.
65
+ def _sum_nonpack(x: Tensor) -> Tensor:
66
+ assert x.ndim >= 1
67
+
68
+ if x.ndim == 1:
69
+ assert PACK_DIM == 0
70
+ return x
71
+ else:
72
+ nonpack_dims = tuple(dim for dim in range(x.ndim) if dim != PACK_DIM)
73
+ return x.sum(nonpack_dims)
74
+
75
+
76
+ def accuracy_pack(*, y_true: Tensor, y_pred: Tensor) -> Tensor:
77
+ """Compute the accuracy for each pack member."""
78
+ if not tabpackage.utils.torch.is_integer(y_true):
79
+ raise TypeError('Labels must have an integer data type')
80
+ if y_true.dtype != y_pred.dtype:
81
+ raise TypeError('Labels and predictions must have the same data type')
82
+ _check_y_shapes(y_true=y_true, y_pred=y_pred, n_trailing_dims=0)
83
+
84
+ return _mean_nonpack((y_true == y_pred).float())
85
+
86
+
87
+ def _midranks_pack(x: Tensor) -> Tensor:
88
+ """Compute the one-based ranks of sorted values, averaged within tied groups.
89
+
90
+ For example, the midranks of `[10, 20, 20, 20, 30]` are `[1, 3, 3, 3, 5]`: the
91
+ three tied values occupy the ranks 2, 3 and 4, hence the average rank 3.
92
+
93
+ The input must be sorted along the last (batch) dimension in the ascending order.
94
+ """
95
+ assert x.ndim == 2
96
+
97
+ n_objects = x.shape[-1]
98
+ indices = torch.arange(n_objects, device=x.device)
99
+ ones = torch.ones((x.shape[PACK_DIM], 1), dtype=torch.bool, device=x.device)
100
+
101
+ changes = x[:, 1:] != x[:, :-1]
102
+ # Whether a position starts a tied group.
103
+ starts = torch.cat([ones, changes], dim=-1)
104
+ # Whether a position ends a tied group.
105
+ ends = torch.cat([changes, ones], dim=-1)
106
+
107
+ # For each position, the first and the last position of its tied group. The running
108
+ # extrema propagate the group boundaries to all the positions within the group.
109
+ first = torch.cummax(torch.where(starts, indices, -1), dim=-1).values
110
+ last = torch.cummin(
111
+ torch.where(ends, indices, n_objects).flip(-1), dim=-1
112
+ ).values.flip(-1)
113
+ # `+ 1` turns the zero-based positions into one-based ranks.
114
+ return (first + last).to(x.dtype) / 2 + 1
115
+
116
+
117
+ def binary_roc_auc_pack(*, y_true: Tensor, y_score: Tensor) -> Tensor:
118
+ """Compute the ROC AUC for each pack member."""
119
+ # Because of the implementation complexity, the supported input data types and
120
+ # shapes are limited to those that were actually tested.
121
+ if y_true.dtype != torch.int64:
122
+ raise TypeError('Labels must have the data type `torch.int64`')
123
+ if y_score.dtype not in (torch.float32, torch.float64):
124
+ raise TypeError(
125
+ 'Scores must have the data type `torch.float32` or `torch.float64`'
126
+ )
127
+
128
+ if y_true.ndim != 1:
129
+ raise ValueError('The label tensor must have one dimension')
130
+ if y_score.ndim != 2:
131
+ raise ValueError('The score tensor must have two dimensions')
132
+ if y_true.shape[-1] != y_score.shape[-1]:
133
+ raise ValueError('Labels and scores must have the same batch size')
134
+ if y_score.numel() == 0:
135
+ raise ValueError('Scores must not be empty')
136
+
137
+ sorted_indices = torch.argsort(y_score, dim=-1, stable=True)
138
+ y_score_sorted = torch.take_along_dim(y_score, sorted_indices, dim=-1)
139
+ y_true_sorted = y_true[sorted_indices]
140
+
141
+ n_positives = y_true.sum()
142
+ n_negatives = len(y_true) - n_positives
143
+ n_pairs = n_positives * n_negatives
144
+
145
+ # The U statistic is the number of the (positive, negative) pairs ranked correctly,
146
+ # with the ties counted as one half.
147
+ positive_rank_sum = (_midranks_pack(y_score_sorted) * y_true_sorted).sum(-1)
148
+ u_statistic = positive_rank_sum - n_positives * (n_positives + 1) / 2
149
+
150
+ return torch.where(n_pairs == 0, torch.nan, u_statistic / n_pairs).to(y_score.dtype)
151
+
152
+
153
+ def binary_cross_entropy_pack(*, y_true: Tensor, y_proba: Tensor) -> Tensor:
154
+ """Compute the binary cross-entropy for each pack member."""
155
+ if not tabpackage.utils.torch.is_integer(y_true):
156
+ raise TypeError('Labels must have an integer data type')
157
+ if not torch.is_floating_point(y_proba):
158
+ raise TypeError('Probabilities must be floating-point values')
159
+ _check_y_shapes(y_true=y_true, y_pred=y_proba, n_trailing_dims=0)
160
+
161
+ return _mean_nonpack(
162
+ nn.functional.binary_cross_entropy(
163
+ input=y_proba,
164
+ target=y_true.to(y_proba.dtype).expand_as(y_proba),
165
+ reduction='none',
166
+ )
167
+ )
168
+
169
+
170
+ def multiclass_cross_entropy_pack(*, y_true: Tensor, y_proba: Tensor) -> Tensor:
171
+ """Compute the multiclass cross-entropy for each pack member."""
172
+ # Only class labels are supported as `y_true`; plus, this tensor is used to index
173
+ # another tensor. Hence the `torch.int64` requirement.
174
+ if y_true.dtype != torch.int64:
175
+ raise TypeError('Labels must have the int64 data type')
176
+ if not torch.is_floating_point(y_proba):
177
+ raise TypeError('Probabilities must be floating-point values')
178
+ _check_y_shapes(y_true=y_true, y_pred=y_proba, n_trailing_dims=1)
179
+
180
+ correct_class_probs = y_proba.gather(
181
+ -1, y_true.expand(y_proba.shape[:-1]).unsqueeze(-1)
182
+ ).squeeze(-1)
183
+ return _mean_nonpack(
184
+ torch.log(correct_class_probs)
185
+ .clamp_min(-100.0) # The clamping follows `torch.nn.BCELoss`.
186
+ .neg()
187
+ )
188
+
189
+
190
+ def mse_pack(*, y_true: Tensor, y_pred: Tensor) -> Tensor:
191
+ """Compute the mean squared error for each pack member."""
192
+ if not torch.is_floating_point(y_true):
193
+ raise TypeError('Labels must be floating-point values')
194
+ if y_true.dtype != y_pred.dtype:
195
+ raise TypeError('Labels and predictions must have the same data type')
196
+ _check_y_shapes(y_true=y_true, y_pred=y_pred, n_trailing_dims=0)
197
+
198
+ return _mean_nonpack(
199
+ nn.functional.mse_loss(y_pred, y_true.expand_as(y_pred), reduction='none')
200
+ )
201
+
202
+
203
+ def mae_pack(*, y_true: Tensor, y_pred: Tensor) -> Tensor:
204
+ """Compute the mean absolute error for each pack member."""
205
+ if not torch.is_floating_point(y_true):
206
+ raise TypeError('Labels must be floating-point values')
207
+ if y_true.dtype != y_pred.dtype:
208
+ raise TypeError('Labels and predictions must have the same data type')
209
+ _check_y_shapes(y_true=y_true, y_pred=y_pred, n_trailing_dims=0)
210
+
211
+ return _mean_nonpack(
212
+ nn.functional.l1_loss(y_pred, y_true.expand_as(y_pred), reduction='none')
213
+ )
214
+
215
+
216
+ def r2_pack(*, y_true: Tensor, y_pred: Tensor, force_finite: bool = True) -> Tensor:
217
+ """Compute the R2 score for each pack member."""
218
+ if not torch.is_floating_point(y_true):
219
+ raise TypeError('Labels must be floating-point values')
220
+ if y_true.dtype != y_pred.dtype:
221
+ raise TypeError('Labels and predictions must have the same data type')
222
+ _check_y_shapes(y_true=y_true, y_pred=y_pred, n_trailing_dims=0)
223
+
224
+ numerator = _sum_nonpack((y_pred - y_true).square())
225
+ if y_true.ndim < y_pred.ndim:
226
+ shape_without_pack_dim = torch.Size(
227
+ [x for i, x in enumerate(y_pred.shape) if i != PACK_DIM]
228
+ )
229
+ denominator = torch.sum(
230
+ torch.broadcast_to(
231
+ (y_true - y_true.mean()).square(), shape_without_pack_dim
232
+ )
233
+ )
234
+ else:
235
+ denominator = _sum_nonpack(
236
+ (
237
+ y_true
238
+ - _mean_nonpack(y_true).reshape(
239
+ [-1 if dim == PACK_DIM else 1 for dim in range(y_true.ndim)]
240
+ )
241
+ ).square()
242
+ )
243
+ ratio = numerator / denominator
244
+
245
+ if force_finite:
246
+ # Map constant-target cases to scores of 1 (perfect) or 0 before checking
247
+ # the sample count below.
248
+ ratio = torch.where(
249
+ numerator == 0.0, 0.0, torch.where(denominator == 0.0, 1.0, ratio)
250
+ )
251
+
252
+ n_objects = y_pred.numel() // y_pred.shape[PACK_DIM]
253
+ return torch.full_like(ratio, torch.nan) if n_objects < 2 else 1.0 - ratio