From 6e71114fde8ab4b90f6918e5e883a9881ff55373 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 11 Jun 2024 18:01:16 -0400 Subject: [PATCH 001/225] Update Basis and TransformerBasis to be compatible with pipelining and CV --- src/nemos/basis.py | 99 ++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 2665d811..c8876e1d 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -12,7 +12,9 @@ from numpy.typing import ArrayLike, NDArray from pynapple import Tsd, TsdFrame from scipy.interpolate import splev +from sklearn.base import clone as sk_clone +from .base_class import Base from .convolve import create_convolutional_predictor from .type_casting import support_pynapple from .utils import row_wise_kron @@ -172,8 +174,37 @@ def fit_transform(self, X: FeatureMatrix, y=None) -> FeatureMatrix: """ return self._basis.compute_features(*self._unpack_inputs(X)) + def __getattr__(self, attr: str): + """ + Enable easy access to attributes of the underlying Basis object. + + Example + ------- + from nemos import basis + + bas = basis.RaisedCosineBasisLinear(5) + trans_bas = basis.TransformerBasis(bas) + print(bas.n_basis_funcs) + print(trans_bas.n_basis_funcs) + """ + return getattr(self._basis, attr) + + def __sklearn_clone__(self) -> TransformerBasis: + """ + By default scikit-learn calls copy.deepcopy on the estimator object. + Cloning the underlying Basis avoids infinite recursive calls to getattr. + """ + return TransformerBasis(sk_clone(self._basis)) + + def set_params(self, **parameters) -> TransformerBasis: + """ + Set the parameters of the underlying Basis. + """ + self._basis = self._basis.set_params(**parameters) + return self -class Basis(abc.ABC): + +class Basis(Base, abc.ABC): """ Abstract base class for defining basis functions for feature transformation. @@ -190,9 +221,6 @@ class Basis(abc.ABC): 'conv' for convolutional operation. window_size : The window size for convolution. Required if mode is 'conv'. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` **kwargs: Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -202,7 +230,6 @@ class Basis(abc.ABC): def __init__( self, n_basis_funcs: int, - *args, mode: Literal["eval", "conv"] = "eval", window_size: Optional[int] = None, **kwargs, @@ -210,7 +237,6 @@ def __init__( self.n_basis_funcs = n_basis_funcs self._n_input_dimensionality = 0 self._check_n_basis_min() - self._conv_args = args self._conv_kwargs = kwargs # check mode if mode not in ["conv", "eval"]: @@ -227,10 +253,6 @@ def __init__( f"`window_size` must be a positive integer. {window_size} provided instead!" ) else: - if args: - raise ValueError( - f"args should only be set when mode=='conv', but '{mode}' provided instead!" - ) if kwargs: raise ValueError( f"kwargs should only be set when mode=='conv', but '{mode}' provided instead!" @@ -347,7 +369,7 @@ def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: # convolve called at the end of any recursive call # this ensures that len(xi) == 1. conv = create_convolutional_predictor( - self._kernel, *xi, *self._conv_args, **self._conv_kwargs + self._kernel, *xi, **self._conv_kwargs ) # move the time axis to the first dimension new_axis = (np.arange(conv.ndim) + axis) % conv.ndim @@ -692,6 +714,12 @@ def __pow__(self, exponent: int) -> MultiplicativeBasis: result = result * self return result + def to_transformer(self) -> TransformerBasis: + """ + Turn the Basis into a TransformerBasis for use with scikit-learn. + """ + return TransformerBasis(self) + class AdditiveBasis(Basis): """ @@ -917,9 +945,6 @@ class SplineBasis(Basis, abc.ABC): ---------- n_basis_funcs : Number of basis functions. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -941,16 +966,13 @@ class SplineBasis(Basis, abc.ABC): def __init__( self, n_basis_funcs: int, - *args, mode="eval", order: int = 2, window_size: Optional[int] = None, **kwargs, ) -> None: self.order = order - super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, **kwargs - ) + super().__init__(n_basis_funcs, mode=mode, window_size=window_size, **kwargs) self._n_input_dimensionality = 1 if self.order < 1: raise ValueError("Spline order must be positive!") @@ -1045,9 +1067,6 @@ class MSplineBasis(SplineBasis): n_basis_funcs : The number of basis functions to generate. More basis functions allow for more flexible data modeling but can lead to overfitting. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1080,7 +1099,6 @@ class MSplineBasis(SplineBasis): def __init__( self, n_basis_funcs: int, - *args, mode="eval", order: int = 2, window_size: Optional[int] = None, @@ -1088,7 +1106,6 @@ def __init__( ) -> None: super().__init__( n_basis_funcs, - *args, mode=mode, order=order, window_size=window_size, @@ -1189,9 +1206,6 @@ class BSplineBasis(SplineBasis): ---------- n_basis_funcs : Number of basis functions. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1221,7 +1235,6 @@ class BSplineBasis(SplineBasis): def __init__( self, n_basis_funcs: int, - *args, mode="eval", order: int = 4, window_size: Optional[int] = None, @@ -1229,7 +1242,6 @@ def __init__( ): super().__init__( n_basis_funcs, - *args, mode=mode, order=order, window_size=window_size, @@ -1305,9 +1317,6 @@ class CyclicBSplineBasis(SplineBasis): ---------- n_basis_funcs : Number of basis functions. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1332,7 +1341,6 @@ class CyclicBSplineBasis(SplineBasis): def __init__( self, n_basis_funcs: int, - *args, mode="eval", order: int = 4, window_size: Optional[int] = None, @@ -1340,7 +1348,6 @@ def __init__( ): super().__init__( n_basis_funcs, - *args, mode=mode, order=order, window_size=window_size, @@ -1442,9 +1449,6 @@ class RaisedCosineBasisLinear(Basis): ---------- n_basis_funcs : The number of basis functions. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1467,24 +1471,25 @@ class RaisedCosineBasisLinear(Basis): def __init__( self, n_basis_funcs: int, - *args, mode="eval", width: float = 2.0, window_size: Optional[int] = None, **kwargs, ) -> None: - super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, **kwargs - ) + super().__init__(n_basis_funcs, mode=mode, window_size=window_size, **kwargs) self._n_input_dimensionality = 1 - self._check_width(width) - self._width = width + self.width = width @property def width(self): """Return width of the raised cosine.""" return self._width + @width.setter + def width(self, width: float): + self._check_width(width) + self._width = width + @staticmethod def _check_width(width: float) -> None: """Validate the width value. @@ -1616,9 +1621,6 @@ class RaisedCosineBasisLog(RaisedCosineBasisLinear): ---------- n_basis_funcs : The number of basis functions. - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1649,7 +1651,6 @@ class RaisedCosineBasisLog(RaisedCosineBasisLinear): def __init__( self, n_basis_funcs: int, - *args, mode="eval", width: float = 2.0, time_scaling: float = None, @@ -1659,7 +1660,6 @@ def __init__( ) -> None: super().__init__( n_basis_funcs, - *args, mode=mode, width=width, window_size=window_size, @@ -1767,9 +1767,6 @@ class OrthExponentialBasis(Basis): Number of basis functions. decay_rates : Decay rates of the exponentials, shape (n_basis_funcs,). - *args: - Only used in "conv" mode. Additional positional arguments that are passed to - `nemos.convolve.create_convolutional_predictor` mode : The mode of operation. 'eval' for evaluation at sample points, 'conv' for convolutional operation. @@ -1784,14 +1781,12 @@ def __init__( self, n_basis_funcs: int, decay_rates: NDArray[np.floating], - *args, mode="eval", window_size: Optional[int] = None, **kwargs, ): super().__init__( n_basis_funcs, - *args, mode=mode, window_size=window_size, **kwargs, From 2b4d61bb71a8932a412aa38b3a8a7ffb6edd8e2f Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 11 Jun 2024 18:01:55 -0400 Subject: [PATCH 002/225] Add some and remove one test for Basis and TransformerBasis --- tests/test_basis.py | 105 ++++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 27 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 2df2db5d..d0d27a23 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -9,6 +9,7 @@ import sklearn.pipeline as pipeline import statsmodels.api as sm import utils_testing +from sklearn.model_selection import GridSearchCV import nemos.basis as basis import nemos.convolve as convolve @@ -514,10 +515,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') @@ -913,10 +910,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - 1 - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') @@ -1297,10 +1290,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - 1 - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') @@ -1760,10 +1749,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, [1,2,3,4,5], 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, decay_rates=[1,2,3,4,5], mode='eval', test='hi') @@ -2165,10 +2150,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - 1 - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') @@ -2588,10 +2569,6 @@ def test_identifiability_constraint_apply(self): assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) assert X.shape[1] == bas.n_basis_funcs - 1 - def test_conv_args_error(self): - with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') - def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') @@ -3925,6 +3902,60 @@ def test_power_of_basis(exponent, basis_class): ) +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_basis_to_transformer(basis_cls): + n_basis_funcs = 5 + bas = basis_cls(n_basis_funcs) + assert isinstance(bas.to_transformer(), basis.TransformerBasis) + assert bas.to_transformer().n_basis_funcs == bas.n_basis_funcs + + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +@pytest.mark.parametrize("n_basis_funcs", [5, 10, 20]) +def test_transformerbasis_getattr(basis_cls, n_basis_funcs): + trans_basis = basis.TransformerBasis(basis_cls(n_basis_funcs)) + assert trans_basis.n_basis_funcs == n_basis_funcs + + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +@pytest.mark.parametrize("n_basis_funcs_init", [5]) +@pytest.mark.parametrize("n_basis_funcs_new", [6, 10, 20]) +def test_transformerbasis_set_params(basis_cls, n_basis_funcs_init, n_basis_funcs_new): + trans_basis = basis.TransformerBasis(basis_cls(n_basis_funcs_init)) + trans_basis.set_params(n_basis_funcs = n_basis_funcs_new) + + assert trans_basis.n_basis_funcs == n_basis_funcs_new + assert trans_basis._basis.n_basis_funcs == n_basis_funcs_new + + + @pytest.mark.parametrize( "bas", [ @@ -3933,9 +3964,7 @@ def test_power_of_basis(exponent, basis_class): basis.CyclicBSplineBasis(5), basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), basis.RaisedCosineBasisLinear(5), - basis.RaisedCosineBasisLog(5), - basis.RaisedCosineBasisLog(5) + basis.MSplineBasis(5), - ], + ] ) def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): X, y, model, _, _ = poissonGLM_model_instantiation @@ -3945,6 +3974,28 @@ def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): pipe.fit(X[:, : bas._basis._n_input_dimensionality] ** 2, y) +@pytest.mark.parametrize( + "bas", + [ + basis.MSplineBasis(5), + basis.BSplineBasis(5), + basis.CyclicBSplineBasis(5), + basis.RaisedCosineBasisLinear(5), + basis.RaisedCosineBasisLog(5), + ], +) +def test_sklearn_transformer_pipeline_cv(bas, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas) + pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) + + param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) + + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) + + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + + @pytest.mark.parametrize( "bas, expected_nans", [ From 616af074145adb49fce0dd93726cd006163e8695 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 11 Jun 2024 18:02:38 -0400 Subject: [PATCH 003/225] Add documentation page for pipelining and CV --- docs/api_guide/_plotting_helpers.py | 178 +++++++++++++++++ .../plot_05_sklearn_pipeline_cv_demo.py | 188 ++++++++++++++++++ docs/gallery_conf.py | 10 +- 3 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 docs/api_guide/_plotting_helpers.py create mode 100644 docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py diff --git a/docs/api_guide/_plotting_helpers.py b/docs/api_guide/_plotting_helpers.py new file mode 100644 index 00000000..87a6fc93 --- /dev/null +++ b/docs/api_guide/_plotting_helpers.py @@ -0,0 +1,178 @@ +""" +Helper functions to not have to add seaborn as a dependency just for the annotated heatmap and despine + +Adapted from: https://matplotlib.org/stable/gallery/images_contours_and_fields/image_annotated_heatmap.html#using-the-helper-function-code-style +""" + +import matplotlib +import matplotlib.pyplot as plt +import numpy as np + + +def heatmap( + data, + row_labels, + col_labels, + ax=None, + colorbar: bool = False, + cbar_kw=None, + cbarlabel="", + **kwargs, +): + """ + Create a heatmap from a numpy array and two lists of labels. + + Parameters + ---------- + data + A 2D numpy array of shape (M, N). + row_labels + A list or array of length M with the labels for the rows. + col_labels + A list or array of length N with the labels for the columns. + ax + A `matplotlib.axes.Axes` instance to which the heatmap is plotted. If + not provided, use current Axes or create a new one. Optional. + cbar_kw + A dictionary with arguments to `matplotlib.Figure.colorbar`. Optional. + cbarlabel + The label for the colorbar. Optional. + **kwargs + All other arguments are forwarded to `imshow`. + """ + + if ax is None: + ax = plt.gca() + + if cbar_kw is None: + cbar_kw = {} + + # Plot the heatmap + im = ax.imshow(data, **kwargs) + + # Create colorbar + if colorbar: + cbar = ax.figure.colorbar(im, ax=ax, **cbar_kw) + cbar.ax.set_ylabel(cbarlabel, rotation=-90, va="bottom") + + # Show all ticks and label them with the respective list entries. + ax.set_xticks(np.arange(data.shape[1]), labels=col_labels) + ax.set_yticks(np.arange(data.shape[0]), labels=row_labels) + + # Let the horizontal axes labeling appear on top. + ax.tick_params(top=True, bottom=False, labeltop=True, labelbottom=False) + + # Rotate the tick labels and set their alignment. + plt.setp(ax.get_xticklabels(), rotation=-30, ha="right", rotation_mode="anchor") + + # Turn spines off and create white grid. + ax.spines[:].set_visible(False) + + ax.set_xticks(np.arange(data.shape[1] + 1) - 0.5, minor=True) + ax.set_yticks(np.arange(data.shape[0] + 1) - 0.5, minor=True) + ax.grid(which="minor", color="w", linestyle="-", linewidth=3) + ax.tick_params(which="minor", bottom=False, left=False) + + return im + + +def annotate_heatmap( + im, + data=None, + valfmt="{x:.2f}", + textcolors=("white", "black"), + threshold=None, + **textkw, +): + """ + A function to annotate a heatmap. + + Parameters + ---------- + im + The AxesImage to be labeled. + data + Data used to annotate. If None, the image's data is used. Optional. + valfmt + The format of the annotations inside the heatmap. This should either + use the string format method, e.g. "$ {x:.2f}", or be a + `matplotlib.ticker.Formatter`. Optional. + textcolors + A pair of colors. The first is used for values below a threshold, + the second for those above. Optional. + threshold + Value in data units according to which the colors from textcolors are + applied. If None (the default) uses the middle of the colormap as + separation. Optional. + **kwargs + All other arguments are forwarded to each call to `text` used to create + the text labels. + """ + + if not isinstance(data, (list, np.ndarray)): + data = im.get_array() + + # Normalize the threshold to the images color range. + if threshold is not None: + threshold = im.norm(threshold) + else: + threshold = im.norm(data.max()) / 2.0 + + # Set default alignment to center, but allow it to be + # overwritten by textkw. + kw = dict(horizontalalignment="center", verticalalignment="center") + kw.update(textkw) + + # Get the formatter in case a string is supplied + if isinstance(valfmt, str): + valfmt = matplotlib.ticker.StrMethodFormatter(valfmt) + + # Loop over the data and create a `Text` for each "pixel". + # Change the text's color depending on the data. + texts = [] + for i in range(data.shape[0]): + for j in range(data.shape[1]): + kw.update(color=textcolors[int(im.norm(data[i, j]) > threshold)]) + text = im.axes.text(j, i, valfmt(data[i, j], None), **kw) + texts.append(text) + + return texts + + +def despine(ax=None, top=True, right=True, left=False, bottom=False): + """ + Remove the specified spines from the plot. + + Parameters: + ax (matplotlib.axes.Axes): The axes to despine. Defaults to current axes. + top (bool): Whether to remove the top spine. + right (bool): Whether to remove the right spine. + left (bool): Whether to remove the left spine. + bottom (bool): Whether to remove the bottom spine. + """ + if ax is None: + ax = plt.gca() + + if top: + ax.spines["top"].set_visible(False) + if right: + ax.spines["right"].set_visible(False) + if left: + ax.spines["left"].set_visible(False) + if bottom: + ax.spines["bottom"].set_visible(False) + + # Optionally, adjust ticks to be displayed only on the remaining spines + if top and bottom: + ax.xaxis.set_ticks_position("none") + elif top: + ax.xaxis.set_ticks_position("bottom") + elif bottom: + ax.xaxis.set_ticks_position("top") + + if right and left: + ax.yaxis.set_ticks_position("none") + elif right: + ax.yaxis.set_ticks_position("left") + elif left: + ax.yaxis.set_ticks_position("right") diff --git a/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py new file mode 100644 index 00000000..37cb5dab --- /dev/null +++ b/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py @@ -0,0 +1,188 @@ +""" +# Pipelining and cross-validation with scikit-learn + +In this demo we will show how to combine basis transformations and GLMs using scikit-learn's pipelines, and demonstrate how cross-validation can be used to fine-tune parameters of each step of the pipeline. +""" + +# %% +# ## Let's start by some imports and creating toy data +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +# %% +from _plotting_helpers import annotate_heatmap, despine, heatmap +from sklearn.model_selection import GridSearchCV +from sklearn.pipeline import Pipeline + +# %% +import nemos as nmo + +# %% + +# predictors, shape (n_samples, n_features) +X = np.random.uniform(low=0, high=1, size=(1000, 1)) +# true coefficients, shape (n_features) +coef = np.array([5]) +# observed counts, shape (n_samples, ) +y = np.random.poisson(np.exp(np.matmul(X, coef))).astype(float) + +# %% +fig, ax = plt.subplots() +ax.scatter(X.flatten(), y, alpha=0.2) +ax.set_xlabel("input") +ax.set_ylabel("spike count") +despine(ax=ax) + +# %% +# ## Combining basis transformations and GLM in a pipeline + +# %% +# ### `TransformerBasis` class +# The `TransformerBasis` wrapper class provides an API consistent with [scikit-learn's data transforms](https://scikit-learn.org/stable/data_transforms.html), allowing its use in pipelines as a transformation step. +# +# Instantiating a `TransformerBasis` can be done using the constructor directly or with `Basis.to_transformer()` and provides convenient access to the underlying `Basis` object's attributes: + +# %% +bas = nmo.basis.RaisedCosineBasisLinear(5) +trans_bas_a = nmo.basis.TransformerBasis(bas) +trans_bas_b = bas.to_transformer() + +print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) + +# %% +# ### Creating and fitting a pipeline +# Use this wrapper class to create a pipeline that first transforms the data, then fits a GLM on the transformed data: + +# %% +pipeline = Pipeline( + [ + ( + "transformerbasis", + nmo.basis.TransformerBasis(nmo.basis.RaisedCosineBasisLinear(20)), + ), + ( + "glm", + nmo.glm.GLM(regularizer=nmo.regularizer.Ridge(regularizer_strength=1.0)), + ), + ] +) + +pipeline.fit(X, y) + +# %% +# ### Visualize the fit: + +# %% +fig, ax = plt.subplots() + +ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") +ax.set_xlabel("input") +ax.set_ylabel("spike count") + +x = np.sort(X.flatten()) +ax.plot( + x, + pipeline.predict(np.expand_dims(x, 1)), + label="predicted rate", + color="tab:orange", +) + +ax.legend() +despine(ax=ax) + +# %% +# There is some room for improvement, so in the next section we'll use cross-validation to tune the parameters of our pipeline. + +# %% +# ## Hyperparameter-tuning with scikit-learn's gridsearch + +# %% +# !!! warning +# Please keep in mind that while `GLM.score` supports different ways of evaluating goodness-of-fit through the `score_type` argument, `pipeline.score(X, y, score_type="...")` does not propagate this, and uses the default value of `log-likelihood`. +# +# To evaluate a pipeline, please create a custom scorer (e.g. `pseudo_r2` below) and call `my_custom_scorer(pipeline, X, y)`. + +# %% +# ### Define parameter grid +# Let's define candidate values for the parameters of each step of the pipeline we want to cross-validate. In this case the number of basis functions in the transformation step and the ridge regularization's strength in the GLM fit: + +# %% +param_grid = dict( + glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), +) + +# %% +# ### Create custom scorer +# Create a custom scorer to evaluate models using the pseudo-R2 score instead of the log-likelihood which is the default in `GLM.score`: + +# %% +from sklearn.metrics import make_scorer + +# NOTE: the order of the arguments is reversed +pseudo_r2 = make_scorer( + lambda y_true, y_pred: nmo.observation_models.PoissonObservations().pseudo_r2( + y_pred, y_true + ) +) + +# %% +# ### Run the grid search: + +# %% +gridsearch = GridSearchCV(pipeline, param_grid=param_grid, cv=5, scoring=pseudo_r2) + +gridsearch.fit(X, y) + +# %% +# ### Visualize the scores +# Let's take a look at the scores to see how the different parameter values influence the test score: + +# %% +cvdf = pd.DataFrame(gridsearch.cv_results_) + + +fig, ax = plt.subplots() + +im = heatmap( + cvdf.pivot( + index="param_transformerbasis__n_basis_funcs", + columns="param_glm__regularizer__regularizer_strength", + values="mean_test_score", + ), + param_grid["transformerbasis__n_basis_funcs"], + param_grid["glm__regularizer__regularizer_strength"][ + ::-1 + ], # note the reverse order + ax=ax, +) +texts = annotate_heatmap(im, valfmt="{x:.3f}") +ax.set_xlabel("ridge regularization strength") +ax.set_ylabel("number of basis functions") + +fig.tight_layout() + +# %% +# ### Visualize the improved predictions +# Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: + +# %% +fig, ax = plt.subplots() + +ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") +ax.set_xlabel("input") +ax.set_ylabel("spike count") + +x = np.sort(X.flatten()) +ax.plot( + x, + gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), + label="predicted rate", + color="tab:orange", +) + +ax.legend() +despine(ax=ax) + +# %% diff --git a/docs/gallery_conf.py b/docs/gallery_conf.py index 8297ab4e..ca01af54 100644 --- a/docs/gallery_conf.py +++ b/docs/gallery_conf.py @@ -5,7 +5,7 @@ from mkdocs_gallery.sorting import FileNameSortKey min_reported_time = 0 -if 'SOURCE_DATE_EPOCH' in os.environ: +if "SOURCE_DATE_EPOCH" in os.environ: min_reported_time = sys.maxint if sys.version_info[0] == 2 else sys.maxsize # To be used as the "base" config, @@ -13,11 +13,11 @@ # of configuration options see https://sphinx-gallery.github.io/stable/configuration.html conf = { # report runtime if larger than this value - 'min_reported_time': min_reported_time, + "min_reported_time": min_reported_time, # order your section in file name alphabetical order - 'within_subsection_order': FileNameSortKey, + "within_subsection_order": FileNameSortKey, # run every script that matches pattern # (here we match every file that ends in .py) - 'filename_pattern': re.escape(os.sep) + r"plot_.+\.py$", - 'ignore_pattern': r"_plot_.+\.py$" + "filename_pattern": re.escape(os.sep) + r"plot_.+\.py$", + "ignore_pattern": r"(_plot_.+\.py$|_helpers\.py$)", } From 7b1c727f8a41e8ca81952858a64cf3f14260f465 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 8 Jul 2024 17:41:34 -0400 Subject: [PATCH 004/225] start refactoring optim/regularizer --- src/nemos/base_class.py | 126 ++++++++++++++++++++++++++++------------ src/nemos/glm.py | 7 ++- 2 files changed, 95 insertions(+), 38 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 5c47e09f..49333cd6 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -16,9 +16,17 @@ from . import validation from .pytrees import FeaturePytree +from .regularizer import Regularizer, UnRegularized, Ridge, GroupLasso, Lasso DESIGN_INPUT_TYPE = Union[jnp.ndarray, FeaturePytree] +REGULARIZERS = { + "unregularized": UnRegularized, + "ridge": Ridge, + "lasso": Lasso, + "group_lasso": GroupLasso, +} + class Base: """Base class for NeMoS estimators. @@ -105,9 +113,9 @@ def set_params(self, **params: Any): # but only if the user did not explicitly set a value for # "base_estimator". if ( - key == "base_estimator" - and valid_params[key] == "deprecated" - and self.__module__.startswith("sklearn.") + key == "base_estimator" + and valid_params[key] == "deprecated" + and self.__module__.startswith("sklearn.") ): warnings.warn( ( @@ -178,8 +186,54 @@ class BaseRegressor(Base, abc.ABC): - [`GLM`](../glm/#nemos.glm.GLM): A feed-forward GLM implementation. - [`GLMRecurrent`](../glm/#nemos.glm.GLMRecurrent): A recurrent GLM implementation. + + Attributes + ---------- + regularizer : + Optional string or Regularizer class that defines the regularizer for the regression model + solver : + Optional string that defines the optimizer/solver for the regression model + + NOTE: Please see the table below for details on regularizer/optimizer pairings: + # TODO: insert nice table that shows the available optimizers for each regularizer """ + def __init__( + self, + regularizer: str | Regularizer = "unregularized", + solver: str = None + ): + # parse the regularizer and solver options + self._parse_regularizer_optimizer_params(self, regularizer=regularizer, solver=solver) + + @staticmethod + def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver: str): + """Parse the regularizer and solver parameters.""" + # check if solver is in the regularizers allowed solvers + if not isinstance(regularizer, str): + # check if solver is in allowed solvers list + if solver not in regularizer._allowed_solvers: + raise ValueError(f"The solver: {solver} is not a valid solver type for the regularizer: " + f"{regularizer.__class__.__name__}. Please use one of {regularizer._allowed_solvers}.") + # store regularizer + self._regularizer = regularizer + # store solver + self.solver = solver + else: + # check if regularizer str passed is even valid + if regularizer not in REGULARIZERS.keys(): + raise KeyError(f"The regularizer: {regularizer} is not a valid regularizer type." + f" Please use one of {REGULARIZERS.keys()}.") + # check if solver is valid + if solver not in REGULARIZERS[regularizer]._allowed_solvers: + raise ValueError(f"The solver: {solver} is not a valid solver type for the regularizer: " + f"{REGULARIZERS[regularizer].__class__.__name__}. Please use one of " + f"{REGULARIZERS[regularizer]._allowed_solvers}.") + # store solver + self.solver = solver + # instantiate regularizer and store + self._regularizer = REGULARIZERS[regularizer]() + @abc.abstractmethod def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): """Fit the model to neural activity.""" @@ -192,20 +246,20 @@ def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: @abc.abstractmethod def score( - self, - X: DESIGN_INPUT_TYPE, - y: Union[NDArray, jnp.ndarray], - # may include score_type or other additional model dependent kwargs - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: Union[NDArray, jnp.ndarray], + # may include score_type or other additional model dependent kwargs + **kwargs, ) -> jnp.ndarray: """Score the predicted firing rates (based on fit) to the target neural activity.""" pass @abc.abstractmethod def simulate( - self, - random_key: jax.Array, - feed_forward_input: DESIGN_INPUT_TYPE, + self, + random_key: jax.Array, + feed_forward_input: DESIGN_INPUT_TYPE, ): """Simulate neural activity in response to a feed-forward input and recurrent activity.""" pass @@ -213,8 +267,8 @@ def simulate( @staticmethod @abc.abstractmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -229,8 +283,8 @@ def _check_params( @staticmethod @abc.abstractmethod def _check_input_dimensionality( - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): pass @@ -247,9 +301,9 @@ def _set_coef_and_intercept(self, params: Any): @staticmethod @abc.abstractmethod def _check_input_and_params_consistency( - params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features in model parameters and input arguments. @@ -264,7 +318,7 @@ def _check_input_and_params_consistency( @staticmethod def _check_input_n_timepoints( - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray ): if y.shape[0] != X.shape[0]: raise ValueError( @@ -274,10 +328,10 @@ def _check_input_n_timepoints( ) def _validate( - self, - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], - y: Union[NDArray, jnp.ndarray], - init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], + self, + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], + y: Union[NDArray, jnp.ndarray], + init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], ): # check input dimensionality self._check_input_dimensionality(X, y) @@ -294,25 +348,25 @@ def _validate( @abc.abstractmethod def update( - self, - params: Tuple[jnp.ndarray, jnp.ndarray], - opt_state: NamedTuple, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - **kwargs, + self, + params: Tuple[jnp.ndarray, jnp.ndarray], + opt_state: NamedTuple, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + **kwargs, ) -> OptStep: """Run a single update step of the jaxopt solver.""" pass @abc.abstractmethod def initialize_solver( - self, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - params: Optional = None, - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + params: Optional = None, + **kwargs, ) -> Tuple[Any, NamedTuple]: """Initialize the solver's state and optionally sets initial model parameters for the optimization.""" pass diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 9d0b7857..4fc96f87 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -85,11 +85,14 @@ def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), regularizer: reg.Regularizer = reg.UnRegularized("GradientDescent"), + solver: str = None ): - super().__init__() + super().__init__( + regularizer=regularizer, + solver=solver + ) self.observation_model = observation_model - self.regularizer = regularizer # initialize to None fit output self.intercept_ = None From c4e95f9b3104fbcf7bb81725df9775e462fd8525 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 9 Jul 2024 12:01:19 -0400 Subject: [PATCH 005/225] fix solver/regularizer parsing --- src/nemos/_regularizer_builder.py | 38 ++++++++++++ src/nemos/base_class.py | 98 ++++++++++++++++--------------- src/nemos/glm.py | 28 +++++---- src/nemos/regularizer.py | 11 ++++ 4 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 src/nemos/_regularizer_builder.py diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py new file mode 100644 index 00000000..cde32398 --- /dev/null +++ b/src/nemos/_regularizer_builder.py @@ -0,0 +1,38 @@ +"""Utility functions for creating regularizer object.""" + +AVAILABLE_REGULARIZERS: [ + "unregularized", + "ridge", + "lasso", + "group_lasso" +] + + +def create_regularizer(name: str): + """ + Create a regularizer from a given name. + + Parameters + ---------- + name : + The string name of the regularizer to create. Must be one of: 'unregularized', 'ridge', 'lasso', 'group_lasso'. + """ + match name: + case "unregularized": + from .regularizer import UnRegularized + return UnRegularized() + case "ridge": + from .regularizer import Ridge + return Ridge() + case "lasso": + from .regularizer import Lasso + return Lasso() + # case "group_lasso": + # from regularizer import GroupLasso + # return GroupLasso() + + raise ValueError(f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}") + + + + diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 49333cd6..78ee7313 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -7,7 +7,7 @@ import inspect import warnings from collections import defaultdict -from typing import Any, NamedTuple, Optional, Tuple, Union +from typing import Any, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING import jax import jax.numpy as jnp @@ -16,16 +16,12 @@ from . import validation from .pytrees import FeaturePytree -from .regularizer import Regularizer, UnRegularized, Ridge, GroupLasso, Lasso +from ._regularizer_builder import create_regularizer DESIGN_INPUT_TYPE = Union[jnp.ndarray, FeaturePytree] -REGULARIZERS = { - "unregularized": UnRegularized, - "ridge": Ridge, - "lasso": Lasso, - "group_lasso": GroupLasso, -} +if TYPE_CHECKING: + from regularizer import Regularizer class Base: @@ -180,22 +176,26 @@ class BaseRegressor(Base, abc.ABC): scoring the model, simulating responses, and preprocessing data. Concrete classes are expected to provide specific implementations of the abstract methods defined here. + Parameters + ---------- + regularizer : + Regularization to use for model optimization. Defines the regularization scheme + and related parameters. + Default is UnRegularized regression. + solver : + Solver to use for model optimization. Defines the optimization scheme and related parameters. + The solver must be an appropriate match for the chosen regularizer. Please see table below for + regularizer/optimizer pairings. + Default is `None`. If no solver specified, one will be chosen based on the regularizer. + + # TODO: insert nice table that shows the available optimizers for each regularizer + See Also -------- Concrete models: - [`GLM`](../glm/#nemos.glm.GLM): A feed-forward GLM implementation. - [`GLMRecurrent`](../glm/#nemos.glm.GLMRecurrent): A recurrent GLM implementation. - - Attributes - ---------- - regularizer : - Optional string or Regularizer class that defines the regularizer for the regression model - solver : - Optional string that defines the optimizer/solver for the regression model - - NOTE: Please see the table below for details on regularizer/optimizer pairings: - # TODO: insert nice table that shows the available optimizers for each regularizer """ def __init__( @@ -203,36 +203,7 @@ def __init__( regularizer: str | Regularizer = "unregularized", solver: str = None ): - # parse the regularizer and solver options - self._parse_regularizer_optimizer_params(self, regularizer=regularizer, solver=solver) - - @staticmethod - def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver: str): - """Parse the regularizer and solver parameters.""" - # check if solver is in the regularizers allowed solvers - if not isinstance(regularizer, str): - # check if solver is in allowed solvers list - if solver not in regularizer._allowed_solvers: - raise ValueError(f"The solver: {solver} is not a valid solver type for the regularizer: " - f"{regularizer.__class__.__name__}. Please use one of {regularizer._allowed_solvers}.") - # store regularizer - self._regularizer = regularizer - # store solver - self.solver = solver - else: - # check if regularizer str passed is even valid - if regularizer not in REGULARIZERS.keys(): - raise KeyError(f"The regularizer: {regularizer} is not a valid regularizer type." - f" Please use one of {REGULARIZERS.keys()}.") - # check if solver is valid - if solver not in REGULARIZERS[regularizer]._allowed_solvers: - raise ValueError(f"The solver: {solver} is not a valid solver type for the regularizer: " - f"{REGULARIZERS[regularizer].__class__.__name__}. Please use one of " - f"{REGULARIZERS[regularizer]._allowed_solvers}.") - # store solver - self.solver = solver - # instantiate regularizer and store - self._regularizer = REGULARIZERS[regularizer]() + self._parse_regularizer_optimizer_params(regularizer=regularizer, solver=solver) @abc.abstractmethod def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): @@ -264,6 +235,37 @@ def simulate( """Simulate neural activity in response to a feed-forward input and recurrent activity.""" pass + def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver: str): + """Parse the regularizer and solver parameters.""" + # check if solver is in the regularizers allowed solvers + if not isinstance(regularizer, str): + # if no solver passed, use default for given regularizer + if solver is None: + self._solver = regularizer.default_solver + # check if solver is in allowed solvers list + if solver not in regularizer.allowed_solvers: + raise ValueError(f"The solver: {solver} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}.") + # store regularizer + self._regularizer = regularizer + # store solver + self._solver = solver + else: + # try to instantiate solver + self._regularizer = create_regularizer(name=regularizer) + + # if no solver passed, use default for given regularizer + if solver is None: + solver = self._regularizer.default_solver + # check if solver str passed is valid for regularizer + if solver not in self._regularizer.allowed_solvers: + raise ValueError(f"The solver: {solver} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}.") + # store solver + self._solver = solver + @staticmethod @abc.abstractmethod def _check_params( diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 4fc96f87..7ce6263b 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -56,10 +56,6 @@ class GLM(BaseRegressor): observation_model : Observation model to use. The model describes the distribution of the neural activity. Default is the Poisson model. - regularizer : - Regularization to use for model optimization. Defines the regularization scheme, the optimization algorithm, - and related parameters. - Default is UnRegularized regression with gradient descent. Attributes ---------- @@ -75,6 +71,7 @@ class GLM(BaseRegressor): $\text{Var} \left( y \right) = \Phi V(\mu)$. This parameter, together with the estimate of the mean $\mu$ fully specifies the distribution of the activity $y$. + Raises ------ TypeError @@ -84,13 +81,9 @@ class GLM(BaseRegressor): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: reg.Regularizer = reg.UnRegularized("GradientDescent"), - solver: str = None + **kwargs ): - super().__init__( - regularizer=regularizer, - solver=solver - ) + super().__init__(**kwargs) self.observation_model = observation_model @@ -109,8 +102,11 @@ def regularizer(self) -> Union[None, reg.Regularizer]: return self._regularizer @regularizer.setter - def regularizer(self, regularizer: reg.Regularizer): + def regularizer(self, regularizer: str | reg.Regularizer): """Setter for the regularizer attribute.""" + # TODO: allow passing str here and then check solver match + super()._parse_regularizer_optimizer_params(regularizer=regularizer, solver=self.solver) + if not hasattr(regularizer, "instantiate_solver"): raise AttributeError( "The provided `solver` doesn't implement the `instantiate_solver` method." @@ -123,7 +119,15 @@ def regularizer(self, regularizer: reg.Regularizer): "The provided `solver` cannot be instantiated on " "the GLM log-likelihood." ) - self._regularizer = regularizer + + @property + def solver(self) -> str: + return self._solver + + @solver.setter + def solver(self, solver: str): + # check if solver/regularizer pairing valid + self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver=solver) @property def observation_model(self) -> Union[None, obs.Observations]: diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index e9b8b1b6..9bf25e35 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -85,6 +85,7 @@ class Regularizer(Base, abc.ABC): """ _allowed_solvers: Tuple[str] = tuple() + _default_solver: str = None def __init__( self, @@ -102,6 +103,10 @@ def __init__( def allowed_solvers(self): return self._allowed_solvers + @property + def default_solver(self): + return self._default_solver + @property def solver_name(self): return self._solver_name @@ -279,6 +284,8 @@ class are defined in the `allowed_solvers` attribute. "LBFGSB", ) + _default_solver = "GradientDescent" + def __init__( self, solver_name: str = "GradientDescent", solver_kwargs: Optional[dict] = None ): @@ -308,6 +315,8 @@ class Ridge(Regularizer): "LBFGSB", ) + _default_solver = "GradientDescent" + def __init__( self, solver_name: str = "GradientDescent", @@ -403,6 +412,8 @@ class ProxGradientRegularizer(Regularizer, abc.ABC): _allowed_solvers = ("ProximalGradient",) + _default_solver = "ProximalGradient" + def __init__( self, solver_name: str, From 2b374b9289b42968d3f6aa10700b90f7fee4ceb4 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 9 Jul 2024 14:45:09 -0400 Subject: [PATCH 006/225] added vmin, vmax option for rescaling --- src/nemos/basis.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 747493bd..e2f8ff20 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -58,13 +58,19 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): return wrapper -def min_max_rescale_samples(sample_pts: NDArray) -> NDArray: +def min_max_rescale_samples(sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None) -> NDArray: """Rescale samples to [0,1].""" + sample_pts = sample_pts.astype(float) + if vmin and vmax and vmax <= vmin: + raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") if np.any(sample_pts < 0) or np.any(sample_pts > 1): - sample_pts -= np.min(sample_pts) - sample_pts /= np.max(sample_pts) + vmin = np.min(sample_pts) if vmin is None else vmin + vmax = np.max(sample_pts) if vmax is None else vmax + sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan + sample_pts -= vmin + sample_pts /= (vmax - vmin) warnings.warn( - "Rescaling sample points for RaisedCosine basis to [0,1]!", UserWarning + "Rescaling sample points to [0,1]!", UserWarning ) return sample_pts From cb34670c629d7c6f81e54b498a72b894ff96b9a1 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 9 Jul 2024 16:17:35 -0400 Subject: [PATCH 007/225] refactor regularizers --- src/nemos/_regularizer_builder.py | 6 +- src/nemos/base_class.py | 239 ++++++++++++++--- src/nemos/glm.py | 39 +-- src/nemos/regularizer.py | 412 ++++++++---------------------- 4 files changed, 329 insertions(+), 367 deletions(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index cde32398..38b4968c 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -27,9 +27,9 @@ def create_regularizer(name: str): case "lasso": from .regularizer import Lasso return Lasso() - # case "group_lasso": - # from regularizer import GroupLasso - # return GroupLasso() + case "group_lasso": + from regularizer import GroupLasso + return GroupLasso() raise ValueError(f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}") diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 78ee7313..b930b2e1 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -7,14 +7,14 @@ import inspect import warnings from collections import defaultdict -from typing import Any, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING +from typing import Any, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING, Callable import jax import jax.numpy as jnp -from jaxopt import OptStep +import jaxopt from numpy.typing import ArrayLike, NDArray -from . import validation +from . import validation, utils from .pytrees import FeaturePytree from ._regularizer_builder import create_regularizer @@ -23,6 +23,34 @@ if TYPE_CHECKING: from regularizer import Regularizer +SolverRun = Callable[ + [ + Any, # parameters, could be any pytree + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + jaxopt.OptStep, +] + +SolverInit = Callable[ + [ + Any, # parameters, could be any pytree + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + NamedTuple, +] + +SolverUpdate = Callable[ + [ + Any, # parameters, could be any pytree + NamedTuple, + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + jaxopt.OptStep, +] + class Base: """Base class for NeMoS estimators. @@ -182,13 +210,25 @@ class BaseRegressor(Base, abc.ABC): Regularization to use for model optimization. Defines the regularization scheme and related parameters. Default is UnRegularized regression. - solver : + solver_name : Solver to use for model optimization. Defines the optimization scheme and related parameters. - The solver must be an appropriate match for the chosen regularizer. Please see table below for - regularizer/optimizer pairings. + The solver must be an appropriate match for the chosen regularizer. Default is `None`. If no solver specified, one will be chosen based on the regularizer. - - # TODO: insert nice table that shows the available optimizers for each regularizer + Please see table below forregularizer/optimizer pairings. + + +---------------+------------------+-------------------------------------------------------------+ + | Regularizer | Default Solver | Available Solvers | + +===============+==================+=============================================================+ + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | + | | | ScipyBoundedMinimize, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | + | | | ScipyBoundedMinimize, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Lasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | GroupLasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ See Also -------- @@ -201,9 +241,155 @@ class BaseRegressor(Base, abc.ABC): def __init__( self, regularizer: str | Regularizer = "unregularized", - solver: str = None + solver_name: str = None, + solver_kwargs: Optional[dict] = None ): - self._parse_regularizer_optimizer_params(regularizer=regularizer, solver=solver) + self._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=solver_name) + + if solver_kwargs is None: + solver_kwargs = dict() + self.solver_kwargs = solver_kwargs + + @property + def solver_kwargs(self): + return self._solver_kwargs + + @solver_kwargs.setter + def solver_kwargs(self, solver_kwargs: dict): + self._check_solver_kwargs(self.solver_name, solver_kwargs) + self._solver_kwargs = solver_kwargs + + @staticmethod + def _check_solver_kwargs(solver_name, solver_kwargs): + """ + Check if provided solver keyword arguments are valid. + + Parameters + ---------- + solver_name : + Name of the solver. + solver_kwargs : + Additional keyword arguments for the solver. + + Raises + ------ + NameError + If any of the solver keyword arguments are not valid. + """ + solver_args = inspect.getfullargspec(getattr(jaxopt, solver_name)).args + undefined_kwargs = set(solver_kwargs.keys()).difference(solver_args) + if undefined_kwargs: + raise NameError( + f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" + ) + + def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver_name: str): + """Parse the regularizer and solver parameters.""" + # check if solver is in the regularizers allowed solvers + if isinstance(regularizer, str): + self._regularizer = create_regularizer(name=regularizer) + else: + self._regularizer = regularizer + + # if no solver passed, use default for given regularizer + if solver_name is None: + solver_name = self._regularizer.default_solver + # check if solver str passed is valid for regularizer + if solver_name not in self._regularizer.allowed_solvers: + raise ValueError(f"The solver: {solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}.") + # store solver + self._solver_name = solver_name + + def instantiate_solver( + self, + loss: Callable, + *args: Any, + prox: Optional[Callable] = None, + **kwargs: Any + ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: + """ + Instantiate the solver with the provided loss function. + + Instantiate the solver with the provided loss function, and return callable functions + that initialize the solver state, update the model parameters, and run the optimization. + + This method creates a solver instance from jaxopt library, tailored to the specific loss + function and regularization approach defined by the Regularizer instance. It also handles + the proximal operator if required for the optimization method. The returned functions are + directly usable in optimization loops, simplifying the syntax by pre-setting + common arguments like regularization strength and other hyperparameters. + + Parameters + ---------- + loss : + The loss function to be optimized. + + *args: + Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing + strength for proximal gradient methods. + + prox: + Optional, the proximal projection operator. + + *kwargs: + Keyword arguments for the jaxopt `solver.run` method. + + Returns + ------- + : + A tuple containing three callable functions: + - solver_init_state: Function to initialize the solver's state, necessary before starting the optimization. + - solver_update: Function to perform a single update step in the optimization process, + returning new parameters and state. + - solver_run: Function to execute the optimization process, applying multiple updates until a + stopping criterion is met. + """ + # check that the loss is Callable + utils.assert_is_callable(loss, "loss") + + # get the solver with given arguments. + # The "fun" argument is not always the first one, but it is always KEYWORD + # see jaxopt.EqualityConstrainedQP for example. The most general way is to pass it as keyword. + # The proximal gradient is added to the kwargs if passed. This avoids issues with over-writing + # the proximal operator. + if "prox" in self.solver_kwargs: + if prox is None: + raise ValueError( + f"Regularizer of type {self.__class__.__name__} " + f"does not require a proximal operator!" + ) + else: + warnings.warn( + "Overwritten the user-defined proximal operator! " + "There is only one valid proximal operator for each regularizer type.", + UserWarning, + ) + # update the kwargs if prox is passed + if prox is not None: + solver_kwargs = self.solver_kwargs.copy() + solver_kwargs.update(prox=prox) + else: + solver_kwargs = self.solver_kwargs + solver = getattr(jaxopt, self._solver_name)(fun=loss, **solver_kwargs) + + def solver_run( + init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray + ) -> jaxopt.OptStep: + return solver.run(init_params, *args, *run_args, **kwargs) + + def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: + return solver.update( + params, state, *args, *run_args, **kwargs, **run_kwargs + ) + + def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: + return solver.init_state( + params, state, *args, *run_args, **kwargs, **run_kwargs + ) + + return solver_init_state, solver_update, solver_run @abc.abstractmethod def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): @@ -235,37 +421,6 @@ def simulate( """Simulate neural activity in response to a feed-forward input and recurrent activity.""" pass - def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver: str): - """Parse the regularizer and solver parameters.""" - # check if solver is in the regularizers allowed solvers - if not isinstance(regularizer, str): - # if no solver passed, use default for given regularizer - if solver is None: - self._solver = regularizer.default_solver - # check if solver is in allowed solvers list - if solver not in regularizer.allowed_solvers: - raise ValueError(f"The solver: {solver} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}.") - # store regularizer - self._regularizer = regularizer - # store solver - self._solver = solver - else: - # try to instantiate solver - self._regularizer = create_regularizer(name=regularizer) - - # if no solver passed, use default for given regularizer - if solver is None: - solver = self._regularizer.default_solver - # check if solver str passed is valid for regularizer - if solver not in self._regularizer.allowed_solvers: - raise ValueError(f"The solver: {solver} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}.") - # store solver - self._solver = solver - @staticmethod @abc.abstractmethod def _check_params( @@ -357,7 +512,7 @@ def update( y: jnp.ndarray, *args, **kwargs, - ) -> OptStep: + ) -> jaxopt.OptStep: """Run a single update step of the jaxopt solver.""" pass diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 7ce6263b..aa28f597 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -15,7 +15,7 @@ from . import observation_models as obs from . import regularizer as reg from . import tree_utils, validation -from .base_class import DESIGN_INPUT_TYPE, BaseRegressor +from .base_class import DESIGN_INPUT_TYPE, BaseRegressor, SolverRun, SolverUpdate, SolverInit from .exceptions import NotFittedError from .pytrees import FeaturePytree from .type_casting import jnp_asarray_if, support_pynapple @@ -104,16 +104,17 @@ def regularizer(self) -> Union[None, reg.Regularizer]: @regularizer.setter def regularizer(self, regularizer: str | reg.Regularizer): """Setter for the regularizer attribute.""" - # TODO: allow passing str here and then check solver match - super()._parse_regularizer_optimizer_params(regularizer=regularizer, solver=self.solver) + # check if current solver and regularizer match is possible + # instantiate solver if necessary + super()._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=self.solver_name) - if not hasattr(regularizer, "instantiate_solver"): - raise AttributeError( - "The provided `solver` doesn't implement the `instantiate_solver` method." - ) # test solver instantiation on the GLM loss try: - regularizer.instantiate_solver(self._predict_and_compute_loss) + super().instantiate_solver( + self.regularizer.penalized_loss(loss=self._predict_and_compute_loss), + self.regularizer.regularizer_strength, + prox=self.regularizer._get_proximal_operator(), + ) except Exception: raise TypeError( "The provided `solver` cannot be instantiated on " @@ -121,13 +122,13 @@ def regularizer(self, regularizer: str | reg.Regularizer): ) @property - def solver(self) -> str: - return self._solver + def solver_name(self) -> str: + return self._solver_name - @solver.setter - def solver(self, solver: str): + @solver_name.setter + def solver_name(self, solver: str): # check if solver/regularizer pairing valid - self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver=solver) + self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver_name=solver) @property def observation_model(self) -> Union[None, obs.Observations]: @@ -142,7 +143,7 @@ def observation_model(self, observation: obs.Observations): self._observation_model = observation @property - def solver_init_state(self) -> Union[None, reg.SolverInit]: + def solver_init_state(self) -> Union[None, SolverInit]: """ Provides the initialization function for the solver's state. @@ -159,7 +160,7 @@ def solver_init_state(self) -> Union[None, reg.SolverInit]: return self._solver_init_state @property - def solver_update(self) -> Union[None, reg.SolverUpdate]: + def solver_update(self) -> Union[None, SolverUpdate]: """ Provides the function for updating the solver's state during the optimization process. @@ -177,7 +178,7 @@ def solver_update(self) -> Union[None, reg.SolverUpdate]: return self._solver_update @property - def solver_run(self) -> Union[None, reg.SolverRun]: + def solver_run(self) -> Union[None, SolverRun]: """ Provides the function to execute the solver's optimization process. @@ -894,7 +895,7 @@ def initialize_solver( self._validate(X, y, init_params) self._solver_init_state, self._solver_update, self._solver_run = ( - self.regularizer.instantiate_solver(self._predict_and_compute_loss) + super().instantiate_solver(self._predict_and_compute_loss) ) if isinstance(X, FeaturePytree): data = X.data @@ -1035,10 +1036,10 @@ class PopulationGLM(GLM): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: reg.Regularizer = reg.UnRegularized("GradientDescent"), feature_mask: Optional[jnp.ndarray] = None, + **kwargs ): - super().__init__(observation_model=observation_model, regularizer=regularizer) + super().__init__(observation_model=observation_model, **kwargs) self.feature_mask = feature_mask @property diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 9bf25e35..ad69c310 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -21,34 +21,6 @@ from .proximal_operator import prox_group_lasso from .pytrees import FeaturePytree -SolverRun = Callable[ - [ - Any, # parameters, could be any pytree - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - jaxopt.OptStep, -] - -SolverInit = Callable[ - [ - Any, # parameters, could be any pytree - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - NamedTuple, -] - -SolverUpdate = Callable[ - [ - Any, # parameters, could be any pytree - NamedTuple, - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - jaxopt.OptStep, -] - ProximalOperator = Callable[ [ Any, # parameters, could be any pytree @@ -58,7 +30,6 @@ Tuple[jnp.ndarray, jnp.ndarray], ] - __all__ = ["UnRegularized", "Ridge", "Lasso", "GroupLasso"] @@ -74,30 +45,30 @@ class Regularizer(Base, abc.ABC): enabling users to easily switch between different regularizers, ensuring compatibility with various loss functions and optimization algorithms. + Parameters + ---------- + regularization_strength + Float representing the strength of the regularization being applied. + Default 1.0. + Attributes ---------- allowed_solvers : Tuple of solver names that are allowed for use with this regularizer. - solver_name : - Name of the solver being used. - solver_kwargs : - Additional keyword arguments to be passed to the solver during instantiation. + default_solver : + String of the default solver name allowed for use with this regularizer. """ _allowed_solvers: Tuple[str] = tuple() _default_solver: str = None def __init__( - self, - solver_name: str, - solver_kwargs: Optional[dict] = None, - **kwargs, + self, + regularization_strength: float = 1.0, + **kwargs, ): super().__init__(**kwargs) - self.solver_name = solver_name - if solver_kwargs is None: - solver_kwargs = dict() - self.solver_kwargs = solver_kwargs + self.regularization_strength = regularization_strength @property def allowed_solvers(self): @@ -107,153 +78,37 @@ def allowed_solvers(self): def default_solver(self): return self._default_solver - @property - def solver_name(self): - return self._solver_name - - @solver_name.setter - def solver_name(self, solver_name: str): - self._check_solver(solver_name) - self._solver_name = solver_name - - @property - def solver_kwargs(self): - return self._solver_kwargs - - @solver_kwargs.setter - def solver_kwargs(self, solver_kwargs: dict): - self._check_solver_kwargs(self.solver_name, solver_kwargs) - self._solver_kwargs = solver_kwargs - - def _check_solver(self, solver_name: str): - """ - Ensure the provided solver name is allowed. - - Parameters - ---------- - solver_name : - Name of the solver to be checked. - - Raises - ------ - ValueError - If the provided solver name is not in the list of allowed optimizers. - """ - if solver_name not in self.allowed_solvers: - raise ValueError( - f"Solver `{solver_name}` not allowed for " - f"{self.__class__} regularization. " - f"Allowed solvers are {self.allowed_solvers}." - ) - - @staticmethod - def _check_solver_kwargs(solver_name, solver_kwargs): + @abc.abstractmethod + def penalized_loss(self, loss: Callable) -> Callable: """ - Check if provided solver keyword arguments are valid. + Abstract method to penalize loss functions. Parameters ---------- - solver_name : - Name of the solver. - solver_kwargs : - Additional keyword arguments for the solver. + loss : + Callable loss function. - Raises - ------ - NameError - If any of the solver keyword arguments are not valid. + Returns + ------- + : + A modified version of the loss function including any relevant penalization based on the regularizer + type. """ - solver_args = inspect.getfullargspec(getattr(jaxopt, solver_name)).args - undefined_kwargs = set(solver_kwargs.keys()).difference(solver_args) - if undefined_kwargs: - raise NameError( - f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" - ) + pass - def instantiate_solver( - self, loss: Callable, *args: Any, prox: Optional[Callable] = None, **kwargs: Any - ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: + @abc.abstractmethod + def _get_proximal_operator( + self, + ) -> ProximalOperator: """ - Instantiate the solver with the provided loss function. - - Instantiate the solver with the provided loss function, and return callable functions - that initialize the solver state, update the model parameters, and run the optimization. - - This method creates a solver instance from jaxopt library, tailored to the specific loss - function and regularization approach defined by the Regularizer instance. It also handles - the proximal operator if required for the optimization method. The returned functions are - directly usable in optimization loops, simplifying the syntax by pre-setting - common arguments like regularization strength and other hyperparameters. - - Parameters - ---------- - loss : - The loss function to be optimized. - - *args: - Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing - strength for proximal gradient methods. - - prox: - Optional, the proximal projection operator. - - *kwargs: - Keyword arguments for the jaxopt `solver.run` method. + Abstract method to retrieve the proximal operator for this solver. Returns ------- : - A tuple containing three callable functions: - - solver_init_state: Function to initialize the solver's state, necessary before starting the optimization. - - solver_update: Function to perform a single update step in the optimization process, - returning new parameters and state. - - solver_run: Function to execute the optimization process, applying multiple updates until a - stopping criterion is met. + The proximal operator, which typically applies a form of regularization. """ - # check that the loss is Callable - utils.assert_is_callable(loss, "loss") - - # get the solver with given arguments. - # The "fun" argument is not always the first one, but it is always KEYWORD - # see jaxopt.EqualityConstrainedQP for example. The most general way is to pass it as keyword. - # The proximal gradient is added to the kwargs if passed. This avoids issues with over-writing - # the proximal operator. - if "prox" in self.solver_kwargs: - if prox is None: - raise ValueError( - f"Regularizer of type {self.__class__.__name__} " - f"does not require a proximal operator!" - ) - else: - warnings.warn( - "Overwritten the user-defined proximal operator! " - "There is only one valid proximal operator for each regularizer type.", - UserWarning, - ) - # update the kwargs if prox is passed - if prox is not None: - solver_kwargs = self.solver_kwargs.copy() - solver_kwargs.update(prox=prox) - else: - solver_kwargs = self.solver_kwargs - solver = getattr(jaxopt, self.solver_name)(fun=loss, **solver_kwargs) - - def solver_run( - init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray - ) -> jaxopt.OptStep: - return solver.run(init_params, *args, *run_args, **kwargs) - - def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: - return solver.update( - params, state, *args, *run_args, **kwargs, **run_kwargs - ) - - def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: - return solver.init_state( - params, state, *args, *run_args, **kwargs, **run_kwargs - ) - - return solver_init_state, solver_update, solver_run + pass class UnRegularized(Regularizer): @@ -268,6 +123,8 @@ class are defined in the `allowed_solvers` attribute. ---------- allowed_solvers : list of str List of solver names that are allowed for this regularizer class. + default_solver : + Default solver for this regularizer is GradientDescent. See Also -------- @@ -287,9 +144,21 @@ class are defined in the `allowed_solvers` attribute. _default_solver = "GradientDescent" def __init__( - self, solver_name: str = "GradientDescent", solver_kwargs: Optional[dict] = None + self, + **kwargs ): - super().__init__(solver_name, solver_kwargs=solver_kwargs) + super().__init__(**kwargs) + + def penalized_loss(self, loss: Callable): + """Unregularized method does not add any penalty.""" + return loss + + def _get_proximal_operator(self,) -> ProximalOperator: + def prox_op(params, scaling=1.0): + Ws, bs = params + return jaxopt.prox.prox_none(Ws, scaling=scaling), bs + + return prox_op class Ridge(Regularizer): @@ -299,10 +168,18 @@ class Ridge(Regularizer): This class uses `jaxopt` optimizers to perform Ridge regularization. It extends the base Solver class, with the added feature of Ridge penalization. + Parameters + ---------- + regularizer_strength : + Indicates the strength of the penalization being applied. + Float with default value of 1.0. + Attributes ---------- allowed_solvers : List[..., str] A list of solver names that are allowed to be used with this regularizer. + default_solver : + Default solver for this regularizer is GradientDescent. """ _allowed_solvers = ( @@ -318,16 +195,13 @@ class Ridge(Regularizer): _default_solver = "GradientDescent" def __init__( - self, - solver_name: str = "GradientDescent", - solver_kwargs: Optional[dict] = None, - regularizer_strength: float = 1.0, + self, + **kwargs ): - super().__init__(solver_name, solver_kwargs=solver_kwargs) - self.regularizer_strength = regularizer_strength + super().__init__(**kwargs) def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] ) -> jnp.ndarray: """ Compute the Ridge penalization for given parameters. @@ -345,10 +219,10 @@ def _penalization( def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: return ( - 0.5 - * self.regularizer_strength - * jnp.sum(jnp.power(coeff, 2)) - / intercept.shape[0] + 0.5 + * self.regularizer_strength + * jnp.sum(jnp.power(coeff, 2)) + / intercept.shape[0] ) # tree map the computation and sum over leaves @@ -356,45 +230,29 @@ def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: lambda x: l2_penalty(x, params[1]), sum, params[0] ) - def instantiate_solver( - self, loss: Callable, *args: Any, **kwargs: Any - ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: - """ - Instantiate the solver with the provided loss function. + def penalized_loss(self, loss: Callable) -> Callable: - Instantiate the solver with the provided loss function and return callable functions - that initialize the solver state, update the model parameters, and run the optimization. - - This method creates a solver instance from jaxopt library, tailored to the specific loss - function and regularization approach defined by the Regularizer instance. It also handles - the proximal operator if required for the optimization method. The returned functions are - directly usable in optimization loops, simplifying the syntax by pre-setting - common arguments like regularization strength and other hyperparameters. + def _penalized_loss(params, X, y): + return loss(params, X, y) + self._penalization(params) - Parameters - ---------- - loss : - The original loss function to be optimized. + return _penalized_loss - Returns - ------- - : - A tuple containing three callable functions: - - solver_init_state: Function to initialize the solver's state, necessary before starting - the optimization. - - solver_update: Function to perform a single update step in the optimization process, returning - new parameters and state. - - solver_run: Function to execute the optimization process, applying multiple updates until a - stopping criterion is met. - """ - # this check has be performed here because the penalized loss will - # always be a callable independently of which loss is passed! - utils.assert_is_callable(loss, "loss") - - def penalized_loss(params, X, y): - return loss(params, X, y) + self._penalization(params) + def _get_proximal_operator( + self, + ) -> ProximalOperator: + def prox_op(params, l2reg, scaling=1.0): + Ws, bs = params + l2reg /= bs.shape[0] + # if Ws is a pytree, l2reg needs to be a pytree with the same + # structure + if isinstance(Ws, (dict, FeaturePytree)): + struct = jax.tree_util.tree_structure(Ws) + l1reg = jax.tree_util.tree_unflatten( + struct, [l2reg] * struct.num_leaves + ) + return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs - return super().instantiate_solver(penalized_loss, *args, **kwargs) + return prox_op class ProxGradientRegularizer(Regularizer, abc.ABC): @@ -404,10 +262,18 @@ class ProxGradientRegularizer(Regularizer, abc.ABC): This class utilizes the `jaxopt` library's Proximal Gradient optimizer. It extends the base Solver class, with the added functionality of a proximal operator. + Parameters + ---------- + regularizer_strength : + Indicates the strength of the penalization being applied. + Float with default value of 1.0. + Attributes ---------- - allowed_solvers : List[...,str] + allowed_solvers : List[..., str] A list of solver names that are allowed to be used with this regularizer. + default_solver : + Default solver for this regularizer is ProximalGradient. """ _allowed_solvers = ("ProximalGradient",) @@ -415,88 +281,31 @@ class ProxGradientRegularizer(Regularizer, abc.ABC): _default_solver = "ProximalGradient" def __init__( - self, - solver_name: str, - solver_kwargs: Optional[dict] = None, - regularizer_strength: float = 1.0, - **kwargs, + self, + **kwargs, ): - super().__init__(solver_name, solver_kwargs=solver_kwargs) - self.regularizer_strength = regularizer_strength - - @abc.abstractmethod - def _get_proximal_operator( - self, - ) -> ProximalOperator: - """ - Abstract method to retrieve the proximal operator for this solver. - - Returns - ------- - : - The proximal operator, which typically applies a form of regularization. - """ - pass - - def instantiate_solver( - self, loss: Callable, *args: Any, **kwargs: Any - ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: - """ - Instantiate the solver with the provided loss function. - - Instantiate the solver with the provided loss function and return callable functions - that initialize the solver state, update the model parameters, and run the optimization. - - This method creates a solver instance from jaxopt library, tailored to the specific loss - function and regularization approach defined by the Regularizer instance. It also handles - the proximal operator if required for the optimization method. The returned functions are - directly usable in optimization loops, simplifying the syntax by pre-setting - common arguments like regularization strength and other hyperparameters. - - Parameters - ---------- - loss : - The original loss function to be optimized. + super().__init__(**kwargs) - Returns - ------- - : - A tuple containing three callable functions: - - solver_init_state: Function to initialize the solver's state, necessary before starting - the optimization. - - solver_update: Function to perform a single update step in the optimization process, - returning new parameters and state. - - solver_run: Function to execute the optimization process, applying multiple updates until - a stopping criterion is met. - """ - return super().instantiate_solver( - loss, - self.regularizer_strength, - *args, - prox=self._get_proximal_operator(), - **kwargs, - ) + def penalized_loss(self, loss: Callable) -> Callable: + return loss class Lasso(ProxGradientRegularizer): """ - Optimization solver using the Lasso (L1 regularization) method with Proximal Gradient. + Optimization solver using the Lasso (L1 regularization) method with Proximal Gradient. This class is a specialized version of the ProxGradientSolver with the proximal operator set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ def __init__( - self, - solver_name: str = "ProximalGradient", - solver_kwargs: Optional[dict] = None, - regularizer_strength: float = 1.0, + self, + **kwargs ): - super().__init__(solver_name, solver_kwargs=solver_kwargs) - self.regularizer_strength = regularizer_strength + super().__init__(**kwargs) def _get_proximal_operator( - self, + self, ) -> ProximalOperator: """ Retrieve the proximal operator for Lasso regularization (L1 penalty). @@ -541,18 +350,15 @@ class GroupLasso(ProxGradientRegularizer): """ def __init__( - self, - solver_name: str, - mask: Union[NDArray, jnp.ndarray], - solver_kwargs: Optional[dict] = None, - regularizer_strength: float = 1.0, + self, + mask: Union[NDArray, jnp.ndarray] = None, + **kwargs ): - super().__init__( - solver_name, - solver_kwargs=solver_kwargs, - ) - self.regularizer_strength = regularizer_strength - self.mask = jnp.asarray(mask) + super().__init__(**kwargs) + + if mask is not None: + self.mask = jnp.asarray(mask) + # TODO: need to add if mask is None, to basically just use Lasso (single group) @property def mask(self): @@ -609,7 +415,7 @@ def _check_mask(mask: jnp.ndarray): ) def _get_proximal_operator( - self, + self, ) -> ProximalOperator: """ Retrieve the proximal operator for Group Lasso regularization. From 38a236689d4a8f86eb79a7e7d22db2c0db8d851b Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 9 Jul 2024 16:28:07 -0400 Subject: [PATCH 008/225] move regularizer and solver to BaseRegressor --- src/nemos/base_class.py | 34 ++++++++++++++++++++++++++++++++++ src/nemos/glm.py | 34 ---------------------------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index b930b2e1..29489471 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -250,6 +250,40 @@ def __init__( solver_kwargs = dict() self.solver_kwargs = solver_kwargs + @property + def regularizer(self) -> Union[None, Regularizer]: + """Getter for the regularizer attribute.""" + return self._regularizer + + @regularizer.setter + def regularizer(self, regularizer: str | Regularizer): + """Setter for the regularizer attribute.""" + # check if current solver and regularizer match is possible + # instantiate solver if necessary + self._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=self.solver_name) + + # test solver instantiation on the GLM loss + try: + self.instantiate_solver( + self.regularizer.penalized_loss(loss=self._predict_and_compute_loss), + self.regularizer.regularizer_strength, + prox=self.regularizer._get_proximal_operator(), + ) + except Exception: + raise TypeError( + "The provided `solver` cannot be instantiated on " + "the GLM log-likelihood." + ) + + @property + def solver_name(self) -> str: + return self._solver_name + + @solver_name.setter + def solver_name(self, solver_name: str): + # check if solver/regularizer pairing valid + self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver_name=solver_name) + @property def solver_kwargs(self): return self._solver_kwargs diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 0af684ec..146090e9 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -96,40 +96,6 @@ def __init__( self._solver_update = None self._solver_run = None - @property - def regularizer(self) -> Union[None, reg.Regularizer]: - """Getter for the regularizer attribute.""" - return self._regularizer - - @regularizer.setter - def regularizer(self, regularizer: str | reg.Regularizer): - """Setter for the regularizer attribute.""" - # check if current solver and regularizer match is possible - # instantiate solver if necessary - super()._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=self.solver_name) - - # test solver instantiation on the GLM loss - try: - super().instantiate_solver( - self.regularizer.penalized_loss(loss=self._predict_and_compute_loss), - self.regularizer.regularizer_strength, - prox=self.regularizer._get_proximal_operator(), - ) - except Exception: - raise TypeError( - "The provided `solver` cannot be instantiated on " - "the GLM log-likelihood." - ) - - @property - def solver_name(self) -> str: - return self._solver_name - - @solver_name.setter - def solver_name(self, solver: str): - # check if solver/regularizer pairing valid - self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver_name=solver) - @property def observation_model(self) -> Union[None, obs.Observations]: """Getter for the observation_model attribute.""" From 54e18675e6c210b278b67122cb1d260053fd7494 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 9 Jul 2024 17:13:38 -0400 Subject: [PATCH 009/225] clean up solver and regularizer logic --- src/nemos/base_class.py | 61 ++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 29489471..ab57fb89 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -244,7 +244,12 @@ def __init__( solver_name: str = None, solver_kwargs: Optional[dict] = None ): - self._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=solver_name) + self.regularizer = regularizer + + if solver_name is None: + self.solver_name = self.regularizer.default_solver + else: + self.solver_name = solver_name if solver_kwargs is None: solver_kwargs = dict() @@ -258,22 +263,11 @@ def regularizer(self) -> Union[None, Regularizer]: @regularizer.setter def regularizer(self, regularizer: str | Regularizer): """Setter for the regularizer attribute.""" - # check if current solver and regularizer match is possible - # instantiate solver if necessary - self._parse_regularizer_optimizer_params(regularizer=regularizer, solver_name=self.solver_name) - - # test solver instantiation on the GLM loss - try: - self.instantiate_solver( - self.regularizer.penalized_loss(loss=self._predict_and_compute_loss), - self.regularizer.regularizer_strength, - prox=self.regularizer._get_proximal_operator(), - ) - except Exception: - raise TypeError( - "The provided `solver` cannot be instantiated on " - "the GLM log-likelihood." - ) + # instantiate regularizer + if isinstance(regularizer, str): + self._regularizer = create_regularizer(name=regularizer) + else: + self._regularizer = regularizer @property def solver_name(self) -> str: @@ -281,8 +275,12 @@ def solver_name(self) -> str: @solver_name.setter def solver_name(self, solver_name: str): - # check if solver/regularizer pairing valid - self._parse_regularizer_optimizer_params(regularizer=self.regularizer, solver_name=solver_name) + # check if solver str passed is valid for regularizer + if solver_name not in self._regularizer.allowed_solvers: + raise ValueError(f"The solver: {solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}.") + self._solver_name = solver_name @property def solver_kwargs(self): @@ -317,25 +315,6 @@ def _check_solver_kwargs(solver_name, solver_kwargs): f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" ) - def _parse_regularizer_optimizer_params(self, regularizer: str | Regularizer, solver_name: str): - """Parse the regularizer and solver parameters.""" - # check if solver is in the regularizers allowed solvers - if isinstance(regularizer, str): - self._regularizer = create_regularizer(name=regularizer) - else: - self._regularizer = regularizer - - # if no solver passed, use default for given regularizer - if solver_name is None: - solver_name = self._regularizer.default_solver - # check if solver str passed is valid for regularizer - if solver_name not in self._regularizer.allowed_solvers: - raise ValueError(f"The solver: {solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}.") - # store solver - self._solver_name = solver_name - def instantiate_solver( self, loss: Callable, @@ -382,6 +361,12 @@ def instantiate_solver( """ # check that the loss is Callable utils.assert_is_callable(loss, "loss") + + # final check that solver is valid for chosen regularizer + if self.solver_name not in self.regularizer.allowed_solvers: + raise ValueError(f"The solver: {self.solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}.") # get the solver with given arguments. # The "fun" argument is not always the first one, but it is always KEYWORD From 745e75c9cfc2ea20424050ecce07ceb4e9e8d3ea Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 9 Jul 2024 17:23:29 -0400 Subject: [PATCH 010/225] fix get_params() --- src/nemos/base_class.py | 2 +- src/nemos/glm.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index ab57fb89..39f43a10 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -361,7 +361,7 @@ def instantiate_solver( """ # check that the loss is Callable utils.assert_is_callable(loss, "loss") - + # final check that solver is valid for chosen regularizer if self.solver_name not in self.regularizer.allowed_solvers: raise ValueError(f"The solver: {self.solver_name} is not allowed for " diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 146090e9..a95d9135 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import wraps -from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union +from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING import jax import jax.numpy as jnp @@ -20,6 +20,9 @@ from .pytrees import FeaturePytree from .type_casting import jnp_asarray_if, support_pynapple +if TYPE_CHECKING: + from regularizer import Regularizer + ModelParams = Tuple[jnp.ndarray, jnp.ndarray] @@ -81,9 +84,11 @@ class GLM(BaseRegressor): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - **kwargs + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: dict = None, ): - super().__init__(**kwargs) + super().__init__(regularizer=regularizer, solver_name=solver_name, solver_kwargs=solver_kwargs) self.observation_model = observation_model From 2a6f5e12988fc4e93543f7464e6646792df76d3e Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 10 Jul 2024 10:11:32 -0400 Subject: [PATCH 011/225] fix regularizer strength param --- src/nemos/regularizer.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 065e0df1..501f3d91 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -47,7 +47,7 @@ class Regularizer(Base, abc.ABC): Parameters ---------- - regularization_strength + regularizer_strength Float representing the strength of the regularization being applied. Default 1.0. @@ -64,11 +64,11 @@ class Regularizer(Base, abc.ABC): def __init__( self, - regularization_strength: float = 1.0, + regularizer_strength: float = 1.0, **kwargs, ): super().__init__(**kwargs) - self.regularization_strength = regularization_strength + self.regularizer_strength = regularizer_strength @property def allowed_solvers(self): @@ -145,15 +145,16 @@ class are defined in the `allowed_solvers` attribute. def __init__( self, - **kwargs + regularizer_strength: float = 1.0 ): - super().__init__(**kwargs) + super().__init__(regularizer_strength=regularizer_strength) def penalized_loss(self, loss: Callable): """Unregularized method does not add any penalty.""" return loss def _get_proximal_operator(self,) -> ProximalOperator: + """Proximal operator for unregularized would return the identity.""" def prox_op(params, scaling=1.0): Ws, bs = params return jaxopt.prox.prox_none(Ws, scaling=scaling), bs @@ -196,9 +197,9 @@ class Ridge(Regularizer): def __init__( self, - **kwargs + regularizer_strength: float = 1.0 ): - super().__init__(**kwargs) + super().__init__(regularizer_strength=regularizer_strength) def _penalization( self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] @@ -282,9 +283,9 @@ class ProxGradientRegularizer(Regularizer, abc.ABC): def __init__( self, - **kwargs, + regularizer_strength: float = 1.0 ): - super().__init__(**kwargs) + super().__init__(regularizer_strength=regularizer_strength) def penalized_loss(self, loss: Callable) -> Callable: return loss @@ -300,9 +301,9 @@ class Lasso(ProxGradientRegularizer): def __init__( self, - **kwargs + regularizer_strength: float = 1.0 ): - super().__init__(**kwargs) + super().__init__(regularizer_strength=regularizer_strength) def _get_proximal_operator( self, @@ -376,9 +377,9 @@ class GroupLasso(ProxGradientRegularizer): def __init__( self, mask: Union[NDArray, jnp.ndarray] = None, - **kwargs + regularizer_strength: float = 1.0 ): - super().__init__(**kwargs) + super().__init__(regularizer_strength=regularizer_strength) if mask is not None: self.mask = jnp.asarray(mask) From 94f554dc5063e0ac077c52216da38946726c2c2c Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 10 Jul 2024 10:55:17 -0400 Subject: [PATCH 012/225] fix lasso reg and solver instantiation --- src/nemos/_regularizer_builder.py | 2 +- src/nemos/base_class.py | 2 +- src/nemos/glm.py | 15 +++++++++++--- src/nemos/regularizer.py | 34 ++++--------------------------- 4 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 38b4968c..41942b9b 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -28,7 +28,7 @@ def create_regularizer(name: str): from .regularizer import Lasso return Lasso() case "group_lasso": - from regularizer import GroupLasso + from .regularizer import GroupLasso return GroupLasso() raise ValueError(f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}") diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 39f43a10..2203fdae 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -376,7 +376,7 @@ def instantiate_solver( if "prox" in self.solver_kwargs: if prox is None: raise ValueError( - f"Regularizer of type {self.__class__.__name__} " + f"Regularizer of type {self.regularizer.__class__.__name__} " f"does not require a proximal operator!" ) else: diff --git a/src/nemos/glm.py b/src/nemos/glm.py index a95d9135..47ff427a 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -865,9 +865,18 @@ def initialize_solver( # validate input self._validate(X, y, init_params) - self._solver_init_state, self._solver_update, self._solver_run = ( - super().instantiate_solver(self._predict_and_compute_loss) - ) + if isinstance(self.regularizer, (reg.GroupLasso, reg.Lasso)): + self._solver_init_state, self._solver_update, self._solver_run = ( + super().instantiate_solver(self._predict_and_compute_loss, + self.regularizer.regularizer_strength, + prox=self.regularizer._get_proximal_operator(), + ) + ) + else: + self._solver_init_state, self._solver_update, self._solver_run = ( + super().instantiate_solver(self._predict_and_compute_loss) + ) + if isinstance(X, FeaturePytree): data = X.data else: diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 501f3d91..95e6c0b8 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -78,7 +78,6 @@ def allowed_solvers(self): def default_solver(self): return self._default_solver - @abc.abstractmethod def penalized_loss(self, loss: Callable) -> Callable: """ Abstract method to penalize loss functions. @@ -96,7 +95,6 @@ def penalized_loss(self, loss: Callable) -> Callable: """ pass - @abc.abstractmethod def _get_proximal_operator( self, ) -> ProximalOperator: @@ -153,14 +151,6 @@ def penalized_loss(self, loss: Callable): """Unregularized method does not add any penalty.""" return loss - def _get_proximal_operator(self,) -> ProximalOperator: - """Proximal operator for unregularized would return the identity.""" - def prox_op(params, scaling=1.0): - Ws, bs = params - return jaxopt.prox.prox_none(Ws, scaling=scaling), bs - - return prox_op - class Ridge(Regularizer): """ @@ -232,29 +222,11 @@ def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: ) def penalized_loss(self, loss: Callable) -> Callable: - def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) return _penalized_loss - def _get_proximal_operator( - self, - ) -> ProximalOperator: - def prox_op(params, l2reg, scaling=1.0): - Ws, bs = params - l2reg /= bs.shape[0] - # if Ws is a pytree, l2reg needs to be a pytree with the same - # structure - if isinstance(Ws, (dict, FeaturePytree)): - struct = jax.tree_util.tree_structure(Ws) - l1reg = jax.tree_util.tree_unflatten( - struct, [l2reg] * struct.num_leaves - ) - return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs - - return prox_op - class ProxGradientRegularizer(Regularizer, abc.ABC): """ @@ -368,7 +340,7 @@ class GroupLasso(ProxGradientRegularizer): >>> mask[2] = [0, 0, 1, 0, 1] # Group 2 includes features 2 and 4 >>> # Create the GroupLasso regularizer instance - >>> group_lasso = GroupLasso(solver_name='ProximalGradient', regularizer_strength=0.1, mask=mask) + >>> group_lasso = GroupLasso(regularizer_strength=0.1, mask=mask) >>> # fit a group-lasso glm >>> model = GLM(regularizer=group_lasso).fit(X, y) >>> print(f"coeff: {model.coef_}") @@ -383,7 +355,9 @@ def __init__( if mask is not None: self.mask = jnp.asarray(mask) - # TODO: need to add if mask is None, to basically just use Lasso (single group) + else: + # default mask if None is a singular group + self.mask = jnp.asarray([[1.0]]) @property def mask(self): From fe743a539236f9964ed707829c627cefe69f8d89 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 10 Jul 2024 13:56:51 -0400 Subject: [PATCH 013/225] requested changes --- src/nemos/base_class.py | 5 +- src/nemos/glm.py | 182 ++++++++++++++++++++------------------- src/nemos/regularizer.py | 112 ++++++++++++++---------- 3 files changed, 161 insertions(+), 138 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 2203fdae..23284f8c 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -242,7 +242,7 @@ def __init__( self, regularizer: str | Regularizer = "unregularized", solver_name: str = None, - solver_kwargs: Optional[dict] = None + solver_kwargs: Optional[dict] = None, ): self.regularizer = regularizer @@ -391,6 +391,9 @@ def instantiate_solver( solver_kwargs.update(prox=prox) else: solver_kwargs = self.solver_kwargs + + # get regularizer kwargs + # check if they are valid solver = getattr(jaxopt, self._solver_name)(fun=loss, **solver_kwargs) def solver_run( diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 47ff427a..a16c31f8 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -82,11 +82,11 @@ class GLM(BaseRegressor): """ def __init__( - self, - observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: str | Regularizer = "unregularized", - solver_name: str = None, - solver_kwargs: dict = None, + self, + observation_model: obs.Observations = obs.PoissonObservations(), + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: dict = None, ): super().__init__(regularizer=regularizer, solver_name=solver_name, solver_kwargs=solver_kwargs) @@ -167,8 +167,8 @@ def solver_run(self) -> Union[None, SolverRun]: @staticmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -192,14 +192,14 @@ def _check_params( params[0], expected_dim=1, err_message="params[0] must be an array or nemos.pytree.FeaturePytree " - "with array leafs of shape (n_features, ).", + "with array leafs of shape (n_features, ).", ) # check the dimensionality of intercept validation.check_tree_leaves_dimensionality( params[1], expected_dim=1, err_message="params[1] must be of shape (1,) but " - f"params[1] has {params[1].ndim} dimensions!", + f"params[1] has {params[1].ndim} dimensions!", ) if params[1].shape[0] != 1: raise ValueError( @@ -209,7 +209,7 @@ def _check_params( @staticmethod def _check_input_dimensionality( - X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None + X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None ): if y is not None: validation.check_tree_leaves_dimensionality( @@ -223,14 +223,14 @@ def _check_input_dimensionality( X, expected_dim=2, err_message="X must be two-dimensional, with shape " - "(n_timebins, n_features) or pytree of the same shape.", + "(n_timebins, n_features) or pytree of the same shape.", ) @staticmethod def _check_input_and_params_consistency( - params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], - X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], + X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features and structure in model parameters and input arguments. @@ -253,7 +253,7 @@ def _check_input_and_params_consistency( data, params[0], err_message=f"X and params[0] must be the same type, but X is " - f"{type(X)} and params[0] is {type(params[0])}", + f"{type(X)} and params[0] is {type(params[0])}", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -262,8 +262,8 @@ def _check_input_and_params_consistency( axis_1=0, axis_2=1, err_message="Inconsistent number of features. " - f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " - f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", + f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " + f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", ) def _check_is_fit(self): @@ -274,7 +274,7 @@ def _check_is_fit(self): ) def _predict( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray ) -> jnp.ndarray: """ Predicts firing rates based on given parameters and design matrix. @@ -358,10 +358,10 @@ def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: return self._predict(params, data) def _predict_and_compute_loss( - self, - params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, + self, + params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, ) -> jnp.ndarray: r"""Predict the rate and compute the negative log-likelihood against neural activity. @@ -388,13 +388,13 @@ def _predict_and_compute_loss( return self._observation_model._negative_log_likelihood(predicted_rate, y) def score( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - score_type: Literal[ - "log-likelihood", "pseudo-r2-McFadden", "pseudo-r2-Cohen" - ] = "log-likelihood", - aggregate_sample_scores: Callable = jnp.mean, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + score_type: Literal[ + "log-likelihood", "pseudo-r2-McFadden", "pseudo-r2-Cohen" + ] = "log-likelihood", + aggregate_sample_scores: Callable = jnp.mean, ) -> jnp.ndarray: r"""Evaluate the goodness-of-fit of the model to the observed neural data. @@ -512,7 +512,7 @@ def score( return score def _initialize_parameters( - self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray + self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray ) -> Tuple[Union[dict, jnp.ndarray], jnp.ndarray]: """Initialize the parameters based on the structure and dimensions X and y. @@ -565,7 +565,7 @@ def func(x): # scipy root finding, much more stable than gradient descent func_root = root(func, y.mean(axis=0, keepdims=False), method="hybr") - if not jnp.allclose(func_root.fun, 0, atol=10**-4): + if not jnp.allclose(func_root.fun, 0, atol=10 ** -4): raise ValueError( "Could not set the initial intercept as the inverse of the firing rate for " "the provided link function. " @@ -591,10 +591,10 @@ def func(x): @cast_to_jax def fit( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, ): """Fit GLM to neural activity. @@ -648,7 +648,7 @@ def fit( params, state = self.solver_run(init_params, data, y) if tree_utils.pytree_map_and_reduce( - lambda x: jnp.any(jnp.isnan(x)), any, params + lambda x: jnp.any(jnp.isnan(x)), any, params ): raise ValueError( "Solver returned at least one NaN parameter, so solution is invalid!" @@ -689,9 +689,9 @@ def _set_coef_and_intercept(self, params): self.intercept_: jnp.ndarray = params[1] def simulate( - self, - random_key: jax.Array, - feedforward_input: DESIGN_INPUT_TYPE, + self, + random_key: jax.Array, + feedforward_input: DESIGN_INPUT_TYPE, ) -> Tuple[jnp.ndarray, jnp.ndarray]: """Simulate neural activity in response to a feed-forward input. @@ -748,7 +748,7 @@ def simulate( ) def estimate_resid_degrees_of_freedom( - self, X: DESIGN_INPUT_TYPE, n_samples: Optional[int] = None + self, X: DESIGN_INPUT_TYPE, n_samples: Optional[int] = None ): """ Estimate the degrees of freedom of the residuals. @@ -799,12 +799,12 @@ def estimate_resid_degrees_of_freedom( @cast_to_jax def initialize_solver( - self, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - init_params: Optional[ModelParams] = None, - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + init_params: Optional[ModelParams] = None, + **kwargs, ) -> Tuple[ModelParams, NamedTuple]: """ Initialize the solver's state and optionally sets initial model parameters for the optimization process. @@ -865,13 +865,15 @@ def initialize_solver( # validate input self._validate(X, y, init_params) - if isinstance(self.regularizer, (reg.GroupLasso, reg.Lasso)): + # if using ProximalGradient, pass prox op + if self.solver_name == "ProximalGradient": self._solver_init_state, self._solver_update, self._solver_run = ( super().instantiate_solver(self._predict_and_compute_loss, self.regularizer.regularizer_strength, - prox=self.regularizer._get_proximal_operator(), - ) - ) + prox=self.regularizer.get_proximal_operator(), + ) + ) + else: self._solver_init_state, self._solver_update, self._solver_run = ( super().instantiate_solver(self._predict_and_compute_loss) @@ -886,14 +888,14 @@ def initialize_solver( @cast_to_jax def update( - self, - params: Tuple[jnp.ndarray, jnp.ndarray], - opt_state: NamedTuple, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - n_samples: Optional[int] = None, - **kwargs, + self, + params: Tuple[jnp.ndarray, jnp.ndarray], + opt_state: NamedTuple, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + n_samples: Optional[int] = None, + **kwargs, ) -> jaxopt.OptStep: """ Update the model parameters and solver state. @@ -1058,10 +1060,10 @@ class PopulationGLM(GLM): """ def __init__( - self, - observation_model: obs.Observations = obs.PoissonObservations(), - feature_mask: Optional[jnp.ndarray] = None, - **kwargs + self, + observation_model: obs.Observations = obs.PoissonObservations(), + feature_mask: Optional[jnp.ndarray] = None, + **kwargs ): super().__init__(observation_model=observation_model, **kwargs) self.feature_mask = feature_mask @@ -1082,7 +1084,7 @@ def feature_mask(self, feature_mask: Union[DESIGN_INPUT_TYPE, dict]): # check if the mask is of 0s and 1s if tree_utils.pytree_map_and_reduce( - lambda x: jnp.any(jnp.logical_and(x != 0, x != 1)), any, feature_mask + lambda x: jnp.any(jnp.logical_and(x != 0, x != 1)), any, feature_mask ): raise ValueError("'feature_mask' must contain only 0s and 1s!") @@ -1112,7 +1114,7 @@ def feature_mask(self, feature_mask: Union[DESIGN_INPUT_TYPE, dict]): @staticmethod def _check_input_dimensionality( - X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None + X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None ): if y is not None: validation.check_tree_leaves_dimensionality( @@ -1126,13 +1128,13 @@ def _check_input_dimensionality( X, expected_dim=2, err_message="X must be two-dimensional, with shape " - "(n_timebins, n_features) or pytree of the same shape.", + "(n_timebins, n_features) or pytree of the same shape.", ) @staticmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -1156,17 +1158,17 @@ def _check_params( params[0], expected_dim=2, err_message="params[0] must be an array or nemos.pytree.FeaturePytree " - "with array leafs of shape (n_features, n_neurons).", + "with array leafs of shape (n_features, n_neurons).", ) # check the dimensionality of intercept validation.check_tree_leaves_dimensionality( params[1], expected_dim=1, err_message="params[1] must be of shape (n_neurons,) but " - f"params[1] has {params[1].ndim} dimensions!", + f"params[1] has {params[1].ndim} dimensions!", ) if tree_utils.pytree_map_and_reduce( - lambda x: x.shape[1] != params[1].shape[0], all, params[0] + lambda x: x.shape[1] != params[1].shape[0], all, params[0] ): raise ValueError( "Inconsistent number of neurons. " @@ -1176,10 +1178,10 @@ def _check_params( return params def _check_input_and_params_consistency( - self, - params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], - X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + self, + params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], + X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features and structure in model parameters and input arguments. @@ -1205,7 +1207,7 @@ def _check_input_and_params_consistency( data, params[0], err_message=f"X and params[0] must be the same type, but X is " - f"{type(X)} and params[0] is {type(params[0])}", + f"{type(X)} and params[0] is {type(params[0])}", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -1214,8 +1216,8 @@ def _check_input_and_params_consistency( axis_1=0, axis_2=1, err_message="Inconsistent number of features. " - f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " - f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", + f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " + f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", ) if y is not None: @@ -1224,8 +1226,8 @@ def _check_input_and_params_consistency( y, axis=1, err_message="Inconsistent number of neurons. " - f"spike basis coefficients assumes {jax.tree_util.tree_map(lambda p: p.shape[1], params[0])} neurons, " - f"y has {jax.tree_util.tree_map(lambda x: x.shape[1], y)} neurons instead!", + f"spike basis coefficients assumes {jax.tree_util.tree_map(lambda p: p.shape[1], params[0])} neurons, " + f"y has {jax.tree_util.tree_map(lambda x: x.shape[1], y)} neurons instead!", ) self._check_mask(X, y, params) @@ -1244,8 +1246,8 @@ def _check_mask(self, X, y, params): data, self.feature_mask, err_message=f"feature_mask and X must have the same structure, but feature_mask has structure " - f"{jax.tree_util.tree_structure(X)}, params[0] is of " - f"{jax.tree_util.tree_structure(self.feature_mask)} structure instead!", + f"{jax.tree_util.tree_structure(X)}, params[0] is of " + f"{jax.tree_util.tree_structure(self.feature_mask)} structure instead!", ) if isinstance(params[0], dict): @@ -1259,8 +1261,8 @@ def _check_mask(self, X, y, params): axis_1=0, axis_2=0, err_message="Inconsistent number of features. " - f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[0], self.feature_mask)} neurons, " - f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", + f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[0], self.feature_mask)} neurons, " + f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -1269,16 +1271,16 @@ def _check_mask(self, X, y, params): axis_1=neural_axis, axis_2=1, err_message="Inconsistent number of neurons. " - f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[neural_axis], self.feature_mask)} neurons, " - f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", + f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[neural_axis], self.feature_mask)} neurons, " + f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", ) @cast_to_jax def fit( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, ): """Fit GLM to the activity of a population of neurons. @@ -1343,7 +1345,7 @@ def _initialize_feature_mask(self, X, y): self._feature_mask = jnp.ones((X.shape[1], y.shape[1])) def _predict( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray ) -> jnp.ndarray: """ Predicts firing rates based on given parameters and design matrix. diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 95e6c0b8..93678fac 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -64,11 +64,12 @@ class Regularizer(Base, abc.ABC): def __init__( self, - regularizer_strength: float = 1.0, **kwargs, ): super().__init__(**kwargs) - self.regularizer_strength = regularizer_strength + + # default regularizer strength + self.regularizer_strength = 1.0 @property def allowed_solvers(self): @@ -78,6 +79,14 @@ def allowed_solvers(self): def default_solver(self): return self._default_solver + @property + def regularizer_strength(self) -> float: + return self._regularizer_strength + + @regularizer_strength.setter + def regularizer_strength(self, strength: float): + self._regularizer_strength = strength + def penalized_loss(self, loss: Callable) -> Callable: """ Abstract method to penalize loss functions. @@ -95,7 +104,7 @@ def penalized_loss(self, loss: Callable) -> Callable: """ pass - def _get_proximal_operator( + def get_proximal_operator( self, ) -> ProximalOperator: """ @@ -137,20 +146,29 @@ class are defined in the `allowed_solvers` attribute. "NonlinearCG", "ScipyBoundedMinimize", "LBFGSB", + "ProximalGradient" ) _default_solver = "GradientDescent" def __init__( self, - regularizer_strength: float = 1.0 ): - super().__init__(regularizer_strength=regularizer_strength) + super().__init__() def penalized_loss(self, loss: Callable): """Unregularized method does not add any penalty.""" return loss + def get_proximal_operator(self, ) -> ProximalOperator: + """Unregularized method has no proximal operator.""" + + def prox_op(params, hyperparams, scaling=1.0): + Ws, bs = params + return jaxopt.prox.prox_none(Ws, hyperparams, scaling=scaling), bs + + return prox_op + class Ridge(Regularizer): """ @@ -181,15 +199,17 @@ class Ridge(Regularizer): "NonlinearCG", "ScipyBoundedMinimize", "LBFGSB", + "ProximalGradient" ) _default_solver = "GradientDescent" def __init__( self, - regularizer_strength: float = 1.0 + regularizer_strength: float = 1.0, ): - super().__init__(regularizer_strength=regularizer_strength) + super().__init__() + self.regularizer_strength = regularizer_strength def _penalization( self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] @@ -227,43 +247,25 @@ def _penalized_loss(params, X, y): return _penalized_loss - -class ProxGradientRegularizer(Regularizer, abc.ABC): - """ - Abstract class for ptimization solvers using the Proximal Gradient method. - - This class utilizes the `jaxopt` library's Proximal Gradient optimizer. It extends - the base Solver class, with the added functionality of a proximal operator. - - Parameters - ---------- - regularizer_strength : - Indicates the strength of the penalization being applied. - Float with default value of 1.0. - - Attributes - ---------- - allowed_solvers : List[..., str] - A list of solver names that are allowed to be used with this regularizer. - default_solver : - Default solver for this regularizer is ProximalGradient. - """ - - _allowed_solvers = ("ProximalGradient",) - - _default_solver = "ProximalGradient" - - def __init__( + def get_proximal_operator( self, - regularizer_strength: float = 1.0 - ): - super().__init__(regularizer_strength=regularizer_strength) + ) -> ProximalOperator: + def prox_op(params, l2reg, scaling=1.0): + Ws, bs = params + l2reg /= bs.shape[0] + # if Ws is a pytree, l2reg needs to be a pytree with the same + # structure + if isinstance(Ws, (dict, FeaturePytree)): + struct = jax.tree_util.tree_structure(Ws) + l2reg = jax.tree_util.tree_unflatten( + struct, [l2reg] * struct.num_leaves + ) + return jaxopt.prox.prox_lasso(Ws, l2reg, scaling=scaling), bs - def penalized_loss(self, loss: Callable) -> Callable: - return loss + return prox_op -class Lasso(ProxGradientRegularizer): +class Lasso(Regularizer): """ Optimization solver using the Lasso (L1 regularization) method with Proximal Gradient. @@ -271,13 +273,18 @@ class Lasso(ProxGradientRegularizer): set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ + _allowed_solvers = ("ProximalGradient,") + + _default_solver = "ProximalGradient" + def __init__( self, - regularizer_strength: float = 1.0 + regularizer_strength: float = 1.0, ): - super().__init__(regularizer_strength=regularizer_strength) + super().__init__() + self.regularizer_strength = regularizer_strength - def _get_proximal_operator( + def get_proximal_operator( self, ) -> ProximalOperator: """ @@ -304,8 +311,11 @@ def prox_op(params, l1reg, scaling=1.0): return prox_op + def penalized_loss(self, loss: Callable) -> Callable: + return loss -class GroupLasso(ProxGradientRegularizer): + +class GroupLasso(Regularizer): """ Optimization solver using the Group Lasso regularization method with Proximal Gradient. @@ -346,12 +356,17 @@ class GroupLasso(ProxGradientRegularizer): >>> print(f"coeff: {model.coef_}") """ + _allowed_solvers = ("ProximalGradient,") + + _default_solver = "ProximalGradient" + def __init__( self, mask: Union[NDArray, jnp.ndarray] = None, - regularizer_strength: float = 1.0 + regularizer_strength: float = 1.0, ): - super().__init__(regularizer_strength=regularizer_strength) + super().__init__() + self.regularizer_strength = regularizer_strength if mask is not None: self.mask = jnp.asarray(mask) @@ -413,7 +428,10 @@ def _check_mask(mask: jnp.ndarray): f"Data type {mask.dtype} provided instead!" ) - def _get_proximal_operator( + def penalized_loss(self, loss: Callable) -> Callable: + return loss + + def get_proximal_operator( self, ) -> ProximalOperator: """ From 8d0494b149dfe57fdb3d75b89aeb566c06fec40e Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 11 Jul 2024 10:01:12 -0400 Subject: [PATCH 014/225] add code comments and doc strings, lint files --- src/nemos/_regularizer_builder.py | 19 ++- src/nemos/base_class.py | 120 ++++++++-------- src/nemos/glm.py | 229 +++++++++++++++++------------- src/nemos/regularizer.py | 59 ++++---- 4 files changed, 236 insertions(+), 191 deletions(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 41942b9b..4521e2dc 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -1,11 +1,6 @@ """Utility functions for creating regularizer object.""" -AVAILABLE_REGULARIZERS: [ - "unregularized", - "ridge", - "lasso", - "group_lasso" -] +AVAILABLE_REGULARIZERS: ["unregularized", "ridge", "lasso", "group_lasso"] def create_regularizer(name: str): @@ -20,19 +15,21 @@ def create_regularizer(name: str): match name: case "unregularized": from .regularizer import UnRegularized + return UnRegularized() case "ridge": from .regularizer import Ridge + return Ridge() case "lasso": from .regularizer import Lasso + return Lasso() case "group_lasso": from .regularizer import GroupLasso - return GroupLasso() - - raise ValueError(f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}") - - + return GroupLasso() + raise ValueError( + f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}" + ) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 23284f8c..ae7bc777 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -137,9 +137,9 @@ def set_params(self, **params: Any): # but only if the user did not explicitly set a value for # "base_estimator". if ( - key == "base_estimator" - and valid_params[key] == "deprecated" - and self.__module__.startswith("sklearn.") + key == "base_estimator" + and valid_params[key] == "deprecated" + and self.__module__.startswith("sklearn.") ): warnings.warn( ( @@ -214,7 +214,10 @@ class BaseRegressor(Base, abc.ABC): Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. Default is `None`. If no solver specified, one will be chosen based on the regularizer. - Please see table below forregularizer/optimizer pairings. + Please see table below for regularizer/optimizer pairings. + solver_kwargs : + Optional dictionary for keyword arguments that are passed to the solver when instantiated. + E.g. stepsize, acceleration, value_and_grad, etc. +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | @@ -230,6 +233,7 @@ class BaseRegressor(Base, abc.ABC): | GroupLasso | ProximalGradient | ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ + See Also -------- Concrete models: @@ -239,13 +243,14 @@ class BaseRegressor(Base, abc.ABC): """ def __init__( - self, - regularizer: str | Regularizer = "unregularized", - solver_name: str = None, - solver_kwargs: Optional[dict] = None, + self, + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: Optional[dict] = None, ): self.regularizer = regularizer + # no solver name provided, use default if solver_name is None: self.solver_name = self.regularizer.default_solver else: @@ -263,7 +268,7 @@ def regularizer(self) -> Union[None, Regularizer]: @regularizer.setter def regularizer(self, regularizer: str | Regularizer): """Setter for the regularizer attribute.""" - # instantiate regularizer + # instantiate regularizer if str if isinstance(regularizer, str): self._regularizer = create_regularizer(name=regularizer) else: @@ -271,23 +276,29 @@ def regularizer(self, regularizer: str | Regularizer): @property def solver_name(self) -> str: + """Getter for the solver_name attribute.""" return self._solver_name @solver_name.setter def solver_name(self, solver_name: str): + """Setter for the solver_name attribute.""" # check if solver str passed is valid for regularizer if solver_name not in self._regularizer.allowed_solvers: - raise ValueError(f"The solver: {solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}.") + raise ValueError( + f"The solver: {solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}." + ) self._solver_name = solver_name @property def solver_kwargs(self): + """Getter for the solver_kwargs attribute.""" return self._solver_kwargs @solver_kwargs.setter def solver_kwargs(self, solver_kwargs: dict): + """Setter for the solver_kwargs attribute.""" self._check_solver_kwargs(self.solver_name, solver_kwargs) self._solver_kwargs = solver_kwargs @@ -316,11 +327,7 @@ def _check_solver_kwargs(solver_name, solver_kwargs): ) def instantiate_solver( - self, - loss: Callable, - *args: Any, - prox: Optional[Callable] = None, - **kwargs: Any + self, loss: Callable, *args: Any, prox: Optional[Callable] = None, **kwargs: Any ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: """ Instantiate the solver with the provided loss function. @@ -331,7 +338,7 @@ def instantiate_solver( This method creates a solver instance from jaxopt library, tailored to the specific loss function and regularization approach defined by the Regularizer instance. It also handles the proximal operator if required for the optimization method. The returned functions are - directly usable in optimization loops, simplifying the syntax by pre-setting + directly usable in optimization loops, simplifying the syntax by pre-setting common arguments like regularization strength and other hyperparameters. Parameters @@ -364,9 +371,11 @@ def instantiate_solver( # final check that solver is valid for chosen regularizer if self.solver_name not in self.regularizer.allowed_solvers: - raise ValueError(f"The solver: {self.solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}.") + raise ValueError( + f"The solver: {self.solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}." + ) # get the solver with given arguments. # The "fun" argument is not always the first one, but it is always KEYWORD @@ -392,8 +401,7 @@ def instantiate_solver( else: solver_kwargs = self.solver_kwargs - # get regularizer kwargs - # check if they are valid + # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **solver_kwargs) def solver_run( @@ -425,20 +433,20 @@ def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: @abc.abstractmethod def score( - self, - X: DESIGN_INPUT_TYPE, - y: Union[NDArray, jnp.ndarray], - # may include score_type or other additional model dependent kwargs - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: Union[NDArray, jnp.ndarray], + # may include score_type or other additional model dependent kwargs + **kwargs, ) -> jnp.ndarray: """Score the predicted firing rates (based on fit) to the target neural activity.""" pass @abc.abstractmethod def simulate( - self, - random_key: jax.Array, - feed_forward_input: DESIGN_INPUT_TYPE, + self, + random_key: jax.Array, + feed_forward_input: DESIGN_INPUT_TYPE, ): """Simulate neural activity in response to a feed-forward input and recurrent activity.""" pass @@ -446,8 +454,8 @@ def simulate( @staticmethod @abc.abstractmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -462,8 +470,8 @@ def _check_params( @staticmethod @abc.abstractmethod def _check_input_dimensionality( - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): pass @@ -480,9 +488,9 @@ def _set_coef_and_intercept(self, params: Any): @staticmethod @abc.abstractmethod def _check_input_and_params_consistency( - params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features in model parameters and input arguments. @@ -497,7 +505,7 @@ def _check_input_and_params_consistency( @staticmethod def _check_input_n_timepoints( - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray ): if y.shape[0] != X.shape[0]: raise ValueError( @@ -507,10 +515,10 @@ def _check_input_n_timepoints( ) def _validate( - self, - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], - y: Union[NDArray, jnp.ndarray], - init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], + self, + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], + y: Union[NDArray, jnp.ndarray], + init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], ): # check input dimensionality self._check_input_dimensionality(X, y) @@ -527,25 +535,25 @@ def _validate( @abc.abstractmethod def update( - self, - params: Tuple[jnp.ndarray, jnp.ndarray], - opt_state: NamedTuple, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - **kwargs, + self, + params: Tuple[jnp.ndarray, jnp.ndarray], + opt_state: NamedTuple, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + **kwargs, ) -> jaxopt.OptStep: """Run a single update step of the jaxopt solver.""" pass @abc.abstractmethod def initialize_solver( - self, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - params: Optional = None, - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + params: Optional = None, + **kwargs, ) -> Tuple[Any, NamedTuple]: """Initialize the solver's state and optionally sets initial model parameters for the optimization.""" pass diff --git a/src/nemos/glm.py b/src/nemos/glm.py index a16c31f8..74e3536e 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -15,7 +15,13 @@ from . import observation_models as obs from . import regularizer as reg from . import tree_utils, validation -from .base_class import DESIGN_INPUT_TYPE, BaseRegressor, SolverRun, SolverUpdate, SolverInit +from .base_class import ( + DESIGN_INPUT_TYPE, + BaseRegressor, + SolverRun, + SolverUpdate, + SolverInit, +) from .exceptions import NotFittedError from .pytrees import FeaturePytree from .type_casting import jnp_asarray_if, support_pynapple @@ -46,7 +52,7 @@ def wrapper(*args, **kwargs): class GLM(BaseRegressor): - r""" + """ Generalized Linear Model (GLM) for neural activity data. This GLM implementation allows users to model neural activity based on a combination of exogenous inputs @@ -59,6 +65,32 @@ class GLM(BaseRegressor): observation_model : Observation model to use. The model describes the distribution of the neural activity. Default is the Poisson model. + regularizer : + Regularization to use for model optimization. Defines the regularization scheme + and related parameters. + Default is UnRegularized regression. + solver_name : + Solver to use for model optimization. Defines the optimization scheme and related parameters. + The solver must be an appropriate match for the chosen regularizer. + Default is `None`. If no solver specified, one will be chosen based on the regularizer. + Please see table below for regularizer/optimizer pairings. + solver_kwargs : + Optional dictionary for keyword arguments that are passed to the solver when instantiated. + E.g. stepsize, acceleration, value_and_grad, etc. + + +---------------+------------------+-------------------------------------------------------------+ + | Regularizer | Default Solver | Available Solvers | + +===============+==================+=============================================================+ + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | + | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | + | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | Lasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | GroupLasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ Attributes ---------- @@ -82,13 +114,17 @@ class GLM(BaseRegressor): """ def __init__( - self, - observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: str | Regularizer = "unregularized", - solver_name: str = None, - solver_kwargs: dict = None, + self, + observation_model: obs.Observations = obs.PoissonObservations(), + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: dict = None, ): - super().__init__(regularizer=regularizer, solver_name=solver_name, solver_kwargs=solver_kwargs) + super().__init__( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) self.observation_model = observation_model @@ -167,8 +203,8 @@ def solver_run(self) -> Union[None, SolverRun]: @staticmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -192,14 +228,14 @@ def _check_params( params[0], expected_dim=1, err_message="params[0] must be an array or nemos.pytree.FeaturePytree " - "with array leafs of shape (n_features, ).", + "with array leafs of shape (n_features, ).", ) # check the dimensionality of intercept validation.check_tree_leaves_dimensionality( params[1], expected_dim=1, err_message="params[1] must be of shape (1,) but " - f"params[1] has {params[1].ndim} dimensions!", + f"params[1] has {params[1].ndim} dimensions!", ) if params[1].shape[0] != 1: raise ValueError( @@ -209,7 +245,7 @@ def _check_params( @staticmethod def _check_input_dimensionality( - X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None + X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None ): if y is not None: validation.check_tree_leaves_dimensionality( @@ -223,14 +259,14 @@ def _check_input_dimensionality( X, expected_dim=2, err_message="X must be two-dimensional, with shape " - "(n_timebins, n_features) or pytree of the same shape.", + "(n_timebins, n_features) or pytree of the same shape.", ) @staticmethod def _check_input_and_params_consistency( - params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], - X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], + X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features and structure in model parameters and input arguments. @@ -253,7 +289,7 @@ def _check_input_and_params_consistency( data, params[0], err_message=f"X and params[0] must be the same type, but X is " - f"{type(X)} and params[0] is {type(params[0])}", + f"{type(X)} and params[0] is {type(params[0])}", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -262,8 +298,8 @@ def _check_input_and_params_consistency( axis_1=0, axis_2=1, err_message="Inconsistent number of features. " - f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " - f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", + f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " + f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", ) def _check_is_fit(self): @@ -274,7 +310,7 @@ def _check_is_fit(self): ) def _predict( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray ) -> jnp.ndarray: """ Predicts firing rates based on given parameters and design matrix. @@ -358,10 +394,10 @@ def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: return self._predict(params, data) def _predict_and_compute_loss( - self, - params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, + self, + params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, ) -> jnp.ndarray: r"""Predict the rate and compute the negative log-likelihood against neural activity. @@ -388,13 +424,13 @@ def _predict_and_compute_loss( return self._observation_model._negative_log_likelihood(predicted_rate, y) def score( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - score_type: Literal[ - "log-likelihood", "pseudo-r2-McFadden", "pseudo-r2-Cohen" - ] = "log-likelihood", - aggregate_sample_scores: Callable = jnp.mean, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + score_type: Literal[ + "log-likelihood", "pseudo-r2-McFadden", "pseudo-r2-Cohen" + ] = "log-likelihood", + aggregate_sample_scores: Callable = jnp.mean, ) -> jnp.ndarray: r"""Evaluate the goodness-of-fit of the model to the observed neural data. @@ -512,7 +548,7 @@ def score( return score def _initialize_parameters( - self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray + self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray ) -> Tuple[Union[dict, jnp.ndarray], jnp.ndarray]: """Initialize the parameters based on the structure and dimensions X and y. @@ -565,7 +601,7 @@ def func(x): # scipy root finding, much more stable than gradient descent func_root = root(func, y.mean(axis=0, keepdims=False), method="hybr") - if not jnp.allclose(func_root.fun, 0, atol=10 ** -4): + if not jnp.allclose(func_root.fun, 0, atol=10**-4): raise ValueError( "Could not set the initial intercept as the inverse of the firing rate for " "the provided link function. " @@ -591,10 +627,10 @@ def func(x): @cast_to_jax def fit( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, ): """Fit GLM to neural activity. @@ -648,7 +684,7 @@ def fit( params, state = self.solver_run(init_params, data, y) if tree_utils.pytree_map_and_reduce( - lambda x: jnp.any(jnp.isnan(x)), any, params + lambda x: jnp.any(jnp.isnan(x)), any, params ): raise ValueError( "Solver returned at least one NaN parameter, so solution is invalid!" @@ -689,9 +725,9 @@ def _set_coef_and_intercept(self, params): self.intercept_: jnp.ndarray = params[1] def simulate( - self, - random_key: jax.Array, - feedforward_input: DESIGN_INPUT_TYPE, + self, + random_key: jax.Array, + feedforward_input: DESIGN_INPUT_TYPE, ) -> Tuple[jnp.ndarray, jnp.ndarray]: """Simulate neural activity in response to a feed-forward input. @@ -748,7 +784,7 @@ def simulate( ) def estimate_resid_degrees_of_freedom( - self, X: DESIGN_INPUT_TYPE, n_samples: Optional[int] = None + self, X: DESIGN_INPUT_TYPE, n_samples: Optional[int] = None ): """ Estimate the degrees of freedom of the residuals. @@ -799,12 +835,12 @@ def estimate_resid_degrees_of_freedom( @cast_to_jax def initialize_solver( - self, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - init_params: Optional[ModelParams] = None, - **kwargs, + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + init_params: Optional[ModelParams] = None, + **kwargs, ) -> Tuple[ModelParams, NamedTuple]: """ Initialize the solver's state and optionally sets initial model parameters for the optimization process. @@ -865,13 +901,16 @@ def initialize_solver( # validate input self._validate(X, y, init_params) - # if using ProximalGradient, pass prox op + # if using ProximalGradient, pass prox op and regularizer strength if self.solver_name == "ProximalGradient": - self._solver_init_state, self._solver_update, self._solver_run = ( - super().instantiate_solver(self._predict_and_compute_loss, - self.regularizer.regularizer_strength, - prox=self.regularizer.get_proximal_operator(), - ) + ( + self._solver_init_state, + self._solver_update, + self._solver_run, + ) = super().instantiate_solver( + self._predict_and_compute_loss, + self.regularizer.regularizer_strength, + prox=self.regularizer.get_proximal_operator(), ) else: @@ -888,14 +927,14 @@ def initialize_solver( @cast_to_jax def update( - self, - params: Tuple[jnp.ndarray, jnp.ndarray], - opt_state: NamedTuple, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - n_samples: Optional[int] = None, - **kwargs, + self, + params: Tuple[jnp.ndarray, jnp.ndarray], + opt_state: NamedTuple, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + n_samples: Optional[int] = None, + **kwargs, ) -> jaxopt.OptStep: """ Update the model parameters and solver state. @@ -1060,10 +1099,10 @@ class PopulationGLM(GLM): """ def __init__( - self, - observation_model: obs.Observations = obs.PoissonObservations(), - feature_mask: Optional[jnp.ndarray] = None, - **kwargs + self, + observation_model: obs.Observations = obs.PoissonObservations(), + feature_mask: Optional[jnp.ndarray] = None, + **kwargs, ): super().__init__(observation_model=observation_model, **kwargs) self.feature_mask = feature_mask @@ -1084,7 +1123,7 @@ def feature_mask(self, feature_mask: Union[DESIGN_INPUT_TYPE, dict]): # check if the mask is of 0s and 1s if tree_utils.pytree_map_and_reduce( - lambda x: jnp.any(jnp.logical_and(x != 0, x != 1)), any, feature_mask + lambda x: jnp.any(jnp.logical_and(x != 0, x != 1)), any, feature_mask ): raise ValueError("'feature_mask' must contain only 0s and 1s!") @@ -1114,7 +1153,7 @@ def feature_mask(self, feature_mask: Union[DESIGN_INPUT_TYPE, dict]): @staticmethod def _check_input_dimensionality( - X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None + X: Union[FeaturePytree, jnp.ndarray] = None, y: jnp.ndarray = None ): if y is not None: validation.check_tree_leaves_dimensionality( @@ -1128,13 +1167,13 @@ def _check_input_dimensionality( X, expected_dim=2, err_message="X must be two-dimensional, with shape " - "(n_timebins, n_features) or pytree of the same shape.", + "(n_timebins, n_features) or pytree of the same shape.", ) @staticmethod def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ Validate the dimensions and consistency of parameters and data. @@ -1158,17 +1197,17 @@ def _check_params( params[0], expected_dim=2, err_message="params[0] must be an array or nemos.pytree.FeaturePytree " - "with array leafs of shape (n_features, n_neurons).", + "with array leafs of shape (n_features, n_neurons).", ) # check the dimensionality of intercept validation.check_tree_leaves_dimensionality( params[1], expected_dim=1, err_message="params[1] must be of shape (n_neurons,) but " - f"params[1] has {params[1].ndim} dimensions!", + f"params[1] has {params[1].ndim} dimensions!", ) if tree_utils.pytree_map_and_reduce( - lambda x: x.shape[1] != params[1].shape[0], all, params[0] + lambda x: x.shape[1] != params[1].shape[0], all, params[0] ): raise ValueError( "Inconsistent number of neurons. " @@ -1178,10 +1217,10 @@ def _check_params( return params def _check_input_and_params_consistency( - self, - params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], - X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, + self, + params: Tuple[Union[FeaturePytree, jnp.ndarray], jnp.ndarray], + X: Optional[Union[FeaturePytree, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, ): """Validate the number of features and structure in model parameters and input arguments. @@ -1207,7 +1246,7 @@ def _check_input_and_params_consistency( data, params[0], err_message=f"X and params[0] must be the same type, but X is " - f"{type(X)} and params[0] is {type(params[0])}", + f"{type(X)} and params[0] is {type(params[0])}", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -1216,8 +1255,8 @@ def _check_input_and_params_consistency( axis_1=0, axis_2=1, err_message="Inconsistent number of features. " - f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " - f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", + f"spike basis coefficients has {jax.tree_util.tree_map(lambda p: p.shape[0], params[0])} features, " + f"X has {jax.tree_util.tree_map(lambda x: x.shape[1], X)} features instead!", ) if y is not None: @@ -1226,8 +1265,8 @@ def _check_input_and_params_consistency( y, axis=1, err_message="Inconsistent number of neurons. " - f"spike basis coefficients assumes {jax.tree_util.tree_map(lambda p: p.shape[1], params[0])} neurons, " - f"y has {jax.tree_util.tree_map(lambda x: x.shape[1], y)} neurons instead!", + f"spike basis coefficients assumes {jax.tree_util.tree_map(lambda p: p.shape[1], params[0])} neurons, " + f"y has {jax.tree_util.tree_map(lambda x: x.shape[1], y)} neurons instead!", ) self._check_mask(X, y, params) @@ -1246,8 +1285,8 @@ def _check_mask(self, X, y, params): data, self.feature_mask, err_message=f"feature_mask and X must have the same structure, but feature_mask has structure " - f"{jax.tree_util.tree_structure(X)}, params[0] is of " - f"{jax.tree_util.tree_structure(self.feature_mask)} structure instead!", + f"{jax.tree_util.tree_structure(X)}, params[0] is of " + f"{jax.tree_util.tree_structure(self.feature_mask)} structure instead!", ) if isinstance(params[0], dict): @@ -1261,8 +1300,8 @@ def _check_mask(self, X, y, params): axis_1=0, axis_2=0, err_message="Inconsistent number of features. " - f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[0], self.feature_mask)} neurons, " - f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", + f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[0], self.feature_mask)} neurons, " + f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", ) # check the consistency of the feature axis validation.check_tree_axis_consistency( @@ -1271,16 +1310,16 @@ def _check_mask(self, X, y, params): axis_1=neural_axis, axis_2=1, err_message="Inconsistent number of neurons. " - f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[neural_axis], self.feature_mask)} neurons, " - f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", + f"feature_mask has {jax.tree_util.tree_map(lambda m: m.shape[neural_axis], self.feature_mask)} neurons, " + f"model coefficients have {jax.tree_util.tree_map(lambda x: x.shape[1], X)} instead!", ) @cast_to_jax def fit( - self, - X: Union[DESIGN_INPUT_TYPE, ArrayLike], - y: ArrayLike, - init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, + self, + X: Union[DESIGN_INPUT_TYPE, ArrayLike], + y: ArrayLike, + init_params: Optional[Tuple[Union[dict, ArrayLike], ArrayLike]] = None, ): """Fit GLM to the activity of a population of neurons. @@ -1345,7 +1384,7 @@ def _initialize_feature_mask(self, X, y): self._feature_mask = jnp.ones((X.shape[1], y.shape[1])) def _predict( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], X: jnp.ndarray ) -> jnp.ndarray: """ Predicts firing rates based on given parameters and design matrix. diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 93678fac..b59b9794 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -7,16 +7,14 @@ """ import abc -import inspect -import warnings -from typing import Any, Callable, NamedTuple, Optional, Tuple, Union +from typing import Any, Callable, Tuple, Union import jax import jax.numpy as jnp import jaxopt from numpy.typing import NDArray -from . import tree_utils, utils +from . import tree_utils from .base_class import DESIGN_INPUT_TYPE, Base from .proximal_operator import prox_group_lasso from .pytrees import FeaturePytree @@ -48,7 +46,8 @@ class Regularizer(Base, abc.ABC): Parameters ---------- regularizer_strength - Float representing the strength of the regularization being applied. + Float representing the strength of the regularization being applied. Only used if the solver method + is ProximalGradient. Default 1.0. Attributes @@ -63,8 +62,8 @@ class Regularizer(Base, abc.ABC): _default_solver: str = None def __init__( - self, - **kwargs, + self, + **kwargs, ): super().__init__(**kwargs) @@ -105,7 +104,7 @@ def penalized_loss(self, loss: Callable) -> Callable: pass def get_proximal_operator( - self, + self, ) -> ProximalOperator: """ Abstract method to retrieve the proximal operator for this solver. @@ -146,13 +145,13 @@ class are defined in the `allowed_solvers` attribute. "NonlinearCG", "ScipyBoundedMinimize", "LBFGSB", - "ProximalGradient" + "ProximalGradient", ) _default_solver = "GradientDescent" def __init__( - self, + self, ): super().__init__() @@ -160,7 +159,9 @@ def penalized_loss(self, loss: Callable): """Unregularized method does not add any penalty.""" return loss - def get_proximal_operator(self, ) -> ProximalOperator: + def get_proximal_operator( + self, + ) -> ProximalOperator: """Unregularized method has no proximal operator.""" def prox_op(params, hyperparams, scaling=1.0): @@ -199,20 +200,20 @@ class Ridge(Regularizer): "NonlinearCG", "ScipyBoundedMinimize", "LBFGSB", - "ProximalGradient" + "ProximalGradient", ) _default_solver = "GradientDescent" def __init__( - self, - regularizer_strength: float = 1.0, + self, + regularizer_strength: float = 1.0, ): super().__init__() self.regularizer_strength = regularizer_strength def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] ) -> jnp.ndarray: """ Compute the Ridge penalization for given parameters. @@ -230,10 +231,10 @@ def _penalization( def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: return ( - 0.5 - * self.regularizer_strength - * jnp.sum(jnp.power(coeff, 2)) - / intercept.shape[0] + 0.5 + * self.regularizer_strength + * jnp.sum(jnp.power(coeff, 2)) + / intercept.shape[0] ) # tree map the computation and sum over leaves @@ -248,7 +249,7 @@ def _penalized_loss(params, X, y): return _penalized_loss def get_proximal_operator( - self, + self, ) -> ProximalOperator: def prox_op(params, l2reg, scaling=1.0): Ws, bs = params @@ -273,19 +274,19 @@ class Lasso(Regularizer): set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ - _allowed_solvers = ("ProximalGradient,") + _allowed_solvers = "ProximalGradient," _default_solver = "ProximalGradient" def __init__( - self, - regularizer_strength: float = 1.0, + self, + regularizer_strength: float = 1.0, ): super().__init__() self.regularizer_strength = regularizer_strength def get_proximal_operator( - self, + self, ) -> ProximalOperator: """ Retrieve the proximal operator for Lasso regularization (L1 penalty). @@ -356,14 +357,14 @@ class GroupLasso(Regularizer): >>> print(f"coeff: {model.coef_}") """ - _allowed_solvers = ("ProximalGradient,") + _allowed_solvers = "ProximalGradient," _default_solver = "ProximalGradient" def __init__( - self, - mask: Union[NDArray, jnp.ndarray] = None, - regularizer_strength: float = 1.0, + self, + mask: Union[NDArray, jnp.ndarray] = None, + regularizer_strength: float = 1.0, ): super().__init__() self.regularizer_strength = regularizer_strength @@ -432,7 +433,7 @@ def penalized_loss(self, loss: Callable) -> Callable: return loss def get_proximal_operator( - self, + self, ) -> ProximalOperator: """ Retrieve the proximal operator for Group Lasso regularization. From 54973cdd2f29de0c0e54006beb36af8d575856b1 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 11 Jul 2024 11:16:01 -0400 Subject: [PATCH 015/225] fix lasso regularizer, move instantiate solver logic to base --- src/nemos/base_class.py | 44 ++++++++++++++++------------------------ src/nemos/glm.py | 24 ++++++---------------- src/nemos/regularizer.py | 37 +++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 47 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index ae7bc777..8b89b7fb 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -223,10 +223,10 @@ class BaseRegressor(Base, abc.ABC): | Regularizer | Default Solver | Available Solvers | +===============+==================+=============================================================+ | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB | + | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB | + | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ | Lasso | ProximalGradient | ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ @@ -327,7 +327,7 @@ def _check_solver_kwargs(solver_name, solver_kwargs): ) def instantiate_solver( - self, loss: Callable, *args: Any, prox: Optional[Callable] = None, **kwargs: Any + self, *args, **kwargs ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: """ Instantiate the solver with the provided loss function. @@ -366,6 +366,9 @@ def instantiate_solver( - solver_run: Function to execute the optimization process, applying multiple updates until a stopping criterion is met. """ + # use penalized loss based on regularizer + loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) + # check that the loss is Callable utils.assert_is_callable(loss, "loss") @@ -377,32 +380,14 @@ def instantiate_solver( f"{self._regularizer.allowed_solvers}." ) - # get the solver with given arguments. - # The "fun" argument is not always the first one, but it is always KEYWORD - # see jaxopt.EqualityConstrainedQP for example. The most general way is to pass it as keyword. - # The proximal gradient is added to the kwargs if passed. This avoids issues with over-writing - # the proximal operator. - if "prox" in self.solver_kwargs: - if prox is None: - raise ValueError( - f"Regularizer of type {self.regularizer.__class__.__name__} " - f"does not require a proximal operator!" - ) - else: - warnings.warn( - "Overwritten the user-defined proximal operator! " - "There is only one valid proximal operator for each regularizer type.", - UserWarning, - ) - # update the kwargs if prox is passed - if prox is not None: - solver_kwargs = self.solver_kwargs.copy() - solver_kwargs.update(prox=prox) - else: - solver_kwargs = self.solver_kwargs + # if using proximal gradient add to solver kwargs + if self.solver_name == "ProximalGradient": + self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) + # add self.regularizer_strength to args + args += (self.regularizer.regularizer_strength,) # instantiate the solver - solver = getattr(jaxopt, self._solver_name)(fun=loss, **solver_kwargs) + solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) def solver_run( init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray @@ -514,6 +499,11 @@ def _check_input_n_timepoints( f"y has {y.shape[0]} instead!" ) + @abc.abstractmethod + def _predict_and_compute_loss(self, params, X, y): + """The loss function for a given model to be optimized over.""" + pass + def _validate( self, X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 74e3536e..b781374c 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -52,8 +52,7 @@ def wrapper(*args, **kwargs): class GLM(BaseRegressor): - """ - Generalized Linear Model (GLM) for neural activity data. + r"""Generalized Linear Model (GLM) for neural activity data. This GLM implementation allows users to model neural activity based on a combination of exogenous inputs (like convolved currents or light intensities) and a choice of observation model. It is suitable for scenarios where @@ -901,22 +900,11 @@ def initialize_solver( # validate input self._validate(X, y, init_params) - # if using ProximalGradient, pass prox op and regularizer strength - if self.solver_name == "ProximalGradient": - ( - self._solver_init_state, - self._solver_update, - self._solver_run, - ) = super().instantiate_solver( - self._predict_and_compute_loss, - self.regularizer.regularizer_strength, - prox=self.regularizer.get_proximal_operator(), - ) - - else: - self._solver_init_state, self._solver_update, self._solver_run = ( - super().instantiate_solver(self._predict_and_compute_loss) - ) + ( + self._solver_init_state, + self._solver_update, + self._solver_run, + ) = super().instantiate_solver(*args, **kwargs) if isinstance(X, FeaturePytree): data = X.data diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index b59b9794..d8027408 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -261,7 +261,7 @@ def prox_op(params, l2reg, scaling=1.0): l2reg = jax.tree_util.tree_unflatten( struct, [l2reg] * struct.num_leaves ) - return jaxopt.prox.prox_lasso(Ws, l2reg, scaling=scaling), bs + return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs return prox_op @@ -312,8 +312,41 @@ def prox_op(params, l1reg, scaling=1.0): return prox_op + def _penalization( + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + ) -> jnp.ndarray: + """ + Compute the Lasso penalization for given parameters. + + Parameters + ---------- + params : + Model parameters for which to compute the penalization. + + Returns + ------- + float + The Lasso penalization value. + """ + + def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: + return ( + 0.5 + * self.regularizer_strength + * jnp.sum(jnp.abs(coeff)) + / intercept.shape[0] + ) + + # tree map the computation and sum over leaves + return tree_utils.pytree_map_and_reduce( + lambda x: l1_penalty(x, params[1]), sum, params[0] + ) + def penalized_loss(self, loss: Callable) -> Callable: - return loss + def _penalized_loss(params, X, y): + return loss(params, X, y) + self._penalization(params) + + return _penalized_loss class GroupLasso(Regularizer): From 060eb36b26ed0cff8d89c39e2e210084663ae603 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 11 Jul 2024 11:44:22 -0400 Subject: [PATCH 016/225] instantiate solver updates --- src/nemos/base_class.py | 6 +++++- src/nemos/glm.py | 2 +- src/nemos/regularizer.py | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 8b89b7fb..a63cffac 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -384,7 +384,11 @@ def instantiate_solver( if self.solver_name == "ProximalGradient": self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) # add self.regularizer_strength to args - args += (self.regularizer.regularizer_strength,) + # need to half the regularizer strength in order to conform to sklearn standards + # of multiplying penalization by 0.5 + args += (0.5 * self.regularizer.regularizer_strength,) + # set tolerance for solver + self.solver_kwargs.update(tol=10**-12) # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index b781374c..caeb669d 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -904,7 +904,7 @@ def initialize_solver( self._solver_init_state, self._solver_update, self._solver_run, - ) = super().instantiate_solver(*args, **kwargs) + ) = super().instantiate_solver() if isinstance(X, FeaturePytree): data = X.data diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index d8027408..69a691d6 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -463,6 +463,7 @@ def _check_mask(mask: jnp.ndarray): ) def penalized_loss(self, loss: Callable) -> Callable: + # TODO: fix this, I think we should be applying L1 loss same as Lasso return loss def get_proximal_operator( From 69acde6f8a134ac6e1241c47246b5c7b5ac45055 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 11 Jul 2024 12:25:58 -0400 Subject: [PATCH 017/225] fix scaling for proximal gradient --- src/nemos/base_class.py | 6 +----- src/nemos/regularizer.py | 8 ++++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index a63cffac..8b89b7fb 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -384,11 +384,7 @@ def instantiate_solver( if self.solver_name == "ProximalGradient": self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) # add self.regularizer_strength to args - # need to half the regularizer strength in order to conform to sklearn standards - # of multiplying penalization by 0.5 - args += (0.5 * self.regularizer.regularizer_strength,) - # set tolerance for solver - self.solver_kwargs.update(tol=10**-12) + args += (self.regularizer.regularizer_strength,) # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 69a691d6..c44b01d0 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -164,7 +164,7 @@ def get_proximal_operator( ) -> ProximalOperator: """Unregularized method has no proximal operator.""" - def prox_op(params, hyperparams, scaling=1.0): + def prox_op(params, hyperparams, scaling=0.5): Ws, bs = params return jaxopt.prox.prox_none(Ws, hyperparams, scaling=scaling), bs @@ -251,7 +251,7 @@ def _penalized_loss(params, X, y): def get_proximal_operator( self, ) -> ProximalOperator: - def prox_op(params, l2reg, scaling=1.0): + def prox_op(params, l2reg, scaling=0.5): Ws, bs = params l2reg /= bs.shape[0] # if Ws is a pytree, l2reg needs to be a pytree with the same @@ -298,7 +298,7 @@ def get_proximal_operator( term is not regularized. """ - def prox_op(params, l1reg, scaling=1.0): + def prox_op(params, l1reg, scaling=0.5): Ws, bs = params l1reg /= bs.shape[0] # if Ws is a pytree, l1reg needs to be a pytree with the same @@ -479,7 +479,7 @@ def get_proximal_operator( intercept term is not regularized. """ - def prox_op(params, regularizer_strength, scaling=1.0): + def prox_op(params, regularizer_strength, scaling=0.5): return prox_group_lasso( params, regularizer_strength, mask=self.mask, scaling=scaling ) From b66ad92186799c0b94f9a982da186235b067e122 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 12 Jul 2024 11:55:04 -0400 Subject: [PATCH 018/225] fix group lasso and model instantiation --- src/nemos/base_class.py | 40 ++++++++++++++++++----------- src/nemos/glm.py | 14 ++++++++--- src/nemos/regularizer.py | 54 ++++++++++++++++++++++++++-------------- 3 files changed, 71 insertions(+), 37 deletions(-) diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 8b89b7fb..5257307b 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -222,11 +222,11 @@ class BaseRegressor(Base, abc.ABC): +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | +---------------+------------------+-------------------------------------------------------------+ | Lasso | ProximalGradient | ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ @@ -366,12 +366,6 @@ def instantiate_solver( - solver_run: Function to execute the optimization process, applying multiple updates until a stopping criterion is met. """ - # use penalized loss based on regularizer - loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) - - # check that the loss is Callable - utils.assert_is_callable(loss, "loss") - # final check that solver is valid for chosen regularizer if self.solver_name not in self.regularizer.allowed_solvers: raise ValueError( @@ -380,11 +374,27 @@ def instantiate_solver( f"{self._regularizer.allowed_solvers}." ) - # if using proximal gradient add to solver kwargs - if self.solver_name == "ProximalGradient": - self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) - # add self.regularizer_strength to args - args += (self.regularizer.regularizer_strength,) + # only use penalized loss if not using proximal gradient descent + if self.solver_name != "ProximalGradient": + loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) + else: + loss = self._predict_and_compute_loss + + # check that the loss is Callable + utils.assert_is_callable(loss, "loss") + + # some parsing to make sure solver gets instantiated properly + match self.solver_name: + case "ProximalGradient": + self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) + # add self.regularizer_strength to args + args += (self.regularizer.regularizer_strength,) + case "LBFGSB": + if "bounds" not in kwargs.keys(): + warnings.warn( + "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." + ) + self.solver_name = "LBFGS" # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index caeb669d..7fd1d204 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -80,11 +80,11 @@ class GLM(BaseRegressor): +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, ScipyMinimize, NonlinearCG, | - | | | ScipyBoundedMinimize, LBFGSB, ProximalGradient | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | +---------------+------------------+-------------------------------------------------------------+ | Lasso | ProximalGradient | ProximalGradient | +---------------+------------------+-------------------------------------------------------------+ @@ -680,6 +680,12 @@ def fit( else: data = X + # check if mask has been set is using group lasso + # if mask has not been set, use a single group as default + if self.regularizer.__class__.__name__ == "GroupLasso": + if self.regularizer.mask is None: + self.regularizer.mask = jnp.ones((1, data.shape[1])) + params, state = self.solver_run(init_params, data, y) if tree_utils.pytree_map_and_reduce( diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index c44b01d0..02b56fce 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -141,10 +141,8 @@ class are defined in the `allowed_solvers` attribute. "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", "ProximalGradient", ) @@ -196,10 +194,8 @@ class Ridge(Regularizer): "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", "ProximalGradient", ) @@ -274,7 +270,7 @@ class Lasso(Regularizer): set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ - _allowed_solvers = "ProximalGradient," + _allowed_solvers = ("ProximalGradient",) _default_solver = "ProximalGradient" @@ -390,7 +386,7 @@ class GroupLasso(Regularizer): >>> print(f"coeff: {model.coef_}") """ - _allowed_solvers = "ProximalGradient," + _allowed_solvers = ("ProximalGradient",) _default_solver = "ProximalGradient" @@ -402,11 +398,7 @@ def __init__( super().__init__() self.regularizer_strength = regularizer_strength - if mask is not None: - self.mask = jnp.asarray(mask) - else: - # default mask if None is a singular group - self.mask = jnp.asarray([[1.0]]) + self.mask = mask @property def mask(self): @@ -414,8 +406,10 @@ def mask(self): return self._mask @mask.setter - def mask(self, mask: jnp.ndarray): - self._check_mask(mask) + def mask(self, mask: jnp.ndarray | None): + # check mask if passed by user, else will be initialized later + if mask is not None: + self._check_mask(mask) self._mask = mask @staticmethod @@ -453,7 +447,7 @@ def _check_mask(mask: jnp.ndarray): if jnp.any(mask.sum(axis=0) > 1): raise ValueError( "Incorrect group assignment. Some of the features are assigned " - "to more then one group." + "to more than one group." ) if not jnp.issubdtype(mask.dtype, jnp.floating): @@ -462,9 +456,33 @@ def _check_mask(mask: jnp.ndarray): f"Data type {mask.dtype} provided instead!" ) + def _penalization( + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + ) -> jnp.ndarray: + """ + Calculate the penalization. + """ + # conform to shape (1, n_features) if param is (n_features,) or (n_neurons, n_features) if + # param is (n_features, n_neurons) + param_with_extra_axis = jnp.atleast_2d(params[1].T) + + vec_prod = jax.vmap( + lambda x: self.mask * x, in_axes=0, out_axes=2 + ) # this vectorizes the product over the neurons, and adds the neuron axis as the last axis + + masked_param = vec_prod( + param_with_extra_axis + ) # this masks the param, (group, feature, neuron) + + penalty = jnp.linalg.norm(masked_param, axis=0).sum() + + return penalty + def penalized_loss(self, loss: Callable) -> Callable: - # TODO: fix this, I think we should be applying L1 loss same as Lasso - return loss + def _penalized_loss(params, X, y): + return loss(params, X, y) + self._penalization(params) + + return _penalized_loss def get_proximal_operator( self, From eacc0f7be93ca74be849360f59485fbfc64e6af1 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Fri, 12 Jul 2024 11:57:27 -0400 Subject: [PATCH 019/225] small typo --- src/nemos/regularizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 02b56fce..44d29903 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -464,7 +464,7 @@ def _penalization( """ # conform to shape (1, n_features) if param is (n_features,) or (n_neurons, n_features) if # param is (n_features, n_neurons) - param_with_extra_axis = jnp.atleast_2d(params[1].T) + param_with_extra_axis = jnp.atleast_2d(params[0].T) vec_prod = jax.vmap( lambda x: self.mask * x, in_axes=0, out_axes=2 From 79b786629e9f881a0db355b9d5df63f91d1ec931 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 12 Jul 2024 21:54:12 -0400 Subject: [PATCH 020/225] shuffle things around --- src/nemos/_regularizer_builder.py | 2 +- src/nemos/base_class.py | 408 +----------------------------- src/nemos/base_regressor.py | 388 ++++++++++++++++++++++++++++ src/nemos/glm.py | 11 +- src/nemos/regularizer.py | 14 +- src/nemos/typing.py | 47 ++++ 6 files changed, 443 insertions(+), 427 deletions(-) create mode 100644 src/nemos/base_regressor.py create mode 100644 src/nemos/typing.py diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 4521e2dc..b1697267 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -1,6 +1,6 @@ """Utility functions for creating regularizer object.""" -AVAILABLE_REGULARIZERS: ["unregularized", "ridge", "lasso", "group_lasso"] +AVAILABLE_REGULARIZERS = ["unregularized", "ridge", "lasso", "group_lasso"] def create_regularizer(name: str): diff --git a/src/nemos/base_class.py b/src/nemos/base_class.py index 5257307b..67b63240 100644 --- a/src/nemos/base_class.py +++ b/src/nemos/base_class.py @@ -3,53 +3,10 @@ # required to get ArrayLike to render correctly from __future__ import annotations -import abc import inspect import warnings from collections import defaultdict -from typing import Any, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING, Callable - -import jax -import jax.numpy as jnp -import jaxopt -from numpy.typing import ArrayLike, NDArray - -from . import validation, utils -from .pytrees import FeaturePytree -from ._regularizer_builder import create_regularizer - -DESIGN_INPUT_TYPE = Union[jnp.ndarray, FeaturePytree] - -if TYPE_CHECKING: - from regularizer import Regularizer - -SolverRun = Callable[ - [ - Any, # parameters, could be any pytree - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - jaxopt.OptStep, -] - -SolverInit = Callable[ - [ - Any, # parameters, could be any pytree - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - NamedTuple, -] - -SolverUpdate = Callable[ - [ - Any, # parameters, could be any pytree - NamedTuple, - jnp.ndarray, # Predictors (i.e. model design for GLM) - jnp.ndarray, - ], # Output (neural activity) - jaxopt.OptStep, -] +from typing import Any class Base: @@ -194,366 +151,3 @@ def _get_param_names(cls): parameters.remove("kwargs") # Extract and sort argument names excluding 'self' return sorted(parameters) - - -class BaseRegressor(Base, abc.ABC): - """Abstract base class for GLM regression models. - - This class encapsulates the common functionality for Generalized Linear Models (GLM) - regression models. It provides an abstraction for fitting the model, making predictions, - scoring the model, simulating responses, and preprocessing data. Concrete classes - are expected to provide specific implementations of the abstract methods defined here. - - Parameters - ---------- - regularizer : - Regularization to use for model optimization. Defines the regularization scheme - and related parameters. - Default is UnRegularized regression. - solver_name : - Solver to use for model optimization. Defines the optimization scheme and related parameters. - The solver must be an appropriate match for the chosen regularizer. - Default is `None`. If no solver specified, one will be chosen based on the regularizer. - Please see table below for regularizer/optimizer pairings. - solver_kwargs : - Optional dictionary for keyword arguments that are passed to the solver when instantiated. - E.g. stepsize, acceleration, value_and_grad, etc. - - +---------------+------------------+-------------------------------------------------------------+ - | Regularizer | Default Solver | Available Solvers | - +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Lasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ - | GroupLasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ - - - See Also - -------- - Concrete models: - - - [`GLM`](../glm/#nemos.glm.GLM): A feed-forward GLM implementation. - - [`GLMRecurrent`](../glm/#nemos.glm.GLMRecurrent): A recurrent GLM implementation. - """ - - def __init__( - self, - regularizer: str | Regularizer = "unregularized", - solver_name: str = None, - solver_kwargs: Optional[dict] = None, - ): - self.regularizer = regularizer - - # no solver name provided, use default - if solver_name is None: - self.solver_name = self.regularizer.default_solver - else: - self.solver_name = solver_name - - if solver_kwargs is None: - solver_kwargs = dict() - self.solver_kwargs = solver_kwargs - - @property - def regularizer(self) -> Union[None, Regularizer]: - """Getter for the regularizer attribute.""" - return self._regularizer - - @regularizer.setter - def regularizer(self, regularizer: str | Regularizer): - """Setter for the regularizer attribute.""" - # instantiate regularizer if str - if isinstance(regularizer, str): - self._regularizer = create_regularizer(name=regularizer) - else: - self._regularizer = regularizer - - @property - def solver_name(self) -> str: - """Getter for the solver_name attribute.""" - return self._solver_name - - @solver_name.setter - def solver_name(self, solver_name: str): - """Setter for the solver_name attribute.""" - # check if solver str passed is valid for regularizer - if solver_name not in self._regularizer.allowed_solvers: - raise ValueError( - f"The solver: {solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}." - ) - self._solver_name = solver_name - - @property - def solver_kwargs(self): - """Getter for the solver_kwargs attribute.""" - return self._solver_kwargs - - @solver_kwargs.setter - def solver_kwargs(self, solver_kwargs: dict): - """Setter for the solver_kwargs attribute.""" - self._check_solver_kwargs(self.solver_name, solver_kwargs) - self._solver_kwargs = solver_kwargs - - @staticmethod - def _check_solver_kwargs(solver_name, solver_kwargs): - """ - Check if provided solver keyword arguments are valid. - - Parameters - ---------- - solver_name : - Name of the solver. - solver_kwargs : - Additional keyword arguments for the solver. - - Raises - ------ - NameError - If any of the solver keyword arguments are not valid. - """ - solver_args = inspect.getfullargspec(getattr(jaxopt, solver_name)).args - undefined_kwargs = set(solver_kwargs.keys()).difference(solver_args) - if undefined_kwargs: - raise NameError( - f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" - ) - - def instantiate_solver( - self, *args, **kwargs - ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: - """ - Instantiate the solver with the provided loss function. - - Instantiate the solver with the provided loss function, and return callable functions - that initialize the solver state, update the model parameters, and run the optimization. - - This method creates a solver instance from jaxopt library, tailored to the specific loss - function and regularization approach defined by the Regularizer instance. It also handles - the proximal operator if required for the optimization method. The returned functions are - directly usable in optimization loops, simplifying the syntax by pre-setting - common arguments like regularization strength and other hyperparameters. - - Parameters - ---------- - loss : - The loss function to be optimized. - - *args: - Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing - strength for proximal gradient methods. - - prox: - Optional, the proximal projection operator. - - *kwargs: - Keyword arguments for the jaxopt `solver.run` method. - - Returns - ------- - : - A tuple containing three callable functions: - - solver_init_state: Function to initialize the solver's state, necessary before starting the optimization. - - solver_update: Function to perform a single update step in the optimization process, - returning new parameters and state. - - solver_run: Function to execute the optimization process, applying multiple updates until a - stopping criterion is met. - """ - # final check that solver is valid for chosen regularizer - if self.solver_name not in self.regularizer.allowed_solvers: - raise ValueError( - f"The solver: {self.solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " - f"{self._regularizer.allowed_solvers}." - ) - - # only use penalized loss if not using proximal gradient descent - if self.solver_name != "ProximalGradient": - loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) - else: - loss = self._predict_and_compute_loss - - # check that the loss is Callable - utils.assert_is_callable(loss, "loss") - - # some parsing to make sure solver gets instantiated properly - match self.solver_name: - case "ProximalGradient": - self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) - # add self.regularizer_strength to args - args += (self.regularizer.regularizer_strength,) - case "LBFGSB": - if "bounds" not in kwargs.keys(): - warnings.warn( - "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." - ) - self.solver_name = "LBFGS" - - # instantiate the solver - solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) - - def solver_run( - init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray - ) -> jaxopt.OptStep: - return solver.run(init_params, *args, *run_args, **kwargs) - - def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: - return solver.update( - params, state, *args, *run_args, **kwargs, **run_kwargs - ) - - def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: - return solver.init_state( - params, state, *args, *run_args, **kwargs, **run_kwargs - ) - - return solver_init_state, solver_update, solver_run - - @abc.abstractmethod - def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): - """Fit the model to neural activity.""" - pass - - @abc.abstractmethod - def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: - """Predict rates based on fit parameters.""" - pass - - @abc.abstractmethod - def score( - self, - X: DESIGN_INPUT_TYPE, - y: Union[NDArray, jnp.ndarray], - # may include score_type or other additional model dependent kwargs - **kwargs, - ) -> jnp.ndarray: - """Score the predicted firing rates (based on fit) to the target neural activity.""" - pass - - @abc.abstractmethod - def simulate( - self, - random_key: jax.Array, - feed_forward_input: DESIGN_INPUT_TYPE, - ): - """Simulate neural activity in response to a feed-forward input and recurrent activity.""" - pass - - @staticmethod - @abc.abstractmethod - def _check_params( - params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], - data_type: Optional[jnp.dtype] = None, - ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: - """ - Validate the dimensions and consistency of parameters and data. - - This function checks the consistency of shapes and dimensions for model - parameters. - It ensures that the parameters and data are compatible for the model. - - """ - pass - - @staticmethod - @abc.abstractmethod - def _check_input_dimensionality( - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, - ): - pass - - @abc.abstractmethod - def _get_coef_and_intercept(self) -> Tuple[Any, Any]: - """Pack coef_ and intercept_ into a params pytree.""" - pass - - @abc.abstractmethod - def _set_coef_and_intercept(self, params: Any): - """Unpack and store params pytree to coef_ and intercept_.""" - pass - - @staticmethod - @abc.abstractmethod - def _check_input_and_params_consistency( - params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], - X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, - y: Optional[jnp.ndarray] = None, - ): - """Validate the number of features in model parameters and input arguments. - - Raises - ------ - ValueError - - if the number of features is inconsistent between params[1] and X - (when provided). - - """ - pass - - @staticmethod - def _check_input_n_timepoints( - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray - ): - if y.shape[0] != X.shape[0]: - raise ValueError( - "The number of time-points in X and y must agree. " - f"X has {X.shape[0]} time-points, " - f"y has {y.shape[0]} instead!" - ) - - @abc.abstractmethod - def _predict_and_compute_loss(self, params, X, y): - """The loss function for a given model to be optimized over.""" - pass - - def _validate( - self, - X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], - y: Union[NDArray, jnp.ndarray], - init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], - ): - # check input dimensionality - self._check_input_dimensionality(X, y) - self._check_input_n_timepoints(X, y) - - # error if all samples are invalid - validation.error_all_invalid(X, y) - - # validate input and params consistency - init_params = self._check_params(init_params) - - # validate input and params consistency - self._check_input_and_params_consistency(init_params, X=X, y=y) - - @abc.abstractmethod - def update( - self, - params: Tuple[jnp.ndarray, jnp.ndarray], - opt_state: NamedTuple, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - **kwargs, - ) -> jaxopt.OptStep: - """Run a single update step of the jaxopt solver.""" - pass - - @abc.abstractmethod - def initialize_solver( - self, - X: DESIGN_INPUT_TYPE, - y: jnp.ndarray, - *args, - params: Optional = None, - **kwargs, - ) -> Tuple[Any, NamedTuple]: - """Initialize the solver's state and optionally sets initial model parameters for the optimization.""" - pass diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py new file mode 100644 index 00000000..3fd3eb5b --- /dev/null +++ b/src/nemos/base_regressor.py @@ -0,0 +1,388 @@ +"""Abstract class for regression models.""" + +# required to get ArrayLike to render correctly +from __future__ import annotations + +import abc +import inspect +import warnings +from typing import Any, NamedTuple, Optional, Tuple, Union + +import jax +import jax.numpy as jnp +import jaxopt +from numpy.typing import ArrayLike, NDArray + +from . import utils, validation +from ._regularizer_builder import AVAILABLE_REGULARIZERS, create_regularizer +from .base_class import Base +from .regularizer import Regularizer +from .typing import DESIGN_INPUT_TYPE, SolverInit, SolverRun, SolverUpdate + + +class BaseRegressor(Base, abc.ABC): + """Abstract base class for GLM regression models. + + This class encapsulates the common functionality for Generalized Linear Models (GLM) + regression models. It provides an abstraction for fitting the model, making predictions, + scoring the model, simulating responses, and preprocessing data. Concrete classes + are expected to provide specific implementations of the abstract methods defined here. + + Parameters + ---------- + regularizer : + Regularization to use for model optimization. Defines the regularization scheme + and related parameters. + Default is UnRegularized regression. + solver_name : + Solver to use for model optimization. Defines the optimization scheme and related parameters. + The solver must be an appropriate match for the chosen regularizer. + Default is `None`. If no solver specified, one will be chosen based on the regularizer. + Please see table below for regularizer/optimizer pairings. + solver_kwargs : + Optional dictionary for keyword arguments that are passed to the solver when instantiated. + E.g. stepsize, acceleration, value_and_grad, etc. + + +---------------+------------------+-------------------------------------------------------------+ + | Regularizer | Default Solver | Available Solvers | + +===============+==================+=============================================================+ + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Lasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | GroupLasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + + + See Also + -------- + Concrete models: + + - [`GLM`](../glm/#nemos.glm.GLM): A feed-forward GLM implementation. + - [`GLMRecurrent`](../glm/#nemos.glm.GLMRecurrent): A recurrent GLM implementation. + """ + + def __init__( + self, + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: Optional[dict] = None, + ): + self.regularizer = regularizer + + # no solver name provided, use default + if solver_name is None: + self.solver_name = self.regularizer.default_solver + else: + self.solver_name = solver_name + + if solver_kwargs is None: + solver_kwargs = dict() + self.solver_kwargs = solver_kwargs + + @property + def regularizer(self) -> Union[None, Regularizer]: + """Getter for the regularizer attribute.""" + return self._regularizer + + @regularizer.setter + def regularizer(self, regularizer: str | Regularizer): + """Setter for the regularizer attribute.""" + # instantiate regularizer if str + if isinstance(regularizer, str): + self._regularizer = create_regularizer(name=regularizer) + elif isinstance(regularizer, Regularizer): + self._regularizer = regularizer + else: + raise TypeError( + f"The regularizer should be either a string from {AVAILABLE_REGULARIZERS} " + f"or an instance of `nemos.regularizer.Regularizer`" + ) + + @property + def solver_name(self) -> str: + """Getter for the solver_name attribute.""" + return self._solver_name + + @solver_name.setter + def solver_name(self, solver_name: str): + """Setter for the solver_name attribute.""" + # check if solver str passed is valid for regularizer + if solver_name not in self._regularizer.allowed_solvers: + raise ValueError( + f"The solver: {solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}." + ) + self._solver_name = solver_name + + @property + def solver_kwargs(self): + """Getter for the solver_kwargs attribute.""" + return self._solver_kwargs + + @solver_kwargs.setter + def solver_kwargs(self, solver_kwargs: dict): + """Setter for the solver_kwargs attribute.""" + self._check_solver_kwargs(self.solver_name, solver_kwargs) + self._solver_kwargs = solver_kwargs + + @staticmethod + def _check_solver_kwargs(solver_name, solver_kwargs): + """ + Check if provided solver keyword arguments are valid. + + Parameters + ---------- + solver_name : + Name of the solver. + solver_kwargs : + Additional keyword arguments for the solver. + + Raises + ------ + NameError + If any of the solver keyword arguments are not valid. + """ + solver_args = inspect.getfullargspec(getattr(jaxopt, solver_name)).args + undefined_kwargs = set(solver_kwargs.keys()).difference(solver_args) + if undefined_kwargs: + raise NameError( + f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" + ) + + def instantiate_solver( + self, *args, **kwargs + ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: + """ + Instantiate the solver with the provided loss function. + + Instantiate the solver with the provided loss function, and return callable functions + that initialize the solver state, update the model parameters, and run the optimization. + + This method creates a solver instance from jaxopt library, tailored to the specific loss + function and regularization approach defined by the Regularizer instance. It also handles + the proximal operator if required for the optimization method. The returned functions are + directly usable in optimization loops, simplifying the syntax by pre-setting + common arguments like regularization strength and other hyperparameters. + + Parameters + ---------- + loss : + The loss function to be optimized. + + *args: + Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing + strength for proximal gradient methods. + + prox: + Optional, the proximal projection operator. + + *kwargs: + Keyword arguments for the jaxopt `solver.run` method. + + Returns + ------- + : + A tuple containing three callable functions: + - solver_init_state: Function to initialize the solver's state, necessary before starting the optimization. + - solver_update: Function to perform a single update step in the optimization process, + returning new parameters and state. + - solver_run: Function to execute the optimization process, applying multiple updates until a + stopping criterion is met. + """ + # final check that solver is valid for chosen regularizer + if self.solver_name not in self.regularizer.allowed_solvers: + raise ValueError( + f"The solver: {self.solver_name} is not allowed for " + f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.allowed_solvers}." + ) + + # only use penalized loss if not using proximal gradient descent + if self.solver_name != "ProximalGradient": + loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) + else: + loss = self._predict_and_compute_loss + + # check that the loss is Callable + utils.assert_is_callable(loss, "loss") + + # some parsing to make sure solver gets instantiated properly + match self.solver_name: + case "ProximalGradient": + self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) + # add self.regularizer_strength to args + args += (self.regularizer.regularizer_strength,) + case "LBFGSB": + if "bounds" not in kwargs.keys(): + warnings.warn( + "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." + ) + self.solver_name = "LBFGS" + + # instantiate the solver + solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) + + def solver_run( + init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray + ) -> jaxopt.OptStep: + return solver.run(init_params, *args, *run_args, **kwargs) + + def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: + return solver.update( + params, state, *args, *run_args, **kwargs, **run_kwargs + ) + + def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: + return solver.init_state( + params, state, *args, *run_args, **kwargs, **run_kwargs + ) + + return solver_init_state, solver_update, solver_run + + @abc.abstractmethod + def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): + """Fit the model to neural activity.""" + pass + + @abc.abstractmethod + def predict(self, X: DESIGN_INPUT_TYPE) -> jnp.ndarray: + """Predict rates based on fit parameters.""" + pass + + @abc.abstractmethod + def score( + self, + X: DESIGN_INPUT_TYPE, + y: Union[NDArray, jnp.ndarray], + # may include score_type or other additional model dependent kwargs + **kwargs, + ) -> jnp.ndarray: + """Score the predicted firing rates (based on fit) to the target neural activity.""" + pass + + @abc.abstractmethod + def simulate( + self, + random_key: jax.Array, + feed_forward_input: DESIGN_INPUT_TYPE, + ): + """Simulate neural activity in response to a feed-forward input and recurrent activity.""" + pass + + @staticmethod + @abc.abstractmethod + def _check_params( + params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], + data_type: Optional[jnp.dtype] = None, + ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: + """ + Validate the dimensions and consistency of parameters and data. + + This function checks the consistency of shapes and dimensions for model + parameters. + It ensures that the parameters and data are compatible for the model. + + """ + pass + + @staticmethod + @abc.abstractmethod + def _check_input_dimensionality( + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, + ): + pass + + @abc.abstractmethod + def _get_coef_and_intercept(self) -> Tuple[Any, Any]: + """Pack coef_ and intercept_ into a params pytree.""" + pass + + @abc.abstractmethod + def _set_coef_and_intercept(self, params: Any): + """Unpack and store params pytree to coef_ and intercept_.""" + pass + + @staticmethod + @abc.abstractmethod + def _check_input_and_params_consistency( + params: Tuple[Union[DESIGN_INPUT_TYPE, jnp.ndarray], jnp.ndarray], + X: Optional[Union[DESIGN_INPUT_TYPE, jnp.ndarray]] = None, + y: Optional[jnp.ndarray] = None, + ): + """Validate the number of features in model parameters and input arguments. + + Raises + ------ + ValueError + - if the number of features is inconsistent between params[1] and X + (when provided). + + """ + pass + + @staticmethod + def _check_input_n_timepoints( + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], y: jnp.ndarray + ): + if y.shape[0] != X.shape[0]: + raise ValueError( + "The number of time-points in X and y must agree. " + f"X has {X.shape[0]} time-points, " + f"y has {y.shape[0]} instead!" + ) + + @abc.abstractmethod + def _predict_and_compute_loss(self, params, X, y): + """The loss function for a given model to be optimized over.""" + pass + + def _validate( + self, + X: Union[DESIGN_INPUT_TYPE, jnp.ndarray], + y: Union[NDArray, jnp.ndarray], + init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], + ): + # check input dimensionality + self._check_input_dimensionality(X, y) + self._check_input_n_timepoints(X, y) + + # error if all samples are invalid + validation.error_all_invalid(X, y) + + # validate input and params consistency + init_params = self._check_params(init_params) + + # validate input and params consistency + self._check_input_and_params_consistency(init_params, X=X, y=y) + + @abc.abstractmethod + def update( + self, + params: Tuple[jnp.ndarray, jnp.ndarray], + opt_state: NamedTuple, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + **kwargs, + ) -> jaxopt.OptStep: + """Run a single update step of the jaxopt solver.""" + pass + + @abc.abstractmethod + def initialize_solver( + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + *args, + params: Optional = None, + **kwargs, + ) -> Tuple[Any, NamedTuple]: + """Initialize the solver's state and optionally sets initial model parameters for the optimization.""" + pass diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 7fd1d204..6e3242d2 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import wraps -from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, Literal, NamedTuple, Optional, Tuple, Union import jax import jax.numpy as jnp @@ -15,16 +15,11 @@ from . import observation_models as obs from . import regularizer as reg from . import tree_utils, validation -from .base_class import ( - DESIGN_INPUT_TYPE, - BaseRegressor, - SolverRun, - SolverUpdate, - SolverInit, -) +from .base_regressor import BaseRegressor from .exceptions import NotFittedError from .pytrees import FeaturePytree from .type_casting import jnp_asarray_if, support_pynapple +from .typing import DESIGN_INPUT_TYPE, SolverInit, SolverRun, SolverUpdate if TYPE_CHECKING: from regularizer import Regularizer diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 44d29903..85e3b03e 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -7,7 +7,7 @@ """ import abc -from typing import Any, Callable, Tuple, Union +from typing import Callable, Tuple, Union import jax import jax.numpy as jnp @@ -15,18 +15,10 @@ from numpy.typing import NDArray from . import tree_utils -from .base_class import DESIGN_INPUT_TYPE, Base +from .base_class import Base from .proximal_operator import prox_group_lasso from .pytrees import FeaturePytree - -ProximalOperator = Callable[ - [ - Any, # parameters, could be any pytree - float, # Regularizer strength (for now float, eventually pytree) - float, - ], # Step-size for optimization (must be a float) - Tuple[jnp.ndarray, jnp.ndarray], -] +from .typing import DESIGN_INPUT_TYPE, ProximalOperator __all__ = ["UnRegularized", "Ridge", "Lasso", "GroupLasso"] diff --git a/src/nemos/typing.py b/src/nemos/typing.py new file mode 100644 index 00000000..792655a0 --- /dev/null +++ b/src/nemos/typing.py @@ -0,0 +1,47 @@ +"""Collection of nemos typing.""" + +from typing import Any, Callable, NamedTuple, Tuple, Union + +import jax.numpy as jnp +import jaxopt + +from .pytrees import FeaturePytree + +DESIGN_INPUT_TYPE = Union[jnp.ndarray, FeaturePytree] + +SolverRun = Callable[ + [ + Any, # parameters, could be any pytree + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + jaxopt.OptStep, +] + +SolverInit = Callable[ + [ + Any, # parameters, could be any pytree + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + NamedTuple, +] + +SolverUpdate = Callable[ + [ + Any, # parameters, could be any pytree + NamedTuple, + jnp.ndarray, # Predictors (i.e. model design for GLM) + jnp.ndarray, + ], # Output (neural activity) + jaxopt.OptStep, +] + +ProximalOperator = Callable[ + [ + Any, # parameters, could be any pytree + float, # Regularizer strength (for now float, eventually pytree) + float, + ], # Step-size for optimization (must be a float) + Tuple[jnp.ndarray, jnp.ndarray], +] From 6d617c85af6389670bf7a7e52f948195c23a9f3d Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 12 Jul 2024 22:06:06 -0400 Subject: [PATCH 021/225] use isinstance for checking GroupLasso --- src/nemos/glm.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 6e3242d2..e61f73af 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -13,17 +13,14 @@ from scipy.optimize import root from . import observation_models as obs -from . import regularizer as reg from . import tree_utils, validation from .base_regressor import BaseRegressor from .exceptions import NotFittedError from .pytrees import FeaturePytree +from .regularizer import GroupLasso, Lasso, Regularizer, Ridge from .type_casting import jnp_asarray_if, support_pynapple from .typing import DESIGN_INPUT_TYPE, SolverInit, SolverRun, SolverUpdate -if TYPE_CHECKING: - from regularizer import Regularizer - ModelParams = Tuple[jnp.ndarray, jnp.ndarray] @@ -677,7 +674,7 @@ def fit( # check if mask has been set is using group lasso # if mask has not been set, use a single group as default - if self.regularizer.__class__.__name__ == "GroupLasso": + if isinstance(self.regularizer, GroupLasso): if self.regularizer.mask is None: self.regularizer.mask = jnp.ones((1, data.shape[1])) @@ -822,10 +819,10 @@ def estimate_resid_degrees_of_freedom( # if the regularizer is lasso use the non-zero # coeff as an estimate of the dof # see https://arxiv.org/abs/0712.0881 - if isinstance(self.regularizer, (reg.GroupLasso, reg.Lasso)): + if isinstance(self.regularizer, (GroupLasso, Lasso)): coef, _ = self._get_coef_and_intercept() return n_samples - jnp.sum(jnp.isclose(coef, jnp.zeros_like(coef))) - elif isinstance(self.regularizer, reg.Ridge): + elif isinstance(self.regularizer, Ridge): # for Ridge, use the tot parameters (X.shape[1] + intercept) return n_samples - X.shape[1] - 1 else: From 13caece9845011069318fd0e98ee11f1e6198462 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 12 Jul 2024 22:07:38 -0400 Subject: [PATCH 022/225] remove type_checking --- src/nemos/glm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index e61f73af..2bc5cc0d 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import wraps -from typing import TYPE_CHECKING, Callable, Literal, NamedTuple, Optional, Tuple, Union +from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union import jax import jax.numpy as jnp From f15e176854456b5c90fbf2695266dc1b2eb51dcd Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 12 Jul 2024 22:47:45 -0400 Subject: [PATCH 023/225] fixed docs --- docs/api_guide/plot_02_glm_demo.py | 15 +++++---------- docs/api_guide/plot_03_glm_pytree.py | 2 +- docs/api_guide/plot_04_population_glm.py | 6 +++--- docs/api_guide/plot_05_batch_glm.py | 14 ++++++++------ docs/neural_modeling/plot_01_current_injection.py | 2 +- docs/neural_modeling/plot_02_head_direction.py | 11 ++++++----- docs/neural_modeling/plot_03_grid_cells.py | 3 ++- docs/neural_modeling/plot_04_v1_cells.py | 2 +- docs/neural_modeling/plot_05_place_cells.py | 4 +++- docs/neural_modeling/plot_06_calcium_imaging.py | 6 +++--- 10 files changed, 33 insertions(+), 32 deletions(-) diff --git a/docs/api_guide/plot_02_glm_demo.py b/docs/api_guide/plot_02_glm_demo.py index 8a5d3a5c..cafbd97e 100644 --- a/docs/api_guide/plot_02_glm_demo.py +++ b/docs/api_guide/plot_02_glm_demo.py @@ -94,17 +94,12 @@ # Poisson observation model with soft-plus NL observation_models = nmo.observation_models.PoissonObservations(jax.nn.softplus) -# Observation model -regularizer = nmo.regularizer.Ridge( - solver_name="LBFGS", - regularizer_strength=0.1, - solver_kwargs={"tol":10**-10} -) # define the GLM model = nmo.glm.GLM( observation_model=observation_models, - regularizer=regularizer, + solver_name="LBFGS", + solver_kwargs={"tol":10**-10}, ) print("Regularizer type: ", type(model.regularizer)) @@ -172,7 +167,7 @@ # # **Lasso** -model.set_params(regularizer=nmo.regularizer.Lasso()) +model.set_params(regularizer=nmo.regularizer.Lasso(), solver_name="ProximalGradient") cls = model_selection.GridSearchCV(model, parameter_grid, cv=2) cls.fit(X, spikes) @@ -189,8 +184,8 @@ mask[0, [0, -1]] = 1 mask[1, 1:-1] = 1 -regularizer = nmo.regularizer.GroupLasso("ProximalGradient", mask=mask) -model.set_params(regularizer=regularizer) +regularizer = nmo.regularizer.GroupLasso(mask=mask) +model.set_params(regularizer=regularizer, solver_name="ProximalGradient") cls = model_selection.GridSearchCV(model, parameter_grid, cv=2) cls.fit(X, spikes) diff --git a/docs/api_guide/plot_03_glm_pytree.py b/docs/api_guide/plot_03_glm_pytree.py index 3ef7ac84..1ea2dbae 100644 --- a/docs/api_guide/plot_03_glm_pytree.py +++ b/docs/api_guide/plot_03_glm_pytree.py @@ -285,7 +285,7 @@ # Running the GLM is identical to before, but we can see that our coef_ # FeaturePytree now has two separate keys, one for each feature type. -model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized(solver_name="LBFGS")) +model = nmo.glm.GLM(solver_name="LBFGS") model.fit(X, spikes) model.coef_ diff --git a/docs/api_guide/plot_04_population_glm.py b/docs/api_guide/plot_04_population_glm.py index 026aac8f..84282477 100644 --- a/docs/api_guide/plot_04_population_glm.py +++ b/docs/api_guide/plot_04_population_glm.py @@ -104,7 +104,7 @@ # after the model is fit. # set a quasi-newton solver and low tolerance for better numerical precision -model = nmo.glm.PopulationGLM(regularizer=nmo.regularizer.UnRegularized("LBFGS", solver_kwargs={"tol": 10**-12})) +model = nmo.glm.PopulationGLM(solver_name="LBFGS", solver_kwargs={"tol": 10**-12}) # set the mask model.feature_mask = feature_mask @@ -135,7 +135,7 @@ # loop over the neurons and fit a GLM for neuron in range(2): model_neu = nmo.glm.GLM( - regularizer=nmo.regularizer.UnRegularized("LBFGS", solver_kwargs={"tol":10**-12}) + solver_name="LBFGS", solver_kwargs={"tol":10**-12} ) model_neu.fit(input_features[:, features_by_neuron[neuron]], spikes[:, neuron]) coeff[:, neuron] = model_neu.coef_ @@ -177,7 +177,7 @@ ) # fit a model -model_tree = nmo.glm.PopulationGLM(regularizer=nmo.regularizer.UnRegularized("LBFGS"), feature_mask=pytree_mask) +model_tree = nmo.glm.PopulationGLM(solver_name="LBFGS", feature_mask=pytree_mask) model_tree.fit(pytree_features, spikes) # print the coefficients diff --git a/docs/api_guide/plot_05_batch_glm.py b/docs/api_guide/plot_05_batch_glm.py index 3fcb2cd9..cc48818a 100644 --- a/docs/api_guide/plot_05_batch_glm.py +++ b/docs/api_guide/plot_05_batch_glm.py @@ -47,10 +47,8 @@ # In jaxopt, this can be done by setting the parameters `acceleration` to False and setting the `stepsize`. # glm = nmo.glm.PopulationGLM( - regularizer=nmo.regularizer.UnRegularized( - solver_name="GradientDescent", - solver_kwargs={"stepsize": 0.1, "acceleration": False} - ) + solver_name="GradientDescent", + solver_kwargs={"stepsize": 0.1, "acceleration": False} ) # %% @@ -163,8 +161,12 @@ def batcher(): # %% # Now that the full model is fitted, we are scoring the full model and the batch model against the full datasets to compare the scores. # The score is pseudo-R2 -full_scores = full_model.score(X, Y, aggregate_sample_scores=lambda x:np.mean(x, axis=0)) -batch_scores = glm.score(X, Y, aggregate_sample_scores=lambda x:np.mean(x, axis=0)) +full_scores = full_model.score( + X, Y, aggregate_sample_scores=lambda x:np.mean(x, axis=0), score_type="pseudo-r2-McFadden" +) +batch_scores = glm.score( + X, Y, aggregate_sample_scores=lambda x:np.mean(x, axis=0), score_type="pseudo-r2-McFadden" +) # %% # Let's compare scores for each neurons as well as the coefficients. diff --git a/docs/neural_modeling/plot_01_current_injection.py b/docs/neural_modeling/plot_01_current_injection.py index ef32cd81..694ae864 100644 --- a/docs/neural_modeling/plot_01_current_injection.py +++ b/docs/neural_modeling/plot_01_current_injection.py @@ -502,7 +502,7 @@ # # Initialize the model w/regularizer and solver -model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized(solver_name="LBFGS")) +model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized(), solver_name="LBFGS") # %% # diff --git a/docs/neural_modeling/plot_02_head_direction.py b/docs/neural_modeling/plot_02_head_direction.py index 41ccb0b6..527d2c8c 100644 --- a/docs/neural_modeling/plot_02_head_direction.py +++ b/docs/neural_modeling/plot_02_head_direction.py @@ -255,7 +255,7 @@ # define the GLM object -model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized("LBFGS")) +model = nmo.glm.GLM(solver_name="LBFGS") # Fit over the training epochs model.fit( @@ -282,7 +282,7 @@ # fit on the test set -model_second_half = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized("LBFGS")) +model_second_half = nmo.glm.GLM(solver_name="LBFGS") model_second_half.fit( input_feature.restrict(second_half), neuron_count.restrict(second_half) @@ -422,7 +422,7 @@ # #### Fit and compare the models # use restrict on interval set training -model_basis = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized("LBFGS")) +model_basis = nmo.glm.GLM(solver_name="LBFGS") model_basis.fit(conv_spk.restrict(first_half), neuron_count.restrict(first_half)) # %% @@ -456,7 +456,7 @@ # Let's check if our new estimate does a better job in terms of over-fitting. We can do that # by visual comparison, as we did previously. Let's fit the second half of the dataset. -model_basis_second_half = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized("LBFGS")) +model_basis_second_half = nmo.glm.GLM(solver_name="LBFGS") model_basis_second_half.fit(conv_spk.restrict(second_half), neuron_count.restrict(second_half)) # compute responses for the 2nd half fit @@ -532,7 +532,8 @@ # model = nmo.glm.PopulationGLM( - regularizer=nmo.regularizer.Ridge(regularizer_strength=0.1, solver_name="LBFGS"), + regularizer=nmo.regularizer.Ridge(regularizer_strength=0.1), + solver_name="LBFGS" ).fit(convolved_count, count) # %% diff --git a/docs/neural_modeling/plot_03_grid_cells.py b/docs/neural_modeling/plot_03_grid_cells.py index 38e9f3e4..08748a58 100644 --- a/docs/neural_modeling/plot_03_grid_cells.py +++ b/docs/neural_modeling/plot_03_grid_cells.py @@ -160,7 +160,8 @@ # Here we will focus on the last neuron (neuron 7) who has a nice grid pattern model = nmo.glm.GLM( - regularizer=nmo.regularizer.Ridge(regularizer_strength=0.001, solver_name="LBFGS") + regularizer=nmo.regularizer.Ridge(regularizer_strength=0.001), + solver_name="LBFGS" ) # %% diff --git a/docs/neural_modeling/plot_04_v1_cells.py b/docs/neural_modeling/plot_04_v1_cells.py index d76ee5b5..6a030db5 100644 --- a/docs/neural_modeling/plot_04_v1_cells.py +++ b/docs/neural_modeling/plot_04_v1_cells.py @@ -233,7 +233,7 @@ # Now we're ready to fit the model! Let's do it, same as before: -model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized(solver_name="LBFGS")) +model = nmo.glm.GLM(solver_name="LBFGS") model.fit(convolved_input, counts) # %% diff --git a/docs/neural_modeling/plot_05_place_cells.py b/docs/neural_modeling/plot_05_place_cells.py index db5a414c..f8afa8cd 100644 --- a/docs/neural_modeling/plot_05_place_cells.py +++ b/docs/neural_modeling/plot_05_place_cells.py @@ -265,7 +265,9 @@ # We can now use the Poisson GLM from NeMoS to learn the model. glm = nmo.glm.GLM( - regularizer=nmo.regularizer.UnRegularized("LBFGS", solver_kwargs=dict(tol=10**-12)) + regularizer=nmo.regularizer.UnRegularized(), + solver_kwargs=dict(tol=10**-12), + solver_name="LBFGS" ) glm.fit(X, count) diff --git a/docs/neural_modeling/plot_06_calcium_imaging.py b/docs/neural_modeling/plot_06_calcium_imaging.py index f031c8fc..d22b4409 100644 --- a/docs/neural_modeling/plot_06_calcium_imaging.py +++ b/docs/neural_modeling/plot_06_calcium_imaging.py @@ -141,9 +141,9 @@ # Different option are possible. With a soft-plus we are assuming an "additive" effect of the predictors, while an exponential non-linearity assumes multiplicative effects. Deciding which firing rate model works best is an empirical question. You can fit different configurations to see which one capture best the neural activity. model = nmo.glm.GLM( - regularizer=nmo.regularizer.Ridge(solver_name="LBFGS", - regularizer_strength=0.02, - solver_kwargs=dict(tol=10**-13)), + solver_name="LBFGS", + solver_kwargs=dict(tol=10**-13), + regularizer=nmo.regularizer.Ridge(regularizer_strength=0.02), observation_model=nmo.observation_models.GammaObservations(inverse_link_function=jax.nn.softplus) ) From 3299d1183d97672266a963f3807beb8649b829f3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 15 Jul 2024 10:24:00 -0400 Subject: [PATCH 024/225] fixed table --- src/nemos/glm.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 2bc5cc0d..f04b9d1e 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -50,6 +50,14 @@ class GLM(BaseRegressor): (like convolved currents or light intensities) and a choice of observation model. It is suitable for scenarios where the relationship between predictors and the response variable might be non-linear, and the residuals don't follow a normal distribution. + Below, a table listing the default and available solvers for each regularizer. + + | Regularizer | Default Solver | Available Solvers | + | ------------- | ---------------- | ----------------------------------------------------------- | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Lasso | ProximalGradient | ProximalGradient | + | GroupLasso | ProximalGradient | ProximalGradient | Parameters ---------- @@ -69,20 +77,6 @@ class GLM(BaseRegressor): Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. - +---------------+------------------+-------------------------------------------------------------+ - | Regularizer | Default Solver | Available Solvers | - +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Lasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ - | GroupLasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ - Attributes ---------- intercept_ : @@ -102,6 +96,7 @@ class GLM(BaseRegressor): ------ TypeError If provided `regularizer` or `observation_model` are not valid. + """ def __init__( From 60957a95c8e8182ccbb192caadcb69e314440469 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 15 Jul 2024 10:30:45 -0400 Subject: [PATCH 025/225] requested changes --- src/nemos/base_regressor.py | 1 + src/nemos/glm.py | 28 ++++++++++++++++++++++++++-- src/nemos/regularizer.py | 13 ++++--------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 3fd3eb5b..8145b6f4 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -42,6 +42,7 @@ class BaseRegressor(Base, abc.ABC): solver_kwargs : Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. + See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | diff --git a/src/nemos/glm.py b/src/nemos/glm.py index f04b9d1e..c290e58a 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -76,6 +76,7 @@ class GLM(BaseRegressor): solver_kwargs : Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. + See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ Attributes ---------- @@ -1011,14 +1012,37 @@ class PopulationGLM(GLM): Observation model to use. The model describes the distribution of the neural activity. Default is the Poisson model. regularizer : - Regularization to use for model optimization. Defines the regularization scheme, the optimization algorithm, + Regularization to use for model optimization. Defines the regularization scheme and related parameters. - Default is UnRegularized regression with gradient descent. + Default is UnRegularized regression. + solver_name : + Solver to use for model optimization. Defines the optimization scheme and related parameters. + The solver must be an appropriate match for the chosen regularizer. + Default is `None`. If no solver specified, one will be chosen based on the regularizer. + Please see table below for regularizer/optimizer pairings. + solver_kwargs : + Optional dictionary for keyword arguments that are passed to the solver when instantiated. + E.g. stepsize, acceleration, value_and_grad, etc. + See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ feature_mask : Either a matrix of shape (num_features, num_neurons) or a [FeaturePytree](../pytrees) of 0s and 1s, with `feature_mask[feature_name]` of shape (num_neurons, ). The mask will be used to select which features are used as predictors for which neuron. + +---------------+------------------+-------------------------------------------------------------+ + | Regularizer | Default Solver | Available Solvers | + +===============+==================+=============================================================+ + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | + | | | ProximalGradient, LBFGSB | + +---------------+------------------+-------------------------------------------------------------+ + | Lasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + | GroupLasso | ProximalGradient | ProximalGradient | + +---------------+------------------+-------------------------------------------------------------+ + Attributes ---------- intercept_ : diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 85e3b03e..a82b2e64 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -154,11 +154,7 @@ def get_proximal_operator( ) -> ProximalOperator: """Unregularized method has no proximal operator.""" - def prox_op(params, hyperparams, scaling=0.5): - Ws, bs = params - return jaxopt.prox.prox_none(Ws, hyperparams, scaling=scaling), bs - - return prox_op + return jaxopt.prox.prox_none class Ridge(Regularizer): @@ -286,7 +282,7 @@ def get_proximal_operator( term is not regularized. """ - def prox_op(params, l1reg, scaling=0.5): + def prox_op(params, l1reg, scaling=1.0): Ws, bs = params l1reg /= bs.shape[0] # if Ws is a pytree, l1reg needs to be a pytree with the same @@ -319,8 +315,7 @@ def _penalization( def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: return ( - 0.5 - * self.regularizer_strength + self.regularizer_strength * jnp.sum(jnp.abs(coeff)) / intercept.shape[0] ) @@ -489,7 +484,7 @@ def get_proximal_operator( intercept term is not regularized. """ - def prox_op(params, regularizer_strength, scaling=0.5): + def prox_op(params, regularizer_strength, scaling=1.0): return prox_group_lasso( params, regularizer_strength, mask=self.mask, scaling=scaling ) From 505a90b04caf5dcad2f07da5d33741776b0a7e2b Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 15 Jul 2024 10:55:32 -0400 Subject: [PATCH 026/225] added rescaling to all but exp decay basis --- src/nemos/basis.py | 118 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index e2f8ff20..c0b96fc9 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -58,7 +58,9 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): return wrapper -def min_max_rescale_samples(sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None) -> NDArray: +def min_max_rescale_samples( + sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None +) -> NDArray: """Rescale samples to [0,1].""" sample_pts = sample_pts.astype(float) if vmin and vmax and vmax <= vmin: @@ -68,10 +70,8 @@ def min_max_rescale_samples(sample_pts: NDArray, vmin: Optional[float] = None, v vmax = np.max(sample_pts) if vmax is None else vmax sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin - sample_pts /= (vmax - vmin) - warnings.warn( - "Rescaling sample points to [0,1]!", UserWarning - ) + sample_pts /= vmax - vmin + warnings.warn("Rescaling sample points to [0,1]!", UserWarning) return sample_pts @@ -1104,7 +1104,12 @@ def __init__( @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: + def __call__( + self, + sample_pts: ArrayLike, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """ Evaluate the M-spline basis functions at given sample points. @@ -1112,7 +1117,14 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: ---------- sample_pts : An array of sample points where the M-spline basis functions are to be - evaluated. + evaluated. Sample points are rescaled to [0,1] as follows: + `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, + respectively. Values outside the [vmin, vmax] range are set to NaN. + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Returns ------- @@ -1127,6 +1139,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ + sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots if not passed knot_locs = self._generate_knots( sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False @@ -1245,7 +1258,12 @@ def __init__( @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: + def __call__( + self, + sample_pts: ArrayLike, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """ Evaluate the B-spline basis functions with given sample points. @@ -1257,7 +1275,15 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: Returns ------- basis_funcs : - The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) + The basis function evaluated at the samples, shape (n_samples, n_basis_funcs). + Sample points are rescaled to [0,1] as follows: + `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, + respectively. Values outside the [vmin, vmax] range are set to NaN. + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Raises ------ @@ -1269,6 +1295,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: The evaluation is performed by looping over each element and using `splev` from SciPy to compute the basis values. """ + sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) @@ -1361,14 +1388,26 @@ def __init__( @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: + def __call__( + self, + sample_pts: ArrayLike, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """Evaluate the Cyclic B-spline basis functions with given sample points. Parameters ---------- sample_pts : The sample points at which the cyclic B-spline is evaluated, shape - (n_samples,). + (n_samples,).Sample points are rescaled to [0,1] as follows: + `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, + respectively. Values outside the [vmin, vmax] range are set to NaN. + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Returns ------- @@ -1381,6 +1420,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: SciPy to compute the basis values. """ + sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) # for cyclic, do not repeat knots @@ -1517,18 +1557,29 @@ def _check_width(width: float) -> None: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: ArrayLike, rescale_samples=True) -> FeatureMatrix: + def __call__( + self, + sample_pts: ArrayLike, + rescale_samples=True, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """Generate basis functions with given samples. Parameters ---------- sample_pts : Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). + rescale_samples : + If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. + Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, respectively. + Values outside the [vmin, vmax] range are set to NaN. + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - Returns - ------- - basis_funcs : - Raised cosine basis functions, shape (n_samples, n_basis_funcs). Raises ------ @@ -1544,7 +1595,9 @@ def __call__(self, sample_pts: ArrayLike, rescale_samples=True) -> FeatureMatrix # basis2 = nmo.basis.RaisedCosineBasisLog(5) # additive_basis = basis1 + basis2 # additive_basis(*([x] * 2)) would modify both inputs - sample_pts = min_max_rescale_samples(np.copy(sample_pts)) + sample_pts = min_max_rescale_samples( + np.copy(sample_pts), vmin=vmin, vmax=vmax + ) peaks = self._compute_peaks() delta = peaks[1] - peaks[0] @@ -1690,7 +1743,12 @@ def _check_time_scaling(time_scaling: float) -> None: f"Only strictly positive time_scaling are allowed, {time_scaling} provided instead." ) - def _transform_samples(self, sample_pts: ArrayLike) -> NDArray: + def _transform_samples( + self, + sample_pts: ArrayLike, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> NDArray: """ Map the sample domain to log-space. @@ -1699,6 +1757,10 @@ def _transform_samples(self, sample_pts: ArrayLike) -> NDArray: sample_pts : Sample points used for evaluating the splines, shape (n_samples, ). + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Returns ------- @@ -1707,7 +1769,7 @@ def _transform_samples(self, sample_pts: ArrayLike) -> NDArray: """ # rescale to [0,1] # copy is necessary to avoid unwanted rescaling in additive/multiplicative basis. - sample_pts = min_max_rescale_samples(np.copy(sample_pts)) + sample_pts = min_max_rescale_samples(np.copy(sample_pts), vmin=vmin, vmax=vmax) # This log-stretching of the sample axis has the following effect: # - as the time_scaling tends to 0, the points will be linearly spaced across the whole domain. # - as the time_scaling tends to inf, basis will be small and dense around 0 and @@ -1741,13 +1803,24 @@ def _compute_peaks(self) -> NDArray: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: + def __call__( + self, + sample_pts: ArrayLike, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """Generate log-spaced raised cosine basis with given samples. Parameters ---------- sample_pts : - Spacing for basis functions, holding elements on interval [0, 1]. + Spacing for basis functions. Samples will be rescaled to the interval [0, 1]. + Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + Values outside the [vmin, vmax] range are set to NaN. + vmin : + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Returns ------- @@ -1760,7 +1833,8 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: If the sample provided do not lie in [0,1]. """ return super().__call__( - self._transform_samples(sample_pts), rescale_samples=False + self._transform_samples(sample_pts, vmin=vmin, vmax=vmax), + rescale_samples=False, ) From e65d75d0fee55b4b886b771bc09da95c4e231773 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 15 Jul 2024 11:24:29 -0400 Subject: [PATCH 027/225] added rescaling to all but exp decay basis --- src/nemos/basis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index c0b96fc9..85d5332d 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -66,8 +66,8 @@ def min_max_rescale_samples( if vmin and vmax and vmax <= vmin: raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") if np.any(sample_pts < 0) or np.any(sample_pts > 1): - vmin = np.min(sample_pts) if vmin is None else vmin - vmax = np.max(sample_pts) if vmax is None else vmax + vmin = np.nanmin(sample_pts) if vmin is None else vmin + vmax = np.nanmax(sample_pts) if vmax is None else vmax sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin sample_pts /= vmax - vmin From 786ac47ca4a3f4d59611b839cb3a2a693c13dfb2 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 15 Jul 2024 11:32:56 -0400 Subject: [PATCH 028/225] small changes --- src/nemos/base_regressor.py | 13 +++---------- src/nemos/glm.py | 13 +++---------- src/nemos/regularizer.py | 2 +- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 8145b6f4..d747b7ba 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -44,19 +44,12 @@ class BaseRegressor(Base, abc.ABC): E.g. stepsize, acceleration, value_and_grad, etc. See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ - +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | - +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ + | ------------- | ---------------- | ----------------------------------------------------------- | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | | Lasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ | GroupLasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ See Also diff --git a/src/nemos/glm.py b/src/nemos/glm.py index c290e58a..d5fbcfff 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -1029,19 +1029,12 @@ class PopulationGLM(GLM): `feature_mask[feature_name]` of shape (num_neurons, ). The mask will be used to select which features are used as predictors for which neuron. - +---------------+------------------+-------------------------------------------------------------+ | Regularizer | Default Solver | Available Solvers | - +===============+==================+=============================================================+ - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, | - | | | ProximalGradient, LBFGSB | - +---------------+------------------+-------------------------------------------------------------+ + | ------------- | ---------------- | ----------------------------------------------------------- | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | | Lasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ | GroupLasso | ProximalGradient | ProximalGradient | - +---------------+------------------+-------------------------------------------------------------+ Attributes ---------- diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index a82b2e64..bee8902a 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -258,7 +258,7 @@ class Lasso(Regularizer): set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ - _allowed_solvers = ("ProximalGradient",) + _allowed_solvers = ("ProximalGradient", ) _default_solver = "ProximalGradient" From adf31a1a6829b5e570d47e88cae539a96e062424 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 15 Jul 2024 15:19:31 -0400 Subject: [PATCH 029/225] add more docstrings --- src/nemos/regularizer.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index bee8902a..304f2336 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -146,7 +146,10 @@ def __init__( super().__init__() def penalized_loss(self, loss: Callable): - """Unregularized method does not add any penalty.""" + """ + Returns the original loss function unpenalized. Unregularized regularization method does not add any + penalty. + """ return loss def get_proximal_operator( @@ -227,6 +230,7 @@ def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: ) def penalized_loss(self, loss: Callable) -> Callable: + """Returns the penalized loss function for Ridge regularization.""" def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) @@ -235,6 +239,15 @@ def _penalized_loss(params, X, y): def get_proximal_operator( self, ) -> ProximalOperator: + """ + Retrieve the proximal operator for Ridge regularization (L2 penalty). + + Returns + ------- + : + The proximal operator, applying L2 regularization to the provided parameters. The intercept + term is not regularized. + """ def prox_op(params, l2reg, scaling=0.5): Ws, bs = params l2reg /= bs.shape[0] @@ -326,6 +339,7 @@ def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: ) def penalized_loss(self, loss: Callable) -> Callable: + """Returns a function for calculating the penalized loss using Lasso regularization.""" def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) @@ -394,6 +408,7 @@ def mask(self): @mask.setter def mask(self, mask: jnp.ndarray | None): + """Setter for the mask attribute.""" # check mask if passed by user, else will be initialized later if mask is not None: self._check_mask(mask) @@ -463,9 +478,10 @@ def _penalization( penalty = jnp.linalg.norm(masked_param, axis=0).sum() - return penalty + return penalty * self.regularizer_strength def penalized_loss(self, loss: Callable) -> Callable: + """Returns a function for calculating the penalized loss using Group Lasso regularization.""" def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) From 4ff561c1870928b89719d966567fc70323222450 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 15 Jul 2024 15:20:01 -0400 Subject: [PATCH 030/225] black src --- src/nemos/regularizer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 304f2336..ec152ac8 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -231,6 +231,7 @@ def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: def penalized_loss(self, loss: Callable) -> Callable: """Returns the penalized loss function for Ridge regularization.""" + def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) @@ -248,6 +249,7 @@ def get_proximal_operator( The proximal operator, applying L2 regularization to the provided parameters. The intercept term is not regularized. """ + def prox_op(params, l2reg, scaling=0.5): Ws, bs = params l2reg /= bs.shape[0] @@ -271,7 +273,7 @@ class Lasso(Regularizer): set for L1 regularization (Lasso). It utilizes the `jaxopt` library's proximal gradient optimizer. """ - _allowed_solvers = ("ProximalGradient", ) + _allowed_solvers = ("ProximalGradient",) _default_solver = "ProximalGradient" @@ -328,9 +330,7 @@ def _penalization( def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: return ( - self.regularizer_strength - * jnp.sum(jnp.abs(coeff)) - / intercept.shape[0] + self.regularizer_strength * jnp.sum(jnp.abs(coeff)) / intercept.shape[0] ) # tree map the computation and sum over leaves @@ -340,6 +340,7 @@ def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: def penalized_loss(self, loss: Callable) -> Callable: """Returns a function for calculating the penalized loss using Lasso regularization.""" + def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) @@ -482,6 +483,7 @@ def _penalization( def penalized_loss(self, loss: Callable) -> Callable: """Returns a function for calculating the penalized loss using Group Lasso regularization.""" + def _penalized_loss(params, X, y): return loss(params, X, y) + self._penalization(params) From d567efbf3d80c1ad9fdddcf95ab6dedf4a6739de Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 15 Jul 2024 16:47:18 -0400 Subject: [PATCH 031/225] fix group lasso penalization --- src/nemos/regularizer.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index ec152ac8..95407dbb 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -477,9 +477,14 @@ def _penalization( param_with_extra_axis ) # this masks the param, (group, feature, neuron) - penalty = jnp.linalg.norm(masked_param, axis=0).sum() + penalty = jnp.linalg.norm(masked_param, axis=1) * jax.numpy.sqrt( + self.mask.sum(axis=1) + ) + + # divide regularization strength by number of neurons + regularizer_strength = self.regularizer_strength / params[1].shape[0] - return penalty * self.regularizer_strength + return penalty * regularizer_strength def penalized_loss(self, loss: Callable) -> Callable: """Returns a function for calculating the penalized loss using Group Lasso regularization.""" From 722e9d13de7fb8efffb6617c8a8efe244ffac81b Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 16 Jul 2024 09:28:24 -0400 Subject: [PATCH 032/225] trimmed parameters when default are available --- docs/neural_modeling/plot_01_current_injection.py | 2 +- docs/neural_modeling/plot_05_place_cells.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/neural_modeling/plot_01_current_injection.py b/docs/neural_modeling/plot_01_current_injection.py index 694ae864..0ec46964 100644 --- a/docs/neural_modeling/plot_01_current_injection.py +++ b/docs/neural_modeling/plot_01_current_injection.py @@ -502,7 +502,7 @@ # # Initialize the model w/regularizer and solver -model = nmo.glm.GLM(regularizer=nmo.regularizer.UnRegularized(), solver_name="LBFGS") +model = nmo.glm.GLM(solver_name="LBFGS") # %% # diff --git a/docs/neural_modeling/plot_05_place_cells.py b/docs/neural_modeling/plot_05_place_cells.py index f8afa8cd..61c84bea 100644 --- a/docs/neural_modeling/plot_05_place_cells.py +++ b/docs/neural_modeling/plot_05_place_cells.py @@ -265,7 +265,6 @@ # We can now use the Poisson GLM from NeMoS to learn the model. glm = nmo.glm.GLM( - regularizer=nmo.regularizer.UnRegularized(), solver_kwargs=dict(tol=10**-12), solver_name="LBFGS" ) @@ -335,7 +334,6 @@ X = models[m](*features[m]) print("2. Fitting model : ", m) - # glm = nmo.glm.GLM() glm.fit( X.restrict(train_iset), count.restrict(train_iset), From 1d13a46bcfae6fc880455c14b0f01b7fdbb54bf3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 16 Jul 2024 09:28:37 -0400 Subject: [PATCH 033/225] added description of regularizer as string param --- docs/quickstart.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 94870683..fa423b27 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -53,7 +53,7 @@ Model intercept: [-0.0010547] During initialization, the `GLM` class accepts the following optional input arguments, 1. `model.observation_model`: The statistical model for the observed variable. The available option so far are `nemos.observation_models.PoissonObservation` and `nemos.observation_models.GammaObservations`, which are the most common choices for modeling spike counts and calcium imaging traces respectively. -2. `model.regularizer`: Determines the regularization type, defaulting to `nemos.regularizer.Unregularized`. +2. `model.regularizer`: Determines the regularization type, defaulting to `nemos.regularizer.Unregularized`. This parameter can be provided either as a string ("unregularized", "ridge", "lasso", or "group_lasso") or as an instance of `nemos.regularizer.Regularizer`. For more information on how to change default arguments, see the API guide for [`observation_models`](reference/nemos/observation_models.md) and [`regularizer`](reference/nemos/regularizer.md). @@ -63,7 +63,7 @@ import nemos as nmo # initialize a Gamma GLM with Ridge regularization model = nmo.glm.GLM( - regularizer=nmo.regularizer.Ridge(), + regularizer="ridge", observation_model=nmo.observation_models.GammaObservations() ) ``` @@ -166,7 +166,7 @@ from sklearn.model_selection import GridSearchCV # ...Assume X and counts are available or generated as shown above # model definition -model = nmo.glm.GLM(regularizer=nmo.regularizer.Ridge()) +model = nmo.glm.GLM(regularizer="ridge") # fit a 5-fold cross-validation scheme for comparing two different # regularizer strengths: From 88c140ea951f3e0cfd26056afaf03e7736f1958d Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 16 Jul 2024 11:30:00 -0400 Subject: [PATCH 034/225] requested changes --- src/nemos/_regularizer_builder.py | 10 ++++++++++ src/nemos/regularizer.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index b1697267..9f2fc42d 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -11,6 +11,16 @@ def create_regularizer(name: str): ---------- name : The string name of the regularizer to create. Must be one of: 'unregularized', 'ridge', 'lasso', 'group_lasso'. + + Returns + ------- + : + The regularizer instance with default parameters. + + Raises + ------ + ValueError + If the `name` provided does not match to any available regularizer. """ match name: case "unregularized": diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 95407dbb..16f87bf9 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -76,6 +76,12 @@ def regularizer_strength(self) -> float: @regularizer_strength.setter def regularizer_strength(self, strength: float): + try: + # force conversion to float to prevent weird GPU issues + strength = float(strength) + except ValueError: + # raise a more detailed ValueError + raise ValueError(f"Could not convert the regularizer strength: {strength} to a float.") self._regularizer_strength = strength def penalized_loss(self, loss: Callable) -> Callable: @@ -464,6 +470,14 @@ def _penalization( ) -> jnp.ndarray: """ Calculate the penalization. + + Note: the penalty is being calculated according to the following formula: + + $$\\text{loss}(\beta_1,...,\beta_g) + \alpha \cdot \sum _{j=1...,g} \sqrt{\dim(\beta_j)} || \beta_j||_2$$ + + where $g$ is the number of groups, $\dim(\cdot)$ is the dimension of the vector, + i.e. the number of coefficient in each $\beta_j$, and $||\cdot||_2$ is the euclidean norm. + """ # conform to shape (1, n_features) if param is (n_features,) or (n_neurons, n_features) if # param is (n_features, n_neurons) From 30532599ea1f65211b063399688e465d50685a73 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 16 Jul 2024 15:07:36 -0400 Subject: [PATCH 035/225] Create a copy of basis in TransformerBasis.__init__ --- src/nemos/basis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index c8876e1d..81c0d066 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc +import copy import warnings from typing import Callable, Generator, Literal, Optional, Tuple, Union @@ -87,7 +88,7 @@ class TransformerBasis: """ def __init__(self, basis: Basis): - self._basis = basis + self._basis = copy.deepcopy(basis) @staticmethod def _unpack_inputs(X: FeatureMatrix): From 008df4421ccbf87efbc9636796589d680fc5cc2e Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 16 Jul 2024 15:07:38 -0400 Subject: [PATCH 036/225] modified basis --- src/nemos/basis.py | 207 ++++++++++++++------ tests/test_basis.py | 448 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 558 insertions(+), 97 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 85d5332d..e87b4be7 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -65,13 +65,12 @@ def min_max_rescale_samples( sample_pts = sample_pts.astype(float) if vmin and vmax and vmax <= vmin: raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") - if np.any(sample_pts < 0) or np.any(sample_pts > 1): - vmin = np.nanmin(sample_pts) if vmin is None else vmin - vmax = np.nanmax(sample_pts) if vmax is None else vmax - sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan - sample_pts -= vmin + vmin = np.nanmin(sample_pts) if vmin is None else vmin + vmax = np.nanmax(sample_pts) if vmax is None else vmax + sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan + sample_pts -= vmin + if vmin != vmax: # Needed is samples contain a single value. sample_pts /= vmax - vmin - warnings.warn("Rescaling sample points to [0,1]!", UserWarning) return sample_pts @@ -196,10 +195,18 @@ class Basis(abc.ABC): 'conv' for convolutional operation. window_size : The window size for convolution. Required if mode is 'conv'. - *args: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + *args : Only used in "conv" mode. Additional positional arguments that are passed to `nemos.convolve.create_convolutional_predictor` - **kwargs: + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -211,6 +218,8 @@ def __init__( *args, mode: Literal["eval", "conv"] = "eval", window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ) -> None: self.n_basis_funcs = n_basis_funcs @@ -218,6 +227,8 @@ def __init__( self._check_n_basis_min() self._conv_args = args self._conv_kwargs = kwargs + self._vmin = vmin if vmin is None else float(vmin) + self._vmax = vmax if vmax is None else float(vmax) # check mode if mode not in ["conv", "eval"]: raise ValueError( @@ -232,6 +243,10 @@ def __init__( raise ValueError( f"`window_size` must be a positive integer. {window_size} provided instead!" ) + if vmin is not None or vmax is not None: + raise ValueError( + "`vmin` and `vmax` should only be set when `mode=='conv'`." + ) else: if args: raise ValueError( @@ -247,6 +262,14 @@ def __init__( self._kernel = None self._identifiability_constraints = False + @property + def vmin(self): + return self._vmin + + @property + def vmax(self): + return self._vmax + @property def mode(self): return self._mode @@ -344,7 +367,7 @@ def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: # check if self._kernel is not None for mode="conv" self._check_has_kernel() if self.mode == "eval": # evaluate at the sample - return self.__call__(*xi) + return self.__call__(*xi, vmin=self._vmin, vmax=self._vmax) else: # convolve, called only at the last layer if "axis" not in self._conv_kwargs: axis = 0 @@ -430,7 +453,7 @@ def _set_kernel(self, *xi: ArrayLike) -> Basis: return self @abc.abstractmethod - def __call__(self, *xi: ArrayLike) -> FeatureMatrix: + def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: """ Abstract method to evaluate the basis functions at given points. @@ -445,6 +468,9 @@ def __call__(self, *xi: ArrayLike) -> FeatureMatrix: Variable number of arguments, each representing an array of points at which to evaluate the basis functions. The dimensions and requirements of these inputs vary depending on the specific basis implementation. + **kwargs : + Keyword args such as `vmin` and `vmax`, needed for all basis except for + AdditiveBasis and MultiplicativeBasis. Returns ------- @@ -734,7 +760,7 @@ def _check_n_basis_min(self) -> None: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, *xi: ArrayLike) -> FeatureMatrix: + def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: """ Evaluate the basis at the input samples. @@ -864,7 +890,7 @@ def _set_kernel(self, *xi: NDArray) -> Basis: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, *xi: ArrayLike) -> FeatureMatrix: + def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: """ Evaluate the basis at the input samples. @@ -933,7 +959,15 @@ class SplineBasis(Basis, abc.ABC): Spline order. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -951,11 +985,13 @@ def __init__( mode="eval", order: int = 2, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ) -> None: self.order = order super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, **kwargs + n_basis_funcs, *args, mode=mode, window_size=window_size, vmin=vmin, vmax=vmax, **kwargs ) self._n_input_dimensionality = 1 if self.order < 1: @@ -1063,6 +1099,14 @@ class MSplineBasis(SplineBasis): derivatives at each interior knot, resulting in smoother basis functions. window_size : The window size for convolution. Required if mode is 'conv'. + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. **kwargs: Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1090,6 +1134,8 @@ def __init__( mode="eval", order: int = 2, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ) -> None: super().__init__( @@ -1098,6 +1144,8 @@ def __init__( mode=mode, order=order, window_size=window_size, + vmin=vmin, + vmax=vmax, **kwargs, ) @@ -1119,12 +1167,14 @@ def __call__( An array of sample points where the M-spline basis functions are to be evaluated. Sample points are rescaled to [0,1] as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, - respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin : + If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of + `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. + vmin: The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + This value determines the minimum of the domain of the basis. vmax : The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. + This value determines the maximum of the domain of the basis. Returns ------- @@ -1220,7 +1270,15 @@ class BSplineBasis(SplineBasis): The higher this number, the smoother the basis representation will be. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1244,6 +1302,8 @@ def __init__( mode="eval", order: int = 4, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ): super().__init__( @@ -1252,6 +1312,8 @@ def __init__( mode=mode, order=order, window_size=window_size, + vmin=vmin, + vmax=vmax, **kwargs, ) @@ -1271,6 +1333,12 @@ def __call__( ---------- sample_pts : The sample points at which the B-spline is evaluated, shape (n_samples,). + vmin: + The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + This value determines the minimum of the domain of the basis. + vmax : + The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. + This value determines the maximum of the domain of the basis. Returns ------- @@ -1280,10 +1348,6 @@ def __call__( `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin : - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Raises ------ @@ -1350,7 +1414,15 @@ class CyclicBSplineBasis(SplineBasis): The higher this number, the smoother the basis representation will be. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1369,6 +1441,8 @@ def __init__( mode="eval", order: int = 4, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ): super().__init__( @@ -1377,6 +1451,8 @@ def __init__( mode=mode, order=order, window_size=window_size, + vmin=vmin, + vmax=vmax, **kwargs, ) if self.order < 2: @@ -1404,10 +1480,12 @@ def __call__( `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin : + vmin: The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + This value determines the minimum of the domain of the basis. vmax : The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. + This value determines the maximum of the domain of the basis. Returns ------- @@ -1498,7 +1576,15 @@ class RaisedCosineBasisLinear(Basis): Width of the raised cosine. By default, it's set to 2.0. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1517,10 +1603,12 @@ def __init__( mode="eval", width: float = 2.0, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ) -> None: super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, **kwargs + n_basis_funcs, *args, mode=mode, window_size=window_size, vmin=vmin, vmax=vmax, **kwargs ) self._n_input_dimensionality = 1 self._check_width(width) @@ -1575,10 +1663,12 @@ def __call__( Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin : + vmin: The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + This value determines the minimum of the domain of the basis. vmax : The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. + This value determines the maximum of the domain of the basis. Raises @@ -1693,7 +1783,15 @@ class RaisedCosineBasisLog(RaisedCosineBasisLinear): decays to 0. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1714,6 +1812,8 @@ def __init__( time_scaling: float = None, enforce_decay_to_zero: bool = True, window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ) -> None: super().__init__( @@ -1722,6 +1822,8 @@ def __init__( mode=mode, width=width, window_size=window_size, + vmin=vmin, + vmax=vmax, **kwargs, ) self.enforce_decay_to_zero = enforce_decay_to_zero @@ -1817,10 +1919,12 @@ def __call__( Spacing for basis functions. Samples will be rescaled to the interval [0, 1]. Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. Values outside the [vmin, vmax] range are set to NaN. - vmin : + vmin: The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. + This value determines the minimum of the domain of the basis. vmax : The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. + This value determines the maximum of the domain of the basis. Returns ------- @@ -1855,7 +1959,15 @@ class OrthExponentialBasis(Basis): 'conv' for convolutional operation. window_size : The window size for convolution. Required if mode is 'conv'. - **kwargs: + vmin : + The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of + the samples provided when evaluating the basis. + If a sample is lower than `vmin`, the basis will return NaN. + vmax : + The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of + the samples provided when evaluating the basis. + If a sample is larger than `vmax`, the basis will return NaN. + **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` """ @@ -1867,6 +1979,8 @@ def __init__( *args, mode="eval", window_size: Optional[int] = None, + vmin: Optional[float] = None, + vmax: Optional[float] = None, **kwargs, ): super().__init__( @@ -1874,6 +1988,8 @@ def __init__( *args, mode=mode, window_size=window_size, + vmin=vmin, + vmax=vmax, **kwargs, ) self._decay_rates = np.asarray(decay_rates) @@ -1919,27 +2035,6 @@ def _check_rates(self) -> None: "linearly dependent set of function for the basis." ) - @staticmethod - def _check_sample_range(sample_pts: NDArray) -> None: - """ - Check if the sample points are all positive. - - Parameters - ---------- - sample_pts - Sample points to check. - - Raises - ------ - ValueError - If any of the sample points are negative, as OrthExponentialBasis requires - positive samples. - """ - if any(sample_pts < 0): - raise ValueError( - "OrthExponentialBasis requires positive samples. Negative values provided instead!" - ) - def _check_sample_size(self, *sample_pts: NDArray) -> None: """Check that the sample size is greater than the number of basis. @@ -1966,7 +2061,7 @@ def _check_sample_size(self, *sample_pts: NDArray) -> None: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: NDArray) -> FeatureMatrix: + def __call__(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None) -> FeatureMatrix: """Generate basis functions with given spacing. Parameters @@ -1982,14 +2077,16 @@ def __call__(self, sample_pts: NDArray) -> FeatureMatrix: orthogonalized, shape (n_samples, n_basis_funcs) """ - self._check_sample_range(sample_pts) self._check_sample_size(sample_pts) + sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + valid_idx = ~np.isnan(sample_pts) # because of how scipy.linalg.orth works, have to create a matrix of # shape (n_pts, n_basis_funcs) and then transpose, rather than # directly computing orth on the matrix of shape (n_basis_funcs, # n_pts) - basis_funcs = scipy.linalg.orth( - np.stack([np.exp(-lam * sample_pts) for lam in self._decay_rates], axis=1) + basis_funcs = np.full(shape=(sample_pts.shape[0], self.n_basis_funcs), fill_value=np.nan) + basis_funcs[valid_idx] = scipy.linalg.orth( + np.stack([np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1) ) if self.identifiability_constraints: basis_funcs = self._apply_identifiability_constraints(basis_funcs) diff --git a/tests/test_basis.py b/tests/test_basis.py index 2df2db5d..1c47d152 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -522,6 +522,86 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') + @pytest.mark.parametrize( + "vmin, vmax, expectation", + [ + (None, None, does_not_raise()), + (None, 3, does_not_raise()), + (1, None, does_not_raise()), + (1, 3, does_not_raise()), + ("a", 3, pytest.raises(ValueError, match="could not convert")), + (1, "a", pytest.raises(ValueError, match="could not convert")), + ("a", "a", pytest.raises(ValueError, match="could not convert")) + ] + ) + def test_vmin_vmax_init(self, vmin, vmax, expectation): + with expectation: + bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else:bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, None, np.arange(5), []), + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out = bas.compute_features(samples) + assert np.all(np.isnan(out[nan_idx])) + valid_idx = list(set(samples).difference(nan_idx)) + assert np.all(~np.isnan(out[valid_idx])) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out1 = bas.evaluate_on_grid(10) + out2 = bas_no_range.evaluate_on_grid(10) + assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + + @pytest.mark.parametrize( + "vmin, vmax, samples, exception", + [ + (None, None, np.arange(5), does_not_raise()), + (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + ] + ) + def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + with exception: + self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + + class TestRaisedCosineLinearBasis(BasisFuncsTesting): cls = basis.RaisedCosineBasisLinear @@ -612,21 +692,6 @@ def test_minimum_number_of_basis_required_is_matched( else: self.cls(n_basis_funcs=n_basis_funcs, mode=mode, window_size=window_size) - @pytest.mark.parametrize( - "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] - ) - def test_samples_range_matches_evaluate_requirements(self, sample_range: tuple): - """ - Ensures that the fit_transform() method correctly handles sample range inputs that are outside of its required bounds (0, 1). - """ - raise_exception = (sample_range[0] < 0) | (sample_range[1] > 1) - basis_obj = self.cls(n_basis_funcs=5) - if raise_exception: - with pytest.warns(UserWarning, match="sample points for"): - basis_obj.compute_features(np.linspace(*sample_range, 100)) - else: - basis_obj.compute_features(np.linspace(*sample_range, 100)) - @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): @@ -809,7 +874,6 @@ def test_call_non_empty(self, n_basis, mode, window_size): "mn, mx, expectation", [ (0, 1, does_not_raise()), - (-2, 2, pytest.warns(UserWarning, match="Rescaling sample points")), ], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) @@ -921,6 +985,86 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') + @pytest.mark.parametrize( + "vmin, vmax, expectation", + [ + (None, None, does_not_raise()), + (None, 3, does_not_raise()), + (1, None, does_not_raise()), + (1, 3, does_not_raise()), + ("a", 3, pytest.raises(ValueError, match="could not convert")), + (1, "a", pytest.raises(ValueError, match="could not convert")), + ("a", "a", pytest.raises(ValueError, match="could not convert")) + ] + ) + def test_vmin_vmax_init(self, vmin, vmax, expectation): + with expectation: + bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, None, np.arange(5), []), + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out = bas.compute_features(samples) + assert np.all(np.isnan(out[nan_idx])) + valid_idx = list(set(samples).difference(nan_idx)) + assert np.all(~np.isnan(out[valid_idx])) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out1 = bas.evaluate_on_grid(10) + out2 = bas_no_range.evaluate_on_grid(10) + assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + + @pytest.mark.parametrize( + "vmin, vmax, samples, exception", + [ + (None, None, np.arange(5), does_not_raise()), + (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + ] + ) + def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + with exception: + self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + class TestMSplineBasis(BasisFuncsTesting): cls = basis.MSplineBasis @@ -1305,6 +1449,87 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') + @pytest.mark.parametrize( + "vmin, vmax, expectation", + [ + (None, None, does_not_raise()), + (None, 3, does_not_raise()), + (1, None, does_not_raise()), + (1, 3, does_not_raise()), + ("a", 3, pytest.raises(ValueError, match="could not convert")), + (1, "a", pytest.raises(ValueError, match="could not convert")), + ("a", "a", pytest.raises(ValueError, match="could not convert")) + ] + ) + def test_vmin_vmax_init(self, vmin, vmax, expectation): + with expectation: + bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + bas = self.cls(3, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, None, np.arange(5), []), + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out = bas.compute_features(samples) + assert np.all(np.isnan(out[nan_idx])) + valid_idx = list(set(samples).difference(nan_idx)) + assert np.all(~np.isnan(out[valid_idx])) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + out1 = bas.evaluate_on_grid(10) + out2 = bas_no_range.evaluate_on_grid(10) + assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + + @pytest.mark.parametrize( + "vmin, vmax, samples, exception", + [ + (None, None, np.arange(5), does_not_raise()), + (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + ] + ) + def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + with exception: + self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + + class TestOrthExponentialBasis(BasisFuncsTesting): cls = basis.OrthExponentialBasis @@ -1418,28 +1643,6 @@ def test_minimum_number_of_basis_required_is_matched( window_size=window_size, ) - @pytest.mark.parametrize( - "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] - ) - def test_samples_range_matches_fit_transform_requirements( - self, sample_range: tuple - ): - """ - Tests whether the fit_transform method correctly processes the given sample range. - Raises an exception for negative samples - """ - raise_exception = sample_range[0] < 0 - basis_obj = self.cls(n_basis_funcs=5, decay_rates=np.arange(1, 6)) - if raise_exception: - with pytest.raises( - ValueError, - match=rf"{self.cls.__name__} requires positive samples\. " - r"Negative values provided instead\!", - ): - basis_obj.compute_features(np.linspace(*sample_range, 100)) - else: - basis_obj.compute_features(np.linspace(*sample_range, 100)) - @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): @@ -1768,6 +1971,7 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, decay_rates=[1,2,3,4,5], mode='eval', test='hi') + class TestBSplineBasis(BasisFuncsTesting): cls = basis.BSplineBasis @@ -2173,6 +2377,87 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') + @pytest.mark.parametrize( + "vmin, vmax, expectation", + [ + (None, None, does_not_raise()), + (None, 3, does_not_raise()), + (1, None, does_not_raise()), + (1, 3, does_not_raise()), + ("a", 3, pytest.raises(ValueError, match="could not convert")), + (1, "a", pytest.raises(ValueError, match="could not convert")), + ("a", "a", pytest.raises(ValueError, match="could not convert")) + ] + ) + def test_vmin_vmax_init(self, vmin, vmax, expectation): + with expectation: + bas = self.cls(5, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + bas = self.cls(5, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, None, np.arange(5), []), + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + out = bas.compute_features(samples) + assert np.all(np.isnan(out[nan_idx])) + valid_idx = list(set(samples).difference(nan_idx)) + assert np.all(~np.isnan(out[valid_idx])) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + out1 = bas.evaluate_on_grid(10) + out2 = bas_no_range.evaluate_on_grid(10) + assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + + @pytest.mark.parametrize( + "vmin, vmax, samples, exception", + [ + (None, None, np.arange(5), does_not_raise()), + (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + ] + ) + def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + with exception: + self.cls(5, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + + class TestCyclicBSplineBasis(BasisFuncsTesting): cls = basis.CyclicBSplineBasis @@ -2596,6 +2881,86 @@ def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): bas = self.cls(5, mode='eval', test='hi') + @pytest.mark.parametrize( + "vmin, vmax, expectation", + [ + (None, None, does_not_raise()), + (None, 3, does_not_raise()), + (1, None, does_not_raise()), + (1, 3, does_not_raise()), + ("a", 3, pytest.raises(ValueError, match="could not convert")), + (1, "a", pytest.raises(ValueError, match="could not convert")), + ("a", "a", pytest.raises(ValueError, match="could not convert")) + ] + ) + def test_vmin_vmax_init(self, vmin, vmax, expectation): + with expectation: + bas = self.cls(5, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + bas = self.cls(5, vmin=vmin, vmax=vmax) + if vmin is None: + assert bas.vmin is vmin + else: + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + assert bas.vmin == vmin + + if vmax is None: + assert bas.vmax is vmax + else: + assert bas.vmax == vmax + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, None, np.arange(5), []), + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + out = bas.compute_features(samples) + assert np.all(np.isnan(out[nan_idx])) + valid_idx = list(set(samples).difference(nan_idx)) + assert np.all(~np.isnan(out[valid_idx])) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + out1 = bas.evaluate_on_grid(10) + out2 = bas_no_range.evaluate_on_grid(10) + assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + + @pytest.mark.parametrize( + "vmin, vmax, samples, exception", + [ + (None, None, np.arange(5), does_not_raise()), + (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), + (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + ] + ) + def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + with exception: + self.cls(5, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + class CombinedBasis(BasisFuncsTesting): """ This class is used to run tests on combination operations (e.g., addition, multiplication) among Basis functions. @@ -3151,7 +3516,7 @@ def test_call_non_empty( @pytest.mark.parametrize( "mn, mx, expectation", - [(0, 1, does_not_raise()), (-2, 2, "check"), (0.1, 2, does_not_raise())], + [(0, 1, does_not_raise()), (-2, 2, does_not_raise()), (0.1, 2, does_not_raise())], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) @pytest.mark.parametrize( @@ -3778,7 +4143,7 @@ def test_call_non_empty( @pytest.mark.parametrize( "mn, mx, expectation", - [(0, 1, does_not_raise()), (-2, 2, "check"), (0.1, 2, does_not_raise())], + [(0, 1, does_not_raise()), (-2, 2, does_not_raise()), (0.1, 2, does_not_raise())], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) @pytest.mark.parametrize( @@ -3965,7 +4330,7 @@ def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): ), ( basis.OrthExponentialBasis( - 5, decay_rates=np.arange(1, 6), mode="conv", window_size=7 + 5, decay_rates=np.linspace(0.1, 1, 5), mode="conv", window_size=7 ), 14, ), @@ -3992,7 +4357,6 @@ def test_sklearn_transformer_pipeline_pynapple( ep = nap.IntervalSet(start=[0, 20.5], end=[20, X.shape[0]]) X_nap = nap.TsdFrame(t=np.arange(X.shape[0]), d=X, time_support=ep) y_nap = nap.Tsd(t=np.arange(X.shape[0]), d=y, time_support=ep) - bas = basis.TransformerBasis(bas) # fit a pipeline & predict from pynapple pipe = pipeline.Pipeline([("eval", bas), ("fit", model)]) From 11091ac77a9cdac71e0a351f22a64e8700b22024 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 16 Jul 2024 15:19:46 -0400 Subject: [PATCH 037/225] Make TransformerBasis compatible with pickle and CV, define __setattr__ and __dir__ --- src/nemos/basis.py | 125 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 81c0d066..7acabeb0 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -13,7 +13,6 @@ from numpy.typing import ArrayLike, NDArray from pynapple import Tsd, TsdFrame from scipy.interpolate import splev -from sklearn.base import clone as sk_clone from .base_class import Base from .convolve import create_convolutional_predictor @@ -175,7 +174,27 @@ def fit_transform(self, X: FeatureMatrix, y=None) -> FeatureMatrix: """ return self._basis.compute_features(*self._unpack_inputs(X)) - def __getattr__(self, attr: str): + def __getstate__(self): + """ + Explicitly define how to pickle TransformerBasis object. + + See https://docs.python.org/3/library/pickle.html#object.__getstate__ + and https://docs.python.org/3/library/pickle.html#pickle-state + """ + return {"_basis": self._basis} + + def __setstate__(self, state): + """ + Define how to populate the object's state when unpickling. + Not that during unpickling a new object is created without calling __init__. + Needed to avoid infinite recursion in __getattr__ when unpickling. + + See https://docs.python.org/3/library/pickle.html#object.__setstate__ + and https://docs.python.org/3/library/pickle.html#pickle-state + """ + self._basis = state["_basis"] + + def __getattr__(self, name: str): """ Enable easy access to attributes of the underlying Basis object. @@ -188,22 +207,112 @@ def __getattr__(self, attr: str): print(bas.n_basis_funcs) print(trans_bas.n_basis_funcs) """ - return getattr(self._basis, attr) + return getattr(self._basis, name) + + def __setattr__(self, name: str, value) -> None: + """ + Allow setting _basis or the attributes of _basis with a convenient dot assignment syntax. + Setting any other attribute is not allowed. + + Example + ------- + trans_bas = nmo.basis.TransformerBasis(nmo.basis.MSplineBasis(10)) + # allowed + trans_bas._basis = nmo.basis.BSplineBasis(10) + # allowed + trans_bas.n_basis_funcs = 20 + # not allowed + tran_bas.random_attribute_name = "some value" + """ + # allow self._basis = basis + if name == "_basis": + super().__setattr__(name, value) + # allow changing existing attributes of self._basis + elif hasattr(self._basis, name): + setattr(self._basis, name, value) + # don't allow setting any other attribute + else: + raise ValueError( + "Only setting _basis or existing attributes of _basis is allowed." + ) def __sklearn_clone__(self) -> TransformerBasis: """ - By default scikit-learn calls copy.deepcopy on the estimator object. - Cloning the underlying Basis avoids infinite recursive calls to getattr. + Customize how TransformerBasis objects are cloned when used with sklearn.model_selection. + By default scikit-learn tries to clone the object by calling __init__ using the output of get_params, + which fails in our case. + + For more info: https://scikit-learn.org/stable/developers/develop.html#cloning """ - return TransformerBasis(sk_clone(self._basis)) + self._basis._kernel = None + return TransformerBasis(copy.deepcopy(self._basis)) def set_params(self, **parameters) -> TransformerBasis: """ - Set the parameters of the underlying Basis. + When used with, sklearn.model_selection, either set the _basis attribute directly, + or set the parameters of the underlying Basis, but doing both at the same time is not allowed. + + Example + ------- + pipeline = Pipeline( + [ + ("transformerbasis", basis.TransformerBasis(basis.MSplineBasis(10))), + ("glm", nmo.glm.GLM()), + ] + ) + # setting parameters of _basis is allowed + param_grid = dict( + transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), + ) + + # setting _basis directly is allowed + param_grid = dict( + transformerbasis___basis=( + nmo.basis.RaisedCosineBasisLinear(10), + nmo.basis.RaisedCosineBasisLog(10), + nmo.basis.MSplineBasis(10), + ) + ) + + # mixing the two doesn't work + param_grid = dict( + transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), + transformerbasis___basis=( + nmo.basis.RaisedCosineBasisLinear(10), + nmo.basis.RaisedCosineBasisLog(10), + nmo.basis.MSplineBasis(10), + ) + ) + + gridsearch = GridSearchCV( + pipeline, param_grid=param_grid, cv=5, scoring=pseudo_r2, n_jobs=4 + ) + """ - self._basis = self._basis.set_params(**parameters) + new_basis = parameters.pop("_basis", None) + if new_basis is not None: + self._basis = new_basis + if len(parameters) > 0: + raise ValueError( + "Set either _basis or parameters for _basis, not both." + ) + else: + self._basis = self._basis.set_params(**parameters) + return self + def get_params(self, deep: bool = True) -> dict: + """ + Extend the dict of parameters from the underlying Basis with _basis. + """ + return {"_basis": self._basis, **self._basis.get_params(deep)} + + def __dir__(self) -> list[str]: + """ + Extend the list of properties of methods with the ones from the underlying Basis. + """ + return super().__dir__() + self._basis.__dir__() + class Basis(Base, abc.ABC): """ From 8a13aca3d4dee1f5cecba354482a5f97511ad9e8 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 16 Jul 2024 15:30:57 -0400 Subject: [PATCH 038/225] Add tests for TransformerBasis --- tests/test_basis.py | 192 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 2 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index d0d27a23..929b90c3 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -1,5 +1,6 @@ import abc import inspect +import pickle from contextlib import nullcontext as does_not_raise import jax.numpy @@ -3955,6 +3956,134 @@ def test_transformerbasis_set_params(basis_cls, n_basis_funcs_init, n_basis_func assert trans_basis._basis.n_basis_funcs == n_basis_funcs_new +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_setattr_basis(basis_cls): + # setting the _basis attribute should change it + trans_bas = basis.TransformerBasis(basis_cls(10)) + trans_bas._basis = basis_cls(20) + + assert trans_bas.n_basis_funcs == 20 + assert trans_bas._basis.n_basis_funcs == 20 + assert isinstance(trans_bas._basis, basis_cls) + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_setattr_basis_attribute(basis_cls): + # setting an attribute that is an attribute of the underlying _basis + # should propagate setting it on _basis itself + trans_bas = basis.TransformerBasis(basis_cls(10)) + trans_bas.n_basis_funcs = 20 + + assert trans_bas.n_basis_funcs == 20 + assert trans_bas._basis.n_basis_funcs == 20 + assert isinstance(trans_bas._basis, basis_cls) + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_copy_basis_on_contsruct(basis_cls): + # modifying the transformerbasis's attributes shouldn't + # touch the original basis that was used to create it + orig_bas = basis_cls(10) + trans_bas = basis.TransformerBasis(orig_bas) + trans_bas.n_basis_funcs = 20 + + assert orig_bas.n_basis_funcs == 10 + assert trans_bas._basis.n_basis_funcs == 20 + assert trans_bas._basis.n_basis_funcs == 20 + assert isinstance(trans_bas._basis, basis_cls) + + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_setattr_illegal_attribute(basis_cls): + # changing an attribute that is not _basis or an attribute of _basis + # is not allowed + trans_bas = basis.TransformerBasis(basis_cls(10)) + + with pytest.raises(ValueError, match="Only setting _basis or existing attributes of _basis is allowed."): + trans_bas.random_attr = "random value" + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_sk_clone_kernel_noned(basis_cls): + orig_bas = basis_cls(10, mode="conv", window_size=5) + trans_bas = basis.TransformerBasis(orig_bas) + + # kernel should + trans_bas.fit(np.random.randn(100, 20)) + assert isinstance(trans_bas._kernel, np.ndarray) + + # cloning should set _kernel to None + trans_bas_clone = trans_bas.__sklearn_clone__() + + assert orig_bas._kernel is None + assert trans_bas_clone._kernel is None + + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +@pytest.mark.parametrize("n_basis_funcs", [5]) +def test_transformerbasis_pickle(tmpdir, basis_cls, n_basis_funcs): + # the test that tries cross-validation with n_jobs = 2 already should test this + trans_bas = basis.TransformerBasis(basis_cls(n_basis_funcs)) + filepath = tmpdir / "transformerbasis.pickle" + with open(filepath, "wb") as f: + pickle.dump(trans_bas, f) + with open(filepath, "rb") as f: + trans_bas2 = pickle.load(f) + + assert isinstance(trans_bas2, basis.TransformerBasis) + assert trans_bas2.n_basis_funcs == n_basis_funcs + @pytest.mark.parametrize( "bas", @@ -3964,7 +4093,7 @@ def test_transformerbasis_set_params(basis_cls, n_basis_funcs_init, n_basis_func basis.CyclicBSplineBasis(5), basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), basis.RaisedCosineBasisLinear(5), - ] + ] ) def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): X, y, model, _, _ = poissonGLM_model_instantiation @@ -3988,13 +4117,72 @@ def test_sklearn_transformer_pipeline_cv(bas, poissonGLM_model_instantiation): X, y, model, _, _ = poissonGLM_model_instantiation bas = basis.TransformerBasis(bas) pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) + param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) +@pytest.mark.parametrize( + "bas", + [ + basis.MSplineBasis(5), + basis.BSplineBasis(5), + basis.CyclicBSplineBasis(5), + basis.RaisedCosineBasisLinear(5), + basis.RaisedCosineBasisLog(5), + ], +) +def test_sklearn_transformer_pipeline_cv_multiprocess(bas, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas) + pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3, n_jobs=3) + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) +@pytest.mark.parametrize( + "bas_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_sklearn_transformer_pipeline_cv_directly_over_basis(bas_cls, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas_cls(5)) + pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) + param_grid = dict( + transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20)) + ) gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) - gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) +@pytest.mark.parametrize( + "bas_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_sklearn_transformer_pipeline_cv_illegal_combination(bas_cls, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas_cls(5)) + pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) + param_grid = dict( + transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20)), + transformerbasis__n_basis_funcs=(3, 5, 10), + ) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) + with pytest.raises( + ValueError, match="Set either _basis or parameters for _basis, not both." + ): + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + @pytest.mark.parametrize( "bas, expected_nans", From 865765c1a675e54941da44dacab946f60be52ab1 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 16 Jul 2024 16:55:11 -0400 Subject: [PATCH 039/225] Don't set the original's kernel to None in TransformerBasis.__sklearn_clone__ --- src/nemos/basis.py | 5 +++-- tests/test_basis.py | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 7acabeb0..b784c7e8 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -244,8 +244,9 @@ def __sklearn_clone__(self) -> TransformerBasis: For more info: https://scikit-learn.org/stable/developers/develop.html#cloning """ - self._basis._kernel = None - return TransformerBasis(copy.deepcopy(self._basis)) + cloned_obj = TransformerBasis(copy.deepcopy(self._basis)) + cloned_obj._basis._kernel = None + return cloned_obj def set_params(self, **parameters) -> TransformerBasis: """ diff --git a/tests/test_basis.py b/tests/test_basis.py index 929b90c3..2be6d9f2 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -11,6 +11,7 @@ import statsmodels.api as sm import utils_testing from sklearn.model_selection import GridSearchCV +from sklearn.base import clone as sk_clone import nemos.basis as basis import nemos.convolve as convolve @@ -4050,14 +4051,16 @@ def test_transformerbasis_sk_clone_kernel_noned(basis_cls): orig_bas = basis_cls(10, mode="conv", window_size=5) trans_bas = basis.TransformerBasis(orig_bas) - # kernel should + # kernel should be saved in the object after fit trans_bas.fit(np.random.randn(100, 20)) assert isinstance(trans_bas._kernel, np.ndarray) # cloning should set _kernel to None - trans_bas_clone = trans_bas.__sklearn_clone__() + trans_bas_clone = sk_clone(trans_bas) - assert orig_bas._kernel is None + # the original object should still have _kernel + assert isinstance(trans_bas._kernel, np.ndarray) + # but the clone should not have one assert trans_bas_clone._kernel is None From b8f4560cf3785bce1a92b64733eed69987bed302 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 16 Jul 2024 17:00:57 -0400 Subject: [PATCH 040/225] update tests --- src/nemos/base_regressor.py | 4 +- src/nemos/glm.py | 11 +- src/nemos/regularizer.py | 4 +- tests/conftest.py | 36 +- tests/test_base_class.py | 3 +- tests/test_basis.py | 33 +- tests/test_glm.py | 102 ++-- tests/test_observation_models.py | 10 +- tests/test_regularizer.py | 450 ++++++++---------- ...test_vallidation.py => test_validation.py} | 0 10 files changed, 317 insertions(+), 336 deletions(-) rename tests/{test_vallidation.py => test_validation.py} (100%) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index d747b7ba..e52765e3 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -93,8 +93,8 @@ def regularizer(self, regularizer: str | Regularizer): self._regularizer = regularizer else: raise TypeError( - f"The regularizer should be either a string from {AVAILABLE_REGULARIZERS} " - f"or an instance of `nemos.regularizer.Regularizer`" + f"The regularizer should be either a string from " + f"{AVAILABLE_REGULARIZERS} or an instance of `nemos.regularizer.Regularizer`" ) @property diff --git a/src/nemos/glm.py b/src/nemos/glm.py index d5fbcfff..a604816b 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -1099,10 +1099,19 @@ class PopulationGLM(GLM): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), + regularizer: str | Regularizer = "unregularized", + solver_name: str = None, + solver_kwargs: dict = None, feature_mask: Optional[jnp.ndarray] = None, **kwargs, ): - super().__init__(observation_model=observation_model, **kwargs) + super().__init__( + observation_model=observation_model, + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + **kwargs, + ) self.feature_mask = feature_mask @property diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 16f87bf9..e0fa473a 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -81,7 +81,9 @@ def regularizer_strength(self, strength: float): strength = float(strength) except ValueError: # raise a more detailed ValueError - raise ValueError(f"Could not convert the regularizer strength: {strength} to a float.") + raise ValueError( + f"Could not convert the regularizer strength: {strength} to a float." + ) self._regularizer_strength = strength def penalized_loss(self, loss: Callable) -> Callable: diff --git a/tests/conftest.py b/tests/conftest.py index 8a9a69ff..f49cae42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ # Sample subclass to test instantiation and methods -class MockRegressor(nmo.base_class.BaseRegressor): +class MockRegressor(nmo.base_regressor.BaseRegressor): """ Mock implementation of the BaseRegressor abstract class for testing purposes. Implements all required abstract methods as empty methods. @@ -72,6 +72,10 @@ def update(self, *args, **kwargs): def initialize_solver(self, *args, **kwargs): pass + def _predict_and_compute_loss(self, params, X, y): + pass + + class MockRegressorNested(MockRegressor): def __init__(self, other_param: int, std_param: int = 0): super().__init__(std_param=std_param) @@ -154,7 +158,7 @@ def poissonGLM_model_instantiation(): b_true = np.zeros((1,)) w_true = np.random.normal(size=(5,)) observation_model = nmo.observation_models.PoissonObservations(jnp.exp) - regularizer = nmo.regularizer.UnRegularized("GradientDescent", {}) + regularizer = nmo.regularizer.UnRegularized() model = nmo.glm.GLM(observation_model, regularizer) rate = jax.numpy.exp(jax.numpy.einsum("k,tk->t", w_true, X) + b_true) return X, np.random.poisson(rate), model, (w_true, b_true), rate @@ -181,8 +185,10 @@ def poisson_population_GLM_model(): b_true = -2 * np.ones((3,)) w_true = np.random.normal(size=(5, 3)) observation_model = nmo.observation_models.PoissonObservations(jnp.exp) - regularizer = nmo.regularizer.UnRegularized("GradientDescent", {}) - model = nmo.glm.PopulationGLM(observation_model, regularizer) + regularizer = nmo.regularizer.UnRegularized() + model = nmo.glm.PopulationGLM( + observation_model=observation_model, regularizer=regularizer + ) rate = jnp.exp(jnp.einsum("ki,tk->ti", w_true, X) + b_true) return X, np.random.poisson(rate), model, (w_true, b_true), rate @@ -209,7 +215,9 @@ def poisson_population_GLM_model_pytree(poisson_population_GLM_model): dict(input_1=true_params[0][:3], input_2=true_params[0][3:]), true_params[1], ) - model_tree = nmo.glm.PopulationGLM(model.observation_model, model.regularizer) + model_tree = nmo.glm.PopulationGLM( + observation_model=model.observation_model, regularizer=model.regularizer + ) return X_tree, np.random.poisson(rate), model_tree, true_params_tree, rate @@ -335,7 +343,7 @@ def group_sparse_poisson_glm_model_instantiation(): mask[0, 1:4] = 1 mask[1, [0, 4]] = 1 observation_model = nmo.observation_models.PoissonObservations(jnp.exp) - regularizer = nmo.regularizer.UnRegularized("GradientDescent", {}) + regularizer = nmo.regularizer.UnRegularized() model = nmo.glm.GLM(observation_model, regularizer) rate = jax.numpy.exp(jax.numpy.einsum("k,tk->t", w_true, X) + b_true) return X, np.random.poisson(rate), model, (w_true, b_true), rate, mask @@ -414,7 +422,7 @@ def poisson_observation_model(): @pytest.fixture def ridge_regularizer(): - return nmo.regularizer.Ridge(solver_name="LBFGS", regularizer_strength=0.1) + return nmo.regularizer.Ridge(regularizer_strength=0.1) @pytest.fixture @@ -470,7 +478,7 @@ def gammaGLM_model_instantiation(): b_true = np.zeros((1,)) w_true = np.random.uniform(size=(5,)) observation_model = nmo.observation_models.GammaObservations() - regularizer = nmo.regularizer.UnRegularized("GradientDescent", {}) + regularizer = nmo.regularizer.UnRegularized() model = nmo.glm.GLM(observation_model, regularizer) rate = (jax.numpy.einsum("k,tk->t", w_true, X) + b_true) ** -1 theta = 3 @@ -478,6 +486,7 @@ def gammaGLM_model_instantiation(): model.scale = theta return X, np.random.gamma(k, scale=theta), model, (w_true, b_true), rate + @pytest.fixture def gammaGLM_model_instantiation_pytree(gammaGLM_model_instantiation): """Set up a Gamma GLM for testing purposes. @@ -511,14 +520,17 @@ def gamma_population_GLM_model(): b_true = 0.5 * np.ones((3,)) w_true = np.random.uniform(size=(5, 3)) observation_model = nmo.observation_models.GammaObservations() - regularizer = nmo.regularizer.UnRegularized("GradientDescent", {}) - model = nmo.glm.PopulationGLM(observation_model, regularizer) + regularizer = nmo.regularizer.UnRegularized() + model = nmo.glm.PopulationGLM( + observation_model=observation_model, regularizer=regularizer + ) rate = 1 / (jnp.einsum("ki,tk->ti", w_true, X) + b_true) theta = 3 model.scale = theta y = jax.random.gamma(jax.random.PRNGKey(123), rate / theta) * theta return X, y, model, (w_true, b_true), rate + @pytest.fixture() def gamma_population_GLM_model_pytree(gamma_population_GLM_model): X, spikes, model, true_params, rate = gamma_population_GLM_model @@ -527,5 +539,7 @@ def gamma_population_GLM_model_pytree(gamma_population_GLM_model): dict(input_1=true_params[0][:3], input_2=true_params[0][3:]), true_params[1], ) - model_tree = nmo.glm.PopulationGLM(model.observation_model, model.regularizer) + model_tree = nmo.glm.PopulationGLM( + observation_model=model.observation_model, regularizer=model.regularizer + ) return X_tree, spikes, model_tree, true_params_tree, rate diff --git a/tests/test_base_class.py b/tests/test_base_class.py index 3058497a..d6ddff02 100644 --- a/tests/test_base_class.py +++ b/tests/test_base_class.py @@ -4,7 +4,8 @@ import pytest from numpy.typing import NDArray -from nemos.base_class import Base, BaseRegressor +from nemos.base_class import Base +from nemos.base_regressor import BaseRegressor class MockBaseRegressorInvalid(BaseRegressor): diff --git a/tests/test_basis.py b/tests/test_basis.py index 2df2db5d..290bcf72 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -516,11 +516,12 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') + bas = self.cls(5, 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, mode='eval', test='hi') + bas = self.cls(5, mode="eval", test="hi") + class TestRaisedCosineLinearBasis(BasisFuncsTesting): cls = basis.RaisedCosineBasisLinear @@ -915,11 +916,11 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') + bas = self.cls(5, 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, mode='eval', test='hi') + bas = self.cls(5, mode="eval", test="hi") class TestMSplineBasis(BasisFuncsTesting): @@ -1299,11 +1300,12 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') + bas = self.cls(5, 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, mode='eval', test='hi') + bas = self.cls(5, mode="eval", test="hi") + class TestOrthExponentialBasis(BasisFuncsTesting): cls = basis.OrthExponentialBasis @@ -1747,14 +1749,14 @@ def test_convolution_is_performed(self): assert np.all(np.isnan(conv_2[~valid])) def test_identifiability_constraint_setter(self): - bas = self.cls(5, decay_rates=[1,2,3,4,5]) + bas = self.cls(5, decay_rates=[1, 2, 3, 4, 5]) bas.identifiability_constraints = True assert bas.identifiability_constraints with pytest.raises(TypeError, match="`identifiability_constraints` must be"): bas.identifiability_constraints = "True" def test_identifiability_constraint_apply(self): - bas = self.cls(5, decay_rates=[1,2,3,4,5]) + bas = self.cls(5, decay_rates=[1, 2, 3, 4, 5]) bas.identifiability_constraints = True X = bas(np.linspace(0, 1, 20)) assert np.allclose(X.mean(axis=0), np.zeros(X.shape[1])) @@ -1762,11 +1764,12 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, [1,2,3,4,5], 10, mode='eval') + bas = self.cls(5, [1, 2, 3, 4, 5], 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, decay_rates=[1,2,3,4,5], mode='eval', test='hi') + bas = self.cls(5, decay_rates=[1, 2, 3, 4, 5], mode="eval", test="hi") + class TestBSplineBasis(BasisFuncsTesting): cls = basis.BSplineBasis @@ -2167,11 +2170,12 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') + bas = self.cls(5, 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, mode='eval', test='hi') + bas = self.cls(5, mode="eval", test="hi") + class TestCyclicBSplineBasis(BasisFuncsTesting): cls = basis.CyclicBSplineBasis @@ -2590,11 +2594,12 @@ def test_identifiability_constraint_apply(self): def test_conv_args_error(self): with pytest.raises(ValueError, match="args should only be set"): - bas = self.cls(5, 10, mode='eval') + bas = self.cls(5, 10, mode="eval") def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, mode='eval', test='hi') + bas = self.cls(5, mode="eval", test="hi") + class CombinedBasis(BasisFuncsTesting): """ diff --git a/tests/test_glm.py b/tests/test_glm.py index f88129ea..1bc30236 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -46,29 +46,31 @@ class TestGLM: # Test model.__init__ ####################### @pytest.mark.parametrize( - "regularizer, expectation", + "regularizer, solver_name, expectation", [ - (nmo.regularizer.Ridge("BFGS"), does_not_raise()), + (nmo.regularizer.Ridge(), "BFGS", does_not_raise()), ( + None, None, pytest.raises( - AttributeError, match="The provided `solver` doesn't implement " + TypeError, match="The regularizer should be either a string from " ), ), ( nmo.regularizer.Ridge, + None, pytest.raises( - TypeError, match="The provided `solver` cannot be instantiated" + TypeError, match="The regularizer should be either a string from " ), ), ], ) - def test_solver_type(self, regularizer, expectation, glm_class): + def test_solver_type(self, regularizer, solver_name, expectation, glm_class): """ Test that an error is raised if a non-compatible solver is passed. """ with expectation: - glm_class(regularizer=regularizer) + glm_class(regularizer=regularizer, solver_name=solver_name) @pytest.mark.parametrize( "observation, expectation", @@ -98,7 +100,11 @@ def test_init_observation_type( when the regularizer name is not present in jaxopt. """ with expectation: - glm_class(regularizer=ridge_regularizer, observation_model=observation) + glm_class( + regularizer=ridge_regularizer, + solver_name="LBFGS", + observation_model=observation, + ) @pytest.mark.parametrize( "X, y", @@ -409,9 +415,8 @@ def test_fit_mask_grouplasso(self, group_sparse_poisson_glm_model_instantiation) """Test that the group lasso fit goes through""" X, y, model, params, rate, mask = group_sparse_poisson_glm_model_instantiation model.set_params( - regularizer=nmo.regularizer.GroupLasso( - solver_name="ProximalGradient", mask=mask - ) + regularizer=nmo.regularizer.GroupLasso(mask=mask), + solver_name="ProximalGradient", ) model.fit(X, y) @@ -1095,9 +1100,8 @@ def test_initialize_solver_mask_grouplasso( """Test that the group lasso initialize_solver goes through""" X, y, model, params, rate, mask = group_sparse_poisson_glm_model_instantiation model.set_params( - regularizer=nmo.regularizer.GroupLasso( - solver_name="ProximalGradient", mask=mask - ) + regularizer=nmo.regularizer.GroupLasso(mask=mask), + solver_name="ProximalGradient", ) model.initialize_solver(X, y) @@ -1345,12 +1349,12 @@ def test_deviance_against_statsmodels(self, poissonGLM_model_instantiation): def test_compatibility_with_sklearn_cv(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - param_grid = {"regularizer__solver_name": ["BFGS", "GradientDescent"]} + param_grid = {"solver_name": ["BFGS", "GradientDescent"]} GridSearchCV(model, param_grid).fit(X, y) def test_compatibility_with_sklearn_cv_gamma(self, gammaGLM_model_instantiation): X, y, model, true_params, firing_rate = gammaGLM_model_instantiation - param_grid = {"regularizer__solver_name": ["BFGS", "GradientDescent"]} + param_grid = {"solver_name": ["BFGS", "GradientDescent"]} GridSearchCV(model, param_grid).fit(X, y) @@ -1365,17 +1369,17 @@ class TestPopulationGLM: @pytest.mark.parametrize( "regularizer, expectation", [ - (nmo.regularizer.Ridge("BFGS"), does_not_raise()), + (nmo.regularizer.Ridge(), does_not_raise()), ( None, pytest.raises( - AttributeError, match="The provided `solver` doesn't implement " + TypeError, match="The regularizer should be either a string from " ), ), ( nmo.regularizer.Ridge, pytest.raises( - TypeError, match="The provided `solver` cannot be instantiated" + TypeError, match="The regularizer should be either a string from " ), ), ], @@ -1466,13 +1470,11 @@ def test_fit_param_length( [ nmo.regularizer.UnRegularized(), nmo.regularizer.Lasso(), - nmo.regularizer.Ridge() - ] + nmo.regularizer.Ridge(), + ], ) @pytest.mark.parametrize("n_samples", [1, 20]) - def test_estimate_dof_resid( - self, n_samples, reg, poisson_population_GLM_model - ): + def test_estimate_dof_resid(self, n_samples, reg, poisson_population_GLM_model): """ Test that the dof is an integer. """ @@ -1739,9 +1741,8 @@ def test_fit_mask_grouplasso(self, group_sparse_poisson_glm_model_instantiation) """Test that the group lasso fit goes through""" X, y, model, params, rate, mask = group_sparse_poisson_glm_model_instantiation model.set_params( - regularizer=nmo.regularizer.GroupLasso( - solver_name="ProximalGradient", mask=mask - ) + regularizer=nmo.regularizer.GroupLasso(mask=mask), + solver_name="ProximalGradient", ) model.fit(X, y) @@ -2125,9 +2126,8 @@ def test_initialize_solver_mask_grouplasso( """Test that the group lasso initialize_solver goes through""" X, y, model, params, rate, mask = group_sparse_poisson_glm_model_instantiation model.set_params( - regularizer=nmo.regularizer.GroupLasso( - solver_name="ProximalGradient", mask=mask - ) + regularizer=nmo.regularizer.GroupLasso(mask=mask), + solver_name="ProximalGradient", ) model.initialize_solver(X, y) @@ -2661,12 +2661,12 @@ def test_deviance_against_statsmodels(self, poisson_population_GLM_model): def test_compatibility_with_sklearn_cv(self, poisson_population_GLM_model): X, y, model, true_params, firing_rate = poisson_population_GLM_model - param_grid = {"regularizer__solver_name": ["BFGS", "GradientDescent"]} + param_grid = {"solver_name": ["BFGS", "GradientDescent"]} GridSearchCV(model, param_grid).fit(X, y) def test_compatibility_with_sklearn_cv_gamma(self, gamma_population_GLM_model): X, y, model, true_params, firing_rate = gamma_population_GLM_model - param_grid = {"regularizer__solver_name": ["BFGS", "GradientDescent"]} + param_grid = {"solver_name": ["BFGS", "GradientDescent"]} GridSearchCV(model, param_grid).fit(X, y) @pytest.mark.parametrize( @@ -2801,24 +2801,24 @@ def test_feature_mask_compatibility_fit_tree( getattr(model, attr_name)(X, y) @pytest.mark.parametrize( - "regularizer", + "regularizer, solver_name, solver_kwargs", [ - nmo.regularizer.UnRegularized( - solver_name="LBFGS", solver_kwargs={"stepsize": 0.1, "tol": 10**-14} - ), - nmo.regularizer.UnRegularized( - solver_name="GradientDescent", solver_kwargs={"tol": 10**-14} - ), - nmo.regularizer.Ridge( - solver_name="GradientDescent", - regularizer_strength=0.001, - solver_kwargs={"tol": 10**-14}, + ( + nmo.regularizer.UnRegularized(), + "LBFGS", + {"stepsize": 0.1, "tol": 10**-14}, ), - nmo.regularizer.Ridge( - solver_name="LBFGS", solver_kwargs={"stepsize": 0.1, "tol": 10**-14} + (nmo.regularizer.UnRegularized(), "GradientDescent", {"tol": 10**-14}), + ( + nmo.regularizer.Ridge(regularizer_strength=0.001), + "LBFGS", + {"tol": 10**-14}, ), - nmo.regularizer.Lasso( - regularizer_strength=0.001, solver_kwargs={"tol": 10**-14} + (nmo.regularizer.Ridge(), "LBFGS", {"stepsize": 0.1, "tol": 10**-14}), + ( + nmo.regularizer.Lasso(regularizer_strength=0.001), + "ProximalGradient", + {"tol": 10**-14}, ), ], ) @@ -2840,6 +2840,8 @@ def test_feature_mask_compatibility_fit_tree( def test_masked_fit_vs_loop( self, regularizer, + solver_name, + solver_kwargs, mask, poisson_population_GLM_model, poisson_population_GLM_model_pytree, @@ -2870,6 +2872,8 @@ def map_neu(k, coef_): # fit pop glm model.feature_mask = mask model.regularizer = regularizer + model.solver_name = solver_name + model.solver_kwargs = solver_kwargs model.fit(X, y) coef_vectorized = np.vstack(jax.tree_util.tree_leaves(model.coef_)) @@ -2877,7 +2881,11 @@ def map_neu(k, coef_): intercept_loop = np.zeros((3,)) # loop over neuron for k in range(y.shape[1]): - model_single_neu = nmo.glm.GLM(regularizer=regularizer) + model_single_neu = nmo.glm.GLM( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) if isinstance(mask_bool, dict): X_neu = {} for key, xx in X.items(): diff --git a/tests/test_observation_models.py b/tests/test_observation_models.py index 8a48d8c9..9c1a4a19 100644 --- a/tests/test_observation_models.py +++ b/tests/test_observation_models.py @@ -153,7 +153,7 @@ def test_pseudo_r2_mean(self, score_type, poissonGLM_model_instantiation): pseudo_r2 = model.observation_model.pseudo_r2( y.mean(), y, score_type=score_type ) - if not np.allclose(pseudo_r2, 0, atol=10**-7, rtol=0.): + if not np.allclose(pseudo_r2, 0, atol=10**-7, rtol=0.0): raise ValueError( f"pseudo-r2 of {pseudo_r2} for the null model. Should be equal to 0!" ) @@ -414,9 +414,9 @@ def test_pseudo_r2_range(self, score_type, gammaGLM_model_instantiation): rate = model.predict(X) ysim, _ = model.simulate(jax.random.PRNGKey(123), X) - pseudo_r2 = nmo.observation_models.GammaObservations(inverse_link_function=lambda x: 1/x).pseudo_r2( - rate, ysim, score_type=score_type - ) + pseudo_r2 = nmo.observation_models.GammaObservations( + inverse_link_function=lambda x: 1 / x + ).pseudo_r2(rate, ysim, score_type=score_type) if (pseudo_r2 > 1) or (pseudo_r2 < 0): raise ValueError(f"pseudo-r2 of {pseudo_r2} outside the [0,1] range!") @@ -429,7 +429,7 @@ def test_pseudo_r2_mean(self, score_type, gammaGLM_model_instantiation): pseudo_r2 = model.observation_model.pseudo_r2( y.mean(), y, score_type=score_type ) - if not np.allclose(pseudo_r2, 0, atol=10**-7, rtol=0.): + if not np.allclose(pseudo_r2, 0, atol=10**-7, rtol=0.0): raise ValueError( f"pseudo-r2 of {pseudo_r2} for the null model. Should be equal to 0!" ) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 8cb614d9..f7ceee83 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -18,7 +18,7 @@ nmo.regularizer.UnRegularized(), nmo.regularizer.Ridge(), nmo.regularizer.Lasso(), - nmo.regularizer.GroupLasso("ProximalGradient", np.array([[1.0]])), + nmo.regularizer.GroupLasso(mask=np.array([[1.0]])), ], ) def test_get_only_allowed_solvers(regularizer): @@ -36,7 +36,7 @@ def test_get_only_allowed_solvers(regularizer): nmo.regularizer.UnRegularized(), nmo.regularizer.Ridge(), nmo.regularizer.Lasso(), - nmo.regularizer.GroupLasso("ProximalGradient", np.array([[1.0]])), + nmo.regularizer.GroupLasso(mask=np.array([[1.0]])), ], ) def test_item_assignment_allowed_solvers(regularizer): @@ -59,19 +59,19 @@ def test_init_solver_name(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", + "ProximalGradient", ] + raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) else: - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) @pytest.mark.parametrize( "solver_name", @@ -83,53 +83,66 @@ def test_set_solver_name_allowed(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", + "ProximalGradient", ] - regularizer = self.cls("GradientDescent") + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) else: - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_name, solver_kwargs): """Test RidgeSolver acceptable kwargs.""" - + regularizer = self.cls() raise_exception = "tols" in list(solver_kwargs.keys()) if raise_exception: with pytest.raises( NameError, match="kwargs {'tols'} in solver_kwargs not a kwarg" ): - self.cls(solver_name, solver_kwargs=solver_kwargs) + nmo.glm.GLM( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) else: - self.cls(solver_name, solver_kwargs=solver_kwargs) + nmo.glm.GLM( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_is_callable(self, loss): - """Test that the loss function is a callable""" + """Test Unregularized callable loss.""" raise_exception = not callable(loss) + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + model._predict_and_compute_loss = loss if raise_exception: with pytest.raises(TypeError, match="The `loss` must be a Callable"): - self.cls("GradientDescent").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") else: - self.cls("GradientDescent").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - runner = self.cls("GradientDescent").instantiate_solver( - model._predict_and_compute_loss - )[2] + + # set regularizer and solver name + model.regularizer = self.cls() + model.solver_name = solver_name + runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) def test_solver_output_match(self, poissonGLM_model_instantiation): @@ -138,31 +151,25 @@ def test_solver_output_match(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 - runner_gd = self.cls("GradientDescent", {"tol": 10**-12}).instantiate_solver( - model._predict_and_compute_loss - )[2] - runner_bfgs = self.cls("BFGS", {"tol": 10**-12}).instantiate_solver( - model._predict_and_compute_loss - )[2] - runner_scipy = self.cls( - "ScipyMinimize", {"method": "BFGS", "tol": 10**-12} - ).instantiate_solver(model._predict_and_compute_loss)[2] + # set model params + model.regularizer = self.cls() + model.solver_name = "GradientDescent" + model.solver_kwargs = {"tol": 10**-12} + runner_gd = model.instantiate_solver()[2] + + # update solver name + model.solver_name = "BFGS" + runner_bfgs = model.instantiate_solver()[2] weights_gd, intercepts_gd = runner_gd( (true_params[0] * 0.0, true_params[1]), X, y )[0] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] - weights_scipy, intercepts_scipy = runner_scipy( - (true_params[0] * 0.0, true_params[1]), X, y - )[0] - match_weights = np.allclose(weights_gd, weights_bfgs) and np.allclose( - weights_gd, weights_scipy - ) - match_intercepts = np.allclose(intercepts_gd, intercepts_bfgs) and np.allclose( - intercepts_gd, intercepts_scipy - ) + match_weights = np.allclose(weights_gd, weights_bfgs) + match_intercepts = np.allclose(intercepts_gd, intercepts_bfgs) + if (not match_weights) or (not match_intercepts): raise ValueError( "Convex estimators should converge to the same numerical value." @@ -174,8 +181,9 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 - regularizer = self.cls("GradientDescent", {"tol": 10**-12}) - runner_bfgs = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_kwargs = {"tol": 10**-12} + runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] @@ -185,7 +193,7 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): match_weights = np.allclose(model_skl.coef_, weights_bfgs) match_intercepts = np.allclose(model_skl.intercept_, intercepts_bfgs) if (not match_weights) or (not match_intercepts): - raise ValueError("Ridge GLM regularizer estimate does not match sklearn!") + raise ValueError("UnRegularized GLM estimate does not match sklearn!") def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): """Test that different solvers converge to the same solution.""" @@ -194,8 +202,9 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 model.observation_model.inverse_link_function = jnp.exp - regularizer = self.cls("GradientDescent", {"tol": 10**-12}) - runner_bfgs = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_kwargs = {"tol": 10**-12} + runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] @@ -223,8 +232,10 @@ def test_solver_match_statsmodels_gamma( # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 model.observation_model.inverse_link_function = inv_link_jax - regularizer = self.cls("LBFGS", {"tol": 10**-13}) - runner_bfgs = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_name = "LBFGS" + model.solver_kwargs = {"tol": 10**-13} + runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( model._initialize_parameters(X, y), X, y )[0] @@ -240,29 +251,6 @@ def test_solver_match_statsmodels_gamma( if (not match_weights) or (not match_intercepts): raise ValueError("Unregularized GLM estimate does not match statsmodels!") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) - @pytest.mark.parametrize( - "kwargs, expectation", - [ - ({}, does_not_raise()), - ( - {"prox": 0}, - pytest.raises( - ValueError, - match=r"Regularizer of type [A-z]+ does not require a " - r"proximal operator!", - ), - ), - ], - ) - def test_overwritten_proximal_operator( - self, solver_name, kwargs, expectation, poissonGLM_model_instantiation - ): - X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - with expectation: - model.regularizer.solver_kwargs = kwargs - model.fit(X, y) - class TestRidge: cls = nmo.regularizer.Ridge @@ -277,19 +265,18 @@ def test_init_solver_name(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", + "ProximalGradient", ] raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) else: - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) @pytest.mark.parametrize( "solver_name", @@ -301,53 +288,66 @@ def test_set_solver_name_allowed(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "ScipyMinimize", - "NonlinearCG", - "ScipyBoundedMinimize", "LBFGSB", + "NonlinearCG", + "ProximalGradient", ] - regularizer = self.cls("GradientDescent") + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) else: - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_name, solver_kwargs): """Test Ridge acceptable kwargs.""" - + regularizer = self.cls() raise_exception = "tols" in list(solver_kwargs.keys()) if raise_exception: with pytest.raises( NameError, match="kwargs {'tols'} in solver_kwargs not a kwarg" ): - self.cls(solver_name, solver_kwargs=solver_kwargs) + nmo.glm.GLM( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) else: - self.cls(solver_name, solver_kwargs=solver_kwargs) + nmo.glm.GLM( + regularizer=regularizer, + solver_name=solver_name, + solver_kwargs=solver_kwargs, + ) @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_is_callable(self, loss): - """Test that the loss function is a callable""" + """Test Ridge callable loss.""" raise_exception = not callable(loss) + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + model._predict_and_compute_loss = loss if raise_exception: with pytest.raises(TypeError, match="The `loss` must be a Callable"): - self.cls("GradientDescent").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") else: - self.cls("GradientDescent").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - runner = self.cls("GradientDescent").instantiate_solver( - model._predict_and_compute_loss - )[2] + + # set regularizer and solver name + model.regularizer = self.cls() + model.solver_name = solver_name + runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) def test_solver_output_match(self, poissonGLM_model_instantiation): @@ -356,31 +356,25 @@ def test_solver_output_match(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 - runner_gd = self.cls("GradientDescent", {"tol": 10**-12}).instantiate_solver( - model._predict_and_compute_loss - )[2] - runner_bfgs = self.cls("BFGS", {"tol": 10**-12}).instantiate_solver( - model._predict_and_compute_loss - )[2] - runner_scipy = self.cls( - "ScipyMinimize", {"method": "BFGS", "tol": 10**-12} - ).instantiate_solver(model._predict_and_compute_loss)[2] + + # set model params + model.regularizer = self.cls() + model.solver_name = "GradientDescent" + model.solver_kwargs = {"tol": 10**-12} + + runner_gd = model.instantiate_solver()[2] + runner_bfgs = model.instantiate_solver()[2] + weights_gd, intercepts_gd = runner_gd( (true_params[0] * 0.0, true_params[1]), X, y )[0] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] - weights_scipy, intercepts_scipy = runner_scipy( - (true_params[0] * 0.0, true_params[1]), X, y - )[0] - match_weights = np.allclose(weights_gd, weights_bfgs) and np.allclose( - weights_gd, weights_scipy - ) - match_intercepts = np.allclose(intercepts_gd, intercepts_bfgs) and np.allclose( - intercepts_gd, intercepts_scipy - ) + match_weights = np.allclose(weights_gd, weights_bfgs) + match_intercepts = np.allclose(intercepts_gd, intercepts_bfgs) + if (not match_weights) or (not match_intercepts): raise ValueError( "Convex estimators should converge to the same numerical value." @@ -392,13 +386,17 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 - regularizer = self.cls("GradientDescent", {"tol": 10**-12}) - runner_bfgs = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_kwargs = {"tol": 10**-12} + + runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] model_skl = PoissonRegressor( - fit_intercept=True, tol=10**-12, alpha=regularizer.regularizer_strength + fit_intercept=True, + tol=10**-12, + alpha=model.regularizer.regularizer_strength, ) model_skl.fit(X, y) @@ -414,14 +412,17 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 model.observation_model.inverse_link_function = jnp.exp - regularizer = self.cls("GradientDescent", {"tol": 10**-12}) - regularizer.regularizer_strength = 0.1 - runner_bfgs = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_kwargs = {"tol": 10**-12} + model.regularizer.regularizer_strength = 0.1 + runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] model_skl = GammaRegressor( - fit_intercept=True, tol=10**-12, alpha=regularizer.regularizer_strength + fit_intercept=True, + tol=10**-12, + alpha=model.regularizer.regularizer_strength, ) model_skl.fit(X, y) @@ -430,29 +431,6 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): if (not match_weights) or (not match_intercepts): raise ValueError("Ridge GLM estimate does not match sklearn!") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) - @pytest.mark.parametrize( - "kwargs, expectation", - [ - ({}, does_not_raise()), - ( - {"prox": 0}, - pytest.raises( - ValueError, - match=r"Regularizer of type [A-z]+ does not require a " - r"proximal operator!", - ), - ), - ], - ) - def test_overwritten_proximal_operator( - self, solver_name, kwargs, expectation, poissonGLM_model_instantiation - ): - X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - with expectation: - model.regularizer.solver_kwargs = kwargs - model.fit(X, y) - class TestLasso: cls = nmo.regularizer.Lasso @@ -467,11 +445,11 @@ def test_init_solver_name(self, solver_name): raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) else: - self.cls(solver_name) + nmo.glm.GLM(regularizer=self.cls(), solver_name=solver_name) @pytest.mark.parametrize( "solver_name", @@ -480,45 +458,51 @@ def test_init_solver_name(self, solver_name): def test_set_solver_name_allowed(self, solver_name): """Test Lasso acceptable solvers.""" acceptable_solvers = ["ProximalGradient"] - regularizer = self.cls("ProximalGradient") + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) raise_exception = solver_name not in acceptable_solvers if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) else: - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_kwargs): """Test LassoSolver acceptable kwargs.""" + regularizer = self.cls() raise_exception = "tols" in list(solver_kwargs.keys()) if raise_exception: with pytest.raises( NameError, match="kwargs {'tols'} in solver_kwargs not a kwarg" ): - self.cls("ProximalGradient", solver_kwargs=solver_kwargs) + nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) else: - self.cls("ProximalGradient", solver_kwargs=solver_kwargs) + nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): """Test that the loss function is a callable""" raise_exception = not callable(loss) + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + model._predict_and_compute_loss = loss if raise_exception: with pytest.raises(TypeError, match="The `loss` must be a Callable"): - self.cls("ProximalGradient").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") else: - self.cls("ProximalGradient").instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") def test_run_solver(self, poissonGLM_model_instantiation): """Test that the solver runs.""" X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - runner = self.cls("ProximalGradient").instantiate_solver( - model._predict_and_compute_loss - )[2] + + model.regularizer = self.cls() + model.solver_name = "ProximalGradient" + runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): @@ -527,15 +511,18 @@ def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation # set precision to float64 for accurate matching of the results model.data_type = jnp.float64 - regularizer = self.cls("ProximalGradient", {"tol": 10**-12}) - runner = regularizer.instantiate_solver(model._predict_and_compute_loss)[2] + model.regularizer = self.cls() + model.solver_name = "ProximalGradient" + model.solver_kwargs = {"tol": 10**-12} + + runner = model.instantiate_solver()[2] weights, intercepts = runner((true_params[0] * 0.0, true_params[1]), X, y)[0] # instantiate the glm with statsmodels glm_sm = sm.GLM(endog=y, exog=sm.add_constant(X), family=sm.families.Poisson()) # regularize everything except intercept - alpha_sm = np.ones(X.shape[1] + 1) * regularizer.regularizer_strength + alpha_sm = np.ones(X.shape[1] + 1) * model.regularizer.regularizer_strength alpha_sm[0] = 0 # pure lasso = elastic net with L1 weight = 1 @@ -549,32 +536,11 @@ def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): if not match_weights: raise ValueError("Lasso GLM solver estimate does not match statsmodels!") - @pytest.mark.parametrize("solver_name", ["ProximalGradient"]) - @pytest.mark.parametrize( - "kwargs, expectation", - [ - ({}, does_not_raise()), - ( - {"prox": 0}, - pytest.warns( - UserWarning, match=r"Overwritten the user-defined proximal operator" - ), - ), - ], - ) - def test_overwritten_proximal_operator( - self, solver_name, kwargs, expectation, poissonGLM_model_instantiation - ): - X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - model.regularizer = nmo.regularizer.Lasso() - with expectation: - model.regularizer.solver_kwargs = kwargs - model.fit(X, y) - def test_lasso_pytree(self, poissonGLM_model_instantiation_pytree): """Check pytree X can be fit.""" X, y, model, true_params, firing_rate = poissonGLM_model_instantiation_pytree model.regularizer = nmo.regularizer.Lasso() + model.solver_name = "ProximalGradient" model.fit(X, y) @pytest.mark.parametrize("reg_str", [0.001, 0.01, 0.1, 1, 10]) @@ -591,6 +557,8 @@ def test_lasso_pytree_match( model.regularizer = nmo.regularizer.Lasso(regularizer_strength=reg_str) model_array.regularizer = nmo.regularizer.Lasso(regularizer_strength=reg_str) + model.solver_name = "ProximalGradient" + model_array.solver_name = "ProximalGradient" model.fit(X, y) model_array.fit(X_array, y) assert np.allclose( @@ -618,11 +586,11 @@ def test_init_solver_name(self, solver_name): if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - self.cls(solver_name, mask) + nmo.glm.GLM(regularizer=self.cls(mask=mask), solver_name=solver_name) else: - self.cls(solver_name, mask) + nmo.glm.GLM(regularizer=self.cls(mask=mask), solver_name=solver_name) @pytest.mark.parametrize( "solver_name", @@ -636,15 +604,16 @@ def test_set_solver_name_allowed(self, solver_name): mask[0, :5] = 1 mask[1, 5:] = 1 mask = jnp.asarray(mask) - regularizer = self.cls("ProximalGradient", mask=mask) + regularizer = self.cls(mask=mask) raise_exception = solver_name not in acceptable_solvers + model = nmo.glm.GLM(regularizer=regularizer) if raise_exception: with pytest.raises( - ValueError, match=f"Solver `{solver_name}` not allowed for " + ValueError, match=f"The solver: {solver_name} is not allowed for " ): - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) else: - regularizer.set_params(solver_name=solver_name) + model.set_params(solver_name=solver_name) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_kwargs): @@ -657,13 +626,15 @@ def test_init_solver_kwargs(self, solver_kwargs): mask[0, 1:] = 1 mask = jnp.asarray(mask) + regularizer = self.cls(mask=mask) + if raise_exception: with pytest.raises( NameError, match="kwargs {'tols'} in solver_kwargs not a kwarg" ): - self.cls("ProximalGradient", mask, solver_kwargs=solver_kwargs) + nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) else: - self.cls("ProximalGradient", mask, solver_kwargs=solver_kwargs) + nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): @@ -676,11 +647,15 @@ def test_loss_callable(self, loss): mask[1, 5:] = 1 mask = jnp.asarray(mask) + regularizer = self.cls(mask=mask) + model = nmo.glm.GLM(regularizer=regularizer) + model._predict_and_compute_loss = loss + if raise_exception: with pytest.raises(TypeError, match="The `loss` must be a Callable"): - self.cls("ProximalGradient", mask).instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") else: - self.cls("ProximalGradient", mask).instantiate_solver(loss) + nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") def test_run_solver(self, poissonGLM_model_instantiation): """Test that the solver runs.""" @@ -693,9 +668,10 @@ def test_run_solver(self, poissonGLM_model_instantiation): mask[1, 2:] = 1 mask = jnp.asarray(mask) - runner = self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - )[2] + model.regularizer = self.cls(mask=mask) + model.solver_name = "ProximalGradient" + + runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) def test_init_solver(self, poissonGLM_model_instantiation): @@ -709,9 +685,10 @@ def test_init_solver(self, poissonGLM_model_instantiation): mask[1, 2:] = 1 mask = jnp.asarray(mask) - init, _, _ = self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) + model.solver_name = "ProximalGradient" + + init, _, _ = model.instantiate_solver() state = init(true_params, X, y) # asses that state is a NamedTuple by checking tuple type and the availability of some NamedTuple # specific namespace attributes @@ -733,9 +710,11 @@ def test_update_solver(self, poissonGLM_model_instantiation): mask[1, 2:] = 1 mask = jnp.asarray(mask) - init, update, _ = self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) + model.solver_name = "ProximalGradient" + + init, update, _ = model.instantiate_solver() + state = init((true_params[0] * 0.0, true_params[1]), X, y) params, state = update(true_params, state, X, y) # asses that state is a NamedTuple by checking tuple type and the availability of some NamedTuple @@ -785,13 +764,9 @@ def test_mask_validity_groups( with pytest.raises( ValueError, match="Incorrect group assignment. " "Some of the features" ): - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) else: - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) @pytest.mark.parametrize("set_entry", [0, 1, -1, 2, 2.5]) def test_mask_validity_entries(self, set_entry, poissonGLM_model_instantiation): @@ -809,13 +784,9 @@ def test_mask_validity_entries(self, set_entry, poissonGLM_model_instantiation): if raise_exception: with pytest.raises(ValueError, match="Mask elements be 0s and 1s"): - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) else: - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) @pytest.mark.parametrize("n_dim", [0, 1, 2, 3]) def test_mask_dimension_1(self, n_dim, poissonGLM_model_instantiation): @@ -842,13 +813,9 @@ def test_mask_dimension_1(self, n_dim, poissonGLM_model_instantiation): if raise_exception: with pytest.raises(ValueError, match="`mask` must be 2-dimensional"): - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) else: - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) @pytest.mark.parametrize("n_groups", [0, 1, 2]) def test_mask_n_groups(self, n_groups, poissonGLM_model_instantiation): @@ -867,13 +834,9 @@ def test_mask_n_groups(self, n_groups, poissonGLM_model_instantiation): if raise_exception: with pytest.raises(ValueError, match=r"Empty mask provided! Mask has "): - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) else: - self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - ) + model.regularizer = self.cls(mask=mask) def test_group_sparsity_enforcement( self, group_sparse_poisson_glm_model_instantiation @@ -893,9 +856,10 @@ def test_group_sparsity_enforcement( mask[1, ~zeros_true] = 1 mask = jnp.asarray(mask, dtype=jnp.float32) - runner = self.cls("ProximalGradient", mask).instantiate_solver( - model._predict_and_compute_loss - )[2] + model.regularizer = self.cls(mask=mask) + model.solver_name = "ProximalGradient" + + runner = model.instantiate_solver()[2] params, _ = runner((true_params[0] * 0.0, true_params[1]), X, y) zeros_est = params[0] == 0 @@ -924,7 +888,7 @@ def test_mask_validity_groups_set_params( mask = np.zeros((2, X.shape[1])) mask[0, :2] = 1 mask[1, 2:] = 1 - regularizer = self.cls("ProximalGradient", mask) + regularizer = self.cls(mask=mask) # change assignment if n_groups_assign == 0: @@ -954,7 +918,7 @@ def test_mask_validity_entries_set_params( mask = np.zeros((2, X.shape[1])) mask[0, :2] = 1 mask[1, 2:] = 1 - regularizer = self.cls("ProximalGradient", mask) + regularizer = self.cls(mask=mask) # assign an entry mask[1, 2] = set_entry @@ -976,7 +940,7 @@ def test_mask_dimension(self, n_dim, poissonGLM_model_instantiation): valid_mask = np.zeros((2, X.shape[1])) valid_mask[0, :1] = 1 valid_mask[1, 1:] = 1 - regularizer = self.cls("ProximalGradient", valid_mask) + regularizer = self.cls(mask=valid_mask) # create a mask if n_dim == 0: @@ -1008,7 +972,7 @@ def test_mask_n_groups_set_params(self, n_groups, poissonGLM_model_instantiation valid_mask = np.zeros((2, X.shape[1])) valid_mask[0, :1] = 1 valid_mask[1, 1:] = 1 - regularizer = self.cls("ProximalGradient", valid_mask) + regularizer = self.cls(mask=valid_mask) # create a mask mask = np.zeros((n_groups, X.shape[1])) @@ -1024,25 +988,3 @@ def test_mask_n_groups_set_params(self, n_groups, poissonGLM_model_instantiation regularizer.set_params(mask=mask) else: regularizer.set_params(mask=mask) - - @pytest.mark.parametrize("solver_name", ["ProximalGradient"]) - @pytest.mark.parametrize( - "kwargs, expectation", - [ - ({}, does_not_raise()), - ( - {"prox": 0}, - pytest.warns( - UserWarning, match=r"Overwritten the user-defined proximal operator" - ), - ), - ], - ) - def test_overwritten_proximal_operator( - self, solver_name, kwargs, expectation, poissonGLM_model_instantiation - ): - X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - model.regularizer = nmo.regularizer.Lasso() - with expectation: - model.regularizer.solver_kwargs = kwargs - model.fit(X, y) diff --git a/tests/test_vallidation.py b/tests/test_validation.py similarity index 100% rename from tests/test_vallidation.py rename to tests/test_validation.py From 4e0dcd2649ca4b40a358a03aa2d004df5ae8a33d Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Tue, 16 Jul 2024 17:15:12 -0400 Subject: [PATCH 041/225] Rename Basis._kernel to kernel_ to follow scikit-learn's convention "Attributes that have been estimated from the data must always have a name ending with trailing underscore" https://scikit-learn.org/stable/developers/develop.html#estimated-attributes --- src/nemos/basis.py | 15 +++++++------- tests/test_basis.py | 50 ++++++++++++++++++++++----------------------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index b784c7e8..0eafa7c0 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -30,6 +30,7 @@ "OrthExponentialBasis", "AdditiveBasis", "MultiplicativeBasis", + # "TransformerBasis", ] @@ -245,7 +246,7 @@ def __sklearn_clone__(self) -> TransformerBasis: For more info: https://scikit-learn.org/stable/developers/develop.html#cloning """ cloned_obj = TransformerBasis(copy.deepcopy(self._basis)) - cloned_obj._basis._kernel = None + cloned_obj._basis.kernel_ = None return cloned_obj def set_params(self, **parameters) -> TransformerBasis: @@ -371,7 +372,7 @@ def __init__( self._window_size = window_size self._mode = mode - self._kernel = None + self.kernel_ = None self._identifiability_constraints = False @property @@ -468,7 +469,7 @@ def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: ValueError: If an invalid mode is specified or necessary parameters for the chosen mode are missing. """ - # check if self._kernel is not None for mode="conv" + # check if self.kernel_ is not None for mode="conv" self._check_has_kernel() if self.mode == "eval": # evaluate at the sample return self.__call__(*xi) @@ -480,7 +481,7 @@ def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: # convolve called at the end of any recursive call # this ensures that len(xi) == 1. conv = create_convolutional_predictor( - self._kernel, *xi, **self._conv_kwargs + self.kernel_, *xi, **self._conv_kwargs ) # move the time axis to the first dimension new_axis = (np.arange(conv.ndim) + axis) % conv.ndim @@ -517,7 +518,7 @@ def compute_features(self, *xi: ArrayLike) -> FeatureMatrix: Subclasses should implement how to handle the transformation specific to their basis function types and operation modes. """ - if self._kernel is None: + if self.kernel_ is None: self._set_kernel(*xi) return self._compute_features(*xi) @@ -553,7 +554,7 @@ def _set_kernel(self, *xi: ArrayLike) -> Basis: mode exclusively, this method should simply return `self` without modification. """ if self.mode == "conv": - self._kernel = self.__call__(np.linspace(0, 1, self.window_size)) + self.kernel_ = self.__call__(np.linspace(0, 1, self.window_size)) return self @abc.abstractmethod @@ -641,7 +642,7 @@ def _check_transform_input( def _check_has_kernel(self) -> None: """Check that the kernel is pre-computed.""" - if self.mode == "conv" and self._kernel is None: + if self.mode == "conv" and self.kernel_ is None: raise ValueError( "You must call `_set_kernel` before `_compute_features` when mode =`conv`." ) diff --git a/tests/test_basis.py b/tests/test_basis.py index 2be6d9f2..a8ac74b5 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -424,12 +424,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel.shape == (3, 5) + assert bas.kernel_.shape == (3, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=3) @@ -497,7 +497,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -820,12 +820,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel.shape == (3, 5) + assert bas.kernel_.shape == (3, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=3) @@ -893,7 +893,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -1200,12 +1200,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel.shape == (3, 5) + assert bas.kernel_.shape == (3, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=3) @@ -1273,7 +1273,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -1658,12 +1658,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) bas._set_kernel(None) - assert bas._kernel.shape == (10, 5) + assert bas.kernel_.shape == (10, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) @@ -1732,7 +1732,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -2060,12 +2060,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel.shape == (3, 5) + assert bas.kernel_.shape == (3, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=3) @@ -2133,7 +2133,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -2479,12 +2479,12 @@ def test_call_sample_range(self, mn, mx, expectation, mode, window_size): def test_fit_kernel(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel is not None + assert bas.kernel_ is not None def test_fit_kernel_shape(self): bas = self.cls(5, mode="conv", window_size=3) bas._set_kernel(None) - assert bas._kernel.shape == (3, 5) + assert bas.kernel_.shape == (3, 5) def test_transform_fails(self): bas = self.cls(5, mode="conv", window_size=3) @@ -2552,7 +2552,7 @@ def test_convolution_is_performed(self): bas = self.cls(5, mode="conv", window_size=10) x = np.random.normal(size=100) conv = bas.compute_features(x) - conv_2 = convolve.create_convolutional_predictor(bas._kernel, x) + conv_2 = convolve.create_convolutional_predictor(bas.kernel_, x) valid = ~np.isnan(conv) assert np.all(conv[valid] == conv_2[valid]) assert np.all(np.isnan(conv_2[~valid])) @@ -3202,7 +3202,7 @@ def check_kernel(basis_obj): has_kern += check_kernel(basis_obj._basis2) else: has_kern += [ - basis_obj._kernel is not None if basis_obj.mode == "conv" else True + basis_obj.kernel_ is not None if basis_obj.mode == "conv" else True ] return has_kern @@ -3829,7 +3829,7 @@ def check_kernel(basis_obj): has_kern += check_kernel(basis_obj._basis2) else: has_kern += [ - basis_obj._kernel is not None if basis_obj.mode == "conv" else True + basis_obj.kernel_ is not None if basis_obj.mode == "conv" else True ] return has_kern @@ -4053,15 +4053,15 @@ def test_transformerbasis_sk_clone_kernel_noned(basis_cls): # kernel should be saved in the object after fit trans_bas.fit(np.random.randn(100, 20)) - assert isinstance(trans_bas._kernel, np.ndarray) + assert isinstance(trans_bas.kernel_, np.ndarray) - # cloning should set _kernel to None + # cloning should set kernel_ to None trans_bas_clone = sk_clone(trans_bas) - # the original object should still have _kernel - assert isinstance(trans_bas._kernel, np.ndarray) + # the original object should still have kernel_ + assert isinstance(trans_bas.kernel_, np.ndarray) # but the clone should not have one - assert trans_bas_clone._kernel is None + assert trans_bas_clone.kernel_ is None @pytest.mark.parametrize( From 86b1c4d35655d402f4cba9d02ab9db9f3db976c5 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 16 Jul 2024 17:18:45 -0400 Subject: [PATCH 042/225] remove switch case to support python3.9 --- src/nemos/_regularizer_builder.py | 41 +++++++++++++++---------------- src/nemos/base_regressor.py | 21 ++++++++-------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 9f2fc42d..7b2848ce 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -22,24 +22,23 @@ def create_regularizer(name: str): ValueError If the `name` provided does not match to any available regularizer. """ - match name: - case "unregularized": - from .regularizer import UnRegularized - - return UnRegularized() - case "ridge": - from .regularizer import Ridge - - return Ridge() - case "lasso": - from .regularizer import Lasso - - return Lasso() - case "group_lasso": - from .regularizer import GroupLasso - - return GroupLasso() - - raise ValueError( - f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}" - ) + if name == "unregularized": + from .regularizer import UnRegularized + + return UnRegularized() + elif name == "ridge": + from .regularizer import Ridge + + return Ridge() + elif name == "lasso": + from .regularizer import Lasso + + return Lasso() + elif name == "group_lasso": + from .regularizer import GroupLasso + + return GroupLasso() + else: + raise ValueError( + f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}" + ) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index e52765e3..c7298497 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -207,17 +207,16 @@ def instantiate_solver( utils.assert_is_callable(loss, "loss") # some parsing to make sure solver gets instantiated properly - match self.solver_name: - case "ProximalGradient": - self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) - # add self.regularizer_strength to args - args += (self.regularizer.regularizer_strength,) - case "LBFGSB": - if "bounds" not in kwargs.keys(): - warnings.warn( - "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." - ) - self.solver_name = "LBFGS" + if self.solver_name == "ProximalGradient": + self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) + # add self.regularizer_strength to args + args += (self.regularizer.regularizer_strength,) + if self.solver_name == "LBFGSB": + if "bounds" not in kwargs.keys(): + warnings.warn( + "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." + ) + self.solver_name = "LBFGS" # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) From 756222e80f7fdd1bd72eb85d8b8b0ed8ddb3ed12 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 16 Jul 2024 17:29:33 -0400 Subject: [PATCH 043/225] python3.9 must use Union for type checking --- src/nemos/base_regressor.py | 4 ++-- src/nemos/regularizer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index c7298497..924b8151 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -62,7 +62,7 @@ class BaseRegressor(Base, abc.ABC): def __init__( self, - regularizer: str | Regularizer = "unregularized", + regularizer: Union[str, Regularizer] = "unregularized", solver_name: str = None, solver_kwargs: Optional[dict] = None, ): @@ -84,7 +84,7 @@ def regularizer(self) -> Union[None, Regularizer]: return self._regularizer @regularizer.setter - def regularizer(self, regularizer: str | Regularizer): + def regularizer(self, regularizer: Union[str, Regularizer]): """Setter for the regularizer attribute.""" # instantiate regularizer if str if isinstance(regularizer, str): diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index e0fa473a..2add41d9 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -416,7 +416,7 @@ def mask(self): return self._mask @mask.setter - def mask(self, mask: jnp.ndarray | None): + def mask(self, mask: Union[jnp.ndarray, None]): """Setter for the mask attribute.""" # check mask if passed by user, else will be initialized later if mask is not None: From 9be4ad3f3c51b86a95d886ff6f0eddf31cebc367 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 16 Jul 2024 18:36:59 -0400 Subject: [PATCH 044/225] fixed tests --- src/nemos/basis.py | 66 ++++++++++++++++++++++++++++++++------------- tests/test_basis.py | 31 ++++++--------------- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index e87b4be7..73c9e833 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -4,7 +4,6 @@ from __future__ import annotations import abc -import warnings from typing import Callable, Generator, Literal, Optional, Tuple, Union import numpy as np @@ -60,7 +59,7 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): def min_max_rescale_samples( sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None -) -> NDArray: +) -> Tuple[NDArray, float]: """Rescale samples to [0,1].""" sample_pts = sample_pts.astype(float) if vmin and vmax and vmax <= vmin: @@ -70,8 +69,11 @@ def min_max_rescale_samples( sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin if vmin != vmax: # Needed is samples contain a single value. - sample_pts /= vmax - vmin - return sample_pts + scaling = vmax - vmin + sample_pts /= scaling + else: + scaling = 1.0 + return sample_pts, scaling class TransformerBasis: @@ -991,7 +993,13 @@ def __init__( ) -> None: self.order = order super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, vmin=vmin, vmax=vmax, **kwargs + n_basis_funcs, + *args, + mode=mode, + window_size=window_size, + vmin=vmin, + vmax=vmax, + **kwargs, ) self._n_input_dimensionality = 1 if self.order < 1: @@ -1189,7 +1197,7 @@ def __call__( conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ - sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + sample_pts, scaling = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots if not passed knot_locs = self._generate_knots( sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False @@ -1202,6 +1210,8 @@ def __call__( ], axis=1, ) + # re-normalize so that it integrates to 1 over the range. + X /= scaling if self.identifiability_constraints: X = self._apply_identifiability_constraints(X) return X @@ -1359,7 +1369,7 @@ def __call__( The evaluation is performed by looping over each element and using `splev` from SciPy to compute the basis values. """ - sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) @@ -1498,7 +1508,7 @@ def __call__( SciPy to compute the basis values. """ - sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) # for cyclic, do not repeat knots @@ -1608,7 +1618,13 @@ def __init__( **kwargs, ) -> None: super().__init__( - n_basis_funcs, *args, mode=mode, window_size=window_size, vmin=vmin, vmax=vmax, **kwargs + n_basis_funcs, + *args, + mode=mode, + window_size=window_size, + vmin=vmin, + vmax=vmax, + **kwargs, ) self._n_input_dimensionality = 1 self._check_width(width) @@ -1661,8 +1677,8 @@ def __call__( rescale_samples : If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, respectively. - Values outside the [vmin, vmax] range are set to NaN. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` + are used, respectively. Values outside the [vmin, vmax] range are set to NaN. vmin: The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. This value determines the minimum of the domain of the basis. @@ -1685,7 +1701,7 @@ def __call__( # basis2 = nmo.basis.RaisedCosineBasisLog(5) # additive_basis = basis1 + basis2 # additive_basis(*([x] * 2)) would modify both inputs - sample_pts = min_max_rescale_samples( + sample_pts, _ = min_max_rescale_samples( np.copy(sample_pts), vmin=vmin, vmax=vmax ) @@ -1871,7 +1887,9 @@ def _transform_samples( """ # rescale to [0,1] # copy is necessary to avoid unwanted rescaling in additive/multiplicative basis. - sample_pts = min_max_rescale_samples(np.copy(sample_pts), vmin=vmin, vmax=vmax) + sample_pts, _ = min_max_rescale_samples( + np.copy(sample_pts), vmin=vmin, vmax=vmax + ) # This log-stretching of the sample axis has the following effect: # - as the time_scaling tends to 0, the points will be linearly spaced across the whole domain. # - as the time_scaling tends to inf, basis will be small and dense around 0 and @@ -2061,7 +2079,12 @@ def _check_sample_size(self, *sample_pts: NDArray) -> None: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None) -> FeatureMatrix: + def __call__( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ) -> FeatureMatrix: """Generate basis functions with given spacing. Parameters @@ -2078,16 +2101,23 @@ def __call__(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Opti """ self._check_sample_size(sample_pts) - sample_pts = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) valid_idx = ~np.isnan(sample_pts) # because of how scipy.linalg.orth works, have to create a matrix of # shape (n_pts, n_basis_funcs) and then transpose, rather than # directly computing orth on the matrix of shape (n_basis_funcs, # n_pts) - basis_funcs = np.full(shape=(sample_pts.shape[0], self.n_basis_funcs), fill_value=np.nan) - basis_funcs[valid_idx] = scipy.linalg.orth( - np.stack([np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1) + exp_decay_eval = np.stack( + [np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1 + ) + # count the linear independent components (could be lower than n_basis_funcs for num precision). + n_independent_component = np.linalg.matrix_rank(exp_decay_eval) + # initialize output to nan + basis_funcs = np.full( + shape=(sample_pts.shape[0], n_independent_component), fill_value=np.nan ) + # orthonormalize on valid points + basis_funcs[valid_idx] = scipy.linalg.orth(exp_decay_eval) if self.identifiability_constraints: basis_funcs = self._apply_identifiability_constraints(basis_funcs) return basis_funcs diff --git a/tests/test_basis.py b/tests/test_basis.py index 1c47d152..9c30d058 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -152,22 +152,6 @@ def test_minimum_number_of_basis_required_is_matched( else: self.cls(n_basis_funcs=n_basis_funcs, mode=mode, window_size=window_size) - @pytest.mark.parametrize( - "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] - ) - def test_samples_range_matches_evaluate_requirements(self, sample_range): - """ - Ensures that the evaluate() method correctly handles sample range inputs that are outside of its - required bounds (0, 1). - """ - raise_warn = (sample_range[0] < 0) | (sample_range[1] > 1) - basis_obj = self.cls(n_basis_funcs=5, mode="eval") - if raise_warn: - with pytest.warns(UserWarning, match="Rescaling sample points"): - basis_obj.compute_features(np.linspace(*sample_range, 100)) - else: - basis_obj.compute_features(np.linspace(*sample_range, 100)) - @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): @@ -409,7 +393,7 @@ def test_call_non_empty(self, n_basis, mode, window_size): "mn, mx, expectation", [ (0, 1, does_not_raise()), - (-2, 2, pytest.warns(UserWarning, match="Rescaling sample points")), + (-2, 2, does_not_raise()), ], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) @@ -1594,13 +1578,13 @@ def test_fit_transform_returns_expected_number_of_basis( return @pytest.mark.parametrize("sample_size", [100, 1000]) - @pytest.mark.parametrize("n_basis_funcs", [2, 10, 20]) + @pytest.mark.parametrize("n_basis_funcs", [2, 10, 12]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 30)]) def test_sample_size_of_fit_transform_matches_that_of_input( self, n_basis_funcs, sample_size, mode, window_size ): """Tests whether the sample size of the fit_transformed result matches that of the input.""" - decay_rates = np.arange(1, 1 + n_basis_funcs) + decay_rates = np.linspace(0.1, 20, n_basis_funcs) basis_obj = self.cls( n_basis_funcs=n_basis_funcs, decay_rates=decay_rates, @@ -1810,10 +1794,11 @@ def test_call_nan(self, mode, window_size): bas = self.cls( 5, mode=mode, window_size=window_size, decay_rates=np.arange(1, 6) ) - x = np.linspace(0, 1, 10) - x[3] = np.nan - with pytest.raises(ValueError, match="array must not contain infs or NaNs"): - bas(x) + x = np.linspace(0, 1, 15) + x[13] = np.nan + with does_not_raise(): + out = bas(x) + assert np.all(np.isnan(out[13])) def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) From fceea567428ad45ff55261a99efa3cf09cef3ea2 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 16 Jul 2024 22:44:34 -0400 Subject: [PATCH 045/225] run ci only for non-draft prs --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff6568b..a31d1df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: branches: - main - development + types: [opened, synchronize, reopened, ready_for_review] push: branches: - main From 0561ec48c2d205b46b0178eeeb467eb6e033f23c Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 17 Jul 2024 10:09:55 -0400 Subject: [PATCH 046/225] increase test coverage --- src/nemos/_regularizer_builder.py | 3 ++- tests/test_regularizer.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 7b2848ce..59654a77 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -40,5 +40,6 @@ def create_regularizer(name: str): return GroupLasso() else: raise ValueError( - f"Unknown regularizer: {name}. Regularizer must be one of {AVAILABLE_REGULARIZERS}" + f"Unknown regularizer: {name}. " + f"Regularizer must be one of {AVAILABLE_REGULARIZERS}" ) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index f7ceee83..ccfb0920 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -10,6 +10,34 @@ from sklearn.linear_model import GammaRegressor, PoissonRegressor import nemos as nmo +from nemos.regularizer import Regularizer + + +@pytest.mark.parametrize( + "reg_str, reg_type", + [ + ("unregularized", nmo.regularizer.Regularizer), + ("ridge", nmo.regularizer.Ridge), + ("lasso", nmo.regularizer.Lasso), + ("group_lasso", nmo.regularizer.GroupLasso), + ("not_valid", None), + ], +) +def test_regularizer_builder(reg_str, reg_type): + """Test building a regularizer from a string""" + raise_exception = reg_str not in nmo._regularizer_builder.AVAILABLE_REGULARIZERS + if raise_exception: + with pytest.raises(ValueError, match=f"Unknown regularizer: {reg_str}. "): + nmo._regularizer_builder.create_regularizer(reg_str) + else: + # build a regularizer by string + regularizer = nmo._regularizer_builder.create_regularizer(reg_str) + # assert correct type of regularizer is instantiated + assert isinstance(regularizer, reg_type) + # create a regularizer of that type + regularizer2 = reg_type() + # assert that they have the same attributes + assert regularizer.__dict__ == regularizer2.__dict__ @pytest.mark.parametrize( From 64b2019431ea838563e97f2dbfaf7c8c8115ef62 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 17 Jul 2024 10:13:52 -0400 Subject: [PATCH 047/225] improved dof calc --- src/nemos/glm.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index a604816b..caa0f3d7 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -809,9 +809,6 @@ def estimate_resid_degrees_of_freedom( "instead!" ) - if isinstance(self.observation_model, obs.PoissonObservations): - return 1.0 # scale is fix, residual not used - # if the regularizer is lasso use the non-zero # coeff as an estimate of the dof # see https://arxiv.org/abs/0712.0881 From f886eb10ca997d785596fcf3f1b57f355cb9ffe1 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Wed, 17 Jul 2024 10:23:51 -0400 Subject: [PATCH 048/225] Have Basis.to_transformer make a copy and update docs --- docs/api_guide/_plotting_helpers.py | 178 ---------- .../plot_05_sklearn_pipeline_cv_demo.py | 188 ---------- .../plot_06_sklearn_pipeline_cv_demo.py | 336 ++++++++++++++++++ pyproject.toml | 3 +- src/nemos/basis.py | 2 +- 5 files changed, 339 insertions(+), 368 deletions(-) delete mode 100644 docs/api_guide/_plotting_helpers.py delete mode 100644 docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py create mode 100644 docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py diff --git a/docs/api_guide/_plotting_helpers.py b/docs/api_guide/_plotting_helpers.py deleted file mode 100644 index 87a6fc93..00000000 --- a/docs/api_guide/_plotting_helpers.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Helper functions to not have to add seaborn as a dependency just for the annotated heatmap and despine - -Adapted from: https://matplotlib.org/stable/gallery/images_contours_and_fields/image_annotated_heatmap.html#using-the-helper-function-code-style -""" - -import matplotlib -import matplotlib.pyplot as plt -import numpy as np - - -def heatmap( - data, - row_labels, - col_labels, - ax=None, - colorbar: bool = False, - cbar_kw=None, - cbarlabel="", - **kwargs, -): - """ - Create a heatmap from a numpy array and two lists of labels. - - Parameters - ---------- - data - A 2D numpy array of shape (M, N). - row_labels - A list or array of length M with the labels for the rows. - col_labels - A list or array of length N with the labels for the columns. - ax - A `matplotlib.axes.Axes` instance to which the heatmap is plotted. If - not provided, use current Axes or create a new one. Optional. - cbar_kw - A dictionary with arguments to `matplotlib.Figure.colorbar`. Optional. - cbarlabel - The label for the colorbar. Optional. - **kwargs - All other arguments are forwarded to `imshow`. - """ - - if ax is None: - ax = plt.gca() - - if cbar_kw is None: - cbar_kw = {} - - # Plot the heatmap - im = ax.imshow(data, **kwargs) - - # Create colorbar - if colorbar: - cbar = ax.figure.colorbar(im, ax=ax, **cbar_kw) - cbar.ax.set_ylabel(cbarlabel, rotation=-90, va="bottom") - - # Show all ticks and label them with the respective list entries. - ax.set_xticks(np.arange(data.shape[1]), labels=col_labels) - ax.set_yticks(np.arange(data.shape[0]), labels=row_labels) - - # Let the horizontal axes labeling appear on top. - ax.tick_params(top=True, bottom=False, labeltop=True, labelbottom=False) - - # Rotate the tick labels and set their alignment. - plt.setp(ax.get_xticklabels(), rotation=-30, ha="right", rotation_mode="anchor") - - # Turn spines off and create white grid. - ax.spines[:].set_visible(False) - - ax.set_xticks(np.arange(data.shape[1] + 1) - 0.5, minor=True) - ax.set_yticks(np.arange(data.shape[0] + 1) - 0.5, minor=True) - ax.grid(which="minor", color="w", linestyle="-", linewidth=3) - ax.tick_params(which="minor", bottom=False, left=False) - - return im - - -def annotate_heatmap( - im, - data=None, - valfmt="{x:.2f}", - textcolors=("white", "black"), - threshold=None, - **textkw, -): - """ - A function to annotate a heatmap. - - Parameters - ---------- - im - The AxesImage to be labeled. - data - Data used to annotate. If None, the image's data is used. Optional. - valfmt - The format of the annotations inside the heatmap. This should either - use the string format method, e.g. "$ {x:.2f}", or be a - `matplotlib.ticker.Formatter`. Optional. - textcolors - A pair of colors. The first is used for values below a threshold, - the second for those above. Optional. - threshold - Value in data units according to which the colors from textcolors are - applied. If None (the default) uses the middle of the colormap as - separation. Optional. - **kwargs - All other arguments are forwarded to each call to `text` used to create - the text labels. - """ - - if not isinstance(data, (list, np.ndarray)): - data = im.get_array() - - # Normalize the threshold to the images color range. - if threshold is not None: - threshold = im.norm(threshold) - else: - threshold = im.norm(data.max()) / 2.0 - - # Set default alignment to center, but allow it to be - # overwritten by textkw. - kw = dict(horizontalalignment="center", verticalalignment="center") - kw.update(textkw) - - # Get the formatter in case a string is supplied - if isinstance(valfmt, str): - valfmt = matplotlib.ticker.StrMethodFormatter(valfmt) - - # Loop over the data and create a `Text` for each "pixel". - # Change the text's color depending on the data. - texts = [] - for i in range(data.shape[0]): - for j in range(data.shape[1]): - kw.update(color=textcolors[int(im.norm(data[i, j]) > threshold)]) - text = im.axes.text(j, i, valfmt(data[i, j], None), **kw) - texts.append(text) - - return texts - - -def despine(ax=None, top=True, right=True, left=False, bottom=False): - """ - Remove the specified spines from the plot. - - Parameters: - ax (matplotlib.axes.Axes): The axes to despine. Defaults to current axes. - top (bool): Whether to remove the top spine. - right (bool): Whether to remove the right spine. - left (bool): Whether to remove the left spine. - bottom (bool): Whether to remove the bottom spine. - """ - if ax is None: - ax = plt.gca() - - if top: - ax.spines["top"].set_visible(False) - if right: - ax.spines["right"].set_visible(False) - if left: - ax.spines["left"].set_visible(False) - if bottom: - ax.spines["bottom"].set_visible(False) - - # Optionally, adjust ticks to be displayed only on the remaining spines - if top and bottom: - ax.xaxis.set_ticks_position("none") - elif top: - ax.xaxis.set_ticks_position("bottom") - elif bottom: - ax.xaxis.set_ticks_position("top") - - if right and left: - ax.yaxis.set_ticks_position("none") - elif right: - ax.yaxis.set_ticks_position("left") - elif left: - ax.yaxis.set_ticks_position("right") diff --git a/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py deleted file mode 100644 index 37cb5dab..00000000 --- a/docs/api_guide/plot_05_sklearn_pipeline_cv_demo.py +++ /dev/null @@ -1,188 +0,0 @@ -""" -# Pipelining and cross-validation with scikit-learn - -In this demo we will show how to combine basis transformations and GLMs using scikit-learn's pipelines, and demonstrate how cross-validation can be used to fine-tune parameters of each step of the pipeline. -""" - -# %% -# ## Let's start by some imports and creating toy data -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -# %% -from _plotting_helpers import annotate_heatmap, despine, heatmap -from sklearn.model_selection import GridSearchCV -from sklearn.pipeline import Pipeline - -# %% -import nemos as nmo - -# %% - -# predictors, shape (n_samples, n_features) -X = np.random.uniform(low=0, high=1, size=(1000, 1)) -# true coefficients, shape (n_features) -coef = np.array([5]) -# observed counts, shape (n_samples, ) -y = np.random.poisson(np.exp(np.matmul(X, coef))).astype(float) - -# %% -fig, ax = plt.subplots() -ax.scatter(X.flatten(), y, alpha=0.2) -ax.set_xlabel("input") -ax.set_ylabel("spike count") -despine(ax=ax) - -# %% -# ## Combining basis transformations and GLM in a pipeline - -# %% -# ### `TransformerBasis` class -# The `TransformerBasis` wrapper class provides an API consistent with [scikit-learn's data transforms](https://scikit-learn.org/stable/data_transforms.html), allowing its use in pipelines as a transformation step. -# -# Instantiating a `TransformerBasis` can be done using the constructor directly or with `Basis.to_transformer()` and provides convenient access to the underlying `Basis` object's attributes: - -# %% -bas = nmo.basis.RaisedCosineBasisLinear(5) -trans_bas_a = nmo.basis.TransformerBasis(bas) -trans_bas_b = bas.to_transformer() - -print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) - -# %% -# ### Creating and fitting a pipeline -# Use this wrapper class to create a pipeline that first transforms the data, then fits a GLM on the transformed data: - -# %% -pipeline = Pipeline( - [ - ( - "transformerbasis", - nmo.basis.TransformerBasis(nmo.basis.RaisedCosineBasisLinear(20)), - ), - ( - "glm", - nmo.glm.GLM(regularizer=nmo.regularizer.Ridge(regularizer_strength=1.0)), - ), - ] -) - -pipeline.fit(X, y) - -# %% -# ### Visualize the fit: - -# %% -fig, ax = plt.subplots() - -ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") -ax.set_xlabel("input") -ax.set_ylabel("spike count") - -x = np.sort(X.flatten()) -ax.plot( - x, - pipeline.predict(np.expand_dims(x, 1)), - label="predicted rate", - color="tab:orange", -) - -ax.legend() -despine(ax=ax) - -# %% -# There is some room for improvement, so in the next section we'll use cross-validation to tune the parameters of our pipeline. - -# %% -# ## Hyperparameter-tuning with scikit-learn's gridsearch - -# %% -# !!! warning -# Please keep in mind that while `GLM.score` supports different ways of evaluating goodness-of-fit through the `score_type` argument, `pipeline.score(X, y, score_type="...")` does not propagate this, and uses the default value of `log-likelihood`. -# -# To evaluate a pipeline, please create a custom scorer (e.g. `pseudo_r2` below) and call `my_custom_scorer(pipeline, X, y)`. - -# %% -# ### Define parameter grid -# Let's define candidate values for the parameters of each step of the pipeline we want to cross-validate. In this case the number of basis functions in the transformation step and the ridge regularization's strength in the GLM fit: - -# %% -param_grid = dict( - glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), - transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), -) - -# %% -# ### Create custom scorer -# Create a custom scorer to evaluate models using the pseudo-R2 score instead of the log-likelihood which is the default in `GLM.score`: - -# %% -from sklearn.metrics import make_scorer - -# NOTE: the order of the arguments is reversed -pseudo_r2 = make_scorer( - lambda y_true, y_pred: nmo.observation_models.PoissonObservations().pseudo_r2( - y_pred, y_true - ) -) - -# %% -# ### Run the grid search: - -# %% -gridsearch = GridSearchCV(pipeline, param_grid=param_grid, cv=5, scoring=pseudo_r2) - -gridsearch.fit(X, y) - -# %% -# ### Visualize the scores -# Let's take a look at the scores to see how the different parameter values influence the test score: - -# %% -cvdf = pd.DataFrame(gridsearch.cv_results_) - - -fig, ax = plt.subplots() - -im = heatmap( - cvdf.pivot( - index="param_transformerbasis__n_basis_funcs", - columns="param_glm__regularizer__regularizer_strength", - values="mean_test_score", - ), - param_grid["transformerbasis__n_basis_funcs"], - param_grid["glm__regularizer__regularizer_strength"][ - ::-1 - ], # note the reverse order - ax=ax, -) -texts = annotate_heatmap(im, valfmt="{x:.3f}") -ax.set_xlabel("ridge regularization strength") -ax.set_ylabel("number of basis functions") - -fig.tight_layout() - -# %% -# ### Visualize the improved predictions -# Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: - -# %% -fig, ax = plt.subplots() - -ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") -ax.set_xlabel("input") -ax.set_ylabel("spike count") - -x = np.sort(X.flatten()) -ax.plot( - x, - gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), - label="predicted rate", - color="tab:orange", -) - -ax.legend() -despine(ax=ax) - -# %% diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py new file mode 100644 index 00000000..d6f5d6e4 --- /dev/null +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -0,0 +1,336 @@ +""" +# Pipelining and cross-validation with scikit-learn + +In this demo we will show how to combine basis transformations and GLMs using scikit-learn's pipelines, and demonstrate how cross-validation can be used to fine-tune parameters of each step of the pipeline. +""" + +# %% [markdown] +# ## Let's start by creating some toy data + +# %% +import nemos as nmo +import numpy as np +import pandas as pd +import scipy.stats +import matplotlib.pyplot as plt +import seaborn as sns + +from sklearn.pipeline import Pipeline +from sklearn.model_selection import GridSearchCV + +# %% + +# predictors, shape (n_samples, n_features) +X = np.random.uniform(low=0, high=1, size=(1000, 1)) +# observed counts, shape (n_samples, ) +rate = 2 * ( + scipy.stats.norm.pdf(X, scale=0.1, loc=0.25) + + scipy.stats.norm.pdf(X, scale=0.1, loc=0.75) +) +y = np.random.poisson(rate).astype(float).flatten() + +# %% +fig, ax = plt.subplots() +ax.scatter(X.flatten(), y, alpha=0.2) +ax.set_xlabel("input") +ax.set_ylabel("spike count") +sns.despine(ax=ax) + +# %% [markdown] +# ## Combining basis transformations and GLM in a pipeline + +# %% [markdown] +# The `TransformerBasis` wrapper class provides an API consistent with [scikit-learn's data transforms](https://scikit-learn.org/stable/data_transforms.html), allowing its use in pipelines as a transformation step. +# +# Instantiating a `TransformerBasis` can be done using the constructor directly or with `Basis.to_transformer()`: + +# %% +bas = nmo.basis.RaisedCosineBasisLinear(5, mode="conv", window_size=5) +trans_bas_a = nmo.basis.TransformerBasis(bas) +trans_bas_b = bas.to_transformer() + +# %% [markdown] +# `TransformerBasis` provides convenient access to the underlying `Basis` object's attributes: + +# %% +print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) + +# %% [markdown] +# We can also set attributes of the underlying `Basis`. Note that -- because `TransformerBasis` is created with a copy of the `Basis` object passed to it -- this does not change the original `Basis`, and neither does changing the original `Basis` change `TransformerBasis` we created: + +# %% +trans_bas_a.n_basis_funcs = 10 +bas.n_basis_funcs = 100 + +print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) + +# %% [markdown] +# ### Creating and fitting a pipeline +# We might want to combine first transforming the input data with our basis functions, then fitting a GLM on the transformed data. +# +# This is exactly what [scikit-learn's Pipeline](https://scikit-learn.org/stable/modules/compose.html#pipeline) is for! + +# %% +pipeline = Pipeline( + [ + ( + "transformerbasis", + nmo.basis.TransformerBasis(nmo.basis.RaisedCosineBasisLinear(6)), + ), + ( + "glm", + nmo.glm.GLM(regularizer=nmo.regularizer.Ridge(regularizer_strength=0.5)), + ), + ] +) + +pipeline.fit(X, y) + +# %% [markdown] +# Visualize the fit: + +# %% +fig, ax = plt.subplots() + +ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") +ax.set_xlabel("input") +ax.set_ylabel("spike count") + +x = np.sort(X.flatten()) +ax.plot( + x, + pipeline.predict(np.expand_dims(x, 1)), + label="predicted rate", + color="tab:orange", +) + +ax.legend() +sns.despine(ax=ax) + +# %% [markdown] +# There is some room for improvement, so in the next section we'll use cross-validation to tune the parameters of our pipeline. + +# %% [markdown] +# ## Hyperparameter-tuning with scikit-learn's gridsearch + +# %% [markdown] +# !!! warning +# Please keep in mind that while `GLM.score` supports different ways of evaluating goodness-of-fit through the `score_type` argument, `pipeline.score(X, y, score_type="...")` does not propagate this, and uses the default value of `log-likelihood`. +# +# To evaluate a pipeline, please create a custom scorer (e.g. `pseudo_r2` below) and call `my_custom_scorer(pipeline, X, y)`. + +# %% [markdown] +# ### Evaluating different values of the number of basis functions + +# %% [markdown] +# #### Define the parameter grid +# +# Let's define candidate values for the parameters of each step of the pipeline we want to cross-validate. In this case the number of basis functions in the transformation step and the ridge regularization's strength in the GLM fit: + +# %% +param_grid = dict( + glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), +) + +# %% [markdown] +# #### Create a custom scorer +# Create a custom scorer to evaluate models using the pseudo-R2 score instead of the log-likelihood which is the default in `GLM.score`: + +# %% +from sklearn.metrics import make_scorer + +# NOTE: the order of the arguments is reversed +pseudo_r2 = make_scorer( + lambda y_true, y_pred: nmo.observation_models.PoissonObservations().pseudo_r2( + y_pred, y_true + ) +) + +# %% [markdown] +# #### Run the grid search: + +# %% +gridsearch = GridSearchCV( + pipeline, + param_grid=param_grid, + cv=5, + scoring=pseudo_r2, +) + +# run the 5-fold cross-validation grid search +gridsearch.fit(X, y) + +# %% [markdown] +# #### Visualize the scores +# +# Let's extract the scores from `gridsearch` and take a look at how the different parameter values of our pipeline influence the test score: + +# %% +from matplotlib.patches import Rectangle + + +def highlight_max_cell(cvdf_wide, ax): + max_col = cvdf_wide.max().idxmax() + max_col_index = cvdf_wide.columns.get_loc(max_col) + max_row = cvdf_wide[max_col].idxmax() + max_row_index = cvdf_wide.index.get_loc(max_row) + + ax.add_patch( + Rectangle( + (max_col_index, max_row_index), 1, 1, fill=False, lw=3, color="skyblue" + ) + ) + + +# %% +cvdf = pd.DataFrame(gridsearch.cv_results_) + +cvdf_wide = cvdf.pivot( + index="param_transformerbasis__n_basis_funcs", + columns="param_glm__regularizer__regularizer_strength", + values="mean_test_score", +) + +ax = sns.heatmap( + cvdf_wide, + annot=True, + square=True, + linecolor="white", + linewidth=0.5, +) + +ax.set_xlabel("ridge regularization strength") +ax.set_ylabel("number of basis functions") + +highlight_max_cell(cvdf_wide, ax) + +# %% [markdown] +# #### Evaluating different values of the number of basis functions +# Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: + +# %% +fig, ax = plt.subplots() + +ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") +ax.set_xlabel("input") +ax.set_ylabel("spike count") + +x = np.sort(X.flatten()) +ax.plot( + x, + gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), + label="predicted rate", + color="tab:orange", +) + +ax.legend() +sns.despine(ax=ax) + +# %% [markdown] +# ### Evaluating different bases directly + +# %% [markdown] +# In the previous example we set the number of basis functions of the `Basis` wrapped in our `TransformerBasis`. However, if we are for example not sure about the type of basis functions we want to use, or we have already defined some basis functions of our own, then we can use cross-validation to directly evaluate those as well. +# +# Here we include `transformerbasis___basis` in the parameter grid to try different values for `TransformerBasis._basis`: + +# %% +param_grid = dict( + glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + transformerbasis___basis=( + nmo.basis.RaisedCosineBasisLinear(5), + nmo.basis.RaisedCosineBasisLinear(10), + nmo.basis.RaisedCosineBasisLog(5), + nmo.basis.RaisedCosineBasisLog(10), + nmo.basis.MSplineBasis(5), + nmo.basis.MSplineBasis(10), + ), +) + +# %% [markdown] +# Then run the grid search: + +# %% +gridsearch = GridSearchCV( + pipeline, + param_grid=param_grid, + cv=5, + scoring=pseudo_r2, +) + +# run the 5-fold cross-validation grid search +gridsearch.fit(X, y) + +# %% [markdown] +# Wrangling the output data a bit and looking at the scores: + +# %% +cvdf = pd.DataFrame(gridsearch.cv_results_) + +# read out the number of +cvdf["transformerbasis_config"] = [ + f"{b.__class__.__name__} - {b.n_basis_funcs}" + for b in cvdf["param_transformerbasis___basis"] +] + +cvdf_wide = cvdf.pivot( + index="transformerbasis_config", + columns="param_glm__regularizer__regularizer_strength", + values="mean_test_score", +) + +ax = sns.heatmap( + cvdf_wide, + annot=True, + square=True, + linecolor="white", + linewidth=0.5, +) + +ax.set_xlabel("ridge regularization strength") +ax.set_ylabel("number of basis functions") + +highlight_max_cell(cvdf_wide, ax) + +# %% [markdown] +# Looks like `RaisedCosineBasisLinear` was probably a decent choice for our toy data: + +# %% +fig, ax = plt.subplots() + +ax.scatter(X.flatten(), y, alpha=0.2, label="generated spike counts") +ax.set_xlabel("input") +ax.set_ylabel("spike count") + +x = np.sort(X.flatten()) +ax.plot( + x, + gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), + label="predicted rate", + color="tab:orange", +) + +ax.legend() +sns.despine(ax=ax) + +# %% [markdown] +# !!! warning +# Please note that because it would lead to unexpected behavior, mixing the two ways of defining values for the parameter grid is not allowed. The following would lead to an error: +# +# ```python +# param_grid = dict( +# glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), +# transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), +# transformerbasis___basis=( +# nmo.basis.RaisedCosineBasisLinear(5), +# nmo.basis.RaisedCosineBasisLinear(10), +# nmo.basis.RaisedCosineBasisLog(5), +# nmo.basis.RaisedCosineBasisLog(10), +# nmo.basis.MSplineBasis(5), +# nmo.basis.MSplineBasis(10), +# ), +# ) +# ``` + +# %% diff --git a/pyproject.toml b/pyproject.toml index bae2612a..dd205c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,8 @@ docs = [ "scikit-learn", "dandi", "ipython", - "matplotlib>=3.7" + "matplotlib>=3.7", + "seaborn", ] diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 0eafa7c0..2390d8b0 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -830,7 +830,7 @@ def to_transformer(self) -> TransformerBasis: """ Turn the Basis into a TransformerBasis for use with scikit-learn. """ - return TransformerBasis(self) + return TransformerBasis(copy.deepcopy(self)) class AdditiveBasis(Basis): From be9fe56404a9049b81051236bb8e92c344028f29 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Wed, 17 Jul 2024 10:35:36 -0400 Subject: [PATCH 049/225] Test that copying in Basis.to_transformer works --- tests/test_basis.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index a8ac74b5..252b7807 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -10,8 +10,8 @@ import sklearn.pipeline as pipeline import statsmodels.api as sm import utils_testing -from sklearn.model_selection import GridSearchCV from sklearn.base import clone as sk_clone +from sklearn.model_selection import GridSearchCV import nemos.basis as basis import nemos.convolve as convolve @@ -3920,6 +3920,30 @@ def test_basis_to_transformer(basis_cls): assert isinstance(bas.to_transformer(), basis.TransformerBasis) assert bas.to_transformer().n_basis_funcs == bas.n_basis_funcs +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_basis_to_transformer_makes_a_copy(basis_cls): + bas_a = basis_cls(5) + trans_bas_a = bas_a.to_transformer() + + # changing an attribute in bas should not change trans_bas + bas_a.n_basis_funcs = 10 + assert trans_bas_a.n_basis_funcs == 5 + + # changing an attribute in the transformerbasis should not change the original + bas_b = basis_cls(5) + trans_bas_b = bas_b.to_transformer() + trans_bas_b.n_basis_funcs = 100 + assert bas_b.n_basis_funcs == 5 + @pytest.mark.parametrize( "basis_cls", From 16e82893df5181dd4673724af21ddab8f23aa2ff Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 17 Jul 2024 11:18:18 -0400 Subject: [PATCH 050/225] fix bug --- src/nemos/glm.py | 3 +++ tests/test_glm.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index caa0f3d7..11e19d1c 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -814,6 +814,9 @@ def estimate_resid_degrees_of_freedom( # see https://arxiv.org/abs/0712.0881 if isinstance(self.regularizer, (GroupLasso, Lasso)): coef, _ = self._get_coef_and_intercept() + if isinstance(coef, dict): + resid_dof = jax.tree.map(lambda x: n_samples - jnp.sum(jnp.isclose(x, jnp.zeros_like(x))), coef) + return resid_dof return n_samples - jnp.sum(jnp.isclose(coef, jnp.zeros_like(coef))) elif isinstance(self.regularizer, Ridge): # for Ridge, use the tot parameters (X.shape[1] + intercept) diff --git a/tests/test_glm.py b/tests/test_glm.py index 1bc30236..aba4db80 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1480,6 +1480,8 @@ def test_estimate_dof_resid(self, n_samples, reg, poisson_population_GLM_model): """ X, y, model, true_params, firing_rate = poisson_population_GLM_model model.regularizer = reg + model.solver_name = model.regularizer.default_solver + model.fit(X, y) num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) assert int(num) == num From ce65ab01f9bc586eb988b821dca7d7f6a1659bef Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 17 Jul 2024 11:20:35 -0400 Subject: [PATCH 051/225] lint files --- src/nemos/glm.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 11e19d1c..02c5c6bf 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -815,7 +815,10 @@ def estimate_resid_degrees_of_freedom( if isinstance(self.regularizer, (GroupLasso, Lasso)): coef, _ = self._get_coef_and_intercept() if isinstance(coef, dict): - resid_dof = jax.tree.map(lambda x: n_samples - jnp.sum(jnp.isclose(x, jnp.zeros_like(x))), coef) + resid_dof = jax.tree.map( + lambda x: n_samples - jnp.sum(jnp.isclose(x, jnp.zeros_like(x))), + coef, + ) return resid_dof return n_samples - jnp.sum(jnp.isclose(coef, jnp.zeros_like(coef))) elif isinstance(self.regularizer, Ridge): From e60957fc082a050a53777025e2bdd4ed27f7bc0e Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Wed, 17 Jul 2024 11:54:11 -0400 Subject: [PATCH 052/225] Define addition, multiplication, exponentiation for TransformerBasis --- src/nemos/basis.py | 58 ++++++++++++++++++++++++++++++++++ tests/test_basis.py | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 2390d8b0..68a3dc0e 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -315,6 +315,64 @@ def __dir__(self) -> list[str]: """ return super().__dir__() + self._basis.__dir__() + def __add__(self, other: TransformerBasis) -> TransformerBasis: + """ + Add two TransformerBasis objects. + + Parameters + ---------- + other + The other TransformerBasis object to add. + + Returns + ------- + : TransformerBasis + The resulting Basis object. + """ + return TransformerBasis(self._basis + other._basis) + + def __mul__(self, other: TransformerBasis) -> TransformerBasis: + """ + Multiply two TransformerBasis objects. + + Parameters + ---------- + other + The other TransformerBasis object to multiply. + + Returns + ------- + : + The resulting Basis object. + """ + return TransformerBasis(self._basis * other._basis) + + def __pow__(self, exponent: int) -> TransformerBasis: + """Exponentiation of a TransformerBasis object. + + Define the power of a basis by repeatedly applying the method __multiply__. + The exponent must be a positive integer. + + Parameters + ---------- + exponent : + Positive integer exponent + + Returns + ------- + : + The product of the basis with itself "exponent" times. Equivalent to self * self * ... * self. + + Raises + ------ + TypeError + If the provided exponent is not an integer. + ValueError + If the integer is zero or negative. + """ + # errors are handled by Basis.__pow__ + return TransformerBasis(self._basis**exponent) + class Basis(Base, abc.ABC): """ diff --git a/tests/test_basis.py b/tests/test_basis.py index 252b7807..b628be51 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -4061,6 +4061,82 @@ def test_transformerbasis_setattr_illegal_attribute(basis_cls): with pytest.raises(ValueError, match="Only setting _basis or existing attributes of _basis is allowed."): trans_bas.random_attr = "random value" + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_addition(basis_cls): + n_basis_funcs_a = 5 + n_basis_funcs_b = n_basis_funcs_a * 2 + trans_bas_a = basis.TransformerBasis(basis_cls(n_basis_funcs_a)) + trans_bas_b = basis.TransformerBasis(basis_cls(n_basis_funcs_b)) + trans_bas_sum = trans_bas_a + trans_bas_b + assert isinstance(trans_bas_sum, basis.TransformerBasis) + assert isinstance(trans_bas_sum._basis, basis.AdditiveBasis) + assert trans_bas_sum.n_basis_funcs == trans_bas_a.n_basis_funcs + trans_bas_b.n_basis_funcs + assert trans_bas_sum._n_input_dimensionality == trans_bas_a._n_input_dimensionality + trans_bas_b._n_input_dimensionality + assert trans_bas_sum._basis1.n_basis_funcs == n_basis_funcs_a + assert trans_bas_sum._basis2.n_basis_funcs == n_basis_funcs_b + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_multiplication(basis_cls): + n_basis_funcs_a = 5 + n_basis_funcs_b = n_basis_funcs_a * 2 + trans_bas_a = basis.TransformerBasis(basis_cls(n_basis_funcs_a)) + trans_bas_b = basis.TransformerBasis(basis_cls(n_basis_funcs_b)) + trans_bas_prod = trans_bas_a * trans_bas_b + assert isinstance(trans_bas_prod, basis.TransformerBasis) + assert isinstance(trans_bas_prod._basis, basis.MultiplicativeBasis) + assert trans_bas_prod.n_basis_funcs == trans_bas_a.n_basis_funcs * trans_bas_b.n_basis_funcs + assert trans_bas_prod._n_input_dimensionality == trans_bas_a._n_input_dimensionality + trans_bas_b._n_input_dimensionality + assert trans_bas_prod._basis1.n_basis_funcs == n_basis_funcs_a + assert trans_bas_prod._basis2.n_basis_funcs == n_basis_funcs_b + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +@pytest.mark.parametrize( + "exponent, error_type, error_message", + [ + (2, does_not_raise, None), + (5, does_not_raise, None), + (0.5, TypeError, "Exponent should be an integer"), + (-1, ValueError, "Exponent should be a non-negative integer") + ] +) +def test_transformerbasis_exponentiation(basis_cls, exponent: int, error_type, error_message): + trans_bas = basis.TransformerBasis(basis_cls(5)) + + if not isinstance(exponent, int): + with pytest.raises(error_type, match=error_message): + trans_bas_exp = trans_bas ** exponent + assert isinstance(trans_bas_exp, basis.TransformerBasis) + assert isinstance(trans_bas_exp._basis, basis.MultiplicativeBasis) + + @pytest.mark.parametrize( "basis_cls", [ From 092b631fb948cbf46233037c98003c9775e8d38c Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 17 Jul 2024 12:12:16 -0400 Subject: [PATCH 053/225] fix dof calc with pytrees, add regularizer tests --- src/nemos/glm.py | 7 +++--- tests/test_regularizer.py | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 02c5c6bf..89da3468 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -815,11 +815,12 @@ def estimate_resid_degrees_of_freedom( if isinstance(self.regularizer, (GroupLasso, Lasso)): coef, _ = self._get_coef_and_intercept() if isinstance(coef, dict): - resid_dof = jax.tree.map( - lambda x: n_samples - jnp.sum(jnp.isclose(x, jnp.zeros_like(x))), + resid_dof = tree_utils.pytree_map_and_reduce( + lambda x: jnp.isclose(x, jnp.zeros_like(x)), + lambda x: sum([jnp.sum(i) for i in x]), coef, ) - return resid_dof + return n_samples - resid_dof return n_samples - jnp.sum(jnp.isclose(coef, jnp.zeros_like(coef))) elif isinstance(self.regularizer, Ridge): # for Ridge, use the tot parameters (X.shape[1] + intercept) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index ccfb0920..11e176b3 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -40,6 +40,58 @@ def test_regularizer_builder(reg_str, reg_type): assert regularizer.__dict__ == regularizer2.__dict__ +@pytest.mark.parametrize( + "regularizer_strength, reg_type", + [ + (0.001, nmo.regularizer.Ridge), + (1.0, nmo.regularizer.Ridge), + ("bah", nmo.regularizer.Ridge), + (0.001, nmo.regularizer.Lasso), + (1.0, nmo.regularizer.Lasso), + ("bah", nmo.regularizer.Lasso), + (0.001, nmo.regularizer.GroupLasso), + (1.0, nmo.regularizer.GroupLasso), + ("bah", nmo.regularizer.GroupLasso), + ], +) +def test_regularizer(regularizer_strength, reg_type): + if not isinstance(regularizer_strength, float): + with pytest.raises( + ValueError, + match=f"Could not convert the regularizer strength: {regularizer_strength} " + f"to a float.", + ): + reg_type(regularizer_strength=regularizer_strength) + else: + reg_type(regularizer_strength=regularizer_strength) + + +@pytest.mark.parametrize( + "regularizer_strength, regularizer", + [ + (0.001, nmo.regularizer.Ridge()), + (1.0, nmo.regularizer.Ridge()), + ("bah", nmo.regularizer.Ridge()), + (0.001, nmo.regularizer.Lasso()), + (1.0, nmo.regularizer.Lasso()), + ("bah", nmo.regularizer.Lasso()), + (0.001, nmo.regularizer.GroupLasso()), + (1.0, nmo.regularizer.GroupLasso()), + ("bah", nmo.regularizer.GroupLasso()), + ], +) +def test_regularizer_setter(regularizer_strength, regularizer): + if not isinstance(regularizer_strength, float): + with pytest.raises( + ValueError, + match=f"Could not convert the regularizer strength: {regularizer_strength} " + f"to a float.", + ): + regularizer.regularizer_strength = regularizer_strength + else: + regularizer.regularizer_strength = regularizer_strength + + @pytest.mark.parametrize( "regularizer", [ From d670a2350f26677af6daadc73252579673bb0639 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Wed, 17 Jul 2024 12:26:26 -0400 Subject: [PATCH 054/225] Add TransformerBasis to __all__ and update tests accordingly --- src/nemos/basis.py | 2 +- tests/test_basis.py | 380 ++++++++++---------------------------------- 2 files changed, 86 insertions(+), 296 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 68a3dc0e..f5dfe6a5 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -30,7 +30,7 @@ "OrthExponentialBasis", "AdditiveBasis", "MultiplicativeBasis", - # "TransformerBasis", + "TransformerBasis", ] diff --git a/tests/test_basis.py b/tests/test_basis.py index b628be51..e733b744 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -19,6 +19,17 @@ # automatic define user accessible basis and check the methods +def list_all_basis_classes() -> list[type]: + """ + Return all the classes in nemos.basis which are a subclass of Basis, + which should be all concrete classes except TransformerBasis. + """ + return [ + class_obj + for _, class_obj in utils_testing.get_non_abstract_classes(basis) + if issubclass(class_obj, basis.Basis) + ] + def test_all_basis_are_tested() -> None: """Meta-test. @@ -41,9 +52,7 @@ def test_all_basis_are_tested() -> None: tested_bases = {test_cls.cls for test_cls in subclasses} # Create the set of all the concrete basis classes - all_bases = { - class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis) - } + all_bases = set(list_all_basis_classes()) if all_bases != all_bases.intersection(tested_bases): raise ValueError( @@ -2671,14 +2680,8 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @pytest.mark.parametrize("sample_size", [10, 1000]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_fit_transform_returns_expected_number_of_basis( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size @@ -2709,14 +2712,8 @@ def test_fit_transform_returns_expected_number_of_basis( @pytest.mark.parametrize("sample_size", [100, 1000]) @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_sample_size_of_fit_transform_matches_that_of_input( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size @@ -2741,14 +2738,8 @@ def test_sample_size_of_fit_transform_matches_that_of_input( f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", ) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_input", [0, 1, 2, 3, 10, 30]) @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @@ -2781,14 +2772,8 @@ def test_number_of_required_inputs_fit_transform( basis_obj.compute_features(*inputs) @pytest.mark.parametrize("sample_size", [11, 20]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_meshgrid_size( @@ -2807,14 +2792,8 @@ def test_evaluate_on_grid_meshgrid_size( assert grid.shape[0] == sample_size @pytest.mark.parametrize("sample_size", [11, 20]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_basis_size( @@ -2832,14 +2811,8 @@ def test_evaluate_on_grid_basis_size( assert eval_basis.shape[0] == sample_size @pytest.mark.parametrize("n_input", [0, 1, 2, 5, 6, 11, 30]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_input_number( @@ -2868,14 +2841,8 @@ def test_evaluate_on_grid_input_number( @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) def test_pynapple_support_fit_transform( self, basis_a, basis_b, n_basis_a, n_basis_b, sample_size ): @@ -2896,14 +2863,8 @@ def test_pynapple_support_fit_transform( assert np.all(out.time_support.values == inp.time_support.values) # TEST CALL - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) @pytest.mark.parametrize("num_input", [0, 1, 2, 3, 4, 5]) @@ -2935,14 +2896,8 @@ def test_call_input_num( ], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_input_shape( @@ -2968,14 +2923,8 @@ def test_call_input_shape( @pytest.mark.parametrize("time_axis_shape", [10, 11, 12]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_sample_axis( @@ -2992,14 +2941,8 @@ def test_call_sample_axis( assert basis_obj(*inp).shape[0] == time_axis_shape @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_nan(self, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_size): @@ -3020,14 +2963,8 @@ def test_call_nan(self, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_siz x[3] = np.nan assert all(np.isnan(basis_obj(*inp)[3])) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_equivalent_in_conv(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3051,14 +2988,8 @@ def test_call_equivalent_in_conv(self, n_basis_a, n_basis_b, basis_a, basis_b): assert np.all(bas_con(*x) == bas_eva(*x)) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_pynapple_support( @@ -3081,14 +3012,8 @@ def test_pynapple_support( assert np.all(y_nap.t == x_nap[0].t) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [6, 7]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_basis_number( @@ -3105,14 +3030,8 @@ def test_call_basis_number( assert bas(*x).shape[1] == basis_a_obj.n_basis_funcs + basis_b_obj.n_basis_funcs @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_non_empty( @@ -3133,14 +3052,8 @@ def test_call_non_empty( [(0, 1, does_not_raise()), (-2, 2, "check"), (0.1, 2, does_not_raise())], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_sample_range( @@ -3175,14 +3088,8 @@ def test_call_sample_range( with expectation: bas(*([np.linspace(mn, mx, 10)] * bas._n_input_dimensionality)) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_fit_kernel(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3208,14 +3115,8 @@ def check_kernel(basis_obj): assert all(check_kernel(bas)) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_transform_fails(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3274,14 +3175,8 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @pytest.mark.parametrize("sample_size", [10, 1000]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_fit_transform_returns_expected_number_of_basis( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size @@ -3312,14 +3207,8 @@ def test_fit_transform_returns_expected_number_of_basis( @pytest.mark.parametrize("sample_size", [12, 30, 35]) @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_sample_size_of_fit_transform_matches_that_of_input( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size @@ -3344,14 +3233,8 @@ def test_sample_size_of_fit_transform_matches_that_of_input( f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", ) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_input", [0, 1, 2, 3, 10, 30]) @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @@ -3384,14 +3267,8 @@ def test_number_of_required_inputs_fit_transform( basis_obj.compute_features(*inputs) @pytest.mark.parametrize("sample_size", [11, 20]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_meshgrid_size( @@ -3410,14 +3287,8 @@ def test_evaluate_on_grid_meshgrid_size( assert grid.shape[0] == sample_size @pytest.mark.parametrize("sample_size", [11, 20]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_basis_size( @@ -3435,14 +3306,8 @@ def test_evaluate_on_grid_basis_size( assert eval_basis.shape[0] == sample_size @pytest.mark.parametrize("n_input", [0, 1, 2, 5, 6, 11, 30]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [6]) def test_evaluate_on_grid_input_number( @@ -3498,14 +3363,8 @@ def test_inconsistent_sample_sizes( @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) def test_pynapple_support_fit_transform( self, basis_a, basis_b, n_basis_a, n_basis_b, sample_size ): @@ -3523,14 +3382,8 @@ def test_pynapple_support_fit_transform( assert np.all(out.time_support.values == inp.time_support.values) # TEST CALL - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) @pytest.mark.parametrize("num_input", [0, 1, 2, 3, 4, 5]) @@ -3562,14 +3415,8 @@ def test_call_input_num( ], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_input_shape( @@ -3595,14 +3442,8 @@ def test_call_input_shape( @pytest.mark.parametrize("time_axis_shape", [10, 11, 12]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_sample_axis( @@ -3619,14 +3460,8 @@ def test_call_sample_axis( assert basis_obj(*inp).shape[0] == time_axis_shape @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_nan(self, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_size): @@ -3647,14 +3482,8 @@ def test_call_nan(self, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_siz x[3] = np.nan assert all(np.isnan(basis_obj(*inp)[3])) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_equivalent_in_conv(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3678,14 +3507,8 @@ def test_call_equivalent_in_conv(self, n_basis_a, n_basis_b, basis_a, basis_b): assert np.all(bas_con(*x) == bas_eva(*x)) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_pynapple_support( @@ -3708,14 +3531,8 @@ def test_pynapple_support( assert np.all(y_nap.t == x_nap[0].t) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [6, 7]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_basis_number( @@ -3732,14 +3549,8 @@ def test_call_basis_number( assert bas(*x).shape[1] == basis_a_obj.n_basis_funcs * basis_b_obj.n_basis_funcs @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_non_empty( @@ -3760,14 +3571,8 @@ def test_call_non_empty( [(0, 1, does_not_raise()), (-2, 2, "check"), (0.1, 2, does_not_raise())], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_call_sample_range( @@ -3802,14 +3607,8 @@ def test_call_sample_range( with expectation: bas(*([np.linspace(mn, mx, 10)] * bas._n_input_dimensionality)) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_fit_kernel(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3835,14 +3634,8 @@ def check_kernel(basis_obj): assert all(check_kernel(bas)) - @pytest.mark.parametrize( - "basis_a", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) - @pytest.mark.parametrize( - "basis_b", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], - ) + @pytest.mark.parametrize("basis_a", list_all_basis_classes()) + @pytest.mark.parametrize("basis_b", list_all_basis_classes()) @pytest.mark.parametrize("n_basis_a", [5]) @pytest.mark.parametrize("n_basis_b", [5]) def test_transform_fails(self, n_basis_a, n_basis_b, basis_a, basis_b): @@ -3863,10 +3656,7 @@ def test_transform_fails(self, n_basis_a, n_basis_b, basis_a, basis_b): @pytest.mark.parametrize( "exponent", [-1, 0, 0.5, basis.RaisedCosineBasisLog(4), 1, 2, 3] ) -@pytest.mark.parametrize( - "basis_class", - [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], -) +@pytest.mark.parametrize("basis_class", list_all_basis_classes()) def test_power_of_basis(exponent, basis_class): """Test if the power behaves as expected.""" raise_exception_type = not type(exponent) is int From e170a38488e3dbcb0607fbcd1de956ebf9583dae Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Wed, 17 Jul 2024 13:08:48 -0400 Subject: [PATCH 055/225] Add tests for TransformerBasis.__dir__ --- tests/test_basis.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_basis.py b/tests/test_basis.py index e733b744..094e2dfc 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -3926,6 +3926,22 @@ def test_transformerbasis_exponentiation(basis_cls, exponent: int, error_type, e assert isinstance(trans_bas_exp, basis.TransformerBasis) assert isinstance(trans_bas_exp._basis, basis.MultiplicativeBasis) +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformerbasis_dir(basis_cls): + trans_bas = basis.TransformerBasis(basis_cls(5)) + for attr_name in ("fit", "transform", "fit_transform", "n_basis_funcs", "mode", "window_size"): + assert attr_name in dir(trans_bas) + + @pytest.mark.parametrize( "basis_cls", From 5641f0b9078122752665588a01730835c117ec2d Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 17 Jul 2024 15:31:21 -0400 Subject: [PATCH 056/225] more tests, increase test coverage --- src/nemos/regularizer.py | 5 +- tests/test_convergence.py | 150 ++++++++++++++++++++++++++++++++++++++ tests/test_regularizer.py | 58 +++++++++++++++ 3 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/test_convergence.py diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 2add41d9..113cfd97 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -493,8 +493,9 @@ def _penalization( param_with_extra_axis ) # this masks the param, (group, feature, neuron) - penalty = jnp.linalg.norm(masked_param, axis=1) * jax.numpy.sqrt( - self.mask.sum(axis=1) + penalty = jax.numpy.sum( + jax.numpy.linalg.norm(masked_param, axis=1) + * jax.numpy.sqrt(self.mask.sum(axis=1)) ) # divide regularization strength by number of neurons diff --git a/tests/test_convergence.py b/tests/test_convergence.py new file mode 100644 index 00000000..a4351d4d --- /dev/null +++ b/tests/test_convergence.py @@ -0,0 +1,150 @@ +"""Tests for making sure that solution reached for proximal operator is that the same as using another +method with just penalized loss.""" + +import jax +import numpy as np +import nemos as nmo +from scipy.optimize import minimize + + +def test_unregularized_convergence(): + """ + Assert that solution found when using GradientDescent vs ProximalGradient with an + unregularized GLM is the same. + """ + # generate toy data + np.random.seed(111) + # random design tensor. Shape (n_time_points, n_features). + X = 0.5 * np.random.normal(size=(100, 5)) + + # log-rates & weights, shape (1, ) and (n_features, ) respectively. + b_true = np.zeros((1,)) + w_true = np.random.normal(size=(5,)) + + # sparsify weights + w_true[1:4] = 0.0 + + # generate counts + rate = jax.numpy.exp(jax.numpy.einsum("k,tk->t", w_true, X) + b_true) + y = np.random.poisson(rate) + + # instantiate and fit unregularized GLM with GradientDescent + model_GD = nmo.glm.GLM() + model_GD.fit(X, y) + + # instantiate and fit unregularized GLM with ProximalGradient + model_PG = nmo.glm.GLM(solver_name="ProximalGradient") + model_PG.fit(X, y) + + # assert weights are the same + assert np.allclose(np.round(model_GD.coef_, 2), np.round(model_PG.coef_, 2)) + + +def test_ridge_convergence(): + """ + Assert that solution found when using GradientDescent vs ProximalGradient with an + ridge GLM is the same. + """ + # generate toy data + np.random.seed(111) + # random design tensor. Shape (n_time_points, n_features). + X = 0.5 * np.random.normal(size=(100, 5)) + + # log-rates & weights, shape (1, ) and (n_features, ) respectively. + b_true = np.zeros((1,)) + w_true = np.random.normal(size=(5,)) + + # sparsify weights + w_true[1:4] = 0.0 + + # generate counts + rate = jax.numpy.exp(jax.numpy.einsum("k,tk->t", w_true, X) + b_true) + y = np.random.poisson(rate) + + # instantiate and fit ridge GLM with GradientDescent + model_GD = nmo.glm.GLM(regularizer="ridge") + model_GD.fit(X, y) + + # instantiate and fit ridge GLM with ProximalGradient + model_PG = nmo.glm.GLM(regularizer="ridge", solver_name="ProximalGradient") + model_PG.fit(X, y) + + # assert weights are the same + assert np.allclose(np.round(model_GD.coef_, 2), np.round(model_PG.coef_, 2)) + + +def test_lasso_convergence(): + """ + Assert that solution found when using ProximalGradient versus Nelder-Mead method using + lasso GLM is the same. + """ + # generate toy data + num_samples, num_features, num_groups = 1000, 5, 3 + X = np.random.normal(size=(num_samples, num_features)) # design matrix + w = [0, 0.5, 1, 0, -0.5] # define some weights + y = np.random.poisson(np.exp(X.dot(w))) # observed counts + + # instantiate and fit GLM with ProximalGradient + model_PG = nmo.glm.GLM(regularizer="lasso", solver_name="ProximalGradient") + model_PG.regularizer.regularizer_strength = 0.1 + model_PG.fit(X, y) + + # use the penalized loss function to solve optimization via Nelder-Mead + penalized_loss = lambda p, x, y: model_PG.regularizer.penalized_loss( + model_PG._predict_and_compute_loss + )( + ( + p[1:], + p[0].reshape( + 1, + ), + ), + x, + y, + ) + res = minimize(penalized_loss, [0] + w, args=(X, y), method="Nelder-Mead") + + # assert absolute difference between the weights is less than 0.1 + a = np.abs(np.subtract(np.round(res.x[1:], 2), np.round(model_PG.coef_, 2))) < 1e-1 + assert a.all() + + +def test_group_lasso_convergence(): + """ + Assert that solution found when using ProximalGradient versus Nelder-Mead method using + group lasso GLM is the same. + """ + # generate toy data + num_samples, num_features, num_groups = 1000, 5, 3 + X = np.random.normal(size=(num_samples, num_features)) # design matrix + w = [0, 0.5, 1, 0, -0.5] # define some weights + y = np.random.poisson(np.exp(X.dot(w))) # observed counts + + mask = np.zeros((num_groups, num_features)) + mask[0] = [1, 0, 0, 1, 0] # Group 0 includes features 0 and 3 + mask[1] = [0, 1, 0, 0, 0] # Group 1 includes features 1 + mask[2] = [0, 0, 1, 0, 1] # Group 2 includes features 2 and 4 + + # instantiate and fit GLM with ProximalGradient + model_PG = nmo.glm.GLM(regularizer=nmo.regularizer.GroupLasso(mask=mask)) + model_PG.fit(X, y) + + # use the penalized loss function to solve optimization via Nelder-Mead + penalized_loss = lambda p, x, y: model_PG.regularizer.penalized_loss( + model_PG._predict_and_compute_loss + )( + ( + p[1:], + p[0].reshape( + 1, + ), + ), + x, + y, + ) + + res = minimize(penalized_loss, [0] + w, args=(X, y), method="Nelder-Mead") + + # assert absolute difference between the weights is less than 0.5 + a = np.abs(np.subtract(np.round(res.x[1:], 2), np.round(model_PG.coef_, 2))) < 0.5 + assert a.all() diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 11e176b3..4c1a249c 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -331,6 +331,23 @@ def test_solver_match_statsmodels_gamma( if (not match_weights) or (not match_intercepts): raise ValueError("Unregularized GLM estimate does not match statsmodels!") + @pytest.mark.parametrize( + "solver_name", + [ + "GradientDescent", + "BFGS", + "LBFGS", + "LBFGSB", + "NonlinearCG", + "ProximalGradient", + ], + ) + def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + model.regularizer = self.cls() + model.solver_name = solver_name + model.fit(X, y) + class TestRidge: cls = nmo.regularizer.Ridge @@ -511,6 +528,23 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): if (not match_weights) or (not match_intercepts): raise ValueError("Ridge GLM estimate does not match sklearn!") + @pytest.mark.parametrize( + "solver_name", + [ + "GradientDescent", + "BFGS", + "LBFGS", + "LBFGSB", + "NonlinearCG", + "ProximalGradient", + ], + ) + def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + model.regularizer = self.cls() + model.solver_name = solver_name + model.fit(X, y) + class TestLasso: cls = nmo.regularizer.Lasso @@ -645,6 +679,18 @@ def test_lasso_pytree_match( np.hstack(jax.tree_util.tree_leaves(model.coef_)), model_array.coef_ ) + @pytest.mark.parametrize( + "solver_name", + [ + "ProximalGradient", + ], + ) + def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + model.regularizer = self.cls() + model.solver_name = solver_name + model.fit(X, y) + class TestGroupLasso: cls = nmo.regularizer.GroupLasso @@ -1068,3 +1114,15 @@ def test_mask_n_groups_set_params(self, n_groups, poissonGLM_model_instantiation regularizer.set_params(mask=mask) else: regularizer.set_params(mask=mask) + + @pytest.mark.parametrize( + "solver_name", + [ + "ProximalGradient", + ], + ) + def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + model.regularizer = self.cls() + model.solver_name = solver_name + model.fit(X, y) From 9f0dc51f3239b91ddf12f5f7f0fc7eaed2e859e4 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 17 Jul 2024 16:43:25 -0400 Subject: [PATCH 057/225] change _call logic --- docs/background/plot_01_1D_basis_function.py | 24 +- src/nemos/basis.py | 326 +++++++++---------- tests/test_basis.py | 125 +++++-- 3 files changed, 287 insertions(+), 188 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index 0b58871b..4a892b6b 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -27,12 +27,13 @@ # %% # Evaluating a Basis # ------------------------------------ -# The `Basis` object is callable, and can be evaluated as a function. For `SplineBasis`, the domain is defined by -# the samples that we input to the `__call__` method. This results in an equi-spaced set of knots, which spans -# the range from the smallest to the largest sample. These knots are then used to construct a uniformly spaced basis. +# The `Basis` object is callable, and can be evaluated as a function. By default, the support of the basis +# is defined by the samples that we input to the `__call__` method. This results in an equi-spaced set of knots, +# which spans the range from the smallest to the largest sample. These knots are then used to construct a +# uniformly spaced basis. # Generate an array of sample points -samples = np.random.uniform(0, 10, size=1000) +samples = np.linspace(0, 10,1000) # Evaluate the basis at the sample points eval_basis = mspline_basis(samples) @@ -41,6 +42,21 @@ print(f"Evaluated M-spline of order {order} with {eval_basis.shape[1]} " f"basis element and {eval_basis.shape[0]} samples.") +plt.figure() +plt.title("MSpline basis") +plt.plot(eval_basis) + +# %% +# ## Setting the basis support +# You can specify a range for the support of the basis by setting the `vmin` and `vmax` +# parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. + +mspline_basis = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) + +print(f"Within the domain: {np.round(mspline_basis(0.5), 3)}") +print(f"Outside the domain: {mspline_basis(0.1)}") + + # %% # ## Basis `mode` # In constructing features, `Basis` objects can be used in two modalities: `"eval"` for evaluate or `"conv"` diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 73c9e833..49a07ca3 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -369,7 +369,7 @@ def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: # check if self._kernel is not None for mode="conv" self._check_has_kernel() if self.mode == "eval": # evaluate at the sample - return self.__call__(*xi, vmin=self._vmin, vmax=self._vmax) + return self.__call__(*xi) else: # convolve, called only at the last layer if "axis" not in self._conv_kwargs: axis = 0 @@ -455,7 +455,12 @@ def _set_kernel(self, *xi: ArrayLike) -> Basis: return self @abc.abstractmethod - def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: + def _call(self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, **kwargs): + """Evaluate basis on samples.""" + pass + + @abc.abstractmethod + def __call__(self, *xi: ArrayLike) -> FeatureMatrix: """ Abstract method to evaluate the basis functions at given points. @@ -470,9 +475,6 @@ def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: Variable number of arguments, each representing an array of points at which to evaluate the basis functions. The dimensions and requirements of these inputs vary depending on the specific basis implementation. - **kwargs : - Keyword args such as `vmin` and `vmax`, needed for all basis except for - AdditiveBasis and MultiplicativeBasis. Returns ------- @@ -595,7 +597,7 @@ def evaluate_on_grid(self, *n_samples: int) -> Tuple[Tuple[NDArray], NDArray]: Xs = np.meshgrid(*sample_tuple, indexing="ij") # evaluates the basis on a flat NDArray and reshape to match meshgrid output - Y = self.__call__(*tuple(grid_axis.flatten() for grid_axis in Xs)).reshape( + Y = self._call(*tuple(grid_axis.flatten() for grid_axis in Xs)).reshape( (*n_samples, self.n_basis_funcs) ) @@ -759,10 +761,22 @@ def __init__(self, basis1: Basis, basis2: Basis) -> None: def _check_n_basis_min(self) -> None: pass + def _call(self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, **kwargs): + """Evaluate basis on samples.""" + X = np.hstack( + ( + self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), + self._basis2.__call__(*xi[self._basis1._n_input_dimensionality:]), + ) + ) + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: + def __call__(self, *xi: ArrayLike) -> FeatureMatrix: """ Evaluate the basis at the input samples. @@ -778,15 +792,7 @@ def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) """ - X = np.hstack( - ( - self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), - self._basis2.__call__(*xi[self._basis1._n_input_dimensionality :]), - ) - ) - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X + return self._call(*xi, vmin=self.vmin, vmax=self.vmax) @check_transform_input def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: @@ -892,7 +898,7 @@ def _set_kernel(self, *xi: NDArray) -> Basis: @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__(self, *xi: ArrayLike, **kwargs) -> FeatureMatrix: + def __call__(self, *xi: ArrayLike) -> FeatureMatrix: """ Evaluate the basis at the input samples. @@ -1157,15 +1163,31 @@ def __init__( **kwargs, ) + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + """Evaluate basis over samples.""" + sample_pts, scaling = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + # add knots if not passed + knot_locs = self._generate_knots( + sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False + ) + + X = np.stack( + [ + mspline(sample_pts, self.order, i, knot_locs) + for i in range(self.n_basis_funcs) + ], + axis=1, + ) + # re-normalize so that it integrates to 1 over the range. + X /= scaling + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__( - self, - sample_pts: ArrayLike, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ) -> FeatureMatrix: + def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: """ Evaluate the M-spline basis functions at given sample points. @@ -1177,12 +1199,6 @@ def __call__( `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin: - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - This value determines the minimum of the domain of the basis. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - This value determines the maximum of the domain of the basis. Returns ------- @@ -1197,24 +1213,7 @@ def __call__( conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ - sample_pts, scaling = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - # add knots if not passed - knot_locs = self._generate_knots( - sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False - ) - - X = np.stack( - [ - mspline(sample_pts, self.order, i, knot_locs) - for i in range(self.n_basis_funcs) - ], - axis=1, - ) - # re-normalize so that it integrates to 1 over the range. - X /= scaling - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """ @@ -1327,14 +1326,25 @@ def __init__( **kwargs, ) + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + """Evaluate basis over samples.""" + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + # add knots + knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) + + basis_eval = bspline( + sample_pts, knot_locs, order=self.order, der=0, outer_ok=False + ) + if self.identifiability_constraints: + basis_eval = self._apply_identifiability_constraints(basis_eval) + return basis_eval + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional def __call__( self, sample_pts: ArrayLike, - vmin: Optional[float] = None, - vmax: Optional[float] = None, ) -> FeatureMatrix: """ Evaluate the B-spline basis functions with given sample points. @@ -1369,16 +1379,7 @@ def __call__( The evaluation is performed by looping over each element and using `splev` from SciPy to compute the basis values. """ - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - # add knots - knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) - - basis_eval = bspline( - sample_pts, knot_locs, order=self.order, der=0, outer_ok=False - ) - if self.identifiability_constraints: - basis_eval = self._apply_identifiability_constraints(basis_eval) - return basis_eval + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the B-spline basis set on a grid of equi-spaced sample points. @@ -1471,14 +1472,48 @@ def __init__( f"order {self.order} specified instead!" ) + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + """Evaluate basis over samples.""" + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) + + # for cyclic, do not repeat knots + knot_locs = np.unique(knot_locs) + + nk = knot_locs.shape[0] + + # make sure knots are sorted + knot_locs.sort() + + # extend knots + xc = knot_locs[nk - self.order] + knots = np.hstack( + ( + knot_locs[0] - knot_locs[-1] + knot_locs[nk - self.order: nk - 1], + knot_locs, + ) + ) + ind = sample_pts > xc + + basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) + sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] + + if np.sum(ind): + basis_eval[ind] = basis_eval[ind] + bspline( + sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 + ) + # restore points + sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] + if self.identifiability_constraints: + basis_eval = self._apply_identifiability_constraints(basis_eval) + return basis_eval + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional def __call__( self, sample_pts: ArrayLike, - vmin: Optional[float] = None, - vmax: Optional[float] = None, ) -> FeatureMatrix: """Evaluate the Cyclic B-spline basis functions with given sample points. @@ -1508,39 +1543,7 @@ def __call__( SciPy to compute the basis values. """ - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) - - # for cyclic, do not repeat knots - knot_locs = np.unique(knot_locs) - - nk = knot_locs.shape[0] - - # make sure knots are sorted - knot_locs.sort() - - # extend knots - xc = knot_locs[nk - self.order] - knots = np.hstack( - ( - knot_locs[0] - knot_locs[-1] + knot_locs[nk - self.order : nk - 1], - knot_locs, - ) - ) - ind = sample_pts > xc - - basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) - sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] - - if np.sum(ind): - basis_eval[ind] = basis_eval[ind] + bspline( - sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 - ) - # restore points - sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] - if self.identifiability_constraints: - basis_eval = self._apply_identifiability_constraints(basis_eval) - return basis_eval + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the Cyclic B-spline basis set on a grid of equi-spaced sample points. @@ -1658,41 +1661,8 @@ def _check_width(width: float) -> None: f"2*width must be a positive integer, 2*width = {2 * width} instead!" ) - @support_pynapple(conv_type="numpy") - @check_transform_input - @check_one_dimensional - def __call__( - self, - sample_pts: ArrayLike, - rescale_samples=True, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ) -> FeatureMatrix: - """Generate basis functions with given samples. - - Parameters - ---------- - sample_pts : - Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). - rescale_samples : - If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. - Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` - are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin: - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - This value determines the minimum of the domain of the basis. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - This value determines the maximum of the domain of the basis. - - - Raises - ------ - ValueError - If the sample provided do not lie in [0,1]. - - """ + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, rescale_samples: bool = False): + """Evaluate basis over samples.""" if rescale_samples: # note that sample points is converted to NDArray # with the decorator. @@ -1724,6 +1694,34 @@ def __call__( basis_funcs = self._apply_identifiability_constraints(basis_funcs) return basis_funcs + @support_pynapple(conv_type="numpy") + @check_transform_input + @check_one_dimensional + def __call__( + self, + sample_pts: ArrayLike, + rescale_samples=True, + ) -> FeatureMatrix: + """Generate basis functions with given samples. + + Parameters + ---------- + sample_pts : + Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). + rescale_samples : + If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. + Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of `sample_pts` + are used, respectively. Values outside the [vmin, vmax] range are set to NaN. + + Raises + ------ + ValueError + If the sample provided do not lie in [0,1]. + + """ + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax, rescale_samples=rescale_samples) + def _compute_peaks(self) -> NDArray: """ Compute the location of raised cosine peaks. @@ -1920,14 +1918,21 @@ def _compute_peaks(self) -> NDArray: last_peak = 1 return np.linspace(0, last_peak, self.n_basis_funcs) + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + """Evaluate basis over samples.""" + return super()._call( + self._transform_samples(sample_pts, vmin=vmin, vmax=vmax), + vmin=vmin, + vmax=vmax, + rescale_samples=False, + ) + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional def __call__( self, sample_pts: ArrayLike, - vmin: Optional[float] = None, - vmax: Optional[float] = None, ) -> FeatureMatrix: """Generate log-spaced raised cosine basis with given samples. @@ -1937,12 +1942,6 @@ def __call__( Spacing for basis functions. Samples will be rescaled to the interval [0, 1]. Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. Values outside the [vmin, vmax] range are set to NaN. - vmin: - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - This value determines the minimum of the domain of the basis. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - This value determines the maximum of the domain of the basis. Returns ------- @@ -1954,10 +1953,7 @@ def __call__( ValueError If the sample provided do not lie in [0,1]. """ - return super().__call__( - self._transform_samples(sample_pts, vmin=vmin, vmax=vmax), - rescale_samples=False, - ) + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) class OrthExponentialBasis(Basis): @@ -2076,14 +2072,36 @@ def _check_sample_size(self, *sample_pts: NDArray) -> None: f"but only {sample_pts[0].size} samples provided!" ) + def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + """Evaluate basis over samples.""" + self._check_sample_size(sample_pts) + sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + valid_idx = ~np.isnan(sample_pts) + # because of how scipy.linalg.orth works, have to create a matrix of + # shape (n_pts, n_basis_funcs) and then transpose, rather than + # directly computing orth on the matrix of shape (n_basis_funcs, + # n_pts) + exp_decay_eval = np.stack( + [np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1 + ) + # count the linear independent components (could be lower than n_basis_funcs for num precision). + n_independent_component = np.linalg.matrix_rank(exp_decay_eval) + # initialize output to nan + basis_funcs = np.full( + shape=(sample_pts.shape[0], n_independent_component), fill_value=np.nan + ) + # orthonormalize on valid points + basis_funcs[valid_idx] = scipy.linalg.orth(exp_decay_eval) + if self.identifiability_constraints: + basis_funcs = self._apply_identifiability_constraints(basis_funcs) + return basis_funcs + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional def __call__( self, sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, ) -> FeatureMatrix: """Generate basis functions with given spacing. @@ -2100,27 +2118,7 @@ def __call__( orthogonalized, shape (n_samples, n_basis_funcs) """ - self._check_sample_size(sample_pts) - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - valid_idx = ~np.isnan(sample_pts) - # because of how scipy.linalg.orth works, have to create a matrix of - # shape (n_pts, n_basis_funcs) and then transpose, rather than - # directly computing orth on the matrix of shape (n_basis_funcs, - # n_pts) - exp_decay_eval = np.stack( - [np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1 - ) - # count the linear independent components (could be lower than n_basis_funcs for num precision). - n_independent_component = np.linalg.matrix_rank(exp_decay_eval) - # initialize output to nan - basis_funcs = np.full( - shape=(sample_pts.shape[0], n_independent_component), fill_value=np.nan - ) - # orthonormalize on valid points - basis_funcs[valid_idx] = scipy.linalg.orth(exp_decay_eval) - if self.identifiability_constraints: - basis_funcs = self._apply_identifiability_constraints(basis_funcs) - return basis_funcs + return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the basis set on a grid of equi-spaced sample points. diff --git a/tests/test_basis.py b/tests/test_basis.py index 9c30d058..06b74036 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -565,12 +565,29 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) - out1 = bas.evaluate_on_grid(10) - out2 = bas_no_range.evaluate_on_grid(10) - assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + _, out1 = bas.evaluate_on_grid(10) + _, out2 = bas_no_range.evaluate_on_grid(10) + assert np.allclose(out1, out2) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + x1, _ = bas.evaluate_on_grid(10) + x2, _ = bas_no_range.evaluate_on_grid(10) + mn = 0 if vmin is None else vmin + mx = mn + 1 if vmax is None else vmax + assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( "vmin, vmax, samples, exception", @@ -1029,12 +1046,29 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + _, out1 = bas.evaluate_on_grid(10) + _, out2 = bas_no_range.evaluate_on_grid(10) + assert np.allclose(out1, out2) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) - out1 = bas.evaluate_on_grid(10) - out2 = bas_no_range.evaluate_on_grid(10) - assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + x1, _ = bas.evaluate_on_grid(10) + x2, _ = bas_no_range.evaluate_on_grid(10) + mn = 0 if vmin is None else vmin + mx = mn + 1 if vmax is None else vmax + assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( "vmin, vmax, samples, exception", @@ -1493,12 +1527,29 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) - out1 = bas.evaluate_on_grid(10) - out2 = bas_no_range.evaluate_on_grid(10) - assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + _, out1 = bas.evaluate_on_grid(10) + _, out2 = bas_no_range.evaluate_on_grid(10) + assert np.allclose(out1, out2) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + x1, _ = bas.evaluate_on_grid(10) + x2, _ = bas_no_range.evaluate_on_grid(10) + mn = 0 if vmin is None else vmin + mx = mn + 1 if vmax is None else vmax + assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( "vmin, vmax, samples, exception", @@ -2422,12 +2473,29 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) - out1 = bas.evaluate_on_grid(10) - out2 = bas_no_range.evaluate_on_grid(10) - assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + _, out1 = bas.evaluate_on_grid(10) + _, out2 = bas_no_range.evaluate_on_grid(10) + assert np.allclose(out1, out2) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + x1, _ = bas.evaluate_on_grid(10) + x2, _ = bas_no_range.evaluate_on_grid(10) + mn = 0 if vmin is None else vmin + mx = mn + 1 if vmax is None else vmax + assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( "vmin, vmax, samples, exception", @@ -2926,12 +2994,29 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_eval_on_grid_no_effect(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): + bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + _, out1 = bas.evaluate_on_grid(10) + _, out2 = bas_no_range.evaluate_on_grid(10) + assert np.allclose(out1, out2) + + @pytest.mark.parametrize( + "vmin, vmax, samples, nan_idx", + [ + (None, 3, np.arange(5), [4]), + (1, None, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) + ] + ) + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) - out1 = bas.evaluate_on_grid(10) - out2 = bas_no_range.evaluate_on_grid(10) - assert all([np.all(out1[k] == out2[k]) for k in range(len(out1))]) + x1, _ = bas.evaluate_on_grid(10) + x2, _ = bas_no_range.evaluate_on_grid(10) + mn = 0 if vmin is None else vmin + mx = mn + 1 if vmax is None else vmax + assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( "vmin, vmax, samples, exception", From 956ee6a2b3dcd4a9f431f48296ee15845d94f96a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 17 Jul 2024 17:39:32 -0400 Subject: [PATCH 058/225] fixed basis and tests --- src/nemos/basis.py | 122 ++++++++++++++++++++++++++++++++++---------- tests/test_basis.py | 81 +++++++++++++++-------------- 2 files changed, 136 insertions(+), 67 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 49a07ca3..09aaa627 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -455,7 +455,13 @@ def _set_kernel(self, *xi: ArrayLike) -> Basis: return self @abc.abstractmethod - def _call(self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, **kwargs): + def _call( + self, + *xi: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + **kwargs, + ): """Evaluate basis on samples.""" pass @@ -485,8 +491,7 @@ def __call__(self, *xi: ArrayLike) -> FeatureMatrix: """ pass - @staticmethod - def _get_samples(*n_samples: int) -> Generator[NDArray]: + def _get_samples(self, *n_samples: int) -> Generator[NDArray]: """Get equi-spaced samples for all the input dimensions. This will be used to evaluate the basis on a grid of @@ -502,7 +507,19 @@ def _get_samples(*n_samples: int) -> Generator[NDArray]: : A generator yielding numpy arrays of linspaces from 0 to 1 of sizes specified by `n_samples`. """ - return (np.linspace(0, 1, n_samples[k]) for k in range(len(n_samples))) + # handling of defaults when evaluating on a grid + # (i.e. when we cannot use max and min of samples) + if self.vmin is None and self.vmax is None: + mn, mx = 0, 1 + elif self.vmin is None: + mn = self.vmax - 1 # min has to be lower than the provided max + mx = self.vmax + elif self.vmax is None: + mn = self.vmin + mx = mn + 1 # max has to be greater than the provided min + else: + mn, mx = self.vmin, self.vmax + return (np.linspace(mn, mx, n_samples[k]) for k in range(len(n_samples))) @support_pynapple(conv_type="numpy") def _check_transform_input( @@ -597,7 +614,7 @@ def evaluate_on_grid(self, *n_samples: int) -> Tuple[Tuple[NDArray], NDArray]: Xs = np.meshgrid(*sample_tuple, indexing="ij") # evaluates the basis on a flat NDArray and reshape to match meshgrid output - Y = self._call(*tuple(grid_axis.flatten() for grid_axis in Xs)).reshape( + Y = self.__call__(*tuple(grid_axis.flatten() for grid_axis in Xs)).reshape( (*n_samples, self.n_basis_funcs) ) @@ -761,12 +778,22 @@ def __init__(self, basis1: Basis, basis2: Basis) -> None: def _check_n_basis_min(self) -> None: pass - def _call(self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, **kwargs): + def _call( + self, + *xi: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + **kwargs, + ): """Evaluate basis on samples.""" X = np.hstack( ( - self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), - self._basis2.__call__(*xi[self._basis1._n_input_dimensionality:]), + self._basis1._call( + *xi[: self._basis1._n_input_dimensionality], vmin=vmin, vmax=vmax + ), + self._basis2._call( + *xi[self._basis1._n_input_dimensionality :], vmin=vmin, vmax=vmax + ), ) ) if self.identifiability_constraints: @@ -895,6 +922,25 @@ def _set_kernel(self, *xi: NDArray) -> Basis: self._basis2._set_kernel(*xi) return self + def _call( + self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None + ): + """Evaluate basis on samples.""" + X = np.asarray( + row_wise_kron( + self._basis1._call( + *xi[: self._basis1._n_input_dimensionality], vmin=vmin, vmax=vmax + ), + self._basis2._call( + *xi[self._basis1._n_input_dimensionality :], vmin=vmin, vmax=vmax + ), + transpose=False, + ) + ) + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X + @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -913,16 +959,7 @@ def __call__(self, *xi: ArrayLike) -> FeatureMatrix: : The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) """ - X = np.asarray( - row_wise_kron( - self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), - self._basis2.__call__(*xi[self._basis1._n_input_dimensionality :]), - transpose=False, - ) - ) - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X + return self._call(*xi, vmin=self.vmin, vmax=self.vmax) @check_transform_input def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: @@ -1163,7 +1200,12 @@ def __init__( **kwargs, ) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ): """Evaluate basis over samples.""" sample_pts, scaling = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots if not passed @@ -1326,7 +1368,12 @@ def __init__( **kwargs, ) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ): """Evaluate basis over samples.""" sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) # add knots @@ -1472,7 +1519,12 @@ def __init__( f"order {self.order} specified instead!" ) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ): """Evaluate basis over samples.""" sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) @@ -1489,7 +1541,7 @@ def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optiona xc = knot_locs[nk - self.order] knots = np.hstack( ( - knot_locs[0] - knot_locs[-1] + knot_locs[nk - self.order: nk - 1], + knot_locs[0] - knot_locs[-1] + knot_locs[nk - self.order : nk - 1], knot_locs, ) ) @@ -1661,7 +1713,13 @@ def _check_width(width: float) -> None: f"2*width must be a positive integer, 2*width = {2 * width} instead!" ) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None, rescale_samples: bool = False): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + rescale_samples: bool = False, + ): """Evaluate basis over samples.""" if rescale_samples: # note that sample points is converted to NDArray @@ -1720,7 +1778,9 @@ def __call__( If the sample provided do not lie in [0,1]. """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax, rescale_samples=rescale_samples) + return self._call( + sample_pts, vmin=self.vmin, vmax=self.vmax, rescale_samples=rescale_samples + ) def _compute_peaks(self) -> NDArray: """ @@ -1918,7 +1978,12 @@ def _compute_peaks(self) -> NDArray: last_peak = 1 return np.linspace(0, last_peak, self.n_basis_funcs) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ): """Evaluate basis over samples.""" return super()._call( self._transform_samples(sample_pts, vmin=vmin, vmax=vmax), @@ -2072,7 +2137,12 @@ def _check_sample_size(self, *sample_pts: NDArray) -> None: f"but only {sample_pts[0].size} samples provided!" ) - def _call(self, sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None): + def _call( + self, + sample_pts: NDArray, + vmin: Optional[float] = None, + vmax: Optional[float] = None, + ): """Evaluate basis over samples.""" self._check_sample_size(sample_pts) sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) diff --git a/tests/test_basis.py b/tests/test_basis.py index 06b74036..901833b0 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -573,20 +573,19 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, mn, mx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, None, np.arange(5), [4], 0, 1), + (None, 3, np.arange(5), [4], 2, 3), + (1, None, np.arange(5), [0], 1, 2), + (1, 3, np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) - mn = 0 if vmin is None else vmin - mx = mn + 1 if vmax is None else vmax assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( @@ -1054,20 +1053,19 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, mn, mx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, None, np.arange(5), [4], 0, 1), + (None, 3, np.arange(5), [4], 2, 3), + (1, None, np.arange(5), [0], 1, 2), + (1, 3, np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) - mn = 0 if vmin is None else vmin - mx = mn + 1 if vmax is None else vmax assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( @@ -1520,35 +1518,38 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): assert np.all(~np.isnan(out[valid_idx])) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, scaling", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, 3, np.arange(5), [4], 1), + (1, None, np.arange(5), [0], 1), + (1, 3, np.arange(5), [0, 4], 2) ] ) - def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, vmin, vmax, samples, nan_idx, scaling): + """Check that the MSpline has the expected scaling property.""" bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) - assert np.allclose(out1, out2) + # multiply by scaling to get the invariance + # mspline must integrate to one, if the support + # is reduced, the height of the spline increases. + assert np.allclose(out1 * scaling, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, mn, mx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, None, np.arange(5), [4], 0, 1), + (None, 3, np.arange(5), [4], 2, 3), + (1, None, np.arange(5), [0], 1, 2), + (1, 3, np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) - mn = 0 if vmin is None else vmin - mx = mn + 1 if vmax is None else vmax assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( @@ -2481,20 +2482,19 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, mn, mx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, None, np.arange(5), [4], 0, 1), + (None, 3, np.arange(5), [4], 2, 3), + (1, None, np.arange(5), [0], 1, 2), + (1, 3, np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) - mn = 0 if vmin is None else vmin - mx = mn + 1 if vmax is None else vmax assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( @@ -3002,20 +3002,19 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "vmin, vmax, samples, nan_idx, mn, mx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, None, np.arange(5), [4], 0, 1), # default to 0, 1 by convention of Basis._get_samples + (None, 3, np.arange(5), [4], 2, 3), # mn is mx - 1 by convention of Basis._get_samples + (1, None, np.arange(5), [0], 1, 2), # mx is mn + 1 by convention of Basis._get_samples + (1, 3, np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx): + def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) - mn = 0 if vmin is None else vmin - mx = mn + 1 if vmax is None else vmax assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( From fe9a65d932c427985aefbcad24c5cc09fb787737 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 17 Jul 2024 18:53:24 -0400 Subject: [PATCH 059/225] bugfix cosyne --- docs/background/plot_01_1D_basis_function.py | 44 +++++++++------ src/nemos/basis.py | 56 ++++++++++---------- 2 files changed, 57 insertions(+), 43 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index 4a892b6b..d43f4494 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -16,34 +16,35 @@ import numpy as np import nemos as nmo +import pynapple as nap # Initialize hyperparameters order = 4 n_basis = 10 # Define the 1D basis function object -mspline_basis = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, order=order) +bspline = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order) # %% -# Evaluating a Basis -# ------------------------------------ +# ## Evaluating a Basis +# # The `Basis` object is callable, and can be evaluated as a function. By default, the support of the basis -# is defined by the samples that we input to the `__call__` method. This results in an equi-spaced set of knots, +# is defined by the samples that we input to the `__call__` method. This results in an equispaced set of knots, # which spans the range from the smallest to the largest sample. These knots are then used to construct a # uniformly spaced basis. -# Generate an array of sample points -samples = np.linspace(0, 10,1000) +# Generate a time series of sample points +samples = nap.Tsd(t=np.arange(1001), d=np.linspace(0, 1,1001)) # Evaluate the basis at the sample points -eval_basis = mspline_basis(samples) +eval_basis = bspline(samples) # Output information about the evaluated basis -print(f"Evaluated M-spline of order {order} with {eval_basis.shape[1]} " +print(f"Evaluated B-spline of order {order} with {eval_basis.shape[1]} " f"basis element and {eval_basis.shape[0]} samples.") plt.figure() -plt.title("MSpline basis") +plt.title("B-spline basis") plt.plot(eval_basis) # %% @@ -51,11 +52,22 @@ # You can specify a range for the support of the basis by setting the `vmin` and `vmax` # parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. -mspline_basis = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) +bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) -print(f"Within the domain: {np.round(mspline_basis(0.5), 3)}") -print(f"Outside the domain: {mspline_basis(0.1)}") +print(f"Within the domain: {np.round(bspline_range(0.5), 3)}") +print(f"Outside the domain: {bspline_range(0.1)}") +# %% +# Let's compare the default behavior of basis (estimating the range from the samples) with +# the fixed range basis. + +fig, axs = plt.subplots(2,1, sharex=True) +plt.suptitle("B-spline basis ") +axs[0].plot(bspline(samples), color="k") +axs[0].set_title("default") +axs[1].plot(bspline_range(samples), color="tomato") +axs[1].set_title("range=[0.2, 0.8]") +plt.tight_layout() # %% # ## Basis `mode` @@ -68,8 +80,8 @@ # # Let's see how this two modalities operate. -eval_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="eval") -conv_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) +eval_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="eval") +conv_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) # define an input angles = np.linspace(0, np.pi*4, 201) @@ -122,11 +134,11 @@ # Call evaluate on grid on 100 sample points to generate samples and evaluate the basis at those samples n_samples = 100 -equispaced_samples, eval_basis = mspline_basis.evaluate_on_grid(n_samples) +equispaced_samples, eval_basis = bspline.evaluate_on_grid(n_samples) # Plot each basis element plt.figure() -plt.title(f"M-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") +plt.title(f"B-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") plt.plot(equispaced_samples, eval_basis) plt.show() diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 09aaa627..c8b40e16 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1083,21 +1083,11 @@ def _generate_knots( if is_cyclic: num_interior_knots += self.order - 1 - assert 0 <= perc_low <= 1, "Specify `perc_low` as a float between 0 and 1." - assert 0 <= perc_high <= 1, "Specify `perc_high` as a float between 0 and 1." - assert perc_low < perc_high, "perc_low must be less than perc_high." - - # clip to avoid numerical errors in case of percentile numerical precision close to 0 and 1 - # Spline basis have support on the semi-open [a, b) interval, we add a small epsilon - # to mx so that the so that basis_element(max(samples)) != 0 - mn = np.nanpercentile(sample_pts, np.clip(perc_low * 100, 0, 100)) - mx = np.nanpercentile(sample_pts, np.clip(perc_high * 100, 0, 100)) + 10**-8 - knot_locs = np.concatenate( ( - mn * np.ones(self.order - 1), - np.linspace(mn, mx, num_interior_knots + 2), - mx * np.ones(self.order - 1), + np.zeros(self.order - 1), + np.linspace(0, (1 + 10**-8), num_interior_knots + 2), + np.full(self.order - 1, 1 + 10**-8), ) ) return knot_locs @@ -1379,9 +1369,14 @@ def _call( # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) - basis_eval = bspline( - sample_pts, knot_locs, order=self.order, der=0, outer_ok=False - ) + valid = ~np.isnan(sample_pts) + if np.any(valid): + basis_eval = bspline( + sample_pts, knot_locs, order=self.order, der=0, outer_ok=False + ) + else: + basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) + if self.identifiability_constraints: basis_eval = self._apply_identifiability_constraints(basis_eval) return basis_eval @@ -1545,19 +1540,26 @@ def _call( knot_locs, ) ) - ind = sample_pts > xc - basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) - sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] + valid = ~np.isnan(sample_pts) + if np.any(valid): + ind = sample_pts > xc + + basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) + sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] + + if np.sum(ind): + basis_eval[ind] = basis_eval[ind] + bspline( + sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 + ) + # restore points + sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] + if self.identifiability_constraints: + basis_eval = self._apply_identifiability_constraints(basis_eval) + else: + # deal with the case of no valid samples + basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) - if np.sum(ind): - basis_eval[ind] = basis_eval[ind] + bspline( - sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 - ) - # restore points - sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] - if self.identifiability_constraints: - basis_eval = self._apply_identifiability_constraints(basis_eval) return basis_eval @support_pynapple(conv_type="numpy") From fb84bf29a28ab6d9b39c04f002c7cb369132c0a4 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 12:30:50 -0400 Subject: [PATCH 060/225] changes --- docs/background/plot_01_1D_basis_function.py | 56 +++--------- src/nemos/basis.py | 34 +++++++- src/nemos/validation.py | 60 +++++++++++++ tests/test_basis.py | 89 ++++++++++++++++++++ 4 files changed, 196 insertions(+), 43 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index d43f4494..0b58871b 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -16,59 +16,31 @@ import numpy as np import nemos as nmo -import pynapple as nap # Initialize hyperparameters order = 4 n_basis = 10 # Define the 1D basis function object -bspline = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order) +mspline_basis = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, order=order) # %% -# ## Evaluating a Basis -# -# The `Basis` object is callable, and can be evaluated as a function. By default, the support of the basis -# is defined by the samples that we input to the `__call__` method. This results in an equispaced set of knots, -# which spans the range from the smallest to the largest sample. These knots are then used to construct a -# uniformly spaced basis. +# Evaluating a Basis +# ------------------------------------ +# The `Basis` object is callable, and can be evaluated as a function. For `SplineBasis`, the domain is defined by +# the samples that we input to the `__call__` method. This results in an equi-spaced set of knots, which spans +# the range from the smallest to the largest sample. These knots are then used to construct a uniformly spaced basis. -# Generate a time series of sample points -samples = nap.Tsd(t=np.arange(1001), d=np.linspace(0, 1,1001)) +# Generate an array of sample points +samples = np.random.uniform(0, 10, size=1000) # Evaluate the basis at the sample points -eval_basis = bspline(samples) +eval_basis = mspline_basis(samples) # Output information about the evaluated basis -print(f"Evaluated B-spline of order {order} with {eval_basis.shape[1]} " +print(f"Evaluated M-spline of order {order} with {eval_basis.shape[1]} " f"basis element and {eval_basis.shape[0]} samples.") -plt.figure() -plt.title("B-spline basis") -plt.plot(eval_basis) - -# %% -# ## Setting the basis support -# You can specify a range for the support of the basis by setting the `vmin` and `vmax` -# parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. - -bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) - -print(f"Within the domain: {np.round(bspline_range(0.5), 3)}") -print(f"Outside the domain: {bspline_range(0.1)}") - -# %% -# Let's compare the default behavior of basis (estimating the range from the samples) with -# the fixed range basis. - -fig, axs = plt.subplots(2,1, sharex=True) -plt.suptitle("B-spline basis ") -axs[0].plot(bspline(samples), color="k") -axs[0].set_title("default") -axs[1].plot(bspline_range(samples), color="tomato") -axs[1].set_title("range=[0.2, 0.8]") -plt.tight_layout() - # %% # ## Basis `mode` # In constructing features, `Basis` objects can be used in two modalities: `"eval"` for evaluate or `"conv"` @@ -80,8 +52,8 @@ # # Let's see how this two modalities operate. -eval_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="eval") -conv_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) +eval_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="eval") +conv_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) # define an input angles = np.linspace(0, np.pi*4, 201) @@ -134,11 +106,11 @@ # Call evaluate on grid on 100 sample points to generate samples and evaluate the basis at those samples n_samples = 100 -equispaced_samples, eval_basis = bspline.evaluate_on_grid(n_samples) +equispaced_samples, eval_basis = mspline_basis.evaluate_on_grid(n_samples) # Plot each basis element plt.figure() -plt.title(f"B-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") +plt.title(f"M-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") plt.plot(equispaced_samples, eval_basis) plt.show() diff --git a/src/nemos/basis.py b/src/nemos/basis.py index c8b40e16..9c97925e 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -3,6 +3,8 @@ # required to get ArrayLike to render correctly from __future__ import annotations +from functools import wraps + import abc from typing import Callable, Generator, Literal, Optional, Tuple, Union @@ -15,6 +17,7 @@ from .convolve import create_convolutional_predictor from .type_casting import support_pynapple from .utils import row_wise_kron +from .validation import check_fraction_valid_samples FeatureMatrix = Union[NDArray, TsdFrame] @@ -49,6 +52,7 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs) -> NDArray: def check_one_dimensional(func: Callable) -> Callable: + @wraps(func) def wrapper(self: Basis, *xi: ArrayLike, **kwargs): if any(x.ndim != 1 for x in xi): raise ValueError("Input sample must be one dimensional!") @@ -60,7 +64,28 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): def min_max_rescale_samples( sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None ) -> Tuple[NDArray, float]: - """Rescale samples to [0,1].""" + """Rescale samples to [0,1]. + + Parameters + ---------- + sample_pts: + The original samples. + vmin: + Sample value that is mapped to 0. Default is `min(sample_pts)`. + vmax: + Sample value that is mapped to 1. Default is `max(sample_pts)`. + + Raises + ------ + ValueError + If all the samples contain invalid entries (either NaN or Inf). + This may happen if `max(sample) < vmin` or `min(sample) > vmax`. + + Warns + ----- + UserWarning + If more than 90% of the sample points contain NaNs or Infs. + """ sample_pts = sample_pts.astype(float) if vmin and vmax and vmax <= vmin: raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") @@ -73,6 +98,13 @@ def min_max_rescale_samples( sample_pts /= scaling else: scaling = 1.0 + + check_fraction_valid_samples( + sample_pts, + err_msg="All the samples lie outside the [vmin, vmax] range.", + warn_msg="More than 90% of the samples lie outside the [vmin, vmax] range." + ) + return sample_pts, scaling diff --git a/src/nemos/validation.py b/src/nemos/validation.py index 54be4f25..b23f5ca2 100644 --- a/src/nemos/validation.py +++ b/src/nemos/validation.py @@ -1,5 +1,8 @@ """Collection of methods utilities.""" +import warnings +from functools import wraps +import warnings from typing import Any, Optional, Union import jax @@ -10,6 +13,33 @@ from .tree_utils import get_valid_multitree, pytree_map_and_reduce +def warn_invalid_entry(*pytree: Any): + """ + Warns if any entry in the provided pytrees contains NaN or Infinite (Inf) values. + + Parameters + ---------- + *pytree : + Variable number of pytrees to check for invalid entries. A pytree is a nested structure of lists, tuples, + dictionaries, or other containers, with leaves that are arrays. + + """ + any_infs = pytree_map_and_reduce( + jnp.any, any, jax.tree_util.tree_map(jnp.isinf, pytree) + ) + any_nans = pytree_map_and_reduce( + jnp.any, any, jax.tree_util.tree_map(jnp.isnan, pytree) + ) + if any_infs and any_nans: + warnings.warn( + message="The provided trees contain Infs and Nans!", category=UserWarning + ) + elif any_infs: + warnings.warn(message="The provided trees contain Infs!", category=UserWarning) + elif any_nans: + warnings.warn(message="The provided trees contain Nans!", category=UserWarning) + + def error_invalid_entry(*pytree: Any): """ Raise an error if any entry in the provided pytrees contains NaN or Infinite (Inf) values. @@ -275,3 +305,33 @@ def check_tree_structure(tree_1: Any, tree_2: Any, err_message: str): """ if jax.tree_util.tree_structure(tree_1) != jax.tree_util.tree_structure(tree_2): raise TypeError(err_message) + + +def check_fraction_valid_samples(*tree: Any, err_msg: str, warn_msg: str) -> None: + """ + Check the fraction of valid entries. + + Parameters + ---------- + *tree : + Trees containing arrays with the same sample axis. + err_msg : + The exception message. + warn_msg : + The warning message. + + Raises + ------ + ValueError + If all the samples contain invalid entries (either NaN or Inf). + + Warns + ----- + UserWarning + If more than 90% of the sample points contain NaNs or Infs. + """ + valid = get_valid_multitree(tree) + if all(~valid): + raise ValueError(err_msg) + elif valid.mean() <= 0.1: + warnings.warn(warn_msg, UserWarning) diff --git a/tests/test_basis.py b/tests/test_basis.py index 901833b0..e1fea059 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -845,6 +845,21 @@ def test_call_equivalent_in_conv(self): x = np.linspace(0, 1, 10) assert np.all(bas_con(x) == bas_eva(x)) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) def test_pynapple_support(self, mode, window_size): bas = self.cls(5, mode=mode, window_size=window_size) @@ -1330,6 +1345,21 @@ def test_call_equivalent_in_conv(self): x = np.linspace(0, 1, 10) assert np.all(bas_con(x) == bas_eva(x)) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) def test_pynapple_support(self, mode, window_size): bas = self.cls(5, mode=mode, window_size=window_size) @@ -1858,6 +1888,20 @@ def test_call_equivalent_in_conv(self): x = np.linspace(0, 1, 10) assert np.all(bas_con(x) == bas_eva(x)) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (np.linspace(-1,-0.5, 10), 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, decay_rates=np.linspace(0,1,5), vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) def test_pynapple_support(self, mode, window_size): bas = self.cls( @@ -2277,6 +2321,21 @@ def test_call_equivalent_in_conv(self): x = np.linspace(0, 1, 10) assert np.all(bas_con(x) == bas_eva(x)) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) def test_pynapple_support(self, mode, window_size): bas = self.cls(5, mode=mode, window_size=window_size) @@ -2797,6 +2856,36 @@ def test_call_equivalent_in_conv(self): x = np.linspace(0, 1, 10) assert np.all(bas_con(x) == bas_eva(x)) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1,1,10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 3)]) def test_pynapple_support(self, mode, window_size): bas = self.cls(5, mode=mode, window_size=window_size) From 874b1f1869ee9da60e97b9799dd1bef65a5257b8 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 12:33:52 -0400 Subject: [PATCH 061/225] reverted changes --- docs/background/plot_01_1D_basis_function.py | 56 +++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index 0b58871b..d43f4494 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -16,31 +16,59 @@ import numpy as np import nemos as nmo +import pynapple as nap # Initialize hyperparameters order = 4 n_basis = 10 # Define the 1D basis function object -mspline_basis = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, order=order) +bspline = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order) # %% -# Evaluating a Basis -# ------------------------------------ -# The `Basis` object is callable, and can be evaluated as a function. For `SplineBasis`, the domain is defined by -# the samples that we input to the `__call__` method. This results in an equi-spaced set of knots, which spans -# the range from the smallest to the largest sample. These knots are then used to construct a uniformly spaced basis. +# ## Evaluating a Basis +# +# The `Basis` object is callable, and can be evaluated as a function. By default, the support of the basis +# is defined by the samples that we input to the `__call__` method. This results in an equispaced set of knots, +# which spans the range from the smallest to the largest sample. These knots are then used to construct a +# uniformly spaced basis. -# Generate an array of sample points -samples = np.random.uniform(0, 10, size=1000) +# Generate a time series of sample points +samples = nap.Tsd(t=np.arange(1001), d=np.linspace(0, 1,1001)) # Evaluate the basis at the sample points -eval_basis = mspline_basis(samples) +eval_basis = bspline(samples) # Output information about the evaluated basis -print(f"Evaluated M-spline of order {order} with {eval_basis.shape[1]} " +print(f"Evaluated B-spline of order {order} with {eval_basis.shape[1]} " f"basis element and {eval_basis.shape[0]} samples.") +plt.figure() +plt.title("B-spline basis") +plt.plot(eval_basis) + +# %% +# ## Setting the basis support +# You can specify a range for the support of the basis by setting the `vmin` and `vmax` +# parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. + +bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) + +print(f"Within the domain: {np.round(bspline_range(0.5), 3)}") +print(f"Outside the domain: {bspline_range(0.1)}") + +# %% +# Let's compare the default behavior of basis (estimating the range from the samples) with +# the fixed range basis. + +fig, axs = plt.subplots(2,1, sharex=True) +plt.suptitle("B-spline basis ") +axs[0].plot(bspline(samples), color="k") +axs[0].set_title("default") +axs[1].plot(bspline_range(samples), color="tomato") +axs[1].set_title("range=[0.2, 0.8]") +plt.tight_layout() + # %% # ## Basis `mode` # In constructing features, `Basis` objects can be used in two modalities: `"eval"` for evaluate or `"conv"` @@ -52,8 +80,8 @@ # # Let's see how this two modalities operate. -eval_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="eval") -conv_mode = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) +eval_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="eval") +conv_mode = nmo.basis.MSplineBasis(n_basis_funcs=n_basis, mode="conv", window_size=100) # define an input angles = np.linspace(0, np.pi*4, 201) @@ -106,11 +134,11 @@ # Call evaluate on grid on 100 sample points to generate samples and evaluate the basis at those samples n_samples = 100 -equispaced_samples, eval_basis = mspline_basis.evaluate_on_grid(n_samples) +equispaced_samples, eval_basis = bspline.evaluate_on_grid(n_samples) # Plot each basis element plt.figure() -plt.title(f"M-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") +plt.title(f"B-spline basis with {eval_basis.shape[1]} elements\nevaluated at {eval_basis.shape[0]} sample points") plt.plot(equispaced_samples, eval_basis) plt.show() From 54a587260730bfcdeb6a8a20ec2c05d8f9e1aaf1 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 12:35:48 -0400 Subject: [PATCH 062/225] removed unused --- src/nemos/validation.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/nemos/validation.py b/src/nemos/validation.py index b23f5ca2..f9d0fefa 100644 --- a/src/nemos/validation.py +++ b/src/nemos/validation.py @@ -13,33 +13,6 @@ from .tree_utils import get_valid_multitree, pytree_map_and_reduce -def warn_invalid_entry(*pytree: Any): - """ - Warns if any entry in the provided pytrees contains NaN or Infinite (Inf) values. - - Parameters - ---------- - *pytree : - Variable number of pytrees to check for invalid entries. A pytree is a nested structure of lists, tuples, - dictionaries, or other containers, with leaves that are arrays. - - """ - any_infs = pytree_map_and_reduce( - jnp.any, any, jax.tree_util.tree_map(jnp.isinf, pytree) - ) - any_nans = pytree_map_and_reduce( - jnp.any, any, jax.tree_util.tree_map(jnp.isnan, pytree) - ) - if any_infs and any_nans: - warnings.warn( - message="The provided trees contain Infs and Nans!", category=UserWarning - ) - elif any_infs: - warnings.warn(message="The provided trees contain Infs!", category=UserWarning) - elif any_nans: - warnings.warn(message="The provided trees contain Nans!", category=UserWarning) - - def error_invalid_entry(*pytree: Any): """ Raise an error if any entry in the provided pytrees contains NaN or Infinite (Inf) values. From 6d92592aa6da5253cfaee3e527532cb45c076ef2 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 15:07:58 -0400 Subject: [PATCH 063/225] renamed tests --- tests/test_basis.py | 208 ++++++++++++++++++++++---------------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index e1fea059..c615eca9 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -82,7 +82,7 @@ def test_non_empty_samples(self, samples, mode, window_size): @pytest.mark.parametrize( "eval_input", [0, [0], (0,), np.array([0]), jax.numpy.array([0])] ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ Checks that the sample size of the output from the evaluate() method matches the input sample size. """ @@ -94,7 +94,7 @@ def test_fit_transform_input(self, eval_input): [[{"n_basis_funcs": n_basis}, 100] for n_basis in [2, 10, 100]], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, args, mode, window_size, sample_size ): """ @@ -114,7 +114,7 @@ def test_fit_transform_returns_expected_number_of_basis( @pytest.mark.parametrize("sample_size", [100, 1000]) @pytest.mark.parametrize("n_basis_funcs", [2, 10, 100]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, mode, window_size ): """ @@ -154,9 +154,9 @@ def test_minimum_number_of_basis_required_is_matched( @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): """ - Confirms that the fit_transform() method correctly handles the number of input samples that are provided. + Confirms that the compute_features() method correctly handles the number of input samples that are provided. """ basis_obj = self.cls(n_basis_funcs=5, mode=mode, window_size=window_size) inputs = [np.linspace(0, 1, 20)] * n_input @@ -306,7 +306,7 @@ def test_time_scaling_property(self): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -623,9 +623,9 @@ def test_non_empty_samples(self, samples, mode, window_size): @pytest.mark.parametrize( "eval_input", [0, [0], (0,), np.array([0]), jax.numpy.array([0])] ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls(n_basis_funcs=5) basis_obj.compute_features(eval_input) @@ -635,30 +635,30 @@ def test_fit_transform_input(self, eval_input): [[{"n_basis_funcs": n_basis}, 100] for n_basis in [2, 10, 100]], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, args, mode, window_size, sample_size ): """ - Verifies that the fit_transform() method returns the expected number of basis functions. + Verifies that the compute_features() method returns the expected number of basis functions. """ basis_obj = self.cls(mode=mode, window_size=window_size, **args) eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[1] != args["n_basis_funcs"]: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {args['n_basis_funcs']}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features is {eval_basis.shape[1]}", ) return @pytest.mark.parametrize("sample_size", [100, 1000]) @pytest.mark.parametrize("n_basis_funcs", [2, 10, 100]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, mode, window_size ): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the co ute_features() method matches the input sample size. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, mode=mode, window_size=window_size @@ -666,9 +666,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features basis is {eval_basis.shape[0]}", ) @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @@ -694,9 +694,9 @@ def test_minimum_number_of_basis_required_is_matched( @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): """ - Confirms that the fit_transform() method correctly handles the number of input samples that are provided. + Confirms that the compute_features() method correctly handles the number of input samples that are provided. """ basis_obj = self.cls(n_basis_funcs=5, mode=mode, window_size=window_size) inputs = [np.linspace(0, 1, 20)] * n_input @@ -787,7 +787,7 @@ def test_width_values(self, width, expectation): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -1118,9 +1118,9 @@ def test_non_empty_samples(self, samples, mode, window_size): @pytest.mark.parametrize( "eval_input", [0, [0], (0,), np.array([0]), jax.numpy.array([0])] ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls(n_basis_funcs=5) basis_obj.compute_features(eval_input) @@ -1128,11 +1128,11 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_funcs", [6, 8, 10]) @pytest.mark.parametrize("order", range(1, 6)) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_funcs: int, order: int, mode, window_size ): """ - Verifies that the fit_transform() method returns the expected number of basis functions. + Verifies that the compute_features() method returns the expected number of basis functions. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -1140,20 +1140,20 @@ def test_fit_transform_returns_expected_number_of_basis( eval_basis = basis_obj.compute_features(np.linspace(0, 1, 100)) if eval_basis.shape[1] != n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_funcs}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features is {eval_basis.shape[1]}", ) @pytest.mark.parametrize("sample_size", [100, 1000]) @pytest.mark.parametrize("n_basis_funcs", [4, 10, 100]) @pytest.mark.parametrize("order", [1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, order, mode, window_size ): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -1161,9 +1161,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features is {eval_basis.shape[0]}", ) @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @@ -1202,20 +1202,20 @@ def test_minimum_number_of_basis_required_is_matched( @pytest.mark.parametrize( "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] ) - def test_samples_range_matches_fit_transform_requirements( + def test_samples_range_matches_compute_features_requirements( self, sample_range: tuple ): """ - Verifies that the fit_transform() method can handle input range. + Verifies that the compute_features() method can handle input range. """ basis_obj = self.cls(n_basis_funcs=5, order=3) basis_obj.compute_features(np.linspace(*sample_range, 100)) @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): """ - Confirms that the fit_transform() method correctly handles the number of input samples that are provided. + Confirms that the compute_features() method correctly handles the number of input samples that are provided. """ basis_obj = self.cls( n_basis_funcs=5, order=3, mode=mode, window_size=window_size @@ -1287,7 +1287,7 @@ def test_evaluate_on_grid_input_number(self, n_input): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -1621,9 +1621,9 @@ def test_non_empty_samples(self, samples, mode, window_size): "eval_input", [0, [0] * 6, (0,) * 6, np.array([0] * 6), jax.numpy.array([0] * 6)], ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls(n_basis_funcs=5, decay_rates=np.arange(1, 6)) if isinstance(eval_input, int): @@ -1639,7 +1639,7 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_funcs", [1, 2, 4, 8]) @pytest.mark.parametrize("sample_size", [10, 1000]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_funcs, sample_size, mode, window_size ): """Tests whether the evaluate method returns the expected number of basis functions.""" @@ -1653,19 +1653,19 @@ def test_fit_transform_returns_expected_number_of_basis( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[1] != n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_funcs}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features basis is {eval_basis.shape[1]}", ) return @pytest.mark.parametrize("sample_size", [100, 1000]) @pytest.mark.parametrize("n_basis_funcs", [2, 10, 12]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 30)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, mode, window_size ): - """Tests whether the sample size of the fit_transformed result matches that of the input.""" + """Tests whether the sample size of the features result matches that of the input.""" decay_rates = np.linspace(0.1, 20, n_basis_funcs) basis_obj = self.cls( n_basis_funcs=n_basis_funcs, @@ -1676,9 +1676,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features is {eval_basis.shape[0]}", ) @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @@ -1711,8 +1711,8 @@ def test_minimum_number_of_basis_required_is_matched( @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): - """Tests whether the fit_transform method correctly processes the number of required inputs.""" + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): + """Tests whether the compute_features method correctly processes the number of required inputs.""" basis_obj = self.cls( n_basis_funcs=5, decay_rates=np.arange(1, 6), @@ -1732,7 +1732,7 @@ def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_siz @pytest.mark.parametrize("sample_size", [-1, 0, 1, 2, 3, 4, 5, 6, 10, 11, 100]) def test_evaluate_on_grid_meshgrid_size(self, sample_size): - """Tests whether the fit_transform_on_grid method correctly outputs the grid mesh size.""" + """Tests whether the compute_features_on_grid method correctly outputs the grid mesh size.""" basis_obj = self.cls(n_basis_funcs=5, decay_rates=np.arange(1, 6)) raise_exception = sample_size < 5 if raise_exception: @@ -1820,7 +1820,7 @@ def test_decay_rate_size_match_n_basis_func(self, decay_rates, n_basis_func): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -2074,9 +2074,9 @@ def test_non_empty_samples(self, samples, mode, window_size): @pytest.mark.parametrize( "eval_input", [0, [0], (0,), np.array([0]), jax.numpy.array([0])] ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls(n_basis_funcs=5) basis_obj.compute_features(eval_input) @@ -2084,11 +2084,11 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_funcs", [6, 8, 10]) @pytest.mark.parametrize("order", range(1, 6)) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_funcs: int, order: int, mode, window_size ): """ - Verifies that the fit_transform() method returns the expected number of basis functions. + Verifies that the compute_features() method returns the expected number of basis functions. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -2096,9 +2096,9 @@ def test_fit_transform_returns_expected_number_of_basis( eval_basis = basis_obj.compute_features(np.linspace(0, 1, 100)) if eval_basis.shape[1] != n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_funcs}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features is {eval_basis.shape[1]}", ) return @@ -2106,11 +2106,11 @@ def test_fit_transform_returns_expected_number_of_basis( @pytest.mark.parametrize("n_basis_funcs", [4, 10, 100]) @pytest.mark.parametrize("order", [1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, order, mode, window_size ): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -2118,9 +2118,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features is {eval_basis.shape[0]}", ) @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @@ -2174,20 +2174,20 @@ def test_order_is_positive(self, n_basis_funcs, order): @pytest.mark.parametrize( "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] ) - def test_samples_range_matches_fit_transform_requirements( + def test_samples_range_matches_compute_features_requirements( self, sample_range: tuple ): """ - Verifies that the fit_transform() method can handle input range. + Verifies that the compute_features() method can handle input range. """ basis_obj = self.cls(n_basis_funcs=5, order=3) basis_obj.compute_features(np.linspace(*sample_range, 100)) @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): """ - Confirms that the fit_transform() method correctly handles the number of input samples that are provided. + Confirms that the compute_features() method correctly handles the number of input samples that are provided. """ basis_obj = self.cls( n_basis_funcs=5, order=3, mode=mode, window_size=window_size @@ -2206,7 +2206,7 @@ def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_siz @pytest.mark.parametrize("sample_size", [-1, 0, 1, 10, 11, 100]) def test_evaluate_on_grid_meshgrid_size(self, sample_size): """ - Checks that the fit_transform_on_grid() method returns a grid of the expected size. + Checks that the evaluate_on_grid() method returns a grid of the expected size. """ basis_obj = self.cls(n_basis_funcs=5, order=3) raise_exception = sample_size <= 0 @@ -2263,7 +2263,7 @@ def test_evaluate_on_grid_input_number(self, n_input): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -2591,9 +2591,9 @@ def test_non_empty_samples(self, samples, mode, window_size): @pytest.mark.parametrize( "eval_input", [0, [0], (0,), np.array([0]), jax.numpy.array([0])] ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls(n_basis_funcs=5) basis_obj.compute_features(eval_input) @@ -2601,11 +2601,11 @@ def test_fit_transform_input(self, eval_input): @pytest.mark.parametrize("n_basis_funcs", [8, 10]) @pytest.mark.parametrize("order", range(2, 6)) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_funcs: int, order: int, mode, window_size ): """ - Verifies that the fit_transform() method returns the expected number of basis functions. + Verifies that the compute_features() method returns the expected number of basis functions. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -2613,9 +2613,9 @@ def test_fit_transform_returns_expected_number_of_basis( eval_basis = basis_obj.compute_features(np.linspace(0, 1, 100)) if eval_basis.shape[1] != n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_funcs}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The first dimension of the output features is {eval_basis.shape[0]}", ) return @@ -2623,11 +2623,11 @@ def test_fit_transform_returns_expected_number_of_basis( @pytest.mark.parametrize("n_basis_funcs", [8, 10, 100]) @pytest.mark.parametrize("order", [2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_funcs, sample_size, order, mode, window_size ): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = self.cls( n_basis_funcs=n_basis_funcs, order=order, mode=mode, window_size=window_size @@ -2635,9 +2635,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( eval_basis = basis_obj.compute_features(np.linspace(0, 1, sample_size)) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The second dimension of the output features is {eval_basis.shape[1]}", ) @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @@ -2709,20 +2709,20 @@ def test_order_1_invalid(self, n_basis_funcs, order): @pytest.mark.parametrize( "sample_range", [(0, 1), (0.1, 0.9), (-0.5, 1), (0, 1.5), (-0.5, 1.5)] ) - def test_samples_range_matches_fit_transform_requirements( + def test_samples_range_matches_compute_features_requirements( self, sample_range: tuple ): """ - Verifies that the fit_transform() method can handle input range. + Verifies that the compute_features() method can handle input range. """ basis_obj = self.cls(n_basis_funcs=5, order=3) basis_obj.compute_features(np.linspace(*sample_range, 100)) @pytest.mark.parametrize("n_input", [0, 1, 2, 3]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_number_of_required_inputs_fit_transform(self, n_input, mode, window_size): + def test_number_of_required_inputs_compute_features(self, n_input, mode, window_size): """ - Confirms that the fit_transform() method correctly handles the number of input samples that are provided. + Confirms that the compute_features() method correctly handles the number of input samples that are provided. """ basis_obj = self.cls( n_basis_funcs=5, order=3, mode=mode, window_size=window_size @@ -2798,7 +2798,7 @@ def test_evaluate_on_grid_input_number(self, n_input): @pytest.mark.parametrize("sample_size", [30]) @pytest.mark.parametrize("n_basis", [5]) - def test_pynapple_support_fit_transform(self, n_basis, sample_size): + def test_pynapple_support_compute_features(self, n_basis, sample_size): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) inp = nap.Tsd( t=np.linspace(0, 1, sample_size), @@ -3205,9 +3205,9 @@ def test_non_empty_samples(self, samples, mode, ws): [jax.numpy.array([0]), [0]], ], ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = basis.MSplineBasis(5) + basis.MSplineBasis(5) basis_obj.compute_features(*eval_input) @@ -3224,7 +3224,7 @@ def test_fit_transform_input(self, eval_input): [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size ): """ @@ -3245,9 +3245,9 @@ def test_fit_transform_returns_expected_number_of_basis( ) if eval_basis.shape[1] != basis_a_obj.n_basis_funcs + basis_b_obj.n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_a + n_basis_b}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features is {eval_basis.shape[1]}", ) @pytest.mark.parametrize("sample_size", [100, 1000]) @@ -3262,11 +3262,11 @@ def test_fit_transform_returns_expected_number_of_basis( [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size ): """ - Test whether the output sample size from the `AdditiveBasis` fit_transform function matches the input sample size. + Test whether the output sample size from the `AdditiveBasis` compute_features function matches the input sample size. """ basis_a_obj = self.instantiate_basis( n_basis_a, basis_a, mode=mode, window_size=window_size @@ -3280,9 +3280,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( ) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features basis." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features basis is {eval_basis.shape[0]}", ) @pytest.mark.parametrize( @@ -3297,11 +3297,11 @@ def test_sample_size_of_fit_transform_matches_that_of_input( @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_number_of_required_inputs_fit_transform( + def test_number_of_required_inputs_compute_features( self, n_input, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_size ): """ - Test whether the number of required inputs for the `fit_transform` function matches + Test whether the number of required inputs for the `compute_features` function matches the sum of the number of input samples from the two bases. """ basis_a_obj = self.instantiate_basis( @@ -3420,7 +3420,7 @@ def test_evaluate_on_grid_input_number( "basis_b", [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) - def test_pynapple_support_fit_transform( + def test_pynapple_support_compute_features( self, basis_a, basis_b, n_basis_a, n_basis_b, sample_size ): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) @@ -3432,7 +3432,7 @@ def test_pynapple_support_fit_transform( basis_add = self.instantiate_basis(n_basis_a, basis_a) + self.instantiate_basis( n_basis_b, basis_b ) - # fit_transform the basis over pynapple Tsd objects + # compute_features the basis over pynapple Tsd objects out = basis_add.compute_features(*([inp] * basis_add._n_input_dimensionality)) # check type assert isinstance(out, nap.TsdFrame) @@ -3808,9 +3808,9 @@ def test_non_empty_samples(self, samples, mode, ws): [jax.numpy.array([0]), [0]], ], ) - def test_fit_transform_input(self, eval_input): + def test_compute_features_input(self, eval_input): """ - Checks that the sample size of the output from the fit_transform() method matches the input sample size. + Checks that the sample size of the output from the compute_features() method matches the input sample size. """ basis_obj = basis.MSplineBasis(5) * basis.MSplineBasis(5) basis_obj.compute_features(*eval_input) @@ -3827,7 +3827,7 @@ def test_fit_transform_input(self, eval_input): [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_fit_transform_returns_expected_number_of_basis( + def test_compute_features_returns_expected_number_of_basis( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size ): """ @@ -3848,9 +3848,9 @@ def test_fit_transform_returns_expected_number_of_basis( ) if eval_basis.shape[1] != basis_a_obj.n_basis_funcs * basis_b_obj.n_basis_funcs: raise ValueError( - "Dimensions do not agree: The number of basis should match the first dimension of the fit_transformed basis." + "Dimensions do not agree: The number of basis should match the first dimension of the output features." f"The number of basis is {n_basis_a * n_basis_b}", - f"The first dimension of the fit_transformed basis is {eval_basis.shape[1]}", + f"The first dimension of the output features is {eval_basis.shape[1]}", ) @pytest.mark.parametrize("sample_size", [12, 30, 35]) @@ -3865,11 +3865,11 @@ def test_fit_transform_returns_expected_number_of_basis( [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_sample_size_of_fit_transform_matches_that_of_input( + def test_sample_size_of_compute_features_matches_that_of_input( self, n_basis_a, n_basis_b, sample_size, basis_a, basis_b, mode, window_size ): """ - Test whether the output sample size from the `MultiplicativeBasis` fit_transform function matches the input sample size. + Test whether the output sample size from the `MultiplicativeBasis` compute_features function matches the input sample size. """ basis_a_obj = self.instantiate_basis( n_basis_a, basis_a, mode=mode, window_size=window_size @@ -3883,9 +3883,9 @@ def test_sample_size_of_fit_transform_matches_that_of_input( ) if eval_basis.shape[0] != sample_size: raise ValueError( - f"Dimensions do not agree: The window size should match the second dimension of the fit_transformed basis." + f"Dimensions do not agree: The window size should match the second dimension of the output features." f"The window size is {sample_size}", - f"The second dimension of the fit_transformed basis is {eval_basis.shape[0]}", + f"The second dimension of the output features is {eval_basis.shape[0]}", ) @pytest.mark.parametrize( @@ -3900,11 +3900,11 @@ def test_sample_size_of_fit_transform_matches_that_of_input( @pytest.mark.parametrize("n_basis_a", [5, 6]) @pytest.mark.parametrize("n_basis_b", [5, 6]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 10)]) - def test_number_of_required_inputs_fit_transform( + def test_number_of_required_inputs_compute_features( self, n_input, n_basis_a, n_basis_b, basis_a, basis_b, mode, window_size ): """ - Test whether the number of required inputs for the `fit_transform` function matches + Test whether the number of required inputs for the `compute_features` function matches the sum of the number of input samples from the two bases. """ basis_a_obj = self.instantiate_basis( @@ -4021,7 +4021,7 @@ def test_evaluate_on_grid_input_number( def test_inconsistent_sample_sizes( self, basis_a, basis_b, n_basis_a, n_basis_b, sample_size_a, sample_size_b ): - """Test that the inputs of inconsistent sample sizes result in an exception when fit_transform is called""" + """Test that the inputs of inconsistent sample sizes result in an exception when compute_features is called""" raise_exception = sample_size_a != sample_size_b basis_a_obj = self.instantiate_basis(n_basis_a, basis_a) basis_b_obj = self.instantiate_basis(n_basis_b, basis_b) @@ -4050,7 +4050,7 @@ def test_inconsistent_sample_sizes( "basis_b", [class_obj for _, class_obj in utils_testing.get_non_abstract_classes(basis)], ) - def test_pynapple_support_fit_transform( + def test_pynapple_support_compute_features( self, basis_a, basis_b, n_basis_a, n_basis_b, sample_size ): iset = nap.IntervalSet(start=[0, 0.5], end=[0.49999, 1]) From ed3545ac83a0dc7386a9ddd38b1cac9c3423510e Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 15:17:27 -0400 Subject: [PATCH 064/225] fixed tests --- tests/test_basis.py | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_basis.py b/tests/test_basis.py index c615eca9..1007e4c7 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -131,6 +131,22 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the evaluated basis is {eval_basis.shape[0]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) def test_minimum_number_of_basis_required_is_matched( @@ -671,6 +687,21 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the output features basis is {eval_basis.shape[0]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) def test_minimum_number_of_basis_required_is_matched( @@ -1166,6 +1197,21 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the output features is {eval_basis.shape[0]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("order", [-1, 0, 1, 2, 3, 4, 5]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) @@ -1681,6 +1727,22 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the output features is {eval_basis.shape[0]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (np.linspace(-0.5, -0.001, 7), 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(1.5, 2., 7), 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + ([-0.5, -0.1, -0.01, 1.5, 2 , 3], 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax, decay_rates=np.linspace(0.1, 1, 5)) + with expectation: + bas(samples) + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 30)]) def test_minimum_number_of_basis_required_is_matched( @@ -2123,6 +2185,21 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the output features is {eval_basis.shape[0]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("order", [1, 2, 3, 4, 5]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) @@ -2640,6 +2717,21 @@ def test_sample_size_of_compute_features_matches_that_of_input( f"The second dimension of the output features is {eval_basis.shape[1]}", ) + @pytest.mark.parametrize( + "samples, vmin, vmax, expectation", + [ + (0.5, 0, 1, does_not_raise()), + (-0.5, 0, 1, pytest.raises(ValueError, match="All the samples lie outside")), + (np.linspace(-1, 1, 10), 0, 1, does_not_raise()), + (np.linspace(-1, 0, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + (np.linspace(1, 2, 10), 0, 1, pytest.warns(UserWarning, match="More than 90% of the samples")), + ] + ) + def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): + bas = self.cls(5, vmin=vmin, vmax=vmax) + with expectation: + bas(samples) + @pytest.mark.parametrize("n_basis_funcs", [-1, 0, 1, 3, 10, 20]) @pytest.mark.parametrize("order", [2, 3, 4, 5]) @pytest.mark.parametrize("mode, window_size", [("eval", None), ("conv", 2)]) From e83d2073c8df4c7abc30012c5e1bfade6dbead05 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 15:36:10 -0400 Subject: [PATCH 065/225] added tests for basis --- docs/background/plot_01_1D_basis_function.py | 6 +- src/nemos/basis.py | 2 +- tests/test_basis.py | 66 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index d43f4494..d0e3f61c 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -54,8 +54,10 @@ bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) -print(f"Within the domain: {np.round(bspline_range(0.5), 3)}") -print(f"Outside the domain: {bspline_range(0.1)}") +print("Evaluated basis:") +# 0.5 is within the support, 0.1 is outside the support +print(np.round(bspline_range([0.5, 0.1]), 3)) + # %% # Let's compare the default behavior of basis (estimating the range from the samples) with diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 9c97925e..ffa005ac 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -578,7 +578,7 @@ def _check_transform_input( # make sure array is at least 1d (so that we succeed when only # passed a scalar) xi = tuple(np.atleast_1d(np.asarray(x, dtype=float)) for x in xi) - except TypeError: + except (TypeError, ValueError): raise TypeError("Input samples must be array-like of floats!") # check for non-empty samples diff --git a/tests/test_basis.py b/tests/test_basis.py index 1007e4c7..ba71251d 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -374,6 +374,17 @@ def test_call_nan(self, mode, window_size): x[3] = np.nan assert all(np.isnan(bas(x)[3])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10) bas_eva = self.cls(5, mode="eval") @@ -870,6 +881,17 @@ def test_call_nan(self, mode, window_size): x[3] = np.nan assert all(np.isnan(bas(x)[3])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10) bas_eva = self.cls(5, mode="eval") @@ -1385,6 +1407,17 @@ def test_call_nan(self, mode, window_size): x[3] = np.nan assert all(np.isnan(bas(x)[3])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10) bas_eva = self.cls(5, mode="eval") @@ -1944,6 +1977,17 @@ def test_call_nan(self, mode, window_size): out = bas(x) assert np.all(np.isnan(out[13])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5, np.linspace(0.1, 1, 5)) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10, decay_rates=np.arange(1, 6)) bas_eva = self.cls(5, mode="eval", decay_rates=np.arange(1, 6)) @@ -2392,6 +2436,17 @@ def test_call_nan(self, mode, window_size): x[3] = np.nan assert all(np.isnan(bas(x)[3])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10) bas_eva = self.cls(5, mode="eval") @@ -2942,6 +2997,17 @@ def test_call_nan(self, mode, window_size): x[3] = np.nan assert all(np.isnan(bas(x)[3])) + @pytest.mark.parametrize( + "samples, expectation", + [ + (np.array([0, 1, 2, 3, 4, 5]), does_not_raise()), + (np.array(['a', '1', '2', '3', '4', '5']), pytest.raises(TypeError, match="Input samples must")), + ]) + def test_call_input_type(self, samples, expectation): + bas = self.cls(5) + with expectation: + bas(samples) + def test_call_equivalent_in_conv(self): bas_con = self.cls(5, mode="conv", window_size=10) bas_eva = self.cls(5, mode="eval") From b3f6b2b3a83fc83bd786466cfc53c4108ba0c1ef Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 15:37:37 -0400 Subject: [PATCH 066/225] linted --- src/nemos/basis.py | 9 +++++---- src/nemos/convolve.py | 1 - src/nemos/validation.py | 2 -- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index ffa005ac..322e30ea 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -3,9 +3,8 @@ # required to get ArrayLike to render correctly from __future__ import annotations -from functools import wraps - import abc +from functools import wraps from typing import Callable, Generator, Literal, Optional, Tuple, Union import numpy as np @@ -102,7 +101,7 @@ def min_max_rescale_samples( check_fraction_valid_samples( sample_pts, err_msg="All the samples lie outside the [vmin, vmax] range.", - warn_msg="More than 90% of the samples lie outside the [vmin, vmax] range." + warn_msg="More than 90% of the samples lie outside the [vmin, vmax] range.", ) return sample_pts, scaling @@ -1577,7 +1576,9 @@ def _call( if np.any(valid): ind = sample_pts > xc - basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) + basis_eval = bspline( + sample_pts, knots, order=self.order, der=0, outer_ok=True + ) sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] if np.sum(ind): diff --git a/src/nemos/convolve.py b/src/nemos/convolve.py index 9d7efb15..58a9b154 100644 --- a/src/nemos/convolve.py +++ b/src/nemos/convolve.py @@ -169,7 +169,6 @@ def _convolve_pad_and_shift( predictor : Predictor of with same shape and structure as `time_series` """ - # apply convolution def conv(x): return _shift_time_axis_and_convolve(x, basis_matrix, axis=axis) diff --git a/src/nemos/validation.py b/src/nemos/validation.py index f9d0fefa..289fbc52 100644 --- a/src/nemos/validation.py +++ b/src/nemos/validation.py @@ -1,6 +1,4 @@ """Collection of methods utilities.""" -import warnings -from functools import wraps import warnings from typing import Any, Optional, Union From 992a1e1cf0622a891540eae0e868427021bf478c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 15:57:26 -0400 Subject: [PATCH 067/225] simplified call logic --- src/nemos/basis.py | 380 ++++++++++++++++-------------------------- src/nemos/convolve.py | 1 + 2 files changed, 143 insertions(+), 238 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 322e30ea..aead58f6 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -485,17 +485,6 @@ def _set_kernel(self, *xi: ArrayLike) -> Basis: self._kernel = self.__call__(np.linspace(0, 1, self.window_size)) return self - @abc.abstractmethod - def _call( - self, - *xi: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - **kwargs, - ): - """Evaluate basis on samples.""" - pass - @abc.abstractmethod def __call__(self, *xi: ArrayLike) -> FeatureMatrix: """ @@ -809,28 +798,6 @@ def __init__(self, basis1: Basis, basis2: Basis) -> None: def _check_n_basis_min(self) -> None: pass - def _call( - self, - *xi: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - **kwargs, - ): - """Evaluate basis on samples.""" - X = np.hstack( - ( - self._basis1._call( - *xi[: self._basis1._n_input_dimensionality], vmin=vmin, vmax=vmax - ), - self._basis2._call( - *xi[self._basis1._n_input_dimensionality :], vmin=vmin, vmax=vmax - ), - ) - ) - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -850,7 +817,15 @@ def __call__(self, *xi: ArrayLike) -> FeatureMatrix: The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) """ - return self._call(*xi, vmin=self.vmin, vmax=self.vmax) + X = np.hstack( + ( + self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), + self._basis2.__call__(*xi[self._basis1._n_input_dimensionality :]), + ) + ) + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X @check_transform_input def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: @@ -953,25 +928,6 @@ def _set_kernel(self, *xi: NDArray) -> Basis: self._basis2._set_kernel(*xi) return self - def _call( - self, *xi: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None - ): - """Evaluate basis on samples.""" - X = np.asarray( - row_wise_kron( - self._basis1._call( - *xi[: self._basis1._n_input_dimensionality], vmin=vmin, vmax=vmax - ), - self._basis2._call( - *xi[self._basis1._n_input_dimensionality :], vmin=vmin, vmax=vmax - ), - transpose=False, - ) - ) - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -990,7 +946,16 @@ def __call__(self, *xi: ArrayLike) -> FeatureMatrix: : The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) """ - return self._call(*xi, vmin=self.vmin, vmax=self.vmax) + X = np.asarray( + row_wise_kron( + self._basis1.__call__(*xi[: self._basis1._n_input_dimensionality]), + self._basis2.__call__(*xi[self._basis1._n_input_dimensionality :]), + transpose=False, + ) + ) + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X @check_transform_input def _compute_features(self, *xi: ArrayLike) -> FeatureMatrix: @@ -1221,32 +1186,6 @@ def __init__( **kwargs, ) - def _call( - self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ): - """Evaluate basis over samples.""" - sample_pts, scaling = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - # add knots if not passed - knot_locs = self._generate_knots( - sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False - ) - - X = np.stack( - [ - mspline(sample_pts, self.order, i, knot_locs) - for i in range(self.n_basis_funcs) - ], - axis=1, - ) - # re-normalize so that it integrates to 1 over the range. - X /= scaling - if self.identifiability_constraints: - X = self._apply_identifiability_constraints(X) - return X - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -1276,7 +1215,27 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) + """Evaluate basis over samples.""" + sample_pts, scaling = min_max_rescale_samples( + sample_pts, vmin=self.vmin, vmax=self.vmax + ) + # add knots if not passed + knot_locs = self._generate_knots( + sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False + ) + + X = np.stack( + [ + mspline(sample_pts, self.order, i, knot_locs) + for i in range(self.n_basis_funcs) + ], + axis=1, + ) + # re-normalize so that it integrates to 1 over the range. + X /= scaling + if self.identifiability_constraints: + X = self._apply_identifiability_constraints(X) + return X def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """ @@ -1389,29 +1348,6 @@ def __init__( **kwargs, ) - def _call( - self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ): - """Evaluate basis over samples.""" - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - # add knots - knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) - - valid = ~np.isnan(sample_pts) - if np.any(valid): - basis_eval = bspline( - sample_pts, knot_locs, order=self.order, der=0, outer_ok=False - ) - else: - basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) - - if self.identifiability_constraints: - basis_eval = self._apply_identifiability_constraints(basis_eval) - return basis_eval - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -1426,12 +1362,6 @@ def __call__( ---------- sample_pts : The sample points at which the B-spline is evaluated, shape (n_samples,). - vmin: - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - This value determines the minimum of the domain of the basis. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - This value determines the maximum of the domain of the basis. Returns ------- @@ -1452,7 +1382,23 @@ def __call__( The evaluation is performed by looping over each element and using `splev` from SciPy to compute the basis values. """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) + sample_pts, _ = min_max_rescale_samples( + sample_pts, vmin=self.vmin, vmax=self.vmax + ) + # add knots + knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) + + valid = ~np.isnan(sample_pts) + if np.any(valid): + basis_eval = bspline( + sample_pts, knot_locs, order=self.order, der=0, outer_ok=False + ) + else: + basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) + + if self.identifiability_constraints: + basis_eval = self._apply_identifiability_constraints(basis_eval) + return basis_eval def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the B-spline basis set on a grid of equi-spaced sample points. @@ -1545,14 +1491,38 @@ def __init__( f"order {self.order} specified instead!" ) - def _call( + @support_pynapple(conv_type="numpy") + @check_transform_input + @check_one_dimensional + def __call__( self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ): - """Evaluate basis over samples.""" - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) + sample_pts: ArrayLike, + ) -> FeatureMatrix: + """Evaluate the Cyclic B-spline basis functions with given sample points. + + Parameters + ---------- + sample_pts : + The sample points at which the cyclic B-spline is evaluated, shape + (n_samples,).Sample points are rescaled to [0,1] as follows: + `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, + respectively. Values outside the [vmin, vmax] range are set to NaN. + + Returns + ------- + basis_funcs : + The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) + + Notes + ----- + The evaluation is performed by looping over each element and using `splev` from + SciPy to compute the basis values. + + """ + sample_pts, _ = min_max_rescale_samples( + sample_pts, vmin=self.vmin, vmax=self.vmax + ) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) # for cyclic, do not repeat knots @@ -1595,43 +1565,6 @@ def _call( return basis_eval - @support_pynapple(conv_type="numpy") - @check_transform_input - @check_one_dimensional - def __call__( - self, - sample_pts: ArrayLike, - ) -> FeatureMatrix: - """Evaluate the Cyclic B-spline basis functions with given sample points. - - Parameters - ---------- - sample_pts : - The sample points at which the cyclic B-spline is evaluated, shape - (n_samples,).Sample points are rescaled to [0,1] as follows: - `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, - respectively. Values outside the [vmin, vmax] range are set to NaN. - vmin: - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - This value determines the minimum of the domain of the basis. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. - This value determines the maximum of the domain of the basis. - - Returns - ------- - basis_funcs : - The basis function evaluated at the samples, shape (n_samples, n_basis_funcs) - - Notes - ----- - The evaluation is performed by looping over each element and using `splev` from - SciPy to compute the basis values. - - """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) - def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the Cyclic B-spline basis set on a grid of equi-spaced sample points. @@ -1748,14 +1681,32 @@ def _check_width(width: float) -> None: f"2*width must be a positive integer, 2*width = {2 * width} instead!" ) - def _call( + @support_pynapple(conv_type="numpy") + @check_transform_input + @check_one_dimensional + def __call__( self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - rescale_samples: bool = False, - ): - """Evaluate basis over samples.""" + sample_pts: ArrayLike, + rescale_samples=True, + ) -> FeatureMatrix: + """Generate basis functions with given samples. + + Parameters + ---------- + sample_pts : + Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). + rescale_samples : + If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. + Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. + If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of `sample_pts` + are used, respectively. Values outside the [vmin, vmax] range are set to NaN. + + Raises + ------ + ValueError + If the sample provided do not lie in [0,1]. + + """ if rescale_samples: # note that sample points is converted to NDArray # with the decorator. @@ -1765,7 +1716,7 @@ def _call( # additive_basis = basis1 + basis2 # additive_basis(*([x] * 2)) would modify both inputs sample_pts, _ = min_max_rescale_samples( - np.copy(sample_pts), vmin=vmin, vmax=vmax + np.copy(sample_pts), vmin=self.vmin, vmax=self.vmax ) peaks = self._compute_peaks() @@ -1787,36 +1738,6 @@ def _call( basis_funcs = self._apply_identifiability_constraints(basis_funcs) return basis_funcs - @support_pynapple(conv_type="numpy") - @check_transform_input - @check_one_dimensional - def __call__( - self, - sample_pts: ArrayLike, - rescale_samples=True, - ) -> FeatureMatrix: - """Generate basis functions with given samples. - - Parameters - ---------- - sample_pts : - Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). - rescale_samples : - If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. - Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of `sample_pts` - are used, respectively. Values outside the [vmin, vmax] range are set to NaN. - - Raises - ------ - ValueError - If the sample provided do not lie in [0,1]. - - """ - return self._call( - sample_pts, vmin=self.vmin, vmax=self.vmax, rescale_samples=rescale_samples - ) - def _compute_peaks(self) -> NDArray: """ Compute the location of raised cosine peaks. @@ -2013,20 +1934,6 @@ def _compute_peaks(self) -> NDArray: last_peak = 1 return np.linspace(0, last_peak, self.n_basis_funcs) - def _call( - self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ): - """Evaluate basis over samples.""" - return super()._call( - self._transform_samples(sample_pts, vmin=vmin, vmax=vmax), - vmin=vmin, - vmax=vmax, - rescale_samples=False, - ) - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -2053,7 +1960,10 @@ def __call__( ValueError If the sample provided do not lie in [0,1]. """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) + return super().__call__( + self._transform_samples(sample_pts, vmin=self.vmin, vmax=self.vmax), + rescale_samples=False, + ) class OrthExponentialBasis(Basis): @@ -2172,35 +2082,6 @@ def _check_sample_size(self, *sample_pts: NDArray) -> None: f"but only {sample_pts[0].size} samples provided!" ) - def _call( - self, - sample_pts: NDArray, - vmin: Optional[float] = None, - vmax: Optional[float] = None, - ): - """Evaluate basis over samples.""" - self._check_sample_size(sample_pts) - sample_pts, _ = min_max_rescale_samples(sample_pts, vmin=vmin, vmax=vmax) - valid_idx = ~np.isnan(sample_pts) - # because of how scipy.linalg.orth works, have to create a matrix of - # shape (n_pts, n_basis_funcs) and then transpose, rather than - # directly computing orth on the matrix of shape (n_basis_funcs, - # n_pts) - exp_decay_eval = np.stack( - [np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1 - ) - # count the linear independent components (could be lower than n_basis_funcs for num precision). - n_independent_component = np.linalg.matrix_rank(exp_decay_eval) - # initialize output to nan - basis_funcs = np.full( - shape=(sample_pts.shape[0], n_independent_component), fill_value=np.nan - ) - # orthonormalize on valid points - basis_funcs[valid_idx] = scipy.linalg.orth(exp_decay_eval) - if self.identifiability_constraints: - basis_funcs = self._apply_identifiability_constraints(basis_funcs) - return basis_funcs - @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional @@ -2223,7 +2104,30 @@ def __call__( orthogonalized, shape (n_samples, n_basis_funcs) """ - return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) + self._check_sample_size(sample_pts) + sample_pts, _ = min_max_rescale_samples( + sample_pts, vmin=self.vmin, vmax=self.vmax + ) + valid_idx = ~np.isnan(sample_pts) + # because of how scipy.linalg.orth works, have to create a matrix of + # shape (n_pts, n_basis_funcs) and then transpose, rather than + # directly computing orth on the matrix of shape (n_basis_funcs, + # n_pts) + exp_decay_eval = np.stack( + [np.exp(-lam * sample_pts[valid_idx]) for lam in self._decay_rates], axis=1 + ) + # count the linear independent components (could be lower than n_basis_funcs for num precision). + n_independent_component = np.linalg.matrix_rank(exp_decay_eval) + # initialize output to nan + basis_funcs = np.full( + shape=(sample_pts.shape[0], n_independent_component), fill_value=np.nan + ) + # orthonormalize on valid points + basis_funcs[valid_idx] = scipy.linalg.orth(exp_decay_eval) + if self.identifiability_constraints: + basis_funcs = self._apply_identifiability_constraints(basis_funcs) + return basis_funcs + # return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the basis set on a grid of equi-spaced sample points. diff --git a/src/nemos/convolve.py b/src/nemos/convolve.py index 58a9b154..9d7efb15 100644 --- a/src/nemos/convolve.py +++ b/src/nemos/convolve.py @@ -169,6 +169,7 @@ def _convolve_pad_and_shift( predictor : Predictor of with same shape and structure as `time_series` """ + # apply convolution def conv(x): return _shift_time_axis_and_convolve(x, basis_matrix, axis=axis) From 7ae9b52f3232c8a11e8d4575ee87163c207909f2 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 16:09:35 -0400 Subject: [PATCH 068/225] removed docsrtings incorrect info --- src/nemos/basis.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index aead58f6..dd921704 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1197,10 +1197,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: ---------- sample_pts : An array of sample points where the M-spline basis functions are to be - evaluated. Sample points are rescaled to [0,1] as follows: - `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of - `sample_pts` are used, respectively. Values outside the [vmin, vmax] range are set to NaN. + evaluated. Returns ------- @@ -1215,7 +1212,6 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ - """Evaluate basis over samples.""" sample_pts, scaling = min_max_rescale_samples( sample_pts, vmin=self.vmin, vmax=self.vmax ) @@ -1351,10 +1347,7 @@ def __init__( @support_pynapple(conv_type="numpy") @check_transform_input @check_one_dimensional - def __call__( - self, - sample_pts: ArrayLike, - ) -> FeatureMatrix: + def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: """ Evaluate the B-spline basis functions with given sample points. @@ -1367,10 +1360,6 @@ def __call__( ------- basis_funcs : The basis function evaluated at the samples, shape (n_samples, n_basis_funcs). - Sample points are rescaled to [0,1] as follows: - `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, - respectively. Values outside the [vmin, vmax] range are set to NaN. Raises ------ @@ -1504,10 +1493,7 @@ def __call__( ---------- sample_pts : The sample points at which the cyclic B-spline is evaluated, shape - (n_samples,).Sample points are rescaled to [0,1] as follows: - `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided, the minimum and maximum values of `sample_pts` are used, - respectively. Values outside the [vmin, vmax] range are set to NaN. + (n_samples,). Returns ------- @@ -1947,8 +1933,6 @@ def __call__( ---------- sample_pts : Spacing for basis functions. Samples will be rescaled to the interval [0, 1]. - Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - Values outside the [vmin, vmax] range are set to NaN. Returns ------- @@ -2127,7 +2111,6 @@ def __call__( if self.identifiability_constraints: basis_funcs = self._apply_identifiability_constraints(basis_funcs) return basis_funcs - # return self._call(sample_pts, vmin=self.vmin, vmax=self.vmax) def evaluate_on_grid(self, n_samples: int) -> Tuple[NDArray, NDArray]: """Evaluate the basis set on a grid of equi-spaced sample points. From a5a0c5bf71ef7269eb137e94ffd4642a335ebb93 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 17:12:36 -0400 Subject: [PATCH 069/225] bugfix ridge prox, removed if statement on lasso; --- src/nemos/regularizer.py | 13 +------------ tests/test_regularizer.py | 40 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 2add41d9..783c9be0 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -261,13 +261,6 @@ def get_proximal_operator( def prox_op(params, l2reg, scaling=0.5): Ws, bs = params l2reg /= bs.shape[0] - # if Ws is a pytree, l2reg needs to be a pytree with the same - # structure - if isinstance(Ws, (dict, FeaturePytree)): - struct = jax.tree_util.tree_structure(Ws) - l2reg = jax.tree_util.tree_unflatten( - struct, [l2reg] * struct.num_leaves - ) return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs return prox_op @@ -310,11 +303,7 @@ def prox_op(params, l1reg, scaling=1.0): l1reg /= bs.shape[0] # if Ws is a pytree, l1reg needs to be a pytree with the same # structure - if isinstance(Ws, (dict, FeaturePytree)): - struct = jax.tree_util.tree_structure(Ws) - l1reg = jax.tree_util.tree_unflatten( - struct, [l1reg] * struct.num_leaves - ) + l1reg = jax.tree_util.tree_map(lambda x: l1reg * jnp.ones_like(x), Ws) return jaxopt.prox.prox_lasso(Ws, l1reg, scaling=scaling), bs return prox_op diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index ccfb0920..61848244 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -161,7 +161,7 @@ def test_loss_is_callable(self, loss): else: nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" @@ -173,6 +173,18 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytree): + """Test that the solver runs.""" + + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation_pytree + + # set regularizer and solver name + model.regularizer = self.cls() + model.solver_name = solver_name + runner = model.instantiate_solver()[2] + runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + def test_solver_output_match(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" jax.config.update("jax_enable_x64", True) @@ -366,7 +378,7 @@ def test_loss_is_callable(self, loss): else: nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" @@ -378,6 +390,18 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytree): + """Test that the solver runs.""" + + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation_pytree + + # set regularizer and solver name + model.regularizer = self.cls() + model.solver_name = solver_name + runner = model.instantiate_solver()[2] + runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + def test_solver_output_match(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" jax.config.update("jax_enable_x64", True) @@ -533,6 +557,18 @@ def test_run_solver(self, poissonGLM_model_instantiation): runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) + @pytest.mark.parametrize("solver_name", ["ProximalGradient"]) + def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytree): + """Test that the solver runs.""" + + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation_pytree + + # set regularizer and solver name + model.regularizer = self.cls() + model.solver_name = solver_name + runner = model.instantiate_solver()[2] + runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" jax.config.update("jax_enable_x64", True) From fb66051b4d7c24f1599558d66e750d85618158a1 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 17:27:49 -0400 Subject: [PATCH 070/225] simplified logic dof --- src/nemos/glm.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 89da3468..db2cca25 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -814,14 +814,13 @@ def estimate_resid_degrees_of_freedom( # see https://arxiv.org/abs/0712.0881 if isinstance(self.regularizer, (GroupLasso, Lasso)): coef, _ = self._get_coef_and_intercept() - if isinstance(coef, dict): - resid_dof = tree_utils.pytree_map_and_reduce( - lambda x: jnp.isclose(x, jnp.zeros_like(x)), - lambda x: sum([jnp.sum(i) for i in x]), - coef, - ) - return n_samples - resid_dof - return n_samples - jnp.sum(jnp.isclose(coef, jnp.zeros_like(coef))) + resid_dof = tree_utils.pytree_map_and_reduce( + lambda x: jnp.isclose(x, jnp.zeros_like(x)), + lambda x: sum([jnp.sum(i) for i in x]), + coef, + ) + return n_samples - resid_dof + elif isinstance(self.regularizer, Ridge): # for Ridge, use the tot parameters (X.shape[1] + intercept) return n_samples - X.shape[1] - 1 From 9b5e5d27dd80046c3f6b039fede5f87f47ace290 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 18 Jul 2024 17:28:39 -0400 Subject: [PATCH 071/225] move regularizer_stregnth to base regressor, update tests --- src/nemos/base_regressor.py | 37 ++++++++++++++++++-- src/nemos/glm.py | 14 ++++++-- src/nemos/regularizer.py | 70 +++++++++---------------------------- tests/conftest.py | 10 ++---- tests/test_convergence.py | 6 ++-- tests/test_glm.py | 22 +++++++++--- tests/test_regularizer.py | 27 ++++++++------ 7 files changed, 103 insertions(+), 83 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 924b8151..032be215 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -34,6 +34,9 @@ class BaseRegressor(Base, abc.ABC): Regularization to use for model optimization. Defines the regularization scheme and related parameters. Default is UnRegularized regression. + regularizer_strength : + Float that is default None. Sets the regularizer strength. If a user does not pass a value, and it is needed for + regularization, a warning will be raised and the strength will default to 1.0. solver_name : Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. @@ -63,10 +66,12 @@ class BaseRegressor(Base, abc.ABC): def __init__( self, regularizer: Union[str, Regularizer] = "unregularized", + regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: Optional[dict] = None, ): self.regularizer = regularizer + self.regularizer_strength = regularizer_strength # no solver name provided, use default if solver_name is None: @@ -97,6 +102,25 @@ def regularizer(self, regularizer: Union[str, Regularizer]): f"{AVAILABLE_REGULARIZERS} or an instance of `nemos.regularizer.Regularizer`" ) + @property + def regularizer_strength(self) -> float: + return self._regularizer_strength + + @regularizer_strength.setter + def regularizer_strength(self, strength: float): + if strength is None: + self._regularizer_strength = None + return + try: + # force conversion to float to prevent weird GPU issues + strength = float(strength) + except ValueError: + # raise a more detailed ValueError + raise ValueError( + f"Could not convert the regularizer strength: {strength} to a float." + ) + self._regularizer_strength = strength + @property def solver_name(self) -> str: """Getter for the solver_name attribute.""" @@ -197,9 +221,18 @@ def instantiate_solver( f"{self._regularizer.allowed_solvers}." ) + if self.regularizer_strength is None: + warnings.warn( + "Caution: regularizer strength has not been set. Defaulting to 1.0. Please see " + "the documentation for best practices in setting regularization strength." + ) + self.regularizer_strength = 1.0 + # only use penalized loss if not using proximal gradient descent if self.solver_name != "ProximalGradient": - loss = self.regularizer.penalized_loss(self._predict_and_compute_loss) + loss = self.regularizer.penalized_loss( + self._predict_and_compute_loss, self.regularizer_strength + ) else: loss = self._predict_and_compute_loss @@ -210,7 +243,7 @@ def instantiate_solver( if self.solver_name == "ProximalGradient": self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) # add self.regularizer_strength to args - args += (self.regularizer.regularizer_strength,) + args += (self.regularizer_strength,) if self.solver_name == "LBFGSB": if "bounds" not in kwargs.keys(): warnings.warn( diff --git a/src/nemos/glm.py b/src/nemos/glm.py index db2cca25..9cc935ab 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -68,6 +68,9 @@ class GLM(BaseRegressor): Regularization to use for model optimization. Defines the regularization scheme and related parameters. Default is UnRegularized regression. + regularizer_strength : + Float that is default None. Sets the regularizer strength. If a user does not pass a value, and it is needed for + regularization, a warning will be raised and the strength will default to 1.0. solver_name : Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. @@ -103,12 +106,14 @@ class GLM(BaseRegressor): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: str | Regularizer = "unregularized", + regularizer: Union[str, Regularizer] = "unregularized", + regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: dict = None, ): super().__init__( regularizer=regularizer, + regularizer_strength=regularizer_strength, solver_name=solver_name, solver_kwargs=solver_kwargs, ) @@ -1018,6 +1023,9 @@ class PopulationGLM(GLM): Regularization to use for model optimization. Defines the regularization scheme and related parameters. Default is UnRegularized regression. + regularizer_strength : + Float that is default None. Sets the regularizer strength. If a user does not pass a value, and it is needed for + regularization, a warning will be raised and the strength will default to 1.0. solver_name : Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. @@ -1102,7 +1110,8 @@ class PopulationGLM(GLM): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: str | Regularizer = "unregularized", + regularizer: Union[str, Regularizer] = "unregularized", + regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: dict = None, feature_mask: Optional[jnp.ndarray] = None, @@ -1110,6 +1119,7 @@ def __init__( ): super().__init__( observation_model=observation_model, + regularizer_strength=regularizer_strength, regularizer=regularizer, solver_name=solver_name, solver_kwargs=solver_kwargs, diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index a8102719..a65d3d44 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -35,13 +35,6 @@ class Regularizer(Base, abc.ABC): enabling users to easily switch between different regularizers, ensuring compatibility with various loss functions and optimization algorithms. - Parameters - ---------- - regularizer_strength - Float representing the strength of the regularization being applied. Only used if the solver method - is ProximalGradient. - Default 1.0. - Attributes ---------- allowed_solvers : @@ -59,9 +52,6 @@ def __init__( ): super().__init__(**kwargs) - # default regularizer strength - self.regularizer_strength = 1.0 - @property def allowed_solvers(self): return self._allowed_solvers @@ -70,23 +60,7 @@ def allowed_solvers(self): def default_solver(self): return self._default_solver - @property - def regularizer_strength(self) -> float: - return self._regularizer_strength - - @regularizer_strength.setter - def regularizer_strength(self, strength: float): - try: - # force conversion to float to prevent weird GPU issues - strength = float(strength) - except ValueError: - # raise a more detailed ValueError - raise ValueError( - f"Could not convert the regularizer strength: {strength} to a float." - ) - self._regularizer_strength = strength - - def penalized_loss(self, loss: Callable) -> Callable: + def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """ Abstract method to penalize loss functions. @@ -94,6 +68,8 @@ def penalized_loss(self, loss: Callable) -> Callable: ---------- loss : Callable loss function. + regularizer_strength : + Float the indicates the regularization strength. Returns ------- @@ -153,7 +129,7 @@ def __init__( ): super().__init__() - def penalized_loss(self, loss: Callable): + def penalized_loss(self, loss: Callable, regularizer_strength: float): """ Returns the original loss function unpenalized. Unregularized regularization method does not add any penalty. @@ -175,12 +151,6 @@ class Ridge(Regularizer): This class uses `jaxopt` optimizers to perform Ridge regularization. It extends the base Solver class, with the added feature of Ridge penalization. - Parameters - ---------- - regularizer_strength : - Indicates the strength of the penalization being applied. - Float with default value of 1.0. - Attributes ---------- allowed_solvers : List[..., str] @@ -202,13 +172,11 @@ class Ridge(Regularizer): def __init__( self, - regularizer_strength: float = 1.0, ): super().__init__() - self.regularizer_strength = regularizer_strength def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float ) -> jnp.ndarray: """ Compute the Ridge penalization for given parameters. @@ -227,7 +195,7 @@ def _penalization( def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: return ( 0.5 - * self.regularizer_strength + * regularizer_strength * jnp.sum(jnp.power(coeff, 2)) / intercept.shape[0] ) @@ -237,11 +205,11 @@ def l2_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: lambda x: l2_penalty(x, params[1]), sum, params[0] ) - def penalized_loss(self, loss: Callable) -> Callable: + def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """Returns the penalized loss function for Ridge regularization.""" def _penalized_loss(params, X, y): - return loss(params, X, y) + self._penalization(params) + return loss(params, X, y) + self._penalization(params, regularizer_strength) return _penalized_loss @@ -280,10 +248,8 @@ class Lasso(Regularizer): def __init__( self, - regularizer_strength: float = 1.0, ): super().__init__() - self.regularizer_strength = regularizer_strength def get_proximal_operator( self, @@ -309,7 +275,7 @@ def prox_op(params, l1reg, scaling=1.0): return prox_op def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float ) -> jnp.ndarray: """ Compute the Lasso penalization for given parameters. @@ -326,20 +292,18 @@ def _penalization( """ def l1_penalty(coeff: jnp.ndarray, intercept: jnp.ndarray) -> jnp.ndarray: - return ( - self.regularizer_strength * jnp.sum(jnp.abs(coeff)) / intercept.shape[0] - ) + return regularizer_strength * jnp.sum(jnp.abs(coeff)) / intercept.shape[0] # tree map the computation and sum over leaves return tree_utils.pytree_map_and_reduce( lambda x: l1_penalty(x, params[1]), sum, params[0] ) - def penalized_loss(self, loss: Callable) -> Callable: + def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """Returns a function for calculating the penalized loss using Lasso regularization.""" def _penalized_loss(params, X, y): - return loss(params, X, y) + self._penalization(params) + return loss(params, X, y) + self._penalization(params, regularizer_strength) return _penalized_loss @@ -392,10 +356,8 @@ class GroupLasso(Regularizer): def __init__( self, mask: Union[NDArray, jnp.ndarray] = None, - regularizer_strength: float = 1.0, ): super().__init__() - self.regularizer_strength = regularizer_strength self.mask = mask @@ -457,7 +419,7 @@ def _check_mask(mask: jnp.ndarray): ) def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray] + self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float ) -> jnp.ndarray: """ Calculate the penalization. @@ -488,15 +450,15 @@ def _penalization( ) # divide regularization strength by number of neurons - regularizer_strength = self.regularizer_strength / params[1].shape[0] + regularizer_strength = regularizer_strength / params[1].shape[0] return penalty * regularizer_strength - def penalized_loss(self, loss: Callable) -> Callable: + def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """Returns a function for calculating the penalized loss using Group Lasso regularization.""" def _penalized_loss(params, X, y): - return loss(params, X, y) + self._penalization(params) + return loss(params, X, y) + self._penalization(params, regularizer_strength) return _penalized_loss diff --git a/tests/conftest.py b/tests/conftest.py index f49cae42..bb69d428 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -422,14 +422,12 @@ def poisson_observation_model(): @pytest.fixture def ridge_regularizer(): - return nmo.regularizer.Ridge(regularizer_strength=0.1) + return nmo.regularizer.Ridge() @pytest.fixture def lasso_regularizer(): - return nmo.regularizer.Lasso( - solver_name="ProximalGradient", regularizer_strength=0.1 - ) + return nmo.regularizer.Lasso(solver_name="ProximalGradient") @pytest.fixture @@ -437,9 +435,7 @@ def group_lasso_2groups_5features_regularizer(): mask = np.zeros((2, 5)) mask[0, :2] = 1 mask[1, 2:] = 1 - return nmo.regularizer.GroupLasso( - solver_name="ProximalGradient", mask=mask, regularizer_strength=0.1 - ) + return nmo.regularizer.GroupLasso(solver_name="ProximalGradient", mask=mask) @pytest.fixture diff --git a/tests/test_convergence.py b/tests/test_convergence.py index a4351d4d..2bc25c32 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -86,12 +86,12 @@ def test_lasso_convergence(): # instantiate and fit GLM with ProximalGradient model_PG = nmo.glm.GLM(regularizer="lasso", solver_name="ProximalGradient") - model_PG.regularizer.regularizer_strength = 0.1 + model_PG.regularizer_strength = 0.1 model_PG.fit(X, y) # use the penalized loss function to solve optimization via Nelder-Mead penalized_loss = lambda p, x, y: model_PG.regularizer.penalized_loss( - model_PG._predict_and_compute_loss + model_PG._predict_and_compute_loss, model_PG.regularizer_strength )( ( p[1:], @@ -131,7 +131,7 @@ def test_group_lasso_convergence(): # use the penalized loss function to solve optimization via Nelder-Mead penalized_loss = lambda p, x, y: model_PG.regularizer.penalized_loss( - model_PG._predict_and_compute_loss + model_PG._predict_and_compute_loss, model_PG.regularizer_strength )( ( p[1:], diff --git a/tests/test_glm.py b/tests/test_glm.py index aba4db80..f370db70 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -102,6 +102,7 @@ def test_init_observation_type( with expectation: glm_class( regularizer=ridge_regularizer, + regularizer_strength=0.1, solver_name="LBFGS", observation_model=observation, ) @@ -2803,22 +2804,30 @@ def test_feature_mask_compatibility_fit_tree( getattr(model, attr_name)(X, y) @pytest.mark.parametrize( - "regularizer, solver_name, solver_kwargs", + "regularizer, regularizer_strength, solver_name, solver_kwargs", [ ( nmo.regularizer.UnRegularized(), + 0.001, "LBFGS", {"stepsize": 0.1, "tol": 10**-14}, ), - (nmo.regularizer.UnRegularized(), "GradientDescent", {"tol": 10**-14}), ( - nmo.regularizer.Ridge(regularizer_strength=0.001), + nmo.regularizer.UnRegularized(), + None, + "GradientDescent", + {"tol": 10**-14}, + ), + ( + nmo.regularizer.Ridge(), + None, "LBFGS", {"tol": 10**-14}, ), - (nmo.regularizer.Ridge(), "LBFGS", {"stepsize": 0.1, "tol": 10**-14}), + (nmo.regularizer.Ridge(), None, "LBFGS", {"stepsize": 0.1, "tol": 10**-14}), ( - nmo.regularizer.Lasso(regularizer_strength=0.001), + nmo.regularizer.Lasso(), + 0.001, "ProximalGradient", {"tol": 10**-14}, ), @@ -2842,6 +2851,7 @@ def test_feature_mask_compatibility_fit_tree( def test_masked_fit_vs_loop( self, regularizer, + regularizer_strength, solver_name, solver_kwargs, mask, @@ -2874,6 +2884,7 @@ def map_neu(k, coef_): # fit pop glm model.feature_mask = mask model.regularizer = regularizer + model.regularizer_strength = regularizer_strength model.solver_name = solver_name model.solver_kwargs = solver_kwargs model.fit(X, y) @@ -2885,6 +2896,7 @@ def map_neu(k, coef_): for k in range(y.shape[1]): model_single_neu = nmo.glm.GLM( regularizer=regularizer, + regularizer_strength=regularizer_strength, solver_name=solver_name, solver_kwargs=solver_kwargs, ) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 66f8fad2..bc09a9e7 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -61,9 +61,11 @@ def test_regularizer(regularizer_strength, reg_type): match=f"Could not convert the regularizer strength: {regularizer_strength} " f"to a float.", ): - reg_type(regularizer_strength=regularizer_strength) + nmo.glm.GLM( + regularizer=reg_type(), regularizer_strength=regularizer_strength + ) else: - reg_type(regularizer_strength=regularizer_strength) + nmo.glm.GLM(regularizer=reg_type(), regularizer_strength=regularizer_strength) @pytest.mark.parametrize( @@ -87,9 +89,12 @@ def test_regularizer_setter(regularizer_strength, regularizer): match=f"Could not convert the regularizer strength: {regularizer_strength} " f"to a float.", ): - regularizer.regularizer_strength = regularizer_strength + + nmo.glm.GLM( + regularizer=regularizer, regularizer_strength=regularizer_strength + ) else: - regularizer.regularizer_strength = regularizer_strength + nmo.glm.GLM(regularizer=regularizer, regularizer_strength=regularizer_strength) @pytest.mark.parametrize( @@ -517,7 +522,7 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): model_skl = PoissonRegressor( fit_intercept=True, tol=10**-12, - alpha=model.regularizer.regularizer_strength, + alpha=model.regularizer_strength, ) model_skl.fit(X, y) @@ -535,7 +540,7 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): model.observation_model.inverse_link_function = jnp.exp model.regularizer = self.cls() model.solver_kwargs = {"tol": 10**-12} - model.regularizer.regularizer_strength = 0.1 + model.regularizer_strength = 0.1 runner_bfgs = model.instantiate_solver()[2] weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y @@ -543,7 +548,7 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): model_skl = GammaRegressor( fit_intercept=True, tol=10**-12, - alpha=model.regularizer.regularizer_strength, + alpha=model.regularizer_strength, ) model_skl.fit(X, y) @@ -672,7 +677,7 @@ def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): glm_sm = sm.GLM(endog=y, exog=sm.add_constant(X), family=sm.families.Poisson()) # regularize everything except intercept - alpha_sm = np.ones(X.shape[1] + 1) * model.regularizer.regularizer_strength + alpha_sm = np.ones(X.shape[1] + 1) * model.regularizer_strength alpha_sm[0] = 0 # pure lasso = elastic net with L1 weight = 1 @@ -705,8 +710,10 @@ def test_lasso_pytree_match( X, _, model, _, _ = poissonGLM_model_instantiation_pytree X_array, y, model_array, _, _ = poissonGLM_model_instantiation - model.regularizer = nmo.regularizer.Lasso(regularizer_strength=reg_str) - model_array.regularizer = nmo.regularizer.Lasso(regularizer_strength=reg_str) + model.regularizer_strength = reg_str + model_array.regularizer_strength = reg_str + model.regularizer = nmo.regularizer.Lasso() + model_array.regularizer = nmo.regularizer.Lasso() model.solver_name = "ProximalGradient" model_array.solver_name = "ProximalGradient" model.fit(X, y) From 1f650aa837474e50c9eb3dba216b69ddb424f6d4 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 18 Jul 2024 17:44:07 -0400 Subject: [PATCH 072/225] remove unused import --- src/nemos/regularizer.py | 1 - tests/test_regularizer.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index a65d3d44..753ed642 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -17,7 +17,6 @@ from . import tree_utils from .base_class import Base from .proximal_operator import prox_group_lasso -from .pytrees import FeaturePytree from .typing import DESIGN_INPUT_TYPE, ProximalOperator __all__ = ["UnRegularized", "Ridge", "Lasso", "GroupLasso"] diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index bc09a9e7..5fbfe721 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -218,7 +218,9 @@ def test_loss_is_callable(self, loss): else: nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + @pytest.mark.parametrize( + "solver_name", ["GradientDescent", "BFGS", "ProximalGradient"] + ) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" @@ -230,7 +232,9 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + @pytest.mark.parametrize( + "solver_name", ["GradientDescent", "BFGS", "ProximalGradient"] + ) def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytree): """Test that the solver runs.""" @@ -240,7 +244,11 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre model.regularizer = self.cls() model.solver_name = solver_name runner = model.instantiate_solver()[2] - runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + runner( + (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), + X.data, + y, + ) def test_solver_output_match(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" @@ -452,7 +460,9 @@ def test_loss_is_callable(self, loss): else: nmo.utils.assert_is_callable(model._predict_and_compute_loss, "loss") - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + @pytest.mark.parametrize( + "solver_name", ["GradientDescent", "BFGS", "ProximalGradient"] + ) def test_run_solver(self, solver_name, poissonGLM_model_instantiation): """Test that the solver runs.""" @@ -464,7 +474,9 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): runner = model.instantiate_solver()[2] runner((true_params[0] * 0.0, true_params[1]), X, y) - @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS", "ProximalGradient"]) + @pytest.mark.parametrize( + "solver_name", ["GradientDescent", "BFGS", "ProximalGradient"] + ) def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytree): """Test that the solver runs.""" @@ -474,7 +486,11 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre model.regularizer = self.cls() model.solver_name = solver_name runner = model.instantiate_solver()[2] - runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + runner( + (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), + X.data, + y, + ) def test_solver_output_match(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" @@ -658,7 +674,11 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre model.regularizer = self.cls() model.solver_name = solver_name runner = model.instantiate_solver()[2] - runner((jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y) + runner( + (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), + X.data, + y, + ) def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): """Test that different solvers converge to the same solution.""" From cf6a38eb3379bffa0b7f50ea43338f0bc93e1d3d Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:24:53 -0400 Subject: [PATCH 073/225] added tests improved dof calculation --- src/nemos/glm.py | 14 ++++++------- src/nemos/observation_models.py | 4 ++-- tests/test_glm.py | 36 +++++++++++++++++++++++++++------ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 9cc935ab..09ca07d2 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -814,25 +814,25 @@ def estimate_resid_degrees_of_freedom( "instead!" ) + params = self._get_coef_and_intercept() # if the regularizer is lasso use the non-zero # coeff as an estimate of the dof # see https://arxiv.org/abs/0712.0881 if isinstance(self.regularizer, (GroupLasso, Lasso)): - coef, _ = self._get_coef_and_intercept() resid_dof = tree_utils.pytree_map_and_reduce( - lambda x: jnp.isclose(x, jnp.zeros_like(x)), - lambda x: sum([jnp.sum(i) for i in x]), - coef, + lambda x: ~jnp.isclose(x, jnp.zeros_like(x)), + lambda x: sum([jnp.sum(i, axis=0) for i in x]), + params[0] ) - return n_samples - resid_dof + return n_samples - resid_dof - 1 elif isinstance(self.regularizer, Ridge): # for Ridge, use the tot parameters (X.shape[1] + intercept) - return n_samples - X.shape[1] - 1 + return (n_samples - X.shape[1] - 1) * jnp.ones_like(params[1]) else: # for UnRegularized, use the rank rank = jnp.linalg.matrix_rank(X) - return n_samples - rank - 1 + return (n_samples - rank - 1) * jnp.ones_like(params[1]) @cast_to_jax def initialize_solver( diff --git a/src/nemos/observation_models.py b/src/nemos/observation_models.py index 5ba7c4ab..81724614 100644 --- a/src/nemos/observation_models.py +++ b/src/nemos/observation_models.py @@ -430,7 +430,7 @@ class PoissonObservations(Observations): def __init__(self, inverse_link_function=jnp.exp): super().__init__(inverse_link_function=inverse_link_function) - self.scale = 1 + self.scale = 1. def _negative_log_likelihood( self, @@ -633,7 +633,7 @@ def estimate_scale( dof_resid : The DOF of the residuals. """ - return 1.0 + return jnp.ones_like(jnp.atleast_1d(y[0])) class GammaObservations(Observations): diff --git a/tests/test_glm.py b/tests/test_glm.py index f370db70..ef5190c3 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1358,6 +1358,29 @@ def test_compatibility_with_sklearn_cv_gamma(self, gammaGLM_model_instantiation) param_grid = {"solver_name": ["BFGS", "GradientDescent"]} GridSearchCV(model, param_grid).fit(X, y) + @pytest.mark.parametrize( + "reg, dof", + [ + (nmo.regularizer.UnRegularized(), np.array([5])), + (nmo.regularizer.Lasso(), np.array([3])), # this lasso fit has only 3 coeff of the first neuron + # surviving + (nmo.regularizer.Ridge(), np.array([5])), + ], + ) + @pytest.mark.parametrize("n_samples", [1, 20]) + def test_estimate_dof_resid(self, n_samples, dof, reg, poissonGLM_model_instantiation): + """ + Test that the dof is an integer. + """ + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + model.regularizer = reg + model.solver_name = model.regularizer.default_solver + model.fit(X, y) + num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + assert np.allclose(num, n_samples - dof - 1) + + + class TestPopulationGLM: """ @@ -1467,15 +1490,16 @@ def test_fit_param_length( model.fit(X, y, init_params=init_params) @pytest.mark.parametrize( - "reg", + "reg, dof", [ - nmo.regularizer.UnRegularized(), - nmo.regularizer.Lasso(), - nmo.regularizer.Ridge(), + (nmo.regularizer.UnRegularized(), np.array([5, 5, 5])), + (nmo.regularizer.Lasso(), np.array([3, 0, 0])), # this lasso fit has only 3 coeff of the first neuron + # surviving + (nmo.regularizer.Ridge(), np.array([5, 5, 5])), ], ) @pytest.mark.parametrize("n_samples", [1, 20]) - def test_estimate_dof_resid(self, n_samples, reg, poisson_population_GLM_model): + def test_estimate_dof_resid(self, n_samples, dof, reg, poisson_population_GLM_model): """ Test that the dof is an integer. """ @@ -1484,7 +1508,7 @@ def test_estimate_dof_resid(self, n_samples, reg, poisson_population_GLM_model): model.solver_name = model.regularizer.default_solver model.fit(X, y) num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) - assert int(num) == num + assert np.allclose(num, n_samples - dof - 1) @pytest.mark.parametrize( "dim_weights, expectation", From 87d577e0f79b0c30bcb70332d6718ebf6499cf23 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:25:48 -0400 Subject: [PATCH 074/225] added tests improved dof calculation --- src/nemos/glm.py | 2 +- src/nemos/observation_models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 09ca07d2..d44dab0b 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -822,7 +822,7 @@ def estimate_resid_degrees_of_freedom( resid_dof = tree_utils.pytree_map_and_reduce( lambda x: ~jnp.isclose(x, jnp.zeros_like(x)), lambda x: sum([jnp.sum(i, axis=0) for i in x]), - params[0] + params[0], ) return n_samples - resid_dof - 1 diff --git a/src/nemos/observation_models.py b/src/nemos/observation_models.py index 81724614..26b53b1a 100644 --- a/src/nemos/observation_models.py +++ b/src/nemos/observation_models.py @@ -430,7 +430,7 @@ class PoissonObservations(Observations): def __init__(self, inverse_link_function=jnp.exp): super().__init__(inverse_link_function=inverse_link_function) - self.scale = 1. + self.scale = 1.0 def _negative_log_likelihood( self, From 2f8ab2de2669016ea4f250918ee143ba9c93c3cd Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:43:48 -0400 Subject: [PATCH 075/225] fixed docs --- docs/api_guide/plot_02_glm_demo.py | 4 ++-- docs/api_guide/plot_03_glm_pytree.py | 3 +-- docs/neural_modeling/plot_02_head_direction.py | 5 +++-- docs/neural_modeling/plot_03_grid_cells.py | 11 ++++++----- docs/neural_modeling/plot_06_calcium_imaging.py | 3 ++- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/api_guide/plot_02_glm_demo.py b/docs/api_guide/plot_02_glm_demo.py index cafbd97e..b1220651 100644 --- a/docs/api_guide/plot_02_glm_demo.py +++ b/docs/api_guide/plot_02_glm_demo.py @@ -133,7 +133,7 @@ # The same exact syntax works for any configuration. # fit a ridge regression Poisson GLM -model = nmo.glm.GLM(regularizer=nmo.regularizer.Ridge(regularizer_strength=0.1)) +model = nmo.glm.GLM(regularizer="ridge", regularizer_strength=0.1) model.fit(X, spikes) print("Ridge results") @@ -151,7 +151,7 @@ # # **Ridge** -parameter_grid = {"regularizer__regularizer_strength": np.logspace(-1.5, 1.5, 6)} +parameter_grid = {"regularizer_strength": np.logspace(-1.5, 1.5, 6)} # in practice, you should use more folds than 2, but for the purposes of this # demo, 2 is sufficient. cls = model_selection.GridSearchCV(model, parameter_grid, cv=2) diff --git a/docs/api_guide/plot_03_glm_pytree.py b/docs/api_guide/plot_03_glm_pytree.py index 1ea2dbae..d7e91ccb 100644 --- a/docs/api_guide/plot_03_glm_pytree.py +++ b/docs/api_guide/plot_03_glm_pytree.py @@ -231,8 +231,7 @@ # %% # # Now we'll fit our GLM and then see what our head direction tuning looks like: -ridge = nmo.regularizer.Ridge(regularizer_strength=0.001) -model = nmo.glm.GLM(regularizer=ridge) +model = nmo.glm.GLM(regularizer="ridge", regularizer_strength=0.001) model.fit(X, spikes) print(model.coef_['head_direction']) diff --git a/docs/neural_modeling/plot_02_head_direction.py b/docs/neural_modeling/plot_02_head_direction.py index 527d2c8c..12b2288d 100644 --- a/docs/neural_modeling/plot_02_head_direction.py +++ b/docs/neural_modeling/plot_02_head_direction.py @@ -532,8 +532,9 @@ # model = nmo.glm.PopulationGLM( - regularizer=nmo.regularizer.Ridge(regularizer_strength=0.1), - solver_name="LBFGS" + regularizer="ridge", + solver_name="LBFGS", + regularizer_strength=0.1 ).fit(convolved_count, count) # %% diff --git a/docs/neural_modeling/plot_03_grid_cells.py b/docs/neural_modeling/plot_03_grid_cells.py index 08748a58..4e42d925 100644 --- a/docs/neural_modeling/plot_03_grid_cells.py +++ b/docs/neural_modeling/plot_03_grid_cells.py @@ -160,8 +160,9 @@ # Here we will focus on the last neuron (neuron 7) who has a nice grid pattern model = nmo.glm.GLM( - regularizer=nmo.regularizer.Ridge(regularizer_strength=0.001), - solver_name="LBFGS" + regularizer="ridge", + solver_name="LBFGS", + regularizer_strength=0.001 ) # %% @@ -210,7 +211,7 @@ from sklearn.model_selection import GridSearchCV # define the regularization strength that we want cross-validate -param_grid = dict(regularizer__regularizer_strength=[1e-6, 1e-5, 1e-3]) +param_grid = dict(regularizer_strength=[1e-6, 1e-5, 1e-3]) # pass the model and the grid cls = GridSearchCV(model, param_grid=param_grid) @@ -247,9 +248,9 @@ plt.suptitle("Rate predictions\n") axs[0].set_title("Raw Counts") axs[0].imshow(smooth_pos_tuning, vmin=vmin, vmax=vmax) -axs[1].set_title(f"Ridge - strength: {model.regularizer.regularizer_strength}") +axs[1].set_title(f"Ridge - strength: {model.regularizer_strength}") axs[1].imshow(smooth_model, vmin=vmin, vmax=vmax) -axs[2].set_title(f"Ridge - strength: {best_model.regularizer.regularizer_strength}") +axs[2].set_title(f"Ridge - strength: {best_model.regularizer_strength}") axs[2].imshow(smooth_best_model, vmin=vmin, vmax=vmax) plt.tight_layout() diff --git a/docs/neural_modeling/plot_06_calcium_imaging.py b/docs/neural_modeling/plot_06_calcium_imaging.py index d22b4409..3b6bf0cf 100644 --- a/docs/neural_modeling/plot_06_calcium_imaging.py +++ b/docs/neural_modeling/plot_06_calcium_imaging.py @@ -143,7 +143,8 @@ model = nmo.glm.GLM( solver_name="LBFGS", solver_kwargs=dict(tol=10**-13), - regularizer=nmo.regularizer.Ridge(regularizer_strength=0.02), + regularizer="ridge", + regularizer_strength=0.02, observation_model=nmo.observation_models.GammaObservations(inverse_link_function=jax.nn.softplus) ) From 3033d2fb289604e2ab76fb7d03a92dc2b42bc83a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:52:48 -0400 Subject: [PATCH 076/225] added tests that must pass --- tests/test_glm.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_glm.py b/tests/test_glm.py index ef5190c3..31e69963 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -10,6 +10,7 @@ import nemos as nmo from nemos.pytrees import FeaturePytree +import warnings def test_validate_higher_dimensional_data_X(mock_glm): @@ -1379,6 +1380,32 @@ def test_estimate_dof_resid(self, n_samples, dof, reg, poissonGLM_model_instanti num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) assert np.allclose(num, n_samples - dof - 1) + @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + def test_waning_solver_reg_str(self, reg): + # check that a warning is triggered + # if no param is passed + with pytest.warns(UserWarning): + nmo.glm.GLM(regularizer=reg) + + # check that the warning is not triggered + with warnings.catch_warnings(): + warnings.simplefilter("error") + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + + # reset to unregularized + model.regularizer = "unregularized" + with pytest.warns(UserWarning): + nmo.glm.GLM(regularizer=reg) + + @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + def test_reg_strength_reset(self, reg): + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model.regularizer = "unregularized" + assert model.regularizer_strength is None + + + + From 3bd9cde9fcd7f70a51f3c7b78deb302e6d075fa5 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:53:11 -0400 Subject: [PATCH 077/225] isorted tests --- tests/test_convergence.py | 3 ++- tests/test_glm.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_convergence.py b/tests/test_convergence.py index 2bc25c32..5eeb5d3f 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -3,9 +3,10 @@ import jax import numpy as np -import nemos as nmo from scipy.optimize import minimize +import nemos as nmo + def test_unregularized_convergence(): """ diff --git a/tests/test_glm.py b/tests/test_glm.py index 31e69963..6dd84ae8 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1,3 +1,4 @@ +import warnings from contextlib import nullcontext as does_not_raise from typing import Callable @@ -10,7 +11,6 @@ import nemos as nmo from nemos.pytrees import FeaturePytree -import warnings def test_validate_higher_dimensional_data_X(mock_glm): From cc14f6995a4c156f463f89344ae10ba99a26bc22 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 18 Jul 2024 18:55:24 -0400 Subject: [PATCH 078/225] added test to pop glm --- tests/test_glm.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_glm.py b/tests/test_glm.py index 6dd84ae8..c6ed5eb2 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -2966,3 +2966,27 @@ def map_neu(k, coef_): intercept_loop[k] = np.array(model_single_neu.intercept_)[0] print(f"\nMAX ERR: {np.abs(coef_loop - coef_vectorized).max()}") assert np.allclose(coef_loop, coef_vectorized, atol=10**-5, rtol=0) + + @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + def test_waning_solver_reg_str(self, reg): + # check that a warning is triggered + # if no param is passed + with pytest.warns(UserWarning): + nmo.glm.GLM(regularizer=reg) + + # check that the warning is not triggered + with warnings.catch_warnings(): + warnings.simplefilter("error") + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + + # reset to unregularized + model.regularizer = "unregularized" + with pytest.warns(UserWarning): + nmo.glm.GLM(regularizer=reg) + + @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + def test_reg_strength_reset(self, reg): + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model.regularizer = "unregularized" + assert model.regularizer_strength is None + From a862db94609febcc0c4d086131344e2b35a870c5 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis <69729525+clewis7@users.noreply.github.com> Date: Thu, 18 Jul 2024 22:06:02 -0400 Subject: [PATCH 079/225] fix regularizer strength param, update tests --- src/nemos/base_regressor.py | 28 +++++++++++++---------- tests/test_glm.py | 44 ++++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 032be215..a0ddffc5 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -16,7 +16,7 @@ from . import utils, validation from ._regularizer_builder import AVAILABLE_REGULARIZERS, create_regularizer from .base_class import Base -from .regularizer import Regularizer +from .regularizer import Regularizer, UnRegularized from .typing import DESIGN_INPUT_TYPE, SolverInit, SolverRun, SolverUpdate @@ -71,7 +71,18 @@ def __init__( solver_kwargs: Optional[dict] = None, ): self.regularizer = regularizer - self.regularizer_strength = regularizer_strength + + # check regularizer strength + if regularizer_strength is None: + warnings.warn( + UserWarning( + "Caution: regularizer strength has not been set. Defaulting to 1.0. Please see " + "the documentation for best practices in setting regularization strength." + ) + ) + self._regularizer_strength = 1.0 + else: + self.regularizer_strength = regularizer_strength # no solver name provided, use default if solver_name is None: @@ -102,15 +113,15 @@ def regularizer(self, regularizer: Union[str, Regularizer]): f"{AVAILABLE_REGULARIZERS} or an instance of `nemos.regularizer.Regularizer`" ) + if isinstance(self._regularizer, UnRegularized): + self._regularizer_strength = None + @property def regularizer_strength(self) -> float: return self._regularizer_strength @regularizer_strength.setter def regularizer_strength(self, strength: float): - if strength is None: - self._regularizer_strength = None - return try: # force conversion to float to prevent weird GPU issues strength = float(strength) @@ -221,13 +232,6 @@ def instantiate_solver( f"{self._regularizer.allowed_solvers}." ) - if self.regularizer_strength is None: - warnings.warn( - "Caution: regularizer strength has not been set. Defaulting to 1.0. Please see " - "the documentation for best practices in setting regularization strength." - ) - self.regularizer_strength = 1.0 - # only use penalized loss if not using proximal gradient descent if self.solver_name != "ProximalGradient": loss = self.regularizer.penalized_loss( diff --git a/tests/test_glm.py b/tests/test_glm.py index c6ed5eb2..499df262 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1363,13 +1363,18 @@ def test_compatibility_with_sklearn_cv_gamma(self, gammaGLM_model_instantiation) "reg, dof", [ (nmo.regularizer.UnRegularized(), np.array([5])), - (nmo.regularizer.Lasso(), np.array([3])), # this lasso fit has only 3 coeff of the first neuron + ( + nmo.regularizer.Lasso(), + np.array([3]), + ), # this lasso fit has only 3 coeff of the first neuron # surviving (nmo.regularizer.Ridge(), np.array([5])), ], ) @pytest.mark.parametrize("n_samples", [1, 20]) - def test_estimate_dof_resid(self, n_samples, dof, reg, poissonGLM_model_instantiation): + def test_estimate_dof_resid( + self, n_samples, dof, reg, poissonGLM_model_instantiation + ): """ Test that the dof is an integer. """ @@ -1381,16 +1386,16 @@ def test_estimate_dof_resid(self, n_samples, dof, reg, poissonGLM_model_instanti assert np.allclose(num, n_samples - dof - 1) @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) - def test_waning_solver_reg_str(self, reg): + def test_warning_solver_reg_str(self, reg): # check that a warning is triggered # if no param is passed with pytest.warns(UserWarning): nmo.glm.GLM(regularizer=reg) - # check that the warning is not triggered + # # check that the warning is not triggered with warnings.catch_warnings(): warnings.simplefilter("error") - model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) # reset to unregularized model.regularizer = "unregularized" @@ -1399,16 +1404,11 @@ def test_waning_solver_reg_str(self, reg): @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) def test_reg_strength_reset(self, reg): - model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) model.regularizer = "unregularized" assert model.regularizer_strength is None - - - - - class TestPopulationGLM: """ Unit tests for the PoissonGLM class. @@ -1520,13 +1520,18 @@ def test_fit_param_length( "reg, dof", [ (nmo.regularizer.UnRegularized(), np.array([5, 5, 5])), - (nmo.regularizer.Lasso(), np.array([3, 0, 0])), # this lasso fit has only 3 coeff of the first neuron - # surviving + ( + nmo.regularizer.Lasso(), + np.array([3, 0, 0]), + ), # this lasso fit has only 3 coeff of the first neuron + # surviving (nmo.regularizer.Ridge(), np.array([5, 5, 5])), ], ) @pytest.mark.parametrize("n_samples", [1, 20]) - def test_estimate_dof_resid(self, n_samples, dof, reg, poisson_population_GLM_model): + def test_estimate_dof_resid( + self, n_samples, dof, reg, poisson_population_GLM_model + ): """ Test that the dof is an integer. """ @@ -2865,17 +2870,17 @@ def test_feature_mask_compatibility_fit_tree( ), ( nmo.regularizer.UnRegularized(), - None, + 1.0, "GradientDescent", {"tol": 10**-14}, ), ( nmo.regularizer.Ridge(), - None, + 1.0, "LBFGS", {"tol": 10**-14}, ), - (nmo.regularizer.Ridge(), None, "LBFGS", {"stepsize": 0.1, "tol": 10**-14}), + (nmo.regularizer.Ridge(), 1.0, "LBFGS", {"stepsize": 0.1, "tol": 10**-14}), ( nmo.regularizer.Lasso(), 0.001, @@ -2977,7 +2982,7 @@ def test_waning_solver_reg_str(self, reg): # check that the warning is not triggered with warnings.catch_warnings(): warnings.simplefilter("error") - model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) # reset to unregularized model.regularizer = "unregularized" @@ -2986,7 +2991,6 @@ def test_waning_solver_reg_str(self, reg): @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) def test_reg_strength_reset(self, reg): - model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.) + model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) model.regularizer = "unregularized" assert model.regularizer_strength is None - From 897dca678179cc6570bde176fa234eaf8104cdd7 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 16:52:34 -0400 Subject: [PATCH 080/225] switched to bounds --- docs/background/plot_01_1D_basis_function.py | 2 +- src/nemos/basis.py | 227 ++++++-------- tests/test_basis.py | 296 +++++++------------ 3 files changed, 187 insertions(+), 338 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index d0e3f61c..35e005fe 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -52,7 +52,7 @@ # You can specify a range for the support of the basis by setting the `vmin` and `vmax` # parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. -bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, vmin=0.2, vmax=0.8) +bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, bounds=0.2, vmax=0.8) print("Evaluated basis:") # 0.5 is within the support, 0.1 is outside the support diff --git a/src/nemos/basis.py b/src/nemos/basis.py index dd921704..e680f343 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -61,7 +61,7 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): def min_max_rescale_samples( - sample_pts: NDArray, vmin: Optional[float] = None, vmax: Optional[float] = None + sample_pts: NDArray, bounds: Optional[Tuple[float, float]] = None, ) -> Tuple[NDArray, float]: """Rescale samples to [0,1]. @@ -69,16 +69,15 @@ def min_max_rescale_samples( ---------- sample_pts: The original samples. - vmin: - Sample value that is mapped to 0. Default is `min(sample_pts)`. - vmax: - Sample value that is mapped to 1. Default is `max(sample_pts)`. + bounds: + Sample bounds. `bounds[0]` abd `bounds[1]` are mapped to 0/1 respectively. + Default are `min(sample_pts), max(sample_pts)`. Raises ------ ValueError If all the samples contain invalid entries (either NaN or Inf). - This may happen if `max(sample) < vmin` or `min(sample) > vmax`. + This may happen if `max(sample) < bounds[0]` or `min(sample) > bounds[1]`. Warns ----- @@ -86,10 +85,10 @@ def min_max_rescale_samples( If more than 90% of the sample points contain NaNs or Infs. """ sample_pts = sample_pts.astype(float) + vmin = np.nanmin(sample_pts) if bounds is None else bounds[0] + vmax = np.nanmax(sample_pts) if bounds is None else bounds[1] if vmin and vmax and vmax <= vmin: raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") - vmin = np.nanmin(sample_pts) if vmin is None else vmin - vmax = np.nanmax(sample_pts) if vmax is None else vmax sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin if vmin != vmax: # Needed is samples contain a single value. @@ -228,14 +227,10 @@ class Basis(abc.ABC): 'conv' for convolutional operation. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. *args : Only used in "conv" mode. Additional positional arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -251,8 +246,7 @@ def __init__( *args, mode: Literal["eval", "conv"] = "eval", window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ) -> None: self.n_basis_funcs = n_basis_funcs @@ -260,8 +254,13 @@ def __init__( self._check_n_basis_min() self._conv_args = args self._conv_kwargs = kwargs - self._vmin = vmin if vmin is None else float(vmin) - self._vmax = vmax if vmax is None else float(vmax) + + if bounds is not None and len(bounds) != 2: + raise ValueError(f"The provided `bounds` must be of length two. Length {len(bounds)} provided instead!") + + # convert to float and store + self._bounds = bounds if bounds is None else tuple(map(float, bounds)) + # check mode if mode not in ["conv", "eval"]: raise ValueError( @@ -276,9 +275,9 @@ def __init__( raise ValueError( f"`window_size` must be a positive integer. {window_size} provided instead!" ) - if vmin is not None or vmax is not None: + if bounds is not None: raise ValueError( - "`vmin` and `vmax` should only be set when `mode=='conv'`." + "`bounds` should only be set when `mode=='eval'`." ) else: if args: @@ -296,12 +295,8 @@ def __init__( self._identifiability_constraints = False @property - def vmin(self): - return self._vmin - - @property - def vmax(self): - return self._vmax + def bounds(self): + return self._bounds @property def mode(self): @@ -529,16 +524,10 @@ def _get_samples(self, *n_samples: int) -> Generator[NDArray]: """ # handling of defaults when evaluating on a grid # (i.e. when we cannot use max and min of samples) - if self.vmin is None and self.vmax is None: + if self.bounds is None: mn, mx = 0, 1 - elif self.vmin is None: - mn = self.vmax - 1 # min has to be lower than the provided max - mx = self.vmax - elif self.vmax is None: - mn = self.vmin - mx = mn + 1 # max has to be greater than the provided min else: - mn, mx = self.vmin, self.vmax + mn, mx = self.bounds return (np.linspace(mn, mx, n_samples[k]) for k in range(len(n_samples))) @support_pynapple(conv_type="numpy") @@ -1000,14 +989,10 @@ class SplineBasis(Basis, abc.ABC): Spline order. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1026,8 +1011,7 @@ def __init__( mode="eval", order: int = 2, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ) -> None: self.order = order @@ -1036,8 +1020,7 @@ def __init__( *args, mode=mode, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) self._n_input_dimensionality = 1 @@ -1136,14 +1119,10 @@ class MSplineBasis(SplineBasis): derivatives at each interior knot, resulting in smoother basis functions. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs: Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1171,8 +1150,7 @@ def __init__( mode="eval", order: int = 2, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ) -> None: super().__init__( @@ -1181,8 +1159,7 @@ def __init__( mode=mode, order=order, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) @@ -1213,7 +1190,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: integrate to one over the domain defined by the sample points. """ sample_pts, scaling = min_max_rescale_samples( - sample_pts, vmin=self.vmin, vmax=self.vmax + sample_pts, self.bounds ) # add knots if not passed knot_locs = self._generate_knots( @@ -1297,14 +1274,10 @@ class BSplineBasis(SplineBasis): The higher this number, the smoother the basis representation will be. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1329,8 +1302,7 @@ def __init__( mode="eval", order: int = 4, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ): super().__init__( @@ -1339,8 +1311,7 @@ def __init__( mode=mode, order=order, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) @@ -1372,7 +1343,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: from SciPy to compute the basis values. """ sample_pts, _ = min_max_rescale_samples( - sample_pts, vmin=self.vmin, vmax=self.vmax + sample_pts, self.bounds ) # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) @@ -1433,14 +1404,10 @@ class CyclicBSplineBasis(SplineBasis): The higher this number, the smoother the basis representation will be. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1460,8 +1427,7 @@ def __init__( mode="eval", order: int = 4, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ): super().__init__( @@ -1470,8 +1436,7 @@ def __init__( mode=mode, order=order, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) if self.order < 2: @@ -1507,7 +1472,7 @@ def __call__( """ sample_pts, _ = min_max_rescale_samples( - sample_pts, vmin=self.vmin, vmax=self.vmax + sample_pts, self.bounds ) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) @@ -1595,14 +1560,10 @@ class RaisedCosineBasisLinear(Basis): Width of the raised cosine. By default, it's set to 2.0. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1622,8 +1583,7 @@ def __init__( mode="eval", width: float = 2.0, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ) -> None: super().__init__( @@ -1631,13 +1591,16 @@ def __init__( *args, mode=mode, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) self._n_input_dimensionality = 1 self._check_width(width) self._width = width + # if linear raised cosine are initialized + # this flag is always true, the samples + # must be rescaled to 0 and 1. + self._rescale_samples = True @property def width(self): @@ -1673,7 +1636,6 @@ def _check_width(width: float) -> None: def __call__( self, sample_pts: ArrayLike, - rescale_samples=True, ) -> FeatureMatrix: """Generate basis functions with given samples. @@ -1681,11 +1643,6 @@ def __call__( ---------- sample_pts : Spacing for basis functions, holding elements on interval [0, 1], Shape (number of samples, ). - rescale_samples : - If `True`, the sample points will be rescaled to the interval [0, 1]. If `False`, no rescaling is applied. - Rescaling is performed as follows: `sample_pts = (sample_pts - vmin) / (vmax - vmin)`. - If `vmin` and `vmax` are not provided at initialization, the minimum and maximum values of `sample_pts` - are used, respectively. Values outside the [vmin, vmax] range are set to NaN. Raises ------ @@ -1693,7 +1650,7 @@ def __call__( If the sample provided do not lie in [0,1]. """ - if rescale_samples: + if self._rescale_samples: # note that sample points is converted to NDArray # with the decorator. # copy is necessary otherwise: @@ -1702,7 +1659,7 @@ def __call__( # additive_basis = basis1 + basis2 # additive_basis(*([x] * 2)) would modify both inputs sample_pts, _ = min_max_rescale_samples( - np.copy(sample_pts), vmin=self.vmin, vmax=self.vmax + np.copy(sample_pts), self.bounds ) peaks = self._compute_peaks() @@ -1799,14 +1756,10 @@ class RaisedCosineBasisLog(RaisedCosineBasisLinear): decays to 0. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1828,8 +1781,7 @@ def __init__( time_scaling: float = None, enforce_decay_to_zero: bool = True, window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ) -> None: super().__init__( @@ -1838,10 +1790,14 @@ def __init__( mode=mode, width=width, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) + # overwrite the flag for scaling the samples to [0,1] in the super.__call__(...). + # The samples are scaled appropriately in the self._transform_samples which scales + # and applies the log-stretch, no additional transform is needed. + self._rescale_samples = False + self.enforce_decay_to_zero = enforce_decay_to_zero if time_scaling is None: self._time_scaling = 50.0 @@ -1864,8 +1820,6 @@ def _check_time_scaling(time_scaling: float) -> None: def _transform_samples( self, sample_pts: ArrayLike, - vmin: Optional[float] = None, - vmax: Optional[float] = None, ) -> NDArray: """ Map the sample domain to log-space. @@ -1875,10 +1829,6 @@ def _transform_samples( sample_pts : Sample points used for evaluating the splines, shape (n_samples, ). - vmin : - The minimum value for rescaling. If not provided, the default is the minimum value of `sample_pts`. - vmax : - The maximum value for rescaling. If not provided, the default is the maximum value of `sample_pts`. Returns ------- @@ -1888,7 +1838,7 @@ def _transform_samples( # rescale to [0,1] # copy is necessary to avoid unwanted rescaling in additive/multiplicative basis. sample_pts, _ = min_max_rescale_samples( - np.copy(sample_pts), vmin=vmin, vmax=vmax + np.copy(sample_pts), self.bounds ) # This log-stretching of the sample axis has the following effect: # - as the time_scaling tends to 0, the points will be linearly spaced across the whole domain. @@ -1944,10 +1894,7 @@ def __call__( ValueError If the sample provided do not lie in [0,1]. """ - return super().__call__( - self._transform_samples(sample_pts, vmin=self.vmin, vmax=self.vmax), - rescale_samples=False, - ) + return super().__call__(self._transform_samples(sample_pts)) class OrthExponentialBasis(Basis): @@ -1967,14 +1914,10 @@ class OrthExponentialBasis(Basis): 'conv' for convolutional operation. window_size : The window size for convolution. Required if mode is 'conv'. - vmin : - The minimum value for the basis domain in `mode="eval"`. The default `vmin` is the minimum of - the samples provided when evaluating the basis. - If a sample is lower than `vmin`, the basis will return NaN. - vmax : - The maximum value for the samples in `mode="eval"`. The default `vmax` is the maximum of - the samples provided when evaluating the basis. - If a sample is larger than `vmax`, the basis will return NaN. + bounds : + The bounds for the basis domain in `mode="eval"`. The default `bounds[0]` and `bounds[1]` are the + minimum and the maximum of the samples provided when evaluating the basis. + If a sample is outside the bonuds, the basis will return NaN. **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` @@ -1987,8 +1930,7 @@ def __init__( *args, mode="eval", window_size: Optional[int] = None, - vmin: Optional[float] = None, - vmax: Optional[float] = None, + bounds: Optional[Tuple[float, float]] = None, **kwargs, ): super().__init__( @@ -1996,8 +1938,7 @@ def __init__( *args, mode=mode, window_size=window_size, - vmin=vmin, - vmax=vmax, + bounds=bounds, **kwargs, ) self._decay_rates = np.asarray(decay_rates) @@ -2090,7 +2031,7 @@ def __call__( """ self._check_sample_size(sample_pts) sample_pts, _ = min_max_rescale_samples( - sample_pts, vmin=self.vmin, vmax=self.vmax + sample_pts, self.bounds ) valid_idx = ~np.isnan(sample_pts) # because of how scipy.linalg.orth works, have to create a matrix of diff --git a/tests/test_basis.py b/tests/test_basis.py index ba71251d..7bc11c0a 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -142,7 +142,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -534,51 +534,35 @@ def test_conv_kwargs_error(self): bas = self.cls(5, mode='eval', test='hi') @pytest.mark.parametrize( - "vmin, vmax, expectation", + "bounds, expectation", [ - (None, None, does_not_raise()), - (None, 3, does_not_raise()), - (1, None, does_not_raise()), - (1, 3, does_not_raise()), - ("a", 3, pytest.raises(ValueError, match="could not convert")), - (1, "a", pytest.raises(ValueError, match="could not convert")), - ("a", "a", pytest.raises(ValueError, match="could not convert")) + (None, does_not_raise()), + ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, 3), does_not_raise()), + (("a", 3), pytest.raises(ValueError, match="could not convert")), + ((1, "a"), pytest.raises(ValueError, match="could not convert")), + (("a", "a"), pytest.raises(ValueError, match="could not convert")) ] ) - def test_vmin_vmax_init(self, vmin, vmax, expectation): + def test_vmin_vmax_init(self, bounds, expectation): with expectation: - bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else:bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax + bas = self.cls(3, bounds=bounds) + assert bounds == bas.bounds if bounds else bas.bounds is None - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", [ (None, None, np.arange(5), []), - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bounds = (vmin, vmax) if vmin else None + bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -594,7 +578,7 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @@ -610,7 +594,7 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan ) def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @@ -626,7 +610,7 @@ def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn ) def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) class TestRaisedCosineLinearBasis(BasisFuncsTesting): @@ -709,7 +693,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -909,7 +893,7 @@ def test_call_equivalent_in_conv(self): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -1054,52 +1038,33 @@ def test_conv_kwargs_error(self): bas = self.cls(5, mode='eval', test='hi') @pytest.mark.parametrize( - "vmin, vmax, expectation", + "bounds, expectation", [ - (None, None, does_not_raise()), - (None, 3, does_not_raise()), - (1, None, does_not_raise()), - (1, 3, does_not_raise()), - ("a", 3, pytest.raises(ValueError, match="could not convert")), - (1, "a", pytest.raises(ValueError, match="could not convert")), - ("a", "a", pytest.raises(ValueError, match="could not convert")) + (None, does_not_raise()), + ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, 3), does_not_raise()), + (("a", 3), pytest.raises(ValueError, match="could not convert")), + ((1, "a"), pytest.raises(ValueError, match="could not convert")), + (("a", "a"), pytest.raises(ValueError, match="could not convert")) ] ) - def test_vmin_vmax_init(self, vmin, vmax, expectation): + def test_vmin_vmax_init(self, bounds, expectation): with expectation: - bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax - - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax + bas = self.cls(3, bounds=bounds) + assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "bounds, samples, nan_idx", [ - (None, None, np.arange(5), []), - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, np.arange(5), []), + ((0, 3), np.arange(5), [4]), + ((1, 6), np.arange(5), [0]), + ((1, 3), np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + def test_vmin_vmax_range(self, bounds, samples, nan_idx): + bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -1115,7 +1080,7 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @@ -1131,7 +1096,7 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan ) def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @@ -1147,7 +1112,7 @@ def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn ) def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) class TestMSplineBasis(BasisFuncsTesting): @@ -1230,7 +1195,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -1435,7 +1400,7 @@ def test_call_equivalent_in_conv(self): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -1575,52 +1540,33 @@ def test_conv_kwargs_error(self): bas = self.cls(5, mode='eval', test='hi') @pytest.mark.parametrize( - "vmin, vmax, expectation", + "bounds, expectation", [ - (None, None, does_not_raise()), - (None, 3, does_not_raise()), - (1, None, does_not_raise()), - (1, 3, does_not_raise()), - ("a", 3, pytest.raises(ValueError, match="could not convert")), - (1, "a", pytest.raises(ValueError, match="could not convert")), - ("a", "a", pytest.raises(ValueError, match="could not convert")) + (None, does_not_raise()), + ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, 3), does_not_raise()), + (("a", 3), pytest.raises(ValueError, match="could not convert")), + ((1, "a"), pytest.raises(ValueError, match="could not convert")), + (("a", "a"), pytest.raises(ValueError, match="could not convert")) ] ) - def test_vmin_vmax_init(self, vmin, vmax, expectation): + def test_vmin_vmax_init(self, bounds, expectation): with expectation: - bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - bas = self.cls(3, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax - - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax + bas = self.cls(3, bounds=bounds) + assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx", + "bounds, samples, nan_idx", [ - (None, None, np.arange(5), []), - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), - (1, 3, np.arange(5), [0, 4]) + (None, np.arange(5), []), + ((0, 3), np.arange(5), [4]), + ((1, 6), np.arange(5), [0]), + ((1, 3), np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + def test_vmin_vmax_range(self, bounds, samples, nan_idx): + bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -1637,7 +1583,7 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, vmin, vmax, samples, nan_idx, scaling): """Check that the MSpline has the expected scaling property.""" bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) # multiply by scaling to get the invariance @@ -1656,7 +1602,7 @@ def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, vmin, vmax, samples ) def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @@ -1672,7 +1618,7 @@ def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn ) def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) class TestOrthExponentialBasis(BasisFuncsTesting): @@ -1772,7 +1718,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax, decay_rates=np.linspace(0.1, 1, 5)) + bas = self.cls(5, bounds=(vmin, vmax), decay_rates=np.linspace(0.1, 1, 5)) with expectation: bas(samples) @@ -2004,7 +1950,7 @@ def test_call_equivalent_in_conv(self): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, decay_rates=np.linspace(0,1,5), vmin=vmin, vmax=vmax) + bas = self.cls(5, decay_rates=np.linspace(0,1,5), bounds=(vmin, vmax)) with expectation: bas(samples) @@ -2240,7 +2186,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -2464,7 +2410,7 @@ def test_call_equivalent_in_conv(self): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -2606,40 +2552,21 @@ def test_conv_kwargs_error(self): bas = self.cls(5, mode='eval', test='hi') @pytest.mark.parametrize( - "vmin, vmax, expectation", + "bounds, expectation", [ - (None, None, does_not_raise()), - (None, 3, does_not_raise()), - (1, None, does_not_raise()), - (1, 3, does_not_raise()), - ("a", 3, pytest.raises(ValueError, match="could not convert")), - (1, "a", pytest.raises(ValueError, match="could not convert")), - ("a", "a", pytest.raises(ValueError, match="could not convert")) + (None, does_not_raise()), + ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, 3), does_not_raise()), + (("a", 3), pytest.raises(ValueError, match="could not convert")), + ((1, "a"), pytest.raises(ValueError, match="could not convert")), + (("a", "a"), pytest.raises(ValueError, match="could not convert")) ] ) - def test_vmin_vmax_init(self, vmin, vmax, expectation): + def test_vmin_vmax_init(self, bounds, expectation): with expectation: - bas = self.cls(5, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - bas = self.cls(5, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax - - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax + bas = self.cls(5, bounds=bounds) + assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", @@ -2651,7 +2578,7 @@ def test_vmin_vmax_init(self, vmin, vmax, expectation): ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -2667,7 +2594,7 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @@ -2683,7 +2610,7 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan ) def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @@ -2699,7 +2626,7 @@ def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn ) def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): with exception: - self.cls(5, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + self.cls(5, mode="conv", window_size=10, bounds=(vmin, vmax)) class TestCyclicBSplineBasis(BasisFuncsTesting): @@ -2783,7 +2710,7 @@ def test_sample_size_of_compute_features_matches_that_of_input( ] ) def test_compute_features_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -3025,7 +2952,7 @@ def test_call_equivalent_in_conv(self): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -3040,7 +2967,7 @@ def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): ] ) def test_call_vmin_vmax(self, samples, vmin, vmax, expectation): - bas = self.cls(5, vmin=vmin, vmax=vmax) + bas = self.cls(5, bounds=(vmin, vmax)) with expectation: bas(samples) @@ -3182,40 +3109,21 @@ def test_conv_kwargs_error(self): bas = self.cls(5, mode='eval', test='hi') @pytest.mark.parametrize( - "vmin, vmax, expectation", + "bounds, expectation", [ - (None, None, does_not_raise()), - (None, 3, does_not_raise()), - (1, None, does_not_raise()), - (1, 3, does_not_raise()), - ("a", 3, pytest.raises(ValueError, match="could not convert")), - (1, "a", pytest.raises(ValueError, match="could not convert")), - ("a", "a", pytest.raises(ValueError, match="could not convert")) + (None, does_not_raise()), + ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((1, 3), does_not_raise()), + (("a", 3), pytest.raises(ValueError, match="could not convert")), + ((1, "a"), pytest.raises(ValueError, match="could not convert")), + (("a", "a"), pytest.raises(ValueError, match="could not convert")) ] ) - def test_vmin_vmax_init(self, vmin, vmax, expectation): + def test_vmin_vmax_init(self, bounds, expectation): with expectation: - bas = self.cls(5, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - bas = self.cls(5, vmin=vmin, vmax=vmax) - if vmin is None: - assert bas.vmin is vmin - else: - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax - - assert bas.vmin == vmin - - if vmax is None: - assert bas.vmax is vmax - else: - assert bas.vmax == vmax + bas = self.cls(5, bounds=bounds) + assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", @@ -3227,7 +3135,7 @@ def test_vmin_vmax_init(self, vmin, vmax, expectation): ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -3243,7 +3151,7 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @@ -3259,7 +3167,7 @@ def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan ) def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", vmin=vmin, vmax=vmax) + bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @@ -3275,7 +3183,7 @@ def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn ) def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): with exception: - self.cls(5, mode="conv", window_size=10, vmin=vmin, vmax=vmax) + self.cls(5, mode="conv", window_size=10, bounds=(vmin, vmax)) class CombinedBasis(BasisFuncsTesting): """ From cca807595bdd854c8d883cf192eef7c8d4611b70 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:16:34 -0400 Subject: [PATCH 081/225] fixed tests basis --- tests/test_basis.py | 232 ++++++++++++++++++++++---------------------- 1 file changed, 118 insertions(+), 114 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 7bc11c0a..685d1236 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -561,7 +561,7 @@ def test_vmin_vmax_init(self, bounds, expectation): ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bounds = (vmin, vmax) if vmin else None + bounds = None if vmin is None else (vmin, vmax) bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) @@ -571,46 +571,46 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) + bas_no_range = self.cls(3, mode="eval", window_size=10, bounds=None) + bas = self.cls(3, mode="eval", window_size=10, bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, mn, mx", + "bounds, samples, nan_idx, mn, mx", [ - (None, None, np.arange(5), [4], 0, 1), - (None, 3, np.arange(5), [4], 2, 3), - (1, None, np.arange(5), [0], 1, 2), - (1, 3, np.arange(5), [0, 4], 1, 3) + (None, np.arange(5), [4], 0, 1), + ((0, 3), np.arange(5), [4], 0, 3), + ((1, 4), np.arange(5), [0], 1, 4), + ((1, 3), np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) + def test_vmin_vmax_eval_on_grid_affects_x(self, bounds, samples, nan_idx, mn, mx): + bas_no_range = self.cls(3, mode="eval", bounds=None) + bas = self.cls(3, mode="eval", bounds=bounds) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( - "vmin, vmax, samples, exception", + "bounds, samples, exception", [ - (None, None, np.arange(5), does_not_raise()), - (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + (None, np.arange(5), does_not_raise()), + ((0, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 4), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")) ] ) - def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) + self.cls(3, mode="conv", window_size=10, bounds=bounds) class TestRaisedCosineLinearBasis(BasisFuncsTesting): @@ -1055,15 +1055,16 @@ def test_vmin_vmax_init(self, bounds, expectation): assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( - "bounds, samples, nan_idx", + "vmin, vmax, samples, nan_idx", [ - (None, np.arange(5), []), - ((0, 3), np.arange(5), [4]), - ((1, 6), np.arange(5), [0]), - ((1, 3), np.arange(5), [0, 4]) + (None, None, np.arange(5), []), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_range(self, bounds, samples, nan_idx): + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bounds = None if vmin is None else (vmin, vmax) bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) @@ -1073,46 +1074,46 @@ def test_vmin_vmax_range(self, bounds, samples, nan_idx): @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) + bas_no_range = self.cls(3, mode="eval", bounds=None) bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, mn, mx", + "bounds, samples, nan_idx, mn, mx", [ - (None, None, np.arange(5), [4], 0, 1), - (None, 3, np.arange(5), [4], 2, 3), - (1, None, np.arange(5), [0], 1, 2), - (1, 3, np.arange(5), [0, 4], 1, 3) + (None, np.arange(5), [4], 0, 1), + ((0, 3), np.arange(5), [4], 0, 3), + ((1, 4), np.arange(5), [0], 1, 4), + ((1, 3), np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) + def test_vmin_vmax_eval_on_grid_affects_x(self, bounds, samples, nan_idx, mn, mx): + bas_no_range = self.cls(3, mode="eval", bounds=None) + bas = self.cls(3, mode="eval", bounds=bounds) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( - "vmin, vmax, samples, exception", + "bounds, samples, exception", [ - (None, None, np.arange(5), does_not_raise()), - (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + (None, np.arange(5), does_not_raise()), + ((0, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 4), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")) ] ) - def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) + self.cls(3, mode="conv", window_size=10, bounds=bounds) class TestMSplineBasis(BasisFuncsTesting): @@ -1557,15 +1558,16 @@ def test_vmin_vmax_init(self, bounds, expectation): assert bounds == bas.bounds if bounds else bas.bounds is None @pytest.mark.parametrize( - "bounds, samples, nan_idx", + "vmin, vmax, samples, nan_idx", [ - (None, np.arange(5), []), - ((0, 3), np.arange(5), [4]), - ((1, 6), np.arange(5), [0]), - ((1, 3), np.arange(5), [0, 4]) + (None, None, np.arange(5), []), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), + (1, 3, np.arange(5), [0, 4]) ] ) - def test_vmin_vmax_range(self, bounds, samples, nan_idx): + def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): + bounds = None if vmin is None else (vmin, vmax) bas = self.cls(3, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) @@ -1573,17 +1575,17 @@ def test_vmin_vmax_range(self, bounds, samples, nan_idx): assert np.all(~np.isnan(out[valid_idx])) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, scaling", + "bounds, samples, nan_idx, scaling", [ - (None, 3, np.arange(5), [4], 1), - (1, None, np.arange(5), [0], 1), - (1, 3, np.arange(5), [0, 4], 2) + (None, np.arange(5), [4], 1), + ((1, 4), np.arange(5), [0], 3), + ((1, 3), np.arange(5), [0, 4], 2) ] ) - def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, vmin, vmax, samples, nan_idx, scaling): + def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, bounds, samples, nan_idx, scaling): """Check that the MSpline has the expected scaling property.""" - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) + bas_no_range = self.cls(3, mode="eval", bounds=None) + bas = self.cls(3, mode="eval", bounds=bounds) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) # multiply by scaling to get the invariance @@ -1592,33 +1594,33 @@ def test_vmin_vmax_eval_on_grid_scaling_effect_on_eval(self, vmin, vmax, samples assert np.allclose(out1 * scaling, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, mn, mx", + "bounds, samples, nan_idx, mn, mx", [ - (None, None, np.arange(5), [4], 0, 1), - (None, 3, np.arange(5), [4], 2, 3), - (1, None, np.arange(5), [0], 1, 2), - (1, 3, np.arange(5), [0, 4], 1, 3) + (None, np.arange(5), [4], 0, 1), + ((0, 3), np.arange(5), [4], 0, 3), + ((1, 4), np.arange(5), [0], 1, 4), + ((1, 3), np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): - bas_no_range = self.cls(3, mode="eval", vmin=None, vmax=None) - bas = self.cls(3, mode="eval", bounds=(vmin, vmax)) + def test_vmin_vmax_eval_on_grid_affects_x(self, bounds, samples, nan_idx, mn, mx): + bas_no_range = self.cls(3, mode="eval", bounds=None) + bas = self.cls(3, mode="eval", bounds=bounds) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( - "vmin, vmax, samples, exception", + "bounds, samples, exception", [ - (None, None, np.arange(5), does_not_raise()), - (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + (None, np.arange(5), does_not_raise()), + ((0, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 4), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")) ] ) - def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: - self.cls(3, mode="conv", window_size=10, bounds=(vmin, vmax)) + self.cls(3, mode="conv", window_size=10, bounds=bounds) class TestOrthExponentialBasis(BasisFuncsTesting): @@ -2572,13 +2574,14 @@ def test_vmin_vmax_init(self, bounds, expectation): "vmin, vmax, samples, nan_idx", [ (None, None, np.arange(5), []), - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) + bounds = None if vmin is None else (vmin, vmax) + bas = self.cls(5, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -2587,46 +2590,46 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): - bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas_no_range = self.cls(5, mode="eval", bounds=None) bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, mn, mx", + "bounds, samples, nan_idx, mn, mx", [ - (None, None, np.arange(5), [4], 0, 1), - (None, 3, np.arange(5), [4], 2, 3), - (1, None, np.arange(5), [0], 1, 2), - (1, 3, np.arange(5), [0, 4], 1, 3) + (None, np.arange(5), [4], 0, 1), + ((0, 3), np.arange(5), [4], 0, 3), + ((1, 4), np.arange(5), [0], 1, 4), + ((1, 3), np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): - bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) + def test_vmin_vmax_eval_on_grid_affects_x(self, bounds, samples, nan_idx, mn, mx): + bas_no_range = self.cls(5, mode="eval", bounds=None) + bas = self.cls(5, mode="eval", bounds=bounds) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( - "vmin, vmax, samples, exception", + "bounds, samples, exception", [ - (None, None, np.arange(5), does_not_raise()), - (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + (None, np.arange(5), does_not_raise()), + ((0, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 4), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")) ] ) - def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: - self.cls(5, mode="conv", window_size=10, bounds=(vmin, vmax)) + self.cls(5, mode="conv", window_size=10, bounds=bounds) class TestCyclicBSplineBasis(BasisFuncsTesting): @@ -3129,13 +3132,14 @@ def test_vmin_vmax_init(self, bounds, expectation): "vmin, vmax, samples, nan_idx", [ (None, None, np.arange(5), []), - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): - bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) + bounds = None if vmin is None else (vmin, vmax) + bas = self.cls(5, mode="eval", bounds=bounds) out = bas.compute_features(samples) assert np.all(np.isnan(out[nan_idx])) valid_idx = list(set(samples).difference(nan_idx)) @@ -3144,46 +3148,46 @@ def test_vmin_vmax_range(self, vmin, vmax, samples, nan_idx): @pytest.mark.parametrize( "vmin, vmax, samples, nan_idx", [ - (None, 3, np.arange(5), [4]), - (1, None, np.arange(5), [0]), + (0, 3, np.arange(5), [4]), + (1, 4, np.arange(5), [0]), (1, 3, np.arange(5), [0, 4]) ] ) def test_vmin_vmax_eval_on_grid_no_effect_on_eval(self, vmin, vmax, samples, nan_idx): - bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) + bas_no_range = self.cls(5, mode="eval", bounds=None) bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) _, out1 = bas.evaluate_on_grid(10) _, out2 = bas_no_range.evaluate_on_grid(10) assert np.allclose(out1, out2) @pytest.mark.parametrize( - "vmin, vmax, samples, nan_idx, mn, mx", + "bounds, samples, nan_idx, mn, mx", [ - (None, None, np.arange(5), [4], 0, 1), # default to 0, 1 by convention of Basis._get_samples - (None, 3, np.arange(5), [4], 2, 3), # mn is mx - 1 by convention of Basis._get_samples - (1, None, np.arange(5), [0], 1, 2), # mx is mn + 1 by convention of Basis._get_samples - (1, 3, np.arange(5), [0, 4], 1, 3) + (None, np.arange(5), [4], 0, 1), + ((0, 3), np.arange(5), [4], 0, 3), + ((1, 4), np.arange(5), [0], 1, 4), + ((1, 3), np.arange(5), [0, 4], 1, 3) ] ) - def test_vmin_vmax_eval_on_grid_affects_x(self, vmin, vmax, samples, nan_idx, mn, mx): - bas_no_range = self.cls(5, mode="eval", vmin=None, vmax=None) - bas = self.cls(5, mode="eval", bounds=(vmin, vmax)) + def test_vmin_vmax_eval_on_grid_affects_x(self, bounds, samples, nan_idx, mn, mx): + bas_no_range = self.cls(5, mode="eval", bounds=None) + bas = self.cls(5, mode="eval", bounds=bounds) x1, _ = bas.evaluate_on_grid(10) x2, _ = bas_no_range.evaluate_on_grid(10) assert np.allclose(x1, x2 * (mx - mn) + mn) @pytest.mark.parametrize( - "vmin, vmax, samples, exception", + "bounds, samples, exception", [ - (None, None, np.arange(5), does_not_raise()), - (None, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, None, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")), - (1, 3, np.arange(5), pytest.raises(ValueError, match="`vmin` and `vmax` should")) + (None, np.arange(5), does_not_raise()), + ((0, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 4), np.arange(5), pytest.raises(ValueError, match="`bounds` should")), + ((1, 3), np.arange(5), pytest.raises(ValueError, match="`bounds` should")) ] ) - def test_vmin_vmax_mode_conv(self, vmin, vmax, samples, exception): + def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: - self.cls(5, mode="conv", window_size=10, bounds=(vmin, vmax)) + self.cls(5, mode="conv", window_size=10, bounds=bounds) class CombinedBasis(BasisFuncsTesting): """ From 998cfd3283ceab50a8de014a1cd4c4687d90d3f1 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:22:23 -0400 Subject: [PATCH 082/225] linted --- src/nemos/basis.py | 43 ++++++++++++++++--------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index e680f343..fb2f9873 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -61,7 +61,8 @@ def wrapper(self: Basis, *xi: ArrayLike, **kwargs): def min_max_rescale_samples( - sample_pts: NDArray, bounds: Optional[Tuple[float, float]] = None, + sample_pts: NDArray, + bounds: Optional[Tuple[float, float]] = None, ) -> Tuple[NDArray, float]: """Rescale samples to [0,1]. @@ -256,8 +257,10 @@ def __init__( self._conv_kwargs = kwargs if bounds is not None and len(bounds) != 2: - raise ValueError(f"The provided `bounds` must be of length two. Length {len(bounds)} provided instead!") - + raise ValueError( + f"The provided `bounds` must be of length two. Length {len(bounds)} provided instead!" + ) + # convert to float and store self._bounds = bounds if bounds is None else tuple(map(float, bounds)) @@ -276,9 +279,7 @@ def __init__( f"`window_size` must be a positive integer. {window_size} provided instead!" ) if bounds is not None: - raise ValueError( - "`bounds` should only be set when `mode=='eval'`." - ) + raise ValueError("`bounds` should only be set when `mode=='eval'`.") else: if args: raise ValueError( @@ -1189,9 +1190,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: conditions are handled such that the basis functions are positive and integrate to one over the domain defined by the sample points. """ - sample_pts, scaling = min_max_rescale_samples( - sample_pts, self.bounds - ) + sample_pts, scaling = min_max_rescale_samples(sample_pts, self.bounds) # add knots if not passed knot_locs = self._generate_knots( sample_pts, perc_low=0.0, perc_high=1.0, is_cyclic=False @@ -1342,9 +1341,7 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: The evaluation is performed by looping over each element and using `splev` from SciPy to compute the basis values. """ - sample_pts, _ = min_max_rescale_samples( - sample_pts, self.bounds - ) + sample_pts, _ = min_max_rescale_samples(sample_pts, self.bounds) # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) @@ -1471,9 +1468,7 @@ def __call__( SciPy to compute the basis values. """ - sample_pts, _ = min_max_rescale_samples( - sample_pts, self.bounds - ) + sample_pts, _ = min_max_rescale_samples(sample_pts, self.bounds) knot_locs = self._generate_knots(sample_pts, 0.0, 1.0, is_cyclic=True) # for cyclic, do not repeat knots @@ -1598,7 +1593,7 @@ def __init__( self._check_width(width) self._width = width # if linear raised cosine are initialized - # this flag is always true, the samples + # this flag is always true, the samples # must be rescaled to 0 and 1. self._rescale_samples = True @@ -1658,9 +1653,7 @@ def __call__( # basis2 = nmo.basis.RaisedCosineBasisLog(5) # additive_basis = basis1 + basis2 # additive_basis(*([x] * 2)) would modify both inputs - sample_pts, _ = min_max_rescale_samples( - np.copy(sample_pts), self.bounds - ) + sample_pts, _ = min_max_rescale_samples(np.copy(sample_pts), self.bounds) peaks = self._compute_peaks() delta = peaks[1] - peaks[0] @@ -1793,11 +1786,11 @@ def __init__( bounds=bounds, **kwargs, ) - # overwrite the flag for scaling the samples to [0,1] in the super.__call__(...). + # overwrite the flag for scaling the samples to [0,1] in the super.__call__(...). # The samples are scaled appropriately in the self._transform_samples which scales # and applies the log-stretch, no additional transform is needed. self._rescale_samples = False - + self.enforce_decay_to_zero = enforce_decay_to_zero if time_scaling is None: self._time_scaling = 50.0 @@ -1837,9 +1830,7 @@ def _transform_samples( """ # rescale to [0,1] # copy is necessary to avoid unwanted rescaling in additive/multiplicative basis. - sample_pts, _ = min_max_rescale_samples( - np.copy(sample_pts), self.bounds - ) + sample_pts, _ = min_max_rescale_samples(np.copy(sample_pts), self.bounds) # This log-stretching of the sample axis has the following effect: # - as the time_scaling tends to 0, the points will be linearly spaced across the whole domain. # - as the time_scaling tends to inf, basis will be small and dense around 0 and @@ -2030,9 +2021,7 @@ def __call__( """ self._check_sample_size(sample_pts) - sample_pts, _ = min_max_rescale_samples( - sample_pts, self.bounds - ) + sample_pts, _ = min_max_rescale_samples(sample_pts, self.bounds) valid_idx = ~np.isnan(sample_pts) # because of how scipy.linalg.orth works, have to create a matrix of # shape (n_pts, n_basis_funcs) and then transpose, rather than From 0c31798d8aea90ddd9b65aa1379e3c196400ae27 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:34:48 -0400 Subject: [PATCH 083/225] fixed docs --- docs/background/plot_01_1D_basis_function.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index 35e005fe..dbf59fbe 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -29,9 +29,7 @@ # ## Evaluating a Basis # # The `Basis` object is callable, and can be evaluated as a function. By default, the support of the basis -# is defined by the samples that we input to the `__call__` method. This results in an equispaced set of knots, -# which spans the range from the smallest to the largest sample. These knots are then used to construct a -# uniformly spaced basis. +# is defined by the samples that we input to the `__call__` method, and covers from the smallest to the largest value. # Generate a time series of sample points samples = nap.Tsd(t=np.arange(1001), d=np.linspace(0, 1,1001)) @@ -49,10 +47,12 @@ # %% # ## Setting the basis support -# You can specify a range for the support of the basis by setting the `vmin` and `vmax` -# parameters at initialization. Evaluating the basis at any sample outside the range will result in a NaN. +# Sometimes, it is useful to restrict the basis to a fixed range. This can help manage outliers or ensure that +# your basis covers the same range across multiple experimental sessions. +# You can specify a range for the support of your basis by setting the `bounds` +# parameter at initialization. Evaluating the basis at any sample outside the bounds will result in a NaN. -bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, bounds=0.2, vmax=0.8) +bspline_range = nmo.basis.BSplineBasis(n_basis_funcs=n_basis, order=order, bounds=(0.2, 0.8)) print("Evaluated basis:") # 0.5 is within the support, 0.1 is outside the support @@ -68,7 +68,7 @@ axs[0].plot(bspline(samples), color="k") axs[0].set_title("default") axs[1].plot(bspline_range(samples), color="tomato") -axs[1].set_title("range=[0.2, 0.8]") +axs[1].set_title("bounds=[0.2, 0.8]") plt.tight_layout() # %% From ad9ce6966686aa6b2273c0a5d31e810dfbaa098c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:42:05 -0400 Subject: [PATCH 084/225] improved docstrings --- src/nemos/validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/validation.py b/src/nemos/validation.py index 289fbc52..9dd0853f 100644 --- a/src/nemos/validation.py +++ b/src/nemos/validation.py @@ -280,7 +280,7 @@ def check_tree_structure(tree_1: Any, tree_2: Any, err_message: str): def check_fraction_valid_samples(*tree: Any, err_msg: str, warn_msg: str) -> None: """ - Check the fraction of valid entries. + Check the fraction of entries that are not infinite or NaN. Parameters ---------- From b39d23b64e4b5627f84b6d681943b03343759cb8 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Fri, 19 Jul 2024 17:42:19 -0400 Subject: [PATCH 085/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index fb2f9873..4e8e2383 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -92,7 +92,8 @@ def min_max_rescale_samples( raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin - if vmin != vmax: # Needed is samples contain a single value. + # this passes if `samples_pts` contains a single value + if vmin != vmax: scaling = vmax - vmin sample_pts /= scaling else: From e2a6a9229ef116c0afb476cdff6deec44a102a9f Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:52:12 -0400 Subject: [PATCH 086/225] removed unused if else --- src/nemos/basis.py | 49 ++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 4e8e2383..a7a048fa 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1091,7 +1091,7 @@ def _check_n_basis_min(self) -> None: class MSplineBasis(SplineBasis): - """ + r""" M-spline[$^1$](#references) basis functions for modeling and data transformation. M-splines are a type of spline basis function used for smooth curve fitting @@ -1143,6 +1143,11 @@ class MSplineBasis(SplineBasis): ---------- [1] Ramsay, J. O. (1988). Monotone regression splines in action. Statistical science, 3(4), 425-441. + + Notes + ----- + MSplines must integrate to 1 over their domain. Therefore, if the domain (x-axis) of an MSpline + basis is dilated by a factor of $\alpha$, the co-domain (y-axis) values will shrink by a factor of $1/\alpha$. """ def __init__( @@ -1346,13 +1351,10 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: # add knots knot_locs = self._generate_knots(sample_pts, 0.0, 1.0) - valid = ~np.isnan(sample_pts) - if np.any(valid): - basis_eval = bspline( - sample_pts, knot_locs, order=self.order, der=0, outer_ok=False - ) - else: - basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) + basis_eval = bspline( + sample_pts, knot_locs, order=self.order, der=0, outer_ok=False + ) + if self.identifiability_constraints: basis_eval = self._apply_identifiability_constraints(basis_eval) @@ -1489,26 +1491,21 @@ def __call__( ) ) - valid = ~np.isnan(sample_pts) - if np.any(valid): - ind = sample_pts > xc + ind = sample_pts > xc - basis_eval = bspline( - sample_pts, knots, order=self.order, der=0, outer_ok=True - ) - sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] + basis_eval = bspline( + sample_pts, knots, order=self.order, der=0, outer_ok=True + ) + sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] - if np.sum(ind): - basis_eval[ind] = basis_eval[ind] + bspline( - sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 - ) - # restore points - sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] - if self.identifiability_constraints: - basis_eval = self._apply_identifiability_constraints(basis_eval) - else: - # deal with the case of no valid samples - basis_eval = np.full((sample_pts.shape[0], self.n_basis_funcs), np.nan) + if np.sum(ind): + basis_eval[ind] = basis_eval[ind] + bspline( + sample_pts[ind], knots, order=self.order, outer_ok=True, der=0 + ) + # restore points + sample_pts[ind] = sample_pts[ind] + knots.max() - knot_locs[0] + if self.identifiability_constraints: + basis_eval = self._apply_identifiability_constraints(basis_eval) return basis_eval From ea335175399f2d742900c0f65c1c401851fc9df9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 19 Jul 2024 17:58:54 -0400 Subject: [PATCH 087/225] black --- src/nemos/basis.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index a7a048fa..96115e27 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -93,7 +93,7 @@ def min_max_rescale_samples( sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin # this passes if `samples_pts` contains a single value - if vmin != vmax: + if vmin != vmax: scaling = vmax - vmin sample_pts /= scaling else: @@ -1355,7 +1355,6 @@ def __call__(self, sample_pts: ArrayLike) -> FeatureMatrix: sample_pts, knot_locs, order=self.order, der=0, outer_ok=False ) - if self.identifiability_constraints: basis_eval = self._apply_identifiability_constraints(basis_eval) return basis_eval @@ -1493,9 +1492,7 @@ def __call__( ind = sample_pts > xc - basis_eval = bspline( - sample_pts, knots, order=self.order, der=0, outer_ok=True - ) + basis_eval = bspline(sample_pts, knots, order=self.order, der=0, outer_ok=True) sample_pts[ind] = sample_pts[ind] - knots.max() + knot_locs[0] if np.sum(ind): From 71c9e3f08fda9ca0aa5c2a5279d1966b37c4a74b Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 14:18:16 -0400 Subject: [PATCH 088/225] some requested changes --- src/nemos/_regularizer_builder.py | 10 +++++----- src/nemos/base_regressor.py | 26 +++++++++++++------------- src/nemos/glm.py | 29 +++++++++++++++-------------- tests/test_convergence.py | 6 +++--- tests/test_glm.py | 16 ++++++++-------- tests/test_regularizer.py | 14 ++++++++++---- 6 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 59654a77..3bd7d30b 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -1,6 +1,6 @@ """Utility functions for creating regularizer object.""" -AVAILABLE_REGULARIZERS = ["unregularized", "ridge", "lasso", "group_lasso"] +AVAILABLE_REGULARIZERS = ["UnRegularized", "Ridge", "Lasso", "GroupLasso"] def create_regularizer(name: str): @@ -22,19 +22,19 @@ def create_regularizer(name: str): ValueError If the `name` provided does not match to any available regularizer. """ - if name == "unregularized": + if name == "UnRegularized": from .regularizer import UnRegularized return UnRegularized() - elif name == "ridge": + elif name == "Ridge": from .regularizer import Ridge return Ridge() - elif name == "lasso": + elif name == "Lasso": from .regularizer import Lasso return Lasso() - elif name == "group_lasso": + elif name == "GroupLasso": from .regularizer import GroupLasso return GroupLasso() diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index a0ddffc5..c2844a98 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -27,6 +27,14 @@ class BaseRegressor(Base, abc.ABC): regression models. It provides an abstraction for fitting the model, making predictions, scoring the model, simulating responses, and preprocessing data. Concrete classes are expected to provide specific implementations of the abstract methods defined here. + Below is a table listing the default and available solvers for each regularizer. + + | Regularizer | Default Solver | Available Solvers | + | ------------- | ---------------- | ----------------------------------------------------------- | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Lasso | ProximalGradient | ProximalGradient | + | GroupLasso | ProximalGradient | ProximalGradient | Parameters ---------- @@ -41,19 +49,11 @@ class BaseRegressor(Base, abc.ABC): Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. Default is `None`. If no solver specified, one will be chosen based on the regularizer. - Please see table below for regularizer/optimizer pairings. + Please see table above for regularizer/optimizer pairings. solver_kwargs : Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. - See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ - - | Regularizer | Default Solver | Available Solvers | - | ------------- | ---------------- | ----------------------------------------------------------- | - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Lasso | ProximalGradient | ProximalGradient | - | GroupLasso | ProximalGradient | ProximalGradient | - + See the jaxopt documentation for details on each solver's kwargs: https://jaxopt.github.io/stable/ See Also -------- @@ -65,7 +65,7 @@ class BaseRegressor(Base, abc.ABC): def __init__( self, - regularizer: Union[str, Regularizer] = "unregularized", + regularizer: Union[str, Regularizer] = "UnRegularized", regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: Optional[dict] = None, @@ -144,7 +144,7 @@ def solver_name(self, solver_name: str): if solver_name not in self._regularizer.allowed_solvers: raise ValueError( f"The solver: {solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.__class__.__name__} regularizaration. Allowed solvers are " f"{self._regularizer.allowed_solvers}." ) self._solver_name = solver_name @@ -228,7 +228,7 @@ def instantiate_solver( if self.solver_name not in self.regularizer.allowed_solvers: raise ValueError( f"The solver: {self.solver_name} is not allowed for " - f"{self._regularizer.__class__} regularizaration. Allowed solvers are " + f"{self._regularizer.__class__.__name__} regularizaration. Allowed solvers are " f"{self._regularizer.allowed_solvers}." ) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index d44dab0b..a23b2a3d 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -50,7 +50,7 @@ class GLM(BaseRegressor): (like convolved currents or light intensities) and a choice of observation model. It is suitable for scenarios where the relationship between predictors and the response variable might be non-linear, and the residuals don't follow a normal distribution. - Below, a table listing the default and available solvers for each regularizer. + Below is a table listing the default and available solvers for each regularizer. | Regularizer | Default Solver | Available Solvers | | ------------- | ---------------- | ----------------------------------------------------------- | @@ -75,11 +75,11 @@ class GLM(BaseRegressor): Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. Default is `None`. If no solver specified, one will be chosen based on the regularizer. - Please see table below for regularizer/optimizer pairings. + Please see table above for regularizer/optimizer pairings. solver_kwargs : Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. - See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ + See the jaxopt documentation for details on each solver's kwargs: https://jaxopt.github.io/stable/ Attributes ---------- @@ -106,7 +106,7 @@ class GLM(BaseRegressor): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: Union[str, Regularizer] = "unregularized", + regularizer: Union[str, Regularizer] = "UnRegularized", regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: dict = None, @@ -1013,6 +1013,14 @@ class PopulationGLM(GLM): It is suitable for scenarios where the relationship between predictors and the response variable might be non-linear, and the residuals don't follow a normal distribution. The predictors must be stored in tabular format, shape (n_timebins, num_features) or as [FeaturePytree](../pytrees). + Below is a table listing the default and available solvers for each regularizer. + + | Regularizer | Default Solver | Available Solvers | + | ------------- | ---------------- | ----------------------------------------------------------- | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | Lasso | ProximalGradient | ProximalGradient | + | GroupLasso | ProximalGradient | ProximalGradient | Parameters ---------- @@ -1030,23 +1038,16 @@ class PopulationGLM(GLM): Solver to use for model optimization. Defines the optimization scheme and related parameters. The solver must be an appropriate match for the chosen regularizer. Default is `None`. If no solver specified, one will be chosen based on the regularizer. - Please see table below for regularizer/optimizer pairings. + Please see table above for regularizer/optimizer pairings. solver_kwargs : Optional dictionary for keyword arguments that are passed to the solver when instantiated. E.g. stepsize, acceleration, value_and_grad, etc. - See the jaxopt documentation for more details: https://jaxopt.github.io/stable/ + See the jaxopt documentation for details on each solver's kwargs: https://jaxopt.github.io/stable/ feature_mask : Either a matrix of shape (num_features, num_neurons) or a [FeaturePytree](../pytrees) of 0s and 1s, with `feature_mask[feature_name]` of shape (num_neurons, ). The mask will be used to select which features are used as predictors for which neuron. - | Regularizer | Default Solver | Available Solvers | - | ------------- | ---------------- | ----------------------------------------------------------- | - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Lasso | ProximalGradient | ProximalGradient | - | GroupLasso | ProximalGradient | ProximalGradient | - Attributes ---------- intercept_ : @@ -1110,7 +1111,7 @@ class PopulationGLM(GLM): def __init__( self, observation_model: obs.Observations = obs.PoissonObservations(), - regularizer: Union[str, Regularizer] = "unregularized", + regularizer: Union[str, Regularizer] = "UnRegularized", regularizer_strength: Optional[float] = None, solver_name: str = None, solver_kwargs: dict = None, diff --git a/tests/test_convergence.py b/tests/test_convergence.py index 5eeb5d3f..d4765a80 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -63,11 +63,11 @@ def test_ridge_convergence(): y = np.random.poisson(rate) # instantiate and fit ridge GLM with GradientDescent - model_GD = nmo.glm.GLM(regularizer="ridge") + model_GD = nmo.glm.GLM(regularizer="Ridge") model_GD.fit(X, y) # instantiate and fit ridge GLM with ProximalGradient - model_PG = nmo.glm.GLM(regularizer="ridge", solver_name="ProximalGradient") + model_PG = nmo.glm.GLM(regularizer="Ridge", solver_name="ProximalGradient") model_PG.fit(X, y) # assert weights are the same @@ -86,7 +86,7 @@ def test_lasso_convergence(): y = np.random.poisson(np.exp(X.dot(w))) # observed counts # instantiate and fit GLM with ProximalGradient - model_PG = nmo.glm.GLM(regularizer="lasso", solver_name="ProximalGradient") + model_PG = nmo.glm.GLM(regularizer="Lasso", solver_name="ProximalGradient") model_PG.regularizer_strength = 0.1 model_PG.fit(X, y) diff --git a/tests/test_glm.py b/tests/test_glm.py index 499df262..4d436789 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1385,7 +1385,7 @@ def test_estimate_dof_resid( num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) assert np.allclose(num, n_samples - dof - 1) - @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + @pytest.mark.parametrize("reg", ["Ridge", "Lasso", "GroupLasso"]) def test_warning_solver_reg_str(self, reg): # check that a warning is triggered # if no param is passed @@ -1398,14 +1398,14 @@ def test_warning_solver_reg_str(self, reg): model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) # reset to unregularized - model.regularizer = "unregularized" + model.regularizer = "UnRegularized" with pytest.warns(UserWarning): nmo.glm.GLM(regularizer=reg) - @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + @pytest.mark.parametrize("reg", ["Ridge", "Lasso", "GroupLasso"]) def test_reg_strength_reset(self, reg): model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) - model.regularizer = "unregularized" + model.regularizer = "UnRegularized" assert model.regularizer_strength is None @@ -2972,7 +2972,7 @@ def map_neu(k, coef_): print(f"\nMAX ERR: {np.abs(coef_loop - coef_vectorized).max()}") assert np.allclose(coef_loop, coef_vectorized, atol=10**-5, rtol=0) - @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + @pytest.mark.parametrize("reg", ["Ridge", "Lasso", "GroupLasso"]) def test_waning_solver_reg_str(self, reg): # check that a warning is triggered # if no param is passed @@ -2985,12 +2985,12 @@ def test_waning_solver_reg_str(self, reg): model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) # reset to unregularized - model.regularizer = "unregularized" + model.regularizer = "UnRegularized" with pytest.warns(UserWarning): nmo.glm.GLM(regularizer=reg) - @pytest.mark.parametrize("reg", ["ridge", "lasso", "group_lasso"]) + @pytest.mark.parametrize("reg", ["Ridge", "Lasso", "GroupLasso"]) def test_reg_strength_reset(self, reg): model = nmo.glm.GLM(regularizer=reg, regularizer_strength=1.0) - model.regularizer = "unregularized" + model.regularizer = "UnRegularized" assert model.regularizer_strength is None diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 5fbfe721..73ab2028 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -16,10 +16,10 @@ @pytest.mark.parametrize( "reg_str, reg_type", [ - ("unregularized", nmo.regularizer.Regularizer), - ("ridge", nmo.regularizer.Ridge), - ("lasso", nmo.regularizer.Lasso), - ("group_lasso", nmo.regularizer.GroupLasso), + ("UnRegularized", nmo.regularizer.Regularizer), + ("Ridge", nmo.regularizer.Ridge), + ("Lasso", nmo.regularizer.Lasso), + ("GroupLasso", nmo.regularizer.GroupLasso), ("not_valid", None), ], ) @@ -40,6 +40,12 @@ def test_regularizer_builder(reg_str, reg_type): assert regularizer.__dict__ == regularizer2.__dict__ +def test_regularizer_available(): + for regularizer in nmo._regularizer_builder.AVAILABLE_REGULARIZERS: + reg = nmo._regularizer_builder.create_regularizer(regularizer) + assert reg.__class__.__name__ == regularizer + + @pytest.mark.parametrize( "regularizer_strength, reg_type", [ From d4b56cb286f63a7e06129dadaea0bcedb262a9db Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 14:48:09 -0400 Subject: [PATCH 089/225] make degrees of freedom an attribute of glm, make calculation private --- src/nemos/glm.py | 13 ++++++++----- tests/test_glm.py | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index a23b2a3d..3326105e 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -94,6 +94,8 @@ class GLM(BaseRegressor): Scale parameter for the model. The scale parameter is the constant $\Phi$, for which $\text{Var} \left( y \right) = \Phi V(\mu)$. This parameter, together with the estimate of the mean $\mu$ fully specifies the distribution of the activity $y$. + dof: + Degrees of freedom for the residuals. Raises @@ -128,6 +130,7 @@ def __init__( self._solver_init_state = None self._solver_update = None self._solver_run = None + self.dof = None @property def observation_model(self) -> Union[None, obs.Observations]: @@ -692,9 +695,9 @@ def fit( self._set_coef_and_intercept(params) - dof_resid = self.estimate_resid_degrees_of_freedom(X) + self.dof = self._estimate_resid_degrees_of_freedom(X) self.scale = self.observation_model.estimate_scale( - self._predict(params, data), y, dof_resid=dof_resid + self._predict(params, data), y, dof_resid=self.dof ) # note that this will include an error value, which is not the same as @@ -781,7 +784,7 @@ def simulate( predicted_rate, ) - def estimate_resid_degrees_of_freedom( + def _estimate_resid_degrees_of_freedom( self, X: DESIGN_INPUT_TYPE, n_samples: Optional[int] = None ): """ @@ -995,9 +998,9 @@ def update( self.solver_state = opt_step[1] # estimate the scale - dof_resid = self.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + self.dof = self._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) self.scale = self.observation_model.estimate_scale( - self._predict(params, data), y, dof_resid=dof_resid + self._predict(params, data), y, dof_resid=self.dof ) return opt_step diff --git a/tests/test_glm.py b/tests/test_glm.py index 4d436789..35e91007 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -1382,7 +1382,7 @@ def test_estimate_dof_resid( model.regularizer = reg model.solver_name = model.regularizer.default_solver model.fit(X, y) - num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + num = model._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) assert np.allclose(num, n_samples - dof - 1) @pytest.mark.parametrize("reg", ["Ridge", "Lasso", "GroupLasso"]) @@ -1539,7 +1539,7 @@ def test_estimate_dof_resid( model.regularizer = reg model.solver_name = model.regularizer.default_solver model.fit(X, y) - num = model.estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + num = model._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) assert np.allclose(num, n_samples - dof - 1) @pytest.mark.parametrize( From c7faf4439c2528a675daf8c42fa922d4e129b447 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 16:30:15 -0400 Subject: [PATCH 090/225] separate param initialization and solver initialization --- docs/api_guide/plot_05_batch_glm.py | 3 +- src/nemos/base_regressor.py | 22 ++++---- src/nemos/glm.py | 82 +++++++++++++++++++++------ tests/conftest.py | 5 +- tests/test_glm.py | 88 +++++++++++++++++++---------- tests/test_regularizer.py | 8 +++ 6 files changed, 148 insertions(+), 60 deletions(-) diff --git a/docs/api_guide/plot_05_batch_glm.py b/docs/api_guide/plot_05_batch_glm.py index cc48818a..559e99f5 100644 --- a/docs/api_guide/plot_05_batch_glm.py +++ b/docs/api_guide/plot_05_batch_glm.py @@ -88,7 +88,8 @@ def batcher(): # # First we need to initialize the gradient descent solver within the `PopulationGLM`. # This gets you the initial parameters and the first state of the solver. -params, state = glm.initialize_solver(*batcher()) +params = glm.initialize_params(*batcher()) +state = glm.initialize_state(*batcher, params) # %% # ## Batch learning diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index c2844a98..db808fc8 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -248,12 +248,6 @@ def instantiate_solver( self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) # add self.regularizer_strength to args args += (self.regularizer_strength,) - if self.solver_name == "LBFGSB": - if "bounds" not in kwargs.keys(): - warnings.warn( - "Bounds must be provided for LBFGSB. Reverting back to LBFGS solver." - ) - self.solver_name = "LBFGS" # instantiate the solver solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) @@ -406,13 +400,21 @@ def update( pass @abc.abstractmethod - def initialize_solver( + def initialize_params( self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray, - *args, params: Optional = None, - **kwargs, - ) -> Tuple[Any, NamedTuple]: + ) -> Union[Any, NamedTuple]: """Initialize the solver's state and optionally sets initial model parameters for the optimization.""" pass + + @abc.abstractmethod + def initialize_state( + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + init_params, + ) -> Union[Any, NamedTuple]: + """Initialize the state of the solver for running fit and update.""" + pass diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 3326105e..64feba71 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -3,8 +3,9 @@ # required to get ArrayLike to render correctly from __future__ import annotations +import warnings from functools import wraps -from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union +from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union, Any import jax import jax.numpy as jnp @@ -661,7 +662,7 @@ def fit( """ # validate the inputs & initialize solver - init_params, _ = self.initialize_solver(X, y, init_params=init_params) + init_params = self.initialize_params(X, y, init_params=init_params) # find non-nans is_valid = tree_utils.get_valid_multitree(X, y) @@ -680,7 +681,28 @@ def fit( # if mask has not been set, use a single group as default if isinstance(self.regularizer, GroupLasso): if self.regularizer.mask is None: + warnings.warn( + UserWarning( + "Mask has not been set. Defaulting to a single group for all parameters. " + "Please see the documentation on GroupLasso regularization for defining a " + "mask." + ) + ) self.regularizer.mask = jnp.ones((1, data.shape[1])) + if self.solver_name == "LBFGSB": + if "bounds" not in self.solver_kwargs.keys(): + warnings.warn( + UserWarning( + "No bounds provided. Defaulting to unlimited bounds: (-jnp.inf, jnp.inf). " + "Please see the documentation on how to set box constraints." + ) + ) + # bounds = (jax.tree_map(lambda x: -jnp.inf * jnp.ones_like(x), init_params), + # jax.tree_map(lambda x: jnp.inf * jnp.ones_like(x), init_params)) + # self.solver_kwargs.update(bounds=bounds) + self.solver_name = "LBFGS" + + self.initialize_state(data, y, init_params) params, state = self.solver_run(init_params, data, y) @@ -838,19 +860,16 @@ def _estimate_resid_degrees_of_freedom( return (n_samples - rank - 1) * jnp.ones_like(params[1]) @cast_to_jax - def initialize_solver( + def initialize_params( self, X: DESIGN_INPUT_TYPE, y: jnp.ndarray, - *args, init_params: Optional[ModelParams] = None, - **kwargs, ) -> Tuple[ModelParams, NamedTuple]: """ - Initialize the solver's state and optionally sets initial model parameters for the optimization process. + Initialize the model parameters for the optimization process. - This method prepares the solver by instantiating its components (initial state, update function, - and run function) and initializes model parameters if they are not provided. It is typically called + This method prepares the initializes model parameters if they are not provided. It is typically called before starting the optimization process to ensure that all necessary components and states are correctly configured. @@ -864,16 +883,11 @@ def initialize_solver( they are not provided. init_params : Initial parameters for the model. If not provided, they will be initialized based on the input data X and y. - *args - Additional positional arguments to be passed to the solver's init_state method. - **kwargs - Additional keyword arguments to be passed to the solver's init_state method. Returns ------- - Tuple[ModelParams, NamedTuple] - A tuple containing the initialized model parameters and the solver's initial state. This setup is ready - to be used for running the solver's optimization routines. + ModelParams + The initialized model parameters Raises ------ @@ -890,7 +904,8 @@ def initialize_solver( Examples -------- >>> X, y = load_data() # Hypothetical function to load data - >>> params, opt_state = model.initialize_solver(X, y) + >>> params = model.initialize_params(X, y) + >>> opt_state = model.initialize_state(X, y) >>> # Now ready to run optimization or update steps """ if init_params is None: @@ -905,6 +920,37 @@ def initialize_solver( # validate input self._validate(X, y, init_params) + return init_params + + def initialize_state( + self, + X: DESIGN_INPUT_TYPE, + y: jnp.ndarray, + init_params, + ) -> Union[Any, NamedTuple]: + """ + Initialize the solver by instantiating its components (initial state, update function, + and run function). + + This method also prepares the solver's state by using the initialized model parameters and data. + This setup is ready to be used for running the solver's optimization routines. + + Parameters + ---------- + X : + The predictors used in the model fitting process. This can include feature matrices or other structures + compatible with the model's design. + y : + The response variables or outputs corresponding to the predictors. Used to initialize parameters when + they are not provided. + init_params : + Initial parameters for the model. + + Returns + ------- + NamedTuple + The initialized solver state + """ ( self._solver_init_state, self._solver_update, @@ -915,8 +961,8 @@ def initialize_solver( data = X.data else: data = X - opt_state = self.solver_init_state(init_params, data, y, *args, **kwargs) - return init_params, opt_state + opt_state = self.solver_init_state(init_params, data, y) + return opt_state @cast_to_jax def update( diff --git a/tests/conftest.py b/tests/conftest.py index bb69d428..48038ba0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,7 +69,10 @@ def _set_coef_and_intercept(self, params): def update(self, *args, **kwargs): pass - def initialize_solver(self, *args, **kwargs): + def initialize_state(self, *args, **kwargs): + pass + + def initialize_params(self, *args, **kwargs): pass def _predict_and_compute_loss(self, params, X, y): diff --git a/tests/test_glm.py b/tests/test_glm.py index 35e91007..b60b0379 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -831,7 +831,8 @@ def test_initialize_solver_param_length( else: init_params = true_params + (true_params[0],) * (n_params - 2) with expectation: - model.initialize_solver(X, y, init_params=init_params) + params = model.initialize_params(X, y, init_params=init_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "dim_weights, expectation", @@ -882,7 +883,7 @@ def test_initialize_solver_weights_dimensionality( else: init_w = jnp.zeros((n_features, n_neurons) + (1,) * (dim_weights - 2)) with expectation: - model.initialize_solver(X, y, init_params=(init_w, true_params[1])) + model.initialize_params(X, y, init_params=(init_w, true_params[1])) @pytest.mark.parametrize( "dim_intercepts, expectation", @@ -904,7 +905,8 @@ def test_initialize_solver_intercepts_dimensionality( init_b = jnp.zeros((1,) * dim_intercepts) init_w = jnp.zeros((n_features,)) with expectation: - model.initialize_solver(X, y, init_params=(init_w, init_b)) + params = model.initialize_params(X, y, init_params=(init_w, init_b)) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "init_params, expectation", @@ -954,7 +956,8 @@ def test_initialize_solver_init_params_type( """ X, y, model, true_params, firing_rate = poissonGLM_model_instantiation with expectation: - model.initialize_solver(X, y, init_params=init_params) + params = model.initialize_params(X, y, init_params=init_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_dim, expectation", @@ -976,7 +979,8 @@ def test_initialize_solver_x_dimensionality( elif delta_dim == 1: X = np.zeros((X.shape[0], 1, X.shape[1])) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_dim, expectation", @@ -998,7 +1002,8 @@ def test_initialize_solver_y_dimensionality( elif delta_dim == 1: y = np.zeros((y.shape[0], 1)) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_n_features, expectation", @@ -1021,7 +1026,8 @@ def test_initialize_solver_n_feature_consistency_weights( 1, ) with expectation: - model.initialize_solver(X, y, init_params=(init_w, init_b)) + params = model.initialize_params(X, y, init_params=(init_w, init_b)) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_n_features, expectation", @@ -1044,7 +1050,8 @@ def test_initialize_solver_n_feature_consistency_x( elif delta_n_features == -1: X = X[..., :-1] with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_tp, expectation", @@ -1069,7 +1076,8 @@ def test_initialize_solver_time_points_x( X, y, model, true_params, firing_rate = poissonGLM_model_instantiation X = jnp.zeros((X.shape[0] + delta_tp,) + X.shape[1:]) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_tp, expectation", @@ -1094,7 +1102,7 @@ def test_initialize_solver_time_points_y( X, y, model, true_params, firing_rate = poissonGLM_model_instantiation y = jnp.zeros((y.shape[0] + delta_tp,) + y.shape[1:]) with expectation: - model.initialize_solver(X, y, init_params=true_params) + model.initialize_params(X, y, init_params=true_params) def test_initialize_solver_mask_grouplasso( self, group_sparse_poisson_glm_model_instantiation @@ -1105,7 +1113,8 @@ def test_initialize_solver_mask_grouplasso( regularizer=nmo.regularizer.GroupLasso(mask=mask), solver_name="ProximalGradient", ) - model.initialize_solver(X, y) + params = model.initialize_params(X, y) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "fill_val, expectation", @@ -1131,7 +1140,8 @@ def test_initialize_solver_all_invalid_X( X, y, model, true_params, firing_rate = poissonGLM_model_instantiation X.fill(fill_val) with expectation: - model.initialize_solver(X, y) + params = model.initialize_params(X, y) + model.initialize_state(X, y, params) def test_initializer_solver_set_solver_callable( self, poissonGLM_model_instantiation @@ -1140,7 +1150,8 @@ def test_initializer_solver_set_solver_callable( assert model.solver_init_state is None assert model.solver_update is None assert model.solver_run is None - model.initialize_solver(X, y) + init_params = model.initialize_params(X, y) + model.initialize_state(X, y, init_params) assert isinstance(model.solver_init_state, Callable) assert isinstance(model.solver_update, Callable) assert isinstance(model.solver_run, Callable) @@ -1165,7 +1176,8 @@ def test_update_n_samples( self, n_samples, expectation, batch_size, poissonGLM_model_instantiation ): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - params, state = model.initialize_solver(X, y) + params = model.initialize_params(X, y) + state = model.initialize_state(X, y, params) with expectation: model.update( params, state, X[:batch_size], y[:batch_size], n_samples=n_samples @@ -1174,7 +1186,8 @@ def test_update_n_samples( @pytest.mark.parametrize("batch_size", [1, 10]) def test_update_params_stored(self, batch_size, poissonGLM_model_instantiation): X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - params, state = model.initialize_solver(X, y) + params = model.initialize_params(X, y) + state = model.initialize_state(X, y, params) assert model.coef_ is None assert model.intercept_ is None assert model.scale is None @@ -1189,7 +1202,8 @@ def test_update_nan_drop_at_jit_comp( ): """Test that jit compilation does not affect the update in the presence of nans.""" X, y, model, true_params, firing_rate = poissonGLM_model_instantiation - params, state = model.initialize_solver(X, y) + params = model.initialize_params(X, y) + state = model.initialize_state(X, y, params) # extract batch and add nans Xnan = X[:batch_size] @@ -1923,7 +1937,8 @@ def test_initialize_solver_param_length( else: init_params = true_params + (true_params[0],) * (n_params - 2) with expectation: - model.initialize_solver(X, y, init_params=init_params) + params = model.initialize_params(X, y, init_params=init_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "dim_weights, expectation", @@ -1974,7 +1989,8 @@ def test_initialize_solver_weights_dimensionality( else: init_w = jnp.zeros((n_features, n_neurons) + (1,) * (dim_weights - 2)) with expectation: - model.initialize_solver(X, y, init_params=(init_w, true_params[1])) + params = model.initialize_params(X, y, init_params=(init_w, true_params[1])) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "dim_intercepts, expectation", @@ -1996,7 +2012,8 @@ def test_initialize_solver_intercepts_dimensionality( init_b = jnp.zeros((y.shape[1],) * dim_intercepts) init_w = jnp.zeros((n_features, y.shape[1])) with expectation: - model.initialize_solver(X, y, init_params=(init_w, init_b)) + params = model.initialize_params(X, y, init_params=(init_w, init_b)) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "init_params, expectation", @@ -2037,7 +2054,8 @@ def test_initialize_solver_init_params_type( """ X, y, model, true_params, firing_rate = poisson_population_GLM_model with expectation: - model.initialize_solver(X, y, init_params=init_params) + params = model.initialize_params(X, y, init_params=init_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_dim, expectation", @@ -2059,7 +2077,8 @@ def test_initialize_solver_x_dimensionality( elif delta_dim == 1: X = np.zeros((X.shape[0], 1, X.shape[1])) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_dim, expectation", @@ -2081,7 +2100,8 @@ def test_initialize_solver_y_dimensionality( elif delta_dim == 1: y = np.zeros((*y.shape, 1)) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_n_features, expectation", @@ -2104,7 +2124,8 @@ def test_initialize_solver_n_feature_consistency_weights( y.shape[1], ) with expectation: - model.initialize_solver(X, y, init_params=(init_w, init_b)) + params = model.initialize_params(X, y, init_params=(init_w, init_b)) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_n_features, expectation", @@ -2127,7 +2148,8 @@ def test_initialize_solver_n_feature_consistency_x( elif delta_n_features == -1: X = X[..., :-1] with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_tp, expectation", @@ -2152,7 +2174,8 @@ def test_initialize_solver_time_points_x( X, y, model, true_params, firing_rate = poisson_population_GLM_model X = jnp.zeros((X.shape[0] + delta_tp,) + X.shape[1:]) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "delta_tp, expectation", @@ -2177,7 +2200,8 @@ def test_initialize_solver_time_points_y( X, y, model, true_params, firing_rate = poisson_population_GLM_model y = jnp.zeros((y.shape[0] + delta_tp,) + y.shape[1:]) with expectation: - model.initialize_solver(X, y, init_params=true_params) + params = model.initialize_params(X, y, init_params=true_params) + model.initialize_state(X, y, params) def test_initialize_solver_mask_grouplasso( self, group_sparse_poisson_glm_model_instantiation @@ -2188,7 +2212,8 @@ def test_initialize_solver_mask_grouplasso( regularizer=nmo.regularizer.GroupLasso(mask=mask), solver_name="ProximalGradient", ) - model.initialize_solver(X, y) + params = model.initialize_params(X, y) + model.initialize_state(X, y, params) @pytest.mark.parametrize( "fill_val, expectation", @@ -2214,7 +2239,8 @@ def test_initialize_solver_all_invalid_X( X, y, model, true_params, firing_rate = poisson_population_GLM_model X.fill(fill_val) with expectation: - model.initialize_solver(X, y) + params = model.initialize_params(X, y) + model.initialize_state(X, y, params) ####################### # Test model.update @@ -2236,7 +2262,8 @@ def test_update_n_samples( self, n_samples, expectation, batch_size, poisson_population_GLM_model ): X, y, model, true_params, firing_rate = poisson_population_GLM_model - params, state = model.initialize_solver(X, y) + params = model.initialize_params(X, y) + state = model.initialize_state(X, y, params) with expectation: model.update( params, state, X[:batch_size], y[:batch_size], n_samples=n_samples @@ -2245,7 +2272,8 @@ def test_update_n_samples( @pytest.mark.parametrize("batch_size", [1, 10]) def test_update_params_stored(self, batch_size, poisson_population_GLM_model): X, y, model, true_params, firing_rate = poisson_population_GLM_model - params, state = model.initialize_solver(X, y) + params = model.initialize_params(X, y) + state = model.initialize_state(X, y, params) assert model.coef_ is None assert model.intercept_ is None assert model.scale is None diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 73ab2028..752fe477 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -1184,6 +1184,14 @@ def test_mask_n_groups_set_params(self, n_groups, poissonGLM_model_instantiation else: regularizer.set_params(mask=mask) + def test_mask_none(self, poissonGLM_model_instantiation): + X, y, model, true_params, firing_rate = poissonGLM_model_instantiation + + with pytest.warns(UserWarning): + model.regularizer = self.cls() + model.solver_name = "ProximalGradient" + model.fit(X, y) + @pytest.mark.parametrize( "solver_name", [ From 2bece54b8c6c6311314dff01cd721d7e1ef0b149 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 22 Jul 2024 17:03:40 -0400 Subject: [PATCH 091/225] changed precision on clip --- src/nemos/observation_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/observation_models.py b/src/nemos/observation_models.py index 26b53b1a..bd082d41 100644 --- a/src/nemos/observation_models.py +++ b/src/nemos/observation_models.py @@ -798,7 +798,7 @@ def deviance( log-likelihood. Lower values of deviance indicate a better fit. """ y_mu = jnp.clip( - neural_activity / predicted_rate, min=jnp.finfo(predicted_rate.dtype).eps + neural_activity / predicted_rate, min=jnp.finfo(float).eps ) resid_dev = 2 * ( -jnp.log(y_mu) + (neural_activity - predicted_rate) / predicted_rate From d7b47f1439910e2fdf348a7d75c936d9b43af1fe Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 22 Jul 2024 17:07:18 -0400 Subject: [PATCH 092/225] enabled float64 for precise comparrison --- tests/test_observation_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_observation_models.py b/tests/test_observation_models.py index 9c1a4a19..0a88a62f 100644 --- a/tests/test_observation_models.py +++ b/tests/test_observation_models.py @@ -505,6 +505,7 @@ def test_pseudo_r2_vs_statsmodels(self, gammaGLM_model_instantiation): Compare log-likelihood to scipy. Assesses if the model estimates are close to statsmodels' results. """ + jax.config.update("jax_enable_x64", True) X, y, model, _, firing_rate = gammaGLM_model_instantiation # statsmodels mcfadden From a189cae9daf6c1bc57e78ad9f9eacc06cdcf2f1b Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 17:04:25 -0400 Subject: [PATCH 093/225] fix docs --- docs/api_guide/plot_05_batch_glm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_05_batch_glm.py b/docs/api_guide/plot_05_batch_glm.py index 559e99f5..15656065 100644 --- a/docs/api_guide/plot_05_batch_glm.py +++ b/docs/api_guide/plot_05_batch_glm.py @@ -89,7 +89,7 @@ def batcher(): # First we need to initialize the gradient descent solver within the `PopulationGLM`. # This gets you the initial parameters and the first state of the solver. params = glm.initialize_params(*batcher()) -state = glm.initialize_state(*batcher, params) +state = glm.initialize_state(*batcher(), params) # %% # ## Batch learning From 0ae67f355f33f5044677eaff59b30f97c1c342d0 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 17:08:28 -0400 Subject: [PATCH 094/225] fix docs --- src/nemos/observation_models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/nemos/observation_models.py b/src/nemos/observation_models.py index bd082d41..18243b09 100644 --- a/src/nemos/observation_models.py +++ b/src/nemos/observation_models.py @@ -797,9 +797,7 @@ def deviance( where $ y $ is the observed data, $ \hat{y} $ is the predicted data, and $\text{LL}$ is the model log-likelihood. Lower values of deviance indicate a better fit. """ - y_mu = jnp.clip( - neural_activity / predicted_rate, min=jnp.finfo(float).eps - ) + y_mu = jnp.clip(neural_activity / predicted_rate, min=jnp.finfo(float).eps) resid_dev = 2 * ( -jnp.log(y_mu) + (neural_activity - predicted_rate) / predicted_rate ) From 781f8bbc28f3ecce4d5a842ab36abcbc95546cf5 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 17:16:35 -0400 Subject: [PATCH 095/225] change scale param in glm to scale_, update tests --- src/nemos/glm.py | 14 +++++++------- tests/conftest.py | 4 ++-- tests/test_glm.py | 26 +++++++++++++------------- tests/test_observation_models.py | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 64feba71..c014c029 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -91,7 +91,7 @@ class GLM(BaseRegressor): Basis coefficients for the model. solver_state : State of the solver after fitting. May include details like optimization error. - scale: + scale_: Scale parameter for the model. The scale parameter is the constant $\Phi$, for which $\text{Var} \left( y \right) = \Phi V(\mu)$. This parameter, together with the estimate of the mean $\mu$ fully specifies the distribution of the activity $y$. @@ -127,7 +127,7 @@ def __init__( self.intercept_ = None self.coef_ = None self.solver_state = None - self.scale = None + self.scale_ = None self._solver_init_state = None self._solver_update = None self._solver_run = None @@ -524,7 +524,7 @@ def score( score = self._observation_model.log_likelihood( self._predict(params, data), y, - self.scale, + self.scale_, aggregate_sample_scores=aggregate_sample_scores, ) elif score_type.startswith("pseudo-r2"): @@ -532,7 +532,7 @@ def score( self._predict(params, data), y, score_type=score_type, - scale=self.scale, + scale=self.scale_, aggregate_sample_scores=aggregate_sample_scores, ) else: @@ -718,7 +718,7 @@ def fit( self._set_coef_and_intercept(params) self.dof = self._estimate_resid_degrees_of_freedom(X) - self.scale = self.observation_model.estimate_scale( + self.scale_ = self.observation_model.estimate_scale( self._predict(params, data), y, dof_resid=self.dof ) @@ -801,7 +801,7 @@ def simulate( predicted_rate = self._predict(params, feedforward_input) return ( self._observation_model.sample_generator( - key=random_key, predicted_rate=predicted_rate, scale=self.scale + key=random_key, predicted_rate=predicted_rate, scale=self.scale_ ), predicted_rate, ) @@ -1045,7 +1045,7 @@ def update( # estimate the scale self.dof = self._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) - self.scale = self.observation_model.estimate_scale( + self.scale_ = self.observation_model.estimate_scale( self._predict(params, data), y, dof_resid=self.dof ) diff --git a/tests/conftest.py b/tests/conftest.py index 48038ba0..6693bd58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -482,7 +482,7 @@ def gammaGLM_model_instantiation(): rate = (jax.numpy.einsum("k,tk->t", w_true, X) + b_true) ** -1 theta = 3 k = rate / theta - model.scale = theta + model.scale_ = theta return X, np.random.gamma(k, scale=theta), model, (w_true, b_true), rate @@ -525,7 +525,7 @@ def gamma_population_GLM_model(): ) rate = 1 / (jnp.einsum("ki,tk->ti", w_true, X) + b_true) theta = 3 - model.scale = theta + model.scale_ = theta y = jax.random.gamma(jax.random.PRNGKey(123), rate / theta) * theta return X, y, model, (w_true, b_true), rate diff --git a/tests/test_glm.py b/tests/test_glm.py index b60b0379..6b443864 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -469,7 +469,7 @@ def test_fit_pytree_equivalence_gamma( assert np.allclose(model.intercept_, model_tree.intercept_) assert np.allclose(model.score(X, y), model_tree.score(X_tree, y)) assert np.allclose(model.predict(X), model_tree.predict(X_tree)) - assert np.allclose(model.scale, model_tree.scale) + assert np.allclose(model.scale_, model_tree.scale_) @pytest.mark.parametrize( "fill_val, expectation", @@ -508,7 +508,7 @@ def test_fit_set_scale(self, inv_link, gammaGLM_model_instantiation): X, y, model, true_params, firing_rate = gammaGLM_model_instantiation model.observation_model.inverse_link_function = inv_link model.fit(X, y) - assert model.scale != 1 + assert model.scale_ != 1 ####################### # Test model.score @@ -727,7 +727,7 @@ def test_score_gamma_glm(self, inv_link, gammaGLM_model_instantiation): model.observation_model.inverse_link_function = inv_link model.coef_ = true_params[0] model.intercept_ = true_params[1] - model.scale = 1.0 + model.scale_ = 1.0 model.score(X, y) ####################### @@ -1190,11 +1190,11 @@ def test_update_params_stored(self, batch_size, poissonGLM_model_instantiation): state = model.initialize_state(X, y, params) assert model.coef_ is None assert model.intercept_ is None - assert model.scale is None + assert model.scale_ is None _, _ = model.update(params, state, X[:batch_size], y[:batch_size]) assert model.coef_ is not None assert model.intercept_ is not None - assert model.scale is not None + assert model.scale_ is not None @pytest.mark.parametrize("batch_size", [2, 10]) def test_update_nan_drop_at_jit_comp( @@ -1340,7 +1340,7 @@ def test_simulate_gamma_glm(self, inv_link, gammaGLM_model_instantiation): model.observation_model.inverse_link_function = inv_link model.coef_ = true_params[0] model.intercept_ = true_params[1] - model.scale = 1.0 + model.scale_ = 1.0 ysim, ratesim = model.simulate(jax.random.PRNGKey(123), X) assert ysim.shape == y.shape assert ratesim.shape == y.shape @@ -1864,7 +1864,7 @@ def test_fit_pytree_equivalence_gamma( assert np.allclose(model.intercept_, model_tree.intercept_) assert np.allclose(model.score(X, y), model_tree.score(X_tree, y)) assert np.allclose(model.predict(X), model_tree.predict(X_tree)) - assert np.allclose(model.scale, model_tree.scale) + assert np.allclose(model.scale_, model_tree.scale_) @pytest.mark.parametrize( "fill_val, expectation", @@ -1903,12 +1903,12 @@ def test_fit_set_scale(self, inv_link, gamma_population_GLM_model): X, y, model, true_params, firing_rate = gamma_population_GLM_model model.observation_model.inverse_link_function = inv_link model.fit(X, y) - assert np.all(model.scale != 1) + assert np.all(model.scale_ != 1) def test_fit_scale_array(self, gamma_population_GLM_model): X, y, model, true_params, firing_rate = gamma_population_GLM_model model.fit(X, y) - assert model.scale.size == y.shape[1] + assert model.scale_.size == y.shape[1] ####################### # Test model.initialize_solver @@ -2276,11 +2276,11 @@ def test_update_params_stored(self, batch_size, poisson_population_GLM_model): state = model.initialize_state(X, y, params) assert model.coef_ is None assert model.intercept_ is None - assert model.scale is None + assert model.scale_ is None _, _ = model.update(params, state, X[:batch_size], y[:batch_size]) assert model.coef_ is not None assert model.intercept_ is not None - assert model.scale is not None + assert model.scale_ is not None ####################### # Test model.score @@ -2498,7 +2498,7 @@ def test_score_gamma_glm(self, inv_link, gamma_population_GLM_model): model.observation_model.inverse_link_function = inv_link model.coef_ = true_params[0] model.intercept_ = true_params[1] - model.scale = np.ones((y.shape[1])) + model.scale_ = np.ones((y.shape[1])) model.score(X, y) @pytest.mark.parametrize( @@ -2723,7 +2723,7 @@ def test_simulate_gamma_glm(self, inv_link, gamma_population_GLM_model): model.feature_mask = jnp.ones((X.shape[1], y.shape[1])) model.coef_ = true_params[0] model.intercept_ = true_params[1] - model.scale = jnp.ones((y.shape[1])) + model.scale_ = jnp.ones((y.shape[1])) ysim, ratesim = model.simulate(jax.random.PRNGKey(123), X) assert ysim.shape == y.shape assert ratesim.shape == y.shape diff --git a/tests/test_observation_models.py b/tests/test_observation_models.py index 0a88a62f..1fd8bf79 100644 --- a/tests/test_observation_models.py +++ b/tests/test_observation_models.py @@ -410,7 +410,7 @@ def test_pseudo_r2_range(self, score_type, gammaGLM_model_instantiation): X, y, model, true_params, firing_rate = gammaGLM_model_instantiation model.coef_ = true_params[0] model.intercept_ = true_params[1] - model.scale = 0.5 + model.scale_ = 0.5 rate = model.predict(X) ysim, _ = model.simulate(jax.random.PRNGKey(123), X) From 1a535f5d0d19e8e8453593341505229ac6f5d3b9 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Mon, 22 Jul 2024 17:40:16 -0400 Subject: [PATCH 096/225] fix docs --- docs/api_guide/plot_02_glm_demo.py | 2 +- docs/api_guide/plot_03_glm_pytree.py | 2 +- docs/neural_modeling/plot_02_head_direction.py | 2 +- docs/neural_modeling/plot_03_grid_cells.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api_guide/plot_02_glm_demo.py b/docs/api_guide/plot_02_glm_demo.py index b1220651..b64836c3 100644 --- a/docs/api_guide/plot_02_glm_demo.py +++ b/docs/api_guide/plot_02_glm_demo.py @@ -133,7 +133,7 @@ # The same exact syntax works for any configuration. # fit a ridge regression Poisson GLM -model = nmo.glm.GLM(regularizer="ridge", regularizer_strength=0.1) +model = nmo.glm.GLM(regularizer="Ridge", regularizer_strength=0.1) model.fit(X, spikes) print("Ridge results") diff --git a/docs/api_guide/plot_03_glm_pytree.py b/docs/api_guide/plot_03_glm_pytree.py index d7e91ccb..f2459e4e 100644 --- a/docs/api_guide/plot_03_glm_pytree.py +++ b/docs/api_guide/plot_03_glm_pytree.py @@ -231,7 +231,7 @@ # %% # # Now we'll fit our GLM and then see what our head direction tuning looks like: -model = nmo.glm.GLM(regularizer="ridge", regularizer_strength=0.001) +model = nmo.glm.GLM(regularizer="Ridge", regularizer_strength=0.001) model.fit(X, spikes) print(model.coef_['head_direction']) diff --git a/docs/neural_modeling/plot_02_head_direction.py b/docs/neural_modeling/plot_02_head_direction.py index 12b2288d..ad9d6dd1 100644 --- a/docs/neural_modeling/plot_02_head_direction.py +++ b/docs/neural_modeling/plot_02_head_direction.py @@ -532,7 +532,7 @@ # model = nmo.glm.PopulationGLM( - regularizer="ridge", + regularizer="Ridge", solver_name="LBFGS", regularizer_strength=0.1 ).fit(convolved_count, count) diff --git a/docs/neural_modeling/plot_03_grid_cells.py b/docs/neural_modeling/plot_03_grid_cells.py index 4e42d925..0fd2bb20 100644 --- a/docs/neural_modeling/plot_03_grid_cells.py +++ b/docs/neural_modeling/plot_03_grid_cells.py @@ -160,7 +160,7 @@ # Here we will focus on the last neuron (neuron 7) who has a nice grid pattern model = nmo.glm.GLM( - regularizer="ridge", + regularizer="Ridge", solver_name="LBFGS", regularizer_strength=0.001 ) From 84914a9d6a2004abe1cea293ad63ef7bfce8fdb5 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 23 Jul 2024 09:14:37 -0400 Subject: [PATCH 097/225] fixed docs --- docs/neural_modeling/plot_06_calcium_imaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/neural_modeling/plot_06_calcium_imaging.py b/docs/neural_modeling/plot_06_calcium_imaging.py index 3b6bf0cf..57e85955 100644 --- a/docs/neural_modeling/plot_06_calcium_imaging.py +++ b/docs/neural_modeling/plot_06_calcium_imaging.py @@ -143,7 +143,7 @@ model = nmo.glm.GLM( solver_name="LBFGS", solver_kwargs=dict(tol=10**-13), - regularizer="ridge", + regularizer="Ridge", regularizer_strength=0.02, observation_model=nmo.observation_models.GammaObservations(inverse_link_function=jax.nn.softplus) ) From feef74be284447986a12a9efddbb85cb5a3568cf Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 10:10:27 -0400 Subject: [PATCH 098/225] make _penalized() methods static --- src/nemos/regularizer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 753ed642..efbd4517 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -174,8 +174,9 @@ def __init__( ): super().__init__() + @staticmethod def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float + params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float ) -> jnp.ndarray: """ Compute the Ridge penalization for given parameters. @@ -273,8 +274,9 @@ def prox_op(params, l1reg, scaling=1.0): return prox_op + @staticmethod def _penalization( - self, params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float + params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], regularizer_strength: float ) -> jnp.ndarray: """ Compute the Lasso penalization for given parameters. From b786369c95f1706e40177c9242ad47b1d3749a22 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 11:43:54 -0400 Subject: [PATCH 099/225] remove LBFGSB --- src/nemos/base_regressor.py | 55 +++++++++++++++++++++++++++++++++---- src/nemos/glm.py | 12 -------- src/nemos/regularizer.py | 2 -- tests/test_regularizer.py | 6 ---- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index db808fc8..de23ab6c 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -6,7 +6,7 @@ import abc import inspect import warnings -from typing import Any, NamedTuple, Optional, Tuple, Union +from typing import Any, NamedTuple, Optional, Tuple, Union, Dict import jax import jax.numpy as jnp @@ -249,26 +249,71 @@ def instantiate_solver( # add self.regularizer_strength to args args += (self.regularizer_strength,) + ( + solver_run_kwargs, + solver_init_state_kwargs, + solver_update_kwargs, + solver_init_kwargs, + ) = self._inspect_solver_kwargs() + # instantiate the solver - solver = getattr(jaxopt, self._solver_name)(fun=loss, **self.solver_kwargs) + solver = getattr(jaxopt, self.solver_name)(fun=loss, **solver_init_kwargs) def solver_run( init_params: Tuple[DESIGN_INPUT_TYPE, jnp.ndarray], *run_args: jnp.ndarray ) -> jaxopt.OptStep: - return solver.run(init_params, *args, *run_args, **kwargs) + return solver.run(init_params, *args, *run_args, **solver_run_kwargs) def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: return solver.update( - params, state, *args, *run_args, **kwargs, **run_kwargs + params, state, *args, *run_args, **solver_update_kwargs, **run_kwargs ) def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: + print(solver_init_state_kwargs) return solver.init_state( - params, state, *args, *run_args, **kwargs, **run_kwargs + params, + state, + *run_args, + **run_kwargs, + **solver_init_state_kwargs, ) return solver_init_state, solver_update, solver_run + def _inspect_solver_kwargs( + self, + ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + + solver_run_kwargs = dict() + solver_init_state_kwargs = dict() + solver_update_kwargs = dict() + solver_init_kwargs = dict() + + def dummy_loss(): + pass + + if self.solver_kwargs: + # instantiate a solver to then inspect the params of its various functions + solver = getattr(jaxopt, self.solver_name)(fun=dummy_loss) + + for key, value in self.solver_kwargs.items(): + if key in inspect.getfullargspec(solver.run).args: + solver_run_kwargs[key] = value + if key in inspect.getfullargspec(solver.init_state).args: + solver_init_state_kwargs[key] = value + if key in inspect.getfullargspec(solver.update).args: + solver_update_kwargs[key] = value + if key in inspect.getfullargspec(solver.__init__).args: + solver_init_kwargs[key] = value + + return ( + solver_run_kwargs, + solver_init_state_kwargs, + solver_update_kwargs, + solver_init_kwargs, + ) + @abc.abstractmethod def fit(self, X: DESIGN_INPUT_TYPE, y: Union[NDArray, jnp.ndarray]): """Fit the model to neural activity.""" diff --git a/src/nemos/glm.py b/src/nemos/glm.py index c014c029..53974912 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -689,18 +689,6 @@ def fit( ) ) self.regularizer.mask = jnp.ones((1, data.shape[1])) - if self.solver_name == "LBFGSB": - if "bounds" not in self.solver_kwargs.keys(): - warnings.warn( - UserWarning( - "No bounds provided. Defaulting to unlimited bounds: (-jnp.inf, jnp.inf). " - "Please see the documentation on how to set box constraints." - ) - ) - # bounds = (jax.tree_map(lambda x: -jnp.inf * jnp.ones_like(x), init_params), - # jax.tree_map(lambda x: jnp.inf * jnp.ones_like(x), init_params)) - # self.solver_kwargs.update(bounds=bounds) - self.solver_name = "LBFGS" self.initialize_state(data, y, init_params) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index efbd4517..0a425f57 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -116,7 +116,6 @@ class are defined in the `allowed_solvers` attribute. "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ) @@ -162,7 +161,6 @@ class Ridge(Regularizer): "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 752fe477..8348e959 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -10,7 +10,6 @@ from sklearn.linear_model import GammaRegressor, PoissonRegressor import nemos as nmo -from nemos.regularizer import Regularizer @pytest.mark.parametrize( @@ -149,7 +148,6 @@ def test_init_solver_name(self, solver_name): acceptable_solvers = [ "GradientDescent", "BFGS", - "LBFGS", "LBFGSB", "NonlinearCG", "ProximalGradient", @@ -174,7 +172,6 @@ def test_set_solver_name_allowed(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ] @@ -368,7 +365,6 @@ def test_solver_match_statsmodels_gamma( "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ], @@ -393,7 +389,6 @@ def test_init_solver_name(self, solver_name): "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ] @@ -585,7 +580,6 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): "GradientDescent", "BFGS", "LBFGS", - "LBFGSB", "NonlinearCG", "ProximalGradient", ], From be6e379071f755492e69a3234ab910cbf25aa8a4 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 15:08:54 -0400 Subject: [PATCH 100/225] start working on updating contributing guide --- CONTRIBUTING.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25694f90..96b3890c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,142 @@ # Contributing -The NeMoS package is designed to provide a robust set of statistical analysis tools for neuroscience research. While the repository is managed by a core team of data scientists at the Center for Computational Neuroscience of the Flatiron Institute, we warmly welcome contributions from external collaborators. +The NeMoS package is designed to provide a robust set of statistical analysis tools for neuroscience research. While the repository is managed by a core team of data scientists at the Center for Computational Neuroscience of the Flatiron Institute, we warmly welcome contributions from external collaborators. +This guide explains how to contribute: if you have questions about the process, please feel free to reach out on [Github Discussions](https://github.com/flatironinstitute/nemos/discussions). ## General Guidelines Developers are encouraged to contribute to various areas of development. This could include the creation of concrete classes, such as those for new basis function types, or the addition of further checks at evaluation. Enhancements to documentation and the overall readability of the code are also greatly appreciated. -Feel free to work on any section of code that you believe you can improve. More importantly, remember to thoroughly test all your classes and functions, and to provide clear, detailed comments within your code. This not only aids others in using your library, but also facilitates future maintenance and further development. +Feel free to work on any section of code that you believe you can improve. More importantly, remember to thoroughly test all your classes and functions, and to provide clear, detailed comments within your code. This not only aids others in using the library, but also facilitates future maintenance and further development. + +## Contributing to the code + +### Contribution workflow cycle + +In order to contribute, you will need to do the following: + +1) Create your own branch +2) Make sure that tests pass +3) Open a Pull Request + +The NeMoS package follows the [GitHub Flow](https://www.gitkraken.com/learn/git/best-practices/git-branch-strategy#github-flow-branch-strategy) workflow. In essence, this means no one is allowed to +push to the `main` branch and all development happens in separate feature branches that are then merged into `main` once we have determined they are ready. When enough changes have accumulated, we +put out a new release, adding a new tag which increments the version number, and uploading the new release to PyPI. + + +#### Creating a development environment + +You will need a local installation of `nemos` which keeps up-to-date with any changes you make. To do so, you will need to clone `nemos` and checkout a new branch: + +1) Clone the `nemos` repo to your local machine + +```bash +git clone https://github.com/flatironinstitute/nemos.git +cd nemos +``` + +2) Install `nemos` in editable mode with developer dependencies +```bash +pip install -e .[dev] +``` + +> **_NOTE:_** In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that +> provides guidance on how to create and activate a virtual environment. + + +#### Creating a new branch + +As mentioned previously, each feature in `nemos` is worked on in a separate branch. This allows multiple people developing multiple features simultaneously, without interfering with each other's work. To create +your own branch, run the following from within your `nemos` directory: + +```bash +# switch to the main branch +git checkout main +# update your main branch +git pull origin main +# create and switch to a new branch +git checkout -b my_feature_branch +``` + +After you have made changes on this branch, add and commit them when you are ready: + +```bash +# stage the changes +git add src/nemos/the_changed_file.py +# commit your changes +git commit -m "A one-line message explaining the changes made" +# push to the remote origin +git push origin my_feature_branch +``` + +#### Contributing your change back to NeMoS + +You can make any number of changes on your branch. Once you are happy with your changes, add tests to check that they run correctly and add documentation to properly note your changes. +See below for details on how to [add tests]() and [add documentation](). + +Lastly, you should make sure that the existing tests all run successfully and that the codebase is formatted properly: + +```bash +# run tests and make sure they all pass +pytest tests/ +# format the code base +black src/ +black tests/ +``` +> **_NOTE:_** If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. + +Now you are ready to make a Pull Request. You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo +after pushing to your branch (see [here](https://intersect-training.org/collaborative-git/03-pr/index.html) for a tutorial). + +Your pull request should include the following: +- A summary including information on what you changed and why +- References to relevant issues or discussions +- Special notice to any portion of your changes where you have lingering questions (e.g., "was this the right way to implement this?") or want reviewers to pay special attention to + +Next, we will be notified of the pull request and will read it over. We will try to give an initial response quickly, and then do a longer in-depth review, at which point +you will probably need to respond to our comments, making changes as appropriate. We'll then respond again, and proceed in an iterative fashion until everyone is happy with the proposed +changes. + +Once your changes are integrated, you will be added as a GitHub contributor and as one of the authors of the package. Thank you for being part of `nemos`! + +### Style Guide + +The next section will talk about the style of your code and specific requirements for certain feature development in `nemos`. + +- Longer, descriptive names are preferred (e.g., x is not an appropriate name for a variable), especially for anything user-facing, such as methods, attributes, or arguments. +- Any public method or function must have a complete type-annotated docstring (see below for details). Hidden ones do not need to have complete docstrings, but they probably should. + +#### Adding a regularization method + +#### Adding a new model + +#### Adding a new optimization method + +### Testing + +To run all tests, run `pytest tests/` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. +There are several options for how to run a subset of tests: +- Run tests from one file: `pytest tests/test_glm.py` +- Run a specific test within a specific module: `pytests tests/test_glm.py::test_func` +- Another example specifying a test method via the command line: `pytest tests/test_glm.py::GLMClass::test_func` + +#### Adding tests + +New tests can be added in any of the existing `tests/test_*.py` scripts. Tests should be functions, contained within classes. The class contains a bunch of related tests +(e.g., regularizers, bases), and each test should ideally be a unit test, only testing one thing. The classes should be named `TestSomething`, while test functions should be named +`test_something` in snakecase. + +If you're adding a substantial bunch of tests that are separate from the existing ones, you can create a new test script. Its name must begin with `test_`, +it must have an `.py` extension, and it must be contained within the `tests` directory. Assuming you do that, our github actions will automatically find it and +add it to the tests-to-run. + +> **_NOTE:_** If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official `pytest` +> documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html). + +> **_NOTE_** If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use fixtures to avoid having to +> load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official `pytest` documentation +> [here](https://docs.pytest.org/en/stable/how-to/fixtures.html) + +### Documentation + + From bbcc472e168d4d4f39b9dc5b1b4726680c20101f Mon Sep 17 00:00:00 2001 From: Caitlin Lewis <69729525+clewis7@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:21:27 -0400 Subject: [PATCH 101/225] Apply suggestions from code review Co-authored-by: Edoardo Balzani --- src/nemos/base_regressor.py | 7 ++----- src/nemos/glm.py | 10 +++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index de23ab6c..634a0b34 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -60,7 +60,7 @@ class BaseRegressor(Base, abc.ABC): Concrete models: - [`GLM`](../glm/#nemos.glm.GLM): A feed-forward GLM implementation. - - [`GLMRecurrent`](../glm/#nemos.glm.GLMRecurrent): A recurrent GLM implementation. + - [`PopulationGLM`](../glm/#nemos.glm.PopulationGLM): A population GLM implementation. """ def __init__( @@ -290,12 +290,9 @@ def _inspect_solver_kwargs( solver_update_kwargs = dict() solver_init_kwargs = dict() - def dummy_loss(): - pass - if self.solver_kwargs: # instantiate a solver to then inspect the params of its various functions - solver = getattr(jaxopt, self.solver_name)(fun=dummy_loss) + solver = getattr(jaxopt, self.solver_name) for key, value in self.solver_kwargs.items(): if key in inspect.getfullargspec(solver.run).args: diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 53974912..7973386b 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -95,7 +95,7 @@ class GLM(BaseRegressor): Scale parameter for the model. The scale parameter is the constant $\Phi$, for which $\text{Var} \left( y \right) = \Phi V(\mu)$. This parameter, together with the estimate of the mean $\mu$ fully specifies the distribution of the activity $y$. - dof: + dof_resid_: Degrees of freedom for the residuals. @@ -131,7 +131,7 @@ def __init__( self._solver_init_state = None self._solver_update = None self._solver_run = None - self.dof = None + self.dof_resid_ = None @property def observation_model(self) -> Union[None, obs.Observations]: @@ -705,7 +705,7 @@ def fit( self._set_coef_and_intercept(params) - self.dof = self._estimate_resid_degrees_of_freedom(X) + self.dof_resid_ = self._estimate_resid_degrees_of_freedom(X) self.scale_ = self.observation_model.estimate_scale( self._predict(params, data), y, dof_resid=self.dof ) @@ -1032,9 +1032,9 @@ def update( self.solver_state = opt_step[1] # estimate the scale - self.dof = self._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + self.dof_resid_ = self._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) self.scale_ = self.observation_model.estimate_scale( - self._predict(params, data), y, dof_resid=self.dof + self._predict(params, data), y, dof_resid=self.dof_resid_ ) return opt_step From 14f3c863626df07b851211cba724670a066f5834 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 16:00:28 -0400 Subject: [PATCH 102/225] requested changes --- src/nemos/base_regressor.py | 54 +++++++++++++------------- src/nemos/glm.py | 14 ++++--- tests/test_regularizer.py | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 634a0b34..5a0c193b 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -31,8 +31,8 @@ class BaseRegressor(Base, abc.ABC): | Regularizer | Default Solver | Available Solvers | | ------------- | ---------------- | ----------------------------------------------------------- | - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | | Lasso | ProximalGradient | ProximalGradient | | GroupLasso | ProximalGradient | ProximalGradient | @@ -71,18 +71,7 @@ def __init__( solver_kwargs: Optional[dict] = None, ): self.regularizer = regularizer - - # check regularizer strength - if regularizer_strength is None: - warnings.warn( - UserWarning( - "Caution: regularizer strength has not been set. Defaulting to 1.0. Please see " - "the documentation for best practices in setting regularization strength." - ) - ) - self._regularizer_strength = 1.0 - else: - self.regularizer_strength = regularizer_strength + self.regularizer_strength = regularizer_strength # no solver name provided, use default if solver_name is None: @@ -113,24 +102,39 @@ def regularizer(self, regularizer: Union[str, Regularizer]): f"{AVAILABLE_REGULARIZERS} or an instance of `nemos.regularizer.Regularizer`" ) - if isinstance(self._regularizer, UnRegularized): - self._regularizer_strength = None + # force check of regularizer_strength + # need to use hasattr to avoid class instantiation issues + if hasattr(self, "_regularizer_strength"): + self.regularizer_strength = self._regularizer_strength @property def regularizer_strength(self) -> float: return self._regularizer_strength @regularizer_strength.setter - def regularizer_strength(self, strength: float): - try: - # force conversion to float to prevent weird GPU issues - strength = float(strength) - except ValueError: - # raise a more detailed ValueError - raise ValueError( - f"Could not convert the regularizer strength: {strength} to a float." + def regularizer_strength(self, strength: Union[float, None]): + # if using unregularized, strength will be None no matter what + if isinstance(self._regularizer, UnRegularized): + self._regularizer_strength = None + # check regularizer strength + elif strength is None: + warnings.warn( + UserWarning( + "Caution: regularizer strength has not been set. Defaulting to 1.0. Please see " + "the documentation for best practices in setting regularization strength." + ) ) - self._regularizer_strength = strength + self._regularizer_strength = 1.0 + else: + try: + # force conversion to float to prevent weird GPU issues + strength = float(strength) + except ValueError: + # raise a more detailed ValueError + raise ValueError( + f"Could not convert the regularizer strength: {strength} to a float." + ) + self._regularizer_strength = strength @property def solver_name(self) -> str: diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 7973386b..233a0fc6 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -55,8 +55,8 @@ class GLM(BaseRegressor): | Regularizer | Default Solver | Available Solvers | | ------------- | ---------------- | ----------------------------------------------------------- | - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | | Lasso | ProximalGradient | ProximalGradient | | GroupLasso | ProximalGradient | ProximalGradient | @@ -707,7 +707,7 @@ def fit( self.dof_resid_ = self._estimate_resid_degrees_of_freedom(X) self.scale_ = self.observation_model.estimate_scale( - self._predict(params, data), y, dof_resid=self.dof + self._predict(params, data), y, dof_resid=self.dof_resid_ ) # note that this will include an error value, which is not the same as @@ -1032,7 +1032,9 @@ def update( self.solver_state = opt_step[1] # estimate the scale - self.dof_resid_ = self._estimate_resid_degrees_of_freedom(X, n_samples=n_samples) + self.dof_resid_ = self._estimate_resid_degrees_of_freedom( + X, n_samples=n_samples + ) self.scale_ = self.observation_model.estimate_scale( self._predict(params, data), y, dof_resid=self.dof_resid_ ) @@ -1054,8 +1056,8 @@ class PopulationGLM(GLM): | Regularizer | Default Solver | Available Solvers | | ------------- | ---------------- | ----------------------------------------------------------- | - | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | - | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient, LBFGSB | + | UnRegularized | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | + | Ridge | GradientDescent | GradientDescent, BFGS, LBFGS, NonlinearCG, ProximalGradient | | Lasso | ProximalGradient | ProximalGradient | | GroupLasso | ProximalGradient | ProximalGradient | diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 8348e959..0a9fb3d2 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -186,6 +186,24 @@ def test_set_solver_name_allowed(self, solver_name): else: model.set_params(solver_name=solver_name) + def test_regularizer_strength_none(self): + """Add test to assert that regularizer strength of UnRegularized model should be `None`""" + # unregularized should be None + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + + assert model.regularizer_strength is None + + # changing to ridge, lasso, or grouplasso should raise UserWarning and set to 1.0 + with pytest.warns(UserWarning): + model.regularizer = "Lasso" + + assert model.regularizer_strength == 1.0 + + # assert change back to unregularized is none + model.regularizer = regularizer + assert model.regularizer_strength is None + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_name, solver_kwargs): @@ -448,6 +466,25 @@ def test_init_solver_kwargs(self, solver_name, solver_kwargs): solver_kwargs=solver_kwargs, ) + def test_regularizer_strength_none(self): + """Assert regularizer strength handled appropriately.""" + # if no strength given, should warn and set to 1.0 + with pytest.warns(UserWarning): + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + + assert model.regularizer_strength == 1.0 + + # if changed to regularized, should go to None + model.regularizer = "UnRegularized" + assert model.regularizer_strength is None + + # if changed back, should warn and set to 1.0 + with pytest.warns(UserWarning): + model.regularizer = "Ridge" + + assert model.regularizer_strength == 1.0 + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_is_callable(self, loss): """Test Ridge callable loss.""" @@ -641,6 +678,25 @@ def test_init_solver_kwargs(self, solver_kwargs): else: nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) + def test_regularizer_strength_none(self): + """Assert regularizer strength handled appropriately.""" + # if no strength given, should warn and set to 1.0 + with pytest.warns(UserWarning): + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + + assert model.regularizer_strength == 1.0 + + # if changed to regularized, should go to None + model.regularizer = "UnRegularized" + assert model.regularizer_strength is None + + # if changed back, should warn and set to 1.0 + with pytest.warns(UserWarning): + model.regularizer = regularizer + + assert model.regularizer_strength == 1.0 + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): """Test that the loss function is a callable""" @@ -825,6 +881,25 @@ def test_init_solver_kwargs(self, solver_kwargs): else: nmo.glm.GLM(regularizer=regularizer, solver_kwargs=solver_kwargs) + def test_regularizer_strength_none(self): + """Assert regularizer strength handled appropriately.""" + # if no strength given, should warn and set to 1.0 + with pytest.warns(UserWarning): + regularizer = self.cls() + model = nmo.glm.GLM(regularizer=regularizer) + + assert model.regularizer_strength == 1.0 + + # if changed to regularized, should go to None + model.regularizer = "UnRegularized" + assert model.regularizer_strength is None + + # if changed back, should warn and set to 1.0 + with pytest.warns(UserWarning): + model.regularizer = regularizer + + assert model.regularizer_strength == 1.0 + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): """Test that the loss function is a callable""" From e63ad213366805e7f2502e40001db3c26000733e Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 16:36:13 -0400 Subject: [PATCH 103/225] more contrib guide, stop CI run on draft --- .github/workflows/ci.yml | 6 ++++ CONTRIBUTING.md | 62 +++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff6568b..1c34740b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,18 @@ on: branches: - main - development + types: + - opened + - reopened + - synchronize + - ready_for_review push: branches: - main jobs: tox: + if: ${{ !github.event.pull_request.draft }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96b3890c..4e45ad1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,12 +16,12 @@ Feel free to work on any section of code that you believe you can improve. More In order to contribute, you will need to do the following: 1) Create your own branch -2) Make sure that tests pass +2) Make sure that tests pass and code coverage is maintained 3) Open a Pull Request The NeMoS package follows the [GitHub Flow](https://www.gitkraken.com/learn/git/best-practices/git-branch-strategy#github-flow-branch-strategy) workflow. In essence, this means no one is allowed to push to the `main` branch and all development happens in separate feature branches that are then merged into `main` once we have determined they are ready. When enough changes have accumulated, we -put out a new release, adding a new tag which increments the version number, and uploading the new release to PyPI. +put out a new release, adding a new tag which increments the version number, and upload the new release to PyPI. #### Creating a development environment @@ -72,7 +72,7 @@ git push origin my_feature_branch #### Contributing your change back to NeMoS You can make any number of changes on your branch. Once you are happy with your changes, add tests to check that they run correctly and add documentation to properly note your changes. -See below for details on how to [add tests]() and [add documentation](). +See below for details on how to [add tests](#adding-tests) and properly [document](#adding-documentation) your code. Lastly, you should make sure that the existing tests all run successfully and that the codebase is formatted properly: @@ -108,10 +108,24 @@ The next section will talk about the style of your code and specific requirement #### Adding a regularization method +All regularization method can be found in `/src/nemos/regularizer.py`. If you would like to add a new regularizer class to the `nemos` +library, it will need to do the following: + +1) Inherit from the base `Regularizer` base class +2) Have an `_allowed_solvers` attribute of type `List[str]` that gives the `string` names of compatible `jaxopt` solvers +3) Have two methods implemented + - `get_proximal_operator()` that returns the proximal operator for the regularization method when using projected gradient descent + - `penalized_loss()` that wraps a given model's loss function and returns the augmented loss based on the regularization being applied + #### Adding a new model +A new model should be put into its own `.py` file. If you would like to add a new model to the `nemos` library, it will need to inherit from the +`BaseRegressor` class and implement all of its abstract methods. + #### Adding a new optimization method +#### Adding a new basis + ### Testing To run all tests, run `pytest tests/` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. @@ -130,13 +144,47 @@ If you're adding a substantial bunch of tests that are separate from the existin it must have an `.py` extension, and it must be contained within the `tests` directory. Assuming you do that, our github actions will automatically find it and add it to the tests-to-run. -> **_NOTE:_** If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official `pytest` +> **_NOTE:_** If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official > documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html). -> **_NOTE_** If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use fixtures to avoid having to -> load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official `pytest` documentation -> [here](https://docs.pytest.org/en/stable/how-to/fixtures.html) +> **_NOTE_** If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to +> load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official documentation +> [here](https://docs.pytest.org/en/stable/how-to/fixtures.html). ### Documentation +Documentation is a crucial part of open-source software and greatly influences the ability to use a codebase. As such, it is imperative that any new changes are +properly documented as outlined below. + +#### Adding documentation + +1) **Docstrings** + +All public-facing functions and classes should have complete docstrings, which start with a one-line short summary of the function, +a medium-length description of the function / class and what it does, and a complete description of all arguments and return values. +Math should be included in a `Notes` section when necessary to explain what the function is doing, and references to primary literature +should be included in a `References` section when appropriate. Docstrings should be relatively short, providing the information necessary +for a user to use the code. + +Private functions and classes should have sufficient explanation that other developers know what the function / class does and how to use it, +but do not need to be as extensive. + +We follow the [numpydoc](https://numpydoc.readthedocs.io/en/latest/) conventions for docstring structure. + +2) **Examples/Tutorials** + +If your changes are significant (add a new functionality or drastically change the current codebase), then the current examples may need to be updated or +a new example may need to be added. + +All examples live within the `docs/` subfolder of `nemos`. + +To see if changes you have made break the current documentation, you can build the documentation locally. + +```bash +# build the docs within the nemos repo +mkdocs build +``` + +If the build fails, you will see line-specific errors that prompted the failure. + From a5d36bf8f68f192d6a16e22affe4a5370ee1c954 Mon Sep 17 00:00:00 2001 From: Caitlin Date: Tue, 23 Jul 2024 16:40:10 -0400 Subject: [PATCH 104/225] don't think this should run on draft either --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c34740b..1cb89b6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} check: - if: always() + if: ${{ !github.event.pull_request.draft }} needs: tox runs-on: ubuntu-latest steps: From 18c46307be0228a0c0a59092b38d485295f6389f Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 10:32:10 -0400 Subject: [PATCH 105/225] Update src/nemos/_regularizer_builder.py --- src/nemos/_regularizer_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/_regularizer_builder.py b/src/nemos/_regularizer_builder.py index 3bd7d30b..1264be05 100644 --- a/src/nemos/_regularizer_builder.py +++ b/src/nemos/_regularizer_builder.py @@ -10,7 +10,7 @@ def create_regularizer(name: str): Parameters ---------- name : - The string name of the regularizer to create. Must be one of: 'unregularized', 'ridge', 'lasso', 'group_lasso'. + The string name of the regularizer to create. Must be one of: 'UnRegularized', 'Ridge', 'Lasso', 'GroupLasso'. Returns ------- From 3949cc4a5bd9ebc6beacc145756ac9a87a5344f9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 10:42:56 -0400 Subject: [PATCH 106/225] moved solver instantiation to base regressor --- src/nemos/base_regressor.py | 59 ++++++++++++++++++++++++++++- src/nemos/glm.py | 74 ++++--------------------------------- 2 files changed, 66 insertions(+), 67 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 5a0c193b..8ad02f1e 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -82,6 +82,61 @@ def __init__( if solver_kwargs is None: solver_kwargs = dict() self.solver_kwargs = solver_kwargs + self._solver_init_state = None + self._solver_update = None + self._solver_run = None + + @property + def solver_init_state(self) -> Union[None, SolverInit]: + """ + Provides the initialization function for the solver's state. + + This function is responsible for initializing the solver's state, necessary for the start + of the optimization process. It sets up initial values for parameters like gradients and step + sizes based on the model configuration and input data. + + Returns + ------- + : + The function to initialize the state of the solver, if available; otherwise, None if + the solver has not yet been instantiated. + """ + return self._solver_init_state + + @property + def solver_update(self) -> Union[None, SolverUpdate]: + """ + Provides the function for updating the solver's state during the optimization process. + + This function is used to perform a single update step in the optimization process. It updates + the model's parameters based on the current state, data, and gradients. It is typically used + in scenarios where fine-grained control over each optimization step is necessary, such as in + online learning or complex optimization scenarios. + + Returns + ------- + : + The function to update the solver's state, if available; otherwise, None if the solver + has not yet been instantiated. + """ + return self._solver_update + + @property + def solver_run(self) -> Union[None, SolverRun]: + """ + Provides the function to execute the solver's optimization process. + + This function runs the solver using the initialized parameters and state, performing the + optimization to fit the model to the data. It iteratively updates the model parameters until + a stopping criterion is met, such as convergence or exceeding a maximum number of iterations. + + Returns + ------- + : + The function to run the solver's optimization process, if available; otherwise, None if + the solver has not yet been instantiated. + """ + return self._solver_run @property def regularizer(self) -> Union[None, Regularizer]: @@ -283,7 +338,9 @@ def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: **solver_init_state_kwargs, ) - return solver_init_state, solver_update, solver_run + self._solver_init_state = solver_init_state + self._solver_update = solver_update + self._solver_run = solver_run def _inspect_solver_kwargs( self, diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 233a0fc6..85899671 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -89,7 +89,7 @@ class GLM(BaseRegressor): firing rate will be `jnp.exp(model.intercept_)`. coef_ : Basis coefficients for the model. - solver_state : + solver_state_ : State of the solver after fitting. May include details like optimization error. scale_: Scale parameter for the model. The scale parameter is the constant $\Phi$, for which @@ -126,11 +126,8 @@ def __init__( # initialize to None fit output self.intercept_ = None self.coef_ = None - self.solver_state = None + self.solver_state_ = None self.scale_ = None - self._solver_init_state = None - self._solver_update = None - self._solver_run = None self.dof_resid_ = None @property @@ -145,58 +142,6 @@ def observation_model(self, observation: obs.Observations): obs.check_observation_model(observation) self._observation_model = observation - @property - def solver_init_state(self) -> Union[None, SolverInit]: - """ - Provides the initialization function for the solver's state. - - This function is responsible for initializing the solver's state, necessary for the start - of the optimization process. It sets up initial values for parameters like gradients and step - sizes based on the model configuration and input data. - - Returns - ------- - : - The function to initialize the state of the solver, if available; otherwise, None if - the solver has not yet been instantiated. - """ - return self._solver_init_state - - @property - def solver_update(self) -> Union[None, SolverUpdate]: - """ - Provides the function for updating the solver's state during the optimization process. - - This function is used to perform a single update step in the optimization process. It updates - the model's parameters based on the current state, data, and gradients. It is typically used - in scenarios where fine-grained control over each optimization step is necessary, such as in - online learning or complex optimization scenarios. - - Returns - ------- - : - The function to update the solver's state, if available; otherwise, None if the solver - has not yet been instantiated. - """ - return self._solver_update - - @property - def solver_run(self) -> Union[None, SolverRun]: - """ - Provides the function to execute the solver's optimization process. - - This function runs the solver using the initialized parameters and state, performing the - optimization to fit the model to the data. It iteratively updates the model parameters until - a stopping criterion is met, such as convergence or exceeding a maximum number of iterations. - - Returns - ------- - : - The function to run the solver's optimization process, if available; otherwise, None if - the solver has not yet been instantiated. - """ - return self._solver_run - @staticmethod def _check_params( params: Tuple[Union[DESIGN_INPUT_TYPE, ArrayLike], ArrayLike], @@ -713,7 +658,7 @@ def fit( # note that this will include an error value, which is not the same as # the output of loss. I believe it's the output of # solver.l2_optimality_error - self.solver_state = state + self.solver_state_ = state return self def _get_coef_and_intercept(self): @@ -939,11 +884,8 @@ def initialize_state( NamedTuple The initialized solver state """ - ( - self._solver_init_state, - self._solver_update, - self._solver_run, - ) = super().instantiate_solver() + # set up the solver init/run/update attrs + self.instantiate_solver() if isinstance(X, FeaturePytree): data = X.data @@ -1011,7 +953,7 @@ def update( -------- >>> # Assume glm_instance is an instance of GLM that has been previously fitted. >>> params = glm_instance.coef_, glm_instance.intercept_ - >>> opt_state = glm_instance.solver_state + >>> opt_state = glm_instance.solver_state_ >>> new_params, new_opt_state = glm_instance.update(params, opt_state, X, y) """ # find non-nans @@ -1029,7 +971,7 @@ def update( # store params and state self._set_coef_and_intercept(opt_step[0]) - self.solver_state = opt_step[1] + self.solver_state_ = opt_step[1] # estimate the scale self.dof_resid_ = self._estimate_resid_degrees_of_freedom( @@ -1094,7 +1036,7 @@ class PopulationGLM(GLM): firing rate will be `jnp.exp(model.intercept_)`. coef_ : Basis coefficients for the model. - solver_state : + solver_state_ : State of the solver after fitting. May include details like optimization error. Raises From 4f0a12d7f936825cac3c354ff003b058b99b66d3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 11:11:10 -0400 Subject: [PATCH 107/225] deepcopied solver_kwargs --- src/nemos/base_regressor.py | 62 +++++++++++++++++++++++++------------ src/nemos/glm.py | 8 ++--- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 8ad02f1e..41ddd03b 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -6,7 +6,8 @@ import abc import inspect import warnings -from typing import Any, NamedTuple, Optional, Tuple, Union, Dict +from copy import deepcopy +from typing import Any, Dict, NamedTuple, Optional, Tuple, Union import jax import jax.numpy as jnp @@ -164,6 +165,7 @@ def regularizer(self, regularizer: Union[str, Regularizer]): @property def regularizer_strength(self) -> float: + """Regularizer strength getter.""" return self._regularizer_strength @regularizer_strength.setter @@ -243,14 +245,13 @@ def _check_solver_kwargs(solver_name, solver_kwargs): f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" ) - def instantiate_solver( - self, *args, **kwargs - ) -> Tuple[SolverInit, SolverUpdate, SolverRun]: + def instantiate_solver(self, *args, **kwargs) -> None: """ Instantiate the solver with the provided loss function. - Instantiate the solver with the provided loss function, and return callable functions - that initialize the solver state, update the model parameters, and run the optimization. + Instantiate the solver with the provided loss function, and store callable functions + that initialize the solver state, update the model parameters, and run the optimization + as attributes. This method creates a solver instance from jaxopt library, tailored to the specific loss function and regularization approach defined by the Regularizer instance. It also handles @@ -260,16 +261,10 @@ def instantiate_solver( Parameters ---------- - loss : - The loss function to be optimized. - *args: Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing strength for proximal gradient methods. - prox: - Optional, the proximal projection operator. - *kwargs: Keyword arguments for the jaxopt `solver.run` method. @@ -299,12 +294,22 @@ def instantiate_solver( else: loss = self._predict_and_compute_loss + # copy dictionary of kwargs to avoid modifying user settings + solver_kwargs = deepcopy(self.solver_kwargs) + # check that the loss is Callable utils.assert_is_callable(loss, "loss") # some parsing to make sure solver gets instantiated properly if self.solver_name == "ProximalGradient": - self.solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) + if "prox" in self.solver_kwargs: + raise ValueError( + "Proximal operator specification is not permitted. " + "The proximal operator is automatically determined based on the selected regularizer. " + "Please remove the 'prox' argument from the `solver_kwargs` " + ) + + solver_kwargs.update(prox=self.regularizer.get_proximal_operator()) # add self.regularizer_strength to args args += (self.regularizer_strength,) @@ -313,7 +318,7 @@ def instantiate_solver( solver_init_state_kwargs, solver_update_kwargs, solver_init_kwargs, - ) = self._inspect_solver_kwargs() + ) = self._inspect_solver_kwargs(solver_kwargs) # instantiate the solver solver = getattr(jaxopt, self.solver_name)(fun=loss, **solver_init_kwargs) @@ -329,7 +334,6 @@ def solver_update(params, state, *run_args, **run_kwargs) -> jaxopt.OptStep: ) def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: - print(solver_init_state_kwargs) return solver.init_state( params, state, @@ -343,19 +347,39 @@ def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: self._solver_run = solver_run def _inspect_solver_kwargs( - self, + self, solver_kwargs: dict ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + """Inspect and categorize the solver keyword arguments. + + This method inspects the provided `solver_kwargs` dictionary and categorizes + the keyword arguments based on which solver functions they apply to: + `run`, `init_state`, `update`, and `__init__`. This ensures that the + appropriate arguments are passed to each function when the solver is used. + Parameters + ---------- + solver_kwargs : + Dictionary containing keyword arguments for the solver. + + Returns + ------- + : + A tuple containing four dictionaries: + - solver_run_kwargs: Arguments for the solver's `run` method. + - solver_init_state_kwargs: Arguments for the solver's `init_state` method. + - solver_update_kwargs: Arguments for the solver's `update` method. + - solver_init_kwargs: Arguments for the solver's `__init__` constructor. + """ solver_run_kwargs = dict() solver_init_state_kwargs = dict() solver_update_kwargs = dict() solver_init_kwargs = dict() - if self.solver_kwargs: + if solver_kwargs: # instantiate a solver to then inspect the params of its various functions solver = getattr(jaxopt, self.solver_name) - for key, value in self.solver_kwargs.items(): + for key, value in solver_kwargs.items(): if key in inspect.getfullargspec(solver.run).args: solver_run_kwargs[key] = value if key in inspect.getfullargspec(solver.init_state).args: @@ -467,7 +491,7 @@ def _check_input_n_timepoints( @abc.abstractmethod def _predict_and_compute_loss(self, params, X, y): - """The loss function for a given model to be optimized over.""" + """Loss function for a given model to be optimized over.""" pass def _validate( diff --git a/src/nemos/glm.py b/src/nemos/glm.py index 85899671..5c65ef81 100644 --- a/src/nemos/glm.py +++ b/src/nemos/glm.py @@ -5,7 +5,7 @@ import warnings from functools import wraps -from typing import Callable, Literal, NamedTuple, Optional, Tuple, Union, Any +from typing import Any, Callable, Literal, NamedTuple, Optional, Tuple, Union import jax import jax.numpy as jnp @@ -20,7 +20,7 @@ from .pytrees import FeaturePytree from .regularizer import GroupLasso, Lasso, Regularizer, Ridge from .type_casting import jnp_asarray_if, support_pynapple -from .typing import DESIGN_INPUT_TYPE, SolverInit, SolverRun, SolverUpdate +from .typing import DESIGN_INPUT_TYPE ModelParams = Tuple[jnp.ndarray, jnp.ndarray] @@ -861,9 +861,7 @@ def initialize_state( y: jnp.ndarray, init_params, ) -> Union[Any, NamedTuple]: - """ - Initialize the solver by instantiating its components (initial state, update function, - and run function). + """Initialize the solver by instantiating its init_state, update and, run methods. This method also prepares the solver's state by using the initialized model parameters and data. This setup is ready to be used for running the solver's optimization routines. From 094b61a5d7de48a9dfe8793fea6fcceeeecbb624 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 11:19:12 -0400 Subject: [PATCH 108/225] fixed tests --- tests/test_regularizer.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 0a9fb3d2..85adb351 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -1,3 +1,4 @@ +import copy import warnings from contextlib import nullcontext as does_not_raise from typing import NamedTuple @@ -250,8 +251,8 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): # set regularizer and solver name model.regularizer = self.cls() model.solver_name = solver_name - runner = model.instantiate_solver()[2] - runner((true_params[0] * 0.0, true_params[1]), X, y) + model.instantiate_solver() + model.solver_run((true_params[0] * 0.0, true_params[1]), X, y) @pytest.mark.parametrize( "solver_name", ["GradientDescent", "BFGS", "ProximalGradient"] @@ -264,8 +265,8 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre # set regularizer and solver name model.regularizer = self.cls() model.solver_name = solver_name - runner = model.instantiate_solver()[2] - runner( + model.instantiate_solver() + model.solver_run( (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, y, @@ -281,15 +282,16 @@ def test_solver_output_match(self, poissonGLM_model_instantiation): model.regularizer = self.cls() model.solver_name = "GradientDescent" model.solver_kwargs = {"tol": 10**-12} - runner_gd = model.instantiate_solver()[2] + model.instantiate_solver() # update solver name - model.solver_name = "BFGS" - runner_bfgs = model.instantiate_solver()[2] - weights_gd, intercepts_gd = runner_gd( + model_bfgs = copy.deepcopy(model) + model_bfgs.solver_name = "BFGS" + model_bfgs.instantiate_solver() + weights_gd, intercepts_gd = model.solver_run( (true_params[0] * 0.0, true_params[1]), X, y )[0] - weights_bfgs, intercepts_bfgs = runner_bfgs( + weights_bfgs, intercepts_bfgs = model_bfgs.solver_run( (true_params[0] * 0.0, true_params[1]), X, y )[0] @@ -309,8 +311,8 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): model.data_type = jnp.float64 model.regularizer = self.cls() model.solver_kwargs = {"tol": 10**-12} - runner_bfgs = model.instantiate_solver()[2] - weights_bfgs, intercepts_bfgs = runner_bfgs( + model.instantiate_solver() + weights_bfgs, intercepts_bfgs = model.solver_run( (true_params[0] * 0.0, true_params[1]), X, y )[0] model_skl = PoissonRegressor(fit_intercept=True, tol=10**-12, alpha=0.0) @@ -330,8 +332,8 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): model.observation_model.inverse_link_function = jnp.exp model.regularizer = self.cls() model.solver_kwargs = {"tol": 10**-12} - runner_bfgs = model.instantiate_solver()[2] - weights_bfgs, intercepts_bfgs = runner_bfgs( + model.instantiate_solver() + weights_bfgs, intercepts_bfgs = model.solver_run( (true_params[0] * 0.0, true_params[1]), X, y )[0] model_skl = GammaRegressor(fit_intercept=True, tol=10**-12, alpha=0.0) @@ -361,8 +363,8 @@ def test_solver_match_statsmodels_gamma( model.regularizer = self.cls() model.solver_name = "LBFGS" model.solver_kwargs = {"tol": 10**-13} - runner_bfgs = model.instantiate_solver()[2] - weights_bfgs, intercepts_bfgs = runner_bfgs( + model.instantiate_solver() + weights_bfgs, intercepts_bfgs = model.solver_run( model._initialize_parameters(X, y), X, y )[0] From 404a2660eecbf2c5571e8f1ba1b3fea1f180dbe9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 11:23:06 -0400 Subject: [PATCH 109/225] removed unused inputs --- src/nemos/base_regressor.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 41ddd03b..3d079300 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -245,7 +245,7 @@ def _check_solver_kwargs(solver_name, solver_kwargs): f"kwargs {undefined_kwargs} in solver_kwargs not a kwarg for jaxopt.{solver_name}!" ) - def instantiate_solver(self, *args, **kwargs) -> None: + def instantiate_solver(self, *args) -> BaseRegressor: """ Instantiate the solver with the provided loss function. @@ -265,18 +265,10 @@ def instantiate_solver(self, *args, **kwargs) -> None: Positional arguments for the jaxopt `solver.run` method, e.g. the regularizing strength for proximal gradient methods. - *kwargs: - Keyword arguments for the jaxopt `solver.run` method. - Returns ------- : - A tuple containing three callable functions: - - solver_init_state: Function to initialize the solver's state, necessary before starting the optimization. - - solver_update: Function to perform a single update step in the optimization process, - returning new parameters and state. - - solver_run: Function to execute the optimization process, applying multiple updates until a - stopping criterion is met. + The instance itself for method chaining. """ # final check that solver is valid for chosen regularizer if self.solver_name not in self.regularizer.allowed_solvers: @@ -320,6 +312,7 @@ def instantiate_solver(self, *args, **kwargs) -> None: solver_init_kwargs, ) = self._inspect_solver_kwargs(solver_kwargs) + # instantiate the solver solver = getattr(jaxopt, self.solver_name)(fun=loss, **solver_init_kwargs) @@ -345,6 +338,7 @@ def solver_init_state(params, state, *run_args, **run_kwargs) -> NamedTuple: self._solver_init_state = solver_init_state self._solver_update = solver_update self._solver_run = solver_run + return self def _inspect_solver_kwargs( self, solver_kwargs: dict From 769c31218a44adabc92d45e991a4a8368b9b3dc4 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 11:25:11 -0400 Subject: [PATCH 110/225] additional text fixes --- tests/test_regularizer.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 85adb351..661d9fac 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -937,8 +937,8 @@ def test_run_solver(self, poissonGLM_model_instantiation): model.regularizer = self.cls(mask=mask) model.solver_name = "ProximalGradient" - runner = model.instantiate_solver()[2] - runner((true_params[0] * 0.0, true_params[1]), X, y) + model.instantiate_solver() + model.solver_run((true_params[0] * 0.0, true_params[1]), X, y) def test_init_solver(self, poissonGLM_model_instantiation): """Test that the solver initialization returns a state.""" @@ -954,8 +954,8 @@ def test_init_solver(self, poissonGLM_model_instantiation): model.regularizer = self.cls(mask=mask) model.solver_name = "ProximalGradient" - init, _, _ = model.instantiate_solver() - state = init(true_params, X, y) + model.instantiate_solver() + state = model.solver_init_state(true_params, X, y) # asses that state is a NamedTuple by checking tuple type and the availability of some NamedTuple # specific namespace attributes assert isinstance(state, tuple) @@ -979,10 +979,10 @@ def test_update_solver(self, poissonGLM_model_instantiation): model.regularizer = self.cls(mask=mask) model.solver_name = "ProximalGradient" - init, update, _ = model.instantiate_solver() + model.instantiate_solver() - state = init((true_params[0] * 0.0, true_params[1]), X, y) - params, state = update(true_params, state, X, y) + state = model.solver_init_state((true_params[0] * 0.0, true_params[1]), X, y) + params, state = model.solver_update(true_params, state, X, y) # asses that state is a NamedTuple by checking tuple type and the availability of some NamedTuple # specific namespace attributes assert isinstance(state, tuple) @@ -1125,7 +1125,7 @@ def test_group_sparsity_enforcement( model.regularizer = self.cls(mask=mask) model.solver_name = "ProximalGradient" - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run params, _ = runner((true_params[0] * 0.0, true_params[1]), X, y) zeros_est = params[0] == 0 From 5c745ec1b98d3accb189864c01e9adcbd8a754ff Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 11:31:23 -0400 Subject: [PATCH 111/225] final text fixes and linting --- src/nemos/base_regressor.py | 1 - tests/test_regularizer.py | 21 ++++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 3d079300..d533e75e 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -312,7 +312,6 @@ def instantiate_solver(self, *args) -> BaseRegressor: solver_init_kwargs, ) = self._inspect_solver_kwargs(solver_kwargs) - # instantiate the solver solver = getattr(jaxopt, self.solver_name)(fun=loss, **solver_init_kwargs) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 661d9fac..49afdc47 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -511,7 +511,7 @@ def test_run_solver(self, solver_name, poissonGLM_model_instantiation): # set regularizer and solver name model.regularizer = self.cls() model.solver_name = solver_name - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run runner((true_params[0] * 0.0, true_params[1]), X, y) @pytest.mark.parametrize( @@ -525,7 +525,7 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre # set regularizer and solver name model.regularizer = self.cls() model.solver_name = solver_name - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run runner( (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, @@ -544,8 +544,11 @@ def test_solver_output_match(self, poissonGLM_model_instantiation): model.solver_name = "GradientDescent" model.solver_kwargs = {"tol": 10**-12} - runner_gd = model.instantiate_solver()[2] - runner_bfgs = model.instantiate_solver()[2] + model_bfgs = copy.deepcopy(model) + model_bfgs.solver_name = "BFGS" + + runner_gd = model.instantiate_solver().solver_run + runner_bfgs = model_bfgs.instantiate_solver().solver_run weights_gd, intercepts_gd = runner_gd( (true_params[0] * 0.0, true_params[1]), X, y @@ -571,7 +574,7 @@ def test_solver_match_sklearn(self, poissonGLM_model_instantiation): model.regularizer = self.cls() model.solver_kwargs = {"tol": 10**-12} - runner_bfgs = model.instantiate_solver()[2] + runner_bfgs = model.instantiate_solver().solver_run weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] @@ -597,7 +600,7 @@ def test_solver_match_sklearn_gamma(self, gammaGLM_model_instantiation): model.regularizer = self.cls() model.solver_kwargs = {"tol": 10**-12} model.regularizer_strength = 0.1 - runner_bfgs = model.instantiate_solver()[2] + runner_bfgs = model.instantiate_solver().solver_run weights_bfgs, intercepts_bfgs = runner_bfgs( (true_params[0] * 0.0, true_params[1]), X, y )[0] @@ -719,7 +722,7 @@ def test_run_solver(self, poissonGLM_model_instantiation): model.regularizer = self.cls() model.solver_name = "ProximalGradient" - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run runner((true_params[0] * 0.0, true_params[1]), X, y) @pytest.mark.parametrize("solver_name", ["ProximalGradient"]) @@ -731,7 +734,7 @@ def test_run_solver_tree(self, solver_name, poissonGLM_model_instantiation_pytre # set regularizer and solver name model.regularizer = self.cls() model.solver_name = solver_name - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run runner( (jax.tree_util.tree_map(jnp.zeros_like, true_params[0]), true_params[1]), X.data, @@ -748,7 +751,7 @@ def test_solver_match_statsmodels(self, poissonGLM_model_instantiation): model.solver_name = "ProximalGradient" model.solver_kwargs = {"tol": 10**-12} - runner = model.instantiate_solver()[2] + runner = model.instantiate_solver().solver_run weights, intercepts = runner((true_params[0] * 0.0, true_params[1]), X, y)[0] # instantiate the glm with statsmodels From 6f4866b9e8d1bf2fb1a2b0a5bb7baf4c8f6e4107 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 12:01:48 -0400 Subject: [PATCH 112/225] improved comment --- src/nemos/base_regressor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index d533e75e..5ebd3b7b 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -279,6 +279,8 @@ def instantiate_solver(self, *args) -> BaseRegressor: ) # only use penalized loss if not using proximal gradient descent + # In proximal method you must use the unpenalized loss independently + # of what regularizer you are using. if self.solver_name != "ProximalGradient": loss = self.regularizer.penalized_loss( self._predict_and_compute_loss, self.regularizer_strength From 0f12021235603dff08611cf37d5e2de2571ee32a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 12:18:34 -0400 Subject: [PATCH 113/225] added test and improved docs for GroupLasso mask --- src/nemos/regularizer.py | 3 ++- tests/test_regularizer.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 0a425f57..e983287f 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -317,11 +317,12 @@ class GroupLasso(Regularizer): Attributes ---------- - mask : Union[jnp.ndarray, NDArray] + mask : A 2d mask array indicating groups of features for regularization, shape (num_groups, num_features). Each row represents a group of features. Each column corresponds to a feature, where a value of 1 indicates that the feature belongs to the group, and a value of 0 indicates it doesn't. + Default is `mask = np.ones((1, num_features))`, grouping all features in a single group. Examples -------- diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 49afdc47..00049e8a 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -1277,3 +1277,9 @@ def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): model.regularizer = self.cls() model.solver_name = solver_name model.fit(X, y) + + +def test_available_regularizer_match(): + """Test matching of the two regularizer lists.""" + assert set(nmo._regularizer_builder.AVAILABLE_REGULARIZERS) == set(nmo.regularizer.__dir__()) + From 54e032612a20da30f38457ff221b3d6143540b1d Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 24 Jul 2024 13:48:57 -0400 Subject: [PATCH 114/225] add tests for checking get_params() --- tests/test_glm.py | 149 +++++++++++++++++++++++++++++++ tests/test_observation_models.py | 16 ++++ tests/test_regularizer.py | 29 +++++- 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 6b443864..7f4466c9 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -121,6 +121,78 @@ def test_parameter_initialization(self, X, y, poissonGLM_model_instantiation): assert coef.shape == (X.shape[1],) assert inter.shape == (1,) + def test_get_params(self): + """ + Test that get_params() contains expected values. + """ + expected_keys = [ + "observation_model__inverse_link_function", + "observation_model", + "regularizer", + "regularizer_strength", + "solver_kwargs", + "solver_name", + ] + + model = nmo.glm.GLM() + + expected_values = [ + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # passing params + model = nmo.glm.GLM(solver_name="LBFGS", regularizer="UnRegularized") + + expected_values = [ + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # changing regularizer + model.regularizer = "Ridge" + + expected_values = [ + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # changing solver + model.solver_name = "ProximalGradient" + + expected_values = [ + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + ####################### # Test model.fit ####################### @@ -1456,6 +1528,83 @@ def test_solver_type(self, regularizer, expectation, population_glm_class): with expectation: population_glm_class(regularizer=regularizer) + def test_get_params(self): + """ + Test that get_params() contains expected values. + """ + expected_keys = [ + "feature_mask", + "observation_model__inverse_link_function", + "observation_model", + "regularizer", + "regularizer_strength", + "solver_kwargs", + "solver_name", + ] + + model = nmo.glm.PopulationGLM() + + expected_values = [ + model.feature_mask, + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # passing params + model = nmo.glm.PopulationGLM(solver_name="LBFGS", regularizer="UnRegularized") + + expected_values = [ + model.feature_mask, + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # changing regularizer + model.regularizer = "Ridge" + + expected_values = [ + model.feature_mask, + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + + # changing solver + model.solver_name = "ProximalGradient" + + expected_values = [ + model.feature_mask, + model.observation_model.inverse_link_function, + model.observation_model, + model.regularizer, + model.regularizer_strength, + model.solver_kwargs, + model.solver_name, + ] + + assert list(model.get_params().keys()) == expected_keys + assert list(model.get_params().values()) == expected_values + @pytest.mark.parametrize( "observation, expectation", [ diff --git a/tests/test_observation_models.py b/tests/test_observation_models.py index 1fd8bf79..755c161d 100644 --- a/tests/test_observation_models.py +++ b/tests/test_observation_models.py @@ -110,6 +110,14 @@ def test_initialization_link_returns_scalar( else: observation_model.set_params(inverse_link_function=link_function) + def test_get_params(self, poisson_observations): + """Test get_params() returns expected values.""" + observation_model = poisson_observations() + + assert observation_model.get_params() == { + "inverse_link_function": observation_model.inverse_link_function + } + def test_deviance_against_statsmodels(self, poissonGLM_model_instantiation): """ Compare fitted parameters to statsmodels. @@ -380,6 +388,14 @@ def test_initialization_link_returns_scalar( else: observation_model.set_params(inverse_link_function=link_function) + def test_get_params(self, gamma_observations): + """Test get_params() returns expected values.""" + observation_model = gamma_observations() + + assert observation_model.get_params() == { + "inverse_link_function": observation_model.inverse_link_function + } + def test_deviance_against_statsmodels(self, gammaGLM_model_instantiation): """ Compare fitted parameters to statsmodels. diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 00049e8a..2a1ef4eb 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -205,6 +205,12 @@ def test_regularizer_strength_none(self): model.regularizer = regularizer assert model.regularizer_strength is None + def test_get_params(self): + """Test get_params() returns expected values.""" + regularizer = self.cls() + + assert regularizer.get_params() == {} + @pytest.mark.parametrize("solver_name", ["GradientDescent", "BFGS"]) @pytest.mark.parametrize("solver_kwargs", [{"tol": 10**-10}, {"tols": 10**-10}]) def test_init_solver_kwargs(self, solver_name, solver_kwargs): @@ -487,6 +493,12 @@ def test_regularizer_strength_none(self): assert model.regularizer_strength == 1.0 + def test_get_params(self): + """Test get_params() returns expected values.""" + regularizer = self.cls() + + assert regularizer.get_params() == {} + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_is_callable(self, loss): """Test Ridge callable loss.""" @@ -702,6 +714,12 @@ def test_regularizer_strength_none(self): assert model.regularizer_strength == 1.0 + def test_get_params(self): + """Test get_params() returns expected values.""" + regularizer = self.cls() + + assert regularizer.get_params() == {} + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): """Test that the loss function is a callable""" @@ -905,6 +923,12 @@ def test_regularizer_strength_none(self): assert model.regularizer_strength == 1.0 + def test_get_params(self): + """Test get_params() returns expected values.""" + regularizer = self.cls() + + assert regularizer.get_params() == {"mask": None} + @pytest.mark.parametrize("loss", [lambda a, b, c: 0, 1, None, {}]) def test_loss_callable(self, loss): """Test that the loss function is a callable""" @@ -1281,5 +1305,6 @@ def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): def test_available_regularizer_match(): """Test matching of the two regularizer lists.""" - assert set(nmo._regularizer_builder.AVAILABLE_REGULARIZERS) == set(nmo.regularizer.__dir__()) - + assert set(nmo._regularizer_builder.AVAILABLE_REGULARIZERS) == set( + nmo.regularizer.__dir__() + ) From 12fa0b613dd23956f1cb943c9833b17b96cd30ba Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 14:18:15 -0400 Subject: [PATCH 115/225] added description on how to contribute to basis and updated dev notes --- CONTRIBUTING.md | 11 +++++++--- docs/developers_notes/01-basis_module.md | 28 +++++++++++++++--------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e45ad1d..99e6ad7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,12 +120,17 @@ library, it will need to do the following: #### Adding a new model A new model should be put into its own `.py` file. If you would like to add a new model to the `nemos` library, it will need to inherit from the -`BaseRegressor` class and implement all of its abstract methods. - -#### Adding a new optimization method +`BaseRegressor` class and implement all of its abstract methods. #### Adding a new basis +All basis are in the `/src/nemos/basis.py` class. If you would like to add a new basis class, you: + +- **Must** inherit the abstract superclass `Basis` +- **Must** define the `__call__` and `_check_n_basis_min` methods with the expected input/output format, see [Code References](../../reference/nemos/basis/) for the specifics. +- **Should not** overwrite the `compute_features` and `evaluate_on_grid` methods inherited from `Basis`. +- **May** inherit any number of abstract intermediate classes (e.g., `SplineBasis`). + ### Testing To run all tests, run `pytest tests/` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. diff --git a/docs/developers_notes/01-basis_module.md b/docs/developers_notes/01-basis_module.md index c0bdf6f8..f6180c5b 100644 --- a/docs/developers_notes/01-basis_module.md +++ b/docs/developers_notes/01-basis_module.md @@ -26,18 +26,27 @@ Abstract Class Basis └─ Concrete Subclass OrthExponentialBasis ``` -The super-class `Basis` provides two public methods, [`evaluate`](#the-public-method-evaluate) and [`evaluate_on_grid`](#the-public-method-evaluate_on_grid). These methods perform checks on both the input provided by the user and the output of the evaluation to ensure correctness, and are thus considered "safe". They both make use of the private abstract method `_evaluate` that is specific for each concrete class. See below for more details. +The super-class `Basis` provides two public methods, [`compute_features`](#the-public-method-evaluate) and [`evaluate_on_grid`](#the-public-method-evaluate_on_grid). These methods perform checks on both the input provided by the user and the output of the evaluation to ensure correctness, and are thus considered "safe". They both make use of the abstract method `__call__` that is specific for each concrete class. See below for more details. ## The Class `nemos.basis.Basis` -### The Public Method `evaluate` +### The Public Method `compute_features` -The `evaluate` method checks input consistency and evaluates the basis function at some sample points. It accepts one or more numpy arrays as input, which represent the sample points at which the basis will be evaluated, and performs the following steps: +The `compute_features` method checks input consistency and applies the basis function to the inputs. +`Basis` can operate in two modes defined at initialization: `"eval"` and `"conv"`. When a basis is in mode `"eval"`, +`compute_features` evaluates the basis at the given input samples. When in mode `"conv"`, it will convolve the samples +with a bank of kernels, one per basis function. + +It accepts one or more NumPy array or pynapple `Tsd` object as input, and performs the following steps: 1. Checks that the inputs all have the same sample size `M`, and raises a `ValueError` if this is not the case. 2. Checks that the number of inputs matches what the basis being evaluated expects (e.g., one input for a 1-D basis, N inputs for an N-D basis, or the sum of N 1-D bases), and raises a `ValueError` if this is not the case. -3. Calls the `_evaluate` method on the input, which is the subclass-specific implementation of the basis set evaluation. -4. Returns a numpy array of shape `(M, n_basis_funcs)`, with each basis element evaluated at the samples. +3. In `"eval"` mode, calls the `__call__` method on the input, which is the subclass-specific implementation of the basis set evaluation. In `"conv"` mode, generates a filter bank using `evaluate_on_grid` and then applies the convolution to the input with `nemos.convolve.create_convolutional_predictor`. +4. Returns a NumPy array or pynapple `TsdFrame` of shape `(M, n_basis_funcs)`, with each basis element evaluated at the samples. + +!!! note "Multiple epochs" + Note that the convolution works gracefully with multiple disjoint epochs, when a pynapple time series is used as + input. ### The Public Method `evaluate_on_grid` @@ -47,14 +56,14 @@ This method performs the following steps: 1. Checks that the number of inputs matches what the basis being evaluated expects (e.g., one input for a 1-D basis, N inputs for an N-D basis, or the sum of N 1-D bases), and raises a `ValueError` if this is not the case. 2. Calls `_get_samples` method, which returns equidistant samples over the domain of the basis function. The domain may depend on the type of basis. -3. Calls the `evaluate` method. +3. Calls the `__call__` method. 4. Returns both the sample grid points of shape `(m1, ..., mN)`, and the evaluation output at each grid point of shape `(m1, ..., mN, n_basis_funcs)`, where `mi` is the number of sample points for the i-th axis of the grid. ### Abstract Methods The `nemos.basis.Basis` class has the following abstract methods, which every concrete subclass must implement: -1. `_evaluate`: Evaluates a basis over some specified samples. +1. `__call__`: Evaluates a basis over some specified samples. 2. `_check_n_basis_min`: Checks the minimum number of basis functions required. This requirement can be specific to the type of basis. ## Contributors Guidelines @@ -63,8 +72,7 @@ The `nemos.basis.Basis` class has the following abstract methods, which every co To write a usable (i.e., concrete, non-abstract) basis object, you - **Must** inherit the abstract superclass `Basis` -- **Must** define the `_evaluate` and `_check_n_basis_min` methods with the expected input/output format, see [Code References](../../reference/nemos/basis/) for the specifics. -- **Should not** overwrite the `evaluate` and `evaluate_on_grid` methods inherited from `Basis`. +- **Must** define the `__call__` and `_check_n_basis_min` methods with the expected input/output format, see [Code References](../../reference/nemos/basis/) for the specifics. +- **Should not** overwrite the `compute_features` and `evaluate_on_grid` methods inherited from `Basis`. - **May** inherit any number of abstract intermediate classes (e.g., `SplineBasis`). -- **May** reimplement the `_get_samples` method if your basis domain differs from `[0,1]`. However, we recommend mapping the specific basis domain to `[0,1]` whenever possible. From a00536bc33d8a55b6a750b25f17f1ce04e688361 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 14:22:11 -0400 Subject: [PATCH 116/225] changeing to dev notes --- docs/developers_notes/02-base_class.md | 43 +--------------------- docs/developers_notes/03-base_regressor.md | 39 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 41 deletions(-) create mode 100644 docs/developers_notes/03-base_regressor.md diff --git a/docs/developers_notes/02-base_class.md b/docs/developers_notes/02-base_class.md index 18a3b8e7..16189054 100644 --- a/docs/developers_notes/02-base_class.md +++ b/docs/developers_notes/02-base_class.md @@ -2,9 +2,9 @@ ## Introduction -The `base_class` module introduces the `Base` class and abstract classes defining broad model categories. These abstract classes **must** inherit from `Base`. +The `base_class` module introduces the `Base` class and abstract classes defining broad model categories and feature constructors. These abstract classes **must** inherit from `Base`. -The `Base` class is envisioned as the foundational component for any object type (e.g., regression, dimensionality reduction, clustering, observation models, regularizers etc.). In contrast, abstract classes derived from `Base` define overarching object categories (e.g., `base_class.BaseRegressor` is building block for GLMs, GAMS, etc. while `observation_models.Observations` is the building block for the Poisson observations, Gamma observations, ... etc.). +The `Base` class is envisioned as the foundational component for any object type (e.g., basis, regression, dimensionality reduction, clustering, observation models, regularizers etc.). In contrast, abstract classes derived from `Base` define overarching object categories (e.g., `base_regressor.BaseRegressor` is building block for GLMs, GAMS, etc. while `observation_models.Observations` is the building block for the Poisson observations, Gamma observations, ... etc.). Designed to be compatible with the `scikit-learn` API, the class structure aims to facilitate access to `scikit-learn`'s robust pipeline and cross-validation modules. This is achieved while leveraging the accelerated computational capabilities of `jax` and `jaxopt` in the backend, which is essential for analyzing extensive neural recordings and fitting large models. @@ -59,42 +59,3 @@ For a detailed understanding, consult the [`scikit-learn` API Reference](https:/ - **`get_params`**: The `get_params` method retrieves parameters set during model instance initialization. Opting for a deep inspection allows the method to assess nested object parameters, resulting in a comprehensive parameter dictionary. - **`set_params`**: The `set_params` method offers a mechanism to adjust or set an estimator's parameters. It's versatile, accommodating both individual estimators and more complex nested structures like pipelines. Feeding an unrecognized parameter will raise a `ValueError`. -## The Abstract Class `model_base.BaseRegressor` - -`BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of abstract methods: `fit`, `predict`, `score`, and `simulate`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. - -### Abstract Methods - -For subclasses derived from `BaseRegressor` to function correctly, they must implement the following: - -1. `fit`: Adapt the model using input data `X` and corresponding observations `y`. -2. `predict`: Provide predictions based on the trained model and input data `X`. -3. `score`: Score the accuracy of model predictions using input data `X` against the actual observations `y`. -4. `simulate`: Simulate data based on the trained regression model. - -### Public Methods - -To ensure the consistency and conformity of input data, the `BaseRegressor` introduces two public preprocessing methods: - -1. `preprocess_fit`: Assesses and converts the input for the `fit` method into the desired `jax.ndarray` format. If necessary, this method can initialize model parameters using default values. -2. `preprocess_simulate`: Validates and converts inputs for the `simulate` method. This method confirms the integrity of the feedforward input and, when provided, the initial values for feedback. - -### Auxiliary Methods - -Moreover, `BaseRegressor` incorporates auxiliary methods such as `_convert_to_jnp_ndarray`, `_has_invalid_entry` -and a number of other methods for checking input consistency. - -!!! Tip - Deciding between concrete and abstract methods in a superclass can be nuanced. As a general guideline: any method that's expected in all subclasses and isn't subclass-specific should be concretely implemented in the superclass. Conversely, methods essential for a subclass's expected behavior, but vary based on the subclass, should be abstract in the superclass. For instance, compatibility with the `sklearn.cross_validation` module demands `score`, `fit`, `get_params`, and `set_params` methods. Given their specificity to individual models, `score` and `fit` are abstract in `BaseRegressor`. Conversely, as `get_params` and `set_params` are consistent across model classes, they're inherited from `Base`. This approach typifies our general implementation strategy. However, it's important to note that while these are sound guidelines, exceptions exist based on various factors like future extensibility, clarity, and maintainability. - - -## Contributor Guidelines - -### Implementing Model Subclasses - -When devising a new model subclass based on the `BaseRegressor` abstract class, adhere to the subsequent guidelines: - -- **Must** inherit the `BaseRegressor` abstract superclass. -- **Must** realize the abstract methods: `fit`, `predict`, `score`, and `simulate`. -- **Should not** overwrite the `get_params` and `set_params` methods, inherited from `Base`. -- **May** introduce auxiliary methods such as `_convert_to_jnp_ndarray` for added utility. diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md new file mode 100644 index 00000000..e4f3c4e2 --- /dev/null +++ b/docs/developers_notes/03-base_regressor.md @@ -0,0 +1,39 @@ +## The Abstract Class `model_base.BaseRegressor` + +`BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of abstract methods: `fit`, `predict`, `score`, and `simulate`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. + +### Abstract Methods + +For subclasses derived from `BaseRegressor` to function correctly, they must implement the following: + +1. `fit`: Adapt the model using input data `X` and corresponding observations `y`. +2. `predict`: Provide predictions based on the trained model and input data `X`. +3. `score`: Score the accuracy of model predictions using input data `X` against the actual observations `y`. +4. `simulate`: Simulate data based on the trained regression model. + +### Public Methods + +To ensure the consistency and conformity of input data, the `BaseRegressor` introduces two public preprocessing methods: + +1. `preprocess_fit`: Assesses and converts the input for the `fit` method into the desired `jax.ndarray` format. If necessary, this method can initialize model parameters using default values. +2. `preprocess_simulate`: Validates and converts inputs for the `simulate` method. This method confirms the integrity of the feedforward input and, when provided, the initial values for feedback. + +### Auxiliary Methods + +Moreover, `BaseRegressor` incorporates auxiliary methods such as `_convert_to_jnp_ndarray`, `_has_invalid_entry` +and a number of other methods for checking input consistency. + +!!! Tip + Deciding between concrete and abstract methods in a superclass can be nuanced. As a general guideline: any method that's expected in all subclasses and isn't subclass-specific should be concretely implemented in the superclass. Conversely, methods essential for a subclass's expected behavior, but vary based on the subclass, should be abstract in the superclass. For instance, compatibility with the `sklearn.cross_validation` module demands `score`, `fit`, `get_params`, and `set_params` methods. Given their specificity to individual models, `score` and `fit` are abstract in `BaseRegressor`. Conversely, as `get_params` and `set_params` are consistent across model classes, they're inherited from `Base`. This approach typifies our general implementation strategy. However, it's important to note that while these are sound guidelines, exceptions exist based on various factors like future extensibility, clarity, and maintainability. + + +## Contributor Guidelines + +### Implementing Model Subclasses + +When devising a new model subclass based on the `BaseRegressor` abstract class, adhere to the subsequent guidelines: + +- **Must** inherit the `BaseRegressor` abstract superclass. +- **Must** realize the abstract methods: `fit`, `predict`, `score`, and `simulate`. +- **Should not** overwrite the `get_params` and `set_params` methods, inherited from `Base`. +- **May** introduce auxiliary methods such as `_convert_to_jnp_ndarray` for added utility. From 5ecc8ccc1773aa586894f13d240b3e17e756105c Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 24 Jul 2024 14:42:39 -0400 Subject: [PATCH 117/225] requested changes --- CONTRIBUTING.md | 51 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e45ad1d..a50e714a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,16 +26,19 @@ put out a new release, adding a new tag which increments the version number, and #### Creating a development environment -You will need a local installation of `nemos` which keeps up-to-date with any changes you make. To do so, you will need to clone `nemos` and checkout a new branch: +You will need a local installation of `nemos` which keeps up-to-date with any changes you make. To do so, you will need to fork and clone `nemos` before checking out +a new branch: -1) Clone the `nemos` repo to your local machine +1) Go to the [nemos repo](https://github.com/flatironinstitute/nemos) and click on the `Fork` button at the top right of the page. This will create a copy +of `nemos` in your GitHub account. You should then clone *your fork* to your local machine. ```bash -git clone https://github.com/flatironinstitute/nemos.git +git clone https://github.com//nemos.git cd nemos ``` 2) Install `nemos` in editable mode with developer dependencies + ```bash pip install -e .[dev] ``` @@ -43,17 +46,32 @@ pip install -e .[dev] > **_NOTE:_** In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that > provides guidance on how to create and activate a virtual environment. +3) Add the upstream branch: + +```bash +git remote add upstream https://github.com/flatironinstitute/nemos +``` + +At this point you have two remotes: `origin` (your fork) and `upstream` (the canonical version). You won't have permission to push to upstream (only `origin`), but +this make it easy to keep your `nemos` up-to-date with the canonical version by pulling from upstream: `git pull upstream`. #### Creating a new branch As mentioned previously, each feature in `nemos` is worked on in a separate branch. This allows multiple people developing multiple features simultaneously, without interfering with each other's work. To create your own branch, run the following from within your `nemos` directory: +> **_NOTE:_** Below we are checking out the `development` branch. In terms of the `nemos` contribution workflow cycle, the `development` branch accumulates a series of +> changes from different feature branches that are then all merged into the `main` branch at one time (normally at the time of a release) :D + ```bash -# switch to the main branch -git checkout main -# update your main branch -git pull origin main +# switch to the development branch +git checkout development +# update your development branch +git pull origin development +# sync with upstream development +git pull upstream development +# update your fork's development branch with any changs from upstream +git push origin development # create and switch to a new branch git checkout -b my_feature_branch ``` @@ -81,8 +99,14 @@ Lastly, you should make sure that the existing tests all run successfully and th pytest tests/ # format the code base black src/ -black tests/ +isort src +flake8 --config=tox.ini src ``` + +> **_INFO:_** [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically +> reformat your code and organize your imports, respectively. [`flake8`](https://flake8.pycqa.org/en/stable/#) does not modify your +> code directly; instead, it identifies syntax errors and code complexity issues that need to be addressed manually. + > **_NOTE:_** If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. Now you are ready to make a Pull Request. You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo @@ -112,8 +136,10 @@ All regularization method can be found in `/src/nemos/regularizer.py`. If you wo library, it will need to do the following: 1) Inherit from the base `Regularizer` base class -2) Have an `_allowed_solvers` attribute of type `List[str]` that gives the `string` names of compatible `jaxopt` solvers -3) Have two methods implemented +2) Have an `_allowed_solvers` attribute of type `List[str]` that gives the string names of compatible `jaxopt` solvers +3) Have a `_default_solver` attribute of type `str` that gives a string name of a default solver to be used when instantiating a model +with this type of regularizer if a user does not provide one +4) Have two methods implemented - `get_proximal_operator()` that returns the proximal operator for the regularization method when using projected gradient descent - `penalized_loss()` that wraps a given model's loss function and returns the augmented loss based on the regularization being applied @@ -128,7 +154,7 @@ A new model should be put into its own `.py` file. If you would like to add a ne ### Testing -To run all tests, run `pytest tests/` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. +To run all tests, run `pytest` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. There are several options for how to run a subset of tests: - Run tests from one file: `pytest tests/test_glm.py` - Run a specific test within a specific module: `pytests tests/test_glm.py::test_func` @@ -181,6 +207,9 @@ All examples live within the `docs/` subfolder of `nemos`. To see if changes you have made break the current documentation, you can build the documentation locally. ```bash +# Clear the cached documentation pages +# This step is only necessary if your changes affected the src/ directory +rm -r docs/generated # build the docs within the nemos repo mkdocs build ``` From a0e873b86564db0547112c29e6ace61d734b896a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 14:47:39 -0400 Subject: [PATCH 118/225] updated base regressor note --- docs/developers_notes/02-base_class.md | 4 --- docs/developers_notes/03-base_regressor.md | 38 +++++++++++++--------- src/nemos/base_regressor.py | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/developers_notes/02-base_class.md b/docs/developers_notes/02-base_class.md index 16189054..04c40fdf 100644 --- a/docs/developers_notes/02-base_class.md +++ b/docs/developers_notes/02-base_class.md @@ -40,10 +40,6 @@ Abstract Class Base ... ``` -!!! Example - The current package version includes a concrete class named `nemos.glm.GLM`. This class inherits from `BaseRegressor`, which in turn inherits `Base`, since it falls under the " GLM regression" category. - As any `BaseRegressor`, it **must** implement the `fit`, `score`, `predict`, and `simulate` methods. - ## The Class `model_base.Base` diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index e4f3c4e2..78864d0d 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -1,6 +1,11 @@ -## The Abstract Class `model_base.BaseRegressor` +## The Abstract Class `base_regressor.BaseRegressor` -`BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of abstract methods: `fit`, `predict`, `score`, and `simulate`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. +`BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of number of abstract methods such as `fit`, `predict`, `score`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. + + +!!! Example + The current package version includes a concrete class named `nemos.glm.GLM`. This class inherits from `BaseRegressor`, which in turn inherits `Base`, since it falls under the " GLM regression" category. + As any `BaseRegressor`, it **must** implement the `fit`, `score`, `predict` methods. ### Abstract Methods @@ -10,22 +15,23 @@ For subclasses derived from `BaseRegressor` to function correctly, they must imp 2. `predict`: Provide predictions based on the trained model and input data `X`. 3. `score`: Score the accuracy of model predictions using input data `X` against the actual observations `y`. 4. `simulate`: Simulate data based on the trained regression model. +5. `update`: Run a single optimization step, and stores the updated parameter and solver state. Used by stochastic optimization schemes. +6. `_predict_and_compute_loss`: Compute prediction and evaluates the loss function prvided the parameters and `X` and `y`. +7. `_check_params`: Check the parameter structure. +8. `_check_input_dimensionality`: Check the input dimensionality matches model expectation. +9. `_check_input_and_params_consistency`: Checks that the input and the parameters are consistent. +10. `_get_coef_and_intercept` and `_set_coef_and_intercept`: set and get model coefficient and intercept term. -### Public Methods - -To ensure the consistency and conformity of input data, the `BaseRegressor` introduces two public preprocessing methods: - -1. `preprocess_fit`: Assesses and converts the input for the `fit` method into the desired `jax.ndarray` format. If necessary, this method can initialize model parameters using default values. -2. `preprocess_simulate`: Validates and converts inputs for the `simulate` method. This method confirms the integrity of the feedforward input and, when provided, the initial values for feedback. - -### Auxiliary Methods -Moreover, `BaseRegressor` incorporates auxiliary methods such as `_convert_to_jnp_ndarray`, `_has_invalid_entry` -and a number of other methods for checking input consistency. +### Attributes -!!! Tip - Deciding between concrete and abstract methods in a superclass can be nuanced. As a general guideline: any method that's expected in all subclasses and isn't subclass-specific should be concretely implemented in the superclass. Conversely, methods essential for a subclass's expected behavior, but vary based on the subclass, should be abstract in the superclass. For instance, compatibility with the `sklearn.cross_validation` module demands `score`, `fit`, `get_params`, and `set_params` methods. Given their specificity to individual models, `score` and `fit` are abstract in `BaseRegressor`. Conversely, as `get_params` and `set_params` are consistent across model classes, they're inherited from `Base`. This approach typifies our general implementation strategy. However, it's important to note that while these are sound guidelines, exceptions exist based on various factors like future extensibility, clarity, and maintainability. +Public attributes are stored as properties: +- `regularizer`: An instance of the `nemos.regularizer.Regularizer` class. The setter for this property accepts either the instance directly or a string that is used to instantiate the appropriate regularizer. +- `regularizer_strength`: A float quantifying the amount of regularization. +- `solver_name`: One of the `jaxopt` solver supported solvers, currently "GradientDescent", "BFGS", "LBFGS", "ProximalGradient" and, "NonlinearCG". +- `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. +- `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. ## Contributor Guidelines @@ -34,6 +40,6 @@ and a number of other methods for checking input consistency. When devising a new model subclass based on the `BaseRegressor` abstract class, adhere to the subsequent guidelines: - **Must** inherit the `BaseRegressor` abstract superclass. -- **Must** realize the abstract methods: `fit`, `predict`, `score`, and `simulate`. +- **Must** realize the abstract methods, see above. - **Should not** overwrite the `get_params` and `set_params` methods, inherited from `Base`. -- **May** introduce auxiliary methods such as `_convert_to_jnp_ndarray` for added utility. +- **May** introduce auxiliary methods for added utility. diff --git a/src/nemos/base_regressor.py b/src/nemos/base_regressor.py index 5ebd3b7b..0e7581a7 100644 --- a/src/nemos/base_regressor.py +++ b/src/nemos/base_regressor.py @@ -428,7 +428,7 @@ def _check_params( data_type: Optional[jnp.dtype] = None, ) -> Tuple[DESIGN_INPUT_TYPE, jnp.ndarray]: """ - Validate the dimensions and consistency of parameters and data. + Validate the dimensions and consistency of parameters. This function checks the consistency of shapes and dimensions for model parameters. From 96729943788fe3d1169e8ced4224f3f2caf023c0 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 14:59:08 -0400 Subject: [PATCH 119/225] updated note on observation models --- .../developers_notes/03-observation_models.md | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/developers_notes/03-observation_models.md b/docs/developers_notes/03-observation_models.md index b7429bcd..1417e92a 100644 --- a/docs/developers_notes/03-observation_models.md +++ b/docs/developers_notes/03-observation_models.md @@ -8,42 +8,38 @@ The abstract class `Observations` defines the structure of the subclasses which ## The Abstract class `Observations` -The abstract class `Observations` is the backbone of any observation model. Any class inheriting `Observations` must reimplement the `negative_log_likelihood`, `sample_generator`, `residual_deviance`, and `estimate_scale` methods. +The abstract class `Observations` is the backbone of any observation model. Any class inheriting `Observations` must reimplement the `_negative_log_likelihood`, `log_likelihood`, `sample_generator`, `deviance`, and `estimate_scale` methods. ### Abstract Methods For subclasses derived from `Observations` to function correctly, they must implement the following: -- **negative_log_likelihood**: Computes the negative-log likelihood of the model up to a normalization constant. This method is usually part of the objective function used to learn GLM parameters. +- **_negative_log_likelihood**: Computes the negative-log likelihood of the model up to a normalization constant. This method is usually part of the objective function used to learn GLM parameters. + +- **log_likelihood**: Computes the full log-likelihood including the normalization constant. - **sample_generator**: Returns the random emission probability function. This typically invokes `jax.random` emission probability, provided some sufficient statistics[^1]. For distributions in the exponential family, the sufficient statistics are the canonical parameter and the scale. In GLMs, the canonical parameter is entirely specified by the model's weights, while the scale is either fixed (i.e., Poisson) or needs to be estimated (i.e., Gamma). -- **residual_deviance**: Computes the residual deviance based on the model's estimated rates and observations. +- **deviance**: Computes the deviance based on the model's estimated rates and observations. -- **estimate_scale**: A method for estimating the scale parameter of the model. +- **estimate_scale**: A method for estimating the scale parameter of the model. Rate and scale are sufficient to fully characterize distributions from the exponential family. ### Public Methods - **pseudo_r2**: Method for computing the pseudo-$R^2$ of the model based on the residual deviance. There is no consensus definition for the pseudo-$R^2$, what we used here is the definition by Cohen at al. 2003[^2]. +- **check_inverse_link_function**: Check that the link function is a auto-differentiable, vectorized function form $\mathbb{R} \longrightarrow \mathbb{R}$. -### Auxiliary Methods +## Concrete classes -- **_check_inverse_link_function**: Check that the provided link function is a `Callable` of the `jax` namespace. +### `PoissonObservations` -## Concrete `PoissonObservations` class +The `PoissonObservations` is designed for modeling observed spike counts based on a Poisson distribution with a given rate. -The `PoissonObservations` class extends the abstract `Observations` class to provide functionalities specific to the Poisson observation model. It is designed for modeling observed spike counts based on a Poisson distribution with a given rate. +### `GammaObservations` -### Overridden Methods +The `GammaObservations` is designed for modeling continuous calcium/voltage traces based on a Gamma distribution with a given rate. -- **negative_log_likelihood**: This method computes the Poisson negative log-likelihood of the predicted rates for the observed spike counts. - -- **sample_generator**: Generates random numbers from a Poisson distribution based on the given `predicted_rate`. - -- **residual_deviance**: Calculates the residual deviance for a Poisson model. - -- **estimate_scale**: Assigns a fixed value of 1 to the scale parameter of the Poisson model since Poisson distribution has a fixed scale. ## Contributor Guidelines @@ -51,9 +47,9 @@ To implement an observation model class you - **Must** inherit from `Observations` -- **Must** provide a concrete implementation of `negative_log_likelihood`, `sample_generator`, `residual_deviance`, and `estimate_scale`. +- **Must** provide a concrete implementation of the abstract methods, see above. -- **Should not** reimplement the `pseudo_r2` method as well as the `_check_inverse_link_function` auxiliary method. +- **Should not** reimplement the `pseudo_r2` method as well as the `check_inverse_link_function` auxiliary method. [^1]: In statistics, a statistic is sufficient with respect to a statistical model and its associated unknown parameters if "no other statistic that can be calculated from the same sample provides any additional information as to the value of the parameters", adapted from Fisher R. A. From f33b559d8375ee8c355e9fb21f9ad080eb56baca Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 15:34:12 -0400 Subject: [PATCH 120/225] fixed regularizer note --- docs/developers_notes/03-base_regressor.md | 14 +++ docs/developers_notes/04-regularizer.md | 140 +++++++-------------- src/nemos/regularizer.py | 6 +- 3 files changed, 62 insertions(+), 98 deletions(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 78864d0d..50e3b287 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -33,6 +33,10 @@ Public attributes are stored as properties: - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. - `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. +!!! note "Sovlers" + Solvers are typically optimizers from the `jaxopt` package, but in principle they could be custom optimization routines as long as they respect the `jaxopt` api (i.e., have a `run`, `init_state`, and `update` method with the appropriate input/output types). + We choose to rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. In the future we will provide a number of custom solvers optimized for convex stochastic optimization. + ## Contributor Guidelines ### Implementing Model Subclasses @@ -43,3 +47,13 @@ When devising a new model subclass based on the `BaseRegressor` abstract class, - **Must** realize the abstract methods, see above. - **Should not** overwrite the `get_params` and `set_params` methods, inherited from `Base`. - **May** introduce auxiliary methods for added utility. + + +## Glossary + +| Term | Description | +|--------------------| ----------- | +| **Regularization** | Regularization is a technique used to prevent overfitting by adding a penalty to the loss function, which discourages complex models. Common regularization techniques include L1 (Lasso) and L2 (Ridge) regularization. | +| **Optimization** | Optimization refers to the process of minimizing (or maximizing) a function by systematically choosing the values of the variables within an allowable set. In machine learning, optimization aims to minimize the loss function to train models. | +| **Solver** | A solver is an algorithm or a set of algorithms used for solving optimization problems. In the given module, solvers are used to find the parameters that minimize the loss function, potentially subject to some constraints. | +| **Runner** | A runner in this context refers to a callable function configured to execute the solver with the specified parameters and data. It acts as an interface to the solver, simplifying the process of running optimization tasks. | diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index 3026c162..4527819e 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -4,12 +4,9 @@ The `regularizer` module introduces an archetype class `Regularizer` which provides the structural components for each concrete sub-class. -Objects of type `Regularizer` provide methods to define a regularized optimization objective, and instantiate a solver for it. These objects serve as attribute of the [`nemos.glm.GLM`](../05-glm/#the-concrete-class-glm), equipping the glm with a solver for learning model parameters. +Objects of type `Regularizer` provide methods to define a regularized optimization objective. These objects serve as attribute of the [`nemos.glm.GLM`](../05-glm/#the-concrete-class-glm), equipping the glm with an appropriate regularization scheme. -Solvers are typically optimizers from the `jaxopt` package, but in principle they could be custom optimization routines as long as they respect the `jaxopt` api (i.e., have a `run` and `update` method with the appropriate input/output types). -We choose to rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. - -Each `Regularizer` object defines a set of allowed optimizers, which in turn depends on the loss function characteristics (smooth vs non-smooth) and/or the optimization type (constrained, un-constrained, batched, etc.). +Each `Regularizer` object defines a default solver, set of allowed solvers, which depends on the loss function characteristics (smooth vs non-smooth). ``` Abstract Class Regularizer @@ -26,120 +23,84 @@ Abstract Class Regularizer ``` !!! note - If we need advanced adaptive optimizers (e.g., Adam, LAMB etc.) in the future, we should consider adding [`Optax`](https://optax.readthedocs.io/en/latest/) as a dependency, which is compatible with `jaxopt`, see [here](https://jaxopt.github.io/stable/_autosummary/jaxopt.OptaxSolver.html#jaxopt.OptaxSolver). + If we need advanced adaptive solvers (e.g., Adam, LAMB etc.) in the future, we should consider adding [`Optax`](https://optax.readthedocs.io/en/latest/) as a dependency, which is compatible with `jaxopt`, see [here](https://jaxopt.github.io/stable/_autosummary/jaxopt.OptaxSolver.html#jaxopt.OptaxSolver). ## The Abstract Class `Regularizer` -The abstract class `Regularizer` enforces the implementation of the `instantiate_solver` method on any concrete realization of a `Regularizer` object. `Regularizer` objects are equipped with a method for instantiating a solver runner with the appropriately regularized loss function, i.e., a function that receives as input the initial parameters, the endogenous and the exogenous variables, and outputs the optimization results. - -Additionally, the class provides auxiliary methods for checking that the solver and loss function specifications are valid. +The abstract class `Regularizer` enforces the implementation of the `penalized_loss` and `get_proximal_operator` methods. -### Public Methods - -- **`instantiate_solver`**: Instantiate a solver runner for a provided loss function, configure and return a `solver_run` callable. The loss function must be of type `Callable`. +### Attributes -### Auxiliary Methods +The attributes of `Regularizer` consist of the `default_solver` and `allowed_solvers`, which are stored as read-only properties as a string and tuple of strings respectively. -- **`_check_solver`**: This method ensures that the provided solver name is in the list of allowed solvers for the specific `Regularizer` object. This is crucial for maintaining consistency and correctness in the solver's operation. +### Abstract Methods -- **`_check_solver_kwargs`**: This method checks if the provided keyword arguments are valid for the specified solver. This helps in catching and preventing potential errors in solver configuration. +- **`penalized_loss`**: Returns a penalized version of the input loss function which is uniquely defined by the regularization scheme and the regularizer strength parameter. +- **`get_proximal_operator`**: Returns the proximal projection operator which is uniquely defined by the regularization scheme. ## The `UnRegularized` Class The `UnRegularized` class extends the base `Regularizer` class and is designed specifically for optimizing unregularized models. This means that the solver instantiated by this class does not add any regularization penalty to the loss function during the optimization process. -### Attributes - -- **`allowed_solvers`**: A list of string identifiers for the optimization solvers that can be used with this regularizer class. The optimization methods listed here are specifically suitable for unregularized optimization problems. -### Methods +### Concrete methods specifics +- **`penalized_loss`**: Returns the original loss without any changes +- **`get_proximal_operator`**: Returns the identity operator. -- **`__init__`**: The constructor method for this class which initializes a new `UnRegularized` object. It accepts the name of the solver algorithm to use (`solver_name`) and an optional dictionary of additional keyword arguments (`solver_kwargs`) for the solver. - -- **`instantiate_solver`**: A method which prepares and returns a runner function for the specified loss function. This method ensures that the loss function is callable and prepares the necessary keyword arguments for calling the `get_runner` method from the base `Regularizer` class. - -### Example Usage - -```python -unregularized = UnRegularized(solver_name="GradientDescent") -runner = unregularized.instantiate_solver(loss_function) -optim_results = runner(init_params, exog_vars, endog_vars) -``` ## The `Ridge` Class The `Ridge` class extends the `Regularizer` class to handle optimization problems with Ridge regularization. Ridge regularization adds a penalty to the loss function, proportional to the sum of squares of the model parameters, to prevent overfitting and stabilize the optimization. -### Attributes - -- **`allowed_solvers`**: A list containing string identifiers of optimization solvers compatible with Ridge regularization. - -- **`regularizer_strength`**: A floating-point value determining the strength of the Ridge regularization. Higher values correspond to stronger regularization which tends to drive the model parameters towards zero. - -### Methods - -- **`__init__`**: The constructor method for the `Ridge` class. It accepts the name of the solver algorithm (`solver_name`), an optional dictionary of additional keyword arguments (`solver_kwargs`) for the solver, and the regularization strength (`regularizer_strength`). - -- **`penalization`**: A method to compute the Ridge regularization penalty for a given set of model parameters. - -- **`instantiate_solver`**: A method that prepares and returns a runner function with a penalized loss function for Ridge regularization. This method modifies the original loss function to include the Ridge penalty, ensures the loss function is callable, and prepares the necessary keyword arguments for calling the `get_runner` method from the base `Regularizer` class. +### Concrete methods specifics +- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of squares of the coefficients (excluding the intercept). +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + +\text{l2reg} \cdot ||y||_2^2$, where "l2reg" is the regularizer strength. ### Example Usage ```python -ridge = Ridge(solver_name="LBFGS", regularizer_strength=1.0) -runner = ridge.instantiate_solver(loss_function) -optim_results = runner(init_params, exog_vars, endog_vars) -``` - -## `ProxGradientRegularizer` Class - -`ProxGradientRegularizer` class extends the `Regularizer` class to utilize the Proximal Gradient method for optimization. It leverages the `jaxopt` library's Proximal Gradient optimizer, introducing the functionality of a proximal operator. - -### Attributes: -- **`allowed_solvers`**: A list containing string identifiers of optimization solvers compatible with this solver, specifically the "ProximalGradient". +import nemos as nmo -### Methods: -- **`__init__`**: The constructor method for the `ProxGradientRegularizer` class. It accepts the name of the solver algorithm (`solver_name`), an optional dictionary of additional keyword arguments (`solver_kwargs`) for the solver, the regularization strength (`regularizer_strength`), and an optional mask array (`mask`). - -- **`get_prox_operator`**: Abstract method to retrieve the proximal operator for this solver. - -- **`instantiate_solver`**: Method to prepare and return a runner function for optimization with a provided loss function and proximal operator. +ridge = nmo.regularizer.Ridge() +model = nmo.glm.GLM(regularizer=ridge) +``` ## `Lasso` Class -`Lasso` class extends `ProxGradientRegularizer` to specialize in optimization using the Lasso (L1 regularization) method with Proximal Gradient. +The `Lasso` class enables optimization using the Lasso (L1 regularization) method with Proximal Gradient. -### Methods: -- **`__init__`**: Constructor method similar to `ProxGradientRegularizer` but defaults `solver_name` to "ProximalGradient". - -- **`get_prox_operator`**: Method to retrieve the proximal operator for Lasso regularization (L1 penalty). +### Concrete methods specifics +- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the absolute values of the coefficients (excluding the intercept). +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + + \text{l1reg} \cdot ||y||_1$, where "l1reg" is the regularizer strength. ## `GroupLasso` Class -`GroupLasso` class extends `ProxGradientRegularizer` to specialize in optimization using the Group Lasso regularization method with Proximal Gradient. It induces sparsity on groups of features rather than individual features. +The `GroupLasso` class enables optimization using the Group Lasso regularization method with Proximal Gradient. It induces sparsity on groups of features rather than individual features. ### Attributes: - **`mask`**: A mask array indicating groups of features for regularization. -### Methods: -- **`__init__`**: Constructor method similar to `ProxGradientRegularizer`, but additionally requires a `mask` array to identify groups of features. - -- **`get_prox_operator`**: Method to retrieve the proximal operator for Group Lasso regularization. - -- **`_check_mask`**: Static method to check that the provided mask is a float `jax.numpy.ndarray` of 0s and 1s. The mask must be in floats to be applied correctly through the linear algebra operations of the `nemos.proimal_operator.prox_group_lasso` function. +### Concrete methods specifics +- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the L2 norms of the coefficient vectors for each group defined by the `mask`. +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + + \text{lgreg} \cdot \sum_j ||y^{(j)}||_2$, where "lgreg" is the regularizer strength, and $j$ runs over the coefficient groups. ### Example Usage ```python -lasso = Lasso(regularizer_strength=1.0) -runner = lasso.instantiate_solver(loss_function) -optim_results = runner(init_params, exog_vars, endog_vars) - -group_lasso = GroupLasso(solver_name="ProximalGradient", mask=group_mask, regularizer_strength=1.0) -runner = group_lasso.instantiate_solver(loss_function) -optim_results = runner(init_params, exog_vars, endog_vars) +import nemos as nmo +import numpy as np + +group_mask = np.zeros((2, 10)) # assume 2 groups and 10 features +group_mask[0, :4] = 1 # assume the first group consist of the first 4 coefficients +group_mask[1, 4:] = 1 # assume the second group consist of the last 6 coefficients +group_lasso = nmo.regularizer.GroupLasso(mask=group_mask) +model = nmo.glm.GLM(regularizer=group_lasso) ``` + + ## Contributor Guidelines ### Implementing `Regularizer` Subclasses @@ -147,20 +108,7 @@ optim_results = runner(init_params, exog_vars, endog_vars) When developing a functional (i.e., concrete) `Regularizer` class: - **Must** inherit from `Regularizer` or one of its derivatives. -- **Must** implement the `instantiate_solver` method to tailor the solver instantiation based on the provided loss function. -- For any Proximal Gradient method, **must** include a `get_prox_operator` method to define the proximal operator. -- **Must** possess an `allowed_solvers` attribute to list the solver names that are permissible to be used with this regularizer. -- **May** embed additional attributes and methods such as `mask` and `_check_mask` if required by the specific Solver subclass for handling special optimization scenarios. -- **May** include a `regularizer_strength` attribute to control the strength of the regularization in scenarios where regularization is applicable. -- **May** rely on a custom solver implementation for specific optimization problems, but the implementation **must** adhere to the `jaxopt` API. - -These guidelines ensure that each Solver subclass adheres to a consistent structure and behavior, facilitating ease of extension and maintenance. - -## Glossary - -| Term | Description | -|--------------------| ----------- | -| **Regularization** | Regularization is a technique used to prevent overfitting by adding a penalty to the loss function, which discourages complex models. Common regularization techniques include L1 (Lasso) and L2 (Ridge) regularization. | -| **Optimization** | Optimization refers to the process of minimizing (or maximizing) a function by systematically choosing the values of the variables within an allowable set. In machine learning, optimization aims to minimize the loss function to train models. | -| **Solver** | A solver is an algorithm or a set of algorithms used for solving optimization problems. In the given module, solvers are used to find the parameters that minimize the loss function, potentially subject to some constraints. | -| **Runner** | A runner in this context refers to a callable function configured to execute the solver with the specified parameters and data. It acts as an interface to the solver, simplifying the process of running optimization tasks. | +- **Must** implement the `penalized_loss` and `proximal_operator` methods. +- **Must** define a default solver and a tuple of allowed solvers. +- + diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index e983287f..af5bf224 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -52,13 +52,14 @@ def __init__( super().__init__(**kwargs) @property - def allowed_solvers(self): + def allowed_solvers(self) -> Tuple[str]: return self._allowed_solvers @property - def default_solver(self): + def default_solver(self) -> str: return self._default_solver + @abc.abstractmethod def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """ Abstract method to penalize loss functions. @@ -78,6 +79,7 @@ def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callabl """ pass + @abc.abstractmethod def get_proximal_operator( self, ) -> ProximalOperator: From 8cbe550b5e49a2a9e7196f05c423d428cec6a8a9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:03:27 -0400 Subject: [PATCH 121/225] uodated glm guidelines --- docs/developers_notes/05-glm.md | 44 ++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/developers_notes/05-glm.md b/docs/developers_notes/05-glm.md index dc01329d..24b849c6 100644 --- a/docs/developers_notes/05-glm.md +++ b/docs/developers_notes/05-glm.md @@ -9,13 +9,13 @@ Generalized Linear Models (GLM) provide a flexible framework for modeling a vari The `nemos.glm` module currently offers implementations of two GLM classes: 1. **`GLM`:** A direct implementation of a feedforward GLM. -2. **`RecurrentGLM`:** An implementation of a recurrent GLM. This class inherits from `GLM` and redefines the `simulate` method to generate spikes akin to a recurrent neural network. +2. **`PopulationGLM`:** An implementation of a GLM for fitting a populaiton of neuron in a vectorized manner. This class inherits from `GLM` and redefines the `fit` and `_predict` to fit the model and predict the firing rate. Our design aligns with the `scikit-learn` API, facilitating seamless integration of our GLM classes with the well-established `scikit-learn` pipeline and its cross-validation tools. The classes provided here are modular by design offering a standard foundation for any GLM variant. -Instantiating a specific GLM simply requires providing an observation model (Gamma, Poisson, etc.) and a regularization strategies (Ridge, Lasso, etc.) during initialization. This is done using the [`nemos.observation_models.Observations`](../03-observation_models/#the-abstract-class-observations) and [`nemos.regularizer.Regularizer`](../04-regularizer/#the-abstract-class-regularizer) objects, respectively. +Instantiating a specific GLM simply requires providing an observation model (Gamma, Poisson, etc.), a regularization strategies (Ridge, Lasso, etc.) and an optimization scheme during initialization. This is done using the [`nemos.observation_models.Observations`](../03-observation_models/#the-abstract-class-observations), [`nemos.regularizer.Regularizer`](../04-regularizer/#the-abstract-class-regularizer) objects as well as the compatible `jaxopt` solvers, respectively.
@@ -31,15 +31,19 @@ The `GLM` class provides a direct implementation of the GLM model and is designe ### Inheritance -`GLM` inherits from [`BaseRegressor`](../02-base_class/#the-abstract-class-baseregressor). This inheritance mandates the direct implementation of methods like `predict`, `fit`, `score`, and `simulate`. +`GLM` inherits from [`BaseRegressor`](../02-base_class/#the-abstract-class-baseregressor). This inheritance mandates the direct implementation of methods like `predict`, `fit`, `score` `update`, and `simulate`, plus a number of validation methods. ### Attributes -- **`regularizer`**: Refers to the optimization regularizer - an object of the [`nemos.regularizer.regularizer`](../04-regularizer/#the-abstract-class-regularizer) type. It uses the `jaxopt` solver to minimize the (penalized) negative log-likelihood of the GLM. -- **`observation_models`**: Represents the GLM observation model, which is an object of the [`nemos.observation_models.Observations`](../03-observation_models/#the-abstract-class-observations) type. This model determines the log-likelihood and the emission probability mechanism for the `GLM`. +- **`regularizer`**: Refers to the optimization regularizer - an object of the [`nemos.regularizer.regularizer`](../04-regularizer/#the-abstract-class-regularizer) type. This is used to add a penalization to the GLM negative log-likelihood loss or to define a proximal operator for the `ProximalGradient` solver. +- **`observation_model`**: Property that represents the GLM observation model, which is an object of the [`nemos.observation_models.Observations`](../03-observation_models/#the-abstract-class-observations) type. This model determines the log-likelihood and the emission probability mechanism for the `GLM`. - **`coef_`**: Stores the solution for spike basis coefficients as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. - **`intercept_`**: Stores the bias terms' solutions as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. -- **`solver_state`**: Indicates the solver's state. For specific solver states, refer to the [`jaxopt` documentation](https://jaxopt.github.io/stable/index.html#). +- *`dof_resid_`*: The degrees of freedom of the model's residual. this quantity is used to estimate the scale parameter, see below, and compute frequentist confidence intervals. +- **`scale_`**: The scale parameter of the observation distribution, which togheter with the rate, uniquely specifies a disribution of the exponential family. Example: a 1D Gaussian is specified by the mean which is the rate, and the standard deviation, which is the scale. +- **`solver_state_`**: Indicates the solver's state. For specific solver states, refer to the [`jaxopt` documentation](https://jaxopt.github.io/stable/index.html#). + +Additionally, the `GLM` class inherits the attributes of `BaseRegressor`, see the [relative note](03-base_regressor.md) for more information. ### Public Methods @@ -47,21 +51,34 @@ The `GLM` class provides a direct implementation of the GLM model and is designe - **`score`**: Validates input and assesses the Poisson GLM using either log-likelihood or pseudo-$R^2$. This method uses the `observation_models` to determine log-likelihood or pseudo-$R^2$. - **`fit`**: Validates input and aligns the Poisson GLM with spike train data. It leverages the `observation_models` and `regularizer` to define the model's loss function and instantiate the regularizer. - **`simulate`**: Simulates spike trains using the GLM as a feedforward network, invoking the `observation_models.sample_generator` method for emission probability. +- **`initialize_params`**: Initialize model parameters, setting to zero the coefficients, and setting the intercept by matching the firing rate. +- **`initialize_state`**: Initialize the state of the solver. +- **`update`**: Run a step of optimization and update the parameter and solver step. ### Private Methods +Here we list the private method related to the model compuations: + - **`_predict`**: Forecasts rates based on current model parameters and the inverse-link function of the `observation_models`. -- **`_score`**: Determines the Poisson negative log-likelihood, excluding normalization constants. -- **`_check_is_fit`**: Validates whether the model has been appropriately fit by ensuring model parameters are set. If not, a `NotFittedError` is raised. +- **`_predict_and_compute_loss`**: Predicts the rate and calculates the mean Poisson negative log-likelihood, excluding normalization constants. + +A number of `GLM` specific private methods are used for checking parameters and inputs, while the methods related for checking the solver-regularizer configurations/instantiation are inherited from `BaseRergessor`. + +## The Concrete Class `PopulationGLM` -## The Concrete Class `RecurrentGLM` +The `PopulationGLM` class is an extension of the `GLM`, designed to fit multiple neurons jointly. This involves vectorized fitting processes that efficiently handle multiple neurons simultaneously, leveraging the inherent parallelism. -The `RecurrentGLM` class is an extension of the `GLM`, designed to simulate models with recurrent connections. It inherits the `predict`, `fit`, and `score` methods from `GLM`, but provides its own implementation for the `simulate` method. +### `PopulationGLM` Specific Attributes + +- **`feature_mask`**: A mask that determines which features are used as predictors for each neuron. It can be a matrix of shape `(num_features, num_neurons)` or a `FeaturePytree` of binary values, where 1 indicates that a feature is used for a particular neuron and 0 indicates it is not. ### Overridden Methods -- **`simulate`**: This method simulates spike trains, treating the GLM as a recurrent neural network. It utilizes the `observation_models.sample_generator` method to determine the emission probability. +- **`fit`**: Overridden to handle fitting of the model to a neural population. This method validates input including the mask and fits the model parameters (coefficients and intercepts) to the data. +- **`_predict`**: Computes the predicted firing rates using the model parameters and the feature mask. + + ## Contributor Guidelines @@ -70,7 +87,6 @@ The `RecurrentGLM` class is an extension of the `GLM`, designed to simulate mode When crafting a functional (i.e., concrete) GLM class: - **Must** inherit from `BaseRegressor` or one of its derivatives. -- **Must** realize the `predict`, `fit`, `score`, and `simulate` methods, either directly or through inheritance. -- **Should** incorporate a `observation_models` attribute of type `nemos.observation_models.Observations` to specify the link-function, emission probability, and likelihood. -- **Should** include a `regularizer` attribute of type `nemos.regularizer.Regularizer` to instantiate the solver based on regularization type. +- **Must** realize all abstract methods. - **May** embed additional parameter and input checks if required by the specific GLM subclass. +- **May** override some of the computations if needed by the model specifications. From ad4d5e7e05f54720da28228f489f68aeca88b752 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:04:27 -0400 Subject: [PATCH 122/225] removed warning --- docs/developers_notes/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/developers_notes/README.md b/docs/developers_notes/README.md index c0d90ac5..250ef834 100644 --- a/docs/developers_notes/README.md +++ b/docs/developers_notes/README.md @@ -1,7 +1,5 @@ # Introduction -!!! warning - This note is out-of-sync with the current API. Please, do not rely on this yet for contributing to NeMoS. Welcome to the Developer Notes of the NeMoS project. These notes aim to provide detailed technical information about the various modules, classes, and functions that make up this library, as well as guidelines on how to write code that integrates nicely with our package. They are intended to help current and future developers understand the design decisions, structure, and functioning of the library, and to provide guidance on how to modify, extend, and maintain the codebase. From 3c127b55c8b0c05c7118985764af3e46be40fcd9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:09:23 -0400 Subject: [PATCH 123/225] renamed stuff --- docs/developers_notes/03-base_regressor.md | 2 +- .../{03-observation_models.md => 05-observation_models.md} | 0 docs/developers_notes/{05-glm.md => 06-glm.md} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename docs/developers_notes/{03-observation_models.md => 05-observation_models.md} (100%) rename docs/developers_notes/{05-glm.md => 06-glm.md} (100%) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 50e3b287..dd1f8bcc 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -1,4 +1,4 @@ -## The Abstract Class `base_regressor.BaseRegressor` +# The Abstract Class `base_regressor.BaseRegressor` `BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of number of abstract methods such as `fit`, `predict`, `score`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. diff --git a/docs/developers_notes/03-observation_models.md b/docs/developers_notes/05-observation_models.md similarity index 100% rename from docs/developers_notes/03-observation_models.md rename to docs/developers_notes/05-observation_models.md diff --git a/docs/developers_notes/05-glm.md b/docs/developers_notes/06-glm.md similarity index 100% rename from docs/developers_notes/05-glm.md rename to docs/developers_notes/06-glm.md From 0344360a373cfde3a9ab3539b1a2c50253fc3cc9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:10:32 -0400 Subject: [PATCH 124/225] renamed stuff --- docs/developers_notes/03-base_regressor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index dd1f8bcc..58875a37 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -1,4 +1,4 @@ -# The Abstract Class `base_regressor.BaseRegressor` +# The Abstract Class `BaseRegressor` `BaseRegressor` is an abstract class that inherits from `Base`, stipulating the implementation of number of abstract methods such as `fit`, `predict`, `score`. This ensures seamless assimilation with `scikit-learn` pipelines and cross-validation procedures. From 849d55a02b6870206a93749f6af82cecdd52e7b8 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:18:26 -0400 Subject: [PATCH 125/225] updated descr --- docs/developers_notes/04-regularizer.md | 26 ++++++++++++------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index 4527819e..43ac9c14 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -6,7 +6,7 @@ The `regularizer` module introduces an archetype class `Regularizer` which provi Objects of type `Regularizer` provide methods to define a regularized optimization objective. These objects serve as attribute of the [`nemos.glm.GLM`](../05-glm/#the-concrete-class-glm), equipping the glm with an appropriate regularization scheme. -Each `Regularizer` object defines a default solver, set of allowed solvers, which depends on the loss function characteristics (smooth vs non-smooth). +Each `Regularizer` object defines a default solver, and a set of allowed solvers, which depends on the loss function characteristics (smooth vs non-smooth). ``` Abstract Class Regularizer @@ -31,7 +31,7 @@ The abstract class `Regularizer` enforces the implementation of the `penalized_l ### Attributes -The attributes of `Regularizer` consist of the `default_solver` and `allowed_solvers`, which are stored as read-only properties as a string and tuple of strings respectively. +The attributes of `Regularizer` consist of the `default_solver` and `allowed_solvers`, which are stored as read-only properties of type string and tuple of strings respectively. ### Abstract Methods @@ -43,8 +43,8 @@ The attributes of `Regularizer` consist of the `default_solver` and `allowed_sol The `UnRegularized` class extends the base `Regularizer` class and is designed specifically for optimizing unregularized models. This means that the solver instantiated by this class does not add any regularization penalty to the loss function during the optimization process. -### Concrete methods specifics -- **`penalized_loss`**: Returns the original loss without any changes +### Concrete Methods Specifics +- **`penalized_loss`**: Returns the original loss without any changes. - **`get_proximal_operator`**: Returns the identity operator. @@ -52,10 +52,9 @@ The `UnRegularized` class extends the base `Regularizer` class and is designed s The `Ridge` class extends the `Regularizer` class to handle optimization problems with Ridge regularization. Ridge regularization adds a penalty to the loss function, proportional to the sum of squares of the model parameters, to prevent overfitting and stabilize the optimization. -### Concrete methods specifics +### Concrete Methods Specifics - **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of squares of the coefficients (excluding the intercept). -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 - +\text{l2reg} \cdot ||y||_2^2$, where "l2reg" is the regularizer strength. +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 +\text{l2reg} \cdot ||y||_2^2,$$ where "l2reg" is the regularizer strength. ### Example Usage @@ -70,10 +69,9 @@ model = nmo.glm.GLM(regularizer=ridge) The `Lasso` class enables optimization using the Lasso (L1 regularization) method with Proximal Gradient. -### Concrete methods specifics +### Concrete Methods Specifics - **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the absolute values of the coefficients (excluding the intercept). -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 - + \text{l1reg} \cdot ||y||_1$, where "l1reg" is the regularizer strength. +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + \text{l1reg} \cdot ||y||_1,$$ where "l1reg" is the regularizer strength. ## `GroupLasso` Class @@ -82,10 +80,11 @@ The `GroupLasso` class enables optimization using the Group Lasso regularization ### Attributes: - **`mask`**: A mask array indicating groups of features for regularization. -### Concrete methods specifics +### Concrete Methods Specifics - **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the L2 norms of the coefficient vectors for each group defined by the `mask`. -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 - + \text{lgreg} \cdot \sum_j ||y^{(j)}||_2$, where "lgreg" is the regularizer strength, and $j$ runs over the coefficient groups. +- **`get_proximal_operator`**: Returns the ridge proximal operator, solving +$$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + \text{lgreg} \cdot \sum_j ||y^{(j)}||_2,$$ where "lgreg" is the regularizer strength, and $j$ runs over the coefficient groups. + ### Example Usage ```python @@ -110,5 +109,4 @@ When developing a functional (i.e., concrete) `Regularizer` class: - **Must** inherit from `Regularizer` or one of its derivatives. - **Must** implement the `penalized_loss` and `proximal_operator` methods. - **Must** define a default solver and a tuple of allowed solvers. -- From df263d9f76eecfa0482ac748c41edc7df6705671 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:25:56 -0400 Subject: [PATCH 126/225] add more info --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d34cd973..5dc39197 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -221,4 +221,6 @@ mkdocs build If the build fails, you will see line-specific errors that prompted the failure. +### More On NeMoS Modules +For more detailed information about NeMoS modules, including design choices and implementation details, visit the [`For Developers`](https://nemos.readthedocs.io/en/latest/developers_notes/) section of the package documentation. \ No newline at end of file From 05ba9bf4f04f33b19edbb687c6f4d44c84479ab3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 16:29:19 -0400 Subject: [PATCH 127/225] correct method name --- docs/developers_notes/04-regularizer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index 43ac9c14..a5c1110b 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -107,6 +107,6 @@ model = nmo.glm.GLM(regularizer=group_lasso) When developing a functional (i.e., concrete) `Regularizer` class: - **Must** inherit from `Regularizer` or one of its derivatives. -- **Must** implement the `penalized_loss` and `proximal_operator` methods. +- **Must** implement the `penalized_loss` and `get_proximal_operator` methods. - **Must** define a default solver and a tuple of allowed solvers. From 5d04de02d5ac3750aa52f152f0cdd606562c4206 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:02 -0400 Subject: [PATCH 128/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 7f4466c9..3d3d2219 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -151,14 +151,14 @@ def test_get_params(self): # passing params model = nmo.glm.GLM(solver_name="LBFGS", regularizer="UnRegularized") - expected_values = [ + expected_values = { model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - ] + } assert list(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values From eb046cb43dd86617bd66d46eae06be9579fb351b Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:09 -0400 Subject: [PATCH 129/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 3d3d2219..38715b39 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -160,8 +160,8 @@ def test_get_params(self): model.solver_name, } - assert list(model.get_params().keys()) == expected_keys - assert list(model.get_params().values()) == expected_values + assert set(model.get_params().keys()) == expected_keys + assert set(model.get_params().values()) == expected_values # changing regularizer model.regularizer = "Ridge" From ee539812e523375d437e5eeb4b3dfa9512844017 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:15 -0400 Subject: [PATCH 130/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 38715b39..d85442a4 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -166,14 +166,14 @@ def test_get_params(self): # changing regularizer model.regularizer = "Ridge" - expected_values = [ + expected_values = { model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - ] + } assert list(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values From e3057c09b3e43bf2cba970e5d672daa3aa2b44d7 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:23 -0400 Subject: [PATCH 131/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index d85442a4..2716e56d 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -125,14 +125,14 @@ def test_get_params(self): """ Test that get_params() contains expected values. """ - expected_keys = [ + expected_keys = { "observation_model__inverse_link_function", "observation_model", "regularizer", "regularizer_strength", "solver_kwargs", "solver_name", - ] + } model = nmo.glm.GLM() From 51f344b386a3982d1325141c1a8827854c5bbbaa Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:30 -0400 Subject: [PATCH 132/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 2716e56d..cde11b86 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -136,14 +136,14 @@ def test_get_params(self): model = nmo.glm.GLM() - expected_values = [ + expected_values = { model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - ] + } assert list(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values From 68b956a3093c55e7476855f5521526a91e6ab1ef Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:37 -0400 Subject: [PATCH 133/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index cde11b86..7f05b3af 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -145,8 +145,8 @@ def test_get_params(self): model.solver_name, } - assert list(model.get_params().keys()) == expected_keys - assert list(model.get_params().values()) == expected_values + assert set(model.get_params().keys()) == expected_keys + assert set(model.get_params().values()) == expected_values # passing params model = nmo.glm.GLM(solver_name="LBFGS", regularizer="UnRegularized") From 7524ea3c15579dab9a553b18f5e1563fcb1474c6 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:43 -0400 Subject: [PATCH 134/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 7f05b3af..15984ce4 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -181,14 +181,14 @@ def test_get_params(self): # changing solver model.solver_name = "ProximalGradient" - expected_values = [ + expected_values = { model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - ] + } assert list(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values From 09fb337a43847474f3de98e272600e16a60723fc Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Wed, 24 Jul 2024 16:53:49 -0400 Subject: [PATCH 135/225] Update tests/test_glm.py --- tests/test_glm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 15984ce4..0e71ce60 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -190,8 +190,8 @@ def test_get_params(self): model.solver_name, } - assert list(model.get_params().keys()) == expected_keys - assert list(model.get_params().values()) == expected_values + assert set(model.get_params().keys()) == expected_keys + assert set(model.get_params().values()) == expected_values ####################### # Test model.fit From 680027e93355e54ae43bb9a3f9e0dd4e6d991bed Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 24 Jul 2024 16:57:43 -0400 Subject: [PATCH 136/225] some minor tweaks --- CONTRIBUTING.md | 33 ++----------------------- docs/developers_notes/04-regularizer.md | 8 +++--- docs/developers_notes/06-glm.md | 3 +-- 3 files changed, 6 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dc39197..04451e83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,8 @@ Developers are encouraged to contribute to various areas of development. This co Feel free to work on any section of code that you believe you can improve. More importantly, remember to thoroughly test all your classes and functions, and to provide clear, detailed comments within your code. This not only aids others in using the library, but also facilitates future maintenance and further development. +For more detailed information about NeMoS modules, including design choices and implementation details, visit the [`For Developers`](https://nemos.readthedocs.io/en/latest/developers_notes/) section of the package documentation. + ## Contributing to the code ### Contribution workflow cycle @@ -130,33 +132,6 @@ The next section will talk about the style of your code and specific requirement - Longer, descriptive names are preferred (e.g., x is not an appropriate name for a variable), especially for anything user-facing, such as methods, attributes, or arguments. - Any public method or function must have a complete type-annotated docstring (see below for details). Hidden ones do not need to have complete docstrings, but they probably should. -#### Adding a regularization method - -All regularization method can be found in `/src/nemos/regularizer.py`. If you would like to add a new regularizer class to the `nemos` -library, it will need to do the following: - -1) Inherit from the base `Regularizer` base class -2) Have an `_allowed_solvers` attribute of type `List[str]` that gives the string names of compatible `jaxopt` solvers -3) Have a `_default_solver` attribute of type `str` that gives a string name of a default solver to be used when instantiating a model -with this type of regularizer if a user does not provide one -4) Have two methods implemented - - `get_proximal_operator()` that returns the proximal operator for the regularization method when using projected gradient descent - - `penalized_loss()` that wraps a given model's loss function and returns the augmented loss based on the regularization being applied - -#### Adding a new model - -A new model should be put into its own `.py` file. If you would like to add a new model to the `nemos` library, it will need to inherit from the -`BaseRegressor` class and implement all of its abstract methods. - -#### Adding a new basis - -All basis are in the `/src/nemos/basis.py` class. If you would like to add a new basis class, you: - -- **Must** inherit the abstract superclass `Basis` -- **Must** define the `__call__` and `_check_n_basis_min` methods with the expected input/output format, see [Code References](../../reference/nemos/basis/) for the specifics. -- **Should not** overwrite the `compute_features` and `evaluate_on_grid` methods inherited from `Basis`. -- **May** inherit any number of abstract intermediate classes (e.g., `SplineBasis`). - ### Testing To run all tests, run `pytest` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. @@ -220,7 +195,3 @@ mkdocs build ``` If the build fails, you will see line-specific errors that prompted the failure. - -### More On NeMoS Modules - -For more detailed information about NeMoS modules, including design choices and implementation details, visit the [`For Developers`](https://nemos.readthedocs.io/en/latest/developers_notes/) section of the package documentation. \ No newline at end of file diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index a5c1110b..e415b342 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -15,11 +15,9 @@ Abstract Class Regularizer | ├─ Concrete Class Ridge | -└─ Abstract Class ProximalGradientRegularizer - | - ├─ Concrete Class Lasso - | - └─ Concrete Class GroupLasso +├─ Concrete Class Lasso +| +└─ Concrete Class GroupLasso ``` !!! note diff --git a/docs/developers_notes/06-glm.md b/docs/developers_notes/06-glm.md index 24b849c6..f7ca8079 100644 --- a/docs/developers_notes/06-glm.md +++ b/docs/developers_notes/06-glm.md @@ -35,11 +35,10 @@ The `GLM` class provides a direct implementation of the GLM model and is designe ### Attributes -- **`regularizer`**: Refers to the optimization regularizer - an object of the [`nemos.regularizer.regularizer`](../04-regularizer/#the-abstract-class-regularizer) type. This is used to add a penalization to the GLM negative log-likelihood loss or to define a proximal operator for the `ProximalGradient` solver. - **`observation_model`**: Property that represents the GLM observation model, which is an object of the [`nemos.observation_models.Observations`](../03-observation_models/#the-abstract-class-observations) type. This model determines the log-likelihood and the emission probability mechanism for the `GLM`. - **`coef_`**: Stores the solution for spike basis coefficients as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. - **`intercept_`**: Stores the bias terms' solutions as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. -- *`dof_resid_`*: The degrees of freedom of the model's residual. this quantity is used to estimate the scale parameter, see below, and compute frequentist confidence intervals. +- **`dof_resid_`**: The degrees of freedom of the model's residual. this quantity is used to estimate the scale parameter, see below, and compute frequentist confidence intervals. - **`scale_`**: The scale parameter of the observation distribution, which togheter with the rate, uniquely specifies a disribution of the exponential family. Example: a 1D Gaussian is specified by the mean which is the rate, and the standard deviation, which is the scale. - **`solver_state_`**: Indicates the solver's state. For specific solver states, refer to the [`jaxopt` documentation](https://jaxopt.github.io/stable/index.html#). From 58b375dd152735e62b26e65a03f569e6d48db656 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 24 Jul 2024 17:04:23 -0400 Subject: [PATCH 137/225] use sets when possible --- tests/test_glm.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/test_glm.py b/tests/test_glm.py index 0e71ce60..e32de8f8 100644 --- a/tests/test_glm.py +++ b/tests/test_glm.py @@ -136,62 +136,62 @@ def test_get_params(self): model = nmo.glm.GLM() - expected_values = { + expected_values = [ model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - } + ] assert set(model.get_params().keys()) == expected_keys - assert set(model.get_params().values()) == expected_values + assert list(model.get_params().values()) == expected_values # passing params model = nmo.glm.GLM(solver_name="LBFGS", regularizer="UnRegularized") - expected_values = { + expected_values = [ model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - } + ] assert set(model.get_params().keys()) == expected_keys - assert set(model.get_params().values()) == expected_values + assert list(model.get_params().values()) == expected_values # changing regularizer model.regularizer = "Ridge" - expected_values = { + expected_values = [ model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - } + ] - assert list(model.get_params().keys()) == expected_keys + assert set(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values # changing solver model.solver_name = "ProximalGradient" - expected_values = { + expected_values = [ model.observation_model.inverse_link_function, model.observation_model, model.regularizer, model.regularizer_strength, model.solver_kwargs, model.solver_name, - } + ] assert set(model.get_params().keys()) == expected_keys - assert set(model.get_params().values()) == expected_values + assert list(model.get_params().values()) == expected_values ####################### # Test model.fit @@ -1532,7 +1532,7 @@ def test_get_params(self): """ Test that get_params() contains expected values. """ - expected_keys = [ + expected_keys = { "feature_mask", "observation_model__inverse_link_function", "observation_model", @@ -1540,7 +1540,7 @@ def test_get_params(self): "regularizer_strength", "solver_kwargs", "solver_name", - ] + } model = nmo.glm.PopulationGLM() @@ -1554,7 +1554,7 @@ def test_get_params(self): model.solver_name, ] - assert list(model.get_params().keys()) == expected_keys + assert set(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values # passing params @@ -1570,7 +1570,7 @@ def test_get_params(self): model.solver_name, ] - assert list(model.get_params().keys()) == expected_keys + assert set(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values # changing regularizer @@ -1586,7 +1586,7 @@ def test_get_params(self): model.solver_name, ] - assert list(model.get_params().keys()) == expected_keys + assert set(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values # changing solver @@ -1602,7 +1602,7 @@ def test_get_params(self): model.solver_name, ] - assert list(model.get_params().keys()) == expected_keys + assert set(model.get_params().keys()) == expected_keys assert list(model.get_params().values()) == expected_values @pytest.mark.parametrize( From 8004c0aef3fd49395983e009788e329e5daa389c Mon Sep 17 00:00:00 2001 From: Caitlin Date: Wed, 24 Jul 2024 17:10:41 -0400 Subject: [PATCH 138/225] small test typo --- tests/test_regularizer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_regularizer.py b/tests/test_regularizer.py index 00049e8a..821eca1f 100644 --- a/tests/test_regularizer.py +++ b/tests/test_regularizer.py @@ -16,7 +16,7 @@ @pytest.mark.parametrize( "reg_str, reg_type", [ - ("UnRegularized", nmo.regularizer.Regularizer), + ("UnRegularized", nmo.regularizer.UnRegularized), ("Ridge", nmo.regularizer.Ridge), ("Lasso", nmo.regularizer.Lasso), ("GroupLasso", nmo.regularizer.GroupLasso), @@ -1281,5 +1281,6 @@ def test_solver_combination(self, solver_name, poissonGLM_model_instantiation): def test_available_regularizer_match(): """Test matching of the two regularizer lists.""" - assert set(nmo._regularizer_builder.AVAILABLE_REGULARIZERS) == set(nmo.regularizer.__dir__()) - + assert set(nmo._regularizer_builder.AVAILABLE_REGULARIZERS) == set( + nmo.regularizer.__dir__() + ) From 33372fdcaff055cc7174bc724cc0e4d0e269de3d Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 10:55:37 -0400 Subject: [PATCH 139/225] modified scheme --- docs/developers_notes/06-glm.md | 2 +- docs/developers_notes/GLM_scheme.jpg | Bin 294441 -> 0 bytes docs/developers_notes/classes_nemos.png | Bin 0 -> 329624 bytes docs/developers_notes/classes_nemos.svg | 653 ++++++++++++++++++++++++ 4 files changed, 654 insertions(+), 1 deletion(-) delete mode 100644 docs/developers_notes/GLM_scheme.jpg create mode 100644 docs/developers_notes/classes_nemos.png create mode 100644 docs/developers_notes/classes_nemos.svg diff --git a/docs/developers_notes/06-glm.md b/docs/developers_notes/06-glm.md index 24b849c6..fdda5539 100644 --- a/docs/developers_notes/06-glm.md +++ b/docs/developers_notes/06-glm.md @@ -19,7 +19,7 @@ Instantiating a specific GLM simply requires providing an observation model (Gam
- +
Schematic of the module interactions.
diff --git a/docs/developers_notes/GLM_scheme.jpg b/docs/developers_notes/GLM_scheme.jpg deleted file mode 100644 index 712a98bab0bf8f09161e5b8052a51b248670a49f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 294441 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 XKugR?N6${f6E}Z^z5gTk*Mt8DgXBKz diff --git a/docs/developers_notes/classes_nemos.png b/docs/developers_notes/classes_nemos.png new file mode 100644 index 0000000000000000000000000000000000000000..357b61ed0caffe93824d8255d3c7a2857dcf942a GIT binary patch literal 329624 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 p5A_D)3;Q}+$CL##R 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 z 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�}lI% zoI9_x>!+dKZ$7^~I`T#>vr`Ecf)CcmeydPSpNIpLMz@;&FuakXo9RELg(CRYd?9b5 zD+xRi{CkHviWmCqXQ1>Ot}Q8fHFttkW{x9tmStQFUn7?^sI?0a0zYnlT8JC~^O$2< zLCBNy5X70^ds$uK)cpJ2&tf#CWCU6r2&Vg$Cti}~i7V4-0f0+4W-`6C&jq5hN?Q`w z5^vVk>O4lxqe5-evm)m`#2rlS67s|7k+|L@c^jYt{ zoJD3lUn$M00`>a0h-o0}9A!)Zk?lX`!4Og@{>L&CpzNd^wm7n}x)m)IipZl6K zDHzIK9|ZQyr-Yg$Zb3bP&Iw5sfib^74Ks!_Z!Q9x-5lehn^;-2$S*hbeM?bz4I{5R zE^Vz)qz&UR3FcRBDtS3E__H1l-~5#BcVSahyfoHgdpxInqR1{~(*jOO>y)UP-l<6o z!$s5gT*;7*DY}J~ERjyC4J@p#t4Yy(C)9$qpg8q`g9k(5o-O4mMLX$;cSY8ws|QDFmD+A2B3N4y3*0 z^gRg6nad?3@UI}!73M_OT_37HSa;dc79JiRYBd>oU+jEKtl_cqB`Nhd39cfq6Wmj_ zjc+W&b(mmS$wwM88h0JZy8eT|w=KG;I|sn?hJP7c(bZz@_gJHu>-p-~WlN>(ugF-P z+f7}XdH3!>)>x$~di1xPu!{wfFo=?2?d1Gj(!;TsmY=ngt1E&#bkLeVh0P9dllo+L z;A=J@(7Rw&3-2|QJ4|O*_lh9zOhNZ*srBKFFXQ>O1k3T+UMh-CJ~sz6H4^CSsDa*< z&J?v;YJi|1y zlq!tu)#BWb7e1cnt{->a{-(e%sY4#0jW7D`Ntp4$KmWu;>e<)P;Q^~9g0#8CL8W3E zn!u3}nYHgVk;K+k@aS&bNzsMXt(TZdX6}}CWg%;sEZS)8{D zhhy>21o>*|W@6uGC!j+Vp_)dkAn7!QaZmLFi&nU0#cq=hQ*z%i+V?l4lo&>ru7wik zWK~Q5oZlic87bY!YBe-$GIY~U4Ar{C+hdJ*n;#BKy;GKi&pQJ|tPG`}x3PB9?U_sU zib*7DwNTM)dRP64x>`mv$Ihtu$E#ZC{1Xoel!;g4B26Wgc$>Rm9tgV0xsZQTNbL!*&uX)%;*N6#9WS-QO!mIjG{ z?EU7mK)XMNT=01***4`Boq^hG!}O6UiNDo~S+C0=W?Ek35|gOha+UWR>HlBeb zUw*$wxvQbIfg&bTTkrXjSb6TA_38V_ADYT-)kh2z_U*RWsMaj5ob4X$riVa7K7Gzg zluHz@YqF)if;L1BFRw~N)I@s z2^gnnn%;DJk-vnD3@q8CRuiyN(G~8BHC%5xS945y)r5*es1-B%E83$7aO&ZE*3C5n zr-7NBWw)ElwV)o+Sqv|v=EX0U{ir+Sk$*_Yj#mrU)W6XWBXhP-19v))9lKS#hSKR$ zd~(;X99f4X&gOQI(;&Sy4(UyUhC8r1T_bgPDRHoh5sW9@^riCh z=W=@<<#Z}gtN~10S6<(Sdo<28tfn|NKu!!j9n)1)NNdCzH9B6f!vW5U^q)P|`q&zC zg1f3A1qC!HH~HOx8}N#tjkd!Z2xGS_v+W`&`Z(Jg6y_oUoz7t;(JE%U-VjfSMjToA z6^#f>ptR2OfR(eU{lx3Cc{4`UXmJNaFBffD?oTgRKJpRWDjr~v)r{yw_;*}g6tqJ@ z)yD$uBYN@=rBX;6!e0l^8s)D3?yIC(S{h(ve=2%-2txB5J<~eYI3d)?>iXyCW`)a?HSb5V4M6X+YF>^X{kh2?I&qrNOJ>2L#bDL z3yBr{Q%~tmgGNd|vLRc_U}#LnX0EAu z4VC{ST)TEn=AB9Ob~0}rR9?IooV|APo|)a^9#wM>R496!)Sl_?a1x>#VzU$VFWzMb zt)1~|fjr=_%Jnv?dmst9i9`Eob(lSq2Ni>T?-o{L611@H%$z^~rHanNwdoI`PKm3C zTq#^CkIwO?z*$b$%&^SbfW3OHZF>sEGBnxI=3!$HNAeNp1(JtyVa6G)FKZ9XVSy6M z=YyJ~^v7_58P_w-4ajd1ACIWYw111K(o93rF+)X<$Y$-yenfRPoVJqPoa~j7AVmbe z+vAo6`rPo0URdGVC3rQ~>0lITDU!2E+Nx5~x1-DGYV7@!{EeV%sT6(%i9qnEd60Xb zOLM*v-&{=5MjOAv?1h9sal-QdedItY8TlxbuW1DS%KRCJu$TqBe|WyG=CC|wQvnp^OR#91r6eV|Ct!!y-WIOuT3aHv!V4<5{;Y1}ch_D~m&D4Ogl z-O1*8LEDf6*(9R~`#^tAJj>uR` zZRaka()a#2r3sN?#$2>!QXV8S*@tMJ`D6h!?r|Fl2XY%!b0cl z9_aLo44cS)d4=Zx_1ZVR-#xH+tyCb6&$e136eE+-_K3BZ?bf!9z+TG5Xfmp%;*WMQ7O&N0UvR3eSuU=##={JGC33DVzi0u}nZP_5FVyydd{ss+l{sl(7eS_TyO>NJV|=3va}SfV zE`0ba?%D3OmcD`?rSml8m;STlNYlk3M>_H()rKbLesXY-nvae2%&{-68lK|wyJEWz z4x)<1#RV6(+rE*uzZx#Z7-7#nw*m^55umqZQB@x^wi-#`ln0!Yq65D(fqr)?6nJv_x)}``r(wM1M3lYG1*cxkSH4^9(CiJ6Hi3_vR zs*)9D>l31k+vX(hH29&W4wwzSqz$d{;Jb!azZSeCcMB(j)#&Sh==1uyufnX!DyQm* z^^l$n2G|3;sg|yhA7~NWi9h`W&2X(sbJI9O`t-m z@R9};lo{eT$$P6-%Z#IxZo5l4W5n(b_6PKbY`PR{Ykp;4A`h?w7#=y%&6^(`~|JBesQ@;(SC0ob zmfSfSXl+yaPj1EOq5(1L3DTH3`K*<8HT4gAZzD3@8PSYK7vT~XG*q8{nm(1@IntLI zD#A^-M4#HHM=kTV@=72iet7Q}vG*8kpj5(Nnz(Z%AggnmG-zID-BaeH>Wt9%QhVy8JD5)MX&cR3r%b=1 zVFq}T4&+$P5g<$DQwPRi0rR!+@4tVyF&!=mtUxBkT$zvu_P0MU=p_YHRx0UA9jw#* z1>1@pYd3JYE<-duz%VF0Q$Sm#w!#1qBOS4tj4#)rXx`AOwZDy03ZdLrwk!5Kr-Qn+ zGMgZMd0OFJ|3)((fL_S^r8iE$byqf#S1>$P>T9#EDNOaH^^Mjva?9{2l?q|Ga9ezz zBVIxq(0Ka){<%XEdIF(q<%n5k*qR8D0Ykt~=AfLk zdWIm1$NIgvHNx)eyKJJTJ4bNeEMTVv&hmc?Dk-B3zfcuWJssv!%WQ(~1=a4M`rZHh0LNvSL2GGqZMdmrHbD2S4GcxhSm4jJ zd(duQF&|0z{@)qC`;3!V;LnDNDPZP}bxUjSup{vEbLT11UtgcVoJn;Z<+3gvZ=Eo4 zr1q)J!V{zm2DXVXHgt>5_8gKVYDhzeN|mzCSVjWj<%&fpqD>L_%NU;r2(g9qvTvjN zx{H;)8H6%lgcL(=)QDV#a;cZiNtpwrQv_VR`+yVYJNGck#yYwd3PnZMkd8YW;{nq& z*?gSd=De|L0bo_IlCPU6>KvK*Aco;IShqMui87W;z=@?2?Q}@0Nsz5`yP4T+(FXq< zS~jwCUufIv?TcSASV;?H+kEQOP+mOXB8{2RQSr2+V0S6q->OzI5wN4)EuPjcsi0OO z&`^DGA>bmCfL0>FQ>moshgu`6;`7T%m7^3@mACoED(Be5-@O*jW2LS|QeonR!FAlZ zV3bM)sbWE`TK+PU_2ecl50wBses09!8%1rA{kCRxug-_f(-y18-o{q5pUFRP!4k`<(d|Nqy z3*%J<1VAji`7TUL(sEr8pE?n+eGFpnzu)vb!$W+e_@bpGwv(7~Ko$$}AQ3_{9B^`L zvlatPjUsrC5L}_rpkN}`uXpYoCpjXgEP?@so#|8!+&U;2Kl)%Ml0bP*wLx{BtOZB=GMed!oU z)Od!s?(SjVNw-eHEv0)vIuDdex>vPUXuUj-4uXN+DZ0U)3dccM+P}+pewl{Ei6i+=4R_3}b!#a+ zk8mhzHRqGxlufh$SsJAZq;*NQJ4AO{GhO5+AqaA{I_@M%Sc?x=?s{qL=h_HEtzuHw_ChFy$=mjjL0v{r%*t5oS zF=zo6&L|)q72Zc zT~a>eZNj9>gOg~flrA-Ws9c2?=;IN6PHkXTL$~<%hQc$4$h0q|QbqvwTegIZeKC>x zN>%CO;_Z6?MmFJaf{q>^Oh|GP@)Q#$WM%ny6RM# zaQbu)nTQ~VMkhs6{PXCyMkCf}*;JuLxn&wUsm^R1kh+ksS^CEMiF|;0DitFaWI_j#^>G^W`bx78QwZS4F;}Ez{@zrH ztn?N$Lcm{|43p~L$ZQ>HA$TlGip~72#4G+$^;XZ(heaS^@=(EvToB<1k2p`#K1N;p ztM);+=?_{R3I#=^)}~l=ovN=fGBWaEuB%EVgG*$9k^D8|8P!o$>Co*$RV}Y8Z{gB) zT7Vy{b$>fkp?gFs>|pru=*a&~Fs+tJVaC>mDx$ucWECums72(28VwbvM=icu^%BjV z?Oc|Otg5weru;aW{g1c_uH{4$|1TyHAslY!JMoYBF|+O;55otE>w)GSoHla1{GXU# zcg(-mDPAq^ab~seI`X@;0qJu|`!<4JWQ#!(`a|;F6OofmIlsh1)q2UZ-(@($?t#aa zQC!TYCofGyCAEXzH@1ma^F6vl*?cfCLgt+67-f$3j#xvlT~t9$`O|;haoW$KH_Zt9 zpA~-hEUD2*O`5cK_HT4AP`hg43`IP_Pn|U4!~YR>CSW<|-`~H@Scb8TeU~L$_DT{h z$WlTjds(7VSwcvn%#1CBWJ#8&Xdyc#(F}>mQYlL+WlM;m5mC?UeD5st|6kAZ+}Hp5 zU)K+F-{0kY&Uv5rd7pD!)RuG0r#5Y$ce!32oeL;Grb`jE*AarI&{ca9x=64oe6xiybOTA^>@?@27~t{&Z-3xK2;adv;Y@nh)e*%j=&?Uc6asE zH=+t_Cm2q!GwIm3-w}e0sn!TJU@%1$#m!|*LiZ`74s}LPnD7+>uEfg>A`<^j-cs>< zKgqb`IgY3Us_#rVeKl@tn+k6k+?U8-!TkCBRPwl^aZ_n?cYKN@WDNv2Az_lpp<)BY zF4VYRO2}Uyx}}Otl;QYCuU{t<$;UErnx{wN_N2T_gGaZorwL2=Y7I4J(Ejk%=q3qL z7X$6m-uc>jYu zsXHh?aJM$}+ZtT1k!Z@4fyF?U#OU;?1dj%+%0wYUg=RnIka$@>3R#6snkub`kQNO>2iQQp0M%&r7%t5W= zZ*)TPF@!$h@vjd@(AneZQof&c@X9 z$fq_K(}Ji>q@vUM*u5po$~M7F&FJ^`QvG~dfReAvSvL9*d$YYu<$Ll9(Q3;SxC%S5 zrpo9&=~%Cyh3$snYS@}tZ7$hN8)R1Yzq6iSJ92{Xc-;9IM=gVnZbaa%f zQj};Y`PNRnlOuESq2AYuCQUSR$W9yYKYe=9I`TPGxXbr`yzV5s%4F_!Hzg*Bz^1VH zkDm~l1vS;`(xoEXX}uL#mSq7LlXLZEZUdE3C`Hz{%+Zi7SNT%QV4i@sP^u@5BzsjJ z#Fo9EHAMn`SNgDo)|DwugA3UB6>z;z>%pef_AV>#(CW))0`q!7J(8L1{qoW&KQby> zM$*lw-L$QEo8UI0%aLyAzzGB^832S2*d|qf1=LC_wi;AH%O~%sQ){88SY zM$7L*bd4*zDLkgns$O0tZ?crxVZqgBOkxI=R38JIio%b^LGWv(^c@EtA2vzY+}TmDUAWSUPo^a#Jxa*lg^{}4|#K2gk|4?`aAH?&nANaRa2v6*gECT-#wp6 zqpb9U*H?N0V1{nfg)VIhlIsZttPIkp{Z>{$D5XLf!6QR?r3FVHLnt)d6~koJ=_sNE z>CFBzDe4TGgC}KGlgW!OL-0>|(|uCN(hsjCPiDeA$R$w(Ns^~MeMW>OAsE6Dm|wn2 zGVzjkd&`NeUMJ~XdGeKou-r*%sjG>-pT5rsL))U4$S04ExTy&Fsj*1;Tp#gg2xlpF zuQDlwFxZs5=ivdTjB!M6>1YX@`%ihnDDP%6j&8tG-GClmkA4k&2K>MbbQCjS7!%A( zw`*~MvhL~_U-Z@CXjPz;sZp;$)-s6vPL~JJ;;%f%%3=%_Pb~S&cl+`Zo<5BNONii8 z##TNk&gw7Yo%*Bql?R$)lue!z!+wkWr8%XO2kgz+q=2Laa+qi8D~D#fM(KD*33Y04 z>^1(s82mDctols`NnN8j`OdQ;D?jPKzcnad>M5`bj~xl|O(b{0{QF!vaptjLM%fa8 z2{tA?tPugC^jA+Qy`Nd*Q`V|&ABIF8YTk)QaX3xH;FJ_}bY$Az1^P!hqN@Q?KX{U` zPg&Qeetjio%jP=H93~Ut0OaX~zvl6yN$t1WTE7`d1KAo;hKqU21)zzx{55^Skb(< z_md~*ZRJhAkG;99I}5~i1ja)2eNOP%zXSH*b@{&vo>uds8)W;zs>cu4s%C8=yKD67 zo=n(_Ki`SnksDysPP|#Uv#;_(#>?TBx@uq`JEUY*a8P*N&({D~YH=Nxn6hQc$;zQm z`7+(UWJ;oh51xDv5O*eF_D*BvTReCbH&Y~j0s(DdO<3*9NWHBU9KrX?(KQo3Cw~5s zbN`sr&U21$=%fCIz04PyPO;^>qJCO?KjDJR3xDT@XYiArxQf7NE_V9vwW(@nD5vmF zzkYb>pY)u|6zBU$hP?UpeY%4$iCrM`>3OrIY95UwULLnkx{Isp0-fyPm31}Chg1qS zyU&dj?q-LZRiz+Z@90&yo4=dTVlzkhMxa=!I^`Jnh7gYe&(9?i|EdExBp)>s_g3^m- zWjF7o7HF=NFt1PVl(vp&L}Va`RXg*CINJGNd65)48x>eILPk};*gcyfg4}@sR7-s> z@mfmE?iDs_2&^&>`u4A}{ujGn@qGB#Ml@!wlhq=ktYpB)#4YiG_GlrLY3GcWJr)HP z`OAyRjY31p-9(_!==1Ize2g*}q2jH8?@e6p8F#MYGej1?7T=KcyvnTOES<|pETU8( zQ|a+WQ`VfgVGxyj6KB;Yzm9jwAPZ)mppR}QzYQ%rd)qw@u(C%5nRVj!^Y6oEl4Aa~ zboK@r#nB2!?SYhLKk5vbyOJ_Q=Fs}dBmzX9jl~UlF}rsop!V>Z4SmkgYa=T@@@O_z zoC8DY?GYnck%UqC-heN=ETsG1&yPFi=ajw5$!heID#3UmeddA(N}#Y#CQ@7PUtbz} zWY|viAS>Vhnv*^7UBG#;nI{+aVrvxGOa%H>=G1>nXHmD;XxZiFg^Ot0wzg48?(atL zJ$M5NJcgN!Rv=36Ei{d?^9g<1a8(CO!|mdCi$wZQCWWjZCR>9k4` z{R2wdIcRZW61t)7t|73cuOq_r4N_-4|1o85hgpL=CpXs-x>0KTe1x%(B*CYH6TaXr zdXkJT?Ay8pmo{jCGP9rdL1jsU> ziq{ES5;@#)eg1FMB^DmkXET6@7sGfrfRECoCMP>Onw@)B4W+;Z83r*ks4SLF(hhQo zP%dlfEb1Fg1@12N)33Mck&w&s$+z)E%X%!x?Kdyabb8eqkz_w&gV!y7NL!o0&ZKkX zX3m$dA{UJu_b=R;>n*1XwBPljr&P4$=g`^J`gFfoB$t=DltwbK*ioa1Gx<5Unz2ZJ zFL#f7`Om(a4^Xy>SB<87PdGMHQ<(*2R2(rX5gXbYT52o@NLG5F*GP2KFeV`98<*u9 z+tU?z?`1yE`z;xW%qMMuH16-!laBzyS-2oM(5GifGH+KNMp@q(ETR1PY{zst9#&Rw z_m$T!6!FkJbb3il?CewOQK34N-53(}$VFtg%5+?#Rqmf;5vX$r8Yrxy|+-c6wx1gY?unN9K!i^DP9LxHu1 zJWp#ejPkOg^2tYL;ppqPxCS@Va#Ua*Z$P4Mcyyd?zuGtnOBjA!(v&&-|+EB|~~oQr4| zY30gmmt%7pEf9OiXUPO}87;8U>!BbCsc=b?t1S(BRbs0XfG*F)+ybYsBJ_Y?%YJj( zHE+n@V*~sFIFG;9=69Ys*Xxf-Lc_GGVzR|*+_=U9b?ni#7AH@ktK=BcyIGk!NPC(| zo#gZLp~hOtBWjkr0m3g+#W&WSAxW5QKe^UI+shJ!D%#!katfuWU$-d7Yt!E{A|YV( z$d9))Jf(i$t#(tRaALbOtqNbUPiu|{on(GZP_3p?(hw{4Vg_+m-rS^DP|jY3lX84T zorb$PnwXgbbeG*XSLa?OS$ccrljmCvsRajD4`V;hD#?H=7z|NkMu3cNX-fXJn3+t1 z%;W!=Qu23W${i63<_BiHzn@8c%mFu$%I5l&pEMi zFO#EW=+&&WJF}%$$LR2k!UfIq4)`DoOp^JIP<%nl7!TTC#A2QoMhdKEgoF%>xNeL2 z<7u4Nc`tl<_ig!!#m{bAjVbt)v^TkOo9dd^SM^-^XU{(eR9*4sFS<24O*Lul`}4?d zv0A^>?&e8i<&>P@eVzhFGe*?HLR zNb@@pCd-Unizl9Oy|A>XTDG3~ke<&7$3P z>($#2WnYM}@XUk{)G=H^J&T-R5fX~qF?5?bAjc-WgZQ(1tJ6DIi+$#Lne;#&VNBnS z01&zI$*r!dqw3k5@{x~u5d(pm8Muejpffw}MVx!Ve%7R1X#r$Y|0*j!dnw)2~Wqj%+6ppVF3xc0hNR?BR^k@11%EZGQ{q|K;-O=TESS;t1#=w%8Am zOaZl3Yu3Rvbj543tbP2-B5d*Z;X517x_5SA6dQY%-Z_Qo_FMRrR5vm=H+O#K&ArA) znBAWqal(5p$_|mnhn2rBDmo&|1^y{6Uiji19Kv&wy^|A0+t`HC@Y;8wk=)E|o~quV zV;|z`=62et%tnxR#3*dNOah9Fbv;$~@06%a!q_3dpU5$UpE=1=-(r-5g-AD7mHz(6 zA6xzX_p{eR#*_ecW$yCwzkcxpwW^iP%<5mi~G> zK0|HKd04T%)qK>bOyly8qYMdUXPE>FfzyqzAC8b?6@5E3Z@zu^E{#5cBfS0sTxah# zAH8GjwFfSN0u6zvnzHZ}YZT^tYA4*mlY1YHoxIs`6?@iQsRKTVW|-Kmf)hj6H<`A; z(9rPVn`yiLp-=qQ9K3P93rgdlqpko8+%V?jDUf6**R)AVZ^<$ z&$C#O{SaKyj1}sa}X!L6~mqImN#Q9LWd z3r>dNzr7t&f8k2CHX`;fMA&m;!aEE<0v4`A```QAJu`Rjv|1GvWR=S8%e9o0Q?WM| zbXD1IxJpiOMrNBe>N-A{!0${E^M3r&Gie)Y0`Tup{s?9!g>A=?yDZJkeUq9s)r>IT zsx_x?pKjeo1ofGF{>4Ol`~9qT<&H=jp*?8g#EG%4Y~GEW9Dg>=sr1vQ7m!}3 zCY+ddvFhrZ-L$k)yBs+;Kk*roAor3(|6IC!xmgC2$UE`U92WCRa_`@QbEx)6)Fwswbao^3XQr-?Cc`)ar9AO7;?tYt_bn(4a* zM~*%GqF`o2&nr3GkgT1`It9yi_VamPi=jR33s>x*+5{Ip=J{t7m3M7(xa*5f2g1U{ zUybw14jEc!^-ZmfhAB1u-y>^?eK}HWp*a^(^WZTDCnr}xvOL|AqO0!ZGBI^)-cs7b zl}|$a4<=Lo)6YMTq0*06ioo$`b?ur?eeu=(rJ&ndO|>tjRPVBpXCdm zakCMgZl|3?PM);6ckiB_D@*t0>Nov+8$)jH+AemiaYZ=TJ2zRx7z}lx@0ESWCTeU- z^jR{W*vH~&Y$wrDbO;%5l4AdtE8@%0a=NJ&Jh-Iq(fGS_^q;3Q$j$AjnVHJtR}DFV9(xlKOuE^|STa3z z(H%D1cxUi#pFFkMdXXG3*^Ywd?zLuGH}>@C(Qnbq@LMg%MA}#}O)S}N@kuNDkN-S@ zd5pB|Fph|{{$M+vL&DFr&!=DT_73U|qBD`(>ESf!Fd{ zeYl6&v17+-?=D90G`X078|2D&)ZQ)fFB?;eat*;Iq;dh1*bLJ)p1!gU2mUTzqn20j zZ+tW-93IQUhzE!E1y=Hj`siW6bB-IaOWz7?z0U3wqMZzA1Xum5k0y@E{^R|Hx0hz9 zu712)(?30Ic;lc#s_D|RkoIi2HG`C5U5Qomo*#m<Wh<|Bfw0$s!J}!i3mTScpY-%|hUoP)y zkECC==w$6m)>)susvu%*gNDwIvPx)n^!BY=otX(Va&Hean&x9)JnPk~muzKw_>9n^ z)vsS^q$@ym4ExhZf0Q3`s@1Ch-J^~OU^{63Reb$<+)Q`(lPs_vVg5Ckya@n=UTpUL zO+}49=qJ9{$43?SLe^Z3DXCPF{2b7#mpRH1;O*PDP&CIU=q!9;L-N#j;c|H> zqv1k#u6&9))FRYA=J?%6Hc86mbfz@TSwV?&UqTc`8I?N-+zFg90AUZy0!e>ap=M z*x>%5zV6RAk)j(OyipD|9W!yFXW}yiEqVI3(i`UZnp#9@h7K-U(XFL^x9W*?EtHkDXqA z)dIZTpHd83=;u!ByehW_TkG{QuO%l3Y}@8&-OP5bUyHu>C3d<72KPgC<=W*lO1#9h z5e&*0G3=*=pj+1&>tlo9AmZ-b@o_G7S{j5BQ6ghv?xsXsXRGSe1rPW1sJV6X#*G^} zGEVQVT_+Qf8=1T?dEgSfcPMS?xyNr}5xto)1msA5w5w93N_v^7x?a2qobFAAb{~t1u)iY&>$4BXhXhpCTcm? z+f2uCebDH@iYl?=emls~AYp;e9-V8q-E z>$bK%fMtwBoCDTZe|!$^He8zV)PO6^TA`RGVc;O3qkzEyN284T^_xU6w#L?iw!{vF zqpsyjEobJydJCw#4+2qcd+&PDbl||>>(#G+2N*cm)U;FU*16WRhYedpKG)l;#Er)5 zF{K}hGAWWqvFs`4-R7 zQ%@j`-XZdsx$gfwHmjfdd397ronaUB_8_AiUQkdF$$v_5Ac}`OlZbHt=(IoZG5i1i zd*JTA3x6?IX4iue-g;9nfA=^Es`1);vnV4T(ufQ%>o1?P_6U^z=(iYTV+v z7fWIHBkOk@8Dn^jonpZp@+j{oOe-B}c)ai$>@zx~RkLI*tFq z_t(MJ_wCy!x%&vo-EHejCR?keLHg@Yh6egi7OknncX~ac@Qj5Aam2 zH8eEXO?1mKg*Mdv3~;&{Y8sPqFYWqoEnCjUtExXH4hb`=s`i-R8xlH`i5v-sfm8Ong@c2qy+1A-1^y3&bLBnrcy?#BNZbj+Ss#3M8 z3w996XprGI7AZ;kQDGHQ;kX{oFDGIY!?`EBoYLylX&AxX?V2Ii5%aHRhYrm$Le;+E zkM-+o1{LBYC1B#X)Sra3^&NBwP6;jL8R~}x2PZ@ujMFpx^Upt@v)qmKUz_ZT*|0ng ztV%9XbiC$kRx|XBYxTw(U2Q;6P@#W6;_9sE=F6;(4aMI=G=+e(hWoQfDhg^-rKt*`SZLl<`>C3|>#^DW$!4>!HE-WsZERVK(Q1>@WNt^UC*R5OE z(&z-vUrI4G3;kLdMpEFo@#x(V@pn%@p?B7D2NwBJDUD+D)yGsPx`D!P1-=IPg|Z{! z@1xO}IC3u^VZXL##gU8_KfgVwo+&jgHP zThrb;M6s+;&5CEP8r7w1S6#0g>u_?f)g}TGTXe@R#_g{si|c3@+0ycKI&L}K#3%Is zdH=K+Vw^DLoX72m1&H(y6ONi@AW9xYSe*aDFQ5|+y7ud$AAC*r!Vx3>bdR!%&0%v`DdKQ&Vt%GP@OcZxg=k;!D$BbAFr`H_1d%D zbBa?kTKAq^Im3kQ@DmH1SFs$U#^LQ9F;$9+C!+NF_MJdBH1EY8@U4&_t7>&5|GGtq zde_Xwau_Gm)A#hR|q46FWg~7whxg!BAnL_^Gw-YQG1p|SH-uCE{U(Wc$W62x` zRp>_7BKr z1cjV=`_5g=60B~xYIODGCl)*#d1l5hzx;BG!V`mkhVIs%_xI6kms2m0UO?AvXwbOv zGyj^TgORt+b|YS;4yt1~Lx0J>n3(Y(xZv~UWlKlw=|2GwwP?3@-lgg|2O&;Y34+EM zXVP7EgkISf#5~KHGs9Vq84gWJRu>Srm^oLd5#nA1Ld&PpD1`q#NIf}{7#TV4$e!PS z|GoPN&0lKPw1GS7@#@#=)vZ8WV^AIPpUcLM8g=TNarON@gssI^%OGw!6kF|zSJ%ItPM}RVe{ru2(Y@q^>O2OAka>6--iK9&d$!`c6&;V%7w)^(PjBOvs{C^ z&6-6raOB?8lPADh1u*>Ls@7h0>vr|Zm7a&z`ub`E(bb<|?W0cG0!Ne}?{krPd#-V= zX{o8Y(=XTjSQ4|miKVIOpDa^rSN!V1IAYzZVi4MkoC_DI_8cY(jC=l?Sp6K1SkG1K z{uR2g+?l>HYSrkvyhk~r0#cb-7>ankRqxsN-$$N;Hx}D~fD6T&npxZ0?gFaJedovj zGNdv!&dZ1&|8leZ^QhQTJcP?TTQjZBovnrpS!GuzEubR@15M>vGBV8K0HGr>G4bln zn;$h6c5So{!O>x0a`UpdvUtcm7xD*m{0A;BHexmWPAu_Gn&3m1#VohAJjnC_~A!9u}j$N<*rVf4V;@ zZ3fE|W-b1LvL^lDD2;9?CkB3CEZg>RyY=5Jh14In?A`fztl}hj+WhAA%N~$Zyl0+N z7x)M2dCjzEtnuHnrI_){W|F*7#0%pwDZFR;m7a@Uj$d8&@yliSb$G?w3UH4`YE*mg ztM#~oSNBc~#Tvvz?T3Ht)VA$F{C*~+{BEN@{0~B7L6}+H*ShHHzOXiRQ=ZSGAu?MK= z4nzTWn|f%A`=z}2LS%yTJod&@*g1m6{^ILTyaf={@=uiNL*evR7xi2IIfj&X>bO_T zBMT$dGw@29%X;Xlhwb9VQVf|lGY4Mn6of=rXy|iT(6nkAUXoNiJ>C`g8!%`?XGlrl z)h#5NhcO!2asT4hzCTh75rqhq;l2Vh`@*Ta~ROL?~xgJ&7t;!O5 zKcOKe&ht7o>z+0%j3%AMdE?L9)oRky1g+V^l5_Zqc$!JsSiP1(aFIxpzGfHDKwWla z+8>(<7sN*6z=uoWp5D{@9)a6}^VlIrN1g|}Gshh^hMHq_YBcP36z_aho#me$iAOFu z=i`|HKj*`q@{HgCKT`u6i8$fN=+g2@_#6qr1(aLP6b=`WU@>1_fC7NNyba78nRn;# zq|E3zwjp7U){wO0bWf*oa1Kh%A) z`S8R>BF}#qb~BP-z3*z33qq{ZU)Zp53g~P`#FXH3fSlqvaOOnm`kYpt8az`vzn6hjF}!r4A;QIGP>rC;V68EcS6~~`pRU4Q!|AQ7QM>jc7jN=50qwFhlNO>|awIv; zaUc3XBAc0+35uasvO8JsiR9@4DsCI&^3Mxmdm~#gH#3`7qe*vW%sAgYbQ3VH{=Df9 zY&#iygFkpJj8Se4>VG?pz0V(5GSPXNKRH-yTX8o73WssrBAJ zw?sC4gduF}(x;CEr$}xG(&!*@NyhV2HkcUujHNF_P<3U(#7Uv*jH5Torc=O1PQW-x zH%NxXkmQZijU}$konkIqgevtYERpe9!^i`DI%;U_tx?`r9zsE;<1@Ox zw#6;~^xj!wIykArfq@R&c~s*s{_v!*m)y=`A8xgN{pA-+6c&M zwVp8}RB}bMKjDC6*x)|Be~q4@8`y?b2j6lgpX7u+j;k=_AfX2L3uhRUMX#FhP+zEV zZ#Nn~a^zUj$f5t(9GPr~*=*8;c!>hywz^trfz%nOo}J5kSLN46!Uyiuaq_EXE#ESh z&gPol=NB&4xyZ1&Q}&59HZ;{4Qnzkh-whk?;PHEu8oddMiP1mfxMDUyls9nvM+&-e z$b~-JLUC?CT$l~nq`U>rcj57y)RiCYGrb@sP0a|r?D6qkTc!N;;Tq{Zg4%0#H*ee! z1|XQx>(7zkbm#yB;&zyPIRZDN=ZaT*{LkJcbhU(V?K5fa6RbZ6?V-ehPh>;0Ua#A@ zaj|o4{SG=hqX|roBLJ#q&OCGFv-K~6cr90UzfBd^S1GmRYNH_({0B%oc+IFz?b>5e z85;(?7)O0&nNS(g&Ut6#<%VTh_@uHLW+h&v`dYT-7Sc>O~`Iae}zj_&9cD^^f2 z%bhJOgsQs^G^o`6d~Z_DDOjOfehm6d0H?n$rLT>SD@r8>Yj}S?_OO^zdb56u4%Mqy zFM3BOIh@^?))@@lHCvbu1Fz^iE_c{87|d+d$()rjWXD+%eR{Ge_sFM@WZ5r0hoA2k5D@U3o`!9j zaE{MWjJz3Ed&e`pKS}li4{2q&vu{u}#Rdu6gS5HD58;!}L_ey0oFujoh2JWttpNd2 z#uf6Nz!u7cLh8aJQi&^FcAWrdG&K6T;q-Lc%j*91;FS8ssuHd>8R0ndR`3d`aki5L z)@L|5p(`y$&dx#}6eUP?@K2BN(Ha$Q{dE8H%` zY$WRc`!VxE3NOm*HIvs{44zyj8HHUxJh>E`Xju-&umubA1S4Q7&gxDlpBjGik9=)y z?OD-7hYk%2s9bS{mJ_%_YDGT%6nA2~pA(MdJ&C@66VmF|t#`MZBn;epC{PcU?cBSO z=;E_ZmOh$5303k_DVZRSSpA{s?y*78%;qCUW}r#W+JtN*;S{y%S>w0t-04|RZbWGb z@ry3*6XtLVf&3Hl|Al3rmz;TgVtm&ZsbT9T-B|Sn))|0q@l6F3-W8L}HIp!>+Rk#a zRY2kKuRlS)_a6mST%MV2spc$%d6%t^LlwWHPi`NvTUaz$;=JY!XP z7|6Wf-=&u42@w0@-N$8ZE zWC|)X$!%?WyQI5)y7^~e68qmzLi25C(Kj`sb4z;qEK$Vp>T7EJgzJ1$nb1E97Zh}` zoku*R=f!4HbXPt;i>0W1@Z%D7(5+mpnh1b=H!5lC?2M;RpKA5)JqGmXuLd5{N2pnDwYG<5J_W~>*mr+V+T^7S#NSFTzm zWWZVIM&0?3hsi3veP5*L%S#u2gBs-0{x2zvYpn$LKWB_jDrPq0*my06{PtU#_P-e( z_*YU=($T&xD=yz{*|CFxZTk1m`cqSNuAx-x6T+w=$@m+p$K#2*N0(ywYdME6Jtxgv zA{nkB+(r?L zqWqj;XqDb*fV*jp*ewbFjvY-BFJE?HRP857z%C2hQM$6Jx-K!Vpvh#O-~Yf6f6vrL z>zS4CYpY5z>gU&uTZtg<7t$6wmV$-t^5q088JE><^11OMHh^X~umAI|)bq=hviE!d zmnEe5#nLmg?(L7Zi<6%|>+yDfbLP6L=58g|;#sxt*s)9h{1FYM|nr1t97I8C23YG5;lX3~=m*BXh{i=K{@d6!X?r0EdpMql2K-rawR zHBWCeF51hhWOLPq`8;gN^#R~#pKP1dB6|ulKspdR>FJFnb9Emy5ZK&}GaZ3icqSVA zNGMyz_0$%=15?=J_w*#R)~KOxJZY3v%J)B{@w$to(fFN5y^Q!?20vJ`GKjjO+*UMDzoqDq)shx$Rw2dT*I9T2( z<_0w`3-X~f^!VXajV98Hw`eBAYDyOt9zOE|A+pS4?Km#Cc6zV0(>$*)i zZi?5qf5K_w!FI7D2&|&o8kO|M6Yb)v9}{mX!ZJbR-XY23#EExNq)GUI5bnkml*d=f z=Af3@-ee!Fe6`?)(?M`bB(WV%*%=D)02|JvzS*YD0A_&1MmKM&2}6~gR=0KMcI_@c zJ&TNh)cCu0B)q1SES2o;wP?1`z+EIkJGrcMkw_~4c{}fO(le%oiM^02NK?8Pv`;c1 zf3#p_e}8`)x{)3-EQ8GYHpttsWr{BGc#of|=_pBYEIGSBf=>9RT2d)Uc&K1HRZXeV z%69`pCZ3*E@TLrXh$|}C7q>%_7MB0(fD-B9>xXf@8IlbC93f%>fyIE{PJQ}hrrqu@ z?Y`rW(Hh7Ux zK{BN0pJ=^21iS0`#pVEOngC=s(|Wbezgkl?T;JqwHjT(RDK|>Kue>&Oxu9A}X{jp{ zVhO!l(8uS!DT7>ep-1dRR_qDk`uk`Ch4yH}E~C)mi?uc=$IK^`TF3tk@TD#Kd)#{J zNtm^`S}7q;K};(BeK^C37na9=144|F7{LvVCW_2ous|`39o;CVV1OdgJ{V$CgTKbr zJkN}%De1ru(YaIrqgWn*QAd3X9Y2?2C!M5IlK5~3u+!>9R*Ep9of=!7WySC^1~nP3TM4__wLyzMAmX$ zs%Ln%wj7u=wAQpJ9u?MPQn?a|(ar8`dPI+ZQFIA%6FoStOtGh) zX$;N_@b_=a{frvgYYeiS{w*t&ZxaQHp(q73D}KNyIS=o+syFCw-qPAC5mFHv|9~$W z!4Qqqn0fiTJU!+K*Lo*%E8}SPQ!G}j$nhgAdbl7^thU&_L7IC}_QSNgZ|43l+PKlb zx;i}pI!)ClFDn!vEzdwgFQm#}kG_z|iqx2-=_HxB`s>XsEYg{Zgg!5?pwpaZL=?!` zr3O_snNLulR9nwKk7-FM)**@%#U%?RgP+{ zP|p>xlrXCYK~(e>Pp)0mgItH-jw_Ls38Sc-OCnT`!U`R}hl$u*t-wry}wr$%6O`3d{ zCz?38y?<`iut9^nit7A?$jsoFt^jg^$&y}>YK)WWM7B3O*HH|cN-#mVkSEYSVYe`s zJg_Lzf<1nD+UUnDIZ1{se=c{_4P4R3RZNqQ&xc-~b^S#5_pagx?s8kFrYF?~gIkjZ zJ^DhslNAI3D164LiHc8(8O~9b;@(5LF!}a+prJi}J~_QUt!!{%{nr;zG<}rL&pj?y zs(1lD=B;&gZ7A>AsO3>yI7A_EDhk{zatXzR7DqrtK@}$kJnSqwEn4e-(_{nNGd(*)>i3Me7iOtWhcEL z%6WU8b??y^DM0Ga+YcrZY^#37)u{XR$@UmTwd!B@1_x<(iNNLV@mt}o^T{FLQjgHD z_t0FCramk3tF&XS65)dVT>#P|wgM$$;~^hf4V{Ypf5sq1v31*F?j1K)@0M|EL3vT-P{|B)Z)d%h0`$IQwQ;faNk zdaE*hxEdFVVM4|ulzsK43|kFn^lCGp*~q;{uN0HLLl5y}a_i?im*n>E-u(vtuOCEa zi34y<`FLoAT+4WQuIH)9g-V>K+Kx=nWN5xYLIlKv!72=3{L}J%?Kna=bl{f@3r&#EhdYJ z0Qv_vJ{7csZcon@W16a`MmL30>nP?1B*RBh1-ik!8fa)70hx=CM;wUB;40Osbs<^7 z&A)YTfDleX&^nRZkE$@IgwmJU@ehu!;`KU;yd2`KHY|hqRjCYt6k29ap==49p*%eh zbh$`k5CZl{o7;@D*gv$@l1m|d(t3aK%(ni<|S3xM_=e_Y}Vi7C@^0Xi3%WS_=u_?6vZf#^FvA6Jp=d6XXo=6j63DW z+)%PB1mbuJ9IWbslEJFp{3Hhs9g7ej+|TL{Y?d%Vno9Dp`Hss%C60E*0n&gm27}Q1 z@|q%`d|GAnPYHUYrnMSGflxMLEis-CIUzwFM zcK&oC2~xiRCd-XviH4t-*Y(ma7wET=9;5>gt4;s+^R6F$pgVEj0D?-90eSpX@e>8N zLsp|`mwn1tsZT!o!v5$D_s`5dLS`KfaiRo?6H4dh?tbIokPl=v*3g)nYj+1iDhgwE_<#xz6sPE zGcorFu+o_viq+5l?U&mG4J0X|=zV_7^%fOn&sL<8icFte#~!4VNh7~5Z2*L#zURtC zpNSjkz5KHWg}K|xo#-F~J50GrikNfaF4`Z1qn&BlY@OIgzf{hn zFNm{*6_9TQR-IKK8an0xiNSClXWwU$)@|Rj+LvceuKbQEb-`jPfidnof7;SiYcGgY z=>k!Ie?r{Ltk}`0$n;z>py@oQuXmB$8!CKu&Uri0LV0<$DF4Kyp?dN<_=o7o7RVu- zdIW(DN6P}$;^8+F$Z*&1ivCj}@=Hrf!s+4v-p6E9m4~IH^yM5>Kf;6q)GB83b;#Ej zzu1@fHKV7hz{|;3443AB0iO^ZL7dv`6G_Z27xzDxQWl*lysGrBe(kj}aX1Pe5mYE` zSFl@R(cAO;_{n{~Or>RbcJ{dmtf=y*XRt-~g+;F>O12?5ixwB99&oo^>((Y3%ieD; zqdnhX*$@Dc?T%lf?WraHHv{`SJa8XL)IT3S^r6gS>!%z<)gr2ek;sICg??lbRPCY* z5PLMDhm!~uVhD?sCsv0HPiQk~D0T5BHrVa@C3@=rs#n@8mi0j136>?@a(r4)l4&kA zcY(Mxr$Hmam=S$CBZ5SDqnwiU6fyWXGrJHond=VMV;rkjqI)ScRa5i1V20D z#~*-8spSek;7a{4#z2l?`QG@Y8>1t8zPntmsafxJc$IUbkG3@F{(GlBbxrFIHtk$P zr|4Wrbiv76Z(prRb6%}K*=J(!h&#R^ewMCX?nbuWzoy3TKeY}1-Dlu$zg4ah8~)2r z^GDq%+F14?y^(*5D}6hzxaribeCEIJUVaHnn9yaK_lK%J?fzj;P(SkFdZ;DWF-=%A z{sOAG7!dq)nHj?5i-WD(s4TZ>FfBvIKgbs81*gGE&cjTPa|h#|HrET_()svxlg64r zo#dJ(9Xd;GprJkMb!5Cv6Wy7&roGz8mS#wZrcC{a$ep1wSt7F;-jXT|re1BnyO1n- zXT8nqkpyUC=Jm-z{WvIzRF7nOFe`1wnL>26>(i$Xgy+rzWn!l)^fpa~=7wd5=Pv6q z(x4zzH>^_0q`rYdrM58iGcGHUX%n(IrmT2Md2i|iA))^=Iw36vIaj_6Oq0HqwKDpC zuvsOaKZs33&wY9kCELg~{@GxbTytwCXm#3$>vgJ5DUYUD0(QShfyh zg5oX@)HL$dQU`xuc5cZ#*;qqrqd^)wV!%LE?o`lm3wnwCaLvoVoMJTCjGlFsFSVRZ z>tWXgz#%>7VL!ReOy*RYB+C|cINj-xEtE{RG^EZU} zwq7>*ns!oGluX80jSXzmv7@4-{eJH$i_gE??LmL_bU05dyin)|W8_q_A;+GHamVnr z4d4aKCgRzr!?5-u^%$j|aN(o1|B&_|`0#ejejMSfsy$K;`Y?prhpaR7G0Y7xmv;v>assUT;eV7B$i-E*fU@P3!2~lv(wWe(w^_Xn?!h!;ST0qs@TXG0| z*6Yhq7#XlgnK@CWdA~1%a>%FI!4Hb=GSJfhcQ^v|Q!A!PB-ded$<>M16+YTIVu(`M|Pu|i%d7b&x$}|v7 z`y#?0LSY^wK_(!3z+^(_c=lb(eJLN4JC;1^e*O9hO!{^KHqD?q*TI@3QWTU?$-_O% zk@sxm#Sfv&w}rl2YphuP4Z-ufL|wmyxkwk*&Qw*4ozkao8NoYhM|)K(g4ag6xOx

R)^0{3op+u3!SSO)nNLh24;~3cO!)<+RvLM8(i+#+6MTA09529d~A)AHJOl|O}}Z5BgYr{H>;$v`z( zIZq*QmG<7YZ!@WAT$F`ppVN%LjQ&hJoFD5jdf`yiU)_3vbZoqMi?z)peDRP1us{6+ zvmc)AeR|Hrodr!)CVfe3TXQfP8JFI>X%ByI_Nqg@wKBnmaYS9-A_vxHGRD~kVPD_; zbs0x*Zn#{w-C^+9V6gi=#qVuekBlr4-qaqe!Lc~u)6uev#|xR8bN(1{@%yvCKAxrG z5lIik{xnq%?<8gio73wzsw+DMsAc)dV)6axP9nY#5tGo?Oh+v9c5eBT6yF%#(ILos+TWc z%KS-rh_l4{60F(~X6WQ=CbMnHZ`#K*%BrkW*RJa+J8my@R{MW`B`~0P?n9W9HN3k6 zookugo3Gtc)%s4^JL7U48CdINP*vq@4F!$yT!b)g$fSr3vi<5UGHP9*@aFU~0$&Kg z=H}KtCHz<=%hsLA1GNrSl z9o7Ej%qKE!yLp%kb+@;&+ND#cPHSi&4(rr@Mn=PNwsr5_+p~ngmH}xRLavh5OKv(h zeDv7BeVFlWCg_RLXozFW-Qh;-V-kQMPow;;M*eE!&(owc)$G+=P75{T>opk2_xJvyAxJlPbNN8widQ*q0!P?f2{ITBzn86J)E)XelnN-wL zONr{f&>=%`q8!7H-(EqmyUffB)Ipv%Y*0O)$4VGYI!Qy2%ruo5wDVZt+%eu8L?z0uQor$82DdG8om%0^v;UX_nXx%UF3<>r3FIht& zKA>+JvL{{5O2hWZn@DIQ6Vhp0Yz8eneTGxV@>gkeugm)DUh^Mc=|tHKbff zN_jnxNrJ<_T|t0!%*(%({dn}8^)L9s%Td;8HlFM;P0+))ccDL)PP{Ye`Qm3}PzP3} zeiW9qiH{rp6H~o`xwjEJ(8%XOlUa}NU$Sl1gVWvA`0obVYfgarUQGIO>JyQ`O=pPV zZLAYe1yrV6VvWRDL51I*MecZ6G{x<87Ni$`T^tj>J<^%jE;|`$SGrxYvAksC-Pi?A z)}?~ochRROW4kWxUYuR9br1hM!OEF~{FaH^dX#UUB)`k`$D?RuG@r6h3nwu;Pv*{Y zo)UTiLi#$~pe3e#Ei+Z^jmndscICzmKMs2$16=FLGU{d|Un5+;2H{I|ss1MVYid`o z(%X&1^{C8D2iSB`J1K31b1mOeozqY26>ME{b<}eIf?qbxZ*1$ff^QGKhrn;RVK$XL zSv!{+P_^|Eb^Np3+S@r*^Z%REsNeX|Af)x%KW;lpO~;s*rM_~LDoxm~B`=ng$bEb% zm9~vwc?qgo`s!>)r5YMU(KLzo$I+ME1?}p^`F~gMcuCw8$*eDy+wpqO9KdOKt!<1uuzq5smd-JFoVPUd*_VxL+#N5j`&&}8T+U`YN@<4;!D!zLMi zpbLk0jDYPkJsWb#p1&r9VIcCD6(?!pTahCu09g!$xsP?=OF`fo0B;BVJ1C$ z58$>`sgGqT2{RG;5hsSFq6FO9R25uL&Sjtue9!V`{;C#%FD;~DLw~H-vwk^iWi=jw zq@jo(Jj>Y~w1y#+og`Q=EPvFuldlzbJh>)t@ixzaL=2RhbFsH?<+E5VXGU}Dp0LD`J%^nDqORtP;Jaw)?!>wR;< zCJwz|zO0&@Zc%7QD|mR+SlxpY#~tz1zea@pa#3s7M2 z`!+R|-`A4qb-h^TwWI%%`Mc1Wn-$Hyf3kD*g|bhw{Xv!>f49k*@`_B`TD@4W{j=pA z_3sbhHrIv?-+mc>pvk>^SG`@?pZ-`x(z1h-u9EK$&{v!Ct@TDH2Zuz~t#2YdY&b{^ zD%dv?3S0g;O`0v8b}s~*Oj-WXApd2{*+iUQB!&}+rd5#3?HZzZSnc+>8 zmpR6keSr3)ehg~)jqGWZr#CS~rl3li_}_qND@OwoecM-q2-jD>W9W*ktgI#m?lWaW z9c*5}`!djS2<)pEl?R2W@3(W&DuZRiZM&Z@$pajOk5_-eX7TGx%91q`R)`xyKWt~t zC{^9HNUaD!{j4_~b&&+~K)Z@}lN572xh5}+Tq)RGIiUKh5R(qy1VnGi*Dgl zH0Xlpe8c1rxCJyPvdNAVB3lFMQ)WfN%i*6J;c7m*hjAl9BjV6v+zRggsV(aZauu^DH}6sorHfI-G9UR@=7r zKyul|qj#O@4wW6+DlAp=UeaS-X74ERbAQ@y!Vb1Sc6#QQpe8&=4w2}S!?{Z2e z!iUewId3>pMO6Ora zCXiIRLblBHn$4k>@zGA5zFo5?$+tZ=8FoV^$vT_=Z24AULH(@0!gg}owvbH)Ux?d- zv8%Q?I`H0>-~TM=gV2fH4#@*Hl4C48#=+3-Si#5mje**5Q9CV#3v#P@?dsLFJOlCn zbqZHYYu83(Q*vO>;X=63$y{h*n~hRspU2#r&|Vc&DBQZj%vkc%UsKyW<;ni$v}cK< zMUY-Mnn&NtRv7>6Dab0tGKFDa-*OM@O+Ab0M{Nb2-gV*A3yguA4)a=2y@GpaaYFc6 zd~Uae2N<2OdCQh92TWBBm5ArFW(|zhSID2Y(>2=6(|Gf+OXNl&ICPjrmrK^5v3CCvP0ggd9T03<=`+UFP-df`JROcVDXM5Cty>Or+@JlU)_gRNS8Om2or?Ip1FptMFGM9jA?(&Qw4$A~= z4*q7m_wXAs2QBOd*tiR`I5FPVy%iJdG@1&d@BzKpGbGDF$xc~HAk3HWZDg~<2mF0; zw5@Fmg01nP_`OWAXsA&^bLl^oei`n|e0MAyKj*FWLiM+9XBe*}A~GSGr7fG3=qyyv zJmyv>lAw;>)N_ff_{c-LV|?f#(8WMj#?=`4)hY@R15Q+jOhy|JvO@hN3s9L6F-J(o zA#MV3@JP>c3Ba663C8vy%Fe^a8)4VR`_FWt=jRfPgH{Ezta3X<$BiG|EZYszKtWWu zrtPd(>FYONtH>|dE6|unZvJ0>4^ESBJ}tX$7;>=8^qB{cMiy)z3jSskb7UGkClG^+ zf1fG_*G)0F+LOpO?S)0g6_#Y2ISP7sbz9J$F#IlIboBu?xQ4#Sr09I|Iy?=Qu&c%=lX23I%=T008PCH&Tc zY{rK*^pt6od~)x~ObOX{5uwA}$Mu9!-S$6K+`Lx0ELHh`r63LW6v2$H2c(R7-dA`v z)Nk`w?y0Z-a{bd5dyNch)1v(1Iw{ajxo9P~F(Xc>< zHw@v(3A*GJExoivc(p`H$}c_Zy94$`*}n0^*J7i+uLrifDBIP!HNrRl*2fD zSrNAZG&dMmz9qXH)m`5Hb=`d{V26J;gFn|0P1?tAUr!h3C0tx!s;l~&RYUjUrndK8 zz$U6Np2H{gbI>E6OU*!b_X%NI@Wr%kUCE#rWt0RZcrE?|_&IS<9m9b4#kG^5zZ7?I zt)lbIC*&fdFKl1D#T7`NC`Wj;a)g~1GuI@EjW)6|N!Isth`&G{@SIGoW()nELdKdeRMd6r%oMZLZH%&V;H%B%v)oiBQ z31{b@t-Oq7K z;s9ms!icr1RCOpar()AgHb}{#lp+adBRjsIForC9+JInR9HTwauJf>orxAVkM-8`P zy=}$#-SB3zS-FHBOi~8udwF@=m6x~mh9pgt{@49CX@ZN9wGE-I?LNwEC#bwBGjuD? zndOprfPlj44(+18`LM5iB4<;!z=wW70|slUS>f1c^4G8M z#K`Bdt939#J#1M@5$LxiK#`Tf?&el$(Bn_n7WOQ4f_fel0d+`qv2BhQ5W#I_3XFR1a zoHj4{J$tf%1zjpC8syuR=}c(aZ}ID{3lBiN$Q?9Q-+{C6975`nHd#jGq*~hAzI;`2 z;U=c00*664P@bS1?SubhKM}0*gt-UJ#r+XqXBe1A>|yz&RK;}E6%>`~N-(a1wG{&+ zbYyq2tf0d*wUvC&C**G^N1c}YED*#9>GSO|YtIBL3j=HIgdn0hZ$@5J#f2^bjM|=~ zI1uP()g5O3koE*MZ1VA?)^c-eUs)*5>AKQYF|f#!WUB46lizHY-6#bWfW>5$+)!An zBVa{a)`S&Q|9R7ZiU$G3NvYgn-?S56POJ%bRWuG5)7-*t9W5$K?gHP2z z_(ZA#vd6_DDg3uH4;M{RUUr35ZnDQ^cm?WEiWotWAbEgf@;+M6m^|r66OEI>kd>XZ zw0vckm%0YS-!?!SK9^MCaC)E+Js={1q3;yH7;Y`dqU1>r@4$j44PTsoFlQZK?eO&w zY-)=S+KBi2ew9yx1qzvL)rLtIp8vpfn{e2!OJtCHD1ok?4TcdZK!tVrn9Ho>Z>FqO zM{OnRK^oe!^2j1-*jlLrLL%`pg0o~j2A`+Co`+sZ>-vj&N#>_Rl(-Iq0j`P9?RII_uYI;M%q2A`83l*~xGWxMvw zR{2eWDr0G87$jRw2XR}`s=dt>ddZa1Me6_I>O8=D?%VetnVD%vMs|^gRYoKvs}dP0 zl$ljFDIzKjqs**`3XzerQc?+JXNM%K2obIS>&kuq?&tVBj_3FMo|{|W@8|P=U*kN_ z>%2U=W`y#7Qe_RXa2BEkpmx0C?n$kyWxaGRY^#H8<`97XWD)SfuP^Y6Tbel$(mh+fwnHdCjhLT2Az zc^iaGs*6Kl96|)AqDatYh1Nu2Ul7l=k}MS7!aVrkN7?g7(b`^MbIDM1Y2xp1Db*Kw z%(EmWzAPTNQ^ z$X*Ujmxr%L*5c4Z78I5)ylYuy19&+*aR!~b{5-gDZT4kU?a5SyHATY!E;c0y0(Nhy^y~U@tV_f!OmpWB8O>{GpnHYc{&ZG{8o{2Ly`)fIwf%6 zecoKmt(ZT=1ha&W64+%a<{|W}?so@kTo*ElGki&vT|~Pi zZt|g~=K+noAFd98ucpWnFq{_LqfN+$k>^(_rnlQK*};CLv=i88>)$uJCt^2uY?ZF- zEPKA?L#LtSt~sedaZTYs2Fkh=kus!um#2GM@(Aq;su`I;lPo{Vuc7kZfpL|KkLI3{ zE5JXKr=H(`f$kPR*%AZb7(oeMoLg0qyDwo-8pd$Fst*NhHl}6Q8A_K1NYI)cpN_1! zr?0Pn%%Y(Btw$}#4ztsuBhr>`RpIDT-{|-$M=f zkl{e_X0N8ngrJa!CGMO{iwdl1W}HE!|Yk^u9~o~M_gP}8L#-f;isw~ zg92IS?*@qeFx9z0SP@xr7RP%NVEN{`Gs}zLNDc4FJkG||ldXK=sb-kftm_VKtMko& zvXnvi*8Lf3C=X^$!xcoKo8ccQ=%G;TNBzyP6NOlWs~|ZHk=*i6GP6BFpOn8FC*DeB z=VQW+k{?S;PI~JQpr&WZcCXkAA7fmKPPDV1UtMFhUD%W>U5w3TdR2LLl_dy99w5#p z!CnPkO6NM~K^bH%u5QfNxICiQu#ZE>Oh`7?Svv^%mVc{?_d|selSSQKkUxmMcJB{| zw;AhHV`i^*p7Y4hBn|2sgx+2p zXw3?h(m^7-xo;F~jW0t+#*#B<&g8M#${*>F@l0H+)p#P-gY-I0T2XoHFJ$O#_b6!@ z77Ig|ZifUt;8Bf8AGnvuR?xOma14p0LTn*0G385`@d}rpc&RlfI_T$ohR+QCeLhr< z0HLS7;LG&Jfb7)wvbOU+km)~vq1~=t3!E;I1F7bs`x4TpSDxAyQvkr%Ip;atkQBw8 zNBwOd1`OT><+ZEHf=`?$d3pa-|9S$c9%*ZD=5Lu&)otiO&gAx0OY!sXJmT+{88CPy z=XH2LCq`ssleB!HZ~uHDKI;%cJo)*Z1;io*FQA-KixOf~Mrv*w@J6rC{`+O3>H)p^ z(=^d74Oxc~R>uu+x;fTXC% ztcIwwpe^;zhgWlppqw-;LrLdCJq;_w8pWKx)h_ho0`@ud16|$~|7o!2_nZUk-l0U+x)+0UP&%1^Ft%j z*PNAuCrrduqt|@)VE$=oauctnqW1edx7#U*&jW8xKnGMS&PL$J6#>FPQEro1T_Y;E zW{{O{_HY2!SM4aDB4_`mhRVv|^s_A2TPU`hO>&mA`pcU!jWwP3gI zyYH>$Jr}c)DU~xoU2KVZ=?xHXOlYq#&qMca$NsAYfLn|6{Qa$z)ezJE7FsRnwH&#M zhDgJdrGTNdnqk&q;|HXDvbI{A+(iL^$>C7>tRsij&^w>h#khQzg<|M@Mqwoi$@XI1d-qufsyh)~pE;8Lb1Oxx-< z^S7*)DoSvcDiWvC+0ow#&mr=sMe0>mP*uLaH@Qo%ouE;9y#5g@Ww%tZb5)7+^sMUg z0a@ISg%N`-Q>mMrrzSmG{EQx?F91_9a<4Wod0mgGo)edGy|si!XWx1oF~vv;_5O24 z2;k}(K{{DP=La4bUlT+X7~2TaCCPNDBxV|pmduHp9`8bb9mr^ED(>rL@-_r7lr5sy z%yK|9%7cy;&B&DRDbl3Pi^NCV**gujU(~Ja1a=gqlGg8i(>jk(rF!=%c2MF|TKdbo z>ery{S|9^DIKbLG7nXF|g_-b{gf)b6XX@ynOL_ z4!WM9d7#nepP%0l0S?0SwLE9fL#UUfnZXPjAghU=Y+%oTh!tLxn z+s-$fG}ZZivUBa&4tJYe|2FB8EY8U3SA|G0G&sCOOmy+qBh~B5H6=@ z;VRYEiN%P^aWj82)4Xg#`8D(ML9z@|r28ArDoY(E1*|~69$y465x4`sDNyJ>k>7p6 zgt~g;JMl(m>2}Zw`S8M$M0d2@{;NZ=)U>GB)R{6eJ?=^MmOiy`x*fHxpgTCcd)(O* zEoxojX#nEFbWWkLSP~*0Q_SU4M_!* zVch!QzO8?NYp)AwE_G)c@nEc$23#V~3L?5)e<*%Xvaa0=7qfJ9bKir^C*}Vx^Xydh zrK%!nJf+{%FAkxEe;xN-uQ^r0(0HO;NqpDjrxvP^Q=y(|myvN5(`y(o8d?^Vy?IcD z=|tH`UqO-H#=MHyt&#NS;YrcbY#e*GW^Zj|IOhJ?m*zxf^<}sLE>4BMcA@L^>5-ka z>{|+3NB~KrBGHiPfQwn^9)w_!MmvEj=@60$;$b>|&FlNG>q6lAS03$}9;hV|Q} z`(4($I!s7gBc|czC z(TWw{8|e}a0wg?qI>|(XIR#xSWn~z{0Gwp|(l{i!yHsouut6m0aU^(22tun#0l1vR z5b}OVN#;j^Ouhdc^$q~3^v?T4eQdP+>njQHZ@yF(eW{$W07%k#hhycRVI_21e$-C- zf=bW2f8@4uzCZ|lfzhfL?-JtPkp1$XKEp%pN70M20}EVA-6V0L_(DVexy?+Xvrup% zqvT}XQtmhKmrZ7w+wjP$GViJd;Y**+-gr5Q^dRF9Qf2XUfpbLgcBL}#W_Gsuo9qN3 z^X0NY0#-KtfD8c!jHJk(;b~OO`7edQ6N#yOVFQ^jk?caJT76ql9>&2kl+u98L`~$Cv9INH(NSEu z^<~qx6()nIc_YAg>2V{zpcH=NtKsDLFLLIwzo47UEPL^B)508_v_z5t8QQ&GSX zQydnWDXcN?{3*x*i2C0o`cJ`g50Cton?Tl>s4UB^^0GeD7i39Z@q*{7lW@nZmQ#aj z1|gZ3u^tzWcy&dD5xENS@p+rvrl){@6F?4SDZ1!{q$bzz{)M-g2N+ekr@1#7tOdY_ zE30No7c7-!stG{DE|fd&C^}pIcvnT_Thf zQn1V0DCR19<(1O@<}utABvc&D?$*q6T!o!I=S?gx4)G2Y(DeC&hoxtvw33l2S!DNe z7f6jvmw0$o{z^|^#zyL?lMNaQQetPibxD-@B!O|xvU*-ZJ3W#>9UYgvl-C5rbo0`{ zLaIo!&&{{%>I%lK*?0NZUi)sX0+<-eGmju&ITuvj%ub;9O~aYi{mys6bSgh3RqD%G zCMx>#!8?W(P_BkZBBs32LQ^;FLLfd2;nI!Tjn_O`G@o_%WlKtuh^iWSV&L1aCCnzx-W?+IW^TXvc=*;6ccNY9_`q-Uq34SIg#d@2&&5DD+~W-?aziD{PXim zZU_gh(`qNM(>Q#QLk%WRY%l?fI^~L7s>oX2E(_h=*P=`eX7Re7=J;C>5!JsA59;Q0 zY0dfb=c68j#i!5-xh3*0T8Sp=k?TZ7MMaiuYjHd*m=pBqw}5Ltp0XZYSrFiL3l|bz z-BqhrVdQqmF49{2#xIV`!o`bIDb3#oKYQ}zHCU-q$1!_$@0OZ4$}UnCs~_3oxt?h! zO)k_mprms>(_jkLgHv>Nyqwh9KO{u-9Sf0a&7H@>2&D#E{gJr&G64=Tqrq9+=C;Y- zf9=MNbw`EwZZqff8|XA;O-1DLHsQ1FxR^F_mvL>< zr2X=s@5RM8E~MqhU(5u{Iuq+|;cLWBG+ta?+PsCpSYew&>^r+E94PV8(2Xc}WjZ5b znQQ6Rq$KknB_(lO-4lg|Lqd134b~7>rEQ$DTM#r&0?9Sm~ zD&1$_tHnD`q2Mw^)7}Ex=xDFdC%fA8>d>LX*I&QV`P#*$#V)o5p(K5hNvBiCeyv)u zLcE=#YX;Nopdzcu-*9i*jE@s8ERMcc@cz@Mwvg7o4SDy6Rj}FcF+wS7ma*P1YRc<- zy8oOBOS7$^p|#-EFM0m9vo9xZhdLEH9w$yr!$x(&HqYC)Z@cqt%Rvy8I{vO%rBimX9@X&G0Pra>}qdY^7{l z@bSZkeRJ>4td}(DwopKI@{HH`SK-osCi&e(4wG=-c|jfSHHfw z-oUqlPj-OTn%_*y^ldnLU*Cb0BBzzK4%L17KFjDP?6Gc99t zn~aQ%J2vAie-8rcVWfByELA;qL6dKwyDMP7Laz$NmJJ9nDeOf{{qSGguYJB2ftY~PFa0$r@l(7Ue@yNP66N=>~p&6mseJAqWp9rX)84_P`K`0*V5AWI(4p*cco|0 zkNhuRLb%zd53E%R0%D*%(8PgFJ#b!5tj_$Gm;5ouPhoFsUgBavMc|vkbVHWc-KNdl zFd|cRcNHaNLW>o^d{h7?T= z4GoUlr%s?jKI^Va1gQQsaVaeN+w~tPe z_;lmOu&K9*JFl@(va7o4eQbXfH{Ud#QNF<+DsSDoWr$^xuMtjWn&w`8u|o^bGmo1z zaMJkko%ZfOz1V1jffTtpJ;yHlg`NJW*m=Nj*SVIp$`D6)6X(Fkj~`!6ORGZ&kjr=2 z?)AxW*HThWcX-GK6}>v;#G^)K|G} zJbl!?FZh_)4Q`P*A<3&>%C&2?^KcHDO5e4(YDYKc4Lf%1c-~_^eTv@bY2sb`D)l_P ztq6n@;~k=GYH-N;h>ee*H~~P{2nnJpB- z#n0ySWA-0&pFQi7vscHyYYNWetqJOj=7j_Yry-9T`>|}%({R^)!)A^B>C;+IPl@w; z`&@HN1|6mgp z`5qI}GeOD37WPF>0iw=snkFchh!^9cy^i8E_|>eI0=FxLx^k+k>w1n>pY)UfrfM2% z7&u-<0{J$!ZsW#{ao*8a&8epw@f=QUtiAH7#6(jNf;#}dElfQu?FX(S#1O_)bhM+c zURYh$lZ4R}Yd*uQjEs5&jh+2hUwFUw(1Z42)}SiJV zF?(0b{+@zny0vNkkkpZRIlXS{(*9=K^g?Rz?zWtEHP*7Quux6YY~K73rKMS>AE|q) zk>WqEc1sD(M&NFM#b-(O!Va#6yH%J?IKG6&C_F=GZ!31GrCt47Wu2A@mWiDoA1L9fnV z0Bw|>si>%oeY*D`b*@r_b^Mc~i}&w>s62V9pQ))9#3!|9GO8?(plw#|DEJNc^4AJ&1xu#ozrRIBRTdT&#zDq% z(#)9yyYQ$eok2OOd+!V#6C|G@H2hf*(L4MXU^NV{6$YXOy0#tthd@TG15Kf<_Paq z8)JP(*#}}gO7BwucrQSX$QhkMV7|Yy6!7z7sq=j9K-xd#viN=F#>6gb}f} zG0W^y(4yypmw_N%?;gkAm{w=N^Vp?NAGbF;88tC<%kLLMy`=V_L(9->Zz>A~|fgU%>|WF#cy|gD4j=0sMhyu|sAiyrh+34gU*);r6%EY7d+YIWU-rZ)ZECVP8O!o~(( zaDi=4DKwt2t5+A3%c+68c8O>9(L;e`rI!Dbm39$N37LkNXVFfw5Tp+H+WjX=JGpd^ z6Wm?m^8ohmJk~^BpsuzIySBTludMi9r#iPxe(~^q*DhV|@W*_Orgf;XPPB9*-cwYFfzk;tfNWm`E&38} z8hUzq+J7`RGt-9V$z0ku$vIEqbhmi8XGrDCsr^=ma)|FejgLhLfV(QGiT1Ujl-N{h0}A~LWVhBSUjxgF(JCgxKkr| zK8Ne`dp^0Vg_Gb;RR{K0{}b5NS)Zsy*Kr+5l@aYAK#=w8`3g_5xm)w#!GpQ!-={@< zfg85`^_#foTiB|@>L|1APKX<~3pj|Y> zyG9(j=jzU%*|~eSo@3WXvXc0R(j`oQ;7lA1V=19Z!NKiYc0BL)9AfhnQ8%ot!jaM} zs`vC*_cdR$8akJ``T2F)7SE}fuHL=3scHS#s1IMhG*KWkZPQO_m+1z$l}ui&m4~Rd z>jj%JJ9rIishV71F(;&tzja^*ff~}R2J109X*~QOe+s>Uk3;W&NVZFnkQQf+Ab*5Wd+8f60Tr0oHFRQi0ktxXIp9DX9 z{P+OvxX-rIF=NKO{`fHfYnXx?Aw^AE_8mZ35j+o#{ayE-DHpqX8XSGmuD^zg$~sE6 zbK_LicBi&oTGM29F!y8bym>wDIpAKMqSF^wEqj9*3J1PV9;dXMI=Bscho_=qn~sa# zp>LWLz1IMWJmyYfjYnohjP)+J7FTncDsRzzO0%EpQ%5fYI%E^LPW?1?HB2Qonq^q^ zAcv*nwL@*yKtZX&#$ii9XQV%?ExX^KkLYn>6T1pHGkopnj zy1l08Cp^#VYtyJ-TafejN%;BM--(|$3FSh!2o`6%+y(IHyUS0Ij=dq#es?s=_MbU> z*8cH69_XibGvaVmj65}Y%(joimc2~!4k0W>ASA?PC;pYI6K$G-qi-s#cu5agF0lecv!)H=>FE@X20^@k7sT)Z%cwGP(_As&65V(dFheQI{I z?KaZL`^R8FN@u;1(%XjS zvg)V4L5HzmA@KV)L$#EZ)f~IbX#EfRVYkIzZ}5~UtyvGX2J_?wXYsND-?z`{N>ZA#vTe~f zj)Y1B?Woohu6QO!^)@jvIlCa&>E@HD?95E1ef##Ia5h~w<4y!j-W1p?CG!O{;T}87 z!+h|jR!k-s)6dfr_w9>4)7Kso0+I@Yf7f|hV~flU3=DGOsoxJ# z(CYmf`E=2*qRDb%PMtd^o>U9Lqad0S5)#1h>H!DP6xZfiZ_bJKS|Y6{JbrR-{?GUc z6QcV$&Cf}A{CK>$guXCWR{r4Rq+#ru*=5$L_;~fe5|BWO92@%ZL*`I z(k%l!Iz>wWH7NaWW?>dLA3RW{v2LnX_AP&SE!;&-4^vAl1^yvxHq`rwgcpoq3DEap$2aZ+@)9y#(lN#H?C@SDZ@Nvg)K(*`Q$ zFT-2gYWQ#!wND_Y`E$Fc-?`J29+*@p+PM&%gY^TkENe`d9s98g0M4}KN!vqp2wgFD`wy?+WNbWh#)J-Py-d}NjBsDL z@G3tyjx&Ylr+LVk!~CzAlLnUzM@6kXxxD>hL&rQnzxP$&CjG59z2X7i?#9)Q-nn-1 zHGI_;D_7AINwekFV?R=tJfyrH=}b{NO_2Q#(aX!55Q);m*8ro-GIP4L+@16Fh90sM4E(#{pv)h%2!bT~xHB4m zigD14oxZQNS?^nQ^*42$dRm}Rw5$X7PATIDpd2??IAV? zEy=)-(jK*2wrpANK7Dv^#?zJf9>?sHx;bA-rRqPULtkJ90DQvU`S1i*8h`!ya|nl3 z=%d7VpqV!m5et_s%Rcn^{c!$h3IulTj`R38HF&!Z9w+$b^ZkCDyZPmx?{^z1Rhw-$ zEU!O`vQ>o6zNuw9&>m(T7AznDl=&)0=j_&@^x*8_$`3}(tPyD)b}w_eWTY6D3F!ju z9yB~c3D;>-{PF58xb(+&vl{;dtB91tq+B|Iw)A~^bEe&3gOWc4@toBG7ARI2;GCofQ3rd)rHIH6K7)#4!S)jKlCtbIUC=;XXDpf{0K|* zsxaf`x7XZ}!{ab+pe<~{pAt9UIGS;V%4cC#dij4m4zEDXqoOm3wLV>ESk!(&?xG#- z^07}K5n>t&5AE!!sCukMjmo;vmE=Q*85AOg@!?n+-1=$DmoI-Wg>lON0Jz$Zum{4u zq(0N)8>32L241scY%qGg?z@LV+Ns8K3TG|uJMVOSd>T@dJ6N$IQ0kbzljn7Shy9p^ zW3OU{(z9KsPstC4;jdq#Mh(Zf?oD1KdAB0c!-)@!?+K2C?VTbZ$BNBb`R^Ea#}j1j zlyDpFt8X z9NBpgoPFZ0^-33bmnnDeDg)vx2fnzxsuzs&p}RTuH2SC{-UhQQb^GAnJdDsUha)4W zKO6aRIE~W#r{|7UAC(pf4eR|I$JLSfGzYSam_~IAubb4M>bHPAeO*vMt+9S?A+@PcS6_inX6kISj)WN3wiq<1w!_Mfg`Bpv zXU;4N_HEd+XU}@|>rZVns6X5IlxV{FWSs*)(ORLIk18%capDA!VyIgg823U_WKhU( zOUnx`^XCT>xjn~ZY!(al-X3r30W;Tj0Yt_nr73yveQT3H6G6iwl@xAqw9xzw#wM;HJ5qQ{6-yPMVFn{VmBA|0Er%fcz_u3@fU9pM}QPdu#2-_N>* zYlW$}_;?;Eg)oj-Stx4vkuGn8?FMz`3!<5n4@ z4R@+2Un8E~*W%*4nWfGAYe^52veRd25bQ*iNv~e%)QxulcrT4=KXmERr4bFZF}TOE z)keMh=Cn)8i)#YwM&xm1PspPIUmQsNJNSB?TD4x``J~-^6)?WX`NGeig}n+Yk;3EuN(kOI1g4iFEN@P1l6(z+6(yYxlT*&|yoSua=`d}_~py0>p zYuM$ILEYg~T0GMmcf6j1gM-dYOA9Ux(#ICby`h>opmVy0q^zud3@=-~&9T@pg0D}R ztETn|G;+-vg|0I#oi}0X&{@ryBE7Jkg>NQNZt!(#lKNce2N^rLKR3p;?uW$|6j+wLZc!i?(_7*;ACvPnTYitvKiYeEaz`Yj@vwq*{hW09_QR9+p>@ zo3c1zGhHcu%dc3_7xg$S7#?|gZs8mBJ>T}#x%zQ!!9(-eII4WP2B>fvoh{(efwXqpf4>4jr* zI=b~3#Y{C%H|@pq=Lr@aX9u1>_GS-z8#-@$zPj{l4RF;5A2s<{>n~rvJTAR-cS2&~ zRG6WNfayqogMfn$0O!sL`kikupMe}-CW|Moma=O0poI$!k+!I&A?j-W=TG?7;g*NH zIGIZ6Ex*prg&pj@=w0CZ8)cyNgONIRx0~|n?b~*mNGV(FRW(5dqJcG#zQWb}_s8UW z(J&jrU>|q7tX-#09h_j>V}x})y89CA4%_mVD6N=Ji?y8EI<}ChqrHvKv@v3;&Ibcl zhpC{_jWE%R*-{Mi*g(Ow=qo>GJq4G3U$O0(xZkR-0{T&;g(eD7%c@V9u$3SR#2>3G zU1lY$`eo3fMT@V%N6F(!m{d3(b`5w|7YArHZ|=sk>gQeQ_-WfcNN5!3W+#G+d)&>* zNk{)MxNBNd4UM|q-rjc`^m_XiG!v2Y{^xO21e;#I*vgZz+JU=iw`5KAQ!2{QLw4uX z|Cfwf-3XnA90k9Ckt0X?9y@j{>S0q~%EeX`2X>k&En5Zy*BtA&T^IGH1fFTrbmjJd z`-lDEAFaK3u@5h)5ugy$MJKPS8!HR|yK_3axqxu37GKbP?cQ%${1vYY3%hEHTYp@^ ze2hD|p_x7J)>oiRu#K>+?^^)YU4MR!ld(u{c>QfUcdm~VL4B;%5KGGxj*>45g*LpN zlVi3>uLEaiXWwQWKOB-R#iGu=P6ySdCb>e&MUL=r;4d7p)362ty}@v5J$}Bt16T!d z)envDCMqbm!=wKsk*SlZaqT~oNw=`u?%R0&?4Th-hCC`1Y47{&UqmhY<^3SsIciE` ziF3nu1t>-8g4@-ni~qA&4raYZjRwQte=W`%np{e|e~`Gy?+pM9wr}a67nXASb|dN= z2hCpW3P4q2dFt!Hu@smI9{EZNQ<&l0Y#rw8(zbp3+EVxf*|ynYb&9A*-RNdEHi)$( zV;;xO8tQX$?T8N707X>OF0J}CYUq>Ao5@B9o~#yr3*Wf0u5_Jo?lfCX6gp8M$M@u% zQ2R|20=$ek(i{0;AQ@y%my_AhM-7=VQdB@)G$GQhMGXKA3tsp)vqrl24nnNFh6h!0 z0fs>-vU$PMSsBl_U}itj@DbmI;>71megDv~@bKgIm)N_$-T202X=Ev(6I}?vkFTum zgR$)KA7$mFwW)jnG?|U6j+~960Ms;^R?DlZVp;NI`jxir+SP(Tu@3oO^r8>=rfrd| zcmjz%xY(JeZ~0WcY17sTH$8~ui$fccG_L($()gn%Rt5zwQteXXy~q4Ns+0)Ks9O!# zw+#$SuXs)vY@lw^*SoR>bTS0uI?$9%m;ARm&fbFu3}6-n%u?^f*`53L1rwd~fP)p* z@B$7YgVVk}ruq5~(^AvYEIMq9E-6=cMIP8JJhff-WLqn6?1=^Us~9BFrYE-71g=^~ z-#UkQvr`jjx%06v;nI|z+9?l`K84nx_km)!=d=f><9D4ub7mc6^N_Nt-ISD5hzTq9 z)=~bjnfD95eF}}4uRA}@XWO>#oDF&(_;BW0!Y6OwzNi7?4DsWyI9s zgb|adO+amu6QJ!yHyF{Oi(d~mXp6-A(@!9?P^ZCTI&4e6d9wjTXSP|-)qfTTWkfn1jjS@sT@b241F~dk^K1P6Uc8OK=-%7z-G<-+yW|7^&aThfaAEZ&N z*UF<3ox=L;pUZc}FUZ|D_UjndAGUBIh};DrNxl-?$~>6p=l0_QePNOdq}_z`V-rDl zR^M*dtp2!I$NE@KB|pC9vw=_lsr8x9<1+^Q_CI~`SN{2I#rXJmYc<2hTE^>r8=Bdl zb{)nzue1GU!cb`CEt>u9^O9vsFv7xD$-1PvD`+6{2|8r8wBBZB!VUdR>;KUPtHS`{ zy6FKp*yg#gi$(z*tBTH-!onc%pqL)-a5&J@E8@s6T)1!m>(eLZv1E2CHM_^)x`I&P zAaQRsv`amn@9hiorc7Cb$}c)>e{3k&g5eB?V)T9`aqVokt>LbYZ8>NUrD81?YHE9f zQat33ObP5+nPxIO!f!ysgEwY>RG&*xi}Y{&{QL*u96iCC=LD5RB4AN0{j%W3ojZm( zz2kBy&wom{jW*FnUpC7Mae*FdLb_C;MB?a&Uv?}m(bm`# z?&^`unO=Oa^AUHl*!;Qghl^-q^5Kgd!N@-^&$(%|H1`Uh*Ow0%Q9g^`^D#nc@^mx; zxTY@u$D*$e6Z1j6B8naDR?n_pRp7HkbbLG0)%5^CztCkXMsV^3owNT;K*YmKhe73G zk^W~<3<%Lq8?j@_EwG0NjcYoKiKEx=e2qjGB%oiveQS0u#@mU#wspF6>7tq@6e54? z*qB_tr^p{DW@@&+q?jrjA3vIRv`~2m93NJ62KdoOWhdIfSJ2`V*>@{uxVnlwCqilP zgIx~{7Vp2VJ~Ddv`$XGi-=6ZEnzn2iYnXkyr}c zZCk;2Rj21mcJ{7_yw{_M`kbcipyQUYFd(uzC9+I*vp$YJ1~PB!bThklqb{xo%B`O4 zJ@qYbl@he5?XetfP0a%&NwW;U{tl7WYG{_m+Rt6SyfOcY1(o_-J;pKPNmNe_DvPtWo#Pp^}3}#dZZ4=8sT8(SykB& zN_9PDOibT*VA7T+UukJ*Y-BL-ApaT9y3ki|ckme3`}#@}Wc_V@T&zML&Y=v+mKBN>R9@+Jy~ ztlHMxZ#toJIJp3tetK*H)TolWawjwAZR^wMEo~FOu*VGpdnd!J?CjAWhApvR=z32{ zE*+CkF&{z2^NPu3s85crut99&2|=RPDRA`g;n&C>5EnB=Jc{(|#+);!P7ScKsz-G= zIHXfnHr^O=+W;??7}Jr{{Fbt#>yBvV#rNs7NAzO*ni7{dGXF+QmNC za07M!^J8|R%e4Lrq-e|c1x-`(S|EEZ5JA~jx0$2LdxG#(|GHvEAdTqQe{#aY8k~1S zL&H1EThfb5Z^+o0uAaDxVxgdj8+^M9on9{zK`WFB}h@437IoahP`4LFj z_u#=cEbXMm+=#9(+}yi=xHS87E2? z%$?g%2t!^9a#4^s8t(zT>#1&Tn`p-lIVO5w-mOmmJvOsPFOaQN?t^MtxY-gVij-99 z6#$bxEjlJortQ!lcYFZ99tA4|2M5u4o3$Y%yzACyFi6An|AOuyg^( z8Xj8#+owqZyD>3w#iKYPr3*03V~%BPpQyf`r8b5IQvch>ZMYzcfK!u4#3mLKWXhAX zu3b~aa@=M`V7Mqi04i===nCEfipC6Pf2^Cjt_-JQah;qer5P~H^vQY17Nt9o~ZtGqL{v>EE%kwMiWlH6Tx zAQ9pg_m=PzvFuf|izWwS>laR|tHASzjPkh0wmDI~QGsa$HmYAgnI_rhphaz8cjux^ z8_W*Nu}PZaI!xO3ekhxp1~T}b^iu_0#@o^$Z3!X#TD^pp9e%<<@23bLvCEJ}lVOgG zMy&U){9Tu>PhHLX-7T%fMbEXE2D6=gbRFA|Ux9v=GAI&i)~!bmHTw9hc%KMeU@Z}H z!!IA1yL*nx#hU|1U8(pvN#YizT}NcKZNg&CC(ecxz`}P#vOjRni$OG`w&*V&KYMnK zKuhh;RDW&omyP_jnb)5^d*(uwYiMlTEG(U4bqZ*8{G>_Ocre0`6RzIh_$jo%5%MC3 z3p=NletkXuw5$IsgSuC}`$U36fb!H4(L5+`@w?m2`>y_DU$o!?464-p49lJAH{bcf zitioh4g9%J1JLqt_qlWDo*rpsmqWU{d(5F*VuAe#BSi&tVoIcoLCA9KTu>{KGHdeCBeIQ*jZ^rF zcQGW1O#2glXF1`Lg7?v%Pte=uH=6v-52FxqQGELa2CAxcvoGK6kgEEjxwiJl)+(8| zad5L#GXW}_N+U)AcB98TqReA9F+Yz3p^T~6wTDn*)tpDR0pJY6jHyC46GiV?k7mTk zORpUthF=jUGr?hL>`pu|r2$GIFrD2R0(Vbo&1!VrKjDHo!i_|9jP;yN>9cU@(s3Ug z<9M2a@1lL2c)|-c_OXM3sTUFK5vE0^!FciT#RrGYr<6H0?YdIpUR_n9ax(o}5aVrA zfR<=5R>jx<4R@UeaWHn`i3*+_U)7S9%W{GDY7mm-R`V6E=H@zo@PrUYiPA1IYuLz< zgYE5Axz2vnvNuMqWSrR)%4mO&fA* zi@ho3)JiTLJ47$UiQhOe=N^H}|Ifug4D>3aSNsOXTH6+C8inkXx%~Izy*cYoZC7s#rE3taJ$CO$7ep&R zbKrZ_j`(dw5$zlI; z4!x(gRFF{{QjIPZb@X#4M~YEabzYLSg9IymPK!QE8&bzlNGF~L5KQzQ?l^R)t@H}> zJjNosWn!fUM-0|ykiD}E@KMFQ?NB(&0^>QHd)CseZ3@^A)(ouRLwjAV%unG1sih%1 zxSF0`7x2!y$_=Y-K`yR%uHYXZpd(AQ_g{R#z|TTZB~8_M1fkzxXpx^xRJWkE!aO zV_xD(ice2O)MLo*fn0WtgE- ze+3|j%svl?LT72&FKHJFlnsPInIoaj^nayiE5sU!UN~a`eQCLERt_!|W(Qi0L2#Xa zvTQORmHU2s$?5%w5GYr$i}oXy==Es%HFwTIk#u7yar)e_cFm+Y;IGu^;qHk1DotQCBm%%u-lG38Wu*yUQ%lNx%1FQ_a&Y zkcWUzTBZ*fIy4Usr;|f$xVlM1N(?arZS~4{9mPebiPwU>QFnNxGn&v1$IuR+;v*z<>A$BZi$?e**a#!6A$^!piD64ee>->Fm$$88-+<(<#6< z4~w+h+wZ({<@aB|9Ll^Ti4$@<{P9xNA2*^W0~1fc8$1fX!$sZ)3#50~0y_wV1|hH28krNk)PU+fm1Zmg)d&an*Z)+3+k+BLSJb%fg8_lkeU&dbXUZb;ZwK+n*&#UApqPheuWA>Iq0a9$ZLUf(GFy zg9kC)uP_bxsO63YSAGx4Pj7#cBWRdYX2OW&rBNY%-=YahyT}YtZY~TTx>DYZiK&7Z z$so-QF8RXu8a{I5!+ZU@dIAV6THQr|Y)oj*2eswAPK}By$=TW6PTs#QbL2iNa>%nT zc?h2C2h&;~jw}8)k(w&M=o!4>*dp0E7UArMRM?|{MmK{Jr?ZC1l&cEoik<=echcLz z*vz@KgW;2r#pI-yqiW`5@qQo3O6%lbTEK)W_I6OVJ(!$tQQes!mA9eD4YND8{n#N4 zBeXx=B%V@PJNzyOgVkR>irx?oVLew?m)Rp|fmHLV@Y#uHyD!P2NLB0RFD zv6;9gLfY$K?8QU}=067NZjo^Z=@dtd2z<1NV`)Y-f>t`<(F*2ZFm%EE+$%v;3vIh} z5d?JGvmV?`ZR%H%PCS3{f?<*)r)cDcgVfc*KVv9$-02fOogWF?jovUUOU>@sxUR{X zaCVSnvSCNyPC#Xm61VQzQyq;I!kHlmsqY@2U_$LXA^PDTtjPqyo=W8)L#U+dDf8zy zX0IF|MoTz=Cre*&pIW3`yB2okj;!M9>#8tqB9MSoO^^ZL4=2l4m`2*5n5T>w!O9*cv#YkK%@n8}rE66AB>;Ma(xb zR-oCBR#sM5`PyIzjN&k-{jeHC*l*cECGNc0KX+MeIiRuy1#a*>N;uJ*xep25&l*$7 zISCB)NmtpbRTJbBfAA0JdA4jpRqW62W`ygRIe33|y* z?+&`F%b#G%4OrmetSZZP-GJe_1=m2|-p0Cfyaw|PY*4Z7bE1om`LCRBYN>+0UQ1fHQQ^bfFt1YXk-E$U**{-{DOo9Wzxf zrQWYz;HiSd+RvReYu2M$Nf3l@0I(;_!}BZ|V+i{!MFsXW29qG`r!nC!s;D_ZgP%-J z^r#JcH92@5gXwKY?duzzhZNke`%K;~TYc7~h?qV3R~!n)mPzyem8wO|ZP=)hOk&C2 z!jofaC`bmROthk?rYBnzUT@ObS+*#fRH)JISA_GK87)`f)uanM3!TvyLpg(Y_}XZ9 zn{g*6$6?Nn8oG8Ntp0tP(?D1EUcZq=cdn9S8WPCZfa&%*H_6$sr>UtHpf@{8LmL|( zN+BuQl7!=;riBnA1ay`J%n;DqrwRHpgOfkAx?5G&TZ+&y-|9c{imvWpl1(kH!{H-G zA}Gv6ZS*M3m@0*eUrJU62O@;ZEbH$Rdtm1C@)cGIW_@>UxHi~K; zz(XTMG5`T`@cLY<`*1EbV8vi zr^SX|<;$%i-yGSseAO!Peep==;Gsl_2znsV`awJhU82RUaHcpzzRcu&1pYU)82SYm zIZ&sMI_3MSM{x)uUttt?bDJlve`rZ=`9UB4hE887pkxG^Q{oc`onZmwDZ=dkpS#m* ziUzC^U(Ys^8-bvrf^@Y%g6r+Fv$K0U-z~;(@MpKe;dIA7ob(|c982AMXDL!%Q$Z=YaNm6; z(O19te*gR8GZ8Z|tz@0(0+V9_h4#Hu+tx7p_3PJHSOW zJBxr~nd^a?1*~jWu9z^L?kvHp0;QYo5-{1R1hxt1X%|l{Zbd=vao)uIe#|l2IaV+v z+7(Cy=94|lD3$pBl<_Mj^~mKS)BI8LAE}|7-&_>9=g%*T&l4BEb#p7fFn7btsHG6u zah`ybTbBO!?&p-`AxL2MNP5_IRKxHjwE?1h;=pYeE?IJ&mT$>stqn^Wdif515@gp9C&Uq61VhZ{*t%Vv^$d^I^J^?tRc0@;SxXiFLS{?q@+N-{Ns z&u5+`!}avsc|_e6o9|Y2Ws+23ii^vkdjUJ;dmf%_7p)3`sQ2a0t9S1@>o^z*x7_n){a!gHW)vIV>8V|^PxJ?oO* z7*t^ZvuA`I#!Jp-Es}$;TPz>;`1l$m7~5i?f6)mx(2BCrGWBSOXrT2Lt6DA`kh_;x zEo;}I!^hSpD=56pntwtp_1jA$K)?cerR=4SVbeCy{nA_vDSAx5Ce(Y__p+_X0mvS; zNo38B#u=ILtwz~9I$et8ZbwZqUGc3QN@dlw?sFd0L$G>Z>J{f!|4l&IUSXE&gX1%1 zJjH}^p7DlwK7|NZIHiM`0c6Baw4Ow&y@AzQ{uqR5I6-JUKPc=h1us(4`?fPIFPNqtwm;fmD$-{0O)*&C#$qS98+pkF^7<6R?eK5;$IB#C%jPfGTU zjBHPrdVH}rS>;L5MhIcrw+ETu7lDt8FeqiGNWI`Ta~|(hV3y2>!5hB-wdQCHEuFg| z07>47uAzE*=cLB}(K&vdoMo08zRP1?(&7)z07f>T-JaETBS&&7(k=O2>})sCT+kFX z1i2l;6FS$^fRm~`8*{N_I@v{CXZ;sqEat(pBVFu;_OQ0gNk@EHR;{9I=cP$?#zfxn zQE4?*ztk#Cq|UnJRWb4Alk!~tVyTr9E?z|L(001`NgH&$7u4Q6grmHc0eto2PzWev@e;%+Gbm=kjO_b_Uk2I?T$f{kn8!15Ae>|QC z?tXF@SKl3-G1`kav^7dDY#mw0Ty$$g!=@Cn1E46*ZoSXYsi9}-LKv@lX+3A%8PL{+ zsa(6oLfX-V^e~=33MxWSFg9H_0*V}ypg0vwE40YD!?rg3Tj}w4?$}7pVi!H*(dF(Y zCf88IeWht|bz_*fnIQ37-1{&4@!mFa8en*AaSQTW)WXM?R+vy7tEI`Ds;~joF*9|+ zdw!Q+ot!(N#6zyMj;b+#iQex=n58K=IOJR!wa)5Thf(2vYz_ll88X$84@!l8kdb-D z5N1{!10^Q~Do&d8Y_7RsdC28eP3~WaRjI1ltu;!qRzN+UBYxW2b(>@u?5<(0+xeBx z0R1|<8<;T7eP_V-S8b+$wmxiMYlMU0qX8!@yMG^FXZMKK^_1GZTU%?(+tzhWZ{7M5 zQgJRf{-M^4gfC?#8`JYfzbmQq+~+uX$7GXRpS)yqtPJi6=*N9YgW;LR`7MTaksU`r zykCm>ROyp!FLg?y(I0Y^71rGPUEo`1Q^N;CXextbQRRvAFSW(vn8GvZ)^CQ`&BTuk zGHiA-rG#okqyABox?af;-)JE+p*S(U8z~<5VjwF1iUJwu?_=I1pn*Zcfs`++ z`5ThH4!K6dntpu38Cj#E=2fJDicC z_6|}>Qf0|W!2g!=(L4$eBFMsZF$c`9SP3(&i$V=p)$2|pu^SZ+7h2=)MHj>PFwJChKL`%hkaN6M7HxlAgK|q+NHmyIiLX%)_Y5^nFsU22jj!HW^%J zGsdzzGZ{J2lx66=nOXdfET#?a`z@oJ<9l%Tp{!&aYMDx+lm$z&;i&5OnaqzsA1&~Q ztlKY%=0Z@d=NM{E&q6?Iz!Y$EyvXz0bc?pMq{=h9Qc<{|>ZEg>zK~RTeR6+iwJ|Si zRS4iOYr;GhX#O6`CS>s#)t6PWy>X%&^Hfp6egVChShgC^6l*~A({39A6y|ihDJv@M zweYET3diDH?SjFT_|kX1lC;?RKI)#-FNj9@Hm7pV@y5!O7?L8Pp$rF;5Niyh`|5BkigwL%(wHTy-@{%!S?2=C2 z*Q?jahj^E}Ad&7=y-n>N1cveoMtPp(${TD?8lK3Z|;2phf~f9f)FeQdjc+3&lU{X@N0FG_WEzpjRcF{JnRTP96Dr0oXKOP5>6xuM2H_%05uKfYIrEVKBj z+sW(DtYSzK1pGR-R13P;4_3|+s9Jo4t6d8T5secYDMY<&{BGO^o>s$9g(-hGJzyg@ zKknAo(Qb-cGbN{b{j^^n*|F*W?SD(3uKuDKR5|W%w+x*TRqw#kn|fX80L;XSyHXafixqLh+|xtko{5%$oVl_5IN?2w zFJk`7jfy51-@rj$bq|lhhT{?o@^bSmRlCXTfRn28ZE{uZ0ED<<+Oyt+a zzDM&fBN*&=xGG4NPoT&MB-=au(Spu5jrvkD=Cw`Lm2(eekq1}(Pk~nRJanhz&hQ zurk}cwcH$LX>`DJ{A69StkHVR6c(wWQ)8yldw)K4t=fW=C?19IHl>6&^Y9uQTUyEz zSx#no_WnYwcKesUTDKg#M;Y;f;0~^u(7HE4`w*=!HQ%+~te2BJ;Epk{j60<5Zr*)X z(?;6eGai?14c)s}hv2pRp4NVSwaNHc_Ft%|V6_L`&i0EL3waUg*v0QTsH@P$gr9EH z{sqRK#mAg0^jPP)b=0sC;J~ZRj6&tfO2~wY8g9nUuLDD5dkg4qe@6TEFJ>>P{ZmY6 z9g1$rDxnKI8`wPi-OYf%ajYGOS&Sk_nzgkh|4gsmGEbhBUjxoDv6of)JKNjo9YK5v z%a>v^Ml@AWp8EH;&ZQ*7-n;OU{V7SmtS%=P{QXxSB98nn4`g~)`Lm>oNs`e94OXxm zKZD;tpwx$SmB%RdXtTD;Vs7jC`=;|KvwyZ<#axC#3hT3tig;}^g~~1e>?dlz;F6J8 zIV~PC1YDpK8$P#U%_zUt%sI({wEvK%CafE9d3H&`7WfN8CgH|&nfJBmvo80(|$#6*~?!Myu)ELT~nuS)KO=3 z!{q<|t!fALP0r;Jw{}s&TxYm;pZvssKI|o`it9|cY|YB>!{OSJ>5Kn9>;gugit!Wp zyGYNHkYsFM>#F(b>Z5y(wNg!SxEW^F_5}E9OVTd6qWWw8Kf=yDpyvGh|98gBn6Zu} zWZ#!$O|*#YLWIheJ)%fTvXq)dcCw37sI-us7OI&n31ul+q9UbGSyBo89_M|pnfdMg z@tw~X-TQvOmUEuxd7kGSmHnR>+IZzzaV#Y()n}rcKo81<4=fu#^@&qr?YF@_Jh~K! zzvn0Pqcno1ws@tlo;0c8@f3`t-<_hHMZHFr1R|>AM?W%xl$}XfQ z)8DrlYEyP0nAAXnNj6J|{j;9!`g4gWPCVG#j$FRY+$C$Phjv%L-*LH+%5IMglPM4A zk@hb8N&R}3t6^ zglFyQ5(3B&@NF0RvsaHf%CWXT%3+r7BK+zz>GF@42$I{!PvGz<`mrbbYwO!v{xnL} zS+gj?2#0I1f`5sJll_VCWtJe*H3? zhTPR&mn-uYnKkjBZ=+AuVd|`J`f?D5;hZ@s-(GR?bY!PXnGD#;e|(pjm5(wG(>=-T z+tuBo%m<)%ZnA$f;P6lODuX8KZ#*wpj4tS7jE+ojyi|HWNHcXW@TwqXcdQgspP1Am zV@NsD$#d1K@@87rCu=|4vYDObNW;I5ocyK*fO|a*lK=OcrSq@$lRLBaf=JGWUzrfkNiF)=OPv&w)@vwB?D zzruGelP_qlzzKBq&p=9JjDp87iR|qktvGITzkka}^W9HjL(!?Jz!M}!P?&pse;qN#XTfI09>uHw-Hms&^go;F3(y@wx{q@Z+PR zS_tE*{{3YcF1Lb+^oHL;L@l*i81-RqEO#AHj&7qyx|5aB7qGAG{U@{$Oiz!D4*#6| zWX5(i15M9Msnx8nE}nk}=Se47W0YlVPf}{Cg3t?gjgqKS1|t3E%O%K_w!;3g^fvCz z<(@aMQwv%2fPA2yOpKS#;=zryIniy6jUO&q4gPLQ1CeoOHEzg$zp{JCFIYbMfsdd9 zK(5d{wI52JLh8-Wo1ls#@Fnhj|$^O z3b?l;ac=V(n#c;svH=h^6zQjFb63&wdiCC26SaPA-#_i%dZtX14lZ%0^I3Fu`;*=J@S9WSYnXPdjtLJV$wAhWIT_EGl6EsGR{i9G=b;zf#Ou6B;o$02(^m!kuKw&0C`Hm= z4G?#%XL|9mzw^(WJ}uM4QC2ko`Wd`*8PRg3?dFyyF@G0FY(C%^(?xvJb~p8tn=Y2Y za^<-@$0hq25|$kXxU-wt7K(*x-Aht!!-?F=A3YkQW?GY}J)_{S` z=^o`DvHpbni>}lR`<#vYh5(MR+!r!^3BPOufAF$ArR?HV8FfhO`y)8Bw{#|FslkOu zX!;@pJB0J-`ef`IrJd4FX!vO&ms=$X8E-hvuvwoA~Xy z>l68G0WwChY|pWB+(>c}SJ*%q4Zf6V7{P~1;svI{YVNyh$B6jjT|8Q^1L&9cge%Z%DC zD;udlvZN}LgK>&I>aEOd1*|9I2~uH6mO+NuIIt5HMZIKa9xz5*i` zR#Nf$^=szkY$AXe4luZDF*!~soUu&H9+T(kbl7w?BYw0FS)YtsQ;d4ZPg#1jcR5a==su3UsC^<=k1*?=IQQVigSDM zNoF)0J-EtVoI9&GDd)WqZ8ATFDvA1d>Cr=GGq5sO3A&fpQGaIVEM!4yaLM$X)?$)2 zDJCiIHlHDd0b-|DKy&K2$8kJmn%otc=wQIOlzSE{UJ7*1Y1XKF{O5N%m1UZPTu9{Z z`WDIkUPIc$d=YX~7P+x<k$~fXoDKk zVSofg$cl7QRzwB|9wFS9akAI4zJ9z=BVZt--k-r7D&GedxNEU*jht0b@{yaq5hvn| zM0n=gTD^;*!{oY<_v!3L%af&I?HVZ7E^Q84s`aF=uE`)!nZdI*H3P8_v5mMz=^)dm z;r#my&w00YrUSo9k#Fjr2aagI^v_rJ>W^$~&MHSs-U0N8Riw(V zJ=WP)@v>f1b7ereOg_;}@8)ymaP!Z_aRF0U<%`^U865F7BS5QR!=V71`Wn;WC9@Y7 z0%@A+hhNQXQa8c#jJQIJnk2NkeH+@1X|O8)EQF1WHe%jHudzl2L46upcauqYwsuuz z7j8<-0XFXR?a1j1D0$W|zQWvBkG#k>>Xu;VyvBq9_f2CY>KBTK2rbD6i$9FQ-@Dot z!BoGtj$|S)2{4!7l)B7Ohm<-f%+Ev{Ygd_piT16b!o+$vb6n4z=;*IG6Q;N@QoRkHbt2XQ}QRM@7>L~K>l_@1dxZ< z(GrRPJbn7inRaTtGtoh!5?QttM9DIw@88c`#u1hQa>;%&<9S(OS{K>g9ZGOu?=!Hf zB2|!Hk4qmW1gE&M1n6*DaOun!Pu6#Z+3Mu)(@SQj2q-343iO_KX_f0;m|Mt~dO-gz zc;{Wl{kTJ7xqnbDr+xy_+H*|KW&|$M@}jGI91uVTl3xKF=XU&ksy(tovrh6U9EVN+ z$4ld@*pR^e)2PDT+@x1q8Qnh0e;^QGE==Xy^b>3Jugp__f+}DMts*#Qq-db-B?zg1 zdZ*ayKxfGb%7i5zz{-C6A&{G5kZG^KXuJ9fsFL={_%S~P?}7;RLoK67fuU9p-pT}x*C1gFegc4ruHZ*%#(z43m})MI~DUZ*)4 z$JJ3Cy@2GgCSYUEJwnGg2S%45%3@Hv5nDHtDS-CapbYSXrxs*7%3tPu08v^<@Yis! z=23SQ6TohUXN|e(%VE7NFrP?CK9&t>Ae24=JsC@@IuWwmX>e_iMDtG-u0%Rp)P^GqiEFgNzh-}y3I7CF?$$Bwbx zXgjgXdvN#x#AC+a=mz^!Bun(fBj??eQNA<36i$-Y+@XHWK@`kwrs(IID282?SP-N> z0(AP%Yg#Oo5xSJ2$7V*OnBi<%XxS=ZX+jki{GbpJF#o|5Hr0a8?X~h0UK{C7^aiILf0QTd1Nr_ggUS~5_LM{YV5N{vM-FAf*+TD7qeCn~bV~mv3=4Jm<22){Q z_ZqsSjURDE1E0dx(X_TDau{MzDSEq1NN1h%9-kz}g!R|}@g8*OSJl@OqRyUT=WkV{ zm94yBN$MIB0(*^J@F6lVPi}>k!s)dhB0*RA)qZjHkglyHnbk>afF~n^uS!Z!oCDHA z^@ox+A4cL}F6%5vvpp5n{Fv&$lUqp~gfD|x7puWRwCteZ$-AP<7dM8~WHTYgA>z>8Ws(@l*pP2d^--z)&2Uj!WywBq#d=ZLDYMaJpT85u!A2Fuyb|ul zN(pLO50jLUm|x-#t3r2R*)ygtig1p@YFZ&_NJ-D5S-E&K8U@BQ_`z%+54Qg8rD6~9 zII7m>VzI!yH+{(mUN!6FzYEtJe)x5eY`XpLnrKYtQc_O|#m>y%!Zofhc8YqeNzpR7 z73IKn(|U45RO#t52vEtE6lG$EmE)H;pDpnkQi5RNDi&47NQR|Va03UR3#uoB#M#E| zY~;>{l;^adk;}l)m5QrVsdmi+;?Q0(uD4yvz^czOcoLWz8m{EHEPeqph<(sdU>cWA z6N-h~zw^3$kztwW+b{ev171(2%M*YbFFdB%#7AVuGpQ?nkpir;4gAR~JVmhpS|4QM zy^xs_6ydd*Ra1m6PMPV>vL^hsn&3m`&B|oB8UfQO1w2d3j@nkr3Rv3F_{oj=O9Vyt z#u|6SPhXUCw>w(G(CpS4n<$&0}e8hj2ybkoF#LX=%$N5B&n)vytlYN%q^@D7T|a zcg^GCm{Uda(cP^`(38Y+4LQx4PZiw(^*{BNTq?EZhPb2{$_pjV>D@;0U zE`t+4=+QaG@9(zkZn($~7GHR>N5GXciieK=D$~C?o4?6&`Ra*qhl{G%M%D^Gt!m_Eh zs@6*-ETRicwvQk*(t*zab>@S#l%3G0(^t)BZ!TnE=;2hw(aT1(C|@ie?RlEuu}R-W zlRq$XUb2^jIEA4{<{&nv%JFZ5ynS=;Vz4NA_ji*vXwNWjT~_B&EkEI-uJZZe7i3VQ z8Wu@#0hSD}sT|v?c*<7k1~3+lcpDlUOjB$(&E0NKW+~_=)VcOrmBmu*me`mPbfz2? z5KCS4t^sxBlf4QHxFQ>PX6SY*i6Mw3%t;^SuiVM--x05L5_}K5j!3I$FxH@rj4lFDA-oTbf^ZvV2;!9@(@_DAw(`t%XvPcu0CBT7n{}l7=yk=*n^wCxaZ50;@<6+^Y%n>IH6kK zSZ46fsBXQRBh*nSX5a@7$Ku6BV1^j0&E0zs9eNR>NvzA}I3+LTm3thav`Czs^W!R? z6wjCUS5gQT&$~e)2ycY#-^i(}5zvb9nF!fBzI*qsEu4j%w3P_p=lfw4-sTUiGTdGk ztIkdYevE!|{;@i>h8V6j(p*VSgtwDJY`UeCpz&&ly*BmP%}rdilYc8Q7L{o$WbPH6 zxVL)`RvGjWwg{7TO)UTArV56{m34>p9ZTeKA~Ra%Bg5lZ+6hqbYI;-?XC3u(9@QTX zPR&{{eDoC2m&_e+TI0Ly+m~P!g$3)mRV(cpDW!G7NN6C2r0L>5Z@*O zLngWIkdS0#@c$#|YN86deFE8(9OCEXKSvvNm_g4()TVZfr!pvLsC>iS*C?RY!>jBv zE|V;lJi8mw3a9u_saK<)0sV|maoU7;1OI+IZ69wBse1(#UijsRt99Zu_ zbe#(39s`(tjw_P9aJsK>TY-;O%amg1DJi@br~x@CDj>Ku6B%p_NE|n$jVfq3U+tc6 zoYkf5Q_&b&$}d%iij1ih&GK*6TXuFU!h78Wk?8-X{^UnoEFs|H5F1{Dq9<4+g!zVb zo+G2MXT4kNlb{pM0jjnM*vBmYb1U4snFgx%sl2moO+!3N7neMFN}f=BLSf`s5jd)L zzy06CHA#S86}b=L+Q{R;9^u@?-dD73PzUFBBvj;}=FlKY>{6NxxfKr~ zmoI2yon)#ocB=C}t!s-bR4A^Z>I2JvYv%r4=&w~LSP#pU66guZv|gN`M2qfmJk#69 zSzr79!!@&=_pxQqA+fdz#b$Q2`B~Pby}xn>O`D3~6Psbp;LlF}4GAfPR@7HgKY__Q z8qS&0NuAOgeptK>4w2CvC5T!ITW8>I%E1{^i!#gN ztG=KH67;5TruHraQqiH$1^BHTGP?4$_l z7)R4w5jCK?;f6QFFRgxt5~_`e?8HwFMlI_Jx5Sl7bxMT=EB3NEpj_y5Tc!o&Az_`T^W<)5V(=3ho| zv~O8*8wv=`zFndu8BtB4a(9HZ z-WjX*=jCUA(*krsgtQmSI5I$jGYPQCB#kqA;X@x!15FG*L7Ke3ecq}nN&@KbTlJ}K z8XTQLu3=;vk>Xr(s4XPeYBBq&O#C2A1s{Yqo)M3&}=Fgrl)u zb^emE^+k*S9?`r(ezz5>X9zxY?^h}38SDGHhrseek4su7eFZgMEG!B2_J%< zh>6B5qw=r-?%?c)(}oM-U~=1Ps&s1vg($f%4)4)>sB&~M)JByE!CHELeIGy!Ok`r? znr@}6uZyz2*2FH`ggU`M>9~H~te3JL>g1ZXo!@rMV3Hr??o}EwJX0(3-pIY7@x?Yl zaGKrI2@DF#4Ro9rh2Z@8$;3K0r z496$imK$FX#tKt3P&g|9&|U>eMxif|@X_2(Fw4m3tj~Ge64J2Nt158qEg(#ET|&fV z2?>fBX_jVY#G8d`E6R%z+qMEoq9)`s*Ri3{zs*r!B#D5t)x?yl54B##>yHx5M{lU-WWO*jnhgj;&d7O1ZeTeI$A_cqI(9%sZaFlo zRnGf|4<8D#v*hC2;Wkcx{kX964?KR(+Q!dNL>&KMK1?5)Wb_WT+X-wg)WrK+{b0YK zL=ddG%852FY%h6i%Yl-#0=Zg#YOt4JuL%fht0EmDGH7py8bL{af-QE84XZD4pqgV; zy%4>-G-ZV?fs0m9CQkCvWkJ~qgB~*i5PoJjsgADrUrflp{wtkHeoSE&@YRQO*`qzM zF%>)emW|q7B06!dmZJ7clKJH#uc{+2p6Ps}+lv&sKlI;f^6`NfkjIJ&MRBa6Iocus zh!d&f{uRI~F_851kv`3`Dz=nn_gRB;y^2$Xx;nVNs(f43dPwB<{=4^~I=hPVQlxlE zq1-jewa!8ABy{~Q2g1P@Uz=aRl!&lRj*|MjlPRX#O6AugIfIgYrgCdBo!X6JL)x9HSbnG|y;8U+EDyhUxzBl}}kc@waP5KwIWlj^P#u9OS}X3nMfNsP`uV>idOsm#5; zLP>}Wkrjj|3w=w`X%KTO!8l|jVOl5Ao=Apq+0*tg zt4e1|2%#2UO7Ss(>D+FC!IVZmMGq^HpeRn>QWm>I#4l_5seqC7hyuV^!&Cfy~HX+&*b)3mdJrd#m&DiqGCZ9_ZPtjJ8 zh3LDeN!FJlH(~b|TYvdl$~_$EE0H*yu~x>6)r*-I6~Rgnj;|mDQJxOTF1w&&$G5$ckqsy4%cPJtBC zX%8DQ;<1M(zCykRh#Jae=sxmi^qDV;iXzL)R|FqDn!kLP?qcddF0_+hH{B|IbYSey zh9y}*O=;;)zU|~l?6aTZd%b61geJ_<@}`s4eAf`VsjUM2j~T_qlS`$_j{B7k4v<8h z`}en@jCgrgE85f3*2A8Xj?lvHiGQ~DUCeyFNe64nSEu;U+nRur z`=y+{p-XuP6yQo(&75`iGFy_D2X0O#xpbO|bF(KHLH?Pq{Fuh# zs8lCA8X8U^6?Cp(s5cPC9tl_YP$;^G9-b6LoTJqQbYmF3)RkTF>n6L> zv1iYc$X1OzTbL3Tq(j#?WOyKc)1EBpyRx#qSLQ5Tbrt1^fyX#`0;k=6jkFz=m2iG6 z^GeA)O!n_WRC5$o*ZD{Fp=i0CEu(T4MWr+(T%J{&lXHkMQp8Ww(ey7JG?A&1>Z^R&kbYD0E2a?ALE1!;c8jaiQ>(peD>Ho zSh+_@=8-zRGlw7IuI|H7m{Gay?Sf)Ha?~E(g|ZO6k0=)=cOnI`QgtPJ1BB#UprSqJ zS+(ZJ>eVAmfBRlJ7dKC?csnQJRfkc#7=3cf@7=NQf2e69-MgoE zqk_{B(_VRw+&`xb^M2e}j>V+WqecO~cGk+5>0Qiw+2`}K%Iny8#csaaPE+e}eB^#Q zcn>M7{^O6*_O$Rx?1h~>IS!d8x%i2`499A)V4e!*I#wpT|i}vnIJ&u%Ne@%it6f>dOR)8y$iG7rT5j2ZRblYI=sPJThGCdIx3p&>TL4 z$6%mk6y-eAL`#o32m=9oOL(7Pz(Bi`{J#7k_LdLyD&FfojTK!Y6$qLsTpODa4|U zXFUku`id25oodGJgJfV*+R;DmN>OD^EuA2QO!j1@sTJ5>v;OxguZsY~zYAlSb^--3 zYXW&8PHL-nQ@7{zU0tI#_q8E^WV#2r;$f!uso#uBJjV&#t79&ylzj8^H5F@~PJq;+ z|BBm(Dk;)i=s{ZNU1_PSGj`EuFTDg7V1upg4kDVUC@`yB4=C*r>R&d__&Ui zwv4ld$@l_Q*AVLzRabXn9v9{y$KXD-0azF|Rm8|V_EJFjq zfwzKiAx$)g37FE^m zncvRe$Bi2{+`*+e8Z_o@mMmG)p#FENSjuYV^Hsa5{`mr0v-rUU%y!L#@V?E>l!NBAIMcKZ2R%2pLBF|lAZ3;Qj28scAoi{>x=GT_(e1faIgYsq(6kPyME)w zXcF$dx)6&eb(O|nc(250*o!a_V8`~~s}h!rL-5vZv`O}l9`}0(Mxd1G?)j4{H-yxX zK&Pq9Q6Ey45jtMLg#DevYZaUHneHx%%f9_*dwDb;H~d}%0-3&A+H?*KBeuy8KYfW9>?2Dm>k3;VA15_u=TF5+Nz}0<@bJ3 z&1!%#v!bId{op1bAn(J_pW{=`3`fIr2f^u^M>l9SPwTx)Bx|z|pH=x>KrS_rI4%H8y0El9oX5JC?hhip@(^1#j4rzQEjNMK-|E<1SHBe=rWM2gPgK17y9ORW95Vmk#GaF08 z5ZZ_l{1{oI z^8GgbrOij??Zc|edF|5Wx)bsGsZZH|)EzStW9j@D3(TNu_rz$H|Lyn9Ri+281X!rN znukp~agY|#4)5I&)V%-D>yj#47vLzv5gO9Y`gXGfT<=j5U*dj$tkKX(xht*s&ZNRs zw1ZANJgnx^cI6B`Q`fSGc+wdu&EY!F(?V3>|slYF>2hn z3r5mN%>>#`jzhv4xf+J|4-Kh)z>WyK`0w^ApUOy^=)=U#;lz_h^kjWfwpVw`1kzw~ zH0j=2sQ8U|qg5Y5QM)*}hCtAe`Rv_A(WB%pe){z3#?7107X0bW3q)WmR~G*1%^Hz0 zxt`ZR8)#rZSVy)E?qLuJNzbp@z%>`y)n5QDPPK3*rdv&*$or<=kC>FY)(kgvi;jS_ z2gHp|V&k6(c<90&0Q8*(!A$yOY5OO)HWS0!UNO9%pMU(N_W6cQ>5iBVBh-KVg7q6W zo~{_`O?2%H(X1--g|KUqLLJAWkcr{!ueq`i)bi*b$m?%nVg(e;@cz!r#);x}SGrvS zATm(%V3=V<Pkl72-lHP;wyDTJC-0epyuHhT1#u%O!zqv^+|W&DY+%xxYp zK(bwn&JtN+ddC44MvWbNzH$v6qND;H{wez`ZhY1m+6f-FiN0HS6U9>`PWQkrwfnzt z(OUiVi=@N$LIii#c0gUJ^c8!Y{e${3s*x_fN78`Ck9vtq>dXsH! zxTMSYPMoW?oxyH7bgI`oC)KFkkoWNFhDfImEqD{7spzw+Yx2QIIofp;kL;yI_TCEm z@B;x(R}+&bMU}=3xq{wWKm_CL^8CgUULk({`kxbh#Vh*!ARk`TEqb~f;t3=NI|Ff4 z;;8WH`_i>x>0oy=1x^@L4+s$>e_BSumKHskMlIQGF~o84bn_Nofx&<>y;_Nj%l;sR z)NRV?%Y|bwYgD_~3CQ9&U&ysSl(T$$4lu#{jJ#1){1ZqEuyjAa)#y(c>d`?kMs|N? zy}aUmjol(8m_+vb$m7J7Id>2vA0u#)u%kO8;#5VQglhnSr~8uC+^0TFMdtGxH&tF{ zwL46WPzve3Oow<$Hm#qW>y#l1@G7r@4GCfKBRH`F$e)xAgo1+YzYAp*icybM4pSDl zu!(Y5HEXtbzLw_mZ7!{{k}_sLDM_-5v@uj3-$r?Sr!1j_Nb8geJ|@^Beulh(LEz3# zUj_X-T^Oaz6PuH35i#xI%9t&bb;^F4S2(zuINMn#(1kaPTYrKc&#>7S6nNwkoxhxa zRip*F2J8CZ+9F9-bsaEZ22qIB;K9FxAlm@fOn4a~FpvryCHTu7mBRU@!^04w;EkI$ zO&m39qhLmHQ|qdI7l3{WG(`I1-d`vmr=*N;E^@Dc%;6?KxfrzYF%M|E)E6Pmj|Ofp>j^x_nX zOp~2co;;ZjAh+Z%(-|~dQCS&5|C$}bdliyYzD;OuMJ?#jTzLePwqabJHvkP5eo6|B zK}SM@j7J!r#xLt5GIn&VGMQzM$)IbB|A5aWLyp=er{K)Bw6uaD2Noj274L>0+_5l{ zPEz4BF2z7;b;Zt{TX=6cjKs2qeGT$>)Ec#Gr&HayYsZc`Wnb3T(7KD*vJ@fe+wTUm zko853?)OWrT7BC5A_pdOrr&h6{b}BdMy1o^*E}iQ478!IBU;uoz-!B_%=o03Dv)tB)#<>h*YIko{wDXG( z3DxR$#{SYr&yq`rNa=LuE0h~)6r&s>Ct+Y>haaWUPfcmMvT@6nOAc>9_7==@&dtjU zfeh~2v17-3U+71BpOz{|APKrK(Ut(%Xyv;gI#usDVuOa^6LU-`lRO){<_pfs61%dy z`}ChE!Y7@2^a;Mmi6`gzo51RcZQ>zKrYEd&zW+V4>#><=Ol zmD$iimijGXV675l`jn(RPERib+u^$5{T9ywcL<=Q1L7mg1wQR(&dk!SskYhe0PjcP zvzx4`-HXak>5~#V=7{}j9gjHph?qD$YHnoRXO-pUo%{5$6xxhMlHQPS9-bM& z07n;i41&=IaOoAL%Qio9! z=QHJw^i7t(3Fub%8692S*JB^HiJjPDk!yL$Y<&hT0_iTke>LgQ;2%B2YI=UX$oz*x z$N*+nCvk#X3X%tU-k9ow@_WolXgx2i+uy zU$=>B`Hc%nxD$}`HhfRY()mq{Ip{2CI9Q;NHp0uZW+Dac#`s4nHcl0kakIVjDDS*o z7(TLQXt??bWmT_tK`=_k@R-+Y)i7esMeZrVIcj$Lba4-YV955)`{2`)JK z7c~3KPM7^3ysGhv1n?uHc6PG?t-IJA;T2LTONv)yzT&W(5jf{X2dnatJkhY02{G(spB1Od;H&%9xGqD(2J!6JOq5ko`|Mg3;C$jXU zA`_GT{l&td_3HB27pMx6=`;9`nhXRR%4^+49qvjMM{Kc3Tx6a8n{DyA#3}9Cu3chb zt$Cy6)q7^WY0z|0mp)Cj>C4&)1no73L0`4v2WAEisunk*mMY3l@_Pvh=swO8(Jsnh z{Ar6vT%?$Xk-`Mmd1D9@H70nIq)%{&b>4I`0}(T|BE!FxvGezX@l+x4i>Qyf9ND1T z*S%~e%FY!T`Xk28Uk{3dL^-Y78x`xFoVM%RQ=?S?t=YG~)?B0=T-jyihEb1+>?|4k z2!(RBXf=s7*wo>|lCR41wexEQRg~}(8iBJhML22hfhH&`i3gYwoEMa|)P(!0-cwG^ zkG4tE?>+O3eoEj&jA8Z8r~~K-t@r#O|f+L%=dget*OC^l0brzT?L4RP~CBErsmIQkd>HjLtzDCmwW*r!DOcn%gaWe-X2(NiKzv z+0}s8Hr=!4YlR&+Ke2E#ArthI5;mw` zB;irB(hjr*etO7blUCO~Z>G{f9ydQbV-1+@=DQ)|WaZyBtKzD<=ePf5^=O%jK4k`N zEdCg>B(-WLfnSu%vcs36NpT+g`p0i)HvLEgA~}@lzSucY=8W|y`ht5O?;ZbiM;etm zBtA_c5WTBr0(g2RlIyE2`BL+d5>cvpI#>Sr)3h18^w_vds6jdg@y8XnzzGsPCq_%x zV$?xS9<5cXF+(M8s|JQT8AHV4y6)q-<-PxCzb?7fLtiFnO<(=RAO4?`%qeCc18)A> ztl5DVrE&ytREEhfo_mgq(PAB0qkqj~`E46946V zP9_Mzd?bGJ-4O!iS0EFG5Z{0`zRW&c3a~^CB zF?eP=;fr{Bhm19z83Y-;me;Iq8GN+s=klfZT;RW_NP_+RD?I700|(y6k7?T5c74tm z8u25m%KKoKwN2s>lA}HEm6Cd*;p!yf=ajWTP}Rd}RB~dkkEJ}A;G=|M`)-hg6Fq^K zyH1-ngOtF$8IlCHQ=vp}4pTM&n|c=pqC<}!Ct@R4uU`EMT#|v%{^gE*;Qb^0nauWD zltFYr@Y_jJHaY!4^O7xivgFi-Jrb$1zxP>}7E-Yl#DgX7?EWF~Rj9>ZLSXINrr~-&+61-AU9? zQ2}wSMjtFqYPT(va+SWfI(Fz#Kx?MEXzYSg2UgkO)Sb;bnibv+-b7gMaPKvNmUKQz zkDNYD(B#zqPM68VEp)xac;t8ZnnJy*NC3aL9#6S+p#(R%EpTvZIR#iC43U;Py~1Cq zqV7US)SmfHD$fX36NH(|Rmi_6#z}0(${oQViHc_9`t=qVc8paJ66RM=+_GG+yAbx& zkBBMro<@&Ko~3XQoifVdkBhEbd1AjDLkT2EVDW!mE=l3yElQR zLTf5(o?x}+OX(zVY_d}kbsO6cG;3A-gDSJK{9YZ1dPY9ok)?v%k$fY$q66)(li(xM z$ylFjQaL56v=57f;Q#vh{kV!xpNy7y!l?*HLGRm}C0kTdF1(j&1~MNnXD@ZmFM7gMLl6QT~Od~^hcJKW;X?>U)lx6MIuGSLpWT(!D}%V=!*vmAT* z$!$SoClOqZ4Q}rP!sh)V^T-?_KBqd9NG!1jtB^J>Bd9UX0HGXX-|Wc^bIvWhcicr- zQ%bs8M(ovj60mDm5XF@vNO8ohczgJsGXu~D^U78jh88gc8J_}KDPj=f*+uU?xOM1R z<IY{EzPGcY5xP;@Yd%IPzFY0dxUhU~muBSA!kOgYtiD7NT<_%8WmjGFi6~@0Oxm z#sR{_KPX^1>>r;$4#yDq^5sk83=-7D1d?8m{8RM45=TyQ2GWwlNLP&92VVv;gkXh7 zVUCthjl>!jVCFJtFNuk7v+iR#-;^HX>VUg{7*fG8PeOxNIEEWevav<-+-)c-wNn`B z7Oh-JXW_uoOG!|LBN|a_2IRq`{{9D5QGGFpp1_myxMW8@P!!#n;~KIO zB0ABR=)z#vm6p7AKff->rEa+JJgv)KlY^4@#ppeyQxlyP3?ks8h-;O`c|4ZFWRwNq zH8Fy%mjucU+#>8Ax2Vg#E-FNMIN~%aRrig)zGxEF4CekuFbr=dP<{UwOcFVeYZPYXTx9^;Y{~9jR zDB`E(PgVoq#E5r@rY){G0SVizNg}_Aaib8RDOvp-8=ktB-ObK=@QPEXpO_H5ksR0r zgrwOs&g0D@UcsGUYI=6j^p8^|Xb~EQD*6^gCU>g0a-Susmv?mrw5hI&TGD}u@sNvT z&!l4hybG z7s(&jY;?BbDSU@LNXpud+gH1+*O8k}K%0nHc=OO3q8Dmi*4$*3C3M7jBW_+Zt!`7( z& zdWdy9aByz}gh$pH(gS6$9S>z%Bsr&?G2}zXr~?%K&}p|MKI2}WBtjFuPVom%2V<9< zW(vIIS#{0i*bv9=dXdTX2Uy z{EQ(y=aN4*F`8UEZ!A4h>bzKmi2t1J_4?)k1E#|T_w@_l$zVaYzlkALl+%*Yetq0W zJB~%ceW!30RJP40Dc{bGgrS%=ouu(?x`AleD*oK-46)lt89<+t3BzX?+*sw5B?{cA z9<(OP8~a{*m+M#rnuhp-OxXJlk4*%ZVOyegk6dDKYb0_*u6(x&gUr=K0@`E0u35gV?ep8KAzH)=6o^X#XrP^_S&Qc)#(txg9=)Q+E@I{Ih=9!mLn zeFk^7US7I(TL!oX`1oXha8Ge6 zxZ6RRuV1TV&`w%OR3aI@#}~7pS&RL=w>MzTGMMajJcWeU^}PQV8d=t|xS`<>WnwaWeL0unJ5O!_@9N4xG*yj1s^G{9wz#kizu4EyTrY)+NdWqD? zzy8%=B154;f%6SA5R^sq*!u%H&p}uTpSiUCN_z9N_txfyhq>!r_4P|5U*VY^K7F2( zGdrr7BLC*eE+jf6h%Fp5XU^dvt4H4@Q!E;)b;VW8Do;;de(Pz2A5e}tyC1%?7}d(d z$cH;32!$mvd@M!O|5OZKFg~CkU-dN>!QmO3SBPLmA(fWTfoQ$Nx8IV3>8_;8L@^U2 z!dm6WPE@4q{jn5sWeX7+shzDPSd&h@9>r*EW+42E48a!3Sy=-g zR|@eQRfaZq8KHFSJDVt{K?IF``mf9>UipTsQc&|Jp3|7>wkPA##f!oQ#eRgmze5b` zSFh{itZ}?Y-@UrB>~J%a{VOO**J{?x!7c8!ZQ1)1_+`czOgw4=bEI0CD(o|}F(dS# z+jo-Rl)JC~NpZ&OP`CKVDH8OzipW7oX49vF9ew7?1z%W1*d5Zisp#pDzP-dq``*Vm z%`5%zBSB;07b5;J+Lpa!L{8rql0QK-1=1W89X+LL?g}6~!%(A~{wh%-bD=;dc6T`a z>DT-9wq_R<_Po;P!F}?xz_||{7sdjDs&;8dy43i2{l3VWtZ+bO9^YKt+ z)oyAq&_%@>-0}>NX`EDejs+gy2hN}N<8mBN_gG}&noagyLKg_(kjs}Zk9mdAL}cmo z>R9N?P-WurRkSDTGxut(h)Rb4PWNOfr)MyDR?;<+!0J2i6(gT8+w<=&T26Z&--7+z zpG42-v4|3}OJ_{pw&@g2`u+pi_DAPZMV?H|I|a2_((*jXs#9{?Dpv376VQpzi**^Y z_ZY+G#mJw%E<&ZQw9X(slm_1ZkJ)-bYUUFm0V)9eJ&~&t8+`uDHzOD?cLfSuyE*%1t4DU)gp7K`kX&dt9kQd zT;6a3V2ItjNKVdzftbFV>H7xdy2Qp9|Lb#3*;dZK&-aPet&pW)IThZBcwlO zy&?&R!R~>qZe&r>$Es7Fh`qir9>O=WE=fP`^6Ay_kF8J4I1b963G~=7q^t_V@(8(I z$ScYEph%xzwuXyjxnf0>GG6bx)Oky+EW+d1vb{m5QNIut_(n1`b|%9hoED7ZD&)3e z2Op7APS-|MAnMEi_)aoXEB5qA_&gJRR~nSv5(OJpSI&?&QaMSsy#LJAW}2ZP`FBfl zu$GWE5`<{=n9={I6gj8~m z&^@?zTDP4@?ImxT>U^Bq1Su?~sav;x7phurwZ#9*45-In1YXG0l6wOH=4ZQXYcKJh zL}>6TizaPo4|bEL`XoZ2wx+{9oL?F6nt=^dh#5sUUi5j@gJ;}uAi>O#R}fAUAnTi_ z+`})D4hW~Igxl_Mj-yA8v;Yf=c9L-L@5oEM!NC^`n+oeLVFd||buJvJ)}H&Yl{p2r z=lAUS=GyB$JzT=yC|ZD(*SG*O*H|qG#^EWhsY(KrlaC1y1h=8Nb3hBSJSzYhKRQLq zqYMZ7ZP`3_Ues|oC7Su1NTb_DbCVNXflu&Hxy8kv8Ku;VE+8S%w;26f2)j%!Dm^m4 z2PIE@ZiWaguu@G@WkTNenL%`-?H-Q!Y_@I{E95PDxfyXHl*mdH?AxtIJ#xN6C+M^wVCz09imdI5aC zYEql4b51P)**5Jp=Lbo}dsSX$EXuLSX}XON1wQXc#c9w%}J4uo|bm| zg3^qclTcDEeiQ+NCfTj1$xCsYsC`1A%J!240R0~#R(LvP99ew%CTxOKQNpQR_tz&F z1;I)-QWU>KQd3j7@|N*yJgAtMDqfLq4(pEczE2X0t|>=pjM=ot545TTM3@ZEdY zA44O5eA=U{BZ0V(Z=nB|6}11P>QN!Pfyitr09T%IcKb*)sfgs%=v?ohxaWbtjz(Q zcVP`VKpp9eR+Vz#7bvQ;ga!yE-=(;du-d(U|Eftj&(2Q>9woK(O*taq!ktHKrl6?* zo!(jzESwdsU=Lxh_be2aP6SAjF%f1E1HKziBU*bVh>UqJ0WN^@IE^mG!8*IFlHehC zI@wJNU|72E>*TYVwe-5!g-KNCRJ>gfY~|RZK|L+7Y*uG(g&?apO~z88t1Y9Riz-QY zhLIvEq(XBWrGJ62i%+dg>7!Qt14Zq!c7;zb#cpie!|`4mdFH4(%?$#vT$TX#44g*$ zFJ-mAiV{P__?+uhr&bJb)t8}_!#7)?vMjP0hkN0i30Ea0EJDHo44v&vdi8o6=^>Il z;esegfF8Z%Y}2SSRJa8wFt^B=DDM!^9R)WZ7J`)zLA90X&pA4gbuN`mOlOgl zL3lx@`Y?KUtMfw{egKE(kIN22wzB}6P55Iky)pay zTtAtVT~H9l!FwQ{J$IaAv^0y5pQuS3nbiL!1!j;4#n@-%JKMx)@*ERs;onn2(u&4} zeacV5kJ1hZPe0jzA;3^9wrEwR&+lxmv#}ziTK2(ye{H5}k_VBbe{LSZXTX^%Jg9e!ObZQ7qNTTT)lbPi`w%94qDO zg43jVzv4AK)s;;A6N4!T#XwDqUGo4-lhAqm8!9GkAjjTSDto#^LcmNZfpI)2dDe8f5{mc8UJFi5pss{$9?^{vj^C5ds&sD z7K&h4zM+$p)==DfJjLmzf#xf7>ypAq!?vjDP#WLJ%49zk8>aj?9agKkKniL#7k}#I zw;pbdXry+&$Uwyf3P#CRXe;$^?Vu|8_~cW67mg9tI9V-?Jc7T?4yBIarBd1~r0PLzM$d>>tBRjz`R3^U{8Ja(!`|zH?G`} zY*uHZ-w!_ye`e5r=d7k7ovWYgpB6I4ZSnTkf%Eodd(fqTXtToUa{6`zm+V$Nv#R8OkCj%*KtR0*e0vPzVqf@r^e>d`&O>}R!e$tKw?n-vF z?ziWYE{%Cv80%4?kqFo@7DZ*9GR#`Evlz8^TqA{S5>VsJy02D&jJ!FyClH z1|Q5p3pmd52l=anlqi}eKC>HGIi<*vo|7`A@&^vZ0L`K39>ZTd_d`H5+TwflFQ9C9 zkiJcBWi*c(FxI3rO@3jrP7 z1g#}M6lV<_^ox9t%Gd!COs3A>G$;Gcjs!Xz-z5WyWY8aev>pyHB`x z7>^U+XGV>-dpdF3oNmXx2fQH_FVx+#+cxjDXJEf)~4`d9gp0d0241CN3_-KqFu# zzb#-BZ1*M^3F>=*ijL5*unqiYsFlhh?%PAkY5MO&E;ttd`9%+D<(c_}JgoEu{$-;| zrCy8MBVAoxl~&6xGLD>nGY$UvCMrNSa@OZN$|zt+=h3XaW{Zb^%BkzwMA~rWg%E_^ zAzsql-rNcViPD+z`V+Y)qUmLe*E^|KWXpMY_e^v2E*c|od$RO_|Mcn8&08qyn*hnp z`Ie#IzDD{sNq0Hl1Gw|cEal!%r8LQ_zchlAW{uIYc#x}f?AyuLlsD77)z8$e%#^t% zE;0=+G(3Ei`fix+iHV7cVrY?z8)WS=87jtFc*W=MQ;uiT*^s``2hOAzb=91}E#s$6 zYbFD}o!#{H^~X;sh@beqsr}bd$?17DmuAKcwHe59S@rVJ+f=rViy%Kq@0b#J1vbvb zO`4Mu{kHG?J%cCJCp}4qILV}By?&5e+hhmTw$C=28ESL*4c`NBanc%I4op>&`dmnl z`h`XFDZtg7za%E(q+X_*gNqy9ck7TkhPJ8Izf%=oSO0rkCX_Xv18Vm@KzEy~bSS?5 z?R&t=W75*o=Q(~v+(W&x4F)s&l4jX!UC4AE-LS^mo3}VG_0yFX@_8XW7Nwb0(h~qs zJ2}ul3{xdkE>-WWwj;vUoAHyZt%p-5)^FZ&6-e&6(wcn-h&_mT$$tbLY+}y{+8(*`@f&268`aHQ!bIoW;HMMEb>kTZnhk zJPm5VS?x3A<1n$UHI`*XT~0Zwr;RsnaZ(x_^{!wPL`yP-^*%H!d&R^L(X_d(o^Cd5e(0OPRGOpkY1AG1Ro61%J_r5)$vkWRH z?CjkSj>=Q4Eyn706O9jOC5JVx>PxYmBQ**2JNY>c{`Q17;D4Ue&23>(k+?ZRtm2B+ zs=zy%ZyGomJRptiTwJKV8ewH+rTN9LcTjN51y)7JqUJG@=Hw==eo(z`sPR;F75iw& zv>DA^ELj7J*XnVx1m|`5a9ewO`@^U2dhY$fE97c?iY!)%8wKgXqSMI>fnIqVHqJvw zV{xlq?w&Mb159>)Q3^{eUH({(S$2kMZxw$A8?4w>nQN|biz|9?-M1j|vvrjAb?NEp z?7o-(3-w+6ngYjgu%awEIk`#CS*9AV`1MZenvZAXm5U^Ma)&ThPGO|RzEr&-tlmPL zspIlqTv;8Xmw}oi|M`-)w|5g-yvo6zPAXD*SmIH=rsrtB@G(UvF8yQz)bI7>byZJq z$h$cLZT?TsRqEx+VQbaYo5crn}tImo8zl9!1&8 z0(zakz$Rzj;zaG#JYoC=JexGGD~ja2NSjsV-#^p*`;TyiSEcvgJjbm}oKoY;_$TaJpez}AzU%u+5dyAH-XDJZ`=Qyd$xP# zw#C7l0-z!SVKaxmaRmKNQjD>F~f{X38O`2Nt7jIOOhG1&?1$J5=D_jNcR5k z^Lt%aGtcjTy)D<4SgrQ@+fM-i z*ndny9)_`;V6_NCG6=C3?e+uQatV`O17Jn)3kt(kmuvEAMZsRgyD~B>N7N@gq{B#k zy}EQ+&rDLP`-KIm?oE zBp3nU?PJw7h7>+5Vfaf(1lJJ3Em=m-&FW>m0Vc6xFmr*E)RAB*wz{ia@(lAuixz?Q zjRM}?+goeCh3XWV7OkbkUyh=DMzPn+eVzS9BZ2T&UJ7+L(B#gaihe^i7O)< zhP_&T9g+z6oJtq{cUGTh>8i~1?!Lt};O;jy%W5m;X7(CB1ms^yw*b#8ffv_Bi1Nq?0cwaoDRuTU!ZRcw5q*qC3sB3d7Kq zO}@-nc@jjhg=X~Z6yE+y%XI6TznZWA2IxPt*sIoep5KOMLwl)L=#5Mgshn|aZ0x}! zN9scRYi-v{ZDKkrY`EuYw0=C7Nw3SXO(n4YCxpeVd*Yvgn9}`XU#sQTCN7W3_jv z51uhSVuR*-J~@?P&YhL3E1>{acbWPwrK1fPwqmeoP^%sO_Be#3h8~5av$zEE$u<;T z%=n20pJ%>Wt#vWt6_n}*K!~T

a{vemzlrUD8EidqhE`LYORE>2FXcPrWQ8lqbGc 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 T(`BgmpC(hyrX)^U=>2~Hpx0Dl literal 0 HcmV?d00001 diff --git a/docs/developers_notes/classes_nemos.svg b/docs/developers_notes/classes_nemos.svg new file mode 100644 index 00000000..9933cbf5 --- /dev/null +++ b/docs/developers_notes/classes_nemos.svg @@ -0,0 +1,653 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + nemos.base_regressor.BaseRegressor + + BaseRegressor + + + + nemos.base_class.Base + + Base + + + nemos.glm.GLM + + GLM + + + + nemos.observation_models.GammaObservations + + GammaObservations + + + nemos.observation_models.Observations + + Observations + + + nemos.observation_models.PoissonObservations + + PoissonObservations + + + + + + + + + nemos.regularizer.Regularizer + + Regularizer + + + nemos.regularizer.GroupLasso + + GroupLasso + + + nemos.regularizer.Lasso + + Lasso + + + nemos.regularizer.Ridge + + Ridge + + + nemos.regularizer.UnRegularized + + UnRegularized + + + + + + + + + + From b762094ff3bf652da3dd284a99a30fae27f19e01 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 25 Jul 2024 10:58:39 -0400 Subject: [PATCH 140/225] Update CONTRIBUTING.md Co-authored-by: William F. Broderick --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04451e83..e9d79075 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ cd nemos pip install -e .[dev] ``` -> **_NOTE:_** In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that +> [!NOTE] In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that > provides guidance on how to create and activate a virtual environment. 3) Add the upstream branch: From 55f621869339bb4da1c705e0f94de720235783c2 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 25 Jul 2024 10:59:13 -0400 Subject: [PATCH 141/225] Update CONTRIBUTING.md Co-authored-by: William F. Broderick --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9d79075..6b8ef8c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,15 +66,15 @@ your own branch, run the following from within your `nemos` directory: > changes from different feature branches that are then all merged into the `main` branch at one time (normally at the time of a release) :D ```bash -# switch to the development branch +# switch to the development branch on your local copy git checkout development -# update your development branch +# update your local copy from your fork git pull origin development -# sync with upstream development +# sync your local copy with upstream development git pull upstream development # update your fork's development branch with any changs from upstream git push origin development -# create and switch to a new branch +# create and switch to a new branch, where you'll work on your new feature git checkout -b my_feature_branch ``` From b9fd3f092cce72ae99d07ff564224964bb1ee794 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 11:04:14 -0400 Subject: [PATCH 142/225] modified notes --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b8ef8c2..ff9b1839 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ this make it easy to keep your `nemos` up-to-date with the canonical version by As mentioned previously, each feature in `nemos` is worked on in a separate branch. This allows multiple people developing multiple features simultaneously, without interfering with each other's work. To create your own branch, run the following from within your `nemos` directory: -> **_NOTE:_** Below we are checking out the `development` branch. In terms of the `nemos` contribution workflow cycle, the `development` branch accumulates a series of +> [!NOTE] Below we are checking out the `development` branch. In terms of the `nemos` contribution workflow cycle, the `development` branch accumulates a series of > changes from different feature branches that are then all merged into the `main` branch at one time (normally at the time of a release) :D ```bash @@ -105,11 +105,11 @@ isort src flake8 --config=tox.ini src ``` -> **_INFO:_** [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically +> [!INFO] [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically > reformat your code and organize your imports, respectively. [`flake8`](https://flake8.pycqa.org/en/stable/#) does not modify your > code directly; instead, it identifies syntax errors and code complexity issues that need to be addressed manually. -> **_NOTE:_** If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. +> [!NOTE] If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. Now you are ready to make a Pull Request. You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo after pushing to your branch (see [here](https://intersect-training.org/collaborative-git/03-pr/index.html) for a tutorial). From 393b8829f1cd9049a605777a58bd4c6615d11f5c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 11:10:11 -0400 Subject: [PATCH 143/225] fixed gitflow info --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff9b1839..f305e57e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,9 @@ In order to contribute, you will need to do the following: 2) Make sure that tests pass and code coverage is maintained 3) Open a Pull Request -The NeMoS package follows the [GitHub Flow](https://www.gitkraken.com/learn/git/best-practices/git-branch-strategy#github-flow-branch-strategy) workflow. In essence, this means no one is allowed to -push to the `main` branch and all development happens in separate feature branches that are then merged into `main` once we have determined they are ready. When enough changes have accumulated, we -put out a new release, adding a new tag which increments the version number, and upload the new release to PyPI. +The NeMoS package follows the [GitHub Flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) workflow. In essence, there are two primary branches, `main` and `development`, to which no one is allowed to +push directly. All development happens in separate feature branches that are then merged into `development` once we have determined they are ready. When enough changes have accumulated, `developemnt` is merged into `main`, and a new release is +generated. This process includes adding a new tag to increment the version number and uploading the new release to PyPI. #### Creating a development environment From ddfed693ff095460cadd741c08c500efe4347830 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 11:14:21 -0400 Subject: [PATCH 144/225] fixed notes --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f305e57e..3b30fb96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,10 +150,10 @@ If you're adding a substantial bunch of tests that are separate from the existin it must have an `.py` extension, and it must be contained within the `tests` directory. Assuming you do that, our github actions will automatically find it and add it to the tests-to-run. -> **_NOTE:_** If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official +> [!NOTE] If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official > documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html). -> **_NOTE_** If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to +> [!NOTE] If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to > load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official documentation > [here](https://docs.pytest.org/en/stable/how-to/fixtures.html). From 15008f4ccb3d5202e4243aa7348015c366a6b394 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:12:08 -0400 Subject: [PATCH 145/225] added legend --- docs/developers_notes/classes_nemos.png | Bin 329624 -> 375676 bytes docs/developers_notes/classes_nemos.svg | 622 ++++++++++++++---------- 2 files changed, 354 insertions(+), 268 deletions(-) diff --git a/docs/developers_notes/classes_nemos.png b/docs/developers_notes/classes_nemos.png index 357b61ed0caffe93824d8255d3c7a2857dcf942a..187ab3b15fa598edc029608601c56119c478dab6 100644 GIT binary patch literal 375676 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

^NgWzC+q({=`n5sE@u|OAxz)j-S&$ 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%0DS&J*rAZv-MGk;?ZpEP4^7+zV3iaP_I_l~QUT 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 p5A_D)3;Q}+$CL##R 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 z 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�}lI% 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

+# +#
Schematic of a scikit-learn pipeline.
+#
+# # A pipeline is a sequence of data transformations. Each step in the pipeline transforms the input data into a different representation. The final step is either another transformation or a model step that fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. # # To set up a scikit-learn `Pipeline`, ensure that: diff --git a/docs/assets/pipeline.svg b/docs/assets/pipeline.svg new file mode 100644 index 00000000..8c67c7ab --- /dev/null +++ b/docs/assets/pipeline.svg @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + . . . + Transformer 1 + Transformer k + Estimator(GLM) + Pipeline + + + From 7b5b4db0d836399e9e71da9dc5ce3180a822b607 Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Fri, 2 Aug 2024 12:15:10 -0400 Subject: [PATCH 184/225] Improve docstrings --- src/nemos/basis.py | 78 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index e026b9ae..10752e37 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -275,14 +275,15 @@ def __getattr__(self, name: str): """ Enable easy access to attributes of the underlying Basis object. - Example - ------- - from nemos import basis - - bas = basis.RaisedCosineBasisLinear(5) - trans_bas = basis.TransformerBasis(bas) - print(bas.n_basis_funcs) - print(trans_bas.n_basis_funcs) + Examples + -------- + >>> from nemos import basis + >>> bas = basis.RaisedCosineBasisLinear(5) + >>> trans_bas = basis.TransformerBasis(bas) + >>> bas.n_basis_funcs + 5 + >>> trans_bas.n_basis_funcs + 5 """ return getattr(self._basis, name) @@ -292,15 +293,28 @@ def __setattr__(self, name: str, value) -> None: Setting any other attribute is not allowed. - Example + Returns ------- - trans_bas = nmo.basis.TransformerBasis(nmo.basis.MSplineBasis(10)) - # allowed - trans_bas._basis = nmo.basis.BSplineBasis(10) - # allowed - trans_bas.n_basis_funcs = 20 - # not allowed - tran_bas.random_attribute_name = "some value" + None + + Raises + ------ + ValueError + If the attribute being set is not `_basis` or an attribute of `_basis`. + + Examples + -------- + >>> import nemos as nmo + >>> trans_bas = nmo.basis.TransformerBasis(nmo.basis.MSplineBasis(10)) + >>> # allowed + >>> trans_bas._basis = nmo.basis.BSplineBasis(10) + >>> # allowed + >>> trans_bas.n_basis_funcs = 20 + >>> # not allowed + >>> tran_bas.random_attribute_name = "some value" + Traceback (most recent call last): + ... + ValueError: Only setting _basis or existing attributes of _basis is allowed. """ # allow self._basis = basis if name == "_basis": @@ -334,8 +348,8 @@ def set_params(self, **parameters) -> TransformerBasis: When used with, sklearn.model_selection, either set the _basis attribute directly, or set the parameters of the underlying Basis, but doing both at the same time is not allowed. - Example - ------- + Examples + -------- >>> from nemos.basis import BSplineBasis, MSplineBasis, TransformerBasis >>> basis = MSplineBasis(10) >>> transformer_basis = TransformerBasis(basis=basis) @@ -348,7 +362,6 @@ def set_params(self, **parameters) -> TransformerBasis: >>> # mixing is not allowed, this will raise an exception >>> transformer_basis.set_params(_basis=BSplineBasis(10), n_basis_funcs=2) - """ new_basis = parameters.pop("_basis", None) if new_basis is not None: @@ -974,7 +987,32 @@ def __pow__(self, exponent: int) -> MultiplicativeBasis: return result def to_transformer(self) -> TransformerBasis: - """Turn the Basis into a TransformerBasis for use with scikit-learn.""" + """ + Turn the Basis into a TransformerBasis for use with scikit-learn. + + Examples + -------- + Jointly cross-validating basis and GLM parameters with scikit-learn. + + >>> import nemos as nmo + >>> from sklearn.pipeline import Pipeline + >>> from sklearn.model_selection import GridSearchCV + >>> basis = nmo.basis.RaisedCosineBasisLinear(10) + >>> glm = nmo.glm.GLM(regularizer="Ridge") + >>> pipeline = Pipeline([("basis", basis), ("glm", glm)]) + >>> param_grid = dict( + ... glm__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + ... basis__n_basis_funcs=(3, 5, 10, 20, 100), + ... ) + >>> gridsearch = GridSearchCV( + ... pipeline, + ... param_grid=param_grid, + ... cv=5, + ... scoring=pseudo_r2, + ... ) + >>> gridsearch.fit() + """ + return TransformerBasis(copy.deepcopy(self)) From 058a738d48ad815443f6e367a67516884fbfce16 Mon Sep 17 00:00:00 2001 From: "William F. Broderick" Date: Mon, 5 Aug 2024 11:07:28 -0400 Subject: [PATCH 185/225] fix name of workflow --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8f5a7af..7a05e45a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ In order to contribute, you will need to do the following: 2) Make sure that tests pass and code coverage is maintained 3) Open a Pull Request -The NeMoS package follows the [GitHub Flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) workflow. In essence, there are two primary branches, `main` and `development`, to which no one is allowed to +The NeMoS package follows the [Git Flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) workflow. In essence, there are two primary branches, `main` and `development`, to which no one is allowed to push directly. All development happens in separate feature branches that are then merged into `development` once we have determined they are ready. When enough changes have accumulated, `developemnt` is merged into `main`, and a new release is generated. This process includes adding a new tag to increment the version number and uploading the new release to PyPI. From 0109eb544ef6cbd5cf33ee480c300714d3247e4a Mon Sep 17 00:00:00 2001 From: "William F. Broderick" Date: Mon, 5 Aug 2024 11:07:36 -0400 Subject: [PATCH 186/225] fix alert syntax --- CONTRIBUTING.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a05e45a..8db9f289 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,8 @@ cd nemos pip install -e .[dev] ``` -> [!NOTE] In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that -> provides guidance on how to create and activate a virtual environment. +> [!NOTE] +> In order to install `nemos` in editable mode you will need a Python virtual environment. Please see our documentation [here](https://nemos.readthedocs.io/en/latest/installation/) that provides guidance on how to create and activate a virtual environment. 3) Add the upstream branch: @@ -62,8 +62,8 @@ this make it easy to keep your `nemos` up-to-date with the canonical version by As mentioned previously, each feature in `nemos` is worked on in a separate branch. This allows multiple people developing multiple features simultaneously, without interfering with each other's work. To create your own branch, run the following from within your `nemos` directory: -> [!NOTE] Below we are checking out the `development` branch. In terms of the `nemos` contribution workflow cycle, the `development` branch accumulates a series of -> changes from different feature branches that are then all merged into the `main` branch at one time (normally at the time of a release) :D +> [!NOTE] +> Below we are checking out the `development` branch. In terms of the `nemos` contribution workflow cycle, the `development` branch accumulates a series of changes from different feature branches that are then all merged into the `main` branch at one time (normally at the time of a release). ```bash # switch to the development branch on your local copy @@ -105,11 +105,11 @@ isort src flake8 --config=tox.ini src ``` -> [!IMPORTANT] [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically -> reformat your code and organize your imports, respectively. [`flake8`](https://flake8.pycqa.org/en/stable/#) does not modify your -> code directly; instead, it identifies syntax errors and code complexity issues that need to be addressed manually. +> [!IMPORTANT] +> [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically reformat your code and organize your imports, respectively. [`flake8`](https://flake8.pycqa.org/en/stable/#) does not modify your code directly; instead, it identifies syntax errors and code complexity issues that need to be addressed manually. -> [!NOTE] If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. +> [!NOTE] +> If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. Now you are ready to make a Pull Request. You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo after pushing to your branch (see [here](https://intersect-training.org/collaborative-git/03-pr/index.html) for a tutorial). @@ -150,12 +150,11 @@ If you're adding a substantial bunch of tests that are separate from the existin it must have an `.py` extension, and it must be contained within the `tests` directory. Assuming you do that, our github actions will automatically find it and add it to the tests-to-run. -> [!NOTE] If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official -> documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html) and NeMoS [`test_error_invalid_entry`](https://github.com/flatironinstitute/nemos/blob/main/tests/test_vallidation.py#L27) for a concrete implementation. +> [!NOTE] +> If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html) and NeMoS [`test_error_invalid_entry`](https://github.com/flatironinstitute/nemos/blob/main/tests/test_vallidation.py#L27) for a concrete implementation. -> [!NOTE] If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to -> load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official documentation -> [here](https://docs.pytest.org/en/stable/how-to/fixtures.html). +> [!NOTE] +> If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official documentation [here](https://docs.pytest.org/en/stable/how-to/fixtures.html). ### Documentation From 7e2be08eb3c6e7a77e25beba2c69155aca6736b6 Mon Sep 17 00:00:00 2001 From: "William F. Broderick" Date: Mon, 5 Aug 2024 11:12:31 -0400 Subject: [PATCH 187/225] adds missing word --- docs/developers_notes/06-glm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/06-glm.md b/docs/developers_notes/06-glm.md index 9f9110e2..c5104022 100644 --- a/docs/developers_notes/06-glm.md +++ b/docs/developers_notes/06-glm.md @@ -85,7 +85,7 @@ The `PopulationGLM` class is an extension of the `GLM`, designed to fit multiple When crafting a functional (i.e., concrete) GLM class: -- You **must** inherit from or one of its derivatives. +- You **must** inherit from `GLM` or one of its derivatives. - If you inherit directly from `BaseRegressor`, you **must** implement all the abstract methods, see the [`BaseRegressor` page](03-base_regressor.md) for more details. - If you inherit `GLM` or any of the other concrete classes directly, there won't be any abstract methods. - You **may** embed additional parameter and input checks if required by the specific GLM subclass. From a6d5a4b13afd5286865e3ab3831580df92b13b78 Mon Sep 17 00:00:00 2001 From: "William F. Broderick" Date: Mon, 5 Aug 2024 11:17:27 -0400 Subject: [PATCH 188/225] small text fix --- docs/developers_notes/03-base_regressor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 6028e1a0..d5c87122 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -35,7 +35,7 @@ Public attributes are stored as properties: - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. - `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. -When implementing a `BaseRegressor`, the only attributes you must interact directly with are the operates the solver, i.e. `solver_init_state`, `solver_update`, `solver_run`. +When implementing a new subclass of `BaseRegressor`, the only attributes you must interact directly with are those that operate on the solver, i.e. `solver_init_state`, `solver_update`, `solver_run`. Typically, in `YourRegressor` you will call `self.solver_init_state` at the parameter initialization step, `self.sovler_run` in `fit`, and `self.solver_update` in `update`. !!! note "Solvers" From 17d1a2433974926f208954d6055ac0a9828a9426 Mon Sep 17 00:00:00 2001 From: "William F. Broderick" Date: Mon, 5 Aug 2024 11:20:46 -0400 Subject: [PATCH 189/225] describe releases --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8db9f289..25b94e89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,6 +132,17 @@ The next section will talk about the style of your code and specific requirement - Longer, descriptive names are preferred (e.g., x is not an appropriate name for a variable), especially for anything user-facing, such as methods, attributes, or arguments. - Any public method or function must have a complete type-annotated docstring (see below for details). Hidden ones do not need to have complete docstrings, but they probably should. +### Releases + +We create releases on Github, deploy on / distribute via [pypi](https://pypi.org/), and try to follow [semantic versioning](https://semver.org/): + +> Given a version number MAJOR.MINOR.PATCH, increment the: +> 1. MAJOR version when you make incompatible API changes +> 2. MINOR version when you add functionality in a backward compatible manner +> 3. PATCH version when you make backward compatible bug fixes + +ro release a new version, we [create a Github release](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) with a new tag incrementing the version as described above. Creating the Github release will trigger the deployment to pypi, via our `deploy` action (found in `.github/workflows/deploy-pure-python.yml`). The built version will grab the version tag from the Github release, using [setuptools_scm](https://github.com/pypa/setuptools_scm). + ### Testing To run all tests, run `pytest` from within the main `nemos` repository. This may take a while as there are many tests, broken into several categories. From ce30a414d8d5d212fc6cb57483d2c866b53072af Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 6 Aug 2024 14:47:44 -0400 Subject: [PATCH 190/225] added note on tox --- CONTRIBUTING.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25b94e89..136f1a54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,7 +111,7 @@ flake8 --config=tox.ini src > [!NOTE] > If some files were reformatted after running `black`, make sure to commit those changes and push them to your feature branch as well. -Now you are ready to make a Pull Request. You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo +Now you are ready to make a Pull Request (PR). You can open a pull request by clicking on the big `Compare & pull request` button that appears at the top of the `nemos` repo after pushing to your branch (see [here](https://intersect-training.org/collaborative-git/03-pr/index.html) for a tutorial). Your pull request should include the following: @@ -123,6 +123,16 @@ Next, we will be notified of the pull request and will read it over. We will try you will probably need to respond to our comments, making changes as appropriate. We'll then respond again, and proceed in an iterative fashion until everyone is happy with the proposed changes. +Additionally, every PR to `main` or `development` will automatically run linters and tests through a [GitHub action](https://docs.github.com/en/actions). Merges can happen only when all check passes. + +> [!NOTE] +> The [NeMoS GitHub action](.github/workflows/ci.yml) runs tests in an isolated environment using [`tox`](https://tox.wiki/en/). `tox` is not installed by default as an optional dependency. If you want to replicate the action workflow locally, you need to install `tox` via pip and then run it. From the package directory: +> ```sh +> pip install tox +> tox -e py +> ``` +> This will execute `tox` with a Python version that matches your local environment. `tox` configurations can be found in the [`tox.ini`](tox.ini) file. + Once your changes are integrated, you will be added as a GitHub contributor and as one of the authors of the package. Thank you for being part of `nemos`! ### Style Guide From 7d47df52f8c8e5bdb3f39f503b55abd7caec4285 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 6 Aug 2024 15:00:19 -0400 Subject: [PATCH 191/225] updated tox ini --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 6b9cb384..7295d866 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = True -envlist = py38, py39, py310 +envlist = py39, py310, py311 [testenv] # means we'll run the equivalent of `pip install .[dev]`, also installing pytest @@ -23,9 +23,9 @@ commands = [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [flake8] From 47bed5183791f5e01669513108fb9c319f5fb1a1 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 08:50:33 -0400 Subject: [PATCH 192/225] Update CONTRIBUTING.md Co-authored-by: William F. Broderick --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 136f1a54..3bcc3e63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,7 +126,7 @@ changes. Additionally, every PR to `main` or `development` will automatically run linters and tests through a [GitHub action](https://docs.github.com/en/actions). Merges can happen only when all check passes. > [!NOTE] -> The [NeMoS GitHub action](.github/workflows/ci.yml) runs tests in an isolated environment using [`tox`](https://tox.wiki/en/). `tox` is not installed by default as an optional dependency. If you want to replicate the action workflow locally, you need to install `tox` via pip and then run it. From the package directory: +> The [NeMoS GitHub action](.github/workflows/ci.yml) runs tests in an isolated environment using [`tox`](https://tox.wiki/en/). `tox` is not included in our optional dependencies, so if you want to replicate the action workflow locally, you need to install `tox` via pip and then run it. From the package directory: > ```sh > pip install tox > tox -e py From 74303402de7c4c56541bb532591958e772348b19 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 08:51:17 -0400 Subject: [PATCH 193/225] Update docs/developers_notes/04-regularizer.md Co-authored-by: William F. Broderick --- docs/developers_notes/04-regularizer.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index 0158c075..aecd6faa 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -57,10 +57,13 @@ When developing a functional (i.e., concrete) `Regularizer` class: - **Must** define a default solver and a tuple of allowed solvers. - **May** require extra initialization parameters, like the `mask` argument of `GroupLasso`. -!!! tip - It is important to verify that minimizing the penalized loss directly or through the proximal operator - with the `ProximalGradient` algorithm result in the same model parameters on a convex problem (up to numerical - precision). When the regularization scheme is differentiable, you can use `GradientDescent` over the penalized - loss. For non-smooth penalization, you can use non-gradient based method like `Nelder-Mead` - from [`scipy.optimize.minimize`](https://docs.scipy.org/doc/scipy/reference/optimize.minimize-neldermead.html). - You can refer to NeMoS `test_lasso_convergence` from `tests/test_convergence.py` for a concrete implementation. +!!! info + When adding a new regularizer, you must include a convergence test, which verifies that + the model parameters the regularizer finds for a convex problem such as the GLM are identical + whether one minimizes the penalized loss directly and uses the proximal operator (i.e., when + using `ProximalGradient`). In practice, this means you should test the result of the `ProximalGradient` + optimization against that of either `GradientDescent` (if your regularization is differentiable) or + `Nelder-Mead` from [`scipy.optimize.minimize`](https://docs.scipy.org/doc/scipy/reference/optimize.minimize-neldermead.html) + (or another non-gradient based method, if your regularization is non-differentiable). You can refer to NeMoS `test_lasso_convergence` + from `tests/test_convergence.py` for a concrete example. + From 7015833aff11cda7d7583684fc5bfa0d2a17c81d Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:25:34 -0400 Subject: [PATCH 194/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index c2ee5522..d8842eaf 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -20,7 +20,7 @@ #
Schematic of a scikit-learn pipeline.
#

a{vemzlrUD8EidqhE`LYORE>2FXcPrWQ8lqbGc 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 T(`BgmpC(hyrX)^U=>2~Hpx0Dl diff --git a/docs/developers_notes/classes_nemos.svg b/docs/developers_notes/classes_nemos.svg index 9933cbf5..fae37888 100644 --- a/docs/developers_notes/classes_nemos.svg +++ b/docs/developers_notes/classes_nemos.svg @@ -5,13 +5,16 @@ + + + + + - - nemos.base_regressor.BaseRegressor - - BaseRegressor - - - - nemos.base_class.Base - - Base - - - nemos.glm.GLM - - GLM - - - - nemos.observation_models.GammaObservations - - GammaObservations - - - nemos.observation_models.Observations - - Observations - - - nemos.observation_models.PoissonObservations - - PoissonObservations - + + BaseRegressor + + Base + + GLM + + GammaObservations + + Observations + + PoissonObservations + transform="matrix(0.90155898,0,0,0.93034858,-509.94253,44.644756)" /> + transform="matrix(0.95231596,0,0,0.94687851,-545.23069,39.244466)" /> + transform="matrix(1,0,0,-1,-630.65791,489.95739)" /> + + + transform="translate(-630.65791,26.398824)" /> - - - nemos.regularizer.Regularizer - - Regularizer - - - nemos.regularizer.GroupLasso - - GroupLasso - - - nemos.regularizer.Lasso - - Lasso - - - nemos.regularizer.Ridge - - Ridge - - - nemos.regularizer.UnRegularized - - UnRegularized - - - - - - - + transform="translate(-630.65791,24.898549)" /> + + Regularizer + + GroupLasso + + Lasso + + Ridge + + UnRegularized + + + + + + transform="translate(-630.65791,20.397727)" /> + transform="translate(-630.65791,20.397727)" /> + A is an attribute of B + A is a subclass if B + A + A + B: + B: + Legend From 7753105eaad9a5186898cce0551b7d4eb4d2c5b7 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:16:15 -0400 Subject: [PATCH 146/225] added example --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b30fb96..4dc11dab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,7 +151,7 @@ it must have an `.py` extension, and it must be contained within the `tests` dir add it to the tests-to-run. > [!NOTE] If you have many variants on a test you wish to run, you should make use of pytest's `parameterize` mark. See the official -> documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html). +> documentation [here](https://docs.pytest.org/en/stable/how-to/parametrize.html) and NeMoS [`test_error_invalid_entry`](https://github.com/flatironinstitute/nemos/blob/main/tests/test_vallidation.py#L27) for a concrete implementation. > [!NOTE] If you are using an object that gets used in multiple tests (such as a model with certain data, regularizer, or solver), you should use pytest's `fixtures` to avoid having to > load or instantiate the object multiple times. Look at our `conftest.py` to see already available fixtures for your tests. See the official documentation From 07c34946908ab63297ec5ca16f4be397b721588b Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:26:04 -0400 Subject: [PATCH 147/225] added more info on docs --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4dc11dab..7c4c3cf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,7 +182,9 @@ We follow the [numpydoc](https://numpydoc.readthedocs.io/en/latest/) conventions If your changes are significant (add a new functionality or drastically change the current codebase), then the current examples may need to be updated or a new example may need to be added. -All examples live within the `docs/` subfolder of `nemos`. +All examples live within the `docs/` subfolder of `nemos`. These are written as `.py` files but are converted to +notebooks by [`mkdocs-gallery`](https://smarie.github.io/mkdocs-gallery/), and have a special syntax, see this [example +gallery](https://smarie.github.io/mkdocs-gallery/generated/gallery/). To see if changes you have made break the current documentation, you can build the documentation locally. From da1ba916577e2e161964d195c0c154ae18c4c1a9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:32:59 -0400 Subject: [PATCH 148/225] added more info on ipynb --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c4c3cf9..e714e263 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -183,9 +183,11 @@ If your changes are significant (add a new functionality or drastically change t a new example may need to be added. All examples live within the `docs/` subfolder of `nemos`. These are written as `.py` files but are converted to -notebooks by [`mkdocs-gallery`](https://smarie.github.io/mkdocs-gallery/), and have a special syntax, see this [example +notebooks by [`mkdocs-gallery`](https://smarie.github.io/mkdocs-gallery/), and have a special syntax, as demonstrated in this [example gallery](https://smarie.github.io/mkdocs-gallery/generated/gallery/). +We avoid using `.ipynb` notebooks directly because their JSON-based format makes them difficult to read, interpret, and resolve merge conflicts in version control. + To see if changes you have made break the current documentation, you can build the documentation locally. ```bash From f29c8e1694dfd39bf8a2fee1ba345d7759d5990f Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:39:32 -0400 Subject: [PATCH 149/225] added info on methods --- docs/developers_notes/03-base_regressor.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 58875a37..28241209 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -4,8 +4,8 @@ !!! Example - The current package version includes a concrete class named `nemos.glm.GLM`. This class inherits from `BaseRegressor`, which in turn inherits `Base`, since it falls under the " GLM regression" category. - As any `BaseRegressor`, it **must** implement the `fit`, `score`, `predict` methods. + The current package version includes a concrete class named `nemos.glm.GLM`. This class inherits from `BaseRegressor`, which in turn inherits `Base`, since it falls under the "GLM regression" category. + As a `BaseRegressor`, it **must** implement the `fit`, `score`, `predict` and the other abstract methods of this class, see below. ### Abstract Methods @@ -16,12 +16,14 @@ For subclasses derived from `BaseRegressor` to function correctly, they must imp 3. `score`: Score the accuracy of model predictions using input data `X` against the actual observations `y`. 4. `simulate`: Simulate data based on the trained regression model. 5. `update`: Run a single optimization step, and stores the updated parameter and solver state. Used by stochastic optimization schemes. -6. `_predict_and_compute_loss`: Compute prediction and evaluates the loss function prvided the parameters and `X` and `y`. +6. `_predict_and_compute_loss`: Compute prediction and evaluates the loss function prvided the parameters and `X` and `y`. This is used by the `instantiate_solver` method which prepares the solver. 7. `_check_params`: Check the parameter structure. 8. `_check_input_dimensionality`: Check the input dimensionality matches model expectation. 9. `_check_input_and_params_consistency`: Checks that the input and the parameters are consistent. 10. `_get_coef_and_intercept` and `_set_coef_and_intercept`: set and get model coefficient and intercept term. +All the `_check_` methods are called by the `_validate` method which checks that the provided +input and parameters conform with the model requirements. ### Attributes From 1fb8f46fec496ef387455afc3dfa3ee7c21f9d58 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 12:41:18 -0400 Subject: [PATCH 150/225] typo --- docs/developers_notes/03-base_regressor.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 28241209..3650a95a 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -16,7 +16,7 @@ For subclasses derived from `BaseRegressor` to function correctly, they must imp 3. `score`: Score the accuracy of model predictions using input data `X` against the actual observations `y`. 4. `simulate`: Simulate data based on the trained regression model. 5. `update`: Run a single optimization step, and stores the updated parameter and solver state. Used by stochastic optimization schemes. -6. `_predict_and_compute_loss`: Compute prediction and evaluates the loss function prvided the parameters and `X` and `y`. This is used by the `instantiate_solver` method which prepares the solver. +6. `_predict_and_compute_loss`: Compute prediction and evaluates the loss function prvided the parameters and `X` and `y`. This is used by the `instantiate_solver` method which sets up the solver. 7. `_check_params`: Check the parameter structure. 8. `_check_input_dimensionality`: Check the input dimensionality matches model expectation. 9. `_check_input_and_params_consistency`: Checks that the input and the parameters are consistent. @@ -35,7 +35,7 @@ Public attributes are stored as properties: - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. - `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. -!!! note "Sovlers" +!!! note "Solvers" Solvers are typically optimizers from the `jaxopt` package, but in principle they could be custom optimization routines as long as they respect the `jaxopt` api (i.e., have a `run`, `init_state`, and `update` method with the appropriate input/output types). We choose to rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. In the future we will provide a number of custom solvers optimized for convex stochastic optimization. From fc27bb4f3733469e6286db33add3253f568f65a5 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:01:40 -0400 Subject: [PATCH 151/225] update called --- docs/developers_notes/03-base_regressor.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 3650a95a..a2094c09 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -35,6 +35,9 @@ Public attributes are stored as properties: - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. - `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. +When implementing a `BaseRegressor`, the only attributes you must interact directly with are the operates the solver, i.e. `solver_init_state`, `solver_update`, `solver_run`. +Typically, in `YourRegressor` you will call `self.solver_init_state` at the parameter initialization step, `self.sovler_run` in `fit`, and `self.solver_update` in `update`. + !!! note "Solvers" Solvers are typically optimizers from the `jaxopt` package, but in principle they could be custom optimization routines as long as they respect the `jaxopt` api (i.e., have a `run`, `init_state`, and `update` method with the appropriate input/output types). We choose to rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. In the future we will provide a number of custom solvers optimized for convex stochastic optimization. From 91611d98c80a67532e819e8e48c7a830eb47b76a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:07:50 -0400 Subject: [PATCH 152/225] describe the attrs and how they should be used --- docs/developers_notes/03-base_regressor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index a2094c09..6028e1a0 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -40,7 +40,7 @@ Typically, in `YourRegressor` you will call `self.solver_init_state` at the para !!! note "Solvers" Solvers are typically optimizers from the `jaxopt` package, but in principle they could be custom optimization routines as long as they respect the `jaxopt` api (i.e., have a `run`, `init_state`, and `update` method with the appropriate input/output types). - We choose to rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. In the future we will provide a number of custom solvers optimized for convex stochastic optimization. + We rely on `jaxopt` because it provides a comprehensive set of robust, GPU accelerated, batchable and differentiable optimizers in JAX, that are highly customizable. In the future we may provide a number of custom solvers optimized for convex stochastic optimization. ## Contributor Guidelines From d748877929ab86e3bb7ff465599f7c69f466368b Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:09:55 -0400 Subject: [PATCH 153/225] removed examples and concrete implementations --- docs/developers_notes/04-regularizer.md | 53 +------------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index e415b342..7d395111 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -46,58 +46,6 @@ The `UnRegularized` class extends the base `Regularizer` class and is designed s - **`get_proximal_operator`**: Returns the identity operator. -## The `Ridge` Class - -The `Ridge` class extends the `Regularizer` class to handle optimization problems with Ridge regularization. Ridge regularization adds a penalty to the loss function, proportional to the sum of squares of the model parameters, to prevent overfitting and stabilize the optimization. - -### Concrete Methods Specifics -- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of squares of the coefficients (excluding the intercept). -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 +\text{l2reg} \cdot ||y||_2^2,$$ where "l2reg" is the regularizer strength. - -### Example Usage - -```python -import nemos as nmo - -ridge = nmo.regularizer.Ridge() -model = nmo.glm.GLM(regularizer=ridge) -``` - -## `Lasso` Class - -The `Lasso` class enables optimization using the Lasso (L1 regularization) method with Proximal Gradient. - -### Concrete Methods Specifics -- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the absolute values of the coefficients (excluding the intercept). -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving $$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + \text{l1reg} \cdot ||y||_1,$$ where "l1reg" is the regularizer strength. - -## `GroupLasso` Class - -The `GroupLasso` class enables optimization using the Group Lasso regularization method with Proximal Gradient. It induces sparsity on groups of features rather than individual features. - -### Attributes: -- **`mask`**: A mask array indicating groups of features for regularization. - -### Concrete Methods Specifics -- **`penalized_loss`**: Returns the original loss penalized by a term proportional to the sum of the L2 norms of the coefficient vectors for each group defined by the `mask`. -- **`get_proximal_operator`**: Returns the ridge proximal operator, solving -$$\underset{y \ge 0}{\text{argmin}} ~ \frac{1}{2} ||x - y||_2^2 + \text{lgreg} \cdot \sum_j ||y^{(j)}||_2,$$ where "lgreg" is the regularizer strength, and $j$ runs over the coefficient groups. - - -### Example Usage -```python -import nemos as nmo -import numpy as np - -group_mask = np.zeros((2, 10)) # assume 2 groups and 10 features -group_mask[0, :4] = 1 # assume the first group consist of the first 4 coefficients -group_mask[1, 4:] = 1 # assume the second group consist of the last 6 coefficients -group_lasso = nmo.regularizer.GroupLasso(mask=group_mask) -model = nmo.glm.GLM(regularizer=group_lasso) -``` - - - ## Contributor Guidelines ### Implementing `Regularizer` Subclasses @@ -107,4 +55,5 @@ When developing a functional (i.e., concrete) `Regularizer` class: - **Must** inherit from `Regularizer` or one of its derivatives. - **Must** implement the `penalized_loss` and `get_proximal_operator` methods. - **Must** define a default solver and a tuple of allowed solvers. +- **May** require extra initialization parameters, like the `mask` argument of `GroupLasso`. From 2ee3fde08b534a616d7012a64795f7192d78c0bc Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:10:20 -0400 Subject: [PATCH 154/225] removed examples and concrete implementations --- docs/developers_notes/05-observation_models.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/developers_notes/05-observation_models.md b/docs/developers_notes/05-observation_models.md index 1417e92a..e0c3fe32 100644 --- a/docs/developers_notes/05-observation_models.md +++ b/docs/developers_notes/05-observation_models.md @@ -29,18 +29,6 @@ For subclasses derived from `Observations` to function correctly, they must impl - **pseudo_r2**: Method for computing the pseudo-$R^2$ of the model based on the residual deviance. There is no consensus definition for the pseudo-$R^2$, what we used here is the definition by Cohen at al. 2003[^2]. - **check_inverse_link_function**: Check that the link function is a auto-differentiable, vectorized function form $\mathbb{R} \longrightarrow \mathbb{R}$. - -## Concrete classes - -### `PoissonObservations` - -The `PoissonObservations` is designed for modeling observed spike counts based on a Poisson distribution with a given rate. - -### `GammaObservations` - -The `GammaObservations` is designed for modeling continuous calcium/voltage traces based on a Gamma distribution with a given rate. - - ## Contributor Guidelines To implement an observation model class you From ec9b0782314b568f4721738a1d458ea9cdb6eeb9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:20:17 -0400 Subject: [PATCH 155/225] improve guidelines --- docs/developers_notes/06-glm.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/developers_notes/06-glm.md b/docs/developers_notes/06-glm.md index 9afd7aab..9f9110e2 100644 --- a/docs/developers_notes/06-glm.md +++ b/docs/developers_notes/06-glm.md @@ -39,7 +39,7 @@ The `GLM` class provides a direct implementation of the GLM model and is designe - **`coef_`**: Stores the solution for spike basis coefficients as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. - **`intercept_`**: Stores the bias terms' solutions as `jax.ndarray` after the fitting process. It is initialized as `None` during class instantiation. - **`dof_resid_`**: The degrees of freedom of the model's residual. this quantity is used to estimate the scale parameter, see below, and compute frequentist confidence intervals. -- **`scale_`**: The scale parameter of the observation distribution, which togheter with the rate, uniquely specifies a disribution of the exponential family. Example: a 1D Gaussian is specified by the mean which is the rate, and the standard deviation, which is the scale. +- **`scale_`**: The scale parameter of the observation distribution, which together with the rate, uniquely specifies a distribution of the exponential family. Example: a 1D Gaussian is specified by the mean which is the rate, and the standard deviation, which is the scale. - **`solver_state_`**: Indicates the solver's state. For specific solver states, refer to the [`jaxopt` documentation](https://jaxopt.github.io/stable/index.html#). Additionally, the `GLM` class inherits the attributes of `BaseRegressor`, see the [relative note](03-base_regressor.md) for more information. @@ -56,7 +56,7 @@ Additionally, the `GLM` class inherits the attributes of `BaseRegressor`, see th ### Private Methods -Here we list the private method related to the model compuations: +Here we list the private method related to the model computations: - **`_predict`**: Forecasts rates based on current model parameters and the inverse-link function of the `observation_models`. - **`_predict_and_compute_loss`**: Predicts the rate and calculates the mean Poisson negative log-likelihood, excluding normalization constants. @@ -81,11 +81,13 @@ The `PopulationGLM` class is an extension of the `GLM`, designed to fit multiple ## Contributor Guidelines -### Implementing GLM Subclasses +### Implementing a `BaseRegressor` Subclasses When crafting a functional (i.e., concrete) GLM class: -- **Must** inherit from `BaseRegressor` or one of its derivatives. -- **Must** realize all abstract methods. -- **May** embed additional parameter and input checks if required by the specific GLM subclass. -- **May** override some of the computations if needed by the model specifications. +- You **must** inherit from or one of its derivatives. +- If you inherit directly from `BaseRegressor`, you **must** implement all the abstract methods, see the [`BaseRegressor` page](03-base_regressor.md) for more details. +- If you inherit `GLM` or any of the other concrete classes directly, there won't be any abstract methods. +- You **may** embed additional parameter and input checks if required by the specific GLM subclass. +- You **may** override some of the computations if needed by the model specifications. + From 48c33e84f4b0db92d626e450171daf7186596ff0 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 14:27:23 -0400 Subject: [PATCH 156/225] important mark --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e714e263..a8f5a7af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,7 @@ isort src flake8 --config=tox.ini src ``` -> [!INFO] [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically +> [!IMPORTANT] [`black`](https://black.readthedocs.io/en/stable/) and [`isort`](https://pycqa.github.io/isort/) automatically > reformat your code and organize your imports, respectively. [`flake8`](https://flake8.pycqa.org/en/stable/#) does not modify your > code directly; instead, it identifies syntax errors and code complexity issues that need to be addressed manually. From 8dd83f1635cf77b7d16b6e2689702922ddee452b Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 15:18:43 -0400 Subject: [PATCH 157/225] linted --- src/nemos/basis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index ef3d2a86..8d699163 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1767,7 +1767,6 @@ def __init__( # must be rescaled to 0 and 1. self._rescale_samples = True - @property def width(self): """Return width of the raised cosine.""" From 2be45741c8666710ad7444798a97932a888214ae Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 16:00:39 -0400 Subject: [PATCH 158/225] added example --- src/nemos/basis.py | 73 ++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 8d699163..009bc882 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -119,11 +119,43 @@ class TransformerBasis: transformations. It supports fitting to data (calculating any necessary parameters of the basis functions), transforming data (applying the basis functions to data), and both fitting and transforming in one step. + This could be extremely useful in combination with scikit-learn pipelining and + cross-validation, enabling the cross-validation of the basis type and parameters, + for example `n_basis_funcs`. See the example section below. Parameters ---------- basis : A concrete subclass of `Basis`. + + Examples + -------- + >>> from nemos.basis import BSplineBasis, TransformerBasis + >>> from nemos.glm import GLM + >>> from sklearn.pipeline import Pipeline + >>> from sklearn.model_selection import GridSearchCV + >>> import numpy as np + >>> np.random.seed(123) + + >>> # Generate data + >>> num_samples, num_features = 10000, 1 + >>> x = np.random.normal(size=(num_samples, )) # raw time series + >>> basis = BSplineBasis(10) + >>> features = basis.compute_features(x) # basis transformed time series + >>> weights = np.random.normal(size=basis.n_basis_funcs) # true weights + >>> y = np.random.poisson(np.exp(features.dot(weights))) # spike counts + + >>> # transformer can be used in pipelines + >>> transformer = TransformerBasis(basis) + >>> pipeline = Pipeline([ ("compute_features", transformer), ("glm", GLM()),]) + >>> pipeline.fit(x[:, None], y) # x need to be 2D for sklearn transformer API + >>> print(pipeline.predict(np.random.normal(size=(10, 1)))) # predict rate from new data + + >>> # 5-fold cross-validate the number of basis + >>> param_grid = dict(compute_features__n_basis_funcs=[4, 10]) + >>> grid_cv = GridSearchCV(pipeline, param_grid, cv=5) + >>> grid_cv.fit(x[:, None], y) + >>> print("Cross-validated number of basis:", grid_cv.best_params_) """ def __init__(self, basis: Basis): @@ -279,7 +311,7 @@ def __setattr__(self, name: str, value) -> None: def __sklearn_clone__(self) -> TransformerBasis: """ Customize how TransformerBasis objects are cloned when used with sklearn.model_selection. - By default scikit-learn tries to clone the object by calling __init__ using the output of get_params, + By default, scikit-learn tries to clone the object by calling __init__ using the output of get_params, which fails in our case. For more info: https://scikit-learn.org/stable/developers/develop.html#cloning @@ -295,39 +327,18 @@ def set_params(self, **parameters) -> TransformerBasis: Example ------- - pipeline = Pipeline( - [ - ("transformerbasis", basis.TransformerBasis(basis.MSplineBasis(10))), - ("glm", nmo.glm.GLM()), - ] - ) - # setting parameters of _basis is allowed - param_grid = dict( - transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), - ) + >>> from nemos.basis import BSplineBasis, MSplineBasis, TransformerBasis + >>> basis = MSplineBasis(10) + >>> transformer_basis = TransformerBasis(basis=basis) - # setting _basis directly is allowed - param_grid = dict( - transformerbasis___basis=( - nmo.basis.RaisedCosineBasisLinear(10), - nmo.basis.RaisedCosineBasisLog(10), - nmo.basis.MSplineBasis(10), - ) - ) + >>> # setting parameters of _basis is allowed + >>> print(transformer_basis.set_params(n_basis_funcs=8).n_basis_funcs) - # mixing the two doesn't work - param_grid = dict( - transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), - transformerbasis___basis=( - nmo.basis.RaisedCosineBasisLinear(10), - nmo.basis.RaisedCosineBasisLog(10), - nmo.basis.MSplineBasis(10), - ) - ) + >>> # setting _basis directly is allowed + >>> print(transformer_basis.set_params(_basis=BSplineBasis(10))._basis) - gridsearch = GridSearchCV( - pipeline, param_grid=param_grid, cv=5, scoring=pseudo_r2, n_jobs=4 - ) + >>> # mixing is not allowed, this will raise an exception + >>> transformer_basis.set_params(_basis=BSplineBasis(10), n_basis_funcs=2) """ new_basis = parameters.pop("_basis", None) From a29eb3e8b3c958ab7ee099d078c57d9953bbbcc3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 16:08:58 -0400 Subject: [PATCH 159/225] linted docstrings --- src/nemos/basis.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 009bc882..85552bd8 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -258,6 +258,7 @@ def __getstate__(self): def __setstate__(self, state): """ Define how to populate the object's state when unpickling. + Not that during unpickling a new object is created without calling __init__. Needed to avoid infinite recursion in __getattr__ when unpickling. @@ -284,6 +285,7 @@ def __getattr__(self, name: str): def __setattr__(self, name: str, value) -> None: """ Allow setting _basis or the attributes of _basis with a convenient dot assignment syntax. + Setting any other attribute is not allowed. Example @@ -311,6 +313,7 @@ def __setattr__(self, name: str, value) -> None: def __sklearn_clone__(self) -> TransformerBasis: """ Customize how TransformerBasis objects are cloned when used with sklearn.model_selection. + By default, scikit-learn tries to clone the object by calling __init__ using the output of get_params, which fails in our case. @@ -322,6 +325,8 @@ def __sklearn_clone__(self) -> TransformerBasis: def set_params(self, **parameters) -> TransformerBasis: """ + Set TransformerBasis parameters. + When used with, sklearn.model_selection, either set the _basis attribute directly, or set the parameters of the underlying Basis, but doing both at the same time is not allowed. @@ -354,15 +359,11 @@ def set_params(self, **parameters) -> TransformerBasis: return self def get_params(self, deep: bool = True) -> dict: - """ - Extend the dict of parameters from the underlying Basis with _basis. - """ + """Extend the dict of parameters from the underlying Basis with _basis.""" return {"_basis": self._basis, **self._basis.get_params(deep)} def __dir__(self) -> list[str]: - """ - Extend the list of properties of methods with the ones from the underlying Basis. - """ + """Extend the list of properties of methods with the ones from the underlying Basis.""" return super().__dir__() + self._basis.__dir__() def __add__(self, other: TransformerBasis) -> TransformerBasis: @@ -400,7 +401,7 @@ def __mul__(self, other: TransformerBasis) -> TransformerBasis: def __pow__(self, exponent: int) -> TransformerBasis: """Exponentiation of a TransformerBasis object. - Define the power of a basis by repeatedly applying the method __multiply__. + Define the power of a basis by repeatedly applying the method __mul__. The exponent must be a positive integer. Parameters @@ -963,9 +964,7 @@ def __pow__(self, exponent: int) -> MultiplicativeBasis: return result def to_transformer(self) -> TransformerBasis: - """ - Turn the Basis into a TransformerBasis for use with scikit-learn. - """ + """Turn the Basis into a TransformerBasis for use with scikit-learn.""" return TransformerBasis(copy.deepcopy(self)) From fb2af5138d870284c4af2dfb75a44f7caef8ddbf Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 16:10:40 -0400 Subject: [PATCH 160/225] linted docstrings --- src/nemos/basis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 85552bd8..f6c0fa82 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -119,9 +119,10 @@ class TransformerBasis: transformations. It supports fitting to data (calculating any necessary parameters of the basis functions), transforming data (applying the basis functions to data), and both fitting and transforming in one step. + This could be extremely useful in combination with scikit-learn pipelining and cross-validation, enabling the cross-validation of the basis type and parameters, - for example `n_basis_funcs`. See the example section below. + for example `n_basis_funcs`. See the example section below. Parameters ---------- @@ -151,6 +152,7 @@ class TransformerBasis: >>> pipeline.fit(x[:, None], y) # x need to be 2D for sklearn transformer API >>> print(pipeline.predict(np.random.normal(size=(10, 1)))) # predict rate from new data + >>> # TransformerBasis parameter can be cross-validated. >>> # 5-fold cross-validate the number of basis >>> param_grid = dict(compute_features__n_basis_funcs=[4, 10]) >>> grid_cv = GridSearchCV(pipeline, param_grid, cv=5) From 507fd1bea4908effc34c96321f3d135e6328008d Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 25 Jul 2024 16:20:02 -0400 Subject: [PATCH 161/225] fixed docs --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index d6f5d6e4..7490aa9c 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -79,7 +79,7 @@ ), ( "glm", - nmo.glm.GLM(regularizer=nmo.regularizer.Ridge(regularizer_strength=0.5)), + nmo.glm.GLM(regularizer_strength=0.5, regularizer="Ridge"), ), ] ) @@ -129,7 +129,7 @@ # %% param_grid = dict( - glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + glm__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), ) @@ -188,7 +188,7 @@ def highlight_max_cell(cvdf_wide, ax): cvdf_wide = cvdf.pivot( index="param_transformerbasis__n_basis_funcs", - columns="param_glm__regularizer__regularizer_strength", + columns="param_glm__regularizer_strength", values="mean_test_score", ) @@ -237,7 +237,7 @@ def highlight_max_cell(cvdf_wide, ax): # %% param_grid = dict( - glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), + glm__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), transformerbasis___basis=( nmo.basis.RaisedCosineBasisLinear(5), nmo.basis.RaisedCosineBasisLinear(10), @@ -276,7 +276,7 @@ def highlight_max_cell(cvdf_wide, ax): cvdf_wide = cvdf.pivot( index="transformerbasis_config", - columns="param_glm__regularizer__regularizer_strength", + columns="param_glm__regularizer_strength", values="mean_test_score", ) @@ -320,7 +320,7 @@ def highlight_max_cell(cvdf_wide, ax): # # ```python # param_grid = dict( -# glm__regularizer__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), +# glm__regularizer_strength=(0.1, 0.01, 0.001, 1e-6), # transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), # transformerbasis___basis=( # nmo.basis.RaisedCosineBasisLinear(5), From 5da9ab1ba9adca5099500f06b0907a7426ef1fb4 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 26 Jul 2024 09:27:10 -0400 Subject: [PATCH 162/225] updated info about gamma --- docs/developers_notes/02-base_class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/02-base_class.md b/docs/developers_notes/02-base_class.md index 04c40fdf..cc9339df 100644 --- a/docs/developers_notes/02-base_class.md +++ b/docs/developers_notes/02-base_class.md @@ -34,7 +34,7 @@ Abstract Class Base │ │ │ ├─ Concrete Subclass PoissonObservations │ │ -│ ├─ Concrete Subclass GammaObservations *(not implemented yet) +│ ├─ Concrete Subclass GammaObservations │ ... │ ... From b94951a35c353fa7a81010e6989569cd48f91127 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 26 Jul 2024 09:29:38 -0400 Subject: [PATCH 163/225] updated info about Base --- docs/developers_notes/02-base_class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/02-base_class.md b/docs/developers_notes/02-base_class.md index cc9339df..f22d5c87 100644 --- a/docs/developers_notes/02-base_class.md +++ b/docs/developers_notes/02-base_class.md @@ -43,7 +43,7 @@ Abstract Class Base ## The Class `model_base.Base` -The `Base` class aligns with the `scikit-learn` API for `base.BaseEstimator`. This alignment is achieved by implementing the `get_params` and `set_params` methods, essential for `scikit-learn` compatibility and foundational for all model implementations. Additionally, the class provides auxiliary helper methods to identify available computational devices (such as GPUs and TPUs) and to facilitate data transfer to these devices. +The `Base` class aligns with the `scikit-learn` API for `base.BaseEstimator`. This alignment is achieved by implementing the `get_params` and `set_params` methods, essential for `scikit-learn` compatibility and foundational for all model implementations. For a detailed understanding, consult the [`scikit-learn` API Reference](https://scikit-learn.org/stable/modules/classes.html) and [`BaseEstimator`](https://scikit-learn.org/stable/modules/generated/sklearn.base.BaseEstimator.html). From de71113c114ad52ed7d2d2a39ff9488759f78891 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 26 Jul 2024 09:32:02 -0400 Subject: [PATCH 164/225] fixed grammar --- docs/developers_notes/03-base_regressor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 6028e1a0..3f06c57d 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -33,7 +33,7 @@ Public attributes are stored as properties: - `regularizer_strength`: A float quantifying the amount of regularization. - `solver_name`: One of the `jaxopt` solver supported solvers, currently "GradientDescent", "BFGS", "LBFGS", "ProximalGradient" and, "NonlinearCG". - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. -- `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees for a consistent API for all solver. +- `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees a consistent API for all solvers. When implementing a `BaseRegressor`, the only attributes you must interact directly with are the operates the solver, i.e. `solver_init_state`, `solver_update`, `solver_run`. Typically, in `YourRegressor` you will call `self.solver_init_state` at the parameter initialization step, `self.sovler_run` in `fit`, and `self.solver_update` in `update`. From fe98584b7a448f8cf5635f433e9707ca817cf406 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 26 Jul 2024 09:33:33 -0400 Subject: [PATCH 165/225] fixed grammar --- docs/developers_notes/03-base_regressor.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developers_notes/03-base_regressor.md b/docs/developers_notes/03-base_regressor.md index 3f06c57d..78870ab2 100644 --- a/docs/developers_notes/03-base_regressor.md +++ b/docs/developers_notes/03-base_regressor.md @@ -35,7 +35,7 @@ Public attributes are stored as properties: - `solver_kwargs`: Extra keyword arguments to be passed at solver initialization. - `solver_init_state`, `solver_update`, `solver_run`: Read-only property with a partially evaluated `solver.init_state`, `solver.update` and, `solver.run` methods. The partial evaluation guarantees a consistent API for all solvers. -When implementing a `BaseRegressor`, the only attributes you must interact directly with are the operates the solver, i.e. `solver_init_state`, `solver_update`, `solver_run`. +When implementing a BaseRegressor, the only attributes you must interact directly with are those that operate on the solver, i.e., `solver_init_state`, `solver_update`, and `solver_run`. Typically, in `YourRegressor` you will call `self.solver_init_state` at the parameter initialization step, `self.sovler_run` in `fit`, and `self.solver_update` in `update`. !!! note "Solvers" From 14b6a1ed42b75b59b68a9fb18fff3d7810349d02 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Fri, 26 Jul 2024 09:55:36 -0400 Subject: [PATCH 166/225] imporoved description --- docs/developers_notes/04-regularizer.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/developers_notes/04-regularizer.md b/docs/developers_notes/04-regularizer.md index 7d395111..0158c075 100644 --- a/docs/developers_notes/04-regularizer.md +++ b/docs/developers_notes/04-regularizer.md @@ -57,3 +57,10 @@ When developing a functional (i.e., concrete) `Regularizer` class: - **Must** define a default solver and a tuple of allowed solvers. - **May** require extra initialization parameters, like the `mask` argument of `GroupLasso`. +!!! tip + It is important to verify that minimizing the penalized loss directly or through the proximal operator + with the `ProximalGradient` algorithm result in the same model parameters on a convex problem (up to numerical + precision). When the regularization scheme is differentiable, you can use `GradientDescent` over the penalized + loss. For non-smooth penalization, you can use non-gradient based method like `Nelder-Mead` + from [`scipy.optimize.minimize`](https://docs.scipy.org/doc/scipy/reference/optimize.minimize-neldermead.html). + You can refer to NeMoS `test_lasso_convergence` from `tests/test_convergence.py` for a concrete implementation. From 7c99155cc76e3dd94a9464c3a35e6dd6c6d03fab Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 27 Jul 2024 12:20:30 -0400 Subject: [PATCH 167/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 96115e27..edc69f57 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -71,7 +71,7 @@ def min_max_rescale_samples( sample_pts: The original samples. bounds: - Sample bounds. `bounds[0]` abd `bounds[1]` are mapped to 0/1 respectively. + Sample bounds. `bounds[0]` and `bounds[1]` are mapped to 0 and 1, respectively. Default are `min(sample_pts), max(sample_pts)`. Raises From 390e84acd48a8e47d0e05d25b44c87e2ff4c425d Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 27 Jul 2024 12:20:43 -0400 Subject: [PATCH 168/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index edc69f57..7c8f848e 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -89,7 +89,7 @@ def min_max_rescale_samples( vmin = np.nanmin(sample_pts) if bounds is None else bounds[0] vmax = np.nanmax(sample_pts) if bounds is None else bounds[1] if vmin and vmax and vmax <= vmin: - raise ValueError("Invalid value range. `vmax` must be larger then `vmin`!") + raise ValueError("Invalid value range. `bounds[1]` must be larger then `bounds[0]`!") sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin # this passes if `samples_pts` contains a single value From a3e1ebdbf1d30b96eb6c67c75aa1c49a49b343aa Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 12:36:06 -0400 Subject: [PATCH 169/225] moved to setter, surfaced exception --- src/nemos/basis.py | 23 +++++++++++++-------- tests/test_basis.py | 50 ++++++++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 7c8f848e..4c37b30f 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -239,7 +239,6 @@ class Basis(abc.ABC): **kwargs : Only used in "conv" mode. Additional keyword arguments that are passed to `nemos.convolve.create_convolutional_predictor` - """ def __init__( @@ -256,14 +255,7 @@ def __init__( self._check_n_basis_min() self._conv_args = args self._conv_kwargs = kwargs - - if bounds is not None and len(bounds) != 2: - raise ValueError( - f"The provided `bounds` must be of length two. Length {len(bounds)} provided instead!" - ) - - # convert to float and store - self._bounds = bounds if bounds is None else tuple(map(float, bounds)) + self.bounds = bounds # check mode if mode not in ["conv", "eval"]: @@ -300,6 +292,19 @@ def __init__( def bounds(self): return self._bounds + @bounds.setter + def bounds(self, values: Union[None, Tuple[float, float]]): + """Setter for bounds.""" + if values is not None and len(values) != 2: + raise ValueError( + f"The provided `bounds` must be of length two. Length {len(values)} provided instead!" + ) + # convert to float and store + try: + self._bounds = values if values is None else tuple(map(float, values)) + except (ValueError, TypeError): + raise TypeError("Could not convert `bounds` to float.") + @property def mode(self): return self._mode diff --git a/tests/test_basis.py b/tests/test_basis.py index 64418c6e..7454df3d 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -538,12 +538,12 @@ def test_conv_kwargs_error(self): "bounds, expectation", [ (None, does_not_raise()), - ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), - ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((None, 3), pytest.raises(TypeError, match=r"Could not convert")), + ((1, None), pytest.raises(TypeError, match=r"Could not convert")), ((1, 3), does_not_raise()), - (("a", 3), pytest.raises(ValueError, match="could not convert")), - ((1, "a"), pytest.raises(ValueError, match="could not convert")), - (("a", "a"), pytest.raises(ValueError, match="could not convert")) + (("a", 3), pytest.raises(TypeError, match="Could not convert")), + ((1, "a"), pytest.raises(TypeError, match="Could not convert")), + (("a", "a"), pytest.raises(TypeError, match="Could not convert")) ] ) def test_vmin_vmax_init(self, bounds, expectation): @@ -1042,12 +1042,12 @@ def test_conv_kwargs_error(self): "bounds, expectation", [ (None, does_not_raise()), - ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), - ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((None, 3), pytest.raises(TypeError, match=r"Could not convert")), + ((1, None), pytest.raises(TypeError, match=r"Could not convert")), ((1, 3), does_not_raise()), - (("a", 3), pytest.raises(ValueError, match="could not convert")), - ((1, "a"), pytest.raises(ValueError, match="could not convert")), - (("a", "a"), pytest.raises(ValueError, match="could not convert")) + (("a", 3), pytest.raises(TypeError, match="Could not convert")), + ((1, "a"), pytest.raises(TypeError, match="Could not convert")), + (("a", "a"), pytest.raises(TypeError, match="Could not convert")) ] ) def test_vmin_vmax_init(self, bounds, expectation): @@ -1546,12 +1546,12 @@ def test_conv_kwargs_error(self): "bounds, expectation", [ (None, does_not_raise()), - ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), - ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((None, 3), pytest.raises(TypeError, match=r"Could not convert")), + ((1, None), pytest.raises(TypeError, match=r"Could not convert")), ((1, 3), does_not_raise()), - (("a", 3), pytest.raises(ValueError, match="could not convert")), - ((1, "a"), pytest.raises(ValueError, match="could not convert")), - (("a", "a"), pytest.raises(ValueError, match="could not convert")) + (("a", 3), pytest.raises(TypeError, match="Could not convert")), + ((1, "a"), pytest.raises(TypeError, match="Could not convert")), + (("a", "a"), pytest.raises(TypeError, match="Could not convert")) ] ) def test_vmin_vmax_init(self, bounds, expectation): @@ -2561,12 +2561,12 @@ def test_conv_kwargs_error(self): "bounds, expectation", [ (None, does_not_raise()), - ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), - ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((None, 3), pytest.raises(TypeError, match=r"Could not convert")), + ((1, None), pytest.raises(TypeError, match=r"Could not convert")), ((1, 3), does_not_raise()), - (("a", 3), pytest.raises(ValueError, match="could not convert")), - ((1, "a"), pytest.raises(ValueError, match="could not convert")), - (("a", "a"), pytest.raises(ValueError, match="could not convert")) + (("a", 3), pytest.raises(TypeError, match="Could not convert")), + ((1, "a"), pytest.raises(TypeError, match="Could not convert")), + (("a", "a"), pytest.raises(TypeError, match="Could not convert")) ] ) def test_vmin_vmax_init(self, bounds, expectation): @@ -3120,12 +3120,12 @@ def test_conv_kwargs_error(self): "bounds, expectation", [ (None, does_not_raise()), - ((None, 3), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), - ((1, None), pytest.raises(TypeError, match=r"float\(\) argument must be a string ")), + ((None, 3), pytest.raises(TypeError, match=r"Could not convert")), + ((1, None), pytest.raises(TypeError, match=r"Could not convert")), ((1, 3), does_not_raise()), - (("a", 3), pytest.raises(ValueError, match="could not convert")), - ((1, "a"), pytest.raises(ValueError, match="could not convert")), - (("a", "a"), pytest.raises(ValueError, match="could not convert")) + (("a", 3), pytest.raises(TypeError, match="Could not convert")), + ((1, "a"), pytest.raises(TypeError, match="Could not convert")), + (("a", "a"), pytest.raises(TypeError, match="Could not convert")) ] ) def test_vmin_vmax_init(self, bounds, expectation): From 631dcd8fd1a99bd423bafac24dbd594d359875c4 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 27 Jul 2024 12:36:48 -0400 Subject: [PATCH 170/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 7c8f848e..76c3c5aa 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -557,6 +557,7 @@ def _check_transform_input( # make sure array is at least 1d (so that we succeed when only # passed a scalar) xi = tuple(np.atleast_1d(np.asarray(x, dtype=float)) for x in xi) + # ValueError here surfaces the exception with e.g., `x=np.array["a", "b"])` except (TypeError, ValueError): raise TypeError("Input samples must be array-like of floats!") From 2316f1912d82b88a1b88f3cc8a100cbef94c3f4f Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 12:41:15 -0400 Subject: [PATCH 171/225] set to numnerical precision --- src/nemos/basis.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 58e84820..98ed6313 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1070,11 +1070,13 @@ def _generate_knots( if is_cyclic: num_interior_knots += self.order - 1 + # Spline basis have support on the semi-open [a, b) interval, we add a small epsilon + # to mx so that the so that basis_element(max(samples)) != 0 knot_locs = np.concatenate( ( np.zeros(self.order - 1), - np.linspace(0, (1 + 10**-8), num_interior_knots + 2), - np.full(self.order - 1, 1 + 10**-8), + np.linspace(0, (1 + np.finfo(float).eps), num_interior_knots + 2), + np.full(self.order - 1, 1 + np.finfo(float).eps), ) ) return knot_locs From 3e12e390bbe8e994cdacb52f1e3fd51672c3028d Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 27 Jul 2024 12:49:41 -0400 Subject: [PATCH 172/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 76c3c5aa..3a4e8b1a 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1588,9 +1588,8 @@ def __init__( self._n_input_dimensionality = 1 self._check_width(width) self._width = width - # if linear raised cosine are initialized - # this flag is always true, the samples - # must be rescaled to 0 and 1. + # for these linear raised-cosine basis functions, + # the samples must be rescaled to 0 and 1. self._rescale_samples = True @property From 5ae3d06b1ea2cbf26d1bfc99d046b8352ce24257 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 12:51:21 -0400 Subject: [PATCH 173/225] added example for mspline --- src/nemos/basis.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 98ed6313..a4cdccfb 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1154,8 +1154,11 @@ class MSplineBasis(SplineBasis): Notes ----- - MSplines must integrate to 1 over their domain. Therefore, if the domain (x-axis) of an MSpline - basis is dilated by a factor of $\alpha$, the co-domain (y-axis) values will shrink by a factor of $1/\alpha$. + MSplines must integrate to 1 over their domain (the area under the curve is 1). Therefore, if the domain + (x-axis) of an MSpline basis is expanded by a factor of $\alpha$, the values on the co-domain (y-axis) values + will shrink by a factor of $1/\alpha$. + For example, over the standard bounds of (0, 1), the maximum value of the MSpline is 18. + If we set the bounds to (0, 2), the maximum value will be 9, i.e., 18 / 2. """ def __init__( From 5735a3b2f90ccdf7c29ba920767eb352d1fd893b Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 27 Jul 2024 12:51:49 -0400 Subject: [PATCH 174/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 3a4e8b1a..e329d073 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -1781,7 +1781,6 @@ def __init__( bounds=bounds, **kwargs, ) - # overwrite the flag for scaling the samples to [0,1] in the super.__call__(...). # The samples are scaled appropriately in the self._transform_samples which scales # and applies the log-stretch, no additional transform is needed. self._rescale_samples = False From 3cfe3e02596a4c26081841e22ce34479ff9eeb29 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 12:53:34 -0400 Subject: [PATCH 175/225] linted --- src/nemos/basis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index a4cdccfb..d441acee 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -89,7 +89,9 @@ def min_max_rescale_samples( vmin = np.nanmin(sample_pts) if bounds is None else bounds[0] vmax = np.nanmax(sample_pts) if bounds is None else bounds[1] if vmin and vmax and vmax <= vmin: - raise ValueError("Invalid value range. `bounds[1]` must be larger then `bounds[0]`!") + raise ValueError( + "Invalid value range. `bounds[1]` must be larger then `bounds[0]`!" + ) sample_pts[(sample_pts < vmin) | (sample_pts > vmax)] = np.nan sample_pts -= vmin # this passes if `samples_pts` contains a single value From e3d4a3275f9ca678cfb061789818476c61fef209 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 12:54:10 -0400 Subject: [PATCH 176/225] merged --- docs/background/plot_01_1D_basis_function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/background/plot_01_1D_basis_function.py b/docs/background/plot_01_1D_basis_function.py index dbf59fbe..fc665c93 100644 --- a/docs/background/plot_01_1D_basis_function.py +++ b/docs/background/plot_01_1D_basis_function.py @@ -14,9 +14,9 @@ import matplotlib.pylab as plt import numpy as np +import pynapple as nap import nemos as nmo -import pynapple as nap # Initialize hyperparameters order = 4 From 296b4497d38929370efb40715c90d67427984845 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 27 Jul 2024 18:16:40 -0400 Subject: [PATCH 177/225] added test for get params transformer --- src/nemos/basis.py | 24 +++++++++++++++------ tests/test_basis.py | 52 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 271b4d04..e026b9ae 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -2121,16 +2121,26 @@ def __init__( bounds=bounds, **kwargs, ) - self._decay_rates = np.asarray(decay_rates) - if self._decay_rates.shape[0] != n_basis_funcs: + self.decay_rates = decay_rates + self._check_rates() + self._n_input_dimensionality = 1 + + @property + def decay_rates(self): + """Decay rate getter""" + return self._decay_rates + + @decay_rates.setter + def decay_rates(self, value: NDArray): + """Decay rate setter.""" + value = np.asarray(value) + if value.shape[0] != self.n_basis_funcs: raise ValueError( f"The number of basis functions must match the number of decay rates provided. " - f"Number of basis functions provided: {n_basis_funcs}, " - f"Number of decay rates provided: {self._decay_rates.shape[0]}" + f"Number of basis functions provided: {self.n_basis_funcs}, " + f"Number of decay rates provided: {value.shape[0]}" ) - - self._check_rates() - self._n_input_dimensionality = 1 + self._decay_rates = value def _check_n_basis_min(self) -> None: """Check that the user required enough basis elements. diff --git a/tests/test_basis.py b/tests/test_basis.py index a69a175e..2d1973f2 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -621,6 +621,14 @@ def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: self.cls(3, mode="conv", window_size=10, bounds=bounds) + def test_transformer_get_params(self): + bas = self.cls(5) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + params_basis = bas.get_params() + assert params_transf == params_basis + class TestRaisedCosineLinearBasis(BasisFuncsTesting): cls = basis.RaisedCosineBasisLinear @@ -1120,6 +1128,14 @@ def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: self.cls(3, mode="conv", window_size=10, bounds=bounds) + def test_transformer_get_params(self): + bas = self.cls(5) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + params_basis = bas.get_params() + assert params_transf == params_basis + class TestMSplineBasis(BasisFuncsTesting): cls = basis.MSplineBasis @@ -1625,6 +1641,13 @@ def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: self.cls(3, mode="conv", window_size=10, bounds=bounds) + def test_transformer_get_params(self): + bas = self.cls(5) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + params_basis = bas.get_params() + assert params_transf == params_basis class TestOrthExponentialBasis(BasisFuncsTesting): cls = basis.OrthExponentialBasis @@ -2103,9 +2126,18 @@ def test_identifiability_constraint_apply(self): def test_conv_kwargs_error(self): with pytest.raises(ValueError, match="kwargs should only be set"): - bas = self.cls(5, decay_rates=[1, 2, 3, 4, 5], mode="eval", test="hi") - + self.cls(5, decay_rates=[1, 2, 3, 4, 5], mode="eval", test="hi") + def test_transformer_get_params(self): + bas = self.cls(5, decay_rates=[1, 2, 3, 4, 5]) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + rates_transf = params_transf.pop("decay_rates") + params_basis = bas.get_params() + rates_basis = params_basis.pop("decay_rates") + assert params_transf == params_basis + assert np.all(rates_transf == rates_basis) class TestBSplineBasis(BasisFuncsTesting): cls = basis.BSplineBasis @@ -2628,6 +2660,14 @@ def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: self.cls(5, mode="conv", window_size=10, bounds=bounds) + def test_transformer_get_params(self): + bas = self.cls(5) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + params_basis = bas.get_params() + assert params_transf == params_basis + class TestCyclicBSplineBasis(BasisFuncsTesting): cls = basis.CyclicBSplineBasis @@ -3184,6 +3224,14 @@ def test_vmin_vmax_mode_conv(self, bounds, samples, exception): with exception: self.cls(5, mode="conv", window_size=10, bounds=bounds) + def test_transformer_get_params(self): + bas = self.cls(5) + bas_transformer = bas.to_transformer() + params_transf = bas_transformer.get_params() + params_transf.pop("_basis") + params_basis = bas.get_params() + assert params_transf == params_basis + class CombinedBasis(BasisFuncsTesting): """ This class is used to run tests on combination operations (e.g., addition, multiplication) among Basis functions. From 06129b4b82dc2d650a4d470c30449d110a333171 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 29 Jul 2024 14:09:38 -0400 Subject: [PATCH 178/225] substantial changes in the plot_06_sklearn_pipeline_cv_demo.py --- .../plot_06_sklearn_pipeline_cv_demo.py | 197 +++++++++++++----- 1 file changed, 147 insertions(+), 50 deletions(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 7490aa9c..bb36fb41 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -1,13 +1,74 @@ """ -# Pipelining and cross-validation with scikit-learn +# Selecting basis by cross-validation with scikit-learn + +In this demo we will demonstrate how to select an appropriate basis and its hyperparameters by means of cross-validation. +In particular, we will learn: + +1. What is a scikit-learn pipeline +2. Why pipelines are useful. +3. How to combine NeMoS `Basis` and `GLM` object in a pipleline. +4. How to select the number of basis and the basis type by cross-validation (or any other hyperparameter in the pipeline). +5. How to use a custom scoring metric for quantifying the performance of each configuration. -In this demo we will show how to combine basis transformations and GLMs using scikit-learn's pipelines, and demonstrate how cross-validation can be used to fine-tune parameters of each step of the pipeline. """ -# %% [markdown] -# ## Let's start by creating some toy data +# %% +# ## What is a scikit-learn pipeline. +# In general, we can define a pipeline as a chain of data transformations. At each step of the pipeline, the input of the previous step is "transformed" into a different representation by the next tansfomrmation. +# The last step can be another transformation, or a regression step in which the output of the previous piepline step is used to predict some observations. +# Setting up such machinery may seem complicated, but that's when scikit-learn `Pipeline` comes to the rescue. +# +# To set up a scikit-learn `Pipeline`, we need to make sure that: +# +# 1. Each intermediate step is a [scikit-learn transformer object](https://scikit-learn.org/stable/data_transforms.html), i.e. it should implement a `transform` and/or a `fit_transform` method. +# 2. The final step is another transformer or an [estimator object](https://scikit-learn.org/stable/developers/develop.html#estimators), i.e. it must implement a `fit` method, or a model, which implements and a `predict` and a `score` method as well. +# +# Each transformation step takes a 2D array `X` of shape `(num_samples, num_original_feature)` as input and output another 2D array of shape `(num_samples, num_transformed_feature)`. The prediction step instead +# takes as input a pair `(X, y)`, where `X` is as before and `y` a 1D array of shape `(n_samples,)` containing the observations we are trying to model. +# You can define a pipeline as follows, +# +# ```python +# from sklearn.pipeline import Pipeline +# +# # assume that transformer_i/preditor is a transformer/model object +# pipe = Pipeline( +# [ +# ("label_1", transformer_1), +# ("label_2", transformer_2), +# ..., +# ("label_n", transformer_n), +# ("label_model", model) +# +# ] +# ) +# ``` +# +# +# Calling `pipe.fit(X, y)` will perform the following computations, +# ```python +# # chain of transformations +# X1 = transformer_1.transform(X) +# X2 = transformer_2.transform(X1) +# # ... +# Xn = transformer_n.transform(Xn_1) +# +# # fit step +# model.fit(Xn, y) +# ``` +# +# +# And the same holds for `pipe.score` and `pipe.predict`. +# +# ## Why pipelines are useful +# While clearly pipelining your processing steps streamlines and simplifies your code, this is far from being the only advantage of such a machinery. +# The real power of this approach becomes evident when a pipeline is combined with the scikit-learn `model_selection` module capabilities. +# This approach will enable you to tune hyperparameters at each step of the pipeline in a surprisingly streight-forward manner. +# In the next sessions will showcase this approach in a concrete example: selecting the appropriate basis type and number of basis for a GLM regression in NeMoS. # %% +# ## Combining basis transformations and GLM in a pipeline +# Let's start by creating some toy data + import nemos as nmo import numpy as np import pandas as pd @@ -36,11 +97,9 @@ ax.set_ylabel("spike count") sns.despine(ax=ax) -# %% [markdown] -# ## Combining basis transformations and GLM in a pipeline - -# %% [markdown] -# The `TransformerBasis` wrapper class provides an API consistent with [scikit-learn's data transforms](https://scikit-learn.org/stable/data_transforms.html), allowing its use in pipelines as a transformation step. +# %% +# ### Converting NeMoS `Basis` to a transformer +# In order to use NeMoS `Basis` in a pipeline, we need to convert it into a scikit-learn transformer. This can be achieved through the `TransformerBasis` wrapper class. # # Instantiating a `TransformerBasis` can be done using the constructor directly or with `Basis.to_transformer()`: @@ -49,13 +108,13 @@ trans_bas_a = nmo.basis.TransformerBasis(bas) trans_bas_b = bas.to_transformer() -# %% [markdown] +# %% # `TransformerBasis` provides convenient access to the underlying `Basis` object's attributes: # %% print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) -# %% [markdown] +# %% # We can also set attributes of the underlying `Basis`. Note that -- because `TransformerBasis` is created with a copy of the `Basis` object passed to it -- this does not change the original `Basis`, and neither does changing the original `Basis` change `TransformerBasis` we created: # %% @@ -64,11 +123,11 @@ print(bas.n_basis_funcs, trans_bas_a.n_basis_funcs, trans_bas_b.n_basis_funcs) -# %% [markdown] +# %% # ### Creating and fitting a pipeline # We might want to combine first transforming the input data with our basis functions, then fitting a GLM on the transformed data. # -# This is exactly what [scikit-learn's Pipeline](https://scikit-learn.org/stable/modules/compose.html#pipeline) is for! +# This is exactly what `Pipeline` is for! # %% pipeline = Pipeline( @@ -86,7 +145,9 @@ pipeline.fit(X, y) -# %% [markdown] +# %% +# Note how NeMoS models are already scikit-learn compatible and can be used directly in the pipeline. +# # Visualize the fit: # %% @@ -107,22 +168,18 @@ ax.legend() sns.despine(ax=ax) -# %% [markdown] +# %% # There is some room for improvement, so in the next section we'll use cross-validation to tune the parameters of our pipeline. -# %% [markdown] -# ## Hyperparameter-tuning with scikit-learn's gridsearch +# %% +# ### Select the number of basis by cross-validation -# %% [markdown] +# %% # !!! warning # Please keep in mind that while `GLM.score` supports different ways of evaluating goodness-of-fit through the `score_type` argument, `pipeline.score(X, y, score_type="...")` does not propagate this, and uses the default value of `log-likelihood`. # # To evaluate a pipeline, please create a custom scorer (e.g. `pseudo_r2` below) and call `my_custom_scorer(pipeline, X, y)`. - -# %% [markdown] -# ### Evaluating different values of the number of basis functions - -# %% [markdown] +# # #### Define the parameter grid # # Let's define candidate values for the parameters of each step of the pipeline we want to cross-validate. In this case the number of basis functions in the transformation step and the ridge regularization's strength in the GLM fit: @@ -133,35 +190,20 @@ transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), ) -# %% [markdown] -# #### Create a custom scorer -# Create a custom scorer to evaluate models using the pseudo-R2 score instead of the log-likelihood which is the default in `GLM.score`: - # %% -from sklearn.metrics import make_scorer - -# NOTE: the order of the arguments is reversed -pseudo_r2 = make_scorer( - lambda y_true, y_pred: nmo.observation_models.PoissonObservations().pseudo_r2( - y_pred, y_true - ) -) - -# %% [markdown] -# #### Run the grid search: +# #### Run the grid search # %% gridsearch = GridSearchCV( pipeline, param_grid=param_grid, - cv=5, - scoring=pseudo_r2, + cv=5 ) # run the 5-fold cross-validation grid search gridsearch.fit(X, y) -# %% [markdown] +# %% # #### Visualize the scores # # Let's extract the scores from `gridsearch` and take a look at how the different parameter values of our pipeline influence the test score: @@ -205,8 +247,8 @@ def highlight_max_cell(cvdf_wide, ax): highlight_max_cell(cvdf_wide, ax) -# %% [markdown] -# #### Evaluating different values of the number of basis functions +# %% +# #### Visualize the predicted # Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: # %% @@ -227,10 +269,10 @@ def highlight_max_cell(cvdf_wide, ax): ax.legend() sns.despine(ax=ax) -# %% [markdown] +# %% # ### Evaluating different bases directly -# %% [markdown] +# %% # In the previous example we set the number of basis functions of the `Basis` wrapped in our `TransformerBasis`. However, if we are for example not sure about the type of basis functions we want to use, or we have already defined some basis functions of our own, then we can use cross-validation to directly evaluate those as well. # # Here we include `transformerbasis___basis` in the parameter grid to try different values for `TransformerBasis._basis`: @@ -248,7 +290,7 @@ def highlight_max_cell(cvdf_wide, ax): ), ) -# %% [markdown] +# %% # Then run the grid search: # %% @@ -256,13 +298,12 @@ def highlight_max_cell(cvdf_wide, ax): pipeline, param_grid=param_grid, cv=5, - scoring=pseudo_r2, ) # run the 5-fold cross-validation grid search gridsearch.fit(X, y) -# %% [markdown] +# %% # Wrangling the output data a bit and looking at the scores: # %% @@ -293,7 +334,7 @@ def highlight_max_cell(cvdf_wide, ax): highlight_max_cell(cvdf_wide, ax) -# %% [markdown] +# %% # Looks like `RaisedCosineBasisLinear` was probably a decent choice for our toy data: # %% @@ -314,7 +355,7 @@ def highlight_max_cell(cvdf_wide, ax): ax.legend() sns.despine(ax=ax) -# %% [markdown] +# %% # !!! warning # Please note that because it would lead to unexpected behavior, mixing the two ways of defining values for the parameter grid is not allowed. The following would lead to an error: # @@ -332,5 +373,61 @@ def highlight_max_cell(cvdf_wide, ax): # ), # ) # ``` +# +# ## Create a custom scorer +# By default, the GLM score method returns the model log-likelihoood. If you want to try a different metric, such as the pseudo-R2, you can create a custom scorer that overwrites the default: + +# %% +from sklearn.metrics import make_scorer + +# NOTE: the order of the arguments is reversed +pseudo_r2 = make_scorer( + lambda y_true, y_pred: nmo.observation_models.PoissonObservations().pseudo_r2( + y_pred, y_true + ) +) + +# %% +# #### Run the grid search providing the custom scorer # %% +gridsearch = GridSearchCV( + pipeline, + param_grid=param_grid, + cv=5, + scoring=pseudo_r2, +) + +# run the 5-fold cross-validation grid search +gridsearch.fit(X, y) + +# %% +# #### Plot the pseudo-R2 scores + +# plot the pseudo-r2 scores +cvdf = pd.DataFrame(gridsearch.cv_results_) + +# read out the number of +cvdf["transformerbasis_config"] = [ + f"{b.__class__.__name__} - {b.n_basis_funcs}" + for b in cvdf["param_transformerbasis___basis"] +] + +cvdf_wide = cvdf.pivot( + index="transformerbasis_config", + columns="param_glm__regularizer_strength", + values="mean_test_score", +) + +ax = sns.heatmap( + cvdf_wide, + annot=True, + square=True, + linecolor="white", + linewidth=0.5, +) + +ax.set_xlabel("ridge regularization strength") +ax.set_ylabel("number of basis functions") + +highlight_max_cell(cvdf_wide, ax) \ No newline at end of file From bac99f40f81ff65b618b477e195a307f4ec66a37 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 29 Jul 2024 15:10:17 -0400 Subject: [PATCH 179/225] refined tutorial --- .../plot_06_sklearn_pipeline_cv_demo.py | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index bb36fb41..0804a0fb 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -1,36 +1,34 @@ """ # Selecting basis by cross-validation with scikit-learn -In this demo we will demonstrate how to select an appropriate basis and its hyperparameters by means of cross-validation. +In this demo, we will demonstrate how to select an appropriate basis and its hyperparameters using cross-validation. In particular, we will learn: -1. What is a scikit-learn pipeline +1. What a scikit-learn pipeline is. 2. Why pipelines are useful. -3. How to combine NeMoS `Basis` and `GLM` object in a pipleline. -4. How to select the number of basis and the basis type by cross-validation (or any other hyperparameter in the pipeline). -5. How to use a custom scoring metric for quantifying the performance of each configuration. +3. How to combine NeMoS `Basis` and `GLM` objects in a pipeline. +4. How to select the number of bases and the basis type through cross-validation (or any other hyperparameter in the pipeline). +5. How to use a custom scoring metric to quantify the performance of each configuration. """ # %% -# ## What is a scikit-learn pipeline. -# In general, we can define a pipeline as a chain of data transformations. At each step of the pipeline, the input of the previous step is "transformed" into a different representation by the next tansfomrmation. -# The last step can be another transformation, or a regression step in which the output of the previous piepline step is used to predict some observations. -# Setting up such machinery may seem complicated, but that's when scikit-learn `Pipeline` comes to the rescue. +# ## What is a scikit-learn pipeline # -# To set up a scikit-learn `Pipeline`, we need to make sure that: +# A pipeline is a sequence of data transformations. Each step in the pipeline transforms the input data into a different representation, with the final step being either another transformation or a model step that fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. # -# 1. Each intermediate step is a [scikit-learn transformer object](https://scikit-learn.org/stable/data_transforms.html), i.e. it should implement a `transform` and/or a `fit_transform` method. -# 2. The final step is another transformer or an [estimator object](https://scikit-learn.org/stable/developers/develop.html#estimators), i.e. it must implement a `fit` method, or a model, which implements and a `predict` and a `score` method as well. +# To set up a scikit-learn `Pipeline`, ensure that: # -# Each transformation step takes a 2D array `X` of shape `(num_samples, num_original_feature)` as input and output another 2D array of shape `(num_samples, num_transformed_feature)`. The prediction step instead -# takes as input a pair `(X, y)`, where `X` is as before and `y` a 1D array of shape `(n_samples,)` containing the observations we are trying to model. -# You can define a pipeline as follows, +# 1. Each intermediate step is a [scikit-learn transformer object](https://scikit-learn.org/stable/data_transforms.html) with a `transform` and/or `fit_transform` method. +# 2. The final step is either another transformer or an [estimator object](https://scikit-learn.org/stable/developers/develop.html#estimators) with a `fit` method, or a model with `fit`, `predict`, and `score` methods. # +# Each transformation step takes a 2D array `X` of shape `(num_samples, num_original_features)` as input and outputs another 2D array of shape `(num_samples, num_transformed_features)`. The final step takes a pair `(X, y)`, where `X` is as before, and `y` is a 1D array of shape `(n_samples,)` containing the observations to be modeled. +# +# You can define a pipeline as follows: # ```python # from sklearn.pipeline import Pipeline # -# # assume that transformer_i/preditor is a transformer/model object +# # Assume transformer_i/predictor is a transformer/model object # pipe = Pipeline( # [ # ("label_1", transformer_1), @@ -38,36 +36,31 @@ # ..., # ("label_n", transformer_n), # ("label_model", model) -# # ] # ) # ``` # -# -# Calling `pipe.fit(X, y)` will perform the following computations, +# Calling `pipe.fit(X, y)` will perform the following computations: # ```python -# # chain of transformations +# # Chain of transformations # X1 = transformer_1.transform(X) # X2 = transformer_2.transform(X1) # # ... # Xn = transformer_n.transform(Xn_1) # -# # fit step +# # Fit step # model.fit(Xn, y) # ``` -# -# # And the same holds for `pipe.score` and `pipe.predict`. # # ## Why pipelines are useful -# While clearly pipelining your processing steps streamlines and simplifies your code, this is far from being the only advantage of such a machinery. -# The real power of this approach becomes evident when a pipeline is combined with the scikit-learn `model_selection` module capabilities. -# This approach will enable you to tune hyperparameters at each step of the pipeline in a surprisingly streight-forward manner. -# In the next sessions will showcase this approach in a concrete example: selecting the appropriate basis type and number of basis for a GLM regression in NeMoS. - -# %% +# +# Pipelines not only streamline and simplify your code but also offer several other advantages. The real power of pipelines becomes evident when combined with the scikit-learn `model_selection` module. This combination allows you to tune hyperparameters at each step of the pipeline in a straightforward manner. +# +# In the following sections, we will showcase this approach with a concrete example: selecting the appropriate basis type and number of bases for a GLM regression in NeMoS. +# # ## Combining basis transformations and GLM in a pipeline -# Let's start by creating some toy data +# Let's start by creating some toy data. import nemos as nmo import numpy as np @@ -79,11 +72,9 @@ from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV -# %% - # predictors, shape (n_samples, n_features) X = np.random.uniform(low=0, high=1, size=(1000, 1)) -# observed counts, shape (n_samples, ) +# observed counts, shape (n_samples,) rate = 2 * ( scipy.stats.norm.pdf(X, scale=0.1, loc=0.25) + scipy.stats.norm.pdf(X, scale=0.1, loc=0.75) @@ -192,6 +183,7 @@ # %% # #### Run the grid search +# Let's run a 5-fold cross-validation of the hyperparameters with the scikit-learn `model_selection.GridsearchCV` class. # %% gridsearch = GridSearchCV( @@ -211,7 +203,6 @@ # %% from matplotlib.patches import Rectangle - def highlight_max_cell(cvdf_wide, ax): max_col = cvdf_wide.max().idxmax() max_col_index = cvdf_wide.columns.get_loc(max_col) @@ -224,7 +215,6 @@ def highlight_max_cell(cvdf_wide, ax): ) ) - # %% cvdf = pd.DataFrame(gridsearch.cv_results_) @@ -242,6 +232,10 @@ def highlight_max_cell(cvdf_wide, ax): linewidth=0.5, ) +# Labeling the colorbar +colorbar = ax.collections[0].colorbar +colorbar.set_label('log-likelihood') + ax.set_xlabel("ridge regularization strength") ax.set_ylabel("number of basis functions") @@ -271,8 +265,7 @@ def highlight_max_cell(cvdf_wide, ax): # %% # ### Evaluating different bases directly - -# %% +# # In the previous example we set the number of basis functions of the `Basis` wrapped in our `TransformerBasis`. However, if we are for example not sure about the type of basis functions we want to use, or we have already defined some basis functions of our own, then we can use cross-validation to directly evaluate those as well. # # Here we include `transformerbasis___basis` in the parameter grid to try different values for `TransformerBasis._basis`: @@ -303,13 +296,14 @@ def highlight_max_cell(cvdf_wide, ax): # run the 5-fold cross-validation grid search gridsearch.fit(X, y) + # %% # Wrangling the output data a bit and looking at the scores: # %% cvdf = pd.DataFrame(gridsearch.cv_results_) -# read out the number of +# Read out the number of basis functions cvdf["transformerbasis_config"] = [ f"{b.__class__.__name__} - {b.n_basis_funcs}" for b in cvdf["param_transformerbasis___basis"] @@ -329,6 +323,10 @@ def highlight_max_cell(cvdf_wide, ax): linewidth=0.5, ) +# Labeling the colorbar +colorbar = ax.collections[0].colorbar +colorbar.set_label('log-likelihood') + ax.set_xlabel("ridge regularization strength") ax.set_ylabel("number of basis functions") @@ -373,9 +371,10 @@ def highlight_max_cell(cvdf_wide, ax): # ), # ) # ``` -# + +# %% # ## Create a custom scorer -# By default, the GLM score method returns the model log-likelihoood. If you want to try a different metric, such as the pseudo-R2, you can create a custom scorer that overwrites the default: +# By default, the GLM score method returns the model log-likelihood. If you want to try a different metric, such as the pseudo-R2, you can create a custom scorer that overwrites the default: # %% from sklearn.metrics import make_scorer @@ -398,16 +397,17 @@ def highlight_max_cell(cvdf_wide, ax): scoring=pseudo_r2, ) -# run the 5-fold cross-validation grid search +# Run the 5-fold cross-validation grid search gridsearch.fit(X, y) # %% # #### Plot the pseudo-R2 scores -# plot the pseudo-r2 scores +# %% +# Plot the pseudo-R2 scores cvdf = pd.DataFrame(gridsearch.cv_results_) -# read out the number of +# Read out the number of basis functions cvdf["transformerbasis_config"] = [ f"{b.__class__.__name__} - {b.n_basis_funcs}" for b in cvdf["param_transformerbasis___basis"] @@ -427,7 +427,11 @@ def highlight_max_cell(cvdf_wide, ax): linewidth=0.5, ) +# Labeling the colorbar +colorbar = ax.collections[0].colorbar +colorbar.set_label('pseudo-R2') + ax.set_xlabel("ridge regularization strength") ax.set_ylabel("number of basis functions") -highlight_max_cell(cvdf_wide, ax) \ No newline at end of file +highlight_max_cell(cvdf_wide, ax) From cc2285cedd74216399f109834a6b9514d679f07c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 29 Jul 2024 15:46:49 -0400 Subject: [PATCH 180/225] improved content of tutorial --- .../plot_06_sklearn_pipeline_cv_demo.py | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 0804a0fb..22a34f04 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -15,7 +15,7 @@ # %% # ## What is a scikit-learn pipeline # -# A pipeline is a sequence of data transformations. Each step in the pipeline transforms the input data into a different representation, with the final step being either another transformation or a model step that fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. +# A pipeline is a sequence of data transformations. Each step in the pipeline transforms the input data into a different representation. The final step is either another transformation or a model step that fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. # # To set up a scikit-learn `Pipeline`, ensure that: # @@ -43,10 +43,10 @@ # Calling `pipe.fit(X, y)` will perform the following computations: # ```python # # Chain of transformations -# X1 = transformer_1.transform(X) -# X2 = transformer_2.transform(X1) +# X1 = transformer_1.fit_transform(X) +# X2 = transformer_2.fit_transform(X1) # # ... -# Xn = transformer_n.transform(Xn_1) +# Xn = transformer_n.fit_transform(Xn_1) # # # Fit step # model.fit(Xn, y) @@ -141,6 +141,14 @@ # # Visualize the fit: +# %% + +# Predict the rate. +# Note that you need a 2D input even if x is a flat array. +# We are using expand dim to add the extra-dimension +x = np.expand_dims(np.sort(X.flatten()), axis=1) +predicted_rate = pipeline.predict(x) + # %% fig, ax = plt.subplots() @@ -148,10 +156,10 @@ ax.set_xlabel("input") ax.set_ylabel("spike count") -x = np.sort(X.flatten()) + ax.plot( x, - pipeline.predict(np.expand_dims(x, 1)), + predicted_rate, label="predicted rate", color="tab:orange", ) @@ -181,6 +189,18 @@ transformerbasis__n_basis_funcs=(3, 5, 10, 20, 100), ) +# %% +# !!! note "Grid definition" +# In order to define a parameter grid dictionary for a pipeline, you must structure the dictionary keys as follows: +# +# - Start with the pipeline label (`"glm"` or `"transformerbasis"` for us). This determines which pipeline step has the relevant hyperparameter. +# - Add `"__"` followed by the hyperparameter name (for example, `"n_basis_funcs"`). +# - If the hyperparameter is itself an object with attributes, add another `"__"` followed by the attribute name. For instance, `"glm__observation_model__inverse_link_function"` +# would be a valid key for cross-validating over the link function of the GLM's `observation_model` attribute `inverse_link_function`. +# The values in the dictionary are the parameters to be tested. + + + # %% # #### Run the grid search # Let's run a 5-fold cross-validation of the hyperparameters with the scikit-learn `model_selection.GridsearchCV` class. @@ -242,9 +262,15 @@ def highlight_max_cell(cvdf_wide, ax): highlight_max_cell(cvdf_wide, ax) # %% -# #### Visualize the predicted +# #### Visualize the predicted rate # Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: +# %% + +# Predict the ate using the best configuration, +x = np.expand_dims(np.sort(X.flatten()), 1) +predicted_rate = gridsearch.best_estimator_.predict(x) + # %% fig, ax = plt.subplots() @@ -252,10 +278,10 @@ def highlight_max_cell(cvdf_wide, ax): ax.set_xlabel("input") ax.set_ylabel("spike count") -x = np.sort(X.flatten()) + ax.plot( x, - gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), + predicted_rate, label="predicted rate", color="tab:orange", ) @@ -335,6 +361,12 @@ def highlight_max_cell(cvdf_wide, ax): # %% # Looks like `RaisedCosineBasisLinear` was probably a decent choice for our toy data: +# %% + +# Predict the rate using the optimal configuration +x = np.expand_dims(np.sort(X.flatten()), 1) +predicted_rate = gridsearch.best_estimator_.predict(x) + # %% fig, ax = plt.subplots() @@ -342,10 +374,9 @@ def highlight_max_cell(cvdf_wide, ax): ax.set_xlabel("input") ax.set_ylabel("spike count") -x = np.sort(X.flatten()) ax.plot( x, - gridsearch.best_estimator_.predict(np.expand_dims(x, 1)), + predicted_rate, label="predicted rate", color="tab:orange", ) From eb3ce98c15228a70df7c8b5477cbc027640ed6d6 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 31 Jul 2024 11:44:14 -0400 Subject: [PATCH 181/225] fixed capitalization --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 66a00be2..c317f3d2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ -NeMoS (NEural MOdelS) is a statistical modeling framework optimized for systems neuroscience and powered by [JAX](https://jax.readthedocs.io/en/latest/). +NeMoS (Neural ModelS) is a statistical modeling framework optimized for systems neuroscience and powered by [JAX](https://jax.readthedocs.io/en/latest/). It streamlines the process of defining and selecting models, through a collection of easy-to-use methods for feature design. The core of NeMoS includes GPU-accelerated, well-tested implementations of standard statistical models, currently From 05487f0a02281b63f8cf526ad9895442a157d1d2 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 31 Jul 2024 11:45:06 -0400 Subject: [PATCH 182/225] fix capitalization readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 99cfafc4..7c2f50dc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![nemos CI](https://github.com/flatironinstitute/nemos/actions/workflows/ci.yml/badge.svg)](https://github.com/flatironinstitute/nemos/actions/workflows/ci.yml) -NeMoS (NEural MOdelS) is a statistical modeling framework optimized for systems neuroscience and powered by [JAX](https://jax.readthedocs.io/en/latest/). +NeMoS (Neural ModelS) is a statistical modeling framework optimized for systems neuroscience and powered by [JAX](https://jax.readthedocs.io/en/latest/). It streamlines the process of creating and selecting models, through a collection of easy-to-use methods for feature design. The core of NeMoS includes GPU-accelerated, well-tested implementations of standard statistical models, currently From d8f188aa002cb23431b1d2ba78ab849b2005a2c3 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 31 Jul 2024 12:20:28 -0400 Subject: [PATCH 183/225] added schematic of pipeline --- .../plot_06_sklearn_pipeline_cv_demo.py | 5 + docs/assets/pipeline.svg | 272 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/assets/pipeline.svg diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 22a34f04..c2ee5522 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -15,6 +15,11 @@ # %% # ## What is a scikit-learn pipeline # +#

# -# A pipeline is a sequence of data transformations. Each step in the pipeline transforms the input data into a different representation. The final step is either another transformation or a model step that fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. +# A pipeline is a sequence of data transformations leading up to a model. Each step before the final one transforms the input data into a different representation, and then the final model step fits, predicts, or scores based on the previous step's output and some observations. Setting up such machinery can be simplified using the `Pipeline` class from scikit-learn. # # To set up a scikit-learn `Pipeline`, ensure that: # From f22d2225f63fdee99beffcbf365649935a80595d Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:25:57 -0400 Subject: [PATCH 195/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index d8842eaf..1a1deab7 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -25,7 +25,7 @@ # To set up a scikit-learn `Pipeline`, ensure that: # # 1. Each intermediate step is a [scikit-learn transformer object](https://scikit-learn.org/stable/data_transforms.html) with a `transform` and/or `fit_transform` method. -# 2. The final step is either another transformer or an [estimator object](https://scikit-learn.org/stable/developers/develop.html#estimators) with a `fit` method, or a model with `fit`, `predict`, and `score` methods. +# 2. The final step is an [estimator object](https://scikit-learn.org/stable/developers/develop.html#estimators) with a `fit` method, or a model with `fit`, `predict`, and `score` methods. # # Each transformation step takes a 2D array `X` of shape `(num_samples, num_original_features)` as input and outputs another 2D array of shape `(num_samples, num_transformed_features)`. The final step takes a pair `(X, y)`, where `X` is as before, and `y` is a 1D array of shape `(n_samples,)` containing the observations to be modeled. # From d527734e6f44353e36d60148613852dbfad5bd7d Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:38:30 -0400 Subject: [PATCH 196/225] updated quickstart link and note on labeling --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 4 +++- docs/quickstart.md | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index c2ee5522..3772cc49 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -36,7 +36,7 @@ # # Assume transformer_i/predictor is a transformer/model object # pipe = Pipeline( # [ -# ("label_1", transformer_1), +# ("label_1", transformer_1), # ("label_2", transformer_2), # ..., # ("label_n", transformer_n), @@ -45,6 +45,8 @@ # ) # ``` # +# Note that you have to assign a label to each step of the pipeline. Here we used a placeholder `"label_i"` for demonstration; you should choose a more descriptive name depending on the type of transformation step. +# # Calling `pipe.fit(X, y)` will perform the following computations: # ```python # # Chain of transformations diff --git a/docs/quickstart.md b/docs/quickstart.md index fa423b27..35433fd8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -189,4 +189,6 @@ Now we can print the best coefficient. {'regularizer__regularizer_strength': 0.001} ``` +For a more detailed example you can checkout our [tutorial on cross-validation](/generated/api_guide/plot_06_sklearn_pipeline_cv_demo). + Enjoy modeling with NeMoS! From 50134733a4bb3324543cab08e3d3ee4e52c1df0b Mon Sep 17 00:00:00 2001 From: Caitlin Date: Thu, 8 Aug 2024 09:40:26 -0400 Subject: [PATCH 197/225] fix convergence tests --- tests/test_convergence.py | 71 ++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/tests/test_convergence.py b/tests/test_convergence.py index d4765a80..bc0b10f0 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -13,6 +13,8 @@ def test_unregularized_convergence(): Assert that solution found when using GradientDescent vs ProximalGradient with an unregularized GLM is the same. """ + jax.config.update("jax_enable_x64", True) + # generate toy data np.random.seed(111) # random design tensor. Shape (n_time_points, n_features). @@ -30,15 +32,17 @@ def test_unregularized_convergence(): y = np.random.poisson(rate) # instantiate and fit unregularized GLM with GradientDescent - model_GD = nmo.glm.GLM() + model_GD = nmo.glm.GLM(solver_kwargs=dict(tol=10**-12)) model_GD.fit(X, y) # instantiate and fit unregularized GLM with ProximalGradient - model_PG = nmo.glm.GLM(solver_name="ProximalGradient") + model_PG = nmo.glm.GLM( + solver_name="ProximalGradient", solver_kwargs=dict(tol=10**-12) + ) model_PG.fit(X, y) # assert weights are the same - assert np.allclose(np.round(model_GD.coef_, 2), np.round(model_PG.coef_, 2)) + assert np.allclose(model_GD.coef_, model_PG.coef_) def test_ridge_convergence(): @@ -46,6 +50,7 @@ def test_ridge_convergence(): Assert that solution found when using GradientDescent vs ProximalGradient with an ridge GLM is the same. """ + jax.config.update("jax_enable_x64", True) # generate toy data np.random.seed(111) # random design tensor. Shape (n_time_points, n_features). @@ -63,15 +68,19 @@ def test_ridge_convergence(): y = np.random.poisson(rate) # instantiate and fit ridge GLM with GradientDescent - model_GD = nmo.glm.GLM(regularizer="Ridge") + model_GD = nmo.glm.GLM(regularizer="Ridge", solver_kwargs=dict(tol=10**-12)) model_GD.fit(X, y) # instantiate and fit ridge GLM with ProximalGradient - model_PG = nmo.glm.GLM(regularizer="Ridge", solver_name="ProximalGradient") + model_PG = nmo.glm.GLM( + regularizer="Ridge", + solver_name="ProximalGradient", + solver_kwargs=dict(tol=10**-12), + ) model_PG.fit(X, y) # assert weights are the same - assert np.allclose(np.round(model_GD.coef_, 2), np.round(model_PG.coef_, 2)) + assert np.allclose(model_GD.coef_, model_PG.coef_) def test_lasso_convergence(): @@ -79,14 +88,19 @@ def test_lasso_convergence(): Assert that solution found when using ProximalGradient versus Nelder-Mead method using lasso GLM is the same. """ + jax.config.update("jax_enable_x64", True) # generate toy data - num_samples, num_features, num_groups = 1000, 5, 3 + num_samples, num_features, num_groups = 1000, 1, 3 X = np.random.normal(size=(num_samples, num_features)) # design matrix - w = [0, 0.5, 1, 0, -0.5] # define some weights + w = [0.5] # define some weights y = np.random.poisson(np.exp(X.dot(w))) # observed counts # instantiate and fit GLM with ProximalGradient - model_PG = nmo.glm.GLM(regularizer="Lasso", solver_name="ProximalGradient") + model_PG = nmo.glm.GLM( + regularizer="Lasso", + solver_name="ProximalGradient", + solver_kwargs=dict(tol=10**-12), + ) model_PG.regularizer_strength = 0.1 model_PG.fit(X, y) @@ -103,11 +117,12 @@ def test_lasso_convergence(): x, y, ) - res = minimize(penalized_loss, [0] + w, args=(X, y), method="Nelder-Mead") + res = minimize( + penalized_loss, [0] + w, args=(X, y), method="Nelder-Mead", tol=10**-12 + ) - # assert absolute difference between the weights is less than 0.1 - a = np.abs(np.subtract(np.round(res.x[1:], 2), np.round(model_PG.coef_, 2))) < 1e-1 - assert a.all() + # assert weights are the same + assert np.allclose(res.x[1:], model_PG.coef_) def test_group_lasso_convergence(): @@ -115,19 +130,23 @@ def test_group_lasso_convergence(): Assert that solution found when using ProximalGradient versus Nelder-Mead method using group lasso GLM is the same. """ + jax.config.update("jax_enable_x64", True) # generate toy data - num_samples, num_features, num_groups = 1000, 5, 3 + num_samples, num_features, num_groups = 1000, 2, 2 X = np.random.normal(size=(num_samples, num_features)) # design matrix - w = [0, 0.5, 1, 0, -0.5] # define some weights + w = [-0.5, 0.5] # define some weights y = np.random.poisson(np.exp(X.dot(w))) # observed counts mask = np.zeros((num_groups, num_features)) - mask[0] = [1, 0, 0, 1, 0] # Group 0 includes features 0 and 3 - mask[1] = [0, 1, 0, 0, 0] # Group 1 includes features 1 - mask[2] = [0, 0, 1, 0, 1] # Group 2 includes features 2 and 4 + mask[0] = [1, 0] # Group 0 includes features 0 and 3 + mask[1] = [0, 1] # Group 1 includes features 1 # instantiate and fit GLM with ProximalGradient - model_PG = nmo.glm.GLM(regularizer=nmo.regularizer.GroupLasso(mask=mask)) + model_PG = nmo.glm.GLM( + regularizer=nmo.regularizer.GroupLasso(mask=mask), + solver_kwargs=dict(tol=10**-12), + regularizer_strength=0.2, + ) model_PG.fit(X, y) # use the penalized loss function to solve optimization via Nelder-Mead @@ -144,8 +163,14 @@ def test_group_lasso_convergence(): y, ) - res = minimize(penalized_loss, [0] + w, args=(X, y), method="Nelder-Mead") + res = minimize( + penalized_loss, + [0] + w, + args=(X, y), + method="Nelder-Mead", + tol=10**-12, + options=dict(maxiter=350), + ) - # assert absolute difference between the weights is less than 0.5 - a = np.abs(np.subtract(np.round(res.x[1:], 2), np.round(model_PG.coef_, 2))) < 0.5 - assert a.all() + # assert weights are the same + assert np.allclose(res.x[1:], model_PG.coef_) From 75c057db3bdf797f76bae9b874506952bc3f7f9b Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:40:38 -0400 Subject: [PATCH 198/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 1a1deab7..8d5479d6 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -60,7 +60,7 @@ # # ## Why pipelines are useful # -# Pipelines not only streamline and simplify your code but also offer several other advantages. The real power of pipelines becomes evident when combined with the scikit-learn `model_selection` module. This combination allows you to tune hyperparameters at each step of the pipeline in a straightforward manner. +# Pipelines not only streamline and simplify your code but also offer several other advantages. The real power of pipelines becomes evident when combined with the scikit-learn `model_selection` module, which includes cross-validation and similar methods. This combination allows you to tune hyperparameters at each step of the pipeline in a straightforward manner. # # In the following sections, we will showcase this approach with a concrete example: selecting the appropriate basis type and number of bases for a GLM regression in NeMoS. # From 6f102407589188befa28082fa682a8d37c5bb034 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:54:36 -0400 Subject: [PATCH 199/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 8d5479d6..d9ae10d9 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -97,7 +97,7 @@ # ### Converting NeMoS `Basis` to a transformer # In order to use NeMoS `Basis` in a pipeline, we need to convert it into a scikit-learn transformer. This can be achieved through the `TransformerBasis` wrapper class. # -# Instantiating a `TransformerBasis` can be done using the constructor directly or with `Basis.to_transformer()`: +# Instantiating a `TransformerBasis` can be done either using the constructor directly or with `Basis.to_transformer()`: # %% bas = nmo.basis.RaisedCosineBasisLinear(5, mode="conv", window_size=5) From 5693b77c930128673b7930677eb7d64223cd3757 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 09:54:54 -0400 Subject: [PATCH 200/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index d9ae10d9..d2637652 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -101,6 +101,7 @@ # %% bas = nmo.basis.RaisedCosineBasisLinear(5, mode="conv", window_size=5) +# these two ways of creating the TransformerBasis are equivalent trans_bas_a = nmo.basis.TransformerBasis(bas) trans_bas_b = bas.to_transformer() From 073c9357c05334c37ff1bf2d6f60a665ae6df0b4 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Thu, 8 Aug 2024 10:18:14 -0400 Subject: [PATCH 201/225] added a sentence --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 3a45717b..6eb7a621 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -88,6 +88,9 @@ ) y = np.random.poisson(rate).astype(float).flatten() +# %% +# Let's now plot the simulated neuron's tuning curve, which is bimodal, Gaussian-shaped, and has peaks at 0.25 and 0.75. + # %% fig, ax = plt.subplots() ax.scatter(X.flatten(), y, alpha=0.2) @@ -175,7 +178,7 @@ sns.despine(ax=ax) # %% -# There is some room for improvement, so in the next section we'll use cross-validation to tune the parameters of our pipeline. +# The current model captures the bimodal distribution of responses, appropriately picking out the peaks. However, it doesn't do a good job capturing the actual firing rate: the peaks are too low and the valleys are not low enough. This might be because of our choice of basis function and/or regularizer strength, so let's see if tuning those parameters results in a better fit! we could do this manually, but doing this with the sklearn pipeline will make everything much easier! # %% # ### Select the number of basis by cross-validation From 5379f371f71e500fd43a31ac9e20f4a6d015bac9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 8 Aug 2024 10:27:48 -0400 Subject: [PATCH 202/225] added explicit opening figure --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 74863ffd..96d90a03 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -255,6 +255,7 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) +plt.figure() ax = sns.heatmap( cvdf_wide, annot=True, @@ -352,6 +353,7 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) +plt.figure() ax = sns.heatmap( cvdf_wide, annot=True, @@ -461,6 +463,7 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) +plt.figure() ax = sns.heatmap( cvdf_wide, annot=True, From b7e0414ac3851fd14711fc1153bd2625a5efa100 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 8 Aug 2024 10:29:39 -0400 Subject: [PATCH 203/225] improved description --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 96d90a03..2dea9d6f 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -179,7 +179,7 @@ sns.despine(ax=ax) # %% -# The current model captures the bimodal distribution of responses, appropriately picking out the peaks. However, it doesn't do a good job capturing the actual firing rate: the peaks are too low and the valleys are not low enough. This might be because of our choice of basis function and/or regularizer strength, so let's see if tuning those parameters results in a better fit! we could do this manually, but doing this with the sklearn pipeline will make everything much easier! +# The current model captures the bimodal distribution of responses, appropriately picking out the peaks. However, it doesn't do a good job capturing the actual firing rate: the peaks are too low and the valleys are not low enough. This might be because of our choice of basis and/or regularizer strength, so let's see if tuning those parameters results in a better fit! We could do this manually, but doing this with the sklearn pipeline will make everything much easier! # %% # ### Select the number of basis by cross-validation From e2cee2c7fa2c538b68d30f91f9a859b9bc8306a9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 8 Aug 2024 11:21:50 -0400 Subject: [PATCH 204/225] added boilerplate code --- .../plot_06_sklearn_pipeline_cv_demo.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 2dea9d6f..8fa8e106 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -226,6 +226,64 @@ # run the 5-fold cross-validation grid search gridsearch.fit(X, y) +# %% +# !!! note {Manual cross-validation} +# To appreciate how much boiler-plate code we are saving by calling scikit-learn cross-validation, below +# we can see how this cross-validation will look like in a manual loop +# ```python +# from itertools import product +# from copy import deepcopy +# +# regularizer_strength = (0.1, 0.01, 0.001, 1e-6) +# n_basis_funcs = (3, 5, 10, 20, 100) +# +# # define the folds +# n_folds = 5 +# fold_idx = np.arange(X.shape[0] - X.shape[0] % n_folds).reshape(n_folds, -1) +# +# +# # Initialize the scores +# scores = np.zeros((len(regularizer_strength) * len(n_basis_funcs), n_folds)) +# +# # Dictionary to store coefficients +# coeffs = {} +# +# # initialize basis and model +# basis = nmo.basis.TransformerBasis(nmo.basis.RaisedCosineBasisLinear(6)) +# model = nmo.glm.GLM(regularizer="Ridge") +# +# # loop over combinations +# for fold in range(n_folds): +# test_idx = fold_idx[fold] +# train_idx = fold_idx[[x for x in range(n_folds) if x != fold]].flatten() +# for i, params in enumerate(product(regularizer_strength, n_basis_funcs)): +# reg_strength, n_basis = params +# +# # deepcopy the basis and model +# bas = deepcopy(basis) +# glm = deepcopy(model) +# +# # set the parameters +# bas.n_basis_funcs = n_basis +# glm.regularizer_strength = reg_strength +# +# # fit the model +# glm.fit(bas.transform(X[train_idx]), y[train_idx]) +# +# # store score and coefficients +# scores[i, fold] = glm.score(bas.transform(X[test_idx]), y[test_idx]) +# coeffs[(i, fold)] = (glm.coef_, glm.intercept_) +# +# # get the best mean test score +# i_best = np.argmax(scores.mean(axis=1)) +# # get the overall best coeffs +# fold_best = np.argmax(scores[i_best]) +# +# # set up the best model +# model.coef_ = coeffs[(i_best, fold_best)][0] +# model.intercept_ = coeffs[(i_best, fold_best)][0] +# ``` + # %% # #### Visualize the scores # From e281225642c6d0c858314764d4e2aca3df134dea Mon Sep 17 00:00:00 2001 From: Bence Bagi Date: Thu, 8 Aug 2024 14:03:54 -0400 Subject: [PATCH 205/225] Add some tests --- tests/test_basis.py | 61 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 2d1973f2..b33f7c13 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -4355,8 +4355,65 @@ def test_power_of_basis(exponent, basis_class): def test_basis_to_transformer(basis_cls): n_basis_funcs = 5 bas = basis_cls(n_basis_funcs) - assert isinstance(bas.to_transformer(), basis.TransformerBasis) - assert bas.to_transformer().n_basis_funcs == bas.n_basis_funcs + + trans_bas = bas.to_transformer() + + assert isinstance(trans_bas, basis.TransformerBasis) + + # check that things like n_basis_funcs are the same as the original basis + for k in bas.__dict__.keys(): + assert getattr(bas, k) == getattr(trans_bas, k) + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_transformer_has_the_same_public_attributes_as_basis(basis_cls): + n_basis_funcs = 5 + bas = basis_cls(n_basis_funcs) + + public_attrs_basis = {attr for attr in dir(bas) if not attr.startswith("_")} + public_attrs_transformerbasis = { + attr for attr in dir(bas.to_transformer()) if not attr.startswith("_") + } + + assert public_attrs_transformerbasis - public_attrs_basis == { + "fit", + "fit_transform", + "transform", + } + + assert public_attrs_basis - public_attrs_transformerbasis == set() + + +@pytest.mark.parametrize( + "basis_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_to_transformer_and_constructor_are_equivalent(basis_cls): + n_basis_funcs = 5 + bas = basis_cls(n_basis_funcs) + + trans_bas_a = bas.to_transformer() + trans_bas_b = basis.TransformerBasis(bas) + + # they both just have a _basis + assert list(trans_bas_a.__dict__.keys()) == list(trans_bas_b.__dict__.keys()) == ["_basis"] + # and those bases are the same + assert trans_bas_a._basis.__dict__ == trans_bas_b._basis.__dict__ + @pytest.mark.parametrize( "basis_cls", From c5376793a01691bea6c5387eda6cdab201e61695 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 10 Aug 2024 10:50:15 -0400 Subject: [PATCH 206/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 74863ffd..af0002c2 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -416,7 +416,7 @@ def highlight_max_cell(cvdf_wide, ax): # %% # ## Create a custom scorer -# By default, the GLM score method returns the model log-likelihood. If you want to try a different metric, such as the pseudo-R2, you can create a custom scorer that overwrites the default: +# By default, the GLM score method returns the model log-likelihood. If you want to try a different metric, such as the pseudo-R2, you can create a custom scorer and pass it to the cross-validation object: # %% from sklearn.metrics import make_scorer From d944c6d51188ab4216615d27d37a379407a7d9d1 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 10 Aug 2024 10:54:42 -0400 Subject: [PATCH 207/225] addressed comments --- .../plot_06_sklearn_pipeline_cv_demo.py | 117 +++++++----------- docs/api_guide/utils/plotting.py | 38 ++++++ docs/assets/grid_search_cross_validation.png | Bin 0 -> 36718 bytes docs/quickstart.md | 3 + mkdocs.yml | 4 + 5 files changed, 88 insertions(+), 74 deletions(-) create mode 100644 docs/api_guide/utils/plotting.py create mode 100644 docs/assets/grid_search_cross_validation.png diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 8fa8e106..97d9624f 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -45,7 +45,9 @@ # ) # ``` # -# Note that you have to assign a label to each step of the pipeline. Here we used a placeholder `"label_i"` for demonstration; you should choose a more descriptive name depending on the type of transformation step. +# Note that you have to assign a label to each step of the pipeline. +# !!! tip +# Here we used a placeholder `"label_i"` for demonstration; you should choose a more descriptive name depending on the type of transformation step. # # Calling `pipe.fit(X, y)` will perform the following computations: # ```python @@ -79,6 +81,8 @@ from sklearn.pipeline import Pipeline from sklearn.model_selection import GridSearchCV +from utils import plotting + # predictors, shape (n_samples, n_features) X = np.random.uniform(low=0, high=1, size=(1000, 1)) # observed counts, shape (n_samples,) @@ -157,7 +161,7 @@ # Predict the rate. # Note that you need a 2D input even if x is a flat array. # We are using expand dim to add the extra-dimension -x = np.expand_dims(np.sort(X.flatten()), axis=1) +x = np.sort(X, axis=0) predicted_rate = pipeline.predict(x) # %% @@ -215,6 +219,15 @@ # %% # #### Run the grid search # Let's run a 5-fold cross-validation of the hyperparameters with the scikit-learn `model_selection.GridsearchCV` class. +# ??? info "K-Fold cross-validation (click to expand/collapse)" +# +# K-fold cross-validation is a robust method used for selecting hyperparameters. In this procedure, the data is divided into K equally sized chunks (or folds). The model is trained on K-1 of these chunks, with the remaining chunk used for evaluation. This process is repeated K times, with each chunk being used exactly once as the evaluation set. +# After completing the K iterations, the K evaluation scores are averaged to provide a reliable estimate of the model's performance. To select the optimal hyperparameters, K-fold cross-validation can be applied over a grid of potential hyperparameters, with the set yielding the highest average score being chosen as the best. + # %% gridsearch = GridSearchCV( @@ -227,9 +240,11 @@ gridsearch.fit(X, y) # %% -# !!! note {Manual cross-validation} +# +# ??? note "Manual cross-validation (click to expand/collapse)" # To appreciate how much boiler-plate code we are saving by calling scikit-learn cross-validation, below -# we can see how this cross-validation will look like in a manual loop +# we can see how this cross-validation will look like in a manual loop. +# # ```python # from itertools import product # from copy import deepcopy @@ -281,7 +296,11 @@ # # # set up the best model # model.coef_ = coeffs[(i_best, fold_best)][0] -# model.intercept_ = coeffs[(i_best, fold_best)][0] +# model.intercept_ = coeffs[(i_best, fold_best)][1] +# +# # get the best hyperparameters +# best_reg_strength = regularizer_strength[i_best // len(n_basis_funcs)] +# best_n_basis = n_basis_funcs[i_best % len(n_basis_funcs)] # ``` # %% @@ -289,22 +308,7 @@ # # Let's extract the scores from `gridsearch` and take a look at how the different parameter values of our pipeline influence the test score: -# %% -from matplotlib.patches import Rectangle - -def highlight_max_cell(cvdf_wide, ax): - max_col = cvdf_wide.max().idxmax() - max_col_index = cvdf_wide.columns.get_loc(max_col) - max_row = cvdf_wide[max_col].idxmax() - max_row_index = cvdf_wide.index.get_loc(max_row) - ax.add_patch( - Rectangle( - (max_col_index, max_row_index), 1, 1, fill=False, lw=3, color="skyblue" - ) - ) - -# %% cvdf = pd.DataFrame(gridsearch.cv_results_) cvdf_wide = cvdf.pivot( @@ -313,32 +317,18 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) -plt.figure() -ax = sns.heatmap( - cvdf_wide, - annot=True, - square=True, - linecolor="white", - linewidth=0.5, -) - -# Labeling the colorbar -colorbar = ax.collections[0].colorbar -colorbar.set_label('log-likelihood') - -ax.set_xlabel("ridge regularization strength") -ax.set_ylabel("number of basis functions") - -highlight_max_cell(cvdf_wide, ax) +plotting.plot_heatmap_cv_results(cvdf_wide) # %% +# The plot displays the model's log-likelihood for each parameter combination in the grid. The parameter combination with the highest score, which is the one selected by the procedure, is highlighted with a blue rectangle. +# # #### Visualize the predicted rate # Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: # %% # Predict the ate using the best configuration, -x = np.expand_dims(np.sort(X.flatten()), 1) +x = np.sort(X, axis=0) predicted_rate = gridsearch.best_estimator_.predict(x) # %% @@ -360,6 +350,9 @@ def highlight_max_cell(cvdf_wide, ax): sns.despine(ax=ax) # %% +# !!! success +# We are now able to capture the distribution of the firing rate appropriately: both peaks and valleys in the spiking activity are matched by our model predicitons. +# # ### Evaluating different bases directly # # In the previous example we set the number of basis functions of the `Basis` wrapped in our `TransformerBasis`. However, if we are for example not sure about the type of basis functions we want to use, or we have already defined some basis functions of our own, then we can use cross-validation to directly evaluate those as well. @@ -411,31 +404,16 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) -plt.figure() -ax = sns.heatmap( - cvdf_wide, - annot=True, - square=True, - linecolor="white", - linewidth=0.5, -) - -# Labeling the colorbar -colorbar = ax.collections[0].colorbar -colorbar.set_label('log-likelihood') - -ax.set_xlabel("ridge regularization strength") -ax.set_ylabel("number of basis functions") +plotting.plot_heatmap_cv_results(cvdf_wide) -highlight_max_cell(cvdf_wide, ax) # %% -# Looks like `RaisedCosineBasisLinear` was probably a decent choice for our toy data: - +# As shown in the table, the model with the highest score, highlighted in blue, used a RaisedCosineBasisLinear, which appears to be a suitable choice for our toy data. +# We can confirm that by plotting the firing rate predictions: # %% # Predict the rate using the optimal configuration -x = np.expand_dims(np.sort(X.flatten()), 1) +x = np.sort(X, axis=0) predicted_rate = gridsearch.best_estimator_.predict(x) # %% @@ -455,6 +433,9 @@ def highlight_max_cell(cvdf_wide, ax): ax.legend() sns.despine(ax=ax) +# %% +# The plot confirms that the firing rate distribution is accurately captured by our model predictions. + # %% # !!! warning # Please note that because it would lead to unexpected behavior, mixing the two ways of defining values for the parameter grid is not allowed. The following would lead to an error: @@ -489,7 +470,7 @@ def highlight_max_cell(cvdf_wide, ax): ) # %% -# #### Run the grid search providing the custom scorer +# We can now run the grid search providing the custom scorer # %% gridsearch = GridSearchCV( @@ -503,7 +484,7 @@ def highlight_max_cell(cvdf_wide, ax): gridsearch.fit(X, y) # %% -# #### Plot the pseudo-R2 scores +# And finally, we can plot each model's score. # %% # Plot the pseudo-R2 scores @@ -521,20 +502,8 @@ def highlight_max_cell(cvdf_wide, ax): values="mean_test_score", ) -plt.figure() -ax = sns.heatmap( - cvdf_wide, - annot=True, - square=True, - linecolor="white", - linewidth=0.5, -) - -# Labeling the colorbar -colorbar = ax.collections[0].colorbar -colorbar.set_label('pseudo-R2') +plotting.plot_heatmap_cv_results(cvdf_wide, label="pseudo-R2") -ax.set_xlabel("ridge regularization strength") -ax.set_ylabel("number of basis functions") +# %% +# As you can see, with this new metric scores are normalized between 0 and 1, the highest the better. -highlight_max_cell(cvdf_wide, ax) diff --git a/docs/api_guide/utils/plotting.py b/docs/api_guide/utils/plotting.py new file mode 100644 index 00000000..ea43b5db --- /dev/null +++ b/docs/api_guide/utils/plotting.py @@ -0,0 +1,38 @@ +from matplotlib.patches import Rectangle +import matplotlib.pyplot as plt +import seaborn as sns + +def highlight_max_cell(cvdf_wide, ax): + max_col = cvdf_wide.max().idxmax() + max_col_index = cvdf_wide.columns.get_loc(max_col) + max_row = cvdf_wide[max_col].idxmax() + max_row_index = cvdf_wide.index.get_loc(max_row) + + ax.add_patch( + Rectangle( + (max_col_index, max_row_index), 1, 1, fill=False, lw=3, color="skyblue" + ) + ) + + +def plot_heatmap_cv_results(cvdf_wide, label=None): + plt.figure() + ax = sns.heatmap( + cvdf_wide, + annot=True, + square=True, + linecolor="white", + linewidth=0.5, + ) + + # Labeling the colorbar + colorbar = ax.collections[0].colorbar + if not label: + colorbar.set_label('log-likelihood') + else: + colorbar.set_label(label) + + ax.set_xlabel("ridge regularization strength") + ax.set_ylabel("number of basis functions") + + highlight_max_cell(cvdf_wide, ax) \ No newline at end of file diff --git a/docs/assets/grid_search_cross_validation.png b/docs/assets/grid_search_cross_validation.png new file mode 100644 index 0000000000000000000000000000000000000000..a9f840777c9c52005562bed66c1de655d3d23b87 GIT binary patch literal 36718 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 RMfL;qwN0*IE?>X@zW^JIc3A)b literal 0 HcmV?d00001 diff --git a/docs/quickstart.md b/docs/quickstart.md index 35433fd8..79b593b8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -181,6 +181,9 @@ cls = GridSearchCV(model, param_grid=param_grid, cv=5) cls.fit(X, counts) ``` +!!! info "Cross-Validation" + For more information and a practical example on how to construct a parameter grid and cross-validate hyperparameters across an entire pipeline, please refer to the [tutorial on pipelining and cross-validation](../generated/api_guide/plot_06_sklearn_pipeline_cv_demo). + Now we can print the best coefficient. ```python diff --git a/mkdocs.yml b/mkdocs.yml index 1a973efb..c350ff56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,9 @@ theme: # an error when building the docs. markdown_extensions: - footnotes + - pymdownx.superfences + - pymdownx.details # add notes toggleable notes ??? + plugins: - search @@ -51,6 +54,7 @@ plugins: extra_javascript: - javascripts/katex.js + - javascripts/toggle_code.js - https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.7/katex.min.js - https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.7/contrib/auto-render.min.js From 5f52fed678f1d6aa2b859732805638f638268327 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 10 Aug 2024 10:55:52 -0400 Subject: [PATCH 208/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 10752e37..48d31514 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -122,8 +122,8 @@ class TransformerBasis: of the basis functions), transforming data (applying the basis functions to data), and both fitting and transforming in one step. - This could be extremely useful in combination with scikit-learn pipelining and - cross-validation, enabling the cross-validation of the basis type and parameters, + `TransformerBasis`, unlike `Basis`, is compatible with scikit-learn pipelining and + model selection, enabling the cross-validation of the basis type and parameters, for example `n_basis_funcs`. See the example section below. Parameters From 0ac28e75793454a1125d304fb749a70df2db8062 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 10 Aug 2024 10:56:29 -0400 Subject: [PATCH 209/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 48d31514..7b4da396 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -263,7 +263,7 @@ def __setstate__(self, state): """ Define how to populate the object's state when unpickling. - Not that during unpickling a new object is created without calling __init__. + Note that during unpickling a new object is created without calling __init__. Needed to avoid infinite recursion in __getattr__ when unpickling. See https://docs.python.org/3/library/pickle.html#object.__setstate__ From d27f0c6e08d1889a61e144dea1fc202f35d4ec34 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 10 Aug 2024 10:56:43 -0400 Subject: [PATCH 210/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 7b4da396..873f503b 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -345,8 +345,8 @@ def set_params(self, **parameters) -> TransformerBasis: """ Set TransformerBasis parameters. - When used with, sklearn.model_selection, either set the _basis attribute directly, - or set the parameters of the underlying Basis, but doing both at the same time is not allowed. + When used with `sklearn.model_selection`, users can set either the `_basis` attribute directly + or the parameters of the underlying Basis, but not both. Examples -------- From 18c19e5c4d62d12c01429ee4d286acedec53b587 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Sat, 10 Aug 2024 10:56:56 -0400 Subject: [PATCH 211/225] Update src/nemos/basis.py Co-authored-by: William F. Broderick --- src/nemos/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 873f503b..4f5a56d6 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -368,7 +368,7 @@ def set_params(self, **parameters) -> TransformerBasis: self._basis = new_basis if len(parameters) > 0: raise ValueError( - "Set either _basis or parameters for _basis, not both." + "Set either new _basis object or parameters for existing _basis, not both." ) else: self._basis = self._basis.set_params(**parameters) From 91f5e7a81b4097fc67b015804319c1273fb774a9 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 10 Aug 2024 11:01:05 -0400 Subject: [PATCH 212/225] improved example --- src/nemos/basis.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 10752e37..ed401c7a 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -997,6 +997,8 @@ def to_transformer(self) -> TransformerBasis: >>> import nemos as nmo >>> from sklearn.pipeline import Pipeline >>> from sklearn.model_selection import GridSearchCV + >>> # load some data + >>> X, y = ... # X: features, y: neural activity >>> basis = nmo.basis.RaisedCosineBasisLinear(10) >>> glm = nmo.glm.GLM(regularizer="Ridge") >>> pipeline = Pipeline([("basis", basis), ("glm", glm)]) @@ -1008,9 +1010,8 @@ def to_transformer(self) -> TransformerBasis: ... pipeline, ... param_grid=param_grid, ... cv=5, - ... scoring=pseudo_r2, ... ) - >>> gridsearch.fit() + >>> gridsearch.fit(X, y) """ return TransformerBasis(copy.deepcopy(self)) From e175cf6b56056e37569b6fd6f9afb0540a5aae43 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 10 Aug 2024 11:05:43 -0400 Subject: [PATCH 213/225] moved pipeline tests --- tests/test_basis.py | 160 ------------------------------------- tests/test_pipeline.py | 174 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 160 deletions(-) create mode 100644 tests/test_pipeline.py diff --git a/tests/test_basis.py b/tests/test_basis.py index b33f7c13..6f6741fc 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -4699,166 +4699,6 @@ def test_transformerbasis_pickle(tmpdir, basis_cls, n_basis_funcs): assert trans_bas2.n_basis_funcs == n_basis_funcs -@pytest.mark.parametrize( - "bas", - [ - basis.MSplineBasis(5), - basis.BSplineBasis(5), - basis.CyclicBSplineBasis(5), - basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), - basis.RaisedCosineBasisLinear(5), - ] -) -def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): - X, y, model, _, _ = poissonGLM_model_instantiation - bas = basis.TransformerBasis(bas) - pipe = pipeline.Pipeline([("eval", bas), ("fit", model)]) - - pipe.fit(X[:, : bas._basis._n_input_dimensionality] ** 2, y) - - -@pytest.mark.parametrize( - "bas", - [ - basis.MSplineBasis(5), - basis.BSplineBasis(5), - basis.CyclicBSplineBasis(5), - basis.RaisedCosineBasisLinear(5), - basis.RaisedCosineBasisLog(5), - ], -) -def test_sklearn_transformer_pipeline_cv(bas, poissonGLM_model_instantiation): - X, y, model, _, _ = poissonGLM_model_instantiation - bas = basis.TransformerBasis(bas) - pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) - param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) - gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) - gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) - -@pytest.mark.parametrize( - "bas", - [ - basis.MSplineBasis(5), - basis.BSplineBasis(5), - basis.CyclicBSplineBasis(5), - basis.RaisedCosineBasisLinear(5), - basis.RaisedCosineBasisLog(5), - ], -) -def test_sklearn_transformer_pipeline_cv_multiprocess(bas, poissonGLM_model_instantiation): - X, y, model, _, _ = poissonGLM_model_instantiation - bas = basis.TransformerBasis(bas) - pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) - param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) - gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3, n_jobs=3) - gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) - -@pytest.mark.parametrize( - "bas_cls", - [ - basis.MSplineBasis, - basis.BSplineBasis, - basis.CyclicBSplineBasis, - basis.RaisedCosineBasisLinear, - basis.RaisedCosineBasisLog, - ], -) -def test_sklearn_transformer_pipeline_cv_directly_over_basis(bas_cls, poissonGLM_model_instantiation): - X, y, model, _, _ = poissonGLM_model_instantiation - bas = basis.TransformerBasis(bas_cls(5)) - pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) - param_grid = dict( - transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20)) - ) - gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) - gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) - -@pytest.mark.parametrize( - "bas_cls", - [ - basis.MSplineBasis, - basis.BSplineBasis, - basis.CyclicBSplineBasis, - basis.RaisedCosineBasisLinear, - basis.RaisedCosineBasisLog, - ], -) -def test_sklearn_transformer_pipeline_cv_illegal_combination(bas_cls, poissonGLM_model_instantiation): - X, y, model, _, _ = poissonGLM_model_instantiation - bas = basis.TransformerBasis(bas_cls(5)) - pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) - param_grid = dict( - transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20)), - transformerbasis__n_basis_funcs=(3, 5, 10), - ) - gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv = 3) - with pytest.raises( - ValueError, match="Set either _basis or parameters for _basis, not both." - ): - gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) - - -@pytest.mark.parametrize( - "bas, expected_nans", - [ - (basis.MSplineBasis(5), 0), - (basis.BSplineBasis(5), 0), - (basis.CyclicBSplineBasis(5), 0), - (basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), 0), - (basis.RaisedCosineBasisLinear(5), 0), - (basis.RaisedCosineBasisLog(5), 0), - (basis.RaisedCosineBasisLog(5) + basis.MSplineBasis(5), 0), - (basis.MSplineBasis(5, mode="conv", window_size=3), 6), - (basis.BSplineBasis(5, mode="conv", window_size=3), 6), - ( - basis.CyclicBSplineBasis( - 5, mode="conv", window_size=3, predictor_causality="acausal" - ), - 4, - ), - ( - basis.OrthExponentialBasis( - 5, decay_rates=np.linspace(0.1, 1, 5), mode="conv", window_size=7 - ), - 14, - ), - (basis.RaisedCosineBasisLinear(5, mode="conv", window_size=3), 6), - (basis.RaisedCosineBasisLog(5, mode="conv", window_size=3), 6), - ( - basis.RaisedCosineBasisLog(5, mode="conv", window_size=3) - + basis.MSplineBasis(5), - 6, - ), - ( - basis.RaisedCosineBasisLog(5, mode="conv", window_size=3) - * basis.MSplineBasis(5), - 6, - ), - ], -) -def test_sklearn_transformer_pipeline_pynapple( - bas, poissonGLM_model_instantiation, expected_nans -): - X, y, model, _, _ = poissonGLM_model_instantiation - - # transform input to pynapple - ep = nap.IntervalSet(start=[0, 20.5], end=[20, X.shape[0]]) - X_nap = nap.TsdFrame(t=np.arange(X.shape[0]), d=X, time_support=ep) - y_nap = nap.Tsd(t=np.arange(X.shape[0]), d=y, time_support=ep) - bas = basis.TransformerBasis(bas) - # fit a pipeline & predict from pynapple - pipe = pipeline.Pipeline([("eval", bas), ("fit", model)]) - pipe.fit(X_nap[:, : bas._basis._n_input_dimensionality] ** 2, y_nap) - - # get rate - rate = pipe.predict(X_nap[:, : bas._basis._n_input_dimensionality] ** 2) - # check rate is Tsd with same time info - assert isinstance(rate, nap.Tsd) - assert np.all(rate.t == X_nap.t) - assert np.all(rate.time_support == X_nap.time_support) - assert np.sum(np.isnan(rate.d)) == expected_nans - - @pytest.mark.parametrize( "tsd", [ diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 00000000..5b76f902 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,174 @@ +import numpy as np +import pynapple as nap +import pytest +from sklearn import pipeline +from sklearn.model_selection import GridSearchCV + +from nemos import basis + + +@pytest.mark.parametrize( + "bas", + [ + basis.MSplineBasis(5), + basis.BSplineBasis(5), + basis.CyclicBSplineBasis(5), + basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), + basis.RaisedCosineBasisLinear(5), + ], +) +def test_sklearn_transformer_pipeline(bas, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas) + pipe = pipeline.Pipeline([("eval", bas), ("fit", model)]) + + pipe.fit(X[:, : bas._basis._n_input_dimensionality] ** 2, y) + + +@pytest.mark.parametrize( + "bas", + [ + basis.MSplineBasis(5), + basis.BSplineBasis(5), + basis.CyclicBSplineBasis(5), + basis.RaisedCosineBasisLinear(5), + basis.RaisedCosineBasisLog(5), + ], +) +def test_sklearn_transformer_pipeline_cv(bas, poissonGLM_model_instantiation): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas) + pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) + param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv=3) + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + + +@pytest.mark.parametrize( + "bas", + [ + basis.MSplineBasis(5), + basis.BSplineBasis(5), + basis.CyclicBSplineBasis(5), + basis.RaisedCosineBasisLinear(5), + basis.RaisedCosineBasisLog(5), + ], +) +def test_sklearn_transformer_pipeline_cv_multiprocess( + bas, poissonGLM_model_instantiation +): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas) + pipe = pipeline.Pipeline([("basis", bas), ("fit", model)]) + param_grid = dict(basis__n_basis_funcs=(3, 5, 10)) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv=3, n_jobs=3) + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + + +@pytest.mark.parametrize( + "bas_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_sklearn_transformer_pipeline_cv_directly_over_basis( + bas_cls, poissonGLM_model_instantiation +): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas_cls(5)) + pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) + param_grid = dict(transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20))) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv=3) + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + + +@pytest.mark.parametrize( + "bas_cls", + [ + basis.MSplineBasis, + basis.BSplineBasis, + basis.CyclicBSplineBasis, + basis.RaisedCosineBasisLinear, + basis.RaisedCosineBasisLog, + ], +) +def test_sklearn_transformer_pipeline_cv_illegal_combination( + bas_cls, poissonGLM_model_instantiation +): + X, y, model, _, _ = poissonGLM_model_instantiation + bas = basis.TransformerBasis(bas_cls(5)) + pipe = pipeline.Pipeline([("transformerbasis", bas), ("fit", model)]) + param_grid = dict( + transformerbasis___basis=(bas_cls(5), bas_cls(10), bas_cls(20)), + transformerbasis__n_basis_funcs=(3, 5, 10), + ) + gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv=3) + with pytest.raises( + ValueError, match="Set either _basis or parameters for _basis, not both." + ): + gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) + + +@pytest.mark.parametrize( + "bas, expected_nans", + [ + (basis.MSplineBasis(5), 0), + (basis.BSplineBasis(5), 0), + (basis.CyclicBSplineBasis(5), 0), + (basis.OrthExponentialBasis(5, decay_rates=np.arange(1, 6)), 0), + (basis.RaisedCosineBasisLinear(5), 0), + (basis.RaisedCosineBasisLog(5), 0), + (basis.RaisedCosineBasisLog(5) + basis.MSplineBasis(5), 0), + (basis.MSplineBasis(5, mode="conv", window_size=3), 6), + (basis.BSplineBasis(5, mode="conv", window_size=3), 6), + ( + basis.CyclicBSplineBasis( + 5, mode="conv", window_size=3, predictor_causality="acausal" + ), + 4, + ), + ( + basis.OrthExponentialBasis( + 5, decay_rates=np.linspace(0.1, 1, 5), mode="conv", window_size=7 + ), + 14, + ), + (basis.RaisedCosineBasisLinear(5, mode="conv", window_size=3), 6), + (basis.RaisedCosineBasisLog(5, mode="conv", window_size=3), 6), + ( + basis.RaisedCosineBasisLog(5, mode="conv", window_size=3) + + basis.MSplineBasis(5), + 6, + ), + ( + basis.RaisedCosineBasisLog(5, mode="conv", window_size=3) + * basis.MSplineBasis(5), + 6, + ), + ], +) +def test_sklearn_transformer_pipeline_pynapple( + bas, poissonGLM_model_instantiation, expected_nans +): + X, y, model, _, _ = poissonGLM_model_instantiation + + # transform input to pynapple + ep = nap.IntervalSet(start=[0, 20.5], end=[20, X.shape[0]]) + X_nap = nap.TsdFrame(t=np.arange(X.shape[0]), d=X, time_support=ep) + y_nap = nap.Tsd(t=np.arange(X.shape[0]), d=y, time_support=ep) + bas = basis.TransformerBasis(bas) + # fit a pipeline & predict from pynapple + pipe = pipeline.Pipeline([("eval", bas), ("fit", model)]) + pipe.fit(X_nap[:, : bas._basis._n_input_dimensionality] ** 2, y_nap) + + # get rate + rate = pipe.predict(X_nap[:, : bas._basis._n_input_dimensionality] ** 2) + # check rate is Tsd with same time info + assert isinstance(rate, nap.Tsd) + assert np.all(rate.t == X_nap.t) + assert np.all(rate.time_support == X_nap.time_support) + assert np.sum(np.isnan(rate.d)) == expected_nans From 016ea2ebc44dc06d407af563a66ed50cbe342cb8 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Sat, 10 Aug 2024 11:17:23 -0400 Subject: [PATCH 214/225] change error msg --- tests/test_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 5b76f902..38754143 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -108,7 +108,7 @@ def test_sklearn_transformer_pipeline_cv_illegal_combination( ) gridsearch = GridSearchCV(pipe, param_grid=param_grid, cv=3) with pytest.raises( - ValueError, match="Set either _basis or parameters for _basis, not both." + ValueError, match="Set either new _basis object or parameters for existing _basis, not both." ): gridsearch.fit(X[:, : bas._n_input_dimensionality] ** 2, y) From 93767ef0e77c9adbf597080834705a1e2e36efeb Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 12 Aug 2024 10:03:10 -0400 Subject: [PATCH 215/225] fixed prox-operator loss --- src/nemos/regularizer.py | 4 ++-- tests/test_convergence.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index af5bf224..8ac7a300 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -226,7 +226,7 @@ def get_proximal_operator( term is not regularized. """ - def prox_op(params, l2reg, scaling=0.5): + def prox_op(params, l2reg, scaling=1.): Ws, bs = params l2reg /= bs.shape[0] return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs @@ -454,7 +454,7 @@ def _penalization( # divide regularization strength by number of neurons regularizer_strength = regularizer_strength / params[1].shape[0] - return penalty * regularizer_strength + return 0.5 * penalty * regularizer_strength def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """Returns a function for calculating the penalized loss using Group Lasso regularization.""" diff --git a/tests/test_convergence.py b/tests/test_convergence.py index bc0b10f0..7abff79b 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -43,6 +43,7 @@ def test_unregularized_convergence(): # assert weights are the same assert np.allclose(model_GD.coef_, model_PG.coef_) + assert np.allclose(model_GD.intercept_, model_PG.intercept_) def test_ridge_convergence(): @@ -81,6 +82,7 @@ def test_ridge_convergence(): # assert weights are the same assert np.allclose(model_GD.coef_, model_PG.coef_) + assert np.allclose(model_GD.intercept_, model_PG.intercept_) def test_lasso_convergence(): @@ -123,6 +125,7 @@ def test_lasso_convergence(): # assert weights are the same assert np.allclose(res.x[1:], model_PG.coef_) + assert np.allclose(res.x[:1], model_PG.intercept_) def test_group_lasso_convergence(): @@ -174,3 +177,4 @@ def test_group_lasso_convergence(): # assert weights are the same assert np.allclose(res.x[1:], model_PG.coef_) + assert np.allclose(res.x[:1], model_PG.intercept_) From 205c4e8184a91e0e7abebc2e1adb331fd5daf1fe Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 12 Aug 2024 10:25:40 -0400 Subject: [PATCH 216/225] test with groups larger than 1 --- src/nemos/regularizer.py | 4 ++-- tests/test_convergence.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 8ac7a300..9a2f8ab5 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -447,14 +447,14 @@ def _penalization( ) # this masks the param, (group, feature, neuron) penalty = jax.numpy.sum( - jax.numpy.linalg.norm(masked_param, axis=1) + jax.numpy.linalg.norm(masked_param, axis=1).T * jax.numpy.sqrt(self.mask.sum(axis=1)) ) # divide regularization strength by number of neurons regularizer_strength = regularizer_strength / params[1].shape[0] - return 0.5 * penalty * regularizer_strength + return penalty * regularizer_strength def penalized_loss(self, loss: Callable, regularizer_strength: float) -> Callable: """Returns a function for calculating the penalized loss using Group Lasso regularization.""" diff --git a/tests/test_convergence.py b/tests/test_convergence.py index 7abff79b..226caacd 100644 --- a/tests/test_convergence.py +++ b/tests/test_convergence.py @@ -135,19 +135,19 @@ def test_group_lasso_convergence(): """ jax.config.update("jax_enable_x64", True) # generate toy data - num_samples, num_features, num_groups = 1000, 2, 2 + num_samples, num_features, num_groups = 1000, 3, 2 X = np.random.normal(size=(num_samples, num_features)) # design matrix - w = [-0.5, 0.5] # define some weights + w = [-0.5, 0.25, 0.5] # define some weights y = np.random.poisson(np.exp(X.dot(w))) # observed counts mask = np.zeros((num_groups, num_features)) - mask[0] = [1, 0] # Group 0 includes features 0 and 3 - mask[1] = [0, 1] # Group 1 includes features 1 + mask[0] = [1, 1, 0] # Group 0 includes features 0 and 1 + mask[1] = [0, 0, 1] # Group 1 includes features 1 # instantiate and fit GLM with ProximalGradient model_PG = nmo.glm.GLM( regularizer=nmo.regularizer.GroupLasso(mask=mask), - solver_kwargs=dict(tol=10**-12), + solver_kwargs=dict(tol=10**-14, maxiter=10000), regularizer_strength=0.2, ) model_PG.fit(X, y) @@ -172,7 +172,7 @@ def test_group_lasso_convergence(): args=(X, y), method="Nelder-Mead", tol=10**-12, - options=dict(maxiter=350), + options=dict(maxiter=1000), ) # assert weights are the same From 49d8577c21a972a365ae1020a5f7314c8fd1230c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Mon, 12 Aug 2024 10:30:33 -0400 Subject: [PATCH 217/225] linted --- src/nemos/regularizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nemos/regularizer.py b/src/nemos/regularizer.py index 9a2f8ab5..7c9268cf 100644 --- a/src/nemos/regularizer.py +++ b/src/nemos/regularizer.py @@ -226,7 +226,7 @@ def get_proximal_operator( term is not regularized. """ - def prox_op(params, l2reg, scaling=1.): + def prox_op(params, l2reg, scaling=1.0): Ws, bs = params l2reg /= bs.shape[0] return jaxopt.prox.prox_ridge(Ws, l2reg, scaling=scaling), bs From 18cf7394455a61faea5d6bccee4f19977fed4b4b Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Tue, 13 Aug 2024 10:54:45 -0400 Subject: [PATCH 218/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index c7a2b1fa..087f3077 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -505,5 +505,5 @@ plotting.plot_heatmap_cv_results(cvdf_wide, label="pseudo-R2") # %% -# As you can see, with this new metric scores are normalized between 0 and 1, the highest the better. +# As you can see, the results with pseudo-R2 agree with those of the negative log-likelihood. Note that this new metric is normalized between 0 and 1, with a higher score indicating better performance. From 1f5cff0b2648b94bd356767000cbe407c1e5aa24 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Tue, 13 Aug 2024 11:02:00 -0400 Subject: [PATCH 219/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 087f3077..2dff509b 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -408,7 +408,7 @@ # %% -# As shown in the table, the model with the highest score, highlighted in blue, used a RaisedCosineBasisLinear, which appears to be a suitable choice for our toy data. +# As shown in the table, the model with the highest score, highlighted in blue, used a RaisedCosineBasisLinear basis (as used above), which appears to be a suitable choice for our toy data. # We can confirm that by plotting the firing rate predictions: # %% From 01bb2b56982fbffe56bc24d0f44a6b979207ebb7 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Tue, 13 Aug 2024 11:14:22 -0400 Subject: [PATCH 220/225] Update docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py Co-authored-by: William F. Broderick --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 2dff509b..be06b000 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -320,7 +320,7 @@ plotting.plot_heatmap_cv_results(cvdf_wide) # %% -# The plot displays the model's log-likelihood for each parameter combination in the grid. The parameter combination with the highest score, which is the one selected by the procedure, is highlighted with a blue rectangle. +# The plot displays the model's log-likelihood for each parameter combination in the grid. The parameter combination with the highest score, which is the one selected by the procedure, is highlighted with a blue rectangle. We can thus see that we need 10 or more basis functions, and that all of the tested regularization strengths agree with each other. In general, we want the fewest number of basis functions required to get a good fit, so we'll choose 10 here. # # #### Visualize the predicted rate # Finally, visualize the predicted firing rates using the best model found by our grid-search, which gives a better fit than the randomly chosen parameter values we tried in the beginning: From a16bc6dfa4b198c5fb5db354e05b63a6d1dbb280 Mon Sep 17 00:00:00 2001 From: Edoardo Balzani Date: Tue, 13 Aug 2024 11:15:04 -0400 Subject: [PATCH 221/225] Update docs/quickstart.md Co-authored-by: William F. Broderick --- docs/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 79b593b8..eb977530 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -192,6 +192,6 @@ Now we can print the best coefficient. {'regularizer__regularizer_strength': 0.001} ``` -For a more detailed example you can checkout our [tutorial on cross-validation](/generated/api_guide/plot_06_sklearn_pipeline_cv_demo). +For more information, including a practical example on how to construct a parameter grid and cross-validate hyperparameters across an entire pipeline, please refer to the [tutorial on pipelining and cross-validation](../generated/api_guide/plot_06_sklearn_pipeline_cv_demo). Enjoy modeling with NeMoS! From 58b9d99325cbe6c1a0f2a4985a68ddf750f004af Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 13 Aug 2024 11:20:13 -0400 Subject: [PATCH 222/225] added emojis --- docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py | 5 ++--- mkdocs.yml | 3 +++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index 087f3077..a2828eb1 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -350,8 +350,8 @@ sns.despine(ax=ax) # %% -# !!! success -# We are now able to capture the distribution of the firing rate appropriately: both peaks and valleys in the spiking activity are matched by our model predicitons. +# :rocket::rocket::rocket: **Success!** :rocket::rocket::rocket: +# We are now able to capture the distribution of the firing rate appropriately: both peaks and valleys in the spiking activity are matched by our model predicitons. # # ### Evaluating different bases directly # @@ -410,7 +410,6 @@ # %% # As shown in the table, the model with the highest score, highlighted in blue, used a RaisedCosineBasisLinear, which appears to be a suitable choice for our toy data. # We can confirm that by plotting the firing rate predictions: -# %% # Predict the rate using the optimal configuration x = np.sort(X, axis=0) diff --git a/mkdocs.yml b/mkdocs.yml index c350ff56..fbc75ce1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,9 @@ theme: - md_in_html - admonition - tables + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg # if footnotes is defined in theme doesn't work # If md_in_html is defined outside theme, it also results in From 935e911483ad86aae6d67c93fdae8ec775f97069 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Tue, 13 Aug 2024 13:59:22 -0400 Subject: [PATCH 223/225] added cv figure in svg --- .../plot_06_sklearn_pipeline_cv_demo.py | 2 +- docs/assets/grid_search_cross_validation.png | Bin 36718 -> 0 bytes docs/assets/kfold.svg | 882 ++++++++++++++++++ 3 files changed, 883 insertions(+), 1 deletion(-) delete mode 100644 docs/assets/grid_search_cross_validation.png create mode 100644 docs/assets/kfold.svg diff --git a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py index a2828eb1..9eaef8fd 100644 --- a/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py +++ b/docs/api_guide/plot_06_sklearn_pipeline_cv_demo.py @@ -221,7 +221,7 @@ # Let's run a 5-fold cross-validation of the hyperparameters with the scikit-learn `model_selection.GridsearchCV` class. # ??? info "K-Fold cross-validation (click to expand/collapse)" #

-# Grid Search Cross Validation +# Grid Search Cross Validation #
# K-fold cross-validation (modified from scikit-learn docs) #

diff --git a/docs/assets/grid_search_cross_validation.png b/docs/assets/grid_search_cross_validation.png deleted file mode 100644 index a9f840777c9c52005562bed66c1de655d3d23b87..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36718 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 RMfL;qwN0*IE?>X@zW^JIc3A)b diff --git a/docs/assets/kfold.svg b/docs/assets/kfold.svg new file mode 100644 index 00000000..131a30d1 --- /dev/null +++ b/docs/assets/kfold.svg @@ -0,0 +1,882 @@ + + + + + + + + + + + + + + + + + + All Data + Tranining + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + + Fold 1 + + Fold 2 + + Fold 3 + + Fold 4 + + Fold 5 + Test + Test + + + + + + + + + Findingparameters + Finalevaluation + Split 1 + Split 2 + Split 3 + Split 4 + Split 5 + + From 32132b169028081afbe47c36b0a9d3f42fafc192 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 14 Aug 2024 12:00:20 +0200 Subject: [PATCH 224/225] fixed basis docstrings --- src/nemos/basis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nemos/basis.py b/src/nemos/basis.py index 195be610..881ea394 100644 --- a/src/nemos/basis.py +++ b/src/nemos/basis.py @@ -46,6 +46,7 @@ def check_transform_input(func: Callable) -> Callable: when the wrong number of input is provided to __call__. """ + @wraps(func) def wrapper(self: Basis, *xi: ArrayLike, **kwargs) -> NDArray: xi = self._check_transform_input(*xi) return func(self, *xi, **kwargs) # Call the basis From b5f0808a07d75061ab7b18e5df62d09a3bffb1aa Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 14 Aug 2024 12:25:32 +0200 Subject: [PATCH 225/225] updated package version --- pyproject.toml | 2 +- src/nemos/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c8d7c16b..ef1b4096 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nemos" -version = "0.1.5" +version = "0.1.6" authors = [{name = "nemos authors"}] description = "NEural MOdelS, a statistical modeling framework for neuroscience." readme = "README.md" diff --git a/src/nemos/__init__.py b/src/nemos/__init__.py index a6a5a1df..b969bee9 100644 --- a/src/nemos/__init__.py +++ b/src/nemos/__init__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -__version__ = "0.1.5" +__version__ = "0.1.6" from . import ( basis,

+# Grid Search Cross Validation +#
+# K-fold cross-validation (modified from
scikit-learn docs) +#