Metadata-Version: 2.4
Name: poly-basis-ml
Version: 0.3.0
Summary: Chebyshev polynomial feature expansion and regression for scikit-learn
Author-email: Luciano Gerber <L.Gerber@mmu.ac.uk>
License-Expression: MIT
Project-URL: Homepage, https://github.com/gerberl/poly-basis-ml
Project-URL: Issues, https://github.com/gerberl/poly-basis-ml/issues
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Intended Audience :: Science/Research
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: scipy>=1.7
Requires-Dist: scikit-learn>=1.0
Requires-Dist: pandas>=1.3
Dynamic: license-file

# poly-basis-ml

Chebyshev polynomial feature expansion and regression for scikit-learn.

[![PyPI version](https://img.shields.io/pypi/v/poly-basis-ml.svg)](https://pypi.org/project/poly-basis-ml/)
[![Tests](https://github.com/gerberl/poly-basis-ml/actions/workflows/test.yml/badge.svg)](https://github.com/gerberl/poly-basis-ml/actions)

## Installation

```bash
pip install poly-basis-ml
```

## Quick start

```python
from poly_basis_ml import ChebyshevRegressor
from sklearn.datasets import make_friedman1
from sklearn.model_selection import train_test_split

X, y = make_friedman1(n_samples=1000, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

model = ChebyshevRegressor(complexity=5, alpha=0.1)
model.fit(X_train, y_train)
print(f"R2: {model.score(X_test, y_test):.3f}")
# R2: 0.927
```

## Tuning with GridSearchCV

ChebyshevRegressor is a standard sklearn estimator, so it works directly with
GridSearchCV, RandomizedSearchCV, cross_val_score, pipelines, etc.

```python
from sklearn.model_selection import GridSearchCV

param_grid = {"complexity": [3, 5, 7, 9], "alpha": [0.01, 0.1, 1.0, 10.0]}
gs = GridSearchCV(ChebyshevRegressor(), param_grid, cv=5, scoring="r2")
gs.fit(X_train, y_train)
print(gs.best_params_)
# {'alpha': 0.1, 'complexity': 3}
print(f"Best CV R2: {gs.best_score_:.3f}")
# Best CV R2: 0.910
print(f"Test R2:    {gs.score(X_test, y_test):.3f}")
# Test R2:    0.932
```

## Inspecting fitted models

### Feature names and coefficient table

When fitted on a DataFrame, original column names are preserved in the
polynomial feature names. Array input falls back to `x0`, `x1`, etc.

```python
import pandas as pd

X_train_df = pd.DataFrame(X_train, columns=["speed", "temp", "pressure", "humidity", "voltage"])

model = ChebyshevRegressor(complexity=5, alpha=0.1).fit(X_train_df, y_train)

model.get_feature_names_out()[:6]
# array(['speed_T1', 'speed_T2', 'speed_T3', 'speed_T4', 'speed_T5',
#        'temp_T1'], dtype='<U11')

model.coef_table().head(6)
#        feature     coef input_feature
# 0  humidity_T1 4.899597      humidity
# 1     speed_T1 3.218066         speed
# 2      temp_T1 3.163345          temp
# 3   voltage_T1 2.479831       voltage
# 4  pressure_T2 2.413147      pressure
```

### Feature importances

Per-input-feature importances are computed by aggregating absolute coefficient
values across all polynomial terms for each original feature (excluding the
intercept term), normalised to sum to 1.

```python
for name, imp in zip(X_train_df.columns, model.feature_importances_):
    print(f"  {name:>10s}: {imp:.3f}")
#       speed: 0.245
#        temp: 0.244
#    pressure: 0.128
#    humidity: 0.249
#     voltage: 0.133
```

### Feature mapping

```python
mapping = model.get_feature_mapping()
mapping["temp"]
# ['temp_T1', 'temp_T2', 'temp_T3', 'temp_T4', 'temp_T5']
mapping["speed"]
# ['speed_T1', 'speed_T2', 'speed_T3', 'speed_T4', 'speed_T5']
```

The intercept is fitted separately and is not ridge-regularised. Polynomial
feature names therefore start at T1 for every input.

## Extrapolation and range diagnostics

This report checks only the fitted minimum and maximum of each feature. It is
not a chemical applicability-domain estimate and does not establish that a new
row is supported by the joint training distribution. Choose the behaviour
outside the per-feature ranges explicitly:

```python
model = ChebyshevRegressor(extrapolation="raise").fit(X_train, y_train)
prediction = model.predict(X_test)
report = model.extrapolation_report(X_test)
print(report[["is_out_of_range", "out_of_range_features", "max_relative_excess"]])
```

Available policies are:

- `"raise"`: refuse out-of-range predictions;
- `"clip"`: clamp each input to its nearest fitted boundary;
- `"polynomial"`: evaluate the polynomial outside `[-1, 1]`;
- `"linear_tail"`: continue an additive model along its boundary tangent; and
- `"ridge"`: fit an encapsulated plain Ridge model and use it for out-of-range rows.

`linear_tail` is unavailable with interactions. Set `warn_out_of_range=True`
to emit an `ExtrapolationWarning` under any non-raising policy. The historical
`clip_input=True/False` argument remains supported when `extrapolation=None`.
Indices of constant training features are available in `constant_features_`.
Prediction DataFrames must retain the fitted column names and order.

The Ridge fallback is fitted on the original standardised inputs alongside the
Chebyshev model:

```python
model = ChebyshevRegressor(
    complexity=2,
    alpha=0.1,
    extrapolation="ridge",
    fallback_alpha=1.0,
).fit(X_train, y_train)

prediction = model.predict(X_test)
report = model.extrapolation_report(X_test)
print(report["used_fallback"])
```

When `fallback_alpha=None`, the fallback reuses `alpha`. The two values can be
tuned independently through `GridSearchCV`; they are not selected internally.

## Model tree example

```python
from poly_basis_ml import ChebyshevModelTreeRegressor

X, y = make_friedman1(n_samples=2000, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

model = ChebyshevModelTreeRegressor(max_depth=3, complexity=3, alpha=0.1, min_samples_leaf=100)
model.fit(X_train, y_train)
print(f"R2:          {model.score(X_test, y_test):.3f}")
# R2:          0.951
print(f"Leaf models: {len(model.leaf_models_)}")
# Leaf models: 7
```

## Key features

- **ChebyshevExpander** --- standalone sklearn transformer for polynomial feature expansion
- **ChebyshevRegressor** --- convenience estimator wrapping Chebyshev expansion + Ridge
- **ChebyshevModelTreeRegressor** --- decision tree with Chebyshev polynomial leaf models
- **Bivariate interactions** --- optional product, contrast, and additive interaction features

## How it works

Features are mapped to [-1, 1] via MinMaxScaler, then expanded into Chebyshev
polynomial basis functions T1 through Tc for each input. The resulting design
matrix is fitted with Ridge regression and a separate, unpenalised intercept.
For the model tree variant, a decision tree first partitions
the data into regions, then each leaf fits a separate ChebyshevRegressor for
smooth local approximation.

## Main classes

### ChebyshevRegressor

| Parameter | Default | Description |
|-----------|---------|-------------|
| `complexity` | `5` | Chebyshev polynomial degree |
| `alpha` | `1.0` | Ridge regularisation strength |
| `clip_input` | `True` | Backward-compatible switch used when `extrapolation=None` |
| `extrapolation` | `None` | `clip`, `raise`, `polynomial`, additive `linear_tail`, or plain `ridge` fallback |
| `warn_out_of_range` | `False` | Warn when prediction rows leave fitted feature ranges |
| `fallback_alpha` | `None` | Plain Ridge fallback alpha; `None` reuses `alpha` |
| `include_interactions` | `False` | Add bivariate interaction features |

### ChebyshevModelTreeRegressor

| Parameter | Default | Description |
|-----------|---------|-------------|
| `max_depth` | `3` | Maximum depth of routing tree |
| `min_samples_leaf` | `200` | Minimum samples per leaf for polynomial fit |
| `complexity` | `2` | Chebyshev degree for leaf models |
| `alpha` | `10.0` | Ridge regularisation for leaf models |

## Citation

```bibtex
@misc{gerber2026revisiting,
  title={Revisiting Chebyshev Polynomial and Anisotropic RBF Models for Tabular Regression},
  author={Luciano Gerber and Huw Lloyd},
  year={2026},
  eprint={2602.22422},
  archivePrefix={arXiv},
  primaryClass={cs.LG},
  url={https://arxiv.org/abs/2602.22422}
}
```

## Licence

MIT
