"""Single-term penalised B-spline GAM (Eilers & Marx backend)."""
from typing import Optional, Tuple
import numpy as np
from numpy.typing import ArrayLike
from ._gam import GAMCore
from ._terms import _SplineTerm
[docs]
class LinGAM(GAMCore):
"""Penalised B-spline GAM for a single smooth term y = f(x) + eps."""
def __init__(
self,
n_splines: int = 10,
lam: float = 1.0,
spline_order: int = 3,
constraint: Optional[str] = None,
) -> None:
if lam < 0:
raise ValueError("Smoothing parameter 'lam' must be non-negative.")
if spline_order < 1:
raise ValueError("spline_order must be >= 1.")
super().__init__(
f"s(0, n_splines={int(n_splines)}, lam={float(lam)}, constraint={constraint!r})",
spline_order=int(spline_order),
fit_intercept=True,
)
@property
def _term(self) -> _SplineTerm:
return self._terms[0] # type: ignore[return-value]
@property
def n_splines(self) -> int:
return self._term.n_splines
@n_splines.setter
def n_splines(self, val: int) -> None:
val = int(val)
self._term.n_splines = val
self._term.n_coefs = (
val - self.spline_order
if self.constraint == "periodic"
else val
)
self._term_configs[0]["kwargs"]["n_splines"] = val
@property
def lam(self) -> float:
return self._term.lam
@lam.setter
def lam(self, val: float) -> None:
self._term.lam = float(val)
self._term_configs[0]["kwargs"]["lam"] = float(val)
@property
def constraint(self) -> Optional[str]:
return self._term.constraint
@property
def n_coefs(self) -> int:
"""Effective number of spline coefficients (reduced for periodic)."""
return self._term.n_coefs
@property
def edge_knots_(self) -> Optional[np.ndarray]:
return self._term._edge_knots
@property
def design_matrix_(self) -> Optional[np.ndarray]:
return self._design_matrix_
@property
def knots_(self) -> Optional[np.ndarray]:
ek = self.edge_knots_
if ek is None:
return None
scale = (ek[1] - ek[0]) or 1.0
return (
np.linspace(0.0, 1.0, 1 + self.n_splines - self.spline_order)
* scale
+ ek[0]
)
def __repr__(self) -> str:
status = "fitted" if self.is_fitted else "unfitted"
cons = f", constraint='{self.constraint}'" if self.constraint else ""
return (
f"LinGAM(n_splines={self.n_splines}, lam={self.lam:.4g}, "
f"spline_order={self.spline_order}{cons}) [{status}]"
)
def _diagnostics_title(self) -> str:
cons = f", constraint='{self.constraint}'" if self.constraint else ""
return f"LinGAM Diagnostics (K={self.n_splines}, lam={self.lam:.4f}{cons})"
[docs]
def gridsearch(
self,
x: ArrayLike,
y: ArrayLike,
lam: Optional[ArrayLike] = None,
n_splines: Optional[ArrayLike] = None,
gamma: float = 1.4,
n_jobs: int = 1,
weights: Optional[ArrayLike] = None,
) -> "LinGAM":
"""Select optimal (lam, n_splines) by minimising GCV."""
nsp = [np.atleast_1d(n_splines)] if n_splines is not None else None
lam_g = np.atleast_1d(lam) if lam is not None else None
super().gridsearch(
x,
y,
lam=lam_g,
n_splines=nsp,
gamma=gamma,
n_jobs=n_jobs,
weights=weights,
)
return self
[docs]
def plot_decomposition(
self,
x: ArrayLike,
y: ArrayLike,
n_grid: int = 300,
figsize: Tuple[int, int] = (16, 5),
) -> Tuple:
cons_str = f", constraint='{self.constraint}'" if self.constraint else ""
return self._plot_lingam_decomposition(
x,
y,
k_smooth=self.n_coefs,
prefix="B",
basis_title=f"B-Spline Basis (K_eff={self.n_coefs})",
fit_title="Weighted Basis Functions + Fit",
suptitle=f"GAM Decomposition (K={self.n_splines}{cons_str}, lam={self.lam:.4f})",
knots=self.knots_,
show_weighted=True,
n_grid=n_grid,
figsize=figsize,
)