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/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