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/classifier.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Native binary and shared-OVR multiclass classification."""
|
|
2
|
+
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.base import ClassifierMixin
|
|
7
|
+
from sklearn.utils.validation import check_is_fitted
|
|
8
|
+
|
|
9
|
+
from ._base import _BaseCodAdapt
|
|
10
|
+
from ._core import sigmoid
|
|
11
|
+
from ._multiclass import fit_heads, is_multiclass, prepare
|
|
12
|
+
from ._multiclass_artifact import Artifact
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodAdaptClassifier(ClassifierMixin, _BaseCodAdapt):
|
|
16
|
+
"""Single-label classifier; probabilities follow the order in ``classes_``.
|
|
17
|
+
|
|
18
|
+
Two positive-weight classes retain the native binary path. More classes use
|
|
19
|
+
shared encoding and target-specific native OVR heads, with normalized sigmoid
|
|
20
|
+
probabilities rather than a softmax link.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_task = "classification"
|
|
24
|
+
|
|
25
|
+
def fit(self, X, y, sample_weight=None, eval_set=None):
|
|
26
|
+
"""Fit binary or multiclass targets with the same constructor parameters."""
|
|
27
|
+
if not is_multiclass(y, sample_weight):
|
|
28
|
+
return super().fit(X, y, sample_weight=sample_weight, eval_set=eval_set)
|
|
29
|
+
tick = perf_counter()
|
|
30
|
+
for key in list(vars(self)):
|
|
31
|
+
if key.endswith("_"):
|
|
32
|
+
delattr(self, key)
|
|
33
|
+
data = prepare(self, X, y, sample_weight, eval_set)
|
|
34
|
+
encoder, heads, preprocessing_s = fit_heads(self, data)
|
|
35
|
+
self._ovr_artifact_ = Artifact.create(
|
|
36
|
+
encoder,
|
|
37
|
+
data.classes,
|
|
38
|
+
heads,
|
|
39
|
+
dict(random_state=self.random_state, categorical_features=self.categorical_features),
|
|
40
|
+
)
|
|
41
|
+
self._restore_aliases()
|
|
42
|
+
self.estimated_memory_bytes_ = data.estimated_bytes
|
|
43
|
+
self.timings_ = {"preprocessing": preprocessing_s, "fit_total": perf_counter() - tick}
|
|
44
|
+
if self.verbosity:
|
|
45
|
+
print(
|
|
46
|
+
f"CodAdapt multiclass fitted {self.n_classes_} unchanged native heads; fit={self.timings_['fit_total']:.3f}s"
|
|
47
|
+
)
|
|
48
|
+
self._is_fitted_ = True
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def _restore_aliases(self):
|
|
52
|
+
artifact = self._ovr_artifact_
|
|
53
|
+
state = artifact.shared_state
|
|
54
|
+
self.classes_, self.n_classes_ = state.classes, len(state.classes)
|
|
55
|
+
self.n_features_in_ = state.encoder.n_features_in_
|
|
56
|
+
self.encoder_ = self.shared_encoder_ = state.encoder
|
|
57
|
+
self.encoders_ = state.encoder.level_views_
|
|
58
|
+
if hasattr(state.encoder, "feature_names_in_"):
|
|
59
|
+
self.feature_names_in_ = state.encoder.feature_names_in_
|
|
60
|
+
self._heads_ = artifact.heads
|
|
61
|
+
self.n_iter_ = max(len(h.levels) for h in artifact.heads)
|
|
62
|
+
self.best_iteration_ = self.n_iter_
|
|
63
|
+
self.lookup_count_per_sample_ = sum(
|
|
64
|
+
len(level["main_tables"]) + len(level["tables"])
|
|
65
|
+
for head in artifact.heads
|
|
66
|
+
for level in head.levels
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def decision_function(self, X):
|
|
70
|
+
"""Return binary scores (n,) or multiclass OVR scores (n, K)."""
|
|
71
|
+
if hasattr(self, "_ovr_artifact_"):
|
|
72
|
+
check_is_fitted(self)
|
|
73
|
+
return self._ovr_artifact_.decision_function(X)
|
|
74
|
+
return self._decision(X)
|
|
75
|
+
|
|
76
|
+
def predict_proba(self, X):
|
|
77
|
+
"""Return class-ordered probabilities, shape (n_samples, n_classes)."""
|
|
78
|
+
if hasattr(self, "_ovr_artifact_"):
|
|
79
|
+
check_is_fitted(self)
|
|
80
|
+
return self._ovr_artifact_.predict_proba(X)
|
|
81
|
+
positive = sigmoid(self._decision(X))
|
|
82
|
+
return np.column_stack((1.0 - positive, positive))
|
|
83
|
+
|
|
84
|
+
def predict(self, X):
|
|
85
|
+
"""Return original labels; multiclass ties select the first class."""
|
|
86
|
+
if hasattr(self, "_ovr_artifact_"):
|
|
87
|
+
check_is_fitted(self)
|
|
88
|
+
return self._ovr_artifact_.predict(X)
|
|
89
|
+
score = self._decision(X)
|
|
90
|
+
return self.classes_[(score > 0.0).astype(np.intp)]
|
|
91
|
+
|
|
92
|
+
def __sklearn_tags__(self):
|
|
93
|
+
tags = super().__sklearn_tags__()
|
|
94
|
+
tags.classifier_tags.multi_class = True
|
|
95
|
+
return tags
|
|
96
|
+
|
|
97
|
+
def _more_tags(self):
|
|
98
|
+
return {**super()._more_tags(), "binary_only": False}
|
|
99
|
+
|
|
100
|
+
def __getstate__(self):
|
|
101
|
+
state = super().__getstate__().copy()
|
|
102
|
+
if "_ovr_artifact_" in state:
|
|
103
|
+
for key in (
|
|
104
|
+
"classes_",
|
|
105
|
+
"encoder_",
|
|
106
|
+
"shared_encoder_",
|
|
107
|
+
"encoders_",
|
|
108
|
+
"feature_names_in_",
|
|
109
|
+
"_heads_",
|
|
110
|
+
):
|
|
111
|
+
state.pop(key, None)
|
|
112
|
+
return state
|
|
113
|
+
|
|
114
|
+
def __setstate__(self, state):
|
|
115
|
+
super().__setstate__(state)
|
|
116
|
+
if "_ovr_artifact_" in state:
|
|
117
|
+
self._restore_aliases()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Opt-in experimental exports; the default CodAdapt estimators are unchanged."""
|
|
2
|
+
|
|
3
|
+
from ._compiler import CompilationError, compile_ebm
|
|
4
|
+
from ._model import CompiledEBMClassifier, CompiledEBMRegressor
|
|
5
|
+
from ._safe_blend import SafeBlendRegressor
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CompilationError",
|
|
9
|
+
"CompiledEBMClassifier",
|
|
10
|
+
"CompiledEBMRegressor",
|
|
11
|
+
"SafeBlendRegressor",
|
|
12
|
+
"compile_ebm",
|
|
13
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Exact additive EBM compilation, restricted to a verified optional dependency."""
|
|
2
|
+
|
|
3
|
+
import numbers
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from ._model import CompiledEBMClassifier, CompiledEBMRegressor, _frame, _Runtime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CompilationError(ValueError):
|
|
12
|
+
"""REJECT: no compiled model is returned when the contract cannot be verified."""
|
|
13
|
+
|
|
14
|
+
compiler_status_ = "REJECT"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compile_ebm(teacher, *, X_verify, max_states=None):
|
|
18
|
+
"""Compile a fitted additive EBM with mandatory independent verification.
|
|
19
|
+
|
|
20
|
+
Supports interpret-core 0.7.8, binary classification and scalar regression.
|
|
21
|
+
X_verify must be a nonempty held-out DataFrame with teacher feature names.
|
|
22
|
+
max_states limits exact lookup entries (including missing/unknown states).
|
|
23
|
+
No approximation, fitting or teacher retention occurs. Raw output must be
|
|
24
|
+
bitwise equal; probabilities must be within absolute tolerance 1e-15.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from importlib.metadata import version
|
|
28
|
+
|
|
29
|
+
from interpret.glassbox import ExplainableBoostingClassifier, ExplainableBoostingRegressor
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError("Compilation requires the optional codadapt[ebm] dependency.") from exc
|
|
32
|
+
if version("interpret-core") != "0.7.8":
|
|
33
|
+
raise CompilationError("REJECT: only interpret-core 0.7.8 has been validated.")
|
|
34
|
+
if type(teacher) not in (ExplainableBoostingClassifier, ExplainableBoostingRegressor):
|
|
35
|
+
raise CompilationError("REJECT: expected a supported fitted additive EBM teacher.")
|
|
36
|
+
try:
|
|
37
|
+
return _compile(
|
|
38
|
+
teacher, X_verify, max_states, type(teacher) is ExplainableBoostingClassifier
|
|
39
|
+
)
|
|
40
|
+
except CompilationError:
|
|
41
|
+
raise
|
|
42
|
+
except (ValueError, TypeError, AttributeError, IndexError, KeyError, OverflowError) as exc:
|
|
43
|
+
raise CompilationError(
|
|
44
|
+
f"REJECT: unsupported model/schema or verification failure: {exc}"
|
|
45
|
+
) from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compile(t, x, budget, classification):
|
|
49
|
+
if not isinstance(x, pd.DataFrame) or len(x) == 0 or not x.columns.is_unique:
|
|
50
|
+
raise CompilationError("REJECT: X_verify must be a nonempty unique-column DataFrame.")
|
|
51
|
+
columns = list(t.feature_names_in_)
|
|
52
|
+
p = len(columns)
|
|
53
|
+
if (
|
|
54
|
+
not p
|
|
55
|
+
or len(set(columns)) != p
|
|
56
|
+
or any(not isinstance(c, str) for c in columns)
|
|
57
|
+
or list(x.columns) != columns
|
|
58
|
+
):
|
|
59
|
+
raise CompilationError("REJECT: verification feature names/order must match the teacher.")
|
|
60
|
+
if budget is not None and (
|
|
61
|
+
isinstance(budget, bool) or not isinstance(budget, numbers.Integral) or budget < 0
|
|
62
|
+
):
|
|
63
|
+
raise CompilationError("REJECT: max_states must be a nonnegative integer or None.")
|
|
64
|
+
if (
|
|
65
|
+
list(t.term_features_) != [(j,) for j in range(p)]
|
|
66
|
+
or len(t.bins_) != p
|
|
67
|
+
or len(t.term_scores_) != p
|
|
68
|
+
):
|
|
69
|
+
raise CompilationError("REJECT: only ordered additive univariate terms are supported.")
|
|
70
|
+
if len(t.feature_types_in_) != p or any(
|
|
71
|
+
v not in ("continuous", "nominal", "ordinal") for v in t.feature_types_in_
|
|
72
|
+
):
|
|
73
|
+
raise CompilationError("REJECT: unsupported feature type.")
|
|
74
|
+
if getattr(t, "link_", None) != ("logit" if classification else "identity"):
|
|
75
|
+
raise CompilationError("REJECT: unsupported output link.")
|
|
76
|
+
intercept = np.asarray(t.intercept_, dtype=float)
|
|
77
|
+
if intercept.size != 1 or not np.isfinite(intercept).all():
|
|
78
|
+
raise CompilationError("REJECT: finite scalar intercept required; multiclass unsupported.")
|
|
79
|
+
if classification and len(t.classes_) != 2:
|
|
80
|
+
raise CompilationError("REJECT: binary classification only.")
|
|
81
|
+
specs = []
|
|
82
|
+
numeric_unknown = []
|
|
83
|
+
capacity = payload = 0
|
|
84
|
+
for j, levels in enumerate(t.bins_):
|
|
85
|
+
bins = levels[0]
|
|
86
|
+
v = np.asarray(t.term_scores_[j], dtype=float)
|
|
87
|
+
if v.ndim != 1 or len(v) < 2 or not np.isfinite(v).all():
|
|
88
|
+
raise CompilationError("REJECT: invalid or nonfinite cell values.")
|
|
89
|
+
if isinstance(bins, dict):
|
|
90
|
+
if any(not isinstance(k, str) for k in bins):
|
|
91
|
+
raise CompilationError("REJECT: categorical bin labels must be strings.")
|
|
92
|
+
if any(
|
|
93
|
+
isinstance(i, bool)
|
|
94
|
+
or not isinstance(i, numbers.Integral)
|
|
95
|
+
or i < 1
|
|
96
|
+
or i >= len(v) - 1
|
|
97
|
+
for i in bins.values()
|
|
98
|
+
):
|
|
99
|
+
raise CompilationError("REJECT: invalid category bin index.")
|
|
100
|
+
keys = pd.Index(list(bins))
|
|
101
|
+
values = np.array([v[i] for i in bins.values()] + [v[-1], v[0]])
|
|
102
|
+
specs.append((False, keys, values))
|
|
103
|
+
capacity += len(values)
|
|
104
|
+
payload += values.nbytes + sum(len(k.encode("utf8")) + 8 for k in keys)
|
|
105
|
+
else:
|
|
106
|
+
cuts = np.asarray(bins, dtype=float)
|
|
107
|
+
if (
|
|
108
|
+
cuts.ndim != 1
|
|
109
|
+
or len(v) != len(cuts) + 3
|
|
110
|
+
or not np.isfinite(cuts).all()
|
|
111
|
+
or np.any(np.diff(cuts) <= 0)
|
|
112
|
+
):
|
|
113
|
+
raise CompilationError("REJECT: invalid numeric cuts/cells.")
|
|
114
|
+
values = v[1:-1]
|
|
115
|
+
keep = values[1:].view("u8") != values[:-1].view("u8")
|
|
116
|
+
# Lossless equal-neighbor coalescing only, preserving cut semantics.
|
|
117
|
+
edges = cuts[keep].copy()
|
|
118
|
+
values = np.r_[values[np.r_[True, keep]], v[0]]
|
|
119
|
+
specs.append((True, edges, values))
|
|
120
|
+
numeric_unknown.append(v[-1])
|
|
121
|
+
capacity += len(values) + 1 # Includes teacher numeric unknown-state audit slot.
|
|
122
|
+
payload += edges.nbytes + values.nbytes + 8
|
|
123
|
+
if budget is not None and capacity > budget:
|
|
124
|
+
raise CompilationError(
|
|
125
|
+
f"REJECT: exact export requires {capacity} states, budget is {budget}."
|
|
126
|
+
)
|
|
127
|
+
x = _frame(x, columns, specs)
|
|
128
|
+
model = CompiledEBMClassifier() if classification else CompiledEBMRegressor()
|
|
129
|
+
model._runtime_ = _Runtime(columns, float(intercept.ravel()[0]), specs, numeric_unknown)
|
|
130
|
+
model.feature_names_in_ = np.asarray(columns, dtype=object)
|
|
131
|
+
model.n_features_in_ = p
|
|
132
|
+
if classification:
|
|
133
|
+
model.classes_ = np.array(t.classes_, copy=True)
|
|
134
|
+
raw = model._raw(x)
|
|
135
|
+
expected = np.asarray(t.decision_function(x) if classification else t.predict(x), dtype=float)
|
|
136
|
+
if (
|
|
137
|
+
expected.shape != raw.shape
|
|
138
|
+
or not np.isfinite(raw).all()
|
|
139
|
+
or not np.array_equal(expected.view("u8"), raw.view("u8"))
|
|
140
|
+
):
|
|
141
|
+
raise CompilationError("REJECT: raw-score verification failed (exact float64 required).")
|
|
142
|
+
probability_error = None
|
|
143
|
+
if classification:
|
|
144
|
+
got = model.predict_proba(x)
|
|
145
|
+
ref = np.asarray(t.predict_proba(x), dtype=float)
|
|
146
|
+
if ref.shape != got.shape or not np.isfinite(ref).all():
|
|
147
|
+
raise CompilationError("REJECT: invalid teacher probabilities.")
|
|
148
|
+
probability_error = float(np.max(abs(got - ref)))
|
|
149
|
+
if probability_error > 1e-15:
|
|
150
|
+
raise CompilationError(
|
|
151
|
+
"REJECT: probability verification exceeded absolute tolerance 1e-15."
|
|
152
|
+
)
|
|
153
|
+
model.compiler_status_ = "SAFE"
|
|
154
|
+
model.required_capacity_ = capacity
|
|
155
|
+
model.compiled_state_count_ = capacity
|
|
156
|
+
model.estimated_memory_bytes_ = payload
|
|
157
|
+
model.teacher_type_ = type(t).__name__
|
|
158
|
+
model.task_ = "classification" if classification else "regression"
|
|
159
|
+
model.verification_error_ = {
|
|
160
|
+
"raw_max_absolute_error": 0.0,
|
|
161
|
+
"raw_bit_mismatches": 0,
|
|
162
|
+
"probability_max_absolute_error": probability_error,
|
|
163
|
+
"probability_absolute_tolerance": 1e-15,
|
|
164
|
+
"verification_rows": len(x),
|
|
165
|
+
}
|
|
166
|
+
return model
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Standalone ordered NumPy lookup runtime. No teacher imports or retained teacher."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.special import expit
|
|
6
|
+
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
|
|
7
|
+
from sklearn.utils.validation import check_is_fitted
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Runtime:
|
|
11
|
+
def __init__(self, columns, intercept, specs, numeric_unknown):
|
|
12
|
+
self.columns = tuple(columns)
|
|
13
|
+
self.intercept = intercept
|
|
14
|
+
self.cache = {}
|
|
15
|
+
self.numeric_unknown = np.array(numeric_unknown, dtype=float)
|
|
16
|
+
numeric = [(e, v) for num, e, v in specs if num]
|
|
17
|
+
self.edge_buffer = np.concatenate([e for e, _ in numeric]) if numeric else np.empty(0)
|
|
18
|
+
self.value_buffer = np.concatenate([v for _, v in numeric]) if numeric else np.empty(0)
|
|
19
|
+
self.specs = []
|
|
20
|
+
ec = vc = 0
|
|
21
|
+
for num, edges, values in specs:
|
|
22
|
+
if num:
|
|
23
|
+
edges = self.edge_buffer[ec : ec + len(edges)]
|
|
24
|
+
values = self.value_buffer[vc : vc + len(values)]
|
|
25
|
+
ec += len(edges)
|
|
26
|
+
vc += len(values)
|
|
27
|
+
self.specs.append((num, edges, values))
|
|
28
|
+
|
|
29
|
+
def __getstate__(self):
|
|
30
|
+
state = self.__dict__.copy()
|
|
31
|
+
state["cache"] = {}
|
|
32
|
+
state["specs"] = [
|
|
33
|
+
(True, len(e), len(v)) if num else (False, e, v) for num, e, v in self.specs
|
|
34
|
+
]
|
|
35
|
+
return state
|
|
36
|
+
|
|
37
|
+
def __setstate__(self, state):
|
|
38
|
+
specs = state.pop("specs")
|
|
39
|
+
self.__dict__.update(state)
|
|
40
|
+
self.specs = []
|
|
41
|
+
ec = vc = 0
|
|
42
|
+
for num, edges, values in specs:
|
|
43
|
+
if num:
|
|
44
|
+
self.specs.append(
|
|
45
|
+
(True, self.edge_buffer[ec : ec + edges], self.value_buffer[vc : vc + values])
|
|
46
|
+
)
|
|
47
|
+
ec += edges
|
|
48
|
+
vc += values
|
|
49
|
+
else:
|
|
50
|
+
self.specs.append((False, edges, values))
|
|
51
|
+
|
|
52
|
+
def _categorical(self, j, col, keys, values):
|
|
53
|
+
if isinstance(col.dtype, pd.CategoricalDtype):
|
|
54
|
+
levels = col.cat.categories
|
|
55
|
+
cached = self.cache.get(j)
|
|
56
|
+
if cached is None or cached[0] is not levels:
|
|
57
|
+
idx = keys.get_indexer(levels)
|
|
58
|
+
idx[idx < 0] = len(keys)
|
|
59
|
+
aligned = np.r_[values[idx], values[-1]]
|
|
60
|
+
self.cache[j] = (levels, aligned)
|
|
61
|
+
else:
|
|
62
|
+
aligned = cached[1]
|
|
63
|
+
return aligned[col.cat.codes.to_numpy()]
|
|
64
|
+
codes, levels = pd.factorize(col, sort=False)
|
|
65
|
+
idx = keys.get_indexer(levels)
|
|
66
|
+
idx[idx < 0] = len(keys)
|
|
67
|
+
return np.r_[values[idx], values[-1]][codes]
|
|
68
|
+
|
|
69
|
+
def raw(self, x):
|
|
70
|
+
# Fixed policy: small C-order blocks; otherwise ordered feature loop.
|
|
71
|
+
n = len(x)
|
|
72
|
+
out = np.full(n, self.intercept)
|
|
73
|
+
numeric = x.to_numpy(copy=False) if all(s[0] for s in self.specs) else None
|
|
74
|
+
mask = np.empty(n, bool)
|
|
75
|
+
width = min(16, len(self.specs))
|
|
76
|
+
buffer = np.empty((width + 1, n), order="C") if n <= 32 else None
|
|
77
|
+
used = 0
|
|
78
|
+
for j, (num, edges, values) in enumerate(self.specs):
|
|
79
|
+
if num:
|
|
80
|
+
a = (
|
|
81
|
+
np.asarray(numeric[:, j], float)
|
|
82
|
+
if numeric is not None
|
|
83
|
+
else x[self.columns[j]].to_numpy(dtype=float, na_value=np.nan)
|
|
84
|
+
)
|
|
85
|
+
a = np.ascontiguousarray(a)
|
|
86
|
+
idx = np.searchsorted(edges, a, side="right")
|
|
87
|
+
np.isnan(a, out=mask)
|
|
88
|
+
np.copyto(idx, len(values) - 1, where=mask)
|
|
89
|
+
v = values[idx]
|
|
90
|
+
else:
|
|
91
|
+
v = self._categorical(j, x[self.columns[j]], edges, values)
|
|
92
|
+
if buffer is None:
|
|
93
|
+
np.add(out, v, out=out)
|
|
94
|
+
else:
|
|
95
|
+
if used == 0:
|
|
96
|
+
buffer[0] = out
|
|
97
|
+
used += 1
|
|
98
|
+
buffer[used] = v
|
|
99
|
+
if used == width or j == len(self.specs) - 1:
|
|
100
|
+
np.add.accumulate(buffer[: used + 1], axis=0, out=buffer[: used + 1])
|
|
101
|
+
out[:] = buffer[used]
|
|
102
|
+
used = 0
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _frame(x, columns, specs):
|
|
107
|
+
if not isinstance(x, pd.DataFrame) or not x.columns.is_unique:
|
|
108
|
+
raise ValueError("Expected a DataFrame with unique string feature names.")
|
|
109
|
+
if len(x.columns) != len(columns) or set(x.columns) != set(columns):
|
|
110
|
+
raise ValueError("Feature schema must exactly match the compiled model.")
|
|
111
|
+
if tuple(x.columns) != tuple(columns):
|
|
112
|
+
x = x.loc[:, list(columns)]
|
|
113
|
+
converted = False
|
|
114
|
+
for j, (num, _, _) in enumerate(specs):
|
|
115
|
+
col = x.iloc[:, j]
|
|
116
|
+
if num:
|
|
117
|
+
if (
|
|
118
|
+
not pd.api.types.is_numeric_dtype(col.dtype)
|
|
119
|
+
or pd.api.types.is_complex_dtype(col.dtype)
|
|
120
|
+
or pd.api.types.is_bool_dtype(col.dtype)
|
|
121
|
+
):
|
|
122
|
+
raise ValueError(f"Numeric feature {columns[j]!r} needs real numeric dtype.")
|
|
123
|
+
if isinstance(col.dtype, pd.api.extensions.ExtensionDtype):
|
|
124
|
+
if not converted:
|
|
125
|
+
x = x.copy()
|
|
126
|
+
converted = True
|
|
127
|
+
x[columns[j]] = col.to_numpy(dtype=float, na_value=np.nan)
|
|
128
|
+
else:
|
|
129
|
+
levels = (
|
|
130
|
+
col.cat.categories
|
|
131
|
+
if isinstance(col.dtype, pd.CategoricalDtype)
|
|
132
|
+
else pd.unique(col.dropna())
|
|
133
|
+
)
|
|
134
|
+
if any(not isinstance(v, str) for v in levels):
|
|
135
|
+
raise ValueError(f"Categorical feature {columns[j]!r} supports string labels only.")
|
|
136
|
+
return x
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _CompiledBase(BaseEstimator):
|
|
140
|
+
def fit(self, X, y=None):
|
|
141
|
+
"""Compiled estimators cannot be trained; use compile_ebm with a fitted teacher."""
|
|
142
|
+
raise TypeError(
|
|
143
|
+
"Compiled estimators are not trainable; use compile_ebm(teacher, X_verify=...)."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _raw(self, x):
|
|
147
|
+
check_is_fitted(self, "_runtime_")
|
|
148
|
+
return self._runtime_.raw(_frame(x, self.feature_names_in_, self._runtime_.specs))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class CompiledEBMClassifier(ClassifierMixin, _CompiledBase):
|
|
152
|
+
"""Experimental compiler-produced binary classifier; not a trainable learner."""
|
|
153
|
+
|
|
154
|
+
def decision_function(self, X):
|
|
155
|
+
"""Return verified exact raw logits in the teacher's class order."""
|
|
156
|
+
return self._raw(X)
|
|
157
|
+
|
|
158
|
+
def predict_proba(self, X):
|
|
159
|
+
"""Return numerically equivalent probabilities, not bitwise teacher probabilities."""
|
|
160
|
+
p = expit(self.decision_function(X))
|
|
161
|
+
return np.column_stack((1 - p, p))
|
|
162
|
+
|
|
163
|
+
def predict(self, X):
|
|
164
|
+
"""Return original labels by probability argmax; ties select classes_[0]."""
|
|
165
|
+
probabilities = self.predict_proba(X)
|
|
166
|
+
return self.classes_[np.argmax(probabilities, axis=1)]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CompiledEBMRegressor(RegressorMixin, _CompiledBase):
|
|
170
|
+
"""Experimental compiler-produced single-target regressor; not trainable."""
|
|
171
|
+
|
|
172
|
+
def predict(self, X):
|
|
173
|
+
"""Return verified exact regression predictions."""
|
|
174
|
+
return self._raw(X)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Frozen native Rational16 kernel; internal implementation, not a public estimator."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.optimize import minimize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Codes:
|
|
9
|
+
def __init__(self, bins=16, cats=()):
|
|
10
|
+
self.bins = bins
|
|
11
|
+
self.cats = tuple(cats)
|
|
12
|
+
|
|
13
|
+
def fit(self, x):
|
|
14
|
+
a = np.asarray(x)
|
|
15
|
+
self.spec = []
|
|
16
|
+
self.sizes = []
|
|
17
|
+
for j in range(a.shape[1]):
|
|
18
|
+
v = a[:, j]
|
|
19
|
+
if j in self.cats:
|
|
20
|
+
counts = pd.Series(v).dropna().value_counts()
|
|
21
|
+
values = counts.index[: self.bins].tolist()
|
|
22
|
+
spec = {v: i + 2 for i, v in enumerate(values)}
|
|
23
|
+
size = len(values) + 2
|
|
24
|
+
else:
|
|
25
|
+
v = np.asarray(v, dtype=float)
|
|
26
|
+
finite = v[np.isfinite(v)]
|
|
27
|
+
spec = (
|
|
28
|
+
np.unique(np.quantile(finite, np.linspace(0, 1, self.bins + 1)[1:-1]))
|
|
29
|
+
if len(finite)
|
|
30
|
+
else np.array([])
|
|
31
|
+
)
|
|
32
|
+
size = len(spec) + 2
|
|
33
|
+
self.spec.append(spec)
|
|
34
|
+
self.sizes.append(size)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def transform(self, x):
|
|
38
|
+
a = np.asarray(x)
|
|
39
|
+
z = np.zeros(a.shape, dtype=np.int32)
|
|
40
|
+
for j, spec in enumerate(self.spec):
|
|
41
|
+
if isinstance(spec, dict):
|
|
42
|
+
s = pd.Series(a[:, j])
|
|
43
|
+
z[:, j] = s.map(spec).fillna(1).astype(np.int32)
|
|
44
|
+
z[s.isna().to_numpy(), j] = 0
|
|
45
|
+
else:
|
|
46
|
+
v = np.asarray(a[:, j], dtype=float)
|
|
47
|
+
z[:, j] = np.searchsorted(spec, v, side="right") + 1
|
|
48
|
+
z[~np.isfinite(v), j] = 0
|
|
49
|
+
return z
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Rational:
|
|
53
|
+
def __init__(self, bins=16, ridge=0.001, seed=0, maxiter=100, cats=()):
|
|
54
|
+
self.bins, self.ridge, self.seed = bins, ridge, seed
|
|
55
|
+
self.maxiter, self.cats = maxiter, cats
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def objective(theta, z, offsets, target, ridge):
|
|
59
|
+
m = int(offsets[-1])
|
|
60
|
+
num = np.full(len(z), theta[0])
|
|
61
|
+
den = np.ones(len(z))
|
|
62
|
+
for j in range(z.shape[1]):
|
|
63
|
+
idx = offsets[j] + z[:, j]
|
|
64
|
+
num += theta[1 + idx]
|
|
65
|
+
den += theta[1 + m + idx]
|
|
66
|
+
pred = num / den
|
|
67
|
+
res = pred - target
|
|
68
|
+
g = np.zeros_like(theta)
|
|
69
|
+
dn = res / den / len(z)
|
|
70
|
+
dd = -dn * pred
|
|
71
|
+
g[0] = dn.sum()
|
|
72
|
+
for j in range(z.shape[1]):
|
|
73
|
+
lo, hi = offsets[j : j + 2]
|
|
74
|
+
g[1 + lo : 1 + hi] = np.bincount(z[:, j], weights=dn, minlength=hi - lo)
|
|
75
|
+
g[1 + m + lo : 1 + m + hi] = np.bincount(z[:, j], weights=dd, minlength=hi - lo)
|
|
76
|
+
g[1:] += ridge * theta[1:]
|
|
77
|
+
return 0.5 * np.mean(res**2) + 0.5 * ridge * (theta[1:] @ theta[1:]), g
|
|
78
|
+
|
|
79
|
+
def fit(self, x, y):
|
|
80
|
+
self.encoder = Codes(self.bins, self.cats).fit(x)
|
|
81
|
+
z = self.encoder.transform(x)
|
|
82
|
+
self.offsets = np.r_[0, np.cumsum(self.encoder.sizes)]
|
|
83
|
+
m = self.offsets[-1]
|
|
84
|
+
self.mean, self.scale = float(np.mean(y)), max(float(np.std(y)), 1e-12)
|
|
85
|
+
target = (np.asarray(y) - self.mean) / self.scale
|
|
86
|
+
theta = np.zeros(1 + 2 * m)
|
|
87
|
+
# Nonzero training-only numerator initializes denominator derivatives.
|
|
88
|
+
for j in range(z.shape[1]):
|
|
89
|
+
lo, hi = self.offsets[j : j + 2]
|
|
90
|
+
sums = np.bincount(z[:, j], weights=target, minlength=hi - lo)
|
|
91
|
+
counts = np.bincount(z[:, j], minlength=hi - lo)
|
|
92
|
+
theta[1 + lo : 1 + hi] = sums / (counts + 5) / z.shape[1]
|
|
93
|
+
self.trace = []
|
|
94
|
+
cached = None
|
|
95
|
+
|
|
96
|
+
def objective(v, *args):
|
|
97
|
+
nonlocal cached
|
|
98
|
+
value, gradient = self.objective(v, *args)
|
|
99
|
+
cached = (v.copy(), float(value))
|
|
100
|
+
return value, gradient
|
|
101
|
+
|
|
102
|
+
def callback(v):
|
|
103
|
+
# L-BFGS-B has already evaluated the accepted iterate. Keep this
|
|
104
|
+
# fit-local scalar instead of recomputing its full gradient to log it.
|
|
105
|
+
if cached is None or not np.array_equal(v, cached[0]):
|
|
106
|
+
raise RuntimeError("Diagnostic objective does not match the accepted iterate.")
|
|
107
|
+
self.trace.append(cached[1])
|
|
108
|
+
|
|
109
|
+
result = minimize(
|
|
110
|
+
objective,
|
|
111
|
+
theta,
|
|
112
|
+
args=(z, self.offsets, target, self.ridge),
|
|
113
|
+
jac=True,
|
|
114
|
+
method="L-BFGS-B",
|
|
115
|
+
bounds=[(None, None)] * (m + 1) + [(0, 8)] * m,
|
|
116
|
+
options={"maxiter": self.maxiter, "ftol": 1e-9},
|
|
117
|
+
callback=callback,
|
|
118
|
+
)
|
|
119
|
+
self.theta = result.x
|
|
120
|
+
self.converged = bool(result.success)
|
|
121
|
+
self.solver_message = str(result.message)
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def predict(self, x):
|
|
125
|
+
z = self.encoder.transform(x)
|
|
126
|
+
m = self.offsets[-1]
|
|
127
|
+
num = np.full(len(z), self.theta[0])
|
|
128
|
+
den = np.ones(len(z))
|
|
129
|
+
for j in range(z.shape[1]):
|
|
130
|
+
idx = self.offsets[j] + z[:, j]
|
|
131
|
+
num += self.theta[1 + idx]
|
|
132
|
+
den += self.theta[1 + m + idx]
|
|
133
|
+
return self.mean + self.scale * num / den
|