poly-basis-ml 0.1.0__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.
- poly_basis_ml-0.1.0/LICENSE +21 -0
- poly_basis_ml-0.1.0/PKG-INFO +97 -0
- poly_basis_ml-0.1.0/README.md +77 -0
- poly_basis_ml-0.1.0/pyproject.toml +30 -0
- poly_basis_ml-0.1.0/setup.cfg +4 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/__init__.py +18 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/_vandermonde.py +41 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/expanders.py +109 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/interactions.py +200 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/model_tree.py +135 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml/regressor.py +138 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml.egg-info/PKG-INFO +97 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml.egg-info/SOURCES.txt +17 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml.egg-info/dependency_links.txt +1 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml.egg-info/requires.txt +3 -0
- poly_basis_ml-0.1.0/src/poly_basis_ml.egg-info/top_level.txt +1 -0
- poly_basis_ml-0.1.0/tests/test_expander.py +61 -0
- poly_basis_ml-0.1.0/tests/test_model_tree.py +37 -0
- poly_basis_ml-0.1.0/tests/test_regressor.py +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luciano Gerber
|
|
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,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: poly-basis-ml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chebyshev polynomial feature expansion and regression for scikit-learn
|
|
5
|
+
Author-email: Luciano Gerber <L.Gerber@mmu.ac.uk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gerberl/poly-basis-ml
|
|
8
|
+
Project-URL: Issues, https://github.com/gerberl/poly-basis-ml/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy>=1.21
|
|
17
|
+
Requires-Dist: scipy>=1.7
|
|
18
|
+
Requires-Dist: scikit-learn>=1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# poly-basis-ml
|
|
22
|
+
|
|
23
|
+
Chebyshev polynomial feature expansion and regression for scikit-learn.
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/poly-basis-ml/)
|
|
26
|
+
[](https://github.com/gerberl/poly-basis-ml/actions)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install poly-basis-ml
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from poly_basis_ml import ChebyshevRegressor
|
|
38
|
+
from sklearn.datasets import make_friedman1
|
|
39
|
+
from sklearn.model_selection import train_test_split
|
|
40
|
+
|
|
41
|
+
X, y = make_friedman1(n_samples=1000, random_state=42)
|
|
42
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
|
|
43
|
+
|
|
44
|
+
model = ChebyshevRegressor(complexity=5, alpha=0.1)
|
|
45
|
+
model.fit(X_train, y_train)
|
|
46
|
+
print(f"R2: {model.score(X_test, y_test):.3f}")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Key features
|
|
50
|
+
|
|
51
|
+
- **ChebyshevExpander** --- standalone sklearn transformer for polynomial feature expansion
|
|
52
|
+
- **ChebyshevRegressor** --- convenience estimator wrapping Chebyshev expansion + Ridge
|
|
53
|
+
- **ChebyshevModelTreeRegressor** --- decision tree with Chebyshev polynomial leaf models
|
|
54
|
+
- **Bivariate interactions** --- optional product, contrast, and additive interaction features
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
Features are mapped to [-1, 1] via MinMaxScaler, then expanded into Chebyshev
|
|
59
|
+
polynomial basis functions with proper intercept handling (one T0 term retained,
|
|
60
|
+
redundant constant columns stripped). The resulting design matrix is fitted with
|
|
61
|
+
Ridge regression. For the model tree variant, a decision tree first partitions
|
|
62
|
+
the data into regions, then each leaf fits a separate ChebyshevRegressor for
|
|
63
|
+
smooth local approximation.
|
|
64
|
+
|
|
65
|
+
## Main classes
|
|
66
|
+
|
|
67
|
+
### ChebyshevRegressor
|
|
68
|
+
|
|
69
|
+
| Parameter | Default | Description |
|
|
70
|
+
|-----------|---------|-------------|
|
|
71
|
+
| `complexity` | `5` | Chebyshev polynomial degree |
|
|
72
|
+
| `alpha` | `1.0` | Ridge regularisation strength |
|
|
73
|
+
| `clip_input` | `True` | Clip prediction-time inputs to training range |
|
|
74
|
+
| `include_interactions` | `False` | Add bivariate interaction features |
|
|
75
|
+
|
|
76
|
+
### ChebyshevModelTreeRegressor
|
|
77
|
+
|
|
78
|
+
| Parameter | Default | Description |
|
|
79
|
+
|-----------|---------|-------------|
|
|
80
|
+
| `max_depth` | `3` | Maximum depth of routing tree |
|
|
81
|
+
| `min_samples_leaf` | `200` | Minimum samples per leaf for polynomial fit |
|
|
82
|
+
| `complexity` | `2` | Chebyshev degree for leaf models |
|
|
83
|
+
| `alpha` | `10.0` | Ridge regularisation for leaf models |
|
|
84
|
+
|
|
85
|
+
## Citation
|
|
86
|
+
|
|
87
|
+
```bibtex
|
|
88
|
+
@article{gerber2026revisiting,
|
|
89
|
+
title={Revisiting Chebyshev Polynomial and Anisotropic RBF Models for Tabular Regression},
|
|
90
|
+
author={Gerber, Luciano and Lloyd, Chris},
|
|
91
|
+
year={2026}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Licence
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# poly-basis-ml
|
|
2
|
+
|
|
3
|
+
Chebyshev polynomial feature expansion and regression for scikit-learn.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/poly-basis-ml/)
|
|
6
|
+
[](https://github.com/gerberl/poly-basis-ml/actions)
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install poly-basis-ml
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from poly_basis_ml import ChebyshevRegressor
|
|
18
|
+
from sklearn.datasets import make_friedman1
|
|
19
|
+
from sklearn.model_selection import train_test_split
|
|
20
|
+
|
|
21
|
+
X, y = make_friedman1(n_samples=1000, random_state=42)
|
|
22
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
|
|
23
|
+
|
|
24
|
+
model = ChebyshevRegressor(complexity=5, alpha=0.1)
|
|
25
|
+
model.fit(X_train, y_train)
|
|
26
|
+
print(f"R2: {model.score(X_test, y_test):.3f}")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Key features
|
|
30
|
+
|
|
31
|
+
- **ChebyshevExpander** --- standalone sklearn transformer for polynomial feature expansion
|
|
32
|
+
- **ChebyshevRegressor** --- convenience estimator wrapping Chebyshev expansion + Ridge
|
|
33
|
+
- **ChebyshevModelTreeRegressor** --- decision tree with Chebyshev polynomial leaf models
|
|
34
|
+
- **Bivariate interactions** --- optional product, contrast, and additive interaction features
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
Features are mapped to [-1, 1] via MinMaxScaler, then expanded into Chebyshev
|
|
39
|
+
polynomial basis functions with proper intercept handling (one T0 term retained,
|
|
40
|
+
redundant constant columns stripped). The resulting design matrix is fitted with
|
|
41
|
+
Ridge regression. For the model tree variant, a decision tree first partitions
|
|
42
|
+
the data into regions, then each leaf fits a separate ChebyshevRegressor for
|
|
43
|
+
smooth local approximation.
|
|
44
|
+
|
|
45
|
+
## Main classes
|
|
46
|
+
|
|
47
|
+
### ChebyshevRegressor
|
|
48
|
+
|
|
49
|
+
| Parameter | Default | Description |
|
|
50
|
+
|-----------|---------|-------------|
|
|
51
|
+
| `complexity` | `5` | Chebyshev polynomial degree |
|
|
52
|
+
| `alpha` | `1.0` | Ridge regularisation strength |
|
|
53
|
+
| `clip_input` | `True` | Clip prediction-time inputs to training range |
|
|
54
|
+
| `include_interactions` | `False` | Add bivariate interaction features |
|
|
55
|
+
|
|
56
|
+
### ChebyshevModelTreeRegressor
|
|
57
|
+
|
|
58
|
+
| Parameter | Default | Description |
|
|
59
|
+
|-----------|---------|-------------|
|
|
60
|
+
| `max_depth` | `3` | Maximum depth of routing tree |
|
|
61
|
+
| `min_samples_leaf` | `200` | Minimum samples per leaf for polynomial fit |
|
|
62
|
+
| `complexity` | `2` | Chebyshev degree for leaf models |
|
|
63
|
+
| `alpha` | `10.0` | Ridge regularisation for leaf models |
|
|
64
|
+
|
|
65
|
+
## Citation
|
|
66
|
+
|
|
67
|
+
```bibtex
|
|
68
|
+
@article{gerber2026revisiting,
|
|
69
|
+
title={Revisiting Chebyshev Polynomial and Anisotropic RBF Models for Tabular Regression},
|
|
70
|
+
author={Gerber, Luciano and Lloyd, Chris},
|
|
71
|
+
year={2026}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Licence
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "poly-basis-ml"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Chebyshev polynomial feature expansion and regression for scikit-learn"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "Luciano Gerber", email = "L.Gerber@mmu.ac.uk"}]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy>=1.21",
|
|
15
|
+
"scipy>=1.7",
|
|
16
|
+
"scikit-learn>=1.0",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
"Intended Audience :: Science/Research",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/gerberl/poly-basis-ml"
|
|
27
|
+
Issues = "https://github.com/gerberl/poly-basis-ml/issues"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
poly_basis_ml - Chebyshev polynomial feature expansion and regression.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .expanders import ChebyshevExpander
|
|
6
|
+
from .regressor import ChebyshevRegressor
|
|
7
|
+
from .model_tree import ChebyshevModelTreeRegressor
|
|
8
|
+
from .interactions import INTERACTION_FUNCS, RECOMMENDED_INTERACTION_TYPES
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'ChebyshevExpander',
|
|
12
|
+
'ChebyshevRegressor',
|
|
13
|
+
'ChebyshevModelTreeRegressor',
|
|
14
|
+
'INTERACTION_FUNCS',
|
|
15
|
+
'RECOMMENDED_INTERACTION_TYPES',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level Chebyshev Vandermonde matrix generation with intercept handling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.polynomial.chebyshev import chebvander
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_vandermonde_matrix(X, complexity):
|
|
10
|
+
"""Generate Chebyshev Vandermonde matrix with intercept handling.
|
|
11
|
+
|
|
12
|
+
Keeps T0 from the first feature and strips redundant T0 columns from
|
|
13
|
+
remaining features to avoid perfect multicollinearity.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
X : ndarray of shape (n_samples, n_features)
|
|
18
|
+
Input data, should be scaled to [-1, 1].
|
|
19
|
+
complexity : int
|
|
20
|
+
Chebyshev polynomial degree.
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
vander : ndarray of shape (n_samples, n_terms)
|
|
25
|
+
Vandermonde matrix with exactly one intercept term.
|
|
26
|
+
"""
|
|
27
|
+
V_first = chebvander(X[:, 0], complexity)
|
|
28
|
+
V_rest = [chebvander(X[:, j], complexity)[:, 1:] for j in range(1, X.shape[1])]
|
|
29
|
+
return np.hstack([V_first] + V_rest)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _VandermondeTransform:
|
|
33
|
+
"""Picklable callable for Vandermonde matrix generation.
|
|
34
|
+
|
|
35
|
+
Replaces lambda to enable joblib/pickle serialisation of fitted models.
|
|
36
|
+
"""
|
|
37
|
+
def __init__(self, complexity):
|
|
38
|
+
self.complexity = complexity
|
|
39
|
+
|
|
40
|
+
def __call__(self, X):
|
|
41
|
+
return get_vandermonde_matrix(X, self.complexity)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChebyshevExpander: standalone sklearn transformer for Chebyshev polynomial
|
|
3
|
+
feature expansion.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
8
|
+
from sklearn.preprocessing import MinMaxScaler
|
|
9
|
+
from sklearn.utils.validation import check_array, check_is_fitted
|
|
10
|
+
|
|
11
|
+
from ._vandermonde import get_vandermonde_matrix
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChebyshevExpander(BaseEstimator, TransformerMixin):
|
|
15
|
+
"""Chebyshev polynomial feature expansion.
|
|
16
|
+
|
|
17
|
+
Maps features to [-1, 1] via MinMaxScaler, then generates a Chebyshev
|
|
18
|
+
Vandermonde matrix with proper intercept handling (one T0 term kept,
|
|
19
|
+
redundant T0 columns stripped).
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
complexity : int, default=5
|
|
24
|
+
Chebyshev polynomial degree.
|
|
25
|
+
clip_input : bool, default=True
|
|
26
|
+
Clip prediction-time inputs to the training range before scaling.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
scaler_ : MinMaxScaler
|
|
31
|
+
Fitted scaler mapping features to [-1, 1].
|
|
32
|
+
n_features_in_ : int
|
|
33
|
+
Number of input features seen during fit.
|
|
34
|
+
|
|
35
|
+
Examples
|
|
36
|
+
--------
|
|
37
|
+
>>> from poly_basis_ml import ChebyshevExpander
|
|
38
|
+
>>> from sklearn.pipeline import Pipeline
|
|
39
|
+
>>> from sklearn.linear_model import Ridge
|
|
40
|
+
>>>
|
|
41
|
+
>>> pipe = Pipeline([('expand', ChebyshevExpander(complexity=5)), ('reg', Ridge())])
|
|
42
|
+
>>> pipe.fit(X_train, y_train)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, complexity=5, clip_input=True):
|
|
46
|
+
self.complexity = complexity
|
|
47
|
+
self.clip_input = clip_input
|
|
48
|
+
|
|
49
|
+
def fit(self, X, y=None):
|
|
50
|
+
"""Fit the MinMaxScaler to map features to [-1, 1].
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
X : array-like of shape (n_samples, n_features)
|
|
55
|
+
Training data.
|
|
56
|
+
y : ignored
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
self
|
|
61
|
+
"""
|
|
62
|
+
X = check_array(X)
|
|
63
|
+
self.n_features_in_ = X.shape[1]
|
|
64
|
+
self.scaler_ = MinMaxScaler(feature_range=(-1, 1))
|
|
65
|
+
self.scaler_.fit(X)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def transform(self, X):
|
|
69
|
+
"""Scale to [-1, 1] and generate Chebyshev Vandermonde matrix.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
X : array-like of shape (n_samples, n_features)
|
|
74
|
+
Data to transform.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
X_poly : ndarray of shape (n_samples, n_terms)
|
|
79
|
+
Chebyshev polynomial features.
|
|
80
|
+
"""
|
|
81
|
+
check_is_fitted(self)
|
|
82
|
+
X = check_array(X)
|
|
83
|
+
|
|
84
|
+
if self.clip_input:
|
|
85
|
+
X = np.clip(X, self.scaler_.data_min_, self.scaler_.data_max_)
|
|
86
|
+
|
|
87
|
+
X_scaled = self.scaler_.transform(X)
|
|
88
|
+
return get_vandermonde_matrix(X_scaled, self.complexity)
|
|
89
|
+
|
|
90
|
+
def get_feature_names_out(self, input_features=None):
|
|
91
|
+
"""Get output feature names.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
list of str
|
|
96
|
+
Names like 'T0_f0', 'T1_f0', ..., 'T1_f1', ...
|
|
97
|
+
"""
|
|
98
|
+
check_is_fitted(self)
|
|
99
|
+
d = self.n_features_in_
|
|
100
|
+
c = self.complexity
|
|
101
|
+
names = []
|
|
102
|
+
# First feature keeps T0..Tc
|
|
103
|
+
for deg in range(c + 1):
|
|
104
|
+
names.append(f'T{deg}_f0')
|
|
105
|
+
# Remaining features: T1..Tc (T0 stripped)
|
|
106
|
+
for j in range(1, d):
|
|
107
|
+
for deg in range(1, c + 1):
|
|
108
|
+
names.append(f'T{deg}_f{j}')
|
|
109
|
+
return names
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bivariate interaction functions for feature engineering.
|
|
3
|
+
|
|
4
|
+
Three families (pick one from each to avoid redundancy):
|
|
5
|
+
- Product family: product, harmonic (multiplicative relationships)
|
|
6
|
+
- Ratio family: contrast, ratio, log_ratio (relative comparisons)
|
|
7
|
+
- Additive family: addition, difference (linear combinations)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from itertools import combinations
|
|
12
|
+
|
|
13
|
+
from sklearn.base import BaseEstimator
|
|
14
|
+
from sklearn.utils.validation import check_array
|
|
15
|
+
|
|
16
|
+
from ._vandermonde import get_vandermonde_matrix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Interaction functions ---
|
|
20
|
+
|
|
21
|
+
def _interaction_product(a, b, eps=1e-6):
|
|
22
|
+
"""Product interaction: a * b."""
|
|
23
|
+
return a * b
|
|
24
|
+
|
|
25
|
+
def _interaction_harmonic(a, b, eps=1e-6):
|
|
26
|
+
"""Harmonic interaction: 2*a*b / (|a| + |b| + eps)."""
|
|
27
|
+
return 2 * a * b / (np.abs(a) + np.abs(b) + eps)
|
|
28
|
+
|
|
29
|
+
def _interaction_contrast(a, b, eps=1e-6):
|
|
30
|
+
"""Contrast interaction: (a - b) / (|a| + |b| + eps)."""
|
|
31
|
+
return (a - b) / (np.abs(a) + np.abs(b) + eps)
|
|
32
|
+
|
|
33
|
+
def _interaction_ratio(a, b, eps=1e-6):
|
|
34
|
+
"""Ratio interaction: a / (b + eps). Unbounded."""
|
|
35
|
+
return a / (b + eps)
|
|
36
|
+
|
|
37
|
+
def _interaction_log_ratio(a, b, eps=1e-6):
|
|
38
|
+
"""Log-ratio interaction: log(|a| + eps) - log(|b| + eps)."""
|
|
39
|
+
return np.log(np.abs(a) + eps) - np.log(np.abs(b) + eps)
|
|
40
|
+
|
|
41
|
+
def _interaction_addition(a, b, eps=1e-6):
|
|
42
|
+
"""Addition interaction: (a + b) / 2."""
|
|
43
|
+
return (a + b) / 2
|
|
44
|
+
|
|
45
|
+
def _interaction_difference(a, b, eps=1e-6):
|
|
46
|
+
"""Difference interaction: (a - b) / 2."""
|
|
47
|
+
return (a - b) / 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
INTERACTION_FUNCS = {
|
|
51
|
+
'product': _interaction_product,
|
|
52
|
+
'harmonic': _interaction_harmonic,
|
|
53
|
+
'contrast': _interaction_contrast,
|
|
54
|
+
'ratio': _interaction_ratio,
|
|
55
|
+
'log_ratio': _interaction_log_ratio,
|
|
56
|
+
'addition': _interaction_addition,
|
|
57
|
+
'difference': _interaction_difference,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
RECOMMENDED_INTERACTION_TYPES = ['product', 'contrast', 'addition']
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _PolyInteractionTransform(BaseEstimator):
|
|
64
|
+
"""Sklearn transformer for combined Chebyshev polynomial + interaction features.
|
|
65
|
+
|
|
66
|
+
Generates Chebyshev Vandermonde matrix and optionally adds interaction features.
|
|
67
|
+
Supports variance-based and MI-based feature ranking for pair selection.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, complexity,
|
|
71
|
+
include_interactions=False, interaction_types=None,
|
|
72
|
+
interaction_pairs='auto', interaction_d_threshold=30,
|
|
73
|
+
interaction_top_frac=0.5, interaction_top_n=None,
|
|
74
|
+
max_interactions=100,
|
|
75
|
+
expand_interactions=False, max_interaction_complexity=5,
|
|
76
|
+
interaction_ranking='variance', mi_spearman_threshold=0.1):
|
|
77
|
+
self.complexity = complexity
|
|
78
|
+
self.include_interactions = include_interactions
|
|
79
|
+
self.interaction_types = interaction_types or ['product']
|
|
80
|
+
self.interaction_pairs = interaction_pairs
|
|
81
|
+
self.interaction_d_threshold = interaction_d_threshold
|
|
82
|
+
self.interaction_top_frac = interaction_top_frac
|
|
83
|
+
self.interaction_top_n = interaction_top_n
|
|
84
|
+
self.max_interactions = max_interactions
|
|
85
|
+
self.expand_interactions = expand_interactions
|
|
86
|
+
self.max_interaction_complexity = max_interaction_complexity
|
|
87
|
+
self.interaction_ranking = interaction_ranking
|
|
88
|
+
self.mi_spearman_threshold = mi_spearman_threshold
|
|
89
|
+
self._interaction_pairs_cache = None
|
|
90
|
+
self._feature_ranking = None
|
|
91
|
+
|
|
92
|
+
def _rank_features_variance(self, X):
|
|
93
|
+
variances = np.var(X, axis=0)
|
|
94
|
+
return np.argsort(variances)[::-1]
|
|
95
|
+
|
|
96
|
+
def _rank_features_mi_rescue(self, X, y):
|
|
97
|
+
from scipy.stats import spearmanr
|
|
98
|
+
from sklearn.feature_selection import mutual_info_regression
|
|
99
|
+
|
|
100
|
+
n_features = X.shape[1]
|
|
101
|
+
spearman_scores = np.array([
|
|
102
|
+
abs(spearmanr(X[:, i], y, nan_policy='omit')[0])
|
|
103
|
+
for i in range(n_features)
|
|
104
|
+
])
|
|
105
|
+
spearman_scores = np.nan_to_num(spearman_scores, nan=0.0)
|
|
106
|
+
|
|
107
|
+
low_spearman_mask = spearman_scores < self.mi_spearman_threshold
|
|
108
|
+
mi_scores = np.zeros(n_features)
|
|
109
|
+
if low_spearman_mask.any():
|
|
110
|
+
rescue_indices = np.where(low_spearman_mask)[0]
|
|
111
|
+
mi_rescue = mutual_info_regression(
|
|
112
|
+
X[:, rescue_indices], y, random_state=42
|
|
113
|
+
)
|
|
114
|
+
mi_scores[rescue_indices] = mi_rescue
|
|
115
|
+
|
|
116
|
+
spearman_max = spearman_scores.max()
|
|
117
|
+
spearman_norm = spearman_scores / (spearman_max + 1e-10) if spearman_max > 0 else spearman_scores
|
|
118
|
+
mi_max = mi_scores.max()
|
|
119
|
+
mi_norm = mi_scores / (mi_max + 1e-10) if mi_max > 0 else mi_scores
|
|
120
|
+
|
|
121
|
+
combined_scores = np.where(
|
|
122
|
+
low_spearman_mask & (mi_norm > spearman_norm),
|
|
123
|
+
mi_norm, spearman_norm
|
|
124
|
+
)
|
|
125
|
+
return np.argsort(combined_scores)[::-1]
|
|
126
|
+
|
|
127
|
+
def _compute_feature_ranking(self, X, y=None):
|
|
128
|
+
if self.interaction_ranking == 'mi_rescue' and y is not None:
|
|
129
|
+
return self._rank_features_mi_rescue(X, y)
|
|
130
|
+
return self._rank_features_variance(X)
|
|
131
|
+
|
|
132
|
+
def _get_pairs_from_ranking(self, X, ranking):
|
|
133
|
+
n_features = X.shape[1]
|
|
134
|
+
|
|
135
|
+
if not self.include_interactions or self.interaction_pairs == 'none':
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
if self.interaction_pairs == 'auto':
|
|
139
|
+
if n_features <= self.interaction_d_threshold:
|
|
140
|
+
pairs = list(combinations(range(n_features), 2))
|
|
141
|
+
else:
|
|
142
|
+
n_top = max(2, int(n_features * self.interaction_top_frac))
|
|
143
|
+
top_indices = ranking[:n_top]
|
|
144
|
+
pairs = list(combinations(sorted(top_indices), 2))
|
|
145
|
+
elif self.interaction_pairs == 'all':
|
|
146
|
+
pairs = list(combinations(range(n_features), 2))
|
|
147
|
+
elif self.interaction_pairs == 'top_n':
|
|
148
|
+
n = self.interaction_top_n or min(n_features, 10)
|
|
149
|
+
top_indices = ranking[:n]
|
|
150
|
+
pairs = list(combinations(sorted(top_indices), 2))
|
|
151
|
+
else:
|
|
152
|
+
pairs = list(combinations(range(n_features), 2))
|
|
153
|
+
|
|
154
|
+
if self.max_interactions is not None:
|
|
155
|
+
n_types = len(self.interaction_types)
|
|
156
|
+
max_pairs = self.max_interactions // max(1, n_types)
|
|
157
|
+
if len(pairs) > max_pairs:
|
|
158
|
+
rank_lookup = {idx: rank for rank, idx in enumerate(ranking)}
|
|
159
|
+
pair_scores = [
|
|
160
|
+
(i, j, rank_lookup.get(i, n_features) + rank_lookup.get(j, n_features))
|
|
161
|
+
for i, j in pairs
|
|
162
|
+
]
|
|
163
|
+
pair_scores.sort(key=lambda x: x[2])
|
|
164
|
+
pairs = [(p[0], p[1]) for p in pair_scores[:max_pairs]]
|
|
165
|
+
|
|
166
|
+
return pairs
|
|
167
|
+
|
|
168
|
+
def fit(self, X, y=None):
|
|
169
|
+
X = check_array(X)
|
|
170
|
+
self._feature_ranking = self._compute_feature_ranking(X, y)
|
|
171
|
+
self._interaction_pairs_cache = self._get_pairs_from_ranking(X, self._feature_ranking)
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def transform(self, X):
|
|
175
|
+
X = check_array(X)
|
|
176
|
+
X_poly = get_vandermonde_matrix(X, self.complexity)
|
|
177
|
+
|
|
178
|
+
if self.include_interactions and self._interaction_pairs_cache:
|
|
179
|
+
interaction_blocks = []
|
|
180
|
+
for i, j in self._interaction_pairs_cache:
|
|
181
|
+
for itype in self.interaction_types:
|
|
182
|
+
func = INTERACTION_FUNCS[itype]
|
|
183
|
+
z = func(X[:, i], X[:, j])
|
|
184
|
+
|
|
185
|
+
if self.expand_interactions:
|
|
186
|
+
z_clipped = np.clip(z, -1, 1).reshape(-1, 1)
|
|
187
|
+
inter_complexity = min(self.complexity, self.max_interaction_complexity)
|
|
188
|
+
z_poly = get_vandermonde_matrix(z_clipped, inter_complexity)
|
|
189
|
+
interaction_blocks.append(z_poly[:, 1:])
|
|
190
|
+
else:
|
|
191
|
+
interaction_blocks.append(z.reshape(-1, 1))
|
|
192
|
+
|
|
193
|
+
if interaction_blocks:
|
|
194
|
+
X_inter = np.hstack(interaction_blocks)
|
|
195
|
+
return np.hstack([X_poly, X_inter])
|
|
196
|
+
|
|
197
|
+
return X_poly
|
|
198
|
+
|
|
199
|
+
def fit_transform(self, X, y=None):
|
|
200
|
+
return self.fit(X, y).transform(X)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChebyshevModelTreeRegressor: decision tree with Chebyshev polynomial leaf models.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.base import BaseEstimator, RegressorMixin
|
|
7
|
+
from sklearn.tree import DecisionTreeRegressor
|
|
8
|
+
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
|
|
9
|
+
from sklearn.metrics import r2_score
|
|
10
|
+
|
|
11
|
+
from .regressor import ChebyshevRegressor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChebyshevModelTreeRegressor(BaseEstimator, RegressorMixin):
|
|
15
|
+
"""Decision tree with Chebyshev polynomial leaf models.
|
|
16
|
+
|
|
17
|
+
The routing tree partitions the feature space into regions, then each
|
|
18
|
+
leaf fits a ChebyshevRegressor for local approximation.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
max_depth : int, default=3
|
|
23
|
+
Maximum depth of the routing tree.
|
|
24
|
+
min_samples_leaf : int or float, default=200
|
|
25
|
+
Minimum samples per leaf for fitting a polynomial model.
|
|
26
|
+
Leaves with fewer samples fall back to tree prediction.
|
|
27
|
+
If int, the absolute minimum number of samples.
|
|
28
|
+
If float, a fraction of n_samples (consistent with sklearn).
|
|
29
|
+
complexity : int, default=2
|
|
30
|
+
Chebyshev polynomial degree for leaf models.
|
|
31
|
+
alpha : float, default=10.0
|
|
32
|
+
Ridge regularisation strength for leaf models.
|
|
33
|
+
routing_features : list of int or None, default=None
|
|
34
|
+
Column indices for tree routing. If None, uses all.
|
|
35
|
+
leaf_features : list of int or None, default=None
|
|
36
|
+
Column indices for leaf models. If None, uses all.
|
|
37
|
+
random_state : int, default=42
|
|
38
|
+
Random state for reproducibility.
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
--------
|
|
42
|
+
>>> from poly_basis_ml import ChebyshevModelTreeRegressor
|
|
43
|
+
>>> model = ChebyshevModelTreeRegressor(max_depth=3, complexity=2)
|
|
44
|
+
>>> model.fit(X_train, y_train)
|
|
45
|
+
>>> print(f"R2: {model.score(X_test, y_test):.3f}")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, max_depth=3, min_samples_leaf=200,
|
|
49
|
+
complexity=2, alpha=10.0,
|
|
50
|
+
routing_features=None, leaf_features=None,
|
|
51
|
+
random_state=42):
|
|
52
|
+
self.max_depth = max_depth
|
|
53
|
+
self.min_samples_leaf = min_samples_leaf
|
|
54
|
+
self.complexity = complexity
|
|
55
|
+
self.alpha = alpha
|
|
56
|
+
self.routing_features = routing_features
|
|
57
|
+
self.leaf_features = leaf_features
|
|
58
|
+
self.random_state = random_state
|
|
59
|
+
|
|
60
|
+
def fit(self, X, y):
|
|
61
|
+
"""Fit model tree with Chebyshev leaf models.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
X : array-like of shape (n_samples, n_features)
|
|
66
|
+
y : array-like of shape (n_samples,)
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
self
|
|
71
|
+
"""
|
|
72
|
+
X, y = check_X_y(X, y)
|
|
73
|
+
self.n_features_in_ = X.shape[1]
|
|
74
|
+
n_samples = X.shape[0]
|
|
75
|
+
|
|
76
|
+
# Resolve min_samples_leaf: float means fraction of n_samples
|
|
77
|
+
if isinstance(self.min_samples_leaf, float):
|
|
78
|
+
min_leaf_abs = max(1, int(np.ceil(self.min_samples_leaf * n_samples)))
|
|
79
|
+
else:
|
|
80
|
+
min_leaf_abs = self.min_samples_leaf
|
|
81
|
+
|
|
82
|
+
X_route = X[:, self.routing_features] if self.routing_features is not None else X
|
|
83
|
+
X_leaf = X[:, self.leaf_features] if self.leaf_features is not None else X
|
|
84
|
+
|
|
85
|
+
# Fit routing tree (sklearn handles int/float min_samples_leaf natively)
|
|
86
|
+
self.tree_ = DecisionTreeRegressor(
|
|
87
|
+
max_depth=self.max_depth,
|
|
88
|
+
min_samples_leaf=self.min_samples_leaf,
|
|
89
|
+
random_state=self.random_state,
|
|
90
|
+
)
|
|
91
|
+
self.tree_.fit(X_route, y)
|
|
92
|
+
|
|
93
|
+
# Fit leaf models
|
|
94
|
+
leaf_ids = self.tree_.apply(X_route)
|
|
95
|
+
self.leaf_models_ = {}
|
|
96
|
+
for leaf_id in np.unique(leaf_ids):
|
|
97
|
+
mask = leaf_ids == leaf_id
|
|
98
|
+
if mask.sum() >= min_leaf_abs:
|
|
99
|
+
self.leaf_models_[leaf_id] = ChebyshevRegressor(
|
|
100
|
+
complexity=self.complexity, alpha=self.alpha,
|
|
101
|
+
).fit(X_leaf[mask], y[mask])
|
|
102
|
+
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def predict(self, X):
|
|
106
|
+
"""Predict using fitted model tree.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
X : array-like of shape (n_samples, n_features)
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
y_pred : ndarray of shape (n_samples,)
|
|
115
|
+
"""
|
|
116
|
+
check_is_fitted(self, ['tree_', 'leaf_models_'])
|
|
117
|
+
X = check_array(X)
|
|
118
|
+
|
|
119
|
+
X_route = X[:, self.routing_features] if self.routing_features is not None else X
|
|
120
|
+
X_leaf = X[:, self.leaf_features] if self.leaf_features is not None else X
|
|
121
|
+
|
|
122
|
+
leaf_ids = self.tree_.apply(X_route)
|
|
123
|
+
y_pred = np.full(len(X), np.nan)
|
|
124
|
+
|
|
125
|
+
for leaf_id, model in self.leaf_models_.items():
|
|
126
|
+
mask = leaf_ids == leaf_id
|
|
127
|
+
if mask.any():
|
|
128
|
+
y_pred[mask] = model.predict(X_leaf[mask])
|
|
129
|
+
|
|
130
|
+
# Fallback for leaves without polynomial model
|
|
131
|
+
nan_mask = np.isnan(y_pred)
|
|
132
|
+
if nan_mask.any():
|
|
133
|
+
y_pred[nan_mask] = self.tree_.predict(X_route[nan_mask])
|
|
134
|
+
|
|
135
|
+
return y_pred
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChebyshevRegressor: convenience estimator wrapping ChebyshevExpander + Ridge.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.base import BaseEstimator, RegressorMixin
|
|
7
|
+
from sklearn.linear_model import Ridge
|
|
8
|
+
from sklearn.pipeline import Pipeline
|
|
9
|
+
from sklearn.preprocessing import MinMaxScaler, FunctionTransformer
|
|
10
|
+
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
|
|
11
|
+
from sklearn.metrics import r2_score
|
|
12
|
+
|
|
13
|
+
from ._vandermonde import _VandermondeTransform
|
|
14
|
+
from .interactions import _PolyInteractionTransform
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChebyshevRegressor(BaseEstimator, RegressorMixin):
|
|
18
|
+
"""Chebyshev polynomial regression with Ridge regularisation.
|
|
19
|
+
|
|
20
|
+
Internally builds Pipeline(MinMaxScaler, ChebyshevVandermonde, Ridge).
|
|
21
|
+
Optionally adds bivariate interaction features.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
complexity : int, default=5
|
|
26
|
+
Chebyshev polynomial degree.
|
|
27
|
+
alpha : float, default=1.0
|
|
28
|
+
Ridge regularisation strength.
|
|
29
|
+
clip_input : bool, default=True
|
|
30
|
+
Clip prediction-time inputs to training range.
|
|
31
|
+
include_interactions : bool, default=False
|
|
32
|
+
Whether to generate interaction features.
|
|
33
|
+
interaction_types : list of str or None
|
|
34
|
+
Types of interactions (default: ['product']).
|
|
35
|
+
interaction_pairs : str, default='auto'
|
|
36
|
+
Which pairs: 'auto', 'all', 'top_n', 'none'.
|
|
37
|
+
max_interactions : int, default=100
|
|
38
|
+
Hard limit on interaction pairs.
|
|
39
|
+
expand_interactions : bool, default=False
|
|
40
|
+
Apply Chebyshev expansion to interactions.
|
|
41
|
+
max_interaction_complexity : int, default=5
|
|
42
|
+
Max degree for interaction expansion.
|
|
43
|
+
interaction_ranking : str, default='variance'
|
|
44
|
+
Feature ranking: 'variance' or 'mi_rescue'.
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> from poly_basis_ml import ChebyshevRegressor
|
|
49
|
+
>>> model = ChebyshevRegressor(complexity=8, alpha=0.1)
|
|
50
|
+
>>> model.fit(X_train, y_train)
|
|
51
|
+
>>> print(f"R2: {model.score(X_test, y_test):.3f}")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, complexity=5, alpha=1.0, clip_input=True,
|
|
55
|
+
include_interactions=False, interaction_types=None,
|
|
56
|
+
interaction_pairs='auto', max_interactions=100,
|
|
57
|
+
expand_interactions=False, max_interaction_complexity=5,
|
|
58
|
+
interaction_ranking='variance'):
|
|
59
|
+
self.complexity = complexity
|
|
60
|
+
self.alpha = alpha
|
|
61
|
+
self.clip_input = clip_input
|
|
62
|
+
self.include_interactions = include_interactions
|
|
63
|
+
self.interaction_types = interaction_types
|
|
64
|
+
self.interaction_pairs = interaction_pairs
|
|
65
|
+
self.max_interactions = max_interactions
|
|
66
|
+
self.expand_interactions = expand_interactions
|
|
67
|
+
self.max_interaction_complexity = max_interaction_complexity
|
|
68
|
+
self.interaction_ranking = interaction_ranking
|
|
69
|
+
|
|
70
|
+
def fit(self, X, y):
|
|
71
|
+
"""Fit the Chebyshev regression model.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
X : array-like of shape (n_samples, n_features)
|
|
76
|
+
y : array-like of shape (n_samples,)
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
self
|
|
81
|
+
"""
|
|
82
|
+
X, y = check_X_y(X, y)
|
|
83
|
+
self.n_features_in_ = X.shape[1]
|
|
84
|
+
|
|
85
|
+
if self.include_interactions:
|
|
86
|
+
transform = _PolyInteractionTransform(
|
|
87
|
+
complexity=self.complexity,
|
|
88
|
+
include_interactions=True,
|
|
89
|
+
interaction_types=self.interaction_types or ['product'],
|
|
90
|
+
interaction_pairs=self.interaction_pairs,
|
|
91
|
+
max_interactions=self.max_interactions,
|
|
92
|
+
expand_interactions=self.expand_interactions,
|
|
93
|
+
max_interaction_complexity=self.max_interaction_complexity,
|
|
94
|
+
interaction_ranking=self.interaction_ranking,
|
|
95
|
+
)
|
|
96
|
+
self.pipe_ = Pipeline([
|
|
97
|
+
('scl', MinMaxScaler(feature_range=(-1, 1))),
|
|
98
|
+
('vdr', transform),
|
|
99
|
+
('reg', Ridge(alpha=self.alpha, fit_intercept=False)),
|
|
100
|
+
])
|
|
101
|
+
else:
|
|
102
|
+
transform = _VandermondeTransform(self.complexity)
|
|
103
|
+
self.pipe_ = Pipeline([
|
|
104
|
+
('scl', MinMaxScaler(feature_range=(-1, 1))),
|
|
105
|
+
('vdr', FunctionTransformer(transform)),
|
|
106
|
+
('reg', Ridge(alpha=self.alpha, fit_intercept=False)),
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
self.pipe_.fit(X, y)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def predict(self, X):
|
|
113
|
+
"""Predict target values.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
X : array-like of shape (n_samples, n_features)
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
y_pred : ndarray of shape (n_samples,)
|
|
122
|
+
"""
|
|
123
|
+
check_is_fitted(self, 'pipe_')
|
|
124
|
+
X = check_array(X)
|
|
125
|
+
if self.clip_input:
|
|
126
|
+
scaler = self.pipe_['scl']
|
|
127
|
+
X = np.clip(X, scaler.data_min_, scaler.data_max_)
|
|
128
|
+
return self.pipe_.predict(X)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def coef_(self):
|
|
132
|
+
check_is_fitted(self, 'pipe_')
|
|
133
|
+
return self.pipe_['reg'].coef_
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def intercept_(self):
|
|
137
|
+
check_is_fitted(self, 'pipe_')
|
|
138
|
+
return self.pipe_['reg'].intercept_
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: poly-basis-ml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chebyshev polynomial feature expansion and regression for scikit-learn
|
|
5
|
+
Author-email: Luciano Gerber <L.Gerber@mmu.ac.uk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/gerberl/poly-basis-ml
|
|
8
|
+
Project-URL: Issues, https://github.com/gerberl/poly-basis-ml/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy>=1.21
|
|
17
|
+
Requires-Dist: scipy>=1.7
|
|
18
|
+
Requires-Dist: scikit-learn>=1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# poly-basis-ml
|
|
22
|
+
|
|
23
|
+
Chebyshev polynomial feature expansion and regression for scikit-learn.
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/poly-basis-ml/)
|
|
26
|
+
[](https://github.com/gerberl/poly-basis-ml/actions)
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install poly-basis-ml
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from poly_basis_ml import ChebyshevRegressor
|
|
38
|
+
from sklearn.datasets import make_friedman1
|
|
39
|
+
from sklearn.model_selection import train_test_split
|
|
40
|
+
|
|
41
|
+
X, y = make_friedman1(n_samples=1000, random_state=42)
|
|
42
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
|
|
43
|
+
|
|
44
|
+
model = ChebyshevRegressor(complexity=5, alpha=0.1)
|
|
45
|
+
model.fit(X_train, y_train)
|
|
46
|
+
print(f"R2: {model.score(X_test, y_test):.3f}")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Key features
|
|
50
|
+
|
|
51
|
+
- **ChebyshevExpander** --- standalone sklearn transformer for polynomial feature expansion
|
|
52
|
+
- **ChebyshevRegressor** --- convenience estimator wrapping Chebyshev expansion + Ridge
|
|
53
|
+
- **ChebyshevModelTreeRegressor** --- decision tree with Chebyshev polynomial leaf models
|
|
54
|
+
- **Bivariate interactions** --- optional product, contrast, and additive interaction features
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
Features are mapped to [-1, 1] via MinMaxScaler, then expanded into Chebyshev
|
|
59
|
+
polynomial basis functions with proper intercept handling (one T0 term retained,
|
|
60
|
+
redundant constant columns stripped). The resulting design matrix is fitted with
|
|
61
|
+
Ridge regression. For the model tree variant, a decision tree first partitions
|
|
62
|
+
the data into regions, then each leaf fits a separate ChebyshevRegressor for
|
|
63
|
+
smooth local approximation.
|
|
64
|
+
|
|
65
|
+
## Main classes
|
|
66
|
+
|
|
67
|
+
### ChebyshevRegressor
|
|
68
|
+
|
|
69
|
+
| Parameter | Default | Description |
|
|
70
|
+
|-----------|---------|-------------|
|
|
71
|
+
| `complexity` | `5` | Chebyshev polynomial degree |
|
|
72
|
+
| `alpha` | `1.0` | Ridge regularisation strength |
|
|
73
|
+
| `clip_input` | `True` | Clip prediction-time inputs to training range |
|
|
74
|
+
| `include_interactions` | `False` | Add bivariate interaction features |
|
|
75
|
+
|
|
76
|
+
### ChebyshevModelTreeRegressor
|
|
77
|
+
|
|
78
|
+
| Parameter | Default | Description |
|
|
79
|
+
|-----------|---------|-------------|
|
|
80
|
+
| `max_depth` | `3` | Maximum depth of routing tree |
|
|
81
|
+
| `min_samples_leaf` | `200` | Minimum samples per leaf for polynomial fit |
|
|
82
|
+
| `complexity` | `2` | Chebyshev degree for leaf models |
|
|
83
|
+
| `alpha` | `10.0` | Ridge regularisation for leaf models |
|
|
84
|
+
|
|
85
|
+
## Citation
|
|
86
|
+
|
|
87
|
+
```bibtex
|
|
88
|
+
@article{gerber2026revisiting,
|
|
89
|
+
title={Revisiting Chebyshev Polynomial and Anisotropic RBF Models for Tabular Regression},
|
|
90
|
+
author={Gerber, Luciano and Lloyd, Chris},
|
|
91
|
+
year={2026}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Licence
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/poly_basis_ml/__init__.py
|
|
5
|
+
src/poly_basis_ml/_vandermonde.py
|
|
6
|
+
src/poly_basis_ml/expanders.py
|
|
7
|
+
src/poly_basis_ml/interactions.py
|
|
8
|
+
src/poly_basis_ml/model_tree.py
|
|
9
|
+
src/poly_basis_ml/regressor.py
|
|
10
|
+
src/poly_basis_ml.egg-info/PKG-INFO
|
|
11
|
+
src/poly_basis_ml.egg-info/SOURCES.txt
|
|
12
|
+
src/poly_basis_ml.egg-info/dependency_links.txt
|
|
13
|
+
src/poly_basis_ml.egg-info/requires.txt
|
|
14
|
+
src/poly_basis_ml.egg-info/top_level.txt
|
|
15
|
+
tests/test_expander.py
|
|
16
|
+
tests/test_model_tree.py
|
|
17
|
+
tests/test_regressor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
poly_basis_ml
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for ChebyshevExpander."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from sklearn.base import clone
|
|
6
|
+
from sklearn.pipeline import Pipeline
|
|
7
|
+
from sklearn.linear_model import Ridge
|
|
8
|
+
|
|
9
|
+
from poly_basis_ml import ChebyshevExpander
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_fit_transform_shape():
|
|
13
|
+
"""Output shape (n, d*complexity + 1)."""
|
|
14
|
+
rng = np.random.RandomState(0)
|
|
15
|
+
X = rng.randn(100, 3)
|
|
16
|
+
exp = ChebyshevExpander(complexity=4)
|
|
17
|
+
X_out = exp.fit_transform(X)
|
|
18
|
+
# First feature: 5 cols (T0..T4), others: 4 cols each (T1..T4)
|
|
19
|
+
expected_cols = 5 + 2 * 4
|
|
20
|
+
assert X_out.shape == (100, expected_cols)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_transform_clipping():
|
|
24
|
+
"""Out-of-range inputs handled via clipping."""
|
|
25
|
+
rng = np.random.RandomState(0)
|
|
26
|
+
X_train = rng.randn(100, 2)
|
|
27
|
+
exp = ChebyshevExpander(complexity=3)
|
|
28
|
+
exp.fit(X_train)
|
|
29
|
+
# Test with out-of-range values
|
|
30
|
+
X_test = X_train * 5
|
|
31
|
+
X_out = exp.transform(X_test)
|
|
32
|
+
assert not np.any(np.isnan(X_out))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_feature_names_out():
|
|
36
|
+
"""Returns T0_f0, T1_f0, ..."""
|
|
37
|
+
rng = np.random.RandomState(0)
|
|
38
|
+
X = rng.randn(50, 2)
|
|
39
|
+
exp = ChebyshevExpander(complexity=3)
|
|
40
|
+
exp.fit(X)
|
|
41
|
+
names = exp.get_feature_names_out()
|
|
42
|
+
assert names[0] == 'T0_f0'
|
|
43
|
+
assert names[1] == 'T1_f0'
|
|
44
|
+
assert 'T1_f1' in names
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_sklearn_clone():
|
|
48
|
+
exp = ChebyshevExpander(complexity=7)
|
|
49
|
+
cloned = clone(exp)
|
|
50
|
+
assert cloned.complexity == 7
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_pipeline_integration():
|
|
54
|
+
"""Pipeline([ChebyshevExpander(), Ridge()]).fit works."""
|
|
55
|
+
rng = np.random.RandomState(42)
|
|
56
|
+
X = rng.randn(200, 3)
|
|
57
|
+
y = X[:, 0] ** 2 + rng.normal(0, 0.1, 200)
|
|
58
|
+
pipe = Pipeline([('expand', ChebyshevExpander(complexity=4)), ('reg', Ridge())])
|
|
59
|
+
pipe.fit(X, y)
|
|
60
|
+
r2 = pipe.score(X, y)
|
|
61
|
+
assert r2 > 0.5
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tests for ChebyshevModelTreeRegressor."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from poly_basis_ml import ChebyshevModelTreeRegressor
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_fit_predict_basic():
|
|
8
|
+
"""Synthetic data, R2 > 0."""
|
|
9
|
+
rng = np.random.RandomState(42)
|
|
10
|
+
X = rng.randn(500, 3)
|
|
11
|
+
y = np.where(X[:, 0] > 0, X[:, 1] ** 2, -X[:, 1]) + rng.normal(0, 0.1, 500)
|
|
12
|
+
model = ChebyshevModelTreeRegressor(max_depth=2, min_samples_leaf=50, complexity=2)
|
|
13
|
+
model.fit(X, y)
|
|
14
|
+
r2 = model.score(X, y)
|
|
15
|
+
assert r2 > 0, f"R2={r2:.3f}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_min_samples_leaf_fallback():
|
|
19
|
+
"""Tiny leaf falls back to tree prediction."""
|
|
20
|
+
rng = np.random.RandomState(42)
|
|
21
|
+
X = rng.randn(100, 2)
|
|
22
|
+
y = X[:, 0] + rng.normal(0, 0.1, 100)
|
|
23
|
+
# Very high min_samples_leaf so most leaves fall back
|
|
24
|
+
model = ChebyshevModelTreeRegressor(max_depth=5, min_samples_leaf=80)
|
|
25
|
+
model.fit(X, y)
|
|
26
|
+
pred = model.predict(X)
|
|
27
|
+
assert pred.shape == (100,)
|
|
28
|
+
assert not np.any(np.isnan(pred))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_set_params():
|
|
32
|
+
model = ChebyshevModelTreeRegressor(max_depth=4, complexity=3)
|
|
33
|
+
params = model.get_params()
|
|
34
|
+
assert params['max_depth'] == 4
|
|
35
|
+
assert params['complexity'] == 3
|
|
36
|
+
model.set_params(max_depth=2)
|
|
37
|
+
assert model.max_depth == 2
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for ChebyshevRegressor."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from poly_basis_ml import ChebyshevRegressor
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_fit_predict_1d_sin():
|
|
8
|
+
"""sin(x), R2 > 0.95 with complexity=8."""
|
|
9
|
+
rng = np.random.RandomState(42)
|
|
10
|
+
X = rng.uniform(-3, 3, (300, 1))
|
|
11
|
+
y = np.sin(X[:, 0]) + rng.normal(0, 0.05, 300)
|
|
12
|
+
model = ChebyshevRegressor(complexity=8, alpha=0.01)
|
|
13
|
+
model.fit(X, y)
|
|
14
|
+
r2 = model.score(X, y)
|
|
15
|
+
assert r2 > 0.95, f"R2={r2:.3f}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_fit_predict_nd_friedman1():
|
|
19
|
+
"""friedman1, R2 > 0.5."""
|
|
20
|
+
from sklearn.datasets import make_friedman1
|
|
21
|
+
X, y = make_friedman1(n_samples=500, n_features=5, random_state=42)
|
|
22
|
+
model = ChebyshevRegressor(complexity=3, alpha=1.0)
|
|
23
|
+
model.fit(X, y)
|
|
24
|
+
r2 = model.score(X, y)
|
|
25
|
+
assert r2 > 0.5, f"R2={r2:.3f}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_with_interactions():
|
|
29
|
+
"""include_interactions=True runs without error."""
|
|
30
|
+
rng = np.random.RandomState(42)
|
|
31
|
+
X = rng.randn(200, 4)
|
|
32
|
+
y = X[:, 0] * X[:, 1] + rng.normal(0, 0.1, 200)
|
|
33
|
+
model = ChebyshevRegressor(complexity=3, include_interactions=True)
|
|
34
|
+
model.fit(X, y)
|
|
35
|
+
pred = model.predict(X)
|
|
36
|
+
assert pred.shape == (200,)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_set_params():
|
|
40
|
+
model = ChebyshevRegressor(complexity=5, alpha=0.5)
|
|
41
|
+
params = model.get_params()
|
|
42
|
+
assert params['complexity'] == 5
|
|
43
|
+
assert params['alpha'] == 0.5
|
|
44
|
+
model.set_params(complexity=3)
|
|
45
|
+
assert model.complexity == 3
|