regspline 25.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regspline-25.5.2/LICENSE +28 -0
- regspline-25.5.2/PKG-INFO +107 -0
- regspline-25.5.2/README.md +86 -0
- regspline-25.5.2/pyproject.toml +40 -0
- regspline-25.5.2/regspline/__init__.py +6 -0
- regspline-25.5.2/regspline/base.py +451 -0
- regspline-25.5.2/regspline/linear_spline.py +150 -0
- regspline-25.5.2/regspline/natural_cubic_spline.py +238 -0
- regspline-25.5.2/regspline/util.py +37 -0
regspline-25.5.2/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023, mvds314
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: regspline
|
|
3
|
+
Version: 25.5.2
|
|
4
|
+
Summary: Regression spline
|
|
5
|
+
Keywords: statistics,regression,splines
|
|
6
|
+
Author: Martin van der Schans
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Requires-Dist: statsmodels
|
|
13
|
+
Requires-Dist: pyqreg ; extra == "fastqr"
|
|
14
|
+
Requires-Dist: cvxopt!=1.3.0 ; extra == "lasso"
|
|
15
|
+
Requires-Dist: scikit-learn ; extra == "svr"
|
|
16
|
+
Project-URL: repository, https://github.com/mvds314/regspline
|
|
17
|
+
Provides-Extra: fastqr
|
|
18
|
+
Provides-Extra: lasso
|
|
19
|
+
Provides-Extra: svr
|
|
20
|
+
|
|
21
|
+
# Regression splines
|
|
22
|
+
|
|
23
|
+
This module includes two spline implementations splines suitable for regression: linear splines using a hinge function basis, and natural cubic splines.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import numpy as np
|
|
27
|
+
import matplotlib.pyplot as plt
|
|
28
|
+
from regspline import LinearSpline
|
|
29
|
+
plt.close('all')
|
|
30
|
+
|
|
31
|
+
knots = [0,1,2]
|
|
32
|
+
coeffs = [1,2,3]
|
|
33
|
+
s = LinearSpline(knots, coeffs)
|
|
34
|
+
y=s(np.linspace(0,1))
|
|
35
|
+
|
|
36
|
+
x=np.linspace(0,np.pi)
|
|
37
|
+
y=np.sin(x)
|
|
38
|
+
xobs = np.repeat(x,50)
|
|
39
|
+
yobs = np.repeat(y,50) + 0.01*np.random.randn(*xobs.shape)
|
|
40
|
+
|
|
41
|
+
s, res = LinearSpline.from_data(xobs, yobs,
|
|
42
|
+
knots=np.linspace(0,np.pi,30),
|
|
43
|
+
method='OLS',
|
|
44
|
+
return_estim_result=True,
|
|
45
|
+
prune=True)
|
|
46
|
+
|
|
47
|
+
plt.plot(x,y)
|
|
48
|
+
plt.plot(x,s(x))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Several regression types are supported to extract the splines from data, including OLS, LASSO, and quantile regression. See the example files.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
You can install this library directly from github:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install git+https://github.com/mvds314/regspline.git
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
There are two optional dependencies: `scikit-learn`, and `cvxopt`. They are only required to estimate splines on data with, respectively, support vector regressions, and LASSO.
|
|
62
|
+
|
|
63
|
+
## Background
|
|
64
|
+
|
|
65
|
+
The module contains two splines:
|
|
66
|
+
|
|
67
|
+
- A linear spline represented by Hinge functions: $h_i(x) = \max(x-k_i,0)$, where $k_i$ are the knots.
|
|
68
|
+
- A natural cubic spline.
|
|
69
|
+
|
|
70
|
+
The splines chosen:
|
|
71
|
+
|
|
72
|
+
- have coefficents that have a one-to-one correspondence with the knots.
|
|
73
|
+
- have the ability that knots can be removed, e.g., when the corresponding coefficient is small or insignificant, without changing the basis functions corresponding to other knots.
|
|
74
|
+
- have the ability to represent functions with sparse basis.
|
|
75
|
+
|
|
76
|
+
One way to interpret, e.g., the linear spline in the hings basis is as follows. $h_1(x)$ sets an initial slope from the first knot onwards. Then next basis function $h_2(x)$ can adjust the slope at the knot $k_2$, if no adjustment is required, its coefficient is insignificant and the knot can be removed from the spline without any impact on the other basis functions.
|
|
77
|
+
|
|
78
|
+
## Related projects
|
|
79
|
+
|
|
80
|
+
Some projects with related methods:
|
|
81
|
+
|
|
82
|
+
- [basis-expansions](https://github.com/madrury/basis-expansions)
|
|
83
|
+
- [py-earth](https://github.com/scikit-learn-contrib/py-earth)
|
|
84
|
+
- Quantile regression using decision trees [scikit-garden](https://scikit-garden.github.io/)
|
|
85
|
+
|
|
86
|
+
The module differs from these implementations as it implements the splines as functions, and they are not integrated within an estimation framework.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
For development purposes, clone the repo:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git clone https://github.com/mvds314/regspline.git
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Then navigate to the folder containing `setup.py` and run
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install -e .
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
to install the package in edit mode.
|
|
103
|
+
|
|
104
|
+
Run unittests with `pytest`.
|
|
105
|
+
|
|
106
|
+
Install the optional dependencies to test all functionality.
|
|
107
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Regression splines
|
|
2
|
+
|
|
3
|
+
This module includes two spline implementations splines suitable for regression: linear splines using a hinge function basis, and natural cubic splines.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import numpy as np
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from regspline import LinearSpline
|
|
9
|
+
plt.close('all')
|
|
10
|
+
|
|
11
|
+
knots = [0,1,2]
|
|
12
|
+
coeffs = [1,2,3]
|
|
13
|
+
s = LinearSpline(knots, coeffs)
|
|
14
|
+
y=s(np.linspace(0,1))
|
|
15
|
+
|
|
16
|
+
x=np.linspace(0,np.pi)
|
|
17
|
+
y=np.sin(x)
|
|
18
|
+
xobs = np.repeat(x,50)
|
|
19
|
+
yobs = np.repeat(y,50) + 0.01*np.random.randn(*xobs.shape)
|
|
20
|
+
|
|
21
|
+
s, res = LinearSpline.from_data(xobs, yobs,
|
|
22
|
+
knots=np.linspace(0,np.pi,30),
|
|
23
|
+
method='OLS',
|
|
24
|
+
return_estim_result=True,
|
|
25
|
+
prune=True)
|
|
26
|
+
|
|
27
|
+
plt.plot(x,y)
|
|
28
|
+
plt.plot(x,s(x))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Several regression types are supported to extract the splines from data, including OLS, LASSO, and quantile regression. See the example files.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
You can install this library directly from github:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/mvds314/regspline.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
There are two optional dependencies: `scikit-learn`, and `cvxopt`. They are only required to estimate splines on data with, respectively, support vector regressions, and LASSO.
|
|
42
|
+
|
|
43
|
+
## Background
|
|
44
|
+
|
|
45
|
+
The module contains two splines:
|
|
46
|
+
|
|
47
|
+
- A linear spline represented by Hinge functions: $h_i(x) = \max(x-k_i,0)$, where $k_i$ are the knots.
|
|
48
|
+
- A natural cubic spline.
|
|
49
|
+
|
|
50
|
+
The splines chosen:
|
|
51
|
+
|
|
52
|
+
- have coefficents that have a one-to-one correspondence with the knots.
|
|
53
|
+
- have the ability that knots can be removed, e.g., when the corresponding coefficient is small or insignificant, without changing the basis functions corresponding to other knots.
|
|
54
|
+
- have the ability to represent functions with sparse basis.
|
|
55
|
+
|
|
56
|
+
One way to interpret, e.g., the linear spline in the hings basis is as follows. $h_1(x)$ sets an initial slope from the first knot onwards. Then next basis function $h_2(x)$ can adjust the slope at the knot $k_2$, if no adjustment is required, its coefficient is insignificant and the knot can be removed from the spline without any impact on the other basis functions.
|
|
57
|
+
|
|
58
|
+
## Related projects
|
|
59
|
+
|
|
60
|
+
Some projects with related methods:
|
|
61
|
+
|
|
62
|
+
- [basis-expansions](https://github.com/madrury/basis-expansions)
|
|
63
|
+
- [py-earth](https://github.com/scikit-learn-contrib/py-earth)
|
|
64
|
+
- Quantile regression using decision trees [scikit-garden](https://scikit-garden.github.io/)
|
|
65
|
+
|
|
66
|
+
The module differs from these implementations as it implements the splines as functions, and they are not integrated within an estimation framework.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
For development purposes, clone the repo:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/mvds314/regspline.git
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then navigate to the folder containing `setup.py` and run
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
to install the package in edit mode.
|
|
83
|
+
|
|
84
|
+
Run unittests with `pytest`.
|
|
85
|
+
|
|
86
|
+
Install the optional dependencies to test all functionality.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "regspline"
|
|
3
|
+
description = "Regression spline"
|
|
4
|
+
version = "25.5.2"
|
|
5
|
+
authors = [{ name = "Martin van der Schans" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
keywords = ["statistics", "regression", "splines"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
dependencies = ["numpy", "pandas", "statsmodels"]
|
|
10
|
+
license = { text = "BSD-3-Clause" }
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
repository = "https://github.com/mvds314/regspline"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
SVR = ["scikit-learn"]
|
|
17
|
+
LASSO = ["cvxopt!=1.3.0"] # Note: 1.3.0 has a domain error bug:
|
|
18
|
+
FASTQR = ["pyqreg"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["flit_core >=3.2,<4"]
|
|
22
|
+
build-backend = "flit_core.buildapi"
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 99
|
|
26
|
+
|
|
27
|
+
[tool.ruff.lint]
|
|
28
|
+
select = ["E", "F", "W"] # Pycodestyle, pyflakes, bugbear, and isort
|
|
29
|
+
ignore = [
|
|
30
|
+
# Ignore common conflicts with Blackjj
|
|
31
|
+
"E203",
|
|
32
|
+
"E501",
|
|
33
|
+
"E731", # Ignore lamda expression warning
|
|
34
|
+
"E402", # Ignore module level import not at top of file
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
markers = [
|
|
39
|
+
"tofix: marks tests as to be fixed (deselect with '-m \"not tofix\"')",
|
|
40
|
+
]
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import statsmodels.api as sm
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from sklearn.svm import LinearSVR, NuSVR
|
|
11
|
+
from sklearn.linear_model import QuantileRegressor
|
|
12
|
+
except ImportError:
|
|
13
|
+
_has_sklearn = False
|
|
14
|
+
else:
|
|
15
|
+
_has_sklearn = True
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import cvxopt
|
|
19
|
+
|
|
20
|
+
if cvxopt.__version__ == "1.3.0":
|
|
21
|
+
raise ImportError("SVR doesn not work well with cvxopt 1.3.0")
|
|
22
|
+
# Note there is a domain error bug in 1.3.0: https://github.com/cvxopt/cvxopt/issues/202
|
|
23
|
+
except ImportError:
|
|
24
|
+
_has_cvxopt = False
|
|
25
|
+
else:
|
|
26
|
+
_has_cvxopt = True
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from pyqreg import QuantReg as qrQuantReg
|
|
30
|
+
except ImportError:
|
|
31
|
+
_has_pyqreg = False
|
|
32
|
+
else:
|
|
33
|
+
_has_pyqreg = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
from .util import type_wrapper, PandasWrapper
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BasisFuncInterface(ABC):
|
|
40
|
+
r"""
|
|
41
|
+
Abstraction of basis functions
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, xmin=-np.inf, xmax=np.inf, val=0):
|
|
45
|
+
assert np.isscalar(xmin) and np.isreal(xmin)
|
|
46
|
+
assert np.isscalar(xmax) and np.isreal(xmax)
|
|
47
|
+
assert np.isscalar(val) and np.isreal(val)
|
|
48
|
+
assert xmin < xmax
|
|
49
|
+
self.xmax = xmax
|
|
50
|
+
self.xmin = xmin
|
|
51
|
+
self.val = val
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def _apply(self, x):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
@type_wrapper(xloc=1)
|
|
58
|
+
def __call__(self, x):
|
|
59
|
+
y = self._apply(x)
|
|
60
|
+
if np.isfinite(self.xmax):
|
|
61
|
+
y = np.where(x <= self.xmax, y, self.val)
|
|
62
|
+
if np.isfinite(self.xmin):
|
|
63
|
+
y = np.where(x >= self.xmin, y, self.val)
|
|
64
|
+
return y
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class KnotsInterface(ABC):
|
|
68
|
+
"""
|
|
69
|
+
Abstraction for a class containing knots
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, knots):
|
|
73
|
+
self._knots = None
|
|
74
|
+
self.knots = knots
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def knots(self):
|
|
78
|
+
return self._knots
|
|
79
|
+
|
|
80
|
+
@knots.setter
|
|
81
|
+
def knots(self, value):
|
|
82
|
+
if value is not None:
|
|
83
|
+
value = np.asanyarray(value)
|
|
84
|
+
assert len(value) >= 2, "Must specify at least 2 knots"
|
|
85
|
+
assert np.all(value[:-1] < value[1:]), "Knots are assumed to be sorted and unique"
|
|
86
|
+
self._knots = value
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def n_knots(self):
|
|
90
|
+
return None if self.knots is None else len(self.knots)
|
|
91
|
+
|
|
92
|
+
def __len__(self):
|
|
93
|
+
return self.n_knots
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegressionSplineBase(KnotsInterface, ABC):
|
|
97
|
+
r"""
|
|
98
|
+
Regression linear spline represented by basis functions
|
|
99
|
+
:math:`b_1\ldots b_M`:, and with knots :math:`k_1<k_2\ldots <k{N}`.
|
|
100
|
+
The result is a function of the form
|
|
101
|
+
.. math::
|
|
102
|
+
s(x)=c_0+\sum\limits_{i=1}^{M} c_i b_i(x).
|
|
103
|
+
|
|
104
|
+
The relation between the basis functions and the knots differs per
|
|
105
|
+
spline. Nevertheless, in all cases, :math:`c_0` is a constant
|
|
106
|
+
and the other :math:`c_i` are coefficients.
|
|
107
|
+
|
|
108
|
+
The spline intended to be defined for :math:`k_1\leq x \leq k_N`. Outside
|
|
109
|
+
of this range, the function can be extrapolated by either:
|
|
110
|
+
|
|
111
|
+
* simply evaluating the basis functions;
|
|
112
|
+
* with the value :math:`s(k_0)=c_0` left of :math:`k_0`, and with the value :math:`s(k_N)` right of :math:`k_N`;
|
|
113
|
+
* with NaN.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, knots, coeffs, extrapolation_method="nan"):
|
|
117
|
+
self._knots = None
|
|
118
|
+
self._coeffs = None
|
|
119
|
+
super().__init__(knots)
|
|
120
|
+
self.coeffs = coeffs
|
|
121
|
+
self.extrapolation_method = extrapolation_method
|
|
122
|
+
|
|
123
|
+
def __hash__(self):
|
|
124
|
+
return hash(
|
|
125
|
+
(
|
|
126
|
+
self.__class__.__name__,
|
|
127
|
+
tuple(self.knots),
|
|
128
|
+
tuple(self.coeffs),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def extrapolation_method(self):
|
|
134
|
+
return self._extrapolation_method
|
|
135
|
+
|
|
136
|
+
@extrapolation_method.setter
|
|
137
|
+
def extrapolation_method(self, value):
|
|
138
|
+
assert value in ["nan", "const", "basis", "linear"]
|
|
139
|
+
self._extrapolation_method = value
|
|
140
|
+
if hasattr(self, "_bi_cache"):
|
|
141
|
+
del self._bi_cache
|
|
142
|
+
|
|
143
|
+
@KnotsInterface.knots.setter
|
|
144
|
+
def knots(self, value):
|
|
145
|
+
if value is not None and self.coeffs is not None:
|
|
146
|
+
value = np.asanyarray(value)
|
|
147
|
+
assert self._validate_knots_coeffs(value, self.coeffs)
|
|
148
|
+
super(RegressionSplineBase, RegressionSplineBase).knots.__set__(self, value)
|
|
149
|
+
if hasattr(self, "_bi_cache"):
|
|
150
|
+
del self._bi_cache
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def coeffs(self):
|
|
154
|
+
return self._coeffs
|
|
155
|
+
|
|
156
|
+
@coeffs.setter
|
|
157
|
+
def coeffs(self, value):
|
|
158
|
+
if value is not None:
|
|
159
|
+
value = np.asanyarray(value)
|
|
160
|
+
assert self._validate_knots_coeffs(self.knots, value)
|
|
161
|
+
self._coeffs = value
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def _validate_knots_coeffs(self, knots, coeffs):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
@abstractmethod
|
|
169
|
+
def _bi(self):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def _ci(self):
|
|
174
|
+
assert self.knots is not None and self.coeffs is not None
|
|
175
|
+
return self.coeffs[1:] if self.has_const else self.coeffs
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def const(self):
|
|
179
|
+
if self.coeffs is None:
|
|
180
|
+
return None
|
|
181
|
+
elif self.knots is None:
|
|
182
|
+
return 0
|
|
183
|
+
else:
|
|
184
|
+
return self.coeffs[0] if self.has_const else 0
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def has_const(self):
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
@const.setter
|
|
192
|
+
def const(self, value):
|
|
193
|
+
assert self.knots is not None, "Cannot set constant if knots are not specified"
|
|
194
|
+
assert self.coeffs is not None, "Cannot set constant if coeffs are not specified"
|
|
195
|
+
assert np.isscalar(value) and np.isreal(value)
|
|
196
|
+
if self.has_const:
|
|
197
|
+
self.coeffs[0] = value
|
|
198
|
+
else:
|
|
199
|
+
self.coeffs = np.insert(self.coeffs, 0, value, axis=0)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def n_coeffs(self):
|
|
203
|
+
return None if self.coeffs is None else len(self.coeffs)
|
|
204
|
+
|
|
205
|
+
@type_wrapper(xloc=1)
|
|
206
|
+
def __call__(self, x):
|
|
207
|
+
if self.knots is None or self.coeffs is None:
|
|
208
|
+
return np.nan * x
|
|
209
|
+
if self.extrapolation_method == "const":
|
|
210
|
+
x = np.clip(x, self.knots[0], self.knots[-1])
|
|
211
|
+
# Note: bi takes care of other extrapolation methods
|
|
212
|
+
return self.const + self._ci.dot([bi(x) for bi in self._bi])
|
|
213
|
+
|
|
214
|
+
def eval_basis(self, x, include_constant=False):
|
|
215
|
+
"""
|
|
216
|
+
Evaluates the basis functions of the spline on x, usefull for
|
|
217
|
+
linear regression pruposes.
|
|
218
|
+
"""
|
|
219
|
+
wrapper = PandasWrapper(x)
|
|
220
|
+
x = np.asanyarray(x, dtype=np.float64)
|
|
221
|
+
y = [np.ones(x.shape)] if include_constant else []
|
|
222
|
+
y += [bi(x) for bi in self._bi]
|
|
223
|
+
y = np.asanyarray(y).T
|
|
224
|
+
y = y.tolist() if len(y.shape) == 0 else wrapper.wrap(y)
|
|
225
|
+
if isinstance(y, pd.DataFrame):
|
|
226
|
+
columns = ["const"] if include_constant else []
|
|
227
|
+
columns += [f"b{i}" for i, _ in enumerate(self._bi)]
|
|
228
|
+
y.columns = columns
|
|
229
|
+
return y
|
|
230
|
+
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def prune_knots(self, method="isclose", tol=1e-6, **kwargs):
|
|
233
|
+
"""
|
|
234
|
+
Prunes knots based on criterion
|
|
235
|
+
|
|
236
|
+
Parameters
|
|
237
|
+
----------
|
|
238
|
+
method : string, optional
|
|
239
|
+
Method used to determine which knots to prune. The default is 'isclose'.
|
|
240
|
+
tol : float, optional
|
|
241
|
+
Tolerance, passed to method. The default is 1e-6.
|
|
242
|
+
kwargs : dictionary
|
|
243
|
+
Other keyword arguments passed to method.
|
|
244
|
+
"""
|
|
245
|
+
raise NotImplementedError(
|
|
246
|
+
"Which knots can be removed depends on coeffs and knots are related"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def from_data(
|
|
251
|
+
cls,
|
|
252
|
+
x,
|
|
253
|
+
y,
|
|
254
|
+
knots=None,
|
|
255
|
+
method="OLS",
|
|
256
|
+
add_constant=True,
|
|
257
|
+
prune=False,
|
|
258
|
+
return_estim_result=False,
|
|
259
|
+
backend=None,
|
|
260
|
+
**kwargs,
|
|
261
|
+
):
|
|
262
|
+
"""
|
|
263
|
+
Estimates a spline from data
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
x : numpy array
|
|
268
|
+
observations of the independent variable
|
|
269
|
+
y : numpy array
|
|
270
|
+
observations of the dependent variable
|
|
271
|
+
knots : numpy array, optional
|
|
272
|
+
knots, defaults to 10 knots between min and max x-value
|
|
273
|
+
method : string, optional
|
|
274
|
+
Estimation method. The default is "OLS".
|
|
275
|
+
add_constant : boolean, optional
|
|
276
|
+
Add a constant to the spline. The default is True.
|
|
277
|
+
prune : boolean, optional
|
|
278
|
+
Prunes insignificant knots after estimation, and estimates again. The default is False.
|
|
279
|
+
return_estim_result : boolean, optional
|
|
280
|
+
If True, estimation results are returned. The default is False.
|
|
281
|
+
backend : None or string
|
|
282
|
+
Force a certain backend, can be statsmodels, sklearn, or pyqreg. Defaults to using statsmodels where possible.
|
|
283
|
+
|
|
284
|
+
Notable kwargs
|
|
285
|
+
------
|
|
286
|
+
Dictionary with additional kwargs passed to estimation (statsmodels fit methods).
|
|
287
|
+
q : float
|
|
288
|
+
quantile used for quantile regression, defaults to the median (0.5)
|
|
289
|
+
C : float
|
|
290
|
+
Regularization parameter for SVR. The strength of the regularization is inversely proportional to C. Must be strictly positive.
|
|
291
|
+
epsilon : float
|
|
292
|
+
Epsilon parameter in the epsilon-insensitive loss function when using SVR. Note that the value of this parameter depends on the scale of the target variable y.
|
|
293
|
+
nu : float
|
|
294
|
+
Used in NuSVR. An upper bound on the fraction of training errors and a lower bound of the fraction of support vectors. Should be in the interval (0, 1]. By default 0.5 will be taken.
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
Spline object
|
|
300
|
+
"""
|
|
301
|
+
# Initialize
|
|
302
|
+
x = np.asanyarray(x)
|
|
303
|
+
y = np.asanyarray(y)
|
|
304
|
+
if knots is None:
|
|
305
|
+
knots = np.linspace(np.min(x), np.max(x), num=10)
|
|
306
|
+
elif isinstance(knots, int):
|
|
307
|
+
knots = np.linspace(np.min(x), np.max(x), num=knots)
|
|
308
|
+
else:
|
|
309
|
+
knots = np.asanyarray(knots)
|
|
310
|
+
spline = cls(knots, None, extrapolation_method=kwargs.pop("extrapolation_method", "nan"))
|
|
311
|
+
# Estimate
|
|
312
|
+
if method == "OLS":
|
|
313
|
+
assert backend is None or backend == "statsmodels", "sklearn backend not implemented"
|
|
314
|
+
smkwargs = dict(
|
|
315
|
+
exog=spline.eval_basis(x, include_constant=add_constant),
|
|
316
|
+
hasconst=True,
|
|
317
|
+
missing=kwargs.pop("missing", "none"),
|
|
318
|
+
)
|
|
319
|
+
model = sm.OLS(y, **smkwargs)
|
|
320
|
+
result = model.fit(**kwargs)
|
|
321
|
+
spline.coeffs = result.params
|
|
322
|
+
insignificant = np.abs(result.tvalues) < 1.96
|
|
323
|
+
if prune and np.any(insignificant):
|
|
324
|
+
add_constant = add_constant and not insignificant[0]
|
|
325
|
+
spline.prune_knots(method="coeffs", coeffs_to_prune=insignificant)
|
|
326
|
+
return cls.from_data(
|
|
327
|
+
x,
|
|
328
|
+
y,
|
|
329
|
+
knots=spline.knots,
|
|
330
|
+
method=method,
|
|
331
|
+
add_constant=add_constant,
|
|
332
|
+
prune=False,
|
|
333
|
+
return_estim_result=return_estim_result,
|
|
334
|
+
**kwargs,
|
|
335
|
+
)
|
|
336
|
+
elif method == "LASSO":
|
|
337
|
+
assert backend is None or backend == "statsmodels", "sklearn backend not implemented"
|
|
338
|
+
assert _has_cvxopt, "Mising optional dependency cvxopt"
|
|
339
|
+
smkwargs = dict(
|
|
340
|
+
exog=spline.eval_basis(x, include_constant=add_constant),
|
|
341
|
+
hasconst=True,
|
|
342
|
+
missing=kwargs.pop("missing", "none"),
|
|
343
|
+
)
|
|
344
|
+
model = sm.OLS(y, **smkwargs)
|
|
345
|
+
result = model.fit_regularized(method="sqrt_lasso", **kwargs)
|
|
346
|
+
spline.coeffs = result.params
|
|
347
|
+
if prune:
|
|
348
|
+
spline.prune_knots()
|
|
349
|
+
elif method == "QuantileRegression":
|
|
350
|
+
if backend is None or backend == "statsmodels":
|
|
351
|
+
smkwargs = dict(
|
|
352
|
+
exog=spline.eval_basis(x, include_constant=add_constant),
|
|
353
|
+
hasconst=True,
|
|
354
|
+
missing=kwargs.pop("missing", "none"),
|
|
355
|
+
)
|
|
356
|
+
model = sm.QuantReg(y, **smkwargs)
|
|
357
|
+
kwargs.setdefault("q", 0.5)
|
|
358
|
+
result = model.fit(**kwargs)
|
|
359
|
+
spline.coeffs = result.params
|
|
360
|
+
insignificant = np.abs(result.tvalues) < 1.96
|
|
361
|
+
if prune and np.any(insignificant):
|
|
362
|
+
add_constant = add_constant and not insignificant[0]
|
|
363
|
+
spline.prune_knots(method="coeffs", coeffs_to_prune=insignificant)
|
|
364
|
+
return cls.from_data(
|
|
365
|
+
x,
|
|
366
|
+
y,
|
|
367
|
+
knots=spline.knots,
|
|
368
|
+
method=method,
|
|
369
|
+
add_constant=add_constant,
|
|
370
|
+
prune=False,
|
|
371
|
+
return_estim_result=return_estim_result,
|
|
372
|
+
**kwargs,
|
|
373
|
+
)
|
|
374
|
+
elif backend == "sklearn":
|
|
375
|
+
assert _has_sklearn, "Mising optional dependency scikit learn"
|
|
376
|
+
kwargs.setdefault("fit_intercept", add_constant)
|
|
377
|
+
kwargs.setdefault("solver", "highs")
|
|
378
|
+
kwargs.setdefault("quantile", kwargs.pop("q", 0.5))
|
|
379
|
+
kwargs.setdefault("alpha", 0)
|
|
380
|
+
model = QuantileRegressor(**kwargs)
|
|
381
|
+
result = model.fit(spline.eval_basis(x, include_constant=False), y)
|
|
382
|
+
spline.coeffs = np.append(result.intercept_, result.coef_)
|
|
383
|
+
assert np.allclose(spline(x), result.predict(spline.eval_basis(x))), (
|
|
384
|
+
"Something is wrong, this should give the same result"
|
|
385
|
+
)
|
|
386
|
+
if prune:
|
|
387
|
+
spline.prune_knots()
|
|
388
|
+
elif backend == "pyqreg":
|
|
389
|
+
assert _has_pyqreg, "Mising optional dependency pyqreg"
|
|
390
|
+
exog = spline.eval_basis(x, include_constant=add_constant)
|
|
391
|
+
model = qrQuantReg(y, exog)
|
|
392
|
+
q = kwargs.pop("q", 0.5)
|
|
393
|
+
result = model.fit(q, **kwargs)
|
|
394
|
+
spline.coeffs = result.params
|
|
395
|
+
insignificant = np.abs(result.tvalues) < 1.96
|
|
396
|
+
if prune and np.any(insignificant):
|
|
397
|
+
add_constant = add_constant and not insignificant[0]
|
|
398
|
+
spline.prune_knots(method="coeffs", coeffs_to_prune=insignificant)
|
|
399
|
+
return cls.from_data(
|
|
400
|
+
x,
|
|
401
|
+
y,
|
|
402
|
+
knots=spline.knots,
|
|
403
|
+
method=method,
|
|
404
|
+
add_constant=add_constant,
|
|
405
|
+
prune=False,
|
|
406
|
+
return_estim_result=return_estim_result,
|
|
407
|
+
**kwargs,
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
raise ValueError("Invalid backend")
|
|
411
|
+
elif method == "SVR":
|
|
412
|
+
assert backend is None or backend == "sklearn", "statsmodels backend not implemented"
|
|
413
|
+
assert _has_sklearn, "Mising optional dependency scikit learn"
|
|
414
|
+
kwargs.setdefault("random_state", 0)
|
|
415
|
+
kwargs.setdefault("tol", 1e-5)
|
|
416
|
+
kwargs.setdefault("loss", "epsilon_insensitive")
|
|
417
|
+
kwargs.setdefault("C", 1)
|
|
418
|
+
kwargs.setdefault("max_iter", int(1e7))
|
|
419
|
+
kwargs.setdefault("fit_intercept", add_constant)
|
|
420
|
+
model = LinearSVR(**kwargs)
|
|
421
|
+
result = model.fit(spline.eval_basis(x, include_constant=False), y)
|
|
422
|
+
spline.coeffs = np.append(result.intercept_, result.coef_)
|
|
423
|
+
assert np.allclose(spline(x), result.predict(spline.eval_basis(x))), (
|
|
424
|
+
"Something is wrong, this should give the same result"
|
|
425
|
+
)
|
|
426
|
+
if prune:
|
|
427
|
+
spline.prune_knots()
|
|
428
|
+
elif method == "NuSVR":
|
|
429
|
+
assert backend is None or backend == "sklearn", "statsmodels backend not implemented"
|
|
430
|
+
assert _has_sklearn, "Mising optional dependency scikit learn"
|
|
431
|
+
assert add_constant, "A constant is always fitted for NuSVR"
|
|
432
|
+
kwargs.setdefault("tol", 1e-5)
|
|
433
|
+
kwargs.setdefault("nu", 0.5)
|
|
434
|
+
kwargs.setdefault("C", 1)
|
|
435
|
+
kwargs.setdefault("kernel", "linear")
|
|
436
|
+
kwargs.setdefault("max_iter", int(1e7))
|
|
437
|
+
model = NuSVR(**kwargs)
|
|
438
|
+
result = model.fit(spline.eval_basis(x, include_constant=False), y)
|
|
439
|
+
spline.coeffs = np.append(result.intercept_, result.coef_)
|
|
440
|
+
assert np.allclose(spline(x), result.predict(spline.eval_basis(x))), (
|
|
441
|
+
"Something is wrong, this should give the same result"
|
|
442
|
+
)
|
|
443
|
+
if prune:
|
|
444
|
+
spline.prune_knots()
|
|
445
|
+
else:
|
|
446
|
+
raise ValueError(f"Unknown method: {method}")
|
|
447
|
+
# Return
|
|
448
|
+
if return_estim_result:
|
|
449
|
+
return spline, result
|
|
450
|
+
else:
|
|
451
|
+
return spline
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .base import BasisFuncInterface, RegressionSplineBase
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HingeBasisFunction(BasisFuncInterface):
|
|
9
|
+
r"""
|
|
10
|
+
Defines the Hinge basis function
|
|
11
|
+
|
|
12
|
+
.. math::
|
|
13
|
+
\text{max}(x-\text{ref}, 0)
|
|
14
|
+
|
|
15
|
+
Furthermore, input values outside the range xmin to xmax are mapped to val.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, ref, xmin=-np.inf, xmax=np.inf, val=0):
|
|
20
|
+
super().__init__(xmin=xmin, xmax=xmax, val=val)
|
|
21
|
+
assert np.isscalar(ref) and np.isreal(ref) and np.isfinite(ref)
|
|
22
|
+
if np.isfinite(xmin):
|
|
23
|
+
assert xmin <= ref
|
|
24
|
+
if np.isfinite(xmax):
|
|
25
|
+
assert xmax > ref
|
|
26
|
+
self.ref = ref
|
|
27
|
+
|
|
28
|
+
def _apply(self, x):
|
|
29
|
+
return np.fmax(x - self.ref, 0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LinearSpline(RegressionSplineBase):
|
|
33
|
+
r"""
|
|
34
|
+
Linear regression spline represented by Hinge basis functions :math:`h_i`:
|
|
35
|
+
|
|
36
|
+
.. math:: h_i(x) = \max(x-k_i,0),
|
|
37
|
+
|
|
38
|
+
for :math:`i=1\ldots N-1`, and where :math:`k_1<k_2<\ldots <k_{N}`
|
|
39
|
+
are the knots. The result is a piecewise linear function :math:`s(x)`
|
|
40
|
+
between the knots of the form:
|
|
41
|
+
|
|
42
|
+
.. math::
|
|
43
|
+
s(x)=c_0+\sum\limits_{i=1}^{N-1} c_i h_i(x),
|
|
44
|
+
|
|
45
|
+
where :math:`c_0` is a constant and the other :math:`c_i` are coefficients.
|
|
46
|
+
Note that, by construction, :math:`s(k_1)=c_0`.
|
|
47
|
+
|
|
48
|
+
The spline intended to be defined for :math:`k_1\leq x \leq k_N`. Outside
|
|
49
|
+
of this range, the function can be extrapolated by either:
|
|
50
|
+
|
|
51
|
+
* simply evaluating the basis functions;
|
|
52
|
+
* with the value :math:`s(k_0)=c_0` left of :math:`k_0`, and with the value :math:`s(k_N)` right of :math:`k_N`;
|
|
53
|
+
* with NaN.
|
|
54
|
+
|
|
55
|
+
One to interpret the spline in this basis is:
|
|
56
|
+
|
|
57
|
+
* :math:`h_1(x)` equals zero at :math:`k_0`,sets slope in the interval :math:`k_1\leq x\leq k_1`.
|
|
58
|
+
* :math:`h_2(x)` compensates and sets the slope in the interval :math:`k_2 \leq x\leq k_3`.
|
|
59
|
+
* etc.
|
|
60
|
+
|
|
61
|
+
Contrary to other basis functions,, such as spikes, one can represent a linear
|
|
62
|
+
function with only one basis function. Each kink adds requires another basis
|
|
63
|
+
function. Consequently, in regression, coefficients of basis functions become
|
|
64
|
+
insignificant, unless there is a kink at the knot. This keeps the regression
|
|
65
|
+
sparse.
|
|
66
|
+
|
|
67
|
+
Note that this approach is similar to the MARS splines, see the py-earth package.
|
|
68
|
+
This implementation is more lean, and merely implements the spline (in 1 dimension)
|
|
69
|
+
without algorithms, or a modelling framework around it.
|
|
70
|
+
|
|
71
|
+
Params
|
|
72
|
+
--
|
|
73
|
+
x - float, 1d numpy array or pd.Series object
|
|
74
|
+
coeffs - pd.Series with coeffs and knots as index
|
|
75
|
+
- coeffs created by linear regression on :math:`f_i(x)` and the observed y's
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def _validate_knots_coeffs(self, knots, coeffs):
|
|
79
|
+
if knots is not None:
|
|
80
|
+
knots = np.asanyarray(knots)
|
|
81
|
+
assert len(knots) >= 2, "Must specify at least 2 knots"
|
|
82
|
+
if coeffs is not None:
|
|
83
|
+
coeffs = np.asanyarray(coeffs)
|
|
84
|
+
if coeffs is not None and knots is not None:
|
|
85
|
+
assert len(knots) == len(coeffs) or len(knots) - 1 == len(coeffs)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def _bi(self):
|
|
90
|
+
assert self.knots is not None
|
|
91
|
+
if not hasattr(self, "_bi_cache"):
|
|
92
|
+
if self.extrapolation_method == "nan":
|
|
93
|
+
kwargs = dict(xmin=self.knots[0], xmax=self.knots[-1], val=np.nan)
|
|
94
|
+
else:
|
|
95
|
+
kwargs = {}
|
|
96
|
+
if self.extrapolation_method == "linear":
|
|
97
|
+
self._bi_cache = [lambda x, ref=self.knots[0]: x - ref] + [
|
|
98
|
+
HingeBasisFunction(k, **kwargs) for k in self.knots[1:-1]
|
|
99
|
+
]
|
|
100
|
+
else:
|
|
101
|
+
self._bi_cache = [HingeBasisFunction(k, **kwargs) for k in self.knots[:-1]]
|
|
102
|
+
return self._bi_cache
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def has_const(self):
|
|
106
|
+
return self.n_knots == self.n_coeffs
|
|
107
|
+
|
|
108
|
+
def prune_knots(self, method="isclose", tol=1e-6, coeffs_to_prune=None, **kwargs):
|
|
109
|
+
"""
|
|
110
|
+
Prunes knots based on criterion
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
method : string, optional
|
|
115
|
+
Method used to determine which knots to prune. The default is 'isclose'.
|
|
116
|
+
tol : float, optional
|
|
117
|
+
Tolerance, passed to method. The default is 1e-6.
|
|
118
|
+
kwargs : dictionary
|
|
119
|
+
Other keyword arguments passed to method.
|
|
120
|
+
"""
|
|
121
|
+
# Initialize
|
|
122
|
+
coeffs = np.copy(self.coeffs)
|
|
123
|
+
knots = np.copy(self.knots)
|
|
124
|
+
# Determine to prune
|
|
125
|
+
if method == "isclose":
|
|
126
|
+
kwargs.setdefault("atol", tol)
|
|
127
|
+
to_prune = np.isclose(coeffs, 0, **kwargs)
|
|
128
|
+
elif method == "coeffs":
|
|
129
|
+
assert len(coeffs_to_prune) == self.n_coeffs
|
|
130
|
+
to_prune = np.asanyarray(coeffs_to_prune)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"Method {method} invalid")
|
|
133
|
+
# Prune
|
|
134
|
+
if len(to_prune) > 0:
|
|
135
|
+
try:
|
|
136
|
+
# Note first can correspond to const, last knot has no coeff
|
|
137
|
+
self.coeffs = None
|
|
138
|
+
self.knots = None
|
|
139
|
+
self.coeffs = coeffs[~to_prune]
|
|
140
|
+
to_prune = np.append(
|
|
141
|
+
to_prune[1:] if len(knots) == len(coeffs) else to_prune, False
|
|
142
|
+
)
|
|
143
|
+
self.knots = knots[~to_prune]
|
|
144
|
+
except:
|
|
145
|
+
# Cleanup, don't leave the spline in an invalid state
|
|
146
|
+
self.knots = None
|
|
147
|
+
self.coeffs = None
|
|
148
|
+
self.knots = knots
|
|
149
|
+
self.coeffs = coeffs
|
|
150
|
+
raise
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from abc import ABC
|
|
7
|
+
from .base import BasisFuncInterface, KnotsInterface, RegressionSplineBase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _NaturalCubicSplineBasisFuncInterface(KnotsInterface, BasisFuncInterface, ABC):
|
|
11
|
+
r"""
|
|
12
|
+
Abstraction for basis functions relative to knot :math:`k_i`, where
|
|
13
|
+
:math:`k=1\ldots N`
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, i, knots, xmin=-np.inf, xmax=np.inf, val=0):
|
|
17
|
+
KnotsInterface.__init__(self, knots)
|
|
18
|
+
BasisFuncInterface.__init__(self, xmin=xmin, xmax=xmax, val=val)
|
|
19
|
+
assert 1 <= i <= self.n_knots - 1, (
|
|
20
|
+
f"Cannot initialize for {i}th knot, only possible up to {self.n_knots - 1}th knot"
|
|
21
|
+
)
|
|
22
|
+
self._i = i
|
|
23
|
+
if np.isfinite(xmin):
|
|
24
|
+
assert xmin <= self.knots[0]
|
|
25
|
+
if np.isfinite(xmax):
|
|
26
|
+
assert xmax >= self.knots[-1]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def ref(self):
|
|
30
|
+
return self.knots[self._i - 1]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class di(_NaturalCubicSplineBasisFuncInterface):
|
|
34
|
+
r"""
|
|
35
|
+
Defines helper functions for the Natural spline basis
|
|
36
|
+
|
|
37
|
+
.. math::
|
|
38
|
+
d_i(x) = \frac{\max(x-k_i,0)^3-\max(x-k_N,0)^3}{k_N-k_i}
|
|
39
|
+
= \frac{ h(x,k_i)^3-h(x,k_n)^3}{k_N-k_i}\\,
|
|
40
|
+
|
|
41
|
+
for :math:`i=1\ldots N-1`. Here :math:`k_1<k_2<\ldots <k_{N}`
|
|
42
|
+
are the knots, and the :math:`h(x,k_i)=\max(x-k_i,0)` are Hinge functions.
|
|
43
|
+
|
|
44
|
+
See [1] Chapter 5 for further details.
|
|
45
|
+
|
|
46
|
+
References
|
|
47
|
+
----------
|
|
48
|
+
[1] Hastie, Tibshirani, Friedman (2009) - The elements of statistical learning
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def _apply(self, x):
|
|
53
|
+
return (np.fmax(x - self.ref, 0) ** 3 - np.fmax(x - self.knots[-1], 0) ** 3) / (
|
|
54
|
+
self.knots[-1] - self.ref
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NaturalCubicSplineBasisFunction(_NaturalCubicSplineBasisFuncInterface):
|
|
59
|
+
r"""
|
|
60
|
+
Defines the natural cubic spline basis function
|
|
61
|
+
|
|
62
|
+
.. math::
|
|
63
|
+
N_1(x) = x-k_1,
|
|
64
|
+
|
|
65
|
+
.. math::
|
|
66
|
+
N_i(x) = d_{i-1}(x)-d_{N-1}(x)
|
|
67
|
+
|
|
68
|
+
for :math:`i=2\ldots N-1`. Here :math:`k_1<k_2<\ldots <k_{N}`
|
|
69
|
+
are the knots. In total, we have :math:`N-1` basis functions, plus
|
|
70
|
+
a constant.
|
|
71
|
+
|
|
72
|
+
See [1] Chapter 5.3 for further details. Note we have changed notation
|
|
73
|
+
compared to the referenc. First, the index has shifted. We left out
|
|
74
|
+
the constant and handle it manually. Sometimes we can to leave it out
|
|
75
|
+
of regression to prevent misspecification. Second, The first basis
|
|
76
|
+
is relative to the first knot, this keeps the splines more properly
|
|
77
|
+
scaled.
|
|
78
|
+
|
|
79
|
+
References
|
|
80
|
+
----------
|
|
81
|
+
[1] Hastie, Tibshirani, Friedman (2009) - The elements of statistical learning
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def ref(self):
|
|
87
|
+
# Note: first and second basis function are both relative to first knot
|
|
88
|
+
return self.knots[self._i - 1] if self._i == 1 else self.knots[self._i - 2]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def _dim1(self):
|
|
92
|
+
if not hasattr(self, "_dim1_cache"):
|
|
93
|
+
assert self._i > 1 and self._i <= self.n_knots - 1
|
|
94
|
+
self._dim1_cache = di(
|
|
95
|
+
self._i - 1, self.knots, xmin=self.xmin, xmax=self.xmax, val=self.val
|
|
96
|
+
)
|
|
97
|
+
return self._dim1_cache
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def _dNm1(self):
|
|
101
|
+
if not hasattr(self, "_dNm1_cache"):
|
|
102
|
+
self._dNm1_cache = di(
|
|
103
|
+
self.n_knots - 1,
|
|
104
|
+
self.knots,
|
|
105
|
+
xmin=self.xmin,
|
|
106
|
+
xmax=self.xmax,
|
|
107
|
+
val=self.val,
|
|
108
|
+
)
|
|
109
|
+
return self._dNm1_cache
|
|
110
|
+
|
|
111
|
+
def _apply(self, x):
|
|
112
|
+
if self._i == 1:
|
|
113
|
+
return x - self.ref
|
|
114
|
+
else:
|
|
115
|
+
return self._dim1(x) - self._dNm1(x)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NaturalCubicSpline(RegressionSplineBase):
|
|
119
|
+
r"""
|
|
120
|
+
Natural cubic spline represented by basis functions :math:`N_i`:
|
|
121
|
+
|
|
122
|
+
.. math::
|
|
123
|
+
N_1(x) = x-k_1,
|
|
124
|
+
|
|
125
|
+
.. math::
|
|
126
|
+
N_i(x) = d_{i-1}(x)-d_{N-1}(x)
|
|
127
|
+
|
|
128
|
+
for :math:`i=2\ldots N-1`. Here :math:`k_1<k_2<\ldots <k_{N}`
|
|
129
|
+
are the knots. In total, we have :math:`N-1` basis functions, plus
|
|
130
|
+
a constant.
|
|
131
|
+
|
|
132
|
+
The result is a cubic spline linear function :math:`s(x)`
|
|
133
|
+
between the knots of the form:
|
|
134
|
+
|
|
135
|
+
.. math::
|
|
136
|
+
s(x)=c_0+\sum\limits_{i=1}^{N-1} c_i h_i(x),
|
|
137
|
+
|
|
138
|
+
where :math:`c_0` is a constant and the other :math:`c_i` are coefficients.
|
|
139
|
+
Note that, by construction, :math:`s(k_1)=c_0`. Also, by construction,
|
|
140
|
+
the spline is linear in the range outside of the knots.
|
|
141
|
+
|
|
142
|
+
The spline intended to be defined for :math:`k_1\leq x \leq k_N`. Outside
|
|
143
|
+
of this range, the function can be extrapolated by either:
|
|
144
|
+
|
|
145
|
+
* simply evaluating the basis functions, in which case it becomes linear;
|
|
146
|
+
* with the value :math:`s(k_0)=c_0` left of :math:`k_0`, and with the value :math:`s(k_N)` right of :math:`k_N`;
|
|
147
|
+
* with NaN.
|
|
148
|
+
|
|
149
|
+
Some notes on the interpretation of the coefficients:
|
|
150
|
+
|
|
151
|
+
* First coefficient represent the constant, if there is one.
|
|
152
|
+
* Next one is the coefficient of a linear function which is zero at the first knot
|
|
153
|
+
* Next ones are the spline basis functions for knots :math:`k_1` to :math:`k_{N-2}`
|
|
154
|
+
* Interval in between the last two knots is 'cleanup', there is no coefficient.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def _validate_knots_coeffs(self, knots, coeffs):
|
|
158
|
+
if knots is not None:
|
|
159
|
+
knots = np.asanyarray(knots)
|
|
160
|
+
assert len(knots) >= 3, "Must specify at least 3 knots"
|
|
161
|
+
if coeffs is not None:
|
|
162
|
+
coeffs = np.asanyarray(coeffs)
|
|
163
|
+
if coeffs is not None and knots is not None:
|
|
164
|
+
assert len(knots) == len(coeffs) or len(knots) - 1 == len(coeffs)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def _bi(self):
|
|
169
|
+
assert self.knots is not None
|
|
170
|
+
if not hasattr(self, "_bi_cache"):
|
|
171
|
+
if self.extrapolation_method == "nan":
|
|
172
|
+
kwargs = dict(xmin=self.knots[0], xmax=self.knots[-1], val=np.nan)
|
|
173
|
+
else:
|
|
174
|
+
kwargs = {}
|
|
175
|
+
self._bi_cache = [
|
|
176
|
+
NaturalCubicSplineBasisFunction(k, self.knots, **kwargs)
|
|
177
|
+
for k in range(1, self.n_knots)
|
|
178
|
+
]
|
|
179
|
+
return self._bi_cache
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def has_const(self):
|
|
183
|
+
return self.n_knots == self.n_coeffs
|
|
184
|
+
|
|
185
|
+
def prune_knots(self, method="isclose", tol=1e-6, coeffs_to_prune=None, **kwargs):
|
|
186
|
+
"""
|
|
187
|
+
Prunes knots based on criterion
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
method : string, optional
|
|
192
|
+
Method used to determine which knots to prune. The default is 'isclose'.
|
|
193
|
+
tol : float, optional
|
|
194
|
+
Tolerance, passed to method. The default is 1e-6.
|
|
195
|
+
kwargs : dictionary
|
|
196
|
+
Other keyword arguments passed to method.
|
|
197
|
+
"""
|
|
198
|
+
# Initialize
|
|
199
|
+
coeffs = np.copy(self.coeffs)
|
|
200
|
+
knots = np.copy(self.knots)
|
|
201
|
+
# Determine to prune
|
|
202
|
+
if method == "isclose":
|
|
203
|
+
kwargs.setdefault("atol", tol)
|
|
204
|
+
to_prune = np.isclose(coeffs, 0, **kwargs)
|
|
205
|
+
elif method == "coeffs":
|
|
206
|
+
assert len(coeffs_to_prune) == self.n_coeffs
|
|
207
|
+
to_prune = np.asanyarray(coeffs_to_prune)
|
|
208
|
+
else:
|
|
209
|
+
raise ValueError(f"Method {method} invalid")
|
|
210
|
+
# Prune
|
|
211
|
+
if len(to_prune) > 0:
|
|
212
|
+
try:
|
|
213
|
+
# Note first coeff can correspond to const, last knot has no coeff
|
|
214
|
+
# Never prune the first knot, and never prune the last knot
|
|
215
|
+
if self.has_const and to_prune[0]:
|
|
216
|
+
self.coeffs = self.coeffs[1:]
|
|
217
|
+
to_prune = to_prune[1:]
|
|
218
|
+
# Never prune the first knot
|
|
219
|
+
# And never prune the last two knots
|
|
220
|
+
if self.n_knots <= 3:
|
|
221
|
+
return
|
|
222
|
+
if self.has_const:
|
|
223
|
+
knots_to_prune = np.append(np.append(False, to_prune[3:]), [False, False])
|
|
224
|
+
coeffs_to_prune = np.append([False, False, False], to_prune[3:])
|
|
225
|
+
else:
|
|
226
|
+
knots_to_prune = np.append(np.append(False, to_prune[2:]), [False, False])
|
|
227
|
+
coeffs_to_prune = np.append([False, False], to_prune[2:])
|
|
228
|
+
self.coeffs = None
|
|
229
|
+
self.knots = None
|
|
230
|
+
self.coeffs = coeffs[~coeffs_to_prune]
|
|
231
|
+
self.knots = knots[~knots_to_prune]
|
|
232
|
+
except:
|
|
233
|
+
# Cleanup, don't leave the spline in an invalid state
|
|
234
|
+
self.knots = None
|
|
235
|
+
self.coeffs = None
|
|
236
|
+
self.knots = knots
|
|
237
|
+
self.coeffs = coeffs
|
|
238
|
+
raise
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
import functools as ft
|
|
6
|
+
import numpy as np
|
|
7
|
+
from statsmodels.tools.validation import PandasWrapper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Timer:
|
|
11
|
+
def __init__(self, msg="Running"):
|
|
12
|
+
self.msg = f"{msg}..."
|
|
13
|
+
self.t0 = 0
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
print(self.msg, end="")
|
|
17
|
+
self.t0 = time.time()
|
|
18
|
+
return self.t0
|
|
19
|
+
|
|
20
|
+
def __exit__(self, ex_type, ex_value, ex_traceback):
|
|
21
|
+
print(f" finished in {time.time() - self.t0:.2f} seconds")
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def type_wrapper(xloc=0):
|
|
26
|
+
def decorator(f):
|
|
27
|
+
@ft.wraps(f)
|
|
28
|
+
def wrapped(*args, **kwargs):
|
|
29
|
+
x = args[xloc]
|
|
30
|
+
wrapper = PandasWrapper(x)
|
|
31
|
+
args = tuple(np.asanyarray(x) if i == xloc else a for i, a in enumerate(args))
|
|
32
|
+
y = np.asanyarray(f(*args, **kwargs))
|
|
33
|
+
return y.tolist() if len(y.shape) == 0 else wrapper.wrap(y)
|
|
34
|
+
|
|
35
|
+
return wrapped
|
|
36
|
+
|
|
37
|
+
return decorator
|