tabdpt 1.3.0__tar.gz → 1.3.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabdpt
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: TabDPT: Scaling Tabular Foundation Models on Real Data
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/layer6ai-labs/TabDPT-inference
@@ -31,6 +31,8 @@ Requires-Dist: openml==0.15.1; extra == "reproduce-results"
31
31
  Requires-Dist: jupyter==1.1.1; extra == "reproduce-results"
32
32
  Requires-Dist: tenacity==9.1.2; extra == "reproduce-results"
33
33
  Requires-Dist: gdown==5.2.0; extra == "reproduce-results"
34
+ Provides-Extra: explain
35
+ Requires-Dist: captum~=0.9.0; extra == "explain"
34
36
  Dynamic: license-file
35
37
 
36
38
  <div align="center">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tabdpt"
7
- version = "1.3.0"
7
+ version = "1.3.2"
8
8
  description = "TabDPT: Scaling Tabular Foundation Models on Real Data"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -37,6 +37,9 @@ reproduce-results = [
37
37
  "tenacity==9.1.2",
38
38
  "gdown==5.2.0",
39
39
  ]
40
+ explain = [
41
+ "captum~=0.9.0"
42
+ ]
40
43
 
41
44
  [project.urls]
42
45
  Repository = "https://github.com/layer6ai-labs/TabDPT-inference"
@@ -54,6 +57,7 @@ show_missing = true
54
57
 
55
58
  [dependency-groups]
56
59
  dev = [
60
+ "tabdpt[explain]",
57
61
  "pytest>=8.4.1",
58
62
  "pytest-cov>=5.0.0,<8.0",
59
63
  "rliable>=1.2.0,<2.0",
@@ -8,6 +8,7 @@ from .bar_distribution import (
8
8
  )
9
9
  from .classifier import TabDPTClassifier
10
10
  from .regressor import OutputType, TabDPTRegressor
11
+ from .explain import integrated_gradients
11
12
 
12
13
  __all__ = [
13
14
  "TabDPTClassifier",
@@ -19,4 +20,5 @@ __all__ = [
19
20
  "distribution_mode",
20
21
  "distribution_quantiles",
21
22
  "distribution_sample",
23
+ "integrated_gradients",
22
24
  ]
@@ -7,7 +7,7 @@ from scipy.special import softmax
7
7
  from sklearn.base import ClassifierMixin
8
8
 
9
9
  from .estimator import TabDPTEstimator
10
- from .utils import generate_random_permutation, pad_x
10
+ from .utils import generate_random_permutation
11
11
 
12
12
 
13
13
  class TabDPTClassifier(TabDPTEstimator, ClassifierMixin):
@@ -99,81 +99,50 @@ class TabDPTClassifier(TabDPTEstimator, ClassifierMixin):
99
99
  Returns:
100
100
  A class probability array of size `n_instances` x `n_classes`.
101
101
  """
102
- train_x, train_y, test_x = self._prepare_prediction(X, class_perm=class_perm, seed=seed)
103
- num_features = torch.tensor([train_x.shape[-1]], dtype=torch.long, device=train_x.device)
102
+ prep = self._preprocess(X, context_size=context_size, seed=seed, class_perm=class_perm)
103
+ X_ctx, y_ctx, X_query = prep.X_ctx, prep.y_ctx, prep.X_query
104
+ num_features = torch.tensor([X_ctx.shape[-1]], dtype=torch.long, device=X_ctx.device)
104
105
 
105
106
  if context_size is None:
106
107
  context_size = np.inf
107
-
108
108
  if batch_size is None:
109
- if self.device == "cpu":
110
- batch_size = 4096
111
- else:
112
- batch_size = 128*1024
113
-
114
- if seed is not None:
115
- feat_perm = generate_random_permutation(train_x.shape[1], seed)
116
- train_x = train_x[:, feat_perm]
117
- test_x = test_x[:, feat_perm]
109
+ batch_size = self._default_batch_size()
118
110
 
119
111
  pred_list = []
120
- if self._uses_stacked_context(context_size):
121
- X_ctx, y_ctx = self._prepare_stacked_context(train_x, train_y, context_size, seed)
122
- X_test = pad_x(test_x[None, :, :], self.max_features).to(self.device)
123
-
124
- for b in range(math.ceil(len(self.X_test) / batch_size)):
125
- start = b * batch_size
126
- end = min(len(self.X_test), (b + 1) * batch_size)
127
-
128
- if self.num_classes <= self.max_num_classes:
129
- pred = self.model(
130
- x_src=torch.cat([X_ctx, X_test[:, start:end]], dim=1),
131
- y_src=y_ctx,
132
- is_cls=True
133
- )
134
- pred = torch.nn.functional.log_softmax(pred[..., : self.num_classes].float(), dim=-1)
135
- else:
136
- pred = self._predict_large_cls(X_ctx, X_test[:, start:end], y_ctx, num_features)
137
-
138
- if not return_logits:
139
- pred = pred[..., :self.num_classes] / temperature
140
- pred = torch.nn.functional.softmax(pred.float(), dim=-1)
141
- pred_list.append(pred.squeeze(1))
142
- else:
143
- for b in range(math.ceil(len(self.X_test) / batch_size)):
144
- start = b * batch_size
145
- end = min(len(self.X_test), (b + 1) * batch_size)
146
-
147
- indices_nni = self._get_context_indices(
148
- self.X_test[start:end], context_size=context_size, seed=seed
149
- )
150
- X_nni = train_x[torch.tensor(indices_nni)]
151
- y_nni = train_y[torch.tensor(indices_nni)]
152
-
153
- X_nni, y_nni = (
154
- pad_x(torch.Tensor(X_nni), self.max_features).to(self.device),
155
- torch.Tensor(y_nni).to(self.device),
112
+ for b in range(math.ceil(len(self.X_test) / batch_size)):
113
+ start = b * batch_size
114
+ end = min(len(self.X_test), (b + 1) * batch_size)
115
+
116
+ if self._uses_stacked_context(context_size):
117
+ X_query_batch = X_query[:, start:end]
118
+ X_ctx_batch = X_ctx
119
+ y_ctx_batch = y_ctx
120
+ else:
121
+ X_query_batch = X_query[start:end]
122
+ X_ctx_batch = X_ctx[prep.indices_nni[start:end]]
123
+ y_ctx_batch = y_ctx[prep.indices_nni[start:end]]
124
+
125
+ if self.num_classes <= self.max_num_classes:
126
+ pred = self.model(
127
+ x_src=torch.cat([X_ctx_batch, X_query_batch], dim=1),
128
+ y_src=y_ctx_batch,
129
+ is_cls=True
156
130
  )
157
- X_eval = test_x[start:end]
158
- X_eval = pad_x(X_eval.unsqueeze(1), self.max_features).to(self.device)
131
+ pred = torch.nn.functional.log_softmax(pred[..., : self.num_classes].float(), dim=-1)
132
+ else:
133
+ pred = self._predict_large_cls(X_ctx_batch, X_query_batch, y_ctx_batch, num_features)
159
134
 
160
- if self.num_classes <= self.max_num_classes:
161
- pred = self.model(
162
- x_src=torch.cat([X_nni, X_eval], dim=1),
163
- y_src=y_nni,
164
- is_cls=True
165
- )
166
- pred = torch.nn.functional.log_softmax(pred[..., : self.num_classes].float(), dim=-1)
167
- else:
168
- pred = self._predict_large_cls(X_nni, X_eval, y_nni, num_features)
135
+ if not return_logits:
136
+ pred = pred[..., :self.num_classes] / temperature
137
+ pred = torch.nn.functional.softmax(pred.float(), dim=-1)
169
138
 
170
- pred = pred.float()
139
+ if self._uses_stacked_context(context_size):
140
+ pred_list.append(pred.squeeze(1))
141
+ else:
171
142
  if not return_logits:
172
- pred = pred[..., :self.num_classes] / temperature
173
- pred = torch.nn.functional.softmax(pred, dim=-1)
174
- pred /= pred.sum(axis=-1, keepdims=True) # numerical stability
175
-
176
- pred_list.append(pred.squeeze(dim=0))
143
+ # numerical stability - not sure if needed now, but from legacy code
144
+ pred /= pred.sum(axis=-1, keepdims=True)
145
+ pred_list.append(pred.squeeze(0))
177
146
 
178
147
  pred_val = torch.cat(pred_list, dim=0).detach().cpu().float().numpy()
179
148
 
@@ -1,6 +1,7 @@
1
+ from __future__ import annotations
1
2
  import json
2
3
  from typing import Literal
3
- import warnings
4
+ from dataclasses import dataclass
4
5
 
5
6
  import numpy as np
6
7
  import torch
@@ -13,7 +14,7 @@ from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler, Po
13
14
  from sklearn.utils.validation import check_is_fitted
14
15
 
15
16
  from .model import TabDPTModel
16
- from .utils import convert_to_torch_tensor, Log1pScaler, generate_random_permutation, pad_x
17
+ from .utils import convert_to_torch_tensor, Log1pScaler, generate_random_permutation, pad_x, normalize_data
17
18
 
18
19
  # Constants for model caching and download
19
20
  _VERSION = "1_3"
@@ -90,21 +91,10 @@ class TabDPTEstimator(BaseEstimator):
90
91
  self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
91
92
  self.use_flash = use_flash and self.device == "cuda"
92
93
  self.missing_indicators = missing_indicators
94
+ self.clip_sigma = clip_sigma
95
+ self.model_weight_path = model_weight_path
93
96
 
94
- if model_weight_path:
95
- self.path = model_weight_path
96
- else:
97
- self.path = self.download_weights()
98
-
99
- with safe_open(self.path, framework="pt", device=self.device) as f:
100
- meta = f.metadata()
101
- cfg_dict = json.loads(meta["cfg"])
102
- cfg = OmegaConf.create(cfg_dict)
103
- model_state = {k: f.get_tensor(k) for k in f.keys()}
104
-
105
- cfg.env.device = self.device
106
- self.model = TabDPTModel.load(model_state=model_state, config=cfg, use_flash=self.use_flash, clip_sigma=clip_sigma)
107
- self.model.eval()
97
+ self._load_model()
108
98
 
109
99
  self.max_features = self.model.num_features
110
100
  self.max_num_classes = self.model.n_out
@@ -151,6 +141,32 @@ class TabDPTEstimator(BaseEstimator):
151
141
 
152
142
  self.projection = None
153
143
 
144
+ def _load_model(self) -> None:
145
+ """Read the checkpoint and build ``self.model``; the constructor calls this once.
146
+
147
+ Separated out so a caller can share one network across several estimators built with the
148
+ same ``model_weight_path`` (or the same default download), ``use_flash`` and ``clip_sigma``:
149
+ build one estimator, then for the next skip its own load by overriding or monkeypatching
150
+ this method to hand back the cached module instead of rebuilding it. Downloads from
151
+ Hugging Face when ``model_weight_path`` is unset.
152
+ """
153
+ if self.model_weight_path:
154
+ self.path = self.model_weight_path
155
+ else:
156
+ self.path = self.download_weights()
157
+
158
+ with safe_open(self.path, framework="pt", device=self.device) as f:
159
+ meta = f.metadata()
160
+ cfg_dict = json.loads(meta["cfg"])
161
+ cfg = OmegaConf.create(cfg_dict)
162
+ model_state = {k: f.get_tensor(k) for k in f.keys()}
163
+
164
+ cfg.env.device = self.device
165
+ self.model = TabDPTModel.load(
166
+ model_state=model_state, config=cfg, use_flash=self.use_flash, clip_sigma=self.clip_sigma
167
+ )
168
+ self.model.eval()
169
+
154
170
  def fit(self, X: np.ndarray, y: np.ndarray):
155
171
  assert isinstance(X, np.ndarray), "X must be a numpy array"
156
172
  assert isinstance(y, np.ndarray), "y must be a numpy array"
@@ -268,8 +284,39 @@ class TabDPTEstimator(BaseEstimator):
268
284
  y_ctx = ctx_y[None, :].float()
269
285
  return X_ctx, y_ctx
270
286
 
271
- def _prepare_prediction(self, X: np.ndarray, class_perm: np.ndarray | None = None, seed: int | None = None):
287
+ def _get_ensemble_iterator(self, n_ensembles: int, seed: int | None = None):
288
+ generator = np.random.SeedSequence(seed)
289
+ ensemble_iterator = generator.generate_state(n_ensembles)
290
+ if self.verbose:
291
+ from tqdm import tqdm
292
+ ensemble_iterator = tqdm(ensemble_iterator, desc="ensembles")
293
+ return ensemble_iterator
294
+
295
+ @dataclass
296
+ class PreprocessResult:
297
+ X_ctx: torch.Tensor
298
+ y_ctx: torch.Tensor
299
+ X_query: torch.Tensor
300
+ feat_perm: torch.Tensor
301
+ mean_y: torch.Tensor | None
302
+ std_y: torch.Tensor | None
303
+ indices_nni: torch.Tensor | None
304
+
305
+ def _preprocess(
306
+ self,
307
+ X: np.ndarray,
308
+ context_size: int | None = None,
309
+ seed: int | None = None,
310
+ class_perm: np.ndarray | None = None,
311
+ normalize_y_ctx: bool = False
312
+ ) -> TabDPTEstimator.PreprocessResult:
313
+ """
314
+ Do all operations requred to go from a predict function input to tensors for the model
315
+ """
316
+
272
317
  check_is_fitted(self)
318
+ if context_size is None:
319
+ context_size = np.inf
273
320
 
274
321
  if self.missing_indicators:
275
322
  inds = np.isnan(X)[:, self.has_missing_indicator].astype(float)
@@ -280,35 +327,54 @@ class TabDPTEstimator(BaseEstimator):
280
327
  if self.normalizer == 'quantile-uniform':
281
328
  self.X_test = 2*self.X_test - 1
282
329
 
283
- train_x, train_y, test_x = (
330
+ X_ctx, y_ctx, X_query = (
284
331
  convert_to_torch_tensor(self.X_train).to(self.device).float(),
285
332
  convert_to_torch_tensor(self.y_train).to(self.device).float(),
286
333
  convert_to_torch_tensor(self.X_test).to(self.device).float(),
287
334
  )
288
335
 
289
336
  # Apply PCA/subsampling to reduce the number of features if necessary
337
+ feat_perm = None
290
338
  if self.n_features > self.max_features:
291
339
  if self.feature_reduction == "pca":
292
- train_x = train_x @ self.projection
293
- test_x = test_x @ self.projection
340
+ X_ctx = X_ctx @ self.projection
341
+ X_query = X_query @ self.projection
294
342
  elif self.feature_reduction == "subsample":
295
- feat_perm = generate_random_permutation(train_x.shape[1], seed)
296
- train_x = train_x[:, feat_perm][:, :self.max_features]
297
- test_x = test_x[:, feat_perm][:, :self.max_features]
343
+ feat_perm = generate_random_permutation(X_ctx.shape[1], seed)
344
+ X_ctx = X_ctx[:, feat_perm][:, :self.max_features]
345
+ X_query = X_query[:, feat_perm][:, :self.max_features]
298
346
 
299
347
  if class_perm is not None:
300
348
  assert self.mode == "cls", "class_perm only makes sense for classification"
301
349
  inv_perm = np.argsort(class_perm)
302
- train_y = train_y.to(torch.long)
303
- inv_perm = torch.as_tensor(inv_perm, device=train_y.device)
304
- train_y = inv_perm[train_y].to(torch.float)
305
-
306
- return train_x, train_y, test_x
350
+ y_ctx = y_ctx.to(torch.long)
351
+ inv_perm = torch.as_tensor(inv_perm, device=y_ctx.device)
352
+ y_ctx = inv_perm[y_ctx].to(torch.float)
307
353
 
308
- def _get_ensemble_iterator(self, n_ensembles: int, seed: int | None = None):
309
- generator = np.random.SeedSequence(seed)
310
- ensemble_iterator = generator.generate_state(n_ensembles)
311
- if self.verbose:
312
- from tqdm import tqdm
313
- ensemble_iterator = tqdm(ensemble_iterator, desc="ensembles")
314
- return ensemble_iterator
354
+ if feat_perm is None:
355
+ if seed is not None:
356
+ feat_perm = generate_random_permutation(X_ctx.shape[1], seed)
357
+ X_ctx = X_ctx[:, feat_perm]
358
+ X_query = X_query[:, feat_perm]
359
+ else:
360
+ feat_perm = torch.arange(X_ctx.shape[1])
361
+
362
+ if normalize_y_ctx:
363
+ y_ctx, mean_y, std_y = normalize_data(y_ctx, return_mean_std=True)
364
+ else:
365
+ mean_y, std_y = None, None
366
+
367
+ if not self._uses_stacked_context(context_size):
368
+ indices_nni = self._get_context_indices(
369
+ self.X_test, context_size=context_size, seed=seed
370
+ )
371
+ X_ctx = pad_x(X_ctx, self.max_features)
372
+ X_query = pad_x(X_query.unsqueeze(1), self.max_features).to(self.device)
373
+ return self.PreprocessResult(X_ctx, y_ctx, X_query, feat_perm, mean_y, std_y, torch.tensor(indices_nni))
374
+
375
+ X_ctx, y_ctx = self._prepare_stacked_context(X_ctx, y_ctx, context_size, seed)
376
+ X_query = pad_x(X_query[None, :, :], self.max_features).to(self.device)
377
+ return self.PreprocessResult(X_ctx, y_ctx, X_query, feat_perm, mean_y, std_y, None)
378
+
379
+ def _default_batch_size(self):
380
+ return 4096 if self.device == "cpu" else 128*1024
@@ -0,0 +1,134 @@
1
+ import math
2
+ import warnings
3
+ import numpy as np
4
+ import torch
5
+
6
+ from .classifier import TabDPTClassifier
7
+ from .regressor import TabDPTRegressor
8
+
9
+
10
+ def integrated_gradients(
11
+ fitted_model: TabDPTRegressor | TabDPTClassifier,
12
+ X_test: np.ndarray,
13
+ class_index: int = 0,
14
+ batch_size: int | None = None,
15
+ n_steps: int = 50,
16
+ context_size: int | None = None,
17
+ seed: int | None = None,
18
+ class_perm: np.ndarray | None = None
19
+ ) -> np.ndarray:
20
+ """
21
+ Runs integrated gradients for one output and returns local attributions.
22
+
23
+ Attributions for classification are relative to logit space (normalized by log_softmax), and attributions for
24
+ regression are relative to output values after normalization by target mean/std.
25
+
26
+ Options for baselines are not included because by default, mean/std scaling is applied, meaning that a zero baseline
27
+ is reasonable, and using the model without similar scaling would be an uncommon use case.
28
+
29
+ Retrieval is not supported since the context will no longer be relevant when testing points along the integration
30
+ path. Support for high numbers of classes is not yet implemented, although integrated gradients does not scale well
31
+ to high numbers of classes anyway.
32
+
33
+ Args:
34
+ fitted_model: TabDPT model after running .fit
35
+ X_test: Model inputs to attribute, of size `n_test` x `n_features`
36
+ class_index: For classifiers, the index of the class to produce attributions for. Logits are normalized using
37
+ log_softmax, so for the binary classification case, only explaining either class index 0 or 1 should suffice,
38
+ but multiple runs could be required for >2 classes.
39
+ batch_size: Internal test batch size for forward and backward passes. The context is shared and batching
40
+ across the sequence dimension is used. Note that IG requires `n_steps*X_test.shape[0]` actual test points, so
41
+ larger batch sizes than the size of `X_test` can be used if memory permits.
42
+ n_steps: Number of steps to integrate over
43
+ context_size, seed, class_perm: Same meaning as in predict functions
44
+
45
+ Returns:
46
+ Local attributions of size `n_test` x `n_features`
47
+ """
48
+ from captum.attr import IntegratedGradients
49
+
50
+ if context_size is not None and not fitted_model._uses_stacked_context(context_size):
51
+ raise NotImplementedError(
52
+ "Integrated gradients only supports shared contexts for test points. Note that IG with retrieval is not "
53
+ "well-defined since the context depends on the test point values."
54
+ )
55
+
56
+ is_cls = isinstance(fitted_model, TabDPTClassifier)
57
+ if is_cls and fitted_model.num_classes > fitted_model.max_num_classes:
58
+ raise NotImplementedError(
59
+ "Integrated gradients not implemented for high numbers of classes, since this requires multiple forward "
60
+ "passes internally."
61
+ )
62
+
63
+ if fitted_model.scaler is None:
64
+ warnings.warn(
65
+ "Using integrated gradients with no scaler will likely produce invalid results since we assume a zero baseline"
66
+ )
67
+
68
+ if batch_size is None:
69
+ batch_size = fitted_model._default_batch_size()
70
+
71
+ prep = fitted_model._preprocess(
72
+ X_test,
73
+ context_size=context_size,
74
+ seed=seed,
75
+ class_perm=class_perm,
76
+ normalize_y_ctx=not is_cls
77
+ )
78
+ X_ctx, y_ctx, X_query, feat_perm = prep.X_ctx, prep.y_ctx, prep.X_query, prep.feat_perm
79
+ feat_perm = feat_perm.cpu().numpy()
80
+
81
+ def cls_wrapped_forward(X_query):
82
+ x_src = torch.cat([X_ctx, X_query.unsqueeze(0)], dim=1)
83
+ pred = fitted_model.model(x_src=x_src, y_src=y_ctx, is_cls=True)
84
+ return torch.nn.functional.log_softmax(pred[..., :fitted_model.num_classes], dim=-1).squeeze(1)
85
+
86
+ def reg_wrapped_forward(X_query):
87
+ x_src = torch.cat([X_ctx, X_query.unsqueeze(0)], dim=1)
88
+ pred = fitted_model.model(x_src=x_src, y_src=y_ctx, is_cls=False)
89
+ logits = pred.squeeze(1)[:, fitted_model.max_num_classes:].float()
90
+ ret = fitted_model._expectation_from_regression_logits(logits)
91
+ return ret
92
+
93
+ forward = cls_wrapped_forward if is_cls else reg_wrapped_forward
94
+ ig = IntegratedGradients(forward)
95
+
96
+ attrs = []
97
+ for b in range(math.ceil(X_query.shape[1] / batch_size)):
98
+ attrs.append(ig.attribute(
99
+ X_query.squeeze(0)[b*batch_size : (b+1)*batch_size],
100
+ internal_batch_size=batch_size,
101
+ n_steps=n_steps,
102
+ target=class_index if is_cls else None
103
+ ).cpu().numpy())
104
+ attrs = np.concatenate(attrs)
105
+
106
+ if fitted_model.n_features > fitted_model.max_features and fitted_model.feature_reduction == "pca":
107
+ # In the case of PCA projection, IG is done with respect to the principal components: u = x @ V, so divide by u
108
+ # to get the integral, multiply by V.T to make the inner derivative in the IG formula be with respect to x
109
+ # (chain rule), then multiply by x.
110
+ pca_X = X_query.squeeze(0).cpu().numpy()
111
+ integral = np.divide(attrs, pca_X, out=np.zeros_like(attrs), where=pca_X!=0) # Avoid divide by zero
112
+ inv_perm = np.argsort(feat_perm)
113
+ integral = integral[:, inv_perm]
114
+ proj = fitted_model.projection.cpu().numpy()
115
+ # Padding to max_features in _preprocess can lead to X_query/integral being larger than projection matrix
116
+ attrs = integral[:, :proj.shape[1]] @ proj.T * fitted_model.X_test
117
+ else:
118
+ inv_perm = np.argsort(feat_perm)
119
+ # If feature subsampling is used, feat_perm will be longer than attrs, so we need to add zero attributions
120
+ if attrs.shape[1] < len(inv_perm):
121
+ attrs = np.pad(attrs, ((0, 0), (0, len(inv_perm) - attrs.shape[1])))
122
+ attrs = attrs[:, inv_perm]
123
+
124
+ # Add back any columns removed by SimpleImputer
125
+ for i in np.nonzero(np.isnan(fitted_model.imputer.statistics_))[0]:
126
+ attrs = np.insert(attrs, i, np.zeros_like(attrs[:, 0]), axis=1)
127
+
128
+ if fitted_model.missing_indicators and any(fitted_model.has_missing_indicator):
129
+ n_missing_indicators = np.sum(fitted_model.has_missing_indicator)
130
+ feat_attrs = attrs[:, :-n_missing_indicators]
131
+ feat_attrs[:, fitted_model.has_missing_indicator] += attrs[:, -n_missing_indicators:]
132
+ return feat_attrs
133
+
134
+ return attrs
@@ -7,7 +7,6 @@ from sklearn.base import RegressorMixin
7
7
 
8
8
  from .bar_distribution import FullPrediction
9
9
  from .estimator import TabDPTEstimator
10
- from .utils import generate_random_permutation, pad_x, normalize_data
11
10
 
12
11
  OutputType = Literal["mean", "full"]
13
12
 
@@ -74,73 +73,44 @@ class TabDPTRegressor(TabDPTEstimator, RegressorMixin):
74
73
  seed: int | None = None,
75
74
  return_logits: bool = False,
76
75
  ):
77
- train_x, train_y, test_x = self._prepare_prediction(X, seed=seed)
78
- num_features = torch.tensor([train_x.shape[-1]], dtype=torch.long, device=train_x.device)
76
+ prep = self._preprocess(X, context_size=context_size, seed=seed, normalize_y_ctx=True)
77
+ X_ctx, y_ctx, X_query, mean_y, std_y = prep.X_ctx, prep.y_ctx, prep.X_query, prep.mean_y, prep.std_y
79
78
 
80
79
  if context_size is None:
81
80
  context_size = np.inf
82
-
83
81
  if batch_size is None:
84
- if self.device == "cpu":
85
- batch_size = 4096
86
- else:
87
- batch_size = 128*1024
88
-
89
- if seed is not None:
90
- feat_perm = generate_random_permutation(train_x.shape[1], seed)
91
- train_x = train_x[:, feat_perm]
92
- test_x = test_x[:, feat_perm]
93
-
94
- train_y, mean_y, std_y = normalize_data(train_y, return_mean_std=True)
82
+ batch_size = self._default_batch_size()
95
83
 
96
84
  pred_list = []
97
- if self._uses_stacked_context(context_size):
98
- X_ctx, y_ctx = self._prepare_stacked_context(train_x, train_y, context_size, seed)
99
- X_test = pad_x(test_x[None, :, :], self.max_features).to(self.device)
100
-
101
- for b in range(math.ceil(len(self.X_test) / batch_size)):
102
- start = b * batch_size
103
- end = min(len(self.X_test), (b + 1) * batch_size)
104
-
105
- pred = self.model(
106
- x_src=torch.cat([X_ctx, X_test[:, start:end]], dim=1),
107
- y_src=y_ctx,
108
- is_cls=False
109
- )
110
- logits = pred.squeeze(1)[:, self.max_num_classes:].float()
111
- if return_logits:
112
- pred_list.append(logits)
113
- else:
114
- y_hat = self._expectation_from_regression_logits(logits)
115
- pred_list.append(y_hat * std_y + mean_y)
116
- else:
117
- for b in range(math.ceil(len(self.X_test) / batch_size)):
118
- start = b * batch_size
119
- end = min(len(self.X_test), (b + 1) * batch_size)
120
-
121
- indices_nni = self._get_context_indices(
122
- self.X_test[start:end], context_size=context_size, seed=seed
123
- )
124
- X_nni = train_x[torch.tensor(indices_nni)]
125
- y_nni = train_y[torch.tensor(indices_nni)]
85
+ for b in range(math.ceil(len(self.X_test) / batch_size)):
86
+ start = b * batch_size
87
+ end = min(len(self.X_test), (b + 1) * batch_size)
88
+
89
+ if self._uses_stacked_context(context_size):
90
+ X_query_batch = X_query[:, start:end]
91
+ X_ctx_batch = X_ctx
92
+ y_ctx_batch = y_ctx
93
+ else:
94
+ X_query_batch = X_query[start:end]
95
+ X_ctx_batch = X_ctx[prep.indices_nni[start:end]]
96
+ y_ctx_batch = y_ctx[prep.indices_nni[start:end]]
97
+
98
+ pred = self.model(
99
+ x_src=torch.cat([X_ctx_batch, X_query_batch], dim=1),
100
+ y_src=y_ctx_batch,
101
+ is_cls=False
102
+ )
103
+ if self._uses_stacked_context(context_size):
104
+ pred = pred.squeeze(1)
105
+ else:
106
+ pred = pred.squeeze(0)
107
+ logits = pred[:, self.max_num_classes:].float()
126
108
 
127
- X_nni, y_nni = (
128
- pad_x(torch.Tensor(X_nni), self.max_features).to(self.device),
129
- torch.Tensor(y_nni).to(self.device),
130
- )
131
- X_eval = test_x[start:end]
132
- X_eval = pad_x(X_eval.unsqueeze(1), self.max_features).to(self.device)
133
- pred = self.model(
134
- x_src=torch.cat([X_nni, X_eval], dim=1),
135
- y_src=y_nni,
136
- is_cls=False
137
- )
138
- logits = pred.squeeze(0)[:, self.max_num_classes:].float()
139
- if return_logits:
140
- pred_list.append(logits)
141
- else:
142
- y_hat = self._expectation_from_regression_logits(logits)
143
- pred_list.append(y_hat * std_y + mean_y)
109
+ if return_logits:
110
+ pred_list.append(logits)
111
+ else:
112
+ y_hat = self._expectation_from_regression_logits(logits)
113
+ pred_list.append(y_hat * std_y + mean_y)
144
114
 
145
115
  preds = torch.cat(pred_list, dim=0)
146
116
  if return_logits:
@@ -40,13 +40,14 @@ def maskmean(x, mask, dim):
40
40
 
41
41
  def maskstd(x, mask, dim=0):
42
42
  num = mask.sum(dim=dim, keepdim=True)
43
- mean = maskmean(x, mask, dim=0)
43
+ mean = maskmean(x, mask, dim=dim)
44
44
  diffs = torch.where(mask, mean - x, 0)
45
- return ((diffs**2).sum(dim=0, keepdim=True) / (num - 1)) ** 0.5
45
+ return ((diffs**2).sum(dim=dim, keepdim=True) / (num - 1)) ** 0.5
46
46
 
47
47
 
48
48
  def normalize_data(data, eval_pos=-1, dim=0, return_mean_std: bool = False):
49
49
  X = data[:eval_pos] if eval_pos > 0 else data
50
+ X = X.detach() # Allow gradient computations wrt inputs
50
51
  mask = ~torch.isnan(X)
51
52
  mean = maskmean(X, mask, dim=dim)
52
53
  std = maskstd(X, mask, dim=dim) + 1e-6
@@ -58,6 +59,7 @@ def normalize_data(data, eval_pos=-1, dim=0, return_mean_std: bool = False):
58
59
 
59
60
  def clip_outliers(data, eval_pos=-1, n_sigma=4, dim=0):
60
61
  X = data[:eval_pos] if eval_pos > 0 else data
62
+ X = X.detach() # Allow gradient computations wrt inputs
61
63
  mask = ~torch.isnan(X)
62
64
  mean = maskmean(X, mask, dim=dim)
63
65
  cutoff = n_sigma * maskstd(X, mask, dim=dim)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabdpt
3
- Version: 1.3.0
3
+ Version: 1.3.2
4
4
  Summary: TabDPT: Scaling Tabular Foundation Models on Real Data
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/layer6ai-labs/TabDPT-inference
@@ -31,6 +31,8 @@ Requires-Dist: openml==0.15.1; extra == "reproduce-results"
31
31
  Requires-Dist: jupyter==1.1.1; extra == "reproduce-results"
32
32
  Requires-Dist: tenacity==9.1.2; extra == "reproduce-results"
33
33
  Requires-Dist: gdown==5.2.0; extra == "reproduce-results"
34
+ Provides-Extra: explain
35
+ Requires-Dist: captum~=0.9.0; extra == "explain"
34
36
  Dynamic: license-file
35
37
 
36
38
  <div align="center">
@@ -5,6 +5,7 @@ src/tabdpt/__init__.py
5
5
  src/tabdpt/bar_distribution.py
6
6
  src/tabdpt/classifier.py
7
7
  src/tabdpt/estimator.py
8
+ src/tabdpt/explain.py
8
9
  src/tabdpt/model.py
9
10
  src/tabdpt/regressor.py
10
11
  src/tabdpt/utils.py
@@ -15,6 +16,7 @@ src/tabdpt.egg-info/requires.txt
15
16
  src/tabdpt.egg-info/top_level.txt
16
17
  tests/test_cls.py
17
18
  tests/test_estimator.py
19
+ tests/test_explain.py
18
20
  tests/test_inference.py
19
21
  tests/test_reg.py
20
22
  tests/test_utils.py
@@ -8,6 +8,9 @@ scipy<2.0,>=1.9.0
8
8
  torch<3.0,>=2.6.0
9
9
  tqdm<5.0,>=4.38.0
10
10
 
11
+ [explain]
12
+ captum~=0.9.0
13
+
11
14
  [reproduce-results]
12
15
  faiss-cpu==1.11.0
13
16
  omegaconf==2.3.0
@@ -82,3 +82,34 @@ def test_invalid_feature_reduction_raises():
82
82
  def test_invalid_faiss_metric_raises():
83
83
  with pytest.raises(AssertionError):
84
84
  build(faiss_metric="bad")
85
+
86
+
87
+ # --- Separable model loading ---
88
+
89
+ def test_load_model_is_separately_callable():
90
+ """``_load_model`` can be re-run (or overridden) after construction to swap the network."""
91
+ est = build()
92
+ first_model = est.model
93
+ est._load_model()
94
+ assert est.model is not first_model # a fresh module, same checkpoint
95
+ assert est.max_features == MAX_FEATURES # unaffected: derived once, from the first load
96
+
97
+
98
+ def test_load_model_can_be_overridden_to_share_a_network():
99
+ """A caller can skip the per-instance download/build entirely by overriding ``_load_model``."""
100
+ donor = build()
101
+
102
+ class _SharedWeightEstimator(TabDPTEstimator):
103
+ def _load_model(self) -> None:
104
+ self.model = donor.model # reuse the donor's network instead of loading one
105
+
106
+ est = _SharedWeightEstimator(mode="cls", device=DEVICE)
107
+ assert est.model is donor.model
108
+
109
+
110
+ def test_get_params_reads_every_constructor_argument():
111
+ """``clip_sigma`` and ``model_weight_path`` are stored on ``self``, as sklearn's contract requires."""
112
+ est = build(clip_sigma=4.0)
113
+ params = est.get_params()
114
+ assert params["clip_sigma"] == 4.0
115
+ assert params["model_weight_path"] is None
@@ -0,0 +1,110 @@
1
+ import unittest
2
+
3
+ import numpy as np
4
+
5
+ from tabdpt import TabDPTClassifier, TabDPTRegressor
6
+ from tabdpt.explain import integrated_gradients
7
+
8
+ from device_utils import pick_device, seed_everything
9
+
10
+ DEVICE = pick_device()
11
+ N_TRAIN = 2000
12
+ N_TEST = 100
13
+ N_COL = 4
14
+
15
+ class TestIntegratedGradients(unittest.TestCase):
16
+ @classmethod
17
+ def setUpClass(cls):
18
+ seed_everything(0)
19
+ cls.gen = np.random.default_rng(0)
20
+ cls.X_train = cls.gen.normal(size=(N_TRAIN, N_COL))
21
+ cls.X_test = cls.gen.normal(size=(N_TEST, N_COL))
22
+ # Expected attributions: feature 1 > feature 2, feature 0 and 3 uninmportant
23
+ cls.y_reg = cls.X_train[:,1] + 0.1*cls.X_train[:,2]
24
+ cls.y_cls = cls.y_reg > 0
25
+
26
+ def validate_attrs(self, attrs):
27
+ self.assertEqual(attrs.shape, (N_TEST, N_COL))
28
+ agg = np.mean(np.abs(attrs), axis=0)
29
+ self.assertGreater(agg[1], agg[2])
30
+ self.assertGreater(agg[2], agg[0])
31
+ self.assertGreater(agg[2], agg[3])
32
+ self.assertGreater(agg[1]/10, agg[0], "most important feature should be clearly above unimportant ones")
33
+ self.assertGreater(agg[1]/10, agg[3], "most important feature should be clearly above unimportant ones")
34
+
35
+ def test_regression(self):
36
+ model = TabDPTRegressor(device=DEVICE)
37
+ model.fit(self.X_train, self.y_reg)
38
+ attrs = integrated_gradients(model, self.X_test)
39
+ self.validate_attrs(attrs)
40
+
41
+ def test_classification(self):
42
+ model = TabDPTClassifier(device=DEVICE)
43
+ model.fit(self.X_train, self.y_cls)
44
+ attrs = integrated_gradients(model, self.X_test, class_index=1)
45
+ self.validate_attrs(attrs)
46
+
47
+ def test_no_retrieval(self):
48
+ model = TabDPTClassifier(context_reduction="retrieval", device=DEVICE)
49
+ model.fit(self.X_train, self.y_cls)
50
+ with self.assertRaises(NotImplementedError):
51
+ integrated_gradients(model, self.X_test, context_size=10)
52
+
53
+ def test_no_large_cls(self):
54
+ model = TabDPTClassifier(device=DEVICE)
55
+ n_classes = model.max_num_classes + 2
56
+ y = np.tile(np.arange(n_classes), len(self.y_cls) // n_classes + 1)[:len(self.y_cls)]
57
+ model.fit(self.X_train, y)
58
+ with self.assertRaises(NotImplementedError):
59
+ integrated_gradients(model, self.X_test)
60
+
61
+ def test_pca(self):
62
+ model = TabDPTRegressor(device=DEVICE)
63
+ n_features = model.max_features + 10
64
+ X_train = np.hstack((self.X_train, self.gen.normal(size=(N_TRAIN, n_features-N_COL))))
65
+ X_test = np.hstack((self.X_test, self.gen.normal(size=(N_TEST, n_features-N_COL))))
66
+ model.fit(X_train, self.y_reg)
67
+ attrs = integrated_gradients(model, X_test)
68
+ self.assertEqual(attrs.shape, (N_TEST, n_features))
69
+ agg = np.mean(np.abs(attrs), axis=0)
70
+ self.assertGreater(agg[1], agg[2])
71
+ self.assertGreater(agg[2], max(agg[0], np.max(agg[3:])))
72
+
73
+ def test_feature_subsample(self):
74
+ model = TabDPTRegressor(feature_reduction="subsample", device=DEVICE, compile=False, verbose=False)
75
+ n_features = model.max_features + 10
76
+ X_train = np.hstack((self.X_train, self.gen.normal(size=(N_TRAIN, n_features-N_COL))))
77
+ X_test = np.hstack((self.X_test, self.gen.normal(size=(N_TEST, n_features-N_COL))))
78
+ model.fit(X_train, self.y_reg)
79
+ attrs = integrated_gradients(model, X_test)
80
+ self.assertEqual(attrs.shape, (N_TEST, n_features))
81
+ # Can't assert feature ordering because relevant features might not have been sampled
82
+
83
+ def test_missing_indicators(self):
84
+ model = TabDPTRegressor(missing_indicators=True, device=DEVICE)
85
+ X_train = self.X_train.copy()
86
+ X_train[self.gen.uniform(size=X_train.shape[0]) > 0.9, 1] = np.nan
87
+ X_train[self.gen.uniform(size=X_train.shape[0]) > 0.5, 3] = np.nan
88
+ X_test = self.X_test.copy()
89
+ X_test[self.gen.uniform(size=X_test.shape[0]) > 0.9, 1] = np.nan
90
+ X_test[self.gen.uniform(size=X_test.shape[0]) > 0.5, 3] = np.nan
91
+ model.fit(X_train, self.y_reg)
92
+ attrs = integrated_gradients(model, X_test)
93
+ self.validate_attrs(attrs)
94
+
95
+ def test_batching(self):
96
+ model = TabDPTRegressor(device=DEVICE)
97
+ model.fit(self.X_train, self.y_reg)
98
+ attrs = integrated_gradients(model, self.X_test, batch_size=N_TEST // 10)
99
+ self.validate_attrs(attrs)
100
+
101
+ def test_nan_cols(self):
102
+ model = TabDPTClassifier(device=DEVICE)
103
+ X_train = self.X_train.copy()
104
+ X_train[:, 0] = np.nan
105
+ X_train[:, N_COL-1] = np.nan
106
+ model.fit(X_train, self.y_cls)
107
+ attrs = integrated_gradients(model, self.X_test)
108
+ self.assertEqual(attrs.shape, (N_TEST, N_COL))
109
+ self.assertTrue(np.all(attrs[:, 0] == 0))
110
+ self.assertTrue(np.all(attrs[:, N_COL-1] == 0))
@@ -26,11 +26,20 @@ class TestClassifier(unittest.TestCase):
26
26
  self.assertEqual(p.shape, (N_TRAIN, N_CLASSES))
27
27
  np.testing.assert_allclose(p.sum(axis=1), 1.0, atol=1e-4)
28
28
 
29
- def test_retrieval_path(self):
30
- p = self.model.predict_proba(self.X, context_size=20)
29
+ def test_batching(self):
30
+ p = self.model.predict_proba(self.X, batch_size=N_TRAIN//4)
31
31
  self.assertEqual(p.shape, (N_TRAIN, N_CLASSES))
32
32
  np.testing.assert_allclose(p.sum(axis=1), 1.0, atol=1e-4)
33
33
 
34
+ def test_context_reduction(self):
35
+ for context_reduction in ("retrieval", "subsample", "subsample-balanced"):
36
+ model = TabDPTClassifier(device=DEVICE, context_reduction=context_reduction)
37
+ model.fit(self.X, self.y)
38
+ for batch_size in (None, N_TRAIN//4):
39
+ p = model.predict_proba(self.X, context_size=20, batch_size=batch_size)
40
+ self.assertEqual(p.shape, (N_TRAIN, N_CLASSES))
41
+ np.testing.assert_allclose(p.sum(axis=1), 1.0, atol=1e-4)
42
+
34
43
  def test_return_logits(self):
35
44
  logits = self.model.predict_proba(self.X, context_size=2048, return_logits=True)
36
45
  self.assertEqual(logits.shape[0], N_TRAIN)
@@ -97,9 +106,13 @@ class TestRegressor(unittest.TestCase):
97
106
  pred = self.model.predict(self.X, n_ensembles=1, context_size=2048)
98
107
  self.assertEqual(pred.shape, (N_TRAIN,))
99
108
 
100
- def test_retrieval_path(self):
101
- pred = self.model.predict(self.X, n_ensembles=1, context_size=20)
102
- self.assertEqual(pred.shape, (N_TRAIN,))
109
+ def test_context_reduction(self):
110
+ for context_reduction in ("retrieval", "subsample"):
111
+ model = TabDPTRegressor(device=DEVICE, verbose=False)
112
+ model.fit(self.X, self.y)
113
+ for batch_size in (None, N_TRAIN//4):
114
+ pred = model.predict(self.X, n_ensembles=1, context_size=20, batch_size=batch_size)
115
+ self.assertEqual(pred.shape, (N_TRAIN,))
103
116
 
104
117
  def test_ensemble(self):
105
118
  pred = self.model.predict(self.X, n_ensembles=3, context_size=2048, seed=0)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes