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 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
@@ -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
+