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 +41 -0
- phenoforge/ensemble/__init__.py +19 -0
- phenoforge/ensemble/bape.py +172 -0
- phenoforge/ensemble/bootstrap.py +98 -0
- phenoforge/ensemble/glue.py +102 -0
- phenoforge/ensemble/ic.py +80 -0
- phenoforge/ensemble/stacking.py +96 -0
- phenoforge/families/__init__.py +12 -0
- phenoforge/families/base.py +162 -0
- phenoforge/families/comminution.py +122 -0
- phenoforge/families/flotation.py +273 -0
- phenoforge/families/registry.py +31 -0
- phenoforge/fit/__init__.py +3 -0
- phenoforge/fit/nls.py +105 -0
- phenoforge/metrics/__init__.py +20 -0
- phenoforge/metrics/calibration.py +66 -0
- phenoforge/metrics/point.py +23 -0
- phenoforge/metrics/structural.py +43 -0
- phenoforge/router.py +22 -0
- phenoforge-0.1.2.dist-info/METADATA +105 -0
- phenoforge-0.1.2.dist-info/RECORD +24 -0
- phenoforge-0.1.2.dist-info/WHEEL +5 -0
- phenoforge-0.1.2.dist-info/licenses/LICENSE +21 -0
- phenoforge-0.1.2.dist-info/top_level.txt +1 -0
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
|
+
]
|