codadapt 1.0.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.
codadapt/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """CodAdapt: experimental adaptive integer addressing for tabular data."""
2
+
3
+ from ._version import __version__
4
+ from .classifier import CodAdaptClassifier
5
+ from .regressor import CodAdaptRegressor
6
+
7
+ CodAdapt = CodAdaptClassifier
8
+ __all__ = ["CodAdapt", "CodAdaptClassifier", "CodAdaptRegressor", "__version__"]
codadapt/_base.py ADDED
@@ -0,0 +1,380 @@
1
+ """Shared estimator API and train-only preparation."""
2
+
3
+ import copy
4
+ import numbers
5
+ import warnings
6
+ from time import perf_counter
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from scipy import sparse
11
+ from sklearn.base import BaseEstimator
12
+ from sklearn.exceptions import DataConversionWarning
13
+ from sklearn.model_selection import train_test_split
14
+ from sklearn.utils.multiclass import type_of_target
15
+ from sklearn.utils.validation import check_is_fitted
16
+
17
+ from ._core import addresses
18
+ from ._training import (
19
+ predict_coded_memory_buckets,
20
+ train_coded_memory,
21
+ )
22
+ from .preprocessing import BucketEncoder
23
+
24
+
25
+ def _rows(X, indices):
26
+ return X.iloc[indices] if isinstance(X, pd.DataFrame) else X[indices]
27
+
28
+
29
+ def _target(y):
30
+ if y is None:
31
+ raise ValueError("This estimator requires y to be passed, but the target y is None.")
32
+ result = np.asarray(y)
33
+ if result.ndim == 2 and result.shape[1] == 1:
34
+ warnings.warn(
35
+ "A column-vector y was passed when a 1d array was expected.",
36
+ DataConversionWarning,
37
+ stacklevel=3,
38
+ )
39
+ result = result.ravel()
40
+ if result.ndim != 1:
41
+ raise ValueError("Only single-target, one-dimensional y is supported; no multioutput.")
42
+ if len(result) == 0 or pd.isna(result).any():
43
+ raise ValueError("Target y must be nonempty and contain no missing values (NaN or NA).")
44
+ if result.dtype.kind == "c":
45
+ raise ValueError("Complex data not supported in target y.")
46
+ if result.dtype.kind in "fiu" and not np.isfinite(result).all():
47
+ raise ValueError("Target y contains infinity or a value too large.")
48
+ return result
49
+
50
+
51
+ class _BaseCodAdapt(BaseEstimator):
52
+ """Common parameters; construction only stores the supplied values."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ n_bins=32,
58
+ max_categories=128,
59
+ quantile_sample_size=20000,
60
+ n_tables=8,
61
+ features_per_table=3,
62
+ table_size=256,
63
+ main_effects=True,
64
+ l2=5.0,
65
+ categorical_features=None,
66
+ early_stopping=True,
67
+ validation_fraction=0.15,
68
+ tol=1e-5,
69
+ random_state=None,
70
+ verbosity=0,
71
+ lookup_budget=24,
72
+ memory_budget_mb=512,
73
+ ):
74
+ self.n_bins = n_bins
75
+ self.max_categories = max_categories
76
+ self.quantile_sample_size = quantile_sample_size
77
+ self.n_tables = n_tables
78
+ self.features_per_table = features_per_table
79
+ self.table_size = table_size
80
+ self.main_effects = main_effects
81
+ self.l2 = l2
82
+ self.categorical_features = categorical_features
83
+ self.early_stopping = early_stopping
84
+ self.validation_fraction = validation_fraction
85
+ self.tol = tol
86
+ self.random_state = random_state
87
+ self.verbosity = verbosity
88
+ self.lookup_budget = lookup_budget
89
+ self.memory_budget_mb = memory_budget_mb
90
+
91
+ def _validate_parameters(self):
92
+ if (
93
+ isinstance(self.memory_budget_mb, (bool, np.bool_))
94
+ or not isinstance(self.memory_budget_mb, numbers.Integral)
95
+ or self.memory_budget_mb < 1
96
+ ):
97
+ raise ValueError("memory_budget_mb must be a positive integer in MiB.")
98
+ bounds = {
99
+ "n_bins": (2, 4096),
100
+ "max_categories": (1, 4096),
101
+ "quantile_sample_size": (2, 1000000),
102
+ "n_tables": (0, 128),
103
+ "features_per_table": (1, 32),
104
+ "table_size": (16, 4096),
105
+ "verbosity": (0, 2),
106
+ "lookup_budget": (1, 4096),
107
+ }
108
+ for name, (low, high) in bounds.items():
109
+ value = getattr(self, name)
110
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Integral):
111
+ raise ValueError(f"{name} must be an integer in [{low}, {high}].")
112
+ if not low <= value <= high:
113
+ raise ValueError(f"{name} must be in [{low}, {high}].")
114
+ if self.table_size & (self.table_size - 1):
115
+ raise ValueError("table_size must be a power of two from 16 through 4096.")
116
+ for name in ("main_effects", "early_stopping"):
117
+ if not isinstance(getattr(self, name), (bool, np.bool_)):
118
+ raise ValueError(f"{name} must be boolean.")
119
+ for name in ("l2", "tol", "validation_fraction"):
120
+ value = getattr(self, name)
121
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
122
+ raise ValueError(f"{name} must be a finite real number.")
123
+ if not np.isfinite(value):
124
+ raise ValueError(f"{name} must be finite.")
125
+ if self.l2 <= 0 or self.tol < 0:
126
+ raise ValueError("l2 must be positive and tol nonnegative.")
127
+ if not 0 < self.validation_fraction < 1:
128
+ raise ValueError("validation_fraction must lie strictly between zero and one.")
129
+ state = self.random_state
130
+ if state is not None and not isinstance(state, (numbers.Integral, np.random.RandomState)):
131
+ raise ValueError("random_state must be None, an integer, or a NumPy RandomState.")
132
+ if isinstance(state, numbers.Integral) and not 0 <= state <= 2**32 - 1:
133
+ raise ValueError("random_state integer must lie in [0, 2**32 - 1].")
134
+
135
+ def _check_memory_budget(self, n, p, eval_set=None):
136
+ """Apply the deterministic working-allocation heuristic, not an RSS cap."""
137
+ validation_rows = 0
138
+ if eval_set is not None:
139
+ if not isinstance(eval_set, (tuple, list)) or len(eval_set) != 2:
140
+ raise ValueError("eval_set must be the pair (X_valid, y_valid).")
141
+ if sparse.issparse(eval_set[0]):
142
+ raise TypeError(
143
+ "Sparse validation input is not supported; provide a dense numeric array."
144
+ )
145
+ # Read only the row count before copies/encoding; detailed validation
146
+ # remains in preparation. External validation is live during fit too.
147
+ try:
148
+ validation_rows = len(eval_set[0])
149
+ except TypeError as error:
150
+ raise ValueError("Validation X must be a 2D array or DataFrame.") from error
151
+ estimate = (int(n) + int(validation_rows)) * (40 * int(p) + 2 * self.n_tables + 80)
152
+ estimate += (
153
+ 24
154
+ * (p + self.n_tables * min(p, self.features_per_table))
155
+ * max(self.n_bins + 1, self.max_categories + 3)
156
+ )
157
+ estimate += 32 * self.n_tables * self.table_size
158
+ budget = int(self.memory_budget_mb) * 1024**2
159
+ if estimate > budget:
160
+ raise ValueError(
161
+ f"Estimated working allocation {estimate / 1024**2:.2f} MiB exceeds "
162
+ f"the {self.memory_budget_mb} MiB memory_budget_mb budget "
163
+ f"({n} training rows, {validation_rows} external validation rows, "
164
+ f"{p} features). Use fewer rows/features or explicitly raise "
165
+ "memory_budget_mb only with sufficient RAM headroom. This heuristic "
166
+ "is not a total process RSS limit or an out-of-memory guarantee."
167
+ )
168
+ return int(estimate)
169
+
170
+ def __setstate__(self, state):
171
+ # Trusted RC1 artifacts predate the explicit resource-only parameter.
172
+ state = state.copy()
173
+ state.setdefault("memory_budget_mb", 512)
174
+ super().__setstate__(state)
175
+
176
+ def _encode_target(self, y, fitting=False):
177
+ if self._task == "regression":
178
+ try:
179
+ values = np.asarray(y, dtype=np.float64)
180
+ except (TypeError, ValueError) as error:
181
+ raise ValueError("Regression requires a finite numeric target.") from error
182
+ if not np.isfinite(values).all():
183
+ raise ValueError("Regression target must be finite.")
184
+ return values
185
+ if fitting:
186
+ try:
187
+ target_type = type_of_target(y)
188
+ classes = np.unique(y)
189
+ except (TypeError, ValueError) as error:
190
+ raise ValueError(
191
+ "Binary targets require comparable scalar class labels."
192
+ ) from error
193
+ if target_type == "continuous":
194
+ raise ValueError(
195
+ "Unknown label type: continuous. Only binary classification is supported."
196
+ )
197
+ if target_type != "binary" or len(classes) != 2:
198
+ raise ValueError(
199
+ "Only binary classification is supported: one class was found; "
200
+ "need two classes with positive weight."
201
+ )
202
+ self.classes_ = classes
203
+ if not np.isin(y, self.classes_).all():
204
+ raise ValueError(
205
+ "Validation target contains classes absent from positive-weight training."
206
+ )
207
+ return (y == self.classes_[1]).astype(np.float64)
208
+
209
+ def fit(self, X, y, sample_weight=None, eval_set=None):
210
+ """Fit only on positive-weight training rows, with optional external validation."""
211
+ started = perf_counter()
212
+ # A failed refit must not leave the former fitted model available for prediction.
213
+ for name in list(vars(self)):
214
+ if name.endswith("_"):
215
+ delattr(self, name)
216
+ self._validate_parameters()
217
+ if sparse.issparse(X):
218
+ raise TypeError("Sparse input is not supported; provide a dense numeric array.")
219
+ if not isinstance(X, pd.DataFrame):
220
+ try:
221
+ X = np.asarray(X)
222
+ except (TypeError, ValueError) as error:
223
+ raise ValueError(
224
+ "X must be a DataFrame or dense numeric array-like input."
225
+ ) from error
226
+ if X.ndim != 2:
227
+ raise ValueError("Expected 2D array. Reshape your data to (n_samples, n_features).")
228
+ n, p = X.shape
229
+ if not p:
230
+ raise ValueError(
231
+ f"Found array with 0 feature(s) (shape={X.shape}) while a minimum of 1 is required."
232
+ )
233
+ if not n:
234
+ raise ValueError(
235
+ f"Found array with 0 sample(s) (shape={X.shape}); a minimum of 1 is required."
236
+ )
237
+ y = _target(y)
238
+ if len(y) != n:
239
+ raise ValueError("X and y have inconsistent numbers of samples.")
240
+ if sample_weight is None:
241
+ weight = np.ones(n, dtype=np.float64)
242
+ else:
243
+ try:
244
+ weight = np.asarray(sample_weight, dtype=np.float64)
245
+ except (TypeError, ValueError) as error:
246
+ raise ValueError("sample_weight must be a finite nonnegative 1D array.") from error
247
+ if weight.ndim != 1 or len(weight) != n:
248
+ raise ValueError("sample_weight must be a 1D array with one value per sample.")
249
+ if not np.isfinite(weight).all() or (weight < 0).any():
250
+ raise ValueError("sample_weight must be finite and nonnegative.")
251
+ if not np.isfinite(weight.sum()) or weight.sum() <= 0:
252
+ raise ValueError(
253
+ "sample_weight must contain at least one non-zero value and have a finite positive sum."
254
+ )
255
+ self.estimated_memory_bytes_ = self._check_memory_budget(n, p, eval_set)
256
+ keep = np.flatnonzero(weight > 0)
257
+ X_train, y_train, weight = _rows(X, keep), y[keep], weight[keep].copy()
258
+ y_train = self._encode_target(y_train, fitting=True)
259
+ rng = (
260
+ copy.deepcopy(self.random_state)
261
+ if isinstance(self.random_state, np.random.RandomState)
262
+ else np.random.RandomState(self.random_state)
263
+ )
264
+ X_valid = y_valid = None
265
+ if eval_set is not None:
266
+ if not isinstance(eval_set, (tuple, list)) or len(eval_set) != 2:
267
+ raise ValueError("eval_set must be the pair (X_valid, y_valid).")
268
+ X_valid, y_valid = eval_set
269
+ if sparse.issparse(X_valid):
270
+ raise TypeError(
271
+ "Sparse validation input is not supported; provide a dense numeric array."
272
+ )
273
+ if not isinstance(X_valid, pd.DataFrame):
274
+ try:
275
+ X_valid = np.asarray(X_valid)
276
+ except (TypeError, ValueError) as error:
277
+ raise ValueError(
278
+ "Validation X must be a 2D dense array or DataFrame."
279
+ ) from error
280
+ if X_valid.ndim != 2:
281
+ raise ValueError(
282
+ "Validation X must be a 2D array with shape (n_samples, n_features)."
283
+ )
284
+ y_valid = self._encode_target(_target(y_valid))
285
+ if len(y_valid) != X_valid.shape[0]:
286
+ raise ValueError("Validation X and y have inconsistent numbers of samples.")
287
+ elif self.early_stopping:
288
+ indices = np.arange(len(y_train))
289
+ try:
290
+ train_indices, valid_indices = train_test_split(
291
+ indices,
292
+ test_size=self.validation_fraction,
293
+ random_state=copy.deepcopy(rng),
294
+ stratify=y_train if self._task == "classification" else None,
295
+ )
296
+ if self._task == "classification" and len(np.unique(y_train[train_indices])) != 2:
297
+ raise ValueError("Training split does not retain both classes.")
298
+ except ValueError as error:
299
+ raise ValueError(
300
+ f"Insufficient data ({len(y_train)} sample(s)) for internal validation; "
301
+ "use early_stopping=False or a valid eval_set."
302
+ ) from error
303
+ X_valid, y_valid = _rows(X_train, valid_indices), y_train[valid_indices]
304
+ X_train, y_train, weight = (
305
+ _rows(X_train, train_indices),
306
+ y_train[train_indices],
307
+ weight[train_indices],
308
+ )
309
+ self.n_features_in_ = p
310
+ self.timings_ = {}
311
+ preprocessing_start = perf_counter()
312
+ encoder_seed = int(rng.randint(2**31 - 1))
313
+ finest = max(2, min(16, self.n_bins))
314
+ self.resolution_bins_ = [max(2, finest // 4), max(2, finest // 2), finest]
315
+ self.shared_encoder_ = BucketEncoder(
316
+ n_bins=finest,
317
+ max_categories=self.max_categories,
318
+ quantile_sample_size=self.quantile_sample_size,
319
+ categorical_features=self.categorical_features,
320
+ random_state=encoder_seed,
321
+ )
322
+ Z_levels = self.shared_encoder_.fit_transform_levels(X_train, self.resolution_bins_)
323
+ Z_valid_levels = (
324
+ self.shared_encoder_.transform_levels(X_valid) if X_valid is not None else None
325
+ )
326
+ self.encoders_ = self.shared_encoder_.level_views_
327
+ self.encoder_ = self.shared_encoder_
328
+ if hasattr(self.encoder_, "feature_names_in_"):
329
+ self.feature_names_in_ = self.encoder_.feature_names_in_.copy()
330
+ self.timings_["preprocessing"] = perf_counter() - preprocessing_start
331
+ train_coded_memory(
332
+ self,
333
+ Z_levels,
334
+ y_train,
335
+ weight,
336
+ Z_valid_levels,
337
+ y_valid,
338
+ rng,
339
+ )
340
+ self.timings_["fit_total"] = perf_counter() - started
341
+ if self.verbosity:
342
+ print(
343
+ f"CodAdapt fitted {self.n_iter_} iterations; restored={self.best_iteration_}; moves={self.accepted_moves_}; fit={self.timings_['fit_total']:.3f}s"
344
+ )
345
+ self._is_fitted_ = True
346
+ return self
347
+
348
+ def _raw_predict_buckets(self, Z):
349
+ result = np.full(len(Z), self.intercept_, dtype=np.float64)
350
+ for j, values in enumerate(self.main_tables_):
351
+ result += values[Z[:, j]]
352
+ for features, codes, values in zip(self.groups_, self.codes_, self.tables_):
353
+ index = addresses(Z, features, codes, self.table_size)
354
+ supported = np.ones(len(Z), dtype=bool)
355
+ for j in features:
356
+ supported &= self.encoder_.support_[j][Z[:, j]]
357
+ result += np.where(supported, values[index], 0.0)
358
+ return result
359
+
360
+ def _decision(self, X):
361
+ check_is_fitted(self)
362
+ Z_levels = self.shared_encoder_.transform_levels(X)
363
+ return predict_coded_memory_buckets(
364
+ self.levels_, self.encoders_, Z_levels, self.intercept_, self.table_size
365
+ )
366
+
367
+ def __sklearn_is_fitted__(self):
368
+ return getattr(self, "_is_fitted_", False)
369
+
370
+ def _more_tags(self):
371
+ return {"allow_nan": True, "requires_y": True, "X_types": ["2darray", "categorical"]}
372
+
373
+ def __sklearn_tags__(self):
374
+ tags = super().__sklearn_tags__()
375
+ tags.input_tags.allow_nan = True
376
+ tags.input_tags.categorical = True
377
+ tags.input_tags.string = True
378
+ tags.input_tags.sparse = False
379
+ tags.target_tags.required = True
380
+ return tags
codadapt/_core.py ADDED
@@ -0,0 +1,70 @@
1
+ """Vectorized addressing and exact incremental sufficient statistics.
2
+
3
+ The candidate loop only touches arrays of length ``table_size``. Row indices
4
+ are grouped once by training; each visited coordinate aggregates its rows once.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ GAIN_RTOL = 1e-12
10
+ _CANCELLATION_RTOL = 64 * np.finfo(np.float64).eps
11
+
12
+
13
+ def sigmoid(scores):
14
+ """Stable logistic function, including scores whose exponent would overflow."""
15
+ scores = np.asarray(scores, dtype=np.float64)
16
+ result = np.empty_like(scores)
17
+ positive = scores >= 0
18
+ result[positive] = 1.0 / (1.0 + np.exp(-scores[positive]))
19
+ exponent = np.exp(scores[~positive])
20
+ result[~positive] = exponent / (1.0 + exponent)
21
+ return result
22
+
23
+
24
+ def addresses(z, features, codes, table_size):
25
+ """Return compact addresses; ``codes[k]`` belongs to global ``features[k]``.
26
+
27
+ Add in int64 before the modulo: compact input code dtypes must not determine
28
+ arithmetic precision. Parameter and feature validation belongs to the API.
29
+ """
30
+ total = np.zeros(z.shape[0], dtype=np.int64)
31
+ for feature, code in zip(features, codes):
32
+ total += code[z[:, feature]].astype(np.int64, copy=False)
33
+ return (total % table_size).astype(np.uint16)
34
+
35
+
36
+ def aggregate(address, h, r, table_size):
37
+ """Aggregate mass A and weighted response B in float64 for one addressing."""
38
+ h, r = np.asarray(h, dtype=np.float64), np.asarray(r, dtype=np.float64)
39
+ a = np.bincount(address, weights=h, minlength=table_size)
40
+ b = np.bincount(address, weights=h * r, minlength=table_size)
41
+ return a, b
42
+
43
+
44
+ def gain(a, b, l2):
45
+ """The maximized regularized quadratic improvement, not exact logistic gain."""
46
+ result = 0.5 * np.dot(b, b / (a + l2))
47
+ if not np.isfinite(result):
48
+ raise FloatingPointError("Non-finite quadratic gain; rescale target or sample_weight.")
49
+ return float(result)
50
+
51
+
52
+ def incremental_stats(a, b, d, e, delta):
53
+ """Move a group's statistics forward by delta cells, without mutating input.
54
+
55
+ D/E refer to the current addresses of the group. Negative residual mass is
56
+ clipped only within 64 machine epsilons of the per-cell arithmetic scale;
57
+ larger negative mass signals a broken invariant and raises an exception.
58
+ Zero-mass cancellation in B is corrected only at the same relative scale.
59
+ """
60
+ a, b, d, e = (np.asarray(value, dtype=np.float64) for value in (a, b, d, e))
61
+ remaining_a, remaining_b = a - d, b - e
62
+ mass_tolerance = _CANCELLATION_RTOL * np.maximum(1.0, np.maximum(np.abs(a), np.abs(d)))
63
+ if np.any(remaining_a < -mass_tolerance):
64
+ raise FloatingPointError("Significant negative mass in incremental statistics.")
65
+ remaining_a[remaining_a < 0] = 0.0
66
+ response_tolerance = _CANCELLATION_RTOL * np.maximum(1.0, np.maximum(np.abs(b), np.abs(e)))
67
+ cancelled = (remaining_a == 0) & (np.abs(remaining_b) <= response_tolerance)
68
+ remaining_b[cancelled] = 0.0
69
+ shift = int(delta) % len(a)
70
+ return remaining_a + np.roll(d, shift), remaining_b + np.roll(e, shift)
@@ -0,0 +1,229 @@
1
+ """Exact native multiclass preparation and fit-local X-only reuse."""
2
+
3
+ import copy
4
+ import warnings
5
+ from dataclasses import dataclass
6
+ from time import perf_counter
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from scipy import sparse
11
+ from sklearn.model_selection import train_test_split
12
+ from sklearn.utils.multiclass import type_of_target
13
+
14
+ from ._base import _rows, _target
15
+ from ._core import addresses, aggregate
16
+ from ._training import _surrogate, train_coded_memory
17
+ from .preprocessing import BucketEncoder
18
+
19
+
20
+ def validate_weights(weight, n):
21
+ if weight is None:
22
+ result = np.ones(n, dtype=np.float64)
23
+ else:
24
+ try:
25
+ result = np.asarray(weight, dtype=np.float64)
26
+ except (TypeError, ValueError) as error:
27
+ raise ValueError("sample_weight must be a finite nonnegative 1D array.") from error
28
+ if result.ndim != 1 or len(result) != n:
29
+ raise ValueError("sample_weight must be a 1D array with one value per sample.")
30
+ if not np.isfinite(result).all() or (result < 0).any():
31
+ raise ValueError("sample_weight must be finite and nonnegative.")
32
+ if not np.isfinite(result.sum()) or result.sum() <= 0:
33
+ raise ValueError(
34
+ "sample_weight must contain at least one non-zero value and have a finite positive sum."
35
+ )
36
+ return result
37
+
38
+
39
+ def is_multiclass(y, weight):
40
+ """Binary/invalid cases still delegate to the original public fit verbatim."""
41
+ try:
42
+ a = np.asarray(y)
43
+ if a.ndim == 2 and a.shape[1] == 1:
44
+ a = a.ravel()
45
+ if a.ndim != 1 or not len(a) or pd.isna(a).any():
46
+ return False
47
+ if weight is not None:
48
+ w = np.asarray(weight, dtype=float)
49
+ if w.ndim == 1 and len(w) == len(a) and np.isfinite(w).all() and (w >= 0).all():
50
+ a = a[w > 0]
51
+ return type_of_target(a) == "multiclass" and len(np.unique(a)) > 2
52
+ except (TypeError, ValueError):
53
+ return False
54
+
55
+
56
+ @dataclass
57
+ class Prepared:
58
+ x: object
59
+ y: np.ndarray
60
+ weight: np.ndarray
61
+ xv: object
62
+ yv: object
63
+ classes: np.ndarray
64
+ rng: object
65
+ train_ids: np.ndarray
66
+ valid_ids: object
67
+ estimated_bytes: int
68
+
69
+
70
+ def prepare(model, X, y, sample_weight, eval_set):
71
+ model._validate_parameters()
72
+ if sparse.issparse(X):
73
+ raise TypeError("Sparse input is not supported; provide a dense numeric array.")
74
+ if not isinstance(X, pd.DataFrame):
75
+ X = np.asarray(X)
76
+ if X.ndim != 2:
77
+ raise ValueError("Expected 2D array. Reshape your data to (n_samples, n_features).")
78
+ n, p = X.shape
79
+ if not n or not p:
80
+ raise ValueError(f"Found array with {n} sample(s) and {p} feature(s); minimum1 required.")
81
+ y = _target(y)
82
+ if len(y) != n:
83
+ raise ValueError("X and y have inconsistent numbers of samples.")
84
+ weight = validate_weights(sample_weight, n)
85
+ estimate = model._check_memory_budget(n, p, eval_set)
86
+ keep = np.flatnonzero(weight > 0)
87
+ x, y, w = _rows(X, keep), y[keep], weight[keep].copy()
88
+ try:
89
+ classes, counts = np.unique(y, return_counts=True)
90
+ kind = type_of_target(y)
91
+ except (TypeError, ValueError) as error:
92
+ raise ValueError("Classification requires comparable scalar class labels.") from error
93
+ if kind != "multiclass" or len(classes) < 3:
94
+ raise ValueError("At least three positive-weight classes required for shared OVR.")
95
+ rng = (
96
+ copy.deepcopy(model.random_state)
97
+ if isinstance(model.random_state, np.random.RandomState)
98
+ else np.random.RandomState(model.random_state)
99
+ )
100
+ xv = yv = valid_ids = None
101
+ train_ids = keep
102
+ if eval_set is not None:
103
+ if not isinstance(eval_set, (tuple, list)) or len(eval_set) != 2:
104
+ raise ValueError("eval_set must be the pair (X_valid, y_valid).")
105
+ xv, yv = eval_set
106
+ if sparse.issparse(xv):
107
+ raise TypeError(
108
+ "Sparse validation input is not supported; provide a dense numeric array."
109
+ )
110
+ if not isinstance(xv, pd.DataFrame):
111
+ xv = np.asarray(xv)
112
+ yv = _target(yv)
113
+ if xv.ndim != 2 or len(yv) != len(xv):
114
+ raise ValueError("Validation X and y have inconsistent numbers of samples.")
115
+ if not np.isin(yv, classes).all():
116
+ raise ValueError(
117
+ "Validation target contains classes absent from positive-weight training."
118
+ )
119
+ elif model.early_stopping:
120
+ try:
121
+ a, b = train_test_split(
122
+ np.arange(len(y)),
123
+ test_size=model.validation_fraction,
124
+ random_state=copy.deepcopy(rng),
125
+ stratify=y,
126
+ )
127
+ if len(np.unique(y[a])) != len(classes):
128
+ raise ValueError("Training split does not retain all classes.")
129
+ except ValueError as error:
130
+ raise ValueError(
131
+ f"Insufficient data ({len(y)} sample(s)) for common stratified internal validation; use early_stopping=False or a valid eval_set."
132
+ ) from error
133
+ xv, yv = _rows(x, b), y[b]
134
+ x, y, w = _rows(x, a), y[a], w[a]
135
+ train_ids, valid_ids = keep[a], keep[b]
136
+ _, counts = np.unique(y, return_counts=True)
137
+ if counts.min() < 5:
138
+ warnings.warn(
139
+ "Rare class (<5 positive-weight TRAIN examples): native OVR quality is not guaranteed.",
140
+ UserWarning,
141
+ stacklevel=2,
142
+ )
143
+ return Prepared(x, y, w, xv, yv, classes, rng, train_ids, valid_ids, int(estimate))
144
+
145
+
146
+ @dataclass
147
+ class NativeHead:
148
+ intercept: float
149
+ levels: list
150
+ table_size: int
151
+
152
+
153
+ class XCache:
154
+ def __init__(self, weight, budget=32 * 2**20):
155
+ self.weight, self.budget, self.bytes = weight, budget, 0
156
+ self.addresses, self.masses, self.combinations = {}, {}, {}
157
+
158
+ def address(self, z, features, codes, size):
159
+ key = (id(z), tuple(features), size, tuple(c.tobytes() for c in codes))
160
+ if key not in self.addresses:
161
+ value = addresses(z, features, codes, size)
162
+ if self.bytes + value.nbytes <= self.budget:
163
+ self.addresses[key] = value
164
+ self.bytes += value.nbytes
165
+ return value
166
+ return self.addresses[key]
167
+
168
+ def surrogate(self, y, score, old, weight, binary):
169
+ assert binary and weight is self.weight
170
+ return _surrogate(y, score, old, weight, binary)
171
+
172
+ def aggregate(self, index, h, r, size):
173
+ # native h=weight/4 for EVERY class in this fit; weight is identity bound.
174
+ key = (index.__array_interface__["data"][0], index.shape, index.strides, size)
175
+ if key in self.masses:
176
+ return self.masses[key][1], np.bincount(index, weights=h * r, minlength=size)
177
+ a, b = aggregate(index, h, r, size)
178
+ required = a.nbytes + (0 if index.base is not None else index.nbytes)
179
+ if self.bytes + required <= self.budget:
180
+ self.masses[key] = (index, a)
181
+ self.bytes += required
182
+ return a, b
183
+
184
+ def combination_count(self, z, features):
185
+ key = (id(z), tuple(features))
186
+ if key not in self.combinations:
187
+ self.combinations[key] = int(np.unique(z[:, features], axis=0).shape[0])
188
+ return self.combinations[key]
189
+
190
+
191
+ def fit_heads(model, d):
192
+ tick = perf_counter()
193
+ rng = copy.deepcopy(d.rng)
194
+ encoder_seed = int(rng.randint(2**31 - 1))
195
+ finest = max(2, min(16, model.n_bins))
196
+ resolutions = [max(2, finest // 4), max(2, finest // 2), finest]
197
+ encoder = BucketEncoder(
198
+ n_bins=finest,
199
+ max_categories=model.max_categories,
200
+ quantile_sample_size=model.quantile_sample_size,
201
+ categorical_features=model.categorical_features,
202
+ random_state=encoder_seed,
203
+ )
204
+ z = encoder.fit_transform_levels(d.x, resolutions)
205
+ zv = encoder.transform_levels(d.xv) if d.xv is not None else None
206
+ preprocessing_s = perf_counter() - tick
207
+ cache = XCache(d.weight)
208
+ heads = []
209
+ # Local native head models are discarded; only predictive state survives.
210
+ from codadapt import CodAdaptClassifier
211
+
212
+ for label in d.classes:
213
+ head = CodAdaptClassifier(**model.get_params(deep=False))
214
+ head.classes_ = np.array([0, 1])
215
+ head.shared_encoder_ = head.encoder_ = encoder
216
+ head.encoders_, head.resolution_bins_ = encoder.level_views_, resolutions
217
+ head.n_features_in_, head.timings_ = d.x.shape[1], {"preprocessing": 0.0}
218
+ train_coded_memory(
219
+ head,
220
+ z,
221
+ (d.y == label).astype(float),
222
+ d.weight,
223
+ zv,
224
+ (d.yv == label).astype(float) if d.yv is not None else None,
225
+ copy.deepcopy(rng),
226
+ cache=cache,
227
+ )
228
+ heads.append(NativeHead(head.intercept_, head.levels_, head.table_size))
229
+ return encoder, heads, preprocessing_s