phenoforge 0.1.2__tar.gz

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.
Files changed (35) hide show
  1. phenoforge-0.1.2/LICENSE +21 -0
  2. phenoforge-0.1.2/PKG-INFO +105 -0
  3. phenoforge-0.1.2/README.md +82 -0
  4. phenoforge-0.1.2/pyproject.toml +40 -0
  5. phenoforge-0.1.2/setup.cfg +4 -0
  6. phenoforge-0.1.2/src/phenoforge/__init__.py +41 -0
  7. phenoforge-0.1.2/src/phenoforge/ensemble/__init__.py +19 -0
  8. phenoforge-0.1.2/src/phenoforge/ensemble/bape.py +172 -0
  9. phenoforge-0.1.2/src/phenoforge/ensemble/bootstrap.py +98 -0
  10. phenoforge-0.1.2/src/phenoforge/ensemble/glue.py +102 -0
  11. phenoforge-0.1.2/src/phenoforge/ensemble/ic.py +80 -0
  12. phenoforge-0.1.2/src/phenoforge/ensemble/stacking.py +96 -0
  13. phenoforge-0.1.2/src/phenoforge/families/__init__.py +12 -0
  14. phenoforge-0.1.2/src/phenoforge/families/base.py +162 -0
  15. phenoforge-0.1.2/src/phenoforge/families/comminution.py +122 -0
  16. phenoforge-0.1.2/src/phenoforge/families/flotation.py +273 -0
  17. phenoforge-0.1.2/src/phenoforge/families/registry.py +31 -0
  18. phenoforge-0.1.2/src/phenoforge/fit/__init__.py +3 -0
  19. phenoforge-0.1.2/src/phenoforge/fit/nls.py +105 -0
  20. phenoforge-0.1.2/src/phenoforge/metrics/__init__.py +20 -0
  21. phenoforge-0.1.2/src/phenoforge/metrics/calibration.py +66 -0
  22. phenoforge-0.1.2/src/phenoforge/metrics/point.py +23 -0
  23. phenoforge-0.1.2/src/phenoforge/metrics/structural.py +43 -0
  24. phenoforge-0.1.2/src/phenoforge/router.py +22 -0
  25. phenoforge-0.1.2/src/phenoforge.egg-info/PKG-INFO +105 -0
  26. phenoforge-0.1.2/src/phenoforge.egg-info/SOURCES.txt +33 -0
  27. phenoforge-0.1.2/src/phenoforge.egg-info/dependency_links.txt +1 -0
  28. phenoforge-0.1.2/src/phenoforge.egg-info/requires.txt +6 -0
  29. phenoforge-0.1.2/src/phenoforge.egg-info/top_level.txt +1 -0
  30. phenoforge-0.1.2/tests/test_ensembles.py +107 -0
  31. phenoforge-0.1.2/tests/test_families.py +61 -0
  32. phenoforge-0.1.2/tests/test_fit_nls.py +54 -0
  33. phenoforge-0.1.2/tests/test_metrics.py +55 -0
  34. phenoforge-0.1.2/tests/test_router.py +22 -0
  35. phenoforge-0.1.2/tests/test_small_n_fallback.py +48 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Santibanez-Leal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: phenoforge
3
+ Version: 0.1.2
4
+ Summary: Phenomenological model-family bank and ensemble calibration engine for industrial processes (BAPE: Bootstrap-Aggregated Phenomenological Ensembles)
5
+ Author-email: Felipe Santibanez-Leal <fsantibanez@gmail.com>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/fsantibanezleal/CAOS_PhenoForge
8
+ Keywords: phenomenological-models,ensemble,model-averaging,mineral-processing,flotation,uncertainty-quantification,bootstrap,stacking
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: scipy>=1.10
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: ruff>=0.4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # phenoforge
25
+
26
+ Phenomenological model-family bank and ensemble calibration engine for industrial
27
+ processes. The reference implementation of **BAPE (Bootstrap-Aggregated
28
+ Phenomenological Ensembles)**: instead of selecting one phenomenological model for a
29
+ process dataset, fit many realizations (model families x parameter multistarts x
30
+ bootstrap resamples) from a curated family bank and aggregate them into a calibrated
31
+ ensemble with structural inclusion probabilities.
32
+
33
+ Consumed by the Fragua research product (`CAOS_RES_Fragua`); designed as a
34
+ standalone, domain-agnostic library. The core is pure numpy/scipy (Pyodide-safe).
35
+
36
+ ## What it provides
37
+
38
+ - **A family bank** (`phenoforge.families`): named, cited, bounded phenomenological
39
+ models with physical parameter ranges and declared calibration-data contracts.
40
+ Shipping now: the flotation kinetics zoo (Garcia-Zuniga first-order, Klimpel,
41
+ Kelsall, modified Kelsall, gamma rate distribution, second-order, fully mixed, bank
42
+ of N mixers) and the comminution energy-size laws (Rittinger, Kick, Bond, Morrell
43
+ Mi). The bank grows with the Fragua build (thickening, leaching, utilities, PBM).
44
+ - **Fitting** (`phenoforge.fit`): bounded multistart trust-region nonlinear least
45
+ squares per family; information criteria (AIC/AICc/BIC) on every fit.
46
+ - **Ensembles** (`phenoforge.ensemble`):
47
+ - `select` / `akaike_weights` / `averaged_prediction`: IC selection and multimodel
48
+ averaging (Burnham-Anderson).
49
+ - `glue_fit`: GLUE behavioural parameter-set ensembles (Beven-Binley).
50
+ - `bootstrap_fit`: bagging/bragging of one family (paired or moving-block).
51
+ - `stack_fit`: cross-validated convex stacking over the bank (super-learner
52
+ recipe; M-open rationale per Yao-Vehtari-Simpson-Gelman).
53
+ - `bape_fit`: the BAPE ensemble (bootstrap x family-library subsampling, one
54
+ fitted model per member, inclusion probabilities over families).
55
+ - **Metrics** (`phenoforge.metrics`): point (RMSE/MAE/R2), probabilistic calibration
56
+ (ensemble CRPS, PIT, interval coverage), and structural readouts (weight entropy,
57
+ known-truth structural recovery that refuses to run silently null).
58
+ - **Routing** (`phenoforge.route`): match a dataset's declared data kinds to the
59
+ families they can calibrate.
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ import numpy as np
65
+ from phenoforge import list_families
66
+ from phenoforge.families import flotation
67
+ from phenoforge.ensemble import bape_fit
68
+
69
+ t = np.array([0.5, 1, 2, 4, 8, 12, 16, 20], dtype=float)
70
+ r = 0.85 * (1 - np.exp(-1.2 * t)) # a batch flotation test
71
+
72
+ ens = bape_fit(flotation.BATCH_FAMILIES, t, r, n_members=200, seed=0)
73
+ print(ens.selection_shares()) # which family explains the data, with what share
74
+ print(ens.quantiles(t)) # calibrated predictive bands
75
+ ```
76
+
77
+ ## Method background (primary sources)
78
+
79
+ - Fasel, Kutz, Brunton, Brunton 2022. Ensemble-SINDy. Proc. R. Soc. A 478:20210904.
80
+ DOI 10.1098/rspa.2021.0904.
81
+ - Pinto, de Azevedo, Oliveira, von Stosch 2019. Bioprocess Biosyst. Eng.
82
+ 42:1853-1865. DOI 10.1007/s00449-019-02181-y.
83
+ - Duan, Ajami, Gao, Sorooshian 2007. Adv. Water Resour. 30:1371-1386.
84
+ DOI 10.1016/j.advwatres.2006.11.014.
85
+ - Beven, Binley 1992. Hydrol. Process. 6:279-298; Beven 2006. J. Hydrol. 320:18-36.
86
+ DOI 10.1016/j.jhydrol.2005.07.007.
87
+ - Yao, Vehtari, Simpson, Gelman 2018. Bayesian Anal. 13:917-1007.
88
+ DOI 10.1214/17-BA1091.
89
+ - Burnham, Anderson 2004. Sociol. Methods Res. 33:261-304.
90
+ DOI 10.1177/0049124104268644.
91
+ - Polat, Chander 2000. Int. J. Miner. Process. 58:145-166.
92
+ DOI 10.1016/S0301-7516(99)00069-1 (flotation family library).
93
+
94
+ ## Development
95
+
96
+ ```
97
+ py -3.12 -m venv .venv
98
+ .venv/Scripts/python -m pip install -e ".[dev]"
99
+ .venv/Scripts/python -m pytest tests -v
100
+ .venv/Scripts/python -m ruff check src tests
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,82 @@
1
+ # phenoforge
2
+
3
+ Phenomenological model-family bank and ensemble calibration engine for industrial
4
+ processes. The reference implementation of **BAPE (Bootstrap-Aggregated
5
+ Phenomenological Ensembles)**: instead of selecting one phenomenological model for a
6
+ process dataset, fit many realizations (model families x parameter multistarts x
7
+ bootstrap resamples) from a curated family bank and aggregate them into a calibrated
8
+ ensemble with structural inclusion probabilities.
9
+
10
+ Consumed by the Fragua research product (`CAOS_RES_Fragua`); designed as a
11
+ standalone, domain-agnostic library. The core is pure numpy/scipy (Pyodide-safe).
12
+
13
+ ## What it provides
14
+
15
+ - **A family bank** (`phenoforge.families`): named, cited, bounded phenomenological
16
+ models with physical parameter ranges and declared calibration-data contracts.
17
+ Shipping now: the flotation kinetics zoo (Garcia-Zuniga first-order, Klimpel,
18
+ Kelsall, modified Kelsall, gamma rate distribution, second-order, fully mixed, bank
19
+ of N mixers) and the comminution energy-size laws (Rittinger, Kick, Bond, Morrell
20
+ Mi). The bank grows with the Fragua build (thickening, leaching, utilities, PBM).
21
+ - **Fitting** (`phenoforge.fit`): bounded multistart trust-region nonlinear least
22
+ squares per family; information criteria (AIC/AICc/BIC) on every fit.
23
+ - **Ensembles** (`phenoforge.ensemble`):
24
+ - `select` / `akaike_weights` / `averaged_prediction`: IC selection and multimodel
25
+ averaging (Burnham-Anderson).
26
+ - `glue_fit`: GLUE behavioural parameter-set ensembles (Beven-Binley).
27
+ - `bootstrap_fit`: bagging/bragging of one family (paired or moving-block).
28
+ - `stack_fit`: cross-validated convex stacking over the bank (super-learner
29
+ recipe; M-open rationale per Yao-Vehtari-Simpson-Gelman).
30
+ - `bape_fit`: the BAPE ensemble (bootstrap x family-library subsampling, one
31
+ fitted model per member, inclusion probabilities over families).
32
+ - **Metrics** (`phenoforge.metrics`): point (RMSE/MAE/R2), probabilistic calibration
33
+ (ensemble CRPS, PIT, interval coverage), and structural readouts (weight entropy,
34
+ known-truth structural recovery that refuses to run silently null).
35
+ - **Routing** (`phenoforge.route`): match a dataset's declared data kinds to the
36
+ families they can calibrate.
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ import numpy as np
42
+ from phenoforge import list_families
43
+ from phenoforge.families import flotation
44
+ from phenoforge.ensemble import bape_fit
45
+
46
+ t = np.array([0.5, 1, 2, 4, 8, 12, 16, 20], dtype=float)
47
+ r = 0.85 * (1 - np.exp(-1.2 * t)) # a batch flotation test
48
+
49
+ ens = bape_fit(flotation.BATCH_FAMILIES, t, r, n_members=200, seed=0)
50
+ print(ens.selection_shares()) # which family explains the data, with what share
51
+ print(ens.quantiles(t)) # calibrated predictive bands
52
+ ```
53
+
54
+ ## Method background (primary sources)
55
+
56
+ - Fasel, Kutz, Brunton, Brunton 2022. Ensemble-SINDy. Proc. R. Soc. A 478:20210904.
57
+ DOI 10.1098/rspa.2021.0904.
58
+ - Pinto, de Azevedo, Oliveira, von Stosch 2019. Bioprocess Biosyst. Eng.
59
+ 42:1853-1865. DOI 10.1007/s00449-019-02181-y.
60
+ - Duan, Ajami, Gao, Sorooshian 2007. Adv. Water Resour. 30:1371-1386.
61
+ DOI 10.1016/j.advwatres.2006.11.014.
62
+ - Beven, Binley 1992. Hydrol. Process. 6:279-298; Beven 2006. J. Hydrol. 320:18-36.
63
+ DOI 10.1016/j.jhydrol.2005.07.007.
64
+ - Yao, Vehtari, Simpson, Gelman 2018. Bayesian Anal. 13:917-1007.
65
+ DOI 10.1214/17-BA1091.
66
+ - Burnham, Anderson 2004. Sociol. Methods Res. 33:261-304.
67
+ DOI 10.1177/0049124104268644.
68
+ - Polat, Chander 2000. Int. J. Miner. Process. 58:145-166.
69
+ DOI 10.1016/S0301-7516(99)00069-1 (flotation family library).
70
+
71
+ ## Development
72
+
73
+ ```
74
+ py -3.12 -m venv .venv
75
+ .venv/Scripts/python -m pip install -e ".[dev]"
76
+ .venv/Scripts/python -m pytest tests -v
77
+ .venv/Scripts/python -m ruff check src tests
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "phenoforge"
7
+ version = "0.1.2"
8
+ description = "Phenomenological model-family bank and ensemble calibration engine for industrial processes (BAPE: Bootstrap-Aggregated Phenomenological Ensembles)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Felipe Santibanez-Leal", email = "fsantibanez@gmail.com" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["numpy>=1.24", "scipy>=1.10"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ ]
21
+ keywords = ["phenomenological-models", "ensemble", "model-averaging", "mineral-processing", "flotation", "uncertainty-quantification", "bootstrap", "stacking"]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/fsantibanezleal/CAOS_PhenoForge"
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8", "ruff>=0.4"]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "W", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )