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.
@@ -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,6 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ from .linear_spline import LinearSpline, HingeBasisFunction
5
+ from .natural_cubic_spline import NaturalCubicSpline, NaturalCubicSplineBasisFunction
6
+ from .util import Timer
@@ -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