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
|
@@ -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)
|