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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/poly-basis-ml.svg)](https://pypi.org/project/poly-basis-ml/)
26
+ [![Tests](https://github.com/gerberl/poly-basis-ml/actions/workflows/test.yml/badge.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/poly-basis-ml.svg)](https://pypi.org/project/poly-basis-ml/)
6
+ [![Tests](https://github.com/gerberl/poly-basis-ml/actions/workflows/test.yml/badge.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/poly-basis-ml.svg)](https://pypi.org/project/poly-basis-ml/)
26
+ [![Tests](https://github.com/gerberl/poly-basis-ml/actions/workflows/test.yml/badge.svg)](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,3 @@
1
+ numpy>=1.21
2
+ scipy>=1.7
3
+ scikit-learn>=1.0
@@ -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