geds-python 0.1.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.
- geds/__init__.py +25 -0
- geds/_backend.py +458 -0
- geds/_estimators.py +844 -0
- geds/_plotting.py +69 -0
- geds/_validation.py +148 -0
- geds/check.py +64 -0
- geds/py.typed +1 -0
- geds_python-0.1.0.dist-info/METADATA +406 -0
- geds_python-0.1.0.dist-info/RECORD +11 -0
- geds_python-0.1.0.dist-info/WHEEL +4 -0
- geds_python-0.1.0.dist-info/licenses/LICENSE +674 -0
geds/_estimators.py
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
"""Scikit-learn-style estimators that delegate all statistics to R GeDS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import copy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pickle
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from pandas.api.types import is_numeric_dtype
|
|
13
|
+
from sklearn.base import BaseEstimator, RegressorMixin
|
|
14
|
+
from sklearn.utils.validation import check_is_fitted
|
|
15
|
+
|
|
16
|
+
from ._backend import get_backend
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
FeatureSelector = Sequence[str | int] | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _GeDSBase(RegressorMixin, BaseEstimator):
|
|
23
|
+
_fit_function = ""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _offset_array(offset: Any, length: int) -> np.ndarray:
|
|
27
|
+
values = np.asarray(offset, dtype=float)
|
|
28
|
+
if values.ndim != 1 or len(values) != length or not np.isfinite(values).all():
|
|
29
|
+
raise ValueError("offset must contain one finite value per observation.")
|
|
30
|
+
return values
|
|
31
|
+
|
|
32
|
+
def _validate_configuration(self) -> None:
|
|
33
|
+
if self.order not in (2, 3, 4):
|
|
34
|
+
raise ValueError("order must be 2, 3, or 4.")
|
|
35
|
+
if not self.higher_order and self.order != 2:
|
|
36
|
+
raise ValueError("order must be 2 when higher_order=False.")
|
|
37
|
+
if self.beta is not None and not 0 <= self.beta <= 1:
|
|
38
|
+
raise ValueError("beta must lie in [0, 1].")
|
|
39
|
+
if not 0 <= self.phi <= 1:
|
|
40
|
+
raise ValueError("phi must lie in [0, 1].")
|
|
41
|
+
if not isinstance(self.q, (int, np.integer)) or self.q < 1:
|
|
42
|
+
raise ValueError("q must be a positive integer.")
|
|
43
|
+
if self.stop_type not in {"SR", "RD", "LR"}:
|
|
44
|
+
raise ValueError("stop_type must be 'SR', 'RD', or 'LR'.")
|
|
45
|
+
for name in ("min_internal_knots", "max_internal_knots"):
|
|
46
|
+
value = getattr(self, name)
|
|
47
|
+
if value is not None and (
|
|
48
|
+
not isinstance(value, (int, np.integer)) or value < 0
|
|
49
|
+
):
|
|
50
|
+
raise ValueError(f"{name} must be a non-negative integer or None.")
|
|
51
|
+
if (
|
|
52
|
+
self.min_internal_knots is not None
|
|
53
|
+
and self.max_internal_knots is not None
|
|
54
|
+
and self.min_internal_knots > self.max_internal_knots
|
|
55
|
+
):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"min_internal_knots must not exceed max_internal_knots."
|
|
58
|
+
)
|
|
59
|
+
self._validate_range("x_range", self.x_range)
|
|
60
|
+
self._validate_range("y_range", self.y_range)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _validate_range(name: str, value: Sequence[float] | None) -> None:
|
|
64
|
+
if value is None:
|
|
65
|
+
return
|
|
66
|
+
array = np.asarray(value, dtype=float)
|
|
67
|
+
if array.shape != (2,) or not np.isfinite(array).all() or array[0] >= array[1]:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{name} must contain two finite, strictly increasing values."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _frame(X: Any) -> tuple[pd.DataFrame, bool]:
|
|
74
|
+
if isinstance(X, pd.DataFrame):
|
|
75
|
+
if X.columns.has_duplicates:
|
|
76
|
+
raise ValueError("X must not contain duplicate column names.")
|
|
77
|
+
return X.copy(), True
|
|
78
|
+
array = np.asarray(X)
|
|
79
|
+
if array.ndim != 2:
|
|
80
|
+
raise ValueError("X must be a two-dimensional array or DataFrame.")
|
|
81
|
+
return (
|
|
82
|
+
pd.DataFrame(array, columns=[f"x{i}" for i in range(array.shape[1])]),
|
|
83
|
+
False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _resolve(
|
|
88
|
+
selectors: FeatureSelector, columns: list[Any], *, default: list[int]
|
|
89
|
+
) -> list[int]:
|
|
90
|
+
if selectors is None:
|
|
91
|
+
return default
|
|
92
|
+
if isinstance(selectors, (str, int)):
|
|
93
|
+
selectors = [selectors]
|
|
94
|
+
indices: list[int] = []
|
|
95
|
+
for selector in selectors:
|
|
96
|
+
if isinstance(selector, str):
|
|
97
|
+
if selector not in columns:
|
|
98
|
+
raise ValueError(f"Unknown feature {selector!r}.")
|
|
99
|
+
index = columns.index(selector)
|
|
100
|
+
elif isinstance(selector, (int, np.integer)):
|
|
101
|
+
index = int(selector)
|
|
102
|
+
if index < 0 or index >= len(columns):
|
|
103
|
+
raise ValueError(f"Feature index {index} is out of range.")
|
|
104
|
+
else:
|
|
105
|
+
raise TypeError("Feature selectors must be column names or indices.")
|
|
106
|
+
if index not in indices:
|
|
107
|
+
indices.append(index)
|
|
108
|
+
return indices
|
|
109
|
+
|
|
110
|
+
def _prepare_training_data(
|
|
111
|
+
self, X: Any, y: Any, sample_weight: Any, offset: Any
|
|
112
|
+
) -> tuple[pd.DataFrame, str, Any | None]:
|
|
113
|
+
frame, named_input = self._frame(X)
|
|
114
|
+
y_array = np.asarray(y, dtype=float)
|
|
115
|
+
if y_array.ndim != 1 or len(y_array) != len(frame):
|
|
116
|
+
raise ValueError("y must be one-dimensional and have the same length as X.")
|
|
117
|
+
if not np.isfinite(y_array).all():
|
|
118
|
+
raise ValueError("y must contain only finite values.")
|
|
119
|
+
|
|
120
|
+
columns = list(frame.columns)
|
|
121
|
+
spline = self._resolve(
|
|
122
|
+
self.spline_features, columns, default=list(range(len(columns)))
|
|
123
|
+
)
|
|
124
|
+
if not spline:
|
|
125
|
+
raise ValueError("At least one spline feature is required.")
|
|
126
|
+
if offset is not None and len(spline) != 1:
|
|
127
|
+
raise ValueError("offset currently requires exactly one spline feature.")
|
|
128
|
+
remainder = [index for index in range(len(columns)) if index not in spline]
|
|
129
|
+
linear = self._resolve(self.linear_features, columns, default=remainder)
|
|
130
|
+
overlap = sorted(set(spline).intersection(linear))
|
|
131
|
+
if overlap:
|
|
132
|
+
raise ValueError("Spline and linear feature selections must not overlap.")
|
|
133
|
+
non_numeric = [
|
|
134
|
+
columns[index]
|
|
135
|
+
for index in spline
|
|
136
|
+
if not is_numeric_dtype(frame.iloc[:, index])
|
|
137
|
+
]
|
|
138
|
+
if non_numeric:
|
|
139
|
+
raise TypeError(f"Spline features must be numeric; got {non_numeric}.")
|
|
140
|
+
if frame.isna().to_numpy().any():
|
|
141
|
+
raise ValueError("X must not contain missing values.")
|
|
142
|
+
|
|
143
|
+
self.n_features_in_ = frame.shape[1]
|
|
144
|
+
self._input_columns_ = columns
|
|
145
|
+
self._named_input_ = named_input
|
|
146
|
+
if named_input and all(isinstance(column, str) for column in columns):
|
|
147
|
+
self.feature_names_in_ = np.asarray(columns, dtype=object)
|
|
148
|
+
self._internal_columns_ = [f"x{index}" for index in range(len(columns))]
|
|
149
|
+
self._spline_indices_ = spline
|
|
150
|
+
self._linear_indices_ = linear
|
|
151
|
+
self.spline_features_ = np.asarray(
|
|
152
|
+
[columns[index] for index in spline], dtype=object
|
|
153
|
+
)
|
|
154
|
+
self.linear_features_ = np.asarray(
|
|
155
|
+
[columns[index] for index in linear], dtype=object
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
internal = frame.copy()
|
|
159
|
+
internal.columns = self._internal_columns_
|
|
160
|
+
internal.insert(0, "response", y_array)
|
|
161
|
+
spline_term = "f(" + ", ".join(self._internal_columns_[i] for i in spline) + ")"
|
|
162
|
+
linear_terms = [self._internal_columns_[i] for i in linear]
|
|
163
|
+
self._has_offset_ = offset is not None
|
|
164
|
+
if self._has_offset_:
|
|
165
|
+
internal["geds_offset"] = self._offset_array(offset, len(frame))
|
|
166
|
+
formula_terms = [spline_term, *linear_terms]
|
|
167
|
+
if self._has_offset_:
|
|
168
|
+
formula_terms.append("offset(geds_offset)")
|
|
169
|
+
formula = "response ~ " + " + ".join(formula_terms)
|
|
170
|
+
self.formula_ = formula
|
|
171
|
+
|
|
172
|
+
weights = None
|
|
173
|
+
if sample_weight is not None:
|
|
174
|
+
weight_array = np.asarray(sample_weight, dtype=float)
|
|
175
|
+
if weight_array.ndim != 1 or len(weight_array) != len(frame):
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"sample_weight must be one-dimensional and match the length of X."
|
|
178
|
+
)
|
|
179
|
+
if not np.isfinite(weight_array).all() or np.any(weight_array < 0):
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"sample_weight must contain only finite, non-negative values."
|
|
182
|
+
)
|
|
183
|
+
weights = get_backend().vector(weight_array)
|
|
184
|
+
return internal, formula, weights
|
|
185
|
+
|
|
186
|
+
def _prepare_new_data(self, X: Any, offset: Any = None) -> pd.DataFrame:
|
|
187
|
+
check_is_fitted(self, "_r_model_")
|
|
188
|
+
frame, named_input = self._frame(X)
|
|
189
|
+
if frame.shape[1] != self.n_features_in_:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"X has {frame.shape[1]} features; expected {self.n_features_in_}."
|
|
192
|
+
)
|
|
193
|
+
if self._named_input_:
|
|
194
|
+
missing = [
|
|
195
|
+
name for name in self._input_columns_ if name not in frame.columns
|
|
196
|
+
]
|
|
197
|
+
if missing:
|
|
198
|
+
raise ValueError(f"X is missing columns: {missing}.")
|
|
199
|
+
frame = frame.loc[:, self._input_columns_]
|
|
200
|
+
elif named_input:
|
|
201
|
+
frame = frame.iloc[:, : self.n_features_in_]
|
|
202
|
+
if frame.isna().to_numpy().any():
|
|
203
|
+
raise ValueError("X must not contain missing values.")
|
|
204
|
+
non_numeric = [
|
|
205
|
+
self._input_columns_[index]
|
|
206
|
+
for index in self._spline_indices_
|
|
207
|
+
if not is_numeric_dtype(frame.iloc[:, index])
|
|
208
|
+
]
|
|
209
|
+
if non_numeric:
|
|
210
|
+
raise TypeError(f"Spline features must be numeric; got {non_numeric}.")
|
|
211
|
+
frame.columns = self._internal_columns_
|
|
212
|
+
if self._has_offset_:
|
|
213
|
+
if offset is None:
|
|
214
|
+
raise ValueError("This model requires offset for prediction.")
|
|
215
|
+
frame["geds_offset"] = self._offset_array(offset, len(frame))
|
|
216
|
+
elif offset is not None:
|
|
217
|
+
raise ValueError("offset was not supplied when this model was fitted.")
|
|
218
|
+
return frame
|
|
219
|
+
|
|
220
|
+
def _common_fit_kwargs(self, weights: Any | None) -> dict[str, Any]:
|
|
221
|
+
kwargs: dict[str, Any] = {
|
|
222
|
+
"phi": self.phi,
|
|
223
|
+
"q": self.q,
|
|
224
|
+
"show_iters": self.verbose,
|
|
225
|
+
"stoptype": self.stop_type,
|
|
226
|
+
"higher_order": self.higher_order,
|
|
227
|
+
}
|
|
228
|
+
optional = {
|
|
229
|
+
"beta": self.beta,
|
|
230
|
+
"min_intknots": self.min_internal_knots,
|
|
231
|
+
"max_intknots": self.max_internal_knots,
|
|
232
|
+
"Xextr": (
|
|
233
|
+
get_backend().vector(self.x_range) if self.x_range is not None else None
|
|
234
|
+
),
|
|
235
|
+
"Yextr": (
|
|
236
|
+
get_backend().vector(self.y_range) if self.y_range is not None else None
|
|
237
|
+
),
|
|
238
|
+
"weights": weights,
|
|
239
|
+
}
|
|
240
|
+
kwargs.update(
|
|
241
|
+
{key: value for key, value in optional.items() if value is not None}
|
|
242
|
+
)
|
|
243
|
+
return kwargs
|
|
244
|
+
|
|
245
|
+
def _finish_fit(self, model: Any) -> None:
|
|
246
|
+
backend = get_backend()
|
|
247
|
+
self._r_model_ = model
|
|
248
|
+
self.coef_ = backend.coefficients(model, self.order)
|
|
249
|
+
self.knots_ = backend.knots(model, self.order)
|
|
250
|
+
self.deviance_ = backend.deviance(model, self.order)
|
|
251
|
+
self.n_iter_ = backend.component(model, "iters")
|
|
252
|
+
|
|
253
|
+
def predict(self, X: Any, *, offset: Any = None) -> np.ndarray:
|
|
254
|
+
frame = self._prepare_new_data(X, offset)
|
|
255
|
+
backend = get_backend()
|
|
256
|
+
return backend.predict(
|
|
257
|
+
self._r_model_, backend.dataframe_to_r(frame), self.order, "response"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def predict_link(self, X: Any, *, offset: Any = None) -> np.ndarray:
|
|
261
|
+
"""Return predictions on the link scale."""
|
|
262
|
+
frame = self._prepare_new_data(X, offset)
|
|
263
|
+
backend = get_backend()
|
|
264
|
+
return backend.predict(
|
|
265
|
+
self._r_model_, backend.dataframe_to_r(frame), self.order, "link"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def predict_terms(self, X: Any, *, offset: Any = None) -> pd.DataFrame:
|
|
269
|
+
"""Return R's spline and parametric contributions on the link scale."""
|
|
270
|
+
frame = self._prepare_new_data(X, offset)
|
|
271
|
+
backend = get_backend()
|
|
272
|
+
return backend.predict(
|
|
273
|
+
self._r_model_, backend.dataframe_to_r(frame), self.order, "terms"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def get_coefficients(self, order: int | None = None) -> Any:
|
|
277
|
+
check_is_fitted(self, "_r_model_")
|
|
278
|
+
selected_order = self.order if order is None else order
|
|
279
|
+
self._validate_requested_order(selected_order)
|
|
280
|
+
return get_backend().coefficients(self._r_model_, selected_order)
|
|
281
|
+
|
|
282
|
+
def get_knots(self, order: int | None = None) -> Any:
|
|
283
|
+
check_is_fitted(self, "_r_model_")
|
|
284
|
+
selected_order = self.order if order is None else order
|
|
285
|
+
self._validate_requested_order(selected_order)
|
|
286
|
+
return get_backend().knots(self._r_model_, selected_order)
|
|
287
|
+
|
|
288
|
+
def get_deviance(self, order: int | None = None) -> float:
|
|
289
|
+
"""Return the R GeDS deviance for a selected spline order."""
|
|
290
|
+
check_is_fitted(self, "_r_model_")
|
|
291
|
+
selected_order = self.order if order is None else order
|
|
292
|
+
self._validate_requested_order(selected_order)
|
|
293
|
+
return get_backend().deviance(self._r_model_, selected_order)
|
|
294
|
+
|
|
295
|
+
def get_log_likelihood(self, order: int | None = None) -> float:
|
|
296
|
+
"""Return the R GeDS log likelihood for a selected spline order."""
|
|
297
|
+
check_is_fitted(self, "_r_model_")
|
|
298
|
+
selected_order = self.order if order is None else order
|
|
299
|
+
self._validate_requested_order(selected_order)
|
|
300
|
+
return get_backend().log_likelihood(self._r_model_, selected_order)
|
|
301
|
+
|
|
302
|
+
def get_confidence_intervals(
|
|
303
|
+
self, order: int | None = None, *, level: float = 0.95
|
|
304
|
+
) -> pd.DataFrame:
|
|
305
|
+
"""Return R GeDS coefficient intervals with lower and upper columns."""
|
|
306
|
+
check_is_fitted(self, "_r_model_")
|
|
307
|
+
if hasattr(self, "shape_constraints_"):
|
|
308
|
+
raise NotImplementedError(
|
|
309
|
+
"Standard coefficient confidence intervals are not available "
|
|
310
|
+
"for shape-constrained fits."
|
|
311
|
+
)
|
|
312
|
+
selected_order = self.order if order is None else order
|
|
313
|
+
self._validate_requested_order(selected_order)
|
|
314
|
+
if not np.isfinite(level) or not 0 < level < 1:
|
|
315
|
+
raise ValueError("level must be a finite number strictly between 0 and 1.")
|
|
316
|
+
return get_backend().confidence_intervals(
|
|
317
|
+
self._r_model_, selected_order, level
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _require_univariate_spline(self) -> None:
|
|
321
|
+
check_is_fitted(self, "_r_model_")
|
|
322
|
+
if len(self._spline_indices_) != 1 or self._linear_indices_:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
"This operation requires a fitted univariate spline without "
|
|
325
|
+
"additional linear features."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def derive(
|
|
329
|
+
self, x: Any, *, derivative_order: int = 1, order: int | None = None
|
|
330
|
+
) -> np.ndarray:
|
|
331
|
+
"""Evaluate R ``Derive`` on the predictor scale at one or more x values."""
|
|
332
|
+
self._require_univariate_spline()
|
|
333
|
+
selected_order = self.order if order is None else order
|
|
334
|
+
self._validate_requested_order(selected_order)
|
|
335
|
+
if (not isinstance(derivative_order, (int, np.integer))
|
|
336
|
+
or not 1 <= derivative_order < selected_order):
|
|
337
|
+
raise ValueError("derivative_order must be an integer from 1 to order - 1.")
|
|
338
|
+
values = np.atleast_1d(np.asarray(x, dtype=float))
|
|
339
|
+
if values.ndim != 1 or not len(values) or not np.isfinite(values).all():
|
|
340
|
+
raise ValueError("x must contain finite numeric values.")
|
|
341
|
+
return get_backend().derive(
|
|
342
|
+
self._r_model_, values, derivative_order, selected_order
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def integrate(
|
|
346
|
+
self, lower: Any, upper: Any, *, order: int | None = None
|
|
347
|
+
) -> np.ndarray:
|
|
348
|
+
"""Evaluate R ``Integrate`` on the predictor scale over given limits."""
|
|
349
|
+
self._require_univariate_spline()
|
|
350
|
+
selected_order = self.order if order is None else order
|
|
351
|
+
self._validate_requested_order(selected_order)
|
|
352
|
+
lower_values = np.atleast_1d(np.asarray(lower, dtype=float))
|
|
353
|
+
upper_values = np.atleast_1d(np.asarray(upper, dtype=float))
|
|
354
|
+
if lower_values.ndim != 1 or upper_values.ndim != 1 or not len(upper_values):
|
|
355
|
+
raise ValueError("Integration limits must be scalars or one-dimensional.")
|
|
356
|
+
if len(lower_values) not in (1, len(upper_values)):
|
|
357
|
+
raise ValueError("lower must be scalar or have the same length as upper.")
|
|
358
|
+
if np.isnan(lower_values).any() or np.isnan(upper_values).any():
|
|
359
|
+
raise ValueError("Integration limits must not be NaN.")
|
|
360
|
+
return get_backend().integrate(
|
|
361
|
+
self._r_model_, lower_values, upper_values, selected_order
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def piecewise_polynomial(
|
|
365
|
+
self, *, order: int | None = None
|
|
366
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
367
|
+
"""Return R ``PPolyRep`` knots and polynomial coefficient matrix."""
|
|
368
|
+
self._require_univariate_spline()
|
|
369
|
+
selected_order = self.order if order is None else order
|
|
370
|
+
self._validate_requested_order(selected_order)
|
|
371
|
+
return get_backend().piecewise_polynomial(self._r_model_, selected_order)
|
|
372
|
+
|
|
373
|
+
def shape_constrain(
|
|
374
|
+
self, constraints: str | Sequence[str] = "increasing", *,
|
|
375
|
+
order: int | None = None, base_learner: str | None = None,
|
|
376
|
+
eps: float = 0.0, ridge: float = 1e-8,
|
|
377
|
+
) -> "_GeDSBase":
|
|
378
|
+
"""Return a new estimator constrained by R ``shapeConstrain``."""
|
|
379
|
+
check_is_fitted(self, "_r_model_")
|
|
380
|
+
selected_order = self.order if order is None else order
|
|
381
|
+
self._validate_requested_order(selected_order)
|
|
382
|
+
selected = [constraints] if isinstance(constraints, str) else list(constraints)
|
|
383
|
+
allowed = {"increasing", "decreasing", "convex", "concave"}
|
|
384
|
+
if not selected or len(selected) != len(set(selected)) or not set(selected) <= allowed:
|
|
385
|
+
raise ValueError(f"constraints must be distinct values from {sorted(allowed)}.")
|
|
386
|
+
if ({"increasing", "decreasing"} <= set(selected)
|
|
387
|
+
or {"convex", "concave"} <= set(selected)):
|
|
388
|
+
raise ValueError("Opposing shape constraints cannot be combined.")
|
|
389
|
+
if not np.isfinite(eps) or eps < 0 or not np.isfinite(ridge) or ridge <= 0:
|
|
390
|
+
raise ValueError("eps must be finite and non-negative; ridge must be positive.")
|
|
391
|
+
if hasattr(self, "_base_learner_lookup_"):
|
|
392
|
+
if self.family.lower() != "gaussian" or self.normalize_data:
|
|
393
|
+
raise ValueError("Shape constraints require Gaussian, unnormalized additive fits.")
|
|
394
|
+
if base_learner is not None:
|
|
395
|
+
if base_learner not in self._base_learner_lookup_:
|
|
396
|
+
raise ValueError(f"Unknown base learner {base_learner!r}.")
|
|
397
|
+
internal_learner = self._base_learner_lookup_[base_learner]
|
|
398
|
+
if base_learner not in self.base_learner_names_ or "," in internal_learner:
|
|
399
|
+
raise ValueError("base_learner must name a univariate spline term.")
|
|
400
|
+
else:
|
|
401
|
+
univariate = [name for name in self.base_learner_names_ if "," not in name]
|
|
402
|
+
if len(univariate) != 1:
|
|
403
|
+
raise ValueError("Specify one univariate spline base_learner.")
|
|
404
|
+
internal_learner = self._base_learner_lookup_[univariate[0]]
|
|
405
|
+
else:
|
|
406
|
+
if self._fit_function != "NGeDS" or len(self._spline_indices_) != 1:
|
|
407
|
+
raise ValueError("Shape constraints require a univariate normal GeDS fit.")
|
|
408
|
+
if base_learner is not None:
|
|
409
|
+
raise ValueError("base_learner applies only to GAM and boosting fits.")
|
|
410
|
+
internal_learner = None
|
|
411
|
+
backend = get_backend()
|
|
412
|
+
result = copy(self)
|
|
413
|
+
result.order = selected_order
|
|
414
|
+
constrained = backend.shape_constrain(
|
|
415
|
+
self._r_model_, selected_order, selected, eps, ridge, internal_learner
|
|
416
|
+
)
|
|
417
|
+
result._finish_fit(constrained)
|
|
418
|
+
result.shape_constraints_ = tuple(selected)
|
|
419
|
+
return result
|
|
420
|
+
|
|
421
|
+
def _validate_requested_order(self, order: int) -> None:
|
|
422
|
+
if order not in (2, 3, 4):
|
|
423
|
+
raise ValueError("order must be 2, 3, or 4.")
|
|
424
|
+
if not self.higher_order and order != 2:
|
|
425
|
+
raise ValueError("Only order 2 is available when higher_order=False.")
|
|
426
|
+
|
|
427
|
+
def save(self, path: str | Path) -> None:
|
|
428
|
+
"""Serialize the estimator and its opaque R model with pickle."""
|
|
429
|
+
check_is_fitted(self, "_r_model_")
|
|
430
|
+
with Path(path).open("wb") as stream:
|
|
431
|
+
pickle.dump(self, stream, protocol=pickle.HIGHEST_PROTOCOL)
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def load(cls, path: str | Path) -> "_GeDSBase":
|
|
435
|
+
"""Load an estimator saved by :meth:`save` from a trusted source."""
|
|
436
|
+
with Path(path).open("rb") as stream:
|
|
437
|
+
model = pickle.load(stream)
|
|
438
|
+
if not isinstance(model, cls):
|
|
439
|
+
raise TypeError(f"The file does not contain a {cls.__name__} estimator.")
|
|
440
|
+
return model
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class GeDSRegressor(_GeDSBase):
|
|
444
|
+
"""Normal-response GeDS estimator backed by ``GeDS::NGeDS``."""
|
|
445
|
+
|
|
446
|
+
_fit_function = "NGeDS"
|
|
447
|
+
|
|
448
|
+
def __init__(
|
|
449
|
+
self,
|
|
450
|
+
*,
|
|
451
|
+
spline_features: FeatureSelector = None,
|
|
452
|
+
linear_features: FeatureSelector = None,
|
|
453
|
+
order: int = 3,
|
|
454
|
+
beta: float = 0.5,
|
|
455
|
+
phi: float = 0.99,
|
|
456
|
+
min_internal_knots: int | None = None,
|
|
457
|
+
max_internal_knots: int | None = None,
|
|
458
|
+
q: int = 2,
|
|
459
|
+
x_range: Sequence[float] | None = None,
|
|
460
|
+
y_range: Sequence[float] | None = None,
|
|
461
|
+
stop_type: str = "RD",
|
|
462
|
+
higher_order: bool = True,
|
|
463
|
+
verbose: bool = False,
|
|
464
|
+
) -> None:
|
|
465
|
+
self.spline_features = spline_features
|
|
466
|
+
self.linear_features = linear_features
|
|
467
|
+
self.order = order
|
|
468
|
+
self.beta = beta
|
|
469
|
+
self.phi = phi
|
|
470
|
+
self.min_internal_knots = min_internal_knots
|
|
471
|
+
self.max_internal_knots = max_internal_knots
|
|
472
|
+
self.q = q
|
|
473
|
+
self.x_range = x_range
|
|
474
|
+
self.y_range = y_range
|
|
475
|
+
self.stop_type = stop_type
|
|
476
|
+
self.higher_order = higher_order
|
|
477
|
+
self.verbose = verbose
|
|
478
|
+
|
|
479
|
+
def fit(
|
|
480
|
+
self, X: Any, y: Any, sample_weight: Any = None, *, offset: Any = None
|
|
481
|
+
) -> "GeDSRegressor":
|
|
482
|
+
self._validate_configuration()
|
|
483
|
+
frame, formula, weights = self._prepare_training_data(X, y, sample_weight, offset)
|
|
484
|
+
backend = get_backend()
|
|
485
|
+
model = backend.fit(
|
|
486
|
+
self._fit_function,
|
|
487
|
+
backend.formula(formula),
|
|
488
|
+
backend.dataframe_to_r(frame),
|
|
489
|
+
**self._common_fit_kwargs(weights),
|
|
490
|
+
)
|
|
491
|
+
self._finish_fit(model)
|
|
492
|
+
return self
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class GeDSGeneralizedRegressor(_GeDSBase):
|
|
496
|
+
"""Exponential-family GeDS estimator backed by ``GeDS::GGeDS``."""
|
|
497
|
+
|
|
498
|
+
_fit_function = "GGeDS"
|
|
499
|
+
|
|
500
|
+
def __init__(
|
|
501
|
+
self,
|
|
502
|
+
*,
|
|
503
|
+
family: str = "gaussian",
|
|
504
|
+
link: str | None = None,
|
|
505
|
+
spline_features: FeatureSelector = None,
|
|
506
|
+
linear_features: FeatureSelector = None,
|
|
507
|
+
order: int = 3,
|
|
508
|
+
beta: float | None = None,
|
|
509
|
+
phi: float = 0.99,
|
|
510
|
+
min_internal_knots: int | None = None,
|
|
511
|
+
max_internal_knots: int | None = None,
|
|
512
|
+
q: int = 2,
|
|
513
|
+
x_range: Sequence[float] | None = None,
|
|
514
|
+
y_range: Sequence[float] | None = None,
|
|
515
|
+
stop_type: str = "SR",
|
|
516
|
+
higher_order: bool = True,
|
|
517
|
+
verbose: bool = False,
|
|
518
|
+
) -> None:
|
|
519
|
+
self.family = family
|
|
520
|
+
self.link = link
|
|
521
|
+
self.spline_features = spline_features
|
|
522
|
+
self.linear_features = linear_features
|
|
523
|
+
self.order = order
|
|
524
|
+
self.beta = beta
|
|
525
|
+
self.phi = phi
|
|
526
|
+
self.min_internal_knots = min_internal_knots
|
|
527
|
+
self.max_internal_knots = max_internal_knots
|
|
528
|
+
self.q = q
|
|
529
|
+
self.x_range = x_range
|
|
530
|
+
self.y_range = y_range
|
|
531
|
+
self.stop_type = stop_type
|
|
532
|
+
self.higher_order = higher_order
|
|
533
|
+
self.verbose = verbose
|
|
534
|
+
|
|
535
|
+
def fit(
|
|
536
|
+
self, X: Any, y: Any, sample_weight: Any = None, *, offset: Any = None
|
|
537
|
+
) -> "GeDSGeneralizedRegressor":
|
|
538
|
+
self._validate_configuration()
|
|
539
|
+
frame, formula, weights = self._prepare_training_data(X, y, sample_weight, offset)
|
|
540
|
+
backend = get_backend()
|
|
541
|
+
kwargs = self._common_fit_kwargs(weights)
|
|
542
|
+
kwargs["family"] = backend.family(self.family, self.link)
|
|
543
|
+
model = backend.fit(
|
|
544
|
+
self._fit_function,
|
|
545
|
+
backend.formula(formula),
|
|
546
|
+
backend.dataframe_to_r(frame),
|
|
547
|
+
**kwargs,
|
|
548
|
+
)
|
|
549
|
+
self._finish_fit(model)
|
|
550
|
+
return self
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class _GeDSAdditiveBase(_GeDSBase):
|
|
554
|
+
"""Shared Python data handling for R's additive GAM and boosting fits."""
|
|
555
|
+
|
|
556
|
+
def _validate_configuration(self) -> None:
|
|
557
|
+
if self.order not in (2, 3, 4):
|
|
558
|
+
raise ValueError("order must be 2, 3, or 4.")
|
|
559
|
+
if not self.higher_order and self.order != 2:
|
|
560
|
+
raise ValueError("order must be 2 when higher_order=False.")
|
|
561
|
+
if not 0 <= self.beta <= 1 or not 0 < self.phi < 1:
|
|
562
|
+
raise ValueError("beta must be in [0, 1] and phi in (0, 1).")
|
|
563
|
+
if not isinstance(self.q, (int, np.integer)) or self.q < 1:
|
|
564
|
+
raise ValueError("q must be a positive integer.")
|
|
565
|
+
for name in ("min_iterations", "max_iterations"):
|
|
566
|
+
value = getattr(self, name)
|
|
567
|
+
if value is not None and (not isinstance(value, (int, np.integer)) or value < 0):
|
|
568
|
+
raise ValueError(f"{name} must be a non-negative integer or None.")
|
|
569
|
+
if (self.min_iterations is not None and self.max_iterations is not None
|
|
570
|
+
and self.min_iterations > self.max_iterations):
|
|
571
|
+
raise ValueError("min_iterations must not exceed max_iterations.")
|
|
572
|
+
if self.max_iterations == 0:
|
|
573
|
+
raise ValueError("max_iterations must be positive when specified.")
|
|
574
|
+
|
|
575
|
+
def _prepare_additive_data(
|
|
576
|
+
self, X: Any, y: Any, sample_weight: Any
|
|
577
|
+
) -> tuple[pd.DataFrame, str, Any | None]:
|
|
578
|
+
frame, named_input = self._frame(X)
|
|
579
|
+
response = np.asarray(y, dtype=float)
|
|
580
|
+
if response.ndim != 1 or len(response) != len(frame) or not np.isfinite(response).all():
|
|
581
|
+
raise ValueError("y must contain one finite numeric value per row of X.")
|
|
582
|
+
if frame.isna().to_numpy().any():
|
|
583
|
+
raise ValueError("X must not contain missing values.")
|
|
584
|
+
|
|
585
|
+
columns = list(frame.columns)
|
|
586
|
+
linear = self._resolve(self.linear_features, columns, default=[])
|
|
587
|
+
if self.spline_terms is None:
|
|
588
|
+
terms = [[index] for index in range(len(columns)) if index not in linear]
|
|
589
|
+
else:
|
|
590
|
+
terms = [self._resolve(group, columns, default=[]) for group in self.spline_terms]
|
|
591
|
+
if not terms or any(not group for group in terms):
|
|
592
|
+
raise ValueError("spline_terms must contain at least one non-empty feature group.")
|
|
593
|
+
spline = [index for group in terms for index in group]
|
|
594
|
+
if len(spline) != len(set(spline)):
|
|
595
|
+
raise ValueError("A feature may occur in only one spline term.")
|
|
596
|
+
if set(spline).intersection(linear):
|
|
597
|
+
raise ValueError("Spline and linear feature selections must not overlap.")
|
|
598
|
+
non_numeric = [columns[index] for index in spline if not is_numeric_dtype(frame.iloc[:, index])]
|
|
599
|
+
if non_numeric:
|
|
600
|
+
raise TypeError(f"Spline features must be numeric; got {non_numeric}.")
|
|
601
|
+
|
|
602
|
+
self.n_features_in_ = frame.shape[1]
|
|
603
|
+
self._input_columns_ = columns
|
|
604
|
+
self._named_input_ = named_input
|
|
605
|
+
if named_input and all(isinstance(column, str) for column in columns):
|
|
606
|
+
self.feature_names_in_ = np.asarray(columns, dtype=object)
|
|
607
|
+
self._internal_columns_ = [f"x{index}" for index in range(len(columns))]
|
|
608
|
+
self._spline_indices_ = spline
|
|
609
|
+
self._linear_indices_ = linear
|
|
610
|
+
self._has_offset_ = False
|
|
611
|
+
self.spline_terms_ = [
|
|
612
|
+
tuple(columns[index] for index in group) for group in terms
|
|
613
|
+
]
|
|
614
|
+
self.spline_features_ = np.asarray([columns[index] for index in spline], dtype=object)
|
|
615
|
+
self.linear_features_ = np.asarray([columns[index] for index in linear], dtype=object)
|
|
616
|
+
|
|
617
|
+
internal = frame.copy()
|
|
618
|
+
internal.columns = self._internal_columns_
|
|
619
|
+
internal.insert(0, "response", response)
|
|
620
|
+
self._r_base_learner_names_ = [
|
|
621
|
+
"f(" + ", ".join(self._internal_columns_[index] for index in group) + ")"
|
|
622
|
+
for group in terms
|
|
623
|
+
]
|
|
624
|
+
self.base_learner_names_ = [
|
|
625
|
+
"f(" + ", ".join(str(columns[index]) for index in group) + ")"
|
|
626
|
+
for group in terms
|
|
627
|
+
]
|
|
628
|
+
self._base_learner_lookup_ = dict(zip(self.base_learner_names_, self._r_base_learner_names_))
|
|
629
|
+
self._base_learner_lookup_.update(
|
|
630
|
+
{str(columns[index]): self._internal_columns_[index] for index in linear}
|
|
631
|
+
)
|
|
632
|
+
formula_terms = [*self._r_base_learner_names_, *[self._internal_columns_[i] for i in linear]]
|
|
633
|
+
self.formula_ = "response ~ " + " + ".join(formula_terms)
|
|
634
|
+
|
|
635
|
+
weights = None
|
|
636
|
+
if sample_weight is not None:
|
|
637
|
+
weight_array = np.asarray(sample_weight, dtype=float)
|
|
638
|
+
if (weight_array.ndim != 1 or len(weight_array) != len(frame)
|
|
639
|
+
or not np.isfinite(weight_array).all() or np.any(weight_array < 0)):
|
|
640
|
+
raise ValueError("sample_weight must contain one finite, non-negative value per row.")
|
|
641
|
+
weights = get_backend().vector(weight_array)
|
|
642
|
+
return internal, self.formula_, weights
|
|
643
|
+
|
|
644
|
+
def predict_terms(self, X: Any, *, offset: Any = None) -> pd.DataFrame:
|
|
645
|
+
raise NotImplementedError(
|
|
646
|
+
"R's NGeDSgam/NGeDSboost predict method does not support type='terms'; "
|
|
647
|
+
"use predict_component() for a named base learner."
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def predict_component(
|
|
651
|
+
self, X: Any, base_learner: str, *, prediction_type: str = "response"
|
|
652
|
+
) -> np.ndarray:
|
|
653
|
+
"""Return R's prediction for one named base learner (for example ``f(x)``)."""
|
|
654
|
+
check_is_fitted(self, "_r_model_")
|
|
655
|
+
if base_learner not in self._base_learner_lookup_:
|
|
656
|
+
raise ValueError(f"Unknown base learner {base_learner!r}.")
|
|
657
|
+
if prediction_type not in {"response", "link"}:
|
|
658
|
+
raise ValueError("prediction_type must be 'response' or 'link'.")
|
|
659
|
+
frame = self._prepare_new_data(X)
|
|
660
|
+
backend = get_backend()
|
|
661
|
+
return backend.predict(
|
|
662
|
+
self._r_model_, backend.dataframe_to_r(frame), self.order,
|
|
663
|
+
prediction_type, base_learner=self._base_learner_lookup_[base_learner],
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class GeDSGAMRegressor(_GeDSAdditiveBase):
|
|
668
|
+
"""Additive GeDS estimator backed entirely by ``GeDS::NGeDSgam``."""
|
|
669
|
+
|
|
670
|
+
def __init__(
|
|
671
|
+
self, *, family: str = "gaussian", link: str | None = None,
|
|
672
|
+
spline_terms: Sequence[Sequence[str | int]] | None = None,
|
|
673
|
+
linear_features: FeatureSelector = None, order: int = 3,
|
|
674
|
+
normalize_data: bool = False, min_iterations: int | None = None,
|
|
675
|
+
max_iterations: int | None = None, phi_gam_exit: float = 0.99,
|
|
676
|
+
q_gam: int = 2, beta: float = 0.5, phi: float = 0.99,
|
|
677
|
+
internal_knots: int = 500, q: int = 2, higher_order: bool = True,
|
|
678
|
+
) -> None:
|
|
679
|
+
self.family = family
|
|
680
|
+
self.link = link
|
|
681
|
+
self.spline_terms = spline_terms
|
|
682
|
+
self.linear_features = linear_features
|
|
683
|
+
self.order = order
|
|
684
|
+
self.normalize_data = normalize_data
|
|
685
|
+
self.min_iterations = min_iterations
|
|
686
|
+
self.max_iterations = max_iterations
|
|
687
|
+
self.phi_gam_exit = phi_gam_exit
|
|
688
|
+
self.q_gam = q_gam
|
|
689
|
+
self.beta = beta
|
|
690
|
+
self.phi = phi
|
|
691
|
+
self.internal_knots = internal_knots
|
|
692
|
+
self.q = q
|
|
693
|
+
self.higher_order = higher_order
|
|
694
|
+
|
|
695
|
+
def fit(self, X: Any, y: Any, sample_weight: Any = None) -> "GeDSGAMRegressor":
|
|
696
|
+
self._validate_configuration()
|
|
697
|
+
if not 0 < self.phi_gam_exit < 1:
|
|
698
|
+
raise ValueError("phi_gam_exit must lie in (0, 1).")
|
|
699
|
+
if not isinstance(self.q_gam, (int, np.integer)) or self.q_gam < 1:
|
|
700
|
+
raise ValueError("q_gam must be a positive integer.")
|
|
701
|
+
if not isinstance(self.internal_knots, (int, np.integer)) or self.internal_knots < 0:
|
|
702
|
+
raise ValueError("internal_knots must be a non-negative integer.")
|
|
703
|
+
frame, formula, weights = self._prepare_additive_data(X, y, sample_weight)
|
|
704
|
+
if self.family.lower() == "binomial":
|
|
705
|
+
levels = np.unique(frame["response"])
|
|
706
|
+
if len(levels) != 2 or not np.array_equal(levels, [0, 1]):
|
|
707
|
+
raise ValueError("Binomial GAM responses must contain both 0 and 1.")
|
|
708
|
+
frame["response"] = pd.Categorical(
|
|
709
|
+
frame["response"].astype(int).astype(str), categories=["0", "1"]
|
|
710
|
+
)
|
|
711
|
+
backend = get_backend()
|
|
712
|
+
kwargs: dict[str, Any] = {
|
|
713
|
+
"family": backend.family(self.family, self.link),
|
|
714
|
+
"normalize_data": self.normalize_data,
|
|
715
|
+
"phi_gam_exit": self.phi_gam_exit, "q_gam": self.q_gam,
|
|
716
|
+
"beta": self.beta, "phi": self.phi,
|
|
717
|
+
"internal_knots": self.internal_knots, "q": self.q,
|
|
718
|
+
"higher_order": self.higher_order,
|
|
719
|
+
}
|
|
720
|
+
for name, value in (("weights", weights), ("min_iterations", self.min_iterations),
|
|
721
|
+
("max_iterations", self.max_iterations)):
|
|
722
|
+
if value is not None:
|
|
723
|
+
kwargs[name] = value
|
|
724
|
+
model = backend.fit("NGeDSgam", backend.formula(formula), backend.dataframe_to_r(frame), **kwargs)
|
|
725
|
+
self._finish_fit(model)
|
|
726
|
+
return self
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class GeDSBoostRegressor(_GeDSAdditiveBase):
|
|
730
|
+
"""Boosted GeDS estimator backed entirely by ``GeDS::NGeDSboost``."""
|
|
731
|
+
|
|
732
|
+
def __init__(
|
|
733
|
+
self, *, family: str = "gaussian", link: str | None = None,
|
|
734
|
+
spline_terms: Sequence[Sequence[str | int]] | None = None,
|
|
735
|
+
linear_features: FeatureSelector = None, order: int = 3,
|
|
736
|
+
normalize_data: bool = False, initial_learner: bool = True,
|
|
737
|
+
int_knots_init: int = 2, min_iterations: int | None = None,
|
|
738
|
+
max_iterations: int | None = None, shrinkage: float = 1.0,
|
|
739
|
+
phi_boost_exit: float = 0.99, q_boost: int = 2,
|
|
740
|
+
beta: float = 0.5, phi: float = 0.99,
|
|
741
|
+
int_knots_boost: int | None = None, q: int = 2,
|
|
742
|
+
higher_order: bool = True, boosting_with_memory: bool = False,
|
|
743
|
+
) -> None:
|
|
744
|
+
self.family = family
|
|
745
|
+
self.link = link
|
|
746
|
+
self.spline_terms = spline_terms
|
|
747
|
+
self.linear_features = linear_features
|
|
748
|
+
self.order = order
|
|
749
|
+
self.normalize_data = normalize_data
|
|
750
|
+
self.initial_learner = initial_learner
|
|
751
|
+
self.int_knots_init = int_knots_init
|
|
752
|
+
self.min_iterations = min_iterations
|
|
753
|
+
self.max_iterations = max_iterations
|
|
754
|
+
self.shrinkage = shrinkage
|
|
755
|
+
self.phi_boost_exit = phi_boost_exit
|
|
756
|
+
self.q_boost = q_boost
|
|
757
|
+
self.beta = beta
|
|
758
|
+
self.phi = phi
|
|
759
|
+
self.int_knots_boost = int_knots_boost
|
|
760
|
+
self.q = q
|
|
761
|
+
self.higher_order = higher_order
|
|
762
|
+
self.boosting_with_memory = boosting_with_memory
|
|
763
|
+
|
|
764
|
+
def fit(self, X: Any, y: Any, sample_weight: Any = None) -> "GeDSBoostRegressor":
|
|
765
|
+
self._validate_configuration()
|
|
766
|
+
if not 0 < self.shrinkage <= 1:
|
|
767
|
+
raise ValueError("shrinkage must lie in (0, 1].")
|
|
768
|
+
if not 0 < self.phi_boost_exit < 1:
|
|
769
|
+
raise ValueError("phi_boost_exit must lie in (0, 1).")
|
|
770
|
+
if not isinstance(self.q_boost, (int, np.integer)) or self.q_boost < 1:
|
|
771
|
+
raise ValueError("q_boost must be a positive integer.")
|
|
772
|
+
for name in ("int_knots_init", "int_knots_boost"):
|
|
773
|
+
value = getattr(self, name)
|
|
774
|
+
if value is not None and (
|
|
775
|
+
not isinstance(value, (int, np.integer)) or value < 0
|
|
776
|
+
):
|
|
777
|
+
raise ValueError(f"{name} must be a non-negative integer or None.")
|
|
778
|
+
frame, formula, weights = self._prepare_additive_data(X, y, sample_weight)
|
|
779
|
+
if self.family.lower() == "binomial" and not np.isin(frame["response"], [-1, 1]).all():
|
|
780
|
+
raise ValueError("Binomial boosting responses must be encoded as -1 and 1.")
|
|
781
|
+
backend = get_backend()
|
|
782
|
+
kwargs: dict[str, Any] = {
|
|
783
|
+
"family": backend.boost_family(self.family),
|
|
784
|
+
"normalize_data": self.normalize_data,
|
|
785
|
+
"initial_learner": self.initial_learner,
|
|
786
|
+
"int.knots_init": self.int_knots_init,
|
|
787
|
+
"shrinkage": self.shrinkage,
|
|
788
|
+
"phi_boost_exit": self.phi_boost_exit, "q_boost": self.q_boost,
|
|
789
|
+
"beta": self.beta, "phi": self.phi, "q": self.q,
|
|
790
|
+
"higher_order": self.higher_order,
|
|
791
|
+
"boosting_with_memory": self.boosting_with_memory,
|
|
792
|
+
}
|
|
793
|
+
if self.link is not None:
|
|
794
|
+
kwargs["link"] = self.link
|
|
795
|
+
for name, value in (("weights", weights), ("min_iterations", self.min_iterations),
|
|
796
|
+
("max_iterations", self.max_iterations),
|
|
797
|
+
("int.knots_boost", self.int_knots_boost)):
|
|
798
|
+
if value is not None:
|
|
799
|
+
kwargs[name] = value
|
|
800
|
+
model = backend.fit("NGeDSboost", backend.formula(formula), backend.dataframe_to_r(frame), **kwargs)
|
|
801
|
+
self._finish_fit(model)
|
|
802
|
+
return self
|
|
803
|
+
|
|
804
|
+
def get_base_learner_importance(
|
|
805
|
+
self, *, boosting_iterations_only: bool = False
|
|
806
|
+
) -> pd.Series:
|
|
807
|
+
"""Return R ``bl_imp()`` in-bag risk reductions by base learner."""
|
|
808
|
+
check_is_fitted(self, "_r_model_")
|
|
809
|
+
importance = get_backend().base_learner_importance(
|
|
810
|
+
self._r_model_, boosting_iterations_only
|
|
811
|
+
)
|
|
812
|
+
inverse = {value: key for key, value in self._base_learner_lookup_.items()}
|
|
813
|
+
importance.index = [inverse.get(name, name) for name in importance.index]
|
|
814
|
+
return importance
|
|
815
|
+
|
|
816
|
+
def save_boosting_diagnostics(
|
|
817
|
+
self, path: str | Path, *, iterations: Sequence[int] = (0,),
|
|
818
|
+
final_fits: bool = False, overwrite: bool = False,
|
|
819
|
+
) -> Path:
|
|
820
|
+
"""Save R ``visualize_boosting()`` plots as a multipage PDF."""
|
|
821
|
+
check_is_fitted(self, "_r_model_")
|
|
822
|
+
if self.n_features_in_ != 1 or len(self.spline_terms_) != 1:
|
|
823
|
+
raise ValueError(
|
|
824
|
+
"R boosting visualization requires one univariate spline feature."
|
|
825
|
+
)
|
|
826
|
+
target = Path(path)
|
|
827
|
+
if target.suffix.lower() != ".pdf":
|
|
828
|
+
raise ValueError("path must name a PDF file.")
|
|
829
|
+
if target.exists() and not overwrite:
|
|
830
|
+
raise FileExistsError(f"Refusing to overwrite existing file: {target}")
|
|
831
|
+
selected = list(iterations)
|
|
832
|
+
n_iterations = int(np.asarray(self.n_iter_).item())
|
|
833
|
+
if not selected or any(
|
|
834
|
+
not isinstance(value, (int, np.integer))
|
|
835
|
+
or value < 0 or value > n_iterations for value in selected
|
|
836
|
+
):
|
|
837
|
+
raise ValueError(
|
|
838
|
+
"iterations must contain integers from 0 through n_iter_."
|
|
839
|
+
)
|
|
840
|
+
get_backend().save_boosting_diagnostics(
|
|
841
|
+
self._r_model_, target, [int(value) for value in selected],
|
|
842
|
+
final_fits,
|
|
843
|
+
)
|
|
844
|
+
return target
|