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.
@@ -0,0 +1,215 @@
1
+ """Opt-in native Base/Rational diversity blend with frozen validation fallback."""
2
+
3
+ from numbers import Integral
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from sklearn.base import BaseEstimator, RegressorMixin
8
+ from sklearn.model_selection import train_test_split
9
+ from sklearn.utils import check_random_state
10
+ from sklearn.utils.validation import check_is_fitted
11
+ from threadpoolctl import threadpool_limits
12
+
13
+ from ..preprocessing import BucketEncoder
14
+ from ..regressor import CodAdaptRegressor
15
+ from ._rational import Rational
16
+
17
+
18
+ class SafeBlendRegressor(RegressorMixin, BaseEstimator):
19
+ """Experimental, fully native regression blend; never the CodAdapt default.
20
+
21
+ A fixed 25% internal holdout selects Base or Base + 0.30*(Rational-Base).
22
+ Both models use only the remaining training rows; neither is refitted after
23
+ selection. Validation fallback reduces observed downside, but cannot
24
+ guarantee improvement on unseen data. No external model is fitted or retained.
25
+
26
+ Parameters
27
+ ----------
28
+ random_state : int, RandomState or None, default=None
29
+ Controls the internal split and native Base. Integers give repeatable fits.
30
+ verbosity : int, default=0
31
+ Native Base logging level, between 0 and 2.
32
+ n_jobs : int, default=1
33
+ Positive numerical thread limit during fit. No process pool is created.
34
+ Concurrent fits in separate threads should not share thread-pool limits.
35
+
36
+ Notes
37
+ -----
38
+ Dense real numeric arrays and mixed pandas DataFrames are supported. Use
39
+ pandas categorical/string/boolean columns to identify categories. This
40
+ minimal interface does not expose categorical column indices, sample weights
41
+ or an external eval_set. Targets must be one-dimensional, finite regression
42
+ values; explicit class labels and conventional small integer class encodings
43
+ are rejected. See docs/SAFE_BLEND_EXPERIMENTAL.md for the frozen contract.
44
+ """
45
+
46
+ def __init__(self, random_state=None, verbosity=0, n_jobs=1):
47
+ self.random_state = random_state
48
+ self.verbosity = verbosity
49
+ self.n_jobs = n_jobs
50
+
51
+ @staticmethod
52
+ def _target(y, n):
53
+ if isinstance(getattr(y, "dtype", None), pd.CategoricalDtype):
54
+ raise ValueError(
55
+ "SafeBlendRegressor supports regression, not categorical class labels."
56
+ )
57
+ target = np.asarray(y)
58
+ if target.ndim != 1:
59
+ raise ValueError(
60
+ "SafeBlendRegressor supports one-dimensional single-target regression."
61
+ )
62
+ if len(target) != n:
63
+ raise ValueError("X and y must contain the same number of samples.")
64
+ if target.dtype.kind not in "iuf":
65
+ raise ValueError("y must contain real numeric regression values, not class labels.")
66
+ target = target.astype(np.float64)
67
+ if not np.isfinite(target).all():
68
+ raise ValueError(
69
+ "y must contain finite regression values; missing/infinity are invalid."
70
+ )
71
+ labels = np.unique(target)
72
+ if (
73
+ 2 <= len(labels) <= 20
74
+ and np.all((labels >= -(2**63)) & (labels < 2**63))
75
+ and np.array_equal(labels, np.floor(labels))
76
+ ):
77
+ if len(labels) == 2 or np.array_equal(labels, np.arange(len(labels))):
78
+ raise ValueError(
79
+ "Classification labels are unsupported; use CodAdaptClassifier. "
80
+ "SafeBlendRegressor expects regression targets."
81
+ )
82
+ return target
83
+
84
+ def _validate_parameters(self):
85
+ for name, low, high in (("verbosity", 0, 2), ("n_jobs", 1, None)):
86
+ value = getattr(self, name)
87
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral):
88
+ raise ValueError(f"{name} must be an integer.")
89
+ if value < low or (high is not None and value > high):
90
+ raise ValueError(f"{name} is outside its supported range.")
91
+ if isinstance(self.random_state, (bool, np.bool_)):
92
+ raise ValueError("random_state must be an integer, RandomState or None.")
93
+ if isinstance(self.random_state, Integral):
94
+ seed = int(self.random_state)
95
+ if not 0 <= seed <= 2**32 - 101:
96
+ raise ValueError("random_state must be between 0 and 2**32 - 101.")
97
+ return seed
98
+ return int(check_random_state(self.random_state).randint(0, 2**32 - 100, dtype=np.int64))
99
+
100
+ def _prepare(self, X):
101
+ X = BucketEncoder._validate_input(X)
102
+ if isinstance(X, pd.DataFrame) != self._fit_dataframe_:
103
+ raise ValueError(
104
+ "Input must use the same DataFrame/array representation as during fit."
105
+ )
106
+ if X.shape[1] != self.n_features_in_:
107
+ raise ValueError(
108
+ f"X must have {self.n_features_in_} features, matching the fitted schema."
109
+ )
110
+ if self._fit_dataframe_:
111
+ order = X.columns.get_indexer(self._columns_)
112
+ if (order < 0).any():
113
+ raise ValueError("DataFrame columns must match the fitted schema.")
114
+ if not X.columns.equals(self._columns_):
115
+ X = X.iloc[:, order]
116
+ X = X.copy()
117
+ else:
118
+ X = pd.DataFrame(X, columns=self._columns_)
119
+ for j, vocabulary in self._vocab_.items():
120
+ series = X.iloc[:, j]
121
+ BucketEncoder._categorical_values(series, self._columns_[j])
122
+ X[X.columns[j]] = series.map(vocabulary).astype(float)
123
+ # Validate unsupported numeric dtypes/infinities before conversion. Base
124
+ # performs its own numeric validation; no additional fitted encoder exists.
125
+ for j in range(X.shape[1]):
126
+ if j not in self._vocab_:
127
+ series = X.iloc[:, j]
128
+ BucketEncoder._numeric_values(series, self._columns_[j])
129
+ return X.astype(np.float64)
130
+
131
+ def __sklearn_is_fitted__(self):
132
+ return getattr(self, "_is_fitted_", False)
133
+
134
+ def _clear_fitted_state(self):
135
+ for name in list(vars(self)):
136
+ if name.endswith("_"):
137
+ delattr(self, name)
138
+
139
+ def fit(self, X, y):
140
+ """Fit the frozen recipe; a failed fit leaves the estimator unfitted."""
141
+ self._clear_fitted_state()
142
+ try:
143
+ return self._fit(X, y)
144
+ except BaseException:
145
+ # Discard partial schema/model state, including an interrupted refit,
146
+ # and propagate the original failure without changing its type.
147
+ self._clear_fitted_state()
148
+ raise
149
+
150
+ def _fit(self, X, y):
151
+ seed = self._validate_parameters()
152
+ X = BucketEncoder._validate_input(X)
153
+ target = self._target(y, len(X))
154
+ if len(X) < 8:
155
+ raise ValueError(
156
+ "SafeBlendRegressor requires at least 8 samples for internal validation."
157
+ )
158
+ cats = sorted(BucketEncoder()._categorical_indices(X))
159
+ train, validation = train_test_split(
160
+ np.arange(len(X)), test_size=0.25, random_state=seed + 100
161
+ )
162
+ self.n_features_in_ = X.shape[1]
163
+ self._fit_dataframe_ = isinstance(X, pd.DataFrame)
164
+ self._columns_ = X.columns.copy() if self._fit_dataframe_ else pd.RangeIndex(X.shape[1])
165
+ if self._fit_dataframe_ and all(isinstance(c, str) for c in X.columns):
166
+ self.feature_names_in_ = X.columns.to_numpy(dtype=object, copy=True)
167
+ self._vocab_ = {}
168
+ for j in cats:
169
+ series = BucketEncoder._column(X, j)
170
+ BucketEncoder._categorical_values(series, self._columns_[j])
171
+ values = sorted(series.iloc[train].dropna().unique(), key=str)
172
+ self._vocab_[j] = {v: i for i, v in enumerate(values)}
173
+ prepared = self._prepare(X)
174
+ x_train, x_validation = prepared.iloc[train], prepared.iloc[validation]
175
+ with threadpool_limits(limits=int(self.n_jobs)):
176
+ base = CodAdaptRegressor(
177
+ categorical_features=cats, random_state=seed, verbosity=self.verbosity
178
+ ).fit(x_train, target[train], eval_set=(x_validation, target[validation]))
179
+ rational = Rational(bins=16, ridge=0.001, seed=seed, maxiter=100, cats=cats).fit(
180
+ x_train, target[train]
181
+ )
182
+ base_prediction = base.predict(x_validation)
183
+ rational_prediction = rational.predict(x_validation)
184
+ blended = base_prediction + 0.30 * (rational_prediction - base_prediction)
185
+ if not np.isfinite(rational.theta).all() or not np.isfinite(blended).all():
186
+ raise ValueError("Native Rational fitting produced nonfinite values; fit failed.")
187
+ base_loss = np.mean((target[validation] - base_prediction) ** 2)
188
+ blend_loss = np.mean((target[validation] - blended) ** 2)
189
+ # Strict squared-loss comparison preserves the Round78 arithmetic exactly;
190
+ # square root is monotone, so this is strict validation RMSE improvement.
191
+ accepted = bool(blend_loss < base_loss)
192
+ self.base_ = base
193
+ self.rational_ = rational if accepted else None
194
+ self.branch_ = "BLEND" if accepted else "BASE"
195
+ self.alpha_ = 0.30 if accepted else 0.0
196
+ self.validation_base_rmse_ = float(np.sqrt(base_loss))
197
+ self.validation_blend_rmse_ = float(np.sqrt(blend_loss))
198
+ self.fit_diagnostics_ = {
199
+ "train_samples": len(train),
200
+ "validation_samples": len(validation),
201
+ "rational_converged": rational.converged,
202
+ "rational_iterations": len(rational.trace),
203
+ "refit": False,
204
+ }
205
+ self._is_fitted_ = True
206
+ return self
207
+
208
+ def predict(self, X):
209
+ """Return predictions using only the retained native branch."""
210
+ check_is_fitted(self, "_is_fitted_")
211
+ prepared = self._prepare(X)
212
+ prediction = self.base_.predict(prepared)
213
+ if self.rational_ is None:
214
+ return prediction
215
+ return prediction + self.alpha_ * (self.rational_.predict(prepared) - prediction)
@@ -0,0 +1,435 @@
1
+ """Private train-only encoder implementation; not a standalone public transformer API.
2
+
3
+ The historical module/class path is retained for internal imports and trusted
4
+ model persistence. Importability does not imply a supported standalone contract.
5
+ """
6
+
7
+ from enum import Enum
8
+ from numbers import Integral, Real
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from pandas.api.types import (
13
+ infer_dtype,
14
+ is_bool_dtype,
15
+ is_complex_dtype,
16
+ is_datetime64_any_dtype,
17
+ is_numeric_dtype,
18
+ is_object_dtype,
19
+ is_string_dtype,
20
+ is_timedelta64_dtype,
21
+ )
22
+ from scipy import sparse
23
+ from sklearn.base import BaseEstimator, TransformerMixin
24
+ from sklearn.utils import check_random_state
25
+ from sklearn.utils.validation import check_is_fitted
26
+
27
+ __all__ = []
28
+
29
+
30
+ class _BooleanKey(Enum):
31
+ """Avoid Python's equality collision between True/1 and False/0."""
32
+
33
+ FALSE = "false"
34
+ TRUE = "true"
35
+
36
+
37
+ class _ResolutionView:
38
+ """Minimal per-level metadata over one shared fitted encoder."""
39
+
40
+ def __init__(self, n_buckets, support):
41
+ self.n_buckets_ = n_buckets
42
+ self.support_ = support
43
+
44
+
45
+ class BucketEncoder(TransformerMixin, BaseEstimator):
46
+ """Internal encoder for compact, deterministic integer buckets.
47
+
48
+ Not a public standalone transformer. Its sklearn base classes support native
49
+ estimator internals and tests; no independent stable API is promised.
50
+
51
+ Numeric thresholds use linear training quantiles in float64. Values equal
52
+ to a threshold enter the interval on its right; finite extremes enter the
53
+ outer intervals. The final numeric bucket is reserved for missing values.
54
+ Categorical buckets 0, 1 and 2 mean missing, observed-rare and unseen.
55
+ Frequency ties preserve first observation order. Strings, booleans and
56
+ numbers are distinct; equal integers/floats describe the same category.
57
+ ``schemas_`` stores thresholds/vocabularies but never training rows.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ n_bins=32,
63
+ max_categories=128,
64
+ quantile_sample_size=20000,
65
+ categorical_features=None,
66
+ random_state=None,
67
+ ):
68
+ self.n_bins = n_bins
69
+ self.max_categories = max_categories
70
+ self.quantile_sample_size = quantile_sample_size
71
+ self.categorical_features = categorical_features
72
+ self.random_state = random_state
73
+
74
+ def _validate_parameters(self):
75
+ for name, upper in (
76
+ ("n_bins", 4096),
77
+ ("max_categories", 4096),
78
+ ("quantile_sample_size", 1_000_000),
79
+ ):
80
+ value = getattr(self, name)
81
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral):
82
+ raise ValueError(f"{name} must be an integer between 1 and {upper}.")
83
+ if not 1 <= value <= upper:
84
+ raise ValueError(f"{name} must be between 1 and {upper}.")
85
+
86
+ @staticmethod
87
+ def _validate_input(X):
88
+ if sparse.issparse(X):
89
+ raise TypeError(
90
+ "Sparse input is not supported; provide dense numeric arrays or a DataFrame."
91
+ )
92
+ if isinstance(X, pd.DataFrame):
93
+ if X.columns.has_duplicates:
94
+ raise ValueError("DataFrame feature names must not contain duplicate columns.")
95
+ else:
96
+ try:
97
+ X = np.asarray(X)
98
+ except (TypeError, ValueError) as exc:
99
+ raise ValueError(
100
+ "X must be a two-dimensional numeric array or a DataFrame."
101
+ ) from exc
102
+ if X.ndim != 2:
103
+ raise ValueError(
104
+ f"Expected 2D array, got {X.ndim}D array instead. Reshape your data."
105
+ )
106
+ if X.dtype.kind == "c":
107
+ raise ValueError("Complex data not supported.")
108
+ if X.dtype.kind not in "biuf":
109
+ raise TypeError(
110
+ "NumPy arrays must contain real numeric values; use a DataFrame for categories."
111
+ )
112
+ if X.shape[0] == 0:
113
+ raise ValueError(
114
+ f"Found array with 0 sample(s) (shape={X.shape}); a minimum of 1 is required."
115
+ )
116
+ if X.shape[1] == 0:
117
+ raise ValueError(
118
+ f"Found array with 0 feature(s) (shape={X.shape}); a minimum of 1 is required."
119
+ )
120
+ if int(X.shape[0]) * int(X.shape[1]) * np.dtype(np.uint16).itemsize > 512 * 1024**2:
121
+ raise ValueError(
122
+ "Encoded input would exceed the 512 MiB bucket allocation limit; use smaller batches."
123
+ )
124
+ return X
125
+
126
+ @staticmethod
127
+ def _column(X, j):
128
+ return X.iloc[:, j] if isinstance(X, pd.DataFrame) else pd.Series(X[:, j], copy=False)
129
+
130
+ @staticmethod
131
+ def _check_dtype(series, feature):
132
+ dtype = series.dtype
133
+ if is_complex_dtype(dtype):
134
+ raise ValueError(f"Complex data not supported in feature {feature!r}.")
135
+ if is_datetime64_any_dtype(dtype) or is_timedelta64_dtype(dtype):
136
+ raise TypeError(
137
+ f"Date/time feature {feature!r} must be transformed explicitly before fitting."
138
+ )
139
+
140
+ def _categorical_indices(self, X):
141
+ declared = self.categorical_features
142
+ indices = set()
143
+ if declared is not None:
144
+ if isinstance(declared, (str, bytes)):
145
+ raise ValueError(
146
+ "categorical_features must be a sequence of column names or indices."
147
+ )
148
+ try:
149
+ declared = list(declared)
150
+ except TypeError as exc:
151
+ raise ValueError(
152
+ "categorical_features must be a sequence of column names or indices."
153
+ ) from exc
154
+ for feature in declared:
155
+ if isinstance(feature, (bool, np.bool_)):
156
+ raise ValueError(
157
+ "categorical_features entries must be names or integer indices, not booleans."
158
+ )
159
+ if isinstance(feature, Integral):
160
+ j = int(feature)
161
+ if not 0 <= j < X.shape[1]:
162
+ raise ValueError(f"Categorical feature index {j} is out of range.")
163
+ elif isinstance(feature, str) and isinstance(X, pd.DataFrame):
164
+ if feature not in X.columns:
165
+ raise ValueError(f"Unknown categorical feature name {feature!r}.")
166
+ j = X.columns.get_loc(feature)
167
+ else:
168
+ raise ValueError(
169
+ "Use column names or integer indices for DataFrames, and indices for arrays."
170
+ )
171
+ if j in indices:
172
+ raise ValueError(f"Categorical feature {feature!r} is declared more than once.")
173
+ indices.add(j)
174
+ for j in range(X.shape[1]):
175
+ series = self._column(X, j)
176
+ self._check_dtype(series, j)
177
+ dtype = series.dtype
178
+ if is_bool_dtype(dtype) or isinstance(dtype, pd.CategoricalDtype):
179
+ indices.add(j)
180
+ elif is_object_dtype(dtype) or is_string_dtype(dtype):
181
+ indices.add(j)
182
+ elif not is_numeric_dtype(dtype):
183
+ raise TypeError(f"Unsupported dtype {dtype!r} in feature {j}.")
184
+ return indices
185
+
186
+ @staticmethod
187
+ def _numeric_values(series, feature):
188
+ BucketEncoder._check_dtype(series, feature)
189
+ if not is_numeric_dtype(series.dtype):
190
+ inferred = infer_dtype(series, skipna=True)
191
+ if not is_object_dtype(series.dtype) or inferred not in (
192
+ "empty",
193
+ "integer",
194
+ "floating",
195
+ "mixed-integer-float",
196
+ "boolean",
197
+ ):
198
+ raise TypeError(
199
+ f"Numeric feature {feature!r} received nonnumeric or ambiguous values."
200
+ )
201
+ try:
202
+ values = series.to_numpy(dtype=np.float64, na_value=np.nan, copy=True)
203
+ except (TypeError, ValueError, OverflowError) as exc:
204
+ raise ValueError(
205
+ f"Feature {feature!r} cannot be converted to finite float64 values."
206
+ ) from exc
207
+ if np.isinf(values).any():
208
+ raise ValueError(
209
+ f"Input contains infinity in feature {feature!r}; infinities are not supported."
210
+ )
211
+ return values
212
+
213
+ @staticmethod
214
+ def _categorical_values(series, feature):
215
+ BucketEncoder._check_dtype(series, feature)
216
+ values = series.to_numpy(dtype=object, na_value=None, copy=True)
217
+ missing = pd.isna(series).to_numpy(dtype=bool)
218
+ try:
219
+ observed = pd.unique(values[~missing])
220
+ except TypeError as exc:
221
+ raise TypeError(
222
+ f"Feature {feature!r} contains unsupported nonscalar categories."
223
+ ) from exc
224
+ for value in observed:
225
+ if isinstance(value, (str, bool, np.bool_, Integral)):
226
+ continue
227
+ if isinstance(value, Real):
228
+ if not np.isfinite(value):
229
+ raise ValueError(f"Input contains infinity in categorical feature {feature!r}.")
230
+ continue
231
+ raise TypeError(
232
+ f"Feature {feature!r} contains unsupported category type {type(value).__name__}; "
233
+ "only strings, finite numbers, booleans and missing values are supported."
234
+ )
235
+ inferred = infer_dtype(values, skipna=True)
236
+ if inferred == "boolean":
237
+ truth = series.to_numpy(dtype=bool, na_value=False)
238
+ values[~missing & truth] = _BooleanKey.TRUE
239
+ values[~missing & ~truth] = _BooleanKey.FALSE
240
+ elif inferred in ("mixed", "mixed-integer"):
241
+ # Native pandas string operations preserve boolean/number identity
242
+ # even though Python equality and pandas.unique collapse True/1.
243
+ objects = pd.Series(values, copy=False)
244
+ strings = objects.str.fullmatch(r"(?s).*", na=False).to_numpy(dtype=bool)
245
+ text = objects.astype("string")
246
+ true = text.eq("True").fillna(False).to_numpy(dtype=bool) & ~strings
247
+ false = text.eq("False").fillna(False).to_numpy(dtype=bool) & ~strings
248
+ numeric_kind = infer_dtype(objects[~missing & ~strings & ~true & ~false], skipna=True)
249
+ if numeric_kind not in ("empty", "integer", "floating", "mixed-integer-float"):
250
+ raise TypeError(
251
+ f"Feature {feature!r} contains unsupported or ambiguous scalar categories."
252
+ )
253
+ values[true], values[false] = _BooleanKey.TRUE, _BooleanKey.FALSE
254
+ return pd.Series(values, dtype=object, copy=False), missing
255
+
256
+ def _fit_numeric(self, series, feature, sample):
257
+ values = self._numeric_values(series, feature)
258
+ sampled = values if sample is None else values[sample]
259
+ sampled = np.sort(sampled[~np.isnan(sampled)])
260
+ thresholds = np.empty(0, dtype=np.float64)
261
+ if sampled.size > 1 and sampled[0] != sampled[-1] and self.n_bins > 1:
262
+ positions = np.linspace(0, 1, self.n_bins + 1)[1:-1] * (sampled.size - 1)
263
+ left = positions.astype(np.intp)
264
+ fraction = positions - left
265
+ right = np.minimum(left + 1, sampled.size - 1)
266
+ # Convex interpolation avoids overflow in (right_value-left_value).
267
+ thresholds = np.unique((1 - fraction) * sampled[left] + fraction * sampled[right])
268
+ thresholds = thresholds[(thresholds > sampled[0]) & (thresholds <= sampled[-1])]
269
+ schema = {
270
+ "kind": "numeric",
271
+ "thresholds": thresholds,
272
+ "missing_bucket": len(thresholds) + 1,
273
+ }
274
+ buckets = self._transform_numeric(values, schema)
275
+ return schema, buckets, len(thresholds) + 2
276
+
277
+ @staticmethod
278
+ def _transform_numeric(values, schema):
279
+ buckets = np.searchsorted(schema["thresholds"], values, side="right").astype(np.uint16)
280
+ buckets[np.isnan(values)] = schema["missing_bucket"]
281
+ return buckets
282
+
283
+ def _fit_categorical(self, series, feature):
284
+ values, missing = self._categorical_values(series, feature)
285
+ counts = (
286
+ values[~missing].value_counts(sort=False).sort_values(ascending=False, kind="stable")
287
+ )
288
+ if len(counts) > 1_000_000:
289
+ raise ValueError(
290
+ f"Feature {feature!r} exceeds the 1,000,000 observed-category membership limit."
291
+ )
292
+ vocabulary = {key: j + 3 for j, key in enumerate(counts.index[: self.max_categories])}
293
+ schema = {
294
+ "kind": "categorical",
295
+ "vocabulary": vocabulary,
296
+ "observed_categories": counts.index.copy(),
297
+ "missing_bucket": 0,
298
+ "rare_bucket": 1,
299
+ "unknown_bucket": 2,
300
+ "n_rare_categories": max(0, len(counts) - len(vocabulary)),
301
+ }
302
+ return schema, self._transform_categorical(values, missing, schema), len(vocabulary) + 3
303
+
304
+ @staticmethod
305
+ def _transform_categorical(values, missing, schema):
306
+ mapped = values.map(schema["vocabulary"])
307
+ buckets = mapped.fillna(schema["unknown_bucket"]).to_numpy(dtype=np.uint16)
308
+ if schema["n_rare_categories"]:
309
+ rare = mapped.isna().to_numpy() & values.isin(schema["observed_categories"]).to_numpy()
310
+ buckets[rare] = schema["rare_bucket"]
311
+ buckets[missing] = schema["missing_bucket"]
312
+ return buckets
313
+
314
+ def fit_transform(self, X, y=None, **fit_params):
315
+ """Learn representation and return buckets without retaining input rows."""
316
+ if fit_params:
317
+ raise TypeError("BucketEncoder does not accept additional fit parameters.")
318
+ if hasattr(self, "_is_fitted_"):
319
+ del self._is_fitted_
320
+ self._validate_parameters()
321
+ X = self._validate_input(X)
322
+ categorical = self._categorical_indices(X)
323
+ rng = (
324
+ np.random.RandomState()
325
+ if self.random_state is None
326
+ else check_random_state(self.random_state)
327
+ )
328
+ sample = None
329
+ if X.shape[0] > self.quantile_sample_size:
330
+ sample = rng.choice(X.shape[0], size=self.quantile_sample_size, replace=False)
331
+ transformed = np.empty(X.shape, dtype=np.uint16)
332
+ schemas, support, sizes = [], [], []
333
+ for j in range(X.shape[1]):
334
+ series = self._column(X, j)
335
+ feature = X.columns[j] if isinstance(X, pd.DataFrame) else j
336
+ if j in categorical:
337
+ schema, buckets, size = self._fit_categorical(series, feature)
338
+ else:
339
+ schema, buckets, size = self._fit_numeric(series, feature, sample)
340
+ transformed[:, j] = buckets
341
+ schemas.append(schema)
342
+ support.append(np.bincount(buckets, minlength=size) > 0)
343
+ sizes.append(size)
344
+ self.n_features_in_ = X.shape[1]
345
+ self._fit_dataframe_ = isinstance(X, pd.DataFrame)
346
+ self._columns_ = X.columns.copy() if self._fit_dataframe_ else None
347
+ if hasattr(self, "feature_names_in_"):
348
+ del self.feature_names_in_
349
+ if self._fit_dataframe_ and all(isinstance(name, str) for name in X.columns):
350
+ self.feature_names_in_ = X.columns.to_numpy(dtype=object, copy=True)
351
+ self.schemas_, self.support_, self.n_buckets_ = schemas, support, sizes
352
+ self.categorical_features_ = np.array(sorted(categorical), dtype=np.intp)
353
+ self._is_fitted_ = True
354
+ return transformed
355
+
356
+ def fit(self, X, y=None):
357
+ """Fit exclusively on the effective training rows supplied by the caller."""
358
+ self.fit_transform(X, y)
359
+ return self
360
+
361
+ def transform(self, X):
362
+ """Apply the frozen representation; unseen categorical buckets stay unsupported."""
363
+ check_is_fitted(self, "_is_fitted_")
364
+ X = self._validate_input(X)
365
+ if isinstance(X, pd.DataFrame) != self._fit_dataframe_:
366
+ raise ValueError(
367
+ "Input must use the same DataFrame/array representation as during fit."
368
+ )
369
+ if X.shape[1] != self.n_features_in_:
370
+ raise ValueError(
371
+ f"X has {X.shape[1]} features, but BucketEncoder is expecting {self.n_features_in_} features as input."
372
+ )
373
+ if self._fit_dataframe_:
374
+ order = X.columns.get_indexer(self._columns_)
375
+ if np.any(order < 0):
376
+ raise ValueError(
377
+ "DataFrame columns must match the fitted schema; missing or additional features found."
378
+ )
379
+ if not X.columns.equals(self._columns_):
380
+ X = X.iloc[:, order]
381
+ transformed = np.empty(X.shape, dtype=np.uint16)
382
+ for j, schema in enumerate(self.schemas_):
383
+ series = self._column(X, j)
384
+ feature = X.columns[j] if self._fit_dataframe_ else j
385
+ if schema["kind"] == "numeric":
386
+ transformed[:, j] = self._transform_numeric(
387
+ self._numeric_values(series, feature), schema
388
+ )
389
+ else:
390
+ values, missing = self._categorical_values(series, feature)
391
+ transformed[:, j] = self._transform_categorical(values, missing, schema)
392
+ return transformed
393
+
394
+ def fit_transform_levels(self, X, resolutions):
395
+ """Fit one finest representation and derive nested integer bucket levels."""
396
+ finest = self.fit_transform(X)
397
+ self.resolution_bins_ = [int(value) for value in resolutions]
398
+ self.level_mappings_, self.level_views_ = [], []
399
+ levels = []
400
+ for resolution in self.resolution_bins_:
401
+ transformed = np.empty_like(finest)
402
+ mappings, sizes, support = [], [], []
403
+ for feature, schema in enumerate(self.schemas_):
404
+ if schema["kind"] == "numeric":
405
+ finite_count = int(schema["missing_bucket"])
406
+ level_count = max(1, min(resolution, finite_count))
407
+ mapping = np.empty(finite_count + 1, dtype=np.uint16)
408
+ mapping[:finite_count] = (
409
+ np.arange(finite_count, dtype=np.int64) * level_count // finite_count
410
+ ).astype(np.uint16)
411
+ mapping[finite_count] = level_count
412
+ size = level_count + 1
413
+ else:
414
+ size = self.n_buckets_[feature]
415
+ mapping = np.arange(size, dtype=np.uint16)
416
+ transformed[:, feature] = mapping[finest[:, feature]]
417
+ mappings.append(mapping)
418
+ sizes.append(size)
419
+ support.append(np.bincount(transformed[:, feature], minlength=size) > 0)
420
+ self.level_mappings_.append(mappings)
421
+ self.level_views_.append(_ResolutionView(sizes, support))
422
+ levels.append(transformed)
423
+ return levels
424
+
425
+ def transform_levels(self, X):
426
+ """Transform once at the finest resolution, then apply cached integer maps."""
427
+ check_is_fitted(self, "level_mappings_")
428
+ finest = self.transform(X)
429
+ levels = []
430
+ for mappings in self.level_mappings_:
431
+ transformed = np.empty_like(finest)
432
+ for feature, mapping in enumerate(mappings):
433
+ transformed[:, feature] = mapping[finest[:, feature]]
434
+ levels.append(transformed)
435
+ return levels
codadapt/regressor.py ADDED
@@ -0,0 +1,15 @@
1
+ """Single-target regression with adaptive lookup tables."""
2
+
3
+ from sklearn.base import RegressorMixin
4
+
5
+ from ._base import _BaseCodAdapt
6
+
7
+
8
+ class CodAdaptRegressor(RegressorMixin, _BaseCodAdapt):
9
+ """Single-target regressor, including integer-valued regression targets."""
10
+
11
+ _task = "regression"
12
+
13
+ def predict(self, X):
14
+ """Return real-valued target estimates."""
15
+ return self._decision(X)