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