magli 0.3.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.
- magli/__init__.py +73 -0
- magli/_base.py +542 -0
- magli/_formula.py +49 -0
- magli/eilers/__init__.py +29 -0
- magli/eilers/_bspline.py +113 -0
- magli/eilers/_gam.py +580 -0
- magli/eilers/_lingam.py +145 -0
- magli/eilers/_penalty.py +27 -0
- magli/eilers/_solver.py +23 -0
- magli/eilers/_terms.py +287 -0
- magli/wood/__init__.py +37 -0
- magli/wood/_gam.py +333 -0
- magli/wood/_lingam.py +238 -0
- magli/wood/_optimizer.py +392 -0
- magli/wood/_smooths.py +582 -0
- magli/wood/_terms.py +219 -0
- magli-0.3.0.dist-info/METADATA +207 -0
- magli-0.3.0.dist-info/RECORD +21 -0
- magli-0.3.0.dist-info/WHEEL +5 -0
- magli-0.3.0.dist-info/licenses/LICENSE +693 -0
- magli-0.3.0.dist-info/top_level.txt +1 -0
magli/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""magli (Modelo Aditivo Generalizado Lineal) — Pythonic Generalized Additive Models (GAMs) with Wood and Eilers backends."""
|
|
2
|
+
|
|
3
|
+
from . import eilers, wood
|
|
4
|
+
from .eilers import GAMCore as EilersGAMCore, LinGAM as EilersLinGAM
|
|
5
|
+
from .wood import GAMCore as WoodGAMCore, LinGAM as WoodLinGAM
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
|
|
9
|
+
_BACKENDS = {
|
|
10
|
+
"wood": (WoodLinGAM, WoodGAMCore),
|
|
11
|
+
"eilers": (EilersLinGAM, EilersGAMCore),
|
|
12
|
+
"pygam": (EilersLinGAM, EilersGAMCore),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _dispatch(idx: int, backend: str, *args, **kwargs):
|
|
17
|
+
key = str(backend).lower()
|
|
18
|
+
if key not in _BACKENDS:
|
|
19
|
+
raise ValueError(f"Unknown backend '{backend}'. Choose 'wood' or 'eilers'.")
|
|
20
|
+
return _BACKENDS[key][idx](*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GAM:
|
|
24
|
+
"""Single-term Generalized Additive Model dispatcher.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
backend : {'wood', 'eilers'}, default='wood'
|
|
29
|
+
Engine backend: ``'wood'`` for Simon Wood's REML/GCV spline engine
|
|
30
|
+
(:class:`magli.wood.LinGAM`) or ``'eilers'`` for Eilers & Marx
|
|
31
|
+
B-splines with discrete GCV grid search (:class:`magli.eilers.LinGAM`).
|
|
32
|
+
*args, **kwargs
|
|
33
|
+
Forwarded to the selected backend's ``LinGAM`` constructor.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __new__(cls, *args, backend: str = "wood", **kwargs):
|
|
37
|
+
return _dispatch(0, backend, *args, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
LinGAM = GAM
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GAMCore:
|
|
44
|
+
"""Multi-term Generalized Additive Model formula dispatcher.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
formula : str
|
|
49
|
+
Additive model formula with ``s()``, ``te()``, ``f()``, and ``l()`` terms.
|
|
50
|
+
backend : {'wood', 'eilers'}, default='wood'
|
|
51
|
+
Engine backend: ``'wood'`` (:class:`magli.wood.GAMCore`) or
|
|
52
|
+
``'eilers'`` (:class:`magli.eilers.GAMCore`).
|
|
53
|
+
*args, **kwargs
|
|
54
|
+
Forwarded to the selected backend's ``GAMCore`` constructor.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __new__(cls, *args, backend: str = "wood", **kwargs):
|
|
58
|
+
return _dispatch(1, backend, *args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"GAM",
|
|
63
|
+
"LinGAM",
|
|
64
|
+
"GAMCore",
|
|
65
|
+
"EilersLinGAM",
|
|
66
|
+
"EilersGAMCore",
|
|
67
|
+
"WoodLinGAM",
|
|
68
|
+
"WoodGAMCore",
|
|
69
|
+
"eilers",
|
|
70
|
+
"wood",
|
|
71
|
+
"__version__",
|
|
72
|
+
]
|
|
73
|
+
|
magli/_base.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Shared base class, plotting, and tensor helpers for GAM backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
from scipy import stats
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validate_weights(
|
|
13
|
+
weights: Optional[ArrayLike], n: int
|
|
14
|
+
) -> Optional[np.ndarray]:
|
|
15
|
+
"""Validate and return 1D observation weights array, or None."""
|
|
16
|
+
if weights is None:
|
|
17
|
+
return None
|
|
18
|
+
w = np.asarray(weights, dtype=float).ravel()
|
|
19
|
+
if len(w) != n:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"weights length ({len(w)}) must match n_samples ({n})."
|
|
22
|
+
)
|
|
23
|
+
if not np.all(np.isfinite(w)) or np.any(w < 0) or np.all(w == 0):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"weights must be finite, non-negative, and not all zero."
|
|
26
|
+
)
|
|
27
|
+
return w
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _khatri_rao(marginals: List[np.ndarray]) -> np.ndarray:
|
|
31
|
+
"""Row-wise Kronecker (Khatri-Rao) product of marginal basis matrices."""
|
|
32
|
+
n = marginals[0].shape[0]
|
|
33
|
+
result = marginals[0]
|
|
34
|
+
for B_next in marginals[1:]:
|
|
35
|
+
result = (result[:, :, None] * B_next[:, None, :]).reshape(n, -1)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _kron_sum(penalties: List[np.ndarray]) -> np.ndarray:
|
|
40
|
+
"""Kronecker sum: sum_i I_0 kron ... kron P_i kron ... kron I_{d-1}."""
|
|
41
|
+
sizes = [p.shape[0] for p in penalties]
|
|
42
|
+
total = int(np.prod(sizes))
|
|
43
|
+
result = np.zeros((total, total))
|
|
44
|
+
for i, P_i in enumerate(penalties):
|
|
45
|
+
I_pre = np.eye(int(np.prod(sizes[:i])))
|
|
46
|
+
I_post = np.eye(int(np.prod(sizes[i + 1 :])))
|
|
47
|
+
result += np.kron(np.kron(I_pre, P_i), I_post)
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _GAMBase:
|
|
52
|
+
"""Shared lifecycle, prediction, interval, criteria, and plotting methods."""
|
|
53
|
+
|
|
54
|
+
_scale_is_var: bool = False
|
|
55
|
+
formula: str = ""
|
|
56
|
+
fit_intercept: bool = True
|
|
57
|
+
_terms: List[Any] = []
|
|
58
|
+
_coef_slices: List[Tuple[int, int]] = []
|
|
59
|
+
coef_: Optional[np.ndarray] = None
|
|
60
|
+
statistics_: Optional[Dict[str, Any]] = None
|
|
61
|
+
_design_matrix_: Optional[np.ndarray] = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_fitted(self) -> bool:
|
|
65
|
+
return self.coef_ is not None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def n_terms(self) -> int:
|
|
69
|
+
return len(self._terms)
|
|
70
|
+
|
|
71
|
+
def _check_fitted(self) -> None:
|
|
72
|
+
if not self.is_fitted:
|
|
73
|
+
raise RuntimeError("Model not fitted. Call fit() or gridsearch() first.")
|
|
74
|
+
|
|
75
|
+
_check_is_fitted = _check_fitted
|
|
76
|
+
|
|
77
|
+
def _compile(self, x: np.ndarray) -> None:
|
|
78
|
+
for term in self._terms:
|
|
79
|
+
term.compile(x)
|
|
80
|
+
|
|
81
|
+
def _compute_coef_slices(self) -> None:
|
|
82
|
+
slices = []
|
|
83
|
+
offset = 0
|
|
84
|
+
for t in self._terms:
|
|
85
|
+
slices.append((offset, offset + t.n_coefs))
|
|
86
|
+
offset += t.n_coefs
|
|
87
|
+
self._coef_slices = slices
|
|
88
|
+
|
|
89
|
+
def _build_model_matrix(self, x: np.ndarray) -> np.ndarray:
|
|
90
|
+
blocks = [t.build_columns(x) for t in self._terms]
|
|
91
|
+
if self.fit_intercept:
|
|
92
|
+
blocks.append(np.ones((len(x), 1), dtype=float))
|
|
93
|
+
return np.hstack(blocks)
|
|
94
|
+
|
|
95
|
+
def predict(self, x: ArrayLike) -> np.ndarray:
|
|
96
|
+
"""Predict target values for feature data x."""
|
|
97
|
+
self._check_fitted()
|
|
98
|
+
x_arr = np.asarray(x, dtype=float)
|
|
99
|
+
if x_arr.ndim == 1:
|
|
100
|
+
x_arr = x_arr.reshape(-1, 1)
|
|
101
|
+
return self._build_model_matrix(x_arr) @ self.coef_
|
|
102
|
+
|
|
103
|
+
def partial_dependence(self, term_idx: int, x: ArrayLike) -> np.ndarray:
|
|
104
|
+
"""Evaluate the partial contribution of term term_idx."""
|
|
105
|
+
self._check_fitted()
|
|
106
|
+
if term_idx < 0 or term_idx >= len(self._terms):
|
|
107
|
+
raise IndexError(
|
|
108
|
+
f"term_idx {term_idx} out of range (0..{len(self._terms) - 1})."
|
|
109
|
+
)
|
|
110
|
+
x_arr = np.asarray(x, dtype=float)
|
|
111
|
+
if x_arr.ndim == 1:
|
|
112
|
+
x_arr = x_arr.reshape(-1, 1)
|
|
113
|
+
B_term = self._terms[term_idx].build_columns(x_arr)
|
|
114
|
+
start, end = self._coef_slices[term_idx]
|
|
115
|
+
return B_term @ self.coef_[start:end]
|
|
116
|
+
|
|
117
|
+
def confidence_intervals(self, x: ArrayLike, width: float = 0.95) -> np.ndarray:
|
|
118
|
+
"""Compute confidence intervals for the linear predictor."""
|
|
119
|
+
return self._compute_intervals(x, width, prediction=False)
|
|
120
|
+
|
|
121
|
+
def prediction_intervals(self, x: ArrayLike, width: float = 0.95) -> np.ndarray:
|
|
122
|
+
"""Compute prediction intervals for new observations."""
|
|
123
|
+
return self._compute_intervals(x, width, prediction=True)
|
|
124
|
+
|
|
125
|
+
def _compute_intervals(
|
|
126
|
+
self, x: ArrayLike, width: float, prediction: bool
|
|
127
|
+
) -> np.ndarray:
|
|
128
|
+
self._check_fitted()
|
|
129
|
+
x_arr = np.asarray(x, dtype=float)
|
|
130
|
+
if x_arr.ndim == 1:
|
|
131
|
+
x_arr = x_arr.reshape(-1, 1)
|
|
132
|
+
X = self._build_model_matrix(x_arr)
|
|
133
|
+
lp = X @ self.coef_
|
|
134
|
+
|
|
135
|
+
cov = self.statistics_["cov"]
|
|
136
|
+
var = np.sum((X @ cov) * X, axis=1)
|
|
137
|
+
if prediction:
|
|
138
|
+
s = self.statistics_["scale"]
|
|
139
|
+
var = var + (s if self._scale_is_var else s**2)
|
|
140
|
+
|
|
141
|
+
alpha = (1.0 - width) / 2.0
|
|
142
|
+
df = max(self.statistics_["n_samples"] - self.statistics_["edof"], 1.0)
|
|
143
|
+
q_lo = stats.t.ppf(alpha, df=df)
|
|
144
|
+
q_hi = stats.t.ppf(1.0 - alpha, df=df)
|
|
145
|
+
|
|
146
|
+
se = np.sqrt(np.maximum(var, 1e-14 if self._scale_is_var else 0.0))
|
|
147
|
+
return np.column_stack([lp + q_lo * se, lp + q_hi * se])
|
|
148
|
+
|
|
149
|
+
def loglikelihood(self) -> float:
|
|
150
|
+
"""Gaussian log-likelihood of the fitted model."""
|
|
151
|
+
self._check_fitted()
|
|
152
|
+
n = self.statistics_["n_samples"]
|
|
153
|
+
rss = self.statistics_["rss"]
|
|
154
|
+
s = self.statistics_["scale"]
|
|
155
|
+
s2 = s if self._scale_is_var else s**2
|
|
156
|
+
return float(-0.5 * n * np.log(2.0 * np.pi * s2) - 0.5 * rss / s2)
|
|
157
|
+
|
|
158
|
+
def aic(self) -> float:
|
|
159
|
+
"""Akaike Information Criterion (AIC)."""
|
|
160
|
+
self._check_fitted()
|
|
161
|
+
return float(-2.0 * self.loglikelihood() + 2.0 * (self.statistics_["edof"] + 1))
|
|
162
|
+
|
|
163
|
+
def bic(self) -> float:
|
|
164
|
+
"""Bayesian Information Criterion (BIC)."""
|
|
165
|
+
self._check_fitted()
|
|
166
|
+
n = self.statistics_["n_samples"]
|
|
167
|
+
return float(
|
|
168
|
+
-2.0 * self.loglikelihood() + np.log(n) * (self.statistics_["edof"] + 1)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def deviance_explained(self) -> float:
|
|
172
|
+
"""Fraction of null deviance explained (pseudo R-squared)."""
|
|
173
|
+
self._check_fitted()
|
|
174
|
+
null_dev = self.statistics_.get("null_deviance")
|
|
175
|
+
if not null_dev:
|
|
176
|
+
return 0.0
|
|
177
|
+
return float(max(0.0, 1.0 - self.statistics_["rss"] / null_dev))
|
|
178
|
+
|
|
179
|
+
def _diagnostics_title(self) -> str:
|
|
180
|
+
return f"{type(self).__name__} Diagnostics — {self.formula}"
|
|
181
|
+
|
|
182
|
+
def _overall_plot_title(self) -> str:
|
|
183
|
+
s = self.statistics_
|
|
184
|
+
if self._scale_is_var:
|
|
185
|
+
return f"GAM Fit (Score={s['score']:.3f}, EDF={s['edof']:.1f}, n={s['n_samples']})"
|
|
186
|
+
constrained = [
|
|
187
|
+
f"s({t.feature})={t.constraint}"
|
|
188
|
+
for t in self._terms
|
|
189
|
+
if getattr(t, "constraint", None)
|
|
190
|
+
]
|
|
191
|
+
c_info = f" constraints: {', '.join(constrained)}" if constrained else ""
|
|
192
|
+
return f"GAM Fit (GCV={s['GCV']:.3f}, EDF={s['edof']:.1f}, n={s['n_samples']}){c_info}"
|
|
193
|
+
|
|
194
|
+
def _plot_gamcore_decomposition(
|
|
195
|
+
self,
|
|
196
|
+
x: ArrayLike,
|
|
197
|
+
y: ArrayLike,
|
|
198
|
+
n_grid: int = 200,
|
|
199
|
+
extrap_frac: float = 0.0,
|
|
200
|
+
figsize: Optional[Tuple[int, int]] = None,
|
|
201
|
+
feature_names: Optional[List[str]] = None,
|
|
202
|
+
) -> Tuple[Any, np.ndarray]:
|
|
203
|
+
import matplotlib.pyplot as plt
|
|
204
|
+
|
|
205
|
+
self._check_fitted()
|
|
206
|
+
x_arr = np.asarray(x, dtype=float)
|
|
207
|
+
y_arr = np.asarray(y, dtype=float).ravel()
|
|
208
|
+
if x_arr.ndim == 1:
|
|
209
|
+
x_arr = x_arr.reshape(-1, 1)
|
|
210
|
+
|
|
211
|
+
def _fname(idx: int) -> str:
|
|
212
|
+
if feature_names is not None and idx < len(feature_names):
|
|
213
|
+
return feature_names[idx]
|
|
214
|
+
return f"x[{idx}]"
|
|
215
|
+
|
|
216
|
+
p_predictors = x_arr.shape[1]
|
|
217
|
+
n_terms = len(self._terms)
|
|
218
|
+
n_term_cols = min(3, n_terms)
|
|
219
|
+
n_term_rows = int(np.ceil(n_terms / n_term_cols)) if n_terms > 0 else 0
|
|
220
|
+
n_rows = 1 + n_term_rows
|
|
221
|
+
n_cols = max(n_term_cols, 1)
|
|
222
|
+
|
|
223
|
+
fig = plt.figure(figsize=figsize or (n_cols * 5, n_rows * 4))
|
|
224
|
+
gs = fig.add_gridspec(n_rows, n_cols, hspace=0.4, wspace=0.35)
|
|
225
|
+
ax_overall = fig.add_subplot(gs[0, :])
|
|
226
|
+
|
|
227
|
+
feat_mins = np.min(x_arr, axis=0)
|
|
228
|
+
feat_maxs = np.max(x_arr, axis=0)
|
|
229
|
+
medians = np.median(x_arr, axis=0)
|
|
230
|
+
|
|
231
|
+
feat_grids = []
|
|
232
|
+
for j in range(p_predictors):
|
|
233
|
+
rng = (feat_maxs[j] - feat_mins[j]) or 1.0
|
|
234
|
+
feat_grids.append(
|
|
235
|
+
np.linspace(
|
|
236
|
+
feat_mins[j] - extrap_frac * rng,
|
|
237
|
+
feat_maxs[j] + extrap_frac * rng,
|
|
238
|
+
n_grid,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
x_overall = np.tile(medians, (n_grid, 1))
|
|
243
|
+
x_overall[:, 0] = feat_grids[0]
|
|
244
|
+
y_overall = self.predict(x_overall)
|
|
245
|
+
sort_idx = np.argsort(x_overall[:, 0])
|
|
246
|
+
ax_overall.plot(
|
|
247
|
+
x_overall[sort_idx, 0], y_overall[sort_idx], "C0-", lw=2, label="GAM fit"
|
|
248
|
+
)
|
|
249
|
+
ax_overall.scatter(
|
|
250
|
+
x_arr[:, 0], y_arr, alpha=0.4, color="gray", s=10, label="Data"
|
|
251
|
+
)
|
|
252
|
+
if extrap_frac > 0:
|
|
253
|
+
ax_overall.axvspan(feat_grids[0][0], feat_mins[0], alpha=0.06, color="red")
|
|
254
|
+
ax_overall.axvspan(
|
|
255
|
+
feat_maxs[0],
|
|
256
|
+
feat_grids[0][-1],
|
|
257
|
+
alpha=0.06,
|
|
258
|
+
color="red",
|
|
259
|
+
label="Extrapolated",
|
|
260
|
+
)
|
|
261
|
+
ax_overall.set(
|
|
262
|
+
xlabel=_fname(0) if p_predictors > 1 else "x",
|
|
263
|
+
ylabel="y",
|
|
264
|
+
title=self._overall_plot_title(),
|
|
265
|
+
)
|
|
266
|
+
ax_overall.legend(fontsize=7, loc="best")
|
|
267
|
+
|
|
268
|
+
for i, term in enumerate(self._terms):
|
|
269
|
+
ax = fig.add_subplot(gs[1 + i // n_term_cols, i % n_term_cols])
|
|
270
|
+
tname = type(term).__name__
|
|
271
|
+
|
|
272
|
+
if "SplineTerm" in tname:
|
|
273
|
+
x_sweep = np.tile(medians, (n_grid, 1))
|
|
274
|
+
fv = feat_grids[term.feature]
|
|
275
|
+
x_sweep[:, term.feature] = fv
|
|
276
|
+
pdep = self.partial_dependence(i, x_sweep)
|
|
277
|
+
cons = getattr(term, "constraint", None)
|
|
278
|
+
label = f"s({term.feature})" + (f" [{cons}]" if cons else "")
|
|
279
|
+
ax.plot(fv, pdep, "C1-", lw=2, label=label)
|
|
280
|
+
if extrap_frac > 0:
|
|
281
|
+
ax.axvspan(fv[0], feat_mins[term.feature], alpha=0.06, color="red")
|
|
282
|
+
ax.axvspan(
|
|
283
|
+
feat_maxs[term.feature], fv[-1], alpha=0.06, color="red"
|
|
284
|
+
)
|
|
285
|
+
ax.axhline(0, color="gray", ls="--", lw=0.5)
|
|
286
|
+
if hasattr(term, "n_splines"):
|
|
287
|
+
title = (
|
|
288
|
+
f"s({term.feature}) K={term.n_splines}, lam={term.lam:.3f}"
|
|
289
|
+
+ (f" [{cons}]" if cons else "")
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
sp_val = term.sp[0] if term.sp else 0.0
|
|
293
|
+
title = f"s({term.feature}) k={term.k}, sp={sp_val:.3g}"
|
|
294
|
+
ax.set(xlabel=_fname(term.feature), ylabel="Contribution", title=title)
|
|
295
|
+
ax.legend(fontsize=7)
|
|
296
|
+
|
|
297
|
+
elif "TensorTerm" in tname:
|
|
298
|
+
if term.d == 2:
|
|
299
|
+
ng2d = min(n_grid, 60 if self._scale_is_var else 75)
|
|
300
|
+
f0_idx, f1_idx = term.features[0], term.features[1]
|
|
301
|
+
g0 = np.linspace(
|
|
302
|
+
feat_grids[f0_idx][0], feat_grids[f0_idx][-1], ng2d
|
|
303
|
+
)
|
|
304
|
+
g1 = np.linspace(
|
|
305
|
+
feat_grids[f1_idx][0], feat_grids[f1_idx][-1], ng2d
|
|
306
|
+
)
|
|
307
|
+
f0, f1 = np.meshgrid(g0, g1, indexing="ij")
|
|
308
|
+
x_2d = np.tile(medians, (ng2d * ng2d, 1))
|
|
309
|
+
x_2d[:, f0_idx] = f0.ravel()
|
|
310
|
+
x_2d[:, f1_idx] = f1.ravel()
|
|
311
|
+
pdep_2d = self.partial_dependence(i, x_2d).reshape(ng2d, ng2d)
|
|
312
|
+
im = ax.pcolormesh(f0, f1, pdep_2d, shading="auto", cmap="RdBu_r")
|
|
313
|
+
plt.colorbar(im, ax=ax, label="Contribution")
|
|
314
|
+
ax.set(xlabel=_fname(f0_idx), ylabel=_fname(f1_idx))
|
|
315
|
+
else:
|
|
316
|
+
x_sweep = np.tile(medians, (n_grid, 1))
|
|
317
|
+
fv = feat_grids[term.features[0]]
|
|
318
|
+
x_sweep[:, term.features[0]] = fv
|
|
319
|
+
ax.plot(fv, self.partial_dependence(i, x_sweep), "C2-", lw=1.5)
|
|
320
|
+
ax.set_xlabel(f"{_fname(term.features[0])} (projection)")
|
|
321
|
+
feats_str = ",".join(str(f) for f in term.features)
|
|
322
|
+
if hasattr(term, "n_splines_per"):
|
|
323
|
+
ks_str = ",".join(str(k) for k in term.n_splines_per)
|
|
324
|
+
ax.set_title(
|
|
325
|
+
f"te({feats_str}) K=({ks_str}) {term.n_coefs} coefs"
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
ax.set_title(f"te({feats_str})")
|
|
329
|
+
|
|
330
|
+
elif "FactorTerm" in tname:
|
|
331
|
+
unique_lvls = np.unique(np.asarray(x_arr[:, term.feature]).ravel())
|
|
332
|
+
contribs = []
|
|
333
|
+
for lvl in unique_lvls:
|
|
334
|
+
x_lvl = medians.copy().reshape(1, -1)
|
|
335
|
+
x_lvl[0, term.feature] = lvl
|
|
336
|
+
contribs.append(self.partial_dependence(i, x_lvl)[0])
|
|
337
|
+
colors = ["C2" if v >= 0 else "C3" for v in contribs]
|
|
338
|
+
ax.bar(
|
|
339
|
+
range(len(contribs)),
|
|
340
|
+
contribs,
|
|
341
|
+
color=colors,
|
|
342
|
+
edgecolor="k",
|
|
343
|
+
lw=0.5,
|
|
344
|
+
)
|
|
345
|
+
ax.axhline(0, color="gray", ls="-", lw=0.5)
|
|
346
|
+
ax.set_xticks(range(len(contribs)))
|
|
347
|
+
ax.set_xticklabels(unique_lvls, fontsize=8)
|
|
348
|
+
title = (
|
|
349
|
+
f"f({term.feature})"
|
|
350
|
+
if self._scale_is_var
|
|
351
|
+
else f"f({term.feature}) lam={term.lam:.3f}"
|
|
352
|
+
)
|
|
353
|
+
ax.set(ylabel="Contribution", title=title)
|
|
354
|
+
|
|
355
|
+
elif "LinearTerm" in tname:
|
|
356
|
+
x_sweep = np.tile(medians, (n_grid, 1))
|
|
357
|
+
fv = feat_grids[term.feature]
|
|
358
|
+
x_sweep[:, term.feature] = fv
|
|
359
|
+
beta = self.coef_[self._coef_slices[i][0]]
|
|
360
|
+
ax.plot(fv, self.partial_dependence(i, x_sweep), "C4-", lw=2)
|
|
361
|
+
if extrap_frac > 0:
|
|
362
|
+
ax.axvspan(fv[0], feat_mins[term.feature], alpha=0.06, color="red")
|
|
363
|
+
ax.axvspan(
|
|
364
|
+
feat_maxs[term.feature], fv[-1], alpha=0.06, color="red"
|
|
365
|
+
)
|
|
366
|
+
ax.axhline(0, color="gray", ls="--", lw=0.5)
|
|
367
|
+
ax.set(
|
|
368
|
+
xlabel=_fname(term.feature),
|
|
369
|
+
ylabel="Contribution",
|
|
370
|
+
title=f"l({term.feature}) beta={beta:.4f}",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
fig.suptitle(
|
|
374
|
+
f"GAMCore Decomposition — {self.formula}",
|
|
375
|
+
fontsize=12,
|
|
376
|
+
fontweight="bold",
|
|
377
|
+
)
|
|
378
|
+
return fig, np.array(fig.axes)
|
|
379
|
+
|
|
380
|
+
def _plot_lingam_decomposition(
|
|
381
|
+
self,
|
|
382
|
+
x: ArrayLike,
|
|
383
|
+
y: ArrayLike,
|
|
384
|
+
k_smooth: int,
|
|
385
|
+
prefix: str,
|
|
386
|
+
basis_title: str,
|
|
387
|
+
fit_title: str,
|
|
388
|
+
suptitle: str,
|
|
389
|
+
knots: Optional[np.ndarray] = None,
|
|
390
|
+
show_weighted: bool = False,
|
|
391
|
+
n_grid: int = 300,
|
|
392
|
+
figsize: Tuple[int, int] = (16, 5),
|
|
393
|
+
) -> Tuple[Any, np.ndarray]:
|
|
394
|
+
import matplotlib.pyplot as plt
|
|
395
|
+
|
|
396
|
+
self._check_fitted()
|
|
397
|
+
x_arr = np.asarray(x, dtype=float).ravel()
|
|
398
|
+
y_arr = np.asarray(y, dtype=float).ravel()
|
|
399
|
+
|
|
400
|
+
x_grid = np.linspace(np.min(x_arr), np.max(x_arr), n_grid)
|
|
401
|
+
X_grid = self._build_model_matrix(x_grid.reshape(-1, 1))
|
|
402
|
+
B_grid = X_grid[:, :k_smooth]
|
|
403
|
+
y_pred = X_grid @ self.coef_
|
|
404
|
+
|
|
405
|
+
cmap = plt.get_cmap(
|
|
406
|
+
"tab20" if k_smooth <= 20 else "tab10" if k_smooth <= 10 else "hsv"
|
|
407
|
+
)
|
|
408
|
+
colors = [
|
|
409
|
+
cmap(i % 20 if k_smooth <= 20 else i / k_smooth)
|
|
410
|
+
for i in range(k_smooth)
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
fig, axes = plt.subplots(1, 3, figsize=figsize)
|
|
414
|
+
|
|
415
|
+
ax1 = axes[0]
|
|
416
|
+
for j in range(k_smooth):
|
|
417
|
+
ax1.plot(
|
|
418
|
+
x_grid,
|
|
419
|
+
B_grid[:, j],
|
|
420
|
+
color=colors[j],
|
|
421
|
+
ls="-",
|
|
422
|
+
alpha=0.8,
|
|
423
|
+
label=f"{prefix}_{j}",
|
|
424
|
+
)
|
|
425
|
+
if knots is not None and show_weighted:
|
|
426
|
+
for i_knot, knot in enumerate(knots):
|
|
427
|
+
ax1.axvline(
|
|
428
|
+
knot,
|
|
429
|
+
color="gray",
|
|
430
|
+
ls="--",
|
|
431
|
+
lw=1,
|
|
432
|
+
label="Knots" if i_knot == 0 else None,
|
|
433
|
+
)
|
|
434
|
+
ax1.minorticks_on()
|
|
435
|
+
ax1.set(xlabel="x", ylabel="B_j(x)", title=basis_title)
|
|
436
|
+
if show_weighted or k_smooth <= 12:
|
|
437
|
+
ax1.legend(fontsize=6 if show_weighted else 7, loc="upper right")
|
|
438
|
+
|
|
439
|
+
ax2 = axes[1]
|
|
440
|
+
vals = np.append(self.coef_[:k_smooth], self.coef_[-1])
|
|
441
|
+
labels = [f"{prefix}_{j}" for j in range(k_smooth)] + ["intercept"]
|
|
442
|
+
ax2.bar(range(len(vals)), vals, color=colors + ["dimgray"])
|
|
443
|
+
ax2.set_xticks(range(len(vals)))
|
|
444
|
+
ax2.set_xticklabels(labels, rotation=45, ha="right", fontsize=7)
|
|
445
|
+
ax2.axhline(0, color="k")
|
|
446
|
+
ax2.set(ylabel="Coefficient value", title="Estimated Coefficients")
|
|
447
|
+
|
|
448
|
+
ax3 = axes[2]
|
|
449
|
+
ax3.scatter(x_arr, y_arr, alpha=0.5, color="gray", s=12, label="Data")
|
|
450
|
+
if show_weighted:
|
|
451
|
+
for j in range(k_smooth):
|
|
452
|
+
ax3.plot(
|
|
453
|
+
x_grid,
|
|
454
|
+
self.coef_[j] * B_grid[:, j] + self.coef_[-1],
|
|
455
|
+
color=colors[j],
|
|
456
|
+
lw=1,
|
|
457
|
+
ls="-",
|
|
458
|
+
)
|
|
459
|
+
ax3.plot(x_grid, y_pred, "k-", label="Fit (sum)")
|
|
460
|
+
if knots is not None:
|
|
461
|
+
for knot in knots:
|
|
462
|
+
ax3.axvline(knot, color="gray", ls="--", lw=1)
|
|
463
|
+
ax3.minorticks_on()
|
|
464
|
+
ax3.legend(fontsize=6, loc="upper left")
|
|
465
|
+
else:
|
|
466
|
+
ax3.plot(x_grid, y_pred, "k-", lw=2, label="GAM fit")
|
|
467
|
+
ax3.legend(fontsize=7, loc="upper left")
|
|
468
|
+
ax3.set(xlabel="x", ylabel="y", title=fit_title)
|
|
469
|
+
|
|
470
|
+
fig.suptitle(suptitle)
|
|
471
|
+
return fig, axes
|
|
472
|
+
|
|
473
|
+
def plot_diagnostics(
|
|
474
|
+
self,
|
|
475
|
+
x: ArrayLike,
|
|
476
|
+
y: ArrayLike,
|
|
477
|
+
figsize: Optional[Tuple[int, int]] = None,
|
|
478
|
+
) -> Tuple[Any, np.ndarray]:
|
|
479
|
+
"""Diagnostic plots for model validation."""
|
|
480
|
+
import matplotlib.pyplot as plt
|
|
481
|
+
|
|
482
|
+
self._check_fitted()
|
|
483
|
+
x_arr = np.asarray(x, dtype=float)
|
|
484
|
+
y_arr = np.asarray(y, dtype=float).ravel()
|
|
485
|
+
if x_arr.ndim == 1:
|
|
486
|
+
x_arr = x_arr.reshape(-1, 1)
|
|
487
|
+
|
|
488
|
+
y_fit = self.predict(x_arr)
|
|
489
|
+
residuals = y_arr - y_fit
|
|
490
|
+
r2 = self.deviance_explained()
|
|
491
|
+
|
|
492
|
+
fig, axes = plt.subplots(2, 2, figsize=figsize or (10, 9))
|
|
493
|
+
axes = axes.ravel()
|
|
494
|
+
|
|
495
|
+
ax = axes[0]
|
|
496
|
+
ax.scatter(y_fit, y_arr, alpha=0.5, edgecolors="k", s=20)
|
|
497
|
+
lims = [min(y_arr.min(), y_fit.min()), max(y_arr.max(), y_fit.max())]
|
|
498
|
+
ax.plot(lims, lims, "r--", lw=1.5, label="1:1")
|
|
499
|
+
ax.set(
|
|
500
|
+
xlabel="Fitted values",
|
|
501
|
+
ylabel="Observed values",
|
|
502
|
+
title=f"Observed vs Fitted ($R^2$={r2:.3f})",
|
|
503
|
+
)
|
|
504
|
+
ax.legend()
|
|
505
|
+
|
|
506
|
+
ax = axes[1]
|
|
507
|
+
ax.scatter(y_fit, residuals, alpha=0.5, edgecolors="k", s=20)
|
|
508
|
+
ax.axhline(0, color="r", linestyle="--", lw=1.5)
|
|
509
|
+
ax.set(xlabel="Fitted values", ylabel="Residuals", title="Residuals vs Fitted")
|
|
510
|
+
|
|
511
|
+
ax = axes[2]
|
|
512
|
+
stats.probplot(residuals, dist="norm", plot=ax)
|
|
513
|
+
ax.set_title("Normal Q-Q")
|
|
514
|
+
ax.get_lines()[0].set_markerfacecolor("steelblue")
|
|
515
|
+
ax.get_lines()[0].set_markeredgecolor("k")
|
|
516
|
+
ax.get_lines()[0].set_alpha(0.6)
|
|
517
|
+
ax.get_lines()[1].set_color("r")
|
|
518
|
+
|
|
519
|
+
ax = axes[3]
|
|
520
|
+
ax.hist(
|
|
521
|
+
residuals,
|
|
522
|
+
bins="auto",
|
|
523
|
+
density=True,
|
|
524
|
+
color="steelblue",
|
|
525
|
+
edgecolor="k",
|
|
526
|
+
alpha=0.7,
|
|
527
|
+
)
|
|
528
|
+
sigma = np.std(residuals, ddof=1)
|
|
529
|
+
x_norm = np.linspace(residuals.min(), residuals.max(), 200)
|
|
530
|
+
ax.plot(
|
|
531
|
+
x_norm,
|
|
532
|
+
stats.norm.pdf(x_norm, 0.0, sigma),
|
|
533
|
+
"r-",
|
|
534
|
+
lw=2,
|
|
535
|
+
label="Normal approx.",
|
|
536
|
+
)
|
|
537
|
+
ax.set(xlabel="Residuals", ylabel="Density", title="Residual Distribution")
|
|
538
|
+
ax.legend()
|
|
539
|
+
|
|
540
|
+
fig.suptitle(self._diagnostics_title(), fontsize=12, fontweight="bold")
|
|
541
|
+
fig.tight_layout(rect=[0, 0.03, 1, 0.97])
|
|
542
|
+
return fig, axes
|
magli/_formula.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Formula string parser for GAM terms."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _eval_ast(node: ast.AST) -> Any:
|
|
8
|
+
return node.id if isinstance(node, ast.Name) else ast.literal_eval(node)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _flatten_plus(node: ast.AST) -> List[ast.AST]:
|
|
12
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
13
|
+
return _flatten_plus(node.left) + _flatten_plus(node.right)
|
|
14
|
+
return [node]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_formula(formula: str) -> List[Dict[str, Any]]:
|
|
18
|
+
"""Parse a GAM formula string into a list of term configuration dicts."""
|
|
19
|
+
if not formula or not formula.strip():
|
|
20
|
+
raise ValueError("Formula string is empty.")
|
|
21
|
+
try:
|
|
22
|
+
tree = ast.parse(formula.strip(), mode="eval").body
|
|
23
|
+
except SyntaxError as exc:
|
|
24
|
+
raise ValueError(f"Cannot parse formula '{formula}'.") from exc
|
|
25
|
+
|
|
26
|
+
result: List[Dict[str, Any]] = []
|
|
27
|
+
for node in _flatten_plus(tree):
|
|
28
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)):
|
|
29
|
+
raise ValueError(f"Cannot parse term in '{formula}'.")
|
|
30
|
+
func_name = node.func.id.lower()
|
|
31
|
+
if func_name not in ("s", "te", "f", "l"):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Unknown term type '{func_name}'. Expected s, te, f, or l."
|
|
34
|
+
)
|
|
35
|
+
positional = [_eval_ast(a) for a in node.args]
|
|
36
|
+
kwargs = {kw.arg: _eval_ast(kw.value) for kw in node.keywords if kw.arg}
|
|
37
|
+
min_feats = 2 if func_name == "te" else 1
|
|
38
|
+
if len(positional) < min_feats:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"'{func_name}()' requires at least {min_feats} feature index(es)."
|
|
41
|
+
)
|
|
42
|
+
result.append(
|
|
43
|
+
{
|
|
44
|
+
"type": func_name,
|
|
45
|
+
"features": positional[:1] if min_feats == 1 else list(positional),
|
|
46
|
+
"kwargs": kwargs,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
return result
|
magli/eilers/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Eilers & Marx (1996) P-spline GAM backend for magli."""
|
|
2
|
+
|
|
3
|
+
from ._gam import GAMCore
|
|
4
|
+
from ._lingam import LinGAM
|
|
5
|
+
from ._solver import solve_pirls
|
|
6
|
+
from ._terms import (
|
|
7
|
+
_FactorTerm,
|
|
8
|
+
_LinearTerm,
|
|
9
|
+
_SplineTerm,
|
|
10
|
+
_TensorTerm,
|
|
11
|
+
_Term,
|
|
12
|
+
instantiate_terms,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
GAM = LinGAM
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"GAM",
|
|
19
|
+
"LinGAM",
|
|
20
|
+
"GAMCore",
|
|
21
|
+
"solve_pirls",
|
|
22
|
+
"instantiate_terms",
|
|
23
|
+
"_Term",
|
|
24
|
+
"_SplineTerm",
|
|
25
|
+
"_TensorTerm",
|
|
26
|
+
"_FactorTerm",
|
|
27
|
+
"_LinearTerm",
|
|
28
|
+
]
|
|
29
|
+
|