tabctx 0.9.0__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.
tabctx/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """tabctx: multi-tenant context caching and serving for tabular
2
+ in-context-learning foundation models (TabICL, TabPFN, and similar).
3
+
4
+ The core idea: these models already cache their encoded training context
5
+ internally to speed up repeated predict() calls against the same training
6
+ set (both TabICL and TabPFN call this a "KV cache"), but only as a
7
+ single-process, single-estimator-object feature. tabctx makes that a real
8
+ multi-tenant, evictable, memory-governed resource -- the analog of what
9
+ PagedAttention did for per-request LLM KV caches.
10
+ """
11
+
12
+ from tabctx.cache.manager import CachedContext, ContextCacheManager
13
+ from tabctx.engine import TabctxEngine
14
+ from tabctx.errors import (
15
+ AdmissionRejected,
16
+ BackendComputeError,
17
+ CacheCapacityError,
18
+ DatasetNotFoundError,
19
+ InvalidInputError,
20
+ TabctxError,
21
+ UploadNotFoundError,
22
+ UploadTooLargeError,
23
+ )
24
+ from tabctx.types import EngineStats, PredictOutcome, Task
25
+
26
+ __all__ = [
27
+ "AdmissionRejected",
28
+ "BackendComputeError",
29
+ "CacheCapacityError",
30
+ "CachedContext",
31
+ "ContextCacheManager",
32
+ "DatasetNotFoundError",
33
+ "EngineStats",
34
+ "InvalidInputError",
35
+ "PredictOutcome",
36
+ "TabctxEngine",
37
+ "TabctxError",
38
+ "Task",
39
+ "UploadNotFoundError",
40
+ "UploadTooLargeError",
41
+ ]
@@ -0,0 +1,3 @@
1
+ from tabctx.backends.base import TabularICLBackend
2
+
3
+ __all__ = ["TabularICLBackend"]
@@ -0,0 +1,58 @@
1
+ """Backend protocol: what tabctx needs from a tabular ICL model implementation.
2
+
3
+ This is the seam that lets tabctx add a second backend (e.g. TabPFN) later
4
+ without touching the cache manager, memory estimator, or engine -- none of
5
+ those import a specific backend, they only depend on this protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Protocol
11
+
12
+ from tabctx.types import ArrayLike, PredictOutcome, Task
13
+
14
+
15
+ class TabularICLBackend(Protocol):
16
+ """A tabular in-context-learning model, wrapped for tabctx.
17
+
18
+ Implementations MUST construct a fresh underlying estimator/model
19
+ instance inside every fit() call. fit() mutates instance state (that's
20
+ the whole point -- it's where the training context gets encoded), so
21
+ reusing one shared instance across dataset_ids would silently corrupt
22
+ every cached context that isn't the most recent one.
23
+ """
24
+
25
+ name: str
26
+
27
+ def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
28
+ """Encode the training context. Returns an opaque, backend-owned
29
+ payload that the cache manager retains standalone (the engine never
30
+ inspects it) and later passes back into predict()."""
31
+ ...
32
+
33
+ def predict(
34
+ self, payload: Any, X_test: ArrayLike, return_proba: bool = False
35
+ ) -> PredictOutcome:
36
+ """Run inference against a cached context. Must be a SINGLE
37
+ inference pass regardless of return_proba -- derive predictions from
38
+ probabilities (e.g. argmax) rather than calling predict() and
39
+ predict_proba() separately, since each call is a full forward pass
40
+ over the cached context."""
41
+ ...
42
+
43
+ def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
44
+ """RESIDENT cost of the context most recently returned by fit(),
45
+ for cache capacity/eviction accounting -- called by the engine
46
+ AFTER fit(), not before, so an implementation can report a real
47
+ measurement (e.g. actual device memory delta) instead of a guess.
48
+ Return None if the backend has no opinion; the engine falls back
49
+ to its own (much more conservative, pre-fit-only) MemoryEstimator.
50
+
51
+ Implementations MAY also expose ``fit_peak_bytes_hint() -> int |
52
+ None`` (optional; the engine probes with getattr): the TRANSIENT
53
+ high-water memory of the most recent fit(). That is the
54
+ admission-relevant quantity -- peak is what actually OOMs, and it
55
+ can exceed the resident context by orders of magnitude -- and the
56
+ engine feeds it (not the resident size) back into the estimator's
57
+ admission learning."""
58
+ ...
@@ -0,0 +1,99 @@
1
+ """Deterministic backend with no GPU/torch/tabicl dependency, for unit tests.
2
+
3
+ Not a serious model: classification predicts the training majority class for
4
+ every test row, regression predicts the training mean. What matters for
5
+ tabctx's own tests is the *shape* of the contract (fit returns an opaque
6
+ payload, predict does one pass, context_bytes_hint is controllable), not
7
+ prediction quality.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections import Counter
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from tabctx.types import ArrayLike, PredictOutcome, Task
18
+
19
+
20
+ @dataclass
21
+ class _FakePayload:
22
+ task: Task
23
+ n_train: int
24
+ n_features: int
25
+ majority_class: str | None = None
26
+ class_fractions: dict[str, float] | None = None
27
+ mean_y: float | None = None
28
+
29
+
30
+ class FakeBackend:
31
+ """Test double. `fit_delay_s`/`predict_delay_s` let tests simulate work
32
+ without a real GPU, e.g. to assert cache reuse actually skips fit()."""
33
+
34
+ name = "fake"
35
+
36
+ def __init__(
37
+ self,
38
+ bytes_hint: int | None = None,
39
+ fit_delay_s: float = 0.0,
40
+ predict_delay_s: float = 0.0,
41
+ peak_bytes_hint: int | None = None,
42
+ name: str = "fake",
43
+ ) -> None:
44
+ self.name = name # instance override (tests deploy several fakes)
45
+ self._bytes_hint = bytes_hint
46
+ self._peak_bytes_hint = peak_bytes_hint
47
+ self._fit_delay_s = fit_delay_s
48
+ self._predict_delay_s = predict_delay_s
49
+ self.fit_calls = 0
50
+ self.predict_calls = 0
51
+
52
+ def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
53
+ self.fit_calls += 1
54
+ if self._fit_delay_s:
55
+ time.sleep(self._fit_delay_s)
56
+ n_train = len(X)
57
+ n_features = len(X[0]) if n_train else 0
58
+ if task == "classification":
59
+ counts = Counter(str(v) for v in y)
60
+ total = sum(counts.values()) or 1
61
+ return _FakePayload(
62
+ task=task,
63
+ n_train=n_train,
64
+ n_features=n_features,
65
+ majority_class=counts.most_common(1)[0][0] if counts else None,
66
+ class_fractions={k: v / total for k, v in counts.items()},
67
+ )
68
+ mean_y = sum(float(v) for v in y) / len(y) if len(y) else 0.0
69
+ return _FakePayload(
70
+ task=task, n_train=n_train, n_features=n_features, mean_y=mean_y
71
+ )
72
+
73
+ def predict(
74
+ self, payload: _FakePayload, X_test: ArrayLike, return_proba: bool = False
75
+ ) -> PredictOutcome:
76
+ self.predict_calls += 1
77
+ if self._predict_delay_s:
78
+ time.sleep(self._predict_delay_s)
79
+ n_test = len(X_test)
80
+ if payload.task == "classification":
81
+ predictions = [payload.majority_class] * n_test
82
+ probabilities = None
83
+ classes = None
84
+ if return_proba:
85
+ fractions = payload.class_fractions or {}
86
+ classes = sorted(fractions)
87
+ row = [fractions.get(c, 0.0) for c in classes]
88
+ probabilities = [row for _ in range(n_test)]
89
+ return PredictOutcome(
90
+ predictions=predictions, probabilities=probabilities, classes=classes
91
+ )
92
+ return PredictOutcome(predictions=[payload.mean_y] * n_test)
93
+
94
+ def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
95
+ del n_train, n_features # unused: this backend's hint is fixed at construction
96
+ return self._bytes_hint
97
+
98
+ def fit_peak_bytes_hint(self) -> int | None:
99
+ return self._peak_bytes_hint
@@ -0,0 +1,263 @@
1
+ """TabICL/TabICLv2 backend (github.com/soda-inria/tabicl).
2
+
3
+ Fixes two bugs found in the hand-rolled wrapper this library replaces
4
+ (inference-platform's tests/gke-tabicl-test/app.py):
5
+
6
+ 1. That wrapper called model.predict() and then model.predict_proba()
7
+ separately when return_proba was requested -- two full inference passes
8
+ for one request. Here, predict_proba() is called once and predictions are
9
+ derived from it via argmax, so return_proba never costs an extra pass.
10
+ 2. That wrapper built a fresh estimator per request and never reused
11
+ anything across requests, discarding the entire benefit of TabICL's own
12
+ fit-time context caching. Here, fit() still builds a fresh estimator per
13
+ call (required -- see backends/base.py), but the returned estimator is
14
+ retained by tabctx's ContextCacheManager and reused across many predict()
15
+ calls, which is the actual point of this library.
16
+
17
+ torch/tabicl are imported lazily inside this module (not at package import
18
+ time) so the rest of tabctx has no hard dependency on either.
19
+
20
+ EMPIRICAL CHECK (per the tabctx build plan, run locally on CPU before
21
+ finalizing this file): does constructing a fresh TabICLClassifier/Regressor
22
+ reload the checkpoint every time, or is it memoized in-process? Confirmed by
23
+ reading tabicl/_sklearn/classifier.py's _load_model(): huggingface_hub only
24
+ avoids re-downloading (the file is cached on disk after the first fetch),
25
+ but torch.load() + load_state_dict() run fresh on every instantiation --
26
+ there is no in-process memoization. Measured cost of that reload alone (CPU,
27
+ checkpoint already on disk): ~0.1s per fit() call, separate from and in
28
+ addition to the actual context-encoding cost. Fine for v1 (a fresh instance
29
+ per fit() is required regardless -- see backends/base.py), but a real
30
+ optimization opportunity for later: load the backbone weights once per
31
+ backend instance and share them across fit() calls, keeping only the
32
+ per-fit training-encoding state instance-specific.
33
+
34
+ REAL GPU MEMORY MEASUREMENT (found via extensive multi-tenant load testing
35
+ on a real A100-40GB, 2026-08-28): the formula-based MemoryEstimator, when
36
+ used to size a context for cache-capacity accounting, reported ~14x more
37
+ bytes than what was actually resident on the device for a realistic shape
38
+ (cache accounting said ~21.85GB used; torch.cuda.memory_allocated() said
39
+ ~1.56GB) -- because that estimator is calibrated on active train+test
40
+ forward-pass memory, not resting post-fit context size, and (separately)
41
+ already over-estimates small inputs (see memory/estimator.py). This
42
+ needlessly throttled effective multi-tenant capacity to a fraction of what
43
+ the hardware actually supports. Fixed here by measuring the real
44
+ torch.cuda.memory_allocated() delta across fit() and reporting it via
45
+ context_bytes_hint() -- safe because the engine holds its cache lock across
46
+ the whole fit() call (see engine.py), so no concurrent GPU work can pollute
47
+ the before/after delta.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from typing import Any
53
+
54
+ import numpy as np
55
+
56
+ from tabctx.errors import BackendComputeError
57
+ from tabctx.types import ArrayLike, PredictOutcome, Task
58
+
59
+
60
+ class TabICLBackend:
61
+ # The exact model id callers select via the API's `model` field --
62
+ # named for the checkpoint actually loaded (tabicl-classifier-v2-*).
63
+ name = "tabicl-v2"
64
+
65
+ def __init__(self, device: str | None = None, kv_cache: bool | str = "kv") -> None:
66
+ """kv_cache: passed through to TabICL ("kv", "repr", True, or
67
+ False). tabicl's own default is False, which makes every
68
+ predict() re-encode the ENTIRE training set through all three
69
+ transformer stages -- i.e. the exact cost tabctx exists to avoid
70
+ paying twice (confirmed by tracing predict_proba: with no cache,
71
+ transform(mode="both") re-concatenates and re-encodes train+test
72
+ on every call). tabctx defaults it ON ("kv": fastest, most
73
+ memory-hungry -- the memory cost lands inside fit()'s measured
74
+ delta, so cache accounting and the adaptive admission gate see it
75
+ automatically; "repr" trades ~24x less ICL-cache memory for
76
+ re-running the ICL stack per predict)."""
77
+ import torch
78
+
79
+ self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
80
+ self._kv_cache = kv_cache
81
+ self._last_context_bytes: int | None = None
82
+ self._last_fit_peak_bytes: int | None = None
83
+ # task -> attrs of an already-loaded backbone, shared across
84
+ # fits. Loading is ~hundreds of MB of torch.load + H2D transfer
85
+ # PER FIT without this. Sharing one nn.Module across estimators
86
+ # is tabicl's own sanctioned pattern (see
87
+ # _unsupervised/unsupervised.py and _finetune/base.py, both of
88
+ # which do exactly this with the comment "prevents redundant
89
+ # torch.load() calls"). Safe here because the engine serializes
90
+ # every backend call behind its cache lock -- the shared module's
91
+ # per-call mutable state (its _cache pointer, InferenceManager
92
+ # config) is never touched concurrently.
93
+ self._shared_backbones: dict[str, dict[str, Any]] = {}
94
+
95
+ def _make_estimator(self, task: Task) -> Any:
96
+ if task == "classification":
97
+ from tabicl import TabICLClassifier
98
+
99
+ est = TabICLClassifier(device=self._device, kv_cache=self._kv_cache)
100
+ else:
101
+ from tabicl import TabICLRegressor
102
+
103
+ est = TabICLRegressor(device=self._device, kv_cache=self._kv_cache)
104
+
105
+ shared = self._shared_backbones.get(task)
106
+ if shared is not None:
107
+ for attr, value in shared.items():
108
+ setattr(est, attr, value)
109
+ # Instance attribute shadows the method: fit() calls
110
+ # self._load_model() and gets this no-op instead of a fresh
111
+ # torch.load + load_state_dict.
112
+ est._load_model = lambda: None
113
+ return est
114
+
115
+ def _stash_backbone(self, task: Task, model: Any) -> None:
116
+ if task in self._shared_backbones:
117
+ return
118
+ shared = {"model_": model.model_}
119
+ # Keep save()/pickling of estimators working: __setstate__ only
120
+ # rebuilds from weights when model_config_ exists (see tabicl
121
+ # _sklearn/base.py); copying these mirrors _finetune/base.py.
122
+ for attr in ("model_config_", "model_path_"):
123
+ if hasattr(model, attr):
124
+ shared[attr] = getattr(model, attr)
125
+ self._shared_backbones[task] = shared
126
+
127
+ def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
128
+ import torch
129
+
130
+ # Fresh estimator instance every call (load-bearing -- see
131
+ # backends/base.py), but the underlying pretrained nn.Module is
132
+ # shared across fits after the first (see __init__).
133
+ model = self._make_estimator(task)
134
+
135
+ measuring = self._device == "cuda"
136
+ before_bytes = torch.cuda.memory_allocated() if measuring else 0
137
+ if measuring:
138
+ torch.cuda.reset_peak_memory_stats()
139
+ try:
140
+ model.fit(X, y)
141
+ except torch.cuda.OutOfMemoryError as e:
142
+ self._last_context_bytes = None
143
+ self._last_fit_peak_bytes = None
144
+ raise BackendComputeError(f"CUDA OOM during fit(): {e}") from e
145
+ # Two DIFFERENT real measurements, for two different consumers --
146
+ # conflating them was the v0.4.0 design flaw fixed in v0.9.0:
147
+ # - resident delta (context_bytes_hint): what the fitted context
148
+ # occupies afterward -> cache capacity/eviction accounting.
149
+ # - peak delta (fit_peak_bytes_hint): the transient high-water
150
+ # during fit -> what ADMISSION must bound, since this is what
151
+ # actually OOMs. Peak can exceed resident by orders of
152
+ # magnitude (activations, ensemble intermediates).
153
+ # None on CPU; the engine falls back to the formula estimator.
154
+ if measuring:
155
+ after_bytes = torch.cuda.memory_allocated()
156
+ self._last_context_bytes = max(0, after_bytes - before_bytes)
157
+ self._last_fit_peak_bytes = max(
158
+ 0, torch.cuda.max_memory_allocated() - before_bytes
159
+ )
160
+ else:
161
+ self._last_context_bytes = None
162
+ self._last_fit_peak_bytes = None
163
+ self._stash_backbone(task, model)
164
+ # Explicit even though tabicl's fit path clears internally today:
165
+ # a future tabicl change must not silently re-pin (see
166
+ # _unpin_shared_module for the OOM this prevents).
167
+ self._unpin_shared_module(model)
168
+ return model
169
+
170
+ def _unpin_shared_module(self, model: Any) -> None:
171
+ """Drop the shared backbone's pointer to the last-used context.
172
+
173
+ tabicl's `forward_with_cache` stores the active context's cache
174
+ on the MODULE (`model_._cache = cache`) and leaves it there.
175
+ With the backbone shared across all contexts (see __init__),
176
+ that pointer pins the most recently used context's multi-GB kv
177
+ cache even after tabctx evicts it -- found on a real A100 as
178
+ ~15GB that eviction "freed" in the accounting but never left the
179
+ device, eventually OOMing a fit the books said was safe. The
180
+ context's own cache lives on its estimator (model_kv_cache_), so
181
+ clearing the module pointer costs nothing and unpins eviction."""
182
+ module = getattr(model, "model_", None)
183
+ if module is not None and hasattr(module, "clear_cache"):
184
+ module.clear_cache()
185
+
186
+ def predict(
187
+ self, payload: Any, X_test: ArrayLike, return_proba: bool = False
188
+ ) -> PredictOutcome:
189
+ import torch
190
+
191
+ model = payload
192
+ try:
193
+ if hasattr(model, "predict_proba"):
194
+ if return_proba:
195
+ proba = model.predict_proba(X_test)
196
+ class_idx = np.argmax(proba, axis=1)
197
+ classes = [str(c) for c in model.classes_]
198
+ predictions = [classes[i] for i in class_idx]
199
+ return PredictOutcome(
200
+ predictions=predictions,
201
+ probabilities=proba.tolist(),
202
+ classes=classes,
203
+ )
204
+ predictions = model.predict(X_test).tolist()
205
+ return PredictOutcome(predictions=predictions)
206
+ # Regressor: no predict_proba to derive from.
207
+ predictions = model.predict(X_test).tolist()
208
+ return PredictOutcome(predictions=predictions)
209
+ except torch.cuda.OutOfMemoryError as e:
210
+ raise BackendComputeError(f"CUDA OOM during predict(): {e}") from e
211
+ finally:
212
+ self._unpin_shared_module(model)
213
+
214
+ def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
215
+ del n_train, n_features
216
+ # Real measurement from the fit() call this is queried after (see
217
+ # module docstring); None on CPU or if fit() OOM'd, in which case
218
+ # the engine falls back to the formula-based MemoryEstimator.
219
+ return self._last_context_bytes
220
+
221
+ def fit_peak_bytes_hint(self) -> int | None:
222
+ """Transient high-water memory of the most recent fit() -- the
223
+ admission-relevant quantity (see fit()); None on CPU/after OOM."""
224
+ return self._last_fit_peak_bytes
225
+
226
+ # ---- spillover serialization (see cache/spill.py) ----------------
227
+
228
+ def dumps_payload(self, payload: Any) -> bytes:
229
+ """Pickle a fitted estimator for the disk spill tier WITHOUT the
230
+ shared pretrained backbone (hundreds of MB, identical across all
231
+ contexts, unpicklable anyway once _load_model is shadowed with a
232
+ lambda): strip the shared attributes, pickle, restore."""
233
+ import pickle
234
+
235
+ stripped = {}
236
+ for attr in ("model_", "_load_model"):
237
+ if attr in payload.__dict__:
238
+ stripped[attr] = payload.__dict__.pop(attr)
239
+ try:
240
+ return pickle.dumps(payload)
241
+ finally:
242
+ payload.__dict__.update(stripped)
243
+
244
+ def loads_payload(self, data: bytes) -> Any:
245
+ """Inverse of dumps_payload: unpickle and re-attach this
246
+ process's shared backbone (loading it first if this replica has
247
+ never fit this task type -- e.g. right after a restart)."""
248
+ import pickle
249
+
250
+ model = pickle.loads(data)
251
+ task: Task = (
252
+ "classification" if hasattr(model, "predict_proba") else "regression"
253
+ )
254
+ if task not in self._shared_backbones:
255
+ # Cold restore on a fresh replica: borrow a throwaway
256
+ # estimator's load path to populate the shared backbone.
257
+ probe = self._make_estimator(task)
258
+ probe._load_model()
259
+ self._stash_backbone(task, probe)
260
+ for attr, value in self._shared_backbones[task].items():
261
+ setattr(model, attr, value)
262
+ model._load_model = lambda: None
263
+ return model
@@ -0,0 +1,147 @@
1
+ """TabPFN backend (github.com/PriorLabs/TabPFN).
2
+
3
+ The second backend behind the `TabularICLBackend` protocol -- proving
4
+ the engine/cache/estimator stack really is backend-agnostic (the design
5
+ claim in backends/base.py; nothing outside this module imports tabpfn).
6
+
7
+ The kv-cache lesson from TabICL (see backends/tabicl.py and CHANGELOG
8
+ 0.7.0) applies verbatim here: TabPFN's default `fit_mode` is
9
+ "fit_preprocessors", which caches training-data PREPROCESSING but still
10
+ re-runs the transformer's training-context forward pass on every
11
+ predict. Its equivalent of the kv cache is `fit_mode="fit_with_cache"`
12
+ ("the transformer key-value cache is also initialized, allowing for much
13
+ faster inference on the same data at a large cost of memory" -- exactly
14
+ the cost tabctx's memory accounting exists to price). tabctx maps its
15
+ cache modes accordingly:
16
+
17
+ tabctx mode -> TabPFN fit_mode
18
+ "kv" "fit_with_cache" (fastest predicts, memory-heavy)
19
+ "repr" "fit_preprocessors" (cheap contexts, slower predicts)
20
+ "off" "low_memory" (escape hatch; re-preprocesses
21
+ per predict)
22
+
23
+ TabPFN enforces pretraining limits (~10k train rows, ~500 features on
24
+ v2-series checkpoints) by raising before any meaningful GPU work; those
25
+ surface as InvalidInputError so a multi-tenant caller gets a clean 422,
26
+ not a 500. Unlike TabICL, TabPFN memoizes model loading internally per
27
+ (model_path, device) via its model-cache mechanism, so there is no
28
+ per-fit torch.load to elide here; a fresh estimator per fit() (required
29
+ by the protocol -- fit() mutates estimator state) is already cheap.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any
35
+
36
+ import numpy as np
37
+
38
+ from tabctx.errors import BackendComputeError, InvalidInputError
39
+ from tabctx.types import ArrayLike, PredictOutcome, Task
40
+
41
+ _FIT_MODES = {
42
+ "kv": "fit_with_cache",
43
+ "repr": "fit_preprocessors",
44
+ "off": "low_memory",
45
+ }
46
+
47
+
48
+ class TabPFNBackend:
49
+ # The exact model id callers select via the API's `model` field --
50
+ # named for the checkpoint the OSS package loads locally
51
+ # (tabpfn-v3-*-v3_default.ckpt, i.e. PriorLabs' "TabPFN-3").
52
+ name = "tabpfn-3"
53
+
54
+ def __init__(self, device: str | None = None, cache_mode: str = "kv") -> None:
55
+ import torch
56
+
57
+ if cache_mode not in _FIT_MODES:
58
+ raise ValueError(
59
+ f"cache_mode must be one of {sorted(_FIT_MODES)}, got {cache_mode!r}"
60
+ )
61
+ self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
62
+ self._fit_mode = _FIT_MODES[cache_mode]
63
+ self._last_context_bytes: int | None = None
64
+ self._last_fit_peak_bytes: int | None = None
65
+
66
+ def _make_estimator(self, task: Task) -> Any:
67
+ if task == "classification":
68
+ from tabpfn import TabPFNClassifier
69
+
70
+ return TabPFNClassifier(device=self._device, fit_mode=self._fit_mode)
71
+ from tabpfn import TabPFNRegressor
72
+
73
+ return TabPFNRegressor(device=self._device, fit_mode=self._fit_mode)
74
+
75
+ def fit(self, X: ArrayLike, y: ArrayLike, task: Task) -> Any:
76
+ import torch
77
+
78
+ model = self._make_estimator(task)
79
+ measuring = self._device == "cuda"
80
+ before_bytes = torch.cuda.memory_allocated() if measuring else 0
81
+ if measuring:
82
+ torch.cuda.reset_peak_memory_stats()
83
+ try:
84
+ model.fit(np.asarray(X, dtype=float), np.asarray(y))
85
+ except torch.cuda.OutOfMemoryError as e:
86
+ self._last_context_bytes = None
87
+ self._last_fit_peak_bytes = None
88
+ raise BackendComputeError(f"CUDA OOM during fit(): {e}") from e
89
+ except ValueError as e:
90
+ # TabPFN's own input gate (pretraining limits: too many rows,
91
+ # features, or classes) raises before meaningful GPU work; a
92
+ # caller's oversized table is bad input, not a server fault.
93
+ self._last_context_bytes = None
94
+ raise InvalidInputError(f"tabpfn rejected the training table: {e}") from e
95
+ # Resident vs peak: two different consumers -- see
96
+ # backends/tabicl.py fit() for the full rationale.
97
+ if measuring:
98
+ self._last_context_bytes = max(
99
+ 0, torch.cuda.memory_allocated() - before_bytes
100
+ )
101
+ self._last_fit_peak_bytes = max(
102
+ 0, torch.cuda.max_memory_allocated() - before_bytes
103
+ )
104
+ else:
105
+ self._last_context_bytes = None
106
+ self._last_fit_peak_bytes = None
107
+ return model
108
+
109
+ def predict(
110
+ self, payload: Any, X_test: ArrayLike, return_proba: bool = False
111
+ ) -> PredictOutcome:
112
+ import torch
113
+
114
+ model = payload
115
+ X_arr = np.asarray(X_test, dtype=float)
116
+ try:
117
+ if hasattr(model, "predict_proba"):
118
+ # Single pass regardless of return_proba (protocol
119
+ # requirement): derive labels from probabilities.
120
+ proba = model.predict_proba(X_arr)
121
+ class_idx = np.argmax(proba, axis=1)
122
+ classes = [str(c) for c in model.classes_]
123
+ predictions = [classes[i] for i in class_idx]
124
+ if return_proba:
125
+ return PredictOutcome(
126
+ predictions=predictions,
127
+ probabilities=proba.tolist(),
128
+ classes=classes,
129
+ )
130
+ return PredictOutcome(predictions=predictions)
131
+ return PredictOutcome(predictions=model.predict(X_arr).tolist())
132
+ except torch.cuda.OutOfMemoryError as e:
133
+ raise BackendComputeError(f"CUDA OOM during predict(): {e}") from e
134
+
135
+ def context_bytes_hint(self, n_train: int, n_features: int) -> int | None:
136
+ del n_train, n_features
137
+ # Real measurement from the fit() this is queried after; None on
138
+ # CPU, where the engine falls back to its MemoryEstimator. NOTE:
139
+ # that estimator's static calibration is TabICL-on-A100 data --
140
+ # treat capacity numbers for TabPFN deployments as provisional
141
+ # until real fits accumulate in the AdaptiveMemoryEstimator.
142
+ return self._last_context_bytes
143
+
144
+ def fit_peak_bytes_hint(self) -> int | None:
145
+ """Transient high-water memory of the most recent fit() -- the
146
+ admission-relevant quantity; None on CPU/after OOM."""
147
+ return self._last_fit_peak_bytes