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 +8 -0
- codadapt/_base.py +380 -0
- codadapt/_core.py +70 -0
- codadapt/_multiclass.py +229 -0
- codadapt/_multiclass_artifact.py +75 -0
- codadapt/_multiclass_packing.py +165 -0
- codadapt/_training.py +370 -0
- codadapt/_version.py +1 -0
- codadapt/classifier.py +117 -0
- codadapt/experimental/__init__.py +13 -0
- codadapt/experimental/_compiler.py +166 -0
- codadapt/experimental/_model.py +174 -0
- codadapt/experimental/_rational.py +133 -0
- codadapt/experimental/_safe_blend.py +215 -0
- codadapt/preprocessing.py +435 -0
- codadapt/regressor.py +15 -0
- codadapt-1.0.0.dist-info/METADATA +566 -0
- codadapt-1.0.0.dist-info/RECORD +21 -0
- codadapt-1.0.0.dist-info/WHEEL +5 -0
- codadapt-1.0.0.dist-info/licenses/LICENSE +21 -0
- codadapt-1.0.0.dist-info/top_level.txt +1 -0
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)
|
codadapt/_multiclass.py
ADDED
|
@@ -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
|