phenoforge 0.1.2__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.
phenoforge/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """phenoforge: phenomenological model-family bank + ensemble calibration engine.
2
+
3
+ The package implements the BAPE methodology (Bootstrap-Aggregated Phenomenological
4
+ Ensembles): instead of selecting ONE phenomenological model for a process dataset,
5
+ fit MANY realizations (model families x parameter multistarts x bootstrap resamples)
6
+ from a curated family bank and aggregate them into a calibrated ensemble with
7
+ structural inclusion probabilities.
8
+
9
+ Nearest prior art, cited and differentiated (see the Fragua research dossiers):
10
+ - Fasel, Kutz, Brunton, Brunton 2022, Ensemble-SINDy, Proc. R. Soc. A 478:20210904,
11
+ DOI 10.1098/rspa.2021.0904 (bagging over generic term libraries).
12
+ - Pinto, de Azevedo, Oliveira, von Stosch 2019, Bioprocess Biosyst. Eng. 42:1853-1865,
13
+ DOI 10.1007/s00449-019-02181-y (bootstrap-aggregated hybrid models, one family).
14
+ - Duan, Ajami, Gao, Sorooshian 2007, Adv. Water Resour. 30:1371-1386,
15
+ DOI 10.1016/j.advwatres.2006.11.014 (BMA across model structures, hydrology).
16
+ - Beven, Binley 1992, Hydrol. Process. 6:279-298 (GLUE); Beven 2006,
17
+ J. Hydrol. 320:18-36, DOI 10.1016/j.jhydrol.2005.07.007 (equifinality).
18
+ - Yao, Vehtari, Simpson, Gelman 2018, Bayesian Anal. 13:917-1007,
19
+ DOI 10.1214/17-BA1091 (stacking in the M-open setting).
20
+
21
+ The core is pure numpy/scipy (Pyodide-safe by design).
22
+ """
23
+
24
+ __version__ = "0.01.002"
25
+
26
+ from phenoforge.families.base import DataKind, FitResult, ModelFamily, Param
27
+ from phenoforge.families.registry import get_family, list_families
28
+ from phenoforge.fit.nls import fit_family
29
+ from phenoforge.router import route
30
+
31
+ __all__ = [
32
+ "DataKind",
33
+ "FitResult",
34
+ "ModelFamily",
35
+ "Param",
36
+ "__version__",
37
+ "fit_family",
38
+ "get_family",
39
+ "list_families",
40
+ "route",
41
+ ]
@@ -0,0 +1,19 @@
1
+ from phenoforge.ensemble.bape import BapeEnsemble, bape_fit
2
+ from phenoforge.ensemble.bootstrap import BootstrapEnsemble, bootstrap_fit
3
+ from phenoforge.ensemble.glue import GlueEnsemble, glue_fit
4
+ from phenoforge.ensemble.ic import akaike_weights, averaged_prediction, select
5
+ from phenoforge.ensemble.stacking import StackedEnsemble, stack_fit
6
+
7
+ __all__ = [
8
+ "BapeEnsemble",
9
+ "BootstrapEnsemble",
10
+ "GlueEnsemble",
11
+ "StackedEnsemble",
12
+ "akaike_weights",
13
+ "averaged_prediction",
14
+ "bape_fit",
15
+ "bootstrap_fit",
16
+ "glue_fit",
17
+ "select",
18
+ "stack_fit",
19
+ ]
@@ -0,0 +1,172 @@
1
+ """BAPE: Bootstrap-Aggregated Phenomenological Ensembles (rung 6, the novel method).
2
+
3
+ The random-forest recipe with phenomenological model families as base learners:
4
+
5
+ - Each ensemble MEMBER sees (a) a bootstrap resample of the data (the bagging axis,
6
+ Breiman 1996) and (b) a random SUBSET of the family library (the feature-subsampling
7
+ analog applied to equation structure; in E-SINDy this is "library bagging" over
8
+ candidate terms, Fasel et al. 2022, DOI 10.1098/rspa.2021.0904; here it operates
9
+ over whole curated families).
10
+ - Within its subset, the member fits every family and keeps the AICc-best fit (one
11
+ member = one fitted phenomenological model, the way one random-forest member is one
12
+ tree).
13
+ - The ensemble aggregates member predictions (mean/median/quantiles) and reads
14
+ STRUCTURE off the member population: the inclusion probability of a family is the
15
+ fraction of members that selected it (the E-SINDy inclusion-probability readout
16
+ lifted from terms to families).
17
+
18
+ Differentiation from prior art (all cited in the package docstring): E-SINDy bags
19
+ sparse regressions over generic term libraries; Pinto et al. 2019 bags ONE hybrid
20
+ structure; BMA/stacking weight given models without resampling; GLUE keeps parameter
21
+ sets of given structures. BAPE crosses the family axis with the bootstrap axis.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+
28
+ import numpy as np
29
+
30
+ from phenoforge.ensemble.bootstrap import bootstrap_indices
31
+ from phenoforge.families.base import FitResult, ModelFamily
32
+ from phenoforge.fit.nls import fit_family
33
+
34
+
35
+ @dataclass
36
+ class BapeMember:
37
+ family: ModelFamily
38
+ fit: FitResult
39
+ boot_index: int
40
+ subset_keys: tuple[str, ...]
41
+
42
+
43
+ @dataclass
44
+ class BapeEnsemble:
45
+ families: tuple[ModelFamily, ...]
46
+ members: list[BapeMember]
47
+ requested: int
48
+ meta: dict = field(default_factory=dict)
49
+
50
+ @property
51
+ def kept(self) -> int:
52
+ return len(self.members)
53
+
54
+ def member_predictions(self, x: np.ndarray) -> np.ndarray:
55
+ return np.stack([m.family.predict(x, m.fit.theta) for m in self.members])
56
+
57
+ def predict(self, x: np.ndarray, aggregate: str = "mean") -> np.ndarray:
58
+ m = self.member_predictions(x)
59
+ if aggregate == "mean":
60
+ return m.mean(axis=0)
61
+ if aggregate == "median":
62
+ return np.median(m, axis=0)
63
+ raise ValueError("aggregate must be 'mean' or 'median'")
64
+
65
+ def quantiles(
66
+ self, x: np.ndarray, qs: tuple[float, ...] = (0.05, 0.25, 0.5, 0.75, 0.95)
67
+ ) -> np.ndarray:
68
+ return np.quantile(self.member_predictions(x), qs, axis=0)
69
+
70
+ def inclusion_probabilities(self) -> dict[str, float]:
71
+ """P(family selected | member), the structural-uncertainty readout.
72
+
73
+ Normalizing by the number of members in which the family was OFFERED (it was
74
+ in the member's random subset) rather than by all members, so a family is not
75
+ penalized for having been absent from a member's menu.
76
+ """
77
+ offered: dict[str, int] = {f.key: 0 for f in self.families}
78
+ selected: dict[str, int] = {f.key: 0 for f in self.families}
79
+ for m in self.members:
80
+ for key in m.subset_keys:
81
+ offered[key] += 1
82
+ selected[m.family.key] += 1
83
+ return {
84
+ k: (selected[k] / offered[k]) if offered[k] > 0 else 0.0 for k in offered
85
+ }
86
+
87
+ def selection_shares(self) -> dict[str, float]:
88
+ """Fraction of members won by each family (sums to 1)."""
89
+ shares: dict[str, int] = {f.key: 0 for f in self.families}
90
+ for m in self.members:
91
+ shares[m.family.key] += 1
92
+ total = max(self.kept, 1)
93
+ return {k: v / total for k, v in shares.items()}
94
+
95
+
96
+ def bape_fit(
97
+ families: tuple[ModelFamily, ...],
98
+ x: np.ndarray,
99
+ y: np.ndarray,
100
+ *,
101
+ n_members: int = 200,
102
+ subset_size: int | None = None,
103
+ seed: int = 0,
104
+ block: int | None = None,
105
+ n_starts: int = 6,
106
+ criterion: str = "aicc",
107
+ ) -> BapeEnsemble:
108
+ """Fit a BAPE ensemble over the bank.
109
+
110
+ subset_size defaults to ceil(sqrt(len(families))) + 1, the random-forest
111
+ heuristic adapted to small libraries (guarantees >= 2 families per member when
112
+ the bank has >= 2).
113
+ """
114
+ x = np.asarray(x, dtype=float)
115
+ y = np.asarray(y, dtype=float)
116
+ n = y.shape[0]
117
+ n_fam = len(families)
118
+ if n_fam == 0:
119
+ raise ValueError("empty family bank")
120
+ if subset_size is None:
121
+ subset_size = min(n_fam, int(np.ceil(np.sqrt(n_fam))) + 1)
122
+ subset_size = max(1, min(subset_size, n_fam))
123
+
124
+ rng = np.random.default_rng(seed)
125
+ idx = bootstrap_indices(n, n_members, rng, block=block)
126
+
127
+ members: list[BapeMember] = []
128
+ for b in range(n_members):
129
+ xb, yb = x[idx[b]], y[idx[b]]
130
+ chosen = rng.choice(n_fam, size=subset_size, replace=False)
131
+ subset = tuple(families[int(c)] for c in chosen)
132
+ fits: list[tuple[ModelFamily, FitResult]] = []
133
+ for j, fam in enumerate(subset):
134
+ res = fit_family(
135
+ fam, xb, yb, n_starts=n_starts, seed=seed + 104729 * (b + 1) + j
136
+ )
137
+ if res.success and np.isfinite(res.rss):
138
+ fits.append((fam, res))
139
+ # coherent per-member criterion: preferred unless it is +inf for every
140
+ # candidate in this member's menu (tiny resamples), then BIC for all
141
+ crit = criterion
142
+ if fits and not any(np.isfinite(getattr(r, criterion)) for _, r in fits):
143
+ crit = "bic"
144
+ best: tuple[ModelFamily, FitResult] | None = None
145
+ best_val = float("inf")
146
+ for fam, res in fits:
147
+ val = getattr(res, crit)
148
+ if np.isfinite(val) and val < best_val:
149
+ best_val = val
150
+ best = (fam, res)
151
+ if best is not None:
152
+ members.append(
153
+ BapeMember(
154
+ family=best[0],
155
+ fit=best[1],
156
+ boot_index=b,
157
+ subset_keys=tuple(f.key for f in subset),
158
+ )
159
+ )
160
+
161
+ return BapeEnsemble(
162
+ families=families,
163
+ members=members,
164
+ requested=n_members,
165
+ meta={
166
+ "subset_size": subset_size,
167
+ "criterion": criterion,
168
+ "seed": seed,
169
+ "block": block,
170
+ "n_starts": n_starts,
171
+ },
172
+ )
@@ -0,0 +1,98 @@
1
+ """Bootstrap-aggregated fits of one family (rung 5 substrate).
2
+
3
+ Paired (case) bootstrap for independent observations and a moving-block bootstrap for
4
+ serially correlated data (plant time series). Aggregation by mean (bagging, Breiman
5
+ 1996) or median (bragging, the E-SINDy usage: Fasel et al. 2022,
6
+ DOI 10.1098/rspa.2021.0904). The single-family bootstrap generalizes the mechanism of
7
+ Pinto et al. 2019 (DOI 10.1007/s00449-019-02181-y) beyond one hybrid structure.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ import numpy as np
15
+
16
+ from phenoforge.families.base import FitResult, ModelFamily
17
+ from phenoforge.fit.nls import fit_family
18
+
19
+
20
+ def bootstrap_indices(
21
+ n: int, n_boot: int, rng: np.random.Generator, block: int | None = None
22
+ ) -> np.ndarray:
23
+ """(n_boot, n) resample index matrix; block > 1 switches to moving-block."""
24
+ if block is None or block <= 1:
25
+ return rng.integers(0, n, size=(n_boot, n))
26
+ n_blocks = int(np.ceil(n / block))
27
+ starts = rng.integers(0, max(n - block + 1, 1), size=(n_boot, n_blocks))
28
+ idx = (starts[:, :, None] + np.arange(block)[None, None, :]).reshape(n_boot, -1)
29
+ return idx[:, :n]
30
+
31
+
32
+ @dataclass
33
+ class BootstrapEnsemble:
34
+ """Fitted bootstrap fleet of ONE family."""
35
+
36
+ family: ModelFamily
37
+ fits: list[FitResult]
38
+ kept: int
39
+ requested: int
40
+ meta: dict = field(default_factory=dict)
41
+
42
+ def thetas(self) -> np.ndarray:
43
+ return np.stack([f.theta for f in self.fits])
44
+
45
+ def member_predictions(self, x: np.ndarray) -> np.ndarray:
46
+ """(n_members, n_x) member prediction matrix."""
47
+ return np.stack([self.family.predict(x, f.theta) for f in self.fits])
48
+
49
+ def predict(self, x: np.ndarray, aggregate: str = "mean") -> np.ndarray:
50
+ m = self.member_predictions(x)
51
+ if aggregate == "mean":
52
+ return m.mean(axis=0)
53
+ if aggregate == "median":
54
+ return np.median(m, axis=0)
55
+ raise ValueError("aggregate must be 'mean' (bagging) or 'median' (bragging)")
56
+
57
+ def quantiles(
58
+ self, x: np.ndarray, qs: tuple[float, ...] = (0.05, 0.25, 0.5, 0.75, 0.95)
59
+ ) -> np.ndarray:
60
+ return np.quantile(self.member_predictions(x), qs, axis=0)
61
+
62
+
63
+ def bootstrap_fit(
64
+ family: ModelFamily,
65
+ x: np.ndarray,
66
+ y: np.ndarray,
67
+ *,
68
+ n_boot: int = 200,
69
+ seed: int = 0,
70
+ block: int | None = None,
71
+ n_starts: int = 6,
72
+ ) -> BootstrapEnsemble:
73
+ """Fit `family` on n_boot bootstrap resamples of (x, y).
74
+
75
+ Members whose fit fails (non-finite RSS) are dropped and COUNTED: `kept` vs
76
+ `requested` is part of the record, never silently equalized.
77
+ """
78
+ x = np.asarray(x, dtype=float)
79
+ y = np.asarray(y, dtype=float)
80
+ n = y.shape[0]
81
+ rng = np.random.default_rng(seed)
82
+ idx = bootstrap_indices(n, n_boot, rng, block=block)
83
+
84
+ fits: list[FitResult] = []
85
+ for b in range(n_boot):
86
+ xb, yb = x[idx[b]], y[idx[b]]
87
+ res = fit_family(family, xb, yb, n_starts=n_starts, seed=seed + 7919 * (b + 1))
88
+ if res.success and np.isfinite(res.rss):
89
+ res.meta["boot_index"] = b
90
+ fits.append(res)
91
+
92
+ return BootstrapEnsemble(
93
+ family=family,
94
+ fits=fits,
95
+ kept=len(fits),
96
+ requested=n_boot,
97
+ meta={"block": block, "seed": seed},
98
+ )
@@ -0,0 +1,102 @@
1
+ """GLUE: generalized likelihood uncertainty estimation (rung 4).
2
+
3
+ Monte Carlo sampling of parameter sets inside the physical bounds, retention of the
4
+ "behavioural" sets above a likelihood threshold, and likelihood-weighted predictive
5
+ quantiles. The equifinality readout: many parameter sets (and structures) reproduce
6
+ the data comparably well, and keeping them IS the uncertainty statement.
7
+
8
+ Primary sources: Beven and Binley 1992, Hydrol. Process. 6, 279-298; Beven 2006,
9
+ J. Hydrol. 320, 18-36, DOI 10.1016/j.jhydrol.2005.07.007; Beven and Binley 2014,
10
+ Hydrol. Process. 28, 5897-5918, DOI 10.1002/hyp.10082. GLUE weights are an informal
11
+ likelihood by construction; that caveat ships with every GLUE artifact
12
+ (Stedinger et al. 2008, DOI 10.1029/2008WR006822).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from phenoforge.families.base import ModelFamily
22
+
23
+
24
+ @dataclass
25
+ class GlueEnsemble:
26
+ family: ModelFamily
27
+ thetas: np.ndarray # (n_behavioural, k)
28
+ likelihoods: np.ndarray # (n_behavioural,) normalized to sum 1
29
+ threshold: float
30
+ n_sampled: int
31
+
32
+ @property
33
+ def n_behavioural(self) -> int:
34
+ return int(self.thetas.shape[0])
35
+
36
+ def member_predictions(self, x: np.ndarray) -> np.ndarray:
37
+ return np.stack([self.family.predict(x, t) for t in self.thetas])
38
+
39
+ def predict(self, x: np.ndarray) -> np.ndarray:
40
+ """Likelihood-weighted mean prediction."""
41
+ m = self.member_predictions(x)
42
+ return np.einsum("m,mn->n", self.likelihoods, m)
43
+
44
+ def quantiles(self, x: np.ndarray, qs: tuple[float, ...] = (0.05, 0.5, 0.95)) -> np.ndarray:
45
+ """Likelihood-weighted predictive quantiles per x point."""
46
+ m = self.member_predictions(x) # (M, N)
47
+ order = np.argsort(m, axis=0)
48
+ sorted_m = np.take_along_axis(m, order, axis=0)
49
+ w = self.likelihoods[order]
50
+ cw = np.cumsum(w, axis=0)
51
+ cw /= cw[-1:, :]
52
+ out = np.empty((len(qs), m.shape[1]))
53
+ for j, q in enumerate(qs):
54
+ hit = np.argmax(cw >= q, axis=0)
55
+ out[j] = sorted_m[hit, np.arange(m.shape[1])]
56
+ return out
57
+
58
+
59
+ def nash_sutcliffe(y: np.ndarray, yhat: np.ndarray) -> float:
60
+ """Nash-Sutcliffe efficiency, the customary GLUE informal likelihood."""
61
+ y = np.asarray(y, dtype=float)
62
+ denom = float(np.sum((y - y.mean()) ** 2))
63
+ if denom == 0.0:
64
+ return float("-inf")
65
+ return 1.0 - float(np.sum((y - yhat) ** 2)) / denom
66
+
67
+
68
+ def glue_fit(
69
+ family: ModelFamily,
70
+ x: np.ndarray,
71
+ y: np.ndarray,
72
+ *,
73
+ n_samples: int = 20000,
74
+ threshold: float = 0.0,
75
+ seed: int = 0,
76
+ ) -> GlueEnsemble:
77
+ """Sample uniform-in-bounds parameter sets; keep NSE > threshold as behavioural.
78
+
79
+ Weights are shifted NSE (informal likelihood), normalized over the behavioural
80
+ set. An empty behavioural set is returned EMPTY (n_behavioural == 0), which is a
81
+ valid scientific verdict, not an error.
82
+ """
83
+ x = np.asarray(x, dtype=float)
84
+ y = np.asarray(y, dtype=float)
85
+ rng = np.random.default_rng(seed)
86
+ lo, hi = family.bounds
87
+ thetas = lo + rng.random((n_samples, family.k)) * (hi - lo)
88
+
89
+ nse = np.array([nash_sutcliffe(y, family.predict(x, t)) for t in thetas])
90
+ keep = nse > threshold
91
+ kept = thetas[keep]
92
+ scores = nse[keep] - threshold
93
+ total = float(scores.sum())
94
+ lik = scores / total if total > 0 else scores
95
+
96
+ return GlueEnsemble(
97
+ family=family,
98
+ thetas=kept,
99
+ likelihoods=lik,
100
+ threshold=threshold,
101
+ n_sampled=n_samples,
102
+ )
@@ -0,0 +1,80 @@
1
+ """Information-criterion selection and model averaging across a family bank.
2
+
3
+ Rungs 2 and 3 of the Fragua ladder:
4
+ - select(): the better current practice (AICc/BIC winner-take-all).
5
+ - akaike_weights() + averaged_prediction(): keep the WHOLE family and average with
6
+ IC weights (multimodel inference, Burnham and Anderson 2004,
7
+ DOI 10.1177/0049124104268644).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from phenoforge.families.base import FitResult, ModelFamily
15
+
16
+
17
+ def _criterion_values(results: list[FitResult], criterion: str) -> np.ndarray:
18
+ if criterion not in ("aic", "aicc", "bic"):
19
+ raise ValueError("criterion must be one of aic, aicc, bic")
20
+ return np.array([getattr(r, criterion) for r in results], dtype=float)
21
+
22
+
23
+ def choose_criterion(results: list[FitResult], preferred: str = "aicc") -> str:
24
+ """Pick ONE coherent criterion for a bank on a given dataset.
25
+
26
+ AICc is preferred, but its small-sample correction is undefined (+inf) when
27
+ n <= p + 1; on very short series that hits EVERY family. Mixing criteria
28
+ across families would be incoherent, so: use `preferred` when at least one
29
+ successful fit has a finite value, else fall back to BIC for the whole bank
30
+ (finite whenever the fit itself is finite).
31
+ """
32
+ vals = _criterion_values(results, preferred)
33
+ ok = [np.isfinite(v) and r.success for v, r in zip(vals, results, strict=True)]
34
+ if any(ok):
35
+ return preferred
36
+ return "bic"
37
+
38
+
39
+ def select(results: list[FitResult], criterion: str = "aicc") -> FitResult:
40
+ """Winner-take-all selection by an information criterion."""
41
+ vals = _criterion_values(results, criterion)
42
+ if not np.isfinite(vals).any():
43
+ raise ValueError("no finite criterion value in the bank (all fits failed)")
44
+ return results[int(np.nanargmin(np.where(np.isfinite(vals), vals, np.nan)))]
45
+
46
+
47
+ def akaike_weights(results: list[FitResult], criterion: str = "aicc") -> np.ndarray:
48
+ """Normalized exp(-delta/2) weights over the bank.
49
+
50
+ Non-finite criteria (failed or over-parameterized fits) receive weight 0; the
51
+ remainder renormalizes. This is the honest treatment: an unfittable member does
52
+ not silently disappear from the record, it carries zero weight.
53
+ """
54
+ vals = _criterion_values(results, criterion)
55
+ finite = np.isfinite(vals)
56
+ w = np.zeros_like(vals)
57
+ if finite.any():
58
+ d = vals[finite] - np.min(vals[finite])
59
+ e = np.exp(-0.5 * d)
60
+ w[finite] = e / np.sum(e)
61
+ return w
62
+
63
+
64
+ def averaged_prediction(
65
+ families: tuple[ModelFamily, ...],
66
+ results: list[FitResult],
67
+ x: np.ndarray,
68
+ criterion: str = "aicc",
69
+ ) -> tuple[np.ndarray, np.ndarray]:
70
+ """IC-weight model-averaged prediction over the bank.
71
+
72
+ Returns (y_avg, weights) with weights aligned to `families`/`results` order.
73
+ """
74
+ if len(families) != len(results):
75
+ raise ValueError("families and results disagree in length")
76
+ w = akaike_weights(results, criterion)
77
+ preds = np.stack(
78
+ [fam.predict(x, res.theta) for fam, res in zip(families, results, strict=True)]
79
+ )
80
+ return np.einsum("m,mn->n", w, preds), w
@@ -0,0 +1,96 @@
1
+ """Stacking: cross-validated convex combination of family predictions (rung 7).
2
+
3
+ Learns nonnegative weights summing to 1 that minimize the K-fold out-of-fold squared
4
+ error of the combined prediction; the super-learner recipe restricted to the convex
5
+ simplex. Primary sources: Wolpert 1992, Neural Networks 5, 241-259; van der Laan,
6
+ Polley, Hubbard 2007, Stat. Appl. Genet. Mol. Biol. 6(1):25; Yao, Vehtari, Simpson,
7
+ Gelman 2018, Bayesian Anal. 13, 917-1007, DOI 10.1214/17-BA1091 (why stacking is the
8
+ robust combination in the M-open setting every industrial family bank lives in).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+ from scipy.optimize import nnls
17
+
18
+ from phenoforge.families.base import ModelFamily
19
+ from phenoforge.fit.nls import fit_family
20
+
21
+
22
+ def kfold_indices(n: int, k: int, rng: np.random.Generator) -> list[np.ndarray]:
23
+ perm = rng.permutation(n)
24
+ return [perm[i::k] for i in range(k)]
25
+
26
+
27
+ def simplex_weights_for(oof: np.ndarray, y: np.ndarray) -> np.ndarray:
28
+ """Nonnegative least squares on the out-of-fold matrix, renormalized to sum 1.
29
+
30
+ NNLS + renormalization is the standard practical solver for simplex-constrained
31
+ stacking; exact simplex QP differs negligibly for well-scaled problems.
32
+ """
33
+ w, _ = nnls(oof, y)
34
+ s = w.sum()
35
+ if s <= 0:
36
+ # Degenerate: no member helps; fall back to uniform (honest, recorded upstream).
37
+ return np.full(oof.shape[1], 1.0 / oof.shape[1])
38
+ return w / s
39
+
40
+
41
+ @dataclass
42
+ class StackedEnsemble:
43
+ families: tuple[ModelFamily, ...]
44
+ thetas: list[np.ndarray] # full-data refit per family
45
+ weights: np.ndarray # (n_families,) convex
46
+ oof_rmse: float # out-of-fold RMSE of the stack
47
+
48
+ def predict(self, x: np.ndarray) -> np.ndarray:
49
+ preds = np.stack(
50
+ [f.predict(x, t) for f, t in zip(self.families, self.thetas, strict=True)]
51
+ )
52
+ return np.einsum("m,mn->n", self.weights, preds)
53
+
54
+
55
+ def stack_fit(
56
+ families: tuple[ModelFamily, ...],
57
+ x: np.ndarray,
58
+ y: np.ndarray,
59
+ *,
60
+ k_folds: int = 5,
61
+ seed: int = 0,
62
+ n_starts: int = 8,
63
+ ) -> StackedEnsemble:
64
+ """K-fold stacking over the bank.
65
+
66
+ Each family is fit on every training fold; its out-of-fold predictions fill one
67
+ column of the OOF matrix; weights solve the simplex-constrained least squares on
68
+ that matrix; final member parameters are full-data refits.
69
+ """
70
+ x = np.asarray(x, dtype=float)
71
+ y = np.asarray(y, dtype=float)
72
+ n = y.shape[0]
73
+ if n < 2 * k_folds:
74
+ k_folds = max(2, n // 2)
75
+ rng = np.random.default_rng(seed)
76
+ folds = kfold_indices(n, k_folds, rng)
77
+
78
+ oof = np.zeros((n, len(families)))
79
+ for j, fam in enumerate(families):
80
+ for f_idx, test_idx in enumerate(folds):
81
+ train_mask = np.ones(n, dtype=bool)
82
+ train_mask[test_idx] = False
83
+ res = fit_family(
84
+ fam, x[train_mask], y[train_mask],
85
+ n_starts=n_starts, seed=seed + 101 * j + f_idx,
86
+ )
87
+ oof[test_idx, j] = fam.predict(x[test_idx], res.theta)
88
+
89
+ w = simplex_weights_for(oof, y)
90
+ oof_rmse = float(np.sqrt(np.mean((y - oof @ w) ** 2)))
91
+
92
+ thetas = [
93
+ fit_family(fam, x, y, n_starts=n_starts, seed=seed + 977 * j).theta
94
+ for j, fam in enumerate(families)
95
+ ]
96
+ return StackedEnsemble(families=families, thetas=thetas, weights=w, oof_rmse=oof_rmse)
@@ -0,0 +1,12 @@
1
+ from phenoforge.families.base import DataKind, FitResult, ModelFamily, Param, Reference
2
+ from phenoforge.families.registry import get_family, list_families
3
+
4
+ __all__ = [
5
+ "DataKind",
6
+ "FitResult",
7
+ "ModelFamily",
8
+ "Param",
9
+ "Reference",
10
+ "get_family",
11
+ "list_families",
12
+ ]